diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8268895..ad82cb7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,8 +38,8 @@ jobs: - run: npm run build - name: Run every suite except the dynamics sandbox builds run: >- - node scripts/run-tests.mjs - $(find src web/src -name '*.test.ts' -not -path 'src/dynamics/*' | sort) + node dist/scripts/run-tests.js + $(find src web/src scripts -name '*.test.ts' -not -path 'src/dynamics/*' | sort) dynamics-sandbox-builds: name: dynamics sandbox builds @@ -55,5 +55,5 @@ jobs: - run: npm run build - name: Build and load dynamics packages in hermetic sandboxes run: >- - node scripts/run-tests.mjs + node dist/scripts/run-tests.js $(find src/dynamics -name '*.test.ts' | sort) diff --git a/.gitignore b/.gitignore index 1220b99..eceb585 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,6 @@ coverage/ .simfile-dev/ .simfile-composed/ runs/* -!runs/real-grok-composed/ -!runs/office-world-v0/ here.txt .env .DS_Store diff --git a/AGENTS.md b/AGENTS.md index ed3fd01..f2ae953 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,10 @@ checked out anywhere; never infer a sibling repository or import its source. ├── docs/ # Design and research docs: DESIGN, VIEW_DESIGN, │ # VIEW_STYLEGUIDE, SITE_DESIGN, SYSTEMS_VIEW, │ # RESEARCH, COVERAGE +├── archive/ # Historical plans and reviews +├── fixtures/observe/ # Frozen recorded evidence for tests +├── scripts/ # Maintained TypeScript development tools +├── tools/ # Maintained TypeScript package verification tools ├── package.json # npm package metadata and CLI scripts ├── tsconfig.json # Typecheck config ├── tsconfig.build.json # Build-only emit config @@ -22,7 +26,8 @@ checked out anywhere; never infer a sibling repository or import its source. - Keep Simfile aligned with `docs/DESIGN.md`. - Keep the schema genre-neutral. Domain concepts belong in fixtures, not keys. -- Named exports only. +- Named exports only. Maintained repository tooling must be strict TypeScript. +- Keep historical plans and reviews in `archive/`; recorded test evidence belongs in `fixtures/`, while local runs remain ignored. - Add nested `AGENTS.md` files for implementation folders and compatibility `CLAUDE.md` symlinks pointing to them. - Keep source files under 400 lines. - Keep tests beside the files they cover. diff --git a/README.md b/README.md index 01c492f..b66daf5 100644 --- a/README.md +++ b/README.md @@ -241,3 +241,10 @@ Full documentation at [**simfile.org**](https://simfile.org). Design and researc ## License MIT + +## Repository maintenance + +- [Archived plans and reviews](archive/): historical implementation notes. +- [Observer test recordings](fixtures/observe/): frozen evidence used by tests. +- `runs/`: ignored local execution output. +- `scripts/` and `tools/`: maintained development and package-verification tooling. diff --git a/PLAN.md b/archive/PLAN.md similarity index 100% rename from PLAN.md rename to archive/PLAN.md diff --git a/PLAN_REVIEW.md b/archive/PLAN_REVIEW.md similarity index 100% rename from PLAN_REVIEW.md rename to archive/PLAN_REVIEW.md diff --git a/archive/README.md b/archive/README.md new file mode 100644 index 0000000..69d57af --- /dev/null +++ b/archive/README.md @@ -0,0 +1,13 @@ +# Archive + +Historical plans and reviews are retained here for context, not as current +acceptance gates or operating instructions. + +- [Composed example plan](PLAN.md): original implementation checklist and + external compatibility blockers. +- [Plan review](PLAN_REVIEW.md): review of that work in progress. + +Current behavior is described by the [project README](../README.md), +[design](../docs/DESIGN.md), and tested public contracts. These historical files +refer to old releases and working-tree states; their status claims are not +current verification results. diff --git a/fixtures/observe/README.md b/fixtures/observe/README.md new file mode 100644 index 0000000..91e518d --- /dev/null +++ b/fixtures/observe/README.md @@ -0,0 +1,13 @@ +# Observer fixtures + +Recorded run evidence used by viewer and observation tests belongs here. +These are frozen test inputs, not live runs or current operating state. + +- `office-sim-golden/`: canonical reconciled office simulation evidence. +- `office-world-v0-golden/`: recorded world playback trace for viewer-server tests. +- `real-grok-composed/`: recorded composed-run evidence used by timeline, + raw-artifact, world-trace, and run-model tests. + +Keep each recording's internal paths, identifiers, and artifact digests intact. +Tests reference these fixtures directly. New local runs belong under ignored +`runs/`; never add a gitignore exception to track execution output there. diff --git a/runs/office-world-v0/ledger.jsonl b/fixtures/observe/office-world-v0-golden/ledger.jsonl similarity index 100% rename from runs/office-world-v0/ledger.jsonl rename to fixtures/observe/office-world-v0-golden/ledger.jsonl diff --git a/runs/office-world-v0/manifest.yaml b/fixtures/observe/office-world-v0-golden/manifest.yaml similarity index 100% rename from runs/office-world-v0/manifest.yaml rename to fixtures/observe/office-world-v0-golden/manifest.yaml diff --git a/runs/office-world-v0/moltnet-transcript.json b/fixtures/observe/office-world-v0-golden/moltnet-transcript.json similarity index 100% rename from runs/office-world-v0/moltnet-transcript.json rename to fixtures/observe/office-world-v0-golden/moltnet-transcript.json diff --git a/runs/office-world-v0/report.json b/fixtures/observe/office-world-v0-golden/report.json similarity index 100% rename from runs/office-world-v0/report.json rename to fixtures/observe/office-world-v0-golden/report.json diff --git a/runs/office-world-v0/telemetry.json b/fixtures/observe/office-world-v0-golden/telemetry.json similarity index 100% rename from runs/office-world-v0/telemetry.json rename to fixtures/observe/office-world-v0-golden/telemetry.json diff --git a/runs/office-world-v0/viewer-trace.json b/fixtures/observe/office-world-v0-golden/viewer-trace.json similarity index 100% rename from runs/office-world-v0/viewer-trace.json rename to fixtures/observe/office-world-v0-golden/viewer-trace.json diff --git a/runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/manifest.json b/fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/manifest.json similarity index 100% rename from runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/manifest.json rename to fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/manifest.json diff --git a/runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/observe/report.json b/fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/observe/report.json similarity index 100% rename from runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/observe/report.json rename to fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/observe/report.json diff --git a/runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/daimon/eleanor/causal.jsonl b/fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/daimon/eleanor/causal.jsonl similarity index 100% rename from runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/daimon/eleanor/causal.jsonl rename to fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/daimon/eleanor/causal.jsonl diff --git a/runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/daimon/sam/causal.jsonl b/fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/daimon/sam/causal.jsonl similarity index 100% rename from runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/daimon/sam/causal.jsonl rename to fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/daimon/sam/causal.jsonl diff --git a/runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/mneme/office-recall/causal.jsonl b/fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/mneme/office-recall/causal.jsonl similarity index 100% rename from runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/mneme/office-recall/causal.jsonl rename to fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/mneme/office-recall/causal.jsonl diff --git a/runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/mneme/office-recall/events.jsonl b/fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/mneme/office-recall/events.jsonl similarity index 100% rename from runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/mneme/office-recall/events.jsonl rename to fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/mneme/office-recall/events.jsonl diff --git a/runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/moltnet/causal.jsonl b/fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/moltnet/causal.jsonl similarity index 100% rename from runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/moltnet/causal.jsonl rename to fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/moltnet/causal.jsonl diff --git a/runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/moltnet/transcript.json b/fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/moltnet/transcript.json similarity index 100% rename from runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/moltnet/transcript.json rename to fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/raw/moltnet/transcript.json diff --git a/runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/spawnfile/export-index.json b/fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/spawnfile/export-index.json similarity index 100% rename from runs/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/spawnfile/export-index.json rename to fixtures/observe/real-grok-composed/run-b7ef07f0fd2c4779894c2bb746140972/spawnfile/export-index.json diff --git a/package-lock.json b/package-lock.json index 7ddc9c3..ca7db8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1851,9 +1851,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -2041,9 +2041,9 @@ } }, "node_modules/hono": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", - "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -2426,9 +2426,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", diff --git a/package.json b/package.json index a8597de..080fa1f 100644 --- a/package.json +++ b/package.json @@ -87,41 +87,32 @@ "web/dist", "README.md", "examples/jungian-dialogue", - "examples/composed-development", - "scripts/bounded-process.mjs", - "scripts/simfile-local-example.mjs", - "scripts/spawnfile-capability-probe.mjs", - "scripts/spawnfile-composed-smoke.mjs", - "scripts/spawnfile-development.mjs", - "scripts/spawnfile-development-context.mjs", - "scripts/spawnfile-development-setup.mjs", - "scripts/spawnfile-install-integrity.mjs", - "scripts/spawnfile-local-endpoint.mjs", - "scripts/spawnfile-source-stage.mjs" + "examples/composed-development" ], "repository": { "type": "git", "url": "git+ssh://git@github.com/noopolis/simfile.git" }, "scripts": { - "build": "rm -rf dist web/dist && tsc --project tsconfig.build.json && npm run build:web && chmod +x dist/cli/index.js", + "build": "rm -rf dist web/dist && tsc --project tsconfig.build.json && npm run build:scripts && npm run build:web && chmod +x dist/cli/index.js", + "build:scripts": "tsc --project tsconfig.scripts.build.json", "build:web": "vite build --config web/vite.config.ts", "build:website": "npm --prefix website run build", "clean": "rm -rf dist web/dist", "prepack": "npm run build", "prepublishOnly": "npm run typecheck && npm test", - "typecheck": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.web.json --noEmit", + "typecheck": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.web.json --noEmit && tsc --project tsconfig.scripts.json", "pretest": "npm run build", - "test": "node scripts/run-tests.mjs", - "coverage:render": "node --import tsx scripts/render-coverage.ts", - "example:composed": "npm run build && node scripts/spawnfile-composed-smoke.mjs", - "example:internal-smoke": "npm run build && node scripts/spawnfile-composed-smoke.mjs --internal-lifecycle-smoke", - "example:local": "npm run build && node scripts/simfile-local-example.mjs", - "dev:spawnfile:check": "node scripts/spawnfile-development.mjs check", - "dev:spawnfile:run": "npm run build && node scripts/spawnfile-composed-smoke.mjs", - "dev:spawnfile:setup": "node scripts/spawnfile-development.mjs setup", - "dev:spawnfile:status": "node scripts/spawnfile-development.mjs status", - "verify:package-closure": "node tools/verify-package-closure.mjs", + "test": "node dist/scripts/run-tests.js", + "coverage:render": "node --experimental-strip-types scripts/render-coverage.ts", + "example:composed": "npm run build && node dist/scripts/spawnfile-composed-smoke.js", + "example:internal-smoke": "npm run build && node dist/scripts/spawnfile-composed-smoke.js --internal-lifecycle-smoke", + "example:local": "npm run build && node dist/scripts/simfile-local-example.js", + "dev:spawnfile:check": "node dist/scripts/spawnfile-development.js check", + "dev:spawnfile:run": "npm run build && node dist/scripts/spawnfile-composed-smoke.js", + "dev:spawnfile:setup": "node dist/scripts/spawnfile-development.js setup", + "dev:spawnfile:status": "node dist/scripts/spawnfile-development.js status", + "verify:package-closure": "npm run build && node dist/tools/verify-package-closure.js", "emit-causal-fixture": "tsx src/runtime/emit-causal-fixture.ts" }, "engines": { diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index e63a0c4..b8c6f2e 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -14,8 +14,11 @@ credentials. - Prefer versioned JSON receipts so tests and documentation can make exact claims about what a setup proves. - Every loop, poll, and subprocess wait must have a finite end condition. -- `spawnfile-development.mjs` dispatches setup/check/status; its context and - install transaction live in `spawnfile-development-context.mjs` and - `spawnfile-development-setup.mjs`. -- `spawnfile-composed-smoke.mjs` must prove the selected endpoint is local via - `spawnfile-local-endpoint.mjs` before it starts the built Simfile CLI. +- Maintained scripts are strict TypeScript sources. Local source-only tools may + run with Node's native `--experimental-strip-types`; package scripts and + installed-package checks must use emitted JavaScript under `dist/scripts/`. +- `spawnfile-development.ts` dispatches setup/check/status; its context and + install transaction live in `spawnfile-development-context.ts` and + `spawnfile-development-setup.ts`. +- `spawnfile-composed-smoke.ts` must prove the selected endpoint is local via + `spawnfile-local-endpoint.ts` before it starts the built Simfile CLI. diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..8dbacbd --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,25 @@ +# Development Scripts + +These scripts are maintained TypeScript source. Source checkouts run them after +`npm run build` through the emitted JavaScript under `dist/scripts/`, while +source-only maintenance such as coverage rendering uses Node's native type +stripping. + +The scripts do repository-local development work only: + +- `run-tests.ts` runs the local Node test suite and rejects empty or cancelled + TAP runs. +- `simfile-local-example.ts` runs the checked-in local example through the + freshly built CLI. +- `spawnfile-development.ts` owns isolated Spawnfile setup, status, and check + commands under `.simfile-dev/`. +- `spawnfile-composed-smoke.ts` runs the composed smoke path after proving the + selected Spawnfile endpoint is local. +- `spawnfile-capability-probe.ts`, `spawnfile-local-endpoint.ts`, + `spawnfile-install-integrity.ts`, and `spawnfile-source-stage.ts` are shared + helpers for those entrypoints. +- `render-coverage.ts` regenerates `docs/COVERAGE.md` from source coverage + metadata. + +Do not import Spawnfile source or infer sibling checkouts here. Inputs must be +explicit package coordinates, artifacts, or absolute source paths. diff --git a/scripts/bounded-process.mjs b/scripts/bounded-process.ts similarity index 68% rename from scripts/bounded-process.mjs rename to scripts/bounded-process.ts index def358c..6f49d4f 100644 --- a/scripts/bounded-process.mjs +++ b/scripts/bounded-process.ts @@ -1,12 +1,30 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcessByStdio } from "node:child_process"; import path from "node:path"; +import type { Readable } from "node:stream"; const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024 * 1024; const TERMINATION_GRACE_MS = 1_000; const QUIESCENCE_TIMEOUT_MS = 1_000; const QUIESCENCE_POLL_MS = 25; -const signalProcessGroup = (child, signal) => { +export interface BoundedProcessResult { + code: number; + stderr: string; + stdout: string; +} + +export interface BoundedProcessOptions { + allowNonzero?: boolean; + cwd?: string; + env?: NodeJS.ProcessEnv; + maxOutputBytes?: number; + timeoutMs?: number; +} + +type BoundedChild = ChildProcessByStdio; +type TerminationReason = "output" | "timeout"; + +const signalProcessGroup = (child: BoundedChild, signal: NodeJS.Signals): boolean => { try { if (process.platform !== "win32" && child.pid !== undefined) { process.kill(-child.pid, signal); @@ -14,23 +32,24 @@ const signalProcessGroup = (child, signal) => { } child.kill(signal); return true; - } catch (error) { - if (error?.code === "ESRCH") return false; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; throw error; } }; -const processTreeIsAlive = (child) => { +const processTreeIsAlive = (child: BoundedChild): boolean => { if (process.platform === "win32") return child.exitCode === null && child.signalCode === null; - if (!Number.isSafeInteger(child.pid) || child.pid <= 1 || child.pid === process.pid) { + const pid = child.pid; + if (pid === undefined || !Number.isSafeInteger(pid) || pid <= 1 || pid === process.pid) { throw new Error("Development subprocess group identity is invalid"); } try { - process.kill(-child.pid, 0); + process.kill(-pid, 0); return true; - } catch (error) { - if (error?.code === "ESRCH") return false; - if (error?.code === "EPERM") return true; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + if ((error as NodeJS.ErrnoException).code === "EPERM") return true; throw error; } }; @@ -39,7 +58,11 @@ const processTreeIsAlive = (child) => { * Runs a bounded subprocess. On a timeout or bounded-output failure, the * entire detached POSIX process group is reaped before the promise settles. */ -export const runBoundedProcess = (command, args, options = {}) => new Promise((resolve, reject) => { +export const runBoundedProcess = ( + command: string, + args: readonly string[], + options: BoundedProcessOptions = {} +): Promise => new Promise((resolve, reject) => { const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000; if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30 * 60 * 1000) { reject(new TypeError("Development subprocess timeout is invalid")); @@ -60,10 +83,10 @@ export const runBoundedProcess = (command, args, options = {}) => new Promise((r let stdout = ""; let stderr = ""; let settled = false; - let termination; - let timeoutTimer; - let forceTimer; - let quiescenceTimer; + let termination: { reason: TerminationReason } | undefined; + let timeoutTimer: NodeJS.Timeout | undefined; + let forceTimer: NodeJS.Timeout | undefined; + let quiescenceTimer: NodeJS.Timeout | undefined; let forceSent = false; let quiescenceDeadline = 0; @@ -72,20 +95,20 @@ export const runBoundedProcess = (command, args, options = {}) => new Promise((r if (forceTimer !== undefined) clearTimeout(forceTimer); if (quiescenceTimer !== undefined) clearTimeout(quiescenceTimer); }; - const settle = (outcome) => { + const settle = (outcome: () => void): void => { if (settled) return; settled = true; clearTimers(); outcome(); }; - const terminationError = () => termination.reason === "timeout" + const terminationError = (): Error => termination?.reason === "timeout" ? new Error(`${path.basename(command)} exceeded its ${timeoutMs}ms timeout`) : new Error(`${path.basename(command)} exceeded the bounded output limit`); - const awaitQuiescence = () => { + const awaitQuiescence = (): void => { quiescenceTimer = undefined; let alive; try { alive = processTreeIsAlive(child); } - catch (error) { settle(() => reject(error)); return; } + catch (error: unknown) { settle(() => reject(error)); return; } if (!alive) { settle(() => reject(terminationError())); return; @@ -98,7 +121,7 @@ export const runBoundedProcess = (command, args, options = {}) => new Promise((r } quiescenceTimer = setTimeout(awaitQuiescence, QUIESCENCE_POLL_MS); }; - const terminate = (reason) => { + const terminate = (reason: TerminationReason): void => { if (termination !== undefined) return; termination = { reason }; try { @@ -108,16 +131,16 @@ export const runBoundedProcess = (command, args, options = {}) => new Promise((r forceSent = true; quiescenceDeadline = Date.now() + QUIESCENCE_TIMEOUT_MS; signalProcessGroup(child, "SIGKILL"); - } catch (error) { + } catch (error: unknown) { settle(() => reject(error)); } }, TERMINATION_GRACE_MS); awaitQuiescence(); - } catch (error) { + } catch (error: unknown) { settle(() => reject(error)); } }; - const retain = (current, chunk) => { + const retain = (current: string, chunk: string): string => { if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(chunk, "utf8") > maxOutputBytes) { terminate("output"); diff --git a/scripts/ci-workflow.test.ts b/scripts/ci-workflow.test.ts new file mode 100644 index 0000000..fe9310b --- /dev/null +++ b/scripts/ci-workflow.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import { parse } from "yaml"; + +type WorkflowStep = Readonly<{ name?: string; run?: string }>; +type WorkflowJob = Readonly<{ steps?: readonly WorkflowStep[] }>; +type Workflow = Readonly<{ jobs?: Record }>; + +const workflowPath = ".github/workflows/test.yml"; + +const readWorkflow = async (): Promise => parse(await readFile(workflowPath, "utf8")) as Workflow; + +test("CI jobs call the built test runner", async () => { + const workflow = await readWorkflow(); + const jobs = workflow.jobs ?? {}; + const runs = Object.values(jobs).flatMap((job) => job.steps ?? []) + .map((step) => step.run ?? "") + .filter((run) => run.includes("run-tests")); + + assert.equal(runs.length, 2); + assert.equal(runs.every((run) => run.includes("node dist/scripts/run-tests.js")), true); + assert.equal(runs.some((run) => /find\s+src\s+web\/src\s+scripts\s+-name\s+'\*\.test\.ts'/.test(run)), true); + assert.equal(runs.some((run) => run.includes("scripts/run-tests.mjs")), false); +}); diff --git a/scripts/entrypoint.ts b/scripts/entrypoint.ts new file mode 100644 index 0000000..191b337 --- /dev/null +++ b/scripts/entrypoint.ts @@ -0,0 +1,20 @@ +import { realpathSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const asPath = (value: string): string => value.startsWith("file:") ? fileURLToPath(value) : value; + +const canonicalPath = (value: string): string => { + const resolved = path.resolve(asPath(value)); + try { return realpathSync.native(resolved); } + catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return resolved; + throw error; + } +}; + +export const isMainModule = (moduleUrl: string, argv: readonly string[] = process.argv): boolean => { + const entrypoint = argv[1]; + if (entrypoint === undefined) return false; + return canonicalPath(fileURLToPath(moduleUrl)) === canonicalPath(entrypoint); +}; diff --git a/scripts/package-root.ts b/scripts/package-root.ts new file mode 100644 index 0000000..c53117d --- /dev/null +++ b/scripts/package-root.ts @@ -0,0 +1,18 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const resolvePackageRoot = (moduleUrl: string): string => { + let directory = path.dirname(fileURLToPath(moduleUrl)); + for (let depth = 0; depth < 6; depth += 1) { + const manifestPath = path.join(directory, "package.json"); + if (existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown }; + if (manifest.name === "simfile") return directory; + } + const parent = path.dirname(directory); + if (parent === directory) break; + directory = parent; + } + throw new Error("Unable to locate Simfile package root"); +}; diff --git a/scripts/render-coverage.ts b/scripts/render-coverage.ts index b84fe6e..466e815 100644 --- a/scripts/render-coverage.ts +++ b/scripts/render-coverage.ts @@ -1,7 +1,7 @@ import { writeFile } from "node:fs/promises"; -import { matrix } from "../src/coverage/matrix.js"; -import { renderCoverageMarkdown } from "../src/coverage/render.js"; +import { matrix } from "../src/coverage/matrix.ts"; +import { renderCoverageMarkdown } from "../src/coverage/render.ts"; const outputUrl = new URL("../docs/COVERAGE.md", import.meta.url); diff --git a/scripts/run-tests.test.mjs b/scripts/run-tests.test.mjs deleted file mode 100644 index 8138c96..0000000 --- a/scripts/run-tests.test.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { adjudicateSummary, nodeTestArguments, parseTapSummary } from "./run-tests.mjs"; - -test("pins TAP before every requested test pattern", () => { - assert.deepEqual(nodeTestArguments(["src/example.test.ts"]), [ - "--import", "tsx", "--test", "--test-reporter=tap", "src/example.test.ts", - ]); -}); - -test("rejects a run with zero passed tests even when Node exits successfully", () => { - const summary = parseTapSummary("# tests 0\n# pass 0\n# fail 0\n# cancelled 0\n# skipped 0\n"); - const verdict = adjudicateSummary(summary, 0); - assert.equal(verdict.exitCode, 1); - assert.match(verdict.message, /proved nothing/); -}); - -test("rejects cancelled tests and names their count", () => { - const summary = parseTapSummary("# tests 13\n# pass 0\n# fail 0\n# cancelled 13\n# skipped 0\n"); - const verdict = adjudicateSummary(summary, 1); - assert.equal(verdict.exitCode, 1); - assert.match(verdict.message, /13 test\(s\) cancelled/); -}); - -test("allows a healthy passing run", () => { - const summary = parseTapSummary("# tests 7\n# pass 7\n# fail 0\n# cancelled 0\n# skipped 0\n"); - assert.deepEqual(adjudicateSummary(summary, 0), { message: null, exitCode: 0 }); -}); diff --git a/scripts/run-tests.test.ts b/scripts/run-tests.test.ts new file mode 100644 index 0000000..19f04ea --- /dev/null +++ b/scripts/run-tests.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFile, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { adjudicateSummary, nodeTestArguments, parseTapSummary } from "./run-tests.ts"; + +test("pins TAP before every requested test pattern", () => { + assert.deepEqual(nodeTestArguments(["src/example.test.ts"]), [ + "--import", "tsx", "--test", "--test-reporter=tap", "src/example.test.ts", + ]); +}); + +test("default run includes maintained script tests", async () => { + const { defaultTestArguments } = await import("./run-tests.ts"); + assert.equal(defaultTestArguments.includes("scripts/**/*.test.ts"), true); +}); + +test("rejects a run with zero passed tests even when Node exits successfully", () => { + const summary = parseTapSummary("# tests 0\n# pass 0\n# fail 0\n# cancelled 0\n# skipped 0\n"); + const verdict = adjudicateSummary(summary, 0); + assert.equal(verdict.exitCode, 1); + assert.match(verdict.message ?? "", /proved nothing/); +}); + +test("rejects cancelled tests and names their count", () => { + const summary = parseTapSummary("# tests 13\n# pass 0\n# fail 0\n# cancelled 13\n# skipped 0\n"); + const verdict = adjudicateSummary(summary, 1); + assert.equal(verdict.exitCode, 1); + assert.match(verdict.message ?? "", /13 test\(s\) cancelled/); +}); + +test("allows a healthy passing run", () => { + const summary = parseTapSummary("# tests 7\n# pass 7\n# fail 0\n# cancelled 0\n# skipped 0\n"); + assert.deepEqual(adjudicateSummary(summary, 0), { message: null, exitCode: 0 }); +}); + + +test("CLI runner emits TAP from a path containing spaces", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile runner path ")); + try { + const runner = path.join(root, "runner with spaces.ts"); + await copyFile(new URL("./run-tests.ts", import.meta.url), runner); + await copyFile(new URL("./entrypoint.ts", import.meta.url), path.join(root, "entrypoint.ts")); + const sentinel = path.join(root, "sentinel.test.ts"); + await writeFile(sentinel, [ + 'import test from "node:test";', + 'import assert from "node:assert/strict";', + 'test("encoded path runner sentinel", () => assert.equal(1, 1));', + "", + ].join("\n"), "utf8"); + + const result = spawnSync(process.execPath, ["--experimental-strip-types", runner, sentinel], { + cwd: process.cwd(), + encoding: "utf8", + env: { + HOME: process.env.HOME ?? "", + PATH: process.env.PATH ?? "", + TMPDIR: process.env.TMPDIR ?? "", + }, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /encoded path runner sentinel/u); + assert.match(result.stdout, /# pass 1/u); + assert.equal(result.stderr, ""); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/scripts/run-tests.mjs b/scripts/run-tests.ts similarity index 58% rename from scripts/run-tests.mjs rename to scripts/run-tests.ts index b69c235..a33097d 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.ts @@ -1,25 +1,44 @@ import { spawn } from "node:child_process"; -export const defaultTestArguments = ["src/**/*.test.ts", "web/src/**/*.test.ts"]; -export const nodeTestArguments = (testArguments) => [ +import { isMainModule } from "./entrypoint.ts"; + +export const defaultTestArguments = [ + "src/**/*.test.ts", + "web/src/**/*.test.ts", + "scripts/**/*.test.ts", +]; +export const nodeTestArguments = (testArguments: readonly string[]): string[] => [ "--import", "tsx", "--test", "--test-reporter=tap", ...testArguments, ]; const summaryPattern = /^# (tests|pass|fail|cancelled|skipped) (\d+)\s*$/gmu; -export const parseTapSummary = (output) => { - const counts = {}; +export interface TapSummary { + cancelled: number; + fail: number; + pass: number; + skipped: number; + tests: number; +} + +export interface TestVerdict { + exitCode: number; + message: string | null; +} + +export const parseTapSummary = (output: string): TapSummary => { + const counts: Partial> = {}; for (const match of String(output).matchAll(summaryPattern)) { - counts[match[1]] = Number(match[2]); + counts[match[1] as keyof TapSummary] = Number(match[2]); } - const required = ["tests", "pass", "fail", "cancelled", "skipped"]; + const required: Array = ["tests", "pass", "fail", "cancelled", "skipped"]; if (required.some((name) => !Number.isInteger(counts[name]))) { throw new Error("test run summary could not be parsed"); } - return counts; + return counts as TapSummary; }; -export const adjudicateSummary = (summary, nodeExitCode) => { +export const adjudicateSummary = (summary: TapSummary, nodeExitCode: number): TestVerdict => { if (summary.cancelled > 0) { return { message: `test run is not green: ${summary.cancelled} test(s) cancelled — a cancelled test did not run`, @@ -36,7 +55,7 @@ export const adjudicateSummary = (summary, nodeExitCode) => { return { message: null, exitCode: 0 }; }; -export const runTests = (testArguments = defaultTestArguments) => new Promise((resolve) => { +export const runTests = (testArguments: readonly string[] = defaultTestArguments): Promise => new Promise((resolve) => { const child = spawn(process.execPath, nodeTestArguments(testArguments), { stdio: ["inherit", "pipe", "pipe"], }); @@ -49,7 +68,7 @@ export const runTests = (testArguments = defaultTestArguments) => new Promise((r output += chunk; process.stderr.write(chunk); }); - child.on("error", (error) => { + child.on("error", (error: Error) => { console.error(`test runner could not start: ${error.message}`); resolve(1); }); @@ -57,17 +76,18 @@ export const runTests = (testArguments = defaultTestArguments) => new Promise((r let summary; try { summary = parseTapSummary(output); - } catch (error) { - if (nodeExitCode === 0) console.error(`test run is not green: ${error.message}`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (nodeExitCode === 0) console.error(`test run is not green: ${message}`); resolve(nodeExitCode || 1); return; } - const verdict = adjudicateSummary(summary, nodeExitCode); + const verdict = adjudicateSummary(summary, nodeExitCode ?? 1); if (verdict.message) console.error(verdict.message); resolve(verdict.exitCode); }); }); -if (import.meta.url === `file://${process.argv[1]}`) { +if (isMainModule(import.meta.url)) { process.exitCode = await runTests(process.argv.slice(2).length > 0 ? process.argv.slice(2) : defaultTestArguments); } diff --git a/scripts/simfile-local-example.test.mjs b/scripts/simfile-local-example.test.ts similarity index 98% rename from scripts/simfile-local-example.test.mjs rename to scripts/simfile-local-example.test.ts index 24b98f9..0328251 100644 --- a/scripts/simfile-local-example.test.mjs +++ b/scripts/simfile-local-example.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import path from "node:path"; import test from "node:test"; -import { createLocalExampleInvocation } from "./simfile-local-example.mjs"; +import { createLocalExampleInvocation } from "./simfile-local-example.ts"; test("local example uses the canonical project and a unique bounded output", () => { const first = createLocalExampleInvocation("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); diff --git a/scripts/simfile-local-example.mjs b/scripts/simfile-local-example.ts similarity index 65% rename from scripts/simfile-local-example.mjs rename to scripts/simfile-local-example.ts index d524009..27cffa2 100644 --- a/scripts/simfile-local-example.mjs +++ b/scripts/simfile-local-example.ts @@ -2,13 +2,19 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { runBoundedProcess } from "./bounded-process.ts"; +import { isMainModule } from "./entrypoint.ts"; +import { resolvePackageRoot } from "./package-root.ts"; -import { runBoundedProcess } from "./bounded-process.mjs"; +const packageRoot = resolvePackageRoot(import.meta.url); -const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export interface LocalExampleInvocation { + args: readonly string[]; + out: string; + run_id: string; +} -export const createLocalExampleInvocation = (nonce = randomUUID()) => { +export const createLocalExampleInvocation = (nonce: string = randomUUID()): Readonly => { if (!/^[a-f0-9-]{8,64}$/u.test(nonce)) { throw new TypeError("Local example nonce is invalid"); } @@ -26,7 +32,7 @@ export const createLocalExampleInvocation = (nonce = randomUUID()) => { }); }; -export const runLocalExample = async (nonce) => { +export const runLocalExample = async (nonce?: string): Promise> => { const invocation = createLocalExampleInvocation(nonce); const result = await runBoundedProcess(process.execPath, invocation.args, { cwd: packageRoot, @@ -38,8 +44,7 @@ export const runLocalExample = async (nonce) => { return invocation; }; -if (process.argv[1] !== undefined - && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (isMainModule(import.meta.url)) { try { await runLocalExample(); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); diff --git a/scripts/source-policy.test.ts b/scripts/source-policy.test.ts new file mode 100644 index 0000000..6a8a003 --- /dev/null +++ b/scripts/source-policy.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import { test } from "node:test"; + +const maintainedSourceDirectories = ["scripts", "tools"] as const; +const forbiddenMaintainedSource = /\.(?:mjs|js)$/u; + +export const maintainedJavaScriptSourceViolations = (entries: readonly string[]): string[] => + entries.filter((entry) => forbiddenMaintainedSource.test(entry)).sort(); + +const topLevelFiles = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => path.posix.join(directory, entry.name)); +}; + +test("maintained scripts and tools have no JavaScript source files", async () => { + const entries = (await Promise.all(maintainedSourceDirectories.map(topLevelFiles))).flat(); + assert.deepEqual(maintainedJavaScriptSourceViolations(entries), []); +}); + +test("source policy catches a maintained JavaScript mutation", () => { + assert.deepEqual(maintainedJavaScriptSourceViolations([ + "scripts/spawnfile-development.mjs", + "tools/verify-package-closure.js", + ]), [ + "scripts/spawnfile-development.mjs", + "tools/verify-package-closure.js", + ]); +}); diff --git a/scripts/spawnfile-capability-probe.mjs b/scripts/spawnfile-capability-probe.mjs deleted file mode 100644 index 0bffbf6..0000000 --- a/scripts/spawnfile-capability-probe.mjs +++ /dev/null @@ -1,126 +0,0 @@ -export const PROBE_VERSION = "simfile.spawnfile-public-capability-probe.v1"; -export const CAPABILITIES_VERSION = "spawnfile.capabilities.v1"; -export const COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION = - "spawnfile.composed-lifecycle-contract-set.v1"; -const ADMITTED_PACKAGE_VERSION = "0.1.17"; -const ADMITTED_ROWS_SHA256 = "095db48660b286add81b00bdb084edc457f57b29c1c5b8a59c312e02560c4146"; - -const semanticVersion = (value) => /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/u.test(value); -const versionedIdentifier = (value) => /^[a-z][a-z0-9.-]{0,127}\.v[1-9][0-9]*$/u.test(value); -const helpHasToken = (source, token) => source.split(/\r?\n/u).some((line) => { - const normalized = line.trim(); - return normalized === token || normalized.startsWith(`${token} `); -}); -const canonical = (value) => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` - : value !== null && typeof value === "object" ? `{${Object.keys(value).sort().map((key) => - `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value); -const rowsDigest = (rows) => createHash("sha256") - .update(`simfile.spawnfile-capability-contract.v1\0${canonical(rows)}`, "utf8").digest("hex"); - -const parseCapabilities = (source) => { - let value; - try { value = JSON.parse(source); } - catch { throw new Error("Spawnfile capabilities did not emit JSON"); } - if (value?.version !== CAPABILITIES_VERSION - || value?.implementation?.cli !== "spawnfile" - || value?.implementation?.package !== "spawnfile" - || value?.implementation?.version !== ADMITTED_PACKAGE_VERSION - || value?.capabilities?.composed_lifecycle?.command_set_version !== COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION - || value?.capabilities?.composed_lifecycle?.complete !== true - || value?.capabilities?.target_config_resolver?.output_version !== "spawnfile.target-config-resolution.v1" - || value?.capabilities?.target_config_resolver?.target_config_version !== "spawnfile.target-default-config.v1" - || value?.capabilities?.evidence_export_helper?.identity !== "docker-image-config-digest" - || value?.capabilities?.evidence_export_helper?.local_context_only !== true - || JSON.stringify(value?.capabilities?.evidence_export_helper?.prepare_command) !== JSON.stringify(["helper", "prepare-evidence-export", "--context", "", "--json"]) - || value?.capabilities?.evidence_export_helper?.receipt_version !== "spawnfile.target-evidence-export-helper.prepared.v1" - || value?.capabilities?.evidence_export_helper?.resolver_option !== "--prepare-evidence-helper" - || value?.capabilities?.evidence_export_helper?.provisioning !== "spawnfile-owned-target-local" - || value?.capabilities?.terminal_public_artifact?.request_version !== "spawnfile.target-public-artifact-snapshot.request.v1" - || value?.capabilities?.terminal_public_artifact?.snapshot_version !== "spawnfile.target-public-artifact-snapshot.v1" - || value?.capabilities?.terminal_public_artifact?.not_present_version !== "spawnfile.target-public-artifact-snapshot.not-present.v1") { - throw new Error("Spawnfile generic capabilities receipt is invalid"); - } - const candidates = ["command_rows", "commands", "operations", "rows"] - .filter((key) => Array.isArray(value.capabilities.composed_lifecycle[key])); - if (candidates.length !== 1 || value.capabilities.composed_lifecycle[candidates[0]].length !== 43) { - throw new Error("Spawnfile generic capabilities command set is invalid"); - } - const fields = [ - "argv", "stdin_versions", "request_versions", "receipt_versions", - "invocation_versions", "pending_versions", "stdout", - ]; - const rows = value.capabilities.composed_lifecycle[candidates[0]]; - for (const row of rows) { - if (row === null || typeof row !== "object" || !fields.every((field) => Object.hasOwn(row, field)) - || !Array.isArray(row.argv) || row.argv.length === 0 || row.argv.some((arg) => typeof arg !== "string" || !arg) - || !["stdin_versions", "request_versions", "receipt_versions", "invocation_versions", "pending_versions"] - .every((field) => Array.isArray(row[field]) && row[field].every(versionedIdentifier))) { - throw new Error("Spawnfile generic capabilities command row is invalid"); - } - } - if (rowsDigest(rows) !== ADMITTED_ROWS_SHA256) { - throw new Error("Spawnfile generic capabilities command contract drifted"); - } - return Object.freeze({ - command_count: rows.length, - command_set_version: value.capabilities.composed_lifecycle.command_set_version, - implementation: Object.freeze({ ...value.implementation }), - version: value.version, - }); -}; - -export const createSpawnfileCapabilityProbe = (input) => { - const version = input.version.trim(); - if (!semanticVersion(version)) throw new Error("Spawnfile did not report a semantic version"); - const legacyDiscovery = input.capabilities_json === undefined; - const commands = legacyDiscovery - ? { - compile: helpHasToken(input.root_help, "compile"), - target: helpHasToken(input.root_help, "target"), - validate: helpHasToken(input.root_help, "validate"), - resolve_config: helpHasToken(input.target_help, "resolve_config"), - snapshot_public_artifact: helpHasToken(input.target_help, "snapshot_public_artifact"), - } - : { capabilities: true }; - const resolver = legacyDiscovery - ? { - evidence_destination: helpHasToken(input.resolver_help, "--evidence-destination"), - prepared_plan: helpHasToken(input.resolver_help, "--prepared-plan"), - } - : { generic_capabilities_receipt: true }; - const blockers = legacyDiscovery - ? Object.entries(commands).filter(([, available]) => !available) - .map(([name]) => `generic_command_unavailable:${name}`) - : []; - if (legacyDiscovery && !resolver.evidence_destination) { - blockers.push("generic_resolver_option_unavailable:evidence_destination"); - } - if (legacyDiscovery && !resolver.prepared_plan) { - blockers.push("generic_resolver_option_unavailable:prepared_plan"); - } - let capabilities; - if (legacyDiscovery) { - blockers.push( - "generic_capabilities_receipt_unavailable", - "evidence_export_helper_capability_unverifiable", - "typed_terminal_not_present_capability_unverifiable", - ); - } else { - capabilities = parseCapabilities(input.capabilities_json); - if (capabilities.implementation.version !== version) { - blockers.push("capabilities_implementation_version_mismatch"); - } - } - return Object.freeze({ - ...(capabilities === undefined ? {} : { capabilities }), - commands: Object.freeze(commands), - composed: Object.freeze({ blockers: Object.freeze(blockers), ready: blockers.length === 0 }), - development: Object.freeze({ - ready: legacyDiscovery ? commands.compile && commands.validate : true, - }), - implementation: Object.freeze({ package: "spawnfile", version }), - resolver: Object.freeze(resolver), - version: PROBE_VERSION, - }); -}; -import { createHash } from "node:crypto"; diff --git a/scripts/spawnfile-capability-probe.ts b/scripts/spawnfile-capability-probe.ts new file mode 100644 index 0000000..1830f28 --- /dev/null +++ b/scripts/spawnfile-capability-probe.ts @@ -0,0 +1,167 @@ +import { createHash } from "node:crypto"; + +export const PROBE_VERSION = "simfile.spawnfile-public-capability-probe.v1"; +export const CAPABILITIES_VERSION = "spawnfile.capabilities.v1"; +export const COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION = + "spawnfile.composed-lifecycle-contract-set.v1"; +const ADMITTED_PACKAGE_VERSION = "0.1.17"; +const ADMITTED_ROWS_SHA256 = "095db48660b286add81b00bdb084edc457f57b29c1c5b8a59c312e02560c4146"; + +type JsonObject = Record; + +interface CapabilityProbeInput { + capabilities_json?: string; + resolver_help: string; + root_help: string; + target_help: string; + version: string; +} + + +export interface SpawnfileCapabilityProbe { + capabilities?: Readonly<{ + command_count: number; + command_set_version: unknown; + implementation: Readonly & { version?: unknown }>; + version: unknown; + }>; + commands: Readonly>; + composed: Readonly<{ blockers: readonly string[]; ready: boolean }>; + development: Readonly<{ ready: boolean }>; + implementation: Readonly<{ package: "spawnfile"; version: string }>; + resolver: Readonly>; + version: typeof PROBE_VERSION; +} + +const isObject = (value: unknown): value is JsonObject => + value !== null && typeof value === "object" && !Array.isArray(value); + +const objectAt = (value: unknown, key: string): JsonObject | undefined => + isObject(value) && isObject(value[key]) ? value[key] : undefined; + +const semanticVersion = (value: string): boolean => /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/u.test(value); +const versionedIdentifier = (value: string): boolean => /^[a-z][a-z0-9.-]{0,127}\.v[1-9][0-9]*$/u.test(value); +const helpHasToken = (source: string, token: string): boolean => source.split(/\r?\n/u).some((line) => { + const normalized = line.trim(); + return normalized === token || normalized.startsWith(`${token} `); +}); +const canonical = (value: unknown): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` + : isObject(value) ? `{${Object.keys(value).sort().map((key) => + `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value); +const rowsDigest = (rows: readonly unknown[]): string => createHash("sha256") + .update(`simfile.spawnfile-capability-contract.v1\0${canonical(rows)}`, "utf8").digest("hex"); + +const parseCapabilities = (source: string) => { + let value: unknown; + try { value = JSON.parse(source); } + catch { throw new Error("Spawnfile capabilities did not emit JSON"); } + const implementation = objectAt(value, "implementation"); + const capabilities = objectAt(value, "capabilities"); + const composed = objectAt(capabilities, "composed_lifecycle"); + const resolver = objectAt(capabilities, "target_config_resolver"); + const evidence = objectAt(capabilities, "evidence_export_helper"); + const terminal = objectAt(capabilities, "terminal_public_artifact"); + if (!isObject(value) || value.version !== CAPABILITIES_VERSION + || implementation?.cli !== "spawnfile" + || implementation.package !== "spawnfile" + || implementation.version !== ADMITTED_PACKAGE_VERSION + || composed?.command_set_version !== COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION + || composed.complete !== true + || resolver?.output_version !== "spawnfile.target-config-resolution.v1" + || resolver.target_config_version !== "spawnfile.target-default-config.v1" + || evidence?.identity !== "docker-image-config-digest" + || evidence.local_context_only !== true + || JSON.stringify(evidence.prepare_command) !== JSON.stringify(["helper", "prepare-evidence-export", "--context", "", "--json"]) + || evidence.receipt_version !== "spawnfile.target-evidence-export-helper.prepared.v1" + || evidence.resolver_option !== "--prepare-evidence-helper" + || evidence.provisioning !== "spawnfile-owned-target-local" + || terminal?.request_version !== "spawnfile.target-public-artifact-snapshot.request.v1" + || terminal.snapshot_version !== "spawnfile.target-public-artifact-snapshot.v1" + || terminal.not_present_version !== "spawnfile.target-public-artifact-snapshot.not-present.v1") { + throw new Error("Spawnfile generic capabilities receipt is invalid"); + } + const candidates = ["command_rows", "commands", "operations", "rows"] + .filter((key) => Array.isArray(composed[key])); + const rowsKey = candidates[0]; + if (candidates.length !== 1 || rowsKey === undefined || (composed[rowsKey] as unknown[]).length !== 43) { + throw new Error("Spawnfile generic capabilities command set is invalid"); + } + const fields = [ + "argv", "stdin_versions", "request_versions", "receipt_versions", + "invocation_versions", "pending_versions", "stdout", + ]; + const rows = composed[rowsKey] as unknown[]; + for (const row of rows) { + if (!isObject(row) || !fields.every((field) => Object.hasOwn(row, field)) + || !Array.isArray(row.argv) || row.argv.length === 0 || row.argv.some((arg: unknown) => typeof arg !== "string" || !arg) + || !["stdin_versions", "request_versions", "receipt_versions", "invocation_versions", "pending_versions"] + .every((field) => Array.isArray(row[field]) && row[field].every((version: unknown) => typeof version === "string" && versionedIdentifier(version)))) { + throw new Error("Spawnfile generic capabilities command row is invalid"); + } + } + if (rowsDigest(rows) !== ADMITTED_ROWS_SHA256) { + throw new Error("Spawnfile generic capabilities command contract drifted"); + } + return Object.freeze({ + command_count: rows.length, + command_set_version: composed.command_set_version, + implementation: Object.freeze({ ...implementation }), + version: value.version, + }); +}; + +export const createSpawnfileCapabilityProbe = (input: CapabilityProbeInput): Readonly => { + const version = input.version.trim(); + if (!semanticVersion(version)) throw new Error("Spawnfile did not report a semantic version"); + const capabilitiesJson = input.capabilities_json; + const legacyDiscovery = capabilitiesJson === undefined; + const commands: Readonly> = legacyDiscovery + ? { + compile: helpHasToken(input.root_help, "compile"), + target: helpHasToken(input.root_help, "target"), + validate: helpHasToken(input.root_help, "validate"), + resolve_config: helpHasToken(input.target_help, "resolve_config"), + snapshot_public_artifact: helpHasToken(input.target_help, "snapshot_public_artifact"), + } + : { capabilities: true }; + const resolver: Readonly> = legacyDiscovery + ? { + evidence_destination: helpHasToken(input.resolver_help, "--evidence-destination"), + prepared_plan: helpHasToken(input.resolver_help, "--prepared-plan"), + } + : { generic_capabilities_receipt: true }; + const blockers = legacyDiscovery + ? Object.entries(commands).filter(([, available]) => !available) + .map(([name]) => `generic_command_unavailable:${name}`) + : []; + if (legacyDiscovery && !resolver.evidence_destination) { + blockers.push("generic_resolver_option_unavailable:evidence_destination"); + } + if (legacyDiscovery && !resolver.prepared_plan) { + blockers.push("generic_resolver_option_unavailable:prepared_plan"); + } + let capabilities; + if (legacyDiscovery) { + blockers.push( + "generic_capabilities_receipt_unavailable", + "evidence_export_helper_capability_unverifiable", + "typed_terminal_not_present_capability_unverifiable", + ); + } else { + capabilities = parseCapabilities(capabilitiesJson); + if (capabilities.implementation.version !== version) { + blockers.push("capabilities_implementation_version_mismatch"); + } + } + return Object.freeze({ + ...(capabilities === undefined ? {} : { capabilities }), + commands: Object.freeze(commands), + composed: Object.freeze({ blockers: Object.freeze(blockers), ready: blockers.length === 0 }), + development: Object.freeze({ + ready: legacyDiscovery ? commands.compile === true && commands.validate === true : true, + }), + implementation: Object.freeze({ package: "spawnfile", version }), + resolver: Object.freeze(resolver), + version: PROBE_VERSION, + }); +}; diff --git a/scripts/spawnfile-composed-smoke.test.mjs b/scripts/spawnfile-composed-smoke.test.ts similarity index 98% rename from scripts/spawnfile-composed-smoke.test.mjs rename to scripts/spawnfile-composed-smoke.test.ts index 927c86f..ebfb1d6 100644 --- a/scripts/spawnfile-composed-smoke.test.mjs +++ b/scripts/spawnfile-composed-smoke.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { createComposedSmokeInvocation, parseSmokeRunArguments, -} from "./spawnfile-composed-smoke.mjs"; +} from "./spawnfile-composed-smoke.ts"; test("composed smoke runner requires one explicit portable local target", () => { assert.deepEqual(parseSmokeRunArguments([ diff --git a/scripts/spawnfile-composed-smoke.mjs b/scripts/spawnfile-composed-smoke.ts similarity index 77% rename from scripts/spawnfile-composed-smoke.mjs rename to scripts/spawnfile-composed-smoke.ts index 0b203ac..a32df55 100755 --- a/scripts/spawnfile-composed-smoke.mjs +++ b/scripts/spawnfile-composed-smoke.ts @@ -2,23 +2,43 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - import { probeSpawnfileCapabilities, readCurrentState, -} from "./spawnfile-development.mjs"; -import { runBoundedProcess } from "./bounded-process.mjs"; -import { proveSpawnfileLocalEndpoint } from "./spawnfile-local-endpoint.mjs"; +} from "./spawnfile-development.ts"; +import { runBoundedProcess } from "./bounded-process.ts"; +import { isMainModule } from "./entrypoint.ts"; +import { resolvePackageRoot } from "./package-root.ts"; +import { proveSpawnfileLocalEndpoint } from "./spawnfile-local-endpoint.ts"; -const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packageRoot = resolvePackageRoot(import.meta.url); const builtCli = path.join(packageRoot, "dist", "cli", "index.js"); const composedExample = path.join(packageRoot, "examples", "jungian-dialogue", "Simfile"); const internalSmokeExample = path.join( packageRoot, "examples", "composed-development", "Simfile", ); -const fail = (message) => { throw new Error(message); }; -const takeValue = (argv, index, flag) => { +type OwnFlagKey = "baseImage" | "context" | "dockerCommand"; + +export interface SmokeRunArguments { + baseImage?: string; + context: string; + dockerCommand?: string; + internalLifecycleSmoke: boolean; + simfileArgs: readonly string[]; +} + +export interface ComposedSmokeInvocation extends SmokeRunArguments { + command: string; + command_args: readonly string[]; + example: string; + mode: "lifecycle-replay-smoke"; + out: string; + run_id: string; + simfileArgs: readonly string[]; +} + +const fail = (message: string): never => { throw new Error(message); }; +const takeValue = (argv: readonly string[], index: number, flag: string): { consumed: number; value: string } | undefined => { const arg = argv[index]; if (arg === flag) { const value = argv[index + 1]; @@ -33,15 +53,15 @@ const takeValue = (argv, index, flag) => { return undefined; }; -export const parseSmokeRunArguments = (argv) => { - let context; - let baseImage; - let dockerCommand; +export const parseSmokeRunArguments = (argv: readonly string[]): SmokeRunArguments => { + let context: string | undefined; + let baseImage: string | undefined; + let dockerCommand: string | undefined; let internalLifecycleSmoke = false; - const simfileArgs = []; + const simfileArgs: string[] = []; const values = ["--out", "--run-id", "--seed"]; for (let index = 0; index < argv.length;) { - const ownFlags = [ + const ownFlags: Array = [ ["--context", "context"], ["--base-image", "baseImage"], ["--docker-command", "dockerCommand"], @@ -50,8 +70,8 @@ export const parseSmokeRunArguments = (argv) => { for (const [flag, key] of ownFlags) { const parsed = takeValue(argv, index, flag); if (parsed === undefined) continue; - if ({ context, baseImage, dockerCommand }[key] - !== undefined) return fail(`Duplicate ${flag}`); + const existing: Record = { context, baseImage, dockerCommand }; + if (existing[key] !== undefined) return fail(`Duplicate ${flag}`); if (key === "context") context = parsed.value; if (key === "baseImage") baseImage = parsed.value; if (key === "dockerCommand") dockerCommand = parsed.value; @@ -91,13 +111,16 @@ export const parseSmokeRunArguments = (argv) => { return { baseImage, context, dockerCommand, internalLifecycleSmoke, simfileArgs }; }; -const argumentValue = (args, flag) => { +const argumentValue = (args: readonly string[], flag: string): string | undefined => { const index = args.findIndex((value) => value === flag || value.startsWith(`${flag}=`)); if (index === -1) return undefined; return args[index].startsWith(`${flag}=`) ? args[index].slice(flag.length + 1) : args[index + 1]; }; -export const createComposedSmokeInvocation = (argv, nonce = randomUUID()) => { +export const createComposedSmokeInvocation = ( + argv: readonly string[], + nonce: string = randomUUID() +): Readonly => { if (!/^[a-f0-9-]{8,64}$/u.test(nonce)) return fail("Composed example nonce is invalid"); const parsed = parseSmokeRunArguments(argv); const example = parsed.internalLifecycleSmoke ? internalSmokeExample : composedExample; @@ -124,7 +147,7 @@ export const createComposedSmokeInvocation = (argv, nonce = randomUUID()) => { }); }; -export const runComposedDevelopmentSmoke = async (argv) => { +export const runComposedDevelopmentSmoke = async (argv: readonly string[]): Promise => { const invocation = createComposedSmokeInvocation(argv); const state = await readCurrentState(); const probe = await probeSpawnfileCapabilities(state.bin); @@ -169,8 +192,7 @@ export const runComposedDevelopmentSmoke = async (argv) => { return result.code; }; -if (process.argv[1] !== undefined - && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (isMainModule(import.meta.url)) { try { process.exitCode = await runComposedDevelopmentSmoke(process.argv.slice(2)); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); diff --git a/scripts/spawnfile-development-context.mjs b/scripts/spawnfile-development-context.ts similarity index 53% rename from scripts/spawnfile-development-context.mjs rename to scripts/spawnfile-development-context.ts index d3abd77..f509998 100644 --- a/scripts/spawnfile-development-context.mjs +++ b/scripts/spawnfile-development-context.ts @@ -1,18 +1,18 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { createSpawnfileCapabilityProbe, PROBE_VERSION } from - "./spawnfile-capability-probe.mjs"; -import { runBoundedProcess } from "./bounded-process.mjs"; + "./spawnfile-capability-probe.ts"; +import { runBoundedProcess, type BoundedProcessResult } from "./bounded-process.ts"; +import { resolvePackageRoot } from "./package-root.ts"; import { assertInstalledArtifact, assertOrigin, executableAt, probeIdentity, -} from "./spawnfile-install-integrity.mjs"; +} from "./spawnfile-install-integrity.ts"; -export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const packageRoot = resolvePackageRoot(import.meta.url); export const developmentRoot = path.join(packageRoot, ".simfile-dev", "spawnfile"); export const installsRoot = path.join(developmentRoot, "installs"); export const currentPath = path.join(developmentRoot, "current.json"); @@ -22,21 +22,71 @@ export const linkedExample = path.join( export const STATE_VERSION = "simfile.spawnfile-development-state.v3"; export const CHECK_VERSION = "simfile.spawnfile-development-check.v1"; -export const fail = (message) => { throw new Error(message); }; -export const run = (command, args, options = {}) => runBoundedProcess(command, args, { +type RunCommand = ( + command: string, + args: readonly string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv; maxOutputBytes?: number; timeoutMs?: number } +) => Promise; +type JsonObject = Record; + +export interface DevelopmentImplementationIdentity { + executable_sha256: string; + installed_closure_sha256: string; + package_version: string; + tarball_sha256: string; +} + +export interface DevelopmentState { + bin: string; + capability_probe: Readonly<{ sha256: string; version: string }>; + implementation: DevelopmentImplementationIdentity; + install_root: string; + origin: unknown; + version: typeof STATE_VERSION; +} + +const isObject = (value: unknown): value is JsonObject => + value !== null && typeof value === "object" && !Array.isArray(value); + +const isDevelopmentState = (value: unknown): value is DevelopmentState => { + if (!isObject(value) || value.version !== STATE_VERSION + || typeof value.bin !== "string" + || typeof value.install_root !== "string" + || !path.isAbsolute(value.install_root) + || !value.install_root.startsWith(`${installsRoot}${path.sep}`) + || value.bin !== executableAt(value.install_root) + || !isObject(value.implementation) + || typeof value.implementation.package_version !== "string" + || !/^[0-9a-f]{64}$/u.test(String(value.implementation.tarball_sha256 ?? "")) + || !/^[0-9a-f]{64}$/u.test(String(value.implementation.executable_sha256 ?? "")) + || !/^[0-9a-f]{64}$/u.test(String(value.implementation.installed_closure_sha256 ?? "")) + || !isObject(value.capability_probe) + || value.capability_probe.version !== PROBE_VERSION + || !/^[0-9a-f]{64}$/u.test(String(value.capability_probe.sha256 ?? ""))) { + return false; + } + return true; +}; + +export const fail = (message: string): never => { throw new Error(message); }; +export const run = ( + command: string, + args: readonly string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv; maxOutputBytes?: number; timeoutMs?: number } = {} +) => runBoundedProcess(command, args, { ...options, cwd: options.cwd ?? packageRoot, env: options.env ?? process.env, }); -const readJson = async (filePath) => { +const readJson = async (filePath: string): Promise => { try { return JSON.parse(await readFile(filePath, "utf8")); } catch (error) { return fail(`Unable to read ${filePath}: ${error instanceof Error ? error.message : String(error)}`); } }; -export const probeSpawnfileCapabilities = async (bin, runCommand = run) => { +export const probeSpawnfileCapabilities = async (bin: string, runCommand: RunCommand = run) => { const [version, capabilities] = await Promise.all([ runCommand(bin, ["--version"]), runCommand(bin, ["capabilities", "--json"]).then(({ stdout }) => stdout) @@ -58,16 +108,7 @@ export const probeSpawnfileCapabilities = async (bin, runCommand = run) => { export const readCurrentState = async () => { const value = await readJson(currentPath); - if (value?.version !== STATE_VERSION || typeof value.bin !== "string" - || typeof value.install_root !== "string" || !path.isAbsolute(value.install_root) - || !value.install_root.startsWith(`${installsRoot}${path.sep}`) - || value.bin !== executableAt(value.install_root) - || typeof value.implementation?.package_version !== "string" - || !/^[0-9a-f]{64}$/u.test(value.implementation?.tarball_sha256 ?? "") - || !/^[0-9a-f]{64}$/u.test(value.implementation?.executable_sha256 ?? "") - || !/^[0-9a-f]{64}$/u.test(value.implementation?.installed_closure_sha256 ?? "") - || value.capability_probe?.version !== PROBE_VERSION - || !/^[0-9a-f]{64}$/u.test(value.capability_probe?.sha256 ?? "")) { + if (!isDevelopmentState(value)) { return fail("Spawnfile development state is invalid; rerun dev:spawnfile:setup"); } await assertOrigin(value.origin); diff --git a/scripts/spawnfile-development-setup.mjs b/scripts/spawnfile-development-setup.ts similarity index 71% rename from scripts/spawnfile-development-setup.mjs rename to scripts/spawnfile-development-setup.ts index 1536ceb..d973c04 100644 --- a/scripts/spawnfile-development-setup.mjs +++ b/scripts/spawnfile-development-setup.ts @@ -19,20 +19,42 @@ import { probeSpawnfileCapabilities, run, STATE_VERSION, -} from "./spawnfile-development-context.mjs"; -import { stagePhysicalSpawnfileSource } from "./spawnfile-source-stage.mjs"; +} from "./spawnfile-development-context.ts"; +import { stagePhysicalSpawnfileSource } from "./spawnfile-source-stage.ts"; import { assertInstalledArtifact, hash, + type InstalledArtifactExpectation, packagedTarballAt, probeIdentity, -} from "./spawnfile-install-integrity.mjs"; +} from "./spawnfile-install-integrity.ts"; -export const parseSetupArguments = (args) => { - let source; - let packageSpec; - let artifact; - let sha256; +type SetupOptions = Readonly<{ + artifact?: string; + packageSpec?: string; + sha256?: string; + source?: string; +}>; + +type PackSelection = Readonly<{ + identity: string; + installSpec: string; + origin: + | Readonly<{ kind: "artifact"; package_version: string; path: string; sha256: string }> + | Readonly<{ kind: "registry"; package_version: string; spec: string | undefined }> + | Readonly<{ kind: "source"; package_version: string; path: string }>; + tarball: string; + tarball_sha256: string; +}>; + +const isObject = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +export const parseSetupArguments = (args: readonly string[]): SetupOptions => { + let source: string | undefined; + let packageSpec: string | undefined; + let artifact: string | undefined; + let sha256: string | undefined; for (let index = 0; index < args.length; index += 1) { const flag = args[index]; if (!["--artifact", "--package", "--sha256", "--source"].includes(flag)) { @@ -66,19 +88,19 @@ export const parseSetupArguments = (args) => { return { artifact, packageSpec, sha256, source }; }; -const parsePackResult = (stdout) => { - let value; +const parsePackResult = (stdout: string): { filename: string; integrity: string; version: string } => { + let value: unknown; try { value = JSON.parse(stdout); } catch { return fail("npm pack did not return JSON"); } - if (!Array.isArray(value) || value.length !== 1 - || typeof value[0]?.filename !== "string" || typeof value[0]?.integrity !== "string" - || typeof value[0]?.version !== "string") { + if (!Array.isArray(value) || value.length !== 1 || !isObject(value[0]) + || typeof value[0].filename !== "string" || typeof value[0].integrity !== "string" + || typeof value[0].version !== "string") { return fail("npm pack did not report exactly one Spawnfile tarball"); } - return value[0]; + return { filename: value[0].filename, integrity: value[0].integrity, version: value[0].version }; }; -const installPackage = async (spec, temporaryRoot) => { +const installPackage = async (spec: string, temporaryRoot: string): Promise => { await writeFile(path.join(temporaryRoot, "package.json"), `${JSON.stringify({ name: "simfile-spawnfile-development-tool", private: true, version: "0.0.0", }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); @@ -86,26 +108,27 @@ const installPackage = async (spec, temporaryRoot) => { "--no-package-lock", "--save-exact", spec], { cwd: temporaryRoot }); }; -const packedSelection = async (input, temporaryRoot) => { +const packedSelection = async (input: SetupOptions, temporaryRoot: string): Promise => { if (input.artifact !== undefined) { const info = await lstat(input.artifact).catch(() => fail("Spawnfile artifact is missing")); if (!info.isFile() || info.isSymbolicLink()) fail("Spawnfile artifact must be a regular file"); const tarballHash = hash(await readFile(input.artifact)); if (tarballHash !== input.sha256) fail("Spawnfile artifact SHA-256 did not match --sha256"); const manifestText = (await run("tar", ["-xOf", input.artifact, "package/package.json"])).stdout; - let manifest; - try { manifest = JSON.parse(manifestText); } catch { fail("Spawnfile artifact package metadata is invalid"); } - if (manifest?.name !== "spawnfile" || typeof manifest.version !== "string") { - fail("Spawnfile artifact is not a versioned spawnfile package"); + let manifest: unknown; + try { manifest = JSON.parse(manifestText); } catch { return fail("Spawnfile artifact package metadata is invalid"); } + if (!isObject(manifest) || manifest.name !== "spawnfile" || typeof manifest.version !== "string") { + return fail("Spawnfile artifact is not a versioned spawnfile package"); } + const packageVersion = manifest.version; return { identity: `artifact-v1:${tarballHash}`, installSpec: input.artifact, - origin: { kind: "artifact", package_version: manifest.version, + origin: { kind: "artifact", package_version: packageVersion, path: input.artifact, sha256: tarballHash }, tarball: input.artifact, tarball_sha256: tarballHash }; } - let packCwd; - let packSpec; - let origin; + let packCwd: string | undefined; + let packSpec: string | undefined; + let origin: PackSelection["origin"] | undefined; if (input.source !== undefined) { const staged = await stagePhysicalSpawnfileSource(input.source, temporaryRoot); await run("npm", ["ci", "--no-audit", "--no-fund"], { cwd: staged.staging }); @@ -114,6 +137,7 @@ const packedSelection = async (input, temporaryRoot) => { origin = { kind: "source", package_version: staged.origin.package_version, path: staged.origin.path }; } else { + if (input.packageSpec === undefined) return fail("Setup requires exactly one of --source, --package, or --artifact"); packSpec = input.packageSpec; } const packRoot = path.join(temporaryRoot, "pack"); @@ -126,18 +150,18 @@ const packedSelection = async (input, temporaryRoot) => { const tarballHash = hash(await readFile(tarball)); return { identity: `${input.source === undefined ? "registry" : "source"}-v2:${tarballHash}`, installSpec: tarball, - origin: origin ?? { kind: "registry", package_version: packed.version, spec: input.packageSpec }, + origin: origin ?? { kind: "registry", package_version: packed.version, spec: packSpec }, tarball, tarball_sha256: tarballHash }; }; -const writeCurrentState = async (state) => { +const writeCurrentState = async (state: unknown): Promise => { await mkdir(developmentRoot, { recursive: true, mode: 0o700 }); const pending = path.join(developmentRoot, `.current-${randomUUID()}.json`); await writeFile(pending, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); await rename(pending, currentPath); }; -export const setupSpawnfileDevelopment = async (args) => { +export const setupSpawnfileDevelopment = async (args: readonly string[]): Promise => { const options = parseSetupArguments(args); await mkdir(installsRoot, { recursive: true, mode: 0o700 }); const temporaryRoot = await mkdtemp(path.join(developmentRoot, ".install-")); @@ -145,8 +169,8 @@ export const setupSpawnfileDevelopment = async (args) => { const selected = await packedSelection(options, temporaryRoot); const installRoot = path.join(installsRoot, hash(selected.identity).slice(0, 32)); try { await lstat(installRoot); } - catch (error) { - if (error?.code !== "ENOENT") throw error; + catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; const staged = path.join(temporaryRoot, "installed"); await mkdir(staged, { mode: 0o700 }); await installPackage(selected.installSpec, staged); @@ -158,7 +182,7 @@ export const setupSpawnfileDevelopment = async (args) => { const installed = await assertInstalledArtifact(installRoot, { package_version: selected.origin.package_version, tarball_sha256: selected.tarball_sha256, - }); + } satisfies InstalledArtifactExpectation); const probe = await probeSpawnfileCapabilities(installed.executable); if (!probe.development.ready) fail("Installed Spawnfile lacks required generic development commands"); const state = { bin: installed.executable, capability_probe: probeIdentity(probe), diff --git a/scripts/spawnfile-development.test.mjs b/scripts/spawnfile-development.test.ts similarity index 91% rename from scripts/spawnfile-development.test.mjs rename to scripts/spawnfile-development.test.ts index 60403b6..c0143c3 100644 --- a/scripts/spawnfile-development.test.mjs +++ b/scripts/spawnfile-development.test.ts @@ -5,20 +5,25 @@ import path from "node:path"; import test from "node:test"; import { setTimeout as delay } from "node:timers/promises"; +import type { BoundedProcessResult } from "./bounded-process.ts"; + import { createSpawnfileCapabilityProbe, parseSetupArguments, probeSpawnfileCapabilities, run, -} from "./spawnfile-development.mjs"; +} from "./spawnfile-development.ts"; -const assertProcessGroupStopped = (pid) => { +const assertProcessGroupStopped = (pid: number): void => { assert.throws( () => process.kill(-pid, 0), - (error) => error?.code === "ESRCH", + (error: unknown) => (error as NodeJS.ErrnoException).code === "ESRCH", ); }; +type ProbeCall = readonly [string, readonly string[]]; +const successfulProbeResult = (stdout: string): BoundedProcessResult => ({ code: 0, stderr: "", stdout }); + test("spawnfile development setup requires one explicit standalone source", () => { assert.deepEqual(parseSetupArguments(["--source", "/tmp/spawnfile-source"]), { artifact: undefined, @@ -90,7 +95,7 @@ test("generic help discovery ignores presentation indentation", () => { }); test("capability probing uses generic JSON discovery before its legacy help fallback", async () => { - const calls = []; + const calls: ProbeCall[] = []; const output = new Map([ ["--version", "0.1.14\n"], ["--help", " compile [options] [path]\n target [options]\n validate [path]\n"], @@ -100,7 +105,7 @@ test("capability probing uses generic JSON discovery before its legacy help fall const probe = await probeSpawnfileCapabilities("/isolated/spawnfile", async (bin, args) => { calls.push([bin, args]); if (args.join(" ") === "capabilities --json") throw new Error("unsupported"); - return { stderr: "", stdout: output.get(args.join(" ")) ?? "" }; + return successfulProbeResult(output.get(args.join(" ")) ?? ""); }); assert.deepEqual(calls, [ ["/isolated/spawnfile", ["--version"]], @@ -114,7 +119,7 @@ test("capability probing uses generic JSON discovery before its legacy help fall }); test("capability probing rejects a structurally valid but unpinned JSON contract", async () => { - const row = (index) => ({ + const row = (index: number) => ({ argv: [`command-${index}`], invocation_versions: [], pending_versions: [], @@ -152,12 +157,12 @@ test("capability probing rejects a structurally valid but unpinned JSON contract implementation: { cli: "spawnfile", package: "spawnfile", version: "0.1.17" }, version: "spawnfile.capabilities.v1", }; - const calls = []; + const calls: Array = []; await assert.rejects(probeSpawnfileCapabilities("/isolated/spawnfile", async (_bin, args) => { calls.push(args); - if (args.join(" ") === "--version") return { stderr: "", stdout: "0.1.17\n" }; + if (args.join(" ") === "--version") return successfulProbeResult("0.1.17\n"); if (args.join(" ") === "capabilities --json") { - return { stderr: "", stdout: JSON.stringify(report) }; + return successfulProbeResult(JSON.stringify(report)); } throw new Error("help must not be queried after a valid JSON contract"); }), /command contract drifted/u); @@ -166,12 +171,9 @@ test("capability probing rejects a structurally valid but unpinned JSON contract test("missing nested generic help remains a structured fail-closed result", async () => { const probe = await probeSpawnfileCapabilities("/isolated/spawnfile", async (_bin, args) => { - if (args.join(" ") === "--version") return { stderr: "", stdout: "0.1.14\n" }; + if (args.join(" ") === "--version") return successfulProbeResult("0.1.14\n"); if (args.join(" ") === "--help") { - return { - stderr: "", - stdout: " compile [options] [path]\n target [options]\n validate [path]\n", - }; + return successfulProbeResult(" compile [options] [path]\n target [options]\n validate [path]\n"); } throw new Error("unsupported generic discovery command"); }); diff --git a/scripts/spawnfile-development.mjs b/scripts/spawnfile-development.ts similarity index 80% rename from scripts/spawnfile-development.mjs rename to scripts/spawnfile-development.ts index c8e3585..0d203a2 100644 --- a/scripts/spawnfile-development.mjs +++ b/scripts/spawnfile-development.ts @@ -1,8 +1,6 @@ #!/usr/bin/env node import { mkdtemp, rm } from "node:fs/promises"; import path from "node:path"; -import { pathToFileURL } from "node:url"; - import { CHECK_VERSION, PROBE_VERSION, @@ -15,11 +13,12 @@ import { probeSpawnfileCapabilities, readCurrentState, run, -} from "./spawnfile-development-context.mjs"; +} from "./spawnfile-development-context.ts"; +import { isMainModule } from "./entrypoint.ts"; import { parseSetupArguments, setupSpawnfileDevelopment } from - "./spawnfile-development-setup.mjs"; + "./spawnfile-development-setup.ts"; -const check = async () => { +const check = async (): Promise => { const state = await readCurrentState(); const probe = state.capability_probe; if (!probe.development.ready) fail("Installed Spawnfile lacks required generic development commands"); @@ -39,7 +38,7 @@ const check = async () => { }, null, 2)}\n`); }; -const main = async (args) => { +export const runSpawnfileDevelopmentCommand = async (args: readonly string[]): Promise => { const [command, ...rest] = args; if (command === "setup") return setupSpawnfileDevelopment(rest); if (command === "check" && rest.length === 0) return check(); @@ -48,7 +47,7 @@ const main = async (args) => { process.stdout.write(`${JSON.stringify(state, null, 2)}\n`); return; } - fail("Usage: spawnfile-development.mjs "); + fail("Usage: spawnfile-development "); }; export { @@ -62,9 +61,8 @@ export { run, }; -if (process.argv[1] !== undefined - && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { await main(process.argv.slice(2)); } +if (isMainModule(import.meta.url)) { + try { await runSpawnfileDevelopmentCommand(process.argv.slice(2)); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; diff --git a/scripts/spawnfile-install-integrity.test.mjs b/scripts/spawnfile-install-integrity.test.ts similarity index 96% rename from scripts/spawnfile-install-integrity.test.mjs rename to scripts/spawnfile-install-integrity.test.ts index c79d256..354bce2 100644 --- a/scripts/spawnfile-install-integrity.test.mjs +++ b/scripts/spawnfile-install-integrity.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; -import { assertInstalledArtifact, hash } from "./spawnfile-install-integrity.mjs"; +import { assertInstalledArtifact, hash } from "./spawnfile-install-integrity.ts"; test("installed Spawnfile artifact verification rejects a tampered tarball", async () => { const root = await mkdtemp(path.join(tmpdir(), "simfile-install-integrity-")); @@ -27,9 +27,9 @@ test("installed Spawnfile artifact verification rejects a tampered tarball", asy try { const installed = await assertInstalledArtifact(root, expected); const pinned = { ...expected, installed_closure_sha256: installed.installed_closure_sha256 }; - await writeFile(path.join(root, "node_modules", "spawnfile", "runtime.mjs"), "export {};\n"); + await writeFile(path.join(root, "node_modules", "spawnfile", "runtime.ts"), "export {};\n"); await assert.rejects(assertInstalledArtifact(root, pinned), /module closure drifted/u); - await rm(path.join(root, "node_modules", "spawnfile", "runtime.mjs")); + await rm(path.join(root, "node_modules", "spawnfile", "runtime.ts")); await writeFile(tarball, "tampered tarball\n"); await assert.rejects(assertInstalledArtifact(root, pinned), /tarball digest drifted/u); } finally { diff --git a/scripts/spawnfile-install-integrity.mjs b/scripts/spawnfile-install-integrity.ts similarity index 64% rename from scripts/spawnfile-install-integrity.mjs rename to scripts/spawnfile-install-integrity.ts index e975866..c701c24 100644 --- a/scripts/spawnfile-install-integrity.mjs +++ b/scripts/spawnfile-install-integrity.ts @@ -1,23 +1,46 @@ -import { createHash } from "node:crypto"; +import { createHash, type BinaryLike } from "node:crypto"; import { chmod, lstat, readFile, readdir, readlink, realpath } from "node:fs/promises"; import path from "node:path"; -import { inspectPhysicalSpawnfileSource } from "./spawnfile-source-stage.mjs"; +import { inspectPhysicalSpawnfileSource } from "./spawnfile-source-stage.ts"; -const fail = (message) => { throw new Error(message); }; +export type SpawnfileOrigin = + | Readonly<{ kind: "artifact"; package_version: string; path: string; sha256: string }> + | Readonly<{ kind: "registry"; package_version: string; spec: string }> + | Readonly<{ kind: "source"; package_version: string; path: string }>; -export const hash = (value) => createHash("sha256").update(value).digest("hex"); -export const executableAt = (root) => path.join(root, "node_modules", ".bin", "spawnfile"); -export const packagedTarballAt = (root) => path.join(root, "spawnfile.tgz"); -export const probeIdentity = (probe) => Object.freeze({ +export interface InstalledArtifactExpectation { + executable_sha256?: string; + installed_closure_sha256?: string; + package_version?: string; + repair_permissions?: boolean; + tarball_sha256?: string; +} + +export interface InstalledArtifact { + executable: string; + executable_sha256: string; + installed_closure_sha256: string; + package_version: string; + tarball_sha256: string; +} + +const fail = (message: string): never => { throw new Error(message); }; +const isObject = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +export const hash = (value: BinaryLike): string => createHash("sha256").update(value).digest("hex"); +export const executableAt = (root: string): string => path.join(root, "node_modules", ".bin", "spawnfile"); +export const packagedTarballAt = (root: string): string => path.join(root, "spawnfile.tgz"); +export const probeIdentity = (probe: { version: string }): Readonly<{ sha256: string; version: string }> => Object.freeze({ sha256: hash(JSON.stringify(probe)), version: probe.version, }); -export const installedClosureHash = async (installRoot) => { +export const installedClosureHash = async (installRoot: string): Promise => { const closureRoot = path.join(installRoot, "node_modules"); const digest = createHash("sha256"); - const visit = async (directory, relativeRoot = "") => { + const visit = async (directory: string, relativeRoot = ""): Promise => { const entries = await readdir(directory, { withFileTypes: true }); entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); for (const entry of entries) { @@ -40,20 +63,22 @@ export const installedClosureHash = async (installRoot) => { return digest.digest("hex"); }; -const installedPackage = async (installRoot) => { - let manifest; +const installedPackage = async (installRoot: string): Promise<{ name: string; version: string }> => { + let manifest: unknown; try { manifest = JSON.parse(await readFile(path.join(installRoot, "node_modules", "spawnfile", "package.json"), "utf8")); } catch (error) { return fail(`Unable to read installed Spawnfile metadata: ${error instanceof Error ? error.message : String(error)}`); } - if (manifest?.name !== "spawnfile" || typeof manifest.version !== "string") { + if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest) + || (manifest as { name?: unknown }).name !== "spawnfile" + || typeof (manifest as { version?: unknown }).version !== "string") { return fail("Installed Spawnfile package metadata is invalid"); } - return manifest; + return manifest as { name: string; version: string }; }; -const installedExecutable = async (installRoot, repairPermissions = false) => { +const installedExecutable = async (installRoot: string, repairPermissions = false): Promise> => { const executable = executableAt(installRoot); const target = await realpath(executable).catch(() => fail("Installed Spawnfile executable is missing")); const physicalRoot = await realpath(installRoot); @@ -66,7 +91,10 @@ const installedExecutable = async (installRoot, repairPermissions = false) => { return { executable, target }; }; -export const assertInstalledArtifact = async (installRoot, expected) => { +export const assertInstalledArtifact = async ( + installRoot: string, + expected: InstalledArtifactExpectation +): Promise> => { const tarball = packagedTarballAt(installRoot); const tarballInfo = await lstat(tarball).catch(() => fail("Installed Spawnfile tarball is missing")); if (!tarballInfo.isFile() || tarballInfo.isSymbolicLink()) { @@ -99,20 +127,23 @@ export const assertInstalledArtifact = async (installRoot, expected) => { }); }; -export const assertOrigin = async (origin) => { - if (origin?.kind === "artifact" && typeof origin.path === "string" +export const assertOrigin = async (origin: unknown): Promise => { + if (!isObject(origin)) { + return fail("Spawnfile development origin is invalid; rerun dev:spawnfile:setup"); + } + if (origin.kind === "artifact" && typeof origin.path === "string" && path.isAbsolute(origin.path) && path.normalize(origin.path) === origin.path - && /^[0-9a-f]{64}$/u.test(origin.sha256 ?? "") + && typeof origin.sha256 === "string" && /^[0-9a-f]{64}$/u.test(origin.sha256) && typeof origin.package_version === "string") { const info = await lstat(origin.path).catch(() => fail("Spawnfile artifact origin is missing")); if (!info.isFile() || info.isSymbolicLink()) return fail("Spawnfile artifact origin is invalid"); if (hash(await readFile(origin.path)) === origin.sha256) return; return fail("Spawnfile artifact origin digest changed; rerun dev:spawnfile:setup"); } - if (origin?.kind === "registry" && typeof origin.spec === "string" + if (origin.kind === "registry" && typeof origin.spec === "string" && /^spawnfile@[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/u.test(origin.spec) && typeof origin.package_version === "string") return; - if (origin?.kind === "source" && typeof origin.path === "string" + if (origin.kind === "source" && typeof origin.path === "string" && typeof origin.package_version === "string") { const current = await inspectPhysicalSpawnfileSource(origin.path); if (current.package_version === origin.package_version) return; diff --git a/scripts/spawnfile-local-endpoint.mjs b/scripts/spawnfile-local-endpoint.mjs deleted file mode 100644 index 01556d6..0000000 --- a/scripts/spawnfile-local-endpoint.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import path from "node:path"; - -import { runBoundedProcess } from "./bounded-process.mjs"; - -const digest = /^sha256:[a-f0-9]{64}$/u; -const contextName = /^[a-z][a-z0-9_-]{0,63}$/u; -const fail = (message) => { throw new TypeError(message); }; - -export const parseSpawnfileLocalEndpointProof = (raw, expectedContext) => { - const value = raw; - if (value === null || typeof value !== "object" || Array.isArray(value) - || value.version !== "spawnfile.target-config-resolution.v1" - || value.context_selection !== "explicit" - || value.endpoint?.class !== "local" - || !["fd", "npipe", "unix"].includes(value.endpoint?.transport) - || value.platform?.os !== "linux" - || !["amd64", "arm64"].includes(value.platform?.architecture) - || value.target_config?.context !== expectedContext - || value.target_config?.version !== "spawnfile.target-default-config.v1" - || !digest.test(value.target_config_digest ?? "") - || !digest.test(value.base_image?.config_digest ?? "")) { - return fail("Spawnfile did not prove the exact context is a local endpoint"); - } - return Object.freeze({ - architecture: value.platform.architecture, - context: expectedContext, - endpoint_class: "local", - transport: value.endpoint.transport, - version: "simfile.spawnfile-local-endpoint-proof.v1", - }); -}; - -export const proveSpawnfileLocalEndpoint = async (input) => { - if (typeof input.spawnfile_bin !== "string" || !path.isAbsolute(input.spawnfile_bin) - || path.normalize(input.spawnfile_bin) !== input.spawnfile_bin - || !contextName.test(input.context ?? "")) { - return fail("Local endpoint proof requires an absolute Spawnfile bin and exact context"); - } - const root = await mkdtemp(path.join(input.state_root, ".endpoint-proof-")); - try { - const args = ["target", "resolve_config", "--context", input.context, - "--evidence-destination", path.join(root, "world-evidence.tar"), - "--timeout-ms", "120000"]; - if (input.base_image !== undefined) args.push("--base-image", input.base_image); - if (input.docker_command !== undefined) { - args.push("--docker-command", input.docker_command); - } - const result = await runBoundedProcess(input.spawnfile_bin, args, { - cwd: input.cwd, env: input.env, timeoutMs: 120_000, - }); - let raw; - try { raw = JSON.parse(result.stdout); } - catch { return fail("Spawnfile local endpoint proof did not emit JSON"); } - return parseSpawnfileLocalEndpointProof(raw, input.context); - } finally { - await rm(root, { force: true, recursive: true }); - } -}; diff --git a/scripts/spawnfile-local-endpoint.test.mjs b/scripts/spawnfile-local-endpoint.test.ts similarity index 98% rename from scripts/spawnfile-local-endpoint.test.mjs rename to scripts/spawnfile-local-endpoint.test.ts index 881dd5d..3223301 100644 --- a/scripts/spawnfile-local-endpoint.test.mjs +++ b/scripts/spawnfile-local-endpoint.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { parseSpawnfileLocalEndpointProof } from "./spawnfile-local-endpoint.mjs"; +import { parseSpawnfileLocalEndpointProof } from "./spawnfile-local-endpoint.ts"; const receipt = { base_image: { config_digest: `sha256:${"1".repeat(64)}`, reference: "node:22" }, diff --git a/scripts/spawnfile-local-endpoint.ts b/scripts/spawnfile-local-endpoint.ts new file mode 100644 index 0000000..a71aa8e --- /dev/null +++ b/scripts/spawnfile-local-endpoint.ts @@ -0,0 +1,92 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; + +import { runBoundedProcess } from "./bounded-process.ts"; + +const digest = /^sha256:[a-f0-9]{64}$/u; +const contextName = /^[a-z][a-z0-9_-]{0,63}$/u; +type JsonObject = Record; + +export interface SpawnfileLocalEndpointProof { + architecture: "amd64" | "arm64"; + context: string; + endpoint_class: "local"; + transport: "fd" | "npipe" | "unix"; + version: "simfile.spawnfile-local-endpoint-proof.v1"; +} + +export interface ProveSpawnfileLocalEndpointInput { + base_image?: string; + context: string; + cwd: string; + docker_command?: string; + env: NodeJS.ProcessEnv; + spawnfile_bin: string; + state_root: string; +} + +const fail = (message: string): never => { throw new TypeError(message); }; +const isObject = (value: unknown): value is JsonObject => + value !== null && typeof value === "object" && !Array.isArray(value); + +export const parseSpawnfileLocalEndpointProof = ( + raw: unknown, + expectedContext: string +): Readonly => { + const value = raw; + if (!isObject(value) + || value.version !== "spawnfile.target-config-resolution.v1" + || value.context_selection !== "explicit" + || !isObject(value.endpoint) + || value.endpoint.class !== "local" + || !["fd", "npipe", "unix"].includes(String(value.endpoint.transport)) + || !isObject(value.platform) + || value.platform.os !== "linux" + || !["amd64", "arm64"].includes(String(value.platform.architecture)) + || !isObject(value.target_config) + || value.target_config.context !== expectedContext + || value.target_config.version !== "spawnfile.target-default-config.v1" + || typeof value.target_config_digest !== "string" + || !digest.test(value.target_config_digest) + || !isObject(value.base_image) + || typeof value.base_image.config_digest !== "string" + || !digest.test(value.base_image.config_digest)) { + return fail("Spawnfile did not prove the exact context is a local endpoint"); + } + return Object.freeze({ + architecture: value.platform.architecture as "amd64" | "arm64", + context: expectedContext, + endpoint_class: "local", + transport: value.endpoint.transport as "fd" | "npipe" | "unix", + version: "simfile.spawnfile-local-endpoint-proof.v1", + }); +}; + +export const proveSpawnfileLocalEndpoint = async ( + input: ProveSpawnfileLocalEndpointInput +): Promise> => { + if (typeof input.spawnfile_bin !== "string" || !path.isAbsolute(input.spawnfile_bin) + || path.normalize(input.spawnfile_bin) !== input.spawnfile_bin + || !contextName.test(input.context ?? "")) { + return fail("Local endpoint proof requires an absolute Spawnfile bin and exact context"); + } + const root = await mkdtemp(path.join(input.state_root, ".endpoint-proof-")); + try { + const args = ["target", "resolve_config", "--context", input.context, + "--evidence-destination", path.join(root, "world-evidence.tar"), + "--timeout-ms", "120000"]; + if (input.base_image !== undefined) args.push("--base-image", input.base_image); + if (input.docker_command !== undefined) { + args.push("--docker-command", input.docker_command); + } + const result = await runBoundedProcess(input.spawnfile_bin, args, { + cwd: input.cwd, env: input.env, timeoutMs: 120_000, + }); + let raw; + try { raw = JSON.parse(result.stdout); } + catch { return fail("Spawnfile local endpoint proof did not emit JSON"); } + return parseSpawnfileLocalEndpointProof(raw, input.context); + } finally { + await rm(root, { force: true, recursive: true }); + } +}; diff --git a/scripts/spawnfile-source-stage.test.mjs b/scripts/spawnfile-source-stage.test.ts similarity index 99% rename from scripts/spawnfile-source-stage.test.mjs rename to scripts/spawnfile-source-stage.test.ts index e0e5e3a..2899db4 100644 --- a/scripts/spawnfile-source-stage.test.mjs +++ b/scripts/spawnfile-source-stage.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; -import { stagePhysicalSpawnfileSource } from "./spawnfile-source-stage.mjs"; +import { stagePhysicalSpawnfileSource } from "./spawnfile-source-stage.ts"; test("source staging copies a physical checkout without its dependency or runtime state", async () => { const root = await mkdtemp(path.join(tmpdir(), "simfile-source-stage-")); diff --git a/scripts/spawnfile-source-stage.mjs b/scripts/spawnfile-source-stage.ts similarity index 61% rename from scripts/spawnfile-source-stage.mjs rename to scripts/spawnfile-source-stage.ts index 492272d..d530f69 100644 --- a/scripts/spawnfile-source-stage.mjs +++ b/scripts/spawnfile-source-stage.ts @@ -15,33 +15,48 @@ const omittedDirectories = new Set([ "runs", ]); -const fail = (message) => { throw new Error(message); }; +interface SpawnfileSourceInspection { + package_version: string; + path: string; +} -export const isOmittedSourcePath = (sourceRoot, candidate) => { +interface StagedSpawnfileSource { + origin: Readonly; + staging: string; +} + +const fail = (message: string): never => { throw new Error(message); }; + +export const isOmittedSourcePath = (sourceRoot: string, candidate: string): boolean => { const relative = path.relative(sourceRoot, candidate); return relative !== "" && relative.split(path.sep).some((part) => omittedDirectories.has(part)); }; -export const inspectPhysicalSpawnfileSource = async (source) => { +export const inspectPhysicalSpawnfileSource = async (source: string): Promise> => { const sourceInfo = await lstat(source).catch(() => fail("--source checkout is unavailable")); if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) { return fail("--source must be a physical Spawnfile checkout directory"); } const physicalPath = await realpath(source); - let manifest; + let manifest: unknown; try { manifest = JSON.parse(await readFile(path.join(source, "package.json"), "utf8")); } catch (error) { return fail(`Unable to read ${path.join(source, "package.json")}: ${error instanceof Error ? error.message : String(error)}`); } - if (manifest?.name !== "spawnfile" || typeof manifest.version !== "string") { + if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest) + || (manifest as { name?: unknown }).name !== "spawnfile" + || typeof (manifest as { version?: unknown }).version !== "string") { return fail("--source does not identify a Spawnfile package checkout"); } - return Object.freeze({ package_version: manifest.version, path: physicalPath }); + return Object.freeze({ package_version: (manifest as { version: string }).version, path: physicalPath }); }; /** Copies source into a private build staging area without changing the checkout. */ -export const stagePhysicalSpawnfileSource = async (source, temporaryRoot) => { +export const stagePhysicalSpawnfileSource = async ( + source: string, + temporaryRoot: string +): Promise> => { const origin = await inspectPhysicalSpawnfileSource(source); const staging = path.join(temporaryRoot, "source-stage"); await mkdir(staging, { mode: 0o700 }); diff --git a/src/view/runRawArtifacts.test.ts b/src/view/runRawArtifacts.test.ts index 2b72b0f..51dc117 100644 --- a/src/view/runRawArtifacts.test.ts +++ b/src/view/runRawArtifacts.test.ts @@ -14,7 +14,7 @@ const REAL_RUN_DIR = path.resolve( here, "..", "..", - "runs", + "fixtures", "observe", "real-grok-composed", "run-b7ef07f0fd2c4779894c2bb746140972", ); diff --git a/src/view/runTimeline.test.ts b/src/view/runTimeline.test.ts index ce85667..92c481c 100644 --- a/src/view/runTimeline.test.ts +++ b/src/view/runTimeline.test.ts @@ -16,7 +16,7 @@ const REAL_RUN_DIR = path.resolve( here, "..", "..", - "runs", + "fixtures", "observe", "real-grok-composed", "run-b7ef07f0fd2c4779894c2bb746140972", ); diff --git a/src/view/runViewModel.test.ts b/src/view/runViewModel.test.ts index e213c6d..d00638c 100644 --- a/src/view/runViewModel.test.ts +++ b/src/view/runViewModel.test.ts @@ -194,7 +194,7 @@ describe("buildRunViewModel — engine provenance for a real-grok run", () => { path.dirname(fileURLToPath(import.meta.url)), "..", "..", - "runs", + "fixtures", "observe", "real-grok-composed", "run-b7ef07f0fd2c4779894c2bb746140972" ); diff --git a/src/view/runWorldTrace.test.ts b/src/view/runWorldTrace.test.ts index e098eeb..8bebba2 100644 --- a/src/view/runWorldTrace.test.ts +++ b/src/view/runWorldTrace.test.ts @@ -12,7 +12,7 @@ const REAL_RUN_DIR = path.resolve( here, "..", "..", - "runs", + "fixtures", "observe", "real-grok-composed", "run-b7ef07f0fd2c4779894c2bb746140972", ); diff --git a/src/view/server.test.ts b/src/view/server.test.ts index 5f46cab..6c313ef 100644 --- a/src/view/server.test.ts +++ b/src/view/server.test.ts @@ -343,7 +343,7 @@ describe("createViewerServer", () => { }); it("serves viewer traces from the shipped office fixture run", async () => { - const fixturePath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "runs", "office-world-v0"); + const fixturePath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "fixtures", "observe", "office-world-v0-golden"); const handle = await createViewerServer({ mode: "replay", port: 0, diff --git a/tools/AGENTS.md b/tools/AGENTS.md index 6de20b2..c108297 100644 --- a/tools/AGENTS.md +++ b/tools/AGENTS.md @@ -4,9 +4,9 @@ This folder contains repository-maintenance tools. Resolve repository paths relative to each tool's own module URL, keep checks read-only, and keep refresh operations deterministic and fail-closed. -- `refreshVendorStele.mjs` refreshes the integrity-pinned source tarball while - preserving Simfile's release-safe exact dependency coordinate and bundle. -- `verify-package-closure.mjs` packs and offline-installs Simfile, checks the - bundled Stele closure and runtime imports, and starts the installed CLI. Its - contract and isolated-install helpers live in `package-closure-contract.mjs` - and `package-closure-install.mjs`. +- Maintained tools are strict TypeScript sources and are emitted to + `dist/tools/` during build for package and installed-consumer checks. +- `verify-package-closure.ts` packs and offline-installs Simfile, checks the + bundled package closure and runtime imports, and starts the installed CLI. + Its contract and isolated-install helpers live in + `package-closure-contract.ts` and `package-closure-install.ts`. diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..fe80f63 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,16 @@ +# Repository Tools + +`tools/` holds maintainer checks that are not product entrypoints. + +- `verify-package-closure.ts` packs Simfile, installs it offline in an isolated + consumer project, checks that runtime dependencies resolve from the package + graph, verifies the installed CLI, and proves the emitted development command + modules are usable from the installed package. +- `package-closure-contract.ts` contains package-manifest and tarball-entry + assertions. +- `package-closure-install.ts` contains the isolated install and packed-example + probes. + +These tools are TypeScript source in the repository and emitted to +`dist/tools/` during build so package-closure verification does not rely on +TypeScript files inside `node_modules`. diff --git a/tools/package-closure-contract.mjs b/tools/package-closure-contract.mjs deleted file mode 100644 index 45b5c71..0000000 --- a/tools/package-closure-contract.mjs +++ /dev/null @@ -1,109 +0,0 @@ -import { lstat, readFile } from "node:fs/promises"; -import path from "node:path"; - -const STELE = "@noopolis/stele"; -export const STELE_VERSION = "0.0.2"; - -export const fail = (message) => { throw new Error(message); }; -export const readJson = async (filePath) => JSON.parse(await readFile(filePath, "utf8")); - -export const parseSinglePack = (stdout) => { - let parsed; - for (let index = stdout.lastIndexOf("["); index >= 0; - index = stdout.lastIndexOf("[", index - 1)) { - try { - const candidate = JSON.parse(stdout.slice(index)); - if (Array.isArray(candidate)) { parsed = candidate; break; } - } catch { - // Lifecycle scripts may write before npm's final JSON array. - } - } - if (!Array.isArray(parsed) || parsed.length !== 1) { - fail("npm pack must report exactly one tarball"); - } - const [result] = parsed; - if (!result || typeof result.filename !== "string" || !Array.isArray(result.files)) { - fail("npm pack returned an invalid manifest"); - } - return result; -}; - -export const assertRegistrySource = async (packageRoot, manifest, lock) => { - if (manifest.dependencies?.[STELE] !== STELE_VERSION) { - fail(`${STELE} must use the published ${STELE_VERSION} release coordinate`); - } - if (manifest.bundledDependencies !== undefined || manifest.bundleDependencies !== undefined) { - fail("published registry dependencies must not be bundled"); - } - for (const [name, coordinate] of Object.entries(manifest.dependencies ?? {})) { - if (typeof coordinate !== "string" || /^(?:file|link|workspace):/u.test(coordinate)) { - fail(`runtime dependency ${name} is not a registry coordinate`); - } - } - for (const [location, entry] of Object.entries(lock.packages ?? {})) { - if (location === "") continue; - if (entry?.link === true - || (typeof entry?.resolved === "string" && /^(?:file|link):/u.test(entry.resolved))) { - fail(`package lock contains a checkout-relative dependency at ${location}`); - } - } - const locked = lock.packages?.[`node_modules/${STELE}`]; - const expected = `https://registry.npmjs.org/@noopolis/stele/-/stele-${STELE_VERSION}.tgz`; - if (!locked || locked.version !== STELE_VERSION || locked.resolved !== expected) { - fail(`${STELE} lock entry must resolve to the exact npm registry tarball`); - } - if (typeof locked.integrity !== "string" || !locked.integrity.startsWith("sha512-")) { - fail(`${STELE} registry lock is missing sha512 integrity`); - } - const installed = path.join(packageRoot, "node_modules", STELE); - if ((await lstat(installed)).isSymbolicLink()) { - fail(`${STELE} must be physically installed; source-checkout links are rejected`); - } - if ((await readJson(path.join(installed, "package.json"))).version !== STELE_VERSION) { - fail(`${STELE} installed version drifted`); - } - return expected; -}; - -export const assertPackedManifest = (manifest) => { - if (manifest.version !== "0.0.3") fail("packed Simfile version drifted"); - if (manifest.dependencies?.[STELE] !== STELE_VERSION) fail(`packed ${STELE} coordinate drifted`); - if (manifest.bundledDependencies !== undefined || manifest.bundleDependencies !== undefined) { - fail("packed manifest unexpectedly bundles registry dependencies"); - } - for (const [name, coordinate] of Object.entries(manifest.dependencies ?? {})) { - if (typeof coordinate === "string" && /^(?:file|link|workspace):/u.test(coordinate)) { - fail(`packed dependency ${name} retains a checkout-relative coordinate`); - } - } -}; - -export const assertDevelopmentAssets = (entries) => { - const required = [ - "examples/jungian-dialogue/README.md", - "examples/jungian-dialogue/Simfile", - "examples/jungian-dialogue/binding.mjs", - "examples/jungian-dialogue/harness/jungian-engine.mjs", - "examples/jungian-dialogue/org/Spawnfile", - "examples/jungian-dialogue/org/agents/analyst/Spawnfile", - "examples/jungian-dialogue/org/agents/daimon/Spawnfile", - "scripts/bounded-process.mjs", - "scripts/simfile-local-example.mjs", - "scripts/spawnfile-capability-probe.mjs", - "scripts/spawnfile-composed-smoke.mjs", - "scripts/spawnfile-development-context.mjs", - "scripts/spawnfile-development-setup.mjs", - "scripts/spawnfile-development.mjs", - "scripts/spawnfile-install-integrity.mjs", - "scripts/spawnfile-local-endpoint.mjs", - "scripts/spawnfile-source-stage.mjs", - ]; - for (const entry of required) { - if (!entries.includes(entry)) fail(`packed tarball omitted required development asset ${entry}`); - } - if (entries.some((entry) => entry.includes(".test.") || entry.includes(".test-helper."))) { - fail("packed tarball leaked development test files"); - } -}; - -export const stelePackageName = STELE; diff --git a/tools/package-closure-contract.ts b/tools/package-closure-contract.ts new file mode 100644 index 0000000..e6a0ffa --- /dev/null +++ b/tools/package-closure-contract.ts @@ -0,0 +1,188 @@ +import { lstat, readFile } from "node:fs/promises"; +import path from "node:path"; + +const STELE = "@noopolis/stele"; +export const STELE_VERSION = "0.0.2"; + +type JsonObject = Record; + +export interface PackFileEntry { + path: string; +} + +export interface SinglePackManifest { + bundled?: readonly string[]; + entryCount: number; + filename: string; + files: readonly PackFileEntry[]; + id: string; + integrity: string; + shasum: string; +} + +export interface PackageManifest { + bin?: JsonObject; + bundleDependencies?: unknown; + bundledDependencies?: unknown; + dependencies?: JsonObject; + name?: unknown; + version?: unknown; +} + +export const fail = (message: string): never => { throw new Error(message); }; + +const isObject = (value: unknown): value is JsonObject => + value !== null && typeof value === "object" && !Array.isArray(value); + +const objectAt = (value: unknown, key: string): JsonObject | undefined => + isObject(value) && isObject(value[key]) ? value[key] : undefined; + +const dependenciesOf = (manifest: unknown): JsonObject => + isObject(manifest) && isObject(manifest.dependencies) ? manifest.dependencies : {}; + +export const readJson = async (filePath: string): Promise => + JSON.parse(await readFile(filePath, "utf8")); + +export const parseSinglePack = (stdout: string): SinglePackManifest => { + let parsed: unknown; + for (let index = stdout.lastIndexOf("["); index >= 0; + index = stdout.lastIndexOf("[", index - 1)) { + try { + const candidate: unknown = JSON.parse(stdout.slice(index)); + if (Array.isArray(candidate)) { parsed = candidate; break; } + } catch { + // Lifecycle scripts may write before npm's final JSON array. + } + } + if (!Array.isArray(parsed) || parsed.length !== 1) { + return fail("npm pack must report exactly one tarball"); + } + const packEntries = parsed; + const [result] = packEntries; + if (!isObject(result) || typeof result.filename !== "string" + || !Array.isArray(result.files) || typeof result.integrity !== "string" + || typeof result.shasum !== "string") { + return fail("npm pack returned an invalid manifest"); + } + const files: PackFileEntry[] = []; + for (const entry of result.files) { + if (!isObject(entry) || typeof entry.path !== "string") { + return fail("npm pack returned an invalid file entry"); + } + files.push({ path: entry.path }); + } + const bundled = Array.isArray(result.bundled) + && result.bundled.every((entry: unknown) => typeof entry === "string") + ? result.bundled : undefined; + return { + bundled, + entryCount: typeof result.entryCount === "number" ? result.entryCount : files.length, + filename: result.filename, + files, + id: typeof result.id === "string" ? result.id : result.filename, + integrity: result.integrity, + shasum: result.shasum, + }; +}; + +export const assertRegistrySource = async ( + packageRoot: string, + manifest: unknown, + lock: unknown +): Promise => { + const dependencies = dependenciesOf(manifest); + if (dependencies[STELE] !== STELE_VERSION) { + fail(`${STELE} must use the published ${STELE_VERSION} release coordinate`); + } + if (isObject(manifest) + && (manifest.bundledDependencies !== undefined || manifest.bundleDependencies !== undefined)) { + fail("published registry dependencies must not be bundled"); + } + for (const [name, coordinate] of Object.entries(dependencies)) { + if (typeof coordinate !== "string" || /^(?:file|link|workspace):/u.test(coordinate)) { + fail(`runtime dependency ${name} is not a registry coordinate`); + } + } + const packages = objectAt(lock, "packages") ?? {}; + for (const [location, entry] of Object.entries(packages)) { + if (location === "") continue; + if (!isObject(entry)) continue; + if (entry.link === true + || (typeof entry.resolved === "string" && /^(?:file|link):/u.test(entry.resolved))) { + fail(`package lock contains a checkout-relative dependency at ${location}`); + } + } + const locked = objectAt(packages, `node_modules/${STELE}`); + const expected = `https://registry.npmjs.org/@noopolis/stele/-/stele-${STELE_VERSION}.tgz`; + if (locked === undefined || locked.version !== STELE_VERSION || locked.resolved !== expected) { + return fail(`${STELE} lock entry must resolve to the exact npm registry tarball`); + } + const lockedEntry = locked; + if (typeof lockedEntry.integrity !== "string" || !lockedEntry.integrity.startsWith("sha512-")) { + fail(`${STELE} registry lock is missing sha512 integrity`); + } + const installed = path.join(packageRoot, "node_modules", STELE); + if ((await lstat(installed)).isSymbolicLink()) { + fail(`${STELE} must be physically installed; source-checkout links are rejected`); + } + const installedManifest = await readJson(path.join(installed, "package.json")); + if (!isObject(installedManifest) || installedManifest.version !== STELE_VERSION) { + fail(`${STELE} installed version drifted`); + } + return expected; +}; + +export const assertPackedManifest = (manifest: unknown): PackageManifest => { + if (!isObject(manifest)) return fail("packed manifest is invalid"); + const manifestObject = manifest; + const dependencies = dependenciesOf(manifestObject); + if (manifestObject.version !== "0.0.3") fail("packed Simfile version drifted"); + if (dependencies[STELE] !== STELE_VERSION) fail(`packed ${STELE} coordinate drifted`); + if (manifestObject.bundledDependencies !== undefined || manifestObject.bundleDependencies !== undefined) { + fail("packed manifest unexpectedly bundles registry dependencies"); + } + for (const [name, coordinate] of Object.entries(dependencies)) { + if (typeof coordinate === "string" && /^(?:file|link|workspace):/u.test(coordinate)) { + fail(`packed dependency ${name} retains a checkout-relative coordinate`); + } + } + return manifestObject; +}; + +export const assertDevelopmentAssets = (entries: readonly string[]): void => { + const required = [ + "examples/jungian-dialogue/README.md", + "examples/jungian-dialogue/Simfile", + "examples/jungian-dialogue/binding.mjs", + "examples/jungian-dialogue/harness/jungian-engine.mjs", + "examples/jungian-dialogue/org/Spawnfile", + "examples/jungian-dialogue/org/agents/analyst/Spawnfile", + "examples/jungian-dialogue/org/agents/daimon/Spawnfile", + "dist/scripts/bounded-process.js", + "dist/scripts/entrypoint.js", + "dist/scripts/package-root.js", + "dist/scripts/simfile-local-example.js", + "dist/scripts/spawnfile-capability-probe.js", + "dist/scripts/spawnfile-composed-smoke.js", + "dist/scripts/spawnfile-development-context.js", + "dist/scripts/spawnfile-development-setup.js", + "dist/scripts/spawnfile-development.js", + "dist/scripts/spawnfile-install-integrity.js", + "dist/scripts/spawnfile-local-endpoint.js", + "dist/scripts/spawnfile-source-stage.js", + "dist/tools/package-closure-contract.js", + "dist/tools/package-closure-install.js", + "dist/tools/verify-package-closure.js", + ]; + for (const entry of required) { + if (!entries.includes(entry)) fail(`packed tarball omitted required development asset ${entry}`); + } + if (entries.some((entry) => entry.includes(".test.") || entry.includes(".test-helper."))) { + fail("packed tarball leaked development test files"); + } + if (entries.some((entry) => entry.startsWith("scripts/") || entry.startsWith("tools/"))) { + fail("packed tarball leaked source tooling instead of emitted dist tooling"); + } +}; + +export const stelePackageName = STELE; diff --git a/tools/package-closure-install.mjs b/tools/package-closure-install.mjs deleted file mode 100644 index d0eccc6..0000000 --- a/tools/package-closure-install.mjs +++ /dev/null @@ -1,106 +0,0 @@ -import { lstat, mkdir, realpath, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; - -import { runBoundedProcess } from "../scripts/bounded-process.mjs"; -import { - fail, - readJson, - stelePackageName as STELE, - STELE_VERSION, -} from "./package-closure-contract.mjs"; - -export const runPackageClosureProcess = ( - command, args, cwd, env = process.env, -) => runBoundedProcess(command, args, { cwd, env, timeoutMs: 10 * 60 * 1000 }); - -const dependencyRoot = async (installRoot, installedRoot) => { - for (const candidate of [ - path.join(installedRoot, "node_modules", STELE), - path.join(installRoot, "node_modules", STELE), - ]) { - try { await lstat(candidate); return candidate; } - catch (error) { if (error?.code !== "ENOENT") throw error; } - } - fail(`${STELE} was not installed from the packed Simfile dependency graph`); -}; - -const assertPackedExampleBuild = async (packedRoot, scratchRoot) => { - const exampleRoot = path.join(packedRoot, "examples", "jungian-dialogue"); - const binding = await import(pathToFileURL(path.join(exampleRoot, "binding.mjs")).href); - if (typeof binding.composedProjectBinding?.prepareComposedProject !== "function") { - fail("packed composed example binding is unavailable"); - } - const preparation = await binding.composedProjectBinding.prepareComposedProject({ - base_image_config_digest: `sha256:${"a".repeat(64)}`, - evidence_root: path.join(scratchRoot, "example-evidence"), internal_port: 4070, - organization_container_name: "package-closure-example", - platform: { architecture: process.arch === "arm64" ? "arm64" : "amd64", os: "linux" }, - run_id: "package-closure-example", secret_root: path.join(scratchRoot, "example-secrets"), - seed: "package-closure-example-seed", simfile_path: path.join(exampleRoot, "Simfile"), - spawnfile_path: path.join(exampleRoot, "org", "Spawnfile"), - }); - if (!Array.isArray(preparation.bundle?.archive_bytes) - || preparation.bundle.archive_bytes.length < 1) fail("packed composed example bundle archive is empty"); - if (!/^sha256:[a-f0-9]{64}$/u.test(preparation.bundle.manifest?.digest ?? "")) { - fail("packed composed example bundle digest is invalid"); - } - if (preparation.evidence_artifacts?.length !== 10) { - fail("packed composed example evidence mapping is incomplete"); - } - return preparation.bundle.manifest.digest; -}; - -export const buildPackedExample = async (temporaryRoot, tarballPath) => { - const packedRoot = path.join(temporaryRoot, "packed-package"); - const scratchRoot = path.join(temporaryRoot, "packed-example-scratch"); - await Promise.all([mkdir(packedRoot, { recursive: true }), mkdir(scratchRoot, { recursive: true })]); - await runPackageClosureProcess( - "tar", ["-xzf", tarballPath, "--strip-components=1", "-C", packedRoot], temporaryRoot, - ); - await runPackageClosureProcess("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", - "--omit=dev", "--registry=https://registry.npmjs.org"], packedRoot); - return assertPackedExampleBuild(await realpath(packedRoot), await realpath(scratchRoot)); -}; - -export const assertInstalledClosure = async (installRoot, manifest, tarballPath) => { - await writeFile(path.join(installRoot, "package.json"), `${JSON.stringify({ - name: "simfile-package-closure-consumer", private: true, version: "1.0.0", - }, null, 2)}\n`, "utf8"); - await runPackageClosureProcess("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", - "--registry=https://registry.npmjs.org", tarballPath], installRoot); - const installedRoot = path.join(installRoot, "node_modules", manifest.name); - if ((await lstat(installedRoot)).isSymbolicLink()) fail("Simfile installed as a source link"); - const simfile = await import(pathToFileURL(path.join(installedRoot, "dist/index.js")).href); - if (typeof simfile.parseSimfileSource !== "function") fail("installed Simfile public import is incomplete"); - const steleRoot = await dependencyRoot(installRoot, installedRoot); - if ((await lstat(steleRoot)).isSymbolicLink()) fail(`${STELE} installed as a source link`); - const steleManifest = await readJson(path.join(steleRoot, "package.json")); - if (steleManifest.version !== STELE_VERSION) fail(`${STELE} installed version drifted`); - const steleImport = steleManifest.exports?.["."]?.import; - if (typeof steleImport !== "string" || !steleImport.startsWith("./")) { - fail(`${STELE} does not expose a package-relative ESM entrypoint`); - } - const installRealRoot = await realpath(installRoot); - const steleRealPath = await realpath(path.resolve(steleRoot, steleImport)); - if (!steleRealPath.startsWith(`${installRealRoot}${path.sep}`)) { - fail(`${STELE} resolved outside the isolated install`); - } - const stele = await import(pathToFileURL(steleRealPath).href); - if (typeof stele.parseCausalJsonl !== "function") fail(`${STELE} runtime import is incomplete`); - const executable = path.join(installRoot, "node_modules", ".bin", "simfile"); - const help = await runPackageClosureProcess(executable, ["--help"], installRoot, { - ...process.env, PATH: `${path.dirname(executable)}${path.delimiter}${process.env.PATH ?? ""}`, - }); - if (!help.stdout.startsWith("Usage:\n") || !help.stdout.includes("simfile run ")) { - fail("installed Simfile executable did not invoke the CLI entrypoint"); - } - const importProbe = path.join(installRoot, "import-cli.mjs"); - const installedCli = path.join(installedRoot, manifest.bin.simfile); - await writeFile(importProbe, `await import(${JSON.stringify(pathToFileURL(installedCli).href)});\nprocess.stdout.write("import-only-ok\\n");\n`, "utf8"); - const imported = await runPackageClosureProcess(process.execPath, [importProbe], installRoot); - if (imported.stdout !== "import-only-ok\n" || imported.stderr !== "") { - fail("importing the installed CLI produced entrypoint side effects"); - } - return { steleResolved: path.relative(installRealRoot, steleRealPath) }; -}; diff --git a/tools/package-closure-install.ts b/tools/package-closure-install.ts new file mode 100644 index 0000000..9ed82cd --- /dev/null +++ b/tools/package-closure-install.ts @@ -0,0 +1,168 @@ +import { lstat, mkdir, realpath, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { runBoundedProcess, type BoundedProcessResult } from "../scripts/bounded-process.ts"; +import { + fail, + readJson, + stelePackageName as STELE, + STELE_VERSION, + type PackageManifest, +} from "./package-closure-contract.ts"; + +type JsonObject = Record; +type PrepareComposedProject = (input: Record) => Promise; + +const isObject = (value: unknown): value is JsonObject => + value !== null && typeof value === "object" && !Array.isArray(value); + +export const runPackageClosureProcess = ( + command: string, + args: readonly string[], + cwd: string, + env: NodeJS.ProcessEnv = process.env +): Promise => runBoundedProcess(command, args, { cwd, env, timeoutMs: 10 * 60 * 1000 }); + +const dependencyRoot = async (installRoot: string, installedRoot: string): Promise => { + for (const candidate of [ + path.join(installedRoot, "node_modules", STELE), + path.join(installRoot, "node_modules", STELE), + ]) { + try { await lstat(candidate); return candidate; } + catch (error: unknown) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + } + return fail(`${STELE} was not installed from the packed Simfile dependency graph`); +}; + +const assertPackedExampleBuild = async (packedRoot: string, scratchRoot: string): Promise => { + const exampleRoot = path.join(packedRoot, "examples", "jungian-dialogue"); + const binding = await import(pathToFileURL(path.join(exampleRoot, "binding.mjs")).href) as { + composedProjectBinding?: { prepareComposedProject?: unknown }; + }; + const prepare = binding.composedProjectBinding?.prepareComposedProject; + if (typeof prepare !== "function") { + fail("packed composed example binding is unavailable"); + } + const preparation = await (prepare as PrepareComposedProject)({ + base_image_config_digest: `sha256:${"a".repeat(64)}`, + evidence_root: path.join(scratchRoot, "example-evidence"), internal_port: 4070, + organization_container_name: "package-closure-example", + platform: { architecture: process.arch === "arm64" ? "arm64" : "amd64", os: "linux" }, + run_id: "package-closure-example", secret_root: path.join(scratchRoot, "example-secrets"), + seed: "package-closure-example-seed", simfile_path: path.join(exampleRoot, "Simfile"), + spawnfile_path: path.join(exampleRoot, "org", "Spawnfile"), + }) as { + bundle?: { archive_bytes?: unknown; manifest?: { digest?: unknown } }; + evidence_artifacts?: unknown[]; + }; + const bundle = preparation.bundle; + if (bundle === undefined || !Array.isArray(bundle.archive_bytes) || bundle.archive_bytes.length < 1) { + return fail("packed composed example bundle archive is empty"); + } + const digest = bundle.manifest?.digest; + if (typeof digest !== "string" || !/^sha256:[a-f0-9]{64}$/u.test(digest)) { + return fail("packed composed example bundle digest is invalid"); + } + if (preparation.evidence_artifacts?.length !== 10) { + return fail("packed composed example evidence mapping is incomplete"); + } + return digest; +}; + +export const buildPackedExample = async (temporaryRoot: string, tarballPath: string): Promise => { + const packedRoot = path.join(temporaryRoot, "packed-package"); + const scratchRoot = path.join(temporaryRoot, "packed-example-scratch"); + await Promise.all([mkdir(packedRoot, { recursive: true }), mkdir(scratchRoot, { recursive: true })]); + await runPackageClosureProcess( + "tar", ["-xzf", tarballPath, "--strip-components=1", "-C", packedRoot], temporaryRoot, + ); + await runPackageClosureProcess("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", + "--omit=dev", "--registry=https://registry.npmjs.org"], packedRoot); + return assertPackedExampleBuild(await realpath(packedRoot), await realpath(scratchRoot)); +}; + +const assertInstalledDevelopmentCommands = async ( + installRoot: string, + installedRoot: string +): Promise => { + const probe = path.join(installRoot, "import-development-commands.mjs"); + const localExample = pathToFileURL(path.join(installedRoot, "dist/scripts/simfile-local-example.js")).href; + const composedSmoke = pathToFileURL(path.join(installedRoot, "dist/scripts/spawnfile-composed-smoke.js")).href; + const development = pathToFileURL(path.join(installedRoot, "dist/scripts/spawnfile-development.js")).href; + await writeFile(probe, ` +const local = await import(${JSON.stringify(localExample)}); +const smoke = await import(${JSON.stringify(composedSmoke)}); +const dev = await import(${JSON.stringify(development)}); +const localInvocation = local.createLocalExampleInvocation("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa"); +const smokeInvocation = smoke.createComposedSmokeInvocation(["--context", "local_dev"], "bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb"); +if (!localInvocation.args[0].endsWith("/node_modules/simfile/dist/cli/index.js")) throw new Error("local example command did not resolve installed CLI"); +if (!smokeInvocation.command_args[0].endsWith("/node_modules/simfile/dist/cli/index.js")) throw new Error("composed smoke command did not resolve installed CLI"); +if (typeof dev.runSpawnfileDevelopmentCommand !== "function") throw new Error("development dispatcher export missing"); +process.stdout.write("installed-development-commands-ok\\n"); +`, "utf8"); + const result = await runPackageClosureProcess(process.execPath, [probe], installRoot); + if (result.stdout !== "installed-development-commands-ok\n" || result.stderr !== "") { + fail("installed emitted development commands did not import cleanly"); + } +}; + +export const assertInstalledClosure = async ( + installRoot: string, + manifest: PackageManifest, + tarballPath: string +): Promise> => { + await writeFile(path.join(installRoot, "package.json"), `${JSON.stringify({ + name: "simfile-package-closure-consumer", private: true, version: "1.0.0", + }, null, 2)}\n`, "utf8"); + await runPackageClosureProcess("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", + "--registry=https://registry.npmjs.org", tarballPath], installRoot); + if (typeof manifest.name !== "string") return fail("package manifest name is missing"); + const packageName = manifest.name; + const installedRoot = path.join(installRoot, "node_modules", packageName); + if ((await lstat(installedRoot)).isSymbolicLink()) fail("Simfile installed as a source link"); + const simfile = await import(pathToFileURL(path.join(installedRoot, "dist/index.js")).href) as { + parseSimfileSource?: unknown; + }; + if (typeof simfile.parseSimfileSource !== "function") fail("installed Simfile public import is incomplete"); + const steleRoot = await dependencyRoot(installRoot, installedRoot); + if ((await lstat(steleRoot)).isSymbolicLink()) fail(`${STELE} installed as a source link`); + const steleManifest = await readJson(path.join(steleRoot, "package.json")); + if (!isObject(steleManifest) || steleManifest.version !== STELE_VERSION) return fail(`${STELE} installed version drifted`); + const steleManifestObject = steleManifest; + const steleExports = isObject(steleManifestObject.exports) ? steleManifestObject.exports : undefined; + const rootExport = steleExports !== undefined && isObject(steleExports["."]) ? steleExports["."] : undefined; + const steleImport = rootExport?.import; + if (typeof steleImport !== "string" || !steleImport.startsWith("./")) { + return fail(`${STELE} does not expose a package-relative ESM entrypoint`); + } + const steleImportPath = steleImport; + const installRealRoot = await realpath(installRoot); + const steleRealPath = await realpath(path.resolve(steleRoot, steleImportPath)); + if (!steleRealPath.startsWith(`${installRealRoot}${path.sep}`)) { + fail(`${STELE} resolved outside the isolated install`); + } + const stele = await import(pathToFileURL(steleRealPath).href) as { parseCausalJsonl?: unknown }; + if (typeof stele.parseCausalJsonl !== "function") fail(`${STELE} runtime import is incomplete`); + const simfileBin = manifest.bin?.simfile; + if (typeof simfileBin !== "string") { + return fail("package manifest is missing the simfile executable"); + } + const simfileBinPath = simfileBin; + const executable = path.join(installRoot, "node_modules", ".bin", "simfile"); + const help = await runPackageClosureProcess(executable, ["--help"], installRoot, { + ...process.env, PATH: `${path.dirname(executable)}${path.delimiter}${process.env.PATH ?? ""}`, + }); + if (!help.stdout.startsWith("Usage:\n") || !help.stdout.includes("simfile run ")) { + fail("installed Simfile executable did not invoke the CLI entrypoint"); + } + const importProbe = path.join(installRoot, "import-cli.mjs"); + const installedCli = path.join(installedRoot, simfileBinPath); + await writeFile(importProbe, `await import(${JSON.stringify(pathToFileURL(installedCli).href)});\nprocess.stdout.write("import-only-ok\\n");\n`, "utf8"); + const imported = await runPackageClosureProcess(process.execPath, [importProbe], installRoot); + if (imported.stdout !== "import-only-ok\n" || imported.stderr !== "") { + fail("importing the installed CLI produced entrypoint side effects"); + } + await assertInstalledDevelopmentCommands(installRoot, installedRoot); + return { steleResolved: path.relative(installRealRoot, steleRealPath) }; +}; diff --git a/tools/verify-package-closure.mjs b/tools/verify-package-closure.ts similarity index 85% rename from tools/verify-package-closure.mjs rename to tools/verify-package-closure.ts index c8a713a..21f7c18 100644 --- a/tools/verify-package-closure.mjs +++ b/tools/verify-package-closure.ts @@ -3,8 +3,8 @@ import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { resolvePackageRoot } from "../scripts/package-root.ts"; import { assertDevelopmentAssets, assertPackedManifest, @@ -12,14 +12,14 @@ import { fail, parseSinglePack, readJson, -} from "./package-closure-contract.mjs"; +} from "./package-closure-contract.ts"; import { assertInstalledClosure, buildPackedExample, runPackageClosureProcess, -} from "./package-closure-install.mjs"; +} from "./package-closure-install.ts"; -const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packageRoot = resolvePackageRoot(import.meta.url); const main = async () => { const manifest = await readJson(path.join(packageRoot, "package.json")); @@ -51,17 +51,18 @@ const main = async () => { fail("packed tarball leaked a fixture, dependency, source checkout, or vendor archive"); } assertDevelopmentAssets(entries); - const packedManifest = JSON.parse((await runPackageClosureProcess( + const packedManifest: unknown = JSON.parse((await runPackageClosureProcess( "tar", ["-xOf", tarballPath, "package/package.json"], packageRoot, )).stdout); - assertPackedManifest(packedManifest); - const installed = await assertInstalledClosure(installRoot, manifest, tarballPath); + const packedManifestData = assertPackedManifest(packedManifest); + const rootManifest = assertPackedManifest(manifest); + const installed = await assertInstalledClosure(installRoot, rootManifest, tarballPath); const exampleBundleDigest = await buildPackedExample(temporaryRoot, tarballPath); process.stdout.write(`${JSON.stringify({ bundled: packed.bundled ?? [], entries: packed.entryCount, integrity: packed.integrity, package: packed.id, packed_file: packed.filename, packed_example_bundle_digest: exampleBundleDigest, - runtime_dependencies: packedManifest.dependencies, + runtime_dependencies: packedManifestData.dependencies, stele_registry_tarball: steleRegistryTarball, stele_resolved_inside_install: installed.steleResolved, }, null, 2)}\n`); diff --git a/tsconfig.scripts.build.json b/tsconfig.scripts.build.json new file mode 100644 index 0000000..8ecb1f0 --- /dev/null +++ b/tsconfig.scripts.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.scripts.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "declaration": false, + "declarationMap": false, + "noEmit": false, + "outDir": "dist", + "rootDir": ".", + "rewriteRelativeImportExtensions": true, + "sourceMap": false + }, + "exclude": ["scripts/**/*.test.ts", "scripts/render-coverage.ts"] +} diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000..f600e05 --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "erasableSyntaxOnly": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["node"] + }, + "include": ["scripts/**/*.ts", "tools/**/*.ts"] +}