@a2f0/agent-tool 0.1.23 → 0.1.25

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.
@@ -1,5 +1,6 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { packageDirectories } from "../bun/workspace";
3
4
  import { manifestPath, readVersion, workspacePackages } from "./packageVersion";
4
5
 
5
6
  function field(value: unknown, key: string): unknown {
@@ -41,3 +42,126 @@ export function assertBunLockVersions(
41
42
  );
42
43
  }
43
44
  }
45
+
46
+ const SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
47
+
48
+ function entries(value: unknown): [string, string][] {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value)
50
+ ? Object.entries(value).map(([name, spec]) => [name, typeof spec === "string" ? spec : JSON.stringify(spec)])
51
+ : [];
52
+ }
53
+
54
+ /**
55
+ * Third-party declarations that bun.lock does not already record, as
56
+ * `<manifest>: <name>@<spec>`: a package added or re-sourced by hand rather
57
+ * than through an approved add. Every manifest Bun installs is compared with
58
+ * the lockfile's record of that workspace, section by section, and the root's
59
+ * catalogs, overrides, and patches with the lockfile's. A new workspace may
60
+ * only use a third-party package at a spec the lockfile already records.
61
+ */
62
+ export function unresolvedDependencies(
63
+ rootDir: string,
64
+ lockSource = readFileSync(path.join(rootDir, "bun.lock"), "utf8"),
65
+ ): string[] {
66
+ const lock = Bun.JSONC.parse(lockSource);
67
+ const workspaces = field(lock, "workspaces");
68
+ const root = realpathSync(rootDir);
69
+ const manifests = packageDirectories(root).map((directory) => ({
70
+ key: path.relative(root, directory).split(path.sep).join("/"),
71
+ manifest: JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8")) as Record<string, unknown>,
72
+ }));
73
+ const known = new Set<string>();
74
+ for (const [, workspace] of entries(workspaces).length ? Object.entries(workspaces as object) : []) {
75
+ for (const section of SECTIONS) for (const [name, spec] of entries(field(workspace, section))) known.add(`${name}@${spec}`);
76
+ }
77
+ // A name the lockfile resolved to a workspace that no longer exists would now come from the registry.
78
+ const current = new Set(manifests.map(({ manifest }) => manifest.name));
79
+ const removed = new Set(Object.values((workspaces ?? {}) as Record<string, unknown>).map((workspace) => field(workspace, "name")).filter((name) => typeof name === "string" && !current.has(name)));
80
+ const missing = new Set<string>();
81
+ for (const { key, manifest } of manifests) {
82
+ const recorded = field(workspaces, key);
83
+ for (const section of SECTIONS) {
84
+ for (const [name, spec] of entries(manifest[section])) {
85
+ if (removed.has(name)) missing.add(`${key || "."}: ${name}@${spec} (its workspace was removed)`);
86
+ // A workspace's name with a version spec can still resolve from the registry; only `workspace:` is local.
87
+ if (spec.startsWith("workspace:")) continue;
88
+ // A relative source names different code from a new directory, so only position-independent specs carry over.
89
+ const relative = /^(?:file:|link:|\.{1,2}\/|\/|~\/)/.test(spec);
90
+ const covered = recorded === undefined ? !relative && known.has(`${name}@${spec}`) : field(field(recorded, section), name) === spec;
91
+ if (!covered) missing.add(`${key || "."}: ${name}@${spec}`);
92
+ }
93
+ }
94
+ }
95
+ const rootManifest = manifests.find(({ key }) => key === "")?.manifest ?? {};
96
+ const nested = field(rootManifest, "workspaces");
97
+ const catalogs = field(rootManifest, "catalogs") ?? field(nested, "catalogs");
98
+ const sources: [string, unknown, unknown][] = [
99
+ ["catalog", field(rootManifest, "catalog") ?? field(nested, "catalog"), field(lock, "catalog")],
100
+ // Names from both sides, so deleting a whole named catalog is compared too.
101
+ ...[...new Set([...entries(catalogs), ...entries(field(lock, "catalogs"))].map(([name]) => name))].map((name): [string, unknown, unknown] => [`catalogs.${name}`, field(catalogs, name), field(field(lock, "catalogs"), name)]),
102
+ ["overrides", field(rootManifest, "overrides") ?? field(rootManifest, "resolutions"), field(lock, "overrides")],
103
+ ["patchedDependencies", field(rootManifest, "patchedDependencies"), field(lock, "patchedDependencies")],
104
+ ];
105
+ for (const [label, declared, recorded] of sources) {
106
+ // Nested overrides are objects on both sides; compare them as JSON, in both directions.
107
+ for (const [name, spec] of entries(declared)) if (entries(recorded).find(([key]) => key === name)?.[1] !== spec) missing.add(`${label}: ${name}@${spec}`);
108
+ for (const [name] of entries(recorded)) if (!entries(declared).some(([key]) => key === name)) missing.add(`${label}: removed ${name}`);
109
+ }
110
+ return [...missing].sort();
111
+ }
112
+
113
+ function workspaceVersions(rootDir: string): Map<string, string> {
114
+ const root = realpathSync(rootDir);
115
+ const versions = new Map<string, string>();
116
+ for (const directory of packageDirectories(root)) {
117
+ const manifest = JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8")) as Record<string, unknown>;
118
+ if (typeof manifest.name === "string" && typeof manifest.version === "string") versions.set(manifest.name, manifest.version);
119
+ }
120
+ return versions;
121
+ }
122
+
123
+ /** Workspace versions as bun.lock last resolved them, by package name. */
124
+ export function lockedWorkspaceVersions(
125
+ rootDir: string,
126
+ lockSource = readFileSync(path.join(rootDir, "bun.lock"), "utf8"),
127
+ ): Map<string, string> {
128
+ const versions = new Map<string, string>();
129
+ for (const workspace of Object.values((field(Bun.JSONC.parse(lockSource), "workspaces") ?? {}) as Record<string, unknown>)) {
130
+ const name = field(workspace, "name"), version = field(workspace, "version");
131
+ if (typeof name === "string" && typeof version === "string") versions.set(name, version);
132
+ }
133
+ return versions;
134
+ }
135
+
136
+ /**
137
+ * Declarations that a workspace satisfied at its locked version but no longer
138
+ * does, whether a committed or a prepared bump moved it, so Bun would resolve
139
+ * them from the registry instead of the workspace.
140
+ */
141
+ export function lostWorkspaceReferences(rootDir: string, before: ReadonlyMap<string, string>): string[] {
142
+ const root = realpathSync(rootDir);
143
+ const after = workspaceVersions(root);
144
+ const rootManifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as Record<string, unknown>;
145
+ const nested = field(rootManifest, "workspaces");
146
+ // `catalog:` names a root catalog entry; compare the version it stands for.
147
+ const resolve = (name: string, spec: string): unknown => {
148
+ if (!spec.startsWith("catalog:")) return spec;
149
+ const catalog = spec.slice("catalog:".length);
150
+ return catalog && catalog !== "default"
151
+ ? field(field(field(rootManifest, "catalogs") ?? field(nested, "catalogs"), catalog), name)
152
+ : field(field(rootManifest, "catalog") ?? field(nested, "catalog"), name);
153
+ };
154
+ const lost = new Set<string>();
155
+ for (const directory of packageDirectories(root)) {
156
+ const manifest = JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8")) as Record<string, unknown>;
157
+ const key = path.relative(root, directory).split(path.sep).join("/") || ".";
158
+ for (const section of SECTIONS) {
159
+ for (const [name, spec] of entries(manifest[section])) {
160
+ const old = before.get(name), now = after.get(name), range = resolve(name, spec);
161
+ if (spec.startsWith("workspace:") || old === undefined || now === undefined || typeof range !== "string") continue;
162
+ if (Bun.semver.satisfies(old, range) && !Bun.semver.satisfies(now, range)) lost.add(`${key}: ${name}@${spec}`);
163
+ }
164
+ }
165
+ }
166
+ return [...lost].sort();
167
+ }
@@ -1,7 +1,7 @@
1
1
  import { loadConfig } from "../config";
2
2
  import { validateCommitSubject } from "../pr/validateCommitSubject";
3
3
  import { checkVersions, planVersions, rewriteVersions } from "./bumpVersions";
4
- import { assertBunLockVersions } from "./lockfileVersions";
4
+ import { assertBunLockVersions, lockedWorkspaceVersions, lostWorkspaceReferences, unresolvedDependencies } from "./lockfileVersions";
5
5
  import {
6
6
  assertAllowedPaths,
7
7
  assertClean,
@@ -69,11 +69,24 @@ export function prepareVersions(rootDir: string, baseOid?: string): number {
69
69
  const allowed = [...pending.map((plan) => plan.manifest), ...(lockfile ? [lockfile] : [])];
70
70
  let committed = false;
71
71
  let lockfileChanged = false;
72
+ if (lockfile) {
73
+ // The refresh below is unfrozen; it may record workspace changes, never resolve a hand-added package.
74
+ const missing = unresolvedDependencies(rootDir);
75
+ if (missing.length) {
76
+ throw new Error(`bun.lock does not record ${missing.join(", ")}; add or change third-party packages through agent-tool bun commands, or refresh the lockfile with approval, before preparing versions.`);
77
+ }
78
+ }
79
+ const versionsBefore = lockfile ? lockedWorkspaceVersions(rootDir) : undefined;
72
80
  try {
73
81
  assertHead(rootDir, startHead);
74
82
  rewriteVersions(rootDir, baseCommit);
83
+ if (versionsBefore) {
84
+ // A bump can leave a version-pinned reference unsatisfied by its workspace, sending it to the registry.
85
+ const lost = lostWorkspaceReferences(rootDir, versionsBefore);
86
+ if (lost.length) throw new Error(`The version bump leaves ${lost.join(", ")} unsatisfied by the workspace, so Bun would fetch it from the registry; use a workspace: spec.`);
87
+ }
75
88
  if (lockfile) {
76
- // Also repairs a stale lockfile after a deliberate release or new package.
89
+ // Records the bumped workspace versions in the lockfile.
77
90
  runProjectCommand(rootDir, ["bun", "install", "--lockfile-only", "--ignore-scripts"]);
78
91
  assertBunLockVersions(rootDir);
79
92
  }
@@ -1,6 +1,8 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { gitEnvironment } from "../git/environment";
3
- import { readFileSync } from "node:fs";
3
+ import { closeSync, readFileSync } from "node:fs";
4
+ import { createLog } from "../commands/logFile";
5
+ import { DEFAULT_LIMITS, logTail, outputLimit } from "../commands/output";
4
6
  import { loadConfig } from "../config";
5
7
  import { remoteBranchHead, resolvePr, resolveRepositoryGitUrl } from "../git/prContext";
6
8
  import { assertMergeChecks } from "../pr/mergeChecks";
@@ -39,6 +41,29 @@ export function checkSnapshot(pr: { prNumber: string; repo: string }, expected:
39
41
  }
40
42
  }
41
43
 
44
+ const NOT_FAILED = ["SUCCESS", "SKIPPED", "NEUTRAL", "PENDING", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED", "EXPECTED"];
45
+
46
+ /** Each failed GitHub Actions job of the PR head, saved to a private log with a bounded tail. */
47
+ export function failedJobLogs(pr: { prNumber: string; repo: string }, expected: string, limits = DEFAULT_LIMITS) {
48
+ const view = json<{ headRefOid: string }>(["pr", "view", pr.prNumber, "-R", pr.repo, "--json", "headRefOid"]);
49
+ assert(view.headRefOid === expected, "PR HEAD differs from the requested head; pass --head with the PR's commit.");
50
+ const checks = json<(Check & { link: string })[]>(["pr", "checks", pr.prNumber, "-R", pr.repo, "--json", "name,state,workflow,link"]);
51
+ assert(Array.isArray(checks), "Could not read checks.");
52
+ const failed = checks.filter(check => !NOT_FAILED.includes(check.state)).map(check => {
53
+ const job = check.link?.match(/\/actions\/runs\/(\d+)\/job\/(\d+)/);
54
+ if (!job) return { ...check, log: null, note: "Not a GitHub Actions job; follow its link." };
55
+ const { log, fd } = createLog("agent-tool-ci-");
56
+ let status: number | null;
57
+ try { status = spawnSync("gh", ["run", "view", job[1]!, "-R", pr.repo, "--job", job[2]!, "--log-failed"], { stdio: ["ignore", fd, fd], env: gitEnvironment() }).status; }
58
+ finally { closeSync(fd); }
59
+ const tail = logTail(log, limits);
60
+ return { ...check, runId: Number(job[1]), jobId: Number(job[2]), ok: status === 0, log, tail: tail.text, truncated: tail.truncated };
61
+ });
62
+ // A push while reading would label another commit's logs with the requested head.
63
+ assert(json<{ headRefOid: string }>(["pr", "view", pr.prNumber, "-R", pr.repo, "--json", "headRefOid"]).headRefOid === expected, "PR HEAD changed while reading logs; read them again for the new head.");
64
+ return failed;
65
+ }
66
+
42
67
  export function preflight(expectedHead: string, baseBranch: string, baseOid: string): unknown {
43
68
  oid(expectedHead); oid(baseOid); clean();
44
69
  assert(head() === expectedHead, "Local HEAD differs from reviewed HEAD.");
@@ -125,6 +150,14 @@ export async function prWorkflowCommand(args: string[]): Promise<number> {
125
150
  output({ repo: identity.repo, ...pr, liveBaseOid: remoteBranchHead(resolveRepositoryGitUrl(identity.repo), pr.baseRefName), localHeadOid: head() }); return 0;
126
151
  }
127
152
  if (action === "preflight") { assert(args.length === 3, "Usage: pr preflight <reviewed-head> <base-branch> <reviewed-base>"); output(preflight(args[0]!, args[1]!, args[2]!)); return 0; }
153
+ if (action === "logs") {
154
+ const expected = option(args, "--head") ?? head();
155
+ const maxBytes = option(args, "--max-bytes"), tailLines = option(args, "--tail-lines");
156
+ const limits = { maxBytes: maxBytes ? outputLimit("--max-bytes", maxBytes) : DEFAULT_LIMITS.maxBytes, tailLines: tailLines ? outputLimit("--tail-lines", tailLines) : DEFAULT_LIMITS.tailLines };
157
+ assert(!args.length, "Usage: pr logs [--head <oid>] [--max-bytes N] [--tail-lines N]"); oid(expected);
158
+ const identity = resolvePr();
159
+ output({ ...identity, headOid: expected, failed: failedJobLogs(identity, expected, limits) }); return 0;
160
+ }
128
161
  assert(action === "checks", "Unknown PR workflow command.");
129
162
  const wait = flag(args, "--wait");
130
163
  const expected = option(args, "--head") ?? head();