diff --git a/.changeset/9081-census-control-names-one-citation.md b/.changeset/9081-census-control-names-one-citation.md new file mode 100644 index 0000000000..1575a934e7 --- /dev/null +++ b/.changeset/9081-census-control-names-one-citation.md @@ -0,0 +1,22 @@ +--- +--- + +Internal tooling only, no package source changed. + +`census:cross-file-line-citations` refused every run on `main` — `exit 1`, +`✗ 1 control(s) failed -- this run is NOT a reading` — because its controls were +addressed by FILE PAIR while `evaluateControls` folded every row matching that +pair into one answer. Two epitaphs in `packages/types/src/crud.ts` address +`packages/plugin-detail/src/index.tsx` with one number between them; the number +rotted, and one unrelated sentence took the whole census down with it. + +A control now names ONE citation: the citing file, the file it cites, and a +SUBJECT phrase out of the citing prose — still by content, still never by a line +number of its own. Zero matches, or more than one, is a failure rather than +something to fold away. The non-firing control also now requires positive +evidence (`resolves`) instead of merely the absence of a false verdict, so a +verdict the census declined to reach can no longer pass it. + +The rotted `crud.ts` address was NOT re-numbered and NOT repaired here: +objectui#8875 clause 4 reserves how an address is repaired, and it stays in the +population where this census reports it. diff --git a/scripts/__tests__/cross-file-line-citation-census.test.ts b/scripts/__tests__/cross-file-line-citation-census.test.ts index 20b305c335..0cec60b4f1 100644 --- a/scripts/__tests__/cross-file-line-citation-census.test.ts +++ b/scripts/__tests__/cross-file-line-citation-census.test.ts @@ -43,6 +43,7 @@ import { inTestTitle, evaluateClassifier, evaluateControls, + finalVerdict, bucketOf, CONTROLS, FALSE_VERDICTS, @@ -317,13 +318,97 @@ describe('the test-name classifier, whose zero is only a reading if it is shown }); }); +type Control = { id: string; from: string; to: string; subject: string; want: string }; +type Scored = { ok: boolean; detail: string }; + +/** A row as the census hands one to `evaluateControls`, for the control `c`. */ +const rowFor = (c: Control, verdict: string, over: Partial> = {}) => ({ + file: c.from, + citedPath: c.to, + line: 10, + citedLine: 20, + text: `* the prose that says ${c.subject} and cites a line`, + verdict, + ...over, +}); + describe('the controls are addressed by CONTENT, and the tree still satisfies them', () => { it('names no line number of its own — a control pinned by line address is the defect', () => { - for (const c of CONTROLS as { from: string; to: string }[]) { - expect(`${c.from} ${c.to}`).not.toMatch(/:\d+/); + for (const c of CONTROLS as Control[]) { + expect(`${c.from} ${c.to} ${c.subject}`).not.toMatch(/:\d+/); + } + }); + + it('gives every control a subject — a FILE PAIR is not a citation', () => { + // REGRESSION objectui#9081: the controls were addressed by file pair alone. + // `packages/types/src/crud.ts` addresses `plugin-detail/src/index.tsx` from + // two epitaphs, so the pair named two rows and never one citation. + for (const c of CONTROLS as Control[]) { + expect(c.subject, `${c.id} has no subject`).toBeTruthy(); } }); + it('is not sunk by a SECOND citation between the same two files', () => { + // REGRESSION objectui#9081: `evaluateControls` required that NO row matching + // the pair be false, so one unrelated rotted sentence refused every run of + // the whole census — for some 580 commits of `main`. + const nonFiring = (CONTROLS as Control[]).find((c) => c.want !== 'false') as Control; + const subject = rowFor(nonFiring, 'resolves'); + const sibling = rowFor(nonFiring, 'drifted', { + line: 400, + text: '* an unrelated sentence citing the same file', + }); + const scored = (evaluateControls([subject, sibling]) as Scored[]) + .find((_, i) => (CONTROLS as Control[])[i].id === nonFiring.id) as Scored; + expect(scored.ok).toBe(true); + }); + + it('does not let a sibling citation satisfy a FIRING control on its behalf', () => { + // The other direction of the same fold, and the quieter one: `some` over the + // pair meant any rotted neighbour reported the instrument as proven while the + // case the control was written for had stopped firing. + const firing = (CONTROLS as Control[]).find((c) => c.want === 'false') as Control; + const subject = rowFor(firing, 'resolves'); + const sibling = rowFor(firing, 'drifted', { + line: 700, + text: '* an unrelated sentence citing the same file', + }); + const [scored] = evaluateControls([subject, sibling]) as Scored[]; + expect(scored.ok).toBe(false); + }); + + it('refuses when its subject names more than one citation', () => { + const firing = (CONTROLS as Control[]).find((c) => c.want === 'false') as Control; + const [scored] = evaluateControls([ + rowFor(firing, 'drifted'), + rowFor(firing, 'drifted', { line: 11 }), + ]) as Scored[]; + expect(scored.ok).toBe(false); + expect(scored.detail).toContain('AMBIGUOUS'); + }); + + it('takes positive evidence only — a verdict the census declined to reach is not a pass', () => { + // objectui#9081: `anchor-absent` is this census refusing to judge. The + // retired `crud.ts` pair carried exactly that on one of its two rows while + // the address was rotted, so `not-false` would have passed on it. + const nonFiring = (CONTROLS as Control[]).find((c) => c.want !== 'false') as Control; + for (const verdict of ['anchor-absent', 'no-anchor', 'drifted']) { + const scored = (evaluateControls([rowFor(nonFiring, verdict)]) as Scored[]) + .find((_, i) => (CONTROLS as Control[])[i].id === nonFiring.id) as Scored; + expect(scored.ok, `${nonFiring.id} passed on ${verdict}`).toBe(false); + } + }); + + it('says so when the pair still carries citations but none of them is the subject', () => { + const firing = (CONTROLS as Control[]).find((c) => c.want === 'false') as Control; + const [scored] = evaluateControls([ + rowFor(firing, 'drifted', { text: '* a sentence that is not this control' }), + ]) as Scored[]; + expect(scored.ok).toBe(false); + expect(scored.detail).toContain('NOT FOUND'); + expect(scored.detail).toContain('none carries this subject'); + }); + it('still reproduces the card off-by-one, verified by content rather than by number', () => { // objectui#8875: `check-doc-component-types.mjs` cites the action vocabulary // at `ActionRunner.ts:112`; that line closes the docblock and `ActionDef` @@ -357,6 +442,42 @@ describe('the controls are addressed by CONTENT, and the tree still satisfies th }); }); +type Verdict = { exit: number; certification: string | null; refusal: string[] | null }; + +describe('a run certifies itself LAST, after every refusal has been consulted', () => { + const held = [{ ok: true }, { ok: true }]; + const verdict = (controls: { ok: boolean }[], populationSize: number) => + finalVerdict({ controls, classifier: held, populationSize }) as Verdict; + + it('refuses an EMPTY population without certifying anything first', () => { + // REGRESSION objectui#9081: the `IS a reading` line was first printed under + // the control check and ABOVE this guard, so a blind run announced a reading + // and then exited 1 on the next line. A grep keying on that string took a + // certificate off a run that had refused — in the one script whose subject + // is that an instrument must not report a reading it did not take. + const v = verdict(held, 0); + expect(v.exit).toBe(1); + expect(v.certification).toBeNull(); + expect(JSON.stringify(v)).not.toContain('IS a reading'); + expect(v.refusal?.join('\n')).toContain('Empty population'); + }); + + it('DOES certify a healthy population — the control leg, so the absence above is a barrier', () => { + const v = verdict(held, 1); + expect(v.exit).toBe(0); + expect(v.refusal).toBeNull(); + expect(v.certification).toContain('IS a reading'); + }); + + it('refuses a failed control without certifying, whatever the population', () => { + const v = verdict([{ ok: true }, { ok: false }], 1274); + expect(v.exit).toBe(1); + expect(v.certification).toBeNull(); + expect(JSON.stringify(v)).not.toContain('IS a reading'); + expect(v.refusal?.join('\n')).toContain('NOT a reading'); + }); +}); + describe('reporting buckets', () => { it('splits packages and apps one level deep, and keeps the root visible', () => { expect(bucketOf('packages/plugin-form/README.md')).toBe('packages/plugin-form'); diff --git a/scripts/cross-file-line-citation-census.mjs b/scripts/cross-file-line-citation-census.mjs index cb47a7425f..5953e57990 100644 --- a/scripts/cross-file-line-citation-census.mjs +++ b/scripts/cross-file-line-citation-census.mjs @@ -696,39 +696,106 @@ export function bucketOf(relPath) { } /** - * The two controls, addressed BY CONTENT -- the citing file and the file it - * cites -- and never by their own line numbers. A control pinned by line - * address would be an instance of the defect this census measures. + * The two controls, addressed BY CONTENT -- the citing file, the file it cites, + * and a SUBJECT phrase lifted out of the citing prose -- and never by their own + * line numbers. A control pinned by line address would be an instance of the + * defect this census measures. + * + * ## Why the subject exists, and why it is the control's identity (objectui#9081) + * + * A FILE PAIR IS NOT A CITATION. Two sentences in one file may address the same + * file, and until objectui#9081 a control was addressed by pair alone while + * `evaluateControls` folded EVERY row matching that pair into one answer. Both + * directions of that fold were wrong, quietly and in opposite ways: + * + * - a NON-FIRING control was sunk by any second citation between the same two + * files, whatever that citation said. That is what happened. Two epitaphs in + * `packages/types/src/crud.ts` address `packages/plugin-detail/src/index.tsx` + * with one number between them, the number rotted, and this census answered + * `exit 1 -- NOT a reading` to every run on `main` for some 580 commits. + * ⭐ The refusal was CORRECT -- the pinned address really had rotted -- but + * no reading could be taken downstream either, which is the outage. + * - a FIRING control could be satisfied by a row that is NOT its subject. A + * rotted sibling citation would report the instrument as proven while the + * case the control was written for had silently stopped firing. That is the + * unearned green this family exists to prevent, and it has no symptom. + * + * ⇒ a control names ONE citation. A subject matching zero rows, or more than + * one, is a FAILURE rather than something to fold away. ⛔ Neither direction is + * tolerant of a false row: this is strictly narrower than the fold it replaced. + * + * How exposed the old shape was is a number this file deliberately does not + * carry: run `--json --list-all` and group `rows` by `file` + `citedPath` to + * re-derive the share of pairs that carry more than one citation. + * + * ⚠️ `want: 'resolves'`, ⛔ NOT `not-false`. `anchor-absent` and `no-anchor` are + * this census DECLINING to judge, so a non-firing control that accepted them + * would pass on a citation whose health it never established -- and one of the + * two retired `crud.ts` rows judged exactly that, with its address rotted, at + * the moment the other one failed. A non-firing control takes positive evidence + * only: the anchor must sit ON the cited line. + * + * ## ⛔ When a control's subject rots, RE-PIN it -- never renumber it + * + * objectui#8875 clause 4 repairs an existing address "by converting it to a + * content anchor, never by moving the number to a different number". A control + * needs a live line address to score at all, so a converted citation leaves no + * row behind and the control has to move to a different citation. ⛔ It may + * never move to the same citation wearing a fresher number -- that is the + * clause, and it is why the `crud.ts` pair below was retired rather than + * re-addressed. Its rot stays in the population, where this census reports it. */ export const CONTROLS = [ { id: 'firing', from: 'scripts/check-doc-component-types.mjs', to: 'packages/core/src/actions/ActionRunner.ts', + subject: 'action vocabulary declared at', want: 'false', why: 'the docblock closes at the cited line and `ActionDef` opens on the next one (objectui#8875)', }, { id: 'non-firing', - from: 'packages/types/src/crud.ts', - to: 'packages/plugin-detail/src/index.tsx', - want: 'not-false', - why: "the cited line is the `ComponentRegistry.register('detail',` call the prose names", + from: 'packages/components/src/__tests__/layout-containers-declare-containment.test.tsx', + to: 'packages/components/src/renderers/layout/page.tsx', + subject: 'module-private, hence the four lines here', + want: 'resolves', + why: 'the cited line declares the `getJsxManifest` the citing docblock says it mirrors', }, ]; +/** How one row reads in a control's detail line. */ +const controlRow = (r) => `${r.file}:${r.line} -> ${r.citedPath}:${r.citedLine} [${r.verdict}]`; + +/** + * Scores each control against the ONE citation its subject names. `want: 'false'` + * accepts any FALSE verdict; every other `want` is the exact verdict required, + * so a control asking for positive evidence cannot be satisfied by a verdict + * this census declined to reach. + */ export function evaluateControls(rows) { return CONTROLS.map((c) => { - const matches = rows.filter((r) => r.file === c.from && r.citedPath === c.to); + const pair = rows.filter((r) => r.file === c.from && r.citedPath === c.to); + const matches = pair.filter((r) => r.text.includes(c.subject)); if (matches.length === 0) { - return { ...c, ok: false, detail: 'NOT FOUND -- the census did not see this citation at all' }; + // Said separately, because "the citation is gone" and "the citation is + // there but no longer says this" are different repairs. + const others = pair.length > 0 + ? ` (${pair.length} citation(s) do run between these two files; none carries this subject)` + : ''; + return { ...c, ok: false, detail: `NOT FOUND -- the census did not see this citation at all${others}` }; + } + if (matches.length > 1) { + return { + ...c, + ok: false, + detail: `AMBIGUOUS -- ${matches.length} citations carry this subject, so it names no single one: ` + + matches.map(controlRow).join('; '), + }; } - const anyFalse = matches.some((r) => FALSE_VERDICTS.has(r.verdict)); - const ok = c.want === 'false' ? anyFalse : !anyFalse; - const detail = matches - .map((r) => `${r.file}:${r.line} -> ${r.citedPath}:${r.citedLine} [${r.verdict}]`) - .join('; '); - return { ...c, ok, detail }; + const [row] = matches; + const ok = c.want === 'false' ? FALSE_VERDICTS.has(row.verdict) : row.verdict === c.want; + return { ...c, ok, detail: controlRow(row) }; }); } @@ -738,6 +805,52 @@ function tally(rows, key) { return [...m].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]))); } +/** + * Whether a run may certify itself, and -- the part worth exporting -- in WHICH + * ORDER its refusals are consulted. + * + * ⚠️ CERTIFICATION IS LAST, and that is a fact this file has already got wrong + * once (objectui#9081). The first cut of the `IS a reading` line printed it + * directly under the control check and ABOVE the empty-population guard, so a + * blind run announced `✓ ... this run IS a reading.`, then printed + * `✗ Empty population ...` and exited 1. A reader -- or a grep keying on that + * string -- took a certificate off a run that had refused. In the one script + * whose whole subject is that an instrument must not report a reading it did + * not take, that is the unearned green arriving through the door marked "say + * the good news out loud". + * + * ⇒ every refusal is consulted first, and a refusing verdict carries NO + * certification string at all rather than a suppressed one: there is nothing + * for a later edit to print by accident. + */ +export function finalVerdict({ controls, classifier, populationSize }) { + const all = [...controls, ...classifier]; + const failed = all.filter((c) => !c.ok); + if (failed.length > 0) { + return { + exit: 1, + certification: null, + refusal: [`\n✗ ${failed.length} of ${all.length} control(s) failed -- this run is NOT a reading.`], + }; + } + if (populationSize === 0) { + return { + exit: 1, + certification: null, + refusal: [ + '\n✗ Empty population. A census that reads nothing because it is blind is', + ' indistinguishable from a clean tree, so this exits non-zero rather than', + ' printing a silent zero.', + ], + }; + } + return { + exit: 0, + refusal: null, + certification: `✓ ${all.length} of ${all.length} control(s) passed -- this run IS a reading.`, + }; +} + function main(argv) { const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); const head = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); @@ -887,18 +1000,17 @@ function main(argv) { } } - const failedControls = [...controls, ...classifier].filter((c) => !c.ok); - if (failedControls.length > 0) { - console.error(`\n✗ ${failedControls.length} control(s) failed -- this run is NOT a reading.`); - return 1; - } - if (population.length === 0) { - console.error('\n✗ Empty population. A census that reads nothing because it is blind is'); - console.error(' indistinguishable from a clean tree, so this exits non-zero rather than'); - console.error(' printing a silent zero.'); - return 1; + const verdict = finalVerdict({ controls, classifier, populationSize: population.length }); + if (verdict.refusal) { + for (const line of verdict.refusal) console.error(line); + return verdict.exit; } - return 0; + // Said out loud rather than left to be inferred from silence: a reader who + // cannot tell "every control held" from "the script died before printing" has + // no certification, only an exit code. ⛔ Printed HERE and nowhere earlier -- + // see `finalVerdict` for why the position is the point. + if (!asJson) console.log(verdict.certification); + return verdict.exit; } if (isEntrypoint(import.meta.url)) {