@a2f0/agent-tool 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,7 +69,7 @@ from a clone:
69
69
  mise install
70
70
  bun install --frozen-lockfile
71
71
  bun src/index.ts --help
72
- bun test
72
+ bun test --parallel
73
73
  ```
74
74
 
75
75
  [Development](docs/development.md) covers the source layout, the full
@@ -81,7 +81,7 @@ validation suite, and release procedures.
81
81
  PR checks/threads, cleanup, CI aggregate gates, release verification, durable
82
82
  validation receipts, and toolchain/protection diagnostics.
83
83
  - [CLI reference](docs/reference.md): existing review, version, skill installation,
84
- command capture, configuration, and distribution contracts.
84
+ command capture, import-graph checks, configuration, and distribution contracts.
85
85
  - [Development](docs/development.md): setup, source layout, validation, and release
86
86
  procedures for this package.
87
87
  - [Images](docs/images.md): declarative brand sources and generated outputs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "packageManager": "bun@1.4.3",
5
5
  "type": "module",
6
6
  "description": "Portable cross-agent review, PR workflows, and shared skills",
@@ -28,7 +28,7 @@
28
28
  "start": "bun src/index.ts",
29
29
  "agent:run": "bun src/index.ts run",
30
30
  "agent:logs": "bun src/index.ts logs",
31
- "test": "bun test src scripts",
31
+ "test": "bun test --parallel src scripts",
32
32
  "typecheck": "tsc --noEmit",
33
33
  "generate": "bun scripts/build/bundle-skills.ts",
34
34
  "build": "bun run generate && bun build --compile --minify src/index.ts --outfile dist/agent-tool"
@@ -7,7 +7,7 @@ import { assert, flag, option, output } from "../workflows/core";
7
7
  import { bunExecute } from "./execute";
8
8
  import { pinnedBun, type BunExecutable } from "./toolchain";
9
9
  import { lockedWorkspaceVersions, lostWorkspaceReferences } from "../version/lockfileVersions";
10
- import { packageDirectories, workspacePackage } from "./workspace";
10
+ import { packageDirectories, workspacePackage } from "../system/workspace";
11
11
 
12
12
  const SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"] as const;
13
13
  type Section = typeof SECTIONS[number];
@@ -8,7 +8,7 @@ import { CLASSES } from "../permissions/catalog";
8
8
  import { checkPermissions } from "../permissions/install";
9
9
  import { projectCommands } from "../permissions/project";
10
10
  import { pinnedBun } from "./toolchain";
11
- import { workspacePackage } from "./workspace";
11
+ import { workspacePackage } from "../system/workspace";
12
12
 
13
13
  const USAGE = `Usage:
14
14
  agent-tool bun test [--cwd <dir>] [--log] [-- args...]
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { accessSync, constants, existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
- import { outsideExecutable } from "../review/executable";
5
+ import { outsideExecutable } from "../system/executable";
6
6
  import { assert } from "../workflows/core";
7
7
 
8
8
  export interface BunExecutable { path: string; version: string; source: "mise" | "path"; pinned: string | null }
@@ -1,40 +1,2 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync, readFileSync, realpathSync } from "node:fs";
3
- import path from "node:path";
4
- import { assert } from "../workflows/core";
5
-
6
- /** The checkout root, where the workspace manifest and its lockfile live. */
7
- export function projectRoot(directory: string): string {
8
- const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf8" });
9
- assert(result.status === 0, "Run Bun commands inside a Git checkout.");
10
- return realpathSync(result.stdout.trim());
11
- }
12
-
13
- /** The root package and its declared Bun workspaces: the packages that share the root lockfile. */
14
- export function packageDirectories(root: string): string[] {
15
- const manifest = path.join(root, "package.json");
16
- if (!existsSync(manifest)) return [];
17
- const value = JSON.parse(readFileSync(manifest, "utf8")) as { workspaces?: unknown };
18
- const patterns = Array.isArray(value.workspaces) ? value.workspaces : (value.workspaces as { packages?: unknown } | undefined)?.packages;
19
- const list = (Array.isArray(patterns) ? patterns : []).filter((pattern): pattern is string => typeof pattern === "string");
20
- // `!pattern` entries exclude directories that the other patterns match.
21
- const excluded = list.filter(pattern => pattern.startsWith("!")).map(pattern => new Bun.Glob(pattern.slice(1).replace(/\/$/, "")));
22
- const directories = new Set([root]);
23
- for (const pattern of list.filter(pattern => !pattern.startsWith("!"))) {
24
- for (const match of new Bun.Glob(`${pattern.replace(/\/$/, "")}/package.json`).scanSync({ cwd: root, onlyFiles: true })) {
25
- const relative = path.dirname(match);
26
- if (!match.split("/").includes("node_modules") && !excluded.some(glob => glob.match(relative))) directories.add(path.join(root, relative));
27
- }
28
- }
29
- return [...directories];
30
- }
31
-
32
- /** Resolve a package directory that belongs to the checkout's workspace, refusing standalone packages. */
33
- export function workspacePackage(directory: string): { root: string; directory: string } {
34
- const resolved = realpathSync(directory);
35
- const root = projectRoot(resolved);
36
- // Harness rules come from the invoking checkout; --cwd may not reach another repository.
37
- assert(root === projectRoot(process.cwd()), "--cwd must name a package in this checkout.");
38
- assert(packageDirectories(root).some(item => realpathSync(item) === resolved), "Use the root package or a declared workspace; standalone packages keep their own lockfiles.");
39
- return { root, directory: resolved };
40
- }
1
+ // Moved to the primitives layer; re-exported so existing deep imports keep working.
2
+ export { packageDirectories, projectRoot, workspacePackage } from "../system/workspace";
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@ import { DEFAULT_CONFIG, loadConfig } from "./config";
5
5
  import { doctor } from "./doctor";
6
6
  import { checkTerraformPlan } from "./dependencies/terraformPlan";
7
7
  import { dependencyDiff } from "./dependencies/diff";
8
+ import { checkGraph } from "./graph/check";
8
9
  import { review } from "./harnesses";
9
10
  import { runAgentToolAction } from "./runAgentToolAction";
10
11
  import { BUNDLED_SKILLS } from "./skills/bundled";
@@ -72,6 +73,7 @@ Usage: agent-tool [--repo <directory>] [--config <file>] <command>
72
73
  permissions check [--harness <all|claude|codex>]
73
74
  images list [target...] JSON files each image target writes
74
75
  images build [target...] [--out <dir>] Render assets/brand/*.svg per agent-tool.json
76
+ graph check Import cycles and layer violations per agent-tool.json
75
77
  init Create agent-tool.json without overwriting
76
78
  config show
77
79
  doctor JSON versions and supported review flags
@@ -149,6 +151,11 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
149
151
  output(action === "list" ? listImages(images, names) : await buildImages(process.cwd(), images, names, out)); return 0;
150
152
  });
151
153
  }
154
+ if (command === "graph") {
155
+ usage(args.length === 1 && args[0] === "check", "Usage: agent-tool graph check");
156
+ const report = checkGraph(process.cwd(), loadConfig().graph);
157
+ output(report); return report.ok ? 0 : 1;
158
+ }
152
159
  if (command === "skills") {
153
160
  const action = args.shift();
154
161
  if (action === "list") { usage(args.length === 0, "Usage: agent-tool skills list"); output(Object.keys(BUNDLED_SKILLS)); return 0; }
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, lstatSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { GRAPH_DEFAULTS, parseGraph, type GraphConfig } from "./graph/config";
3
4
  import { parseImages, type ImagesConfig } from "./images/config";
4
5
  import { CLASSES, type PermissionClass } from "./permissions/catalog";
5
6
  import { WORKFLOW_DEFAULTS, validateWorkflowConfig, type WorkflowConfig } from "./workflows/config";
@@ -17,6 +18,7 @@ export interface AgentToolConfig extends WorkflowConfig {
17
18
  versions: { packages: string[] | null; bundles: string[]; lockfile: "bun.lock" | null; validate: string[][]; commitMessage: string };
18
19
  pr: { rejectClaudeBranding: boolean };
19
20
  images: ImagesConfig;
21
+ graph: GraphConfig;
20
22
  permissions: { executables: Record<string, PermissionClass> };
21
23
  }
22
24
 
@@ -36,6 +38,7 @@ export const DEFAULT_CONFIG: AgentToolConfig = {
36
38
  versions: { packages: null, bundles: [], lockfile: "bun.lock", validate: [], commitMessage: "chore: bump package versions" },
37
39
  pr: { rejectClaudeBranding: false },
38
40
  images: { targets: {} },
41
+ graph: structuredClone(GRAPH_DEFAULTS),
39
42
  permissions: { executables: {} },
40
43
  };
41
44
 
@@ -69,6 +72,7 @@ export function parseConfig(source: string): AgentToolConfig {
69
72
  Object.assign(result[section], settings);
70
73
  }
71
74
  if (input.images !== undefined) result.images = parseImages(input.images);
75
+ if (input.graph !== undefined) result.graph = parseGraph(input.graph);
72
76
  if (input.branch !== undefined && !Object.hasOwn(input.branch as object, "enforce")) result.branch.enforce = true;
73
77
  const invalid = (setting: string): never => { throw new Error(`Invalid config setting '${setting}'.`); };
74
78
  if (typeof result.subject.conventional !== "boolean") invalid("subject.conventional");
@@ -0,0 +1,139 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { GraphConfig } from "./config";
4
+ import { relativeImports } from "./scan";
5
+
6
+ /** Extensions `graph check` scans; declaration files (`.d.ts`, `.d.mts`, `.d.cts`) are skipped. */
7
+ const SOURCE = /\.(?:ts|tsx|mts|cts)$/;
8
+ const DECLARATION = /\.d\.(?:ts|mts|cts)$/;
9
+ // TypeScript resolves an emitted-JavaScript extension to its source.
10
+ const EMITTED: Record<string, string[]> = { ".js": [".ts", ".tsx"], ".jsx": [".tsx"], ".mjs": [".mts"], ".cjs": [".cts"] };
11
+
12
+ export type Graph = ReadonlyMap<string, ReadonlySet<string>>;
13
+
14
+ export interface GraphReport {
15
+ ok: boolean;
16
+ files: number;
17
+ imports: number;
18
+ cycles: string[][];
19
+ violations: string[];
20
+ unlayered: string[];
21
+ }
22
+
23
+ const matcher = (patterns: readonly string[]) => {
24
+ const globs = patterns.map(pattern => new Bun.Glob(pattern));
25
+ return (file: string) => globs.some(glob => glob.match(file));
26
+ };
27
+
28
+ /** Repository-relative POSIX paths of the scanned sources, sorted. */
29
+ export function sourceFiles(root: string, config: GraphConfig): string[] {
30
+ const excluded = matcher(config.exclude);
31
+ const files = new Set<string>();
32
+ for (const pattern of config.include) {
33
+ // Hidden directories hold sources too; Git metadata and installed packages never do.
34
+ for (const file of new Bun.Glob(pattern).scanSync({ cwd: root, onlyFiles: true, dot: true })) {
35
+ const relative = file.split(path.sep).join("/");
36
+ const parts = relative.split("/");
37
+ if (SOURCE.test(relative) && !DECLARATION.test(relative) && !parts.includes("node_modules") && !parts.includes(".git") && !excluded(relative)) files.add(relative);
38
+ }
39
+ }
40
+ return [...files].sort();
41
+ }
42
+
43
+ function isFile(root: string, file: string): boolean {
44
+ try { return statSync(path.join(root, file)).isFile(); } catch { return false; }
45
+ }
46
+
47
+ /**
48
+ * The scanned-or-not source file a relative specifier names: the exact path, an
49
+ * emitted `.js`/`.jsx`/`.mjs`/`.cjs` name's TypeScript source, an extensionless
50
+ * `.ts` or `.tsx`, or a directory's `index.ts`/`index.tsx`, in TypeScript's order.
51
+ */
52
+ export function resolveImport(root: string, from: string, specifier: string): string | undefined {
53
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier)).replace(/\/+$/, "");
54
+ if (base.startsWith("../") || base === "..") return undefined;
55
+ // `.`, `..`, and a trailing slash name a directory, so only its index can match.
56
+ const directory = /(?:^|\/)\.{1,2}$|\/$/.test(specifier);
57
+ // `.` is the repository root itself, whose index has no directory prefix.
58
+ const prefix = base === "." ? "" : `${base}/`;
59
+ const extension = path.posix.extname(base);
60
+ const stem = base.slice(0, base.length - extension.length);
61
+ const candidates = [
62
+ ...(directory ? [] : [base, ...(EMITTED[extension] ?? []).map(source => stem + source), `${base}.ts`, `${base}.tsx`]),
63
+ `${prefix}index.ts`, `${prefix}index.tsx`,
64
+ ];
65
+ return candidates.find(candidate => SOURCE.test(candidate) && !DECLARATION.test(candidate) && isFile(root, candidate));
66
+ }
67
+
68
+ /** Edges between scanned files; imports of files outside the scan are leaves. */
69
+ export function importGraph(root: string, files: readonly string[], read = (file: string) => readFileSync(path.join(root, file), "utf8")): Map<string, Set<string>> {
70
+ const scanned = new Set(files);
71
+ const graph = new Map<string, Set<string>>();
72
+ for (const file of files) {
73
+ const targets = new Set<string>();
74
+ for (const specifier of relativeImports(read(file))) {
75
+ const target = resolveImport(root, file, specifier);
76
+ if (target && scanned.has(target)) targets.add(target);
77
+ }
78
+ graph.set(file, targets);
79
+ }
80
+ return graph;
81
+ }
82
+
83
+ /** Each cycle once, rotated to start at its smallest module; at least one per strongly connected component. */
84
+ export function findCycles(graph: Graph): string[][] {
85
+ const cycles = new Map<string, string[]>();
86
+ const done = new Set<string>();
87
+ const stack: string[] = [];
88
+ const onStack = new Set<string>();
89
+ const visit = (file: string) => {
90
+ if (onStack.has(file)) {
91
+ const cycle = stack.slice(stack.indexOf(file));
92
+ const start = cycle.indexOf([...cycle].sort()[0]!);
93
+ const rotated = [...cycle.slice(start), ...cycle.slice(0, start)];
94
+ cycles.set(rotated.join(" -> "), rotated);
95
+ return;
96
+ }
97
+ if (done.has(file)) return;
98
+ stack.push(file); onStack.add(file);
99
+ for (const target of [...(graph.get(file) ?? [])].sort()) visit(target);
100
+ stack.pop(); onStack.delete(file);
101
+ done.add(file);
102
+ };
103
+ for (const file of [...graph.keys()].sort()) visit(file);
104
+ return [...cycles.values()].sort((a, b) => a.join(" -> ").localeCompare(b.join(" -> ")));
105
+ }
106
+
107
+ /**
108
+ * Layer and test-only violations. A file belongs to the first layer, lowest first,
109
+ * with a matching glob; production files may import only their own layer or lower
110
+ * ones, and never a test-only file. Test-only files may import anything.
111
+ */
112
+ export function layerViolations(graph: Graph, config: Pick<GraphConfig, "tests" | "layers">): { violations: string[]; unlayered: string[] } {
113
+ const isTest = matcher(config.tests);
114
+ const layers = config.layers.map(layer => matcher(layer.include));
115
+ const layerOf = (file: string) => layers.findIndex(owns => owns(file));
116
+ const violations: string[] = [];
117
+ const unlayered = new Set<string>();
118
+ for (const [file, targets] of graph) {
119
+ if (isTest(file)) continue;
120
+ const from = layerOf(file);
121
+ if (layers.length && from === -1) unlayered.add(file);
122
+ for (const target of targets) {
123
+ if (isTest(target)) { violations.push(`${file} imports test-only ${target}`); continue; }
124
+ const to = layerOf(target);
125
+ if (from !== -1 && to > from) violations.push(`${file} (${config.layers[from]!.name}) imports ${target} (${config.layers[to]!.name})`);
126
+ }
127
+ }
128
+ return { violations: violations.sort(), unlayered: [...unlayered].sort() };
129
+ }
130
+
131
+ export function checkGraph(root: string, config: GraphConfig): GraphReport {
132
+ if (config.include.length === 0) throw new Error("graph.include is empty; list the sources to scan in the graph section of agent-tool.json.");
133
+ const files = sourceFiles(root, config);
134
+ const graph = importGraph(root, files);
135
+ const cycles = findCycles(graph);
136
+ const { violations, unlayered } = layerViolations(graph, config);
137
+ const imports = [...graph.values()].reduce((total, targets) => total + targets.size, 0);
138
+ return { ok: cycles.length === 0 && violations.length === 0 && unlayered.length === 0, files: files.length, imports, cycles, violations, unlayered };
139
+ }
@@ -0,0 +1,43 @@
1
+ /** The data-only `graph` section of agent-tool.json: which TypeScript files `graph check` scans and their layers. */
2
+
3
+ export interface GraphLayer { name: string; include: string[] }
4
+ export interface GraphConfig { include: string[]; exclude: string[]; tests: string[]; layers: GraphLayer[] }
5
+
6
+ export const GRAPH_DEFAULTS: GraphConfig = { include: [], exclude: [], tests: [], layers: [] };
7
+
8
+ function fail(setting: string): never { throw new Error(`Invalid config setting '${setting}'.`); }
9
+ function object(value: unknown, setting: string, allowed: string[]): Record<string, unknown> {
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail(setting);
11
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new Error(`Unknown ${setting} setting '${key}'.`);
12
+ return value as Record<string, unknown>;
13
+ }
14
+
15
+ /** Repository-relative glob: no absolute paths, backslashes, negations, or `.`/`..` segments. */
16
+ function globs(value: unknown, setting: string): string[] {
17
+ if (!Array.isArray(value)) fail(setting);
18
+ for (const pattern of value) {
19
+ if (typeof pattern !== "string" || !pattern || pattern.startsWith("/") || pattern.startsWith("!") || pattern.includes("\\")
20
+ || pattern.split("/").some(part => !part || part === "." || part === "..")) fail(setting);
21
+ }
22
+ return [...value] as string[];
23
+ }
24
+
25
+ export function parseGraph(value: unknown): GraphConfig {
26
+ const input = object(value, "graph", Object.keys(GRAPH_DEFAULTS));
27
+ const result = structuredClone(GRAPH_DEFAULTS);
28
+ for (const key of ["include", "exclude", "tests"] as const) if (input[key] !== undefined) result[key] = globs(input[key], `graph.${key}`);
29
+ if (input.layers !== undefined) {
30
+ if (!Array.isArray(input.layers)) fail("graph.layers");
31
+ result.layers = input.layers.map((entry, index) => {
32
+ const setting = `graph.layers[${index}]`;
33
+ const layer = object(entry, setting, ["name", "include"]);
34
+ if (typeof layer.name !== "string" || !/^[\w.-]+$/.test(layer.name)) fail(`${setting}.name`);
35
+ const include = globs(layer.include, `${setting}.include`);
36
+ if (include.length === 0) fail(`${setting}.include`);
37
+ return { name: layer.name, include };
38
+ });
39
+ const names = result.layers.map(layer => layer.name);
40
+ if (new Set(names).size !== names.length) throw new Error("graph.layers names must be unique.");
41
+ }
42
+ return result;
43
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Import specifiers of a TypeScript source, type-only imports included. TypeScript 7
3
+ * has no JS compiler API and Bun.Transpiler.scanImports drops type-only imports, so
4
+ * this tokenizes just enough of the language to tell code from comments, strings,
5
+ * template text, and regular expression literals, then matches import forms.
6
+ */
7
+
8
+ /** Whether a `/` after this code starts a regular expression literal rather than a division. */
9
+ function startsRegex(before: string): boolean {
10
+ const code = before.trimEnd();
11
+ // `<` is left out so JSX closing tags (`</a>`) do not read as expressions; `a++ / b` divides.
12
+ // A postfix `!` after an operand is a non-null assertion (`value! / 2`), not logical not.
13
+ if (/(?:\+\+|--|[\w$)\]]!)$/.test(code)) return false;
14
+ return code === "" || /[(,=:[!&|?{};+\-*%>~^]$/.test(code)
15
+ || /(?<![.$\w])(?:return|typeof|case|in|of|delete|void|throw|new|yield|await|else|do)$/.test(code);
16
+ }
17
+
18
+ // A string or template right after one of these is a module specifier.
19
+ const SPECIFIER_CONTEXT = /(?:(?<![.$\w])from|(?<![.$\w])import\s*\(?|(?<![.$\w])require\s*\()\s*$/;
20
+ const CALL_CONTEXT = /(?<![.$\w])(?:import|require)\s*\(\s*$/;
21
+ // A `)` closing the condition of one of these is followed by a statement, so a `/` there starts a regex.
22
+ const CONTROL = /(?<![.$\w])(?:if|while|for(?:\s+await)?|with)\s*$/;
23
+
24
+ /**
25
+ * Blanks comments, template-literal text, regular expression bodies, and every string
26
+ * that is not a module specifier, keeping the code inside `${...}` interpolations,
27
+ * which can import too. Offsets and line breaks are preserved.
28
+ */
29
+ export function stripNonCode(source: string): string {
30
+ const out = source.split("");
31
+ const blank = (from: number, to: number) => { for (let i = from; i < to; i++) if (out[i] !== "\n") out[i] = " "; };
32
+ // The code before offset i with whitespace runs (including blanked comments) collapsed, so a
33
+ // long comment between `from` and its specifier cannot push `from` out of view.
34
+ const before = (i: number) => {
35
+ let text = "";
36
+ for (let j = i - 1; j >= 0 && text.length < 40; j--) {
37
+ if (/\s/.test(out[j]!)) { if (!text.startsWith(" ")) text = ` ${text}`; } else text = out[j] + text;
38
+ }
39
+ return text;
40
+ };
41
+ const lastCode = (i: number) => { let j = i - 1; while (j >= 0 && /\s/.test(out[j]!)) j--; return j; };
42
+ const regexAt = (i: number) => {
43
+ const last = out[lastCode(i)];
44
+ if (last === ")") return controlClose.has(lastCode(i));
45
+ // `}/>` closes a JSX element whose last attribute is an expression (`<A x={1}/>`).
46
+ if (last === "}" && source[i + 1] === ">") return false;
47
+ return startsRegex(before(i));
48
+ };
49
+ // Open parentheses, each true when it opened a control-flow condition, and the offsets of `)`s that closed one.
50
+ const parens: boolean[] = [];
51
+ const controlClose = new Set<number>();
52
+ // Each entry is the brace depth of an open interpolation; template text resumes when it closes.
53
+ const interpolations: number[] = [];
54
+ let depth = 0;
55
+ let i = 0;
56
+ if (source.startsWith("#!")) { i = source.indexOf("\n"); if (i === -1) i = source.length; blank(0, i); }
57
+ const skipTemplateText = () => {
58
+ const start = i;
59
+ while (i < source.length) {
60
+ if (source[i] === "\\") { i += 2; continue; }
61
+ if (source[i] === "`") { blank(start, i); i++; return; }
62
+ if (source[i] === "$" && source[i + 1] === "{") { blank(start, i); interpolations.push(depth); depth++; i += 2; return; }
63
+ i++;
64
+ }
65
+ blank(start, source.length);
66
+ };
67
+ while (i < source.length) {
68
+ const char = source[i]!;
69
+ if (char === "/" && source[i + 1] === "/") {
70
+ const end = source.indexOf("\n", i);
71
+ const stop = end === -1 ? source.length : end;
72
+ blank(i, stop); i = stop;
73
+ } else if (char === "/" && source[i + 1] === "*") {
74
+ const end = source.indexOf("*/", i + 2);
75
+ const stop = end === -1 ? source.length : end + 2;
76
+ blank(i, stop); i = stop;
77
+ } else if (char === "/" && regexAt(i)) {
78
+ // Skip to the closing slash, honoring escapes and character classes, where `/` is literal.
79
+ const start = ++i;
80
+ let inClass = false;
81
+ while (i < source.length && source[i] !== "\n" && (inClass || source[i] !== "/")) {
82
+ if (source[i] === "\\") i++;
83
+ else if (source[i] === "[") inClass = true;
84
+ else if (source[i] === "]") inClass = false;
85
+ i++;
86
+ }
87
+ blank(start, Math.min(i, source.length));
88
+ i++;
89
+ } else if (char === "\"" || char === "'") {
90
+ const specifier = SPECIFIER_CONTEXT.test(before(i));
91
+ const start = ++i;
92
+ while (i < source.length && source[i] !== char && source[i] !== "\n") i += source[i] === "\\" ? 2 : 1;
93
+ if (!specifier) blank(start, Math.min(i, source.length));
94
+ i++;
95
+ } else if (char === "`") {
96
+ // A substitution-free template right after `import(` or `require(` is a specifier; keep it.
97
+ const close = source.indexOf("`", i + 1);
98
+ const literal = close === -1 ? "" : source.slice(i + 1, close);
99
+ if (close !== -1 && !literal.includes("${") && !literal.includes("\\") && CALL_CONTEXT.test(before(i))) i = close + 1;
100
+ else { i++; skipTemplateText(); }
101
+ } else if (char === "(") { parens.push(CONTROL.test(before(i))); i++; }
102
+ else if (char === ")") { if (parens.pop()) controlClose.add(i); i++; }
103
+ else if (char === "{") { depth++; i++; }
104
+ else if (char === "}") {
105
+ depth--; i++;
106
+ if (interpolations.at(-1) === depth) { interpolations.pop(); skipTemplateText(); }
107
+ } else i++;
108
+ }
109
+ return out.join("");
110
+ }
111
+
112
+ const SPECIFIER = /(?<![.$\w])(?:import|export)(?:\s+type\b)?\s*(?:[\w*{}\s,$"']*?\bfrom\s*)?["']([^"'\n]+)["']|(?<![.$\w])(?:import|require)\s*\(\s*["'`]([^"'`\n]+)["'`]\s*[,)]/g;
113
+
114
+ /** Every module specifier in source order, bare packages included. */
115
+ export function importSpecifiers(source: string): string[] {
116
+ return [...stripNonCode(source).matchAll(SPECIFIER)].map(match => (match[1] ?? match[2])!);
117
+ }
118
+
119
+ /** Relative specifiers (`./` or `../`) in source order; bare packages are leaves of the graph. */
120
+ export function relativeImports(source: string): string[] {
121
+ return importSpecifiers(source).filter(specifier => specifier === "." || specifier === ".." || specifier.startsWith("./") || specifier.startsWith("../"));
122
+ }
@@ -19,7 +19,7 @@ export type Decision = "allow" | "ask";
19
19
  export interface CatalogEntry { command: string; class: PermissionClass; applyClass?: PermissionClass }
20
20
 
21
21
  export const CATALOG: CatalogEntry[] = [
22
- ...["--help", "--version", "logs", "runs list", "doctor", "config show", "branch check", "branch status", "commits check", "checks binaries", "repo context", "policy check", "toolchain check", "ci gate", "release plan", "release verify", "pr context", "pr checks", "pr logs", "pr preflight", "pr threads list", "versions plan", "versions check", "dependencies check-terraform-plan", "dependencies diff", "skills list", "skills check", "hooks check", "images list", "permissions show", "permissions check"]
22
+ ...["--help", "--version", "logs", "runs list", "doctor", "config show", "branch check", "branch status", "commits check", "checks binaries", "repo context", "policy check", "toolchain check", "ci gate", "release plan", "release verify", "pr context", "pr checks", "pr logs", "pr preflight", "pr threads list", "versions plan", "versions check", "dependencies check-terraform-plan", "dependencies diff", "skills list", "skills check", "hooks check", "images list", "graph check", "permissions show", "permissions check"]
23
23
  .map(command => ({ command, class: "read" as const })),
24
24
  ...["bun install", "bun update", "bun remove", "bun test", "branch create", "versions bump", "versions prepare", "versions resolve-conflicts", "images build", "review", "init"]
25
25
  .map(command => ({ command, class: "local" as const })),
@@ -1,32 +1,8 @@
1
- import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
- import path from "node:path";
1
+ import { outsideExecutable } from "../system/executable";
3
2
  import type { ReviewerEnv } from "./runReview";
4
3
 
5
- function within(root: string, candidate: string): boolean {
6
- const relative = path.relative(root, candidate);
7
- return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
8
- }
9
-
10
- /**
11
- * Find a PATH executable whose entry and target both lie outside the
12
- * repository. `candidate` keeps the PATH entry, which multi-call binaries such
13
- * as `bunx` need; `resolved` is its real file.
14
- */
15
- export function outsideExecutable(name: string, env: ReviewerEnv, repositoryRoot = process.cwd()): { candidate: string; resolved: string } | undefined {
16
- const root = realpathSync(repositoryRoot);
17
- for (const directory of (env.PATH ?? "").split(path.delimiter)) {
18
- if (!path.isAbsolute(directory)) continue;
19
- const candidate = path.join(directory, name);
20
- try {
21
- accessSync(candidate, constants.X_OK);
22
- const resolved = realpathSync(candidate);
23
- if (!statSync(resolved).isFile() || within(root, candidate) || within(root, resolved)) continue;
24
- return { candidate, resolved };
25
- } catch { /* Continue searching outside the repository. */ }
26
- }
27
- // Never fall back to a predictable path or PATH resolution after rejection.
28
- return undefined;
29
- }
4
+ // The generic PATH lookup lives in the primitives layer; re-exported for existing callers.
5
+ export { outsideExecutable };
30
6
 
31
7
  /** Never launch a reviewer binary supplied by the repository being reviewed. */
32
8
  export function reviewExecutable(name: string, env: ReviewerEnv, repositoryRoot = process.cwd()): string | undefined {
@@ -0,0 +1,28 @@
1
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ function within(root: string, candidate: string): boolean {
5
+ const relative = path.relative(root, candidate);
6
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
7
+ }
8
+
9
+ /**
10
+ * Find a PATH executable whose entry and target both lie outside the
11
+ * repository. `candidate` keeps the PATH entry, which multi-call binaries such
12
+ * as `bunx` need; `resolved` is its real file.
13
+ */
14
+ export function outsideExecutable(name: string, env: Record<string, string | undefined>, repositoryRoot = process.cwd()): { candidate: string; resolved: string } | undefined {
15
+ const root = realpathSync(repositoryRoot);
16
+ for (const directory of (env.PATH ?? "").split(path.delimiter)) {
17
+ if (!path.isAbsolute(directory)) continue;
18
+ const candidate = path.join(directory, name);
19
+ try {
20
+ accessSync(candidate, constants.X_OK);
21
+ const resolved = realpathSync(candidate);
22
+ if (!statSync(resolved).isFile() || within(root, candidate) || within(root, resolved)) continue;
23
+ return { candidate, resolved };
24
+ } catch { /* Continue searching outside the repository. */ }
25
+ }
26
+ // Never fall back to a predictable path or PATH resolution after rejection.
27
+ return undefined;
28
+ }
@@ -0,0 +1,40 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { assert } from "../workflows/core";
5
+
6
+ /** The checkout root, where the workspace manifest and its lockfile live. */
7
+ export function projectRoot(directory: string): string {
8
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf8" });
9
+ assert(result.status === 0, "Run Bun commands inside a Git checkout.");
10
+ return realpathSync(result.stdout.trim());
11
+ }
12
+
13
+ /** The root package and its declared Bun workspaces: the packages that share the root lockfile. */
14
+ export function packageDirectories(root: string): string[] {
15
+ const manifest = path.join(root, "package.json");
16
+ if (!existsSync(manifest)) return [];
17
+ const value = JSON.parse(readFileSync(manifest, "utf8")) as { workspaces?: unknown };
18
+ const patterns = Array.isArray(value.workspaces) ? value.workspaces : (value.workspaces as { packages?: unknown } | undefined)?.packages;
19
+ const list = (Array.isArray(patterns) ? patterns : []).filter((pattern): pattern is string => typeof pattern === "string");
20
+ // `!pattern` entries exclude directories that the other patterns match.
21
+ const excluded = list.filter(pattern => pattern.startsWith("!")).map(pattern => new Bun.Glob(pattern.slice(1).replace(/\/$/, "")));
22
+ const directories = new Set([root]);
23
+ for (const pattern of list.filter(pattern => !pattern.startsWith("!"))) {
24
+ for (const match of new Bun.Glob(`${pattern.replace(/\/$/, "")}/package.json`).scanSync({ cwd: root, onlyFiles: true })) {
25
+ const relative = path.dirname(match);
26
+ if (!match.split("/").includes("node_modules") && !excluded.some(glob => glob.match(relative))) directories.add(path.join(root, relative));
27
+ }
28
+ }
29
+ return [...directories];
30
+ }
31
+
32
+ /** Resolve a package directory that belongs to the checkout's workspace, refusing standalone packages. */
33
+ export function workspacePackage(directory: string): { root: string; directory: string } {
34
+ const resolved = realpathSync(directory);
35
+ const root = projectRoot(resolved);
36
+ // Harness rules come from the invoking checkout; --cwd may not reach another repository.
37
+ assert(root === projectRoot(process.cwd()), "--cwd must name a package in this checkout.");
38
+ assert(packageDirectories(root).some(item => realpathSync(item) === resolved), "Use the root package or a declared workspace; standalone packages keep their own lockfiles.");
39
+ return { root, directory: resolved };
40
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { packageDirectories } from "../bun/workspace";
3
+ import { packageDirectories } from "../system/workspace";
4
4
  import { manifestPath, readVersion, workspacePackages } from "./packageVersion";
5
5
 
6
6
  function field(value: unknown, key: string): unknown {
@@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { gitEnvironment } from "../git/environment";
5
- import { outsideExecutable } from "../review/executable";
5
+ import { outsideExecutable } from "../system/executable";
6
6
 
7
7
  /** Helper Git operations run without hooks or replace objects. */
8
8
  export function git(rootDir: string, args: readonly string[]): string {