@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 +3 -3
- package/package.json +1 -1
- package/src/enforce.ts +136 -20
- package/src/hooks.ts +33 -11
package/integrity.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.10.
|
|
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": "
|
|
1264
|
-
"src/hooks.ts": "
|
|
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
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
179
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
76
|
-
const
|
|
77
|
-
const
|
|
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
|
|
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}` }; }
|