From 29fa506084d57ad72d12427728fc4d3aa8939bca Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:29:28 +0800 Subject: [PATCH 1/5] Refactor non-RAG assurance scripts and speed focused checks --- ...odescene-non-rag-remediation-2026-10-09.md | 102 ++ ...1d488603e6f1b84a31a20751433515d6.record.md | 1 + scripts/check-codex-cloud-setup.mjs | 633 ++++---- scripts/check-design-system-contract.mjs | 6 +- scripts/ci-change-scope.mjs | 338 ++-- scripts/design-system-contract-utils.mjs | 632 ++++---- scripts/generate-design-system-adoption.mjs | 1117 +++++++------ scripts/ledger-inbox.mjs | 1440 +++++++++-------- scripts/lib/tenancy-scan.mjs | 253 +-- scripts/setup-codex-cloud.sh | 48 +- tests/codex-cloud-policy-harness.test.ts | 136 ++ tests/design-system-adoption.test.ts | 145 ++ tests/design-system-contract-utils.test.ts | 28 + 13 files changed, 2944 insertions(+), 1935 deletions(-) create mode 100644 docs/audit/codescene-non-rag-remediation-2026-10-09.md create mode 100644 docs/branch-review-records/ab93745778dff4eab8369ee651e62e0b1d488603e6f1b84a31a20751433515d6.record.md create mode 100644 tests/codex-cloud-policy-harness.test.ts diff --git a/docs/audit/codescene-non-rag-remediation-2026-10-09.md b/docs/audit/codescene-non-rag-remediation-2026-10-09.md new file mode 100644 index 0000000000..af0468f8f2 --- /dev/null +++ b/docs/audit/codescene-non-rag-remediation-2026-10-09.md @@ -0,0 +1,102 @@ +# CodeScene maintainability remediation: non-RAG phase + +This work follows the full-file CodeScene audit of `BigSimmo/PsychSift` at +`7f3303c009df84a1e4e2b3e54ea6e4b32f3bf147`. Audit warnings describe maintainability; +they do not establish functional defects. Individual file scores are not +CodeScene's official repository score. + +The full finding backlog, plan, source hashes and verification evidence are kept +in the local task workspace outside this repository. Task identity: +`psychsift-codescene-remediation-20261009`. This document explains the maintained +source changes; the task checkpoint records their current acceptance state. + +## Scope and boundaries + +RAG remediation is paused at Josh's request. This includes retrieval and ranking, +answer generation and verification, extraction/chunking/index/embedding producers, +related evaluation and protected tests. Source-rendering components are treated +conservatively as adjacent to that deferred work. + +Local maintainability edits do not authorize provider traffic, production access, +publication or deployment. Existing unrelated changes in the original checkout +must be preserved. The remediation uses an isolated checkout with one writer per +owned file and one controller for shared verification. + +## Implemented work packages + +- `scripts/generate-design-system-adoption.mjs`: separates visual-provenance and + adoption-manifest checks into ordered validation phases. Each phase keeps the + existing error text, rejection conditions, ordering and early-return behavior. + PNG header, structure and decoded-data checks, candidate source-binding phases + and class-expression resolution have distinct helpers. Invalid-image refusal, + exact suite/path checks, lexical shadowing, unknown values and cycles remain + part of their original contracts. +- `scripts/design-system-contract-utils.mjs`: separates class-expression AST + resolution from class-root and token-contract reporting. Unknown bindings, + shadowing, cycles, alternative order and the edge-conflict early stop retain + their existing meaning. Internal raw-scale checks receive a cohesive options + object. +- `scripts/ledger-inbox.mjs`: separates request validation, payload construction, + request application, cancellation, reconciliation and CLI dispatch. Receipt + identity, fingerprints, dry-run behavior, locking and existing remote-check + semantics must remain unchanged. +- `scripts/lib/tenancy-scan.mjs`: the separate privacy-sensitive work package + simplifies AST evidence collection without changing tenancy rules. Acceptance + requires focused offline proof and independent privacy/schema review. +- `scripts/ci-change-scope.mjs`: separates owner-manifest path checks, direct and + derived change classification, Lighthouse budget parsing and self-test groups. + Path containment, symlink refusal, policy patterns, output order and the full + set of routing assertions are preserved. +- `scripts/check-codex-cloud-setup.mjs`: separates TOML/MCP parsing, inventory + checks, setup phases and runtime validation phases. Credential restrictions, + defaults, command requirements, diagnostic order and dependency checks remain + intact. This source refactor does not change or provision any environment. + +## Verification and completion + +Verification uses existing focused behavioral tests and original-versus-candidate +output comparisons where appropriate. Required local domain checks and independent +reviews apply to stable source hashes. CodeScene re-reviews record remaining +warnings; helper extraction alone does not establish whole-file completion. + +Do not regenerate adoption manifests, visual baselines, gate figures, debt +baselines or snapshots to make a check pass. Existing failures must be diagnosed +and distinguished from regressions on the audited base. + +The programme remains incomplete while non-deferred findings lack implementation +and acceptance evidence or a reviewed, finding-specific reason to retain their +structure. Deferred, blocked, pending, failed and unscored work must remain visible +in the same task checkpoint. A local receipt export does not imply ledger sync, +CI success, merge, deployment or release acceptance. + +## Restored checkpoint — 9 October 2026 + +The six saved script refactors were restored byte for byte at their original base commit. Their source hashes are unchanged, so the saved CodeScene analysis and independent source reviews remain historical evidence for exactly those versions. + +Fresh local verification completed on the restored checkout: + +- Adoption: 58 tests passed; the check-only repository contract passed for 55 components and 183 roots. +- Adoption parity: 23 synthetic PNG cases, 28 AST cases and 1,592 production files compared without differences. +- Cloud: all 35 tests passed, preserving the existing assertions and timeout limits. +- CI: all 129 tests across three suites passed, including the complete 384-case Bash matrix. +- Installed dependencies: 709 package locations and 48,240 files matched the saved locked tree. + +The earlier interrupted and failed runs remain in the external evidence history. No additional source refactor or test relaxation was needed to close these verification gaps. These results accept the selected behavior-preserving batch locally; they do not establish that every CodeScene warning is fixed, nor hosted CI, publication, deployment or clinical readiness. + +The remaining non-RAG inventory contains 1,272 warning-bearing files: 1,253 queued, 13 requiring ownership review and six partially refactored files with remaining findings. There are 4,904 original unresolved finding instances. The broad RAG deferral was widened to include 15 adjacent source/evaluation test files. Every future queued package still needs current-source and caller inspection. + +The original checkout has advanced independently since the audit. The restored batch is isolated at its audited base and has not been integrated into that newer checkout. The canonical checkpoint, complete source copies, patch, recovery report, local receipt and hashes are kept in the existing external audit workspace. + +## Local testing speed improvements — 9 October 2026 + +The same restored checkout now contains three focused performance improvements: + +- Adoption manifest builds index imports and reuse source analysis within each operation. Later builds observe changed source and configuration. +- Design-contract helpers privately reuse at most four ASTs with identical filenames, source text and parser settings. The text-role scanner skips sources and tokens that cannot match its unchanged rule, retaining CSS errors and exact finding locations. +- The existing policy-only Cloud test harness skips runtime bootstrap and runtime diagnostics. Ordinary setup retains its runtime and credential checks; policy refusals, atomic replacement and failure status remain intact. + +Six complete local suites passed: 60 adoption/visual-baseline tests, 73 design-helper/status tests, 35 unchanged Cloud tests and four new policy-harness tests. The complete design-system contract gate passed, including figures, token divergence, adoption and design-sync checks. Baseline/current comparison covered 2,351 tracked source files and 25,861 helper comparisons without differences. Separate fixtures cover cache keys, eviction, changed inputs and scanner errors. + +Controlled timings and their limitations are recorded in the external `testing-speed-20261009` evidence directory under the existing remediation workspace. Benchmarks use fresh processes and disable result reuse. The existing content-pinned test receipts were also checked separately; a reused result is labelled as reused proof. + +The adoption and contract utility hashes changed in this phase, so their earlier CodeScene reviews are historical. Fresh reviews of these versions are saved separately. The remaining maintainability programme and broader RAG deferral are unchanged. These local performance checks do not establish full-suite, hosted CI or release acceptance, and the prepared changes remain isolated from the newer primary checkout. diff --git a/docs/branch-review-records/ab93745778dff4eab8369ee651e62e0b1d488603e6f1b84a31a20751433515d6.record.md b/docs/branch-review-records/ab93745778dff4eab8369ee651e62e0b1d488603e6f1b84a31a20751433515d6.record.md new file mode 100644 index 0000000000..6bd273ea2f --- /dev/null +++ b/docs/branch-review-records/ab93745778dff4eab8369ee651e62e0b1d488603e6f1b84a31a20751433515d6.record.md @@ -0,0 +1 @@ +| 2026-10-10 | codescene-non-rag-current-20261010 | a9189921bcc182950e04ee41cb8ceca42f2adb2a | codescene-non-rag-current-20261010 independent governance integration dirty diff | No governance blocker found; independent source-only dirty-diff review; current staffing declaration preserved | Input identities and limitations: external integration-20261010/independent-governance-review.md under psychsift-codescene-remediation-20261009; twelve reviewed source hashes in report; no provider checks or behavioral-equivalence claim | diff --git a/scripts/check-codex-cloud-setup.mjs b/scripts/check-codex-cloud-setup.mjs index bc6775be65..6f3abfdfbf 100644 --- a/scripts/check-codex-cloud-setup.mjs +++ b/scripts/check-codex-cloud-setup.mjs @@ -68,12 +68,53 @@ function parseTomlScalar(value) { const trimmed = value.trim(); if (trimmed === "true") return true; if (trimmed === "false") return false; - if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + if (isQuotedTomlScalar(trimmed)) { return trimmed.slice(1, -1); } return trimmed; } +function isQuotedTomlScalar(value) { + const quote = value[0]; + return (quote === '"' || quote === "'") && value.endsWith(quote); +} + +function parseCodexMcpTableHeader(line, state) { + const nested = line.match(/^\[mcp_servers\.([A-Za-z0-9_-]+)\./); + if (nested) { + state.current = nested[1]; + state.servers[state.current] ??= {}; + state.nestedServers.add(state.current); + return true; + } + const table = line.match(/^\[mcp_servers\.([A-Za-z0-9_-]+)\]$/); + if (table) { + state.current = table[1]; + state.servers[state.current] ??= {}; + return true; + } + if (!line.startsWith("[")) return false; + state.current = null; + return true; +} + +function parseCodexMcpSetting(line, state) { + if (!state.current) { + const dotted = line.match(/^mcp_servers\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_.-]+)\s*=\s*(.+)$/); + if (dotted) { + state.servers[dotted[1]] ??= {}; + state.servers[dotted[1]][dotted[2]] = parseTomlScalar(dotted[3]); + } + return; + } + const keyValue = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/); + if (!keyValue) { + state.unparsedServers.add(state.current); + return; + } + state.servers[state.current][keyValue[1]] = parseTomlScalar(keyValue[2]); +} + /** * Parse `[mcp_servers.name]` tables from a Codex project config.toml. * Supports only the scalar keys this gate governs. @@ -85,45 +126,12 @@ export function parseCodexProjectMcpServers(text) { const servers = {}; const nestedServers = new Set(); const unparsedServers = new Set(); - let current = null; + const state = { servers, nestedServers, unparsedServers, current: null }; for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; - - const nested = line.match(/^\[mcp_servers\.([A-Za-z0-9_-]+)\./); - if (nested) { - current = nested[1]; - servers[current] ??= {}; - nestedServers.add(current); - continue; - } - - const table = line.match(/^\[mcp_servers\.([A-Za-z0-9_-]+)\]$/); - if (table) { - current = table[1]; - servers[current] ??= {}; - continue; - } - - if (line.startsWith("[")) { - current = null; - continue; - } - if (!current) { - const dotted = line.match(/^mcp_servers\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_.-]+)\s*=\s*(.+)$/); - if (dotted) { - servers[dotted[1]] ??= {}; - servers[dotted[1]][dotted[2]] = parseTomlScalar(dotted[3]); - } - continue; - } - - const kv = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/); - if (!kv) { - unparsedServers.add(current); - continue; - } - servers[current][kv[1]] = parseTomlScalar(kv[2]); + if (parseCodexMcpTableHeader(line, state)) continue; + parseCodexMcpSetting(line, state); } return { servers, nestedServers, unparsedServers }; } @@ -153,14 +161,7 @@ function validateSupabaseMcpUrl(urlString, label, errors) { } } -/** - * Project `.codex/config.toml` must register the approved Desktop/CLI MCP surface - * as disabled URL-only templates. Hosted tools require separately installed apps. - * @param {string} text - * @returns {string[]} - */ -export function validateCodexProjectMcpConfiguration(text) { - const errors = []; +function validateProjectModelDefaults(text, errors) { const rootConfiguration = text.split(/^\s*\[/m, 1)[0]; if ( /^\s*(?:(?:model|model_reasoning_effort)|(["'])(?:model|model_reasoning_effort)\1)\s*=/m.test(rootConfiguration) @@ -169,67 +170,75 @@ export function validateCodexProjectMcpConfiguration(text) { ".codex/config.toml must not set repository-wide model or model_reasoning_effort; leave them to the task or desktop picker.", ); } - const { servers, nestedServers, unparsedServers } = parseCodexProjectMcpServers(text); - const expectedNames = Object.keys(expectedCodexProjectMcpServers).sort(); +} + +function validateProjectMcpServerNames(servers, expectedNames, errors) { const actualNames = Object.keys(servers).sort(); - if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { - const unexpectedNames = actualNames.filter((name) => !expectedNames.includes(name)); - const missingNames = expectedNames.filter((name) => !actualNames.includes(name)); - const details = [ - unexpectedNames.length ? `unexpected: ${unexpectedNames.join(", ")}` : null, - missingNames.length ? `missing: ${missingNames.join(", ")}` : null, - ] - .filter(Boolean) - .join("; "); + if (JSON.stringify(actualNames) === JSON.stringify(expectedNames)) return; + const unexpectedNames = actualNames.filter((name) => !expectedNames.includes(name)); + const missingNames = expectedNames.filter((name) => !actualNames.includes(name)); + const details = [ + unexpectedNames.length ? `unexpected: ${unexpectedNames.join(", ")}` : null, + missingNames.length ? `missing: ${missingNames.join(", ")}` : null, + ] + .filter(Boolean) + .join("; "); + errors.push( + `.codex/config.toml must register exactly these MCP servers: ${expectedNames.join(", ")}.${details ? ` ${details}.` : ""} Use the canonical railway template name; stale railway_cloud registrations are host-local OAuth apps and must not be reintroduced here.`, + ); +} + +function validateProjectMcpServer(name, server, nestedServers, unparsedServers, errors) { + if (!server) return; + const label = `.codex/config.toml ${name}`; + const expected = expectedCodexProjectMcpServers[name]; + if (server.enabled !== false) { errors.push( - `.codex/config.toml must register exactly these MCP servers: ${expectedNames.join(", ")}.${details ? ` ${details}.` : ""} Use the canonical railway template name; stale railway_cloud registrations are host-local OAuth apps and must not be reintroduced here.`, + `${label} must set enabled = false (opt in via $CODEX_HOME/config.toml or a never-committed local edit; do not commit enabled = true).`, ); } - - for (const name of expectedNames) { - const server = servers[name]; - if (!server) continue; - const label = `.codex/config.toml ${name}`; - const expected = expectedCodexProjectMcpServers[name]; - - if (server.enabled !== false) { - errors.push( - `${label} must set enabled = false (opt in via $CODEX_HOME/config.toml or a never-committed local edit; do not commit enabled = true).`, - ); - } - if (server.default_tools_approval_mode !== expected.approvalMode) { - const reason = - expected.approvalMode === "writes" - ? "write-capable tools require explicit approval" - : "the production server is constrained read-only"; - errors.push(`${label} must set default_tools_approval_mode = "${expected.approvalMode}" because ${reason}.`); - } - for (const key of Object.keys(server)) { - const rootKey = key.split(".")[0]; - if (forbiddenCodexProjectMcpKeys.includes(rootKey)) { - errors.push(`${label} must not embed ${rootKey}; keep OAuth credentials in the host store.`); - } else if (!allowedCodexProjectMcpKeys.includes(key)) { - errors.push(`${label} must be URL-only; unsupported key ${key}.`); - } - } - if (nestedServers.has(name)) { - errors.push(`${label} must not declare nested tool override tables in the shared project config.`); - } - if (unparsedServers.has(name)) { - errors.push(`${label} contains unsupported or unparsed entries.`); - } - if (typeof server.url !== "string" || !server.url) { - errors.push(`${label} must declare a secret-free url.`); - continue; - } - - if (expected.kind === "supabase") { - validateSupabaseMcpUrl(server.url, label, errors); - } else if (server.url !== expected.url) { - errors.push(`${label} must use the pinned endpoint ${expected.url}.`); + if (server.default_tools_approval_mode !== expected.approvalMode) { + const reason = + expected.approvalMode === "writes" + ? "write-capable tools require explicit approval" + : "the production server is constrained read-only"; + errors.push(`${label} must set default_tools_approval_mode = "${expected.approvalMode}" because ${reason}.`); + } + for (const key of Object.keys(server)) { + const rootKey = key.split(".")[0]; + if (forbiddenCodexProjectMcpKeys.includes(rootKey)) { + errors.push(`${label} must not embed ${rootKey}; keep OAuth credentials in the host store.`); + } else if (!allowedCodexProjectMcpKeys.includes(key)) { + errors.push(`${label} must be URL-only; unsupported key ${key}.`); } } + if (nestedServers.has(name)) { + errors.push(`${label} must not declare nested tool override tables in the shared project config.`); + } + if (unparsedServers.has(name)) errors.push(`${label} contains unsupported or unparsed entries.`); + if (typeof server.url !== "string" || !server.url) { + errors.push(`${label} must declare a secret-free url.`); + return; + } + if (expected.kind === "supabase") validateSupabaseMcpUrl(server.url, label, errors); + else if (server.url !== expected.url) errors.push(`${label} must use the pinned endpoint ${expected.url}.`); +} +/** + * Project `.codex/config.toml` must register the approved Desktop/CLI MCP surface + * as disabled URL-only templates. Hosted tools require separately installed apps. + * @param {string} text + * @returns {string[]} + */ +export function validateCodexProjectMcpConfiguration(text) { + const errors = []; + validateProjectModelDefaults(text, errors); + const { servers, nestedServers, unparsedServers } = parseCodexProjectMcpServers(text); + const expectedNames = Object.keys(expectedCodexProjectMcpServers).sort(); + validateProjectMcpServerNames(servers, expectedNames, errors); + for (const name of expectedNames) { + validateProjectMcpServer(name, servers[name], nestedServers, unparsedServers, errors); + } return errors; } @@ -450,41 +459,43 @@ export function parseMcpServerMetadata(text) { if (!servers || Array.isArray(servers) || typeof servers !== "object") { throw new Error(".mcp.json must contain an mcpServers object."); } - return Object.entries(servers).map(([name, server]) => { - let endpoint = "none"; - let queryNames = []; - if (typeof server?.url === "string") { - const url = new URL(server.url); - endpoint = `${url.origin}${url.pathname}`; - queryNames = [...url.searchParams.keys()].sort(); - } - return { - name, - type: typeof server?.type === "string" ? server.type : typeof server?.command === "string" ? "stdio" : "invalid", - command: typeof server?.command === "string" ? server.command : "none", - endpoint, - queryNames, - environmentNames: - server?.env && !Array.isArray(server.env) && typeof server.env === "object" - ? Object.keys(server.env).sort() - : [], - }; - }); + return Object.entries(servers).map(([name, server]) => mcpServerMetadata(name, server)); } -export function validateMcpConfiguration(text) { - const errors = []; - let parsed; +function mcpServerMetadata(name, server) { + let endpoint = "none"; + let queryNames = []; + if (typeof server?.url === "string") { + const url = new URL(server.url); + endpoint = `${url.origin}${url.pathname}`; + queryNames = [...url.searchParams.keys()].sort(); + } + let type = "invalid"; + if (typeof server?.type === "string") type = server.type; + else if (typeof server?.command === "string") type = "stdio"; + const validEnvironment = server?.env && !Array.isArray(server.env) && typeof server.env === "object"; + return { + name, + type, + command: typeof server?.command === "string" ? server.command : "none", + endpoint, + queryNames, + environmentNames: validEnvironment ? Object.keys(server.env).sort() : [], + }; +} + +function parseMcpConfiguration(text) { try { - parsed = JSON.parse(text); + return { parsed: JSON.parse(text), error: null }; } catch (error) { - return [`.mcp.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`]; - } - const servers = parsed?.mcpServers; - if (!servers || Array.isArray(servers) || typeof servers !== "object") { - return [".mcp.json must contain an mcpServers object."]; + return { + parsed: null, + error: `.mcp.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + }; } +} +function validateMcpServerInventory(servers, errors) { const serverNames = Object.keys(servers).sort(); if (JSON.stringify(serverNames) !== JSON.stringify(["railway", "supabase"])) { errors.push("Desktop/CLI .mcp.json must contain only Railway and Supabase."); @@ -494,39 +505,33 @@ export function validateMcpConfiguration(text) { errors.push(`${name} MCP must use OAuth without embedded environment variables or headers.`); } } +} - const railway = servers.railway; +function validateRailwayMcpServer(railway, errors) { if (railway?.type !== "http" || railway?.url !== expectedMcpConfiguration.railwayUrl.replace(/\/$/, "")) { errors.push("Railway MCP must use Railway's official remote OAuth endpoint."); } +} - const supabase = servers.supabase; +function validateSupabaseMcpServer(supabase, errors) { if (supabase?.type !== "http" || typeof supabase?.url !== "string") { errors.push("Supabase MCP must use the hosted HTTP endpoint."); - return errors; + return; } - try { - const url = new URL(supabase.url); - if (`${url.origin}${url.pathname}` !== expectedMcpConfiguration.supabaseUrl) { - errors.push("Supabase MCP must use the official hosted endpoint."); - } - if (url.searchParams.get("project_ref") !== expectedMcpConfiguration.supabaseProjectRef) { - errors.push("Supabase MCP must be scoped to the expected project."); - } - if (url.searchParams.get("read_only") !== "true") { - errors.push("Supabase MCP must keep the production project read-only."); - } - const queryNames = [...url.searchParams.keys()].sort(); - if (JSON.stringify(queryNames) !== JSON.stringify(["features", "project_ref", "read_only"])) { - errors.push("Supabase MCP must not include additional query parameters."); - } - const features = (url.searchParams.get("features") ?? "").split(",").filter(Boolean).sort(); - if (JSON.stringify(features) !== JSON.stringify(expectedMcpConfiguration.supabaseFeatures)) { - errors.push("Supabase MCP must expose only the approved read-only feature groups."); - } - } catch { - errors.push("Supabase MCP URL must be valid."); + validateSupabaseMcpUrl(supabase.url, "Supabase MCP", errors); +} + +export function validateMcpConfiguration(text) { + const errors = []; + const { parsed, error } = parseMcpConfiguration(text); + if (error) return [error]; + const servers = parsed?.mcpServers; + if (!servers || Array.isArray(servers) || typeof servers !== "object") { + return [".mcp.json must contain an mcpServers object."]; } + validateMcpServerInventory(servers, errors); + validateRailwayMcpServer(servers.railway, errors); + validateSupabaseMcpServer(servers.supabase, errors); return errors; } @@ -753,27 +758,23 @@ export function pythonWorkerVersionLine(pythonCommand, run = spawnSync) { : "python.worker_versions=unavailable"; } -export function validateCloudNpmInstallContract(setupText) { - const errors = []; +function cloudSetupLogicalLines(setupText) { const setup = setupText.replace(/\\\r?\n[\t ]*/gu, " "); - const logicalLines = setup + return setup .split(/\r?\n/gu) .map((line, index) => ({ text: line.trim(), index })) .filter(({ text }) => text && !text.startsWith("#")); +} + +function cloudProjectInstallCommands(logicalLines) { const installCommands = logicalLines.filter(({ text }) => /\bnpm\s+(?:ci|install|i)(?:\s|$)/u.test(text)); - const projectInstallCommands = installCommands.filter(({ text }) => { + return installCommands.filter(({ text }) => { const tokens = text.split(/\s+/u); return !tokens.includes("--global") && !tokens.includes("-g"); }); +} - if (projectInstallCommands.length !== 1) { - errors.push( - `Cloud setup must contain exactly one project dependency install; found ${projectInstallCommands.length}.`, - ); - return errors; - } - - const command = projectInstallCommands[0]; +function validateCloudInstallPlacement(logicalLines, command, errors) { const nodeDependenciesStart = logicalLines.findIndex(({ text }) => text === 'setup_step="node-dependencies"'); const nextStep = logicalLines.findIndex( ({ text }, index) => index > nodeDependenciesStart && /^setup_step=/u.test(text), @@ -786,54 +787,71 @@ export function validateCloudNpmInstallContract(setupText) { ) { errors.push('Cloud project dependency install must run inside the "node-dependencies" setup step.'); } +} +function validateCloudInstallCommand(command, errors) { if (/[;&|`$()]/u.test(command.text)) { errors.push("Cloud project dependency install must be a direct, unchained command without expansion."); - return errors; + return; } - const tokens = command.text.split(/\s+/u); const expectedFlags = ["--include=dev", "--prefer-offline", "--no-audit", "--no-fund"]; const actualFlags = tokens.slice(2); - if ( - tokens[0] !== "npm" || - tokens[1] !== "ci" || - actualFlags.length !== expectedFlags.length || - expectedFlags.some((flag) => !actualFlags.includes(flag)) || - new Set(actualFlags).size !== expectedFlags.length - ) { + const commandIsNpmCi = tokens[0] === "npm" && tokens[1] === "ci"; + const flagsMatch = + actualFlags.length === expectedFlags.length && + expectedFlags.every((flag) => actualFlags.includes(flag)) && + new Set(actualFlags).size === expectedFlags.length; + if (!commandIsNpmCi || !flagsMatch) { errors.push( `Cloud project dependency install must be npm ci with exactly these flags: ${expectedFlags.join(" ")}.`, ); } +} +export function validateCloudNpmInstallContract(setupText) { + const errors = []; + const logicalLines = cloudSetupLogicalLines(setupText); + const projectInstallCommands = cloudProjectInstallCommands(logicalLines); + if (projectInstallCommands.length !== 1) { + errors.push( + `Cloud setup must contain exactly one project dependency install; found ${projectInstallCommands.length}.`, + ); + return errors; + } + const command = projectInstallCommands[0]; + validateCloudInstallPlacement(logicalLines, command, errors); + validateCloudInstallCommand(command, errors); return errors; } -export function validateCodexCloudSetup() { - const errors = []; - const packageJson = JSON.parse(read("package.json")); - const nodeVersion = read(".node-version").trim(); - const nvmVersion = read(".nvmrc").trim(); - const setup = read("scripts/setup-codex-cloud.sh"); - const pythonSelector = read("scripts/select-codex-cloud-python.sh"); - const maintenance = read("scripts/maintain-codex-cloud.sh"); - const commandShims = read("scripts/install-codex-cloud-command-shims.sh"); - const checkoutBaseRefresh = read("scripts/refresh-codex-cloud-base.sh"); - const rawEnvironmentProbe = read("scripts/check-codex-cloud-raw-env.sh"); - const githubShellAccess = read("scripts/check-github-shell-access.mjs"); - const patDelete = read("scripts/delete-codex-cloud-branch-with-pat.sh"); - const guide = read("docs/codex-cloud.md"); - const agents = read("AGENTS.md"); - const modularCloudAgents = read("docs/agents/codex-cloud-environment.md"); - const envExample = read(".env.example"); - const gitignore = read(".gitignore"); - const mcp = read(".mcp.json"); - const codexProjectConfig = read(".codex/config.toml"); - - // engines.node declares a minor-level floor (">=24.15.0 <25") rather than a - // bare major, because dev dependencies carry a floor a "24.x" range cannot - // express. Validate the shape and that its major still tracks .node-version. +function readCodexCloudSetupSources() { + return { + packageJson: JSON.parse(read("package.json")), + nodeVersion: read(".node-version").trim(), + nvmVersion: read(".nvmrc").trim(), + setup: read("scripts/setup-codex-cloud.sh"), + pythonSelector: read("scripts/select-codex-cloud-python.sh"), + maintenance: read("scripts/maintain-codex-cloud.sh"), + commandShims: read("scripts/install-codex-cloud-command-shims.sh"), + checkoutBaseRefresh: read("scripts/refresh-codex-cloud-base.sh"), + rawEnvironmentProbe: read("scripts/check-codex-cloud-raw-env.sh"), + githubShellAccess: read("scripts/check-github-shell-access.mjs"), + patDelete: read("scripts/delete-codex-cloud-branch-with-pat.sh"), + guide: read("docs/codex-cloud.md"), + agents: read("AGENTS.md"), + modularCloudAgents: read("docs/agents/codex-cloud-environment.md"), + envExample: read(".env.example"), + gitignore: read(".gitignore"), + mcp: read(".mcp.json"), + codexProjectConfig: read(".codex/config.toml"), + }; +} + +function validateCloudToolchainContract(sources, errors) { + const { packageJson, nodeVersion, nvmVersion, gitignore } = sources; + // engines.node declares a minor-level floor rather than a bare major because + // dev dependencies carry a floor that a "24.x" range cannot express. const engineRange = String(packageJson.engines?.node ?? ""); const engineFloor = engineRange.match(/>=\s*(\d+)\.(\d+)\.(\d+)/); const engineCeiling = engineRange.match(/<\s*(\d+)/); @@ -850,7 +868,10 @@ export function validateCodexCloudSetup() { } if (nvmVersion !== nodeVersion) errors.push(".nvmrc and .node-version must match."); requireMatch(errors, gitignore, /^\/error\.log$/m, "Codex Cloud diagnostic error.log must stay ignored."); +} +function validateCloudSetupLifecycleContract(sources, errors) { + const { setup, pythonSelector, maintenance } = sources; for (const [pattern, message] of [ [/expected_node_range=/, "Cloud setup must read the complete Node engine range."], [/node_version_supported/, "Cloud setup must validate the complete Node engine range."], @@ -909,18 +930,9 @@ export function validateCodexCloudSetup() { for (const name of providerCredentialVariables) { if (!setup.includes(name)) errors.push(`Cloud setup must handle provider environment variable ${name}.`); } - // Reject any executable (non-comment) reference that would write MCP tables. - // Strip full-line comments and whitespace-prefixed trailing comments so an - // inline note cannot false-trip the guard. Text-level only: a `#` inside a - // quoted shell string is also stripped, and split/concatenated table names - // would not match. The generated-config behavioural test (fresh temp $HOME - // asserting no `[mcp_servers.` after connected setup) is the stronger proof. const setupWithoutComments = setup .split("\n") - .map((line) => { - if (/^\s*#/.test(line)) return ""; - return line.replace(/\s+#.*$/, ""); - }) + .map((line) => (/^\s*#/.test(line) ? "" : line.replace(/\s+#.*$/, ""))) .join("\n"); if (/\bmcp_servers\b/.test(setupWithoutComments)) { errors.push("Cloud setup must not generate MCP registrations; hosted apps are external to the repository."); @@ -933,6 +945,9 @@ export function validateCodexCloudSetup() { "Cloud lifecycle scripts must not persist setup-only GitHub credentials for the agent phase; use the native connector.", ); } +} + +function validateGithubShellAccessContract(githubShellAccess, errors) { for (const [pattern, message] of [ [/expectedIdentity = "BigSimmo"/, "GitHub shell acceptance must pin the intended identity."], [/repository = "BigSimmo\/PsychSift"/, "GitHub shell acceptance must pin the intended repository."], @@ -966,6 +981,10 @@ export function validateCodexCloudSetup() { errors.push(`GitHub shell acceptance must not read credentials or permit history rewriting (${forbidden}).`); } } +} + +function validateProviderScrubAndInventory(sources, errors) { + const { setup, envExample } = sources; const providerScrubIndex = setup.indexOf("unset OPENAI_API_KEY"); const accessProfileBranchIndex = setup.indexOf('if [ "\\$CODEX_CLOUD_ACCESS_PROFILE" = "connected" ]'); if (providerScrubIndex < 0 || accessProfileBranchIndex < 0 || providerScrubIndex > accessProfileBranchIndex) { @@ -979,51 +998,32 @@ export function validateCodexCloudSetup() { errors.push(`Cloud credential inventory must include .env.example variable ${name}.`); } } - requireMatch( - errors, - maintenance, - /exec bash scripts\/setup-codex-cloud\.sh/, - "Maintenance must repair the full toolchain.", - ); - requireMatch( - errors, - maintenance, - /ensure-codex-cloud-git-remote\.mjs/, - "Maintenance must preserve the safe origin remote.", - ); - requireMatch( - errors, - maintenance, - /refresh-codex-cloud-base\.sh/, - "Maintenance must refresh and pin the task checkout base.", - ); - requireMatch( - errors, - commandShims, - /nvm version/, - "Cloud command shims must resolve the selected Node version without following their own wrappers.", - ); - requireMatch( - errors, - commandShims, - /clean_path=.*\.local.*bin/, - "Cloud command shims must remove their directory before running child npm scripts.", - ); - requireMatch( - errors, - commandShims, - /mkdir -p "\$HOME\/\.local\/bin"/, - "Cloud command shims must create their destination directory.", - ); - requireMatch( - errors, - commandShims, - /\.clinical-kb-codex-cloud\.sh/, - "Cloud command shims must load the generated profile.", - ); +} + +function validateMaintenanceAndCommandShims(sources, errors) { + const { maintenance, commandShims } = sources; + for (const [pattern, message] of [ + [/exec bash scripts\/setup-codex-cloud\.sh/, "Maintenance must repair the full toolchain."], + [/ensure-codex-cloud-git-remote\.mjs/, "Maintenance must preserve the safe origin remote."], + [/refresh-codex-cloud-base\.sh/, "Maintenance must refresh and pin the task checkout base."], + ]) { + requireMatch(errors, maintenance, pattern, message); + } + for (const [pattern, message] of [ + [/nvm version/, "Cloud command shims must resolve the selected Node version without following their own wrappers."], + [/clean_path=.*\.local.*bin/, "Cloud command shims must remove their directory before running child npm scripts."], + [/mkdir -p "\$HOME\/\.local\/bin"/, "Cloud command shims must create their destination directory."], + [/\.clinical-kb-codex-cloud\.sh/, "Cloud command shims must load the generated profile."], + ]) { + requireMatch(errors, commandShims, pattern, message); + } if (!commandShims.includes('exec "$node_bin/$command_name" "\\$@"')) { errors.push("Cloud command shims must execute absolute Node commands."); } +} + +function validateCheckoutAndRawEnvironmentContracts(sources, errors) { + const { checkoutBaseRefresh, patDelete, rawEnvironmentProbe } = sources; requireMatch( errors, checkoutBaseRefresh, @@ -1072,44 +1072,31 @@ export function validateCodexCloudSetup() { errors.push(`Raw Cloud environment probe must cover provider environment variable ${name}.`); } } - requireMatch( - errors, - patDelete, - /\[\[ "\$branch" != -\* \]\]/, - "PAT deletion helper must reject option-like branch names.", - ); - requireMatch(errors, patDelete, /git check-ref-format --branch/, "PAT deletion helper must validate branch names."); - requireMatch( - errors, - patDelete, - /git remote get-url --push --all origin/, - "PAT deletion helper must validate effective push URLs.", - ); - requireMatch(errors, patDelete, /GIT_ASKPASS/, "PAT deletion helper must use a temporary askpass program."); - requireMatch( - errors, - patDelete, - /core\.hooksPath=\/dev\/null/, - "PAT deletion helper must disable Git hooks before the token-bearing push.", - ); - requireMatch( - errors, - patDelete, - /https:\/\/github\.com\/BigSimmo\/PsychSift\.git/, - "PAT deletion helper must require the credential-free origin.", - ); - requireMatch(errors, guide, /bash scripts\/setup-codex-cloud\.sh/, "The guide must provide the setup command."); - requireMatch( - errors, - guide, - /install-codex-cloud-command-shims\.sh/, - "The guide must document the command-shim workaround.", - ); - requireMatch(errors, guide, /CODEX_CLOUD_ACCESS_PROFILE=connected/, "The guide must document connected access."); - requireMatch(errors, guide, /CODEX_CLOUD_GITHUB_PAT/, "The guide must document the narrowly scoped PAT exception."); - requireMatch(errors, guide, /GitHub connector/, "The guide must document GitHub connector access."); - requireMatch(errors, guide, /Personal Pro/, "The guide must identify the active Personal Pro workspace."); - requireMatch(errors, guide, /split control plane/, "The guide must document the Personal Pro provider workaround."); + for (const [pattern, message] of [ + [/\[\[ "\$branch" != -\* \]\]/, "PAT deletion helper must reject option-like branch names."], + [/git check-ref-format --branch/, "PAT deletion helper must validate branch names."], + [/git remote get-url --push --all origin/, "PAT deletion helper must validate effective push URLs."], + [/GIT_ASKPASS/, "PAT deletion helper must use a temporary askpass program."], + [/core\.hooksPath=\/dev\/null/, "PAT deletion helper must disable Git hooks before the token-bearing push."], + [/https:\/\/github\.com\/BigSimmo\/PsychSift\.git/, "PAT deletion helper must require the credential-free origin."], + ]) { + requireMatch(errors, patDelete, pattern, message); + } +} + +function validateGuideAndMcpContracts(sources, errors) { + const { guide, mcp, codexProjectConfig } = sources; + for (const [pattern, message] of [ + [/bash scripts\/setup-codex-cloud\.sh/, "The guide must provide the setup command."], + [/install-codex-cloud-command-shims\.sh/, "The guide must document the command-shim workaround."], + [/CODEX_CLOUD_ACCESS_PROFILE=connected/, "The guide must document connected access."], + [/CODEX_CLOUD_GITHUB_PAT/, "The guide must document the narrowly scoped PAT exception."], + [/GitHub connector/, "The guide must document GitHub connector access."], + [/Personal Pro/, "The guide must identify the active Personal Pro workspace."], + [/split control plane/, "The guide must document the Personal Pro provider workaround."], + ]) { + requireMatch(errors, guide, pattern, message); + } try { parseMcpServerMetadata(mcp); } catch (error) { @@ -1117,7 +1104,10 @@ export function validateCodexCloudSetup() { } errors.push(...validateMcpConfiguration(mcp)); errors.push(...validateCodexProjectMcpConfiguration(codexProjectConfig)); +} +function validateForbiddenCloudBootstrapUsage(sources, errors) { + const { setup, maintenance } = sources; for (const command of [ "check:supabase-project", "test:live", @@ -1130,7 +1120,6 @@ export function validateCodexCloudSetup() { errors.push(`Cloud bootstrap scripts must not invoke provider-capable command ${command}.`); } } - for (const pattern of [ /sjrfecxgysukkwxsowpy/i, /5deaad0b-675a-4c13-978e-5ca2b5b877f9/i, @@ -1141,19 +1130,37 @@ export function validateCodexCloudSetup() { errors.push(`Cloud bootstrap scripts contain a live provider identifier matching ${pattern}.`); } } +} - const cloudHeadingCount = (modularCloudAgents.match(/^## Codex Cloud environment$/gm) ?? []).length; +function validateCloudAgentHeadings(sources, errors) { + const cloudHeadingCount = (sources.modularCloudAgents.match(/^## Codex Cloud environment$/gm) ?? []).length; if (cloudHeadingCount !== 1) { errors.push( `docs/agents/codex-cloud-environment.md must contain exactly one Codex Cloud environment section; found ${cloudHeadingCount}.`, ); } - const rootHeadingCount = (agents.match(/^## Codex Cloud environment$/gm) ?? []).length; + const rootHeadingCount = (sources.agents.match(/^## Codex Cloud environment$/gm) ?? []).length; if (rootHeadingCount !== 1) { errors.push( `AGENTS.md must contain exactly one Codex Cloud environment pointer section; found ${rootHeadingCount}.`, ); } +} + +export function validateCodexCloudSetup() { + const errors = []; + const sources = readCodexCloudSetupSources(); + validateCloudToolchainContract(sources, errors); + + validateCloudSetupLifecycleContract(sources, errors); + validateGithubShellAccessContract(sources.githubShellAccess, errors); + validateProviderScrubAndInventory(sources, errors); + validateMaintenanceAndCommandShims(sources, errors); + validateCheckoutAndRawEnvironmentContracts(sources, errors); + validateGuideAndMcpContracts(sources, errors); + + validateForbiddenCloudBootstrapUsage(sources, errors); + validateCloudAgentHeadings(sources, errors); return errors; } @@ -1177,12 +1184,7 @@ function repositoryCommand(command, args) { return `${command} ${args.join(" ")} failed: ${output || `exit ${result.status}`}`; } -export async function validateCodexCloudRuntime(env = process.env) { - const errors = validateCodexCloudEnvironment(env); - - // Hosted Railway access is the OAuth ChatGPT/Codex app, not CLI token auth. - // Do not reintroduce `railway --version` or RAILWAY_API_TOKEN-vs-RAILWAY_TOKEN - // substitution checks here unless a future workflow restores CLI token auth. +function appendRuntimeCommandErrors(errors) { for (const error of [ commandVersion("deno", ["--version"], /^deno 2\./m), commandVersion("tesseract", ["--version"], /^tesseract \d+\./m), @@ -1190,26 +1192,34 @@ export async function validateCodexCloudRuntime(env = process.env) { ]) { if (error) errors.push(error); } +} + +function appendPythonWorkerErrors(env, errors) { const pythonError = pythonWorkerImportError(env.CODEX_CLOUD_OCR_PYTHON); if (pythonError) errors.push(pythonError); else errors.push(...pythonWorkerDependencyErrors(env.CODEX_CLOUD_OCR_PYTHON)); +} +function appendRepositoryRuntimeErrors(errors) { for (const error of [ repositoryCommand(process.execPath, ["scripts/run-tsx.mjs", "scripts/check-runtime.ts"]), repositoryCommand(process.execPath, ["scripts/check-installed-lock-parity.mjs"]), ]) { if (error) errors.push(error); } +} - if (env.CODEX_CLOUD_SKIP_BROWSER_INSTALL !== "1") { - try { - const { chromium, firefox, webkit } = await import("playwright"); - errors.push(...(await playwrightBrowserErrors({ chromium, firefox, webkit }))); - } catch (error) { - errors.push(`Playwright browser validation failed: ${error.message}`); - } +async function appendBrowserRuntimeErrors(env, errors) { + if (env.CODEX_CLOUD_SKIP_BROWSER_INSTALL === "1") return; + try { + const { chromium, firefox, webkit } = await import("playwright"); + errors.push(...(await playwrightBrowserErrors({ chromium, firefox, webkit }))); + } catch (error) { + errors.push(`Playwright browser validation failed: ${error.message}`); } +} +function appendCheckoutRuntimeErrors(env, errors) { const obsoleteProxyNames = obsoleteNpmProxyVariables(env); if (obsoleteProxyNames.length > 0) { errors.push(`Obsolete npm proxy variable names are set: ${obsoleteProxyNames.join(", ")}.`); @@ -1226,10 +1236,27 @@ export async function validateCodexCloudRuntime(env = process.env) { if (checkout.expectedBaseAncestor === "false") { errors.push("CODEX_CLOUD_EXPECTED_BASE_SHA is not an ancestor of the current HEAD."); } +} + +function appendOriginRuntimeErrors(errors) { const origin = inspectOriginRemote(repoRoot); if (!origin.configured) errors.push("origin is unavailable in the Cloud checkout."); else if (origin.credentialsEmbedded) errors.push("origin contains embedded credentials."); else if (!origin.repositoryMatch) errors.push(`origin must identify ${CODEX_CLOUD_REPOSITORY}.`); +} + +export async function validateCodexCloudRuntime(env = process.env) { + const errors = validateCodexCloudEnvironment(env); + + // Hosted Railway access is the OAuth ChatGPT/Codex app, not CLI token auth. + // Do not reintroduce `railway --version` or RAILWAY_API_TOKEN-vs-RAILWAY_TOKEN + // substitution checks here unless a future workflow restores CLI token auth. + appendRuntimeCommandErrors(errors); + appendPythonWorkerErrors(env, errors); + appendRepositoryRuntimeErrors(errors); + await appendBrowserRuntimeErrors(env, errors); + appendCheckoutRuntimeErrors(env, errors); + appendOriginRuntimeErrors(errors); return errors; } diff --git a/scripts/check-design-system-contract.mjs b/scripts/check-design-system-contract.mjs index a91ab91093..18c25fd3ff 100644 --- a/scripts/check-design-system-contract.mjs +++ b/scripts/check-design-system-contract.mjs @@ -68,19 +68,19 @@ function withoutComments(text) { } function findInteractiveTapLiterals(file) { - const sourceText = fs.readFileSync(file.absolutePath, "utf8"); + const sourceText = textAt(file.relativePath); return findInteractiveTapLiteralsInSource(file.relativePath, sourceText); } function findInteractiveTapFloorDeclarations(file) { - const sourceText = fs.readFileSync(file.absolutePath, "utf8"); + const sourceText = textAt(file.relativePath); return findInteractiveTapFloorDeclarationsInSource(file.relativePath, sourceText); } function findTherapyButtonsWithoutBaseClass(file) { if (!file.relativePath.startsWith("src/components/therapy-compass/") || !file.relativePath.endsWith(".tsx")) return []; - const sourceText = fs.readFileSync(file.absolutePath, "utf8"); + const sourceText = textAt(file.relativePath); const source = ts.createSourceFile(file.relativePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); const findings = []; diff --git a/scripts/ci-change-scope.mjs b/scripts/ci-change-scope.mjs index bd050f35e5..63182d7fa9 100644 --- a/scripts/ci-change-scope.mjs +++ b/scripts/ci-change-scope.mjs @@ -78,72 +78,97 @@ function isWithinPath(rootPath, candidatePath) { ); } -function validateSiteContentChangeOwners(value, label = "site-content-owner-manifest-malformed") { - if ( - !value || - typeof value !== "object" || - Array.isArray(value) || - value.version !== "site-content-change-owners-v1" - ) { - throw new Error(label); +const allowedSiteContentOwnerRoots = new Set(["data", "public", "src"]); + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function validateSiteContentProducerOwners(mode, owners, label) { + if (!mode || !Array.isArray(owners) || owners.length === 0) throw new Error(label); + if (owners.some((owner) => typeof owner !== "string")) throw new Error(label); + if (owners.join("|") !== [...new Set(owners)].sort().join("|")) throw new Error(label); +} + +function assertCanonicalSiteContentOwnerSpelling(owner) { + if (owner.length === 0) throw new Error("site-content-owner-manifest-empty-owner"); + const normalizedOwner = normalizePath(owner); + const canonicalOwner = posixPath.normalize(normalizedOwner); + const hasNoncanonicalSegment = + canonicalOwner === "" || + canonicalOwner === "." || + normalizedOwner.includes("//") || + normalizedOwner.split("/").includes("."); + if (owner !== normalizedOwner || canonicalOwner !== normalizedOwner || hasNoncanonicalSegment) { + throw new Error("site-content-owner-manifest-noncanonical-owner"); + } +} + +function siteContentOwnerRepositoryRoot(owner, label) { + if (owner.startsWith("/") || owner.includes("..") || owner.includes("\\")) throw new Error(label); + if (/[?\[\]{}]/.test(owner) || (/\*/.test(owner) && !owner.endsWith("/**"))) { + throw new Error("site-content-owner-manifest-malformed-glob"); + } + const repositoryRoot = owner.split("/", 1)[0]; + if (allowedSiteContentOwnerRoots.has(repositoryRoot) && owner === `${repositoryRoot}/**`) { + throw new Error("site-content-owner-manifest-broad-path"); + } + if (!allowedSiteContentOwnerRoots.has(repositoryRoot)) { + throw new Error("site-content-owner-manifest-unknown-root"); + } + return repositoryRoot; +} + +function canonicalSiteContentOwner(owner, label) { + assertCanonicalSiteContentOwnerSpelling(owner); + const repositoryRoot = siteContentOwnerRepositoryRoot(owner, label); + return { + ownerPath: owner.endsWith("/**") ? owner.slice(0, -3) : owner, + repositoryRoot, + }; +} + +function assertSiteContentOwnerType(owner, ownerPath) { + if (!existsSync(ownerPath)) throw new Error("site-content-owner-manifest-missing-owner"); + const ownerStat = lstatSync(ownerPath); + if (ownerStat.isSymbolicLink()) throw new Error("site-content-owner-manifest-missing-owner"); + const hasWrongType = owner.endsWith("/**") ? !ownerStat.isDirectory() : !ownerStat.isFile(); + if (hasWrongType) throw new Error("site-content-owner-manifest-missing-owner"); +} + +function assertSiteContentOwnerAncestorsAreReal(ownerPath) { + const ownerPathParts = ownerPath.split("/"); + for (let partCount = 1; partCount < ownerPathParts.length; partCount += 1) { + const ancestorPath = ownerPathParts.slice(0, partCount).join("/"); + if (lstatSync(ancestorPath).isSymbolicLink()) { + throw new Error("site-content-owner-manifest-escaping-owner"); + } + } +} + +function assertSiteContentOwnerWithinRoots(repositoryRealPath, repositoryRoot, ownerPath) { + const allowedRootRealPath = realpathSync(repositoryRoot); + const ownerRealPath = realpathSync(ownerPath); + if (!isWithinPath(repositoryRealPath, ownerRealPath) || !isWithinPath(allowedRootRealPath, ownerRealPath)) { + throw new Error("site-content-owner-manifest-escaping-owner"); } +} + +function validateSiteContentOwner(repositoryRealPath, owner, label) { + const { ownerPath, repositoryRoot } = canonicalSiteContentOwner(owner, label); + assertSiteContentOwnerType(owner, ownerPath); + assertSiteContentOwnerAncestorsAreReal(ownerPath); + assertSiteContentOwnerWithinRoots(repositoryRealPath, repositoryRoot, ownerPath); +} + +function validateSiteContentChangeOwners(value, label = "site-content-owner-manifest-malformed") { + if (!isPlainObject(value) || value.version !== "site-content-change-owners-v1") throw new Error(label); const repositoryRealPath = realpathSync("."); const producers = value.producers; - if (!producers || typeof producers !== "object" || Array.isArray(producers) || Object.keys(producers).length === 0) { - throw new Error(label); - } + if (!isPlainObject(producers) || Object.keys(producers).length === 0) throw new Error(label); for (const [mode, owners] of Object.entries(producers)) { - if (!mode || !Array.isArray(owners) || owners.length === 0 || owners.some((owner) => typeof owner !== "string")) { - throw new Error(label); - } - if (owners.join("|") !== [...new Set(owners)].sort().join("|")) throw new Error(label); - for (const owner of owners) { - if (owner.length === 0) throw new Error("site-content-owner-manifest-empty-owner"); - const normalizedOwner = normalizePath(owner); - const canonicalOwner = posixPath.normalize(normalizedOwner); - if ( - owner !== normalizedOwner || - canonicalOwner !== normalizedOwner || - canonicalOwner === "" || - canonicalOwner === "." || - normalizedOwner.includes("//") || - normalizedOwner.split("/").includes(".") - ) { - throw new Error("site-content-owner-manifest-noncanonical-owner"); - } - if (owner.startsWith("/") || owner.includes("..") || owner.includes("\\")) { - throw new Error(label); - } - if (/[?\[\]{}]/.test(owner) || (/\*/.test(owner) && !owner.endsWith("/**"))) { - throw new Error("site-content-owner-manifest-malformed-glob"); - } - const repositoryRoot = owner.split("/", 1)[0]; - const allowedRepositoryRoots = new Set(["data", "public", "src"]); - if (allowedRepositoryRoots.has(repositoryRoot) && owner === `${repositoryRoot}/**`) { - throw new Error("site-content-owner-manifest-broad-path"); - } - if (!allowedRepositoryRoots.has(repositoryRoot)) { - throw new Error("site-content-owner-manifest-unknown-root"); - } - const ownerPath = owner.endsWith("/**") ? owner.slice(0, -3) : owner; - if (!existsSync(ownerPath)) throw new Error("site-content-owner-manifest-missing-owner"); - const ownerStat = lstatSync(ownerPath); - if (ownerStat.isSymbolicLink() || (owner.endsWith("/**") ? !ownerStat.isDirectory() : !ownerStat.isFile())) { - throw new Error("site-content-owner-manifest-missing-owner"); - } - const ownerPathParts = ownerPath.split("/"); - for (let partCount = 1; partCount < ownerPathParts.length; partCount += 1) { - const ancestorPath = ownerPathParts.slice(0, partCount).join("/"); - if (lstatSync(ancestorPath).isSymbolicLink()) { - throw new Error("site-content-owner-manifest-escaping-owner"); - } - } - const allowedRootRealPath = realpathSync(repositoryRoot); - const ownerRealPath = realpathSync(ownerPath); - if (!isWithinPath(repositoryRealPath, ownerRealPath) || !isWithinPath(allowedRootRealPath, ownerRealPath)) { - throw new Error("site-content-owner-manifest-escaping-owner"); - } - } + validateSiteContentProducerOwners(mode, owners, label); + for (const owner of owners) validateSiteContentOwner(repositoryRealPath, owner, label); } return producers; } @@ -597,8 +622,7 @@ function isRecognisedLightPath(filePath) { // drive both the empty and non-empty cases without touching the real file. const readFlakeLedger = () => readFileSync("tests/flake-ledger.json", "utf8"); -function classify(files, { readLedger = readFlakeLedger } = {}) { - const normalized = [...new Set(files.map(normalizePath).filter(Boolean))].sort(); +function directChangeScopes(normalized, readLedger) { const docsChanged = normalized.some((file) => pathMatches(file, docPatterns)); const sourceChanged = normalized.some((file) => pathMatches(file, [...sourcePatterns, ...staticConfigPatterns])); const uiChanged = normalized.some((file) => isUiChangedPath(file)); @@ -616,6 +640,25 @@ function classify(files, { readLedger = readFlakeLedger } = {}) { const siteContentChanged = normalized.some((file) => pathMatches(file, siteContentPatterns)) || fullRunSentinelFiles.some((sentinel) => normalized.includes(sentinel)); + return { + advisoryUiChanged, + buildChanged, + containerChanged, + dbChanged, + docsChanged, + ingestionSastChanged, + lockfileChanged, + perfChanged, + prPolicyBodyChanged, + ragEvalChanged, + siteContentChanged, + sourceChanged, + uiChanged, + workflowChanged, + }; +} + +function isStaticHeavyChange(normalized, direct) { // Only two categories are allowed to take the lightweight path: recognised // documentation and recognised non-executable workflow/policy surfaces. // Unknown non-doc files fail closed to the heavy plan. Executable files that @@ -623,40 +666,62 @@ function classify(files, { readLedger = readFlakeLedger } = {}) { // or a workflow helper under `scripts/`) remain heavy even when the directory // is otherwise treated as a light policy surface. const hasUnknownNonLightPath = normalized.some((file) => !isRecognisedLightPath(file)); - const staticHeavyChanged = - sourceChanged || buildChanged || containerChanged || dbChanged || lockfileChanged || hasUnknownNonLightPath; + return ( + direct.sourceChanged || + direct.buildChanged || + direct.containerChanged || + direct.dbChanged || + direct.lockfileChanged || + hasUnknownNonLightPath + ); +} + +function isDocsOnlyChange(normalized, direct) { + return ( + normalized.length > 0 && + normalized.every((file) => pathMatches(file, docPatterns)) && + !normalized.includes(generatedMedicationLexiconReport) && + !direct.sourceChanged && + !direct.workflowChanged + ); +} + +function derivedChangeScopes(normalized, direct) { + const staticHeavyChanged = isStaticHeavyChange(normalized, direct); // Pure workflow YAML/policy changes use focused workflow-contract tests in // static-pr. Product, test, build, database, dependency and unknown changes // retain the complete coverage lane. const coverageChanged = staticHeavyChanged; - const docsOnly = - normalized.length > 0 && - normalized.every((file) => pathMatches(file, docPatterns)) && - !normalized.includes(generatedMedicationLexiconReport) && - !sourceChanged && - !workflowChanged; - const workflowOnly = workflowChanged && !staticHeavyChanged; + const docsOnly = isDocsOnlyChange(normalized, direct); + const workflowOnly = direct.workflowChanged && !staticHeavyChanged; + return { coverageChanged, docsOnly, staticHeavyChanged, workflowOnly }; +} + +function classify(files, { readLedger = readFlakeLedger } = {}) { + const normalized = [...new Set(files.map(normalizePath).filter(Boolean))].sort(); + const direct = directChangeScopes(normalized, readLedger); + const derived = derivedChangeScopes(normalized, direct); return { files: normalized, - docs_only: docsOnly, - docs_changed: docsChanged, - source_changed: sourceChanged, - static_heavy_changed: staticHeavyChanged, - coverage_changed: coverageChanged, - ingestion_sast_changed: ingestionSastChanged, - ui_changed: uiChanged, - perf_changed: perfChanged, - advisory_ui_changed: advisoryUiChanged, - db_changed: dbChanged, - container_changed: containerChanged, - rag_eval_changed: ragEvalChanged, - workflow_changed: workflowChanged, - workflow_only: workflowOnly, - build_changed: buildChanged, - lockfile_changed: lockfileChanged, - pr_policy_body_changed: prPolicyBodyChanged, - site_content_changed: siteContentChanged, + docs_only: derived.docsOnly, + docs_changed: direct.docsChanged, + source_changed: direct.sourceChanged, + static_heavy_changed: derived.staticHeavyChanged, + coverage_changed: derived.coverageChanged, + ingestion_sast_changed: direct.ingestionSastChanged, + ui_changed: direct.uiChanged, + perf_changed: direct.perfChanged, + advisory_ui_changed: direct.advisoryUiChanged, + db_changed: direct.dbChanged, + container_changed: direct.containerChanged, + rag_eval_changed: direct.ragEvalChanged, + workflow_changed: direct.workflowChanged, + workflow_only: derived.workflowOnly, + build_changed: direct.buildChanged, + lockfile_changed: direct.lockfileChanged, + pr_policy_body_changed: direct.prPolicyBodyChanged, + site_content_changed: direct.siteContentChanged, }; } @@ -869,10 +934,9 @@ function assertMockupSpecParity() { * perfInitialLoadApiPatterns. A new query-bearing route usually means more handlers * need the same carve-out. Fails CLOSED on an unreadable budget. */ -function assertBudgetRoutesAreQueryFree() { - let budget; +function readLighthouseBudget() { try { - budget = JSON.parse(readFileSync("lighthouse-budget.json", "utf8")); + return JSON.parse(readFileSync("lighthouse-budget.json", "utf8")); } catch (error) { throw new Error( `budget-routes-query-free: could not read lighthouse-budget.json (${ @@ -880,14 +944,20 @@ function assertBudgetRoutesAreQueryFree() { }). If that file moved, update this guard — do not delete it.`, ); } +} +function lighthouseBudgetRoutes(budget) { const routes = Array.isArray(budget?.routes) ? budget.routes : null; if (!routes || routes.length === 0) { throw new Error( "budget-routes-query-free: lighthouse-budget.json lists no routes; this guard has nothing to check.", ); } + return routes; +} +function assertBudgetRoutesAreQueryFree() { + const routes = lighthouseBudgetRoutes(readLighthouseBudget()); const withQuery = routes.filter((route) => typeof route === "string" && route.includes("?")); if (withQuery.length > 0) { throw new Error( @@ -898,7 +968,7 @@ function assertBudgetRoutesAreQueryFree() { console.log(`Budget routes query-free: ${routes.length} routes carry no query string.`); } -function selfTest() { +function assertBrowserAndAdvisoryScopes() { for (const file of [ "tests/adaptive-answer-ui.spec.ts", "tests/api-csrf-proxy.spec.ts", @@ -1007,7 +1077,9 @@ function selfTest() { assertScope("runtime-config-keeps-coverage", ["lighthouse-budget.json"], { coverage_changed: true, }); +} +function assertCoreStaticAndUiScopes() { assertScope("unstaged-status", parseStatusPorcelain(" M scripts/ci-change-scope.mjs\0"), { source_changed: true, workflow_changed: true, @@ -1098,7 +1170,9 @@ function selfTest() { ui_changed: true, }, ); +} +function assertPerformanceScopeExclusions() { // ---- perf_changed: the narrow scope the Lighthouse budget job keys off. ---- // // Both directions are load-bearing. A false negative means a render regression @@ -1193,7 +1267,9 @@ function selfTest() { assertScope("perf-on-for-proxy-owned-developer-headers", ["src/lib/developer-area/headers.ts"], { perf_changed: true, }); +} +function assertPerformanceScopeInclusions() { assertScope("perf-on-for-route-page", ["src/app/(search-app)/dsm/page.tsx"], { ui_changed: true, perf_changed: true, @@ -1251,6 +1327,9 @@ function selfTest() { // scope, so a future refactor over-triggers by one job rather than silently // dropping a render surface. assertScope("perf-on-for-unrecognised-src-path", ["src/features/new-thing/index.ts"], { perf_changed: true }); +} + +function assertProductScopeRouting() { assertScope("runtime-data", ["data/medications-snapshot.json"], { source_changed: true, coverage_changed: true, @@ -1328,6 +1407,9 @@ function selfTest() { ui_changed: true, source_changed: true, }); +} + +function assertRagIngestionAndDatabaseScopes() { assertScope("rag-fixture", ["src/lib/retrieval-selection.ts", "scripts/fixtures/rag-retrieval-golden.json"], { rag_eval_changed: true, source_changed: true, @@ -1419,6 +1501,9 @@ function selfTest() { source_changed: true, }, ); +} + +function assertWorkflowAndPolicyScopes() { assertScope("workflow", [".github/workflows/ci.yml", "docs/process-hardening.md"], { workflow_changed: true, workflow_only: true, @@ -1478,6 +1563,9 @@ function selfTest() { docs_only: false, source_changed: false, }); +} + +function assertDependencyAndContainerScopes() { assertScope("package", ["package.json"], { source_changed: false, coverage_changed: true, @@ -1552,6 +1640,9 @@ function selfTest() { build_changed: true, }, ); +} + +function assertFallbackAndRenameScopes() { assertScope("renamed-destination", parseStatusPorcelain("R src/lib/rag-new.ts\0docs/rag-old.md\0"), { source_changed: true, rag_eval_changed: true, @@ -1591,6 +1682,9 @@ function selfTest() { assertScope("inherited-pr-policy-body-does-not-sync", ["docs/testing.md"], { pr_policy_body_changed: false, }); +} + +function assertSiteContentRoutingScopes() { for (const owners of Object.values(siteContentProducerOwners)) { for (const owner of owners) { assertScope(`site-content-owner:${owner}`, [owner.replace(/\/\*\*$/, "/representative.json")], { @@ -1604,22 +1698,30 @@ function selfTest() { site_content_changed: true, }); assertScope("site-content-negative-ui", ["src/components/ordinary-card.tsx"], { site_content_changed: false }); +} + +function assertSiteContentManifestRejected(value, expectedError) { try { - validateSiteContentChangeOwners({ version: "site-content-change-owners-v1", producers: {} }); - throw new Error("site-content-owner-manifest-malformed:self-test-did-not-fail"); + validateSiteContentChangeOwners(value); + throw new Error(`${expectedError}:self-test-did-not-fail`); } catch (error) { - if (!(error instanceof Error) || !error.message.includes("site-content-owner-manifest-malformed")) throw error; + if (!(error instanceof Error) || !error.message.includes(expectedError)) throw error; } +} + +function assertSiteContentValidationScopes() { + assertSiteContentManifestRejected( + { version: "site-content-change-owners-v1", producers: {} }, + "site-content-owner-manifest-malformed", + ); for (const owner of ["data/**", "public/**", "src/**"]) { - try { - validateSiteContentChangeOwners({ + assertSiteContentManifestRejected( + { version: "site-content-change-owners-v1", producers: { malformed: [owner] }, - }); - throw new Error("site-content-owner-manifest-broad-path:self-test-did-not-fail"); - } catch (error) { - if (!(error instanceof Error) || !error.message.includes("site-content-owner-manifest-broad-path")) throw error; - } + }, + "site-content-owner-manifest-broad-path", + ); } for (const [expectedError, owner] of [ ["site-content-owner-manifest-empty-owner", ""], @@ -1633,16 +1735,28 @@ function selfTest() { ["site-content-owner-manifest-noncanonical-owner", "public/therapy-compass-data//**"], ["site-content-owner-manifest-noncanonical-owner", "public/therapy-compass-data/./**"], ]) { - try { - validateSiteContentChangeOwners({ + assertSiteContentManifestRejected( + { version: "site-content-change-owners-v1", producers: { malformed: [owner] }, - }); - throw new Error(`${expectedError}:self-test-did-not-fail`); - } catch (error) { - if (!(error instanceof Error) || !error.message.includes(expectedError)) throw error; - } + }, + expectedError, + ); } +} + +function selfTest() { + assertBrowserAndAdvisoryScopes(); + assertCoreStaticAndUiScopes(); + assertPerformanceScopeExclusions(); + assertPerformanceScopeInclusions(); + assertProductScopeRouting(); + assertRagIngestionAndDatabaseScopes(); + assertWorkflowAndPolicyScopes(); + assertDependencyAndContainerScopes(); + assertFallbackAndRenameScopes(); + assertSiteContentRoutingScopes(); + assertSiteContentValidationScopes(); console.log("CI change scope self-test passed."); } diff --git a/scripts/design-system-contract-utils.mjs b/scripts/design-system-contract-utils.mjs index 022500e411..0d0e287838 100644 --- a/scripts/design-system-contract-utils.mjs +++ b/scripts/design-system-contract-utils.mjs @@ -4,6 +4,26 @@ import path from "node:path"; import ts from "@typescript/typescript6"; import postcss from "postcss"; +// Helpers only traverse these trees; their scopes and findings remain per-call. +// Keep a small window for successive checks of one file, including its TS/TSX +// parse variants. Exact text matching observes edits without file timestamps. +const SOURCE_FILE_CACHE_LIMIT = 4; +const sourceFileCache = []; + +function parsedSourceFile(relativePath, sourceText, scriptKind) { + const cached = sourceFileCache.find( + (entry) => + entry.relativePath === relativePath && entry.sourceText === sourceText && entry.scriptKind === scriptKind, + ); + if (cached) return cached.source; + + // Target and parent-node settings are fixed for every caller of this helper. + const source = ts.createSourceFile(relativePath, sourceText, ts.ScriptTarget.Latest, true, scriptKind); + sourceFileCache.push({ relativePath, sourceText, scriptKind, source }); + if (sourceFileCache.length > SOURCE_FILE_CACHE_LIMIT) sourceFileCache.shift(); + return source; +} + /** Public barrel after the DS-P2-21 split. Recipe bodies live under `primitive-recipes/`. */ export const UI_PRIMITIVES_BARREL = "src/components/ui-primitives.tsx"; export const PRIMITIVE_RECIPES_DIR = "src/components/primitive-recipes"; @@ -147,12 +167,17 @@ export function findTextSoftConsumersInSource(relativePath, sourceText) { return findings; } + // Every match requires this literal; keep CSS parsing and nonstring scanner + // inputs on their existing paths even when no consumer can be found. + if (typeof sourceText === "string" && !sourceText.includes("--text-soft")) return findings; + const languageVariant = relativePath.endsWith(".tsx") ? ts.LanguageVariant.JSX : ts.LanguageVariant.Standard; const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, languageVariant, sourceText); const commentTokens = new Set([ts.SyntaxKind.SingleLineCommentTrivia, ts.SyntaxKind.MultiLineCommentTrivia]); for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) { if (commentTokens.has(token)) continue; const tokenText = scanner.getTokenText(); + if (!tokenText.includes("--text-soft")) continue; const tokenOffset = scanner.getTokenPos(); const tokenLineOffset = sourceText.slice(0, tokenOffset).split(/\r?\n/).length - 1; recordMatches(tokenText, tokenLineOffset); @@ -241,7 +266,7 @@ export function jsxClassText(attribute) { export function findInteractiveTapLiteralsInSource(relativePath, sourceText) { if (!relativePath.endsWith(".tsx")) return []; - const source = ts.createSourceFile(relativePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const source = parsedSourceFile(relativePath, sourceText, ts.ScriptKind.TSX); const findings = []; const interactiveTags = new Set(["a", "button", "input", "select", "summary", "textarea"]); @@ -580,7 +605,7 @@ export const SUB_TAP_MIN_HEIGHT_PREFILTER = /\bmin-h-(?:[0-9]|1[01]|px\b|\[|comp export function findInteractiveTapFloorDeclarationsInSource(relativePath, sourceText) { if (!relativePath.endsWith(".tsx")) return []; if (!SUB_TAP_MIN_HEIGHT_PREFILTER.test(sourceText)) return []; - const source = ts.createSourceFile(relativePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const source = parsedSourceFile(relativePath, sourceText, ts.ScriptKind.TSX); const findings = []; function inspectOpeningElement(node) { @@ -816,7 +841,7 @@ function isRawScaleLiteralValue(value) { return !trimmed.split(/\s+/).every((part) => CSS_ZERO_VALUE.test(part) || CSS_WIDE_KEYWORD.test(part)); } -function recordRawScaleLiteralProperty(result, relativePath, line, prop, value, token) { +function recordRawScaleLiteralProperty(result, { relativePath, line, prop, value, token }) { if (!isRawScaleLiteralValue(value)) return; const label = token ?? `${prop}: ${value}`; if (RAW_PADDING_PROPERTY.test(prop)) { @@ -938,7 +963,7 @@ function tokenEntries(node, source) { function classExpressionAnalyzer(relativePath, sourceText) { if (!/\.[cm]?[jt]sx?$/.test(relativePath)) return null; const kind = relativePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; - const source = ts.createSourceFile(relativePath, sourceText, ts.ScriptTarget.Latest, true, kind); + const source = parsedSourceFile(relativePath, sourceText, kind); const scopeByNode = new WeakMap(); const rootScope = { bindings: new Map(), functionScope: true, parent: null }; const blockedBinding = Symbol("blocked binding"); @@ -1051,158 +1076,175 @@ function classExpressionAnalyzer(relativePath, sourceText) { const empty = () => [{ usesDensityRecipe: false, tokens: [] }]; - function possibilities(node, resolving = new Set()) { - if (!node) return empty(); + function densityRecipePossibility() { + return [{ usesDensityRecipe: true, tokens: [] }]; + } + + function transparentExpression(node) { + if (ts.isParenthesizedExpression(node)) return node.expression; + if (ts.isAsExpression(node)) return node.expression; + if (ts.isTypeAssertionExpression(node)) return node.expression; + if (ts.isNonNullExpression(node)) return node.expression; + return null; + } + + function binaryPossibilities(node, resolving) { + if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { + return [...empty(), ...possibilities(node.right, resolving)]; + } if ( - ts.isParenthesizedExpression(node) || - ts.isAsExpression(node) || - ts.isTypeAssertionExpression(node) || - ts.isNonNullExpression(node) + node.operatorToken.kind === ts.SyntaxKind.BarBarToken || + node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken ) { - return possibilities(node.expression, resolving); - } - if (ts.isConditionalExpression(node)) { - return [...possibilities(node.whenTrue, resolving), ...possibilities(node.whenFalse, resolving)]; + return [...possibilities(node.left, resolving), ...possibilities(node.right, resolving)]; } - if (ts.isBinaryExpression(node)) { - if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { - return [...empty(), ...possibilities(node.right, resolving)]; - } - if ( - node.operatorToken.kind === ts.SyntaxKind.BarBarToken || - node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken - ) { - return [...possibilities(node.left, resolving), ...possibilities(node.right, resolving)]; - } - return combinePossibilities(possibilities(node.left, resolving), possibilities(node.right, resolving)); - } - if (ts.isCallExpression(node)) { - const callee = ts.isIdentifier(node.expression) ? node.expression.text : ""; - if ( - ts.isPropertyAccessExpression(node.expression) && - (node.expression.name.text === "filter" || node.expression.name.text === "join") - ) { + return combinePossibilities(possibilities(node.left, resolving), possibilities(node.right, resolving)); + } + + function callPossibilities(node, resolving) { + if (ts.isPropertyAccessExpression(node.expression)) { + const method = node.expression.name.text; + if (method === "filter" || method === "join") { return possibilities(node.expression.expression, resolving); } - if (!CLASS_COMPOSERS.has(callee)) return empty(); - return node.arguments.reduce( - (entries, argument) => combinePossibilities(entries, possibilities(argument, resolving)), - empty(), + } + const callee = ts.isIdentifier(node.expression) ? node.expression.text : ""; + if (!CLASS_COMPOSERS.has(callee)) return empty(); + return node.arguments.reduce( + (entries, argument) => combinePossibilities(entries, possibilities(argument, resolving)), + empty(), + ); + } + + function objectPropertyPossibilities(entries, property, resolving) { + if (ts.isSpreadAssignment(property)) { + return combinePossibilities(entries, possibilities(property.expression, resolving)); + } + if (ts.isShorthandPropertyAssignment(property)) { + return combinePossibilities(entries, possibilities(property.name, resolving)); + } + if (!ts.isPropertyAssignment(property)) return entries; + const name = property.name; + let keyEntries = empty(); + if (ts.isStringLiteralLike(name) || ts.isIdentifier(name)) { + keyEntries = [{ usesDensityRecipe: false, tokens: tokenEntries(name, source) }]; + } else if (ts.isComputedPropertyName(name)) { + keyEntries = possibilities(name.expression, resolving); + } + return combinePossibilities(entries, [...empty(), ...keyEntries]); + } + + function objectPossibilities(node, resolving) { + return node.properties.reduce( + (entries, property) => objectPropertyPossibilities(entries, property, resolving), + empty(), + ); + } + + function templatePossibilities(node, resolving) { + let entries = [{ usesDensityRecipe: false, tokens: tokenEntries(node.head, source) }]; + for (const span of node.templateSpans) { + entries = combinePossibilities(entries, possibilities(span.expression, resolving)); + entries = combinePossibilities(entries, [ + { usesDensityRecipe: false, tokens: tokenEntries(span.literal, source) }, + ]); + } + return entries; + } + + function identifierPossibilities(node, resolving) { + const binding = resolveBinding(node); + if (binding === blockedBinding) return empty(); + if (!binding) { + return node.text === "metadataPill" || node.text === "metadataPillDensity" ? densityRecipePossibility() : empty(); + } + if (binding.densityRecipe) return densityRecipePossibility(); + if (resolving.has(binding)) return empty(); + const next = new Set(resolving); + next.add(binding); + if (binding.destructuredFrom) { + const sourcePossibilities = possibilities(binding.destructuredFrom, next); + if (sourcePossibilities.some((entry) => entry.usesDensityRecipe)) return densityRecipePossibility(); + } + return binding.initializer ? possibilities(binding.initializer, next) : empty(); + } + + function objectPropertyInitializer(initializer, propertyName) { + const unwrapped = ts.isAsExpression(initializer) ? initializer.expression : initializer; + if (!ts.isObjectLiteralExpression(unwrapped)) return null; + const property = unwrapped.properties.find((candidate) => { + if (!ts.isPropertyAssignment(candidate)) return false; + const name = candidate.name; + return ( + (ts.isIdentifier(name) && name.text === propertyName) || + (ts.isStringLiteralLike(name) && name.text === propertyName) ); + }); + return property && ts.isPropertyAssignment(property) ? property.initializer : null; + } + + function namespaceDensityRecipe(node, binding) { + if (!binding || binding === blockedBinding || !binding.namespaceImport) return false; + return node.name.text === "metadataPill" || node.name.text === "metadataPillDensity"; + } + + function propertyAccessPossibilities(node, resolving) { + const expressionPossibilities = possibilities(node.expression, resolving); + if (expressionPossibilities.some((entry) => entry.usesDensityRecipe)) return densityRecipePossibility(); + if (!ts.isIdentifier(node.expression)) return empty(); + const binding = resolveBinding(node.expression); + if (namespaceDensityRecipe(node, binding)) return densityRecipePossibility(); + if (!binding || binding === blockedBinding || !binding.initializer || resolving.has(binding)) return empty(); + const initializer = objectPropertyInitializer(binding.initializer, node.name.text); + if (!initializer) return empty(); + const next = new Set(resolving); + next.add(binding); + return possibilities(initializer, next); + } + + function elementAccessPossibilities(node, resolving) { + const expressionPossibilities = possibilities(node.expression, resolving); + if (expressionPossibilities.some((entry) => entry.usesDensityRecipe)) return densityRecipePossibility(); + if (!ts.isIdentifier(node.expression)) return empty(); + const binding = resolveBinding(node.expression); + if (!binding || binding === blockedBinding || !binding.initializer || resolving.has(binding)) return empty(); + const initializer = ts.isAsExpression(binding.initializer) ? binding.initializer.expression : binding.initializer; + if (!ts.isObjectLiteralExpression(initializer)) return empty(); + const requested = ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null; + const next = new Set(resolving); + next.add(binding); + return initializer.properties.flatMap((property) => { + if (!ts.isPropertyAssignment(property)) return []; + const propertyName = + ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name) ? property.name.text : null; + if (requested !== null && propertyName !== requested) return []; + return possibilities(property.initializer, next); + }); + } + + function possibilities(node, resolving = new Set()) { + if (!node) return empty(); + const transparent = transparentExpression(node); + if (transparent) return possibilities(transparent, resolving); + if (ts.isConditionalExpression(node)) { + return [...possibilities(node.whenTrue, resolving), ...possibilities(node.whenFalse, resolving)]; } + if (ts.isBinaryExpression(node)) return binaryPossibilities(node, resolving); + if (ts.isCallExpression(node)) return callPossibilities(node, resolving); if (ts.isArrayLiteralExpression(node)) { return node.elements.reduce( (entries, element) => combinePossibilities(entries, possibilities(element, resolving)), empty(), ); } - if (ts.isObjectLiteralExpression(node)) { - return node.properties.reduce((entries, property) => { - if (ts.isSpreadAssignment(property)) - return combinePossibilities(entries, possibilities(property.expression, resolving)); - if (ts.isShorthandPropertyAssignment(property)) { - return combinePossibilities(entries, possibilities(property.name, resolving)); - } - if (!ts.isPropertyAssignment(property)) return entries; - const name = property.name; - const keyEntries = - ts.isStringLiteralLike(name) || ts.isIdentifier(name) - ? [{ usesDensityRecipe: false, tokens: tokenEntries(name, source) }] - : ts.isComputedPropertyName(name) - ? possibilities(name.expression, resolving) - : empty(); - return combinePossibilities(entries, [...empty(), ...keyEntries]); - }, empty()); - } + if (ts.isObjectLiteralExpression(node)) return objectPossibilities(node, resolving); if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) { return [{ usesDensityRecipe: false, tokens: tokenEntries(node, source) }]; } - if (ts.isTemplateExpression(node)) { - let entries = [{ usesDensityRecipe: false, tokens: tokenEntries(node.head, source) }]; - for (const span of node.templateSpans) { - entries = combinePossibilities(entries, possibilities(span.expression, resolving)); - entries = combinePossibilities(entries, [ - { usesDensityRecipe: false, tokens: tokenEntries(span.literal, source) }, - ]); - } - return entries; - } - if (ts.isIdentifier(node)) { - const binding = resolveBinding(node); - if (binding === blockedBinding) return empty(); - if (!binding && (node.text === "metadataPill" || node.text === "metadataPillDensity")) { - return [{ usesDensityRecipe: true, tokens: [] }]; - } - if (!binding) return empty(); - if (binding.densityRecipe) return [{ usesDensityRecipe: true, tokens: [] }]; - if (resolving.has(binding)) return empty(); - const next = new Set(resolving); - next.add(binding); - if (binding.destructuredFrom) { - const sourcePossibilities = possibilities(binding.destructuredFrom, next); - if (sourcePossibilities.some((entry) => entry.usesDensityRecipe)) { - return [{ usesDensityRecipe: true, tokens: [] }]; - } - } - if (!binding.initializer) return empty(); - return possibilities(binding.initializer, next); - } - if (ts.isPropertyAccessExpression(node)) { - const expressionPossibilities = possibilities(node.expression, resolving); - if (expressionPossibilities.some((entry) => entry.usesDensityRecipe)) { - return [{ usesDensityRecipe: true, tokens: [] }]; - } - if (ts.isIdentifier(node.expression)) { - const binding = resolveBinding(node.expression); - if ( - binding && - binding !== blockedBinding && - binding.namespaceImport && - (node.name.text === "metadataPill" || node.name.text === "metadataPillDensity") - ) { - return [{ usesDensityRecipe: true, tokens: [] }]; - } - if (binding && binding !== blockedBinding && binding.initializer && !resolving.has(binding)) { - const initializer = binding.initializer; - const unwrapped = ts.isAsExpression(initializer) ? initializer.expression : initializer; - const property = ts.isObjectLiteralExpression(unwrapped) - ? unwrapped.properties.find( - (candidate) => - ts.isPropertyAssignment(candidate) && - ((ts.isIdentifier(candidate.name) && candidate.name.text === node.name.text) || - (ts.isStringLiteralLike(candidate.name) && candidate.name.text === node.name.text)), - ) - : null; - if (property && ts.isPropertyAssignment(property)) { - const next = new Set(resolving); - next.add(binding); - return possibilities(property.initializer, next); - } - } - } - return empty(); - } - if (ts.isElementAccessExpression(node)) { - const expressionPossibilities = possibilities(node.expression, resolving); - if (expressionPossibilities.some((entry) => entry.usesDensityRecipe)) { - return [{ usesDensityRecipe: true, tokens: [] }]; - } - if (!ts.isIdentifier(node.expression)) return empty(); - const binding = resolveBinding(node.expression); - if (!binding || binding === blockedBinding || !binding.initializer || resolving.has(binding)) return empty(); - const initializer = ts.isAsExpression(binding.initializer) ? binding.initializer.expression : binding.initializer; - if (!ts.isObjectLiteralExpression(initializer)) return empty(); - const requested = ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null; - const next = new Set(resolving); - next.add(binding); - return initializer.properties.flatMap((property) => { - if (!ts.isPropertyAssignment(property)) return []; - const propertyName = - ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name) ? property.name.text : null; - if (requested !== null && propertyName !== requested) return []; - return possibilities(property.initializer, next); - }); - } + if (ts.isTemplateExpression(node)) return templatePossibilities(node, resolving); + if (ts.isIdentifier(node)) return identifierPossibilities(node, resolving); + if (ts.isPropertyAccessExpression(node)) return propertyAccessPossibilities(node, resolving); + if (ts.isElementAccessExpression(node)) return elementAccessPossibilities(node, resolving); return empty(); } @@ -1505,9 +1547,8 @@ function hasAccessibleNameInScope(owner, source) { return false; } -export function analyzeClassContractsInSource(relativePath, sourceText) { - const analyzer = classExpressionAnalyzer(relativePath, sourceText); - const result = { +function emptyClassContractResult() { + return { arbitraryTracking: [], colourOnlyStatusIndicators: [], darkColorOverrides: [], @@ -1530,140 +1571,177 @@ export function analyzeClassContractsInSource(relativePath, sourceText) { typeStepUsages: [], unapprovedZIndices: [], }; - if (!analyzer) return result; +} - const allTokens = new Map(); - for (const root of analyzer.classRoots) { - const possibilities = analyzer.possibilities(root.expression); - const densityConflictsForRoot = new Set(); - for (const possibility of possibilities) { - const bases = possibility.tokens.map(({ token }) => utilityBase(token)); - if ( - bases.some((token) => BORDER_WIDTH_UTILITY.test(token)) && - bases.some((token) => RING_WIDTH_UTILITY.test(token)) - ) { - const line = analyzer.source.getLineAndCharacterOfPosition(root.owner.getStart(analyzer.source)).line + 1; - result.edgeOwnershipConflicts.push(`${relativePath}:${line}`); - break; - } - const densityConflicts = possibility.tokens.filter(({ token }) => { - const base = utilityBase(token); - return DENSITY_HEIGHT_UTILITY.test(base) || DENSITY_TEXT_UTILITY.test(base); - }); - if ((root.tag === "Chip" || possibility.usesDensityRecipe) && densityConflicts.length > 0) { - for (const { token } of densityConflicts) densityConflictsForRoot.add(utilityBase(token)); - } - } - if (densityConflictsForRoot.size > 0) { - const line = analyzer.source.getLineAndCharacterOfPosition(root.owner.getStart(analyzer.source)).line + 1; - result.densityOverrides.push(`${relativePath}:${line} (${[...densityConflictsForRoot].join(", ")})`); - } - - // Status-colour boundary (GATES.md §3). Two distinct defects, one pass over - // the same resolved class set, and deliberately mutually exclusive: a - // numeral has children, a colour-only indicator has none. - const rootBases = [...new Set(possibilities.flatMap((p) => p.tokens.map(({ token }) => utilityBase(token))))]; - const statusText = rootBases.filter((base) => STATUS_TEXT_UTILITY.test(base)); - const statusSurface = rootBases.filter((base) => STATUS_SURFACE_UTILITY.test(base)); - const rootLine = analyzer.source.getLineAndCharacterOfPosition(root.owner.getStart(analyzer.source)).line + 1; - - if (statusText.length > 0 && childrenAreNumeralOnly(jsxOwnerChildren(root.owner))) { - result.statusColouredNumerals.push(`${relativePath}:${rootLine} (${statusText[0]})`); - } - - if (statusSurface.length > 0 && root.tag !== "StatusMark") { - const isJsxRoot = ts.isJsxOpeningElement(root.owner) || ts.isJsxSelfClosingElement(root.owner); - if (isJsxRoot) { - // A status hue painted on a box that says nothing: no children to read, - // and no name on it or anything containing it. Colour is then the only - // channel the state has. - if ( - !hasAccessibleNameInScope(root.owner, analyzer.source) && - !hasSiblingTextChannel(root.owner) && - jsxOwnerChildren(root.owner).length === 0 - ) { - result.colourOnlyStatusIndicators.push(`${relativePath}:${rootLine} (${statusSurface[0]})`); - } - } else if (root.tag === "recipe") { - // A shared swatch recipe — a status hue plus a tiny round box and no text - // utility. Its call sites inherit the defect, and the analyzer cannot see - // across files to find them, so the recipe is where it is catchable. - const swatch = - rootBases.some((base) => SWATCH_GEOMETRY.test(base)) && - rootBases.includes("rounded-full") && - !rootBases.some((base) => base.startsWith("text-")); - if (swatch) result.colourOnlyStatusIndicators.push(`${relativePath}:${rootLine} (${statusSurface[0]})`); - } - } +function classRootLine(analyzer, root) { + return analyzer.source.getLineAndCharacterOfPosition(root.owner.getStart(analyzer.source)).line + 1; +} - for (const entry of uniqueTokenEntries(possibilities)) allTokens.set(entry.id, entry); - } - - for (const { token, line } of allTokens.values()) { - const base = utilityBase(token); - const variants = utilityVariants(token); - if (base === "transition-all" || HARDCODED_MOTION_UTILITY.test(base)) { - result.hardcodedMotionClasses.push(`${relativePath}:${line} (${token})`); - } - if (IMAGE_INVERSION_UTILITY.test(base) || INVERSION_FUNCTION.test(base)) { - result.imageInversions.push(`${relativePath}:${line} (${token})`); - } - if (LITERAL_SHADOW_UTILITY.test(base)) result.literalShadowClasses.push(`${relativePath}:${line} (${token})`); - if (ARBITRARY_TRACKING_UTILITY.test(base)) result.arbitraryTracking.push(`${relativePath}:${line} (${token})`); - if (RAW_PADDING_UTILITY.test(base)) result.rawPaddingLiterals.push(`${relativePath}:${line} (${token})`); - if (RAW_RADIUS_UTILITY.test(base)) result.rawRadiusLiterals.push(`${relativePath}:${line} (${token})`); - if (RAW_GAP_UTILITY.test(base)) result.rawGapLiterals.push(`${relativePath}:${line} (${token})`); - if (RAW_MARGIN_UTILITY.test(base)) result.rawMarginLiterals.push(`${relativePath}:${line} (${token})`); - if (RAW_LINE_HEIGHT_UTILITY.test(base)) result.rawLineHeightLiterals.push(`${relativePath}:${line} (${token})`); - const arbitraryProperty = base.match(ARBITRARY_PROPERTY_UTILITY); - if (arbitraryProperty) { - recordRawScaleLiteralProperty( - result, - relativePath, - line, - arbitraryProperty[1].toLowerCase(), - arbitraryProperty[2], - token, - ); - } - // Every bare `text-` this file uses, whatever `` turns out to - // mean. The caller decides which of these are type steps by reading the - // `@theme` block, so the scale is never spelled out twice — writing the - // step list here would make this module a second source of truth that - // globals.css could drift away from silently. - const bareTextUtility = base.match(/^text-([a-z0-9][a-z0-9-]*)$/); - if (bareTextUtility) result.typeStepUsages.push(bareTextUtility[1]); - if (hasLegacyTapClass(token)) result.legacyTapClasses.push(`${relativePath}:${line} (${token})`); - for (const match of token.matchAll(LEGACY_SHADOW_ALIAS)) { - result.legacyShadowAliases.push(`${relativePath}:${line} (${match[0]})`); - } - const transition = base.match(/^transition-\[([^\]]+)\]$/); - if (transition) { - const properties = transition[1].split(/[,_]/).filter(Boolean); - for (const property of properties) { - if (!SAFE_TRANSITION_PROPERTIES.has(property)) result.layoutTransitions.push({ relativePath, line, property }); - } - } - if (/^-?z-(?:\[|\(|\$|$)/.test(base) || /^-?z-\d+$/.test(base)) { - const numeric = base.match(/^(-?)z-(?:\[(-?\d+)\]|(-?\d+))$/); - const tokenMatch = base.match(/^z-(?:\[var\((--z-[a-z-]+)\)\]|\((--z-[a-z-]+)\))$/); - const numericAllowed = numeric && ALLOWED_Z_INDEX_RUNGS.has(Number(`${numeric[1]}${numeric[2] ?? numeric[3]}`)); - const tokenAllowed = tokenMatch && ALLOWED_Z_INDEX_TOKENS.has(tokenMatch[1] ?? tokenMatch[2]); - if (!numericAllowed && !tokenAllowed) result.unapprovedZIndices.push(`${relativePath}:${line} (${token})`); +function classRootStructuralFindings(root, possibilities) { + const densityConflicts = new Set(); + let edgeOwnershipConflict = false; + for (const possibility of possibilities) { + const bases = possibility.tokens.map(({ token }) => utilityBase(token)); + const ownsBorder = bases.some((token) => BORDER_WIDTH_UTILITY.test(token)); + const ownsRing = bases.some((token) => RING_WIDTH_UTILITY.test(token)); + if (ownsBorder && ownsRing) { + edgeOwnershipConflict = true; + break; } - if (LEGACY_PALETTE_UTILITY.test(base)) result.legacyPaletteUtilities.push(`${relativePath}:${line} (${token})`); - if (variants.includes("dark") && isColorUtility(base)) { - result.darkColorOverrides.push(`${relativePath}:${line} (${token})`); + if (root.tag !== "Chip" && !possibility.usesDensityRecipe) continue; + for (const base of bases) { + if (DENSITY_HEIGHT_UTILITY.test(base) || DENSITY_TEXT_UTILITY.test(base)) densityConflicts.add(base); } - if (variants.includes("disabled") && DISABLED_OPACITY_UTILITY.test(base)) { - result.disabledOpacityUses.push(`${relativePath}:${line} (${token})`); + } + return { densityConflicts, edgeOwnershipConflict }; +} + +function isColourOnlySwatchRecipe(rootBases) { + return ( + rootBases.some((base) => SWATCH_GEOMETRY.test(base)) && + rootBases.includes("rounded-full") && + !rootBases.some((base) => base.startsWith("text-")) + ); +} + +function jsxRootHasOnlyColourChannel(root, source) { + return ( + !hasAccessibleNameInScope(root.owner, source) && + !hasSiblingTextChannel(root.owner) && + jsxOwnerChildren(root.owner).length === 0 + ); +} + +function recordStatusColourContracts(result, { relativePath, analyzer, root, rootLine, possibilities }) { + const rootBases = [...new Set(possibilities.flatMap((entry) => entry.tokens.map(({ token }) => utilityBase(token))))]; + const statusText = rootBases.filter((base) => STATUS_TEXT_UTILITY.test(base)); + const statusSurface = rootBases.filter((base) => STATUS_SURFACE_UTILITY.test(base)); + + if (statusText.length > 0 && childrenAreNumeralOnly(jsxOwnerChildren(root.owner))) { + result.statusColouredNumerals.push(`${relativePath}:${rootLine} (${statusText[0]})`); + } + if (statusSurface.length === 0 || root.tag === "StatusMark") return; + + const isJsxRoot = ts.isJsxOpeningElement(root.owner) || ts.isJsxSelfClosingElement(root.owner); + if (isJsxRoot) { + if (jsxRootHasOnlyColourChannel(root, analyzer.source)) { + result.colourOnlyStatusIndicators.push(`${relativePath}:${rootLine} (${statusSurface[0]})`); } + return; + } + if (root.tag === "recipe" && isColourOnlySwatchRecipe(rootBases)) { + result.colourOnlyStatusIndicators.push(`${relativePath}:${rootLine} (${statusSurface[0]})`); + } +} + +function recordClassRootContracts(result, { allTokens, relativePath, analyzer, root }) { + const possibilities = analyzer.possibilities(root.expression); + const rootLine = classRootLine(analyzer, root); + const context = { relativePath, analyzer, root, rootLine, possibilities }; + const { densityConflicts, edgeOwnershipConflict } = classRootStructuralFindings(root, possibilities); + if (edgeOwnershipConflict) result.edgeOwnershipConflicts.push(`${relativePath}:${rootLine}`); + if (densityConflicts.size > 0) { + result.densityOverrides.push(`${relativePath}:${rootLine} (${[...densityConflicts].join(", ")})`); + } + recordStatusColourContracts(result, context); + for (const entry of uniqueTokenEntries(possibilities)) allTokens.set(entry.id, entry); +} + +function recordRawClassTokenContracts(result, { relativePath, line, token, base }) { + if (LITERAL_SHADOW_UTILITY.test(base)) result.literalShadowClasses.push(`${relativePath}:${line} (${token})`); + if (ARBITRARY_TRACKING_UTILITY.test(base)) result.arbitraryTracking.push(`${relativePath}:${line} (${token})`); + if (RAW_PADDING_UTILITY.test(base)) result.rawPaddingLiterals.push(`${relativePath}:${line} (${token})`); + if (RAW_RADIUS_UTILITY.test(base)) result.rawRadiusLiterals.push(`${relativePath}:${line} (${token})`); + if (RAW_GAP_UTILITY.test(base)) result.rawGapLiterals.push(`${relativePath}:${line} (${token})`); + if (RAW_MARGIN_UTILITY.test(base)) result.rawMarginLiterals.push(`${relativePath}:${line} (${token})`); + if (RAW_LINE_HEIGHT_UTILITY.test(base)) result.rawLineHeightLiterals.push(`${relativePath}:${line} (${token})`); + + const arbitraryProperty = base.match(ARBITRARY_PROPERTY_UTILITY); + if (!arbitraryProperty) return; + recordRawScaleLiteralProperty(result, { + relativePath, + line, + prop: arbitraryProperty[1].toLowerCase(), + value: arbitraryProperty[2], + token, + }); +} + +function recordTypeAndLegacyClassTokenContracts(result, { relativePath, line, token, base }) { + // The caller decides which bare text names are type steps by reading the + // `@theme` block, keeping globals.css as the only source of truth. + const bareTextUtility = base.match(/^text-([a-z0-9][a-z0-9-]*)$/); + if (bareTextUtility) result.typeStepUsages.push(bareTextUtility[1]); + if (hasLegacyTapClass(token)) result.legacyTapClasses.push(`${relativePath}:${line} (${token})`); + for (const match of token.matchAll(LEGACY_SHADOW_ALIAS)) { + result.legacyShadowAliases.push(`${relativePath}:${line} (${match[0]})`); + } + if (LEGACY_PALETTE_UTILITY.test(base)) result.legacyPaletteUtilities.push(`${relativePath}:${line} (${token})`); +} + +function recordTransitionClassTokenContracts(result, { relativePath, line, base }) { + const transition = base.match(/^transition-\[([^\]]+)\]$/); + if (!transition) return; + const properties = transition[1].split(/[,_]/).filter(Boolean); + for (const property of properties) { + if (!SAFE_TRANSITION_PROPERTIES.has(property)) result.layoutTransitions.push({ relativePath, line, property }); + } +} + +function recordZIndexClassTokenContract(result, { relativePath, line, token, base }) { + if (!/^-?z-(?:\[|\(|\$|$)/.test(base) && !/^-?z-\d+$/.test(base)) return; + const numeric = base.match(/^(-?)z-(?:\[(-?\d+)\]|(-?\d+))$/); + const tokenMatch = base.match(/^z-(?:\[var\((--z-[a-z-]+)\)\]|\((--z-[a-z-]+)\))$/); + const numericAllowed = numeric && ALLOWED_Z_INDEX_RUNGS.has(Number(`${numeric[1]}${numeric[2] ?? numeric[3]}`)); + const tokenAllowed = tokenMatch && ALLOWED_Z_INDEX_TOKENS.has(tokenMatch[1] ?? tokenMatch[2]); + if (!numericAllowed && !tokenAllowed) result.unapprovedZIndices.push(`${relativePath}:${line} (${token})`); +} + +function recordClassTokenContracts(result, relativePath, { token, line }) { + const base = utilityBase(token); + const variants = utilityVariants(token); + const context = { relativePath, line, token, base }; + if (base === "transition-all" || HARDCODED_MOTION_UTILITY.test(base)) { + result.hardcodedMotionClasses.push(`${relativePath}:${line} (${token})`); + } + if (IMAGE_INVERSION_UTILITY.test(base) || INVERSION_FUNCTION.test(base)) { + result.imageInversions.push(`${relativePath}:${line} (${token})`); + } + recordRawClassTokenContracts(result, context); + recordTypeAndLegacyClassTokenContracts(result, context); + recordTransitionClassTokenContracts(result, context); + recordZIndexClassTokenContract(result, context); + if (variants.includes("dark") && isColorUtility(base)) { + result.darkColorOverrides.push(`${relativePath}:${line} (${token})`); + } + if (variants.includes("disabled") && DISABLED_OPACITY_UTILITY.test(base)) { + result.disabledOpacityUses.push(`${relativePath}:${line} (${token})`); + } +} + +function deduplicateClassRootFindings(result) { + for (const key of [ + "edgeOwnershipConflicts", + "densityOverrides", + "colourOnlyStatusIndicators", + "statusColouredNumerals", + ]) { + result[key] = [...new Set(result[key])]; + } +} + +export function analyzeClassContractsInSource(relativePath, sourceText) { + const analyzer = classExpressionAnalyzer(relativePath, sourceText); + const result = emptyClassContractResult(); + if (!analyzer) return result; + + const allTokens = new Map(); + for (const root of analyzer.classRoots) { + recordClassRootContracts(result, { allTokens, relativePath, analyzer, root }); } - result.edgeOwnershipConflicts = [...new Set(result.edgeOwnershipConflicts)]; - result.densityOverrides = [...new Set(result.densityOverrides)]; - result.colourOnlyStatusIndicators = [...new Set(result.colourOnlyStatusIndicators)]; - result.statusColouredNumerals = [...new Set(result.statusColouredNumerals)]; + for (const entry of allTokens.values()) recordClassTokenContracts(result, relativePath, entry); + deduplicateClassRootFindings(result); return result; } @@ -1753,7 +1831,7 @@ function isOutOfFlowClassText(classText) { export function findElevationInversionsInSource(relativePath, sourceText) { if (!relativePath.endsWith(".tsx") && !relativePath.endsWith(".ts")) return []; if (relativePath.includes("/mockups/")) return []; - const source = ts.createSourceFile(relativePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const source = parsedSourceFile(relativePath, sourceText, ts.ScriptKind.TSX); const findings = []; function visit(node) { @@ -1799,7 +1877,7 @@ export function findHandRolledCommandButtonsInSource(relativePath, sourceText) { if (!relativePath.endsWith(".tsx")) return []; if (relativePath.includes("/mockups/")) return []; if (relativePath === "src/components/ui/button.tsx") return []; - const source = ts.createSourceFile(relativePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const source = parsedSourceFile(relativePath, sourceText, ts.ScriptKind.TSX); const findings = []; function visit(node) { @@ -1867,7 +1945,7 @@ export function findHandRolledCommandButtonsInSource(relativePath, sourceText) { export function findVisibleLiveRegionsInSource(relativePath, sourceText) { if (!relativePath.endsWith(".tsx")) return []; if (relativePath.includes("/mockups/")) return []; - const source = ts.createSourceFile(relativePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const source = parsedSourceFile(relativePath, sourceText, ts.ScriptKind.TSX); const findings = []; function attributeStringValue(attribute) { @@ -2028,7 +2106,7 @@ export function analyzeCssContractsInSource(relativePath, sourceText) { // Custom-property declarations are the token definitions themselves — the // scale has to be written down somewhere — so only real properties count. if (!prop.startsWith("--")) { - recordRawScaleLiteralProperty(result, relativePath, line, prop, declaration.value); + recordRawScaleLiteralProperty(result, { relativePath, line, prop, value: declaration.value }); } if (/^(?:-webkit-)?(?:backdrop-)?filter$/.test(prop)) { for (const match of declaration.value.matchAll(/\b(invert|hue-rotate)\(/g)) { @@ -2115,7 +2193,7 @@ export function findSameFileTextSmMinusMix(relativePath, sourceText) { export function findErrorStateCountPropsInSource(relativePath, sourceText) { if (!relativePath.endsWith(".tsx") || !sourceText.includes("ErrorState")) return []; - const source = ts.createSourceFile(relativePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const source = parsedSourceFile(relativePath, sourceText, ts.ScriptKind.TSX); const findings = []; const countBearingCopy = /\b\d+\s+(?:items?|matches?|records?|results?|sources?)\b|\b(?:item|match|record|result|source)?counts?\b|\btotal(?:Items?|Matches?|Records?|Results?|Sources?)?\b|\.(?:count|length)\b/i; @@ -2147,7 +2225,7 @@ export function findErrorStateCountPropsInSource(relativePath, sourceText) { export function findFailedStateResultCountsInSource(relativePath, sourceText) { if (!relativePath.endsWith(".tsx") || !/error|fail/i.test(sourceText)) return []; - const source = ts.createSourceFile(relativePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const source = parsedSourceFile(relativePath, sourceText, ts.ScriptKind.TSX); const findings = []; const failureSignal = /\b(?:error|failed|failure|hasError|isError|loadError|requestError)\b|["'](?:error|failed)["']/i; @@ -2393,7 +2471,7 @@ function isInsideCommentOrString(source, position) { } function namedFunctionRange(relativePath, source, functionName) { - const parsed = ts.createSourceFile(relativePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const parsed = parsedSourceFile(relativePath, source, ts.ScriptKind.TSX); const declaration = parsed.statements.find( (node) => ts.isFunctionDeclaration(node) && node.name?.text === functionName, ); diff --git a/scripts/generate-design-system-adoption.mjs b/scripts/generate-design-system-adoption.mjs index 50b04b9a61..d0bcded680 100644 --- a/scripts/generate-design-system-adoption.mjs +++ b/scripts/generate-design-system-adoption.mjs @@ -15,9 +15,6 @@ const MANIFEST_PATH = "docs/design-system/adoption-manifest.json"; const CONFIG_PATH = ".design-sync/config.json"; const SOURCE_EXTENSIONS = new Set([".ts", ".tsx"]); const TEXT_CACHE = new Map(); -const SOURCE_FILE_CACHE = new Map(); -const IMPORT_FACT_CACHE = new Map(); -const SOURCE_IMPORT_CACHE = new Map(); const TRACKED_FILE_CACHE = new Map(); const CANONICAL_GLOBAL_SHELL_ROOT = Object.freeze({ file: "src/app/layout.tsx", element: "html" }); const PROOF_APPLICABILITY = new Set(["required", "not-applicable"]); @@ -83,6 +80,20 @@ const CANONICAL_NON_VISUAL_ROUTES = Object.freeze({ kind: "next-redirect-only", }, }); +const PNG_ALLOWED_BIT_DEPTHS = new Map([ + [0, new Set([1, 2, 4, 8, 16])], + [2, new Set([8, 16])], + [3, new Set([1, 2, 4, 8])], + [4, new Set([8, 16])], + [6, new Set([8, 16])], +]); +const PNG_CHANNELS_BY_COLOUR_TYPE = new Map([ + [0, 1], + [2, 3], + [3, 1], + [4, 2], + [6, 4], +]); const toPosix = (value) => value.split(path.sep).join("/"); const read = (relativePath, root = ROOT) => { @@ -112,34 +123,30 @@ function crc32(buffer) { return (value ^ 0xffffffff) >>> 0; } -export function inspectPng(buffer) { - const failures = []; - if (!Buffer.isBuffer(buffer) || buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) - return { valid: false, width: 0, height: 0, failures: ["must contain a PNG file signature"] }; +function inspectPngHeader(data, failures) { + const width = data.readUInt32BE(0); + const height = data.readUInt32BE(4); + const bitDepth = data[8]; + const colourType = data[9]; + const interlace = data[12]; + if (width < 16 || height < 16 || width > 20_000 || height > 20_000 || width * height > 100_000_000) + failures.push("PNG dimensions must be between 16 and 20000 pixels with a sane total area"); + if (!PNG_ALLOWED_BIT_DEPTHS.get(colourType)?.has(bitDepth)) + failures.push("PNG colour type and bit depth are invalid"); + if (data[10] !== 0 || data[11] !== 0) failures.push("PNG compression and filter methods must be standard"); + if (interlace !== 0) failures.push("PNG visual baselines must be non-interlaced"); + return { width, height, bitDepth, colourType }; +} +function inspectPngStructure(buffer, failures) { let offset = PNG_SIGNATURE.length; let width = 0; let height = 0; let bitDepth = 0; let colourType = -1; - let interlace = -1; let sawIhdr = false; let sawIend = false; const idatChunks = []; - const allowedBitDepths = new Map([ - [0, new Set([1, 2, 4, 8, 16])], - [2, new Set([8, 16])], - [3, new Set([1, 2, 4, 8])], - [4, new Set([8, 16])], - [6, new Set([8, 16])], - ]); - const channelsByColourType = new Map([ - [0, 1], - [2, 3], - [3, 1], - [4, 2], - [6, 4], - ]); while (offset < buffer.length) { if (offset + 12 > buffer.length) { @@ -165,17 +172,7 @@ export function inspectPng(buffer) { if (type !== "IHDR" || length !== 13) failures.push("PNG must begin with a 13-byte IHDR chunk"); else { sawIhdr = true; - width = data.readUInt32BE(0); - height = data.readUInt32BE(4); - bitDepth = data[8]; - colourType = data[9]; - interlace = data[12]; - if (width < 16 || height < 16 || width > 20_000 || height > 20_000 || width * height > 100_000_000) - failures.push("PNG dimensions must be between 16 and 20000 pixels with a sane total area"); - if (!allowedBitDepths.get(colourType)?.has(bitDepth)) - failures.push("PNG colour type and bit depth are invalid"); - if (data[10] !== 0 || data[11] !== 0) failures.push("PNG compression and filter methods must be standard"); - if (interlace !== 0) failures.push("PNG visual baselines must be non-interlaced"); + ({ width, height, bitDepth, colourType } = inspectPngHeader(data, failures)); } } else if (type === "IHDR") { failures.push("PNG contains more than one IHDR chunk"); @@ -191,32 +188,43 @@ export function inspectPng(buffer) { offset = chunkEnd; } - if (!sawIhdr) failures.push("PNG is missing IHDR"); - if (idatChunks.length === 0) failures.push("PNG is missing image data"); - if (!sawIend) failures.push("PNG is missing IEND"); - if (sawIend && offset !== buffer.length) failures.push("PNG contains trailing bytes after IEND"); + return { offset, width, height, bitDepth, colourType, sawIhdr, sawIend, idatChunks }; +} - if (failures.length === 0) { - try { - const channels = channelsByColourType.get(colourType); - const rowBytes = Math.ceil((width * channels * bitDepth) / 8); - const expectedBytes = (rowBytes + 1) * height; - const decoded = inflateSync(Buffer.concat(idatChunks), { maxOutputLength: expectedBytes + 1 }); - if (decoded.length !== expectedBytes) failures.push("PNG decoded byte length does not match its dimensions"); - else { - for (let row = 0; row < height; row += 1) { - if (decoded[row * (rowBytes + 1)] > 4) { - failures.push("PNG contains an invalid row filter"); - break; - } +function validateDecodedPng({ width, height, bitDepth, colourType, idatChunks }, failures) { + try { + const channels = PNG_CHANNELS_BY_COLOUR_TYPE.get(colourType); + const rowBytes = Math.ceil((width * channels * bitDepth) / 8); + const expectedBytes = (rowBytes + 1) * height; + const decoded = inflateSync(Buffer.concat(idatChunks), { maxOutputLength: expectedBytes + 1 }); + if (decoded.length !== expectedBytes) failures.push("PNG decoded byte length does not match its dimensions"); + else { + for (let row = 0; row < height; row += 1) { + if (decoded[row * (rowBytes + 1)] > 4) { + failures.push("PNG contains an invalid row filter"); + break; } } - } catch { - failures.push("PNG image data cannot be decoded"); } + } catch { + failures.push("PNG image data cannot be decoded"); } +} - return { valid: failures.length === 0, width, height, failures }; +export function inspectPng(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) + return { valid: false, width: 0, height: 0, failures: ["must contain a PNG file signature"] }; + + const failures = []; + const inspection = inspectPngStructure(buffer, failures); + if (!inspection.sawIhdr) failures.push("PNG is missing IHDR"); + if (inspection.idatChunks.length === 0) failures.push("PNG is missing image data"); + if (!inspection.sawIend) failures.push("PNG is missing IEND"); + if (inspection.sawIend && inspection.offset !== buffer.length) + failures.push("PNG contains trailing bytes after IEND"); + if (failures.length === 0) validateDecodedPng(inspection, failures); + + return { valid: failures.length === 0, width: inspection.width, height: inspection.height, failures }; } export function validateAdoptionArtifactPath( @@ -392,47 +400,8 @@ function gitSucceeds(root, args) { } } -function validateCandidateSourceBinding(candidateSourceHead, { root, policy }) { +function validateAwaitingBaselineBinding(candidateAwaiting, currentAwaiting, canonicalIds) { const failures = []; - if (!/^[0-9a-f]{40}$/.test(candidateSourceHead ?? "")) { - return ["visual baseline provenance candidateSourceHead must be a full Git SHA"]; - } - const currentHead = gitOutput(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim(); - if (!currentHead) return ["visual baseline source binding requires a Git repository with a current HEAD"]; - if (!gitSucceeds(root, ["cat-file", "-e", `${candidateSourceHead}^{commit}`])) { - return ["visual baseline provenance candidateSourceHead must identify an existing commit"]; - } - if (!gitSucceeds(root, ["merge-base", "--is-ancestor", candidateSourceHead, currentHead])) { - failures.push("visual baseline provenance candidateSourceHead must be an ancestor of current HEAD"); - } - - const visualSuiteFile = policy.sourceBinding.visualSuiteFile; - const candidateSuite = gitOutput(root, ["show", `${candidateSourceHead}:${visualSuiteFile}`]); - const currentSuite = exists(visualSuiteFile, root) ? fs.readFileSync(path.join(root, visualSuiteFile), "utf8") : null; - const canonicalIds = policy.canonicalCandidates.map((candidate) => candidate.id).sort(); - const candidateIds = candidateSuite === null ? null : visualBaselineTargetIds(candidateSuite, visualSuiteFile); - const currentIds = currentSuite === null ? null : visualBaselineTargetIds(currentSuite, visualSuiteFile); - if (JSON.stringify(candidateIds) !== JSON.stringify(canonicalIds)) - failures.push("candidateSourceHead visual suite targets must exactly match the canonical six ids"); - if (JSON.stringify(currentIds) !== JSON.stringify(canonicalIds)) - failures.push("current visual suite targets must exactly match the canonical six ids"); - - const candidateAwaiting = candidateSuite === null ? null : visualBaselineAwaitingIds(candidateSuite, visualSuiteFile); - const currentAwaiting = currentSuite === null ? null : visualBaselineAwaitingIds(currentSuite, visualSuiteFile); - // Two legitimate shapes, and the second is what makes updating the design possible. - // - // FIRST ADOPTION — the capture head still declares the canonical six as awaiting, - // and the adopting commit empties that list. - // REFRESH — the list is already empty at both ends. Deliberately re-shooting a - // surface produces exactly this, and it is the ORDINARY case once baselines - // exist: the pixels move, the goldens are replaced, and the suite itself does - // not change at all. - // - // Requiring the six-to-empty transition alone made this binding satisfiable - // exactly once. After the first adoption no commit declares the six again, so no - // capture head could ever qualify and every later refresh was unprovable — the - // goldens would go red on the first intentional design change with no supported - // way to re-adopt them. const candidateAwaitingCanonical = candidateAwaiting?.valid && JSON.stringify(candidateAwaiting.ids) === JSON.stringify(canonicalIds); const candidateAwaitingEmpty = candidateAwaiting?.valid && candidateAwaiting.ids.length === 0; @@ -454,7 +423,26 @@ function validateCandidateSourceBinding(candidateSourceHead, { root, policy }) { } else if (!currentAwaitingEmpty) { failures.push("current AWAITING_BASELINE must be empty after committing all six baselines"); } + return { failures, candidateAwaitingAdoptable, currentAwaitingEmpty }; +} + +function inspectVisualSuiteSourceBinding(candidateSourceHead, { root, policy }) { + const failures = []; + const visualSuiteFile = policy.sourceBinding.visualSuiteFile; + const candidateSuite = gitOutput(root, ["show", `${candidateSourceHead}:${visualSuiteFile}`]); + const currentSuite = exists(visualSuiteFile, root) ? fs.readFileSync(path.join(root, visualSuiteFile), "utf8") : null; + const canonicalIds = policy.canonicalCandidates.map((candidate) => candidate.id).sort(); + const candidateIds = candidateSuite === null ? null : visualBaselineTargetIds(candidateSuite, visualSuiteFile); + const currentIds = currentSuite === null ? null : visualBaselineTargetIds(currentSuite, visualSuiteFile); + if (JSON.stringify(candidateIds) !== JSON.stringify(canonicalIds)) + failures.push("candidateSourceHead visual suite targets must exactly match the canonical six ids"); + if (JSON.stringify(currentIds) !== JSON.stringify(canonicalIds)) + failures.push("current visual suite targets must exactly match the canonical six ids"); + const candidateAwaiting = candidateSuite === null ? null : visualBaselineAwaitingIds(candidateSuite, visualSuiteFile); + const currentAwaiting = currentSuite === null ? null : visualBaselineAwaitingIds(currentSuite, visualSuiteFile); + const awaitingBinding = validateAwaitingBaselineBinding(candidateAwaiting, currentAwaiting, canonicalIds); + failures.push(...awaitingBinding.failures); const normalizedCandidateSuite = candidateSuite === null || candidateAwaiting === null ? null @@ -467,13 +455,20 @@ function validateCandidateSourceBinding(candidateSourceHead, { root, policy }) { // exact for a refresh (where the suite is byte-identical) as well as for the // first adoption (where only those values moved). const visualSuiteOnlyChangedAwaiting = - candidateAwaitingAdoptable && - currentAwaitingEmpty && + awaitingBinding.candidateAwaitingAdoptable && + awaitingBinding.currentAwaitingEmpty && normalizedCandidateSuite !== null && normalizedCandidateSuite === normalizedCurrentSuite; if (!visualSuiteOnlyChangedAwaiting) failures.push("visual suite changed after candidate capture beyond the AWAITING_BASELINE declaration"); + return { failures, visualSuiteFile, visualSuiteOnlyChangedAwaiting }; +} +function validatePostCandidateRepositoryChanges( + candidateSourceHead, + { root, policy, visualSuiteFile, visualSuiteOnlyChangedAwaiting }, +) { + const failures = []; const changedOutput = gitOutput(root, [ "diff", "--no-ext-diff", @@ -503,6 +498,47 @@ function validateCandidateSourceBinding(candidateSourceHead, { root, policy }) { return failures; } +function validateCandidateSourceBinding(candidateSourceHead, { root, policy }) { + const failures = []; + if (!/^[0-9a-f]{40}$/.test(candidateSourceHead ?? "")) { + return ["visual baseline provenance candidateSourceHead must be a full Git SHA"]; + } + const currentHead = gitOutput(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim(); + if (!currentHead) return ["visual baseline source binding requires a Git repository with a current HEAD"]; + if (!gitSucceeds(root, ["cat-file", "-e", `${candidateSourceHead}^{commit}`])) { + return ["visual baseline provenance candidateSourceHead must identify an existing commit"]; + } + if (!gitSucceeds(root, ["merge-base", "--is-ancestor", candidateSourceHead, currentHead])) { + failures.push("visual baseline provenance candidateSourceHead must be an ancestor of current HEAD"); + } + + // Two legitimate shapes, and the second is what makes updating the design possible. + // + // FIRST ADOPTION — the capture head still declares the canonical six as awaiting, + // and the adopting commit empties that list. + // REFRESH — the list is already empty at both ends. Deliberately re-shooting a + // surface produces exactly this, and it is the ORDINARY case once baselines + // exist: the pixels move, the goldens are replaced, and the suite itself does + // not change at all. + // + // Requiring the six-to-empty transition alone made this binding satisfiable + // exactly once. After the first adoption no commit declares the six again, so no + // capture head could ever qualify and every later refresh was unprovable — the + // goldens would go red on the first intentional design change with no supported + // way to re-adopt them. + const visualSuiteBinding = inspectVisualSuiteSourceBinding(candidateSourceHead, { root, policy }); + failures.push(...visualSuiteBinding.failures); + failures.push( + ...validatePostCandidateRepositoryChanges(candidateSourceHead, { + root, + policy, + visualSuiteFile: visualSuiteBinding.visualSuiteFile, + visualSuiteOnlyChangedAwaiting: visualSuiteBinding.visualSuiteOnlyChangedAwaiting, + }), + ); + return failures; +} + const AUTOMATED_OR_PENDING_REVIEWER = /(?:\bai\b|\bagents?\b|automat(?:ed|ion|ic)|\bbots?\b|chatgpt|claude|codex|copilot|cursor\b|gemini|gpt-?\d|\bllm\b|openai|anthropic|pending)/i; const HUMAN_GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; @@ -603,41 +639,8 @@ export function humanReviewerAttributionFailure(reviewedBy) { return displayNameFailure(value); } -export function validateLinuxVisualBaselineSet( - baselinePaths, - { root = ROOT, trackedFiles = trackedFilesForRoot(root) } = {}, -) { - const declaredPaths = [...new Set(baselinePaths)].sort(); - if (declaredPaths.length === 0) return []; +function validateVisualBaselineReview(provenance) { const failures = []; - const provenancePath = CANONICAL_VISUAL_BASELINE_POLICY.provenanceFile; - for (const reason of validateAdoptionArtifactPath(provenancePath, { - root, - trackedFiles, - kind: "baseline-provenance", - })) - failures.push(`visual baseline provenance ${provenancePath}: ${reason}`); - if (!exists(provenancePath, root) || !fs.statSync(path.join(root, provenancePath)).isFile()) return failures; - - let provenance; - try { - provenance = JSON.parse(read(provenancePath, root)); - } catch { - failures.push("visual baseline provenance must contain valid JSON"); - return failures; - } - if (provenance.schemaVersion !== CANONICAL_VISUAL_BASELINE_POLICY.provenanceSchemaVersion) - failures.push("visual baseline provenance has an unsupported schema version"); - if (provenance.platform !== CANONICAL_VISUAL_BASELINE_POLICY.requiredPlatform) - failures.push("visual baseline provenance platform must be linux"); - if (provenance.runnerImage !== CANONICAL_VISUAL_BASELINE_POLICY.requiredRunnerImage) - failures.push("visual baseline provenance runnerImage must be ubuntu-24.04"); - failures.push( - ...validateCandidateSourceBinding(provenance.candidateSourceHead, { - root, - policy: CANONICAL_VISUAL_BASELINE_POLICY, - }), - ); if ( provenance.review?.status !== CANONICAL_VISUAL_BASELINE_POLICY.requiredReviewStatus || provenance.review?.reviewerType !== "human" || @@ -660,6 +663,11 @@ export function validateLinuxVisualBaselineSet( : humanReviewerAttributionFailure(provenance.review?.reviewedBy); if (attributionFailure) failures.push(attributionFailure); } + return failures; +} + +function validateVisualBaselineSource(provenance) { + const failures = []; const runId = provenance.source?.runId; if ( provenance.source?.kind !== "hosted-ci-artifact" || @@ -670,7 +678,30 @@ export function validateLinuxVisualBaselineSet( ) { failures.push("visual baseline provenance requires a matching hosted-CI artifact source"); } + return failures; +} + +function validateVisualBaselineMetadata(provenance, { root }) { + const failures = []; + if (provenance.schemaVersion !== CANONICAL_VISUAL_BASELINE_POLICY.provenanceSchemaVersion) + failures.push("visual baseline provenance has an unsupported schema version"); + if (provenance.platform !== CANONICAL_VISUAL_BASELINE_POLICY.requiredPlatform) + failures.push("visual baseline provenance platform must be linux"); + if (provenance.runnerImage !== CANONICAL_VISUAL_BASELINE_POLICY.requiredRunnerImage) + failures.push("visual baseline provenance runnerImage must be ubuntu-24.04"); + failures.push( + ...validateCandidateSourceBinding(provenance.candidateSourceHead, { + root, + policy: CANONICAL_VISUAL_BASELINE_POLICY, + }), + ); + failures.push(...validateVisualBaselineReview(provenance)); + failures.push(...validateVisualBaselineSource(provenance)); + return failures; +} +function validateVisualBaselineCandidates(provenance, { root, trackedFiles }) { + const failures = []; const candidates = Array.isArray(provenance.candidates) ? provenance.candidates : []; if (candidates.length !== CANONICAL_VISUAL_BASELINE_POLICY.requiredCandidateCount) failures.push( @@ -713,7 +744,11 @@ export function validateLinuxVisualBaselineSet( if (candidate.width !== inspected.width || candidate.height !== inspected.height) failures.push(`visual baseline provenance candidate ${candidate.path} dimensions do not match the PNG`); } + return { failures, candidatePaths }; +} +function validateVisualBaselinePathSets(declaredPaths, candidatePaths) { + const failures = []; const sortedCandidatePaths = [...candidatePaths].sort(); const canonicalPaths = CANONICAL_VISUAL_BASELINE_POLICY.canonicalCandidates.map((candidate) => candidate.path).sort(); if (JSON.stringify(declaredPaths) !== JSON.stringify(canonicalPaths)) @@ -723,6 +758,36 @@ export function validateLinuxVisualBaselineSet( return failures; } +export function validateLinuxVisualBaselineSet( + baselinePaths, + { root = ROOT, trackedFiles = trackedFilesForRoot(root) } = {}, +) { + const declaredPaths = [...new Set(baselinePaths)].sort(); + if (declaredPaths.length === 0) return []; + const failures = []; + const provenancePath = CANONICAL_VISUAL_BASELINE_POLICY.provenanceFile; + for (const reason of validateAdoptionArtifactPath(provenancePath, { + root, + trackedFiles, + kind: "baseline-provenance", + })) + failures.push(`visual baseline provenance ${provenancePath}: ${reason}`); + if (!exists(provenancePath, root) || !fs.statSync(path.join(root, provenancePath)).isFile()) return failures; + + let provenance; + try { + provenance = JSON.parse(read(provenancePath, root)); + } catch { + failures.push("visual baseline provenance must contain valid JSON"); + return failures; + } + failures.push(...validateVisualBaselineMetadata(provenance, { root })); + const candidateValidation = validateVisualBaselineCandidates(provenance, { root, trackedFiles }); + failures.push(...candidateValidation.failures); + failures.push(...validateVisualBaselinePathSets(declaredPaths, candidateValidation.candidatePaths)); + return failures; +} + function walk(directory, root = ROOT) { if (!fs.existsSync(directory)) return []; return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { @@ -735,7 +800,11 @@ function walk(directory, root = ROOT) { } export function productionPageRoutes(root = ROOT) { - return walk(path.join(root, "src", "app"), root) + return pageRoutesFromFiles(walk(path.join(root, "src", "app"), root)); +} + +function pageRoutesFromFiles(files) { + return files .filter( (relativePath) => /\/page\.(?:ts|tsx)$/.test(relativePath) && @@ -746,7 +815,11 @@ export function productionPageRoutes(root = ROOT) { } export function productionNextUiEntries(root = ROOT) { - return walk(path.join(root, "src", "app"), root) + return nextUiEntriesFromFiles(walk(path.join(root, "src", "app"), root)); +} + +function nextUiEntriesFromFiles(files) { + return files .filter( (relativePath) => !relativePath.includes("/api/") && @@ -854,20 +927,43 @@ export function analyzeNextRedirectOnlyRoute(relativePath, sourceText) { }; } -function sourceFile(relativePath, root = ROOT) { - const key = `${root}:${relativePath}`; - if (!SOURCE_FILE_CACHE.has(key)) - SOURCE_FILE_CACHE.set( - key, - ts.createSourceFile(relativePath, read(relativePath, root), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX), +function createSourceScan(root, sourceMap = {}) { + const texts = new Map(); + const componentsBySource = new Map(); + for (const [component, componentSource] of Object.entries(sourceMap)) { + for (const source of new Set([componentSource, componentSource.replace(/\.tsx$/, ".ts")])) { + const components = componentsBySource.get(source) ?? []; + components.push(component); + componentsBySource.set(source, components); + } + } + return { + root, + componentsBySource, + sourceFiles: new Map(), + importFacts: new Map(), + sourceImports: new Map(), + importResolutions: new Map(), + read(relativePath) { + if (!texts.has(relativePath)) texts.set(relativePath, fs.readFileSync(path.join(root, relativePath), "utf8")); + return texts.get(relativePath); + }, + }; +} + +function sourceFile(relativePath, scan) { + if (!scan.sourceFiles.has(relativePath)) + scan.sourceFiles.set( + relativePath, + ts.createSourceFile(relativePath, scan.read(relativePath), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX), ); - return SOURCE_FILE_CACHE.get(key); + return scan.sourceFiles.get(relativePath); } -function exportedNames(relativePath, root = ROOT, seen = new Set()) { - if (!exists(relativePath, root) || seen.has(relativePath)) return []; +function exportedNames(relativePath, scan, seen = new Set()) { + if (!exists(relativePath, scan.root) || seen.has(relativePath)) return []; seen.add(relativePath); - const source = sourceFile(relativePath, root); + const source = sourceFile(relativePath, scan); const names = new Set(); for (const statement of source.statements) { const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined; @@ -882,14 +978,16 @@ function exportedNames(relativePath, root = ROOT, seen = new Set()) { // Barrel `export * from "./module"` (DS-P2-21). Follow one hop so // componentSrcMap can keep pointing at `ui-primitives.tsx`. if (!statement.exportClause && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) { - const resolved = resolveImport(relativePath, statement.moduleSpecifier.text, root); - if (resolved) for (const name of exportedNames(resolved, root, seen)) names.add(name); + const resolved = resolveImport(relativePath, statement.moduleSpecifier.text, scan); + if (resolved) for (const name of exportedNames(resolved, scan, seen)) names.add(name); } } return [...names].sort(); } -function resolveImport(importer, specifier, root = ROOT) { +function resolveImport(importer, specifier, scan) { + const key = `${importer}:${specifier}`; + if (scan.importResolutions.has(key)) return scan.importResolutions.get(key); let candidate; if (specifier.startsWith("@/")) candidate = `src/${specifier.slice(2)}`; else if (specifier.startsWith(".")) candidate = toPosix(path.normalize(path.join(path.dirname(importer), specifier))); @@ -897,37 +995,36 @@ function resolveImport(importer, specifier, root = ROOT) { const candidates = path.extname(candidate) ? [candidate] : [`${candidate}.ts`, `${candidate}.tsx`, `${candidate}/index.ts`, `${candidate}/index.tsx`]; - return candidates.find((entry) => exists(entry, root)) ?? (path.extname(candidate) ? candidate : `${candidate}.tsx`); + const resolved = + candidates.find((entry) => exists(entry, scan.root)) ?? (path.extname(candidate) ? candidate : `${candidate}.tsx`); + scan.importResolutions.set(key, resolved); + return resolved; } -function importFacts(relativePath, sourceMap, root = ROOT) { - if (!exists(relativePath, root)) return []; - const cacheKey = `${root}:${relativePath}`; - if (IMPORT_FACT_CACHE.has(cacheKey)) return IMPORT_FACT_CACHE.get(cacheKey); - const source = sourceFile(relativePath, root); +function importFacts(relativePath, scan) { + if (!exists(relativePath, scan.root)) return []; + if (scan.importFacts.has(relativePath)) return scan.importFacts.get(relativePath); + const source = sourceFile(relativePath, scan); const facts = []; for (const statement of source.statements) { if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue; - const resolved = resolveImport(relativePath, statement.moduleSpecifier.text, root); + const resolved = resolveImport(relativePath, statement.moduleSpecifier.text, scan); const imported = statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) ? statement.importClause.namedBindings.elements.map((element) => element.name.text).sort() : []; - for (const [component, componentSource] of Object.entries(sourceMap)) { - const matchesSource = resolved === componentSource || resolved === componentSource.replace(/\.tsx$/, ".ts"); - if (matchesSource && (imported.length === 0 || imported.includes(component))) facts.push(component); - } + for (const component of scan.componentsBySource.get(resolved) ?? []) + if (imported.length === 0 || imported.includes(component)) facts.push(component); } const result = [...new Set(facts)].sort(); - IMPORT_FACT_CACHE.set(cacheKey, result); + scan.importFacts.set(relativePath, result); return result; } -function sourceImports(relativePath, root = ROOT) { - if (!exists(relativePath, root)) return []; - const cacheKey = `${root}:${relativePath}`; - if (SOURCE_IMPORT_CACHE.has(cacheKey)) return SOURCE_IMPORT_CACHE.get(cacheKey); - const source = sourceFile(relativePath, root); +function sourceImports(relativePath, scan) { + if (!exists(relativePath, scan.root)) return []; + if (scan.sourceImports.has(relativePath)) return scan.sourceImports.get(relativePath); + const source = sourceFile(relativePath, scan); const specifiers = []; function collect(node) { if ( @@ -951,22 +1048,26 @@ function sourceImports(relativePath, root = ROOT) { const result = [ ...new Set( specifiers - .map((specifier) => resolveImport(relativePath, specifier, root)) - .filter((resolved) => resolved && exists(resolved, root)), + .map((specifier) => resolveImport(relativePath, specifier, scan)) + .filter((resolved) => resolved && exists(resolved, scan.root)), ), ].sort(); - SOURCE_IMPORT_CACHE.set(cacheKey, result); + scan.sourceImports.set(relativePath, result); return result; } export function reachableSourceFiles(entryFiles, { root = ROOT } = {}) { + return reachableFilesForScan(entryFiles, createSourceScan(root)); +} + +function reachableFilesForScan(entryFiles, scan) { const reachable = new Set(); - const pending = [...new Set(entryFiles)].filter((file) => exists(file, root)); + const pending = [...new Set(entryFiles)].filter((file) => exists(file, scan.root)); while (pending.length) { const file = pending.pop(); if (reachable.has(file)) continue; reachable.add(file); - for (const dependency of sourceImports(file, root)) if (!reachable.has(dependency)) pending.push(dependency); + for (const dependency of sourceImports(file, scan)) if (!reachable.has(dependency)) pending.push(dependency); } return reachable; } @@ -1051,6 +1152,10 @@ function combineClassPatterns(groups, separator, constructed = false) { */ export function analyzeCkbV2ClassUsage(relativePath, sourceText, { elementName = null } = {}) { const source = ts.createSourceFile(relativePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + return analyzeCkbV2Source(source, elementName); +} + +function analyzeCkbV2Source(source, elementName = null) { const bindings = []; const classExpressions = []; const classSpreadExpressions = []; @@ -1199,18 +1304,7 @@ export function analyzeCkbV2ClassUsage(relativePath, sourceText, { elementName = return staticText.includes("ckb") && staticText.includes("v2"); } - function patternsFor(expression, seen = new Set()) { - const current = unwrapExpression(expression); - if (ts.isStringLiteralLike(current)) return [{ value: current.text, constructed: false }]; - if (ts.isNumericLiteral(current)) return [{ value: current.text, constructed: false }]; - if (current.kind === ts.SyntaxKind.TrueKeyword || current.kind === ts.SyntaxKind.FalseKeyword) - return [{ value: "", constructed: false }]; - if (ts.isIdentifier(current)) { - const binding = resolveBinding(current); - if (!binding?.declaration.initializer || seen.has(binding.declaration)) - return [{ value: UNKNOWN_CLASS_FRAGMENT, constructed: false }]; - return patternsFor(binding.declaration.initializer, new Set(seen).add(binding.declaration)); - } + function compositeClassPatterns(current, seen) { if (ts.isTemplateExpression(current)) { let combined = [{ value: current.head.text, constructed: true }]; for (const span of current.templateSpans) { @@ -1242,74 +1336,6 @@ export function analyzeCkbV2ClassUsage(relativePath, sourceText, { elementName = ].slice(0, MAX_CLASS_PATTERNS); } } - if (ts.isCallExpression(current)) { - if (ts.isPropertyAccessExpression(current.expression) && current.expression.name.text === "replace") { - const receivers = patternsFor(current.expression.expression, seen); - const searches = current.arguments[0] - ? patternsFor(current.arguments[0], seen) - : [{ value: UNKNOWN_CLASS_FRAGMENT, constructed: false }]; - const replacements = current.arguments[1] - ? patternsFor(current.arguments[1], seen) - : [{ value: "undefined", constructed: false }]; - const results = []; - for (const receiver of receivers) { - for (const search of searches) { - for (const replacement of replacements) { - const resolvable = ![receiver.value, search.value, replacement.value].some((value) => - value.includes(UNKNOWN_CLASS_FRAGMENT), - ); - results.push({ - value: resolvable - ? receiver.value.replace(search.value, replacement.value) - : unresolvedCallHasCkbV2Evidence(current) - ? "ckb-v2" - : UNKNOWN_CLASS_FRAGMENT, - constructed: true, - }); - if (results.length >= MAX_CLASS_PATTERNS) return results; - } - } - } - return results; - } - if (ts.isPropertyAccessExpression(current.expression) && current.expression.name.text === "join") { - const elements = arrayElements(current.expression.expression, seen); - if (elements) { - const separators = current.arguments[0] - ? patternsFor(current.arguments[0], seen) - : [{ value: ",", constructed: false }]; - const results = []; - for (const separator of separators) { - results.push( - ...combineClassPatterns( - elements.map((element) => patternsFor(element, seen)), - separator.value, - true, - ), - ); - if (results.length >= MAX_CLASS_PATTERNS) break; - } - return results.slice(0, MAX_CLASS_PATTERNS); - } - } - const helperName = ts.isIdentifier(current.expression) - ? current.expression.text - : ts.isPropertyAccessExpression(current.expression) - ? current.expression.name.text - : ""; - if (CLASS_NAME_HELPERS.has(helperName)) { - return combineClassPatterns( - current.arguments.map((argument) => patternsFor(argument, seen)), - " ", - ); - } - return [ - { - value: unresolvedCallHasCkbV2Evidence(current) ? "ckb-v2" : UNKNOWN_CLASS_FRAGMENT, - constructed: unresolvedCallHasCkbV2Evidence(current), - }, - ]; - } if (ts.isArrayLiteralExpression(current)) { return combineClassPatterns( current.elements.map((element) => patternsFor(element, seen)), @@ -1328,6 +1354,97 @@ export function analyzeCkbV2ClassUsage(relativePath, sourceText, { elementName = }); return combineClassPatterns(classKeys, " "); } + return null; + } + + function replacementCallPatterns(call, seen) { + const receivers = patternsFor(call.expression.expression, seen); + const searches = call.arguments[0] + ? patternsFor(call.arguments[0], seen) + : [{ value: UNKNOWN_CLASS_FRAGMENT, constructed: false }]; + const replacements = call.arguments[1] + ? patternsFor(call.arguments[1], seen) + : [{ value: "undefined", constructed: false }]; + const results = []; + for (const receiver of receivers) { + for (const search of searches) { + for (const replacement of replacements) { + const resolvable = ![receiver.value, search.value, replacement.value].some((value) => + value.includes(UNKNOWN_CLASS_FRAGMENT), + ); + results.push({ + value: resolvable + ? receiver.value.replace(search.value, replacement.value) + : unresolvedCallHasCkbV2Evidence(call) + ? "ckb-v2" + : UNKNOWN_CLASS_FRAGMENT, + constructed: true, + }); + if (results.length >= MAX_CLASS_PATTERNS) return results; + } + } + } + return results; + } + + function callClassPatterns(call, seen) { + if (ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === "replace") + return replacementCallPatterns(call, seen); + if (ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === "join") { + const elements = arrayElements(call.expression.expression, seen); + if (elements) { + const separators = call.arguments[0] + ? patternsFor(call.arguments[0], seen) + : [{ value: ",", constructed: false }]; + const results = []; + for (const separator of separators) { + results.push( + ...combineClassPatterns( + elements.map((element) => patternsFor(element, seen)), + separator.value, + true, + ), + ); + if (results.length >= MAX_CLASS_PATTERNS) break; + } + return results.slice(0, MAX_CLASS_PATTERNS); + } + } + const helperName = ts.isIdentifier(call.expression) + ? call.expression.text + : ts.isPropertyAccessExpression(call.expression) + ? call.expression.name.text + : ""; + if (CLASS_NAME_HELPERS.has(helperName)) { + return combineClassPatterns( + call.arguments.map((argument) => patternsFor(argument, seen)), + " ", + ); + } + const hasCkbV2Evidence = unresolvedCallHasCkbV2Evidence(call); + return [ + { + value: hasCkbV2Evidence ? "ckb-v2" : UNKNOWN_CLASS_FRAGMENT, + constructed: hasCkbV2Evidence, + }, + ]; + } + + function patternsFor(expression, seen = new Set()) { + const current = unwrapExpression(expression); + if (ts.isStringLiteralLike(current)) return [{ value: current.text, constructed: false }]; + if (ts.isNumericLiteral(current)) return [{ value: current.text, constructed: false }]; + if (current.kind === ts.SyntaxKind.TrueKeyword || current.kind === ts.SyntaxKind.FalseKeyword) + return [{ value: "", constructed: false }]; + if (ts.isIdentifier(current)) { + const binding = resolveBinding(current); + if (!binding?.declaration.initializer || seen.has(binding.declaration)) + return [{ value: UNKNOWN_CLASS_FRAGMENT, constructed: false }]; + return patternsFor(binding.declaration.initializer, new Set(seen).add(binding.declaration)); + } + const compositePatterns = compositeClassPatterns(current, seen); + if (compositePatterns) return compositePatterns; + if (ts.isCallExpression(current)) return callClassPatterns(current, seen); return [{ value: UNKNOWN_CLASS_FRAGMENT, constructed: false }]; } @@ -1523,24 +1640,48 @@ function surfaceRootFiles(surface) { .sort(); } +function componentEvidenceIndexes(componentNames, sourceFiles, testFiles, scan) { + const directImports = new Map(componentNames.map((name) => [name, []])); + const tests = new Map(componentNames.map((name) => [name, []])); + for (const file of sourceFiles) { + for (const name of importFacts(file, scan)) directImports.get(name)?.push(file); + } + const testMatchers = componentNames.map((name) => [name, new RegExp(`\\b${name}\\b`)]); + for (const file of testFiles) { + const text = scan.read(file); + for (const [name, matcher] of testMatchers) if (matcher.test(text)) tests.get(name).push(file); + } + return { directImports, tests }; +} + export function buildAdoptionManifest({ root = ROOT } = {}) { - const contract = JSON.parse(read(CONTRACT_PATH, root)); - const config = JSON.parse(read(CONFIG_PATH, root)); + const config = JSON.parse(fs.readFileSync(path.join(root, CONFIG_PATH), "utf8")); const sourceMap = config.componentSrcMap ?? {}; + // Keep text, AST and derived facts within this operation. A later build must + // observe source/config edits, and one config must never reuse another's imports. + const scan = createSourceScan(root, sourceMap); + const contract = JSON.parse(scan.read(CONTRACT_PATH)); // Directory enumeration order differs between Windows and Linux. Sort the // scanner inputs before filtering so generated import/test evidence is // byte-identical on developer machines and hosted CI. const sourceFiles = walk(path.join(root, "src"), root).sort(); const testFiles = walk(path.join(root, "tests"), root).sort(); - const entry = read(".design-sync/entry.tsx", root); + const appFiles = sourceFiles.filter((file) => file.startsWith("src/app/")); + const nextUiEntries = nextUiEntriesFromFiles(appFiles); + const componentNames = Object.keys(contract.componentFamilies).sort(); + const evidence = componentEvidenceIndexes(componentNames, sourceFiles, testFiles, scan); + const exportsBySource = new Map(); + const entry = scan.read(".design-sync/entry.tsx"); const globalShellDeclaration = contract.globalShellRoot ?? { file: "src/app/layout.tsx", element: "html", }; - const globalShellSource = exists(globalShellDeclaration.file, root) ? read(globalShellDeclaration.file, root) : ""; - const globalShellUsage = analyzeCkbV2ClassUsage(globalShellDeclaration.file, globalShellSource, { - elementName: globalShellDeclaration.element, - }); + const globalShellSource = exists(globalShellDeclaration.file, root) ? scan.read(globalShellDeclaration.file) : ""; + const globalShellUsage = globalShellSource + ? analyzeCkbV2Source(sourceFile(globalShellDeclaration.file, scan), globalShellDeclaration.element) + : analyzeCkbV2ClassUsage(globalShellDeclaration.file, globalShellSource, { + elementName: globalShellDeclaration.element, + }); const globalShell = { file: globalShellDeclaration.file, element: globalShellDeclaration.element, @@ -1548,53 +1689,50 @@ export function buildAdoptionManifest({ root = ROOT } = {}) { ...globalShellUsage, observedShellState: globalShellUsage.literalCkbV2 ? "v2" : "compatibility", }; - const productionReachableFiles = reachableSourceFiles( - [globalShell.file, ...productionNextUiEntries(root), ...contract.productionSurfaces.flatMap(surfaceRootFiles)], - { root }, + const productionReachableFiles = reachableFilesForScan( + [globalShell.file, ...nextUiEntries, ...contract.productionSurfaces.flatMap(surfaceRootFiles)], + scan, ); - const componentInventory = Object.keys(contract.componentFamilies) - .sort() - .map((name) => { - const source = sourceMap[name] ?? null; - const sourceExports = source ? exportedNames(source, root) : []; - const directImportFiles = sourceFiles.filter((file) => importFacts(file, sourceMap, root).includes(name)); - const productImportFiles = directImportFiles.filter((file) => productionReachableFiles.has(file)); - const preview = `.design-sync/previews/${name}.tsx`; - const previewValid = - exists(preview, root) && - new RegExp(`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*["']${config.pkg}["']`).test( - read(preview, root), - ); - const testMatches = testFiles.filter((file) => new RegExp(`\\b${name}\\b`).test(read(file, root))); - const entryExported = source - ? (source === "src/components/ui-primitives.tsx" && - /export \* from ["']@\/components\/ui-primitives["']/.test(entry)) || - new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`).test(entry) - : false; - return { - name, - family: contract.componentFamilies[name], - source, - sourceExported: sourceExports.includes(name), - entryExported, - directImportFiles, - productImportFiles, - designSync: { - listedInSourceMap: Boolean(source), - listedInDtsProps: Object.hasOwn(config.dtsPropsFor ?? {}, name), - preview, - previewValid, - }, - testFiles: testMatches, - baseline: contract.baseline, - }; - }); + const componentInventory = componentNames.map((name) => { + const source = sourceMap[name] ?? null; + // Cache only complete top-level traversals: memoizing a nested traversal + // could retain a partial export set when barrels contain cycles. + if (source && !exportsBySource.has(source)) exportsBySource.set(source, new Set(exportedNames(source, scan))); + const directImportFiles = evidence.directImports.get(name); + const productImportFiles = directImportFiles.filter((file) => productionReachableFiles.has(file)); + const preview = `.design-sync/previews/${name}.tsx`; + const previewValid = + exists(preview, root) && + new RegExp(`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*["']${config.pkg}["']`).test(scan.read(preview)); + const entryExported = source + ? (source === "src/components/ui-primitives.tsx" && + /export \* from ["']@\/components\/ui-primitives["']/.test(entry)) || + new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`).test(entry) + : false; + return { + name, + family: contract.componentFamilies[name], + source, + sourceExported: Boolean(source && exportsBySource.get(source).has(name)), + entryExported, + directImportFiles, + productImportFiles, + designSync: { + listedInSourceMap: Boolean(source), + listedInDtsProps: Object.hasOwn(config.dtsPropsFor ?? {}, name), + preview, + previewValid, + }, + testFiles: evidence.tests.get(name), + baseline: contract.baseline, + }; + }); const surfaces = contract.productionSurfaces.map((surface) => { const rootFiles = surfaceRootFiles(surface); const nonVisualContract = contract.nonVisualRouteContracts?.[surface.id] ?? null; const nonVisualSource = nonVisualContract?.route && exists(nonVisualContract.route, root) - ? analyzeNextRedirectOnlyRoute(nonVisualContract.route, read(nonVisualContract.route, root)) + ? analyzeNextRedirectOnlyRoute(nonVisualContract.route, scan.read(nonVisualContract.route)) : null; const nonVisualTopologyMatches = Boolean( nonVisualContract && @@ -1606,10 +1744,12 @@ export function buildAdoptionManifest({ root = ROOT } = {}) { nonVisualSource?.redirectOnly, ); const roots = rootFiles.map((rootFile) => { - const sourceText = exists(rootFile, root) ? read(rootFile, root) : ""; - const imports = importFacts(rootFile, sourceMap, root); + const sourceText = exists(rootFile, root) ? scan.read(rootFile) : ""; + const imports = importFacts(rootFile, scan); const importedFamilies = [...new Set(imports.map((name) => contract.componentFamilies[name]))].sort(); - const { literalCkbV2, dynamicCkbV2 } = analyzeCkbV2ClassUsage(rootFile, sourceText); + const { literalCkbV2, dynamicCkbV2 } = sourceText + ? analyzeCkbV2Source(sourceFile(rootFile, scan)) + : analyzeCkbV2ClassUsage(rootFile, sourceText); return { file: rootFile, exists: Boolean(sourceText), @@ -1683,7 +1823,7 @@ export function buildAdoptionManifest({ root = ROOT } = {}) { const inheritedCkbV2RootCount = surfaces .flatMap((surface) => surface.roots) .filter((rootFact) => rootFact.v2MountMode === "inherited-global-root").length; - const discoveredRoutes = productionPageRoutes(root); + const discoveredRoutes = pageRoutesFromFiles(appFiles); const routeOwners = new Map(); for (const surface of contract.productionSurfaces) { for (const route of surface.routes) { @@ -1732,13 +1872,12 @@ export function buildAdoptionManifest({ root = ROOT } = {}) { productImportedComponentCount: components.filter((component) => component.productImportFiles.length > 0).length, rootCount: surfaces.flatMap((surface) => surface.roots).length, productionRouteCount: discoveredRoutes.length, - nextUiEntryCount: productionNextUiEntries(root).length, + nextUiEntryCount: nextUiEntries.length, }, }; } -export function checkAdoptionManifest(manifest, { root = ROOT, trackedFiles = trackedFilesForRoot(root) } = {}) { - const contract = JSON.parse(read(CONTRACT_PATH, root)); +function validateAdoptionContractBindings(manifest, contract, { root }) { const failures = []; const globalShellDeclaration = contract.globalShellRoot ?? {}; if (JSON.stringify(globalShellDeclaration) !== JSON.stringify(CANONICAL_GLOBAL_SHELL_ROOT)) @@ -1768,18 +1907,27 @@ export function checkAdoptionManifest(manifest, { root = ROOT, trackedFiles = tr }; if (JSON.stringify(manifest.proofPolicy) !== JSON.stringify(expectedProofPolicy)) failures.push("generated proof policy drifted from the adoption contract"); - if (!manifest.globalShell || typeof manifest.globalShell !== "object") { + return failures; +} + +function validateGlobalShellObservation(globalShell, globalShellDeclaration) { + const failures = []; + if (!globalShell || typeof globalShell !== "object") { failures.push("global shell observation is missing"); - } else { - if (!manifest.globalShell.exists) failures.push(`global shell root is missing: ${globalShellDeclaration.file}`); - if (manifest.globalShell.file !== globalShellDeclaration.file) - failures.push("global shell root drifted from adoption contract"); - if (manifest.globalShell.element !== globalShellDeclaration.element) - failures.push("global shell element drifted from adoption contract"); - if (manifest.globalShell.dynamicCkbV2) - failures.push(`global shell dynamically constructs ckb-v2: ${manifest.globalShell.file}`); - } - for (const component of manifest.components) { + return failures; + } + if (!globalShell.exists) failures.push(`global shell root is missing: ${globalShellDeclaration.file}`); + if (globalShell.file !== globalShellDeclaration.file) + failures.push("global shell root drifted from adoption contract"); + if (globalShell.element !== globalShellDeclaration.element) + failures.push("global shell element drifted from adoption contract"); + if (globalShell.dynamicCkbV2) failures.push(`global shell dynamically constructs ckb-v2: ${globalShell.file}`); + return failures; +} + +function validateComponentAdoptionFacts(components) { + const failures = []; + for (const component of components) { for (const fact of ["built", "locallyRegistered", "v2ShellMounted", "proofDeclared", "baselineCommitted"]) if (typeof component[fact] !== "boolean") failures.push(`${component.name} is missing boolean ${fact} fact`); if (!new Set(["none", "inherited-global-root", "direct-surface"]).has(component.v2MountMode)) @@ -1793,6 +1941,203 @@ export function checkAdoptionManifest(manifest, { root = ROOT, trackedFiles = tr if (!component.designSync.previewValid) failures.push(`${component.name} is missing a valid design-sync preview`); if (component.testFiles.length === 0) failures.push(`${component.name} has no direct test proof`); } + return failures; +} + +function validateSurfaceContractAndShell(surface, contractSurface) { + const failures = []; + if (!contractSurface) { + failures.push(`generated adoption surface is undeclared: ${surface.id}`); + } else { + if (surface.disposition !== contractSurface.disposition) + failures.push(`${surface.id} disposition drifted from the adoption contract`); + if (JSON.stringify(surface.routes) !== JSON.stringify([...contractSurface.routes].sort())) + failures.push(`${surface.id} routes drifted from the adoption contract`); + if (surface.routeRoots !== Boolean(contractSurface.routeRoots)) + failures.push(`${surface.id} route-root ownership drifted from the adoption contract`); + const expectedRootFiles = surfaceRootFiles(contractSurface); + const observedRootFiles = surface.roots.map((rootFact) => rootFact.file).sort(); + if (JSON.stringify(observedRootFiles) !== JSON.stringify(expectedRootFiles)) + failures.push(`${surface.id} roots drifted from the adoption contract`); + if (surface.documentedDisposition !== (contractSurface.documentedDisposition ?? null)) + failures.push(`${surface.id} documented disposition drifted from the adoption contract`); + } + if (!new Set(["compatibility", "v2"]).has(surface.declaredShellState)) + failures.push(`${surface.id} has invalid declared shell state: ${String(surface.declaredShellState)}`); + if (!new Set(["compatibility", "v2"]).has(surface.observedShellState)) + failures.push(`${surface.id} has invalid observed shell state: ${String(surface.observedShellState)}`); + if (!V2_MOUNT_MODES.has(surface.v2MountMode)) + failures.push(`${surface.id} has invalid v2 mount mode: ${String(surface.v2MountMode)}`); + if (surface.v2ShellMounted !== (surface.observedShellState === "v2")) + failures.push(`${surface.id} observed shell state and mount boolean disagree`); + if (surface.declaredShellState === "compatibility" && surface.v2ShellMounted) { + const source = + surface.v2MountMode === "inherited-global-root" + ? `global root ${surface.inheritedFrom}` + : `direct root ${surface.directV2MountFiles.join(", ")}`; + failures.push(`${surface.id} declares compatibility but observes v2 through ${source}`); + } + if (surface.declaredShellState === "v2" && !surface.v2ShellMounted) + failures.push(`${surface.id} declares v2 but no literal source mount was observed`); + return failures; +} + +function validateSurfaceProofTopology(surface, contractSurface, contract, { root }) { + const failures = []; + const nonVisualContract = contract.nonVisualRouteContracts?.[surface.id] ?? null; + const nonVisualSource = + nonVisualContract?.route && exists(nonVisualContract.route, root) + ? analyzeNextRedirectOnlyRoute(nonVisualContract.route, read(nonVisualContract.route, root)) + : null; + const nonVisualTopologyMatches = Boolean( + contractSurface && + nonVisualContract && + nonVisualContract.kind === "next-redirect-only" && + contractSurface.routes.length === 1 && + contractSurface.routes[0] === nonVisualContract.route && + !contractSurface.routeRoots && + (contractSurface.roots ?? []).length === 0 && + nonVisualSource?.redirectOnly, + ); + const expectedProofApplicability = nonVisualTopologyMatches + ? "not-applicable" + : CANONICAL_DEFAULT_PROOF_APPLICABILITY; + if (!PROOF_APPLICABILITY.has(surface.proofApplicability)) + failures.push(`${surface.id} has invalid proof applicability: ${String(surface.proofApplicability)}`); + if (surface.proofApplicability !== expectedProofApplicability) + failures.push(`${surface.id} proof applicability disagrees with its source-derived route topology`); + const proofNotApplicable = nonVisualTopologyMatches && surface.proofApplicability === "not-applicable"; + if (surface.proofApplicability === "not-applicable" && !nonVisualTopologyMatches) + failures.push(`${surface.id} may omit visual proof only when the pinned route is statically redirect-only`); + if (nonVisualContract && !nonVisualSource?.redirectOnly) + failures.push(`${surface.id} non-visual route source is not statically redirect-only`); + return { failures, proofNotApplicable }; +} + +function validateSurfaceProofDeclarations(surface, contract, proofNotApplicable, { root, trackedFiles }) { + const failures = []; + const proof = surface.proof && typeof surface.proof === "object" ? surface.proof : {}; + const requiredProofCategories = Array.isArray(contract.requiredProofCategories) + ? contract.requiredProofCategories + : []; + for (const category of requiredProofCategories) { + const declaration = proof[category]; + if (!declaration || typeof declaration !== "object") { + failures.push(`${surface.id} surface proof is missing ${category}`); + continue; + } + if (!SURFACE_PROOF_STATUSES.has(declaration.status)) + failures.push(`${surface.id} ${category} proof has invalid status: ${String(declaration.status)}`); + const validEvidence = + Array.isArray(declaration.evidence) && declaration.evidence.every((entry) => typeof entry === "string"); + if (!validEvidence) failures.push(`${surface.id} ${category} proof evidence must be a string array`); + if (declaration.status === "passed" && (!validEvidence || declaration.evidence.length === 0)) + failures.push(`${surface.id} ${category} proof is passed without evidence`); + if (declaration.status === "passed" && validEvidence) { + for (const evidencePath of declaration.evidence) { + for (const reason of validateAdoptionArtifactPath(evidencePath, { root, trackedFiles, kind: "proof" })) + failures.push(`${surface.id} ${category} proof evidence ${evidencePath}: ${reason}`); + } + } + if ( + proofNotApplicable && + (declaration.status !== "not-applicable" || !validEvidence || declaration.evidence.length !== 0) + ) + failures.push(`${surface.id} ${category} proof must remain not-applicable without evidence`); + if (!proofNotApplicable && declaration.status === "not-applicable") + failures.push(`${surface.id} ${category} proof is required for its visual disposition`); + if (surface.declaredShellState === "v2" && !proofNotApplicable && declaration.status !== "passed") + failures.push(`${surface.id} v2 adoption requires passed ${category} proof`); + } + for (const category of Object.keys(proof)) { + if (!requiredProofCategories.includes(category)) + failures.push(`${surface.id} declares unknown surface proof category: ${category}`); + } + return failures; +} + +function validateSurfaceBaseline(surface, proofNotApplicable, { root, trackedFiles }) { + const failures = []; + const committedBaselinePaths = []; + if (!surface.baseline || typeof surface.baseline !== "object") { + failures.push(`${surface.id} surface baseline declaration is missing`); + return { failures, committedBaselinePaths }; + } + if (!BASELINE_STATUSES.has(surface.baseline.status)) + failures.push(`${surface.id} surface baseline has invalid status: ${String(surface.baseline.status)}`); + const validBaselineFiles = + Array.isArray(surface.baseline.files) && surface.baseline.files.every((entry) => typeof entry === "string"); + if (!validBaselineFiles) failures.push(`${surface.id} surface baseline files must be a string array`); + if (surface.baseline.status === "committed" && (!validBaselineFiles || surface.baseline.files.length === 0)) + failures.push(`${surface.id} surface baseline is committed without files`); + if (surface.baseline.status === "not-committed" && validBaselineFiles && surface.baseline.files.length !== 0) + failures.push( + `${surface.id} surface baseline is not-committed but lists files; keep files empty until screenshots are committed`, + ); + if (surface.baseline.status === "committed" && validBaselineFiles) { + committedBaselinePaths.push(...surface.baseline.files); + for (const baselinePath of surface.baseline.files) { + for (const reason of validateAdoptionArtifactPath(baselinePath, { + root, + trackedFiles, + kind: "visual-baseline", + })) + failures.push(`${surface.id} surface baseline ${baselinePath}: ${reason}`); + } + } + if ( + proofNotApplicable && + (surface.baseline.status !== "not-applicable" || !validBaselineFiles || surface.baseline.files.length !== 0) + ) + failures.push(`${surface.id} surface baseline must remain not-applicable without files`); + if (!proofNotApplicable && surface.baseline.status === "not-applicable") + failures.push(`${surface.id} visual disposition requires a surface baseline`); + // Draft/CI path: declared v2 may remain not-committed with empty files until + // human-approved Linux screenshots + provenance are committed. Only + // not-applicable is forbidden for visual v2 surfaces. + if (surface.declaredShellState === "v2" && !proofNotApplicable && surface.baseline.status === "not-applicable") + failures.push(`${surface.id} v2 adoption requires a visual baseline (committed or not-committed)`); + return { failures, committedBaselinePaths }; +} + +function validateSurfaceRootsAndDisposition(surface) { + const failures = []; + for (const rootFact of surface.roots) { + if (!rootFact.exists) failures.push(`${surface.id} root is missing: ${rootFact.file}`); + if (rootFact.dynamicCkbV2) failures.push(`${surface.id} root dynamically constructs ckb-v2: ${rootFact.file}`); + for (const family of rootFact.importedFamilies) { + if (!surface.permittedComponentFamilies.includes(family)) + failures.push(`${surface.id} imports an unpermitted ${family} component`); + } + } + if (surface.disposition !== "owned" && !surface.documentedDisposition) + failures.push(`${surface.id} ${surface.disposition} disposition is undocumented`); + return failures; +} + +function validateAdoptionSurface(surface, contractSurface, contract, { root, trackedFiles }) { + const failures = validateSurfaceContractAndShell(surface, contractSurface); + const proofTopology = validateSurfaceProofTopology(surface, contractSurface, contract, { root }); + failures.push(...proofTopology.failures); + failures.push( + ...validateSurfaceProofDeclarations(surface, contract, proofTopology.proofNotApplicable, { root, trackedFiles }), + ); + const baselineValidation = validateSurfaceBaseline(surface, proofTopology.proofNotApplicable, { + root, + trackedFiles, + }); + failures.push(...baselineValidation.failures); + failures.push(...validateSurfaceRootsAndDisposition(surface)); + return { failures, committedBaselinePaths: baselineValidation.committedBaselinePaths }; +} + +export function checkAdoptionManifest(manifest, { root = ROOT, trackedFiles = trackedFilesForRoot(root) } = {}) { + const contract = JSON.parse(read(CONTRACT_PATH, root)); + const failures = []; + const globalShellDeclaration = contract.globalShellRoot ?? {}; + failures.push(...validateAdoptionContractBindings(manifest, contract, { root })); + failures.push(...validateGlobalShellObservation(manifest.globalShell, globalShellDeclaration)); + failures.push(...validateComponentAdoptionFacts(manifest.components)); const contractSurfacesById = new Map(contract.productionSurfaces.map((surface) => [surface.id, surface])); const manifestSurfaceIds = manifest.surfaces.map((surface) => surface.id); if (new Set(manifestSurfaceIds).size !== manifestSurfaceIds.length) @@ -1803,151 +2148,9 @@ export function checkAdoptionManifest(manifest, { root = ROOT, trackedFiles = tr const committedBaselinePaths = []; for (const surface of manifest.surfaces) { const contractSurface = contractSurfacesById.get(surface.id); - if (!contractSurface) { - failures.push(`generated adoption surface is undeclared: ${surface.id}`); - } else { - if (surface.disposition !== contractSurface.disposition) - failures.push(`${surface.id} disposition drifted from the adoption contract`); - if (JSON.stringify(surface.routes) !== JSON.stringify([...contractSurface.routes].sort())) - failures.push(`${surface.id} routes drifted from the adoption contract`); - if (surface.routeRoots !== Boolean(contractSurface.routeRoots)) - failures.push(`${surface.id} route-root ownership drifted from the adoption contract`); - const expectedRootFiles = surfaceRootFiles(contractSurface); - const observedRootFiles = surface.roots.map((rootFact) => rootFact.file).sort(); - if (JSON.stringify(observedRootFiles) !== JSON.stringify(expectedRootFiles)) - failures.push(`${surface.id} roots drifted from the adoption contract`); - if (surface.documentedDisposition !== (contractSurface.documentedDisposition ?? null)) - failures.push(`${surface.id} documented disposition drifted from the adoption contract`); - } - if (!new Set(["compatibility", "v2"]).has(surface.declaredShellState)) - failures.push(`${surface.id} has invalid declared shell state: ${String(surface.declaredShellState)}`); - if (!new Set(["compatibility", "v2"]).has(surface.observedShellState)) - failures.push(`${surface.id} has invalid observed shell state: ${String(surface.observedShellState)}`); - if (!V2_MOUNT_MODES.has(surface.v2MountMode)) - failures.push(`${surface.id} has invalid v2 mount mode: ${String(surface.v2MountMode)}`); - if (surface.v2ShellMounted !== (surface.observedShellState === "v2")) - failures.push(`${surface.id} observed shell state and mount boolean disagree`); - if (surface.declaredShellState === "compatibility" && surface.v2ShellMounted) { - const source = - surface.v2MountMode === "inherited-global-root" - ? `global root ${surface.inheritedFrom}` - : `direct root ${surface.directV2MountFiles.join(", ")}`; - failures.push(`${surface.id} declares compatibility but observes v2 through ${source}`); - } - if (surface.declaredShellState === "v2" && !surface.v2ShellMounted) - failures.push(`${surface.id} declares v2 but no literal source mount was observed`); - const nonVisualContract = contract.nonVisualRouteContracts?.[surface.id] ?? null; - const nonVisualSource = - nonVisualContract?.route && exists(nonVisualContract.route, root) - ? analyzeNextRedirectOnlyRoute(nonVisualContract.route, read(nonVisualContract.route, root)) - : null; - const nonVisualTopologyMatches = Boolean( - contractSurface && - nonVisualContract && - nonVisualContract.kind === "next-redirect-only" && - contractSurface.routes.length === 1 && - contractSurface.routes[0] === nonVisualContract.route && - !contractSurface.routeRoots && - (contractSurface.roots ?? []).length === 0 && - nonVisualSource?.redirectOnly, - ); - const expectedProofApplicability = nonVisualTopologyMatches - ? "not-applicable" - : CANONICAL_DEFAULT_PROOF_APPLICABILITY; - if (!PROOF_APPLICABILITY.has(surface.proofApplicability)) - failures.push(`${surface.id} has invalid proof applicability: ${String(surface.proofApplicability)}`); - if (surface.proofApplicability !== expectedProofApplicability) - failures.push(`${surface.id} proof applicability disagrees with its source-derived route topology`); - const proofNotApplicable = nonVisualTopologyMatches && surface.proofApplicability === "not-applicable"; - if (surface.proofApplicability === "not-applicable" && !nonVisualTopologyMatches) - failures.push(`${surface.id} may omit visual proof only when the pinned route is statically redirect-only`); - if (nonVisualContract && !nonVisualSource?.redirectOnly) - failures.push(`${surface.id} non-visual route source is not statically redirect-only`); - const proof = surface.proof && typeof surface.proof === "object" ? surface.proof : {}; - const requiredProofCategories = Array.isArray(contract.requiredProofCategories) - ? contract.requiredProofCategories - : []; - for (const category of requiredProofCategories) { - const declaration = proof[category]; - if (!declaration || typeof declaration !== "object") { - failures.push(`${surface.id} surface proof is missing ${category}`); - continue; - } - if (!SURFACE_PROOF_STATUSES.has(declaration.status)) - failures.push(`${surface.id} ${category} proof has invalid status: ${String(declaration.status)}`); - const validEvidence = - Array.isArray(declaration.evidence) && declaration.evidence.every((entry) => typeof entry === "string"); - if (!validEvidence) failures.push(`${surface.id} ${category} proof evidence must be a string array`); - if (declaration.status === "passed" && (!validEvidence || declaration.evidence.length === 0)) - failures.push(`${surface.id} ${category} proof is passed without evidence`); - if (declaration.status === "passed" && validEvidence) { - for (const evidencePath of declaration.evidence) { - for (const reason of validateAdoptionArtifactPath(evidencePath, { root, trackedFiles, kind: "proof" })) - failures.push(`${surface.id} ${category} proof evidence ${evidencePath}: ${reason}`); - } - } - if ( - proofNotApplicable && - (declaration.status !== "not-applicable" || !validEvidence || declaration.evidence.length !== 0) - ) - failures.push(`${surface.id} ${category} proof must remain not-applicable without evidence`); - if (!proofNotApplicable && declaration.status === "not-applicable") - failures.push(`${surface.id} ${category} proof is required for its visual disposition`); - if (surface.declaredShellState === "v2" && !proofNotApplicable && declaration.status !== "passed") - failures.push(`${surface.id} v2 adoption requires passed ${category} proof`); - } - for (const category of Object.keys(proof)) { - if (!requiredProofCategories.includes(category)) - failures.push(`${surface.id} declares unknown surface proof category: ${category}`); - } - if (!surface.baseline || typeof surface.baseline !== "object") { - failures.push(`${surface.id} surface baseline declaration is missing`); - } else { - if (!BASELINE_STATUSES.has(surface.baseline.status)) - failures.push(`${surface.id} surface baseline has invalid status: ${String(surface.baseline.status)}`); - const validBaselineFiles = - Array.isArray(surface.baseline.files) && surface.baseline.files.every((entry) => typeof entry === "string"); - if (!validBaselineFiles) failures.push(`${surface.id} surface baseline files must be a string array`); - if (surface.baseline.status === "committed" && (!validBaselineFiles || surface.baseline.files.length === 0)) - failures.push(`${surface.id} surface baseline is committed without files`); - if (surface.baseline.status === "not-committed" && validBaselineFiles && surface.baseline.files.length !== 0) - failures.push( - `${surface.id} surface baseline is not-committed but lists files; keep files empty until screenshots are committed`, - ); - if (surface.baseline.status === "committed" && validBaselineFiles) { - committedBaselinePaths.push(...surface.baseline.files); - for (const baselinePath of surface.baseline.files) { - for (const reason of validateAdoptionArtifactPath(baselinePath, { - root, - trackedFiles, - kind: "visual-baseline", - })) - failures.push(`${surface.id} surface baseline ${baselinePath}: ${reason}`); - } - } - if ( - proofNotApplicable && - (surface.baseline.status !== "not-applicable" || !validBaselineFiles || surface.baseline.files.length !== 0) - ) - failures.push(`${surface.id} surface baseline must remain not-applicable without files`); - if (!proofNotApplicable && surface.baseline.status === "not-applicable") - failures.push(`${surface.id} visual disposition requires a surface baseline`); - // Draft/CI path: declared v2 may remain not-committed with empty files until - // human-approved Linux screenshots + provenance are committed. Only - // not-applicable is forbidden for visual v2 surfaces. - if (surface.declaredShellState === "v2" && !proofNotApplicable && surface.baseline.status === "not-applicable") - failures.push(`${surface.id} v2 adoption requires a visual baseline (committed or not-committed)`); - } - for (const rootFact of surface.roots) { - if (!rootFact.exists) failures.push(`${surface.id} root is missing: ${rootFact.file}`); - if (rootFact.dynamicCkbV2) failures.push(`${surface.id} root dynamically constructs ckb-v2: ${rootFact.file}`); - for (const family of rootFact.importedFamilies) { - if (!surface.permittedComponentFamilies.includes(family)) - failures.push(`${surface.id} imports an unpermitted ${family} component`); - } - } - if (surface.disposition !== "owned" && !surface.documentedDisposition) - failures.push(`${surface.id} ${surface.disposition} disposition is undocumented`); + const surfaceValidation = validateAdoptionSurface(surface, contractSurface, contract, { root, trackedFiles }); + failures.push(...surfaceValidation.failures); + committedBaselinePaths.push(...surfaceValidation.committedBaselinePaths); } failures.push(...validateLinuxVisualBaselineSet(committedBaselinePaths, { root, trackedFiles })); for (const route of manifest.routeCoverage.undeclared) failures.push(`production page route is undeclared: ${route}`); diff --git a/scripts/ledger-inbox.mjs b/scripts/ledger-inbox.mjs index 0006fdb8d0..b47667216c 100644 --- a/scripts/ledger-inbox.mjs +++ b/scripts/ledger-inbox.mjs @@ -59,6 +59,69 @@ function readOutstandingIssues() { return readFileSync(path.join(ROOT, ISSUES_PATH), "utf8"); } +function validateFingerprint(payload, action, problems) { + if (payload?.baseRowFingerprint !== undefined && !isValidIssueRowFingerprint(payload.baseRowFingerprint)) { + problems.push(`${action} requires a valid baseRowFingerprint`); + } +} + +function validateAddRequest(request, problems) { + for (const field of ["pri", "type", "summary"]) { + if (!request.payload?.[field]) problems.push(`add requires ${field}`); + } + if (request.payload?.issueUlid !== undefined && !isIssueUlid(request.payload.issueUlid)) { + problems.push("add issueUlid must be a valid ULID"); + } + if (request.version === 2 && request.payload?.issueUlid === undefined) { + problems.push("version 2 add requires a valid issueUlid"); + } +} + +function validateDoneRequest(payload, problems) { + if (!isIssueDisplayId(payload?.id)) problems.push("done requires a canonical issue display id"); + if (!payload?.outcome) problems.push("done requires outcome"); + validateFingerprint(payload, "done", problems); +} + +function validateUpdateRequest(action, payload, problems) { + if (!isIssueDisplayId(payload?.id)) { + problems.push(`${action} requires a canonical issue display id`); + } + if (action === "amend-outcome") { + if (!payload?.outcome) problems.push("amend-outcome requires outcome"); + } else { + const openFields = ["pri", "summary", "detail", "source"].filter((field) => payload?.[field] !== undefined); + const hasOutcome = payload?.outcome !== undefined; + if (hasOutcome && openFields.length > 0) { + problems.push("update cannot mix outcome with pri, summary, detail, or source"); + } else if (!hasOutcome && openFields.length === 0) { + problems.push("update requires pri, summary, detail, source, or outcome"); + } + } + validateFingerprint(payload, action, problems); + if (payload?.pri !== undefined && !["P1", "P2", "P3"].includes(String(payload.pri))) { + problems.push("update pri must be P1, P2, or P3"); + } +} + +function validateQueueRequest(payload, problems) { + if (!isIssueDisplayId(payload?.id)) problems.push("queue requires a canonical issue display id"); + // A queue re-grade is usually exactly one cell (acuity), so requiring any + // one field rather than a prose field keeps the common correction — the + // #M6JNR8 instance — expressible without inventing filler text. + if (!QUEUE_FIELDS.some((field) => payload?.[field] !== undefined)) { + problems.push(`queue requires one of ${QUEUE_FIELDS.join(", ")}`); + } + validateFingerprint(payload, "queue", problems); +} + +function validateCancelRequest(payload, problems) { + if (!REQUEST_ID.test(payload?.requestId ?? "")) { + problems.push("cancel requires a pending request UUID"); + } + if (!String(payload?.reason ?? "").trim()) problems.push("cancel requires reason"); +} + export function validateRequest(request) { const problems = []; if (!request || typeof request !== "object") return ["request must be an object"]; @@ -67,149 +130,91 @@ export function validateRequest(request) { if (!/^\d{4}-\d{2}-\d{2}$/.test(request.createdOn ?? "")) problems.push("createdOn must be YYYY-MM-DD"); if (!ACTIONS.has(request.action)) problems.push("action must be add, done, update, queue, or cancel"); if (!request.payload || typeof request.payload !== "object") problems.push("payload must be an object"); - if (request.action === "add") { - for (const field of ["pri", "type", "summary"]) - if (!request.payload?.[field]) problems.push(`add requires ${field}`); - if (request.payload?.issueUlid !== undefined && !isIssueUlid(request.payload.issueUlid)) { - problems.push("add issueUlid must be a valid ULID"); - } - if (request.version === 2 && request.payload?.issueUlid === undefined) { - problems.push("version 2 add requires a valid issueUlid"); - } - } - if (request.action === "done") { - if (!isIssueDisplayId(request.payload?.id)) problems.push("done requires a canonical issue display id"); - if (!request.payload?.outcome) problems.push("done requires outcome"); - if ( - request.payload?.baseRowFingerprint !== undefined && - !isValidIssueRowFingerprint(request.payload.baseRowFingerprint) - ) - problems.push("done requires a valid baseRowFingerprint"); - } + if (request.action === "add") validateAddRequest(request, problems); + if (request.action === "done") validateDoneRequest(request.payload, problems); if (request.action === "update" || request.action === "amend-outcome") { - if (!isIssueDisplayId(request.payload?.id)) - problems.push(`${request.action} requires a canonical issue display id`); - if (request.action === "amend-outcome") { - if (!request.payload?.outcome) problems.push("amend-outcome requires outcome"); - } else { - const openFields = ["pri", "summary", "detail", "source"].filter( - (field) => request.payload?.[field] !== undefined, - ); - const hasOutcome = request.payload?.outcome !== undefined; - if (hasOutcome && openFields.length > 0) { - problems.push("update cannot mix outcome with pri, summary, detail, or source"); - } else if (!hasOutcome && openFields.length === 0) { - problems.push("update requires pri, summary, detail, source, or outcome"); - } - } - if ( - request.payload?.baseRowFingerprint !== undefined && - !isValidIssueRowFingerprint(request.payload.baseRowFingerprint) - ) - problems.push(`${request.action} requires a valid baseRowFingerprint`); - if (request.payload?.pri !== undefined && !["P1", "P2", "P3"].includes(String(request.payload.pri))) { - problems.push("update pri must be P1, P2, or P3"); - } - } - if (request.action === "queue") { - if (!isIssueDisplayId(request.payload?.id)) problems.push("queue requires a canonical issue display id"); - // A queue re-grade is usually exactly one cell (acuity), so requiring any - // one field rather than a prose field keeps the common correction — the - // #M6JNR8 instance — expressible without inventing filler text. - if (!QUEUE_FIELDS.some((field) => request.payload?.[field] !== undefined)) { - problems.push(`queue requires one of ${QUEUE_FIELDS.join(", ")}`); - } - if ( - request.payload?.baseRowFingerprint !== undefined && - !isValidIssueRowFingerprint(request.payload.baseRowFingerprint) - ) - problems.push("queue requires a valid baseRowFingerprint"); - } - if (request.action === "cancel") { - if (!REQUEST_ID.test(request.payload?.requestId ?? "")) { - problems.push("cancel requires a pending request UUID"); - } - if (!String(request.payload?.reason ?? "").trim()) problems.push("cancel requires reason"); + validateUpdateRequest(request.action, request.payload, problems); } + if (request.action === "queue") validateQueueRequest(request.payload, problems); + if (request.action === "cancel") validateCancelRequest(request.payload, problems); return problems; } -export function applyRequest(markdown, request, options = {}) { - const problems = validateRequest(request); - if (problems.length > 0) throw new Error(problems.join("; ")); - if (request.action === "cancel") { - throw new Error("cancel requests must be applied through batch reconciliation"); +function isOutcomeAmendment(request) { + return request.action === "amend-outcome" || (request.action === "update" && request.payload?.outcome); +} + +function findIssueRow(markdown, id, tables) { + const targetId = normalizeIssueDisplayId(id); + return parseIssues(markdown).rows.find( + (row) => normalizeIssueDisplayId(row.id) === targetId && tables.includes(row.table), + ); +} + +function assertOutcomeAmendmentTarget(markdown, request) { + if (!isOutcomeAmendment(request)) return; + const target = findIssueRow(markdown, request.payload.id, ["open", "archive"]); + if (target?.table === "open") { + throw new Error( + `${request.action} outcome amendments require an archived issue; ${request.payload.id} is still open`, + ); } - if (request.action === "amend-outcome" || (request.action === "update" && request.payload?.outcome)) { - const targetId = normalizeIssueDisplayId(request.payload.id); - const target = parseIssues(markdown).rows.find( - (row) => normalizeIssueDisplayId(row.id) === targetId && (row.table === "open" || row.table === "archive"), +} + +function assertCurrentQueueFingerprint(markdown, request) { + if (request.action !== "queue" || !request.payload?.baseRowFingerprint) return; + const id = request.payload.id; + const fingerprint = queueRowFingerprint(markdown, id); + if (!fingerprint) { + throw new Error( + `${id} no longer has exactly one queue row; reread and reissue this request from the latest ledger`, ); - if (target?.table === "open") { - throw new Error( - `${request.action} outcome amendments require an archived issue; ${request.payload.id} is still open`, - ); - } } - const dateOptions = { ...options, date: request.createdOn }; - if (request.action === "queue" && request.payload?.baseRowFingerprint) { - const id = request.payload.id; - const fingerprint = queueRowFingerprint(markdown, id); - if (!fingerprint) { - throw new Error( - `${id} no longer has exactly one queue row; reread and reissue this request from the latest ledger`, - ); - } - if (fingerprint !== String(request.payload.baseRowFingerprint).toLowerCase()) { - throw new Error( - `${id} queue row is stale: it changed after this request was queued; reread and reissue from the latest ledger`, - ); - } + if (fingerprint !== String(request.payload.baseRowFingerprint).toLowerCase()) { + throw new Error( + `${id} queue row is stale: it changed after this request was queued; reread and reissue from the latest ledger`, + ); } - const allowArchived = Boolean(options.allowArchived ?? request.payload?.allowArchived); - let autoAllowArchived = allowArchived; - // Do NOT auto-allow every done-against-archive: without explicit allowArchived - // (createRequest sets it) or a fingerprint race, archived targets must keep - // throwing (non-idempotent) or no-oping (idempotent) for ledger integrity. - if (options.idempotent && ["done", "update", "amend-outcome"].includes(request.action) && !autoAllowArchived) { - const id = request.payload?.id; - if (typeof id === "string") { - const parsed = parseIssues(markdown); - const targetId = normalizeIssueDisplayId(id); - const row = parsed.rows.find((r) => r.table === "archive" && normalizeIssueDisplayId(r.id) === targetId); - if (row) { - if ((request.action === "update" && !request.payload?.outcome) || request.action === "done") { - return markdown; - } - } - } +} + +function isArchivedIssue(markdown, id) { + return Boolean(findIssueRow(markdown, id, ["archive"])); +} + +function shouldKeepArchivedMarkdown(markdown, request, options, allowArchived) { + if (!options.idempotent || !["done", "update", "amend-outcome"].includes(request.action) || allowArchived) { + return false; } - if ( - (request.action === "done" || request.action === "update" || request.action === "amend-outcome") && - request.payload?.baseRowFingerprint - ) { - const id = request.payload.id; - let fingerprint = issueRowFingerprint(markdown, id); - if (!fingerprint) { - const parsed = parseIssues(markdown); - const targetId = normalizeIssueDisplayId(id); - const isArchived = parsed.rows.some((r) => r.table === "archive" && normalizeIssueDisplayId(r.id) === targetId); - if (isArchived) { - if (request.action === "done") { - autoAllowArchived = true; - } else { - fingerprint = archiveRowFingerprint(markdown, id); - } - } else { - throw new Error(`${id} is no longer open; reread and reissue this request from the latest ledger`); - } + const id = request.payload?.id; + if (typeof id !== "string" || !isArchivedIssue(markdown, id)) return false; + return (request.action === "update" && !request.payload?.outcome) || request.action === "done"; +} + +function verifyIssueFingerprint(markdown, request, allowArchived) { + if (!["done", "update", "amend-outcome"].includes(request.action) || !request.payload?.baseRowFingerprint) { + return allowArchived; + } + const id = request.payload.id; + let fingerprint = issueRowFingerprint(markdown, id); + if (!fingerprint) { + if (!isArchivedIssue(markdown, id)) { + throw new Error(`${id} is no longer open; reread and reissue this request from the latest ledger`); } - if (fingerprint && fingerprint !== String(request.payload.baseRowFingerprint).toLowerCase()) { - throw new Error( - `${id} is stale: the ledger row changed after this request was queued; reread and reissue from the latest ledger`, - ); + if (request.action === "done") { + allowArchived = true; + } else { + fingerprint = archiveRowFingerprint(markdown, id); } } + if (fingerprint && fingerprint !== String(request.payload.baseRowFingerprint).toLowerCase()) { + throw new Error( + `${id} is stale: the ledger row changed after this request was queued; reread and reissue from the latest ledger`, + ); + } + return allowArchived; +} + +function applyValidatedRequest(markdown, request, options, dateOptions, allowArchived) { if (request.action === "add") { const durableId = request.payload.issueUlid ?? issueUlidFromRequest(request.createdOn, request.id); return addIssue(markdown, request.payload, { ...dateOptions, issueUlid: durableId }); @@ -217,16 +222,13 @@ export function applyRequest(markdown, request, options = {}) { if (request.action === "done") { return resolveIssue(markdown, request.payload.id, request.payload.outcome, { ...dateOptions, - allowArchived: autoAllowArchived, + allowArchived, idempotent: Boolean(options.idempotent), }); } if (request.action === "queue") return updateQueueRow(markdown, request.payload.id, request.payload); - if (request.action === "amend-outcome" || (request.action === "update" && request.payload?.outcome)) { - const parsed = parseIssues(markdown); - const targetId = normalizeIssueDisplayId(request.payload.id); - const isArchived = parsed.rows.some((r) => r.table === "archive" && normalizeIssueDisplayId(r.id) === targetId); - if (isArchived) { + if (isOutcomeAmendment(request)) { + if (isArchivedIssue(markdown, request.payload.id)) { return updateArchivedIssue(markdown, request.payload.id, request.payload.outcome, { ...dateOptions, replace: Boolean(options.replace ?? request.payload.replace), @@ -237,24 +239,50 @@ export function applyRequest(markdown, request, options = {}) { return updateIssue(markdown, request.payload.id, request.payload); } -function mutationConflicts(requests) { +export function applyRequest(markdown, request, options = {}) { + const problems = validateRequest(request); + if (problems.length > 0) throw new Error(problems.join("; ")); + if (request.action === "cancel") { + throw new Error("cancel requests must be applied through batch reconciliation"); + } + assertOutcomeAmendmentTarget(markdown, request); + const dateOptions = { ...options, date: request.createdOn }; + assertCurrentQueueFingerprint(markdown, request); + const allowArchived = Boolean(options.allowArchived ?? request.payload?.allowArchived); + // Do NOT auto-allow every done-against-archive: without explicit allowArchived + // (createRequest sets it) or a fingerprint race, archived targets must keep + // throwing (non-idempotent) or no-oping (idempotent) for ledger integrity. + if (shouldKeepArchivedMarkdown(markdown, request, options, allowArchived)) return markdown; + const autoAllowArchived = verifyIssueFingerprint(markdown, request, allowArchived); + return applyValidatedRequest(markdown, request, options, dateOptions, autoAllowArchived); +} + +function mutationTargets(request) { + if (request.action === "add" || request.action === "cancel") return []; + const id = normalizeIssueDisplayId(request.payload.id); + // Key by the rows a request actually mutates. `update` changes only the + // Open-items row and `queue` changes only its queue row, so they can safely + // share a batch. `done` moves the Open-items row *and* prunes its queue + // citation, so it conflicts with either action on the same issue. + if (request.action === "done") return [id, `queue ${id}`]; + return [request.action === "queue" ? `queue ${id}` : id]; +} + +function requestsByMutationTarget(requests) { const byTarget = new Map(); for (const request of requests) { - if (request.action === "add" || request.action === "cancel") continue; - const id = normalizeIssueDisplayId(request.payload.id); - // Key by the rows a request actually mutates. `update` changes only the - // Open-items row and `queue` changes only its queue row, so they can safely - // share a batch. `done` moves the Open-items row *and* prunes its queue - // citation, so it conflicts with either action on the same issue. - const targets = request.action === "done" ? [id, `queue ${id}`] : [request.action === "queue" ? `queue ${id}` : id]; - for (const target of targets) { + for (const target of mutationTargets(request)) { const entries = byTarget.get(target) ?? []; entries.push(request); byTarget.set(target, entries); } } + return byTarget; +} + +function mutationConflicts(requests) { const conflicts = []; - for (const [target, targetRequests] of byTarget.entries()) { + for (const [target, targetRequests] of requestsByMutationTarget(requests).entries()) { if (targetRequests.length <= 1) continue; // Duplicate closes are batch-aware: after the first archives the row, later // closes append their outcomes. Archive amendments retain their original @@ -265,6 +293,67 @@ function mutationConflicts(requests) { return conflicts; } +function indexRequests(requests) { + const byId = new Map(); + for (const request of requests) { + const problems = validateRequest(request); + if (problems.length > 0) throw new Error(`${request?.id ?? "unknown request"}: ${problems.join("; ")}`); + if (byId.has(request.id)) throw new Error(`duplicate immutable request id: ${request.id}`); + byId.set(request.id, request); + } + return byId; +} + +function recordIneffectiveCancellation(request, appliedTarget, ineffective, warn) { + if (appliedTarget.action === "cancel") { + throw new Error(`cancel request ${request.id} cannot cancel another cancellation request`); + } + ineffective.push({ requestId: request.id, targetId: request.payload.requestId }); + warn( + `ledger inbox: cancel request ${request.id} did not take effect — its target ` + + `${request.payload.requestId} was already applied by an earlier reconciliation. ` + + `The cancellation is recorded but changed nothing; correct the affected row with a new update request.`, + ); +} + +function recordPendingCancellation(request, target, cancelledIds, cancellations) { + if (target.action === "cancel") { + throw new Error(`cancel request ${request.id} cannot cancel another cancellation request`); + } + if (cancelledIds.has(target.id)) { + throw new Error(`pending request ${target.id} is cancelled more than once`); + } + cancelledIds.add(target.id); + cancellations.push({ + requestId: request.id, + targetId: target.id, + reason: String(request.payload.reason).trim(), + }); +} + +function planCancellations(requests, byId, applied, warn) { + const cancelledIds = new Set(); + const cancellations = []; + const ineffective = []; + for (const request of requests) { + if (request.action !== "cancel") continue; + const target = byId.get(request.payload.requestId); + if (target) { + recordPendingCancellation(request, target, cancelledIds, cancellations); + continue; + } + const appliedTarget = applied.get(request.payload.requestId); + if (!appliedTarget) { + throw new Error(`cancel request ${request.id} targets missing pending request ${request.payload.requestId}`); + } + // Lost the race. Say so plainly: the correction this cancellation was + // protecting did NOT take effect, and whoever queued it needs to fix the + // row with a fresh update rather than assume the cancel did its job. + recordIneffectiveCancellation(request, appliedTarget, ineffective, warn); + } + return { cancelledIds, cancellations, ineffective }; +} + /** * Ids of requests that a previous reconciliation already applied. Used to tell a * cancellation that lost a race (its target landed via another branch's batch) @@ -302,52 +391,8 @@ function appliedRequestsById() { export function planRequestBatch(requests, options = {}) { const applied = options.appliedRequests ?? appliedRequestsById(); const warn = options.warn ?? ((message) => console.warn(message)); - const byId = new Map(); - for (const request of requests) { - const problems = validateRequest(request); - if (problems.length > 0) throw new Error(`${request?.id ?? "unknown request"}: ${problems.join("; ")}`); - if (byId.has(request.id)) throw new Error(`duplicate immutable request id: ${request.id}`); - byId.set(request.id, request); - } - - const cancelledIds = new Set(); - const cancellations = []; - const ineffective = []; - for (const request of requests) { - if (request.action !== "cancel") continue; - const target = byId.get(request.payload.requestId); - if (!target) { - const appliedTarget = applied.get(request.payload.requestId); - if (appliedTarget) { - if (appliedTarget.action === "cancel") { - throw new Error(`cancel request ${request.id} cannot cancel another cancellation request`); - } - // Lost the race. Say so plainly: the correction this cancellation was - // protecting did NOT take effect, and whoever queued it needs to fix the - // row with a fresh update rather than assume the cancel did its job. - ineffective.push({ requestId: request.id, targetId: request.payload.requestId }); - warn( - `ledger inbox: cancel request ${request.id} did not take effect — its target ` + - `${request.payload.requestId} was already applied by an earlier reconciliation. ` + - `The cancellation is recorded but changed nothing; correct the affected row with a new update request.`, - ); - continue; - } - throw new Error(`cancel request ${request.id} targets missing pending request ${request.payload.requestId}`); - } - if (target.action === "cancel") { - throw new Error(`cancel request ${request.id} cannot cancel another cancellation request`); - } - if (cancelledIds.has(target.id)) { - throw new Error(`pending request ${target.id} is cancelled more than once`); - } - cancelledIds.add(target.id); - cancellations.push({ - requestId: request.id, - targetId: target.id, - reason: String(request.payload.reason).trim(), - }); - } + const byId = indexRequests(requests); + const { cancelledIds, cancellations, ineffective } = planCancellations(requests, byId, applied, warn); const active = requests.filter((request) => request.action !== "cancel" && !cancelledIds.has(request.id)); const conflicts = mutationConflicts(active); @@ -475,52 +520,63 @@ function recoverReconcileTransaction(lockPath) { return true; } +function createReconcileLock(lockPath) { + mkdirSync(lockPath); + const owner = { + pid: process.pid, + startedAt: new Date().toISOString(), + purpose: "outstanding-issues reconciliation", + }; + writeFileSync(path.join(lockPath, "owner.json"), `${JSON.stringify(owner)}\n`, "utf8"); + return owner; +} + +function readReconcileLockOwner(lockPath) { + try { + return JSON.parse(readFileSync(path.join(lockPath, "owner.json"), "utf8")); + } catch { + // The directory creation is atomic, but owner.json is written immediately + // after it. Give an ownerless lock a short grace period before recovery so + // a concurrent process cannot be mistaken for a crashed one. + return null; + } +} + +function reconcileLockAge(lockPath) { + try { + return Math.max(0, Date.now() - statSync(lockPath).mtimeMs); + } catch { + throw new Error(`could not inspect reconciliation lock at ${lockPath}`); + } +} + +function recoverStaleReconcileLock(lockPath) { + const owner = readReconcileLockOwner(lockPath); + if (!owner && reconcileLockAge(lockPath) < OWNERLESS_LOCK_GRACE_MS) { + throw new Error(`reconciliation lock at ${lockPath} is still initializing; retry after it completes`); + } + if (owner && processIsAlive(owner.pid)) { + throw new Error( + `another reconciliation is active at ${lockPath}${owner?.pid ? ` (PID ${owner.pid})` : ""}; retry after it completes`, + ); + } + try { + recoverReconcileTransaction(lockPath); + } catch (recoveryError) { + throw new Error(`stale reconciliation requires recovery before retry: ${recoveryError.message}`); + } + rmSync(lockPath, { recursive: true, force: true }); +} + function acquireReconcileLock() { const lockPath = reconcileLockPath(); - const attempt = () => { - mkdirSync(lockPath); - const owner = { - pid: process.pid, - startedAt: new Date().toISOString(), - purpose: "outstanding-issues reconciliation", - }; - writeFileSync(path.join(lockPath, "owner.json"), `${JSON.stringify(owner)}\n`, "utf8"); - return owner; - }; try { - attempt(); + createReconcileLock(lockPath); } catch (error) { if (error?.code !== "EEXIST") throw error; - let owner = null; - try { - owner = JSON.parse(readFileSync(path.join(lockPath, "owner.json"), "utf8")); - } catch { - // The directory creation is atomic, but owner.json is written immediately - // after it. Give an ownerless lock a short grace period before recovery so - // a concurrent process cannot be mistaken for a crashed one. - } - let lockAgeMs = 0; - try { - lockAgeMs = Math.max(0, Date.now() - statSync(lockPath).mtimeMs); - } catch { - throw new Error(`could not inspect reconciliation lock at ${lockPath}`); - } - if (!owner && lockAgeMs < OWNERLESS_LOCK_GRACE_MS) { - throw new Error(`reconciliation lock at ${lockPath} is still initializing; retry after it completes`); - } - if (owner && processIsAlive(owner.pid)) { - throw new Error( - `another reconciliation is active at ${lockPath}${owner?.pid ? ` (PID ${owner.pid})` : ""}; retry after it completes`, - ); - } - try { - recoverReconcileTransaction(lockPath); - } catch (recoveryError) { - throw new Error(`stale reconciliation requires recovery before retry: ${recoveryError.message}`); - } - rmSync(lockPath, { recursive: true, force: true }); + recoverStaleReconcileLock(lockPath); try { - attempt(); + createReconcileLock(lockPath); } catch (retryError) { throw new Error(`could not acquire reconciliation lock at ${lockPath}: ${retryError.message}`); } @@ -565,45 +621,43 @@ function assertFreshReconciliationBase() { * }} [options] * @returns {Array<{ branch: string, sha: string, unmergedCount?: number, reason: string }>} */ -export function findUnmergedRemoteReconciliations(options = {}) { - const runner = +function remoteGitRunner(options) { + return ( options.runner ?? ((args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], - }).trim()); + }).trim()) + ); +} - let lsRemoteText = options.lsRemoteOutput; - if (lsRemoteText === undefined) { - try { - lsRemoteText = runner(["ls-remote", "--heads", "origin"]); - } catch { - // Offline or network error: fail open safely. - return []; - } +function readRemoteHeads(options, runner) { + if (options.lsRemoteOutput !== undefined) return options.lsRemoteOutput; + try { + return runner(["ls-remote", "--heads", "origin"]); + } catch { + // Offline or network error: fail open safely. + return null; } +} - if (!lsRemoteText || typeof lsRemoteText !== "string") return []; - - const originMainRef = options.originRef ?? "refs/remotes/origin/main"; - let currentBranch = options.currentBranch; - if (currentBranch === undefined) { - try { - currentBranch = runner(["branch", "--show-current"]).trim(); - } catch { - currentBranch = ""; - } +function readCurrentBranch(options, runner) { + if (options.currentBranch !== undefined) return options.currentBranch; + try { + return runner(["branch", "--show-current"]).trim(); + } catch { + return ""; } +} +function parseRemoteCandidates(lsRemoteText, currentBranch) { const lines = lsRemoteText .split("\n") .map((l) => l.trim()) .filter(Boolean); const candidateBranches = []; - const shasToCheck = []; - for (const line of lines) { const parts = line.split(/\s+/); if (parts.length < 2) continue; @@ -612,21 +666,19 @@ export function findUnmergedRemoteReconciliations(options = {}) { const branchName = ref.replace(/^refs\/heads\//, ""); if (!/^[0-9a-f]{40}$/i.test(sha)) continue; if (branchName === "main" || branchName === "master" || (currentBranch && branchName === currentBranch)) continue; - candidateBranches.push({ branch: branchName, sha }); - shasToCheck.push(sha); } + return candidateBranches; +} - if (candidateBranches.length === 0) return []; - - let mainApplied = new Set(); +function readMainAppliedPaths(originMainRef) { try { const mainTree = execFileSync("git", ["ls-tree", "-r", "--name-only", originMainRef, "--", APPLIED_DIR], { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); - mainApplied = new Set( + return new Set( mainTree .trim() .split("\n") @@ -635,9 +687,12 @@ export function findUnmergedRemoteReconciliations(options = {}) { ); } catch { // If originMainRef cannot be inspected, fail open for tree diffs + return new Set(); } +} - let localShas = new Set(); +function locallyAvailableCommitShas(shasToCheck) { + const localShas = new Set(); try { const checkOutput = execFileSync("git", ["cat-file", "--batch-check=%(objectname) %(objecttype)"], { cwd: ROOT, @@ -652,94 +707,113 @@ export function findUnmergedRemoteReconciliations(options = {}) { } catch { // If batch-check fails, fallback } + return localShas; +} - const detected = []; - const presentCandidates = candidateBranches.filter((b) => localShas.has(b.sha)); - const presentShas = presentCandidates.map((b) => b.sha); +function suspiciousAppliedCommits(originMainRef, presentShas, mainApplied) { + if (presentShas.length === 0) return new Set(); + try { + const logArgs = [ + "log", + "--format=COMMIT:%H", + "--name-only", + `^${originMainRef}`, + ...presentShas, + "--", + APPLIED_DIR, + ]; + const logOutput = execFileSync("git", logArgs, { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const suspicious = new Set(); + for (const block of logOutput.split("COMMIT:").filter(Boolean)) { + const lines = block.trim().split("\n"); + const files = lines + .slice(1) + .map((file) => file.trim()) + .filter(Boolean); + if (files.some((file) => !mainApplied.has(file))) suspicious.add(lines[0].trim()); + } + return suspicious; + } catch { + // fail open + return new Set(); + } +} - if (presentShas.length > 0) { +function detectPresentReconciliations(presentCandidates, originMainRef, mainApplied, suspiciousCommits) { + if (suspiciousCommits.size === 0) return []; + const detected = []; + for (const candidate of presentCandidates) { try { - const logArgs = [ - "log", - "--format=COMMIT:%H", - "--name-only", - `^${originMainRef}`, - ...presentShas, - "--", - APPLIED_DIR, - ]; - const logOutput = execFileSync("git", logArgs, { + const branchLog = execFileSync( + "git", + ["log", "-1", "--format=%H", `^${originMainRef}`, candidate.sha, "--", APPLIED_DIR], + { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }, + ).trim(); + if (!branchLog || !suspiciousCommits.has(branchLog)) continue; + const branchApplied = execFileSync("git", ["ls-tree", "-r", "--name-only", candidate.sha, "--", APPLIED_DIR], { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], - }); - if (logOutput) { - const suspiciousCommits = new Set(); - for (const block of logOutput.split("COMMIT:").filter(Boolean)) { - const blines = block.trim().split("\n"); - const cSha = blines[0].trim(); - const files = blines - .slice(1) - .map((f) => f.trim()) - .filter(Boolean); - if (files.some((f) => !mainApplied.has(f))) { - suspiciousCommits.add(cSha); - } - } - - if (suspiciousCommits.size > 0) { - for (const candidate of presentCandidates) { - try { - const branchLog = execFileSync( - "git", - ["log", "-1", "--format=%H", `^${originMainRef}`, candidate.sha, "--", APPLIED_DIR], - { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }, - ).trim(); - if (branchLog && suspiciousCommits.has(branchLog)) { - const branchApplied = execFileSync( - "git", - ["ls-tree", "-r", "--name-only", candidate.sha, "--", APPLIED_DIR], - { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }, - ) - .trim() - .split("\n") - .map((s) => s.trim()) - .filter(Boolean); - const unmerged = branchApplied.filter((f) => !mainApplied.has(f)); - if (unmerged.length > 0) { - detected.push({ - branch: candidate.branch, - sha: candidate.sha, - unmergedCount: unmerged.length, - reason: `carries ${unmerged.length} unmerged applied inbox record(s) under ${APPLIED_DIR}`, - }); - } - } - } catch { - // ignore - } - } - } - } - } catch { - // fail open - } - } - - for (const candidate of candidateBranches) { - if (localShas.has(candidate.sha)) continue; - if (/(?:issues|ledger|inbox)[-_]?reconcile|reconcile[-_]?(?:issues|ledger|inbox)/i.test(candidate.branch)) { + }) + .trim() + .split("\n") + .map((entry) => entry.trim()) + .filter(Boolean); + const unmerged = branchApplied.filter((entry) => !mainApplied.has(entry)); + if (unmerged.length === 0) continue; detected.push({ branch: candidate.branch, sha: candidate.sha, - reason: "unfetched remote branch name indicates an in-flight unmerged reconcile branch", + unmergedCount: unmerged.length, + reason: `carries ${unmerged.length} unmerged applied inbox record(s) under ${APPLIED_DIR}`, }); + } catch { + // ignore } } - return detected; } +function detectUnfetchedNamedReconciliations(candidateBranches, localShas) { + return candidateBranches + .filter( + (candidate) => + !localShas.has(candidate.sha) && + /(?:issues|ledger|inbox)[-_]?reconcile|reconcile[-_]?(?:issues|ledger|inbox)/i.test(candidate.branch), + ) + .map((candidate) => ({ + branch: candidate.branch, + sha: candidate.sha, + reason: "unfetched remote branch name indicates an in-flight unmerged reconcile branch", + })); +} + +export function findUnmergedRemoteReconciliations(options = {}) { + const runner = remoteGitRunner(options); + const lsRemoteText = readRemoteHeads(options, runner); + if (!lsRemoteText || typeof lsRemoteText !== "string") return []; + const candidateBranches = parseRemoteCandidates(lsRemoteText, readCurrentBranch(options, runner)); + if (candidateBranches.length === 0) return []; + + const originMainRef = options.originRef ?? "refs/remotes/origin/main"; + const mainApplied = readMainAppliedPaths(originMainRef); + const localShas = locallyAvailableCommitShas(candidateBranches.map((candidate) => candidate.sha)); + const presentCandidates = candidateBranches.filter((candidate) => localShas.has(candidate.sha)); + const suspiciousCommits = suspiciousAppliedCommits( + originMainRef, + presentCandidates.map((candidate) => candidate.sha), + mainApplied, + ); + return [ + ...detectPresentReconciliations(presentCandidates, originMainRef, mainApplied, suspiciousCommits), + ...detectUnfetchedNamedReconciliations(candidateBranches, localShas), + ]; +} + export function assertSafeRemoteReconciliation(argv = [], options = {}) { const allowConcurrent = argv.includes("--allow-concurrent") || @@ -769,102 +843,98 @@ export function assertSafeRemoteReconciliation(argv = [], options = {}) { } } -function createRequest(action, argv) { +function requestTargetId(argv) { const positionalId = argv[1]?.startsWith("--") ? undefined : argv[1]; const rawTargetId = argValue(argv, "id") ?? positionalId; - const targetId = rawTargetId - ? rawTargetId.startsWith("#") || rawTargetId.includes("-") - ? rawTargetId - : `#${rawTargetId}` - : undefined; - const targetRequestId = argValue(argv, "requestId") ?? argValue(argv, "id") ?? positionalId; - const payload = - action === "add" - ? { - pri: argValue(argv, "pri"), - type: argValue(argv, "type"), - summary: argValue(argv, "summary"), - detail: argValue(argv, "detail"), - source: argValue(argv, "source"), - issueUlid: issueUlid(), - } - : action === "done" || action === "amend-outcome" - ? { id: targetId, outcome: argValue(argv, "outcome") ?? argValue(argv, "reason") } - : action === "cancel" - ? { requestId: targetRequestId, reason: argValue(argv, "reason") } - : action === "queue" - ? { - id: targetId, - acuity: argValue(argv, "acuity"), - capability: argValue(argv, "capability"), - when: argValue(argv, "when"), - estimate: argValue(argv, "estimate"), - outcome: argValue(argv, "outcome") ?? argValue(argv, "reason"), - } - : { - // `pri` rides the same update request as the prose fields so a - // demotion and the reason for it land as one auditable mutation. - // Without it the CLI could not express a re-prioritisation at all, - // which is the half of ledger #313 the inbox would otherwise - // reintroduce: updateIssue accepts --pri and validateRequest - // permits it, but nothing could produce the payload. - id: targetId, - pri: argValue(argv, "pri"), - summary: argValue(argv, "summary"), - detail: argValue(argv, "detail"), - source: argValue(argv, "source"), - outcome: argValue(argv, "outcome") ?? argValue(argv, "reason"), - }; - if (["done", "update", "amend-outcome"].includes(action) && typeof payload.id === "string") { - const current = readOutstandingIssues(); - const currentFingerprint = issueRowFingerprint(current, payload.id); - if (currentFingerprint === null) { - const parsed = parseIssues(current); - const targetId = normalizeIssueDisplayId(payload.id); - const isArchived = parsed.rows.some( - (row) => row.table === "archive" && normalizeIssueDisplayId(row.id) === targetId, - ); - if (action === "done") { - if (!isArchived) { - throw new Error(`ledger request rejected: ${payload.id} is not in Open items`); - } - // Carry archive-aware intent so reconcile merges the outcome instead of - // no-oping via the idempotent archived fast-path. - payload.allowArchived = true; - } else if (action === "update" || action === "amend-outcome") { - if (!isArchived) { - throw new Error(`ledger request rejected: ${payload.id} is not in Open items`); - } - const archiveFp = archiveRowFingerprint(current, payload.id); - if (archiveFp) { - payload.baseRowFingerprint = archiveFp; - payload.allowArchived = true; - payload.targetTable = "archive"; - } - } else { - throw new Error(`ledger request rejected: ${payload.id} is not in Open items`); - } - } else { - if (action === "amend-outcome" || (action === "update" && payload.outcome !== undefined)) { - throw new Error( - `ledger request rejected: ${payload.id} is still open; outcome amendments require an archived issue`, - ); - } - payload.baseRowFingerprint = currentFingerprint; - } + if (!rawTargetId) return undefined; + return rawTargetId.startsWith("#") || rawTargetId.includes("-") ? rawTargetId : `#${rawTargetId}`; +} + +function createRequestPayload(action, argv) { + const targetId = requestTargetId(argv); + if (action === "add") { + return { + pri: argValue(argv, "pri"), + type: argValue(argv, "type"), + summary: argValue(argv, "summary"), + detail: argValue(argv, "detail"), + source: argValue(argv, "source"), + issueUlid: issueUlid(), + }; + } + if (action === "done" || action === "amend-outcome") { + return { id: targetId, outcome: argValue(argv, "outcome") ?? argValue(argv, "reason") }; } - if (action === "queue" && typeof payload.id === "string") { - const currentFingerprint = queueRowFingerprint(readOutstandingIssues(), payload.id); - if (currentFingerprint === null) { + if (action === "cancel") { + const positionalId = argv[1]?.startsWith("--") ? undefined : argv[1]; + return { + requestId: argValue(argv, "requestId") ?? argValue(argv, "id") ?? positionalId, + reason: argValue(argv, "reason"), + }; + } + if (action === "queue") { + return { + id: targetId, + acuity: argValue(argv, "acuity"), + capability: argValue(argv, "capability"), + when: argValue(argv, "when"), + estimate: argValue(argv, "estimate"), + outcome: argValue(argv, "outcome") ?? argValue(argv, "reason"), + }; + } + return { + // `pri` rides the same update request as the prose fields so a demotion and + // its reason land as one auditable mutation. Without it, the CLI could not + // express the re-prioritisation that updateIssue and validation both support. + id: targetId, + pri: argValue(argv, "pri"), + summary: argValue(argv, "summary"), + detail: argValue(argv, "detail"), + source: argValue(argv, "source"), + outcome: argValue(argv, "outcome") ?? argValue(argv, "reason"), + }; +} + +function attachIssueFingerprint(action, payload) { + if (!["done", "update", "amend-outcome"].includes(action) || typeof payload.id !== "string") return; + const current = readOutstandingIssues(); + const currentFingerprint = issueRowFingerprint(current, payload.id); + if (currentFingerprint !== null) { + if (action === "amend-outcome" || (action === "update" && payload.outcome !== undefined)) { throw new Error( - `ledger request rejected: ${payload.id} does not have exactly one recommended-execution-queue row`, + `ledger request rejected: ${payload.id} is still open; outcome amendments require an archived issue`, ); } payload.baseRowFingerprint = currentFingerprint; + return; } - const request = { version: 2, id: randomUUID(), createdOn: date(), action, payload }; - const problems = validateRequest(request); - if (problems.length > 0) throw new Error(problems.join("; ")); + + const isArchived = isArchivedIssue(current, payload.id); + if (!isArchived) throw new Error(`ledger request rejected: ${payload.id} is not in Open items`); + if (action === "done") { + // Carry archive-aware intent so reconcile merges the outcome instead of + // no-oping via the idempotent archived fast-path. + payload.allowArchived = true; + return; + } + const archiveFingerprint = archiveRowFingerprint(current, payload.id); + if (archiveFingerprint) { + payload.baseRowFingerprint = archiveFingerprint; + payload.allowArchived = true; + payload.targetTable = "archive"; + } +} + +function attachQueueFingerprint(action, payload) { + if (action !== "queue" || typeof payload.id !== "string") return; + const currentFingerprint = queueRowFingerprint(readOutstandingIssues(), payload.id); + if (currentFingerprint === null) { + throw new Error(`ledger request rejected: ${payload.id} does not have exactly one recommended-execution-queue row`); + } + payload.baseRowFingerprint = currentFingerprint; +} + +function writeRequest(action, argv, request) { const relative = requestPath(request.id); if (argv.includes("--dry-run")) { console.log(JSON.stringify(request, null, 2)); @@ -879,6 +949,16 @@ function createRequest(action, argv) { ); } +function createRequest(action, argv) { + const payload = createRequestPayload(action, argv); + attachIssueFingerprint(action, payload); + attachQueueFingerprint(action, payload); + const request = { version: 2, id: randomUUID(), createdOn: date(), action, payload }; + const problems = validateRequest(request); + if (problems.length > 0) throw new Error(problems.join("; ")); + writeRequest(action, argv, request); +} + /** * `reconcile` is the only sanctioned writer of docs/outstanding-issues.md, and * it also moves every request it applied into `applied/` — so both the ledger @@ -914,68 +994,68 @@ function regenerateLedgerSnapshot() { } } -function reconcile(argv) { - const dryRun = argv.includes("--dry-run"); - const pending = loadPending(); +function refuseReconciliation(error) { + console.error(`refusing to reconcile: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +} + +function pendingRequestsAreValid(pending) { const bad = pending.filter((entry) => entry.error || validateRequest(entry.request).length > 0); - if (bad.length > 0) { - for (const entry of bad) - console.error(`${entry.relative}: ${entry.error ?? validateRequest(entry.request).join("; ")}`); - process.exitCode = 1; - return; - } - if (pending.length === 0) { - console.log("No outstanding-issue requests to reconcile."); - return; + if (bad.length === 0) return true; + for (const entry of bad) { + console.error(`${entry.relative}: ${entry.error ?? validateRequest(entry.request).join("; ")}`); } + process.exitCode = 1; + return false; +} + +function verifyTrackedPendingRequests(pending) { try { pendingRequestsAreTrackedAndClean(pending); + return true; } catch (error) { - console.error(`refusing to reconcile: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - return; + refuseReconciliation(error); + return false; } - if (!dryRun && !canonicalLedgerIsClean()) { - console.error( - `refusing to reconcile: ${ISSUES_PATH} has staged or unstaged changes; preserve or commit them first.`, - ); - process.exitCode = 1; - return; - } - assertFreshReconciliationBase(); +} + +function verifySafeRemoteReconciliation(argv) { try { assertSafeRemoteReconciliation(argv); + return true; } catch (error) { - console.error(`refusing to reconcile: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - return; + refuseReconciliation(error); + return false; } +} - const apply = () => { - const current = readFileSync(path.join(ROOT, ISSUES_PATH), "utf8"); - const result = applyRequestBatch( - current, - pending.map((entry) => entry.request), - ); - const problems = checkIssues(result.markdown, { prettierIgnored: true }); - if (problems.length > 0) throw new Error(`invalid ${ISSUES_PATH}: ${problems.join("; ")}`); - return result; - }; +function applyPendingRequests(pending) { + const current = readFileSync(path.join(ROOT, ISSUES_PATH), "utf8"); + const result = applyRequestBatch( + current, + pending.map((entry) => entry.request), + ); + const problems = checkIssues(result.markdown, { prettierIgnored: true }); + if (problems.length > 0) throw new Error(`invalid ${ISSUES_PATH}: ${problems.join("; ")}`); + return result; +} +function previewReconciliation(pending) { let result; try { - result = apply(); + result = applyPendingRequests(pending); } catch (error) { - console.error(`refusing to reconcile: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - return; + refuseReconciliation(error); } + return result; +} + +function commitReconciliation(pending, initialResult) { const decisions = - result.cancellations.length > 0 ? ` with ${result.cancellations.length} cancellation decision(s)` : ""; - console.log( - `${dryRun ? "Would reconcile" : "Reconciling"} ${pending.length} request(s) into ${ISSUES_PATH}${decisions}.`, - ); - if (dryRun) return; + initialResult.cancellations.length > 0 + ? ` with ${initialResult.cancellations.length} cancellation decision(s)` + : ""; + console.log(`Reconciling ${pending.length} request(s) into ${ISSUES_PATH}${decisions}.`); const release = acquireReconcileLock(); const lockPath = reconcileLockPath(); const transactionPath = path.join(lockPath, RECONCILE_TRANSACTION_NAME); @@ -985,7 +1065,7 @@ function reconcile(argv) { throw new Error(`${ISSUES_PATH} changed while reconciliation was waiting for its lock`); pendingRequestsAreTrackedAndClean(pending); assertFreshReconciliationBase(); - result = apply(); + const result = applyPendingRequests(pending); const current = readFileSync(path.join(ROOT, ISSUES_PATH), "utf8"); writeFileSync(path.join(lockPath, RECONCILE_BACKUP_NAME), current, "utf8"); @@ -1016,14 +1096,166 @@ function reconcile(argv) { console.error(`reconciliation recovery is required: ${recoveryError.message}`); } } - console.error(`refusing to reconcile: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; + refuseReconciliation(error); } finally { if (!transactionOpen) release(); } } -function selfTest() { +function reconcile(argv) { + const dryRun = argv.includes("--dry-run"); + const pending = loadPending(); + if (!pendingRequestsAreValid(pending)) return; + if (pending.length === 0) { + console.log("No outstanding-issue requests to reconcile."); + return; + } + if (!verifyTrackedPendingRequests(pending)) return; + if (!dryRun && !canonicalLedgerIsClean()) { + refuseReconciliation(`${ISSUES_PATH} has staged or unstaged changes; preserve or commit them first.`); + return; + } + assertFreshReconciliationBase(); + if (!verifySafeRemoteReconciliation(argv)) return; + const result = previewReconciliation(pending); + if (!result) return; + const decisions = + result.cancellations.length > 0 ? ` with ${result.cancellations.length} cancellation decision(s)` : ""; + if (dryRun) { + console.log(`Would reconcile ${pending.length} request(s) into ${ISSUES_PATH}${decisions}.`); + return; + } + commitReconciliation(pending, result); +} + +function selfTestRemoteInterlock() { + const offlineResult = findUnmergedRemoteReconciliations({ + runner: () => { + throw new Error("offline"); + }, + }); + if (offlineResult.length !== 0) { + throw new Error("self-test failed: remote check must fail open when offline"); + } + + const cleanRemoteOutput = [`${"a".repeat(40)}\trefs/heads/main`, `${"b".repeat(40)}\trefs/heads/feature-branch`].join( + "\n", + ); + const cleanResult = findUnmergedRemoteReconciliations({ + lsRemoteOutput: cleanRemoteOutput, + currentBranch: "feature-branch", + }); + if (cleanResult.length !== 0) { + throw new Error("self-test failed: clean remote branches must report no conflicts"); + } + + const conflictingRemoteOutput = [ + `${"a".repeat(40)}\trefs/heads/main`, + `${"c".repeat(40)}\trefs/heads/claude/issues-reconcile-20260818`, + ].join("\n"); + const conflictResult = findUnmergedRemoteReconciliations({ + lsRemoteOutput: conflictingRemoteOutput, + currentBranch: "feature-branch", + }); + if (conflictResult.length === 0 || conflictResult[0].branch !== "claude/issues-reconcile-20260818") { + throw new Error("self-test failed: unmerged reconcile branch on origin was not detected"); + } + + let remoteInterlockError = false; + try { + assertSafeRemoteReconciliation([], { + lsRemoteOutput: conflictingRemoteOutput, + currentBranch: "feature-branch", + }); + } catch (error) { + remoteInterlockError = /detected unmerged branch/.test(String(error)); + } + if (!remoteInterlockError) { + throw new Error( + "self-test failed: assertSafeRemoteReconciliation must throw when unmerged reconcile branch is found", + ); + } + + const remoteWarnings = []; + assertSafeRemoteReconciliation(["--allow-concurrent"], { + lsRemoteOutput: conflictingRemoteOutput, + currentBranch: "feature-branch", + warn: (msg) => remoteWarnings.push(msg), + }); + if (remoteWarnings.length === 0 || !remoteWarnings[0].includes("claude/issues-reconcile-20260818")) { + throw new Error("self-test failed: --allow-concurrent must warn loudly rather than throw"); + } +} + +function selfTestQueueRequests(base, update, done) { + // #M6JNR8: a queue re-grade must survive validation, fingerprinting against + // the queue row, and writing without disturbing Order or the ID(s) linkage. + const queueBase = base.replace( + ["| Order | ID(s) |", "| --- | --- |", "| 1 | `#001` |"].join("\n"), + [ + "| Order | ID(s) | Acuity | Capability | When | Estimate | Outcome |", + "| --- | --- | --- | --- | --- | --- | --- |", + "| 1 | `#001` | A1 | Specialist | Immediate | 2h | investigate |", + ].join("\n"), + ); + const regrade = { + version: 1, + id: "77777777-7777-4777-8777-777777777777", + createdOn: "2026-08-13", + action: "queue", + payload: { + id: "#001", + acuity: "A3", + when: "After the next release", + baseRowFingerprint: queueRowFingerprint(queueBase, "#001"), + }, + }; + if (validateRequest(regrade).length > 0) { + throw new Error("self-test failed: a queue re-grade request must validate"); + } + if (validateRequest({ ...regrade, payload: { id: "#001" } }).length === 0) { + throw new Error("self-test failed: a queue request with no editable field must be rejected"); + } + const regraded = applyRequest(queueBase, regrade); + if (!/\|\s*1\s*\|\s*`#001`\s*\|\s*A3\s*\|/.test(regraded)) { + throw new Error("self-test failed: a queue re-grade did not reach the Acuity cell"); + } + if (!/\|\s*#001\s*\|\s*P2\s*\|/.test(regraded)) { + throw new Error("self-test failed: a queue re-grade must not touch the Open-items row"); + } + let staleQueueRejected = false; + try { + applyRequest(queueBase.replace("| 1 | `#001` | A1 |", "| 1 | `#001` | A2 |"), regrade); + } catch (error) { + staleQueueRejected = /stale/.test(String(error)); + } + if (!staleQueueRejected) throw new Error("self-test failed: stale queue request was not rejected"); + if (!applyRequest(queueBase.replace("| #001 | P2 | issue | one |", "| #001 | P2 | issue | one prime |"), regrade)) { + throw new Error("self-test failed: an unrelated Open-items edit invalidated a queue re-grade"); + } + if (mutationConflicts([regrade, update]).length > 0) { + throw new Error("self-test failed: a queue edit and an open-row update were treated as conflicting"); + } + if (mutationConflicts([regrade, { ...regrade, id: "88888888-8888-4888-8888-888888888888" }]).length !== 1) { + throw new Error("self-test failed: two queue edits for one id must conflict"); + } + for (const requests of [ + [regrade, done], + [done, regrade], + ]) { + let queueClosureRejected = false; + try { + applyRequestBatch(queueBase, requests); + } catch (error) { + queueClosureRejected = /explicit cancellation decision/.test(String(error)); + } + if (!queueClosureRejected) { + throw new Error("self-test failed: a queue edit and closure must require an explicit cancellation decision"); + } + } +} + +function createSelfTestFixtures() { const base = [ "", "", @@ -1083,9 +1315,6 @@ function selfTest() { baseRowFingerprint: issueRowFingerprint(base, "#041061"), }, }; - if (!applyRequest(base, modernUpdate).includes("| modern updated |")) { - throw new Error("self-test failed: modern Crockford display id update was not applied"); - } const cancel = { version: 1, id: "44444444-4444-4444-8444-444444444444", @@ -1103,91 +1332,10 @@ function selfTest() { action: "update", payload: { id: "#001", pri: "P3", baseRowFingerprint: issueRowFingerprint(base, "#001") }, }; - if (validateRequest(reprioritise).length > 0) { - throw new Error("self-test failed: a pri-only update request must validate"); - } - if (validateRequest({ ...reprioritise, payload: { id: "#001", pri: "P9" } }).length === 0) { - throw new Error("self-test failed: an out-of-range pri must be rejected"); - } - const reprioritised = applyRequest(base, reprioritise); - if (!/\|\s*#001\s*\|\s*P3\s*\|/.test(reprioritised)) { - throw new Error("self-test failed: a pri-only update did not reach the Pri cell"); - } - - // #M6JNR8: a queue re-grade must survive the whole path — validated, carried - // by a request, fingerprinted against the queue row rather than the - // Open-items row, and written to the Acuity cell without disturbing Order or - // the ID(s) linkage. The base fixture's queue is deliberately two columns - // wide, so this needs a fixture shaped like the real seven-column queue. - const queueBase = base.replace( - ["| Order | ID(s) |", "| --- | --- |", "| 1 | `#001` |"].join("\n"), - [ - "| Order | ID(s) | Acuity | Capability | When | Estimate | Outcome |", - "| --- | --- | --- | --- | --- | --- | --- |", - "| 1 | `#001` | A1 | Specialist | Immediate | 2h | investigate |", - ].join("\n"), - ); - const regrade = { - version: 1, - id: "77777777-7777-4777-8777-777777777777", - createdOn: "2026-08-13", - action: "queue", - payload: { - id: "#001", - acuity: "A3", - when: "After the next release", - baseRowFingerprint: queueRowFingerprint(queueBase, "#001"), - }, - }; - if (validateRequest(regrade).length > 0) { - throw new Error("self-test failed: a queue re-grade request must validate"); - } - if (validateRequest({ ...regrade, payload: { id: "#001" } }).length === 0) { - throw new Error("self-test failed: a queue request with no editable field must be rejected"); - } - const regraded = applyRequest(queueBase, regrade); - if (!/\|\s*1\s*\|\s*`#001`\s*\|\s*A3\s*\|/.test(regraded)) { - throw new Error("self-test failed: a queue re-grade did not reach the Acuity cell"); - } - if (!/\|\s*#001\s*\|\s*P2\s*\|/.test(regraded)) { - throw new Error("self-test failed: a queue re-grade must not touch the Open-items row"); - } - // The whole point of the fingerprint is that it tracks the QUEUE row: an edit - // to the Open-items row must not invalidate a queued re-grade, and an edit to - // the queue row must. - let staleQueueRejected = false; - try { - applyRequest(queueBase.replace("| 1 | `#001` | A1 |", "| 1 | `#001` | A2 |"), regrade); - } catch (error) { - staleQueueRejected = /stale/.test(String(error)); - } - if (!staleQueueRejected) throw new Error("self-test failed: stale queue request was not rejected"); - if (!applyRequest(queueBase.replace("| #001 | P2 | issue | one |", "| #001 | P2 | issue | one prime |"), regrade)) { - throw new Error("self-test failed: an unrelated Open-items edit invalidated a queue re-grade"); - } - // A queue edit and an Open-items update for the same id are two rows, so - // reconciling both in one batch must not demand a cancellation decision. - if (mutationConflicts([regrade, update]).length > 0) { - throw new Error("self-test failed: a queue edit and an open-row update were treated as conflicting"); - } - if (mutationConflicts([regrade, { ...regrade, id: "88888888-8888-4888-8888-888888888888" }]).length !== 1) { - throw new Error("self-test failed: two queue edits for one id must conflict"); - } - for (const requests of [ - [regrade, done], - [done, regrade], - ]) { - let queueClosureRejected = false; - try { - applyRequestBatch(queueBase, requests); - } catch (error) { - queueClosureRejected = /explicit cancellation decision/.test(String(error)); - } - if (!queueClosureRejected) { - throw new Error("self-test failed: a queue edit and closure must require an explicit cancellation decision"); - } - } + return { base, add, done, update, modernUpdate, cancel, reprioritise }; +} +function selfTestApplyingRequests({ base, add, done, update, cancel }) { const added = applyRequest(base, add); const replayed = applyRequest(base, add); const legacyUlid = issueUlidFromRequest(add.createdOn, add.id); @@ -1206,8 +1354,9 @@ function selfTest() { throw new Error("self-test failed: invalid version 2 durable id accepted"); } const resolved = applyRequest(base, done); - if (resolved.includes("`#001`")) + if (resolved.includes("`#001`")) { throw new Error("self-test failed: queued resolve did not preserve ledger invariants"); + } const idempDone = { version: 1, id: "99999999-1111-4111-8111-111111111111", @@ -1219,8 +1368,6 @@ function selfTest() { if (!idempResult.includes("done \\| Note: idempotent note")) { throw new Error("self-test failed: idempotent close did not merge outcome note into archive row"); } - // One-request batch with createRequest-style allowArchived must merge the - // outcome — reproduces archive-aware close surviving reconcile (idempotent). const archivedClose = { version: 1, id: "99999999-4444-4444-8444-444444444444", @@ -1232,8 +1379,9 @@ function selfTest() { if (!archivedBatch.markdown.includes("done \\| Note: post-archive close note")) { throw new Error("self-test failed: one-request archived done batch did not merge outcome"); } - if (validateRequest({ ...add, payload: {} }).length === 0) + if (validateRequest({ ...add, payload: {} }).length === 0) { throw new Error("self-test failed: invalid request accepted"); + } const staleBase = base.replace("one", "stale"); let staleRejected = false; @@ -1283,6 +1431,26 @@ function selfTest() { if (!planned.markdown.includes("updated") || planned.cancelledIds[0] !== done.id) { throw new Error("self-test failed: cancellation did not select the intended mutation"); } +} + +function selfTest() { + const { base, add, done, update, modernUpdate, cancel, reprioritise } = createSelfTestFixtures(); + if (!applyRequest(base, modernUpdate).includes("| modern updated |")) { + throw new Error("self-test failed: modern Crockford display id update was not applied"); + } + if (validateRequest(reprioritise).length > 0) { + throw new Error("self-test failed: a pri-only update request must validate"); + } + if (validateRequest({ ...reprioritise, payload: { id: "#001", pri: "P9" } }).length === 0) { + throw new Error("self-test failed: an out-of-range pri must be rejected"); + } + const reprioritised = applyRequest(base, reprioritise); + if (!/\|\s*#001\s*\|\s*P3\s*\|/.test(reprioritised)) { + throw new Error("self-test failed: a pri-only update did not reach the Pri cell"); + } + + selfTestQueueRequests(base, update, done); + selfTestApplyingRequests({ base, add, done, update, cancel }); let missingTargetRejected = false; try { planRequestBatch([{ ...cancel, payload: { requestId: add.id, reason: "missing" } }]); @@ -1298,62 +1466,7 @@ function selfTest() { } // Pre-flight remote interlock self-test (#EH9VA6) - const offlineResult = findUnmergedRemoteReconciliations({ - runner: () => { - throw new Error("offline"); - }, - }); - if (offlineResult.length !== 0) { - throw new Error("self-test failed: remote check must fail open when offline"); - } - - const cleanRemoteOutput = [`${"a".repeat(40)}\trefs/heads/main`, `${"b".repeat(40)}\trefs/heads/feature-branch`].join( - "\n", - ); - const cleanResult = findUnmergedRemoteReconciliations({ - lsRemoteOutput: cleanRemoteOutput, - currentBranch: "feature-branch", - }); - if (cleanResult.length !== 0) { - throw new Error("self-test failed: clean remote branches must report no conflicts"); - } - - const conflictingRemoteOutput = [ - `${"a".repeat(40)}\trefs/heads/main`, - `${"c".repeat(40)}\trefs/heads/claude/issues-reconcile-20260818`, - ].join("\n"); - const conflictResult = findUnmergedRemoteReconciliations({ - lsRemoteOutput: conflictingRemoteOutput, - currentBranch: "feature-branch", - }); - if (conflictResult.length === 0 || conflictResult[0].branch !== "claude/issues-reconcile-20260818") { - throw new Error("self-test failed: unmerged reconcile branch on origin was not detected"); - } - - let remoteInterlockError = false; - try { - assertSafeRemoteReconciliation([], { - lsRemoteOutput: conflictingRemoteOutput, - currentBranch: "feature-branch", - }); - } catch (error) { - remoteInterlockError = /detected unmerged branch/.test(String(error)); - } - if (!remoteInterlockError) { - throw new Error( - "self-test failed: assertSafeRemoteReconciliation must throw when unmerged reconcile branch is found", - ); - } - - const remoteWarnings = []; - assertSafeRemoteReconciliation(["--allow-concurrent"], { - lsRemoteOutput: conflictingRemoteOutput, - currentBranch: "feature-branch", - warn: (msg) => remoteWarnings.push(msg), - }); - if (remoteWarnings.length === 0 || !remoteWarnings[0].includes("claude/issues-reconcile-20260818")) { - throw new Error("self-test failed: --allow-concurrent must warn loudly rather than throw"); - } + selfTestRemoteInterlock(); const parsedEnqueue = parseInboxCommand(["enqueue", "--action", "done", "--id", "#001", "--reason", "tested"]); if (parsedEnqueue.action !== "done" || !parsedEnqueue.argv.includes("--reason")) { @@ -1365,22 +1478,56 @@ function selfTest() { export function parseInboxCommand(argv) { const action = argv[0]; - if (action === "enqueue") { - const explicitAction = argValue(argv, "action"); - if (!explicitAction || !ACTIONS.has(explicitAction)) { - throw new Error(`enqueue requires --action <${[...ACTIONS].join("|")}>`); + if (action !== "enqueue") return { action, argv }; + const explicitAction = argValue(argv, "action"); + if (!explicitAction || !ACTIONS.has(explicitAction)) { + throw new Error(`enqueue requires --action <${[...ACTIONS].join("|")}>`); + } + const filteredArgv = []; + for (let i = 1; i < argv.length; i++) { + if (argv[i] === "--action") { + i++; + continue; } - const filteredArgv = []; - for (let i = 1; i < argv.length; i++) { - if (argv[i] === "--action") { - i++; - continue; - } - filteredArgv.push(argv[i]); - } - return { action: explicitAction, argv: [explicitAction, ...filteredArgv] }; + filteredArgv.push(argv[i]); + } + return { action: explicitAction, argv: [explicitAction, ...filteredArgv] }; +} + +function entryProblems(entry) { + return entry.error ? entry.error : validateRequest(entry.request).join("; "); +} + +function duplicateRequestPaths(requests) { + const seen = new Set(); + const duplicates = []; + for (const entry of requests) { + if (seen.has(entry.request.id)) duplicates.push(entry.relative); + seen.add(entry.request.id); + } + return duplicates; +} + +function checkInbox() { + const requests = allRequestEntries(); + const bad = requests.filter((entry) => entry.error || validateRequest(entry.request).length > 0); + if (bad.length > 0) { + throw new Error(bad.map((entry) => `${entry.relative}: ${entryProblems(entry)}`).join("\n")); } - return { action, argv }; + const duplicates = duplicateRequestPaths(requests); + if (duplicates.length > 0) throw new Error(`duplicate immutable request id(s): ${duplicates.join(", ")}`); + console.log( + `Ledger inbox check passed: ${loadPending().length} pending request(s), ${requests.length - loadPending().length} applied.`, + ); +} + +function dispatchInboxCommand(action, argv) { + if (ACTIONS.has(action)) return createRequest(action, argv); + if (action === "reconcile") return reconcile(argv.slice(1)); + if (action === "check") return checkInbox(); + throw new Error( + "usage: ledger-inbox.mjs [args]", + ); } function main() { @@ -1388,32 +1535,7 @@ function main() { if (rawArgv.includes("--self-test")) return selfTest(); try { const { action, argv } = parseInboxCommand(rawArgv); - if (ACTIONS.has(action)) return createRequest(action, argv); - if (action === "reconcile") return reconcile(argv.slice(1)); - if (action === "check") { - const requests = allRequestEntries(); - const bad = requests.filter((entry) => entry.error || validateRequest(entry.request).length > 0); - if (bad.length > 0) - throw new Error( - bad - .map((entry) => `${entry.relative}: ${entry.error ?? validateRequest(entry.request).join("; ")}`) - .join("\n"), - ); - const seen = new Set(); - const duplicates = []; - for (const entry of requests) { - if (seen.has(entry.request.id)) duplicates.push(entry.relative); - seen.add(entry.request.id); - } - if (duplicates.length > 0) throw new Error(`duplicate immutable request id(s): ${duplicates.join(", ")}`); - console.log( - `Ledger inbox check passed: ${loadPending().length} pending request(s), ${requests.length - loadPending().length} applied.`, - ); - return; - } - throw new Error( - "usage: ledger-inbox.mjs [args]", - ); + return dispatchInboxCommand(action, argv); } catch (error) { console.error(`ledger inbox: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; diff --git a/scripts/lib/tenancy-scan.mjs b/scripts/lib/tenancy-scan.mjs index 60beae10d5..1246050f79 100644 --- a/scripts/lib/tenancy-scan.mjs +++ b/scripts/lib/tenancy-scan.mjs @@ -702,55 +702,60 @@ function payloadStampsColumn(argument, columns) { /* ---------------------------------------------------------- chain analysis */ -/** - * Inspect one `.from("table")` chain and report which tenancy predicates ride on it. - * Recognised in-chain forms, all of which exist in this codebase: - * .eq("owner_id", x) / .eq("user_id", x) / .eq("documents.owner_id", x) - * .is("owner_id", null) - * .or("…") where EVERY top-level disjunct constrains the column - * an owner_id:/user_id: key at the top level of an .insert()/.upsert() payload - * plus the chain being handed to a sanctioned wrapper: withOwnerReadScope(chain, ownerId). - */ -function analyzeChain(sourceFile, fromCall, context) { - const top = chainTop(fromCall); - const calls = chainMethodCalls(top); - const proofs = []; - let documentFilter = null; - let idFilter = null; - let innerJoinsDocuments = false; - - for (const call of calls) { - const [first, second] = call.args; - if ((call.name === "eq" || call.name === "in") && isStringLiteral(first)) { - const column = first.text; - if (OWNER_COLUMNS.has(column)) proofs.push(`${call.name}:${column}`); - if (USER_COLUMNS.has(column)) proofs.push(`${call.name}:${column}`); - if (column === "document_id") documentFilter = { method: call.name, argument: valueExpressionText(second) }; - if (column === "id") idFilter = { method: call.name, argument: valueExpressionText(second) }; - } - if (call.name === "is" && isStringLiteral(first) && OWNER_COLUMNS.has(first.text)) { - proofs.push(`is:${first.text}`); - } - if (call.name === "or" && isStringLiteral(first)) { - if (orFilterConstrainsColumn(first.text, "owner_id")) proofs.push("or:owner_id"); - if (orFilterConstrainsColumn(first.text, "user_id")) proofs.push("or:user_id"); - } - if (call.name === "select" && isStringLiteral(first) && /documents!inner/.test(first.text)) { - innerJoinsDocuments = true; - } +function collectComparisonFilters(call, chain) { + const [first, second] = call.args; + if (!isStringLiteral(first)) return; + const column = first.text; + if (OWNER_COLUMNS.has(column)) chain.proofs.push(`${call.name}:${column}`); + if (USER_COLUMNS.has(column)) chain.proofs.push(`${call.name}:${column}`); + if (column === "document_id") chain.documentFilter = { method: call.name, argument: valueExpressionText(second) }; + if (column === "id") chain.idFilter = { method: call.name, argument: valueExpressionText(second) }; +} + +function collectDisjunctionProofs(argument, proofs) { + if (!isStringLiteral(argument)) return; + if (orFilterConstrainsColumn(argument.text, "owner_id")) proofs.push("or:owner_id"); + if (orFilterConstrainsColumn(argument.text, "user_id")) proofs.push("or:user_id"); +} + +function collectWriteProofs(call, proofs) { + const [payload] = call.args; + if (payloadStampsColumn(payload, new Set(["owner_id"]))) proofs.push(`${call.name}:owner_id`); + if (payloadStampsColumn(payload, new Set(["user_id"]))) proofs.push(`${call.name}:user_id`); +} + +/** Add the evidence from one method without changing the chain's newest-first order. */ +function collectChainMethodFacts(call, chain) { + const [first] = call.args; + switch (call.name) { + case "eq": + case "in": + collectComparisonFilters(call, chain); + break; + case "is": + if (isStringLiteral(first) && OWNER_COLUMNS.has(first.text)) chain.proofs.push(`is:${first.text}`); + break; + case "or": + collectDisjunctionProofs(first, chain.proofs); + break; + case "select": + if (isStringLiteral(first) && /documents!inner/.test(first.text)) chain.innerJoinsDocuments = true; + break; // `insert`/`upsert` create the row already owned, so an `owner_id:` key in the payload // IS the tenancy fact. `update` is deliberately ABSENT: there the key is the value // WRITTEN, and says nothing about which rows are matched — // `.update({ owner_id: user.id, title })` with no filter reassigns every tenant's rows // to the caller, and was reported as ownerScoped before 2026-09-02 (security review // P1-1). An update is scoped by its `.eq(...)` filters or not at all. - if (call.name === "insert" || call.name === "upsert") { - if (payloadStampsColumn(first, new Set(["owner_id"]))) proofs.push(`${call.name}:owner_id`); - if (payloadStampsColumn(first, new Set(["user_id"]))) proofs.push(`${call.name}:user_id`); - } + case "insert": + case "upsert": + collectWriteProofs(call, chain.proofs); + break; } +} - // The chain handed straight to withOwnerReadScope(chain, ownerId). +/** The chain handed straight to withOwnerReadScope(chain, ownerId). */ +function collectWrapperProof(top, context, proofs) { const parent = top.parent; if ( parent && @@ -761,8 +766,23 @@ function analyzeChain(sourceFile, fromCall, context) { ) { proofs.push(`wrapper:${parent.expression.text}`); } +} - return { proofs, documentFilter, idFilter, innerJoinsDocuments }; +/** + * Inspect one `.from("table")` chain and report which tenancy predicates ride on it. + * Recognised in-chain forms, all of which exist in this codebase: + * .eq("owner_id", x) / .eq("user_id", x) / .eq("documents.owner_id", x) + * .is("owner_id", null) + * .or("…") where EVERY top-level disjunct constrains the column + * an owner_id:/user_id: key at the top level of an .insert()/.upsert() payload + * plus the chain being handed to a sanctioned wrapper: withOwnerReadScope(chain, ownerId). + */ +function analyzeChain(sourceFile, fromCall, context) { + const top = chainTop(fromCall); + const chain = { proofs: [], documentFilter: null, idFilter: null, innerJoinsDocuments: false }; + for (const call of chainMethodCalls(top)) collectChainMethodFacts(call, chain); + collectWrapperProof(top, context, chain.proofs); + return chain; } const OWNER_PROOF = /^(eq|in|is):(owner_id|documents\.owner_id)$|^(insert|upsert):owner_id$|^or:owner_id$|^wrapper:/; @@ -770,63 +790,76 @@ const USER_PROOF = /^(eq|in):user_id$|^(insert|upsert):user_id$|^or:user_id$/; /* --------------------------------------------------------- scope-level facts */ +function collectOwnerScopedDocumentFacts(sourceFile, node, context, facts) { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "from" && + node.arguments.length === 1 && + isStringLiteral(node.arguments[0]) && + node.arguments[0].text === "documents" + ) { + const chain = analyzeChain(sourceFile, node, context); + if (!chain.proofs.some((proof) => OWNER_PROOF.test(proof))) return; + facts.hasOwnerScopedDocumentsQuery = true; + if (chain.idFilter?.argument) facts.ownerScopedDocumentIds.add(chain.idFilter.argument); + } +} + +function collectDocumentIdProperties(value, owningHelperArguments) { + for (const property of value.properties) { + if (ts.isShorthandPropertyAssignment(property) && DOCUMENT_ID_PARAMETER.test(property.name.text)) { + owningHelperArguments.add(property.name.text); + } else if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + DOCUMENT_ID_PARAMETER.test(property.name.text) && + ts.isIdentifier(property.initializer) + ) { + owningHelperArguments.add(property.initializer.text); + } + } +} + +function collectOwningHelperArguments(node, context, owningHelperArguments) { + if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression)) return; + if (!context.helpers.has(node.expression.text)) return; + // Only the DOCUMENT-ID-shaped argument registers. Collecting every identifier and + // shorthand argument meant `loadOwnedDocument({ supabase, documentId: id })` + // registered `supabase` as an owning-document argument too (security review P2-4). + const helper = context.helpers.get(node.expression.text); + node.arguments.forEach((argument, index) => { + const value = unwrapExpression(argument); + if (!value) return; + if (ts.isObjectLiteralExpression(value)) { + collectDocumentIdProperties(value, owningHelperArguments); + return; + } + // A positional call is resolved against the helper's own parameter names, which is + // possible only because the helper resolved to a file-local definition. + if (ts.isIdentifier(value) && DOCUMENT_ID_PARAMETER.test(helper.parameters[index] ?? "")) { + owningHelperArguments.add(value.text); + } + }); +} + /** * Facts about one enclosing scope that a derived-tier query may lean on for its ownership * proof: which identifiers were pinned by an owner-scoped `documents` query, which were * handed to an owning-document helper, and which are locally declared. */ function scopeFacts(sourceFile, scopeNode, context, reachableScopes = new Set()) { - const ownerScopedDocumentIds = new Set(); - const owningHelperArguments = new Set(); - const localDeclarations = new Set(); - let hasOwnerScopedDocumentsQuery = false; + const facts = { + ownerScopedDocumentIds: new Set(), + owningHelperArguments: new Set(), + localDeclarations: new Set(), + hasOwnerScopedDocumentsQuery: false, + }; const visit = (node) => { - if ( - ts.isCallExpression(node) && - ts.isPropertyAccessExpression(node.expression) && - node.expression.name.text === "from" && - node.arguments.length === 1 && - isStringLiteral(node.arguments[0]) && - node.arguments[0].text === "documents" - ) { - const chain = analyzeChain(sourceFile, node, context); - if (chain.proofs.some((proof) => OWNER_PROOF.test(proof))) { - hasOwnerScopedDocumentsQuery = true; - if (chain.idFilter?.argument) ownerScopedDocumentIds.add(chain.idFilter.argument); - } - } - if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && context.helpers.has(node.expression.text)) { - // Only the DOCUMENT-ID-shaped argument registers. Collecting every identifier and - // shorthand argument meant `loadOwnedDocument({ supabase, documentId: id })` - // registered `supabase` as an owning-document argument too (security review P2-4). - const helper = context.helpers.get(node.expression.text); - node.arguments.forEach((argument, index) => { - const value = unwrapExpression(argument); - if (!value) return; - if (ts.isObjectLiteralExpression(value)) { - for (const property of value.properties) { - if (ts.isShorthandPropertyAssignment(property) && DOCUMENT_ID_PARAMETER.test(property.name.text)) { - owningHelperArguments.add(property.name.text); - } else if ( - ts.isPropertyAssignment(property) && - ts.isIdentifier(property.name) && - DOCUMENT_ID_PARAMETER.test(property.name.text) && - ts.isIdentifier(property.initializer) - ) { - owningHelperArguments.add(property.initializer.text); - } - } - return; - } - // A positional call is resolved against the helper's own parameter names, which is - // possible only because the helper resolved to a file-local definition. - if (ts.isIdentifier(value) && DOCUMENT_ID_PARAMETER.test(helper.parameters[index] ?? "")) { - owningHelperArguments.add(value.text); - } - }); - } - if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) localDeclarations.add(node.name.text); + collectOwnerScopedDocumentFacts(sourceFile, node, context, facts); + collectOwningHelperArguments(node, context, facts.owningHelperArguments); + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) facts.localDeclarations.add(node.name.text); ts.forEachChild(node, (child) => { // A nested function's body is NOT part of this scope's execution unless the query // being proved sits inside it. Descending unconditionally let a never-invoked helper @@ -841,7 +874,7 @@ function scopeFacts(sourceFile, scopeNode, context, reachableScopes = new Set()) }; if (scopeNode) visit(scopeNode); - return { ownerScopedDocumentIds, owningHelperArguments, localDeclarations, hasOwnerScopedDocumentsQuery }; + return facts; } /* ------------------------------------------------- dynamic table dispatch */ @@ -1701,6 +1734,27 @@ function describe(site) { return `${site.file}:${site.line} ${site.table} (${site.tier}, in ${site.scope})`; } +/** Consume up to the declared count in site order, leaving every other site unmatched. */ +function matchDeclaredSites(entry, sites) { + const matched = []; + const unmatched = []; + for (const site of sites) { + if (matched.length < entry.queries && !proofFailure(entry, site)) matched.push(site); + else unmatched.push(site); + } + return { matched, unmatched }; +} + +function declaredCountViolation(entry, sites, matchedCount, label, key) { + const why = sites.map((site) => `${describe(site)} — ${proofFailure(entry, site) ?? "already matched"}`); + return ( + `${label} entry ${key} (proof ${entry.proof}) declares ${entry.queries} query(ies) but matched ${matchedCount}.` + + (why.length + ? `\n candidates: ${why.join("; ")}` + : " No query site matched it at all — remove the stale entry.") + ); +} + /** * Match declared entries against the sites actually found, and report every mismatch: * an undeclared query, a declared entry that matches nothing, a wrong query count, or a @@ -1730,20 +1784,9 @@ function reconcile(entries, sites, label, remedy = "Scope it on the chain, or ad ); let remaining = grouped.get(key) ?? []; for (const entry of ordered) { - const matched = []; - const unmatched = []; - for (const site of remaining) { - if (matched.length < entry.queries && !proofFailure(entry, site)) matched.push(site); - else unmatched.push(site); - } + const { matched, unmatched } = matchDeclaredSites(entry, remaining); if (matched.length !== entry.queries) { - const why = remaining.map((site) => `${describe(site)} — ${proofFailure(entry, site) ?? "already matched"}`); - violations.push( - `${label} entry ${key} (proof ${entry.proof}) declares ${entry.queries} query(ies) but matched ${matched.length}.` + - (why.length - ? `\n candidates: ${why.join("; ")}` - : " No query site matched it at all — remove the stale entry."), - ); + violations.push(declaredCountViolation(entry, remaining, matched.length, label, key)); } remaining = unmatched; } diff --git a/scripts/setup-codex-cloud.sh b/scripts/setup-codex-cloud.sh index 7ad66f021a..a14bc08be7 100755 --- a/scripts/setup-codex-cloud.sh +++ b/scripts/setup-codex-cloud.sh @@ -22,9 +22,15 @@ require_npm_config() { setup_step="initialization" diagnostic_python_bin="" +policy_only_harness="${CODEX_CLOUD_SETUP_STOP_AFTER_POLICY:-0}" diagnose_setup_failure() { local status="$?" trap - ERR + if [[ "$policy_only_harness" = "1" ]]; then + printf '[codex-cloud:setup] Setup failed during %s (exit %s; policy-only test harness).\n' "$setup_step" "$status" >&2 + printf '[codex-cloud:setup] FIX: review the first error above, then retry setup.\n' >&2 + exit "$status" + fi printf '[codex-cloud:setup] Setup failed during %s (exit %s). Running local diagnostics.\n' "$setup_step" "$status" >&2 if command -v node >/dev/null 2>&1 && [[ -f scripts/diagnose-codex-cloud.mjs ]]; then diagnostic_args=(--setup-step "$setup_step" --exit-code "$status") @@ -89,25 +95,29 @@ install_npm_cli() { setup_step="node-runtime" export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" -setup_step="node-runtime" -actual_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" -if ! node_version_supported "$actual_node_version"; then - [[ -s "$NVM_DIR/nvm.sh" ]] || fail "Node ${expected_node_range} is required; detected ${actual_node_version:-unavailable}. Select it in the Codex Cloud environment or provide nvm." - # shellcheck source=/dev/null - source "$NVM_DIR/nvm.sh" - log "Installing and selecting Node ${expected_node_major}.x to satisfy ${expected_node_range}." - nvm install "$expected_node_major" - nvm alias default "$expected_node_major" - nvm use "$expected_node_major" -fi +# The policy-only harness exercises shell configuration without executing or +# installing a runtime. Ordinary setup retains the full bootstrap below. +if [[ "$policy_only_harness" != "1" ]]; then + setup_step="node-runtime" + actual_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" + if ! node_version_supported "$actual_node_version"; then + [[ -s "$NVM_DIR/nvm.sh" ]] || fail "Node ${expected_node_range} is required; detected ${actual_node_version:-unavailable}. Select it in the Codex Cloud environment or provide nvm." + # shellcheck source=/dev/null + source "$NVM_DIR/nvm.sh" + log "Installing and selecting Node ${expected_node_major}.x to satisfy ${expected_node_range}." + nvm install "$expected_node_major" + nvm alias default "$expected_node_major" + nvm use "$expected_node_major" + fi -actual_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" -node_version_supported "$actual_node_version" || fail "Node ${expected_node_range} is required; detected ${actual_node_version:-unavailable}." + actual_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" + node_version_supported "$actual_node_version" || fail "Node ${expected_node_range} is required; detected ${actual_node_version:-unavailable}." -if [[ "$(npm --version)" != "$expected_npm_version" ]]; then - log "Installing the repository npm version ${expected_npm_version}." - npm install --global "npm@${expected_npm_version}" - hash -r + if [[ "$(npm --version)" != "$expected_npm_version" ]]; then + log "Installing the repository npm version ${expected_npm_version}." + npm install --global "npm@${expected_npm_version}" + hash -r + fi fi setup_step="runtime-profile" @@ -255,8 +265,8 @@ mv -f "$codex_config_candidate" "$codex_config_file" trap - EXIT # Test harness only: write the runtime profile + shell policy, then stop before -# toolchain installs so unit tests can exercise config merge without npm/Playwright. -if [[ "${CODEX_CLOUD_SETUP_STOP_AFTER_POLICY:-0}" = "1" ]]; then +# toolchain installs so unit tests can exercise config merge without a runtime. +if [[ "$policy_only_harness" = "1" ]]; then log "Stopping after shell-policy write (test harness)." exit 0 fi diff --git a/tests/codex-cloud-policy-harness.test.ts b/tests/codex-cloud-policy-harness.test.ts new file mode 100644 index 0000000000..4512a187c8 --- /dev/null +++ b/tests/codex-cloud-policy-harness.test.ts @@ -0,0 +1,136 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const bashCommand = + process.platform === "win32" + ? path.join(process.env.ProgramFiles || "C:\\Program Files", "Git/bin/bash.exe") + : "/bin/bash"; +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +function bashPath(value: string) { + if (process.platform !== "win32") return value; + return value.replace(/^([A-Za-z]):/, (_, drive: string) => `/${drive.toLowerCase()}`).replaceAll("\\", "/"); +} + +function fixture(runtime: "missing" | "mismatched") { + const home = mkdtempSync(path.join(os.tmpdir(), "cloud-policy-harness-")); + temporaryDirectories.push(home); + const bin = path.join(home, "bin"); + const nvmDirectory = path.join(home, ".nvm"); + mkdirSync(bin); + mkdirSync(nvmDirectory); + mkdirSync(path.join(home, ".codex")); + const runtimeCalls = path.join(home, "runtime-calls.log"); + for (const command of ["node", "npm"]) { + const executable = path.join(bin, command); + writeFileSync( + executable, + [ + "#!/bin/bash", + `printf '%s\\n' '${command}' >> "$CLOUD_POLICY_RUNTIME_CALLS"`, + runtime === "missing" ? "exit 127" : `printf '%s\\n' '${command === "node" ? "20.0.0" : "1.0.0"}'`, + "", + ].join("\n"), + ); + chmodSync(executable, 0o755); + } + writeFileSync( + path.join(nvmDirectory, "nvm.sh"), + 'printf "%s\\n" nvm >> "$CLOUD_POLICY_RUNTIME_CALLS"\nnvm() { return 97; }\n', + ); + + const configPath = path.join(home, ".codex/config.toml"); + const config = '[mcp_servers.preserved]\ncommand = "echo"\n'; + writeFileSync(configPath, config); + return { + home, + config, + configPath, + runtimeCalls, + run(extraEnvironment: Record = {}) { + // A minimal environment keeps inherited provider credentials and user + // setup flags out of this isolated shell. Runtime shims precede system + // utilities and log every invocation, including failure diagnostics. + return spawnSync(bashCommand, ["scripts/setup-codex-cloud.sh"], { + cwd: repoRoot, + encoding: "utf8", + env: { + NODE_ENV: "test", + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + TEMP: process.env.TEMP, + TMP: process.env.TMP, + HOME: bashPath(home), + NVM_DIR: bashPath(nvmDirectory), + PATH: `${bashPath(bin)}:${process.platform === "win32" ? "/mingw64/bin:" : ""}/usr/bin:/bin`, + CLOUD_POLICY_RUNTIME_CALLS: bashPath(runtimeCalls), + CODEX_CLOUD_ACCESS_PROFILE: "offline", + CODEX_CLOUD_SETUP_STOP_AFTER_POLICY: "1", + ...extraEnvironment, + }, + }); + }, + }; +} + +describe("Codex Cloud policy-only harness runtime isolation", () => { + it.each(["missing", "mismatched"] as const)("writes policy without probing or installing a %s runtime", (runtime) => { + const test = fixture(runtime); + const result = test.run(); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain("Stopping after shell-policy write (test harness)."); + expect(existsSync(test.runtimeCalls)).toBe(false); + const config = readFileSync(test.configPath, "utf8"); + expect(config).toContain(test.config); + expect(config).toContain("[shell_environment_policy]"); + expect(existsSync(path.join(test.home, ".clinical-kb-codex-cloud.sh"))).toBe(true); + for (const profile of [".bashrc", ".profile", ".bash_profile"]) { + expect(existsSync(path.join(test.home, profile))).toBe(false); + } + }); + + it.each(["unmanaged-policy", "atomic-write"] as const)( + "preserves configuration and failure status without runtime diagnostics after %s failure", + (failure) => { + const test = fixture("mismatched"); + const config = failure === "unmanaged-policy" ? '[shell_environment_policy]\ninherit = "all"\n' : test.config; + writeFileSync(test.configPath, config); + const result = test.run(failure === "atomic-write" ? { CODEX_CLOUD_SETUP_TEST_FAIL_ATOMIC_WRITE: "1" } : {}); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr || result.stdout).toBe(1); + expect(result.stderr).toContain("exit 1; policy-only test harness"); + expect(result.stderr).toContain("review the first error above"); + expect(result.stderr).not.toContain("Running local diagnostics"); + if (failure === "unmanaged-policy") { + expect(result.stderr).toContain("Unmanaged [shell_environment_policy] table found"); + } else { + expect(result.stdout).toContain("Forcing failure after writing the atomic candidate"); + } + expect(existsSync(test.runtimeCalls)).toBe(false); + expect(readFileSync(test.configPath, "utf8")).toBe(config); + expect(readdirSync(path.dirname(test.configPath)).filter((name) => name.startsWith(".config.toml."))).toEqual([]); + }, + ); +}); diff --git a/tests/design-system-adoption.test.ts b/tests/design-system-adoption.test.ts index 30845946c9..07e6430c8b 100644 --- a/tests/design-system-adoption.test.ts +++ b/tests/design-system-adoption.test.ts @@ -1306,6 +1306,151 @@ describe("design-system adoption manifest", () => { } }); + it("preserves indexed import, word-boundary and cyclic barrel evidence", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "design-system-adoption-index-")); + try { + writeFixtureFile( + fixtureRoot, + "docs/design-system/adoption-contract.json", + JSON.stringify({ + schemaVersion: 7, + componentFamilies: { Gamma: "fixture", Beta: "fixture", Alpha: "fixture" }, + productionSurfaces: [], + requiredProofCategories: [], + baseline: { visualBaselineStatus: "not-committed" }, + }), + ); + writeFixtureFile( + fixtureRoot, + ".design-sync/config.json", + JSON.stringify({ + pkg: "@fixture/ui", + componentSrcMap: { + Alpha: "src/components/barrel.tsx", + Beta: "src/components/barrel.tsx", + Gamma: "src/components/fallback.tsx", + }, + }), + ); + writeFixtureFile(fixtureRoot, ".design-sync/entry.tsx", 'export { Alpha, Beta, Gamma } from "@fixture/ui";'); + writeFixtureFile( + fixtureRoot, + "src/app/layout.tsx", + 'import "./shared"; const fragment = "ckb-"; export default () => ;', + ); + writeFixtureFile( + fixtureRoot, + "src/app/page.tsx", + 'import { Alpha, Beta as Alias } from "@/components/barrel"; import { Gamma } from "@/components/fallback"; const lazy = import("./lazy");', + ); + writeFixtureFile(fixtureRoot, "src/app/shared.tsx", 'import * as Kit from "@/components/barrel";'); + writeFixtureFile(fixtureRoot, "src/app/lazy.tsx", 'import { Beta } from "@/components/barrel";'); + writeFixtureFile(fixtureRoot, "src/components/reference.tsx", 'import Kit from "./barrel";'); + writeFixtureFile(fixtureRoot, "src/components/barrel.tsx", 'export * from "./alpha"; export * from "./beta";'); + writeFixtureFile(fixtureRoot, "src/components/alpha.tsx", 'export function Alpha() {} export * from "./beta";'); + writeFixtureFile(fixtureRoot, "src/components/beta.tsx", 'export function Beta() {} export * from "./alpha";'); + writeFixtureFile(fixtureRoot, "src/components/fallback.ts", "export function Gamma() {}"); + writeFixtureFile( + fixtureRoot, + "tests/a.test.ts", + "// AlphaLong Beta Gamma; comments also count as textual proof.", + ); + writeFixtureFile(fixtureRoot, "tests/z.test.ts", "// Alpha Alpha Beta Gamma"); + + const manifest = buildAdoptionManifest({ root: fixtureRoot }); + expect(manifest.components.map((component: { name: string }) => component.name)).toEqual([ + "Alpha", + "Beta", + "Gamma", + ]); + expect(manifest.components[0]).toMatchObject({ + sourceExported: true, + directImportFiles: ["src/app/page.tsx", "src/app/shared.tsx", "src/components/reference.tsx"], + productImportFiles: ["src/app/page.tsx", "src/app/shared.tsx"], + testFiles: ["tests/z.test.ts"], + }); + // The existing scanner matches local named-import spelling, so an aliased + // Beta import alone does not add Beta evidence; namespace/default imports do. + expect(manifest.components[1]).toMatchObject({ + sourceExported: true, + directImportFiles: ["src/app/lazy.tsx", "src/app/shared.tsx", "src/components/reference.tsx"], + productImportFiles: ["src/app/lazy.tsx", "src/app/shared.tsx"], + testFiles: ["tests/a.test.ts", "tests/z.test.ts"], + }); + expect(manifest.components[2]).toMatchObject({ + sourceExported: false, + directImportFiles: ["src/app/page.tsx"], + productImportFiles: ["src/app/page.tsx"], + testFiles: ["tests/a.test.ts", "tests/z.test.ts"], + }); + expect(manifest.globalShell).toMatchObject({ literalCkbV2: false, dynamicCkbV2: true }); + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true, maxRetries: 5, retryDelay: 100 }); + } + }); + + it("observes source, proof, registration and contract edits on the next build", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "design-system-adoption-rebuild-")); + try { + const contract = { + schemaVersion: 7, + componentFamilies: { Alpha: "original" }, + productionSurfaces: [], + requiredProofCategories: [], + baseline: { visualBaselineStatus: "not-committed" }, + }; + const config = { pkg: "@fixture/ui", componentSrcMap: { Alpha: "src/components/alpha.tsx" } }; + writeFixtureFile(fixtureRoot, "docs/design-system/adoption-contract.json", JSON.stringify(contract)); + writeFixtureFile(fixtureRoot, ".design-sync/config.json", JSON.stringify(config)); + writeFixtureFile(fixtureRoot, ".design-sync/entry.tsx", 'export { Alpha } from "@fixture/ui";'); + writeFixtureFile(fixtureRoot, ".design-sync/previews/Alpha.tsx", 'import { Alpha } from "@fixture/ui";'); + writeFixtureFile(fixtureRoot, "src/app/layout.tsx", 'export default () => ;'); + writeFixtureFile(fixtureRoot, "src/app/page.tsx", 'import { Alpha } from "@/components/alpha";'); + writeFixtureFile(fixtureRoot, "src/components/alpha.tsx", "export function Alpha() {}"); + writeFixtureFile(fixtureRoot, "tests/proof.test.ts", "// Alpha"); + + const before = buildAdoptionManifest({ root: fixtureRoot }); + expect(before.components[0]).toMatchObject({ + sourceExported: true, + designSync: { previewValid: true }, + testFiles: ["tests/proof.test.ts"], + }); + expect(before.globalShell.literalCkbV2).toBe(true); + + writeFixtureFile(fixtureRoot, "src/components/alpha.tsx", "export function Other() {}"); + expect(buildAdoptionManifest({ root: fixtureRoot }).components[0].sourceExported).toBe(false); + + contract.componentFamilies.Alpha = "updated"; + config.componentSrcMap.Alpha = "src/components/renamed.tsx"; + writeFixtureFile(fixtureRoot, "docs/design-system/adoption-contract.json", JSON.stringify(contract)); + writeFixtureFile(fixtureRoot, ".design-sync/config.json", JSON.stringify(config)); + writeFixtureFile(fixtureRoot, ".design-sync/entry.tsx", "export {};"); + writeFixtureFile(fixtureRoot, ".design-sync/previews/Alpha.tsx", 'import { Other } from "@fixture/ui";'); + writeFixtureFile( + fixtureRoot, + "src/app/layout.tsx", + 'const fragment = "ckb-"; export default () => ;', + ); + writeFixtureFile(fixtureRoot, "src/app/page.tsx", 'import { Alpha } from "@/components/renamed";'); + writeFixtureFile(fixtureRoot, "src/components/renamed.tsx", "export function Other() {}"); + writeFixtureFile(fixtureRoot, "tests/proof.test.ts", "// AlphaLong"); + + const after = buildAdoptionManifest({ root: fixtureRoot }); + expect(after.components[0]).toMatchObject({ + family: "updated", + source: "src/components/renamed.tsx", + sourceExported: false, + entryExported: false, + designSync: { previewValid: false }, + productImportFiles: ["src/app/page.tsx"], + testFiles: [], + }); + expect(after.globalShell).toMatchObject({ literalCkbV2: false, dynamicCkbV2: true }); + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true, maxRetries: 5, retryDelay: 100 }); + } + }); + // Full-repo rebuild + deep equality is intentionally heavy; under coverage instrumentation // of scripts/** this exceeds Vitest's default 30s (CI Unit coverage on 4b85f0e2 timed out). it("is deterministic and records declared global v2 adoption", { timeout: 90_000 }, () => { diff --git a/tests/design-system-contract-utils.test.ts b/tests/design-system-contract-utils.test.ts index 2a0cde2de4..1481bbbbdb 100644 --- a/tests/design-system-contract-utils.test.ts +++ b/tests/design-system-contract-utils.test.ts @@ -334,6 +334,34 @@ describe("design-system contract helpers", () => { ]); }); + it("keeps non-consumer TypeScript empty while still rejecting invalid CSS without --text-soft", () => { + expect( + findTextSoftConsumersInSource( + "src/example.tsx", + String.raw`const lookalike = "var(--text\u002dsoft)"; const other = "var(--decoration-soft)";`, + ), + ).toEqual([]); + expect(() => findTextSoftConsumersInSource("src/example.css", ".broken { color: red;")).toThrow(/Unclosed block/); + }); + + it("keeps CRLF locations inside multiline templates and skips comments and prefix lookalikes", () => { + const source = [ + "// var(--text-soft)", + "/* var(--text-soft)", + " var(--text-soft) */", + "const value = `prefix", + "var(--text-soft)", + "var(/* trivia */ --text-soft, red)`;", + 'const lookalike = "var(--text-softer)";', + 'const plain = "var(--text-soft)";', + ].join("\r\n"); + expect(findTextSoftConsumersInSource("src/example.tsx", source)).toEqual([ + "src/example.tsx:5", + "src/example.tsx:6", + "src/example.tsx:8", + ]); + }); + it("parses CSS declarations while ignoring declarations, comments and quoted content", () => { const source = [ ":root { --text-soft: #8894a6; --decoration-soft: #8894a6; }", From d77d16c2e168d4360b3fd4d597eb4252c695d895 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:55:10 +0800 Subject: [PATCH 2/5] Refresh repository-awareness snapshot for assurance documentation --- data/repo-awareness-snapshot.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index af60392c22..9c8b1da9be 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -2416,6 +2416,11 @@ "section": "decisions", "catalogued": false }, + { + "path": "docs/audit/codescene-non-rag-remediation-2026-10-09.md", + "section": "audit", + "catalogued": false + }, { "path": "docs/superpowers/rag-upgrade/local/specs/2026-08-21-trusted-admin-document-ingestion-design.md", "section": "superpowers", @@ -19587,6 +19592,14 @@ "outcome": "APPROVE. The remaining bottom band clipped live result content above the hidden dock because both phone application owners used viewport-sized fixed roots, a physical-iOS paint path that can disagree with correct DOM geometry. Both owners now share a bounded in-flow dynamic-viewport shell; hidden reserve remains zero, the last viewport pixel remains content-owned, and viewport resize preserves the reading offset. No P0-P3 finding remains. Highest residual risk is physical-device iOS compositing beyond desktop WebKit emulation.", "checks": "`verify:cheap` PASS (393 files; 3519 passed / 2 skipped); focused Therapy and dashboard production WebKit PASS; `verify:ui` PASS (314/314); `verify:pr-local` PASS including production build/client-secret scan and 36-case offline RAG fixtures; no live provider-backed checks." }, + { + "date": "2026-10-10", + "ref": "codescene-non-rag-current-20261010", + "head": "a9189921bcc182950e04ee41cb8ceca42f2adb2a", + "scope": "codescene-non-rag-current-20261010 independent governance integration dirty diff", + "outcome": "No governance blocker found; independent source-only dirty-diff review; current staffing declaration preserved", + "checks": "Input identities and limitations: external integration-20261010/independent-governance-review.md under psychsift-codescene-remediation-20261009; twelve reviewed source hashes in report; no provider checks or behavioral-equivalence claim" + }, { "date": "2026-07-30", "ref": "codex/review-pr1429", From f1b7565fcd850ace0f32218cb3377aaadda2059e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:14:36 +0800 Subject: [PATCH 3/5] Repair assurance review findings and record run evidence --- ...odescene-non-rag-remediation-2026-10-09.md | 49 ++++++++++++ scripts/check-codex-cloud-setup.mjs | 43 ++++++++-- scripts/ledger-inbox.mjs | 41 ++++++++-- scripts/setup-codex-cloud.sh | 3 + tests/codex-cloud-policy-harness.test.ts | 50 +++++++----- tests/codex-cloud-setup.test.ts | 19 ++++- tests/ledger-inbox.test.ts | 80 ++++++++++++++++++- 7 files changed, 252 insertions(+), 33 deletions(-) diff --git a/docs/audit/codescene-non-rag-remediation-2026-10-09.md b/docs/audit/codescene-non-rag-remediation-2026-10-09.md index 46594df271..6d10dfc98d 100644 --- a/docs/audit/codescene-non-rag-remediation-2026-10-09.md +++ b/docs/audit/codescene-non-rag-remediation-2026-10-09.md @@ -114,8 +114,57 @@ preserved. Fresh provider-free evidence: the CI scope self-test, static Cloud contracts and owner-scope scanner passed. Ten focused suites passed all 230 tests, covering CI scope, tenancy, adoption, design utilities, Cloud setup/policy and ledger handling. + +Those integration runs were fresh executions on the source later committed as +`66d21b5f482007cb47092afdea8e6ff25f85f7d4`; they were not reused receipts. The +decisive console output was: + +```text +CI change scope self-test passed. +[Codex Cloud Check] PASS: static Cloud contracts match. +✓ owner-scope phase 1: 125 src/app/api files clean against 56 owner-scoped tables. +✓ owner-scope phase 2: 169 direct, 32 user-keyed, 29 derived-tier, 2 untiered-table and 1 dynamic-table-dispatch queries scoped on their chain or declared; 8 versioned-RPC call sites, all literal. +Test Files 10 passed (10) +Tests 230 passed (230) +``` + +The Vitest run finished at `2026-10-13T06:16:18.233Z`, with result reuse disabled +(`GATE_RECEIPTS=refresh`). Its ten explicit owners were CI scope, API tenancy, +design-system adoption, design-contract utilities, Cloud setup, Cloud policy +harness, ledger inbox, remote ledger interlock, ledger cancellation and visual +baseline adoption. The later snapshot-only commit `d77d16c2e168d4360b3fd4d597eb4252c695d895` +did not rerun those suites; its fresh snapshot check reported +`[repo-awareness] in step with data/repo-awareness-snapshot.json (307 pages, 576 documents, 2779 reviews)`. + Dependency provisioning initially waited behind another worktree's exclusive lease; normal coordinator admission later succeeded and an exact-lock local donor was reused. Historical results above remain historical. Hosted CI and release readiness still require their own evidence. No merge, deployment, live canary or provider call is authorized by this repair. + +## Unresolved review findings - 13 October 2026 + +The shell-comment parser, add-request domain mismatch and stale-lock removal race +were inherited from the integration base; they were still present, so green CI +did not dispose of those comments. The follow-up preserves the existing refusal +paths while checking quoted/escaped hashes, rejecting add priorities/types the +writer cannot accept, and atomically serializing lock acquisition and stale +recovery. Tests use isolated lock fixtures, never the real reconciliation lock. +An abandoned admission claim fails closed and requires reviewed recovery; this +change does not authorize deleting a live or unclear claim. + +The policy-only shortcut now requires both an explicit `--policy-only-test-harness` +invocation and `NODE_ENV=test`; an inherited stop flag alone refuses before +runtime/configuration work. Ordinary setup without the stop flag is unchanged. + +Fresh direct checks of the follow-up source reported: + +```text +[Codex Cloud Check] PASS: static Cloud contracts match. +ledger inbox self-test passed. +``` + +Follow-up Vitest evidence is currently **unverified**. Normal admission returned +`DATABASE_HEAVY_RUN_ADMISSION_BUSY` while another dependency task held capacity; +no test or lock bypass was used. The earlier 230 passing tests are historical +evidence for the integration source, not acceptance of these new review fixes. diff --git a/scripts/check-codex-cloud-setup.mjs b/scripts/check-codex-cloud-setup.mjs index 6f3abfdfbf..886fd4d6c9 100644 --- a/scripts/check-codex-cloud-setup.mjs +++ b/scripts/check-codex-cloud-setup.mjs @@ -870,6 +870,43 @@ function validateCloudToolchainContract(sources, errors) { requireMatch(errors, gitignore, /^\/error\.log$/m, "Codex Cloud diagnostic error.log must stay ignored."); } +export function cloudSetupContainsMcpRegistration(setup) { + let quote = null; + let ansiQuoted = false; + let escaped = false; + let wordStart = true; + let uncommented = ""; + for (let index = 0; index < setup.length; index += 1) { + const character = setup[index]; + if (escaped) { + uncommented += character; + escaped = false; + continue; + } + if (quote) { + uncommented += character; + if (character === quote) { + quote = null; + ansiQuoted = false; + } else if (character === "\\" && (quote === '"' || ansiQuoted)) escaped = true; + continue; + } + if (character === "#" && wordStart) { + while (index < setup.length && setup[index] !== "\n") index += 1; + uncommented += "\n"; + wordStart = true; + continue; + } + uncommented += character; + if (character === "'" || character === '"') { + quote = character; + ansiQuoted = character === "'" && setup[index - 1] === "$"; + } else if (character === "\\") escaped = true; + wordStart = /[\s;|&()]/.test(character); + } + return /\bmcp_servers\b/.test(uncommented); +} + function validateCloudSetupLifecycleContract(sources, errors) { const { setup, pythonSelector, maintenance } = sources; for (const [pattern, message] of [ @@ -930,11 +967,7 @@ function validateCloudSetupLifecycleContract(sources, errors) { for (const name of providerCredentialVariables) { if (!setup.includes(name)) errors.push(`Cloud setup must handle provider environment variable ${name}.`); } - const setupWithoutComments = setup - .split("\n") - .map((line) => (/^\s*#/.test(line) ? "" : line.replace(/\s+#.*$/, ""))) - .join("\n"); - if (/\bmcp_servers\b/.test(setupWithoutComments)) { + if (cloudSetupContainsMcpRegistration(setup)) { errors.push("Cloud setup must not generate MCP registrations; hosted apps are external to the repository."); } if (setup.includes("@railway/cli") || setup.includes('setup_step="railway-cli"')) { diff --git a/scripts/ledger-inbox.mjs b/scripts/ledger-inbox.mjs index b47667216c..967f6ae66d 100644 --- a/scripts/ledger-inbox.mjs +++ b/scripts/ledger-inbox.mjs @@ -69,6 +69,12 @@ function validateAddRequest(request, problems) { for (const field of ["pri", "type", "summary"]) { if (!request.payload?.[field]) problems.push(`add requires ${field}`); } + if (request.payload?.pri && !["P1", "P2", "P3"].includes(String(request.payload.pri))) { + problems.push("add pri must be P1, P2, or P3"); + } + if (request.payload?.type && !["task", "issue", "rec"].includes(String(request.payload.type))) { + problems.push("add type must be task, issue, or rec"); + } if (request.payload?.issueUlid !== undefined && !isIssueUlid(request.payload.issueUlid)) { problems.push("add issueUlid must be a valid ULID"); } @@ -550,7 +556,7 @@ function reconcileLockAge(lockPath) { } } -function recoverStaleReconcileLock(lockPath) { +function recoverStaleReconcileLock(lockPath, recoverTransaction) { const owner = readReconcileLockOwner(lockPath); if (!owner && reconcileLockAge(lockPath) < OWNERLESS_LOCK_GRACE_MS) { throw new Error(`reconciliation lock at ${lockPath} is still initializing; retry after it completes`); @@ -561,25 +567,44 @@ function recoverStaleReconcileLock(lockPath) { ); } try { - recoverReconcileTransaction(lockPath); + recoverTransaction(lockPath); } catch (recoveryError) { throw new Error(`stale reconciliation requires recovery before retry: ${recoveryError.message}`); } rmSync(lockPath, { recursive: true, force: true }); } -function acquireReconcileLock() { - const lockPath = reconcileLockPath(); +export function acquireReconcileLock({ + lockPath = reconcileLockPath(), + recoverTransaction = recoverReconcileTransaction, +} = {}) { + // Every acquirer uses this atomic admission claim, including callers that + // find no existing lock. A stale recoverer cannot remove a replacement lock + // created between another recoverer's ownership check and directory removal. + const admissionPath = `${lockPath}.admission`; try { - createReconcileLock(lockPath); + mkdirSync(admissionPath); } catch (error) { if (error?.code !== "EEXIST") throw error; - recoverStaleReconcileLock(lockPath); + throw new Error( + `reconciliation admission is already claimed at ${admissionPath}; retry after release or obtain reviewed recovery`, + ); + } + try { + writeFileSync(path.join(admissionPath, "owner.json"), `${JSON.stringify({ pid: process.pid })}\n`, "utf8"); try { createReconcileLock(lockPath); - } catch (retryError) { - throw new Error(`could not acquire reconciliation lock at ${lockPath}: ${retryError.message}`); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + recoverStaleReconcileLock(lockPath, recoverTransaction); + try { + createReconcileLock(lockPath); + } catch (retryError) { + throw new Error(`could not acquire reconciliation lock at ${lockPath}: ${retryError.message}`); + } } + } finally { + rmSync(admissionPath, { recursive: true, force: true }); } return () => rmSync(lockPath, { recursive: true, force: true }); } diff --git a/scripts/setup-codex-cloud.sh b/scripts/setup-codex-cloud.sh index a14bc08be7..ca03a4e556 100755 --- a/scripts/setup-codex-cloud.sh +++ b/scripts/setup-codex-cloud.sh @@ -23,6 +23,9 @@ require_npm_config() { setup_step="initialization" diagnostic_python_bin="" policy_only_harness="${CODEX_CLOUD_SETUP_STOP_AFTER_POLICY:-0}" +if [[ "$policy_only_harness" = "1" && ( "${1:-}" != "--policy-only-test-harness" || "${NODE_ENV:-}" != "test" ) ]]; then + fail "CODEX_CLOUD_SETUP_STOP_AFTER_POLICY requires --policy-only-test-harness in NODE_ENV=test." +fi diagnose_setup_failure() { local status="$?" trap - ERR diff --git a/tests/codex-cloud-policy-harness.test.ts b/tests/codex-cloud-policy-harness.test.ts index 4512a187c8..5464d1342e 100644 --- a/tests/codex-cloud-policy-harness.test.ts +++ b/tests/codex-cloud-policy-harness.test.ts @@ -66,33 +66,47 @@ function fixture(runtime: "missing" | "mismatched") { config, configPath, runtimeCalls, - run(extraEnvironment: Record = {}) { + run(extraEnvironment: Record = {}, explicitHarness = true) { // A minimal environment keeps inherited provider credentials and user // setup flags out of this isolated shell. Runtime shims precede system // utilities and log every invocation, including failure diagnostics. - return spawnSync(bashCommand, ["scripts/setup-codex-cloud.sh"], { - cwd: repoRoot, - encoding: "utf8", - env: { - NODE_ENV: "test", - SystemRoot: process.env.SystemRoot, - WINDIR: process.env.WINDIR, - TEMP: process.env.TEMP, - TMP: process.env.TMP, - HOME: bashPath(home), - NVM_DIR: bashPath(nvmDirectory), - PATH: `${bashPath(bin)}:${process.platform === "win32" ? "/mingw64/bin:" : ""}/usr/bin:/bin`, - CLOUD_POLICY_RUNTIME_CALLS: bashPath(runtimeCalls), - CODEX_CLOUD_ACCESS_PROFILE: "offline", - CODEX_CLOUD_SETUP_STOP_AFTER_POLICY: "1", - ...extraEnvironment, + return spawnSync( + bashCommand, + ["scripts/setup-codex-cloud.sh", ...(explicitHarness ? ["--policy-only-test-harness"] : [])], + { + cwd: repoRoot, + encoding: "utf8", + env: { + NODE_ENV: "test", + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + TEMP: process.env.TEMP, + TMP: process.env.TMP, + HOME: bashPath(home), + NVM_DIR: bashPath(nvmDirectory), + PATH: `${bashPath(bin)}:${process.platform === "win32" ? "/mingw64/bin:" : ""}/usr/bin:/bin`, + CLOUD_POLICY_RUNTIME_CALLS: bashPath(runtimeCalls), + CODEX_CLOUD_ACCESS_PROFILE: "offline", + CODEX_CLOUD_SETUP_STOP_AFTER_POLICY: "1", + ...extraEnvironment, + }, }, - }); + ); }, }; } describe("Codex Cloud policy-only harness runtime isolation", () => { + it.each(["inherited-flag", "ordinary-runtime"] as const)("rejects a policy-only shortcut in %s setup", (scenario) => { + const test = fixture("missing"); + const result = scenario === "inherited-flag" ? test.run({}, false) : test.run({ NODE_ENV: "production" }); + expect(result.status, result.stderr || result.stdout).toBe(1); + expect(result.stderr).toContain("requires --policy-only-test-harness in NODE_ENV=test"); + expect(readFileSync(test.configPath, "utf8")).toBe(test.config); + expect(existsSync(test.runtimeCalls)).toBe(false); + expect(existsSync(path.join(test.home, ".clinical-kb-codex-cloud.sh"))).toBe(false); + }); + it.each(["missing", "mismatched"] as const)("writes policy without probing or installing a %s runtime", (runtime) => { const test = fixture(runtime); const result = test.run(); diff --git a/tests/codex-cloud-setup.test.ts b/tests/codex-cloud-setup.test.ts index 37ac743998..b7894555cc 100644 --- a/tests/codex-cloud-setup.test.ts +++ b/tests/codex-cloud-setup.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { codexCloudValidationScope, + cloudSetupContainsMcpRegistration, configuredProviderCredentialNames, expectedMcpConfiguration, executableFile, @@ -126,13 +127,14 @@ function runSetupPolicyOnly(home: string, env: Record { + it.each([ + 'printf "%s\\n" "# [mcp_servers.hidden]"', + "printf '%s\\n' '# [mcp_servers.hidden]'", + 'printf "%s\\n" "prefix # [mcp_servers.hidden]"', + "printf '%s\\n' prefix\\ #mcp_servers.hidden", + 'printf "%s\\n" "first line\n# [mcp_servers.hidden]"', + "printf '%s\\n' $'escaped\\' # [mcp_servers.hidden]'", + ])("detects MCP output despite quoted or escaped hashes: %s", (source) => { + expect(cloudSetupContainsMcpRegistration(source)).toBe(true); + }); + + it("ignores real full-line and inline shell comments", () => { + expect(cloudSetupContainsMcpRegistration('# mcp_servers.hidden\nprintf "policy" # mcp_servers.hidden')).toBe(false); + }); + it("keeps the checked-in setup reproducible and provider-safe", () => { const staticEnvironment = { ...process.env }; delete staticEnvironment.CODEX_CLOUD; diff --git a/tests/ledger-inbox.test.ts b/tests/ledger-inbox.test.ts index 2f1df90641..40540e3f9b 100644 --- a/tests/ledger-inbox.test.ts +++ b/tests/ledger-inbox.test.ts @@ -1,6 +1,15 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; -import { applyRequest, applyRequestBatch, planRequestBatch, validateRequest } from "../scripts/ledger-inbox.mjs"; +import { + acquireReconcileLock, + applyRequest, + applyRequestBatch, + planRequestBatch, + validateRequest, +} from "../scripts/ledger-inbox.mjs"; import { mergeArchiveOutcome, pruneResolvedIdFromQueue, @@ -47,6 +56,75 @@ const BASE_LEDGER = [ "", ].join("\n"); +describe("ledger inbox intake and lock admission", () => { + const add = { + version: 1, + id: "11111111-1111-4111-8111-111111111111", + createdOn: "2026-10-13", + action: "add", + payload: { pri: "P2", type: "task", summary: "Synthetic fixture" }, + }; + + it.each(["P9", "p2", "high"])("rejects an unsupported add priority before enqueue: %s", (pri) => { + expect(validateRequest({ ...add, payload: { ...add.payload, pri } })).toContain("add pri must be P1, P2, or P3"); + }); + + it.each(["bug", "TASK", "other"])("rejects an unsupported add type before enqueue: %s", (type) => { + expect(validateRequest({ ...add, payload: { ...add.payload, type } })).toContain( + "add type must be task, issue, or rec", + ); + }); + + it.each(["P1", "P2", "P3"])("accepts every writer-supported type at %s", (pri) => { + for (const type of ["task", "issue", "rec"]) { + const request = { ...add, payload: { ...add.payload, pri, type } }; + expect(validateRequest(request)).toEqual([]); + expect(() => applyRequest(BASE_LEDGER, request)).not.toThrow(); + } + }); + + it("serializes two stale recoverers before either can remove a replacement live lock", () => { + const fixture = mkdtempSync(path.join(os.tmpdir(), "ledger-lock-admission-")); + const lockPath = path.join(fixture, "reconcile.lock"); + mkdirSync(lockPath); + writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify({ pid: 2147483647 })); + let release: (() => void) | undefined; + try { + release = acquireReconcileLock({ + lockPath, + recoverTransaction: () => { + // Deterministically enter the competing acquirer at the old race + // window, after stale-owner inspection but before lock removal. + expect(() => acquireReconcileLock({ lockPath })).toThrow(/admission is already claimed/); + expect(JSON.parse(readFileSync(path.join(lockPath, "owner.json"), "utf8")).pid).toBe(2147483647); + }, + }); + const owner = readFileSync(path.join(lockPath, "owner.json"), "utf8"); + expect(JSON.parse(owner).pid).toBe(process.pid); + expect(() => acquireReconcileLock({ lockPath })).toThrow(/another reconciliation is active/); + expect(readFileSync(path.join(lockPath, "owner.json"), "utf8")).toBe(owner); + } finally { + release?.(); + rmSync(fixture, { recursive: true, force: true }); + } + }); + + it("fails closed on an abandoned admission claim without deleting the stale lock", () => { + const fixture = mkdtempSync(path.join(os.tmpdir(), "ledger-lock-admission-")); + const lockPath = path.join(fixture, "reconcile.lock"); + mkdirSync(lockPath); + mkdirSync(`${lockPath}.admission`); + const owner = JSON.stringify({ pid: 2147483647 }); + writeFileSync(path.join(lockPath, "owner.json"), owner); + try { + expect(() => acquireReconcileLock({ lockPath })).toThrow(/obtain reviewed recovery/); + expect(readFileSync(path.join(lockPath, "owner.json"), "utf8")).toBe(owner); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } + }); +}); + describe("ledger-inbox idempotent close and duplicate done handling", () => { describe("idempotent close of already-archived issues", () => { it("preserves existing outcome and merges new outcome note when applying done request to archived issue", () => { From 90d05aa49ba65c436fa128bbc23b18c048f64451 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:39:20 +0800 Subject: [PATCH 4/5] Cover nested shell quoting and publish focused review evidence --- ...odescene-non-rag-remediation-2026-10-09.md | 21 +++++++++-- scripts/check-codex-cloud-setup.mjs | 37 ++++++++++++++++++- tests/codex-cloud-setup.test.ts | 4 ++ tests/ledger-inbox.test.ts | 1 + 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/docs/audit/codescene-non-rag-remediation-2026-10-09.md b/docs/audit/codescene-non-rag-remediation-2026-10-09.md index 6d10dfc98d..20e372898f 100644 --- a/docs/audit/codescene-non-rag-remediation-2026-10-09.md +++ b/docs/audit/codescene-non-rag-remediation-2026-10-09.md @@ -164,7 +164,20 @@ Fresh direct checks of the follow-up source reported: ledger inbox self-test passed. ``` -Follow-up Vitest evidence is currently **unverified**. Normal admission returned -`DATABASE_HEAVY_RUN_ADMISSION_BUSY` while another dependency task held capacity; -no test or lock bypass was used. The earlier 230 passing tests are historical -evidence for the integration source, not acceptance of these new review fixes. +The initial follow-up Vitest attempt did not run: normal admission returned +`DATABASE_HEAVY_RUN_ADMISSION_BUSY` while another dependency task held capacity. +After normal release, the final review-fix source passed five focused owners with +result reuse disabled (`GATE_RECEIPTS=refresh`): ledger inbox, remote ledger +interlock, ledger cancellation, Cloud setup and the policy-only harness. + +```text +Test Files 5 passed (5) +Tests 109 passed (109) +``` + +These tests include quoted/escaped hashes and nested shell substitutions, invalid +add domains and accepted writer domains, the deterministic stale-recoverer race, +an abandoned admission claim, and inherited/ordinary-runtime harness refusal. +No test or lock bypass was used. The earlier 230 passing tests remain historical +evidence for the integration source. Local passing checks and unresolved review +threads do not establish merge or release clearance. diff --git a/scripts/check-codex-cloud-setup.mjs b/scripts/check-codex-cloud-setup.mjs index 886fd4d6c9..9ae1c096ca 100644 --- a/scripts/check-codex-cloud-setup.mjs +++ b/scripts/check-codex-cloud-setup.mjs @@ -876,6 +876,7 @@ export function cloudSetupContainsMcpRegistration(setup) { let escaped = false; let wordStart = true; let uncommented = ""; + const substitutions = []; for (let index = 0; index < setup.length; index += 1) { const character = setup[index]; if (escaped) { @@ -883,6 +884,29 @@ export function cloudSetupContainsMcpRegistration(setup) { escaped = false; continue; } + if (quote !== "'" && character === "$" && ["(", "{"].includes(setup[index + 1])) { + const opening = setup[++index]; + substitutions.push({ quote, closing: opening === "(" ? ")" : "}", depth: 1 }); + quote = null; + ansiQuoted = false; + uncommented += `$${opening}`; + wordStart = true; + continue; + } + if (quote !== "'" && character === "`") { + const active = substitutions.at(-1); + if (!quote && active?.closing === "`") { + quote = substitutions.pop().quote; + wordStart = false; + } else { + substitutions.push({ quote, closing: "`", depth: 1 }); + quote = null; + wordStart = true; + } + ansiQuoted = false; + uncommented += character; + continue; + } if (quote) { uncommented += character; if (character === quote) { @@ -891,7 +915,18 @@ export function cloudSetupContainsMcpRegistration(setup) { } else if (character === "\\" && (quote === '"' || ansiQuoted)) escaped = true; continue; } - if (character === "#" && wordStart) { + const active = substitutions.at(-1); + if (active && active.closing !== "`") { + if (character === active.closing) { + active.depth -= 1; + if (active.depth === 0) quote = substitutions.pop().quote; + uncommented += character; + wordStart = false; + continue; + } + if (character === (active.closing === ")" ? "(" : "{")) active.depth += 1; + } + if (character === "#" && wordStart && active?.closing !== "}") { while (index < setup.length && setup[index] !== "\n") index += 1; uncommented += "\n"; wordStart = true; diff --git a/tests/codex-cloud-setup.test.ts b/tests/codex-cloud-setup.test.ts index b7894555cc..552dddf1a0 100644 --- a/tests/codex-cloud-setup.test.ts +++ b/tests/codex-cloud-setup.test.ts @@ -157,12 +157,16 @@ describe("Codex Cloud environment contract", () => { "printf '%s\\n' prefix\\ #mcp_servers.hidden", 'printf "%s\\n" "first line\n# [mcp_servers.hidden]"', "printf '%s\\n' $'escaped\\' # [mcp_servers.hidden]'", + 'printf "%s" "$(printf "prefix # [mcp_servers.hidden]")"', + 'printf "%s" "`printf "prefix # [mcp_servers.hidden]"`"', + 'printf "%s" "${unused:-"prefix # [mcp_servers.hidden]"}"', ])("detects MCP output despite quoted or escaped hashes: %s", (source) => { expect(cloudSetupContainsMcpRegistration(source)).toBe(true); }); it("ignores real full-line and inline shell comments", () => { expect(cloudSetupContainsMcpRegistration('# mcp_servers.hidden\nprintf "policy" # mcp_servers.hidden')).toBe(false); + expect(cloudSetupContainsMcpRegistration('printf "%s" "$(printf policy)" # mcp_servers.hidden')).toBe(false); }); it("keeps the checked-in setup reproducible and provider-safe", () => { diff --git a/tests/ledger-inbox.test.ts b/tests/ledger-inbox.test.ts index 40540e3f9b..cb6efec0ca 100644 --- a/tests/ledger-inbox.test.ts +++ b/tests/ledger-inbox.test.ts @@ -97,6 +97,7 @@ describe("ledger inbox intake and lock admission", () => { // window, after stale-owner inspection but before lock removal. expect(() => acquireReconcileLock({ lockPath })).toThrow(/admission is already claimed/); expect(JSON.parse(readFileSync(path.join(lockPath, "owner.json"), "utf8")).pid).toBe(2147483647); + return false; }, }); const owner = readFileSync(path.join(lockPath, "owner.json"), "utf8"); From f642a3bc75731c806ea32eb3a623b3f017335795 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:27:26 +0800 Subject: [PATCH 5/5] test: retry recursive ledger fixture cleanup --- tests/ledger-inbox.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ledger-inbox.test.ts b/tests/ledger-inbox.test.ts index cb6efec0ca..ef993d6ea5 100644 --- a/tests/ledger-inbox.test.ts +++ b/tests/ledger-inbox.test.ts @@ -106,7 +106,7 @@ describe("ledger inbox intake and lock admission", () => { expect(readFileSync(path.join(lockPath, "owner.json"), "utf8")).toBe(owner); } finally { release?.(); - rmSync(fixture, { recursive: true, force: true }); + rmSync(fixture, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }); @@ -121,7 +121,7 @@ describe("ledger inbox intake and lock admission", () => { expect(() => acquireReconcileLock({ lockPath })).toThrow(/obtain reviewed recovery/); expect(readFileSync(path.join(lockPath, "owner.json"), "utf8")).toBe(owner); } finally { - rmSync(fixture, { recursive: true, force: true }); + rmSync(fixture, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }); });