diff --git a/README.md b/README.md index 74ced838..a9e9b1a1 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 + 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 (6988+ 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 (6988+ 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):** -- **6988+ 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/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..d3951576 100644 --- a/scripts/signing/signing-core.mjs +++ b/scripts/signing/signing-core.mjs @@ -330,6 +330,30 @@ 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; + 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'; + } + 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 +365,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 +394,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 +406,7 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = { changedFiles: [...changedFiles], evidenceState: 'RESOLVED', pathEvidenceState, + workingTreeState: aggregateWorkingTreeState(evidenceUpdates), }; } catch (error) { return { @@ -382,6 +414,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..472216bd 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,126 @@ 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'); + }); + + // 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)', () => { + 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', }), }),