@afokapu/atdd-bun 0.10.85 → 0.10.87

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.85",
2
+ "version": "0.10.87",
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": "e0c10519aeecd7080f2900ea230fad2f09482af47c1cee885d5de71573e29732",
1264
- "src/hooks.ts": "f20024340979de799ed977b8d2483ca09c494ef952dcea2cc7c4c39e6e10679c",
1264
+ "src/hooks.ts": "e3ef7e17eaec8fe3b646e6b33a4a04c40eb25ac9a2642e68e4401fec973521e2",
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.85",
3
+ "version": "0.10.87",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/hooks.ts CHANGED
@@ -2,7 +2,7 @@ import { chmod, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, stat, w
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, profileConventions } from "./enforce";
5
+ import { concreteProfiles, enabledProfiles, enforce, normalizeProfileName, profileConventions, type ConcreteProfile } from "./enforce";
6
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";
@@ -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: ConcreteProfile[] = [], 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}` }; }