Holds back dependency changes, new imports and sensitive-file edits until the session has checked the registry or the official docs for them.

A Claude Code mod that stops Claude from building on a remembered fact about a package or platform. Three kinds of tool call wait until the session has checked the registry or the official docs:
| Gated action | Example | Needs evidence for |
|---|---|---|
| Dependency change | pnpm add zod, npx -y some-tool, pip install requests, brew install x, or adding or re-pinning a dependency in package.json | each package it names |
| New external import | an Edit or Write that imports a package the project neither declares nor has installed | that package |
| Sensitive-file edit | auth middleware, a schema or migration, a billing or webhook handler, deployment or CI config | any library the file uses |
Everything else passes without a decision: reads, searches, agents, plans, prose and scratch files.
When the gate denies a call, the reason names each package that has no evidence and the lookup that would provide it, such as pnpm view zod version. So the way past the gate is to do the lookup.
The gate records evidence from tool calls that succeed:
pnpm view, npm info, pip index versions, brew info, cargo info, go list), a registry URL, or reading the package's own files under node_modules.next and @next/*; Stripe's docs cover stripe and @stripe/*. It comes from official docs pages, Context7, GitHub repositories, and a vendor's own MCP server.Evidence for one package never covers another whose name merely contains it: checking react does not cover react-native-svg. A failed lookup or a 404 page records nothing.
Evidence lasts for the session, including its subagents, and survives claude --resume. /clear starts a new session with none. GLOSSARY.md defines each term.
/docs-gate shows the evidence the session holds and the last denial. It runs at once, even while Claude is working, and starts no turn.hook override turns the gate off for 15 minutes. It works only when the user types it at the terminal or through Remote Control; a message from another session, a subagent, a scheduled task or claude -p cannot do it.Requires Claude Code 2.1.287 or later.
claude plugin marketplace add nuko-nova-dynamics/marketplace
claude plugin install docs-first@nuko-nova-tools
In a running session, /reload-plugins loads it.
A mod runs with your permissions. Before installing, claude plugin validate <dir> on a clone lists every event it hooks and every call it makes.
pnpm install
pnpm test # the rules, under node --test
pnpm test:mod # the wiring, under claude plugin test
pnpm validate
claude -p "/docs-gate" --plugin-dir . # once, so Claude Code writes the type declarations
pnpm typecheck
claude --plugin-dir . # a session that reloads the mod on save
hooks/register.ts is the only file that talks to Claude Code. The rules live in src/ as plain TypeScript with no Claude Code or Node dependencies: classify decides which calls are gated actions, observe turns finished calls into evidence, coverage decides what evidence covers, decide writes the denial, and ecosystems holds one entry per package manager. Supporting another ecosystem means adding an entry there.
MIT
hooks/register.ts 196 lines1// The mod's wiring: the only file that talks to Claude Code. Everything it decides comes from ../src.
2
3import { atom, update } from "claude-code";
4import type { EngineInterface, Register } from "claude-code";
5import type { DocsFirstBlock } from "../types";
6import { BRIEFING, OVERRIDE_ACK } from "../src/briefing.ts";
7import { classify } from "../src/classify.ts";
8import { decide } from "../src/decide.ts";
9import { Ledgers, OVERRIDE_MS, isOverridden, record, type Ledger, type Store } from "../src/ledger.ts";
10import type { ProjectFiles, ToolCall, ToolOutcome, Verdict } from "../src/model.ts";
11import { observe } from "../src/observe.ts";
12import { isOverrideRequest } from "../src/override.ts";
13import { describe } from "../src/report.ts";
14
15type Api = EngineInterface;
16
17const GATED_TOOLS = new Set(["Bash", "Edit", "Write"]);
18
19const ledgers = new Ledgers();
20// The main session's id. Subagents' calls share it, so their evidence counts for the whole session.
21// classic.SessionStart moves it on /clear (a new id) and /resume (the resumed one).
22let sessionId = "";
23// The last failure of the gate itself. While set, calls are allowed and the status line says why.
24let gateError = "";
25let overrideTimer: { cancel(): void } | null = null;
26// The last denial, shared so other mods can show it; cleared once evidence arrives or a gated call goes through.
27const block = atom({ plugin: "docs-first", key: "block" } as const, null as DocsFirstBlock | null);
28
29const storeOf = ($: Api): Store => ({
30 get: (key) => $.store.get(key),
31 set: (key, value) => $.store.set(key, value),
32 delete: (key) => $.store.delete(key),
33 keys: () => $.store.keys()
34});
35
36const filesOf = ($: Api): ProjectFiles => ({
37 read: (path) => $.fs.read(path).catch(() => null),
38 exists: (path) => $.fs.exists(path).catch(() => false)
39});
40
41const message = (err: unknown) => (err instanceof Error ? err.message : String(err)).slice(0, 160);
42
43async function currentSession($: Api): Promise<string> {
44 if (!sessionId) sessionId = await $.session.id();
45 return sessionId;
46}
47
48function clockTime(ms: number): string {
49 const d = new Date(ms);
50 return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
51}
52
53/** The status line: shown only while the gate is failing or overridden. */
54function showStatus($: Api, ledger: Ledger, now: number): void {
55 if (gateError) $.ui.status(`gate error, calls allowed: ${gateError}`);
56 else if (isOverridden(ledger, now)) $.ui.status(`override on, gate off until ${clockTime(ledger.overrideUntil)}`);
57 else $.ui.status(undefined);
58}
59
60async function refreshStatus($: Api): Promise<void> {
61 const ledger = await ledgers.get(storeOf($), await currentSession($));
62 const now = await $.clock.now();
63 showStatus($, ledger, now);
64 overrideTimer?.cancel();
65 overrideTimer = isOverridden(ledger, now) ? $.clock.after(ledger.overrideUntil - now + 1_000, () => { void refreshStatus($); }) : null;
66}
67
68function fail($: Api, where: string, err: unknown): void {
69 gateError = `${where}: ${message(err)}`;
70 $.ui.status(`gate error, calls allowed: ${gateError}`);
71 $.ui.log(`docs-first ${gateError}`, { to: "debug" });
72}
73
74async function judge($: Api, call: ToolCall): Promise<Verdict | null> {
75 if (!GATED_TOOLS.has(call.tool)) return null;
76 const cwd = call.tool === "Bash" ? "" : await $.session.cwd();
77 const actions = await classify(call, filesOf($), cwd);
78 if (!actions.length) return null;
79 const ledger = await ledgers.get(storeOf($), await currentSession($));
80 if (isOverridden(ledger, await $.clock.now())) return null;
81 return decide(actions, ledger.evidence, cwd);
82}
83
84function outcomeOf(result: unknown): ToolOutcome {
85 const r = (result ?? {}) as { deny?: unknown; isError?: unknown; text?: unknown; result?: unknown };
86 const succeeded = r.deny === undefined && !r.isError;
87 if (typeof r.text === "string") return { succeeded, text: r.text };
88 if (typeof r.result === "string") return { succeeded, text: r.result };
89 try {
90 return { succeeded, text: JSON.stringify(r.result ?? "").slice(0, 4000) };
91 } catch {
92 return { succeeded, text: "" };
93 }
94}
95
96export const register: Register = (on) => {
97 on("session.start", async ($, e, next) => {
98 const result = await next(e);
99 sessionId = await $.session.id();
100 $.clock.after(5_000, () => { void ledgers.sweep(storeOf($), Date.now()).catch((err) => fail($, "sweeping old ledgers", err)); });
101 try {
102 await refreshStatus($);
103 } catch (err) {
104 fail($, "loading the ledger", err);
105 }
106 try {
107 await $.command.register({ name: "docs-gate", description: "Show the docs-first gate: evidence held, override, last denial", immediate: true });
108 } catch (err) {
109 $.ui.log(`docs-first: /docs-gate not registered: ${message(err)}`, { to: "debug" });
110 }
111 return result;
112 });
113
114 on("classic.SessionStart", async ($, e, next) => {
115 const result = await next(e);
116 if (typeof e.session_id === "string" && e.session_id && e.session_id !== sessionId) {
117 sessionId = e.session_id;
118 await update($, block, () => null).catch(() => {});
119 // /clear and /resume switch ledgers: the status line and override timer follow the new one.
120 try {
121 await refreshStatus($);
122 } catch (err) {
123 fail($, "loading the ledger", err);
124 }
125 }
126 return { ...result, additionalContext: [...(result.additionalContext ?? []), BRIEFING] };
127 });
128
129 on("prompt.submit", async ($, e, next) => {
130 if (!isOverrideRequest(e.text, e.origin.kind)) return next(e);
131 let accepted = false;
132 try {
133 const now = await $.clock.now();
134 await ledgers.change(storeOf($), await currentSession($), now, (ledger) => {
135 ledger.overrideUntil = now + OVERRIDE_MS;
136 return true;
137 });
138 accepted = true;
139 await update($, block, () => null).catch(() => {});
140 await refreshStatus($);
141 } catch (err) {
142 fail($, "recording the override", err);
143 }
144 return accepted ? next({ ...e, context: [...(e.context ?? []), OVERRIDE_ACK] }) : next(e);
145 });
146
147 on("tool.call", async ($, e, next) => {
148 const call = e as unknown as ToolCall;
149 let verdict: Verdict | null = null;
150 try {
151 verdict = await judge($, call);
152 if (gateError) {
153 gateError = "";
154 await refreshStatus($);
155 }
156 } catch (err) {
157 fail($, `judging ${call.tool}`, err);
158 }
159 if (verdict && !verdict.allow) {
160 const reason = verdict.reason;
161 const summary = verdict.summary;
162 try {
163 const now = await $.clock.now();
164 await update($, block, () => ({ at: now, tool: call.tool, summary })).catch(() => {});
165 await ledgers.change(storeOf($), await currentSession($), now, (ledger) => {
166 ledger.lastDenial = { at: now, tool: call.tool, reason };
167 return true;
168 });
169 } catch (err) {
170 $.ui.log(`docs-first: could not record the denial: ${message(err)}`, { to: "debug" });
171 }
172 return { deny: reason };
173 }
174
175 if (verdict?.allow) await update($, block, () => null).catch(() => {});
176 const result = await next(e);
177 try {
178 const findings = observe(call, outcomeOf(result));
179 if (findings.length) {
180 const now = await $.clock.now();
181 let isNew = false;
182 await ledgers.change(storeOf($), await currentSession($), now, (ledger) => (isNew = record(ledger, findings, now)));
183 if (isNew) await update($, block, () => null).catch(() => {});
184 }
185 } catch (err) {
186 fail($, "recording evidence", err);
187 }
188 return result;
189 });
190
191 on("command.run", { command: "docs-gate" }, async ($) => {
192 const ledger = await ledgers.get(storeOf($), await currentSession($));
193 return { text: describe(ledger, await $.clock.now(), gateError) };
194 });
195};
196src/briefing.ts 15 lines1// What Claude is told at the start of every session. It never varies, so it never costs the prompt cache.
2
3export const BRIEFING = [
4 "Docs-first gate (action-level) is active in this session.",
5 "It denies three kinds of tool call until the session holds matching evidence:",
6 "1. A dependency change: adding, upgrading or re-pinning a named package with a package manager (pnpm/npm/yarn/bun add or up, npx or dlx of a remote package, pip or uv, brew, cargo add, go get), or adding or changing a dependency's version in package.json. It needs registry evidence for that exact package (`pnpm view <pkg> version`, `pip index versions <pkg>`, `brew info <pkg>`, or reading node_modules/<pkg>/package.json), or docs evidence for its family (a Context7 query, or the official docs, which cover the packages they document, such as nextjs.org for next and @next/*).",
7 "2. A new external import: adding an import of a package the project neither declares nor has installed. It needs the same evidence for that package.",
8 "3. A sensitive-file edit: an auth, database schema or migration, billing or webhook, or deployment config file. It needs evidence for a library that file uses.",
9 "A denial names each missing package and the lookup that satisfies it. Evidence lasts for the whole session, subagents included, and survives a resume.",
10 "Read-only work, agents, plans, prose and scratch files are never gated. `/docs-gate` shows the evidence held. Typing `hook override` turns the gate off for 15 minutes, and only works when the user types it.",
11 "Final answers for library, API, auth, schema, billing, or deployment work end with a short Evidence checked section."
12].join("\n");
13
14export const OVERRIDE_ACK = "Docs-first gate override accepted; the gate is off for 15 minutes.";
15src/classify.ts 372 lines1// Which tool calls are gated actions, and which subjects each one depends on.
2
3import { changesIn, npmPackageName } from "./ecosystems.ts";
4import type { GatedAction, ProjectFiles, Subject, ToolCall } from "./model.ts";
5import { inlineEvidence } from "./observe.ts";
6import { basename, dirname, join, resolve } from "./paths.ts";
7import { commands } from "./shell.ts";
8
9// ---------------------------------------------------------------------------
10// Imports
11// ---------------------------------------------------------------------------
12
13const SOURCE_FILE_RE = /\.([cm]?[jt]sx?|vue|svelte|astro)$/i;
14// Import, export-from and require statements only: never the word inside a longer word,
15// a property path, or a string literal such as a kind name or an exports map key.
16const IMPORT_RE = /(?:(?<![\w$.'"-])import\s*(?:[^'"`;]*?\bfrom\s*)?|(?<![\w$.'"-])export\s+[^'"`;]*?\bfrom\s*|(?<![\w$.'"-])require\s*\(\s*|(?<![\w$.'"-])import\s*\(\s*)["']([^"'\n]+)["']/g;
17
18// Modules the runtime provides rather than a package: Node's built-ins, Bun's, and Claude Code's mod API.
19const RUNTIME_MODULES = new Set([
20 "assert", "assert/strict", "async_hooks", "buffer", "child_process", "cluster", "console", "constants", "crypto",
21 "dgram", "diagnostics_channel", "dns", "dns/promises", "domain", "events", "fs", "fs/promises", "http", "http2",
22 "https", "inspector", "inspector/promises", "module", "net", "os", "path", "path/posix", "path/win32", "perf_hooks",
23 "process", "punycode", "querystring", "readline", "readline/promises", "repl", "stream", "stream/consumers",
24 "stream/promises", "stream/web", "string_decoder", "sys", "timers", "timers/promises", "tls", "trace_events", "tty",
25 "url", "util", "util/types", "v8", "vm", "wasi", "worker_threads", "zlib",
26 "bun", "claude-code", "claude-code/testing"
27]);
28
29// What can come right before a regular expression literal, as opposed to a division sign.
30const REGEX_AFTER = new Set(["", "(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "<", ">", "~", "^"]);
31const REGEX_AFTER_WORD_RE = /(?:^|[^\w$])(?:return|typeof|case|do|else|in|of|void|yield|await|delete|throw|new|instanceof)$/;
32
33function regexStartsAfter(code: string): boolean {
34 const before = code.replace(/\s+$/, "");
35 return REGEX_AFTER.has(before.slice(-1)) || REGEX_AFTER_WORD_RE.test(before);
36}
37
38/** The index of the slash that closes a regular expression literal opened at `start`, or the end of its line. */
39function regexEnd(src: string, start: number): number {
40 let inClass = false;
41 for (let j = start + 1; j < src.length; j += 1) {
42 const ch = src[j];
43 if (ch === "\\") { j += 1; continue; }
44 if (ch === "\n") return j - 1;
45 if (inClass) { if (ch === "]") inClass = false; continue; }
46 if (ch === "[") inClass = true;
47 else if (ch === "/") return j;
48 }
49 return src.length - 1;
50}
51
52/**
53 * Source with its comments removed and its strings and regular expressions kept, so a commented-out
54 * statement is not read as code, a comment inside a dynamic import's parentheses does not hide its
55 * specifier, and a `/*` inside a regular expression does not hide what follows.
56 */
57export function stripComments(source: string): string {
58 const src = String(source || "");
59 let out = "";
60 let quote = "";
61 for (let i = 0; i < src.length; i += 1) {
62 const ch = src[i] ?? "";
63 const next = src[i + 1] ?? "";
64 if (quote) {
65 out += ch;
66 if (ch === "\\") { out += next; i += 1; }
67 else if (ch === quote) quote = "";
68 continue;
69 }
70 if (ch === "/" && next === "*") {
71 const close = src.indexOf("*/", i + 2);
72 i = close < 0 ? src.length : close + 1;
73 out += " ";
74 continue;
75 }
76 if (ch === "/" && next === "/") {
77 const close = src.indexOf("\n", i + 2);
78 i = (close < 0 ? src.length : close) - 1;
79 continue;
80 }
81 if (ch === "/" && regexStartsAfter(out)) {
82 const close = regexEnd(src, i);
83 out += src.slice(i, close + 1);
84 i = close;
85 continue;
86 }
87 if (ch === "'" || ch === "\"" || ch === "`") quote = ch;
88 out += ch;
89 }
90 return out;
91}
92
93export function importSpecs(source: string): string[] {
94 const out = new Set<string>();
95 const re = new RegExp(IMPORT_RE.source, "g");
96 const code = stripComments(source);
97 let m: RegExpExecArray | null;
98 while ((m = re.exec(code))) if (m[1]) out.add(m[1]);
99 return [...out];
100}
101
102/** The package an import specifier names, or "" for relative paths, aliases and runtime modules. */
103export function specToPackage(spec: string): string {
104 const s = String(spec || "").trim();
105 if (!s || /^(\.|\/|~|#|@\/|\$|\?|http)/.test(s)) return "";
106 if (RUNTIME_MODULES.has(s)) return "";
107 // Scheme specifiers (node:fs, bun:sqlite, astro:content, cloudflare:workers) are runtime-provided, except npm:pkg.
108 if (/^[a-z][a-z0-9-]*:/.test(s) && !s.startsWith("npm:")) return "";
109 const clean = s.replace(/^npm:/, "");
110 const segs = clean.split("/");
111 return clean.startsWith("@") ? segs.slice(0, 2).join("/") : (segs[0] ?? "");
112}
113
114function parseJson(text: string | null): Record<string, any> | null {
115 if (text == null || !text.trim()) return null;
116 for (const candidate of [text, text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1")]) {
117 try {
118 const value = JSON.parse(candidate);
119 if (value && typeof value === "object" && !Array.isArray(value)) return value;
120 } catch {
121 // try the lenient form next
122 }
123 }
124 return null;
125}
126
127/** What a project declares, found by walking up from a file. */
128export type Knowledge = { declared: Set<string>; aliasExact: Set<string>; aliasPrefixes: string[]; nodeModulesDirs: string[] };
129
130/** A path alias key: `@ui/*` is a prefix, `react` matches only itself, and a bare `*` is ignored. */
131function addAlias(knowledge: Knowledge, key: string): void {
132 if (!key.endsWith("*")) knowledge.aliasExact.add(key);
133 else if (key.length > 1) knowledge.aliasPrefixes.push(key.slice(0, -1));
134}
135
136/**
137 * Walks up from the file's directory collecting declared dependencies, `imports` and tsconfig path
138 * aliases, and node_modules folders. Null when no package.json lies above: the file is a scratch file
139 * and the gate cannot judge its imports.
140 */
141export async function projectKnowledge(file: string, files: ProjectFiles, rootLimit = 8): Promise<Knowledge | null> {
142 let dir = dirname(file);
143 let sawPackageJson = false;
144 const knowledge: Knowledge = { declared: new Set(), aliasExact: new Set(), aliasPrefixes: [], nodeModulesDirs: [] };
145 for (let i = 0; i < rootLimit; i += 1) {
146 const pkg = parseJson(await files.read(join(dir, "package.json")));
147 if (pkg) {
148 sawPackageJson = true;
149 for (const key of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
150 for (const name of Object.keys(pkg[key] || {})) knowledge.declared.add(name);
151 }
152 for (const key of Object.keys(pkg.imports || {})) addAlias(knowledge, key);
153 }
154 const tsconfig = parseJson(await files.read(join(dir, "tsconfig.json")));
155 for (const key of Object.keys(tsconfig?.compilerOptions?.paths || {})) addAlias(knowledge, key);
156 if (await files.exists(join(dir, "node_modules"))) knowledge.nodeModulesDirs.push(join(dir, "node_modules"));
157 const atRoot = (await files.exists(join(dir, ".git"))) || (await files.exists(join(dir, "pnpm-workspace.yaml")));
158 const parent = dirname(dir);
159 if (parent === dir || (atRoot && sawPackageJson)) break;
160 dir = parent;
161 }
162 return sawPackageJson ? knowledge : null;
163}
164
165async function isKnownPackage(pkg: string, spec: string, knowledge: Knowledge, files: ProjectFiles): Promise<boolean> {
166 if (knowledge.declared.has(pkg)) return true;
167 if (knowledge.aliasExact.has(spec) || knowledge.aliasPrefixes.some((p) => spec.startsWith(p))) return true;
168 for (const nm of knowledge.nodeModulesDirs) if (await files.exists(join(nm, pkg))) return true;
169 return false;
170}
171
172/** Packages this edit newly imports that the project neither declares nor has installed. */
173export async function newUnknownImports(file: string, before: string, after: string, files: ProjectFiles): Promise<string[]> {
174 if (!SOURCE_FILE_RE.test(file)) return [];
175 const had = new Set(importSpecs(before));
176 const added = importSpecs(after).filter((s) => !had.has(s));
177 if (!added.length) return [];
178 const knowledge = await projectKnowledge(file, files);
179 if (!knowledge) return [];
180 const out = new Set<string>();
181 for (const spec of added) {
182 const pkg = specToPackage(spec);
183 if (pkg && !(await isKnownPackage(pkg, spec, knowledge, files))) out.add(pkg);
184 }
185 return [...out];
186}
187
188// ---------------------------------------------------------------------------
189// package.json
190// ---------------------------------------------------------------------------
191
192const DEP_SECTIONS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
193// Specs that point at local folders, workspaces or git rather than the registry.
194// A leading `~` alone is a semver range (`~3.24.0`); only `~/` is a path.
195const NON_REGISTRY_SPEC_RE = /^(workspace:|link:|file:|portal:|git\+|git:|github:|https?:|ssh:|\.\.?\/|\/|~\/)|^[\w.-]+\/[\w.-]+(#.*)?$/;
196
197type Dependency = { spec: string; pkg: string };
198
199/** The registry package a dependency entry fetches: its own name, or the target of an `npm:` alias. */
200function dependencyOf(name: string, spec: string): Dependency {
201 return { spec, pkg: (spec.startsWith("npm:") && npmPackageName(spec.slice(4))) || name };
202}
203
204/** The package an override key names: `foo`, `@s/foo`, `foo@1` (npm), `a>foo` (pnpm), or a yarn path ending in `/foo`. */
205function overrideTarget(key: string): string {
206 const last = key.split(">").pop() ?? key;
207 return last.match(/(@[^/@]+\/[^/@]+|[^/@]+)(?:@[^/]*)?$/)?.[1] ?? "";
208}
209
210/** Adds every pinned version in an overrides or resolutions block, npm's nested form included, keyed by where it sits. */
211function addOverrides(deps: Map<string, Dependency>, block: unknown, at: string, parent: string): void {
212 if (!block || typeof block !== "object" || Array.isArray(block)) return;
213 for (const [key, value] of Object.entries(block)) {
214 const pkg = key === "." ? parent : overrideTarget(key);
215 if (!pkg) continue;
216 const where = `${at}/${key}`;
217 if (typeof value === "string") {
218 // `$name` refers to the version the project's own dependency pins.
219 if (!value.startsWith("$") && !NON_REGISTRY_SPEC_RE.test(value)) deps.set(where, dependencyOf(pkg, value));
220 } else {
221 addOverrides(deps, value, where, pkg);
222 }
223 }
224}
225
226/**
227 * A package.json's registry dependencies, or null when the text is not a JSON object. Direct dependencies
228 * are keyed by name, so moving one between sections is no change; overrides by where they sit.
229 */
230function manifestDependencies(text: string): Map<string, Dependency> | null {
231 const json = parseJson(text);
232 if (!json) return null;
233 const deps = new Map<string, Dependency>();
234 for (const section of DEP_SECTIONS) {
235 const block = json[section];
236 if (!block || typeof block !== "object") continue;
237 for (const [name, spec] of Object.entries(block)) {
238 if (typeof spec === "string" && !NON_REGISTRY_SPEC_RE.test(spec)) deps.set(name, dependencyOf(name, spec));
239 }
240 }
241 addOverrides(deps, json.overrides, "overrides", "");
242 addOverrides(deps, json.pnpm?.overrides, "pnpm.overrides", "");
243 addOverrides(deps, json.resolutions, "resolutions", "");
244 return deps;
245}
246
247// For fragments that are not JSON on their own: a "name": "spec" pair whose spec is a version range, an alias or a dist-tag.
248const MANIFEST_NON_DEP_KEYS = new Set(["name", "version", "type", "main", "types", "module", "license", "author", "description", "packageManager", "private", "node", "pnpm", "npm", "yarn", "bun", "engines", "browser", "homepage", "repository"]);
249const DEP_PAIR_RE = /"((?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*)"\s*:\s*"((?:[\^~>=<*]|\d|x\b|npm:)[^"]*|latest|next|canary|beta|rc)"/g;
250
251function fragmentDependencies(text: string): Map<string, Dependency> {
252 const deps = new Map<string, Dependency>();
253 const re = new RegExp(DEP_PAIR_RE.source, "g");
254 let m: RegExpExecArray | null;
255 while ((m = re.exec(String(text || "")))) {
256 const [, name, spec] = m;
257 if (name && spec !== undefined && !MANIFEST_NON_DEP_KEYS.has(name)) deps.set(name, dependencyOf(name, spec));
258 }
259 return deps;
260}
261
262/** Registry packages this package.json change adds or re-pins: a new dependency, or a known one with a different spec. */
263export function manifestChanges(before: string, after: string): string[] {
264 const now = manifestDependencies(after);
265 const was = now ? (manifestDependencies(before) ?? new Map<string, Dependency>()) : fragmentDependencies(before);
266 const changed = new Set<string>();
267 for (const [where, dep] of now ?? fragmentDependencies(after)) if (was.get(where)?.spec !== dep.spec) changed.add(dep.pkg);
268 return [...changed];
269}
270
271// ---------------------------------------------------------------------------
272// Sensitive files
273// ---------------------------------------------------------------------------
274
275export type SensitiveRule = { label: string; re: RegExp; vendors: string[] };
276
277const SENSITIVE_RULES: readonly SensitiveRule[] = [
278 {
279 label: "database schema or migration",
280 re: /(^|\/)(schema\.prisma|prisma\/migrations\/|migrations?\/[^/]+\.(sql|ts|js|py)$|convex\/schema\.[jt]s$|drizzle\/.*schema.*\.[jt]s$|[^/]*schema\.(sql|prisma)$|supabase\/migrations\/)/i,
281 vendors: ["prisma", "drizzle", "convex", "supabase", "postgres", "sqlite", "mysql"]
282 },
283 {
284 label: "auth or request middleware",
285 re: /(^|\/)(middleware\.[jt]s$|proxy\.[jt]s$|o?auth\.[jt]s$|auth\.config\.[jt]s$|o?auth\/[^/]+\.[jt]sx?$|[^/]*(?:^|[-_.])o?auth(?:[-_.][^/]*)?\.[jt]sx?$)/i,
286 vendors: ["auth", "clerk", "next-auth", "better-auth", "supabase", "firebase", "lucia", "passport", "jose"]
287 },
288 {
289 // A keyword in the filename, or in a folder under an api/server/lib-style tree (api/stripe/webhook/route.ts).
290 label: "billing or webhook handler",
291 re: /(^|\/)[^/]*(webhook|billing|stripe|payment|checkout|subscription|revenuecat)[^/]*\.[jt]sx?$|(^|\/)(?:api|server|lib|services?|functions|convex|supabase|workers|handlers?)\/(?:[^/]+\/)*[^/]*(webhook|billing|stripe|payment|checkout|subscription|revenuecat)[^/]*\/[^/]+\.[jt]sx?$/i,
292 vendors: ["stripe", "revenuecat", "lemonsqueezy", "paddle", "polar"]
293 },
294 {
295 label: "deployment or CI config",
296 re: /(^|\/)(vercel\.json|wrangler\.(toml|jsonc?)|fly\.toml|railway\.(toml|json)|netlify\.toml|render\.yaml|Dockerfile[^/]*|docker-compose[^/]*\.ya?ml|eas\.json|app\.config\.[jt]s|\.github\/workflows\/[^/]+\.ya?ml)$/i,
297 vendors: ["vercel", "cloudflare", "wrangler", "fly", "railway", "netlify", "render", "docker", "expo", "eas", "github"]
298 }
299];
300
301export function sensitiveRule(file: string): SensitiveRule | null {
302 return SENSITIVE_RULES.find((r) => r.re.test(file)) ?? null;
303}
304
305const npmSubject = (name: string): Subject => ({ name, ecosystem: "npm" });
306
307/** The libraries a sensitive file depends on: the packages it imports, and vendors its path names. */
308export function sensitiveSubjects(file: string, before: string, after: string, rule: SensitiveRule): Subject[] {
309 const names = new Set<string>();
310 for (const spec of [...importSpecs(before), ...importSpecs(after)]) {
311 const pkg = specToPackage(spec);
312 if (pkg) names.add(pkg);
313 }
314 const lower = file.toLowerCase();
315 for (const vendor of rule.vendors) if (lower.includes(vendor)) names.add(vendor);
316 return [...names].map(npmSubject);
317}
318
319// ---------------------------------------------------------------------------
320// Tool calls
321// ---------------------------------------------------------------------------
322
323/**
324 * The file's text before and after the call. An Edit is applied to the file on disk, so a fragment
325 * that only swaps a version or a module name is judged in its full context; when the file cannot be
326 * read or the fragment is not in it, the fragments themselves stand in.
327 */
328async function contents(call: ToolCall, file: string, files: ProjectFiles): Promise<{ before: string; after: string }> {
329 if (call.tool === "Write") return { before: (await files.read(file)) ?? "", after: String(call.content ?? "") };
330 const oldString = String(call.old_string ?? "");
331 const newString = String(call.new_string ?? "");
332 const current = oldString ? await files.read(file) : null;
333 if (current !== null && current.includes(oldString)) {
334 const after = call.replace_all === true ? current.split(oldString).join(newString) : current.replace(oldString, () => newString);
335 return { before: current, after };
336 }
337 return { before: oldString, after: newString };
338}
339
340/** The gated actions a tool call would take; empty for every call the gate leaves alone. */
341export async function classify(call: ToolCall, files: ProjectFiles, cwd: string): Promise<GatedAction[]> {
342 if (call.tool === "Bash") {
343 const steps = commands(String(call.command ?? ""));
344 const actions: GatedAction[] = [];
345 steps.forEach((step, k) => {
346 const subjects = changesIn(step.tokens);
347 if (subjects.length) actions.push({ kind: "dependency-change", via: "command", subjects, inline: inlineEvidence(steps, k) });
348 });
349 return actions;
350 }
351 if (call.tool !== "Edit" && call.tool !== "Write") return [];
352 const rawPath = String(call.file_path ?? "");
353 if (!rawPath) return [];
354 const file = resolve(cwd, rawPath);
355 const isManifest = basename(file) === "package.json";
356 const rule = sensitiveRule(file);
357 if (!isManifest && !rule && !SOURCE_FILE_RE.test(file)) return [];
358 const { before, after } = await contents(call, file, files);
359
360 if (isManifest) {
361 const changed = manifestChanges(before, after).map(npmSubject);
362 return changed.length ? [{ kind: "dependency-change", via: "manifest", subjects: changed }] : [];
363 }
364 const actions: GatedAction[] = [];
365 const unknown = (await newUnknownImports(file, before, after, files)).map(npmSubject);
366 if (unknown.length) {
367 actions.push({ kind: "new-external-import", file, subjects: unknown });
368 }
369 if (rule) actions.push({ kind: "sensitive-file-edit", file, label: rule.label, subjects: sensitiveSubjects(file, before, after, rule) });
370 return actions;
371}
372src/decide.ts 65 lines1// Whether the evidence in hand covers a call's gated actions, and the denial when it does not.
2
3import { isCovered } from "./coverage.ts";
4import { remedyFor } from "./ecosystems.ts";
5import type { Evidence, Finding, GatedAction, Subject, Verdict } from "./model.ts";
6import { shorten } from "./paths.ts";
7
8const names = (subjects: readonly Subject[]) => subjects.map((s) => s.name).join(", ");
9const them = (subjects: readonly unknown[]) => (subjects.length > 1 ? "them" : "it");
10const remedies = (subjects: readonly Subject[]) => subjects.map((s) => `\`${remedyFor(s)}\``).join("; ");
11
12// `reason` is what Claude reads; `summary` is one short line for a person (statusline-hud's band).
13type Denial = { reason: string; summary: string };
14
15function denial(action: GatedAction, evidence: readonly (Evidence | Finding)[], cwd: string): Denial | null {
16 if (action.kind === "sensitive-file-edit") {
17 const file = shorten(cwd, action.file);
18 if (action.subjects.length) {
19 if (action.subjects.some((s) => isCovered(evidence, s))) return null;
20 return {
21 reason: `${file} is a sensitive file (${action.label}) and this session has no current docs or registry evidence for any library it uses (${names(action.subjects.slice(0, 6))}). Read the current docs for the one this change depends on (a Context7 query, the official docs, a registry view, or the vendor's MCP), then retry.`,
22 summary: `editing ${file}: needs docs or registry evidence for one of ${names(action.subjects.slice(0, 3))}${action.subjects.length > 3 ? ", ..." : ""}`
23 };
24 }
25 if (evidence.length) return null;
26 return {
27 reason: `${file} is a sensitive file (${action.label}) and this session has consulted no docs or registry data yet. Check the docs of the platform or library this change depends on, then retry.`,
28 summary: `editing ${file}: needs a docs lookup first`
29 };
30 }
31
32 const known = action.kind === "dependency-change" && action.inline ? [...evidence, ...action.inline] : evidence;
33 const missing = action.subjects.filter((s) => !isCovered(known, s));
34 if (!missing.length) return null;
35 const lookup = `Run ${remedies(missing)}, or read ${them(missing) === "it" ? "its" : "their"} official docs or Context7`;
36 const needs = "needs registry or docs evidence";
37 if (action.kind === "dependency-change" && action.via === "manifest") {
38 return {
39 reason: `this edit adds or re-pins ${names(missing)} in package.json with no registry or docs evidence for ${them(missing)} this session. ${lookup}, so the version you pin is current, then retry.`,
40 summary: `pinning ${names(missing)} in package.json: ${needs}`
41 };
42 }
43 if (action.kind === "dependency-change") {
44 return {
45 reason: `this command adds, upgrades or installs ${names(missing)} with no registry or docs evidence for ${them(missing)} this session. ${lookup}, confirm the version and API you intend to use, then retry.`,
46 summary: `changing ${names(missing)}: ${needs}`
47 };
48 }
49 return {
50 reason: `this edit imports ${names(missing)}, which the nearest package.json does not declare and node_modules does not contain, and this session has no registry or docs evidence for ${them(missing)}. ${lookup}, add the dependency, then retry.`,
51 summary: `importing ${names(missing)}: ${needs}`
52 };
53}
54
55/** Allows the call when the evidence covers every gated action it takes; otherwise denies with one reason per uncovered action. */
56export function decide(actions: readonly GatedAction[], evidence: readonly (Evidence | Finding)[], cwd: string): Verdict {
57 const denials = actions.map((a) => denial(a, evidence, cwd)).filter((d): d is Denial => d !== null);
58 if (!denials.length) return { allow: true };
59 return {
60 allow: false,
61 reason: `docs-first: ${denials.map((d) => d.reason).join(" Also, ")}`,
62 summary: denials.map((d) => d.summary).join("; ")
63 };
64}
65src/ledger.ts 104 lines1// The ledger: the evidence and override one session holds, kept in the plugin's store under the session's id,
2// so resuming a session keeps it and clearing a session (a new id) starts empty.
3
4import type { Evidence, Finding } from "./model.ts";
5
6export const EVIDENCE_CAP = 200;
7export const OVERRIDE_MS = 15 * 60_000;
8export const LEDGER_TTL_MS = 14 * 86_400_000;
9const KEY_PREFIX = "ledger:";
10
11export type Denial = { at: number; tool: string; reason: string };
12export type Ledger = { evidence: Evidence[]; overrideUntil: number; lastDenial: Denial | null; touchedAt: number };
13
14/** The plugin's store, as the mods API offers it. */
15export type Store = {
16 get(key: string): Promise<unknown>;
17 set(key: string, value: unknown): Promise<void>;
18 delete(key: string): Promise<void>;
19 keys(): Promise<string[]>;
20};
21
22export function emptyLedger(): Ledger {
23 return { evidence: [], overrideUntil: 0, lastDenial: null, touchedAt: 0 };
24}
25
26/** A ledger read back from the store; anything malformed is dropped rather than trusted. */
27export function readLedger(raw: unknown): Ledger {
28 const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
29 const evidence = Array.isArray(r.evidence)
30 ? r.evidence.filter((e): e is Evidence => !!e && typeof e === "object" && (e.kind === "registry" || e.kind === "docs") && typeof e.subject === "string" && e.subject.length > 0)
31 : [];
32 const d = r.lastDenial as Record<string, unknown> | null | undefined;
33 const lastDenial = d && typeof d.reason === "string" ? { at: Number(d.at) || 0, tool: String(d.tool ?? ""), reason: d.reason } : null;
34 return { evidence, overrideUntil: Number(r.overrideUntil) || 0, lastDenial, touchedAt: Number(r.touchedAt) || 0 };
35}
36
37const identity = (e: Evidence | Finding) => (e.kind === "registry" ? `registry:${e.ecosystem}:${e.subject}` : `docs:${e.subject}`);
38
39/** Adds findings the ledger does not hold yet. True when something was added. */
40export function record(ledger: Ledger, findings: readonly Finding[], now: number): boolean {
41 const held = new Set(ledger.evidence.map(identity));
42 let added = false;
43 for (const f of findings) {
44 const id = identity(f);
45 if (held.has(id)) continue;
46 held.add(id);
47 ledger.evidence.push({ ...f, at: now } as Evidence);
48 added = true;
49 }
50 if (ledger.evidence.length > EVIDENCE_CAP) ledger.evidence.splice(0, ledger.evidence.length - EVIDENCE_CAP);
51 return added;
52}
53
54export function isOverridden(ledger: Ledger, now: number): boolean {
55 return ledger.overrideUntil > now;
56}
57
58/**
59 * Every session's ledger, loaded once per session and kept in memory. Changes mutate the one shared
60 * object and then write it whole, so parallel tool calls (subagents included) never lose each other's evidence.
61 */
62export class Ledgers {
63 private loaded = new Map<string, Promise<Ledger>>();
64
65 /** The session's ledger. A failed read rejects, so the caller's failure handling runs, and is retried on the next call. */
66 get(store: Store, sessionId: string): Promise<Ledger> {
67 let ledger = this.loaded.get(sessionId);
68 if (!ledger) {
69 ledger = store.get(KEY_PREFIX + sessionId).then(readLedger);
70 ledger.catch(() => this.loaded.delete(sessionId));
71 this.loaded.set(sessionId, ledger);
72 }
73 return ledger;
74 }
75
76 /** Applies a change and writes the ledger when the change reports that it changed something. */
77 async change(store: Store, sessionId: string, now: number, mutate: (ledger: Ledger) => boolean): Promise<Ledger> {
78 const ledger = await this.get(store, sessionId);
79 if (mutate(ledger)) {
80 ledger.touchedAt = now;
81 await store.set(KEY_PREFIX + sessionId, ledger);
82 }
83 return ledger;
84 }
85
86 /**
87 * Deletes ledgers untouched for the TTL, sparing sessions loaded in this process. The store has no
88 * compare-and-set, so a session another process resumes between this read and the delete can lose its
89 * evidence; that needs a two-week-old session resumed within one store round trip, and costs a lookup.
90 * Returns how many went.
91 */
92 async sweep(store: Store, now: number): Promise<number> {
93 let removed = 0;
94 for (const key of await store.keys()) {
95 if (!key.startsWith(KEY_PREFIX) || this.loaded.has(key.slice(KEY_PREFIX.length))) continue;
96 if (now - readLedger(await store.get(key)).touchedAt > LEDGER_TTL_MS) {
97 await store.delete(key);
98 removed += 1;
99 }
100 }
101 return removed;
102 }
103}
104src/model.ts 44 lines1// The gate's vocabulary as types. GLOSSARY.md defines each term.
2
3/** The package worlds the gate knows how to read. */
4export type Ecosystem = "npm" | "python" | "brew" | "cargo" | "go";
5
6/** A package or library name a gated action depends on. */
7export type Subject = { name: string; ecosystem: Ecosystem };
8
9/**
10 * A record that the session consulted a primary source about a subject.
11 * Registry evidence names one package of one ecosystem; docs evidence names a family.
12 */
13export type Evidence =
14 | { kind: "registry"; ecosystem: Ecosystem; subject: string; source: string; at: number }
15 | { kind: "docs"; subject: string; source: string; at: number };
16
17/** Evidence before it is stamped with a time. */
18export type Finding =
19 | { kind: "registry"; ecosystem: Ecosystem; subject: string; source: string }
20 | { kind: "docs"; subject: string; source: string };
21
22/** A tool call the gate judges before it runs. */
23export type GatedAction =
24 // `inline`: lookups the same command makes before this change, on success only.
25 | { kind: "dependency-change"; via: "command" | "manifest"; subjects: Subject[]; inline?: Finding[] }
26 | { kind: "new-external-import"; file: string; subjects: Subject[] }
27 | { kind: "sensitive-file-edit"; file: string; label: string; subjects: Subject[] };
28
29/** The gate's answer for one tool call. */
30export type Verdict = { allow: true } | { allow: false; reason: string; summary: string };
31
32/** A tool call as the gate reads it: the tool's name and its arguments as top-level fields. */
33export type ToolCall = { readonly tool: string; readonly [field: string]: unknown };
34
35/** What a tool call came back with, as far as the gate cares. */
36export type ToolOutcome = { readonly succeeded: boolean; readonly text: string };
37
38/** The project files the gate may read while classifying an edit. */
39export type ProjectFiles = {
40 /** The file's text, or null when it does not exist or cannot be read. */
41 read(path: string): Promise<string | null>;
42 exists(path: string): Promise<boolean>;
43};
44src/observe.ts 104 lines1// What evidence a finished tool call produced, and what a command looks up before its own changes.
2
3import { lookupsIn, registryPackageFromUrl } from "./ecosystems.ts";
4import type { Finding, ToolCall, ToolOutcome } from "./model.ts";
5import { commands, head, type Step } from "./shell.ts";
6import { context7Subjects, docsSubjectsFromUrl, urlsIn, vendorOfMcpTool } from "./sources.ts";
7
8// Output that means the lookup found nothing, even when the tool reported success (curl prints a 404 page and exits 0).
9const FAILURE_RE = /\b(E404|ERR_PNPM_FETCH_404|404 Not Found|ENOTFOUND|ECONNREFUSED|command not found|No such file or directory|Could not resolve|Request failed)\b/i;
10const FETCH_COMMANDS = new Set(["curl", "wget", "gh", "firecrawl", "ctx7", "http", "xh"]);
11const GITHUB_DOCS_TOOL_RE = /github__(get_file_contents|get_latest_release|list_releases|get_release_by_tag|search_code)$/i;
12// Tools that only read: a node_modules path in their input means the package's own files were read.
13const READ_TOOLS = new Set(["Read", "Glob", "Grep", "LS"]);
14// Flags that make a program print help or its version instead of doing its work.
15const HELP_FLAGS = new Set(["-h", "--help", "--version", "-V", "--manual"]);
16
17export function looksFailed(text: string): boolean {
18 return FAILURE_RE.test(String(text || "").slice(0, 400));
19}
20
21function urlFindings(url: string, source: string): Finding[] {
22 const out: Finding[] = docsSubjectsFromUrl(url).map((subject) => ({ kind: "docs", subject, source }));
23 const pkg = registryPackageFromUrl(url);
24 if (pkg) out.push({ kind: "registry", ecosystem: pkg.ecosystem, subject: pkg.name, source });
25 return out;
26}
27
28const urlsOf = (t: readonly string[]) => t.filter((x) => /^https?:\/\//.test(x));
29const asksForHelp = (t: readonly string[]) => t.slice(1).some((x) => HELP_FLAGS.has(x));
30
31/** The registry lookups one simple command makes: registry commands, and registry URLs a fetch program requests. */
32function registryFindingsOf(t: readonly string[], source: string): Finding[] {
33 if (asksForHelp(t)) return [];
34 const out: Finding[] = lookupsIn(t).map((s) => ({ kind: "registry", ecosystem: s.ecosystem, subject: s.name, source }));
35 if (FETCH_COMMANDS.has(head(t))) {
36 for (const url of urlsOf(t)) {
37 const pkg = registryPackageFromUrl(url);
38 if (pkg) out.push({ kind: "registry", ecosystem: pkg.ecosystem, subject: pkg.name, source });
39 }
40 }
41 return out;
42}
43
44/**
45 * The registry lookups a command line makes before its change at step `k`, credited only when the shell
46 * runs that change because the lookup succeeded: each step from the lookup to the change joined by `&&`,
47 * none of them inside a substitution. `pnpm view x && pnpm add x` stands on its own; `pnpm add x && pnpm
48 * view x`, `pnpm view x || pnpm add x` and `echo "$(pnpm view x)" && pnpm add x` do not.
49 */
50export function inlineEvidence(steps: readonly Step[], k: number): Finding[] {
51 const out: Finding[] = [];
52 if (steps[k]?.nested) return out;
53 for (let j = k - 1; j >= 0; j -= 1) {
54 if (!steps.slice(j + 1, k + 1).every((s) => s.chained && !s.nested)) break;
55 const step = steps[j];
56 if (step && !step.nested) out.push(...registryFindingsOf(step.tokens, "same command"));
57 }
58 return out;
59}
60
61function shellFindings(command: string): Finding[] {
62 const out: Finding[] = [];
63 for (const { tokens: t } of commands(command)) {
64 out.push(...registryFindingsOf(t, "shell"));
65 const h = head(t);
66 if (!FETCH_COMMANDS.has(h) || asksForHelp(t)) continue;
67 for (const url of urlsOf(t)) out.push(...docsSubjectsFromUrl(url).map((subject): Finding => ({ kind: "docs", subject, source: h })));
68 if (h === "gh" && t[1] === "api" && t[2]) {
69 const m = t[2].match(/^\/?repos\/[^/]+\/([^/]+)/);
70 if (m?.[1]) out.push({ kind: "docs", subject: m[1], source: "gh api" }, { kind: "docs", subject: "github", source: "gh api" });
71 }
72 }
73 return out;
74}
75
76function toolFindings(call: ToolCall): Finding[] {
77 const tool = String(call.tool);
78 const out: Finding[] = [];
79 if (READ_TOOLS.has(tool)) {
80 for (const field of ["file_path", "path", "pattern"]) {
81 const m = String(call[field] ?? "").match(/node_modules\/((?:@[^/]+\/)?[^/@]+)/);
82 if (m?.[1]) out.push({ kind: "registry", ecosystem: "npm", subject: m[1], source: "installed source" });
83 }
84 }
85 const isMcp = tool.startsWith("mcp__");
86 if (tool === "WebFetch" || isMcp) {
87 const urls = [call.url, call.urls].flat().filter((x): x is string => typeof x === "string");
88 for (const url of urlsIn(urls.join(" "))) out.push(...urlFindings(url, tool));
89 }
90 if (isMcp) {
91 if (/context7/i.test(tool)) out.push(...context7Subjects(call).map((subject): Finding => ({ kind: "docs", subject, source: "context7" })));
92 const vendor = vendorOfMcpTool(tool);
93 if (vendor) out.push({ kind: "docs", subject: vendor, source: tool });
94 if (GITHUB_DOCS_TOOL_RE.test(tool) && typeof call.repo === "string") out.push({ kind: "docs", subject: call.repo, source: tool });
95 }
96 return out;
97}
98
99/** The evidence a tool call produced. A call that failed, was refused, or came back with a not-found page produces none. */
100export function observe(call: ToolCall, outcome: ToolOutcome): Finding[] {
101 if (!outcome.succeeded || looksFailed(outcome.text)) return [];
102 return call.tool === "Bash" ? shellFindings(String(call.command ?? "")) : toolFindings(call);
103}
104src/override.ts 13 lines1// The override: a phrase the user types that turns the gate off for a while.
2
3const OVERRIDE_RE = /(?:DOCS_FIRST_OVERRIDE|HOOK_OVERRIDE|RESEARCH_GATE_OVERRIDE)\s*:|(?:^|\n)\s*(?:hook override|docs[- ]first override|override docs[- ]first|research gate override|override research gate)\b/i;
4
5// Prompt origins that are the person: typed at their terminal, or sent through Remote Control.
6// Everything else (other sessions, subagents, scheduled tasks, task notifications, channels, plugins,
7// `claude -p` callers such as Codex) is never the person and cannot turn the gate off.
8const PERSON_ORIGINS = new Set(["composer", "bridge"]);
9
10export function isOverrideRequest(text: string, originKind: string): boolean {
11 return PERSON_ORIGINS.has(originKind) && OVERRIDE_RE.test(String(text || ""));
12}
13src/report.ts 34 lines1// The text /docs-gate prints: what the gate holds for this session.
2
3import { isOverridden, type Ledger } from "./ledger.ts";
4
5const SHOWN = 30;
6
7function ago(ms: number): string {
8 const min = Math.round(ms / 60_000);
9 if (min < 1) return "just now";
10 if (min < 60) return `${min} min ago`;
11 return `${Math.round(min / 60)} h ago`;
12}
13
14export function describe(ledger: Ledger, now: number, gateError: string): string {
15 const state = gateError
16 ? `failing, so calls are allowed (${gateError})`
17 : isOverridden(ledger, now)
18 ? `overridden for ${Math.ceil((ledger.overrideUntil - now) / 60_000)} more min`
19 : "active";
20 const lines = [`Gate: ${state}`];
21 const evidence = ledger.evidence;
22 if (!evidence.length) {
23 lines.push("Evidence: none yet this session.");
24 } else {
25 lines.push(`Evidence (${evidence.length}${evidence.length > SHOWN ? `, newest ${SHOWN} shown` : ""}):`);
26 for (const e of evidence.slice(-SHOWN)) {
27 const kind = e.kind === "registry" ? `registry (${e.ecosystem})` : "docs";
28 lines.push(` ${kind}: ${e.subject}, from ${e.source}, ${ago(now - e.at)}`);
29 }
30 }
31 if (ledger.lastDenial) lines.push(`Last denial, ${ago(now - ledger.lastDenial.at)}, ${ledger.lastDenial.tool}: ${ledger.lastDenial.reason}`);
32 return lines.join("\n");
33}
34src/ecosystems.ts 287 lines1// One entry per package ecosystem: which commands change dependencies, which commands
2// look packages up, which URLs are its registry, and the lookup that produces evidence.
3// Supporting another ecosystem means adding one entry here.
4
5import type { Ecosystem, Subject } from "./model.ts";
6import { commands, head } from "./shell.ts";
7
8type Entry = {
9 id: Ecosystem;
10 /** Packages this simple command would add, upgrade, re-pin or scaffold. */
11 changes(t: readonly string[]): string[];
12 /** Packages this simple command looks up in the registry or reads from an install. */
13 lookups(t: readonly string[]): string[];
14 /** The package a registry page is about, or "" when the URL is not this ecosystem's registry. */
15 registryPackage(host: string, segs: readonly string[]): string;
16 /** The command that produces registry evidence for one package. */
17 remedy(name: string): string;
18};
19
20const NO_FLAGS: ReadonlySet<string> = new Set();
21
22/** The words after `index` that are not flags or flag values. */
23function argsAfter(t: readonly string[], index: number, valueFlags: ReadonlySet<string> = NO_FLAGS): string[] {
24 const names: string[] = [];
25 for (let i = index + 1; i < t.length; i += 1) {
26 const word = t[i] ?? "";
27 if (valueFlags.has(word)) { i += 1; continue; }
28 if (!word.startsWith("-")) names.push(word);
29 }
30 return names;
31}
32
33/** The index of the first word at or after `from` that is neither a flag nor a flag's value; -1 when none. */
34function subcommandIndex(t: readonly string[], from: number, valueFlags: ReadonlySet<string>): number {
35 for (let i = from; i < t.length; i += 1) {
36 const word = t[i] ?? "";
37 if (valueFlags.has(word)) { i += 1; continue; }
38 if (!word.startsWith("-")) return i;
39 }
40 return -1;
41}
42
43/** Packages named with `--package x`, `--package=x` or `-p x`: what a dlx or npx run fetches, whatever binary it then runs. */
44function packageFlags(t: readonly string[]): string[] {
45 const out: string[] = [];
46 t.forEach((word, i) => {
47 const inline = word.match(/^--package=(.+)$/);
48 const value = t[i + 1];
49 if (inline?.[1]) out.push(inline[1]);
50 else if ((word === "--package" || word === "-p") && value) out.push(value);
51 });
52 return out;
53}
54
55// Programs that only read files: a node_modules path in their arguments means the package's own files were read.
56const FILE_READERS = new Set(["cat", "head", "tail", "less", "more", "bat", "ls", "tree", "jq", "grep", "rg", "sed", "awk", "find", "wc", "file", "stat", "diff"]);
57
58function installedPackagesRead(t: readonly string[]): string[] {
59 if (!FILE_READERS.has(head(t))) return [];
60 const names: string[] = [];
61 for (const word of t.slice(1)) {
62 const m = word.match(/node_modules\/((?:@[^/\s]+\/)?[^/\s@]+)/);
63 if (m?.[1]) names.push(m[1]);
64 }
65 return names;
66}
67
68// ---------------------------------------------------------------------------
69// npm and the package managers that share its registry
70// ---------------------------------------------------------------------------
71
72// Each manager's flags that take a value. They differ: npm's -w names a workspace, pnpm's -w is a switch.
73const JS_VALUE_FLAGS: Record<string, ReadonlySet<string>> = {
74 pnpm: new Set(["--filter", "-F", "-C", "--dir", "--registry", "--package", "--reporter", "--loglevel", "--config"]),
75 npm: new Set(["-w", "--workspace", "--prefix", "--registry", "--tag", "--userconfig", "--cache", "--loglevel"]),
76 yarn: new Set(["--cwd", "--registry"]),
77 bun: new Set(["--cwd", "--registry", "--backend", "--filter", "-F"]),
78 npx: new Set(["-p", "--package", "-w", "--workspace", "--prefix", "--registry", "--cache"]),
79 bunx: new Set(["-p", "--package"])
80};
81const JS_CHANGE = new Set(["add", "install", "i", "up", "update", "upgrade"]);
82// `exec` runs an installed binary and fetches nothing, so it is not listed.
83const JS_FETCH_RUN: Record<string, Set<string>> = {
84 pnpm: new Set(["dlx", "create"]),
85 npm: new Set(["init"]),
86 yarn: new Set(["dlx", "create"]),
87 bun: new Set(["x", "create"])
88};
89const JS_VIEW = new Set(["view", "info", "show", "v"]);
90const REGISTRY_FIELDS = new Set(["version", "versions", "dist-tags", "dist-tags.latest", "dependencies", "peerdependencies", "devdependencies", "engines", "repository", "homepage", "time", "description", "main", "exports", "types", "license", "name", "--json"]);
91
92/**
93 * The registry package a spec names: `@scope/pkg@1.2.3` → `@scope/pkg`, `zod@3` → `zod`,
94 * and an alias `compat@npm:zod@3` → `zod`, the package actually fetched. "" for paths, git and workspace specs.
95 */
96export function npmPackageName(spec: string): string {
97 let s = String(spec || "").trim();
98 if (!s || s.startsWith("-")) return "";
99 const alias = s.indexOf("@npm:");
100 if (alias > 0) return npmPackageName(s.slice(alias + 5));
101 if (/^(file:|link:|workspace:|portal:|github:|git\+|git:|https?:|ssh:|\.|\/|~)/.test(s)) return "";
102 s = s.replace(/^npm:/, "");
103 if (s.startsWith("@")) {
104 const [scope = "", name = ""] = s.slice(1).split("/");
105 const bare = name.split("@")[0] ?? "";
106 return scope && bare ? `@${scope}/${bare}` : "";
107 }
108 return s.split("@")[0]?.split("/")[0] ?? "";
109}
110
111/** For a package manager command, the index of its subcommand, looking through `yarn workspace <name>` and `yarn npm`. */
112function jsSubcommand(t: readonly string[], h: string): number {
113 const flags = JS_VALUE_FLAGS[h] ?? NO_FLAGS;
114 let idx = subcommandIndex(t, 1, flags);
115 if (h === "yarn" && t[idx] === "workspace") idx = subcommandIndex(t, idx + 2, flags);
116 if (h === "yarn" && t[idx] === "npm") idx = subcommandIndex(t, idx + 1, flags);
117 return idx;
118}
119
120const npm: Entry = {
121 id: "npm",
122 changes(t) {
123 const h = head(t);
124 if (h in JS_FETCH_RUN) {
125 const idx = jsSubcommand(t, h);
126 if (idx < 0) return [];
127 const sub = t[idx] ?? "";
128 const flags = JS_VALUE_FLAGS[h] ?? NO_FLAGS;
129 // A bare `pnpm install` or `pnpm up` names no package and installs from the lockfile or ranges.
130 if (JS_CHANGE.has(sub)) return argsAfter(t, idx, flags).map(npmPackageName).filter(Boolean);
131 if (JS_FETCH_RUN[h]?.has(sub)) {
132 const flagged = packageFlags(t).map(npmPackageName).filter(Boolean);
133 if (flagged.length) return flagged;
134 const name = npmPackageName(argsAfter(t, idx, flags)[0] ?? "");
135 if (!name) return [];
136 const scaffolds = sub === "create" && !name.startsWith("create-") && !name.includes("/create-");
137 return [scaffolds ? `create-${name}` : name];
138 }
139 return [];
140 }
141 if (h === "npx" || h === "bunx") {
142 // npx runs an installed binary when one exists, so only an explicit fetch changes dependencies:
143 // a scaffolder (create-*), a pinned spec (pkg@version), -p/--package, or -y/--yes.
144 if (t.some((x) => /^(--version|-v|--help|-h)$/.test(x))) return [];
145 const flagged = packageFlags(t);
146 if (flagged.length) return flagged.map(npmPackageName).filter(Boolean);
147 const spec = argsAfter(t, 0, JS_VALUE_FLAGS[h])[0] ?? "";
148 const name = npmPackageName(spec);
149 if (!name) return [];
150 const pinned = spec.slice(spec.startsWith("@") ? 1 : 0).includes("@");
151 const explicitYes = t.some((x) => x === "-y" || x === "--yes");
152 return /^(@[^/]+\/)?create-/.test(name) || pinned || explicitYes ? [name] : [];
153 }
154 return [];
155 },
156 lookups(t) {
157 const h = head(t);
158 if (h in JS_FETCH_RUN) {
159 const idx = jsSubcommand(t, h);
160 if (idx < 0 || !JS_VIEW.has(t[idx] ?? "")) return [];
161 return argsAfter(t, idx, JS_VALUE_FLAGS[h]).filter((x) => !REGISTRY_FIELDS.has(x.toLowerCase())).map(npmPackageName).filter(Boolean);
162 }
163 return installedPackagesRead(t);
164 },
165 registryPackage(host, segs) {
166 // A scope and its name arrive as two path segments, or as one when the slash was percent-encoded.
167 const scoped = (a: string, b: string) => (a.startsWith("@") && !a.includes("/") ? `${a}/${b}` : a);
168 const [first = "", second = "", third = ""] = segs;
169 if (host === "registry.npmjs.org" && first) return scoped(first, second);
170 if (host === "npmjs.com" && first === "package" && second) return scoped(second, third);
171 return "";
172 },
173 remedy: (name) => `pnpm view ${name} version`
174};
175
176// ---------------------------------------------------------------------------
177// Python
178// ---------------------------------------------------------------------------
179
180const PIP_VALUE_FLAGS = new Set(["-r", "-c", "-e", "--requirement", "--constraint", "--editable", "--index-url", "-i", "--extra-index-url", "--target", "-t"]);
181
182const python: Entry = {
183 id: "python",
184 changes(t) {
185 const h = head(t);
186 const uvAdd = h === "uv" && t[1] === "add";
187 if (!["pip", "pip3", "pipx", "uv"].includes(h) || !(t.includes("install") || uvAdd)) return [];
188 const idx = t.indexOf(uvAdd ? "add" : "install");
189 return argsAfter(t, idx, PIP_VALUE_FLAGS)
190 .filter((w) => !/^(\.|\/|~)/.test(w) && !/\.(txt|whl|tar\.gz|zip)$/.test(w))
191 .map((w) => w.replace(/[<>=!~[;].*$/, ""))
192 .filter(Boolean);
193 },
194 lookups(t) {
195 const h = head(t);
196 if (h !== "pip" && h !== "pip3") return [];
197 if (t[1] === "show") return argsAfter(t, 1);
198 if (t[1] === "index" && t[2] === "versions") return argsAfter(t, 2);
199 return [];
200 },
201 registryPackage: (host, segs) => (host === "pypi.org" && (segs[0] === "pypi" || segs[0] === "project") && segs[1] ? segs[1] : ""),
202 remedy: (name) => `pip index versions ${name}`
203};
204
205// ---------------------------------------------------------------------------
206// Homebrew, Cargo, Go
207// ---------------------------------------------------------------------------
208
209const withoutVersion = (spec: string) => spec.split("@")[0] ?? spec;
210const withoutTap = (name: string) => name.replace(/^.*\//, "");
211const CARGO_VALUE_FLAGS = new Set(["--features", "-F", "--rename", "--package", "-p", "--path", "--git", "--branch", "--tag", "--rev", "--registry", "--target", "--manifest-path"]);
212
213const brew: Entry = {
214 id: "brew",
215 changes: (t) => (head(t) === "brew" && (t[1] === "install" || t[1] === "upgrade") ? argsAfter(t, 1).map(withoutTap) : []),
216 lookups: (t) => (head(t) === "brew" && t[1] === "info" ? argsAfter(t, 1).map(withoutTap) : []),
217 registryPackage: (host, segs) => (host === "formulae.brew.sh" && segs[1] ? segs[1].replace(/\.html$/, "") : ""),
218 remedy: (name) => `brew info ${name}`
219};
220
221const cargo: Entry = {
222 id: "cargo",
223 changes: (t) => (head(t) === "cargo" && t[1] === "add" ? argsAfter(t, 1, CARGO_VALUE_FLAGS).map(withoutVersion) : []),
224 lookups: (t) => (head(t) === "cargo" && (t[1] === "search" || t[1] === "info") ? argsAfter(t, 1, CARGO_VALUE_FLAGS) : []),
225 registryPackage: (host, segs) => (host === "crates.io" && segs[0] === "crates" && segs[1] ? segs[1] : ""),
226 remedy: (name) => `cargo info ${name}`
227};
228
229const go: Entry = {
230 id: "go",
231 changes: (t) => (head(t) === "go" && t[1] === "get" ? argsAfter(t, 1).map(withoutVersion) : []),
232 lookups: (t) => (head(t) === "go" && t[1] === "list" ? argsAfter(t, 1).map(withoutVersion) : []),
233 registryPackage: (host, segs) => (host === "pkg.go.dev" && segs.length ? withoutVersion(segs.join("/")) : ""),
234 remedy: (name) => `go list -m -versions ${name}`
235};
236
237const ENTRIES: readonly Entry[] = [npm, python, brew, cargo, go];
238const BY_ID = new Map(ENTRIES.map((e) => [e.id, e]));
239
240function collect(tokenLists: readonly (readonly string[])[], pick: (entry: Entry, t: readonly string[]) => string[]): Subject[] {
241 const seen = new Map<string, Subject>();
242 for (const t of tokenLists) {
243 for (const entry of ENTRIES) {
244 for (const name of pick(entry, t)) seen.set(`${entry.id}:${name}`, { name, ecosystem: entry.id });
245 }
246 }
247 return [...seen.values()];
248}
249
250/** Packages one simple command would add, upgrade, re-pin or scaffold. */
251export function changesIn(tokens: readonly string[]): Subject[] {
252 return collect([tokens], (entry, t) => entry.changes(t));
253}
254
255/** Packages one simple command looks up in a registry or reads from an install. */
256export function lookupsIn(tokens: readonly string[]): Subject[] {
257 return collect([tokens], (entry, t) => entry.lookups(t));
258}
259
260/** Packages a command line would add, upgrade, re-pin or scaffold. */
261export function dependencyChanges(command: string): Subject[] {
262 return collect(commands(command).map((s) => s.tokens), (entry, t) => entry.changes(t));
263}
264
265/** Packages a command line looks up in a registry or reads from an install. */
266export function registryLookups(command: string): Subject[] {
267 return collect(commands(command).map((s) => s.tokens), (entry, t) => entry.lookups(t));
268}
269
270/** The package a registry URL is about, or null. */
271export function registryPackageFromUrl(url: string): Subject | null {
272 let u: URL;
273 try { u = new URL(url); } catch { return null; }
274 const host = u.hostname.replace(/^www\./, "").toLowerCase();
275 const segs = u.pathname.split("/").filter(Boolean).map((s) => { try { return decodeURIComponent(s); } catch { return s; } });
276 for (const entry of ENTRIES) {
277 const name = entry.registryPackage(host, segs);
278 if (name) return { name, ecosystem: entry.id };
279 }
280 return null;
281}
282
283/** The one command that produces registry evidence for a subject. */
284export function remedyFor(subject: Subject): string {
285 return BY_ID.get(subject.ecosystem)!.remedy(subject.name);
286}
287src/paths.ts 35 lines1// POSIX path helpers: the mod runtime has no node:path.
2
3export function dirname(path: string): string {
4 const p = path.length > 1 ? path.replace(/\/+$/, "") : path;
5 const i = p.lastIndexOf("/");
6 if (i < 0) return ".";
7 return i === 0 ? "/" : p.slice(0, i);
8}
9
10export function basename(path: string): string {
11 return path.replace(/\/+$/, "").split("/").pop() || "";
12}
13
14export function join(dir: string, name: string): string {
15 return dir.endsWith("/") ? dir + name : `${dir}/${name}`;
16}
17
18/** `path` made absolute against `cwd`, with `.` and `..` segments resolved. */
19export function resolve(cwd: string, path: string): string {
20 const raw = path.startsWith("/") ? path : join(cwd || "/", path);
21 const out: string[] = [];
22 for (const seg of raw.split("/")) {
23 if (!seg || seg === ".") continue;
24 if (seg === "..") out.pop();
25 else out.push(seg);
26 }
27 return "/" + out.join("/");
28}
29
30/** `path` relative to `cwd` when it lies beneath it, otherwise unchanged. */
31export function shorten(cwd: string, path: string): string {
32 const base = cwd.endsWith("/") ? cwd : `${cwd}/`;
33 return cwd && path.startsWith(base) ? path.slice(base.length) : path;
34}
35src/shell.ts 225 lines1// Shell parsing: small and quote-aware, enough to read package-manager and fetch commands.
2
3/** What joins a simple command to the one before it. Only `&&` means "runs only if the previous one succeeded". */
4export type Connector = "start" | "&&" | "||" | ";" | "|" | "&" | "newline" | "substitution";
5
6/**
7 * One simple command of a command line: its text, the connector before it, and whether it runs inside
8 * a `$(...)`, backticks or an expanded heredoc, where its exit status decides nothing outside.
9 */
10export type Segment = { text: string; after: Connector; nested: boolean };
11
12type Heredoc = { delimiter: string; quoted: boolean; dash: boolean };
13
14// `<<EOF`, `<<-EOF`, `<<'EOF'`, `<<"EOF"`: the delimiter is a whole shell word, dots and all.
15const HEREDOC_RE = /^<<(-?)[ \t]*(?:'([^'\n]*)'|"([^"\n]*)"|([^\s;&|<>()'"`]+))/;
16
17function heredocAt(src: string, i: number): { length: number; heredoc: Heredoc } | null {
18 if (src[i] !== "<" || src[i + 1] !== "<" || src[i + 2] === "<") return null;
19 const m = src.slice(i).match(HEREDOC_RE);
20 const delimiter = m?.[2] ?? m?.[3] ?? m?.[4] ?? "";
21 if (!m || !delimiter) return null;
22 return { length: m[0].length, heredoc: { delimiter, quoted: m[2] !== undefined || m[3] !== undefined, dash: m[1] === "-" } };
23}
24
25/**
26 * Skips the bodies of the heredocs pending at the newline at `i`. Returns the index of the last character
27 * consumed, and the bodies the shell expands (unquoted delimiters), whose substitutions run.
28 */
29function skipHeredocs(src: string, i: number, pending: Heredoc[]): { end: number; expanded: string[] } {
30 const expanded: string[] = [];
31 let pos = i;
32 for (const doc of pending) {
33 const lines: string[] = [];
34 while (pos < src.length) {
35 const next = src.indexOf("\n", pos + 1);
36 const line = src.slice(pos + 1, next < 0 ? src.length : next);
37 pos = next < 0 ? src.length : next;
38 if ((doc.dash ? line.replace(/^\t+/, "") : line) === doc.delimiter) break;
39 lines.push(line);
40 }
41 if (!doc.quoted) expanded.push(lines.join("\n"));
42 }
43 pending.length = 0;
44 return { end: pos, expanded };
45}
46
47/** The index of the parenthesis that closes a `$(...)` whose body starts at `start`; heredoc bodies inside are skipped. */
48function substitutionEnd(src: string, start: number): number {
49 let depth = 1;
50 let quote = "";
51 const pending: Heredoc[] = [];
52 for (let j = start; j < src.length; j += 1) {
53 const ch = src[j];
54 if (quote) {
55 if (ch === "\\" && quote === "\"") j += 1;
56 else if (ch === quote) quote = "";
57 continue;
58 }
59 if (ch === "\\") { j += 1; continue; }
60 if (ch === "'" || ch === "\"") { quote = ch; continue; }
61 const doc = heredocAt(src, j);
62 if (doc) { pending.push(doc.heredoc); j += doc.length - 1; continue; }
63 if (ch === "\n" && pending.length) { j = skipHeredocs(src, j, pending).end; continue; }
64 if (ch === "(") depth += 1;
65 else if (ch === ")" && --depth === 0) return j;
66 }
67 return src.length;
68}
69
70/** The index of the backtick that closes one opened at `start`. */
71function backtickEnd(src: string, start: number): number {
72 let j = start + 1;
73 while (j < src.length && src[j] !== "`") j += src[j] === "\\" ? 2 : 1;
74 return j;
75}
76
77/**
78 * Splits a command line into simple commands at `&&`, `||`, `;`, `|`, `&` and newlines. The bodies of
79 * `$(...)` and backticks, quoted or not, and of those inside unquoted heredocs, become commands of their
80 * own, since the shell runs them. Comments and quoted heredoc bodies are data and are skipped.
81 */
82export function segments(command: string, nested = false): Segment[] {
83 const out: Segment[] = [];
84 const src = String(command || "");
85 let cur = "";
86 let quote = "";
87 let pending: Connector = "start";
88 const heredocs: Heredoc[] = [];
89 // Ends the current command. A blank or comment line keeps the connector before it, so `a &&` + newline + `b` stays chained.
90 const end = (next: Connector) => {
91 if (cur.trim()) { out.push({ text: cur.trim(), after: pending, nested }); pending = next; }
92 else if (next !== "newline") pending = next;
93 cur = "";
94 };
95 const substitute = (body: string) => {
96 segments(body, true).forEach((s, k) => out.push({ ...s, after: k === 0 ? "substitution" : s.after, nested: true }));
97 };
98 // The substitutions an expanded heredoc body runs.
99 const expand = (body: string) => {
100 for (let k = 0; k < body.length; k += 1) {
101 if (body[k] === "\\") { k += 1; continue; }
102 if (body[k] === "$" && body[k + 1] === "(") { const close = substitutionEnd(body, k + 2); substitute(body.slice(k + 2, close)); k = close; }
103 else if (body[k] === "`") { const close = backtickEnd(body, k); substitute(body.slice(k + 1, close)); k = close; }
104 }
105 };
106
107 for (let i = 0; i < src.length; i += 1) {
108 const ch = src[i] ?? "";
109 const next = src[i + 1] ?? "";
110 if (quote === "'") {
111 cur += ch;
112 if (ch === "'") quote = "";
113 continue;
114 }
115 if (ch === "\\") { cur += ch + next; i += 1; continue; }
116 if (ch === "$" && next === "(") {
117 const close = substitutionEnd(src, i + 2);
118 substitute(src.slice(i + 2, close));
119 i = close;
120 continue;
121 }
122 if (ch === "`") {
123 const close = backtickEnd(src, i);
124 substitute(src.slice(i + 1, close));
125 i = close;
126 continue;
127 }
128 if (quote === "\"") {
129 cur += ch;
130 if (ch === "\"") quote = "";
131 continue;
132 }
133 if (ch === "'" || ch === "\"") { quote = ch; cur += ch; continue; }
134 // A comment runs to the end of its line; a `#` inside a word (a URL fragment) is not one.
135 if (ch === "#" && (cur === "" || /\s$/.test(cur))) {
136 const nl = src.indexOf("\n", i);
137 i = (nl < 0 ? src.length : nl) - 1;
138 continue;
139 }
140 const doc = heredocAt(src, i);
141 if (doc) { heredocs.push(doc.heredoc); cur += src.slice(i, i + doc.length); i += doc.length - 1; continue; }
142 if (ch === "\n") {
143 end("newline");
144 if (heredocs.length) {
145 const skipped = skipHeredocs(src, i, heredocs);
146 skipped.expanded.forEach(expand);
147 i = skipped.end;
148 }
149 continue;
150 }
151 if (ch === "&" && next === "&") { end("&&"); i += 1; continue; }
152 if (ch === "|" && next === "|") { end("||"); i += 1; continue; }
153 // A lone `&` backgrounds the command before it; `2>&1` and `&>file` are redirections, not separators.
154 if (ch === "&" && next !== ">" && src[i - 1] !== ">" && src[i - 1] !== "<") { end("&"); continue; }
155 if (ch === ";") { end(";"); continue; }
156 if (ch === "|") { end("|"); continue; }
157 cur += ch;
158 }
159 end("start");
160 return out;
161}
162
163/** The simple commands of a command line, as text. */
164export function splitShell(command: string): string[] {
165 return segments(command).map((s) => s.text);
166}
167
168// A redirection operator alone (`>`, `2>>`, `&>`), whose target is the next word.
169const REDIRECT_ALONE_RE = /^(\d*|&)[<>]{1,2}$/;
170// A redirection with its target attached (`2>&1`, `>/dev/null`, `2>err.log`).
171const REDIRECT_ATTACHED_RE = /^(\d*|&)[<>]{1,2}\S/;
172const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
173// Commands that run the command after them, and their flags that take a value.
174const WRAPPERS = new Set(["sudo", "env", "command", "exec", "time", "nohup", "nice"]);
175const WRAPPER_VALUE_FLAGS = new Set(["-u", "-g", "-C", "-n", "-S"]);
176
177/**
178 * Splits one simple command into words, dropping redirections, leading `VAR=value` assignments,
179 * and wrappers such as `sudo` and `env`, so the first word is the program that runs.
180 */
181export function tokenize(segment: string): string[] {
182 const out: string[] = [];
183 const re = /"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g;
184 let m: RegExpExecArray | null;
185 let skipTarget = false;
186 while ((m = re.exec(segment))) {
187 const bare = m[3];
188 if (skipTarget) { skipTarget = false; continue; }
189 if (bare !== undefined && REDIRECT_ALONE_RE.test(bare)) { skipTarget = true; continue; }
190 if (bare !== undefined && REDIRECT_ATTACHED_RE.test(bare)) continue;
191 out.push(m[1] ?? m[2] ?? bare ?? "");
192 }
193 for (;;) {
194 while (out.length && ASSIGNMENT_RE.test(out[0] ?? "")) out.shift();
195 const word = out[0] ?? "";
196 if (!WRAPPERS.has(word)) break;
197 // `command -v` and `command -V` only describe a command; they run nothing.
198 if (word === "command" && (out[1] === "-v" || out[1] === "-V")) break;
199 out.shift();
200 while ((out[0] ?? "").startsWith("-")) {
201 const flag = out.shift() ?? "";
202 if (WRAPPER_VALUE_FLAGS.has(flag)) out.shift();
203 }
204 }
205 return out;
206}
207
208/** The program name of a tokenized command, without its directory. */
209export function head(tokens: readonly string[]): string {
210 return (tokens[0] || "").replace(/^.*\//, "");
211}
212
213/**
214 * A simple command's words; whether it runs only when the command before it succeeded; and whether it
215 * runs nested inside a substitution, where its failure does not stop the commands outside.
216 */
217export type Step = { tokens: string[]; chained: boolean; nested: boolean };
218
219/** The simple commands of a command line, tokenized, skipping empty ones. */
220export function commands(command: string): Step[] {
221 return segments(command)
222 .map((s) => ({ tokens: tokenize(s.text), chained: s.after === "&&", nested: s.nested }))
223 .filter((s) => s.tokens.length > 0);
224}
225