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