From 3d465c6f47f1c0bcd904e3e7cddf803b64bb0cc3 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:44:11 +0200 Subject: [PATCH 1/3] feat(signing): add working-tree-vs-push divergence detection (S3b Part 1) Neither pathEvidenceState (#498) nor any other current signal proves that the content ci-prepush-lowend.mjs's local checks actually validate (everything runs against whatever is on disk, cwd: projectRoot) corresponds to localSha's exact committed tree, rather than a working tree that has drifted from it (uncommitted edits, staged-but-uncommitted changes, wrong ref checked out). Required CI remains the sole merge-safety authority regardless, so this is a local-gate trust/DX gap, not a safety hole -- and this commit closes only that gap, as a diagnostic signal, not as exact-tree validation. resolvePushEvidence() (signing-core.mjs) gains a new orthogonal dimension, workingTreeState: 'MATCHES' | 'DIVERGED' | 'NOT_APPLICABLE' | 'UNKNOWN', computed per-update via a new computeWorkingTreeState() using `git diff --quiet --` (compares working tree directly against the commit's tree; structurally cannot see untracked files, which is exactly why MATCHES is not sufficient to prove exact-tree equivalence -- an untracked file satisfying an import that's absent from the real commit would make a local check pass when a real checkout of localSha would fail). DELETED updates get NOT_APPLICABLE (no commit to compare against); TAG updates are included on the same terms as branch updates via the same call (git's own revision parsing transitively peels a tag to its target), not excluded as an earlier draft had them -- a diverged tag push is now reported instead of silently unreported. Aggregation precedence: DIVERGED > UNKNOWN > MATCHES > (NOT_APPLICABLE only when no relevant update exists, e.g. an all-deletions push). computeWorkingTreeState() never throws -- it maps any runGit() failure (spawn error, timeout) to UNKNOWN internally, and critically checks result.error before trusting result.status, since runGit's own `status: result.status ?? 1` fallback makes a killed/failed subprocess indistinguishable from a genuine "differences found" exit code 1 unless the caller inspects error first. This keeps the new diagnostic fully isolated from resolvePushEvidence's existing canonical try/catch: a diagnostic failure can only ever produce workingTreeState: 'UNKNOWN' on the RESOLVED path, never evidenceState: 'INVALID'. This matters because ci-prepush-range-resolver.mjs's resolveManualEvidence() throws whenever evidenceState !== 'RESOLVED' -- without this isolation, a transient git timeout on the new diagnostic would have turned otherwise-valid, already-verified #494/#498 evidence into a hard pre-push rejection. Propagated through resolveManualEvidence()'s evidence-file path unchanged; the manual/no-evidence-file path (changedFilesFromManualRange, direct `pnpm run ci:prepush` invocation) reports NOT_APPLICABLE, not MATCHES -- there is no localSha and no push event in that mode, so no comparison of any kind happens, and claiming MATCHES would assert an equivalence that was never checked. ci-prepush-lowend.mjs reports a non-blocking, informational line only on DIVERGED or UNKNOWN (MATCHES/NOT_APPLICABLE stay silent) -- deliberately not coupled to the existing `full`-admission escalation lever, since divergence and incomplete-change-evidence are different failure modes with no shared remedy. Deliberately not implemented in this slice (reserved for Part 2, an isolated-localSha-worktree exact verification mechanism to be designed fresh against then-current main): MATCHES is never treated as a skip condition for exact verification anywhere in this codebase, in this commit or otherwise -- proving tracked-content equivalence sufficient to safely skip real verification would require also ruling out untracked/gitignored drift, which is exactly what an isolated worktree proves and a cheap diff cannot. Tests: computeWorkingTreeState's runGit-error-vs-status-1 distinction (the exact ambiguity this commit exists to close) tested directly via injected runGit results; the UNKNOWN-does-not-mutate-evidenceState regression proven directly, not just asserted in prose; tag inclusion, deletion/manual NOT_APPLICABLE, and aggregation precedence (including a mixed DIVERGED+UNKNOWN and MATCHES+UNKNOWN case) all covered deterministically with no real git subprocess in the new tests. Four pre-existing resolvePushEvidence tests updated to inject worktreeMatchesCommit so they stay git-subprocess-free rather than incidentally exercising the real default against fake SHAs. README test-count badges resynced via `pnpm run sync:readme` (578 files / 6997+ tests). Validated: full `pnpm run ci:prepush`, `pnpm run lint`, `pnpm run typecheck` (4-checker), and both targeted test files all pass. --- README.md | 8 +- scripts/ci-prepush-lowend.mjs | 13 ++ scripts/ci-prepush-range-resolver.d.mts | 3 + scripts/ci-prepush-range-resolver.mjs | 21 ++- scripts/signing/signing-core.d.mts | 10 ++ scripts/signing/signing-core.mjs | 31 ++++- tests/unit/signing.test.ts | 121 +++++++++++++++++- .../tooling/ciPrepushRangeResolver.test.ts | 46 +++++-- 8 files changed, 233 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 74ced838..ade901d2 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 6988+ tests / 578 files + 6997+ tests / 578 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 (6988+ tests / 578 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6997+ tests / 578 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 (6988+ tests, 578 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (6997+ tests, 578 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):** -- **6988+ unit tests** across **578 test files** — CI is authoritative for pass/fail +- **6997+ unit tests** across **578 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-lowend.mjs b/scripts/ci-prepush-lowend.mjs index 82a6d2f2..252be6a9 100644 --- a/scripts/ci-prepush-lowend.mjs +++ b/scripts/ci-prepush-lowend.mjs @@ -44,6 +44,19 @@ async function main() { console.log( '[local-admission] change evidence incomplete or unresolved; using conservative full admission', ); + // QNBS-v3: informational only — required CI remains authoritative regardless of this signal. + if (manualEvidence.workingTreeState === 'DIVERGED') + report( + 'Working tree vs. push', + 'DIVERGED', + 'local results do not verify the exact pushed commit(s); required CI remains authoritative', + ); + else if (manualEvidence.workingTreeState === 'UNKNOWN') + report( + 'Working tree vs. push', + 'UNKNOWN', + 'could not determine whether local results reflect the exact pushed commit(s); required CI remains authoritative', + ); if (!ensureDependencyState()) { report('Dependency state', 'FAIL'); diff --git a/scripts/ci-prepush-range-resolver.d.mts b/scripts/ci-prepush-range-resolver.d.mts index 5820b030..f3fb8081 100644 --- a/scripts/ci-prepush-range-resolver.d.mts +++ b/scripts/ci-prepush-range-resolver.d.mts @@ -1,6 +1,9 @@ +import type { WorkingTreeState } from './signing/signing-core.d.mts'; + export interface ManualChangeEvidence { readonly files: readonly string[]; readonly rangeResolved: boolean; + readonly workingTreeState: WorkingTreeState; } export interface ManualRangeDependencies { diff --git a/scripts/ci-prepush-range-resolver.mjs b/scripts/ci-prepush-range-resolver.mjs index 59faa175..d6970585 100644 --- a/scripts/ci-prepush-range-resolver.mjs +++ b/scripts/ci-prepush-range-resolver.mjs @@ -47,13 +47,20 @@ export function changedFilesFromManualRange(dependencies = {}) { const diffNames = dependencies.diffNames ?? defaultDiffNames; const workingTreeFiles = dependencies.workingTreeFiles ?? defaultWorkingTreeFiles; + // QNBS-v3: no push event or localSha exists in this mode, so nothing is ever compared. const upstream = resolveUpstream(); - if (!upstream) return { files: [], rangeResolved: false }; + if (!upstream) return { files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' }; const diffFiles = diffNames(`${upstream}..HEAD`); - if (diffFiles === null) return { files: [], rangeResolved: false }; + if (diffFiles === null) + return { files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' }; const workingFiles = workingTreeFiles(); - if (workingFiles === null) return { files: [], rangeResolved: false }; - return { files: diffFiles.concat(workingFiles), rangeResolved: true }; + if (workingFiles === null) + return { files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' }; + return { + files: diffFiles.concat(workingFiles), + rangeResolved: true, + workingTreeState: 'NOT_APPLICABLE', + }; } export function resolveManualEvidence(evidenceFile, dependencies = {}) { @@ -63,5 +70,9 @@ export function resolveManualEvidence(evidenceFile, dependencies = {}) { 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' }; + return { + files: evidence.changedFiles, + rangeResolved: evidence.pathEvidenceState === 'COMPLETE', + workingTreeState: evidence.workingTreeState, + }; } diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts index 4036d23a..8ebfdb00 100644 --- a/scripts/signing/signing-core.d.mts +++ b/scripts/signing/signing-core.d.mts @@ -79,17 +79,26 @@ export function writePrePushEvidenceFile( file: string, input: string | string[] | RefUpdate[], ): void; +// QNBS-v3: diagnostic-only dimension, independent of evidenceState/pathEvidenceState validity. +export type WorkingTreeState = 'MATCHES' | 'DIVERGED' | 'NOT_APPLICABLE' | 'UNKNOWN'; export interface PushEvidenceUpdate extends RefUpdate { base?: string; disposition: 'DELETED' | 'TAG' | 'NEW_BRANCH' | 'UPDATED'; + workingTreeState: WorkingTreeState; } export interface PushEvidence { updates: PushEvidenceUpdate[]; changedFiles: string[]; evidenceState: 'RESOLVED' | 'INVALID'; pathEvidenceState: 'COMPLETE' | 'PARTIAL'; + workingTreeState: WorkingTreeState; reason?: string; } +export function computeWorkingTreeState( + sha: string, + cwd?: string, + dependencies?: { runGit?: (args: string[], options?: GitOptions) => GitResult }, +): WorkingTreeState; export function resolvePushEvidence( input: string | string[] | RefUpdate[], cwd?: string, @@ -97,6 +106,7 @@ export function resolvePushEvidence( commitExists?: (sha: string) => boolean; objectExists?: (sha: string) => boolean; changedFilesBetween?: (base: string, head: string) => string[]; + worktreeMatchesCommit?: (sha: string) => WorkingTreeState; }, ): PushEvidence; export function selectIntroducedCommits(commits: string[], reachableFromBase: string[]): string[]; diff --git a/scripts/signing/signing-core.mjs b/scripts/signing/signing-core.mjs index 918169f4..1b7946c9 100644 --- a/scripts/signing/signing-core.mjs +++ b/scripts/signing/signing-core.mjs @@ -330,6 +330,24 @@ function changedFilesBetween(base, head, cwd) { return result.stdout.split('\0').filter((path) => path.length > 0); } +// QNBS-v3: diagnostic-only signal; never throws, so it cannot corrupt canonical evidence validity. +export function computeWorkingTreeState(sha, cwd, dependencies = {}) { + const runGitFn = dependencies.runGit ?? runGit; + const result = runGitFn(['diff', '--quiet', sha, '--'], { cwd }); + if (result.error) return 'UNKNOWN'; + if (result.status === 0) return 'MATCHES'; + if (result.status === 1) return 'DIVERGED'; + return 'UNKNOWN'; +} + +function aggregateWorkingTreeState(evidenceUpdates) { + const relevant = evidenceUpdates.filter((update) => update.workingTreeState !== 'NOT_APPLICABLE'); + if (relevant.length === 0) return 'NOT_APPLICABLE'; + if (relevant.some((update) => update.workingTreeState === 'DIVERGED')) return 'DIVERGED'; + if (relevant.some((update) => update.workingTreeState === 'UNKNOWN')) return 'UNKNOWN'; + return 'MATCHES'; +} + export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {}) { try { const updates = validatedPrePushUpdates(input); @@ -341,17 +359,23 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = { ((sha) => isSha(sha) && runGit(['cat-file', '-e', `${sha}^{object}`], { cwd }).status === 0); const resolveFiles = dependencies.changedFilesBetween ?? ((base, head) => changedFilesBetween(base, head, cwd)); + const matchesWorktree = + dependencies.worktreeMatchesCommit ?? ((sha) => computeWorkingTreeState(sha, cwd)); const changedFiles = new Set(); const evidenceUpdates = []; for (const update of updates) { if (isZeroSha(update.localSha)) { - evidenceUpdates.push({ ...update, disposition: 'DELETED' }); + evidenceUpdates.push({ ...update, disposition: 'DELETED', workingTreeState: 'NOT_APPLICABLE' }); continue; } if (update.remoteRef.startsWith('refs/tags/')) { if (!objectExists(update.localSha)) throw new Error(`local outgoing object is unavailable for ${update.localRef}`); - evidenceUpdates.push({ ...update, disposition: 'TAG' }); + evidenceUpdates.push({ + ...update, + disposition: 'TAG', + workingTreeState: matchesWorktree(update.localSha), + }); continue; } if (!commitExists(update.localSha)) @@ -364,6 +388,7 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = { ...update, base, disposition: isZeroSha(update.remoteSha) ? 'NEW_BRANCH' : 'UPDATED', + workingTreeState: matchesWorktree(update.localSha), }); } // QNBS-v3: tag updates prove object validity but not a complete changed-path set. @@ -375,6 +400,7 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = { changedFiles: [...changedFiles], evidenceState: 'RESOLVED', pathEvidenceState, + workingTreeState: aggregateWorkingTreeState(evidenceUpdates), }; } catch (error) { return { @@ -382,6 +408,7 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = { changedFiles: [], evidenceState: 'INVALID', pathEvidenceState: 'PARTIAL', + workingTreeState: 'NOT_APPLICABLE', reason: error instanceof Error ? error.message : 'invalid push evidence', }; } diff --git a/tests/unit/signing.test.ts b/tests/unit/signing.test.ts index cae51aa4..2db67fe9 100644 --- a/tests/unit/signing.test.ts +++ b/tests/unit/signing.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { classifyCommitObject, classifyTagVerification, + computeWorkingTreeState, getSigningConfig, hasCommitSignature, isGitHubCompatibleEmail, @@ -218,6 +219,7 @@ describe('local signing controls', () => { base === '4b825dc642cb6eb9a060e54bf8d69288fbee4904' ? ['new\nfile.ts'] : ['src/with\t tab.ts', '世界 file.ts', 'src/with\t tab.ts'], + worktreeMatchesCommit: () => 'MATCHES', }); expect(result.evidenceState).toBe('RESOLVED'); expect(result.pathEvidenceState).toBe('PARTIAL'); @@ -241,6 +243,7 @@ describe('local signing controls', () => { const result = resolvePushEvidence(updates, process.cwd(), { commitExists: () => true, changedFilesBetween: () => ['src/example.ts'], + worktreeMatchesCommit: () => 'MATCHES', }); expect(result.evidenceState).toBe('RESOLVED'); @@ -257,10 +260,13 @@ describe('local signing controls', () => { for (const update of [lightweight, annotated]) { const result = resolvePushEvidence([update], process.cwd(), { objectExists: () => true, + worktreeMatchesCommit: () => 'MATCHES', }); expect(result.evidenceState).toBe('RESOLVED'); expect(result.pathEvidenceState).toBe('PARTIAL'); expect(result.changedFiles).toEqual([]); + // QNBS-v3: tags are no longer excluded from divergence detection (unlike pathEvidenceState). + expect(result.updates[0]?.workingTreeState).toBe('MATCHES'); } }); @@ -276,6 +282,7 @@ describe('local signing controls', () => { commitExists: () => true, objectExists: () => true, changedFilesBetween: () => ['src/a.ts'], + worktreeMatchesCommit: () => 'MATCHES', }, ); @@ -285,7 +292,10 @@ describe('local signing controls', () => { }); it('keeps empty evidence complete and rejects an unavailable tag object', () => { - expect(resolvePushEvidence([]).pathEvidenceState).toBe('COMPLETE'); + const empty = resolvePushEvidence([]); + expect(empty.pathEvidenceState).toBe('COMPLETE'); + // QNBS-v3: nothing was compared, not "compared and found equal" — see aggregation precedence. + expect(empty.workingTreeState).toBe('NOT_APPLICABLE'); const result = resolvePushEvidence( [parseRefUpdate(`refs/tags/v1 ${'a'.repeat(40)} refs/tags/v1 ${'0'.repeat(40)}`)!], @@ -335,6 +345,115 @@ describe('local signing controls', () => { ).toBe('INVALID'); }); + describe('computeWorkingTreeState (diagnostic-only, isolated from canonical evidence)', () => { + const sha = 'a'.repeat(40); + + it('reports MATCHES for a genuine exit code 0 with no process error', () => { + expect( + computeWorkingTreeState(sha, process.cwd(), { + runGit: () => ({ status: 0, stdout: '', stderr: '' }), + }), + ).toBe('MATCHES'); + }); + + it('reports DIVERGED for a genuine exit code 1 with no process error', () => { + expect( + computeWorkingTreeState(sha, process.cwd(), { + runGit: () => ({ status: 1, stdout: '', stderr: '' }), + }), + ).toBe('DIVERGED'); + }); + + // QNBS-v3: runGit maps a killed/failed spawn's status:null to 1 via `?? 1` — error must win first. + it('reports UNKNOWN on a process error, even though runGit maps its status to 1', () => { + expect( + computeWorkingTreeState(sha, process.cwd(), { + runGit: () => ({ status: 1, stdout: '', stderr: '', error: new Error('spawn timeout') }), + }), + ).toBe('UNKNOWN'); + expect( + computeWorkingTreeState(sha, process.cwd(), { + runGit: () => ({ status: 0, stdout: '', stderr: '', error: new Error('spawn timeout') }), + }), + ).toBe('UNKNOWN'); + }); + + it('reports UNKNOWN for any other exit code (e.g. a tag peeling to a non-commit)', () => { + expect( + computeWorkingTreeState(sha, process.cwd(), { + runGit: () => ({ status: 128, stdout: '', stderr: 'fatal: bad revision' }), + }), + ).toBe('UNKNOWN'); + }); + }); + + describe('workingTreeState (diagnostic dimension, never affects canonical evidence validity)', () => { + it('does not mutate evidenceState or pathEvidenceState when the diagnostic reports UNKNOWN', () => { + const update = parseRefUpdate( + `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`, + )!; + const result = resolvePushEvidence([update], process.cwd(), { + commitExists: () => true, + changedFilesBetween: () => ['src/example.ts'], + worktreeMatchesCommit: () => 'UNKNOWN', + }); + + // QNBS-v3: this is the regression guard for the diagnostic-isolation correction specifically. + expect(result.evidenceState).toBe('RESOLVED'); + expect(result.pathEvidenceState).toBe('COMPLETE'); + expect(result.workingTreeState).toBe('UNKNOWN'); + }); + + it('assigns DELETED updates NOT_APPLICABLE without calling the diagnostic', () => { + const zero = '0'.repeat(40); + const deletion = parseRefUpdate(`refs/heads/old ${zero} refs/heads/old ${'b'.repeat(40)}`)!; + const result = resolvePushEvidence([deletion], process.cwd(), { + worktreeMatchesCommit: () => { + throw new Error('must not be called for a deletion'); + }, + }); + + expect(result.evidenceState).toBe('RESOLVED'); + expect(result.updates[0]?.workingTreeState).toBe('NOT_APPLICABLE'); + expect(result.workingTreeState).toBe('NOT_APPLICABLE'); + }); + + it('aggregates with DIVERGED outranking UNKNOWN, and UNKNOWN outranking MATCHES', () => { + const updateA = parseRefUpdate( + `refs/heads/a ${'a'.repeat(40)} refs/heads/a ${'b'.repeat(40)}`, + )!; + const updateB = parseRefUpdate( + `refs/heads/b ${'c'.repeat(40)} refs/heads/b ${'d'.repeat(40)}`, + )!; + const shared = { commitExists: () => true, changedFilesBetween: () => [] }; + + const divergedPlusUnknown = resolvePushEvidence([updateA, updateB], process.cwd(), { + ...shared, + worktreeMatchesCommit: (sha) => (sha === updateA.localSha ? 'DIVERGED' : 'UNKNOWN'), + }); + expect(divergedPlusUnknown.workingTreeState).toBe('DIVERGED'); + + const matchesPlusUnknown = resolvePushEvidence([updateA, updateB], process.cwd(), { + ...shared, + worktreeMatchesCommit: (sha) => (sha === updateA.localSha ? 'MATCHES' : 'UNKNOWN'), + }); + expect(matchesPlusUnknown.workingTreeState).toBe('UNKNOWN'); + }); + + it('aggregates a push containing only deletions as NOT_APPLICABLE', () => { + const zero = '0'.repeat(40); + const result = resolvePushEvidence( + [ + parseRefUpdate(`refs/heads/a ${zero} refs/heads/a ${'a'.repeat(40)}`)!, + parseRefUpdate(`refs/heads/b ${zero} refs/heads/b ${'b'.repeat(40)}`)!, + ], + process.cwd(), + ); + expect(result.evidenceState).toBe('RESOLVED'); + expect(result.workingTreeState).toBe('NOT_APPLICABLE'); + }); + }); + it('round-trips the same immutable artifact for both consumers', () => { let dir: string; try { diff --git a/tests/unit/tooling/ciPrepushRangeResolver.test.ts b/tests/unit/tooling/ciPrepushRangeResolver.test.ts index d1a7330b..6e45704f 100644 --- a/tests/unit/tooling/ciPrepushRangeResolver.test.ts +++ b/tests/unit/tooling/ciPrepushRangeResolver.test.ts @@ -34,7 +34,7 @@ describe('manual committed-range resolution', () => { it('is unresolved when no upstream is configured', () => { const result = changedFilesFromManualRange({ resolveUpstream: () => null }); - expect(result).toEqual({ files: [], rangeResolved: false }); + expect(result).toEqual({ files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' }); }); it('resolves and merges working-tree changes when the diff succeeds', () => { @@ -47,7 +47,12 @@ describe('manual committed-range resolution', () => { workingTreeFiles: () => ['src/dirty.ts'], }); - expect(result).toEqual({ files: ['src/committed.ts', 'src/dirty.ts'], rangeResolved: true }); + // QNBS-v3: no push event/localSha exists in manual mode — NOT_APPLICABLE, never MATCHES. + expect(result).toEqual({ + files: ['src/committed.ts', 'src/dirty.ts'], + rangeResolved: true, + workingTreeState: 'NOT_APPLICABLE', + }); }); // QNBS-v3: regression for the fail-open bug — a failed diff must not read as an empty resolved range. @@ -60,7 +65,7 @@ describe('manual committed-range resolution', () => { }, }); - expect(result).toEqual({ files: [], rangeResolved: false }); + expect(result).toEqual({ files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' }); }); it('treats a genuinely empty diff as a resolved, complete range', () => { @@ -70,7 +75,7 @@ describe('manual committed-range resolution', () => { workingTreeFiles: () => [], }); - expect(result).toEqual({ files: [], rangeResolved: true }); + expect(result).toEqual({ files: [], rangeResolved: true, workingTreeState: 'NOT_APPLICABLE' }); }); // QNBS-v3: regression — a successful committed-range diff must not mask a working-tree failure. @@ -81,7 +86,7 @@ describe('manual committed-range resolution', () => { workingTreeFiles: () => null, }); - expect(result).toEqual({ files: [], rangeResolved: false }); + expect(result).toEqual({ files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' }); }); }); @@ -91,7 +96,7 @@ describe('resolveManualEvidence', () => { resolveUpstream: () => null, }); - expect(result).toEqual({ files: [], rangeResolved: false }); + expect(result).toEqual({ files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' }); }); it('trusts changedFiles as complete when pathEvidenceState is COMPLETE', () => { @@ -104,10 +109,15 @@ describe('resolveManualEvidence', () => { evidenceState: 'RESOLVED', pathEvidenceState: 'COMPLETE', changedFiles: ['src/example.ts'], + workingTreeState: 'MATCHES', }), }); - expect(result).toEqual({ files: ['src/example.ts'], rangeResolved: true }); + expect(result).toEqual({ + files: ['src/example.ts'], + rangeResolved: true, + workingTreeState: 'MATCHES', + }); }); // QNBS-v3: wiring check — a PARTIAL tag push must not be treated as a complete file list. @@ -118,10 +128,29 @@ describe('resolveManualEvidence', () => { evidenceState: 'RESOLVED', pathEvidenceState: 'PARTIAL', changedFiles: [], + workingTreeState: 'NOT_APPLICABLE', }), }); - expect(result).toEqual({ files: [], rangeResolved: false }); + expect(result).toEqual({ files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' }); + }); + + // QNBS-v3: proves the two signals are orthogonal, not coupled to `full` via pathEvidenceState. + it('propagates DIVERGED and UNKNOWN independently of pathEvidenceState being COMPLETE', () => { + for (const workingTreeState of ['DIVERGED', 'UNKNOWN']) { + const result = resolveManualEvidence('/tmp/evidence.json', { + readPrePushEvidenceFile: () => 'raw', + resolvePushEvidence: () => ({ + evidenceState: 'RESOLVED', + pathEvidenceState: 'COMPLETE', + changedFiles: ['src/example.ts'], + workingTreeState, + }), + }); + + expect(result.rangeResolved).toBe(true); + expect(result.workingTreeState).toBe(workingTreeState); + } }); it('throws for INVALID evidence', () => { @@ -132,6 +161,7 @@ describe('resolveManualEvidence', () => { evidenceState: 'INVALID', pathEvidenceState: 'PARTIAL', changedFiles: [], + workingTreeState: 'NOT_APPLICABLE', reason: 'boom', }), }), From d061c8e9ac4acbcef6aa2d8463c237c6ef5adb3e Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:52:39 +0200 Subject: [PATCH 2/3] fix(signing): prevent an injected runGit throw from escaping computeWorkingTreeState The default runGit() never throws (spawnSync failures are captured into result.error, not thrown), so this was unreachable through the real production call path. But computeWorkingTreeState() is an exported function with an optional dependencies.runGit override, and its whole contract -- documented in its own QNBS-v3 comment and this slice's commit message -- is that it never throws, so a diagnostic failure can never corrupt canonical evidence validity via resolvePushEvidence's outer try/catch. A future caller (or test) providing a runGit-shaped dependency that throws instead of returning {error} would have silently broken that guarantee. Wrap the call in its own try/catch and return UNKNOWN, matching the existing handling for a returned result.error. README test-count badges resynced via `pnpm run sync:readme` for the added regression test (578 files / 6998+ tests). Validated: full `pnpm run ci:prepush`, `pnpm run lint`, `pnpm run typecheck` (4-checker), and both targeted test files all pass. --- README.md | 8 ++++---- scripts/signing/signing-core.mjs | 7 ++++++- tests/unit/signing.test.ts | 11 +++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ade901d2..a9e9b1a1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 6997+ tests / 578 files + 6998+ tests / 578 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 (6997+ tests / 578 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6998+ tests / 578 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 (6997+ tests, 578 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (6998+ tests, 578 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):** -- **6997+ unit tests** across **578 test files** — CI is authoritative for pass/fail +- **6998+ unit tests** across **578 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/signing/signing-core.mjs b/scripts/signing/signing-core.mjs index 1b7946c9..811abf6c 100644 --- a/scripts/signing/signing-core.mjs +++ b/scripts/signing/signing-core.mjs @@ -333,7 +333,12 @@ function changedFilesBetween(base, head, cwd) { // QNBS-v3: diagnostic-only signal; never throws, so it cannot corrupt canonical evidence validity. export function computeWorkingTreeState(sha, cwd, dependencies = {}) { const runGitFn = dependencies.runGit ?? runGit; - const result = runGitFn(['diff', '--quiet', sha, '--'], { cwd }); + let result; + try { + result = runGitFn(['diff', '--quiet', sha, '--'], { cwd }); + } catch { + return 'UNKNOWN'; + } if (result.error) return 'UNKNOWN'; if (result.status === 0) return 'MATCHES'; if (result.status === 1) return 'DIVERGED'; diff --git a/tests/unit/signing.test.ts b/tests/unit/signing.test.ts index 2db67fe9..472216bd 100644 --- a/tests/unit/signing.test.ts +++ b/tests/unit/signing.test.ts @@ -385,6 +385,17 @@ describe('local signing controls', () => { }), ).toBe('UNKNOWN'); }); + + // QNBS-v3: an injected runner that throws must not escape into the canonical evidence catch. + it('reports UNKNOWN rather than propagating a throw from an injected runner', () => { + expect( + computeWorkingTreeState(sha, process.cwd(), { + runGit: () => { + throw new Error('spawn EMFILE'); + }, + }), + ).toBe('UNKNOWN'); + }); }); describe('workingTreeState (diagnostic dimension, never affects canonical evidence validity)', () => { From 928c32f0ab48cb59e652135a60103f54d80f7923 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:01:42 +0200 Subject: [PATCH 3/3] docs(signing): make computeWorkingTreeState's tracked-content-only scope explicit inline chatgpt-codex-connector correctly observed that an untracked file can make git diff --quiet -- report MATCHES even when that file is absent from the pushed commit (e.g. an untracked ambient .d.ts satisfying an import the real commit is missing). This is not a new gap -- it is the exact, deliberately-scoped limitation this slice's design already reasons through: MATCHES is documented (commit message, PR description, the report() wording in ci-prepush-lowend.mjs) as "no tracked-content divergence detected", never "verified"/"validated", and is never used anywhere as a skip condition for real verification. Widening this check to also flag any untracked file (e.g. via `git ls-files --others --exclude-standard`) was evaluated and rejected: it would reintroduce exactly the false-positive-on-an-unrelated-scratch- file problem this design avoids by construction today (a benign WIP file anywhere in the repo would mark every push DIVERGED, making the signal noisy enough to lose developer trust), and closing the gap soundly would additionally require ruling out gitignored/generated- artifact drift -- at which point the "cheap detector" has grown into the isolated worktree that is Part 2's job, not Part 1's. Added a one-line inline comment at the exact call site making this scope boundary explicit in the code itself, not only in the commit message and PR description, since a reviewer found it non-obvious enough to flag independently. No behavior change. Validated: full `pnpm run ci:prepush`, `pnpm run lint`, `pnpm run typecheck` (4-checker), and both targeted test files all pass. --- scripts/signing/signing-core.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/signing/signing-core.mjs b/scripts/signing/signing-core.mjs index 811abf6c..d3951576 100644 --- a/scripts/signing/signing-core.mjs +++ b/scripts/signing/signing-core.mjs @@ -335,6 +335,7 @@ export function computeWorkingTreeState(sha, cwd, dependencies = {}) { const runGitFn = dependencies.runGit ?? runGit; let result; try { + // QNBS-v3: intentionally tracked-content only -- untracked files never make MATCHES a proof. result = runGitFn(['diff', '--quiet', sha, '--'], { cwd }); } catch { return 'UNKNOWN';