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+ / m o d i f i e s t h e g e n e r a t o r t h i s e x c e p t i o n t r u s t s — t h e p a t h s t a y s g o v e r n e d / . 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