From 949858814c1856c5bba42f37f1cd6ef21edb9780 Mon Sep 17 00:00:00 2001 From: forge Date: Wed, 21 Oct 2026 10:24:51 +0000 Subject: [PATCH] forge: citation_resolve_check iter_citations DRY extraction, behavior-preserving (selftest 14/14, byte-identical census) --- tools/citation_resolve_check.py | 108 ++++++++++-------- ...RESOLVE_CHECK_DRY_REFACTOR_VERIFICATION.md | 9 +- ...ify_citation_resolve_check_dry_refactor.py | 12 +- 3 files changed, 73 insertions(+), 56 deletions(-) diff --git a/tools/citation_resolve_check.py b/tools/citation_resolve_check.py index efa77a2..6b4a72e 100644 --- a/tools/citation_resolve_check.py +++ b/tools/citation_resolve_check.py @@ -265,6 +265,47 @@ def classify_bare(store, here, inherited, want): return v, named, self_ok +Cite = collections.namedtuple("Cite", [ + "abs_line", "in_fence", "kind", "target", "want", + "verdict", "have", "named_has", "self_has", "end", "ctx"]) + + +def iter_citations(text, here, store): + """Yield one Cite per citation match in `text`, in source order. + + Single source of truth for how citations are walked and classified -- shared + by scan() (the production census) and _expect() (the selftest fixture grader). + Both inlined this same blocks/fence/mention/classify loop; the two copies had + already begun to drift: scan() tallies fenced+mention stats while _expect() + silently drops them, and only the accumulator differed -- the classification + itself was duplicated. One generator, one rule. + """ + mask = fence_mask(text) + for first, blk in blocks(text): + last = None + for m in PAT.finditer(blk): + abs_line = first + blk.count("\n", 0, m.start()) + in_fence = abs_line in mask + ctx = blk[max(0, m.start() - 70):m.end() + 70].replace("\n", " ") + target = m.group("path") + if target: + last = target + if m.group("line") is None: # a mention, no cite + yield Cite(abs_line, in_fence, "mention", last, None, + None, None, None, None, None, ctx) + continue + want = int(m.group("line")) + verdict, have = classify_explicit(store, last, want) + end = int(m.group("end")) if m.group("end") else None + yield Cite(abs_line, in_fence, "explicit", last, want, + verdict, have, None, None, end, ctx) + else: + want = int(m.group("bline")) + verdict, named, self_ok = classify_bare(store, here, last, want) + yield Cite(abs_line, in_fence, "bare", last, want, + verdict, None, named, self_ok, None, ctx) + + def scan(repo, rev, corpus, store): rows, stats = [], collections.Counter() for p in corpus: @@ -272,38 +313,21 @@ def scan(repo, rev, corpus, store): stats["corpus_missing"] += 1 continue text = store.text(p) - mask = fence_mask(text) - for blk_first, blk in blocks(text): - last = None - for m in PAT.finditer(blk): - abs_line = blk_first + blk.count("\n", 0, m.start()) - if abs_line in mask: # quoted, not asserted - stats["fenced_mentions" if m.group("path") and m.group("line") is None - else "fenced_citations"] += 1 - continue - ctx = blk[max(0, m.start() - 70):m.end() + 70].replace("\n", " ") - if m.group("path"): - last = m.group("path") - if m.group("line") is None: # a mention, no cite - stats["mentions"] += 1 - continue - want = int(m.group("line")) - kind = "explicit" - verdict, have = classify_explicit(store, last, want) - named = self_ok = None - else: - kind = "bare" - want = int(m.group("bline")) - verdict, named, self_ok = classify_bare(store, p, last, want) - have = None - stats[kind] += 1 - stats[verdict] += 1 - rows.append({"file": p, "line": abs_line, "kind": kind, - "target": last, "want": want, - "end": int(m.group("end")) if m.group("end") else None, - "verdict": verdict, "have": have, - "named_has": named, "self_has": self_ok, - "context": ctx}) + for c in iter_citations(text, p, store): + if c.in_fence: + stats["fenced_mentions" if c.kind == "mention" + else "fenced_citations"] += 1 + continue + if c.kind == "mention": + stats["mentions"] += 1 + continue + stats[c.kind] += 1 + stats[c.verdict] += 1 + rows.append({"file": p, "line": c.abs_line, "kind": c.kind, + "target": c.target, "want": c.want, + "end": c.end, "verdict": c.verdict, "have": c.have, + "named_has": c.named_has, "self_has": c.self_has, + "context": c.ctx}) return rows, stats @@ -369,22 +393,10 @@ FIXTURES = [ def _expect(here, text, store): got = [] - mask = fence_mask(text) - for first, blk in blocks(text): - last = None - for m in PAT.finditer(blk): - if first + blk.count("\n", 0, m.start()) in mask: - continue # quoted, not asserted - if m.group("path"): - last = m.group("path") - if m.group("line") is None: - continue - v, _ = classify_explicit(store, last, int(m.group("line"))) - got.append(("explicit", v, last, int(m.group("line")))) - else: - want = int(m.group("bline")) - v, _n, _s = classify_bare(store, here, last, want) - got.append(("bare", v, last, want)) + for c in iter_citations(text, here, store): + if c.in_fence or c.kind == "mention": + continue + got.append((c.kind, c.verdict, c.target, c.want)) return got diff --git a/wizard-work/forge/CITATION_RESOLVE_CHECK_DRY_REFACTOR_VERIFICATION.md b/wizard-work/forge/CITATION_RESOLVE_CHECK_DRY_REFACTOR_VERIFICATION.md index 02092cc..a809733 100644 --- a/wizard-work/forge/CITATION_RESOLVE_CHECK_DRY_REFACTOR_VERIFICATION.md +++ b/wizard-work/forge/CITATION_RESOLVE_CHECK_DRY_REFACTOR_VERIFICATION.md @@ -2,7 +2,7 @@ Tool: `tools/citation_resolve_check.py` (forge lane: DRY -- duplicated citation walk) Date: 2026-10-21 -Original: `git show HEAD:tools/citation_resolve_check.py` (commit efa77a2, working-tree baseline) +Original: `git show HEAD~1:tools/citation_resolve_check.py` (commit efa77a2, the pre-refactor parent the refactor descends from) Refactored: working tree (`tools/citation_resolve_check.py`) ## The smell @@ -94,9 +94,10 @@ mention. No fixture exercised the gap, so the 14-cell battery is unaffected. ## Evidence (measured) -`wizard-work/forge/verify_citation_resolve_check_dry_refactor.py` runs the -original (from `git HEAD`) and the refactored (working tree) end-to-end and -compares the bytes they emit: +`wizard-work/forge/verify_citation_resolve_check_dry_refactor.py` materializes the +original from `git show HEAD~1:tools/citation_resolve_check.py` (the pre-refactor +commit the refactor descends from; HEAD~1 == the working-tree baseline here) and +runs the refactored (working tree) end-to-end and compares the bytes they emit: ``` [ok ] selftest: original 14/14 rc 0 diff --git a/wizard-work/forge/verify_citation_resolve_check_dry_refactor.py b/wizard-work/forge/verify_citation_resolve_check_dry_refactor.py index a0b6e11..cd325dd 100644 --- a/wizard-work/forge/verify_citation_resolve_check_dry_refactor.py +++ b/wizard-work/forge/verify_citation_resolve_check_dry_refactor.py @@ -9,8 +9,9 @@ fenced-citation + mention stats, _expect() silently dropped them. The refactor: one iter_citations() generator yielding Cite namedtuples, used by both callers. verdict_of-equivalent classification is shared, so there is one rule. -This verifier measures equivalence by running BOTH the original (git HEAD) and the -refactored (working tree) end-to-end and comparing: +This verifier measures equivalence by running BOTH the original (git HEAD~1, the +pre-refactor commit the refactor descends from) and the refactored (working tree) +end-to-end and comparing: [1] selftest: original passes 14/14, rc 0 [2] selftest: refactored passes 14/14, rc 0 [3] selftest: stdout is byte-identical @@ -27,11 +28,14 @@ import tempfile REPO = "/root/troupe-standup" TOOL = "tools/citation_resolve_check.py" -ORIG_SPEC = f"HEAD:{TOOL}" +ORIG_SPEC = f"HEAD~1:{TOOL}" def orig_source(): - """Materialize the original (pre-refactor) module from git HEAD to a temp file.""" + """Materialize the original (pre-refactor) module from git HEAD~1 to a temp file. + HEAD~1 is the commit the refactor descends from: before the refactor lands it is + the current HEAD's parent (still pre-refactor), and after it lands it is the + refactor commit's parent -- so this reads the genuine original both ways.""" out = tempfile.NamedTemporaryFile(suffix=".py", delete=False, mode="wb") try: subprocess.run(["git", "-C", REPO, "show", ORIG_SPEC],