diff --git a/README.md b/README.md
index 74ced838..a9e9b1a1 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (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',
}),
}),