@a2f0/agent-tool 0.1.1

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.
Files changed (45) hide show
  1. package/README.md +288 -0
  2. package/package.json +36 -0
  3. package/skills/cross-agent-review/SKILL.md +46 -0
  4. package/skills/open-pr/SKILL.md +31 -0
  5. package/skills/reset/SKILL.md +25 -0
  6. package/skills/ship-pr/SKILL.md +48 -0
  7. package/skills/squash-merge/SKILL.md +39 -0
  8. package/src/cli.ts +121 -0
  9. package/src/config.ts +101 -0
  10. package/src/doctor.ts +23 -0
  11. package/src/git/currentBranchPr.ts +114 -0
  12. package/src/git/environment.ts +10 -0
  13. package/src/git/prContext.ts +497 -0
  14. package/src/harnesses.ts +53 -0
  15. package/src/index.ts +9 -0
  16. package/src/pr/assertBranchPushed.ts +21 -0
  17. package/src/pr/assertNoClaudeBranding.ts +52 -0
  18. package/src/pr/baseFreshness.ts +67 -0
  19. package/src/pr/mergeChecks.ts +90 -0
  20. package/src/pr/openPr.ts +80 -0
  21. package/src/pr/prNumberSuffix.ts +43 -0
  22. package/src/pr/squashMerge.ts +229 -0
  23. package/src/pr/subjectLine.ts +21 -0
  24. package/src/pr/validateCommitSubject.ts +12 -0
  25. package/src/review/destinationPaths.ts +154 -0
  26. package/src/review/executable.ts +34 -0
  27. package/src/review/pinnedReviewInput.ts +91 -0
  28. package/src/review/rawGitTree.ts +301 -0
  29. package/src/review/reviewEffort.ts +54 -0
  30. package/src/review/reviewOutput.ts +62 -0
  31. package/src/review/reviewPrompt.ts +104 -0
  32. package/src/review/reviewSnapshot.ts +67 -0
  33. package/src/review/runReview.ts +62 -0
  34. package/src/review/solicitClaudeCodeReview.ts +140 -0
  35. package/src/review/solicitCodexReview.ts +205 -0
  36. package/src/review/solicitOpencodeReview.ts +233 -0
  37. package/src/runAgentToolAction.ts +100 -0
  38. package/src/skills/bundled.ts +8 -0
  39. package/src/skills/install.ts +92 -0
  40. package/src/version/bumpVersions.ts +196 -0
  41. package/src/version/lockfileVersions.ts +43 -0
  42. package/src/version/packageVersion.ts +112 -0
  43. package/src/version/prepareVersions.ts +135 -0
  44. package/src/version/resolveVersionConflicts.ts +126 -0
  45. package/src/version/versionPreparationGit.ts +127 -0
@@ -0,0 +1,90 @@
1
+ import { run } from "../git/prContext";
2
+ import { loadConfig, type AgentToolConfig } from "../config";
3
+
4
+ type CheckPolicy = Pick<AgentToolConfig["merge"], "requireChecks" | "requiredChecks">;
5
+
6
+ function field(value: unknown, key: string): unknown {
7
+ return typeof value === "object" && value !== null
8
+ ? Reflect.get(value, key)
9
+ : undefined;
10
+ }
11
+
12
+ export function assertMergeChecks(source: string, expectedHead: string, policy: CheckPolicy = loadConfig().merge): void {
13
+ const payload: unknown = JSON.parse(source);
14
+ if (field(payload, "headRefOid") !== expectedHead) {
15
+ throw new Error(
16
+ "PR head changed while checking CI; re-review before merging.",
17
+ );
18
+ }
19
+ const checks = field(payload, "checks");
20
+ if (!Array.isArray(checks)) throw new Error("Could not read CI checks; refusing to merge.");
21
+ if (checks.length === 0 && policy.requireChecks) {
22
+ throw new Error("PR has no CI checks; refusing to merge.");
23
+ }
24
+ for (const check of checks) {
25
+ const name = field(check, "name");
26
+ const success = field(check, "state") === "SUCCESS";
27
+ const skipped = field(check, "state") === "SKIPPED";
28
+ if (!success && !skipped) {
29
+ throw new Error(
30
+ `CI check '${String(name)}' has not passed; refusing to merge.`,
31
+ );
32
+ }
33
+ }
34
+ for (const required of policy.requiredChecks) {
35
+ if (!checks.some(check => field(check, "name") === required.name && field(check, "state") === "SUCCESS" && (required.workflow === undefined || field(check, "workflow") === required.workflow))) {
36
+ throw new Error(
37
+ `Required CI check '${required.name}' has not passed; refusing to merge.`,
38
+ );
39
+ }
40
+ }
41
+ }
42
+
43
+ export function requirePassingMergeChecks(
44
+ pr: { readonly prNumber: string; readonly repo: string },
45
+ expectedHead: string,
46
+ read: typeof run = run,
47
+ policy: CheckPolicy = loadConfig().merge,
48
+ ): void {
49
+ // gh pr checks paginates and selects the latest run per workflow/job/event.
50
+ // Raw statusCheckRollup can retain failures from superseded runs. JSON mode
51
+ // can exit zero for pending/failed checks: assertMergeChecks enforces states.
52
+ // API and other command failures also throw before any merge.
53
+ let checks: unknown;
54
+ try {
55
+ checks = JSON.parse(
56
+ read("gh", [
57
+ "pr",
58
+ "checks",
59
+ pr.prNumber,
60
+ "-R",
61
+ pr.repo,
62
+ "--json",
63
+ "name,state,workflow",
64
+ ]),
65
+ );
66
+ } catch (error) {
67
+ if (policy.requireChecks || policy.requiredChecks.length) throw error;
68
+ // gh pr checks exits nonzero when no checks exist. Independently confirm
69
+ // an empty rollup before honoring an explicit no-check project policy.
70
+ const rollup = field(JSON.parse(read("gh", ["pr", "view", pr.prNumber, "-R", pr.repo, "--json", "statusCheckRollup"])), "statusCheckRollup");
71
+ if (!Array.isArray(rollup) || rollup.length !== 0) throw error;
72
+ checks = [];
73
+ }
74
+ const head: unknown = JSON.parse(
75
+ read("gh", [
76
+ "pr",
77
+ "view",
78
+ pr.prNumber,
79
+ "-R",
80
+ pr.repo,
81
+ "--json",
82
+ "headRefOid",
83
+ ]),
84
+ );
85
+ assertMergeChecks(
86
+ JSON.stringify({ headRefOid: field(head, "headRefOid"), checks }),
87
+ expectedHead,
88
+ policy,
89
+ );
90
+ }
@@ -0,0 +1,80 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import {
4
+ findOpenPrNumber,
5
+ remoteBranchHead,
6
+ resolveRepoContext,
7
+ resolveRepositoryGitUrl,
8
+ run,
9
+ spawnExitCode,
10
+ } from "../git/prContext";
11
+ import { assertBranchPushed } from "./assertBranchPushed";
12
+ import { assertNoClaudeBranding } from "./assertNoClaudeBranding";
13
+ import { singleLineSubject } from "./subjectLine";
14
+ import { validateCommitSubject } from "./validateCommitSubject";
15
+ import { loadConfig } from "../config";
16
+
17
+ /** Read the PR body from stdin, or "" when stdin is a terminal/empty. */
18
+ function readBody(): string {
19
+ // With no pipe/redirect, fd 0 is an open TTY and a blocking read would hang.
20
+ if (process.stdin.isTTY) {
21
+ return "";
22
+ }
23
+ try {
24
+ return readFileSync(0, "utf8");
25
+ } catch {
26
+ return "";
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Open a pull request for the current branch with a policy-conforming
32
+ * title. The title defaults to the branch's latest commit subject, is validated
33
+ * against data-only project policy before the PR is created; the body is read from
34
+ * stdin. The base defaults to the repository's default branch. The branch must
35
+ * already be pushed at the local head; this never pushes.
36
+ */
37
+ export function openPr(rootDir: string, titleArg: string | undefined): number {
38
+ const { branch, repo, defaultBranch } = resolveRepoContext();
39
+
40
+ const existing = findOpenPrNumber(branch, repo);
41
+ if (existing.length > 0) {
42
+ throw new Error(`An open PR already exists for '${branch}': #${existing}.`);
43
+ }
44
+
45
+ const tipSubject = run("git", ["log", "-1", "--format=%s"]);
46
+ const title = singleLineSubject(titleArg, tipSubject, "PR title");
47
+ validateCommitSubject(rootDir, title);
48
+
49
+ const body = readBody();
50
+ if (loadConfig(rootDir).pr.rejectClaudeBranding) assertNoClaudeBranding(body);
51
+
52
+ // Local validation first; the remote head is the last thing checked.
53
+ assertBranchPushed({
54
+ branch,
55
+ localHead: run("git", ["rev-parse", "HEAD"]),
56
+ remoteHead: remoteBranchHead(resolveRepositoryGitUrl(repo), branch),
57
+ });
58
+
59
+ // Pin the base to the repo default branch; without --base, gh honors a
60
+ // branch.<name>.gh-merge-base git config that could target another branch.
61
+ const baseArgs = defaultBranch.length > 0 ? ["--base", defaultBranch] : [];
62
+ const result = spawnSync(
63
+ "gh",
64
+ [
65
+ "pr",
66
+ "create",
67
+ "--title",
68
+ title,
69
+ "--body-file",
70
+ "-",
71
+ "--head",
72
+ branch,
73
+ ...baseArgs,
74
+ "-R",
75
+ repo,
76
+ ],
77
+ { input: body, stdio: ["pipe", "inherit", "inherit"] },
78
+ );
79
+ return spawnExitCode("gh pr create", result);
80
+ }
@@ -0,0 +1,43 @@
1
+ // Matches a trailing GitHub reference like " (#1531)" at the very end of a
2
+ // subject line. Anchored to the end so a mid-subject reference is left intact,
3
+ // and un-globbed so only the final reference is removed per call.
4
+ const PR_NUMBER_SUFFIX = /\s*\(#\d+\)$/;
5
+
6
+ /** Strip the single trailing `(#<n>)` reference from a subject, if present. */
7
+ export function stripPrNumberSuffix(subject: string): string {
8
+ return subject.replace(PR_NUMBER_SUFFIX, "");
9
+ }
10
+
11
+ /**
12
+ * Append ` (#<prNumber>)` to a subject that has already had its trailing
13
+ * reference removed (see `stripPrNumberSuffix`). Deliberately strip-free: the
14
+ * caller strips exactly once, so the validated base and the merged subject stay
15
+ * in sync and an earlier `(#<n>)` reference is never silently discarded. Throws
16
+ * when `prNumber` is not a positive integer.
17
+ */
18
+ export function appendPrNumberSuffix(
19
+ baseSubject: string,
20
+ prNumber: string,
21
+ ): string {
22
+ if (!/^\d+$/.test(prNumber)) {
23
+ throw new Error(
24
+ `Invalid PR number '${prNumber}' for the squash subject suffix.`,
25
+ );
26
+ }
27
+ const result = `${baseSubject} (#${prNumber})`;
28
+ assertPrNumberSuffix(result, prNumber);
29
+ return result;
30
+ }
31
+
32
+ /**
33
+ * Assert a subject ends with the exact `(#<prNumber>)` reference — the invariant
34
+ * every squash subject must satisfy before the merge runs.
35
+ */
36
+ export function assertPrNumberSuffix(subject: string, prNumber: string): void {
37
+ const suffix = ` (#${prNumber})`;
38
+ if (!subject.endsWith(suffix)) {
39
+ throw new Error(
40
+ `Squash subject must end with '${suffix}', got: '${subject}'.`,
41
+ );
42
+ }
43
+ }
@@ -0,0 +1,229 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ import { loadConfig } from "../config";
4
+ import { prState, resolvePr, run, spawnExitCode } from "../git/prContext";
5
+ import { assertStrictBaseFreshness } from "./baseFreshness";
6
+ import { requirePassingMergeChecks } from "./mergeChecks";
7
+ import {
8
+ appendPrNumberSuffix,
9
+ assertPrNumberSuffix,
10
+ stripPrNumberSuffix,
11
+ } from "./prNumberSuffix";
12
+ import { singleLineSubject } from "./subjectLine";
13
+ import { validateCommitSubject } from "./validateCommitSubject";
14
+
15
+ /**
16
+ * Resolve the squash subject from the CLI argument, falling back to the PR
17
+ * title. Rejects an empty subject and any embedded line break so the squash
18
+ * commit stays a single subject line.
19
+ */
20
+ export function resolveSubject(
21
+ subjectArg: string | undefined,
22
+ prTitle: string,
23
+ ): string {
24
+ return singleLineSubject(subjectArg, prTitle, "squash subject");
25
+ }
26
+
27
+ const PULL_REQUEST_MERGE_TARGET_QUERY = `
28
+ query($owner: String!, $name: String!, $number: Int!) {
29
+ repository(owner: $owner, name: $name) {
30
+ pullRequest(number: $number) {
31
+ autoMergeRequest { enabledAt }
32
+ baseRefName
33
+ headRefOid
34
+ id
35
+ isInMergeQueue
36
+ state
37
+ }
38
+ }
39
+ }
40
+ `;
41
+
42
+ const MERGE_PULL_REQUEST_MUTATION = `
43
+ mutation(
44
+ $pullRequestId: ID!
45
+ $commitHeadline: String!
46
+ $commitBody: String!
47
+ $expectedHeadOid: GitObjectID!
48
+ $mergeMethod: PullRequestMergeMethod!
49
+ ) {
50
+ mergePullRequest(input: {
51
+ pullRequestId: $pullRequestId
52
+ commitHeadline: $commitHeadline
53
+ commitBody: $commitBody
54
+ expectedHeadOid: $expectedHeadOid
55
+ mergeMethod: $mergeMethod
56
+ }) {
57
+ pullRequest { state }
58
+ }
59
+ }
60
+ `;
61
+
62
+ interface PullRequestMergeTarget {
63
+ readonly baseRefName: string;
64
+ readonly headOid: string;
65
+ readonly id: string;
66
+ }
67
+
68
+ function recordField(value: unknown, key: string): unknown {
69
+ return typeof value === "object" && value !== null
70
+ ? Reflect.get(value, key)
71
+ : undefined;
72
+ }
73
+
74
+ /** Parse the current target and reject unsafe asynchronous merge state. */
75
+ export function parsePullRequestMergeTarget(
76
+ source: string,
77
+ expectedBaseRef?: string,
78
+ ): PullRequestMergeTarget {
79
+ const parsed: unknown = JSON.parse(source);
80
+ const data = recordField(parsed, "data");
81
+ const repository = recordField(data, "repository");
82
+ const pullRequest = recordField(repository, "pullRequest");
83
+ const baseRefName = recordField(pullRequest, "baseRefName");
84
+ const id = recordField(pullRequest, "id");
85
+ const headOid = recordField(pullRequest, "headRefOid");
86
+ const state = recordField(pullRequest, "state");
87
+ if (
88
+ state !== "OPEN" ||
89
+ typeof baseRefName !== "string" ||
90
+ typeof id !== "string" ||
91
+ typeof headOid !== "string"
92
+ ) {
93
+ throw new Error("Could not resolve an open pull request merge target.");
94
+ }
95
+ if (
96
+ recordField(pullRequest, "isInMergeQueue") === true ||
97
+ recordField(pullRequest, "autoMergeRequest") !== null
98
+ ) {
99
+ throw new Error(
100
+ "Pull request already has a queued or automatic merge; cancel it before running squashMerge.",
101
+ );
102
+ }
103
+ if (expectedBaseRef !== undefined && baseRefName !== expectedBaseRef) {
104
+ throw new Error(
105
+ `Pull request base changed from '${expectedBaseRef}' to '${baseRefName}'; revalidate and re-review before merging.`,
106
+ );
107
+ }
108
+ return { baseRefName, headOid, id };
109
+ }
110
+
111
+ function resolvePullRequestMergeTarget(
112
+ pr: {
113
+ prNumber: string;
114
+ repo: string;
115
+ },
116
+ expectedBaseRef?: string,
117
+ ): PullRequestMergeTarget {
118
+ const repoParts = pr.repo.split("/");
119
+ if (repoParts.length !== 2 || repoParts.some((part) => part.length === 0)) {
120
+ throw new Error(`Invalid repository slug '${pr.repo}'.`);
121
+ }
122
+ const [owner, name] = repoParts;
123
+ const source = run("gh", [
124
+ "api",
125
+ "graphql",
126
+ "-f",
127
+ `query=${PULL_REQUEST_MERGE_TARGET_QUERY}`,
128
+ "-f",
129
+ `owner=${owner}`,
130
+ "-f",
131
+ `name=${name}`,
132
+ "-F",
133
+ `number=${pr.prNumber}`,
134
+ ]);
135
+ return parsePullRequestMergeTarget(source, expectedBaseRef);
136
+ }
137
+
138
+ /**
139
+ * Build a direct GraphQL merge mutation for a subject-only squash. Unlike
140
+ * `gh pr merge`, this cannot silently enable auto-merge or enqueue the PR.
141
+ */
142
+ export function buildSquashMergeArgs(
143
+ target: PullRequestMergeTarget,
144
+ finalSubject: string,
145
+ prNumber: string,
146
+ expectedHeadSha?: string,
147
+ ): string[] {
148
+ // Keep the final assertion at the mutation boundary so no caller can build a
149
+ // custom headline that accidentally suppresses GitHub's PR reference.
150
+ assertPrNumberSuffix(finalSubject, prNumber);
151
+ return [
152
+ "api",
153
+ "graphql",
154
+ "-f",
155
+ `query=${MERGE_PULL_REQUEST_MUTATION}`,
156
+ "-f",
157
+ `pullRequestId=${target.id}`,
158
+ "-f",
159
+ `commitHeadline=${finalSubject}`,
160
+ // Empty body keeps the squash commit to the subject line only.
161
+ "-f",
162
+ "commitBody=",
163
+ "-f",
164
+ `expectedHeadOid=${expectedHeadSha || target.headOid}`,
165
+ "-f",
166
+ "mergeMethod=SQUASH",
167
+ ];
168
+ }
169
+
170
+ /**
171
+ * Squash-merge the open PR for the current branch with a subject-only commit
172
+ * message — no auto-generated body or extended message. The subject defaults to
173
+ * the PR title when one is not supplied, and is validated against the repo's
174
+ * data-only subject policy before the merge runs. When `expectedHeadSha` is supplied the
175
+ * merge is bound to that commit through GraphQL's `expectedHeadOid` input. An
176
+ * expected base ref is checked immediately before the mutation, but GitHub has
177
+ * no corresponding atomic merge precondition for the target branch; policy can
178
+ * require a non-bypassable strict status rule that makes GitHub enforce it.
179
+ */
180
+ export function squashMerge(
181
+ rootDir: string,
182
+ subjectArg: string | undefined,
183
+ expectedHeadSha?: string,
184
+ expectedBaseRef?: string,
185
+ ): number {
186
+ const pr = resolvePr();
187
+ const subject = resolveSubject(subjectArg, pr.title);
188
+
189
+ // Validate the human-authored subject without the PR-number suffix: GitHub's
190
+ // native squash appends `(#<n>)` server-side, past the commit-msg hook, so the
191
+ // repo's existing history carries suffixes over its configured header limit. Keep
192
+ // the suffix "free" here too by validating the base, then append it ourselves —
193
+ // a custom GraphQL commit headline otherwise drops GitHub's automatic reference.
194
+ const baseSubject = stripPrNumberSuffix(subject);
195
+ validateCommitSubject(rootDir, baseSubject);
196
+ const finalSubject = appendPrNumberSuffix(baseSubject, pr.prNumber);
197
+ const mergeTarget = resolvePullRequestMergeTarget(pr, expectedBaseRef);
198
+ if (loadConfig(rootDir).merge.requireStrictBaseFreshness) {
199
+ assertStrictBaseFreshness(pr.repo, mergeTarget.baseRefName);
200
+ }
201
+ requirePassingMergeChecks(pr, expectedHeadSha || mergeTarget.headOid);
202
+
203
+ const result = spawnSync(
204
+ "gh",
205
+ buildSquashMergeArgs(
206
+ mergeTarget,
207
+ finalSubject,
208
+ pr.prNumber,
209
+ expectedHeadSha,
210
+ ),
211
+ { stdio: "inherit" },
212
+ );
213
+ const exitCode = spawnExitCode("GitHub mergePullRequest mutation", result);
214
+ if (exitCode !== 0) {
215
+ return exitCode;
216
+ }
217
+
218
+ // Confirm that GitHub committed the direct mutation before cleanup can run.
219
+ const state = prState(pr.prNumber, pr.repo);
220
+ if (state !== "MERGED") {
221
+ process.stderr.write(
222
+ `PR #${pr.prNumber} is not merged (state: ${state || "unknown"}). ` +
223
+ "The synchronous subject-only squash did not land.\n",
224
+ );
225
+ return 1;
226
+ }
227
+
228
+ return 0;
229
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Resolve a single-line commit-style subject: trim the supplied value, fall
3
+ * back to `fallback` when it is empty, and reject an empty result or any
4
+ * embedded line break so the value stays a single header line (commitlint would
5
+ * otherwise accept later lines as a body and let them through). `noun` labels
6
+ * the value in error messages.
7
+ */
8
+ export function singleLineSubject(
9
+ value: string | undefined,
10
+ fallback: string,
11
+ noun: string,
12
+ ): string {
13
+ const subject = (value ?? "").trim() || fallback;
14
+ if (subject.length === 0) {
15
+ throw new Error(`No ${noun} provided and no fallback is available.`);
16
+ }
17
+ if (/[\r\n]/.test(subject)) {
18
+ throw new Error(`${noun} must be a single line (no line breaks).`);
19
+ }
20
+ return subject;
21
+ }
@@ -0,0 +1,12 @@
1
+ import { loadConfig } from "../config";
2
+
3
+ /** Validate a data-only header policy without running branch-controlled code. */
4
+ export function validateCommitSubject(rootDir: string, subject: string): void {
5
+ const policy = loadConfig(rootDir).subject;
6
+ if (!subject.trim() || /[\r\n]/.test(subject)) throw new Error("Commit subject must be a nonempty single line.");
7
+ if ([...subject].length > policy.maxLength) throw new Error(`Commit subject exceeds ${policy.maxLength} characters.`);
8
+ if (policy.conventional) {
9
+ const match = /^([a-z]+)(?:\([^\r\n()]+\))?!?: \S.*$/.exec(subject);
10
+ if (!match || !policy.types.includes(match[1]!)) throw new Error(`Commit subject must use a conventional type: ${policy.types.join(", ")}.`);
11
+ }
12
+ }
@@ -0,0 +1,154 @@
1
+ import {
2
+ closeSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ openSync,
7
+ readdirSync,
8
+ rmSync,
9
+ } from "node:fs";
10
+ import path from "node:path";
11
+
12
+ function portableCollisionKey(relativePath: string): string {
13
+ return relativePath.normalize("NFC").toLowerCase().normalize("NFC");
14
+ }
15
+
16
+ export function destinationFor(rootDir: string, relativePath: string): string {
17
+ const resolvedRoot = path.resolve(rootDir);
18
+ const destination = path.resolve(resolvedRoot, ...relativePath.split("/"));
19
+ const relativeDestination = path.relative(resolvedRoot, destination);
20
+ if (
21
+ relativeDestination === "" ||
22
+ relativeDestination === ".." ||
23
+ relativeDestination.startsWith(`..${path.sep}`) ||
24
+ path.isAbsolute(relativeDestination)
25
+ ) {
26
+ throw new Error(
27
+ `Unsafe destination in reviewed Git tree: ${JSON.stringify(relativePath)}`,
28
+ );
29
+ }
30
+ return destination;
31
+ }
32
+
33
+ function collision(prior: string, candidate: string): never {
34
+ throw new Error(
35
+ `Colliding paths in reviewed Git tree: ${JSON.stringify(prior)} and ${JSON.stringify(candidate)}`,
36
+ );
37
+ }
38
+
39
+ function nodeIdentity(destination: string): string {
40
+ const stats = lstatSync(destination);
41
+ return `${stats.dev}:${stats.ino}`;
42
+ }
43
+
44
+ function priorFilesystemAlias(
45
+ destination: string,
46
+ identities: ReadonlyMap<string, string>,
47
+ ): string | undefined {
48
+ try {
49
+ return identities.get(nodeIdentity(destination));
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ function assertExactStoredName(
56
+ destination: string,
57
+ relativePath: string,
58
+ ): void {
59
+ const expectedName = Buffer.from(relativePath.split("/").at(-1) ?? "");
60
+ const exactNameExists = readdirSync(path.dirname(destination), {
61
+ encoding: "buffer",
62
+ }).some((name) => Buffer.compare(Buffer.from(name), expectedName) === 0);
63
+ if (!exactNameExists) {
64
+ throw new Error(
65
+ `Destination filesystem rewrote reviewed Git path: ${JSON.stringify(relativePath)}`,
66
+ );
67
+ }
68
+ }
69
+
70
+ function probeDestinationFilesystem(
71
+ rootDir: string,
72
+ relativePaths: string[],
73
+ ): void {
74
+ if (relativePaths.length === 0) {
75
+ return;
76
+ }
77
+ const directories = new Set<string>();
78
+ for (const relativePath of relativePaths) {
79
+ const segments = relativePath.split("/");
80
+ for (let end = 1; end < segments.length; end += 1) {
81
+ directories.add(segments.slice(0, end).join("/"));
82
+ }
83
+ }
84
+ const nodes = new Map<string, boolean>();
85
+ for (const directory of directories) {
86
+ nodes.set(directory, true);
87
+ }
88
+ for (const relativePath of relativePaths) {
89
+ if (nodes.has(relativePath)) {
90
+ collision(relativePath, relativePath);
91
+ }
92
+ nodes.set(relativePath, false);
93
+ }
94
+ const orderedNodes = [...nodes].sort(([left], [right]) => {
95
+ const depth = left.split("/").length - right.split("/").length;
96
+ return depth || Buffer.compare(Buffer.from(left), Buffer.from(right));
97
+ });
98
+ const probeRoot = mkdtempSync(
99
+ path.join(rootDir, ".agent-tool-destination-probe-"),
100
+ );
101
+ const identities = new Map<string, string>();
102
+ try {
103
+ for (const [relativePath, isDirectory] of orderedNodes) {
104
+ const destination = destinationFor(probeRoot, relativePath);
105
+ try {
106
+ if (isDirectory) {
107
+ mkdirSync(destination);
108
+ } else {
109
+ closeSync(openSync(destination, "wx"));
110
+ }
111
+ } catch (error) {
112
+ const prior = priorFilesystemAlias(destination, identities);
113
+ if (prior !== undefined) {
114
+ collision(prior, relativePath);
115
+ }
116
+ throw new Error(
117
+ `Destination filesystem rejected reviewed Git path: ${JSON.stringify(relativePath)}`,
118
+ { cause: error },
119
+ );
120
+ }
121
+ const identity = nodeIdentity(destination);
122
+ const prior = identities.get(identity);
123
+ if (prior !== undefined && prior !== relativePath) {
124
+ collision(prior, relativePath);
125
+ }
126
+ identities.set(identity, relativePath);
127
+ assertExactStoredName(destination, relativePath);
128
+ }
129
+ } finally {
130
+ rmSync(probeRoot, { recursive: true, force: true });
131
+ }
132
+ }
133
+
134
+ /** Reject paths that alias lexically or on the actual snapshot filesystem. */
135
+ export function validateDestinationPaths(
136
+ rootDir: string,
137
+ relativePaths: string[],
138
+ ): void {
139
+ const seenPaths = new Map<string, string>();
140
+ for (const relativePath of relativePaths) {
141
+ const segments = relativePath.split("/");
142
+ for (let end = 1; end <= segments.length; end += 1) {
143
+ const candidate = segments.slice(0, end).join("/");
144
+ const key = portableCollisionKey(candidate);
145
+ const prior = seenPaths.get(key);
146
+ if (prior !== undefined && prior !== candidate) {
147
+ collision(prior, candidate);
148
+ }
149
+ seenPaths.set(key, candidate);
150
+ }
151
+ destinationFor(rootDir, relativePath);
152
+ }
153
+ probeDestinationFilesystem(rootDir, relativePaths);
154
+ }
@@ -0,0 +1,34 @@
1
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { ReviewerEnv } from "./runReview";
4
+
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
+ }
30
+
31
+ /** Never launch a reviewer binary supplied by the repository being reviewed. */
32
+ export function reviewExecutable(name: string, env: ReviewerEnv, repositoryRoot = process.cwd()): string | undefined {
33
+ return outsideExecutable(name, env, repositoryRoot)?.resolved;
34
+ }