@afokapu/atdd-bun 0.10.84 → 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 +2 -2
- package/package.json +1 -1
- package/src/enforce.ts +136 -20
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,7 +1260,7 @@
|
|
|
1260
1260
|
"src/ci.ts": "2a5e5052c4b99eb5c7282c7db2af3846ad6f3ca7d1eaefe924441116f765f37b",
|
|
1261
1261
|
"src/cli.ts": "995e41b3cb44c04f65f4d599f0fc51b93b17b7558b7c7dcfabed64de3d455250",
|
|
1262
1262
|
"src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
|
|
1263
|
-
"src/enforce.ts": "
|
|
1263
|
+
"src/enforce.ts": "e0c10519aeecd7080f2900ea230fad2f09482af47c1cee885d5de71573e29732",
|
|
1264
1264
|
"src/hooks.ts": "f20024340979de799ed977b8d2483ca09c494ef952dcea2cc7c4c39e6e10679c",
|
|
1265
1265
|
"src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
|
|
1266
1266
|
"src/integrity.ts": "f5ad22759eec675454df82cdcf791b1f081951f08ca5084ebdcb8cd82aa1ecef",
|
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,
|