diff --git a/.harness/scripts/ci/46-validate-derived-artifact-order.mjs b/.harness/scripts/ci/46-validate-derived-artifact-order.mjs index c8b05701..247d0061 100644 --- a/.harness/scripts/ci/46-validate-derived-artifact-order.mjs +++ b/.harness/scripts/ci/46-validate-derived-artifact-order.mjs @@ -90,6 +90,41 @@ const FIX = argv.includes('--fix'); * Keep this list SHORT and true. A chain that lists artifacts nobody derives is * worse than no chain, because it turns a real ordering claim into noise. */ +/** + * Artifacts a generator emits ONE PER SOURCE, resolved from disk instead of listed. + * + * `generate-adr-rulesets.mjs` writes one conformance ruleset per ADR — 135 files at + * the time of writing, and one more every time an ADR is authored. Hand-listing them + * would rot on the next ADR and rot silently, since a `writes` entry that stops + * existing fails LOUDLY but one that was never added simply is not checked. Reading + * the directory keeps the declaration true by construction. + * + * Returns repo-relative POSIX paths, sorted, so the order is stable across machines. + */ +function emittedPerSource(dir, suffix) { + const abs = path.join(root, dir); + // ABSENT is legitimate: a synthetic `--root` has no such tree, and the missing + // producer is what fails there. EMPTY is not — a directory that exists and yields + // nothing means the generator's output moved, and returning [] would hand the + // chain a link that verifies zero artifacts while reporting success. That is the + // zero-element scan this repo has been bitten by before (GT-557). + if (!fs.existsSync(abs)) return []; + const found = fs.readdirSync(abs) + .filter((f) => f.endsWith(suffix)) + .sort() + .map((f) => `${dir}/${f}`); + if (found.length === 0) { + fail([ + `${dir} exists but holds no ${suffix} files.`, + 'A link that declares zero artifacts checks nothing and still reports success.', + 'Either the generator stopped writing there, or the path moved — fix the declaration.', + ]); + } + return found; +} + +const ADR_RULESETS = emittedPerSource('src/rulesets/adr/generated', '.rules.json'); + export const CHAIN = [ { name: 'ABAC rego tool sets', @@ -121,6 +156,25 @@ export const CHAIN = [ 'src/sdk/cli/src/commands/api/api.catalog.tool-schemas.generated.ts', ], }, + { + name: 'ADR conformance rulesets', + producer: '.harness/scripts/generate-adr-rulesets.mjs', + checkArgs: ['--check'], + // Its input is the ADR corpus itself: the generator reads every decision record + // and emits one conformance ruleset per ADR, lifting the decision text into the + // rule's `statement`. So editing an ADR's PROSE drifts a generated artifact — + // which is how a one-word correction to ADR-0126 ("Sixteen" -> "Seventeen") turned + // `Validate documentation` red on a PR that touched no ruleset at all. + // + // It sits HERE, before the snapshot, because the snapshot classifies the whole + // ruleset corpus and these files are part of it. That edge was already known and + // written down one link below — "documentation-only moved 129 -> 136 purely + // because seven generated ADR rulesets appeared" — but only in prose: the + // generator was not a link, so nothing replayed it in order and `--fix` could not + // repair it. Declaring it makes the ordering claim machine-checked. + consumes: ['reference/core/architecture/adrs'], + writes: ADR_RULESETS, + }, { name: 'native evaluability snapshot', producer: 'src/rulesets/standards/capture-native-evaluability-snapshot.mjs', @@ -134,6 +188,10 @@ export const CHAIN = [ 'src/packages/core-domain/test/rule-corpus-triage.ts', 'src/packages/core-domain/src/application/validators/rule-evaluability.ts', 'src/packages/core-domain/src/application/validators/evaluators/native-evaluator.ts', + // The corpus half, now DECLARED. Listing these turns the sentence above into an + // assertion: move the ADR link after this one and `validateChainShape` fails + // with "consumes X, which a LATER link writes" instead of quietly reordering. + ...ADR_RULESETS, ], writes: ['src/rulesets/standards/native-evaluability-snapshot.json'], }, diff --git a/.harness/scripts/ci/46-validate-derived-artifact-order.test.mjs b/.harness/scripts/ci/46-validate-derived-artifact-order.test.mjs index 3c698094..31cc6040 100644 --- a/.harness/scripts/ci/46-validate-derived-artifact-order.test.mjs +++ b/.harness/scripts/ci/46-validate-derived-artifact-order.test.mjs @@ -58,7 +58,11 @@ test('the real repository is current and at a fixed point', () => { // both appended so no other link's // reported position moved. Pinned rather than loosened to `\d+` — the count is the point: // a link silently dropped from the chain is an artifact nobody verifies any more. - assert.match(out, /links declared \.+ 8/); + // 8 -> 9 (GT-703 follow-on): the ADR conformance rulesets joined the chain, inserted + // BEFORE the native evaluability snapshot that classifies them. Still pinned, for the + // reason above — and the insertion does move later links' positions, which is why the + // assertions that care use `linkPosition` rather than a typed index. + assert.match(out, /links declared \.+ 9/); }); test('the guard leaves the real tree byte-identical', () => { @@ -104,6 +108,11 @@ const stubProducer = (artifacts) => "for (const f of files) fs.writeFileSync(f, 'stable\\n');\n"; const PRELUDE_STUBS = { + // The ADR conformance rulesets link. In a synthetic root its `writes` resolve to + // NOTHING — the generated directory does not exist there — so the stub writes no + // artifacts; what the fixture has to supply is the producer, or the chain fails on + // "declared producer does not exist" long before the assertion under test. + '.harness/scripts/generate-adr-rulesets.mjs': stubProducer([]), // link 7 — universal phase artifacts (GT-650 / ADR-0125), derived from the artifact registry '.harness/scripts/generate-universal-phase-artifacts.mjs': stubProducer([ 'src/packages/core-domain/src/application/services/universal-phase-artifacts.generated.ts',