Skip to content

Commit 33e36d4

Browse files
wshallwshallwshallwshallclaude
authored
docs(backlog): wire the dangling-citation detector to report, advisory only (BACKLOG #1431) (#821)
scripts/docs/dangling_citation_check.py works and its findings are real, but nothing ran the script. Verified at HEAD with controls in the same pass rather than trusted from its docstring: the script path occurs 0 times across .github/ plus .pre-commit-config.yaml, while backlog_citation_check.py (a real `run:` at backlog-hygiene.yml:236) occurs once and an invented path occurs 0 times. The one .github/ line naming this module is ci.yml:250, and it names the TEST. Adds a `dangling backlog citations (advisory)` job to quality-advisory.yml, the one workflow in this repository that holds no required context. It reports on every pull request, including documentation-only ones, which is exactly the change shape that introduces a citation and exactly the shape the pytest tier holding this tool's unit test is skipped for. IT CANNOT BLOCK A MERGE, by four independent means: 1. the workflow holds no required status-check context, and .github/required-contexts.txt records that it must never be promoted; 2. --advisory makes a live-shape finding exit 0 (the tool's default is 1); 3. continue-on-error: true, so even the checker's empty-population refusal cannot fail the job; 4. the job is absent from the liveness meta-gate's `needs`. Promoting it to blocking is an owner decision and is deliberately untaken, on the #353 precedent. tests/test_dangling_citation_advisory.py pins the three of those four a repository test can see, each with a positive control, and each was mutated to confirm it reddens rather than passing inertly. ONE GUARD WAS INERT ON FIRST WRITING AND THE MUTATION CAUGHT IT. The advisory check asked whether "--advisory" appeared anywhere in the step, and it did -- in the step's own warning text -- so deleting the flag from the command left the test green. It now asserts on the invocation line, and the warning no longer carries the string. The item records the measurement, the wiring gap, the recognition rule (the window is [1000,9000), which is why a planted #999998 control did not fire), and a triage of the nine numbers into kinds: one is another project's issue number, seven are the ledger deliberately narrating a burned or abandoned number, and one points at real dangling citations in non-Markdown files the default scan never reads. No citation is repaired here; that is a separate act. Co-authored-by: wshallwshall <mefordev@messagefoundry.org> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b619551 commit 33e36d4

4 files changed

Lines changed: 350 additions & 0 deletions

File tree

.github/workflows/quality-advisory.yml

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ name: quality-advisory
2626
# PRs too; survivor table in the step summary.
2727
# signal 10 (lint breadth) is a coordinated one-shot sweep, not an advisory job; DORA is a context caveat, not a gate.
2828
#
29+
# ONE JOB HERE IS NOT A RUBRIC SIGNAL, AND IT IS HERE ON PURPOSE (BACKLOG #1431). `dangling-citations`
30+
# reports backlog `#N` references that name no filed item in either ledger. It is a LEDGER-hygiene check,
31+
# not a code-quality measurement, and its natural sibling -- backlog-hygiene.yml -- is the wrong home:
32+
# that workflow's only job IS a required context, so a step added there could block every merge. This
33+
# file is the repository's one place where a check can report without being able to gate, which is the
34+
# property that job needs. Promoting it to blocking is an owner decision and is deliberately untaken;
35+
# see the item for the reasoning and for what the nine current findings actually are.
36+
#
2937
# WHY THIS USES WORKFLOW-COMMAND ANNOTATIONS AND *NOT* CODE SCANNING / SARIF. This was measured, not
3038
# assumed, and the measurements are recorded here so a future session does not "helpfully" add a SARIF
3139
# upload back:
@@ -766,6 +774,73 @@ jobs:
766774
# the log rather than silently swallowed.
767775
if-no-files-found: warn
768776

777+
dangling-citations:
778+
# BACKLOG #1431 -- a working detector that ran nowhere.
779+
#
780+
# `scripts/docs/dangling_citation_check.py` reports a `#N` backlog citation that names no item in
781+
# EITHER ledger. Until this job, the SCRIPT was invoked by no workflow and no pre-commit hook:
782+
# measured at 46ea10a78 with controls in the same pass, the script path occurs 0 times across
783+
# .github/ plus .pre-commit-config.yaml, while backlog_citation_check.py (a real `run:`) occurs
784+
# once and an invented path occurs 0 times. Only its TEST was wired, via ci.yml's DOC_GUARDS.
785+
#
786+
# WHY IT RUNS HERE AND CANNOT GATE. Four independent things keep it advisory, and
787+
# tests/test_dangling_citation_advisory.py pins the three a repository test can see:
788+
# 1. this workflow holds no required context (.github/required-contexts.txt records that it must
789+
# never be promoted) -- branch-protection membership is the only thing that decides gating;
790+
# 2. `--advisory` makes a live-shape finding exit 0 rather than 1, which is this tool's default;
791+
# 3. `continue-on-error: true` means even the checker's own empty-population REFUSAL cannot fail
792+
# the job -- it shows as a warning on a green job, which is the visibility this wants;
793+
# 4. this job is deliberately absent from `liveness`'s `needs`, so it cannot redden the one job
794+
# in this file that is built to go red.
795+
#
796+
# THE EXIT CODE IS NOT SWALLOWED WITH `|| true`, and that is the point of (3). With `--advisory`
797+
# the only way this tool exits non-zero is a GENUINE malfunction -- most plausibly its refusal to
798+
# report on an empty population, which is what running from the wrong directory looks like. A
799+
# clean scan and a scan that read nothing must not render alike, so that case stays visible as a
800+
# red step inside a green job rather than being turned into a silent success.
801+
#
802+
# NO PATHS FILTER ON THIS WORKFLOW, which is what makes the job worth having: a citation is
803+
# introduced by editing PROSE, and the pytest tier that holds this tool's unit test is skipped on
804+
# a documentation-only pull request. This job runs on all of them.
805+
name: dangling backlog citations (advisory)
806+
runs-on: ubuntu-latest
807+
permissions:
808+
contents: read
809+
steps:
810+
- name: Check out the source
811+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
812+
with:
813+
persist-credentials: false
814+
- name: Report backlog citations that name no filed item (advisory - never gates)
815+
continue-on-error: true
816+
run: |
817+
echo "== backlog citations under docs/ that name no item in either ledger =="
818+
echo "== advisory: this reports, it never gates a merge =="
819+
TOOL_STATUS=0
820+
python3 scripts/docs/dangling_citation_check.py --advisory \
821+
> dangling-citations.txt 2>&1 || TOOL_STATUS=$?
822+
cat dangling-citations.txt
823+
{
824+
echo "## Dangling backlog citations"
825+
echo ""
826+
echo "Advisory only. A reported token is an UPPER BOUND ON CITATIONS, not a count of"
827+
echo "defects: a number at or below the allocator high-water mark can never be issued,"
828+
echo "and a PR, issue or foreign-repo reference is not a backlog citation at all. Both"
829+
echo "kinds are printed so a human judges them. See BACKLOG item 1431 for the triage."
830+
echo ""
831+
echo '```'
832+
# $GITHUB_STEP_SUMMARY is capped at 1 MiB per step and an oversized write is DROPPED
833+
# ENTIRELY, so truncate rather than silently lose the whole summary.
834+
head -c 200000 dangling-citations.txt
835+
echo '```'
836+
} >> "$GITHUB_STEP_SUMMARY"
837+
if [ "$TOOL_STATUS" -ne 0 ]; then
838+
echo "::warning title=Dangling-citation scan did not complete::the checker exited ${TOOL_STATUS} in advisory mode, which means a malfunction rather than a finding. See the job log."
839+
fi
840+
# Re-raise the checker's status. `continue-on-error` above keeps the JOB green; this keeps a
841+
# broken scan distinguishable from a clean one.
842+
exit "$TOOL_STATUS"
843+
769844
liveness:
770845
# THE META-GATE. Every other job here is built so it can never fail; this one is built so it CAN.
771846
#

docs/BACKLOG.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20623,3 +20623,94 @@ helper-per-class split and for why each hook carries a local copy), #1339 (a dif
2062320623

2062420624
**Source:** measured 2026-09-03 while re-verifying #1040 against `origin/main` at `fd44b0f1`, by
2062520625
enumerating every hook that writes text an agent acts on rather than only the ones #1040 named.
20626+
20627+
## 1431. the dangling-citation detector runs in no workflow and no hook, so its nine unresolved numbers report to nobody
20628+
20629+
> 🔢 **Filed 2026-09-03. THE WIRING LIMB SHIPPED WITH THIS ROW; THE ENFORCEMENT DECISION AND THE CITATION REPAIRS DID NOT.** `scripts/docs/dangling_citation_check.py` works and its findings are real, but until this row nothing ran the SCRIPT: the filing PR adds an ADVISORY job to `.github/workflows/quality-advisory.yml` that reports on every pull request and cannot fail one. Promoting it to a merge gate is deliberately **available and untaken** -- that is an owner call, not a Builder's, on the #353 precedent -- and so is repairing any of the citations below.
20630+
>
20631+
> **Scored 2026-09-03 -> P3.** Value **4/10** · Difficulty **2/10** · _fill-in_. The remaining limbs are an owner ruling on enforcement plus, if that ruling goes the blocking way, moving the job's context into branch protection and `.github/required-contexts.txt` with the count in `tests/test_required_contexts.py` in the same pull request. No engine code, no dependency, no new CI leg.
20632+
> Verdict: owner-ruling
20633+
> Research: none
20634+
> Closing-act: owner-ruling
20635+
20636+
**Cluster:** Ledger hygiene / gate wiring. **Priority:** P3. **Verdict:** owner-ruling.
20637+
**Severity:** no deployment axis (sec. 0). Nothing here reaches a running instance; the cost is paid by a future reader of the ledger.
20638+
20639+
### The measurement, at `46ea10a78`
20640+
20641+
`python scripts/docs/dangling_citation_check.py docs/BACKLOG.md` reports **53 tokens across 9 distinct numbers** -- #1084, #1203, #1231, #1297, #1303, #1326, #1364, #1411 and the pyodbc one dealt with below -- each naming no item in either ledger.
20642+
20643+
**FILING THIS ROW RAISES THAT COUNT, AND THAT IS EXPECTED.** Naming eight of the nine as subjects adds tokens the tool reports, all of them below the floor and so permanently unable to arm. The ninth is written only in its qualified `mkleehammer/pyodbc#1459` form throughout this row, because the bare spelling **is** the live shape and a row about the defect must not become an instance of it. The tool caught exactly that during drafting.
20644+
20645+
**THE JOB THIS ROW WIRES REPORTS A BIGGER NUMBER, AND THE TWO MUST NOT BE READ AS A DISAGREEMENT.** The figures above scan one file, because that is where the row's subject lives. The advisory job scans the tool's default, all of `docs/**/*.md`, and with this row filed that is **83 tokens across 11 distinct numbers** -- driven end to end locally, exit 0. The two extra numbers are the same two kinds already in the table below and neither is new: one is a pull-request number in `docs/adr/README.md` and `docs/adr/0104-*.md`, and one is another allocation made from the wrong worktree, recorded in the closed archive. Both sit below the floor. Anyone reconciling the job's output against this row should expect 11, not 9.
20646+
20647+
**IT THEN EXITS 0, AND THAT ZERO IS CORRECT.** This is worth stating plainly, because the obvious reading -- a detector that finds nine things and passes anyway -- is wrong and would send someone to "fix" a working exit code. The tool fails closed on the LIVE shape by default; `--advisory` is the opt-out. Today it exits 0 because **zero of the 53 tokens are live-shape**: 50 sit at or below the allocator's high-water mark and can never be issued, and the other 3 are another project's issue number. Measure the exit code directly, not through a pipe -- `$?` after `| tail` reads `tail` (SDS-3.8).
20648+
20649+
### The wiring gap, verified at HEAD rather than read from the docstring
20650+
20651+
The script's own module docstring claims it is invoked by no workflow and no pre-commit hook. That claim is **true at `46ea10a78`**, re-measured with controls in the same pass rather than trusted:
20652+
20653+
| probe over `.github/` + `.pre-commit-config.yaml` | lines |
20654+
|---|---|
20655+
| `scripts/docs/dangling_citation_check.py` (the subject) | 0 |
20656+
| `scripts/docs/backlog_citation_check.py` (positive control -- a real `run:` at `backlog-hygiene.yml:236`) | 1 |
20657+
| `scripts/docs/zzz_nonsense.py` (null control) | 0 |
20658+
20659+
The one `.github/` line carrying this module's name is `ci.yml:250`, and it names the **test**, not the script -- the `DOC_GUARDS` entry. `.mefor-hooks/pre-commit` execs `messagefoundry check` and nothing else. So a behaviour change in the tool can red a build by way of its test; a FINDING could reach nobody.
20660+
20661+
### The recognition rule, determined because a control failed
20662+
20663+
A first attempt to positive-control this tool planted `See BACKLOG #999998 for details.` in a scratch file and got "No unresolved backlog citation" back. **The control did not fire, and the reason is the window, not a path filter and not a file-type filter.** `_LOW = 1000` and `_HIGH = 9000`, so `#999998` is out of range and invisible. A detector nobody can make fire on demand is one nobody can trust when it says clean, so the rule is recorded here in full. A token is recognised when **all** of these hold:
20664+
20665+
1. It is `#` immediately followed by digits, **ending at a non-alphanumeric boundary**. `#8997abc` does not match -- that boundary is what stops a CSS or Mermaid hex colour reading as a citation.
20666+
2. The number lies in `[1000, 9000)`.
20667+
3. A space between `#` and the digits disqualifies it, so a `## 1431.` item heading cannot report as a citation of itself.
20668+
20669+
Two further rules decide only whether a recognised token can arm, and neither suppresses reporting: a number **at or below** the ledger high-water mark can never be issued, and a token introduced by `PR`/`pull request`/`commit`/`issue`/`discussion` or glued to an identifier (`pyodbc#1459`) is another namespace's number.
20670+
20671+
**How to make it fire on demand.** In any file, write the words `See BACKLOG`, then a `#` glued directly to `8999`, then a full stop, and pass that path. Measured 2026-09-03: reported, annotated *This is the live shape*, exit **1**. Introduce the same token with the word `PR` in front instead and it is reported and annotated but does **not** change the exit code, which is the distinction the annotation exists to draw. The file need not be Markdown and need not be inside `docs/` -- only the DEFAULT path list is `docs/**/*.md`, and it is relative to the current working directory.
20672+
20673+
**That recipe is spelled out rather than shown for a reason worth keeping.** A literal worked example of a live-shape citation, pasted into this ledger, IS a live-shape citation -- it would report forever as a defect in the row explaining the defect, and no reader could tell the demonstration from the disease. So the one artifact that most wants a copy-pasteable example is the one that cannot hold it.
20674+
20675+
**THAT DEFAULT IS ITSELF A COVERAGE GAP, and #1364 below is the worked instance.** The citations that actually resolve to nothing in the repository today sit in `.gitattributes`, a `scripts/quality/` module and a test file. None of them is Markdown, so the scan as wired reads about them and never reads them.
20676+
20677+
### The nine are not one thing, and a count that fuses them is wrong
20678+
20679+
Every one of the 53 tokens is **below the floor** (1421 at the time of measuring, from the ledger headings) except the three foreign-repo ones, so **none can arm**. The kinds differ:
20680+
20681+
| number | tokens | kind |
20682+
|---|---|---|
20683+
| #1084 | 17 | Allocated, then **dropped** during PR #261's merge resolution on the owner's instruction. Never reached `main` and never will. The ledger says so in terms: the number "stays allocated and burned rather than reused". |
20684+
| #1203 | 8 | **Allocated and never filed.** The allocator record exists; no heading does. Separately MIS-CITED at `.github/workflows/asvs-tally-lint.yml:3`, where the item implemented is #1204. |
20685+
| #1231 | 6 | Allocated 2026-08-12, **released without being filed** because the work duplicated #1229. Cited in a private-repo document this scan cannot see. |
20686+
| #1297 | 5 | Allocated from the **primary checkout** rather than the session's worktree, so the ledger gate correctly refused the commit and the item was re-filed at #1298. A recorded permanent hole. |
20687+
| #1303 | 4 | The same work allocated twice with character-identical titles; the other row shipped and this number was **folded in** as a permanent hole. |
20688+
| #1326 | 1 | The same finding filed 22 seconds earlier by another seat, on a branch that never landed. |
20689+
| #1411 | 5 | Allocated to a worktree whose session ended before the item could be filed; superseded by #1415. The ledger's own text says it "MUST NEVER BE CITED AGAIN". |
20690+
| #1364 | 4 | **Not a hole -- narrative about one.** The BACKLOG tokens describe five real dangling citations that commit `4c8845754` wrote into non-Markdown files. Those five are outside the default scan. |
20691+
| `mkleehammer/pyodbc#1459` | 3 | **Not a backlog citation at all.** See below. |
20692+
20693+
So the honest split is: **1 number (3 tokens) is a false positive**, **7 numbers (46 tokens) are the ledger deliberately narrating a burned, abandoned or duplicated number** -- prose whose whole subject is that the number resolves to nothing -- and **1 number (4 tokens) points at real dangling citations that live where the scan does not look**. The tool's own summary already refuses to call 53 a defect count; this table is what that refusal was protecting.
20694+
20695+
**Nothing below is repaired here.** Repairing a citation is a separate act with its own judgment, and #1411's entry in particular instructs a repointing that this row does not perform.
20696+
20697+
### The pyodbc number is a false positive, and the arithmetic that says otherwise is right about the wrong namespace
20698+
20699+
That number is above the floor, so the reflex reading is that it is the trap CLAUDE.md forbids: a citation to a number nobody has allocated, which starts resolving to unrelated work the day somebody does. **It is not.** All three tokens are `mkleehammer/pyodbc#1459` -- the upstream pyodbc issue whose fix would license removing `scripts/ci/retry-native-crash.sh` from the database legs. The tool annotates all three as foreign-repo shaped and its exit code passes over them, which is the correct call.
20700+
20701+
**The only thing standing between that token and a red gate is the one annotation**, so it is worth naming as load-bearing rather than cosmetic: drop the `_FOREIGN_REPO` rule and this repository's ledger reports a live-shape defect that does not exist.
20702+
20703+
### What the advisory wiring is, and what it deliberately is not
20704+
20705+
The filing PR adds a `dangling citations (advisory)` job to `.github/workflows/quality-advisory.yml`. That file is advisory by design, holds no required contexts, and `.github/required-contexts.txt` records that it "must never be promoted". Four things keep this job unable to block a merge, and `tests/test_dangling_citation_advisory.py` pins the ones a repository test can see:
20706+
20707+
1. It lives in a workflow with no required context.
20708+
2. Its analysis step passes `--advisory`, so a live-shape finding exits 0.
20709+
3. Its analysis step carries `continue-on-error: true`, so even the tool's empty-population refusal cannot fail the job.
20710+
4. It is **not** in the `liveness` job's `needs`, so it cannot redden the one job in that file built to go red.
20711+
20712+
**Why advisory and not blocking.** A new merge-blocking gate is the owner's decision, and #353 sits unbuilt on exactly that reasoning. The reviewer process is suspended to unblock merges as this row is filed, so adding a blocking gate today would push the other way. The job reports; the enforcement decision stays on the table.
20713+
20714+
**One thing this wiring buys that the existing test leg cannot.** A citation is introduced by editing prose, and `quality-advisory.yml` triggers on `pull_request` with no paths filter -- so it runs on a documentation-only pull request, which is exactly the change shape that introduces the defect and exactly the shape the pytest tier is skipped for.
20715+
20716+
**Its limit, stated rather than left to be found.** The job scans the tool's default `docs/**/*.md`. It therefore does not read the five #1364 citations in `.gitattributes`, `scripts/quality/licence_header_check.py` and `tests/test_licence_header_gate.py`, and it cannot see the private companion repository at all, which the tool prints on every exit path.

0 commit comments

Comments
 (0)