Skip to content

grund 0.10.0

Choose a tag to compare

@github-actions github-actions released this 18 Aug 06:56
· 2551 commits to main since this release

Added

  • §FS-workspace.6.1 / §FS-workspace.1 / §DF-nested-workspaces: a workspace member may declare its own [workspace], to any depth, and a project is named by its whole alias path — <§>hardware/sprayer/FS-nozzle, one segment per level, read from the outermost workspace down. Before this, a member carrying [workspace] was a load-time error, so a repository that groups sub-projects under a directory had to enumerate every leaf at the root and the grouping directory could not own its own member list; the hierarchy survived only in prose. The grammar is now <§>[alias/…/alias/]<ID>[.section] and the namespace is still one string, so nesting adds recursion at the expansion step and no second shape anywhere below it — an ID never contains /, so the last separator is the path/ID boundary for the scanner, the CLI argument, and [citations] rule entries alike. Paths, not globally unique names, are what let two pod projects under different parents coexist: alias uniqueness is now per sibling set, not per tree. An intermediate node is a project of its own unless its block sets include_root = false — otherwise its files would be scanned by nobody, since the outer scan stops at the member boundary — and opting out still leaves its segment in the paths below it. Alias paths do not change with scope for every scope in the claimed chain — each level's [workspace] block listing the directory below it — so a run narrowed to one of those blocks resolves a subset of the same paths, and a citation cannot pass such a check and fail the run CI does. The guarantee is quantified over the scope a command starts at, not over the project it names: the projects under a block the chain never lists can themselves be reached by the chain, since a multi-segment entry (grp/inner) hops the block, and a run started at that block re-spells them — the recorded limitation below, not a property of a chained scope. Every block in that chain has to answer, so one that claims a directory and cannot expand its members or name the project below it fails the run with its own error instead of dropping its segment and letting the subtree invent a namespace; a [workspace] block that no enclosing block lists is outside that chain, absorbed into the enclosing namespace at the outer scope and a root of its own from the inside, and reporting it is a recorded limitation rather than a check. A member that resolves to a project root another block already holds is a config error at the members line that introduced it — a backstop, since what bounds expansion is containment: a member root lands strictly inside the block that lists it and no member of one block contains another, so every step goes strictly downward into a finite tree and no two roots in it can be equal. Two entries in one members list naming the same root stay deduped, so a glob may overlap an explicit entry. A malformed namespace qualifier in a [citations] rule entry is now reported the way a malformed <alias>/<ID> command argument is — naming the segment that failed rather than quoting the whole path (§FS-config.3.9.3). grund init's Workspace members section (§FS-init.2.3.4.15) follows the same claimed chain, so it lists the tree a command run at that directory resolves — a repository that merely sits under an unrelated [workspace] still teaches its own aliases. Single-level workspaces are unaffected — one level is one segment, which is what they already write. Resolves issue #47. PR #66
  • §FS-check.3.8: an unknown project alias now names the projects it could have meant. Whole alias paths make one mistake likely — writing a project's short leaf name where its path is required — so check matches the unresolved path against the projects in scope in one tier only, best first: a path that ends with what was written (a dropped prefix), else one whose last segment matches (a wrong prefix), else one a typo away under the near-match rule §3.1 already uses. Up to three are listed, and a path with no candidate reports exactly as it did. unknown project alias sprayer becomes unknown project alias sprayer; did you mean hardware/sprayer?, which is what makes migrating a flat leaf list to a nested tree mechanical instead of a search. This reaches single-level workspaces too, with no config change: a typo'd alias that reported bare now carries the suffix (unknown project alias ap; did you mean api?) from the typo tier. Message text is part of the user-visible contract (§FS-errors.3, §GOAL-no-silent-breakage.1), so it is named here as intended: everything up to the ; is byte-identical, so a consumer matching the phrase or its prefix still matches. A run narrowed to a subtree offers no candidate at all and says which subtree it covers instead: it cannot tell a dropped prefix from a path that correctly names a project outside its scope — a shorter written path is a complete alias path whenever a top-level project carries that name — so any hint it could give risks rewriting a citation the workspace-root run accepts into a different project's, green before and green after. The tiers run at the outermost root, which is the scope CI runs and the only one that can see every project a path could name. PR #66
  • §FS-inline-citation-style.3.3 / §FS-inline-citation-style.4.4 / §DF-inline-note-layout: add [reference] inline_note_layout, the third axis of an inline citation's shape — where the §<ID> tokens sit inside the note — and inline_note_layout_check, which says whether grund check reports a deviation and through which channel. inline_style = "citation-with-note" already said whether prose is allowed and how much of it; it said nothing about arrangement, so // <§>FS-x: note, // note. <§>FS-x, and // <§>FS-x note all passed and a project that wanted exactly one of them had nowhere to say so. The cost is not tidiness. It is that the convention became repository prose: the generated entrypoint taught the budget and then left the agent to infer the arrangement from the surrounding files (§FS-init.2.3) — the guessing §GOAL-friendliness-first exists to remove. And one arrangement in particular is worth being able to demand: under [reference] strict = false a bare // FS-x: note line is an inline declaration heading, so writing the marker and the colon is what makes "cites FS-x" and "declares FS-x" two visibly different lines instead of one shape read two ways. inline_note_layout = "citation-first-colon" requires a run of citations joined by exactly , , then a colon, then a space and the note — or a colon that ends the line — read on the content left after the comment prefix and any block closer are stripped, exactly as the note-presence rule already reads it. A leading Markdown list marker is skipped with that indentation, so the enumerated block of grounded points every doc-comment convention renders as a list — /// - §<ID>: note — opens with its run like any other line; the marker stays note text where §2.3 decides whether the site carries a note at all, so no bulleted pointer is silently reclassified. The rule is judged per line, not per site: a line carrying no citation is unconstrained, so the summary-then-citation doc-comment that Rustdoc, Javadoc, and JSDoc all encourage stays legal, and the finding lands on the line an author has to edit rather than on the block's opener — the one member of the inline-citation-style rule that does not anchor at first_line. Per line is not every line: the site's first citation-bearing line opens the note and is always judged, a later one is judged when it opens with a citation of its own, and a citation-bearing line that opens with prose is the continuation of a note that already opened correctly. So a note may wrap and still name a second point on the way, which is exactly the freedom the line budget and the same-line rule already granted and which judging every such line quietly took back. A site with no note at all is exempt, because a layout is a relation between a citation and a note and // §A §B is a pure pointer; the deliberate consequence is that a // §A line followed by a prose line in the same block is one site with a note, so the citation line is judged and fails. The form is exact — a space where the comma belongs, a comma with no space, a space before the colon, a dash for the colon — because a tolerant matcher would leave the project with the variation the key exists to remove while still leaving an agent to guess which tolerated spelling the humans write. Citations later on the line are free, so // §A: note (see also §B) conforms; only the opening run is constrained. Two keys rather than one, because a project answers "which style?" and "gate on it yet?" at different times: adopt the style so the entrypoint starts teaching it, migrate the tree with warn as the worklist, then move to error. That is a per-rule channel choice with existing precedent — warn_on_suggested does it for the soft cap, [citations] must/should does it per obligation — and not configurable severity: what an error and a warning mean, and how the exit code follows, stay fixed by §FS-non-goals.9, and both installs read the level from the same committed grund.toml (§FS-non-goals.13). Both keys default to the inert value, so nothing turns red on upgrade (§GOAL-no-silent-breakage): no grund_config_version bump, and no AGENTS.md block bump either — under any the rendered block is byte-for-byte what the previous release produced, so no repository's managed block goes stale for a rule that changes nothing about what it should write. Under citation-first-colon the block gains one sentence naming the form with the project's own marker — deliberately wider than the gate, since a doc-comment that declares an ID is not a citation site and its body is therefore taught the form and never measured against it (§FS-inline-citation-style.3.3, rule 6) — and the enforcement level deliberately does not change it: the house style is what an agent is asked to write, and an agent told the form and then told it is only advisory has been handed a reason to ignore it. The half of the report that is declined is grund fmt normalization, in --check and in --write alike (§DF-inline-note-layout.2.5). Turning // note (§A). into // §A: note. is a prose edit wearing a token edit's clothes — it needs to decide where the sentence ends, whether the trailing parenthetical was the citation, and what punctuation the remainder now needs, which is the judgment §FS-non-goals.3 refuses to acquire; a formatter that is right most of the time fails here as a silently mangled comment in a commit nobody read line by line. What the report actually needs is served without it: the fix is one token in the loop of whoever is already editing the line, and migrating an existing tree is what warn is for. grund config show prints both keys at every value, inert or not, and templates/grund.toml carries both with their accepted-value comments so the generated config still teaches the whole schema (§FS-config.3.1, §FS-check.3.10, §FS-check.4.4). The enum is closed and ships one member on purpose: citation-first-dash, a bare citation-first, a bracketed form, note-first-parenthesized, and citations on a line of their own were all considered and all left out until something asks for them, since each costs its own spec examples, its own rendered sentence, and its own coverage, while adding one later is additive; a free-form "{cites}: {note}" template is rejected outright for now, because a template is a small language — parser, inverse matcher, escaping, validation — and there is no way to render one canonical sentence into an entrypoint from a form the tool was handed rather than one it knows. Implementation-wise the rule is one file, crates/grund-core/src/inline_note_layout.rs, over a second holding what is not the rule — comment_line.rs, where reducing one line to its content and its citation tokens now lives for the scanner walk and both note verdicts alike (§AR-scanner.3, §AR-checker.2.14): the scanner records, per inline citation site, the ascending list of its lines that deviate, and the checker reads that list instead of re-opening the file, so the rule stays a pure pass over Findings. Under any, and under a layout whose check is off, no line is classified at all, which is why neither the default path nor a documented-only style tokenizes or classifies a line on the key's account (§GOAL-fast-feedback); the memo that lets the two verdicts share one tokenization is built only where a second reader exists, so the default path allocates none of it and pays one comparison per site. It comes out ahead on the way: the comment-prefix list every note walk reads is now built once per comment block rather than once per line, which it was before this key existed. Gathering the classifier, the note-presence helpers it shares, and the checker rule in that one file — the arrangement shorthand.rs already uses, for the same reason: four halves of one invariant in four files with nothing naming the invariant is how the halves drift — took scanner.rs from 1523 to 1383 lines — the citation tokenizer left with the helpers that are now its only callers, which is most of that file's standing until clause — and checker.rs from 1083 to 1013, and every file-size ceiling drops to what the files now measure; sharing them is literal and lazy, since a line of a site is tokenized once, when the first pass that needs it asks, and the note-presence verdict and the layout verdicts read that one answer — so a block the note walk leaves early, and every block under a configuration that classifies nothing, is read no further than its answer needs. One reading changes for every project, not only for one that sets the key: what separates two citation tokens of a chain — whitespace, or a single comma with optional whitespace — is no longer note text (§FS-inline-citation-style.1), so // <§>FS-a, <§>FS-b is a pure citation comment. It has to be: that is the spelling the layout mandates in front of a colon, and it was previously read as prose — rejected outright under inline_style = "citation-only", and reported as a layout deviation under the new key for lacking the note it does not have. Resolves issue #52. PR #65

Changed

  • §FS-workspace.6.1: the empty-workspace error now carries a location: error: .agents/grund.toml:7: workspace has no projects in scope (include_root = false and no members) where v0.9.1 printed the sentence with no path or line. It points at the block's members line, or at its [workspace] line when there is no members key to point at — a nested tree may hold many blocks, and the message has to say which one is empty. PR #67
  • §FS-workspace.8.1 / §FS-show.3: a qualified ID naming an unknown project exits 2 at every path length. grund api/sub/FS-endpoint used to be invalid ID with exit 1, because a two-segment path could not be a namespace and the whole token was read as an ID; it now joins the single-segment form — error: unknown project alias `api/sub` , exit 2. grund show --help said 2 CLI error and left a reader to guess where an unknown alias landed; it now names it. PR #67
  • §FS-workspace.1 / §AR-workspace.3.1: migration — a §-marked token that looks like <segment>/<segment>/<ID> now resolves as an alias path plus an ID in every repository, including one with no [workspace] at all. The single-segment namespace could not match two segments, so a marked file path used to be plain text; <§>docs/functional-spec/FS-login.md is now a two-segment alias path plus FS-login.md and reports unknown project alias docs/functional-spec at its site — a repository that was green on v0.9.1 can go red on nothing but this line, and grund fmt --write --marker can create the token by promoting a $$-prefixed path to the marker. Nothing changes for a token without the marker, which was never a citation and still is not (§AR-workspace.3.1). The fix is to unmark the path — drop the marker, or move the path out of the citation — since marking it is precisely what asks grund to resolve it. PR #67
  • §FS-workspace.2 / §FS-workspace.6.1: a [workspace] member must resolve to a directory strictly inside the config root that lists it — not that root, not an ancestor of it, not another tree — and one that does not is now a config error at that block's members line, naming the entry as written. Member entries already reject .., so escaping takes a symlink, and canonicalize used to accept wherever it pointed. Two failures made that unfixable from anywhere else: a member root with no lexical ancestor listing it is named from a different chain at each scope, so grund check at the root demanded real/leaf where a run at the member demanded leaf and no citation text passed both; and a member root that is an ancestor of its own block scans nothing at all, because every scan root then lies under that project's own member boundary — its declarations vanish from grund list and its dangling citations pass, a §GOAL-no-dangling-refs hole opened by a symlink. Migration: a workspace that joined an external directory by symlink stops loading, whether the link is the member entry itself (members = ["link"] with link -> ../other-repo) or a directory on the way to it (members = ["pkgs/api"] with pkgs -> ../store/pkgs) — the rule is about where the entry resolves, not which segment carries the link. Move or nest the project, or check it as its own repository, since external-repository aliases are out of contract until a cache layer exists (§DF-subproject-namespaces §3.4). PR #66
  • §FS-config.3.2 / §FS-workspace.1: no ID the grammar can build may contain a / — a literal / in [id] format or in a [[kinds]] prefix, or an [id] number_pattern / [id] slug_pattern that can match one, is now a config error at that key's line. [id] section_separator may not carry one either, which is the same invariant from the other side: a citation's alias path ends at the last /, so section_separator = "/" made the ID/section boundary indistinguishable from it — <§>root/fs-x/1 read as alias path root/fs-x and ID 1, and a [citations] obligation resting on that citation turned red with no config change. For the two patterns the question is what they match, not what they spell: slug_pattern = "[^.[:space:]]+" carries no / and produces one freely, so it is rejected, while "[^/.]+" names the character in order to exclude it and loads. slug_pattern = "[a-z][a-z0-9/-]*" used to load, declare FS-a/b, and check clean, while grund list printed the ID as m/FS-a/b and grund FS-a/b, run inside m, answered invalid project alias naming the segment FS-a — grund emitted an ID it then refused as a query. Alias paths sharpen it rather than introducing it: an <alias>/<ID> argument now splits on the last /, so m/FS-a/b reads as alias path m/FS-a and ID b where before it read as project m, ID FS-a/b and resolved. The nested-workspace naming rules rest on "an ID never contains /" and nothing enforced it. A repository that really did spell IDs with a / must rename them before it can load. PR #66
  • §AR-ci.9: markdown now carries two file-size budgets instead of one. The citable tree under docs/ goes to soft 750 / hard 1500, because grund decides who pays for a document's length: a spec is reached by citation and read as a lead, a section, or a table of contents (§FS-show), so §GOAL-token-economy is answered at the read rather than at the file and a ceiling in the hundreds of lines bought a reader nothing. It did have a price — a document whose numbered sections are live citation targets cannot be split without relocating those IDs and sweeping every citing site in the same change, so the budget kept collecting deferred exceptions instead of splits, and three of them retire against the new number: the soft entry for FS-workspace.md and both halves of the clickable-citations.md pair. A fourth retires for the opposite reason — crates/grund-core/src/checker_cmd.rs was actually split, its member-expansion rule set now living in workspace_members.rs (§AR-workspace.6.1). The always-read-whole entrypoints — README.md, CLAUDE.md, AGENTS.md, skills/**/*.md — move to a rule of their own, keeping the old soft 250 with the hard ceiling raised from 400 to 500. Nothing addresses them by citation — an agent entrypoint is loaded into every session's context, so there every line is charged to every read — but the hard tier is the one with no override, and skills/grund-init/SKILL.md is a shipped artifact whose bytes a test asserts (§FS-init.5) at 387 lines, so 400 left it 13 lines from a block no contributor was allowed to clear. skills/grund-init/SKILL.md keeps its structural entry, repointed at the rule that now measures it. PR #66

Fixed

  • §AR-workspace.9: the rest of the e2e symlinks manifest is now a contract, not a convention. An empty manifest yielded no links, no diagnostic and no platform skip, so a case could be green with a dead manifest; a manifest case run against {repo} never created its links and was green too; self -> . -> extra was accepted verbatim and failed later as a golden mismatch; and the support probe was pid-keyed rather than thread-safe and cleaned up with remove_file, which fails on the directory symlink Windows makes, so it self-poisoned after its first call. Each of those is now a located rejection or a probe that runs once per process, and e2e/README.md states the contract. PR #67
  • §AR-workspace.9: an e2e symlinks manifest could create links outside the fixture copy. The link path went unchecked into repo_copy.join(...), which discards its base for an absolute path, so a manifest line could write a symlink anywhere the test process reached — an absolute line outside the tree, ../../../../x in the repository root — with nothing cleaning it up, and an unvalidated target let a write-mode fmt case rewrite files in an unrelated directory. The link path is now rejected unless every component is a plain name, and the parent it lands in is resolved and checked against the copy, so an earlier line cannot open the way for a later one. The target stays free to escape: link -> .. is what one case tests. PR #67
  • §AR-workspace.9: the e2e harness reported a skipped case as an ordinary pass, and probed the wrong directory for symlink support. The platform skip was a bare return — nothing printed, nothing counted — and the probe tried std::env::temp_dir() while the links are created under target/e2e-work/, so a TMPDIR that could not hold a symlink silently deleted the member-containment coverage on Linux and macOS too. The probe now tests the directory it writes in, once per process; every pass counts and names the cases it did not run, and fails outright where the platform can create a symlink. The [workspace] skip in the determinism pass was also dead — || against && made it unreachable for the only cases it was for — and the coverage claim now says which platforms actually exercise it. PR #67
  • §FS-workspace.6.1 / §FS-workspace.5: two of the tests added above asserted a run-level outcome their own fixtures could not produce. Only a run at a directory that declares [workspace] climbs for an alias path, so both fixtures — whose run root declared none — passed while the command they describe exited 0. They now run at a block, which fails as their prose says, and the leaf-run asymmetry is stated where a reader meets it and pinned by a test of its own — in tests_workspace_claim_answers.rs, which takes the propagation half of the claim cases so the naming half stays one subject. PR #67
  • §FS-workspace.6.1: an enclosing [workspace] block that claims a directory and whose config does not load no longer reads as claiming nothing. The claim was decided from a loaded config, so the one file that could answer it — the members line — silenced the question when it failed to parse: the whole segment chain was dropped and the subtree spelled itself alpha where the root says group/alpha, with §FS-check.3.8 then hinting that one spelling. Two mistakes on one members line behaved oppositely — "missing" failed a run below it, "/abs" re-spelled it. The claim is now read from the members entries alone, parsed out of the file with no other key read and no shape rule applied, and a claiming block that will not load fails the run with its own load error. A block that claims nothing here is still climbed past however broken it is, since a stray grund.toml above a repository is not that repository's problem; where even the members text cannot be obtained — an unreadable file, a value that is not a list — the claim is undecidable in both directions and the run prints one warning: naming that config and what may be missing below it, rather than deciding silently. PR #67
  • §FS-workspace.6.1 / §FS-check.3.8 / §FS-config.3.2: four defects in the nested-workspace work above, found by review after it reached main and corrected before any release. A [workspace] block that claimed nothing in a tree still had its member list expanded, so one broken grund.toml in any ancestor directory failed every command in a repository below it; the obligation is now the claim's alone. A run narrowed to a subtree still offered the dropped-prefix hint, and a shorter written path is itself valid when a top-level project carries that leaf name — so the hint could re-point a citation the workspace-root run accepts at a different project's declaration, green before and after; a narrowed run now offers no candidate and names the subtree it covers. The / rule read the text of [id] slug_pattern and number_pattern, which both admitted a pattern that matches / without containing one ([^.[:space:]]+) and rejected one that forbids / ([^/. ]+); it now asks the parsed pattern, and [id] section_separator is covered too. And the alias-path guarantee was quantified over the projects the claimed chain reaches rather than the scopes in it, so a run started at a block nothing lists re-spelled its own subtree while three documents said that could not happen. PR #67