@afokapu/atdd-bun 0.10.84 → 0.10.86

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.84",
2
+ "version": "0.10.86",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
@@ -1260,8 +1260,8 @@
1260
1260
  "src/ci.ts": "2a5e5052c4b99eb5c7282c7db2af3846ad6f3ca7d1eaefe924441116f765f37b",
1261
1261
  "src/cli.ts": "995e41b3cb44c04f65f4d599f0fc51b93b17b7558b7c7dcfabed64de3d455250",
1262
1262
  "src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
1263
- "src/enforce.ts": "7e6b3a1fc7c90f8e1836ab3e4655778fb569a63be5e7c199de44024fc003e6be",
1264
- "src/hooks.ts": "f20024340979de799ed977b8d2483ca09c494ef952dcea2cc7c4c39e6e10679c",
1263
+ "src/enforce.ts": "e0c10519aeecd7080f2900ea230fad2f09482af47c1cee885d5de71573e29732",
1264
+ "src/hooks.ts": "f714cff597e8bf2d48d9aefabc191da25094c8f07baabc9e8424054973469d18",
1265
1265
  "src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
1266
1266
  "src/integrity.ts": "f5ad22759eec675454df82cdcf791b1f081951f08ca5084ebdcb8cd82aa1ecef",
1267
1267
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.84",
3
+ "version": "0.10.86",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/enforce.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
- import { mkdtemp, readFile, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
2
+ import { link, mkdir, mkdtemp, readFile, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
3
+ import { availableParallelism, tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import { topologyFor } from "./topology";
6
6
 
@@ -125,6 +125,119 @@ export async function undeclaredEmissions(implementation: string, violations: Pi
125
125
  return [...new Set(violations.map(v => v.rule_id).filter(id => !declared.has(id)))].sort();
126
126
  }
127
127
 
128
+ // Detector concurrency is resource control, never verification: it changes when detectors run, not what they find.
129
+
130
+ /** A positive integer from the environment, else `fallback`. Any other value fails closed. */
131
+ function detectorLimit(name: string, fallback: number): number {
132
+ const raw = process.env[name];
133
+ if (raw === undefined) return fallback;
134
+ if (!/^[1-9]\d*$/.test(raw)) throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`);
135
+ return Number(raw);
136
+ }
137
+
138
+ /** How many detectors one run may have in flight. */
139
+ const runDetectorLimit = () => detectorLimit("ATDD_BUN_MAX_DETECTORS", Math.max(2, Math.floor(availableParallelism() / 2)));
140
+ /** How many detectors all atdd-bun runs on this host may have in flight together. */
141
+ const hostDetectorLimit = () => detectorLimit("ATDD_BUN_HOST_DETECTORS", availableParallelism());
142
+ /** A held slot is a lease: its holder refreshes it every lease interval, and one not refreshed for the stale age is
143
+ * free whatever pid it names, since a killed holder's pid can be reused by an unrelated live process. */
144
+ function slotLease(): { leaseMs: number; staleMs: number } {
145
+ const leaseMs = detectorLimit("ATDD_BUN_DETECTOR_LEASE_MS", 5_000), staleMs = detectorLimit("ATDD_BUN_DETECTOR_STALE_MS", 60_000);
146
+ if (staleMs <= leaseMs) throw new Error(`ATDD_BUN_DETECTOR_STALE_MS (${staleMs}) must exceed ATDD_BUN_DETECTOR_LEASE_MS (${leaseMs})`);
147
+ return { leaseMs, staleMs };
148
+ }
149
+ const slotDirectory = () => process.env.ATDD_BUN_DETECTOR_SLOTS_DIR ?? join(tmpdir(), "atdd-bun-detector-slots");
150
+
151
+ /** Map `work` over `items` with at most `limit` in flight; results keep the order of `items`. */
152
+ async function pooled<T, R>(items: T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
153
+ const results: R[] = new Array(items.length);
154
+ let next = 0;
155
+ const worker = async () => { while (next < items.length) { const index = next++; results[index] = await work(items[index]); } };
156
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
157
+ return results;
158
+ }
159
+
160
+ const errorCode = (error: unknown) => (error as NodeJS.ErrnoException).code;
161
+ const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch (error) { return errorCode(error) === "EPERM"; } };
162
+ const ownerPid = async (path: string) => Number((await readFile(path, "utf8").catch(() => "")).split(" ")[0]) || undefined;
163
+
164
+ /** Hard-link the fully written claim to the slot path: atomic and exclusive, like O_EXCL, so a reader never sees a half-written owner. */
165
+ async function claim(staging: string, slot: string): Promise<boolean> {
166
+ try { await link(staging, slot); return true; } catch (error) { if (errorCode(error) === "EEXIST") return false; throw error; }
167
+ }
168
+
169
+ /** Whether a slot file is a live lease: its owner runs and refreshed it within the stale age. Undefined when absent. */
170
+ async function leaseHeld(path: string, staleMs: number): Promise<boolean | undefined> {
171
+ const info = await stat(path).catch(() => undefined);
172
+ if (!info) return undefined;
173
+ const owner = await ownerPid(path);
174
+ return owner !== undefined && alive(owner) && Date.now() - info.mtimeMs < staleMs;
175
+ }
176
+
177
+ /** Free a slot whose owner is gone or whose lease expired. Moving it aside first means two reclaimers cannot both
178
+ * delete it; a live lease claimed in the meantime is put back. */
179
+ async function reclaimIfStale(slot: string, token: string, staleMs: number): Promise<void> {
180
+ if (await leaseHeld(slot, staleMs) !== false) return;
181
+ const aside = `${slot}.reclaim-${token.replace(" ", "-")}`;
182
+ try { await rename(slot, aside); } catch { return; }
183
+ if (await leaseHeld(aside, staleMs)) await link(aside, slot).catch(() => undefined);
184
+ await rm(aside, { force: true });
185
+ }
186
+
187
+ const touch = (path: string) => { const now = new Date(); return utimes(path, now, now); };
188
+
189
+ /** Keep a claimed slot's lease fresh until it is released. */
190
+ function holdLease(slot: string, token: string, leaseMs: number): () => Promise<void> {
191
+ const timer = setInterval(() => void touch(slot).catch(() => undefined), leaseMs);
192
+ timer.unref();
193
+ return async () => { clearInterval(timer); await release(slot, token); };
194
+ }
195
+
196
+ const warned = new Set<string>();
197
+ function warnUnusable(directory: string, error: unknown): void {
198
+ if (warned.has(directory)) return;
199
+ warned.add(directory);
200
+ process.stderr.write(`atdd-bun: detector slot directory ${directory} is unusable (${(error as Error).message}); bounding detectors by the per-run cap only\n`);
201
+ }
202
+
203
+ /** Take one of the host-wide detector slots, waiting with a small backoff while all are leased. Returns the release.
204
+ * A slot directory that cannot be used at all degrades to the per-run cap, reported on stderr. */
205
+ async function acquireHostSlot(): Promise<() => Promise<void>> {
206
+ const limit = hostDetectorLimit(), { leaseMs, staleMs } = slotLease(), directory = slotDirectory();
207
+ const token = `${process.pid} ${crypto.randomUUID()}`, staging = join(directory, `.claim-${token.replace(" ", "-")}`);
208
+ try {
209
+ await mkdir(directory, { recursive: true });
210
+ await writeFile(staging, token);
211
+ for (let delay = 5; ; delay = Math.min(delay * 2, 200)) {
212
+ // A claim links this file, so it must be fresh: a waiter's slot starts its lease at the claim.
213
+ await touch(staging);
214
+ for (let index = 0; index < limit; index++) {
215
+ const slot = join(directory, `slot-${index}`);
216
+ if (await claim(staging, slot)) return holdLease(slot, token, leaseMs);
217
+ await reclaimIfStale(slot, token, staleMs);
218
+ if (await claim(staging, slot)) return holdLease(slot, token, leaseMs);
219
+ }
220
+ await Bun.sleep(delay);
221
+ }
222
+ } catch (error) {
223
+ warnUnusable(directory, error);
224
+ return async () => undefined;
225
+ } finally {
226
+ await rm(staging, { force: true }).catch(() => undefined);
227
+ }
228
+ }
229
+
230
+ /** Release only a slot this claim still owns. */
231
+ async function release(slot: string, token: string): Promise<void> {
232
+ if (await readFile(slot, "utf8").catch(() => "") === token) await rm(slot, { force: true });
233
+ }
234
+
235
+ /** Run `work` while holding one host-wide detector slot. */
236
+ async function withHostSlot<T>(work: () => Promise<T>): Promise<T> {
237
+ const release = await acquireHostSlot();
238
+ try { return await work(); } finally { await release(); }
239
+ }
240
+
128
241
  export async function runImplementation(
129
242
  implementation: string,
130
243
  config: Required<Pick<EnforcementConfig, "scanRoots" | "excludes">> & { profiles?: ConcreteProfile[] },
@@ -134,23 +247,26 @@ export async function runImplementation(
134
247
  const report = join(scratch, "violations.json");
135
248
  try {
136
249
  const bun = Bun.which("bun") ?? process.execPath, topology = await topologyFor(config.scanRoots[0] ?? process.cwd());
137
- const child = Bun.spawn({
138
- cmd: [bun, detector],
139
- cwd: config.scanRoots[0] ?? process.cwd(),
140
- stdout: "pipe",
141
- stderr: "pipe",
142
- env: {
143
- ...process.env,
144
- ATDD_SCAN_ROOTS: JSON.stringify(config.scanRoots),
145
- ATDD_SCAN_EXCLUDES: JSON.stringify(config.excludes),
146
- ATDD_PLAN_ROOT: topology.planRoot,
147
- ATDD_VIOLATIONS_REPORT: report,
148
- // The profiles this run selected, so a detector shared by several profiles can tell a planning run from
149
- // one that has reached RED or GREEN. Absent on a direct call, where detectors keep their full behaviour.
150
- ...(config.profiles ? { ATDD_PROFILES: JSON.stringify(config.profiles) } : {}),
151
- },
250
+ // One host-wide slot per detector process, so concurrent runs cannot together exhaust the machine.
251
+ const { exitCode, child } = await withHostSlot(async () => {
252
+ const child = Bun.spawn({
253
+ cmd: [bun, detector],
254
+ cwd: config.scanRoots[0] ?? process.cwd(),
255
+ stdout: "pipe",
256
+ stderr: "pipe",
257
+ env: {
258
+ ...process.env,
259
+ ATDD_SCAN_ROOTS: JSON.stringify(config.scanRoots),
260
+ ATDD_SCAN_EXCLUDES: JSON.stringify(config.excludes),
261
+ ATDD_PLAN_ROOT: topology.planRoot,
262
+ ATDD_VIOLATIONS_REPORT: report,
263
+ // The profiles this run selected, so a detector shared by several profiles can tell a planning run from
264
+ // one that has reached RED or GREEN. Absent on a direct call, where detectors keep their full behaviour.
265
+ ...(config.profiles ? { ATDD_PROFILES: JSON.stringify(config.profiles) } : {}),
266
+ },
267
+ });
268
+ return { exitCode: await child.exited, child };
152
269
  });
153
- const exitCode = await child.exited;
154
270
  if (exitCode !== 0) {
155
271
  const stderr = await new Response(child.stderr).text();
156
272
  throw new Error(`${implementation} failed to run (exit ${exitCode}): ${stderr.trim()}`);
@@ -175,8 +291,8 @@ export async function enforce(config: EnforcementConfig = {}): Promise<Violation
175
291
  // is validated on every run, so a misspelled atdd-bun.yaml fails whichever profile is requested.
176
292
  const requested = config.profiles ?? ["all"], enabled = await enabledProfiles(root);
177
293
  const selected = requested.includes("all") ? [...new Set([...requested.filter(p => p !== "all"), ...enabled])] : requested;
178
- const results = await Promise.all(
179
- implementationsFor(selected).map((implementation) => runImplementation(implementation, { scanRoots, excludes, profiles: selected as ConcreteProfile[] })),
294
+ const results = await pooled(implementationsFor(selected), runDetectorLimit(),
295
+ (implementation) => runImplementation(implementation, { scanRoots, excludes, profiles: selected as ConcreteProfile[] }),
180
296
  );
181
297
  return results.flat().sort((left, right) =>
182
298
  left.rule_id.localeCompare(right.rule_id) || left.file.localeCompare(right.file) || left.line - right.line,
package/src/hooks.ts CHANGED
@@ -33,7 +33,19 @@ const stagedStats = async (root: string) => (await git(root, ["diff", "--cached"
33
33
 
34
34
  export async function policy(root: string): Promise<HookPolicy> { const file = join(root, "atdd-bun.yaml"); if (!existsSync(file)) return defaultHookPolicy; return parsePolicy(await readFile(file, "utf8")); }
35
35
  function parsePolicy(text: string): HookPolicy { const data = Bun.YAML.parse(text) as Record<string, unknown>; const configuredWorktrees = data?.worktrees && typeof data.worktrees === "object" && !Array.isArray(data.worktrees) ? data.worktrees as Record<string, unknown> : {}; return { ...defaultHookPolicy, ...Object.fromEntries(Object.entries(data ?? {}).filter(([key, value]) => key in defaultHookPolicy && key !== "worktrees" && typeof value === typeof (defaultHookPolicy as any)[key])), protected_branches: strings(data?.protected_branches) ?? defaultHookPolicy.protected_branches, registry_paths: strings(data?.registry_paths) ?? defaultHookPolicy.registry_paths, worktrees: { ...defaultWorktreePolicy, ...Object.fromEntries(Object.entries(configuredWorktrees).filter(([key, value]) => key in defaultWorktreePolicy && typeof value === typeof (defaultWorktreePolicy as any)[key])) } }; }
36
- async function validation(root: string, changed: string[], full: boolean, registryChanged = false) { const profiles: any[] = [], topology = await topologyFor(root); if (registryChanged || changed.some(path => path.startsWith(`${topology.planRoot}/`)) || changed.includes("atdd-bun.yaml")) profiles.push("planner", "traceability"); if (changed.some(path => /\.(?:[cm]?[jt]sx?|html)$/.test(path))) profiles.push("coder", "tester"); if (changed.includes("atdd-bun.yaml") || changed.some(path => /\.ya?ml$/.test(path))) profiles.push("topology"); if (changed.some(isGeneratedView)) profiles.push("docs"); if (!profiles.length) return { ok: true, message: "no affected area" }; try { const enabled = await enabledProfiles(root), selected = full ? ["all" as const] : [...new Set(profiles)].filter(profile => enabled.includes(profile)); if (!selected.length) return { ok: true, message: "no activated profile covers the affected area" }; const findings = await enforce({ root, profiles: selected }); return findings.length ? bad(findings.map(f => `${f.rule_id}: ${f.file}`).join("\n")) : { ok: true, message: "validation clean" }; } catch (error) { return bad(`Bun enforcer resolution failed: ${String(error)}`); } }
36
+ /** One mapping serves ordinary validation, strict pre-push, and the commit ratchet gate so their affected-area decisions cannot drift. */
37
+ async function affectedProfiles(root: string, changed: string[], registryChanged = false) {
38
+ const profiles: string[] = [], topology = await topologyFor(root);
39
+ if (registryChanged || changed.some(path => path.startsWith(`${topology.planRoot}/`)) || changed.includes("atdd-bun.yaml")) profiles.push("planner", "traceability");
40
+ if (changed.some(path => /\.(?:[cm]?[jt]sx?|html)$/.test(path))) profiles.push("coder", "tester");
41
+ if (changed.includes("atdd-bun.yaml") || changed.some(path => /\.ya?ml$/.test(path))) profiles.push("topology");
42
+ if (changed.some(isGeneratedView)) profiles.push("docs");
43
+ return [...new Set(profiles)];
44
+ }
45
+ /** Detector inputs that can shift findings outside one changed area must retain the conservative full ratchet profile set. */
46
+ const fullCommitRatchetInputs = ["package.json", "bun.lock", "bun.lockb", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bunfig.toml"];
47
+ const needsFullCommitRatchetProfiles = (path: string) => path === "atdd-bun.yaml" || fullCommitRatchetInputs.includes(basename(path)) || /^tsconfig.*\.json$/.test(basename(path));
48
+ async function validation(root: string, changed: string[], full: boolean, registryChanged = false) { const profiles = await affectedProfiles(root, changed, registryChanged); if (!profiles.length) return { ok: true, message: "no affected area" }; try { const enabled = await enabledProfiles(root), selected = full ? ["all" as const] : profiles.filter(profile => enabled.includes(profile as any)); if (!selected.length) return { ok: true, message: "no activated profile covers the affected area" }; const findings = await enforce({ root, profiles: selected }); return findings.length ? bad(findings.map(f => `${f.rule_id}: ${f.file}`).join("\n")) : { ok: true, message: "validation clean" }; } catch (error) { return bad(`Bun enforcer resolution failed: ${String(error)}`); } }
37
49
 
38
50
  const stable = (profiles: string[]) => [...new Set(profiles)].sort();
39
51
  const configAt = async (root: string) => {
@@ -42,7 +54,7 @@ const configAt = async (root: string) => {
42
54
  };
43
55
 
44
56
  /** A staged reject-new policy compares its exact HEAD: equal active profiles are ordinary ratchet flow; strict expansion is the one bootstrap exception. */
45
- async function ratchetCommitGate(root: string): Promise<{ ok: boolean; message: string } | null> {
57
+ async function ratchetCommitGate(root: string, staged: string[], registryChanged: boolean): Promise<{ ok: boolean; message: string } | null> {
46
58
  const stagedConfig = await git(root, ["show", ":atdd-bun.yaml"]);
47
59
  if (stagedConfig.code) return null;
48
60
  let candidateConfig: Record<string, unknown>, policy;
@@ -64,7 +76,16 @@ async function ratchetCommitGate(root: string): Promise<{ ok: boolean; message:
64
76
  const activation = isStrictProfileExpansion(baseProfiles, candidateProfiles) && activationContextDigest(baseConfig) === activationContextDigest(candidateConfig);
65
77
  const unchanged = baseProfiles.join(",") === candidateProfiles.join(",") && contextDigest(baseConfig, baseProfiles) === contextDigest(candidateConfig, candidateProfiles);
66
78
  if (candidateProfiles.some(profile => !concreteProfiles.includes(profile as typeof concreteProfiles[number])) || !policyMatches || (!activation && !unchanged)) return bad("ratchet reject-new requires either unchanged explicit profiles/context or an explicit monotonic profile expansion with identical non-profile context");
67
- const baseFindings = await enforce({ root: scratch, profiles: candidateProfiles as any });
79
+ const selectedProfiles = activation || staged.some(needsFullCommitRatchetProfiles) ? candidateProfiles : (await affectedProfiles(root, staged, registryChanged)).filter(profile => candidateProfiles.includes(profile));
80
+ const tree = await git(root, ["write-tree"]);
81
+ if (tree.code || !/^[0-9a-f]{40}$/i.test(tree.out)) return bad(`could not identify staged ratchet activation candidate: ${tree.err || tree.out}`);
82
+ const baseTree = await git(root, ["rev-parse", "--verify", `${head.out}^{tree}`]);
83
+ if (baseTree.code || !/^[0-9a-f]{40}$/i.test(baseTree.out)) return bad(`could not identify ratchet activation base tree: ${baseTree.err || baseTree.out}`);
84
+ const normalize = (findings: Awaited<ReturnType<typeof enforce>>) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(scratch, finding.file) }));
85
+ // Scan and cache the unpatched HEAD before materializing the staged candidate; both keys remain exact Git trees.
86
+ const cacheDirectory = await findingsCacheDirectory(root), toolkit = await toolkitIdentity().catch(() => null);
87
+ const findingsAt = async (treeId: string) => cachedFindings(cacheDirectory, treeId, toolkit, selectedProfiles, async () => normalize(await enforce({ root: scratch, profiles: selectedProfiles as any })));
88
+ const baseFindings = selectedProfiles.length ? await findingsAt(baseTree.out) : [];
68
89
  const patch = await git(root, ["diff", "--cached", "--binary"], undefined, true);
69
90
  if (patch.code) return bad(`could not read staged ratchet activation candidate: ${patch.err || patch.out}`);
70
91
  const applied = await git(scratch, ["apply", "--whitespace=nowarn"], patch.out);
@@ -72,12 +93,9 @@ async function ratchetCommitGate(root: string): Promise<{ ok: boolean; message:
72
93
  const materialized = await configAt(scratch), materializedProfiles = stable(await enabledProfiles(scratch));
73
94
  const materializedContext = activation ? activationContextDigest(materialized) === activationContextDigest(candidateConfig) : materializedProfiles.join(",") === candidateProfiles.join(",") && contextDigest(materialized, materializedProfiles) === contextDigest(candidateConfig, candidateProfiles);
74
95
  if (!materializedContext) return bad("staged ratchet candidate did not materialize exactly");
75
- const candidateFindings = await enforce({ root: scratch, profiles: candidateProfiles as any });
76
- const normalize = (findings: Awaited<ReturnType<typeof enforce>>) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(scratch, finding.file) }));
77
- const delta = compareFindings(normalize(baseFindings), normalize(candidateFindings));
78
- const tree = await git(root, ["write-tree"]);
79
- if (tree.code || !/^[0-9a-f]{40}$/i.test(tree.out)) return bad(`could not identify staged ratchet activation candidate: ${tree.err || tree.out}`);
80
- const report = JSON.stringify({ schema: "atdd-bun.ratchet-report/v1", mode: policy.mode, base: head.out, candidate: tree.out, profiles: candidateProfiles, context: contextDigest(candidateConfig, candidateProfiles), ...delta });
96
+ const candidateFindings = selectedProfiles.length ? await findingsAt(tree.out) : [];
97
+ const delta = compareFindings(baseFindings, candidateFindings);
98
+ const report = JSON.stringify({ schema: "atdd-bun.ratchet-report/v1", mode: policy.mode, base: head.out, candidate: tree.out, profiles: selectedProfiles, context: contextDigest(candidateConfig, candidateProfiles), ...delta });
81
99
  return { ok: delta.new.length === 0, message: report };
82
100
  } catch (error) { return bad(`ratchet activation commit gate failed closed: ${String(error)}`); }
83
101
  finally { await git(root, ["worktree", "remove", "--force", scratch]); await rm(scratch, { recursive: true, force: true }); }
@@ -203,6 +221,10 @@ const cachedEntry = (text: string, key: string): FindingIdentity[] | null => {
203
221
  return valid ? entry.findings : null;
204
222
  };
205
223
  /** Findings for one exact tree: reuse a valid entry, otherwise compute and record the result for the next push. */
224
+ async function findingsCacheDirectory(root: string): Promise<string | null> {
225
+ const common = await git(root, ["rev-parse", "--git-common-dir"]);
226
+ return common.code || !common.out ? null : join(resolve(root, common.out), "atdd-bun", "findings-cache");
227
+ }
206
228
  async function cachedFindings(directory: string | null, tree: string, toolkit: ToolkitIdentity | null, profiles: string[], compute: () => Promise<FindingIdentity[]>): Promise<FindingIdentity[]> {
207
229
  if (!directory || !toolkit || !/^[0-9a-f]{40}$/i.test(tree)) return compute();
208
230
  const key = JSON.stringify({ schema: "atdd-bun.findings-cache-key/v1", tree: tree.toLowerCase(), version: toolkit.version, digest: toolkit.digest, profiles: stable(profiles) });
@@ -258,7 +280,7 @@ async function ratchetPushGate(root: string, remoteBase: string, candidate: stri
258
280
  if (contextDigest(protectedConfig, candidateProfiles) === contextDigest(candidateConfig, candidateProfiles)) protectedUsed = protectedBase;
259
281
  }
260
282
  // Findings of an exact tree are reused across pushes; uncached enforcements run one at a time to cap peak processes.
261
- const common = await git(root, ["rev-parse", "--git-common-dir"]), cacheDirectory = common.code || !common.out ? null : join(resolve(root, common.out), "atdd-bun", "findings-cache");
283
+ const cacheDirectory = await findingsCacheDirectory(root);
262
284
  const toolkit = await toolkitIdentity().catch(() => null);
263
285
  const findingsAt = async (directory: string, commit: string) => {
264
286
  const tree = await git(root, ["rev-parse", "--verify", `${commit}^{tree}`]);
@@ -289,7 +311,7 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
289
311
  const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
290
312
  if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
291
313
  if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
292
- if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), registries = staged.filter(path => isRegistry(cfg, path)), generated = staged.filter(isGeneratedView), counted = staged.length - registries.length - generated.length, lines = (await stagedLineStats(root)).filter(row => !isRegistry(cfg, row.path) && !isGeneratedView(row.path)).reduce((n, row) => n + row.added + row.removed, 0); if (counted > cfg.max_staged_files) return bad(`staged files ${counted} exceed ${cfg.max_staged_files}`); if (lines > cfg.max_staged_changed_lines) return bad(`staged changed lines ${lines} exceed ${cfg.max_staged_changed_lines}`); const ratchet = await ratchetCommitGate(root); if (ratchet) return ratchet; return cfg.require_traceability || registries.length || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
314
+ if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), registries = staged.filter(path => isRegistry(cfg, path)), generated = staged.filter(isGeneratedView), counted = staged.length - registries.length - generated.length, lines = (await stagedLineStats(root)).filter(row => !isRegistry(cfg, row.path) && !isGeneratedView(row.path)).reduce((n, row) => n + row.added + row.removed, 0); if (counted > cfg.max_staged_files) return bad(`staged files ${counted} exceed ${cfg.max_staged_files}`); if (lines > cfg.max_staged_changed_lines) return bad(`staged changed lines ${lines} exceed ${cfg.max_staged_changed_lines}`); const ratchet = await ratchetCommitGate(root, staged, registries.length > 0); if (ratchet) return ratchet; return cfg.require_traceability || registries.length || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
293
315
  if (event === "commit-msg") { const deleted = (await files(root, ["diff", "--cached", "--name-only", "--diff-filter=D"])).length, stats = await stagedStats(root), lines = stats.reduce((n, row) => n + row.removed, 0), registryRemoved = stats.filter(row => isRegistry(cfg, row.path)).reduce((n, row) => n + row.removed - row.added, 0), message = args[0] && existsSync(args[0]) ? await readFile(args[0], "utf8") : "", approved = message.includes("[mass-delete-approved]"); if ((deleted > 50 || lines > 10_000) && !approved) return bad("mass delete requires [mass-delete-approved]"); return registryRemoved > cfg.max_registry_removed_lines && !approved ? bad(`registry removal of ${registryRemoved} net lines exceeds ${cfg.max_registry_removed_lines}; requires [mass-delete-approved]`) : { ok: true, message: "ok" }; }
294
316
  if (event === "pre-push") { const reports: string[] = []; let exclusions: Awaited<ReturnType<typeof countExclusionTips>> | undefined; for (const row of stdin.split("\n").filter(Boolean).map(row => row.split(/\s+/))) { const [, local, remote, remoteSha] = row, target = remote?.replace("refs/heads/", ""); if (target && cfg.protected_branches.includes(target)) return bad(`protected branch ${target} is blocked`); if (!local) return bad("pre-push update has no local object id"); if (/^0+$/.test(local)) { const deletion = await deletionPushGate(root, remoteSha); if (!deletion.ok) return deletion; continue; } const isNew = !remoteSha || /^0+$/.test(remoteSha), range = isNew ? `${local}^..${local}` : `${remoteSha}..${local}`, shared = exclusions ??= await countExclusionTips(root, cfg.protected_branches, args[0]); if ("error" in shared) return bad(`could not list remote branch tips for the pushed range ${range}: ${shared.error}`); const counted = await git(root, ["rev-list", "--count", ...(shared.tips.length ? [isNew ? local : range, "--not", ...shared.tips] : [range])]); if (counted.code || !/^\d+$/.test(counted.out)) return bad(`could not count commits in pushed range ${range}: ${counted.err || counted.out}`); if (Number(counted.out) > cfg.max_commits_per_push) return bad(`commits per push ${counted.out} exceed ${cfg.max_commits_per_push}`); const ratchet = remote?.startsWith("refs/heads/") ? await ratchetPushGate(root, remoteSha ?? "", local, cfg.protected_branches, args[0]) : null; if (ratchet) { if (!ratchet.ok) return ratchet; reports.push(ratchet.message); continue; } const strict = await strictPushGate(root, local, range); if (!strict.ok) return strict; } return reports.length ? { ok: true, message: reports.length === 1 ? reports[0]! : `[${reports.join(",")}]` } : { ok: true, message: "validation clean" }; }
295
317
  if (event === "post-commit") { const result = await validation(root, await files(root, ["show", "--pretty=format:", "--name-only", "HEAD"]), false); return { ok: true, message: result.ok ? result.message : `advisory: ${result.message}` }; }