From 1185bd4b7c25fe6d5a2943a5ae6f749835cba133 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:40:06 -0400 Subject: [PATCH 01/11] =?UTF-8?q?=E2=9C=85=20Prove=20Workspace=20effects?= =?UTF-8?q?=20survive=20a=20real=20host=20crash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other atomic-Workspace proof ends its transaction inside the test process, where Effection tears the scope down. A crash does none of that: it leaves an open SQLite transaction with nobody to roll it back, and what the database holds afterwards is SQLite's recovery rather than anything the adapter runs. So this is made of real processes. One resumes a committed run, performs a real Workspace effect, and stops at the accepted construction-time routed-append hook with the mutation, the immutable root, the current-root pointer and the routed journal row all written and none of them committed. It says so on standard output, a second connection is shown seeing only the baseline, and then it is killed with SIGKILL. A different process, through a newly installed production provider, finds the baseline filesystem, root, retained counts and journal exactly. Two more processes commit a Workspace history and reconstruct it cold: the second observes the committed filesystem, current root, ordered events, event identities and event-to-root associations without performing a recorded effect again, then selects the older event's root through the adapter-private materializer and rebuilds its topology, bytes, modes, hardlinks and symbolic links from the DOFS content that root retains. The crash child assembles the adapter's own modules rather than calling `useWorkflowRunStorage`, because the routed-append hook is installed when the connection registry is constructed and the provider constructs its own. The inspector and both restart processes use the provider. No production behavior changes. --- architecture.md | 12 + .../tests/support/workspace-crash-child.ts | 205 +++++++++ .../tests/support/workspace-process.ts | 67 +++ .../tests/support/workspace-restart-child.ts | 213 +++++++++ .../tests/workspace-crash-recovery.test.ts | 421 ++++++++++++++++++ scripts/runtime-test-exclusions.ts | 6 + specs/executable-mdx-spec.md | 3 + specs/workflow-spec.md | 15 + specs/workflow-workspace-spec.md | 13 +- 9 files changed, 954 insertions(+), 1 deletion(-) create mode 100644 packages/workflow/tests/support/workspace-crash-child.ts create mode 100644 packages/workflow/tests/support/workspace-process.ts create mode 100644 packages/workflow/tests/support/workspace-restart-child.ts create mode 100644 packages/workflow/tests/workspace-crash-recovery.test.ts diff --git a/architecture.md b/architecture.md index 5cc28fbd..71f5d708 100644 --- a/architecture.md +++ b/architecture.md @@ -550,6 +550,18 @@ supplies a provider-neutral failure activator so a provider can retain that first infrastructure failure by identity; the default coordinator does not use it and keeps its existing behavior. +Losing the host is not one of those outcomes, because nothing runs to handle +it. A killed process leaves its transaction open, and SQLite's own recovery +decides what the database holds next: the mutation, the immutable root, the +current-root pointer and the routed event are all written by then, and none of +them survives. While that process is alive another connection sees only the +last committed state, so the boundary is the same one every other reader +observes rather than a second rule for crashes. What a later process finds is +that committed state — the filesystem, the current root, the retained roots and +references, and the ordered journal with its event identities and root +associations — from which it performs no recorded effect again and can +materialize any retained root. + The Deno journal adapter routes an append ordinarily when no destination is bound. A publication may instead bind one exact transaction destination for its own lexical scope after the existing secret gate. The route validates the diff --git a/packages/workflow/tests/support/workspace-crash-child.ts b/packages/workflow/tests/support/workspace-crash-child.ts new file mode 100644 index 00000000..c82d1695 --- /dev/null +++ b/packages/workflow/tests/support/workspace-crash-child.ts @@ -0,0 +1,205 @@ +/** + * The two processes a crash proof needs, and neither of them is the test. + * + * A cancelled task, a thrown error and a closed scope all unwind. A killed + * process does not: SQLite is left holding a transaction nobody will finish, + * and recovery is the database's own. Proving that recovery therefore takes a + * process the test can kill without warning, and a second one that has never + * seen the first — which is what these two modes are. + * + * ```sh + * deno run -A workspace-crash-child.ts crash + * deno run -A workspace-crash-child.ts inspect + * ``` + * + * `crash` opens the run, performs one real Workspace effect, and stops inside + * the still-open transaction with the mutation, the immutable root, the + * current-root pointer and the routed journal row all written and none of them + * committed. It reports what that connection can see and then waits to be + * killed. + * + * That stopping point is the connection registry's construction-time routed + * append hook, which only a caller that builds the registry can install. So + * `crash` assembles the same adapter modules the Deno provider installs, at the + * path the provider derives, rather than calling `useWorkflowRunStorage`. + * `inspect` has no such need and uses the provider itself. + */ + +import process from "node:process"; +import { durableRun, guardDurableStream, type Workflow } from "@executablemd/durable-streams"; +import { ensure, main, type Operation, suspend } from "effection"; +import { WorkflowRunStorage } from "../../mod.ts"; +import { useWorkflowRunStorage, workflowRunPath } from "../../deno.ts"; +import { createWorkflowRunConnections } from "../../src/deno/connections.ts"; +import { openWorkflowRunDatabase, readRunRow } from "../../src/deno/database.ts"; +import { useJournalRouting } from "../../src/deno/journal-route.ts"; +import { readTransaction } from "../../src/deno/reading.ts"; +import { verifySchema } from "../../src/deno/schema.ts"; +import { + createWorkspaceProofEffect, + useWorkspaceEffects, + withWorkspaceEffects, +} from "../../src/deno/workspace/effect.ts"; +import type { DenoWorkspaceFilesystem } from "../../src/deno/workspace/filesystem.ts"; +import { currentWorkspaceRoot } from "../../src/deno/workspace/root.ts"; +import { + setPrivateWorkspaceClock, + transactWorkspaceRoots, + usePrivateWorkspace, +} from "../../src/deno/workspace/private.ts"; +import { + BASELINE_EFFECT, + count, + CRASH_CONTENT, + CRASH_EFFECT, + CRASH_PATH, + readTree, + report, +} from "./workspace-process.ts"; + +const CLOCK = 1_750_000_100_000; + +function* crash(root: string, runId: string): Operation { + const path = workflowRunPath(root, runId); + let filesystem: DenoWorkspaceFilesystem | undefined; + let gateCalls = 0; + let baselineExecutions = 0; + + const connections = createWorkflowRunConnections(() => {}, { + *afterRoutedJournalAppend(_database, event): Operation { + if (event.type !== "yield" || filesystem === undefined) { + return; + } + // Every read below is on the connection that opened the transaction, so + // it sees that transaction's own uncommitted writes. Nothing else can. + const sqlite = connection.database; + const currentRoot = currentWorkspaceRoot(sqlite, path); + const journalRow = sqlite + .prepare( + `SELECT event_id, workspace_root_id FROM journal_events + WHERE record LIKE ? ORDER BY sequence DESC LIMIT 1`, + ) + .get(`%"name":"${CRASH_EFFECT}"%`); + report({ + ready: true, + content: yield* filesystem.readTextFile(CRASH_PATH), + currentRoot, + retainedRoots: count( + sqlite.prepare("SELECT COUNT(*) AS count FROM workspace_roots").get()?.["count"], + ), + currentRootRetained: count( + sqlite + .prepare("SELECT COUNT(*) AS count FROM workspace_roots WHERE root_id = ?") + .get(currentRoot)?.["count"], + ), + journalEventId: journalRow?.["event_id"], + journalRootId: journalRow?.["workspace_root_id"], + journalRows: count( + sqlite.prepare("SELECT COUNT(*) AS count FROM journal_events").get()?.["count"], + ), + gateCalls, + baselineExecutions, + }); + // Deno leaves when its event loop is empty, and a suspended Effection + // task is not on it. A timer nothing clears is what keeps this process + // and its open transaction alive until the signal arrives. + setInterval(() => {}, 1_000); + // The transaction stays open from here until the operating system takes + // this process away, which is what the process is for. + yield* suspend(); + }, + }); + yield* ensure(() => connections.close()); + + const connection = connections.at(path); + readTransaction(connection.database, () => { + verifySchema(connection.database, path, connection.dofs); + }); + const record = readRunRow(connection.database, path); + + yield* useJournalRouting(connections); + yield* usePrivateWorkspace(connections); + yield* useWorkspaceEffects(connections); + const database = yield* openWorkflowRunDatabase({ connection, connections, record }); + yield* setPrivateWorkspaceClock(database, () => CLOCK); + + const guarded = guardDurableStream(database.journal, function* (event) { + if (event.type === "yield") { + gateCalls += 1; + } + }); + + function* workflow(): Workflow { + // The run this process resumes already holds this effect's result, so it + // replays. Executing it would mean the crash effect below is not the + // first live work of the process, and the count says which happened. + yield createWorkspaceProofEffect( + database, + { type: "workspace-proof", name: BASELINE_EFFECT }, + // deno-lint-ignore require-yield + function* () { + baselineExecutions += 1; + return null; + }, + ); + yield createWorkspaceProofEffect( + database, + { type: "workspace-proof", name: CRASH_EFFECT }, + function* (selected) { + filesystem = selected; + yield* selected.writeFile(CRASH_PATH, CRASH_CONTENT, 0o640); + return null; + }, + ); + } + + yield* withWorkspaceEffects(database, durableRun(workflow, { stream: guarded })); + report({ ready: false, reason: "the crash effect committed" }); +} + +function* inspect(root: string, runId: string): Operation { + yield* useWorkflowRunStorage({ root }); + const opened = yield* WorkflowRunStorage.operations.lookup(runId); + if (!opened.ok) { + throw opened.error; + } + const database = opened.value; + + const entries = yield* database.readJournalEntries(); + if (!entries.ok) { + throw entries.error; + } + + const observed = yield* transactWorkspaceRoots(database, function* (workspace) { + return { + currentRoot: yield* workspace.currentRoot(), + tree: yield* readTree(workspace.filesystem, "/"), + }; + }); + if (!observed.ok) { + throw observed.error; + } + + report({ + ...observed.value, + events: entries.value.map((entry) => ({ + eventId: entry.eventId, + name: entry.event.type === "yield" ? entry.event.description.name : undefined, + })), + }); +} + +main(function* () { + // `process.argv` rather than `Deno.args`: this file is Deno-only to run, and + // still has to typecheck under the Node project like every other source. + const [mode, root, runId] = process.argv.slice(2); + if (mode === "crash") { + yield* crash(root, runId); + return; + } + if (mode === "inspect") { + yield* inspect(root, runId); + return; + } + throw new Error(`the Workspace crash helper has no ${mode} mode`); +}); diff --git a/packages/workflow/tests/support/workspace-process.ts b/packages/workflow/tests/support/workspace-process.ts new file mode 100644 index 00000000..3674a716 --- /dev/null +++ b/packages/workflow/tests/support/workspace-process.ts @@ -0,0 +1,67 @@ +/** + * What the crash and restart processes and the test that drives them agree on. + * + * The helpers below are imported by a child process and by the suite that + * launches it, so this module starts nothing: a child's `main()` lives in the + * child's own file, and importing a constant from it would run the child + * inside the test. + */ + +import type { Operation } from "effection"; +import type { DenoWorkspaceFilesystem } from "../../src/deno/workspace/filesystem.ts"; + +/** The mutation the killed process performs, and never publishes. */ +export const CRASH_PATH = "/crash.txt"; +export const CRASH_CONTENT = "bytes that must never be published"; +export const CRASH_EFFECT = "crash-before-commit"; + +/** The baseline the crash runs against, committed before the child starts. */ +export const BASELINE_PATH = "/baseline.txt"; +export const BASELINE_CONTENT = "committed baseline bytes"; +export const NESTED_PATH = "/kept/nested.txt"; +export const NESTED_CONTENT = "nested baseline bytes"; +export const BASELINE_EFFECT = "baseline"; +export const BASELINE_CLOCK = 1_750_000_000_000; + +/** The two committed effects of the restart proof, and what the first retains. */ +export const SEED_CLOCK = 10_000; +export const REVISE_CLOCK = 20_000; +export const HISTORICAL_PATH = "/tree/file.txt"; +export const HISTORICAL_CONTENT = "historical bytes"; + +/** A SQLite count, which arrives as a `bigint` from a read that asked for one. */ +export function count(value: unknown): number { + return typeof value === "bigint" ? Number(value) : Number(value); +} + +/** Everything the Workspace holds, as one comparable value. */ +export function* readTree( + filesystem: DenoWorkspaceFilesystem, + directory: string, +): Operation> { + const tree: Record = {}; + for (const entry of yield* filesystem.readdir(directory)) { + const path = directory === "/" ? `/${entry.name}` : `${directory}/${entry.name}`; + const stat = yield* filesystem.lstat(path); + if (entry.kind === "directory") { + tree[path] = { kind: "directory", mode: stat.mode, mtime: stat.mtime }; + Object.assign(tree, yield* readTree(filesystem, path)); + } else if (entry.kind === "symlink") { + tree[path] = { kind: "symlink", target: yield* filesystem.readlink(path) }; + } else { + tree[path] = { + kind: "file", + mode: stat.mode, + mtime: stat.mtime, + size: stat.size, + content: yield* filesystem.readTextFile(path), + }; + } + } + return tree; +} + +/** One line of JSON on standard output is the whole protocol with the parent. */ +export function report(value: unknown): void { + console.log(JSON.stringify(value)); +} diff --git a/packages/workflow/tests/support/workspace-restart-child.ts b/packages/workflow/tests/support/workspace-restart-child.ts new file mode 100644 index 00000000..c3a94e04 --- /dev/null +++ b/packages/workflow/tests/support/workspace-restart-child.ts @@ -0,0 +1,213 @@ +/** + * A committed Workspace, and a second process that has never seen it. + * + * `commit` performs two real Workspace effects through the production Deno + * provider and exits normally, so its connection, its DOFS wrapper and every + * cache they held are gone before anything else looks. `restore` reopens the + * same run and answers three questions the first process cannot: whether the + * committed filesystem, root and journal come back; whether replay performs + * the recorded effects a second time; and whether an event's historical root + * still materializes exactly. + * + * ```sh + * deno run -A workspace-restart-child.ts commit + * deno run -A workspace-restart-child.ts read + * deno run -A workspace-restart-child.ts restore + * ``` + * + * Each effect appends its name to the marker file, so "did this run again" is + * observable from outside the process rather than inferred from its output. + */ + +import { appendFileSync } from "node:fs"; +import process from "node:process"; +import { durableRun, type Workflow } from "@executablemd/durable-streams"; +import { main, type Operation } from "effection"; +import { WorkflowRunStorage, type WorkflowRunDatabase } from "../../mod.ts"; +import { useWorkflowRunStorage } from "../../deno.ts"; +import { + createWorkspaceProofEffect, + withWorkspaceEffects, +} from "../../src/deno/workspace/effect.ts"; +import { + setPrivateWorkspaceClock, + transactWorkspaceRoots, +} from "../../src/deno/workspace/private.ts"; +import { + HISTORICAL_CONTENT, + HISTORICAL_PATH, + readTree, + report, + REVISE_CLOCK, + SEED_CLOCK, +} from "./workspace-process.ts"; + +const DEFINITION = { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "9fceb02d0ae598e95dc970b74767f19372d61af8", + rootDocumentPath: "workflows/release.md", +} as const; + +/** + * Two effects, so there is a history to select from. + * + * The first builds every topology the root format describes: a directory with + * its own mode, a file with its own mode, a hardlink and a symbolic link. The + * second takes all of it apart again — overwrite, mode change, rename, two + * deletions, a different symbolic link and a new directory. Nothing the first + * effect wrote survives at the path it wrote it to, so restoring its root + * cannot be satisfied by whatever happens to be live. + */ +function workflow(database: WorkflowRunDatabase, marker: string, clock: { now: number }) { + return function* (): Workflow { + yield createWorkspaceProofEffect( + database, + { type: "workspace-proof", name: "seed" }, + function* (filesystem) { + appendFileSync(marker, "seed\n"); + yield* filesystem.mkdir("/tree", { mode: 0o750 }); + yield* filesystem.writeFile(HISTORICAL_PATH, HISTORICAL_CONTENT, 0o640); + yield* filesystem.link(HISTORICAL_PATH, "/tree/hardlink.txt"); + yield* filesystem.symlink("file.txt", "/tree/current.txt"); + return null; + }, + ); + yield createWorkspaceProofEffect( + database, + { type: "workspace-proof", name: "revise" }, + function* (filesystem) { + appendFileSync(marker, "revise\n"); + clock.now = REVISE_CLOCK; + yield* filesystem.writeFile(HISTORICAL_PATH, "later bytes"); + yield* filesystem.chmod(HISTORICAL_PATH, 0o600); + yield* filesystem.rename(HISTORICAL_PATH, "/renamed.txt"); + yield* filesystem.remove("/tree/hardlink.txt"); + yield* filesystem.remove("/tree/current.txt"); + yield* filesystem.symlink("/renamed.txt", "/latest.txt"); + yield* filesystem.mkdir("/later", { mode: 0o700 }); + return null; + }, + ); + }; +} + +function* openRun(root: string, runId: string, create: boolean): Operation { + yield* useWorkflowRunStorage({ root }); + const opened = create + ? yield* WorkflowRunStorage.operations.create({ + runId, + definition: DEFINITION, + base: "main", + props: { channel: "stable" }, + }) + : yield* WorkflowRunStorage.operations.lookup(runId); + if (!opened.ok) { + throw opened.error; + } + return opened.value; +} + +function* observe(database: WorkflowRunDatabase): Operation> { + const entries = yield* database.readJournalEntries(); + if (!entries.ok) { + throw entries.error; + } + const state = yield* transactWorkspaceRoots(database, function* (workspace) { + return { + currentRoot: yield* workspace.currentRoot(), + tree: yield* readTree(workspace.filesystem, "/"), + }; + }); + if (!state.ok) { + throw state.error; + } + return { + ...state.value, + events: entries.value.map((entry) => ({ + eventId: entry.eventId, + name: entry.event.type === "yield" ? entry.event.description.name : undefined, + })), + }; +} + +function* run( + root: string, + runId: string, + marker: string, + create: boolean, +): Operation { + const database = yield* openRun(root, runId, create); + const clock = { now: SEED_CLOCK }; + yield* setPrivateWorkspaceClock(database, () => clock.now); + yield* withWorkspaceEffects( + database, + durableRun(workflow(database, marker, clock), { stream: database.journal }), + ); + return database; +} + +function* commit(root: string, runId: string, marker: string): Operation { + report(yield* observe(yield* run(root, runId, marker, true))); +} + +/** + * The same workflow, in a process that never ran it. + * + * Every effect is already in the journal, so replay must restore each result + * without reaching the coordinator — and the marker file is what says whether + * it did. + */ +function* read(root: string, runId: string, marker: string): Operation { + report(yield* observe(yield* run(root, runId, marker, false))); +} + +function* restore(root: string, runId: string, marker: string, rootId: string): Operation { + const database = yield* run(root, runId, marker, false); + const committed = yield* observe(database); + + const restored = yield* transactWorkspaceRoots(database, function* (workspace) { + let absent: string | undefined; + try { + yield* workspace.filesystem.readTextFile(HISTORICAL_PATH); + } catch (error) { + absent = error instanceof Error ? error.name : "unknown"; + } + + const selected = yield* workspace.restore(rootId, { publish: true }); + const tree = yield* readTree(workspace.filesystem, "/"); + const resnapshot = yield* workspace.capture({ publish: true }); + return { + absent, + selectedRoot: selected.rootId, + manifestHashes: selected.manifestHashes, + blobHashes: selected.blobHashes, + resnapshotRoot: resnapshot.rootId, + currentRoot: yield* workspace.currentRoot(), + tree, + }; + }); + if (!restored.ok) { + throw restored.error; + } + + report({ committed, restored: restored.value }); +} + +main(function* () { + const [mode, root, runId, marker, rootId] = process.argv.slice(2); + if (mode === "commit") { + yield* commit(root, runId, marker); + return; + } + if (mode === "read") { + yield* read(root, runId, marker); + return; + } + if (mode === "restore") { + yield* restore(root, runId, marker, rootId); + return; + } + throw new Error(`the Workspace restart helper has no ${mode} mode`); +}); diff --git a/packages/workflow/tests/workspace-crash-recovery.test.ts b/packages/workflow/tests/workspace-crash-recovery.test.ts new file mode 100644 index 00000000..e68366cd --- /dev/null +++ b/packages/workflow/tests/workspace-crash-recovery.test.ts @@ -0,0 +1,421 @@ +/** + * Tier WAC, continued — what survives a process, and what must not. + * + * The rest of the atomic Workspace suite ends its transactions in this + * process: it commits, fails, or is cancelled, and Effection tears the scope + * down. None of that is a crash. A crash leaves an open SQLite transaction + * with no one to roll it back, and whether the mutation, the immutable root, + * the current-root pointer and the routed journal row reappear afterwards is + * decided by SQLite's recovery rather than by any code here. + * + * So the proofs below are made of real processes. One is killed with SIGKILL + * while it holds all four of those writes uncommitted; another, which has + * never seen it, reopens the database and must find the baseline exactly. Two + * more commit a Workspace history and then reconstruct an older event's root + * from a cold start. + * + * The handshake is a line of JSON on standard output, never a sleep: the + * parent kills the child at a point the child has said it has reached. + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import process from "node:process"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { exec as execProcess } from "@effectionx/process"; +import { exec } from "@executablemd/runtime"; +import { guardDurableStream } from "@executablemd/durable-streams"; +import { call, type Operation, race, scoped, spawn, withResolvers } from "effection"; +import { setPrivateWorkspaceClock, transactWorkspaceRoots } from "../src/deno/workspace/private.ts"; +import { createRun, runPath, useStorageRoot, withStorage } from "./support/storage.ts"; +import { + BASELINE_CLOCK, + BASELINE_CONTENT, + BASELINE_EFFECT, + BASELINE_PATH, + count, + CRASH_CONTENT, + CRASH_EFFECT, + CRASH_PATH, + HISTORICAL_CONTENT, + HISTORICAL_PATH, + NESTED_CONTENT, + NESTED_PATH, + REVISE_CLOCK, + SEED_CLOCK, +} from "./support/workspace-process.ts"; + +const REPOSITORY = fileURLToPath(new URL("../../..", import.meta.url)); +const CRASH_CHILD = fileURLToPath(new URL("./support/workspace-crash-child.ts", import.meta.url)); +const RESTART_CHILD = fileURLToPath( + new URL("./support/workspace-restart-child.ts", import.meta.url), +); + +interface ReportedEvent { + readonly eventId: string; + readonly name?: string; +} + +/** + * Event names in order, with the run's terminating Close named too. + * + * `toEqual` treats a trailing `undefined` as absent, so a Close reported as an + * unnamed event would let a comparison of names pass without it. + */ +function names(events: readonly ReportedEvent[]): string[] { + return events.map((event) => event.name ?? "close"); +} + +interface ChildResult { + readonly code: number; + readonly out: string; + readonly err: string; +} + +/** One whole child process, reaped before the operation returns. */ +function* runChild(script: string, args: string[]): Operation { + const result = yield* exec({ + command: [process.execPath, "run", "--allow-all", "--frozen", script, ...args], + cwd: REPOSITORY, + }); + return { code: result.exitCode, out: result.stdout, err: result.stderr }; +} + +function announced(result: ChildResult): Record { + if (result.code !== 0) { + throw new Error(`the child exited ${result.code}: ${result.err}`); + } + return JSON.parse(result.out); +} + +/** + * Everything a second connection can see of the database right now. + * + * Raw SQLite rather than another DOFS wrapper: the question is only what has + * been committed, and a second authoritative wrapper on the same path is the + * thing the provider exists to prevent. + */ +function committed(path: string): Record { + const sqlite = new DatabaseSync(path, { readOnly: true }); + try { + sqlite.exec("PRAGMA busy_timeout = 10000"); + const total = (table: string): number => + count(sqlite.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get()?.["count"]); + return { + currentRoot: sqlite.prepare("SELECT current_root_id FROM workspace_state").get()?.[ + "current_root_id" + ], + roots: total("workspace_roots"), + manifestRefs: total("workspace_root_manifest_refs"), + blobRefs: total("workspace_root_blob_refs"), + manifests: total("vfs_manifests"), + blobs: total("vfs_blobs"), + names: sqlite + .prepare("SELECT name FROM vfs_dirents ORDER BY name") + .all() + .map((row) => row["name"]), + journal: sqlite + .prepare("SELECT event_id, workspace_root_id, record FROM journal_events ORDER BY sequence") + .all() + .map((row) => ({ + eventId: row["event_id"], + rootId: row["workspace_root_id"], + name: JSON.parse(String(row["record"])).description?.name, + })), + }; + } finally { + sqlite.close(); + } +} + +/** The retained roots an event never named, so restoration cannot borrow them. */ +function manifestsOf(path: string, rootId: string): string[] { + const sqlite = new DatabaseSync(path, { readOnly: true }); + try { + return sqlite + .prepare( + "SELECT hex(manifest_hash) AS hash FROM workspace_root_manifest_refs WHERE root_id = ?", + ) + .all(rootId) + .map((row) => String(row["hash"]).toLowerCase()); + } finally { + sqlite.close(); + } +} + +/** + * A committed, non-empty Workspace and one filtered journal event. + * + * Recorded as a run that has not finished, rather than as a completed + * `durableRun`: the crash process resumes this run, and a journal that already + * holds a Close is a run with nothing left to execute. So the mutation and its + * root are published through the private Workspace transaction, and the one + * event is appended through a secret gate — the same two writes a coordinated + * effect commits, in a shape the next process continues from. + */ +function* baseline(root: string, runId: string): Operation { + yield* withStorage(root, function* () { + const database = yield* createRun({ runId }); + yield* setPrivateWorkspaceClock(database, () => BASELINE_CLOCK); + const captured = yield* transactWorkspaceRoots(database, function* (workspace) { + yield* workspace.filesystem.mkdir("/kept", { mode: 0o750 }); + yield* workspace.filesystem.writeFile(BASELINE_PATH, BASELINE_CONTENT, 0o640); + yield* workspace.filesystem.writeFile(NESTED_PATH, NESTED_CONTENT, 0o600); + return yield* workspace.capture({ publish: true }); + }); + if (!captured.ok) { + throw captured.error; + } + const guarded = guardDurableStream(database.journal, function* () {}); + yield* guarded.append({ + type: "yield", + coroutineId: "root", + description: { type: "workspace-proof", name: BASELINE_EFFECT }, + result: { status: "ok", value: null }, + }); + }); +} + +describe("Tier WAC — Workspace effects across a process boundary", () => { + it("WAC24: a real SIGKILL before commit leaves the baseline and nothing else", function* () { + const root = yield* useStorageRoot(); + const runId = "crash-before-commit"; + yield* baseline(root, runId); + + // Taken after the provider scope closed, so nothing this process holds is + // keeping the database open when the child starts. + const path = runPath(root, runId); + const before = committed(path); + const baselineJournal = before["journal"] as (ReportedEvent & { rootId: string })[]; + expect(names(baselineJournal)).toEqual([BASELINE_EFFECT]); + expect(before["roots"]).toBe(2); + expect(baselineJournal[0].rootId).toBe(before["currentRoot"]); + + const during = yield* scoped(function* () { + const child = yield* execProcess(process.execPath, { + arguments: ["run", "--allow-all", "--frozen", CRASH_CHILD, "crash", root, runId], + cwd: REPOSITORY, + }); + const ready = withResolvers>(); + const decoder = new TextDecoder(); + let out = ""; + let err = ""; + yield* spawn(function* () { + const subscription = yield* child.stdout; + let next = yield* subscription.next(); + while (!next.done) { + out += decoder.decode(next.value, { stream: true }); + const end = out.indexOf("\n"); + if (end >= 0) { + ready.resolve(JSON.parse(out.slice(0, end))); + } + next = yield* subscription.next(); + } + }); + yield* spawn(function* () { + const subscription = yield* child.stderr; + let next = yield* subscription.next(); + while (!next.done) { + err += decoder.decode(next.value, { stream: true }); + next = yield* subscription.next(); + } + }); + + const announcement = yield* race([ + ready.operation, + call(function* (): Operation> { + const status = yield* child.join(); + throw new Error( + `the crash child ended before its handshake (${JSON.stringify(status)}): ${err}`, + ); + }), + ]); + + // The child holds its transaction open here, so what a second connection + // can see is the discriminating observation: it must still be exactly + // the baseline, with no crash mutation, root, pointer change or event. + const outside = committed(path); + process.kill(child.pid, "SIGKILL"); + const status = yield* child.join(); + return { announcement, outside, status }; + }); + + // The child died from the signal rather than from cleanup of its own. + expect(during.status.signal).toBe("SIGKILL"); + expect(during.status.code ?? null).toBeNull(); + + // Inside the crashed transaction all four writes existed. + expect(during.announcement).toEqual({ + ready: true, + content: CRASH_CONTENT, + currentRoot: expect.any(String), + retainedRoots: 3, + currentRootRetained: 1, + journalEventId: expect.any(String), + journalRootId: during.announcement["currentRoot"], + journalRows: baselineJournal.length + 1, + gateCalls: 1, + baselineExecutions: 0, + }); + expect(during.announcement["currentRoot"]).not.toBe(before["currentRoot"]); + + // None of them was visible to anyone else while the child was alive. The + // two readings are of one database at one moment, through the connection + // that made the writes and through a connection that did not. + expect(during.outside["currentRoot"]).not.toBe(during.announcement["currentRoot"]); + expect(during.outside["roots"]).toBe(2); + expect(during.outside).toEqual(before); + + // A different process, through a newly installed production provider, + // finds the baseline and only the baseline. + const inspected = announced(yield* runChild(CRASH_CHILD, ["inspect", root, runId])); + expect(inspected["currentRoot"]).toBe(before["currentRoot"]); + expect(inspected["tree"]).toEqual({ + "/baseline.txt": { + kind: "file", + mode: 0o640, + mtime: BASELINE_CLOCK, + size: BASELINE_CONTENT.length, + content: BASELINE_CONTENT, + }, + "/kept": { kind: "directory", mode: 0o750, mtime: BASELINE_CLOCK }, + [NESTED_PATH]: { + kind: "file", + mode: 0o600, + mtime: BASELINE_CLOCK, + size: NESTED_CONTENT.length, + content: NESTED_CONTENT, + }, + }); + const inspectedEvents = inspected["events"] as ReportedEvent[]; + expect(names(inspectedEvents)).toEqual([BASELINE_EFFECT]); + expect(inspectedEvents.map((event) => event.eventId)).toEqual( + baselineJournal.map((event) => event.eventId), + ); + expect(JSON.stringify(inspected["tree"])).not.toContain(CRASH_PATH); + + // Recovery restored the file itself, not only what the adapter reads. + expect(committed(path)).toEqual(before); + }); + + it("WAC25: a second process restores the committed Workspace and re-executes nothing", function* () { + const root = yield* useStorageRoot(); + const runId = "workspace-restart"; + const marker = join(root, "restart-marker.txt"); + writeFileSync(marker, ""); + + const first = announced(yield* runChild(RESTART_CHILD, ["commit", root, runId, marker])); + expect(readFileSync(marker, "utf8")).toBe("seed\nrevise\n"); + + const path = runPath(root, runId); + const stored = committed(path); + const second = announced(yield* runChild(RESTART_CHILD, ["read", root, runId, marker])); + + // Nothing ran twice: the second process restored both recorded results. + expect(readFileSync(marker, "utf8")).toBe("seed\nrevise\n"); + expect(second["currentRoot"]).toBe(first["currentRoot"]); + expect(second["tree"]).toEqual(first["tree"]); + expect(second["tree"]).toEqual({ + "/tree": { kind: "directory", mode: 0o750, mtime: SEED_CLOCK }, + "/renamed.txt": { + kind: "file", + mode: 0o600, + mtime: REVISE_CLOCK, + size: 11, + content: "later bytes", + }, + "/latest.txt": { kind: "symlink", target: "/renamed.txt" }, + "/later": { kind: "directory", mode: 0o700, mtime: REVISE_CLOCK }, + }); + expect(second["events"]).toEqual(first["events"]); + expect(names(second["events"] as ReportedEvent[])).toEqual(["seed", "revise", "close"]); + + // Each event keeps the root it was committed against, and the later one + // is the current root a cold process reads. + const journal = stored["journal"] as (ReportedEvent & { rootId: string })[]; + expect(names(journal)).toEqual(["seed", "revise", "close"]); + expect(journal.map((row) => row.eventId)).toEqual( + (first["events"] as ReportedEvent[]).map((event) => event.eventId), + ); + expect(journal[0].rootId).not.toBe(journal[1].rootId); + expect(journal[1].rootId).toBe(first["currentRoot"]); + expect(journal[2].rootId).toBe(first["currentRoot"]); + expect(committed(path)).toEqual(stored); + }); + + it("WAC26: an older event's root reconstructs exactly in a fresh process", function* () { + const root = yield* useStorageRoot(); + const runId = "workspace-history"; + const marker = join(root, "history-marker.txt"); + writeFileSync(marker, ""); + + const first = announced(yield* runChild(RESTART_CHILD, ["commit", root, runId, marker])); + const path = runPath(root, runId); + const stored = committed(path); + const journal = stored["journal"] as (ReportedEvent & { rootId: string })[]; + expect(names(journal)).toEqual(["seed", "revise", "close"]); + const historical = journal[0].rootId; + const later = journal[1].rootId; + expect(historical).not.toBe(later); + + // The retained content the older root needs is no longer reachable from + // the live frontier, so restoration has to come from what that root + // retains rather than from anything still live. + const retired = manifestsOf(path, historical).filter( + (hash) => !manifestsOf(path, later).includes(hash), + ); + expect(retired.length).toBeGreaterThan(0); + + const replayed = announced( + yield* runChild(RESTART_CHILD, ["restore", root, runId, marker, historical]), + ); + expect(readFileSync(marker, "utf8")).toBe("seed\nrevise\n"); + + const restored = replayed["restored"] as Record; + // The negative lookup happened first and was answered from the live + // frontier, so the successful read after restoration is the authoritative + // negative cache having been invalidated rather than never consulted. + expect(restored["absent"]).toBe("WorkspaceFsError"); + expect(restored["selectedRoot"]).toBe(historical); + // Two paths reading the same bytes are not yet one file. The canonical + // manifest names the hardlink group, so a rebuild that gave them separate + // inodes would resnapshot to a different identity than the one selected. + expect(restored["resnapshotRoot"]).toBe(historical); + expect(restored["currentRoot"]).toBe(historical); + expect(restored["tree"]).toEqual({ + "/tree": { kind: "directory", mode: 0o750, mtime: SEED_CLOCK }, + [HISTORICAL_PATH]: { + kind: "file", + mode: 0o640, + mtime: SEED_CLOCK, + size: HISTORICAL_CONTENT.length, + content: HISTORICAL_CONTENT, + }, + "/tree/hardlink.txt": { + kind: "file", + mode: 0o640, + mtime: SEED_CLOCK, + size: HISTORICAL_CONTENT.length, + content: HISTORICAL_CONTENT, + }, + "/tree/current.txt": { kind: "symlink", target: "file.txt" }, + }); + expect(restored["manifestHashes"]).toEqual(expect.arrayContaining(retired)); + + // The committed observations the same process made before restoring are + // still the ones the first process published. + const committedAgain = replayed["committed"] as Record; + expect(committedAgain["currentRoot"]).toBe(first["currentRoot"]); + expect(committedAgain["events"]).toEqual(first["events"]); + + // Restoration published a root that was already retained; it created no + // new one and lost no history. + const after = committed(path); + expect(after["roots"]).toBe(stored["roots"]); + expect(after["currentRoot"]).toBe(historical); + expect((after["journal"] as unknown[]).length).toBe(journal.length); + }); +}); diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 8f17b6a2..a1f35802 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -132,6 +132,12 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ "creates and imports a physical temporary copy of the Workspace modules through Deno.makeTempDir and the Deno module loader", issue: "https://github.com/taras/executable.md/issues/365", }, + { + path: "packages/workflow/tests/workspace-crash-recovery.test.ts", + reason: + "kills real Deno child processes with SIGKILL and reads the recovered node:sqlite WorkflowRun database they leave behind; the children run under the Deno executable and node:sqlite remains behind --experimental-sqlite on Node 22", + issue: "https://github.com/taras/executable.md/issues/365", + }, ]; /** diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 81c4616d..43250521 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -7060,6 +7060,9 @@ workspaces](./workflow-workspace-spec.md) §13. | WAC21 | Selection refusal | A substituted or foreign provider selection is rejected before transaction or savepoint work and leaves no mutation, retained root, pointer change, Yield or Close | | WAC22 | Minimum-priority publication isolation | A minimum-priority same-named handler cannot observe or acknowledge publication; the real mutation, retained root, current pointer and filtered Yield commit together | | WAC23 | Minimum-priority failure isolation | A minimum-priority same-named handler cannot replace infrastructure-failure activation; the exact first failure rolls back mutation, roots and journal and fences later work | +| WAC24 | Real host crash | A `SIGKILL` while the mutation, immutable root, current-root pointer and routed journal row are written and uncommitted leaves a second connection seeing only the baseline, and a fresh process recovers the baseline filesystem, root, retained counts and journal exactly | +| WAC25 | Committed restart | A second process reopens a run whose Workspace effects committed in a process that has ended, observes the same filesystem, current root, ordered events, event identities and event-to-root associations, and performs no recorded effect again | +| WAC26 | Historical reconstruction | A fresh process selects an older event's root through the adapter-private materializer, invalidates the authoritative negative resolution, rebuilds its exact topology, bytes, modes, hardlinks and symbolic links from that root's retained DOFS content, and resnapshots to the selected identity | ### Tier WTX — WorkflowRun savepoints and transaction authority diff --git a/specs/workflow-spec.md b/specs/workflow-spec.md index 93ed93c6..26046369 100644 --- a/specs/workflow-spec.md +++ b/specs/workflow-spec.md @@ -509,6 +509,21 @@ That identity fences later coordinators, executors and appends. An already active durability failure takes precedence. Cancellation at any phase publishes nothing. +Losing the host is not one of those phases. A killed process runs no cleanup, +so its caller-owned transaction stays open and SQLite's recovery decides the +outcome. The mutation, the immutable root, the current-root pointer and the +routed journal row are all written inside that transaction, and none of them is +exposed afterwards. While the process is alive, another connection sees only +the last committed state, so a crash publishes nothing that was not already +visible before it. + +A later process therefore opens the last committed state and nothing else: the +same live filesystem, the same current root, the same retained roots, manifest +and blob references, and the same ordered journal with the same event +identities and Workspace-root associations. Recorded effects replay rather than +execute again, and the private restoration materializer reconstructs any +retained root from that state without the process that wrote it. + The provider-neutral coordinator receives the failure-activation continuation needed for this boundary. The default live coordinator ignores it and preserves ordinary success/failure publication. Replay bypasses coordination, and only an diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index e046613f..6d9e2156 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -690,7 +690,10 @@ BEGIN COMMIT ``` -A crash commits all three or none. Nested child effects finish before the +A crash commits all three or none. A killed host runs no cleanup, so its +transaction stays open and SQLite recovery exposes neither the mutation, the +root, the pointer change nor the result; another connection never sees them +while that host is alive either. Nested child effects finish before the parent's effect transaction begins. Direct filesystem operations and declarative Git operations use this boundary. @@ -841,6 +844,14 @@ fail-stop fence for infrastructure failures. This foundation is not a public filesystem effect: ``, `API.Files`, workflow start/resume and history commands do not route to it in this slice. +That boundary holds across processes as well as within one. A host killed +between the mutation and the commit publishes nothing, and a process that +opens the database afterwards finds the last committed filesystem, current +root, retained roots and ordered event-to-root associations. Reopening replays +recorded effects instead of performing them, and the adapter-private +materializer reconstructs an older event's root from that root's retained DOFS +manifests and blobs. + SQLite is a host implementation detail. The CLI deliberately exposes no remote host-selection option yet, while retaining a control surface that can be delegated without changing the document language. From 6b265ffd5272a4e06c9880d45de0925e298a5fe6 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:40:06 -0400 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=94=92=20Sweep=20the=20whole=20shar?= =?UTF-8?q?ed=20coordination=20surface=20for=20host=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boundary check read two files and searched them for eight names, which answered a smaller question than the one it was named after: whether any shared module of the coordination surface names a host at all. It now globs that surface — the workflow package outside its Deno adapter, and the durable-stream coordination modules — and refuses storage and runtime implementation types, the adapter's private connection, savepoint and transaction-token identities, runtime detection, process globals, and imports that reach an adapter, a vendored source or a host process. Two things make the sweep answerable. It reads code rather than the file: these modules explain in their own prose that they name no host, and a substring search finds the explanation. And it proves it can fail before it runs, on a crossing and on a comment that only looks like one, because a glob that matched nothing would report the cleanest boundary of all. The list of what it must have found is written down, so a module that stops being matched fails instead of quietly leaving the surface. --- .../workflow/tests/workspace-effect.test.ts | 159 ++++++++++++++++-- specs/executable-mdx-spec.md | 2 +- 2 files changed, 144 insertions(+), 17 deletions(-) diff --git a/packages/workflow/tests/workspace-effect.test.ts b/packages/workflow/tests/workspace-effect.test.ts index b2572266..1457d44d 100644 --- a/packages/workflow/tests/workspace-effect.test.ts +++ b/packages/workflow/tests/workspace-effect.test.ts @@ -1,7 +1,10 @@ +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { createApi } from "@effectionx/context-api"; import { readTextFile } from "@effectionx/fs"; +import { glob } from "@executablemd/runtime"; import { type Operation, scoped } from "effection"; import { claimDurablePublicationIdentity, @@ -73,6 +76,95 @@ function successfulProvider(observe?: (authority: WorkspaceCoordinationAuthority }; } +const REPOSITORY = fileURLToPath(new URL("../../..", import.meta.url)); + +/** + * Every name that would mean a host reached the shared coordination surface. + * + * Storage and runtime implementation types, the adapter's private transaction + * identities, runtime detection, process globals, and any import that reaches + * an adapter, a vendored source or a host process. + */ +const FORBIDDEN = [ + "node:sqlite", + "node:process", + "node:child_process", + "DatabaseSync", + "SQLite", + "sqlite", + "Cloudflare", + "DOFS", + "dofs", + "savepoint", + "Savepoint", + "SAVEPOINT", + "RunConnection", + "WorkflowRunConnections", + "WorkflowRunTransactionToken", + "ConnectionGeneration", + "TransactionIdentity", + "Deno", + "Bun", + "globalThis", + "navigator", + "src/deno/", + "/deno.ts", + "vendor/", + "@effectionx/process", +]; + +/** + * Source with its comments removed. + * + * These modules describe in prose that they name no host, and a search of the + * whole file would find that description rather than a boundary crossing. + */ +function code(source: string): string { + let output = ""; + let index = 0; + while (index < source.length) { + const character = source[index]; + const following = source[index + 1]; + if (character === "/" && following === "/") { + while (index < source.length && source[index] !== "\n") { + index += 1; + } + continue; + } + if (character === "/" && following === "*") { + index += 2; + while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) { + index += 1; + } + index += 2; + continue; + } + if (character === '"' || character === "'" || character === "`") { + output += character; + index += 1; + while (index < source.length && source[index] !== character) { + if (source[index] === "\\") { + output += source[index]; + index += 1; + } + output += source[index]; + index += 1; + } + output += character; + index += 1; + continue; + } + output += character; + index += 1; + } + return output; +} + +function forbiddenNames(source: string): string[] { + const scanned = code(source); + return FORBIDDEN.filter((name) => scanned.includes(name)); +} + describe("Tier DLC — Workspace coordination selection", () => { it("DLC10: a missing Workspace provider fails before execution or publication", function* () { const stream = new InMemoryStream(); @@ -149,26 +241,61 @@ describe("Tier DLC — Workspace coordination selection", () => { expect(yieldEvents(stream.snapshot())).toHaveLength(1); }); - it("DLC13: shared Workspace coordination source stays runtime-neutral", function* () { - const sources = [ - yield* readTextFile(new URL("../src/workspace/api.ts", import.meta.url)), - yield* readTextFile(new URL("../src/workspace/effect.ts", import.meta.url)), - ]; - const forbidden = [ + it("DLC13: the whole shared coordination surface stays runtime-neutral", function* () { + // The scanner has to be able to fail, and it has to read code rather than + // prose: these modules explain in their own comments that they name no + // host, and a substring search would find the explanation. + expect(code(`const value = "DOFS"; // Cloudflare\n/* SQLite */`)).toBe( + `const value = "DOFS"; \n`, + ); + expect(forbiddenNames(`import { DatabaseSync } from "node:sqlite";`)).toEqual([ "node:sqlite", "DatabaseSync", - "SQLite", - "Cloudflare", - "DOFS", - "savepoint", - "ConnectionGeneration", - "TransactionIdentity", - ]; - for (const source of sources) { - for (const name of forbidden) { - expect(source.includes(name)).toBe(false); + "sqlite", + ]); + expect(forbiddenNames("// the Deno adapter owns DOFS and its savepoints")).toEqual([]); + + const found = (yield* glob({ + root: REPOSITORY, + patterns: [ + "packages/workflow/mod.ts", + "packages/workflow/src/**/*.ts", + "packages/durable-streams/*.ts", + ], + // Whole packages rather than named modules, so a coordination module + // added later is covered without this list being remembered. The single + // exception carries its reason: the HTTP stream is a client for a remote + // durable stream and reaches the platform's own `fetch`. + exclude: ["packages/workflow/src/deno/**", "packages/durable-streams/http-stream.ts"], + })) + .map((entry) => entry.path) + .sort(); + + // A pattern that matched nothing would report a clean boundary, so the + // surface every Workspace effect actually crosses is named here. + expect(found).toEqual( + expect.arrayContaining([ + "packages/durable-streams/durability.ts", + "packages/durable-streams/effect.ts", + "packages/durable-streams/guard.ts", + "packages/durable-streams/live-coordinator.ts", + "packages/durable-streams/types.ts", + "packages/workflow/mod.ts", + "packages/workflow/src/storage/api.ts", + "packages/workflow/src/workspace/api.ts", + "packages/workflow/src/workspace/effect.ts", + ]), + ); + expect(found.some((path) => path.includes("/src/deno/"))).toBe(false); + + const crossings: Record = {}; + for (const path of found) { + const names = forbiddenNames(yield* readTextFile(join(REPOSITORY, path))); + if (names.length > 0) { + crossings[path] = names; } } + expect(crossings).toEqual({}); }); it("DLC15: live Workspace invocation authority is one-shot", function* () { diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 43250521..9a9bea11 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -7018,7 +7018,7 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6. | DLC10 | Fail-closed Workspace | A missing Workspace provider activates fail-stop before execution or publication and persists no Yield or Close | | DLC11 | Workspace isolation | Replaceable context carries only provider routing; the selected provider directly invokes the credentialed execution-owned capability, while unrelated durable operations stay on the default coordinator | | DLC12 | Workspace replay | Replayed Workspace operations require no live provider | -| DLC13 | Runtime-neutral boundary | Shared Workspace coordination source exposes no runtime or storage implementation type | +| DLC13 | Runtime-neutral boundary | No module of the shared coordination surface names a runtime, storage, connection, savepoint, transaction-token or process-global type, or reaches an adapter, vendored source or host process, outside its own prose | | DLC14 | Provider infrastructure failure | A selected coordinator activates one first failure by identity and fences later execution and publication | | DLC15 | One-shot Workspace invocation | A provider can use the execution-owned invocation authority only during its original call; retained execution, publication and failure operations are refused after completion | | DLC16 | Loaded-copy Workspace selection | A provider installed by one physical package copy coordinates one operation created by another copy exactly once without sharing authority through context or a module registry; substituted selection and retained authority remain fail-closed | From a5592433e230927e1af016c97fd22507873f205e Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:47:28 -0400 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=94=92=20Refuse=20host=20modules=20?= =?UTF-8?q?by=20shape,=20not=20by=20a=20list=20of=20the=20ones=20noticed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DLC13 claimed the shared coordination surface names no host, and its own glob already covered a module that imported one: packages/workflow/src/run.ts import { randomUUID } from "node:crypto"; The detector listed `node:sqlite`, `node:process` and `node:child_process` — the host modules somebody had thought of — so the import that was actually there went unreported and a green run confirmed the blind spot instead of the boundary. A module specifier only one host can resolve is now recognized by its shape: the `node:`, `bun:`, `deno:` and `cloudflare:` schemes, an adapter path, a vendored source, a host process. Specifiers are read from the import forms rather than matched as text, so `node:crypto` in a comment or a string is prose and `import … from "node:crypto"` is a crossing — both asserted, along with the `node:crypto` case that this test used to miss. Shared code allocates a run id through Web Crypto, which every supported runtime resolves, the way `createFingerprinter` already draws its key. The id is still allocated with cryptographic randomness; only the module that provides it stops naming a host. --- packages/workflow/src/run.ts | 5 +- .../workflow/tests/workspace-effect.test.ts | 67 ++++++++++++++++--- specs/executable-mdx-spec.md | 2 +- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/workflow/src/run.ts b/packages/workflow/src/run.ts index ef37c9e6..caef5dcb 100644 --- a/packages/workflow/src/run.ts +++ b/packages/workflow/src/run.ts @@ -28,7 +28,6 @@ import { createContext } from "effection"; import type { Context, Operation } from "effection"; -import { randomUUID } from "node:crypto"; import { createDurableOperation } from "@executablemd/durable-streams"; import { ReplayGuard } from "@executablemd/durable-streams"; import type { EffectDescription, Json, Workflow, Yield } from "@executablemd/durable-streams"; @@ -77,7 +76,9 @@ function* record(description: EffectDescription, base: string): Workflow scanned.includes(name)); + const crossings = FORBIDDEN.filter((name) => scanned.includes(name)); + for (const specifier of specifiers(scanned)) { + if (hostModule(specifier) && !crossings.includes(specifier)) { + crossings.push(specifier); + } + } + return crossings; } describe("Tier DLC — Workspace coordination selection", () => { @@ -249,12 +280,26 @@ describe("Tier DLC — Workspace coordination selection", () => { `const value = "DOFS"; \n`, ); expect(forbiddenNames(`import { DatabaseSync } from "node:sqlite";`)).toEqual([ - "node:sqlite", "DatabaseSync", "sqlite", + "node:sqlite", ]); expect(forbiddenNames("// the Deno adapter owns DOFS and its savepoints")).toEqual([]); + // A host module is a crossing by its shape, not because someone listed it. + // `node:crypto` names nothing else on this list, and shared source did + // import it while an earlier version of this test reported a clean + // boundary. + expect(forbiddenNames(`import { randomUUID } from "node:crypto";`)).toEqual(["node:crypto"]); + expect(forbiddenNames(`export { x } from "node:os";`)).toEqual(["node:os"]); + expect(forbiddenNames(`const m = await import("bun:sqlite");`)).toEqual([ + "sqlite", + "bun:sqlite", + ]); + expect(forbiddenNames(`import "../deno.ts";`)).toEqual(["../deno.ts"]); + expect(forbiddenNames("// run ids used to come from node:crypto")).toEqual([]); + expect(forbiddenNames(`const note = "node:crypto";`)).toEqual([]); + const found = (yield* glob({ root: REPOSITORY, patterns: [ diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 9a9bea11..bb7d0cf4 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -7018,7 +7018,7 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6. | DLC10 | Fail-closed Workspace | A missing Workspace provider activates fail-stop before execution or publication and persists no Yield or Close | | DLC11 | Workspace isolation | Replaceable context carries only provider routing; the selected provider directly invokes the credentialed execution-owned capability, while unrelated durable operations stay on the default coordinator | | DLC12 | Workspace replay | Replayed Workspace operations require no live provider | -| DLC13 | Runtime-neutral boundary | No module of the shared coordination surface names a runtime, storage, connection, savepoint, transaction-token or process-global type, or reaches an adapter, vendored source or host process, outside its own prose | +| DLC13 | Runtime-neutral boundary | No module of the shared coordination surface names a runtime, storage, connection, savepoint, transaction-token or process-global type outside its own prose, and none imports a specifier only one host can resolve — `node:`, `bun:`, `deno:` and `cloudflare:` modules, an adapter, a vendored source or a host process — recognized by shape rather than by an enumerated list | | DLC14 | Provider infrastructure failure | A selected coordinator activates one first failure by identity and fences later execution and publication | | DLC15 | One-shot Workspace invocation | A provider can use the execution-owned invocation authority only during its original call; retained execution, publication and failure operations are refused after completion | | DLC16 | Loaded-copy Workspace selection | A provider installed by one physical package copy coordinates one operation created by another copy exactly once without sharing authority through context or a module registry; substituted selection and retained authority remain fail-closed | From dadc0f7019ea30f09c61b270fff68f8d6864281b Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:47:29 -0400 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=93=9D=20Say=20what=20a=20killed=20?= =?UTF-8?q?process=20actually=20leaves=20behind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new recovery prose said a killed process "leaves its transaction open", which describes something that cannot happen: the process is gone, so the operating system closes its connection and releases every lock it held. What is left is a database the next connection recovers, not a transaction still running somewhere. It also timed the guarantee to the writer's life — "while the process is alive" — when what a second connection actually observes is the last committed state for as long as the writer's transaction is uncommitted. That is the ordinary isolation boundary rather than a rule that only applies to crashes, and saying it the other way invites a reader to expect a special case. --- architecture.md | 19 ++++++++++++------- specs/workflow-spec.md | 18 +++++++++++------- specs/workflow-workspace-spec.md | 10 ++++++---- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/architecture.md b/architecture.md index 71f5d708..9d56ce73 100644 --- a/architecture.md +++ b/architecture.md @@ -551,13 +551,18 @@ first infrastructure failure by identity; the default coordinator does not use it and keeps its existing behavior. Losing the host is not one of those outcomes, because nothing runs to handle -it. A killed process leaves its transaction open, and SQLite's own recovery -decides what the database holds next: the mutation, the immutable root, the -current-root pointer and the routed event are all written by then, and none of -them survives. While that process is alive another connection sees only the -last committed state, so the boundary is the same one every other reader -observes rather than a second rule for crashes. What a later process finds is -that committed state — the filesystem, the current root, the retained roots and +it. No cleanup, commit or rollback happens after the process dies: the +operating system closes its connection and releases the locks it held, and the +interrupted transaction is left for the next connection to recover. The +mutation, the immutable root, the current-root pointer and the routed event +have all been written by then, and recovery exposes the last committed state +and none of them. + +That is the same boundary every other reader already observes rather than a +second rule for crashes. A second connection sees the last committed state for +as long as the writer's transaction is uncommitted, so a crash publishes +nothing that was not visible before it. What a later process finds is that +committed state — the filesystem, the current root, the retained roots and references, and the ordered journal with its event identities and root associations — from which it performs no recorded effect again and can materialize any retained root. diff --git a/specs/workflow-spec.md b/specs/workflow-spec.md index 26046369..f72d75bf 100644 --- a/specs/workflow-spec.md +++ b/specs/workflow-spec.md @@ -509,13 +509,17 @@ That identity fences later coordinators, executors and appends. An already active durability failure takes precedence. Cancellation at any phase publishes nothing. -Losing the host is not one of those phases. A killed process runs no cleanup, -so its caller-owned transaction stays open and SQLite's recovery decides the -outcome. The mutation, the immutable root, the current-root pointer and the -routed journal row are all written inside that transaction, and none of them is -exposed afterwards. While the process is alive, another connection sees only -the last committed state, so a crash publishes nothing that was not already -visible before it. +Losing the host is not one of those phases. Nothing runs after the process +dies: no cleanup, no commit, no rollback. The operating system closes the +connection and releases the locks it held, and the next connection to open the +database recovers it. The mutation, the immutable root, the current-root +pointer and the routed journal row have all been written inside the +caller-owned transaction by then; recovery exposes the last committed state and +none of them. + +A second connection sees that same last committed state for as long as the +writer's transaction is uncommitted, so a crash publishes nothing that was not +already visible before it. A later process therefore opens the last committed state and nothing else: the same live filesystem, the same current root, the same retained roots, manifest diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index 6d9e2156..d5ece6cb 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -690,10 +690,12 @@ BEGIN COMMIT ``` -A crash commits all three or none. A killed host runs no cleanup, so its -transaction stays open and SQLite recovery exposes neither the mutation, the -root, the pointer change nor the result; another connection never sees them -while that host is alive either. Nested child effects finish before the +A crash commits all three or none. Nothing runs after a killed host dies — no +cleanup, no commit, no rollback — so the operating system closes its connection +and releases its locks, and the next connection recovers the database to the +last committed state, exposing neither the mutation, the root, the pointer +change nor the result. Another connection never sees them while that +transaction is uncommitted either. Nested child effects finish before the parent's effect transaction begins. Direct filesystem operations and declarative Git operations use this boundary. From ac76ff80ff3b0d4b6a1c66efc9f4948ac78c217c Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:05:56 -0400 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=94=92=20Decide=20module=20loading?= =?UTF-8?q?=20by=20parsing=20it,=20not=20by=20matching=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DLC13 claimed a fail-closed runtime-neutral boundary and asked a regular expression to find it. Module loading is not a pattern, so four different things went the wrong way at once: await import(`node:crypto`) // a template literal it never matched await import("node:crypto") // an escape it never decoded const t = "node:crypto"; await import(t) // a destination it could not read const note = `import "node:crypto"`; // prose it rejected as an import The scan now reads parsed syntax: static imports and re-exports, `import type`, type-position `import()`, dynamic `import()`, `import =` and `require()`. The parser hands back decoded specifiers, so an escape and a no-substitution template are the module they name, and characters that only look like an import — in a comment, a string, a template — load nothing and are reported as nothing. A specifier this surface computes is refused rather than skipped. Nothing can show it is not a host module, and a boundary that admits what it cannot read is not a boundary. The scan also refuses to be quiet about its own failure: a module whose text imports something must yield a specifier, so a parse that stopped working reports every file as clean exactly once and then fails. Host schemes, adapter paths, vendored sources and host processes are still classified by shape rather than by an enumerated list. --- deno.json | 1 + deno.lock | 1 + .../workflow/tests/workspace-effect.test.ts | 109 +++++++++++++++--- specs/executable-mdx-spec.md | 2 +- 4 files changed, 98 insertions(+), 15 deletions(-) diff --git a/deno.json b/deno.json index 7854ef4b..bf91d5d8 100644 --- a/deno.json +++ b/deno.json @@ -42,6 +42,7 @@ "@std/testing/bdd": "jsr:@std/testing@^1/bdd", "oxlint": "npm:oxlint@1.74.0", "oxlint-tsgolint": "npm:oxlint-tsgolint@0.25.0", + "typescript": "npm:typescript@^5.0.0", "unist-util-select": "npm:unist-util-select@^5", "zod": "npm:zod@^4.3.6", "mdast-util-to-string": "npm:mdast-util-to-string@^4" diff --git a/deno.lock b/deno.lock index d822f1d8..7d09e342 100644 --- a/deno.lock +++ b/deno.lock @@ -3974,6 +3974,7 @@ "npm:mdast-util-to-string@4", "npm:oxlint-tsgolint@0.25.0", "npm:oxlint@1.74.0", + "npm:typescript@5", "npm:unist-util-select@5", "npm:zod@^4.3.6" ], diff --git a/packages/workflow/tests/workspace-effect.test.ts b/packages/workflow/tests/workspace-effect.test.ts index 10541ceb..8efd1503 100644 --- a/packages/workflow/tests/workspace-effect.test.ts +++ b/packages/workflow/tests/workspace-effect.test.ts @@ -5,6 +5,7 @@ import { expect } from "@executablemd/test-support/expect"; import { createApi } from "@effectionx/context-api"; import { readTextFile } from "@effectionx/fs"; import { glob } from "@executablemd/runtime"; +import ts from "typescript"; import { type Operation, scoped } from "effection"; import { claimDurablePublicationIdentity, @@ -169,27 +170,79 @@ function code(source: string): string { return output; } -/** Every module this source imports, however the import is written. */ -function specifiers(source: string): string[] { +/** + * What a module this file loads cannot be shown to be. + * + * A specifier the file computes names whatever it is handed, so no inspection + * of this surface can say it is not a host module. It is refused rather than + * skipped: a boundary that admits what it cannot read is not a boundary. + */ +const COMPUTED = "a computed module specifier"; + +/** + * Every module this source loads, read from the syntax rather than the text. + * + * Parsed, because module loading is not a pattern: a specifier can be a + * template literal, can escape its own characters, can be an expression, and + * the same characters can appear in a string that loads nothing. Each of those + * is a different answer, and only a parse tells them apart. + */ +function moduleSpecifiers(source: string): string[] { + const parsed = ts.createSourceFile( + "scanned.ts", + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); const found: string[] = []; - for (const pattern of [ - /\bfrom\s*["']([^"']+)["']/g, - /\bimport\s*\(\s*["']([^"']+)["']/g, - /\bimport\s*["']([^"']+)["']/g, - /\brequire\s*\(\s*["']([^"']+)["']/g, - ]) { - for (const match of source.matchAll(pattern)) { - found.push(match[1]); + + function record(node: ts.Node | undefined): void { + if ( + node !== undefined && + (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) + ) { + // The parser has already decoded escapes, so `node:crypto` and + // `node:crypto` arrive here as the same specifier. + found.push(node.text); + return; } + found.push(COMPUTED); } + + function visit(node: ts.Node): void { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + if (node.moduleSpecifier !== undefined) { + record(node.moduleSpecifier); + } + } else if (ts.isImportEqualsDeclaration(node)) { + if (ts.isExternalModuleReference(node.moduleReference)) { + record(node.moduleReference.expression); + } + } else if (ts.isImportTypeNode(node)) { + record(ts.isLiteralTypeNode(node.argument) ? node.argument.literal : node.argument); + } else if (ts.isCallExpression(node)) { + const callee = node.expression; + if ( + callee.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(callee) && callee.text === "require") + ) { + record(node.arguments[0]); + } + } + ts.forEachChild(node, visit); + } + + visit(parsed); return found; } function forbiddenNames(source: string): string[] { const scanned = code(source); const crossings = FORBIDDEN.filter((name) => scanned.includes(name)); - for (const specifier of specifiers(scanned)) { - if (hostModule(specifier) && !crossings.includes(specifier)) { + for (const specifier of moduleSpecifiers(source)) { + const refused = specifier === COMPUTED || hostModule(specifier); + if (refused && !crossings.includes(specifier)) { crossings.push(specifier); } } @@ -292,13 +345,32 @@ describe("Tier DLC — Workspace coordination selection", () => { // boundary. expect(forbiddenNames(`import { randomUUID } from "node:crypto";`)).toEqual(["node:crypto"]); expect(forbiddenNames(`export { x } from "node:os";`)).toEqual(["node:os"]); + expect(forbiddenNames(`import "../deno.ts";`)).toEqual(["../deno.ts"]); + expect(forbiddenNames(`import type { X } from "node:fs";`)).toEqual(["node:fs"]); + expect(forbiddenNames(`type X = import("node:fs").Stats;`)).toEqual(["node:fs"]); + expect(forbiddenNames(`import fs = require("node:fs");`)).toEqual(["node:fs"]); expect(forbiddenNames(`const m = await import("bun:sqlite");`)).toEqual([ "sqlite", "bun:sqlite", ]); - expect(forbiddenNames(`import "../deno.ts";`)).toEqual(["../deno.ts"]); + + // How the specifier is spelled is not how it is found: a template literal + // and an escaped character load the same module as the plain string. + expect(forbiddenNames("const m = await import(`node:crypto`);")).toEqual(["node:crypto"]); + expect(forbiddenNames(`const m = await import("node\\u003acrypto");`)).toEqual(["node:crypto"]); + + // A destination this surface computes cannot be shown not to be a host + // module, so it is refused rather than skipped. + expect(forbiddenNames(`const target = "node:crypto";\nawait import(target);`)).toEqual([ + COMPUTED, + ]); + expect(forbiddenNames("await import(`${scheme}:crypto`);")).toEqual([COMPUTED]); + + // And text that only looks like module loading loads nothing. expect(forbiddenNames("// run ids used to come from node:crypto")).toEqual([]); expect(forbiddenNames(`const note = "node:crypto";`)).toEqual([]); + expect(forbiddenNames('const note = `import "node:crypto"`;')).toEqual([]); + expect(forbiddenNames(`const note = 'export { x } from "node:os"';`)).toEqual([]); const found = (yield* glob({ root: REPOSITORY, @@ -334,12 +406,21 @@ describe("Tier DLC — Workspace coordination selection", () => { expect(found.some((path) => path.includes("/src/deno/"))).toBe(false); const crossings: Record = {}; + const unread: string[] = []; for (const path of found) { - const names = forbiddenNames(yield* readTextFile(join(REPOSITORY, path))); + const source = yield* readTextFile(join(REPOSITORY, path)); + // A parse that failed would report every file as clean. A module whose + // text imports something must yield a specifier, or this scan is reading + // nothing and saying so approvingly. + if (/^import\s/m.test(source) && moduleSpecifiers(source).length === 0) { + unread.push(path); + } + const names = forbiddenNames(source); if (names.length > 0) { crossings[path] = names; } } + expect(unread).toEqual([]); expect(crossings).toEqual({}); }); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index bb7d0cf4..9ed5e372 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -7018,7 +7018,7 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6. | DLC10 | Fail-closed Workspace | A missing Workspace provider activates fail-stop before execution or publication and persists no Yield or Close | | DLC11 | Workspace isolation | Replaceable context carries only provider routing; the selected provider directly invokes the credentialed execution-owned capability, while unrelated durable operations stay on the default coordinator | | DLC12 | Workspace replay | Replayed Workspace operations require no live provider | -| DLC13 | Runtime-neutral boundary | No module of the shared coordination surface names a runtime, storage, connection, savepoint, transaction-token or process-global type outside its own prose, and none imports a specifier only one host can resolve — `node:`, `bun:`, `deno:` and `cloudflare:` modules, an adapter, a vendored source or a host process — recognized by shape rather than by an enumerated list | +| DLC13 | Runtime-neutral boundary | No module of the shared coordination surface names a runtime, storage, connection, savepoint, transaction-token or process-global type outside its own prose, and none loads a module only one host can resolve. Module loading is read from parsed syntax — static imports and re-exports, `import type`, type-position `import()`, dynamic `import()`, `import =` and `require()` — with quoted and no-substitution template specifiers decoded, so comments and strings that merely contain import syntax load nothing. A destination the surface computes cannot be shown not to be a host module and is refused. Host schemes (`node:`, `bun:`, `deno:`, `cloudflare:`), adapter paths, vendored sources and host process modules are classified by shape rather than by an enumerated list | | DLC14 | Provider infrastructure failure | A selected coordinator activates one first failure by identity and fences later execution and publication | | DLC15 | One-shot Workspace invocation | A provider can use the execution-owned invocation authority only during its original call; retained execution, publication and failure operations are refused after completion | | DLC16 | Loaded-copy Workspace selection | A provider installed by one physical package copy coordinates one operation created by another copy exactly once without sharing authority through context or a module registry; substituted selection and retained authority remain fail-closed | From 56d7958fd0c5c4b35d713dc5693501cd6225c228 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:30:43 -0400 Subject: [PATCH 06/11] =?UTF-8?q?=F0=9F=94=92=20Recognize=20host=20globals?= =?UTF-8?q?=20and=20every=20runtime's=20adapter,=20not=20just=20Deno's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DLC13 said it rejected process globals and runtime adapter imports by shape, and did neither: const pid = process.pid; // reported nothing import "./node.ts"; // reported nothing The globals were a substring list, and `process` could not go on it: `Bun` is inside `Bundle` and `Deno` inside `Denominator`, so text matching would have started rejecting words for their spelling. They are read from the parse instead — an identifier that refers to the binding it names, so `preprocessor`, `x.process` and `{ process: 1 }` are not uses of a host and `crypto` is a standard rather than a host at all. The adapter rule knew `/deno/` and `/deno.ts`, which is the adapter that happens to exist. Code Rule 12 names four of them for the CLI alone, so any whole path segment naming a runtime this repository builds an entry point for is one. Segments are compared whole for the same reason the globals are parsed: `nodes/`, `bundle.ts` and `vendors/` contain a runtime's name without being one. Both recognitions are held by mutation. Deleting the global scan fails on `process.pid`; narrowing the adapter rule back to Deno fails on `./node.ts`. The crash suite's and crash child's opening comments said a killed process leaves SQLite holding a transaction. The transaction is open at the kill point, `SIGKILL` runs no application cleanup, the operating system closes the connection and releases its locks, and the next connection recovers the interrupted transaction to the last committed state — which is what they now say, matching architecture.md. --- .../tests/support/workspace-crash-child.ts | 10 +- .../tests/workspace-crash-recovery.test.ts | 11 +- .../workflow/tests/workspace-effect.test.ts | 157 +++++++++++++++++- specs/executable-mdx-spec.md | 2 +- 4 files changed, 163 insertions(+), 17 deletions(-) diff --git a/packages/workflow/tests/support/workspace-crash-child.ts b/packages/workflow/tests/support/workspace-crash-child.ts index c82d1695..ce08376b 100644 --- a/packages/workflow/tests/support/workspace-crash-child.ts +++ b/packages/workflow/tests/support/workspace-crash-child.ts @@ -2,10 +2,12 @@ * The two processes a crash proof needs, and neither of them is the test. * * A cancelled task, a thrown error and a closed scope all unwind. A killed - * process does not: SQLite is left holding a transaction nobody will finish, - * and recovery is the database's own. Proving that recovery therefore takes a - * process the test can kill without warning, and a second one that has never - * seen the first — which is what these two modes are. + * process does not: its transaction is open when the signal arrives, no + * application cleanup runs, and the operating system closes the connection and + * releases its locks. The next connection to open the database recovers that + * interrupted transaction to the last committed state. Proving that therefore + * takes a process the test can kill without warning, and a second one that has + * never seen the first — which is what these two modes are. * * ```sh * deno run -A workspace-crash-child.ts crash diff --git a/packages/workflow/tests/workspace-crash-recovery.test.ts b/packages/workflow/tests/workspace-crash-recovery.test.ts index e68366cd..0d9c2a2a 100644 --- a/packages/workflow/tests/workspace-crash-recovery.test.ts +++ b/packages/workflow/tests/workspace-crash-recovery.test.ts @@ -3,10 +3,13 @@ * * The rest of the atomic Workspace suite ends its transactions in this * process: it commits, fails, or is cancelled, and Effection tears the scope - * down. None of that is a crash. A crash leaves an open SQLite transaction - * with no one to roll it back, and whether the mutation, the immutable root, - * the current-root pointer and the routed journal row reappear afterwards is - * decided by SQLite's recovery rather than by any code here. + * down. None of that is a crash. At the kill point the transaction is open; + * `SIGKILL` then runs no application cleanup at all, so nothing commits and + * nothing rolls back. The operating system closes the connection and releases + * its locks, and the next connection to open the database recovers the + * interrupted transaction to the last committed state. Whether the mutation, + * the immutable root, the current-root pointer and the routed journal row + * reappear is decided there rather than by any code here. * * So the proofs below are made of real processes. One is killed with SIGKILL * while it holds all four of those writes uncommitted; another, which has diff --git a/packages/workflow/tests/workspace-effect.test.ts b/packages/workflow/tests/workspace-effect.test.ts index 8efd1503..db9e5db8 100644 --- a/packages/workflow/tests/workspace-effect.test.ts +++ b/packages/workflow/tests/workspace-effect.test.ts @@ -80,10 +80,11 @@ function successfulProvider(observe?: (authority: WorkspaceCoordinationAuthority const REPOSITORY = fileURLToPath(new URL("../../..", import.meta.url)); /** - * Every name that would mean a host reached the shared coordination surface. + * Storage and adapter type names that mean a host reached this surface. * - * Storage and runtime implementation types, the adapter's private transaction - * identities, runtime detection, and process globals. + * Distinctive enough to be read as text. Runtime globals are not here — `Bun` + * is inside `Bundle` and `Deno` inside `Denominator`, so those are recognized + * as identifiers instead. */ const FORBIDDEN = [ "DatabaseSync", @@ -100,10 +101,34 @@ const FORBIDDEN = [ "WorkflowRunTransactionToken", "ConnectionGeneration", "TransactionIdentity", +]; + +/** + * The names this repository gives a runtime-specific entry point. + * + * Code Rule 12 puts host behavior behind runtime-named modules — + * `packages/cli/src/{deno,node,bun,compiled}.ts` are the CLI's — so the name of + * the module is what says a host owns it. `deno` is not the only one, and an + * adapter rule that knows only `deno` is a rule about the adapter someone + * happened to write first. + */ +const RUNTIMES = ["deno", "node", "bun", "compiled", "cloudflare", "workerd"]; + +/** + * Globals only one host provides. + * + * `crypto`, `TextEncoder` and the rest of the cross-runtime Web surface are + * not here: naming a standard is not naming a host. + */ +const HOST_GLOBALS = [ + "process", "Deno", "Bun", + "Buffer", "globalThis", "navigator", + "__dirname", + "__filename", ]; /** @@ -112,14 +137,24 @@ const FORBIDDEN = [ * Named by shape rather than one at a time: a list of the host modules anyone * thought of is a list of the ones that had already been noticed, and the * import that crosses this boundary next is the one nobody wrote down. + * + * Segments are compared whole. `nodes/`, `bundle.ts` and `vendors/` contain a + * runtime's name without being one, and rejecting them would make the rule + * about spelling rather than about hosts. */ function hostModule(specifier: string): boolean { + if (/^(node|bun|deno|cloudflare|workerd):/.test(specifier)) { + return true; + } + if (specifier === "@effectionx/process") { + return true; + } + const segments = specifier.split("/"); + const last = segments[segments.length - 1].replace(/\.[cm]?[jt]sx?$/, ""); return ( - /^(node|bun|deno|cloudflare):/.test(specifier) || - specifier.includes("/deno/") || - specifier.endsWith("/deno.ts") || - specifier.includes("vendor/") || - specifier === "@effectionx/process" + segments.includes("vendor") || + segments.some((segment) => RUNTIMES.includes(segment)) || + RUNTIMES.includes(last) ); } @@ -237,9 +272,79 @@ function moduleSpecifiers(source: string): string[] { return found; } +/** + * Host globals this source actually reads. + * + * A reference, not an occurrence: `preprocessor` is not `process`, `foo.process` + * names a property of something else, and `{ process: 1 }` declares a key. Only + * a parse can tell a use of the global from a word that contains its name. + */ +function hostGlobals(source: string): string[] { + const parsed = ts.createSourceFile( + "scanned.ts", + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const found: string[] = []; + + function visit(node: ts.Node): void { + if (ts.isIdentifier(node) && HOST_GLOBALS.includes(node.text) && names(node)) { + if (!found.includes(node.text)) { + found.push(node.text); + } + } + ts.forEachChild(node, visit); + } + + visit(parsed); + return found; +} + +/** Whether this identifier reads the binding it spells, rather than labelling something. */ +function names(node: ts.Identifier): boolean { + const parent = node.parent; + if (parent === undefined) { + return true; + } + if (ts.isPropertyAccessExpression(parent)) { + return parent.name !== node; + } + if (ts.isQualifiedName(parent)) { + return parent.right !== node; + } + if ( + ts.isPropertyAssignment(parent) || + ts.isPropertySignature(parent) || + ts.isPropertyDeclaration(parent) || + ts.isMethodDeclaration(parent) || + ts.isMethodSignature(parent) || + ts.isVariableDeclaration(parent) || + ts.isParameter(parent) || + ts.isBindingElement(parent) || + ts.isFunctionDeclaration(parent) || + ts.isClassDeclaration(parent) || + ts.isInterfaceDeclaration(parent) || + ts.isTypeAliasDeclaration(parent) || + ts.isImportSpecifier(parent) || + ts.isExportSpecifier(parent) || + ts.isImportClause(parent) || + ts.isNamespaceImport(parent) + ) { + return parent.name !== node; + } + return true; +} + function forbiddenNames(source: string): string[] { const scanned = code(source); const crossings = FORBIDDEN.filter((name) => scanned.includes(name)); + for (const global of hostGlobals(source)) { + if (!crossings.includes(global)) { + crossings.push(global); + } + } for (const specifier of moduleSpecifiers(source)) { const refused = specifier === COMPUTED || hostModule(specifier); if (refused && !crossings.includes(specifier)) { @@ -371,6 +476,42 @@ describe("Tier DLC — Workspace coordination selection", () => { expect(forbiddenNames(`const note = "node:crypto";`)).toEqual([]); expect(forbiddenNames('const note = `import "node:crypto"`;')).toEqual([]); expect(forbiddenNames(`const note = 'export { x } from "node:os"';`)).toEqual([]); + expect(forbiddenNames(`const hint = 'import from "@executablemd/workflow/deno"';`)).toEqual([]); + + // A host global is a crossing wherever the module came from. + expect(forbiddenNames("const pid = process.pid;")).toEqual(["process"]); + expect(forbiddenNames("export const p = process;")).toEqual(["process"]); + expect(forbiddenNames("const { env } = process;")).toEqual(["process"]); + expect(forbiddenNames("const home = Deno.cwd();")).toEqual(["Deno"]); + expect(forbiddenNames("const v = Bun.version;")).toEqual(["Bun"]); + expect(forbiddenNames("const b = Buffer.from([]);")).toEqual(["Buffer"]); + expect(forbiddenNames("const here = __dirname;")).toEqual(["__dirname"]); + expect(forbiddenNames("type F = Deno.FsFile;")).toEqual(["Deno"]); + + // Every runtime this repository names an entry point after, not only the + // one whose adapter exists today. + expect(forbiddenNames(`import "./node.ts";`)).toEqual(["./node.ts"]); + expect(forbiddenNames(`import "./bun.ts";`)).toEqual(["./bun.ts"]); + expect(forbiddenNames(`import "./compiled.ts";`)).toEqual(["./compiled.ts"]); + expect(forbiddenNames(`import "../src/node/journal.ts";`)).toEqual(["../src/node/journal.ts"]); + expect(forbiddenNames(`import "../src/cloudflare/storage.ts";`)).toEqual([ + "../src/cloudflare/storage.ts", + ]); + expect(forbiddenNames(`import "../../vendor/store/mod.ts";`)).toEqual([ + "../../vendor/store/mod.ts", + ]); + + // Positive controls: a word is not a host because a host's name is inside + // it, and neither is a path segment. + expect(forbiddenNames("const preprocessor = 1;")).toEqual([]); + expect(forbiddenNames("const bundle = 1;\nclass Denominator {}")).toEqual([]); + expect(forbiddenNames("const x = { process: 1 };\nconst y = x.process;")).toEqual([]); + expect(forbiddenNames("queue.process(job);")).toEqual([]); + expect(forbiddenNames(`import "./nodes.ts";`)).toEqual([]); + expect(forbiddenNames(`import "./bundle.ts";`)).toEqual([]); + expect(forbiddenNames(`import "../vendors/helper.ts";`)).toEqual([]); + expect(forbiddenNames(`import "@executablemd/runtime";`)).toEqual([]); + expect(forbiddenNames("const id = crypto.randomUUID();")).toEqual([]); const found = (yield* glob({ root: REPOSITORY, diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 9ed5e372..b953be4a 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -7018,7 +7018,7 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6. | DLC10 | Fail-closed Workspace | A missing Workspace provider activates fail-stop before execution or publication and persists no Yield or Close | | DLC11 | Workspace isolation | Replaceable context carries only provider routing; the selected provider directly invokes the credentialed execution-owned capability, while unrelated durable operations stay on the default coordinator | | DLC12 | Workspace replay | Replayed Workspace operations require no live provider | -| DLC13 | Runtime-neutral boundary | No module of the shared coordination surface names a runtime, storage, connection, savepoint, transaction-token or process-global type outside its own prose, and none loads a module only one host can resolve. Module loading is read from parsed syntax — static imports and re-exports, `import type`, type-position `import()`, dynamic `import()`, `import =` and `require()` — with quoted and no-substitution template specifiers decoded, so comments and strings that merely contain import syntax load nothing. A destination the surface computes cannot be shown not to be a host module and is refused. Host schemes (`node:`, `bun:`, `deno:`, `cloudflare:`), adapter paths, vendored sources and host process modules are classified by shape rather than by an enumerated list | +| DLC13 | Runtime-neutral boundary | No module of the shared coordination surface names a storage, connection, savepoint or transaction-token type outside its own prose, reads a host global, or loads a module only one host can resolve. Host globals (`process`, `Deno`, `Bun`, `Buffer`, `globalThis`, `navigator`, `__dirname`, `__filename`) are recognized as parsed identifier references, so a word containing one and a property of that name are not crossings, while cross-runtime Web APIs such as `crypto` are never crossings. Module loading is read from parsed syntax — static imports and re-exports, `import type`, type-position `import()`, dynamic `import()`, `import =` and `require()` — with quoted and no-substitution template specifiers decoded, so comments and strings that merely contain import syntax load nothing. A destination the surface computes cannot be shown not to be a host module and is refused. Host schemes (`node:`, `bun:`, `deno:`, `cloudflare:`, `workerd:`), whole path segments naming any runtime this repository builds an entry point for (`deno`, `node`, `bun`, `compiled`, `cloudflare`, `workerd`), vendored sources and host process modules are classified by shape rather than by an enumerated list | | DLC14 | Provider infrastructure failure | A selected coordinator activates one first failure by identity and fences later execution and publication | | DLC15 | One-shot Workspace invocation | A provider can use the execution-owned invocation authority only during its original call; retained execution, publication and failure operations are refused after completion | | DLC16 | Loaded-copy Workspace selection | A provider installed by one physical package copy coordinates one operation created by another copy exactly once without sharing authority through context or a module registry; substituted selection and retained authority remain fail-closed | From c187879f994cc0f55afda58d0cc46d3fd40843a1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:39:56 -0400 Subject: [PATCH 07/11] =?UTF-8?q?=F0=9F=94=92=20Ask=20what=20declared=20a?= =?UTF-8?q?=20name=20before=20calling=20it=20a=20host=20global?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global scan asked whether an identifier was spelled `process` and whether it sat in a declaration position. Neither question is the one that matters, so three ordinary things were reported as crossings: function inspect(process: { pid: number }) { return process.pid; } const Buffer = 1; const b = Buffer; import { Deno } from "./host.ts"; Deno.cwd(); A parameter, a local and an import named `process` are not the host's `process`. What separates them from the real thing is not spelling or position but what declared the name, so the scan now walks the enclosing scope chain outward from each reference and reports only the ones nothing declared. Shadowing therefore ends where its scope does: a parameter named `process` covers its own function and no more, and a `const Deno` inside a block leaves the reference after that block still reported. Three mutations hold the classifier now. Dropping binding resolution reports `process` for the parameter case; deleting the global scan misses `process.pid`; narrowing adapter recognition back to Deno misses `./node.ts`. --- .../workflow/tests/workspace-effect.test.ts | 151 +++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/packages/workflow/tests/workspace-effect.test.ts b/packages/workflow/tests/workspace-effect.test.ts index db9e5db8..a5ebeac6 100644 --- a/packages/workflow/tests/workspace-effect.test.ts +++ b/packages/workflow/tests/workspace-effect.test.ts @@ -272,6 +272,130 @@ function moduleSpecifiers(source: string): string[] { return found; } +/** Every name a binding pattern introduces, however deeply it destructures. */ +function bindingNames(name: ts.BindingName, into: Set): void { + if (ts.isIdentifier(name)) { + into.add(name.text); + return; + } + for (const element of name.elements) { + if (ts.isBindingElement(element)) { + bindingNames(element.name, into); + } + } +} + +/** The names one statement introduces into the scope that holds it. */ +function statementNames(node: ts.Node, into: Set): void { + if (ts.isVariableStatement(node)) { + for (const declaration of node.declarationList.declarations) { + bindingNames(declaration.name, into); + } + return; + } + if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) { + if (node.name !== undefined) { + into.add(node.name.text); + } + return; + } + if ( + ts.isTypeAliasDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isEnumDeclaration(node) + ) { + into.add(node.name.text); + return; + } + if (ts.isImportDeclaration(node) && node.importClause !== undefined) { + const clause = node.importClause; + if (clause.name !== undefined) { + into.add(clause.name.text); + } + if (clause.namedBindings !== undefined) { + if (ts.isNamespaceImport(clause.namedBindings)) { + into.add(clause.namedBindings.name.text); + } else { + for (const element of clause.namedBindings.elements) { + into.add(element.name.text); + } + } + } + } +} + +/** Whether this node opens a lexical scope, and what that scope declares. */ +function scopeNames(node: ts.Node): Set | undefined { + const names = new Set(); + if (ts.isSourceFile(node) || ts.isBlock(node) || ts.isModuleBlock(node)) { + for (const statement of node.statements) { + statementNames(statement, names); + } + return names; + } + if (ts.isCaseBlock(node)) { + for (const clause of node.clauses) { + for (const statement of clause.statements) { + statementNames(statement, names); + } + } + return names; + } + if (ts.isFunctionLike(node)) { + for (const parameter of node.parameters) { + bindingNames(parameter.name, names); + } + if ( + (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) && + node.name !== undefined + ) { + names.add(node.name.text); + } + return names; + } + if (ts.isCatchClause(node)) { + if (node.variableDeclaration !== undefined) { + bindingNames(node.variableDeclaration.name, names); + } + return names; + } + if (ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node)) { + const initializer = node.initializer; + if (initializer !== undefined && ts.isVariableDeclarationList(initializer)) { + for (const declaration of initializer.declarations) { + bindingNames(declaration.name, names); + } + } + return names; + } + if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { + if (node.name !== undefined) { + names.add(node.name.text); + } + return names; + } + return undefined; +} + +/** + * Whether this name reaches an ambient global rather than something declared. + * + * A parameter, import or local named `process` is not the host's `process`, + * and the scope that declares it is the only place that is true — so the + * answer is the enclosing scope chain, walked outward from the reference. + */ +function unbound(node: ts.Identifier): boolean { + let scope: ts.Node | undefined = node.parent; + while (scope !== undefined) { + const declared = scopeNames(scope); + if (declared !== undefined && declared.has(node.text)) { + return false; + } + scope = scope.parent; + } + return true; +} + /** * Host globals this source actually reads. * @@ -290,7 +414,7 @@ function hostGlobals(source: string): string[] { const found: string[] = []; function visit(node: ts.Node): void { - if (ts.isIdentifier(node) && HOST_GLOBALS.includes(node.text) && names(node)) { + if (ts.isIdentifier(node) && HOST_GLOBALS.includes(node.text) && names(node) && unbound(node)) { if (!found.includes(node.text)) { found.push(node.text); } @@ -488,6 +612,31 @@ describe("Tier DLC — Workspace coordination selection", () => { expect(forbiddenNames("const here = __dirname;")).toEqual(["__dirname"]); expect(forbiddenNames("type F = Deno.FsFile;")).toEqual(["Deno"]); + // A name is the host's only when nothing declared it. A parameter, an + // import and a local are all something else that happens to be spelled + // the same way. + expect( + forbiddenNames("function inspect(process: { pid: number }) { return process.pid; }"), + ).toEqual([]); + expect(forbiddenNames("const Buffer = 1;\nconst b = Buffer;")).toEqual([]); + expect(forbiddenNames(`import { Deno } from "./host.ts";\nconst c = Deno.cwd();`)).toEqual([]); + expect(forbiddenNames(`import Buffer from "./bytes.ts";\nconst b = Buffer.from([]);`)).toEqual( + [], + ); + expect(forbiddenNames("const { process } = deps;\nconst pid = process.pid;")).toEqual([]); + expect(forbiddenNames("try { run(); } catch (process) { report(process); }")).toEqual([]); + expect(forbiddenNames("[1].forEach((process) => report(process));")).toEqual([]); + + // Shadowing is lexical, so it ends where its scope does. + expect( + forbiddenNames( + "function inner(process: unknown) { return process; }\nconst pid = process.pid;", + ), + ).toEqual(["process"]); + expect( + forbiddenNames("{\n const Deno = 1;\n use(Deno);\n}\nconst home = Deno.cwd();"), + ).toEqual(["Deno"]); + // Every runtime this repository names an entry point after, not only the // one whose adapter exists today. expect(forbiddenNames(`import "./node.ts";`)).toEqual(["./node.ts"]); From 221ad3180aa3503a76cbb03eddd05e9de2d55d25 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:53:43 -0400 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=94=92=20Bind=20names=20the=20way?= =?UTF-8?q?=20TypeScript=20binds=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver knew a handful of node shapes rather than the language's binding rules, so four ordinary declarations were still read as ambient host globals: const { process: local } = deps; import { Deno as portable } from "./host.ts"; function read(value: Deno): Deno { return value; } function read() { if (ready) var process = portable; return process.pid; } Each is a different rule. An aliased member is a label on both sides — the name being taken and the name it is given — and neither reads a binding. A type parameter binds its own declaration's type scope. And `var` belongs to the containing function however deeply the statement that writes it is nested, so collecting a block's own statements could never find it. All four are now decided by what the language says declares a name: alias property names are labels, type parameters join the scope of the function, class, interface or alias that introduces them, and every `var` in a function body is hoisted to the function, skipping the nested functions and classes that own their own. Shadow termination is unchanged and still asserted: a type parameter covers its own signature, a hoisted `var` covers its own function, and a reference after either is reported. Reverting to the previous resolver false-positives on all seven newly covered categories — both alias forms, the re-export alias, function and interface type parameters, and `var` hoisted out of an `if` and out of a `for` — while `process.pid` and `Deno.cwd()` stay reported. --- .../workflow/tests/workspace-effect.test.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/packages/workflow/tests/workspace-effect.test.ts b/packages/workflow/tests/workspace-effect.test.ts index a5ebeac6..14df91a5 100644 --- a/packages/workflow/tests/workspace-effect.test.ts +++ b/packages/workflow/tests/workspace-effect.test.ts @@ -285,6 +285,33 @@ function bindingNames(name: ts.BindingName, into: Set): void { } } +/** Whether a declaration list binds its containing function rather than its block. */ +function hoists(list: ts.VariableDeclarationList): boolean { + return (list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) === 0; +} + +/** + * The `var` names a function body binds, wherever inside it they are written. + * + * `var` belongs to the function, not to the `if` or the loop it sits in, so + * collecting only a block's own statements would leave the binding invisible + * from the statement that reads it. Nested functions and classes own their + * own, and are not descended into. + */ +function hoistedNames(node: ts.Node, into: Set): void { + ts.forEachChild(node, function collect(child: ts.Node): void { + if (ts.isFunctionLike(child) || ts.isClassLike(child)) { + return; + } + if (ts.isVariableDeclarationList(child) && hoists(child)) { + for (const declaration of child.declarations) { + bindingNames(declaration.name, into); + } + } + ts.forEachChild(child, collect); + }); +} + /** The names one statement introduces into the scope that holds it. */ function statementNames(node: ts.Node, into: Set): void { if (ts.isVariableStatement(node)) { @@ -331,6 +358,9 @@ function scopeNames(node: ts.Node): Set | undefined { for (const statement of node.statements) { statementNames(statement, names); } + if (ts.isSourceFile(node)) { + hoistedNames(node, names); + } return names; } if (ts.isCaseBlock(node)) { @@ -345,12 +375,18 @@ function scopeNames(node: ts.Node): Set | undefined { for (const parameter of node.parameters) { bindingNames(parameter.name, names); } + typeParameterNames(node.typeParameters, names); if ( (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) && node.name !== undefined ) { names.add(node.name.text); } + hoistedNames(node, names); + return names; + } + if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) { + typeParameterNames(node.typeParameters, names); return names; } if (ts.isCatchClause(node)) { @@ -372,11 +408,22 @@ function scopeNames(node: ts.Node): Set | undefined { if (node.name !== undefined) { names.add(node.name.text); } + typeParameterNames(node.typeParameters, names); return names; } return undefined; } +/** A type parameter binds its own name for the declaration that introduces it. */ +function typeParameterNames( + parameters: ts.NodeArray | undefined, + into: Set, +): void { + for (const parameter of parameters ?? []) { + into.add(parameter.name.text); + } +} + /** * Whether this name reaches an ambient global rather than something declared. * @@ -438,6 +485,14 @@ function names(node: ts.Identifier): boolean { if (ts.isQualifiedName(parent)) { return parent.right !== node; } + // `{ process: local }` and `{ Deno as portable }` name the member being + // taken, not a binding being read. Both sides are labels. + if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent)) { + return false; + } + if (ts.isBindingElement(parent)) { + return parent.name !== node && parent.propertyName !== node; + } if ( ts.isPropertyAssignment(parent) || ts.isPropertySignature(parent) || @@ -627,6 +682,33 @@ describe("Tier DLC — Workspace coordination selection", () => { expect(forbiddenNames("try { run(); } catch (process) { report(process); }")).toEqual([]); expect(forbiddenNames("[1].forEach((process) => report(process));")).toEqual([]); + // Binding semantics, not a list of node shapes. An aliased member is a + // label on both sides, a type parameter binds its own scope, and `var` + // belongs to the function however deeply it is nested. + expect(forbiddenNames("const { process: local } = deps;")).toEqual([]); + expect(forbiddenNames(`import { Deno as portable } from "./host.ts";`)).toEqual([]); + expect(forbiddenNames(`export { process as runner } from "./host.ts";`)).toEqual([]); + expect(forbiddenNames("function read(value: Deno): Deno { return value; }")).toEqual([]); + expect(forbiddenNames("interface Holder { value: Buffer }")).toEqual([]); + expect( + forbiddenNames( + "function read() {\n if (ready) var process = portable;\n return process.pid;\n}", + ), + ).toEqual([]); + expect( + forbiddenNames( + "function read() {\n for (var Buffer of list) use(Buffer);\n return Buffer;\n}", + ), + ).toEqual([]); + + // The same forms still end at their own boundary. + expect( + forbiddenNames("function read(value: Deno): Deno { return value; }\nDeno.cwd();"), + ).toEqual(["Deno"]); + expect( + forbiddenNames("function read() {\n var process = portable;\n}\nconst pid = process.pid;"), + ).toEqual(["process"]); + // Shadowing is lexical, so it ends where its scope does. expect( forbiddenNames( From dd46fd0dd4a96bb50ddc451de90022aa5b3854ec Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:02:03 -0400 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=94=92=20Let=20the=20compiler=20say?= =?UTF-8?q?=20what=20a=20name=20means?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four rounds of review found four more declaration forms the hand-written resolver did not know, and this round found six: `import =`, `namespace`, mapped-type and `infer` type parameters, statement labels, and accessor members. The pattern is the defect. A boundary that claims to follow the language's binding rules cannot be a growing inventory of the declaration shapes somebody remembered. Resolution is now the compiler's. Each scanned source becomes a one-file `ts.Program` with no lib and no module resolution, and every candidate identifier is handed to the type checker: a name is the host's when nothing in the file declares it. Value scopes and type scopes, hoisting, aliasing, namespaces, mapped and inferred type parameters, accessors and shadowing all come from the compiler, and no rule about any of them is written here. What stays is one syntactic question the checker cannot be asked: whether an identifier refers to a binding at all. The member in `x.process`, the loop label in `break process`, the imported member in `{ Deno as portable }` and the key in `{ process: local }` are labels, not references — the language says so, and there are only these positions. The architecture is what the mutation now discriminates. Restoring the manual resolver reports a crossing for every one of the six forms above, while the compiler reports none and both still report `process.pid`, `Deno.cwd()` and a reference outside its shadow. --- .../workflow/tests/workspace-effect.test.ts | 305 +++++------------- 1 file changed, 82 insertions(+), 223 deletions(-) diff --git a/packages/workflow/tests/workspace-effect.test.ts b/packages/workflow/tests/workspace-effect.test.ts index 14df91a5..ab6e9e4e 100644 --- a/packages/workflow/tests/workspace-effect.test.ts +++ b/packages/workflow/tests/workspace-effect.test.ts @@ -222,14 +222,7 @@ const COMPUTED = "a computed module specifier"; * the same characters can appear in a string that loads nothing. Each of those * is a different answer, and only a parse tells them apart. */ -function moduleSpecifiers(source: string): string[] { - const parsed = ts.createSourceFile( - "scanned.ts", - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); +function moduleSpecifiers(file: ts.SourceFile): string[] { const found: string[] = []; function record(node: ts.Node | undefined): void { @@ -268,177 +261,73 @@ function moduleSpecifiers(source: string): string[] { ts.forEachChild(node, visit); } - visit(parsed); + visit(file); return found; } -/** Every name a binding pattern introduces, however deeply it destructures. */ -function bindingNames(name: ts.BindingName, into: Set): void { - if (ts.isIdentifier(name)) { - into.add(name.text); - return; - } - for (const element of name.elements) { - if (ts.isBindingElement(element)) { - bindingNames(element.name, into); - } - } -} - -/** Whether a declaration list binds its containing function rather than its block. */ -function hoists(list: ts.VariableDeclarationList): boolean { - return (list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) === 0; -} - /** - * The `var` names a function body binds, wherever inside it they are written. + * The scanned source, and a checker that knows what its names mean. * - * `var` belongs to the function, not to the `if` or the loop it sits in, so - * collecting only a block's own statements would leave the binding invisible - * from the statement that reads it. Nested functions and classes own their - * own, and are not descended into. + * `noLib` and `noResolve` are the point rather than an economy: nothing + * outside this file is loaded, so a name resolves only to what the file itself + * declares. Anything left unresolved is ambient — supplied by a host at + * runtime — which is exactly the question being asked. */ -function hoistedNames(node: ts.Node, into: Set): void { - ts.forEachChild(node, function collect(child: ts.Node): void { - if (ts.isFunctionLike(child) || ts.isClassLike(child)) { - return; - } - if (ts.isVariableDeclarationList(child) && hoists(child)) { - for (const declaration of child.declarations) { - bindingNames(declaration.name, into); - } - } - ts.forEachChild(child, collect); +function parse(source: string): { file: ts.SourceFile; checker: ts.TypeChecker } { + const path = "/scanned.ts"; + const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const program = ts.createProgram({ + rootNames: [path], + options: { noLib: true, noResolve: true, target: ts.ScriptTarget.Latest }, + host: { + getSourceFile: (name) => (name === path ? file : undefined), + getDefaultLibFileName: () => "", + writeFile: () => {}, + getCurrentDirectory: () => "/", + getCanonicalFileName: (name) => name, + useCaseSensitiveFileNames: () => true, + getNewLine: () => "\n", + fileExists: (name) => name === path, + readFile: (name) => (name === path ? source : undefined), + }, }); + return { file, checker: program.getTypeChecker() }; } -/** The names one statement introduces into the scope that holds it. */ -function statementNames(node: ts.Node, into: Set): void { - if (ts.isVariableStatement(node)) { - for (const declaration of node.declarationList.declarations) { - bindingNames(declaration.name, into); - } - return; - } - if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) { - if (node.name !== undefined) { - into.add(node.name.text); - } - return; - } - if ( - ts.isTypeAliasDeclaration(node) || - ts.isInterfaceDeclaration(node) || - ts.isEnumDeclaration(node) - ) { - into.add(node.name.text); - return; - } - if (ts.isImportDeclaration(node) && node.importClause !== undefined) { - const clause = node.importClause; - if (clause.name !== undefined) { - into.add(clause.name.text); - } - if (clause.namedBindings !== undefined) { - if (ts.isNamespaceImport(clause.namedBindings)) { - into.add(clause.namedBindings.name.text); - } else { - for (const element of clause.namedBindings.elements) { - into.add(element.name.text); - } - } - } - } -} - -/** Whether this node opens a lexical scope, and what that scope declares. */ -function scopeNames(node: ts.Node): Set | undefined { - const names = new Set(); - if (ts.isSourceFile(node) || ts.isBlock(node) || ts.isModuleBlock(node)) { - for (const statement of node.statements) { - statementNames(statement, names); - } - if (ts.isSourceFile(node)) { - hoistedNames(node, names); - } - return names; - } - if (ts.isCaseBlock(node)) { - for (const clause of node.clauses) { - for (const statement of clause.statements) { - statementNames(statement, names); - } - } - return names; +/** + * Whether this identifier refers to a binding at all. + * + * Not a scope question — the checker answers those. This is only about + * positions where an identifier is a label rather than a reference: the member + * in `x.process`, the loop label in `break process`, the imported member in + * `{ Deno as portable }`, the key in `{ process: local }`. None of them reads + * the name they spell. + */ +function refers(node: ts.Identifier): boolean { + const parent = node.parent; + if (parent === undefined) { + return true; } - if (ts.isFunctionLike(node)) { - for (const parameter of node.parameters) { - bindingNames(parameter.name, names); - } - typeParameterNames(node.typeParameters, names); - if ( - (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) && - node.name !== undefined - ) { - names.add(node.name.text); - } - hoistedNames(node, names); - return names; + if (ts.isPropertyAccessExpression(parent)) { + return parent.name !== node; } - if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) { - typeParameterNames(node.typeParameters, names); - return names; + if (ts.isQualifiedName(parent)) { + return parent.right !== node; } - if (ts.isCatchClause(node)) { - if (node.variableDeclaration !== undefined) { - bindingNames(node.variableDeclaration.name, names); - } - return names; + if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent)) { + return false; } - if (ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node)) { - const initializer = node.initializer; - if (initializer !== undefined && ts.isVariableDeclarationList(initializer)) { - for (const declaration of initializer.declarations) { - bindingNames(declaration.name, names); - } - } - return names; + if (ts.isBindingElement(parent)) { + return parent.name !== node && parent.propertyName !== node; } - if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { - if (node.name !== undefined) { - names.add(node.name.text); - } - typeParameterNames(node.typeParameters, names); - return names; + if (ts.isPropertyAssignment(parent)) { + return parent.name !== node; } - return undefined; -} - -/** A type parameter binds its own name for the declaration that introduces it. */ -function typeParameterNames( - parameters: ts.NodeArray | undefined, - into: Set, -): void { - for (const parameter of parameters ?? []) { - into.add(parameter.name.text); + if (ts.isLabeledStatement(parent)) { + return parent.label !== node; } -} - -/** - * Whether this name reaches an ambient global rather than something declared. - * - * A parameter, import or local named `process` is not the host's `process`, - * and the scope that declares it is the only place that is true — so the - * answer is the enclosing scope chain, walked outward from the reference. - */ -function unbound(node: ts.Identifier): boolean { - let scope: ts.Node | undefined = node.parent; - while (scope !== undefined) { - const declared = scopeNames(scope); - if (declared !== undefined && declared.has(node.text)) { - return false; - } - scope = scope.parent; + if (ts.isBreakStatement(parent) || ts.isContinueStatement(parent)) { + return parent.label !== node; } return true; } @@ -446,85 +335,42 @@ function unbound(node: ts.Identifier): boolean { /** * Host globals this source actually reads. * - * A reference, not an occurrence: `preprocessor` is not `process`, `foo.process` - * names a property of something else, and `{ process: 1 }` declares a key. Only - * a parse can tell a use of the global from a word that contains its name. + * A name is the host's only when nothing in this file declares it, and the + * checker is what knows that. Value scopes and type scopes, `var` hoisting, + * `import =`, `namespace`, mapped-type and `infer` type parameters, accessors + * and shadowing are the language's rules, not a list kept here — every one of + * them was a false positive while this was a list. */ -function hostGlobals(source: string): string[] { - const parsed = ts.createSourceFile( - "scanned.ts", - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); +function hostGlobals(parsed: { file: ts.SourceFile; checker: ts.TypeChecker }): string[] { const found: string[] = []; function visit(node: ts.Node): void { - if (ts.isIdentifier(node) && HOST_GLOBALS.includes(node.text) && names(node) && unbound(node)) { - if (!found.includes(node.text)) { + if (ts.isIdentifier(node) && HOST_GLOBALS.includes(node.text) && refers(node)) { + const symbol = parsed.checker.getSymbolAtLocation(node); + const declared = (symbol?.declarations ?? []).some( + (declaration) => declaration.getSourceFile() === parsed.file, + ); + if (!declared && !found.includes(node.text)) { found.push(node.text); } } ts.forEachChild(node, visit); } - visit(parsed); + visit(parsed.file); return found; } -/** Whether this identifier reads the binding it spells, rather than labelling something. */ -function names(node: ts.Identifier): boolean { - const parent = node.parent; - if (parent === undefined) { - return true; - } - if (ts.isPropertyAccessExpression(parent)) { - return parent.name !== node; - } - if (ts.isQualifiedName(parent)) { - return parent.right !== node; - } - // `{ process: local }` and `{ Deno as portable }` name the member being - // taken, not a binding being read. Both sides are labels. - if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent)) { - return false; - } - if (ts.isBindingElement(parent)) { - return parent.name !== node && parent.propertyName !== node; - } - if ( - ts.isPropertyAssignment(parent) || - ts.isPropertySignature(parent) || - ts.isPropertyDeclaration(parent) || - ts.isMethodDeclaration(parent) || - ts.isMethodSignature(parent) || - ts.isVariableDeclaration(parent) || - ts.isParameter(parent) || - ts.isBindingElement(parent) || - ts.isFunctionDeclaration(parent) || - ts.isClassDeclaration(parent) || - ts.isInterfaceDeclaration(parent) || - ts.isTypeAliasDeclaration(parent) || - ts.isImportSpecifier(parent) || - ts.isExportSpecifier(parent) || - ts.isImportClause(parent) || - ts.isNamespaceImport(parent) - ) { - return parent.name !== node; - } - return true; -} - function forbiddenNames(source: string): string[] { + const parsed = parse(source); const scanned = code(source); const crossings = FORBIDDEN.filter((name) => scanned.includes(name)); - for (const global of hostGlobals(source)) { + for (const global of hostGlobals(parsed)) { if (!crossings.includes(global)) { crossings.push(global); } } - for (const specifier of moduleSpecifiers(source)) { + for (const specifier of moduleSpecifiers(parsed.file)) { const refused = specifier === COMPUTED || hostModule(specifier); if (refused && !crossings.includes(specifier)) { crossings.push(specifier); @@ -701,6 +547,19 @@ describe("Tier DLC — Workspace coordination selection", () => { ), ).toEqual([]); + // The language's own resolution, not a catalogue of declaration shapes: + // `import =`, a namespace, a mapped-type parameter, an `infer` parameter, + // a statement label and an accessor member each bind or label the name + // without any rule about them being written here. + expect(forbiddenNames(`import Deno = require("./portable.ts");\nDeno.cwd();`)).toEqual([]); + expect( + forbiddenNames('namespace Deno {\n export const cwd = () => "";\n}\nDeno.cwd();'), + ).toEqual([]); + expect(forbiddenNames("type Rename = { [process in keyof T]: T[process] };")).toEqual([]); + expect(forbiddenNames("type Value = T extends infer Buffer ? Buffer : never;")).toEqual([]); + expect(forbiddenNames("process: for (;;) {\n break process;\n}")).toEqual([]); + expect(forbiddenNames("class Queue {\n get process() {\n return 1;\n }\n}")).toEqual([]); + // The same forms still end at their own boundary. expect( forbiddenNames("function read(value: Deno): Deno { return value; }\nDeno.cwd();"), @@ -784,7 +643,7 @@ describe("Tier DLC — Workspace coordination selection", () => { // A parse that failed would report every file as clean. A module whose // text imports something must yield a specifier, or this scan is reading // nothing and saying so approvingly. - if (/^import\s/m.test(source) && moduleSpecifiers(source).length === 0) { + if (/^import\s/m.test(source) && moduleSpecifiers(parse(source).file).length === 0) { unread.push(path); } const names = forbiddenNames(source); From 23febe4516b2417a9ab20e35d1c284d5d3affe43 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:09:55 -0400 Subject: [PATCH 10/11] =?UTF-8?q?=F0=9F=94=92=20Read=20the=20binding=20a?= =?UTF-8?q?=20shorthand=20property=20refers=20to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{ process }` writes one name in two roles: the property the object literal declares and the value it reads. The ordinary symbol at that identifier is the property, and the property is declared right there — so asking for it answered that every host global becomes locally declared the moment it is put in an object. const environment = { process }; // reported nothing const runtimes = { Deno, Bun }; // reported nothing That direction is the dangerous one. The scan is meant to fail closed, and this made it quietly admit the exact read it exists to catch, in the shortest way anyone would write it. A shorthand assignment's value symbol is what the name refers to, so that is what the scan now asks for. A declared `process` used as `{ process }` still resolves to its declaration and is still accepted, and `const { process } = deps` is a binding element rather than a shorthand assignment and was never this question. Replacing the value symbol with the ordinary one reports nothing for `{ process }`, which is the mutation this keeps. --- .../workflow/tests/workspace-effect.test.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/workflow/tests/workspace-effect.test.ts b/packages/workflow/tests/workspace-effect.test.ts index ab6e9e4e..b14468f3 100644 --- a/packages/workflow/tests/workspace-effect.test.ts +++ b/packages/workflow/tests/workspace-effect.test.ts @@ -344,10 +344,26 @@ function refers(node: ts.Identifier): boolean { function hostGlobals(parsed: { file: ts.SourceFile; checker: ts.TypeChecker }): string[] { const found: string[] = []; + /** + * The binding this identifier reads. + * + * `{ process }` writes one name in two roles: the property the literal + * declares and the value it reads. The ordinary symbol is the property — it + * is declared right there, so asking for it would answer that every host + * global is locally declared the moment it is put in an object. The value + * symbol is the one the shorthand refers to. + */ + function binding(node: ts.Identifier): ts.Symbol | undefined { + const parent = node.parent; + if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === node) { + return parsed.checker.getShorthandAssignmentValueSymbol(parent); + } + return parsed.checker.getSymbolAtLocation(node); + } + function visit(node: ts.Node): void { if (ts.isIdentifier(node) && HOST_GLOBALS.includes(node.text) && refers(node)) { - const symbol = parsed.checker.getSymbolAtLocation(node); - const declared = (symbol?.declarations ?? []).some( + const declared = (binding(node)?.declarations ?? []).some( (declaration) => declaration.getSourceFile() === parsed.file, ); if (!declared && !found.includes(node.text)) { @@ -560,6 +576,15 @@ describe("Tier DLC — Workspace coordination selection", () => { expect(forbiddenNames("process: for (;;) {\n break process;\n}")).toEqual([]); expect(forbiddenNames("class Queue {\n get process() {\n return 1;\n }\n}")).toEqual([]); + // A shorthand property writes one name in two roles. The property it + // declares is not the binding it reads, and reading an ambient global is + // a crossing however briefly the value is held. + expect(forbiddenNames("const environment = { process };")).toEqual(["process"]); + expect(forbiddenNames("const runtimes = { Deno, Bun };")).toEqual(["Deno", "Bun"]); + expect(forbiddenNames("const process = 1;\nconst environment = { process };")).toEqual([]); + expect(forbiddenNames("function hold(Buffer: number) {\n return { Buffer };\n}")).toEqual([]); + expect(forbiddenNames("const environment = { process: local };")).toEqual([]); + // The same forms still end at their own boundary. expect( forbiddenNames("function read(value: Deno): Deno { return value; }\nDeno.cwd();"), From 4fdfe19f10289055988647716d83db8f2409b679 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:21:28 -0400 Subject: [PATCH 11/11] =?UTF-8?q?=F0=9F=94=92=20Ask=20the=20grammar=20whic?= =?UTF-8?q?h=20identifiers=20are=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more label positions were being read as ambient globals: type Pair = [process: string, Deno?: number]; import data from "./x.json" with { process: "portable" }; Both were omissions of the same kind as the last several: the classifier listed the parent node kinds someone had thought of, so every position it had not been shown was a reference by default. TypeScript already draws this line structurally. An `IdentifierName` fills a `name`, `propertyName` or `label` slot of the node that owns it — and a qualified name's `right` is the same thing in type position — while an `IdentifierReference` appears anywhere else. Asking which slot the identifier occupies replaces the list of kinds with the rule the grammar uses, so named tuple elements, import attributes, enum members, property signatures and every declaration's own name are labels because of what they are rather than because they were reported. The shorthand property remains the single name slot that is also a read, and is still resolved to the binding it refers to. Each classification is held on its own: treating a named tuple member as a reference reports `["process", "Deno"]` for the tuple, and treating an import attribute as one reports `["process"]` for the attribute, while `type Pair = [value: typeof process]` and the `node:fs` beside an attribute stay reported either way. --- .../workflow/tests/workspace-effect.test.ts | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/packages/workflow/tests/workspace-effect.test.ts b/packages/workflow/tests/workspace-effect.test.ts index b14468f3..c0bcafd5 100644 --- a/packages/workflow/tests/workspace-effect.test.ts +++ b/packages/workflow/tests/workspace-effect.test.ts @@ -294,42 +294,41 @@ function parse(source: string): { file: ts.SourceFile; checker: ts.TypeChecker } return { file, checker: program.getTypeChecker() }; } +/** + * The slots in which the grammar writes a name rather than a reference. + * + * TypeScript spells the distinction structurally: an `IdentifierName` fills a + * `name`, `propertyName` or `label` slot of the node that owns it, and a + * qualified name's `right` is the same thing in type position. Everywhere else + * an identifier is an `IdentifierReference`. + */ +const LABEL_SLOTS = ["name", "propertyName", "label"]; + /** * Whether this identifier refers to a binding at all. * - * Not a scope question — the checker answers those. This is only about - * positions where an identifier is a label rather than a reference: the member - * in `x.process`, the loop label in `break process`, the imported member in - * `{ Deno as portable }`, the key in `{ process: local }`. None of them reads - * the name they spell. + * Not a scope question — the checker answers those. This asks the grammar + * instead of listing the node kinds someone remembered: the member in + * `x.process`, the label in `break process`, the imported member in + * `{ Deno as portable }`, the key in `{ process: local }`, a named tuple + * element, an import attribute and every declaration's own name all fill a + * name slot, and none of them reads the name it spells. + * + * A shorthand property is the one name slot that is also a read, because + * `{ process }` declares a property and reads a binding with one identifier. */ function refers(node: ts.Identifier): boolean { const parent = node.parent; if (parent === undefined) { return true; } - if (ts.isPropertyAccessExpression(parent)) { - return parent.name !== node; - } - if (ts.isQualifiedName(parent)) { - return parent.right !== node; + if (ts.isShorthandPropertyAssignment(parent) && parent.name === node) { + return true; } - if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent)) { + if (ts.isQualifiedName(parent) && parent.right === node) { return false; } - if (ts.isBindingElement(parent)) { - return parent.name !== node && parent.propertyName !== node; - } - if (ts.isPropertyAssignment(parent)) { - return parent.name !== node; - } - if (ts.isLabeledStatement(parent)) { - return parent.label !== node; - } - if (ts.isBreakStatement(parent) || ts.isContinueStatement(parent)) { - return parent.label !== node; - } - return true; + return !LABEL_SLOTS.some((slot) => Reflect.get(parent, slot) === node); } /** @@ -585,6 +584,20 @@ describe("Tier DLC — Workspace coordination selection", () => { expect(forbiddenNames("function hold(Buffer: number) {\n return { Buffer };\n}")).toEqual([]); expect(forbiddenNames("const environment = { process: local };")).toEqual([]); + // A name slot is a name slot wherever the grammar puts one: a named tuple + // element and an import attribute key are labels, and the specifier beside + // the attribute is still read as a module. + expect(forbiddenNames("type Pair = [process: string, Deno?: number];")).toEqual([]); + expect( + forbiddenNames('import data from "./portable.json" with {\n process: "portable",\n};'), + ).toEqual([]); + expect(forbiddenNames("type Pair = [value: typeof process];")).toEqual(["process"]); + expect(forbiddenNames('import data from "node:fs" with {\n process: "portable",\n};')).toEqual( + ["node:fs"], + ); + expect(forbiddenNames("enum Kind {\n process,\n}")).toEqual([]); + expect(forbiddenNames("interface Host {\n process: number;\n}")).toEqual([]); + // The same forms still end at their own boundary. expect( forbiddenNames("function read(value: Deno): Deno { return value; }\nDeno.cwd();"),