@afokapu/atdd-bun 0.10.82 → 0.10.84

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.82",
2
+ "version": "0.10.84",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
@@ -1261,7 +1261,7 @@
1261
1261
  "src/cli.ts": "995e41b3cb44c04f65f4d599f0fc51b93b17b7558b7c7dcfabed64de3d455250",
1262
1262
  "src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
1263
1263
  "src/enforce.ts": "7e6b3a1fc7c90f8e1836ab3e4655778fb569a63be5e7c199de44024fc003e6be",
1264
- "src/hooks.ts": "5c7dd77c370e745ad7a6f308fa0cfaeeb066560540403d2a4f56543a570116cc",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.82",
3
+ "version": "0.10.84",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
@@ -35,7 +35,7 @@
35
35
  "bun": ">=1.3.0"
36
36
  },
37
37
  "dependencies": {
38
- "@afokapu/atdd-flow": "^0.3.37",
38
+ "@afokapu/atdd-flow": "^0.3.38",
39
39
  "ajv": "^8.17.1",
40
40
  "ajv-formats": "^3.0.1",
41
41
  "typescript": "6.0.3"
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
- const changed = await files(candidateRoot, ["diff", "--name-only", range]);
117
- return await validation(candidateRoot, changed, true);
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
- const [baseFindings, candidateFindings, protectedFindings] = await Promise.all([
217
- enforce({ root: baseRoot, profiles: candidateProfiles as any }),
218
- enforce({ root: candidateRoot, profiles: candidateProfiles as any }),
219
- protectedUsed ? enforce({ root: protectedRoot, profiles: candidateProfiles as any }) : Promise.resolve([]),
220
- ]);
221
- const carriedBase = [...normalize(baseFindings, baseRoot), ...normalize(protectedFindings, protectedRoot)];
222
- const delta = compareFindings(carriedBase, normalize(candidateFindings, candidateRoot));
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 {