@a2f0/agent-tool 0.1.5 → 0.1.7

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
@@ -143,6 +143,7 @@ feature checkout with policy from a trusted source.
143
143
  },
144
144
  "versions": {
145
145
  "packages": ["packages/api", "packages/client"],
146
+ "bundles": ["packages/client"],
146
147
  "lockfile": "bun.lock",
147
148
  "validate": [["bun", "run", "lint", "--", "--staged"]],
148
149
  "commitMessage": "chore: bump package versions"
@@ -172,6 +173,14 @@ including files in other configured packages. A changed package moves one patch
172
173
  past the base; a deliberate major or minor bump is kept. `plan`, `bump`, and
173
174
  `check` neither regenerate lockfiles nor commit.
174
175
 
176
+ `versions.bundles` names versioned packages whose published artifact includes
177
+ the workspace packages they depend on. Such a package also counts as changed
178
+ when any workspace package it reaches through `workspace:` ranges in
179
+ `dependencies` changes, directly or transitively; an alias range such as
180
+ `workspace:crypto@*` names its target. `devDependencies` and
181
+ `peerDependencies` do not count. A bundled package whose only change is its
182
+ version does not move the bundle.
183
+
175
184
  `versions prepare` runs the whole sequence before a review snapshot:
176
185
 
177
186
  1. It requires a clean worktree on an attached branch with the exact base
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "Portable cross-agent review, PR workflows, and shared skills",
6
6
  "license": "UNLICENSED",
package/src/config.ts CHANGED
@@ -11,7 +11,7 @@ export interface AgentToolConfig {
11
11
  subject: { conventional: boolean; maxLength: number; types: string[] };
12
12
  merge: { requireChecks: boolean; requiredChecks: RequiredCheck[]; requireStrictBaseFreshness: boolean };
13
13
  review: { claudeModel: string | null; codexModel: string | null; opencodeModel: string; opencodeVariants: Record<string, string>; timeoutMs: number };
14
- versions: { packages: string[] | null; lockfile: "bun.lock" | null; validate: string[][]; commitMessage: string };
14
+ versions: { packages: string[] | null; bundles: string[]; lockfile: "bun.lock" | null; validate: string[][]; commitMessage: string };
15
15
  pr: { rejectClaudeBranding: boolean };
16
16
  }
17
17
 
@@ -24,7 +24,7 @@ export const DEFAULT_CONFIG: AgentToolConfig = {
24
24
  },
25
25
  merge: { requireChecks: true, requiredChecks: [], requireStrictBaseFreshness: false },
26
26
  review: { claudeModel: null, codexModel: null, opencodeModel: "deepseek/deepseek-v4-pro", opencodeVariants: { low: "low", medium: "medium", high: "high", xhigh: "max", max: "max" }, timeoutMs: 600_000 },
27
- versions: { packages: null, lockfile: "bun.lock", validate: [], commitMessage: "chore: bump package versions" },
27
+ versions: { packages: null, bundles: [], lockfile: "bun.lock", validate: [], commitMessage: "chore: bump package versions" },
28
28
  pr: { rejectClaudeBranding: false },
29
29
  };
30
30
 
@@ -77,9 +77,9 @@ export function parseConfig(source: string): AgentToolConfig {
77
77
  for (const value of Object.values(variants)) if (typeof value !== "string" || !/^[^\s]+$/.test(value)) invalid("review.opencodeVariants");
78
78
  if (typeof result.review.opencodeModel !== "string" || !/^[^\s/]+\/[^\s]+$/.test(result.review.opencodeModel)) invalid("review.opencodeModel");
79
79
  if (!Number.isSafeInteger(result.review.timeoutMs) || result.review.timeoutMs < 1000 || result.review.timeoutMs > 3_600_000) invalid("review.timeoutMs");
80
- if (result.versions.packages !== null) {
81
- if (!strings(result.versions.packages) || result.versions.packages.some(dir => dir !== "." && (path.isAbsolute(dir) || dir.includes("\\") || dir.split("/").some(part => !part || part === "." || part === "..")))) invalid("versions.packages");
82
- }
80
+ const packageDirs = (dirs: unknown): boolean => strings(dirs) && !dirs.some(dir => dir !== "." && (path.isAbsolute(dir) || dir.includes("\\") || dir.split("/").some(part => !part || part === "." || part === "..")));
81
+ if (result.versions.packages !== null && !packageDirs(result.versions.packages)) invalid("versions.packages");
82
+ if (!packageDirs(result.versions.bundles)) invalid("versions.bundles");
83
83
  if (result.versions.lockfile !== null && result.versions.lockfile !== "bun.lock") invalid("versions.lockfile");
84
84
  // Commands are argv arrays resolved on PATH outside the repository; no shell.
85
85
  if (!Array.isArray(result.versions.validate) || result.versions.validate.some(command => !strings(command) || command.length === 0 || !/^[\w.+-]+$/.test(command[0]!))) invalid("versions.validate");
@@ -3,6 +3,7 @@ import { execFileSync, spawnSync } from "node:child_process";
3
3
  import { lstatSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
 
6
+ import { bundledPackages } from "./bundledPackages";
6
7
  import {
7
8
  bumpPatch,
8
9
  isReleaseBump,
@@ -58,18 +59,12 @@ function resolveBaseCommit(
58
59
  return git(rootDir, ["rev-parse", "--verify", `${oid}^{commit}`]).trim();
59
60
  }
60
61
 
61
- /**
62
- * Whether the branch changes the package, counting its manifest only for edits
63
- * beyond the version field.
64
- */
65
- function packageChanged(
62
+ function changedFiles(
66
63
  rootDir: string,
67
64
  mergeBase: string,
68
65
  packageDir: string,
69
- headManifest: string,
70
- ): boolean {
71
- const manifest = manifestPath(packageDir);
72
- const changed = git(rootDir, [
66
+ ): string[] {
67
+ return git(rootDir, [
73
68
  "diff",
74
69
  "--name-only",
75
70
  "--no-renames",
@@ -81,6 +76,20 @@ function packageChanged(
81
76
  ])
82
77
  .split("\0")
83
78
  .filter(Boolean);
79
+ }
80
+
81
+ /**
82
+ * Whether the branch changes the package, counting its manifest only for edits
83
+ * beyond the version field.
84
+ */
85
+ function packageChanged(
86
+ rootDir: string,
87
+ mergeBase: string,
88
+ packageDir: string,
89
+ headManifest: string,
90
+ ): boolean {
91
+ const manifest = manifestPath(packageDir);
92
+ const changed = changedFiles(rootDir, mergeBase, packageDir);
84
93
  if (changed.some((file) => file !== manifest)) {
85
94
  return true;
86
95
  }
@@ -98,10 +107,40 @@ function packageChanged(
98
107
  return withVersion(mergeBaseManifest, version) !== headManifest;
99
108
  }
100
109
 
110
+ /** A manifest's fields other than `version`, in a comparable form. */
111
+ function withoutVersion(manifest: string | null): string | null {
112
+ if (manifest === null) return null;
113
+ const parsed: unknown = JSON.parse(manifest);
114
+ if (typeof parsed === "object" && parsed !== null) {
115
+ Reflect.deleteProperty(parsed, "version");
116
+ }
117
+ return JSON.stringify(parsed);
118
+ }
119
+
120
+ /**
121
+ * Whether the branch changes a package a bundle ships, beyond its version.
122
+ * The package need not be versioned, so its version is never parsed.
123
+ */
124
+ function bundledPackageChanged(
125
+ rootDir: string,
126
+ mergeBase: string,
127
+ packageDir: string,
128
+ ): boolean {
129
+ const manifest = manifestPath(packageDir);
130
+ const changed = changedFiles(rootDir, mergeBase, packageDir);
131
+ if (changed.some((file) => file !== manifest)) return true;
132
+ if (!changed.includes(manifest)) return false;
133
+ return (
134
+ withoutVersion(showFile(rootDir, mergeBase, manifest)) !==
135
+ withoutVersion(showFile(rootDir, "HEAD", manifest))
136
+ );
137
+ }
138
+
101
139
  /**
102
140
  * The version each package should carry at HEAD to merge onto `baseCommit`:
103
- * one patch past the base when the branch changes the package, the base's own
104
- * version when it does not, and a deliberate major or minor bump left alone.
141
+ * one patch past the base when the branch changes the package or a package it
142
+ * bundles, the base's own version when it does not, and a deliberate major or
143
+ * minor bump left alone.
105
144
  */
106
145
  export function planVersions(
107
146
  rootDir: string,
@@ -109,6 +148,16 @@ export function planVersions(
109
148
  ): VersionPlan[] {
110
149
  const baseCommit = resolveBaseCommit(rootDir, baseOid);
111
150
  const mergeBase = git(rootDir, ["merge-base", baseCommit, "HEAD"]).trim();
151
+ const bundles = bundledPackages(rootDir);
152
+ const bundledChanges = new Map<string, boolean>();
153
+ const bundledChanged = (packageDir: string): boolean => {
154
+ let result = bundledChanges.get(packageDir);
155
+ if (result === undefined) {
156
+ result = bundledPackageChanged(rootDir, mergeBase, packageDir);
157
+ bundledChanges.set(packageDir, result);
158
+ }
159
+ return result;
160
+ };
112
161
  const plans: VersionPlan[] = [];
113
162
  for (const packageDir of workspacePackages(rootDir)) {
114
163
  const manifest = manifestPath(packageDir);
@@ -127,7 +176,10 @@ export function planVersions(
127
176
  let targetVersion = baseVersion;
128
177
  if (isReleaseBump(headVersion, baseVersion)) {
129
178
  targetVersion = headVersion;
130
- } else if (packageChanged(rootDir, mergeBase, packageDir, headManifest)) {
179
+ } else if (
180
+ packageChanged(rootDir, mergeBase, packageDir, headManifest) ||
181
+ (bundles.get(packageDir) ?? []).some(bundledChanged)
182
+ ) {
131
183
  targetVersion = bumpPatch(baseVersion);
132
184
  }
133
185
  plans.push({ manifest, baseVersion, headVersion, targetVersion });
@@ -0,0 +1,83 @@
1
+ import { gitEnvironment } from "../git/environment";
2
+ import { spawnSync } from "node:child_process";
3
+ import { loadConfig } from "../config";
4
+ import {
5
+ discoveredWorkspaces,
6
+ manifestPath,
7
+ workspacePackages,
8
+ } from "./packageVersion";
9
+
10
+ interface WorkspaceManifest {
11
+ name?: unknown;
12
+ dependencies?: unknown;
13
+ }
14
+
15
+ function headManifest(rootDir: string, packageDir: string): WorkspaceManifest {
16
+ const file = manifestPath(packageDir);
17
+ const result = spawnSync("git", ["show", `HEAD:${file}`], {
18
+ cwd: rootDir,
19
+ env: gitEnvironment(),
20
+ encoding: "utf8",
21
+ stdio: ["ignore", "pipe", "ignore"],
22
+ });
23
+ if (result.error) throw result.error;
24
+ if (result.status !== 0) throw new Error(`${file} is not committed at HEAD.`);
25
+ return JSON.parse(result.stdout);
26
+ }
27
+
28
+ /**
29
+ * The workspace packages a manifest depends on at runtime through `workspace:`
30
+ * ranges. An alias range such as `workspace:crypto@*` names its target.
31
+ */
32
+ function workspaceDependencies(manifest: WorkspaceManifest): string[] {
33
+ const { dependencies } = manifest;
34
+ if (typeof dependencies !== "object" || dependencies === null) return [];
35
+ return Object.entries(dependencies).flatMap(([name, range]) => {
36
+ if (typeof range !== "string" || !range.startsWith("workspace:")) return [];
37
+ const alias = /^((?:@[^/@]+\/)?[^/@]+)@/.exec(range.slice("workspace:".length));
38
+ return [alias?.[1] ?? name];
39
+ });
40
+ }
41
+
42
+ /**
43
+ * The workspace packages each configured bundle ships inside its own artifact:
44
+ * every package it reaches through `workspace:` ranges in `dependencies`, read
45
+ * from HEAD. A change to any of them is a change to the bundle.
46
+ */
47
+ export function bundledPackages(rootDir: string): Map<string, string[]> {
48
+ const bundles = loadConfig(rootDir).versions.bundles;
49
+ const result = new Map<string, string[]>();
50
+ if (bundles.length === 0) return result;
51
+ const versioned = workspacePackages(rootDir);
52
+ // Discovered only when a bundle has a workspace dependency, so a bundle
53
+ // without any needs no workspace declaration.
54
+ let directories: Map<string, string> | undefined;
55
+ const directoryOf = (name: string): string | undefined => {
56
+ if (directories === undefined) {
57
+ directories = new Map();
58
+ for (const packageDir of discoveredWorkspaces(rootDir)) {
59
+ const manifest = headManifest(rootDir, packageDir);
60
+ if (typeof manifest.name === "string") directories.set(manifest.name, packageDir);
61
+ }
62
+ }
63
+ return directories.get(name);
64
+ };
65
+ for (const bundle of bundles) {
66
+ if (!versioned.includes(bundle)) {
67
+ throw new Error(`versions.bundles names ${bundle}, which is not a versioned package.`);
68
+ }
69
+ const reached = new Set<string>();
70
+ const pending = workspaceDependencies(headManifest(rootDir, bundle));
71
+ for (let name = pending.pop(); name !== undefined; name = pending.pop()) {
72
+ const packageDir = directoryOf(name);
73
+ if (packageDir === undefined) {
74
+ throw new Error(`${bundle} depends on ${name}, which is not a workspace package.`);
75
+ }
76
+ if (packageDir === bundle || reached.has(packageDir)) continue;
77
+ reached.add(packageDir);
78
+ pending.push(...workspaceDependencies(headManifest(rootDir, packageDir)));
79
+ }
80
+ result.set(bundle, [...reached].sort());
81
+ }
82
+ return result;
83
+ }
@@ -6,10 +6,14 @@ import {
6
6
  import { posix } from "node:path";
7
7
  import { loadConfig } from "../config";
8
8
 
9
- /** Discover every committed workspace, including globbed and newly added ones. */
9
+ /** The versioned packages: the configured directories, or every workspace. */
10
10
  export function workspacePackages(rootDir: string): string[] {
11
11
  const configured = loadConfig(rootDir).versions.packages;
12
- if (configured !== null) return [...configured];
12
+ return configured === null ? discoveredWorkspaces(rootDir) : [...configured];
13
+ }
14
+
15
+ /** Discover every committed workspace, including globbed and newly added ones. */
16
+ export function discoveredWorkspaces(rootDir: string): string[] {
13
17
  const options: ExecFileSyncOptionsWithStringEncoding = {
14
18
  cwd: rootDir,
15
19
  env: gitEnvironment(),