From 1cea8c7995591518df25687bc1203e45524ce624 Mon Sep 17 00:00:00 2001 From: "Claude Fable 5.1 (claude-fable-5-1)" Date: Mon, 19 Oct 2026 00:31:19 +0200 Subject: [PATCH 1/4] fix(spec): every occurrence of a term is a link, and a term link is no escape Once a file links a term, every occurrence of the term in that file is a link: the first emphasized, the later ones plain. The validator reports a plain occurrence of a linked term as spec-term-unlinked, and rune spec fix --links links it with the tag the file already uses. Where two terms overlap, the longer wins, in the fixer and in the lint. The matcher keeps byte offsets under Unicode lower-casing, takes the es plural of a label that ends in s, and masks an inline-link destination with balanced parentheses; the prose view closes a code span with a backtick run of the same length. The assembler strips a term link to its text, so a deployed instance carries no dangling reference, and the bundle check accepts a reference into the repository glossary only when its page name is kebab case. The change specs say so. Co-Authored-By: Martin Zeman --- CHANGELOG.md | 2 + .../every-term-cites-definition/design.md | 4 +- .../every-term-cites-definition/proposal.md | 2 +- .../specs/lifecycle-of-a-change/spec.md | 42 +++---- .../glossary-renders-from-ontology/spec.md | 2 +- .../proposal.md | 8 +- .../ontology-deploys-with-provenance/spec.md | 2 +- .../specs/lifecycle-of-a-change/spec.md | 16 +-- rune-docs/src/spec/fix.rs | 85 +++++++++++++ rune-docs/src/spec/lint.rs | 82 +++++++++++++ rune-docs/src/spec/lint/tests.rs | 35 ++++++ rune-docs/src/spec/prose.rs | 38 ++++-- rune-docs/src/spec/term_links.rs | 116 ++++++++++++++++++ src/assemble/references.rs | 17 ++- src/assemble/tests.rs | 10 ++ src/manifest/bundle.rs | 33 +++++ src/manifest/bundle/tests.rs | 30 +++++ 17 files changed, 475 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2be8a418..b3b2b81e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - Resolve a term link through its page's `iri`, or through the tag when the page is absent. Plain italics are emphasis and are not checked. - Report `spec-term-undefined` when a link names no term, including a reference tag with no definition in the file, and `spec-term-draft` (warning) when the term is a draft. - Report `spec-term-first-use` when the first use of a term in a file is not emphasized, or a later use is. +- Report `spec-term-unlinked` when a file links a term and writes it plain elsewhere, and link every occurrence in `rune spec fix --links`; where two terms start at one word, the longer wins. - Report `spec-term-legacy` (warning) on the old `*term* [TAG]` form or an IRI definition, naming the new form. A delta gets `delta-` twins of every term code. - Apply the term link rules in `rune validate` to `docs/decisions` and `runes` of a deck, and to the record and rune directories of a module, when the root has an ontology. - Add `rune spec fix --terms`: adds a draft ontology entry for every term link that names no term and writes its page. @@ -227,6 +228,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- Fix the bundle check to accept a reference-style term link into the repository's glossary, the form the assembler strips before deployment. - Read the comment of an ontology term whose closing quote directly precedes the terminating period. The reader dropped that quote, so `rune spec glossary` listed the term with no definition. - Fix `rune run --clean` for codex: the clean `config.toml` keeps every `[model_providers.*]` table with its `auth` and names the one custom provider as the route. - Fix `rune run grok@grok --clean-harness-state` exiting 127 through the `harness-run` shim: the clean run names the real `~/.grok/bin` in `HARNESS_REAL_BIN_DIR` before it moves `HOME`. diff --git a/docs/changes/every-term-cites-definition/design.md b/docs/changes/every-term-cites-definition/design.md index 87053c7b..7df962b0 100644 --- a/docs/changes/every-term-cites-definition/design.md +++ b/docs/changes/every-term-cites-definition/design.md @@ -2,7 +2,7 @@ ## Approach -A term is marked by a Markdown link to its generated glossary page. The *[validator][VALIDATOR]* reads the term address from the page and matches it to the ontology, so the link text and the tag carry no meaning of their own. This beat the italics model, which marked a term with a form no reader could tell from emphasis, and it beat a parenthetical definition at first use, which copies the definition into every file. The fixer is a separate step, because a validator that edits files is no longer a validator. +A term is marked by a Markdown link to its generated glossary page. The *[validator][VALIDATOR]* reads the term address from the page and matches it to the ontology, so the link text and the tag carry no meaning of their own. This beat the italics model, which marked a term with a form no reader could tell from emphasis, and it beat a parenthetical definition at first use, which copies the definition into every file. The fixer is a separate step, because a [validator][VALIDATOR] that edits files is no longer a [validator][VALIDATOR]. ## Structure @@ -21,7 +21,7 @@ One row per requirement of the delta. The requirement names a role. This table n | Defined terms link to the ontology | validate | `rune-spec-doctor` | `spec-term-undefined`, `delta-term-undefined` | 1 | | The ontology is the term source | validate. The ontology is `ontology/rune.ttl` unless `deck.yaml` names another path under `ontology` | | `spec-term-draft`, `delta-term-draft` (warning) | 0 on a warning alone | | A broken ontology fails validation | validate | | none. The error carries no id and names the ontology path | 1 | -| First use of a term is emphasized | validate | | `spec-term-first-use`, `delta-term-first-use` | 1 | +| Every occurrence of a term is a link | validate; `rune spec fix --links` links the plain occurrences | | `spec-term-unlinked`, `spec-term-first-use`, and their `delta-` twins | 1 | | Term pages back every link | validate. Pages live under `docs/specs/glossary/` | `glossary-current` (deck) | `spec-term-page`, `delta-term-page` | 1 | | The legacy form warns | validate | | `spec-term-legacy`, `delta-term-legacy` (warning) | 0 | | Reference definitions follow Markdown | validate | | none | | diff --git a/docs/changes/every-term-cites-definition/proposal.md b/docs/changes/every-term-cites-definition/proposal.md index c6f3ac76..69ed3156 100644 --- a/docs/changes/every-term-cites-definition/proposal.md +++ b/docs/changes/every-term-cites-definition/proposal.md @@ -14,7 +14,7 @@ CLI-0040 made an italic term an error unless `docs/specs/glossary.md` defines it - A term is a Markdown link to its glossary page: emphasized at the first use in a file, plain after it, with a reference definition at the end of the file that points at the page by relative path. Plain italics are emphasis and are not checked. - The term source is the ontology. A repository without one has no terms, so the glossary fallback goes away. A term may carry a draft status. -- The validator reports a link that names no term, a draft term, a first use that is not emphasized or a later use that is, a page that is missing or stale, and the legacy italics form, which still resolves and warns with the new form to write. The design's Commands table names the diagnostic ids. rumdl MD052 and MD053 keep reporting an undefined and an unused tag. +- The validator reports a link that names no term, a draft term, a plain occurrence of a linked term, a first use that is not emphasized or a later use that is, a page that is missing or stale, and the legacy italics form, which still resolves and warns with the new form to write. The design's Commands table names the diagnostic ids. rumdl MD052 and MD053 keep reporting an undefined and an unused tag. - `rune spec glossary` prints the term list with tags and page definitions. - `rune spec fix --terms` adds a draft entry and a page for every link that names no term. `rune spec fix --links` migrates the legacy form in every Markdown file. Both change nothing on a second run. - `rune spec archive` refuses a change whose files link to a draft term or to no term. diff --git a/docs/changes/every-term-cites-definition/specs/lifecycle-of-a-change/spec.md b/docs/changes/every-term-cites-definition/specs/lifecycle-of-a-change/spec.md index e2859433..e572124e 100644 --- a/docs/changes/every-term-cites-definition/specs/lifecycle-of-a-change/spec.md +++ b/docs/changes/every-term-cites-definition/specs/lifecycle-of-a-change/spec.md @@ -7,16 +7,16 @@ A defined term MUST be a Markdown link to its glossary page. This holds in a *[c #### Scenario: Term link names no term - **WHEN** a specification links a term and no ontology term matches the page or the tag -- **THEN** the validator reports the term, the file and line, and the ontology's location +- **THEN** the [validator][VALIDATOR] reports the term, the file and line, and the ontology's location ### Requirement: The ontology is the term source -The validator MUST read every labeled class, property, and concept from the repository's ontology. A repository without an ontology file has no defined terms. A reference link there is an ordinary link and MUST NOT be reported. A term MAY carry a draft status. The validator MUST warn once for each draft term a file links. +The [validator][VALIDATOR] MUST read every labeled class, property, and concept from the repository's ontology. A repository without an ontology file has no defined terms. A reference link there is an ordinary link and MUST NOT be reported. A term MAY carry a draft status. The [validator][VALIDATOR] MUST warn once for each draft term a file links. #### Scenario: Repository without an ontology - **WHEN** the repository has no ontology file and a file carries a reference link -- **THEN** the validator reports nothing for terms +- **THEN** the [validator][VALIDATOR] reports nothing for terms ### Requirement: A broken ontology fails validation @@ -32,32 +32,32 @@ An unreadable ontology, an undeclared prefix, or an ontology with no labeled ter - **WHEN** the class `Change` and the property `change` are both labeled `change` - **THEN** the property's page and tag take a key suffix, the class's stay plain, and each link resolves to the term its page names -### Requirement: First use of a term is emphasized +### Requirement: Every occurrence of a term is a link -The first link to a term in a file MUST be emphasized. Every later link to the same term in that file MUST be plain. The validator MUST report a first use that is not emphasized and a later use that is, with the form to write. The link text MAY be the plural of the label. A file MUST define each tag it uses as a reference definition. The definition MUST point at the term's page by a path relative to the file. The file SHOULD hold each definition once, at its end. +Once a file links a term, every occurrence of that term in the file MUST be a link, so a copied paragraph keeps its links. The first link MUST be emphasized and every later link MUST be plain. The [validator][VALIDATOR] MUST report a plain occurrence of a linked term, a first use that is not emphasized, and a later use that is, with the form to write. The link text MAY be the plural. A file SHOULD define each tag once, at its end, by a path relative to the file. The [validator][VALIDATOR] does not check the placement. -#### Scenario: First use is plain +#### Scenario: Linked term appears plain later -- **WHEN** a record links a term twice and the first link is not emphasized -- **THEN** the validator reports the line and prints the emphasized form to write +- **WHEN** a record links a term and a later sentence writes the same term as a plain word +- **THEN** the [validator][VALIDATOR] reports the line and prints the link to write ### Requirement: Term pages back every link -Every term link MUST reach a glossary page whose bytes equal the generator's render of that term. The validator MUST report a page that is missing and a page whose bytes differ from the render. It MUST also report a tag the file uses but never defines. Each report MUST name the definition line or the regeneration step that repairs it. +Every term link MUST reach a glossary page whose bytes equal the generator's render of that term. The [validator][VALIDATOR] MUST report a page that is missing and a page whose bytes differ from the render. It MUST also report a tag the file uses but never defines. Each report MUST name the definition line or the regeneration step that repairs it. #### Scenario: Page missing for a defined term - **WHEN** a file links a term the ontology defines and no page exists for it -- **THEN** the validator names the page and the step that writes it +- **THEN** the [validator][VALIDATOR] names the page and the step that writes it ### Requirement: The legacy form warns -An italic term with its tag after it, and a definition that names the term address instead of a page, is the legacy form. The validator MUST still resolve it, MUST warn, and MUST name the link form and the relative page path to write. A link whose definition names the term address instead of a page MUST be treated the same way. +An italic term with its tag after it, and a definition that names the term address instead of a page, is the legacy form. The [validator][VALIDATOR] MUST still resolve it, MUST warn, and MUST name the link form and the relative page path to write. A link whose definition names the term address instead of a page MUST be treated the same way. #### Scenario: Legacy form in a delta - **WHEN** a delta writes an italic term with its tag after it and defines the tag as the term address -- **THEN** the validator warns and prints the emphasized link and the page definition to write +- **THEN** the [validator][VALIDATOR] warns and prints the emphasized link and the page definition to write ### Requirement: Reference definitions follow Markdown @@ -66,7 +66,7 @@ A tag MUST compare without case at its use and its definition. A definition dest #### Scenario: Lowercase tag with a bracketed destination - **WHEN** a record links a term with a lower-case tag and defines the tag with its destination in angle brackets -- **THEN** the validator reports nothing for the term +- **THEN** the [validator][VALIDATOR] reports nothing for the term ### Requirement: The generator lists every term @@ -79,38 +79,38 @@ On request the generator MUST print one line per ontology term, sorted by label, ### Requirement: The fixer adds draft terms -The fixer is a separate step the author invokes or a hook runs before the validator. It MUST add a draft entry to the ontology for every term link that names no term. The entry MUST be labeled with the link text and named by its upper camel case. The fixer MUST write the term's page. It MUST skip a link whose page names no term, whose text gives no name, or whose name already exists under another label. It MUST name the reason for each skip. A second run MUST change nothing. +The fixer is a separate step the author invokes or a hook runs before the [validator][VALIDATOR]. It MUST add a draft entry to the ontology for every term link that names no term. The entry MUST be labeled with the link text and named by its upper camel case. The fixer MUST write the term's page. It MUST skip a link whose page names no term, whose text gives no name, or whose name already exists under another label. It MUST name the reason for each skip. A second run MUST *[change][CHANGE]* nothing. #### Scenario: Link names an undefined term - **WHEN** a record links a term the ontology lacks and the author runs the fixer -- **THEN** the ontology holds a draft entry for the term, its page exists, and the validator warns on the draft instead of failing +- **THEN** the ontology holds a draft entry for the term, its page exists, and the [validator][VALIDATOR] warns on the draft instead of failing ### Requirement: The fixer migrates the legacy form -The fixer MUST rewrite the legacy form to the link form in every Markdown file of the repository. It MUST skip version control trees, archived changes, generated trees, and test fixtures. In each file it MUST emphasize the first use of a term and make later uses plain. It MUST point every definition at the term's page by relative path and add a missing definition. A converted file MUST be left unchanged by a second run. +The fixer MUST rewrite the legacy form to the link form in every Markdown file of the repository. It MUST skip version control trees, archived [changes][CHANGE], generated trees, and test fixtures. In each file it MUST link every plain occurrence of a term the file links, emphasize the first link, and make the later links plain. It MUST point every definition at the term's page by relative path and add a missing definition. A converted file MUST be left unchanged by a second run. #### Scenario: Legacy file is migrated twice - **WHEN** the fixer runs twice over a file in the legacy form -- **THEN** the first run rewrites it to the link form and the second run reports no change +- **THEN** the first run rewrites it to the link form and the second run reports no [change][CHANGE] ### Requirement: Archive refuses unsettled terms -The archiver MUST refuse to merge a *[change][CHANGE]* whose files link to a draft term or to no term. It MUST name each such link by file and line with the step that settles it. Abandoning a change MUST NOT be refused for its terms, because nothing reaches the canonical specifications. +The archiver MUST refuse to merge a [change][CHANGE] whose files link to a draft term or to no term. It MUST name each such link by file and line with the step that settles it. Abandoning a [change][CHANGE] MUST NOT be refused for its terms, because nothing reaches the canonical specifications. #### Scenario: Change links a draft term -- **WHEN** the owner archives a change and one of its files links a term whose entry is a draft +- **WHEN** the owner archives a [change][CHANGE] and one of its files links a term whose entry is a draft - **THEN** the archiver refuses, names the file, the line, and the term, and the canonical specifications stay unchanged ### Requirement: Records and runes are checked -The validator MUST apply the term rules to every Markdown file under the decision records and runes of a repository with a deck marker. In a module root it MUST cover the records, agents, rules, and skills. It MUST do so only when the root has an ontology. A root without one MUST report nothing for terms. It MUST skip provenance, archive, and build trees, fenced code, and a YAML front matter block. +The [validator][VALIDATOR] MUST apply the term rules to every Markdown file under the [decision records][DECISIONRECORD] and [runes][RUNE] of a repository with a deck marker. In a module root it MUST cover the records, agents, rules, and skills. It MUST do so only when the root has an ontology. A root without one MUST report nothing for terms. It MUST skip provenance, archive, and build trees, fenced code, and a YAML front matter block. #### Scenario: Rule links an undefined term -- **WHEN** a rule under the runes directory links a term the ontology lacks +- **WHEN** a rule under the [runes][RUNE] directory links a term the ontology lacks - **THEN** artifact validation fails the terms item and names the file and line [CAPABILITY]: ../../../../specs/glossary/capability.md diff --git a/docs/changes/glossary-renders-from-ontology/specs/glossary-renders-from-ontology/spec.md b/docs/changes/glossary-renders-from-ontology/specs/glossary-renders-from-ontology/spec.md index 2645a557..203dc778 100644 --- a/docs/changes/glossary-renders-from-ontology/specs/glossary-renders-from-ontology/spec.md +++ b/docs/changes/glossary-renders-from-ontology/specs/glossary-renders-from-ontology/spec.md @@ -2,7 +2,7 @@ ### Requirement: The glossary is rendered from the ontology -The glossary is a *[render][RENDER]* of the ontology. The generator MUST write one page per labeled ontology term and one index. They go into the glossary directory beside the canonical specifications, or into a directory the owner names. The index MUST list every page sorted by label with the term's comment. One ontology MUST produce the same bytes on every write. A write MUST remove the page of a term the ontology no longer defines and the single glossary file an older render wrote. +The glossary is a *[render][RENDER]* of the ontology. The generator MUST write one page per labeled ontology term and one index. They go into the glossary directory beside the canonical specifications, or into a directory the owner names. The index MUST list every page sorted by label with the term's comment. One ontology MUST produce the same bytes on every write. A write MUST remove the page of a term the ontology no longer defines and the single glossary file an older [render][RENDER] wrote. #### Scenario: Two writes read one ontology diff --git a/docs/changes/ontology-deploys-with-provenance/proposal.md b/docs/changes/ontology-deploys-with-provenance/proposal.md index c604fc02..f9dfa1b3 100644 --- a/docs/changes/ontology-deploys-with-provenance/proposal.md +++ b/docs/changes/ontology-deploys-with-provenance/proposal.md @@ -8,13 +8,13 @@ status: proposed There is one ontology, the deck's `ontology/rune.ttl`, and every specification that links a term links one it defines. `rune spec validate` and `rune spec doctor` read terms from `ontology/rune.ttl` at the repository root, or from the path the `ontology` key of `deck.yaml` names. A consumer carries no `deck.yaml`, so it has no way to read the deck's ontology today, and a term link in its specifications goes unchecked. Once the copy exists, the link fails with the page diagnostic until the glossary pages are written. The cli repository is the first consumer: its specifications link *[instance][INSTANCE]*, *[provenance][PROVENANCE]*, *[sidecar][SIDECAR]*, and *[target][TARGET]*, which only that file defines. -The deck already deploys runes into a consumer with a fingerprint and a provenance sidecar per file. The ontology is one more file from the same source, and it gets the same record. +The deck already deploys runes into a consumer with a fingerprint and a [provenance][PROVENANCE] [sidecar][SIDECAR] per file. The ontology is one more file from the same source, and it gets the same record. ## What Changes - `.rune` gains a top-level key `ontology: `, naming one of its `sources`. One ontology per consumer. A name that matches no source fails the parse with a message that names the key and the name. -- `rune install` in consumer mode copies that source's ontology, `/ontology/rune.ttl` or the path its `deck.yaml` names under `ontology`, to `/ontology/rune.ttl`, byte for byte. The copy gets `ontology/.provenance/rune.ttl.yaml` and a fingerprint entry in `ontology/.manifest`, through the manifest and sidecar code the provider trees use. A hand-edited copy is skipped as user modified unless `--force` is passed. The source is resolved before any provider tree is written, so a source with no ontology file fails the install with a message that names the key and the source and writes nothing. -- `rune drift` with no mode verifies `ontology/.manifest` beside the provider manifests. `rune drift --target` compares the source ontology with the deployed copy. `rune status` lists `ontology` as a deploy target with its ok and stale counts. +- `rune install` in consumer mode copies that source's ontology, `/ontology/rune.ttl` or the path its `deck.yaml` names under `ontology`, to `/ontology/rune.ttl`, byte for byte. The copy gets `ontology/.provenance/rune.ttl.yaml` and a fingerprint entry in `ontology/.manifest`, through the manifest and [sidecar][SIDECAR] code the provider trees use. A hand-edited copy is skipped as user modified unless `--force` is passed. The source is resolved before any provider tree is written, so a source with no ontology file fails the install with a message that names the key and the source and writes nothing. +- `rune drift` with no mode verifies `ontology/.manifest` beside the provider manifests. `rune drift --target` compares the source ontology with the deployed copy. `rune status` lists `ontology` as a deploy [target][TARGET] with its ok and stale counts. - Nothing changes in the term reader: the copy sits at the default path, so `rune spec validate` in the consumer finds it. - A consumer without the key installs as before. Removing the key leaves the copy in place, because prune does not reach it. @@ -27,7 +27,7 @@ The deck already deploys runes into a consumer with a fingerprint and a provenan - `src/cli/dotrune/parse.rs`, `src/cli/deploy/ontology.rs`, `src/cli/deploy/mod.rs`, `src/cli/drift/mod.rs`, `src/cli/drift/scope.rs`, `src/cli/status.rs`, `src/deck/mod.rs`, `src/cli/spec.rs`. - `tests/install_ontology.rs`, `tests/fixtures/install-ontology/`. - `docs/walkthroughs/Spec.md`, the two changelogs. -- The cli repository becomes a consumer of itself: `.rune` names the deck as a git source and `ontology: deck`, and the deployed copy is committed with its sidecar once the deck's ontology is on origin main. +- The cli repository becomes a consumer of itself: `.rune` names the deck as a git source and `ontology: deck`, and the deployed copy is committed with its [sidecar][SIDECAR] once the deck's ontology is on origin main. [INSTANCE]: ../../specs/glossary/instance.md [PROVENANCE]: ../../specs/glossary/provenance.md diff --git a/docs/changes/ontology-deploys-with-provenance/specs/ontology-deploys-with-provenance/spec.md b/docs/changes/ontology-deploys-with-provenance/specs/ontology-deploys-with-provenance/spec.md index 761ea5d9..9dc16880 100644 --- a/docs/changes/ontology-deploys-with-provenance/specs/ontology-deploys-with-provenance/spec.md +++ b/docs/changes/ontology-deploys-with-provenance/specs/ontology-deploys-with-provenance/spec.md @@ -16,7 +16,7 @@ When a consumer names an ontology source, the deployer MUST copy that ontology, #### Scenario: Consumer cites a deck term - **WHEN** the deployment completes and a delta specification in the consumer cites a term the source ontology defines -- **THEN** validation of that change passes, and the copy, its sidecar, and its manifest entry exist +- **THEN** validation of that change passes, and the copy, its [sidecar][SIDECAR], and its manifest entry exist ### Requirement: Consumer without the key installs as before diff --git a/docs/changes/specs-must-stay-short/specs/lifecycle-of-a-change/spec.md b/docs/changes/specs-must-stay-short/specs/lifecycle-of-a-change/spec.md index 5aa2cf5a..e90d79b0 100644 --- a/docs/changes/specs-must-stay-short/specs/lifecycle-of-a-change/spec.md +++ b/docs/changes/specs-must-stay-short/specs/lifecycle-of-a-change/spec.md @@ -7,7 +7,7 @@ The *[validator][VALIDATOR]* MUST apply the runedeck house rules to every canoni #### Scenario: Legacy keyword in prose - **WHEN** a prose line outside a code fence or inline code span contains `SHALL` -- **THEN** the validator reports a named diagnostic with the line number, and the artifact still parses +- **THEN** the [validator][VALIDATOR] reports a named diagnostic with the line number, and the artifact still parses #### Scenario: Upstream oracle fixture @@ -16,16 +16,16 @@ The *[validator][VALIDATOR]* MUST apply the runedeck house rules to every canoni ### Requirement: House rules run in the commit-stage checks -The commit-stage check MUST run the validator's advisory pass for every change under the specifications or the changes directory. When the environment requires the validator and none is installed, the hook MUST fail. The build quality job, which has the validator built from the same commit, MUST require it. Without that requirement an absent validator MUST skip, so a fresh clone stays usable before the tools are installed. +The commit-stage check MUST run the [validator][VALIDATOR]'s advisory pass for every change under the specifications or the changes directory. When the environment requires the [validator][VALIDATOR] and none is installed, the hook MUST fail. The build quality job, which has the [validator][VALIDATOR] built from the same commit, MUST require it. Without that requirement an absent [validator][VALIDATOR] MUST skip, so a fresh clone stays usable before the tools are installed. #### Scenario: Binary absent in CI -- **WHEN** the commit-stage checks run with the validator required and none on the path +- **WHEN** the commit-stage checks run with the [validator][VALIDATOR] required and none on the path - **THEN** the hook fails instead of skipping #### Scenario: Binary absent on a fresh clone -- **WHEN** the hooks run without the requirement and no validator on the path +- **WHEN** the hooks run without the requirement and no [validator][VALIDATOR] on the path - **THEN** the hook skips so the clone stays usable before the tools are installed ### Requirement: Capabilities stay short @@ -35,12 +35,12 @@ A canonical specification longer than 150 lines MUST fail validation. A delta lo #### Scenario: Specification over the limit - **WHEN** a canonical specification has 151 lines -- **THEN** the validator reports an error that names the limit and asks for a split +- **THEN** the [validator][VALIDATOR] reports an error that names the limit and asks for a split #### Scenario: Specification at the limit - **WHEN** a canonical specification has 150 lines -- **THEN** the validator reports nothing for its length +- **THEN** the [validator][VALIDATOR] reports nothing for its length ### Requirement: Defined terms have a glossary entry @@ -49,12 +49,12 @@ A defined term in a specification or delta MUST be a link to the term's generate #### Scenario: Term link without a page - **WHEN** a specification links a term and the ontology defines no such term -- **THEN** the validator names the term, the file and line, and the ontology to extend +- **THEN** the [validator][VALIDATOR] names the term, the file and line, and the ontology to extend #### Scenario: Plural term - **WHEN** a specification links the plural of a term whose page the definition names -- **THEN** the validator reports nothing for the term +- **THEN** the [validator][VALIDATOR] reports nothing for the term [DELTASPECIFICATION]: ../../../../specs/glossary/delta-specification.md [VALIDATOR]: ../../../../specs/glossary/validator.md diff --git a/rune-docs/src/spec/fix.rs b/rune-docs/src/spec/fix.rs index 4740f886..5a036936 100644 --- a/rune-docs/src/spec/fix.rs +++ b/rune-docs/src/spec/fix.rs @@ -264,6 +264,8 @@ pub(super) fn migrate_links(content: &str, path: &Path, terms: &Terms) -> Option } } + link_every_occurrence(&mut lines, terms, file_dir); + // The first use of each term is emphasized and no later use is. let view = prose(&join(&lines)); let definitions = definitions_of(&view); @@ -316,6 +318,43 @@ pub(super) fn migrate_links(content: &str, path: &Path, terms: &Terms) -> Option (result != content).then_some(result) } +/// Every occurrence of a term the file links is a link: a plain later +/// occurrence of the label takes the tag the file already uses. +fn link_every_occurrence(lines: &mut [String], terms: &Terms, file_dir: &Path) { + let view = prose(&join(lines)); + let definitions = definitions_of(&view); + let mut tags: BTreeMap = BTreeMap::new(); + for (number, line) in &view { + for link in reference_links(*number, line) { + if let Resolution::Term { term, .. } = resolve(terms, &link, &definitions, file_dir) { + tags.entry(term.iri.clone()) + .or_insert_with(|| (term.label.clone(), link.tag.clone())); + } + } + } + for (number, line) in &view { + let mut edits: Vec<(usize, usize, String)> = Vec::new(); + for (label, tag) in tags.values() { + for hit in super::term_links::plain_occurrences(line, label) { + let text = &line[hit.start..hit.end]; + edits.push((hit.start, hit.end, format!("[{text}][{tag}]"))); + } + } + // Where two terms overlap, the longer one is the more specific + // term, so it wins: `missed requirement` over `requirement`. + edits.sort_by_key(|(start, end, _)| (*start, std::cmp::Reverse(*end))); + let mut kept: Vec<(usize, usize, String)> = Vec::new(); + for edit in edits { + if kept.last().is_none_or(|(_, end, _)| edit.0 >= *end) { + kept.push(edit); + } + } + for (start, end, replacement) in kept.into_iter().rev() { + lines[number - 1].replace_range(start..end, &replacement); + } + } +} + fn join(lines: &[String]) -> String { lines.join("\n") } @@ -410,6 +449,52 @@ mod tests { assert_eq!(std::fs::read_to_string(&record).unwrap(), migrated); } + #[test] + fn links_make_every_later_occurrence_of_a_linked_term_a_link() { + let root = repository(); + let path = root + .path() + .join("docs/changes/add-the-gadget-rules/specs/search/spec.md"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + "## ADDED Requirements\n\nA *[change][CHANGE]* MUST name one change. Two changes share a change id.\n\n[CHANGE]: ../../../../specs/glossary/change.md\n", + ) + .unwrap(); + let terms = Terms::load(root.path(), &root.path().join("docs/specs")).unwrap(); + let content = std::fs::read_to_string(&path).unwrap(); + let fixed = migrate_links(&content, &path, &terms).expect("later occurrences change"); + assert!( + fixed.contains( + "MUST name one [change][CHANGE]. Two [changes][CHANGE] share a [change][CHANGE] id." + ), + "{fixed}" + ); + assert!(migrate_links(&fixed, &path, &terms).is_none(), "idempotent"); + } + + #[test] + fn links_prefer_the_longer_term_where_two_overlap() { + let root = repository(); + let turtle = + format!("{TURTLE}\nrune:PlainStage a rdfs:Class ;\n rdfs:label \"stage\" .\n"); + std::fs::write(root.path().join("ontology/rune.ttl"), turtle).unwrap(); + write_glossary_output(root.path().to_str().unwrap(), None).unwrap(); + let record = root.path().join("docs/decisions/Y.md"); + std::fs::write( + &record, + "# Y\n\nA *[lifecycle stage][STAGE]* and a *[stage][PLAINSTAGE]*. The lifecycle stage, then the stage.\n\n[STAGE]: ../specs/glossary/lifecycle-stage.md\n[PLAINSTAGE]: ../specs/glossary/stage.md\n", + ) + .unwrap(); + let terms = Terms::load(root.path(), &root.path().join("docs/specs")).unwrap(); + let content = std::fs::read_to_string(&record).unwrap(); + let fixed = migrate_links(&content, &record, &terms).expect("plain uses change"); + assert!( + fixed.contains("The [lifecycle stage][STAGE], then the [stage][PLAINSTAGE]."), + "{fixed}" + ); + } + #[test] fn links_leaves_ordinary_links_unknown_tags_and_fenced_code_alone() { let root = repository(); diff --git a/rune-docs/src/spec/lint.rs b/rune-docs/src/spec/lint.rs index 3d21fac5..a68cfc20 100644 --- a/rune-docs/src/spec/lint.rs +++ b/rune-docs/src/spec/lint.rs @@ -290,6 +290,8 @@ fn lint_terms( file_dir: target.path.parent().unwrap_or(Path::new("")), seen: BTreeSet::new(), reported: BTreeSet::new(), + tags: BTreeMap::new(), + first_link: BTreeMap::new(), pages: BTreeMap::new(), diagnostics, }; @@ -316,6 +318,7 @@ fn lint_terms( lint.link(&link, &definitions); } } + lint.plain_occurrences(&view); } /// The state of the term rules over one file. @@ -328,6 +331,11 @@ struct TermLint<'a> { seen: BTreeSet, /// Findings reported once per term and rule. reported: BTreeSet<(String, &'static str)>, + /// The tag this file writes for each linked term, first link wins. + tags: BTreeMap, + /// Where the first link of each term sits, as (line, column), so a plain + /// use before it gets the emphasized form as its hint. + first_link: BTreeMap, /// Whether each page read so far matches its render. pages: BTreeMap, diagnostics: &'a mut Vec, @@ -391,6 +399,12 @@ impl TermLint<'_> { ), Resolution::Term { term, via, page } => { let first = self.seen.insert(term.iri.clone()); + self.tags + .entry(term.iri.clone()) + .or_insert_with(|| link.tag.clone()); + self.first_link + .entry(term.iri.clone()) + .or_insert((link.line, link.start)); self.first_use(link, first); if term.draft && self.once(&term.iri, "draft") { self.report( @@ -409,6 +423,74 @@ impl TermLint<'_> { } } + /// Every occurrence of a term in a file is a link. Once a file links a + /// term, a plain later occurrence of its label is a finding. + fn plain_occurrences(&mut self, view: &[(usize, String)]) { + let linked: Vec<(String, String, String)> = self + .seen + .iter() + .filter_map(|iri| self.terms.by_iri(iri)) + .map(|term| { + let tag = self + .tags + .get(&term.iri) + .cloned() + .unwrap_or_else(|| term.name.to_uppercase()); + (term.iri.clone(), term.label.clone(), tag) + }) + .collect(); + for (number, line) in view { + // The same selection as the fixer: where two terms overlap, the + // longer wins, so one span gets one diagnostic. + let mut hits: Vec<(usize, usize, &str, &str, &str)> = linked + .iter() + .flat_map(|(iri, label, tag)| { + super::term_links::plain_occurrences(line, label) + .into_iter() + .map(move |hit| { + ( + hit.start, + hit.end, + iri.as_str(), + label.as_str(), + tag.as_str(), + ) + }) + }) + .collect(); + hits.sort_by_key(|(start, end, ..)| (*start, std::cmp::Reverse(*end))); + let mut last_end = 0; + for (start, end, iri, label, tag) in hits { + if start < last_end { + continue; + } + last_end = end; + let text = &line[start..end]; + let before_first = + self.first_link + .get(iri) + .is_some_and(|(first_line, first_start)| { + (*number, start) < (*first_line, *first_start) + }); + let form = if before_first { + format!("*[{text}][{tag}]*, which becomes the first use") + } else { + format!("[{text}][{tag}]") + }; + if self.once(&format!("{iri}:{number}:{start}"), "unlinked") { + self.report( + "unlinked", + DiagnosticSeverity::Error, + *number, + format!( + "'{text}' is the term '{label}' and this file links it elsewhere; every occurrence links, write `{form}`" + ), + ); + } + } + } + } + /// The first use of a term in a file is `*[text][TAG]*`; every later /// use is `[text][TAG]`. fn first_use(&mut self, link: &TermLink, first: bool) { diff --git a/rune-docs/src/spec/lint/tests.rs b/rune-docs/src/spec/lint/tests.rs index 012d7d85..01f93d97 100644 --- a/rune-docs/src/spec/lint/tests.rs +++ b/rune-docs/src/spec/lint/tests.rs @@ -590,6 +590,41 @@ fn front_matter_fences_and_images_are_not_term_links() { assert!(front.is_empty(), "{front:?}"); } +#[test] +fn a_plain_occurrence_of_a_linked_term_is_reported_with_the_form_to_write() { + let root = ontology_root(); + let path = root.path().join("docs/decisions/X.md"); + let terms = Terms::load(root.path(), &root.path().join("docs/specs")).unwrap(); + let mut diagnostics = Vec::new(); + lint_document( + LintTarget { + repository: root.path(), + path: &path, + capability: None, + change: None, + }, + "# X\n\nA harness first. Then *[harness][HARNESS]* and two harnesses.\n\n[HARNESS]: ../specs/glossary/harness.md\n", + &terms, + &mut diagnostics, + ); + let unlinked: Vec<&str> = diagnostics + .iter() + .filter(|d| d.code == "spec-term-unlinked") + .map(|d| d.message.as_str()) + .collect(); + assert_eq!(unlinked.len(), 2, "{diagnostics:?}"); + assert!( + unlinked[0].contains("write `*[harness][HARNESS]*, which becomes the first use`"), + "{}", + unlinked[0] + ); + assert!( + unlinked[1].contains("write `[harnesses][HARNESS]`"), + "{}", + unlinked[1] + ); +} + #[test] fn a_repository_without_an_ontology_checks_no_links() { let root = TempDir::new().unwrap(); diff --git a/rune-docs/src/spec/prose.rs b/rune-docs/src/spec/prose.rs index 38ffd27d..ff170505 100644 --- a/rune-docs/src/spec/prose.rs +++ b/rune-docs/src/spec/prose.rs @@ -70,16 +70,38 @@ fn front_matter_end(content: &str) -> Option { /// text never matches a rule. fn without_inline_code(line: &str) -> String { let mut result = String::with_capacity(line.len()); - let mut inside = false; - for character in line.chars() { - if character == '`' { - inside = !inside; - result.push(' '); - } else if inside { - result.extend(std::iter::repeat_n(' ', character.len_utf8())); + let chars: Vec = line.chars().collect(); + // A code span opens with a run of backticks and closes with a run of the + // same length, so ``a ` b`` is one span. + let mut open: Option = None; + let mut i = 0; + while i < chars.len() { + if chars[i] == '`' { + let run = chars[i..].iter().take_while(|c| **c == '`').count(); + match open { + Some(width) if width == run => open = None, + Some(_) => {} + None => { + let closes = chars[i + run..].windows(run).enumerate().any(|(j, w)| { + w.iter().all(|c| *c == '`') + && chars.get(i + run + j + run) != Some(&'`') + && (j == 0 || chars[i + run + j - 1] != '`') + }); + if closes { + open = Some(run); + } + } + } + result.extend(std::iter::repeat_n(' ', run)); + i += run; + continue; + } + if open.is_some() { + result.extend(std::iter::repeat_n(' ', chars[i].len_utf8())); } else { - result.push(character); + result.push(chars[i]); } + i += 1; } result } diff --git a/rune-docs/src/spec/term_links.rs b/rune-docs/src/spec/term_links.rs index 0c95da91..ab4d88bf 100644 --- a/rune-docs/src/spec/term_links.rs +++ b/rune-docs/src/spec/term_links.rs @@ -80,6 +80,90 @@ impl TermLink { } } +/// A plain occurrence of a term's label on a prose line: the label or its +/// plural as whole words, outside any link, heading, or inline code (the +/// prose view blanks code), compared without case. Every occurrence of a +/// term in a file is a link, so a plain one is a finding and the fixer +/// links it with the tag the file already uses for the term. +pub(super) struct PlainOccurrence { + pub start: usize, + pub end: usize, +} + +pub(super) fn plain_occurrences(line: &str, label: &str) -> Vec { + if line.trim_start().starts_with('#') || label.is_empty() || is_definition(line) { + return Vec::new(); + } + // Lower-case per character, keeping every byte offset: a character whose + // lower-case form has another length (`İ`, the Kelvin sign) stays as it is. + let lower: String = line + .chars() + .map(|c| { + let mut lowered = c.to_lowercase(); + match (lowered.next(), lowered.next()) { + (Some(l), None) if l.len_utf8() == c.len_utf8() => l, + _ => c, + } + }) + .collect(); + let needle = label.to_lowercase(); + let mut masked = vec![false; line.len()]; + for link in reference_links(1, line) { + masked[link.start..link.end].fill(true); + } + let mut depth = 0usize; + // Inside the destination of an inline link `[text](path)`, which is not + // prose; the count tracks balanced parentheses within it. + let mut destination = 0usize; + let bytes = line.as_bytes(); + for (i, byte) in bytes.iter().enumerate() { + match byte { + b'[' if destination == 0 => depth += 1, + b']' if destination == 0 => { + depth = depth.saturating_sub(1); + if depth == 0 && bytes.get(i + 1) == Some(&b'(') { + destination = 1; + } + } + b'(' if destination > 0 && i > 0 && bytes[i - 1] != b']' => destination += 1, + b')' if destination > 0 => { + masked[i] = true; + destination -= 1; + } + _ => {} + } + if depth > 0 || destination > 0 { + masked[i] = true; + } + } + let mut found = Vec::new(); + let mut from = 0; + while let Some(at) = lower[from..].find(&needle) { + let start = from + at; + let mut end = start + needle.len(); + // The plural: `changes`, `harnesses`, `statuses`. A label that ends + // in s takes `es` only. + if lower[end..].starts_with("es") { + end += 2; + } else if lower[end..].starts_with('s') && !needle.ends_with('s') { + end += 1; + } + let before = lower[..start].chars().next_back(); + let after = lower[end..].chars().next(); + let boundary = + |c: Option| c.is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '-')); + if boundary(before) + && boundary(after) + && !masked[start..end].iter().any(|m| *m) + && !lower[..start].ends_with('*') + { + found.push(PlainOccurrence { start, end }); + } + from = end.max(start + 1); + } + found +} + /// The reference-style links of one prose line, in order. An image /// `![alt][TAG]` is not a link to a page. pub(super) fn reference_links(number: usize, line: &str) -> Vec { @@ -584,6 +668,38 @@ mod tests { ); } + #[test] + fn plain_occurrences_skip_links_headings_code_and_partial_words() { + let line = "A [change][CHANGE] and a change, two changes, no changed, masked."; + let hits: Vec<&str> = plain_occurrences(line, "change") + .into_iter() + .map(|hit| &line[hit.start..hit.end]) + .collect(); + assert_eq!(hits, vec!["change", "changes"]); + assert!(plain_occurrences("## The change", "change").is_empty()); + assert!(plain_occurrences("*[change][CHANGE]* only", "change").is_empty()); + let inline = "See [the design](../changes/x/design.md) for the change."; + let hits: Vec<&str> = plain_occurrences(inline, "change") + .into_iter() + .map(|hit| &inline[hit.start..hit.end]) + .collect(); + assert_eq!(hits, vec!["change"]); + let nested = "See [x](dir(foo)/change.md) then a change."; + assert_eq!(plain_occurrences(nested, "change").len(), 1); + let plural = "The harnesses and the harness."; + let hits: Vec<&str> = plain_occurrences(plural, "harness") + .into_iter() + .map(|hit| &plural[hit.start..hit.end]) + .collect(); + assert_eq!(hits, vec!["harnesses", "harness"]); + let unicode = "İ change. K change."; + let hits: Vec<&str> = plain_occurrences(unicode, "change") + .into_iter() + .map(|hit| &unicode[hit.start..hit.end]) + .collect(); + assert_eq!(hits, vec!["change", "change"]); + } + #[test] fn a_page_iri_comes_from_the_front_matter() { assert_eq!( diff --git a/src/assemble/references.rs b/src/assemble/references.rs index 2dedccc2..828aae91 100644 --- a/src/assemble/references.rs +++ b/src/assemble/references.rs @@ -1,17 +1,21 @@ use std::collections::HashSet; -/// Remove reference-style link definitions (`[1]: url`, `[MADR]: url`) and -/// the inline markers (` [1]`, ` [MADR]`) that point at them. Only markers +/// Remove reference-style link definitions (`[1]: url`, `[MADR]: url`), +/// the inline markers (` [1]`, ` [MADR]`) that point at them, and the term +/// links (`[validator][VALIDATOR]`), which keep their text. Only markers /// whose label is actually defined are touched, so unrelated bracketed prose /// (`Use [optional]`, `> [!NOTE]`) survives. pub fn strip(content: &str) -> String { static INLINE_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static LINK_RE: std::sync::OnceLock = std::sync::OnceLock::new(); static DEF_RE: std::sync::OnceLock = std::sync::OnceLock::new(); let had_newline = content.ends_with('\n'); let inline_re = INLINE_RE.get_or_init(|| regex::Regex::new(r" \[([\w][\w-]*)\]").expect("valid regex")); + let link_re = LINK_RE + .get_or_init(|| regex::Regex::new(r"\[([^\]\n]+)\]\[([\w][\w-]*)\]").expect("valid regex")); let def_re = DEF_RE.get_or_init(|| regex::Regex::new(r"^\[([\w][\w-]*)\]:").expect("valid regex")); @@ -42,8 +46,15 @@ pub fn strip(content: &str) -> String { continue; } removed_definition = false; + let unlinked = link_re.replace_all(line, |caps: ®ex::Captures<'_>| { + if defined.contains(caps.get(2).expect("tag group").as_str()) { + caps.get(1).expect("text group").as_str().to_string() + } else { + caps.get(0).expect("whole match").as_str().to_string() + } + }); let cleaned = inline_re - .replace_all(line, |caps: ®ex::Captures<'_>| { + .replace_all(&unlinked, |caps: ®ex::Captures<'_>| { if defined.contains(caps.get(1).expect("label group").as_str()) { String::new() } else { diff --git a/src/assemble/tests.rs b/src/assemble/tests.rs index 536cceae..e0287884 100644 --- a/src/assemble/tests.rs +++ b/src/assemble/tests.rs @@ -210,6 +210,16 @@ fn strip_removes_both_markers_and_definitions() { assert_eq!(result, "Paragraph here.\n"); } +#[test] +fn strip_unlinks_term_links_and_keeps_their_text() { + let content = "The *[validator][VALIDATOR]* reads it. A [validator][VALIDATOR] again, and [plain][OTHER].\n\n[VALIDATOR]: ../../docs/specs/glossary/validator.md\n"; + let result = references::strip(content); + assert_eq!( + result, + "The *validator* reads it. A validator again, and [plain][OTHER].\n" + ); +} + #[test] fn strip_preserves_content_without_refs() { let result = references::strip(PLAIN_NO_REFS); diff --git a/src/manifest/bundle.rs b/src/manifest/bundle.rs index bc3a0c46..cddfb551 100644 --- a/src/manifest/bundle.rs +++ b/src/manifest/bundle.rs @@ -494,6 +494,15 @@ fn check_references( )); continue; }; + // Only a reference-style link is a term link: the assembler strips + // that form before deployment, and an inline link survives it. + if matches!( + link_type, + LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut + ) && is_term_link(&path) + { + continue; + } let relative = Path::new(document) .parent() .unwrap_or(Path::new("")) @@ -528,6 +537,30 @@ fn check_references( } } +/// A term link points at a glossary page the repository generates from its +/// ontology: a climb out of the skill, then `docs/specs/glossary/.md`. +/// The assembler strips term links before an instance is deployed, so the +/// deployed bundle never carries one, and the source bundle may. +fn is_term_link(path: &str) -> bool { + let mut components = path.split('/').skip_while(|component| *component == ".."); + let Some(page) = (components.next() == Some("docs") + && components.next() == Some("specs") + && components.next() == Some("glossary")) + .then(|| components.next()) + .flatten() else { + return false; + }; + // A generated page name is kebab case, so a backslash, a dot segment, + // or an upper-case letter never passes, however it was encoded. + let page_name = page.strip_suffix(".md").unwrap_or(""); + components.next().is_none() + && !page_name.is_empty() + && page_name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && path.starts_with("../") +} + fn is_external_reference(destination: &str) -> bool { if destination.starts_with("//") { return true; diff --git a/src/manifest/bundle/tests.rs b/src/manifest/bundle/tests.rs index 0996d194..8bf66137 100644 --- a/src/manifest/bundle/tests.rs +++ b/src/manifest/bundle/tests.rs @@ -203,6 +203,36 @@ fn escaping_reference_is_rejected_even_when_destination_exists() { ); } +#[test] +fn term_link_into_the_repository_glossary_is_not_an_escape() { + let parent = TempDir::new().unwrap(); + let root = parent.path().join("skill"); + fs::create_dir(&root).unwrap(); + write( + &root, + "SKILL.md", + b"# Alpha\nThe *[validator][VALIDATOR]* reads it.\n\n[VALIDATOR]: ../../../../docs/specs/glossary/validator.md\n", + ); + let inventory = inspect_bundle(&root); + assert!( + inventory.problems.is_empty(), + "a term link is stripped by the assembler and is no escape: {:?}", + inventory.problems + ); + for escape in [ + b"# Alpha\n[escape](../../docs/specs/glossary/)\n".as_slice(), + b"# Alpha\n[escape](../../docs/specs/glossary/..%5c..%5c..%5csecret.md)\n", + b"# Alpha\n[escape](../../docs/specs/glossary/Secret.MD)\n", + ] { + write(&root, "SKILL.md", escape); + has_problem( + &inspect_bundle(&root), + BundleProblemKind::EscapingReference, + "SKILL.md", + ); + } +} + #[test] fn inaccessible_root_never_produces_a_complete_digest() { let root = TempDir::new().unwrap(); From 122fe718e9e19b4168e094de9d63b67563c568de Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Mon, 19 Oct 2026 18:01:05 +0200 Subject: [PATCH 2/4] open-seal: {"repo":"runedeck/cli","base":"main","pull_request":78,"tree":"28ba950e0d33d04f042963cd0f5e437988eb0ffe","nonce":"4507bb490572c777f347cdea904deabcfc9919605bd058a822fd21a628c06f7b"} From 88ebc9afe5e97035ce77331d39293b2ac0ab39b6 Mon Sep 17 00:00:00 2001 From: "Claude Fable 5.1 (claude-fable-5-1)" Date: Mon, 19 Oct 2026 18:41:58 +0200 Subject: [PATCH 3/4] fix(assemble): strip a term link whatever its tag case, and never inside code Review findings on the pull request. Markdown resolves a reference label without case, so a `[validator][validator]` link with a `[VALIDATOR]:` definition passed the validator and survived assembly as a dangling reference; the stripper now matches tags without case. It also rewrote `[x][TAG]` inside a fenced block or a code span, where the term linter deliberately looks away; the stripper now walks fences and code spans and touches prose only. The bundle check accepted a collapsed or shortcut reference into the glossary, which the stripper never rewrites, so a deployed instance kept a bare `[]`; it now accepts the full `[text][TAG]` form alone. Co-Authored-By: Martin Zeman --- CHANGELOG.md | 4 +- src/assemble/references.rs | 127 +++++++++++++++++++++++++++++------ src/assemble/tests.rs | 10 +++ src/manifest/bundle.rs | 12 ++-- src/manifest/bundle/tests.rs | 2 + 5 files changed, 128 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b2b81e..817ed41d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -228,7 +228,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed -- Fix the bundle check to accept a reference-style term link into the repository's glossary, the form the assembler strips before deployment. +- Fix the bundle check to accept a full reference-style term link (`[text][TAG]`) into the repository's glossary, the one form the assembler strips before deployment. +- Check a collapsed or shortcut reference into the glossary like any other reference, because assembly does not strip it. +- Strip a term link whatever the case of its tag, and leave a fenced block or a code span untouched, so an example of the syntax survives assembly. - Read the comment of an ontology term whose closing quote directly precedes the terminating period. The reader dropped that quote, so `rune spec glossary` listed the term with no definition. - Fix `rune run --clean` for codex: the clean `config.toml` keeps every `[model_providers.*]` table with its `auth` and names the one custom provider as the route. - Fix `rune run grok@grok --clean-harness-state` exiting 127 through the `harness-run` shim: the clean run names the real `~/.grok/bin` in `HARNESS_REAL_BIN_DIR` before it moves `HOME`. diff --git a/src/assemble/references.rs b/src/assemble/references.rs index 828aae91..a1d181a7 100644 --- a/src/assemble/references.rs +++ b/src/assemble/references.rs @@ -4,7 +4,9 @@ use std::collections::HashSet; /// the inline markers (` [1]`, ` [MADR]`) that point at them, and the term /// links (`[validator][VALIDATOR]`), which keep their text. Only markers /// whose label is actually defined are touched, so unrelated bracketed prose -/// (`Use [optional]`, `> [!NOTE]`) survives. +/// (`Use [optional]`, `> [!NOTE]`) survives. Labels match without case, as +/// Markdown resolves them, and nothing inside a fenced block or a code span +/// changes, so an example of the syntax stays an example. pub fn strip(content: &str) -> String { static INLINE_RE: std::sync::OnceLock = std::sync::OnceLock::new(); static LINK_RE: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -19,19 +21,32 @@ pub fn strip(content: &str) -> String { let def_re = DEF_RE.get_or_init(|| regex::Regex::new(r"^\[([\w][\w-]*)\]:").expect("valid regex")); - let defined: HashSet<&str> = content - .lines() - .filter_map(|line| def_re.captures(line)) - .map(|caps| caps.get(1).expect("label group").as_str()) + let prose_lines: Vec<(bool, &str)> = mark_fences(content); + let defined: HashSet = prose_lines + .iter() + .filter(|(in_fence, _)| !in_fence) + .filter_map(|(_, line)| def_re.captures(line)) + .map(|caps| { + caps.get(1) + .expect("label group") + .as_str() + .to_ascii_uppercase() + }) .collect(); if defined.is_empty() { return content.to_string(); } + let is_defined = |label: &str| defined.contains(&label.to_ascii_uppercase()); let mut output_lines: Vec = Vec::new(); let mut removed_definition = false; - for line in content.lines() { + for (in_fence, line) in prose_lines { + if in_fence { + removed_definition = false; + output_lines.push(line.to_string()); + continue; + } if def_re.is_match(line) { removed_definition = true; continue; @@ -46,22 +61,24 @@ pub fn strip(content: &str) -> String { continue; } removed_definition = false; - let unlinked = link_re.replace_all(line, |caps: ®ex::Captures<'_>| { - if defined.contains(caps.get(2).expect("tag group").as_str()) { - caps.get(1).expect("text group").as_str().to_string() - } else { - caps.get(0).expect("whole match").as_str().to_string() - } - }); - let cleaned = inline_re - .replace_all(&unlinked, |caps: ®ex::Captures<'_>| { - if defined.contains(caps.get(1).expect("label group").as_str()) { - String::new() + let cleaned = map_prose(line, |prose| { + let unlinked = link_re.replace_all(prose, |caps: ®ex::Captures<'_>| { + if is_defined(caps.get(2).expect("tag group").as_str()) { + caps.get(1).expect("text group").as_str().to_string() } else { caps.get(0).expect("whole match").as_str().to_string() } - }) - .to_string(); + }); + inline_re + .replace_all(&unlinked, |caps: ®ex::Captures<'_>| { + if is_defined(caps.get(1).expect("label group").as_str()) { + String::new() + } else { + caps.get(0).expect("whole match").as_str().to_string() + } + }) + .to_string() + }); output_lines.push(cleaned); } @@ -74,6 +91,78 @@ pub fn strip(content: &str) -> String { result } +/// Each line with whether it sits inside a fenced code block. A fence opens +/// with three or more backticks or tildes and closes with a run of the same +/// character at least as long. +fn mark_fences(content: &str) -> Vec<(bool, &str)> { + let mut fence: Option<(char, usize)> = None; + content + .lines() + .map(|line| { + let trimmed = line.trim_start(); + let marker = trimmed.chars().next().filter(|c| *c == '`' || *c == '~'); + let run = marker.map_or(0, |c| trimmed.chars().take_while(|d| *d == c).count()); + match fence { + Some((open, width)) + if marker == Some(open) && run >= width && trimmed[run..].trim().is_empty() => + { + fence = None; + (true, line) + } + Some(_) => (true, line), + None if run >= 3 => { + fence = marker.map(|c| (c, run)); + (true, line) + } + None => (false, line), + } + }) + .collect() +} + +/// Apply `f` to the prose segments of a line and keep its code spans as +/// they are. A span opens with a run of backticks and closes with a run of +/// the same length. +fn map_prose(line: &str, f: impl Fn(&str) -> String) -> String { + let chars: Vec<(usize, char)> = line.char_indices().collect(); + let mut out = String::with_capacity(line.len()); + let mut prose_start = 0; + let mut i = 0; + while i < chars.len() { + if chars[i].1 != '`' { + i += 1; + continue; + } + let run = chars[i..].iter().take_while(|(_, c)| *c == '`').count(); + let mut j = i + run; + let mut close = None; + while j < chars.len() { + if chars[j].1 == '`' { + let width = chars[j..].iter().take_while(|(_, c)| *c == '`').count(); + if width == run { + close = Some(j + width); + break; + } + j += width; + } else { + j += 1; + } + } + let Some(end) = close else { + i += run; + continue; + }; + let span_start = chars[i].0; + let span_end = chars.get(end).map_or(line.len(), |(b, _)| *b); + out.push_str(&f(&line[prose_start..span_start])); + out.push_str(&line[span_start..span_end]); + prose_start = span_end; + i = end; + } + out.push_str(&f(&line[prose_start..])); + out +} + /// Extract reference-style link URLs from content. /// /// Parses lines matching `[N]: ` and returns the URLs diff --git a/src/assemble/tests.rs b/src/assemble/tests.rs index e0287884..17e93b0c 100644 --- a/src/assemble/tests.rs +++ b/src/assemble/tests.rs @@ -220,6 +220,16 @@ fn strip_unlinks_term_links_and_keeps_their_text() { ); } +#[test] +fn strip_matches_tags_without_case_and_leaves_code_alone() { + let content = "A *[validator][validator]* reads.\n\nShow `[validator][VALIDATOR]` and ``a ` [validator][VALIDATOR]``.\n\n```md\nThe [validator][VALIDATOR] stays.\n[VALIDATOR]: kept.md\n```\n\n[VALIDATOR]: ../../docs/specs/glossary/validator.md\n"; + let result = references::strip(content); + assert_eq!( + result, + "A *validator* reads.\n\nShow `[validator][VALIDATOR]` and ``a ` [validator][VALIDATOR]``.\n\n```md\nThe [validator][VALIDATOR] stays.\n[VALIDATOR]: kept.md\n```\n" + ); +} + #[test] fn strip_preserves_content_without_refs() { let result = references::strip(PLAIN_NO_REFS); diff --git a/src/manifest/bundle.rs b/src/manifest/bundle.rs index cddfb551..14aeec61 100644 --- a/src/manifest/bundle.rs +++ b/src/manifest/bundle.rs @@ -494,13 +494,11 @@ fn check_references( )); continue; }; - // Only a reference-style link is a term link: the assembler strips - // that form before deployment, and an inline link survives it. - if matches!( - link_type, - LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut - ) && is_term_link(&path) - { + // Only the full reference form `[text][TAG]` is a term link: the + // assembler strips that form to its text before deployment. An + // inline, collapsed, or shortcut link survives assembly, so it is + // checked like any other reference. + if link_type == LinkType::Reference && is_term_link(&path) { continue; } let relative = Path::new(document) diff --git a/src/manifest/bundle/tests.rs b/src/manifest/bundle/tests.rs index 8bf66137..d51bb467 100644 --- a/src/manifest/bundle/tests.rs +++ b/src/manifest/bundle/tests.rs @@ -223,6 +223,8 @@ fn term_link_into_the_repository_glossary_is_not_an_escape() { b"# Alpha\n[escape](../../docs/specs/glossary/)\n".as_slice(), b"# Alpha\n[escape](../../docs/specs/glossary/..%5c..%5c..%5csecret.md)\n", b"# Alpha\n[escape](../../docs/specs/glossary/Secret.MD)\n", + b"# Alpha\n[validator][]\n\n[validator]: ../../docs/specs/glossary/validator.md\n", + b"# Alpha\n[validator]\n\n[validator]: ../../docs/specs/glossary/validator.md\n", ] { write(&root, "SKILL.md", escape); has_problem( From 1cb98f47f0efdef950a9417243556e3c07b6c3c2 Mon Sep 17 00:00:00 2001 From: "Claude Fable 5.1 (claude-fable-5-1)" Date: Mon, 19 Oct 2026 18:44:00 +0200 Subject: [PATCH 4/4] fix(spec): the matcher skips a URL or a tag, takes ies plurals, and says so in the help Review findings on the deck pull request that apply to the cli matcher too. A term inside a bare URL, an autolink, or an HTML tag is not prose, so it is masked. A label that ends in a consonant and y pluralizes to ies, so `capabilities` and `repositories` are occurrences of their terms. The `--links` help now says it links every occurrence, which is the sentence the deck fallback reads before it hands the check over to an installed rune. Co-Authored-By: Martin Zeman --- rune-docs/src/spec/term_links.rs | 49 ++++++++++++++++++++++++++++---- src/cli/mod.rs | 7 +++-- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/rune-docs/src/spec/term_links.rs b/rune-docs/src/spec/term_links.rs index ab4d88bf..8010d64a 100644 --- a/rune-docs/src/spec/term_links.rs +++ b/rune-docs/src/spec/term_links.rs @@ -47,6 +47,11 @@ static LEGACY_LINK: LazyLock = LazyLock::new(|| { .expect("legacy link regex is valid") }); /// A reference definition, `[TAG]: `, indented up to three spaces. +static NOT_PROSE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)\b[a-z][a-z0-9+.-]*://[^\s<>\])]+|<[^<>\n]*>") + .expect("not-prose regex is valid") +}); + static REFERENCE_DEFINITION: LazyLock = LazyLock::new(|| { Regex::new(r"^ {0,3}\[([A-Za-z0-9][A-Za-z0-9_-]*)\]:\s*(\S+)") .expect("reference definition regex is valid") @@ -111,6 +116,11 @@ pub(super) fn plain_occurrences(line: &str, label: &str) -> Vec for link in reference_links(1, line) { masked[link.start..link.end].fill(true); } + // A bare URL and anything in angle brackets (an autolink, an HTML tag + // with its attributes) are not prose. + for span in NOT_PROSE.find_iter(line) { + masked[span.start()..span.end()].fill(true); + } let mut depth = 0usize; // Inside the destination of an inline link `[text](path)`, which is not // prose; the count tracks balanced parentheses within it. @@ -136,14 +146,34 @@ pub(super) fn plain_occurrences(line: &str, label: &str) -> Vec masked[i] = true; } } + // A label that ends in a consonant and y pluralizes to `ies` + // (`capabilities`), so the search runs on the stem and accepts both + // endings. + let y_plural = needle.len() > 1 + && needle.ends_with('y') + && !needle[..needle.len() - 1].ends_with(['a', 'e', 'i', 'o', 'u']); + let stem = if y_plural { + &needle[..needle.len() - 1] + } else { + needle.as_str() + }; let mut found = Vec::new(); let mut from = 0; - while let Some(at) = lower[from..].find(&needle) { + while let Some(at) = lower[from..].find(stem) { let start = from + at; - let mut end = start + needle.len(); - // The plural: `changes`, `harnesses`, `statuses`. A label that ends - // in s takes `es` only. - if lower[end..].starts_with("es") { + let mut end = start + stem.len(); + if y_plural { + if lower[end..].starts_with("ies") { + end += 3; + } else if lower[end..].starts_with('y') { + end += 1; + } else { + from = end.max(start + 1); + continue; + } + } else if lower[end..].starts_with("es") { + // The plural: `changes`, `harnesses`, `statuses`. A label that + // ends in s takes `es` only. end += 2; } else if lower[end..].starts_with('s') && !needle.ends_with('s') { end += 1; @@ -692,6 +722,15 @@ mod tests { .map(|hit| &plural[hit.start..hit.end]) .collect(); assert_eq!(hits, vec!["harnesses", "harness"]); + let plural_y = "Two capabilities, one capability, no capabilitys."; + let hits: Vec<&str> = plain_occurrences(plural_y, "capability") + .into_iter() + .map(|hit| &plural_y[hit.start..hit.end]) + .collect(); + assert_eq!(hits, vec!["capabilities", "capability"]); + let not_prose = + "See https://example.com/harness and harness, then a harness."; + assert_eq!(plain_occurrences(not_prose, "harness").len(), 2); let unicode = "İ change. K change."; let hits: Vec<&str> = plain_occurrences(unicode, "change") .into_iter() diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5e9687f1..35ca52d8 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1038,9 +1038,10 @@ enum SpecAction { terms: bool, /// Rewrite the legacy `*term* [TAG]` form to `*[term][TAG]*` in the - /// Markdown files under the source root, emphasize only the first - /// use of a term in a file, point every definition at the term's - /// page, and add a missing definition. The walk skips version + /// Markdown files under the source root, link every occurrence of a + /// term the file links, emphasize only the first use of a term in a + /// file, point every definition at the term's page, and add a + /// missing definition. The walk skips version /// control, build, dependency, and fixture trees, a deployed tree /// (a directory with a `.manifest`), the change archive, and the /// glossary. Idempotent on converted files.