@afokapu/atdd-bun 0.10.83 → 0.10.85
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 +60 -12
package/integrity.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.10.
|
|
2
|
+
"version": "0.10.85",
|
|
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": "f20024340979de799ed977b8d2483ca09c494ef952dcea2cc7c4c39e6e10679c",
|
|
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
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { chmod, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { concreteProfiles, enabledProfiles, enforce, normalizeProfileName } from "./enforce";
|
|
6
|
-
import { activationContextDigest, canonicalFindingPath, compareFindings, contextDigest, isStrictProfileExpansion, parseRatchetPolicy } from "./ratchet";
|
|
5
|
+
import { concreteProfiles, enabledProfiles, enforce, normalizeProfileName, profileConventions } from "./enforce";
|
|
6
|
+
import { activationContextDigest, canonicalFindingPath, compareFindings, contextDigest, findingFingerprint, isStrictProfileExpansion, parseRatchetPolicy, type FindingIdentity } from "./ratchet";
|
|
7
7
|
import { topologyFor } from "./topology";
|
|
8
8
|
import { JOURNEY_DOCS_DIR } from "./journey-docs";
|
|
9
9
|
|
|
@@ -113,8 +113,9 @@ async function strictPushGate(root: string, candidate: string, range: string) {
|
|
|
113
113
|
const candidateRoot = await mkdtemp(join(tmpdir(), "atdd-strict-push-candidate-"));
|
|
114
114
|
try {
|
|
115
115
|
await checkoutCommit(root, candidateRoot, candidate, "strict pre-push candidate");
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
// The same affected-area selection as pre-commit, judged against the candidate's own registry policy.
|
|
117
|
+
const changed = await files(candidateRoot, ["diff", "--name-only", range]), cfg = await policy(candidateRoot);
|
|
118
|
+
return await validation(candidateRoot, changed, false, changed.some(path => isRegistry(cfg, path)));
|
|
118
119
|
} catch (error) { return bad(`strict pre-push gate failed closed: ${String(error)}`); }
|
|
119
120
|
finally { await git(root, ["worktree", "remove", "--force", candidateRoot]); await rm(candidateRoot, { recursive: true, force: true }); }
|
|
120
121
|
}
|
|
@@ -170,6 +171,49 @@ async function existingBranchProtectedBase(root: string, candidate: string, remo
|
|
|
170
171
|
return { base: tip && tip !== remoteBase.toLowerCase() ? tip : null };
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
/** Ratchet findings depend only on the scanned tree, the toolkit scanning it and the selected profiles, so they are cached
|
|
175
|
+
* under the Git common directory. An entry is advisory: unreadable, malformed or differently keyed means recompute, never clean. */
|
|
176
|
+
const packageRoot = resolve(import.meta.dir, ".."), toolkitInputs = ["package.json", "relationships.yaml", "src", "lib", "detectors", "conventions", "planner-nodes", "planner-schemas"];
|
|
177
|
+
export type ToolkitIdentity = { version: string; digest: string };
|
|
178
|
+
async function toolkitFiles() {
|
|
179
|
+
const found = new Set<string>();
|
|
180
|
+
for (const input of toolkitInputs) {
|
|
181
|
+
const path = join(packageRoot, input);
|
|
182
|
+
if (!existsSync(path)) continue;
|
|
183
|
+
if ((await stat(path)).isFile()) { found.add(input); continue; }
|
|
184
|
+
for await (const file of new Bun.Glob("**/*").scan({ cwd: path, onlyFiles: true, dot: true })) found.add(`${input}/${file.replaceAll("\\", "/")}`);
|
|
185
|
+
}
|
|
186
|
+
// Companion conventions (the Flow lifecycle) live outside this package but still shape findings.
|
|
187
|
+
for (const { path } of Object.values(profileConventions).flat()) found.add(path);
|
|
188
|
+
return [...found].sort();
|
|
189
|
+
}
|
|
190
|
+
async function computeToolkitIdentity(): Promise<ToolkitIdentity> {
|
|
191
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
192
|
+
for (const file of await toolkitFiles()) { const full = resolve(packageRoot, file); hasher.update(`${file}\0`).update(existsSync(full) ? await readFile(full) : "\0missing").update("\0"); }
|
|
193
|
+
const version = (JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")) as { version?: unknown }).version;
|
|
194
|
+
return { version: typeof version === "string" ? version : "unknown", digest: hasher.digest("hex") };
|
|
195
|
+
}
|
|
196
|
+
let identity: Promise<ToolkitIdentity> | undefined;
|
|
197
|
+
/** The running toolkit's package version and a digest of every detector/convention input, computed once per process. */
|
|
198
|
+
export function toolkitIdentity(): Promise<ToolkitIdentity> { return identity ??= computeToolkitIdentity(); }
|
|
199
|
+
const cachedEntry = (text: string, key: string): FindingIdentity[] | null => {
|
|
200
|
+
const entry = JSON.parse(text) as { schema?: unknown; key?: unknown; findings?: unknown };
|
|
201
|
+
if (entry?.schema !== "atdd-bun.findings-cache/v1" || JSON.stringify(entry.key) !== key || !Array.isArray(entry.findings)) return null;
|
|
202
|
+
const valid = entry.findings.every(finding => finding && typeof finding.rule_id === "string" && typeof finding.file === "string" && findingFingerprint(finding));
|
|
203
|
+
return valid ? entry.findings : null;
|
|
204
|
+
};
|
|
205
|
+
/** Findings for one exact tree: reuse a valid entry, otherwise compute and record the result for the next push. */
|
|
206
|
+
async function cachedFindings(directory: string | null, tree: string, toolkit: ToolkitIdentity | null, profiles: string[], compute: () => Promise<FindingIdentity[]>): Promise<FindingIdentity[]> {
|
|
207
|
+
if (!directory || !toolkit || !/^[0-9a-f]{40}$/i.test(tree)) return compute();
|
|
208
|
+
const key = JSON.stringify({ schema: "atdd-bun.findings-cache-key/v1", tree: tree.toLowerCase(), version: toolkit.version, digest: toolkit.digest, profiles: stable(profiles) });
|
|
209
|
+
const file = join(directory, `${new Bun.CryptoHasher("sha256").update(key).digest("hex")}.json`);
|
|
210
|
+
const hit = await readFile(file, "utf8").then(text => cachedEntry(text, key)).catch(() => null);
|
|
211
|
+
if (hit) return hit;
|
|
212
|
+
const findings = await compute(), scratch = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
213
|
+
await mkdir(directory, { recursive: true }).then(() => writeFile(scratch, JSON.stringify({ schema: "atdd-bun.findings-cache/v1", key: JSON.parse(key), findings }))).then(() => rename(scratch, file)).catch(() => rm(scratch, { force: true }));
|
|
214
|
+
return findings;
|
|
215
|
+
}
|
|
216
|
+
|
|
173
217
|
async function ratchetPushGate(root: string, remoteBase: string, candidate: string, protectedBranches: string[], remote: string | undefined): Promise<{ ok: boolean; message: string } | null> {
|
|
174
218
|
let base = remoteBase;
|
|
175
219
|
if (!/^[0-9a-f]{40}$/i.test(candidate)) return bad("ratchet reject-new requires an exact local candidate commit");
|
|
@@ -213,13 +257,17 @@ async function ratchetPushGate(root: string, remoteBase: string, candidate: stri
|
|
|
213
257
|
// Only a protected tip judged under the same profile/context as the candidate is comparable; otherwise the remote tip alone decides.
|
|
214
258
|
if (contextDigest(protectedConfig, candidateProfiles) === contextDigest(candidateConfig, candidateProfiles)) protectedUsed = protectedBase;
|
|
215
259
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
260
|
+
// 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");
|
|
262
|
+
const toolkit = await toolkitIdentity().catch(() => null);
|
|
263
|
+
const findingsAt = async (directory: string, commit: string) => {
|
|
264
|
+
const tree = await git(root, ["rev-parse", "--verify", `${commit}^{tree}`]);
|
|
265
|
+
return cachedFindings(cacheDirectory, tree.code ? "" : tree.out, toolkit, candidateProfiles, async () => normalize(await enforce({ root: directory, profiles: candidateProfiles as any }), directory));
|
|
266
|
+
};
|
|
267
|
+
const baseFindings = await findingsAt(baseRoot, base);
|
|
268
|
+
const protectedFindings = protectedUsed ? await findingsAt(protectedRoot, protectedUsed) : [];
|
|
269
|
+
const candidateFindings = await findingsAt(candidateRoot, candidate);
|
|
270
|
+
const delta = compareFindings([...baseFindings, ...protectedFindings], candidateFindings);
|
|
223
271
|
return { ok: delta.new.length === 0, message: JSON.stringify({ schema: "atdd-bun.ratchet-report/v1", mode: policy.mode, base, ...(protectedUsed ? { protected_base: protectedUsed } : {}), candidate, profiles: candidateProfiles, context: contextDigest(candidateConfig, candidateProfiles), ...delta }) };
|
|
224
272
|
} catch (error) { return bad(`ratchet pre-push gate failed closed: ${String(error)}`); }
|
|
225
273
|
finally {
|