@a2f0/agent-tool 0.1.29 → 0.1.31
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 +1 -1
- package/package.json +1 -1
- package/skills/open-pr/SKILL.md +2 -1
- package/skills/reset/SKILL.md +2 -0
- package/skills/ship-pr/SKILL.md +11 -1
- package/src/bun/dependencies.ts +1 -1
- package/src/bun/execute.ts +1 -1
- package/src/bun/toolchain.ts +1 -1
- package/src/bun/workspace.ts +2 -40
- package/src/cli.ts +13 -0
- package/src/commands/run.ts +11 -2
- package/src/config.ts +4 -0
- package/src/graph/check.ts +139 -0
- package/src/graph/config.ts +43 -0
- package/src/graph/scan.ts +122 -0
- package/src/permissions/catalog.ts +4 -3
- package/src/processes/commands.ts +283 -0
- package/src/processes/ledger.ts +123 -0
- package/src/processes/table.ts +111 -0
- package/src/review/executable.ts +3 -27
- package/src/skills/bundled.ts +3 -3
- package/src/system/executable.ts +28 -0
- package/src/system/workspace.ts +40 -0
- package/src/version/lockfileVersions.ts +1 -1
- package/src/version/versionPreparationGit.ts +1 -1
package/README.md
CHANGED
|
@@ -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
package/skills/open-pr/SKILL.md
CHANGED
|
@@ -33,4 +33,5 @@ open PRs and requires the branch to be pushed at its local HEAD. It does not
|
|
|
33
33
|
push for you. Resolve a fork's head/base identities explicitly if the repository
|
|
34
34
|
uses fork PRs; the initial open helper supports same-repository branches.
|
|
35
35
|
|
|
36
|
-
Report the created PR link and any remaining validation or review work.
|
|
36
|
+
Report the created PR link and any remaining validation or review work. Run
|
|
37
|
+
`agent-tool teardown` first, stop what the work started, and report what remains.
|
package/skills/reset/SKILL.md
CHANGED
|
@@ -39,6 +39,8 @@ switch, and update by fast-forward only. Never discard or automatically stash
|
|
|
39
39
|
edits, force a diverged default branch, or infer shipping from Git ancestry after
|
|
40
40
|
a squash merge.
|
|
41
41
|
|
|
42
|
+
Run `agent-tool teardown` and stop what the finished work left running
|
|
43
|
+
(`teardown --apply` for recorded processes); report anything that remains.
|
|
42
44
|
Run the repository's documented dependency/hook setup if required. When copied
|
|
43
45
|
hooks are configured, use `agent-tool hooks check` and `hooks install --apply`;
|
|
44
46
|
inspect unmanaged hooks before an explicit `--adopt`, which backs them up.
|
package/skills/ship-pr/SKILL.md
CHANGED
|
@@ -23,6 +23,9 @@ machine-readable receipts and review findings visible by running `versions
|
|
|
23
23
|
prepare`, `review`, and other commands whose output drives a decision directly.
|
|
24
24
|
Prefer documented compact test reporters when they preserve required evidence;
|
|
25
25
|
do not reduce test scope, skip checks, silence warnings, or mask failures.
|
|
26
|
+
Start servers, watchers, and other long-running processes with
|
|
27
|
+
`agent-tool proc start --label <name> -- <command>`, never a backgrounded shell
|
|
28
|
+
command, so they can be found and stopped.
|
|
26
29
|
|
|
27
30
|
1. Finish the requested change on a feature branch. Use
|
|
28
31
|
`agent-tool branch create <branch> --base <base-branch> --remote <remote>`
|
|
@@ -73,8 +76,15 @@ do not reduce test scope, skip checks, silence warnings, or mask failures.
|
|
|
73
76
|
live checks. Report deployment failures
|
|
74
77
|
separately from a successful merge; follow project policy for repairs.
|
|
75
78
|
|
|
79
|
+
Before the final report, run `agent-tool teardown` in each repository this work
|
|
80
|
+
touched. Stop what the work started (`teardown --apply`, `proc stop`), stop any
|
|
81
|
+
untracked process it started, and remove worktrees it added. Report what
|
|
82
|
+
remains and why; the work is not done while teardown fails for anything it
|
|
83
|
+
started.
|
|
84
|
+
|
|
76
85
|
Never infer a clean review from process success alone or carry a review across
|
|
77
86
|
content changes. Do not weaken configured CI policy to get a merge through.
|
|
78
87
|
If a step fails, preserve useful intermediate work and report the concrete
|
|
79
88
|
remaining issue. Report PR link, merged commit, review verdict, validation, final
|
|
80
|
-
checkout state, and whether the shipped local branch was
|
|
89
|
+
checkout state, the teardown result, and whether the shipped local branch was
|
|
90
|
+
pruned or preserved.
|
package/src/bun/dependencies.ts
CHANGED
|
@@ -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 "
|
|
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];
|
package/src/bun/execute.ts
CHANGED
|
@@ -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 "
|
|
11
|
+
import { workspacePackage } from "../system/workspace";
|
|
12
12
|
|
|
13
13
|
const USAGE = `Usage:
|
|
14
14
|
agent-tool bun test [--cwd <dir>] [--log] [-- args...]
|
package/src/bun/toolchain.ts
CHANGED
|
@@ -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 "../
|
|
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 }
|
package/src/bun/workspace.ts
CHANGED
|
@@ -1,40 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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";
|
|
@@ -26,6 +27,7 @@ import { hooksCommand } from "./workflows/hooks";
|
|
|
26
27
|
import { repoCommand, policyCommand, toolchainCommand } from "./workflows/repository";
|
|
27
28
|
import { prWorkflowCommand } from "./workflows/pr";
|
|
28
29
|
import { releaseCommand } from "./release/commands";
|
|
30
|
+
import { procCommand, teardownCommand } from "./processes/commands";
|
|
29
31
|
|
|
30
32
|
export const HELP = `agent-tool ${metadata.version}
|
|
31
33
|
|
|
@@ -33,6 +35,9 @@ Usage: agent-tool [--repo <directory>] [--config <file>] <command>
|
|
|
33
35
|
|
|
34
36
|
run [--label text] [--max-bytes N] [--tail-lines N] -- <command> [args...]
|
|
35
37
|
run-group <plan.json> Sequential checks with durable source-bound receipts
|
|
38
|
+
proc start [--label text] [--keep] -- <command> [args...] Recorded long-running process
|
|
39
|
+
proc list | stop <id...>|--all [--grace-ms N]
|
|
40
|
+
teardown [--apply] What this checkout still runs; --apply stops recorded processes
|
|
36
41
|
runs list [--head oid] [--branch name] [--since ISO-date]
|
|
37
42
|
branch check [name...|--pre-push] | status
|
|
38
43
|
branch create <name> [--base branch] [--remote origin]
|
|
@@ -72,6 +77,7 @@ Usage: agent-tool [--repo <directory>] [--config <file>] <command>
|
|
|
72
77
|
permissions check [--harness <all|claude|codex>]
|
|
73
78
|
images list [target...] JSON files each image target writes
|
|
74
79
|
images build [target...] [--out <dir>] Render assets/brand/*.svg per agent-tool.json
|
|
80
|
+
graph check Import cycles and layer violations per agent-tool.json
|
|
75
81
|
init Create agent-tool.json without overwriting
|
|
76
82
|
config show
|
|
77
83
|
doctor JSON versions and supported review flags
|
|
@@ -109,6 +115,8 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
|
|
|
109
115
|
if (command === "run") return runCommand(args);
|
|
110
116
|
if (command === "ci") { usage(args.shift() === "gate", "Usage: ci gate <needs.json|-> [--policy policy.json]"); return gateCommand(args); }
|
|
111
117
|
if (command === "release") return releaseCommand(args);
|
|
118
|
+
if (command === "proc") return procCommand(args);
|
|
119
|
+
if (command === "teardown") return teardownCommand(args);
|
|
112
120
|
if (command === "logs") return inspectLog(args);
|
|
113
121
|
if (command === "dependencies" && args[0] === "diff") {
|
|
114
122
|
usage(args.length === 3 && args[1] === "--base" && !!args[2] && !args[2].startsWith("-"), "Usage: agent-tool dependencies diff --base <ref>");
|
|
@@ -149,6 +157,11 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
|
|
|
149
157
|
output(action === "list" ? listImages(images, names) : await buildImages(process.cwd(), images, names, out)); return 0;
|
|
150
158
|
});
|
|
151
159
|
}
|
|
160
|
+
if (command === "graph") {
|
|
161
|
+
usage(args.length === 1 && args[0] === "check", "Usage: agent-tool graph check");
|
|
162
|
+
const report = checkGraph(process.cwd(), loadConfig().graph);
|
|
163
|
+
output(report); return report.ok ? 0 : 1;
|
|
164
|
+
}
|
|
152
165
|
if (command === "skills") {
|
|
153
166
|
const action = args.shift();
|
|
154
167
|
if (action === "list") { usage(args.length === 0, "Usage: agent-tool skills list"); output(Object.keys(BUNDLED_SKILLS)); return 0; }
|
package/src/commands/run.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createLog } from "./logFile";
|
|
|
6
6
|
|
|
7
7
|
const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
|
|
8
8
|
|
|
9
|
-
export interface RunReceipt { status: string; label?: string; exitCode: number; signal: string | null; durationMs: number; outputBytes: number; log: string }
|
|
9
|
+
export interface RunReceipt { status: string; label?: string; exitCode: number; signal: string | null; durationMs: number; outputBytes: number; log: string; leftoverProcessGroup?: number }
|
|
10
10
|
|
|
11
11
|
/** Run directly into a private file: child output never accumulates in memory. */
|
|
12
12
|
export async function runCommand(args: string[], record?: (receipt: RunReceipt) => void, options: { quiet?: boolean; reraiseChildSignal?: boolean } = {}): Promise<number> {
|
|
@@ -79,9 +79,18 @@ export async function runCommand(args: string[], record?: (receipt: RunReceipt)
|
|
|
79
79
|
forward("SIGKILL");
|
|
80
80
|
}
|
|
81
81
|
const exitCode = signal ? 128 + (constants.signals[signal] ?? 0) : status ?? 1;
|
|
82
|
-
|
|
82
|
+
// A finite command that left processes behind in its group, such as `server &`, is reported:
|
|
83
|
+
// nothing else would notice them before the work is called done.
|
|
84
|
+
let leftover: number | undefined;
|
|
85
|
+
if (grouped && !interrupted && child.pid) {
|
|
86
|
+
// EPERM still means the group exists, now holding a process this user cannot signal.
|
|
87
|
+
try { process.kill(-child.pid, 0); leftover = child.pid; }
|
|
88
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "EPERM") leftover = child.pid; }
|
|
89
|
+
}
|
|
90
|
+
const receipt: RunReceipt = { status: exitCode === 0 ? "passed" : "failed", ...(label ? { label } : {}), exitCode, signal, durationMs: Math.round(performance.now() - started), outputBytes: fstatSync(fd).size, log, ...(leftover ? { leftoverProcessGroup: leftover } : {}) };
|
|
83
91
|
record?.(receipt);
|
|
84
92
|
if (!options.quiet) writeSync(1, `${JSON.stringify(receipt)}\n`);
|
|
93
|
+
if (leftover) writeSync(2, `Processes this command started are still running in its process group ${leftover} (\`kill -TERM -${leftover}\`). Stop them, or start long-running processes with \`agent-tool proc start\`.\n`);
|
|
85
94
|
if (exitCode !== 0) printLogTail(log, limits, 2);
|
|
86
95
|
} finally {
|
|
87
96
|
if (timer) clearTimeout(timer);
|
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,12 +19,13 @@ 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", "proc list"]
|
|
23
23
|
.map(command => ({ command, class: "read" as const })),
|
|
24
|
-
...["bun install", "bun update", "bun remove", "bun test", "branch create", "versions bump", "versions prepare", "versions resolve-conflicts", "images build", "review", "init"]
|
|
24
|
+
...["bun install", "bun update", "bun remove", "bun test", "branch create", "versions bump", "versions prepare", "versions resolve-conflicts", "images build", "review", "init", "proc stop"]
|
|
25
25
|
.map(command => ({ command, class: "local" as const })),
|
|
26
26
|
{ command: "skills install", class: "read", applyClass: "local" },
|
|
27
27
|
{ command: "hooks install", class: "read", applyClass: "local" },
|
|
28
|
+
{ command: "teardown", class: "read", applyClass: "local" },
|
|
28
29
|
{ command: "permissions install", class: "read", applyClass: "policy" },
|
|
29
30
|
...["branch push", "pr open", "pr threads reply", "pr threads resolve"].map(command => ({ command, class: "share" as const })),
|
|
30
31
|
{ command: "pr merge", class: "release" },
|
|
@@ -36,7 +37,7 @@ export const CATALOG: CatalogEntry[] = [
|
|
|
36
37
|
{ command: "openPr", class: "share" },
|
|
37
38
|
{ command: "squashMerge", class: "release" },
|
|
38
39
|
];
|
|
39
|
-
// `run
|
|
40
|
+
// `run`, `run-group` and `proc start` execute arbitrary commands, so they stay unclassified and keep the harness default.
|
|
40
41
|
// `bun x` runs only executables that `permissions.executables` classifies; project.ts adds those.
|
|
41
42
|
// Package scripts are not classified, so `bun run` keeps the harness default.
|
|
42
43
|
|