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. |