From 99e8e7266ee7154c9c95aa23743df346c83a069d Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 25 Aug 2026 13:04:07 +0200
Subject: [PATCH 1/6] refactor(admission): reconstruct change-aware local
checks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ci-prepush-lowend.mjs ran a fixed list of local checks unconditionally
on every push, with no change-aware routing at all. Reconstruct the
change classifier (ci-prepush-classifier.mjs), its i18n/content-guard
routing registry (ci-prepush-check-registry.mjs), and wire both into
the runner so DOCS_ONLY/WORKFLOW_ONLY/TOOLING/etc. changes skip
irrelevant local checks while TypeScript-impacting changes still run
typecheck.
Two defects fixed relative to the prior reconstruction attempt this
is based on: a failed `git diff` on a resolved manual upstream range
silently produced an empty-but-"resolved" file list (fail-open,
allowing an unsafe skip) instead of falling back to conservative full
admission; and scripts/coverage-thresholds.json was classified as
generic TOOLING despite being a resolveJsonModule import into
vitest.config.ts, so a push touching only it wrongly deferred
typecheck.
Also wires in pathEvidenceState (#498): pre-push-hook evidence is only
trusted as a complete file list when path evidence is COMPLETE, not
just when evidenceState is RESOLVED — a tag-only push (PARTIAL) now
correctly falls back to conservative full admission instead of
trusting a possibly-incomplete changedFiles list.
The manual-range and evidence-resolution logic lives in a new
ci-prepush-range-resolver.mjs so it stays independently unit-testable
under Vitest without importing hooks/shared.mjs (which pulls in
dependency-state.mjs's raw import.meta.url path resolution — that
file is intentionally tested via node:test instead, not Vitest).
---
scripts/ci-prepush-check-registry.d.mts | 1 +
scripts/ci-prepush-check-registry.mjs | 41 ++++++
scripts/ci-prepush-classifier.d.mts | 27 ++++
scripts/ci-prepush-classifier.mjs | 118 ++++++++++++++++++
scripts/ci-prepush-lowend.mjs | 114 +++++++++++------
scripts/ci-prepush-range-resolver.d.mts | 23 ++++
scripts/ci-prepush-range-resolver.mjs | 55 ++++++++
.../unit/tooling/ciPrepushClassifier.test.ts | 85 +++++++++++++
.../tooling/ciPrepushRangeResolver.test.ts | 103 +++++++++++++++
9 files changed, 529 insertions(+), 38 deletions(-)
create mode 100644 scripts/ci-prepush-check-registry.d.mts
create mode 100644 scripts/ci-prepush-check-registry.mjs
create mode 100644 scripts/ci-prepush-classifier.d.mts
create mode 100644 scripts/ci-prepush-classifier.mjs
create mode 100644 scripts/ci-prepush-range-resolver.d.mts
create mode 100644 scripts/ci-prepush-range-resolver.mjs
create mode 100644 tests/unit/tooling/ciPrepushClassifier.test.ts
create mode 100644 tests/unit/tooling/ciPrepushRangeResolver.test.ts
diff --git a/scripts/ci-prepush-check-registry.d.mts b/scripts/ci-prepush-check-registry.d.mts
new file mode 100644
index 000000000..64cda4dd0
--- /dev/null
+++ b/scripts/ci-prepush-check-registry.d.mts
@@ -0,0 +1 @@
+export function shouldRunAdmissionCheck(name: string, files: readonly string[]): boolean;
diff --git a/scripts/ci-prepush-check-registry.mjs b/scripts/ci-prepush-check-registry.mjs
new file mode 100644
index 000000000..47b72ab56
--- /dev/null
+++ b/scripts/ci-prepush-check-registry.mjs
@@ -0,0 +1,41 @@
+const routingAuthority = 'scripts/ci-prepush-check-registry.mjs';
+const runnerAuthority = 'scripts/ci-prepush-lowend.mjs';
+const i18nPolicyFiles = new Set([
+ 'scripts/check-i18n-keys.mjs',
+ 'scripts/i18n-locales.mjs',
+ 'scripts/build-i18n.mjs',
+ 'scripts/i18n-quality-report.mjs',
+]);
+
+export const admissionCheckRegistry = Object.freeze([
+ {
+ name: 'i18n',
+ matches: (file) =>
+ file.startsWith('locales/') ||
+ file.startsWith('public/locales/') ||
+ i18nPolicyFiles.has(file),
+ implementationFiles: new Set([
+ routingAuthority,
+ runnerAuthority,
+ 'scripts/ci-prepush-classifier.mjs',
+ ]),
+ },
+ {
+ name: 'contentGuard',
+ matches: (file) =>
+ file === 'scripts/content-guard.mjs' ||
+ file.startsWith('community-templates/') ||
+ file.startsWith('public/community-templates/'),
+ implementationFiles: new Set([
+ routingAuthority,
+ runnerAuthority,
+ 'scripts/ci-prepush-classifier.mjs',
+ ]),
+ },
+]);
+
+export function shouldRunAdmissionCheck(name, files) {
+ const entry = admissionCheckRegistry.find((candidate) => candidate.name === name);
+ if (!entry) throw new Error(`unknown local admission check: ${name}`);
+ return files.some((file) => entry.matches(file) || entry.implementationFiles.has(file));
+}
diff --git a/scripts/ci-prepush-classifier.d.mts b/scripts/ci-prepush-classifier.d.mts
new file mode 100644
index 000000000..a12f63ab9
--- /dev/null
+++ b/scripts/ci-prepush-classifier.d.mts
@@ -0,0 +1,27 @@
+export type ChangeKind =
+ | 'NO_CHANGES'
+ | 'DOCS_ONLY'
+ | 'WORKFLOW_ONLY'
+ | 'NON_CODE_ONLY'
+ | 'RUST_TAURI'
+ | 'TOOLING'
+ | 'TEST_ONLY'
+ | 'TYPESCRIPT_APPLICATION'
+ | 'DEPENDENCY_TOOLCHAIN'
+ | 'BUILD_CONFIGURATION'
+ | 'AMBIGUOUS'
+ | 'MIXED';
+
+export interface ChangeClassification {
+ readonly kind: ChangeKind;
+ readonly categories: readonly string[];
+ readonly files: readonly string[];
+}
+
+export function classifyFile(file: string): string;
+export function classifyChangedFiles(files: readonly string[]): ChangeClassification;
+export function requiresTypecheck(
+ classification: ChangeClassification,
+ options?: { readonly full?: boolean },
+): boolean;
+export function manualAdmissionNeedsFullValidation(rangeResolved: boolean): boolean;
diff --git a/scripts/ci-prepush-classifier.mjs b/scripts/ci-prepush-classifier.mjs
new file mode 100644
index 000000000..ae23f63a6
--- /dev/null
+++ b/scripts/ci-prepush-classifier.mjs
@@ -0,0 +1,118 @@
+const DOC_FILE = /\.(?:md|mdx)$/i;
+const TS_FILE = /\.(?:c|m)?tsx?$/i;
+const WORKFLOW_ROOTS = ['.github/workflows/', '.github/actions/'];
+const RUST_ROOTS = ['src-tauri/', 'crates/'];
+const TOOLING_ROOTS = ['scripts/'];
+const TOOLING_FILES = new Set(['.gitleaks.toml']);
+// QNBS-v3: these scripts/*.json files are imported into TS config with resolveJsonModule, not tooling.
+const TYPED_CONFIG_INPUTS = new Set(['scripts/coverage-thresholds.json']);
+const DEPENDENCY_FILES = new Set([
+ 'package.json',
+ 'pnpm-lock.yaml',
+ 'pnpm-workspace.yaml',
+ '.npmrc',
+ '.nvmrc',
+ 'rust-toolchain',
+ 'rust-toolchain.toml',
+]);
+const BUILD_CONFIG_FILES = new Set([
+ 'biome.json',
+ 'index.html',
+ 'playwright.config.ts',
+ 'postcss.config.js',
+ 'postcss.config.mjs',
+ 'tailwind.config.js',
+ 'tailwind.config.ts',
+ 'turbo.json',
+ 'vite.config.ts',
+ 'vitest.config.ts',
+]);
+
+function startsWithRoot(file, roots) {
+ return roots.some((root) => file.startsWith(root));
+}
+
+function normalizePath(file) {
+ return file.replaceAll('\\', '/').replace(/^\.\//, '');
+}
+
+function isInstructionFile(file) {
+ return (
+ file === 'AGENTS.md' ||
+ file === 'CLAUDE.md' ||
+ file === '.cursorrules' ||
+ file === '.github/copilot-instructions.md' ||
+ file.startsWith('.cursor/rules/')
+ );
+}
+
+export function classifyFile(file) {
+ const normalized = normalizePath(file);
+ const base = normalized.split('/').at(-1) ?? normalized;
+
+ if (startsWithRoot(normalized, WORKFLOW_ROOTS)) return 'WORKFLOW';
+ if (DOC_FILE.test(normalized) || isInstructionFile(normalized)) return 'DOCS';
+ if (
+ RUST_ROOTS.some((root) => normalized.startsWith(root)) ||
+ /(?:^|\/)(?:Cargo\.toml|Cargo\.lock)$/.test(normalized) ||
+ normalized.endsWith('.rs')
+ ) {
+ return 'RUST_TAURI';
+ }
+ if (normalized.startsWith('tests/'))
+ return TS_FILE.test(normalized) ? 'TYPESCRIPT_APPLICATION' : 'TEST_ONLY';
+ if (TS_FILE.test(normalized)) return 'TYPESCRIPT_APPLICATION';
+ if (TYPED_CONFIG_INPUTS.has(normalized)) return 'TYPESCRIPT_APPLICATION';
+ if (TOOLING_FILES.has(normalized) || startsWithRoot(normalized, TOOLING_ROOTS)) return 'TOOLING';
+ if (
+ DEPENDENCY_FILES.has(base) ||
+ normalized.startsWith('patches/') ||
+ (normalized.startsWith('packages/') && base === 'package.json')
+ ) {
+ return 'DEPENDENCY_TOOLCHAIN';
+ }
+ if (BUILD_CONFIG_FILES.has(base)) return 'BUILD_CONFIGURATION';
+ return 'UNKNOWN';
+}
+
+// QNBS-v3: classify change impact before starting expensive local checks.
+export function classifyChangedFiles(files) {
+ const normalizedFiles = [...new Set(files.map(normalizePath).filter(Boolean))].sort();
+ const categories = [...new Set(normalizedFiles.map(classifyFile))];
+
+ if (normalizedFiles.length === 0)
+ return { kind: 'NO_CHANGES', categories, files: normalizedFiles };
+ if (categories.every((category) => category === 'DOCS'))
+ return { kind: 'DOCS_ONLY', categories, files: normalizedFiles };
+ if (categories.every((category) => category === 'WORKFLOW'))
+ return { kind: 'WORKFLOW_ONLY', categories, files: normalizedFiles };
+ if (categories.length === 1) {
+ if (categories[0] === 'UNKNOWN')
+ return { kind: 'AMBIGUOUS', categories, files: normalizedFiles };
+ return { kind: categories[0], categories, files: normalizedFiles };
+ }
+ if (
+ categories.every((category) => ['DOCS', 'WORKFLOW', 'TOOLING', 'TEST_ONLY'].includes(category))
+ )
+ return { kind: 'NON_CODE_ONLY', categories, files: normalizedFiles };
+ if (categories.includes('UNKNOWN'))
+ return { kind: 'AMBIGUOUS', categories, files: normalizedFiles };
+ return { kind: 'MIXED', categories, files: normalizedFiles };
+}
+
+export function requiresTypecheck(classification, { full = false } = {}) {
+ if (full) return true;
+ return ![
+ 'NO_CHANGES',
+ 'DOCS_ONLY',
+ 'WORKFLOW_ONLY',
+ 'NON_CODE_ONLY',
+ 'RUST_TAURI',
+ 'TOOLING',
+ 'TEST_ONLY',
+ ].includes(classification.kind);
+}
+
+export function manualAdmissionNeedsFullValidation(rangeResolved) {
+ return !rangeResolved;
+}
diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs
index 16c7c5b2c..e79311903 100644
--- a/scripts/ci-prepush-lowend.mjs
+++ b/scripts/ci-prepush-lowend.mjs
@@ -1,36 +1,69 @@
import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+import { shouldRunAdmissionCheck } from './ci-prepush-check-registry.mjs';
+import {
+ classifyChangedFiles,
+ manualAdmissionNeedsFullValidation,
+ requiresTypecheck,
+} from './ci-prepush-classifier.mjs';
+import { resolveManualEvidence } from './ci-prepush-range-resolver.mjs';
import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/shared.mjs';
-import { readPrePushEvidenceFile, resolvePushEvidence } from './signing/signing-core.mjs';
-const evidenceIndex = process.argv.indexOf('--prepush-evidence-file');
-if (evidenceIndex >= 0) {
+function report(name, status, detail = '') {
+ console.log(`[local-admission] ${name.padEnd(26)} ${status}${detail ? ` — ${detail}` : ''}`);
+ return status;
+}
+
+function runCheck(name, run) {
+ const status = run();
+ report(name, status === 0 ? 'PASS' : 'FAIL');
+ if (status !== 0) process.exit(status ?? 1);
+}
+
+function main() {
+ const evidenceIndex = process.argv.indexOf('--prepush-evidence-file');
+ const evidenceFile = evidenceIndex >= 0 ? process.argv[evidenceIndex + 1] : undefined;
+ const fullRequested = process.argv.includes('--full');
+
+ let manualEvidence;
try {
- const evidence = resolvePushEvidence(
- readPrePushEvidenceFile(process.argv[evidenceIndex + 1]),
- process.cwd(),
- );
- if (evidence.evidenceState !== 'RESOLVED')
- throw new Error(evidence.reason ?? 'invalid evidence');
+ manualEvidence = resolveManualEvidence(evidenceFile);
} catch (error) {
console.error(`[local-lowend] outgoing evidence rejected: ${error.message}`);
process.exit(1);
}
-}
-const checks = [
- ['toolchain', () => runNodeScript('scripts/check-pnpm-toolchain.mjs', ['--hook'])],
- [
- 'typecheck (single checker)',
- // QNBS-v3: Make the low-end resource contract explicit; tsgo's default checker count is not a safe local default.
- () =>
- runLocalBinary('tsgo', ['--project', 'tsconfig.tsgo.json', '--noEmit', '--checkers', '1']),
- ],
- ['i18n key parity', () => runNodeScript('scripts/check-i18n-keys.mjs')],
- ['i18n bundle rebuild', () => runNodeScript('scripts/build-i18n.mjs')],
- ['i18n content guard', () => runNodeScript('scripts/content-guard.mjs')],
- [
- 'i18n translation quality',
- () =>
+ const full = fullRequested || manualAdmissionNeedsFullValidation(manualEvidence.rangeResolved);
+ const classification = manualEvidence.rangeResolved
+ ? classifyChangedFiles(manualEvidence.files)
+ : { kind: 'AMBIGUOUS', categories: ['UNKNOWN'], files: [] };
+ const typecheckRequired = requiresTypecheck(classification, { full });
+
+ console.log(`[local-admission] change class: ${classification.kind}`);
+ console.log(`[local-admission] files considered: ${classification.files.length}`);
+ if (!manualEvidence.rangeResolved)
+ console.log(
+ '[local-admission] change evidence incomplete or unresolved; using conservative full admission',
+ );
+
+ if (!ensureDependencyState()) {
+ report('Dependency state', 'FAIL');
+ process.exit(1);
+ }
+ report('Dependency state', 'PASS');
+
+ runCheck('Toolchain', () => runNodeScript('scripts/check-pnpm-toolchain.mjs', ['--hook']));
+ runCheck('Docs/release truth', () => runNodeScript('scripts/check-doc-metrics.mjs'));
+ runCheck('CSP policy', () => runNodeScript('scripts/check-csp-policy.mjs'));
+ runCheck('Desktop import boundary', () =>
+ runNodeScript('scripts/check-tauri-import-boundary.mjs'),
+ );
+ runCheck('Native readiness', () => runNodeScript('scripts/check-native-readiness.mjs'));
+
+ if (shouldRunAdmissionCheck('i18n', classification.files) || full) {
+ runCheck('i18n key parity', () => runNodeScript('scripts/check-i18n-keys.mjs'));
+ runCheck('i18n bundle rebuild', () => runNodeScript('scripts/build-i18n.mjs'));
+ runCheck('i18n translation quality', () =>
runNodeScript('scripts/i18n-quality-report.mjs', [
'--strict',
'--min-coverage',
@@ -38,22 +71,27 @@ const checks = [
'--max-length-outliers',
'8',
]),
- ],
- ['release/doc truth', () => runNodeScript('scripts/check-doc-metrics.mjs')],
- ['CSP policy', () => runNodeScript('scripts/check-csp-policy.mjs')],
- ['desktop import boundary', () => runNodeScript('scripts/check-tauri-import-boundary.mjs')],
- ['native readiness', () => runNodeScript('scripts/check-native-readiness.mjs')],
-];
+ );
+ }
-if (!ensureDependencyState()) process.exit(1);
+ if (shouldRunAdmissionCheck('contentGuard', classification.files) || full)
+ runCheck('Content guard', () => runNodeScript('scripts/content-guard.mjs'));
-for (const [name, run] of checks) {
- console.log(`[local-lowend] ${name}`);
- const status = run();
- if (status !== 0) {
- console.error(`[local-lowend] failed: ${name}`);
- process.exit(status);
+ if (typecheckRequired) {
+ runCheck('TypeScript (single checker)', () =>
+ // QNBS-v3: one checker bounds memory use on constrained developer machines.
+ runLocalBinary('tsgo', ['--project', 'tsconfig.tsgo.json', '--noEmit', '--checkers', '1']),
+ );
+ } else {
+ report('TypeScript', 'DEFERRED_TO_REQUIRED_CI', 'no TypeScript-impacting changes detected');
}
+
+ console.log('\nLOCAL ADMISSION RESULT');
+ console.log('Local checks completed sequentially.');
+ console.log('Cloud validation required YES');
+ console.log(`Classification ${classification.kind}`);
}
-console.log('[local-lowend] pre-push checks passed sequentially.');
+// QNBS-v3: guard execution so this module can be imported for testing without running the CLI.
+const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
+if (isMainModule) main();
diff --git a/scripts/ci-prepush-range-resolver.d.mts b/scripts/ci-prepush-range-resolver.d.mts
new file mode 100644
index 000000000..acea69cd4
--- /dev/null
+++ b/scripts/ci-prepush-range-resolver.d.mts
@@ -0,0 +1,23 @@
+export interface ManualChangeEvidence {
+ readonly files: readonly string[];
+ readonly rangeResolved: boolean;
+}
+
+export interface ManualRangeDependencies {
+ readonly resolveUpstream?: () => string | null;
+ readonly diffNames?: (range: string) => readonly string[] | null;
+ readonly workingTreeFiles?: () => readonly string[];
+}
+
+export interface ManualEvidenceDependencies extends ManualRangeDependencies {
+ readonly resolvePushEvidence?: (input: unknown, cwd: string) => unknown;
+ readonly readPrePushEvidenceFile?: (file: string) => unknown;
+}
+
+export function changedFilesFromManualRange(
+ dependencies?: ManualRangeDependencies,
+): ManualChangeEvidence;
+export function resolveManualEvidence(
+ evidenceFile: string | undefined,
+ dependencies?: ManualEvidenceDependencies,
+): ManualChangeEvidence;
diff --git a/scripts/ci-prepush-range-resolver.mjs b/scripts/ci-prepush-range-resolver.mjs
new file mode 100644
index 000000000..a8f88188a
--- /dev/null
+++ b/scripts/ci-prepush-range-resolver.mjs
@@ -0,0 +1,55 @@
+import { spawnSync } from 'node:child_process';
+import process from 'node:process';
+import { readPrePushEvidenceFile, resolvePushEvidence } from './signing/signing-core.mjs';
+
+function parseNulDelimitedPaths(output) {
+ return output.split('\0').filter(Boolean);
+}
+
+function defaultResolveUpstream() {
+ const result = spawnSync('git', ['rev-parse', '--verify', '@{upstream}'], { encoding: 'utf8' });
+ return result.status === 0 ? (result.stdout ?? '').trim() : null;
+}
+
+// QNBS-v3: returns null (not []) on failure so a broken diff can't masquerade as an empty range.
+function defaultDiffNames(range) {
+ const result = spawnSync('git', ['diff', '--no-renames', '--name-only', '-z', range], {
+ encoding: 'utf8',
+ });
+ if (result.status !== 0) return null;
+ return parseNulDelimitedPaths(result.stdout ?? '');
+}
+
+function defaultWorkingTreeFiles() {
+ const staged = spawnSync('git', ['diff', '--no-renames', '--name-only', '-z', 'HEAD'], {
+ encoding: 'utf8',
+ });
+ const untracked = spawnSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], {
+ encoding: 'utf8',
+ });
+ return parseNulDelimitedPaths(staged.status === 0 ? (staged.stdout ?? '') : '').concat(
+ parseNulDelimitedPaths(untracked.status === 0 ? (untracked.stdout ?? '') : ''),
+ );
+}
+
+export function changedFilesFromManualRange(dependencies = {}) {
+ const resolveUpstream = dependencies.resolveUpstream ?? defaultResolveUpstream;
+ const diffNames = dependencies.diffNames ?? defaultDiffNames;
+ const workingTreeFiles = dependencies.workingTreeFiles ?? defaultWorkingTreeFiles;
+
+ const upstream = resolveUpstream();
+ if (!upstream) return { files: [], rangeResolved: false };
+ const diffFiles = diffNames(`${upstream}..HEAD`);
+ if (diffFiles === null) return { files: [], rangeResolved: false };
+ return { files: diffFiles.concat(workingTreeFiles()), rangeResolved: true };
+}
+
+export function resolveManualEvidence(evidenceFile, dependencies = {}) {
+ if (evidenceFile === undefined) return changedFilesFromManualRange(dependencies);
+ const resolveEvidence = dependencies.resolvePushEvidence ?? resolvePushEvidence;
+ const readEvidenceFile = dependencies.readPrePushEvidenceFile ?? readPrePushEvidenceFile;
+ const evidence = resolveEvidence(readEvidenceFile(evidenceFile), process.cwd());
+ if (evidence.evidenceState !== 'RESOLVED') throw new Error(evidence.reason ?? 'invalid evidence');
+ // QNBS-v3: pathEvidenceState, not evidenceState, tells us whether changedFiles is trustworthy.
+ return { files: evidence.changedFiles, rangeResolved: evidence.pathEvidenceState === 'COMPLETE' };
+}
diff --git a/tests/unit/tooling/ciPrepushClassifier.test.ts b/tests/unit/tooling/ciPrepushClassifier.test.ts
new file mode 100644
index 000000000..bea2012bc
--- /dev/null
+++ b/tests/unit/tooling/ciPrepushClassifier.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from 'vitest';
+import { shouldRunAdmissionCheck } from '../../../scripts/ci-prepush-check-registry.mjs';
+import {
+ classifyChangedFiles,
+ classifyFile,
+ manualAdmissionNeedsFullValidation,
+ requiresTypecheck,
+} from '../../../scripts/ci-prepush-classifier.mjs';
+
+describe('change-aware local admission classifier', () => {
+ it.each([
+ ['README.md', 'DOCS'],
+ ['.github/workflows/ci.yml', 'WORKFLOW'],
+ ['tests/unit/example.test.ts', 'TYPESCRIPT_APPLICATION'],
+ ['tests/fixtures/example.json', 'TEST_ONLY'],
+ ['src-tauri/src/lib.rs', 'RUST_TAURI'],
+ ['scripts/check-example.mjs', 'TOOLING'],
+ ['scripts/coverage-thresholds.json', 'TYPESCRIPT_APPLICATION'],
+ ['unknown-extension.data', 'UNKNOWN'],
+ ])('classifies %s as %s', (file, expected) => {
+ expect(classifyFile(file)).toBe(expected);
+ });
+
+ it('runs TypeScript for TypeScript tests but defers non-TypeScript tests', () => {
+ const ts = classifyChangedFiles(['tests/unit/example.test.ts']);
+ const fixture = classifyChangedFiles(['tests/fixtures/example.json']);
+
+ expect(ts.kind).toBe('TYPESCRIPT_APPLICATION');
+ expect(requiresTypecheck(ts)).toBe(true);
+ expect(fixture.kind).toBe('TEST_ONLY');
+ expect(requiresTypecheck(fixture)).toBe(false);
+ });
+
+ it('requires typecheck for a coverage-thresholds.json-only change despite living under scripts/', () => {
+ const classification = classifyChangedFiles(['scripts/coverage-thresholds.json']);
+
+ expect(classification.kind).toBe('TYPESCRIPT_APPLICATION');
+ expect(requiresTypecheck(classification)).toBe(true);
+ });
+
+ it('uses the broad safe class for unknown or mixed changes', () => {
+ const unknown = classifyChangedFiles(['README.md', 'new-file.data']);
+ const mixed = classifyChangedFiles(['components/App.tsx', 'README.md']);
+
+ expect(unknown.kind).toBe('AMBIGUOUS');
+ expect(requiresTypecheck(unknown)).toBe(true);
+ expect(mixed.kind).toBe('MIXED');
+ expect(requiresTypecheck(mixed)).toBe(true);
+ });
+
+ it('routes only governed files and implementation changes to admission checks', () => {
+ expect(shouldRunAdmissionCheck('i18n', ['locales/en/common.json'])).toBe(true);
+ expect(shouldRunAdmissionCheck('i18n', ['README.md'])).toBe(false);
+ expect(shouldRunAdmissionCheck('i18n', ['scripts/ci-prepush-classifier.mjs'])).toBe(true);
+ expect(shouldRunAdmissionCheck('i18n', ['scripts/build-i18n.mjs'])).toBe(true);
+ expect(shouldRunAdmissionCheck('i18n', ['scripts/i18n-quality-report.mjs'])).toBe(true);
+ expect(shouldRunAdmissionCheck('contentGuard', ['community-templates/index.json'])).toBe(true);
+ expect(shouldRunAdmissionCheck('contentGuard', ['README.md'])).toBe(false);
+ expect(shouldRunAdmissionCheck('i18n', ['scripts/ci-prepush-lowend.mjs'])).toBe(true);
+ expect(shouldRunAdmissionCheck('contentGuard', ['scripts/ci-prepush-lowend.mjs'])).toBe(true);
+ });
+
+ it('keeps TypeScript tooling files in the typecheck-required class', () => {
+ const classification = classifyChangedFiles([
+ 'scripts/check-tooling.ts',
+ 'scripts/types.d.mts',
+ ]);
+
+ expect(classification.kind).toBe('TYPESCRIPT_APPLICATION');
+ expect(requiresTypecheck(classification)).toBe(true);
+ });
+
+ it('requires conservative full admission when the manual range is unresolved', () => {
+ expect(manualAdmissionNeedsFullValidation(true)).toBe(false);
+ expect(manualAdmissionNeedsFullValidation(false)).toBe(true);
+
+ const earlierTypeScript = classifyChangedFiles(['src/app.tsx']);
+ const earlierI18n = classifyChangedFiles(['locales/en/common.json']);
+ const earlierContent = classifyChangedFiles(['community-templates/index.json']);
+
+ expect(requiresTypecheck(earlierTypeScript)).toBe(true);
+ expect(earlierI18n.kind).toBe('AMBIGUOUS');
+ expect(earlierContent.kind).toBe('AMBIGUOUS');
+ });
+});
diff --git a/tests/unit/tooling/ciPrepushRangeResolver.test.ts b/tests/unit/tooling/ciPrepushRangeResolver.test.ts
new file mode 100644
index 000000000..de047cfb9
--- /dev/null
+++ b/tests/unit/tooling/ciPrepushRangeResolver.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from 'vitest';
+import {
+ changedFilesFromManualRange,
+ resolveManualEvidence,
+} from '../../../scripts/ci-prepush-range-resolver.mjs';
+
+describe('manual committed-range resolution', () => {
+ it('is unresolved when no upstream is configured', () => {
+ const result = changedFilesFromManualRange({ resolveUpstream: () => null });
+
+ expect(result).toEqual({ files: [], rangeResolved: false });
+ });
+
+ it('resolves and merges working-tree changes when the diff succeeds', () => {
+ const result = changedFilesFromManualRange({
+ resolveUpstream: () => 'origin/main',
+ diffNames: (range) => {
+ expect(range).toBe('origin/main..HEAD');
+ return ['src/committed.ts'];
+ },
+ workingTreeFiles: () => ['src/dirty.ts'],
+ });
+
+ expect(result).toEqual({ files: ['src/committed.ts', 'src/dirty.ts'], rangeResolved: true });
+ });
+
+ // QNBS-v3: regression for the fail-open bug — a failed diff must not read as an empty resolved range.
+ it('fails closed when the upstream resolves but the diff command itself fails', () => {
+ const result = changedFilesFromManualRange({
+ resolveUpstream: () => 'origin/main',
+ diffNames: () => null,
+ workingTreeFiles: () => {
+ throw new Error('must not be called when the diff already failed');
+ },
+ });
+
+ expect(result).toEqual({ files: [], rangeResolved: false });
+ });
+
+ it('treats a genuinely empty diff as a resolved, complete range', () => {
+ const result = changedFilesFromManualRange({
+ resolveUpstream: () => 'origin/main',
+ diffNames: () => [],
+ workingTreeFiles: () => [],
+ });
+
+ expect(result).toEqual({ files: [], rangeResolved: true });
+ });
+});
+
+describe('resolveManualEvidence', () => {
+ it('falls back to the manual committed-range resolver when no evidence file is given', () => {
+ const result = resolveManualEvidence(undefined, {
+ resolveUpstream: () => null,
+ });
+
+ expect(result).toEqual({ files: [], rangeResolved: false });
+ });
+
+ it('trusts changedFiles as complete when pathEvidenceState is COMPLETE', () => {
+ const result = resolveManualEvidence('/tmp/evidence.json', {
+ readPrePushEvidenceFile: (file) => {
+ expect(file).toBe('/tmp/evidence.json');
+ return 'raw';
+ },
+ resolvePushEvidence: () => ({
+ evidenceState: 'RESOLVED',
+ pathEvidenceState: 'COMPLETE',
+ changedFiles: ['src/example.ts'],
+ }),
+ });
+
+ expect(result).toEqual({ files: ['src/example.ts'], rangeResolved: true });
+ });
+
+ // QNBS-v3: wiring check — a PARTIAL tag push must not be treated as a complete file list.
+ it('treats PARTIAL path evidence as unresolved for admission purposes', () => {
+ const result = resolveManualEvidence('/tmp/evidence.json', {
+ readPrePushEvidenceFile: () => 'raw',
+ resolvePushEvidence: () => ({
+ evidenceState: 'RESOLVED',
+ pathEvidenceState: 'PARTIAL',
+ changedFiles: [],
+ }),
+ });
+
+ expect(result).toEqual({ files: [], rangeResolved: false });
+ });
+
+ it('throws for INVALID evidence', () => {
+ expect(() =>
+ resolveManualEvidence('/tmp/evidence.json', {
+ readPrePushEvidenceFile: () => 'raw',
+ resolvePushEvidence: () => ({
+ evidenceState: 'INVALID',
+ pathEvidenceState: 'PARTIAL',
+ changedFiles: [],
+ reason: 'boom',
+ }),
+ }),
+ ).toThrow('boom');
+ });
+});
From 57ca37df94ead37b85fa26765bc23221177a2072 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 25 Aug 2026 13:22:58 +0200
Subject: [PATCH 2/6] fix(admission): close review-loop findings on
change-aware checks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Consolidated E1 remediation for PR #499's first review epoch, covering
every current-material finding from Sourcery, CodeAnt, CodeRabbit, and
chatgpt-codex-connector:
- Working-tree Git-command failures (diff HEAD / ls-files --others) were
silently converted to an empty path list instead of propagating
failure; changedFilesFromManualRange() could still return
rangeResolved:true. Both now return null on failure the same way the
committed-range diff already did.
- scripts/ci-prepush-range-resolver.mjs determines what evidence is
trusted but wasn't in either governed check's implementationFiles set,
so a resolver-only change could skip i18n/content-guard admission.
Added to both, with regression assertions.
- JSON fixtures under tests/fixtures/ get inferred TypeScript types
(confirmed: tests/unit/services/logger.test.ts and
features/project/coreEnvelope.test.ts both import one directly) and
were wrongly deferred as TEST_ONLY. Scoped narrowly to
tests/fixtures/**/*.json, not all of tests/, based on the actual
fixture tree.
- ci-prepush-check-registry.mjs exported an internal admissionCheckRegistry
the paired .d.mts didn't declare; removed the export instead of
widening the public surface, since nothing external consumes it.
- Extracted isMainModule() as a tested pure function (was inline
process.argv[1] === fileURLToPath comparison). Verified via the real
`pnpm run ci:prepush` invocation that main() already executed
correctly before this change (Node resolves process.argv[1] to an
absolute path even for a relative `node scripts/x.mjs` invocation) —
Sourcery's finding was a false positive, kept as a cheap defensive
normalization rather than reverted.
README test-count metrics resynced via the existing `pnpm run
sync:readme` authority (6982+ tests / 577 files) rather than hand-edited,
fixing the doc-metrics drift gate failure from E0.
Validated: full `pnpm run ci:prepush` passes end-to-end (dependency
state, toolchain, docs, CSP, desktop-import boundary, native readiness,
i18n key parity/bundle/quality, content guard, typecheck), not just the
targeted test/lint/typecheck subset.
---
README.md | 8 +++---
scripts/ci-prepush-check-registry.mjs | 5 +++-
scripts/ci-prepush-classifier.mjs | 7 ++++-
scripts/ci-prepush-lowend.mjs | 6 ++--
scripts/ci-prepush-range-resolver.d.mts | 3 +-
scripts/ci-prepush-range-resolver.mjs | 20 +++++++++++--
.../unit/tooling/ciPrepushClassifier.test.ts | 28 +++++++++++++++----
.../tooling/ciPrepushRangeResolver.test.ts | 26 +++++++++++++++++
8 files changed, 84 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index a36bc2464..740328615 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (6963+ tests / 575 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (6982+ tests / 577 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (6963+ tests, 575 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (6982+ tests, 577 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **6963+ unit tests** across **575 test files** — CI is authoritative for pass/fail
+- **6982+ unit tests** across **577 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/scripts/ci-prepush-check-registry.mjs b/scripts/ci-prepush-check-registry.mjs
index 47b72ab56..f74514dea 100644
--- a/scripts/ci-prepush-check-registry.mjs
+++ b/scripts/ci-prepush-check-registry.mjs
@@ -7,7 +7,8 @@ const i18nPolicyFiles = new Set([
'scripts/i18n-quality-report.mjs',
]);
-export const admissionCheckRegistry = Object.freeze([
+// QNBS-v3: not exported — shouldRunAdmissionCheck is the public API, nothing else consumes this.
+const admissionCheckRegistry = Object.freeze([
{
name: 'i18n',
matches: (file) =>
@@ -18,6 +19,7 @@ export const admissionCheckRegistry = Object.freeze([
routingAuthority,
runnerAuthority,
'scripts/ci-prepush-classifier.mjs',
+ 'scripts/ci-prepush-range-resolver.mjs',
]),
},
{
@@ -30,6 +32,7 @@ export const admissionCheckRegistry = Object.freeze([
routingAuthority,
runnerAuthority,
'scripts/ci-prepush-classifier.mjs',
+ 'scripts/ci-prepush-range-resolver.mjs',
]),
},
]);
diff --git a/scripts/ci-prepush-classifier.mjs b/scripts/ci-prepush-classifier.mjs
index ae23f63a6..6a91eb5fa 100644
--- a/scripts/ci-prepush-classifier.mjs
+++ b/scripts/ci-prepush-classifier.mjs
@@ -59,8 +59,13 @@ export function classifyFile(file) {
) {
return 'RUST_TAURI';
}
+ // QNBS-v3: tests/fixtures/**/*.json is imported with inferred TS types (e.g. logger.test.ts),
+ // confirmed by grep — typecheck it conservatively rather than treating it as safe test data.
if (normalized.startsWith('tests/'))
- return TS_FILE.test(normalized) ? 'TYPESCRIPT_APPLICATION' : 'TEST_ONLY';
+ return TS_FILE.test(normalized) ||
+ (normalized.startsWith('tests/fixtures/') && normalized.endsWith('.json'))
+ ? 'TYPESCRIPT_APPLICATION'
+ : 'TEST_ONLY';
if (TS_FILE.test(normalized)) return 'TYPESCRIPT_APPLICATION';
if (TYPED_CONFIG_INPUTS.has(normalized)) return 'TYPESCRIPT_APPLICATION';
if (TOOLING_FILES.has(normalized) || startsWithRoot(normalized, TOOLING_ROOTS)) return 'TOOLING';
diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs
index e79311903..4dae666ad 100644
--- a/scripts/ci-prepush-lowend.mjs
+++ b/scripts/ci-prepush-lowend.mjs
@@ -1,12 +1,11 @@
import process from 'node:process';
-import { fileURLToPath } from 'node:url';
import { shouldRunAdmissionCheck } from './ci-prepush-check-registry.mjs';
import {
classifyChangedFiles,
manualAdmissionNeedsFullValidation,
requiresTypecheck,
} from './ci-prepush-classifier.mjs';
-import { resolveManualEvidence } from './ci-prepush-range-resolver.mjs';
+import { isMainModule, resolveManualEvidence } from './ci-prepush-range-resolver.mjs';
import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/shared.mjs';
function report(name, status, detail = '') {
@@ -93,5 +92,4 @@ function main() {
}
// QNBS-v3: guard execution so this module can be imported for testing without running the CLI.
-const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
-if (isMainModule) main();
+if (isMainModule(process.argv[1], import.meta.url)) main();
diff --git a/scripts/ci-prepush-range-resolver.d.mts b/scripts/ci-prepush-range-resolver.d.mts
index acea69cd4..5820b0306 100644
--- a/scripts/ci-prepush-range-resolver.d.mts
+++ b/scripts/ci-prepush-range-resolver.d.mts
@@ -6,7 +6,7 @@ export interface ManualChangeEvidence {
export interface ManualRangeDependencies {
readonly resolveUpstream?: () => string | null;
readonly diffNames?: (range: string) => readonly string[] | null;
- readonly workingTreeFiles?: () => readonly string[];
+ readonly workingTreeFiles?: () => readonly string[] | null;
}
export interface ManualEvidenceDependencies extends ManualRangeDependencies {
@@ -14,6 +14,7 @@ export interface ManualEvidenceDependencies extends ManualRangeDependencies {
readonly readPrePushEvidenceFile?: (file: string) => unknown;
}
+export function isMainModule(argv1: string | undefined, moduleUrl: string): boolean;
export function changedFilesFromManualRange(
dependencies?: ManualRangeDependencies,
): ManualChangeEvidence;
diff --git a/scripts/ci-prepush-range-resolver.mjs b/scripts/ci-prepush-range-resolver.mjs
index a8f88188a..eb7922656 100644
--- a/scripts/ci-prepush-range-resolver.mjs
+++ b/scripts/ci-prepush-range-resolver.mjs
@@ -1,7 +1,16 @@
import { spawnSync } from 'node:child_process';
+import { resolve } from 'node:path';
import process from 'node:process';
+import { fileURLToPath } from 'node:url';
import { readPrePushEvidenceFile, resolvePushEvidence } from './signing/signing-core.mjs';
+// QNBS-v3: resolve() normalizes a relative argv1 (e.g. `node scripts/x.mjs`) before comparing —
+// a bare `argv1 === fileURLToPath(moduleUrl)` is always false for a relative invocation, which
+// silently skips main() and makes the whole admission gate a no-op.
+export function isMainModule(argv1, moduleUrl) {
+ return argv1 !== undefined && resolve(argv1) === fileURLToPath(moduleUrl);
+}
+
function parseNulDelimitedPaths(output) {
return output.split('\0').filter(Boolean);
}
@@ -20,15 +29,18 @@ function defaultDiffNames(range) {
return parseNulDelimitedPaths(result.stdout ?? '');
}
+// QNBS-v3: returns null (not []) on failure — same fail-closed contract as defaultDiffNames.
function defaultWorkingTreeFiles() {
const staged = spawnSync('git', ['diff', '--no-renames', '--name-only', '-z', 'HEAD'], {
encoding: 'utf8',
});
+ if (staged.status !== 0) return null;
const untracked = spawnSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], {
encoding: 'utf8',
});
- return parseNulDelimitedPaths(staged.status === 0 ? (staged.stdout ?? '') : '').concat(
- parseNulDelimitedPaths(untracked.status === 0 ? (untracked.stdout ?? '') : ''),
+ if (untracked.status !== 0) return null;
+ return parseNulDelimitedPaths(staged.stdout ?? '').concat(
+ parseNulDelimitedPaths(untracked.stdout ?? ''),
);
}
@@ -41,7 +53,9 @@ export function changedFilesFromManualRange(dependencies = {}) {
if (!upstream) return { files: [], rangeResolved: false };
const diffFiles = diffNames(`${upstream}..HEAD`);
if (diffFiles === null) return { files: [], rangeResolved: false };
- return { files: diffFiles.concat(workingTreeFiles()), rangeResolved: true };
+ const workingFiles = workingTreeFiles();
+ if (workingFiles === null) return { files: [], rangeResolved: false };
+ return { files: diffFiles.concat(workingFiles), rangeResolved: true };
}
export function resolveManualEvidence(evidenceFile, dependencies = {}) {
diff --git a/tests/unit/tooling/ciPrepushClassifier.test.ts b/tests/unit/tooling/ciPrepushClassifier.test.ts
index bea2012bc..6838c63ca 100644
--- a/tests/unit/tooling/ciPrepushClassifier.test.ts
+++ b/tests/unit/tooling/ciPrepushClassifier.test.ts
@@ -12,7 +12,9 @@ describe('change-aware local admission classifier', () => {
['README.md', 'DOCS'],
['.github/workflows/ci.yml', 'WORKFLOW'],
['tests/unit/example.test.ts', 'TYPESCRIPT_APPLICATION'],
- ['tests/fixtures/example.json', 'TEST_ONLY'],
+ ['tests/fixtures/example.json', 'TYPESCRIPT_APPLICATION'],
+ ['tests/unit/example.test.ts.snap', 'TEST_ONLY'],
+ ['tests/unit/tooling/other-artifact.json', 'TEST_ONLY'],
['src-tauri/src/lib.rs', 'RUST_TAURI'],
['scripts/check-example.mjs', 'TOOLING'],
['scripts/coverage-thresholds.json', 'TYPESCRIPT_APPLICATION'],
@@ -21,14 +23,25 @@ describe('change-aware local admission classifier', () => {
expect(classifyFile(file)).toBe(expected);
});
- it('runs TypeScript for TypeScript tests but defers non-TypeScript tests', () => {
+ it('runs TypeScript for TypeScript tests but defers non-TypeScript, non-JSON test assets', () => {
const ts = classifyChangedFiles(['tests/unit/example.test.ts']);
- const fixture = classifyChangedFiles(['tests/fixtures/example.json']);
+ const snapshot = classifyChangedFiles(['tests/unit/example.test.ts.snap']);
expect(ts.kind).toBe('TYPESCRIPT_APPLICATION');
expect(requiresTypecheck(ts)).toBe(true);
- expect(fixture.kind).toBe('TEST_ONLY');
- expect(requiresTypecheck(fixture)).toBe(false);
+ expect(snapshot.kind).toBe('TEST_ONLY');
+ expect(requiresTypecheck(snapshot)).toBe(false);
+ });
+
+ // QNBS-v3: fixtures like tests/fixtures/diagnostics/redaction-cases.json get inferred TS types
+ // where a logger test destructures their fields; typecheck fixture JSON conservatively.
+ it('requires typecheck for a JSON test fixture despite living under tests/', () => {
+ const classification = classifyChangedFiles([
+ 'tests/fixtures/diagnostics/redaction-cases.json',
+ ]);
+
+ expect(classification.kind).toBe('TYPESCRIPT_APPLICATION');
+ expect(requiresTypecheck(classification)).toBe(true);
});
it('requires typecheck for a coverage-thresholds.json-only change despite living under scripts/', () => {
@@ -58,6 +71,11 @@ describe('change-aware local admission classifier', () => {
expect(shouldRunAdmissionCheck('contentGuard', ['README.md'])).toBe(false);
expect(shouldRunAdmissionCheck('i18n', ['scripts/ci-prepush-lowend.mjs'])).toBe(true);
expect(shouldRunAdmissionCheck('contentGuard', ['scripts/ci-prepush-lowend.mjs'])).toBe(true);
+ // QNBS-v3: the range resolver decides what's admitted — it must self-route like the classifier.
+ expect(shouldRunAdmissionCheck('i18n', ['scripts/ci-prepush-range-resolver.mjs'])).toBe(true);
+ expect(shouldRunAdmissionCheck('contentGuard', ['scripts/ci-prepush-range-resolver.mjs'])).toBe(
+ true,
+ );
});
it('keeps TypeScript tooling files in the typecheck-required class', () => {
diff --git a/tests/unit/tooling/ciPrepushRangeResolver.test.ts b/tests/unit/tooling/ciPrepushRangeResolver.test.ts
index de047cfb9..b3018c6df 100644
--- a/tests/unit/tooling/ciPrepushRangeResolver.test.ts
+++ b/tests/unit/tooling/ciPrepushRangeResolver.test.ts
@@ -1,9 +1,35 @@
+import { resolve } from 'node:path';
+import { pathToFileURL } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
changedFilesFromManualRange,
+ isMainModule,
resolveManualEvidence,
} from '../../../scripts/ci-prepush-range-resolver.mjs';
+describe('isMainModule', () => {
+ const relativeArgv1 = 'scripts/ci-prepush-lowend.mjs';
+ const absoluteArgv1 = resolve(relativeArgv1);
+ const moduleUrl = pathToFileURL(absoluteArgv1).href;
+
+ // QNBS-v3: regression — package.json's ci:prepush script invokes with a relative argv[1].
+ it('recognizes a relative argv1 invocation as the same file (e.g. `node scripts/x.mjs`)', () => {
+ expect(isMainModule(relativeArgv1, moduleUrl)).toBe(true);
+ });
+
+ it('recognizes an absolute argv1 invocation', () => {
+ expect(isMainModule(absoluteArgv1, moduleUrl)).toBe(true);
+ });
+
+ it('is false when imported rather than executed (no argv1)', () => {
+ expect(isMainModule(undefined, moduleUrl)).toBe(false);
+ });
+
+ it('is false for a different file entirely', () => {
+ expect(isMainModule('scripts/other.mjs', moduleUrl)).toBe(false);
+ });
+});
+
describe('manual committed-range resolution', () => {
it('is unresolved when no upstream is configured', () => {
const result = changedFilesFromManualRange({ resolveUpstream: () => null });
From a64dc29e66cdc3e9dafefc73c603a01ed88dd5b3 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 25 Aug 2026 13:48:52 +0200
Subject: [PATCH 3/6] fix(admission): close E1 findings and one self-identified
test gap
Terminal E2 remediation for PR #499, bounded to what the complete E1
review epoch surfaced plus one independently verified validation gap:
- QNBS-v3 comments in ci-prepush-classifier.mjs, ci-prepush-range-resolver.mjs,
and its test file wrapped onto a second physical line, violating this
repo's hard one-line rule (chatgpt-codex-connector, AGENTS.md L245-254).
Shortened all three to fit on one line each.
- locales/** and community-templates/** files fell through classifyFile()
to UNKNOWN -> AMBIGUOUS, triggering an unnecessary typecheck for
i18n/content-only pushes even though shouldRunAdmissionCheck() already
routes them to their own dedicated checks (Graphite). Added a
NON_CODE_ROOTS classification matching the registry's own routing
patterns, and included NON_CODE_ONLY in the mixed-category exemption
so it composes with DOCS/WORKFLOW/TOOLING/TEST_ONLY.
- changedFilesFromManualRange() correctly propagates a null
workingTreeFiles() (working-tree discovery failure) to
rangeResolved:false, but had no direct regression for that specific
path -- only the sibling diffNames() failure was covered. Added it.
README test-count metrics resynced again via `pnpm run sync:readme`
(6984+ tests / 577 files).
Validated: full `pnpm run ci:prepush` passes end-to-end.
---
README.md | 8 +++---
scripts/ci-prepush-classifier.mjs | 15 ++++++++---
scripts/ci-prepush-range-resolver.mjs | 4 +--
.../unit/tooling/ciPrepushClassifier.test.ts | 25 ++++++++++++++-----
.../tooling/ciPrepushRangeResolver.test.ts | 11 ++++++++
5 files changed, 47 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index 740328615..e398883a5 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (6982+ tests / 577 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (6984+ tests / 577 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (6982+ tests, 577 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (6984+ tests, 577 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **6982+ unit tests** across **577 test files** — CI is authoritative for pass/fail
+- **6984+ unit tests** across **577 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/scripts/ci-prepush-classifier.mjs b/scripts/ci-prepush-classifier.mjs
index 6a91eb5fa..5a6e5388d 100644
--- a/scripts/ci-prepush-classifier.mjs
+++ b/scripts/ci-prepush-classifier.mjs
@@ -1,6 +1,13 @@
const DOC_FILE = /\.(?:md|mdx)$/i;
const TS_FILE = /\.(?:c|m)?tsx?$/i;
const WORKFLOW_ROOTS = ['.github/workflows/', '.github/actions/'];
+// QNBS-v3: matches the i18n/contentGuard registry's own routing so both stay in sync.
+const NON_CODE_ROOTS = [
+ 'locales/',
+ 'public/locales/',
+ 'community-templates/',
+ 'public/community-templates/',
+];
const RUST_ROOTS = ['src-tauri/', 'crates/'];
const TOOLING_ROOTS = ['scripts/'];
const TOOLING_FILES = new Set(['.gitleaks.toml']);
@@ -59,8 +66,8 @@ export function classifyFile(file) {
) {
return 'RUST_TAURI';
}
- // QNBS-v3: tests/fixtures/**/*.json is imported with inferred TS types (e.g. logger.test.ts),
- // confirmed by grep — typecheck it conservatively rather than treating it as safe test data.
+ if (startsWithRoot(normalized, NON_CODE_ROOTS)) return 'NON_CODE_ONLY';
+ // QNBS-v3: tests/fixtures/**/*.json gets inferred TS types (e.g. logger.test.ts) — typecheck it.
if (normalized.startsWith('tests/'))
return TS_FILE.test(normalized) ||
(normalized.startsWith('tests/fixtures/') && normalized.endsWith('.json'))
@@ -97,7 +104,9 @@ export function classifyChangedFiles(files) {
return { kind: categories[0], categories, files: normalizedFiles };
}
if (
- categories.every((category) => ['DOCS', 'WORKFLOW', 'TOOLING', 'TEST_ONLY'].includes(category))
+ categories.every((category) =>
+ ['DOCS', 'WORKFLOW', 'TOOLING', 'TEST_ONLY', 'NON_CODE_ONLY'].includes(category),
+ )
)
return { kind: 'NON_CODE_ONLY', categories, files: normalizedFiles };
if (categories.includes('UNKNOWN'))
diff --git a/scripts/ci-prepush-range-resolver.mjs b/scripts/ci-prepush-range-resolver.mjs
index eb7922656..59faa175f 100644
--- a/scripts/ci-prepush-range-resolver.mjs
+++ b/scripts/ci-prepush-range-resolver.mjs
@@ -4,9 +4,7 @@ import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { readPrePushEvidenceFile, resolvePushEvidence } from './signing/signing-core.mjs';
-// QNBS-v3: resolve() normalizes a relative argv1 (e.g. `node scripts/x.mjs`) before comparing —
-// a bare `argv1 === fileURLToPath(moduleUrl)` is always false for a relative invocation, which
-// silently skips main() and makes the whole admission gate a no-op.
+// QNBS-v3: resolve() normalizes a relative argv1 so it still matches an always-absolute moduleUrl.
export function isMainModule(argv1, moduleUrl) {
return argv1 !== undefined && resolve(argv1) === fileURLToPath(moduleUrl);
}
diff --git a/tests/unit/tooling/ciPrepushClassifier.test.ts b/tests/unit/tooling/ciPrepushClassifier.test.ts
index 6838c63ca..2814430cc 100644
--- a/tests/unit/tooling/ciPrepushClassifier.test.ts
+++ b/tests/unit/tooling/ciPrepushClassifier.test.ts
@@ -18,11 +18,29 @@ describe('change-aware local admission classifier', () => {
['src-tauri/src/lib.rs', 'RUST_TAURI'],
['scripts/check-example.mjs', 'TOOLING'],
['scripts/coverage-thresholds.json', 'TYPESCRIPT_APPLICATION'],
+ ['locales/en/common.json', 'NON_CODE_ONLY'],
+ ['public/locales/en/bundle.json', 'NON_CODE_ONLY'],
+ ['community-templates/index.json', 'NON_CODE_ONLY'],
+ ['public/community-templates/index.json', 'NON_CODE_ONLY'],
['unknown-extension.data', 'UNKNOWN'],
])('classifies %s as %s', (file, expected) => {
expect(classifyFile(file)).toBe(expected);
});
+ // QNBS-v3: i18n/content-template-only pushes must not trigger typecheck (efficiency, not safety).
+ it('does not require typecheck for i18n or community-template-only changes', () => {
+ const i18nOnly = classifyChangedFiles(['locales/en/common.json', 'locales/de/common.json']);
+ const templateOnly = classifyChangedFiles(['community-templates/index.json']);
+ const i18nWithDocs = classifyChangedFiles(['locales/en/common.json', 'README.md']);
+
+ expect(i18nOnly.kind).toBe('NON_CODE_ONLY');
+ expect(requiresTypecheck(i18nOnly)).toBe(false);
+ expect(templateOnly.kind).toBe('NON_CODE_ONLY');
+ expect(requiresTypecheck(templateOnly)).toBe(false);
+ expect(i18nWithDocs.kind).toBe('NON_CODE_ONLY');
+ expect(requiresTypecheck(i18nWithDocs)).toBe(false);
+ });
+
it('runs TypeScript for TypeScript tests but defers non-TypeScript, non-JSON test assets', () => {
const ts = classifyChangedFiles(['tests/unit/example.test.ts']);
const snapshot = classifyChangedFiles(['tests/unit/example.test.ts.snap']);
@@ -33,8 +51,7 @@ describe('change-aware local admission classifier', () => {
expect(requiresTypecheck(snapshot)).toBe(false);
});
- // QNBS-v3: fixtures like tests/fixtures/diagnostics/redaction-cases.json get inferred TS types
- // where a logger test destructures their fields; typecheck fixture JSON conservatively.
+ // QNBS-v3: redaction-cases.json gets inferred TS types in logger.test.ts — typecheck fixture JSON.
it('requires typecheck for a JSON test fixture despite living under tests/', () => {
const classification = classifyChangedFiles([
'tests/fixtures/diagnostics/redaction-cases.json',
@@ -93,11 +110,7 @@ describe('change-aware local admission classifier', () => {
expect(manualAdmissionNeedsFullValidation(false)).toBe(true);
const earlierTypeScript = classifyChangedFiles(['src/app.tsx']);
- const earlierI18n = classifyChangedFiles(['locales/en/common.json']);
- const earlierContent = classifyChangedFiles(['community-templates/index.json']);
expect(requiresTypecheck(earlierTypeScript)).toBe(true);
- expect(earlierI18n.kind).toBe('AMBIGUOUS');
- expect(earlierContent.kind).toBe('AMBIGUOUS');
});
});
diff --git a/tests/unit/tooling/ciPrepushRangeResolver.test.ts b/tests/unit/tooling/ciPrepushRangeResolver.test.ts
index b3018c6df..d1a7330b5 100644
--- a/tests/unit/tooling/ciPrepushRangeResolver.test.ts
+++ b/tests/unit/tooling/ciPrepushRangeResolver.test.ts
@@ -72,6 +72,17 @@ describe('manual committed-range resolution', () => {
expect(result).toEqual({ files: [], rangeResolved: true });
});
+
+ // QNBS-v3: regression — a successful committed-range diff must not mask a working-tree failure.
+ it('fails closed when the committed-range diff succeeds but working-tree discovery fails', () => {
+ const result = changedFilesFromManualRange({
+ resolveUpstream: () => 'origin/main',
+ diffNames: () => ['src/committed.ts'],
+ workingTreeFiles: () => null,
+ });
+
+ expect(result).toEqual({ files: [], rangeResolved: false });
+ });
});
describe('resolveManualEvidence', () => {
From 94f503b4e1a87867afdf47a3e40ecb3bb03d5da5 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 25 Aug 2026 14:03:28 +0200
Subject: [PATCH 4/6] docs: reconcile pre-push gate docs with change-aware
admission
CLAUDE.md and AGENTS.md still described pnpm run ci:prepush as running
the exact CI typecheck and i18n checks unconditionally on every push --
that was accurate for the prior fixed-check-list runner, but this PR
replaced it with change-aware routing (DEFERRED_TO_REQUIRED_CI for
non-impacting classifications, fail-closed to full admission on
incomplete evidence). Runtime and tests are correct and unchanged by
this commit; only the description was stale (confirmed via chatgpt-codex-connector
review findings on PR #499, verified against the actual checked-in text
at this exact branch's HEAD rather than a different branch's copy).
Also documents the same contract in docs/CI.md, which previously had no
mention of the classifier at all.
Docs-only: no runtime, test, or script changes in this commit.
---
AGENTS.md | 18 ++++++++++++------
CLAUDE.md | 4 ++--
docs/CI.md | 21 +++++++++++++++++++++
3 files changed, 35 insertions(+), 8 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index f87667ffd..0915fb71b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -295,12 +295,18 @@ procedure.
### Philosophy
- **Cloud CI-first:** The canonical quality gate is GitHub Actions. Low-end local machines should run only the "Quick" tier.
-- **Quick tier (local, before every push):** `pnpm run ci:prepush` runs the project typecheck with
- one checker, i18n parity/quality/bundle/content checks, release/doc truth, and lightweight desktop guardrails sequentially;
- the pre-commit hook separately runs staged-file Biome checks. Run the gate again after every
- correction before re-pushing; do not
- push based only on a targeted test or a changed-file lint run. Optionally:
- `pnpm exec vitest run ` **without** `--coverage`.
+- **Quick tier (local, before every push):** `pnpm run ci:prepush` always resolves a change-aware
+ classification from the outgoing evidence, then runs release/doc truth and lightweight desktop
+ guardrails sequentially unconditionally. The one-checker project typecheck and i18n
+ parity/quality/bundle/content checks run only when the classification requires them —
+ `DOCS_ONLY`/`WORKFLOW_ONLY`/`NON_CODE_ONLY`/`RUST_TAURI`/`TOOLING`/non-TypeScript `TEST_ONLY`
+ changes report typecheck as deferred to required CI instead, and i18n/content-guard checks run
+ only for their own governed paths; incomplete or unresolved path evidence fails closed into
+ running everything. Required GitHub CI remains the unconditional authority for the full
+ typecheck and i18n validation. The pre-commit hook separately runs staged-file Biome checks. Run
+ the gate again after every correction before re-pushing; do not push based only on a targeted
+ test or a changed-file lint run. Optionally: `pnpm exec vitest run ` **without**
+ `--coverage`.
- **Dependency state:** `pnpm run deps:verify` compares a content fingerprint of dependency
manifests, workspace package manifests, and patches. After a dependency-related branch switch,
run `node scripts/dependency-state.mjs reconcile` (or `pnpm run deps:reconcile` when pnpm can
diff --git a/CLAUDE.md b/CLAUDE.md
index 278a73ce7..6e1eac450 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,9 +41,9 @@ pnpm run token:audit # audit-tokens.mjs — design-token usage gate (CI b
**Vitest watch-mode hard rule:** Never invoke `pnpm test`, `npm run test`, or a bare Vitest wrapper. Always use an explicit targeted `pnpm exec vitest run ` command; watch mode hangs the constrained development hardware.
-**Mandatory pre-push gate:** Run `pnpm run ci:prepush` before every push and again after every local correction before re-pushing. It runs the full repository lint, then the exact CI typecheck and i18n checks sequentially. A targeted test or changed-file lint run alone is insufficient. If pnpm reports dependency verification after a branch or lockfile change, run `pnpm install --frozen-lockfile` first. The pre-commit hook does not replace this gate.
+**Mandatory pre-push gate:** Run `pnpm run ci:prepush` before every push and again after every local correction before re-pushing. It always resolves a change-aware classification (`scripts/ci-prepush-classifier.mjs`) from the outgoing evidence first, then runs lint, docs/CSP/native-readiness guardrails, and dependency-state checks unconditionally. The exact CI typecheck and the i18n/content-guard checks run only when the classification requires them — `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, and non-TypeScript `TEST_ONLY` changes report typecheck as `DEFERRED_TO_REQUIRED_CI` instead of running it locally, and i18n/content-guard checks run only for changes matching their own governed paths or implementation files (see `scripts/ci-prepush-check-registry.mjs`). Whenever outgoing path evidence is incomplete, unresolved, or the manual committed-range diff fails, the gate fails closed into full local admission (every check runs) rather than deferring anything. A targeted test or changed-file lint run alone is insufficient. If pnpm reports dependency verification after a branch or lockfile change, run `pnpm install --frozen-lockfile` first. The pre-commit hook does not replace this gate. Required GitHub CI remains the unconditional authority for the complete TypeScript and i18n validation regardless of what the local gate deferred.
-**Quality gate (local pre-push subset):** `pnpm run ci:prepush` runs the full repository lint followed by the exact CI typecheck and i18n checks; CI additionally runs full-suite coverage and heavy jobs. Locally use only the targeted form `pnpm exec vitest run --coverage` when debugging coverage. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`).
+**Quality gate (local pre-push subset):** `pnpm run ci:prepush` runs the full repository lint plus dependency-state/docs/CSP/native-readiness checks unconditionally, and the exact CI typecheck and i18n/content-guard checks only for changes the classifier marks as potentially impacting them (fail-closed to "run everything" when evidence is incomplete — see the pre-push gate note above); CI additionally runs full-suite coverage and heavy jobs regardless of what the local gate ran. Locally use only the targeted form `pnpm exec vitest run --coverage` when debugging coverage. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`).
**CI pipeline order:** `security` → `quality` (Biome + tsgo + Vitest matrix) → `build` / `e2e` / `storybook` (parallel) → `lighthouse` (after build) → `deploy` on `main`. `ci-success` is a required-status aggregator (`needs: [security, quality, build]`) so branch protection can require one context instead of three/four individual ones — see `docs/CI.md`. Two additional jobs run in parallel with `quality`, both path-scoped via the `changes` job (legitimately `skipping` on PRs that don't touch their directory, which `ci-success` treats as a pass for that job only): `rust-tauri` (`src-tauri/**` — fmt/check/clippy/test, needs the GTK/WebKit apt-get steps) and `core-rust` (`crates/**` — same fmt/check/clippy/test for the renderer-neutral Rust Core, no GUI deps so no apt-get steps needed).
diff --git a/docs/CI.md b/docs/CI.md
index ad059aac6..b76d57a2a 100644
--- a/docs/CI.md
+++ b/docs/CI.md
@@ -306,6 +306,27 @@ pnpm exec vitest run # optional targeted smoke, no coverage
Playwright E2E, Lighthouse, Storybook, and full-suite coverage are intentionally omitted from
the local block above; GitHub Actions owns those heavy checks on this hardware.
+### `ci:prepush` change-aware routing
+
+`pnpm run ci:prepush` always resolves a change classification from the outgoing evidence first
+(`scripts/ci-prepush-classifier.mjs`), then runs lint, docs/release-truth, CSP, desktop-import
+boundary, native-readiness, and dependency-state checks unconditionally on every invocation. Two
+check groups are conditional on that classification instead of always running:
+
+- **TypeScript (single-checker)** — skipped, reporting `DEFERRED_TO_REQUIRED_CI`, when the
+ classification is `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, or
+ non-TypeScript `TEST_ONLY`. Runs for every other classification, including `AMBIGUOUS`/`MIXED`.
+- **i18n (key parity, bundle rebuild, translation quality) and content-guard** — run only when the
+ changed files match their own governed paths or implementation files
+ (`scripts/ci-prepush-check-registry.mjs`), independent of the TypeScript decision above.
+
+**Fail-closed by design:** whenever outgoing path evidence is incomplete — the manual committed
+range can't be resolved, a Git diff command fails, or pre-push-hook evidence reports partial path
+completeness (e.g. a tag-only push) — the gate does not defer anything; it falls back to running
+every check, matching a `--full` invocation. Deferring a check locally never changes what required
+GitHub CI validates: the complete TypeScript and i18n checks always run in CI regardless of what
+the local gate ran or deferred, and CI remains the merge authority.
+
On standard hardware, or when debugging a build-affecting change, run the build-specific checks
separately; CI remains authoritative for the complete build and artifact checks:
From 80b1807552f776ffd991ac88dff0a7e0f78b7197 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 25 Aug 2026 14:13:52 +0200
Subject: [PATCH 5/6] docs: fix false lint claim and reconcile AGENTS.md
sections
Two errors from the prior documentation reconciliation commit,
identified by fresh chatgpt-codex-connector review findings on that
exact commit:
- CLAUDE.md and the new docs/CI.md section both claimed ci:prepush
runs lint unconditionally. scripts/ci-prepush-lowend.mjs has never
invoked Biome/lint at all (grep confirms zero references) -- lint
has only ever been the separate pre-commit hook's job on staged
files, with full-repository lint being CI-owned. Removed the false
claim and made this ownership explicit in all three files.
- AGENTS.md's "Critical Execution Environment Warning" section
(lines 32-36) still described the superseded unconditional
typecheck/i18n behavior after the prior commit only updated the
later "Testing Instructions" section, leaving two authoritative
sections of the same file in direct conflict for the same command.
Reconciled both to describe the same change-aware contract.
Docs-only: no runtime, test, workflow, manifest, or lockfile changes.
---
AGENTS.md | 12 +++++++++++-
CLAUDE.md | 4 ++--
docs/CI.md | 14 ++++++++------
3 files changed, 21 insertions(+), 9 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 0915fb71b..4f045bf52 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,7 +33,17 @@ The app supports a multi-provider AI stack (Gemini, OpenAI, Claude, Grok, OpenRo
```bash
pnpm run ci:prepush
```
- This gate is mandatory before every push and after every local correction before re-pushing; it runs sequentially with a single-checker project typecheck, i18n parity/quality and bundle checks, release/doc truth, and lightweight native guardrails. The pre-commit hook separately runs staged-file Biome checks. Full repository lint, coverage, E2E, Storybook, Lighthouse, and mutation checks belong to cloud CI. If branch switching or a lockfile/package-manifest change makes pnpm report dependency verification errors, run `node scripts/dependency-state.mjs reconcile` and rerun the complete pre-push gate.
+ This gate is mandatory before every push and after every local correction before re-pushing: it
+ always resolves a change-aware classification from the outgoing evidence first, then runs
+ release/doc truth and lightweight native guardrails sequentially and unconditionally. The
+ single-checker project typecheck and the i18n parity/quality/bundle checks run only when that
+ classification requires them (e.g. deferred for docs-, workflow-, tooling-, or non-TypeScript
+ test-only changes); incomplete or unresolved path evidence fails closed into running every
+ conditional check. The pre-commit hook separately runs staged-file Biome checks. Full repository
+ lint, coverage, E2E, Storybook, Lighthouse, and mutation checks belong to cloud CI, which also
+ re-runs the complete typecheck and i18n checks regardless of what the local gate deferred. If
+ branch switching or a lockfile/package-manifest change makes pnpm report dependency verification
+ errors, run `node scripts/dependency-state.mjs reconcile` and rerun the complete pre-push gate.
Optional targeted smoke test: `pnpm exec vitest run ` **without** `--coverage`.
**Hard rule:** Never invoke `pnpm test`, `npm run test`, or a bare Vitest wrapper; always use an explicit `pnpm exec vitest run ` command to avoid watch-mode hangs on constrained hardware. Never start multiple heavyweight processes concurrently.
4. **Audit cloud CI logs, fix locally, then re-push** – If the cloud CI run fails, inspect the logs via GitHub web UI or `gh run watch`, reproduce the specific failing test or lint error in isolation, fix it locally (quick tier to verify), commit, and push again for another cloud CI run.
diff --git a/CLAUDE.md b/CLAUDE.md
index 6e1eac450..c90b31583 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,9 +41,9 @@ pnpm run token:audit # audit-tokens.mjs — design-token usage gate (CI b
**Vitest watch-mode hard rule:** Never invoke `pnpm test`, `npm run test`, or a bare Vitest wrapper. Always use an explicit targeted `pnpm exec vitest run ` command; watch mode hangs the constrained development hardware.
-**Mandatory pre-push gate:** Run `pnpm run ci:prepush` before every push and again after every local correction before re-pushing. It always resolves a change-aware classification (`scripts/ci-prepush-classifier.mjs`) from the outgoing evidence first, then runs lint, docs/CSP/native-readiness guardrails, and dependency-state checks unconditionally. The exact CI typecheck and the i18n/content-guard checks run only when the classification requires them — `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, and non-TypeScript `TEST_ONLY` changes report typecheck as `DEFERRED_TO_REQUIRED_CI` instead of running it locally, and i18n/content-guard checks run only for changes matching their own governed paths or implementation files (see `scripts/ci-prepush-check-registry.mjs`). Whenever outgoing path evidence is incomplete, unresolved, or the manual committed-range diff fails, the gate fails closed into full local admission (every check runs) rather than deferring anything. A targeted test or changed-file lint run alone is insufficient. If pnpm reports dependency verification after a branch or lockfile change, run `pnpm install --frozen-lockfile` first. The pre-commit hook does not replace this gate. Required GitHub CI remains the unconditional authority for the complete TypeScript and i18n validation regardless of what the local gate deferred.
+**Mandatory pre-push gate:** Run `pnpm run ci:prepush` before every push and again after every local correction before re-pushing. It always resolves a change-aware classification (`scripts/ci-prepush-classifier.mjs`) from the outgoing evidence first, then runs docs/release-truth, CSP, desktop-import-boundary, native-readiness, and dependency-state checks unconditionally — it does **not** run Biome lint; that stays the pre-commit hook's job on staged files only (`lint-staged`), and full-repository lint is CI-owned. The exact CI typecheck and the i18n/content-guard checks run only when the classification requires them — `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, and non-TypeScript `TEST_ONLY` changes report typecheck as `DEFERRED_TO_REQUIRED_CI` instead of running it locally, and i18n/content-guard checks run only for changes matching their own governed paths or implementation files (see `scripts/ci-prepush-check-registry.mjs`). Whenever outgoing path evidence is incomplete, unresolved, or the manual committed-range diff fails, the gate fails closed into full local admission (every conditional check runs) rather than deferring anything. A targeted test or changed-file lint run alone is insufficient. If pnpm reports dependency verification after a branch or lockfile change, run `pnpm install --frozen-lockfile` first. The pre-commit hook does not replace this gate. Required GitHub CI remains the unconditional authority for the complete lint, TypeScript, and i18n validation regardless of what the local gate deferred.
-**Quality gate (local pre-push subset):** `pnpm run ci:prepush` runs the full repository lint plus dependency-state/docs/CSP/native-readiness checks unconditionally, and the exact CI typecheck and i18n/content-guard checks only for changes the classifier marks as potentially impacting them (fail-closed to "run everything" when evidence is incomplete — see the pre-push gate note above); CI additionally runs full-suite coverage and heavy jobs regardless of what the local gate ran. Locally use only the targeted form `pnpm exec vitest run --coverage` when debugging coverage. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`).
+**Quality gate (local pre-push subset):** `pnpm run ci:prepush` runs dependency-state/docs/CSP/native-readiness checks unconditionally (never full-repository lint — see the pre-push gate note above for what runs lint locally), and the exact CI typecheck and i18n/content-guard checks only for changes the classifier marks as potentially impacting them (fail-closed to "run everything conditional" when evidence is incomplete); CI additionally runs full-repository lint, full-suite coverage, and heavy jobs regardless of what the local gate ran. Locally use only the targeted form `pnpm exec vitest run --coverage` when debugging coverage. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`).
**CI pipeline order:** `security` → `quality` (Biome + tsgo + Vitest matrix) → `build` / `e2e` / `storybook` (parallel) → `lighthouse` (after build) → `deploy` on `main`. `ci-success` is a required-status aggregator (`needs: [security, quality, build]`) so branch protection can require one context instead of three/four individual ones — see `docs/CI.md`. Two additional jobs run in parallel with `quality`, both path-scoped via the `changes` job (legitimately `skipping` on PRs that don't touch their directory, which `ci-success` treats as a pass for that job only): `rust-tauri` (`src-tauri/**` — fmt/check/clippy/test, needs the GTK/WebKit apt-get steps) and `core-rust` (`crates/**` — same fmt/check/clippy/test for the renderer-neutral Rust Core, no GUI deps so no apt-get steps needed).
diff --git a/docs/CI.md b/docs/CI.md
index b76d57a2a..03285a8aa 100644
--- a/docs/CI.md
+++ b/docs/CI.md
@@ -14,7 +14,7 @@ For historical optimization notes (targets may predate the live workflow), see [
| Tier | Where | Commands / scope |
|------|--------|------------------|
-| **Quick (local)** | Developer laptop | `pnpm run ci:prepush` (single-checker typecheck, i18n quality, release/doc truth, and lightweight guardrails); the pre-commit hook runs staged Biome checks; optional targeted `pnpm exec vitest run ` for a fast smoke |
+| **Quick (local)** | Developer laptop | `pnpm run ci:prepush` (change-aware: single-checker typecheck and i18n/content-guard checks run only when the outgoing change classification requires them, see [`ci:prepush` change-aware routing](#ci-prepush-change-aware-routing); release/doc truth and lightweight guardrails run unconditionally); the pre-commit hook runs staged Biome checks; optional targeted `pnpm exec vitest run ` for a fast smoke |
| **Heavy (CI)** | `ci.yml` | Vitest **with** `--coverage` and thresholds, Playwright E2E (`CI=true`) including **mobile emulation** (Pixel 5 / Chromium), Lighthouse CI, Storybook static build, bundle budget + analyze. Mutation testing (Stryker) is **not** part of this pipeline — see [Mutation testing status](#mutation-testing-status). |
**Merge readiness:** A green workflow run on the PR/branch matters more than reproducing every E2E or LHCI step locally. Use CI **artifacts** (Playwright HTML report, coverage, Lighthouse output) to debug failures.
@@ -309,9 +309,11 @@ the local block above; GitHub Actions owns those heavy checks on this hardware.
### `ci:prepush` change-aware routing
`pnpm run ci:prepush` always resolves a change classification from the outgoing evidence first
-(`scripts/ci-prepush-classifier.mjs`), then runs lint, docs/release-truth, CSP, desktop-import
-boundary, native-readiness, and dependency-state checks unconditionally on every invocation. Two
-check groups are conditional on that classification instead of always running:
+(`scripts/ci-prepush-classifier.mjs`), then runs docs/release-truth, CSP, desktop-import boundary,
+native-readiness, and dependency-state checks unconditionally on every invocation. It does **not**
+run Biome lint — full-repository lint stays CI-owned (`quality` job); only staged files are linted
+locally, by the separate pre-commit hook (`lint-staged`). Two check groups are conditional on the
+change classification instead of always running:
- **TypeScript (single-checker)** — skipped, reporting `DEFERRED_TO_REQUIRED_CI`, when the
classification is `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, or
@@ -324,8 +326,8 @@ check groups are conditional on that classification instead of always running:
range can't be resolved, a Git diff command fails, or pre-push-hook evidence reports partial path
completeness (e.g. a tag-only push) — the gate does not defer anything; it falls back to running
every check, matching a `--full` invocation. Deferring a check locally never changes what required
-GitHub CI validates: the complete TypeScript and i18n checks always run in CI regardless of what
-the local gate ran or deferred, and CI remains the merge authority.
+GitHub CI validates: the complete lint, TypeScript, and i18n checks always run in CI regardless of
+what the local gate ran or deferred, and CI remains the merge authority.
On standard hardware, or when debugging a build-affecting change, run the build-specific checks
separately; CI remains authoritative for the complete build and artifact checks:
From 5873df40457521e907fb0778644ed2c2a0a8c701 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 25 Aug 2026 14:27:36 +0200
Subject: [PATCH 6/6] docs: correct local-typecheck characterization and fix
CI.md anchor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CLAUDE.md described the local ci:prepush typecheck as "the exact CI
typecheck" — it runs tsgo --noEmit with --checkers 1, while required
CI uses --checkers 4. Also pointed dependency-verification recovery
at pnpm install --frozen-lockfile, which does not update the
dependency-state fingerprint; the repository's own authority for
that is scripts/dependency-state.mjs reconcile.
docs/CI.md linked to the "ci:prepush change-aware routing" section
using a hand-guessed fragment (#ci-prepush-change-aware-routing) that
does not match GitHub's generated slug for a heading containing a
colon; corrected to #ciprepush-change-aware-routing.
---
CLAUDE.md | 4 ++--
docs/CI.md | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index c90b31583..aa420ca0a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,9 +41,9 @@ pnpm run token:audit # audit-tokens.mjs — design-token usage gate (CI b
**Vitest watch-mode hard rule:** Never invoke `pnpm test`, `npm run test`, or a bare Vitest wrapper. Always use an explicit targeted `pnpm exec vitest run ` command; watch mode hangs the constrained development hardware.
-**Mandatory pre-push gate:** Run `pnpm run ci:prepush` before every push and again after every local correction before re-pushing. It always resolves a change-aware classification (`scripts/ci-prepush-classifier.mjs`) from the outgoing evidence first, then runs docs/release-truth, CSP, desktop-import-boundary, native-readiness, and dependency-state checks unconditionally — it does **not** run Biome lint; that stays the pre-commit hook's job on staged files only (`lint-staged`), and full-repository lint is CI-owned. The exact CI typecheck and the i18n/content-guard checks run only when the classification requires them — `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, and non-TypeScript `TEST_ONLY` changes report typecheck as `DEFERRED_TO_REQUIRED_CI` instead of running it locally, and i18n/content-guard checks run only for changes matching their own governed paths or implementation files (see `scripts/ci-prepush-check-registry.mjs`). Whenever outgoing path evidence is incomplete, unresolved, or the manual committed-range diff fails, the gate fails closed into full local admission (every conditional check runs) rather than deferring anything. A targeted test or changed-file lint run alone is insufficient. If pnpm reports dependency verification after a branch or lockfile change, run `pnpm install --frozen-lockfile` first. The pre-commit hook does not replace this gate. Required GitHub CI remains the unconditional authority for the complete lint, TypeScript, and i18n validation regardless of what the local gate deferred.
+**Mandatory pre-push gate:** Run `pnpm run ci:prepush` before every push and again after every local correction before re-pushing. It always resolves a change-aware classification (`scripts/ci-prepush-classifier.mjs`) from the outgoing evidence first, then runs docs/release-truth, CSP, desktop-import-boundary, native-readiness, and dependency-state checks unconditionally — it does **not** run Biome lint; that stays the pre-commit hook's job on staged files only (`lint-staged`), and full-repository lint is CI-owned. The single-checker (`--checkers 1`) local typecheck and the i18n/content-guard checks run only when the classification requires them — `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, and non-TypeScript `TEST_ONLY` changes report typecheck as `DEFERRED_TO_REQUIRED_CI` instead of running it locally, and i18n/content-guard checks run only for changes matching their own governed paths or implementation files (see `scripts/ci-prepush-check-registry.mjs`). It is the same `tsgo --noEmit` check as CI, not literally identical to it — CI uses `--checkers 4`. Whenever outgoing path evidence is incomplete, unresolved, or the manual committed-range diff fails, the gate fails closed into full local admission (every conditional check runs) rather than deferring anything. A targeted test or changed-file lint run alone is insufficient. If pnpm reports a dependency verification failure after a branch or lockfile change, run `node scripts/dependency-state.mjs reconcile` (or `pnpm run deps:reconcile`) first, then rerun the gate. The pre-commit hook does not replace this gate. Required GitHub CI remains the unconditional authority for the complete lint, TypeScript, and i18n validation regardless of what the local gate deferred.
-**Quality gate (local pre-push subset):** `pnpm run ci:prepush` runs dependency-state/docs/CSP/native-readiness checks unconditionally (never full-repository lint — see the pre-push gate note above for what runs lint locally), and the exact CI typecheck and i18n/content-guard checks only for changes the classifier marks as potentially impacting them (fail-closed to "run everything conditional" when evidence is incomplete); CI additionally runs full-repository lint, full-suite coverage, and heavy jobs regardless of what the local gate ran. Locally use only the targeted form `pnpm exec vitest run --coverage` when debugging coverage. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`).
+**Quality gate (local pre-push subset):** `pnpm run ci:prepush` runs dependency-state/docs/CSP/native-readiness checks unconditionally (never full-repository lint — see the pre-push gate note above for what runs lint locally), and the single-checker local typecheck and i18n/content-guard checks only for changes the classifier marks as potentially impacting them (fail-closed to "run everything conditional" when evidence is incomplete); CI additionally runs full-repository lint, the 4-checker typecheck, full-suite coverage, and heavy jobs regardless of what the local gate ran. Locally use only the targeted form `pnpm exec vitest run --coverage` when debugging coverage. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`).
**CI pipeline order:** `security` → `quality` (Biome + tsgo + Vitest matrix) → `build` / `e2e` / `storybook` (parallel) → `lighthouse` (after build) → `deploy` on `main`. `ci-success` is a required-status aggregator (`needs: [security, quality, build]`) so branch protection can require one context instead of three/four individual ones — see `docs/CI.md`. Two additional jobs run in parallel with `quality`, both path-scoped via the `changes` job (legitimately `skipping` on PRs that don't touch their directory, which `ci-success` treats as a pass for that job only): `rust-tauri` (`src-tauri/**` — fmt/check/clippy/test, needs the GTK/WebKit apt-get steps) and `core-rust` (`crates/**` — same fmt/check/clippy/test for the renderer-neutral Rust Core, no GUI deps so no apt-get steps needed).
diff --git a/docs/CI.md b/docs/CI.md
index 03285a8aa..25d5dbc4d 100644
--- a/docs/CI.md
+++ b/docs/CI.md
@@ -14,7 +14,7 @@ For historical optimization notes (targets may predate the live workflow), see [
| Tier | Where | Commands / scope |
|------|--------|------------------|
-| **Quick (local)** | Developer laptop | `pnpm run ci:prepush` (change-aware: single-checker typecheck and i18n/content-guard checks run only when the outgoing change classification requires them, see [`ci:prepush` change-aware routing](#ci-prepush-change-aware-routing); release/doc truth and lightweight guardrails run unconditionally); the pre-commit hook runs staged Biome checks; optional targeted `pnpm exec vitest run ` for a fast smoke |
+| **Quick (local)** | Developer laptop | `pnpm run ci:prepush` (change-aware: single-checker typecheck and i18n/content-guard checks run only when the outgoing change classification requires them, see [`ci:prepush` change-aware routing](#ciprepush-change-aware-routing); release/doc truth and lightweight guardrails run unconditionally); the pre-commit hook runs staged Biome checks; optional targeted `pnpm exec vitest run ` for a fast smoke |
| **Heavy (CI)** | `ci.yml` | Vitest **with** `--coverage` and thresholds, Playwright E2E (`CI=true`) including **mobile emulation** (Pixel 5 / Chromium), Lighthouse CI, Storybook static build, bundle budget + analyze. Mutation testing (Stryker) is **not** part of this pipeline — see [Mutation testing status](#mutation-testing-status). |
**Merge readiness:** A green workflow run on the PR/branch matters more than reproducing every E2E or LHCI step locally. Use CI **artifacts** (Playwright HTML report, coverage, Lighthouse output) to debug failures.