diff --git a/conformance/bundles/01-roman-numerals/golden/doc.json b/conformance/bundles/01-roman-numerals/golden/doc.json index db88b530..eafa7681 100644 --- a/conformance/bundles/01-roman-numerals/golden/doc.json +++ b/conformance/bundles/01-roman-numerals/golden/doc.json @@ -36,6 +36,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 17, + "endLine": 1, + "endOffset": 16, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Roman numerals" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 16, + "endLine": 3, + "endOffset": 33, + "startCol": 1, + "startLine": 3, + "startOffset": 18 + }, + "text": "Converting 1" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/02-context-isolation/golden/doc.json b/conformance/bundles/02-context-isolation/golden/doc.json index d48ce040..12e3cb2d 100644 --- a/conformance/bundles/02-context-isolation/golden/doc.json +++ b/conformance/bundles/02-context-isolation/golden/doc.json @@ -71,6 +71,47 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 10, + "endLine": 1, + "endOffset": 9, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Counter" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 30, + "endLine": 3, + "endOffset": 40, + "startCol": 1, + "startLine": 3, + "startOffset": 11 + }, + "text": "First example starts fresh" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 31, + "endLine": 7, + "endOffset": 102, + "startCol": 1, + "startLine": 7, + "startOffset": 72 + }, + "text": "Second example starts fresh" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/03-expected-failure/golden/doc.json b/conformance/bundles/03-expected-failure/golden/doc.json index c558d68f..f4604466 100644 --- a/conformance/bundles/03-expected-failure/golden/doc.json +++ b/conformance/bundles/03-expected-failure/golden/doc.json @@ -57,6 +57,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 11, + "endLine": 1, + "endOffset": 10, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Division" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 32, + "endLine": 3, + "endOffset": 43, + "startCol": 1, + "startLine": 3, + "startOffset": 12 + }, + "text": "Dividing by zero is rejected" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/04-tables-and-docstrings/golden/doc.json b/conformance/bundles/04-tables-and-docstrings/golden/doc.json index 37ebfc34..f4653341 100644 --- a/conformance/bundles/04-tables-and-docstrings/golden/doc.json +++ b/conformance/bundles/04-tables-and-docstrings/golden/doc.json @@ -57,6 +57,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 7, + "endLine": 1, + "endOffset": 6, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Echo" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 28, + "endLine": 3, + "endOffset": 35, + "startCol": 1, + "startLine": 3, + "startOffset": 8 + }, + "text": "It echoes the doc string" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/05-ambiguous-match/golden/doc.json b/conformance/bundles/05-ambiguous-match/golden/doc.json index 49bb868b..0cf582c3 100644 --- a/conformance/bundles/05-ambiguous-match/golden/doc.json +++ b/conformance/bundles/05-ambiguous-match/golden/doc.json @@ -35,6 +35,21 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 8, + "endLine": 1, + "endOffset": 7, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Cukes" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/06-doc-string-mismatch/golden/doc.json b/conformance/bundles/06-doc-string-mismatch/golden/doc.json index cd9bd71c..186394fa 100644 --- a/conformance/bundles/06-doc-string-mismatch/golden/doc.json +++ b/conformance/bundles/06-doc-string-mismatch/golden/doc.json @@ -57,6 +57,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 16, + "endLine": 1, + "endOffset": 15, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Echo mismatch" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 26, + "endLine": 3, + "endOffset": 42, + "startCol": 1, + "startLine": 3, + "startOffset": 17 + }, + "text": "A wrong echo is caught" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/07-row-check-mismatch/golden/doc.json b/conformance/bundles/07-row-check-mismatch/golden/doc.json index 6808742b..8d2eb6db 100644 --- a/conformance/bundles/07-row-check-mismatch/golden/doc.json +++ b/conformance/bundles/07-row-check-mismatch/golden/doc.json @@ -113,6 +113,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 10, + "endLine": 1, + "endOffset": 9, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Scoring" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 27, + "endLine": 3, + "endOffset": 37, + "startCol": 1, + "startLine": 3, + "startOffset": 11 + }, + "text": "A wrong score is caught" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/08-string-capture/golden/doc.json b/conformance/bundles/08-string-capture/golden/doc.json index 940bcf70..2af89f60 100644 --- a/conformance/bundles/08-string-capture/golden/doc.json +++ b/conformance/bundles/08-string-capture/golden/doc.json @@ -36,6 +36,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 11, + "endLine": 1, + "endOffset": 10, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Greeting" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 31, + "endLine": 3, + "endOffset": 42, + "startCol": 1, + "startLine": 3, + "startOffset": 12 + }, + "text": "It captures a quoted string" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/09-expected-message-mismatch/golden/doc.json b/conformance/bundles/09-expected-message-mismatch/golden/doc.json index 20105d88..94358052 100644 --- a/conformance/bundles/09-expected-message-mismatch/golden/doc.json +++ b/conformance/bundles/09-expected-message-mismatch/golden/doc.json @@ -57,6 +57,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 19, + "endLine": 1, + "endOffset": 18, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Message mismatch" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 47, + "endLine": 3, + "endOffset": 66, + "startCol": 1, + "startLine": 3, + "startOffset": 20 + }, + "text": "A non-matching expected message still fails" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/10-error-fence-without-step/golden/doc.json b/conformance/bundles/10-error-fence-without-step/golden/doc.json index c6aac660..0a3fa749 100644 --- a/conformance/bundles/10-error-fence-without-step/golden/doc.json +++ b/conformance/bundles/10-error-fence-without-step/golden/doc.json @@ -56,6 +56,21 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 7, + "endLine": 1, + "endOffset": 6, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Nope" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/11-emoji-offsets/golden/doc.json b/conformance/bundles/11-emoji-offsets/golden/doc.json index c1728d96..69b37844 100644 --- a/conformance/bundles/11-emoji-offsets/golden/doc.json +++ b/conformance/bundles/11-emoji-offsets/golden/doc.json @@ -122,6 +122,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 17, + "endLine": 1, + "endOffset": 16, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "๐Ÿ˜€ Emoji World" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 28, + "endLine": 3, + "endOffset": 45, + "startCol": 1, + "startLine": 3, + "startOffset": 18 + }, + "text": "It greets after an emoji" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/12-combining-marks/golden/doc.json b/conformance/bundles/12-combining-marks/golden/doc.json index 370a7d80..10d59777 100644 --- a/conformance/bundles/12-combining-marks/golden/doc.json +++ b/conformance/bundles/12-combining-marks/golden/doc.json @@ -36,6 +36,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 16, + "endLine": 1, + "endOffset": 15, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "๐ŸŽ‰ Cafรฉ World" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 34, + "endLine": 3, + "endOffset": 50, + "startCol": 1, + "startLine": 3, + "startOffset": 17 + }, + "text": "It greets with combining marks" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/13-custom-parameter-type/golden/doc.json b/conformance/bundles/13-custom-parameter-type/golden/doc.json index 1818b44b..6f275c8e 100644 --- a/conformance/bundles/13-custom-parameter-type/golden/doc.json +++ b/conformance/bundles/13-custom-parameter-type/golden/doc.json @@ -36,6 +36,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 25, + "endLine": 1, + "endOffset": 24, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Custom parameter types" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 20, + "endLine": 3, + "endOffset": 45, + "startCol": 1, + "startLine": 3, + "startOffset": 26 + }, + "text": "Flying to London" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/14-stateless-steps/golden/doc.json b/conformance/bundles/14-stateless-steps/golden/doc.json index 8f27847e..2e012d3c 100644 --- a/conformance/bundles/14-stateless-steps/golden/doc.json +++ b/conformance/bundles/14-stateless-steps/golden/doc.json @@ -36,6 +36,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 18, + "endLine": 1, + "endOffset": 17, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Stateless steps" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 23, + "endLine": 3, + "endOffset": 41, + "startCol": 1, + "startLine": 3, + "startOffset": 19 + }, + "text": "Squaring in my head" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/15-custom-parameter-format/golden/doc.json b/conformance/bundles/15-custom-parameter-format/golden/doc.json index b02c892e..c9d6621a 100644 --- a/conformance/bundles/15-custom-parameter-format/golden/doc.json +++ b/conformance/bundles/15-custom-parameter-format/golden/doc.json @@ -36,6 +36,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 26, + "endLine": 1, + "endOffset": 25, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Custom parameter format" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 44, + "endLine": 3, + "endOffset": 70, + "startCol": 1, + "startLine": 3, + "startOffset": 27 + }, + "text": "A wrong fee renders in document notation" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/16-stimulus-state-replacement/golden/doc.json b/conformance/bundles/16-stimulus-state-replacement/golden/doc.json index ea7e2268..3eeae71b 100644 --- a/conformance/bundles/16-stimulus-state-replacement/golden/doc.json +++ b/conformance/bundles/16-stimulus-state-replacement/golden/doc.json @@ -70,6 +70,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 29, + "endLine": 1, + "endOffset": 28, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Stimulus state replacement" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 68, + "endLine": 6, + "endOffset": 232, + "startCol": 1, + "startLine": 6, + "startOffset": 165 + }, + "text": "A stimulus return replaces the state rather than merging into it" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/17-unexpected-pass/golden/doc.json b/conformance/bundles/17-unexpected-pass/golden/doc.json index 998bdfc6..32635ddb 100644 --- a/conformance/bundles/17-unexpected-pass/golden/doc.json +++ b/conformance/bundles/17-unexpected-pass/golden/doc.json @@ -91,6 +91,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 18, + "endLine": 1, + "endOffset": 17, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Unexpected pass" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 58, + "endLine": 7, + "endOffset": 252, + "startCol": 1, + "startLine": 7, + "startOffset": 195 + }, + "text": "An example expected to fail, that passes, is a failure" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/18-multi-table-example/golden/doc.json b/conformance/bundles/18-multi-table-example/golden/doc.json index 1e3dd813..b7e929ed 100644 --- a/conformance/bundles/18-multi-table-example/golden/doc.json +++ b/conformance/bundles/18-multi-table-example/golden/doc.json @@ -307,6 +307,21 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 36, + "endLine": 1, + "endOffset": 35, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "A basket imports users and assets" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/19-emphasis-parameter/golden/doc.json b/conformance/bundles/19-emphasis-parameter/golden/doc.json index 02268e2e..69bf22a9 100644 --- a/conformance/bundles/19-emphasis-parameter/golden/doc.json +++ b/conformance/bundles/19-emphasis-parameter/golden/doc.json @@ -36,6 +36,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 11, + "endLine": 1, + "endOffset": 10, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Emphasis" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 35, + "endLine": 3, + "endOffset": 46, + "startCol": 1, + "startLine": 3, + "startOffset": 12 + }, + "text": "It captures an emphasized value" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/20-reference-splice/golden/doc.json b/conformance/bundles/20-reference-splice/golden/doc.json index d202e08a..1c7e88c8 100644 --- a/conformance/bundles/20-reference-splice/golden/doc.json +++ b/conformance/bundles/20-reference-splice/golden/doc.json @@ -103,6 +103,21 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 12, + "endLine": 1, + "endOffset": 11, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Late fees" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/21-reference-consumed/golden/doc.json b/conformance/bundles/21-reference-consumed/golden/doc.json index d6fb0ac5..339c1a11 100644 --- a/conformance/bundles/21-reference-consumed/golden/doc.json +++ b/conformance/bundles/21-reference-consumed/golden/doc.json @@ -70,6 +70,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 22, + "endLine": 1, + "endOffset": 21, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Shared world states" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 21, + "endLine": 6, + "endOffset": 153, + "startCol": 1, + "startLine": 6, + "startOffset": 133 + }, + "text": "A stocked library" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/conformance/bundles/22-reference-ambiguous-anchor/LibrarySteps.java b/conformance/bundles/22-reference-ambiguous-anchor/LibrarySteps.java new file mode 100644 index 00000000..c2c7bd6c --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/LibrarySteps.java @@ -0,0 +1,22 @@ +package dev.varar.conformance.bundle22; + +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 22-reference-ambiguous-anchor}). */ +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/22-reference-ambiguous-anchor/example.md b/conformance/bundles/22-reference-ambiguous-anchor/example.md new file mode 100644 index 00000000..4bf40fad --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/example.md @@ -0,0 +1,8 @@ +# Late fees + +A world state written once โ€” but the file it lives in names it twice, so the +link below could mean either section. That is an error, not a guess. + +[A stocked library](./shared.md#a-stocked-library) + +I borrow a book. diff --git a/conformance/bundles/22-reference-ambiguous-anchor/golden/doc.json b/conformance/bundles/22-reference-ambiguous-anchor/golden/doc.json new file mode 100644 index 00000000..359780ea --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/golden/doc.json @@ -0,0 +1,123 @@ +{ + "examples": [ + { + "body": [ + { + "kind": "paragraph", + "segmentMap": [ + { + "sourceOffset": 13, + "textOffset": 0 + } + ], + "span": { + "endCol": 69, + "endLine": 4, + "endOffset": 158, + "startCol": 1, + "startLine": 3, + "startOffset": 13 + }, + "text": "A world state written once โ€” but the file it lives in names it twice, so the\nlink below could mean either section. That is an error, not a guess." + } + ], + "precededByDelimiter": true, + "scopeStack": [ + "Late fees" + ], + "span": { + "endCol": 69, + "endLine": 4, + "endOffset": 158, + "startCol": 1, + "startLine": 3, + "startOffset": 13 + } + }, + { + "body": [ + { + "kind": "paragraph", + "segmentMap": [ + { + "sourceOffset": 160, + "textOffset": 0 + } + ], + "span": { + "endCol": 51, + "endLine": 6, + "endOffset": 210, + "startCol": 1, + "startLine": 6, + "startOffset": 160 + }, + "text": "[A stocked library](./shared.md#a-stocked-library)" + } + ], + "precededByDelimiter": false, + "scopeStack": [ + "Late fees" + ], + "span": { + "endCol": 51, + "endLine": 6, + "endOffset": 210, + "startCol": 1, + "startLine": 6, + "startOffset": 160 + } + }, + { + "body": [ + { + "kind": "paragraph", + "segmentMap": [ + { + "sourceOffset": 212, + "textOffset": 0 + } + ], + "span": { + "endCol": 17, + "endLine": 8, + "endOffset": 228, + "startCol": 1, + "startLine": 8, + "startOffset": 212 + }, + "text": "I borrow a book." + } + ], + "precededByDelimiter": false, + "scopeStack": [ + "Late fees" + ], + "span": { + "endCol": 17, + "endLine": 8, + "endOffset": 228, + "startCol": 1, + "startLine": 8, + "startOffset": 212 + } + } + ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 12, + "endLine": 1, + "endOffset": 11, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Late fees" + } + ], + "orphanAttachments": [], + "path": "example.md" +} diff --git a/conformance/bundles/22-reference-ambiguous-anchor/golden/plan.json b/conformance/bundles/22-reference-ambiguous-anchor/golden/plan.json new file mode 100644 index 00000000..8def758d --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/golden/plan.json @@ -0,0 +1,49 @@ +{ + "diagnostics": [ + { + "code": "ambiguous-anchor", + "severity": "error", + "span": { + "endCol": 51, + "endLine": 6, + "endOffset": 210, + "startCol": 1, + "startLine": 6, + "startOffset": 160 + } + } + ], + "examples": [ + { + "expectedOutcome": "pass", + "name": "I borrow a book", + "scopeStack": [ + "Late fees" + ], + "span": { + "endCol": 17, + "endLine": 8, + "endOffset": 228, + "startCol": 1, + "startLine": 8, + "startOffset": 212 + }, + "steps": [ + { + "args": [], + "matchSpan": { + "endCol": 16, + "endLine": 8, + "endOffset": 227, + "startCol": 1, + "startLine": 8, + "startOffset": 212 + }, + "matchedExpression": "I borrow a book", + "paramSpans": [], + "text": "I borrow a book" + } + ] + } + ] +} diff --git a/conformance/bundles/22-reference-ambiguous-anchor/golden/registry.json b/conformance/bundles/22-reference-ambiguous-anchor/golden/registry.json new file mode 100644 index 00000000..aa29f085 --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/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/22-reference-ambiguous-anchor/golden/trace.json b/conformance/bundles/22-reference-ambiguous-anchor/golden/trace.json new file mode 100644 index 00000000..7a39566f --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/golden/trace.json @@ -0,0 +1,21 @@ +{ + "examples": [ + { + "name": "I borrow a book", + "outcome": "pass", + "steps": [ + { + "contextKey": { + "exampleName": "I borrow a book", + "stepFile": "library.steps" + }, + "exampleName": "I borrow a book", + "matchedExpression": "I borrow a book", + "ordinal": 1, + "outcome": "pass", + "stepText": "I borrow a book" + } + ] + } + ] +} diff --git a/conformance/bundles/22-reference-ambiguous-anchor/library.steps.cs b/conformance/bundles/22-reference-ambiguous-anchor/library.steps.cs new file mode 100644 index 00000000..b1e485b6 --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/library.steps.cs @@ -0,0 +1,28 @@ +// C# sibling of library.steps.ts / .rs (bundle 22-reference-ambiguous-anchor). +using Varar; +using Varar.Core; + +namespace Varar.Corpus.B22; + +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/22-reference-ambiguous-anchor/library.steps.go b/conformance/bundles/22-reference-ambiguous-anchor/library.steps.go new file mode 100644 index 00000000..6922114b --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/library.steps.go @@ -0,0 +1,31 @@ +// Go sibling of library.steps.ts (bundle 22-reference-ambiguous-anchor). +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/22-reference-ambiguous-anchor/library.steps.kt b/conformance/bundles/22-reference-ambiguous-anchor/library.steps.kt new file mode 100644 index 00000000..080278bf --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/library.steps.kt @@ -0,0 +1,17 @@ +@file:JvmName("LibrarySteps") + +// Kotlin sibling of library.steps.ts / library.steps.py / LibrarySteps.java +// (bundle 22-reference-ambiguous-anchor). +package dev.varar.kotlin.conformance.bundle22 + +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/22-reference-ambiguous-anchor/library.steps.py b/conformance/bundles/22-reference-ambiguous-anchor/library.steps.py new file mode 100644 index 00000000..56de56e8 --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/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/22-reference-ambiguous-anchor/library.steps.rb b/conformance/bundles/22-reference-ambiguous-anchor/library.steps.rb new file mode 100644 index 00000000..a575fdd1 --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/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/22-reference-ambiguous-anchor/library.steps.rs b/conformance/bundles/22-reference-ambiguous-anchor/library.steps.rs new file mode 100644 index 00000000..11454baa --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/library.steps.rs @@ -0,0 +1,26 @@ +//! Rust sibling of `library.steps.ts` (bundle `22-reference-ambiguous-anchor`). + +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/22-reference-ambiguous-anchor/library.steps.ts b/conformance/bundles/22-reference-ambiguous-anchor/library.steps.ts new file mode 100644 index 00000000..3fce1e0e --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/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/22-reference-ambiguous-anchor/shared.md b/conformance/bundles/22-reference-ambiguous-anchor/shared.md new file mode 100644 index 00000000..36d2b0ac --- /dev/null +++ b/conformance/bundles/22-reference-ambiguous-anchor/shared.md @@ -0,0 +1,13 @@ +# Shared world states + +Two headings with the same text slug identically (`#a-stocked-library` on +both; GitHub would call the second `#a-stocked-library-1`), so a reference to +that anchor is ambiguous. + +## A stocked library + +I shelve 3 books. + +## A stocked library + +I shelve 5 books. diff --git a/conformance/bundles/23-reference-only-example/golden/doc.json b/conformance/bundles/23-reference-only-example/golden/doc.json index c93c86a4..fefc9192 100644 --- a/conformance/bundles/23-reference-only-example/golden/doc.json +++ b/conformance/bundles/23-reference-only-example/golden/doc.json @@ -71,6 +71,34 @@ } } ], + "headings": [ + { + "kind": "heading", + "level": 1, + "span": { + "endCol": 12, + "endLine": 1, + "endOffset": 11, + "startCol": 1, + "startLine": 1, + "startOffset": 0 + }, + "text": "Late fees" + }, + { + "kind": "heading", + "level": 2, + "span": { + "endCol": 20, + "endLine": 3, + "endOffset": 32, + "startCol": 1, + "startLine": 3, + "startOffset": 13 + }, + "text": "Shelf invariants" + } + ], "orphanAttachments": [], "path": "example.md" } diff --git a/doc/adr/0016-reuse-is-a-link.md b/doc/adr/0016-reuse-is-a-link.md index 6c753084..80645175 100644 --- a/doc/adr/0016-reuse-is-a-link.md +++ b/doc/adr/0016-reuse-is-a-link.md @@ -145,14 +145,14 @@ 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. +[Open questions](#open-questions-and-how-each-was-decided) 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). +[Open questions](#open-questions-and-how-each-was-decided). ### References nest, and depth is a style question @@ -199,8 +199,12 @@ degrade to prose: - **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; +- **ambiguous anchor** โ€” the anchor a reference names belongs to two headings + in the target file (`#setup` / `#setup-1`). Reported on the reference, + naming the headings' lines, and the reference contributes nothing until it + is fixed. The rule is "the anchor a reference names belongs to one heading", + not "unique headings in any referenced file": a duplicate nobody links to is + harmless, and the tool enforces what is checkable, not what is tidy; - **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 @@ -536,41 +540,54 @@ Rollout, in dependency order โ€” each step is independently shippable and green: 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 +## Open questions, and how each was decided -Unresolved; each needs a decision before implementation. +Every question this ADR opened is now closed. They are kept in place, struck +through, with the decision under each, so the reasoning that led to what +shipped stays readable next to the question that prompted it. 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)? -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. +2. ~~Where is drift reported for a referenced section?~~ **Resolved** โ€” it is + not drift at all. A consumed section plans no example in its own file, so + its paragraphs are not in the baseline; a step that stops matching one is + caught as `reference-empty` at the reference that depends on it โ€” once, + loudly, where the fix goes. The consumed file's baseline entry means only + "this file was discovered". Consequence, accepted: delete every referrer and + the section reads as a new example, not a restored one. Documented under + Drift detection in `reference/examples.mdx`. +3. ~~Does a referenced section show up in the report at all?~~ **Resolved** โ€” + flat, attributed. Spliced steps read as if written in place, in the + referencing example, in the order they ran; no nesting, since the seven + adapters share no report shape and a level of nesting in each is real + cost for a reader who mostly wants the steps. What records reuse is the + run result: `failure.docPath` (ADR 0014 v2) names the document a failing + step was written in. +4. ~~What if a referenced section sits under headings?~~ **Resolved** โ€” its + own `scopeStack` is discarded in favour of the referring example's; the + heading is the anchor and the link text, nothing more. Pinned by + `21-reference-consumed`, documented in the reference. +5. ~~May an example reference more than one section, and the same one twice?~~ + **Resolved** โ€” yes and yes, in document order. Two references are two + splices; "the nightly batch runs" twice is a real scenario, not a paste + error, and no diagnostic second-guesses it. +6. ~~Is a fragment-less `.md` reference (whole file) worth keeping?~~ + **Resolved** โ€” kept. A one-section file is the common shared-setup shape + and the link reads best. The hazard (its meaning changes when the file + grows a section) is documented rather than forbidden. 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" - 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 + What the entry *means* is settled by question 2. +8. ~~What does the editor do at a reference block?~~ **Resolved** โ€” both. + Go-to-definition lands on the heading the anchor names (the top of the file + for a whole-file reference; one link per heading for an ambiguous anchor). + Hover shows the steps the reference splices in, the whole chain resolved, + with a step from a third file saying which โ€” which is what keeps the "reader + must see the world state" argument true at the point of use. The three + reference diagnostics and `ambiguous-anchor` sit on the block. +9. ~~Is an opt-in depth lint worth it?~~ **Resolved** โ€” no. Depth stays a + style question for prose, review and agent instructions, as + `explanation/reuse.md` argues; a configurable ceiling would be the same prohibition wearing a hat. ## Documentation @@ -603,13 +620,20 @@ corpus caught: 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). +2. **Sections resolve through the scope stack, and `Doc` carries the outline.** + 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. That rule cannot tell two headings + that slug identically apart, which is the **ambiguous-anchor** error from + the Errors list. It first shipped as a TypeScript-only lint check that + re-read the target's outline from its source, on the argument that a + `headings` field would move every port's `doc.json` golden; the decision + was reversed so the error fails the run in every port, like the other + three. `Doc` now carries `headings` (the heading blocks, in order), the + doc artifact pins them, `plan()` refuses a reference whose anchor names + more than one, and `22-reference-ambiguous-anchor` gates all seven ports. + The goldens moved after all; the outline is now available for sections to + resolve against directly, should the scope-stack rule ever need replacing. 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 @@ -647,8 +671,10 @@ Two adapter-level notes: shared oath has no result file of its own, so its failures previously had no way to reach the editor at all). `conformance/run-results/expected.json` grew a fourth example, which is what gated the other six ports. -- **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. +- ~~**Ambiguous anchors** (deviation 2) are undetected.~~ **Done** โ€” a plan + diagnostic in every port, gated by `22-reference-ambiguous-anchor`; see + deviation 2 for the shape and the reversal. +- ~~**LSP reference support** โ€” go-to-definition and hover on a reference + block.~~ **Done** โ€” open question 8. +- ~~Open questions 2โ€“6 and 9.~~ **Resolved** โ€” each answered in place above, + and the reference documents what was decided. Nothing on this ADR is open. diff --git a/dotnet/Varar.Core/Ast.cs b/dotnet/Varar.Core/Ast.cs index cab3cb68..4551379c 100644 --- a/dotnet/Varar.Core/Ast.cs +++ b/dotnet/Varar.Core/Ast.cs @@ -87,12 +87,19 @@ public sealed record Example( ImmutableArray Body, bool PrecededByDelimiter); -/// The parsed document. Source is kept for the runner but not projected to doc.json. +/// +/// The parsed document. Source is kept for the runner but not projected to doc.json. +/// Headings is every heading in the document, in order: the outline a reference block's +/// anchor is resolved against (ADR 0016). Candidates still carry the chain above them in +/// ScopeStack; this is the list itself, so the planner can tell that two headings slug +/// identically โ€” which the chain cannot. +/// public sealed record Doc( string Path, string Source, ImmutableArray Examples, - ImmutableArray OrphanAttachments); + ImmutableArray OrphanAttachments, + ImmutableArray Headings); /// A raw source line: its text and source offsets. public sealed record RawLine(string Text, int StartOffset, int EndOffset); diff --git a/dotnet/Varar.Core/Conformance.cs b/dotnet/Varar.Core/Conformance.cs index d000a0fb..d262a103 100644 --- a/dotnet/Varar.Core/Conformance.cs +++ b/dotnet/Varar.Core/Conformance.cs @@ -176,7 +176,8 @@ private static Value PlannedStepValue(PlannedStep step, string source) public static Value ToDocArtifact(Doc doc) => Map( ("path", Value.Of(doc.Path)), ("examples", List(doc.Examples, ExampleValue)), - ("orphanAttachments", List(doc.OrphanAttachments, BlockValue))); + ("orphanAttachments", List(doc.OrphanAttachments, BlockValue)), + ("headings", List(doc.Headings, h => BlockValue(h)))); private static Value ExampleValue(Example example) => Map( ("scopeStack", Value.List(example.ScopeStack.Select(Value.Of))), diff --git a/dotnet/Varar.Core/Diagnostics.cs b/dotnet/Varar.Core/Diagnostics.cs index 38064f1c..1698941f 100644 --- a/dotnet/Varar.Core/Diagnostics.cs +++ b/dotnet/Varar.Core/Diagnostics.cs @@ -16,11 +16,12 @@ public enum DiagnosticCode /// /// Reference blocks (ADR 0016): a link that resolves to no oath, to a section with no steps, - /// or to a chain that reaches itself. + /// to a chain that reaches itself, or to an anchor that two headings share. /// ReferenceNotFound, ReferenceEmpty, ReferenceCycle, + AmbiguousAnchor, } /// A diagnostic on the shared rail. Port of diagnostics.ts. @@ -45,6 +46,7 @@ public static class Diagnostics DiagnosticCode.ReferenceNotFound => "reference-not-found", DiagnosticCode.ReferenceEmpty => "reference-empty", DiagnosticCode.ReferenceCycle => "reference-cycle", + DiagnosticCode.AmbiguousAnchor => "ambiguous-anchor", _ => throw new ArgumentOutOfRangeException(nameof(code), code, null), }; @@ -104,4 +106,26 @@ public static Diagnostic AmbiguousMatch(string text, Span span, ImmutableArray + /// The anchor names more than one heading in the target document (two headings that slug + /// identically). An error rather than a guess: GitHub would disambiguate the second with a + /// numeric suffix, but a reference that could mean either section means neither. + /// + public static Diagnostic AmbiguousAnchor( + string text, + string path, + string slug, + IEnumerable headingLines, + Span span) + { + var lines = headingLines.ToList(); + return new Diagnostic( + Severity.Error, + DiagnosticCode.AmbiguousAnchor, + $"Reference to \"{text}\" is ambiguous: \"{path}\" has {lines.Count} headings with the " + + $"anchor \"#{slug}\" (lines {string.Join(", ", lines)}).\n" + + "Rename the headings so each has an anchor of its own.", + span); + } } diff --git a/dotnet/Varar.Core/Plan.cs b/dotnet/Varar.Core/Plan.cs index 4d6b869e..213e71c1 100644 --- a/dotnet/Varar.Core/Plan.cs +++ b/dotnet/Varar.Core/Plan.cs @@ -280,6 +280,23 @@ private static List ResolveReference( return []; } + // An anchor that names two headings could mean either section, so it means neither. A + // whole-file reference (empty slug) names no heading and is never ambiguous. + if (reference.Slug.Length > 0) + { + var named = target.Headings.Where(h => Reference_.Slugify(h.Text) == reference.Slug).ToList(); + if (named.Count > 1) + { + diagnostics.Add(Varar.Core.Diagnostics.AmbiguousAnchor( + reference.Text, + reference.Path, + reference.Slug, + named.Select(h => h.Span.StartLine), + unit.Span)); + return []; + } + } + var result = new List(); foreach (var candidate in Reference_.SectionCandidates(target, reference.Slug)) { diff --git a/dotnet/Varar.Core/Structurer.cs b/dotnet/Varar.Core/Structurer.cs index 0f5967bd..d80a92b0 100644 --- a/dotnet/Varar.Core/Structurer.cs +++ b/dotnet/Varar.Core/Structurer.cs @@ -20,6 +20,7 @@ public static Doc Structure(string path, string source, ImmutableArray bl { var examples = new List(); var orphanAttachments = ImmutableArray.CreateBuilder(); + var headings = ImmutableArray.CreateBuilder(); var scopeStack = new List<(int Level, string Text)>(); int lastExampleIdx = -1; bool attachmentOpen = false; @@ -34,6 +35,7 @@ public static Doc Structure(string path, string source, ImmutableArray bl switch (block) { case Heading heading: + headings.Add(heading); while (scopeStack.Count > 0 && scopeStack[^1].Level >= heading.Level) { scopeStack.RemoveAt(scopeStack.Count - 1); @@ -77,6 +79,6 @@ [.. scopeStack.Select(s => s.Text)], } } - return new Doc(path, source, [.. examples], orphanAttachments.ToImmutable()); + return new Doc(path, source, [.. examples], orphanAttachments.ToImmutable(), headings.ToImmutable()); } } diff --git a/dotnet/Varar.Tests/ConformanceFixtures.cs b/dotnet/Varar.Tests/ConformanceFixtures.cs index a3fc413e..da65bb19 100644 --- a/dotnet/Varar.Tests/ConformanceFixtures.cs +++ b/dotnet/Varar.Tests/ConformanceFixtures.cs @@ -35,6 +35,7 @@ public static class ConformanceFixtures ["20-reference-splice"] = Corpus.B20.LibrarySteps.Register, ["21-reference-consumed"] = Corpus.B21.LibrarySteps.Register, ["23-reference-only-example"] = Corpus.B23.LibrarySteps.Register, + ["22-reference-ambiguous-anchor"] = Corpus.B22.LibrarySteps.Register, }; /// Locate the shared corpus directory by walking up from the test binary. @@ -107,6 +108,7 @@ public static Registry Build(string bundle) ["20-reference-splice"] = Corpus.B20.LibrarySteps.State, ["21-reference-consumed"] = Corpus.B21.LibrarySteps.State, ["23-reference-only-example"] = Corpus.B23.LibrarySteps.State, + ["22-reference-ambiguous-anchor"] = Corpus.B22.LibrarySteps.State, }; /// The bundle's initial-state factory, or a loud failure if none is wired. diff --git a/go/conformance/b22/library.steps.go b/go/conformance/b22/library.steps.go new file mode 100644 index 00000000..6922114b --- /dev/null +++ b/go/conformance/b22/library.steps.go @@ -0,0 +1,31 @@ +// Go sibling of library.steps.ts (bundle 22-reference-ambiguous-anchor). +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 b6500b54..a053b1d8 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" + b22 "github.com/varar-dev/varar/go/conformance/b22" b23 "github.com/varar-dev/varar/go/conformance/b23" ) @@ -69,6 +70,7 @@ var fixtures = map[string]fixture{ "20-reference-splice": {b20.Register, b20.State}, "21-reference-consumed": {b21.Register, b21.State}, "23-reference-only-example": {b23.Register, b23.State}, + "22-reference-ambiguous-anchor": {b22.Register, b22.State}, } func bundlesDir() string { return filepath.Join("..", "..", "conformance", "bundles") } diff --git a/go/core/ast.go b/go/core/ast.go index 144e9bbe..0626a358 100644 --- a/go/core/ast.go +++ b/go/core/ast.go @@ -100,10 +100,14 @@ type Example struct { } // Doc is a parsed source file: its matched examples plus unattached -// table/fence blocks (each of which is a Table or Fence). +// table/fence blocks (each of which is a Table or Fence), and every heading in +// source order โ€” the same Heading blocks the scanner produced, kept so the +// planner can tell whether a reference anchor names one section or several +// (ADR 0016). type Doc struct { Path string Source string Examples []Example OrphanAttachments []Block + Headings []Heading } diff --git a/go/core/conformance.go b/go/core/conformance.go index 427ac2a9..d97b6013 100644 --- a/go/core/conformance.go +++ b/go/core/conformance.go @@ -26,10 +26,15 @@ func ToDocArtifact(doc Doc) Value { for i, o := range doc.OrphanAttachments { orphans[i] = tableOrFenceValue(o) } + headings := make([]Value, len(doc.Headings)) + for i, h := range doc.Headings { + headings[i] = headingValue(h) + } return obj( kv("path", StrValue(doc.Path)), kv("examples", ListOf(examples)), kv("orphanAttachments", ListOf(orphans)), + kv("headings", ListOf(headings)), ) } @@ -497,6 +502,8 @@ func diagnosticCodeString(code DiagnosticCode) string { return "reference-empty" case CodeReferenceCycle: return "reference-cycle" + case CodeAmbiguousAnchor: + return "ambiguous-anchor" } return "" } diff --git a/go/core/diagnostics.go b/go/core/diagnostics.go index 5742b267..f34fe2e6 100644 --- a/go/core/diagnostics.go +++ b/go/core/diagnostics.go @@ -21,10 +21,12 @@ const ( 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. + // section with no steps, to a chain that reaches itself, or to an anchor + // that more than one heading in the target slugifies to. CodeReferenceNotFound CodeReferenceEmpty CodeReferenceCycle + CodeAmbiguousAnchor ) // Diagnostic is one diagnostic: its code, severity, and the source span it @@ -63,3 +65,11 @@ func referenceEmpty(text, path, slug string, span Span) Diagnostic { func referenceCycle(chain []string, span Span) Diagnostic { return Diagnostic{Code: CodeReferenceCycle, Severity: SeverityError, Span: span} } + +// ambiguousAnchor: the referenced document has more than one heading whose +// anchor is the slug, so the link could mean either section. Reported rather +// than guessed (GitHub would suffix the second `-1`; a reference never does). +// headingLines are the 1-based source lines of the colliding headings. +func ambiguousAnchor(text, path, slug string, headingLines []int, span Span) Diagnostic { + return Diagnostic{Code: CodeAmbiguousAnchor, Severity: SeverityError, Span: span} +} diff --git a/go/core/plan.go b/go/core/plan.go index 6a76eb06..2f20d0b7 100644 --- a/go/core/plan.go +++ b/go/core/plan.go @@ -289,6 +289,21 @@ func resolveReference( *diagnostics = append(*diagnostics, referenceNotFound(ref.Text, ref.Path, unit.span)) return nil } + // An anchor that more than one heading slugifies to could mean either + // section: an error, not a guess. A whole-file reference (empty slug) is + // never ambiguous. + if ref.Slug != "" { + var headingLines []int + for _, h := range target.Headings { + if Slugify(h.Text) == ref.Slug { + headingLines = append(headingLines, h.Span.StartLine) + } + } + if len(headingLines) > 1 { + *diagnostics = append(*diagnostics, ambiguousAnchor(ref.Text, ref.Path, ref.Slug, headingLines, unit.span)) + return nil + } + } var out []candidateUnit for _, candidate := range SectionCandidates(target, ref.Slug) { planned := planCandidate(candidate, target, registry, diagnostics) diff --git a/go/core/structurer.go b/go/core/structurer.go index 23878d8b..7801047a 100644 --- a/go/core/structurer.go +++ b/go/core/structurer.go @@ -17,6 +17,7 @@ type scopeEntry struct { func structure(path, source string, blocks []Block) Doc { var examples []Example orphanAttachments := []Block{} + headings := []Heading{} var scopeStack []scopeEntry lastExampleIdx := -1 attachmentOpen := false @@ -28,6 +29,7 @@ func structure(path, source string, blocks []Block) Doc { for _, block := range blocks { switch b := block.(type) { case Heading: + headings = append(headings, b) // Pop deeper-or-equal-level entries before pushing the new heading. for len(scopeStack) > 0 && scopeStack[len(scopeStack)-1].level >= b.Level { scopeStack = scopeStack[:len(scopeStack)-1] @@ -72,6 +74,7 @@ func structure(path, source string, blocks []Block) Doc { Source: source, Examples: examples, OrphanAttachments: orphanAttachments, + Headings: headings, } } diff --git a/java/core/src/main/java/dev/varar/core/Ast.java b/java/core/src/main/java/dev/varar/core/Ast.java index 330ebd57..87f73b4c 100644 --- a/java/core/src/main/java/dev/varar/core/Ast.java +++ b/java/core/src/main/java/dev/varar/core/Ast.java @@ -119,11 +119,21 @@ public record Example(List scopeStack, Span span, List body, bool } } - /** A parsed source file: its matched examples plus any table/fence blocks not attached to one. */ - public record Doc(String path, String source, List examples, List orphanAttachments) { + /** + * A parsed source file: its matched examples, any table/fence blocks not attached to one, and + * every heading in source order (the same {@link Heading} blocks the scanner produced), so a + * reference resolver can tell when two headings share an anchor (ADR 0016). + */ + public record Doc( + String path, + String source, + List examples, + List orphanAttachments, + List headings) { public Doc { examples = List.copyOf(examples); orphanAttachments = List.copyOf(orphanAttachments); + headings = List.copyOf(headings); } } } 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 7a8c5292..c08e1832 100644 --- a/java/core/src/main/java/dev/varar/core/Conformance.java +++ b/java/core/src/main/java/dev/varar/core/Conformance.java @@ -34,7 +34,7 @@ private Conformance() {} /** * Projects a parsed {@link Ast.Doc} to the var-doc wire artifact: {@code - * {path, examples, orphanAttachments}}. + * {path, examples, orphanAttachments, headings}}. */ public static Map toDocArtifact(Ast.Doc doc) { Map out = new LinkedHashMap<>(); @@ -43,6 +43,7 @@ public static Map toDocArtifact(Ast.Doc doc) { out.put( "orphanAttachments", doc.orphanAttachments().stream().map(Conformance::tableOrFence).toList()); + out.put("headings", doc.headings().stream().map(Conformance::heading).toList()); return out; } @@ -383,6 +384,7 @@ private static String diagnosticCode(Diagnostics.DiagnosticCode code) { case REFERENCE_NOT_FOUND -> "reference-not-found"; case REFERENCE_EMPTY -> "reference-empty"; case REFERENCE_CYCLE -> "reference-cycle"; + case AMBIGUOUS_ANCHOR -> "ambiguous-anchor"; 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 ba8f21f7..8c26a05c 100644 --- a/java/core/src/main/java/dev/varar/core/Diagnostics.java +++ b/java/core/src/main/java/dev/varar/core/Diagnostics.java @@ -42,7 +42,9 @@ public enum DiagnosticCode { */ REFERENCE_NOT_FOUND, REFERENCE_EMPTY, - REFERENCE_CYCLE + REFERENCE_CYCLE, + /** The anchor a reference names belongs to more than one heading in the target document. */ + AMBIGUOUS_ANCHOR } /** One diagnostic: its code, severity, and the source span it points at. */ @@ -88,4 +90,13 @@ public static Diagnostic referenceEmpty(Span span) { public static Diagnostic referenceCycle(Span span) { return new Diagnostic(DiagnosticCode.REFERENCE_CYCLE, Severity.ERROR, span); } + + /** + * The referenced document has two or more headings whose GFM slug is the anchor the link + * names (GitHub would suffix the later ones {@code -1}, {@code -2}; Varar does not guess). + * Reported instead of resolving either section, and instead of {@code reference-empty}. + */ + public static Diagnostic ambiguousAnchor(Span span) { + return new Diagnostic(DiagnosticCode.AMBIGUOUS_ANCHOR, 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 30377798..ee389db9 100644 --- a/java/core/src/main/java/dev/varar/core/Plan.java +++ b/java/core/src/main/java/dev/varar/core/Plan.java @@ -339,6 +339,17 @@ private static List resolveReference( diagnostics.add(Diagnostics.referenceNotFound(unit.span())); return List.of(); } + // A whole-file reference (empty slug) is never ambiguous; a section reference is when more + // than one heading in the target slugifies to the anchor it names. + if (!ref.slug().isEmpty()) { + long named = target.headings().stream() + .filter(h -> Reference.slugify(h.text()).equals(ref.slug())) + .count(); + if (named > 1) { + diagnostics.add(Diagnostics.ambiguousAnchor(unit.span())); + return List.of(); + } + } List deeper = new ArrayList<>(chain); deeper.add(key); List out = new ArrayList<>(); diff --git a/java/core/src/main/java/dev/varar/core/Structurer.java b/java/core/src/main/java/dev/varar/core/Structurer.java index 71e28857..55f43bb8 100644 --- a/java/core/src/main/java/dev/varar/core/Structurer.java +++ b/java/core/src/main/java/dev/varar/core/Structurer.java @@ -32,6 +32,7 @@ private record ScopeEntry(int level, String text) {} public static Ast.Doc structure(String path, String source, List blocks) { List examples = new ArrayList<>(); List orphanAttachments = new ArrayList<>(); + List headings = new ArrayList<>(); List scopeStack = new ArrayList<>(); int lastExampleIdx = -1; boolean attachmentOpen = false; @@ -42,6 +43,7 @@ public static Ast.Doc structure(String path, String source, List bloc for (Ast.Block block : blocks) { if (block instanceof Ast.Heading heading) { + headings.add(heading); // Pop deeper-or-equal-level entries before pushing the new heading. while (!scopeStack.isEmpty() && scopeStack.get(scopeStack.size() - 1).level() >= heading.level()) { @@ -76,7 +78,7 @@ public static Ast.Doc structure(String path, String source, List bloc } } - return new Ast.Doc(path, source, examples, orphanAttachments); + return new Ast.Doc(path, source, examples, orphanAttachments, headings); } private static List scopeTexts(List scopeStack) { diff --git a/java/core/src/test/java/dev/varar/core/AstTest.java b/java/core/src/test/java/dev/varar/core/AstTest.java index 596594ee..d1358bff 100644 --- a/java/core/src/test/java/dev/varar/core/AstTest.java +++ b/java/core/src/test/java/dev/varar/core/AstTest.java @@ -145,13 +145,17 @@ void docExposesFieldsAndDefensivelyCopiesExamplesAndOrphanAttachments() { Ast.Example example = new Ast.Example(List.of(), SPAN, List.of(new Ast.ThematicBreak(SPAN)), true); List examples = new ArrayList<>(List.of(example)); List orphanAttachments = new ArrayList<>(List.of(new Ast.Fence(SPAN, "", "", SPAN))); - Ast.Doc doc = new Ast.Doc("oath.md", "# Title", examples, orphanAttachments); + Ast.Heading heading = new Ast.Heading(1, "Title", SPAN); + List headings = new ArrayList<>(List.of(heading)); + Ast.Doc doc = new Ast.Doc("oath.md", "# Title", examples, orphanAttachments, headings); assertEquals("oath.md", doc.path()); assertEquals("# Title", doc.source()); assertEquals(1, doc.examples().size()); assertEquals(1, doc.orphanAttachments().size()); + assertEquals(List.of(heading), doc.headings()); assertThrows(UnsupportedOperationException.class, () -> doc.examples().add(example)); + assertThrows(UnsupportedOperationException.class, () -> doc.headings().add(heading)); assertThrows( UnsupportedOperationException.class, () -> doc.orphanAttachments().add(new Ast.Fence(SPAN, "", "", SPAN))); diff --git a/java/core/src/test/java/dev/varar/core/StructurerTest.java b/java/core/src/test/java/dev/varar/core/StructurerTest.java index e1e4943f..4e3f0d3f 100644 --- a/java/core/src/test/java/dev/varar/core/StructurerTest.java +++ b/java/core/src/test/java/dev/varar/core/StructurerTest.java @@ -25,6 +25,21 @@ void everyParagraphBecomesACandidateExampleScopedByTheHeadingsAboveIt() { assertEquals(List.of("Overdraft"), doc.examples().get(1).scopeStack()); } + @Test + void everyHeadingIsRecordedInSourceOrderWithItsScannedSpan() { + String source = "# Outer\n\n## Inner\n\nA step.\n\n## Inner\n"; + List blocks = Scanner.scan(source); + Doc doc = Structurer.structure("test.md", source, blocks); + assertEquals(3, doc.headings().size()); + assertEquals( + List.of(1, 2, 2), + doc.headings().stream().map(Ast.Heading::level).toList()); + assertEquals( + List.of("Outer", "Inner", "Inner"), + doc.headings().stream().map(Ast.Heading::text).toList()); + assertEquals(blocks.get(0), doc.headings().get(0)); + } + @Test void twoParagraphsUnderTheSameHeadingEachBecomeASeparateExample() { String source = "## Example\n\nFirst paragraph.\n\nSecond paragraph."; 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 5898e6dd..b4ced2a3 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.bundle22.steps as bundle22Steps import dev.varar.kotlin.conformance.bundle23.steps as bundle23Steps import java.nio.charset.StandardCharsets import java.nio.file.Files @@ -162,6 +163,7 @@ class ConformanceTest { "20-reference-splice" -> bundle20Steps "21-reference-consumed" -> bundle21Steps "23-reference-only-example" -> bundle23Steps + "22-reference-ambiguous-anchor" -> bundle22Steps 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 10a5f951..9f67cbc8 100644 --- a/java/varar/src/test/java/dev/varar/ConformanceTest.java +++ b/java/varar/src/test/java/dev/varar/ConformanceTest.java @@ -138,6 +138,7 @@ private static StepDefinitions loadFixture(String bundleName) { 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(); + case "22-reference-ambiguous-anchor" -> new dev.varar.conformance.bundle22.LibrarySteps(); default -> throw new IllegalStateException("No Java step fixture registered for bundle " + bundleName); }; } diff --git a/python/packages/core/src/varar_core/ast.py b/python/packages/core/src/varar_core/ast.py index 0a617a51..52bea7ac 100644 --- a/python/packages/core/src/varar_core/ast.py +++ b/python/packages/core/src/varar_core/ast.py @@ -144,3 +144,7 @@ class Doc: source: str = "" examples: tuple[Example, ...] = () orphan_attachments: tuple[Union[Table, Fence], ...] = () + # Every heading in the document, in source order โ€” the same blocks the + # scanner produced. The planner reads them to tell whether a reference's + # anchor names exactly one heading (ADR 0016). + headings: tuple[Heading, ...] = () diff --git a/python/packages/core/src/varar_core/conformance.py b/python/packages/core/src/varar_core/conformance.py index edb11680..2dffcb7c 100644 --- a/python/packages/core/src/varar_core/conformance.py +++ b/python/packages/core/src/varar_core/conformance.py @@ -183,6 +183,7 @@ def to_doc_artifact(doc: Doc) -> dict[str, Any]: "path": doc.path, "examples": [_example(ex) for ex in doc.examples], "orphanAttachments": [_block(b) for b in doc.orphan_attachments], + "headings": [_block(h) for h in doc.headings], } diff --git a/python/packages/core/src/varar_core/diagnostics.py b/python/packages/core/src/varar_core/diagnostics.py index a5f86806..90981250 100644 --- a/python/packages/core/src/varar_core/diagnostics.py +++ b/python/packages/core/src/varar_core/diagnostics.py @@ -19,6 +19,7 @@ "reference-not-found", "reference-empty", "reference-cycle", + "ambiguous-anchor", ] @@ -121,6 +122,25 @@ def reference_empty(text: str, path: str, slug: str, span: Span) -> Diagnostic: ) +def ambiguous_anchor( + text: str, path: str, slug: str, heading_lines: tuple[int, ...], span: Span +) -> Diagnostic: + """Two headings in the referenced document slug to the same anchor, so the + reference could mean either section. GitHub would suffix the second one + (`#slug-1`); Varar refuses to guess and asks for distinct headings.""" + lines = ", ".join(str(line) for line in heading_lines) + return Diagnostic( + severity="error", + code="ambiguous-anchor", + message=( + f'Reference to "{text}" is ambiguous: "{path}" has {len(heading_lines)} headings ' + f'with the anchor "#{slug}" (lines {lines}).\n' + "Rename the headings so each has an anchor of its own." + ), + 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 diff --git a/python/packages/core/src/varar_core/plan.py b/python/packages/core/src/varar_core/plan.py index 4ffe2895..1518ecb8 100644 --- a/python/packages/core/src/varar_core/plan.py +++ b/python/packages/core/src/varar_core/plan.py @@ -17,6 +17,7 @@ AmbiguousInput, Candidate, Diagnostic, + ambiguous_anchor, ambiguous_match, error_fence_without_step, reference_cycle, @@ -517,6 +518,21 @@ def _resolve_reference( if target is None: diagnostics.append(reference_not_found(ref.text, ref.path, unit.span)) return () + # An anchor that names two headings could mean either section: refuse to + # guess. A whole-file reference has no anchor, so it is never ambiguous. + if ref.slug != "": + named = tuple(h for h in target.headings if slugify(h.text) == ref.slug) + if len(named) > 1: + diagnostics.append( + ambiguous_anchor( + ref.text, + ref.path, + ref.slug, + tuple(h.span.start_line for h in named), # type: ignore[union-attr] + unit.span, + ) + ) + return () out: list[_StepsUnit] = [] for candidate in section_candidates(target, ref.slug): planned = _plan_candidate(candidate, target, registry, diagnostics) diff --git a/python/packages/core/src/varar_core/structurer.py b/python/packages/core/src/varar_core/structurer.py index b3872499..a2cebc8b 100644 --- a/python/packages/core/src/varar_core/structurer.py +++ b/python/packages/core/src/varar_core/structurer.py @@ -14,6 +14,7 @@ Block, Example, Fence, + Heading, Table, Doc, ) @@ -24,6 +25,7 @@ def structure(path: str, source: str, blocks: tuple[Block, ...]) -> Doc: """Group *blocks* into Examples, scoped by headings, with orphan attachments.""" examples: list[Example] = [] orphan_attachments: list[Table | Fence] = [] + headings: list[Heading] = [] scope_stack: list[tuple[int, str]] = [] # (level, text) last_example_idx = -1 attachment_open = False @@ -36,6 +38,7 @@ def structure(path: str, source: str, blocks: tuple[Block, ...]) -> Doc: kind = block.kind if kind == "heading": + headings.append(block) # type: ignore[arg-type] # Pop deeper-or-equal-level entries before pushing the new heading. while scope_stack and scope_stack[-1][0] >= block.level: # type: ignore[union-attr] scope_stack.pop() @@ -82,4 +85,5 @@ def structure(path: str, source: str, blocks: tuple[Block, ...]) -> Doc: source=source, examples=tuple(examples), orphan_attachments=tuple(orphan_attachments), + headings=tuple(headings), ) diff --git a/python/packages/core/tests/test_conformance.py b/python/packages/core/tests/test_conformance.py index 0ace7a57..31326c4f 100644 --- a/python/packages/core/tests/test_conformance.py +++ b/python/packages/core/tests/test_conformance.py @@ -269,3 +269,21 @@ def _divide(_ctx, a, b): assert ex["outcome"] == "pass" assert ex["steps"][0]["outcome"] == "fail" assert ex["steps"][0]["failure"]["kind"] == "thrown" + + +def test_to_doc_artifact_projects_headings() -> None: + art = to_doc_artifact(parse("h.md", "# Top\n\nprose\n\n## Sub\n")) + assert [h["text"] for h in art["headings"]] == ["Top", "Sub"] + assert art["headings"][1] == { + "kind": "heading", + "level": 2, + "text": "Sub", + "span": { + "startOffset": 14, + "endOffset": 20, + "startLine": 5, + "startCol": 1, + "endLine": 5, + "endCol": 7, + }, + } diff --git a/python/packages/core/tests/test_plan.py b/python/packages/core/tests/test_plan.py index 4ff449eb..e7e5bf26 100644 --- a/python/packages/core/tests/test_plan.py +++ b/python/packages/core/tests/test_plan.py @@ -4,7 +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 +from varar_core.reference import build_workspace, empty_workspace def _noop(*_args: object, **_kwargs: object) -> None: @@ -449,3 +449,55 @@ def test_a_reference_mid_example_extends_the_example_to_the_reference_block_not_ assert main[ex.span.start_offset : ex.span.end_offset] == ( "I withdraw 40.\n\n[Fees are enabled](./shared.md#fees-are-enabled)" ) + + +def _library_registry(): + r = create_registry() + for line, expression in enumerate(("I shelve {int} books", "I borrow a book"), start=1): + r = add_step( + r, + expression=expression, + expression_source_file="library.steps.py", + expression_source_line=line, + kind="stimulus", + handler=lambda *_: None, + ) + return r + + +def test_reference_whose_anchor_names_two_headings_is_ambiguous() -> None: + shared = parse( + "shared.md", + "# Shared\n\n## A stocked library\n\nI shelve 3 books.\n\n## A stocked library\n\nI shelve 5 books.\n", + ) + host = parse( + "example.md", + "# Late fees\n\n[A stocked library](./shared.md#a-stocked-library)\n\nI borrow a book.\n", + ) + result = plan(host, _library_registry(), build_workspace((shared, host))) + assert [d.code for d in result.diagnostics] == ["ambiguous-anchor"] + d = result.diagnostics[0] + assert d.severity == "error" + assert d.span == host.examples[0].span + assert d.message == ( + 'Reference to "A stocked library" is ambiguous: "shared.md" has 2 headings ' + 'with the anchor "#a-stocked-library" (lines 3, 7).\n' + "Rename the headings so each has an anchor of its own." + ) + # The reference contributes nothing; the host's own step still plans. + assert [ex.name for ex in result.examples] == ["I borrow a book"] + + +def test_whole_file_reference_is_never_ambiguous() -> None: + shared = parse( + "shared.md", + "## A stocked library\n\nI shelve 3 books.\n\n## A stocked library\n\nI shelve 5 books.\n", + ) + host = parse("example.md", "# Late fees\n\n[Setup](./shared.md)\n\nI borrow a book.\n") + result = plan(host, _library_registry(), build_workspace((shared, host))) + assert result.diagnostics == () + assert [s.text for s in result.examples[0].steps] == [ + "I shelve 3 books", + "I shelve 5 books", + "I borrow a book", + ] diff --git a/python/packages/core/tests/test_structurer.py b/python/packages/core/tests/test_structurer.py index a85776ca..dc7bb59c 100644 --- a/python/packages/core/tests/test_structurer.py +++ b/python/packages/core/tests/test_structurer.py @@ -98,3 +98,15 @@ def test_preceded_by_delimiter_marks_candidates_after_heading_or_thematic_break( True, # after `---` True, # after a heading ] + + +def test_records_every_heading_in_source_order() -> None: + source = "# Outer\n\nbody one\n\n## Inner\n\nbody two\n\n## Inner\n" + doc = structure("test.md", source, scan(source)) + assert [(h.level, h.text, h.span.start_line) for h in doc.headings] == [ + (1, "Outer", 1), + (2, "Inner", 5), + (2, "Inner", 9), + ] + # The same block objects the scanner produced โ€” not copies. + assert all(h.kind == "heading" for h in doc.headings) diff --git a/ruby/packages/core/lib/varar/core/ast.rb b/ruby/packages/core/lib/varar/core/ast.rb index 6d407c4e..b10acfa4 100644 --- a/ruby/packages/core/lib/varar/core/ast.rb +++ b/ruby/packages/core/lib/varar/core/ast.rb @@ -46,6 +46,9 @@ def kind = 'thematic_break' # one example. See ADR 0012. Example = Data.define(:scope_stack, :span, :body, :preceded_by_delimiter) - Doc = Data.define(:path, :source, :examples, :orphan_attachments) + # +headings+ is every heading in the document in source order โ€” the same + # Heading values the scanner produced. The planner uses it to tell whether + # a reference anchor names one section or several (ADR 0016). + Doc = Data.define(:path, :source, :examples, :orphan_attachments, :headings) end end diff --git a/ruby/packages/core/lib/varar/core/conformance.rb b/ruby/packages/core/lib/varar/core/conformance.rb index 2b7bb946..2fda23cb 100644 --- a/ruby/packages/core/lib/varar/core/conformance.rb +++ b/ruby/packages/core/lib/varar/core/conformance.rb @@ -104,7 +104,8 @@ def to_doc_artifact(doc) { 'path' => doc.path, 'examples' => doc.examples.map { |ex| example_hash(ex) }, - 'orphanAttachments' => doc.orphan_attachments.map { |b| block_hash(b) } + 'orphanAttachments' => doc.orphan_attachments.map { |b| block_hash(b) }, + 'headings' => doc.headings.map { |h| block_hash(h) } } end diff --git a/ruby/packages/core/lib/varar/core/diagnostics.rb b/ruby/packages/core/lib/varar/core/diagnostics.rb index a45c795f..db3e667d 100644 --- a/ruby/packages/core/lib/varar/core/diagnostics.rb +++ b/ruby/packages/core/lib/varar/core/diagnostics.rb @@ -3,8 +3,9 @@ module Varar module Core # A planning/run diagnostic on the shared rail. code is one of - # "ambiguous-match", "error-fence-without-step", "drift". Port of - # diagnostics.ts. + # "ambiguous-match", "error-fence-without-step", "drift", + # "reference-not-found", "reference-empty", "reference-cycle", + # "ambiguous-anchor". Port of diagnostics.ts. Diagnostic = Data.define(:code, :severity, :message, :span) Candidate = Data.define(:expression, :source_file, :source_line) AmbiguousInput = Data.define(:text, :span, :candidates) @@ -72,6 +73,20 @@ def reference_empty(text, path, slug, span) ) end + # The anchor names more than one heading in the referenced document: + # GitHub would disambiguate with a numeric suffix, but a reference that + # could mean either section is an error, not a guess (ADR 0016). + def ambiguous_anchor(text, path, slug, heading_lines, span) + Diagnostic.new( + severity: 'error', + code: 'ambiguous-anchor', + message: "Reference to \"#{text}\" is ambiguous: \"#{path}\" has #{heading_lines.length} headings " \ + "with the anchor \"##{slug}\" (lines #{heading_lines.join(', ')}).\n" \ + 'Rename the headings so each has an anchor of its own.', + 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. diff --git a/ruby/packages/core/lib/varar/core/plan.rb b/ruby/packages/core/lib/varar/core/plan.rb index bd0c638a..6f64ea4c 100644 --- a/ruby/packages/core/lib/varar/core/plan.rb +++ b/ruby/packages/core/lib/varar/core/plan.rb @@ -161,6 +161,17 @@ def resolve_reference(unit, from_doc, registry, workspace, diagnostics, chain) diagnostics << Diagnostics.reference_not_found(ref.text, ref.path, unit.span) return [] end + # An anchor that names more than one heading could mean either section: + # report it rather than splicing both. A whole-file reference ('' slug) + # names no heading, so it is never ambiguous. + unless ref.slug.empty? + named = target.headings.select { |h| Reference.slugify(h.text) == ref.slug } + if named.length > 1 + diagnostics << Diagnostics.ambiguous_anchor(ref.text, ref.path, ref.slug, + named.map { |h| h.span.start_line }, unit.span) + return [] + end + end out = [] Reference.section_candidates(target, ref.slug).each do |candidate| planned = plan_candidate(candidate, target, registry, diagnostics) diff --git a/ruby/packages/core/lib/varar/core/structurer.rb b/ruby/packages/core/lib/varar/core/structurer.rb index e02f3c4f..ad461e2f 100644 --- a/ruby/packages/core/lib/varar/core/structurer.rb +++ b/ruby/packages/core/lib/varar/core/structurer.rb @@ -17,6 +17,7 @@ module Structurer def structure(path, source, blocks) examples = [] orphan_attachments = [] + headings = [] scope_stack = [] # [[level, text], ...] last_example_idx = -1 attachment_open = false @@ -32,6 +33,7 @@ def structure(path, source, blocks) # Pop deeper-or-equal-level entries before pushing the new heading. scope_stack.pop while !scope_stack.empty? && scope_stack.last[0] >= block.level scope_stack << [block.level, block.text] + headings << block attachment_open = false delimiter_pending = true @@ -70,7 +72,8 @@ def structure(path, source, blocks) path: path, source: source, examples: examples, - orphan_attachments: orphan_attachments + orphan_attachments: orphan_attachments, + headings: headings ) 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 ad5669e8..439417d9 100644 --- a/ruby/packages/core/spec/varar/core/plan_spec.rb +++ b/ruby/packages/core/spec/varar/core/plan_spec.rb @@ -114,6 +114,22 @@ def step_texts(example) expect(result.diagnostics[0].code).to eq('ambiguous-match') expect(result.examples).to be_empty end + + # A reference whose anchor names two headings in the target could mean + # either section: one ambiguous-anchor diagnostic, no splice, and no + # reference-empty on top of it (ADR 0016). + it 'reports a reference whose anchor names two headings as ambiguous-anchor' do + shared = Parse.parse('shared.md', + "## Funded\n\nI have 100 in my account.\n\n## Funded\n\nI have 5 in my account.") + main = Parse.parse('m.md', "[Funded](./shared.md#funded)\n\nI withdraw 40.") + result = described_class.plan(main, account_reg, Reference.build_workspace([shared, main])) + expect(result.diagnostics.map(&:code)).to eq(['ambiguous-anchor']) + expect(result.diagnostics[0].message).to eq( + %(Reference to "Funded" is ambiguous: "shared.md" has 2 headings with the anchor "#funded" ) + + "(lines 1, 5).\nRename the headings so each has an anchor of its own." + ) + expect(result.examples.map { |ex| step_texts(ex) }).to eq([['I withdraw 40']]) + end end end end diff --git a/rust/core/src/ast.rs b/rust/core/src/ast.rs index 976a8b05..82b78421 100644 --- a/rust/core/src/ast.rs +++ b/rust/core/src/ast.rs @@ -143,4 +143,8 @@ pub struct Doc { pub source: String, pub examples: Vec, pub orphan_attachments: Vec, + /// Every heading in the document, in source order โ€” the same nodes the + /// scanner produced. The planner reads them to tell whether a reference + /// anchor names exactly one section (ADR 0016). + pub headings: Vec, } diff --git a/rust/core/src/conformance.rs b/rust/core/src/conformance.rs index c5e48936..7fc7364d 100644 --- a/rust/core/src/conformance.rs +++ b/rust/core/src/conformance.rs @@ -54,6 +54,7 @@ pub fn to_doc_artifact(doc: &Doc) -> Value { "orphanAttachments", Value::List(doc.orphan_attachments.iter().map(table_or_fence).collect()), ), + ("headings", Value::List(doc.headings.iter().map(heading).collect())), ]) } @@ -334,6 +335,7 @@ fn diagnostic_code(code: DiagnosticCode) -> &'static str { DiagnosticCode::ReferenceNotFound => "reference-not-found", DiagnosticCode::ReferenceEmpty => "reference-empty", DiagnosticCode::ReferenceCycle => "reference-cycle", + DiagnosticCode::AmbiguousAnchor => "ambiguous-anchor", } } diff --git a/rust/core/src/diagnostics.rs b/rust/core/src/diagnostics.rs index 2964bb3e..3aaf230a 100644 --- a/rust/core/src/diagnostics.rs +++ b/rust/core/src/diagnostics.rs @@ -19,10 +19,12 @@ pub enum DiagnosticCode { 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. + /// section with no steps, to a chain that reaches itself, or to an anchor + /// that names more than one heading. ReferenceNotFound, ReferenceEmpty, ReferenceCycle, + AmbiguousAnchor, } /// One diagnostic: its code, severity, and the source span it points at. @@ -81,3 +83,14 @@ pub fn reference_cycle(span: Span) -> Diagnostic { span, } } + +/// The anchor names more than one heading in the target document (two headings +/// slugify identically), so the reference could mean either section. Reported +/// rather than guessed: rename the headings so each has an anchor of its own. +pub fn ambiguous_anchor(span: Span) -> Diagnostic { + Diagnostic { + code: DiagnosticCode::AmbiguousAnchor, + severity: Severity::Error, + span, + } +} diff --git a/rust/core/src/plan.rs b/rust/core/src/plan.rs index 7add049c..7fc29ffa 100644 --- a/rust/core/src/plan.rs +++ b/rust/core/src/plan.rs @@ -6,8 +6,8 @@ use crate::ast::{Block, Doc, Fence, Row, SegmentOffset, Table}; use crate::cell_diff::RowCheck; use crate::diagnostics::{ - Diagnostic, ambiguous_match, error_fence_without_step, reference_cycle, reference_empty, - reference_not_found, + Diagnostic, ambiguous_anchor, 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}; @@ -303,6 +303,20 @@ fn resolve_reference( diagnostics.push(reference_not_found(unit.span)); return Vec::new(); }; + // Two headings that slugify identically make the anchor name two sections; + // that is reported, not guessed, and it is not `reference-empty`. A + // whole-file reference (empty slug) names the document, never a heading. + if !unit.reference.slug.is_empty() { + let named = target + .headings + .iter() + .filter(|h| slugify(&h.text) == unit.reference.slug) + .count(); + if named > 1 { + diagnostics.push(ambiguous_anchor(unit.span)); + return Vec::new(); + } + } let mut out: Vec = Vec::new(); let mut deeper: Vec = chain.to_vec(); deeper.push(key); diff --git a/rust/core/src/structurer.rs b/rust/core/src/structurer.rs index 92f6d4e1..260c161f 100644 --- a/rust/core/src/structurer.rs +++ b/rust/core/src/structurer.rs @@ -1,7 +1,7 @@ //! Groups the flat scanner output into [`Example`]s, tracking a heading scope //! stack โ€” port of `structurer.ts` / `Structurer.java`. -use crate::ast::{Block, Doc, Example, TableOrFence}; +use crate::ast::{Block, Doc, Example, Heading, TableOrFence}; use crate::span::Span; /// Groups `blocks` (scanned from `source`) into a [`Doc`]. @@ -14,6 +14,7 @@ use crate::span::Span; pub fn structure(path: &str, source: &str, blocks: Vec) -> Doc { let mut examples: Vec = Vec::new(); let mut orphan_attachments: Vec = Vec::new(); + let mut headings: Vec = Vec::new(); let mut scope_stack: Vec<(usize, String)> = Vec::new(); let mut last_example_idx: Option = None; let mut attachment_open = false; @@ -30,6 +31,7 @@ pub fn structure(path: &str, source: &str, blocks: Vec) -> Doc { scope_stack.pop(); } scope_stack.push((heading.level, heading.text.clone())); + headings.push(heading.clone()); attachment_open = false; delimiter_pending = true; } @@ -79,6 +81,7 @@ pub fn structure(path: &str, source: &str, blocks: Vec) -> Doc { source: source.to_string(), examples, orphan_attachments, + headings, } } diff --git a/rust/core/tests/ast_test.rs b/rust/core/tests/ast_test.rs index 4ec29987..bdd7a8d5 100644 --- a/rust/core/tests/ast_test.rs +++ b/rust/core/tests/ast_test.rs @@ -186,9 +186,11 @@ fn doc_exposes_fields() { source: "# Title".to_string(), examples: vec![example], orphan_attachments: vec![orphan], + headings: vec![], }; assert_eq!("oath.md", doc.path); assert_eq!("# Title", doc.source); assert_eq!(1, doc.examples.len()); assert_eq!(1, doc.orphan_attachments.len()); + assert!(doc.headings.is_empty()); } diff --git a/rust/varar/tests/conformance.rs b/rust/varar/tests/conformance.rs index 546b9c58..710c54a5 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/22-reference-ambiguous-anchor/library.steps.rs"] +mod b22; #[path = "../../../conformance/bundles/23-reference-only-example/library.steps.rs"] mod b23; @@ -110,6 +112,7 @@ fn fixture(bundle: &str) -> (Registry, ContextFactory) { "20-reference-splice" => bundle!(b20), "21-reference-consumed" => bundle!(b21), "23-reference-only-example" => bundle!(b23), + "22-reference-ambiguous-anchor" => bundle!(b22), other => panic!("no Rust step fixture for bundle {other}"), } } diff --git a/typescript/packages/cli/src/lint.ts b/typescript/packages/cli/src/lint.ts index 5af2d7d1..bf2c98e3 100644 --- a/typescript/packages/cli/src/lint.ts +++ b/typescript/packages/cli/src/lint.ts @@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs' import { relative } from 'node:path' import { fileURLToPath } from 'node:url' import { findFiles, loadConfig, toOathPath } from '@varar/config' -import { buildWorkspace, parse, type StepRegistration } from '@varar/core' -import { loadSteps, planOath } from '@varar/runner' +import { buildWorkspace, parse, plan, type StepRegistration } from '@varar/core' +import { loadSteps } from '@varar/runner' export type LintOptions = { readonly cwd: string @@ -51,13 +51,12 @@ export async function runLint(opts: LintOptions): Promise { 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) ?? '')), + const docs = new Map( + files.map((path) => [path, parse(toOathPath(opts.cwd, path), readFileSync(path, 'utf8'))]), ) - for (const path of files) { - const source = sources.get(path) ?? '' - const execution = planOath(toOathPath(opts.cwd, path), source, registry, workspace) + const workspace = buildWorkspace([...docs.values()]) + for (const [path, doc] of docs) { + const execution = plan(doc, registry, workspace) for (const d of execution.diagnostics) { items.push({ path: rel(opts.cwd, path), diff --git a/typescript/packages/cli/tests/lint.test.ts b/typescript/packages/cli/tests/lint.test.ts index b4702e85..7fa1ac66 100644 --- a/typescript/packages/cli/tests/lint.test.ts +++ b/typescript/packages/cli/tests/lint.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -94,3 +94,39 @@ describe('varar lint (against loaded step definitions)', () => { expect(r.status).toBe(0) }) }) + +test('a reference whose anchor names two headings in its file is an ambiguous-anchor error', async () => { + // ADR 0016's "ambiguous anchor" is lint's to report: a run splices both + // sections in and stays green. No step files needed โ€” the check reads the + // outline, not the registry. + const dir = mkdtempSync(join(tmpdir(), 'varar-lint-anchor-')) + try { + writeFileSync( + join(dir, 'varar.config.json'), + '{ "docs": { "include": ["varar/**/*.md"], "exclude": [] }, "steps": [] }\n', + ) + mkdirSync(join(dir, 'varar', 'shared'), { recursive: true }) + writeFileSync( + join(dir, 'varar', 'shared', 'library.md'), + '# Shared\n\n## Setup\n\nThe library holds "Dune".\n\n## Setup\n\nThe library holds "Emma".\n', + ) + writeFileSync( + join(dir, 'varar', 'fees.md'), + '# Fees\n\n[Setup](./shared/library.md#setup)\n\nMaya borrows "Emma".\n', + ) + const captured: string[] = [] + const result = await runLint({ + cwd: dir, + json: false, + globs: undefined, + writeStdout: (s) => captured.push(s), + writeStderr: () => {}, + }) + expect(captured.join('')).toMatch( + /^varar\/fees\.md:3:1 {2}error {2}ambiguous-anchor {2}Reference to "Setup" is ambiguous: "varar\/shared\/library\.md" has 2 headings with the anchor "#setup" \(lines 3, 7\)/m, + ) + expect(result.exitCode).toBe(1) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/typescript/packages/core/src/ast.ts b/typescript/packages/core/src/ast.ts index 03c296d8..cba532a6 100644 --- a/typescript/packages/core/src/ast.ts +++ b/typescript/packages/core/src/ast.ts @@ -91,4 +91,9 @@ export type Doc = { readonly source: string readonly examples: ReadonlyArray readonly orphanAttachments: ReadonlyArray + // Every heading in the document, in order: the outline a reference block's + // anchor is resolved against (ADR 0016). Candidates still carry the chain + // above them in `scopeStack`; this is the list itself, so the planner can + // tell that two headings slug identically โ€” which the chain cannot. + readonly headings: ReadonlyArray } diff --git a/typescript/packages/core/src/conformance.ts b/typescript/packages/core/src/conformance.ts index 87322a20..0a104549 100644 --- a/typescript/packages/core/src/conformance.ts +++ b/typescript/packages/core/src/conformance.ts @@ -1,5 +1,5 @@ import { type CucumberExpression, type Node, NodeType } from '@cucumber/cucumber-expressions' -import type { Doc, Fence, Table } from './ast.ts' +import type { Doc, Fence, Heading, Table } from './ast.ts' import { isCellMismatchError, ReturnShapeError } from './cell-diff.ts' import type { DiagnosticCode, Severity } from './diagnostics.ts' import { collectExamples, isUnexpectedPassError, type StepObservation } from './execute.ts' @@ -15,6 +15,9 @@ export type DocArtifact = { readonly path: string readonly examples: Doc['examples'] readonly orphanAttachments: ReadonlyArray
+ // The outline (ADR 0016). Pinned so a port whose structurer drops or + // misreads a heading goes red before its reference resolution does. + readonly headings: ReadonlyArray } export type RegistryArtifact = { @@ -159,7 +162,12 @@ function parameterTypeNames(compiled: CucumberExpression): ReadonlyArray } export function toDocArtifact(doc: Doc): DocArtifact { - return { path: doc.path, examples: doc.examples, orphanAttachments: doc.orphanAttachments } + return { + path: doc.path, + examples: doc.examples, + orphanAttachments: doc.orphanAttachments, + headings: doc.headings, + } } export function toRegistryArtifact( diff --git a/typescript/packages/core/src/diagnostics.ts b/typescript/packages/core/src/diagnostics.ts index 9476e743..331f0fab 100644 --- a/typescript/packages/core/src/diagnostics.ts +++ b/typescript/packages/core/src/diagnostics.ts @@ -16,6 +16,7 @@ export type DiagnosticCode = | 'reference-not-found' | 'reference-empty' | 'reference-cycle' + | 'ambiguous-anchor' export type Candidate = { readonly expression: string @@ -121,3 +122,27 @@ export function referenceCycle(input: { span: input.span, } } + +// The anchor a reference names belongs to more than one heading in the target +// file. GitHub would give the second a numeric suffix (`#setup-1`); sections +// here resolve through the scope stack, which cannot tell the two apart, so +// the reference is refused rather than splicing both in. Fails the run, like +// the other reference errors. +export function ambiguousAnchor(input: { + readonly text: string + readonly path: string + readonly slug: string + readonly headingLines: ReadonlyArray + readonly span: Span +}): Diagnostic { + return { + severity: 'error', + code: 'ambiguous-anchor', + message: + `Reference to "${input.text}" is ambiguous: "${input.path}" has ` + + `${input.headingLines.length} headings with the anchor "#${input.slug}" ` + + `(lines ${input.headingLines.join(', ')}).\n` + + 'Rename the headings so each has an anchor of its own.', + span: input.span, + } +} diff --git a/typescript/packages/core/src/index.ts b/typescript/packages/core/src/index.ts index 313752b6..8687f1a9 100644 --- a/typescript/packages/core/src/index.ts +++ b/typescript/packages/core/src/index.ts @@ -47,7 +47,7 @@ export type { DiagnosticCode, Severity, } from './diagnostics.ts' -export { ambiguousMatch, driftDetected } from './diagnostics.ts' +export { ambiguousAnchor, ambiguousMatch, driftDetected } from './diagnostics.ts' export { compareDocString, DOC_STRING_COLUMN } from './doc-string-diff.ts' export type { BaselineExample, Drift, LockFile, OathBaseline } from './drift.ts' export { diff --git a/typescript/packages/core/src/plan.ts b/typescript/packages/core/src/plan.ts index 4358765a..3d7efc56 100644 --- a/typescript/packages/core/src/plan.ts +++ b/typescript/packages/core/src/plan.ts @@ -1,6 +1,7 @@ import type { Block, Doc, Fence, SegmentOffset, Table } from './ast.ts' import type { RowCheck } from './cell-diff.ts' import { + ambiguousAnchor, ambiguousMatch, type Diagnostic, errorFenceWithoutStep, @@ -213,6 +214,26 @@ function resolveReference( ) return [] } + // The anchor must name exactly one heading. Two headings that slug + // identically (`## Setup` twice โ€” GitHub's `#setup` and `#setup-1`) are + // indistinguishable to the scope-stack rule below, which would splice both + // sections in; that is an error, not a guess. A whole-file reference names + // no heading, so it cannot be ambiguous. + if (reference.slug !== '') { + const named = target.headings.filter((h) => slugOf(h.text) === reference.slug) + if (named.length > 1) { + diagnostics.push( + ambiguousAnchor({ + text: reference.text, + path: reference.path, + slug: reference.slug, + headingLines: named.map((h) => h.span.startLine), + span: unit.span, + }), + ) + return [] + } + } const out: Array> = [] for (const candidate of sectionCandidates(target, reference.slug)) { const planned = planCandidate(candidate, target, registry, diagnostics) diff --git a/typescript/packages/core/src/structurer.ts b/typescript/packages/core/src/structurer.ts index ea909fb9..3aac3e70 100644 --- a/typescript/packages/core/src/structurer.ts +++ b/typescript/packages/core/src/structurer.ts @@ -1,4 +1,4 @@ -import type { Block, Doc, Example, Fence, Table } from './ast.ts' +import type { Block, Doc, Example, Fence, Heading, Table } from './ast.ts' import { spanFromOffsets } from './span.ts' // Every paragraph / list item / blockquote becomes a candidate example. The @@ -18,6 +18,7 @@ import { spanFromOffsets } from './span.ts' export function structure(path: string, source: string, blocks: ReadonlyArray): Doc { const examples: Example[] = [] const orphanAttachments: (Table | Fence)[] = [] + const headings: Heading[] = [] const scopeStack: { level: number; text: string }[] = [] let lastExampleIdx = -1 let attachmentOpen = false @@ -29,6 +30,7 @@ export function structure(path: string, source: string, blocks: ReadonlyArray 0 && @@ -76,5 +78,5 @@ export function structure(path: string, source: string, blocks: ReadonlyArray { +test('toDocArtifact keeps path, examples, orphanAttachments and headings', () => { const art = toDocArtifact(parse('e.md', '# A\n\nI have 5 cukes.')) expect(art.path).toBe('e.md') expect(Array.isArray(art.examples)).toBe(true) + expect(art.headings.map((h) => h.text)).toEqual(['A']) }) test('toPlanArtifact projects diagnostics to portable fields (no message/path)', () => { diff --git a/typescript/packages/core/tests/reference.test.ts b/typescript/packages/core/tests/reference.test.ts index 3627fc0a..b2776b21 100644 --- a/typescript/packages/core/tests/reference.test.ts +++ b/typescript/packages/core/tests/reference.test.ts @@ -316,3 +316,87 @@ test('a link that climbs above the workspace root keeps its leading ../', () => const deeper = parse('../outside/a.md', '[Up](../b.md)\n') expect(references(deeper)[0]?.path).toBe('../b.md') }) + +// ADR 0016's "ambiguous anchor": the anchor a reference names belongs to two +// headings in the target file (`## Setup` twice). Sections resolve through the +// scope stack, which cannot tell them apart, so the reference is an error. +const TWICE = `# Shared + +## Setup + +The library holds "Dune". + +## Other + +Fees are enabled. + +## Setup + +The library holds "Emma". +` + +test('a reference whose anchor names two headings is ambiguous-anchor, and contributes nothing', () => { + const { main } = planWith( + `# Fees + +[Setup](./shared.md#setup) + +Maya borrows "Emma". +`, + TWICE, + ) + expect(main.diagnostics.map((d) => d.code)).toEqual(['ambiguous-anchor']) + const d = main.diagnostics[0]! + expect(d.severity).toBe('error') + expect(d.message).toContain('"varar/shared.md" has 2 headings with the anchor "#setup"') + expect(d.message).toContain('lines 3, 11') + // Anchored on the reference block, like every other reference diagnostic. + expect(d.span.startLine).toBe(3) + // Neither section is spliced in; the example is its own paragraph alone. + expect(main.examples.map((ex) => ex.steps.map((s) => s.text))).toEqual([['Maya borrows "Emma"']]) +}) + +test('an anchor that names exactly one heading is not ambiguous', () => { + const { main } = planWith('# Fees\n\n[Other](./shared.md#other)\n', TWICE) + expect(main.diagnostics).toEqual([]) + expect(main.examples[0]?.steps.map((s) => s.text)).toEqual(['Fees are enabled']) +}) + +test('a whole-file reference names no heading and is never ambiguous', () => { + const { main } = planWith('# Fees\n\n[Shared](./shared.md)\n', TWICE) + expect(main.diagnostics).toEqual([]) +}) + +test('a same-file ambiguous anchor is checked against the document itself, workspace or not', () => { + const doc = parse( + 'varar/solo.md', + `# Solo + +## Setup + +The library holds "Dune". + +## Setup + +The library holds "Emma". + +## Late fees + +[Setup](#setup) + +Maya borrows "Emma". +`, + ) + const result = plan(doc, reg(), emptyWorkspace()) + expect(result.diagnostics.map((d) => d.code)).toEqual(['ambiguous-anchor']) + expect(result.diagnostics[0]?.span.startLine).toBe(13) +}) + +test('headings that differ only in inline markup or case still share an anchor', () => { + // GitHub slugs `## *Setup*` and `## setup` both to #setup. + const { main } = planWith( + '# Fees\n\n[Setup](./shared.md#setup)\n', + '# Shared\n\n## *Setup*\n\nFees are enabled.\n\n## setup\n\nFees are enabled.\n', + ) + expect(main.diagnostics.map((d) => d.code)).toEqual(['ambiguous-anchor']) +}) diff --git a/typescript/packages/core/tests/structurer.test.ts b/typescript/packages/core/tests/structurer.test.ts index 1aa73760..34b4fed1 100644 --- a/typescript/packages/core/tests/structurer.test.ts +++ b/typescript/packages/core/tests/structurer.test.ts @@ -90,3 +90,14 @@ test('precededByDelimiter marks candidates after a heading or thematic break (AD true, // after a heading ]) }) + +test('the document records its outline: every heading, in order, with level and span', () => { + const source = '# Shared\n\n## Setup\n\nA.\n\n### Inner\n\nB.\n\n## Setup\n\nC.\n' + const doc = structure('test.md', source, scan(source)) + expect(doc.headings.map((h) => [h.level, h.text, h.span.startLine])).toEqual([ + [1, 'Shared', 1], + [2, 'Setup', 3], + [3, 'Inner', 7], + [2, 'Setup', 11], + ]) +}) diff --git a/typescript/packages/language/tests/index-workspace.test.ts b/typescript/packages/language/tests/index-workspace.test.ts index 1ba2d28f..4ad90cd8 100644 --- a/typescript/packages/language/tests/index-workspace.test.ts +++ b/typescript/packages/language/tests/index-workspace.test.ts @@ -184,3 +184,26 @@ test('a spliced step is a match in the oath it was written in, once, however man expect(shelve[0]?.range.start.line).toBe(5) expect(idx.matches.filter((m) => m.oathPath === '/abs/varar/fees.md')).toHaveLength(1) }) + +test('a reference whose anchor names two headings in its file surfaces as an ambiguous-anchor diagnostic on the reference', () => { + // ADR 0016's ambiguous-anchor is a plan diagnostic, so the index carries it + // like the other three; the squiggle lands on the referring block. + const idx = build({ + stepFiles: [], + oathFiles: [ + { + path: '/abs/varar/shared.md', + source: '# Shared\n\n## Setup\n\nA.\n\n## Setup\n\nB.\n', + }, + { + path: '/abs/varar/fees.md', + source: '# Fees\n\n[Setup](./shared.md#setup)\n\nMaya borrows "Emma".\n', + }, + ], + }) + const anchors = idx.diagnostics.filter((d) => d.code === 'ambiguous-anchor') + expect(anchors).toHaveLength(1) + expect(anchors[0]?.oathPath).toBe('/abs/varar/fees.md') + expect(anchors[0]?.range.start.line).toBe(3) + expect(anchors[0]?.message).toContain('"/abs/varar/shared.md" has 2 headings') +}) diff --git a/typescript/packages/lsp/src/handlers.ts b/typescript/packages/lsp/src/handlers.ts index 8a56c5d4..7b92bc57 100644 --- a/typescript/packages/lsp/src/handlers.ts +++ b/typescript/packages/lsp/src/handlers.ts @@ -1,9 +1,16 @@ import { + type Doc, diffExpressions, expressionSegments, inferStepRole, + type PlannedStep, + plan, + type Reference, + referenceOf, renderExpression, + type Span, type StepKind, + slugify, } from '@varar/core' import { createTypeScriptSnippetEmitter, @@ -12,6 +19,7 @@ import { languageIdForPath, type MatchRef, type SnippetEmitter, + type WorkspaceIndex, } from '@varar/language' import type { GenerateSnippetResult, @@ -100,12 +108,19 @@ type Handlers = { export function buildHandlers(store: Store): Handlers { return { hover({ uri, position }) { + // A reference block (ADR 0016) is never a matched step, so the two + // lookups cannot both hit; the reference goes first because it is the + // cheaper miss. + const at = findReferenceAt(store, uri, position) + if (at) return referenceHover(store.index(), at) const m = findMatchAt(store, uri, position) if (!m) return null const contents = `\`"${m.stepDef.expression}"\` at ${m.stepDef.file}:${m.stepDef.expressionRange.start.line}` return { contents } }, definition({ uri, position }) { + const at = findReferenceAt(store, uri, position) + if (at) return referenceDefinition(store.index(), at) const m = findMatchAt(store, uri, position) if (!m) return [] const targetRange = toLspRange(m.stepDef.expressionRange) @@ -373,6 +388,151 @@ function findMatchAt(store: Store, uri: string, position: Position): MatchRef | }) } +// The reference block (ADR 0016) under the cursor, if the cursor is on one: the +// link-only candidate whose primary block contains the position. The index +// keeps every oath's parsed document, so this is a lookup, not a parse. +type ReferenceAt = { + readonly reference: Reference + readonly doc: Doc + readonly span: Span + // The block as the author wrote it, for display โ€” `referenceOf` resolves + // the target to a workspace path, which is not what they typed. + readonly written: string +} + +function findReferenceAt(store: Store, uri: string, position: Position): ReferenceAt | undefined { + const planned = store.index().oaths.get(uriToPath(uri)) + if (!planned) return undefined + const pos: Position = { line: position.line + 1, character: position.character + 1 } + for (const ex of planned.doc.examples) { + const primary = ex.body[0] + if (!primary || !('text' in primary) || !contains(spanRange(primary.span), pos)) continue + const reference = referenceOf(primary.text, planned.doc.path) + if (!reference) return undefined + return { reference, doc: planned.doc, span: primary.span, written: primary.text.trim() } + } + return undefined +} + +// The same resolution plan() uses: a same-file reference resolves against the +// document itself, anything else against the workspace. +function referenceTarget(index: WorkspaceIndex, at: ReferenceAt): Doc | undefined { + return at.reference.path === at.doc.path ? at.doc : index.workspace.docs.get(at.reference.path) +} + +// Go-to-definition on a reference block lands on the heading it names โ€” or on +// the top of the file for a whole-file reference. An ambiguous anchor yields +// one link per heading, so the editor peeks them all rather than picking one. +// A target that does not exist yields nothing: reference-not-found is already +// a diagnostic on the block. +function referenceDefinition(index: WorkspaceIndex, at: ReferenceAt): DefinitionResult { + const target = referenceTarget(index, at) + if (!target) return [] + const originSelectionRange = toLspRange(spanRange(at.span)) + const targetUri = `file://${target.path}` + const top: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } + const ranges = + at.reference.slug === '' + ? [top] + : sectionHeadings(target, at.reference.slug).map((h) => toLspRange(spanRange(h.span))) + return ranges.map((range) => ({ + originSelectionRange, + targetUri, + targetRange: range, + targetSelectionRange: range, + })) +} + +// Hover on a reference block shows the steps it splices in, resolved through +// any nested references โ€” ADR 0016's case for references is that the reader +// must see the setup, and this keeps that true at the point of use without +// opening the other file. +function referenceHover(index: WorkspaceIndex, at: ReferenceAt): HoverResult { + const target = referenceTarget(index, at) + if (!target) return null + const { slug } = at.reference + const headings = slug === '' ? [] : sectionHeadings(target, slug) + const title = slug === '' ? basenamePosix(target.path) : (headings[0]?.text ?? `#${slug}`) + const lines = [`**${title}** ยท \`${linkTarget(at.written)}\``, ''] + // An ambiguous anchor splices nothing in (it is an error on the block), so + // previewing "both" sections would show steps that never run. + if (headings.length > 1) { + lines.push( + `_Ambiguous: ${headings.length} headings share this anchor (lines ${headings + .map((h) => h.span.startLine) + .join(', ')}), so nothing is spliced in._`, + ) + return { contents: lines.join('\n') } + } + const steps = sectionSteps(index, target, slug) + if (steps.length === 0) lines.push('_Contributes no steps._') + steps.forEach((step, i) => { + // A step a nested reference pulled in from a third file says so. + const from = + step.docPath !== undefined && step.docPath !== target.path + ? ` โ€” from \`${relativePosix(dirnamePosix(target.path), step.docPath)}\`` + : '' + lines.push(`${i + 1}. ${step.text}${from}`) + }) + return { contents: lines.join('\n') } +} + +// The steps a reference splices in, resolved the way plan() resolves them โ€” +// nested references included. The section is consumed (that is what being +// referenced means), so the index's plan of the target holds no example for +// it; planning the target as if nothing referenced it gives the section back. +// Header-bound rows are left out, as the splice leaves them out. +function sectionSteps( + index: WorkspaceIndex, + target: Doc, + slug: string, +): ReadonlyArray { + const standalone = plan(target, index.registry, { + docs: index.workspace.docs, + referenced: new Set(), + }) + return standalone.examples + .filter( + (ex) => !ex.headerBinding && (slug === '' || ex.scopeStack.some((h) => slugify(h) === slug)), + ) + .flatMap((ex) => ex.steps) +} + +function sectionHeadings(target: Doc, slug: string) { + return target.headings.filter((h) => slugify(h.text) === slug) +} + +// `[text](target)` โ†’ `target`, as written. +function linkTarget(written: string): string { + return /\]\(\s*([^\s)]+)\s*\)$/.exec(written)?.[1] ?? written +} + +function spanRange(span: Span): { start: Position; end: Position } { + return { + start: { line: span.startLine, character: span.startCol }, + end: { line: span.endLine, character: span.endCol }, + } +} + +// Oath paths are '/'-separated in every environment the server runs in (the +// browser has no node:path), so these stay string-only. +function dirnamePosix(path: string): string { + const i = path.lastIndexOf('/') + return i === -1 ? '' : path.slice(0, i) +} + +function basenamePosix(path: string): string { + return path.slice(path.lastIndexOf('/') + 1) +} + +function relativePosix(fromDir: string, to: string): string { + const a = fromDir.split('/').filter(Boolean) + const b = to.split('/').filter(Boolean) + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + return [...a.slice(i).map(() => '..'), ...b.slice(i)].join('/') +} + function contains(range: { start: Position; end: Position }, position: Position): boolean { if (position.line < range.start.line || position.line > range.end.line) return false if (position.line === range.start.line && position.character < range.start.character) return false diff --git a/typescript/packages/lsp/tests/handlers.test.ts b/typescript/packages/lsp/tests/handlers.test.ts index 969e295b..dfa5e3a3 100644 --- a/typescript/packages/lsp/tests/handlers.test.ts +++ b/typescript/packages/lsp/tests/handlers.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { loadConfig } from '@varar/config' @@ -831,3 +831,184 @@ test('diagnosticsFor does NOT emit anything for a keyword-led but unmatched sent cleanup() } }) + +// Reference blocks (ADR 0016): a link-only block that splices an oath section +// in. Three oaths: fees.md references a section of shared/library.md, which in +// turn references a section of shared/billing.md. +function referenceWorkspace(dir: string): void { + writeFileSync( + join(dir, 'varar.config.json'), + '{ "docs": { "include": ["varar/**/*.md"], "exclude": [] }, "steps": ["**/*.steps.ts"] }\n', + ) + writeFileSync( + join(dir, 'a.steps.ts'), + `stimulus('The library holds {string}', () => {}) +stimulus('Fees are enabled', () => {}) +stimulus('Maya borrows {string}', () => {}) +`, + ) + mkdirSync(join(dir, 'varar', 'shared'), { recursive: true }) + writeFileSync( + join(dir, 'varar', 'shared', 'library.md'), + '# Shared\n\n## A stocked library\n\nThe library holds "Dune".\n\n[Fees are enabled](./billing.md#fees-are-enabled)\n', + ) + writeFileSync( + join(dir, 'varar', 'shared', 'billing.md'), + '# Billing\n\n## Fees are enabled\n\nFees are enabled.\n', + ) + writeFileSync( + join(dir, 'varar', 'fees.md'), + '# Late fees\n\n[A stocked library](./shared/library.md#a-stocked-library)\n\nMaya borrows "Emma".\n\n[Everything in billing](./shared/billing.md)\n\n[Not an oath](https://example.com/library)\n', + ) +} + +test('definition on a reference block lands on the heading it names', async () => { + const { dir, cleanup } = tempWorkspace(referenceWorkspace) + try { + const h = buildHandlers(await makeStore(dir)) + // 0-based: source line 3 is the reference block โ†’ LSP line 2. + const links = h.definition({ + uri: `file://${join(dir, 'varar', 'fees.md')}`, + position: { line: 2, character: 5 }, + }) + expect(links).toHaveLength(1) + const link = links[0]! + expect(link.targetUri).toBe(`file://${join(dir, 'varar', 'shared', 'library.md')}`) + // `## A stocked library` is source line 3 of library.md โ†’ LSP line 2. + expect(link.targetRange.start.line).toBe(2) + expect(link.targetRange.start.character).toBe(0) + // The whole link is the origin, so cmd-hover underlines all of it. + expect(link.originSelectionRange.start).toEqual({ line: 2, character: 0 }) + expect(link.originSelectionRange.end.line).toBe(2) + } finally { + cleanup() + } +}) + +test('definition on a whole-file reference lands on the top of that file', async () => { + const { dir, cleanup } = tempWorkspace(referenceWorkspace) + try { + const h = buildHandlers(await makeStore(dir)) + // Source line 7 is `[Everything in billing](./shared/billing.md)`. + const links = h.definition({ + uri: `file://${join(dir, 'varar', 'fees.md')}`, + position: { line: 6, character: 3 }, + }) + expect(links).toHaveLength(1) + expect(links[0]?.targetUri).toBe(`file://${join(dir, 'varar', 'shared', 'billing.md')}`) + expect(links[0]?.targetRange.start).toEqual({ line: 0, character: 0 }) + } finally { + cleanup() + } +}) + +test('hover on a reference block shows the steps it splices in, nested references resolved', async () => { + const { dir, cleanup } = tempWorkspace(referenceWorkspace) + try { + const h = buildHandlers(await makeStore(dir)) + const result = h.hover({ + uri: `file://${join(dir, 'varar', 'fees.md')}`, + position: { line: 2, character: 5 }, + }) + expect(result?.contents).toBe( + [ + '**A stocked library** ยท `./shared/library.md#a-stocked-library`', + '', + '1. The library holds "Dune"', + // Pulled in by library.md's own reference, so it says where from. + '2. Fees are enabled โ€” from `billing.md`', + ].join('\n'), + ) + } finally { + cleanup() + } +}) + +test('hover on a whole-file reference is titled by the file and lists every step in it', async () => { + const { dir, cleanup } = tempWorkspace(referenceWorkspace) + try { + const h = buildHandlers(await makeStore(dir)) + const result = h.hover({ + uri: `file://${join(dir, 'varar', 'fees.md')}`, + position: { line: 6, character: 3 }, + }) + expect(result?.contents).toBe( + ['**billing.md** ยท `./shared/billing.md`', '', '1. Fees are enabled'].join('\n'), + ) + } finally { + cleanup() + } +}) + +test('a link-only block whose target is not an oath is prose: no hover, no definition', async () => { + const { dir, cleanup } = tempWorkspace(referenceWorkspace) + try { + const h = buildHandlers(await makeStore(dir)) + const uri = `file://${join(dir, 'varar', 'fees.md')}` + // Source line 9 is `[Not an oath](https://example.com/library)`. + expect(h.hover({ uri, position: { line: 8, character: 3 } })).toBeNull() + expect(h.definition({ uri, position: { line: 8, character: 3 } })).toEqual([]) + } finally { + cleanup() + } +}) + +test('a broken reference is a diagnostic on the block, through the ordinary rail', async () => { + const { dir, cleanup } = tempWorkspace((dir) => { + referenceWorkspace(dir) + writeFileSync( + join(dir, 'varar', 'broken.md'), + '# Broken\n\n[Gone](./shared/gone.md#setup)\n\nMaya borrows "Emma".\n', + ) + }) + try { + const h = buildHandlers(await makeStore(dir)) + const diags = h.diagnosticsFor(`file://${join(dir, 'varar', 'broken.md')}`) + expect(diags.map((d) => d.code)).toEqual(['reference-not-found']) + // Index ranges are 1-based; the server shifts them for the wire. + expect(diags[0]?.range.start.line).toBe(3) + expect(diags[0]?.severity).toBe('error') + // And go-to-definition has nowhere to go โ€” the squiggle says why. + expect( + h.definition({ + uri: `file://${join(dir, 'varar', 'broken.md')}`, + position: { line: 2, character: 3 }, + }), + ).toEqual([]) + } finally { + cleanup() + } +}) + +test('hover on a reference whose anchor is ambiguous says so instead of previewing both sections', async () => { + // An ambiguous anchor splices nothing in (ambiguous-anchor is an error on + // the block), so a preview of "both" sections would show steps that never run. + const { dir, cleanup } = tempWorkspace((dir) => { + referenceWorkspace(dir) + writeFileSync( + join(dir, 'varar', 'shared', 'twice.md'), + '# Twice\n\n## Setup\n\nThe library holds "Dune".\n\n## Setup\n\nFees are enabled.\n', + ) + writeFileSync( + join(dir, 'varar', 'ambiguous.md'), + '# Ambiguous\n\n[Setup](./shared/twice.md#setup)\n', + ) + }) + try { + const h = buildHandlers(await makeStore(dir)) + const uri = `file://${join(dir, 'varar', 'ambiguous.md')}` + const result = h.hover({ uri, position: { line: 2, character: 3 } }) + expect(result?.contents).toBe( + [ + '**Setup** ยท `./shared/twice.md#setup`', + '', + '_Ambiguous: 2 headings share this anchor (lines 3, 7), so nothing is spliced in._', + ].join('\n'), + ) + // Definition still offers both headings, so the reader can see the clash. + expect(h.definition({ uri, position: { line: 2, character: 3 } })).toHaveLength(2) + expect(h.diagnosticsFor(uri).map((d) => d.code)).toEqual(['ambiguous-anchor']) + } finally { + cleanup() + } +}) 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 index c0690543..7128eec6 100644 --- 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 @@ -97,8 +97,11 @@ in place. 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. +- **A broken link fails the run.** A link to a file that is not an oath, to a + heading that contributes no steps, or to a heading the file has twice, is an + error โ€” never silently prose. +- **Referencing a section twice runs it twice.** Two links are two splices, in + document order. - **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. diff --git a/typescript/packages/website/src/content/docs/reference/editor-support.mdx b/typescript/packages/website/src/content/docs/reference/editor-support.mdx index 3b5bad1a..d4f61640 100644 --- a/typescript/packages/website/src/content/docs/reference/editor-support.mdx +++ b/typescript/packages/website/src/content/docs/reference/editor-support.mdx @@ -74,6 +74,22 @@ expression. Hovering a matched step surfaces details about the step it resolves to โ€” the binding behind the sentence โ€” without leaving the oath. +### Reference blocks + +A [reference block](/reference/examples/#reference-blocks) โ€” a link-only block +that splices another section's steps in โ€” gets the same treatment as a step: + +- **Go to Definition** on the link jumps to the heading it names, in the oath + that holds it (or to the top of the file, for a whole-file reference). An + anchor that names two headings offers both. +- **Hover** shows the steps the reference splices in, in order, with nested + references resolved โ€” so the world state an example assumes is visible where + it is used, without opening the other file. A step a nested reference pulled + in from a third file says which. +- **Diagnostics** for a broken reference โ€” no such oath, a section with no + steps, a cycle, or an anchor that names two headings โ€” sit on the reference + block itself. + ### Completion As you write a sentence in an oath, completion offers the registered steps that diff --git a/typescript/packages/website/src/content/docs/reference/examples.mdx b/typescript/packages/website/src/content/docs/reference/examples.mdx index b53e0788..74c7801a 100644 --- a/typescript/packages/website/src/content/docs/reference/examples.mdx +++ b/typescript/packages/website/src/content/docs/reference/examples.mdx @@ -128,12 +128,23 @@ 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: +Six 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. +- **The referring example keeps its own name and headings** โ€” its first + matching paragraph, and its own place in the document outline. The referenced + section's heading serves only as the anchor and the link text; its own + heading chain is not carried into the report. +- **Spliced steps read as if written in place.** A report lists them in the + referencing example, flat, in the order they ran. What records where a step + came from is the [run result](/reference/run-results/): a failure inside a + shared section names the document it was written in. +- **The same section referenced twice runs twice.** Two references are two + splices โ€” "the nightly batch runs" twice is a real scenario, not a mistake. +- **A whole-file reference means the whole file.** `[Setup](./shared/setup.md)` + splices every section in that oath, so its meaning changes when the file + grows a second section. Link to an anchor when the file has more than one. - **References nest to any depth.** Depth is a style question Varar does not enforce; a cycle is an error. @@ -144,6 +155,11 @@ Four authoring mistakes fail the run rather than degrading to prose: | `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. | +| `ambiguous-anchor` | The anchor names two or more headings in the target file (`## Setup` twice โ€” GitHub would call the second `#setup-1`). Reported with the headings' line numbers; give each heading an anchor of its own. Nothing is spliced in until it is. | + +All four sit on the reference block, in the oath that made it, so they surface +in the [editor](/reference/editor-support/) and in [`varar lint`](/reference/lint/) +exactly where the fix goes. A dangling reference is deliberately *not* [drift](#drift-detection): drift is "this used to match and now reads as prose", which needs an acknowledgment @@ -340,6 +356,16 @@ The distinction is deliberate: - A paragraph that **stops** matching is **drift** โ€” surfaced, and held until you explicitly accept it. +One asymmetry is worth knowing. A section another oath +[references](#reference-blocks) plans no example in its own file, so its +paragraphs are not in the baseline at all: the file's entry records only that +it was discovered. If a step definition stops matching a shared paragraph, the +run still fails โ€” as `reference-empty`, once, at the reference that depends on +it โ€” rather than as drift at the section. That is the intended signal, and it +has one consequence: delete every reference to a section and it becomes an +ordinary example again with no baseline history, so it is treated as new, not +restored. + ### The baseline file `varar.lock.json` is written and updated by your test runner, in the directory diff --git a/typescript/packages/website/src/content/docs/reference/lint.md b/typescript/packages/website/src/content/docs/reference/lint.md index 3c414c95..740fe4ec 100644 --- a/typescript/packages/website/src/content/docs/reference/lint.md +++ b/typescript/packages/website/src/content/docs/reference/lint.md @@ -26,6 +26,7 @@ framework's own runner. | --- | --- | --- | | `ambiguous-match` | error | Two or more step definitions match one sentence. Varar refuses to guess, so the example cannot run. | | `error-fence-without-step` | error | An [expected-to-fail](/reference/examples/#expected-to-fail-examples) `error` fence sits on an example with no step to produce that failure. | +| `reference-not-found`, `reference-empty`, `reference-cycle`, `ambiguous-anchor` | error | A [reference block](/reference/examples/#reference-blocks) that resolves to no oath, to no steps, back to itself, or to an anchor two headings share. The same errors fail a run; lint reports them without running anything. | | `orphan-step` | warning | A step definition no sentence in any oath matches โ€” dead code, usually the far half of a rename. | Because lint loads your step files to build that registry, anything that stops