You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/12 14:51:16 UTC

(maka) branch main updated: fix(desktop): harden the renderer architecture ratchet base comparison (#4584)

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new 5049ffc1a7 fix(desktop): harden the renderer architecture ratchet base comparison (#4584)
5049ffc1a7 is described below

commit 5049ffc1a7780c0b3efd336cde7fe011886d9410
Author: yum <ya...@chihum.dev>
AuthorDate: Sat Sep 12 22:51:12 2026 +0800

    fix(desktop): harden the renderer architecture ratchet base comparison (#4584)
    
    Fail closed in CI when base-tree analysis or the required base-checker cross-check cannot run. Measure both trees with the base generator while keeping comparison rules in the current checker; preserve diagnostic fallback for local non-strict runs. Cover compatibility and filesystem failures with real-Git fixtures.
    
    Refs #4582.
    
    Generated-by: Claude Code
    Generated-by: OpenAI Codex
---
 .github/workflows/ci.yml                           |   2 +-
 .gitignore                                         |   3 +
 .../scripts/check-renderer-architecture.mjs        | 203 +++++++++--
 .../scripts/check-renderer-architecture.test.mjs   | 380 ++++++++++++++++++++-
 apps/desktop/src/renderer/README.md                |  18 +
 docs/windows-test-inventory.md                     |   1 +
 scripts/ci-workflow-policy.test.mjs                |   2 +-
 7 files changed, 572 insertions(+), 37 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bcc0b0c4d6..9ad70db3bd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -262,7 +262,7 @@ jobs:
           BASE_SHA: ${{ steps.comparison.outputs.base }}
         run: |
           if [[ -n "$BASE_SHA" ]]; 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 87339f0a9b..6dfe32cd47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,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 a2ec8d90c2..7190ba0495 100644
--- a/apps/desktop/scripts/check-renderer-architecture.mjs
+++ b/apps/desktop/scripts/check-renderer-architecture.mjs
@@ -3418,18 +3418,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,
@@ -3447,11 +3439,132 @@ 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 fallback could reintroduce the stale-ledger failure 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) => {
+    if (strictBase) return unavailable('the base checker is incompatible', 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 {
+    let baseChecker;
+    try {
+      writeFileSync(tempPath, baseSource);
+      baseChecker = await import(pathToFileURL(tempPath).href);
+    } catch (error) {
+      return unavailable('the base checker could not be written or 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}`], {
@@ -3486,7 +3599,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}`);
   }
@@ -3500,25 +3613,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 <commit> [--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];
@@ -3526,31 +3657,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 <commit>]');
-      }
+      if (!value || value.startsWith('--')) throw new Error(CLI_USAGE);
       base = value;
       index += 1;
       continue;
     }
-    throw new Error('usage: check-renderer-architecture.mjs [--write] [--base <commit>]');
+    throw new Error(CLI_USAGE);
   }
-  return { base, write };
+  if (strictBase && base === undefined) throw new Error(`--strict-base requires --base <commit>\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`);
@@ -3561,8 +3696,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}`);
@@ -3576,4 +3711,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 f3b0eba282..5114c6ac88 100644
--- a/apps/desktop/scripts/check-renderer-architecture.test.mjs
+++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs
@@ -19,7 +19,18 @@
 
 import { strict as assert } from 'node:assert';
 import { spawnSync } from 'node:child_process';
-import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
+import {
+  chmod,
+  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';
@@ -2845,11 +2856,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 <commit>/u);
+    assert.match(strictWithoutBase.stderr, /usage: check-renderer-architecture/u);
   });
 });
 
@@ -3287,3 +3305,363 @@ 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 fallback that could reintroduce #4250's failure, 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);
+    });
+  });
+
+  for (const version of [1, 2]) {
+    it(`rejects weakened classification with ledger version ${version} under --strict-base`, 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',
+        );
+        let weakened = original.replaceAll(
+          LEGACY_CLASSIFICATION,
+          ".filter((path) => zoneFor(path).kind === 'legacy' && !path.includes('/widgets/'))",
+        );
+        if (version === 2) {
+          for (const [before, after] of [
+            [
+              "if (config.version !== 1) reject('version must be 1');",
+              "if (config.version !== 2) reject('version must be 2');",
+            ],
+            ['version: 1,', 'version: 2,'],
+          ]) {
+            assert.equal(weakened.split(before).length - 1, 1, `the ledger version anchor moved: ${before}`);
+            weakened = weakened.replace(before, after);
+          }
+        }
+        await writeFile(fixture.scriptPath, weakened, 'utf8');
+        await fixture.writeFiles({ 'src/renderer/widgets/legacy-widget-panel.ts': 'export const hidden = 1;\n' });
+        const headLedger = await fixture.writeLedger({ ...rendererEntrySeedConfig(), version });
+        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);
+        if (version === 2) {
+          assert.match(result.stderr, /--strict-base forbids skipping the cross-check/u);
+          assert.match(result.stderr, /does not produce the current ledger shape.*version must be 2/u);
+          assert.doesNotMatch(result.stdout, /passed|cross-checked debt/u);
+
+          const lenient = fixture.runChecker(['--base', base]);
+          assertPassed(lenient, base, 'lenient schema transition');
+          assert.match(lenient.stdout, /does not produce the current ledger shape.*skipping the base-checker cross-check/u);
+          return;
+        }
+        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('requires the base generator export only under --strict-base', 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 lenient = fixture.runChecker(['--base', base]);
+      assertPassed(lenient, base, 'older base checker');
+      assert.match(lenient.stdout, /does not export generateArchitectureConfig; skipping the base-checker cross-check/u);
+
+      const strict = fixture.runChecker(['--base', base, '--strict-base']);
+      assert.notEqual(strict.status, 0);
+      assert.match(strict.stderr, /--strict-base forbids skipping the cross-check/u);
+      assert.match(strict.stderr, /does not export generateArchitectureConfig/u);
+      assert.doesNotMatch(strict.stdout, /passed|cross-checked debt/u);
+    });
+  });
+
+  it('handles read-only POSIX permissions on the checker directory according to --strict-base', {
+    // Windows does not enforce these mode bits, and root bypasses them.
+    skip: process.platform === 'win32' || process.getuid?.() === 0,
+  }, async () => {
+    await withGitFixture(async (fixture) => {
+      await fixture.writeLedger();
+      const base = fixture.commit('base');
+      const original = await readFile(fixture.scriptPath, 'utf8');
+      await writeFile(fixture.scriptPath, `${original}\n// Checker maintenance.\n`, 'utf8');
+      fixture.commit('change the checker without changing its rules');
+
+      const scriptDirectory = dirname(fixture.scriptPath);
+      await chmod(scriptDirectory, 0o555);
+      try {
+        const lenient = fixture.runChecker(['--base', base]);
+        assertPassed(lenient, base, 'non-writable checker directory');
+        assert.match(lenient.stderr, /base-checker cross-check skipped.*EACCES/u);
+
+        const strict = fixture.runChecker(['--base', base, '--strict-base']);
+        assert.notEqual(strict.status, 0);
+        assert.match(strict.stderr, /--strict-base forbids skipping the cross-check.*EACCES/u);
+        assert.doesNotMatch(strict.stdout, /passed|cross-checked debt/u);
+      } finally {
+        await chmod(scriptDirectory, 0o755);
+      }
+    });
+  });
+
+  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 written or 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 written or 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 ee5cef1d96..5254847879 100644
--- a/apps/desktop/src/renderer/README.md
+++ b/apps/desktop/src/renderer/README.md
@@ -104,6 +104,24 @@ capabilities and dependencies, so ordinary implementation can evolve without
 token-count ledger noise. A support entry may move one way from the AppShell
 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.
+CI runs the checker as `--base <sha> --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. A silent fallback to the committed ledger could reintroduce
+the stale-ledger failure #4250 demonstrated, where a base ledger that
+under-reported its own tree wedged CI. When the checker script itself differs
+from the base commit, the base commit's checker is also imported to measure
+both trees, and debt the base measurement rules (generation and classification)
+would have flagged fails as a `base-checker cross-check:` violation, so one
+change cannot loosen how debt is measured and lower both sides of the ratchet
+at once. Under `--strict-base`, failure to write, import, or run an existing
+base checker, a missing `generateArchitectureConfig` export, or output that
+does not match the current ledger schema is a hard error. Without the flag,
+these conditions are reported and the cross-check is skipped. A base commit
+without a checker has no old measurement rules to run and is skipped in both
+modes. The schema validation and comparison still run under the current checker;
+changes to those rules remain a review concern. In particular, changes to
+`validateMonotonicDebt` are not protected by the cross-check.
 
 Dependency-path debt prices only regressive runtime edges. Type-only imports
 are erased at compile time and never count. Edges into a shell, feature public,
diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md
index 181b6163da..35c833ffd5 100644
--- a/docs/windows-test-inventory.md
+++ b/docs/windows-test-inventory.md
@@ -25,6 +25,7 @@ Total Windows-excluded declarations: **90**
 
 | Classification | Test | Skip expression |
 |---|---|---|
+| platform-contract | `apps/desktop/scripts/check-renderer-architecture.test.mjs` handles read-only POSIX permissions on the checker directory according to --strict-base | `process.platform === 'win32' \|\| process.getuid?.() === 0` |
 | portable-candidate | `apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts` MCP remove reconciles a live manager after the real store publishes then fails directory sync | `process.platform === 'win32'` |
 | portable-candidate | `apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts` MCP upsert reconciles the reread authority including an intervening writer without replaying its mutation | `process.platform === 'win32'` |
 | portable-candidate | `apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts` MCP published write explicitly reports out-of-sync when reconciliation ${phase} fails | `process.platform === 'win32'` |
diff --git a/scripts/ci-workflow-policy.test.mjs b/scripts/ci-workflow-policy.test.mjs
index c3cf6cf1a8..6e4dae8147 100644
--- a/scripts/ci-workflow-policy.test.mjs
+++ b/scripts/ci-workflow-policy.test.mjs
@@ -232,7 +232,7 @@ test('shared comparison drives every diff gate on refreshed merges, pushes and d
           'npm',
           'run',
           'check:renderer-architecture',
-          ...(expectedBase ? ['--', '--base', expectedBase] : []),
+          ...(expectedBase ? ['--', '--base', expectedBase, '--strict-base'] : []),
         ],
       ],
     ];


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org