diff --git a/.agents/scripts/comments.mjs b/.agents/scripts/comments.mjs
index fab3de7da..86897cd4b 100755
--- a/.agents/scripts/comments.mjs
+++ b/.agents/scripts/comments.mjs
@@ -7,7 +7,9 @@
// when no comment is listed.
// `comment-lint.mjs` imports `listComments({ base, head, all, files })` for the same blocks as
// data: `{ path, start, end, state, trailing, lines: [{ n, text }], next: { n, text } | null }`,
-// and `trailingText(line, shell)` for the comment part of a code line.
+// and `trailingText(line, shell)` for the comment part of a code line. `test-lint.mjs` imports
+// `changedFiles({ base, head, files })`: each touched code file as `{ path, lines, added }`, its
+// text at
split into lines and the set of the line numbers (from 1) the diff adds.
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -21,20 +23,25 @@ const QUOTES = new Set(['"', "'", "`"]);
const git = (...args) => execFileSync("git", args, { encoding: "utf8", maxBuffer: 1 << 28 });
export function listComments({ base, head, all = false, files = null }) {
- // why: from where the branch left , so a comment changed since is not blamed on it.
- const from = git("merge-base", base, head ?? "HEAD").trim();
- const range = head === undefined ? [from] : [from, head];
- const wanted = files === null ? null : new Set(files);
const blocks = [];
- for (const path of touchedFiles(range, head, wanted)) {
- const lines = fileText(path, head).split("\n");
- const added = addedLines(path, lines.length, range, head);
+ for (const { path, lines, added } of changedFiles({ base, head, files })) {
const kinds = SHELL.test(path) ? shellKinds(lines) : scriptKinds(lines);
blocks.push(...blocksIn(path, lines, kinds, added, all));
}
return blocks;
}
+export function changedFiles({ base, head, files = null }) {
+ // why: from where the branch left , so a line changed since is not blamed on it.
+ const from = git("merge-base", base, head ?? "HEAD").trim();
+ const range = head === undefined ? [from] : [from, head];
+ const wanted = files === null ? null : new Set(files);
+ return touchedFiles(range, head, wanted).map((path) => {
+ const lines = fileText(path, head).split("\n");
+ return { path, lines, added: addedLines(path, lines.length, range, head) };
+ });
+}
+
export function trailingText(line, shell = false) {
const marker = shell ? "#" : "//";
const at = unquotedIndex(line, marker);
diff --git a/.agents/scripts/test-lint.mjs b/.agents/scripts/test-lint.mjs
new file mode 100644
index 000000000..1cead4ac0
--- /dev/null
+++ b/.agents/scripts/test-lint.mjs
@@ -0,0 +1,370 @@
+#!/usr/bin/env node
+// `node .agents/scripts/test-lint.mjs [--base [] [--head ][] [...]`: the mechanical
+// half of testing rules 5, 6 and 10 and of always-in-scope 8, on the lines (default the
+// working tree) adds since it left (default origin/main). In a test file (`*.test.ts`,
+// `*.test.tsx`), an added line fails when it
+//
+// testing 5 bounds real time: `expect(...).toBeLessThan/GreaterThan` on `Date.now()`,
+// `performance.now()` or `process.hrtime`, or on an x assigned from one of them in
+// the same test; `*.timing.test.ts` and `e2e/slow-*` files are exempt
+// testing 5 skips, focuses or defers a test: `it|test|describe.only|skip|todo`, or a
+// `skipIf(`/`runIf(`; exempt when the line reads a `SIMLOCK_` variable, a
+// `skipIf`/`runIf` on `process.platform`, and one in an `e2e/slow-*` file (the
+// slow lane gates itself on the machine)
+// testing 10 asserts what a failure also satisfies: `toBeInstanceOf(Error)`,
+// `.not.toBe("pending")`, or `?.(` inside an `expect(...)` on its own line
+// testing 6 adds a value import of a sibling internal file (`./x.js`, not index, testing,
+// definition or test-*) in a directory with an index.ts, with no `why:` comment in
+// the 3 lines (or the comment block) above the first `describe(`
+//
+// and in `src/contract/*.ts` (not tests) an added `z.string()` with no `.max(`, `.length(`,
+// `.regex(`, `.uuid(` or `.datetime(` on its chain, a `z.array(` with no `.max(` or `.length(`,
+// or any `z.record(` fails (always-in-scope 8), unless a `why:` comment sits right above it.
+// Strings and comments are never read as code, so a fixture in a string is no finding.
+// Each finding prints as `path:line: ()` and the exit code is 1.
+// restricts the paths, for the commit hook.
+import { execFileSync } from "node:child_process";
+import { existsSync } from "node:fs";
+import path from "node:path";
+import { changedFiles } from "./comments.mjs";
+
+const MESSAGES = {
+ time: "a bound on real time goes in a *.timing.test.ts file or the slow lane; here drive a fake clock and assert the exact value (testing 5)",
+ skip: "never skip, focus or defer a test to get to green; fix it or delete it, and gate a live-device test on a SIMLOCK_ variable (testing 5)",
+ vacuous:
+ "this assertion also passes on a failure; assert the exact value, or the named error and its code (testing 10)",
+ internal:
+ 'a test of an internal file says which reason holds in a "why:" comment above its first describe (pure logic with many cases, or an ordering the surface cannot force), or tests through index.ts (testing 6)',
+ bound:
+ 'bound this wire input with .max(), .length() or .regex(), or say why it needs none in a "why:" comment right above (always-in-scope 8)',
+};
+
+const TEST_FILE = /\.test\.tsx?$/;
+const TIMING_FILE = /\.timing\.test\.tsx?$/;
+const SLOW_LANE = /^e2e\/slow-/;
+const CONTRACT_FILE = /^src\/contract\/[^/]+\.ts$/;
+const HELPER_FILE = /(?:^|\/)(?:testing|test-[^/]*)\.ts$/;
+const CLOCK = /\b(?:Date\.now|performance\.now|process\.hrtime)\b/;
+const BOUND = /\.toBe(?:Less|Greater)Than/;
+const WHY = /\bwhy:/i;
+// why: the files a component is entered through (`.dependency-cruiser.mjs`), so not internal.
+const ENTRY_FILES = new Set(["index", "testing", "definition"]);
+
+function usage() {
+ console.error("usage: test-lint.mjs [--base ][] [--head ][] [...]");
+ process.exit(64);
+}
+
+// why: one entry per rule judged line by line; `applies` says which test files it reads.
+const LINE_RULES = [
+ {
+ rule: "time",
+ applies: (file) => !TIMING_FILE.test(file) && !SLOW_LANE.test(file),
+ breaks: (source, n) => boundsRealTime(source.codeLines, n),
+ },
+ { rule: "skip", applies: () => true, breaks: skips },
+ { rule: "vacuous", applies: () => true, breaks: vacuous },
+];
+
+// why: the chain methods that bound each wire shape; a record is never bounded by its chain.
+const BOUNDED_BY = {
+ string: ["max", "length", "regex", "uuid", "datetime"],
+ array: ["max", "length"],
+ record: [],
+};
+
+function sourceOf({ path: file, lines, added }) {
+ const text = lines.join("\n");
+ const code = mask(text);
+ return { file, lines, added, text, code, codeLines: code.split("\n"), starts: lineStarts(text) };
+}
+
+function testFindings(changed) {
+ const source = sourceOf(changed);
+ const rules = LINE_RULES.filter((entry) => entry.applies(source.file));
+ const out = [...source.added]
+ .sort((a, b) => a - b)
+ .flatMap((n) =>
+ rules
+ .filter((entry) => entry.breaks(source, n))
+ .map((entry) => ({ path: source.file, line: n, rule: entry.rule })),
+ );
+ const internal = internalImportLine(source);
+ if (internal !== undefined) out.push({ path: source.file, line: internal, rule: "internal" });
+ return out;
+}
+
+function boundsRealTime(codeLines, n) {
+ const line = codeLines[n - 1];
+ if (!BOUND.test(line)) return false;
+ if (new RegExp(`\\bexpect\\s*\\([^;]*${CLOCK.source}`).test(line)) return true;
+ const bound = /\bexpect\s*\(\s*(\w+)\s*[,)]/.exec(line);
+ return bound !== null && assignedFromClock(codeLines, n, bound[1]);
+}
+
+function assignedFromClock(codeLines, n, name) {
+ const assigned = new RegExp(`\\b${name}\\s*=[^=;]*${CLOCK.source}`);
+ for (let i = n - 1; i >= 1; i--) {
+ if (assigned.test(codeLines[i - 1])) return true;
+ if (/^\s*(?:it|test)\b[\w.]*\s*\(/.test(codeLines[i - 1])) return false;
+ }
+ return false;
+}
+
+function skips(source, n) {
+ const line = source.codeLines[n - 1] ?? "";
+ if (/\bSIMLOCK_/.test(source.lines[n - 1] ?? "")) return false;
+ if (/\b(?:it|test|describe)\.(?:only|skip|todo)\b/.test(line)) return true;
+ if (!/\b(?:it|test|describe)\.(?:skipIf|runIf)\s*\(/.test(line)) return false;
+ return !SLOW_LANE.test(source.file) && !/\bprocess\.platform\b/.test(line);
+}
+
+function vacuous(source, n) {
+ const raw = source.lines[n - 1] ?? "";
+ const line = source.codeLines[n - 1] ?? "";
+ if (/\.toBeInstanceOf\(\s*Error\s*\)/.test(line)) return true;
+ const pending = /\.not\.toBe\(\s*["']pending["']/.exec(raw);
+ if (pending !== null && line[pending.index] === raw[pending.index]) return true;
+ return optionalCallInExpect(source.code, source.starts[n - 1], line);
+}
+
+function optionalCallInExpect(code, offset, line) {
+ // why: only the argument's text on the expect's own line; a callback spread over the lines
+ // below is setup, and its `?.(` is no assertion.
+ return [...line.matchAll(/\bexpect\s*\(/g)].some((m) => {
+ const open = offset + m.index + m[0].length - 1;
+ const end = Math.min(closing(code, open), offset + line.length);
+ return code.slice(open, end).includes("?.(");
+ });
+}
+
+function internalImportLine(source) {
+ if (!exists(`${path.posix.dirname(source.file)}/index.ts`)) return undefined;
+ const line = firstAddedInternalImport(source);
+ if (line === undefined) return undefined;
+ const first = firstTestBlock(source.codeLines);
+ if (first === -1) return undefined;
+ return hasWhyAbove(source.lines, source.codeLines, first) ? undefined : line;
+}
+
+function firstAddedInternalImport({ text, code, starts, added }) {
+ const importRe =
+ /^[ \t]*import\s+(type\s+)?(?:[^;]*?)\bfrom\s*["']\.\/([^"'/]+?)(?:\.[jt]sx?)?["']/gm;
+ return [...text.matchAll(importRe)]
+ .filter((m) => isInternalValueImport(m, code))
+ .map((m) => lineOf(starts, m.index + m[0].length - 1))
+ .find((line) => added.has(line));
+}
+
+function isInternalValueImport(m, code) {
+ if (m[1] !== undefined || code[m.index + m[0].indexOf("import")] !== "i") return false;
+ return !ENTRY_FILES.has(m[2]) && !m[2].startsWith("test-");
+}
+
+function firstTestBlock(codeLines) {
+ const first = codeLines.findIndex((l) => /^\s*describe\b[\w.]*\s*\(/.test(l));
+ if (first !== -1) return first;
+ return codeLines.findIndex((l) => /^\s*(?:it|test)\b[\w.]*\s*\(/.test(l));
+}
+
+function contractFindings(changed) {
+ const source = sourceOf(changed);
+ return [...source.code.matchAll(/\bz\s*\.\s*(string|array|record)\s*\(/g)]
+ .filter((m) => unboundedAddedShape(source, m))
+ .map((m) => ({
+ path: source.file,
+ line: lineOf(source.starts, m.index + m[0].length - 1),
+ rule: "bound",
+ }));
+}
+
+function unboundedAddedShape(source, m) {
+ const open = m.index + m[0].length - 1;
+ if (!source.added.has(lineOf(source.starts, open))) return false;
+ const chain = chainMethods(source.code, closing(source.code, open));
+ if (BOUNDED_BY[m[1]].some((name) => chain.includes(name))) return false;
+ const statement = lineOf(source.starts, m.index);
+ return !hasWhyAbove(source.lines, source.codeLines, statement - 1, 1);
+}
+
+function chainMethods(code, from) {
+ const names = [];
+ const step = /^\s*\.\s*(\w+)\s*\(/;
+ let at = from;
+ for (;;) {
+ const m = step.exec(code.slice(at, at + 200));
+ if (m === null) return names;
+ names.push(m[1]);
+ at = closing(code, at + m[0].length - 1);
+ }
+}
+
+// why: the index just past the `)` matching the `(` at , or the end of the text.
+function closing(code, open) {
+ let depth = 0;
+ for (let i = open; i < code.length; i++) {
+ if (code[i] === "(") depth++;
+ else if (code[i] === ")" && --depth === 0) return i + 1;
+ }
+ return code.length;
+}
+
+// why: a `why:` counts in the lines above line index , or anywhere in the comment
+// block that ends right above it, so a long reason still reaches its describe.
+function hasWhyAbove(rawLines, codeLines, index, within = 3) {
+ for (let i = index - 1; i >= Math.max(0, index - within); i--) {
+ if (WHY.test(rawLines[i]) && codeLines[i] !== rawLines[i]) return true;
+ }
+ return whyInCommentBlock(rawLines, codeLines, index);
+}
+
+function whyInCommentBlock(rawLines, codeLines, index) {
+ for (let i = index - 1; i >= 0; i--) {
+ if (codeLines[i].trim() !== "" || rawLines[i].trim() === "") return false;
+ if (WHY.test(rawLines[i])) return true;
+ }
+ return false;
+}
+
+function lineStarts(text) {
+ const starts = [0];
+ for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1);
+ return starts;
+}
+
+function lineOf(starts, offset) {
+ let lo = 0;
+ let hi = starts.length - 1;
+ while (lo < hi) {
+ const mid = (lo + hi + 1) >> 1;
+ if (starts[mid] <= offset) lo = mid;
+ else hi = mid - 1;
+ }
+ return lo + 1;
+}
+
+function exists(file) {
+ if (head === undefined) return existsSync(file);
+ try {
+ execFileSync("git", ["cat-file", "-e", `${head}:${file}`], { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// why: blanks every comment and the inside of every string and template literal (keeping the
+// quotes, the `${}` code and every newline), so a pattern matched on the result is code: a lint
+// fixture written as a string in a test is not a finding. Each scanner reads one kind of token at
+// `s.i` and moves past it; what none of them claims is code.
+function mask(text) {
+ const s = { text, out: text.split(""), i: 0, braces: [], last: "" };
+ while (s.i < text.length) (SCANNERS.find((scanner) => scanner.at(s)) ?? CODE).scan(s);
+ return s.out.join("");
+}
+
+const SCANNERS = [
+ { at: (s) => s.text.startsWith("//", s.i), scan: (s) => skipComment(s, "\n", 0) },
+ { at: (s) => s.text.startsWith("/*", s.i), scan: (s) => skipComment(s, "*/", 2) },
+ { at: (s) => s.text[s.i] === '"' || s.text[s.i] === "'", scan: scanQuoted },
+ { at: (s) => s.text[s.i] === "`", scan: (s) => scanTemplate(s, s.i + 1) },
+ { at: (s) => s.text[s.i] === "}" && s.braces.at(-1) === 0, scan: resumeTemplate },
+ { at: (s) => s.text[s.i] === "/" && regexCanStart(s), scan: scanRegex },
+];
+const CODE = { scan: scanCode };
+
+function blank(s, from, to) {
+ for (let k = from; k < to; k++) if (s.out[k] !== "\n") s.out[k] = " ";
+}
+
+function skipComment(s, terminator, length) {
+ const found = s.text.indexOf(terminator, s.i + 2);
+ const stop = found === -1 ? s.text.length : found + length;
+ blank(s, s.i, stop);
+ s.i = stop;
+}
+
+function scanQuoted(s) {
+ const quote = s.text[s.i];
+ let j = s.i + 1;
+ while (j < s.text.length && s.text[j] !== quote && s.text[j] !== "\n") {
+ j += s.text[j] === "\\" ? 2 : 1;
+ }
+ blank(s, s.i + 1, Math.min(j, s.text.length));
+ s.i = j + 1;
+ s.last = quote;
+}
+
+function resumeTemplate(s) {
+ s.braces.pop();
+ scanTemplate(s, s.i + 1);
+}
+
+function scanTemplate(s, start) {
+ let j = start;
+ while (j < s.text.length && s.text[j] !== "`" && !s.text.startsWith("${", j)) {
+ j += s.text[j] === "\\" ? 2 : 1;
+ }
+ blank(s, start, Math.min(j, s.text.length));
+ if (s.text.startsWith("${", j)) s.braces.push(0);
+ s.i = Math.min(j + (s.text[j] === "`" ? 1 : 2), s.text.length);
+ s.last = "`";
+}
+
+function scanRegex(s) {
+ let j = s.i + 1;
+ let inClass = false;
+ while (j < s.text.length && s.text[j] !== "\n") {
+ const c = s.text[j];
+ if (c === "/" && !inClass) break;
+ if (c === "[" || c === "]") inClass = c === "[";
+ j += c === "\\" ? 2 : 1;
+ }
+ s.i = j + 1;
+ s.last = "/";
+}
+
+function scanCode(s) {
+ const c = s.text[s.i];
+ const top = s.braces.length - 1;
+ if (top >= 0 && (c === "{" || c === "}")) s.braces[top] += c === "{" ? 1 : -1;
+ if (!/\s/.test(c)) s.last = c;
+ s.i++;
+}
+
+function regexCanStart(s) {
+ if (s.last === "" || "(,=:[!&|?{};+-*%<>~^".includes(s.last)) return true;
+ return /\b(?:return|typeof|case|of|in|yield|await|void|delete|throw)$/.test(
+ s.text.slice(Math.max(0, s.i - 12), s.i).trimEnd(),
+ );
+}
+
+// why: the run comes last, so every rule table above is initialised before it is read.
+const args = process.argv.slice(2);
+let base = "origin/main";
+let head;
+const files = [];
+for (let i = 0; i < args.length; i++) {
+ if (args[i] === "--base") base = args[++i];
+ else if (args[i] === "--head") head = args[++i];
+ else if (args[i].startsWith("--")) usage();
+ else files.push(args[i]);
+}
+if (base === undefined || (args.includes("--head") && head === undefined)) usage();
+
+const findings = [];
+for (const file of changedFiles({ base, head, files: files.length > 0 ? files : null })) {
+ if (TEST_FILE.test(file.path)) findings.push(...testFindings(file));
+ else if (CONTRACT_FILE.test(file.path) && !HELPER_FILE.test(file.path)) {
+ findings.push(...contractFindings(file));
+ }
+}
+const seen = new Set();
+const unique = findings
+ .map((f) => `${f.path}:${f.line}: ${MESSAGES[f.rule]}`)
+ .filter((line) => !seen.has(line) && seen.add(line));
+for (const line of unique) console.log(line);
+if (unique.length > 0) {
+ console.error(`test-lint: ${unique.length} finding${unique.length === 1 ? "" : "s"}`);
+ process.exit(1);
+}
diff --git a/.agents/scripts/tool-guard.mjs b/.agents/scripts/tool-guard.mjs
index c28edb136..d60464923 100644
--- a/.agents/scripts/tool-guard.mjs
+++ b/.agents/scripts/tool-guard.mjs
@@ -30,13 +30,14 @@ const SCRIPTS = new Set([
"typecheck:e2e:types",
"lint",
"lint:comments",
+ "lint:tests",
"format",
"format:check",
"fallow",
"mutate",
]);
const TOOLS = new Set(["tsc", "oxlint", "depcruise", "oxfmt", "fallow", "stryker"]);
-const NODE_SCRIPTS = new Set(["comment-lint.mjs", "mutate.mjs"]);
+const NODE_SCRIPTS = new Set(["comment-lint.mjs", "test-lint.mjs", "mutate.mjs"]);
const WRAPPERS = new Set(["time", "nice", "env", "command", "exec"]);
const PNPM_VALUE_FLAGS = new Set(["-C", "--dir", "-F", "--filter", "--reporter"]);
const STAGES = new Set(["implementer", "reviewer", "hardware-verifier"]);
diff --git a/.dependency-cruiser-known-violations.json b/.dependency-cruiser-known-violations.json
index 6c591cc39..7e9d8b44b 100644
--- a/.dependency-cruiser-known-violations.json
+++ b/.dependency-cruiser-known-violations.json
@@ -174,5 +174,38 @@
"severity": "error",
"name": "past-index"
}
+ },
+ {
+ "type": "dependency",
+ "from": "src/cli/passthrough.ts",
+ "to": "child_process",
+ "unresolvedTo": "child_process",
+ "dependencyTypes": ["core", "dynamic-import"],
+ "rule": {
+ "severity": "error",
+ "name": "no-fs-or-process"
+ }
+ },
+ {
+ "type": "dependency",
+ "from": "src/history/testing.ts",
+ "to": "fs/promises",
+ "unresolvedTo": "fs/promises",
+ "dependencyTypes": ["core", "import"],
+ "rule": {
+ "severity": "error",
+ "name": "no-fs-or-process"
+ }
+ },
+ {
+ "type": "dependency",
+ "from": "src/http/server.ts",
+ "to": "fs",
+ "unresolvedTo": "fs",
+ "dependencyTypes": ["core", "import"],
+ "rule": {
+ "severity": "error",
+ "name": "no-fs-or-process"
+ }
}
]
diff --git a/.dependency-cruiser.mjs b/.dependency-cruiser.mjs
index 8724a5368..b835f91d9 100644
--- a/.dependency-cruiser.mjs
+++ b/.dependency-cruiser.mjs
@@ -112,12 +112,14 @@ export default {
"^src/(core|leasing)/",
{ path: "^src/drivers/" },
),
- direction(
- "no-fs-or-process",
- "core, leasing, agent-device, host-load, stream and alerts reach the filesystem and processes only through injected ports",
- "^src/(core|leasing|agent-device|host-load|stream|alerts)/",
- { dependencyTypes: ["core"], path: fsOrProcess },
- ),
+ {
+ name: "no-fs-or-process",
+ comment:
+ "application code reaches the filesystem and processes only through injected ports; src/ports/ holds the adapters, src/test-support/ is test-only",
+ severity: "error",
+ from: { path: "^src/", pathNot: `^src/(ports|test-support)/|${testFile}` },
+ to: { dependencyTypes: ["core"], path: fsOrProcess },
+ },
direction(
"not-agent-device",
"the core, leasing and the drivers import nothing from the agent-device edge",
diff --git a/.fallowrc.json b/.fallowrc.json
index bd6f2abc7..ee759eda4 100644
--- a/.fallowrc.json
+++ b/.fallowrc.json
@@ -8,8 +8,8 @@
// The web console is its own entry: Vite loads ui/index.html, which loads ui/src/main.tsx. The
// browser lane's specs are Playwright entry points, as the e2e tests are vitest's.
// vitest.mutate.config.ts is named only by stryker.config.mjs (`pnpm mutate`).
- // .agents/scripts/*.mjs are run by node from the hooks, the skills and the merge gate; two of
- // them (budget-guard, comment-lint) import from their neighbours (usage, comments).
+ // .agents/scripts/*.mjs are run by node from the hooks, the skills and the merge gate; three of
+ // them (budget-guard, comment-lint, test-lint) import from their neighbours (usage, comments).
// .dependency-cruiser.cycles.mjs is named only by src/lint-rules.test.ts, which runs depcruise
// with it (ADR 0027).
// vendor/screen-capture/scripts/unframe.mjs is a diagnostic a person runs by hand against a
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 18a9ac6c1..28607ae7b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -42,7 +42,7 @@ jobs:
- name: Typecheck
run: pnpm run typecheck && pnpm run typecheck:e2e
- name: Lint
- run: pnpm run lint && pnpm run lint:comments
+ run: pnpm run lint && pnpm run lint:comments && pnpm run lint:tests
- name: Format
run: pnpm run format:check
diff --git a/docs/internal/ARCHITECTURE.md b/docs/internal/ARCHITECTURE.md
index c798f8eb1..ab9add4fc 100644
--- a/docs/internal/ARCHITECTURE.md
+++ b/docs/internal/ARCHITECTURE.md
@@ -77,7 +77,7 @@ Arrows point from the optional to the required (architecture rule 15).
| `past-index` | Outside a component, import only its index file (tests may also import its testing file) |
| `core-not-leasing` | Core declares a port; the composition root connects leasing to it |
| `core-not-drivers` | Core and leasing reach a platform only through the `Driver` interface |
-| `no-fs-or-process` | Core, leasing, agent-device, host-load, stream and alerts use ports, never `fs` or `child_process` |
+| `no-fs-or-process` | Everything under `src/` but `ports/` uses ports, never `fs` or `child_process` |
| `definition-is-light` | A module's definition file imports its own module only for types or by dynamic import |
| `auth-imports-little` | `src/auth` imports only ports and contract |
| `modules-imports-little`, `modules-imports-bus-types-only` | `src/modules` imports only ports, contract and core, and the bus for types |
diff --git a/docs/internal/VENDORED-CODE.md b/docs/internal/VENDORED-CODE.md
index 5157d4a57..0d4a11f74 100644
--- a/docs/internal/VENDORED-CODE.md
+++ b/docs/internal/VENDORED-CODE.md
@@ -14,7 +14,6 @@ license text(s) they require under `LICENSES/`, and a `NOTICE.md`.
| What | Upstream | Version / commit | License | Where it lives | Consumed by | Re-verify with |
|---|---|---|---|---|---|---|
-| `@agent-device/proxy` — the transport proxy in front of an agent-device daemon, packed from a branch of agent-device that is not on npm | `callstack/agent-device`, `simlock-integration` branch | commit `253dca023edf13410f7928c76e9e604e5e5871a0` | MIT (Callstack) | `vendor/agent-device-proxy-0.21.28-dev-253dca0.tgz`; license inside the archive at `package/LICENSE` | `package.json`'s `"@agent-device/proxy": "file:vendor/…tgz"`, which `src/agent-device/host/` imports | `tar -tzf vendor/agent-device-proxy-*.tgz \| grep LICENSE`; the commit is what `scripts/slow-e2e.sh` checks a local checkout against, so keep it the first 40-char hash in `vendor/README.md` |
| serve-sim's capture and encode pipeline — CoreSimulator framebuffer capture and the H.264 encode path, patched by us, with four files rewritten by us that stay serve-sim's modified files | `EvanBacon/serve-sim` | commit `6d009686ed08f7d8bd19bca9f9f67a9237931f44` | Apache-2.0 for serve-sim's files and our rewrites of four of them; MIT for the files that are ours entirely | `vendor/screen-capture/Sources/`, licenses at `vendor/screen-capture/LICENSES/serve-sim.APACHE-2.0.txt` and `…/idevice.MIT.txt` | the `simlock-screen-capture` and `simlock-device-capture` programs `pnpm build` puts in dist/bin, run by `src/drivers/ios/screen-capture.ts` | the `curl … \| diff -u -` command in [`vendor/screen-capture/NOTICE.md`](../../vendor/screen-capture/NOTICE.md), per file |
| the HEVC capability string the iPhone's media-stream offer advertises for payload 100 — one string inside one of our own files | `jkcoxson/idevice` | `master` at the time of taking; the string is what its receiver documents | MIT | `vendor/screen-capture/LICENSES/idevice.MIT.txt`, the string in `vendor/screen-capture/Sources/SimlockMediaBridge/SimlockMediaBridge.m` | `simlock-device-capture`, whose offer a physical iPhone accepts without painting torn pictures | the string in `Sources/SimlockMediaBridge/SimlockMediaBridge.m` against idevice's receiver, and its `LICENSE.txt` at `https://raw.githubusercontent.com/jkcoxson/idevice/master/LICENSE.txt` |
| `scrcpy-server` — the Android screen-streaming server artifact, byte-identical to upstream's release asset | `Genymobile/scrcpy` | release `v5.0.1`; revision built `dc8a55a24c27ae78ae202b5a3288da681d0f141a` | Apache-2.0; the Kotlin standard library inside it adds BSD-3-Clause (ThreeTen-BP) and BSL-1.0 (Boost) code | `vendor/scrcpy/scrcpy-server-v5.0.1`, licenses at `vendor/scrcpy/LICENSES/scrcpy.APACHE-2.0.txt` and `vendor/scrcpy/LICENSES/threetenbp.BSD-3-Clause.txt`, digest in `vendor/scrcpy/SHA256SUMS.txt` | the Android driver's screen capture, which pushes it unmodified to a leased emulator; the client that drives it is ours, in `src/drivers/android/`, and is not vendored | `cd vendor/scrcpy && shasum -a 256 -c SHA256SUMS.txt`, and the same against upstream's own release `SHA256SUMS.txt`, whose GPG signature `vendor/scrcpy/NOTICE.md` says how to check |
@@ -39,11 +38,12 @@ reason they are not above.
|---|---|---|---|---|---|
| React, React DOM, Hono, `ws`, Zod, the MCP SDK, TanStack table/virtual, Recharts | npm | as `pnpm-lock.yaml` pins | per package | `node_modules/`, resolved from the lockfile | A registry gives us these, with their licenses; vendoring a tree we never patch only loses upstream's fixes |
| The Geist and Geist Mono variable fonts | `vercel/geist-font`, repackaged as `@fontsource-variable/geist` | `^5.3.0` | OFL-1.1 | `ui/src/main.tsx` imports them; the built console serves the `.woff2` files out of `dist/ui/assets/` | Installed from npm and bundled at build time. **Known gap:** the font license travels with the npm package but Vite does not copy it beside the emitted `.woff2`, and OFL-1.1 wants the license to accompany the font; the fix is to ship the license with the console build, not to vendor the fonts |
-| The agent-device CLI itself | `callstack/agent-device` | the commit above | MIT | a separate checkout, at the absolute path `SIMLOCK_E2E_AGENT_DEVICE` gives, on machines that run the slow lane | It is a tool we invoke, not code we carry; only its proxy is vendored, and `toolchain.md` says how to read its source at that commit |
+| `@agent-device/proxy` — the transport proxy in front of an agent-device daemon | npm (`callstack/agent-device`) | as `package.json` pins | MIT (Callstack) | `node_modules/@agent-device/proxy/`, resolved from the lockfile; `src/agent-device/host/` imports it | A registry gives us the released proxy with its license; nothing of it is a file in this repo |
+| The agent-device CLI itself | `callstack/agent-device` | the version `package.json` pins for `@agent-device/proxy` | MIT | a separate install, at the absolute path `SIMLOCK_E2E_AGENT_DEVICE` gives, on machines that run the slow lane | It is a tool we invoke, not code we carry; `toolchain.md` says how to read its source at that version |
-The license question for the first row is answered by npm, which ships each
-package's own license text next to it; nothing in that row is a file in this
-repo, which is what separates it from the three rows above.
+The license question for the npm rows is answered by npm, which ships each
+package's own license text next to it; nothing in those rows is a file in this
+repo, which is what separates them from the inventory above.
## External tools are not vendored
@@ -70,9 +70,7 @@ vendored while the Xcode that compiles it is not.
to the list in rule 4 of
[agent-rules/vendor-and-third-party-code.md](agent-rules/vendor-and-third-party-code.md).
A binary also gets `SHA256SUMS.txt` in `shasum -c` format.
-3. Add the row here and the directory to `vendor/README.md`'s index. Keep the
- agent-device commit the first 40-character hash in that file — the slow lane
- reads the first one it finds.
+3. Add the row here and the directory to `vendor/README.md`'s index.
4. `pnpm exec vitest run --project unit src/source-hygiene.test.ts` checks the
shape (license text, `NOTICE.md`, the digest, and that a vendored binary is
excluded from the text scan for a reason it can prove).
diff --git a/docs/internal/agent-rules/toolchain.md b/docs/internal/agent-rules/toolchain.md
index d3fd8106e..a4317f912 100644
--- a/docs/internal/agent-rules/toolchain.md
+++ b/docs/internal/agent-rules/toolchain.md
@@ -29,18 +29,19 @@ report until it ends.
Hooks and CI run these. Never run them by hand, and never the tools behind
them (from `node_modules/.bin` or any other path): `pnpm check`, `pnpm test`,
-`pnpm test:timing`, `pnpm typecheck`, `pnpm typecheck:e2e`, `pnpm typecheck:e2e:types`, `pnpm lint`, `pnpm lint:comments`,
+`pnpm test:timing`, `pnpm typecheck`, `pnpm typecheck:e2e`, `pnpm typecheck:e2e:types`, `pnpm lint`, `pnpm lint:comments`, `pnpm lint:tests`,
`pnpm format`, `pnpm format:check`,
`pnpm fallow`, `pnpm mutate` (outside the `harden-tests` routine), the whole
e2e suite, the console lane in every browser (`pnpm test:console`), `tsc`,
`oxlint`, `depcruise`, `oxfmt`, `fallow`, `stryker`, `.agents/scripts/comment-lint.mjs`
-(outside the `garden-comments` routine). A PreToolUse hook
+(outside the `garden-comments` routine), `.agents/scripts/test-lint.mjs`. A PreToolUse hook
(`.agents/scripts/tool-guard.mjs`) refuses them in every sub-agent; keep its
list and this one the same.
- **Commit hook:** formats the staged files and stages the result, then
- lint, the comment lint (architecture rule 17) and typecheck. Errors show
- in its output.
+ lint, the comment lint (architecture rule 17), the test lint (testing
+ rules 5, 6 and 10 and always-in-scope 8, on the lines the commit adds) and
+ typecheck. Errors show in its output.
- **Push hook:** Fallow, then the e2e typecheck.
- **CI:** what `pnpm check` runs, and Fallow, on every push. The `pnpm
check` steps run as parallel jobs (static checks, unit tests, the timing
diff --git a/lefthook.yml b/lefthook.yml
index f32012c03..1b7afa45f 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -25,6 +25,13 @@ pre-commit:
glob: "*.{ts,tsx,js,mjs,mts,cts,sh}"
run: node .agents/scripts/comment-lint.mjs --base HEAD {staged_files}
fail_text: "Delete the comment, or start it with 'why:' when it says why the code does something unexpected."
+ # A test line the commit adds keeps testing rules 5, 6 and 10, and a contract line bounds wire input (always-in-scope 8).
+ tests:
+ glob:
+ - "*.test.{ts,tsx}"
+ - "src/contract/*.ts"
+ run: node .agents/scripts/test-lint.mjs --base HEAD {staged_files}
+ fail_text: "Each line names the rule it breaks and what to write instead."
# A change can break the files that call it, so this checks the whole project; it takes seconds.
typecheck:
glob: "*.{ts,tsx,mts,cts}"
diff --git a/package.json b/package.json
index fecf75b22..901240856 100644
--- a/package.json
+++ b/package.json
@@ -63,11 +63,12 @@
"typecheck:e2e:types": "TURBO_TELEMETRY_DISABLED=1 turbo run build:daemon:run && tsc -p e2e/tsconfig.typecheck.json --incremental --tsBuildInfoFile node_modules/.cache/tsc/e2e.tsbuildinfo",
"lint": "oxlint src e2e ui && depcruise src e2e ui --ignore-known",
"lint:comments": "node .agents/scripts/comment-lint.mjs --base origin/main",
+ "lint:tests": "node .agents/scripts/test-lint.mjs --base origin/main",
"fallow": "fallow",
"format": "oxfmt --write .",
"format:check": "oxfmt --check .",
"prepare": "lefthook install",
- "check": "pnpm run typecheck && pnpm run typecheck:e2e && pnpm run lint && pnpm run lint:comments && pnpm run format:check && pnpm run test",
+ "check": "pnpm run typecheck && pnpm run typecheck:e2e && pnpm run lint && pnpm run lint:comments && pnpm run lint:tests && pnpm run format:check && pnpm run test",
"mutate": "node scripts/mutate.mjs",
"openapi": "TURBO_TELEMETRY_DISABLED=1 turbo run build:daemon:run && node scripts/openapi.mjs"
},
diff --git a/src/adr-index.test.ts b/src/adr-index.test.ts
index 3c76f986e..6d99737b4 100644
--- a/src/adr-index.test.ts
+++ b/src/adr-index.test.ts
@@ -6,6 +6,12 @@ import { describe, expect, it } from "vitest";
const ADR_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "docs", "internal", "adr");
+function statusOf(text: string): string | undefined {
+ return /^(?:Proposed|Accepted(?: — (?:not yet|partly) implemented)?|Superseded by \d{4}|Rejected)/.exec(
+ text,
+ )?.[0];
+}
+
// why: docs/internal/adr/README.md merges by union (.gitattributes), so two features' rows can
// land in any order and a row can go missing only when nobody wrote it; this proves the table.
describe("the ADR index", () => {
@@ -14,14 +20,33 @@ describe("the ADR index", () => {
.sort();
const rows = readFileSync(join(ADR_DIR, "README.md"), "utf8")
.split("\n")
- .map((line) => /^\| \[(\d{4})\]\(([^)]+)\) \|/.exec(line))
+ .map((line) => /^\| \[(\d{4})\]\(([^)]+)\) \|[^|]*\|([^|]*)\|/.exec(line))
.filter((match): match is RegExpExecArray => match !== null)
- .map((match) => ({ number: match[1], file: match[2] }));
+ .map((match) => ({ number: match[1], file: match[2], status: match[3]!.trim() }));
it("has one row per ADR file, naming that file", () => {
expect(rows.map((row) => row.file).sort()).toEqual(files);
});
+ it("gives each ADR the status its own Status line gives", () => {
+ const fileStatus = (file: string) =>
+ /^- \*\*Status:\*\* (.*)$/m.exec(readFileSync(join(ADR_DIR, file), "utf8"))?.[1] ?? "";
+ const differ = rows
+ .map((row) => ({
+ row: statusOf(row.status),
+ file: statusOf(fileStatus(row.file ?? "")),
+ number: row.number,
+ }))
+ .filter(({ row, file }) => row === undefined || row !== file)
+ .map(({ number, row, file }) => `${number}: index ${row}, file ${file}`);
+
+ expect(statusOf("Accepted — not yet implemented; §2 narrowed by 0046")).toBe(
+ "Accepted — not yet implemented",
+ );
+ expect(statusOf("Superseded by 0034 (per-home isolation)")).toBe("Superseded by 0034");
+ expect(differ).toEqual([]);
+ });
+
it("lists the rows in ascending order with no number twice", () => {
const numbers = rows.map((row) => row.number);
expect(numbers).toEqual([...new Set(numbers)].sort());
diff --git a/src/agent-device/host/agent-device-supervisor.test.ts b/src/agent-device/host/agent-device-supervisor.test.ts
index 0bd35192c..a89a344bb 100644
--- a/src/agent-device/host/agent-device-supervisor.test.ts
+++ b/src/agent-device/host/agent-device-supervisor.test.ts
@@ -498,7 +498,6 @@ describe("AgentDeviceSupervisor checking the install", () => {
versionRun("", { hangs: true }),
]) {
const harness = await arrange({ expectations: [expectation] });
- const started = Date.now();
await advancing(harness, harness.begin());
expect(unavailable(harness).reason).toBe("not-agent-device");
@@ -508,7 +507,6 @@ describe("AgentDeviceSupervisor checking the install", () => {
expect(unavailable(harness).message).toContain("timed out");
expect(harness.clock.now()).toBeGreaterThanOrEqual(1_000_000 + 10_000);
}
- expect(Date.now() - started).toBeLessThan(60_000);
}
expect(messages[0]).toContain("3");
expect(new Set(messages).size).toBe(3);
diff --git a/src/agent-device/host/health.test.ts b/src/agent-device/host/health.test.ts
index ec8ca89ba..cc996fdd2 100644
--- a/src/agent-device/host/health.test.ts
+++ b/src/agent-device/host/health.test.ts
@@ -20,13 +20,12 @@ afterEach(async () => {
});
async function serve(
- handler: (url: string | undefined) => { readonly body: string } | "never",
+ handler: (url: string | undefined) => { readonly body: string },
): Promise<{ readonly baseUrl: string; readonly urls: string[] }> {
const urls: string[] = [];
const server = createServer((request, response) => {
urls.push(request.url ?? "");
const answer = handler(request.url);
- if (answer === "never") return;
response.setHeader("content-type", "application/json");
response.end(answer.body);
});
@@ -53,12 +52,4 @@ describe("NodeAgentDeviceHealthProbe", () => {
await expect(new NodeAgentDeviceHealthProbe().read(baseUrl)).rejects.toThrow();
});
-
- it("rejects a server that never answers once its time limit passes", async () => {
- const { baseUrl } = await serve(() => "never");
- const started = Date.now();
-
- await expect(new NodeAgentDeviceHealthProbe(50).read(baseUrl)).rejects.toThrow();
- expect(Date.now() - started).toBeLessThan(2_000);
- });
});
diff --git a/src/agent-device/host/health.timing.test.ts b/src/agent-device/host/health.timing.test.ts
new file mode 100644
index 000000000..3222fb87e
--- /dev/null
+++ b/src/agent-device/host/health.timing.test.ts
@@ -0,0 +1,30 @@
+import { createServer, type Server } from "node:http";
+import type { AddressInfo } from "node:net";
+
+import { afterEach, describe, expect, it } from "vitest";
+
+import { NodeAgentDeviceHealthProbe } from "./health.js";
+
+const servers: Server[] = [];
+
+afterEach(async () => {
+ for (const server of servers.splice(0)) {
+ server.closeAllConnections();
+ await new Promise((resolve) => server.close(() => resolve()));
+ }
+});
+
+// why: the probe's limit is an AbortSignal timer on a real socket, so only real time shows that
+// it keeps the limit it was given rather than `DEFAULT_HEALTH_TIMEOUT_MS`.
+describe("NodeAgentDeviceHealthProbe", () => {
+ it("rejects a server that never answers once its time limit passes", async () => {
+ const server = createServer(() => undefined);
+ servers.push(server);
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ const started = Date.now();
+
+ await expect(new NodeAgentDeviceHealthProbe(50).read(baseUrl)).rejects.toThrow();
+ expect(Date.now() - started).toBeLessThan(2_000);
+ });
+});
diff --git a/src/bus/index.test.ts b/src/bus/index.test.ts
index 6239877fe..3722875d5 100644
--- a/src/bus/index.test.ts
+++ b/src/bus/index.test.ts
@@ -65,6 +65,11 @@ const EVENT_NAMES: Record = {
"warm-pool.target-missed": true,
};
+const CATALOGS = ["docs/internal/EVENTS.md", "website/src/content/docs/docs/reference/events.mdx"];
+
+// why: the facts whose past tense does not end in "ed"; a new one is a choice, so it is added here.
+const NOT_ED = new Set(["ready", "stopping", "shutdown"]);
+
/** The first-column `` `subject.fact` `` of every catalog table row in an EVENTS.md, sorted. */
function documentedEventNames(markdown: string): string[] {
return [...markdown.matchAll(/^\| `([a-z-]+\.[a-z-]+)` \|/gm)]
@@ -273,13 +278,13 @@ describe("EventBus", () => {
expect(bus.replay({ sinceTs: 110 }).map(({ seq }) => seq)).toEqual([3]);
});
- it.each(["docs/internal/EVENTS.md"])("has exactly the event names %s catalogs", (doc) => {
+ it.each(CATALOGS)("has exactly the event names %s catalogs", (doc) => {
expect(documentedEventNames(readFileSync(join(REPO_ROOT, doc), "utf8"))).toEqual(
Object.keys(EVENT_NAMES).sort(),
);
});
- it.each(["docs/internal/EVENTS.md"])(
+ it.each(CATALOGS)(
"lists lease-engine, cleanup-reaper and nuke among the device.deleted initiators in %s",
(doc) => {
const markdown = readFileSync(join(REPO_ROOT, doc), "utf8");
@@ -291,6 +296,17 @@ describe("EventBus", () => {
},
);
+ it("names every event `subject.past-tense-fact`, lowercase and dot-separated", () => {
+ const misshapen = Object.keys(EVENT_NAMES).filter((name) => {
+ const fact = name.split(".")[1]?.split("-").at(-1) ?? "";
+ return (
+ !/^[a-z][a-z-]*\.[a-z][a-z-]*$/.test(name) || !(fact.endsWith("ed") || NOT_ED.has(fact))
+ );
+ });
+
+ expect(misshapen).toEqual([]);
+ });
+
it("types every event's payload as an object", () => {
expectTypeOf().toMatchTypeOf>();
});
diff --git a/src/cli/reference.test.ts b/src/cli/reference.test.ts
new file mode 100644
index 000000000..27f43a98f
--- /dev/null
+++ b/src/cli/reference.test.ts
@@ -0,0 +1,33 @@
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+import { type CommandNode, COMMANDS } from "./tree.js";
+
+const PAGE = join(
+ dirname(fileURLToPath(import.meta.url)),
+ "..",
+ "..",
+ "website/src/content/docs/docs/reference/cli.mdx",
+);
+
+function paths(nodes: readonly CommandNode[], parent = ""): string[] {
+ return nodes.flatMap((node) => {
+ const path = `${parent}${node.name}`;
+ return [path, ...paths(node.children ?? [], `${path} `)];
+ });
+}
+
+// why: the command tree in tree.ts is the one list of what `simlock` accepts, and index.ts does
+// not hand it out; this reads it as data, a catalog check, not a test of the router.
+describe("the website's CLI reference", () => {
+ it("names every command and subcommand of the command tree as `simlock `", () => {
+ const page = readFileSync(PAGE, "utf8");
+ const all = paths(COMMANDS);
+
+ expect(all).toEqual(expect.arrayContaining(["lease renew", "daemon install", "alerts test"]));
+ expect(all.filter((path) => !new RegExp(`simlock ${path}(?![\\w-])`).test(page))).toEqual([]);
+ });
+});
diff --git a/src/contract/errors.test.ts b/src/contract/errors.test.ts
index ab4aeb882..1d966236e 100644
--- a/src/contract/errors.test.ts
+++ b/src/contract/errors.test.ts
@@ -1,3 +1,7 @@
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
import { describe, expect, it } from "vitest";
import {
@@ -8,6 +12,7 @@ import {
} from "./errors.js";
const TABLE: Readonly> = ERROR_TABLE;
+const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
describe("SimlockError", () => {
it("narrows details by code", () => {
@@ -186,3 +191,27 @@ describe("RELAY_TARGET_MISSING", () => {
}
});
});
+
+describe("the website's errors reference", () => {
+ const page = readFileSync(
+ join(REPO_ROOT, "website/src/content/docs/docs/reference/errors.mdx"),
+ "utf8",
+ );
+ const exitCodes = page.split(/^## /m).find((part) => part.startsWith("Exit codes")) ?? "";
+ const rows = [...exitCodes.matchAll(/^\| (\d+) \| ([^|]*) \|/gm)].map((match) => ({
+ exit: Number(match[1]),
+ codes: [...(match[2] ?? "").matchAll(/`([A-Z_]+)`/g)].map((code) => code[1]),
+ }));
+
+ it("lists every error code in exactly one exit-code row, the row of its CLI exit code", () => {
+ expect(rows.find((row) => row.exit === 1)?.codes).toContain("INTERNAL");
+ const misplaced = Object.values(ERROR_TABLE).flatMap(({ code, cliExitCode }) => {
+ const holding = rows.filter((row) => row.codes.includes(code)).map((row) => row.exit);
+ return holding.length === 1 && holding[0] === cliExitCode
+ ? []
+ : [`${code}: exit ${cliExitCode}, listed under ${holding.join(", ") || "no row"}`];
+ });
+
+ expect(misplaced).toEqual([]);
+ });
+});
diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts
index 3b73dea00..2f67a46f9 100644
--- a/src/daemon/dispatcher.test.ts
+++ b/src/daemon/dispatcher.test.ts
@@ -3886,13 +3886,9 @@ describe("Dispatcher: device.exec", () => {
// Let the real child actually spawn and write its one chunk before the timeout fires,
// so the pending delivery this test depends on genuinely exists.
await new Promise((resolve) => setTimeout(resolve, 200));
- const advancedAt = Date.now();
clock.advance(50);
await expect(pending).rejects.toMatchObject({ code: "EXEC_TIMEOUT" });
- expect(Date.now() - advancedAt, "rejected only after the default 5 s cap").toBeLessThan(
- 2_500,
- );
}, 30_000);
});
});
diff --git a/src/docs-audience.test.ts b/src/docs-audience.test.ts
index 89c7730c6..a9d918240 100644
--- a/src/docs-audience.test.ts
+++ b/src/docs-audience.test.ts
@@ -5,6 +5,8 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
+import { blankSource } from "./test-support/source-text.js";
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
/** Every tracked path, from the git index, so no doc escapes by sitting in a directory the test did not name. */
@@ -114,3 +116,52 @@ describe("the markdown files under docs/", () => {
).toEqual([]);
});
});
+
+// why: what documentation rule 3 forbids the running tool to print: a maintainer doc, or a path
+// into this checkout. `SKILL.md` alone is a file `simlock setup` writes, so it is no repo path.
+const REPO_PATH =
+ /docs\/internal|agent-rules|\bAGENTS\.md|\bCLAUDE\.md|\b(?:src|docs|e2e|scripts)\/[\w/.-]+\.(?:md|ts)\b/;
+
+const printingSources = trackedFiles.filter(
+ (name) =>
+ /^(?:src|ui\/src)\/.+\.tsx?$/.test(name) &&
+ !/\.test\.tsx?$|(?:^|\/)testing\.ts$|(?:^|\/)test-[^/]*$/.test(name),
+);
+
+function repoPaths(name: string, text: string): string[] {
+ return blankSource(text)
+ .split("\n")
+ .flatMap((line, index) => {
+ const match = REPO_PATH.exec(line);
+ return match === null ? [] : [`${name}:${index + 1}: ${match[0]}`];
+ });
+}
+
+describe("the source of what the tool prints", () => {
+ it("is every non-test source file under src/ and ui/src/", () => {
+ expect(printingSources).toEqual(
+ expect.arrayContaining(["src/cli/tree.ts", "src/instructions/setup.ts", "ui/src/main.tsx"]),
+ );
+ expect(printingSources.filter((name) => /\.test\.|testing\.ts$/.test(name))).toEqual([]);
+ });
+
+ it("names no file path in this repository in a string, a template or JSX text", () => {
+ const sample = [
+ "// why: docs/internal/EVENTS.md says so",
+ 'throw usage("see docs/internal/EVENTS.md");',
+ "const url = `${base}/simlock/docs/reference/events/`; /* src/a.ts */",
+ "const re = /\\//; const skill = 'SKILL.md';",
+ "return ]read src/core/AGENTS.md
;",
+ ].join("\n");
+ expect(repoPaths("sample.tsx", sample)).toEqual([
+ "sample.tsx:2: docs/internal",
+ "sample.tsx:5: src/core/AGENTS.md",
+ ]);
+
+ expect(
+ printingSources.flatMap((name) =>
+ repoPaths(name, readFileSync(join(REPO_ROOT, name), "utf8")),
+ ),
+ ).toEqual([]);
+ });
+});
diff --git a/src/docs-manuals.test.ts b/src/docs-manuals.test.ts
index 753cca3c5..e5f15c400 100644
--- a/src/docs-manuals.test.ts
+++ b/src/docs-manuals.test.ts
@@ -61,6 +61,33 @@ function brokenLinks(doc: string): string[] {
);
}
+function privateMembers(doc: string): string[] {
+ return lines(doc).flatMap((text, index) =>
+ [...text.matchAll(/`#[A-Za-z]\w*/g)].map((match) => `${doc}:${index + 1}: ${match[0]}\``),
+ );
+}
+
+function emptySections(doc: string, text = lines(doc)): string[] {
+ const found: string[] = [];
+ let open: { level: number; line: number; empty: boolean } | undefined;
+ let fenced = false;
+ const close = (level: number) => {
+ if (open !== undefined && open.empty && level <= open.level) {
+ found.push(`${doc}:${open.line}`);
+ }
+ };
+ text.forEach((line, index) => {
+ if (line.startsWith("```")) fenced = !fenced;
+ const heading = fenced ? null : /^(#{2,}) /.exec(line);
+ if (heading !== null) {
+ close(heading[1]!.length);
+ open = { level: heading[1]!.length, line: index + 1, empty: true };
+ } else if (open !== undefined && line.trim() !== "") open.empty = false;
+ });
+ close(2);
+ return found;
+}
+
describe("module manuals, flow docs and the architecture map", () => {
it("are found, the leasing manual and the startup flow among them", () => {
expect(manuals).toContain("src/leasing/AGENTS.md");
@@ -94,6 +121,29 @@ describe("module manuals, flow docs and the architecture map", () => {
expect([...manuals, ...flows, MAP].flatMap(missingPaths)).toEqual([]);
});
+ it("name files and exported names, never a private member", () => {
+ expect(privateMembers("docs/internal/templates/manual.md")).toEqual([
+ "docs/internal/templates/manual.md:12: `#field`",
+ ]);
+ expect([...manuals, ...flows].flatMap(privateMembers)).toEqual([]);
+ });
+
+ it("each manual opens with its Does, Entry and Uses lines", () => {
+ const missing = manuals.flatMap((manual) =>
+ ["Does", "Entry", "Uses"]
+ .filter((key) => !lines(manual).some((text) => text.startsWith(`${key}: `)))
+ .map((key) => `${manual}: ${key}`),
+ );
+
+ expect(missing).toEqual([]);
+ });
+
+ it("hold no empty section", () => {
+ const sample = ["## Files", "", "## How it works", "### Steps", "1. Start", "## Rules"];
+ expect(emptySections("sample.md", sample)).toEqual(["sample.md:1", "sample.md:6"]);
+ expect([...manuals, ...flows].flatMap((doc) => emptySections(doc))).toEqual([]);
+ });
+
it("link only to files that exist", () => {
expect([...manuals, ...flows, MAP].flatMap(brokenLinks)).toEqual([]);
});
diff --git a/src/gateway/AGENTS.md b/src/gateway/AGENTS.md
index de2da5d96..a1b9e5f15 100644
--- a/src/gateway/AGENTS.md
+++ b/src/gateway/AGENTS.md
@@ -101,7 +101,7 @@ missing from 2 snapshots in a row → dropped; kept across a disconnect, forgott
**Reads.** `status.get`, `catalog.get`, `list.get`, `lease.list` (admin: all; else own) and `lease.get`
(one lease, as `lease.list` would hold it for the caller) answer from views; a gateway lease record
-also carries `workerLeaseId`, the worker's own id (not on `status.get`). `inbox.get` runs `inboxItems` (contract) over the worker views, `#fleetLeases()`, `#fleetWaiting()` and the token list (an unavailable store leaves holders named by id). `worker.install-component` fans out. `nuke.run`, `cleanup.run`, `doctor.run`, `driver.passthrough`, `component.*`, `device.add`, `device.remove` → `UNSUPPORTED_IN_GATEWAY_MODE`. `usage.get` reads this gateway's `history.db` rows through `core/usage`: fleet totals, one entry per registry or named worker; `dispose` stops its read thread.
+also carries `workerLeaseId`, the worker's own id (not on `status.get`). `inbox.get` runs `inboxItems` (contract) over the worker views, the fleet's leases and waiting requests (what `list.get` returns for `leases` and `requests`) and the token list (an unavailable store leaves holders named by id). `worker.install-component` fans out. `nuke.run`, `cleanup.run`, `doctor.run`, `driver.passthrough`, `component.*`, `device.add`, `device.remove` → `UNSUPPORTED_IN_GATEWAY_MODE`. `usage.get` reads this gateway's `history.db` rows through `core/usage`: fleet totals, one entry per registry or named worker; `dispose` stops its read thread.
**Uplink drop.** View → `disconnected`, kept; `worker.disconnected`. In-flight calls → `WORKER_UNREACHABLE`, an in-flight lease request → `worker-failed`. `token.revoke` closes its uplinks.
diff --git a/src/history/AGENTS.md b/src/history/AGENTS.md
index bf39247ca..03f7f5a7b 100644
--- a/src/history/AGENTS.md
+++ b/src/history/AGENTS.md
@@ -22,7 +22,7 @@ Flows: [startup](../../docs/internal/flows/startup.md). Catalog: [EVENTS.md](../
1. **Create** — the factory subscribes to the bus and touches no file. `status()` is `{ opened: false, writing: false }`; `replay` answers the ring.
2. **Start** — `start()` runs once the daemon holds its socket: load `node:sqlite`, open `history.db`, `busy_timeout=5000`, `auto_vacuum=INCREMENTAL`, `journal_mode=WAL`, `synchronous=NORMAL`, schema, `quick_check`. Then each projection is migrated to its code's version (see Projections). A file that fails to open or check is set aside (`history.db.damaged-`, `-wal` and `-shm` first, main file last), and a fresh one opens.
-3. **Catch up** — the ring's events are written in `seq` order, not time order (`INSERT OR IGNORE` by `id`; `index.ts` `#start`). After a set-aside only the events seen since the factory ran are. A projection whose check failed takes no step meanwhile.
+3. **Catch up** — the ring's events are written in `seq` order, not time order (`INSERT OR IGNORE` by `id`; `start()` in `index.ts`). After a set-aside only the events seen since the factory ran are. A projection whose check failed takes no step meanwhile.
4. **Write** — each event is one transaction: `BEGIN`, insert the row, run every projection's `apply` when the row is new, `COMMIT`.
5. **Prune** — after start and every hour: delete rows older than `eventLog.retention`, then the oldest 1 000 at a time while the stored size is over `eventLog.maxBytes`, then each projection's `prune` (rows older than `metrics.retention`), then `incremental_vacuum` and `wal_checkpoint(TRUNCATE)`.
6. **Read** — `replay({ sinceTs?, workerId? })` selects `timestamp > ?` ordered by timestamp, seq, rowid; a row whose id fails `EVENT_ID_PATTERN` is skipped. `openHistoryReader` does the same read-only for the CLI when no daemon answers; a missing file is empty, an unreadable one throws. `replayPage({ beforeId?, limit, workerId? })` never throws: with `beforeId` it selects the `limit` rows before that row's `(timestamp, seq, rowid)` and returns them oldest first (`not-kept` with the oldest row's timestamp when the id is not stored and the table has rows, `unavailable` when the file is not readable or not writing or the read fails); without it, the newest `limit` rows, or the ring's newest when the file is not readable or not writing or the read fails.
diff --git a/src/leasing/AGENTS.md b/src/leasing/AGENTS.md
index 6539b51f2..e3f7501ef 100644
--- a/src/leasing/AGENTS.md
+++ b/src/leasing/AGENTS.md
@@ -27,7 +27,7 @@ Flows: [device selection](../../docs/internal/flows/device-selection.md), [start
## The update hold
-`holdForUpdate()` (on the `Leasing` handle, typed `UpdateHold`) closes admission and refuses every request not provisioning right now, whatever its state (`new`, queued, re-queued, or retrying after a failed provision), with `DaemonUpdatingError` (`DAEMON_UPDATING`, `lease.rejected` reason `updating`); `releaseUpdateHold()` reopens it. It is apart from `beginMaintenance` so the warm pool and reclaim go on: nuke stops them, an update does not. Renew and release never go through admission, so held leases work as usual. A request already provisioning when the hold begins is granted; the check is `#refuseUnderHold`, before every provision starts.
+`holdForUpdate()` (on the `Leasing` handle, typed `UpdateHold`) closes admission and refuses every request not provisioning right now, whatever its state (`new`, queued, re-queued, or retrying after a failed provision), with `DaemonUpdatingError` (`DAEMON_UPDATING`, `lease.rejected` reason `updating`); `releaseUpdateHold()` reopens it. It is apart from `beginMaintenance` so the warm pool and reclaim go on: nuke stops them, an update does not. Renew and release never go through admission, so held leases work as usual. A request already provisioning when the hold begins is granted; the check runs in `lease-acquisition-coordinator.ts` before every provision starts.
## How it works
diff --git a/src/lint-rules.test.ts b/src/lint-rules.test.ts
index 66df24d9a..0ba983e63 100644
--- a/src/lint-rules.test.ts
+++ b/src/lint-rules.test.ts
@@ -15,6 +15,8 @@ import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { blankSource } from "./test-support/source-text.js";
+
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const oxlint = join(repoRoot, "node_modules", ".bin", "oxlint");
const depcruise = join(repoRoot, "node_modules", ".bin", "depcruise");
@@ -183,11 +185,14 @@ const pathCases: readonly (readonly [string, readonly Expectation[]])[] = [
],
],
[
- "passes a core or agent-device test file importing node:fs, and fails the same import in a core file",
+ "passes a test file or a ports adapter importing node:fs, and fails the same import anywhere else in src/",
[
["passes", "src/core/x.test.ts", "node:fs/promises"],
["fails", "src/core/x.ts", "node:fs/promises"],
["passes", "src/agent-device/host/x.test.ts", "node:fs"],
+ ["fails", "src/gateway/x.ts", "node:fs"],
+ ["fails", "src/cli/x.ts", "node:child_process"],
+ ["passes", "src/ports/x.ts", "node:fs"],
],
],
[
@@ -786,7 +791,14 @@ describe("the repo's known violations", () => {
expect(found.map(key).sort()).toEqual(
read(".dependency-cruiser-known-cycles.json").map(key).sort(),
);
- });
+ }, 60_000);
+
+ it("lists every file outside src/ports/ that imports fs or child_process, and no file that stopped", () => {
+ const rule = (v: Violation) => v.rule.name === "no-fs-or-process";
+ expect(cruise(repoRoot, ["src"]).filter(rule).map(key).sort()).toEqual(
+ read(".dependency-cruiser-known-violations.json").filter(rule).map(key).sort(),
+ );
+ }, 60_000);
it("never grows past what the repo had when dependency-cruiser was adopted", () => {
expect(read(".dependency-cruiser-known-violations.json").length).toBeLessThanOrEqual(24);
@@ -794,3 +806,36 @@ describe("the repo's known violations", () => {
expect(read(".dependency-cruiser-known-cycles.json").length).toBeLessThanOrEqual(8);
});
});
+
+// why: architecture rule 9's clock half, which no import rule can see: application code reads the
+// time and sets timers through the Clock port. The list holds what the repo had when the check
+// came in; a fix removes its entry, and nothing joins it.
+const KNOWN_TIME_READS: Readonly> = { "src/core/usage/index.ts": 1 };
+const TIME_READ = /\bDate\.now\(|\bnew Date\(\s*\)|\bset(?:Timeout|Interval)\(/g;
+
+const timeReads = (text: string): number =>
+ blankSource(text, { strings: true }).match(TIME_READ)?.length ?? 0;
+
+describe("the time outside src/ports/", () => {
+ it("is read, and timers set, only through the Clock port, apart from a known list that only shrinks", () => {
+ expect(
+ timeReads(
+ "// Date.now()\nconst at = Date.now(); new Date(); setTimeout(f, 1); 'setInterval('",
+ ),
+ ).toBe(3);
+ const sources = spawnSync("git", ["ls-files", "src/*.ts", "src/*.tsx"], {
+ cwd: repoRoot,
+ encoding: "utf8",
+ })
+ .stdout.split("\n")
+ .filter((name) => name !== "" && !name.startsWith("src/ports/") && !/test/.test(name));
+ const found = Object.fromEntries(
+ sources
+ .map((name) => [name, timeReads(readFileSync(join(repoRoot, name), "utf8"))] as const)
+ .filter(([, count]) => count > 0),
+ );
+
+ expect(sources).toEqual(expect.arrayContaining(["src/core/registry.ts", "src/daemon/main.ts"]));
+ expect(found).toEqual(KNOWN_TIME_READS);
+ });
+});
diff --git a/src/source-hygiene.test.ts b/src/source-hygiene.test.ts
index 74e3aa626..47c461a66 100644
--- a/src/source-hygiene.test.ts
+++ b/src/source-hygiene.test.ts
@@ -287,3 +287,56 @@ describe("a file at vendor/'s root", () => {
expect(rootFiles).toEqual(["vendor/README.md"]);
});
});
+
+/**
+ * why: `docs/internal/VENDORED-CODE.md` and `vendor/README.md` are the inventory a reader trusts
+ * to list what `vendor/` carries (vendor and third-party code rule 11), so both are checked against
+ * the directories on disk, and every `vendor/` path an inventory row names must still be there.
+ */
+describe("the vendored inventory", () => {
+ const inventory = readFileSync(join(REPO_ROOT, "docs/internal/VENDORED-CODE.md"), "utf8");
+ const section = inventory.split(/^## /m).find((part) => part.startsWith("The inventory")) ?? "";
+ const cells = (row: string): string[] =>
+ row
+ .replace(/^\||\|$/g, "")
+ .split(/(? cell.trim());
+ const [header = "", , ...rows] = section.split("\n").filter((line) => line.startsWith("|"));
+ const where = cells(header).indexOf("Where it lives");
+ const backticked = (text: string): string[] =>
+ [...text.matchAll(/`([^`]+)`/g)].map((match) => match[1] ?? "");
+
+ it("was parsed: a row per inventory entry, with a Where it lives column", () => {
+ expect(where).toBeGreaterThan(0);
+ expect(rows.length).toBeGreaterThanOrEqual(vendoredDirs.size);
+ });
+
+ it("names every first-level directory under vendor/ in a row's Where it lives column and in vendor/README.md", () => {
+ const placed = rows.flatMap((row) => backticked(cells(row)[where] ?? ""));
+ const index = readFileSync(join(REPO_ROOT, "vendor/README.md"), "utf8");
+ const missing = [...vendoredDirs.keys()].flatMap((dir) => [
+ ...(placed.some((path) => path.startsWith(`vendor/${dir}/`))
+ ? []
+ : [`VENDORED-CODE.md: vendor/${dir}/`]),
+ ...(index.includes(`\`${dir}/\``) ? [] : [`vendor/README.md: ${dir}/`]),
+ ]);
+
+ expect(missing).toEqual([]);
+ });
+
+ it("names only vendor/ paths that exist", () => {
+ const gone = rows
+ .flatMap(backticked)
+ .filter((token) => /^vendor\/\S+$/.test(token))
+ .filter((path) => {
+ try {
+ statSync(join(REPO_ROOT, path));
+ return false;
+ } catch {
+ return true;
+ }
+ });
+
+ expect(gone).toEqual([]);
+ });
+});
diff --git a/src/test-lint.test.ts b/src/test-lint.test.ts
new file mode 100644
index 000000000..35563ebde
--- /dev/null
+++ b/src/test-lint.test.ts
@@ -0,0 +1,229 @@
+import { execFileSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+const SCRIPT = join(
+ dirname(fileURLToPath(import.meta.url)),
+ "..",
+ ".agents",
+ "scripts",
+ "test-lint.mjs",
+);
+
+function write(dir: string, files: Record): void {
+ for (const [path, text] of Object.entries(files)) {
+ mkdirSync(dirname(join(dir, path)), { recursive: true });
+ writeFileSync(join(dir, path), text);
+ }
+}
+
+function repo(before: Record, after: Record): string {
+ const dir = mkdtempSync(join(tmpdir(), "test-lint-"));
+ const git = (...args: string[]) =>
+ execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...args], { cwd: dir });
+ git("init", "-q", "-b", "base");
+ write(dir, { "README.md": "# Base\n", ...before });
+ git("add", "-A");
+ git("commit", "-q", "-m", "base");
+ git("switch", "-q", "-c", "work");
+ write(dir, after);
+ git("add", "-A");
+ git("commit", "-q", "--allow-empty", "-m", "head");
+ return dir;
+}
+
+function lint(dir: string, ...args: string[]) {
+ try {
+ const stdout = execFileSync("node", [SCRIPT, "--base", "base", "--head", "HEAD", ...args], {
+ cwd: dir,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ return { status: 0, lines: stdout.split("\n").filter((l) => l !== "") };
+ } catch (error) {
+ const failed = error as { status: number; stdout: string };
+ return { status: failed.status, lines: failed.stdout.split("\n").filter((l) => l !== "") };
+ }
+}
+
+const file = (...lines: string[]) => `${lines.join("\n")}\n`;
+
+const TIME =
+ "a bound on real time goes in a *.timing.test.ts file or the slow lane; here drive a fake clock and assert the exact value (testing 5)";
+const SKIP =
+ "never skip, focus or defer a test to get to green; fix it or delete it, and gate a live-device test on a SIMLOCK_ variable (testing 5)";
+const VACUOUS =
+ "this assertion also passes on a failure; assert the exact value, or the named error and its code (testing 10)";
+const INTERNAL =
+ 'a test of an internal file says which reason holds in a "why:" comment above its first describe (pure logic with many cases, or an ordering the surface cannot force), or tests through index.ts (testing 6)';
+const BOUND =
+ 'bound this wire input with .max(), .length() or .regex(), or say why it needs none in a "why:" comment right above (always-in-scope 8)';
+
+const LEASING_INDEX = { "src/leasing/index.ts": "export const leasing = 1;\n" };
+
+describe("test-lint.mjs", () => {
+ it.each([
+ {
+ rule: "testing 5, a real-time bound",
+ before: {},
+ failing: {
+ "src/a/health.test.ts": file(
+ "it('answers in time', () => {",
+ " const started = Date.now();",
+ " expect(Date.now() - started).toBeLessThan(2_000);",
+ "});",
+ "it('answers fast', () => {",
+ " const elapsed = performance.now();",
+ " expect(elapsed).toBeGreaterThan(0);",
+ "});",
+ ),
+ },
+ expected: [`src/a/health.test.ts:3: ${TIME}`, `src/a/health.test.ts:7: ${TIME}`],
+ passing: {
+ "src/a/health.timing.test.ts": file(
+ "const started = Date.now();",
+ "expect(Date.now() - started).toBeLessThan(2_000);",
+ ),
+ "e2e/slow-ios.test.ts": file("expect(Date.now() - started).toBeLessThan(60_000);"),
+ "src/a/clock.test.ts": file(
+ "const at = Date.now();",
+ "clock.advance(2_000);",
+ "expect(clock.now()).toBe(3_000);",
+ "expect(connection.serverNow() - Date.now()).toBe(90_000);",
+ ),
+ },
+ },
+ {
+ rule: "testing 5, a skipped or focused test",
+ before: {},
+ failing: {
+ "src/a/grant.test.ts": file(
+ "it.skip('grants a lease', () => {});",
+ "describe.only('the queue', () => {});",
+ "it.skipIf(slow)('reclaims', () => {});",
+ "it.todo('releases');",
+ ),
+ },
+ expected: [1, 2, 3, 4].map((n) => `src/a/grant.test.ts:${n}: ${SKIP}`),
+ passing: {
+ "src/a/live.test.ts": file(
+ "const live = process.env.SIMLOCK_LIVE_ANDROID === '1' ? it : it.skip;",
+ "it.skipIf(process.platform !== 'darwin')('builds on a Mac', () => {});",
+ ),
+ "e2e/slow-android.test.ts": file(
+ "describe.skipIf(!hasAndroidSdk)('on an emulator', () => {});",
+ ),
+ },
+ },
+ {
+ rule: "testing 10, an assertion a failure satisfies",
+ before: {},
+ failing: {
+ "src/a/fail.test.ts": file(
+ "expect(failure).toBeInstanceOf(Error);",
+ "expect(state).not.toBe('pending');",
+ "expect(recorded[0]?.()).toBe(1);",
+ ),
+ },
+ expected: [1, 2, 3].map((n) => `src/a/fail.test.ts:${n}: ${VACUOUS}`),
+ passing: {
+ "src/a/fail.test.ts": file(
+ "expect(failure).toMatchObject({ code: 'PROVISION_FAILED' });",
+ "const answer = handlers.get('x')?.(1);",
+ "expect(answer).toBe(2);",
+ "await expect(",
+ " run({ onOutput: (chunk) => sink?.(chunk) }),",
+ ").resolves.toBe(0);",
+ ),
+ },
+ },
+ {
+ rule: "testing 6, a test of an internal file",
+ before: LEASING_INDEX,
+ failing: {
+ "src/leasing/wait-queue.test.ts": file(
+ "import { describe, it } from 'vitest';",
+ "",
+ "import { plan } from './wait-queue.js';",
+ "",
+ "describe('the wait queue', () => {",
+ " it('plans', () => plan());",
+ "});",
+ ),
+ },
+ expected: [`src/leasing/wait-queue.test.ts:3: ${INTERNAL}`],
+ passing: {
+ "src/leasing/planner.test.ts": file(
+ "import { plan } from './planner.js';",
+ "",
+ "// why: pure planner, fourteen cases",
+ "describe('the planner', () => {});",
+ ),
+ "src/leasing/index.test.ts": file(
+ "import { leasing } from './index.js';",
+ "import { fakeQueue } from './testing.js';",
+ "import { definition } from './definition.js';",
+ "import type { Queue } from './queue.js';",
+ "describe('leasing', () => {});",
+ ),
+ },
+ },
+ {
+ rule: "always-in-scope 8, unbounded wire input",
+ before: {},
+ failing: {
+ "src/contract/x.ts": file(
+ "export const a = z.object({",
+ " name: z.string().min(1),",
+ " tags: z.array(z.string().max(10)),",
+ " env: z.record(z.string().max(5), z.string().max(5)),",
+ "});",
+ ),
+ },
+ expected: [2, 3, 4].map((n) => `src/contract/x.ts:${n}: ${BOUND}`),
+ passing: {
+ "src/contract/x.ts": file(
+ "export const a = z.object({",
+ " id: z.string().regex(/^[a-z]+$/),",
+ " name: z",
+ " .string()",
+ " .max(NAME_MAX)",
+ " .optional(),",
+ " tags: z.array(z.string().length(4)).max(10),",
+ " // why: the keys are the environment's own names, each value bounded",
+ " env: z.record(z.string().max(5), z.string().max(5)),",
+ "});",
+ ),
+ "src/contract/x.test.ts": file("const loose = z.string();"),
+ },
+ },
+ ])(
+ "$rule: names the rule and what to do on each added line that breaks it, and passes the forms it allows",
+ ({ before, failing, expected, passing }) => {
+ expect(lint(repo(before, failing))).toEqual({ status: 1, lines: expected });
+ expect(lint(repo(before, passing))).toEqual({ status: 0, lines: [] });
+ },
+ );
+
+ it("judges only the lines the diff adds in the files given, and never a pattern inside a string or comment", () => {
+ const dir = repo(
+ { "src/a/old.test.ts": file("it.skip('old', () => {});") },
+ {
+ "src/a/old.test.ts": file(
+ "it.skip('old', () => {});",
+ "const fixture = \"it.only('x', () => {}); expect(e).toBeInstanceOf(Error);\";",
+ "// it.only('commented out')",
+ "const template = `${name} it.skip(`;",
+ ),
+ "src/a/other.test.ts": file("it.only('focused', () => {});"),
+ },
+ );
+
+ expect(lint(dir, "src/a/old.test.ts")).toEqual({ status: 0, lines: [] });
+ expect(lint(dir)).toEqual({ status: 1, lines: [`src/a/other.test.ts:1: ${SKIP}`] });
+ });
+});
diff --git a/src/test-support/AGENTS.md b/src/test-support/AGENTS.md
index 41d08b423..1cfb65ba0 100644
--- a/src/test-support/AGENTS.md
+++ b/src/test-support/AGENTS.md
@@ -1,10 +1,12 @@
# Test support
Does: test-only helpers shared across modules; excluded from the build.
-Entry: `imports.ts` → `importsOf`, `importSpecifiers`, `normalizeSpecifier`; `promise-state.ts` → `promiseState`.
-Uses: Node built-ins only. Used by: module-boundary tests and fake-clock timing tests.
+Entry: `imports.ts` → `importsOf`, `importSpecifiers`, `normalizeSpecifier`; `promise-state.ts` → `promiseState`; `source-text.ts` → `blankSource`.
+Uses: Node built-ins only. Used by: module-boundary tests, fake-clock timing tests and the repo-wide source greps.
- `imports.ts` is the one import scanner: static, side-effect, re-export and dynamic imports,
comments stripped, specifiers normalized to `src/`.
+- `blankSource` blanks a source file's comments (and with `strings`, its string and template
+ text), keeping every newline, so a repo-wide grep matches only code at the right line.
- `promiseState` reads whether a promise settled without awaiting it, so a broken timer fails
on an assertion, not a test timeout.
diff --git a/src/test-support/source-text.ts b/src/test-support/source-text.ts
new file mode 100644
index 000000000..d509a17e5
--- /dev/null
+++ b/src/test-support/source-text.ts
@@ -0,0 +1,104 @@
+// why: a repo-wide test that greps source for a pattern must not match a comment that cites it,
+// and some must not match a string either; regex-only stripping (`imports.ts`'s `stripComments`)
+// reads `"https://…"` as a comment and `"src/**/*.ts"` as a block comment. This walks strings,
+// template literals and regex literals, and keeps every newline, so a line number still holds.
+// Each scanner reads one kind of token at `s.i` and moves past it; what none claims is code.
+export function blankSource(text: string, { strings = false } = {}): string {
+ const s: Scan = { text, out: text.split(""), i: 0, braces: [], last: "", strings };
+ while (s.i < text.length) (SCANNERS.find((scanner) => scanner.at(s)) ?? CODE).scan(s);
+ return s.out.join("");
+}
+
+interface Scan {
+ readonly text: string;
+ readonly out: string[];
+ readonly braces: number[];
+ readonly strings: boolean;
+ i: number;
+ last: string;
+}
+
+interface Scanner {
+ readonly at: (s: Scan) => boolean;
+ readonly scan: (s: Scan) => void;
+}
+
+const SCANNERS: readonly Scanner[] = [
+ { at: (s) => s.text.startsWith("//", s.i), scan: (s) => skipComment(s, "\n", 0) },
+ { at: (s) => s.text.startsWith("/*", s.i), scan: (s) => skipComment(s, "*/", 2) },
+ { at: (s) => s.text[s.i] === '"' || s.text[s.i] === "'", scan: scanQuoted },
+ { at: (s) => s.text[s.i] === "`", scan: (s) => scanTemplate(s, s.i + 1) },
+ { at: (s) => s.text[s.i] === "}" && s.braces.at(-1) === 0, scan: resumeTemplate },
+ { at: (s) => s.text[s.i] === "/" && regexCanStart(s), scan: scanRegex },
+];
+const CODE: Pick = { scan: scanCode };
+
+function blank(s: Scan, from: number, to: number): void {
+ for (let k = from; k < to; k++) if (s.out[k] !== "\n") s.out[k] = " ";
+}
+
+function literal(s: Scan, from: number, to: number): void {
+ if (s.strings) blank(s, from, to);
+}
+
+function skipComment(s: Scan, terminator: string, length: number): void {
+ const found = s.text.indexOf(terminator, s.i + 2);
+ const stop = found === -1 ? s.text.length : found + length;
+ blank(s, s.i, stop);
+ s.i = stop;
+}
+
+function scanQuoted(s: Scan): void {
+ const quote = s.text[s.i]!;
+ let j = s.i + 1;
+ while (j < s.text.length && s.text[j] !== quote && s.text[j] !== "\n") {
+ j += s.text[j] === "\\" ? 2 : 1;
+ }
+ literal(s, s.i + 1, Math.min(j, s.text.length));
+ s.i = j + 1;
+ s.last = quote;
+}
+
+function resumeTemplate(s: Scan): void {
+ s.braces.pop();
+ scanTemplate(s, s.i + 1);
+}
+
+function scanTemplate(s: Scan, start: number): void {
+ let j = start;
+ while (j < s.text.length && s.text[j] !== "`" && !s.text.startsWith("${", j)) {
+ j += s.text[j] === "\\" ? 2 : 1;
+ }
+ literal(s, start, Math.min(j, s.text.length));
+ if (s.text.startsWith("${", j)) s.braces.push(0);
+ s.i = Math.min(j + (s.text[j] === "`" ? 1 : 2), s.text.length);
+ s.last = "`";
+}
+
+function scanRegex(s: Scan): void {
+ let j = s.i + 1;
+ let inClass = false;
+ while (j < s.text.length && s.text[j] !== "\n") {
+ const c = s.text[j];
+ if (c === "/" && !inClass) break;
+ if (c === "[" || c === "]") inClass = c === "[";
+ j += c === "\\" ? 2 : 1;
+ }
+ s.i = j + 1;
+ s.last = "/";
+}
+
+function scanCode(s: Scan): void {
+ const c = s.text[s.i]!;
+ const top = s.braces.length - 1;
+ if (top >= 0 && (c === "{" || c === "}")) s.braces[top]! += c === "{" ? 1 : -1;
+ if (!/\s/.test(c)) s.last = c;
+ s.i++;
+}
+
+function regexCanStart(s: Scan): boolean {
+ if (s.last === "" || "(,=:[!&|?{};+-*%<>~^".includes(s.last)) return true;
+ return /\b(?:return|typeof|case|of|in|yield|await|void|delete|throw)$/.test(
+ s.text.slice(Math.max(0, s.i - 12), s.i).trimEnd(),
+ );
+}
diff --git a/src/tool-guard.test.ts b/src/tool-guard.test.ts
index c62147b70..3ca0181fe 100644
--- a/src/tool-guard.test.ts
+++ b/src/tool-guard.test.ts
@@ -44,6 +44,8 @@ describe("tool-guard.mjs", () => {
"npx -y tsc --noEmit",
"node_modules/.bin/oxlint src",
"node .agents/scripts/comment-lint.mjs --base origin/main",
+ "node .agents/scripts/test-lint.mjs --base origin/main",
+ "pnpm lint:tests",
"pnpm test:e2e",
"pnpm test:console",
"sed -i '' 's/a/b/' f.ts && pnpm check 2>&1 | tail -5",
diff --git a/website/src/content/docs/docs/reference/errors.mdx b/website/src/content/docs/docs/reference/errors.mdx
index a0312f0aa..4f8e2b987 100644
--- a/website/src/content/docs/docs/reference/errors.mdx
+++ b/website/src/content/docs/docs/reference/errors.mdx
@@ -22,7 +22,7 @@ Branch on `code`, not on the message: the message wording can change, the code d
| Exit | Error codes | Meaning |
| --- | --- | --- |
| 0 | — | Success. For a `simlock lease` that stayed running: the lease ended because you asked. |
-| 1 | `INTERNAL`, `PROVISION_FAILED`, `UNKNOWN_LEASE`, `FORBIDDEN`, `ADMIN_AUTHENTICATION_FAILED`, `SETUP_MCP_FAILED`, `DAEMON_STARTUP_FAILED`, `DAEMON_CONNECTION_LOST`, `DAEMON_STOPPING`, `DAEMON_UPDATING`, `HANDSHAKE_REQUIRED`, `PROTOCOL_VERSION_UNSUPPORTED`, `WORKER_UNREACHABLE`, `RELAY_TARGET_MISSING`, `ALERT_SEND_FAILED`, `UPDATE_RESOLVE_FAILED`, `HISTORY_UNAVAILABLE`, `DOCTOR_UNAVAILABLE`, `NUKE_UNAVAILABLE`, `UNKNOWN_DAEMON_ERROR`, `CANCELLED` | The command failed. Read the code and the message. |
+| 1 | `INTERNAL`, `PROVISION_FAILED`, `UNKNOWN_LEASE`, `FORBIDDEN`, `ADMIN_AUTHENTICATION_FAILED`, `SETUP_MCP_FAILED`, `DAEMON_STARTUP_FAILED`, `DAEMON_CONNECTION_LOST`, `DAEMON_STOPPING`, `DAEMON_UPDATING`, `HANDSHAKE_REQUIRED`, `PROTOCOL_VERSION_UNSUPPORTED`, `WORKER_UNREACHABLE`, `RELAY_TARGET_MISSING`, `ALERT_SEND_FAILED`, `UPDATE_RESOLVE_FAILED`, `HISTORY_UNAVAILABLE`, `DOCTOR_UNAVAILABLE`, `NUKE_UNAVAILABLE`, `UNKNOWN_DAEMON_ERROR`, `SERVICE_FAILED`, `CANCELLED` | The command failed. Read the code and the message. |
| 2 | `USAGE`, `BAD_REQUEST`, `BAD_FRAME`, `UNKNOWN_REQUEST`, `IDEMPOTENCY_CONFLICT`, `PASSTHROUGH_REFUSED`, `UNKNOWN_PASSTHROUGH_TOOL`, `SETUP_REFUSED`, `GATEWAY_NOT_PRESENT`, `UPDATE_UNSUPPORTED_INSTALL`, `UPDATE_SHARED_INSTALL`, `UNSUPPORTED_IN_GATEWAY_MODE`, `UNSUPPORTED_IN_WORKER_MODE`, `WORKER_CONNECTED`, `WORKER_NOT_CONNECTED` | The request was wrong. Running it again unchanged fails again. |
| 10 | `QUEUE_TIMEOUT`, `EXEC_TIMEOUT`, `DOWNLOAD_TIMEOUT` | A deadline ran out. |
| 11 | `NO_CAPACITY`, `DEVICE_BUSY`, `COMPONENT_BUSY` | Busy right now. The same request can pass later. |
@@ -142,6 +142,7 @@ The CLI and the HTTP API each add a few codes of their own:
| `USAGE` | CLI | exit 2 | A bad flag, a missing argument or an unknown command. Also a `SIMLOCK_HOME` too long for the daemon's socket path. | Run the command with `--help`. |
| `SETUP_REFUSED` | CLI | exit 2 | `simlock setup` found a file or a symlink where its `simlock` skill directory goes. Nothing was written. | Move it aside and run `setup` again. |
| `SETUP_MCP_FAILED` | CLI | exit 1 | `simlock setup` wrote the skills, but a tool's `mcp add` command failed or ran longer than 30 seconds. The message names each tool and the reason. | Run the tool's `mcp add` command by hand, as the message and [Connect your agent](/simlock/docs/getting-started/connect-your-agent) show. |
+| `SERVICE_FAILED` | CLI | exit 1 | `simlock daemon install` or `uninstall`, or a start through the service, could not finish: the service manager (launchd or systemd) failed or did not answer in time, or the old daemon did not stop. The message names the command and its first error line. | Fix what the message names and run the command again. |
| `UNAUTHENTICATED` | HTTP | 401 | The request has no token, or one the daemon does not accept. | Send a valid token as `Authorization: Bearer `. |
| `UNSUPPORTED_MEDIA_TYPE` | HTTP | 415 | A request with a JSON body did not send `Content-Type: application/json`. | Send the header `Content-Type: application/json`. |
| `UNKNOWN_LEASE_REQUEST` | HTTP | 404 | No lease request has this ID. | Check the ID the create call returned. |