From db65278bac2e1a94d97660313e13f7fbe83d29c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 10:28:42 +0100 Subject: [PATCH 01/21] =?UTF-8?q?docs(adr):=20draft=20ADR=200016=20?= =?UTF-8?q?=E2=80=94=20reuse=20is=20a=20link,=20not=20a=20Background?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proposes a reference block: a paragraph, blockquote or list item whose entire content is a single link to an oath section inlines that section's steps at that point. Covers shared arrange, mid-example act and shared assertions, within a file or across files. Draft status — the open questions (whole-project inbound index, drift liveness, report shape) are unresolved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- doc/adr/0016-reuse-is-a-link.md | 356 ++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 doc/adr/0016-reuse-is-a-link.md diff --git a/doc/adr/0016-reuse-is-a-link.md b/doc/adr/0016-reuse-is-a-link.md new file mode 100644 index 00000000..adb45e6c --- /dev/null +++ b/doc/adr/0016-reuse-is-a-link.md @@ -0,0 +1,356 @@ +# ADR 0016 — Reuse is a link: reference blocks instead of `Background` + +- **Status:** Draft +- **Date:** 2026-09-11 +- **Deciders:** Aslak Hellesøy +- **Tags:** spec, parsing, gfm, reuse, cross-language + +## Context + +Varar has no equivalent of Cucumber's `Background:`. The migration guide says so +plainly — "inline its steps into the examples that need them" — and for a large +class of oaths that is the right answer, because a repeated block of setup is +usually a step nobody wrote yet: + +```markdown +I create a user "maya". I verify her email. I give her a library card. +I add *Dune* to the catalogue. I add *Emma* to the catalogue. +``` + +wants to be one sentence at the altitude the reader actually cares about: + +```markdown +Maya has a card at a library holding *Dune* and *Emma*. +``` + +`thin-steps` and `varar-overview` already make that argument, and any reuse +feature risks licensing low-altitude setup that should have been collapsed into +a single stimulus. That is the main reason this has stayed unbuilt. + +There is a residue the altitude argument does not cover: + +- setup that genuinely is several *distinct* facts, and that the reader must + **see** to judge the example — collapsing it into one sentence hides the world + state the oath is about; +- setup shared across several oath **files**, where a step definition is the + only shared thing and the prose is copy-pasted; +- world states that deserve a name and a definition of their own — "a stocked + library", "a tenant mid-trial" — that today exist only as a habit. + +`Background:` answers only the narrowest version of this: prepend, one per +feature file, same file only, invisible in any individual scenario, and dead +weight that executes only as a prefix and can never be read or run on its own. + +What Markdown already gives us, and Gherkin never had, is **a link**. GFM slugs +every heading into an anchor; a link to one renders on GitHub, is clickable, and +means something to a human reader before any tool touches it. And ADR 0012 gave +us a block-level grouping rule with exactly three delimiters, into which a +fourth block *role* — neither example nor prose — fits without new implicit +adjacency machinery. + +## Decision + +**A block whose entire content is a single link to an oath section is a +*reference block*: it inlines that section's steps at that point, into the +example being built.** + +Nothing else. It is a link, not a keyword; it is block structure, not inline +text (ADR 0012 and `markup-is-yours`); and it is positional, so it is not +restricted to the top of an example. + +### What is a reference block + +A paragraph, blockquote, or list item whose content is exactly one link, whose +target is either + +- a **fragment** — `#a-stocked-library` — resolved within the same oath, or +- a **relative `.md` path**, optionally with a fragment — + `./shared/library.md#a-stocked-library` (no fragment = the whole file). + +All three spellings mean the same thing; the blockquote reads as a callout on +GitHub and the list form groups several: + +```markdown +[A stocked library](#a-stocked-library) + +> [A stocked library](./shared/library.md#a-stocked-library) + +- [A stocked library](./shared/library.md#a-stocked-library) +- [Fees are enabled](./shared/billing.md#fees-are-enabled) +``` + +A link-only block with any **other** target — `https://…`, a `.ts` file, a mail +link — is **prose**, exactly as today. This keeps `See [the docs](https://…).` +working and confines the new meaning to targets that can only be oath sections. + +Anchors resolve by **GFM heading slug**. A section is a heading plus everything +up to the next heading of the same or higher level — the same outline the +`scopeStack` already walks. + +### What it does + +The referenced section's **planned steps**, in order, are spliced into the +current example at the reference block's position, sharing its state. Concretely: + +- A reference block is **not a delimiter**. It neither opens nor closes an + example; a matching paragraph after one continues the same example regardless + of `precededByDelimiter`. +- A reference block with no open example **starts** one. +- Prose, headings and thematic breaks *inside* the referenced section apply + there, not here: the referenced section is planned in its own document and + contributes the steps of the example(s) it contains. A section containing more + than one example splices them in document order as one merged step list. +- Tables and doc strings attached to the referenced steps travel with them. + +Because it is positional, the same construct covers three shapes `Background:` +cannot express. Shared arrange: + +```markdown +## Late fees + +[A stocked library](#a-stocked-library) + +Maya borrows *Emma* on May 25, 2026, due June 1, 2026. +She returns it on June 6, 2026 and owes a £2.50 late fee. +``` + +Shared act, mid-example: + +```markdown +Maya borrows *Emma* on May 25, 2026. + +[The nightly batch runs](./shared/jobs.md#the-nightly-batch) + +Her account shows a £0.50 fee. +``` + +Shared assertions, at the end: + +```markdown +Maya returns *Emma* late and pays the fee. + +[The ledger invariants hold](./shared/invariants.md#the-ledger-invariants-hold) +``` + +### A referenced section stops being a standalone example + +Being linked **consumes** a section. Once any oath references it, it no longer +runs in its own right: it runs only where it is referenced, once per referencing +example. The alternative — running standalone *and* inlined — duplicates every +shared section across the report, doubles its cost, and makes a single failure +appear N+1 times. + +The price is that the section loses its own line in the suite, and with it the +guarantee that shared setup is independently verified. It is still verified, but +only through its references: a section nothing links to any more is simply an +ordinary example again (it was never marked as anything else), and a section +whose steps stop matching is reported as drift at its own location — see +[Open questions](#open-questions) for how that is surfaced. + +Convention (not a rule): shared sections live in `varar/shared/*.md`. + +Note the scope this creates: "is this section referenced?" is **whole-project** +knowledge. It cannot be answered from the file being planned, which has +consequences for single-file runs and for the LSP planning one open buffer — see +[Open questions](#open-questions). + +### References do not nest + +A referenced section may not itself contain a reference block. Reuse is exactly +one level deep. + +This is a deliberate ceiling, not a limitation waiting to be lifted: chained +transclusion makes a reader open three files to learn what the world state is, +which is worse than the repetition it removes. A reference block inside a +referenced section is an error. + +It also **eliminates cycles by construction**. With a maximum depth of one, the +only reachable cycle is a section referencing itself, which the nesting rule +already rejects — so there is no cycle *detection* to implement, only a depth +check with a clear message. (If nesting is ever allowed, cycle detection comes +back with it; that is part of the cost of lifting the ceiling.) + +### Naming + +An example's name stays "its first matching paragraph" — a reference block is +not a matching paragraph, so an example that opens with one is named by its own +first step-bearing paragraph, not by the section it pulls in. Otherwise every +example under a shared setup would be called *A stocked library*. + +### Errors + +All are authoring mistakes, reported as diagnostics and failing the run — none +degrade to prose: + +- **dangling reference** — no such file, or no heading with that slug; +- **nested reference** — a reference block inside a referenced section (this is + also what makes a cycle unreachable); +- **empty reference** — the resolved section plans no steps; +- **ambiguous anchor** — two headings in the target file slug identically + (`#setup` / `#setup-1`); lint requires unique headings in any referenced file; +- **unreferenceable construct in a referenced section** — a header-bound table + (it multiplies examples, which a spliced step list cannot express) or an + ```error``` fence (expected-failure is a property of an example, not of a + reusable fragment). + +A dangling reference is deliberately *not* drift (ADR 0002). Drift is "this used +to match and now reads as prose", which needs an acknowledgment because prose is +a legitimate destination. A link-only block that resolves to nothing has no +legitimate reading, so it is a hard error. + +### Explicitly deferred + +Kept out of v1 so the primitive lands small; each is additive: + +- **Parameterised references.** If it becomes necessary, the preferred shape is + a **table attached to the reference block**, read by the referenced section — + the data stays in the referencing document, so a mismatch diff still lands on + the value the author wrote. Encoding arguments in the link text (matching it + against a heading-as-expression) is rejected: the anchor is unreadable and the + claimed value and the step it feeds end up in different files. +- **Remote references** (`https://specs.example.eu/vat.md#rounding`), pinned by + content hash in `varar.lock.json`. Interesting as a cross-org executable + contract — a supplier publishes an oath, every consumer's suite proves they + honour it — and it fits the attestation story, but it brings supply chain, + offline builds and CI flakiness. Revisit separately. +- **State snapshotting.** A referenced section is deterministic, so its post-state + could be cloned rather than re-executed per example. That is an optimisation + with a cloneability contract attached; it changes no Markdown and can land any + time. +- **Config-declared backgrounds** (a glob → setup mapping in + `varar.config.json`) are **rejected outright**: the review unit in Varar is the + oath diff (`no-theatre`), and setup that is invisible in the document defeats + that. + +## Implementation + +The split follows ADR 0012's: syntax in `structure()`, meaning in `plan()`. + +- **`scanner` / `structurer` (pure syntax, no registry).** Recognising "this + block is exactly one link with an oath-shaped target" is syntax, so a new + `Block` kind — `reference` with `{ path?, fragment?, linkText, span }` — is + emitted as a candidate's primary block. Candidates keep + `precededByDelimiter` unchanged. +- **A pure `references(doc)`** returns the reference targets of a document. The + shell drives the transitive closure: parse, collect, read, repeat — the core + never touches `node:fs`. This is the hexagonal boundary; it must not be + smuggled into `plan()` as a file read. +- **`plan()` gains a resolved document set and an inbound set.** + `plan(doc, registry, { docs, referenced })` where `docs` maps POSIX-relative + path → already-parsed `Doc`, and `referenced` is the set of (path, slug) + sections the project links to. A `reference` candidate resolves to a section, + plans it (memoised per (path, slug); depth is capped at one, so no cycle + guard), and splices its `PlannedStep`s into the open `MergedExample`. A + candidate that *is* a referenced section is planned and then dropped, not + emitted as an example. Two new branches in the grouping loop, and one new + input the caller must supply. +- **`PlannedStep` gains a document identity.** A step spliced from another file + has spans in that file. The minimal change is an optional `docPath` on + `PlannedStep` (absent = the example's own document), threaded through + `failure` / `result`. +- **Run results (ADR 0014) need a v2.** The payload assumes one document per + oath: `sourceHash` is a single hash, and a diagnostic's span implicitly + belongs to the oath file. Per-step `docPath` plus a per-referenced-document + hash is the smallest extension; the LSP must invalidate an oath's diagnostics + when **any** document it references changes. This is the largest downstream + cost of the decision and should be designed before the parser work lands. +- **Conformance.** Bundles are single-`example.md` today. Cross-file references + need a multi-file bundle shape (an `example.md` plus a `shared/` sibling), and + `golden/doc.json` / `plan.json` pin reference resolution. Per the corpus rule + in CLAUDE.md: **anything no corpus pins is what drifts**, so the corpus lands + with — not after — the first port. `parity.json` grows a `references` + capability. +- **Drift (ADR 0002) needs care.** Drift is computed per document against + `varar.lock.json`, and treats a candidate as live when its span overlaps a + planned example. A referenced section produces **no** planned example in its + own document, so its paragraphs stop overlapping anything and every one of + them reads as drift the moment this ships. Liveness has to widen from "overlaps + a planned example" to "overlaps a planned example **or** was spliced into one", + which means the referencing side has to report back. This is the second piece + of whole-project state the change introduces, and the one most likely to be + got wrong quietly. +- **The inbound index is whole-project.** A full run already globs every oath, so + building (path, slug) → referrers costs one pass. The hard cases are the ones + that plan a subset: `vitest path/to/one.md`, and the LSP planning a single open + buffer. Both need the index, or they will run a referenced section as a + standalone example (wrong, and green) — see [Open questions](#open-questions). + +Rollout: TypeScript first behind the corpus, then the remaining six ports; +`spec` commits per CLAUDE.md, with `Ports-deferred:` footers while it lands +incrementally. + +## Consequences + +- **A link-only paragraph changes meaning** when its target is a fragment or a + `.md` path — it was prose (a delimiter), it becomes a reference. Other + link-only paragraphs are untouched. Breaking in principle + (`feat(spec)!`), near-zero in practice; the dogfood oaths contain none. +- **Planning stops being a per-document pure function of one document.** Whether + a section is an example now depends on whether anything, anywhere in the + project, links to it. Every caller that plans a subset — a single-file vitest + run, the LSP on one buffer, each port's runner — has to be handed an inbound + index, and a caller that forgets runs referenced sections as standalone + examples: wrong, and green. This is the deepest change in the proposal and the + one that touches all seven ports plus the LSP. +- **Shared setup is no longer independently verified.** A section that only ever + runs inlined has no line of its own; if every referrer is deleted it silently + becomes an ordinary example again. Accepted in exchange for not duplicating + every shared section N+1 times in the report. +- **Drift needs widening, or it fires on every referenced section.** See + Implementation — liveness has to include "was spliced into an example + elsewhere". +- **Failures can point at a file other than the one under test.** Editors, + reporters and the run-result consumers all have to carry a document identity + per step. This is real work in seven ports plus the LSP. +- **Low-altitude setup gets a cheaper place to hide.** The mitigation is + documentation, not mechanism: the reuse page should open with the altitude + argument and present references as the answer for setup the reader must *see*, + not for setup nobody bothered to name. +- **Cucumber users get a better `Background:`** — one that also does mid-example + and end-of-example reuse, works across files, and is visible at the point of + use. That is the line for the migration guide. + +## Open questions + +Unresolved; each needs a decision before implementation. + +1. **How does a subset run get the inbound index?** Options: (a) the runner + always globs and parses every oath before planning any (accurate, costs a + project scan on every single-file run and every LSP keystroke); (b) a + file-level opt-out — shared files live under a glob that `docs` excludes from + example discovery, making "not standalone" local and cheap but coarse (a whole + file, not a section); (c) cache the index and invalidate on change. (b) is the + only option that keeps planning local; it conflicts with section-level + granularity. +2. **Where is drift reported for a referenced section?** At the section (the + author's location, but the failure names a file the run may not have targeted) + or at each reference block (N copies of one problem)? +3. **Does a referenced section show up in the report at all** — as a nested + `describe` under the referencing example, as a flat run of spliced steps, or + invisibly? This decides whether a reader of CI output can tell reuse happened. +4. **What if a referenced section sits under headings?** Its own `scopeStack` is + discarded in favour of the referencing example's — confirm that is right, and + that the heading is used only as the anchor and the link text. +5. **May an example reference more than one section, and in what order?** The + list spelling implies yes, in document order; confirm, and decide whether the + same section twice in one example is an error or a legitimate "do it again". +6. **Is a fragment-less `.md` reference (whole file) worth keeping?** It is the + one form whose meaning changes when the target file grows a second example. +7. **Does `varar.lock.json` still fingerprint a file that contributes no + standalone examples?** It must, or edits to shared setup go unnoticed — but + the entry's meaning changes. +8. **What does the editor do at a reference block?** Go-to-definition is + obvious; the open question is whether hovering shows the resolved steps + inline, which is what would keep the "reader must see the world state" + argument true at the point of use. + +## Documentation + +- New: `explanation/reuse.md` (why a link, and the altitude argument first). +- New: `how-to/share-setup-between-examples.md`. +- Edit: `reference/examples.mdx` — a fourth block role beside example, prose and + attachment; the naming rule; the error table. +- Edit: `explanation/varar-for-cucumber-users.md` — the `Background:` row stops + saying "no equivalent". + +New pages ship `draft: true` until the release that carries the feature. From 6d4fd5b6864aba59671499ddc0a7a2a4b8725b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 10:30:37 +0100 Subject: [PATCH 02/21] docs(adr): allow nested references in ADR 0016; depth is style, not a rule Reverses the one-level ceiling. A referenced section may contain reference blocks to any depth; cycle detection comes back with it. Deep chains stay bad practice, communicated through the reuse docs and the agent/authoring instructions rather than enforced by the parser. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- doc/adr/0016-reuse-is-a-link.md | 73 +++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/doc/adr/0016-reuse-is-a-link.md b/doc/adr/0016-reuse-is-a-link.md index adb45e6c..8c06900d 100644 --- a/doc/adr/0016-reuse-is-a-link.md +++ b/doc/adr/0016-reuse-is-a-link.md @@ -154,21 +154,34 @@ knowledge. It cannot be answered from the file being planned, which has consequences for single-file runs and for the LSP planning one open buffer — see [Open questions](#open-questions). -### References do not nest - -A referenced section may not itself contain a reference block. Reuse is exactly -one level deep. - -This is a deliberate ceiling, not a limitation waiting to be lifted: chained -transclusion makes a reader open three files to learn what the world state is, -which is worse than the repetition it removes. A reference block inside a -referenced section is an error. - -It also **eliminates cycles by construction**. With a maximum depth of one, the -only reachable cycle is a section referencing itself, which the nesting rule -already rejects — so there is no cycle *detection* to implement, only a depth -check with a clear message. (If nesting is ever allowed, cycle detection comes -back with it; that is part of the cost of lifting the ceiling.) +### References nest, and depth is a style question + +A referenced section may itself contain reference blocks, to any depth. The +steps of an example are the depth-first, document-order flattening of its +reference graph. + +Deep chains are **bad practice** — a reader who must open three files to learn +what the world state is has lost more than the repetition saved — but that is a +judgement about a particular document, not a property the parser can decide. +Varar's line is that the tool enforces what is *checkable* (an anchor resolves, +a step matches, a claimed value holds) and leaves what is *tasteful* to prose: +the reuse how-to and the authoring skills say "one level, two at the outside", +and review catches the rest. Encoding a depth ceiling would also be the first +place Varar told an author their correct document was disallowed on style +grounds. + +The cost is real and is accepted: + +- **Cycles become reachable and must be detected.** A → B → A, and the + self-reference A → A, are errors reported with the full chain + (`library.md#stocked → billing.md#fees → library.md#stocked`), not a stack + overflow. +- **An example's steps can come from arbitrarily many documents.** The per-step + document identity below already carries this; nothing further is needed, but + the "which file am I looking at" burden on reporters and the LSP grows. +- **Cost is multiplicative.** A section referenced from a section referenced by + forty examples runs forty times. Nothing caps it; the deferred state-snapshot + optimisation is the eventual answer. ### Naming @@ -183,8 +196,8 @@ All are authoring mistakes, reported as diagnostics and failing the run — none degrade to prose: - **dangling reference** — no such file, or no heading with that slug; -- **nested reference** — a reference block inside a referenced section (this is - also what makes a cycle unreachable); +- **cycle** — a reference chain that reaches a section already on the chain, + including a section referencing itself; reported with the whole chain; - **empty reference** — the resolved section plans no steps; - **ambiguous anchor** — two headings in the target file slug identically (`#setup` / `#setup-1`); lint requires unique headings in any referenced file; @@ -239,8 +252,9 @@ The split follows ADR 0012's: syntax in `structure()`, meaning in `plan()`. `plan(doc, registry, { docs, referenced })` where `docs` maps POSIX-relative path → already-parsed `Doc`, and `referenced` is the set of (path, slug) sections the project links to. A `reference` candidate resolves to a section, - plans it (memoised per (path, slug); depth is capped at one, so no cycle - guard), and splices its `PlannedStep`s into the open `MergedExample`. A + plans it (memoised per (path, slug), with the chain of in-progress sections + carried down so a repeat is reported as a cycle rather than recursing), and + splices its `PlannedStep`s into the open `MergedExample`. A candidate that *is* a referenced section is planned and then dropped, not emitted as an example. Two new branches in the grouping loop, and one new input the caller must supply. @@ -342,12 +356,27 @@ Unresolved; each needs a decision before implementation. 8. **What does the editor do at a reference block?** Go-to-definition is obvious; the open question is whether hovering shows the resolved steps inline, which is what would keep the "reader must see the world state" - argument true at the point of use. + argument true at the point of use. With nesting allowed, a hover that + resolves the *whole* chain is the thing that keeps a deep document readable + despite itself. +9. **Is an opt-in depth lint worth it?** Nesting depth is a style question and + stays out of the parser, but `reference/lint.md` is where checkable house + style already lives. A rule that is **off by default** and warns past a + configured depth would let a team enforce its own ceiling without Varar + picking one. Decide whether that is a useful escape hatch or the same + prohibition wearing a hat. ## Documentation -- New: `explanation/reuse.md` (why a link, and the altitude argument first). -- New: `how-to/share-setup-between-examples.md`. +- New: `explanation/reuse.md` (why a link, the altitude argument first, and why + nesting depth is left to judgement rather than enforced). +- New: `how-to/share-setup-between-examples.md` — including the house-style + guidance the parser deliberately does not enforce: one level, two at the + outside; a chain a reader cannot hold in their head is a step nobody wrote. +- Edit: `how-to/agent-instructions.md` and the authoring skills — an agent + generating oaths is exactly the author most likely to build a deep reference + chain, so the depth guidance has to reach the instruction block, not only the + prose docs. - Edit: `reference/examples.mdx` — a fourth block role beside example, prose and attachment; the naming rule; the error table. - Edit: `explanation/varar-for-cucumber-users.md` — the `Background:` row stops From 8cd7e2aef25d1c9892a5974ada7842ed81bbdc93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 10:32:36 +0100 Subject: [PATCH 03/21] docs(adr): resolve the inbound-index question in ADR 0016 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every port already globs the whole project once per run (the seam that prunes varar.lock.json), and the LSP already reindexes every oath on every change — so the inbound index has a home everywhere it is needed. Records the decision, the rejected file-level opt-out, and the costs: vitest's zero-test files and its wider watch invalidation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- doc/adr/0016-reuse-is-a-link.md | 104 ++++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 13 deletions(-) diff --git a/doc/adr/0016-reuse-is-a-link.md b/doc/adr/0016-reuse-is-a-link.md index 8c06900d..181fecc9 100644 --- a/doc/adr/0016-reuse-is-a-link.md +++ b/doc/adr/0016-reuse-is-a-link.md @@ -235,6 +235,91 @@ Kept out of v1 so the primitive lands small; each is additive: oath diff (`no-theatre`), and setup that is invisible in the document defeats that. +## The inbound index + +"A section that is linked stops being a standalone example" is the rule with the +longest reach in this ADR, because it makes planning depend on the whole project. +This section resolves how. + +### What the ports do today + +Every runner already performs a **whole-project glob once per run**, and every +one of them already does it for a reason that is the same shape as this problem +— pruning `varar.lock.json` entries for oaths the config no longer discovers, +which must be keyed off the config globs and *not* off the files the runner +happened to collect: + +| Port | Once-per-run whole-project seam | +|------|----------------------------------| +| vitest | plugin `config()` / `configResolved()` — globs `docs`, and `load()` already reads **every step file** per oath | +| pytest | `pytest_configure` → `find_oaths(...)`, explicitly *not* the collected subset ("`pytest tests/one_dir/` is a filtered view") | +| JUnit | `OathTestEngine` — `onDisk` walk before pruning | +| Kotest | `OathSpec` — "`findOaths` ignores whatever test filter Kotest was given" | +| minitest / RSpec | `Runner.find_oaths(...)` at load | +| cargo test | `find_oaths(&config, root)` | +| go test | `runner.FindOaths(cfg, root)` — "Collect always discovers everything" | +| .NET | `Discovery.FindOaths(workspace.Config, workspace.Root)` | + +The LSP is not the hard case it looked like either: `store.reindex()` lists +**every** oath, reads every source, and hands the lot to `buildWorkspaceIndex`, +which already parses and plans all of them — and it does this on every +`didChange`, not per buffer. The LSP is already whole-project on every keystroke. + +Planning itself is per-file and lazy everywhere (`planOath(path, source, +registry)`, `OathFile.collect`, the vitest `load()` hook), which is the seam that +has to change — but the *discovery* it would need already happens one layer up. + +### Decision + +**Build the inbound index at the existing once-per-run glob seam, in every +port.** Concretely: at that point, read and `parse()` every discovered oath +(parsing is pure and executes no step code), collect every reference block, and +carry the resulting `(path, slug) → referrers` map into each `plan()` call. + +The decisive argument is **determinism**: whether a section is a test must not +depend on which files the invocation happened to select. A best-effort index +built from "the files this run planned" would make `pytest tests/fees/` and +`pytest` disagree about whether `shared/library.md` contains a test — the same +source, two answers, both green. That is the failure mode Varar exists to rule +out. + +### The alternative, and why not + +**A file-level opt-out** — shared sections live under a glob `docs` excludes from +example discovery — keeps planning local and needs no index at all. It was the +tempting option, and it loses on three counts: + +1. It does not save the I/O. Resolving a reference still reads the target file + (the *forward* closure). The index only adds inbound bookkeeping over sources + the run already has in hand. +2. It is coarse: a whole file becomes shared-only, so a file cannot hold both + ordinary examples and a section other files link to. +3. It adds a second configuration concept that means the same thing as a link, + and can disagree with it — a file in the shared glob that nothing references, + or a referenced section in a file that is not. + +### The real costs + +- **vitest: an oath whose sections are all consumed produces a test file with + zero tests**, which vitest reports as an error ("No test suite found in + file"). `test.include` is driven straight from the `docs` globs, so the plugin + must either drop fully-consumed oaths from `include` (it now has the index in + `config()` to know) or emit a placeholder. No other port has this problem: an + empty pytest collector, an childless JUnit descriptor and a Go subtest-less + file are all fine. +- **vitest watch mode gets wider invalidation.** Editing *any* oath can change + another oath's plan, so `load()` must `addWatchFile` every oath, not just the + step files. Precedented — step files already force a re-transform of every + oath — but it means one keystroke in a shared oath re-transforms the project. +- **Filtered runs parse files they do not run.** Parse-only, no step execution, + and bounded by the oath count; cache by (path, hash) if it ever shows up in a + profile. +- **Seven ports plus the LSP must thread one more argument through `plan()`.** + Mechanical, but it is the kind of change where one port quietly keeps the old + single-argument call and silently runs shared sections as examples — so the + conformance corpus must pin a bundle where a section is consumed, and the + expected plan for its *defining* file is empty. + ## Implementation The split follows ADR 0012's: syntax in `structure()`, meaning in `plan()`. @@ -301,11 +386,10 @@ incrementally. (`feat(spec)!`), near-zero in practice; the dogfood oaths contain none. - **Planning stops being a per-document pure function of one document.** Whether a section is an example now depends on whether anything, anywhere in the - project, links to it. Every caller that plans a subset — a single-file vitest - run, the LSP on one buffer, each port's runner — has to be handed an inbound - index, and a caller that forgets runs referenced sections as standalone - examples: wrong, and green. This is the deepest change in the proposal and the - one that touches all seven ports plus the LSP. + project, links to it, so every caller that plans an oath must be handed an + inbound index. A caller that forgets runs referenced sections as standalone + examples: wrong, and green. See [The inbound index](#the-inbound-index) — the + seam exists in all seven ports already, but every one of them has to use it. - **Shared setup is no longer independently verified.** A section that only ever runs inlined has no line of its own; if every referrer is deleted it silently becomes an ordinary example again. Accepted in exchange for not duplicating @@ -328,14 +412,8 @@ incrementally. Unresolved; each needs a decision before implementation. -1. **How does a subset run get the inbound index?** Options: (a) the runner - always globs and parses every oath before planning any (accurate, costs a - project scan on every single-file run and every LSP keystroke); (b) a - file-level opt-out — shared files live under a glob that `docs` excludes from - example discovery, making "not standalone" local and cheap but coarse (a whole - file, not a section); (c) cache the index and invalidate on change. (b) is the - only option that keeps planning local; it conflicts with section-level - granularity. +1. ~~How does a subset run get the inbound index?~~ **Resolved** — see + [The inbound index](#the-inbound-index). 2. **Where is drift reported for a referenced section?** At the section (the author's location, but the failure names a file the run may not have targeted) or at each reference block (N copies of one problem)? From dce7412ba2eb7e7ea825f3ac50c14e2f78ab6e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 10:37:48 +0100 Subject: [PATCH 04/21] docs(adr): resolve the port, runner and LSP-cost issues in ADR 0016 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-adapter compatibility table and decisions for the three real incompatibilities: doc.path means a basename in some ports and cannot resolve a relative link (prerequisite fix), vitest fails an oath that becomes a zero-test file (transform it to an empty describe.skip — verified), and the index must be a required plan() argument backed by a conformance bundle and an adapter smoke case. Also records what one LSP keystroke costs today (full reindex, no debounce, every oath planned twice) and the four changes that make it cheaper than today even with references: debounce, hash-keyed parse and scan caches, invalidation along the reference graph, and reusing the index's plans for drift. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- doc/adr/0016-reuse-is-a-link.md | 181 +++++++++++++++++++++++++++----- 1 file changed, 155 insertions(+), 26 deletions(-) diff --git a/doc/adr/0016-reuse-is-a-link.md b/doc/adr/0016-reuse-is-a-link.md index 181fecc9..5bf47c73 100644 --- a/doc/adr/0016-reuse-is-a-link.md +++ b/doc/adr/0016-reuse-is-a-link.md @@ -300,25 +300,144 @@ tempting option, and it loses on three counts: ### The real costs -- **vitest: an oath whose sections are all consumed produces a test file with - zero tests**, which vitest reports as an error ("No test suite found in - file"). `test.include` is driven straight from the `docs` globs, so the plugin - must either drop fully-consumed oaths from `include` (it now has the index in - `config()` to know) or emit a placeholder. No other port has this problem: an - empty pytest collector, an childless JUnit descriptor and a Go subtest-less - file are all fine. - **vitest watch mode gets wider invalidation.** Editing *any* oath can change another oath's plan, so `load()` must `addWatchFile` every oath, not just the step files. Precedented — step files already force a re-transform of every oath — but it means one keystroke in a shared oath re-transforms the project. - **Filtered runs parse files they do not run.** Parse-only, no step execution, - and bounded by the oath count; cache by (path, hash) if it ever shows up in a - profile. -- **Seven ports plus the LSP must thread one more argument through `plan()`.** - Mechanical, but it is the kind of change where one port quietly keeps the old - single-argument call and silently runs shared sections as examples — so the - conformance corpus must pin a bundle where a section is consumed, and the - expected plan for its *defining* file is empty. + bounded by the oath count, and cached by (path, hash) — see + [Keeping the LSP fast](#keeping-the-lsp-fast), whose caches the runners share. + +## Port and runner compatibility + +The adapters differ in *when* they plan, and two of them have hazards the others +do not. Everything below is a decision, not an open question. + +| Adapter | How examples are produced today | What changes | +|---------|---------------------------------|--------------| +| cargo test | `trials_recording` loops every oath from `find_oaths` in one function | one pre-pass in the same loop | +| go test | `Collect` — "always discovers everything" | same | +| .NET | `Discovery.FindOaths` then a loop | same | +| JUnit / Kotest | engine/spec walks the on-disk set in one place | same | +| RSpec / minitest | `Runner.find_oaths` at load | same | +| pytest | plans lazily in `pytest_collect_file` → `OathFile.collect`, but `pytest_configure` already stashes the full set | index into the stash | +| vitest | plans lazily in the `load()` hook per oath; `configResolved` already globs the full set | index built there, cached; plus the zero-test fix below | + +Five ports already hold every oath in one loop, so the index is a local change. +The two that plan lazily both already stash the whole set for baseline pruning. +No port needs a new discovery pass. + +### Document identity must become the same thing in every port + +This is a **prerequisite**, and it is currently broken. The path handed to +`parse()` differs per port: pytest passes `self.path.name` (a *basename*), Rust +passes `file_name`, .NET passes a workspace-relative POSIX path, the vitest +plugin and the LSP pass absolute paths. A relative link — `./shared/library.md` +— cannot be resolved against a basename, and two same-named oaths in different +directories are indistinguishable. + +**Decision: `doc.path` is the workspace-relative POSIX path in every port**, the +identity `varar.lock.json` and `.varar/.json` already use. It lands +before the reference work as its own `fix(spec)` change, which is worth doing on +its own merits — `doc.path` currently means three different things. Each port's +failure rendering must be checked for basename assumptions as part of it. + +### vitest: a fully-consumed oath must not become a zero-test file + +`test.include` is driven straight from the `docs` globs, so every oath is a test +*file*. An oath whose sections are all consumed would register no tests, and +vitest fails that file outright (verified on vitest 5.0.0): + +``` +FAIL empty.test.ts [ empty.test.ts ] +Error: No test suite found in file …/empty.test.ts +``` + +**Decision: a fully-consumed oath transforms to a module containing a single +empty `describe.skip(...)`**, named for the oath and its referrers. Verified: the +run reports `Test Files 1 skipped (1)`, no error, no failure — which is also the +honest report, since the file holds no standalone example. + +The alternative — dropping consumed oaths from `test.include` — is rejected: +`include` is fixed in the `config()` hook, so the first watch-mode edit that +consumes or releases a section would need a dev-server restart to take effect. +Transforming the module instead makes the transition an ordinary re-transform. + +No other adapter has this problem: an empty pytest collector, a childless JUnit +descriptor, a Go parent test with no subtests and a .NET discovery yielding no +test cases are all silent and green. + +### The index is a required argument, not an optional one + +The likeliest way this decays is a port that keeps calling the old +single-argument `plan()` and runs shared sections as standalone examples — +wrong, and green, in exactly the way ADR 0014 describes for unpinned fields. +Three gates, deliberately redundant: + +1. **The core's `plan()` takes the index as a required parameter** in all seven + ports. The five statically-typed ports fail to compile; Python and Ruby fail + loudly at the first call. No defaulting to "no references". +2. **A conformance bundle pins it**: a multi-file bundle where one file's section + is consumed by another, whose `golden/plan.json` for the *defining* file has an + empty example list. A port that ignores the index produces a non-empty plan + and goes red. +3. **An `adapter/` smoke case** runs each example project's real test command and + asserts the consumed oath contributes no test. The corpus exists precisely + because a port can be conformance-green and wired wrong; "shared sections run + twice" is that failure exactly. + +### Run results for a consumed oath + +Adapters write `.varar/.json` for every oath they *discovered*, +including a consumed one — with an empty example list. Skipping the file would +leave the LSP showing diagnostics from the run before the section was consumed. +For the same reason baseline pruning keeps a consumed oath's `varar.lock.json` +entry: it is still a discovered oath, and its source is still fingerprinted. + +## Keeping the LSP fast + +### What it costs today + +Every `didChange` writes the buffer through to the filesystem and calls +`store.reindex()`, which re-globs the workspace, re-reads every step file, +re-runs the **tree-sitter scan on all of them**, re-reads every oath, and +re-parses and re-plans all of them — and then `driftDiagnosticRefs` parses and +plans **every oath a second time**. There is no debounce: that is the cost of one +keystroke today, twice over. + +References do not introduce this, but they would make the whole-project shape +permanent, so the incrementality lands with them. + +### Four changes, in order of payoff + +1. **Debounce `reindex`** — a trailing ~75 ms coalescing timer, so a burst of + typing produces one index, not one per character. Alone, this removes most of + the cost of fast typing. +2. **Memoise by content hash.** `parse()` is pure, and the tree-sitter step scan + is pure in its file's source; cache both keyed by `(path, contentHash)`. After + the first index, a keystroke re-parses exactly one file and re-scans no step + files at all. +3. **Invalidate along the reference graph.** The inbound index *is* a dependency + graph, so a changed oath re-plans only + + {edited} ∪ referrers*(edited) ∪ targets(edited before) ∪ targets(edited after) + + — the last two because editing a reference block changes whether its old and + new targets are standalone. A change that affects the *registry* (a step file, + `varar.config.json`) still invalidates every plan, as it must. +4. **Delete the double plan.** `driftDiagnosticRefs` re-parses and re-plans every + oath; hand it the plans `buildWorkspaceIndex` just computed instead. This is a + straight halving, and it is worth doing whether or not references ship. + +The result is that with references the LSP does *less* work per keystroke than it +does today: an edit to an ordinary oath costs one parse and one plan, and an edit +to a shared oath costs one parse plus a re-plan of its referrers. The pathological +case — a project where every oath references one shared file — is bounded by the +referrer count, and is the same document shape the reuse docs (and the optional +depth lint) warn against. + +The browser LSP used by the website runs the same store over a memory filesystem +and gets the identical improvement. ## Implementation @@ -368,15 +487,24 @@ The split follows ADR 0012's: syntax in `structure()`, meaning in `plan()`. which means the referencing side has to report back. This is the second piece of whole-project state the change introduces, and the one most likely to be got wrong quietly. -- **The inbound index is whole-project.** A full run already globs every oath, so - building (path, slug) → referrers costs one pass. The hard cases are the ones - that plan a subset: `vitest path/to/one.md`, and the LSP planning a single open - buffer. Both need the index, or they will run a referenced section as a - standalone example (wrong, and green) — see [Open questions](#open-questions). - -Rollout: TypeScript first behind the corpus, then the remaining six ports; -`spec` commits per CLAUDE.md, with `Ports-deferred:` footers while it lands -incrementally. +- **The inbound index** is built at each port's existing once-per-run glob and + passed to `plan()` as a required argument — see + [The inbound index](#the-inbound-index) and + [Port and runner compatibility](#port-and-runner-compatibility). + +Rollout, in dependency order — each step is independently shippable and green: + +1. **`doc.path` becomes the workspace-relative POSIX path in every port** + (`fix(spec)`). A prerequisite, and an improvement on its own: the field + currently means a basename, a relative path or an absolute path depending on + who called. +2. **LSP incrementality** — debounce, hash-keyed caches, and dropping the double + plan (`perf(ts/lsp)`). Independent of references, and a win today. +3. **The reference block itself**, TypeScript first behind the new multi-file + conformance bundle, then the remaining six ports; `spec` commits with + `Ports-deferred:` footers while it lands incrementally. +4. **Run-result v2** (per-step document identity) with its golden, once the + parser work proves the shape. ## Consequences @@ -428,9 +556,10 @@ Unresolved; each needs a decision before implementation. same section twice in one example is an error or a legitimate "do it again". 6. **Is a fragment-less `.md` reference (whole file) worth keeping?** It is the one form whose meaning changes when the target file grows a second example. -7. **Does `varar.lock.json` still fingerprint a file that contributes no - standalone examples?** It must, or edits to shared setup go unnoticed — but - the entry's meaning changes. +7. ~~Does `varar.lock.json` still fingerprint a consumed file?~~ **Resolved** — + yes; see [Run results for a consumed oath](#run-results-for-a-consumed-oath). + What remains open is what a consumed file's baseline entry *means* once its + paragraphs are live only through their referrers. 8. **What does the editor do at a reference block?** Go-to-definition is obvious; the open question is whether hovering shows the resolved steps inline, which is what would keep the "reader must see the world state" From c5216be52411fe886e22fca9c39bb2237a60d68f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 10:47:27 +0100 Subject: [PATCH 05/21] fix(spec): an oath's doc.path is its workspace-relative POSIX path in every port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doc.path meant three different things depending on who called: a basename (pytest, unittest, RSpec, minitest, cargo test, go test), a workspace- relative path (.NET, JUnit, the vitest runtime) or an absolute path (the vitest static planner, the CLI linter). A basename cannot tell two same-named oaths in different directories apart, and it cannot anchor a relative path. Every adapter now hands plan()/parse() the same identity varar.lock.json and .varar/.json already use. TypeScript gains a shared toOathPath() helper in @varar/config; the other ports reuse the rel-posix helper each already had next to the call. Prerequisite for ADR 0016 (reference blocks), and a correctness fix on its own. Ports-deferred: java, dotnet — both already passed the relative path Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- go/gotest/gotest.go | 10 ++++++---- .../pytest/src/varar_pytest/plugin.py | 4 +++- .../unittest/src/varar_unittest/__init__.py | 5 ++++- ruby/packages/minitest/lib/varar/minitest.rb | 5 ++++- ruby/packages/rspec/lib/varar/rspec.rb | 5 ++++- rust/cargotest/src/lib.rs | 17 ++++++++--------- typescript/packages/cli/src/lint.ts | 4 ++-- typescript/packages/config/src/index.ts | 1 + typescript/packages/config/src/oath-path.ts | 15 +++++++++++++++ .../packages/config/tests/oath-path.test.ts | 14 ++++++++++++++ typescript/packages/vitest/src/plugin.ts | 13 +++++++------ .../packages/vitest/src/static-examples.ts | 8 ++++++-- .../vitest/tests/static-examples.test.ts | 6 +++--- typescript/pnpm-lock.yaml | 19 ------------------- 14 files changed, 77 insertions(+), 49 deletions(-) create mode 100644 typescript/packages/config/src/oath-path.ts create mode 100644 typescript/packages/config/tests/oath-path.test.ts diff --git a/go/gotest/gotest.go b/go/gotest/gotest.go index 229cf620..92710b2b 100644 --- a/go/gotest/gotest.go +++ b/go/gotest/gotest.go @@ -75,14 +75,16 @@ func Collect(root string, build BuildRegistry, ctx ContextFactory, update bool) for _, oathPath := range oaths { sourceBytes, _ := os.ReadFile(oathPath) source := string(sourceBytes) - oathFile := filepath.Base(oathPath) rel, relErr := filepath.Rel(root, oathPath) if relErr != nil { - rel = oathFile + rel = filepath.Base(oathPath) } rel = filepath.ToSlash(rel) - plan := runner.PlanOath(oathFile, source, build()) + // `rel` (workspace-relative, POSIX), not the basename: doc.path is an + // oath's identity in every port, and a basename cannot tell two + // same-named oaths apart or anchor a relative reference (ADR 0016). + plan := runner.PlanOath(rel, source, build()) for i, display := range runner.ExampleNames(plan) { index := i src := source @@ -109,7 +111,7 @@ func Collect(root string, build BuildRegistry, ctx ContextFactory, update bool) // Drift reconciliation: rewrites the baseline on a clean run; each // drifted paragraph becomes a failing case (ADR 0002). store := runner.NewFileBaselineStore(root) - doc := core.Parse(oathFile, source) + doc := core.Parse(rel, source) for _, drifted := range core.ReconcileDrift(store, rel, source, doc, plan, update) { cases = append(cases, Case{ Name: rel + "::varar:drift:" + strconv.Itoa(drifted.Line), diff --git a/python/packages/pytest/src/varar_pytest/plugin.py b/python/packages/pytest/src/varar_pytest/plugin.py index 4b653bac..82b1d08a 100644 --- a/python/packages/pytest/src/varar_pytest/plugin.py +++ b/python/packages/pytest/src/varar_pytest/plugin.py @@ -98,7 +98,9 @@ class OathFile(pytest.File): def collect(self): _cfg, loaded, root, store, results = _STASH[id(self.config)] source = self.path.read_text(encoding="utf-8") - execution_plan = plan_oath(self.path.name, source, loaded.registry) + # The oath's workspace-relative POSIX path, not its basename: doc.path + # is an oath's identity in every port (ADR 0016). + execution_plan = plan_oath(_oath_path(self.path, root), source, loaded.registry) pairs = examples_with_runs(execution_plan, loaded.create_context, RecordingReporter()) seen: dict[str, int] = {} for example, run in pairs: diff --git a/python/packages/unittest/src/varar_unittest/__init__.py b/python/packages/unittest/src/varar_unittest/__init__.py index 531a29cc..9726d366 100644 --- a/python/packages/unittest/src/varar_unittest/__init__.py +++ b/python/packages/unittest/src/varar_unittest/__init__.py @@ -93,7 +93,10 @@ def _oath_test_case( # gets a stable relative label. rel = Path(os.path.abspath(oath_path)).relative_to(root, walk_up=True).as_posix() source = oath_path.read_text(encoding="utf-8") - execution_plan = plan_oath(oath_path.name, source, loaded.registry) + # `rel`, not the basename: doc.path is an oath's identity in every port, so + # a relative reference resolves alike and two same-named oaths in different + # directories stay distinct (ADR 0016). + execution_plan = plan_oath(rel, source, loaded.registry) pairs = examples_with_runs(execution_plan, loaded.create_context, RecordingReporter()) methods: dict[str, Any] = {"__doc__": rel} diff --git a/ruby/packages/minitest/lib/varar/minitest.rb b/ruby/packages/minitest/lib/varar/minitest.rb index a837fb69..ec117993 100644 --- a/ruby/packages/minitest/lib/varar/minitest.rb +++ b/ruby/packages/minitest/lib/varar/minitest.rb @@ -47,7 +47,10 @@ def generate_tests(namespace = Object, root: nil) def build_test_case(oath_path, root, loaded, store, update, results) rel = Runner.rel_posix(oath_path, root) source = File.read(oath_path, encoding: 'UTF-8') - plan = Runner.plan_oath(File.basename(oath_path), source, loaded.registry) + # `rel`, not the basename: doc.path is an oath's identity in every port, + # so a relative reference resolves alike and two same-named oaths in + # different directories stay distinct (ADR 0016). + plan = Runner.plan_oath(rel, source, loaded.registry) pairs = Runner.examples_with_runs(plan, loaded.create_context, Runner::RecordingReporter.new) klass = Class.new(::Minitest::Test) diff --git a/ruby/packages/rspec/lib/varar/rspec.rb b/ruby/packages/rspec/lib/varar/rspec.rb index de5f0ee1..28615510 100644 --- a/ruby/packages/rspec/lib/varar/rspec.rb +++ b/ruby/packages/rspec/lib/varar/rspec.rb @@ -44,7 +44,10 @@ def generate(root: nil) def define_group(oath_path, root, loaded, store, update, results) rel = Runner.rel_posix(oath_path, root) source = File.read(oath_path, encoding: 'UTF-8') - plan = Runner.plan_oath(File.basename(oath_path), source, loaded.registry) + # `rel`, not the basename: doc.path is an oath's identity in every port, + # so a relative reference resolves alike and two same-named oaths in + # different directories stay distinct (ADR 0016). + plan = Runner.plan_oath(rel, source, loaded.registry) pairs = Runner.examples_with_runs(plan, loaded.create_context, Runner::RecordingReporter.new) drifts = Core::Drifts.reconcile_drift(store, rel, source, plan.doc, plan, update: update) diff --git a/rust/cargotest/src/lib.rs b/rust/cargotest/src/lib.rs index 1d178b7b..17adc4c8 100644 --- a/rust/cargotest/src/lib.rs +++ b/rust/cargotest/src/lib.rs @@ -108,22 +108,21 @@ fn trials_recording( for oath_path in oaths { let source = std::fs::read_to_string(&oath_path).unwrap_or_default(); - let oath_file = oath_path - .file_name() - .unwrap() - .to_string_lossy() - .into_owned(); + // An oath's identity is its workspace-relative POSIX path, not its + // basename: `doc.path` means the same string in every port, so a + // relative reference from one oath to another resolves alike and two + // same-named oaths stay distinct (ADR 0016). let rel = oath_path .strip_prefix(root) .unwrap_or(&oath_path) .to_string_lossy() - .into_owned(); + .replace('\\', "/"); let registry = build_registry(); - let execution = plan_oath(&oath_file, &source, ®istry); + let execution = plan_oath(&rel, &source, ®istry); for (index, display) in example_names(&execution).into_iter().enumerate() { - let (sf, src, r) = (oath_file.clone(), source.clone(), rel.clone()); + let (sf, src, r) = (rel.clone(), source.clone(), rel.clone()); let example = &execution.examples[index]; let name = example.name.clone(); let mut lines: Vec = example @@ -163,7 +162,7 @@ fn trials_recording( // Drift reconciliation (main thread): rewrites the baseline on a clean // run; each drifted paragraph becomes a failing trial (ADR 0002). let mut store = FileBaselineStore::new(root); - let doc = parse(&oath_file, &source); + let doc = parse(&rel, &source); for drifted in reconcile_drift(&mut store, &rel, &source, &doc, &execution, update) { let message = drift::message(&drifted); trials.push(Trial::test(format!("{rel}::varar:drift:{}", drifted.line), move || { diff --git a/typescript/packages/cli/src/lint.ts b/typescript/packages/cli/src/lint.ts index cad331e5..64410c5c 100644 --- a/typescript/packages/cli/src/lint.ts +++ b/typescript/packages/cli/src/lint.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { relative } from 'node:path' import { fileURLToPath } from 'node:url' -import { findFiles, loadConfig } from '@varar/config' +import { findFiles, loadConfig, toOathPath } from '@varar/config' import type { StepRegistration } from '@varar/core' import { loadSteps, planOath } from '@varar/runner' @@ -51,7 +51,7 @@ export async function runLint(opts: LintOptions): Promise { const matched = new Set() for (const path of files) { const source = readFileSync(path, 'utf8') - const execution = planOath(path, source, registry) + const execution = planOath(toOathPath(opts.cwd, path), source, registry) for (const d of execution.diagnostics) { items.push({ path: rel(opts.cwd, path), diff --git a/typescript/packages/config/src/index.ts b/typescript/packages/config/src/index.ts index 77e61a7f..e7c76dba 100644 --- a/typescript/packages/config/src/index.ts +++ b/typescript/packages/config/src/index.ts @@ -1,3 +1,4 @@ export { loadConfig, parseConfig } from './config.ts' export type { Config, Globs, ParsedConfig } from './config-types.ts' export { findFiles } from './find-files.ts' +export { toOathPath } from './oath-path.ts' diff --git a/typescript/packages/config/src/oath-path.ts b/typescript/packages/config/src/oath-path.ts new file mode 100644 index 00000000..3160f0a8 --- /dev/null +++ b/typescript/packages/config/src/oath-path.ts @@ -0,0 +1,15 @@ +import { relative, sep } from 'node:path' + +// An oath's identity: its path relative to the workspace root, POSIX +// separators. The same string keys varar.lock.json and .varar/.json, +// and it is what every port hands to parse()/plan() as `doc.path` — so a +// relative reference from one oath to another resolves against a path that +// means the same thing in every runtime (ADR 0016). +// +// A path outside the root keeps its `../` prefix (oaths may live in a sibling +// directory via a `../shared/**` glob); an unrelatable path falls back to the +// input with POSIX separators. +export function toOathPath(root: string, path: string): string { + const rel = relative(root, path) + return (rel === '' ? path : rel).split(sep).join('/') +} diff --git a/typescript/packages/config/tests/oath-path.test.ts b/typescript/packages/config/tests/oath-path.test.ts new file mode 100644 index 00000000..f928545e --- /dev/null +++ b/typescript/packages/config/tests/oath-path.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest' +import { toOathPath } from '../src/oath-path.ts' + +test('an oath under the root is identified by its relative POSIX path', () => { + expect(toOathPath('/work/proj', '/work/proj/varar/library.md')).toBe('varar/library.md') +}) + +test('an oath outside the root keeps its ../ prefix', () => { + expect(toOathPath('/work/proj', '/work/shared/library.md')).toBe('../shared/library.md') +}) + +test('the root itself falls back to the input rather than an empty identity', () => { + expect(toOathPath('/work/proj', '/work/proj')).toBe('/work/proj') +}) diff --git a/typescript/packages/vitest/src/plugin.ts b/typescript/packages/vitest/src/plugin.ts index e217e4b5..d3a84ab8 100644 --- a/typescript/packages/vitest/src/plugin.ts +++ b/typescript/packages/vitest/src/plugin.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'node:fs' -import { relative, resolve, sep } from 'node:path' -import { findFiles, loadConfig } from '@varar/config' +import { resolve } from 'node:path' +import { findFiles, loadConfig, toOathPath } from '@varar/config' import { type OathBaseline, parseLockFile } from '@varar/core' import type { Plugin } from 'vite' import { configDefaults } from 'vitest/config' @@ -73,14 +73,15 @@ export function vararVitestPlugin(options: VararVitestPluginOptions = {}): Plugi if (configJsonPath) this.addWatchFile(configJsonPath) // Editing the baseline re-transforms so the drift gate reflects it. this.addWatchFile(lockPath) + // This oath's identity: POSIX path relative to cwd. Keys varar.lock.json + // and .varar/, and is the `doc.path` both the static plan below and the + // runtime plan see — they must agree (ADR 0016). + const oathPath = toOathPath(cwd, absPath) const examples = await discoverStaticExamples({ - absPath, + oathPath, source, stepFiles: stepFiles.map((path) => ({ path, source: readFileSync(path, 'utf8') })), }) - // This oath's baseline entry from varar.lock.json (POSIX path, relative to - // cwd), injected so the runtime can run the read-only drift gate. - const oathPath = relative(cwd, absPath).split(sep).join('/') const lock = existsSync(lockPath) ? parseLockFile(readFileSync(lockPath, 'utf8')) : null const baseline = lock?.oaths[oathPath] ?? null return generateVirtualModule({ diff --git a/typescript/packages/vitest/src/static-examples.ts b/typescript/packages/vitest/src/static-examples.ts index 369c431e..e174399f 100644 --- a/typescript/packages/vitest/src/static-examples.ts +++ b/typescript/packages/vitest/src/static-examples.ts @@ -23,7 +23,11 @@ export type StaticExample = { } export type DiscoverInput = { - readonly absPath: string + // The oath's workspace-relative POSIX path — its identity everywhere + // (varar.lock.json, .varar/.json, doc.path). Planning must see the + // same string the runtime does, so a relative reference resolves alike in + // both (ADR 0016). + readonly oathPath: string readonly source: string readonly stepFiles: ReadonlyArray<{ readonly path: string; readonly source: string }> } @@ -44,7 +48,7 @@ export async function discoverStaticExamples( oathFiles: [], scanner, }) - const p = planOath(input.absPath, input.source, registry) + const p = planOath(input.oathPath, input.source, registry) return p.examples.map((ex) => ({ name: ex.name, line: ex.span.startLine, diff --git a/typescript/packages/vitest/tests/static-examples.test.ts b/typescript/packages/vitest/tests/static-examples.test.ts index 29b3d4a8..f5143c5c 100644 --- a/typescript/packages/vitest/tests/static-examples.test.ts +++ b/typescript/packages/vitest/tests/static-examples.test.ts @@ -9,7 +9,7 @@ sensor('the answer is {int}', () => 42) test('discovers only examples with matched steps, named by the whole paragraph', async () => { const source = 'Pure narration, no step.\n\nSo the answer is 42, obviously.\n' const examples = await discoverStaticExamples({ - absPath: '/abs/deep.md', + oathPath: 'varar/deep.md', source, stepFiles: [{ path: '/abs/deep.steps.ts', source: STEPS }], }) @@ -22,7 +22,7 @@ const { sensor } = steps(() => ({})).param('color', /red|green/) sensor('the light is {color}', () => 'green') ` const examples = await discoverStaticExamples({ - absPath: '/abs/light.md', + oathPath: 'varar/light.md', source: 'Right now the light is green.\n', stepFiles: [{ path: '/abs/light.steps.ts', source: stepSource }], }) @@ -31,7 +31,7 @@ sensor('the light is {color}', () => 'green') test('returns an empty list when no paragraph matches any step', async () => { const examples = await discoverStaticExamples({ - absPath: '/abs/prose.md', + oathPath: 'varar/prose.md', source: 'Just words.\n\nMore words.\n', stepFiles: [{ path: '/abs/deep.steps.ts', source: STEPS }], }) diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 5a370f4e..93cedf96 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -6,9 +6,6 @@ importers: .: configDependencies: {} packageManagerDependencies: - '@pnpm/exe': - specifier: 12.3.4 - version: 12.3.4 pnpm: specifier: 12.3.4 version: 12.3.4 @@ -59,11 +56,6 @@ packages: cpu: [x64] os: [win32] - '@pnpm/exe@12.3.4': - resolution: {integrity: sha512-Rq7JokWAyYF9IFfn8zofB/q2AgnZlmoMuH5fMHB2+Ta61I9aD7sNm+woZpx6iH+5vb38chdSoYRvOtSqlH72YQ==} - engines: {node: '>=18.*'} - hasBin: true - pnpm@12.3.4: resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} engines: {node: '>=18.*'} @@ -95,17 +87,6 @@ snapshots: '@pnpm/exe.win32-x64@12.3.4': optional: true - '@pnpm/exe@12.3.4': - optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.3.4 - '@pnpm/exe.darwin-x64': 12.3.4 - '@pnpm/exe.linux-arm64': 12.3.4 - '@pnpm/exe.linux-arm64-musl': 12.3.4 - '@pnpm/exe.linux-x64': 12.3.4 - '@pnpm/exe.linux-x64-musl': 12.3.4 - '@pnpm/exe.win32-arm64': 12.3.4 - '@pnpm/exe.win32-x64': 12.3.4 - pnpm@12.3.4: optionalDependencies: '@pnpm/exe.darwin-arm64': 12.3.4 From 4ed817fe8305cb45848374c9340cfd3c5c2894f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 10:49:56 +0100 Subject: [PATCH 06/21] perf(vscode): the language server reindexes once per typing burst, not per keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A keystroke used to re-run the tree-sitter scan on every step file, re-parse and re-plan every oath, and then parse and plan every oath a second time for the drift pass — with no debouncing, so a burst of typing queued one full workspace reindex per character. Three changes, all invisible except in latency: - buildWorkspaceIndex takes an optional IndexCache, keyed by (path, content hash). Step scans, parsed docs and plans survive across reindexes, so an edit re-scans no step files and re-plans only what changed. A plan is also keyed by the registry's fingerprint, so a step-file edit still invalidates every plan, as it must. - The index exposes each oath's doc and plan, and the drift pass reads them instead of parsing and planning everything again. - Reindexing is debounced 75 ms. The edited buffer is still written through immediately, and every request handler (hover, definition, completion, semantic tokens, and the var/* rename and snippet requests) awaits any pending reindex first — so no feature can answer from an index older than the edit it is answering about. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- .../packages/language/src/index-workspace.ts | 111 ++++++++++++++++-- typescript/packages/language/src/index.ts | 11 +- .../language/tests/index-cache.test.ts | 90 ++++++++++++++ typescript/packages/lsp/src/server.ts | 78 ++++++++++-- typescript/packages/lsp/src/store.ts | 33 ++++-- 5 files changed, 289 insertions(+), 34 deletions(-) create mode 100644 typescript/packages/language/tests/index-cache.test.ts diff --git a/typescript/packages/language/src/index-workspace.ts b/typescript/packages/language/src/index-workspace.ts index 4fad2d12..5903f0f8 100644 --- a/typescript/packages/language/src/index-workspace.ts +++ b/typescript/packages/language/src/index-workspace.ts @@ -1,7 +1,10 @@ import { addStep, createRegistry, + type Doc, defineParameterType, + type ExecutionPlan, + hashSource, parse, plan, type Registry, @@ -9,6 +12,41 @@ import { import type { StepDefScanner } from './scanner.ts' import type { Range, StepDef } from './step-defs.ts' +// Memoisation across reindexes. The LSP rebuilds the whole workspace index on +// every change (see createStore), so without this a keystroke re-runs the +// tree-sitter scan on every step file and re-parses and re-plans every oath. +// Everything cached here is a pure function of a file's content, keyed by +// (path, content hash) — a stale entry is impossible, and an unbounded cache is +// bounded in practice by the number of file versions a session sees. Pass the +// same cache object to every call; omit it for a one-shot index. +export type IndexCache = { + readonly steps: Map + readonly docs: Map + readonly plans: Map +} + +type ScannedSteps = { + readonly parameterTypes: ReadonlyArray<{ readonly name: string; readonly regexp: string }> + readonly stepDefs: ReadonlyArray +} + +export type PlannedOath = { + readonly doc: Doc + readonly plan: ExecutionPlan + readonly matches: ReadonlyArray + readonly diagnostics: ReadonlyArray +} + +export function createIndexCache(): IndexCache { + return { steps: new Map(), docs: new Map(), plans: new Map() } +} + +// A file version's cache key. hashSource is the same FNV-1a every port uses for +// drift baselines, so it is already a dependency and already fast. +function versionKey(path: string, source: string): string { + return `${path}\u0000${hashSource(source)}` +} + export type WorkspaceInput = { readonly stepFiles: ReadonlyArray<{ readonly path: string; readonly source: string }> readonly oathFiles: ReadonlyArray<{ readonly path: string; readonly source: string }> @@ -55,20 +93,45 @@ export type WorkspaceIndex = { // downstream tools — snippet generation, completion, etc. — can use the // same view the matcher used. readonly registry: Registry + // Every oath's parsed document and execution plan, keyed by the path it was + // indexed under. Exposed so a caller that needs the same plan — the LSP's + // drift pass — reuses this one instead of parsing and planning a second time. + readonly oaths: ReadonlyMap } const EMPTY_HANDLER = (): void => {} -export function buildWorkspaceIndex(input: WorkspaceInput): WorkspaceIndex { +export function buildWorkspaceIndex(input: WorkspaceInput, cache?: IndexCache): WorkspaceIndex { const scanner = input.scanner const stepDefs: StepDef[] = [] let registry = createRegistry() + // Scan each step file once — the tree-sitter parse is the most expensive + // thing in a reindex, and a cached hit makes an oath-only edit cost nothing + // here at all. + const scanned = input.stepFiles.map((file) => { + const key = versionKey(file.path, file.source) + const hit = cache?.steps.get(key) + if (hit) return hit + const fresh: ScannedSteps = { + parameterTypes: scanner.discoverParameterTypes(file.path, file.source), + stepDefs: scanner.discoverStepDefs(file.path, file.source), + } + cache?.steps.set(key, fresh) + return fresh + }) + + // The registry's identity: when it changes, every cached plan is stale, since + // which paragraphs are examples depends on the step definitions. + const registryKey = hashSource( + input.stepFiles.map((f) => versionKey(f.path, f.source)).join('\n'), + ) + // First pass: register every custom parameter type. We need them in place // before compiling any step expressions, otherwise a `step('I fly to {airport}')` // discovered in the same file would fail with UndefinedParameterTypeError. - for (const file of input.stepFiles) { - for (const pt of scanner.discoverParameterTypes(file.path, file.source)) { + for (const file of scanned) { + for (const pt of file.parameterTypes) { try { registry = defineParameterType(registry, { name: pt.name, @@ -80,8 +143,8 @@ export function buildWorkspaceIndex(input: WorkspaceInput): WorkspaceIndex { } } - for (const file of input.stepFiles) { - const defs = scanner.discoverStepDefs(file.path, file.source) + for (const file of scanned) { + const defs = file.stepDefs for (const def of defs) { stepDefs.push(def) try { @@ -101,10 +164,28 @@ export function buildWorkspaceIndex(input: WorkspaceInput): WorkspaceIndex { const matches: MatchRef[] = [] const diagnostics: DiagnosticRef[] = [] + const oaths = new Map() for (const file of input.oathFiles) { - const doc = parse(file.path, file.source) + // Parse is pure in the source, so it is cached by content alone; the plan + // and everything derived from it also depend on the registry. + const docKey = versionKey(file.path, file.source) + const planKey = `${docKey}\u0000${registryKey}` + const cached = cache?.plans.get(planKey) + if (cached) { + oaths.set(file.path, cached) + matches.push(...cached.matches) + diagnostics.push(...cached.diagnostics) + continue + } + let doc = cache?.docs.get(docKey) + if (!doc) { + doc = parse(file.path, file.source) + cache?.docs.set(docKey, doc) + } const result = plan(doc, registry) + const fileMatches: MatchRef[] = [] + const fileDiagnostics: DiagnosticRef[] = [] // Header-bound tables expand to one example per row, all sharing the same // binding paragraph. For highlighting we want the paragraph (with its // header-cell words as parameters) once — not the per-row table lines the @@ -120,7 +201,7 @@ export function buildWorkspaceIndex(input: WorkspaceInput): WorkspaceIndex { (d) => d.expression === b.stepDef.expression && d.file === b.stepDef.expressionSourceFile, ) if (!def) continue - matches.push({ + fileMatches.push({ oathPath: file.path, range: toRange(b.matchSpan), paramRanges: b.paramSpans.map(toRange), @@ -137,7 +218,7 @@ export function buildWorkspaceIndex(input: WorkspaceInput): WorkspaceIndex { d.file === step.stepDef.expressionSourceFile, ) if (!def) continue - matches.push({ + fileMatches.push({ oathPath: file.path, range: toRange(step.matchSpan), // Highlight only the value passed to the handler (inner capture @@ -149,7 +230,7 @@ export function buildWorkspaceIndex(input: WorkspaceInput): WorkspaceIndex { } } for (const d of result.diagnostics) { - diagnostics.push({ + fileDiagnostics.push({ oathPath: file.path, code: d.code, severity: d.severity, @@ -157,9 +238,19 @@ export function buildWorkspaceIndex(input: WorkspaceInput): WorkspaceIndex { range: toRange(d.span), }) } + const planned: PlannedOath = { + doc, + plan: result, + matches: fileMatches, + diagnostics: fileDiagnostics, + } + cache?.plans.set(planKey, planned) + oaths.set(file.path, planned) + matches.push(...fileMatches) + diagnostics.push(...fileDiagnostics) } - return { stepDefs, matches, diagnostics, registry } + return { stepDefs, matches, diagnostics, registry, oaths } } type SpanLike = { diff --git a/typescript/packages/language/src/index.ts b/typescript/packages/language/src/index.ts index 46508241..0e5dac74 100644 --- a/typescript/packages/language/src/index.ts +++ b/typescript/packages/language/src/index.ts @@ -1,6 +1,13 @@ export type { GrammarLoader } from './grammar-loader.ts' -export type { DiagnosticRef, MatchRef, WorkspaceIndex, WorkspaceInput } from './index-workspace.ts' -export { buildWorkspaceIndex } from './index-workspace.ts' +export type { + DiagnosticRef, + IndexCache, + MatchRef, + PlannedOath, + WorkspaceIndex, + WorkspaceInput, +} from './index-workspace.ts' +export { buildWorkspaceIndex, createIndexCache } from './index-workspace.ts' export type { StepDefScanner } from './scanner.ts' export type { Snippet } from './snippet.ts' export { generateSnippet } from './snippet.ts' diff --git a/typescript/packages/language/tests/index-cache.test.ts b/typescript/packages/language/tests/index-cache.test.ts new file mode 100644 index 00000000..78af6d31 --- /dev/null +++ b/typescript/packages/language/tests/index-cache.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from 'vitest' +import { buildWorkspaceIndex, createIndexCache } from '../src/index-workspace.ts' +import type { StepDefScanner } from '../src/scanner.ts' + +const STEPS = { path: '/w/greet.steps.ts', source: "sensor('I greet {string}', ...)" } +const OATH = { path: '/w/varar/a.md', source: 'First I greet "world".\n' } + +// A scanner that reports one step def and counts how often it was asked. The +// real one runs tree-sitter, which is the cost the cache exists to avoid. +function countingScanner(): StepDefScanner & { calls: () => number } { + let calls = 0 + return { + calls: () => calls, + discoverParameterTypes: () => [], + discoverStepDefs: (file: string) => { + calls++ + return [ + { + expression: 'I greet {string}', + kind: 'sensor' as const, + file, + expressionRange: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 20 }, + }, + range: { start: { line: 1, character: 1 }, end: { line: 1, character: 20 } }, + callRange: { start: { line: 1, character: 1 }, end: { line: 1, character: 20 } }, + }, + ] + }, + } +} + +test('a second index with the same cache re-scans no step file', () => { + const scanner = countingScanner() + const cache = createIndexCache() + const input = { stepFiles: [STEPS], oathFiles: [OATH], scanner } + + buildWorkspaceIndex(input, cache) + expect(scanner.calls()).toBe(1) + buildWorkspaceIndex(input, cache) + expect(scanner.calls()).toBe(1) +}) + +test('an edited step file is re-scanned; its unchanged siblings are not', () => { + const scanner = countingScanner() + const cache = createIndexCache() + const other = { path: '/w/other.steps.ts', source: "sensor('unrelated', ...)" } + + buildWorkspaceIndex({ stepFiles: [STEPS, other], oathFiles: [OATH], scanner }, cache) + expect(scanner.calls()).toBe(2) + + buildWorkspaceIndex( + { + stepFiles: [{ ...STEPS, source: `${STEPS.source}\n// edited` }, other], + oathFiles: [OATH], + scanner, + }, + cache, + ) + expect(scanner.calls()).toBe(3) +}) + +test('the index exposes each oath’s doc and plan, so drift need not re-plan', () => { + const scanner = countingScanner() + const idx = buildWorkspaceIndex({ stepFiles: [STEPS], oathFiles: [OATH], scanner }) + const planned = idx.oaths.get(OATH.path) + + expect(planned?.doc.path).toBe(OATH.path) + expect(planned?.plan.examples.map((e) => e.name)).toEqual(['First I greet "world"']) +}) + +test('a cached plan is reused only while the registry is unchanged', () => { + const scanner = countingScanner() + const cache = createIndexCache() + const first = buildWorkspaceIndex({ stepFiles: [STEPS], oathFiles: [OATH], scanner }, cache) + const cached = buildWorkspaceIndex({ stepFiles: [STEPS], oathFiles: [OATH], scanner }, cache) + // Same source, same registry → the very same planned object comes back. + expect(cached.oaths.get(OATH.path)).toBe(first.oaths.get(OATH.path)) + + const afterStepEdit = buildWorkspaceIndex( + { + stepFiles: [{ ...STEPS, source: `${STEPS.source}\n// edited` }], + oathFiles: [OATH], + scanner, + }, + cache, + ) + expect(afterStepEdit.oaths.get(OATH.path)).not.toBe(first.oaths.get(OATH.path)) +}) diff --git a/typescript/packages/lsp/src/server.ts b/typescript/packages/lsp/src/server.ts index c08a7b47..de5884bd 100644 --- a/typescript/packages/lsp/src/server.ts +++ b/typescript/packages/lsp/src/server.ts @@ -76,13 +76,49 @@ export function registerHandlers( } }) - // Write-through: persist edited docs to the FileSystem, then reindex. + // Reindexing is whole-workspace, so a burst of keystrokes must not queue one + // per character. The buffer is written through immediately — the filesystem + // is the source of truth and stays current — and only the derived index is + // debounced. Every request handler awaits `settled()` first, so no feature + // can observe an index older than the edit it is answering about. + const REINDEX_DEBOUNCE_MS = 75 + let debounceTimer: ReturnType | undefined + let dirty = false + // Reindexes are serialised through this chain: store.reindex() is async, and + // two overlapping runs would race to assign the index. + let inFlight: Promise = Promise.resolve() + + function scheduleReindex(): void { + dirty = true + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + void settled() + }, REINDEX_DEBOUNCE_MS) + } + + // Run any pending reindex now and wait for it (plus whatever was already in + // flight). Safe to call when nothing is pending: it awaits the current chain. + function settled(): Promise { + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = undefined + } + if (!dirty) return inFlight + dirty = false + inFlight = inFlight.then(async () => { + if (!store) return + await store.reindex() + afterReindex() + }) + return inFlight + } + + // Write-through: persist edited docs to the FileSystem, then reindex (debounced). documents.onDidChangeContent(async (e) => { await opts?.onDidChangeDocument?.(e.document.uri, e.document.getText()) if (!store) return await store.fs().write(uriToPath(e.document.uri), e.document.getText()) - await store.reindex() - afterReindex() + scheduleReindex() }) connection.onDidChangeWatchedFiles(async (params) => { @@ -182,7 +218,8 @@ export function registerHandlers( afterReindex() }) - connection.onHover((params) => { + connection.onHover(async (params) => { + await settled() if (!handlers) return null const result = handlers.hover({ uri: params.textDocument.uri, @@ -191,7 +228,8 @@ export function registerHandlers( return result === null ? null : { contents: result.contents } }) - connection.onDefinition((params) => { + connection.onDefinition(async (params) => { + await settled() if (!handlers) return [] const links = handlers.definition({ uri: params.textDocument.uri, @@ -206,7 +244,12 @@ export function registerHandlers( // text → snippet. connection.onRequest( 'var/generateSnippet', - (params: { text: string; uri?: string; position?: { line: number; character: number } }) => { + async (params: { + text: string + uri?: string + position?: { line: number; character: number } + }) => { + await settled() if (!handlers) return null return handlers.generateSnippet({ text: params.text, @@ -226,7 +269,8 @@ export function registerHandlers( // captured values. Returns null when the position isn't on a step. connection.onRequest( 'var/stepAt', - (params: { uri: string; position: { line: number; character: number } }) => { + async (params: { uri: string; position: { line: number; character: number } }) => { + await settled() if (!handlers) return null return handlers.stepAt(params) }, @@ -237,7 +281,12 @@ export function registerHandlers( // Phase 3 path: refuses when any parameter is added/removed/type-changed. connection.onRequest | null, void>( 'var/renameStep', - (params: { uri: string; position: { line: number; character: number }; newName: string }) => { + async (params: { + uri: string + position: { line: number; character: number } + newName: string + }) => { + await settled() if (!handlers) return null return handlers.renameStep(params) }, @@ -248,7 +297,12 @@ export function registerHandlers( // parameters before applying anything. connection.onRequest | null, void>( 'var/planRename', - (params: { uri: string; position: { line: number; character: number }; newName: string }) => { + async (params: { + uri: string + position: { line: number; character: number } + newName: string + }) => { + await settled() if (!handlers) return null return handlers.planRename(params) }, @@ -266,7 +320,8 @@ export function registerHandlers( connection.onRequest( 'textDocument/semanticTokens/full', - (params: { textDocument: { uri: string } }) => { + async (params: { textDocument: { uri: string } }) => { + await settled() if (!store) return { data: [] } const uri = params.textDocument.uri const source = documents.get(uri)?.getText() ?? '' @@ -274,7 +329,8 @@ export function registerHandlers( }, ) - connection.onCompletion((params) => { + connection.onCompletion(async (params) => { + await settled() if (!handlers) return [] const doc = documents.get(params.textDocument.uri) const line = doc diff --git a/typescript/packages/lsp/src/store.ts b/typescript/packages/lsp/src/store.ts index 8f07bfd7..048c08c8 100644 --- a/typescript/packages/lsp/src/store.ts +++ b/typescript/packages/lsp/src/store.ts @@ -8,15 +8,16 @@ import { parse, parseLockFile, plan, - type Registry, stringifyLockFile, } from '@varar/core' import { buildWorkspaceIndex, + createIndexCache, createTreeSitterScanner, type DiagnosticRef, type GrammarLoader, languageIdForPath, + type PlannedOath, type StepDefScanner, type WorkspaceIndex, } from '@varar/language' @@ -60,7 +61,10 @@ export type Store = { async function driftDiagnosticRefs( fs: FileSystem, oathFiles: ReadonlyArray<{ readonly path: string; readonly source: string }>, - registry: Registry, + // The plans the workspace index just built. Drift used to re-parse and + // re-plan every oath here, doubling the cost of every keystroke; it reads + // them from the index instead. + planned: ReadonlyMap, ): Promise { const [lockAbs] = await fs.list({ include: ['varar.lock.json'], exclude: [] }) if (!lockAbs) return [] @@ -80,9 +84,9 @@ async function driftDiagnosticRefs( const oathPath = toOathPath(root, vf.path) const baseline = lock.oaths[oathPath] if (!baseline) continue - const doc = parse(vf.path, vf.source) - const executionPlan = plan(doc, registry) - for (const drift of detectDrift(baseline, doc, executionPlan)) { + const indexed = planned.get(vf.path) + if (!indexed) continue + for (const drift of detectDrift(baseline, indexed.doc, indexed.plan)) { const diag = driftDetected({ name: drift.name, span: drift.span }) refs.push({ oathPath: vf.path, @@ -116,12 +120,16 @@ export function createStore(deps: StoreDeps): Store { matches: [], diagnostics: [], registry: createRegistry(), + oaths: new Map(), } // Created once, lazily, on the first reindex — not in createStore itself, // which stays synchronous. Later reindexes reuse it. let scannerPromise: Promise | undefined let scannerKey: string | undefined let currentStepPaths: ReadonlyArray = [] + // Survives across reindexes: a keystroke then re-scans no step files and + // re-parses and re-plans only the file that changed. + const indexCache = createIndexCache() return { async reindex() { const stepPaths = await fs.list({ include: config.steps, exclude: [] }) @@ -153,16 +161,19 @@ export function createStore(deps: StoreDeps): Store { const oathFiles = await Promise.all( varPaths.map(async (path) => ({ path, source: await fs.read(path) })), ) - current = buildWorkspaceIndex({ - stepFiles, - oathFiles, - scanner, - }) + current = buildWorkspaceIndex( + { + stepFiles, + oathFiles, + scanner, + }, + indexCache, + ) // Drift is a run-result concern, but the LSP surfaces it live: a // paragraph the committed varar.lock.json recorded as an example that now // matches no step gets a warning squiggle. Additive to the index's own // parse/plan diagnostics. - const drift = await driftDiagnosticRefs(fs, oathFiles, current.registry) + const drift = await driftDiagnosticRefs(fs, oathFiles, current.oaths) if (drift.length > 0) current = { ...current, diagnostics: [...current.diagnostics, ...drift] } }, From 397a3894875b721b1d791a002c84eeda89eaf85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 10:58:19 +0100 Subject: [PATCH 07/21] feat(ts): reuse setup between examples by linking to a section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A block whose entire content is a Markdown link to an oath section — a paragraph, a blockquote or a list item — is a reference block: it splices that section's steps in at its own position, sharing the example's state. It is an ordinary link, so it renders and navigates on GitHub. [An overdue loan](./shared/an-overdue-loan.md#an-overdue-loan) When she asks to borrow *Beloved* on June 10, 2026, the library refuses. Because it is positional it also does what Cucumber's Background cannot: a shared act mid-example, or shared assertions at the end. The target may be a #fragment in the same oath or a relative .md path, with or without a fragment; references nest to any depth, and a cycle is reported with its chain rather than recursed into. A link-only block pointing anywhere else (https:, a .ts file) stays prose, so existing oaths keep their meaning. A section that is referenced stops being a standalone example — it runs where it is referenced, once per referencing example. That is whole-project knowledge, so plan() now takes the workspace as a required argument: a caller that has not built one would otherwise run consumed sections as standalone examples, which is green and wrong. An oath whose sections are all consumed registers one bookkeeping test rather than nothing: vitest fails a module that declares no test at all, and the oath still needs its drift baseline and its .varar record written. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- conformance/adapter/smoke.sh | 4 +- examples/typescript-vitest/varar.lock.json | 29 ++ examples/typescript-vitest/varar/reuse.md | 17 ++ .../varar/shared/an-overdue-loan.md | 9 + typescript/packages/cli/src/lint.ts | 12 +- typescript/packages/core/src/conformance.ts | 6 +- typescript/packages/core/src/diagnostics.ts | 61 ++++- typescript/packages/core/src/index.ts | 11 + typescript/packages/core/src/plan.ts | 170 +++++++++++- typescript/packages/core/src/reference.ts | 148 ++++++++++ .../packages/core/tests/conformance.test.ts | 5 +- typescript/packages/core/tests/drift.test.ts | 97 ++++--- typescript/packages/core/tests/e2e.test.ts | 3 +- .../packages/core/tests/execute-roles.test.ts | 3 +- .../packages/core/tests/execute-state.test.ts | 3 +- .../packages/core/tests/execute.test.ts | 41 +-- .../core/tests/failure-step-span.test.ts | 3 +- typescript/packages/core/tests/index.test.ts | 6 +- typescript/packages/core/tests/plan.test.ts | 65 ++--- .../packages/core/tests/reference.test.ts | 256 ++++++++++++++++++ .../packages/language/src/index-workspace.ts | 61 ++++- typescript/packages/lsp/src/store.ts | 8 +- typescript/packages/runner/src/run.ts | 15 +- typescript/packages/runner/tests/run.test.ts | 10 +- typescript/packages/vitest/src/plugin.ts | 42 ++- typescript/packages/vitest/src/reporter.ts | 11 +- typescript/packages/vitest/src/runtime.ts | 49 +++- .../packages/vitest/src/static-examples.ts | 7 +- typescript/packages/vitest/src/workspace.ts | 86 ++++++ .../packages/vitest/tests/plugin.test.ts | 2 +- 30 files changed, 1109 insertions(+), 131 deletions(-) create mode 100644 examples/typescript-vitest/varar/reuse.md create mode 100644 examples/typescript-vitest/varar/shared/an-overdue-loan.md create mode 100644 typescript/packages/core/src/reference.ts create mode 100644 typescript/packages/core/tests/reference.test.ts create mode 100644 typescript/packages/vitest/src/workspace.ts diff --git a/conformance/adapter/smoke.sh b/conformance/adapter/smoke.sh index 4a2e151b..a2ec0d73 100755 --- a/conformance/adapter/smoke.sh +++ b/conformance/adapter/smoke.sh @@ -64,7 +64,9 @@ oaths_on_disk() { [ "$include" = "varar/**/*.md" ] || fail "$dir: the smoke contract assumes oaths live in varar/" \ "varar.config.json docs.include is [$include]" \ "Teach smoke.sh to glob, or move the oaths (see CLAUDE.md — 'varar means oaths')." - (cd "$REPO_ROOT/$dir" && ls varar/*.md | sort) + # `varar/**/*.md` is recursive, and a project may nest oaths (shared sections + # referenced from elsewhere conventionally live in varar/shared/ — ADR 0016). + (cd "$REPO_ROOT/$dir" && find varar -name '*.md' | sed 's|^\./||' | sort) } run_contract() { diff --git a/examples/typescript-vitest/varar.lock.json b/examples/typescript-vitest/varar.lock.json index 67821b88..4b44211e 100644 --- a/examples/typescript-vitest/varar.lock.json +++ b/examples/typescript-vitest/varar.lock.json @@ -58,6 +58,31 @@ } ] }, + "varar/reuse.md": { + "sourceHash": "fnv1a:a0e53ea6", + "examples": [ + { + "name": "Both examples below start from the same borrowed book. Rather than repeating it, each one links to the section that describes it — the link renders on GitHub, and clicking it takes you to the world state being assumed", + "line": 3 + }, + { + "name": "[An overdue loan](./shared/an-overdue-loan.md#an-overdue-loan)", + "line": 9 + }, + { + "name": "When she asks to borrow *Beloved* on June 10, 2026, the library refuses: an overdue book blocks new loans", + "line": 11 + }, + { + "name": "[An overdue loan](./shared/an-overdue-loan.md#an-overdue-loan)", + "line": 15 + }, + { + "name": "She returns it on June 6, 2026 and owes a £2.50 late fee", + "line": 17 + } + ] + }, "varar/roman-numerals.md": { "sourceHash": "fnv1a:a3b97c9a", "examples": [ @@ -67,6 +92,10 @@ } ] }, + "varar/shared/an-overdue-loan.md": { + "sourceHash": "fnv1a:cf0dea5f", + "examples": [] + }, "varar/tables-and-docstrings.md": { "sourceHash": "fnv1a:f45255ca", "examples": [ diff --git a/examples/typescript-vitest/varar/reuse.md b/examples/typescript-vitest/varar/reuse.md new file mode 100644 index 00000000..6cce5217 --- /dev/null +++ b/examples/typescript-vitest/varar/reuse.md @@ -0,0 +1,17 @@ +# Reusing a world state + +Both examples below start from the same borrowed book. Rather than repeating +it, each one links to the section that describes it — the link renders on +GitHub, and clicking it takes you to the world state being assumed. + +## Borrowing while overdue + +[An overdue loan](./shared/an-overdue-loan.md#an-overdue-loan) + +When she asks to borrow *Beloved* on June 10, 2026, the library refuses: an overdue book blocks new loans. + +## The fee for returning it late + +[An overdue loan](./shared/an-overdue-loan.md#an-overdue-loan) + +She returns it on June 6, 2026 and owes a £2.50 late fee. diff --git a/examples/typescript-vitest/varar/shared/an-overdue-loan.md b/examples/typescript-vitest/varar/shared/an-overdue-loan.md new file mode 100644 index 00000000..5e8de1be --- /dev/null +++ b/examples/typescript-vitest/varar/shared/an-overdue-loan.md @@ -0,0 +1,9 @@ +# Shared world states + +The sections below are not examples in their own right — each one is linked +from the oaths that need it, and runs there. Reuse is a link: see +[Reuse](../reuse.md). + +## An overdue loan + +Noor borrowed *Kindred*, due back on June 1, 2026. diff --git a/typescript/packages/cli/src/lint.ts b/typescript/packages/cli/src/lint.ts index 64410c5c..5af2d7d1 100644 --- a/typescript/packages/cli/src/lint.ts +++ b/typescript/packages/cli/src/lint.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { relative } from 'node:path' import { fileURLToPath } from 'node:url' import { findFiles, loadConfig, toOathPath } from '@varar/config' -import type { StepRegistration } from '@varar/core' +import { buildWorkspace, parse, type StepRegistration } from '@varar/core' import { loadSteps, planOath } from '@varar/runner' export type LintOptions = { @@ -49,9 +49,15 @@ export async function runLint(opts: LintOptions): Promise { const items: Item[] = [] const matched = new Set() + // Parse every oath before planning any: a section another oath references is + // not a standalone example, which is whole-project knowledge (ADR 0016). + const sources = new Map(files.map((path) => [path, readFileSync(path, 'utf8')])) + const workspace = buildWorkspace( + files.map((path) => parse(toOathPath(opts.cwd, path), sources.get(path) ?? '')), + ) for (const path of files) { - const source = readFileSync(path, 'utf8') - const execution = planOath(toOathPath(opts.cwd, path), source, registry) + const source = sources.get(path) ?? '' + const execution = planOath(toOathPath(opts.cwd, path), source, registry, workspace) for (const d of execution.diagnostics) { items.push({ path: rel(opts.cwd, path), diff --git a/typescript/packages/core/src/conformance.ts b/typescript/packages/core/src/conformance.ts index 01dcd706..9f1d463d 100644 --- a/typescript/packages/core/src/conformance.ts +++ b/typescript/packages/core/src/conformance.ts @@ -5,6 +5,7 @@ import type { DiagnosticCode, Severity } from './diagnostics.ts' import { collectExamples, isUnexpectedPassError, type StepObservation } from './execute.ts' import { failureAnchor } from './failure-anchor.ts' import { plan as buildPlan, type ExecutionPlan } from './plan.ts' +import { emptyWorkspace, type OathWorkspace } from './reference.ts' import type { Registry } from './registry.ts' import type { Span } from './span.ts' @@ -246,8 +247,11 @@ export async function runConformance( registry: Registry, createContext: (stepFile: string) => unknown | Promise, parameterTypes: ReadonlyArray<{ name: string; regexp: string }> = [], + // The other oaths in the bundle, for a bundle whose oath references them + // (ADR 0016). A single-document bundle passes nothing. + workspace: OathWorkspace = emptyWorkspace(), ): Promise { - const execution = buildPlan(doc, registry) + const execution = buildPlan(doc, registry, workspace) const observed = new Map() const queue = collectExamples(execution, { diff --git a/typescript/packages/core/src/diagnostics.ts b/typescript/packages/core/src/diagnostics.ts index 54147b31..9476e743 100644 --- a/typescript/packages/core/src/diagnostics.ts +++ b/typescript/packages/core/src/diagnostics.ts @@ -9,7 +9,13 @@ export type Diagnostic = { readonly span: Span } -export type DiagnosticCode = 'ambiguous-match' | 'error-fence-without-step' | 'drift' +export type DiagnosticCode = + | 'ambiguous-match' + | 'error-fence-without-step' + | 'drift' + | 'reference-not-found' + | 'reference-empty' + | 'reference-cycle' export type Candidate = { readonly expression: string @@ -62,3 +68,56 @@ export function errorFenceWithoutStep(input: { readonly span: Span }): Diagnosti span: input.span, } } + +// A reference block (ADR 0016) points at an oath the workspace does not hold. +// Never prose: a link-only block that resolves to nothing has no other reading, +// so it fails the run rather than degrading silently. +export function referenceNotFound(input: { + readonly text: string + readonly path: string + readonly span: Span +}): Diagnostic { + return { + severity: 'error', + code: 'reference-not-found', + message: + `Reference to "${input.text}" points at "${input.path}", which is not an oath in this ` + + 'workspace.\nCheck the path, and that the file is matched by the `docs` globs in ' + + 'varar.config.json.', + span: input.span, + } +} + +// The referenced document exists but the section contributes no steps — a +// mistyped anchor, or a section that is pure prose. +export function referenceEmpty(input: { + readonly text: string + readonly path: string + readonly slug: string + readonly span: Span +}): Diagnostic { + const where = input.slug === '' ? input.path : `${input.path}#${input.slug}` + return { + severity: 'error', + code: 'reference-empty', + message: + `Reference to "${input.text}" resolves to "${where}", which contributes no steps.\n` + + 'Check the heading the anchor names, and that its section contains a matching paragraph.', + span: input.span, + } +} + +// References may nest to any depth (depth is a style question, not a rule), so +// a chain that reaches a section already on it must be reported rather than +// recursed into. +export function referenceCycle(input: { + readonly chain: ReadonlyArray + readonly span: Span +}): Diagnostic { + return { + severity: 'error', + code: 'reference-cycle', + message: `Reference cycle: ${input.chain.join(' → ')}.`, + span: input.span, + } +} diff --git a/typescript/packages/core/src/index.ts b/typescript/packages/core/src/index.ts index c7c2c27b..313752b6 100644 --- a/typescript/packages/core/src/index.ts +++ b/typescript/packages/core/src/index.ts @@ -79,6 +79,17 @@ export { parse } from './parse.ts' export type { ExecutionPlan, PlannedExample, PlannedStep } from './plan.ts' export { plan } from './plan.ts' export type { BaselineStore, Reporter, TestSink } from './ports.ts' +export { + buildWorkspace, + emptyWorkspace, + type OathWorkspace, + type Reference, + referenceOf, + references, + sectionCandidates, + sectionKey, + slugify, +} from './reference.ts' export type { ParameterTypeInput, Registry, diff --git a/typescript/packages/core/src/plan.ts b/typescript/packages/core/src/plan.ts index b4507a4b..340df23e 100644 --- a/typescript/packages/core/src/plan.ts +++ b/typescript/packages/core/src/plan.ts @@ -1,7 +1,22 @@ import type { Block, Doc, Fence, SegmentOffset, Table } from './ast.ts' import type { RowCheck } from './cell-diff.ts' -import { ambiguousMatch, type Diagnostic, errorFenceWithoutStep } from './diagnostics.ts' +import { + ambiguousMatch, + type Diagnostic, + errorFenceWithoutStep, + referenceCycle, + referenceEmpty, + referenceNotFound, +} from './diagnostics.ts' import { findHits, type Hit, resolveHits } from './matcher.ts' +import { + type OathWorkspace, + type Reference, + referenceOf, + sectionCandidates, + sectionKey, + slugify as slugOf, +} from './reference.ts' import type { ParameterFormat, Registry, StepRegistration } from './registry.ts' import { splitSentences } from './sentences.ts' import { type Span, spanFromOffsets } from './span.ts' @@ -48,6 +63,10 @@ export type HeaderBinding = { export type PlannedStep = { readonly text: string readonly matchSpan: Span + // Set only when this step was spliced in from another oath by a reference + // block (ADR 0016): the path of the document its spans belong to. Absent + // means the example's own document, which is the overwhelming majority. + readonly docPath?: string // Whole matched notation per parameter, incl. delimiters (e.g. quotes) — // used for rename and the "actual" side of a mismatch. readonly paramSpans: ReadonlyArray @@ -68,11 +87,27 @@ export type PlannedStep = { } } -export function plan(doc: Doc, registry: Registry): ExecutionPlan { +export function plan( + doc: Doc, + registry: Registry, + // Every oath in the project, plus which sections a reference block consumes + // (ADR 0016). Required, not defaulted: a caller that has not built it would + // otherwise silently run consumed sections as standalone examples — green, + // and wrong. Pass emptyWorkspace() to plan a document in isolation. + workspace: OathWorkspace, +): ExecutionPlan { const diagnostics: Diagnostic[] = [] + // A section another oath references stops being a standalone example: it runs + // where it is referenced, not here. + const consumed = (ex: Doc['examples'][number]): boolean => + ex.scopeStack.some((h) => workspace.referenced.has(sectionKey(doc.path, slugOf(h)))) || + workspace.referenced.has(sectionKey(doc.path, '')) + // Phase 1: plan each candidate paragraph independently into a "unit". - const units = doc.examples.map((ex) => planCandidate(ex, doc, registry, diagnostics)) + const units = doc.examples + .filter((ex) => !consumed(ex)) + .map((ex) => planCandidate(ex, doc, registry, diagnostics)) // Phase 2: group adjacent candidates into examples. A matching candidate // continues the open example when no delimiter (heading / `---`) precedes it; @@ -91,6 +126,29 @@ export function plan(doc: Doc, registry: Registry): ExecutionPlan { examples.push(...unit.rows) continue } + if (unit.kind === 'reference') { + // Splice the referenced section's steps in at this position. The steps + // join the open example (sharing its state) unless a delimiter separates + // them, in which case this reference starts a new example — the same + // grouping rule every other candidate follows. + const resolved = resolveReference(unit, doc, registry, workspace, diagnostics, []) + resolved.forEach((spliced, i) => { + // Only the reference block itself is subject to the delimiter rule. + // Everything it splices in belongs to the same sequence, so a section + // of several paragraphs stays one example rather than fragmenting. + if (open && (i > 0 || !unit.precededByDelimiter)) { + mergeInto(open, spliced, true) + } else { + flush() + open = startMerged(spliced) + // An example that OPENS with a reference is named by its own first + // matching paragraph, not by the section it pulls in — otherwise + // every example under a shared setup carries the same name. + if (open) open.nameFromReference = true + } + }) + continue + } if (!unit.matched) { // Prose paragraph — a delimiter. Drop it and end the open example. flush() @@ -111,10 +169,82 @@ export function plan(doc: Doc, registry: Registry): ExecutionPlan { return { doc, examples, diagnostics } } +// Resolve one reference block into the step-bearing units of the section it +// names, recursively: a referenced section may itself contain reference blocks, +// to any depth (ADR 0016 leaves depth to the author's judgement). `chain` +// carries the sections currently being resolved so a repeat is reported as a +// cycle instead of recursing forever. +function resolveReference( + unit: Extract, + from: Doc, + registry: Registry, + workspace: OathWorkspace, + diagnostics: Diagnostic[], + chain: ReadonlyArray, +): ReadonlyArray> { + const { reference } = unit + const key = sectionKey(reference.path, reference.slug) + if (chain.includes(key)) { + diagnostics.push(referenceCycle({ chain: [...chain, key], span: unit.span })) + return [] + } + // A same-file reference resolves against the document being planned, which is + // not necessarily in the workspace (a caller may plan a document in isolation). + const target = reference.path === from.path ? from : workspace.docs.get(reference.path) + if (!target) { + diagnostics.push( + referenceNotFound({ text: reference.text, path: reference.path, span: unit.span }), + ) + return [] + } + const out: Array> = [] + for (const candidate of sectionCandidates(target, reference.slug)) { + const planned = planCandidate(candidate, target, registry, diagnostics) + if (planned.kind === 'reference') { + out.push( + ...resolveReference(planned, target, registry, workspace, diagnostics, [...chain, key]), + ) + continue + } + // A header-bound table produces one example per row, which a spliced step + // list cannot express; an `error` fence declares an outcome for an example, + // not for a reusable fragment. Both are left out, and the section reads as + // empty if that is all it held. + if (planned.kind !== 'steps' || !planned.matched) continue + out.push(tagWithDoc(planned, target.path, from.path)) + } + if (out.length === 0) { + diagnostics.push( + referenceEmpty({ + text: reference.text, + path: reference.path, + slug: reference.slug, + span: unit.span, + }), + ) + } + return out +} + +// Carry the source document's identity on every spliced step, so a failure in +// a referenced section reports spans against the file they were written in +// rather than the file being run. +function tagWithDoc( + unit: Extract, + docPath: string, + hostPath: string, +): Extract { + if (docPath === hostPath) return unit + return { ...unit, steps: unit.steps.map((step) => ({ ...step, docPath })) } +} + // A step-bearing candidate accumulating into one example while adjacent matching // candidates keep merging in. type MergedExample = { name: string + // True while the name came from a spliced (referenced) paragraph and is + // waiting to be replaced by the example's own first matching paragraph. + nameFromReference?: boolean scopeStack: ReadonlyArray startOffset: number endOffset: number @@ -126,6 +256,15 @@ type MergedExample = { // One candidate paragraph, planned in isolation. type CandidateUnit = | { readonly kind: 'header-bound'; readonly rows: ReadonlyArray } + | { + // A reference block: its whole text is a link to an oath section, whose + // steps are spliced in here (ADR 0016). Never prose, so it does not close + // the open example. + readonly kind: 'reference' + readonly reference: Reference + readonly precededByDelimiter: boolean + readonly span: Span + } | { readonly kind: 'steps' readonly matched: boolean @@ -150,7 +289,16 @@ function startMerged(unit: Extract): MergedExa } } -function mergeInto(open: MergedExample, unit: Extract): void { +function mergeInto( + open: MergedExample, + unit: Extract, + fromReference = false, +): void { + if (open.nameFromReference && !fromReference) { + open.name = unit.name + open.scopeStack = unit.scopeStack + open.nameFromReference = false + } open.endOffset = unit.span.endOffset open.steps.push(...unit.steps) // Any error fence in a merged part marks the whole example expected-to-fail; @@ -183,6 +331,20 @@ function planCandidate( registry: Registry, diagnostics: Diagnostic[], ): CandidateUnit { + // A block whose whole text is a link to an oath section is a reference, not + // content: it is never matched against step definitions, and never prose. + const primary = ex.body[0] + if (primary && 'text' in primary) { + const reference = referenceOf(primary.text, doc.path) + if (reference) { + return { + kind: 'reference', + reference, + precededByDelimiter: ex.precededByDelimiter, + span: ex.span, + } + } + } let hadAmbiguous = false // Pass 1: plan each text-bearing block and collect steps per body index. diff --git a/typescript/packages/core/src/reference.ts b/typescript/packages/core/src/reference.ts new file mode 100644 index 00000000..88b61fd6 --- /dev/null +++ b/typescript/packages/core/src/reference.ts @@ -0,0 +1,148 @@ +import type { Doc, Example } from './ast.ts' + +// Reuse is a link (ADR 0016). A candidate block whose entire content is a +// single Markdown link to an oath section is a REFERENCE BLOCK: it splices +// that section's steps in at its own position instead of being prose. +// +// Everything here is pure text and path arithmetic — no filesystem. The shell +// reads the documents; `references()` tells it which ones to read, and +// `buildWorkspace()` turns the collection into what `plan()` needs. + +export type Reference = { + // The referenced oath's path, resolved against the referring doc's own path. + // Equal to the referring doc's path for a same-file `#fragment` link. + readonly path: string + // The GFM slug of the heading being referenced, or '' for a whole-file link. + readonly slug: string + // The link's visible text, as written. + readonly text: string +} + +// A candidate is a reference block iff its whole text is one Markdown link +// whose target is oath-shaped. Anything else — a link with surrounding words, a +// link to https://…, to a .ts file, to a mailto: — is ordinary content, so +// existing documents keep their meaning. +const LINK_ONLY = /^\[([^\]]*)\]\(\s*([^\s)]+)\s*\)$/ + +export function referenceOf(text: string, fromPath: string): Reference | undefined { + const m = LINK_ONLY.exec(text.trim()) + if (!m) return undefined + const [, linkText = '', target = ''] = m + if (target.startsWith('#')) { + return { path: fromPath, slug: normalizeSlug(target.slice(1)), text: linkText } + } + const hash = target.indexOf('#') + const filePart = hash === -1 ? target : target.slice(0, hash) + const fragment = hash === -1 ? '' : target.slice(hash + 1) + // Only a relative Markdown path is a reference. A protocol (https:, mailto:) + // or any other extension is left alone — remote references are deliberately + // out of scope (ADR 0016). + if (!filePart.endsWith('.md') || /^[a-z][a-z0-9+.-]*:/i.test(filePart)) return undefined + if (filePart.startsWith('/')) return undefined + return { + path: joinPosix(dirnamePosix(fromPath), filePart), + slug: normalizeSlug(fragment), + text: linkText, + } +} + +// GitHub's heading anchors: inline markup dropped, lowercased, spaces to +// hyphens, everything else that isn't a word character or hyphen removed. The +// same function produces the slug of a heading and normalizes the slug written +// in a link, so the two meet in the middle. +export function slugify(headingText: string): string { + return normalizeSlug( + headingText + .replace(/`([^`]*)`/g, '$1') + .replace(/\*\*([^*]*)\*\*/g, '$1') + .replace(/\*([^*]*)\*/g, '$1') + .replace(/_([^_]*)_/g, '$1'), + ) +} + +function normalizeSlug(s: string): string { + return ( + s + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N} _-]/gu, '') + // One hyphen per space, not per run of them: GitHub leaves the gap where + // it dropped punctuation, so "Fees, VAT & rounding" slugs with a double + // hyphen. Matching that exactly is the point — the link has to work on + // GitHub, not just here. + .replace(/ /g, '-') + ) +} + +// POSIX path arithmetic on oath paths (always '/'-separated, relative to the +// workspace root). node:path is a shell dependency the core may not have. +function dirnamePosix(path: string): string { + const i = path.lastIndexOf('/') + return i === -1 ? '' : path.slice(0, i) +} + +export function joinPosix(dir: string, rel: string): string { + const segments = dir === '' ? [] : dir.split('/') + for (const segment of rel.split('/')) { + if (segment === '' || segment === '.') continue + if (segment === '..') segments.pop() + else segments.push(segment) + } + return segments.join('/') +} + +// Every reference block in a document, in document order. The shell uses this +// to walk the closure of documents it must read before planning. +export function references(doc: Doc): ReadonlyArray { + const out: Reference[] = [] + for (const ex of doc.examples) { + const primary = ex.body[0] + if (!primary || !('text' in primary)) continue + const ref = referenceOf(primary.text, doc.path) + if (ref) out.push(ref) + } + return out +} + +// What `plan()` needs to resolve references: every oath by path, plus which +// sections are consumed by a reference somewhere in the project. A section that +// is referenced stops being a standalone example, so this is whole-project +// knowledge — see ADR 0016 on why each runner builds it at its once-per-run +// discovery pass. +export type OathWorkspace = { + readonly docs: ReadonlyMap + // `${path}#${slug}` for every referenced section; a whole-file reference is + // recorded as `${path}#`. + readonly referenced: ReadonlySet +} + +export function sectionKey(path: string, slug: string): string { + return `${path}#${slug}` +} + +// The workspace with no references at all: what a caller planning a single +// document in isolation passes, and the value every existing caller's behaviour +// is unchanged by. +export function emptyWorkspace(): OathWorkspace { + return { docs: new Map(), referenced: new Set() } +} + +export function buildWorkspace(docs: ReadonlyArray): OathWorkspace { + const byPath = new Map() + for (const doc of docs) byPath.set(doc.path, doc) + const referenced = new Set() + for (const doc of docs) { + for (const ref of references(doc)) referenced.add(sectionKey(ref.path, ref.slug)) + } + return { docs: byPath, referenced } +} + +// The candidates that make up a section: those whose heading chain contains the +// slug. A whole-file reference ('' slug) is every candidate in the document. +// Section membership follows the document outline exactly — a heading's section +// runs until the next heading of the same or higher level, which is precisely +// the range over which that heading stays on the scope stack. +export function sectionCandidates(doc: Doc, slug: string): ReadonlyArray { + if (slug === '') return doc.examples + return doc.examples.filter((ex) => ex.scopeStack.some((h) => slugify(h) === slug)) +} diff --git a/typescript/packages/core/tests/conformance.test.ts b/typescript/packages/core/tests/conformance.test.ts index 26e73481..df046404 100644 --- a/typescript/packages/core/tests/conformance.test.ts +++ b/typescript/packages/core/tests/conformance.test.ts @@ -12,6 +12,7 @@ import { compareDocString } from '../src/doc-string-diff.ts' import { UnexpectedPassError } from '../src/execute.ts' import { parse } from '../src/parse.ts' import { plan } from '../src/plan.ts' +import { emptyWorkspace } from '../src/reference.ts' import { addStep, createRegistry, defineParameterType } from '../src/registry.ts' test('canonicalStringify sorts keys recursively and ends with a newline', () => { @@ -129,7 +130,7 @@ test('toPlanArtifact projects examples, expectedOutcome and stringified args', ( kind: 'stimulus', handler: () => {}, }) - const art = toPlanArtifact(plan(parse('e.md', '# A\n\nI have 5 cukes.'), r)) + const art = toPlanArtifact(plan(parse('e.md', '# A\n\nI have 5 cukes.'), r, emptyWorkspace())) expect(art.examples[0]?.expectedOutcome).toBe('pass') expect(art.examples[0]?.steps[0]?.matchedExpression).toBe('I have {int} cukes') expect(art.examples[0]?.steps[0]?.args).toEqual([{ value: '5', parameterType: 'int' }]) @@ -157,7 +158,7 @@ test('toPlanArtifact projects diagnostics to portable fields (no message/path)', kind: 'stimulus', handler: () => {}, }) - const art = toPlanArtifact(plan(parse('e.md', '# A\n\nI have 5 cukes.'), r)) + const art = toPlanArtifact(plan(parse('e.md', '# A\n\nI have 5 cukes.'), r, emptyWorkspace())) expect(art.diagnostics).toHaveLength(1) expect(art.diagnostics[0]).not.toHaveProperty('message') expect(art.diagnostics[0]?.code).toBe('ambiguous-match') diff --git a/typescript/packages/core/tests/drift.test.ts b/typescript/packages/core/tests/drift.test.ts index a300908c..ce711081 100644 --- a/typescript/packages/core/tests/drift.test.ts +++ b/typescript/packages/core/tests/drift.test.ts @@ -16,6 +16,7 @@ import { hashSource } from '../src/hash.ts' import { parse } from '../src/parse.ts' import { plan } from '../src/plan.ts' import type { BaselineStore } from '../src/ports.ts' +import { emptyWorkspace } from '../src/reference.ts' import { addStep, createRegistry, type Registry } from '../src/registry.ts' // A minimal in-memory BaselineStore, like the browser's. @@ -71,20 +72,20 @@ function romanReg(withStep = true): Registry { test('liveExamples records one entry per example-producing paragraph', () => { const source = 'I withdraw 40.' const doc = parse('w.md', source) - const examples = liveExamples(doc, plan(doc, reg())) + const examples = liveExamples(doc, plan(doc, reg(), emptyWorkspace())) expect(examples).toEqual([{ name: 'I withdraw 40', line: 1 }]) }) test('a never-matched paragraph is not recorded as a live example', () => { const source = 'Just some prose.' const doc = parse('w.md', source) - expect(liveExamples(doc, plan(doc, reg()))).toEqual([]) + expect(liveExamples(doc, plan(doc, reg(), emptyWorkspace()))).toEqual([]) }) test('deriveOathBaseline carries the source fingerprint', () => { const source = 'I withdraw 40.' const doc = parse('w.md', source) - const baseline = deriveOathBaseline(source, doc, plan(doc, reg())) + const baseline = deriveOathBaseline(source, doc, plan(doc, reg(), emptyWorkspace())) expect(baseline.sourceHash).toBe(hashSource(source)) expect(baseline.examples).toEqual([{ name: 'I withdraw 40', line: 1 }]) }) @@ -92,33 +93,33 @@ test('deriveOathBaseline carries the source fingerprint', () => { test('no baseline (first run) means no drift', () => { const source = 'I withdraw 40.' const doc = parse('w.md', source) - expect(detectDrift(undefined, doc, plan(doc, reg()))).toEqual([]) + expect(detectDrift(undefined, doc, plan(doc, reg(), emptyWorkspace()))).toEqual([]) }) test('an unchanged oath run against unchanged steps has no drift', () => { const source = 'I withdraw 40.' const doc = parse('w.md', source) - const baseline = deriveOathBaseline(source, doc, plan(doc, reg())) - expect(detectDrift(baseline, doc, plan(doc, reg()))).toEqual([]) + const baseline = deriveOathBaseline(source, doc, plan(doc, reg(), emptyWorkspace())) + expect(detectDrift(baseline, doc, plan(doc, reg(), emptyWorkspace()))).toEqual([]) }) test('a renamed/deleted step definition drifts (Markdown unchanged, matched by name)', () => { const source = 'I withdraw 40.' const doc = parse('w.md', source) - const baseline = deriveOathBaseline(source, doc, plan(doc, reg(true))) + const baseline = deriveOathBaseline(source, doc, plan(doc, reg(true), emptyWorkspace())) // Same source, but the step is gone now. - const drift = detectDrift(baseline, doc, plan(doc, reg(false))) + const drift = detectDrift(baseline, doc, plan(doc, reg(false), emptyWorkspace())) expect(bare(drift)).toEqual([{ name: 'I withdraw 40', line: 1 }]) }) test('an in-place typo drifts (text changed, matched by line)', () => { const before = 'I withdraw 40.' const beforeDoc = parse('w.md', before) - const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg())) + const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg(), emptyWorkspace())) // Typo on the same line: no longer matches "I withdraw {int}". const after = 'I withdrraw 40.' const afterDoc = parse('w.md', after) - const drift = detectDrift(baseline, afterDoc, plan(afterDoc, reg())) + const drift = detectDrift(baseline, afterDoc, plan(afterDoc, reg(), emptyWorkspace())) // Reports the baseline's name; anchors at the current (same) line. expect(bare(drift)).toEqual([{ name: 'I withdraw 40', line: 1 }]) }) @@ -126,40 +127,40 @@ test('an in-place typo drifts (text changed, matched by line)', () => { test('a deleted paragraph is not drift', () => { const before = 'I withdraw 40.' const beforeDoc = parse('w.md', before) - const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg())) + const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg(), emptyWorkspace())) // The paragraph is gone entirely. const afterDoc = parse('w.md', '') - expect(detectDrift(baseline, afterDoc, plan(afterDoc, reg()))).toEqual([]) + expect(detectDrift(baseline, afterDoc, plan(afterDoc, reg(), emptyWorkspace()))).toEqual([]) }) test('a newly added prose paragraph is not drift', () => { const before = 'I withdraw 40.' const beforeDoc = parse('w.md', before) - const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg())) + const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg(), emptyWorkspace())) // Same example still matches; a fresh prose paragraph is added below it. const after = 'I withdraw 40.\n\nSome new narration.' const afterDoc = parse('w.md', after) - expect(detectDrift(baseline, afterDoc, plan(afterDoc, reg()))).toEqual([]) + expect(detectDrift(baseline, afterDoc, plan(afterDoc, reg(), emptyWorkspace()))).toEqual([]) }) test('moving an example (unchanged text) never drifts, wherever it lands', () => { const before = 'I withdraw 40.\n\nI withdraw 10.' const beforeDoc = parse('w.md', before) - const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg())) + const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg(), emptyWorkspace())) // Same two examples, order swapped. const after = 'I withdraw 10.\n\nI withdraw 40.' const afterDoc = parse('w.md', after) - expect(detectDrift(baseline, afterDoc, plan(afterDoc, reg()))).toEqual([]) + expect(detectDrift(baseline, afterDoc, plan(afterDoc, reg(), emptyWorkspace()))).toEqual([]) }) test('moving AND rewording an example that still matches does not drift', () => { const before = 'I withdraw 40.\n\nI withdraw 10.' const beforeDoc = parse('w.md', before) - const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg())) + const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg(), emptyWorkspace())) // Second example reworded (10 → 11, still matches {int}) and moved to the top. const after = 'I withdraw 11.\n\nI withdraw 40.' const afterDoc = parse('w.md', after) - expect(detectDrift(baseline, afterDoc, plan(afterDoc, reg()))).toEqual([]) + expect(detectDrift(baseline, afterDoc, plan(afterDoc, reg(), emptyWorkspace()))).toEqual([]) }) test('move + reword + prose landing on the old line does not false-positive', () => { @@ -167,27 +168,27 @@ test('move + reword + prose landing on the old line does not false-positive', () // is reworded (still matches), and unrelated prose now sits at its old line. const before = 'I withdraw 40.' const beforeDoc = parse('w.md', before) - const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg())) + const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg(), emptyWorkspace())) const after = 'Just some notes.\n\nI withdraw 41.' const afterDoc = parse('w.md', after) - expect(detectDrift(baseline, afterDoc, plan(afterDoc, reg()))).toEqual([]) + expect(detectDrift(baseline, afterDoc, plan(afterDoc, reg(), emptyWorkspace()))).toEqual([]) }) test('a paragraph rewritten past recognition is a remove+add, not drift', () => { const before = 'I withdraw 40.' const beforeDoc = parse('w.md', before) - const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg())) + const baseline = deriveOathBaseline(before, beforeDoc, plan(beforeDoc, reg(), emptyWorkspace())) // Wholly different prose (no word overlap) → below the similarity threshold. const after = 'The branch closed years ago.' const afterDoc = parse('w.md', after) - expect(detectDrift(baseline, afterDoc, plan(afterDoc, reg()))).toEqual([]) + expect(detectDrift(baseline, afterDoc, plan(afterDoc, reg(), emptyWorkspace()))).toEqual([]) }) test('a header-bound table records its binding paragraph once', () => { const source = 'Each row gives a decimal and a roman number:\n\n| decimal | roman |\n| ------: | :---- |\n| 3 | III |\n| 9 | IX |\n' const doc = parse('r.md', source) - const examples = liveExamples(doc, plan(doc, romanReg())) + const examples = liveExamples(doc, plan(doc, romanReg(), emptyWorkspace())) // Two rows run, but the baseline records the single binding paragraph. expect(examples).toEqual([{ name: 'Each row gives a decimal and a roman number:', line: 1 }]) }) @@ -196,16 +197,16 @@ test('a header-bound binding paragraph that stops matching drifts', () => { const source = 'Each row gives a decimal and a roman number:\n\n| decimal | roman |\n| ------: | :---- |\n| 3 | III |\n| 9 | IX |\n' const doc = parse('r.md', source) - const baseline = deriveOathBaseline(source, doc, plan(doc, romanReg(true))) - const drift = detectDrift(baseline, doc, plan(doc, romanReg(false))) + const baseline = deriveOathBaseline(source, doc, plan(doc, romanReg(true), emptyWorkspace())) + const drift = detectDrift(baseline, doc, plan(doc, romanReg(false), emptyWorkspace())) expect(bare(drift)).toEqual([{ name: 'Each row gives a decimal and a roman number:', line: 1 }]) }) test('a drift carries the drifted paragraph span', () => { const source = 'Some prose first.\n\nI withdraw 40.' const doc = parse('w.md', source) - const baseline = deriveOathBaseline(source, doc, plan(doc, reg(true))) - const [drift] = detectDrift(baseline, doc, plan(doc, reg(false))) + const baseline = deriveOathBaseline(source, doc, plan(doc, reg(true), emptyWorkspace())) + const [drift] = detectDrift(baseline, doc, plan(doc, reg(false), emptyWorkspace())) if (!drift) throw new Error('expected a drift') // The example is on line 3; the span covers that paragraph, not line 1's prose. expect(drift.line).toBe(3) @@ -216,8 +217,8 @@ test('a drift carries the drifted paragraph span', () => { test('driftDiagnostics projects drift onto error-severity diagnostics', () => { const source = 'I withdraw 40.' const doc = parse('w.md', source) - const baseline = deriveOathBaseline(source, doc, plan(doc, reg(true))) - const drifts = detectDrift(baseline, doc, plan(doc, reg(false))) + const baseline = deriveOathBaseline(source, doc, plan(doc, reg(true), emptyWorkspace())) + const drifts = detectDrift(baseline, doc, plan(doc, reg(false), emptyWorkspace())) const diags = driftDiagnostics(drifts) expect(diags).toHaveLength(1) expect(diags[0]?.severity).toBe('error') @@ -235,7 +236,7 @@ test('reconcileDrift records a baseline on the first run and reports no drift', oathPath: 'w.md', source, doc, - plan: plan(doc, reg()), + plan: plan(doc, reg(), emptyWorkspace()), }) expect(drifts).toEqual([]) const lock = parseLockFile(store.contents ?? '') @@ -246,7 +247,13 @@ test('reconcileDrift reports drift and preserves the baseline (stays red)', asyn const source = 'I withdraw 40.' const doc = parse('w.md', source) const store = memoryStore() - await reconcileDrift({ store, oathPath: 'w.md', source, doc, plan: plan(doc, reg(true)) }) + await reconcileDrift({ + store, + oathPath: 'w.md', + source, + doc, + plan: plan(doc, reg(true), emptyWorkspace()), + }) const before = store.contents // The step is gone now — same source no longer matches. const drifts = await reconcileDrift({ @@ -254,7 +261,7 @@ test('reconcileDrift reports drift and preserves the baseline (stays red)', asyn oathPath: 'w.md', source, doc, - plan: plan(doc, reg(false)), + plan: plan(doc, reg(false), emptyWorkspace()), }) expect(bare(drifts)).toEqual([{ name: 'I withdraw 40', line: 1 }]) expect(store.contents).toBe(before) // baseline untouched while drift is unacknowledged @@ -264,14 +271,20 @@ test('reconcileDrift in update mode accepts drift and re-records the baseline', const source = 'I withdraw 40.' const doc = parse('w.md', source) const store = memoryStore() - await reconcileDrift({ store, oathPath: 'w.md', source, doc, plan: plan(doc, reg(true)) }) + await reconcileDrift({ + store, + oathPath: 'w.md', + source, + doc, + plan: plan(doc, reg(true), emptyWorkspace()), + }) // Accept: the paragraph is now intentionally prose. const drifts = await reconcileDrift({ store, oathPath: 'w.md', source, doc, - plan: plan(doc, reg(false)), + plan: plan(doc, reg(false), emptyWorkspace()), update: true, }) expect(drifts).toEqual([]) @@ -335,7 +348,7 @@ function depositWithdrawReg(withDeposit = true): Registry { test('two paragraphs that merge into one example are each recorded as a live baseline entry', () => { const source = 'I deposit 100.\n\nI withdraw 40.' const doc = parse('w.md', source) - const plan1 = plan(doc, depositWithdrawReg()) + const plan1 = plan(doc, depositWithdrawReg(), emptyWorkspace()) // One planned example (the two paragraphs merged), but two live entries. expect(plan1.examples).toHaveLength(1) expect(liveExamples(doc, plan1)).toEqual([ @@ -347,10 +360,14 @@ test('two paragraphs that merge into one example are each recorded as a live bas test('deleting one step def of a merged example drifts only the now-prose paragraph', () => { const source = 'I deposit 100.\n\nI withdraw 40.' const doc = parse('w.md', source) - const baseline = deriveOathBaseline(source, doc, plan(doc, depositWithdrawReg(true))) + const baseline = deriveOathBaseline( + source, + doc, + plan(doc, depositWithdrawReg(true), emptyWorkspace()), + ) // The deposit step is gone: its paragraph becomes prose, splitting the // example. The withdraw paragraph is still live; the deposit one drifts. - const drift = detectDrift(baseline, doc, plan(doc, depositWithdrawReg(false))) + const drift = detectDrift(baseline, doc, plan(doc, depositWithdrawReg(false), emptyWorkspace())) expect(bare(drift)).toEqual([{ name: 'I deposit 100', line: 1 }]) }) @@ -359,7 +376,11 @@ test('deleting one step def of a merged example drifts only the now-prose paragr function lockWithStalePath(): string { const source = 'I withdraw 40.' const doc = parse('w.md', source) - const baseline = deriveOathBaseline(source, doc, plan(doc, depositWithdrawReg())) + const baseline = deriveOathBaseline( + source, + doc, + plan(doc, depositWithdrawReg(), emptyWorkspace()), + ) return stringifyLockFile({ version: 2, oaths: { 'varar/w.md': baseline, 'w.md': baseline }, diff --git a/typescript/packages/core/tests/e2e.test.ts b/typescript/packages/core/tests/e2e.test.ts index 808bb980..50a3f986 100644 --- a/typescript/packages/core/tests/e2e.test.ts +++ b/typescript/packages/core/tests/e2e.test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest' import { addStep, createRegistry, parse, plan } from '../src/index.ts' +import { emptyWorkspace } from '../src/reference.ts' test('end-to-end: a complete BDD file with headings, prose, list, table, and fence', () => { let r = createRegistry() @@ -53,7 +54,7 @@ When I send the payload: { "action": "import" } \`\`\`` - const result = plan(parse('e.md', source), r) + const result = plan(parse('e.md', source), r, emptyWorkspace()) expect(result.diagnostics).toHaveLength(0) // 3 paragraphs across 2 headings → 2 examples (ADR 0012): // 1. "Withdrawing cash" scope, one paragraph with all 3 banking steps diff --git a/typescript/packages/core/tests/execute-roles.test.ts b/typescript/packages/core/tests/execute-roles.test.ts index 12d8a0e4..3a2de42d 100644 --- a/typescript/packages/core/tests/execute-roles.test.ts +++ b/typescript/packages/core/tests/execute-roles.test.ts @@ -3,6 +3,7 @@ import { isCellMismatchError } from '../src/cell-diff.ts' import { type ExecutePorts, executePlan } from '../src/execute.ts' import { parse } from '../src/parse.ts' import { plan } from '../src/plan.ts' +import { emptyWorkspace } from '../src/reference.ts' import { addStep, createRegistry, defineParameterType, type StepHandler } from '../src/registry.ts' // Minimal ports that run the example body and surface the thrown error. @@ -14,7 +15,7 @@ function runOne( registry = register(registry) // parse(path, source) — path first, source second const doc = parse('x.md', source) - const p = plan(doc, registry) + const p = plan(doc, registry, emptyWorkspace()) let caught: unknown const ports: ExecutePorts = { reporter: { diagnostic: () => {} }, diff --git a/typescript/packages/core/tests/execute-state.test.ts b/typescript/packages/core/tests/execute-state.test.ts index 3009521c..d667383b 100644 --- a/typescript/packages/core/tests/execute-state.test.ts +++ b/typescript/packages/core/tests/execute-state.test.ts @@ -3,6 +3,7 @@ import { ReturnShapeError } from '../src/cell-diff.ts' import { type ExecutePorts, executePlan } from '../src/execute.ts' import { parse } from '../src/parse.ts' import { plan } from '../src/plan.ts' +import { emptyWorkspace } from '../src/reference.ts' import { addStep, createRegistry } from '../src/registry.ts' // Runs one example. `createContext` seeds the initial state; step handlers may @@ -14,7 +15,7 @@ function run( ) { const registry = register(createRegistry()) const doc = parse('x.md', source) - const p = plan(doc, registry) + const p = plan(doc, registry, emptyWorkspace()) let caught: unknown const ports: ExecutePorts = { reporter: { diagnostic: () => {} }, diff --git a/typescript/packages/core/tests/execute.test.ts b/typescript/packages/core/tests/execute.test.ts index 33a0912e..ccb09e53 100644 --- a/typescript/packages/core/tests/execute.test.ts +++ b/typescript/packages/core/tests/execute.test.ts @@ -9,6 +9,7 @@ import type { Diagnostic } from '../src/diagnostics.ts' import { executePlan, isUnexpectedPassError, type StepObservation } from '../src/execute.ts' import { parse } from '../src/parse.ts' import { plan } from '../src/plan.ts' +import { emptyWorkspace } from '../src/reference.ts' import { addStep, createRegistry, type StepHandler } from '../src/registry.ts' async function runOnly(p: ReturnType, observer?: { step(o: StepObservation): void }) { @@ -36,7 +37,11 @@ test('executePlan calls sink.example for each PlannedExample', () => { // With the paragraph-as-test model, each paragraph is its own example and // its name is the entire paragraph (with the trailing terminator // stripped). Two paragraphs → two named tests. - const p = plan(parse('e.md', '# A\n\nGiven I have 5 cukes\n\n# B\n\nGiven I have 9 cukes'), r) + const p = plan( + parse('e.md', '# A\n\nGiven I have 5 cukes\n\n# B\n\nGiven I have 9 cukes'), + r, + emptyWorkspace(), + ) const names: string[] = [] executePlan(p, { sink: { example: (name) => names.push(name) }, @@ -63,7 +68,7 @@ test('executePlan reports all diagnostics through reporter.diagnostic', () => { kind: 'stimulus', handler: () => {}, }) - const p = plan(parse('m.md', '# A\n\nGiven I have 5 cukes'), r) + const p = plan(parse('m.md', '# A\n\nGiven I have 5 cukes'), r, emptyWorkspace()) const got: Diagnostic[] = [] executePlan(p, { sink: { example: (_n, _r) => {} }, @@ -96,7 +101,7 @@ test('the sink.example run callback executes the step handlers in order', async }, }, ) - const p = plan(parse('e.md', '# Adding\n\nI add 5. I should have 5.'), r) + const p = plan(parse('e.md', '# Adding\n\nI add 5. I should have 5.'), r, emptyWorkspace()) let run: (() => void | Promise) | undefined executePlan(p, { sink: { @@ -120,7 +125,7 @@ test('executePlan augments a thrown error with a .md frame for the failing step' throw new Error('boom') }, }) - const p = plan(parse('e.md', '# A\n\nI throw'), r) + const p = plan(parse('e.md', '# A\n\nI throw'), r, emptyWorkspace()) let captured: Error | undefined let run: (() => void | Promise) | undefined executePlan(p, { @@ -163,7 +168,7 @@ test('executePlan invokes createContext once per example and passes the result t ctxSeen.push(ctx) }, }) - const p = plan(parse('e.md', '# A\n\nI record ctx\n\n# B\n\nI record ctx'), r) + const p = plan(parse('e.md', '# A\n\nI record ctx\n\n# B\n\nI record ctx'), r, emptyWorkspace()) let calls = 0 const runs: Array<() => void | Promise> = [] executePlan(p, { @@ -201,7 +206,7 @@ these books exist: | Lolita | Nabokov | | Anna | Tolstoy | ` - const p = plan(parse('l.md', source), r) + const p = plan(parse('l.md', source), r, emptyWorkspace()) const runs: Array<() => unknown | Promise> = [] executePlan(p, { sink: { example: (_n, run) => runs.push(run) }, @@ -236,7 +241,7 @@ the receipt is: {"ok": true} \`\`\` ` - const p = plan(parse('l.md', source), r) + const p = plan(parse('l.md', source), r, emptyWorkspace()) const runs: Array<() => unknown | Promise> = [] executePlan(p, { sink: { example: (_n, run) => runs.push(run) }, @@ -268,7 +273,7 @@ each row lists the dice, the category and the score: | ------------- | ---------- | ----- | | 3, 3, 3, 4, 4 | full house | 17 | | 3, 3, 3, 3, 3 | Yahtzee | 50 |` - const p = plan(parse('y.md', source), r) + const p = plan(parse('y.md', source), r, emptyWorkspace()) const named: Array<{ name: string; run: () => unknown | Promise }> = [] executePlan(p, { sink: { example: (name, run) => named.push({ name, run }) }, @@ -308,7 +313,7 @@ each row lists the dice, the category and the score: | ------------- | ---------- | ----- | | 3, 3, 3, 4, 4 | full house | 17 | | 3, 3, 3, 3, 3 | Yahtzee | 50 |` - const p = plan(parse('y.md', source), r) + const p = plan(parse('y.md', source), r, emptyWorkspace()) const runs: Array<() => unknown | Promise> = [] executePlan(p, { sink: { example: (_n, run) => runs.push(run) }, @@ -343,7 +348,7 @@ each row lists the dice, the category and the score: | ------------- | ---------- | ----- | | 3, 3, 3, 4, 4 | full house | 17 | | 3, 3, 3, 3, 3 | Yahtzee | 50 |` - const p = plan(parse('y.md', source), r) + const p = plan(parse('y.md', source), r, emptyWorkspace()) const runs: Array<() => unknown | Promise> = [] executePlan(p, { sink: { example: (_n, run) => runs.push(run) }, @@ -381,7 +386,7 @@ each row lists the dice, the category and the score: | dice | category | score | | ------------- | ---------- | ----- | | 3, 3, 3, 4, 4 | full house | 17 |` - const p = plan(parse('y.md', source), r) + const p = plan(parse('y.md', source), r, emptyWorkspace()) const runs: Array<() => unknown | Promise> = [] executePlan(p, { sink: { example: (_n, run) => runs.push(run) }, @@ -391,7 +396,7 @@ each row lists the dice, the category and the score: }) function runsFor(source: string, reg: ReturnType) { - const p = plan(parse('w.md', source), reg) + const p = plan(parse('w.md', source), reg, emptyWorkspace()) const runs: Array<() => unknown | Promise> = [] executePlan(p, { sink: { example: (_n, run) => runs.push(run) }, @@ -550,7 +555,7 @@ test('executePlan passes each example its deduped 1-based step lines via info', // Both steps are in one paragraph (no blank line between them) so the planner // creates a single example. "I have 5 cukes" is on line 3, "I eat 2 cukes" on line 4. const source = '# T\n\nI have 5 cukes.\nI eat 2 cukes.\n' - const p = plan(parse('t.md', source), r) + const p = plan(parse('t.md', source), r, emptyWorkspace()) const seen: Array<{ name: string; lines: ReadonlyArray | undefined }> = [] const sink = { @@ -579,7 +584,7 @@ test('expected-failure example: a thrown step makes the run resolve (pass)', asy }, }) const src = '# D\n\nI divide 1 by 0.\n\n```error\ndivision by zero\n```\n' - const run = await runOnly(plan(parse('e.md', src), r)) + const run = await runOnly(plan(parse('e.md', src), r, emptyWorkspace())) await expect(run?.()).resolves.toBeUndefined() }) @@ -592,7 +597,7 @@ test('expected-failure example: no throw makes the run reject with UnexpectedPas handler: () => {}, }) const src = '# D\n\nI divide 1 by 1.\n\n```error\n```\n' - const run = await runOnly(plan(parse('e.md', src), r)) + const run = await runOnly(plan(parse('e.md', src), r, emptyWorkspace())) await expect(run?.()).rejects.toSatisfy(isUnexpectedPassError) }) @@ -607,7 +612,7 @@ test('expected-failure with message substring: mismatch rejects with the real er }, }) const src = '# D\n\nI divide 1 by 0.\n\n```error\ndivision by zero\n```\n' - const run = await runOnly(plan(parse('e.md', src), r)) + const run = await runOnly(plan(parse('e.md', src), r, emptyWorkspace())) await expect(run?.()).rejects.toThrow('boom') }) @@ -620,7 +625,7 @@ test('observer receives a pass observation per executed step', async () => { handler: () => {}, }) const obs: StepObservation[] = [] - const run = await runOnly(plan(parse('e.md', '# A\n\nI add 5.'), r), { + const run = await runOnly(plan(parse('e.md', '# A\n\nI add 5.'), r, emptyWorkspace()), { step: (o) => obs.push(o), }) await run?.() @@ -640,7 +645,7 @@ test('observer receives a fail observation when a step throws', async () => { }, }) const obs: StepObservation[] = [] - const run = await runOnly(plan(parse('e.md', '# A\n\nI blow up.'), r), { + const run = await runOnly(plan(parse('e.md', '# A\n\nI blow up.'), r, emptyWorkspace()), { step: (o) => obs.push(o), }) await Promise.resolve(run?.()).catch(() => {}) diff --git a/typescript/packages/core/tests/failure-step-span.test.ts b/typescript/packages/core/tests/failure-step-span.test.ts index a06a0df3..13207c8e 100644 --- a/typescript/packages/core/tests/failure-step-span.test.ts +++ b/typescript/packages/core/tests/failure-step-span.test.ts @@ -5,6 +5,7 @@ import { attachFailureAnchor, readFailureAnchor } from '../src/failure-anchor.ts import { hashSource } from '../src/hash.ts' import { parse } from '../src/parse.ts' import { plan } from '../src/plan.ts' +import { emptyWorkspace } from '../src/reference.ts' import { addStep, createRegistry } from '../src/registry.ts' import { runResultDiagnostics } from '../src/run-diagnostics.ts' import { spanFromOffsets } from '../src/span.ts' @@ -33,7 +34,7 @@ function throwingPlan() { throw new Error('expected the library to refuse') }, }) - return plan(parse('l.md', SOURCE), r) + return plan(parse('l.md', SOURCE), r, emptyWorkspace()) } async function failureOf() { diff --git a/typescript/packages/core/tests/index.test.ts b/typescript/packages/core/tests/index.test.ts index d3a59693..8c99799e 100644 --- a/typescript/packages/core/tests/index.test.ts +++ b/typescript/packages/core/tests/index.test.ts @@ -16,7 +16,11 @@ test('end-to-end: parse + plan with a simple expression', () => { expressionSourceLine: 1, handler: () => {}, }) - const result = varApi.plan(varApi.parse('hello.md', '# Belly\n\nGiven I have 5 cukes.'), r) + const result = varApi.plan( + varApi.parse('hello.md', '# Belly\n\nGiven I have 5 cukes.'), + r, + varApi.emptyWorkspace(), + ) expect(result.examples).toHaveLength(1) expect(result.examples[0]?.steps[0]?.text).toBe('I have 5 cukes') expect(result.examples[0]?.steps[0]?.args).toEqual([5]) diff --git a/typescript/packages/core/tests/plan.test.ts b/typescript/packages/core/tests/plan.test.ts index 644378ae..5ad39b91 100644 --- a/typescript/packages/core/tests/plan.test.ts +++ b/typescript/packages/core/tests/plan.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vitest' import { parse } from '../src/parse.ts' import { plan } from '../src/plan.ts' +import { emptyWorkspace } from '../src/reference.ts' import { addStep, createRegistry } from '../src/registry.ts' function reg() { @@ -36,7 +37,7 @@ test('plan produces a PlannedExample with steps in document order', () => { const source = '# Withdrawing\n\nGiven I have 100 in my account. When I withdraw 40. Then I should have 60 left.' const doc = parse('w.md', source) - const result = plan(doc, reg()) + const result = plan(doc, reg(), emptyWorkspace()) expect(result.diagnostics).toHaveLength(0) expect(result.examples).toHaveLength(1) const ex = result.examples[0] @@ -55,7 +56,7 @@ test('plan produces a PlannedExample with steps in document order', () => { test('the example name is the entire paragraph even when only part of it matches steps', () => { const source = 'It was a dark night. I withdraw 40. Nobody was watching.' - const result = plan(parse('w.md', source), reg()) + const result = plan(parse('w.md', source), reg(), emptyWorkspace()) expect(result.examples).toHaveLength(1) expect(result.examples[0]?.name).toBe('It was a dark night. I withdraw 40. Nobody was watching') expect(result.examples[0]?.steps.map((s) => s.text)).toEqual(['I withdraw 40']) @@ -63,7 +64,7 @@ test('the example name is the entire paragraph even when only part of it matches test('hard line breaks inside the paragraph collapse to single spaces in the name', () => { const source = 'I withdraw 40.\nI should have 60 left.' - const result = plan(parse('w.md', source), reg()) + const result = plan(parse('w.md', source), reg(), emptyWorkspace()) expect(result.examples[0]?.name).toBe('I withdraw 40. I should have 60 left') }) @@ -84,7 +85,7 @@ test('plan emits an ambiguous-match diagnostic and produces no runnable example' handler: () => {}, }) const doc = parse('e.md', '# Ambig\n\nGiven I have 5 cukes') - const result = plan(doc, r) + const result = plan(doc, r, emptyWorkspace()) expect(result.diagnostics).toHaveLength(1) expect(result.diagnostics[0]?.code).toBe('ambiguous-match') // An ambiguous candidate has no runnable step, so it is prose (a delimiter), @@ -95,7 +96,7 @@ test('plan emits an ambiguous-match diagnostic and produces no runnable example' test('plan skips an example heading whose body has no matches and no keyword-led sentences', () => { const source = '# Just docs\n\nSome prose with no matches and no keywords.' const doc = parse('d.md', source) - const result = plan(doc, reg()) + const result = plan(doc, reg(), emptyWorkspace()) expect(result.examples).toHaveLength(0) expect(result.diagnostics).toHaveLength(0) }) @@ -119,7 +120,7 @@ test('plan merges consecutive list items into one example (a scenario as a bulle // Two list items, no delimiter between them → one example, shared state (ADR // 0012). A bulleted scenario reads as Given/When/Then bullets. const source = '# Bullets\n\n- Given I have 100 in my account\n- When I withdraw 40' - const result = plan(parse('b.md', source), r) + const result = plan(parse('b.md', source), r, emptyWorkspace()) expect(result.examples).toHaveLength(1) expect(result.examples[0]?.steps.map((s) => s.text)).toEqual([ 'I have 100 in my account', @@ -137,7 +138,7 @@ test('plan walks blockquote content as step-bearing', () => { handler: () => {}, }) const source = '# Quote\n\n> Given I have 100 in my account' - const result = plan(parse('q.md', source), r) + const result = plan(parse('q.md', source), r, emptyWorkspace()) expect(result.examples[0]?.steps).toHaveLength(1) }) @@ -157,7 +158,7 @@ Given these users exist: |------|-----| | Bob | 30 | | Eve | 25 |` - const result = plan(parse('u.md', source), r) + const result = plan(parse('u.md', source), r, emptyWorkspace()) const step = result.examples[0]?.steps[0] expect(step?.dataTable?.header.cells).toEqual(['name', 'age']) expect(step?.dataTable?.rows).toHaveLength(2) @@ -181,7 +182,7 @@ Some interrupting prose. | name | age | |------|-----| | Bob | 30 |` - const result = plan(parse('m.md', source), r) + const result = plan(parse('m.md', source), r, emptyWorkspace()) const step = result.examples[0]?.steps[0] expect(step?.dataTable).toBeUndefined() }) @@ -201,7 +202,7 @@ When I send the payload: \`\`\`json { "action": "import" } \`\`\`` - const result = plan(parse('p.md', source), r) + const result = plan(parse('p.md', source), r, emptyWorkspace()) const step = result.examples[0]?.steps[0] expect(step?.docString?.contentType).toBe('json') expect(step?.docString?.content).toBe('{ "action": "import" }\n') @@ -216,7 +217,7 @@ test('a step with NO following fence has no docString', () => { kind: 'stimulus', handler: () => {}, }) - const result = plan(parse('p.md', '# P\nWhen I send the payload'), r) + const result = plan(parse('p.md', '# P\nWhen I send the payload'), r, emptyWorkspace()) expect(result.examples[0]?.steps[0]?.docString).toBeUndefined() }) @@ -225,14 +226,14 @@ test('a keyword-led sentence with no match does NOT produce a diagnostic (no Giv // keyword-led sentence "should" have matched a step definition. const r = createRegistry() const doc = parse('m.md', '# Empty\n\nGiven I have 5 cukes in my belly.') - const result = plan(doc, r) + const result = plan(doc, r, emptyWorkspace()) expect(result.diagnostics).toHaveLength(0) }) test('an unmatched sentence without a keyword is also silently treated as prose', () => { const r = createRegistry() const doc = parse('p.md', '# Prose\n\nI have 5 cukes in my belly.') - const result = plan(doc, r) + const result = plan(doc, r, emptyWorkspace()) expect(result.diagnostics).toHaveLength(0) }) @@ -253,7 +254,7 @@ each row lists the dice, the category and the score: | ------------- | ---------- | ----- | | 3, 3, 3, 4, 4 | full house | 17 | | 3, 3, 3, 3, 3 | Yahtzee | 50 |` - const result = plan(parse('y.md', source), r) + const result = plan(parse('y.md', source), r, emptyWorkspace()) expect(result.diagnostics).toHaveLength(0) // One example per data row (the header row is the binding, not an example). expect(result.examples).toHaveLength(2) @@ -288,7 +289,7 @@ these users exist: | ---- | --- | | Bob | 30 | | Eve | 25 |` - const result = plan(parse('u.md', source), r) + const result = plan(parse('u.md', source), r, emptyWorkspace()) expect(result.examples).toHaveLength(1) const step = result.examples[0]?.steps[0] expect(step?.dataTable?.header.cells).toEqual(['name', 'age']) @@ -311,7 +312,7 @@ each row lists the Dice and the Score: | dice | score | | --------- | ----- | | 1,1,1,1,1 | 5 |` - const result = plan(parse('c.md', source), r) + const result = plan(parse('c.md', source), r, emptyWorkspace()) // No exact-case match → falls back to a single whole-table example. expect(result.examples).toHaveLength(1) expect(result.examples[0]?.steps[0]?.dataTable?.rows).toHaveLength(1) @@ -334,7 +335,7 @@ each row lists the dice, the category and the score: | ------------- | ---------- | ----- | | 3, 3, 3, 4, 4 | full house | 17 | | 3, 3, 3, 3, 3 | Yahtzee | 50 |` - const result = plan(parse('y.md', source), r) + const result = plan(parse('y.md', source), r, emptyWorkspace()) expect(result.examples.map((e) => e.name)).toEqual([ '3, 3, 3, 4, 4 / full house / 17', '3, 3, 3, 3, 3 / Yahtzee / 50', @@ -367,7 +368,7 @@ each row lists the dice, the category and the score: | dice | category | score | | ------------- | ---------- | ----- | | 3, 3, 3, 4, 4 | full house | 17 |` - const result = plan(parse('y.md', source), r) + const result = plan(parse('y.md', source), r, emptyWorkspace()) const binding = result.examples[0]?.headerBinding if (!binding) throw new Error('no headerBinding') // One span per header cell, located in the table's header row (distinct from @@ -392,7 +393,7 @@ test('plan carries paramInnerSpans (value only) alongside paramSpans (full notat }) const source = '# Greeting\n\nGiven I greet "world" warmly.' const doc = parse('g.md', source) - const result = plan(doc, r) + const result = plan(doc, r, emptyWorkspace()) const step = result.examples[0]?.steps[0] if (!step) throw new Error('no planned step') const outer = step.paramSpans[0] @@ -422,7 +423,7 @@ Some interrupting prose paragraph. | name | age | |------|-----| | Bob | 30 |` - const result = plan(parse('o.md', source), r) + const result = plan(parse('o.md', source), r, emptyWorkspace()) expect(result.diagnostics).toHaveLength(0) }) @@ -442,7 +443,7 @@ each row lists the dice, the category and the score: | dice | category | score | | ------------- | ---------- | ----- | | 3, 3, 3, 4, 4 | full house | 17 |` - const result = plan(parse('y.md', source), r) + const result = plan(parse('y.md', source), r, emptyWorkspace()) const checks = result.examples[0]?.rowChecks if (!checks) throw new Error('no rowChecks') expect(checks.map((c) => c.column)).toEqual(['dice', 'category', 'score']) @@ -461,7 +462,7 @@ test('an `error` fence marks the example expectedOutcome=fail with a message sub handler: () => {}, }) const src = '# Division\n\nI divide 1 by 0.\n\n```error\ndivision by zero\n```\n' - const ex = plan(parse('e.md', src), r).examples[0] + const ex = plan(parse('e.md', src), r, emptyWorkspace()).examples[0] expect(ex?.expectedOutcome).toBe('fail') expect(ex?.expectedErrorMessage).toBe('division by zero') // The error fence must NOT become a docString attachment on the step. @@ -476,7 +477,7 @@ test('no `error` fence leaves expectedOutcome undefined', () => { kind: 'stimulus', handler: () => {}, }) - const ex = plan(parse('e.md', '# Division\n\nI divide 1 by 1.'), r).examples[0] + const ex = plan(parse('e.md', '# Division\n\nI divide 1 by 1.'), r, emptyWorkspace()).examples[0] expect(ex?.expectedOutcome).toBeUndefined() }) @@ -490,7 +491,7 @@ test('an `error` fence with no matching step emits an error-fence-without-step d handler: () => {}, }) const src = '# Nope\n\nThis prose matches nothing.\n\n```error\nboom\n```\n' - const result = plan(parse('e.md', src), r) + const result = plan(parse('e.md', src), r, emptyWorkspace()) expect(result.examples).toHaveLength(0) expect(result.diagnostics).toHaveLength(1) expect(result.diagnostics[0]?.code).toBe('error-fence-without-step') @@ -513,7 +514,7 @@ test('an `error` fence on an ambiguous example emits both diagnostics', () => { handler: () => {}, }) const src = '# Ambiguous\n\nI divide 1 by 0.\n\n```error\nboom\n```\n' - const result = plan(parse('e.md', src), r) + const result = plan(parse('e.md', src), r, emptyWorkspace()) const codes = result.diagnostics.map((d) => d.code).sort() expect(codes).toEqual(['ambiguous-match', 'error-fence-without-step']) }) @@ -533,7 +534,7 @@ the payload is: \`\`\`json { "ok": true } \`\`\`` - const result = plan(parse('d.md', source), r) + const result = plan(parse('d.md', source), r, emptyWorkspace()) const ds = result.examples[0]?.steps[0]?.docString if (!ds) throw new Error('no docString') expect(ds.content).toBe('{ "ok": true }\n') @@ -545,7 +546,7 @@ the payload is: test('consecutive matching paragraphs with no delimiter merge into one example', () => { const source = 'I have 100 in my account.\n\nI withdraw 40.\n\nI should have 60 left.' - const result = plan(parse('m.md', source), reg()) + const result = plan(parse('m.md', source), reg(), emptyWorkspace()) expect(result.examples).toHaveLength(1) expect(result.examples[0]?.steps.map((s) => s.text)).toEqual([ 'I have 100 in my account', @@ -558,7 +559,7 @@ test('consecutive matching paragraphs with no delimiter merge into one example', test('a thematic break (---) between matching paragraphs splits them into two examples', () => { const source = 'I have 100 in my account.\n\n---\n\nI withdraw 40.' - const result = plan(parse('h.md', source), reg()) + const result = plan(parse('h.md', source), reg(), emptyWorkspace()) expect(result.examples).toHaveLength(2) expect(result.examples.map((e) => e.steps.map((s) => s.text))).toEqual([ ['I have 100 in my account'], @@ -568,14 +569,14 @@ test('a thematic break (---) between matching paragraphs splits them into two ex test('a heading between matching paragraphs splits them into two examples', () => { const source = 'I have 100 in my account.\n\n## Next\n\nI withdraw 40.' - const result = plan(parse('hd.md', source), reg()) + const result = plan(parse('hd.md', source), reg(), emptyWorkspace()) expect(result.examples).toHaveLength(2) expect(result.examples[1]?.scopeStack).toEqual(['Next']) }) test('a non-matching paragraph (prose) between matching paragraphs splits the example', () => { const source = 'I have 100 in my account.\n\nJust explaining what happens next.\n\nI withdraw 40.' - const result = plan(parse('p.md', source), reg()) + const result = plan(parse('p.md', source), reg(), emptyWorkspace()) expect(result.examples).toHaveLength(2) expect(result.examples.map((e) => e.steps.map((s) => s.text))).toEqual([ ['I have 100 in my account'], @@ -585,7 +586,7 @@ test('a non-matching paragraph (prose) between matching paragraphs splits the ex test('leading and trailing prose does not merge into an example', () => { const source = 'A preamble that matches nothing.\n\nI withdraw 40.\n\nA closing remark.' - const result = plan(parse('pp.md', source), reg()) + const result = plan(parse('pp.md', source), reg(), emptyWorkspace()) expect(result.examples).toHaveLength(1) expect(result.examples[0]?.steps.map((s) => s.text)).toEqual(['I withdraw 40']) }) @@ -617,7 +618,7 @@ And the following assets have been imported: | name | | ----- | | Moose |` - const result = plan(parse('basket.md', source), r) + const result = plan(parse('basket.md', source), r, emptyWorkspace()) expect(result.examples).toHaveLength(1) const ex = result.examples[0] expect(ex?.steps).toHaveLength(2) diff --git a/typescript/packages/core/tests/reference.test.ts b/typescript/packages/core/tests/reference.test.ts new file mode 100644 index 00000000..73e1a8a1 --- /dev/null +++ b/typescript/packages/core/tests/reference.test.ts @@ -0,0 +1,256 @@ +import { expect, test } from 'vitest' +import { parse } from '../src/parse.ts' +import { plan } from '../src/plan.ts' +import { buildWorkspace, emptyWorkspace, references, slugify } from '../src/reference.ts' +import { addStep, createRegistry, type Registry } from '../src/registry.ts' + +// Reuse is a link (ADR 0016): a block whose entire content is a link to an oath +// section splices that section's steps in at its own position. + +function reg(): Registry { + let r = createRegistry() + for (const expression of [ + 'The library holds {string}', + 'Fees are enabled', + 'Maya borrows {string}', + 'She owes {string}', + 'The nightly batch runs', + ]) { + r = addStep(r, { + expression, + expressionSourceFile: 'steps.ts', + expressionSourceLine: 1, + kind: expression.startsWith('She owes') ? 'sensor' : 'stimulus', + handler: () => {}, + }) + } + return r +} + +const SHARED = `# Shared + +## A stocked library + +The library holds "Dune". + +## Fees are enabled + +Fees are enabled. +` + +function planWith(main: string, shared = SHARED) { + const docs = [parse('varar/fees.md', main), parse('varar/shared.md', shared)] + const workspace = buildWorkspace(docs) + return { + main: plan(docs[0]!, reg(), workspace), + shared: plan(docs[1]!, reg(), workspace), + } +} + +test('a link-only paragraph splices the referenced section into the example', () => { + const { main } = planWith(`# Late fees + +[A stocked library](./shared.md#a-stocked-library) + +Maya borrows "Emma". She owes "£2.50". +`) + + expect(main.examples).toHaveLength(1) + expect(main.examples[0]?.steps.map((s) => s.text)).toEqual([ + 'The library holds "Dune"', + 'Maya borrows "Emma"', + 'She owes "£2.50"', + ]) +}) + +test('the example is named by its own first matching paragraph, not the link', () => { + const { main } = planWith(`# Late fees + +[A stocked library](./shared.md#a-stocked-library) + +Maya borrows "Emma". +`) + + expect(main.examples[0]?.name).toBe('Maya borrows "Emma"') +}) + +test('a spliced step carries the document its spans belong to', () => { + const { main } = planWith(`[A stocked library](./shared.md#a-stocked-library) + +Maya borrows "Emma". +`) + + const [spliced, own] = main.examples[0]?.steps ?? [] + expect(spliced?.docPath).toBe('varar/shared.md') + expect(own?.docPath).toBeUndefined() +}) + +test('a referenced section stops being a standalone example', () => { + const { shared } = planWith(`[A stocked library](./shared.md#a-stocked-library) + +Maya borrows "Emma". +`) + + // "Fees are enabled" is untouched; the consumed section is gone. + expect(shared.examples.map((e) => e.name)).toEqual(['Fees are enabled']) +}) + +test('a reference can appear mid-example, sharing the same state', () => { + const { main } = planWith(`Maya borrows "Emma". + +[Fees are enabled](./shared.md#fees-are-enabled) + +She owes "£2.50". +`) + + expect(main.examples).toHaveLength(1) + expect(main.examples[0]?.steps.map((s) => s.text)).toEqual([ + 'Maya borrows "Emma"', + 'Fees are enabled', + 'She owes "£2.50"', + ]) +}) + +test('a same-file reference resolves against the document being planned', () => { + const source = `# Setup + +## Groundwork + +Fees are enabled. + +# Late fees + +[Groundwork](#groundwork) + +Maya borrows "Emma". +` + const doc = parse('varar/one.md', source) + const p = plan(doc, reg(), buildWorkspace([doc])) + + expect(p.examples).toHaveLength(1) + expect(p.examples[0]?.steps.map((s) => s.text)).toEqual([ + 'Fees are enabled', + 'Maya borrows "Emma"', + ]) +}) + +test('a blockquote or list item spells the same reference', () => { + const quoted = planWith(`> [A stocked library](./shared.md#a-stocked-library) + +Maya borrows "Emma". +`).main + const listed = planWith(`- [A stocked library](./shared.md#a-stocked-library) + +Maya borrows "Emma". +`).main + + expect(quoted.examples[0]?.steps).toHaveLength(2) + expect(listed.examples[0]?.steps).toHaveLength(2) +}) + +test('references nest to any depth', () => { + const shared = `# Shared + +## Base + +Fees are enabled. + +## A stocked library + +[Base](#base) + +The library holds "Dune". +` + const { main } = planWith( + `[A stocked library](./shared.md#a-stocked-library) + +Maya borrows "Emma". +`, + shared, + ) + + expect(main.examples[0]?.steps.map((s) => s.text)).toEqual([ + 'Fees are enabled', + 'The library holds "Dune"', + 'Maya borrows "Emma"', + ]) +}) + +test('a reference cycle is reported, not recursed into', () => { + const shared = `# Shared + +## A + +[B](#b) + +Fees are enabled. + +## B + +[A](#a) + +The library holds "Dune". +` + const { main } = planWith( + `[A](./shared.md#a) + +Maya borrows "Emma". +`, + shared, + ) + + expect(main.diagnostics.map((d) => d.code)).toContain('reference-cycle') +}) + +test('a link to a document the workspace does not hold is an error, not prose', () => { + const { main } = planWith(`[Missing](./nope.md#anything) + +Maya borrows "Emma". +`) + + const diagnostic = main.diagnostics.find((d) => d.code === 'reference-not-found') + expect(diagnostic?.message).toContain('varar/nope.md') +}) + +test('a link whose section contributes no steps is an error', () => { + const { main } = planWith(`[Typo](./shared.md#a-stoked-library) + +Maya borrows "Emma". +`) + + expect(main.diagnostics.map((d) => d.code)).toContain('reference-empty') +}) + +test('a link-only paragraph with any other target stays prose', () => { + const source = `[the docs](https://varar.dev/reference/examples/) + +Maya borrows "Emma". +` + const doc = parse('varar/fees.md', source) + const p = plan(doc, reg(), emptyWorkspace()) + + expect(references(doc)).toEqual([]) + expect(p.diagnostics).toEqual([]) + expect(p.examples).toHaveLength(1) +}) + +test('a link inside a sentence is content, not a reference', () => { + const doc = parse( + 'varar/fees.md', + 'See [A stocked library](./shared.md#a-stocked-library) first.\n', + ) + expect(references(doc)).toEqual([]) +}) + +test('references() resolves targets against the referring document', () => { + const doc = parse('varar/deep/fees.md', '[Up](../shared.md#a-stocked-library)\n') + expect(references(doc)).toEqual([ + { path: 'varar/shared.md', slug: 'a-stocked-library', text: 'Up' }, + ]) +}) + +test('slugs follow GitHub: inline markup dropped, punctuation stripped', () => { + expect(slugify('A *stocked* library')).toBe('a-stocked-library') + expect(slugify('Fees, VAT & rounding!')).toBe('fees-vat--rounding') + expect(slugify('`code` spans')).toBe('code-spans') +}) diff --git a/typescript/packages/language/src/index-workspace.ts b/typescript/packages/language/src/index-workspace.ts index 5903f0f8..8bf6445e 100644 --- a/typescript/packages/language/src/index-workspace.ts +++ b/typescript/packages/language/src/index-workspace.ts @@ -1,10 +1,12 @@ import { addStep, + buildWorkspace, createRegistry, type Doc, defineParameterType, type ExecutionPlan, hashSource, + type OathWorkspace, parse, plan, type Registry, @@ -93,6 +95,10 @@ export type WorkspaceIndex = { // downstream tools — snippet generation, completion, etc. — can use the // same view the matcher used. readonly registry: Registry + // The reference topology the plans were built against (ADR 0016), so a + // caller that re-plans one document — the LSP accepting drift — plans it the + // way the index did rather than as if it stood alone. + readonly workspace: OathWorkspace // Every oath's parsed document and execution plan, keyed by the path it was // indexed under. Exposed so a caller that needs the same plan — the LSP's // drift pass — reuses this one instead of parsing and planning a second time. @@ -166,11 +172,38 @@ export function buildWorkspaceIndex(input: WorkspaceInput, cache?: IndexCache): const diagnostics: DiagnosticRef[] = [] const oaths = new Map() - for (const file of input.oathFiles) { - // Parse is pure in the source, so it is cached by content alone; the plan - // and everything derived from it also depend on the registry. + // Parse every oath before planning any: whether a section is a standalone + // example depends on whether another oath references it, which is + // whole-project knowledge (ADR 0016). + const docs = input.oathFiles.map((file) => { const docKey = versionKey(file.path, file.source) - const planKey = `${docKey}\u0000${registryKey}` + let doc = cache?.docs.get(docKey) + if (!doc) { + doc = parse(file.path, file.source) + cache?.docs.set(docKey, doc) + } + return { file, docKey, doc } + }) + const workspace = buildWorkspace(docs.map((d) => d.doc)) + // A plan depends on the reference topology and on the content of the + // documents that topology reaches — and on nothing else in the workspace, so + // a project without references (the common case) keeps per-file caching + // exact, and one with references invalidates conservatively. + const workspaceKey = hashSource( + [ + ...[...workspace.referenced].sort(), + ...docs + .filter((d) => referencedPaths(workspace).has(d.doc.path)) + .map((d) => d.docKey) + .sort(), + ].join('\n'), + ) + + for (const { file, docKey, doc } of docs) { + // Parse is pure in the source, so it is cached by content alone; the plan + // and everything derived from it also depend on the registry and on the + // project's reference topology. + const planKey = `${docKey}\u0000${registryKey}\u0000${workspaceKey}` const cached = cache?.plans.get(planKey) if (cached) { oaths.set(file.path, cached) @@ -178,12 +211,7 @@ export function buildWorkspaceIndex(input: WorkspaceInput, cache?: IndexCache): diagnostics.push(...cached.diagnostics) continue } - let doc = cache?.docs.get(docKey) - if (!doc) { - doc = parse(file.path, file.source) - cache?.docs.set(docKey, doc) - } - const result = plan(doc, registry) + const result = plan(doc, registry, workspace) const fileMatches: MatchRef[] = [] const fileDiagnostics: DiagnosticRef[] = [] // Header-bound tables expand to one example per row, all sharing the same @@ -250,7 +278,7 @@ export function buildWorkspaceIndex(input: WorkspaceInput, cache?: IndexCache): diagnostics.push(...fileDiagnostics) } - return { stepDefs, matches, diagnostics, registry, oaths } + return { stepDefs, matches, diagnostics, registry, workspace, oaths } } type SpanLike = { @@ -266,3 +294,14 @@ function toRange(span: SpanLike): Range { end: { line: span.endLine, character: span.endCol }, } } + +// The paths a reference points at, so cache invalidation can ignore every +// document that takes no part in the reference graph. +function referencedPaths(workspace: ReturnType): ReadonlySet { + const out = new Set() + for (const key of workspace.referenced) { + const hash = key.lastIndexOf('#') + out.add(hash === -1 ? key : key.slice(0, hash)) + } + return out +} diff --git a/typescript/packages/lsp/src/store.ts b/typescript/packages/lsp/src/store.ts index 048c08c8..05288aad 100644 --- a/typescript/packages/lsp/src/store.ts +++ b/typescript/packages/lsp/src/store.ts @@ -4,6 +4,7 @@ import { deriveOathBaseline, detectDrift, driftDetected, + emptyWorkspace, type LockFile, parse, parseLockFile, @@ -120,6 +121,7 @@ export function createStore(deps: StoreDeps): Store { matches: [], diagnostics: [], registry: createRegistry(), + workspace: emptyWorkspace(), oaths: new Map(), } // Created once, lazily, on the first reindex — not in createStore itself, @@ -196,7 +198,11 @@ export function createStore(deps: StoreDeps): Store { const oathPath = toOathPath(root, absPath) const source = await fs.read(absPath) const doc = parse(absPath, source) - const baseline = deriveOathBaseline(source, doc, plan(doc, current.registry)) + const baseline = deriveOathBaseline( + source, + doc, + plan(doc, current.registry, current.workspace), + ) const next: LockFile = { version: 2, oaths: { ...(existing?.oaths ?? {}), [oathPath]: baseline }, diff --git a/typescript/packages/runner/src/run.ts b/typescript/packages/runner/src/run.ts index 295f1d53..13755549 100644 --- a/typescript/packages/runner/src/run.ts +++ b/typescript/packages/runner/src/run.ts @@ -2,6 +2,7 @@ import { collectExamples, type Diagnostic, type ExecutionPlan, + type OathWorkspace, type PlannedExample, parse, plan, @@ -22,8 +23,18 @@ export function examplesWithRuns( })) } -export function planOath(path: string, source: string, registry: Registry): ExecutionPlan { - return plan(parse(path, source), registry) +// Plan one oath. `workspace` carries every other oath in the project plus the +// sections a reference block consumes (ADR 0016) — it is required because an +// adapter that omitted it would run consumed sections as standalone examples, +// which is green and wrong. An adapter with no reference support yet passes +// emptyWorkspace(); one that discovers the project passes buildWorkspace(docs). +export function planOath( + path: string, + source: string, + registry: Registry, + workspace: OathWorkspace, +): ExecutionPlan { + return plan(parse(path, source), registry, workspace) } export class RecordingReporter implements Reporter { diff --git a/typescript/packages/runner/tests/run.test.ts b/typescript/packages/runner/tests/run.test.ts index e382c7f0..aa3874dc 100644 --- a/typescript/packages/runner/tests/run.test.ts +++ b/typescript/packages/runner/tests/run.test.ts @@ -1,4 +1,4 @@ -import { addStep, createRegistry, type Diagnostic } from '@varar/core' +import { addStep, createRegistry, type Diagnostic, emptyWorkspace } from '@varar/core' import { expect, test } from 'vitest' import { examplesWithRuns, planOath, RecordingReporter } from '../src/run.ts' @@ -36,7 +36,7 @@ test('planOath returns an ExecutionPlan with examples and steps', () => { 'I have 10 cucumbers. I eat 3 cucumbers. I should have 7 cucumbers left.', ].join('\n') - const result = planOath('oath.md', source, makeRegistry()) + const result = planOath('oath.md', source, makeRegistry(), emptyWorkspace()) expect(result.diagnostics).toHaveLength(0) expect(result.examples).toHaveLength(1) @@ -53,7 +53,7 @@ test('planOath returns an ExecutionPlan with examples and steps', () => { test('planOath parses and plans an oath', () => { const source = '# Simple\n\nI have 5 cucumbers.\n' - const result = planOath('oath.md', source, makeRegistry()) + const result = planOath('oath.md', source, makeRegistry(), emptyWorkspace()) expect(result.doc.source).toBe(source) expect(result.examples).toHaveLength(1) }) @@ -79,7 +79,7 @@ test('examplesWithRuns pairs examples with run functions', async () => { '', 'I have 10 cucumbers. I eat 3 cucumbers. I should have 7 cucumbers left.', ].join('\n') - const plan = planOath('oath.md', source, makeRegistry()) + const plan = planOath('oath.md', source, makeRegistry(), emptyWorkspace()) const reporter = new RecordingReporter() const pairs = examplesWithRuns(plan, () => ({}), reporter) @@ -105,7 +105,7 @@ test('examplesWithRuns — failing run rejects', async () => { }, }) const source = '# Test\n\nthe value is 42.\n' - const plan = planOath('oath.md', source, r) + const plan = planOath('oath.md', source, r, emptyWorkspace()) const reporter = new RecordingReporter() const pairs = examplesWithRuns(plan, () => ({}), reporter) diff --git a/typescript/packages/vitest/src/plugin.ts b/typescript/packages/vitest/src/plugin.ts index d3a84ab8..ea4191c2 100644 --- a/typescript/packages/vitest/src/plugin.ts +++ b/typescript/packages/vitest/src/plugin.ts @@ -5,6 +5,13 @@ import { type OathBaseline, parseLockFile } from '@varar/core' import type { Plugin } from 'vite' import { configDefaults } from 'vitest/config' import { discoverStaticExamples, type StaticExample } from './static-examples.ts' +import { + hasConsumedSection, + type OathSources, + projectWorkspace, + referencedKeys, + referencedSources, +} from './workspace.ts' export type VararVitestPluginOptions = { readonly cwd?: string @@ -77,10 +84,15 @@ export function vararVitestPlugin(options: VararVitestPluginOptions = {}): Plugi // and .varar/, and is the `doc.path` both the static plan below and the // runtime plan see — they must agree (ADR 0016). const oathPath = toOathPath(cwd, absPath) + // Any oath can consume a section of this one, or be consumed by it, so + // every oath is a watch dependency of every transform (ADR 0016). + for (const f of oathFiles) if (f !== absPath) this.addWatchFile(f) + const workspace = projectWorkspace(cwd, [...oathFiles]) const examples = await discoverStaticExamples({ oathPath, source, stepFiles: stepFiles.map((path) => ({ path, source: readFileSync(path, 'utf8') })), + workspace, }) const lock = existsSync(lockPath) ? parseLockFile(readFileSync(lockPath, 'utf8')) : null const baseline = lock?.oaths[oathPath] ?? null @@ -90,6 +102,9 @@ export function vararVitestPlugin(options: VararVitestPluginOptions = {}): Plugi source, examples, baseline, + referencedSources: referencedSources(workspace, oathPath), + referencedKeys: referencedKeys(workspace), + consumed: hasConsumedSection(workspace, oathPath), }) }, } @@ -106,6 +121,16 @@ export type GenerateInput = { // This oath's drift baseline from varar.lock.json (or null when unbaselined), // inlined so the runtime can run the read-only drift gate. readonly baseline?: OathBaseline | null + // The sources of every oath this one references, transitively, and the + // project's consumed-section keys — inlined so the runtime plan sees the same + // workspace the build-time plan did (ADR 0016). Both empty in a project that + // uses no reference blocks, which is the common case. + readonly referencedSources?: OathSources + readonly referencedKeys?: ReadonlyArray + // True when every section of this oath is consumed by a reference elsewhere + // — it contributes no standalone example, and must not become a zero-test + // file. + readonly consumed?: boolean } // The generated module preserves an IDENTITY LINE MAPPING to the markdown @@ -118,6 +143,10 @@ export function generateVirtualModule(input: GenerateInput): string { const sourceJson = JSON.stringify(input.source ?? '') const pathJson = JSON.stringify(input.oathPath) const baselineJson = JSON.stringify(input.baseline ?? null) + const workspaceJson = JSON.stringify({ + sources: Object.fromEntries(input.referencedSources ?? new Map()), + referenced: input.referencedKeys ?? [], + }) const examples = input.examples ?? [] const header: string[] = [ "import { test } from 'vitest'", @@ -126,15 +155,24 @@ export function generateVirtualModule(input: GenerateInput): string { // @varar/core here would fail under pnpm's strict node_modules // layout, because the module id (the oath path) resolves in the // consumer's project, where transitive deps are not visible. - "import { collectVararExamples, vararTestBody } from '@varar/vitest/runtime'", + "import { collectVararExamples, vararConsumedBody, vararTestBody } from '@varar/vitest/runtime'", ...input.stepImports.map((p) => `import ${JSON.stringify(p)}`), `const PATH = ${pathJson}`, // Diagnostics and the stale-transform guard register their tests inside // collectVararExamples, so the only `test(...)` callsites in this module // are the real per-example ones below — static AST discovery sees an // exact test tree. - `const EXAMPLES = collectVararExamples(PATH, ${sourceJson}, { expectedCount: ${examples.length}, baseline: ${baselineJson} })`, + `const EXAMPLES = collectVararExamples(PATH, ${sourceJson}, { expectedCount: ${examples.length}, baseline: ${baselineJson}, workspace: ${workspaceJson} })`, ] + // Every section of this oath is consumed by a reference elsewhere, so it + // holds no standalone example. It is still a discovered oath: it plans, and + // its drift baseline is recorded like any other's. One bookkeeping test does + // that — and keeps vitest from failing the file, which it does for a module + // that declares no test at all ("No test suite found in file"). ADR 0016. + if (examples.length === 0 && input.consumed === true) { + const label = JSON.stringify(`varar:referenced-elsewhere`) + return `${header.join(';')};test(${label}, vararConsumedBody(PATH))\n` + } const testCall = (ex: StaticExample, i: number): string => { const nameJson = JSON.stringify(ex.name) return `test(${nameJson}, vararTestBody(EXAMPLES, ${i}, ${nameJson}, PATH))` diff --git a/typescript/packages/vitest/src/reporter.ts b/typescript/packages/vitest/src/reporter.ts index 789db341..83a38fac 100644 --- a/typescript/packages/vitest/src/reporter.ts +++ b/typescript/packages/vitest/src/reporter.ts @@ -16,7 +16,7 @@ import { writeOathResults, } from '@varar/runner' import type { Reporter, TestModule } from 'vitest/node' -import { VARAR_BASELINE_META } from './runtime.ts' +import { VARAR_BASELINE_META, VARAR_CONSUMED_META } from './runtime.ts' // Structural shape of the slice of vitest's TestModule API the collector reads. // `meta()` is typed `unknown` so both vitest's real `TestModule` (whose @@ -29,6 +29,7 @@ type TestCaseNode = { type TestModuleNode = { readonly moduleId: string readonly children: { allTests(): Iterable } + meta?(): unknown } // The baseline arrives on the FILE's meta rather than any test's, so the // collector below reads a different slice of the same TestModule. @@ -51,7 +52,13 @@ export function collectFromModules( ?.vararResult if (vararResult) examples.push(vararResult) } - if (examples.length > 0) byFile.set(m.moduleId, examples) + // A fully-consumed oath (every section referenced elsewhere) runs no + // example but is still a discovered oath: it gets an empty record, so a + // stale diagnostic from before it was consumed is cleared. + const consumed = (m.meta?.() as Record | null | undefined)?.[ + VARAR_CONSUMED_META + ] + if (examples.length > 0 || consumed === true) byFile.set(m.moduleId, examples) } return byFile } diff --git a/typescript/packages/vitest/src/runtime.ts b/typescript/packages/vitest/src/runtime.ts index 4216370f..b48cf8b7 100644 --- a/typescript/packages/vitest/src/runtime.ts +++ b/typescript/packages/vitest/src/runtime.ts @@ -1,10 +1,13 @@ import { + buildWorkspace, type CellDiff, deriveOathBaseline, detectDrift, driftDiagnostics, isCellMismatchError, type OathBaseline, + type OathWorkspace, + parse, type Reporter, toFailure, } from '@varar/core' @@ -29,6 +32,13 @@ export type CollectPorts = { // the gate is skipped and the baseline is re-recorded by the reporter at the // end of the run. readonly baseline?: OathBaseline | null + // The project's reference topology, inlined by the plugin (ADR 0016): the + // sources of every oath this one references, transitively, plus the + // consumed-section keys. Absent in a project that uses no reference blocks. + readonly workspace?: { + readonly sources: Readonly> + readonly referenced: ReadonlyArray + } } // Baselines derived at collection time, keyed by oath path, waiting for a test @@ -42,6 +52,13 @@ const pendingBaselines = new Map() // reporter reads it back through vitest's TestModule.meta(). export const VARAR_BASELINE_META = 'vararBaseline' +// Marks an oath module that is a discovered oath but contributes no standalone +// example, because every section it holds is referenced from another oath (ADR +// 0016). The reporter writes it an EMPTY .varar/.json: skipping the file +// would leave the language server showing diagnostics from the run before the +// section was consumed. +export const VARAR_CONSUMED_META = 'vararConsumed' + export type CollectedExample = { readonly name: string // Unique source lines of the example's matched steps, for the reporter. @@ -66,7 +83,7 @@ export function collectVararExamples( }), } const registry = buildRegistry() - const p = planOath(path, source, registry) + const p = planOath(path, source, registry, runtimeWorkspace(path, source, ports)) // Drift reconciliation, split across the process boundary. Detection happens // HERE, against the runtime plan — the same plan every other port reconciles // from (RSpec at describe time, JUnit in its selector resolver). A paragraph @@ -165,6 +182,20 @@ function attachExpectedActual(error: unknown): void { } } +// The body of the single bookkeeping test a fully-consumed oath registers: it +// contributes no standalone example (every section it holds is referenced from +// another oath — ADR 0016), but it is still a discovered oath, so its drift +// baseline must be recorded like any other's. Registering one test is also what +// keeps vitest from failing the file outright, which it does for a module that +// declares no test at all. +export function vararConsumedBody(path: string): (ctx: TaskContext) => void { + return (ctx) => { + attachBaseline(ctx, path) + const fileMeta = ctx.task.file?.meta + if (fileMeta) fileMeta[VARAR_CONSUMED_META] = true + } +} + export function vararTestBody( examples: ReadonlyArray, index: number, @@ -197,3 +228,19 @@ export function vararTestBody( } } } + +// Rebuild the workspace the plugin saw, from what it inlined. The referencing +// oath itself is included, so a same-file reference resolves; `referenced` is +// project-wide, so this oath's own consumed sections are suppressed here +// exactly as they were in the build-time plan. +function runtimeWorkspace(path: string, source: string, ports: CollectPorts): OathWorkspace { + const inlined = ports.workspace + if (!inlined || inlined.referenced.length === 0) { + return buildWorkspace([]) + } + const docs = [ + parse(path, source), + ...Object.entries(inlined.sources).map(([p, s]) => parse(p, s)), + ] + return { docs: new Map(docs.map((d) => [d.path, d])), referenced: new Set(inlined.referenced) } +} diff --git a/typescript/packages/vitest/src/static-examples.ts b/typescript/packages/vitest/src/static-examples.ts index e174399f..e164f722 100644 --- a/typescript/packages/vitest/src/static-examples.ts +++ b/typescript/packages/vitest/src/static-examples.ts @@ -1,3 +1,4 @@ +import { emptyWorkspace, type OathWorkspace } from '@varar/core' import { buildWorkspaceIndex, createTreeSitterScanner, type StepDefScanner } from '@varar/language' import { planOath } from '@varar/runner' import { createNodeGrammarLoader } from './node-grammar-loader.ts' @@ -30,6 +31,10 @@ export type DiscoverInput = { readonly oathPath: string readonly source: string readonly stepFiles: ReadonlyArray<{ readonly path: string; readonly source: string }> + // The project's reference topology (ADR 0016). Without it this plan would + // disagree with the runtime's about which sections are standalone examples, + // and the stale-transform guard would fire on every run. + readonly workspace?: OathWorkspace } // Build-time twin of the runtime plan: statically scan the step sources @@ -48,7 +53,7 @@ export async function discoverStaticExamples( oathFiles: [], scanner, }) - const p = planOath(input.oathPath, input.source, registry) + const p = planOath(input.oathPath, input.source, registry, input.workspace ?? emptyWorkspace()) return p.examples.map((ex) => ({ name: ex.name, line: ex.span.startLine, diff --git a/typescript/packages/vitest/src/workspace.ts b/typescript/packages/vitest/src/workspace.ts new file mode 100644 index 00000000..82821f28 --- /dev/null +++ b/typescript/packages/vitest/src/workspace.ts @@ -0,0 +1,86 @@ +import { readFileSync, statSync } from 'node:fs' +import { toOathPath } from '@varar/config' +import { buildWorkspace, type OathWorkspace, parse, references } from '@varar/core' + +// Whether a section of an oath is a standalone example depends on whether any +// OTHER oath references it (ADR 0016), so the plugin — which transforms one +// oath at a time — has to know the whole project before it can transform any of +// them. This module builds that view once per set of file versions. + +export type OathSources = ReadonlyMap + +// A cheap fingerprint of the oath set: path, size and mtime. Rebuilding the +// workspace means reading and parsing every oath, which would otherwise happen +// once per transformed oath — quadratic in a project's oath count. +function fingerprint(absPaths: ReadonlyArray): string { + return absPaths + .map((p) => { + try { + const st = statSync(p) + return `${p}:${st.size}:${st.mtimeMs}` + } catch { + return `${p}:missing` + } + }) + .join('\n') +} + +let cached: { readonly key: string; readonly workspace: OathWorkspace } | undefined + +export function projectWorkspace(cwd: string, absPaths: ReadonlyArray): OathWorkspace { + const key = fingerprint(absPaths) + if (cached?.key === key) return cached.workspace + const docs = absPaths.map((abs) => { + const path = toOathPath(cwd, abs) + let source = '' + try { + source = readFileSync(abs, 'utf8') + } catch { + // A file that vanished between glob and read contributes nothing. + } + return parse(path, source) + }) + const workspace = buildWorkspace(docs) + cached = { key, workspace } + return workspace +} + +// The sources the generated module must carry so the RUNTIME plan sees the same +// workspace the build-time plan did: every oath this one references, plus +// everything those reach, transitively. +export function referencedSources(workspace: OathWorkspace, oathPath: string): OathSources { + const out = new Map() + const seen = new Set([oathPath]) + const queue = [oathPath] + while (queue.length > 0) { + const next = queue.shift() + if (next === undefined) break + const doc = workspace.docs.get(next) + if (!doc) continue + for (const ref of references(doc)) { + if (seen.has(ref.path)) continue + seen.add(ref.path) + const target = workspace.docs.get(ref.path) + if (!target) continue + out.set(ref.path, target.source) + queue.push(ref.path) + } + } + return out +} + +// The consumed-section keys, as a plain array the generated module can inline. +export function referencedKeys(workspace: OathWorkspace): ReadonlyArray { + return [...workspace.referenced].sort() +} + +// Whether any section of this oath is consumed by a reference elsewhere. +// Combined with a plan that yields no examples, it distinguishes "nothing to +// run because this file is reused" — which gets the skipped-suite placeholder +// — from "nothing to run because nothing matched", which is an ordinary empty +// oath and must keep failing as it does today. +export function hasConsumedSection(workspace: OathWorkspace, oathPath: string): boolean { + const prefix = `${oathPath}#` + for (const key of workspace.referenced) if (key.startsWith(prefix)) return true + return false +} diff --git a/typescript/packages/vitest/tests/plugin.test.ts b/typescript/packages/vitest/tests/plugin.test.ts index c2c05a22..53d9642a 100644 --- a/typescript/packages/vitest/tests/plugin.test.ts +++ b/typescript/packages/vitest/tests/plugin.test.ts @@ -33,7 +33,7 @@ describe('generateVirtualModule', () => { // depends on (@varar/vitest, vitest) — a bare '@varar/core' // would not resolve from the oath's path under pnpm's strict layout. expect(lines[0]).toContain( - "import { collectVararExamples, vararTestBody } from '@varar/vitest/runtime'", + "import { collectVararExamples, vararConsumedBody, vararTestBody } from '@varar/vitest/runtime'", ) expect(lines[0]).not.toContain("from '@varar/core'") expect(lines[0]).toContain('import "/abs/account.steps.ts"') From 80e15ed3bb2cb091c930e94837427246ccd11283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 11:00:32 +0100 Subject: [PATCH 08/21] feat(ts): a step spliced in from another oath keeps that oath's identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds paramTexts to PlannedStep — the notation each parameter matched, sliced at plan time from the document the step was written in. Consumers sliced the running oath's source by the step's spans instead, which is wrong for a step a reference block spliced in from another file: the spans belong to that file, and the host source yields whatever text happens to sit at those offsets. The plan conformance artifact projects docPath, and the bundle harness parses every .md in a bundle directory so a bundle can hold the oath its example.md links to. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- typescript/packages/core/src/conformance.ts | 8 +++++-- typescript/packages/core/src/plan.ts | 7 ++++++ .../packages/language/src/index-workspace.ts | 4 +++- .../packages/varar/tests/conformance.test.ts | 22 +++++++++++++++---- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/typescript/packages/core/src/conformance.ts b/typescript/packages/core/src/conformance.ts index 9f1d463d..87322a20 100644 --- a/typescript/packages/core/src/conformance.ts +++ b/typescript/packages/core/src/conformance.ts @@ -190,10 +190,14 @@ export function toPlanArtifact(plan: ExecutionPlan): PlanArtifact { matchSpan: step.matchSpan, paramSpans: step.paramSpans, matchedExpression: step.stepDef.expression, - args: step.paramSpans.map((span, i) => ({ - value: plan.doc.source.slice(span.startOffset, span.endOffset), + args: step.paramTexts.map((value, i) => ({ + value, parameterType: stepNames[i] ?? null, })), + // Present only on a step a reference block spliced in from another + // oath (ADR 0016): the document its spans belong to. Pinned so a port + // that resolves references but loses the identity goes red. + ...(step.docPath ? { docPath: step.docPath } : {}), ...(step.dataTable ? { dataTable: step.dataTable } : {}), ...(step.docString ? { docString: step.docString } : {}), } diff --git a/typescript/packages/core/src/plan.ts b/typescript/packages/core/src/plan.ts index 340df23e..819a7f8e 100644 --- a/typescript/packages/core/src/plan.ts +++ b/typescript/packages/core/src/plan.ts @@ -70,6 +70,12 @@ export type PlannedStep = { // Whole matched notation per parameter, incl. delimiters (e.g. quotes) — // used for rename and the "actual" side of a mismatch. readonly paramSpans: ReadonlyArray + // The text those spans cover, sliced at plan time from the document the step + // was written in. Consumers must use this rather than slicing the running + // oath's source: a step spliced in by a reference block (ADR 0016) has spans + // in a DIFFERENT document, and slicing the host source by them yields + // whatever text happens to sit at those offsets. + readonly paramTexts: ReadonlyArray // The value passed to the handler per parameter (inner capture group), for // editor highlighting. Aligned 1:1 with `paramSpans`; equals it when the // parameter regexp has no capture group. @@ -373,6 +379,7 @@ function planCandidate( text: block.text.slice(hit.matchStart, hit.matchEnd), matchSpan: liftSpan(doc.source, block, hit.matchStart, hit.matchEnd), paramSpans: hit.paramSpans.map((p) => liftSpan(doc.source, block, p.start, p.end)), + paramTexts: hit.paramSpans.map((p) => block.text.slice(p.start, p.end)), paramInnerSpans: hit.paramInnerSpans.map((p) => liftSpan(doc.source, block, p.start, p.end), ), diff --git a/typescript/packages/language/src/index-workspace.ts b/typescript/packages/language/src/index-workspace.ts index 8bf6445e..41523664 100644 --- a/typescript/packages/language/src/index-workspace.ts +++ b/typescript/packages/language/src/index-workspace.ts @@ -252,7 +252,9 @@ export function buildWorkspaceIndex(input: WorkspaceInput, cache?: IndexCache): // Highlight only the value passed to the handler (inner capture // group); paramValues keeps the full notation for rename. paramRanges: step.paramInnerSpans.map(toRange), - paramValues: step.paramSpans.map((s) => file.source.slice(s.startOffset, s.endOffset)), + // From the step's own document — a step spliced in by a reference + // block has spans in another file, which this source cannot slice. + paramValues: step.paramTexts, stepDef: def, }) } diff --git a/typescript/packages/varar/tests/conformance.test.ts b/typescript/packages/varar/tests/conformance.test.ts index 6c3a13d4..bc420826 100644 --- a/typescript/packages/varar/tests/conformance.test.ts +++ b/typescript/packages/varar/tests/conformance.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { canonicalStringify, parse, runConformance } from '@varar/core' +import { buildWorkspace, canonicalStringify, parse, runConformance } from '@varar/core' import { describe, expect, test } from 'vitest' import { _customParameterTypes, @@ -41,9 +41,23 @@ for (const name of readdirSync(BUNDLES, { withFileTypes: true }) } const registry = buildRegistry() const createContext = contextFactory() - const source = readFileSync(resolve(dir, 'example.md'), 'utf8') - const doc = parse('example.md', source) - const artifacts = await runConformance(doc, registry, createContext, _customParameterTypes()) + // A bundle is one oath (example.md) plus, for a bundle that exercises + // reference blocks (ADR 0016), the other oaths it links to — every other + // `.md` in the bundle directory. They are parsed under their bare file + // names, so `./shared.md` resolves the same way in every port. + const oathFiles = readdirSync(dir) + .filter((f) => f.endsWith('.md')) + .sort() + const docs = oathFiles.map((f) => parse(f, readFileSync(resolve(dir, f), 'utf8'))) + const doc = docs.find((d) => d.path === 'example.md') + if (!doc) throw new Error(`Bundle "${name}" has no example.md`) + const artifacts = await runConformance( + doc, + registry, + createContext, + _customParameterTypes(), + buildWorkspace(docs), + ) const goldenDir = resolve(dir, 'golden') if (UPDATE && !existsSync(goldenDir)) mkdirSync(goldenDir, { recursive: true }) From 2ba138f90318dc6ad0031172747b540e843c5a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 11:03:24 +0100 Subject: [PATCH 09/21] feat(py): reuse setup between examples by linking to a section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports reference blocks (ADR 0016) to Python: a block whose entire content is a Markdown link to an oath section splices that section's steps in at its own position, in the same file or across files, nesting to any depth with cycles reported rather than recursed into. plan() takes the workspace as a required argument, and both adapters build it from their existing discovery pass — pytest in pytest_configure, where baseline pruning already insists on the config globs rather than the filtered view, and unittest in generate_tests. PlannedStep also gains param_texts, sliced from the document the step was written in: slicing the running oath's source is wrong for a step spliced in from another file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- .../core/src/varar_core/conformance.py | 17 +- .../core/src/varar_core/diagnostics.py | 53 +++++- python/packages/core/src/varar_core/plan.py | 171 ++++++++++++++++-- .../packages/core/src/varar_core/reference.py | 160 ++++++++++++++++ .../packages/core/tests/test_conformance.py | 5 +- python/packages/core/tests/test_drift.py | 63 +++---- python/packages/core/tests/test_execute.py | 43 ++--- .../core/tests/test_failure_step_span.py | 3 +- python/packages/core/tests/test_plan.py | 59 +++--- .../pytest/src/varar_pytest/plugin.py | 33 +++- .../packages/runner/src/varar_runner/run.py | 7 +- python/packages/runner/tests/test_run.py | 3 +- .../unittest/src/varar_unittest/__init__.py | 31 +++- .../packages/varar/tests/test_conformance.py | 18 +- 14 files changed, 542 insertions(+), 124 deletions(-) create mode 100644 python/packages/core/src/varar_core/reference.py diff --git a/python/packages/core/src/varar_core/conformance.py b/python/packages/core/src/varar_core/conformance.py index a64a362d..edb11680 100644 --- a/python/packages/core/src/varar_core/conformance.py +++ b/python/packages/core/src/varar_core/conformance.py @@ -32,8 +32,9 @@ from varar_core.failure_anchor import failure_anchor from varar_core.plan import ExecutionPlan from varar_core.plan import plan as build_plan +from varar_core.reference import OathWorkspace, empty_workspace from varar_core.registry import Registry -from varar_core.span import Span, utf16_slice +from varar_core.span import Span @dataclass(frozen=True, slots=True) @@ -203,7 +204,6 @@ def to_plan_artifact(plan: ExecutionPlan) -> dict[str, Any]: Port of ``toPlanArtifact`` from conformance.ts. """ - source = plan.doc.source def _step(step: Any) -> dict[str, Any]: step_names = parameter_type_names(step.step_def.compiled) @@ -214,12 +214,16 @@ def _step(step: Any) -> dict[str, Any]: "matchedExpression": step.step_def.expression, "args": [ { - "value": utf16_slice(source, s.start_offset, s.end_offset), + "value": value, "parameterType": step_names[i] if i < len(step_names) else None, } - for i, s in enumerate(step.param_spans) + for i, value in enumerate(step.param_texts) ], } + # Present only on a step a reference block spliced in from another oath + # (ADR 0016): the document its spans belong to. + if step.doc_path is not None: + result["docPath"] = step.doc_path if step.data_table is not None: result["dataTable"] = _block(step.data_table) if step.doc_string is not None: @@ -310,12 +314,15 @@ def run_conformance( registry: Registry, create_context: Callable[[str], Any], parameter_types: tuple[dict[str, str], ...] = (), + # The other oaths in the bundle, for a bundle whose oath references them + # (ADR 0016). A single-document bundle passes nothing. + workspace: OathWorkspace | None = None, ) -> BundleArtifacts: """Run all examples and return the four-artifact bundle as a typed BundleArtifacts. Port of ``runConformance`` from conformance.ts. """ - execution = build_plan(doc, registry) + execution = build_plan(doc, registry, workspace or empty_workspace()) # Accumulate step observations keyed by example index. observed: dict[int, list[StepObservation]] = {} diff --git a/python/packages/core/src/varar_core/diagnostics.py b/python/packages/core/src/varar_core/diagnostics.py index 9bd1b316..a5f86806 100644 --- a/python/packages/core/src/varar_core/diagnostics.py +++ b/python/packages/core/src/varar_core/diagnostics.py @@ -12,7 +12,14 @@ from varar_core.span import Span Severity = Literal["error", "warning"] -DiagnosticCode = Literal["ambiguous-match", "error-fence-without-step", "drift"] +DiagnosticCode = Literal[ + "ambiguous-match", + "error-fence-without-step", + "drift", + "reference-not-found", + "reference-empty", + "reference-cycle", +] @dataclass(frozen=True, slots=True) @@ -80,3 +87,47 @@ def error_fence_without_step(span: Span) -> Diagnostic: ), span=span, ) + + +def reference_not_found(text: str, path: str, span: Span) -> Diagnostic: + """A reference block (ADR 0016) points at an oath the workspace does not + hold. Never prose: a link-only block that resolves to nothing has no other + reading, so it fails the run rather than degrading silently.""" + return Diagnostic( + severity="error", + code="reference-not-found", + message=( + f'Reference to "{text}" points at "{path}", which is not an oath in this ' + "workspace.\nCheck the path, and that the file is matched by the `docs` globs " + "in varar.config.json." + ), + span=span, + ) + + +def reference_empty(text: str, path: str, slug: str, span: Span) -> Diagnostic: + """The referenced document exists but the section contributes no steps — a + mistyped anchor, or a section that is pure prose.""" + where = path if slug == "" else f"{path}#{slug}" + return Diagnostic( + severity="error", + code="reference-empty", + message=( + f'Reference to "{text}" resolves to "{where}", which contributes no steps.\n' + "Check the heading the anchor names, and that its section contains a matching " + "paragraph." + ), + span=span, + ) + + +def reference_cycle(chain: tuple[str, ...], span: Span) -> Diagnostic: + """References may nest to any depth (depth is a style question, not a rule), + so a chain that reaches a section already on it must be reported rather than + recursed into.""" + return Diagnostic( + severity="error", + code="reference-cycle", + message="Reference cycle: " + " \u2192 ".join(chain) + ".", + span=span, + ) diff --git a/python/packages/core/src/varar_core/plan.py b/python/packages/core/src/varar_core/plan.py index 9f14a86a..3cb704f0 100644 --- a/python/packages/core/src/varar_core/plan.py +++ b/python/packages/core/src/varar_core/plan.py @@ -8,7 +8,7 @@ from __future__ import annotations import re -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Literal from varar_core.ast import Block, Example, Fence, SegmentOffset, Table, Doc @@ -19,8 +19,19 @@ Diagnostic, ambiguous_match, error_fence_without_step, + reference_cycle, + reference_empty, + reference_not_found, ) from varar_core.matcher import Hit, find_hits, resolve_hits +from varar_core.reference import ( + OathWorkspace, + Reference, + reference_of, + section_candidates, + section_key, + slugify, +) from varar_core.registry import Registry, StepRegistration from varar_core.sentences import split_sentences from varar_core.span import Span, span_from_offsets, to_utf16_offset, utf16_len @@ -48,6 +59,14 @@ class PlannedStep: # Per-argument display formatters from the matched parameter types, # aligned with args. Presentation only — see param_diff.py. formats: tuple = () + # The text the param spans cover, sliced at plan time from the document the + # step was written in. Consumers must use this rather than slicing the + # running oath's source: a step spliced in by a reference block (ADR 0016) + # has spans in a DIFFERENT document. + param_texts: tuple[str, ...] = () + # Set only when this step was spliced in from another oath by a reference + # block: the path of the document its spans belong to. + doc_path: str | None = None data_table: Table | None = None doc_string: DocString | None = None @@ -289,6 +308,17 @@ class _HeaderBoundUnit: rows: tuple[PlannedExample, ...] +@dataclass(frozen=True, slots=True) +class _ReferenceUnit: + """A reference block: its whole text is a link to an oath section, whose + steps are spliced in here (ADR 0016). Never prose, so it does not close the + open example.""" + + reference: Reference + preceded_by_delimiter: bool + span: Span + + @dataclass(frozen=True, slots=True) class _StepsUnit: """A single candidate paragraph, planned in isolation.""" @@ -303,7 +333,7 @@ class _StepsUnit: expected_error_message: str | None = None -_CandidateUnit = _HeaderBoundUnit | _StepsUnit +_CandidateUnit = _HeaderBoundUnit | _StepsUnit | _ReferenceUnit @dataclass(slots=True) @@ -318,6 +348,9 @@ class _MergedExample: steps: list[PlannedStep] expected_outcome: Literal["fail"] | None = None expected_error_message: str | None = None + # True while the name came from a spliced (referenced) paragraph and is + # waiting to be replaced by the example's own first matching paragraph. + name_from_reference: bool = False def _start_merged(unit: _StepsUnit) -> _MergedExample: @@ -332,7 +365,13 @@ def _start_merged(unit: _StepsUnit) -> _MergedExample: ) -def _merge_into(open_ex: _MergedExample, unit: _StepsUnit) -> None: +def _merge_into( + open_ex: _MergedExample, unit: _StepsUnit, from_reference: bool = False +) -> None: + if open_ex.name_from_reference and not from_reference: + open_ex.name = unit.name + open_ex.scope_stack = unit.scope_stack + open_ex.name_from_reference = False open_ex.end_offset = unit.span.end_offset open_ex.steps.extend(unit.steps) # Any error fence in a merged part marks the whole example expected-to-fail; @@ -358,7 +397,7 @@ def _finish_merged(open_ex: _MergedExample, source: str) -> PlannedExample: ) -def plan(doc: Doc, registry: Registry) -> ExecutionPlan: +def plan(doc: Doc, registry: Registry, workspace: OathWorkspace) -> ExecutionPlan: """Mirror plan() from plan.ts. Phase 1 plans each candidate paragraph independently into a "unit"; phase 2 @@ -367,8 +406,22 @@ def plan(doc: Doc, registry: Registry) -> ExecutionPlan: """ diagnostics: list[Diagnostic] = [] + # A section another oath references stops being a standalone example: it + # runs where it is referenced, not here (ADR 0016). + def consumed(ex: Example) -> bool: + if section_key(doc.path, "") in workspace.referenced: + return True + return any( + section_key(doc.path, slugify(h)) in workspace.referenced + for h in ex.scope_stack + ) + # Phase 1: plan each candidate paragraph independently into a "unit". - units = [_plan_candidate(ex, doc, registry, diagnostics) for ex in doc.examples] + units = [ + _plan_candidate(ex, doc, registry, diagnostics) + for ex in doc.examples + if not consumed(ex) + ] # Phase 2: group adjacent candidates into examples. A matching candidate # continues the open example when no delimiter (heading / `---`) precedes it; @@ -389,6 +442,22 @@ def flush() -> None: flush() examples.extend(unit.rows) continue + if isinstance(unit, _ReferenceUnit): + # Splice the referenced section's steps in at this position. Only + # the reference block itself is subject to the delimiter rule; + # everything it splices in belongs to the same sequence, so a + # section of several paragraphs stays one example. + resolved = _resolve_reference(unit, doc, registry, workspace, diagnostics, ()) + for i, spliced in enumerate(resolved): + if open_ex is not None and (i > 0 or not unit.preceded_by_delimiter): + _merge_into(open_ex, spliced, from_reference=True) + else: + flush() + open_ex = _start_merged(spliced) + # An example that OPENS with a reference is named by its own + # first matching paragraph, not by the section it pulls in. + open_ex.name_from_reference = True + continue if not unit.matched: # Prose paragraph — a delimiter. Drop it and end the open example. flush() @@ -410,6 +479,63 @@ def flush() -> None: ) +def _resolve_reference( + unit: _ReferenceUnit, + from_doc: Doc, + registry: Registry, + workspace: OathWorkspace, + diagnostics: list[Diagnostic], + chain: tuple[str, ...], +) -> tuple[_StepsUnit, ...]: + """Resolve one reference block into the step-bearing units of the section it + names, recursively: a referenced section may itself contain reference + blocks, to any depth (ADR 0016 leaves depth to the author's judgement). + *chain* carries the sections currently being resolved so a repeat is + reported as a cycle instead of recursing forever.""" + ref = unit.reference + key = section_key(ref.path, ref.slug) + if key in chain: + diagnostics.append(reference_cycle(chain + (key,), unit.span)) + return () + # A same-file reference resolves against the document being planned, which + # is not necessarily in the workspace (a caller may plan one in isolation). + target = from_doc if ref.path == from_doc.path else workspace.docs.get(ref.path) + if target is None: + diagnostics.append(reference_not_found(ref.text, ref.path, unit.span)) + return () + out: list[_StepsUnit] = [] + for candidate in section_candidates(target, ref.slug): + planned = _plan_candidate(candidate, target, registry, diagnostics) + if isinstance(planned, _ReferenceUnit): + out.extend( + _resolve_reference( + planned, target, registry, workspace, diagnostics, chain + (key,) + ) + ) + continue + # A header-bound table produces one example per row, which a spliced + # step list cannot express; an `error` fence declares an outcome for an + # example, not for a reusable fragment. Both are left out. + if not isinstance(planned, _StepsUnit) or not planned.matched: + continue + out.append(_tag_with_doc(planned, target.path, from_doc.path)) + if not out: + diagnostics.append(reference_empty(ref.text, ref.path, ref.slug, unit.span)) + return tuple(out) + + +def _tag_with_doc(unit: _StepsUnit, doc_path: str, host_path: str) -> _StepsUnit: + """Carry the source document's identity on every spliced step, so a failure + in a referenced section reports spans against the file they were written in + rather than the file being run.""" + if doc_path == host_path: + return unit + return replace( + unit, + steps=tuple(replace(step, doc_path=doc_path) for step in unit.steps), + ) + + def _plan_candidate( ex: Example, doc: Doc, @@ -418,6 +544,19 @@ def _plan_candidate( ) -> _CandidateUnit: """Plan a single candidate paragraph (plus its attached tables/fences) in isolation. Emits ambiguity / error-fence diagnostics into *diagnostics*.""" + # A block whose whole text is a link to an oath section is a reference, not + # content: never matched against step definitions, and never prose. + primary = ex.body[0] if ex.body else None + primary_text = getattr(primary, "text", None) + if primary_text is not None: + ref = reference_of(primary_text, doc.path) + if ref is not None: + return _ReferenceUnit( + reference=ref, + preceded_by_delimiter=ex.preceded_by_delimiter, + span=ex.span, + ) + had_ambiguous = False # ------------------------------------------------------------------ @@ -472,6 +611,10 @@ def _plan_candidate( _lift_span(doc.source, block, p.start, p.end) for p in hit.param_spans ), + param_texts=tuple( + _utf16_slice(block.text, p.start, p.end) # type: ignore[union-attr] + for p in hit.param_spans + ), step_def=hit.step_def, args=hit.args, formats=hit.formats, @@ -498,13 +641,10 @@ def _plan_candidate( row_object: dict[str, str] = {} for i, cell_name in enumerate(table.header.cells): row_object[cell_name] = row.cells[i] if i < len(row.cells) else "" - row_step = PlannedStep( - text=binding_step.text, + row_step = replace( + binding_step, match_span=row.span, # type: ignore[arg-type] - param_spans=binding_step.param_spans, - step_def=binding_step.step_def, args=(*binding_step.args, row_object), - formats=binding_step.formats, ) row_checks = tuple( RowCheck( @@ -578,16 +718,7 @@ def _plan_candidate( if s_idx == len(block_steps) - 1 and attach is not None: data_table, doc_string = attach final_steps.append( - PlannedStep( - text=step.text, - match_span=step.match_span, - param_spans=step.param_spans, - step_def=step.step_def, - args=step.args, - formats=step.formats, - data_table=data_table, - doc_string=doc_string, - ) + replace(step, data_table=data_table, doc_string=doc_string) ) else: final_steps.append(step) diff --git a/python/packages/core/src/varar_core/reference.py b/python/packages/core/src/varar_core/reference.py new file mode 100644 index 00000000..f53b3687 --- /dev/null +++ b/python/packages/core/src/varar_core/reference.py @@ -0,0 +1,160 @@ +"""reference.py — port of typescript/packages/core/src/reference.ts. + +Reuse is a link (ADR 0016). A candidate block whose entire content is a single +Markdown link to an oath section is a REFERENCE BLOCK: it splices that section's +steps in at its own position instead of being prose. + +Everything here is pure text and path arithmetic — no filesystem. The shell +reads the documents; ``references()`` tells it which ones to read, and +``build_workspace()`` turns the collection into what ``plan()`` needs. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from varar_core.ast import Doc, Example + + +@dataclass(frozen=True, slots=True) +class Reference: + # The referenced oath's path, resolved against the referring doc's own path. + # Equal to the referring doc's path for a same-file ``#fragment`` link. + path: str + # The GFM slug of the heading being referenced, or '' for a whole-file link. + slug: str + # The link's visible text, as written. + text: str + + +# A candidate is a reference block iff its whole text is one Markdown link whose +# target is oath-shaped. Anything else — a link with surrounding words, a link to +# https://…, to a .ts file, to a mailto: — is ordinary content, so existing +# documents keep their meaning. +_LINK_ONLY = re.compile(r"^\[([^\]]*)\]\(\s*([^\s)]+)\s*\)$") +_PROTOCOL = re.compile(r"^[a-z][a-z0-9+.\-]*:", re.IGNORECASE) +_NOT_SLUG = re.compile(r"[^\w \-]", re.UNICODE) + + +def reference_of(text: str, from_path: str) -> Reference | None: + m = _LINK_ONLY.match(text.strip()) + if m is None: + return None + link_text, target = m.group(1), m.group(2) + if target.startswith("#"): + return Reference(path=from_path, slug=_normalize_slug(target[1:]), text=link_text) + hash_at = target.find("#") + file_part = target if hash_at == -1 else target[:hash_at] + fragment = "" if hash_at == -1 else target[hash_at + 1 :] + # Only a relative Markdown path is a reference. A protocol (https:, mailto:) + # or any other extension is left alone — remote references are deliberately + # out of scope (ADR 0016). + if not file_part.endswith(".md") or _PROTOCOL.match(file_part): + return None + if file_part.startswith("/"): + return None + return Reference( + path=join_posix(_dirname_posix(from_path), file_part), + slug=_normalize_slug(fragment), + text=link_text, + ) + + +def slugify(heading_text: str) -> str: + """GitHub's heading anchors: inline markup dropped, lowercased, spaces to + hyphens, everything else that isn't a word character or hyphen removed. The + same function produces the slug of a heading and normalizes the slug written + in a link, so the two meet in the middle.""" + stripped = re.sub(r"`([^`]*)`", r"\1", heading_text) + stripped = re.sub(r"\*\*([^*]*)\*\*", r"\1", stripped) + stripped = re.sub(r"\*([^*]*)\*", r"\1", stripped) + stripped = re.sub(r"_([^_]*)_", r"\1", stripped) + return _normalize_slug(stripped) + + +def _normalize_slug(s: str) -> str: + # One hyphen per space, not per run of them: GitHub leaves the gap where it + # dropped punctuation, so "Fees, VAT & rounding" slugs with a double hyphen. + return _NOT_SLUG.sub("", s.strip().lower()).replace(" ", "-") + + +def _dirname_posix(path: str) -> str: + i = path.rfind("/") + return "" if i == -1 else path[:i] + + +def join_posix(directory: str, rel: str) -> str: + """POSIX path arithmetic on oath paths (always '/'-separated, relative to + the workspace root). The core may not depend on a filesystem module.""" + segments = [] if directory == "" else directory.split("/") + for segment in rel.split("/"): + if segment in ("", "."): + continue + if segment == "..": + if segments: + segments.pop() + else: + segments.append(segment) + return "/".join(segments) + + +def references(doc: Doc) -> tuple[Reference, ...]: + """Every reference block in a document, in document order. The shell uses + this to walk the closure of documents it must read before planning.""" + out: list[Reference] = [] + for ex in doc.examples: + primary = ex.body[0] if ex.body else None + text = getattr(primary, "text", None) + if text is None: + continue + ref = reference_of(text, doc.path) + if ref is not None: + out.append(ref) + return tuple(out) + + +@dataclass(frozen=True, slots=True) +class OathWorkspace: + """What ``plan()`` needs to resolve references: every oath by path, plus + which sections are consumed by a reference somewhere in the project. A + section that is referenced stops being a standalone example, so this is + whole-project knowledge — see ADR 0016 on why each runner builds it at its + once-per-run discovery pass.""" + + docs: dict[str, Doc] = field(default_factory=dict) + # ``f"{path}#{slug}"`` for every referenced section; a whole-file reference + # is recorded as ``f"{path}#"``. + referenced: frozenset[str] = frozenset() + + +def section_key(path: str, slug: str) -> str: + return f"{path}#{slug}" + + +def empty_workspace() -> OathWorkspace: + """The workspace with no references at all: what a caller planning a single + document in isolation passes.""" + return OathWorkspace(docs={}, referenced=frozenset()) + + +def build_workspace(docs: tuple[Doc, ...] | list[Doc]) -> OathWorkspace: + by_path = {doc.path: doc for doc in docs} + referenced: set[str] = set() + for doc in docs: + for ref in references(doc): + referenced.add(section_key(ref.path, ref.slug)) + return OathWorkspace(docs=by_path, referenced=frozenset(referenced)) + + +def section_candidates(doc: Doc, slug: str) -> tuple[Example, ...]: + """The candidates that make up a section: those whose heading chain contains + the slug. A whole-file reference ('' slug) is every candidate in the + document. Section membership follows the document outline exactly — a + heading's section runs until the next heading of the same or higher level, + which is precisely the range over which that heading stays on the scope + stack.""" + if slug == "": + return tuple(doc.examples) + return tuple( + ex for ex in doc.examples if any(slugify(h) == slug for h in ex.scope_stack) + ) diff --git a/python/packages/core/tests/test_conformance.py b/python/packages/core/tests/test_conformance.py index f8355731..0ace7a57 100644 --- a/python/packages/core/tests/test_conformance.py +++ b/python/packages/core/tests/test_conformance.py @@ -20,6 +20,7 @@ from varar_core.plan import plan from varar_core.registry import add_step, create_registry, define_parameter_type from varar_core.span import Span +from varar_core.reference import empty_workspace # --------------------------------------------------------------------------- @@ -174,7 +175,7 @@ def test_to_plan_artifact_projects_examples_expected_outcome_and_args(): kind="stimulus", handler=lambda *_: None, ) - art = to_plan_artifact(plan(parse("e.md", "# A\n\nI have 5 cukes."), r)) + art = to_plan_artifact(plan(parse("e.md", "# A\n\nI have 5 cukes."), r, empty_workspace())) assert art["examples"][0]["expectedOutcome"] == "pass" assert art["examples"][0]["steps"][0]["matchedExpression"] == "I have {int} cukes" assert art["examples"][0]["steps"][0]["args"] == [{"value": "5", "parameterType": "int"}] @@ -200,7 +201,7 @@ def test_to_plan_artifact_projects_diagnostics_without_message_or_path(): kind="stimulus", handler=lambda *_: None, ) - art = to_plan_artifact(plan(parse("e.md", "# A\n\nI have 5 cukes."), r)) + art = to_plan_artifact(plan(parse("e.md", "# A\n\nI have 5 cukes."), r, empty_workspace())) assert len(art["diagnostics"]) == 1 assert "message" not in art["diagnostics"][0] assert art["diagnostics"][0]["code"] == "ambiguous-match" diff --git a/python/packages/core/tests/test_drift.py b/python/packages/core/tests/test_drift.py index aab6ca30..15a00aa6 100644 --- a/python/packages/core/tests/test_drift.py +++ b/python/packages/core/tests/test_drift.py @@ -19,6 +19,7 @@ from varar_core.parse import parse from varar_core.plan import plan from varar_core.registry import add_step, create_registry +from varar_core.reference import empty_workspace def _noop(*_args: object, **_kwargs: object) -> None: @@ -70,83 +71,83 @@ def write(self, contents: str) -> None: def test_live_examples_records_one_entry_per_example_producing_paragraph() -> None: doc = parse("w.md", "I withdraw 40.") - assert live_examples(doc, plan(doc, _reg())) == ( + assert live_examples(doc, plan(doc, _reg(), empty_workspace())) == ( BaselineExample(name="I withdraw 40", line=1), ) def test_a_never_matched_paragraph_is_not_a_live_example() -> None: doc = parse("w.md", "Just some prose.") - assert live_examples(doc, plan(doc, _reg())) == () + assert live_examples(doc, plan(doc, _reg(), empty_workspace())) == () def test_derive_oath_baseline_carries_the_source_fingerprint() -> None: source = "I withdraw 40." doc = parse("w.md", source) - baseline = derive_oath_baseline(source, doc, plan(doc, _reg())) + baseline = derive_oath_baseline(source, doc, plan(doc, _reg(), empty_workspace())) assert baseline.source_hash == hash_source(source) assert baseline.examples == (BaselineExample(name="I withdraw 40", line=1),) def test_no_baseline_means_no_drift() -> None: doc = parse("w.md", "I withdraw 40.") - assert detect_drift(None, doc, plan(doc, _reg())) == () + assert detect_drift(None, doc, plan(doc, _reg(), empty_workspace())) == () def test_an_unchanged_oath_and_steps_have_no_drift() -> None: source = "I withdraw 40." doc = parse("w.md", source) - baseline = derive_oath_baseline(source, doc, plan(doc, _reg())) - assert detect_drift(baseline, doc, plan(doc, _reg())) == () + baseline = derive_oath_baseline(source, doc, plan(doc, _reg(), empty_workspace())) + assert detect_drift(baseline, doc, plan(doc, _reg(), empty_workspace())) == () def test_a_renamed_step_drifts_matched_by_name() -> None: source = "I withdraw 40." doc = parse("w.md", source) - baseline = derive_oath_baseline(source, doc, plan(doc, _reg(True))) - drift = detect_drift(baseline, doc, plan(doc, _reg(False))) + baseline = derive_oath_baseline(source, doc, plan(doc, _reg(True), empty_workspace())) + drift = detect_drift(baseline, doc, plan(doc, _reg(False), empty_workspace())) assert _bare(drift) == [("I withdraw 40", 1)] def test_an_in_place_typo_drifts_matched_by_line() -> None: before = "I withdraw 40." before_doc = parse("w.md", before) - baseline = derive_oath_baseline(before, before_doc, plan(before_doc, _reg())) + baseline = derive_oath_baseline(before, before_doc, plan(before_doc, _reg(), empty_workspace())) after_doc = parse("w.md", "I withdrraw 40.") - drift = detect_drift(baseline, after_doc, plan(after_doc, _reg())) + drift = detect_drift(baseline, after_doc, plan(after_doc, _reg(), empty_workspace())) assert _bare(drift) == [("I withdraw 40", 1)] def test_a_deleted_paragraph_is_not_drift() -> None: before = "I withdraw 40." before_doc = parse("w.md", before) - baseline = derive_oath_baseline(before, before_doc, plan(before_doc, _reg())) + baseline = derive_oath_baseline(before, before_doc, plan(before_doc, _reg(), empty_workspace())) after_doc = parse("w.md", "") - assert detect_drift(baseline, after_doc, plan(after_doc, _reg())) == () + assert detect_drift(baseline, after_doc, plan(after_doc, _reg(), empty_workspace())) == () def test_moving_and_rewording_a_still_matching_example_does_not_drift() -> None: before = "I withdraw 40.\n\nI withdraw 10." before_doc = parse("w.md", before) - baseline = derive_oath_baseline(before, before_doc, plan(before_doc, _reg())) + baseline = derive_oath_baseline(before, before_doc, plan(before_doc, _reg(), empty_workspace())) after_doc = parse("w.md", "I withdraw 11.\n\nI withdraw 40.") - assert detect_drift(baseline, after_doc, plan(after_doc, _reg())) == () + assert detect_drift(baseline, after_doc, plan(after_doc, _reg(), empty_workspace())) == () def test_move_plus_reword_plus_prose_on_old_line_does_not_false_positive() -> None: before = "I withdraw 40." before_doc = parse("w.md", before) - baseline = derive_oath_baseline(before, before_doc, plan(before_doc, _reg())) + baseline = derive_oath_baseline(before, before_doc, plan(before_doc, _reg(), empty_workspace())) after_doc = parse("w.md", "Just some notes.\n\nI withdraw 41.") - assert detect_drift(baseline, after_doc, plan(after_doc, _reg())) == () + assert detect_drift(baseline, after_doc, plan(after_doc, _reg(), empty_workspace())) == () def test_a_paragraph_rewritten_past_recognition_is_remove_add_not_drift() -> None: before = "I withdraw 40." before_doc = parse("w.md", before) - baseline = derive_oath_baseline(before, before_doc, plan(before_doc, _reg())) + baseline = derive_oath_baseline(before, before_doc, plan(before_doc, _reg(), empty_workspace())) after_doc = parse("w.md", "The branch closed years ago.") - assert detect_drift(baseline, after_doc, plan(after_doc, _reg())) == () + assert detect_drift(baseline, after_doc, plan(after_doc, _reg(), empty_workspace())) == () _ROMAN = ( @@ -157,23 +158,23 @@ def test_a_paragraph_rewritten_past_recognition_is_remove_add_not_drift() -> Non def test_header_bound_table_records_its_binding_paragraph_once() -> None: doc = parse("r.md", _ROMAN) - assert live_examples(doc, plan(doc, _roman_reg())) == ( + assert live_examples(doc, plan(doc, _roman_reg(), empty_workspace())) == ( BaselineExample(name="Each row gives a decimal and a roman number:", line=1), ) def test_a_header_bound_binding_paragraph_that_stops_matching_drifts() -> None: doc = parse("r.md", _ROMAN) - baseline = derive_oath_baseline(_ROMAN, doc, plan(doc, _roman_reg(True))) - drift = detect_drift(baseline, doc, plan(doc, _roman_reg(False))) + baseline = derive_oath_baseline(_ROMAN, doc, plan(doc, _roman_reg(True), empty_workspace())) + drift = detect_drift(baseline, doc, plan(doc, _roman_reg(False), empty_workspace())) assert _bare(drift) == [("Each row gives a decimal and a roman number:", 1)] def test_drift_diagnostics_are_error_severity() -> None: source = "I withdraw 40." doc = parse("w.md", source) - baseline = derive_oath_baseline(source, doc, plan(doc, _reg(True))) - diags = drift_diagnostics(detect_drift(baseline, doc, plan(doc, _reg(False)))) + baseline = derive_oath_baseline(source, doc, plan(doc, _reg(True), empty_workspace())) + diags = drift_diagnostics(detect_drift(baseline, doc, plan(doc, _reg(False), empty_workspace()))) assert len(diags) == 1 assert diags[0].severity == "error" assert diags[0].code == "drift" @@ -184,9 +185,9 @@ def test_reconcile_records_on_first_run_then_reports_and_preserves_on_drift() -> source = "I withdraw 40." doc = parse("w.md", source) store = MemoryStore() - assert reconcile_drift(store, "w.md", source, doc, plan(doc, _reg(True))) == () + assert reconcile_drift(store, "w.md", source, doc, plan(doc, _reg(True), empty_workspace())) == () before = store.contents - drift = reconcile_drift(store, "w.md", source, doc, plan(doc, _reg(False))) + drift = reconcile_drift(store, "w.md", source, doc, plan(doc, _reg(False), empty_workspace())) assert _bare(drift) == [("I withdraw 40", 1)] assert store.contents == before # baseline untouched while drift unacknowledged @@ -195,9 +196,9 @@ def test_reconcile_update_mode_accepts_drift() -> None: source = "I withdraw 40." doc = parse("w.md", source) store = MemoryStore() - reconcile_drift(store, "w.md", source, doc, plan(doc, _reg(True))) + reconcile_drift(store, "w.md", source, doc, plan(doc, _reg(True), empty_workspace())) drift = reconcile_drift( - store, "w.md", source, doc, plan(doc, _reg(False)), update=True + store, "w.md", source, doc, plan(doc, _reg(False), empty_workspace()), update=True ) assert drift == () lock = parse_lock_file(store.contents or "") @@ -299,7 +300,7 @@ def _deposit_withdraw_reg(with_deposit: bool = True): def test_two_paragraphs_that_merge_are_each_recorded_as_a_live_baseline_entry() -> None: source = "I deposit 100.\n\nI withdraw 40." doc = parse("w.md", source) - plan1 = plan(doc, _deposit_withdraw_reg()) + plan1 = plan(doc, _deposit_withdraw_reg(), empty_workspace()) # One planned example (the two paragraphs merged), but two live entries. assert len(plan1.examples) == 1 assert live_examples(doc, plan1) == ( @@ -311,10 +312,10 @@ def test_two_paragraphs_that_merge_are_each_recorded_as_a_live_baseline_entry() def test_deleting_one_step_def_of_a_merged_example_drifts_only_the_now_prose_paragraph() -> None: source = "I deposit 100.\n\nI withdraw 40." doc = parse("w.md", source) - baseline = derive_oath_baseline(source, doc, plan(doc, _deposit_withdraw_reg(True))) + baseline = derive_oath_baseline(source, doc, plan(doc, _deposit_withdraw_reg(True), empty_workspace())) # The deposit step is gone: its paragraph becomes prose, splitting the # example. The withdraw paragraph is still live; the deposit one drifts. - drift = detect_drift(baseline, doc, plan(doc, _deposit_withdraw_reg(False))) + drift = detect_drift(baseline, doc, plan(doc, _deposit_withdraw_reg(False), empty_workspace())) assert _bare(drift) == [("I deposit 100", 1)] @@ -325,7 +326,7 @@ def _lock_with_stale_path() -> str: """ source = "I withdraw 40." doc = parse("w.md", source) - baseline = derive_oath_baseline(source, doc, plan(doc, _reg())) + baseline = derive_oath_baseline(source, doc, plan(doc, _reg(), empty_workspace())) return stringify_lock_file(LockFile(version=2, oaths={"varar/w.md": baseline, "w.md": baseline})) diff --git a/python/packages/core/tests/test_execute.py b/python/packages/core/tests/test_execute.py index 6ccdbcc5..91881784 100644 --- a/python/packages/core/tests/test_execute.py +++ b/python/packages/core/tests/test_execute.py @@ -15,6 +15,7 @@ from varar_core.parse import parse from varar_core.plan import plan from varar_core.registry import Registry, add_step, create_registry, define_parameter_type +from varar_core.reference import empty_workspace # --------------------------------------------------------------------------- @@ -82,7 +83,7 @@ def test_execute_plan_calls_sink_example_for_each_planned_example() -> None: kind="stimulus", handler=lambda *_: None, ) - p = plan(parse("e.md", "# A\n\nGiven I have 5 cukes\n\n# B\n\nGiven I have 9 cukes"), r) + p = plan(parse("e.md", "# A\n\nGiven I have 5 cukes\n\n# B\n\nGiven I have 9 cukes"), r, empty_workspace()) names: list[str] = [] execute_plan(p, ExecutePorts(sink=_make_name_sink(names), reporter=_noop_reporter())) assert names == ["Given I have 5 cukes", "Given I have 9 cukes"] @@ -107,7 +108,7 @@ def test_execute_plan_reports_all_diagnostics_through_reporter() -> None: kind="stimulus", handler=lambda *_: None, ) - p = plan(parse("m.md", "# A\n\nGiven I have 5 cukes"), r) + p = plan(parse("m.md", "# A\n\nGiven I have 5 cukes"), r, empty_workspace()) got: list[Any] = [] class _R: @@ -140,7 +141,7 @@ def test_sink_example_run_callback_executes_step_handlers_in_order() -> None: kind="sensor", handler=lambda _ctx, n: (calls.append(f"check:{n}"), n)[1], ) - p = plan(parse("e.md", "# Adding\n\nI add 5. I should have 5."), r) + p = plan(parse("e.md", "# Adding\n\nI add 5. I should have 5."), r, empty_workspace()) run = _capture_run(p) run() assert calls == ["add:5", "check:5"] @@ -160,7 +161,7 @@ def _thrower(*_: Any) -> None: kind="stimulus", handler=_thrower, ) - p = plan(parse("e.md", "# A\n\nI throw"), r2) + p = plan(parse("e.md", "# A\n\nI throw"), r2, empty_workspace()) run = _capture_run(p) captured: list[Exception] = [] try: @@ -186,7 +187,7 @@ def test_execute_plan_invokes_create_context_once_per_example() -> None: kind="stimulus", handler=lambda ctx: ctx_seen.append(ctx), ) - p = plan(parse("e.md", "# A\n\nI record ctx\n\n# B\n\nI record ctx"), r) + p = plan(parse("e.md", "# A\n\nI record ctx\n\n# B\n\nI record ctx"), r, empty_workspace()) calls = [0] runs: list[Any] = [] @@ -225,7 +226,7 @@ def test_execute_plan_appends_data_table_as_last_handler_arg() -> None: "| title | author |\n|--------|---------|" "\n| Lolita | Nabokov |\n| Anna | Tolstoy |\n" ) - p = plan(parse("l.md", source), r) + p = plan(parse("l.md", source), r, empty_workspace()) run = _capture_run(p) run() assert len(captured) == 1 @@ -250,7 +251,7 @@ def test_execute_plan_appends_docstring_as_last_handler_arg() -> None: handler=lambda _ctx, *args: captured.extend([args]), ) source = '# Library\n\nthe receipt is:\n\n```json\n{"ok": true}\n```\n' - p = plan(parse("l.md", source), r) + p = plan(parse("l.md", source), r, empty_workspace()) run = _capture_run(p) run() assert list(captured[0]) == ['{"ok": true}\n'] @@ -274,7 +275,7 @@ def test_execute_plan_runs_header_bound_table_once_per_row() -> None: "| 3, 3, 3, 4, 4 | full house | 17 |\n" "| 3, 3, 3, 3, 3 | Yahtzee | 50 |" ) - p = plan(parse("y.md", source), r) + p = plan(parse("y.md", source), r, empty_workspace()) named: list[Any] = [] class _S: @@ -317,7 +318,7 @@ def _handler(_ctx: Any, row: dict) -> dict: "| 3, 3, 3, 4, 4 | full house | 17 |\n" "| 3, 3, 3, 3, 3 | Yahtzee | 50 |" ) - p = plan(parse("y.md", source), r) + p = plan(parse("y.md", source), r, empty_workspace()) runs: list[Any] = [] class _S: @@ -358,7 +359,7 @@ def _handler(_ctx: Any, row: dict) -> dict: "| 3, 3, 3, 4, 4 | full house | 17 |\n" "| 3, 3, 3, 3, 3 | Yahtzee | 50 |" ) - p = plan(parse("y.md", source), r) + p = plan(parse("y.md", source), r, empty_workspace()) runs: list[Any] = [] class _S: @@ -402,7 +403,7 @@ def _handler(_ctx: Any, row: dict) -> dict: "| ------------- | ---------- | ----- |\n" "| 3, 3, 3, 4, 4 | full house | 17 |" ) - p = plan(parse("y.md", source), r) + p = plan(parse("y.md", source), r, empty_workspace()) run = _capture_run(p) run() # should not raise @@ -417,7 +418,7 @@ def _handler(_ctx: Any, row: dict) -> dict: def _runs_for(source: str, reg: Registry) -> list[Any]: - p = plan(parse("w.md", source), reg) + p = plan(parse("w.md", source), reg, empty_workspace()) runs: list[Any] = [] class _S: @@ -561,7 +562,7 @@ def test_execute_plan_passes_each_example_deduped_step_lines_via_info() -> None: handler=lambda *_: None, ) source = "# T\n\nI have 5 cukes.\nI eat 2 cukes.\n" - p = plan(parse("t.md", source), r) + p = plan(parse("t.md", source), r, empty_workspace()) seen: list[dict] = [] @@ -595,7 +596,7 @@ def _handler(_ctx: Any, _a: int, b: int) -> None: handler=_handler, ) src = "# D\n\nI divide 1 by 0.\n\n```error\ndivision by zero\n```\n" - run = _capture_run(plan(parse("e.md", src), r)) + run = _capture_run(plan(parse("e.md", src), r, empty_workspace())) run() # should not raise @@ -610,7 +611,7 @@ def test_expected_failure_no_throw_makes_run_reject_with_unexpected_pass_error() handler=lambda *_: None, ) src = "# D\n\nI divide 1 by 1.\n\n```error\n```\n" - run = _capture_run(plan(parse("e.md", src), r)) + run = _capture_run(plan(parse("e.md", src), r, empty_workspace())) with pytest.raises(UnexpectedPassError): run() @@ -630,7 +631,7 @@ def _handler(*_: Any) -> None: handler=_handler, ) src = "# D\n\nI divide 1 by 0.\n\n```error\ndivision by zero\n```\n" - run = _capture_run(plan(parse("e.md", src), r)) + run = _capture_run(plan(parse("e.md", src), r, empty_workspace())) with pytest.raises(RuntimeError, match="boom"): run() @@ -651,7 +652,7 @@ class _Obs: def step(self, o: StepObservation) -> None: obs.append(o) - run = _capture_run(plan(parse("e.md", "# A\n\nI add 5."), r), _Obs()) + run = _capture_run(plan(parse("e.md", "# A\n\nI add 5."), r, empty_workspace()), _Obs()) run() assert len(obs) == 1 assert obs[0] == StepObservation( @@ -683,7 +684,7 @@ class _Obs: def step(self, o: StepObservation) -> None: obs.append(o) - run = _capture_run(plan(parse("e.md", "# A\n\nI blow up."), r), _Obs()) + run = _capture_run(plan(parse("e.md", "# A\n\nI blow up."), r, empty_workspace()), _Obs()) try: run() except Exception: @@ -708,7 +709,7 @@ def _run_capturing_error( """Run plan and return (ctx_seen_list, caught_error_holder).""" registry = register(create_registry()) doc = parse("x.md", source) - p = plan(doc, registry) + p = plan(doc, registry, empty_workspace()) caught: list[Any] = [None] class _S: @@ -909,7 +910,7 @@ def _run_one( """Run plan and return caught-error holder.""" registry = register(create_registry()) doc = parse("x.md", source) - p = plan(doc, registry) + p = plan(doc, registry, empty_workspace()) caught: list[Any] = [None] class _S: @@ -1244,7 +1245,7 @@ async def _async_sensor(state: Any, expected: int) -> int: kind="sensor", handler=_async_sensor, ) - p = plan(parse("a.md", "# Async\n\nset count to 5. count is 5.\n"), r) + p = plan(parse("a.md", "# Async\n\nset count to 5. count is 5.\n"), r, empty_workspace()) run = _capture_run(p) run() # must not raise assert seen == [5], "async sensor was not driven to completion or state was not merged" diff --git a/python/packages/core/tests/test_failure_step_span.py b/python/packages/core/tests/test_failure_step_span.py index 60d59273..4d2d628e 100644 --- a/python/packages/core/tests/test_failure_step_span.py +++ b/python/packages/core/tests/test_failure_step_span.py @@ -15,6 +15,7 @@ from varar_core.plan import plan from varar_core.registry import add_step, create_registry from varar_core.span import span_from_offsets +from varar_core.reference import empty_workspace SOURCE = "# L\n\nHe asks on June 10, and the library agrees.\n" STEP_TEXT = "the library agrees" @@ -42,7 +43,7 @@ def boom(*_: Any) -> None: kind="sensor", handler=boom, ) - p = plan(parse("l.md", SOURCE), r) + p = plan(parse("l.md", SOURCE), r, empty_workspace()) runs: list[Any] = [] diff --git a/python/packages/core/tests/test_plan.py b/python/packages/core/tests/test_plan.py index c17de3ac..f1c1df35 100644 --- a/python/packages/core/tests/test_plan.py +++ b/python/packages/core/tests/test_plan.py @@ -4,6 +4,7 @@ from varar_core.parse import parse from varar_core.plan import plan from varar_core.registry import add_step, create_registry +from varar_core.reference import empty_workspace def _noop(*_args: object, **_kwargs: object) -> None: @@ -21,7 +22,7 @@ def _reg(): def test_plan_produces_a_planned_example_with_steps_in_document_order() -> None: source = "# Withdrawing\n\nGiven I have 100 in my account. When I withdraw 40. Then I should have 60 left." doc = parse("w.md", source) - result = plan(doc, _reg()) + result = plan(doc, _reg(), empty_workspace()) assert result.diagnostics == () assert len(result.examples) == 1 ex = result.examples[0] @@ -40,7 +41,7 @@ def test_plan_emits_ambiguous_match_diagnostic_and_produces_no_example() -> None r = add_step(r, expression="I have {int} cukes", expression_source_file="a.ts", expression_source_line=3, handler=_noop, kind="stimulus") r = add_step(r, expression="I have {int} {word}", expression_source_file="a.ts", expression_source_line=8, handler=_noop, kind="stimulus") doc = parse("e.md", "# Ambig\n\nGiven I have 5 cukes") - result = plan(doc, r) + result = plan(doc, r, empty_workspace()) assert len(result.diagnostics) == 1 assert result.diagnostics[0].code == "ambiguous-match" # An ambiguous candidate has no runnable step, so it is prose (a delimiter), @@ -51,7 +52,7 @@ def test_plan_emits_ambiguous_match_diagnostic_and_produces_no_example() -> None def test_plan_skips_example_with_no_matches() -> None: source = "# Just docs\n\nSome prose with no matches and no keywords." doc = parse("d.md", source) - result = plan(doc, _reg()) + result = plan(doc, _reg(), empty_workspace()) assert result.examples == () assert result.diagnostics == () @@ -63,7 +64,7 @@ def test_plan_merges_consecutive_list_items_into_one_example() -> None: # Two list items, no delimiter between them → one example, shared state (ADR # 0012). A bulleted scenario reads as Given/When/Then bullets. source = "# Bullets\n\n- Given I have 100 in my account\n- When I withdraw 40" - result = plan(parse("b.md", source), r) + result = plan(parse("b.md", source), r, empty_workspace()) assert len(result.examples) == 1 assert [s.text for s in result.examples[0].steps] == [ "I have 100 in my account", @@ -75,7 +76,7 @@ def test_plan_walks_blockquote_content_as_step_bearing() -> None: r = create_registry() r = add_step(r, expression="I have {int} in my account", expression_source_file="s.ts", expression_source_line=1, handler=_noop, kind="stimulus") source = "# Quote\n\n> Given I have 100 in my account" - result = plan(parse("q.md", source), r) + result = plan(parse("q.md", source), r, empty_workspace()) assert len(result.examples[0].steps) == 1 @@ -83,7 +84,7 @@ def test_markdown_table_immediately_after_step_attaches_as_data_table() -> None: r = create_registry() r = add_step(r, expression="these users exist", expression_source_file="s.ts", expression_source_line=1, handler=_noop, kind="stimulus") source = "# Users\nGiven these users exist:\n\n| name | age |\n|------|-----|\n| Bob | 30 |\n| Eve | 25 |" - result = plan(parse("u.md", source), r) + result = plan(parse("u.md", source), r, empty_workspace()) step = result.examples[0].steps[0] assert step.data_table is not None assert step.data_table.header.cells == ("name", "age") @@ -94,7 +95,7 @@ def test_table_not_immediately_after_step_does_not_attach() -> None: r = create_registry() r = add_step(r, expression="these users exist", expression_source_file="s.ts", expression_source_line=1, handler=_noop, kind="stimulus") source = "# Mid\nGiven these users exist:\n\nSome interrupting prose.\n\n| name | age |\n|------|-----|\n| Bob | 30 |" - result = plan(parse("m.md", source), r) + result = plan(parse("m.md", source), r, empty_workspace()) step = result.examples[0].steps[0] assert step.data_table is None @@ -103,7 +104,7 @@ def test_fenced_code_block_immediately_after_step_attaches_as_doc_string() -> No r = create_registry() r = add_step(r, expression="I send the payload", expression_source_file="s.ts", expression_source_line=1, handler=_noop, kind="stimulus") source = "# Payload\nWhen I send the payload:\n\n```json\n{ \"action\": \"import\" }\n```" - result = plan(parse("p.md", source), r) + result = plan(parse("p.md", source), r, empty_workspace()) step = result.examples[0].steps[0] assert step.doc_string is not None assert step.doc_string.content_type == "json" @@ -113,21 +114,21 @@ def test_fenced_code_block_immediately_after_step_attaches_as_doc_string() -> No def test_step_with_no_following_fence_has_no_doc_string() -> None: r = create_registry() r = add_step(r, expression="I send the payload", expression_source_file="s.ts", expression_source_line=1, handler=_noop, kind="stimulus") - result = plan(parse("p.md", "# P\nWhen I send the payload"), r) + result = plan(parse("p.md", "# P\nWhen I send the payload"), r, empty_workspace()) assert result.examples[0].steps[0].doc_string is None def test_keyword_led_sentence_with_no_match_produces_no_diagnostic() -> None: r = create_registry() doc = parse("m.md", "# Empty\n\nGiven I have 5 cukes in my belly.") - result = plan(doc, r) + result = plan(doc, r, empty_workspace()) assert result.diagnostics == () def test_unmatched_sentence_without_keyword_is_silently_prose() -> None: r = create_registry() doc = parse("p.md", "# Prose\n\nI have 5 cukes in my belly.") - result = plan(doc, r) + result = plan(doc, r, empty_workspace()) assert result.diagnostics == () @@ -142,7 +143,7 @@ def test_header_bound_table_expands_into_one_example_per_row() -> None: "| 3, 3, 3, 4, 4 | full house | 17 |\n" "| 3, 3, 3, 3, 3 | Yahtzee | 50 |" ) - result = plan(parse("y.md", source), r) + result = plan(parse("y.md", source), r, empty_workspace()) assert result.diagnostics == () assert len(result.examples) == 2 first, second = result.examples @@ -161,7 +162,7 @@ def test_table_whose_paragraph_names_only_some_header_cells_keeps_whole_table_be "# Users\nthese users exist:\n\n" "| name | age |\n| ---- | --- |\n| Bob | 30 |\n| Eve | 25 |" ) - result = plan(parse("u.md", source), r) + result = plan(parse("u.md", source), r, empty_workspace()) assert len(result.examples) == 1 step = result.examples[0].steps[0] assert step.data_table is not None @@ -177,7 +178,7 @@ def test_header_bound_matching_is_case_sensitive() -> None: "# Case\neach row lists the Dice and the Score:\n\n" "| dice | score |\n| --------- | ----- |\n| 1,1,1,1,1 | 5 |" ) - result = plan(parse("c.md", source), r) + result = plan(parse("c.md", source), r, empty_workspace()) # No exact-case match → falls back to a single whole-table example. assert len(result.examples) == 1 assert result.examples[0].steps[0].data_table is not None @@ -195,7 +196,7 @@ def test_header_bound_rows_are_named_by_cells_and_nested_under_paragraph() -> No "| 3, 3, 3, 4, 4 | full house | 17 |\n" "| 3, 3, 3, 3, 3 | Yahtzee | 50 |" ) - result = plan(parse("y.md", source), r) + result = plan(parse("y.md", source), r, empty_workspace()) assert [e.name for e in result.examples] == [ "3, 3, 3, 4, 4 / full house / 17", "3, 3, 3, 3, 3 / Yahtzee / 50", @@ -216,7 +217,7 @@ def test_detached_table_produces_no_diagnostic() -> None: "Some interrupting prose paragraph.\n\n" "| name | age |\n|------|-----|\n| Bob | 30 |" ) - result = plan(parse("o.md", source), r) + result = plan(parse("o.md", source), r, empty_workspace()) assert result.diagnostics == () @@ -230,7 +231,7 @@ def test_header_bound_row_example_carries_row_checks() -> None: "| ------------- | ---------- | ----- |\n" "| 3, 3, 3, 4, 4 | full house | 17 |" ) - result = plan(parse("y.md", source), r) + result = plan(parse("y.md", source), r, empty_workspace()) checks = result.examples[0].row_checks assert checks is not None assert [c.column for c in checks] == ["dice", "category", "score"] @@ -243,7 +244,7 @@ def test_header_bound_row_example_carries_row_checks() -> None: def test_error_fence_marks_expected_outcome_fail_with_message() -> None: r = add_step(create_registry(), expression="I divide {int} by {int}", expression_source_file="s.ts", expression_source_line=1, handler=_noop, kind="stimulus") src = "# Division\n\nI divide 1 by 0.\n\n```error\ndivision by zero\n```\n" - ex = plan(parse("e.md", src), r).examples[0] + ex = plan(parse("e.md", src), r, empty_workspace()).examples[0] assert ex.expected_outcome == "fail" assert ex.expected_error_message == "division by zero" # The error fence must NOT become a docString attachment on the step. @@ -252,14 +253,14 @@ def test_error_fence_marks_expected_outcome_fail_with_message() -> None: def test_no_error_fence_leaves_expected_outcome_undefined() -> None: r = add_step(create_registry(), expression="I divide {int} by {int}", expression_source_file="s.ts", expression_source_line=1, handler=_noop, kind="stimulus") - ex = plan(parse("e.md", "# Division\n\nI divide 1 by 1."), r).examples[0] + ex = plan(parse("e.md", "# Division\n\nI divide 1 by 1."), r, empty_workspace()).examples[0] assert ex.expected_outcome is None def test_error_fence_with_no_matching_step_emits_error_fence_without_step() -> None: r = add_step(create_registry(), expression="I divide {int} by {int}", expression_source_file="s.ts", expression_source_line=1, handler=_noop, kind="stimulus") src = "# Nope\n\nThis prose matches nothing.\n\n```error\nboom\n```\n" - result = plan(parse("e.md", src), r) + result = plan(parse("e.md", src), r, empty_workspace()) assert result.examples == () assert len(result.diagnostics) == 1 assert result.diagnostics[0].code == "error-fence-without-step" @@ -270,7 +271,7 @@ def test_error_fence_on_ambiguous_example_emits_both_diagnostics() -> None: r = add_step(r, expression="I divide {int} by {int}", expression_source_file="s.ts", expression_source_line=1, handler=_noop, kind="stimulus") r = add_step(r, expression="I divide 1 by 0", expression_source_file="s.ts", expression_source_line=2, handler=_noop, kind="stimulus") src = "# Ambiguous\n\nI divide 1 by 0.\n\n```error\nboom\n```\n" - result = plan(parse("e.md", src), r) + result = plan(parse("e.md", src), r, empty_workspace()) codes = sorted(d.code for d in result.diagnostics) assert codes == ["ambiguous-match", "error-fence-without-step"] @@ -293,7 +294,7 @@ def test_header_binding_param_spans_point_at_header_cells_in_paragraph() -> None "| ------------- | ---------- | ----- |\n" "| 3, 3, 3, 4, 4 | full house | 17 |" ) - result = plan(parse("y.md", source), r) + result = plan(parse("y.md", source), r, empty_workspace()) assert len(result.examples) == 1 ex = result.examples[0] assert ex.header_binding is not None @@ -309,7 +310,7 @@ def test_header_binding_param_spans_point_at_header_cells_in_paragraph() -> None def test_doc_string_step_carries_fence_body_span() -> None: r = add_step(create_registry(), expression="the payload is", expression_source_file="s.ts", expression_source_line=1, handler=_noop, kind="stimulus") source = "# T\n\nthe payload is:\n\n```json\n{ \"ok\": true }\n```" - result = plan(parse("d.md", source), r) + result = plan(parse("d.md", source), r, empty_workspace()) ds = result.examples[0].steps[0].doc_string assert ds is not None assert ds.content == '{ "ok": true }\n' @@ -322,7 +323,7 @@ def test_doc_string_step_carries_fence_body_span() -> None: def test_consecutive_matching_paragraphs_with_no_delimiter_merge_into_one_example() -> None: source = "I have 100 in my account.\n\nI withdraw 40.\n\nI should have 60 left." - result = plan(parse("m.md", source), _reg()) + result = plan(parse("m.md", source), _reg(), empty_workspace()) assert len(result.examples) == 1 assert [s.text for s in result.examples[0].steps] == [ "I have 100 in my account", @@ -335,7 +336,7 @@ def test_consecutive_matching_paragraphs_with_no_delimiter_merge_into_one_exampl def test_thematic_break_between_matching_paragraphs_splits_them() -> None: source = "I have 100 in my account.\n\n---\n\nI withdraw 40." - result = plan(parse("h.md", source), _reg()) + result = plan(parse("h.md", source), _reg(), empty_workspace()) assert len(result.examples) == 2 assert [[s.text for s in e.steps] for e in result.examples] == [ ["I have 100 in my account"], @@ -345,14 +346,14 @@ def test_thematic_break_between_matching_paragraphs_splits_them() -> None: def test_heading_between_matching_paragraphs_splits_them() -> None: source = "I have 100 in my account.\n\n## Next\n\nI withdraw 40." - result = plan(parse("hd.md", source), _reg()) + result = plan(parse("hd.md", source), _reg(), empty_workspace()) assert len(result.examples) == 2 assert result.examples[1].scope_stack == ("Next",) def test_prose_paragraph_between_matching_paragraphs_splits_the_example() -> None: source = "I have 100 in my account.\n\nJust explaining what happens next.\n\nI withdraw 40." - result = plan(parse("p.md", source), _reg()) + result = plan(parse("p.md", source), _reg(), empty_workspace()) assert len(result.examples) == 2 assert [[s.text for s in e.steps] for e in result.examples] == [ ["I have 100 in my account"], @@ -362,7 +363,7 @@ def test_prose_paragraph_between_matching_paragraphs_splits_the_example() -> Non def test_leading_and_trailing_prose_does_not_merge_into_an_example() -> None: source = "A preamble that matches nothing.\n\nI withdraw 40.\n\nA closing remark." - result = plan(parse("pp.md", source), _reg()) + result = plan(parse("pp.md", source), _reg(), empty_workspace()) assert len(result.examples) == 1 assert [s.text for s in result.examples[0].steps] == ["I withdraw 40"] @@ -377,7 +378,7 @@ def test_multi_table_shape_two_tables_in_one_example_survive_blank_lines() -> No "And the following assets have been imported:\n\n" "| name |\n| ----- |\n| Moose |" ) - result = plan(parse("basket.md", source), r) + result = plan(parse("basket.md", source), r, empty_workspace()) assert len(result.examples) == 1 ex = result.examples[0] assert len(ex.steps) == 2 diff --git a/python/packages/pytest/src/varar_pytest/plugin.py b/python/packages/pytest/src/varar_pytest/plugin.py index 82b1d08a..00e11c43 100644 --- a/python/packages/pytest/src/varar_pytest/plugin.py +++ b/python/packages/pytest/src/varar_pytest/plugin.py @@ -12,6 +12,8 @@ from varar_core.failure import to_failure from varar_core.result import ExampleResult from varar_runner.baseline_store import create_file_baseline_store +from varar_core.parse import parse +from varar_core.reference import OathWorkspace, build_workspace from varar_runner.discovery import find_oaths, match_oath from varar_runner.results import ResultsCollector from varar_runner.run import RecordingReporter, examples_with_runs, plan_oath @@ -43,7 +45,14 @@ def pytest_configure(config: pytest.Config) -> None: wrapped_registry = wrap_registry_for_fixtures(loaded.registry, get_active_request) loaded = dataclasses.replace(loaded, registry=wrapped_registry) store = create_file_baseline_store(root) - _STASH[id(config)] = (cfg, loaded, root, store, ResultsCollector()) + oaths = find_oaths(cfg.docs_include, cfg.docs_exclude, root) + # Whether a section is a standalone example depends on whether another oath + # references it (ADR 0016) — whole-project knowledge, built here from the + # config globs for the same reason baseline pruning is: `pytest one_dir/` + # is a filtered view, and planning against it would run a consumed section + # as an example of its own. + workspace = _project_workspace(oaths, root) + _STASH[id(config)] = (cfg, loaded, root, store, ResultsCollector(), workspace) # Drop baselines for oaths the config no longer discovers. Reconciliation is # per-oath and never sees a path that has gone, so the lock would otherwise @@ -53,11 +62,23 @@ def pytest_configure(config: pytest.Config) -> None: # pruning against it would delete live baselines. prune_baselines( store, - [p.relative_to(root).as_posix() for p in find_oaths(cfg.docs_include, cfg.docs_exclude, root)], + [p.relative_to(root).as_posix() for p in oaths], update=_update_mode(config), ) +def _project_workspace(oaths, root: Path) -> OathWorkspace: + """Parse every discovered oath so references resolve and consumed sections + are recognised. Parsing runs no step code, so this is cheap.""" + docs = [] + for path in oaths: + try: + docs.append(parse(_oath_path(path, root), path.read_text(encoding="utf-8"))) + except OSError: + continue + return build_workspace(docs) + + def pytest_sessionfinish(session: pytest.Session) -> None: """Persist every oath's results for the language server (ADR 0014). @@ -68,7 +89,7 @@ def pytest_sessionfinish(session: pytest.Session) -> None: stashed = _STASH.get(id(session.config)) if stashed is None: return - _cfg, _loaded, root, _store, results = stashed + _cfg, _loaded, root, _store, results, _workspace = stashed results.write_all(root) @@ -88,7 +109,7 @@ def _oath_path(path: Path, root: Path) -> str: def pytest_collect_file(file_path: Path, parent: pytest.Collector): if file_path.suffix != ".md": return None - cfg, _loaded, root, _store, _results = _STASH[id(parent.config)] + cfg, _loaded, root, _store, _results, _workspace = _STASH[id(parent.config)] if not match_oath(file_path, cfg.docs_include, cfg.docs_exclude, root): return None return OathFile.from_parent(parent, path=file_path) @@ -96,11 +117,11 @@ def pytest_collect_file(file_path: Path, parent: pytest.Collector): class OathFile(pytest.File): def collect(self): - _cfg, loaded, root, store, results = _STASH[id(self.config)] + _cfg, loaded, root, store, results, workspace = _STASH[id(self.config)] source = self.path.read_text(encoding="utf-8") # The oath's workspace-relative POSIX path, not its basename: doc.path # is an oath's identity in every port (ADR 0016). - execution_plan = plan_oath(_oath_path(self.path, root), source, loaded.registry) + execution_plan = plan_oath(_oath_path(self.path, root), source, loaded.registry, workspace) pairs = examples_with_runs(execution_plan, loaded.create_context, RecordingReporter()) seen: dict[str, int] = {} for example, run in pairs: diff --git a/python/packages/runner/src/varar_runner/run.py b/python/packages/runner/src/varar_runner/run.py index 75227ca4..2b0c8315 100644 --- a/python/packages/runner/src/varar_runner/run.py +++ b/python/packages/runner/src/varar_runner/run.py @@ -4,6 +4,7 @@ from varar_core.execute import CollectPorts, collect_examples from varar_core.parse import parse from varar_core.plan import ExecutionPlan, PlannedExample, plan +from varar_core.reference import OathWorkspace from varar_core.registry import Registry class RecordingReporter: @@ -12,8 +13,10 @@ def __init__(self) -> None: def diagnostic(self, d: Any) -> None: self.diagnostics.append(d) -def plan_oath(path: str, source: str, registry: Registry) -> ExecutionPlan: - return plan(parse(path, source), registry) +def plan_oath( + path: str, source: str, registry: Registry, workspace: OathWorkspace +) -> ExecutionPlan: + return plan(parse(path, source), registry, workspace) def examples_with_runs( execution_plan: ExecutionPlan, diff --git a/python/packages/runner/tests/test_run.py b/python/packages/runner/tests/test_run.py index 211a712f..0183c484 100644 --- a/python/packages/runner/tests/test_run.py +++ b/python/packages/runner/tests/test_run.py @@ -1,4 +1,5 @@ from varar_runner.steps import load_steps +from varar_core.reference import empty_workspace from varar_runner.run import plan_oath, examples_with_runs, RecordingReporter STEPS = ''' @@ -17,7 +18,7 @@ def _(state, total): def _runs(tmp_path, src): (tmp_path / "c.steps.py").write_text(STEPS, encoding="utf-8") loaded = load_steps(["**/*.steps.py"], tmp_path) - plan = plan_oath("c.md", src, loaded.registry) + plan = plan_oath("c.md", src, loaded.registry, empty_workspace()) return examples_with_runs(plan, loaded.create_context, RecordingReporter()) def test_passing_example_runs_clean(tmp_path): diff --git a/python/packages/unittest/src/varar_unittest/__init__.py b/python/packages/unittest/src/varar_unittest/__init__.py index 9726d366..d709987d 100644 --- a/python/packages/unittest/src/varar_unittest/__init__.py +++ b/python/packages/unittest/src/varar_unittest/__init__.py @@ -29,6 +29,8 @@ from varar_core.drift import prune_baselines, reconcile_drift from varar_core.execute import is_unexpected_pass_error from varar_core.failure import to_failure +from varar_core.parse import parse +from varar_core.reference import OathWorkspace, build_workspace from varar_core.result import ExampleResult from varar_runner.baseline_store import create_file_baseline_store from varar_runner.discovery import find_oaths @@ -75,11 +77,33 @@ def generate_tests(namespace: dict[str, Any], root: str | Path | None = None) -> results = ResultsCollector() atexit.register(results.write_all, root) + # Whether a section is a standalone example depends on whether another oath + # references it, which is whole-project knowledge (ADR 0016). Built here, + # from the config globs — the full set, never the filtered view a `-k` run + # would leave. + workspace = _project_workspace(oaths, root) + for oath_path in oaths: - cls = _oath_test_case(oath_path, root, loaded, module_name, store, results) + cls = _oath_test_case(oath_path, root, loaded, module_name, store, results, workspace) namespace[cls.__name__] = cls +def _rel_of(oath_path: Path, root: Path) -> str: + return Path(os.path.abspath(oath_path)).relative_to(root, walk_up=True).as_posix() + + +def _project_workspace(oaths: list[Path], root: Path) -> OathWorkspace: + """Parse every discovered oath so references resolve and consumed sections + are recognised. Parsing runs no step code, so this is cheap.""" + docs = [] + for path in oaths: + try: + docs.append(parse(_rel_of(path, root), path.read_text(encoding="utf-8"))) + except OSError: + continue + return build_workspace(docs) + + def _oath_test_case( oath_path: Path, root: Path, @@ -87,16 +111,17 @@ def _oath_test_case( module_name: str | None, store: Any, results: ResultsCollector, + workspace: OathWorkspace, ) -> type[unittest.TestCase]: """Build one TestCase subclass for *oath_path*, one method per example.""" # walk_up: an oath outside the config root (matched via a ../ glob) still # gets a stable relative label. - rel = Path(os.path.abspath(oath_path)).relative_to(root, walk_up=True).as_posix() + rel = _rel_of(oath_path, root) source = oath_path.read_text(encoding="utf-8") # `rel`, not the basename: doc.path is an oath's identity in every port, so # a relative reference resolves alike and two same-named oaths in different # directories stay distinct (ADR 0016). - execution_plan = plan_oath(rel, source, loaded.registry) + execution_plan = plan_oath(rel, source, loaded.registry, workspace) pairs = examples_with_runs(execution_plan, loaded.create_context, RecordingReporter()) methods: dict[str, Any] = {"__doc__": rel} diff --git a/python/packages/varar/tests/test_conformance.py b/python/packages/varar/tests/test_conformance.py index 75d4af38..ad7d6b4b 100644 --- a/python/packages/varar/tests/test_conformance.py +++ b/python/packages/varar/tests/test_conformance.py @@ -26,12 +26,24 @@ from varar.registry import _custom_parameter_types, _reset_builder, build_registry, context_factory from varar_core.parse import parse from varar_core.plan import plan as build_plan +from varar_core.reference import build_workspace # python/packages/varar/tests/ -> parents[4] = repo root BUNDLES_DIR = Path(__file__).resolve().parents[4] / "conformance" / "bundles" BUNDLES = sorted(p for p in BUNDLES_DIR.iterdir() if p.is_dir()) +def _workspace(bundle: Path): + """A bundle is one oath (example.md) plus, for a bundle that exercises + reference blocks (ADR 0016), the other oaths it links to — every other + ``.md`` in the bundle directory. They are parsed under their bare file + names, so ``./shared.md`` resolves the same way in every port.""" + docs = [ + parse(p.name, p.read_text(encoding="utf-8")) for p in sorted(bundle.glob("*.md")) + ] + return build_workspace(docs) + + def _golden(bundle: Path, name: str): """The golden's CONTENT. The comparison is deep equality of the parsed file, not of its bytes: the golden is one file generated by the TypeScript @@ -101,7 +113,7 @@ def test_plan_matches_golden(bundle: Path) -> None: registry = build_registry() source = (bundle / "example.md").read_text(encoding="utf-8") doc = parse("example.md", source) - execution = build_plan(doc, registry) + execution = build_plan(doc, registry, _workspace(bundle)) artifact = to_plan_artifact(execution) assert artifact == _golden(bundle, "plan.json"), f"plan.json mismatch for {bundle.name}" @@ -114,5 +126,7 @@ def test_trace_matches_golden(bundle: Path) -> None: create_ctx = context_factory() source = (bundle / "example.md").read_text(encoding="utf-8") doc = parse("example.md", source) - artifacts = run_conformance(doc, registry, create_ctx, tuple(_custom_parameter_types())) + artifacts = run_conformance( + doc, registry, create_ctx, tuple(_custom_parameter_types()), _workspace(bundle) + ) assert artifacts.trace == _golden(bundle, "trace.json"), f"trace.json mismatch for {bundle.name}" From f28cc7795249aa8945dee8b02e9a8b2879c2eeb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 11:05:41 +0100 Subject: [PATCH 10/21] feat(ruby): reuse setup between examples by linking to a section Ports reference blocks (ADR 0016) to Ruby: a block whose entire content is a Markdown link to an oath section splices that section's steps in at its own position, in the same file or across files, nesting to any depth with cycles reported rather than recursed into. Plan.plan takes the workspace as a required argument, and both adapters build it from the discovery pass they already run for baseline pruning. PlannedStep gains param_texts, sliced from the document the step was written in, and doc_path for a step spliced in from another oath. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- .../core/lib/varar/core/conformance.rb | 14 +- .../core/lib/varar/core/diagnostics.rb | 40 +++++ ruby/packages/core/lib/varar/core/plan.rb | 138 +++++++++++++++--- .../packages/core/lib/varar/core/reference.rb | 134 +++++++++++++++++ .../core/spec/varar/core/drift_spec.rb | 2 +- .../core/spec/varar/core/execute_spec.rb | 2 +- .../core/spec/varar/core/failure_spec.rb | 2 +- .../core/spec/varar/core/plan_spec.rb | 2 +- ruby/packages/minitest/lib/varar/minitest.rb | 20 ++- ruby/packages/rspec/lib/varar/rspec.rb | 20 ++- ruby/packages/runner/lib/varar/runner/run.rb | 4 +- .../packages/runner/spec/varar/runner_spec.rb | 6 +- .../spec/conformance/plan_conformance_spec.rb | 16 +- .../conformance/trace_conformance_spec.rb | 17 ++- 14 files changed, 371 insertions(+), 46 deletions(-) create mode 100644 ruby/packages/core/lib/varar/core/reference.rb diff --git a/ruby/packages/core/lib/varar/core/conformance.rb b/ruby/packages/core/lib/varar/core/conformance.rb index d076c48e..2b7bb946 100644 --- a/ruby/packages/core/lib/varar/core/conformance.rb +++ b/ruby/packages/core/lib/varar/core/conformance.rb @@ -2,6 +2,7 @@ require 'varar/core/ast' require 'varar/core/plan' +require 'varar/core/reference' require 'varar/core/execute' require 'varar/core/failure_anchor' @@ -158,20 +159,23 @@ def planned_example_hash(example, source) result end - def planned_step_hash(step, source) + def planned_step_hash(step, _source) step_names = parameter_type_names(step.step_def.compiled) result = { 'text' => step.text, 'matchSpan' => span_hash(step.match_span), 'paramSpans' => step.param_spans.map { |s| span_hash(s) }, 'matchedExpression' => step.step_def.expression, - 'args' => step.param_spans.each_with_index.map do |s, i| + 'args' => step.param_texts.each_with_index.map do |value, i| { - 'value' => Offsets.utf16_slice(source, s.start_offset, s.end_offset), + 'value' => value, 'parameterType' => i < step_names.length ? step_names[i] : nil } end } + # Present only on a step a reference block spliced in from another oath + # (ADR 0016): the document its spans belong to. + result['docPath'] = step.doc_path if step.doc_path result['dataTable'] = block_hash(step.data_table) if step.data_table result['docString'] = doc_string_hash(step.doc_string) if step.doc_string result @@ -206,8 +210,8 @@ def to_failure_artifact(error, match_span) end # Run all examples and return the four-artifact bundle. Port of runConformance. - def run_conformance(doc, registry, create_context, parameter_types = []) - execution = Plan.plan(doc, registry) + def run_conformance(doc, registry, create_context, parameter_types = [], workspace = nil) + execution = Plan.plan(doc, registry, workspace || Reference.empty_workspace) observed = Hash.new { |h, k| h[k] = [] } observer = ->(o) { observed[o.example_index] << o } queue = Execute.collect_examples(execution, create_context: create_context, observer: observer) diff --git a/ruby/packages/core/lib/varar/core/diagnostics.rb b/ruby/packages/core/lib/varar/core/diagnostics.rb index 5235917b..a45c795f 100644 --- a/ruby/packages/core/lib/varar/core/diagnostics.rb +++ b/ruby/packages/core/lib/varar/core/diagnostics.rb @@ -43,6 +43,46 @@ def error_fence_without_step(span) span: span ) end + + # A reference block (ADR 0016) points at an oath the workspace does not + # hold. Never prose: a link-only block that resolves to nothing has no + # other reading, so it fails the run rather than degrading silently. + def reference_not_found(text, path, span) + Diagnostic.new( + severity: 'error', + code: 'reference-not-found', + message: %(Reference to "#{text}" points at "#{path}", which is not an oath in this ) + + "workspace.\nCheck the path, and that the file is matched by the `docs` globs " \ + 'in varar.config.json.', + span: span + ) + end + + # The referenced document exists but the section contributes no steps — a + # mistyped anchor, or a section that is pure prose. + def reference_empty(text, path, slug, span) + where = slug.empty? ? path : "#{path}##{slug}" + Diagnostic.new( + severity: 'error', + code: 'reference-empty', + message: %(Reference to "#{text}" resolves to "#{where}", which contributes no steps.\n) + + 'Check the heading the anchor names, and that its section contains a matching ' \ + 'paragraph.', + span: span + ) + end + + # References may nest to any depth (depth is a style question, not a + # rule), so a chain that reaches a section already on it must be reported + # rather than recursed into. + def reference_cycle(chain, span) + Diagnostic.new( + severity: 'error', + code: 'reference-cycle', + message: "Reference cycle: #{chain.join(' → ')}.", + span: span + ) + end end end end diff --git a/ruby/packages/core/lib/varar/core/plan.rb b/ruby/packages/core/lib/varar/core/plan.rb index 59c5ba3a..aa54c79a 100644 --- a/ruby/packages/core/lib/varar/core/plan.rb +++ b/ruby/packages/core/lib/varar/core/plan.rb @@ -5,16 +5,22 @@ require 'varar/core/cell_diff' require 'varar/core/diagnostics' require 'varar/core/matcher' +require 'varar/core/reference' require 'varar/core/sentences' module Varar module Core DocString = Data.define(:content, :content_type, :span) - PlannedStep = Data.define(:text, :match_span, :param_spans, :step_def, :args, :formats, :data_table, - :doc_string) do - def initialize(text:, match_span:, param_spans:, step_def:, args:, formats: [], data_table: nil, - doc_string: nil) + # param_texts: the notation each parameter matched, sliced at plan time from + # the document the step was WRITTEN in — consumers must use it rather than + # slicing the running oath's source, because a step a reference block + # spliced in (ADR 0016) has spans in a different document. + # doc_path: set only on such a spliced step — the document its spans belong to. + PlannedStep = Data.define(:text, :match_span, :param_spans, :param_texts, :step_def, :args, :formats, + :data_table, :doc_string, :doc_path) do + def initialize(text:, match_span:, param_spans:, step_def:, args:, param_texts: [], formats: [], + data_table: nil, doc_string: nil, doc_path: nil) super end end @@ -42,21 +48,39 @@ module Plan # header-bound table (standalone rows) or a step-bearing candidate the # grouping pass may merge into an open example. HeaderBoundUnit = Data.define(:rows) + # A reference block: its whole text is a link to an oath section, whose + # steps are spliced in here (ADR 0016). Never prose, so it does not close + # the open example. + ReferenceUnit = Data.define(:reference, :preceded_by_delimiter, :span) StepsUnit = Data.define(:matched, :preceded_by_delimiter, :name, :scope_stack, :span, :steps, :expected_outcome, :expected_error_message) # An open, merging example being built up across adjacent matching # candidates in Phase 2. + # name_from_reference: true while the name came from a spliced + # (referenced) paragraph and is waiting to be replaced by the example's + # own first matching paragraph. MergedExample = Struct.new(:name, :scope_stack, :start_offset, :end_offset, :steps, - :expected_outcome, :expected_error_message) + :expected_outcome, :expected_error_message, :name_from_reference) module_function - def plan(doc, registry) + def plan(doc, registry, workspace) diagnostics = [] + # A section another oath references stops being a standalone example: + # it runs where it is referenced, not here (ADR 0016). + whole_file = Reference.section_key(doc.path, '') + consumed = lambda do |ex| + workspace.referenced.include?(whole_file) || + ex.scope_stack.any? do |h| + workspace.referenced.include?(Reference.section_key(doc.path, Reference.slugify(h))) + end + end + # Phase 1: plan each candidate paragraph independently into a "unit". - units = doc.examples.map { |ex| plan_candidate(ex, doc, registry, diagnostics) } + units = doc.examples.reject { |ex| consumed.call(ex) } + .map { |ex| plan_candidate(ex, doc, registry, diagnostics) } # Phase 2: group adjacent candidates into examples. A matching candidate # continues the open example when no delimiter (heading / `---`) precedes @@ -75,6 +99,24 @@ def plan(doc, registry) examples.concat(unit.rows) next end + if unit.is_a?(ReferenceUnit) + # Splice the referenced section's steps in at this position. Only + # the reference block itself is subject to the delimiter rule; + # everything it splices in belongs to the same sequence, so a + # section of several paragraphs stays one example. + resolve_reference(unit, doc, registry, workspace, diagnostics, []).each_with_index do |spliced, i| + if open && (i.positive? || !unit.preceded_by_delimiter) + merge_into(open, spliced, from_reference: true) + else + flush.call + open = start_merged(spliced) + # An example that OPENS with a reference is named by its own + # first matching paragraph, not by the section it pulls in. + open.name_from_reference = true + end + end + next + end unless unit.matched # Prose paragraph — a delimiter. Drop it and end the open example. flush.call @@ -92,12 +134,63 @@ def plan(doc, registry) ExecutionPlan.new(doc: doc, examples: examples, diagnostics: diagnostics) end + # Resolve one reference block into the step-bearing units of the section + # it names, recursively: a referenced section may itself contain + # reference blocks, to any depth (ADR 0016 leaves depth to the author's + # judgement). `chain` carries the sections currently being resolved so a + # repeat is reported as a cycle instead of recursing forever. + def resolve_reference(unit, from_doc, registry, workspace, diagnostics, chain) + ref = unit.reference + key = Reference.section_key(ref.path, ref.slug) + if chain.include?(key) + diagnostics << Diagnostics.reference_cycle(chain + [key], unit.span) + return [] + end + # A same-file reference resolves against the document being planned, + # which is not necessarily in the workspace. + target = ref.path == from_doc.path ? from_doc : workspace.docs[ref.path] + if target.nil? + diagnostics << Diagnostics.reference_not_found(ref.text, ref.path, unit.span) + return [] + end + out = [] + Reference.section_candidates(target, ref.slug).each do |candidate| + planned = plan_candidate(candidate, target, registry, diagnostics) + if planned.is_a?(ReferenceUnit) + out.concat(resolve_reference(planned, target, registry, workspace, diagnostics, chain + [key])) + next + end + # A header-bound table produces one example per row, which a spliced + # step list cannot express; an `error` fence declares an outcome for + # an example, not for a reusable fragment. Both are left out. + next unless planned.is_a?(StepsUnit) && planned.matched + + out << tag_with_doc(planned, target.path, from_doc.path) + end + diagnostics << Diagnostics.reference_empty(ref.text, ref.path, ref.slug, unit.span) if out.empty? + out + end + + # Carry the source document's identity on every spliced step, so a failure + # in a referenced section reports spans against the file they were written + # in rather than the file being run. + def tag_with_doc(unit, doc_path, host_path) + return unit if doc_path == host_path + + unit.with(steps: unit.steps.map { |step| step.with(doc_path: doc_path) }) + end + def start_merged(unit) MergedExample.new(unit.name, unit.scope_stack, unit.span.start_offset, unit.span.end_offset, - unit.steps.dup, unit.expected_outcome, unit.expected_error_message) + unit.steps.dup, unit.expected_outcome, unit.expected_error_message, false) end - def merge_into(open, unit) + def merge_into(open, unit, from_reference: false) + if open.name_from_reference && !from_reference + open.name = unit.name + open.scope_stack = unit.scope_stack + open.name_from_reference = false + end open.end_offset = unit.span.end_offset open.steps.concat(unit.steps) # Any error fence in a merged part marks the whole example @@ -125,6 +218,17 @@ def finish_merged(open, source) # Plan a single candidate paragraph (plus attached tables/fences) in # isolation. Emits ambiguity / error-fence diagnostics into +diagnostics+. def plan_candidate(ex, doc, registry, diagnostics) + # A block whose whole text is a link to an oath section is a reference, + # not content: never matched against step definitions, never prose. + primary = ex.body.first + if primary.respond_to?(:text) + ref = Reference.reference_of(primary.text, doc.path) + if ref + return ReferenceUnit.new(reference: ref, preceded_by_delimiter: ex.preceded_by_delimiter, + span: ex.span) + end + end + had_ambiguous = false steps_by_block = {} @@ -161,6 +265,7 @@ def plan_candidate(ex, doc, registry, diagnostics) text: Offsets.utf16_slice(block.text, hit.match_start, hit.match_end), match_span: lift_span(doc.source, block, hit.match_start, hit.match_end), param_spans: hit.param_spans.map { |p| lift_span(doc.source, block, p.start, p.end) }, + param_texts: hit.param_spans.map { |p| Offsets.utf16_slice(block.text, p.start, p.end) }, step_def: hit.step_def, args: hit.args, formats: hit.formats @@ -182,14 +287,7 @@ def plan_candidate(ex, doc, registry, diagnostics) table.header.cells.each_with_index do |cell_name, i| row_object[cell_name] = i < row.cells.length ? row.cells[i] : '' end - row_step = PlannedStep.new( - text: binding_step.text, - match_span: row.span, - param_spans: binding_step.param_spans, - step_def: binding_step.step_def, - args: binding_step.args + [row_object], - formats: binding_step.formats - ) + row_step = binding_step.with(match_span: row.span, args: binding_step.args + [row_object]) row_checks = table.header.cells.each_with_index.map do |cell_name, i| RowCheck.new( column: cell_name, @@ -236,11 +334,7 @@ def plan_candidate(ex, doc, registry, diagnostics) block_steps.each_with_index do |step, s_idx| if s_idx == block_steps.length - 1 && attach data_table, doc_string = attach - final_steps << PlannedStep.new( - text: step.text, match_span: step.match_span, param_spans: step.param_spans, - step_def: step.step_def, args: step.args, formats: step.formats, - data_table: data_table, doc_string: doc_string - ) + final_steps << step.with(data_table: data_table, doc_string: doc_string) else final_steps << step end diff --git a/ruby/packages/core/lib/varar/core/reference.rb b/ruby/packages/core/lib/varar/core/reference.rb new file mode 100644 index 00000000..16bfceeb --- /dev/null +++ b/ruby/packages/core/lib/varar/core/reference.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'varar/core/ast' + +module Varar + module Core + # Reuse is a link (ADR 0016). A candidate block whose entire content is a + # single Markdown link to an oath section is a REFERENCE BLOCK: it splices + # that section's steps in at its own position instead of being prose. + # + # Everything here is pure text and path arithmetic — no filesystem. The + # shell reads the documents; `references` tells it which ones to read, and + # `build_workspace` turns the collection into what `plan` needs. + module Reference + # The referenced oath's path (resolved against the referring doc's own + # path), the GFM slug of the heading, and the link's visible text. + Ref = Data.define(:path, :slug, :text) + + # What `plan` needs to resolve references: every oath by path, plus which + # sections a reference block consumes somewhere in the project. A section + # that is referenced stops being a standalone example, so this is + # whole-project knowledge — see ADR 0016 on why each runner builds it at + # its once-per-run discovery pass. + Workspace = Data.define(:docs, :referenced) + + # A candidate is a reference block iff its whole text is one Markdown link + # whose target is oath-shaped. Anything else — a link with surrounding + # words, a link to https://…, to a .rb file, to a mailto: — is ordinary + # content, so existing documents keep their meaning. + LINK_ONLY = /\A\[([^\]]*)\]\(\s*([^\s)]+)\s*\)\z/ + PROTOCOL = /\A[a-z][a-z0-9+.-]*:/i + + module_function + + def reference_of(text, from_path) + m = LINK_ONLY.match(text.strip) + return nil if m.nil? + + link_text = m[1] + target = m[2] + return Ref.new(path: from_path, slug: normalize_slug(target[1..]), text: link_text) if target.start_with?('#') + + hash_at = target.index('#') + file_part = hash_at.nil? ? target : target[0...hash_at] + fragment = hash_at.nil? ? '' : target[(hash_at + 1)..] + # Only a relative Markdown path is a reference. A protocol (https:, + # mailto:) or any other extension is left alone — remote references are + # deliberately out of scope (ADR 0016). + return nil unless file_part.end_with?('.md') + return nil if PROTOCOL.match?(file_part) || file_part.start_with?('/') + + Ref.new(path: join_posix(dirname_posix(from_path), file_part), slug: normalize_slug(fragment), + text: link_text) + end + + # GitHub's heading anchors: inline markup dropped, lowercased, spaces to + # hyphens, everything else that isn't a word character or hyphen removed. + # The same function produces the slug of a heading and normalizes the slug + # written in a link, so the two meet in the middle. + def slugify(heading_text) + stripped = heading_text.gsub(/`([^`]*)`/, '\1') + .gsub(/\*\*([^*]*)\*\*/, '\1') + .gsub(/\*([^*]*)\*/, '\1') + .gsub(/_([^_]*)_/, '\1') + normalize_slug(stripped) + end + + # One hyphen per space, not per run of them: GitHub leaves the gap where + # it dropped punctuation, so "Fees, VAT & rounding" slugs with a double + # hyphen. + def normalize_slug(str) + str.strip.downcase.gsub(/[^[[:word:]] -]/, '').tr(' ', '-') + end + + def dirname_posix(path) + i = path.rindex('/') + i.nil? ? '' : path[0...i] + end + + # POSIX path arithmetic on oath paths (always '/'-separated, relative to + # the workspace root). The core may not touch the filesystem. + def join_posix(dir, rel) + segments = dir.empty? ? [] : dir.split('/') + rel.split('/').each do |segment| + next if segment.empty? || segment == '.' + + segment == '..' ? segments.pop : segments << segment + end + segments.join('/') + end + + # Every reference block in a document, in document order. The shell uses + # this to walk the closure of documents it must read before planning. + def references(doc) + doc.examples.filter_map do |ex| + primary = ex.body.first + next nil unless primary.respond_to?(:text) + + reference_of(primary.text, doc.path) + end + end + + def section_key(path, slug) + "#{path}##{slug}" + end + + # The workspace with no references at all: what a caller planning a single + # document in isolation passes. + def empty_workspace + Workspace.new(docs: {}, referenced: Set.new) + end + + def build_workspace(docs) + by_path = docs.to_h { |doc| [doc.path, doc] } + referenced = Set.new + docs.each do |doc| + references(doc).each { |ref| referenced << section_key(ref.path, ref.slug) } + end + Workspace.new(docs: by_path, referenced: referenced) + end + + # The candidates that make up a section: those whose heading chain + # contains the slug. A whole-file reference ('' slug) is every candidate. + # Section membership follows the document outline exactly — a heading's + # section runs until the next heading of the same or higher level, which + # is precisely the range over which it stays on the scope stack. + def section_candidates(doc, slug) + return doc.examples if slug.empty? + + doc.examples.select { |ex| ex.scope_stack.any? { |h| slugify(h) == slug } } + end + end + end +end diff --git a/ruby/packages/core/spec/varar/core/drift_spec.rb b/ruby/packages/core/spec/varar/core/drift_spec.rb index 3969627e..a73de2dd 100644 --- a/ruby/packages/core/spec/varar/core/drift_spec.rb +++ b/ruby/packages/core/spec/varar/core/drift_spec.rb @@ -42,7 +42,7 @@ def roman_reg(with_step: true) def plan_for(source, registry) doc = Parse.parse('w.md', source) - [doc, Plan.plan(doc, registry)] + [doc, Plan.plan(doc, registry, Reference.empty_workspace)] end def bare(drifts) = drifts.map { |d| [d.name, d.line] } diff --git a/ruby/packages/core/spec/varar/core/execute_spec.rb b/ruby/packages/core/spec/varar/core/execute_spec.rb index 2ebf038b..0b15cfa6 100644 --- a/ruby/packages/core/spec/varar/core/execute_spec.rb +++ b/ruby/packages/core/spec/varar/core/execute_spec.rb @@ -14,7 +14,7 @@ module Core def run(source, register, create_context) registry = register.call(Registries.create_registry) doc = Parse.parse('example.md', source) - execution = Plan.plan(doc, registry) + execution = Plan.plan(doc, registry, Reference.empty_workspace) caught = nil queued = Execute.collect_examples(execution, create_context: create_context) queued.each do |q| diff --git a/ruby/packages/core/spec/varar/core/failure_spec.rb b/ruby/packages/core/spec/varar/core/failure_spec.rb index d23c5c76..476804cd 100644 --- a/ruby/packages/core/spec/varar/core/failure_spec.rb +++ b/ruby/packages/core/spec/varar/core/failure_spec.rb @@ -25,7 +25,7 @@ def run_failing_example handler: lambda { |_state| raise 'expected the library to refuse' }) - execution = Plan.plan(Parse.parse('l.md', source), registry) + execution = Plan.plan(Parse.parse('l.md', source), registry, Reference.empty_workspace) caught = nil Execute.collect_examples(execution, create_context: ->(_file) {}).each do |q| q.run.call diff --git a/ruby/packages/core/spec/varar/core/plan_spec.rb b/ruby/packages/core/spec/varar/core/plan_spec.rb index c4f33056..70f89318 100644 --- a/ruby/packages/core/spec/varar/core/plan_spec.rb +++ b/ruby/packages/core/spec/varar/core/plan_spec.rb @@ -24,7 +24,7 @@ def account_reg end def plan_source(source, registry) - described_class.plan(Parse.parse('m.md', source), registry) + described_class.plan(Parse.parse('m.md', source), registry, Reference.empty_workspace) end def step_texts(example) diff --git a/ruby/packages/minitest/lib/varar/minitest.rb b/ruby/packages/minitest/lib/varar/minitest.rb index ec117993..417dea62 100644 --- a/ruby/packages/minitest/lib/varar/minitest.rb +++ b/ruby/packages/minitest/lib/varar/minitest.rb @@ -38,19 +38,33 @@ def generate_tests(namespace = Object, root: nil) # everything. Core::Drifts.prune_baselines(store, oaths.map { |p| Runner.rel_posix(p, root) }, update: update) + workspace = project_workspace(oaths, root) + oaths.each do |oath_path| - klass = build_test_case(oath_path, root, loaded, store, update, results) + klass = build_test_case(oath_path, root, loaded, store, update, results, workspace) namespace.const_set("Var_#{identifier(Runner.rel_posix(oath_path, root))}", klass) end end - def build_test_case(oath_path, root, loaded, store, update, results) + # Whether a section is a standalone example depends on whether another oath + # references it, which is whole-project knowledge (ADR 0016). Built from the + # config globs — the full set, for the same reason baseline pruning is. + def project_workspace(oaths, root) + docs = oaths.filter_map do |path| + Core::Parse.parse(Runner.rel_posix(path, root), File.read(path, encoding: 'UTF-8')) + rescue SystemCallError + nil + end + Core::Reference.build_workspace(docs) + end + + def build_test_case(oath_path, root, loaded, store, update, results, workspace) rel = Runner.rel_posix(oath_path, root) source = File.read(oath_path, encoding: 'UTF-8') # `rel`, not the basename: doc.path is an oath's identity in every port, # so a relative reference resolves alike and two same-named oaths in # different directories stay distinct (ADR 0016). - plan = Runner.plan_oath(rel, source, loaded.registry) + plan = Runner.plan_oath(rel, source, loaded.registry, workspace) pairs = Runner.examples_with_runs(plan, loaded.create_context, Runner::RecordingReporter.new) klass = Class.new(::Minitest::Test) diff --git a/ruby/packages/rspec/lib/varar/rspec.rb b/ruby/packages/rspec/lib/varar/rspec.rb index 28615510..48aecb4b 100644 --- a/ruby/packages/rspec/lib/varar/rspec.rb +++ b/ruby/packages/rspec/lib/varar/rspec.rb @@ -36,18 +36,32 @@ def generate(root: nil) # everything. Core::Drifts.prune_baselines(store, oaths.map { |p| Runner.rel_posix(p, root) }, update: update) + workspace = project_workspace(oaths, root) + oaths.each do |oath_path| - define_group(oath_path, root, loaded, store, update, results) + define_group(oath_path, root, loaded, store, update, results, workspace) + end + end + + # Whether a section is a standalone example depends on whether another oath + # references it, which is whole-project knowledge (ADR 0016). Built from the + # config globs — the full set, for the same reason baseline pruning is. + def project_workspace(oaths, root) + docs = oaths.filter_map do |path| + Core::Parse.parse(Runner.rel_posix(path, root), File.read(path, encoding: 'UTF-8')) + rescue SystemCallError + nil end + Core::Reference.build_workspace(docs) end - def define_group(oath_path, root, loaded, store, update, results) + def define_group(oath_path, root, loaded, store, update, results, workspace) rel = Runner.rel_posix(oath_path, root) source = File.read(oath_path, encoding: 'UTF-8') # `rel`, not the basename: doc.path is an oath's identity in every port, # so a relative reference resolves alike and two same-named oaths in # different directories stay distinct (ADR 0016). - plan = Runner.plan_oath(rel, source, loaded.registry) + plan = Runner.plan_oath(rel, source, loaded.registry, workspace) pairs = Runner.examples_with_runs(plan, loaded.create_context, Runner::RecordingReporter.new) drifts = Core::Drifts.reconcile_drift(store, rel, source, plan.doc, plan, update: update) diff --git a/ruby/packages/runner/lib/varar/runner/run.rb b/ruby/packages/runner/lib/varar/runner/run.rb index 6db43469..14a8d39f 100644 --- a/ruby/packages/runner/lib/varar/runner/run.rb +++ b/ruby/packages/runner/lib/varar/runner/run.rb @@ -19,8 +19,8 @@ def diagnostic(diagnostic) module_function - def plan_oath(path, source, registry) - Core::Plan.plan(Core::Parse.parse(path, source), registry) + def plan_oath(path, source, registry, workspace) + Core::Plan.plan(Core::Parse.parse(path, source), registry, workspace) end # Pair each PlannedExample with its lazy run closure, in plan order. diff --git a/ruby/packages/runner/spec/varar/runner_spec.rb b/ruby/packages/runner/spec/varar/runner_spec.rb index c38b6b1b..a3a06c9d 100644 --- a/ruby/packages/runner/spec/varar/runner_spec.rb +++ b/ruby/packages/runner/spec/varar/runner_spec.rb @@ -43,8 +43,12 @@ def self.corpus_dir it "#{bundle} — runner outcomes agree with the trace goldens" do loaded = described_class.load_steps(['*.steps.rb'], bundle_dir) + docs = Dir.glob(File.join(bundle_dir, '*.md')).map do |path| + Core::Parse.parse(File.basename(path), File.read(path, encoding: 'UTF-8')) + end source = File.read(File.join(bundle_dir, 'example.md'), encoding: 'UTF-8') - plan = described_class.plan_oath('example.md', source, loaded.registry) + plan = described_class.plan_oath('example.md', source, loaded.registry, + Core::Reference.build_workspace(docs)) pairs = described_class.examples_with_runs(plan, loaded.create_context, Runner::RecordingReporter.new) actual = pairs.map do |example, run| diff --git a/ruby/packages/varar/spec/conformance/plan_conformance_spec.rb b/ruby/packages/varar/spec/conformance/plan_conformance_spec.rb index 63f468dc..4214d3e4 100644 --- a/ruby/packages/varar/spec/conformance/plan_conformance_spec.rb +++ b/ruby/packages/varar/spec/conformance/plan_conformance_spec.rb @@ -18,6 +18,16 @@ def self.corpus_dir corpus = corpus_dir + # A bundle is one oath (example.md) plus, for a bundle that exercises + # reference blocks (ADR 0016), the other oaths it links to — every other + # `.md` in the bundle directory. They are parsed under their bare file + # names, so `./shared.md` resolves the same way in every port. + def bundle_docs(dir) + Dir.glob(File.join(dir, '*.md')).map do |path| + Core::Parse.parse(File.basename(path), File.read(path, encoding: 'UTF-8')) + end + end + Dir.children(corpus).sort.each do |bundle| golden = File.join(corpus, bundle, 'golden', 'plan.json') steps_rb = Dir.glob(File.join(corpus, bundle, '*.steps.rb')).first @@ -29,9 +39,9 @@ def self.corpus_dir RegistryGlue.reset_builder load steps_rb registry = RegistryGlue.build_registry - source = File.read(File.join(corpus, bundle, 'example.md'), encoding: 'UTF-8') - doc = Core::Parse.parse('example.md', source) - plan = Core::Plan.plan(doc, registry) + docs = bundle_docs(File.join(corpus, bundle)) + doc = docs.find { |d| d.path == 'example.md' } + plan = Core::Plan.plan(doc, registry, Core::Reference.build_workspace(docs)) actual = Core::Conformance.to_plan_artifact(plan) expect(actual).to eq(JSON.parse(File.read(golden, encoding: 'UTF-8'))) end diff --git a/ruby/packages/varar/spec/conformance/trace_conformance_spec.rb b/ruby/packages/varar/spec/conformance/trace_conformance_spec.rb index 9eaf7c6f..68bb0171 100644 --- a/ruby/packages/varar/spec/conformance/trace_conformance_spec.rb +++ b/ruby/packages/varar/spec/conformance/trace_conformance_spec.rb @@ -18,6 +18,16 @@ def self.corpus_dir corpus = corpus_dir + # A bundle is one oath (example.md) plus, for a bundle that exercises + # reference blocks (ADR 0016), the other oaths it links to — every other + # `.md` in the bundle directory. They are parsed under their bare file + # names, so `./shared.md` resolves the same way in every port. + def bundle_docs(dir) + Dir.glob(File.join(dir, '*.md')).map do |path| + Core::Parse.parse(File.basename(path), File.read(path, encoding: 'UTF-8')) + end + end + Dir.children(corpus).sort.each do |bundle| golden = File.join(corpus, bundle, 'golden', 'trace.json') steps_rb = Dir.glob(File.join(corpus, bundle, '*.steps.rb')).first @@ -30,10 +40,11 @@ def self.corpus_dir load steps_rb registry = RegistryGlue.build_registry create_context = RegistryGlue.context_factory - source = File.read(File.join(corpus, bundle, 'example.md'), encoding: 'UTF-8') - doc = Core::Parse.parse('example.md', source) + docs = bundle_docs(File.join(corpus, bundle)) + doc = docs.find { |d| d.path == 'example.md' } artifacts = Core::Conformance.run_conformance( - doc, registry, create_context, RegistryGlue.custom_parameter_types + doc, registry, create_context, RegistryGlue.custom_parameter_types, + Core::Reference.build_workspace(docs) ) actual = artifacts[:trace] expect(actual).to eq(JSON.parse(File.read(golden, encoding: 'UTF-8'))) From 0a1b6f0d363128001888acc944c5c9f6742ae56b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 11:07:45 +0100 Subject: [PATCH 11/21] feat(go): reuse setup between examples by linking to a section Ports reference blocks (ADR 0016) to Go: a block whose entire content is a Markdown link to an oath section splices that section's steps in at its own position, in the same file or across files, nesting to any depth with cycles reported rather than recursed into. core.Plan takes the workspace as a required argument, and gotest builds it from the discovery pass it already runs for baseline pruning. PlannedStep gains ParamTexts, sliced from the document the step was written in, and DocPath for a step spliced in from another oath. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- go/conformance/b20/library.steps.go | 31 +++++ go/conformance/b21/library.steps.go | 31 +++++ go/conformance/conformance_test.go | 39 +++++- go/core/conformance.go | 24 +++- go/core/diagnostics.go | 24 ++++ go/core/drift_test.go | 16 +-- go/core/plan.go | 164 ++++++++++++++++++++-- go/core/plan_test.go | 16 +-- go/core/reference.go | 206 ++++++++++++++++++++++++++++ go/gotest/gotest.go | 25 +++- go/runner/run.go | 9 +- go/runner/runner_test.go | 2 +- go/varar/adapt_test.go | 8 +- 13 files changed, 550 insertions(+), 45 deletions(-) create mode 100644 go/conformance/b20/library.steps.go create mode 100644 go/conformance/b21/library.steps.go create mode 100644 go/core/reference.go diff --git a/go/conformance/b20/library.steps.go b/go/conformance/b20/library.steps.go new file mode 100644 index 00000000..fb6d212b --- /dev/null +++ b/go/conformance/b20/library.steps.go @@ -0,0 +1,31 @@ +// Go sibling of library.steps.ts (bundle 20-reference-splice). +package fixture + +import "github.com/varar-dev/varar/go/varar" + +func shelfOf(state varar.Value) int { + if m, ok := state.AsMap(); ok { + if c, ok := m["shelf"]; ok { + if n, ok := c.AsInt(); ok { + return int(n) + } + } + } + return 0 +} + +func Register(s *varar.Steps[varar.Value]) { + s.Stimulus("I shelve {int} books", func(state varar.Value, n int) (varar.Value, error) { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(int64(shelfOf(state) + n))}), nil + }) + s.Stimulus("I borrow a book", func(state varar.Value) (varar.Value, error) { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(int64(shelfOf(state) - 1))}), nil + }) + s.Sensor("The shelf holds {int} books", func(state varar.Value, expected int) (int, error) { + return shelfOf(state), nil + }) +} + +func State() varar.Value { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(0)}) +} diff --git a/go/conformance/b21/library.steps.go b/go/conformance/b21/library.steps.go new file mode 100644 index 00000000..85420eb4 --- /dev/null +++ b/go/conformance/b21/library.steps.go @@ -0,0 +1,31 @@ +// Go sibling of library.steps.ts (bundle 21-reference-consumed). +package fixture + +import "github.com/varar-dev/varar/go/varar" + +func shelfOf(state varar.Value) int { + if m, ok := state.AsMap(); ok { + if c, ok := m["shelf"]; ok { + if n, ok := c.AsInt(); ok { + return int(n) + } + } + } + return 0 +} + +func Register(s *varar.Steps[varar.Value]) { + s.Stimulus("I shelve {int} books", func(state varar.Value, n int) (varar.Value, error) { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(int64(shelfOf(state) + n))}), nil + }) + s.Stimulus("I borrow a book", func(state varar.Value) (varar.Value, error) { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(int64(shelfOf(state) - 1))}), nil + }) + s.Sensor("The shelf holds {int} books", func(state varar.Value, expected int) (int, error) { + return shelfOf(state), nil + }) +} + +func State() varar.Value { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(0)}) +} diff --git a/go/conformance/conformance_test.go b/go/conformance/conformance_test.go index 33c3f315..07fb96ec 100644 --- a/go/conformance/conformance_test.go +++ b/go/conformance/conformance_test.go @@ -36,6 +36,8 @@ import ( b17 "github.com/varar-dev/varar/go/conformance/b17" b18 "github.com/varar-dev/varar/go/conformance/b18" b19 "github.com/varar-dev/varar/go/conformance/b19" + b20 "github.com/varar-dev/varar/go/conformance/b20" + b21 "github.com/varar-dev/varar/go/conformance/b21" ) type fixture struct { @@ -63,6 +65,8 @@ var fixtures = map[string]fixture{ "17-unexpected-pass": {b17.Register, b17.State}, "18-multi-table-example": {b18.Register, b18.State}, "19-emphasis-parameter": {b19.Register, b19.State}, + "20-reference-splice": {b20.Register, b20.State}, + "21-reference-consumed": {b21.Register, b21.State}, } func bundlesDir() string { return filepath.Join("..", "..", "conformance", "bundles") } @@ -125,8 +129,8 @@ func TestPlanMatchesGolden(t *testing.T) { for _, name := range bundleNames(t) { t.Run(name, func(t *testing.T) { reg := registryFor(t, name) - doc := core.Parse("example.md", sourceOf(t, name)) - plan := core.Plan(doc, reg) + doc, workspace := bundleDocs(t, name) + plan := core.Plan(doc, reg, workspace) assertMatchesGolden(t, name, "plan.json", core.ToPlanArtifact(plan), golden(t, name, "plan.json")) }) } @@ -138,8 +142,8 @@ func TestTraceMatchesGolden(t *testing.T) { f := fixtures[name] s := varar.NewSteps[varar.Value]() f.register(s) - doc := core.Parse("example.md", sourceOf(t, name)) - artifacts := core.RunConformance(doc, s.Registry(), func() any { return f.state() }) + doc, workspace := bundleDocs(t, name) + artifacts := core.RunConformance(doc, s.Registry(), func() any { return f.state() }, workspace) assertMatchesGolden(t, name, "trace.json", artifacts.Trace, golden(t, name, "trace.json")) }) } @@ -157,3 +161,30 @@ func assertMatchesGolden(t *testing.T, bundle, artifactName string, actual core. t.Errorf("%s mismatch for %s\n--- got ---\n%#v\n--- want ---\n%#v", artifactName, bundle, actual, want) } } + +// bundleDocs returns a bundle's example.md plus the workspace of every other +// `.md` beside it — the oaths a bundle that exercises reference blocks (ADR +// 0016) links to. They are parsed under their bare file names, so `./shared.md` +// resolves the same way in every port. +func bundleDocs(t *testing.T, name string) (core.Doc, core.OathWorkspace) { + t.Helper() + paths, err := filepath.Glob(filepath.Join(bundlesDir(), name, "*.md")) + if err != nil { + t.Fatalf("glob %s: %v", name, err) + } + sort.Strings(paths) + docs := make([]core.Doc, 0, len(paths)) + var doc core.Doc + for _, path := range paths { + b, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("read %s: %v", path, readErr) + } + parsed := core.Parse(filepath.Base(path), string(b)) + docs = append(docs, parsed) + if filepath.Base(path) == "example.md" { + doc = parsed + } + } + return doc, core.BuildWorkspace(docs) +} diff --git a/go/core/conformance.go b/go/core/conformance.go index 7bb29104..427ac2a9 100644 --- a/go/core/conformance.go +++ b/go/core/conformance.go @@ -272,8 +272,11 @@ func fileStem(path string) string { // RunConformance runs one bundle end-to-end: plan, execute (recording // observations), and project all four wire artifacts. Port of runConformance. -func RunConformance(doc Doc, registry Registry, stateFactory func() any) BundleArtifacts { - execution := Plan(doc, registry) +// RunConformance runs every example and projects the four bundle artifacts. +// workspace carries the other oaths in the bundle, for one that exercises +// reference blocks (ADR 0016); a single-document bundle passes EmptyWorkspace(). +func RunConformance(doc Doc, registry Registry, stateFactory func() any, workspace OathWorkspace) BundleArtifacts { + execution := Plan(doc, registry, workspace) observed := map[int][]StepObservation{} ports := ExecutePorts{ @@ -426,8 +429,8 @@ func plannedExampleValue(source string, ex PlannedExample) Value { func plannedStepValue(source string, step PlannedStep) Value { paramNames := ParameterTypeNames(step.StepDef.Expression) - args := make([]Value, len(step.ParamSpans)) - for i, ps := range step.ParamSpans { + args := make([]Value, len(step.ParamTexts)) + for i, text := range step.ParamTexts { var paramType Value if i < len(paramNames) { paramType = StrValue(paramNames[i]) @@ -435,7 +438,7 @@ func plannedStepValue(source string, step PlannedStep) Value { paramType = NullValue } args[i] = obj( - kv("value", StrValue(utf16Slice(source, ps.StartOffset, ps.EndOffset))), + kv("value", StrValue(text)), kv("parameterType", paramType), ) } @@ -450,6 +453,11 @@ func plannedStepValue(source string, step PlannedStep) Value { "matchedExpression": StrValue(step.StepDef.Expression), "args": ListOf(args), } + // Present only on a step a reference block spliced in from another oath + // (ADR 0016): the document its spans belong to. + if step.DocPath != "" { + m["docPath"] = StrValue(step.DocPath) + } if step.DataTable != nil { m["dataTable"] = tableValue(*step.DataTable) } @@ -483,6 +491,12 @@ func diagnosticCodeString(code DiagnosticCode) string { return "error-fence-without-step" case CodeDrift: return "drift" + case CodeReferenceNotFound: + return "reference-not-found" + case CodeReferenceEmpty: + return "reference-empty" + case CodeReferenceCycle: + return "reference-cycle" } return "" } diff --git a/go/core/diagnostics.go b/go/core/diagnostics.go index e045209f..5742b267 100644 --- a/go/core/diagnostics.go +++ b/go/core/diagnostics.go @@ -20,6 +20,11 @@ const ( CodeAmbiguousMatch DiagnosticCode = iota CodeErrorFenceWithoutStep CodeDrift + // Reference blocks (ADR 0016): a link that resolves to no oath, to a + // section with no steps, or to a chain that reaches itself. + CodeReferenceNotFound + CodeReferenceEmpty + CodeReferenceCycle ) // Diagnostic is one diagnostic: its code, severity, and the source span it @@ -39,3 +44,22 @@ func ambiguousMatch(span Span) Diagnostic { func errorFenceWithoutStep(span Span) Diagnostic { return Diagnostic{Code: CodeErrorFenceWithoutStep, Severity: SeverityError, Span: span} } + +// referenceNotFound: a reference block points at an oath the workspace does not +// hold. Never prose — a link-only block that resolves to nothing has no other +// reading, so it fails the run rather than degrading silently (ADR 0016). +func referenceNotFound(text, path string, span Span) Diagnostic { + return Diagnostic{Code: CodeReferenceNotFound, Severity: SeverityError, Span: span} +} + +// referenceEmpty: the referenced document exists but the section contributes no +// steps — a mistyped anchor, or a section that is pure prose. +func referenceEmpty(text, path, slug string, span Span) Diagnostic { + return Diagnostic{Code: CodeReferenceEmpty, Severity: SeverityError, Span: span} +} + +// referenceCycle: references nest to any depth, so a chain that reaches a +// section already on it is reported rather than recursed into. +func referenceCycle(chain []string, span Span) Diagnostic { + return Diagnostic{Code: CodeReferenceCycle, Severity: SeverityError, Span: span} +} diff --git a/go/core/drift_test.go b/go/core/drift_test.go index 9fa60554..8715059b 100644 --- a/go/core/drift_test.go +++ b/go/core/drift_test.go @@ -38,7 +38,7 @@ func romanReg(withStep bool) Registry { } func planOf(source string, r Registry) ExecutionPlan { - return Plan(Parse("w.md", source), r) + return Plan(Parse("w.md", source), r, EmptyWorkspace()) } func bare(drifts []Drifted) []string { @@ -176,7 +176,7 @@ func depositWithdrawReg(t *testing.T, withDeposit bool) Registry { func TestMergedExampleParagraphsAreEachLive(t *testing.T) { source := "I deposit 100.\n\nI withdraw 40." doc := Parse("w.md", source) - plan := Plan(doc, depositWithdrawReg(t, true)) + plan := Plan(doc, depositWithdrawReg(t, true), EmptyWorkspace()) // One planned example (the two paragraphs merged), but two live entries. if len(plan.Examples) != 1 { t.Fatalf("expected 1 planned example, got %d", len(plan.Examples)) @@ -191,10 +191,10 @@ func TestMergedExampleParagraphsAreEachLive(t *testing.T) { func TestDeletingOneStepOfMergedExampleDriftsOnlyNowProseParagraph(t *testing.T) { source := "I deposit 100.\n\nI withdraw 40." doc := Parse("w.md", source) - baseline := DeriveOathBaseline(source, doc, Plan(doc, depositWithdrawReg(t, true))) + baseline := DeriveOathBaseline(source, doc, Plan(doc, depositWithdrawReg(t, true), EmptyWorkspace())) // The deposit step is gone: its paragraph becomes prose, splitting the // example. The withdraw paragraph is still live; the deposit one drifts. - got := bare(DetectDrift(&baseline, doc, Plan(doc, depositWithdrawReg(t, false)))) + got := bare(DetectDrift(&baseline, doc, Plan(doc, depositWithdrawReg(t, false), EmptyWorkspace()))) if !reflect.DeepEqual(got, []string{"I deposit 100@1"}) { t.Errorf("got %v", got) } @@ -204,7 +204,7 @@ const roman = "Each row gives a decimal and a roman number:\n\n| decimal | roman func TestHeaderBoundRecordsBindingOnce(t *testing.T) { doc := Parse("r.md", roman) - got := LiveExamples(doc, Plan(doc, romanReg(true))) + got := LiveExamples(doc, Plan(doc, romanReg(true), EmptyWorkspace())) want := []BaselineExample{{Name: "Each row gives a decimal and a roman number:", Line: 1}} if !reflect.DeepEqual(got, want) { t.Errorf("got %v, want %v", got, want) @@ -213,8 +213,8 @@ func TestHeaderBoundRecordsBindingOnce(t *testing.T) { func TestHeaderBoundBindingThatStopsMatchingDrifts(t *testing.T) { doc := Parse("r.md", roman) - baseline := DeriveOathBaseline(roman, doc, Plan(doc, romanReg(true))) - got := bare(DetectDrift(&baseline, doc, Plan(doc, romanReg(false)))) + baseline := DeriveOathBaseline(roman, doc, Plan(doc, romanReg(true), EmptyWorkspace())) + got := bare(DetectDrift(&baseline, doc, Plan(doc, romanReg(false), EmptyWorkspace()))) if !reflect.DeepEqual(got, []string{"Each row gives a decimal and a roman number:@1"}) { t.Errorf("got %v", got) } @@ -305,7 +305,7 @@ func TestDriftMessageNamesTheParagraph(t *testing.T) { func lockWithStalePath() string { source := "I withdraw 40." doc := Parse("w.md", source) - baseline := DeriveOathBaseline(source, doc, Plan(doc, reg(true))) + baseline := DeriveOathBaseline(source, doc, Plan(doc, reg(true), EmptyWorkspace())) return StringifyLockFile(LockFile{ Version: 2, Oaths: map[string]OathBaseline{"varar/w.md": baseline, "w.md": baseline}, diff --git a/go/core/plan.go b/go/core/plan.go index 0ca93a99..5a5cc120 100644 --- a/go/core/plan.go +++ b/go/core/plan.go @@ -44,11 +44,19 @@ type PlannedStep struct { Text string MatchSpan Span ParamSpans []Span - StepDef *StepRegistration - Args []Value - Formats []FormatFn - DataTable *Table - DocString *Fence + // ParamTexts is the notation each parameter matched, sliced at plan time + // from the document the step was WRITTEN in. Consumers must use it rather + // than slicing the running oath's source: a step a reference block spliced + // in (ADR 0016) has spans in a different document. + ParamTexts []string + // DocPath is set only on such a spliced step: the document its spans belong + // to. Empty means the example's own document. + DocPath string + StepDef *StepRegistration + Args []Value + Formats []FormatFn + DataTable *Table + DocString *Fence } var ( @@ -64,14 +72,31 @@ var ( // example; a matching candidate after a delimiter (or the first) starts a new // one; a non-matching candidate (prose) is a delimiter that closes the open // example and is dropped; a header-bound candidate stays standalone. -func Plan(doc Doc, registry Registry) ExecutionPlan { +func Plan(doc Doc, registry Registry, workspace OathWorkspace) ExecutionPlan { source := doc.Source diagnostics := []Diagnostic{} + // A section another oath references stops being a standalone example: it + // runs where it is referenced, not here (ADR 0016). + consumed := func(ex Example) bool { + if workspace.Referenced[SectionKey(doc.Path, "")] { + return true + } + for _, h := range ex.ScopeStack { + if workspace.Referenced[SectionKey(doc.Path, Slugify(h))] { + return true + } + } + return false + } + // Phase 1: plan each candidate paragraph independently. - units := make([]candidateUnit, len(doc.Examples)) - for i, ex := range doc.Examples { - units[i] = planCandidate(ex, doc, registry, &diagnostics) + units := make([]candidateUnit, 0, len(doc.Examples)) + for _, ex := range doc.Examples { + if consumed(ex) { + continue + } + units = append(units, planCandidate(ex, doc, registry, &diagnostics)) } // Phase 2: group adjacent candidates into examples. @@ -89,13 +114,32 @@ func Plan(doc Doc, registry Registry) ExecutionPlan { examples = append(examples, unit.rows...) continue } + if unit.reference != nil { + // Splice the referenced section's steps in at this position. Only + // the reference block itself is subject to the delimiter rule; + // everything it splices in belongs to the same sequence, so a + // section of several paragraphs stays one example. + for i, spliced := range resolveReference(unit, doc, registry, workspace, &diagnostics, nil) { + if open != nil && (i > 0 || !unit.precededByDelimiter) { + mergeInto(open, spliced, true) + } else { + flush() + open = startMerged(spliced) + // An example that OPENS with a reference is named by its + // own first matching paragraph, not by the section it + // pulls in. + open.nameFromReference = true + } + } + continue + } if !unit.matched { // Prose paragraph — a delimiter. Drop it and end the open example. flush() continue } if open != nil && !unit.precededByDelimiter { - mergeInto(open, unit) + mergeInto(open, unit, false) } else { flush() open = startMerged(unit) @@ -123,6 +167,10 @@ type mergedExample struct { steps []PlannedStep expectedOutcome *string expectedErrorMessage *string + // nameFromReference is true while the name came from a spliced (referenced) + // paragraph and is waiting to be replaced by the example's own first + // matching paragraph. + nameFromReference bool } // candidateUnit is one candidate paragraph, planned in isolation. When @@ -132,6 +180,11 @@ type candidateUnit struct { headerBound bool rows []PlannedExample + // reference is non-nil when the candidate's whole text is a link to an oath + // section, whose steps are spliced in here (ADR 0016). Never prose, so it + // does not close the open example. + reference *Reference + matched bool precededByDelimiter bool name string @@ -161,7 +214,12 @@ func startMerged(unit candidateUnit) *mergedExample { return m } -func mergeInto(open *mergedExample, unit candidateUnit) { +func mergeInto(open *mergedExample, unit candidateUnit, fromReference bool) { + if open.nameFromReference && !fromReference { + open.name = unit.name + open.scopeStack = unit.scopeStack + open.nameFromReference = false + } open.endOffset = unit.span.EndOffset open.steps = append(open.steps, unit.steps...) // Any error fence in a merged part marks the whole example expected-to-fail; @@ -189,7 +247,86 @@ func finishMerged(open *mergedExample, source string) PlannedExample { // planCandidate plans a single candidate paragraph (plus its attached // tables/fences) in isolation, appending any ambiguity / error-fence diagnostics. +// resolveReference resolves one reference block into the step-bearing units of +// the section it names, recursively: a referenced section may itself contain +// reference blocks, to any depth (ADR 0016 leaves depth to the author's +// judgement). chain carries the sections currently being resolved so a repeat is +// reported as a cycle instead of recursing forever. +func resolveReference( + unit candidateUnit, + from Doc, + registry Registry, + workspace OathWorkspace, + diagnostics *[]Diagnostic, + chain []string, +) []candidateUnit { + ref := unit.reference + key := SectionKey(ref.Path, ref.Slug) + for _, seen := range chain { + if seen == key { + *diagnostics = append(*diagnostics, referenceCycle(append(append([]string{}, chain...), key), unit.span)) + return nil + } + } + // A same-file reference resolves against the document being planned, which + // is not necessarily in the workspace. + target, ok := from, true + if ref.Path != from.Path { + target, ok = workspace.Docs[ref.Path] + } + if !ok { + *diagnostics = append(*diagnostics, referenceNotFound(ref.Text, ref.Path, unit.span)) + return nil + } + var out []candidateUnit + for _, candidate := range SectionCandidates(target, ref.Slug) { + planned := planCandidate(candidate, target, registry, diagnostics) + if planned.reference != nil { + out = append(out, resolveReference(planned, target, registry, workspace, diagnostics, append(chain, key))...) + continue + } + // A header-bound table produces one example per row, which a spliced + // step list cannot express; an `error` fence declares an outcome for an + // example, not for a reusable fragment. Both are left out. + if planned.headerBound || !planned.matched { + continue + } + out = append(out, tagWithDoc(planned, target.Path, from.Path)) + } + if len(out) == 0 { + *diagnostics = append(*diagnostics, referenceEmpty(ref.Text, ref.Path, ref.Slug, unit.span)) + } + return out +} + +// tagWithDoc carries the source document's identity on every spliced step, so a +// failure in a referenced section reports spans against the file they were +// written in rather than the file being run. +func tagWithDoc(unit candidateUnit, docPath, hostPath string) candidateUnit { + if docPath == hostPath { + return unit + } + steps := make([]PlannedStep, len(unit.steps)) + for i, step := range unit.steps { + step.DocPath = docPath + steps[i] = step + } + unit.steps = steps + return unit +} + func planCandidate(ex Example, doc Doc, registry Registry, diagnostics *[]Diagnostic) candidateUnit { + // A block whose whole text is a link to an oath section is a reference, not + // content: never matched against step definitions, and never prose. + if len(ex.Body) > 0 && isTextBearing(ex.Body[0]) { + if ref := ReferenceOf(textOf(ex.Body[0]), doc.Path); ref != nil { + return candidateUnit{ + reference: ref, + precededByDelimiter: ex.PrecededByDelimiter, + span: ex.Span, + } + } + } source := doc.Source hadAmbiguous := false body := ex.Body @@ -214,10 +351,15 @@ func planCandidate(ex Example, doc Doc, registry Registry, diagnostics *[]Diagno for i, p := range h.paramSpans { paramSpans[i] = liftSpan(source, block, p.start, p.end) } + paramTexts := make([]string, len(h.paramSpans)) + for i, p := range h.paramSpans { + paramTexts[i] = utf16Slice(text, p.start, p.end) + } blockSteps = append(blockSteps, PlannedStep{ Text: utf16Slice(text, h.matchStart, h.matchEnd), MatchSpan: liftSpan(source, block, h.matchStart, h.matchEnd), ParamSpans: paramSpans, + ParamTexts: paramTexts, StepDef: h.stepDef, Args: h.args, Formats: h.formats, diff --git a/go/core/plan_test.go b/go/core/plan_test.go index 50fd04ca..34f29b56 100644 --- a/go/core/plan_test.go +++ b/go/core/plan_test.go @@ -35,7 +35,7 @@ func stepTexts(ex PlannedExample) []string { func TestConsecutiveMatchingParagraphsMergeIntoOneExample(t *testing.T) { source := "I have 100 in my account.\n\nI withdraw 40.\n\nI should have 60 left." - result := Plan(Parse("m.md", source), bankReg(t)) + result := Plan(Parse("m.md", source), bankReg(t), EmptyWorkspace()) if len(result.Examples) != 1 { t.Fatalf("expected 1 example, got %d", len(result.Examples)) } @@ -52,7 +52,7 @@ func TestConsecutiveMatchingParagraphsMergeIntoOneExample(t *testing.T) { func TestThematicBreakSplitsMatchingParagraphs(t *testing.T) { source := "I have 100 in my account.\n\n---\n\nI withdraw 40." - result := Plan(Parse("h.md", source), bankReg(t)) + result := Plan(Parse("h.md", source), bankReg(t), EmptyWorkspace()) if len(result.Examples) != 2 { t.Fatalf("expected 2 examples, got %d", len(result.Examples)) } @@ -64,7 +64,7 @@ func TestThematicBreakSplitsMatchingParagraphs(t *testing.T) { func TestHeadingSplitsMatchingParagraphs(t *testing.T) { source := "I have 100 in my account.\n\n## Next\n\nI withdraw 40." - result := Plan(Parse("hd.md", source), bankReg(t)) + result := Plan(Parse("hd.md", source), bankReg(t), EmptyWorkspace()) if len(result.Examples) != 2 { t.Fatalf("expected 2 examples, got %d", len(result.Examples)) } @@ -75,7 +75,7 @@ func TestHeadingSplitsMatchingParagraphs(t *testing.T) { func TestProseBetweenMatchingParagraphsSplitsTheExample(t *testing.T) { source := "I have 100 in my account.\n\nJust explaining what happens next.\n\nI withdraw 40." - result := Plan(Parse("p.md", source), bankReg(t)) + result := Plan(Parse("p.md", source), bankReg(t), EmptyWorkspace()) if len(result.Examples) != 2 { t.Fatalf("expected 2 examples, got %d", len(result.Examples)) } @@ -87,7 +87,7 @@ func TestProseBetweenMatchingParagraphsSplitsTheExample(t *testing.T) { func TestLeadingAndTrailingProseDoesNotMerge(t *testing.T) { source := "A preamble that matches nothing.\n\nI withdraw 40.\n\nA closing remark." - result := Plan(Parse("pp.md", source), bankReg(t)) + result := Plan(Parse("pp.md", source), bankReg(t), EmptyWorkspace()) if len(result.Examples) != 1 { t.Fatalf("expected 1 example, got %d", len(result.Examples)) } @@ -107,7 +107,7 @@ func TestConsecutiveListItemsMergeIntoOneExample(t *testing.T) { } // Two list items, no delimiter between them → one example, shared state. source := "# Bullets\n\n- Given I have 100 in my account\n- When I withdraw 40" - result := Plan(Parse("b.md", source), r) + result := Plan(Parse("b.md", source), r, EmptyWorkspace()) if len(result.Examples) != 1 { t.Fatalf("expected 1 example, got %d", len(result.Examples)) } @@ -127,7 +127,7 @@ func TestAmbiguousMatchProducesNoRunnableExample(t *testing.T) { if err != nil { t.Fatal(err) } - result := Plan(Parse("a.md", "I have 42 cukes."), r2) + result := Plan(Parse("a.md", "I have 42 cukes."), r2, EmptyWorkspace()) if len(result.Diagnostics) != 1 { t.Fatalf("expected 1 diagnostic, got %d", len(result.Diagnostics)) } @@ -157,7 +157,7 @@ func TestMultiTableShapeSurvivesBlankLines(t *testing.T) { "| email | name |\n| ----- | ---- |\n| a@b.c | Ada |\n\n" + "And the following assets have been imported:\n\n" + "| name |\n| ----- |\n| Moose |" - result := Plan(Parse("basket.md", source), r) + result := Plan(Parse("basket.md", source), r, EmptyWorkspace()) if len(result.Examples) != 1 { t.Fatalf("expected 1 example, got %d", len(result.Examples)) } diff --git a/go/core/reference.go b/go/core/reference.go new file mode 100644 index 00000000..3719076b --- /dev/null +++ b/go/core/reference.go @@ -0,0 +1,206 @@ +package core + +import ( + "regexp" + "sort" + "strings" +) + +// Reuse is a link (ADR 0016). A candidate block whose entire content is a single +// Markdown link to an oath section is a REFERENCE BLOCK: it splices that +// section's steps in at its own position instead of being prose. +// +// Everything here is pure text and path arithmetic — no filesystem. The shell +// reads the documents; References tells it which ones to read, and +// BuildWorkspace turns the collection into what Plan needs. + +// Reference is one resolved reference block: the referenced oath's path +// (resolved against the referring doc's own path), the GFM slug of the heading +// (empty for a whole-file link), and the link's visible text. +type Reference struct { + Path string + Slug string + Text string +} + +// OathWorkspace is what Plan needs to resolve references: every oath by path, +// plus which sections a reference block consumes somewhere in the project. A +// section that is referenced stops being a standalone example, so this is +// whole-project knowledge — see ADR 0016 on why each runner builds it at its +// once-per-run discovery pass. +type OathWorkspace struct { + Docs map[string]Doc + Referenced map[string]bool +} + +// A candidate is a reference block iff its whole text is one Markdown link whose +// target is oath-shaped. Anything else — a link with surrounding words, a link +// to https://…, to a .go file, to a mailto: — is ordinary content, so existing +// documents keep their meaning. +var ( + linkOnlyRE = regexp.MustCompile(`^\[([^\]]*)\]\(\s*([^\s)]+)\s*\)$`) + protocolRE = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.\-]*:`) + notSlugRE = regexp.MustCompile(`[^\p{L}\p{N} _-]`) + codeSpanRE = regexp.MustCompile("`([^`]*)`") + strongRE = regexp.MustCompile(`\*\*([^*]*)\*\*`) + emphRE = regexp.MustCompile(`\*([^*]*)\*`) + underRE = regexp.MustCompile(`_([^_]*)_`) +) + +// ReferenceOf returns the reference a block's text spells, or nil when the text +// is ordinary content. +func ReferenceOf(text string, fromPath string) *Reference { + m := linkOnlyRE.FindStringSubmatch(strings.TrimSpace(text)) + if m == nil { + return nil + } + linkText, target := m[1], m[2] + if strings.HasPrefix(target, "#") { + return &Reference{Path: fromPath, Slug: normalizeSlug(target[1:]), Text: linkText} + } + filePart, fragment := target, "" + if i := strings.Index(target, "#"); i != -1 { + filePart, fragment = target[:i], target[i+1:] + } + // Only a relative Markdown path is a reference. A protocol (https:, mailto:) + // or any other extension is left alone — remote references are deliberately + // out of scope (ADR 0016). + if !strings.HasSuffix(filePart, ".md") || protocolRE.MatchString(filePart) { + return nil + } + if strings.HasPrefix(filePart, "/") { + return nil + } + return &Reference{ + Path: JoinPosix(dirnamePosix(fromPath), filePart), + Slug: normalizeSlug(fragment), + Text: linkText, + } +} + +// Slugify mirrors GitHub's heading anchors: inline markup dropped, lowercased, +// spaces to hyphens, everything else that isn't a word character or hyphen +// removed. The same function produces the slug of a heading and normalizes the +// slug written in a link, so the two meet in the middle. +func Slugify(headingText string) string { + s := codeSpanRE.ReplaceAllString(headingText, "$1") + s = strongRE.ReplaceAllString(s, "$1") + s = emphRE.ReplaceAllString(s, "$1") + s = underRE.ReplaceAllString(s, "$1") + return normalizeSlug(s) +} + +func normalizeSlug(s string) string { + // One hyphen per space, not per run of them: GitHub leaves the gap where it + // dropped punctuation, so "Fees, VAT & rounding" slugs with a double hyphen. + return strings.ReplaceAll(notSlugRE.ReplaceAllString(strings.ToLower(strings.TrimSpace(s)), ""), " ", "-") +} + +func dirnamePosix(path string) string { + i := strings.LastIndex(path, "/") + if i == -1 { + return "" + } + return path[:i] +} + +// JoinPosix is POSIX path arithmetic on oath paths (always '/'-separated, +// relative to the workspace root). The core may not touch the filesystem. +func JoinPosix(dir, rel string) string { + var segments []string + if dir != "" { + segments = strings.Split(dir, "/") + } + for _, segment := range strings.Split(rel, "/") { + switch segment { + case "", ".": + continue + case "..": + if len(segments) > 0 { + segments = segments[:len(segments)-1] + } + default: + segments = append(segments, segment) + } + } + return strings.Join(segments, "/") +} + +// References returns every reference block in a document, in document order. +// The shell uses it to walk the closure of documents it must read before +// planning. +func References(doc Doc) []Reference { + var out []Reference + for _, ex := range doc.Examples { + if len(ex.Body) == 0 { + continue + } + block := ex.Body[0] + if !isTextBearing(block) { + continue + } + text := textOf(block) + if ref := ReferenceOf(text, doc.Path); ref != nil { + out = append(out, *ref) + } + } + return out +} + +// SectionKey is a section's identity across the project. +func SectionKey(path, slug string) string { + return path + "#" + slug +} + +// EmptyWorkspace is the workspace with no references at all: what a caller +// planning a single document in isolation passes. +func EmptyWorkspace() OathWorkspace { + return OathWorkspace{Docs: map[string]Doc{}, Referenced: map[string]bool{}} +} + +// BuildWorkspace indexes every oath by path and records every consumed section. +func BuildWorkspace(docs []Doc) OathWorkspace { + ws := OathWorkspace{Docs: make(map[string]Doc, len(docs)), Referenced: map[string]bool{}} + for _, doc := range docs { + ws.Docs[doc.Path] = doc + } + for _, doc := range docs { + for _, ref := range References(doc) { + ws.Referenced[SectionKey(ref.Path, ref.Slug)] = true + } + } + return ws +} + +// ReferencedKeys returns the consumed-section keys, sorted — a stable view for +// adapters that hand the set on. +func ReferencedKeys(ws OathWorkspace) []string { + keys := make([]string, 0, len(ws.Referenced)) + for key := range ws.Referenced { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// SectionCandidates returns the candidates that make up a section: those whose +// heading chain contains the slug. A whole-file reference (empty slug) is every +// candidate in the document. Section membership follows the document outline +// exactly — a heading's section runs until the next heading of the same or +// higher level, which is precisely the range over which it stays on the scope +// stack. +func SectionCandidates(doc Doc, slug string) []Example { + if slug == "" { + return doc.Examples + } + var out []Example + for _, ex := range doc.Examples { + for _, h := range ex.ScopeStack { + if Slugify(h) == slug { + out = append(out, ex) + break + } + } + } + return out +} diff --git a/go/gotest/gotest.go b/go/gotest/gotest.go index 92710b2b..8802efba 100644 --- a/go/gotest/gotest.go +++ b/go/gotest/gotest.go @@ -72,6 +72,11 @@ func Collect(root string, build BuildRegistry, ctx ContextFactory, update bool) } core.PruneBaselines(runner.NewFileBaselineStore(root), keep, update) + // Whether a section is a standalone example depends on whether another oath + // references it, which is whole-project knowledge (ADR 0016). Built from the + // config globs — the full set, for the same reason baseline pruning is. + workspace := projectWorkspace(oaths, root) + for _, oathPath := range oaths { sourceBytes, _ := os.ReadFile(oathPath) source := string(sourceBytes) @@ -84,7 +89,7 @@ func Collect(root string, build BuildRegistry, ctx ContextFactory, update bool) // `rel` (workspace-relative, POSIX), not the basename: doc.path is an // oath's identity in every port, and a basename cannot tell two // same-named oaths apart or anchor a relative reference (ADR 0016). - plan := runner.PlanOath(rel, source, build()) + plan := runner.PlanOath(rel, source, build(), workspace) for i, display := range runner.ExampleNames(plan) { index := i src := source @@ -178,3 +183,21 @@ func isUpdate() bool { } return false } + +// projectWorkspace parses every discovered oath so references resolve and +// consumed sections are recognised. Parsing runs no step code, so this is cheap. +func projectWorkspace(oaths []string, root string) core.OathWorkspace { + docs := make([]core.Doc, 0, len(oaths)) + for _, oathPath := range oaths { + sourceBytes, err := os.ReadFile(oathPath) + if err != nil { + continue + } + rel, relErr := filepath.Rel(root, oathPath) + if relErr != nil { + rel = filepath.Base(oathPath) + } + docs = append(docs, core.Parse(filepath.ToSlash(rel), string(sourceBytes))) + } + return core.BuildWorkspace(docs) +} diff --git a/go/runner/run.go b/go/runner/run.go index 3ef42a1c..9d4c10cf 100644 --- a/go/runner/run.go +++ b/go/runner/run.go @@ -6,9 +6,12 @@ import ( "github.com/varar-dev/varar/go/core" ) -// PlanOath parses + plans one oath. -func PlanOath(name, source string, registry core.Registry) core.ExecutionPlan { - return core.Plan(core.Parse(name, source), registry) +// PlanOath plans one oath. workspace carries every other oath in the project +// plus the sections a reference block consumes (ADR 0016); it is required +// because an adapter that omitted it would run consumed sections as standalone +// examples, which is green and wrong. +func PlanOath(name, source string, registry core.Registry, workspace core.OathWorkspace) core.ExecutionPlan { + return core.Plan(core.Parse(name, source), registry, workspace) } // ExampleNames is the per-example display names: the innermost heading (or the diff --git a/go/runner/runner_test.go b/go/runner/runner_test.go index f7a5ab35..7083ccf7 100644 --- a/go/runner/runner_test.go +++ b/go/runner/runner_test.go @@ -79,7 +79,7 @@ func TestBaselineStoreRoundTripsAndReconcileWritesLock(t *testing.T) { } source := "# Hi\n\nI greet \"world\"." doc := core.Parse("hi.md", source) - execution := core.Plan(doc, registry) + execution := core.Plan(doc, registry, core.EmptyWorkspace()) drifts := core.ReconcileDrift(store, "hi.md", source, doc, execution, false) if len(drifts) != 0 { diff --git a/go/varar/adapt_test.go b/go/varar/adapt_test.go index 217dc3ab..d2b30b9e 100644 --- a/go/varar/adapt_test.go +++ b/go/varar/adapt_test.go @@ -92,7 +92,7 @@ func TestToGoConvertsSupportedTypes(t *testing.T) { func TestMismatchedSlotTypeFailsTheStep(t *testing.T) { s := NewSteps[Value]() s.Sensor("the result is {word}", func(state Value, n int) (int, error) { return n, nil }) - plan := core.Plan(core.Parse("t.md", "the result is IV."), s.Registry()) + plan := core.Plan(core.Parse("t.md", "the result is IV."), s.Registry(), core.EmptyWorkspace()) failure := core.ExecutePlan(plan, core.ExecutePorts{}) if failure == nil { t.Fatal("expected the step to fail reading a String slot as int") @@ -107,7 +107,7 @@ func TestMismatchedSlotTypeFailsTheStep(t *testing.T) { func TestSlotCountMismatchFailsTheStep(t *testing.T) { s := NewSteps[Value]() s.Sensor("I have {int} cukes", func(state Value, a, b int) (int, int, error) { return a, b, nil }) - plan := core.Plan(core.Parse("t.md", "I have 5 cukes."), s.Registry()) + plan := core.Plan(core.Parse("t.md", "I have 5 cukes."), s.Registry(), core.EmptyWorkspace()) failure := core.ExecutePlan(plan, core.ExecutePorts{}) if failure == nil { t.Fatal("expected the step to fail on the slot-count mismatch") @@ -242,7 +242,7 @@ func TestSlottedSensorReturningNothingFailsTheStep(t *testing.T) { s.Sensor("the name is {string}", func(state Value, args []Value) (*Value, error) { return nil, nil }) - plan := core.Plan(core.Parse("t.md", `the name is "Ada".`), s.Registry()) + plan := core.Plan(core.Parse("t.md", `the name is "Ada".`), s.Registry(), core.EmptyWorkspace()) failure := core.ExecutePlan(plan, core.ExecutePorts{}) if failure == nil { t.Fatal("expected the step to fail with a missing return") @@ -260,7 +260,7 @@ func TestHeaderBoundRowReturningNothingFailsTheStep(t *testing.T) { }) source := "I report the score and grade.\n\n" + "| score | grade |\n| ----- | ----- |\n| 10 | A |\n" - plan := core.Plan(core.Parse("t.md", source), s.Registry()) + plan := core.Plan(core.Parse("t.md", source), s.Registry(), core.EmptyWorkspace()) failure := core.ExecutePlan(plan, core.ExecutePorts{}) if failure == nil { t.Fatal("expected the row step to fail with a missing return") From d00fd6f67f7a99f197f3716838e571bc556b2e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 11:11:18 +0100 Subject: [PATCH 12/21] feat(rust): reuse setup between examples by linking to a section Ports reference blocks (ADR 0016) to Rust: a block whose entire content is a Markdown link to an oath section splices that section's steps in at its own position, in the same file or across files, nesting to any depth with cycles reported rather than recursed into. plan() takes the workspace as a required argument, and the cargo-test adapter builds it from the discovery pass it already runs for baseline pruning, sharing it across trials behind an Arc. PlannedStep gains param_texts, sliced from the document the step was written in, and doc_path for a step spliced in from another oath. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- examples/rust-cargotest/tests/unit.rs | 1 + rust/cargotest/src/lib.rs | 35 +++- rust/cargotest/tests/adapter.rs | 8 +- rust/core/src/conformance.rs | 23 ++- rust/core/src/diagnostics.rs | 36 +++++ rust/core/src/lib.rs | 1 + rust/core/src/plan.rs | 189 ++++++++++++++++++++-- rust/core/src/reference.rs | 185 +++++++++++++++++++++ rust/core/tests/drift_test.rs | 30 +++- rust/core/tests/execute_test.rs | 3 +- rust/core/tests/failure_step_span_test.rs | 3 +- rust/core/tests/plan_test.rs | 64 ++++---- rust/runner/src/run.rs | 15 +- rust/runner/tests/runner.rs | 3 +- rust/varar/tests/conformance.rs | 43 ++++- 15 files changed, 570 insertions(+), 69 deletions(-) create mode 100644 rust/core/src/reference.rs diff --git a/examples/rust-cargotest/tests/unit.rs b/examples/rust-cargotest/tests/unit.rs index d97ed485..8be034ce 100644 --- a/examples/rust-cargotest/tests/unit.rs +++ b/examples/rust-cargotest/tests/unit.rs @@ -45,6 +45,7 @@ fn a_mutated_expectation_fails_with_a_cell_mismatch() { build_registry, context_value, 0, + &varar_core::reference::empty_workspace(), ) .expect_err("expected a failure"); // Expected column is the source token as written (quotes included); actual diff --git a/rust/cargotest/src/lib.rs b/rust/cargotest/src/lib.rs index 17adc4c8..d0dd875c 100644 --- a/rust/cargotest/src/lib.rs +++ b/rust/cargotest/src/lib.rs @@ -29,6 +29,7 @@ use libtest_mimic::{Arguments, Failed, Trial}; use varar_core::drift::{self, prune_baselines, reconcile_drift}; use varar_core::failure::to_failure; use varar_core::parse::parse; +use varar_core::reference::{OathWorkspace, build_workspace}; use varar_core::registry::Registry; use varar_core::result::{ExampleResult, Status}; use varar_runner::{ @@ -49,8 +50,9 @@ pub fn run_one( build_registry: BuildRegistry, context: ContextFactory, index: usize, + workspace: &OathWorkspace, ) -> Result<(), String> { - run_one_failure(oath_file, source, build_registry, context, index) + run_one_failure(oath_file, source, build_registry, context, index, workspace) .map_err(|failure| render_failure(&failure, source, rel)) } @@ -63,9 +65,10 @@ fn run_one_failure( build_registry: BuildRegistry, context: ContextFactory, index: usize, + workspace: &OathWorkspace, ) -> Result<(), varar_core::error::StepFailure> { let registry = build_registry(); - let execution = plan_oath(oath_file, source, ®istry); + let execution = plan_oath(oath_file, source, ®istry, workspace); let context_factory = move |file: &str| context(file); run_example(&execution, &context_factory, index) } @@ -106,6 +109,11 @@ fn trials_recording( .collect(); prune_baselines(&mut FileBaselineStore::new(root), &keep, update); + // Whether a section is a standalone example depends on whether another oath + // references it, which is whole-project knowledge (ADR 0016). Built from the + // config globs — the full set, for the same reason baseline pruning is. + let workspace = Arc::new(project_workspace(&oaths, root)); + for oath_path in oaths { let source = std::fs::read_to_string(&oath_path).unwrap_or_default(); // An oath's identity is its workspace-relative POSIX path, not its @@ -119,7 +127,7 @@ fn trials_recording( .replace('\\', "/"); let registry = build_registry(); - let execution = plan_oath(&rel, &source, ®istry); + let execution = plan_oath(&rel, &source, ®istry, &workspace); for (index, display) in example_names(&execution).into_iter().enumerate() { let (sf, src, r) = (rel.clone(), source.clone(), rel.clone()); @@ -132,8 +140,9 @@ fn trials_recording( .collect(); lines.dedup(); let recorder = Arc::clone(results); + let ws = Arc::clone(&workspace); trials.push(Trial::test(format!("{rel}::{display}"), move || { - let outcome = run_one_failure(&sf, &src, build_registry, context, index); + let outcome = run_one_failure(&sf, &src, build_registry, context, index, &ws); let recorded = match &outcome { Ok(()) => ExampleResult { name: name.clone(), @@ -191,3 +200,21 @@ pub fn run(root: &Path, build_registry: BuildRegistry, context: ContextFactory) fn read_config(root: &Path) -> varar_config::Config { varar_config::read_config(root).unwrap_or_else(|e| panic!("{e}")) } + +/// Parse every discovered oath so references resolve and consumed sections are +/// recognised (ADR 0016). Parsing runs no step code, so this is cheap. +fn project_workspace(oaths: &[std::path::PathBuf], root: &Path) -> OathWorkspace { + let docs: Vec<_> = oaths + .iter() + .filter_map(|path| { + let source = std::fs::read_to_string(path).ok()?; + let rel = path + .strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + Some(parse(&rel, &source)) + }) + .collect(); + build_workspace(&docs) +} diff --git a/rust/cargotest/tests/adapter.rs b/rust/cargotest/tests/adapter.rs index ef966989..0cfac77a 100644 --- a/rust/cargotest/tests/adapter.rs +++ b/rust/cargotest/tests/adapter.rs @@ -5,6 +5,7 @@ use std::any::Any; use std::rc::Rc; use varar_cargotest::run_one; use varar_core::handler::Handler; +use varar_core::reference::empty_workspace; use varar_core::registry::{Registry, add_step, create_registry}; use varar_core::step_kind::StepKind; use varar_core::value::Value; @@ -28,13 +29,16 @@ fn context(_file: &str) -> Rc { #[test] fn a_matching_example_passes() { let source = "# Q\n\nthe answer is 42."; - assert!(run_one("q.md", source, "q.md", build_registry, context, 0).is_ok()); + assert!( + run_one("q.md", source, "q.md", build_registry, context, 0, &empty_workspace()).is_ok() + ); } #[test] fn a_mismatching_example_fails_with_a_rendered_message() { let source = "# Q\n\nthe answer is 41."; - let err = run_one("q.md", source, "q.md", build_registry, context, 0).unwrap_err(); + let err = run_one("q.md", source, "q.md", build_registry, context, 0, &empty_workspace()) + .unwrap_err(); assert!(err.contains("Cell mismatch"), "unexpected render: {err}"); assert!(err.contains("41") && err.contains("42")); } diff --git a/rust/core/src/conformance.rs b/rust/core/src/conformance.rs index 86449cd1..c5e48936 100644 --- a/rust/core/src/conformance.rs +++ b/rust/core/src/conformance.rs @@ -9,8 +9,8 @@ use crate::ast::{ use crate::diagnostics::{Diagnostic, DiagnosticCode, Severity}; use crate::error::{StepError, StepFailure}; use crate::execute::{ExecutePorts, StepObservation, StepOutcome, collect_examples}; -use crate::offsets::utf16_slice; use crate::plan::{ExecutionPlan, PlannedExample, PlannedStep, plan}; +use crate::reference::OathWorkspace; use crate::registry::Registry; use crate::span::Span; use crate::value::Value; @@ -270,15 +270,15 @@ fn planned_example(source: &str, ex: &PlannedExample) -> Value { obj(pairs) } -fn planned_step(source: &str, step: &PlannedStep) -> Value { +fn planned_step(_source: &str, step: &PlannedStep) -> Value { let param_names = parameter_type_names(&step.step_def.expression); let args: Vec = step - .param_spans + .param_texts .iter() .enumerate() - .map(|(i, ps)| { + .map(|(i, text)| { obj(vec![ - ("value", Value::from(utf16_slice(source, ps.start_offset, ps.end_offset))), + ("value", Value::from(text.as_str())), ( "parameterType", param_names @@ -296,6 +296,11 @@ fn planned_step(source: &str, step: &PlannedStep) -> Value { ("matchedExpression", Value::from(step.step_def.expression.as_str())), ("args", Value::List(args)), ]; + // Present only on a step a reference block spliced in from another oath + // (ADR 0016): the document its spans belong to. + if let Some(p) = &step.doc_path { + pairs.push(("docPath", Value::from(p.as_str()))); + } if let Some(t) = &step.data_table { pairs.push(("dataTable", table(t))); } @@ -326,6 +331,9 @@ fn diagnostic_code(code: DiagnosticCode) -> &'static str { DiagnosticCode::AmbiguousMatch => "ambiguous-match", DiagnosticCode::ErrorFenceWithoutStep => "error-fence-without-step", DiagnosticCode::Drift => "drift", + DiagnosticCode::ReferenceNotFound => "reference-not-found", + DiagnosticCode::ReferenceEmpty => "reference-empty", + DiagnosticCode::ReferenceCycle => "reference-cycle", } } @@ -410,8 +418,11 @@ pub fn run_conformance( doc: &Doc, registry: &Registry, context_factory: &dyn Fn() -> Rc, + // The other oaths in the bundle, for a bundle whose oath references them + // (ADR 0016). A single-document bundle passes `&empty_workspace()`. + workspace: &OathWorkspace, ) -> BundleArtifacts { - let execution = plan(doc, registry); + let execution = plan(doc, registry, workspace); let observed: Rc>>> = Rc::new(RefCell::new(HashMap::new())); diff --git a/rust/core/src/diagnostics.rs b/rust/core/src/diagnostics.rs index 1520c8fc..2964bb3e 100644 --- a/rust/core/src/diagnostics.rs +++ b/rust/core/src/diagnostics.rs @@ -18,6 +18,11 @@ pub enum DiagnosticCode { AmbiguousMatch, ErrorFenceWithoutStep, Drift, + /// Reference blocks (ADR 0016): a link that resolves to no oath, to a + /// section with no steps, or to a chain that reaches itself. + ReferenceNotFound, + ReferenceEmpty, + ReferenceCycle, } /// One diagnostic: its code, severity, and the source span it points at. @@ -45,3 +50,34 @@ pub fn error_fence_without_step(span: Span) -> Diagnostic { span, } } + +/// A reference block (ADR 0016) points at an oath the workspace does not hold. +/// Never prose: a link-only block that resolves to nothing has no other +/// reading, so it fails the run rather than degrading silently. +pub fn reference_not_found(span: Span) -> Diagnostic { + Diagnostic { + code: DiagnosticCode::ReferenceNotFound, + severity: Severity::Error, + span, + } +} + +/// The referenced document exists but the section contributes no steps — a +/// mistyped anchor, or a section that is pure prose. +pub fn reference_empty(span: Span) -> Diagnostic { + Diagnostic { + code: DiagnosticCode::ReferenceEmpty, + severity: Severity::Error, + span, + } +} + +/// References nest to any depth, so a chain that reaches a section already on +/// it is reported rather than recursed into. +pub fn reference_cycle(span: Span) -> Diagnostic { + Diagnostic { + code: DiagnosticCode::ReferenceCycle, + severity: Severity::Error, + span, + } +} diff --git a/rust/core/src/lib.rs b/rust/core/src/lib.rs index c4fe18b3..b3a0af63 100644 --- a/rust/core/src/lib.rs +++ b/rust/core/src/lib.rs @@ -40,6 +40,7 @@ pub mod offsets; pub mod param_diff; pub mod parse; pub mod plan; +pub mod reference; pub mod registry; pub mod result; pub mod scanner; diff --git a/rust/core/src/plan.rs b/rust/core/src/plan.rs index e0b09138..636a7edb 100644 --- a/rust/core/src/plan.rs +++ b/rust/core/src/plan.rs @@ -5,9 +5,15 @@ use crate::ast::{Block, Doc, Fence, Row, SegmentOffset, Table}; use crate::cell_diff::RowCheck; -use crate::diagnostics::{Diagnostic, ambiguous_match, error_fence_without_step}; +use crate::diagnostics::{ + Diagnostic, ambiguous_match, error_fence_without_step, reference_cycle, reference_empty, + reference_not_found, +}; use crate::matcher::{Hit, ParamSpan, ResolvedSteps, find_hits, resolve_hits}; use crate::offsets::{java_trim, utf16_len}; +use crate::reference::{ + OathWorkspace, Reference, reference_of, section_candidates, section_key, slugify, +}; use crate::registry::{FormatFn, Registry, StepRegistration}; use crate::sentences::split_sentences; use crate::span::Span; @@ -50,6 +56,13 @@ pub struct PlannedStep { pub text: String, pub match_span: Span, pub param_spans: Vec, + /// The notation each parameter matched, sliced at plan time from the + /// document the step was WRITTEN in. Consumers must use this rather than + /// slicing the running oath's source: a step a reference block spliced in + /// (ADR 0016) has spans in a different document. + pub param_texts: Vec, + /// Set only on such a spliced step: the document its spans belong to. + pub doc_path: Option, pub step_def: Rc, pub args: Vec, pub formats: Vec>, @@ -61,14 +74,27 @@ static WHITESPACE_RE: LazyLock = LazyLock::new(|| Regex::new(r"\s+").unwr static WORD_CHAR_RE: LazyLock = LazyLock::new(|| Regex::new(r"^[\p{L}\p{N}_]$").unwrap()); /// Plans `doc` against `registry`. Port of `plan()`. -pub fn plan(doc: &Doc, registry: &Registry) -> ExecutionPlan { +pub fn plan(doc: &Doc, registry: &Registry, workspace: &OathWorkspace) -> ExecutionPlan { let source = &doc.source; let mut diagnostics = Vec::new(); + // A section another oath references stops being a standalone example: it + // runs where it is referenced, not here (ADR 0016). + let whole_file = section_key(&doc.path, ""); + let consumed = |ex: &crate::ast::Example| { + workspace.referenced.contains(&whole_file) + || ex.scope_stack.iter().any(|h| { + workspace + .referenced + .contains(§ion_key(&doc.path, &slugify(h))) + }) + }; + // Phase 1: plan each candidate paragraph independently into a "unit". let units: Vec = doc .examples .iter() + .filter(|ex| !consumed(ex)) .map(|ex| plan_candidate(ex, doc, registry, &mut diagnostics)) .collect(); @@ -87,6 +113,33 @@ pub fn plan(doc: &Doc, registry: &Registry) -> ExecutionPlan { } examples.extend(rows); } + CandidateUnit::Reference(unit) => { + // Splice the referenced section's steps in at this position. + // Only the reference block itself is subject to the delimiter + // rule; everything it splices in belongs to the same sequence, + // so a section of several paragraphs stays one example. + let preceded = unit.preceded_by_delimiter; + let resolved = + resolve_reference(&unit, doc, registry, workspace, &mut diagnostics, &[]); + for (i, spliced) in resolved.into_iter().enumerate() { + let mergeable = open.is_some() && (i > 0 || !preceded); + if mergeable { + if let Some(m) = open.as_mut() { + merge_into(m, spliced, true); + } + } else { + if let Some(m) = open.take() { + examples.push(finish_merged(m, source)); + } + let mut fresh = start_merged(spliced); + // An example that OPENS with a reference is named by + // its own first matching paragraph, not by the section + // it pulls in. + fresh.name_from_reference = true; + open = Some(fresh); + } + } + } CandidateUnit::Steps(unit) => { if !unit.matched { // Prose paragraph — a delimiter. Drop it and end the open example. @@ -96,7 +149,7 @@ pub fn plan(doc: &Doc, registry: &Registry) -> ExecutionPlan { continue; } match open.as_mut() { - Some(m) if !unit.preceded_by_delimiter => merge_into(m, unit), + Some(m) if !unit.preceded_by_delimiter => merge_into(m, unit, false), _ => { if let Some(m) = open.take() { examples.push(finish_merged(m, source)); @@ -131,14 +184,29 @@ struct MergedExample { steps: Vec, expected_outcome: Option, expected_error_message: Option, + /// True while the name came from a spliced (referenced) paragraph and is + /// waiting to be replaced by the example's own first matching paragraph. + name_from_reference: bool, } /// One candidate paragraph, planned in isolation. enum CandidateUnit { - HeaderBound { rows: Vec }, + HeaderBound { + rows: Vec, + }, + /// A reference block: its whole text is a link to an oath section, whose + /// steps are spliced in here (ADR 0016). Never prose, so it does not close + /// the open example. + Reference(ReferenceUnit), Steps(StepsUnit), } +struct ReferenceUnit { + reference: Reference, + preceded_by_delimiter: bool, + span: Span, +} + struct StepsUnit { matched: bool, preceded_by_delimiter: bool, @@ -159,10 +227,16 @@ fn start_merged(unit: StepsUnit) -> MergedExample { steps: unit.steps, expected_outcome: unit.expected_outcome, expected_error_message: unit.expected_error_message, + name_from_reference: false, } } -fn merge_into(open: &mut MergedExample, unit: StepsUnit) { +fn merge_into(open: &mut MergedExample, unit: StepsUnit, from_reference: bool) { + if open.name_from_reference && !from_reference { + open.name = unit.name.clone(); + open.scope_stack = unit.scope_stack.clone(); + open.name_from_reference = false; + } open.end_offset = unit.span.end_offset; open.steps.extend(unit.steps); // Any error fence in a merged part marks the whole example expected-to-fail; @@ -189,6 +263,79 @@ fn finish_merged(open: MergedExample, source: &str) -> PlannedExample { } } +/// Resolve one reference block into the step-bearing units of the section it +/// names, recursively: a referenced section may itself contain reference +/// blocks, to any depth (ADR 0016 leaves depth to the author's judgement). +/// `chain` carries the sections currently being resolved so a repeat is +/// reported as a cycle instead of recursing forever. +fn resolve_reference( + unit: &ReferenceUnit, + from: &Doc, + registry: &Registry, + workspace: &OathWorkspace, + diagnostics: &mut Vec, + chain: &[String], +) -> Vec { + let key = section_key(&unit.reference.path, &unit.reference.slug); + if chain.contains(&key) { + diagnostics.push(reference_cycle(unit.span)); + return Vec::new(); + } + // A same-file reference resolves against the document being planned, which + // is not necessarily in the workspace. + let target = if unit.reference.path == from.path { + Some(from) + } else { + workspace.docs.get(&unit.reference.path) + }; + let Some(target) = target else { + diagnostics.push(reference_not_found(unit.span)); + return Vec::new(); + }; + let mut out: Vec = Vec::new(); + let mut deeper: Vec = chain.to_vec(); + deeper.push(key); + for candidate in section_candidates(target, &unit.reference.slug) { + match plan_candidate(candidate, target, registry, diagnostics) { + CandidateUnit::Reference(nested) => out.extend(resolve_reference( + &nested, + target, + registry, + workspace, + diagnostics, + &deeper, + )), + // A header-bound table produces one example per row, which a + // spliced step list cannot express; an `error` fence declares an + // outcome for an example, not for a reusable fragment. Both are + // left out. + CandidateUnit::HeaderBound { .. } => {} + CandidateUnit::Steps(planned) => { + if planned.matched { + out.push(tag_with_doc(planned, &target.path, &from.path)); + } + } + } + } + if out.is_empty() { + diagnostics.push(reference_empty(unit.span)); + } + out +} + +/// Carry the source document's identity on every spliced step, so a failure in +/// a referenced section reports spans against the file they were written in +/// rather than the file being run. +fn tag_with_doc(mut unit: StepsUnit, doc_path: &str, host_path: &str) -> StepsUnit { + if doc_path == host_path { + return unit; + } + for step in &mut unit.steps { + step.doc_path = Some(doc_path.to_string()); + } + unit +} + /// Plan a single candidate paragraph (plus its attached tables/fences) in /// isolation. Emits ambiguity / error-fence diagnostics into `diagnostics`. fn plan_candidate( @@ -197,6 +344,18 @@ fn plan_candidate( registry: &Registry, diagnostics: &mut Vec, ) -> CandidateUnit { + // A block whose whole text is a link to an oath section is a reference, not + // content: never matched against step definitions, and never prose. + if let Some(text) = ex.body.first().and_then(block_text_of) { + if let Some(reference) = reference_of(text, &doc.path) { + return CandidateUnit::Reference(ReferenceUnit { + reference, + preceded_by_delimiter: ex.preceded_by_delimiter, + span: ex.span, + }); + } + } + let source = &doc.source; let mut had_ambiguous = false; let body = &ex.body; @@ -226,6 +385,12 @@ fn plan_candidate( .iter() .map(|p| lift_span(source, block, p.start, p.end)) .collect(), + param_texts: hit + .param_spans + .iter() + .map(|p| crate::offsets::utf16_slice(text, p.start, p.end).to_string()) + .collect(), + doc_path: None, step_def: hit.step_def, args: hit.args, formats: hit.formats, @@ -259,14 +424,11 @@ fn plan_candidate( let mut row_args = bound.step.args.clone(); row_args.push(Value::Map(row_object)); let row_step = PlannedStep { - text: bound.step.text.clone(), match_span: row.span, - param_spans: bound.step.param_spans.clone(), - step_def: bound.step.step_def.clone(), args: row_args, - formats: bound.step.formats.clone(), data_table: None, doc_string: None, + ..bound.step.clone() }; let row_checks: Vec = header_cells .iter() @@ -584,3 +746,12 @@ fn lift_segment_offset(segment_map: &[SegmentOffset], text_offset: usize) -> usi let best = best.expect("empty segmentMap"); best.source_offset + (text_offset - best.text_offset) } + +fn block_text_of(block: &Block) -> Option<&str> { + match block { + Block::Paragraph(p) => Some(&p.text), + Block::ListItem(l) => Some(&l.text), + Block::Blockquote(b) => Some(&b.text), + _ => None, + } +} diff --git a/rust/core/src/reference.rs b/rust/core/src/reference.rs new file mode 100644 index 00000000..a5761618 --- /dev/null +++ b/rust/core/src/reference.rs @@ -0,0 +1,185 @@ +//! Reuse is a link (ADR 0016). A candidate block whose entire content is a +//! single Markdown link to an oath section is a REFERENCE BLOCK: it splices +//! that section's steps in at its own position instead of being prose. +//! +//! Everything here is pure text and path arithmetic — no filesystem. The shell +//! reads the documents; [`references`] tells it which ones to read, and +//! [`build_workspace`] turns the collection into what `plan` needs. + +use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; + +use regex::Regex; + +use crate::ast::{Block, Doc, Example}; + +/// One resolved reference block: the referenced oath's path (resolved against +/// the referring doc's own path), the GFM slug of the heading (empty for a +/// whole-file link), and the link's visible text. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Reference { + pub path: String, + pub slug: String, + pub text: String, +} + +/// What `plan` needs to resolve references: every oath by path, plus which +/// sections a reference block consumes somewhere in the project. A section that +/// is referenced stops being a standalone example, so this is whole-project +/// knowledge — see ADR 0016 on why each runner builds it at its once-per-run +/// discovery pass. +#[derive(Clone, Default)] +pub struct OathWorkspace { + pub docs: HashMap, + /// `"{path}#{slug}"` for every referenced section; a whole-file reference + /// is recorded as `"{path}#"`. + pub referenced: HashSet, +} + +// A candidate is a reference block iff its whole text is one Markdown link +// whose target is oath-shaped. Anything else — a link with surrounding words, a +// link to https://…, to a .rs file, to a mailto: — is ordinary content, so +// existing documents keep their meaning. +static LINK_ONLY: LazyLock = + LazyLock::new(|| Regex::new(r"^\[([^\]]*)\]\(\s*([^\s)]+)\s*\)$").unwrap()); +static PROTOCOL: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^[a-z][a-z0-9+.\-]*:").unwrap()); +static NOT_SLUG: LazyLock = LazyLock::new(|| Regex::new(r"[^\p{L}\p{N} _-]").unwrap()); +static CODE_SPAN: LazyLock = LazyLock::new(|| Regex::new(r"`([^`]*)`").unwrap()); +static STRONG: LazyLock = LazyLock::new(|| Regex::new(r"\*\*([^*]*)\*\*").unwrap()); +static EMPH: LazyLock = LazyLock::new(|| Regex::new(r"\*([^*]*)\*").unwrap()); +static UNDER: LazyLock = LazyLock::new(|| Regex::new(r"_([^_]*)_").unwrap()); + +/// The reference a block's text spells, or `None` when it is ordinary content. +pub fn reference_of(text: &str, from_path: &str) -> Option { + let caps = LINK_ONLY.captures(text.trim())?; + let link_text = caps.get(1).map_or("", |m| m.as_str()).to_string(); + let target = caps.get(2).map_or("", |m| m.as_str()); + if let Some(fragment) = target.strip_prefix('#') { + return Some(Reference { + path: from_path.to_string(), + slug: normalize_slug(fragment), + text: link_text, + }); + } + let (file_part, fragment) = match target.find('#') { + Some(i) => (&target[..i], &target[i + 1..]), + None => (target, ""), + }; + // Only a relative Markdown path is a reference. A protocol (https:, mailto:) + // or any other extension is left alone — remote references are deliberately + // out of scope (ADR 0016). + if !file_part.ends_with(".md") || PROTOCOL.is_match(file_part) || file_part.starts_with('/') { + return None; + } + Some(Reference { + path: join_posix(dirname_posix(from_path), file_part), + slug: normalize_slug(fragment), + text: link_text, + }) +} + +/// GitHub's heading anchors: inline markup dropped, lowercased, spaces to +/// hyphens, everything else that isn't a word character or hyphen removed. The +/// same function produces the slug of a heading and normalizes the slug written +/// in a link, so the two meet in the middle. +pub fn slugify(heading_text: &str) -> String { + let s = CODE_SPAN.replace_all(heading_text, "$1"); + let s = STRONG.replace_all(&s, "$1"); + let s = EMPH.replace_all(&s, "$1"); + let s = UNDER.replace_all(&s, "$1"); + normalize_slug(&s) +} + +fn normalize_slug(s: &str) -> String { + // One hyphen per space, not per run of them: GitHub leaves the gap where it + // dropped punctuation, so "Fees, VAT & rounding" slugs with a double hyphen. + NOT_SLUG + .replace_all(s.trim().to_lowercase().as_str(), "") + .replace(' ', "-") +} + +fn dirname_posix(path: &str) -> &str { + match path.rfind('/') { + Some(i) => &path[..i], + None => "", + } +} + +/// POSIX path arithmetic on oath paths (always '/'-separated, relative to the +/// workspace root). The core may not touch the filesystem. +pub fn join_posix(dir: &str, rel: &str) -> String { + let mut segments: Vec<&str> = if dir.is_empty() { + Vec::new() + } else { + dir.split('/').collect() + }; + for segment in rel.split('/') { + match segment { + "" | "." => continue, + ".." => { + segments.pop(); + } + other => segments.push(other), + } + } + segments.join("/") +} + +/// Every reference block in a document, in document order. The shell uses this +/// to walk the closure of documents it must read before planning. +pub fn references(doc: &Doc) -> Vec { + doc.examples + .iter() + .filter_map(|ex| block_text(ex.body.first()?).and_then(|t| reference_of(t, &doc.path))) + .collect() +} + +fn block_text(block: &Block) -> Option<&str> { + match block { + Block::Paragraph(p) => Some(&p.text), + Block::ListItem(l) => Some(&l.text), + Block::Blockquote(b) => Some(&b.text), + _ => None, + } +} + +/// A section's identity across the project. +pub fn section_key(path: &str, slug: &str) -> String { + format!("{path}#{slug}") +} + +/// The workspace with no references at all: what a caller planning a single +/// document in isolation passes. +pub fn empty_workspace() -> OathWorkspace { + OathWorkspace::default() +} + +/// Index every oath by path and record every consumed section. +pub fn build_workspace(docs: &[Doc]) -> OathWorkspace { + let mut ws = OathWorkspace::default(); + for doc in docs { + ws.docs.insert(doc.path.clone(), doc.clone()); + } + for doc in docs { + for r in references(doc) { + ws.referenced.insert(section_key(&r.path, &r.slug)); + } + } + ws +} + +/// The candidates that make up a section: those whose heading chain contains +/// the slug. A whole-file reference (empty slug) is every candidate in the +/// document. Section membership follows the document outline exactly — a +/// heading's section runs until the next heading of the same or higher level, +/// which is precisely the range over which it stays on the scope stack. +pub fn section_candidates<'a>(doc: &'a Doc, slug: &str) -> Vec<&'a Example> { + if slug.is_empty() { + return doc.examples.iter().collect(); + } + doc.examples + .iter() + .filter(|ex| ex.scope_stack.iter().any(|h| slugify(h) == slug)) + .collect() +} diff --git a/rust/core/tests/drift_test.rs b/rust/core/tests/drift_test.rs index 1cef4155..c0c24658 100644 --- a/rust/core/tests/drift_test.rs +++ b/rust/core/tests/drift_test.rs @@ -10,6 +10,7 @@ use varar_core::handler::Handler; use varar_core::hash::hash_source; use varar_core::parse::parse; use varar_core::plan::{ExecutionPlan, plan}; +use varar_core::reference::empty_workspace; use varar_core::registry::{Registry, add_step, create_registry}; use varar_core::span::Span; use varar_core::step_kind::StepKind; @@ -42,7 +43,7 @@ fn roman_reg(with_step: bool) -> Registry { } fn plan_of(source: &str, r: &Registry) -> ExecutionPlan { - plan(&parse("w.md", source), r) + plan(&parse("w.md", source), r, &empty_workspace()) } fn bare(drifts: &[Drifted]) -> Vec { @@ -200,17 +201,22 @@ fn header_bound_table_records_its_binding_paragraph_once() { name: "Each row gives a decimal and a roman number:".to_string(), line: 1 }], - live_examples(&doc, &plan(&doc, &roman_reg(true))) + live_examples(&doc, &plan(&doc, &roman_reg(true), &empty_workspace())) ); } #[test] fn a_header_bound_binding_paragraph_that_stops_matching_drifts() { let doc = parse("r.md", ROMAN); - let baseline = derive_oath_baseline(ROMAN, &doc, &plan(&doc, &roman_reg(true))); + let baseline = + derive_oath_baseline(ROMAN, &doc, &plan(&doc, &roman_reg(true), &empty_workspace())); assert_eq!( vec!["Each row gives a decimal and a roman number:@1".to_string()], - bare(&detect_drift(Some(&baseline), &doc, &plan(&doc, &roman_reg(false)))) + bare(&detect_drift( + Some(&baseline), + &doc, + &plan(&doc, &roman_reg(false), &empty_workspace()) + )) ); } @@ -295,7 +301,7 @@ fn deposit_withdraw_reg(with_deposit: bool) -> Registry { fn two_paragraphs_that_merge_into_one_example_are_each_a_live_baseline_entry() { let source = "I deposit 100.\n\nI withdraw 40."; let doc = parse("w.md", source); - let plan1 = plan(&doc, &deposit_withdraw_reg(true)); + let plan1 = plan(&doc, &deposit_withdraw_reg(true), &empty_workspace()); // One planned example (the two paragraphs merged), but two live entries. assert_eq!(1, plan1.examples.len()); assert_eq!( @@ -317,10 +323,18 @@ fn two_paragraphs_that_merge_into_one_example_are_each_a_live_baseline_entry() { fn deleting_one_step_def_of_a_merged_example_drifts_only_the_now_prose_paragraph() { let source = "I deposit 100.\n\nI withdraw 40."; let doc = parse("w.md", source); - let baseline = derive_oath_baseline(source, &doc, &plan(&doc, &deposit_withdraw_reg(true))); + let baseline = derive_oath_baseline( + source, + &doc, + &plan(&doc, &deposit_withdraw_reg(true), &empty_workspace()), + ); // The deposit step is gone: its paragraph becomes prose, splitting the // example. The withdraw paragraph is still live; the deposit one drifts. - let drift = detect_drift(Some(&baseline), &doc, &plan(&doc, &deposit_withdraw_reg(false))); + let drift = detect_drift( + Some(&baseline), + &doc, + &plan(&doc, &deposit_withdraw_reg(false), &empty_workspace()), + ); assert_eq!(vec!["I deposit 100@1".to_string()], bare(&drift)); } @@ -342,7 +356,7 @@ fn drift_message_names_the_paragraph() { fn lock_with_stale_path() -> String { let source = "I withdraw 40."; let doc = parse("w.md", source); - let baseline = derive_oath_baseline(source, &doc, &plan(&doc, ®(true))); + let baseline = derive_oath_baseline(source, &doc, &plan(&doc, ®(true), &empty_workspace())); let mut oaths = BTreeMap::new(); oaths.insert("varar/w.md".to_string(), baseline.clone()); oaths.insert("w.md".to_string(), baseline); diff --git a/rust/core/tests/execute_test.rs b/rust/core/tests/execute_test.rs index 0ede3a59..406d30d4 100644 --- a/rust/core/tests/execute_test.rs +++ b/rust/core/tests/execute_test.rs @@ -6,6 +6,7 @@ //! `CompletableFuture` return becomes [`Handler::async0`] driven by the executor's //! `block_on`. +use varar_core::reference::empty_workspace; mod common; use common::vmap; @@ -49,7 +50,7 @@ fn reg( } fn plan_of(source: &str, registry: &Registry) -> ExecutionPlan { - plan(&parse("x.md", source), registry) + plan(&parse("x.md", source), registry, &empty_workspace()) } /// A future that yields `Pending` exactly once (exercising the executor's diff --git a/rust/core/tests/failure_step_span_test.rs b/rust/core/tests/failure_step_span_test.rs index a10ec174..6994f2f3 100644 --- a/rust/core/tests/failure_step_span_test.rs +++ b/rust/core/tests/failure_step_span_test.rs @@ -10,6 +10,7 @@ use varar_core::handler::Handler; use varar_core::offsets::utf16_slice; use varar_core::parse::parse; use varar_core::plan::plan; +use varar_core::reference::empty_workspace; use varar_core::registry::{add_step, create_registry}; use varar_core::step_kind::StepKind; @@ -38,7 +39,7 @@ fn a_failing_step_records_the_anchor_of_the_step_that_failed() { ) .unwrap(); - let p = plan(&parse("l.md", SOURCE), &r); + let p = plan(&parse("l.md", SOURCE), &r, &empty_workspace()); let ports = ExecutePorts::silent(); let failure = collect_examples(&p, &ports)[0].run().unwrap_err(); assert!(matches!(failure.error, StepError::Handler(_))); diff --git a/rust/core/tests/plan_test.rs b/rust/core/tests/plan_test.rs index cb8be569..d6c78d6c 100644 --- a/rust/core/tests/plan_test.rs +++ b/rust/core/tests/plan_test.rs @@ -1,5 +1,6 @@ //! Port of `PlanTest.java` / `plan.test.ts`. +use varar_core::reference::empty_workspace; mod common; use common::vmap; @@ -50,7 +51,7 @@ fn step_texts(ex: &varar_core::plan::PlannedExample) -> Vec { fn plan_produces_a_planned_example_with_steps_in_document_order() { let source = "# Withdrawing\n\nGiven I have 100 in my account. When I withdraw 40. Then I should have 60 left."; let doc = parse("w.md", source); - let result = plan(&doc, ®()); + let result = plan(&doc, ®(), &empty_workspace()); assert_eq!(0, result.diagnostics.len()); assert_eq!(1, result.examples.len()); let ex = &result.examples[0]; @@ -76,7 +77,7 @@ fn plan_emits_an_ambiguous_match_diagnostic_and_produces_no_runnable_example() { let r = step(&r, "I have {int} cukes", "a.ts", 3); let r = step(&r, "I have {int} {word}", "a.ts", 8); let doc = parse("e.md", "# Ambig\n\nGiven I have 5 cukes"); - let result = plan(&doc, &r); + let result = plan(&doc, &r, &empty_workspace()); assert_eq!(1, result.diagnostics.len()); assert_eq!(DiagnosticCode::AmbiguousMatch, result.diagnostics[0].code); // An ambiguous candidate has no runnable step, so it is prose (a delimiter), @@ -87,7 +88,7 @@ fn plan_emits_an_ambiguous_match_diagnostic_and_produces_no_runnable_example() { #[test] fn plan_skips_an_example_heading_whose_body_has_no_matches_and_no_keyword_led_sentences() { let source = "# Just docs\n\nSome prose with no matches and no keywords."; - let result = plan(&parse("d.md", source), ®()); + let result = plan(&parse("d.md", source), ®(), &empty_workspace()); assert_eq!(0, result.examples.len()); assert_eq!(0, result.diagnostics.len()); } @@ -100,7 +101,7 @@ fn plan_merges_consecutive_list_items_into_one_example() { // Two list items, no delimiter between them → one example, shared state (ADR // 0012). A bulleted scenario reads as Given/When/Then bullets. let source = "# Bullets\n\n- Given I have 100 in my account\n- When I withdraw 40"; - let result = plan(&parse("b.md", source), &r); + let result = plan(&parse("b.md", source), &r, &empty_workspace()); assert_eq!(1, result.examples.len()); assert_eq!( vec![ @@ -116,7 +117,7 @@ fn plan_walks_blockquote_content_as_step_bearing() { let r = create_registry(); let r = step(&r, "I have {int} in my account", "s.ts", 1); let source = "# Quote\n\n> Given I have 100 in my account"; - let result = plan(&parse("q.md", source), &r); + let result = plan(&parse("q.md", source), &r, &empty_workspace()); assert_eq!(1, result.examples[0].steps.len()); } @@ -125,7 +126,7 @@ fn a_markdown_table_immediately_following_a_step_bearing_block_attaches_as_data_ let r = create_registry(); let r = step(&r, "these users exist", "s.ts", 1); let source = "# Users\nGiven these users exist:\n\n| name | age |\n|------|-----|\n| Bob | 30 |\n| Eve | 25 |"; - let result = plan(&parse("u.md", source), &r); + let result = plan(&parse("u.md", source), &r, &empty_workspace()); let step0 = &result.examples[0].steps[0]; let table = step0.data_table.as_ref().expect("data table"); assert_eq!(vec!["name".to_string(), "age".to_string()], table.header.cells); @@ -137,7 +138,7 @@ fn a_table_not_immediately_after_a_step_bearing_block_does_not_attach() { let r = create_registry(); let r = step(&r, "these users exist", "s.ts", 1); let source = "# Mid\nGiven these users exist:\n\nSome interrupting prose.\n\n| name | age |\n|------|-----|\n| Bob | 30 |"; - let result = plan(&parse("m.md", source), &r); + let result = plan(&parse("m.md", source), &r, &empty_workspace()); assert!(result.examples[0].steps[0].data_table.is_none()); } @@ -146,7 +147,7 @@ fn a_fenced_code_block_immediately_following_a_step_bearing_block_attaches_as_do let r = create_registry(); let r = step(&r, "I send the payload", "s.ts", 1); let source = "# Payload\nWhen I send the payload:\n\n```json\n{ \"action\": \"import\" }\n```"; - let result = plan(&parse("p.md", source), &r); + let result = plan(&parse("p.md", source), &r, &empty_workspace()); let step0 = &result.examples[0].steps[0]; let doc = step0.doc_string.as_ref().expect("doc string"); assert_eq!("json", doc.info); @@ -157,21 +158,26 @@ fn a_fenced_code_block_immediately_following_a_step_bearing_block_attaches_as_do fn a_step_with_no_following_fence_has_no_doc_string() { let r = create_registry(); let r = step(&r, "I send the payload", "s.ts", 1); - let result = plan(&parse("p.md", "# P\nWhen I send the payload"), &r); + let result = plan(&parse("p.md", "# P\nWhen I send the payload"), &r, &empty_workspace()); assert!(result.examples[0].steps[0].doc_string.is_none()); } #[test] fn a_keyword_led_sentence_with_no_match_does_not_produce_a_diagnostic() { let r = create_registry(); - let result = plan(&parse("m.md", "# Empty\n\nGiven I have 5 cukes in my belly."), &r); + let result = plan( + &parse("m.md", "# Empty\n\nGiven I have 5 cukes in my belly."), + &r, + &empty_workspace(), + ); assert_eq!(0, result.diagnostics.len()); } #[test] fn an_unmatched_sentence_without_a_keyword_is_also_silently_treated_as_prose() { let r = create_registry(); - let result = plan(&parse("p.md", "# Prose\n\nI have 5 cukes in my belly."), &r); + let result = + plan(&parse("p.md", "# Prose\n\nI have 5 cukes in my belly."), &r, &empty_workspace()); assert_eq!(0, result.diagnostics.len()); } @@ -181,7 +187,7 @@ const YAHTZEE: &str = "# Yahtzee\n\neach row lists the dice, the category and th fn a_header_bound_table_expands_into_one_example_per_row() { let r = create_registry(); let r = step(&r, "each row lists the dice, the category and the score", "s.ts", 1); - let result = plan(&parse("y.md", YAHTZEE), &r); + let result = plan(&parse("y.md", YAHTZEE), &r, &empty_workspace()); assert_eq!(0, result.diagnostics.len()); assert_eq!(2, result.examples.len()); let first = &result.examples[0]; @@ -211,7 +217,7 @@ fn a_table_whose_paragraph_names_only_some_header_cells_keeps_whole_table_behavi let r = create_registry(); let r = step(&r, "these users exist", "s.ts", 1); let source = "# Users\nthese users exist:\n\n| name | age |\n| ---- | --- |\n| Bob | 30 |\n| Eve | 25 |"; - let result = plan(&parse("u.md", source), &r); + let result = plan(&parse("u.md", source), &r, &empty_workspace()); assert_eq!(1, result.examples.len()); let table = result.examples[0].steps[0].data_table.as_ref().unwrap(); assert_eq!(vec!["name".to_string(), "age".to_string()], table.header.cells); @@ -223,7 +229,7 @@ fn header_bound_matching_is_case_sensitive() { let r = create_registry(); let r = step(&r, "each row lists the Dice and the Score", "s.ts", 1); let source = "# Case\neach row lists the Dice and the Score:\n\n| dice | score |\n| --------- | ----- |\n| 1,1,1,1,1 | 5 |"; - let result = plan(&parse("c.md", source), &r); + let result = plan(&parse("c.md", source), &r, &empty_workspace()); assert_eq!(1, result.examples.len()); assert_eq!( 1, @@ -240,7 +246,7 @@ fn header_bound_matching_is_case_sensitive() { fn header_bound_rows_are_named_by_their_cells_and_nested_under_the_paragraph() { let r = create_registry(); let r = step(&r, "each row lists the dice, the category and the score", "s.ts", 1); - let result = plan(&parse("y.md", YAHTZEE), &r); + let result = plan(&parse("y.md", YAHTZEE), &r, &empty_workspace()); let names: Vec = result.examples.iter().map(|e| e.name.clone()).collect(); assert_eq!( vec![ @@ -268,7 +274,7 @@ fn a_table_not_attached_to_a_step_is_allowed_no_diagnostic() { let r = create_registry(); let r = step(&r, "I have {int} cukes", "s.ts", 1); let source = "# Detached\n\nGiven I have 5 cukes.\n\nSome interrupting prose paragraph.\n\n| name | age |\n|------|-----|\n| Bob | 30 |"; - let result = plan(&parse("o.md", source), &r); + let result = plan(&parse("o.md", source), &r, &empty_workspace()); assert_eq!(0, result.diagnostics.len()); } @@ -277,7 +283,7 @@ fn a_header_bound_row_example_carries_row_checks() { let r = create_registry(); let r = step(&r, "each row lists the dice, the category and the score", "s.ts", 1); let source = "# Yahtzee\n\neach row lists the dice, the category and the score:\n\n| dice | category | score |\n| ------------- | ---------- | ----- |\n| 3, 3, 3, 4, 4 | full house | 17 |"; - let result = plan(&parse("y.md", source), &r); + let result = plan(&parse("y.md", source), &r, &empty_workspace()); let checks: &Vec = result.examples[0] .row_checks .as_ref() @@ -319,7 +325,9 @@ fn an_error_fence_marks_the_example_expected_outcome_fail_with_a_message_substri ) .unwrap(); let src = "# Division\n\nI divide 1 by 0.\n\n```error\ndivision by zero\n```\n"; - let ex = plan(&parse("e.md", src), &r).examples.remove(0); + let ex = plan(&parse("e.md", src), &r, &empty_workspace()) + .examples + .remove(0); assert_eq!(Some("fail".to_string()), ex.expected_outcome); assert_eq!(Some("division by zero".to_string()), ex.expected_error_message); assert!(ex.steps[0].doc_string.is_none()); @@ -336,7 +344,7 @@ fn no_error_fence_leaves_expected_outcome_null() { Some(StepKind::Stimulus), ) .unwrap(); - let ex = plan(&parse("e.md", "# Division\n\nI divide 1 by 1."), &r) + let ex = plan(&parse("e.md", "# Division\n\nI divide 1 by 1."), &r, &empty_workspace()) .examples .remove(0); assert_eq!(None, ex.expected_outcome); @@ -354,7 +362,7 @@ fn an_error_fence_with_no_matching_step_emits_an_error_fence_without_step_diagno ) .unwrap(); let src = "# Nope\n\nThis prose matches nothing.\n\n```error\nboom\n```\n"; - let result = plan(&parse("e.md", src), &r); + let result = plan(&parse("e.md", src), &r, &empty_workspace()); assert_eq!(0, result.examples.len()); assert_eq!(1, result.diagnostics.len()); assert_eq!(DiagnosticCode::ErrorFenceWithoutStep, result.diagnostics[0].code); @@ -366,7 +374,7 @@ fn an_error_fence_on_an_ambiguous_example_emits_both_diagnostics() { let r = step(&r, "I divide {int} by {int}", "s.ts", 1); let r = step(&r, "I divide 1 by 0", "s.ts", 2); let src = "# Ambiguous\n\nI divide 1 by 0.\n\n```error\nboom\n```\n"; - let result = plan(&parse("e.md", src), &r); + let result = plan(&parse("e.md", src), &r, &empty_workspace()); let mut codes: Vec = result.diagnostics.iter().map(|d| d.code).collect(); codes.sort(); assert_eq!( @@ -390,7 +398,7 @@ fn a_doc_string_step_carries_the_fence_body_span_on_its_plan() { ) .unwrap(); let source = "# T\n\nthe payload is:\n\n```json\n{ \"ok\": true }\n```"; - let result = plan(&parse("d.md", source), &r); + let result = plan(&parse("d.md", source), &r, &empty_workspace()); let doc = result.examples[0].steps[0] .doc_string .as_ref() @@ -407,7 +415,7 @@ fn a_doc_string_step_carries_the_fence_body_span_on_its_plan() { #[test] fn consecutive_matching_paragraphs_with_no_delimiter_merge_into_one_example() { let source = "I have 100 in my account.\n\nI withdraw 40.\n\nI should have 60 left."; - let result = plan(&parse("m.md", source), ®()); + let result = plan(&parse("m.md", source), ®(), &empty_workspace()); assert_eq!(1, result.examples.len()); assert_eq!( vec![ @@ -424,7 +432,7 @@ fn consecutive_matching_paragraphs_with_no_delimiter_merge_into_one_example() { #[test] fn a_thematic_break_between_matching_paragraphs_splits_them_into_two_examples() { let source = "I have 100 in my account.\n\n---\n\nI withdraw 40."; - let result = plan(&parse("h.md", source), ®()); + let result = plan(&parse("h.md", source), ®(), &empty_workspace()); assert_eq!(2, result.examples.len()); let texts: Vec> = result.examples.iter().map(step_texts).collect(); assert_eq!( @@ -439,7 +447,7 @@ fn a_thematic_break_between_matching_paragraphs_splits_them_into_two_examples() #[test] fn a_heading_between_matching_paragraphs_splits_them_into_two_examples() { let source = "I have 100 in my account.\n\n## Next\n\nI withdraw 40."; - let result = plan(&parse("hd.md", source), ®()); + let result = plan(&parse("hd.md", source), ®(), &empty_workspace()); assert_eq!(2, result.examples.len()); assert_eq!(vec!["Next".to_string()], result.examples[1].scope_stack); } @@ -448,7 +456,7 @@ fn a_heading_between_matching_paragraphs_splits_them_into_two_examples() { fn a_non_matching_paragraph_prose_between_matching_paragraphs_splits_the_example() { let source = "I have 100 in my account.\n\nJust explaining what happens next.\n\nI withdraw 40."; - let result = plan(&parse("p.md", source), ®()); + let result = plan(&parse("p.md", source), ®(), &empty_workspace()); assert_eq!(2, result.examples.len()); let texts: Vec> = result.examples.iter().map(step_texts).collect(); assert_eq!( @@ -463,7 +471,7 @@ fn a_non_matching_paragraph_prose_between_matching_paragraphs_splits_the_example #[test] fn leading_and_trailing_prose_does_not_merge_into_an_example() { let source = "A preamble that matches nothing.\n\nI withdraw 40.\n\nA closing remark."; - let result = plan(&parse("pp.md", source), ®()); + let result = plan(&parse("pp.md", source), ®(), &empty_workspace()); assert_eq!(1, result.examples.len()); assert_eq!(vec!["I withdraw 40".to_string()], step_texts(&result.examples[0])); } @@ -474,7 +482,7 @@ fn the_multi_table_shape_two_tables_in_one_example_survive_blank_lines() { let r = step(&r, "the following users have been imported", "s.ts", 1); let r = step(&r, "the following assets have been imported", "s.ts", 2); let source = "Given the following users have been imported:\n\n| email | name |\n| ----- | ---- |\n| a@b.c | Ada |\n\nAnd the following assets have been imported:\n\n| name |\n| ----- |\n| Moose |"; - let result = plan(&parse("basket.md", source), &r); + let result = plan(&parse("basket.md", source), &r, &empty_workspace()); assert_eq!(1, result.examples.len()); let ex = &result.examples[0]; assert_eq!(2, ex.steps.len()); diff --git a/rust/runner/src/run.rs b/rust/runner/src/run.rs index 2088a5df..3925e841 100644 --- a/rust/runner/src/run.rs +++ b/rust/runner/src/run.rs @@ -7,11 +7,20 @@ use varar_core::error::StepFailure; use varar_core::execute::{ExecutePorts, collect_examples}; use varar_core::parse::parse; use varar_core::plan::{ExecutionPlan, plan}; +use varar_core::reference::OathWorkspace; use varar_core::registry::Registry; -/// Parse + plan one oath. -pub fn plan_oath(name: &str, source: &str, registry: &Registry) -> ExecutionPlan { - plan(&parse(name, source), registry) +/// Plans one oath. `workspace` carries every other oath in the project plus the +/// sections a reference block consumes (ADR 0016); it is required because an +/// adapter that omitted it would run consumed sections as standalone examples, +/// which is green and wrong. +pub fn plan_oath( + name: &str, + source: &str, + registry: &Registry, + workspace: &OathWorkspace, +) -> ExecutionPlan { + plan(&parse(name, source), registry, workspace) } /// The per-example display names: the innermost heading (or the body-derived diff --git a/rust/runner/tests/runner.rs b/rust/runner/tests/runner.rs index e2e9b500..260bdce3 100644 --- a/rust/runner/tests/runner.rs +++ b/rust/runner/tests/runner.rs @@ -7,6 +7,7 @@ use varar_core::drift::{BaselineStore, reconcile_drift}; use varar_core::handler::Handler; use varar_core::parse::parse; use varar_core::plan::plan; +use varar_core::reference::empty_workspace; use varar_core::registry::{add_step, create_registry}; use varar_core::step_kind::StepKind; use varar_runner::discovery::glob_to_regex; @@ -137,7 +138,7 @@ fn baseline_store_round_trips_and_reconcile_writes_lock() { .unwrap(); let source = "# Hi\n\nI greet \"world\"."; let doc = parse("hi.md", source); - let execution = plan(&doc, ®istry); + let execution = plan(&doc, ®istry, &empty_workspace()); // Clean run: no drift, and the baseline is written. let drifts = reconcile_drift(&mut store, "hi.md", source, &doc, &execution, false); diff --git a/rust/varar/tests/conformance.rs b/rust/varar/tests/conformance.rs index 62833793..a3173b3a 100644 --- a/rust/varar/tests/conformance.rs +++ b/rust/varar/tests/conformance.rs @@ -12,6 +12,7 @@ use std::fs; use std::path::{Path, PathBuf}; +use varar_core::reference::build_workspace; use std::any::Any; use std::rc::Rc; @@ -63,6 +64,10 @@ mod b17; mod b18; #[path = "../../../conformance/bundles/19-emphasis-parameter/mention.steps.rs"] mod b19; +#[path = "../../../conformance/bundles/20-reference-splice/library.steps.rs"] +mod b20; +#[path = "../../../conformance/bundles/21-reference-consumed/library.steps.rs"] +mod b21; // Each bundle now has its OWN context type, so the fixtures cannot share one // function-pointer type. This macro erases that difference: it builds the @@ -100,10 +105,38 @@ fn fixture(bundle: &str) -> (Registry, ContextFactory) { "17-unexpected-pass" => bundle!(b17), "18-multi-table-example" => bundle!(b18), "19-emphasis-parameter" => bundle!(b19), + "20-reference-splice" => bundle!(b20), + "21-reference-consumed" => bundle!(b21), other => panic!("no Rust step fixture for bundle {other}"), } } +/// A bundle is one oath (example.md) plus, for a bundle that exercises +/// reference blocks (ADR 0016), the other oaths it links to — every other `.md` +/// in the bundle directory. They are parsed under their bare file names, so +/// `./shared.md` resolves the same way in every port. +fn bundle_docs(dir: &Path) -> (varar_core::ast::Doc, varar_core::reference::OathWorkspace) { + let mut paths: Vec = fs::read_dir(dir) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|e| e == "md")) + .collect(); + paths.sort(); + let docs: Vec<_> = paths + .iter() + .map(|p| { + let name = p.file_name().unwrap().to_string_lossy().into_owned(); + parse(&name, &fs::read_to_string(p).unwrap()) + }) + .collect(); + let doc = docs + .iter() + .find(|d| d.path == "example.md") + .expect("bundle has no example.md") + .clone(); + (doc, build_workspace(&docs)) +} + fn bundles_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../conformance/bundles") } @@ -148,9 +181,8 @@ fn plan_matches_golden() { for dir in bundle_dirs() { let name = name_of(&dir); let (registry, _) = fixture(&name); - let source = fs::read_to_string(dir.join("example.md")).unwrap(); - let doc = parse("example.md", &source); - let execution = plan(&doc, ®istry); + let (doc, workspace) = bundle_docs(&dir); + let execution = plan(&doc, ®istry, &workspace); // By CONTENT, not bytes — see the note in core's doc gate. let expected = parse_json_value(&golden(&dir, "plan.json")).expect("golden is valid JSON"); if to_plan_artifact(&execution) != expected { @@ -166,9 +198,8 @@ fn trace_matches_golden() { for dir in bundle_dirs() { let name = name_of(&dir); let (registry, state) = fixture(&name); - let source = fs::read_to_string(dir.join("example.md")).unwrap(); - let doc = parse("example.md", &source); - let artifacts = run_conformance(&doc, ®istry, &|| state()); + let (doc, workspace) = bundle_docs(&dir); + let artifacts = run_conformance(&doc, ®istry, &|| state(), &workspace); // By CONTENT, not bytes — see the note in core's doc gate. let expected = parse_json_value(&golden(&dir, "trace.json")).expect("golden is valid JSON"); if artifacts.trace != expected { From 1cb9ad930c4b6a132673564561b1c49426fdca2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 11:15:28 +0100 Subject: [PATCH 13/21] feat(dotnet): reuse setup between examples by linking to a section Ports reference blocks (ADR 0016) to .NET: a block whose entire content is a Markdown link to an oath section splices that section's steps in at its own position, in the same file or across files, nesting to any depth with cycles reported rather than recursed into. Plan.Run takes the workspace as a required argument, and the VSTest adapter builds it from the discovery pass it already runs for baseline pruning. PlannedStep gains ParamTexts, sliced from the document the step was written in, and DocPath for a step spliced in from another oath. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- .../20-reference-splice/LibrarySteps.java | 22 ++ .../bundles/20-reference-splice/example.md | 7 + .../20-reference-splice/golden/doc.json | 108 +++++++++ .../20-reference-splice/golden/plan.json | 93 ++++++++ .../20-reference-splice/golden/registry.json | 21 ++ .../20-reference-splice/golden/trace.json | 43 ++++ .../20-reference-splice/library.steps.cs | 28 +++ .../20-reference-splice/library.steps.go | 31 +++ .../20-reference-splice/library.steps.kt | 17 ++ .../20-reference-splice/library.steps.py | 18 ++ .../20-reference-splice/library.steps.rb | 9 + .../20-reference-splice/library.steps.rs | 26 +++ .../20-reference-splice/library.steps.ts | 9 + .../bundles/20-reference-splice/shared.md | 7 + .../21-reference-consumed/LibrarySteps.java | 22 ++ .../bundles/21-reference-consumed/example.md | 8 + .../21-reference-consumed/golden/doc.json | 75 ++++++ .../21-reference-consumed/golden/plan.json | 4 + .../golden/registry.json | 21 ++ .../21-reference-consumed/golden/trace.json | 3 + .../21-reference-consumed/library.steps.cs | 28 +++ .../21-reference-consumed/library.steps.go | 31 +++ .../21-reference-consumed/library.steps.kt | 17 ++ .../21-reference-consumed/library.steps.py | 18 ++ .../21-reference-consumed/library.steps.rb | 9 + .../21-reference-consumed/library.steps.rs | 26 +++ .../21-reference-consumed/library.steps.ts | 9 + .../bundles/21-reference-consumed/shared.md | 5 + dotnet/Varar.Core.Tests/DriftTests.cs | 68 +++--- dotnet/Varar.Core.Tests/FailureTests.cs | 2 +- dotnet/Varar.Core.Tests/PlanTests.cs | 2 +- dotnet/Varar.Core.Tests/RunnerTests.cs | 8 +- dotnet/Varar.Core/Conformance.cs | 12 +- dotnet/Varar.Core/Diagnostics.cs | 45 ++++ dotnet/Varar.Core/Plan.cs | 157 ++++++++++++- dotnet/Varar.Core/Reference.cs | 219 ++++++++++++++++++ dotnet/Varar.Runner/Runner.cs | 10 +- dotnet/Varar.TestAdapter/VararAdapter.cs | 36 ++- dotnet/Varar.Tests/ConformanceFixtures.cs | 19 ++ dotnet/Varar.Tests/PlanConformanceTests.cs | 6 +- dotnet/Varar.Tests/TraceConformanceTests.cs | 6 +- 41 files changed, 1245 insertions(+), 60 deletions(-) create mode 100644 conformance/bundles/20-reference-splice/LibrarySteps.java create mode 100644 conformance/bundles/20-reference-splice/example.md create mode 100644 conformance/bundles/20-reference-splice/golden/doc.json create mode 100644 conformance/bundles/20-reference-splice/golden/plan.json create mode 100644 conformance/bundles/20-reference-splice/golden/registry.json create mode 100644 conformance/bundles/20-reference-splice/golden/trace.json create mode 100644 conformance/bundles/20-reference-splice/library.steps.cs create mode 100644 conformance/bundles/20-reference-splice/library.steps.go create mode 100644 conformance/bundles/20-reference-splice/library.steps.kt create mode 100644 conformance/bundles/20-reference-splice/library.steps.py create mode 100644 conformance/bundles/20-reference-splice/library.steps.rb create mode 100644 conformance/bundles/20-reference-splice/library.steps.rs create mode 100644 conformance/bundles/20-reference-splice/library.steps.ts create mode 100644 conformance/bundles/20-reference-splice/shared.md create mode 100644 conformance/bundles/21-reference-consumed/LibrarySteps.java create mode 100644 conformance/bundles/21-reference-consumed/example.md create mode 100644 conformance/bundles/21-reference-consumed/golden/doc.json create mode 100644 conformance/bundles/21-reference-consumed/golden/plan.json create mode 100644 conformance/bundles/21-reference-consumed/golden/registry.json create mode 100644 conformance/bundles/21-reference-consumed/golden/trace.json create mode 100644 conformance/bundles/21-reference-consumed/library.steps.cs create mode 100644 conformance/bundles/21-reference-consumed/library.steps.go create mode 100644 conformance/bundles/21-reference-consumed/library.steps.kt create mode 100644 conformance/bundles/21-reference-consumed/library.steps.py create mode 100644 conformance/bundles/21-reference-consumed/library.steps.rb create mode 100644 conformance/bundles/21-reference-consumed/library.steps.rs create mode 100644 conformance/bundles/21-reference-consumed/library.steps.ts create mode 100644 conformance/bundles/21-reference-consumed/shared.md create mode 100644 dotnet/Varar.Core/Reference.cs diff --git a/conformance/bundles/20-reference-splice/LibrarySteps.java b/conformance/bundles/20-reference-splice/LibrarySteps.java new file mode 100644 index 00000000..ef3e6d3d --- /dev/null +++ b/conformance/bundles/20-reference-splice/LibrarySteps.java @@ -0,0 +1,22 @@ +package dev.varar.conformance.bundle20; + +import dev.varar.State; +import dev.varar.StepDefinitions; +import dev.varar.Steps; + +/** Java sibling of {@code library.steps.ts} / {@code library.steps.py} (bundle {@code 20-reference-splice}). */ +public final class LibrarySteps implements StepDefinitions { + + record Ctx(int shelf) implements State {} + + @Override + public void register(Steps s) { + s.state(() -> new Ctx(0)); + + s.stimulus("I shelve {int} books", (Ctx ctx, Integer n) -> new Ctx(ctx.shelf() + n)); + + s.stimulus("I borrow a book", (Ctx ctx) -> new Ctx(ctx.shelf() - 1)); + + s.sensor("The shelf holds {int} books", (Ctx ctx, Integer n) -> ctx.shelf()); + } +} diff --git a/conformance/bundles/20-reference-splice/example.md b/conformance/bundles/20-reference-splice/example.md new file mode 100644 index 00000000..6a07031f --- /dev/null +++ b/conformance/bundles/20-reference-splice/example.md @@ -0,0 +1,7 @@ +# Late fees + +A world state written once and linked from the examples that need it. + +[A stocked library](./shared.md#a-stocked-library) + +I borrow a book. The shelf holds 2 books. diff --git a/conformance/bundles/20-reference-splice/golden/doc.json b/conformance/bundles/20-reference-splice/golden/doc.json new file mode 100644 index 00000000..d202e08a --- /dev/null +++ b/conformance/bundles/20-reference-splice/golden/doc.json @@ -0,0 +1,108 @@ +{ + "examples": [ + { + "body": [ + { + "kind": "paragraph", + "segmentMap": [ + { + "sourceOffset": 13, + "textOffset": 0 + } + ], + "span": { + "endCol": 70, + "endLine": 3, + "endOffset": 82, + "startCol": 1, + "startLine": 3, + "startOffset": 13 + }, + "text": "A world state written once and linked from the examples that need it." + } + ], + "precededByDelimiter": true, + "scopeStack": [ + "Late fees" + ], + "span": { + "endCol": 70, + "endLine": 3, + "endOffset": 82, + "startCol": 1, + "startLine": 3, + "startOffset": 13 + } + }, + { + "body": [ + { + "kind": "paragraph", + "segmentMap": [ + { + "sourceOffset": 84, + "textOffset": 0 + } + ], + "span": { + "endCol": 51, + "endLine": 5, + "endOffset": 134, + "startCol": 1, + "startLine": 5, + "startOffset": 84 + }, + "text": "[A stocked library](./shared.md#a-stocked-library)" + } + ], + "precededByDelimiter": false, + "scopeStack": [ + "Late fees" + ], + "span": { + "endCol": 51, + "endLine": 5, + "endOffset": 134, + "startCol": 1, + "startLine": 5, + "startOffset": 84 + } + }, + { + "body": [ + { + "kind": "paragraph", + "segmentMap": [ + { + "sourceOffset": 136, + "textOffset": 0 + } + ], + "span": { + "endCol": 42, + "endLine": 7, + "endOffset": 177, + "startCol": 1, + "startLine": 7, + "startOffset": 136 + }, + "text": "I borrow a book. The shelf holds 2 books." + } + ], + "precededByDelimiter": false, + "scopeStack": [ + "Late fees" + ], + "span": { + "endCol": 42, + "endLine": 7, + "endOffset": 177, + "startCol": 1, + "startLine": 7, + "startOffset": 136 + } + } + ], + "orphanAttachments": [], + "path": "example.md" +} diff --git a/conformance/bundles/20-reference-splice/golden/plan.json b/conformance/bundles/20-reference-splice/golden/plan.json new file mode 100644 index 00000000..18e0265a --- /dev/null +++ b/conformance/bundles/20-reference-splice/golden/plan.json @@ -0,0 +1,93 @@ +{ + "diagnostics": [], + "examples": [ + { + "expectedOutcome": "pass", + "name": "I borrow a book. The shelf holds 2 books", + "scopeStack": [ + "Late fees" + ], + "span": { + "endCol": 42, + "endLine": 7, + "endOffset": 177, + "startCol": 31, + "startLine": 5, + "startOffset": 114 + }, + "steps": [ + { + "args": [ + { + "parameterType": "int", + "value": "3" + } + ], + "docPath": "shared.md", + "matchSpan": { + "endCol": 17, + "endLine": 7, + "endOffset": 130, + "startCol": 1, + "startLine": 7, + "startOffset": 114 + }, + "matchedExpression": "I shelve {int} books", + "paramSpans": [ + { + "endCol": 11, + "endLine": 7, + "endOffset": 124, + "startCol": 10, + "startLine": 7, + "startOffset": 123 + } + ], + "text": "I shelve 3 books" + }, + { + "args": [], + "matchSpan": { + "endCol": 16, + "endLine": 7, + "endOffset": 151, + "startCol": 1, + "startLine": 7, + "startOffset": 136 + }, + "matchedExpression": "I borrow a book", + "paramSpans": [], + "text": "I borrow a book" + }, + { + "args": [ + { + "parameterType": "int", + "value": "2" + } + ], + "matchSpan": { + "endCol": 41, + "endLine": 7, + "endOffset": 176, + "startCol": 18, + "startLine": 7, + "startOffset": 153 + }, + "matchedExpression": "The shelf holds {int} books", + "paramSpans": [ + { + "endCol": 35, + "endLine": 7, + "endOffset": 170, + "startCol": 34, + "startLine": 7, + "startOffset": 169 + } + ], + "text": "The shelf holds 2 books" + } + ] + } + ] +} diff --git a/conformance/bundles/20-reference-splice/golden/registry.json b/conformance/bundles/20-reference-splice/golden/registry.json new file mode 100644 index 00000000..aa29f085 --- /dev/null +++ b/conformance/bundles/20-reference-splice/golden/registry.json @@ -0,0 +1,21 @@ +{ + "parameterTypes": [], + "steps": [ + { + "expression": "I shelve {int} books", + "parameterTypeNames": [ + "int" + ] + }, + { + "expression": "I borrow a book", + "parameterTypeNames": [] + }, + { + "expression": "The shelf holds {int} books", + "parameterTypeNames": [ + "int" + ] + } + ] +} diff --git a/conformance/bundles/20-reference-splice/golden/trace.json b/conformance/bundles/20-reference-splice/golden/trace.json new file mode 100644 index 00000000..e24e1fd8 --- /dev/null +++ b/conformance/bundles/20-reference-splice/golden/trace.json @@ -0,0 +1,43 @@ +{ + "examples": [ + { + "name": "I borrow a book. The shelf holds 2 books", + "outcome": "pass", + "steps": [ + { + "contextKey": { + "exampleName": "I borrow a book. The shelf holds 2 books", + "stepFile": "library.steps" + }, + "exampleName": "I borrow a book. The shelf holds 2 books", + "matchedExpression": "I shelve {int} books", + "ordinal": 1, + "outcome": "pass", + "stepText": "I shelve 3 books" + }, + { + "contextKey": { + "exampleName": "I borrow a book. The shelf holds 2 books", + "stepFile": "library.steps" + }, + "exampleName": "I borrow a book. The shelf holds 2 books", + "matchedExpression": "I borrow a book", + "ordinal": 2, + "outcome": "pass", + "stepText": "I borrow a book" + }, + { + "contextKey": { + "exampleName": "I borrow a book. The shelf holds 2 books", + "stepFile": "library.steps" + }, + "exampleName": "I borrow a book. The shelf holds 2 books", + "matchedExpression": "The shelf holds {int} books", + "ordinal": 3, + "outcome": "pass", + "stepText": "The shelf holds 2 books" + } + ] + } + ] +} diff --git a/conformance/bundles/20-reference-splice/library.steps.cs b/conformance/bundles/20-reference-splice/library.steps.cs new file mode 100644 index 00000000..3aa47952 --- /dev/null +++ b/conformance/bundles/20-reference-splice/library.steps.cs @@ -0,0 +1,28 @@ +// C# sibling of library.steps.ts / .rs (bundle 20-reference-splice). +using Varar; +using Varar.Core; + +namespace Varar.Corpus.B20; + +public static class LibrarySteps +{ + public static void Register(Steps s) + { + s.Stimulus( + "I shelve {int} books", + (state, n) => Value.Map([new("shelf", Value.Of(ShelfOf(state) + AsLong(n)))])); + + s.Stimulus( + "I borrow a book", + state => Value.Map([new("shelf", Value.Of(ShelfOf(state) - 1))])); + + s.Sensor("The shelf holds {int} books", (state, n) => Value.Of(ShelfOf(state))); + } + + public static Value State() => Value.Map([new("shelf", Value.Of(0))]); + + private static long ShelfOf(Value state) => + state is VMap m && m.Entries.TryGetValue("shelf", out var v) && v is VInt i ? i.Int : 0; + + private static long AsLong(Value v) => v is VInt i ? i.Int : 0; +} diff --git a/conformance/bundles/20-reference-splice/library.steps.go b/conformance/bundles/20-reference-splice/library.steps.go new file mode 100644 index 00000000..fb6d212b --- /dev/null +++ b/conformance/bundles/20-reference-splice/library.steps.go @@ -0,0 +1,31 @@ +// Go sibling of library.steps.ts (bundle 20-reference-splice). +package fixture + +import "github.com/varar-dev/varar/go/varar" + +func shelfOf(state varar.Value) int { + if m, ok := state.AsMap(); ok { + if c, ok := m["shelf"]; ok { + if n, ok := c.AsInt(); ok { + return int(n) + } + } + } + return 0 +} + +func Register(s *varar.Steps[varar.Value]) { + s.Stimulus("I shelve {int} books", func(state varar.Value, n int) (varar.Value, error) { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(int64(shelfOf(state) + n))}), nil + }) + s.Stimulus("I borrow a book", func(state varar.Value) (varar.Value, error) { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(int64(shelfOf(state) - 1))}), nil + }) + s.Sensor("The shelf holds {int} books", func(state varar.Value, expected int) (int, error) { + return shelfOf(state), nil + }) +} + +func State() varar.Value { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(0)}) +} diff --git a/conformance/bundles/20-reference-splice/library.steps.kt b/conformance/bundles/20-reference-splice/library.steps.kt new file mode 100644 index 00000000..47fb257f --- /dev/null +++ b/conformance/bundles/20-reference-splice/library.steps.kt @@ -0,0 +1,17 @@ +@file:JvmName("LibrarySteps") + +// Kotlin sibling of library.steps.ts / library.steps.py / LibrarySteps.java +// (bundle 20-reference-splice). +package dev.varar.kotlin.conformance.bundle20 + +import dev.varar.kotlin.stimulus +import dev.varar.kotlin.steps +import dev.varar.kotlin.sensor + +data class Ctx(val shelf: Int = 0) + +val steps = steps(::Ctx) { + stimulus("I shelve {int} books") { n: Int -> copy(shelf = shelf + n) } + stimulus("I borrow a book") { copy(shelf = shelf - 1) } + sensor("The shelf holds {int} books") { n: Int -> shelf } +} diff --git a/conformance/bundles/20-reference-splice/library.steps.py b/conformance/bundles/20-reference-splice/library.steps.py new file mode 100644 index 00000000..56de56e8 --- /dev/null +++ b/conformance/bundles/20-reference-splice/library.steps.py @@ -0,0 +1,18 @@ +from varar import steps + +param, stimulus, sensor = steps(lambda: {"shelf": 0}) + + +@stimulus("I shelve {int} books") +def _(state, n): + return {"shelf": state["shelf"] + n} + + +@stimulus("I borrow a book") +def _(state): + return {"shelf": state["shelf"] - 1} + + +@sensor("The shelf holds {int} books") +def _(state, n): + return state["shelf"] diff --git a/conformance/bundles/20-reference-splice/library.steps.rb b/conformance/bundles/20-reference-splice/library.steps.rb new file mode 100644 index 00000000..a575fdd1 --- /dev/null +++ b/conformance/bundles/20-reference-splice/library.steps.rb @@ -0,0 +1,9 @@ +require "varar" + +steps(-> { { shelf: 0 } }) do + stimulus("I shelve {int} books") { |state, n| { shelf: state[:shelf] + n } } + + stimulus("I borrow a book") { |state| { shelf: state[:shelf] - 1 } } + + sensor("The shelf holds {int} books") { |state, _n| state[:shelf] } +end diff --git a/conformance/bundles/20-reference-splice/library.steps.rs b/conformance/bundles/20-reference-splice/library.steps.rs new file mode 100644 index 00000000..aedef813 --- /dev/null +++ b/conformance/bundles/20-reference-splice/library.steps.rs @@ -0,0 +1,26 @@ +//! Rust sibling of `library.steps.ts` (bundle `20-reference-splice`). + +use varar::Steps; + +#[derive(Clone, Default)] +pub struct Ctx { + pub shelf: i64, +} + +pub fn register(s: &mut Steps) { + s.stimulus("I shelve {int} books", |ctx: Ctx, n: i64| { + Ok(Ctx { + shelf: ctx.shelf + n, + }) + }); + s.stimulus("I borrow a book", |ctx: Ctx| { + Ok(Ctx { + shelf: ctx.shelf - 1, + }) + }); + s.sensor("The shelf holds {int} books", |ctx: Ctx, _expected: i64| Ok(ctx.shelf)); +} + +pub fn state() -> Ctx { + Ctx::default() +} diff --git a/conformance/bundles/20-reference-splice/library.steps.ts b/conformance/bundles/20-reference-splice/library.steps.ts new file mode 100644 index 00000000..3fce1e0e --- /dev/null +++ b/conformance/bundles/20-reference-splice/library.steps.ts @@ -0,0 +1,9 @@ +import { steps } from '@varar/varar' + +const { stimulus, sensor } = steps<{ shelf: number }>(() => ({ shelf: 0 })) + +stimulus('I shelve {int} books', (state, n) => ({ shelf: state.shelf + n })) + +stimulus('I borrow a book', (state) => ({ shelf: state.shelf - 1 })) + +sensor('The shelf holds {int} books', (state) => state.shelf) diff --git a/conformance/bundles/20-reference-splice/shared.md b/conformance/bundles/20-reference-splice/shared.md new file mode 100644 index 00000000..df3b2c10 --- /dev/null +++ b/conformance/bundles/20-reference-splice/shared.md @@ -0,0 +1,7 @@ +# Shared world states + +Each section below is referenced from another oath, and runs there. + +## A stocked library + +I shelve 3 books. diff --git a/conformance/bundles/21-reference-consumed/LibrarySteps.java b/conformance/bundles/21-reference-consumed/LibrarySteps.java new file mode 100644 index 00000000..b3588d5e --- /dev/null +++ b/conformance/bundles/21-reference-consumed/LibrarySteps.java @@ -0,0 +1,22 @@ +package dev.varar.conformance.bundle21; + +import dev.varar.State; +import dev.varar.StepDefinitions; +import dev.varar.Steps; + +/** Java sibling of {@code library.steps.ts} / {@code library.steps.py} (bundle {@code 21-reference-consumed}). */ +public final class LibrarySteps implements StepDefinitions { + + record Ctx(int shelf) implements State {} + + @Override + public void register(Steps s) { + s.state(() -> new Ctx(0)); + + s.stimulus("I shelve {int} books", (Ctx ctx, Integer n) -> new Ctx(ctx.shelf() + n)); + + s.stimulus("I borrow a book", (Ctx ctx) -> new Ctx(ctx.shelf() - 1)); + + s.sensor("The shelf holds {int} books", (Ctx ctx, Integer n) -> ctx.shelf()); + } +} diff --git a/conformance/bundles/21-reference-consumed/example.md b/conformance/bundles/21-reference-consumed/example.md new file mode 100644 index 00000000..80f7005e --- /dev/null +++ b/conformance/bundles/21-reference-consumed/example.md @@ -0,0 +1,8 @@ +# Shared world states + +This oath's only section is referenced from another one, so it contributes no +standalone example of its own. + +## A stocked library + +I shelve 3 books. diff --git a/conformance/bundles/21-reference-consumed/golden/doc.json b/conformance/bundles/21-reference-consumed/golden/doc.json new file mode 100644 index 00000000..d6fb0ac5 --- /dev/null +++ b/conformance/bundles/21-reference-consumed/golden/doc.json @@ -0,0 +1,75 @@ +{ + "examples": [ + { + "body": [ + { + "kind": "paragraph", + "segmentMap": [ + { + "sourceOffset": 23, + "textOffset": 0 + } + ], + "span": { + "endCol": 31, + "endLine": 4, + "endOffset": 131, + "startCol": 1, + "startLine": 3, + "startOffset": 23 + }, + "text": "This oath's only section is referenced from another one, so it contributes no\nstandalone example of its own." + } + ], + "precededByDelimiter": true, + "scopeStack": [ + "Shared world states" + ], + "span": { + "endCol": 31, + "endLine": 4, + "endOffset": 131, + "startCol": 1, + "startLine": 3, + "startOffset": 23 + } + }, + { + "body": [ + { + "kind": "paragraph", + "segmentMap": [ + { + "sourceOffset": 155, + "textOffset": 0 + } + ], + "span": { + "endCol": 18, + "endLine": 8, + "endOffset": 172, + "startCol": 1, + "startLine": 8, + "startOffset": 155 + }, + "text": "I shelve 3 books." + } + ], + "precededByDelimiter": true, + "scopeStack": [ + "Shared world states", + "A stocked library" + ], + "span": { + "endCol": 18, + "endLine": 8, + "endOffset": 172, + "startCol": 1, + "startLine": 8, + "startOffset": 155 + } + } + ], + "orphanAttachments": [], + "path": "example.md" +} diff --git a/conformance/bundles/21-reference-consumed/golden/plan.json b/conformance/bundles/21-reference-consumed/golden/plan.json new file mode 100644 index 00000000..86493ac2 --- /dev/null +++ b/conformance/bundles/21-reference-consumed/golden/plan.json @@ -0,0 +1,4 @@ +{ + "diagnostics": [], + "examples": [] +} diff --git a/conformance/bundles/21-reference-consumed/golden/registry.json b/conformance/bundles/21-reference-consumed/golden/registry.json new file mode 100644 index 00000000..aa29f085 --- /dev/null +++ b/conformance/bundles/21-reference-consumed/golden/registry.json @@ -0,0 +1,21 @@ +{ + "parameterTypes": [], + "steps": [ + { + "expression": "I shelve {int} books", + "parameterTypeNames": [ + "int" + ] + }, + { + "expression": "I borrow a book", + "parameterTypeNames": [] + }, + { + "expression": "The shelf holds {int} books", + "parameterTypeNames": [ + "int" + ] + } + ] +} diff --git a/conformance/bundles/21-reference-consumed/golden/trace.json b/conformance/bundles/21-reference-consumed/golden/trace.json new file mode 100644 index 00000000..89b1c4ba --- /dev/null +++ b/conformance/bundles/21-reference-consumed/golden/trace.json @@ -0,0 +1,3 @@ +{ + "examples": [] +} diff --git a/conformance/bundles/21-reference-consumed/library.steps.cs b/conformance/bundles/21-reference-consumed/library.steps.cs new file mode 100644 index 00000000..a29554f2 --- /dev/null +++ b/conformance/bundles/21-reference-consumed/library.steps.cs @@ -0,0 +1,28 @@ +// C# sibling of library.steps.ts / .rs (bundle 21-reference-consumed). +using Varar; +using Varar.Core; + +namespace Varar.Corpus.B21; + +public static class LibrarySteps +{ + public static void Register(Steps s) + { + s.Stimulus( + "I shelve {int} books", + (state, n) => Value.Map([new("shelf", Value.Of(ShelfOf(state) + AsLong(n)))])); + + s.Stimulus( + "I borrow a book", + state => Value.Map([new("shelf", Value.Of(ShelfOf(state) - 1))])); + + s.Sensor("The shelf holds {int} books", (state, n) => Value.Of(ShelfOf(state))); + } + + public static Value State() => Value.Map([new("shelf", Value.Of(0))]); + + private static long ShelfOf(Value state) => + state is VMap m && m.Entries.TryGetValue("shelf", out var v) && v is VInt i ? i.Int : 0; + + private static long AsLong(Value v) => v is VInt i ? i.Int : 0; +} diff --git a/conformance/bundles/21-reference-consumed/library.steps.go b/conformance/bundles/21-reference-consumed/library.steps.go new file mode 100644 index 00000000..85420eb4 --- /dev/null +++ b/conformance/bundles/21-reference-consumed/library.steps.go @@ -0,0 +1,31 @@ +// Go sibling of library.steps.ts (bundle 21-reference-consumed). +package fixture + +import "github.com/varar-dev/varar/go/varar" + +func shelfOf(state varar.Value) int { + if m, ok := state.AsMap(); ok { + if c, ok := m["shelf"]; ok { + if n, ok := c.AsInt(); ok { + return int(n) + } + } + } + return 0 +} + +func Register(s *varar.Steps[varar.Value]) { + s.Stimulus("I shelve {int} books", func(state varar.Value, n int) (varar.Value, error) { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(int64(shelfOf(state) + n))}), nil + }) + s.Stimulus("I borrow a book", func(state varar.Value) (varar.Value, error) { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(int64(shelfOf(state) - 1))}), nil + }) + s.Sensor("The shelf holds {int} books", func(state varar.Value, expected int) (int, error) { + return shelfOf(state), nil + }) +} + +func State() varar.Value { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(0)}) +} diff --git a/conformance/bundles/21-reference-consumed/library.steps.kt b/conformance/bundles/21-reference-consumed/library.steps.kt new file mode 100644 index 00000000..b950b319 --- /dev/null +++ b/conformance/bundles/21-reference-consumed/library.steps.kt @@ -0,0 +1,17 @@ +@file:JvmName("LibrarySteps") + +// Kotlin sibling of library.steps.ts / library.steps.py / LibrarySteps.java +// (bundle 21-reference-consumed). +package dev.varar.kotlin.conformance.bundle21 + +import dev.varar.kotlin.stimulus +import dev.varar.kotlin.steps +import dev.varar.kotlin.sensor + +data class Ctx(val shelf: Int = 0) + +val steps = steps(::Ctx) { + stimulus("I shelve {int} books") { n: Int -> copy(shelf = shelf + n) } + stimulus("I borrow a book") { copy(shelf = shelf - 1) } + sensor("The shelf holds {int} books") { n: Int -> shelf } +} diff --git a/conformance/bundles/21-reference-consumed/library.steps.py b/conformance/bundles/21-reference-consumed/library.steps.py new file mode 100644 index 00000000..56de56e8 --- /dev/null +++ b/conformance/bundles/21-reference-consumed/library.steps.py @@ -0,0 +1,18 @@ +from varar import steps + +param, stimulus, sensor = steps(lambda: {"shelf": 0}) + + +@stimulus("I shelve {int} books") +def _(state, n): + return {"shelf": state["shelf"] + n} + + +@stimulus("I borrow a book") +def _(state): + return {"shelf": state["shelf"] - 1} + + +@sensor("The shelf holds {int} books") +def _(state, n): + return state["shelf"] diff --git a/conformance/bundles/21-reference-consumed/library.steps.rb b/conformance/bundles/21-reference-consumed/library.steps.rb new file mode 100644 index 00000000..a575fdd1 --- /dev/null +++ b/conformance/bundles/21-reference-consumed/library.steps.rb @@ -0,0 +1,9 @@ +require "varar" + +steps(-> { { shelf: 0 } }) do + stimulus("I shelve {int} books") { |state, n| { shelf: state[:shelf] + n } } + + stimulus("I borrow a book") { |state| { shelf: state[:shelf] - 1 } } + + sensor("The shelf holds {int} books") { |state, _n| state[:shelf] } +end diff --git a/conformance/bundles/21-reference-consumed/library.steps.rs b/conformance/bundles/21-reference-consumed/library.steps.rs new file mode 100644 index 00000000..23058754 --- /dev/null +++ b/conformance/bundles/21-reference-consumed/library.steps.rs @@ -0,0 +1,26 @@ +//! Rust sibling of `library.steps.ts` (bundle `21-reference-consumed`). + +use varar::Steps; + +#[derive(Clone, Default)] +pub struct Ctx { + pub shelf: i64, +} + +pub fn register(s: &mut Steps) { + s.stimulus("I shelve {int} books", |ctx: Ctx, n: i64| { + Ok(Ctx { + shelf: ctx.shelf + n, + }) + }); + s.stimulus("I borrow a book", |ctx: Ctx| { + Ok(Ctx { + shelf: ctx.shelf - 1, + }) + }); + s.sensor("The shelf holds {int} books", |ctx: Ctx, _expected: i64| Ok(ctx.shelf)); +} + +pub fn state() -> Ctx { + Ctx::default() +} diff --git a/conformance/bundles/21-reference-consumed/library.steps.ts b/conformance/bundles/21-reference-consumed/library.steps.ts new file mode 100644 index 00000000..3fce1e0e --- /dev/null +++ b/conformance/bundles/21-reference-consumed/library.steps.ts @@ -0,0 +1,9 @@ +import { steps } from '@varar/varar' + +const { stimulus, sensor } = steps<{ shelf: number }>(() => ({ shelf: 0 })) + +stimulus('I shelve {int} books', (state, n) => ({ shelf: state.shelf + n })) + +stimulus('I borrow a book', (state) => ({ shelf: state.shelf - 1 })) + +sensor('The shelf holds {int} books', (state) => state.shelf) diff --git a/conformance/bundles/21-reference-consumed/shared.md b/conformance/bundles/21-reference-consumed/shared.md new file mode 100644 index 00000000..5647d645 --- /dev/null +++ b/conformance/bundles/21-reference-consumed/shared.md @@ -0,0 +1,5 @@ +# Borrowing + +[A stocked library](./example.md#a-stocked-library) + +I borrow a book. The shelf holds 2 books. diff --git a/dotnet/Varar.Core.Tests/DriftTests.cs b/dotnet/Varar.Core.Tests/DriftTests.cs index 3014064c..7392a751 100644 --- a/dotnet/Varar.Core.Tests/DriftTests.cs +++ b/dotnet/Varar.Core.Tests/DriftTests.cs @@ -60,15 +60,15 @@ private static (string Name, int Line)[] Bare(ImmutableArray drifts) => private static ImmutableArray DetectFor(string source, Registry baselineReg, Registry currentReg) { var doc = Parse.Run("w.md", source); - var baseline = DriftDetection.DeriveOathBaseline(source, doc, Plan.Run(doc, baselineReg)); - return DriftDetection.DetectDrift(baseline, doc, Plan.Run(doc, currentReg)); + var baseline = DriftDetection.DeriveOathBaseline(source, doc, Plan.Run(doc, baselineReg, Reference_.EmptyWorkspace())); + return DriftDetection.DetectDrift(baseline, doc, Plan.Run(doc, currentReg, Reference_.EmptyWorkspace())); } [Fact] public void LiveExamplesRecordsOneEntryPerExampleProducingParagraph() { var doc = Parse.Run("w.md", "I withdraw 40."); - var examples = DriftDetection.LiveExamples(doc, Plan.Run(doc, Reg())); + var examples = DriftDetection.LiveExamples(doc, Plan.Run(doc, Reg(), Reference_.EmptyWorkspace())); Assert.Equal(new[] { new BaselineExample("I withdraw 40", 1) }, examples); } @@ -76,7 +76,7 @@ public void LiveExamplesRecordsOneEntryPerExampleProducingParagraph() public void ANeverMatchedParagraphIsNotRecorded() { var doc = Parse.Run("w.md", "Just some prose."); - Assert.Empty(DriftDetection.LiveExamples(doc, Plan.Run(doc, Reg()))); + Assert.Empty(DriftDetection.LiveExamples(doc, Plan.Run(doc, Reg(), Reference_.EmptyWorkspace()))); } [Fact] @@ -84,7 +84,7 @@ public void DeriveOathBaselineCarriesTheSourceFingerprint() { const string source = "I withdraw 40."; var doc = Parse.Run("w.md", source); - var baseline = DriftDetection.DeriveOathBaseline(source, doc, Plan.Run(doc, Reg())); + var baseline = DriftDetection.DeriveOathBaseline(source, doc, Plan.Run(doc, Reg(), Reference_.EmptyWorkspace())); Assert.Equal(Hash.HashSource(source), baseline.SourceHash); Assert.Equal(new[] { new BaselineExample("I withdraw 40", 1) }, baseline.Examples); } @@ -93,7 +93,7 @@ public void DeriveOathBaselineCarriesTheSourceFingerprint() public void NoBaselineMeansNoDrift() { var doc = Parse.Run("w.md", "I withdraw 40."); - Assert.Empty(DriftDetection.DetectDrift(null, doc, Plan.Run(doc, Reg()))); + Assert.Empty(DriftDetection.DetectDrift(null, doc, Plan.Run(doc, Reg(), Reference_.EmptyWorkspace()))); } [Fact] @@ -108,9 +108,9 @@ public void ARenamedStepDefinitionDrifts() => public void AnInPlaceTypoDrifts() { var beforeDoc = Parse.Run("w.md", "I withdraw 40."); - var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.", beforeDoc, Plan.Run(beforeDoc, Reg())); + var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.", beforeDoc, Plan.Run(beforeDoc, Reg(), Reference_.EmptyWorkspace())); var afterDoc = Parse.Run("w.md", "I withdrraw 40."); - var drift = DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg())); + var drift = DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg(), Reference_.EmptyWorkspace())); Assert.Equal(new[] { ("I withdraw 40", 1) }, Bare(drift)); } @@ -118,54 +118,54 @@ public void AnInPlaceTypoDrifts() public void ADeletedParagraphIsNotDrift() { var beforeDoc = Parse.Run("w.md", "I withdraw 40."); - var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.", beforeDoc, Plan.Run(beforeDoc, Reg())); + var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.", beforeDoc, Plan.Run(beforeDoc, Reg(), Reference_.EmptyWorkspace())); var afterDoc = Parse.Run("w.md", string.Empty); - Assert.Empty(DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg()))); + Assert.Empty(DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg(), Reference_.EmptyWorkspace()))); } [Fact] public void ANewProseParagraphIsNotDrift() { var beforeDoc = Parse.Run("w.md", "I withdraw 40."); - var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.", beforeDoc, Plan.Run(beforeDoc, Reg())); + var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.", beforeDoc, Plan.Run(beforeDoc, Reg(), Reference_.EmptyWorkspace())); var afterDoc = Parse.Run("w.md", "I withdraw 40.\n\nSome new narration."); - Assert.Empty(DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg()))); + Assert.Empty(DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg(), Reference_.EmptyWorkspace()))); } [Fact] public void MovingAnExampleNeverDrifts() { var beforeDoc = Parse.Run("w.md", "I withdraw 40.\n\nI withdraw 10."); - var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.\n\nI withdraw 10.", beforeDoc, Plan.Run(beforeDoc, Reg())); + var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.\n\nI withdraw 10.", beforeDoc, Plan.Run(beforeDoc, Reg(), Reference_.EmptyWorkspace())); var afterDoc = Parse.Run("w.md", "I withdraw 10.\n\nI withdraw 40."); - Assert.Empty(DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg()))); + Assert.Empty(DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg(), Reference_.EmptyWorkspace()))); } [Fact] public void MovingAndRewordingAStillMatchingExampleDoesNotDrift() { var beforeDoc = Parse.Run("w.md", "I withdraw 40.\n\nI withdraw 10."); - var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.\n\nI withdraw 10.", beforeDoc, Plan.Run(beforeDoc, Reg())); + var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.\n\nI withdraw 10.", beforeDoc, Plan.Run(beforeDoc, Reg(), Reference_.EmptyWorkspace())); var afterDoc = Parse.Run("w.md", "I withdraw 11.\n\nI withdraw 40."); - Assert.Empty(DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg()))); + Assert.Empty(DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg(), Reference_.EmptyWorkspace()))); } [Fact] public void MoveRewordAndProseOnOldLineDoesNotFalsePositive() { var beforeDoc = Parse.Run("w.md", "I withdraw 40."); - var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.", beforeDoc, Plan.Run(beforeDoc, Reg())); + var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.", beforeDoc, Plan.Run(beforeDoc, Reg(), Reference_.EmptyWorkspace())); var afterDoc = Parse.Run("w.md", "Just some notes.\n\nI withdraw 41."); - Assert.Empty(DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg()))); + Assert.Empty(DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg(), Reference_.EmptyWorkspace()))); } [Fact] public void AParagraphRewrittenPastRecognitionIsNotDrift() { var beforeDoc = Parse.Run("w.md", "I withdraw 40."); - var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.", beforeDoc, Plan.Run(beforeDoc, Reg())); + var baseline = DriftDetection.DeriveOathBaseline("I withdraw 40.", beforeDoc, Plan.Run(beforeDoc, Reg(), Reference_.EmptyWorkspace())); var afterDoc = Parse.Run("w.md", "The branch closed years ago."); - Assert.Empty(DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg()))); + Assert.Empty(DriftDetection.DetectDrift(baseline, afterDoc, Plan.Run(afterDoc, Reg(), Reference_.EmptyWorkspace()))); } [Fact] @@ -173,7 +173,7 @@ public void AHeaderBoundTableRecordsItsBindingParagraphOnce() { const string source = "Each row gives a decimal and a roman number:\n\n| decimal | roman |\n| ------: | :---- |\n| 3 | III |\n| 9 | IX |\n"; var doc = Parse.Run("r.md", source); - var examples = DriftDetection.LiveExamples(doc, Plan.Run(doc, RomanReg())); + var examples = DriftDetection.LiveExamples(doc, Plan.Run(doc, RomanReg(), Reference_.EmptyWorkspace())); Assert.Equal(new[] { new BaselineExample("Each row gives a decimal and a roman number:", 1) }, examples); } @@ -182,8 +182,8 @@ public void AHeaderBoundBindingParagraphThatStopsMatchingDrifts() { const string source = "Each row gives a decimal and a roman number:\n\n| decimal | roman |\n| ------: | :---- |\n| 3 | III |\n| 9 | IX |\n"; var doc = Parse.Run("r.md", source); - var baseline = DriftDetection.DeriveOathBaseline(source, doc, Plan.Run(doc, RomanReg(true))); - var drift = DriftDetection.DetectDrift(baseline, doc, Plan.Run(doc, RomanReg(false))); + var baseline = DriftDetection.DeriveOathBaseline(source, doc, Plan.Run(doc, RomanReg(true), Reference_.EmptyWorkspace())); + var drift = DriftDetection.DetectDrift(baseline, doc, Plan.Run(doc, RomanReg(false), Reference_.EmptyWorkspace())); Assert.Equal(new[] { ("Each row gives a decimal and a roman number:", 1) }, Bare(drift)); } @@ -192,8 +192,8 @@ public void ADriftCarriesTheDriftedParagraphSpan() { const string source = "Some prose first.\n\nI withdraw 40."; var doc = Parse.Run("w.md", source); - var baseline = DriftDetection.DeriveOathBaseline(source, doc, Plan.Run(doc, Reg(true))); - var drift = DriftDetection.DetectDrift(baseline, doc, Plan.Run(doc, Reg(false)))[0]; + var baseline = DriftDetection.DeriveOathBaseline(source, doc, Plan.Run(doc, Reg(true), Reference_.EmptyWorkspace())); + var drift = DriftDetection.DetectDrift(baseline, doc, Plan.Run(doc, Reg(false), Reference_.EmptyWorkspace()))[0]; Assert.Equal(3, drift.Line); Assert.Equal(3, drift.Span.StartLine); Assert.Equal("I withdraw 40.", source.Substring(drift.Span.StartOffset, drift.Span.EndOffset - drift.Span.StartOffset)); @@ -217,7 +217,7 @@ public void ReconcileDriftRecordsABaselineOnFirstRunAndReportsNoDrift() const string source = "I withdraw 40."; var doc = Parse.Run("w.md", source); var store = new MemoryStore(); - var drifts = DriftDetection.ReconcileDrift(store, "w.md", source, doc, Plan.Run(doc, Reg())); + var drifts = DriftDetection.ReconcileDrift(store, "w.md", source, doc, Plan.Run(doc, Reg(), Reference_.EmptyWorkspace())); Assert.Empty(drifts); var lockFile = DriftDetection.ParseLockFile(store.Contents ?? string.Empty); Assert.Equal(new[] { new BaselineExample("I withdraw 40", 1) }, lockFile!.Oaths["w.md"].Examples); @@ -229,9 +229,9 @@ public void ReconcileDriftReportsDriftAndPreservesTheBaseline() const string source = "I withdraw 40."; var doc = Parse.Run("w.md", source); var store = new MemoryStore(); - DriftDetection.ReconcileDrift(store, "w.md", source, doc, Plan.Run(doc, Reg(true))); + DriftDetection.ReconcileDrift(store, "w.md", source, doc, Plan.Run(doc, Reg(true), Reference_.EmptyWorkspace())); var before = store.Contents; - var drifts = DriftDetection.ReconcileDrift(store, "w.md", source, doc, Plan.Run(doc, Reg(false))); + var drifts = DriftDetection.ReconcileDrift(store, "w.md", source, doc, Plan.Run(doc, Reg(false), Reference_.EmptyWorkspace())); Assert.Equal(new[] { ("I withdraw 40", 1) }, Bare(drifts)); Assert.Equal(before, store.Contents); // baseline untouched while drift is unacknowledged } @@ -242,8 +242,8 @@ public void ReconcileDriftInUpdateModeAcceptsDriftAndReRecords() const string source = "I withdraw 40."; var doc = Parse.Run("w.md", source); var store = new MemoryStore(); - DriftDetection.ReconcileDrift(store, "w.md", source, doc, Plan.Run(doc, Reg(true))); - var drifts = DriftDetection.ReconcileDrift(store, "w.md", source, doc, Plan.Run(doc, Reg(false)), update: true); + DriftDetection.ReconcileDrift(store, "w.md", source, doc, Plan.Run(doc, Reg(true), Reference_.EmptyWorkspace())); + var drifts = DriftDetection.ReconcileDrift(store, "w.md", source, doc, Plan.Run(doc, Reg(false), Reference_.EmptyWorkspace()), update: true); Assert.Empty(drifts); Assert.Empty(DriftDetection.ParseLockFile(store.Contents ?? string.Empty)!.Oaths["w.md"].Examples); } @@ -276,7 +276,7 @@ public void TwoParagraphsThatMergeIntoOneExampleAreEachRecordedAsALiveBaselineEn { const string source = "I deposit 100.\n\nI withdraw 40."; var doc = Parse.Run("w.md", source); - var plan = Plan.Run(doc, DepositWithdrawReg()); + var plan = Plan.Run(doc, DepositWithdrawReg(), Reference_.EmptyWorkspace()); // One planned example (the two paragraphs merged), but two live entries. Assert.Single(plan.Examples); @@ -290,11 +290,11 @@ public void DeletingOneStepDefOfAMergedExampleDriftsOnlyTheNowProseParagraph() { const string source = "I deposit 100.\n\nI withdraw 40."; var doc = Parse.Run("w.md", source); - var baseline = DriftDetection.DeriveOathBaseline(source, doc, Plan.Run(doc, DepositWithdrawReg(true))); + var baseline = DriftDetection.DeriveOathBaseline(source, doc, Plan.Run(doc, DepositWithdrawReg(true), Reference_.EmptyWorkspace())); // The deposit step is gone: its paragraph becomes prose, splitting the example. The withdraw // paragraph is still live; the deposit one drifts. - var drift = DriftDetection.DetectDrift(baseline, doc, Plan.Run(doc, DepositWithdrawReg(false))); + var drift = DriftDetection.DetectDrift(baseline, doc, Plan.Run(doc, DepositWithdrawReg(false), Reference_.EmptyWorkspace())); Assert.Equal(new[] { ("I deposit 100", 1) }, Bare(drift)); } @@ -316,7 +316,7 @@ private static string LockWithStalePath() { const string source = "I withdraw 40."; var doc = Parse.Run("w.md", source); - var baseline = DriftDetection.DeriveOathBaseline(source, doc, Plan.Run(doc, Reg())); + var baseline = DriftDetection.DeriveOathBaseline(source, doc, Plan.Run(doc, Reg(), Reference_.EmptyWorkspace())); return DriftDetection.StringifyLockFile(new LockFile( 2, ImmutableDictionary.Empty diff --git a/dotnet/Varar.Core.Tests/FailureTests.cs b/dotnet/Varar.Core.Tests/FailureTests.cs index 6d063b89..33a2f188 100644 --- a/dotnet/Varar.Core.Tests/FailureTests.cs +++ b/dotnet/Varar.Core.Tests/FailureTests.cs @@ -26,7 +26,7 @@ private static Exception RunFailingExample() 2, (_, _) => throw new InvalidOperationException("expected the library to refuse"), StepKind.Sensor)); - var plan = Plan.Run(Parse.Run("l.md", Source), r); + var plan = Plan.Run(Parse.Run("l.md", Source), r, Reference_.EmptyWorkspace()); var failure = Execute.RunExample(plan, plan.Examples[0], _ => Value.Null, []); Assert.NotNull(failure); return failure!; diff --git a/dotnet/Varar.Core.Tests/PlanTests.cs b/dotnet/Varar.Core.Tests/PlanTests.cs index fff9ddce..e81b6786 100644 --- a/dotnet/Varar.Core.Tests/PlanTests.cs +++ b/dotnet/Varar.Core.Tests/PlanTests.cs @@ -18,7 +18,7 @@ private static Registry Reg() } private static ExecutionPlan PlanFor(string source, Registry registry) => - Plan.Run(Parse.Run("m.md", source), registry); + Plan.Run(Parse.Run("m.md", source), registry, Reference_.EmptyWorkspace()); private static string[][] StepTexts(ExecutionPlan plan) => plan.Examples.Select(e => e.Steps.Select(s => s.Text).ToArray()).ToArray(); diff --git a/dotnet/Varar.Core.Tests/RunnerTests.cs b/dotnet/Varar.Core.Tests/RunnerTests.cs index 9854d820..93d851d2 100644 --- a/dotnet/Varar.Core.Tests/RunnerTests.cs +++ b/dotnet/Varar.Core.Tests/RunnerTests.cs @@ -93,7 +93,7 @@ public void FileBaselineStoreRoundTrips() [Fact] public void PlanOathAndRunExamplePassAMatchingExample() { - var plan = Runner.Runner.PlanOath("w.md", "I withdraw 40.", WithdrawReg()); + var plan = Runner.Runner.PlanOath("w.md", "I withdraw 40.", WithdrawReg(), Reference_.EmptyWorkspace()); var failure = Runner.Runner.RunExample(plan, _ => Value.Null, 0); Assert.Null(failure); } @@ -104,7 +104,7 @@ public void ExampleNamesDeduplicateSharedBaseNames() // Two examples under the same innermost heading get a [1] suffix on the second. A `---` // delimiter keeps them as two examples (ADR 0012 — adjacent matching paragraphs otherwise // merge into one). - var plan = Runner.Runner.PlanOath("w.md", "# Withdrawals\n\nI withdraw 40.\n\n---\n\nI withdraw 10.", WithdrawReg()); + var plan = Runner.Runner.PlanOath("w.md", "# Withdrawals\n\nI withdraw 40.\n\n---\n\nI withdraw 10.", WithdrawReg(), Reference_.EmptyWorkspace()); var names = Runner.Runner.ExampleNames(plan); Assert.Equal(new[] { "Withdrawals", "Withdrawals[1]" }, names); } @@ -121,13 +121,13 @@ public void ReconcileDriftPersistsAndDetectsThroughTheFileStore() var store = new FileBaselineStore(root); // First run records the baseline and reports no drift. - var first = DriftDetection.ReconcileDrift(store, "w.md", source, doc, Runner.Runner.PlanOath("w.md", source, WithdrawReg(true))); + var first = DriftDetection.ReconcileDrift(store, "w.md", source, doc, Runner.Runner.PlanOath("w.md", source, WithdrawReg(true), Reference_.EmptyWorkspace())); Assert.Empty(first); Assert.True(File.Exists(Path.Combine(root, "varar.lock.json"))); // The step is gone — same source drifts, and the baseline is preserved on disk. var before = store.Read(); - var drifts = DriftDetection.ReconcileDrift(store, "w.md", source, doc, Runner.Runner.PlanOath("w.md", source, WithdrawReg(false))); + var drifts = DriftDetection.ReconcileDrift(store, "w.md", source, doc, Runner.Runner.PlanOath("w.md", source, WithdrawReg(false), Reference_.EmptyWorkspace())); Assert.Single(drifts); Assert.Equal("I withdraw 40", drifts[0].Name); Assert.Equal(before, store.Read()); diff --git a/dotnet/Varar.Core/Conformance.cs b/dotnet/Varar.Core/Conformance.cs index cd23fdfe..d000a0fb 100644 --- a/dotnet/Varar.Core/Conformance.cs +++ b/dotnet/Varar.Core/Conformance.cs @@ -144,10 +144,18 @@ private static Value PlannedStepValue(PlannedStep step, string source) new("matchSpan", SpanValue(step.MatchSpan)), new("paramSpans", List(step.ParamSpans, SpanValue)), new("matchedExpression", Value.Of(step.StepDef.Expression)), - new("args", Value.List(step.ParamSpans.Select((span, i) => Map( - ("value", Value.Of(Scanner.Slice(source, span.StartOffset, span.EndOffset))), + new("args", Value.List(step.ParamTexts.Select((text, i) => Map( + ("value", Value.Of(text)), ("parameterType", i < typeNames.Length ? Value.Of(typeNames[i]) : Value.Null))))), }; + + // Present only on a step a reference block spliced in from another oath (ADR 0016): the + // document its spans belong to. + if (step.DocPath is not null) + { + entries.Add(new("docPath", Value.Of(step.DocPath))); + } + if (step.DataTable is not null) { entries.Add(new("dataTable", BlockValue(step.DataTable))); diff --git a/dotnet/Varar.Core/Diagnostics.cs b/dotnet/Varar.Core/Diagnostics.cs index d8046704..38064f1c 100644 --- a/dotnet/Varar.Core/Diagnostics.cs +++ b/dotnet/Varar.Core/Diagnostics.cs @@ -13,6 +13,14 @@ public enum DiagnosticCode AmbiguousMatch, ErrorFenceWithoutStep, Drift, + + /// + /// Reference blocks (ADR 0016): a link that resolves to no oath, to a section with no steps, + /// or to a chain that reaches itself. + /// + ReferenceNotFound, + ReferenceEmpty, + ReferenceCycle, } /// A diagnostic on the shared rail. Port of diagnostics.ts. @@ -34,6 +42,9 @@ public static class Diagnostics DiagnosticCode.AmbiguousMatch => "ambiguous-match", DiagnosticCode.ErrorFenceWithoutStep => "error-fence-without-step", DiagnosticCode.Drift => "drift", + DiagnosticCode.ReferenceNotFound => "reference-not-found", + DiagnosticCode.ReferenceEmpty => "reference-empty", + DiagnosticCode.ReferenceCycle => "reference-cycle", _ => throw new ArgumentOutOfRangeException(nameof(code), code, null), }; @@ -59,4 +70,38 @@ public static Diagnostic AmbiguousMatch(string text, Span span, ImmutableArray + /// A reference block (ADR 0016) points at an oath the workspace does not hold. Never prose: a + /// link-only block that resolves to nothing has no other reading, so it fails the run rather + /// than degrading silently. + /// + public static Diagnostic ReferenceNotFound(string text, string path, Span span) => new( + Severity.Error, + DiagnosticCode.ReferenceNotFound, + $"Reference to \"{text}\" points at \"{path}\", which is not an oath in this workspace.\n" + + "Check the path, and that the file is matched by the `docs` globs in varar.config.json.", + span); + + /// + /// The referenced document exists but the section contributes no steps — a mistyped anchor, or + /// a section that is pure prose. + /// + public static Diagnostic ReferenceEmpty(string text, string path, string slug, Span span) => new( + Severity.Error, + DiagnosticCode.ReferenceEmpty, + $"Reference to \"{text}\" resolves to \"{(slug.Length == 0 ? path : $"{path}#{slug}")}\", " + + "which contributes no steps.\nCheck the heading the anchor names, and that its section " + + "contains a matching paragraph.", + span); + + /// + /// References nest to any depth, so a chain that reaches a section already on it is reported + /// rather than recursed into. + /// + public static Diagnostic ReferenceCycle(IEnumerable chain, Span span) => new( + Severity.Error, + DiagnosticCode.ReferenceCycle, + $"Reference cycle: {string.Join(" \u2192 ", chain)}.", + span); } diff --git a/dotnet/Varar.Core/Plan.cs b/dotnet/Varar.Core/Plan.cs index cbbc9f3a..4db95ff6 100644 --- a/dotnet/Varar.Core/Plan.cs +++ b/dotnet/Varar.Core/Plan.cs @@ -6,6 +6,14 @@ namespace Varar.Core; /// A doc string attached to a step (content includes the trailing newline). public sealed record DocString(string Content, string ContentType, Span Span); +/// +/// The notation each parameter matched, sliced at plan time from the document the step was WRITTEN +/// in. Consumers must use this rather than slicing the running oath's source: a step a reference +/// block spliced in (ADR 0016) has spans in a different document. +/// +/// +/// Set only on such a spliced step: the document its spans belong to. Null means the example's own. +/// public sealed record PlannedStep( string Text, Span MatchSpan, @@ -14,7 +22,9 @@ public sealed record PlannedStep( ImmutableArray Args, ImmutableArray Formats, Table? DataTable = null, - DocString? DocString = null); + DocString? DocString = null, + ImmutableArray ParamTexts = default, + string? DocPath = null); public sealed record HeaderBinding( Span MatchSpan, @@ -40,12 +50,21 @@ public sealed record ExecutionPlan( /// Matching + planning. Port of plan.ts. public static class Plan { - public static ExecutionPlan Run(Doc doc, Registry registry) + public static ExecutionPlan Run(Doc doc, Registry registry, OathWorkspace workspace) { var diagnostics = ImmutableArray.CreateBuilder(); + // A section another oath references stops being a standalone example: it runs where it is + // referenced, not here (ADR 0016). + bool Consumed(Example ex) => + workspace.Referenced.Contains(Reference_.SectionKey(doc.Path, string.Empty)) + || ex.ScopeStack.Any(h => workspace.Referenced.Contains(Reference_.SectionKey(doc.Path, Reference_.Slugify(h)))); + // Phase 1: plan each candidate paragraph independently into a "unit". - var units = doc.Examples.Select(ex => PlanCandidate(ex, doc, registry, diagnostics)).ToList(); + var units = doc.Examples + .Where(ex => !Consumed(ex)) + .Select(ex => PlanCandidate(ex, doc, registry, diagnostics)) + .ToList(); // Phase 2: group adjacent candidates into examples. A matching candidate continues the open // example when no delimiter (heading / `---`) precedes it; otherwise it starts a new one. A @@ -74,13 +93,40 @@ void Flush() examples.AddRange(hb.Rows); break; + case ReferenceUnit ru: + { + // Splice the referenced section's steps in at this position. Only the reference + // block itself is subject to the delimiter rule; everything it splices in + // belongs to the same sequence, so a section of several paragraphs stays one + // example. + var resolved = ResolveReference(ru, doc, registry, workspace, diagnostics, []); + for (int i = 0; i < resolved.Count; i++) + { + if (open is not null && (i > 0 || !ru.PrecededByDelimiter)) + { + MergeInto(open, resolved[i], fromReference: true); + } + else + { + Flush(); + open = StartMerged(resolved[i]); + + // An example that OPENS with a reference is named by its own first + // matching paragraph, not by the section it pulls in. + open.NameFromReference = true; + } + } + + break; + } + case StepsUnit { Matched: false }: // Prose paragraph — a delimiter. Drop it and end the open example. Flush(); break; case StepsUnit su when open is not null && !su.PrecededByDelimiter: - MergeInto(open, su); + MergeInto(open, su, fromReference: false); break; case StepsUnit su: @@ -103,7 +149,7 @@ private sealed class MergedExample { public required string Name { get; set; } - public required ImmutableArray ScopeStack { get; init; } + public required ImmutableArray ScopeStack { get; set; } public required int StartOffset { get; init; } @@ -114,6 +160,12 @@ private sealed class MergedExample public bool ExpectedFail { get; set; } public string? ExpectedErrorMessage { get; set; } + + /// + /// True while the name came from a spliced (referenced) paragraph and is waiting to be + /// replaced by the example's own first matching paragraph. + /// + public bool NameFromReference { get; set; } } // One candidate paragraph, planned in isolation. @@ -121,6 +173,13 @@ private abstract record CandidateUnit; private sealed record HeaderBoundUnit(ImmutableArray Rows) : CandidateUnit; + // A reference block: its whole text is a link to an oath section, whose steps are spliced in + // here (ADR 0016). Never prose, so it does not close the open example. + private sealed record ReferenceUnit( + Reference Reference, + bool PrecededByDelimiter, + Span Span) : CandidateUnit; + private sealed record StepsUnit( bool Matched, bool PrecededByDelimiter, @@ -142,8 +201,15 @@ private sealed record StepsUnit( ExpectedErrorMessage = unit.ExpectedErrorMessage, }; - private static void MergeInto(MergedExample open, StepsUnit unit) + private static void MergeInto(MergedExample open, StepsUnit unit, bool fromReference) { + if (open.NameFromReference && !fromReference) + { + open.Name = unit.Name; + open.ScopeStack = unit.ScopeStack; + open.NameFromReference = false; + } + open.EndOffset = unit.Span.EndOffset; open.Steps.AddRange(unit.Steps); @@ -169,12 +235,88 @@ private static void MergeInto(MergedExample open, StepsUnit unit) // Plan a single candidate paragraph (plus its attached tables/fences) in isolation. Emits // ambiguity / error-fence diagnostics into . + /// + /// Resolve one reference block into the step-bearing units of the section it names, + /// recursively: a referenced section may itself contain reference blocks, to any depth (ADR + /// 0016 leaves depth to the author's judgement). carries the sections + /// currently being resolved so a repeat is reported as a cycle instead of recursing forever. + /// + private static List ResolveReference( + ReferenceUnit unit, + Doc from, + Registry registry, + OathWorkspace workspace, + ImmutableArray.Builder diagnostics, + ImmutableList chain) + { + var reference = unit.Reference; + var key = Reference_.SectionKey(reference.Path, reference.Slug); + if (chain.Contains(key)) + { + diagnostics.Add(Varar.Core.Diagnostics.ReferenceCycle(chain.Add(key), unit.Span)); + return []; + } + + // A same-file reference resolves against the document being planned, which is not + // necessarily in the workspace. + Doc? target = reference.Path == from.Path + ? from + : workspace.Docs.TryGetValue(reference.Path, out var found) ? found : null; + if (target is null) + { + diagnostics.Add(Varar.Core.Diagnostics.ReferenceNotFound(reference.Text, reference.Path, unit.Span)); + return []; + } + + var result = new List(); + foreach (var candidate in Reference_.SectionCandidates(target, reference.Slug)) + { + switch (PlanCandidate(candidate, target, registry, diagnostics)) + { + case ReferenceUnit nested: + result.AddRange(ResolveReference(nested, target, registry, workspace, diagnostics, chain.Add(key))); + break; + + // A header-bound table produces one example per row, which a spliced step list + // cannot express; an `error` fence declares an outcome for an example, not for a + // reusable fragment. Both are left out. + case StepsUnit { Matched: true } planned: + result.Add(TagWithDoc(planned, target.Path, from.Path)); + break; + } + } + + if (result.Count == 0) + { + diagnostics.Add(Varar.Core.Diagnostics.ReferenceEmpty( + reference.Text, reference.Path, reference.Slug, unit.Span)); + } + + return result; + } + + // Carry the source document's identity on every spliced step, so a failure in a referenced + // section reports spans against the file they were written in rather than the file being run. + private static StepsUnit TagWithDoc(StepsUnit unit, string docPath, string hostPath) => + docPath == hostPath + ? unit + : unit with { Steps = [.. unit.Steps.Select(s => s with { DocPath = docPath })] }; + private static CandidateUnit PlanCandidate( Example ex, Doc doc, Registry registry, ImmutableArray.Builder diagnostics) { + // A block whose whole text is a link to an oath section is a reference, not content: never + // matched against step definitions, and never prose. + if (ex.Body.Length > 0 + && Reference_.TextOf(ex.Body[0]) is { } primaryText + && Reference_.ReferenceOf(primaryText, doc.Path) is { } reference) + { + return new ReferenceUnit(reference, ex.PrecededByDelimiter, ex.Span); + } + bool hadAmbiguous = false; // Pass 1: plan each text-bearing block, collecting steps per body index. @@ -208,7 +350,8 @@ [.. collision.Candidates [.. hit.ParamSpans.Select(p => LiftSpan(doc.Source, block, p.Start, p.End))], hit.StepDef, hit.Args, - hit.Formats)).ToList(); + hit.Formats, + ParamTexts: [.. hit.ParamSpans.Select(p => Scanner.Slice(blockText, p.Start, p.End))])).ToList(); } } diff --git a/dotnet/Varar.Core/Reference.cs b/dotnet/Varar.Core/Reference.cs new file mode 100644 index 00000000..02e704f8 --- /dev/null +++ b/dotnet/Varar.Core/Reference.cs @@ -0,0 +1,219 @@ +using System.Collections.Immutable; +using System.Text.RegularExpressions; + +namespace Varar.Core; + +/// +/// One resolved reference block: the referenced oath's path (resolved against the referring doc's +/// own path), the GFM slug of the heading (empty for a whole-file link), and the link's text. +/// +public sealed record Reference(string Path, string Slug, string Text); + +/// +/// What needs to resolve references: every oath by path, plus which sections +/// a reference block consumes somewhere in the project. A section that is referenced stops being a +/// standalone example, so this is whole-project knowledge — see ADR 0016 on why each runner builds +/// it at its once-per-run discovery pass. +/// +public sealed record OathWorkspace( + ImmutableDictionary Docs, + ImmutableHashSet Referenced); + +/// +/// Reuse is a link (ADR 0016). A candidate block whose entire content is a single Markdown link to +/// an oath section is a REFERENCE BLOCK: it splices that section's steps in at its own position +/// instead of being prose. +/// +/// Everything here is pure text and path arithmetic — no filesystem. The shell reads the documents; +/// tells it which ones to read, and turns the +/// collection into what the planner needs. +/// +/// +public static partial class Reference_ +{ + // A candidate is a reference block iff its whole text is one Markdown link whose target is + // oath-shaped. Anything else — a link with surrounding words, a link to https://…, to a .cs + // file, to a mailto: — is ordinary content, so existing documents keep their meaning. + [GeneratedRegex(@"^\[([^\]]*)\]\(\s*([^\s)]+)\s*\)$")] + private static partial Regex LinkOnly(); + + [GeneratedRegex(@"^[a-zA-Z][a-zA-Z0-9+.\-]*:")] + private static partial Regex Protocol(); + + [GeneratedRegex(@"[^\p{L}\p{N} _-]")] + private static partial Regex NotSlug(); + + [GeneratedRegex("`([^`]*)`")] + private static partial Regex CodeSpan(); + + [GeneratedRegex(@"\*\*([^*]*)\*\*")] + private static partial Regex Strong(); + + [GeneratedRegex(@"\*([^*]*)\*")] + private static partial Regex Emph(); + + [GeneratedRegex("_([^_]*)_")] + private static partial Regex Under(); + + /// The reference a block's text spells, or null when it is ordinary content. + public static Reference? ReferenceOf(string text, string fromPath) + { + var m = LinkOnly().Match(text.Trim()); + if (!m.Success) + { + return null; + } + + var linkText = m.Groups[1].Value; + var target = m.Groups[2].Value; + if (target.StartsWith('#')) + { + return new Reference(fromPath, NormalizeSlug(target[1..]), linkText); + } + + var hash = target.IndexOf('#'); + var filePart = hash == -1 ? target : target[..hash]; + var fragment = hash == -1 ? string.Empty : target[(hash + 1)..]; + + // Only a relative Markdown path is a reference. A protocol (https:, mailto:) or any other + // extension is left alone — remote references are deliberately out of scope (ADR 0016). + if (!filePart.EndsWith(".md", StringComparison.Ordinal) + || Protocol().IsMatch(filePart) + || filePart.StartsWith('/')) + { + return null; + } + + return new Reference(JoinPosix(DirnamePosix(fromPath), filePart), NormalizeSlug(fragment), linkText); + } + + /// + /// GitHub's heading anchors: inline markup dropped, lowercased, spaces to hyphens, everything + /// else that isn't a word character or hyphen removed. The same function produces the slug of a + /// heading and normalizes the slug written in a link, so the two meet in the middle. + /// + public static string Slugify(string headingText) + { + var s = CodeSpan().Replace(headingText, "$1"); + s = Strong().Replace(s, "$1"); + s = Emph().Replace(s, "$1"); + s = Under().Replace(s, "$1"); + return NormalizeSlug(s); + } + + // One hyphen per space, not per run of them: GitHub leaves the gap where it dropped + // punctuation, so "Fees, VAT & rounding" slugs with a double hyphen. + private static string NormalizeSlug(string s) => + NotSlug().Replace(s.Trim().ToLowerInvariant(), string.Empty).Replace(' ', '-'); + + private static string DirnamePosix(string path) + { + var i = path.LastIndexOf('/'); + return i == -1 ? string.Empty : path[..i]; + } + + /// + /// POSIX path arithmetic on oath paths (always '/'-separated, relative to the workspace root). + /// The core may not touch the filesystem. + /// + public static string JoinPosix(string dir, string rel) + { + var segments = dir.Length == 0 ? [] : new List(dir.Split('/')); + foreach (var segment in rel.Split('/')) + { + if (segment.Length == 0 || segment == ".") + { + continue; + } + + if (segment == "..") + { + if (segments.Count > 0) + { + segments.RemoveAt(segments.Count - 1); + } + } + else + { + segments.Add(segment); + } + } + + return string.Join("/", segments); + } + + /// + /// Every reference block in a document, in document order. The shell uses this to walk the + /// closure of documents it must read before planning. + /// + public static ImmutableArray References(Doc doc) + { + var out_ = ImmutableArray.CreateBuilder(); + foreach (var ex in doc.Examples) + { + if (ex.Body.Length == 0 || TextOf(ex.Body[0]) is not { } text) + { + continue; + } + + if (ReferenceOf(text, doc.Path) is { } reference) + { + out_.Add(reference); + } + } + + return out_.ToImmutable(); + } + + /// The text of a text-bearing block, or null for a table or fence. + public static string? TextOf(Block block) => block switch + { + Paragraph p => p.Text, + ListItem l => l.Text, + Blockquote b => b.Text, + _ => null, + }; + + /// A section's identity across the project. + public static string SectionKey(string path, string slug) => $"{path}#{slug}"; + + /// + /// The workspace with no references at all: what a caller planning a single document in + /// isolation passes. + /// + public static OathWorkspace EmptyWorkspace() => + new(ImmutableDictionary.Empty, ImmutableHashSet.Empty); + + /// Index every oath by path and record every consumed section. + public static OathWorkspace BuildWorkspace(IEnumerable docs) + { + var list = docs.ToList(); + var byPath = ImmutableDictionary.CreateBuilder(); + foreach (var doc in list) + { + byPath[doc.Path] = doc; + } + + var referenced = ImmutableHashSet.CreateBuilder(); + foreach (var doc in list) + { + foreach (var r in References(doc)) + { + referenced.Add(SectionKey(r.Path, r.Slug)); + } + } + + return new OathWorkspace(byPath.ToImmutable(), referenced.ToImmutable()); + } + + /// + /// The candidates that make up a section: those whose heading chain contains the slug. A + /// whole-file reference (empty slug) is every candidate in the document. Section membership + /// follows the document outline exactly — a heading's section runs until the next heading of the + /// same or higher level, which is precisely the range over which it stays on the scope stack. + /// + public static ImmutableArray SectionCandidates(Doc doc, string slug) => + slug.Length == 0 + ? doc.Examples + : [.. doc.Examples.Where(ex => ex.ScopeStack.Any(h => Slugify(h) == slug))]; +} diff --git a/dotnet/Varar.Runner/Runner.cs b/dotnet/Varar.Runner/Runner.cs index 2ea3ed85..9f75bb87 100644 --- a/dotnet/Varar.Runner/Runner.cs +++ b/dotnet/Varar.Runner/Runner.cs @@ -7,9 +7,13 @@ namespace Varar.Runner; /// Planning, running, load-steps, and failure rendering. Port of the runner run/render/steps. public static class Runner { - /// Parse + plan one oath. - public static ExecutionPlan PlanOath(string name, string source, Registry registry) => - Plan.Run(Parse.Run(name, source), registry); + /// + /// Plans one oath. carries every other oath in the project plus the + /// sections a reference block consumes (ADR 0016); it is required because an adapter that + /// omitted it would run consumed sections as standalone examples, which is green and wrong. + /// + public static ExecutionPlan PlanOath(string name, string source, Registry registry, OathWorkspace workspace) => + Plan.Run(Parse.Run(name, source), registry, workspace); /// /// Per-example display names: the innermost heading (or the body-derived name when there is no diff --git a/dotnet/Varar.TestAdapter/VararAdapter.cs b/dotnet/Varar.TestAdapter/VararAdapter.cs index 847f542e..71940250 100644 --- a/dotnet/Varar.TestAdapter/VararAdapter.cs +++ b/dotnet/Varar.TestAdapter/VararAdapter.cs @@ -83,6 +83,11 @@ internal static IEnumerable Discover( [.. oaths.Select(oath => Discovery.RelPosix(oath, workspace.Root))], update); + // Whether a section is a standalone example depends on whether another oath references it, + // which is whole-project knowledge (ADR 0016). Built from the config globs — the full set, + // for the same reason baseline pruning is. + var oathWorkspace = ProjectWorkspace(oaths, workspace.Root); + foreach (var oath in oaths) { var relName = Discovery.RelPosix(oath, workspace.Root); @@ -93,7 +98,7 @@ [.. oaths.Select(oath => Discovery.RelPosix(oath, workspace.Root))], { text = File.ReadAllText(oath); doc = Parse.Run(relName, text); - plan = Plan.Run(doc, workspace.Registry); + plan = Plan.Run(doc, workspace.Registry, oathWorkspace); } catch (Exception e) { @@ -223,7 +228,11 @@ Value CreateContext(string file) => { if (!planCache.TryGetValue(oathPath, out var plan)) { - plan = RunnerApi.PlanOath(oathPath, File.ReadAllText(Path.Combine(workspace.Root, oathPath)), workspace.Registry); + plan = RunnerApi.PlanOath( + oathPath, + File.ReadAllText(Path.Combine(workspace.Root, oathPath)), + workspace.Registry, + ProjectWorkspace(Discovery.FindOaths(workspace.Config, workspace.Root), workspace.Root)); planCache[oathPath] = plan; } @@ -270,6 +279,28 @@ Value CreateContext(string file) => } /// The built test assembly plus its workspace root (nearest varar.config.json) and registry. + /// + /// Parses every discovered oath so references resolve and consumed sections are recognised + /// (ADR 0016). Parsing runs no step code, so this is cheap. + /// + private static OathWorkspace ProjectWorkspace(IEnumerable oaths, string root) + { + var docs = new List(); + foreach (var oath in oaths) + { + try + { + docs.Add(Parse.Run(Discovery.RelPosix(oath, root), File.ReadAllText(oath))); + } + catch (IOException) + { + // A file that vanished between discovery and read contributes nothing. + } + } + + return Reference_.BuildWorkspace(docs); + } + internal sealed class Workspace { internal Workspace(string root, ParsedConfig config, Registry registry) @@ -323,4 +354,5 @@ internal interface ITestReporter void RecordResult(TestResult result); void RecordEnd(TestCase testCase, TestOutcome outcome); + } diff --git a/dotnet/Varar.Tests/ConformanceFixtures.cs b/dotnet/Varar.Tests/ConformanceFixtures.cs index b4ee9dd4..c697d213 100644 --- a/dotnet/Varar.Tests/ConformanceFixtures.cs +++ b/dotnet/Varar.Tests/ConformanceFixtures.cs @@ -32,6 +32,8 @@ public static class ConformanceFixtures ["17-unexpected-pass"] = Corpus.B17.QuietSteps.Register, ["18-multi-table-example"] = Corpus.B18.BasketSteps.Register, ["19-emphasis-parameter"] = Corpus.B19.MentionSteps.Register, + ["20-reference-splice"] = Corpus.B20.LibrarySteps.Register, + ["21-reference-consumed"] = Corpus.B21.LibrarySteps.Register, }; /// Locate the shared corpus directory by walking up from the test binary. @@ -101,6 +103,8 @@ public static Registry Build(string bundle) ["17-unexpected-pass"] = Corpus.B17.QuietSteps.State, ["18-multi-table-example"] = Corpus.B18.BasketSteps.State, ["19-emphasis-parameter"] = Corpus.B19.MentionSteps.State, + ["20-reference-splice"] = Corpus.B20.LibrarySteps.State, + ["21-reference-consumed"] = Corpus.B21.LibrarySteps.State, }; /// The bundle's initial-state factory, or a loud failure if none is wired. @@ -108,4 +112,19 @@ public static Func StateFor(string bundle) => State.TryGetValue(bundle, out var factory) ? factory : throw new InvalidOperationException($"no C# state fixture registered for bundle {bundle}"); + + /// + /// A bundle is one oath (example.md) plus, for a bundle that exercises reference blocks (ADR + /// 0016), the other oaths it links to — every other .md in the bundle directory. They + /// are parsed under their bare file names, so ./shared.md resolves the same way in every + /// port. + /// + public static (Doc Doc, OathWorkspace Workspace) BundleDocs(string bundleDir) + { + var docs = Directory.GetFiles(bundleDir, "*.md") + .OrderBy(p => p, StringComparer.Ordinal) + .Select(p => Parse.Run(Path.GetFileName(p), File.ReadAllText(p))) + .ToList(); + return (docs.Single(d => d.Path == "example.md"), Reference_.BuildWorkspace(docs)); + } } diff --git a/dotnet/Varar.Tests/PlanConformanceTests.cs b/dotnet/Varar.Tests/PlanConformanceTests.cs index 9594ec17..9d2e4c07 100644 --- a/dotnet/Varar.Tests/PlanConformanceTests.cs +++ b/dotnet/Varar.Tests/PlanConformanceTests.cs @@ -18,11 +18,11 @@ public class PlanConformanceTests public void PlanMatchesGolden(string bundle) { var bundleDir = Path.Combine(BundlesDir(), bundle); - var source = File.ReadAllText(Path.Combine(bundleDir, "example.md")); + var registry = ConformanceFixtures.Build(bundle); - var doc = Parse.Run("example.md", source); - var plan = Plan.Run(doc, registry); + var (doc, workspace) = ConformanceFixtures.BundleDocs(bundleDir); + var plan = Plan.Run(doc, registry, workspace); var actual = Conformance.ToPlanArtifact(plan); // By CONTENT, not bytes: the golden is one file generated by the TypeScript diff --git a/dotnet/Varar.Tests/TraceConformanceTests.cs b/dotnet/Varar.Tests/TraceConformanceTests.cs index 81913b9b..0764c898 100644 --- a/dotnet/Varar.Tests/TraceConformanceTests.cs +++ b/dotnet/Varar.Tests/TraceConformanceTests.cs @@ -17,12 +17,12 @@ public class TraceConformanceTests public void TraceMatchesGolden(string bundle) { var bundleDir = Path.Combine(BundlesDir(), bundle); - var source = File.ReadAllText(Path.Combine(bundleDir, "example.md")); + var registry = ConformanceFixtures.Build(bundle); var state = ConformanceFixtures.StateFor(bundle); - var doc = Parse.Run("example.md", source); - var plan = Plan.Run(doc, registry); + var (doc, workspace) = ConformanceFixtures.BundleDocs(bundleDir); + var plan = Plan.Run(doc, registry, workspace); var actual = Conformance.ToTraceArtifact(plan, _ => state()); // By CONTENT, not bytes: the golden is one file generated by the TypeScript From 12875900e6369e021f83920f69885f23102e8b59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 11:21:05 +0100 Subject: [PATCH 14/21] feat(java): reuse setup between examples by linking to a section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports reference blocks (ADR 0016) to Java and Kotlin — the last of the seven ports: a block whose entire content is a Markdown link to an oath section splices that section's steps in at its own position, in the same file or across files, nesting to any depth with cycles reported rather than recursed into. Plan.plan takes the workspace as a required argument, and both adapters build it from the discovery pass they already run for baseline pruning. PlannedStep gains paramTexts, sliced from the document the step was written in, and docPath for a step spliced in from another oath. With every port green, the corpus lands too: two bundles pin the feature across all seven. 20-reference-splice pins a spliced step (docPath and all) and its execution; 21-reference-consumed pins the other half — the defining file of a referenced section plans NO example, which is what catches a port that resolves references but forgets the workspace and runs shared sections twice. parity.json gains the reference capability. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- conformance/parity.json | 13 ++ .../main/java/dev/varar/core/Conformance.java | 18 +- .../main/java/dev/varar/core/Diagnostics.java | 34 +++- .../src/main/java/dev/varar/core/Plan.java | 170 +++++++++++++++- .../main/java/dev/varar/core/Reference.java | 191 ++++++++++++++++++ .../test/java/dev/varar/core/DriftTest.java | 20 +- .../test/java/dev/varar/core/ExecuteTest.java | 2 +- .../dev/varar/core/FailureStepSpanTest.java | 2 +- .../test/java/dev/varar/core/PlanTest.java | 61 +++--- .../varar/junit/OathFileSelectorResolver.java | 37 +++- .../main/kotlin/dev/varar/kotest/OathSpec.kt | 14 +- .../dev/varar/kotlin/ConformanceTest.kt | 35 +++- .../varar/kotlin/ExecuteIntegrationTest.kt | 4 +- .../dev/varar/kotlin/ParameterTypeTest.kt | 4 + .../src/main/java/dev/varar/runner/Run.java | 12 +- .../java/dev/varar/runner/RenderTest.java | 7 +- .../test/java/dev/varar/runner/RunTest.java | 10 +- .../test/java/dev/varar/ConformanceTest.java | 43 +++- 18 files changed, 601 insertions(+), 76 deletions(-) create mode 100644 java/core/src/main/java/dev/varar/core/Reference.java diff --git a/conformance/parity.json b/conformance/parity.json index 0fac4526..54e6a9d9 100644 --- a/conformance/parity.json +++ b/conformance/parity.json @@ -35,6 +35,19 @@ "go": "parse.go" } }, + { + "name": "reference", + "what": "reference blocks: a link-only block splices an oath section's steps in (ADR 0016)", + "files": { + "typescript": "reference.ts", + "python": "reference.py", + "java": "Reference.java", + "ruby": "reference.rb", + "rust": "reference.rs", + "dotnet": "Reference.cs", + "go": "reference.go" + } + }, { "name": "plan", "what": "AST + registry \u2192 the execution plan", diff --git a/java/core/src/main/java/dev/varar/core/Conformance.java b/java/core/src/main/java/dev/varar/core/Conformance.java index 1818c35c..7a8c5292 100644 --- a/java/core/src/main/java/dev/varar/core/Conformance.java +++ b/java/core/src/main/java/dev/varar/core/Conformance.java @@ -214,8 +214,9 @@ private static Map kindLineAnchor( * of its steps is individually traced as {@code "fail"}; see {@code * conformance/bundles/03-expected-failure/golden/trace.json}). */ - public static BundleArtifacts runConformance(Ast.Doc doc, Registry registry, Supplier contextFactory) { - Plan.ExecutionPlan execution = Plan.plan(doc, registry); + public static BundleArtifacts runConformance( + Ast.Doc doc, Registry registry, Supplier contextFactory, Reference.OathWorkspace workspace) { + Plan.ExecutionPlan execution = Plan.plan(doc, registry, workspace); Map> observed = new HashMap<>(); Execute.ExecutePorts ports = new Execute.ExecutePorts( @@ -337,16 +338,18 @@ private static Map plannedStep(String source, Plan.PlannedStep s out.put("matchedExpression", step.stepDef().expression()); List paramNames = parameterTypeNames(step.stepDef().expression()); - List args = new ArrayList<>(step.paramSpans().size()); - for (int i = 0; i < step.paramSpans().size(); i++) { - Span paramSpan = step.paramSpans().get(i); + List args = new ArrayList<>(step.paramTexts().size()); + for (int i = 0; i < step.paramTexts().size(); i++) { Map arg = new LinkedHashMap<>(); - arg.put("value", source.substring(paramSpan.startOffset(), paramSpan.endOffset())); + arg.put("value", step.paramTexts().get(i)); arg.put("parameterType", i < paramNames.size() ? paramNames.get(i) : null); args.add(arg); } out.put("args", args); + // Present only on a step a reference block spliced in from another oath (ADR 0016): the + // document its spans belong to. + if (step.docPath() != null) out.put("docPath", step.docPath()); if (step.dataTable() != null) out.put("dataTable", table(step.dataTable())); if (step.docString() != null) out.put("docString", docString(step.docString())); return out; @@ -377,6 +380,9 @@ private static Map diagnostic(Diagnostics.Diagnostic d) { private static String diagnosticCode(Diagnostics.DiagnosticCode code) { return switch (code) { case AMBIGUOUS_MATCH -> "ambiguous-match"; + case REFERENCE_NOT_FOUND -> "reference-not-found"; + case REFERENCE_EMPTY -> "reference-empty"; + case REFERENCE_CYCLE -> "reference-cycle"; case ERROR_FENCE_WITHOUT_STEP -> "error-fence-without-step"; case DRIFT -> "drift"; }; diff --git a/java/core/src/main/java/dev/varar/core/Diagnostics.java b/java/core/src/main/java/dev/varar/core/Diagnostics.java index 01b0c5ab..ba8f21f7 100644 --- a/java/core/src/main/java/dev/varar/core/Diagnostics.java +++ b/java/core/src/main/java/dev/varar/core/Diagnostics.java @@ -35,7 +35,14 @@ public enum Severity { public enum DiagnosticCode { AMBIGUOUS_MATCH, ERROR_FENCE_WITHOUT_STEP, - DRIFT + DRIFT, + /** + * Reference blocks (ADR 0016): a link that resolves to no oath, to a section with no steps, + * or to a chain that reaches itself. + */ + REFERENCE_NOT_FOUND, + REFERENCE_EMPTY, + REFERENCE_CYCLE } /** One diagnostic: its code, severity, and the source span it points at. */ @@ -56,4 +63,29 @@ public static Diagnostic ambiguousMatch(Span span) { public static Diagnostic errorFenceWithoutStep(Span span) { return new Diagnostic(DiagnosticCode.ERROR_FENCE_WITHOUT_STEP, Severity.ERROR, span); } + + /** + * A reference block (ADR 0016) points at an oath the workspace does not hold. Never prose: a + * link-only block that resolves to nothing has no other reading, so it fails the run rather + * than degrading silently. + */ + public static Diagnostic referenceNotFound(Span span) { + return new Diagnostic(DiagnosticCode.REFERENCE_NOT_FOUND, Severity.ERROR, span); + } + + /** + * The referenced document exists but the section contributes no steps — a mistyped anchor, or a + * section that is pure prose. + */ + public static Diagnostic referenceEmpty(Span span) { + return new Diagnostic(DiagnosticCode.REFERENCE_EMPTY, Severity.ERROR, span); + } + + /** + * References nest to any depth, so a chain that reaches a section already on it is reported + * rather than recursed into. + */ + public static Diagnostic referenceCycle(Span span) { + return new Diagnostic(DiagnosticCode.REFERENCE_CYCLE, Severity.ERROR, span); + } } diff --git a/java/core/src/main/java/dev/varar/core/Plan.java b/java/core/src/main/java/dev/varar/core/Plan.java index b36418bc..ccf179b2 100644 --- a/java/core/src/main/java/dev/varar/core/Plan.java +++ b/java/core/src/main/java/dev/varar/core/Plan.java @@ -83,6 +83,13 @@ public record HeaderBinding(Span matchSpan, List paramSpans, Registry.Step * reaching back into the registry. Copied null-tolerantly ({@code List.copyOf} rejects * nulls). */ + /** + * @param paramTexts the notation each parameter matched, sliced at plan time from the document + * the step was WRITTEN in. Consumers must use this rather than slicing the running oath's + * source: a step a reference block spliced in (ADR 0016) has spans in a different document. + * @param docPath set only on such a spliced step — the document its spans belong to; {@code + * null} means the example's own document. + */ public record PlannedStep( String text, Span matchSpan, @@ -91,11 +98,20 @@ public record PlannedStep( List args, List> formats, Ast.Table dataTable, - Ast.Fence docString) { + Ast.Fence docString, + List paramTexts, + String docPath) { public PlannedStep { paramSpans = List.copyOf(paramSpans); args = List.copyOf(args); formats = Collections.unmodifiableList(new ArrayList<>(formats)); + paramTexts = List.copyOf(paramTexts); + } + + /** Same step, tagged with the document its spans belong to. */ + public PlannedStep withDocPath(String path) { + return new PlannedStep( + text, matchSpan, paramSpans, stepDef, args, formats, dataTable, docString, paramTexts, path); } } @@ -104,12 +120,15 @@ public record PlannedStep( // ----------------------------------------------------------------------------------------- /** Plans {@code doc} against {@code registry}: mirrors {@code plan()} in plan.ts exactly. */ - public static ExecutionPlan plan(Ast.Doc doc, Registry registry) { + public static ExecutionPlan plan(Ast.Doc doc, Registry registry, Reference.OathWorkspace workspace) { List diagnostics = new ArrayList<>(); - // Phase 1: plan each candidate paragraph independently into a "unit". + // Phase 1: plan each candidate paragraph independently into a "unit". A section another + // oath references stops being a standalone example: it runs where it is referenced, not + // here (ADR 0016). List units = new ArrayList<>(doc.examples().size()); for (Ast.Example ex : doc.examples()) { + if (consumed(ex, doc, workspace)) continue; units.add(planCandidate(ex, doc, registry, diagnostics)); } @@ -129,6 +148,25 @@ public static ExecutionPlan plan(Ast.Doc doc, Registry registry) { examples.addAll(hb.rows()); continue; } + if (unit instanceof ReferenceUnit ru) { + // Splice the referenced section's steps in at this position. Only the reference + // block itself is subject to the delimiter rule; everything it splices in belongs + // to the same sequence, so a section of several paragraphs stays one example. + List resolved = resolveReference(ru, doc, registry, workspace, diagnostics, List.of()); + for (int i = 0; i < resolved.size(); i++) { + StepsUnit spliced = resolved.get(i); + if (open != null && (i > 0 || !ru.precededByDelimiter())) { + mergeInto(open, spliced, true); + } else { + if (open != null) examples.add(finishMerged(open, doc.source())); + open = startMerged(spliced); + // An example that OPENS with a reference is named by its own first matching + // paragraph, not by the section it pulls in. + open.nameFromReference = true; + } + } + continue; + } StepsUnit su = (StepsUnit) unit; if (!su.matched()) { // Prose paragraph — a delimiter. Drop it and end the open example. @@ -139,7 +177,7 @@ public static ExecutionPlan plan(Ast.Doc doc, Registry registry) { continue; } if (open != null && !su.precededByDelimiter()) { - mergeInto(open, su); + mergeInto(open, su, false); } else { if (open != null) examples.add(finishMerged(open, doc.source())); open = startMerged(su); @@ -158,7 +196,14 @@ public static ExecutionPlan plan(Ast.Doc doc, Registry registry) { // ----------------------------------------------------------------------------------------- /** One candidate paragraph, planned in isolation. */ - private sealed interface CandidateUnit permits HeaderBoundUnit, StepsUnit {} + private sealed interface CandidateUnit permits HeaderBoundUnit, StepsUnit, ReferenceUnit {} + + /** + * A reference block: its whole text is a link to an oath section, whose steps are spliced in + * here (ADR 0016). Never prose, so it does not close the open example. + */ + private record ReferenceUnit(Reference.Ref reference, boolean precededByDelimiter, Span span) + implements CandidateUnit {} /** A header-bound table candidate — standalone, one planned example per data row. */ private record HeaderBoundUnit(List rows) implements CandidateUnit {} @@ -188,6 +233,12 @@ private static final class MergedExample { List steps; String expectedOutcome; // null or "fail" String expectedErrorMessage; // null when absent + + /** + * True while the name came from a spliced (referenced) paragraph and is waiting to be + * replaced by the example's own first matching paragraph. + */ + boolean nameFromReference; } private static MergedExample startMerged(StepsUnit unit) { @@ -202,7 +253,12 @@ private static MergedExample startMerged(StepsUnit unit) { return m; } - private static void mergeInto(MergedExample open, StepsUnit unit) { + private static void mergeInto(MergedExample open, StepsUnit unit, boolean fromReference) { + if (open.nameFromReference && !fromReference) { + open.name = unit.name(); + open.scopeStack = unit.scopeStack(); + open.nameFromReference = false; + } open.endOffset = unit.span().endOffset(); open.steps.addAll(unit.steps()); // Any error fence in a merged part marks the whole example expected-to-fail; keep the first @@ -233,8 +289,96 @@ private static PlannedExample finishMerged(MergedExample open, String source) { * ambiguity / error-fence diagnostics into {@code diagnostics}. Uses the same per-candidate * logic the old single-pass planner ran per example; grouping is Phase 2's job. */ + private static boolean consumed(Ast.Example ex, Ast.Doc doc, Reference.OathWorkspace workspace) { + if (workspace.referenced().contains(Reference.sectionKey(doc.path(), ""))) return true; + for (String h : ex.scopeStack()) { + if (workspace.referenced().contains(Reference.sectionKey(doc.path(), Reference.slugify(h)))) { + return true; + } + } + return false; + } + + /** + * Resolves one reference block into the step-bearing units of the section it names, + * recursively: a referenced section may itself contain reference blocks, to any depth (ADR 0016 + * leaves depth to the author's judgement). {@code chain} carries the sections currently being + * resolved so a repeat is reported as a cycle instead of recursing forever. + */ + private static List resolveReference( + ReferenceUnit unit, + Ast.Doc from, + Registry registry, + Reference.OathWorkspace workspace, + List diagnostics, + List chain) { + Reference.Ref ref = unit.reference(); + String key = Reference.sectionKey(ref.path(), ref.slug()); + if (chain.contains(key)) { + diagnostics.add(Diagnostics.referenceCycle(unit.span())); + return List.of(); + } + // A same-file reference resolves against the document being planned, which is not + // necessarily in the workspace. + Ast.Doc target = + ref.path().equals(from.path()) ? from : workspace.docs().get(ref.path()); + if (target == null) { + diagnostics.add(Diagnostics.referenceNotFound(unit.span())); + return List.of(); + } + List deeper = new ArrayList<>(chain); + deeper.add(key); + List out = new ArrayList<>(); + for (Ast.Example candidate : Reference.sectionCandidates(target, ref.slug())) { + CandidateUnit planned = planCandidate(candidate, target, registry, diagnostics); + if (planned instanceof ReferenceUnit nested) { + out.addAll(resolveReference(nested, target, registry, workspace, diagnostics, deeper)); + continue; + } + // A header-bound table produces one example per row, which a spliced step list cannot + // express; an `error` fence declares an outcome for an example, not for a reusable + // fragment. Both are left out. + if (planned instanceof StepsUnit su && su.matched()) { + out.add(tagWithDoc(su, target.path(), from.path())); + } + } + if (out.isEmpty()) diagnostics.add(Diagnostics.referenceEmpty(unit.span())); + return out; + } + + /** + * Carries the source document's identity on every spliced step, so a failure in a referenced + * section reports spans against the file they were written in rather than the file being run. + */ + private static StepsUnit tagWithDoc(StepsUnit unit, String docPath, String hostPath) { + if (docPath.equals(hostPath)) return unit; + List tagged = new ArrayList<>(unit.steps().size()); + for (PlannedStep step : unit.steps()) tagged.add(step.withDocPath(docPath)); + return new StepsUnit( + unit.matched(), + unit.precededByDelimiter(), + unit.name(), + unit.scopeStack(), + unit.span(), + tagged, + unit.expectedOutcome(), + unit.expectedErrorMessage()); + } + private static CandidateUnit planCandidate( Ast.Example ex, Ast.Doc doc, Registry registry, List diagnostics) { + // A block whose whole text is a link to an oath section is a reference, not content: never + // matched against step definitions, and never prose. + if (!ex.body().isEmpty()) { + String primaryText = Reference.textOf(ex.body().get(0)); + if (primaryText != null) { + Reference.Ref ref = Reference.referenceOf(primaryText, doc.path()); + if (ref != null) { + return new ReferenceUnit(ref, ex.precededByDelimiter(), ex.span()); + } + } + } + boolean hadAmbiguous = false; List body = ex.body(); @@ -257,6 +401,10 @@ private static CandidateUnit planCandidate( for (Matcher.ParamSpan p : hit.paramSpans()) { paramSpans.add(liftSpan(doc.source(), block, p.start(), p.end())); } + List paramTexts = new ArrayList<>(hit.paramSpans().size()); + for (Matcher.ParamSpan p : hit.paramSpans()) { + paramTexts.add(text.substring(p.start(), p.end())); + } blockSteps.add(new PlannedStep( text.substring(hit.matchStart(), hit.matchEnd()), liftSpan(doc.source(), block, hit.matchStart(), hit.matchEnd()), @@ -265,6 +413,8 @@ private static CandidateUnit planCandidate( hit.args(), hit.formats(), null, + null, + paramTexts, null)); } stepsByBlock.put(idx, blockSteps); @@ -296,7 +446,9 @@ private static CandidateUnit planCandidate( rowArgs, bound.step().formats(), null, - null); + null, + bound.step().paramTexts(), + bound.step().docPath()); List rowChecks = new ArrayList<>(headerCells.size()); for (int i = 0; i < headerCells.size(); i++) { rowChecks.add(new CellDiff.RowCheck(headerCells.get(i), cellAt(row, i), cellSpanAt(row, i))); @@ -357,7 +509,9 @@ private static CandidateUnit planCandidate( step.args(), step.formats(), attachAt.dataTable(), - attachAt.docString())); + attachAt.docString(), + step.paramTexts(), + step.docPath())); } else { finalSteps.add(step); } diff --git a/java/core/src/main/java/dev/varar/core/Reference.java b/java/core/src/main/java/dev/varar/core/Reference.java new file mode 100644 index 00000000..a559d82c --- /dev/null +++ b/java/core/src/main/java/dev/varar/core/Reference.java @@ -0,0 +1,191 @@ +package dev.varar.core; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Reuse is a link (ADR 0016). A candidate block whose entire content is a single Markdown link to an + * oath section is a REFERENCE BLOCK: it splices that section's steps in at its own position instead + * of being prose. + * + *

Everything here is pure text and path arithmetic — no filesystem. The shell reads the + * documents; {@link #references} tells it which ones to read, and {@link #buildWorkspace} turns the + * collection into what {@link Plan#plan} needs. + */ +public final class Reference { + + private Reference() {} + + /** + * One resolved reference block: the referenced oath's path (resolved against the referring doc's + * own path), the GFM slug of the heading (empty for a whole-file link), and the link's text. + */ + public record Ref(String path, String slug, String text) {} + + /** + * What {@link Plan#plan} needs to resolve references: every oath by path, plus which sections a + * reference block consumes somewhere in the project. A section that is referenced stops being a + * standalone example, so this is whole-project knowledge — see ADR 0016 on why each runner + * builds it at its once-per-run discovery pass. + */ + public record OathWorkspace(Map docs, Set referenced) { + public OathWorkspace { + docs = Map.copyOf(docs); + referenced = Set.copyOf(referenced); + } + } + + // A candidate is a reference block iff its whole text is one Markdown link whose target is + // oath-shaped. Anything else — a link with surrounding words, a link to https://…, to a .java + // file, to a mailto: — is ordinary content, so existing documents keep their meaning. + private static final Pattern LINK_ONLY = Pattern.compile("^\\[([^\\]]*)\\]\\(\\s*([^\\s)]+)\\s*\\)$"); + private static final Pattern PROTOCOL = Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.\\-]*:"); + private static final Pattern NOT_SLUG = Pattern.compile("[^\\p{L}\\p{N} _-]"); + private static final Pattern CODE_SPAN = Pattern.compile("`([^`]*)`"); + private static final Pattern STRONG = Pattern.compile("\\*\\*([^*]*)\\*\\*"); + private static final Pattern EMPH = Pattern.compile("\\*([^*]*)\\*"); + private static final Pattern UNDER = Pattern.compile("_([^_]*)_"); + + /** The reference a block's text spells, or {@code null} when it is ordinary content. */ + public static Ref referenceOf(String text, String fromPath) { + Matcher m = LINK_ONLY.matcher(text.trim()); + if (!m.matches()) return null; + String linkText = m.group(1); + String target = m.group(2); + if (target.startsWith("#")) { + return new Ref(fromPath, normalizeSlug(target.substring(1)), linkText); + } + int hash = target.indexOf('#'); + String filePart = hash == -1 ? target : target.substring(0, hash); + String fragment = hash == -1 ? "" : target.substring(hash + 1); + // Only a relative Markdown path is a reference. A protocol (https:, mailto:) or any other + // extension is left alone — remote references are deliberately out of scope (ADR 0016). + if (!filePart.endsWith(".md") || PROTOCOL.matcher(filePart).find() || filePart.startsWith("/")) { + return null; + } + return new Ref(joinPosix(dirnamePosix(fromPath), filePart), normalizeSlug(fragment), linkText); + } + + /** + * GitHub's heading anchors: inline markup dropped, lowercased, spaces to hyphens, everything + * else that isn't a word character or hyphen removed. The same function produces the slug of a + * heading and normalizes the slug written in a link, so the two meet in the middle. + */ + public static String slugify(String headingText) { + String s = CODE_SPAN.matcher(headingText).replaceAll("$1"); + s = STRONG.matcher(s).replaceAll("$1"); + s = EMPH.matcher(s).replaceAll("$1"); + s = UNDER.matcher(s).replaceAll("$1"); + return normalizeSlug(s); + } + + private static String normalizeSlug(String s) { + // One hyphen per space, not per run of them: GitHub leaves the gap where it dropped + // punctuation, so "Fees, VAT & rounding" slugs with a double hyphen. + return NOT_SLUG.matcher(s.trim().toLowerCase(java.util.Locale.ROOT)) + .replaceAll("") + .replace(' ', '-'); + } + + private static String dirnamePosix(String path) { + int i = path.lastIndexOf('/'); + return i == -1 ? "" : path.substring(0, i); + } + + /** + * POSIX path arithmetic on oath paths (always '/'-separated, relative to the workspace root). + * The core may not touch the filesystem. + */ + public static String joinPosix(String dir, String rel) { + List segments = new ArrayList<>(); + if (!dir.isEmpty()) { + for (String s : dir.split("/")) segments.add(s); + } + for (String segment : rel.split("/")) { + if (segment.isEmpty() || segment.equals(".")) continue; + if (segment.equals("..")) { + if (!segments.isEmpty()) segments.remove(segments.size() - 1); + } else { + segments.add(segment); + } + } + return String.join("/", segments); + } + + /** + * Every reference block in a document, in document order. The shell uses this to walk the + * closure of documents it must read before planning. + */ + public static List references(Ast.Doc doc) { + List out = new ArrayList<>(); + for (Ast.Example ex : doc.examples()) { + if (ex.body().isEmpty()) continue; + String text = textOf(ex.body().get(0)); + if (text == null) continue; + Ref ref = referenceOf(text, doc.path()); + if (ref != null) out.add(ref); + } + return out; + } + + /** The text of a text-bearing block, or {@code null} for a table or fence. */ + public static String textOf(Ast.Block block) { + return switch (block) { + case Ast.Paragraph p -> p.text(); + case Ast.ListItem l -> l.text(); + case Ast.Blockquote b -> b.text(); + default -> null; + }; + } + + /** A section's identity across the project. */ + public static String sectionKey(String path, String slug) { + return path + "#" + slug; + } + + /** + * The workspace with no references at all: what a caller planning a single document in isolation + * passes. + */ + public static OathWorkspace emptyWorkspace() { + return new OathWorkspace(Map.of(), Set.of()); + } + + /** Indexes every oath by path and records every consumed section. */ + public static OathWorkspace buildWorkspace(List docs) { + Map byPath = new HashMap<>(); + for (Ast.Doc doc : docs) byPath.put(doc.path(), doc); + Set referenced = new LinkedHashSet<>(); + for (Ast.Doc doc : docs) { + for (Ref r : references(doc)) referenced.add(sectionKey(r.path(), r.slug())); + } + return new OathWorkspace(byPath, new HashSet<>(referenced)); + } + + /** + * The candidates that make up a section: those whose heading chain contains the slug. A + * whole-file reference (empty slug) is every candidate in the document. Section membership + * follows the document outline exactly — a heading's section runs until the next heading of the + * same or higher level, which is precisely the range over which it stays on the scope stack. + */ + public static List sectionCandidates(Ast.Doc doc, String slug) { + if (slug.isEmpty()) return doc.examples(); + List out = new ArrayList<>(); + for (Ast.Example ex : doc.examples()) { + for (String h : ex.scopeStack()) { + if (slugify(h).equals(slug)) { + out.add(ex); + break; + } + } + } + return out; + } +} diff --git a/java/core/src/test/java/dev/varar/core/DriftTest.java b/java/core/src/test/java/dev/varar/core/DriftTest.java index 6d2ba8ca..a502f8c5 100644 --- a/java/core/src/test/java/dev/varar/core/DriftTest.java +++ b/java/core/src/test/java/dev/varar/core/DriftTest.java @@ -32,7 +32,7 @@ private static Registry romanReg(boolean withStep) { } private static Plan.ExecutionPlan planOf(String source, Registry r) { - return Plan.plan(Parse.parse("w.md", source), r); + return Plan.plan(Parse.parse("w.md", source), r, Reference.emptyWorkspace()); } private static List bare(List drifts) { @@ -152,16 +152,17 @@ void headerBoundTableRecordsItsBindingParagraphOnce() { Ast.Doc doc = Parse.parse("r.md", ROMAN); assertEquals( List.of(new Drift.BaselineExample("Each row gives a decimal and a roman number:", 1)), - Drift.liveExamples(doc, Plan.plan(doc, romanReg(true)))); + Drift.liveExamples(doc, Plan.plan(doc, romanReg(true), Reference.emptyWorkspace()))); } @Test void aHeaderBoundBindingParagraphThatStopsMatchingDrifts() { Ast.Doc doc = Parse.parse("r.md", ROMAN); - Drift.OathBaseline baseline = Drift.deriveOathBaseline(ROMAN, doc, Plan.plan(doc, romanReg(true))); + Drift.OathBaseline baseline = + Drift.deriveOathBaseline(ROMAN, doc, Plan.plan(doc, romanReg(true), Reference.emptyWorkspace())); assertEquals( List.of("Each row gives a decimal and a roman number:@1"), - bare(Drift.detectDrift(baseline, doc, Plan.plan(doc, romanReg(false))))); + bare(Drift.detectDrift(baseline, doc, Plan.plan(doc, romanReg(false), Reference.emptyWorkspace())))); } @Test @@ -261,7 +262,7 @@ private static Registry depositWithdrawReg(boolean withDeposit) { void twoParagraphsThatMergeIntoOneExampleAreEachRecordedAsALiveBaselineEntry() { String source = "I deposit 100.\n\nI withdraw 40."; Ast.Doc doc = Parse.parse("w.md", source); - Plan.ExecutionPlan plan = Plan.plan(doc, depositWithdrawReg(true)); + Plan.ExecutionPlan plan = Plan.plan(doc, depositWithdrawReg(true), Reference.emptyWorkspace()); // One planned example (the two paragraphs merged), but two live entries. assertEquals(1, plan.examples().size()); assertEquals( @@ -273,10 +274,12 @@ void twoParagraphsThatMergeIntoOneExampleAreEachRecordedAsALiveBaselineEntry() { void deletingOneStepDefOfAMergedExampleDriftsOnlyTheNowProseParagraph() { String source = "I deposit 100.\n\nI withdraw 40."; Ast.Doc doc = Parse.parse("w.md", source); - Drift.OathBaseline baseline = Drift.deriveOathBaseline(source, doc, Plan.plan(doc, depositWithdrawReg(true))); + Drift.OathBaseline baseline = Drift.deriveOathBaseline( + source, doc, Plan.plan(doc, depositWithdrawReg(true), Reference.emptyWorkspace())); // The deposit step is gone: its paragraph becomes prose, splitting the example. The // withdraw paragraph is still live; the deposit one drifts. - List drift = Drift.detectDrift(baseline, doc, Plan.plan(doc, depositWithdrawReg(false))); + List drift = + Drift.detectDrift(baseline, doc, Plan.plan(doc, depositWithdrawReg(false), Reference.emptyWorkspace())); assertEquals(List.of("I deposit 100@1"), bare(drift)); } @@ -289,7 +292,8 @@ void deletingOneStepDefOfAMergedExampleDriftsOnlyTheNowProseParagraph() { private static String lockWithStalePath() { String source = "I withdraw 40."; Ast.Doc doc = Parse.parse("w.md", source); - Drift.OathBaseline baseline = Drift.deriveOathBaseline(source, doc, Plan.plan(doc, reg(true))); + Drift.OathBaseline baseline = + Drift.deriveOathBaseline(source, doc, Plan.plan(doc, reg(true), Reference.emptyWorkspace())); return Drift.stringifyLockFile(new Drift.LockFile(2, Map.of("varar/w.md", baseline, "w.md", baseline))); } diff --git a/java/core/src/test/java/dev/varar/core/ExecuteTest.java b/java/core/src/test/java/dev/varar/core/ExecuteTest.java index 02a6e78a..f641ab23 100644 --- a/java/core/src/test/java/dev/varar/core/ExecuteTest.java +++ b/java/core/src/test/java/dev/varar/core/ExecuteTest.java @@ -63,7 +63,7 @@ private static Registry reg(String expression, String file, int line, Object han } private static Plan.ExecutionPlan planOf(String source, Registry registry) { - return Plan.plan(Parse.parse("x.md", source), registry); + return Plan.plan(Parse.parse("x.md", source), registry, Reference.emptyWorkspace()); } private static Execute.ExecutePorts silentPorts() { diff --git a/java/core/src/test/java/dev/varar/core/FailureStepSpanTest.java b/java/core/src/test/java/dev/varar/core/FailureStepSpanTest.java index b8f4869a..5d0e98e1 100644 --- a/java/core/src/test/java/dev/varar/core/FailureStepSpanTest.java +++ b/java/core/src/test/java/dev/varar/core/FailureStepSpanTest.java @@ -34,7 +34,7 @@ private static Result.ExampleFailure failureOfThrowingSensor() { throw new AssertionError("expected the library to refuse"); }, StepKind.SENSOR); - Plan.ExecutionPlan p = Plan.plan(Parse.parse("l.md", SOURCE), r); + Plan.ExecutionPlan p = Plan.plan(Parse.parse("l.md", SOURCE), r, Reference.emptyWorkspace()); Throwable caught = assertThrows( AssertionError.class, () -> Execute.collectExamples(p, new Execute.ExecutePorts(d -> {})) diff --git a/java/core/src/test/java/dev/varar/core/PlanTest.java b/java/core/src/test/java/dev/varar/core/PlanTest.java index 8efa7acd..124753a1 100644 --- a/java/core/src/test/java/dev/varar/core/PlanTest.java +++ b/java/core/src/test/java/dev/varar/core/PlanTest.java @@ -30,7 +30,7 @@ void planProducesAPlannedExampleWithStepsInDocumentOrder() { String source = "# Withdrawing\n\nGiven I have 100 in my account. When I withdraw 40. Then I should" + " have 60 left."; Ast.Doc doc = Parse.parse("w.md", source); - Plan.ExecutionPlan result = Plan.plan(doc, reg()); + Plan.ExecutionPlan result = Plan.plan(doc, reg(), Reference.emptyWorkspace()); assertEquals(0, result.diagnostics().size()); assertEquals(1, result.examples().size()); Plan.PlannedExample ex = result.examples().get(0); @@ -48,7 +48,7 @@ void planEmitsAnAmbiguousMatchDiagnosticAndProducesNoRunnableExample() { r = Registry.addStep(r, "I have {int} cukes", "a.ts", 3, NOOP_HANDLER, StepKind.STIMULUS); r = Registry.addStep(r, "I have {int} {word}", "a.ts", 8, NOOP_HANDLER, StepKind.STIMULUS); Ast.Doc doc = Parse.parse("e.md", "# Ambig\n\nGiven I have 5 cukes"); - Plan.ExecutionPlan result = Plan.plan(doc, r); + Plan.ExecutionPlan result = Plan.plan(doc, r, Reference.emptyWorkspace()); assertEquals(1, result.diagnostics().size()); assertEquals( Diagnostics.DiagnosticCode.AMBIGUOUS_MATCH, @@ -62,7 +62,7 @@ void planEmitsAnAmbiguousMatchDiagnosticAndProducesNoRunnableExample() { void planSkipsAnExampleHeadingWhoseBodyHasNoMatchesAndNoKeywordLedSentences() { String source = "# Just docs\n\nSome prose with no matches and no keywords."; Ast.Doc doc = Parse.parse("d.md", source); - Plan.ExecutionPlan result = Plan.plan(doc, reg()); + Plan.ExecutionPlan result = Plan.plan(doc, reg(), Reference.emptyWorkspace()); assertEquals(0, result.examples().size()); assertEquals(0, result.diagnostics().size()); } @@ -75,7 +75,7 @@ void planMergesConsecutiveListItemsIntoOneExample() { // Two list items, no delimiter between them → one example, shared state (ADR 0012). A // bulleted scenario reads as Given/When/Then bullets. String source = "# Bullets\n\n- Given I have 100 in my account\n- When I withdraw 40"; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("b.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("b.md", source), r, Reference.emptyWorkspace()); assertEquals(1, result.examples().size()); assertEquals( List.of("I have 100 in my account", "I withdraw 40"), @@ -89,7 +89,7 @@ void planWalksBlockquoteContentAsStepBearing() { Registry r = Registry.createRegistry(); r = Registry.addStep(r, "I have {int} in my account", "s.ts", 1, NOOP_HANDLER, StepKind.STIMULUS); String source = "# Quote\n\n> Given I have 100 in my account"; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("q.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("q.md", source), r, Reference.emptyWorkspace()); assertEquals(1, result.examples().get(0).steps().size()); } @@ -105,7 +105,7 @@ void aMarkdownTableImmediatelyFollowingAStepBearingBlockAttachesAsDataTable() { |------|-----| | Bob | 30 | | Eve | 25 |"""; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("u.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("u.md", source), r, Reference.emptyWorkspace()); Plan.PlannedStep step = result.examples().get(0).steps().get(0); assertEquals(List.of("name", "age"), step.dataTable().header().cells()); assertEquals(2, step.dataTable().rows().size()); @@ -125,7 +125,7 @@ void aTableNotImmediatelyAfterAStepBearingBlockDoesNotAttach() { | name | age | |------|-----| | Bob | 30 |"""; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("m.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("m.md", source), r, Reference.emptyWorkspace()); Plan.PlannedStep step = result.examples().get(0).steps().get(0); assertNull(step.dataTable()); } @@ -141,7 +141,7 @@ void aFencedCodeBlockImmediatelyFollowingAStepBearingBlockAttachesAsDocString() ```json { "action": "import" } ```"""; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("p.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("p.md", source), r, Reference.emptyWorkspace()); Plan.PlannedStep step = result.examples().get(0).steps().get(0); assertEquals("json", step.docString().info()); assertEquals("{ \"action\": \"import\" }\n", step.docString().body()); @@ -151,7 +151,8 @@ void aFencedCodeBlockImmediatelyFollowingAStepBearingBlockAttachesAsDocString() void aStepWithNoFollowingFenceHasNoDocString() { Registry r = Registry.createRegistry(); r = Registry.addStep(r, "I send the payload", "s.ts", 1, NOOP_HANDLER, StepKind.STIMULUS); - Plan.ExecutionPlan result = Plan.plan(Parse.parse("p.md", "# P\nWhen I send the payload"), r); + Plan.ExecutionPlan result = + Plan.plan(Parse.parse("p.md", "# P\nWhen I send the payload"), r, Reference.emptyWorkspace()); assertNull(result.examples().get(0).steps().get(0).docString()); } @@ -161,7 +162,7 @@ void aKeywordLedSentenceWithNoMatchDoesNotProduceADiagnostic() { // sentence "should" have matched a step definition. Registry r = Registry.createRegistry(); Ast.Doc doc = Parse.parse("m.md", "# Empty\n\nGiven I have 5 cukes in my belly."); - Plan.ExecutionPlan result = Plan.plan(doc, r); + Plan.ExecutionPlan result = Plan.plan(doc, r, Reference.emptyWorkspace()); assertEquals(0, result.diagnostics().size()); } @@ -169,7 +170,7 @@ void aKeywordLedSentenceWithNoMatchDoesNotProduceADiagnostic() { void anUnmatchedSentenceWithoutAKeywordIsAlsoSilentlyTreatedAsProse() { Registry r = Registry.createRegistry(); Ast.Doc doc = Parse.parse("p.md", "# Prose\n\nI have 5 cukes in my belly."); - Plan.ExecutionPlan result = Plan.plan(doc, r); + Plan.ExecutionPlan result = Plan.plan(doc, r, Reference.emptyWorkspace()); assertEquals(0, result.diagnostics().size()); } @@ -187,7 +188,7 @@ void aHeaderBoundTableExpandsIntoOneExamplePerRow() { | ------------- | ---------- | ----- | | 3, 3, 3, 4, 4 | full house | 17 | | 3, 3, 3, 3, 3 | Yahtzee | 50 |"""; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("y.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("y.md", source), r, Reference.emptyWorkspace()); assertEquals(0, result.diagnostics().size()); // One example per data row (the header row is the binding, not an example). assertEquals(2, result.examples().size()); @@ -219,7 +220,7 @@ void aTableWhoseParagraphNamesOnlySomeHeaderCellsKeepsWholeTableBehaviour() { | ---- | --- | | Bob | 30 | | Eve | 25 |"""; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("u.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("u.md", source), r, Reference.emptyWorkspace()); assertEquals(1, result.examples().size()); Plan.PlannedStep step = result.examples().get(0).steps().get(0); assertEquals(List.of("name", "age"), step.dataTable().header().cells()); @@ -238,7 +239,7 @@ void headerBoundMatchingIsCaseSensitive() { | dice | score | | --------- | ----- | | 1,1,1,1,1 | 5 |"""; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("c.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("c.md", source), r, Reference.emptyWorkspace()); // No exact-case match → falls back to a single whole-table example. assertEquals(1, result.examples().size()); assertEquals( @@ -259,7 +260,7 @@ void headerBoundRowsAreNamedByTheirCellsAndNestedUnderTheParagraph() { | ------------- | ---------- | ----- | | 3, 3, 3, 4, 4 | full house | 17 | | 3, 3, 3, 3, 3 | Yahtzee | 50 |"""; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("y.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("y.md", source), r, Reference.emptyWorkspace()); assertEquals( List.of("3, 3, 3, 4, 4 / full house / 17", "3, 3, 3, 3, 3 / Yahtzee / 50"), result.examples().stream().map(Plan.PlannedExample::name).toList()); @@ -290,7 +291,7 @@ void aTableNotAttachedToAStepIsAllowedNoDiagnostic() { | name | age | |------|-----| | Bob | 30 |"""; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("o.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("o.md", source), r, Reference.emptyWorkspace()); assertEquals(0, result.diagnostics().size()); } @@ -307,7 +308,7 @@ void aHeaderBoundRowExampleCarriesRowChecks() { | dice | category | score | | ------------- | ---------- | ----- | | 3, 3, 3, 4, 4 | full house | 17 |"""; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("y.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("y.md", source), r, Reference.emptyWorkspace()); @SuppressWarnings("unchecked") List checks = (List) result.examples().get(0).rowChecks(); @@ -331,8 +332,9 @@ void anErrorFenceMarksTheExampleExpectedOutcomeFailWithAMessageSubstring() { Registry r = Registry.addStep( Registry.createRegistry(), "I divide {int} by {int}", "s.ts", 1, NOOP_HANDLER, StepKind.STIMULUS); String src = "# Division\n\nI divide 1 by 0.\n\n```error\ndivision by zero\n```\n"; - Plan.PlannedExample ex = - Plan.plan(Parse.parse("e.md", src), r).examples().get(0); + Plan.PlannedExample ex = Plan.plan(Parse.parse("e.md", src), r, Reference.emptyWorkspace()) + .examples() + .get(0); assertEquals("fail", ex.expectedOutcome()); assertEquals("division by zero", ex.expectedErrorMessage()); // The error fence must NOT become a docString attachment on the step. @@ -343,7 +345,8 @@ void anErrorFenceMarksTheExampleExpectedOutcomeFailWithAMessageSubstring() { void noErrorFenceLeavesExpectedOutcomeNull() { Registry r = Registry.addStep( Registry.createRegistry(), "I divide {int} by {int}", "s.ts", 1, NOOP_HANDLER, StepKind.STIMULUS); - Plan.PlannedExample ex = Plan.plan(Parse.parse("e.md", "# Division\n\nI divide 1 by 1."), r) + Plan.PlannedExample ex = Plan.plan( + Parse.parse("e.md", "# Division\n\nI divide 1 by 1."), r, Reference.emptyWorkspace()) .examples() .get(0); assertNull(ex.expectedOutcome()); @@ -355,7 +358,7 @@ void anErrorFenceWithNoMatchingStepEmitsAnErrorFenceWithoutStepDiagnostic() { Registry r = Registry.addStep( Registry.createRegistry(), "I divide {int} by {int}", "s.ts", 1, NOOP_HANDLER, StepKind.STIMULUS); String src = "# Nope\n\nThis prose matches nothing.\n\n```error\nboom\n```\n"; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("e.md", src), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("e.md", src), r, Reference.emptyWorkspace()); assertEquals(0, result.examples().size()); assertEquals(1, result.diagnostics().size()); assertEquals( @@ -369,7 +372,7 @@ void anErrorFenceOnAnAmbiguousExampleEmitsBothDiagnostics() { r = Registry.addStep(r, "I divide {int} by {int}", "s.ts", 1, NOOP_HANDLER, StepKind.STIMULUS); r = Registry.addStep(r, "I divide 1 by 0", "s.ts", 2, NOOP_HANDLER, StepKind.STIMULUS); String src = "# Ambiguous\n\nI divide 1 by 0.\n\n```error\nboom\n```\n"; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("e.md", src), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("e.md", src), r, Reference.emptyWorkspace()); List codes = result.diagnostics().stream() .map(Diagnostics.Diagnostic::code) .sorted() @@ -393,7 +396,7 @@ void aDocStringStepCarriesTheFenceBodySpanOnItsPlan() { ```json { "ok": true } ```"""; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("d.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("d.md", source), r, Reference.emptyWorkspace()); Ast.Fence docString = result.examples().get(0).steps().get(0).docString(); if (docString == null) fail("no docString"); assertEquals("{ \"ok\": true }\n", docString.body()); @@ -409,7 +412,7 @@ void aDocStringStepCarriesTheFenceBodySpanOnItsPlan() { @Test void consecutiveMatchingParagraphsWithNoDelimiterMergeIntoOneExample() { String source = "I have 100 in my account.\n\nI withdraw 40.\n\nI should have 60 left."; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("m.md", source), reg()); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("m.md", source), reg(), Reference.emptyWorkspace()); assertEquals(1, result.examples().size()); assertEquals( List.of("I have 100 in my account", "I withdraw 40", "I should have 60 left"), @@ -423,7 +426,7 @@ void consecutiveMatchingParagraphsWithNoDelimiterMergeIntoOneExample() { @Test void aThematicBreakBetweenMatchingParagraphsSplitsThemIntoTwoExamples() { String source = "I have 100 in my account.\n\n---\n\nI withdraw 40."; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("h.md", source), reg()); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("h.md", source), reg(), Reference.emptyWorkspace()); assertEquals(2, result.examples().size()); assertEquals( List.of(List.of("I have 100 in my account"), List.of("I withdraw 40")), @@ -435,7 +438,7 @@ void aThematicBreakBetweenMatchingParagraphsSplitsThemIntoTwoExamples() { @Test void aHeadingBetweenMatchingParagraphsSplitsThemIntoTwoExamples() { String source = "I have 100 in my account.\n\n## Next\n\nI withdraw 40."; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("hd.md", source), reg()); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("hd.md", source), reg(), Reference.emptyWorkspace()); assertEquals(2, result.examples().size()); assertEquals(List.of("Next"), result.examples().get(1).scopeStack()); } @@ -443,7 +446,7 @@ void aHeadingBetweenMatchingParagraphsSplitsThemIntoTwoExamples() { @Test void aNonMatchingParagraphBetweenMatchingParagraphsSplitsTheExample() { String source = "I have 100 in my account.\n\nJust explaining what happens next.\n\nI withdraw 40."; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("p.md", source), reg()); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("p.md", source), reg(), Reference.emptyWorkspace()); assertEquals(2, result.examples().size()); assertEquals( List.of(List.of("I have 100 in my account"), List.of("I withdraw 40")), @@ -455,7 +458,7 @@ void aNonMatchingParagraphBetweenMatchingParagraphsSplitsTheExample() { @Test void leadingAndTrailingProseDoesNotMergeIntoAnExample() { String source = "A preamble that matches nothing.\n\nI withdraw 40.\n\nA closing remark."; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("pp.md", source), reg()); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("pp.md", source), reg(), Reference.emptyWorkspace()); assertEquals(1, result.examples().size()); assertEquals( List.of("I withdraw 40"), @@ -481,7 +484,7 @@ void theMultiTableShapeFromIssue61TwoTablesInOneExampleSurviveBlankLines() { | name | | ----- | | Moose |"""; - Plan.ExecutionPlan result = Plan.plan(Parse.parse("basket.md", source), r); + Plan.ExecutionPlan result = Plan.plan(Parse.parse("basket.md", source), r, Reference.emptyWorkspace()); assertEquals(1, result.examples().size()); Plan.PlannedExample ex = result.examples().get(0); assertEquals(2, ex.steps().size()); diff --git a/java/junit/src/main/java/dev/varar/junit/OathFileSelectorResolver.java b/java/junit/src/main/java/dev/varar/junit/OathFileSelectorResolver.java index c90c3178..ed595523 100644 --- a/java/junit/src/main/java/dev/varar/junit/OathFileSelectorResolver.java +++ b/java/junit/src/main/java/dev/varar/junit/OathFileSelectorResolver.java @@ -1,8 +1,11 @@ package dev.varar.junit; import dev.varar.config.Config; +import dev.varar.core.Ast; import dev.varar.core.Drift; +import dev.varar.core.Parse; import dev.varar.core.Plan; +import dev.varar.core.Reference; import dev.varar.runner.BaselineStores; import dev.varar.runner.Discovery; import dev.varar.runner.Run; @@ -13,6 +16,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -84,6 +88,14 @@ final class OathFileSelectorResolver implements SelectorResolver { */ private final Map fileDescriptors = new HashMap<>(); + /** + * The project's reference topology (ADR 0016), built lazily from the config globs — the full + * set, never the filtered view a single-selector run would give. Whether a section is a + * standalone example depends on whether another oath references it, which no single file can + * answer. + */ + private Reference.OathWorkspace workspace; + private final Drift.BaselineStore baselineStore; private final boolean update; @@ -317,6 +329,29 @@ private Resolution toResolution(Context context, String oathPath, TestSource sou * call, is what lets a second bare {@code UniqueIdSelector} for a different example in the same * file add a sibling instead of silently vanishing (see {@link #resolveOneExample}'s javadoc). */ + private Reference.OathWorkspace workspace() { + if (workspace == null) { + List docs = new ArrayList<>(); + for (Path oath : Discovery.findOaths(config.docsInclude(), config.docsExclude(), root)) { + try { + docs.add(Parse.parse(relOf(oath), Files.readString(oath))); + } catch (IOException e) { + // A file that vanished between discovery and read contributes nothing. + } + } + workspace = Reference.buildWorkspace(docs); + } + return workspace; + } + + private String relOf(Path oath) { + return root.toAbsolutePath() + .normalize() + .relativize(oath.toAbsolutePath().normalize()) + .toString() + .replace('\\', '/'); + } + private OathFileDescriptor createDescriptor( TestDescriptor parent, String oathPath, TestSource source, Integer onlyLine) { OathFileDescriptor existing = fileDescriptors.get(oathPath); @@ -326,7 +361,7 @@ private OathFileDescriptor createDescriptor( } UniqueId uniqueId = parent.getUniqueId().append(OathFileDescriptor.SEGMENT_TYPE, oathPath); String content = readContent(source); - Plan.ExecutionPlan plan = Run.planOath(oathPath, content, loadedSteps.registry()); + Plan.ExecutionPlan plan = Run.planOath(oathPath, content, loadedSteps.registry(), workspace()); OathFileDescriptor fileDescriptor = new OathFileDescriptor(uniqueId, oathPath, source, content, loadedSteps, plan, root); mergeChildren(fileDescriptor, source, onlyLine); diff --git a/java/kotest/src/main/kotlin/dev/varar/kotest/OathSpec.kt b/java/kotest/src/main/kotlin/dev/varar/kotest/OathSpec.kt index b9254fd4..03be4ee2 100644 --- a/java/kotest/src/main/kotlin/dev/varar/kotest/OathSpec.kt +++ b/java/kotest/src/main/kotlin/dev/varar/kotest/OathSpec.kt @@ -3,6 +3,8 @@ package dev.varar.kotest import dev.varar.config.Config import dev.varar.core.Drift import dev.varar.core.Failure +import dev.varar.core.Parse +import dev.varar.core.Reference import dev.varar.core.Result import dev.varar.runner.BaselineStores import dev.varar.runner.Discovery @@ -59,10 +61,20 @@ abstract class OathSpec(root: Path = Path.of(".")) : FunSpec() { // run — see the finalizer below. val results = Results() + // Whether a section is a standalone example depends on whether another oath references it, + // which is whole-project knowledge (ADR 0016). Built from the config globs — the full set, + // for the same reason baseline pruning is. + val workspace = + Reference.buildWorkspace( + oaths.mapNotNull { path -> + runCatching { Parse.parse(relOf(path), Files.readString(path)) }.getOrNull() + } + ) + for (oathPath in oaths) { val rel = relOf(oathPath) val source = Files.readString(oathPath) - val plan = Run.planOath(rel, source, loaded.registry()) + val plan = Run.planOath(rel, source, loaded.registry(), workspace) val runs = Run.examplesWithRuns(plan, loaded.createContext(), Run.RecordingReporter()) // Reconcile drift: a clean run records/updates varar.lock.json; a paragraph that was // an example and no longer matches becomes a failing test (accept with -Dvarar.update). diff --git a/java/kotlin/src/test/kotlin/dev/varar/kotlin/ConformanceTest.kt b/java/kotlin/src/test/kotlin/dev/varar/kotlin/ConformanceTest.kt index 69cf7f03..8e85ddc9 100644 --- a/java/kotlin/src/test/kotlin/dev/varar/kotlin/ConformanceTest.kt +++ b/java/kotlin/src/test/kotlin/dev/varar/kotlin/ConformanceTest.kt @@ -5,6 +5,7 @@ import dev.varar.Steps import dev.varar.core.Conformance import dev.varar.core.JsonValue import dev.varar.core.Parse +import dev.varar.core.Reference import dev.varar.kotlin.conformance.bundle01.steps as bundle01Steps import dev.varar.kotlin.conformance.bundle02.steps as bundle02Steps import dev.varar.kotlin.conformance.bundle03.steps as bundle03Steps @@ -24,6 +25,8 @@ import dev.varar.kotlin.conformance.bundle16.steps as bundle16Steps import dev.varar.kotlin.conformance.bundle17.steps as bundle17Steps import dev.varar.kotlin.conformance.bundle18.steps as bundle18Steps import dev.varar.kotlin.conformance.bundle19.steps as bundle19Steps +import dev.varar.kotlin.conformance.bundle20.steps as bundle20Steps +import dev.varar.kotlin.conformance.bundle21.steps as bundle21Steps import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path @@ -73,9 +76,14 @@ class ConformanceTest { val fixture = loadFixture(bundle.fileName.toString()) val bound = Steps.bind(fixture) - val source = Files.readString(bundle.resolve("example.md"), StandardCharsets.UTF_8) - val doc = Parse.parse("example.md", source) - val artifacts = Conformance.runConformance(doc, bound.registry(), bound.stateFactory()) + val docs = bundleDocs(bundle) + val artifacts = + Conformance.runConformance( + docs.first { it.path() == "example.md" }, + bound.registry(), + bound.stateFactory(), + Reference.buildWorkspace(docs), + ) val actual = JsonValue.normalize(artifacts.trace()) val expected = @@ -110,6 +118,25 @@ class ConformanceTest { } } + /** + * A bundle is one oath (example.md) plus, for a bundle that exercises reference blocks (ADR + * 0016), the other oaths it links to — every other `.md` in the bundle directory. They are + * parsed under their bare file names, so `./shared.md` resolves the same way in every port. + */ + private fun bundleDocs(bundle: Path): List = + Files.list(bundle).use { entries -> + entries + .filter { it.fileName.toString().endsWith(".md") } + .sorted() + .map { + Parse.parse( + it.fileName.toString(), + Files.readString(it, StandardCharsets.UTF_8), + ) + } + .toList() + } + private fun loadFixture(bundleName: String): StepDefinitions<*> = when (bundleName) { "01-roman-numerals" -> bundle01Steps @@ -131,6 +158,8 @@ class ConformanceTest { "17-unexpected-pass" -> bundle17Steps "18-multi-table-example" -> bundle18Steps "19-emphasis-parameter" -> bundle19Steps + "20-reference-splice" -> bundle20Steps + "21-reference-consumed" -> bundle21Steps else -> throw IllegalStateException( "No Kotlin step fixture registered for bundle $bundleName" diff --git a/java/kotlin/src/test/kotlin/dev/varar/kotlin/ExecuteIntegrationTest.kt b/java/kotlin/src/test/kotlin/dev/varar/kotlin/ExecuteIntegrationTest.kt index 469a9ed6..77e2e29a 100644 --- a/java/kotlin/src/test/kotlin/dev/varar/kotlin/ExecuteIntegrationTest.kt +++ b/java/kotlin/src/test/kotlin/dev/varar/kotlin/ExecuteIntegrationTest.kt @@ -5,6 +5,7 @@ import dev.varar.core.CellDiff import dev.varar.core.Execute import dev.varar.core.Parse import dev.varar.core.Plan +import dev.varar.core.Reference import java.util.function.Function import kotlinx.coroutines.delay import org.junit.jupiter.api.Assertions.assertThrows @@ -26,7 +27,8 @@ class ExecuteIntegrationTest { private fun execute(source: String) { val bound = Steps.bind(steps()) - val plan = Plan.plan(Parse.parse("cukes.md", source), bound.registry()) + val plan = + Plan.plan(Parse.parse("cukes.md", source), bound.registry(), Reference.emptyWorkspace()) val ports = Execute.ExecutePorts( Execute.Reporter {}, diff --git a/java/kotlin/src/test/kotlin/dev/varar/kotlin/ParameterTypeTest.kt b/java/kotlin/src/test/kotlin/dev/varar/kotlin/ParameterTypeTest.kt index bc66ed6b..8c39b927 100644 --- a/java/kotlin/src/test/kotlin/dev/varar/kotlin/ParameterTypeTest.kt +++ b/java/kotlin/src/test/kotlin/dev/varar/kotlin/ParameterTypeTest.kt @@ -3,6 +3,7 @@ package dev.varar.kotlin import dev.varar.Steps import dev.varar.core.Parse import dev.varar.core.Plan +import dev.varar.core.Reference import io.cucumber.cucumberexpressions.UndefinedParameterTypeException import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows @@ -26,6 +27,7 @@ class ParameterTypeTest { Plan.plan( Parse.parse("colors.md", "# Colors\n\n## Picking\n\nI pick red.\n"), bound.registry(), + Reference.emptyWorkspace(), ) assertEquals(listOf("RED"), plan.examples()[0].steps()[0].args()) } @@ -50,6 +52,7 @@ class ParameterTypeTest { Plan.plan( Parse.parse("m.md", "# M\n\n## One\n\nI mention *Emma*.\n"), bound.registry(), + Reference.emptyWorkspace(), ) assertEquals(listOf("Emma"), italic.examples()[0].steps()[0].args()) @@ -57,6 +60,7 @@ class ParameterTypeTest { Plan.plan( Parse.parse("m.md", "# M\n\n## Two\n\nI mention **Emma**.\n"), bound.registry(), + Reference.emptyWorkspace(), ) assertEquals(listOf("Emma"), bold.examples()[0].steps()[0].args()) } diff --git a/java/runner/src/main/java/dev/varar/runner/Run.java b/java/runner/src/main/java/dev/varar/runner/Run.java index e1f4b319..fe08c003 100644 --- a/java/runner/src/main/java/dev/varar/runner/Run.java +++ b/java/runner/src/main/java/dev/varar/runner/Run.java @@ -4,6 +4,7 @@ import dev.varar.core.Execute; import dev.varar.core.Parse; import dev.varar.core.Plan; +import dev.varar.core.Reference; import dev.varar.core.Registry; import java.util.ArrayList; import java.util.List; @@ -23,9 +24,14 @@ public final class Run { private Run() {} - /** Parses {@code source} and plans it against {@code registry} in one call. */ - public static Plan.ExecutionPlan planOath(String path, String source, Registry registry) { - return Plan.plan(Parse.parse(path, source), registry); + /** + * Plans one oath. {@code workspace} carries every other oath in the project plus the sections a + * reference block consumes (ADR 0016); it is required because an adapter that omitted it would + * run consumed sections as standalone examples, which is green and wrong. + */ + public static Plan.ExecutionPlan planOath( + String path, String source, Registry registry, Reference.OathWorkspace workspace) { + return Plan.plan(Parse.parse(path, source), registry, workspace); } /** One planned example paired with the {@link Runnable} that actually runs it. */ diff --git a/java/runner/src/test/java/dev/varar/runner/RenderTest.java b/java/runner/src/test/java/dev/varar/runner/RenderTest.java index c5437949..a26825c3 100644 --- a/java/runner/src/test/java/dev/varar/runner/RenderTest.java +++ b/java/runner/src/test/java/dev/varar/runner/RenderTest.java @@ -5,6 +5,7 @@ import dev.varar.core.CellDiff; import dev.varar.core.Plan; +import dev.varar.core.Reference; import dev.varar.runner.StepLoader.LoadedSteps; import dev.varar.runner.fixtures.BoomSteps; import dev.varar.runner.fixtures.GreetingSteps; @@ -28,7 +29,7 @@ void rendersACellMismatchWithTheSourceSlicedExpectedValueTheActualValueAndTheLin LoadedSteps loaded = StepLoader.loadSteps(List.of(WidgetSteps.class.getName()), LOADER); String source = "# Widgets\n\nI have 3 widgets. I should have 4 widgets."; String path = "widgets.md"; - Plan.ExecutionPlan plan = Run.planOath(path, source, loaded.registry()); + Plan.ExecutionPlan plan = Run.planOath(path, source, loaded.registry(), Reference.emptyWorkspace()); List runs = Run.examplesWithRuns(plan, loaded.createContext(), new Run.RecordingReporter()); CellDiff.CellMismatchException error = assertThrows( @@ -57,7 +58,7 @@ void rendersADocStringMismatchWithTheSourceSlicedExpectedValueTheActualValueAndT Hello, world! ```"""; String path = "greeting.md"; - Plan.ExecutionPlan plan = Run.planOath(path, source, loaded.registry()); + Plan.ExecutionPlan plan = Run.planOath(path, source, loaded.registry(), Reference.emptyWorkspace()); List runs = Run.examplesWithRuns(plan, loaded.createContext(), new Run.RecordingReporter()); CellDiff.CellMismatchException error = assertThrows( @@ -77,7 +78,7 @@ void rendersAPlainThrownExceptionUsingItsOwnMessageAndTheFailingLine() { LoadedSteps loaded = StepLoader.loadSteps(List.of(BoomSteps.class.getName()), LOADER); String source = "# Boom\n\nsomething explodes."; String path = "boom.md"; - Plan.ExecutionPlan plan = Run.planOath(path, source, loaded.registry()); + Plan.ExecutionPlan plan = Run.planOath(path, source, loaded.registry(), Reference.emptyWorkspace()); List runs = Run.examplesWithRuns(plan, loaded.createContext(), new Run.RecordingReporter()); RuntimeException error = diff --git a/java/runner/src/test/java/dev/varar/runner/RunTest.java b/java/runner/src/test/java/dev/varar/runner/RunTest.java index c08321ff..8bb4eab6 100644 --- a/java/runner/src/test/java/dev/varar/runner/RunTest.java +++ b/java/runner/src/test/java/dev/varar/runner/RunTest.java @@ -7,6 +7,7 @@ import dev.varar.core.CellDiff; import dev.varar.core.Diagnostics; import dev.varar.core.Plan; +import dev.varar.core.Reference; import dev.varar.core.Registry; import dev.varar.core.StepKind; import dev.varar.runner.StepLoader.LoadedSteps; @@ -36,7 +37,7 @@ private static LoadedSteps loadWidgetSteps() { void planOathParsesAndPlansInOneStep() { LoadedSteps loaded = loadWidgetSteps(); String source = "# Widgets\n\nI have 3 widgets. I should have 3 widgets."; - Plan.ExecutionPlan plan = Run.planOath("widgets.md", source, loaded.registry()); + Plan.ExecutionPlan plan = Run.planOath("widgets.md", source, loaded.registry(), Reference.emptyWorkspace()); assertEquals(1, plan.examples().size()); assertEquals(0, plan.diagnostics().size()); } @@ -46,7 +47,7 @@ void examplesWithRunsPreservesDocumentOrderAndPassingRunDoesNotThrow() { LoadedSteps loaded = loadWidgetSteps(); String source = "# Widgets\n\nI have 3 widgets. I should have 3 widgets.\n\n" + "# More widgets\n\nI have 9 widgets. I should have 9 widgets."; - Plan.ExecutionPlan plan = Run.planOath("widgets.md", source, loaded.registry()); + Plan.ExecutionPlan plan = Run.planOath("widgets.md", source, loaded.registry(), Reference.emptyWorkspace()); Run.RecordingReporter reporter = new Run.RecordingReporter(); List runs = Run.examplesWithRuns(plan, loaded.createContext(), reporter); @@ -67,7 +68,7 @@ void examplesWithRunsSurfacesARealCellMismatchExceptionOnAFailingExample() { // The sensor reports 3, but the oath asserts 4 — a genuine mismatch the core // pipeline detects itself, not a hand-thrown generic exception. String source = "# Widgets\n\nI have 3 widgets. I should have 4 widgets."; - Plan.ExecutionPlan plan = Run.planOath("widgets.md", source, loaded.registry()); + Plan.ExecutionPlan plan = Run.planOath("widgets.md", source, loaded.registry(), Reference.emptyWorkspace()); List runs = Run.examplesWithRuns(plan, loaded.createContext(), new Run.RecordingReporter()); assertEquals(1, runs.size()); @@ -87,7 +88,8 @@ void recordingReporterCollectsDiagnosticsReportedDuringCollectExamples() { LoadedSteps loaded = loadWidgetSteps(); Registry ambiguousRegistry = Registry.addStep(loaded.registry(), "I have 3 widgets", "extra.ts", 1, NOOP_HANDLER, StepKind.STIMULUS); - Plan.ExecutionPlan plan = Run.planOath("widgets.md", "# Widgets\n\nI have 3 widgets.", ambiguousRegistry); + Plan.ExecutionPlan plan = Run.planOath( + "widgets.md", "# Widgets\n\nI have 3 widgets.", ambiguousRegistry, Reference.emptyWorkspace()); assertEquals(1, plan.diagnostics().size()); assertEquals( Diagnostics.DiagnosticCode.AMBIGUOUS_MATCH, diff --git a/java/varar/src/test/java/dev/varar/ConformanceTest.java b/java/varar/src/test/java/dev/varar/ConformanceTest.java index 34f7cf12..91c17208 100644 --- a/java/varar/src/test/java/dev/varar/ConformanceTest.java +++ b/java/varar/src/test/java/dev/varar/ConformanceTest.java @@ -8,12 +8,14 @@ import dev.varar.core.JsonValue; import dev.varar.core.Parse; import dev.varar.core.Plan; +import dev.varar.core.Reference; import dev.varar.core.Registry; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.List; import java.util.function.Supplier; import java.util.stream.Stream; import org.junit.jupiter.api.Named; @@ -86,6 +88,32 @@ static Stream> bundleDirs() throws IOException { * class name) keeps the bundle-to-fixture mapping explicit and compiler-checked — * every case is a real, statically resolved constructor call. */ + /** + * A bundle is one oath ({@code example.md}) plus, for a bundle that exercises reference blocks + * (ADR 0016), the other oaths it links to — every other {@code .md} in the bundle directory. + * They are parsed under their bare file names, so {@code ./shared.md} resolves the same way in + * every port. + */ + private static List bundleDocs(Path bundle) throws IOException { + try (var files = Files.list(bundle)) { + List paths = files.filter(p -> p.getFileName().toString().endsWith(".md")) + .sorted() + .toList(); + List docs = new java.util.ArrayList<>(paths.size()); + for (Path path : paths) { + docs.add(Parse.parse(path.getFileName().toString(), Files.readString(path, StandardCharsets.UTF_8))); + } + return docs; + } + } + + private static Ast.Doc exampleDoc(List docs) { + return docs.stream() + .filter(d -> d.path().equals("example.md")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("bundle has no example.md")); + } + private static StepDefinitions loadFixture(String bundleName) { return switch (bundleName) { case "01-roman-numerals" -> new dev.varar.conformance.bundle01.NumeralsSteps(); @@ -107,6 +135,8 @@ private static StepDefinitions loadFixture(String bundleName) { case "17-unexpected-pass" -> new dev.varar.conformance.bundle17.QuietSteps(); case "18-multi-table-example" -> new dev.varar.conformance.bundle18.BasketSteps(); case "19-emphasis-parameter" -> new dev.varar.conformance.bundle19.MentionSteps(); + case "20-reference-splice" -> new dev.varar.conformance.bundle20.LibrarySteps(); + case "21-reference-consumed" -> new dev.varar.conformance.bundle21.LibrarySteps(); default -> throw new IllegalStateException("No Java step fixture registered for bundle " + bundleName); }; } @@ -145,9 +175,9 @@ void planMatchesGolden(Path bundle) throws IOException { Steps.Bound bound = Steps.bind(fixture); Registry registry = bound.registry(); - String source = Files.readString(bundle.resolve("example.md"), StandardCharsets.UTF_8); - Ast.Doc doc = Parse.parse("example.md", source); - Plan.ExecutionPlan plan = Plan.plan(doc, registry); + List docs = bundleDocs(bundle); + Ast.Doc doc = exampleDoc(docs); + Plan.ExecutionPlan plan = Plan.plan(doc, registry, Reference.buildWorkspace(docs)); var artifact = Conformance.toPlanArtifact(plan); Object actual = JsonValue.normalize(artifact); @@ -182,10 +212,11 @@ void traceMatchesGolden(Path bundle) throws IOException { Registry registry = bound.registry(); Supplier contextFactory = bound.stateFactory(); - String source = Files.readString(bundle.resolve("example.md"), StandardCharsets.UTF_8); - Ast.Doc doc = Parse.parse("example.md", source); + List docs = bundleDocs(bundle); + Ast.Doc doc = exampleDoc(docs); - Conformance.BundleArtifacts artifacts = Conformance.runConformance(doc, registry, contextFactory); + Conformance.BundleArtifacts artifacts = + Conformance.runConformance(doc, registry, contextFactory, Reference.buildWorkspace(docs)); Object actual = JsonValue.normalize(artifacts.trace()); Object expected = JsonValue.normalize(JsonValue.parse( From 37d43c56d936bef602373819839e7fd2138e7f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 11:26:05 +0100 Subject: [PATCH 15/21] docs(website): reuse setup by linking to a section Adds the how-to (Share setup between examples) and the explanation (Reuse is a link), both draft-gated until the release that carries the feature. The examples reference gains reference blocks as a fourth block role, with the three error codes; the Cucumber migration page's Background row stops saying "no equivalent"; the agent-instructions block gains the depth guidance, since an agent is the author most likely to build a chain no human would. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- .../src/content/docs/explanation/reuse.md | 115 +++++++++++++++ .../explanation/varar-for-cucumber-users.md | 9 +- .../content/docs/how-to/agent-instructions.md | 5 + .../how-to/share-setup-between-examples.md | 135 ++++++++++++++++++ .../src/content/docs/reference/examples.mdx | 47 ++++++ 5 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 typescript/packages/website/src/content/docs/explanation/reuse.md create mode 100644 typescript/packages/website/src/content/docs/how-to/share-setup-between-examples.md diff --git a/typescript/packages/website/src/content/docs/explanation/reuse.md b/typescript/packages/website/src/content/docs/explanation/reuse.md new file mode 100644 index 00000000..c30b6902 --- /dev/null +++ b/typescript/packages/website/src/content/docs/explanation/reuse.md @@ -0,0 +1,115 @@ +--- +title: Reuse is a link +description: Why Varar's answer to repeated setup is a Markdown link rather than a Background keyword — and why the tool leaves nesting depth to your judgement. +draft: true +--- + +Cucumber has `Background:`. Varar has no keyword for reuse at all, and for a +long time had no mechanism either — the advice was "inline the steps into the +examples that need them". This page explains what changed, and what deliberately +did not. + +## First, the altitude argument + +Most repeated setup is not a reuse problem. It is a step nobody wrote: + +```markdown +I create a user "maya". I verify her email. I give her a library card. +I add *Dune* to the catalogue. I add *Emma* to the catalogue. +``` + +Five sentences, one idea. The fix is not to share them — it is to say the idea: + +```markdown +Maya has a card at a library holding *Dune* and *Emma*. +``` + +This is the same argument as [thin steps](/explanation/thin-steps/), and it +covers most of what `Background:` is used for in Cucumber suites. Any reuse +mechanism risks licensing low-altitude setup that should have been collapsed, +which is why Varar had none for so long. + +## The residue + +Three cases the altitude argument does not cover: + +- setup that genuinely is several *distinct* facts, and that the reader must see + to judge the example — collapsing it hides the world state the oath is about; +- setup shared across several oath *files*, where the step definition is the + only shared thing and the prose is copy-pasted; +- world states that deserve a name and a definition of their own — "a stocked + library", "a tenant mid-trial". + +## Why a link + +Markdown already has the construct: GitHub slugs every heading into an anchor, +and a link to one is clickable before any tool touches it. So a block whose whole +content is a link to an oath section splices that section's steps in at that +point: + +```markdown +[An overdue loan](./shared/an-overdue-loan.md#an-overdue-loan) + +When she asks to borrow *Beloved* on June 10, 2026, the library refuses. +``` + +Three things fall out of that choice. + +**It reads before it runs.** A reader who does not know what "an overdue loan" +assumes clicks the link and finds out. A `Background:` block, by contrast, is +invisible from inside the scenario that depends on it. + +**It works anywhere, not just at the top.** Because the reference splices at its +own position, the same construct gives you a shared *act* in the middle of an +example and shared *assertions* at the end — neither of which `Background:` can +express. + +**It stays block structure.** Varar's rule is that [the markup is +yours](/explanation/markup-is-yours/): format plugins own block structure, and +nobody touches inline text. A link-only *block* is structure. A sentence with a +link inside it would have put reference syntax in the middle of text the matcher +reads, so that spelling was rejected even though it reads more naturally. + +## A referenced section is not also an example + +Once an oath links to a section, that section runs *where it is referenced* and +nowhere else. The alternative — running it standalone as well — would duplicate +every shared section across the report, double its cost, and show one failure +N+1 times. + +This has a consequence worth knowing: whether a section is a test depends on +whether anything else in the project links to it. That is why Varar builds a +project-wide view before planning any oath, and why a filtered run +(`vitest one.md`, `pytest one_dir/`) still discovers every oath first. A test's +existence must not depend on which files you happened to run. + +## Depth is a style question, not a rule + +A referenced section may contain references of its own, to any depth. Deep chains +are bad practice — a reader who must open three files to learn the world state +has lost more than the repetition saved — but Varar does not enforce a ceiling. + +The line is that the tool enforces what is **checkable**: an anchor resolves, a +step matches, a claimed value holds. What is **tasteful** is left to prose, to +review, and to the guidance in your agent's instructions. A depth limit would be +the first place Varar told an author that a correct document was disallowed on +style grounds, and that is a bad precedent to set in a tool whose whole premise +is that the document belongs to you. + +What is enforced is what cannot be argued with: a link that resolves to no oath, +a link to a section with no steps, and a cycle are all errors that fail the run. + +## What this is not + +- **Not a fixture graph.** A reference splices *steps*, in document order. There + is no dependency resolution, no caching of state between examples, no + ordering beyond what you wrote. +- **Not parameterised.** A reference names a section; it does not pass arguments + to it. If the shared setup needs to vary, that is usually the signal that it + wants to be a step with a parameter. +- **Not remote.** Targets are relative paths within your project. Linking an + executable oath across organisations is an interesting idea and a separate + one — it brings supply chain, offline builds and pinning with it. + +See [Share setup between examples](/how-to/share-setup-between-examples/) for the +mechanics. diff --git a/typescript/packages/website/src/content/docs/explanation/varar-for-cucumber-users.md b/typescript/packages/website/src/content/docs/explanation/varar-for-cucumber-users.md index 36e772c6..09ca8b41 100644 --- a/typescript/packages/website/src/content/docs/explanation/varar-for-cucumber-users.md +++ b/typescript/packages/website/src/content/docs/explanation/varar-for-cucumber-users.md @@ -29,7 +29,7 @@ bound by matching phrases in the text. | `Scenario Outline` + `Examples:` table | A [**header-bound table**](/reference/examples/#header-bound-tables): one step whose parameters name every column, and each data row runs as its own example. | | `World` and untyped state | `steps` — a typed state factory per oath; every example starts fresh. | | `Before` / `After` hooks | None in Varar. Use your test runner's own `beforeEach` / `afterEach`. | -| `Background:` | No equivalent. Inline its steps into the examples that need them. | +| `Background:` | A [**reference block**](/how-to/share-setup-between-examples/): a link to the section that describes the shared world state splices its steps in. It also works mid-example and at the end, across files, and is visible at the point of use. | | Tags | Not in v1. | | A separate test-run artefact | The document *is* the test. There is no report that drifts from the docs, because the docs are what ran. | @@ -49,8 +49,11 @@ What to expect in the translated result: - **A `Scenario Outline` with an `Examples:` table becomes a [header-bound table](/reference/examples/#header-bound-tables)** — a single step whose parameters name the columns, with each row running as its own test. -- **`Background:` is inlined** into the examples that need it (there are no - lifecycle hooks in the BDD layer). +- **`Background:` becomes a linked section.** Write the shared world state once + under its own heading and link to it from each example that needs it — see + [Share setup between examples](/how-to/share-setup-between-examples/). If the + background is really one idea spelled out in several steps, write the step it + wants to be instead (there are no lifecycle hooks in the BDD layer). - **Unmatched lines become prose.** There is no undefined-step report: in Cucumber an unmatched step is an error with a generated snippet; in Varar an unmatched sentence is simply prose, which is what lets the document be a diff --git a/typescript/packages/website/src/content/docs/how-to/agent-instructions.md b/typescript/packages/website/src/content/docs/how-to/agent-instructions.md index 899b9f6d..c21327b1 100644 --- a/typescript/packages/website/src/content/docs/how-to/agent-instructions.md +++ b/typescript/packages/website/src/content/docs/how-to/agent-instructions.md @@ -38,6 +38,11 @@ or fix a bug, you must: 5. When the suite is green and you believe the feature is complete, stop and summarise what you changed. Do not refactor unrelated code. +When several examples share a world state, write it once under its own +heading and link to it — but keep the chain shallow: one level, two at the +outside. If setup repeats because it is one idea spelled out in several +sentences, write the missing step instead of sharing the sentences. + The oath is the contract. If you cannot satisfy the oath, surface the disagreement instead of changing the oath to match your implementation. ``` diff --git a/typescript/packages/website/src/content/docs/how-to/share-setup-between-examples.md b/typescript/packages/website/src/content/docs/how-to/share-setup-between-examples.md new file mode 100644 index 00000000..c0690543 --- /dev/null +++ b/typescript/packages/website/src/content/docs/how-to/share-setup-between-examples.md @@ -0,0 +1,135 @@ +--- +title: Share setup between examples +description: Reuse a world state across examples by linking to the section that describes it — in the same oath or another one. +draft: true +--- + +When several examples start from the same world state, write that state once, +under its own heading, and **link to it**. A block whose entire content is a +link to an oath section splices that section's steps in at that point. + +## Before you start + +- Oaths that repeat the same setup, where the repeated part is several distinct + facts the reader needs to *see*. +- If the repetition is really one idea spelled out in five sentences, write the + step you are missing instead — see [Is this reuse, or a missing + step?](#is-this-reuse-or-a-missing-step) below. + +## 1. Give the world state a section + +Put the shared setup under a heading, conventionally in `varar/shared/`: + +```markdown + +# Shared world states + +The sections below are linked from the oaths that need them, and run there. + +## An overdue loan + +Noor borrowed *Kindred*, due back on June 1, 2026. +``` + +## 2. Link to it from each example + +```markdown + +# Borrowing while overdue + +[An overdue loan](./shared/an-overdue-loan.md#an-overdue-loan) + +When she asks to borrow *Beloved* on June 10, 2026, the library refuses. +``` + +The link is an ordinary Markdown link: it renders on GitHub, and clicking it +takes the reader to the world state the example assumes. The anchor is the +heading's GFM slug — the same anchor GitHub generates. + +A reference works three ways: + +- **`#a-heading`** — a section of the same oath. +- **`./other.md#a-heading`** — a section of another oath, path relative to the + oath doing the linking. +- **`./other.md`** — the whole of another oath. + +All three spellings of the *block* mean the same thing, so pick what reads best: + +```markdown +[An overdue loan](./shared/an-overdue-loan.md#an-overdue-loan) + +> [An overdue loan](./shared/an-overdue-loan.md#an-overdue-loan) + +- [An overdue loan](./shared/an-overdue-loan.md#an-overdue-loan) +- [Fees are enabled](./shared/billing.md#fees-are-enabled) +``` + +A link-only block pointing anywhere else — `https://…`, a `.ts` file — is +ordinary prose, exactly as before. + +## 3. Reference from anywhere in the example + +Because a reference splices steps in *at its own position*, it is not limited to +setup. A shared act, mid-example: + +```markdown +Maya borrows *Emma* on May 25, 2026. + +[The nightly batch runs](./shared/jobs.md#the-nightly-batch) + +Her account shows a £0.50 fee. +``` + +Or shared assertions, at the end: + +```markdown +Maya returns *Emma* late and pays the fee. + +[The ledger invariants hold](./shared/invariants.md#the-ledger-invariants-hold) +``` + +The spliced steps share the example's state, exactly as if you had written them +in place. + +## What to expect + +- **A referenced section stops being a standalone example.** It runs where it is + referenced, once per referencing example — not twice. +- **The example keeps its own name.** An example that opens with a reference is + named after its own first matching paragraph, not the section it pulls in. +- **A broken link fails the run.** A link to a file that is not an oath, or to a + heading that contributes no steps, is an error — never silently prose. +- **Failures point at the file the step was written in.** A mismatch inside a + shared section reports against that section's source, not the oath that + referenced it. + +## Is this reuse, or a missing step? + +Varar's first answer to repetition is not a reference — it is **a step at the +right altitude**. This: + +```markdown +I create a user "maya". I verify her email. I give her a library card. +I add *Dune* to the catalogue. I add *Emma* to the catalogue. +``` + +wants to be one sentence: + +```markdown +Maya has a card at a library holding *Dune* and *Emma*. +``` + +Reach for a reference when the setup is several facts the reader must see to +judge the example — not when it is one idea nobody has named yet. + +## How deep to nest + +A referenced section may itself contain references, to any depth. Varar does not +stop you, because the parser cannot tell a justified chain from a careless one. +**One level, two at the outside.** A reader who has to open three files to learn +what the world state is has lost more than the repetition saved — and so has the +agent generating the next example. If a chain is getting deep, that is the signal +to collapse it into a step. + +A cycle — a chain that reaches a section already on it — is an error, reported +with the whole chain. diff --git a/typescript/packages/website/src/content/docs/reference/examples.mdx b/typescript/packages/website/src/content/docs/reference/examples.mdx index 3c3a9d21..b53e0788 100644 --- a/typescript/packages/website/src/content/docs/reference/examples.mdx +++ b/typescript/packages/website/src/content/docs/reference/examples.mdx @@ -103,6 +103,53 @@ ends it — the steps after the prose start a fresh example and do not see the earlier state. Keep narration before or after an example, not in the middle of it. +## Reference blocks + +There is a fourth kind of block, beside an example, prose, and an attachment: a +block whose **entire content is a single Markdown link to an oath section** is a +**reference block**. It splices that section's steps in at its own position, +sharing the example's state: + +```markdown +[An overdue loan](./shared/an-overdue-loan.md#an-overdue-loan) + +When she asks to borrow *Beloved* on June 10, 2026, the library refuses. +``` + +A paragraph, a blockquote or a list item all spell it. The target is either a +`#fragment` (a section of the same oath), a relative `.md` path with a fragment +(a section of another oath), or a relative `.md` path alone (that whole oath). +Anchors are GFM heading slugs — the anchors GitHub itself generates. A link-only +block with any other target (`https://…`, a `.ts` file) is prose, as before. + +Reference blocks are **not delimiters**: a matching paragraph after one continues +the same example. Because the splice happens at the block's own position, a +reference also works in the middle of an example (a shared act) and at its end +(shared assertions) — see +[Share setup between examples](/how-to/share-setup-between-examples/). + +Three rules follow: + +- **A referenced section stops being a standalone example.** It runs where it is + referenced, once per referencing example. +- **The referring example keeps its own name** — its first matching paragraph, + not the section it pulls in. +- **References nest to any depth.** Depth is a style question Varar does not + enforce; a cycle is an error. + +Four authoring mistakes fail the run rather than degrading to prose: + +| Code | Means | +| ---- | ----- | +| `reference-not-found` | The target is not an oath in this workspace (wrong path, or not matched by the `docs` globs). | +| `reference-empty` | The document exists but the section contributes no steps — a mistyped anchor, or a section that is pure prose. | +| `reference-cycle` | A chain of references reaches a section already on it. Reported with the whole chain. | + +A dangling reference is deliberately *not* [drift](#drift-detection): drift is +"this used to match and now reads as prose", which needs an acknowledgment +because prose is a legitimate destination. A link-only block that resolves to +nothing has no legitimate reading. + ## Tables and doc strings A table or a fenced code block (a **doc string**) is not an example on its own. From 966cc3be23f27e004c0c9a2ab28d954391072797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Fri, 11 Sep 2026 11:26:20 +0100 Subject: [PATCH 16/21] docs(adr): mark ADR 0016 accepted and record what shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the three deliberate deviations (recognition in plan() rather than the structurer, sections resolved through the scope stack, and the added paramTexts), the .NET divergence the new corpus bundle caught on its first run, and what is still open — chiefly that docPath does not yet reach the persisted run-result payload, so a failure inside a referenced section is not placed correctly in editors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSCLupVART3c5PjiffmahX --- doc/adr/0016-reuse-is-a-link.md | 67 ++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/doc/adr/0016-reuse-is-a-link.md b/doc/adr/0016-reuse-is-a-link.md index 5bf47c73..7bbde654 100644 --- a/doc/adr/0016-reuse-is-a-link.md +++ b/doc/adr/0016-reuse-is-a-link.md @@ -1,7 +1,7 @@ # ADR 0016 — Reuse is a link: reference blocks instead of `Background` -- **Status:** Draft -- **Date:** 2026-09-11 +- **Status:** Accepted (implemented) +- **Date:** 2026-09-11 (implemented 2026-09-11) - **Deciders:** Aslak Hellesøy - **Tags:** spec, parsing, gfm, reuse, cross-language @@ -590,3 +590,66 @@ Unresolved; each needs a decision before implementation. saying "no equivalent". New pages ship `draft: true` until the release that carries the feature. + +## What shipped, and how it differs from this plan + +Implemented across all seven ports. Three deliberate deviations, and one bug the +corpus caught: + +1. **Reference blocks are recognised in `plan()`, not the structurer.** The ADR + proposed a new `reference` `Block` kind emitted by `structure()`. Detecting a + link-only block is equally pure in the planner, and keeping it there left + `golden/doc.json` untouched in every port — the var-doc artifact did not have + to change at all. `plan()` returns a reference *unit*, which is where the + splice already had to happen. + +2. **Sections resolve through the scope stack, not a heading index.** A + candidate belongs to a section iff the section's slug is in its `scopeStack` + — which is exactly "from this heading until the next of the same or higher + level", already computed. No `headings` field was added to `Doc`, so no + golden moved. The cost is the **ambiguous-anchor** error from the Errors + list: two headings in one file that slug identically are indistinguishable + this way, so that case is not detected. It remains open (see below). + +3. **`PlannedStep` gained `paramTexts` as well as `docPath`.** Not in the plan, + and necessary: consumers sliced the *running* oath's source by a step's + param spans to recover the matched notation (the conformance artifact's + `args[].value`, the LSP's rename values). For a spliced step those spans + belong to another document, so the slice returned whatever text sat at those + offsets. Slicing at plan time, from the document the step was written in, + removes the hazard at its source rather than teaching each consumer about it. + +4. **The corpus caught a real divergence.** `21-reference-consumed` was green in + six ports and red in .NET: `MergedExample.ScopeStack` was `init`-only, so the + name-replacement rule updated the name but left the *referenced* section's + heading chain on the example. Exactly the failure mode the bundle exists to + catch, caught on its first run. + +Two adapter-level notes: + +- **vitest's zero-test file** was handled as decided, but the placeholder is a + single bookkeeping test rather than an empty `describe.skip`. A skipped suite + keeps vitest happy, but no test body runs — and the drift baseline and the + `.varar` run record are both written from a test body, so a consumed oath + would have silently dropped out of `varar.lock.json` (the adapter smoke + contract's `baseline-complete` check caught this). One `varar:referenced- + elsewhere` test attaches both, alongside the `varar:diagnostic:*` and + `varar:stale-oath-transform` tests the runtime already registers. +- **`smoke.sh` now globs oaths recursively.** It listed `varar/*.md`, which + cannot see the `varar/shared/` convention this ADR introduces. + +### Still open + +- **Run-result v2 (ADR 0014).** `docPath` reaches the plan and the plan artifact, + but *not* the persisted `.varar/.json` payload. Until it does, a failure + inside a referenced section is reported to the LSP with spans in that section's + document and a `sourceHash` for the referencing one, so the editor will not + place it. **A mismatch inside a shared section is therefore not yet rendered + correctly in editors** — the run still fails, with the correct message, in + every runner. This is the next piece of work, and it is a cross-port payload + change with its own golden. +- **Ambiguous anchors** (deviation 2) are undetected; the lint rule requiring + unique headings in a referenced file is not written. +- **LSP reference support** — go-to-definition and hover on a reference block — + is not implemented; the block is inert in the editor beyond ordinary Markdown. +- Open questions 2–6, 8 and 9 stand as written. From 691b2c4eb49a6fe453a248b5a02bad9230e0c157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Mon, 14 Sep 2026 12:12:19 +0100 Subject: [PATCH 17/21] fix(spec): an example a reference block opens is placed at the reference block, under its own headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The steps a reference splices in keep their spans in the document they were written in — that is the point of docPath — but the EXAMPLE they open belongs to the referring document. The merged example took its start/end offsets and its heading chain from the spliced unit, so finishMerged() read another file's offsets against the host source: the example landed on an unrelated line (or past the end of the file), and a reference-only example sat under the shared section's headings instead of its own. The example now starts and ends at the reference block, extends only when a paragraph of its own follows, and keeps the referring document's scopeStack. Bundle 20's golden pinned the wrong span and moves; 23-reference-only-example pins an example that is nothing but a reference. The dogfood baseline had recorded a prose paragraph as live because the wrong span covered it — it is re-recorded. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RQVAKGfMEKPbT919FqAChP --- .../20-reference-splice/golden/plan.json | 4 +- .../LibrarySteps.java | 22 +++++ .../23-reference-only-example/example.md | 8 ++ .../23-reference-only-example/golden/doc.json | 76 +++++++++++++++++ .../golden/plan.json | 81 +++++++++++++++++++ .../golden/registry.json | 21 +++++ .../golden/trace.json | 32 ++++++++ .../library.steps.cs | 28 +++++++ .../library.steps.go | 31 +++++++ .../library.steps.kt | 17 ++++ .../library.steps.py | 18 +++++ .../library.steps.rb | 9 +++ .../library.steps.rs | 26 ++++++ .../library.steps.ts | 9 +++ .../23-reference-only-example/shared.md | 5 ++ dotnet/Varar.Core/Plan.cs | 24 ++++-- dotnet/Varar.Tests/ConformanceFixtures.cs | 2 + examples/typescript-vitest/varar.lock.json | 4 - go/conformance/b23/library.steps.go | 31 +++++++ go/conformance/conformance_test.go | 2 + go/core/plan.go | 18 ++++- go/core/plan_test.go | 58 +++++++++++++ .../src/main/java/dev/varar/core/Plan.java | 25 ++++-- .../dev/varar/kotlin/ConformanceTest.kt | 2 + .../test/java/dev/varar/ConformanceTest.java | 1 + python/packages/core/src/varar_core/plan.py | 21 ++++- python/packages/core/tests/test_plan.py | 63 +++++++++++++++ ruby/packages/core/lib/varar/core/plan.rb | 16 +++- .../core/spec/varar/core/plan_spec.rb | 29 +++++++ rust/core/src/plan.rs | 26 ++++-- rust/core/tests/plan_test.rs | 43 +++++++++- rust/varar/tests/conformance.rs | 3 + typescript/packages/core/src/plan.ts | 20 ++++- 33 files changed, 736 insertions(+), 39 deletions(-) create mode 100644 conformance/bundles/23-reference-only-example/LibrarySteps.java create mode 100644 conformance/bundles/23-reference-only-example/example.md create mode 100644 conformance/bundles/23-reference-only-example/golden/doc.json create mode 100644 conformance/bundles/23-reference-only-example/golden/plan.json create mode 100644 conformance/bundles/23-reference-only-example/golden/registry.json create mode 100644 conformance/bundles/23-reference-only-example/golden/trace.json create mode 100644 conformance/bundles/23-reference-only-example/library.steps.cs create mode 100644 conformance/bundles/23-reference-only-example/library.steps.go create mode 100644 conformance/bundles/23-reference-only-example/library.steps.kt create mode 100644 conformance/bundles/23-reference-only-example/library.steps.py create mode 100644 conformance/bundles/23-reference-only-example/library.steps.rb create mode 100644 conformance/bundles/23-reference-only-example/library.steps.rs create mode 100644 conformance/bundles/23-reference-only-example/library.steps.ts create mode 100644 conformance/bundles/23-reference-only-example/shared.md create mode 100644 go/conformance/b23/library.steps.go diff --git a/conformance/bundles/20-reference-splice/golden/plan.json b/conformance/bundles/20-reference-splice/golden/plan.json index 18e0265a..2795e399 100644 --- a/conformance/bundles/20-reference-splice/golden/plan.json +++ b/conformance/bundles/20-reference-splice/golden/plan.json @@ -11,9 +11,9 @@ "endCol": 42, "endLine": 7, "endOffset": 177, - "startCol": 31, + "startCol": 1, "startLine": 5, - "startOffset": 114 + "startOffset": 84 }, "steps": [ { diff --git a/conformance/bundles/23-reference-only-example/LibrarySteps.java b/conformance/bundles/23-reference-only-example/LibrarySteps.java new file mode 100644 index 00000000..e022d00b --- /dev/null +++ b/conformance/bundles/23-reference-only-example/LibrarySteps.java @@ -0,0 +1,22 @@ +package dev.varar.conformance.bundle23; + +import dev.varar.State; +import dev.varar.StepDefinitions; +import dev.varar.Steps; + +/** Java sibling of {@code library.steps.ts} / {@code library.steps.py} (bundle {@code 23-reference-only-example}). */ +public final class LibrarySteps implements StepDefinitions { + + record Ctx(int shelf) implements State {} + + @Override + public void register(Steps s) { + s.state(() -> new Ctx(0)); + + s.stimulus("I shelve {int} books", (Ctx ctx, Integer n) -> new Ctx(ctx.shelf() + n)); + + s.stimulus("I borrow a book", (Ctx ctx) -> new Ctx(ctx.shelf() - 1)); + + s.sensor("The shelf holds {int} books", (Ctx ctx, Integer n) -> ctx.shelf()); + } +} diff --git a/conformance/bundles/23-reference-only-example/example.md b/conformance/bundles/23-reference-only-example/example.md new file mode 100644 index 00000000..38095a82 --- /dev/null +++ b/conformance/bundles/23-reference-only-example/example.md @@ -0,0 +1,8 @@ +# Late fees + +## Shelf invariants + +An example that is nothing but a reference. It belongs to THIS document — its +headings, its span — and only its steps come from the section it links to. + +[A stocked library](./shared.md#a-stocked-library) diff --git a/conformance/bundles/23-reference-only-example/golden/doc.json b/conformance/bundles/23-reference-only-example/golden/doc.json new file mode 100644 index 00000000..c93c86a4 --- /dev/null +++ b/conformance/bundles/23-reference-only-example/golden/doc.json @@ -0,0 +1,76 @@ +{ + "examples": [ + { + "body": [ + { + "kind": "paragraph", + "segmentMap": [ + { + "sourceOffset": 34, + "textOffset": 0 + } + ], + "span": { + "endCol": 75, + "endLine": 6, + "endOffset": 186, + "startCol": 1, + "startLine": 5, + "startOffset": 34 + }, + "text": "An example that is nothing but a reference. It belongs to THIS document — its\nheadings, its span — and only its steps come from the section it links to." + } + ], + "precededByDelimiter": true, + "scopeStack": [ + "Late fees", + "Shelf invariants" + ], + "span": { + "endCol": 75, + "endLine": 6, + "endOffset": 186, + "startCol": 1, + "startLine": 5, + "startOffset": 34 + } + }, + { + "body": [ + { + "kind": "paragraph", + "segmentMap": [ + { + "sourceOffset": 188, + "textOffset": 0 + } + ], + "span": { + "endCol": 51, + "endLine": 8, + "endOffset": 238, + "startCol": 1, + "startLine": 8, + "startOffset": 188 + }, + "text": "[A stocked library](./shared.md#a-stocked-library)" + } + ], + "precededByDelimiter": false, + "scopeStack": [ + "Late fees", + "Shelf invariants" + ], + "span": { + "endCol": 51, + "endLine": 8, + "endOffset": 238, + "startCol": 1, + "startLine": 8, + "startOffset": 188 + } + } + ], + "orphanAttachments": [], + "path": "example.md" +} diff --git a/conformance/bundles/23-reference-only-example/golden/plan.json b/conformance/bundles/23-reference-only-example/golden/plan.json new file mode 100644 index 00000000..173519f8 --- /dev/null +++ b/conformance/bundles/23-reference-only-example/golden/plan.json @@ -0,0 +1,81 @@ +{ + "diagnostics": [], + "examples": [ + { + "expectedOutcome": "pass", + "name": "I shelve 3 books. The shelf holds 3 books", + "scopeStack": [ + "Late fees", + "Shelf invariants" + ], + "span": { + "endCol": 51, + "endLine": 8, + "endOffset": 238, + "startCol": 1, + "startLine": 8, + "startOffset": 188 + }, + "steps": [ + { + "args": [ + { + "parameterType": "int", + "value": "3" + } + ], + "docPath": "shared.md", + "matchSpan": { + "endCol": 17, + "endLine": 5, + "endOffset": 61, + "startCol": 1, + "startLine": 5, + "startOffset": 45 + }, + "matchedExpression": "I shelve {int} books", + "paramSpans": [ + { + "endCol": 11, + "endLine": 5, + "endOffset": 55, + "startCol": 10, + "startLine": 5, + "startOffset": 54 + } + ], + "text": "I shelve 3 books" + }, + { + "args": [ + { + "parameterType": "int", + "value": "3" + } + ], + "docPath": "shared.md", + "matchSpan": { + "endCol": 42, + "endLine": 5, + "endOffset": 86, + "startCol": 19, + "startLine": 5, + "startOffset": 63 + }, + "matchedExpression": "The shelf holds {int} books", + "paramSpans": [ + { + "endCol": 36, + "endLine": 5, + "endOffset": 80, + "startCol": 35, + "startLine": 5, + "startOffset": 79 + } + ], + "text": "The shelf holds 3 books" + } + ] + } + ] +} diff --git a/conformance/bundles/23-reference-only-example/golden/registry.json b/conformance/bundles/23-reference-only-example/golden/registry.json new file mode 100644 index 00000000..aa29f085 --- /dev/null +++ b/conformance/bundles/23-reference-only-example/golden/registry.json @@ -0,0 +1,21 @@ +{ + "parameterTypes": [], + "steps": [ + { + "expression": "I shelve {int} books", + "parameterTypeNames": [ + "int" + ] + }, + { + "expression": "I borrow a book", + "parameterTypeNames": [] + }, + { + "expression": "The shelf holds {int} books", + "parameterTypeNames": [ + "int" + ] + } + ] +} diff --git a/conformance/bundles/23-reference-only-example/golden/trace.json b/conformance/bundles/23-reference-only-example/golden/trace.json new file mode 100644 index 00000000..b6723814 --- /dev/null +++ b/conformance/bundles/23-reference-only-example/golden/trace.json @@ -0,0 +1,32 @@ +{ + "examples": [ + { + "name": "I shelve 3 books. The shelf holds 3 books", + "outcome": "pass", + "steps": [ + { + "contextKey": { + "exampleName": "I shelve 3 books. The shelf holds 3 books", + "stepFile": "library.steps" + }, + "exampleName": "I shelve 3 books. The shelf holds 3 books", + "matchedExpression": "I shelve {int} books", + "ordinal": 1, + "outcome": "pass", + "stepText": "I shelve 3 books" + }, + { + "contextKey": { + "exampleName": "I shelve 3 books. The shelf holds 3 books", + "stepFile": "library.steps" + }, + "exampleName": "I shelve 3 books. The shelf holds 3 books", + "matchedExpression": "The shelf holds {int} books", + "ordinal": 2, + "outcome": "pass", + "stepText": "The shelf holds 3 books" + } + ] + } + ] +} diff --git a/conformance/bundles/23-reference-only-example/library.steps.cs b/conformance/bundles/23-reference-only-example/library.steps.cs new file mode 100644 index 00000000..02b20f79 --- /dev/null +++ b/conformance/bundles/23-reference-only-example/library.steps.cs @@ -0,0 +1,28 @@ +// C# sibling of library.steps.ts / .rs (bundle 23-reference-only-example). +using Varar; +using Varar.Core; + +namespace Varar.Corpus.B23; + +public static class LibrarySteps +{ + public static void Register(Steps s) + { + s.Stimulus( + "I shelve {int} books", + (state, n) => Value.Map([new("shelf", Value.Of(ShelfOf(state) + AsLong(n)))])); + + s.Stimulus( + "I borrow a book", + state => Value.Map([new("shelf", Value.Of(ShelfOf(state) - 1))])); + + s.Sensor("The shelf holds {int} books", (state, n) => Value.Of(ShelfOf(state))); + } + + public static Value State() => Value.Map([new("shelf", Value.Of(0))]); + + private static long ShelfOf(Value state) => + state is VMap m && m.Entries.TryGetValue("shelf", out var v) && v is VInt i ? i.Int : 0; + + private static long AsLong(Value v) => v is VInt i ? i.Int : 0; +} diff --git a/conformance/bundles/23-reference-only-example/library.steps.go b/conformance/bundles/23-reference-only-example/library.steps.go new file mode 100644 index 00000000..e4f07c2e --- /dev/null +++ b/conformance/bundles/23-reference-only-example/library.steps.go @@ -0,0 +1,31 @@ +// Go sibling of library.steps.ts (bundle 23-reference-only-example). +package fixture + +import "github.com/varar-dev/varar/go/varar" + +func shelfOf(state varar.Value) int { + if m, ok := state.AsMap(); ok { + if c, ok := m["shelf"]; ok { + if n, ok := c.AsInt(); ok { + return int(n) + } + } + } + return 0 +} + +func Register(s *varar.Steps[varar.Value]) { + s.Stimulus("I shelve {int} books", func(state varar.Value, n int) (varar.Value, error) { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(int64(shelfOf(state) + n))}), nil + }) + s.Stimulus("I borrow a book", func(state varar.Value) (varar.Value, error) { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(int64(shelfOf(state) - 1))}), nil + }) + s.Sensor("The shelf holds {int} books", func(state varar.Value, expected int) (int, error) { + return shelfOf(state), nil + }) +} + +func State() varar.Value { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(0)}) +} diff --git a/conformance/bundles/23-reference-only-example/library.steps.kt b/conformance/bundles/23-reference-only-example/library.steps.kt new file mode 100644 index 00000000..2b8c2ad3 --- /dev/null +++ b/conformance/bundles/23-reference-only-example/library.steps.kt @@ -0,0 +1,17 @@ +@file:JvmName("LibrarySteps") + +// Kotlin sibling of library.steps.ts / library.steps.py / LibrarySteps.java +// (bundle 23-reference-only-example). +package dev.varar.kotlin.conformance.bundle23 + +import dev.varar.kotlin.stimulus +import dev.varar.kotlin.steps +import dev.varar.kotlin.sensor + +data class Ctx(val shelf: Int = 0) + +val steps = steps(::Ctx) { + stimulus("I shelve {int} books") { n: Int -> copy(shelf = shelf + n) } + stimulus("I borrow a book") { copy(shelf = shelf - 1) } + sensor("The shelf holds {int} books") { n: Int -> shelf } +} diff --git a/conformance/bundles/23-reference-only-example/library.steps.py b/conformance/bundles/23-reference-only-example/library.steps.py new file mode 100644 index 00000000..56de56e8 --- /dev/null +++ b/conformance/bundles/23-reference-only-example/library.steps.py @@ -0,0 +1,18 @@ +from varar import steps + +param, stimulus, sensor = steps(lambda: {"shelf": 0}) + + +@stimulus("I shelve {int} books") +def _(state, n): + return {"shelf": state["shelf"] + n} + + +@stimulus("I borrow a book") +def _(state): + return {"shelf": state["shelf"] - 1} + + +@sensor("The shelf holds {int} books") +def _(state, n): + return state["shelf"] diff --git a/conformance/bundles/23-reference-only-example/library.steps.rb b/conformance/bundles/23-reference-only-example/library.steps.rb new file mode 100644 index 00000000..a575fdd1 --- /dev/null +++ b/conformance/bundles/23-reference-only-example/library.steps.rb @@ -0,0 +1,9 @@ +require "varar" + +steps(-> { { shelf: 0 } }) do + stimulus("I shelve {int} books") { |state, n| { shelf: state[:shelf] + n } } + + stimulus("I borrow a book") { |state| { shelf: state[:shelf] - 1 } } + + sensor("The shelf holds {int} books") { |state, _n| state[:shelf] } +end diff --git a/conformance/bundles/23-reference-only-example/library.steps.rs b/conformance/bundles/23-reference-only-example/library.steps.rs new file mode 100644 index 00000000..c5a56655 --- /dev/null +++ b/conformance/bundles/23-reference-only-example/library.steps.rs @@ -0,0 +1,26 @@ +//! Rust sibling of `library.steps.ts` (bundle `23-reference-only-example`). + +use varar::Steps; + +#[derive(Clone, Default)] +pub struct Ctx { + pub shelf: i64, +} + +pub fn register(s: &mut Steps) { + s.stimulus("I shelve {int} books", |ctx: Ctx, n: i64| { + Ok(Ctx { + shelf: ctx.shelf + n, + }) + }); + s.stimulus("I borrow a book", |ctx: Ctx| { + Ok(Ctx { + shelf: ctx.shelf - 1, + }) + }); + s.sensor("The shelf holds {int} books", |ctx: Ctx, _expected: i64| Ok(ctx.shelf)); +} + +pub fn state() -> Ctx { + Ctx::default() +} diff --git a/conformance/bundles/23-reference-only-example/library.steps.ts b/conformance/bundles/23-reference-only-example/library.steps.ts new file mode 100644 index 00000000..3fce1e0e --- /dev/null +++ b/conformance/bundles/23-reference-only-example/library.steps.ts @@ -0,0 +1,9 @@ +import { steps } from '@varar/varar' + +const { stimulus, sensor } = steps<{ shelf: number }>(() => ({ shelf: 0 })) + +stimulus('I shelve {int} books', (state, n) => ({ shelf: state.shelf + n })) + +stimulus('I borrow a book', (state) => ({ shelf: state.shelf - 1 })) + +sensor('The shelf holds {int} books', (state) => state.shelf) diff --git a/conformance/bundles/23-reference-only-example/shared.md b/conformance/bundles/23-reference-only-example/shared.md new file mode 100644 index 00000000..8d805f15 --- /dev/null +++ b/conformance/bundles/23-reference-only-example/shared.md @@ -0,0 +1,5 @@ +# Shared world states + +## A stocked library + +I shelve 3 books. The shelf holds 3 books. diff --git a/dotnet/Varar.Core/Plan.cs b/dotnet/Varar.Core/Plan.cs index 4db95ff6..4d6b869e 100644 --- a/dotnet/Varar.Core/Plan.cs +++ b/dotnet/Varar.Core/Plan.cs @@ -102,19 +102,30 @@ void Flush() var resolved = ResolveReference(ru, doc, registry, workspace, diagnostics, []); for (int i = 0; i < resolved.Count; i++) { + MergedExample current; if (open is not null && (i > 0 || !ru.PrecededByDelimiter)) { MergeInto(open, resolved[i], fromReference: true); + current = open; } else { Flush(); - open = StartMerged(resolved[i]); + current = StartMerged(resolved[i]); // An example that OPENS with a reference is named by its own first - // matching paragraph, not by the section it pulls in. - open.NameFromReference = true; + // matching paragraph, not by the section it pulls in, and it sits + // under THIS document's headings, not the section's. + current.NameFromReference = true; + current.ScopeStack = ru.ScopeStack; + current.StartOffset = ru.Span.StartOffset; + open = current; } + + // A spliced unit's span is in the referenced document; the example's + // span is in this one. It ends at the reference block until a later + // paragraph of the example's own extends it. + current.EndOffset = ru.Span.EndOffset; } break; @@ -151,7 +162,7 @@ private sealed class MergedExample public required ImmutableArray ScopeStack { get; set; } - public required int StartOffset { get; init; } + public required int StartOffset { get; set; } public required int EndOffset { get; set; } @@ -178,7 +189,8 @@ private sealed record HeaderBoundUnit(ImmutableArray Rows) : Can private sealed record ReferenceUnit( Reference Reference, bool PrecededByDelimiter, - Span Span) : CandidateUnit; + Span Span, + ImmutableArray ScopeStack) : CandidateUnit; private sealed record StepsUnit( bool Matched, @@ -314,7 +326,7 @@ private static CandidateUnit PlanCandidate( && Reference_.TextOf(ex.Body[0]) is { } primaryText && Reference_.ReferenceOf(primaryText, doc.Path) is { } reference) { - return new ReferenceUnit(reference, ex.PrecededByDelimiter, ex.Span); + return new ReferenceUnit(reference, ex.PrecededByDelimiter, ex.Span, ex.ScopeStack); } bool hadAmbiguous = false; diff --git a/dotnet/Varar.Tests/ConformanceFixtures.cs b/dotnet/Varar.Tests/ConformanceFixtures.cs index c697d213..a3fc413e 100644 --- a/dotnet/Varar.Tests/ConformanceFixtures.cs +++ b/dotnet/Varar.Tests/ConformanceFixtures.cs @@ -34,6 +34,7 @@ public static class ConformanceFixtures ["19-emphasis-parameter"] = Corpus.B19.MentionSteps.Register, ["20-reference-splice"] = Corpus.B20.LibrarySteps.Register, ["21-reference-consumed"] = Corpus.B21.LibrarySteps.Register, + ["23-reference-only-example"] = Corpus.B23.LibrarySteps.Register, }; ///

Locate the shared corpus directory by walking up from the test binary. @@ -105,6 +106,7 @@ public static Registry Build(string bundle) ["19-emphasis-parameter"] = Corpus.B19.MentionSteps.State, ["20-reference-splice"] = Corpus.B20.LibrarySteps.State, ["21-reference-consumed"] = Corpus.B21.LibrarySteps.State, + ["23-reference-only-example"] = Corpus.B23.LibrarySteps.State, }; /// The bundle's initial-state factory, or a loud failure if none is wired. diff --git a/examples/typescript-vitest/varar.lock.json b/examples/typescript-vitest/varar.lock.json index 4b44211e..193358b7 100644 --- a/examples/typescript-vitest/varar.lock.json +++ b/examples/typescript-vitest/varar.lock.json @@ -61,10 +61,6 @@ "varar/reuse.md": { "sourceHash": "fnv1a:a0e53ea6", "examples": [ - { - "name": "Both examples below start from the same borrowed book. Rather than repeating it, each one links to the section that describes it — the link renders on GitHub, and clicking it takes you to the world state being assumed", - "line": 3 - }, { "name": "[An overdue loan](./shared/an-overdue-loan.md#an-overdue-loan)", "line": 9 diff --git a/go/conformance/b23/library.steps.go b/go/conformance/b23/library.steps.go new file mode 100644 index 00000000..e4f07c2e --- /dev/null +++ b/go/conformance/b23/library.steps.go @@ -0,0 +1,31 @@ +// Go sibling of library.steps.ts (bundle 23-reference-only-example). +package fixture + +import "github.com/varar-dev/varar/go/varar" + +func shelfOf(state varar.Value) int { + if m, ok := state.AsMap(); ok { + if c, ok := m["shelf"]; ok { + if n, ok := c.AsInt(); ok { + return int(n) + } + } + } + return 0 +} + +func Register(s *varar.Steps[varar.Value]) { + s.Stimulus("I shelve {int} books", func(state varar.Value, n int) (varar.Value, error) { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(int64(shelfOf(state) + n))}), nil + }) + s.Stimulus("I borrow a book", func(state varar.Value) (varar.Value, error) { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(int64(shelfOf(state) - 1))}), nil + }) + s.Sensor("The shelf holds {int} books", func(state varar.Value, expected int) (int, error) { + return shelfOf(state), nil + }) +} + +func State() varar.Value { + return varar.MapValue(map[string]varar.Value{"shelf": varar.IntValue(0)}) +} diff --git a/go/conformance/conformance_test.go b/go/conformance/conformance_test.go index 07fb96ec..b6500b54 100644 --- a/go/conformance/conformance_test.go +++ b/go/conformance/conformance_test.go @@ -38,6 +38,7 @@ import ( b19 "github.com/varar-dev/varar/go/conformance/b19" b20 "github.com/varar-dev/varar/go/conformance/b20" b21 "github.com/varar-dev/varar/go/conformance/b21" + b23 "github.com/varar-dev/varar/go/conformance/b23" ) type fixture struct { @@ -67,6 +68,7 @@ var fixtures = map[string]fixture{ "19-emphasis-parameter": {b19.Register, b19.State}, "20-reference-splice": {b20.Register, b20.State}, "21-reference-consumed": {b21.Register, b21.State}, + "23-reference-only-example": {b23.Register, b23.State}, } func bundlesDir() string { return filepath.Join("..", "..", "conformance", "bundles") } diff --git a/go/core/plan.go b/go/core/plan.go index 5a5cc120..6a76eb06 100644 --- a/go/core/plan.go +++ b/go/core/plan.go @@ -120,16 +120,27 @@ func Plan(doc Doc, registry Registry, workspace OathWorkspace) ExecutionPlan { // everything it splices in belongs to the same sequence, so a // section of several paragraphs stays one example. for i, spliced := range resolveReference(unit, doc, registry, workspace, &diagnostics, nil) { + var current *mergedExample if open != nil && (i > 0 || !unit.precededByDelimiter) { mergeInto(open, spliced, true) + current = open } else { flush() - open = startMerged(spliced) + current = startMerged(spliced) // An example that OPENS with a reference is named by its // own first matching paragraph, not by the section it - // pulls in. - open.nameFromReference = true + // pulls in — and it sits under THIS document's headings, + // not the section's. + current.nameFromReference = true + current.scopeStack = unit.scopeStack + current.startOffset = unit.span.StartOffset + open = current } + // A spliced unit's span is in the referenced document; the + // example's span is in this one. It ends at the reference + // block until a later paragraph of the example's own extends + // it. + current.endOffset = unit.span.EndOffset } continue } @@ -323,6 +334,7 @@ func planCandidate(ex Example, doc Doc, registry Registry, diagnostics *[]Diagno return candidateUnit{ reference: ref, precededByDelimiter: ex.PrecededByDelimiter, + scopeStack: ex.ScopeStack, span: ex.Span, } } diff --git a/go/core/plan_test.go b/go/core/plan_test.go index 34f29b56..fb77d94a 100644 --- a/go/core/plan_test.go +++ b/go/core/plan_test.go @@ -172,3 +172,61 @@ func TestMultiTableShapeSurvivesBlankLines(t *testing.T) { t.Errorf("step 1 table rows unexpected") } } + +// An example a reference block opens belongs to the referring document (ADR +// 0016): its span is the reference block in THIS source (extended by any later +// paragraph of its own), and it sits under this document's headings — never +// the referenced section's. +func TestReferenceOpenedExampleBelongsToReferringDocument(t *testing.T) { + shared := Parse("shared.md", "# Setup\n\n## Funded\n\nI have 100 in my account.\n\nI withdraw 40.") + refBlock := "[Funded](./shared.md#funded)" + main := Parse("main.md", "# Late fees\n\n## Balance\n\nSome prose.\n\n"+refBlock+"\n\nI should have 60 left.") + ws := BuildWorkspace([]Doc{shared, main}) + result := Plan(main, bankReg(t), ws) + if len(result.Diagnostics) != 0 { + t.Fatalf("unexpected diagnostics: %+v", result.Diagnostics) + } + if len(result.Examples) != 1 { + t.Fatalf("expected 1 example, got %d", len(result.Examples)) + } + ex := result.Examples[0] + wantSteps := []string{"I have 100 in my account", "I withdraw 40", "I should have 60 left"} + if got := stepTexts(ex); !reflect.DeepEqual(got, wantSteps) { + t.Errorf("steps got %v, want %v", got, wantSteps) + } + if ex.Name != "I should have 60 left" { + t.Errorf("name got %q, want the example's own first matching paragraph", ex.Name) + } + if want := []string{"Late fees", "Balance"}; !reflect.DeepEqual(ex.ScopeStack, want) { + t.Errorf("scopeStack got %v, want %v (the referring document's)", ex.ScopeStack, want) + } + got := main.Source[ex.Span.StartOffset:ex.Span.EndOffset] + if want := refBlock + "\n\nI should have 60 left."; got != want { + t.Errorf("span slices %q, want %q", got, want) + } + for _, s := range ex.Steps[:2] { + if s.DocPath != "shared.md" { + t.Errorf("spliced step %q docPath got %q, want shared.md", s.Text, s.DocPath) + } + } +} + +func TestReferenceOnlyExampleSpansTheReferenceBlock(t *testing.T) { + shared := Parse("shared.md", "# Funded\n\nI have 100 in my account.") + refBlock := "[Funded](./shared.md#funded)" + main := Parse("main.md", "# Late fees\n\nSome prose.\n\n"+refBlock+"\n\nMore prose.") + result := Plan(main, bankReg(t), BuildWorkspace([]Doc{shared, main})) + if len(result.Examples) != 1 { + t.Fatalf("expected 1 example, got %d", len(result.Examples)) + } + ex := result.Examples[0] + if got := main.Source[ex.Span.StartOffset:ex.Span.EndOffset]; got != refBlock { + t.Errorf("span slices %q, want %q", got, refBlock) + } + if want := []string{"Late fees"}; !reflect.DeepEqual(ex.ScopeStack, want) { + t.Errorf("scopeStack got %v, want %v", ex.ScopeStack, want) + } + if ex.Name != "I have 100 in my account" { + t.Errorf("name got %q", ex.Name) + } +} diff --git a/java/core/src/main/java/dev/varar/core/Plan.java b/java/core/src/main/java/dev/varar/core/Plan.java index ccf179b2..30377798 100644 --- a/java/core/src/main/java/dev/varar/core/Plan.java +++ b/java/core/src/main/java/dev/varar/core/Plan.java @@ -155,15 +155,25 @@ public static ExecutionPlan plan(Ast.Doc doc, Registry registry, Reference.OathW List resolved = resolveReference(ru, doc, registry, workspace, diagnostics, List.of()); for (int i = 0; i < resolved.size(); i++) { StepsUnit spliced = resolved.get(i); + MergedExample current; if (open != null && (i > 0 || !ru.precededByDelimiter())) { mergeInto(open, spliced, true); + current = open; } else { if (open != null) examples.add(finishMerged(open, doc.source())); - open = startMerged(spliced); + current = startMerged(spliced); // An example that OPENS with a reference is named by its own first matching - // paragraph, not by the section it pulls in. - open.nameFromReference = true; + // paragraph, not by the section it pulls in — and it sits under THIS + // document's headings, not the section's. + current.nameFromReference = true; + current.scopeStack = ru.scopeStack(); + current.startOffset = ru.span().startOffset(); + open = current; } + // A spliced unit's span is in the referenced document; the example's span is in + // this one. It ends at the reference block until a later paragraph of the + // example's own extends it. + current.endOffset = ru.span().endOffset(); } continue; } @@ -200,9 +210,12 @@ private sealed interface CandidateUnit permits HeaderBoundUnit, StepsUnit, Refer /** * A reference block: its whole text is a link to an oath section, whose steps are spliced in - * here (ADR 0016). Never prose, so it does not close the open example. + * here (ADR 0016). Never prose, so it does not close the open example. Carries the referring + * document's own heading chain and the block's own span: an example this reference opens + * belongs here, not to the section. */ - private record ReferenceUnit(Reference.Ref reference, boolean precededByDelimiter, Span span) + private record ReferenceUnit( + Reference.Ref reference, boolean precededByDelimiter, List scopeStack, Span span) implements CandidateUnit {} /** A header-bound table candidate — standalone, one planned example per data row. */ @@ -374,7 +387,7 @@ private static CandidateUnit planCandidate( if (primaryText != null) { Reference.Ref ref = Reference.referenceOf(primaryText, doc.path()); if (ref != null) { - return new ReferenceUnit(ref, ex.precededByDelimiter(), ex.span()); + return new ReferenceUnit(ref, ex.precededByDelimiter(), ex.scopeStack(), ex.span()); } } } diff --git a/java/kotlin/src/test/kotlin/dev/varar/kotlin/ConformanceTest.kt b/java/kotlin/src/test/kotlin/dev/varar/kotlin/ConformanceTest.kt index 8e85ddc9..5898e6dd 100644 --- a/java/kotlin/src/test/kotlin/dev/varar/kotlin/ConformanceTest.kt +++ b/java/kotlin/src/test/kotlin/dev/varar/kotlin/ConformanceTest.kt @@ -27,6 +27,7 @@ import dev.varar.kotlin.conformance.bundle18.steps as bundle18Steps import dev.varar.kotlin.conformance.bundle19.steps as bundle19Steps import dev.varar.kotlin.conformance.bundle20.steps as bundle20Steps import dev.varar.kotlin.conformance.bundle21.steps as bundle21Steps +import dev.varar.kotlin.conformance.bundle23.steps as bundle23Steps import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path @@ -160,6 +161,7 @@ class ConformanceTest { "19-emphasis-parameter" -> bundle19Steps "20-reference-splice" -> bundle20Steps "21-reference-consumed" -> bundle21Steps + "23-reference-only-example" -> bundle23Steps else -> throw IllegalStateException( "No Kotlin step fixture registered for bundle $bundleName" diff --git a/java/varar/src/test/java/dev/varar/ConformanceTest.java b/java/varar/src/test/java/dev/varar/ConformanceTest.java index 91c17208..10a5f951 100644 --- a/java/varar/src/test/java/dev/varar/ConformanceTest.java +++ b/java/varar/src/test/java/dev/varar/ConformanceTest.java @@ -137,6 +137,7 @@ private static StepDefinitions loadFixture(String bundleName) { case "19-emphasis-parameter" -> new dev.varar.conformance.bundle19.MentionSteps(); case "20-reference-splice" -> new dev.varar.conformance.bundle20.LibrarySteps(); case "21-reference-consumed" -> new dev.varar.conformance.bundle21.LibrarySteps(); + case "23-reference-only-example" -> new dev.varar.conformance.bundle23.LibrarySteps(); default -> throw new IllegalStateException("No Java step fixture registered for bundle " + bundleName); }; } diff --git a/python/packages/core/src/varar_core/plan.py b/python/packages/core/src/varar_core/plan.py index 3cb704f0..4ffe2895 100644 --- a/python/packages/core/src/varar_core/plan.py +++ b/python/packages/core/src/varar_core/plan.py @@ -316,6 +316,9 @@ class _ReferenceUnit: reference: Reference preceded_by_delimiter: bool + # The referring document's own heading chain and the block's own span: an + # example this reference opens belongs here, not to the section. + scope_stack: tuple[str, ...] span: Span @@ -449,14 +452,25 @@ def flush() -> None: # section of several paragraphs stays one example. resolved = _resolve_reference(unit, doc, registry, workspace, diagnostics, ()) for i, spliced in enumerate(resolved): + current: _MergedExample if open_ex is not None and (i > 0 or not unit.preceded_by_delimiter): _merge_into(open_ex, spliced, from_reference=True) + current = open_ex else: flush() - open_ex = _start_merged(spliced) + current = _start_merged(spliced) # An example that OPENS with a reference is named by its own - # first matching paragraph, not by the section it pulls in. - open_ex.name_from_reference = True + # first matching paragraph, not by the section it pulls in — + # and it sits under THIS document's headings, not the + # section's. + current.name_from_reference = True + current.scope_stack = unit.scope_stack + current.start_offset = unit.span.start_offset + open_ex = current + # A spliced unit's span is in the referenced document; the + # example's span is in this one. It ends at the reference block + # until a later paragraph of the example's own extends it. + current.end_offset = unit.span.end_offset continue if not unit.matched: # Prose paragraph — a delimiter. Drop it and end the open example. @@ -554,6 +568,7 @@ def _plan_candidate( return _ReferenceUnit( reference=ref, preceded_by_delimiter=ex.preceded_by_delimiter, + scope_stack=ex.scope_stack, span=ex.span, ) diff --git a/python/packages/core/tests/test_plan.py b/python/packages/core/tests/test_plan.py index f1c1df35..4ff449eb 100644 --- a/python/packages/core/tests/test_plan.py +++ b/python/packages/core/tests/test_plan.py @@ -386,3 +386,66 @@ def test_multi_table_shape_two_tables_in_one_example_survive_blank_lines() -> No assert len(ex.steps[0].data_table.rows) == 1 assert ex.steps[1].data_table is not None assert len(ex.steps[1].data_table.rows) == 1 + + +# --------------------------------------------------------------------------- +# Reference blocks (ADR 0016): the example an opening reference starts belongs +# to the REFERRING document — its span and its headings — while the spliced +# steps keep their spans in the referenced one. +# --------------------------------------------------------------------------- + +_SHARED = """# Shared world states + +## Fees are enabled + +Fees are enabled. + +## A stocked library + +I have 100 in my account. +""" + + +def _plan_with(main: str): + from varar_core.reference import build_workspace + + r = _reg() + r = add_step(r, expression="Fees are enabled", expression_source_file="steps.ts", expression_source_line=4, handler=_noop, kind="stimulus") + main_doc = parse("fees.md", main) + workspace = build_workspace([main_doc, parse("shared.md", _SHARED)]) + return plan(main_doc, r, workspace) + + +def test_an_example_a_reference_opens_is_placed_at_the_reference_block_under_the_referring_headings() -> None: + main = "# Late fees\n\n[A stocked library](./shared.md#a-stocked-library)\n\nI withdraw 40.\n" + planned = _plan_with(main) + assert planned.diagnostics == () + ex = planned.examples[0] + assert [s.text for s in ex.steps] == ["I have 100 in my account", "I withdraw 40"] + assert ex.scope_stack == ("Late fees",) + assert (ex.span.start_line, ex.span.start_col, ex.span.end_line) == (3, 1, 5) + assert main[ex.span.start_offset : ex.span.end_offset] == ( + "[A stocked library](./shared.md#a-stocked-library)\n\nI withdraw 40." + ) + + +def test_an_example_that_is_nothing_but_a_reference_spans_the_reference_block_and_keeps_host_headings() -> None: + main = "# Late fees\n\n## Invariants\n\n[Fees are enabled](./shared.md#fees-are-enabled)\n" + planned = _plan_with(main) + assert len(planned.examples) == 1 + ex = planned.examples[0] + assert [s.text for s in ex.steps] == ["Fees are enabled"] + assert ex.scope_stack == ("Late fees", "Invariants") + assert main[ex.span.start_offset : ex.span.end_offset] == ( + "[Fees are enabled](./shared.md#fees-are-enabled)" + ) + + +def test_a_reference_mid_example_extends_the_example_to_the_reference_block_not_into_the_other_file() -> None: + main = "# Late fees\n\nI withdraw 40.\n\n[Fees are enabled](./shared.md#fees-are-enabled)\n" + planned = _plan_with(main) + ex = planned.examples[0] + assert [s.text for s in ex.steps] == ["I withdraw 40", "Fees are enabled"] + assert main[ex.span.start_offset : ex.span.end_offset] == ( + "I withdraw 40.\n\n[Fees are enabled](./shared.md#fees-are-enabled)" + ) diff --git a/ruby/packages/core/lib/varar/core/plan.rb b/ruby/packages/core/lib/varar/core/plan.rb index aa54c79a..bd0c638a 100644 --- a/ruby/packages/core/lib/varar/core/plan.rb +++ b/ruby/packages/core/lib/varar/core/plan.rb @@ -50,8 +50,9 @@ module Plan HeaderBoundUnit = Data.define(:rows) # A reference block: its whole text is a link to an oath section, whose # steps are spliced in here (ADR 0016). Never prose, so it does not close - # the open example. - ReferenceUnit = Data.define(:reference, :preceded_by_delimiter, :span) + # the open example. Its span and scope stack are the referring + # document's: the example it opens lives here, not in the section. + ReferenceUnit = Data.define(:reference, :preceded_by_delimiter, :span, :scope_stack) StepsUnit = Data.define(:matched, :preceded_by_delimiter, :name, :scope_stack, :span, :steps, :expected_outcome, :expected_error_message) @@ -111,9 +112,16 @@ def plan(doc, registry, workspace) flush.call open = start_merged(spliced) # An example that OPENS with a reference is named by its own - # first matching paragraph, not by the section it pulls in. + # first matching paragraph, not by the section it pulls in, and + # it sits under THIS document's headings, not the section's. open.name_from_reference = true + open.scope_stack = unit.scope_stack + open.start_offset = unit.span.start_offset end + # A spliced unit's span is in the referenced document; the + # example's span is in this one. It ends at the reference block + # until a later paragraph of the example's own extends it. + open.end_offset = unit.span.end_offset end next end @@ -225,7 +233,7 @@ def plan_candidate(ex, doc, registry, diagnostics) ref = Reference.reference_of(primary.text, doc.path) if ref return ReferenceUnit.new(reference: ref, preceded_by_delimiter: ex.preceded_by_delimiter, - span: ex.span) + span: ex.span, scope_stack: ex.scope_stack) end end diff --git a/ruby/packages/core/spec/varar/core/plan_spec.rb b/ruby/packages/core/spec/varar/core/plan_spec.rb index 70f89318..ad5669e8 100644 --- a/ruby/packages/core/spec/varar/core/plan_spec.rb +++ b/ruby/packages/core/spec/varar/core/plan_spec.rb @@ -74,6 +74,35 @@ def step_texts(example) expect(step_texts(result.examples[0])).to eq(['I have 100 in my account', 'I withdraw 40']) end + # An example a reference block opens belongs to the referring document + # (ADR 0016): its span is the reference block, its headings are this + # document's, and only its steps come from the section it links to. + it 'places an example opened by a reference block in the referring document' do + shared = Parse.parse('shared.md', "# Shared\n\n## Setup\n\nI have 100 in my account.\n") + source = "# Fees\n\n## Overdraft\n\n[Setup](./shared.md#setup)\n\nI withdraw 40.\n" + doc = Parse.parse('fees.md', source) + plan = described_class.plan(doc, account_reg, Reference.build_workspace([shared, doc])) + expect(plan.diagnostics).to be_empty + expect(plan.examples.length).to eq(1) + example = plan.examples[0] + expect(step_texts(example)).to eq(['I have 100 in my account', 'I withdraw 40']) + expect(example.scope_stack).to eq(%w[Fees Overdraft]) + expect(source[example.span.start_offset...example.span.end_offset]) + .to eq("[Setup](./shared.md#setup)\n\nI withdraw 40.") + end + + it 'ends an example that is nothing but a reference at the reference block' do + shared = Parse.parse('shared.md', "# Shared\n\n## Setup\n\nI have 100 in my account.\n") + source = "# Fees\n\n## Overdraft\n\n[Setup](./shared.md#setup)\n" + doc = Parse.parse('fees.md', source) + plan = described_class.plan(doc, account_reg, Reference.build_workspace([shared, doc])) + example = plan.examples[0] + expect(example.name).to eq('I have 100 in my account') + expect(example.scope_stack).to eq(%w[Fees Overdraft]) + expect(source[example.span.start_offset...example.span.end_offset]).to eq('[Setup](./shared.md#setup)') + expect(example.steps.map(&:doc_path)).to eq(['shared.md']) + end + it 'drops an ambiguous candidate: a diagnostic, not an example' do r = Registries.create_registry r = Registries.add_step(r, expression: 'I have {int} cukes', expression_source_file: 'a.rb', diff --git a/rust/core/src/plan.rs b/rust/core/src/plan.rs index 636a7edb..7add049c 100644 --- a/rust/core/src/plan.rs +++ b/rust/core/src/plan.rs @@ -123,10 +123,10 @@ pub fn plan(doc: &Doc, registry: &Registry, workspace: &OathWorkspace) -> Execut resolve_reference(&unit, doc, registry, workspace, &mut diagnostics, &[]); for (i, spliced) in resolved.into_iter().enumerate() { let mergeable = open.is_some() && (i > 0 || !preceded); - if mergeable { - if let Some(m) = open.as_mut() { - merge_into(m, spliced, true); - } + let current = if mergeable { + let m = open.as_mut().expect("an example is open"); + merge_into(m, spliced, true); + m } else { if let Some(m) = open.take() { examples.push(finish_merged(m, source)); @@ -134,10 +134,18 @@ pub fn plan(doc: &Doc, registry: &Registry, workspace: &OathWorkspace) -> Execut let mut fresh = start_merged(spliced); // An example that OPENS with a reference is named by // its own first matching paragraph, not by the section - // it pulls in. + // it pulls in — and it sits under THIS document's + // headings, starting at the reference block. fresh.name_from_reference = true; - open = Some(fresh); - } + fresh.scope_stack = unit.scope_stack.clone(); + fresh.start_offset = unit.span.start_offset; + open.insert(fresh) + }; + // A spliced unit's span is in the referenced document; the + // example's span is in this one. It ends at the reference + // block until a later paragraph of the example's own + // extends it. + current.end_offset = unit.span.end_offset; } } CandidateUnit::Steps(unit) => { @@ -205,6 +213,9 @@ struct ReferenceUnit { reference: Reference, preceded_by_delimiter: bool, span: Span, + /// The referring document's headings at the reference block: an example + /// the block opens sits under THESE, not the referenced section's. + scope_stack: Vec, } struct StepsUnit { @@ -352,6 +363,7 @@ fn plan_candidate( reference, preceded_by_delimiter: ex.preceded_by_delimiter, span: ex.span, + scope_stack: ex.scope_stack.clone(), }); } } diff --git a/rust/core/tests/plan_test.rs b/rust/core/tests/plan_test.rs index d6c78d6c..99391ef8 100644 --- a/rust/core/tests/plan_test.rs +++ b/rust/core/tests/plan_test.rs @@ -1,6 +1,6 @@ //! Port of `PlanTest.java` / `plan.test.ts`. -use varar_core::reference::empty_workspace; +use varar_core::reference::{build_workspace, empty_workspace}; mod common; use common::vmap; @@ -489,3 +489,44 @@ fn the_multi_table_shape_two_tables_in_one_example_survive_blank_lines() { assert_eq!(1, ex.steps[0].data_table.as_ref().unwrap().rows.len()); assert_eq!(1, ex.steps[1].data_table.as_ref().unwrap().rows.len()); } + +#[test] +fn an_example_a_reference_block_opens_belongs_to_the_referring_document() { + let r = create_registry(); + let r = step(&r, "I shelve {int} books", "s.ts", 1); + let r = step(&r, "I borrow a book", "s.ts", 2); + let shared = parse("varar/shared.md", "# Shared\n\n## Setup\n\nI shelve 3 books."); + let source = "# Late fees\n\n## Shelf invariants\n\nProse.\n\n[Setup](./shared.md#setup)"; + let doc = parse("varar/a.md", source); + let workspace = build_workspace(&[shared, doc.clone()]); + let result = plan(&doc, &r, &workspace); + assert_eq!(0, result.diagnostics.len()); + assert_eq!(1, result.examples.len()); + let ex = &result.examples[0]; + // Its span is the reference block in THIS document, not the spliced + // paragraph's span in the referenced one. + assert_eq!("[Setup](./shared.md#setup)", &source[ex.span.start_offset..ex.span.end_offset]); + assert_eq!(vec!["Late fees".to_string(), "Shelf invariants".to_string()], ex.scope_stack); + assert_eq!(vec!["I shelve 3 books".to_string()], step_texts(ex)); + assert_eq!(Some("varar/shared.md"), ex.steps[0].doc_path.as_deref()); +} + +#[test] +fn a_paragraph_of_the_examples_own_extends_the_span_past_the_reference_block() { + let r = create_registry(); + let r = step(&r, "I shelve {int} books", "s.ts", 1); + let r = step(&r, "I borrow a book", "s.ts", 2); + let shared = parse("varar/shared.md", "# Shared\n\n## Setup\n\nI shelve 3 books."); + let source = "# Late fees\n\n[Setup](./shared.md#setup)\n\nI borrow a book."; + let doc = parse("varar/a.md", source); + let workspace = build_workspace(&[shared, doc.clone()]); + let result = plan(&doc, &r, &workspace); + assert_eq!(1, result.examples.len()); + let ex = &result.examples[0]; + assert_eq!( + "[Setup](./shared.md#setup)\n\nI borrow a book.", + &source[ex.span.start_offset..ex.span.end_offset] + ); + assert_eq!("I borrow a book", ex.name); + assert_eq!(vec!["Late fees".to_string()], ex.scope_stack); +} diff --git a/rust/varar/tests/conformance.rs b/rust/varar/tests/conformance.rs index a3173b3a..546b9c58 100644 --- a/rust/varar/tests/conformance.rs +++ b/rust/varar/tests/conformance.rs @@ -68,6 +68,8 @@ mod b19; mod b20; #[path = "../../../conformance/bundles/21-reference-consumed/library.steps.rs"] mod b21; +#[path = "../../../conformance/bundles/23-reference-only-example/library.steps.rs"] +mod b23; // Each bundle now has its OWN context type, so the fixtures cannot share one // function-pointer type. This macro erases that difference: it builds the @@ -107,6 +109,7 @@ fn fixture(bundle: &str) -> (Registry, ContextFactory) { "19-emphasis-parameter" => bundle!(b19), "20-reference-splice" => bundle!(b20), "21-reference-consumed" => bundle!(b21), + "23-reference-only-example" => bundle!(b23), other => panic!("no Rust step fixture for bundle {other}"), } } diff --git a/typescript/packages/core/src/plan.ts b/typescript/packages/core/src/plan.ts index 819a7f8e..4358765a 100644 --- a/typescript/packages/core/src/plan.ts +++ b/typescript/packages/core/src/plan.ts @@ -142,16 +142,26 @@ export function plan( // Only the reference block itself is subject to the delimiter rule. // Everything it splices in belongs to the same sequence, so a section // of several paragraphs stays one example rather than fragmenting. + let current: MergedExample if (open && (i > 0 || !unit.precededByDelimiter)) { mergeInto(open, spliced, true) + current = open } else { flush() - open = startMerged(spliced) + current = startMerged(spliced) // An example that OPENS with a reference is named by its own first // matching paragraph, not by the section it pulls in — otherwise - // every example under a shared setup carries the same name. - if (open) open.nameFromReference = true + // every example under a shared setup carries the same name — and it + // sits under THIS document's headings, not the section's. + current.nameFromReference = true + current.scopeStack = unit.scopeStack + current.startOffset = unit.span.startOffset + open = current } + // A spliced unit's span is in the referenced document; the example's + // span is in this one. It ends at the reference block until a later + // paragraph of the example's own extends it. + current.endOffset = unit.span.endOffset }) continue } @@ -269,6 +279,9 @@ type CandidateUnit = readonly kind: 'reference' readonly reference: Reference readonly precededByDelimiter: boolean + // The referring document's own heading chain and the block's own span: + // an example this reference opens belongs here, not to the section. + readonly scopeStack: ReadonlyArray readonly span: Span } | { @@ -347,6 +360,7 @@ function planCandidate( kind: 'reference', reference, precededByDelimiter: ex.precededByDelimiter, + scopeStack: ex.scopeStack, span: ex.span, } } From 65aa3e94470ca7ffce3ed9cc9207f0cca7979a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Mon, 14 Sep 2026 12:12:20 +0100 Subject: [PATCH 18/21] fix(spec): a reference to an oath above the workspace root keeps its leading ../ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toOathPath deliberately keeps `../` for an oath matched outside the root, but the link resolver clamped a `..` with nothing left to climb out of, so `../../shared/b.md` from `varar/a.md` resolved to `shared/b.md` — the wrong oath, or a false reference-not-found. An unresolvable `..` now stays in the path, in every port. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RQVAKGfMEKPbT919FqAChP --- dotnet/Varar.Core.Tests/ReferenceTests.cs | 91 +++++++++++++ dotnet/Varar.Core/Reference.cs | 15 ++- go/core/reference.go | 7 +- go/core/reference_test.go | 40 ++++++ .../main/java/dev/varar/core/Reference.java | 11 +- .../java/dev/varar/core/ReferenceTest.java | 126 ++++++++++++++++++ .../packages/core/src/varar_core/reference.py | 12 +- python/packages/core/tests/test_reference.py | 15 +++ .../packages/core/lib/varar/core/reference.rb | 12 +- .../core/spec/varar/core/reference_spec.rb | 31 +++++ rust/core/src/reference.rs | 12 +- rust/core/tests/reference_test.rs | 36 +++++ typescript/packages/core/src/reference.ts | 8 +- .../packages/core/tests/reference.test.ts | 62 +++++++++ 14 files changed, 457 insertions(+), 21 deletions(-) create mode 100644 dotnet/Varar.Core.Tests/ReferenceTests.cs create mode 100644 go/core/reference_test.go create mode 100644 java/core/src/test/java/dev/varar/core/ReferenceTest.java create mode 100644 python/packages/core/tests/test_reference.py create mode 100644 ruby/packages/core/spec/varar/core/reference_spec.rb create mode 100644 rust/core/tests/reference_test.rs diff --git a/dotnet/Varar.Core.Tests/ReferenceTests.cs b/dotnet/Varar.Core.Tests/ReferenceTests.cs new file mode 100644 index 00000000..a563c52f --- /dev/null +++ b/dotnet/Varar.Core.Tests/ReferenceTests.cs @@ -0,0 +1,91 @@ +using System.Linq; +using Varar.Core; +using Xunit; + +namespace Varar.Core.Tests; + +// Reuse is a link (ADR 0016): mirrors the reference.test.ts additions. +public class ReferenceTests +{ + private const string Shared = """ + # Shared + + ## Fees are enabled + + Fees are enabled. + + """; + + private static Registry Reg() + { + var r = Registry.Create(); + r = Registry.AddStep(r, new StepInput("Fees are enabled", "steps.cs", 1, (_, _) => null, StepKind.Stimulus)); + r = Registry.AddStep(r, new StepInput("Maya borrows {string}", "steps.cs", 2, (_, _) => null, StepKind.Stimulus)); + return r; + } + + // Plan fees.md against a workspace holding it and shared.md. + private static ExecutionPlan PlanWith(string main) + { + var mainDoc = Parse.Run("fees.md", main); + var sharedDoc = Parse.Run("shared.md", Shared); + var workspace = Reference_.BuildWorkspace([mainDoc, sharedDoc]); + return Plan.Run(mainDoc, Reg(), workspace); + } + + [Fact] + public void AnExampleAReferenceOpensIsPlacedAtTheReferenceBlockUnderTheReferringDocumentsHeadings() + { + // The spliced steps keep their spans in shared.md; the EXAMPLE lives in fees.md. + const string main = "# Late fees\n\n[Fees are enabled](./shared.md#fees-are-enabled)\n\nMaya borrows \"Emma\".\n"; + var plan = PlanWith(main); + Assert.Empty(plan.Diagnostics); + var ex = Assert.Single(plan.Examples); + Assert.Equal(new[] { "Late fees" }, ex.ScopeStack); + Assert.Equal(3, ex.Span.StartLine); + Assert.Equal(1, ex.Span.StartCol); + Assert.Equal(5, ex.Span.EndLine); + Assert.Equal( + "[Fees are enabled](./shared.md#fees-are-enabled)\n\nMaya borrows \"Emma\".", + main[ex.Span.StartOffset..ex.Span.EndOffset]); + } + + [Fact] + public void AnExampleThatIsNothingButAReferenceSpansTheReferenceBlockAndKeepsTheHostHeadings() + { + const string main = "# Late fees\n\n## Invariants\n\n[Fees are enabled](./shared.md#fees-are-enabled)\n"; + var plan = PlanWith(main); + Assert.Empty(plan.Diagnostics); + var ex = Assert.Single(plan.Examples); + Assert.Equal(new[] { "Fees are enabled" }, ex.Steps.Select(s => s.Text).ToArray()); + Assert.Equal(new[] { "Late fees", "Invariants" }, ex.ScopeStack); + Assert.Equal("shared.md", ex.Steps[0].DocPath); + Assert.Equal( + "[Fees are enabled](./shared.md#fees-are-enabled)", + main[ex.Span.StartOffset..ex.Span.EndOffset]); + } + + [Fact] + public void AReferenceMidExampleExtendsTheExampleToTheReferenceBlockNotIntoTheOtherFile() + { + const string main = "# Late fees\n\nMaya borrows \"Emma\".\n\n[Fees are enabled](./shared.md#fees-are-enabled)\n"; + var plan = PlanWith(main); + Assert.Empty(plan.Diagnostics); + var ex = Assert.Single(plan.Examples); + Assert.Equal(new[] { "Maya borrows \"Emma\"", "Fees are enabled" }, ex.Steps.Select(s => s.Text).ToArray()); + Assert.Equal( + "Maya borrows \"Emma\".\n\n[Fees are enabled](./shared.md#fees-are-enabled)", + main[ex.Span.StartOffset..ex.Span.EndOffset]); + } + + [Fact] + public void ALinkThatClimbsAboveTheWorkspaceRootKeepsItsLeadingDotDot() + { + // The oath-path convention keeps `../` for an oath outside the root; the resolver must too, + // or `../../shared/b.md` from `varar/a.md` would land on `shared/b.md`. + var doc = Parse.Run("varar/a.md", "[Up](../../shared/b.md#setup)\n"); + Assert.Equal("../shared/b.md", Reference_.References(doc)[0].Path); + var deeper = Parse.Run("../outside/a.md", "[Up](../b.md)\n"); + Assert.Equal("../b.md", Reference_.References(deeper)[0].Path); + } +} diff --git a/dotnet/Varar.Core/Reference.cs b/dotnet/Varar.Core/Reference.cs index 02e704f8..b8b93ad9 100644 --- a/dotnet/Varar.Core/Reference.cs +++ b/dotnet/Varar.Core/Reference.cs @@ -126,16 +126,19 @@ public static string JoinPosix(string dir, string rel) continue; } - if (segment == "..") + if (segment != "..") { - if (segments.Count > 0) - { - segments.RemoveAt(segments.Count - 1); - } + segments.Add(segment); + } + else if (segments.Count > 0 && segments[^1] != "..") + { + segments.RemoveAt(segments.Count - 1); } else { - segments.Add(segment); + // Above the workspace root: keep the leading `../`, as the oath-path convention + // does for an oath outside the root. + segments.Add(".."); } } diff --git a/go/core/reference.go b/go/core/reference.go index 3719076b..321dc459 100644 --- a/go/core/reference.go +++ b/go/core/reference.go @@ -116,8 +116,13 @@ func JoinPosix(dir, rel string) string { case "", ".": continue case "..": - if len(segments) > 0 { + // Climbing above the workspace root keeps the leading "../": the + // oath-path convention spells an oath outside the root that way, + // so the resolver must produce the same spelling. + if len(segments) > 0 && segments[len(segments)-1] != ".." { segments = segments[:len(segments)-1] + } else { + segments = append(segments, "..") } default: segments = append(segments, segment) diff --git a/go/core/reference_test.go b/go/core/reference_test.go new file mode 100644 index 00000000..d364c575 --- /dev/null +++ b/go/core/reference_test.go @@ -0,0 +1,40 @@ +package core + +import "testing" + +// A relative link that climbs above the workspace root keeps its leading +// "../" — the oath-path convention spells an oath outside the root that way. +func TestReferenceAboveWorkspaceRootKeepsLeadingDotDot(t *testing.T) { + cases := []struct { + from, text, wantPath, wantSlug string + }{ + {"varar/a.md", "[Up](../../shared/b.md#setup)", "../shared/b.md", "setup"}, + {"../outside/a.md", "[Up](../b.md)", "../b.md", ""}, + {"varar/a.md", "[Sibling](./b.md#setup)", "varar/b.md", "setup"}, + } + for _, c := range cases { + ref := ReferenceOf(c.text, c.from) + if ref == nil { + t.Fatalf("%q from %q: expected a reference, got nil", c.text, c.from) + } + if ref.Path != c.wantPath || ref.Slug != c.wantSlug { + t.Errorf("%q from %q: got path %q slug %q, want path %q slug %q", + c.text, c.from, ref.Path, ref.Slug, c.wantPath, c.wantSlug) + } + } +} + +func TestJoinPosixKeepsUnpoppableDotDot(t *testing.T) { + cases := []struct{ dir, rel, want string }{ + {"varar", "../../shared/b.md", "../shared/b.md"}, + {"../outside", "../b.md", "../b.md"}, + {"", "../b.md", "../b.md"}, + {"a/b", "../c.md", "a/c.md"}, + {"a", "./b.md", "a/b.md"}, + } + for _, c := range cases { + if got := JoinPosix(c.dir, c.rel); got != c.want { + t.Errorf("JoinPosix(%q, %q) = %q, want %q", c.dir, c.rel, got, c.want) + } + } +} diff --git a/java/core/src/main/java/dev/varar/core/Reference.java b/java/core/src/main/java/dev/varar/core/Reference.java index a559d82c..92ec38cc 100644 --- a/java/core/src/main/java/dev/varar/core/Reference.java +++ b/java/core/src/main/java/dev/varar/core/Reference.java @@ -110,10 +110,15 @@ public static String joinPosix(String dir, String rel) { } for (String segment : rel.split("/")) { if (segment.isEmpty() || segment.equals(".")) continue; - if (segment.equals("..")) { - if (!segments.isEmpty()) segments.remove(segments.size() - 1); - } else { + if (!segment.equals("..")) { segments.add(segment); + } else if (!segments.isEmpty() && !segments.get(segments.size() - 1).equals("..")) { + segments.remove(segments.size() - 1); + } else { + // A `..` with nothing left to climb out of stays: an oath above the workspace root + // is addressed as `../shared/b.md` (the oath-path convention keeps the leading + // `../` too), and clamping it would point at the wrong file. + segments.add(".."); } } return String.join("/", segments); diff --git a/java/core/src/test/java/dev/varar/core/ReferenceTest.java b/java/core/src/test/java/dev/varar/core/ReferenceTest.java new file mode 100644 index 00000000..31ca5cc4 --- /dev/null +++ b/java/core/src/test/java/dev/varar/core/ReferenceTest.java @@ -0,0 +1,126 @@ +package dev.varar.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Translated from the ADR 0016 cases of {@code typescript/packages/core/tests/reference.test.ts}. */ +class ReferenceTest { + + private static final Object NOOP_HANDLER = (Runnable) () -> {}; + + private static final String SHARED = """ + # Shared + + ## A stocked library + + I shelve 3 books. The shelf holds 3 books. + + ## Fees are enabled + + Fees are enabled. + """; + + private static Registry reg() { + Registry r = Registry.createRegistry(); + r = Registry.addStep(r, "I shelve {int} books", "steps.ts", 1, NOOP_HANDLER, StepKind.STIMULUS); + r = Registry.addStep(r, "The shelf holds {int} books", "steps.ts", 2, NOOP_HANDLER, StepKind.SENSOR); + r = Registry.addStep(r, "Fees are enabled", "steps.ts", 3, NOOP_HANDLER, StepKind.STIMULUS); + r = Registry.addStep(r, "Maya borrows {string}", "steps.ts", 4, NOOP_HANDLER, StepKind.STIMULUS); + return r; + } + + /** Plans {@code fees.md} with {@code shared.md} in the workspace. */ + private static Plan.ExecutionPlan planWith(String main) { + Ast.Doc shared = Parse.parse("shared.md", SHARED); + Ast.Doc doc = Parse.parse("fees.md", main); + return Plan.plan(doc, reg(), Reference.buildWorkspace(List.of(shared, doc))); + } + + @Test + void anExampleAReferenceOpensIsPlacedAtTheReferenceBlockUnderTheReferringDocumentsHeadings() { + // The spliced steps keep their spans in shared.md; the EXAMPLE lives in fees.md. Its span + // used to be built from the section's offsets read against fees.md's source, which put it + // at an unrelated line. + String main = """ + # Late fees + + [A stocked library](./shared.md#a-stocked-library) + + Maya borrows "Emma". + """; + Plan.ExecutionPlan planned = planWith(main); + assertEquals(List.of(), planned.diagnostics()); + Plan.PlannedExample ex = planned.examples().get(0); + assertEquals(List.of("Late fees"), ex.scopeStack()); + assertEquals(3, ex.span().startLine()); + assertEquals(1, ex.span().startCol()); + assertEquals(5, ex.span().endLine()); + assertEquals( + "[A stocked library](./shared.md#a-stocked-library)\n\nMaya borrows \"Emma\".", + main.substring(ex.span().startOffset(), ex.span().endOffset())); + // Spliced steps carry the document they were written in; the example's own step carries none. + assertEquals( + Arrays.asList("shared.md", "shared.md", null), + ex.steps().stream().map(Plan.PlannedStep::docPath).toList()); + } + + @Test + void anExampleThatIsNothingButAReferenceSpansTheReferenceBlockAndKeepsTheHostHeadings() { + String main = """ + # Late fees + + ## Invariants + + [Fees are enabled](./shared.md#fees-are-enabled) + """; + Plan.ExecutionPlan planned = planWith(main); + assertEquals(1, planned.examples().size()); + Plan.PlannedExample ex = planned.examples().get(0); + assertEquals( + List.of("Fees are enabled"), + ex.steps().stream().map(Plan.PlannedStep::text).toList()); + assertEquals(List.of("Late fees", "Invariants"), ex.scopeStack()); + assertEquals( + "[Fees are enabled](./shared.md#fees-are-enabled)", + main.substring(ex.span().startOffset(), ex.span().endOffset())); + } + + @Test + void aReferenceMidExampleExtendsTheExampleToTheReferenceBlockNotIntoTheOtherFile() { + String main = """ + # Late fees + + Maya borrows "Emma". + + [Fees are enabled](./shared.md#fees-are-enabled) + """; + Plan.ExecutionPlan planned = planWith(main); + Plan.PlannedExample ex = planned.examples().get(0); + assertEquals( + List.of("Maya borrows \"Emma\"", "Fees are enabled"), + ex.steps().stream().map(Plan.PlannedStep::text).toList()); + assertEquals( + "Maya borrows \"Emma\".\n\n[Fees are enabled](./shared.md#fees-are-enabled)", + main.substring(ex.span().startOffset(), ex.span().endOffset())); + } + + @Test + void aLinkThatClimbsAboveTheWorkspaceRootKeepsItsLeadingDotDot() { + // The oath-path convention keeps `../` for an oath outside the root; the resolver must + // too, or `../../shared/b.md` from `varar/a.md` would land on `shared/b.md`. + Ast.Doc doc = Parse.parse("varar/a.md", "[Up](../../shared/b.md#setup)\n"); + assertEquals("../shared/b.md", Reference.references(doc).get(0).path()); + Ast.Doc deeper = Parse.parse("../outside/a.md", "[Up](../b.md)\n"); + assertEquals("../b.md", Reference.references(deeper).get(0).path()); + } + + @Test + void joinPosixNormalisesDotsWithinTheRoot() { + assertEquals("varar/shared.md", Reference.joinPosix("varar", "./shared.md")); + assertEquals("shared/b.md", Reference.joinPosix("varar/x", "../../shared/b.md")); + assertEquals("b.md", Reference.joinPosix("", "b.md")); + } +} diff --git a/python/packages/core/src/varar_core/reference.py b/python/packages/core/src/varar_core/reference.py index f53b3687..2d245951 100644 --- a/python/packages/core/src/varar_core/reference.py +++ b/python/packages/core/src/varar_core/reference.py @@ -90,11 +90,15 @@ def join_posix(directory: str, rel: str) -> str: for segment in rel.split("/"): if segment in ("", "."): continue - if segment == "..": - if segments: - segments.pop() - else: + if segment != "..": segments.append(segment) + # A `..` with nothing left to climb out of stays: an oath above the + # workspace root is addressed as `../shared/b.md` (to_oath_path keeps + # the leading `../` too), and clamping it would point at the wrong file. + elif segments and segments[-1] != "..": + segments.pop() + else: + segments.append("..") return "/".join(segments) diff --git a/python/packages/core/tests/test_reference.py b/python/packages/core/tests/test_reference.py new file mode 100644 index 00000000..3f482292 --- /dev/null +++ b/python/packages/core/tests/test_reference.py @@ -0,0 +1,15 @@ +"""test_reference.py — port of typescript/packages/core/tests/reference.test.ts +(the path-arithmetic part).""" +from __future__ import annotations + +from varar_core.parse import parse +from varar_core.reference import references + + +def test_a_link_that_climbs_above_the_workspace_root_keeps_its_leading_dotdot() -> None: + # to_oath_path keeps `../` for an oath outside the root; the resolver must + # too, or `../../shared/b.md` from `varar/a.md` would land on `shared/b.md`. + doc = parse("varar/a.md", "[Up](../../shared/b.md#setup)\n") + assert references(doc)[0].path == "../shared/b.md" + deeper = parse("../outside/a.md", "[Up](../b.md)\n") + assert references(deeper)[0].path == "../b.md" diff --git a/ruby/packages/core/lib/varar/core/reference.rb b/ruby/packages/core/lib/varar/core/reference.rb index 16bfceeb..5f774b4a 100644 --- a/ruby/packages/core/lib/varar/core/reference.rb +++ b/ruby/packages/core/lib/varar/core/reference.rb @@ -78,13 +78,21 @@ def dirname_posix(path) end # POSIX path arithmetic on oath paths (always '/'-separated, relative to - # the workspace root). The core may not touch the filesystem. + # the workspace root). The core may not touch the filesystem. A link that + # climbs above the root keeps its leading `../`, as the oath-path + # convention does for an oath outside the root. def join_posix(dir, rel) segments = dir.empty? ? [] : dir.split('/') rel.split('/').each do |segment| next if segment.empty? || segment == '.' - segment == '..' ? segments.pop : segments << segment + if segment != '..' + segments << segment + elsif !segments.empty? && segments.last != '..' + segments.pop + else + segments << '..' + end end segments.join('/') end diff --git a/ruby/packages/core/spec/varar/core/reference_spec.rb b/ruby/packages/core/spec/varar/core/reference_spec.rb new file mode 100644 index 00000000..7aa429ae --- /dev/null +++ b/ruby/packages/core/spec/varar/core/reference_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'varar/core' + +module Varar + module Core + # Reference blocks (ADR 0016): the link target is resolved against the + # referring oath's own path with plain POSIX arithmetic — no filesystem. + ::RSpec.describe Reference do + it 'resolves a relative link against the referring document directory' do + ref = described_class.reference_of('[Setup](./shared.md#setup)', 'varar/a.md') + expect(ref.path).to eq('varar/shared.md') + expect(ref.slug).to eq('setup') + expect(ref.text).to eq('Setup') + end + + it 'keeps a leading ../ when the link climbs above the workspace root' do + ref = described_class.reference_of('[Up](../../shared/b.md#setup)', 'varar/a.md') + expect(ref.path).to eq('../shared/b.md') + expect(ref.slug).to eq('setup') + end + + it 'keeps climbing from an oath that is already outside the root' do + ref = described_class.reference_of('[Up](../b.md)', '../outside/a.md') + expect(ref.path).to eq('../b.md') + expect(ref.slug).to eq('') + end + end + end +end diff --git a/rust/core/src/reference.rs b/rust/core/src/reference.rs index a5761618..9c1842b5 100644 --- a/rust/core/src/reference.rs +++ b/rust/core/src/reference.rs @@ -117,9 +117,15 @@ pub fn join_posix(dir: &str, rel: &str) -> String { for segment in rel.split('/') { match segment { "" | "." => continue, - ".." => { - segments.pop(); - } + // Climbing above the workspace root keeps its leading `..`: the + // oath-path convention deliberately spells an oath outside the root + // as `../x.md`, so the resolver must produce the same spelling. + ".." => match segments.last() { + Some(&"..") | None => segments.push(".."), + Some(_) => { + segments.pop(); + } + }, other => segments.push(other), } } diff --git a/rust/core/tests/reference_test.rs b/rust/core/tests/reference_test.rs new file mode 100644 index 00000000..93f5cf4b --- /dev/null +++ b/rust/core/tests/reference_test.rs @@ -0,0 +1,36 @@ +//! Port of `reference.test.ts` — the path arithmetic behind reference blocks +//! (ADR 0016). Pure: no filesystem. + +use varar_core::reference::{join_posix, reference_of}; + +#[test] +fn a_relative_link_resolves_against_the_referring_documents_directory() { + let r = reference_of("[Setup](./shared.md#setup)", "varar/a.md").unwrap(); + assert_eq!("varar/shared.md", r.path); + assert_eq!("setup", r.slug); +} + +#[test] +fn a_link_climbing_above_the_workspace_root_keeps_its_leading_parent_segment() { + // The oath-path convention spells an oath outside the root as `../x.md`; + // the resolver must not swallow the `..` it has nothing to pop. + let r = reference_of("[Up](../../shared/b.md#setup)", "varar/a.md").unwrap(); + assert_eq!("../shared/b.md", r.path); + assert_eq!("setup", r.slug); +} + +#[test] +fn a_link_from_an_oath_already_outside_the_root_climbs_further() { + let r = reference_of("[Up](../b.md)", "../outside/a.md").unwrap(); + assert_eq!("../b.md", r.path); + assert_eq!("", r.slug); +} + +#[test] +fn join_posix_normalizes_dots_and_empty_segments() { + assert_eq!("varar/b.md", join_posix("varar", "./b.md")); + assert_eq!("b.md", join_posix("varar", "../b.md")); + assert_eq!("../b.md", join_posix("", "../b.md")); + assert_eq!("../../b.md", join_posix("x", "../../../b.md")); + assert_eq!("varar/b.md", join_posix("varar", ".//b.md")); +} diff --git a/typescript/packages/core/src/reference.ts b/typescript/packages/core/src/reference.ts index 88b61fd6..5eee722f 100644 --- a/typescript/packages/core/src/reference.ts +++ b/typescript/packages/core/src/reference.ts @@ -85,8 +85,12 @@ export function joinPosix(dir: string, rel: string): string { const segments = dir === '' ? [] : dir.split('/') for (const segment of rel.split('/')) { if (segment === '' || segment === '.') continue - if (segment === '..') segments.pop() - else segments.push(segment) + if (segment !== '..') segments.push(segment) + // A `..` with nothing left to climb out of stays: an oath above the + // workspace root is addressed as `../shared/b.md` (toOathPath keeps the + // leading `../` too), and clamping it would point at the wrong file. + else if (segments.length > 0 && segments[segments.length - 1] !== '..') segments.pop() + else segments.push('..') } return segments.join('/') } diff --git a/typescript/packages/core/tests/reference.test.ts b/typescript/packages/core/tests/reference.test.ts index 73e1a8a1..3627fc0a 100644 --- a/typescript/packages/core/tests/reference.test.ts +++ b/typescript/packages/core/tests/reference.test.ts @@ -254,3 +254,65 @@ test('slugs follow GitHub: inline markup dropped, punctuation stripped', () => { expect(slugify('Fees, VAT & rounding!')).toBe('fees-vat--rounding') expect(slugify('`code` spans')).toBe('code-spans') }) + +test('an example a reference opens is placed at the reference block, under the referring document’s headings', () => { + // The spliced steps keep their spans in shared.md; the EXAMPLE lives in + // fees.md. Its span used to be built from the section's offsets read against + // fees.md's source, which put it at an unrelated line. + const main = `# Late fees + +[A stocked library](./shared.md#a-stocked-library) + +Maya borrows "Emma". +` + const { main: planned } = planWith(main) + const ex = planned.examples[0]! + expect(ex.scopeStack).toEqual(['Late fees']) + expect(ex.span.startLine).toBe(3) + expect(ex.span.startCol).toBe(1) + expect(ex.span.endLine).toBe(5) + expect(main.slice(ex.span.startOffset, ex.span.endOffset)).toBe( + '[A stocked library](./shared.md#a-stocked-library)\n\nMaya borrows "Emma".', + ) +}) + +test('an example that is nothing but a reference spans the reference block and keeps the host headings', () => { + const main = `# Late fees + +## Invariants + +[Fees are enabled](./shared.md#fees-are-enabled) +` + const { main: planned } = planWith(main) + expect(planned.examples).toHaveLength(1) + const ex = planned.examples[0]! + expect(ex.steps.map((s) => s.text)).toEqual(['Fees are enabled']) + expect(ex.scopeStack).toEqual(['Late fees', 'Invariants']) + expect(main.slice(ex.span.startOffset, ex.span.endOffset)).toBe( + '[Fees are enabled](./shared.md#fees-are-enabled)', + ) +}) + +test('a reference mid-example extends the example to the reference block, not into the other file', () => { + const main = `# Late fees + +Maya borrows "Emma". + +[Fees are enabled](./shared.md#fees-are-enabled) +` + const { main: planned } = planWith(main) + const ex = planned.examples[0]! + expect(ex.steps.map((s) => s.text)).toEqual(['Maya borrows "Emma"', 'Fees are enabled']) + expect(main.slice(ex.span.startOffset, ex.span.endOffset)).toBe( + 'Maya borrows "Emma".\n\n[Fees are enabled](./shared.md#fees-are-enabled)', + ) +}) + +test('a link that climbs above the workspace root keeps its leading ../', () => { + // toOathPath keeps `../` for an oath outside the root; the resolver must too, + // or `../../shared/b.md` from `varar/a.md` would land on `shared/b.md`. + const doc = parse('varar/a.md', '[Up](../../shared/b.md#setup)\n') + expect(references(doc)[0]?.path).toBe('../shared/b.md') + const deeper = parse('../outside/a.md', '[Up](../b.md)\n') + expect(references(deeper)[0]?.path).toBe('../b.md') +}) From a30c4a89374e1efcd59f007b48a66ef2044fa356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Mon, 14 Sep 2026 12:12:20 +0100 Subject: [PATCH 19/21] fix(vscode): the language server writes edits through in order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two keystrokes in quick succession were two independent async writes of the same file; the earlier one finishing last left the older text on disk for the debounced reindex to read. Write-throughs are now serialised, the index is marked dirty before the write completes, and a reindex waits for every write queued ahead of it — so no request can observe an index older than the edit it answers about. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RQVAKGfMEKPbT919FqAChP --- typescript/packages/lsp/src/server.ts | 28 ++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/typescript/packages/lsp/src/server.ts b/typescript/packages/lsp/src/server.ts index de5884bd..d515f6ab 100644 --- a/typescript/packages/lsp/src/server.ts +++ b/typescript/packages/lsp/src/server.ts @@ -87,6 +87,10 @@ export function registerHandlers( // Reindexes are serialised through this chain: store.reindex() is async, and // two overlapping runs would race to assign the index. let inFlight: Promise = Promise.resolve() + // Write-throughs are serialised too: two edits in quick succession are two + // async writes of the same file, and the earlier one finishing last would + // leave the older text on disk for the reindex to read. + let writes: Promise = Promise.resolve() function scheduleReindex(): void { dirty = true @@ -106,6 +110,9 @@ export function registerHandlers( if (!dirty) return inFlight dirty = false inFlight = inFlight.then(async () => { + // Every write queued before this reindex lands first, so the index is + // never built from a file older than the edit it answers about. + await writes if (!store) return await store.reindex() afterReindex() @@ -113,11 +120,22 @@ export function registerHandlers( return inFlight } - // Write-through: persist edited docs to the FileSystem, then reindex (debounced). - documents.onDidChangeContent(async (e) => { - await opts?.onDidChangeDocument?.(e.document.uri, e.document.getText()) - if (!store) return - await store.fs().write(uriToPath(e.document.uri), e.document.getText()) + // Write-through: persist edited docs to the FileSystem, then reindex + // (debounced). The reindex is scheduled at once — marking the index dirty + // before the write completes — so a request arriving mid-write still waits + // for it via settled(). + documents.onDidChangeContent((e) => { + const uri = e.document.uri + const text = e.document.getText() + writes = writes + .then(async () => { + await opts?.onDidChangeDocument?.(uri, text) + if (store) await store.fs().write(uriToPath(uri), text) + }) + .catch((err: unknown) => { + // A failed write must not poison the chain for later edits. + connection.console.error(`varar: write-through failed for ${uri}: ${String(err)}`) + }) scheduleReindex() }) From 8535bccaf31a73b2c6b2d482ab4df8e6eb46b67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Mon, 14 Sep 2026 12:12:20 +0100 Subject: [PATCH 20/21] fix(vscode): a spliced step is highlighted in the oath it was written in The workspace index labelled a step a reference block spliced in with the referring oath, while its ranges addressed the referenced one. The editor looks matches up by path, so the host file was painted at another file's offsets and the shared file showed nothing. A spliced match now carries its own document, and a section two oaths reference yields one match, not two. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RQVAKGfMEKPbT919FqAChP --- .../packages/language/src/index-workspace.ts | 30 +++++++++++++++++-- .../language/tests/index-workspace.test.ts | 30 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/typescript/packages/language/src/index-workspace.ts b/typescript/packages/language/src/index-workspace.ts index 41523664..8a590543 100644 --- a/typescript/packages/language/src/index-workspace.ts +++ b/typescript/packages/language/src/index-workspace.ts @@ -169,6 +169,7 @@ export function buildWorkspaceIndex(input: WorkspaceInput, cache?: IndexCache): } const matches: MatchRef[] = [] + const seenMatches = new Set() const diagnostics: DiagnosticRef[] = [] const oaths = new Map() @@ -207,7 +208,7 @@ export function buildWorkspaceIndex(input: WorkspaceInput, cache?: IndexCache): const cached = cache?.plans.get(planKey) if (cached) { oaths.set(file.path, cached) - matches.push(...cached.matches) + addMatches(cached.matches) diagnostics.push(...cached.diagnostics) continue } @@ -247,7 +248,11 @@ export function buildWorkspaceIndex(input: WorkspaceInput, cache?: IndexCache): ) if (!def) continue fileMatches.push({ - oathPath: file.path, + // A step a reference block spliced in from another oath (ADR 0016) + // is labelled with THAT oath: its ranges address that file, and the + // editor looks matches up by path to highlight them and to build + // the URI it navigates to. + oathPath: step.docPath ?? file.path, range: toRange(step.matchSpan), // Highlight only the value passed to the handler (inner capture // group); paramValues keeps the full notation for rename. @@ -276,11 +281,30 @@ export function buildWorkspaceIndex(input: WorkspaceInput, cache?: IndexCache): } cache?.plans.set(planKey, planned) oaths.set(file.path, planned) - matches.push(...fileMatches) + addMatches(fileMatches) diagnostics.push(...fileDiagnostics) } return { stepDefs, matches, diagnostics, registry, workspace, oaths } + + // A section two oaths reference is planned once per referrer, so its matches + // arrive once per referrer too. The site is the same; keep one. + function addMatches(list: ReadonlyArray): void { + for (const m of list) { + const key = [ + m.oathPath, + m.range.start.line, + m.range.start.character, + m.range.end.line, + m.range.end.character, + m.stepDef.file, + m.stepDef.expression, + ].join('') + if (seenMatches.has(key)) continue + seenMatches.add(key) + matches.push(m) + } + } } type SpanLike = { diff --git a/typescript/packages/language/tests/index-workspace.test.ts b/typescript/packages/language/tests/index-workspace.test.ts index 2e1c9696..1ba2d28f 100644 --- a/typescript/packages/language/tests/index-workspace.test.ts +++ b/typescript/packages/language/tests/index-workspace.test.ts @@ -154,3 +154,33 @@ test('a plain (non-header-bound) match carries no headerCellRanges', () => { }) expect(idx.matches[0]?.headerCellRanges).toBeUndefined() }) + +test('a spliced step is a match in the oath it was written in, once, however many oaths reference it', () => { + // Its ranges address shared.md, and the editor finds matches by path — so a + // match labelled with the referring oath would paint shared.md's offsets + // onto fees.md. Two referrers plan the section twice; the site is one. + const idx = build({ + stepFiles: [ + { + path: '/abs/steps.ts', + source: `stimulus('I shelve {int} books', () => {})\nstimulus('I borrow a book', () => {})\n`, + }, + ], + oathFiles: [ + { path: '/abs/varar/shared.md', source: '# Shared\n\n## Stocked\n\nI shelve 3 books.\n' }, + { + path: '/abs/varar/fees.md', + source: '# Fees\n\n[Stocked](./shared.md#stocked)\n\nI borrow a book.\n', + }, + { + path: '/abs/varar/holds.md', + source: '# Holds\n\n[Stocked](./shared.md#stocked)\n\nI borrow a book.\n', + }, + ], + }) + const shelve = idx.matches.filter((m) => m.stepDef.expression === 'I shelve {int} books') + expect(shelve).toHaveLength(1) + expect(shelve[0]?.oathPath).toBe('/abs/varar/shared.md') + expect(shelve[0]?.range.start.line).toBe(5) + expect(idx.matches.filter((m) => m.oathPath === '/abs/varar/fees.md')).toHaveLength(1) +}) From 02396897826b81d7fb47e28823971c97e5d36165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= Date: Mon, 14 Sep 2026 12:12:20 +0100 Subject: [PATCH 21/21] chore(website): the playground plans its oath against an empty workspace plan() takes the workspace as a required argument (ADR 0016); the browser runner still called it with the old arity, which threw at run time. The playground runs one oath on its own, so the empty workspace is the honest one. The stray third argument to parse() goes too. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RQVAKGfMEKPbT919FqAChP --- typescript/packages/website/src/lib/run-oath.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/typescript/packages/website/src/lib/run-oath.ts b/typescript/packages/website/src/lib/run-oath.ts index cc0ad27a..83d3c271 100644 --- a/typescript/packages/website/src/lib/run-oath.ts +++ b/typescript/packages/website/src/lib/run-oath.ts @@ -2,6 +2,7 @@ import { type BaselineStore, type Drift, type ExampleResult, + emptyWorkspace, executePlan, hashSource, type OathResults, @@ -33,8 +34,10 @@ export async function runRegisteredOath( options: RunOathOptions = {}, ): Promise { const registry = buildRegistry() - const doc = parse(oathPath, varSource, []) - const full = plan(doc, registry) + const doc = parse(oathPath, varSource) + // The playground runs one oath on its own, so nothing references anything: + // the empty workspace is the honest one (ADR 0016). + const full = plan(doc, registry, emptyWorkspace()) const { exampleIndex } = options const examples = exampleIndex == null ? full.examples : full.examples.filter((_, i) => i === exampleIndex)