diff --git a/README.md b/README.md
index a9e9b1a1b..7c6ff2642 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 (6998+ tests / 578 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7005+ 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 (6998+ tests, 578 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7005+ 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):**
-- **6998+ unit tests** across **578 test files** — CI is authoritative for pass/fail
+- **7005+ 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 252be6a98..197ab5e16 100644
--- a/scripts/ci-prepush-lowend.mjs
+++ b/scripts/ci-prepush-lowend.mjs
@@ -57,6 +57,19 @@ async function main() {
'UNKNOWN',
'could not determine whether local results reflect the exact pushed commit(s); required CI remains authoritative',
);
+ // QNBS-v3: informational only — compares the pushed commit's manifests to the local reconciled baseline.
+ if (manualEvidence.dependencyState === 'DIVERGED')
+ report(
+ 'Dependency manifests vs. push',
+ 'DIVERGED',
+ 'pushed commit(s) declare dependencies not yet reconciled locally; required CI remains authoritative',
+ );
+ else if (manualEvidence.dependencyState === 'UNKNOWN')
+ report(
+ 'Dependency manifests vs. push',
+ 'UNKNOWN',
+ 'could not compare pushed dependency manifests to the local baseline; 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 f3fb8081b..59a411082 100644
--- a/scripts/ci-prepush-range-resolver.d.mts
+++ b/scripts/ci-prepush-range-resolver.d.mts
@@ -1,9 +1,11 @@
+import type { DependencyState } from './dependency-state.d.mts';
import type { WorkingTreeState } from './signing/signing-core.d.mts';
export interface ManualChangeEvidence {
readonly files: readonly string[];
readonly rangeResolved: boolean;
readonly workingTreeState: WorkingTreeState;
+ readonly dependencyState: DependencyState;
}
export interface ManualRangeDependencies {
diff --git a/scripts/ci-prepush-range-resolver.mjs b/scripts/ci-prepush-range-resolver.mjs
index d69705857..630c7407b 100644
--- a/scripts/ci-prepush-range-resolver.mjs
+++ b/scripts/ci-prepush-range-resolver.mjs
@@ -48,19 +48,14 @@ export function changedFilesFromManualRange(dependencies = {}) {
const workingTreeFiles = dependencies.workingTreeFiles ?? defaultWorkingTreeFiles;
// QNBS-v3: no push event or localSha exists in this mode, so nothing is ever compared.
+ const notApplicable = { workingTreeState: 'NOT_APPLICABLE', dependencyState: 'NOT_APPLICABLE' };
const upstream = resolveUpstream();
- if (!upstream) return { files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' };
+ if (!upstream) return { files: [], rangeResolved: false, ...notApplicable };
const diffFiles = diffNames(`${upstream}..HEAD`);
- if (diffFiles === null)
- return { files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' };
+ if (diffFiles === null) return { files: [], rangeResolved: false, ...notApplicable };
const workingFiles = workingTreeFiles();
- if (workingFiles === null)
- return { files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' };
- return {
- files: diffFiles.concat(workingFiles),
- rangeResolved: true,
- workingTreeState: 'NOT_APPLICABLE',
- };
+ if (workingFiles === null) return { files: [], rangeResolved: false, ...notApplicable };
+ return { files: diffFiles.concat(workingFiles), rangeResolved: true, ...notApplicable };
}
export function resolveManualEvidence(evidenceFile, dependencies = {}) {
@@ -74,5 +69,6 @@ export function resolveManualEvidence(evidenceFile, dependencies = {}) {
files: evidence.changedFiles,
rangeResolved: evidence.pathEvidenceState === 'COMPLETE',
workingTreeState: evidence.workingTreeState,
+ dependencyState: evidence.dependencyState,
};
}
diff --git a/scripts/dependency-state.d.mts b/scripts/dependency-state.d.mts
new file mode 100644
index 000000000..ae227695a
--- /dev/null
+++ b/scripts/dependency-state.d.mts
@@ -0,0 +1,42 @@
+// QNBS-v3: diagnostic-only dimension, independent of resolvePushEvidence's canonical evidence validity.
+export type DependencyState = 'MATCHES' | 'DIVERGED' | 'NOT_APPLICABLE' | 'UNKNOWN';
+
+export function dependencyFiles(root?: string): string[];
+export function calculateDependencyFingerprint(root?: string): string;
+export function fingerprintPath(root?: string): string;
+export function readStoredFingerprint(root?: string): string | null;
+export function writeStoredFingerprint(root?: string, fingerprint?: string): void;
+export function verifyDependencyState(root?: string): string;
+export function main(command?: string): void;
+
+export interface DependencyFilesFromRefDependencies {
+ listTree?: (sha: string) => string[] | null;
+}
+
+export function dependencyFilesFromRef(
+ sha: string,
+ root?: string,
+ dependencies?: DependencyFilesFromRefDependencies,
+): string[] | null;
+
+export interface DependencyFingerprintFromRefDependencies extends DependencyFilesFromRefDependencies {
+ dependencyFilesFromRef?: (sha: string) => string[] | null;
+ readFileAtRef?: (relativePath: string) => Buffer | null;
+}
+
+export function calculateDependencyFingerprintFromRef(
+ sha: string,
+ root?: string,
+ dependencies?: DependencyFingerprintFromRefDependencies,
+): string | null;
+
+export interface ComputeDependencyStateDependencies extends DependencyFingerprintFromRefDependencies {
+ readStoredFingerprint?: () => string | null;
+ calculateDependencyFingerprintFromRef?: (sha: string) => string | null;
+}
+
+export function computeDependencyState(
+ sha: string,
+ root?: string,
+ dependencies?: ComputeDependencyStateDependencies,
+): DependencyState;
diff --git a/scripts/dependency-state.mjs b/scripts/dependency-state.mjs
index 43255ba33..26df2c6e3 100644
--- a/scripts/dependency-state.mjs
+++ b/scripts/dependency-state.mjs
@@ -14,7 +14,15 @@ import { join, relative, resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
-const projectRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
+// QNBS-v3: import.meta.url isn't always a file: URL under Vitest's transform; cwd is the repo root there.
+function resolveProjectRoot() {
+ try {
+ return resolve(fileURLToPath(new URL('..', import.meta.url)));
+ } catch {
+ return process.cwd();
+ }
+}
+const projectRoot = resolveProjectRoot();
const fingerprintRelativePath = 'node_modules/.worldscript-deps-fingerprint';
function walkFiles(directory) {
@@ -42,16 +50,101 @@ export function dependencyFiles(root = projectRoot) {
return files.filter((file) => existsSync(file)).sort();
}
-export function calculateDependencyFingerprint(root = projectRoot) {
+// QNBS-v3: byte-safe CRLF->LF normalization; a latin1 round-trip preserves every byte value 0-255.
+function normalizeLineEndings(content) {
+ const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
+ return Buffer.from(buffer.toString('latin1').replaceAll('\r\n', '\n'), 'latin1');
+}
+
+// QNBS-v3: shared by both the filesystem and git-ref fingerprint paths so they can never drift.
+function hashManifests(entries) {
const hash = createHash('sha256');
- for (const file of dependencyFiles(root)) {
- hash.update(`${relative(root, file).replaceAll('\\', '/')}\0`);
- hash.update(readFileSync(file));
+ for (const [relativePath, content] of [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
+ hash.update(`${relativePath}\0`);
+ // QNBS-v3: normalizes a core.autocrlf checkout vs. the LF-stored git blob to the same bytes.
+ hash.update(normalizeLineEndings(content));
hash.update('\0');
}
return hash.digest('hex');
}
+export function calculateDependencyFingerprint(root = projectRoot) {
+ const entries = dependencyFiles(root).map((file) => [
+ relative(root, file).replaceAll('\\', '/'),
+ readFileSync(file),
+ ]);
+ return hashManifests(entries);
+}
+
+// QNBS-v3: --full-tree ignores cwd-subdirectory scoping; -z disables git's default path C-quoting.
+function defaultListTreeFiles(sha, cwd) {
+ const result = spawnSync('git', ['ls-tree', '-r', '--full-tree', '--name-only', '-z', sha], {
+ cwd,
+ encoding: 'utf8',
+ timeout: 5000,
+ });
+ if (result.error || result.status !== 0) return null;
+ return result.stdout.split('\0').filter(Boolean);
+}
+
+// QNBS-v3: diagnostic-only; mirrors dependencyFiles' inclusion rules against a commit, not disk.
+export function dependencyFilesFromRef(sha, root = projectRoot, dependencies = {}) {
+ const listTree = dependencies.listTree ?? ((ref) => defaultListTreeFiles(ref, root));
+ const allPaths = listTree(sha);
+ if (allPaths === null) return null;
+ const rootFiles = new Set(['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']);
+ const packagePattern = /^packages\/[^/]+\/package\.json$/;
+ return allPaths
+ .filter(
+ (path) => rootFiles.has(path) || path.startsWith('patches/') || packagePattern.test(path),
+ )
+ .sort();
+}
+
+// QNBS-v3: no encoding -- raw Buffer stdout, matching readFileSync's raw bytes for invalid UTF-8 safety.
+function defaultReadFileAtRef(sha, relativePath, cwd) {
+ const result = spawnSync('git', ['show', `${sha}:${relativePath}`], {
+ cwd,
+ timeout: 5000,
+ maxBuffer: 16 * 1024 * 1024,
+ });
+ if (result.error || result.status !== 0) return null;
+ return result.stdout;
+}
+
+// QNBS-v3: diagnostic-only; reads localSha's committed manifests via git objects, no worktree.
+export function calculateDependencyFingerprintFromRef(sha, root = projectRoot, dependencies = {}) {
+ const listFiles = dependencies.dependencyFilesFromRef ?? (() => dependencyFilesFromRef(sha, root, dependencies));
+ const files = listFiles(sha);
+ if (files === null) return null;
+ const readContent = dependencies.readFileAtRef ?? ((path) => defaultReadFileAtRef(sha, path, root));
+ const entries = [];
+ for (const relativePath of files) {
+ const content = readContent(relativePath);
+ if (content === null) return null;
+ entries.push([relativePath, content]);
+ }
+ return hashManifests(entries);
+}
+
+// QNBS-v3: diagnostic-only signal; never throws, so it cannot corrupt canonical evidence validity.
+export function computeDependencyState(sha, root = projectRoot, dependencies = {}) {
+ try {
+ const readStored = dependencies.readStoredFingerprint ?? (() => readStoredFingerprint(root));
+ const storedFingerprint = readStored();
+ // QNBS-v3: no baseline reconciled yet on this machine -- an honest unknown, not "no comparison".
+ if (!storedFingerprint) return 'UNKNOWN';
+ const fingerprintFromRef =
+ dependencies.calculateDependencyFingerprintFromRef ??
+ ((ref) => calculateDependencyFingerprintFromRef(ref, root, dependencies));
+ const refFingerprint = fingerprintFromRef(sha);
+ if (refFingerprint === null) return 'UNKNOWN';
+ return refFingerprint === storedFingerprint ? 'MATCHES' : 'DIVERGED';
+ } catch {
+ return 'UNKNOWN';
+ }
+}
+
export function fingerprintPath(root = projectRoot) {
return join(root, fingerprintRelativePath);
}
diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts
index 8ebfdb001..b6dcc0dd9 100644
--- a/scripts/signing/signing-core.d.mts
+++ b/scripts/signing/signing-core.d.mts
@@ -79,12 +79,15 @@ export function writePrePushEvidenceFile(
file: string,
input: string | string[] | RefUpdate[],
): void;
+import type { DependencyState } from '../dependency-state.d.mts';
+
// 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;
+ dependencyState: DependencyState;
}
export interface PushEvidence {
updates: PushEvidenceUpdate[];
@@ -92,6 +95,7 @@ export interface PushEvidence {
evidenceState: 'RESOLVED' | 'INVALID';
pathEvidenceState: 'COMPLETE' | 'PARTIAL';
workingTreeState: WorkingTreeState;
+ dependencyState: DependencyState;
reason?: string;
}
export function computeWorkingTreeState(
@@ -107,6 +111,7 @@ export function resolvePushEvidence(
objectExists?: (sha: string) => boolean;
changedFilesBetween?: (base: string, head: string) => string[];
worktreeMatchesCommit?: (sha: string) => WorkingTreeState;
+ dependencyStateForRef?: (sha: string) => DependencyState;
},
): PushEvidence;
export function selectIntroducedCommits(commits: string[], reachableFromBase: string[]): string[];
diff --git a/scripts/signing/signing-core.mjs b/scripts/signing/signing-core.mjs
index d39515767..11d2517e4 100644
--- a/scripts/signing/signing-core.mjs
+++ b/scripts/signing/signing-core.mjs
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join, resolve } from 'node:path';
+import { computeDependencyState } from '../dependency-state.mjs';
const SHA = /^[0-9a-f]{40}$/i;
const ZERO_SHA = /^0{40}$/;
@@ -346,11 +347,21 @@ export function computeWorkingTreeState(sha, cwd, dependencies = {}) {
return 'UNKNOWN';
}
-function aggregateWorkingTreeState(evidenceUpdates) {
- const relevant = evidenceUpdates.filter((update) => update.workingTreeState !== 'NOT_APPLICABLE');
+// QNBS-v3: an injected diagnostic resolver that throws must not corrupt canonical evidence validity.
+function safeDiagnostic(resolver, sha) {
+ try {
+ return resolver(sha);
+ } catch {
+ return 'UNKNOWN';
+ }
+}
+
+// QNBS-v3: shared by every diagnostic-only dimension so precedence can never drift between them.
+function aggregateDiagnosticState(states) {
+ const relevant = states.filter((state) => state !== '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';
+ if (relevant.includes('DIVERGED')) return 'DIVERGED';
+ if (relevant.includes('UNKNOWN')) return 'UNKNOWN';
return 'MATCHES';
}
@@ -367,11 +378,18 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {
dependencies.changedFilesBetween ?? ((base, head) => changedFilesBetween(base, head, cwd));
const matchesWorktree =
dependencies.worktreeMatchesCommit ?? ((sha) => computeWorkingTreeState(sha, cwd));
+ const matchesDependencyState =
+ dependencies.dependencyStateForRef ?? ((sha) => computeDependencyState(sha, cwd));
const changedFiles = new Set();
const evidenceUpdates = [];
for (const update of updates) {
if (isZeroSha(update.localSha)) {
- evidenceUpdates.push({ ...update, disposition: 'DELETED', workingTreeState: 'NOT_APPLICABLE' });
+ evidenceUpdates.push({
+ ...update,
+ disposition: 'DELETED',
+ workingTreeState: 'NOT_APPLICABLE',
+ dependencyState: 'NOT_APPLICABLE',
+ });
continue;
}
if (update.remoteRef.startsWith('refs/tags/')) {
@@ -380,7 +398,8 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {
evidenceUpdates.push({
...update,
disposition: 'TAG',
- workingTreeState: matchesWorktree(update.localSha),
+ workingTreeState: safeDiagnostic(matchesWorktree, update.localSha),
+ dependencyState: safeDiagnostic(matchesDependencyState, update.localSha),
});
continue;
}
@@ -394,7 +413,8 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {
...update,
base,
disposition: isZeroSha(update.remoteSha) ? 'NEW_BRANCH' : 'UPDATED',
- workingTreeState: matchesWorktree(update.localSha),
+ workingTreeState: safeDiagnostic(matchesWorktree, update.localSha),
+ dependencyState: safeDiagnostic(matchesDependencyState, update.localSha),
});
}
// QNBS-v3: tag updates prove object validity but not a complete changed-path set.
@@ -406,7 +426,8 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {
changedFiles: [...changedFiles],
evidenceState: 'RESOLVED',
pathEvidenceState,
- workingTreeState: aggregateWorkingTreeState(evidenceUpdates),
+ workingTreeState: aggregateDiagnosticState(evidenceUpdates.map((u) => u.workingTreeState)),
+ dependencyState: aggregateDiagnosticState(evidenceUpdates.map((u) => u.dependencyState)),
};
} catch (error) {
return {
@@ -415,6 +436,7 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {
evidenceState: 'INVALID',
pathEvidenceState: 'PARTIAL',
workingTreeState: 'NOT_APPLICABLE',
+ dependencyState: '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 472216bd6..13838c364 100644
--- a/tests/unit/signing.test.ts
+++ b/tests/unit/signing.test.ts
@@ -220,6 +220,7 @@ describe('local signing controls', () => {
? ['new\nfile.ts']
: ['src/with\t tab.ts', '世界 file.ts', 'src/with\t tab.ts'],
worktreeMatchesCommit: () => 'MATCHES',
+ dependencyStateForRef: () => 'MATCHES',
});
expect(result.evidenceState).toBe('RESOLVED');
expect(result.pathEvidenceState).toBe('PARTIAL');
@@ -244,6 +245,7 @@ describe('local signing controls', () => {
commitExists: () => true,
changedFilesBetween: () => ['src/example.ts'],
worktreeMatchesCommit: () => 'MATCHES',
+ dependencyStateForRef: () => 'MATCHES',
});
expect(result.evidenceState).toBe('RESOLVED');
@@ -261,12 +263,14 @@ describe('local signing controls', () => {
const result = resolvePushEvidence([update], process.cwd(), {
objectExists: () => true,
worktreeMatchesCommit: () => 'MATCHES',
+ dependencyStateForRef: () => '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');
+ expect(result.updates[0]?.dependencyState).toBe('MATCHES');
}
});
@@ -283,6 +287,7 @@ describe('local signing controls', () => {
objectExists: () => true,
changedFilesBetween: () => ['src/a.ts'],
worktreeMatchesCommit: () => 'MATCHES',
+ dependencyStateForRef: () => 'MATCHES',
},
);
@@ -296,6 +301,7 @@ describe('local signing controls', () => {
expect(empty.pathEvidenceState).toBe('COMPLETE');
// QNBS-v3: nothing was compared, not "compared and found equal" — see aggregation precedence.
expect(empty.workingTreeState).toBe('NOT_APPLICABLE');
+ expect(empty.dependencyState).toBe('NOT_APPLICABLE');
const result = resolvePushEvidence(
[parseRefUpdate(`refs/tags/v1 ${'a'.repeat(40)} refs/tags/v1 ${'0'.repeat(40)}`)!],
@@ -407,6 +413,7 @@ describe('local signing controls', () => {
commitExists: () => true,
changedFilesBetween: () => ['src/example.ts'],
worktreeMatchesCommit: () => 'UNKNOWN',
+ dependencyStateForRef: () => 'MATCHES',
});
// QNBS-v3: this is the regression guard for the diagnostic-isolation correction specifically.
@@ -422,11 +429,16 @@ describe('local signing controls', () => {
worktreeMatchesCommit: () => {
throw new Error('must not be called for a deletion');
},
+ dependencyStateForRef: () => {
+ 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.updates[0]?.dependencyState).toBe('NOT_APPLICABLE');
expect(result.workingTreeState).toBe('NOT_APPLICABLE');
+ expect(result.dependencyState).toBe('NOT_APPLICABLE');
});
it('aggregates with DIVERGED outranking UNKNOWN, and UNKNOWN outranking MATCHES', () => {
@@ -441,12 +453,14 @@ describe('local signing controls', () => {
const divergedPlusUnknown = resolvePushEvidence([updateA, updateB], process.cwd(), {
...shared,
worktreeMatchesCommit: (sha) => (sha === updateA.localSha ? 'DIVERGED' : 'UNKNOWN'),
+ dependencyStateForRef: () => 'MATCHES',
});
expect(divergedPlusUnknown.workingTreeState).toBe('DIVERGED');
const matchesPlusUnknown = resolvePushEvidence([updateA, updateB], process.cwd(), {
...shared,
worktreeMatchesCommit: (sha) => (sha === updateA.localSha ? 'MATCHES' : 'UNKNOWN'),
+ dependencyStateForRef: () => 'MATCHES',
});
expect(matchesPlusUnknown.workingTreeState).toBe('UNKNOWN');
});
@@ -462,6 +476,118 @@ describe('local signing controls', () => {
);
expect(result.evidenceState).toBe('RESOLVED');
expect(result.workingTreeState).toBe('NOT_APPLICABLE');
+ expect(result.dependencyState).toBe('NOT_APPLICABLE');
+ });
+
+ // QNBS-v3: an injected worktreeMatchesCommit that throws must not corrupt canonical evidence.
+ it('reports UNKNOWN rather than corrupting canonical evidence when the injected resolver throws', () => {
+ 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: () => {
+ throw new Error('spawn EMFILE');
+ },
+ dependencyStateForRef: () => 'MATCHES',
+ });
+
+ expect(result.evidenceState).toBe('RESOLVED');
+ expect(result.pathEvidenceState).toBe('COMPLETE');
+ expect(result.workingTreeState).toBe('UNKNOWN');
+ });
+ });
+
+ describe('dependencyState (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: () => ['package.json'],
+ worktreeMatchesCommit: () => 'MATCHES',
+ dependencyStateForRef: () => 'UNKNOWN',
+ });
+
+ // QNBS-v3: mirrors the workingTreeState isolation guard, for the dependencyState dimension.
+ expect(result.evidenceState).toBe('RESOLVED');
+ expect(result.pathEvidenceState).toBe('COMPLETE');
+ expect(result.dependencyState).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');
+ },
+ dependencyStateForRef: () => {
+ throw new Error('must not be called for a deletion');
+ },
+ });
+
+ expect(result.evidenceState).toBe('RESOLVED');
+ expect(result.updates[0]?.dependencyState).toBe('NOT_APPLICABLE');
+ expect(result.dependencyState).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: () => 'MATCHES',
+ dependencyStateForRef: (sha) => (sha === updateA.localSha ? 'DIVERGED' : 'UNKNOWN'),
+ });
+ expect(divergedPlusUnknown.dependencyState).toBe('DIVERGED');
+
+ const matchesPlusUnknown = resolvePushEvidence([updateA, updateB], process.cwd(), {
+ ...shared,
+ worktreeMatchesCommit: () => 'MATCHES',
+ dependencyStateForRef: (sha) => (sha === updateA.localSha ? 'MATCHES' : 'UNKNOWN'),
+ });
+ expect(matchesPlusUnknown.dependencyState).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.dependencyState).toBe('NOT_APPLICABLE');
+ });
+
+ // QNBS-v3: regression for the CodeRabbit finding -- an injected resolver throw must map to UNKNOWN.
+ it('reports UNKNOWN rather than corrupting canonical evidence when the injected resolver throws', () => {
+ const update = parseRefUpdate(
+ `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`,
+ )!;
+ const result = resolvePushEvidence([update], process.cwd(), {
+ commitExists: () => true,
+ changedFilesBetween: () => ['package.json'],
+ worktreeMatchesCommit: () => 'MATCHES',
+ dependencyStateForRef: () => {
+ throw new Error('git show failed');
+ },
+ });
+
+ expect(result.evidenceState).toBe('RESOLVED');
+ expect(result.pathEvidenceState).toBe('COMPLETE');
+ expect(result.dependencyState).toBe('UNKNOWN');
});
});
diff --git a/tests/unit/tooling/ciPrepushRangeResolver.test.ts b/tests/unit/tooling/ciPrepushRangeResolver.test.ts
index 6e45704fd..30c63380b 100644
--- a/tests/unit/tooling/ciPrepushRangeResolver.test.ts
+++ b/tests/unit/tooling/ciPrepushRangeResolver.test.ts
@@ -31,10 +31,13 @@ describe('isMainModule', () => {
});
describe('manual committed-range resolution', () => {
+ // QNBS-v3: no push event/localSha exists in manual mode — NOT_APPLICABLE for both dimensions.
+ const notApplicable = { workingTreeState: 'NOT_APPLICABLE', dependencyState: 'NOT_APPLICABLE' };
+
it('is unresolved when no upstream is configured', () => {
const result = changedFilesFromManualRange({ resolveUpstream: () => null });
- expect(result).toEqual({ files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' });
+ expect(result).toEqual({ files: [], rangeResolved: false, ...notApplicable });
});
it('resolves and merges working-tree changes when the diff succeeds', () => {
@@ -47,11 +50,10 @@ describe('manual committed-range resolution', () => {
workingTreeFiles: () => ['src/dirty.ts'],
});
- // 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',
+ ...notApplicable,
});
});
@@ -65,7 +67,7 @@ describe('manual committed-range resolution', () => {
},
});
- expect(result).toEqual({ files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' });
+ expect(result).toEqual({ files: [], rangeResolved: false, ...notApplicable });
});
it('treats a genuinely empty diff as a resolved, complete range', () => {
@@ -75,7 +77,7 @@ describe('manual committed-range resolution', () => {
workingTreeFiles: () => [],
});
- expect(result).toEqual({ files: [], rangeResolved: true, workingTreeState: 'NOT_APPLICABLE' });
+ expect(result).toEqual({ files: [], rangeResolved: true, ...notApplicable });
});
// QNBS-v3: regression — a successful committed-range diff must not mask a working-tree failure.
@@ -86,17 +88,19 @@ describe('manual committed-range resolution', () => {
workingTreeFiles: () => null,
});
- expect(result).toEqual({ files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' });
+ expect(result).toEqual({ files: [], rangeResolved: false, ...notApplicable });
});
});
describe('resolveManualEvidence', () => {
+ const notApplicable = { workingTreeState: 'NOT_APPLICABLE', dependencyState: 'NOT_APPLICABLE' };
+
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, workingTreeState: 'NOT_APPLICABLE' });
+ expect(result).toEqual({ files: [], rangeResolved: false, ...notApplicable });
});
it('trusts changedFiles as complete when pathEvidenceState is COMPLETE', () => {
@@ -110,6 +114,7 @@ describe('resolveManualEvidence', () => {
pathEvidenceState: 'COMPLETE',
changedFiles: ['src/example.ts'],
workingTreeState: 'MATCHES',
+ dependencyState: 'MATCHES',
}),
});
@@ -117,6 +122,7 @@ describe('resolveManualEvidence', () => {
files: ['src/example.ts'],
rangeResolved: true,
workingTreeState: 'MATCHES',
+ dependencyState: 'MATCHES',
});
});
@@ -128,11 +134,11 @@ describe('resolveManualEvidence', () => {
evidenceState: 'RESOLVED',
pathEvidenceState: 'PARTIAL',
changedFiles: [],
- workingTreeState: 'NOT_APPLICABLE',
+ ...notApplicable,
}),
});
- expect(result).toEqual({ files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' });
+ expect(result).toEqual({ files: [], rangeResolved: false, ...notApplicable });
});
// QNBS-v3: proves the two signals are orthogonal, not coupled to `full` via pathEvidenceState.
@@ -145,6 +151,7 @@ describe('resolveManualEvidence', () => {
pathEvidenceState: 'COMPLETE',
changedFiles: ['src/example.ts'],
workingTreeState,
+ dependencyState: 'MATCHES',
}),
});
@@ -153,6 +160,25 @@ describe('resolveManualEvidence', () => {
}
});
+ // QNBS-v3: proves dependencyState propagates independently of workingTreeState/pathEvidenceState.
+ it('propagates DIVERGED and UNKNOWN dependencyState independently of the other signals', () => {
+ for (const dependencyState of ['DIVERGED', 'UNKNOWN']) {
+ const result = resolveManualEvidence('/tmp/evidence.json', {
+ readPrePushEvidenceFile: () => 'raw',
+ resolvePushEvidence: () => ({
+ evidenceState: 'RESOLVED',
+ pathEvidenceState: 'COMPLETE',
+ changedFiles: ['src/example.ts'],
+ workingTreeState: 'MATCHES',
+ dependencyState,
+ }),
+ });
+
+ expect(result.rangeResolved).toBe(true);
+ expect(result.dependencyState).toBe(dependencyState);
+ }
+ });
+
it('throws for INVALID evidence', () => {
expect(() =>
resolveManualEvidence('/tmp/evidence.json', {
@@ -161,7 +187,7 @@ describe('resolveManualEvidence', () => {
evidenceState: 'INVALID',
pathEvidenceState: 'PARTIAL',
changedFiles: [],
- workingTreeState: 'NOT_APPLICABLE',
+ ...notApplicable,
reason: 'boom',
}),
}),
diff --git a/tests/unit/tooling/dependency-state.test.mjs b/tests/unit/tooling/dependency-state.test.mjs
index b4d68853f..e1e2c72dd 100644
--- a/tests/unit/tooling/dependency-state.test.mjs
+++ b/tests/unit/tooling/dependency-state.test.mjs
@@ -1,9 +1,13 @@
import { strict as assert } from 'node:assert';
+import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, it } from 'node:test';
import {
calculateDependencyFingerprint,
+ calculateDependencyFingerprintFromRef,
+ computeDependencyState,
+ dependencyFilesFromRef,
readStoredFingerprint,
writeStoredFingerprint,
} from '../../../scripts/dependency-state.mjs';
@@ -50,3 +54,169 @@ describe('dependency fingerprint', () => {
assert.notEqual(calculateDependencyFingerprint(root), before);
});
});
+
+describe('dependencyFilesFromRef (git-object-only, no worktree)', () => {
+ it('includes root manifests, patches, and single-level package.json, excludes the rest', () => {
+ const files = dependencyFilesFromRef('deadbeef', '/repo', {
+ listTree: () => [
+ 'package.json',
+ 'pnpm-lock.yaml',
+ 'pnpm-workspace.yaml',
+ 'README.md',
+ 'patches/demo.patch',
+ 'patches/nested/also.patch',
+ 'packages/demo/package.json',
+ 'packages/demo/src/index.ts',
+ 'packages/demo/nested/package.json',
+ ],
+ });
+
+ assert.deepEqual(files, [
+ 'package.json',
+ 'packages/demo/package.json',
+ 'patches/demo.patch',
+ 'patches/nested/also.patch',
+ 'pnpm-lock.yaml',
+ 'pnpm-workspace.yaml',
+ ]);
+ });
+
+ it('returns null (not []) when the tree listing fails', () => {
+ assert.equal(dependencyFilesFromRef('deadbeef', '/repo', { listTree: () => null }), null);
+ });
+});
+
+describe('dependencyFilesFromRef (real git invocation, default listTree)', () => {
+ // QNBS-v3: regression -- git's default C-quoting of non-ASCII paths must not exclude real files.
+ it('includes a non-ASCII patch filename via the real git default (no listTree injected)', () => {
+ const root = mkdtempSync(join(process.cwd(), '.worldscript-deps-git-'));
+ temporaryRoots.push(root);
+ const git = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' });
+ git(['init', '--quiet', '--initial-branch=main']);
+ git(['config', 'user.email', 'test@example.com']);
+ git(['config', 'user.name', 'test']);
+ mkdirSync(join(root, 'patches'), { recursive: true });
+ writeFileSync(join(root, 'patches', 'café.patch'), 'patch\n');
+ git(['add', '-A']);
+ git(['-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'test']);
+ const sha = git(['rev-parse', 'HEAD']).trim();
+
+ const files = dependencyFilesFromRef(sha, root);
+
+ assert.ok(
+ files?.includes('patches/café.patch'),
+ `expected patches/café.patch in ${JSON.stringify(files)}`,
+ );
+ });
+});
+
+describe('calculateDependencyFingerprintFromRef (git-object-only, no worktree)', () => {
+ it('matches the filesystem fingerprint for the same paths and content', () => {
+ const root = makeDependencyRoot();
+ const filesystemFingerprint = calculateDependencyFingerprint(root);
+ const contentByPath = {
+ 'package.json': '{}\n',
+ 'pnpm-lock.yaml': "lockfileVersion: '9.0'\n",
+ 'pnpm-workspace.yaml': 'packages:\n - packages/*\n',
+ 'packages/demo/package.json': '{"name":"demo"}\n',
+ 'patches/demo.patch': 'patch\n',
+ };
+ const refFingerprint = calculateDependencyFingerprintFromRef('deadbeef', root, {
+ listTree: () => Object.keys(contentByPath),
+ readFileAtRef: (relativePath) => contentByPath[relativePath],
+ });
+
+ assert.equal(refFingerprint, filesystemFingerprint);
+ });
+
+ it('returns null when a file read at the ref fails', () => {
+ const result = calculateDependencyFingerprintFromRef('deadbeef', '/repo', {
+ listTree: () => ['package.json'],
+ readFileAtRef: () => null,
+ });
+ assert.equal(result, null);
+ });
+
+ // QNBS-v3: regression -- git-object read must hash raw bytes, not a lossy UTF-8-decoded string.
+ it('matches the filesystem fingerprint byte-for-byte, including invalid UTF-8 content', () => {
+ const root = makeDependencyRoot();
+ const invalidUtf8 = Buffer.from([0x70, 0x61, 0x74, 0x63, 0x68, 0x0a, 0xff]);
+ writeFileSync(join(root, 'patches', 'demo.patch'), invalidUtf8);
+ const filesystemFingerprint = calculateDependencyFingerprint(root);
+
+ const contentByPath = {
+ 'package.json': Buffer.from('{}\n'),
+ 'pnpm-lock.yaml': Buffer.from("lockfileVersion: '9.0'\n"),
+ 'pnpm-workspace.yaml': Buffer.from('packages:\n - packages/*\n'),
+ 'packages/demo/package.json': Buffer.from('{"name":"demo"}\n'),
+ 'patches/demo.patch': invalidUtf8,
+ };
+ const refFingerprint = calculateDependencyFingerprintFromRef('deadbeef', root, {
+ listTree: () => Object.keys(contentByPath),
+ readFileAtRef: (relativePath) => contentByPath[relativePath],
+ });
+
+ assert.equal(refFingerprint, filesystemFingerprint);
+ });
+
+ // QNBS-v3: regression -- core.autocrlf CRLF checkout vs. LF git blob must not report false DIVERGED.
+ it('produces the same fingerprint for content differing only by CRLF vs. LF line endings', () => {
+ const lf = calculateDependencyFingerprintFromRef('deadbeef', '/repo', {
+ listTree: () => ['package.json'],
+ readFileAtRef: () => Buffer.from('{\n "name": "demo"\n}\n'),
+ });
+ const crlf = calculateDependencyFingerprintFromRef('deadbeef', '/repo', {
+ listTree: () => ['package.json'],
+ readFileAtRef: () => Buffer.from('{\r\n "name": "demo"\r\n}\r\n'),
+ });
+
+ assert.equal(crlf, lf);
+ });
+});
+
+describe('computeDependencyState (diagnostic-only, isolated from canonical evidence)', () => {
+ it('reports MATCHES when the ref fingerprint equals the stored baseline', () => {
+ const state = computeDependencyState('deadbeef', '/repo', {
+ readStoredFingerprint: () => 'abc123',
+ calculateDependencyFingerprintFromRef: () => 'abc123',
+ });
+ assert.equal(state, 'MATCHES');
+ });
+
+ it('reports DIVERGED when the ref fingerprint differs from the stored baseline', () => {
+ const state = computeDependencyState('deadbeef', '/repo', {
+ readStoredFingerprint: () => 'abc123',
+ calculateDependencyFingerprintFromRef: () => 'def456',
+ });
+ assert.equal(state, 'DIVERGED');
+ });
+
+ it('reports UNKNOWN when no baseline has been reconciled on this machine yet', () => {
+ const state = computeDependencyState('deadbeef', '/repo', {
+ readStoredFingerprint: () => null,
+ calculateDependencyFingerprintFromRef: () => {
+ throw new Error('must not be called without a baseline to compare against');
+ },
+ });
+ assert.equal(state, 'UNKNOWN');
+ });
+
+ it('reports UNKNOWN when the ref fingerprint could not be established', () => {
+ const state = computeDependencyState('deadbeef', '/repo', {
+ readStoredFingerprint: () => 'abc123',
+ calculateDependencyFingerprintFromRef: () => null,
+ });
+ assert.equal(state, 'UNKNOWN');
+ });
+
+ // QNBS-v3: an injected dependency that throws must not escape into resolvePushEvidence's catch.
+ it('reports UNKNOWN rather than propagating a throw from an injected dependency', () => {
+ const state = computeDependencyState('deadbeef', '/repo', {
+ readStoredFingerprint: () => 'abc123',
+ calculateDependencyFingerprintFromRef: () => {
+ throw new Error('git show failed');
+ },
+ });
+ assert.equal(state, 'UNKNOWN');
+ });
+});