diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 714b545eeb..80adc096fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,7 +196,7 @@ jobs: BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} run: | if [[ -n "$BASE_SHA" && ! "$BASE_SHA" =~ ^0+$ ]]; then - npm run check:renderer-architecture -- --base "$BASE_SHA" + npm run check:renderer-architecture -- --base "$BASE_SHA" --strict-base else npm run check:renderer-architecture fi diff --git a/.gitignore b/.gitignore index 46829faa83..16df38bbd9 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ apps/desktop/resources/bin/ # Rebuilt from experiments/windows-sandbox by scripts/package-windows-x64.mjs. apps/desktop/resources/windows-sandbox/ apps/desktop/bundled-git.json +# Scratch copy of the base commit's renderer architecture checker, written next +# to the live script so its imports resolve; the check removes it after each run. +apps/desktop/scripts/.tmp-base-checker-*.mjs # Generated desktop release inputs and outputs. apps/desktop/resources/tools/ diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index b59400f62c..6eab3cea4e 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -2675,18 +2675,10 @@ export function checkRendererArchitecture({ // wedge the ledger permanently. We materialize the base tree and re-derive its // debt, keeping the base ledger only as the source of policy fields (hook // transitions, growth directories, root-debt key set, ownership). -function deriveBaseTreeConfig(repoRoot, desktopRoot, base, baseCommittedConfig) { +function materializeBaseTree(repoRoot, base) { const scratch = mkdtempSync(join(tmpdir(), 'renderer-arch-base-')); const worktreePath = join(scratch, 'tree'); - try { - execFileSync('git', ['worktree', 'add', '--detach', worktreePath, base], { - cwd: repoRoot, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - const baseDesktopRoot = resolve(worktreePath, relative(repoRoot, desktopRoot)); - return generateArchitectureConfig(baseDesktopRoot, baseCommittedConfig); - } finally { + const remove = () => { try { execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot, @@ -2704,11 +2696,131 @@ function deriveBaseTreeConfig(repoRoot, desktopRoot, base, baseCommittedConfig) } catch { // Best-effort cleanup of the scratch directory. } + }; + try { + execFileSync('git', ['worktree', 'add', '--detach', worktreePath, base], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + remove(); + throw error; + } + return { remove, worktreePath }; +} + +function deriveBaseTreeConfig(baseDesktopRoot, baseCommittedConfig) { + return generateArchitectureConfig(baseDesktopRoot, baseCommittedConfig); +} + +// If the base tree cannot be materialized or analyzed (e.g. git worktree is +// unavailable), fall back to the committed base ledger so the ratchet still +// runs. That silent fallback is exactly what wedged CI in #4250, so +// `--strict-base` turns it into a hard failure instead. +function baseTreeFallback({ base, baseCommittedConfig, error, strictBase }) { + const reason = error instanceof Error ? error.message : String(error); + if (strictBase) { + throw new Error( + `could not derive base tree debt at ${base}, and --strict-base forbids falling back to the committed base ledger (${reason})`, + ); + } + console.warn( + `Renderer architecture check: could not derive base tree debt at ${base}; ` + + `falling back to the committed base ledger. (${reason})`, + ); + return baseCommittedConfig; +} + +// A change can weaken a rule in this checker and thereby lower both sides of +// the ratchet at once: the current tree and the re-derived base tree are then +// measured with the same relaxed rule, so the debt that rule used to flag +// vanishes from the comparison. Whenever the checker itself differs from the +// base commit, we therefore also measure both trees with the BASE commit's +// checker and ratchet those two measurements with the current comparison +// logic, so debt the base rules would have caught still fails. +async function crossCheckUnderBaseChecker({ + base, + baseCommittedConfig, + baseDesktopRoot, + desktopRoot, + repoRoot, + strictBase, +}) { + const scriptPath = fileURLToPath(import.meta.url); + const relativeScript = normalizePath(relative(repoRoot, scriptPath)); + let baseSource; + try { + baseSource = execFileSync('git', ['show', `${base}:${relativeScript}`], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + console.log( + `Renderer architecture check: ${base} has no ${relativeScript}; skipping the base-checker cross-check.`, + ); + return []; + } + if (baseSource === readFileSync(scriptPath, 'utf8')) return []; + + const unavailable = (stage, error) => { + const reason = error instanceof Error ? error.message : String(error); + if (strictBase) { + throw new Error( + `base-checker cross-check: ${stage} at ${base}, and --strict-base forbids skipping the cross-check (${reason})`, + ); + } + console.warn(`Renderer architecture check: base-checker cross-check skipped; ${stage} at ${base}. (${reason})`); + return []; + }; + const skip = (reason) => { + console.log(`Renderer architecture check: ${reason}; skipping the base-checker cross-check.`); + return []; + }; + + // The copy lives next to this script so its bare imports resolve exactly as + // ours do; the name is gitignored and the copy is removed even on failure. + const tempPath = join(dirname(scriptPath), `.tmp-base-checker-${process.pid}.mjs`); + try { + writeFileSync(tempPath, baseSource); + let baseChecker; + try { + baseChecker = await import(pathToFileURL(tempPath).href); + } catch (error) { + return unavailable('the base checker could not be imported', error); + } + if (typeof baseChecker.generateArchitectureConfig !== 'function') { + return skip(`the checker at ${base} does not export generateArchitectureConfig`); + } + let baseUnderBaseRules; + let currentUnderBaseRules; + try { + baseUnderBaseRules = baseChecker.generateArchitectureConfig(baseDesktopRoot, baseCommittedConfig); + currentUnderBaseRules = baseChecker.generateArchitectureConfig(desktopRoot, baseCommittedConfig); + } catch (error) { + return unavailable('the base checker could not measure the base and current trees', error); + } + const shapeViolations = []; + if ( + !validateArchitectureConfig(baseUnderBaseRules, 'base-checker base', shapeViolations) || + !validateArchitectureConfig(currentUnderBaseRules, 'base-checker current', shapeViolations) + ) { + return skip(`the checker at ${base} does not produce the current ledger shape (${shapeViolations.join('; ')})`); + } + const violations = []; + validateMonotonicDebt(currentUnderBaseRules, baseUnderBaseRules, desktopRoot, violations); + console.log( + `Renderer architecture check: ${relativeScript} differs from ${base}; cross-checked debt under the base checker.`, + ); + return violations.sort().map((violation) => `base-checker cross-check: ${violation}`); + } finally { + rmSync(tempPath, { force: true }); } } -function loadBaseConfig(repoRoot, desktopRoot, base) { - if (!base) return { baseConfig: undefined, introducedLedger: false }; +async function loadBaseConfig(repoRoot, desktopRoot, base, { strictBase = false } = {}) { + if (!base) return { baseConfig: undefined, crossCheckViolations: [], introducedLedger: false }; const relativeConfig = normalizePath(relative(repoRoot, join(desktopRoot, 'renderer-architecture.json'))); try { execFileSync('git', ['rev-parse', '--verify', `${base}^{commit}`], { @@ -2743,7 +2855,7 @@ function loadBaseConfig(repoRoot, desktopRoot, base) { }, ).trim(); if (diffStatus === `A\t${relativeConfig}` || worktreeStatus === `?? ${relativeConfig}`) { - return { baseConfig: undefined, introducedLedger: true }; + return { baseConfig: undefined, crossCheckViolations: [], introducedLedger: true }; } throw new Error(`base ledger is missing at ${base}:${relativeConfig}`); } @@ -2757,25 +2869,43 @@ function loadBaseConfig(repoRoot, desktopRoot, base) { ); } + let baseTree; try { + baseTree = materializeBaseTree(repoRoot, base); + } catch (error) { return { - baseConfig: deriveBaseTreeConfig(repoRoot, desktopRoot, base, baseCommittedConfig), + baseConfig: baseTreeFallback({ base, baseCommittedConfig, error, strictBase }), + crossCheckViolations: [], introducedLedger: false, }; - } catch (error) { - // If the base tree cannot be materialized or analyzed (e.g. git worktree is - // unavailable), fall back to the committed base ledger so the ratchet still - // runs. This restores the pre-fix behavior rather than crashing the check. - console.warn( - `Renderer architecture check: could not derive base tree debt at ${base}; ` + - `falling back to the committed base ledger. (${error instanceof Error ? error.message : String(error)})`, - ); - return { baseConfig: baseCommittedConfig, introducedLedger: false }; + } + try { + const baseDesktopRoot = resolve(baseTree.worktreePath, relative(repoRoot, desktopRoot)); + let baseConfig; + try { + baseConfig = deriveBaseTreeConfig(baseDesktopRoot, baseCommittedConfig); + } catch (error) { + baseConfig = baseTreeFallback({ base, baseCommittedConfig, error, strictBase }); + } + const crossCheckViolations = await crossCheckUnderBaseChecker({ + base, + baseCommittedConfig, + baseDesktopRoot, + desktopRoot, + repoRoot, + strictBase, + }); + return { baseConfig, crossCheckViolations, introducedLedger: false }; + } finally { + baseTree.remove(); } } +const CLI_USAGE = 'usage: check-renderer-architecture.mjs [--write] [--base [--strict-base]]'; + function parseCliArguments(args) { let base; + let strictBase = false; let write = false; for (let index = 0; index < args.length; index += 1) { const argument = args[index]; @@ -2783,31 +2913,35 @@ function parseCliArguments(args) { write = true; continue; } + if (argument === '--strict-base' && !strictBase) { + strictBase = true; + continue; + } if (argument === '--base' && base === undefined) { const value = args[index + 1]; - if (!value || value.startsWith('--')) { - throw new Error('usage: check-renderer-architecture.mjs [--write] [--base ]'); - } + if (!value || value.startsWith('--')) throw new Error(CLI_USAGE); base = value; index += 1; continue; } - throw new Error('usage: check-renderer-architecture.mjs [--write] [--base ]'); + throw new Error(CLI_USAGE); } - return { base, write }; + if (strictBase && base === undefined) throw new Error(`--strict-base requires --base \n${CLI_USAGE}`); + return { base, strictBase, write }; } -function runCli() { +async function runCli() { const desktopRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); const repoRoot = resolve(desktopRoot, '../..'); let base; let config; let loadedBase; + let strictBase; let write; try { - ({ base, write } = parseCliArguments(process.argv.slice(2))); + ({ base, strictBase, write } = parseCliArguments(process.argv.slice(2))); config = JSON.parse(readFileSync(join(desktopRoot, 'renderer-architecture.json'), 'utf8')); - loadedBase = loadBaseConfig(repoRoot, desktopRoot, base); + loadedBase = await loadBaseConfig(repoRoot, desktopRoot, base, { strictBase }); if (write) { config = generateArchitectureConfig(desktopRoot, config); writeFileSync(join(desktopRoot, 'renderer-architecture.json'), `${JSON.stringify(config, null, 2)}\n`); @@ -2818,8 +2952,8 @@ function runCli() { process.exitCode = 1; return; } - const { baseConfig, introducedLedger } = loadedBase; - const violations = checkRendererArchitecture({ baseConfig, config, desktopRoot }); + const { baseConfig, crossCheckViolations, introducedLedger } = loadedBase; + const violations = [...checkRendererArchitecture({ baseConfig, config, desktopRoot }), ...crossCheckViolations]; if (violations.length > 0) { console.error('Renderer architecture check failed:'); for (const violation of violations) console.error(`- ${violation}`); @@ -2833,4 +2967,4 @@ function runCli() { console.log(`Renderer architecture check passed${baseConfig ? ` against ${base}` : ''}.`); } -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) runCli(); +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) await runCli(); diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs index e6043da2b0..46713d72b9 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -19,7 +19,17 @@ import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { + copyFile, + mkdtemp, + mkdir, + readFile, + realpath, + rm, + symlink, + unlink, + writeFile, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { describe, it } from 'node:test'; @@ -2319,11 +2329,18 @@ describe('renderer architecture checker fixtures', () => { cwd: repoRoot, encoding: 'utf8', }); + const strictWithoutBase = spawnSync(process.execPath, [checker, '--strict-base'], { + cwd: repoRoot, + encoding: 'utf8', + }); assert.notEqual(missing.status, 0); assert.match(missing.stderr, /usage: check-renderer-architecture/u); assert.notEqual(invalid.status, 0); assert.match(invalid.stderr, /base ref does not resolve to a commit/u); + assert.notEqual(strictWithoutBase.status, 0); + assert.match(strictWithoutBase.stderr, /--strict-base requires --base /u); + assert.match(strictWithoutBase.stderr, /usage: check-renderer-architecture/u); }); }); @@ -2549,3 +2566,308 @@ describe('validated copy catalog dependencies', () => { ); }); }); + +// These fixtures exercise the CLI end to end against a real git history: the +// ratchet must re-derive the base commit's debt from the base *tree* (#4249), +// `--strict-base` must refuse the silent fallback that wedged CI in #4250, and +// a checker change must still be measured by the base commit's checker. +describe('renderer architecture base-tree derivation (git fixtures)', () => { + const checkerPath = fileURLToPath(new URL('./check-renderer-architecture.mjs', import.meta.url)); + const realNodeModules = fileURLToPath(new URL('../../../node_modules', import.meta.url)); + const LEGACY_WIDGET_PATH = 'src/renderer/legacy-widget.ts'; + const LEGACY_PANEL_PATH = 'src/renderer/legacy-panel.ts'; + const LEGACY_CLASSIFICATION = ".filter((path) => zoneFor(path).kind === 'legacy')"; + + function fixtureEnvironment(scratch) { + // Keep the fixture repository independent of the developer's git setup + // (signing, hooks, templates) and of any hook-provided git context. + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))); + env.GIT_CONFIG_GLOBAL = join(scratch, 'gitconfig'); + env.GIT_CONFIG_NOSYSTEM = '1'; + return env; + } + + // The base worktree is materialized under the OS temp directory; pointing + // every temp-dir variable at a missing directory makes that step fail + // deterministically on every platform without touching git itself. + function brokenTempDirectory(scratch) { + const missing = join(scratch, 'missing-tmpdir'); + return { TEMP: missing, TMP: missing, TMPDIR: missing }; + } + + async function writeFixtureFiles(root, files) { + for (const [path, source] of Object.entries(files)) { + const absolutePath = join(root, path); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, source, 'utf8'); + } + } + + async function withGitFixture(run) { + // The checker only runs its CLI when process.argv[1] is its own real + // path, so resolve the (possibly symlinked) temp directory up front. + const scratch = await realpath(await mkdtemp(join(tmpdir(), 'maka-renderer-architecture-git-'))); + const repoRoot = join(scratch, 'repo'); + const desktopRoot = join(repoRoot, 'apps', 'desktop'); + const nodeModulesLink = join(repoRoot, 'node_modules'); + const env = fixtureEnvironment(scratch); + const git = (...args) => { + const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', env }); + assert.equal(result.status, 0, `git ${args.join(' ')} failed:\n${result.stderr}`); + return result.stdout.trim(); + }; + const fixture = { + desktopRoot, + ledgerPath: join(desktopRoot, 'renderer-architecture.json'), + scratch, + scriptPath: join(desktopRoot, 'scripts', 'check-renderer-architecture.mjs'), + commit(message) { + git('add', '--all'); + git('commit', '--quiet', '--no-verify', '--message', message); + return git('rev-parse', 'HEAD'); + }, + runChecker(args, extraEnv = {}) { + return spawnSync(process.execPath, [fixture.scriptPath, ...args], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...env, ...extraEnv }, + }); + }, + writeFiles: (files) => writeFixtureFiles(desktopRoot, files), + // Regenerates the ledger with the checker committed in the fixture, so a + // patched checker produces exactly the ledger its own rules accept. + async writeLedger(seed = rendererEntrySeedConfig()) { + await writeFile(fixture.ledgerPath, `${JSON.stringify(seed, null, 2)}\n`, 'utf8'); + const result = fixture.runChecker(['--write']); + assert.equal(result.status, 0, `ledger generation failed:\n${result.stdout}\n${result.stderr}`); + return JSON.parse(await readFile(fixture.ledgerPath, 'utf8')); + }, + }; + try { + await mkdir(join(desktopRoot, 'scripts'), { recursive: true }); + await writeFile(join(scratch, 'gitconfig'), '', 'utf8'); + await copyFile(checkerPath, fixture.scriptPath); + await symlink(realNodeModules, nodeModulesLink, 'junction'); + await writeFile(join(repoRoot, '.gitignore'), 'node_modules\n', 'utf8'); + await fixture.writeFiles( + rendererEntryContractFiles({ + [LEGACY_WIDGET_PATH]: 'export const legacyWidget = 1;\n', + [LEGACY_PANEL_PATH]: 'export const legacyPanel = 1;\n', + }), + ); + git('-c', 'init.defaultBranch=main', 'init', '--quiet'); + git('config', 'user.name', 'Renderer Architecture Fixture'); + git('config', 'user.email', 'renderer-architecture@example.invalid'); + git('config', 'commit.gpgsign', 'false'); + return await run(fixture); + } finally { + // Drop the node_modules link explicitly so no cleanup path can ever + // recurse into the real dependency tree. + try { + await unlink(nodeModulesLink); + } catch { + // The link was never created. + } + await rm(scratch, { force: true, recursive: true }); + } + } + + function assertPassed(result, base, label) { + assert.equal(result.status, 0, `${label}:\n${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, new RegExp(`passed against ${base}`, 'u')); + assert.doesNotMatch(result.stderr, /falling back to the committed base ledger/u); + } + + it('passes when the head commit holds equal or lower debt than the derived base tree', async () => { + await withGitFixture(async (fixture) => { + await fixture.writeLedger(); + const base = fixture.commit('base'); + await fixture.writeFiles({ [LEGACY_WIDGET_PATH]: 'export const legacyWidget = 2;\n' }); + await rm(join(fixture.desktopRoot, LEGACY_PANEL_PATH)); + const headLedger = await fixture.writeLedger(); + assert.deepEqual(headLedger.legacyRendererFiles, [LEGACY_WIDGET_PATH, RENDERER_ENTRY_PATH]); + fixture.commit('edit one legacy file and retire another'); + + for (const args of [['--base', base], ['--base', base, '--strict-base']]) { + const result = fixture.runChecker(args); + assertPassed(result, base, args.join(' ')); + // The checker is unchanged between the commits, so no cross-check ran. + assert.doesNotMatch(`${result.stdout}${result.stderr}`, /cross-check/u); + } + }); + }); + + it('rejects a new unclassified legacy renderer file relative to the derived base tree', async () => { + await withGitFixture(async (fixture) => { + await fixture.writeLedger(); + const base = fixture.commit('base'); + await fixture.writeFiles({ 'src/renderer/legacy-drawer.ts': 'export const legacyDrawer = 1;\n' }); + await fixture.writeLedger(); + fixture.commit('add legacy debt'); + + const result = fixture.runChecker(['--base', base, '--strict-base']); + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /^- src\/renderer\/legacy-drawer\.ts: new unclassified renderer source files are forbidden/mu, + ); + assert.doesNotMatch(result.stderr, /falling back to the committed base ledger/u); + }); + }); + + it('does not wedge on a base ledger that under-reports its own tree (#4250)', async () => { + await withGitFixture(async (fixture) => { + // The base ledger only knows one legacy file while the base *tree* + // already carries a second one. + await rm(join(fixture.desktopRoot, LEGACY_PANEL_PATH)); + await fixture.writeLedger(); + await fixture.writeFiles({ [LEGACY_PANEL_PATH]: 'export const legacyPanel = 1;\n' }); + const base = fixture.commit('base whose ledger under-reports its tree'); + const baseLedger = JSON.parse(await readFile(fixture.ledgerPath, 'utf8')); + assert.deepEqual(baseLedger.legacyRendererFiles, [LEGACY_WIDGET_PATH, RENDERER_ENTRY_PATH]); + + // Head merely records the debt the base tree already had. + const headLedger = await fixture.writeLedger(); + assert.deepEqual(headLedger.legacyRendererFiles, [ + LEGACY_PANEL_PATH, + LEGACY_WIDGET_PATH, + RENDERER_ENTRY_PATH, + ]); + fixture.commit('record the already-present debt'); + + assertPassed(fixture.runChecker(['--base', base, '--strict-base']), base, 'derived base tree'); + + // Trusting the committed base ledger instead is exactly the wedge: the + // faithful correction reads as brand-new debt. + const fallback = fixture.runChecker(['--base', base], brokenTempDirectory(fixture.scratch)); + assert.notEqual(fallback.status, 0); + assert.match(fallback.stderr, /falling back to the committed base ledger/u); + assert.match( + fallback.stderr, + /^- src\/renderer\/legacy-panel\.ts: new unclassified renderer source files are forbidden/mu, + ); + }); + }); + + it('fails loudly under --strict-base when the base tree cannot be materialized', async () => { + await withGitFixture(async (fixture) => { + await fixture.writeLedger(); + const base = fixture.commit('base'); + await fixture.writeFiles({ [LEGACY_WIDGET_PATH]: 'export const legacyWidget = 2;\n' }); + fixture.commit('head'); + const brokenTemp = brokenTempDirectory(fixture.scratch); + + const lenient = fixture.runChecker(['--base', base], brokenTemp); + assert.equal(lenient.status, 0, `lenient:\n${lenient.stdout}\n${lenient.stderr}`); + assert.match( + lenient.stderr, + /could not derive base tree debt at .*; falling back to the committed base ledger/u, + ); + + const strict = fixture.runChecker(['--base', base, '--strict-base'], brokenTemp); + assert.notEqual(strict.status, 0); + assert.match( + strict.stderr, + /could not derive base tree debt at .*--strict-base forbids falling back to the committed base ledger/u, + ); + assert.doesNotMatch(strict.stdout, /passed/u); + }); + }); + + it('cross-checks a weakened checker against the base commit checker', async () => { + await withGitFixture(async (fixture) => { + await fixture.writeLedger(); + const base = fixture.commit('base'); + + // Weaken the measurement: the head checker stops classifying anything under + // src/renderer/widgets/ as legacy, in both the generator and the ledger + // validation. The head ledger, the head snapshot check, and the plain + // ratchet (which re-derives the base with the SAME weakened rules) all agree. + const original = await readFile(fixture.scriptPath, 'utf8'); + assert.equal( + original.split(LEGACY_CLASSIFICATION).length - 1, + 2, + 'the legacy classification filter moved; update this fixture', + ); + await writeFile( + fixture.scriptPath, + original.replaceAll( + LEGACY_CLASSIFICATION, + ".filter((path) => zoneFor(path).kind === 'legacy' && !path.includes('/widgets/'))", + ), + 'utf8', + ); + await fixture.writeFiles({ 'src/renderer/widgets/legacy-widget-panel.ts': 'export const hidden = 1;\n' }); + const headLedger = await fixture.writeLedger(); + assert.deepEqual(headLedger.legacyRendererFiles, [ + LEGACY_PANEL_PATH, + LEGACY_WIDGET_PATH, + RENDERER_ENTRY_PATH, + ]); + fixture.commit('weaken the checker and add the debt it no longer sees'); + + const result = fixture.runChecker(['--base', base, '--strict-base']); + assert.notEqual(result.status, 0); + assert.match(result.stdout, /differs from .*; cross-checked debt under the base checker/u); + assert.match( + result.stderr, + /^- base-checker cross-check: src\/renderer\/widgets\/legacy-widget-panel\.ts: new unclassified renderer source files are forbidden/mu, + ); + // Every reported violation comes from the cross-check: the weakened + // checker alone was satisfied on both sides of the ratchet. + const reported = result.stderr.split('\n').filter((line) => line.startsWith('- ')); + assert.ok(reported.length > 0, result.stderr); + assert.ok( + reported.every((line) => line.startsWith('- base-checker cross-check: ')), + result.stderr, + ); + assert.doesNotMatch(result.stderr, /falling back|cross-check skipped/u); + }); + }); + + it('skips the cross-check with a notice when the base checker predates generateArchitectureConfig', async () => { + await withGitFixture(async (fixture) => { + const original = await readFile(fixture.scriptPath, 'utf8'); + const exported = 'export function generateArchitectureConfig('; + assert.equal(original.split(exported).length - 1, 1); + await writeFile(fixture.scriptPath, original.replace(exported, 'function generateArchitectureConfig('), 'utf8'); + await fixture.writeLedger(); + const base = fixture.commit('base whose checker has no generator export'); + await writeFile(fixture.scriptPath, original, 'utf8'); + fixture.commit('restore the export'); + + const result = fixture.runChecker(['--base', base, '--strict-base']); + assertPassed(result, base, 'older base checker'); + assert.match(result.stdout, /does not export generateArchitectureConfig; skipping the base-checker cross-check/u); + }); + }); + + it('treats a base checker that cannot be imported as a failure only under --strict-base', async () => { + await withGitFixture(async (fixture) => { + const original = await readFile(fixture.scriptPath, 'utf8'); + await writeFile(fixture.scriptPath, `import './missing-base-checker-dependency.mjs';\n${original}`, 'utf8'); + // The broken checker cannot generate its own ledger; the in-process + // generator applies the same rules. + await writeFile( + fixture.ledgerPath, + `${JSON.stringify(generateArchitectureConfig(fixture.desktopRoot, rendererEntrySeedConfig()), null, 2)}\n`, + 'utf8', + ); + const base = fixture.commit('base whose checker cannot be imported'); + await writeFile(fixture.scriptPath, original, 'utf8'); + fixture.commit('restore the checker'); + + const lenient = fixture.runChecker(['--base', base]); + assertPassed(lenient, base, 'lenient'); + assert.match(lenient.stderr, /base-checker cross-check skipped; the base checker could not be imported/u); + + const strict = fixture.runChecker(['--base', base, '--strict-base']); + assert.notEqual(strict.status, 0); + assert.match( + strict.stderr, + /the base checker could not be imported at .*--strict-base forbids skipping the cross-check/u, + ); + assert.doesNotMatch(strict.stdout, /passed/u); + }); + }); +}); diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index bbc8191a27..7c1340875c 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -102,6 +102,14 @@ closure to the root closure without resetting its budget; the reverse move is rejected. Legacy import allowlists may only shrink relative to the base branch. Same-count dependency replacement is allowed only when it moves ownership behind a shell, feature public, or application public/contract boundary. +CI runs the checker as `--base --strict-base`: the ratchet re-derives the +base commit's debt from its materialized tree rather than trusting its committed +ledger, and `--strict-base` turns any failure to materialize or analyze that tree +into a hard error instead of a silent fallback to the committed ledger. When the +checker script itself differs from the base commit, the base commit's checker is +also imported and run over both trees, and any debt the base rules would have +flagged fails as a `base-checker cross-check:` violation, so one change cannot +weaken a rule and lower both sides of the ratchet at once. Validated copy catalogs are the one admitted dependency class: the locale policy (#2672) forces user-visible copy out of business files and into