diff --git a/build/check-doc-drift.mjs b/build/check-doc-drift.mjs index a263a22..e627501 100644 --- a/build/check-doc-drift.mjs +++ b/build/check-doc-drift.mjs @@ -2236,6 +2236,51 @@ for (const rel of trackedMd.filter((p) => RUNNABLE_DOC_PREFIXES.some((pre) => p. }); } +// ── check 23 (F-450 c): the scope-limit canon and the per-pin table agree both ways ── +// doc-lib's CLI_PIN_FACTS carries a `scope` paraphrase per scope-limited list +// command (keyed by catalog id, ONE home, read by report); the canon is prose — +// one `###` subsection per such command under "Scope-limited domains" in +// setup's index-scope-notes.md, headed by the command's canonical path (the +// id with its colons as spaces), which report's `scope.path` points at. A +// table entry with no subsection is a pointer into nothing; a subsection with +// no entry is a limit the report never names. Both are read out of source +// text (check 22's precedent — the build lane may not import the plugin), and +// a reader that no longer matches fails loudly rather than sweeping nothing. +const SCOPE_NOTES_REL = "plugins/gs-superadmin/skills/setup/references/index-scope-notes.md"; +const pinBlock = /export const CLI_PIN_FACTS = Object\.freeze\(\{[\s\S]*?\n\}\);/.exec(read(DOC_LIB_REL)); +const scopeEntryRe = /"([a-z0-9-]+(?::[a-z0-9-]+)+)": Object\.freeze\(\{[^}]*?\bscope: "/g; +const tableScopePaths = pinBlock ? [...pinBlock[0].matchAll(scopeEntryRe)].map((m) => m[1].split(":").join(" ")) : []; +if (!pinBlock || tableScopePaths.length < 1) { + fail(`check 23: could not read the scope entries out of ${DOC_LIB_REL}'s CLI_PIN_FACTS (got ${tableScopePaths.length}) — the table moved or its shape changed; fix the reader, never the sweep`); +} +// The notes are read through the file's one fence grammar (fenceRanges, as +// check 22 does): a fenced example carrying a heading-shaped line is neither +// canon nor a section terminator. The section ends at the next level-1 OR +// level-2 heading (review round: a later `# Appendix` with `###` subsections +// was being read as canon). +const scopeNotesLines = read(SCOPE_NOTES_REL).split(/\r?\n/); +const scopeFenced = fenceRanges(SCOPE_NOTES_REL, scopeNotesLines, "an unreadable fence hides its headings from check 23"); +const scopeInFence = (i) => scopeFenced.some(([a, b]) => i > a && i < b); +const scopeSectionStart = scopeNotesLines.findIndex((l, i) => !scopeInFence(i) && /^## Scope-limited domains\b/.test(l)); +let scopeSectionEnd = scopeNotesLines.length; +if (scopeSectionStart >= 0) for (let i = scopeSectionStart + 1; i < scopeNotesLines.length; i++) if (!scopeInFence(i) && /^#{1,2} /.test(scopeNotesLines[i])) { scopeSectionEnd = i; break; } +const notesScopePaths = scopeSectionStart < 0 ? [] : scopeNotesLines.slice(scopeSectionStart + 1, scopeSectionEnd).filter((l, k) => !scopeInFence(scopeSectionStart + 1 + k) && /^### /.test(l)).map((l) => l.slice(4).trim()); +if (scopeSectionStart < 0) fail(`check 23: ${SCOPE_NOTES_REL} has no "## Scope-limited domains" section — the canon report's scope.path points at is gone (F-450 c)`); +// Both comparison loops run only when BOTH readers succeeded: fail() counts +// rather than throws, and a reader failure followed by the sweep printed one +// spurious "drop the subsection" per canon subsection — the opposite of "fix +// the reader, never the sweep" (review round). +if (tableScopePaths.length && scopeSectionStart >= 0) { + for (const p of tableScopePaths) { + if (!notesScopePaths.includes(p)) + fail(`check 23: doc-lib's CLI_PIN_FACTS carries a scope entry for "${p}" but ${SCOPE_NOTES_REL} has no "### ${p}" subsection under "Scope-limited domains" — report's scope.path would point at nothing; add the canon subsection (F-450 c)`); + } + for (const p of notesScopePaths) { + if (!tableScopePaths.includes(p)) + fail(`check 23: ${SCOPE_NOTES_REL} carries a "### ${p}" subsection under "Scope-limited domains" but doc-lib's CLI_PIN_FACTS has no scope entry for its command — report would never name that limit; add the table entry or drop the subsection (F-450 c)`); + } +} + // The fail() gate sits HERE, after the last check, and must stay last (F-258). // It used to sit immediately after check 12 — with four checks and the pass line // below it — so any fail() raised further down printed its message and then let the @@ -2258,6 +2303,7 @@ const passLine = `${sweep9Read} of ${sweep9.length} files swept for portability-duplicate definitions, ` + `${lcCalls} localeCompare call sites locale-pinned, ` + `check 22: ${runnableLinesSwept} runnable line(s) in ${runnableDocsSwept} skill/template doc(s) name none of the ${laneFolders.length} default domain folders literally, ` + + `check 23: ${tableScopePaths.length} scope-limit entries in CLI_PIN_FACTS match the ${notesScopePaths.length} canon subsections both ways, ` + `${scriptRowsNamed} of ${scriptFiles.length} shipped scripts named in the MAINTAINERS Scripts table (rest are shared libs), ` + `${placeholderFences} placeholder fence(s) in ${pluginRootSkillsSeen.size} skill(s) (setup's pinned link fence; every other skill fence addresses scripts through ${WORKSPACE_LINK}), ` + `${check13Fences} hand-written doc fence(s) free of bash line continuations and ` + diff --git a/build/check-stale-facts.mjs b/build/check-stale-facts.mjs index 92b9e08..eef3d2b 100644 --- a/build/check-stale-facts.mjs +++ b/build/check-stale-facts.mjs @@ -525,6 +525,12 @@ const FACT_CARRIERS = [ // installed package's auth module; the version stamp beside it forces // a re-read of that file when the pin moves. { re: /\/\/ v(\d+\.\d+\.\d+) package's dist\/core\/auth\/index\.js/, expect: meta.cliVersion, what: "auth-death literal audit version" }, + // The per-pin CLI facts table (F-450): what the installed CLI does that + // its catalog does not declare — not-enumerable-bare sublists and scope + // limits — applied only while the catalog carries this exact version. + // The stamp is the table's self-retirement, and this pin forces the hand + // re-verification of every entry at each adoption. + { re: /export const CLI_PIN_FACTS = Object\.freeze\(\{\s*cliVersion: "(\d+\.\d+\.\d+)"/, expect: meta.cliVersion, what: "per-pin CLI facts table version stamp (CLI_PIN_FACTS)" }, ], }, ]; diff --git a/build/test-check-doc-drift.mjs b/build/test-check-doc-drift.mjs index b7b0f7b..4a7ec30 100644 --- a/build/test-check-doc-drift.mjs +++ b/build/test-check-doc-drift.mjs @@ -607,6 +607,53 @@ try { } finally { snap.restore(); } } + // ── check 23 · the scope-limit canon and the per-pin table agree both ways (F-450 c) ─ + { + const NOTES = "plugins/gs-superadmin/skills/setup/references/index-scope-notes.md"; + const DOCLIB = "plugins/gs-superadmin/scripts/doc-lib.mjs"; + const snap = snapshotFiles([at(NOTES), at(DOCLIB)]); + try { + mutate(NOTES, (s) => s.replace("### journey surveys list", "### journey surveys listing")); + const res = run(); + check("check 23a: a table entry whose canon subsection is missing (heading respelled) goes red naming the command and the file", + res.status === 1 && /check 23: .*scope entry for "journey surveys list" but .*index-scope-notes\.md has no "### journey surveys list" subsection/.test(res.stderr) && /"### journey surveys listing" subsection .* has no scope entry/.test(res.stderr), res.stderr); + } finally { snap.restore(); } + try { + mutate(NOTES, (s) => s.replace("## Recovering list-invisible email templates", "### connectors chains\n\nA limit nobody tabled.\n\n## Recovering list-invisible email templates")); + const res = run(); + check("check 23b: a canon subsection with no table entry goes red naming the subsection", + res.status === 1 && /check 23: .*"### connectors chains" subsection under "Scope-limited domains" but doc-lib's CLI_PIN_FACTS has no scope entry/.test(res.stderr), res.stderr); + } finally { snap.restore(); } + try { + mutate(NOTES, (s) => s.replace("## Recovering list-invisible email templates", "## Recovering\n\n### journey surveys list\n\n## Recovering list-invisible email templates").replace("### journey surveys list\n\nHardcodes", "### journey surveys list (moved)\n\nHardcodes")); + const res = run(); + check("check 23c: a subsection outside the Scope-limited section does not count as the canon", + res.status === 1 && /has no "### journey surveys list" subsection/.test(res.stderr), res.stderr); + } finally { snap.restore(); } + try { + mutate(DOCLIB, (s) => s.replace("export const CLI_PIN_FACTS = Object.freeze({", "export const CLI_PIN_FACTS = Object.freeze(Object.assign({}, {")); + const res = run(); + check("check 23d: the table's opener changing shape goes red at the reader, not silently as an empty sweep — and ONLY at the reader (no spurious per-subsection failures)", + res.status === 1 && /check 23: could not read the scope entries out of/.test(res.stderr) && (res.stderr.match(/check 23:/g) ?? []).length === 1, res.stderr); + } finally { snap.restore(); } + try { + mutate(NOTES, (s) => s.replace("### journey surveys list", "```markdown\n### journey fenceprobe list\n```\n\n### journey surveys list")); + const res = run(); + check("check 23f: a heading-shaped line inside a fenced example is neither canon nor a terminator (the file's fence grammar)", res.status === 0, res.stderr); + } finally { snap.restore(); } + try { + mutate(NOTES, (s) => s.replace("## Recovering list-invisible email templates", "# Appendix\n\n### journey appendix list\n\n## Recovering list-invisible email templates")); + const res = run(); + check("check 23g: a level-1 heading ends the section — a ### under a later # Appendix is not canon", res.status === 0, res.stderr); + } finally { snap.restore(); } + try { + mutate(NOTES, (s) => s.replace("## Scope-limited domains — paging cannot fix these", "## Scope limits — paging cannot fix these")); + const res = run(); + check("check 23e: the section heading itself renamed goes red naming the file (the anchor is read, F-439 class)", + res.status === 1 && /check 23: .*index-scope-notes\.md has no "## Scope-limited domains" section/.test(res.stderr), res.stderr); + } finally { snap.restore(); } + } + // ── check 11: guard-residuals anchor drift ───────────────────────────────── { const snap = snapshotFiles([at(PLUGIN_README)]); diff --git a/build/test-check-stale-facts.mjs b/build/test-check-stale-facts.mjs index 3c3389d..a3e5304 100644 --- a/build/test-check-stale-facts.mjs +++ b/build/test-check-stale-facts.mjs @@ -84,8 +84,7 @@ function runRig(files) { // …plus the auth-death literal's audit stamp (F-458), same home. writeFileSync( join(rig, "plugins", "gs-superadmin", "scripts", "doc-lib.mjs"), - `// strict writers went 29 → 0; POST reads 59 → 0\n// read actions in the v${PIN} catalog audited\n` + - `// the sentence every auth-path throw in the\n// v${PIN} package's dist/core/auth/index.js ends with\n`, + docLibStub(PIN), ); // Coverage floor (F-101): the checker requires ≥40 tracked .md files with // some nested — filler carries no version/count/flag tokens. @@ -107,6 +106,35 @@ function runRig(files) { } } +// The doc-lib carrier stub, parameterized on the version its CLI_PIN_FACTS +// stamp cites — the F-450 case below re-writes it with a stale stamp only. +function docLibStub(pinFactsVersion, pin = PIN) { + return ( + `// strict writers went 29 → 0; POST reads 59 → 0\n// read actions in the v${pin} catalog audited\n` + + `// the sentence every auth-path throw in the\n// v${pin} package's dist/core/auth/index.js ends with\n` + + `export const CLI_PIN_FACTS = Object.freeze({\n cliVersion: "${pinFactsVersion}",\n commands: Object.freeze({}),\n});\n` + ); +} + +// ── 0. F-450: the per-pin CLI facts table's version stamp is a carrier ────── +// A table stamped for another pin is not applied by its readers (self- +// retiring), so the stamp must trip here at adoption, by name, while the +// other doc-lib carriers stay green. +{ + // The stamp is a bare version (the const's own literal), so the stale form + // is STALE without its v. + const res = runRig({ "plugins/gs-superadmin/scripts/doc-lib.mjs": docLibStub(STALE.replace(/^v/, "")) }); + ok("stale CLI_PIN_FACTS stamp fails", res.status === 1); + ok("stale CLI_PIN_FACTS failure names the table and the pin", res.stderr.includes("CLI_PIN_FACTS") && res.stderr.includes(PIN)); + ok("the other doc-lib carriers stay green under a stale pin-facts stamp", !res.stderr.includes("read-verb allowlist") && !res.stderr.includes("auth-death")); + // A table that moved or lost its stamp is an unmatched pattern — itself a + // failure (silently unmatched prose is how the F-280 carriers went stale). + const gone = runRig({ "plugins/gs-superadmin/scripts/doc-lib.mjs": docLibStub(PIN).replace("export const CLI_PIN_FACTS", "const PIN_FACTS_MOVED") }); + ok("a missing CLI_PIN_FACTS stamp fails as an unmatched carrier pattern", gone.status === 1 && gone.stderr.includes("CLI_PIN_FACTS") && gone.stderr.includes("no longer pinned")); + const fresh = runRig({ "plugins/gs-superadmin/scripts/doc-lib.mjs": docLibStub(PIN) }); + ok("current CLI_PIN_FACTS stamp passes", fresh.status === 0); +} + // ── 1. Stale citation in bus PROSE: still fails ────────────────────────────── { const res = runRig({ "dev/FEEDBACK.md": `The pin is ${STALE} and that is a claim.\n` }); diff --git a/dev/FEEDBACK.md b/dev/FEEDBACK.md index 940295b..6c79632 100644 --- a/dev/FEEDBACK.md +++ b/dev/FEEDBACK.md @@ -146,8 +146,8 @@ what the gate counts (F-444). The gate refuses both. Sections from F-360 on; the rule also reads the archive. -Under test: dev · hb-20260915-03 · 2026-09-15 -Blind spots (hb-20260915-03): post-merge close-out of PR 20 on dev: nothing new is under test; the Session C1 arms were measured on the sandbox by two tester rounds (both VALIDATION sections CLEARED). Still fixture-only on any tenant: the positive stale-with-doc direction of docPathsUnknown, remove's count, a non-zero docsForUndocumented, the three legacy-stamp refresh lines, setup's Depth / Not-complete lines and the --deep relay, the mark pathless refusal, the reconcile CWD-mismatch and win32 folder-case refusals. Everything Sessions C2 onward bank. +Under test: round-c2-fact-homes · hb-20260915-04 · 2026-09-15 +Blind spots (hb-20260915-04): no tenant in the builder session: the deps-report walk with a tenant override present on one tenant only, the setup Phase 4 relay's rendering of scope lines (no CI executes skill prose), and the sandbox connectors-chains redate rest on the fixtures until C2-V; the per-pin table under a real workspace catalog at another CLI version rests on the synthetic 9.9.9 fixture; the four sublists' runtime errors in CLI_PIN_FACTS are quoted from both tenants' recorded exclusion reasons, not re-run; the scope arm needs NO tenant read (derived from the manifests' recorded list commands, both read locally)