Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,11 @@ jobs:
NODE_OPTIONS: "--no-experimental-webstorage --max-old-space-size=4096"
CI: "true"

# QNBS-v3: node:test tooling scripts (.mjs) fall outside Vitest's include glob (.ts/.tsx only) --
# this is the one authoritative step admitting them to routine CI, not ad-hoc scattered invocation.
- name: Unit tests (node:test, tooling scripts)
run: pnpm run test:node

- name: Coverage ratchet check (informational, non-blocking)
if: always()
continue-on-error: true
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ voice-spike/
*.crt
*.der
.storycraft-*
# Test fixture temp roots created under cwd by dependency-state/signing/verify-exact-tree tests --
# normally cleaned up in afterEach, but an interrupted run can leave these visible to git status.
.worldscript-*
~/

.playwright-mcp/
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ pnpm run token:audit # audit-tokens.mjs — design-token usage gate (CI b

**Quality gate (local pre-push subset):** `pnpm run ci:prepush` runs dependency-state/docs/CSP/native-readiness checks unconditionally (never full-repository lint — see the pre-push gate note above for what runs lint locally), and the single-checker local typecheck and i18n/content-guard checks only for changes the classifier marks as potentially impacting them (fail-closed to "run everything conditional" when evidence is incomplete); CI additionally runs full-repository lint, the 4-checker typecheck, full-suite coverage, and heavy jobs regardless of what the local gate ran. Locally use only the targeted form `pnpm exec vitest run <path> --coverage` when debugging coverage. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`).

**Exact-tree typecheck verification (opt-in, not part of `ci:prepush`):** `pnpm run verify:exact-tree [ref...]` (default `HEAD`) proves the *exact committed tree* of a ref typechecks in full isolation from the live checkout. Before touching disk, it lists the target commit's own git tree (`dependency-state.mjs`'s `listTreeFiles`, reused rather than a second parser) and refuses (`UNKNOWN`) any commit force-tracking a `node_modules` path anywhere (root, nested, or a tracked `node_modules` symlink itself) — an arbitrary ref could otherwise smuggle in an attacker-controlled `node_modules/.bin/tsgo` for the tool to trust and execute. It then creates an isolated `git worktree` at the exact commit with Git hooks disabled for that one invocation (`git -c core.hooksPath=<empty temp dir> worktree add --detach`, cleaned up immediately after) — this repo's `graphify:hooks` `post-checkout` integration, or any other configured hook, must never fire during materialization. Inside that hook-free worktree it runs a real `pnpm install --frozen-lockfile --offline --ignore-scripts --ignore-pnpmfile` (`COREPACK_ENABLE_NETWORK=0` too, since `pnpm` here is a Corepack shim that could otherwise reach the network before pnpm's own `--offline` applies; `--ignore-pnpmfile` blocks pnpm's own hook-file mechanism, a separate arbitrary-code path from `--ignore-scripts`), reconstructing pnpm's own real dependency-resolution graph (root, package-local, and transitive workspace links) rather than symlinking the live checkout's `node_modules` — an earlier symlink-based design was found, in review, to leak uncommitted/live workspace-package source back into the "isolated" result. Finally it runs the same single-checker `tsgo --noEmit` inside that tree with an explicit 6-minute timeout (measured the single-checker run alone at ~56s here; the repo separately documents ~300s for the full multi-checker `pnpm run typecheck`, so 6 minutes clears both figures with real margin, not a bare 300s cutoff). A missing package in the local offline store, an unreadable tree, a hook-dir failure, or a signal/timeout always fails the install/typecheck step and reports `UNKNOWN`, never a silent wrong answer — this tool never falls back to the network and never treats an unprovable state as a pass. Closes the gap where `ci-prepush-lowend.mjs`'s normal typecheck runs against whatever is currently on disk, not necessarily the exact tree about to be pushed. Deliberately **not** wired into the default `ci:prepush` path — the real install alone measures over a minute on this hardware, unacceptable as an always-on default; run it manually before a risky push or when investigating a CI/local typecheck mismatch. Diagnostic-only: reports `PASS | FAIL | NOT_APPLICABLE | UNKNOWN` and never blocks a push on its own; required CI remains the sole merge-safety authority regardless of its result. Its `node:test` suite (`scripts/verify-exact-tree.mjs`'s own tooling test, alongside `scripts/dependency-state.mjs`'s) runs serially (`--test-concurrency=1`, since these fixtures interleave git/pnpm/worktree operations that must not overlap on this hardware) via `pnpm run test:node`, wired into CI's quality job separately from Vitest (whose include glob is `.ts`/`.tsx`-only).

**CI pipeline order:** `security` → `quality` (Biome + tsgo + Vitest matrix) → `build` / `e2e` / `storybook` (parallel) → `lighthouse` (after build) → `deploy` on `main`. `ci-success` is a required-status aggregator (`needs: [security, quality, build]`) so branch protection can require one context instead of three/four individual ones — see `docs/CI.md`. Two additional jobs run in parallel with `quality`, both path-scoped via the `changes` job (legitimately `skipping` on PRs that don't touch their directory, which `ci-success` treats as a pass for that job only): `rust-tauri` (`src-tauri/**` — fmt/check/clippy/test, needs the GTK/WebKit apt-get steps) and `core-rust` (`crates/**` — same fmt/check/clippy/test for the renderer-neutral Rust Core, no GUI deps so no apt-get steps needed).

**CI-cloud-first workflow (constrained local hardware only):** On low-end hardware, run only `ci:prepush` locally before pushing. Coverage, E2E, Lighthouse, and Stryker are CI-gate jobs. After each push, update README.md badges and AUDIT.md quality-gate line with CI-reported numbers. Local CI simulation: `act pull_request --job quality` (Docker + `act`; see `infra/low-end-ci/DAILY-DRIVER.md`).
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
}
},
{
"includes": ["tests/**/*.ts", "tests/**/*.tsx"],
"includes": ["tests/**/*.ts", "tests/**/*.tsx", "tests/**/*.mjs"],
"linter": {
"rules": {
"suspicious": {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"deps:verify": "node scripts/dependency-state.mjs verify",
"deps:reconcile": "node scripts/dependency-state.mjs reconcile",
"ci:prepush": "node scripts/ci-prepush-lowend.mjs",
"verify:exact-tree": "node scripts/verify-exact-tree.mjs",
"ci:local:full": "pnpm run lint && pnpm run typecheck && pnpm run i18n:check && pnpm run guardrail:desktop-imports && pnpm run native-readiness:check",
"predev": "node scripts/sync-csp.mjs && node scripts/sync-sw-version.mjs && node scripts/sync-tauri-version.mjs && node scripts/sync-readme-metrics.mjs && node scripts/build-i18n.mjs && node scripts/copy-duckdb-assets.mjs",
"dev": "vite",
Expand Down Expand Up @@ -86,6 +87,7 @@
"test:turbo": "turbo run test",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:node": "node --test --test-concurrency=1 tests/unit/tooling/dependency-state.test.mjs tests/unit/tooling/verify-exact-tree.test.mjs",
"bench": "vitest bench --run tests/bench",
"test:e2e": "node -e \"if (process.env.CI !== 'true') { console.error('E2E tests are CI-only. Set CI=true or run via GitHub Actions.'); process.exit(1); }\" && playwright test",
"test:e2e:ui": "node -e \"if (process.env.CI !== 'true') { console.error('E2E tests are CI-only. Set CI=true or run via GitHub Actions.'); process.exit(1); }\" && playwright test --ui",
Expand Down
9 changes: 9 additions & 0 deletions scripts/dependency-state.d.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
// QNBS-v3: diagnostic-only dimension, independent of resolvePushEvidence's canonical evidence validity.
export type DependencyState = 'MATCHES' | 'DIVERGED' | 'NOT_APPLICABLE' | 'UNKNOWN';

export interface TreeEntry {
mode: string;
type: string;
hash: string;
path: string;
}
export function listTreeEntries(sha: string, cwd?: string): TreeEntry[] | null;
export function listTreeFiles(sha: string, cwd?: string): string[] | null;
export function readFileAtRef(sha: string, relativePath: string, cwd?: string): Buffer | null;
export function dependencyFiles(root?: string): string[];
export function calculateDependencyFingerprint(root?: string): string;
export function fingerprintPath(root?: string): string;
Expand Down
29 changes: 21 additions & 8 deletions scripts/dependency-state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,20 +76,33 @@ export function calculateDependencyFingerprint(root = projectRoot) {
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], {
// QNBS-v3: -z avoids path C-quoting; includes mode so callers (e.g. symlink detection) don't need a second parser.
export function listTreeEntries(sha, cwd) {
const result = spawnSync('git', ['ls-tree', '-r', '--full-tree', '-z', sha], {
cwd,
encoding: 'utf8',
timeout: 5000,
});
Comment thread
qnbs marked this conversation as resolved.
if (result.error || result.status !== 0) return null;
return result.stdout.split('\0').filter(Boolean);
return result.stdout
.split('\0')
.filter(Boolean)
.map((entry) => {
const tabIndex = entry.indexOf('\t');
const [mode, type, hash] = entry.slice(0, tabIndex).split(' ');
return { mode, type, hash, path: entry.slice(tabIndex + 1) };
});
}

// QNBS-v3: exported so verify-exact-tree.mjs reuses this authority instead of a second parser.
export function listTreeFiles(sha, cwd) {
const entries = listTreeEntries(sha, cwd);
return entries === null ? null : entries.map((entry) => entry.path);
}

// 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 listTree = dependencies.listTree ?? ((ref) => listTreeFiles(ref, root));
const allPaths = listTree(sha);
if (allPaths === null) return null;
const rootFiles = new Set(['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']);
Expand All @@ -101,8 +114,8 @@ export function dependencyFilesFromRef(sha, root = projectRoot, dependencies = {
.sort();
}

// QNBS-v3: no encoding -- raw Buffer stdout, matching readFileSync's raw bytes for invalid UTF-8 safety.
function defaultReadFileAtRef(sha, relativePath, cwd) {
// QNBS-v3: raw Buffer stdout for UTF-8 safety; exported so verify-exact-tree.mjs reuses this, not a second reader.
export function readFileAtRef(sha, relativePath, cwd) {
const result = spawnSync('git', ['show', `${sha}:${relativePath}`], {
cwd,
timeout: 5000,
Expand All @@ -117,7 +130,7 @@ export function calculateDependencyFingerprintFromRef(sha, root = projectRoot, d
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 readContent = dependencies.readFileAtRef ?? ((path) => readFileAtRef(sha, path, root));
const entries = [];
for (const relativePath of files) {
const content = readContent(relativePath);
Expand Down
87 changes: 87 additions & 0 deletions scripts/verify-exact-tree.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { DependencyState, TreeEntry } from './dependency-state.d.mts';
import type { BoundedResult } from './hooks/shared.d.mts';
import type { GitOptions, GitResult } from './signing/signing-core.d.mts';

// QNBS-v3: absolute correctness check, not a comparison -- distinct vocabulary from WorkingTreeState.
export type ExactTreeState = 'PASS' | 'FAIL' | 'NOT_APPLICABLE' | 'UNKNOWN';

export interface VerifyExactTreeDependencies {
runBounded?: (
command: string,
args: string[],
options?: {
timeoutMs?: number;
cwd?: string;
env?: NodeJS.ProcessEnv;
input?: string;
shell?: boolean;
root?: string;
detached?: boolean;
},
) => Promise<BoundedResult>;
runLocalBinaryDetailed?: (
binary: string,
args: string[],
options?: { root?: string; cwd?: string; timeoutMs?: number },
) => Promise<BoundedResult>;
// QNBS-v3: a distinct, pre-existing (#494) synchronous/output-capturing wrapper -- not BoundedResult.
runGit?: (args: string[], options?: GitOptions) => GitResult;
mkdtempFn?: () => Promise<string>;
// QNBS-v3: a separate temp dir authority from mkdtempFn -- distinct lifecycle (hooks dir vs. worktree dir).
mkdtempHooksFn?: () => Promise<string>;
rmFn?: (path: string) => Promise<void>;
installTimeoutMs?: number;
tsgoArgs?: string[];
tsgoTimeoutMs?: number;
repoRoot?: string;
// QNBS-v3: reuses dependency-state.mjs's mode-aware git-tree enumeration authority -- not a second parser.
listTreeEntries?: (sha: string, cwd: string) => TreeEntry[] | null;
// QNBS-v3: reuses dependency-state.mjs's git-object blob reader for symlink/.gitattributes content.
readBlobAtRef?: (sha: string, relativePath: string, cwd: string) => Buffer | null;
// QNBS-v3: checks any scope (local/global/system) for a tracked .gitattributes filter name.
isFilterConfigured?: (name: string, repoRoot: string) => boolean;
// QNBS-v3: reuses #502's manifest-compatibility authority to gate which tsgo binary may be trusted.
computeDependencyState?: (sha: string, root: string) => DependencyState;
// QNBS-v3: the trusted checkout installDependencies resolves the pinned store-dir from -- never the worktree.
trustedRepoRoot?: string;
// QNBS-v3: bypasses the real `pnpm store path` query in tests; undefined means "resolve it for real".
storeDir?: string | null;
resolveStoreDir?: (trustedRepoRoot: string | undefined) => Promise<string | null> | string | null;
}

export function createIsolatedWorktree(
sha: string,
repoRoot: string,
dependencies?: VerifyExactTreeDependencies,
Comment thread
qnbs marked this conversation as resolved.
): Promise<{ ok: boolean; path: string | undefined; interrupted?: boolean }>;

export function removeIsolatedWorktree(
worktreePath: string | undefined,
repoRoot: string,
dependencies?: VerifyExactTreeDependencies,
): Promise<void>;

export function installDependencies(
worktreePath: string,
dependencies?: VerifyExactTreeDependencies,
): Promise<boolean>;

export function verifyExactTreeTypecheck(
sha: string,
repoRoot?: string,
dependencies?: VerifyExactTreeDependencies,
): Promise<ExactTreeState>;

export function verifyExactTreeForShas(
shas: string[],
repoRoot?: string,
dependencies?: VerifyExactTreeDependencies,
): Promise<ExactTreeState>;

export function resolveRef(
ref: string,
repoRoot: string,
dependencies?: VerifyExactTreeDependencies,
): string | null;

export function main(argv?: string[], dependencies?: VerifyExactTreeDependencies): Promise<void>;
Loading
Loading