diff --git a/README.md b/README.md index a9e9b1a1b..7c6ff2642 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 6998+ tests / 578 files + 7005+ 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 (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'); + }); +});