Skip to content

feat(reference): add a resolver for anchors and transclusions - #45

Merged
CameronBrooks11 merged 34 commits into
mainfrom
feat/anchor-resolver
Oct 16, 2026
Merged

CameronBrooks11 merged 34 commits into
mainfrom
feat/anchor-resolver

Conversation

@CameronBrooks11

@CameronBrooks11 CameronBrooks11 commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

The reference resolver for SPEC.md §11, together with §11's conformance
cases. The cases are #46's, test/anchor-cases at d389dee (215 anchor
cases, 452 in all), written from SPEC.md by an author who had not read the
resolver, and merged here with no case changed. Merging this lands both, and
#46 closes as landed through it. main, with 0003's three addenda (#43, #47,
#48), is merged in too.

Landing order (decided). The first anchor case would turn the required
reference check red on main, since the reader had no resolve until
this PR. So the cases were written blind from main on their own branch and
reviewed there, then merged into this branch and run against it.
README.md, AGENTS.md and SPEC.md now say that the 0001 and 0002 cases
came before the reader, and the 0003 cases after the resolver.

Against the cases

$ just conform    # after merging d389dee, before any change made for it
0 failure(s) across 452 case(s) in the fixture tree
$ just conform    # at the head of this branch
0 failure(s) across 452 case(s) in the fixture tree

No case failed, here or at the earlier merges of e0f5525 (422 cases) and
7375c2c (446). So no
case is wrong per SPEC, no SPEC question came out of a failure, and the
resolver needed no fix for the cases. run_cases.MIN_CASES is 452.

What this adds

reference/blockspec/anchor.py, standard library only, implements §11 on
top of the existing reader:

  • records (§11.1): the exact frontmatter lines, the canonical JSON anchor
    (key order, spacing, escape set, B as 40 or 64 lower-case hex digits by
    object format, N as plain decimal), replace tied to kind, ULID file
    names and reply-to, a body the profile refuses, and the consistency
    rules against base, with occurrences counted by start position;
  • correspondence (§11.3): the leftmost longest chain, the §11.2 LCS
    alignment inside each gap with MOVED leaves left out, and EDITED pairing
    with the integer comparisons of the first addendum (22/30 − 42/72 is
    tested as exactly 0.15);
  • the anchored leaf (§11.4): every rule and reason, in first-applies
    order;
  • the span (§11.5): hits counted by start position, the edge rule and
    the alignment check;
  • input problems in §11.10's order, replies included (third addendum);
  • statuses, the suggestion gate and write-back (§11.6, §11.7):
    NFC-to-raw mapping through container prefixes, lost indentation, CRLF and
    bare CR; a \n in replace written as the line's ending plus its
    container prefixes; and unmappable-offset for a span that does not map,
    for a line break that cannot be re-emitted, for a span crossing or ending
    right before a tab's leftover spaces, and for a span ending with a code
    block's final \n;
  • transclusion (§11.9): the pins grammar, path resolution, and steps 0
    to 4, with a referrer the profile refuses reported as
    "REFUSED(not-in-profile)" and the first pinned leaf in the pinned blob's
    document order deciding among several pinned-<reason>s.

document.py gains resolve(repo, queries), the interface the runner
calls. It loads anchor.py from its own directory by path and hands it this
file's reader, so a mutated copy of the reader is the one the resolver runs.
The reader's own output is unchanged: a script run locally compared
conform() from origin/main and from this branch, messages included, for
every parse case input, and got 237 inputs, 0 differ. A copy with one
line changed gives 237 inputs, 35 differ.

Fixed after review

  • A JSON array where the grammar wants an object (anchor: [1], a leaf or
    a pin that is an array) raised out of resolve(). Objects are now told
    from arrays, and every malformed record or pin is record-inconsistent.
    The review's fuzz (6,000 random records, pins and referrers) now raises
    nothing.
  • [a [b](c.md) d](policy.md#scram "transclude") was read as a
    transclusion. Link text cannot hold a link (CommonMark 0.31.2, example
    518), so it is no link and no transclusion.
  • The self-transclusion walk skipped a whole document when one of its
    paragraphs was undecided, so A to B to A went unseen. It now skips only
    that paragraph.
  • The claim that "moved leaves stay in gaps" is equivalent was false: with
    twins beside a move, the mutant swaps repeat and unaccounted-copy.
    It is an ordinary mutant now, a unit test kills it, and
    anchor/repeat/twins-beside-a-move kills it in the gate.
  • Shapes CommonMark decides, which were UNDECIDED: an image titled
    transclude is no link; a paragraph with no inline link is no
    transclusion; a title may be followed by a line ending before ); an
    entity reference is decoded only with its ;, so &#116ransclude is not
    transclude.

The mutation gate

just mutants gates the resolver as well as the reader: a second table in
conformance/mutants.py over reference/blockspec/anchor.py, probed by the
whole tree. Its probe writes each mutant as anchor.py beside a copy of
the reader. Mutants that only turn an UNDECIDED into a guess are left
out, since no case can expect one, as are the duplicate-id warning and a
notes-directory subfolder, which no case field can observe. The
resolver's table, as the gate printed it:

--- the resolver, reference/blockspec/anchor.py
  killed   escape set misses U+2028         (1 case(s): anchor/record/accepts-every-escape)
  killed   escape set misses C1 controls    (2 case(s): anchor/record/accepts-an-escaped-c1-control, anchor/record/refuses-a-raw-c1-control)
  killed   negative nth read                (1 case(s): anchor/record/refuses-a-negative-nth)
  killed   replace not tied to kind         (2 case(s): anchor/record/refuses-a-suggestion-without-replace, anchor/record/refuses-replace-on-a-comment)
  killed   canonical bytes not compared     (7 case(s): anchor/order/grammar-before-document-missing, anchor/record/refuses-a-raw-c1-control, anchor/record/refuses-a-short-tab-escape)
  killed   table pipe not escaped           (1 case(s): anchor/leaf/an-escaped-pipe-keeps-two-tables-apart)
  killed   heading level dropped            (2 case(s): anchor/status/edited-when-a-heading-changes-level, anchor/suggestion/applies-when-a-heading-changes-level)
  killed   text not normalised              (3 case(s): anchor/status/resolved-when-the-normalisation-form-changes, anchor/suggestion/stale-when-the-span-does-not-map, anchor/suggestion/writes-back-without-normalising)
  killed   half compares strictly           (2 case(s): anchor/correspondence/a-similarity-of-exactly-one-half-pairs, anchor/near-copy/exact-with-an-unmatched-leaf-at-one-half)
  killed   below one allows one             (1 case(s): anchor/near-copy/an-exact-copy-elsewhere-is-not-a-near-copy)
  killed   margin compares strictly         (1 case(s): anchor/correspondence/a-margin-of-exactly-0-15-pairs)
  killed   margin in floating point         (1 case(s): anchor/correspondence/a-margin-of-exactly-0-15-pairs)
  killed   alignment skips the second first (1 case(s): anchor/correspondence/the-gap-alignment-skips-the-base-leaf-first)
  killed   chain is the rightmost           (1 case(s): anchor/correspondence/a-swap-keeps-the-leaf-now-first-on-the-chain)
  killed   chain is not the longest         (55 case(s): anchor/correspondence/a-swap-keeps-the-leaf-now-first-on-the-chain, anchor/correspondence/the-gap-alignment-skips-the-base-leaf-first, anchor/exact-copy/a-paired-twin-does-not-count)
  killed   moved pairs dropped              (10 case(s): anchor/correspondence/a-swap-keeps-the-leaf-now-first-on-the-chain, anchor/near-copy/moved-with-its-reworded-text-in-place, anchor/refilled-gap/moved-with-a-new-leaf-in-its-place)
  killed   moved leaves stay in gaps        (1 case(s): anchor/repeat/twins-beside-a-move)
  killed   no lcs in gaps                   (7 case(s): anchor/exact-copy/a-paired-twin-does-not-count, anchor/near-copy/an-exact-copy-elsewhere-is-not-a-near-copy, anchor/no-pair/a-twin-deleted-between-unique-neighbours)
  killed   edit needs no half               (2 case(s): anchor/correspondence/a-similarity-just-below-one-half-does-not-pair, anchor/status/orphaned-when-rewritten-beyond-recognition)
  killed   edit needs no lead over other current (1 case(s): anchor/correspondence/the-margin-must-hold-on-the-base-leaf-side)
  killed   edit needs no lead over other base (1 case(s): anchor/correspondence/a-margin-just-below-0-15-does-not-pair)
  killed   no unaccounted-copy              (3 case(s): anchor/no-pair/a-twin-deleted-between-unique-neighbours, anchor/repeat/one-of-two-adjacent-copies-deleted, anchor/repeat/twins-beside-a-move)
  killed   repeat only when base repeats    (1 case(s): anchor/repeat/a-copy-added-beside-the-leaf)
  killed   repeat only when current repeats (2 case(s): anchor/repeat/one-of-two-adjacent-copies-deleted, anchor/repeat/twins-beside-a-move)
  killed   exact near-copy off              (3 case(s): anchor/near-copy/exact-with-an-unmatched-leaf-at-one-half, anchor/near-copy/exact-with-its-reworded-text-beside-it, anchor/suggestion/stale-on-a-refusal)
  killed   near-copy below half             (8 case(s): anchor/near-copy/exact-with-an-unmatched-leaf-just-below-one-half, anchor/pins/a-pin-matches-its-raw-path, anchor/refilled-gap/moved-with-a-new-leaf-in-its-place)
  killed   exact near-copy counts exact copies (1 case(s): anchor/near-copy/an-exact-copy-elsewhere-is-not-a-near-copy)
  killed   moved near-copy off              (3 case(s): anchor/near-copy/moved-with-its-reworded-text-in-place, anchor/transclusion-order/a-pinned-reason-before-name-rebound, anchor/transclusion/pinned-near-copy)
  killed   refilled gap off                 (1 case(s): anchor/refilled-gap/moved-with-a-new-leaf-in-its-place)
  killed   exact-copy off                   (1 case(s): anchor/exact-copy/edited-with-its-old-text-unmatched)
  killed   exact-copy counts paired copies  (2 case(s): anchor/correspondence/the-gap-alignment-skips-the-base-leaf-first, anchor/exact-copy/a-paired-twin-does-not-count)
  killed   crowded gap off                  (2 case(s): anchor/crowded-gap/edited-with-a-new-leaf-beside-it, anchor/transclusion/pinned-crowded-gap)
  killed   edited near-copy off             (1 case(s): anchor/near-copy/edited-with-a-variant-elsewhere)
  killed   empty span allowed               (1 case(s): anchor/record/refuses-an-empty-span)
  killed   triple may occur twice           (3 case(s): anchor/order/a-quote-twice-before-base-unavailable, anchor/record/refuses-a-quote-that-occurs-twice, anchor/record/refuses-a-quote-that-overlaps-itself)
  killed   short prefix anywhere            (1 case(s): anchor/record/refuses-a-short-prefix-away-from-the-edge)
  killed   short suffix anywhere            (2 case(s): anchor/order/a-short-context-before-base-unavailable, anchor/record/refuses-a-short-suffix-away-from-the-edge)
  killed   occurrences skip overlaps        (2 case(s): anchor/record/refuses-a-quote-that-overlaps-itself, anchor/span/overlapping-hits-are-ambiguous)
  killed   no prefix edge rule              (1 case(s): anchor/context/a-short-prefix-must-sit-at-the-leaf-start)
  killed   no suffix edge rule              (1 case(s): anchor/context/a-short-suffix-must-sit-at-the-leaf-end)
  killed   two hits not ambiguous           (2 case(s): anchor/span/overlapping-hits-are-ambiguous, anchor/span/two-hits-are-ambiguous)
  killed   no alignment check               (1 case(s): anchor/span/the-alignment-detaches-a-rewritten-span)
  killed   gate ignores status              (3 case(s): anchor/suggestion/stale-when-accepted, anchor/suggestion/stale-when-rejected, anchor/suggestion/stale-when-resolved)
  killed   gate applies on moved            (2 case(s): anchor/residual/r1-a-deleted-leaf-quoted-elsewhere, anchor/suggestion/stale-on-moved)
  killed   break written as LF              (2 case(s): anchor/suggestion/writes-a-line-break-as-a-bare-cr, anchor/suggestion/writes-a-line-break-as-crlf)
  killed   break written with no prefix     (2 case(s): anchor/suggestion/writes-back-in-a-block-quote, anchor/suggestion/writes-back-in-a-list-item-continuation-line)
  killed   synthetic spaces mapped          (1 case(s): anchor/suggestion/writes-back-a-span-beginning-after-leftover-tab-spaces)
  killed   absolute path allowed            (1 case(s): anchor/transclusion/unresolvable-path-with-a-scheme)
  killed   scheme path allowed              (2 case(s): anchor/transclusion-order/unresolvable-path-before-broken-pins, anchor/transclusion/unresolvable-absolute-path)
  killed   above the root allowed           (1 case(s): anchor/transclusion/unresolvable-path-above-the-root)
  killed   pins order unchecked             (4 case(s): anchor/pins/refuses-a-path-pinned-twice, anchor/pins/refuses-pins-out-of-order, anchor/transclusion-order/broken-pins-before-document-missing)
  killed   pins after a blank line read     (2 case(s): anchor/pins/refuses-a-pin-after-a-blank-line, anchor/pins/refuses-a-pin-after-a-comment-line)
  killed   name ref skipped                 (1 case(s): anchor/transclusion/a-name-that-binds-nothing-now)
  killed   rebound unchecked                (3 case(s): anchor/pins/a-pin-matches-its-raw-path, anchor/transclusion/name-rebound-by-a-leaf-inserted-after-the-marker, anchor/transclusion/name-rebound-when-the-marker-moves)
  killed   reply-to not canonical           (1 case(s): anchor/reply/refuses-a-reply-to-with-extra-spacing)
  killed   pin not canonical                (1 case(s): anchor/pins/refuses-a-pin-not-in-canonical-form)
  killed   pins line with trailing text     (1 case(s): anchor/pins/refuses-a-pins-line-with-more-after-it)
  killed   dot segment kept                 (2 case(s): anchor/pins/a-pin-matches-its-raw-path, anchor/transclusion/resolves-relative-paths-from-a-subdirectory)
  killed   synthetic char inside span mapped (1 case(s): anchor/suggestion/unmappable-span-across-a-tab-left-by-fence-indentation)
  killed   repeat ignores counts            (3 case(s): anchor/repeat/a-copy-added-beside-the-leaf, anchor/repeat/one-of-two-adjacent-copies-deleted, anchor/repeat/twins-beside-a-move)
  killed   over-long context read           (2 case(s): anchor/record/refuses-a-prefix-longer-than-32, anchor/record/refuses-a-suffix-longer-than-32)
  killed   reply-to any string              (3 case(s): anchor/reply/a-malformed-reply-to-before-document-missing, anchor/reply/refuses-a-lower-case-reply-to, anchor/reply/refuses-a-reply-to-that-is-not-a-ulid)
  killed   lowercase ulid read              (1 case(s): anchor/record/refuses-a-lower-case-ulid-file-name)
  killed   reply parent never found         (3 case(s): anchor/reply/a-reply-to-a-record-renders, anchor/reply/a-reply-to-a-reply-renders, anchor/reply/a-reply-to-an-orphaned-record-renders)
  killed   base checked before record-only checks (2 case(s): anchor/order/a-quote-twice-before-base-unavailable, anchor/order/a-short-context-before-base-unavailable)
  killed   record body not checked          (1 case(s): anchor/record/refuses-a-body-the-profile-refuses)
  killed   document missing ignored for records (3 case(s): anchor/input/document-missing, anchor/input/document-renamed-without-its-notes, anchor/order/document-missing-before-base-unavailable)
  killed   base read before the document    (1 case(s): anchor/order/current-not-in-profile-before-base-unavailable)
  killed   lazy bq line repeats a shorter prefix (1 case(s): anchor/suggestion/unmappable-line-break-on-a-lazy-line)
  killed   lazy list line repeats a shorter prefix (1 case(s): anchor/suggestion/unmappable-line-break-on-a-list-item-s-lazy-line)
  killed   tab after a quote marker repeated (1 case(s): anchor/suggestion/unmappable-line-break-under-a-quote-marker-and-tab)
  equiv    item first line repeated         (a list item's first line holds its marker in the prefix region, so the lazy-line check refuses the break anyway)
  killed   break with no ending written     (1 case(s): anchor/suggestion/unmappable-line-break-on-a-last-line-with-no-ending)
  killed   refused referrer enumerated      (3 case(s): anchor/referrer/refused-for-a-conflict-line-in-a-fence, anchor/referrer/refused-for-conflicting-adjacent-re-pins, anchor/referrer/refused-for-raw-html)
  killed   pins before the path             (1 case(s): anchor/transclusion-order/unresolvable-path-before-broken-pins)
  killed   broken pins ignored              (9 case(s): anchor/pins/refuses-a-path-pinned-twice, anchor/pins/refuses-a-pin-after-a-blank-line, anchor/pins/refuses-a-pin-after-a-comment-line)
  killed   missing target after the pin     (1 case(s): anchor/transclusion-order/document-missing-before-base-unavailable)
  killed   slug-only name not checked early (2 case(s): anchor/transclusion-order/a-slug-only-name-before-base-unavailable, anchor/transclusion-order/a-slug-only-name-before-the-pinned-binding)
  killed   slug match ignored               (2 case(s): anchor/transclusion-order/a-slug-only-name-before-base-unavailable, anchor/transclusion-order/a-slug-only-name-before-the-pinned-binding)
  killed   marker name taken for slug-only  (2 case(s): anchor/merge/re-pins-of-two-targets-merge-cleanly, anchor/transclusion/a-marker-name-that-is-also-a-slug-resolves)
  killed   pinned blob not required         (2 case(s): anchor/transclusion-order/an-unbound-name-after-base-unavailable, anchor/transclusion/base-unavailable)
  killed   pinned name unchecked            (1 case(s): anchor/transclusion/a-pinned-name-that-binds-nothing)
  killed   orphaned pinned leaf passes      (2 case(s): anchor/transclusion-order/several-pinned-reasons-report-the-first-leaf, anchor/transclusion/pinned-orphaned)
  killed   last pinned leaf decides         (1 case(s): anchor/transclusion-order/several-pinned-reasons-report-the-first-leaf)
  killed   anchor array taken for an object (1 case(s): anchor/record/refuses-an-anchor-that-is-an-array)
  killed   inner link ignored               (1 case(s): anchor/transclusion/a-link-inside-link-text-is-not-one)
  killed   entity without semicolon decoded (1 case(s): anchor/transclusion/an-entity-without-a-semicolon-is-not-decoded)
  killed   image read as a link             (1 case(s): anchor/transclusion/an-image-is-not-one)
  killed   prose with a title read as a link (1 case(s): anchor/transclusion/prose-with-the-title-s-characters-is-not-one)
  killed   line ending after the title refused (1 case(s): anchor/transclusion/a-line-ending-before-the-closing-parenthesis)
  killed   code block's final line ending written (1 case(s): anchor/suggestion/unmappable-span-ending-in-a-code-block-s-final-line-ending)
  killed   reply's document unchecked       (3 case(s): anchor/reply/document-missing-before-a-missing-parent, anchor/reply/refused-when-its-document-is-missing, anchor/reply/refused-when-its-document-is-not-in-profile)

91 killed, 0 survived, 1 equivalent, 0 stale, 0 broken

No survivors. The six holes the last run reported, one per review fix,
are now killed by the cases #46 added for them.

One equivalence claim, "item first line repeated": a list item's first
line holds its marker in the prefix region, so the lazy-line check refuses a
break there anyway. kindkit flags a claim only when the suite kills the
mutant; it cannot show that one the suite does not kill is equivalent, so
this rests on the argument.

Where SPEC.md is still open: refused, not guessed

Each of these returns {"status": "UNDECIDED", "question": ...} for that
record or transclusion, which no case can expect.

Left open by §12:

  1. Transclusion shapes: a destination with no path, angle brackets, another
    title quoting form or a title with backslash escapes or entity references
    (one that decodes to transclude but is not written "transclude"), a
    transclude link in a paragraph that holds other text, a transclusion in
    a list item or block quote, no pin, and a document that transcludes
    itself, directly or through another.
  2. Writing a suggestion back into a table.
  3. An empty path segment, and a path naming a file that is not markdown
    (read here as not ending in .md).

Not in §12, and not decided either:

  1. A destination with no #name.
  2. Link text holding a code span or HTML, either of which can move where the
    link text ends under CommonMark.
  3. A line break written over a span whose first and last lines end or are
    prefixed differently: §11.7 writes each \n as the ending of "the line
    it breaks", and such a span has two candidates.
  4. Where the open transclusion questions sit in step 1's order: the empty
    segment and self-transclusion after the path resolves, the non-markdown
    target before the pins, and no pin at step 1.6.
  5. Inputs no case should produce: a record or referrer that is not in the
    tree, and a record path in no notes directory.

Cheap to decide but left UNDECIDED (CommonMark settles them, at the
cost of a full inline parser): prose that holds "transclude") alongside a
real inline link elsewhere in the same paragraph.

Readings taken, not refused, worth confirming:

  • When the span ends at a line break, write-back maps its end past the next
    line's prefixes, so > a\n> b with a\n replaced by x gives > xb.
  • A paragraph holding "transclude") in a link-like shape is treated as a
    possible transclusion and refused as undecided where it is not exactly one
    link. That refuses more than needed, and never resolves wrongly.

Gates

$ just check
All checks passed!
$ just test
181 passed in 13.02s
$ just conform
0 failure(s) across 452 case(s) in the fixture tree
$ just tree
452 cases: 160 accept, 77 refuse, 215 anchor
0 problem(s)
$ just mutants
--- the reader, reference/blockspec/document.py
91 killed, 0 survived, 0 equivalent, 0 stale, 0 broken
--- the resolver, reference/blockspec/anchor.py
91 killed, 0 survived, 1 equivalent, 0 stale, 0 broken

Break-checks of the unit tests

tests/test_anchor.py was break-checked by a sweep run locally (not
committed). It hashes the file before and after each mutation, reports
BROKEN when one does not land, runs the tests, and restores the file. The
latest run gave 105 killed, 1 not, the one being the equivalent mutant
above. The hook and the gate's scratch location: 3 killed, 0 not.

Not covered

  • The thresholds 0.5 and 0.15 are as §11 states them; nothing here
    measures them on real history (0003 §12).
  • Re-anchoring (§11.1) and the tool behaviours of §11.11 are not
    implemented: the format does not require them.

A case of kind `anchor` holds a small repository: the current snapshot
under tree/, readable earlier blobs under blobs/, and optionally the
three sides of a merge that the runner performs with stock git. It
lists records and referrers with the outcome expected for each: the
status and reason, the anchored leaf, the span, the suggestion gate
with the written document, and every transclusion with its unit.

Authors write `{{blob:PATH}}` where an object id goes; the runner
replaces it with `git hash-object`'s id in a repository of the case's
object format. check_tree.py checks anchor cases for their files,
their controls, and that every asserted field is compared.

No anchor case is added: the cases come from an author who has not
read an implementation.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
reference/blockspec/anchor.py implements spec section 11 on top of
the reader: the record and pin grammars with their canonical forms,
correspondence (the leftmost chain, LCS inside gaps, EDITED pairing
with integer thresholds), every refusal rule and reason, the span
search with the edge rule and the alignment check, the statuses, the
suggestion gate and write-back, replies, and transclusion.

Where the spec leaves an input open, the resolver reports it as
UNDECIDED with the question rather than choose an answer.

document.py gains resolve(), the anchor kind's entry point. It loads
anchor.py from its own directory by path and hands it this file's
reader, so a mutated copy of the reader is the one it runs. The
reader's parse output is unchanged.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
The reader's resolve() loads anchor.py from the directory it sits in.
The gate's scratch copy sat in conformance/, where it would find no
anchor.py, and every anchor case would then fail against the
unmutated baseline once such cases exist.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
check_tree.py now reports a placeholder target outside tree/, blobs/
and merge/ whose bytes equal a readable file's: ids are
content-addressed, so such a file names a readable blob. Alterations
now also turn a 0 or 1 index into a bool, and a test kills a
not_integers that checks floats alone.

The schema now requires a transclusion's leaf to be a paragraph and
allows unmappable-offset only on RESOLVED or EDITED, as the README
says. The self-test's good body follows that, and pins ten more
rules. The README states the landing order of the anchor cases and
the resolver, warns about content addressing, notes git's CRLF
conflict markers, and lists what a tool writes among what a case
cannot express.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
CameronBrooks11 added a commit that referenced this pull request Oct 16, 2026
## What this adds

A second case kind, `anchor`, so that the conformance tree can state
what
`SPEC.md` §11 resolves: comments, suggestions, replies and
transclusions,
across edits and stock `git merge`s. It adds the runner support, the
tree
checks and the documentation. **It adds no anchor case**: under the
standing
rule (org contract §2.1), §11's cases come from an author who has not
read an
implementation, and they are to be written from `SPEC.md`, 0003 and
`conformance/cases/README.md`, *The kind `anchor`*, which this PR adds.

## Landing order

The first anchor case turns the required `reference` check red, because
the
reader has no `resolve` until the resolver (#45). So: after this merges,
the
anchor cases are written blind from `main`, on a branch and pull request
of
their own, and reviewed there. #45 is then rebased onto the cases
branch, run
against them with no case changed, reviewed, and merged. That merge
lands the
cases and the resolver together, and the cases' pull request closes as
landed
through #45. `conformance/cases/README.md`'s anchor section states the
same.

## The case shape

    cases/anchor/<group>/<case-name>/
        expect.json
        WHY.md
        tree/<path>         the current snapshot, each file at its path
        blobs/<any>         more readable blobs, such as a record's base
merge/{base,ours,theirs}/<path> optional: a merge that makes <path>
<anything else> not in the repository (a placeholder's target, a written
file)

- **Placeholders.** `{{blob:PATH}}` in any file is replaced by `git
hash-object`'s id of the case file at `PATH`, in a repository of the
case's
`object_format` (`sha1` by default, or `sha256`). Authors write content,
never hashes. A file outside `tree/`, `blobs/` and `merge/` can be named
  but is not readable, which is how `base-unavailable` is set up.
- **Merges.** `"merge": {"policy.md": "clean" | "conflict"}` runs
kindkit's
`gitmerge.merge` (stock git, no configuration from the machine) over the
three sides; the working-tree result, markers and all, becomes the
current
  file, and git's outcome is asserted.
- **`records`**: per record path, `status` (the six statuses of §11.6
with
§11.10's reasons, plus `REPLY` / `#REF!(reply-to)` for replies), `leaf`
(the paired current leaf as a node path), `span` (code point offsets in
  that leaf's §11 text), and `suggestion` (`null`, or the §11.7 gate:
  `apply` with a case file holding the exact written-back document, or
  `stale` with `null` / `unmappable-offset`).
- **`transclusions`**: per referrer, every transclusion paragraph in
document
order, each with its `leaf`, `status` (`RESOLVED`, `REFUSED(<reason>)`,
or
`#REF!(<name>)`) and `unit` (the target document's resolved path and
what
  the name binds now).
- **`controls`**: required when any outcome is `REFUSED(...)`, as for a
  `parse` refusal.

The implementation is handed `resolve(repo, queries)` with the tree's
texts
and the readable blobs by id, so it needs no git (`run_cases.py`'s
docstring).

## Where it is checked

- `just conform` and the mutation gate dispatch `anchor` cases through
  `run_cases.py`; an implementation without `resolve` fails each one.
- `just tree` (`check_tree.py`, stdlib only) checks each anchor case's
right
  answer passes, an empty answer fails, every asserted field is compared
(one alteration at a time, an index or offset given as a float or a bool
  included), its files are consistent (placeholder targets, merge sides,
written files, listed records and referrers, and no unreadable
placeholder
target with a readable file's bytes, since ids are content-addressed),
and
  its controls.
- CI's `conformance` job runs `conformance/schema_selftest.py`, which
feeds
  kindkit's own validator one valid synthetic anchor body and 44 broken
  ones, each in a tree of its own, and requires each broken one to be
  rejected. The empty-implementation step now defines an empty `resolve`
  too, so it keeps failing every case once anchor cases exist.

`tests/test_anchor_kind.py` drives the runner and `check_tree.py` over
synthetic trees in a temporary directory, with a fake implementation
that
checks the repository it is handed (ids recomputed with `hashlib`, merge
results, queries) and echoes a canned answer. Its bodies are not
conformance
cases and say nothing about how an anchor resolves; flagging them here
in
case they should go.

## Gates

    $ just check
    All checks passed!
    $ just test
    26 passed in 2.97s
    $ just conform
    0 failure(s) across 237 case(s) in the fixture tree
    $ just tree
    237 cases: 160 accept, 77 refuse, 0 anchor
    0 problem(s)
    $ just mutants
    91 killed, 0 survived, 0 equivalent, 0 stale, 0 broken
$ python3 conformance/schema_selftest.py <kindkit checkout at 6ebd80d>
    0 broken body(ies) not rejected
    $ python3 <kindkit>/tools/validate_case_tree.py conformance/cases
    0 invalid case(s) across 237 case(s) in 1 tree(s)

## Break-checks

Each new check was broken and seen to fail, by a sweep run locally (not
committed) that hashes the file before and after each mutation and
reports
BROKEN when a mutation does not land. Runner and `check_tree.py` mutants
run
`pytest tests/test_anchor_kind.py`; schema mutants run the self-test.

    killed     compare skips span
    killed     compare skips status
    killed     transclusion length unchecked
    killed     written text unchecked
    killed     stale reason unchecked
    killed     placeholder not substituted
    killed     object format ignored
    killed     blobs/ not readable
    killed     merge sides not readable
    killed     merge outcome unchecked
    killed     merge sides swapped
    killed     nested placeholder allowed
    killed     merge over a tree file allowed
    killed     merge messages dropped
    killed     check_tree: empty answer not tried
    killed     check_tree: controls not required
    killed     schema: leaf nullable whatever the status
    killed     schema: apply on any status
    killed     schema: unit allowed when refused
    killed     schema: anchor body open
    killed     bools taken for integers
    killed     no bool alterations
    killed     unreadable-blob lint off
    killed     schema: transclusion leaf any type
    killed     schema: unmappable-offset on any status
    25 killed, 0 not

## After review

Added in a second commit: the content-addressing lint above; bool
alterations and a test that kills a `not_integers` checking floats
alone;
the schema now requires a transclusion's leaf to be a paragraph and
allows
`unmappable-offset` only on `RESOLVED` or `EDITED`, as the README says
(the
self-test's good body no longer breaks that); ten more rules pinned in
the
self-test; and the README now notes git's CRLF conflict markers and
lists
re-anchoring, anchor creation and re-pinning among what a case cannot
express.

## Notes for review

- The `parse` kind is unchanged: its schema branches are untouched (only
`accept` moved from the top-level `required` into a `parse` branch), and
  `just conform`, `just tree` and `just mutants` report what they did
  before.
- `SPEC.md` §10 says every case has the kind `parse`. That stays true
until
the first anchor case lands, which is the change that should update it.
- This changes the runner and the case-tree format, so it is
load-bearing
  under the org contract §3 and wants more than one review pass.

---------

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Base automatically changed from feat/anchor-case-kind to main October 16, 2026 04:55
Add 141 cases of the anchor kind under conformance/cases/anchor/,
written from SPEC.md section 11 and adjudication 0003 with its
addendum, without reading any implementation. They cover every
resolution status and reason, the record and pin grammars, the
correspondence thresholds at their exact boundaries, the leftmost
chain, the twin, refill and near-copy rules, context edges, the
suggestion gate and write-back, replies, transclusion, merges with
stock git, and the residuals R1 to R4. Each WHY.md quotes the
passage that decides it, and the README records their provenance.

The reference has no resolve yet, so every anchor case fails
`just conform`. They land together with the resolver, as the
README's landing order says.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
No leaf of base has the refused type with the record's text, so the
leaf-at-nth rule gives the same outcome, and the case cannot go red
for the T check alone. Its WHY.md now says so.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Add seven anchor cases: the 0.15 margin on the base leaf's side, the
gap alignment skipping the base element first, a short suffix that
must end at the leaf's end, a matching heading slug that is not a
transclusion target, a pins line with more after it, a list item
after another frontmatter key that is not refused, and pins matched
by their raw path.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
The second addendum renumbers section 11.9's steps, drops the EXACT
rule's gap clause and widens two rows of section 11.10. Re-quote the
WHY.md passages that changed. No expected outcome changes.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Add 35 anchor cases for 0003's second addendum: an over-long context,
the record and transclusion reason orders through cases that set up
several reasons, unresolvable-path before broken pins, the line breaks
that cannot be written back, notes files that are not ULIDs and
malformed reply-to ids, slug-only names, referrers refused as a whole,
a refused record body, and the checks moved into step 1 of the order.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Input problems are now reported in section 11.10's order, the first
that applies deciding, where the resolver had reported two at once as
undecided. A context longer than 32 code points, a notes-directory
entry that is not a <ULID>.md record, a reply-to that is not a ULID,
and a record whose body the profile refuses are record-inconsistent.

A line break in replace that cannot be re-emitted (a list item's
first line, a lazy line, a tab in the container prefix, a last line
with no ending) is stale as unmappable-offset. The repeat rule drops
the clause the addendum removes.

Transclusion follows section 11.9's steps 0 to 4: a referrer the
profile refuses is refused as a whole, the path precedes the pins, a
name only a heading slug matches is #REF!(name) before the pin is
read, and the first pinned leaf in document order decides among
several pinned reasons.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
An unbound name with an unreadable pin is base-unavailable, since only
a slug-only name is decided before the pinned blob is read. A pinned
refusal is reported before name-rebound, step 3 before step 4.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Raises run_cases.MIN_CASES to 422, the tree with the anchor cases. The
status in README.md and AGENTS.md no longer says 0003 has no cases or
implementation: it says the cases were written without reading the
resolver, which was written before them and run against them with no
case changed.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
just mutants now runs a second table over reference/blockspec/anchor.py,
probed by the anchor cases. Its probe writes each mutant as anchor.py
beside a copy of the reader, which loads anchor.py from its own
directory. Mutants that only turn an undecided input into a guess are
left out, as are two that no case field can observe, and two are
claimed equivalent with their reasons.

The EDITED branch's near-copy call is written near(True) so the gate
can name it, since kindkit matches token runs and ignores comments.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Add ten anchor cases: U+0080 escaped and raw, a negative nth, two
non-canonical reply-to lines, write-back after and across a tab of
list-item indentation and after a marker followed by a tab, and line
breaks in replace on a list item's lazy line and under a quote's tab.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
The case pinned a span that crosses a tab partly consumed by list
indentation. That mapping is not decided yet, so the case comes out
until it is.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Add a case with two twins in the gap of a moved leaf, one refused as
repeat and one as unaccounted-copy, and two write-back cases whose
span lies in a leaf with a tab-indented line without reaching the tab.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Section 11.8 and 11.10 now render #REF!(reply-to) only once a reply's
document checks pass. Re-quote the three WHY.md files that cited the
old wording. No expected outcome changes.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Add twelve anchor cases: a quote that overlaps itself and overlapping
hits, replies whose document is missing or refused with their place in
the reason order, spans across, before and after the spaces a partly
consumed tab leaves in a code block, including under a fence's own
indentation, and a span ending at a code block's final line ending
with its control.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
A JSON array where the grammar wants an object raised out of
resolve(); objects are now told from arrays, and every malformed
record or pin is record-inconsistent.

Link text that holds a link is no link (CommonMark example 518), so
such a paragraph is no transclusion. An image is no link, prose with
no inline link is no transclusion, a title may be followed by a line
ending, and an entity reference is decoded only with its semicolon.

The self-transclusion walk skipped a whole document when one of its
paragraphs was undecided; it now skips only that paragraph.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Drops the claim that leaving MOVED leaves in the gaps is equivalent:
with twins beside a move, it swaps repeat and unaccounted-copy. It
is now an ordinary mutant. Adds mutants for the parsing fixes the
cases can observe.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
The 0001 and 0002 cases came before the reader; the 0003 cases came
after the resolver, written without reading it. AGENTS.md and
SPEC.md said the whole suite came first, and the cases README said
the resolver's branch is rebased onto the cases, where it is merged.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
A reply now goes through its document's checks, document-missing
and not-in-profile, before it is looked up by its reply-to. A span
whose last code point is the line ending of a code block's last
content line is stale as unmappable-offset. Overlapping occurrences
were already counted by start position, for hits too; a test now
pins that.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
The tree now holds the final anchor cases: 209 of them, 446 in all.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Adds resolver mutants for a reply's document checks and for a span
that ends with a code block's final line ending; the anchor cases
kill both.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Add six anchor cases: an anchor that is a JSON array, and referrer
paragraphs that are not a transclusion under CommonMark 0.31.2 (a link
inside link text, an entity with no semicolon in the title, an image,
prose holding the title's characters), plus one whose title is
followed by a line ending before the closing parenthesis, which is.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
The tree now holds 215 anchor cases, 452 in all.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
@CameronBrooks11
CameronBrooks11 merged commit 9e07b24 into main Oct 16, 2026
6 checks passed
@CameronBrooks11
CameronBrooks11 deleted the feat/anchor-resolver branch October 16, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant