Skip to content

grund 0.10.1

Choose a tag to compare

@github-actions github-actions released this 24 Aug 10:03
· 2426 commits to main since this release

Added

  • §GRUND-understanding / §GRUND-structure / §GRUND-consistency / §REQ-readme: The why splits into three first-class grounds, the repository gains hard requirements, and the README starts practicing the discipline it sells. The motivation is now three GRUND declarations — the why stays known, the long-term memory stays organized, the structure stays consistent — threaded through the goals, with §GOAL-agent-grounding naming all three. A new REQ kind at docs/requirements/ records what grund must never break — §REQ-backwards-compatibility, §REQ-no-missed-citation, §REQ-no-wrong-citation, §REQ-no-data-loss, §REQ-deterministic-output, §REQ-never-crashes — plus the surface contracts §REQ-readme and §REQ-agents-md. The README's fictional FS-events/src/bus.rs example thread is replaced by grund's own source — the missing-section check cited to FS-check.3.2, the ID left unmarked here so this entry does not join the captured refs demo it describes — with every command output captured from a real run per §REQ-readme.2. Enforcement over prose: README.md and AGENTS.md joined [scan] include, so both root surfaces' citations are checked from now on, and the managed entrypoint block is regenerated for the new kind. No grund_config_version bump and no block-version bump — the config changes are additive (§FS-config.5); the managed block re-renders because [citations.REQ] joins the direction table it carries. Writing the requirements down is what audited them, and four were wrong: REQ-backwards-compatibility declared the project in violation of itself, since the managed block has been bumped seven times and each bump turns green repositories red in the release that ships it — rewritten around silence rather than stasis, separating the verdict guarantee from the byte guarantee and licensing the loud, one-command migration the block mechanism actually is; REQ-deterministic-output claimed output depends only on tree and config, when git ignore state, a config discovery walk that may leave the tree, and the invocation itself under relative_paths = false all feed it; REQ-no-missed-citation promised every citation in every scanned file, when fenced blocks, hidden paths, ignore matches and unlisted extensions are all deliberately unread — reframed so every blind spot must be declared and bounded, which put the fence carve-out into §FS-check.1.1 where a reader looks for it, documented until now only by two cross-references pointing at sections that describe something else; and REQ-no-wrong-citation forbade three findings the spec makes on purpose. REQ-no-data-loss was factually wrong in the other direction — init --force overwrites the --docs scaffold stubs, so it would eat this repository's own docs/goals.md, and §FS-init.3 said "left unchanged" without qualifying the flag. Each requirement is now threaded into the most-specific point that enforces it and given a meter in §AR-goal-measurement.3, because an unmeasured requirement is a wish. Five findings needed code decisions rather than wording and are filed as issues #95 (an invalid ID exits 1 from show and 2 from refs), #96 (a symlinked file is skipped silently, hiding its dangling citations and falsely flagging what it cites), #97 (two headings claiming one section path resolve to a pick), and #98 (blind spots to declare, unreachable comment prefixes, per-language proof gaps). PR #94

  • §FS-init.1.2 / §FS-init.4: grund init now decides whether a target may be scaffolded at all before it writes anything. The home directory is refused unconditionally, no flag lifting it; a target that no .git, .hg, .jj, or .svn marker covers, in it or any ancestor, is refused unless the new --no-vcs says otherwise. A refusal is total — nothing is created, appended, or updated, not even the files that would have been unobjectionable — the run exits 2, and --dry-run reports the refusal rather than a preview, because a preview of a run that would be refused is that refusal. --no-vcs is deliberately not --force: --force decides whether files init owns get overwritten (§FS-init.3), this one says this really is where I want a project, and a run may need either, both, or neither. The reported accident was a shell slip into $HOME followed by grund init --claude, which appended the 56-line managed block to a personal ~/.claude/CLAUDE.md — the global instruction file every agent session in every project loads — turning a three-line file into a 59-line one, unprompted and recoverable only because the block is delimited and the user happened to look. Nothing in the spec was violated when that happened, which is the part worth recording: every guarantee in §FS-init.3 is scoped inside a file — user-authored content preserved, line endings preserved, the config never overwritten, re-running a true no-op — and the entire contract on <path> was that it exists and is a directory. The list answered what will init not touch in a file it was pointed at and never should it have been pointed here, so $HOME was a valid target by construction. Appending made it worse rather than better: appending to an existing entrypoint instead of inventing a competing AGENTS.md is the conservative branch in this spec's own value system, the first bullet of §3, so the one behavior that did the damage is the one every review read as the polite option. The collision underneath is that two commands know ~/.claude/CLAUDE.md and did not know about each other. §FS-integrations.4.3 already models it correctly — a machine-global instruction file, gated on ~/.claude/ existing, receiving a deliberately small marked rendering block — while init modelled the same path as purely <path>-relative, so the same signal, .claude/ exists, reads as "this machine runs Claude, sync the rendering block" to one command and "this project uses Claude, scaffold the project block" to the other. Both readings are right in their own scope; in $HOME they name one file, and nothing arbitrated. A second, independent rule therefore refuses any planned entrypoint that is one of that table's file-backed user-global targets, checked per path rather than per target — every path the run would write, the canonical AGENTS.md included. That rule is not the home rule restated, and the arithmetic that says it is is the same slip that produced the accident: it holds <path> at $HOME and asks which of the two tables' paths coincide there, but <path> is arbitrary, and <path>/AGENTS.md with <path> at ~/.codex, ~/.config/zed, or ~/.pi/agent is the machine-global file, as is <path>/GEMINI.md under ~/.gemini. The home rule says nothing about those targets — they are not the home directory — and on a machine whose dotfiles are a repository, which is the usual state of a machine that has those directories at all, neither does the version-control rule. Four of the five reachable rows are the canonical entrypoint, which is both the one init reaches for by default and the one file it overwrites rather than appends to under --force (§FS-init.3) — the same accident with nothing left behind to hand-remove — and the plan carries it as a flag rather than as a path, so a check handed the entrypoint list could not see it. The division is the one §FS-integrations.4.3 already states — the user-global files carry machine-wide policy and are grund integrations --write's to manage, the repository entrypoint carries this project's syntax and is init's. The version-control rule is the only one with a legitimate other side (a directory scaffolded before git init, a project under no VCS at all), which is why it alone gets a flag; presence is tested rather than type, since a linked worktree and a submodule both write .git as a file, and nothing is parsed and no command is run, so §FS-non-goals.6 is untouched — a marker's existence is a fact about the tree in the way a grund.toml's is. These are refusals, not prompts: init still never asks a question (§FS-non-goals.10), it declines and names the flag that would have let the run through, which is the only shape a guard can take in a non-interactive command and is why the unconditional rules carry no flag at all. No existing test could have caught this, and that is the last part of the record: workdir() in the CLI init suite builds a bare directory under target/init-tests/ and test_root() in the core suite builds one under the system temp root — no marker, no manifest, nothing — which is precisely the "shows no sign of being a project" state, and it was the assumed-good state of every init fixture in the repository. The condition the guard exists to refuse was the condition the whole suite ran in. The five core fixtures that scaffold into a bare temp root now say no_vcs: true rather than growing a fake .git, because faking the marker is what made the hole invisible in the first place, and the new cases build their targets outside this repository's own git tree — the one place the rule can actually fire. <path> interpretation moved with the rules into crates/grund-core/src/init_target.rs: what the target is, and whether it may be scaffolded, decided in one place. InitOpts gains a public no_vcs field: a documented, additive break in the library surface (§FS-distribution.3.1) for downstream code that constructs it with an exhaustive struct literal rather than from Default. Resolves issue #88. PR #91

Changed

  • §AR-ci.9 / §AR-ci.1: the fissile pin moves to 0.7.0 and the managed agent block goes v3 with it. fissile 0.7.0 adds explicit delimiters around its managed block and trims the generated guidance to the check that gates the work; the block here was still v2, so an agent working in this repository was given instructions that the new tool no longer owns. The CI pin moves in the same change: .pre-commit-config.yaml calls the installed binary, so the pin in ci.yml is the only thing deciding which version enforces the budgets remotely. No budget, registry, or exception changes: fissile check and the stale-exception audit are clean under 0.7.0. PR #99

Fixed

  • §REQ-no-missed-citation / §FS-check.1.1 / §FS-config.3.5: the scanner's declared blind spots now match its executable boundary (issue #98). Markdown fences use one shared opener/closer state across scanning, formatting, on-type edits, and show: only the same delimiter character with a run at least as long closes a fence, indentation is bounded to three spaces, and a backtick info string cannot carry a backtick. The default extension list now reaches the advertised ; and -- comment forms for Lisp/Scheme/Clojure and SQL/Haskell/Lua/Ada rather than shipping inert prefixes. Every advertised doc-comment form has an exact marked-dangling failure under strict = true, while the former bare-token Rust, Go, and Python success cases now prove marked citations resolve in strict mode, including both Python quote styles. Finally, this repository explicitly scans .github/workflows with yml / yaml, grounds every workflow, and exercises the hidden include root in ordinary and --full runs. The newly read source extensions had no prior scan meaning, so enabling them is the additive case covered by §REQ-backwards-compatibility.4, while configured repositories retain the same prefix behavior. PR #124

  • §FS-fmt.3: fmt --write with no <path> and fmt --write . now refuse alike on a tree holding an unreadable path (issue #105). --cross-refs — which --write turns on by itself wherever the scope holds Markdown (§6.6) — needs the whole declaration set before it rewrites anything, and an unreadable path is meant to be fatal up front: resolving a link or a shorthand against a partial set can name the wrong declaration, the #81 failure mode a later check run can never see. That guard lived only on the fresh-scan path (fmt_findings_or_abort). The no-<path> invocation instead reused the whole-project Findings load_workspace_context had already computed for the current directory — borrowed off the same struct as the scan's own scan_errors, and without ever reading that field — so on a tree with a broken symlink, fmt --write . correctly aborted with nothing was rewritten while fmt --write on the identical tree wrote through the same partial set and printed the same error a beat late, after the rewrite. usable_findings closes the gap: a project's precomputed findings are offered to fmt_tree only when its scan_errors is empty, in both the workspace-root walk — one project's error no longer taints the findings it hands the rest — and the single-project reuse path, so a caller that skips the fresh scan inherits the completeness check the fresh scan would have made rather than assuming what it reused already passed one. The same reuse pattern, and the same fix, existed twice: in crates/grund-core/src/api.rs's format_references, which backs the shipped CLI, and in the deprecated grund_core::main_entry() compat surface (fmt_cmd.rs) still linked by pre-0.5 consumers. crates/grund-cli/tests/fmt_scope_equivalence.rs pins both invocation forms directly against the built binary with a controlled working directory, since the e2e harness always passes an explicit path argument and cannot exercise the no-<path> form on its own. No verdict moves on a tree with no scan error — the fast reuse path is unchanged there — so §REQ-backwards-compatibility.1 holds. PR #123

  • §AR-scanner.2.6: a qualified shorthand is one citation, not two (PR #114). Outside workspace mode, <§>api/FS-042 is matched by the loose qualified fallback (§FS-workspace.5) and then again by the number-only shorthand pattern (§FS-check.1.2). The shorthand pass skips a marker the full-ID pass claimed, but neither qualified pass records what it claimed, and the shorthand pass only declined a qualified token when a workspace was loaded — so one token in the file became two identical Citations. grund check printed its diagnostic twice; nothing else showed it, because every consumer that would have dropped a qualified citation on the floor. That is no longer true of grund cover, whose index now counts them, so the duplicate became a duplicated row — in the surface §RM-cochange-gate is specified to consume and would have double-counted. A qualified marker a qualified pass claimed now belongs to that pass alone — the same record-keeping rule the shorthand pass already applied to the full-ID pass, extended to the two qualified ones. The workspace pass claims every <§><alias>/... on the line, so in workspace mode the skip is unconditional, as it always was; outside it the loose fallback records each marker it actually parsed, and only those are skipped. Skipping every qualified marker instead would have been the larger defect: the fallback reads the tail as KIND[-NUM]-SLUG with an uppercase, -/_-separated kind, so under an [id] format it cannot read — {kind}{number}-{slug}, a lowercase kind, {kind}:{number}:{slug} — the shorthand pass is the only producer, and a blanket skip deleted the citation outright, printing the “nothing recognized” caution over a file that does contain one (§REQ-no-missed-citation.1). The local shorthand beside a qualified one is untouched, since the skip is about the namespace and not about shorthands. The two shapes this affects are a member-local run citing a sibling — the ordinary <§>root/FS-001 the fallback exists for — and a standalone project carrying a stray <§>alias/ID. Both were already reported; they are now reported once. No tree's verdict moves, in either direction, so §REQ-backwards-compatibility.1 is satisfied without appeal to which direction a move would have been: a run that failed before still fails, with the same findings, and no run that passed starts failing. The text of every finding is unchanged too — what disappears is a byte-identical second copy of a finding still reported (§FS-errors.3).

  • §FS-cover.2 / §FS-workspace.8.6 / §DF-cover-workspace-scope: grund cover indexes every project the run loaded, and counts a cross-project citation toward the file that wrote it (PR #114, issue #104, issue #69). Two narrowings, one command, and both printed as absence. cover scanned the discovered config's own project and stopped, so at a workspace root every member's files were missing from the index and the members' scan errors went with them — the run answered 0 over a tree it had never read. And every <§><alias>/<ID> was dropped at the consumer end, so a file whose citations are all qualified — the normal shape for a grouping node's docs under nested workspaces (§FS-workspace.6.1) — reported (no citations), which reads as "ungrounded" for a file that is fully grounded. This is the one command where the omission cannot be seen from the output: "this file cites nothing" and "this file was never read" are the same absence, which is exactly the silent skip §REQ-no-missed-citation.1 exists to forbid. cover now loads through load_workspace_context, the loader show, refs, list, completions, and fmt --cross-refs already share, so its scope is §FS-workspace.5's and not its own: root plus members at a workspace root, that member alone under a member path. The aggregate is what a run at the workspace root answers: a <path> narrower than the config root is still one narrowed scan of the enclosing project, no workspace loaded, the line §FS-check.1.3 already draws for grund check <dir> — cover is the only command in §FS-workspace.8 whose path bounds a walk rather than choosing which config answers, and an explicit path bypasses [scan] include, so widening it would discard the files the narrowing was for. It applies no filter at all — the index is keyed by file, and the fact a file leans on hardware/FS-nozzle belongs to the file, not to whichever namespace declares the target. The old rationale — that a qualified citation would "distort the per-file local citation map" — read the index as a per-project inventory of local IDs, which it has never been. A qualified ID renders under the target project's [id] config, matching refs (§FS-workspace.8.2), and carries the alias the citation was written with and none of its own: qualifying a local <§>FS-login on the file's behalf would report something other than what the file wrote. --format json gains "project":"<alias>" on the per-file object and on each nested citation object in workspace mode only, so a single-project repository's bytes are unchanged field for field, and a recipe that maps a changed path back to the project whose config governs it no longer has to re-implement member expansion. Paths and scan errors render from the workspace root, so a member's file is spelled the way [workspace] members spells it and joins against the base git diff reports. Verdicts move 0 → 2 in workspaces, never elsewhere: a member's unreadable file now fails the run at the root, and so does a [workspace] block whose members cannot be expanded — a duplicate or invalid alias, a missing member, a nested block with nothing in scope. Reading the members is what expanding them takes, and cover was not reading them, so it answered 0 over the root project while grund list on the same tree already exited 2; that is the same silence one layer earlier. Both are the code check, list, and refs already return for that same tree — the change makes two grund surfaces agree rather than making them differ (§FS-non-goals.13). The licence is the requirement's own lead — "a verdict may move, but only where the release says it will and the finding says what to do" — and both hold: this entry is the release saying so, and every finding that replaces the old 0 names a location and a fix, a colliding members pair or an unreadable path. It is not §1's enumeration (the deprecation path and the loud mechanical migration, neither of which applies) and not §4 either (its two items are a construct with no defined meaning that produced no output, and the pre-1.0 rename licence; the old run produced output and exit 0 had a defined meaning — the answer was simply false). That §1's enumeration is narrower than the lead it elaborates, with no slot for a verdict another hard requirement forbade, is recorded in §DF-cover-workspace-scope.4 for whoever edits the requirement next. Rejected on the way: an --all-projects flag over today's default. Nobody passes a flag to fix an answer they do not know is wrong, and §FS-workspace.8 already closed that question for every other command — the alias is the scope handle, and cover takes no ID, so its invocation path is the only handle it has. The one downstream item this unblocks is the reason it was worth doing now: §RM-cochange-gate is specified to consume grund cover, and built on the old behavior it would have been blind to every member of every workspace — precisely the multi-project repositories a co-change rule is for. Two smaller things the same pass caught: cover answers a bad --format value before it scans, so which of two errors a caller sees no longer depends on whether the tree they pointed at also fails to load; and it renders the index once rather than building the text view and the JSON view from two separate walks of every project. list's reference counts keep their own filter: list counts citations of a declaration, so a <§>api/FS-login belongs to api's row and nobody else's, which is a different question answered correctly today.

  • §FS-check.4.5 / §DF-nothing-recognized: a run that read files and recognized nothing in them stops reporting success (PR #113, issue #79). A docs tree whose headings are written for a different [id] format than the one configured — # FS-login: … under the default {kind}-{number}-{slug} — declares nothing and cites nothing, so nothing dangled, the empty-scan caution (§FS-check.2.2) did not fire because files were read, and the verdict over a repository where nothing was grounded was one word. Every other way of getting the config wrong is loud; this was the one a first-time adopter is most likely to hit. A whole-project walk that matches no declaration and no citation now names the file count, the declaration and citation shapes reduced from the configured template, and the [[kinds]] prefixes. It is a warning — the exit code is untouched, since a tree with nothing in it yet is the ordinary first day of a repository, and an error would flip the verdict of every repository in that state on upgrade (§REQ-backwards-compatibility.2); what it takes away is the success marker, which any warning stands in place of. It asks recognized, not declared, so a workspace member whose whole job is to cite another project stays silent, and it is asked only of a run scoped to the project root, so a narrowed grund check <dir> makes no claim about a slice the caller chose. The LSP reports it as the same diagnostic (§FS-lsp.4); a --full run reports it beside the out-of-scope tier (§FS-check.3.14) rather than instead of it, since the tier says where the citations really are and the caution says the configured scope has not been told. The per-heading half — naming each heading that looks like a declaration and does not match — remains §RM-declaration-near-miss.

  • §FS-inline-citation-style.2.3 / §DF-note-columns-are-characters: inline_note_max_columns counts characters, not bytes (PR #112, issue #84). The key says columns, the finding says inline note exceeds N-column maximum, and the sentence grund init renders into the managed block says ≤ N columns — but the rule measured the UTF-8 byte length of the site's longest line, so the effective cap fell to roughly 85 in prose carrying —, é, ×, or the § marker itself. Two notes of identical length were judged differently for their alphabet alone: at inline_note_max_columns = 100, a 90-character ASCII line (91 bytes) passed while the same 90-character line written with accented padding (157 bytes) failed. In a real tree every one of the 23 findings this produced sat on a note comfortably under the configured 100, so the fix an author reached for was shortening prose that was never too long. The old bullet argued against its own rule: it justified the byte measure as matching "what an editor's column indicator shows in a file" — a correct goal, reached for by measuring the one quantity no editor's indicator reports for UTF-8 text. A column is now one Unicode scalar value: é, —, and § cost one apiece, and a tab still costs one. Display width stays rejected for the reason the old bullet gave — it would make the cap depend on a font, a width table, and a tabstop setting, letting two installs disagree about one file (§FS-non-goals.13) — so only the byte reading is retired. The byte-addressed start column the scanner records on each citation (AR-scanner.3) is deliberately unchanged and the spec now says the two are different measures rather than the same one: that position addresses a place in a file for an editor to jump to, and re-basing it would move refs --format json, the LSP ranges, and the report ordering to fix a defect in none of them. No tree that passes can start failing: a UTF-8 line's character count never exceeds its byte length, so every site's measured width either falls or holds and the finding fires on a strict subset of the sites it fired on before — which is why this ships without the deprecation window of §REQ-backwards-compatibility.2 and without the loud-migration licence of §REQ-backwards-compatibility.3; that requirement guards against a green repository turning red behind the maintainer's back, and a rule that only ever stops rejecting sits outside it. A project that wants the old effective limit can now set a smaller number, which it could not do before, because the old limit was not a number it could name. Rejected on the way: renaming the key to inline_note_max_bytes and leaving the arithmetic alone — honest, but it spends a config-schema change (§FS-config.5) to relocate the surprise from "why did this fail?" to "why is my limit in a unit I can't count?", and the name was never the part that was wrong. No grund_config_version bump and no AGENTS.md block bump: the key, its default, the finding text, and the rendered sentence are all byte-identical.

  • §FS-errors.3 / §FS-check.3.3 / §FS-show.2.2.1 / §FS-workspace.8.1: a path named inside a message is spelled from the same root as the line it sits on (PR #111, issue #109). The printer renders a finding's own <path> against the root the run reports from; three messages rendered the paths inside their text against the member the finding came out of instead. In a workspace the result was one line with two halves relative to two different roots — packages/api/docs/functional-spec/FS-001-api.md:5: duplicate declaration of FS-001-api (also declared at docs/functional-spec/FS-001-api.md:9) — where only the anchor names a file that exists from where the reader is standing, and an editor following the second half opens nothing. §FS-check.3.3's (also declared at …), §FS-show.2.2.1's ambiguous ID: site list, and both of show's broken stub: refusals now render from the report-path config that PR #108 already threaded to every one of these call sites. A stub's link target is unchanged: that text is the user's own link quoted back, not a path grund resolved. §FS-errors.3 gains the rule the code was missing — one base for every path in the line, and the resolved-versus-quoted distinction that decides which paths it governs — so the next message to name a second site has a contract to be written against rather than a convention to be rediscovered. Single-project runs are byte-identical: outside a workspace the two configs are the same config.

  • §FS-check.3.16 / §FS-show.2.2.2 / §AR-scanner.2.2 / §DF-duplicate-section-path: two headings claiming one dotted section path are no longer accepted in silence (PR #108, issue #97). A section path is a citation target, so ## 1. Inputs and ## 1. Outputs under one declaration gave §<ID>.1 two destinations with nothing reported — the case §REQ-no-wrong-citation.1 names in as many words, and the twin one level down of the duplicate-declaration error §FS-check.3.3 has always raised. The two readers of the section map disagreed, and the one a user sees did worse than pick. The scanner recorded the path last-wins, so the map held the second heading; show never read that line — it re-scanned the file and matched the path itself, with no guard against matching twice, so grund <ID>.1 returned both headings and both bodies as one slice, a body no heading in the file spans. That is a wrong resolution reported green, not an ambiguous one. The collision is now a duplicate-section error in §FS-check.3.3's shape — anchored at the first heading, naming every other, carrying the multi-site sites list — scoped to one declaration and independent of [id] section_heading_levels, "loose" included. show refuses the ambiguous coordinate exactly as §FS-show.2.2.1 already refuses an ambiguous ID, while --toc over the whole declaration still lists both heading lines: it maps what is written, and seeing the collision is the point. The section map now records a path once, by the first heading — the one a reader scrolling to section 1 meets and the one the finding anchors at — with later claimants kept in a parallel list that is inert to every lookup. The verdict change is owned rather than excused: §REQ-backwards-compatibility.4 does not cover it — that clause wants a construct which produced no output, and this one printed a body — so the case rests on §REQ-backwards-compatibility.3 instead, where a verdict may flip in the release that introduces it: the finding names every colliding line, renumbering one heading clears it, and this entry is the release saying so. What a tree loses is a green run over output that was wrong. No grund_config_version bump and no AGENTS.md block bump.

    Round two, same PR: the two readers were still two readers. show re-detected the collision while re-scanning the file for the body instead of reading what the scan recorded, and its detector was weaker than the scan's — no fenced-block tracking and no heading-level gate — so in this repository, whose documents are made of Markdown examples, grund check . said success while grund DF-duplicate-section-path.1 refused a coordinate that this very changelog cites. The refusal now comes from duplicate_sections before the body is read (resolving through a stub to the inline home, so a stub's prose is never mistaken for the home's sections), which makes show refuse exactly the coordinates check reports — pinned by a test that asserts the equivalence over a tree holding every shape that has broken it. Two green→red false positives went with it: a ## 1. in the next item's doc-comment, and a stub's own prose, were both recorded against a declaration whose body (§FS-show.2.1, §FS-show.2.3.1) ends before them; the scan now narrows the duplicate list to the declaration's own body span, so the rule reports only headings grund <ID>.<path> could actually reach. §FS-show.2.5 is new and states the fence rule for reads the way §FS-check.1.1 states it for citations: a heading inside a fence is content, so it no longer cuts a section body short or appears in a --toc map. The query failure carries its own ambiguous-section code rather than reusing the ambiguous-ID ambiguous (§FS-distribution.3.0, which also gains the duplicate-section value it was missing) — the two need different edits, and the check side already spells that difference duplicate versus duplicate-section. Both new messages now render their paths against the report-path config, so in a workspace the "also declared at" and the refusal's sites are spelled from the workspace root like the anchor beside them. PR #108

  • §FS-config.3.5 / §AR-scanner.1 / §DF-symlink-scan: a symlinked file inside the scanned tree is no longer dropped from the walk in silence. The walker was built without follow_links, so a symlink entry was neither a file nor a directory to it and fell through with nothing reported. One skip produced two wrong answers at once: the citations in the linked file were never read, so a dangling one passed, and every declaration that file cited was reported declared but never cited because the edge that would have retired the warning went with it. Replacing the link with a copy of the same bytes changed the verdict from clean to two findings — a false negative that also lies about the tree it read, breaking §REQ-no-missed-citation.1 and §REQ-no-wrong-citation.2 in the same run. The spec had discussed symlinks only as [scan] include roots, so a symlinked descendant was not the bounded, declared blind spot §REQ-no-missed-citation.2 permits — it was one nobody had written down, and therefore one no reader could plan around. Links are now followed, files and directories alike, including a target that resolves outside the config root: the file is in the tree by the path the repository wrote, and refusing it by where its target lands would leave the false-alarm half of the bug standing for the case a user is most likely to have written deliberately. Everything is reported at the in-tree link path, never the target's — a target path may be absolute and may sit outside the root, so a finding wearing it is one nobody can jump to and one relative_paths cannot render, and naming the link is also what keeps --full purely additive (§FS-check.1.3), whose promise is that the in-scope lines are the plain run's spelling included. The directory rules keep applying to a followed directory under its link name, so docs/node_modules -> ../../node_modules is excluded exactly as a real directory of that name would be. Following links means a descendant can alias and not just a root, so the identity pass --full already ran for an aliased root now also runs when the walk met a symlink — and only then: a realpath per file on every run of every repository is not a price the ordinary walk should pay (§GOAL-fast-feedback). That made which of two spellings survives a question the filesystem used to answer, so each root's own file list is sorted before it joins the others and the rule is written down: the earlier root wins, and within one root the lexicographically first path (§FS-errors.4). follow_links also turns a link the walker cannot resolve into an error entry in place of the file, and the old entry? would have made one broken link abort the whole scan — the opposite of what §FS-check.2 promises about a file the walk cannot read. Those are collected as per-file scan failures instead: reported error: <path>: <reason> at the link's own path, the walk continuing, the run exiting 2. The report is owed only where the walk would otherwise have read through the link, which is two different tests because a loop and a broken link carry different evidence: a loop is a directory the walker was descending into, so it always earns one — with the hidden-name and [scan] exclude tests re-applied by hand, since the walker's own directory filter never sees an error entry — while a broken link is judged by the same [scan] extensions and ignore-file rules as any other file, so a dangling docs/logo.png -> nowhere, or one .gitignore covers, stays silent, having never been going to be read. The ignore half needs its own answer, because the walker applies those files to the entries it yields and a link it cannot resolve arrives as an error instead: the link's own directory is re-walked one level deep, without following links, where the link is an ordinary entry and the rules do reach it. Without that gate a repository full of links to build outputs turns red for reasons that have nothing to do with citations, and a mode that cries wolf about links is one whose scan errors get ignored. The two boundaries the walk must not cross are asked by identity, not by name. A workspace member root (§AR-workspace.6) is pruned by comparing an entry's path relative to the scan root against precomputed member suffixes, and a member reached under a link name matches none of them — so docs/link -> ../sub, or docs/link -> ../packages with the member one level below it, walked the root scan straight into the member namespace, absorbed its declarations, and reported them as duplicates of themselves. The E2E case directory test had the same shape one compare down: it asks whether a directory's parent is the cases root, so a link onto that root scanned every fixture repo the manifest pass owns. A boundary is a property of the directory, not of the name it is reached under, so a directory the walk reached through a link is resolved with canonicalize and asked again — under both names, which is what keeps a case directory that is itself a link pruned. Only link-reached directories pay the syscall: the walk records each directory link it meets and a prefix test answers for the whole subtree. The identity pass is armed by a list, not a flag. The first cut raised a flag on the first symlink it saw, and the pass canonicalizes every file in the list — so one link anywhere bought a realpath per file, measured at ~2.7x on a 20 000-file tree with a single link in it, against a doc comment in the same file saying that was not a price the ordinary walk should pay. What the walk records instead is which files can wear a second name — a link, anything below a directory link, every file of an aliased root — and only those are resolved; the paths they resolve to are the only ones another file can turn out to be, so everything else is answered by a lookup and never resolved at all (measured against main on a 20 000-file source tree, and on two machines, which is why these are ranges rather than decimals: with no symlink in it 1.02–1.08x plain and 1.08–1.10x --full, which is what follow_links costs; with a single symlink 1.17–1.32x plain and 1.13–1.38x --full, the identity pass costing a hash lookup per file once anything in the tree can wear a second name; a tree of 2 000 symlinks pays 2 000 realpath calls and lands at 1.66–1.69x plain and 1.48–1.74x --full, which is the cost of the links and not of the repository). Two verdict moves are worth naming, both correct and both new (§REQ-backwards-compatibility.1): the identity pass is no longer conditioned on --full, so include = ["docs", "docslink"] with docslink -> docs stops reporting duplicate declaration and exits 0 instead of 1 — the duplicate was always false, and --full was merely where two roots were known to overlap; and where one file has two in-tree names, findings are reported at whichever spelling the rule selects — the earlier root, the lexicographically first path within it — which can be the link rather than the real file the plain run used to name. grund fmt was the one command that walked this tree and said nothing about what it could not read. It took a non-reporting wrapper justified by having "nowhere to put a scan error", while §FS-fmt.3 has defined exit 2 as the I/O failure all along: on a tree where check, refs and list each printed two error: lines and exited 2, fmt --check printed the rewrite it would make and exited 1. It now names every unreadable path in the §FS-errors.2.2 shape after its report and exits 2, with FmtOutput carrying the same list so an embedder sees the same incompleteness — an additive field on that struct (§FS-distribution.3.1). A run that needs the whole declaration set before it rewrites anything — --cross-refs, or a shorthand to expand — keeps its earlier refusal, now written down: an unreadable path is fatal up front there, because wrapping or expanding against a partial set can name the wrong declaration. fmt --write no longer writes through a link that leaves the config root (§FS-fmt.2.3.2, §DF-symlink-scan.2.5): it read one and rewrote it on disk at the target, outside the root, with a relative cross-reference broken where the file actually lives. Reading a foreign file and editing it are different acts (§REQ-no-data-loss.2), so the write, and only the write, stops at the boundary — the file is still read and still checked, --write names it once on stderr, the exit code is untouched, and the dry run still lists what it would have done. A link whose target is inside the root is written through, with one residue recorded rather than fixed: the file is read once under one of its two spellings, so --cross-refs anchors its relative links to that spelling and they are wrong at the other name — no single relative path is right from two directories. The gate that decides which unresolvable links are owed a report is now the same for both kinds. A loop was tested against the hidden-name and [scan] exclude rules only, so a looping directory link that .gitignore covered still turned a green tree red — the exact failure the decision's own rationale argued against, in the paragraph that declared a loop "always owed one". The ignore files now gate both, and [scan] extensions gates a broken link as well, which names a file where a loop names a directory. Two reporting bugs go with it: a scan error was emitted once per walk root — the file list was deduplicated after the sort and the error list only sorted, so under --full, where every include root is walked beside the config root that contains it, every error: line printed twice; and a loop that escapes the walk root was reported at a doubled path, error: docs/up/docs/up for docs/up -> .., because the target is not an ancestor when the walker meets it and it descends a second copy of the tree first. The report is now the shortest path below the walk root naming the same link, tested by identity — the canonical directory holding it plus its name — rather than by "the first prefix that is a symlink", since a link two spellings deep is a different link and docs/shared -> ../shared-docs must not be blamed for a self -> . inside it. Two blind spots are declared rather than closed, both bounded (§REQ-no-missed-citation.2): a broken link with no extension is not reported, because a target that does not exist cannot say whether it was a directory worth descending into and reporting every extensionless dangling link is the noise the gate exists to prevent; and the "reported at the in-tree link path" rule is scoped to a link met inside a walked tree, since an explicit path argument is resolved before any walk begins and grund check docs/beta.md on a symlink therefore reports at the target — pre-existing, and the resolution that makes grund check <path> work at all. The tree walk moved to crates/grund-core/src/scanner_walk.rs along the seam §AR-scanner already draws — §1 is a directory traversal, §2 is a line-by-line pass over a file's text, and they meet only at the file list one hands the other. A second review round found the boundary rule stated in only one direction. The member list a project carries says what lies below it, so a leaf member has none at all, and a symlink inside one member walked into a sibling's tree — or back up into the root project's — and filed that project's declarations under this one's namespace as duplicates of themselves. That is the same harm §AR-workspace.6 names for the root scan and it is mutual, so every project a run loads now carries the canonical root of every other one: a directory reached through a link is asked which project owns it, the innermost project root that contains it, and one owned by anybody else is not descended into — in either direction, and whether the link lands on a directory or on a single file (§FS-workspace.6). A directory no project owns is still followed, because content outside every project is content the repository linked in deliberately. A member checked on its own loads no workspace (§FS-workspace.5) and therefore cannot be told where the other projects are; that bound is written down rather than closed. The report-at-the-link rule failed for the walk root itself. An explicit scope is resolved before the walk so a run can recognize that it is the config root, and the resolved path was then used as the root to walk — so a repository reached through a symlinked path (a linked ~/work, macOS resolving /var to /private/var) had every finding spelled physically: absolute, outside the root, and unrenderable by relative_paths. The resolution now answers only the question it was asked and the walk keeps the name it was given (§FS-config.3.5.2). fmt --check could never go green on a tree holding a link that leaves the config root: the dry run listed the rewrite it would make in the file --write refuses, so it named a pending edit no author could clear and any CI gate or pre-commit hook built on it was red forever. The dry run now reports the refusal instead, in the same warning: shape, and --check predicts --write again (§FS-fmt.2.3.2). A loop was reported and then walked into. docs/up -> .. is inside none of the directories the walker is descending when it meets it, so the walker descended a complete second copy of the tree, printed findings out of a tree the same run had just called unreadable, read past [scan] include, and under --full printed the error twice — once with an empty directory name where the ancestor was the config root. A link whose target is at or above the walk root is now pruned where it is met and the report raised without the descent, behind the same hidden-name, [scan] exclude and ignore-file gates (§FS-config.3.5.5). A two-hop loop — docs/a/link -> ../b with docs/b/link -> ../a — is reported at docs/a/link and docs/b/link, the two names a user can go and fix, rather than at the doubled docs/a/link/link the descent produced: the link's identity is re-expressed under the walk root, which finds a sibling spelling where a search over the reported path's prefixes could not. fmt rendered its own paths against each project rather than against the run, so in a workspace a member's unreadable path came out as docs/FS-003-gone.md where check says packages/sub/docs/FS-003-gone.md — a different real file in the same run's output — and the refusal line had the same defect; both now render where every other path fmt prints does (§FS-fmt.3). --write turns --cross-refs on by itself wherever the scope holds Markdown (§FS-fmt.6.6), so the abort-before-anything-is-written case is its ordinary path rather than the exception §FS-fmt.3 described it as, and that abort now names itself — error: nothing was rewritten: <path>: <reason> against the partial run's bare error: <path>: <reason> — because two exit 2s that mean opposite things must not be spelled the same. The refused paths are returned on FmtOutput.refused_writes and printed by the command surface, as scan_errors beside them already were: an additive field (§FS-distribution.3.1). Three more verdict moves, all correct and all new (§REQ-backwards-compatibility.1): a workspace whose members link into each other stops reporting duplicate declaration and exits 0; fmt --check on a tree with an escaping link exits 0 where it exited 1; and a tree with a loop above its walk root stops reporting the findings it read out of the second copy. Finally, §FS-config.3.5's symlink paragraph carried six rules in one block and could be cited no more precisely than the whole of it — it is now six numbered subsections, so a citation can name the rule it means. Resolves issue #96. PR #100

  • §FS-init.2.1.1 / §FS-init.2.2: grund init no longer writes a second entrypoint for an agent that already has one. --claude wrote CLAUDE.md and .claude/CLAUDE.md unconditionally, so a repository with the first and no .claude/ directory got the second created — and Claude Code reads both, which put the whole managed block in the model's context twice, for no benefit. Across a five-project workspace that meant deleting a freshly written .claude/CLAUDE.md after every init, five times, and remembering to do it again on the next upgrade. The block is the file §GOAL-token-economy is about — it is read at session start, in full, before any work — so paying for it twice is the one cost that goal exists to refuse. The table init selects from already knew the agent behind each path and never used it for this: agent answered does this flag select this row and was asked nothing else, so a row was a path and two rows were two files, which is exactly right for choosing what to update and wrong for choosing what to create. init now creates at most one entrypoint per agent, and only for an agent that has none; every entrypoint the repository already has is still appended to or updated in place, however many there are, because an existing file is the user's and init does not decide which of their files to stop maintaining. The one it creates is the first of that agent's paths that is free: for Claude, CLAUDE.md — the root-visible form, chosen for the reason init also generates the bare grund.toml (§DF-config-file-location.2.3), that it is the form the rest of the ecosystem uses and it adds no hidden directory to a repository that has none. The directory form stays one command away and needs no flag to reach: create <path>/.claude/CLAUDE.md empty and re-run, since init fills in every entrypoint that exists. The same reasoning fixes automatic mode, where .claude/ used to create both aliases — the directory proves Claude is in use, which is one fact, not two files. The rule is per agent, not per Claude: Cursor reads .cursor/rules/grund.mdc and the legacy .cursorrules, and --cursor against a repository whose rules live in the legacy file added the modern one beside it — the same defect one row down, now the same fix, with migration off the legacy form being delete it, then re-run rather than end up with both. A companion that is a symlink to AGENTS.md counts as the agent having one, and the first draft of this fix had that backwards. Reading the symlink as "not the agent's own file, so write it a real one" is defensible — it is the canonical file under another name — but it reproduces the reported bug exactly: with the default config the two files carry byte-identical blocks, so --claude against a repository with CLAUDE.md -> AGENTS.md wrote .claude/CLAUDE.md and Claude read the same 55 lines twice, silently, on that run and every re-run. Automatic mode did it too, through the workspace alias, with nothing asked for at all. The reason that carve-out exists is narrower than the carve-out was: the two files differ only when the repository commits [reference] conversation = "link", which gates the Claude entrypoints to a form the canonical file is not allowed to carry (§FS-init.2.3.4.17). So it is gated on exactly that — with the key, --claude still writes the Claude-specific file the symlink left free, which is the escape hatch that section depends on; without it, the symlink is simply the agent's copy and nothing is written beside it. That section's note: follows the same reasoning one step further: it now names the fix that is still open — write the entrypoint where there is none, delete the symlink where Claude has one — because advising the command that has just run is a note that never retires. Which fix is open is read from the tree, not from the run's plan: an earlier cut asked the plan, so grund init --gemini against a repository with a real .claude/CLAUDE.md and CLAUDE.md -> AGENTS.md announced that the symlinks had taken every path a real Claude entrypoint could go and sent the user to delete one — a false diagnosis of files that flag never selected, contradicting the note a flagless run printed on the identical tree, and destroying a working entrypoint if followed. The deletion is named as safe to do now only where this run left that entrypoint carrying the current block; a preview, or an entrypoint this run did not select, keeps the condition on it, and that condition is the block rather than the file — a would-append names an entrypoint that already exists, so "once it exists" is satisfied on sight and the conditional protects nobody. Where a repository already carries two of one agent's entrypoints, both are maintained and the run says so in one note: line naming the files and the agent that reads them — the run that just wrote to each of them is the only place that duplication is visible, and silence there is what let it accumulate. The --dry-run half of the report loses the pairing it made the reader decode — exists CLAUDE.md beside would-write .claude/CLAUDE.md, which meant two copies of one block and said so nowhere — and both notes are written in the conditional there, because a preview that reports in the present tense describes a tree that does not exist and can advise deleting the symlink it did not replace. The fixtures had the defect written down: e2e/cases/init-conversation-link-claude/expected.repo carried CLAUDE.md and .claude/CLAUDE.md as two byte-identical golden files, asserted as correct output. Landing it moved four boundaries (§AR-core-module-layout.1): entrypoint selection — which files a repository has and which a run touches — left init_templates.rs for init_entrypoints.rs, the managed-block machinery left init.rs for init_block.rs (the extraction half of that file's recorded debt, whose remaining half is the second implementation of the same protocol in integrations.rs), the note: builders left for init_notes.rs, the run's entrypoint plan left init_entrypoints.rs for init_plan.rs — which is what lets the notes read the plan instead of re-deriving it from disk — and the entrypoint-selection test cases left tests/init.rs for tests/init_agent_entrypoints.rs and tests/init_agent_flags.rs over a shared fixture, split on the automatic-mode / explicit-flag boundary §FS-init.2.1 already draws — which is the whole of that file's recorded debt, so its hard-severity exception is retired and the branch adds none. grund check's companion scan is now derived from init's existing-entrypoint set rather than restating the same walk, so the two cannot disagree about what an entrypoint is. Resolves issue #87. PR #92

  • §FS-fmt.2.4.1 / §FS-check.3.15 / §FS-fmt.3 / §DF-shorthand-numeric-run: grund fmt --write no longer expands a number-only shorthand that is glued to a second number, and says so instead of rewriting it. A changelog recording an ID remapping writes the old numbers as runs — <§>SPEC-001→SPEC-003, <§>SPEC-001/003, <§>COMP-047/046/049. Every character after the first number is one that cannot continue an ID, so the trailing-boundary rule of §DF-number-only-citation-shorthand.2.6 passed and today's slug was attached to a number that named something else, turning a renumbering table into a set of pointers at live, unrelated declarations. The rewrite was silent and nothing downstream could see it: the output is a well-formed citation of a real declaration, so grund check passed and the run reported success. That is the worst-shaped instance of the class §GOAL-no-dangling-refs forbids — not a citation that resolves to nothing, but one that resolves to the wrong thing while reporting green — and it is only findable by a human reading the prose, which is how it was found, in a 5,750-line diff re-read after the fact. The rule the boundary test was missing is that a whole token is not always a citation: §2.4 asked does this token end here?, which is a lexical question, and never asked is this token being used as a citation? The marker settles the first and cannot settle the second — a § in front of SPEC-001→SPEC-003 says the sentence is about IDs, not which token in it is the reference — so the shorthand, which is precisely the ambiguous shape §DF-number-only-citation-shorthand.2.4 required the marker for, needs one more piece of evidence. The evidence is the second number, not the punctuation. A site is a numeric run when the characters after the token form a non-empty delimiter sequence carrying no whitespace, no marker, and no bracket or quote, followed by another [id] number_pattern match or another whole shorthand written unqualified — which derives →, /, .., …, |, , and everything else anyone writes between two numbers without enumerating a list that would be one defect short forever. The three exclusions are the safety margin: whitespace, because the gluing is the evidence and <§>FS-042 (2024) is an ordinary citation under the default number_pattern = "\d+"; the marker, because <§>FS-042, <§>FS-043 is two citations the author marked one at a time; and brackets and quotes, because those bound a construct instead of gluing two numerals — a walk that crossed one would join the characters closing the construct the citation sits in to the ones opening the next and read the next construct’s number as the second one, making a run of the Markdown link [<§>FS-042](FS-042-user-login.md) that fmt --cross-refs itself writes and of the footnote reference <§>FS-042[^1]. Requiring the neighbour to be unqualified is the same guard from the other side: an <alias>/ namespace precedes a citation and never follows one, so a path that happens to end in an ID-shaped segment — docs/functional-spec/FS-042-user-login.md — is not a second number. Both are shapes an ordinary citation wears, and refusing them would leave a shorthand-numeric-run error naming an edit fmt will never make. The rule reads forward only — a glued number-shaped token to the left is also what 2026-08-19/<§>FS-042 looks like, and refusing those would cost real citations for no evidence. The site is reported, not skipped in silence: a new shorthand-numeric-run error names both exits — the canonical form to paste if it was a citation, and the <§> escape if the numbers were a mapping — because only the author knows which. Nothing turns green-to-red: these sites are already errors today, and what changes is that the message stops advising the edit that corrupts the line. Recognition is untouched, so the site still resolves, still counts as an edge, still grounds its file, and its declaration is still not reported unused — dropping the edge would tidy the model and reintroduce the exact false negative the shorthand rule was added to end. Only the message that names the rewrite changes: a shorthand in a run matching zero or several declarations keeps its §FS-check.3.13 message, because that is a resolution failure and a run is no reason to say less about it. The editor's live transform (§FS-lsp.1.4) applies the same rule, with one residue it cannot avoid: an author typing a fresh run has not written the second number yet when the keystroke that ends the token fires, so that expansion happens — visibly, one undo away, which is the loud failure and not the silent one. Independent of the rule, and the part that outlives it: grund fmt now prints the text every expansion writes — docs/notes.md:1: shorthand → canonical: <§>FS-042 → <§>FS-042-user-login — so a --check run is a review before --write puts it on disk. The other three rewrites leave the ID token byte-identical and only move markup around it, so a wrong one dangles or fails to resolve and check names it; expanding a shorthand is the one pass that writes characters into the ID, information the source never carried, and naming only the line and the rewrite class was enough to review the former and not the latter. FmtChange.label is therefore a String rather than one of four fixed labels — a documented, additive break in the library surface (§FS-distribution.3.1). No grund_config_version bump, no [id] key, and no AGENTS.md block bump: the rule is derived from [id] format like the shorthand itself, and a knob would let two installs disagree about what a citation is (§FS-non-goals.13). The numeric-run test lives with the [id] format template in id_format.rs rather than in shorthand.rs (§AR-scanner.2.6) — where a token of the grammar ends is a question about the shape, and the test reads the shorthand patterns directly — and its cases are one module across all three stages, because the defect being guarded against is a rewrite and a report agreeing with each other and both being wrong. Resolves issue #81. PR #90

  • §FS-distribution.4: archiving a release no longer breaks the links it carries above docs/. scripts/prepare_changelog_release.py moves the outgoing release body from docs/changelog.md into docs/changelog/<version>.md, one directory deeper, and rewrote each relative destination to match — except one already beginning ../, which it skipped. Those are exactly the destinations that need a second ../: a link to a repository-root path is written ../crates/… against docs/, and from docs/changelog/ it has to climb twice. v0.10.0 archived v0.9.1 and its two §AR-checker links to an inline source declaration became docs/crates/…, which turned the tree's own lychee gate — and so CI on main — red. The rule is now the file's depth rather than the link's shape, an anchor, an absolute path and a URL still mean the same thing at any depth, and the archived v0.9.1 links are corrected. PR #80