diff --git a/AGENTS.md b/AGENTS.md index f87667ffd..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. @@ -295,12 +305,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..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 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 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 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 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/README.md b/README.md index a36bc2464..e398883a5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 6963+ tests / 575 files + 6984+ tests / 577 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 / 575 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 (6963+ tests, 575 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):** -- **6963+ unit tests** across **575 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/docs/CI.md b/docs/CI.md index ad059aac6..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` (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](#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. @@ -306,6 +306,29 @@ 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 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 + 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 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: 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..f74514dea --- /dev/null +++ b/scripts/ci-prepush-check-registry.mjs @@ -0,0 +1,44 @@ +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', +]); + +// QNBS-v3: not exported — shouldRunAdmissionCheck is the public API, nothing else consumes this. +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', + 'scripts/ci-prepush-range-resolver.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', + 'scripts/ci-prepush-range-resolver.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..5a6e5388d --- /dev/null +++ b/scripts/ci-prepush-classifier.mjs @@ -0,0 +1,132 @@ +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']); +// 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 (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')) + ? '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', 'NON_CODE_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..4dae666ad 100644 --- a/scripts/ci-prepush-lowend.mjs +++ b/scripts/ci-prepush-lowend.mjs @@ -1,36 +1,68 @@ import process from 'node:process'; +import { shouldRunAdmissionCheck } from './ci-prepush-check-registry.mjs'; +import { + classifyChangedFiles, + manualAdmissionNeedsFullValidation, + requiresTypecheck, +} from './ci-prepush-classifier.mjs'; +import { isMainModule, 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 +70,26 @@ 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. +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 new file mode 100644 index 000000000..5820b0306 --- /dev/null +++ b/scripts/ci-prepush-range-resolver.d.mts @@ -0,0 +1,24 @@ +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[] | null; +} + +export interface ManualEvidenceDependencies extends ManualRangeDependencies { + readonly resolvePushEvidence?: (input: unknown, cwd: string) => unknown; + readonly readPrePushEvidenceFile?: (file: string) => unknown; +} + +export function isMainModule(argv1: string | undefined, moduleUrl: string): boolean; +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..59faa175f --- /dev/null +++ b/scripts/ci-prepush-range-resolver.mjs @@ -0,0 +1,67 @@ +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 so it still matches an always-absolute moduleUrl. +export function isMainModule(argv1, moduleUrl) { + return argv1 !== undefined && resolve(argv1) === fileURLToPath(moduleUrl); +} + +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 ?? ''); +} + +// 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', + }); + if (untracked.status !== 0) return null; + return parseNulDelimitedPaths(staged.stdout ?? '').concat( + parseNulDelimitedPaths(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 }; + const workingFiles = workingTreeFiles(); + if (workingFiles === null) return { files: [], rangeResolved: false }; + return { files: diffFiles.concat(workingFiles), 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..2814430cc --- /dev/null +++ b/tests/unit/tooling/ciPrepushClassifier.test.ts @@ -0,0 +1,116 @@ +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', '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'], + ['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']); + + expect(ts.kind).toBe('TYPESCRIPT_APPLICATION'); + expect(requiresTypecheck(ts)).toBe(true); + expect(snapshot.kind).toBe('TEST_ONLY'); + expect(requiresTypecheck(snapshot)).toBe(false); + }); + + // 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', + ]); + + 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/', () => { + 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); + // 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', () => { + 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']); + + expect(requiresTypecheck(earlierTypeScript)).toBe(true); + }); +}); diff --git a/tests/unit/tooling/ciPrepushRangeResolver.test.ts b/tests/unit/tooling/ciPrepushRangeResolver.test.ts new file mode 100644 index 000000000..d1a7330b5 --- /dev/null +++ b/tests/unit/tooling/ciPrepushRangeResolver.test.ts @@ -0,0 +1,140 @@ +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 }); + + 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 }); + }); + + // 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', () => { + 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'); + }); +});