From 0a4902f5c3e145d16c0d33c2713cad824079c927 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:11:15 +0200 Subject: [PATCH 1/4] refactor: make local admission change aware --- README.md | 8 +- scripts/ci-prepush-check-registry.d.mts | 1 + scripts/ci-prepush-check-registry.mjs | 27 +++++ scripts/ci-prepush-classifier.d.mts | 26 ++++ scripts/ci-prepush-classifier.mjs | 111 +++++++++++++++++ scripts/ci-prepush-lowend.mjs | 114 ++++++++++++------ .../unit/tooling/ciPrepushClassifier.test.ts | 49 ++++++++ 7 files changed, 295 insertions(+), 41 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 tests/unit/tooling/ciPrepushClassifier.test.ts diff --git a/README.md b/README.md index fe5022aef..db0fa0aae 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 6959+ tests / 575 files + 6962+ tests / 576 files Codecov Coverage License MIT CI Status @@ -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 (6959+ tests / 575 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6962+ tests / 576 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 (6959+ tests, 575 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (6962+ tests, 576 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):** -- **6959+ unit tests** across **575 test files** — CI is authoritative for pass/fail +- **6962+ unit tests** across **576 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.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..f099c1966 --- /dev/null +++ b/scripts/ci-prepush-check-registry.mjs @@ -0,0 +1,27 @@ +const routingAuthority = 'scripts/ci-prepush-check-registry.mjs'; +const i18nPolicyFiles = new Set(['scripts/check-i18n-keys.mjs', 'scripts/i18n-locales.mjs']); + +export const admissionCheckRegistry = Object.freeze([ + { + name: 'i18n', + matches: (file) => + file.startsWith('locales/') || + file.startsWith('public/locales/') || + i18nPolicyFiles.has(file), + implementationFiles: new Set([routingAuthority, '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, '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..90e0188fa --- /dev/null +++ b/scripts/ci-prepush-classifier.d.mts @@ -0,0 +1,26 @@ +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; diff --git a/scripts/ci-prepush-classifier.mjs b/scripts/ci-prepush-classifier.mjs new file mode 100644 index 000000000..1f9f3df6d --- /dev/null +++ b/scripts/ci-prepush-classifier.mjs @@ -0,0 +1,111 @@ +const DOC_FILE = /\.(?:md|mdx)$/i; +const TS_FILE = /\.(?:c|m)?tsx?$|\.(?:c|m)?jsx?$/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']); +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 (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'; + if (TS_FILE.test(normalized)) return 'TYPESCRIPT_APPLICATION'; + 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); +} diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs index 16c7c5b2c..cfa352501 100644 --- a/scripts/ci-prepush-lowend.mjs +++ b/scripts/ci-prepush-lowend.mjs @@ -1,4 +1,7 @@ +import { spawnSync } from 'node:child_process'; import process from 'node:process'; +import { shouldRunAdmissionCheck } from './ci-prepush-check-registry.mjs'; +import { classifyChangedFiles, requiresTypecheck } from './ci-prepush-classifier.mjs'; import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/shared.mjs'; import { readPrePushEvidenceFile, resolvePushEvidence } from './signing/signing-core.mjs'; @@ -17,43 +20,80 @@ if (evidenceIndex >= 0) { } } -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', - () => - runNodeScript('scripts/i18n-quality-report.mjs', [ - '--strict', - '--min-coverage', - '75', - '--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); - -for (const [name, run] of checks) { - console.log(`[local-lowend] ${name}`); +const full = process.argv.includes('--full'); + +function gitRaw(args) { + const result = spawnSync('git', args, { encoding: 'utf8' }); + if (result.status !== 0) return ''; + return result.stdout ?? ''; +} + +function parseNulDelimitedPaths(output) { + return output.split('\0').filter(Boolean); +} + +function changedFilesFromWorkingTree() { + return parseNulDelimitedPaths( + gitRaw(['diff', '--no-renames', '--name-only', '-z', 'HEAD']), + ).concat(parseNulDelimitedPaths(gitRaw(['ls-files', '--others', '--exclude-standard', '-z']))); +} + +function report(name, status, detail = '') { + console.log(`[local-admission] ${name.padEnd(26)} ${status}${detail ? ` — ${detail}` : ''}`); + return status; +} + +function runCheck(name, run) { const status = run(); - if (status !== 0) { - console.error(`[local-lowend] failed: ${name}`); - process.exit(status); - } + report(name, status === 0 ? 'PASS' : 'FAIL'); + if (status !== 0) process.exit(status ?? 1); +} + +const files = changedFilesFromWorkingTree(); +const classification = classifyChangedFiles(files); +const typecheckRequired = requiresTypecheck(classification, { full }); + +console.log(`[local-admission] change class: ${classification.kind}`); +console.log(`[local-admission] files considered: ${classification.files.length}`); + +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', + '75', + '--max-length-outliers', + '8', + ]), + ); +} + +if (shouldRunAdmissionCheck('contentGuard', classification.files) || full) + runCheck('Content guard', () => runNodeScript('scripts/content-guard.mjs')); + +if (typecheckRequired) { + runCheck('TypeScript (single checker)', () => + runLocalBinary('tsgo', ['--project', 'tsconfig.tsgo.json', '--noEmit', '--checkers', '1']), + ); +} else { + report('TypeScript', 'DEFERRED_TO_REQUIRED_CI', 'no TypeScript-impacting changes detected'); } -console.log('[local-lowend] pre-push checks passed sequentially.'); +console.log('\nLOCAL ADMISSION RESULT'); +console.log('Local checks completed sequentially.'); +console.log('Cloud validation required YES'); +console.log(`Classification ${classification.kind}`); diff --git a/tests/unit/tooling/ciPrepushClassifier.test.ts b/tests/unit/tooling/ciPrepushClassifier.test.ts new file mode 100644 index 000000000..19bef40ff --- /dev/null +++ b/tests/unit/tooling/ciPrepushClassifier.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { shouldRunAdmissionCheck } from '../../../scripts/ci-prepush-check-registry.mjs'; +import { + classifyChangedFiles, + classifyFile, + 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'], + ['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('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('contentGuard', ['community-templates/index.json'])).toBe(true); + expect(shouldRunAdmissionCheck('contentGuard', ['README.md'])).toBe(false); + }); +}); From 2d9efefd1a4a1ba61e2b6ecedc688b19c2f75cd6 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:25:45 +0200 Subject: [PATCH 2/4] fix: route committed changes through local admission --- README.md | 8 ++++---- scripts/ci-prepush-check-registry.mjs | 7 ++++++- scripts/ci-prepush-classifier.mjs | 4 ++-- scripts/ci-prepush-lowend.mjs | 4 +++- tests/unit/tooling/ciPrepushClassifier.test.ts | 12 ++++++++++++ 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index db0fa0aae..480b572a4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 6962+ tests / 576 files + 6963+ tests / 576 files Codecov Coverage License MIT CI Status @@ -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 (6962+ tests / 576 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6963+ tests / 576 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 (6962+ tests, 576 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (6963+ tests, 576 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):** -- **6962+ unit tests** across **576 test files** — CI is authoritative for pass/fail +- **6963+ unit tests** across **576 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 f099c1966..0e7f79630 100644 --- a/scripts/ci-prepush-check-registry.mjs +++ b/scripts/ci-prepush-check-registry.mjs @@ -1,5 +1,10 @@ const routingAuthority = 'scripts/ci-prepush-check-registry.mjs'; -const i18nPolicyFiles = new Set(['scripts/check-i18n-keys.mjs', 'scripts/i18n-locales.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([ { diff --git a/scripts/ci-prepush-classifier.mjs b/scripts/ci-prepush-classifier.mjs index 1f9f3df6d..871259d5c 100644 --- a/scripts/ci-prepush-classifier.mjs +++ b/scripts/ci-prepush-classifier.mjs @@ -1,5 +1,5 @@ const DOC_FILE = /\.(?:md|mdx)$/i; -const TS_FILE = /\.(?:c|m)?tsx?$|\.(?:c|m)?jsx?$/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/']; @@ -59,6 +59,7 @@ export function classifyFile(file) { } if (normalized.startsWith('tests/')) return TS_FILE.test(normalized) ? 'TYPESCRIPT_APPLICATION' : 'TEST_ONLY'; + if (TS_FILE.test(normalized)) return 'TYPESCRIPT_APPLICATION'; if (TOOLING_FILES.has(normalized) || startsWithRoot(normalized, TOOLING_ROOTS)) return 'TOOLING'; if ( DEPENDENCY_FILES.has(base) || @@ -68,7 +69,6 @@ export function classifyFile(file) { return 'DEPENDENCY_TOOLCHAIN'; } if (BUILD_CONFIG_FILES.has(base)) return 'BUILD_CONFIGURATION'; - if (TS_FILE.test(normalized)) return 'TYPESCRIPT_APPLICATION'; return 'UNKNOWN'; } diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs index cfa352501..7a1b7080a 100644 --- a/scripts/ci-prepush-lowend.mjs +++ b/scripts/ci-prepush-lowend.mjs @@ -6,6 +6,7 @@ import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/sh import { readPrePushEvidenceFile, resolvePushEvidence } from './signing/signing-core.mjs'; const evidenceIndex = process.argv.indexOf('--prepush-evidence-file'); +let evidenceChangedFiles; if (evidenceIndex >= 0) { try { const evidence = resolvePushEvidence( @@ -14,6 +15,7 @@ if (evidenceIndex >= 0) { ); if (evidence.evidenceState !== 'RESOLVED') throw new Error(evidence.reason ?? 'invalid evidence'); + evidenceChangedFiles = evidence.changedFiles; } catch (error) { console.error(`[local-lowend] outgoing evidence rejected: ${error.message}`); process.exit(1); @@ -49,7 +51,7 @@ function runCheck(name, run) { if (status !== 0) process.exit(status ?? 1); } -const files = changedFilesFromWorkingTree(); +const files = evidenceIndex >= 0 ? evidenceChangedFiles : changedFilesFromWorkingTree(); const classification = classifyChangedFiles(files); const typecheckRequired = requiresTypecheck(classification, { full }); diff --git a/tests/unit/tooling/ciPrepushClassifier.test.ts b/tests/unit/tooling/ciPrepushClassifier.test.ts index 19bef40ff..7457ae466 100644 --- a/tests/unit/tooling/ciPrepushClassifier.test.ts +++ b/tests/unit/tooling/ciPrepushClassifier.test.ts @@ -43,7 +43,19 @@ describe('change-aware local admission classifier', () => { 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); }); + + 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); + }); }); From bc0043108ce83e1041540b2c35f4f5c4a004adf4 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:25:11 +0200 Subject: [PATCH 3/4] fix: close local admission routing gaps --- scripts/ci-prepush-check-registry.mjs | 13 +++++-- scripts/ci-prepush-lowend.mjs | 34 ++++++++++++++++++- .../unit/tooling/ciPrepushClassifier.test.ts | 2 ++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/scripts/ci-prepush-check-registry.mjs b/scripts/ci-prepush-check-registry.mjs index 0e7f79630..47b72ab56 100644 --- a/scripts/ci-prepush-check-registry.mjs +++ b/scripts/ci-prepush-check-registry.mjs @@ -1,4 +1,5 @@ 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', @@ -13,7 +14,11 @@ export const admissionCheckRegistry = Object.freeze([ file.startsWith('locales/') || file.startsWith('public/locales/') || i18nPolicyFiles.has(file), - implementationFiles: new Set([routingAuthority, 'scripts/ci-prepush-classifier.mjs']), + implementationFiles: new Set([ + routingAuthority, + runnerAuthority, + 'scripts/ci-prepush-classifier.mjs', + ]), }, { name: 'contentGuard', @@ -21,7 +26,11 @@ export const admissionCheckRegistry = Object.freeze([ file === 'scripts/content-guard.mjs' || file.startsWith('community-templates/') || file.startsWith('public/community-templates/'), - implementationFiles: new Set([routingAuthority, 'scripts/ci-prepush-classifier.mjs']), + implementationFiles: new Set([ + routingAuthority, + runnerAuthority, + 'scripts/ci-prepush-classifier.mjs', + ]), }, ]); diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs index 7a1b7080a..8ca88f7e7 100644 --- a/scripts/ci-prepush-lowend.mjs +++ b/scripts/ci-prepush-lowend.mjs @@ -30,6 +30,18 @@ function gitRaw(args) { return result.stdout ?? ''; } +function gitRequired(args, description) { + const result = spawnSync('git', args, { encoding: 'utf8' }); + if (result.status !== 0) + throw new Error(`${description} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`); + return result.stdout ?? ''; +} + +function gitOptional(args) { + const result = spawnSync('git', args, { encoding: 'utf8' }); + return result.status === 0 ? (result.stdout ?? '') : null; +} + function parseNulDelimitedPaths(output) { return output.split('\0').filter(Boolean); } @@ -40,6 +52,25 @@ function changedFilesFromWorkingTree() { ).concat(parseNulDelimitedPaths(gitRaw(['ls-files', '--others', '--exclude-standard', '-z']))); } +function changedFilesFromManualRange() { + const upstream = gitOptional(['rev-parse', '--verify', '@{upstream}'])?.trim(); + if (upstream) + return parseNulDelimitedPaths( + gitRequired( + ['diff', '--no-renames', '--name-only', '-z', `${upstream}..HEAD`], + 'resolve manual committed range', + ), + ).concat(changedFilesFromWorkingTree()); + + const head = gitRequired(['rev-parse', '--verify', 'HEAD'], 'resolve HEAD').trim(); + return parseNulDelimitedPaths( + gitRequired( + ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', head], + 'resolve manual HEAD changes', + ), + ).concat(changedFilesFromWorkingTree()); +} + function report(name, status, detail = '') { console.log(`[local-admission] ${name.padEnd(26)} ${status}${detail ? ` — ${detail}` : ''}`); return status; @@ -51,7 +82,7 @@ function runCheck(name, run) { if (status !== 0) process.exit(status ?? 1); } -const files = evidenceIndex >= 0 ? evidenceChangedFiles : changedFilesFromWorkingTree(); +const files = evidenceIndex >= 0 ? evidenceChangedFiles : changedFilesFromManualRange(); const classification = classifyChangedFiles(files); const typecheckRequired = requiresTypecheck(classification, { full }); @@ -89,6 +120,7 @@ if (shouldRunAdmissionCheck('contentGuard', classification.files) || full) 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 { diff --git a/tests/unit/tooling/ciPrepushClassifier.test.ts b/tests/unit/tooling/ciPrepushClassifier.test.ts index 7457ae466..81be3fe03 100644 --- a/tests/unit/tooling/ciPrepushClassifier.test.ts +++ b/tests/unit/tooling/ciPrepushClassifier.test.ts @@ -47,6 +47,8 @@ describe('change-aware local admission classifier', () => { 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', () => { From fd697ab914e843fc25b8974893e8aa96cf4cef11 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:20:49 +0200 Subject: [PATCH 4/4] fix: fail safe on unresolved manual ranges --- README.md | 8 +-- scripts/ci-prepush-classifier.d.mts | 1 + scripts/ci-prepush-classifier.mjs | 4 ++ scripts/ci-prepush-lowend.mjs | 53 ++++++++++--------- .../unit/tooling/ciPrepushClassifier.test.ts | 14 +++++ 5 files changed, 51 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 480b572a4..d2b0969c3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 6963+ tests / 576 files + 6964+ tests / 576 files Codecov Coverage License MIT CI Status @@ -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 / 576 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6964+ tests / 576 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, 576 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (6964+ tests, 576 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 **576 test files** — CI is authoritative for pass/fail +- **6964+ unit tests** across **576 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.d.mts b/scripts/ci-prepush-classifier.d.mts index 90e0188fa..a12f63ab9 100644 --- a/scripts/ci-prepush-classifier.d.mts +++ b/scripts/ci-prepush-classifier.d.mts @@ -24,3 +24,4 @@ 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 index 871259d5c..7a3c8ce23 100644 --- a/scripts/ci-prepush-classifier.mjs +++ b/scripts/ci-prepush-classifier.mjs @@ -109,3 +109,7 @@ export function requiresTypecheck(classification, { full = false } = {}) { '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 8ca88f7e7..952a10ca6 100644 --- a/scripts/ci-prepush-lowend.mjs +++ b/scripts/ci-prepush-lowend.mjs @@ -1,7 +1,11 @@ import { spawnSync } from 'node:child_process'; import process from 'node:process'; import { shouldRunAdmissionCheck } from './ci-prepush-check-registry.mjs'; -import { classifyChangedFiles, requiresTypecheck } from './ci-prepush-classifier.mjs'; +import { + classifyChangedFiles, + manualAdmissionNeedsFullValidation, + requiresTypecheck, +} from './ci-prepush-classifier.mjs'; import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/shared.mjs'; import { readPrePushEvidenceFile, resolvePushEvidence } from './signing/signing-core.mjs'; @@ -22,7 +26,7 @@ if (evidenceIndex >= 0) { } } -const full = process.argv.includes('--full'); +const fullRequested = process.argv.includes('--full'); function gitRaw(args) { const result = spawnSync('git', args, { encoding: 'utf8' }); @@ -30,13 +34,6 @@ function gitRaw(args) { return result.stdout ?? ''; } -function gitRequired(args, description) { - const result = spawnSync('git', args, { encoding: 'utf8' }); - if (result.status !== 0) - throw new Error(`${description} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`); - return result.stdout ?? ''; -} - function gitOptional(args) { const result = spawnSync('git', args, { encoding: 'utf8' }); return result.status === 0 ? (result.stdout ?? '') : null; @@ -55,20 +52,14 @@ function changedFilesFromWorkingTree() { function changedFilesFromManualRange() { const upstream = gitOptional(['rev-parse', '--verify', '@{upstream}'])?.trim(); if (upstream) - return parseNulDelimitedPaths( - gitRequired( - ['diff', '--no-renames', '--name-only', '-z', `${upstream}..HEAD`], - 'resolve manual committed range', - ), - ).concat(changedFilesFromWorkingTree()); - - const head = gitRequired(['rev-parse', '--verify', 'HEAD'], 'resolve HEAD').trim(); - return parseNulDelimitedPaths( - gitRequired( - ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', head], - 'resolve manual HEAD changes', - ), - ).concat(changedFilesFromWorkingTree()); + return { + files: parseNulDelimitedPaths( + gitRaw(['diff', '--no-renames', '--name-only', '-z', `${upstream}..HEAD`]), + ).concat(changedFilesFromWorkingTree()), + rangeResolved: true, + }; + + return { files: [], rangeResolved: false }; } function report(name, status, detail = '') { @@ -82,12 +73,24 @@ function runCheck(name, run) { if (status !== 0) process.exit(status ?? 1); } -const files = evidenceIndex >= 0 ? evidenceChangedFiles : changedFilesFromManualRange(); -const classification = classifyChangedFiles(files); +const manualEvidence = + evidenceIndex >= 0 + ? { files: evidenceChangedFiles, rangeResolved: true } + : changedFilesFromManualRange(); +const full = + fullRequested || (!manualEvidence.rangeResolved && manualAdmissionNeedsFullValidation(false)); +const files = manualEvidence.files; +const classification = manualEvidence.rangeResolved + ? classifyChangedFiles(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 && evidenceIndex < 0) + console.log( + '[local-admission] manual committed range unresolved; using conservative full admission', + ); if (!ensureDependencyState()) { report('Dependency state', 'FAIL'); diff --git a/tests/unit/tooling/ciPrepushClassifier.test.ts b/tests/unit/tooling/ciPrepushClassifier.test.ts index 81be3fe03..9f63a0f43 100644 --- a/tests/unit/tooling/ciPrepushClassifier.test.ts +++ b/tests/unit/tooling/ciPrepushClassifier.test.ts @@ -3,6 +3,7 @@ import { shouldRunAdmissionCheck } from '../../../scripts/ci-prepush-check-regis import { classifyChangedFiles, classifyFile, + manualAdmissionNeedsFullValidation, requiresTypecheck, } from '../../../scripts/ci-prepush-classifier.mjs'; @@ -60,4 +61,17 @@ describe('change-aware local admission classifier', () => { 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'); + }); });