Skip to content

Commit 91e97af

Browse files
fix(pm): fail closed when the tree under test co-edits the generator the provenance exception trusts (#11283)
The #9866 exception's recompute necessarily runs the PR's own scripts/docs-audit/** (the derivation must reflect the PR's docs), so a diff editing the generator and hand-editing the workflow artifact together could construct a splice that byte-equals its own hand edit. In --test, before consulting provenance: any scripts/docs-audit/** path in the submitted list skips the recompute and keeps the path governed with the reason stated. Narrows the ruled exception only — a pure regeneration with an untouched generator lifts exactly as before. Fixes #11084 Co-authored-by: os-zhuang <zhuangjianguo@steedos.com>
1 parent 764dbbc commit 91e97af

1 file changed

Lines changed: 109 additions & 5 deletions

File tree

‎scripts/pm/check-governed-merges.mjs‎

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@
125125
* OTHER governed path still forks the whole PR — 「混合 diff 一条命中即整
126126
* PR 分叉」.
127127
*
128+
* ### The generator co-edit is fenced out (#11084) — a NARROWING, not a widening
129+
*
130+
* Constraint 1 makes the recompute run the tree under test's OWN
131+
* `scripts/docs-audit/**` (it must — the derivation has to reflect the PR's
132+
* own docs). So a PR that edits the generator AND hand-edits the artifact in
133+
* one diff could in principle construct a derivation whose recomputed splice
134+
* byte-equals its hand-edited block: the tree would be certifying itself.
135+
* Cheap fence, `--test` only: if the submitted path list contains ANY
136+
* `scripts/docs-audit/**` path, the recompute is SKIPPED and the path stays
137+
* governed with that reason stated. This only ever moves verdicts toward
138+
* governed — the pure-regeneration path with an untouched generator lifts
139+
* exactly as it did before, and no path that was governed becomes clear.
140+
*
128141
* The exception applies to the `--test` predicate only. The post-merge SWEEP
129142
* still lists a pure-regeneration merge: under-enumeration is the one
130143
* direction the sweep must never be wrong in (#9902), recomputing a generator
@@ -416,6 +429,41 @@ export const GENERATED_SURFACE_EXCEPTIONS = Object.freeze([
416429
}),
417430
]);
418431

432+
/**
433+
* The generator tree the exception's recompute TRUSTS: constraint 1 executes
434+
* this directory's `affected-docs.mjs` and imports its `check-audit-scope.mjs`
435+
* `replaceBlock`, both from the tree under test. A PR that edits anything
436+
* here is editing the instrument the certificate is measured with.
437+
*/
438+
export const TRUSTED_GENERATOR_PREFIX = 'scripts/docs-audit/';
439+
440+
/**
441+
* The #11084 fence, pure so `--self-test` pins both directions offline. Given
442+
* the PR's submitted path list, answer a fail-closed provenance verdict when
443+
* the tree under test also modifies the trusted generator — or `null` when it
444+
* does not, which is the ONLY branch that goes on to recompute.
445+
*
446+
* Direction matters: a `null` answer changes nothing (the recompute runs and
447+
* rules exactly as before), and a non-null answer can only keep a path
448+
* GOVERNED. There is no branch here that lifts anything, so no spelling this
449+
* function fails to recognise can open the fence — recognising a co-edit is
450+
* strictly a tightening, which is why the leading `./` spelling is folded in
451+
* rather than left to `startsWith` alone.
452+
*/
453+
export function generatorCoEditProvenance(paths) {
454+
const coEdited = (Array.isArray(paths) ? paths : []).filter(
455+
(p) => typeof p === 'string' && (p.startsWith(TRUSTED_GENERATOR_PREFIX) || p.startsWith(`./${TRUSTED_GENERATOR_PREFIX}`)),
456+
);
457+
if (coEdited.length === 0) return null;
458+
return {
459+
pureRegeneration: false,
460+
reason:
461+
'the tree under test modifies the generator this exception trusts — the path stays governed ' +
462+
`(co-edited here: ${coEdited.join(', ')}). The recompute would run this PR's own generator, so it ` +
463+
'cannot certify this PR; land the generator change and the artifact regeneration as separate PRs.',
464+
};
465+
}
466+
419467
/**
420468
* The byte-exact provenance verdict, pure so `--self-test` pins all four
421469
* ruled cases offline. Inputs: the file at the PR's merge base
@@ -948,11 +996,20 @@ async function runTestMode(args) {
948996
// stays the zero-git, zero-cost read it always was.
949997
const hitExceptions = GENERATED_SURFACE_EXCEPTIONS.filter((e) => verdict.hitPaths.includes(e.path));
950998
if (hitExceptions.length > 0) {
951-
const rootIdx = args.indexOf('--root');
952-
const root = resolve(rootIdx > -1 && args[rootIdx + 1] ? args[rootIdx + 1] : resolve(scriptDir, '..', '..'));
953999
const provenance = new Map();
954-
for (const exception of hitExceptions) {
955-
provenance.set(exception.path, await recomputeDocsAuditProvenance(root, exception));
1000+
// #11084, BEFORE consulting provenance: the recompute runs the tree under
1001+
// test's own `scripts/docs-audit/**`, so a diff that edits the generator
1002+
// alongside the artifact would be certifying itself. Skip the recompute
1003+
// entirely and fail closed — no git, no generator exec, reason stated.
1004+
const coEdit = generatorCoEditProvenance(paths);
1005+
if (coEdit) {
1006+
for (const exception of hitExceptions) provenance.set(exception.path, coEdit);
1007+
} else {
1008+
const rootIdx = args.indexOf('--root');
1009+
const root = resolve(rootIdx > -1 && args[rootIdx + 1] ? args[rootIdx + 1] : resolve(scriptDir, '..', '..'));
1010+
for (const exception of hitExceptions) {
1011+
provenance.set(exception.path, await recomputeDocsAuditProvenance(root, exception));
1012+
}
9561013
}
9571014
verdict = applyGeneratedExceptions(verdict, provenance);
9581015
}
@@ -1476,6 +1533,53 @@ async function selfTest() {
14761533
siblingVerdict.governed === true && siblingVerdict.exceptions.length === 0, JSON.stringify(siblingVerdict.exceptions));
14771534
assert('a-verdict-that-never-consulted-the-exception-carries-no-exceptions-field', testVerdict(['AGENTS.md']).exceptions === undefined);
14781535

1536+
// ── the generator co-edit fence (#11084), pinned in BOTH directions ──────
1537+
//
1538+
// The fence decides whether `--test` recomputes at all, so a bug in either
1539+
// direction is load-bearing: too loose re-opens the self-certification, too
1540+
// tight would break the ruled pure-regeneration lift. The register's own
1541+
// generator command must live under the fenced prefix — if the generator
1542+
// ever relocates, the fence must follow, and this goes red first.
1543+
assert('the-fence-covers-the-directory-the-registered-generator-actually-runs-from',
1544+
GENERATED_SURFACE_EXCEPTIONS[0].generator.includes(TRUSTED_GENERATOR_PREFIX), GENERATED_SURFACE_EXCEPTIONS[0].generator);
1545+
// Direction A — co-edit: the generator is touched, so no recompute happens
1546+
// and the artifact stays governed, with the stated reason.
1547+
const coEditPaths = [wfPath, 'scripts/docs-audit/affected-docs.mjs'];
1548+
const coEdit = generatorCoEditProvenance(coEditPaths);
1549+
assert('a-generator-co-edit-answers-fail-closed-before-any-recompute',
1550+
coEdit !== null && coEdit.pureRegeneration === false, JSON.stringify(coEdit));
1551+
assert('the-co-edit-reason-states-the-ruled-words-and-names-the-co-edited-file',
1552+
/modifies the generator this exception trusts — the path stays governed/.test(coEdit?.reason ?? '') &&
1553+
(coEdit?.reason ?? '').includes('scripts/docs-audit/affected-docs.mjs'), String(coEdit?.reason));
1554+
// A neutered fence must fail LOUD and READABLE here, not crash the run: the
1555+
// absent answer is itself the finding, so it is asserted, never dereferenced.
1556+
const coEditVerdict = applyGeneratedExceptions(testVerdict(coEditPaths), new Map(coEdit ? [[wfPath, coEdit]] : []));
1557+
assert('a-generator-co-edit-keeps-the-artifact-governed-the-exception-does-not-lift',
1558+
coEditVerdict.governed === true && coEditVerdict.hitPaths.join() === wfPath && coEditVerdict.exceptions[0].pureRegeneration === false,
1559+
JSON.stringify(coEditVerdict.hitPaths));
1560+
const coEditRender = renderTestVerdict(coEditVerdict);
1561+
assert('the-co-edit-render-stays-GOVERNED-and-tells-the-seat-why-it-did-not-lift',
1562+
coEditRender.includes('GOVERNED') && coEditRender.includes('did NOT lift') &&
1563+
coEditRender.includes('modifies the generator this exception trusts'), coEditRender);
1564+
assert('the-dot-slash-spelling-of-a-generator-path-is-fenced-too-recognising-more-only-tightens',
1565+
generatorCoEditProvenance([wfPath, './scripts/docs-audit/check-audit-scope.mjs']) !== null);
1566+
// Direction B — untouched generator: the fence abstains (`null`), the
1567+
// recompute runs exactly as before, and a verified pure regeneration still
1568+
// lifts. This is the narrowing's whole obligation: nothing else moves.
1569+
const regenOnlyPaths = [wfPath, 'content/docs/new-page.mdx'];
1570+
assert('an-untouched-generator-abstains-so-the-recompute-runs-exactly-as-before',
1571+
generatorCoEditProvenance(regenOnlyPaths) === null);
1572+
const regenOnlyVerdict = applyGeneratedExceptions(testVerdict(regenOnlyPaths), verified);
1573+
assert('a-pure-regen-with-an-untouched-generator-still-lifts-and-the-pr-is-not-governed',
1574+
regenOnlyVerdict.governed === false && regenOnlyVerdict.hitPaths.length === 0 && regenOnlyVerdict.exceptions[0].pureRegeneration === true,
1575+
JSON.stringify(regenOnlyVerdict));
1576+
assert('a-docs-only-pr-that-never-hits-the-register-is-untouched-by-the-fence',
1577+
generatorCoEditProvenance(['content/docs/a.mdx']) === null && testVerdict(['content/docs/a.mdx']).governed === false);
1578+
// A prefix on the real directory, not a substring: a sibling directory whose
1579+
// name merely starts the same way is not the trusted generator.
1580+
assert('a-near-miss-sibling-directory-is-not-mistaken-for-the-generator-tree',
1581+
generatorCoEditProvenance(['scripts/docs-auditing/other.mjs']) === null);
1582+
14791583
// The words a seat reads.
14801584
const liftedRender = renderTestVerdict(liftedVerdict);
14811585
assert('a-lifted-render-names-the-exception-the-generator-and-the-recompute',
@@ -1491,7 +1595,7 @@ async function selfTest() {
14911595
for (const failure of failures) console.error(` • ${failure}`);
14921596
process.exit(1);
14931597
}
1494-
console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the replay fixtures, the four-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the --test pre-arm predicate, the generated-artifact provenance exception — the four ruled cases against the generator's own splice, byte-exactness, fail-closed inputs, the untouched mixed-diff rule, single-file-not-a-class, and its render words — the exit table, and the report wording pins).`);
1598+
console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the replay fixtures, the four-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the --test pre-arm predicate, the generated-artifact provenance exception — the four ruled cases against the generator's own splice, byte-exactness, fail-closed inputs, the untouched mixed-diff rule, single-file-not-a-class, the #11084 generator co-edit fence in both directions, and its render words — the exit table, and the report wording pins).`);
14951599
}
14961600

14971601
/** The exit code `--test` would return for a path list — pinned without spawning. */

0 commit comments

Comments
 (0)