@afokapu/atdd-bun 0.10.88 → 0.10.90

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/integrity.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.10.88",
2
+ "version": "0.10.90",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
@@ -1257,13 +1257,13 @@
1257
1257
  "planner-schemas/wmbt.schema.json": "7a3a5693bfcf01340667c2be4a3c48e74f34b1af7e8dc13372c0de06b7f4fe12",
1258
1258
  "relationships.yaml": "5860a308ebc3ee7fa8172fc2c6633984000ab3003f0ae4b6fa28db48879eda22",
1259
1259
  "src/agent.ts": "6d6c6e75908af8fbab13432f0001b0374bb25cf3dee46ba218dfd67e0090cb56",
1260
- "src/ci.ts": "2a5e5052c4b99eb5c7282c7db2af3846ad6f3ca7d1eaefe924441116f765f37b",
1261
- "src/cli.ts": "995e41b3cb44c04f65f4d599f0fc51b93b17b7558b7c7dcfabed64de3d455250",
1260
+ "src/ci.ts": "b5c57dd87fc780b2b272156b68830d14e695a1a28300dec46a06f71864e0c59e",
1261
+ "src/cli.ts": "d93c3aebb78e752fd96347a5b177c0ed0697010c6e2533e116de93e772cd65a8",
1262
1262
  "src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
1263
1263
  "src/enforce.ts": "e0c10519aeecd7080f2900ea230fad2f09482af47c1cee885d5de71573e29732",
1264
- "src/hooks.ts": "e3ef7e17eaec8fe3b646e6b33a4a04c40eb25ac9a2642e68e4401fec973521e2",
1264
+ "src/hooks.ts": "ed1550a02ed9d61828e540ce8d7f1e01d0baea8dcebc74b4ea53d4a49532856b",
1265
1265
  "src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
1266
- "src/integrity.ts": "f5ad22759eec675454df82cdcf791b1f081951f08ca5084ebdcb8cd82aa1ecef",
1266
+ "src/integrity.ts": "19066185f99839c9e95c57f9bfbcd34df58cf8f4c33de86264c89e03b1085abf",
1267
1267
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
1268
1268
  "src/planner-kernel.ts": "5d3f5305fb59becb2cc97d8a03aca6b104f385805d91cc0410856e289dd6fbb1",
1269
1269
  "src/planner-schema-validator.ts": "ce529c0936171075e93c7539b3655fe895a98dd2973ebda5775958e6ebc316d9",
@@ -1276,9 +1276,9 @@
1276
1276
  "src/telemetry-plan.ts": "cdb1e8bfff414f3ea81977388973da2cf6d89567d9e060dc9d5989a33600f153",
1277
1277
  "src/topology.ts": "9779ee48e1c453abb76363163d4ab363463fa14e359571139f6d1f1fcc51ae2c",
1278
1278
  "templates/agents/AGENTS.block.md": "84d19dfabedb1e05ccfb7b19a38644e87f193ae2b74755897f75f13971bb687e",
1279
- "templates/agents/atdd-bun.integrity.test.ts": "dec6f6e5f65a08d9512fc703b367c163f8c9da1f6aa7fd466835c1ec30348a41",
1279
+ "templates/agents/atdd-bun.integrity.test.ts": "76eb5f59c50d2fc3f4835c0adf85db4046597c25b1deb0432e6acb2ee1a617d8",
1280
1280
  "templates/docs/site.css": "482b3427e3a3cb02a85dc85cfbeac80592dfd53abf42fc92d6ea7071f22afc95",
1281
1281
  "templates/github/atdd-bun-release.yml": "d9547e9e6ef3ae010d53d55314f6a54567f88bd900f7e50d994cd308601d2684",
1282
- "templates/github/atdd-bun.yml": "53d77e2d0dc8fa7f8700ac1d4d5c7522f960b423e894d78e1d8bfc63d68b95c7"
1282
+ "templates/github/atdd-bun.yml": "6b766ae25ac86f3c87c91b31726461d0ba13d3383e55bbc9c9d1fe79ec26fcba"
1283
1283
  }
1284
1284
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.88",
3
+ "version": "0.10.90",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/ci.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
- import { policy } from "./hooks";
4
+ import { enforcementIdentity, policy } from "./hooks";
5
5
 
6
6
  const root = resolve(import.meta.dir, "..");
7
7
  const template = join(root, "templates/github/atdd-bun.yml");
@@ -27,7 +27,8 @@ export async function renderWorkflow(repo = process.cwd()) {
27
27
  const config = Bun.YAML.parse(await readFile(configPath, "utf8"));
28
28
  rejectNew = Boolean(config && typeof config === "object" && !Array.isArray(config) && (config as { ratchet?: { mode?: unknown } }).ratchet?.mode === "reject-new");
29
29
  } catch { /* policy(repo) below gives its existing parse diagnostic */ }
30
- return (await readFile(template, "utf8")).replace("{{PLAYWRIGHT}}\n", await usesPlaywright(repo) ? playwrightStep : "").replace("{{VERSION}}", version).replace("{{PROTECTED_BRANCHES}}", (await policy(repo)).protected_branches.map(branch => JSON.stringify(branch)).join(", ")).replace("{{ENFORCEMENT}}", rejectNew ? 'bun run atdd-bun all --ratchet --base "$ATDD_BASE_REF" --ratchet-activate-profiles' : "bun run atdd-bun all");
30
+ const identity = await enforcementIdentity(repo);
31
+ return (await readFile(template, "utf8")).replace("{{PLAYWRIGHT}}\n", await usesPlaywright(repo) ? playwrightStep : "").replace("{{VERSION}}", version).replace("{{IDENTITY}}", identity.digest).replace("{{PROTECTED_BRANCHES}}", (await policy(repo)).protected_branches.map(branch => JSON.stringify(branch)).join(", ")).replace("{{ENFORCEMENT}}", rejectNew ? 'bun run atdd-bun all --ratchet --base "$ATDD_BASE_REF" --ratchet-activate-profiles' : "bun run atdd-bun all");
31
32
  }
32
33
  const githubPackagesRegistry = /^\s*@[\w.-]+:registry\s*=\s*https:\/\/npm\.pkg\.github\.com\/?\s*$/im;
33
34
  const githubPackagesAuth = /^\s*\/\/npm\.pkg\.github\.com\/:_authToken\s*=\s*(.*?)\s*$/gim;
@@ -61,4 +62,4 @@ export async function ciInit(repo = process.cwd(), replace = false) {
61
62
  await writeFile(output, rendered);
62
63
  return { ok: true, message: npmrc.content === undefined ? output : `${output}\n${npmrc.path}` };
63
64
  }
64
- export async function ciStatus(repo = process.cwd()) { const output = join(repo, ".github/workflows/atdd-bun.yml"); return { ok: existsSync(output), message: output }; }
65
+ export async function ciStatus(repo = process.cwd()) { const output = join(repo, ".github/workflows/atdd-bun.yml"); if (!existsSync(output)) return { ok: false, message: `${output} is missing; run: bun run atdd-bun init --replace` }; try { return await readFile(output, "utf8") === await renderWorkflow(repo) ? { ok: true, message: output } : { ok: false, message: `${output} is stale; run: bun run atdd-bun init --replace` }; } catch (error) { return { ok: false, message: `${output} cannot be verified: ${String(error)}; run: bun run atdd-bun init --replace` }; } }
package/src/cli.ts CHANGED
@@ -8,7 +8,7 @@ import { join, resolve } from "node:path";
8
8
  import { finishWorktree, hookEvents, hooksStatus, installHooks, runHook, startWorktree, uninstallHooks, worktreeStatus } from "./hooks";
9
9
  import { ciInit, ciStatus } from "./ci";
10
10
  import { agentInit, agentStatus } from "./agent";
11
- import { checkIntegrity, formatIntegrity, integrityInit, integrityStatus } from "./integrity";
11
+ import { checkEnforcementCoherence, checkIntegrity, formatIntegrity, integrityInit, integrityStatus } from "./integrity";
12
12
  import { journeyDocs } from "./journey-docs";
13
13
  import { adrRegister } from "./docs-capability";
14
14
  import { releaseCheck } from "./release";
@@ -54,6 +54,19 @@ function fail(message: string): never {
54
54
  process.exit(1);
55
55
  }
56
56
 
57
+ async function governedRepository(root: string) {
58
+ const manifest = join(root, "package.json");
59
+ if (!existsSync(manifest)) return false;
60
+ const pkg = await Bun.file(manifest).json() as { name?: string; dependencies?: Record<string, unknown>; devDependencies?: Record<string, unknown> };
61
+ return pkg.name === "@afokapu/atdd-bun" || Object.hasOwn(pkg.dependencies ?? {}, "@afokapu/atdd-bun") || Object.hasOwn(pkg.devDependencies ?? {}, "@afokapu/atdd-bun");
62
+ }
63
+
64
+ async function requireCoherence(root: string, requireHookBinding: boolean) {
65
+ if (!await governedRepository(root)) return;
66
+ const findings = await checkEnforcementCoherence({ root, requireHookBinding });
67
+ if (findings.length) { console.error(formatIntegrity(findings)); process.exit(1); }
68
+ }
69
+
57
70
  if (args[0] === "help" || args[0] === "--help" || args[0] === "-h") {
58
71
  printHelp();
59
72
  process.exit(0);
@@ -68,6 +81,7 @@ if (args[0] === "init") {
68
81
  }
69
82
  if (args[0] === "hook") {
70
83
  if (!hookEvents.includes(args[1] as typeof hookEvents[number])) fail("unknown hook event: " + (args[1] ?? "(missing)"));
84
+ await requireCoherence(process.cwd(), true);
71
85
  const result = await runHook(args[1] as typeof hookEvents[number], process.cwd(), args.slice(2), await new Response(Bun.stdin.stream()).text());
72
86
  if (result.message) console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
73
87
  }
@@ -142,6 +156,7 @@ const git = async (directory: string, gitArgs: string[]) => {
142
156
  const stable = (items: string[]) => [...new Set(items)].sort();
143
157
  const profileActivation = args.includes("--ratchet-activate-profiles");
144
158
  if (profileActivation && !args.includes("--ratchet")) fail("--ratchet-activate-profiles requires --ratchet");
159
+ await requireCoherence(root, process.env.GITHUB_ACTIONS !== "true");
145
160
  if (!args.includes("--ratchet")) {
146
161
  const violations = await enforce({ root, profiles: normalizedRequested as Profile[] });
147
162
  printViolations(violations);
package/src/hooks.ts CHANGED
@@ -192,6 +192,40 @@ async function existingBranchProtectedBase(root: string, candidate: string, remo
192
192
  /** Ratchet findings depend only on the scanned tree, the toolkit scanning it and the selected profiles, so they are cached
193
193
  * under the Git common directory. An entry is advisory: unreadable, malformed or differently keyed means recompute, never clean. */
194
194
  const packageRoot = resolve(import.meta.dir, ".."), toolkitInputs = ["package.json", "relationships.yaml", "src", "lib", "detectors", "conventions", "planner-nodes", "planner-schemas"];
195
+ const PACKAGE = "@afokapu/atdd-bun";
196
+ export type LockedToolkit = { version: string; integrity: string };
197
+ export type EnforcementIdentity = LockedToolkit & { installedVersion: string; profiles: string[]; digest: string; sourceCheckout: boolean };
198
+
199
+ /** The exact registry tuple approved by this repository's Bun lockfile. No registry or network lookup is permitted. */
200
+ export async function lockedToolkit(root: string): Promise<LockedToolkit | null> {
201
+ const lock = join(root, "bun.lock");
202
+ if (!existsSync(lock)) return null;
203
+ const row = (await readFile(lock, "utf8")).split("\n").find(line => line.trimStart().startsWith(`"${PACKAGE}": [`));
204
+ if (!row) return null;
205
+ const version = row.match(/"@afokapu\/atdd-bun@(\d+\.\d+\.\d+(?:-[\w.]+)?)",\s*""/)?.[1];
206
+ const integrity = row.match(/"(sha512-[^"]+)"\s*\],?\s*$/)?.[1];
207
+ return version && integrity ? { version, integrity } : null;
208
+ }
209
+
210
+ /** Only the running toolkit's actual repository root receives the source-checkout exception. */
211
+ export async function isToolkitSourceCheckout(root = process.cwd()) {
212
+ return realpath(resolve(root)).then(repositoryRoot => realpath(packageRoot).then(runningRoot => repositoryRoot === runningRoot)).catch(() => false);
213
+ }
214
+
215
+ /** One identity binds the approved lock tuple, the package that will execute, and the exact activated config/profile set. */
216
+ export async function enforcementIdentity(root = process.cwd()): Promise<EnforcementIdentity> {
217
+ const sourceCheckout = await isToolkitSourceCheckout(root);
218
+ const installedRoot = sourceCheckout ? packageRoot : join(root, "node_modules", PACKAGE);
219
+ const installedManifest = join(installedRoot, "package.json");
220
+ const providerVersion = (await Bun.file(join(packageRoot, "package.json")).json() as { version: string }).version;
221
+ const installedVersion = existsSync(installedManifest) ? (await Bun.file(installedManifest).json() as { version: string }).version : providerVersion;
222
+ const locked = await lockedToolkit(root) ?? { version: installedVersion, integrity: sourceCheckout ? "source" : "unresolved" };
223
+ const configPath = join(root, "atdd-bun.yaml"), config = existsSync(configPath) ? (await readFile(configPath, "utf8")).replace(/\r\n/g, "\n") : "";
224
+ const profiles = stable(await enabledProfiles(root));
225
+ const digest = new Bun.CryptoHasher("sha256").update(JSON.stringify({ schema: "atdd-bun.enforcement-identity/v1", ...locked, installedVersion, config, profiles })).digest("hex");
226
+ return { ...locked, installedVersion, profiles, digest, sourceCheckout };
227
+ }
228
+
195
229
  export type ToolkitIdentity = { version: string; digest: string };
196
230
  async function toolkitFiles() {
197
231
  const found = new Set<string>();
@@ -318,17 +352,32 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
318
352
  return { ok: true, message: "ok" };
319
353
  }
320
354
 
321
- // Resolve from the checkout at hook time: installed consumers use their local package; source checkouts use src.
322
- const dispatcher = (event: HookEvent) => `#!/bin/sh\nroot="$(git rev-parse --show-toplevel)" || exit $?\nentry="$root/node_modules/@afokapu/atdd-bun/src/cli.ts"\nif [ ! -f "$entry" ]; then\n if [ -f "$root/package.json" ] && grep -Eq '"name"[[:space:]]*:[[:space:]]*"@afokapu/atdd-bun"' "$root/package.json" && [ -f "$root/src/cli.ts" ]; then\n entry="$root/src/cli.ts"\n else\n echo "atdd-bun hook: install @afokapu/atdd-bun in this project before running hooks" >&2\n exit 1\n fi\nfi\nexec bun "$entry" hook ${event} "$@"\n`;
323
- export async function installHooks(root = process.cwd(), replace = false) { const current = (await git(root, ["config", "--worktree", "--get", "core.hooksPath"])).out || (await git(root, ["config", "--get", "core.hooksPath"])).out; if (current && current !== ".githooks" && !replace) return bad(`core.hooksPath is ${current}; use --replace`); await git(root, ["config", "extensions.worktreeConfig", "true"]); await mkdir(join(root, ".githooks"), { recursive: true }); for (const event of hookEvents) { const file = join(root, ".githooks", event); await writeFile(file, dispatcher(event)); await chmod(file, 0o755); } const result = await git(root, ["config", "--worktree", "core.hooksPath", ".githooks"]); return result.code ? bad(result.err) : { ok: true, message: "hooks installed" }; }
355
+ // Resolve from the checkout at hook time: installed consumers use only their worktree-local package; this package uses its own source checkout.
356
+ const dispatcher = (event: HookEvent, identity: EnforcementIdentity) => {
357
+ const sourceFallback = identity.sourceCheckout ? `elif [ -f "$root/src/cli.ts" ]; then\n entry="$root/src/cli.ts"\n` : "";
358
+ return `#!/bin/sh\n# atdd-bun enforcement identity: ${identity.digest}\nroot="$(git rev-parse --show-toplevel)" || exit $?\nentry="$root/node_modules/@afokapu/atdd-bun/src/cli.ts"\nmanifest="$root/node_modules/@afokapu/atdd-bun/package.json"\nif [ -f "$entry" ] && [ -f "$manifest" ]; then\n installed="$(grep -m 1 -E '\"version\"[[:space:]]*:' "$manifest" | sed -E 's/.*\"version\"[[:space:]]*:[[:space:]]*\"([^\"]+)\".*/\\1/')"\n if [ "$installed" != "${identity.version}" ]; then\n echo "atdd-bun hook: installed package $installed does not match exact bun.lock resolution ${identity.version}; run: bun install --frozen-lockfile && bun run atdd-bun init --replace" >&2\n exit 1\n fi\n${sourceFallback}else\n echo "atdd-bun hook: worktree-local @afokapu/atdd-bun is missing; run: bun install --frozen-lockfile && bun run atdd-bun init --replace" >&2\n exit 1\nfi\nexec bun "$entry" hook ${event} "$@"\n`;
359
+ };
360
+ export async function renderHook(event: HookEvent, root = process.cwd()) { return dispatcher(event, await enforcementIdentity(root)); }
361
+ export async function installHooks(root = process.cwd(), replace = false) { const current = (await git(root, ["config", "--worktree", "--get", "core.hooksPath"])).out || (await git(root, ["config", "--get", "core.hooksPath"])).out; if (current && current !== ".githooks" && !replace) return bad(`core.hooksPath is ${current}; use --replace`); const identity = await enforcementIdentity(root); await git(root, ["config", "extensions.worktreeConfig", "true"]); await mkdir(join(root, ".githooks"), { recursive: true }); for (const event of hookEvents) { const file = join(root, ".githooks", event); await writeFile(file, dispatcher(event, identity)); await chmod(file, 0o755); } const result = await git(root, ["config", "--worktree", "core.hooksPath", ".githooks"]); return result.code ? bad(result.err) : { ok: true, message: "hooks installed" }; }
324
362
  export async function uninstallHooks(root = process.cwd()) { if ((await git(root, ["config", "--worktree", "--get", "core.hooksPath"])).out === ".githooks") await git(root, ["config", "--worktree", "--unset", "core.hooksPath"]); for (const event of hookEvents) await rm(join(root, ".githooks", event), { force: true }); return { ok: true, message: "hooks uninstalled" }; }
325
- export async function hooksStatus(root = process.cwd()) { const path = (await git(root, ["config", "--worktree", "--get", "core.hooksPath"])).out; return { ok: path === ".githooks" && hookEvents.every(event => existsSync(join(root, ".githooks", event))), message: path || "not installed" }; }
363
+ export async function hooksStatus(root = process.cwd()) {
364
+ const path = (await git(root, ["config", "--worktree", "--get", "core.hooksPath"])).out;
365
+ if (path !== ".githooks") return bad(`core.hooksPath is ${path || "not set"}; run: bun run atdd-bun init --replace`);
366
+ try {
367
+ for (const event of hookEvents) {
368
+ const file = join(root, ".githooks", event);
369
+ if (!existsSync(file)) return bad(`${relative(root, file)} is missing; run: bun run atdd-bun init --replace`);
370
+ if (await readFile(file, "utf8") !== await renderHook(event, root)) return bad(`${relative(root, file)} is stale; run: bun run atdd-bun init --replace`);
371
+ }
372
+ } catch (error) { return bad(`generated hooks cannot be verified: ${String(error)}; run: bun run atdd-bun init --replace`); }
373
+ return { ok: true, message: path };
374
+ }
326
375
 
327
376
  const branchPath = (branch: string) => branch.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
328
377
  export async function startWorktree(root = process.cwd(), branch = "") { const cfg = await policy(root); if (!cfg.worktrees.enabled) return bad("worktree policy is disabled; set worktrees.enabled: true in atdd-bun.yaml"); const state = await layout(root, cfg.worktrees); if ("error" in state) return bad(state.error); if (state.current !== state.primary) return bad(`start must run from the primary checkout ${state.primary}`); if (!branch) return bad("branch is required: atdd-bun worktree start <branch>"); if ((await git(root, ["check-ref-format", "--branch", branch])).code) return bad(`invalid branch name ${JSON.stringify(branch)}`); if (cfg.protected_branches.includes(branch)) return bad(`cannot create a worktree for protected branch ${branch}`); const leaf = branchPath(branch); if (!leaf) return bad(`branch ${JSON.stringify(branch)} cannot form a safe worktree directory`); const target = join(state.worktreeRoot, leaf); if (existsSync(target)) return bad(`worktree path already exists: ${target}`); await mkdir(state.worktreeRoot, { recursive: true }); const created = await git(state.primary, ["worktree", "add", target, "-b", branch]); if (created.code) return bad(created.err || created.out); const hooks = await installHooks(target); if (!hooks.ok) return bad(`created ${target}, but could not install hooks: ${hooks.message}`); return { ok: true, message: `created linked worktree ${target} on ${branch}` }; }
329
378
 
330
379
  export async function worktreeStatus(root = process.cwd()) { const cfg = await primaryPolicy(root); if (!cfg.worktrees.enabled) return bad("worktree policy is disabled"); const state = await layout(root, cfg.worktrees); if ("error" in state) return bad(state.error); const result = await git(state.primary, ["worktree", "list", "--porcelain"]); return result.code ? bad(result.err) : { ok: true, message: result.out }; }
331
380
 
332
- async function generatedHooksOnly(root: string): Promise<boolean> { const directory = join(root, ".githooks"); if (!existsSync(directory)) return false; const names = (await readdir(directory)).sort(); if (names.join("\n") !== [...hookEvents].sort().join("\n")) return false; return (await Promise.all(hookEvents.map(async event => (await readFile(join(directory, event), "utf8")) === dispatcher(event)))).every(Boolean); }
381
+ async function generatedHooksOnly(root: string): Promise<boolean> { const directory = join(root, ".githooks"); if (!existsSync(directory)) return false; const names = (await readdir(directory)).sort(); if (names.join("\n") !== [...hookEvents].sort().join("\n")) return false; return (await Promise.all(hookEvents.map(async event => (await readFile(join(directory, event), "utf8")) === await renderHook(event, root)))).every(Boolean); }
333
382
 
334
383
  export async function finishWorktree(root = process.cwd(), deleteBranch = false) { const cfg = await primaryPolicy(root); if (!cfg.worktrees.enabled) return bad("worktree policy is disabled"); const state = await layout(root, cfg.worktrees); if ("error" in state) return bad(state.error); if (state.current === state.primary || !inside(state.worktreeRoot, state.current)) return bad(`finish must run from a linked worktree beneath ${state.worktreeRoot}`); if (!state.branch || cfg.protected_branches.includes(state.branch)) return bad("finish requires a non-protected named branch"); const status = (await git(state.current, ["status", "--porcelain"])).out, generatedHooks = await generatedHooksOnly(state.current), onlyGeneratedHooks = Boolean(status) && status.split("\n").every(line => line.endsWith(" .githooks/") || line.endsWith(" .githooks")); if (status && !onlyGeneratedHooks) return bad("worktree is dirty; commit, stash, or discard changes before finish"); const tip = (await git(state.primary, ["rev-parse", "--verify", `refs/heads/${state.branch}`])).out.trim(); if (!tip || (await git(state.primary, ["merge-base", "--is-ancestor", tip, cfg.worktrees.primary_branch])).code) return bad(`branch ${state.branch} is not merged into ${cfg.worktrees.primary_branch} in the primary checkout`); const removed = await git(state.primary, ["worktree", "remove", ...(onlyGeneratedHooks && generatedHooks ? ["--force"] : []), state.current]); if (removed.code) return bad(removed.err || removed.out); if (deleteBranch) { const now = (await git(state.primary, ["rev-parse", "--verify", `refs/heads/${state.branch}`])).out.trim(); if (now !== tip || (await git(state.primary, ["merge-base", "--is-ancestor", tip, cfg.worktrees.primary_branch])).code) return bad(`worktree removed, but branch ${state.branch} was not deleted: its tip changed or is no longer merged into ${cfg.worktrees.primary_branch}`); const deleted = await git(state.primary, ["branch", "-D", state.branch]); if (deleted.code) return bad(`worktree removed, but branch deletion failed: ${deleted.err || deleted.out}`); } return { ok: true, message: `removed merged worktree ${state.current}${deleteBranch ? ` and branch ${state.branch}` : ""}` }; }
package/src/integrity.ts CHANGED
@@ -3,7 +3,7 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { instructionPaths } from "./agent";
5
5
  import { renderWorkflow } from "./ci";
6
- import { defaultHookPolicy, type HookPolicy } from "./hooks";
6
+ import { defaultHookPolicy, enforcementIdentity, hookEvents, hooksStatus, isToolkitSourceCheckout, lockedToolkit, renderHook, type HookPolicy } from "./hooks";
7
7
 
8
8
  /**
9
9
  * Integrity: the files an agent could change to weaken enforcement must stay canonical.
@@ -20,8 +20,9 @@ export const MANIFEST = "integrity.json";
20
20
  export const TEST_FILE = "atdd-bun.integrity.test.ts";
21
21
  const WORKFLOW = ".github/workflows/atdd-bun.yml";
22
22
  const BLOCK = /<!-- atdd-bun:start[\s\S]*?<!-- atdd-bun:end -->/;
23
- // Generated files carry the version that wrote them; an upgrade must not read as tampering.
23
+ // Agent blocks are guidance rather than enforcement surfaces, so their writer version remains upgrade-tolerant.
24
24
  const unstamp = (text: string) => text.replace(/@afokapu\/atdd-bun (?:\d+\.\d+\.\d+(?:-[\w.]+)?|\{\{VERSION\}\})/g, "@afokapu/atdd-bun <version>").replace(/\r\n/g, "\n").trimEnd();
25
+ const canonical = (text: string) => text.replace(/\r\n/g, "\n").trimEnd();
25
26
  const sha256 = (data: string | Uint8Array) => new Bun.CryptoHasher("sha256").update(data).digest("hex");
26
27
  /** Where `bun test` will find the generated test: the repository's `[test] root` from bunfig.toml, else the repo root. */
27
28
  export async function testFilePath(repo: string) {
@@ -69,41 +70,58 @@ export async function checkInstalledPackage(packageRoot = ownRoot): Promise<Inte
69
70
  return findings;
70
71
  }
71
72
 
72
- /** The repository installs the package from npm, pinned by a registry integrity hash. */
73
+ /** The repository installs the package version approved by its exact npm-registry lock tuple. */
73
74
  async function checkDependency(root: string): Promise<IntegrityFinding[]> {
74
75
  if (!existsSync(join(root, "package.json"))) return [];
75
76
  const pkg = await Bun.file(join(root, "package.json")).json() as { name?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
76
- if (pkg.name === PACKAGE) return [];
77
+ if (await isToolkitSourceCheckout(root)) return [];
77
78
  const spec = pkg.devDependencies?.[PACKAGE] ?? pkg.dependencies?.[PACKAGE];
78
- if (spec === undefined) return [{ file: "package.json", detail: `${PACKAGE} is not a dependency`, restore: `bun add -d ${PACKAGE}` }];
79
+ if (spec === undefined) return [{ file: "package.json", detail: `${PACKAGE} is not a dependency`, restore: `bun add -d ${PACKAGE}@<operator-approved-version> --exact` }];
79
80
  const findings: IntegrityFinding[] = [];
80
- if (!/^[~^]?\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(spec)) findings.push({ file: "package.json", detail: `${PACKAGE} is "${spec}"; it must be an npm version range, not a git, file, or link source`, restore: `bun add -d ${PACKAGE}` });
81
- const lock = existsSync(join(root, "bun.lock")) ? await readFile(join(root, "bun.lock"), "utf8") : "";
81
+ if (!/^[~^]?\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(spec)) findings.push({ file: "package.json", detail: `${PACKAGE} is "${spec}"; it must be an npm version range, not a git, file, or link source`, restore: `bun add -d ${PACKAGE}@<operator-approved-version> --exact` });
82
+ const locked = await lockedToolkit(root), lock = existsSync(join(root, "bun.lock")) ? await readFile(join(root, "bun.lock"), "utf8") : "";
82
83
  const entry = lock.split("\n").find(line => line.trimStart().startsWith(`"${PACKAGE}": [`));
83
- if (!entry) findings.push({ file: "bun.lock", detail: `bun.lock does not pin ${PACKAGE}`, restore: "bun install" });
84
- else if (!/"@afokapu\/atdd-bun@\d+\.\d+\.\d+[^"]*", ""/.test(entry) || !entry.includes('"sha512-')) findings.push({ file: "bun.lock", detail: `bun.lock does not resolve ${PACKAGE} from the npm registry with an integrity hash`, restore: `bun add -d ${PACKAGE}` });
84
+ if (!entry) findings.push({ file: "bun.lock", detail: `bun.lock does not pin ${PACKAGE}; select the exact operator-approved version to create the reviewed lock resolution`, restore: `bun add -d ${PACKAGE}@<operator-approved-version> --exact` });
85
+ else if (!locked) findings.push({ file: "bun.lock", detail: `bun.lock does not resolve ${PACKAGE} from the npm registry with an integrity hash; select the exact operator-approved version to repair the reviewed lock resolution`, restore: `bun add -d ${PACKAGE}@<operator-approved-version> --exact` });
86
+ const installedManifest = join(root, "node_modules", PACKAGE, "package.json");
87
+ if (!existsSync(installedManifest)) findings.push({ file: `node_modules/${PACKAGE}/package.json`, detail: "the worktree-local installed package is missing", restore: "bun install --frozen-lockfile" });
88
+ else if (locked) {
89
+ const installedVersion = (await Bun.file(installedManifest).json() as { version?: unknown }).version;
90
+ if (installedVersion !== locked.version) findings.push({ file: `node_modules/${PACKAGE}/package.json`, detail: `installed version ${String(installedVersion)} does not match exact bun.lock resolution ${locked.version}`, restore: "bun install --frozen-lockfile" });
91
+ }
85
92
  return findings;
86
93
  }
87
94
 
88
- /** Generated files are byte-identical to what the installed version generates (ignoring its version stamp). */
89
- async function checkGenerated(root: string, packageRoot: string, skipWorkflow = false): Promise<IntegrityFinding[]> {
90
- const findings: IntegrityFinding[] = [];
91
- const same = async (file: string, template: string, restore: string) => {
92
- const path = join(root, file);
93
- if (!existsSync(path)) return findings.push({ file, detail: "is missing", restore });
94
- if (unstamp(await readFile(path, "utf8")) !== unstamp(await readFile(join(packageRoot, template), "utf8"))) findings.push({ file, detail: "was edited; it must match what the package generates", restore });
95
- };
96
- // The workflow is rendered per repository (its protected branches), so it is compared with that rendering.
95
+ async function renderIntegrityTest(root: string, packageRoot: string) {
96
+ const version = (await Bun.file(join(packageRoot, "package.json")).json() as { version: string }).version;
97
+ const { digest } = await enforcementIdentity(root);
98
+ return (await readFile(join(packageRoot, "templates/agents/atdd-bun.integrity.test.ts"), "utf8")).replace("{{VERSION}}", version).replace("{{IDENTITY}}", digest);
99
+ }
100
+
101
+ /** The generated enforcement surfaces are byte-identical to this exact lock/config/profile identity. */
102
+ async function checkEnforcementGenerated(root: string, packageRoot: string, compare = true): Promise<IntegrityFinding[]> {
103
+ const findings: IntegrityFinding[] = [], restore = "bun run atdd-bun init --replace";
104
+ for (const event of hookEvents) {
105
+ const file = `.githooks/${event}`, path = join(root, file);
106
+ if (!existsSync(path)) findings.push({ file, detail: "is missing", restore });
107
+ else if (compare && canonical(await readFile(path, "utf8")) !== canonical(await renderHook(event, root))) findings.push({ file, detail: "is stale; it must match the exact lock/config/profile identity", restore });
108
+ }
97
109
  const workflow = join(root, WORKFLOW);
98
- if (skipWorkflow) { /* rendered from atdd-bun.yaml, which does not parse: reported by the caller */ }
99
- else if (!existsSync(workflow)) findings.push({ file: WORKFLOW, detail: "is missing", restore: "bun run atdd-bun ci init --replace" });
100
- else if (unstamp(await readFile(workflow, "utf8")) !== unstamp(await renderWorkflow(root))) findings.push({ file: WORKFLOW, detail: "was edited; it must match what the package generates (protected_branches decides its push branches)", restore: "bun run atdd-bun ci init --replace" });
101
- await same(relative(root, await testFilePath(root)), "templates/agents/atdd-bun.integrity.test.ts", "bun run atdd-bun integrity init --replace");
102
- const canonical = (await readFile(join(packageRoot, "templates/agents/AGENTS.block.md"), "utf8")).match(BLOCK)![0];
110
+ if (!existsSync(workflow)) findings.push({ file: WORKFLOW, detail: "is missing", restore });
111
+ else if (compare && canonical(await readFile(workflow, "utf8")) !== canonical(await renderWorkflow(root))) findings.push({ file: WORKFLOW, detail: "is stale; it must match the exact lock/config/profile identity", restore });
112
+ const test = relative(root, await testFilePath(root)), testPath = join(root, test);
113
+ if (!existsSync(testPath)) findings.push({ file: test, detail: "is missing", restore });
114
+ else if (compare && canonical(await readFile(testPath, "utf8")) !== canonical(await renderIntegrityTest(root, packageRoot))) findings.push({ file: test, detail: "is stale; it must match the exact lock/config/profile identity", restore });
115
+ return findings;
116
+ }
117
+
118
+ async function checkGenerated(root: string, packageRoot: string, skipComparisons = false): Promise<IntegrityFinding[]> {
119
+ const findings = await checkEnforcementGenerated(root, packageRoot, !skipComparisons);
120
+ const agentCanonical = (await readFile(join(packageRoot, "templates/agents/AGENTS.block.md"), "utf8")).match(BLOCK)![0];
103
121
  for (const file of instructionPaths) {
104
122
  const agents = existsSync(join(root, file)) ? await readFile(join(root, file), "utf8") : "", block = agents.match(BLOCK)?.[0];
105
123
  if (!block) findings.push({ file, detail: "is missing the atdd-bun block", restore: "bun run atdd-bun agent init --replace" });
106
- else if (unstamp(block) !== unstamp(canonical)) findings.push({ file, detail: "atdd-bun block was edited", restore: "bun run atdd-bun agent init --replace" });
124
+ else if (unstamp(block) !== unstamp(agentCanonical)) findings.push({ file, detail: "atdd-bun block was edited", restore: "bun run atdd-bun agent init --replace" });
107
125
  }
108
126
  return findings;
109
127
  }
@@ -216,6 +234,20 @@ async function checkPolicy(root: string, base?: string, push = process.env.GITHU
216
234
  return loosened.length ? [{ file: "atdd-bun.yaml", detail: `loosens the policy of ${against.slice(0, 7)}: ${loosened.join("; ")}`, restore: `git checkout ${against.slice(0, 7)} -- atdd-bun.yaml` }] : [];
217
235
  }
218
236
 
237
+ export async function checkEnforcementCoherence(options: IntegrityOptions & { requireHookBinding?: boolean } = {}): Promise<IntegrityFinding[]> {
238
+ const root = resolve(options.root ?? process.cwd()), packageRoot = options.packageRoot ?? ownRoot;
239
+ try {
240
+ const findings = [...await checkInstalledPackage(packageRoot), ...await checkDependency(root), ...await checkEnforcementGenerated(root, packageRoot)];
241
+ if (options.requireHookBinding) {
242
+ const status = await hooksStatus(root);
243
+ if (!status.ok) findings.push({ file: ".githooks", detail: status.message, restore: "bun run atdd-bun init --replace" });
244
+ }
245
+ return findings;
246
+ } catch (error) {
247
+ return [{ file: "atdd-bun.yaml", detail: `the enforcement identity cannot be resolved: ${String(error)}`, restore: "correct atdd-bun.yaml and run: bun run atdd-bun init --replace" }];
248
+ }
249
+ }
250
+
219
251
  export async function checkIntegrity(options: IntegrityOptions = {}): Promise<IntegrityFinding[]> {
220
252
  const root = resolve(options.root ?? process.cwd()), packageRoot = options.packageRoot ?? ownRoot;
221
253
  // An unreadable atdd-bun.yaml is a finding, not a crash: the policy and the workflow rendered from it cannot be
@@ -250,9 +282,8 @@ export function formatIntegrity(findings: IntegrityFinding[]): string {
250
282
  export async function integrityInit(repo = process.cwd(), replace = false) {
251
283
  const output = await testFilePath(repo);
252
284
  if (existsSync(output) && !replace) return { ok: false, message: `${output} exists; use --replace` };
253
- const version = (await Bun.file(join(ownRoot, "package.json")).json() as { version: string }).version;
254
285
  await mkdir(dirname(output), { recursive: true });
255
- await writeFile(output, (await readFile(join(ownRoot, "templates/agents/atdd-bun.integrity.test.ts"), "utf8")).replace("{{VERSION}}", version));
286
+ await writeFile(output, await renderIntegrityTest(repo, ownRoot));
256
287
  return { ok: true, message: output };
257
288
  }
258
- export async function integrityStatus(repo = process.cwd()) { const output = await testFilePath(repo); return { ok: existsSync(output), message: output }; }
289
+ export async function integrityStatus(repo = process.cwd()) { const output = await testFilePath(repo); if (!existsSync(output)) return { ok: false, message: `${output} is missing; run: bun run atdd-bun init --replace` }; try { return canonical(await readFile(output, "utf8")) === canonical(await renderIntegrityTest(repo, ownRoot)) ? { ok: true, message: output } : { ok: false, message: `${output} is stale; run: bun run atdd-bun init --replace` }; } catch (error) { return { ok: false, message: `${output} cannot be verified: ${String(error)}; run: bun run atdd-bun init --replace` }; } }
@@ -1,4 +1,5 @@
1
1
  // Generated by @afokapu/atdd-bun {{VERSION}}. Do not edit; run `atdd-bun integrity init --replace`.
2
+ // atdd-bun enforcement identity: {{IDENTITY}}
2
3
  // Fails loudly when the installed package, its generated files, or atdd-bun.yaml were changed.
3
4
  import { registerIntegrityTest } from "@afokapu/atdd-bun/register";
4
5
 
@@ -1,4 +1,5 @@
1
1
  # Generated by @afokapu/atdd-bun {{VERSION}}. Do not edit; run `atdd-bun ci init --replace`.
2
+ # atdd-bun enforcement identity: {{IDENTITY}}
2
3
  name: ATdd-Bun
3
4
  # The generated gate only needs checkout and package-download access. All other
4
5
  # token capabilities are explicitly disabled.