From 4d2a82cae445a042940d7d717a6db67348f0860b Mon Sep 17 00:00:00 2001 From: DevBot Date: Fri, 4 Sep 2026 02:16:52 +0800 Subject: [PATCH 1/2] refactor(governance): close M15 decision-test residual and L12 least-privilege docs checker (#1230 B2.8) --- deno.json | 2 +- docs/governance/PROJECT_WORKFLOW.md | 10 + tools/autoflow/__tests__/policy.test.ts | 58 ++++++ tools/autoflow/policy.ts | 2 +- tools/check-audit-citations.ts | 7 +- tools/check-repo-hygiene.test.ts | 83 ++++++++ tools/check-repo-hygiene.ts | 153 +++++++++------ tools/check-static-output-freeze.test.ts | 72 +++++++ tools/check-static-output-freeze.ts | 184 +++++++++--------- .../check-visual-baseline-duplicates.test.ts | 32 +++ tools/check-visual-baseline-duplicates.ts | 66 ++++--- 11 files changed, 485 insertions(+), 184 deletions(-) create mode 100644 tools/check-repo-hygiene.test.ts create mode 100644 tools/check-static-output-freeze.test.ts create mode 100644 tools/check-visual-baseline-duplicates.test.ts diff --git a/deno.json b/deno.json index df4173a37..1602b6c34 100644 --- a/deno.json +++ b/deno.json @@ -76,7 +76,7 @@ "lint:markdown": "deno run -A npm:markdownlint-cli2@0.23.2 \"**/*.md\"", "deno-api:check": "deno run --allow-read --allow-env tools/check-deno-api-free.ts", "text-integrity:check": "deno run --allow-read --allow-run=git tools/check-docs-truth.ts --check=text", - "audit:citations:check": "deno run -A tools/check-audit-citations.ts", + "audit:citations:check": "deno run --allow-read --allow-run=git tools/check-audit-citations.ts", "graph:check": "deno run --allow-read --allow-env tools/check-package-graph.ts", "consumer:local": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/consumer-local.ts", "consumer:packaged": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/consumer-packaged-starter.ts && deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/consumer-local.ts --packaged-import-map-check", diff --git a/docs/governance/PROJECT_WORKFLOW.md b/docs/governance/PROJECT_WORKFLOW.md index 00aa87ca9..0b2f0ad4a 100644 --- a/docs/governance/PROJECT_WORKFLOW.md +++ b/docs/governance/PROJECT_WORKFLOW.md @@ -156,3 +156,13 @@ npm's default `latest` tag. `tools/verify-npm-release.ts` asserts `deno task workflow:check` verifies that the workflow itself remains visible and that the active version plan has the required shape. AutoFlow3 is the single gate and evidence control plane for hooks and CI. + +Gate ownership (#1230): `tools/autoflow/policy.ts` is the machine-readable gate +registry — each gate names exactly one deno task, and each task names its owning +script. Generic toolchain concerns (format, lint, type graph, Markdown +structure, secret content, workflow lint/security) are owned by the pinned OSS +tools themselves and wired as plain CI steps and git-hook calls (ADR-0144), not +as AutoFlow gates. Registry integrity — every gate resolving to an existing +task, and no two gates sharing one command — is asserted in +`tools/autoflow/__tests__/policy.test.ts`, so this document deliberately does +not duplicate the gate list. diff --git a/tools/autoflow/__tests__/policy.test.ts b/tools/autoflow/__tests__/policy.test.ts index bcde69df4..a972bae03 100644 --- a/tools/autoflow/__tests__/policy.test.ts +++ b/tools/autoflow/__tests__/policy.test.ts @@ -388,3 +388,61 @@ Deno.test('release: patch release plan omits publish and GitHub release outside else Deno.env.set('CI', originalCi); } }); + +Deno.test('policy: every gate command resolves to an existing deno task (#1230)', async () => { + // policy.ts is the machine-readable gate registry; this assertion is the + // drift guard that keeps gate -> task -> owning script referentially intact. + const denoJson = JSON.parse(await Deno.readTextFile('deno.json')) as { + tasks: Record; + }; + const gates = allRegisteredGates(); + assert(gates.length > 0); + for (const gate of gates) { + assertEquals( + gate.command.slice(0, 2), + ['deno', 'task'], + `${gate.name} must invoke a deno task`, + ); + const task = gate.command[2]; + assert(task in denoJson.tasks, `${gate.name} references missing deno task "${task}"`); + } +}); + +Deno.test('policy: no two gates share the same command (#1230)', () => { + // One concern, one owner: two gates on the same command would double-run + // the same check and fork its ownership. Parameterized gates (same task, + // different args — e.g. per-browser smoke) are distinct concerns. + const seen = new Map(); + for (const gate of allRegisteredGates()) { + const command = gate.command.join(' '); + assertEquals( + seen.get(command), + undefined, + `gates "${seen.get(command)}" and "${gate.name}" both own command "${command}"`, + ); + seen.set(command, gate.name); + } +}); + +function allRegisteredGates() { + const byName = new Map[number]>(); + for (const tier of ['dev', 'push', 'ci', 'release'] as const) { + // ci/release selection ignores triggers; a maximally-broad changed-path + // set additionally captures dev/push-only triggered gates. + const changedPaths = [ + 'packages/element/src/index.ts', + 'docs/current/VERSION_PLAN.md', + 'www/app/main.tsx', + 'tools/autoflow/policy.ts', + '.github/workflows/autoflow-ci.yml', + 'examples/supabase-cloudflare-starter/deno.json', + 'deno.json', + 'README.md', + 'e2e/starter-smoke/setup.ts', + ]; + for (const gate of selectGates(tier, changedPaths)) { + byName.set(gate.name, gate); + } + } + return [...byName.values()]; +} diff --git a/tools/autoflow/policy.ts b/tools/autoflow/policy.ts index df1d465dd..b3ddeab5d 100644 --- a/tools/autoflow/policy.ts +++ b/tools/autoflow/policy.ts @@ -466,7 +466,7 @@ const GATES: readonly GateDefinition[] = [ triggers: [ /^www\/e2e\/visual-baselines\.spec\.ts$/, /^www\/e2e\/visual-baselines\.spec\.ts-snapshots\//, - /^tools\/check-visual-baseline-duplicates\.ts$/, + /^tools\/check-visual-baseline-duplicates(?:\.test)?\.ts$/, /^deno\.json$/, ], }, diff --git a/tools/check-audit-citations.ts b/tools/check-audit-citations.ts index 784a15411..ef3ce370e 100644 --- a/tools/check-audit-citations.ts +++ b/tools/check-audit-citations.ts @@ -12,9 +12,10 @@ * `packages/adapter-vite/src/internal/ssg/ssg-render.ts`). Ambiguous * basenames (e.g. `index.ts`) are flagged rather than guessed. * - * Usage: - * deno run -A tools/check-audit-citations.ts [files...] [--sha=] - * deno run -A tools/check-audit-citations.ts --write # append a verification appendix + * Usage (least privilege — L12/#1230; the `audit:citations:check` task runs + * without --write, so it needs no write permission): + * deno run --allow-read --allow-run=git tools/check-audit-citations.ts [files...] [--sha=] + * deno run --allow-read --allow-write --allow-run=git tools/check-audit-citations.ts --write * * With no file arguments the tool scans docs/audit/ for reports archived under * the YYYY-MM-DD-* naming convention. Archived reports are verified against diff --git a/tools/check-repo-hygiene.test.ts b/tools/check-repo-hygiene.test.ts new file mode 100644 index 000000000..f6dc18c68 --- /dev/null +++ b/tools/check-repo-hygiene.test.ts @@ -0,0 +1,83 @@ +import { assertEquals } from '@std/assert'; +import { + classifyTrackedBinary, + credentialFileFailure, + isActiveScanFile, + isAllowedRemovedPackageMention, + isAllowedTrackedIgnored, + isForbiddenRootTracked, + isForbiddenUntrackedResidue, + LARGE_BINARY_LIMIT_BYTES, +} from './check-repo-hygiene.ts'; + +// M15 (#1230): the hygiene gate's allow/deny classification is decision logic +// that can turn a failure into a success (a too-broad template carve-out or +// allowlist silently greens a tracked credential or binary). Pin it. + +Deno.test('hygiene: tracked credential files fail, placeholder templates pass', () => { + // Real credentials are always failures. + assertEquals(typeof credentialFileFailure('.env'), 'string'); + assertEquals(typeof credentialFileFailure('packages/app/.env'), 'string'); + assertEquals(typeof credentialFileFailure('.env.production'), 'string'); + assertEquals(typeof credentialFileFailure('certs/server.pem'), 'string'); + assertEquals(typeof credentialFileFailure('.ssh/id_rsa'), 'string'); + assertEquals(typeof credentialFileFailure('id_rsa.pub'), 'string'); + // The template carve-out is exact: only .env.example/.sample/.template. + assertEquals(credentialFileFailure('.env.example'), undefined); + assertEquals(credentialFileFailure('examples/x/.env.sample'), undefined); + assertEquals(credentialFileFailure('.env.template'), undefined); + // Non-credentials pass. + assertEquals(credentialFileFailure('README.md'), undefined); + assertEquals(credentialFileFailure('tools/environment.ts'), undefined); +}); + +Deno.test('hygiene: large tracked binaries fail outside the allowed asset dirs', () => { + const over = LARGE_BINARY_LIMIT_BYTES + 1; + // Over-limit binaries are failures outside the allowlist... + assertEquals(typeof classifyTrackedBinary('packages/element/logo.png', over), 'string'); + // ...and allowed in the intentional asset directories. + assertEquals(classifyTrackedBinary('www/design/mockups/home.png', over), undefined); + assertEquals( + classifyTrackedBinary('www/e2e/visual-baselines.spec.ts-snapshots/home.png', over), + undefined, + ); + assertEquals(classifyTrackedBinary('examples/x/fixtures/banner.mp4', over), undefined); + assertEquals(classifyTrackedBinary('www/public/assets/dragon-hero.mp4', over), undefined); + // Under the limit or non-binary extensions are not this check's concern. + assertEquals(classifyTrackedBinary('packages/element/logo.png', 1024), undefined); + assertEquals(classifyTrackedBinary('packages/element/big.ts', over), undefined); +}); + +Deno.test('hygiene: root generated artifacts are tracked-file failures, nested ones are not', () => { + assertEquals(isForbiddenRootTracked('dist/server/index.js'), true); + assertEquals(isForbiddenRootTracked('playwright-report/index.html'), true); + assertEquals(isForbiddenRootTracked('debug.log'), true); + // Anchored at the repo root: package-level build output is gitignored, not + // this tripwire's concern. + assertEquals(isForbiddenRootTracked('packages/element/dist/mod.js'), false); + assertEquals(isForbiddenRootTracked('packages/element/src/mod.ts'), false); +}); + +Deno.test('hygiene: untracked workflow residue fails, other untracked files pass', () => { + assertEquals(isForbiddenUntrackedResidue('.github/workflows/debug.yml'), true); + assertEquals(isForbiddenUntrackedResidue('hub-submission.json'), true); + assertEquals(isForbiddenUntrackedResidue('notes.md'), false); +}); + +Deno.test('hygiene: only vendored license attributions may be tracked-and-ignored', () => { + assertEquals(isAllowedTrackedIgnored('vendor/jsr.io/@std/fs/LICENSE'), true); + assertEquals(isAllowedTrackedIgnored('vendor/jsr.io/std/LICENSE'), true); + assertEquals(isAllowedTrackedIgnored('vendor/jsr.io/@std/fs/mod.ts'), false); +}); + +Deno.test('hygiene: removed-package mention scan covers active roots only', () => { + assertEquals(isActiveScanFile('deno.json'), true); + assertEquals(isActiveScanFile('packages/element/src/mod.ts'), true); + assertEquals(isActiveScanFile('tools/check-repo-hygiene.ts'), true); + // docs/audit, docs/release and other historical trees are not scanned. + assertEquals(isActiveScanFile('docs/audit/2026-01-01-x.md'), false); + assertEquals(isActiveScanFile('packages/element/README.png'), false); + // The allowlist is exact-path, not substring. + assertEquals(isAllowedRemovedPackageMention('tools/check-repo-hygiene.ts'), true); + assertEquals(isAllowedRemovedPackageMention('tools/check-repo-hygiene-extra.ts'), false); +}); diff --git a/tools/check-repo-hygiene.ts b/tools/check-repo-hygiene.ts index 2b0b11656..ce6674d12 100644 --- a/tools/check-repo-hygiene.ts +++ b/tools/check-repo-hygiene.ts @@ -94,7 +94,7 @@ const forbiddenTrackedSecretFiles = [ // Large tracked binaries: intentional design/e2e/fixture assets are listed; // anything else above 1 MiB should not enter the repository. -const LARGE_BINARY_LIMIT_BYTES = 1024 * 1024; +export const LARGE_BINARY_LIMIT_BYTES = 1024 * 1024; const allowedLargeBinaryDirs = [ /^www\/design\/mockups\//, /^www\/e2e\/visual-baselines\.spec\.ts-snapshots\//, @@ -104,90 +104,115 @@ const allowedLargeBinaryDirs = [ /^www\/public\/assets\/dragon-/, ]; -const failures: Failure[] = []; +export function isForbiddenRootTracked(path: string): boolean { + return forbiddenRootTracked.some((pattern) => pattern.test(path)); +} -function isActiveScanFile(path: string): boolean { - if (!activeScanExtensions.test(path)) return false; - return activeScanRoots.some((root) => path === root || path.startsWith(root)); +export function isForbiddenUntrackedResidue(path: string): boolean { + return forbiddenUntrackedResidue.some((pattern) => pattern.test(path)); } -for (const dir of removedPackageDirs) { - if (await exists(dir)) { - failures.push({ path: dir, message: 'removed package directory is still present' }); - } +export function isAllowedTrackedIgnored(path: string): boolean { + return allowedTrackedIgnoredPaths.some((pattern) => pattern.test(path)); } -for (const path of removedAutoflow2Paths) { - if (await exists(path)) { - failures.push({ path, message: 'AutoFlow2 remnant is still present' }); - } +export function isAllowedRemovedPackageMention(path: string): boolean { + return allowedRemovedPackageMentions.includes(path); } -const files = await gitTrackedFiles(); -for (const file of files) { - if (!(await exists(file))) continue; - if (forbiddenRootTracked.some((pattern) => pattern.test(file))) { - failures.push({ path: file, message: 'generated or archived root artifact is tracked' }); - } +export function isActiveScanFile(path: string): boolean { + if (!activeScanExtensions.test(path)) return false; + return activeScanRoots.some((root) => path === root || path.startsWith(root)); } -for (const file of await gitUntrackedFiles()) { - if (forbiddenUntrackedResidue.some((pattern) => pattern.test(file))) { - failures.push({ path: file, message: 'untracked workflow or root tool residue is present' }); +/** Failure message when `path` is a tracked credential file, else undefined. */ +export function credentialFileFailure(path: string): string | undefined { + if (allowedCredentialTemplates.test(path)) return undefined; + if (forbiddenTrackedSecretFiles.some((pattern) => pattern.test(path))) { + return 'credential file is tracked'; } + return undefined; } -for (const file of await gitTrackedIgnoredFiles()) { - if (allowedTrackedIgnoredPaths.some((pattern) => pattern.test(file))) continue; - failures.push({ path: file, message: 'tracked file is also ignored by .gitignore' }); +/** Failure message when a tracked binary exceeds the size limit outside the allowed dirs. */ +export function classifyTrackedBinary(path: string, size: number): string | undefined { + if (allowedLargeBinaryDirs.some((pattern) => pattern.test(path))) return undefined; + if (!/\.(?:png|jpe?g|gif|webp|pdf|zip|woff2?|mp4|mov|ico|icns)$/i.test(path)) return undefined; + if (size <= LARGE_BINARY_LIMIT_BYTES) return undefined; + return `tracked binary exceeds ${LARGE_BINARY_LIMIT_BYTES / 1024} KiB (${size} bytes)`; } -for (const file of files.filter(isActiveScanFile)) { - if (allowedRemovedPackageMentions.includes(file)) continue; - let text = ''; - try { - text = await Deno.readTextFile(file); - } catch { - continue; +async function main(): Promise { + const failures: Failure[] = []; + + for (const dir of removedPackageDirs) { + if (await exists(dir)) { + failures.push({ path: dir, message: 'removed package directory is still present' }); + } } - for (const packageName of removedPackageNames) { - if (text.includes(packageName)) { - failures.push({ - path: file, - message: `active file references removed package ${packageName}`, - }); + + for (const path of removedAutoflow2Paths) { + if (await exists(path)) { + failures.push({ path, message: 'AutoFlow2 remnant is still present' }); + } + } + + const files = await gitTrackedFiles(); + for (const file of files) { + if (!(await exists(file))) continue; + if (isForbiddenRootTracked(file)) { + failures.push({ path: file, message: 'generated or archived root artifact is tracked' }); } } -} -for (const file of files) { - if ( - !allowedCredentialTemplates.test(file) && - forbiddenTrackedSecretFiles.some((pattern) => pattern.test(file)) - ) { - failures.push({ path: file, message: 'credential file is tracked' }); + for (const file of await gitUntrackedFiles()) { + if (isForbiddenUntrackedResidue(file)) { + failures.push({ path: file, message: 'untracked workflow or root tool residue is present' }); + } } - if (allowedLargeBinaryDirs.some((pattern) => pattern.test(file))) continue; - if (!/\.(?:png|jpe?g|gif|webp|pdf|zip|woff2?|mp4|mov|ico|icns)$/i.test(file)) continue; - try { - const stat = await Deno.stat(file); - if (stat.size > LARGE_BINARY_LIMIT_BYTES) { - failures.push({ - path: file, - message: `tracked binary exceeds ${ - LARGE_BINARY_LIMIT_BYTES / 1024 - } KiB (${stat.size} bytes)`, - }); + + for (const file of await gitTrackedIgnoredFiles()) { + if (isAllowedTrackedIgnored(file)) continue; + failures.push({ path: file, message: 'tracked file is also ignored by .gitignore' }); + } + + for (const file of files.filter(isActiveScanFile)) { + if (isAllowedRemovedPackageMention(file)) continue; + let text = ''; + try { + text = await Deno.readTextFile(file); + } catch { + continue; + } + for (const packageName of removedPackageNames) { + if (text.includes(packageName)) { + failures.push({ + path: file, + message: `active file references removed package ${packageName}`, + }); + } } - } catch { - continue; } -} -if (failures.length > 0) { - console.error('Repo hygiene check failed:'); - for (const failure of failures) console.error(`- ${failure.path}: ${failure.message}`); - Deno.exit(1); + for (const file of files) { + const credentialFailure = credentialFileFailure(file); + if (credentialFailure) failures.push({ path: file, message: credentialFailure }); + try { + const stat = await Deno.stat(file); + const binaryFailure = classifyTrackedBinary(file, stat.size); + if (binaryFailure) failures.push({ path: file, message: binaryFailure }); + } catch { + continue; + } + } + + if (failures.length > 0) { + console.error('Repo hygiene check failed:'); + for (const failure of failures) console.error(`- ${failure.path}: ${failure.message}`); + Deno.exit(1); + } + + console.log('Repo hygiene check passed.'); } -console.log('Repo hygiene check passed.'); +if (import.meta.main) await main(); diff --git a/tools/check-static-output-freeze.test.ts b/tools/check-static-output-freeze.test.ts new file mode 100644 index 000000000..756817d54 --- /dev/null +++ b/tools/check-static-output-freeze.test.ts @@ -0,0 +1,72 @@ +import { assertEquals } from '@std/assert'; +import { diffSnapshots, normalize, parseArgs } from './check-static-output-freeze.ts'; + +// M15 (#1230): the freeze gate's normalizers decide which byte differences +// are masked before comparison — logic that can turn a real failure into a +// success. Pin the masking surface and the diff verdicts. + +const enc = (text: string) => new TextEncoder().encode(text); +const dec = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + +Deno.test('freeze args: defaults, flags and values parse distinctly', () => { + assertEquals(parseArgs([]), { baseline: 'v0.41.2', selfCheck: false }); + assertEquals(parseArgs(['--self-check']), { baseline: 'v0.41.2', selfCheck: true }); + assertEquals(parseArgs(['--baseline', 'v0.43.0']), { baseline: 'v0.43.0', selfCheck: false }); + // A trailing flag without a value must not swallow the next flag as a value. + assertEquals(parseArgs(['--baseline', '--self-check']), { + baseline: 'v0.41.2', + selfCheck: true, + }); +}); + +Deno.test('freeze normalize: island manifests mask only builtAt', () => { + const path = 'island-manifests/home.json'; + const a = normalize(path, enc(JSON.stringify({ builtAt: '2026-01-01', islands: ['x'] }))); + const b = normalize(path, enc(JSON.stringify({ builtAt: '2026-09-04', islands: ['x'] }))); + assertEquals(dec(a), dec(b)); + assertEquals(dec(a).includes('builtAt'), false); + // Any other field difference survives masking. + const c = normalize(path, enc(JSON.stringify({ builtAt: '2026-01-01', islands: ['y'] }))); + assertEquals(dec(a) === dec(c), false); +}); + +Deno.test('freeze normalize: pagefind entry canonicalizes hash, language order and set ordering', () => { + const path = 'pagefind/pagefind-entry.json'; + const a = normalize( + path, + enc(JSON.stringify({ + version: 1, + languages: { zh: { hash: 'h1', pages: 3 }, en: { hash: 'h2', pages: 5 } }, + include_characters: [98, 97], + })), + ); + const b = normalize( + path, + enc(JSON.stringify({ + version: 1, + languages: { en: { hash: 'CHANGED', pages: 5 }, zh: { hash: 'ALSO-CHANGED', pages: 3 } }, + include_characters: [97, 98], + })), + ); + assertEquals(dec(a), dec(b)); + assertEquals(dec(a).includes('hash'), false); +}); + +Deno.test('freeze normalize: non-normalized paths pass bytes through untouched', () => { + const bytes = enc('builtAt stays in ordinary HTML'); + assertEquals(normalize('index.html', bytes), bytes); + assertEquals(normalize('assets/chunk-abc123.js', bytes), bytes); +}); + +Deno.test('freeze diff: identical, content-differing and one-sided snapshots are distinguished', () => { + const a = new Map([['index.html', enc('')], ['app.js', enc('1')]]); + const b = new Map([['index.html', enc('')], ['app.js', enc('1')]]); + assertEquals(diffSnapshots(a, b, 'a', 'b'), []); + + const changed = new Map([['index.html', enc('')], ['app.js', enc('2')]]); + assertEquals(diffSnapshots(a, changed, 'a', 'b'), ['content differs: app.js (a: 1B, b: 1B)']); + + const missing = new Map([['index.html', enc('')]]); + assertEquals(diffSnapshots(a, missing, 'a', 'b'), ['only in a: app.js']); + assertEquals(diffSnapshots(missing, a, 'a', 'b'), ['only in b: app.js']); +}); diff --git a/tools/check-static-output-freeze.ts b/tools/check-static-output-freeze.ts index 31b06771c..710e2eb31 100644 --- a/tools/check-static-output-freeze.ts +++ b/tools/check-static-output-freeze.ts @@ -96,7 +96,7 @@ const NORMALIZERS: Array<{ match: RegExp; description: string; apply: (text: str }, ]; -function normalize(relPath: string, bytes: Uint8Array): Uint8Array { +export function normalize(relPath: string, bytes: Uint8Array): Uint8Array { for (const normalizer of NORMALIZERS) { if (normalizer.match.test(relPath)) { const text = new TextDecoder().decode(bytes); @@ -108,14 +108,14 @@ function normalize(relPath: string, bytes: Uint8Array): Uint8Array { const SITE = { dir: 'www', outDir: 'www/dist', buildTask: 'build' } as const; -function parseArgs(): { baseline: string; selfCheck: boolean } { +export function parseArgs(argv: string[]): { baseline: string; selfCheck: boolean } { const args: Record = {}; const flags = new Set(); - for (let i = 0; i < Deno.args.length; i++) { - const arg = Deno.args[i]; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; if (arg.startsWith('--')) { const key = arg.slice(2); - const next = Deno.args[i + 1]; + const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--')) { args[key] = next; i++; @@ -166,7 +166,7 @@ async function buildAndSnapshot(root: string, site: { outDir: string; buildTask: } /** Byte-compare two snapshots; returns a list of human-readable diffs. */ -function diffSnapshots(a: Snapshot, b: Snapshot, labelA: string, labelB: string): string[] { +export function diffSnapshots(a: Snapshot, b: Snapshot, labelA: string, labelB: string): string[] { const diffs: string[] = []; for (const [path, bytesA] of a) { const bytesB = b.get(path); @@ -189,99 +189,105 @@ function fail(message: string): never { Deno.exit(1); } -const { baseline, selfCheck } = parseArgs(); +async function main(): Promise { + const { baseline, selfCheck } = parseArgs(Deno.args); -const root = Deno.cwd(); + const root = Deno.cwd(); -// Phase 1: determinism self-check — build the current tree twice. -for (const n of NORMALIZERS) console.log(`static-output-freeze: normalizing — ${n.description}`); -console.log('static-output-freeze: building current tree (www) — run 1/2'); -const run1 = await buildAndSnapshot(root, SITE); -if (run1.error) fail(`current-tree build (run 1) failed:\n${run1.error}`); -console.log('static-output-freeze: building current tree — run 2/2'); -const run2 = await buildAndSnapshot(root, SITE); -if (run2.error) fail(`current-tree build (run 2) failed:\n${run2.error}`); + // Phase 1: determinism self-check — build the current tree twice. + for (const n of NORMALIZERS) console.log(`static-output-freeze: normalizing — ${n.description}`); + console.log('static-output-freeze: building current tree (www) — run 1/2'); + const run1 = await buildAndSnapshot(root, SITE); + if (run1.error) fail(`current-tree build (run 1) failed:\n${run1.error}`); + console.log('static-output-freeze: building current tree — run 2/2'); + const run2 = await buildAndSnapshot(root, SITE); + if (run2.error) fail(`current-tree build (run 2) failed:\n${run2.error}`); -const selfDiffs = diffSnapshots(run1.snapshot!, run2.snapshot!, 'run 1', 'run 2'); -if (selfDiffs.length > 0) { - console.error( - `static-output-freeze: the current build is NOT self-deterministic — ${selfDiffs.length} file(s) differ across two runs:`, - ); - for (const d of selfDiffs.slice(0, 50)) console.error(` ${d}`); - fail( - 'freeze proof against a baseline is meaningless until the build is deterministic. ' + - 'Fix the nondeterminism (embedded timestamps, unstable ordering) or add normalization here.', - ); -} -console.log( - `static-output-freeze: determinism OK (${run1.snapshot!.size} files byte-identical across runs)`, -); - -if (selfCheck) { - console.log('static-output-freeze: PASS (self-check only)'); - Deno.exit(0); -} - -// Phase 2: baseline worktree build + comparison. -const tmp = await Deno.makeTempDir({ prefix: 'static-output-freeze-' }); -const worktreeDir = `${tmp}/baseline`; -try { - const add = await run(['git', 'worktree', 'add', '--detach', worktreeDir, baseline], root); - if (!add.ok) { - fail( - `could not create worktree of '${baseline}':\n${tail(add.output)}\n` + - `Reproduce: git worktree add --detach ${baseline}`, + const selfDiffs = diffSnapshots(run1.snapshot!, run2.snapshot!, 'run 1', 'run 2'); + if (selfDiffs.length > 0) { + console.error( + `static-output-freeze: the current build is NOT self-deterministic — ${selfDiffs.length} file(s) differ across two runs:`, ); - } - - // node_modules/ is gitignored: symlink it from the current tree so the - // baseline build resolves npm dependencies offline. vendor/ is partially - // tracked (license attribution), so it already exists in the worktree — - // merge-copy the current tree's vendored sources over it instead. - try { - await Deno.symlink(`${root}/node_modules`, `${worktreeDir}/node_modules`); - } catch (err) { + for (const d of selfDiffs.slice(0, 50)) console.error(` ${d}`); fail( - `could not symlink node_modules into the baseline worktree: ${err}\n` + - `Manual fallback: cd && deno install (needs network).`, + 'freeze proof against a baseline is meaningless until the build is deterministic. ' + + 'Fix the nondeterminism (embedded timestamps, unstable ordering) or add normalization here.', ); } - const vendorCopy = await run(['cp', '-R', `${root}/vendor/`, `${worktreeDir}/vendor/`], root); - if (!vendorCopy.ok) { - fail(`could not copy vendor/ into the baseline worktree:\n${tail(vendorCopy.output)}`); - } + console.log( + `static-output-freeze: determinism OK (${ + run1.snapshot!.size + } files byte-identical across runs)`, + ); - console.log(`static-output-freeze: building baseline ${baseline} in ${worktreeDir}`); - const base = await buildAndSnapshot(worktreeDir, SITE); - if (base.error) { - fail( - `baseline build failed at '${baseline}' (environmental — old toolchain or missing deps):\n${base.error}\n` + - `Reproduce: cd && deno task build. ` + - `Until this is fixed, gate on: deno task check:static-output-freeze -- --self-check`, - ); + if (selfCheck) { + console.log('static-output-freeze: PASS (self-check only)'); + Deno.exit(0); } - const diffs = diffSnapshots(base.snapshot!, run2.snapshot!, baseline, 'current'); - if (diffs.length > 0) { - console.error( - `static-output-freeze: ${diffs.length} file(s) differ between ${baseline} and the current tree:`, - ); - for (const d of diffs.slice(0, 50)) console.error(` ${d}`); - if (diffs.length > 50) console.error(` ... and ${diffs.length - 50} more`); - fail(`static output is not byte-identical to ${baseline}`); - } - console.log( - `static-output-freeze: PASS — ${ - base.snapshot!.size - } files byte-identical between ${baseline} and current tree`, - ); -} finally { - // Best-effort cleanup; report but do not mask the result. - const remove = await run(['git', 'worktree', 'remove', '--force', worktreeDir], root); - if (!remove.ok) { - console.error( - `static-output-freeze: warning — worktree cleanup failed: ${tail(remove.output, 5)}`, + // Phase 2: baseline worktree build + comparison. + const tmp = await Deno.makeTempDir({ prefix: 'static-output-freeze-' }); + const worktreeDir = `${tmp}/baseline`; + try { + const add = await run(['git', 'worktree', 'add', '--detach', worktreeDir, baseline], root); + if (!add.ok) { + fail( + `could not create worktree of '${baseline}':\n${tail(add.output)}\n` + + `Reproduce: git worktree add --detach ${baseline}`, + ); + } + + // node_modules/ is gitignored: symlink it from the current tree so the + // baseline build resolves npm dependencies offline. vendor/ is partially + // tracked (license attribution), so it already exists in the worktree — + // merge-copy the current tree's vendored sources over it instead. + try { + await Deno.symlink(`${root}/node_modules`, `${worktreeDir}/node_modules`); + } catch (err) { + fail( + `could not symlink node_modules into the baseline worktree: ${err}\n` + + `Manual fallback: cd && deno install (needs network).`, + ); + } + const vendorCopy = await run(['cp', '-R', `${root}/vendor/`, `${worktreeDir}/vendor/`], root); + if (!vendorCopy.ok) { + fail(`could not copy vendor/ into the baseline worktree:\n${tail(vendorCopy.output)}`); + } + + console.log(`static-output-freeze: building baseline ${baseline} in ${worktreeDir}`); + const base = await buildAndSnapshot(worktreeDir, SITE); + if (base.error) { + fail( + `baseline build failed at '${baseline}' (environmental — old toolchain or missing deps):\n${base.error}\n` + + `Reproduce: cd && deno task build. ` + + `Until this is fixed, gate on: deno task check:static-output-freeze -- --self-check`, + ); + } + + const diffs = diffSnapshots(base.snapshot!, run2.snapshot!, baseline, 'current'); + if (diffs.length > 0) { + console.error( + `static-output-freeze: ${diffs.length} file(s) differ between ${baseline} and the current tree:`, + ); + for (const d of diffs.slice(0, 50)) console.error(` ${d}`); + if (diffs.length > 50) console.error(` ... and ${diffs.length - 50} more`); + fail(`static output is not byte-identical to ${baseline}`); + } + console.log( + `static-output-freeze: PASS — ${ + base.snapshot!.size + } files byte-identical between ${baseline} and current tree`, ); + } finally { + // Best-effort cleanup; report but do not mask the result. + const remove = await run(['git', 'worktree', 'remove', '--force', worktreeDir], root); + if (!remove.ok) { + console.error( + `static-output-freeze: warning — worktree cleanup failed: ${tail(remove.output, 5)}`, + ); + } + await Deno.remove(tmp, { recursive: true }).catch(() => {}); } - await Deno.remove(tmp, { recursive: true }).catch(() => {}); } + +if (import.meta.main) await main(); diff --git a/tools/check-visual-baseline-duplicates.test.ts b/tools/check-visual-baseline-duplicates.test.ts new file mode 100644 index 000000000..03981e63d --- /dev/null +++ b/tools/check-visual-baseline-duplicates.test.ts @@ -0,0 +1,32 @@ +import { assertEquals } from '@std/assert'; +import { findDuplicateGroups } from './check-visual-baseline-duplicates.ts'; + +// M15 (#1230): duplicate grouping is the pass/fail decision of the +// check:visual-baselines gate — a grouping bug would turn a real duplicate +// (failure) into a pass. + +Deno.test('visual baselines: exact-hash duplicates group, distinct content does not', () => { + const groups = findDuplicateGroups([ + { name: 'b.png', bytes: 10, hash: 'aaa' }, + { name: 'a.png', bytes: 20, hash: 'aaa' }, + { name: 'c.png', bytes: 30, hash: 'bbb' }, + ]); + assertEquals(groups.length, 1); + // Sorted by name inside the group, independent of input order. + assertEquals(groups[0].map((baseline) => baseline.name), ['a.png', 'b.png']); +}); + +Deno.test('visual baselines: same byte length with different hashes is not a duplicate', () => { + assertEquals( + findDuplicateGroups([ + { name: 'a.png', bytes: 10, hash: 'aaa' }, + { name: 'b.png', bytes: 10, hash: 'bbb' }, + ]), + [], + ); +}); + +Deno.test('visual baselines: empty and singleton inputs pass', () => { + assertEquals(findDuplicateGroups([]), []); + assertEquals(findDuplicateGroups([{ name: 'a.png', bytes: 1, hash: 'x' }]), []); +}); diff --git a/tools/check-visual-baseline-duplicates.ts b/tools/check-visual-baseline-duplicates.ts index 6468e3317..a4e9d484b 100644 --- a/tools/check-visual-baseline-duplicates.ts +++ b/tools/check-visual-baseline-duplicates.ts @@ -5,42 +5,56 @@ import { basename, dirname, fromFileUrl, join } from '@std/path'; const repoRoot = dirname(dirname(fromFileUrl(import.meta.url))); const baselineDir = join(repoRoot, 'www', 'e2e', 'visual-baselines.spec.ts-snapshots'); -interface Baseline { +export interface Baseline { name: string; bytes: number; hash: string; } +/** + * The gate's decision logic (#1230 M15): group baselines by content hash and + * return the groups holding more than one file. Sorted by name so the verdict + * is independent of directory iteration order. + */ +export function findDuplicateGroups(baselines: Baseline[]): Baseline[][] { + const sorted = [...baselines].sort((left, right) => left.name.localeCompare(right.name)); + const byHash = Map.groupBy(sorted, (baseline) => baseline.hash); + return [...byHash.values()].filter((group) => group.length > 1); +} + async function digest(path: string): Promise { const bytes = await Deno.readFile(path); const digest = await crypto.subtle.digest('SHA-256', bytes); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); } -const baselines: Baseline[] = []; -for await (const entry of Deno.readDir(baselineDir)) { - if (!entry.isFile || !entry.name.endsWith('.png')) continue; - const path = join(baselineDir, entry.name); - const stat = await Deno.stat(path); - baselines.push({ name: basename(path), bytes: stat.size, hash: await digest(path) }); +async function main(): Promise { + const baselines: Baseline[] = []; + for await (const entry of Deno.readDir(baselineDir)) { + if (!entry.isFile || !entry.name.endsWith('.png')) continue; + const path = join(baselineDir, entry.name); + const stat = await Deno.stat(path); + baselines.push({ name: basename(path), bytes: stat.size, hash: await digest(path) }); + } + + const unique = new Set(baselines.map((baseline) => baseline.hash)).size; + const duplicateGroups = findDuplicateGroups(baselines); + const duplicateBytes = duplicateGroups.reduce( + (total, group) => total + group.slice(1).reduce((sum, baseline) => sum + baseline.bytes, 0), + 0, + ); + + console.log( + `Visual baselines: count=${baselines.length} unique=${unique} ` + + `duplicateGroups=${duplicateGroups.length} duplicateBytes=${duplicateBytes}`, + ); + + if (duplicateGroups.length > 0) { + const details = duplicateGroups + .map((group) => ` ${group.map((baseline) => baseline.name).join(', ')}`) + .join('\n'); + throw new Error(`Unexplained exact-duplicate visual baselines:\n${details}`); + } } -baselines.sort((left, right) => left.name.localeCompare(right.name)); -const byHash = Map.groupBy(baselines, (baseline) => baseline.hash); -const duplicateGroups = [...byHash.values()].filter((group) => group.length > 1); -const duplicateBytes = duplicateGroups.reduce( - (total, group) => total + group.slice(1).reduce((sum, baseline) => sum + baseline.bytes, 0), - 0, -); - -console.log( - `Visual baselines: count=${baselines.length} unique=${byHash.size} ` + - `duplicateGroups=${duplicateGroups.length} duplicateBytes=${duplicateBytes}`, -); - -if (duplicateGroups.length > 0) { - const details = duplicateGroups - .map((group) => ` ${group.map((baseline) => baseline.name).join(', ')}`) - .join('\n'); - throw new Error(`Unexplained exact-duplicate visual baselines:\n${details}`); -} +if (import.meta.main) await main(); From 0dc9c1e5b99c7ec9e9fd7098ee82201f0543d967 Mon Sep 17 00:00:00 2001 From: DevBot Date: Fri, 4 Sep 2026 02:22:01 +0800 Subject: [PATCH 2/2] feat(governance): gate release-line truth in ci/release tiers (#1230 B2.8 thinker follow-up) --- deno.json | 3 ++- tools/autoflow/__tests__/policy.test.ts | 13 +++++++++++++ tools/autoflow/policy.ts | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/deno.json b/deno.json index 1602b6c34..ac60e19e2 100644 --- a/deno.json +++ b/deno.json @@ -55,9 +55,10 @@ "docs:check-claims": "deno run --allow-read tools/check-docs-truth.ts --check=claims", "docs:check-recipe-parity": "deno run --allow-read tools/check-supabase-recipe-parity.ts", "release:evidence:check": "deno run --allow-read --allow-run=git tools/check-docs-truth.ts --check=evidence", + "release:truth:check": "deno run --allow-read tools/check-release-truth.ts", "release:state-machine:check": "deno run --allow-read --allow-run=git tools/check-release-state-machine.ts", "docs:check-version-anchors": "deno run --allow-read tools/check-version-anchors.ts", - "docs:truth": "deno run --allow-read --allow-run=git tools/check-docs-truth.ts && deno run --allow-read tools/check-release-truth.ts && deno task docs:check-version-anchors && deno task docs:check-recipe-parity", + "docs:truth": "deno run --allow-read --allow-run=git tools/check-docs-truth.ts && deno task release:truth:check && deno task docs:check-version-anchors && deno task docs:check-recipe-parity", "www:check-current-truth": "deno run --allow-read tools/check-docs-truth.ts --check=www", "www:check-theme-tokens": "deno run --allow-read tools/check-www-theme-tokens.ts", "www:check-artifact-truth": "deno run --allow-read tools/check-docs-truth.ts --check=www --artifacts", diff --git a/tools/autoflow/__tests__/policy.test.ts b/tools/autoflow/__tests__/policy.test.ts index a972bae03..0f9f7a80b 100644 --- a/tools/autoflow/__tests__/policy.test.ts +++ b/tools/autoflow/__tests__/policy.test.ts @@ -97,6 +97,19 @@ Deno.test('policy: release tier includes publish dry-run and nitro proofs', () = assert(gates.includes('third-party-wc:smoke')); }); +Deno.test('policy: release-line truth is gated in ci and release tiers (#1230)', () => { + // check-release-truth.ts (release-state.json consistency + README/STATUS/ + // ROADMAP registry anchors) must not depend on the local docs:truth + // composition alone — the CI-GATING rule requires a policy gate. + for (const tier of ['ci', 'release'] as const) { + const gates = selectGates(tier, ['docs/release/release-state.json']).map((gate) => gate.name); + assert(gates.includes('release:truth:check'), `release:truth:check missing from ${tier} tier`); + } + const gate = allRegisteredGates().find((candidate) => candidate.name === 'release:truth:check'); + assert(gate, 'release:truth:check must be registered'); + assertEquals(gate.command, ['deno', 'task', 'release:truth:check']); +}); + Deno.test('policy: package artifacts gate packs before the packaged consumer runs', () => { const gates = selectGates('ci', ['packages/element/src/index.ts']).map((gate) => gate.name); assert(gates.indexOf('package-artifacts:check') < gates.indexOf('consumer:packaged')); diff --git a/tools/autoflow/policy.ts b/tools/autoflow/policy.ts index b3ddeab5d..a58144d47 100644 --- a/tools/autoflow/policy.ts +++ b/tools/autoflow/policy.ts @@ -239,6 +239,26 @@ const GATES: readonly GateDefinition[] = [ /^deno\.json$/, ], }, + { + // #1230 (B2.8): check-release-truth.ts (release-state.json consistency + + // README/STATUS/ROADMAP registry anchors) previously ran only inside the + // local docs:truth composition — a release-truth check with no CI wiring + // violates the CI-GATING rule. Same command as the composition, matching + // the sibling release:evidence:check tier declaration. + name: 'release:truth:check', + command: ['deno', 'task', 'release:truth:check'], + tiers: ['ci', 'release'], + triggers: [ + /^docs\/release\/release-state\.json$/, + /^docs\/(status|roadmap|current)\//, + /^README/, + /^examples\/supabase-cloudflare-starter\/deno\.json$/, + /^tools\/check-release-truth(?:\.test)?\.ts$/, + /^tools\/project-constants\.ts$/, + /^tools\/lib\/version\.ts$/, + /^deno\.json$/, + ], + }, { // Replays the durable autoflow3 release state machine recorded under // docs/release/autoflow3/.json from git history and fails unless the