diff --git a/.github/scripts/docs_lint.py b/.github/scripts/docs_lint.py index bfc80376a..99622f92c 100644 --- a/.github/scripts/docs_lint.py +++ b/.github/scripts/docs_lint.py @@ -644,6 +644,7 @@ def check_skip_reason_roster_tether() -> list[str]: # 2. every doc reachable from the index chain (README/MANIFEST) # 3. every SUPERSEDED marker carries a pointer # 4. same-subject proposals reference each other +# 5. every doc has a MANIFEST.md row, and every row resolves (2026-07-29) # # All SOFT findings: they print as ::warning:: and do NOT add to the exit # code, so the corpus can't turn master red before the concurrent @@ -804,6 +805,134 @@ def check_orphans() -> list: return findings +# --- Rule 5: MANIFEST.md coverage ------------------------------------------- +# The orphan rule above asks "is this doc REACHABLE?" — and every doc in the +# corpus currently is, because docs cross-link each other freely. That is a +# weaker property than the one MANIFEST.md exists to provide. MANIFEST.md is +# the ROUTING table: path / purpose / governs-what-surface / authority, so a +# task can be routed to its governing docs by topic without anyone +# remembering which file covers what. A doc reachable only through a +# see-also link in a sibling is reachable and unroutable at the same time. +# +# Eight real docs had no row and no covering directory row, including +# `features/stage-e-operations.md` — an operational runbook. Nothing said so, +# because nothing was checking; MANIFEST.md's own maintenance note ("a new +# `docs/*.md` file gets a row here in the same PR that adds it") was a +# convention in prose, which is the defect this whole rule family exists to +# close. +# +# Checked BOTH directions: a doc with no row, and a row whose path does not +# resolve. The second is not covered by the generic backtick-path check — +# that rule requires a "/" in the candidate, so a top-level row like +# `theory.md` was never verified to exist at all. +MANIFEST_DOC_REL = "MANIFEST.md" + +# Docs that legitimately have NO MANIFEST row. EXPLICIT and per-entry- +# justified, same convention as CALCULATOR_ROSTER_ALLOWLIST / +# SKIP_REASON_ROSTER_ALLOWLIST: an exclusion must be a visible decision, not +# a silent gap. Nothing goes here merely because it currently fails. +MANIFEST_COVERAGE_ALLOWLIST = { + "README.md": ( + "one of the two index roots (INDEX_ROOT_DOCS). MANIFEST.md's own " + "opening distinguishes the two — README.md is the audience-grouped " + "map, MANIFEST.md the by-surface routing table — so a row pointing " + "at the other index adds no routing information." + ), + "MANIFEST.md": ("the routing table itself; a row for the table inside the table " "routes nobody anywhere."), +} + + +def _manifest_rows() -> list: + """The `path` cell of every MANIFEST.md table row, in order. + + DERIVED from the table, never restated: the roster of covered docs is + whatever MANIFEST.md actually says, so this rule cannot disagree with + the file it is checking. Rows come in two shapes — a doc path + (`features/grid-selector.md`) and a directory prefix with a trailing + slash (`reports/`), which covers everything beneath it. + """ + manifest = DOCS_DIR / MANIFEST_DOC_REL + if not manifest.is_file(): + return [] + raw = strip_fenced_code(manifest.read_text()) + paths = [] + for m in TABLE_ROW_RE.finditer(raw): + cells = [c.strip() for c in m.group(1).split("|")] + if not cells or set(cells[0]) <= {"-", ":"}: + continue # header separator row + cell = BACKTICK_RE.match(cells[0]) + if cell: + paths.append((cell.group(1), line_of(raw, m.start()))) + return paths + + +def check_manifest_coverage() -> list: + findings = [] + manifest = DOCS_DIR / MANIFEST_DOC_REL + if not manifest.is_file(): + return findings + manifest_rel = _rel(manifest) + + rows = _manifest_rows() + if not rows: + return [ + ( + manifest_rel, + None, + "MANIFEST.md has no readable table rows — this rule derives the " + "covered-doc set from them, so an empty parse would check nothing " + "and pass. The empty parse is the finding.", + ) + ] + + dir_prefixes = tuple(p for p, _ in rows if p.endswith("/")) + file_rows = {p for p, _ in rows if not p.endswith("/")} + + # Direction 1: a row whose target does not exist. Deliberately checked + # here rather than left to the generic backtick-path rule, which skips + # any candidate without a "/" and so never verified a top-level row. + for path, line in rows: + target = DOCS_DIR / path + exists = target.is_dir() if path.endswith("/") else target.is_file() + if not exists: + findings.append( + ( + manifest_rel, + line, + f"MANIFEST.md row `{path}` does not resolve to a real " + f"{'directory' if path.endswith('/') else 'file'} under docs/. " + f"A routing table pointing at something deleted routes readers " + f"nowhere — update or remove the row.", + ) + ) + + # Direction 2: a doc with no row and no covering directory row. + for doc in sorted(DOCS_DIR.rglob("*.md")): + rel = doc.relative_to(DOCS_DIR).as_posix() + if rel in MANIFEST_COVERAGE_ALLOWLIST: + continue + if rel in file_rows: + continue + if dir_prefixes and rel.startswith(dir_prefixes): + continue + findings.append( + ( + _rel(doc), + None, + f"MANIFEST.md coverage gap: {rel} has no row in docs/MANIFEST.md and " + f"is not covered by a directory row. MANIFEST.md is the by-surface " + f"ROUTING table — a doc missing from it is unroutable even when it is " + f"reachable by a link, which is why the orphan rule passes on it. Add a " + f"row (path / purpose / governs-what-surface / authority, per " + f"MANIFEST.md's own definitions of BINDING / reference / historical), " + f"or add an entry to MANIFEST_COVERAGE_ALLOWLIST in " + f".github/scripts/docs_lint.py with a per-entry reason.", + ) + ) + + return findings + + def check_supersession() -> list: findings = [] for path in sorted(DOCS_DIR.rglob("*.md")): @@ -942,6 +1071,7 @@ def check_wiki_publish_map() -> list: SOFT_CHECKS = ( ("no-letter-labels", check_no_letter_labels), ("orphan", check_orphans), + ("manifest-coverage", check_manifest_coverage), ("supersession", check_supersession), ("proposal-crossref", check_proposal_crossrefs), ("wiki-publish-map", check_wiki_publish_map), diff --git a/.github/scripts/tests/test_docs_lint.py b/.github/scripts/tests/test_docs_lint.py index 7526daee2..b91593143 100644 --- a/.github/scripts/tests/test_docs_lint.py +++ b/.github/scripts/tests/test_docs_lint.py @@ -145,6 +145,123 @@ def test_archive_buckets_excluded(self): self.assertEqual(docs_lint.check_orphans(), []) +class TestManifestCoverage(unittest.TestCase): + """ + MANIFEST.md coverage. Distinct from the orphan rule on purpose: the + orphan rule asks "is this doc REACHABLE", and every doc in the corpus + is, because docs cross-link freely. A doc reachable only through a + see-also link in a sibling is reachable and unroutable at the same + time, which is why the orphan rule passed on all eight real docs that + had no MANIFEST row. + """ + + TABLE = "| path | purpose | surface | authority |\n| --- | --- | --- | --- |\n" + + def _manifest(self, docs, rows: str): + write(docs / "MANIFEST.md", self.TABLE + rows) + write(docs / "README.md", "index\n") + + def test_doc_without_a_row_is_flagged(self): + with temp_docs() as docs: + self._manifest(docs, "| `a.md` | does a | a-work | BINDING |\n") + write(docs / "a.md", "covered\n") + write(docs / "b.md", "not covered\n") + out = msgs(docs_lint.check_manifest_coverage()) + self.assertIn("MANIFEST.md coverage gap: b.md", out) + self.assertNotIn("coverage gap: a.md", out) + + def test_nested_doc_without_a_row_is_flagged(self): + with temp_docs() as docs: + self._manifest(docs, "| `a.md` | does a | a-work | BINDING |\n") + write(docs / "features" / "deep.md", "no row\n") + self.assertIn("coverage gap: features/deep.md", msgs(docs_lint.check_manifest_coverage())) + + def test_directory_row_covers_everything_beneath_it(self): + with temp_docs() as docs: + self._manifest(docs, "| `reports/` | dated records | none | historical |\n") + write(docs / "reports" / "r.md", "covered by the directory row\n") + write(docs / "reports" / "nested" / "deep.md", "also covered\n") + self.assertEqual(docs_lint.check_manifest_coverage(), []) + + def test_directory_row_does_not_cover_a_sibling_prefix(self): + # `report/` must not be satisfied by a `reports/` row, and vice + # versa — a prefix match on the raw string without the trailing + # slash would conflate them. + with temp_docs() as docs: + self._manifest(docs, "| `reports/` | dated records | none | historical |\n") + write(docs / "reports-archive" / "r.md", "different directory\n") + self.assertIn("coverage gap: reports-archive/r.md", msgs(docs_lint.check_manifest_coverage())) + + def test_row_pointing_at_a_deleted_file_is_flagged(self): + # NOT covered by the generic backtick-path check, which skips any + # candidate without a "/" — so a top-level row was never verified. + with temp_docs() as docs: + self._manifest(docs, "| `gone.md` | deleted last month | nothing | BINDING |\n") + out = msgs(docs_lint.check_manifest_coverage()) + self.assertIn("row `gone.md` does not resolve", out) + self.assertIn("file", out) + + def test_row_pointing_at_a_deleted_directory_is_flagged(self): + with temp_docs() as docs: + self._manifest(docs, "| `gone/` | deleted bucket | nothing | historical |\n") + self.assertIn("does not resolve to a real directory", msgs(docs_lint.check_manifest_coverage())) + + def test_index_roots_are_allowlisted(self): + with temp_docs() as docs: + self._manifest(docs, "| `a.md` | does a | a-work | BINDING |\n") + write(docs / "a.md", "covered\n") + out = msgs(docs_lint.check_manifest_coverage()) + self.assertNotIn("README.md", out) + self.assertNotIn("coverage gap: MANIFEST.md", out) + + def test_allowlisted_doc_is_exempt(self): + saved = docs_lint.MANIFEST_COVERAGE_ALLOWLIST + docs_lint.MANIFEST_COVERAGE_ALLOWLIST = {**saved, "b.md": "fixture reason"} + try: + with temp_docs() as docs: + self._manifest(docs, "| `a.md` | does a | a-work | BINDING |\n") + write(docs / "a.md", "covered\n") + write(docs / "b.md", "allowlisted\n") + self.assertEqual(docs_lint.check_manifest_coverage(), []) + finally: + docs_lint.MANIFEST_COVERAGE_ALLOWLIST = saved + + def test_header_separator_row_is_not_a_path(self): + with temp_docs() as docs: + self._manifest(docs, "| `a.md` | does a | a-work | BINDING |\n") + write(docs / "a.md", "covered\n") + self.assertEqual(docs_lint.check_manifest_coverage(), []) + + def test_row_in_a_fenced_block_is_not_a_row(self): + with temp_docs() as docs: + write(docs / "README.md", "index\n") + write( + docs / "MANIFEST.md", + self.TABLE + + "| `a.md` | does a | a-work | BINDING |\n\n" + + "```\n| `illustrative.md` | example only | none | BINDING |\n```\n", + ) + write(docs / "a.md", "covered\n") + # `illustrative.md` does not exist; if the fenced example were + # read as a row this would report an unresolvable row. + self.assertEqual(docs_lint.check_manifest_coverage(), []) + + def test_unparseable_manifest_is_a_finding_not_a_pass(self): + with temp_docs() as docs: + write(docs / "MANIFEST.md", "# routing map\n\nno table at all\n") + write(docs / "README.md", "index\n") + write(docs / "a.md", "would be uncovered\n") + out = msgs(docs_lint.check_manifest_coverage()) + self.assertIn("no readable table rows", out) + + def test_missing_manifest_is_not_a_finding(self): + # Same defensive shape as the tether rules: a missing doc is the + # path-existence check's business, not this rule's. + with temp_docs() as docs: + write(docs / "a.md", "no manifest exists\n") + self.assertEqual(docs_lint.check_manifest_coverage(), []) + + class TestSupersession(unittest.TestCase): def test_marker_without_pointer_is_flagged(self): with temp_docs() as docs: diff --git a/docs/MANIFEST.md b/docs/MANIFEST.md index bd8013b1d..80afb8c5a 100644 --- a/docs/MANIFEST.md +++ b/docs/MANIFEST.md @@ -15,50 +15,59 @@ governing docs without anyone having to remember which file covers what. - `historical` — point-in-time record (a report, a resolved proposal, a HOLD spec not yet real). Useful for context, never for "what's true now." -| path | purpose | governs-what-surface | authority | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------- | -| `documentation-process.md` | docs/ as source of truth; wiki as a generated view; lint vs. quarterly judgment pass | any docs/ or wiki-publish-pipeline edit | BINDING | -| `pipeline-fidelity-gate.md` | canonical status page for the pipeline-fidelity gate (GitHub issue #154): artifact 1 (parity replay — DONE, owner-accepted 2026-07-22, now closed history) and artifact 2 status (all constants decided, fix merged AND deployed 2026-07-23T01:33Z); the #347-amended §9 fire sequence is COMPLETE end to end, true completion 2026-07-24 — pilot write + `consensus_recompute --apply` plus five further 2026-07-24 corrective/completion passes (lexicon-gate retraction, marker reparse, artist-credit fill, calculator re-pass, a second closer) all DONE, gate FIRED; live resolved-printing count corrected 4→3, artist consensus finding (all cards unresolved, sub-threshold), review queue 134,370; Bug-A full re-scan deferred post-pilot is the one tracked open item; #340 footprint sizing | Stage D full-catalog fire go/no-go | BINDING | -| `overview.md` | what this fork is, how it relates to upstream, orientation for a zero-context reader | none (orientation only) | reference | -| `theory.md` | printing-identification pipeline as candidate-constrained decoding: false-accept bound, soundness mechanisms. Owner-reviewed 2026-07-17 | printing-consensus / tag-consensus design decisions | BINDING | -| `reference/skip-reasons.md` | complete roster of every `CardScanLog.skip_reason` value: what it means, which calculator emits it, whether it is live/retired/report-only; tethered to the `*_SKIP_REASON` declarations in code by `check_skip_reason_roster_tether()` | any new/renamed/retired skip reason, any skip-breakdown or review-queue work | BINDING | -| `reference/vote-weight-matrix.md` | owner-ratified 2026-07-22 vote-weight scenario matrix, raw decision record (PR #325); narrated by `theory.md` §4/§7a | printing-consensus / tag-consensus design decisions | historical | -| `data/` | dated JSON pipeline snapshots + provenance `.md` siblings (chart/infographic generation, homepage-panel's reserved slot); plus per-run reports + resource metrics keyed by `run_id`, `.md`-only, for `pipeline-fidelity-gate.md` §9's fire sequence | none (data record only) | historical | -| `federation-v1.md` | federation verdict exchange format v1 (spec, no implementation yet) | future federation work only | historical | -| `federation/public-export-v1.md` | HOLD spec: publish-first federation export | future federation work only | historical | -| `infrastructure.md` | Docker build/deploy, secrets, CI/CD state, cache aliases (`default` vs `shared`), push policy detail, upstreaming workflow, branch-protection trade-off | any deploy/CI/push/branch-protection change | BINDING | -| `troubleshooting.md` | symptom-first index of recurring blockers | any blocker costing >15min | reference | -| `lessons.md` | terse cross-session lessons + the lessons→gates triage ritual | any recurring "always/never" pattern | reference | -| `upstreaming/conventions.md` | checklist any `upstream-fix-*`/`upstream-feat-*` branch must satisfy before PR-ready | any upstream-bound branch | BINDING | -| `upstreaming/license-provenance.md` | PROTECTED CORE file list + CI license lint + absorption protocol for external code | any external-code intake, any PROTECTED CORE file edit | BINDING | -| `upstreaming/readiness-audit.md` | fork-vs-upstream diff, extraction-ease ladder, branch architecture | upstreaming planning | reference | -| `upstreaming/drift-log.md` | auto-generated weekly: do `upstream-*` branches still apply cleanly | upstreaming maintenance | reference | -| `upstreaming/upstream-wiki-drift.md` | auto-generated weekly: chilli-axe wiki changes (detection only) | upstreaming maintenance | reference | -| `upstreaming/vote-system.md` | vote system as cherry-pick extraction manifest. Accurate through 2026-07-13 only | upstreaming the vote system specifically | historical | -| `upstreaming/extractable-primitives.md` | repo-wide ledger of generic, no-fork-dependency code an outside consumer could lift; HOLD, seeded audit awaiting owner review. `CLEAN` claims checked by a mechanical tether in `docs_lint.py` | judging what's safe to extract/upstream | historical | -| `features/catalog-completion-plan.md` | the harvest/calculate pipeline (Stage 8+): run-cohort safety, phash backfill, evidence recovery, governing image-storage posture. **Live source of truth for anything past Stage 7** | Stage 8+ pipeline work, harvest/calculate/evidence-store code | BINDING | -| `features/printing-tags.md` | "What's That Card?" printing-consensus + vote-queue funnel, backend + frontend, Stages 1-7 | printing-consensus code, vote-queue UI | BINDING | -| `features/moderation.md` | Discord OAuth, Moderators group gate, sensitive-tag queue, card reports | moderation-surface code | BINDING | -| `features/card-dom-api.md` | generic `data-card-*` attributes + `mpc:card-selected` event | card DOM/external-tooling API | BINDING | -| `features/pdf-generator.md` | PDF export tab bug-fix history | PDF export code | reference | -| `features/print-export-page.md` | "Print!" page ordering tabs + flag icons | print-export page code | BINDING | -| `features/google-drive-connect.md` | Drive picker, Local Folder, Save-PDF-to-Drive | Google Drive integration code | BINDING | -| `features/grid-selector.md` | card-version-picker modal + `Card.tsx` image loading/error states | grid-selector / Card.tsx code | BINDING | -| `features/search-operator-syntax.md` | Scryfall-style `artist:`/`border:`/`frame:`/`tag:`/`set:`/`lang:` search-operator syntax: pure parser + fork-coupled wiring seam | search-operator parser + search-bar wiring | BINDING | -| `features/image-cdn.md` | the Worker + R2 bucket image CDN | image-cdn/ Worker code | BINDING | -| `features/local-file-source.md` | backend `LOCAL_FILE` catalog source type | local-file source code | BINDING | -| `features/saved-decks.md` | zero-knowledge accounts + saved decks: crypto design, endpoints, shipped PR-5 share links, PR-6/7 addenda | accounts/saved-decks code | BINDING | -| `features/artist-support-links.md` | zero-crawl link-out to MTG Artist Connection | `ArtistSupportLink.tsx` and its surfaces | BINDING | -| `features/homepage-panel.md` | landing panel, its gating, reserved catalog-stats slot | `HomepagePanel.tsx` | BINDING | -| `features/catalog-stats.md` | Proposal F backend pass 1 (issue #233): the `catalog_stats` compute/warm/cache-only-read aggregate, hourly `warm_catalog_stats` schedule, `GET 1/catalogStats/` — 5 of 7 charts + participation panel; frontend `/stats` page built 2026-07-29 (branch `feat/stats-page-frontend`), transformed from the old `/contributions` page and restored to the top nav | `cardpicker/catalog_stats.py`, `1/catalogStats/`, `frontend/src/pages/stats.tsx`, migration 0094 | BINDING | -| `soak-gate.md` | width-ramp soak gate: the seven per-step criteria (issue #155), thresholds, runbook (`soak_gate_report`), standing rules for widening/halt decisions | width-ramp pipeline work | BINDING | -| `user-guide.md` | end-user guide: search, vote queue, PDF export, saving a project | end-user-facing docs only | reference | -| `self-hosting.md` | standing up your own instance (not this fork's own hosting) | self-hoster-facing docs only | reference | -| `readme-sections.md` | source regions the README-pipeline assembles from | README-generation pipeline | BINDING | -| `wiki-home-intro.md` | wiki homepage intro content | wiki-publish pipeline | BINDING | -| `proposals/` | one HOLD/BUILDING/PARTIAL/SHIPPED spec per lettered proposal — see [`README.md`](README.md)'s own status table | whichever proposal a task implements | historical | -| `reports/` | dated, point-in-time session/agent reports — see [`reports/README.md`](reports/README.md) | none (record only, check its own date before trusting) | historical | -| `audits/` | UI content-accuracy findings (`ui-content-audit.md`, landed via #56, build pass via #64 — Disposition column records what shipped) | UI-content-accuracy work | historical | +| path | purpose | governs-what-surface | authority | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `documentation-process.md` | docs/ as source of truth; wiki as a generated view; lint vs. quarterly judgment pass | any docs/ or wiki-publish-pipeline edit | BINDING | +| `pipeline-fidelity-gate.md` | canonical status page for the pipeline-fidelity gate (GitHub issue #154): artifact 1 (parity replay — DONE, owner-accepted 2026-07-22, now closed history) and artifact 2 status (all constants decided, fix merged AND deployed 2026-07-23T01:33Z); the #347-amended §9 fire sequence is COMPLETE end to end, true completion 2026-07-24 — pilot write + `consensus_recompute --apply` plus five further 2026-07-24 corrective/completion passes (lexicon-gate retraction, marker reparse, artist-credit fill, calculator re-pass, a second closer) all DONE, gate FIRED; live resolved-printing count corrected 4→3, artist consensus finding (all cards unresolved, sub-threshold), review queue 134,370; Bug-A full re-scan deferred post-pilot is the one tracked open item; #340 footprint sizing | Stage D full-catalog fire go/no-go | BINDING | +| `overview.md` | what this fork is, how it relates to upstream, orientation for a zero-context reader | none (orientation only) | reference | +| `theory.md` | printing-identification pipeline as candidate-constrained decoding: false-accept bound, soundness mechanisms. Owner-reviewed 2026-07-17 | printing-consensus / tag-consensus design decisions | BINDING | +| `identification-pipeline.md` | plain-language walkthrough of how a card gets identified as the code runs it TODAY: FIG-1's nine interceptions, Stage C evidence extraction (`run_image_evidence_cohort`), Stage D's g1-g5 join-key calculator (`local_calculate_verdicts`), the set-code-lexicon and collector-line-artist acceptance gates, machine-vote-never-resolves | orientation before printing-identification work (mechanics only; `theory.md` and `pipeline-fidelity-gate.md` are the authorities) | reference | +| `reference/skip-reasons.md` | complete roster of every `CardScanLog.skip_reason` value: what it means, which calculator emits it, whether it is live/retired/report-only; tethered to the `*_SKIP_REASON` declarations in code by `check_skip_reason_roster_tether()` | any new/renamed/retired skip reason, any skip-breakdown or review-queue work | BINDING | +| `reference/vote-weight-matrix.md` | owner-ratified 2026-07-22 vote-weight scenario matrix, raw decision record (PR #325); narrated by `theory.md` §4/§7a | printing-consensus / tag-consensus design decisions | historical | +| `reference/funnel-spec.md` | verbatim durable copy of the owner-ratified 2026-07-22 `/display` left-rail art-picker funnel spec, implemented in PR #329; narrated by `features/grid-selector.md`'s "The art-picker FUNNEL" section, which is the living doc for this surface | art-picker funnel / `/display` context-menu design decisions (raw ruling only) | historical | +| `data/` | dated JSON pipeline snapshots + provenance `.md` siblings (chart/infographic generation, homepage-panel's reserved slot); plus per-run reports + resource metrics keyed by `run_id`, `.md`-only, for `pipeline-fidelity-gate.md` §9's fire sequence | none (data record only) | historical | +| `federation-v1.md` | federation verdict exchange format v1 (spec, no implementation yet) | future federation work only | historical | +| `federation/public-export-v1.md` | HOLD spec: publish-first federation export | future federation work only | historical | +| `infrastructure.md` | Docker build/deploy, secrets, CI/CD state, cache aliases (`default` vs `shared`), push policy detail, upstreaming workflow, branch-protection trade-off | any deploy/CI/push/branch-protection change | BINDING | +| `troubleshooting.md` | symptom-first index of recurring blockers | any blocker costing >15min | reference | +| `lessons.md` | terse cross-session lessons + the lessons→gates triage ritual | any recurring "always/never" pattern | reference | +| `upstreaming/conventions.md` | checklist any `upstream-fix-*`/`upstream-feat-*` branch must satisfy before PR-ready | any upstream-bound branch | BINDING | +| `upstreaming/license-provenance.md` | PROTECTED CORE file list + CI license lint + absorption protocol for external code | any external-code intake, any PROTECTED CORE file edit | BINDING | +| `upstreaming/readiness-audit.md` | fork-vs-upstream diff, extraction-ease ladder, branch architecture | upstreaming planning | reference | +| `upstreaming/drift-log.md` | auto-generated weekly: do `upstream-*` branches still apply cleanly | upstreaming maintenance | reference | +| `upstreaming/upstream-wiki-drift.md` | auto-generated weekly: chilli-axe wiki changes (detection only) | upstreaming maintenance | reference | +| `upstreaming/vote-system.md` | vote system as cherry-pick extraction manifest. Accurate through 2026-07-13 only | upstreaming the vote system specifically | historical | +| `upstreaming/extractable-primitives.md` | repo-wide ledger of generic, no-fork-dependency code an outside consumer could lift; HOLD, seeded audit awaiting owner review. `CLEAN` claims checked by a mechanical tether in `docs_lint.py` | judging what's safe to extract/upstream | historical | +| `upstreaming/drafts/` | one motivation/PR-body draft per prospective `upstream-feat-*` contribution, each carrying its own status banner (prep-only vs. branch cut but PR not opened); the binding checklist for such a branch is `upstreaming/conventions.md`, not these | whichever upstream-bound branch a task cuts or sends | historical | +| `features/catalog-completion-plan.md` | the harvest/calculate pipeline (Stage 8+): run-cohort safety, phash backfill, evidence recovery, governing image-storage posture. **Live source of truth for anything past Stage 7** | Stage 8+ pipeline work, harvest/calculate/evidence-store code | BINDING | +| `features/printing-tags.md` | "What's That Card?" printing-consensus + vote-queue funnel, backend + frontend, Stages 1-7 | printing-consensus code, vote-queue UI | BINDING | +| `features/moderation.md` | Discord OAuth, Moderators group gate, sensitive-tag queue, card reports | moderation-surface code | BINDING | +| `features/card-dom-api.md` | generic `data-card-*` attributes + `mpc:card-selected` event | card DOM/external-tooling API | BINDING | +| `features/pdf-generator.md` | PDF export tab bug-fix history | PDF export code | reference | +| `features/print-export-page.md` | "Print!" page ordering tabs + flag icons | print-export page code | BINDING | +| `features/google-drive-connect.md` | Drive picker, Local Folder, Save-PDF-to-Drive | Google Drive integration code | BINDING | +| `features/grid-selector.md` | card-version-picker modal + `Card.tsx` image loading/error states | grid-selector / Card.tsx code | BINDING | +| `features/search-operator-syntax.md` | Scryfall-style `artist:`/`border:`/`frame:`/`tag:`/`set:`/`lang:` search-operator syntax: pure parser + fork-coupled wiring seam | search-operator parser + search-bar wiring | BINDING | +| `features/image-cdn.md` | the Worker + R2 bucket image CDN | image-cdn/ Worker code | BINDING | +| `features/local-file-source.md` | backend `LOCAL_FILE` catalog source type | local-file source code | BINDING | +| `features/saved-decks.md` | zero-knowledge accounts + saved decks: crypto design, endpoints, shipped PR-5 share links, PR-6/7 addenda | accounts/saved-decks code | BINDING | +| `features/artist-support-links.md` | zero-crawl link-out to MTG Artist Connection | `ArtistSupportLink.tsx` and its surfaces | BINDING | +| `features/homepage-panel.md` | landing panel, its gating, reserved catalog-stats slot | `HomepagePanel.tsx` | BINDING | +| `features/catalog-stats.md` | Proposal F backend pass 1 (issue #233): the `catalog_stats` compute/warm/cache-only-read aggregate, hourly `warm_catalog_stats` schedule, `GET 1/catalogStats/` — 5 of 7 charts + participation panel; frontend `/stats` page built 2026-07-29 (branch `feat/stats-page-frontend`), transformed from the old `/contributions` page and restored to the top nav | `cardpicker/catalog_stats.py`, `1/catalogStats/`, `frontend/src/pages/stats.tsx`, migration 0094 | BINDING | +| `features/bleed-measurement.md` | `ImageEvidence.bleed_diff_mm`: the pure aspect-ratio formula, `measure_bleed_diff_mm` in `cardpicker/local_fallback.py` (PROTECTED CORE, under a function-scoped owner exception), Stage C's `geometry_bleed` block in `image_evidence.py`, migration `0087`, `golden_set.py` expectations | `bleed_diff_mm` producers/consumers, Stage C geometry extraction | BINDING | +| `features/consent-toast.md` | reusable per-action consent toast (issue #204): `frontend/src/features/consent/`'s `consentToast.ts`/`ConsentToast.tsx`/`useConsentToast.tsx`, the `requestConsent({key,...})` promise contract, per-key sessionStorage decisions, the `role="alertdialog"` + `transition={false}` requirement, dismiss-counts-as-decline | any new permission point / consent-gated action, `useConsentToast` call sites | BINDING | +| `features/display-left-rail.md` | the `/display` left rail as shipped 2026-07-23 (PR #352): `ConfidenceElement.tsx` confidence band + `isNoMatch` vote, `SourcesAccordion.tsx` + pinned sources, continuous Select Version grid, unified Frame+Treatment filter, divider/machine-diff fidelity rounds, the `suggestedCanonicalCardConfidence` backend seam | `features/display/` rail code, `SelectVersionResults.tsx` | BINDING | +| `features/foreign-order-resilience.md` | issue #324 Phase 1 as shipped: client-synthesized orphan `CardDocument`s (`common/orphanCard.ts`), the `[mpc:]` import token, `listenerMiddleware.ts`'s invalid-identifier carve-out, direct-lh4 rendering that never touches the image CDN, `OrphanBadge`, shared-deck consent gate; Phase 2 deferrals named | orphan/foreign-identifier handling across editor, `/display` sheet, PDF export, XML import | BINDING | +| `features/stage-e-operations.md` | admin-facing operational truth for Stage E: PASSIVE-vs-BULK modes, the four envelope bars, `EnvelopeTrip`/`resolve_envelope_trip` no-self-resume trip runbook, the streaming dispatch loop (`cardpicker/stage_e_dispatch.py`), two-cursor backlog walk, concurrency cap, ledger conventions, shakedown + `stream_full_catalog` drivers, `rejudge_fallback_channel` | any Stage E streaming run, trip investigation/clearing, or dispatch-loop code change | BINDING | +| `features/theming.md` | `frontend/src/styles/_theme-tokens.scss` as the single palette/radius/spacing/type token source, `styles.scss`'s "every Bootstrap override assigns from a `$theme-*` token" layering, the Tokyo-11 palette, AAA contrast policy, the `contrastAudit.ts`/`ContrastAudit.spec.ts` regression gate, the born-grey and 2026-07-25 residual-grey rounds | any colour/radius/spacing change, retheming, new Bootstrap variable override, fidelity-spec literal sync | BINDING | +| `soak-gate.md` | width-ramp soak gate: the seven per-step criteria (issue #155), thresholds, runbook (`soak_gate_report`), standing rules for widening/halt decisions | width-ramp pipeline work | BINDING | +| `user-guide.md` | end-user guide: search, vote queue, PDF export, saving a project | end-user-facing docs only | reference | +| `self-hosting.md` | standing up your own instance (not this fork's own hosting) | self-hoster-facing docs only | reference | +| `readme-sections.md` | source regions the README-pipeline assembles from | README-generation pipeline | BINDING | +| `wiki-home-intro.md` | wiki homepage intro content | wiki-publish pipeline | BINDING | +| `proposals/` | one HOLD/BUILDING/PARTIAL/SHIPPED spec per lettered proposal — see [`README.md`](README.md)'s own status table | whichever proposal a task implements | historical | +| `reports/` | dated, point-in-time session/agent reports — see [`reports/README.md`](reports/README.md) | none (record only, check its own date before trusting) | historical | +| `audits/` | UI content-accuracy findings (`ui-content-audit.md`, landed via #56, build pass via #64 — Disposition column records what shipped) | UI-content-accuracy work | historical | ## Not yet in this table @@ -70,11 +79,26 @@ reach past `docs/` itself. ## Maintenance -A soft orphan check now exists (`docs_lint.py`'s orphan rule warns when -a `docs/*.md` file is unreachable from `README.md`/`MANIFEST.md`), but a -row here is not yet hard-enforced (deliberately deferred — see the -lessons→gates triage note in `lessons.md`: measure whether a doc actually goes stale -unnoticed before promoting this to a gate). For now: a new `docs/*.md` -file gets a row here in the same PR that adds it, same convention as -every other "edit in place, don't let it rot silently" rule in this -project. +**A row here is now ENFORCED** (`docs_lint.py`'s `manifest-coverage` +rule, 2026-07-29). Every `docs/**/*.md` must have a row, or be covered by +a directory row, or carry a per-entry-justified entry in that script's +`MANIFEST_COVERAGE_ALLOWLIST` — and, in the other direction, every row's +path must resolve to a real file or directory, which nothing checked +before (the generic backtick-path rule skips any candidate without a +`/`, so a top-level row like `theory.md` was never verified to exist at +all). + +This section previously said a row was "not yet hard-enforced +(deliberately deferred — see the lessons→gates triage note in +`lessons.md`: measure whether a doc actually goes stale unnoticed before +promoting this to a gate)". **That measurement came back.** Eight real +docs had no row and no covering directory row, including +`features/stage-e-operations.md` — an operational runbook for Stage E +streaming runs and envelope-trip clearing — and nothing said so, because +nothing was looking. The separate orphan rule passed on every one of +them: they are all reachable by some link from some sibling. Reachable +and unroutable are different properties, and this table exists to +provide the second one. + +The convention is unchanged, only now checked: a new `docs/**/*.md` file +gets a row here in the same PR that adds it. diff --git a/docs/documentation-process.md b/docs/documentation-process.md index 75b4f66b2..d8f075155 100644 --- a/docs/documentation-process.md +++ b/docs/documentation-process.md @@ -293,8 +293,8 @@ subject should have one document or they should at least reference each other"). There is no central decisions register and no label grammar: **a decision lives written out in prose in its own subject doc**, and the subject doc is the source of truth. `docs_lint.py` enforces that model with -four rules (hard-fail as of 2026-07-23) on top of the mechanical link/path -checks: +five rules (hard-fail; the first four flipped 2026-07-23, the fifth added +already-hard 2026-07-29) on top of the mechanical link/path checks: 1. **No new D-number decision labels.** The abolished convention wrote a decision as a bold `D`-number marker or a "decision D-number" phrase (the @@ -327,15 +327,35 @@ checks: direction, so a reader can navigate between them. This is the anti-fragmentation guarantee the "one doc per subject, or they reference each other" ruling asks for, replacing the old shared-label heuristic. - -**Hard-fail (flipped 2026-07-23).** These four rules now print as +5. **MANIFEST coverage** (added 2026-07-29). Every `docs/**/*.md` must have + a row in [`MANIFEST.md`](MANIFEST.md), or be covered by one of its + directory rows, or carry a per-entry-justified entry in `docs_lint.py`'s + `MANIFEST_COVERAGE_ALLOWLIST` (today: the two index roots only). Checked + in the other direction too: every row's path must resolve to a real file + or directory — the generic backtick-path rule skips any candidate + without a `/`, so a top-level row like `theory.md` was never verified to + exist at all. + + **Deliberately separate from rule 2, not a duplicate of it.** The orphan + rule asks "is this doc REACHABLE"; this one asks "is it ROUTABLE by + surface". Every doc in the corpus is reachable, because docs cross-link + freely — so the orphan rule passed on all eight real docs that had no + MANIFEST row, including + [`features/stage-e-operations.md`](features/stage-e-operations.md), an + operational runbook. MANIFEST.md's own maintenance note had deferred + this gate pending evidence that docs go stale unnoticed; the evidence + arrived as those eight docs. + +**Hard-fail (rules 1-4 flipped 2026-07-23).** These rules now print as `::error::` and count toward the exit code, same as the original link/path/tether checks — [`docs-lint.yml`](../.github/workflows/docs-lint.yml)'s `Run docs lint` step runs `python3 .github/scripts/docs_lint.py --strict`. The de-lettering sweep (PR #357) had already left the whole corpus clean -under `--strict` (exit 0) before this flip, so promoting the four rules from +under `--strict` (exit 0) before this flip, so promoting rules 1-4 from warn-only to blocking changed no doc content — only what CI enforces going -forward. `DOCS_LINT_STRICT=1` in the job's env is the equivalent alternate +forward. Rule 5 was added after that flip and so was blocking from its +first commit; it did require doc content (nine new MANIFEST rows) to go +green, which is the point of adding it. `DOCS_LINT_STRICT=1` in the job's env is the equivalent alternate trigger, documented here in case a future workflow edit prefers the env-var form over the CLI flag.