@afokapu/atdd-bun 0.10.62 → 0.10.63

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.62",
2
+ "version": "0.10.63",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
@@ -1261,7 +1261,7 @@
1261
1261
  "src/cli.ts": "90e59edbb8a7ea116ad9e7b4ba56b5c612ac18ada05824e8e941048be4dc72c8",
1262
1262
  "src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
1263
1263
  "src/enforce.ts": "7e6b3a1fc7c90f8e1836ab3e4655778fb569a63be5e7c199de44024fc003e6be",
1264
- "src/hooks.ts": "4816318553e8f6a96716d8f72f6a1976734fe8333ac4f1d2d8144ac71fefad81",
1264
+ "src/hooks.ts": "6b579c030bb4772f08806e802f4f814444b1878bbac0d6c62bc10b4ee184e449",
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.62",
3
+ "version": "0.10.63",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/hooks.ts CHANGED
@@ -82,6 +82,86 @@ async function ratchetCommitGate(root: string): Promise<{ ok: boolean; message:
82
82
  finally { await git(root, ["worktree", "remove", "--force", scratch]); await rm(scratch, { recursive: true, force: true }); }
83
83
  }
84
84
 
85
+ /** A pushed reject-new candidate is judged from its immutable object IDs, never the hook's checkout or index. */
86
+ const exactProfiles = (config: Record<string, unknown>, label: string) => {
87
+ if (!Array.isArray(config.profiles) || !config.profiles.length || !config.profiles.every(profile => typeof profile === "string")) throw new Error(`${label} must declare a non-empty explicit profiles list`);
88
+ const profiles = config.profiles as string[];
89
+ if (profiles.some(profile => normalizeProfileName(profile) !== profile || !concreteProfiles.includes(profile as typeof concreteProfiles[number]))) throw new Error(`${label} lists an unknown or legacy profile`);
90
+ if (new Set(profiles).size !== profiles.length) throw new Error(`${label} lists duplicate profiles`);
91
+ return stable(profiles);
92
+ };
93
+
94
+ async function checkoutCommit(root: string, directory: string, commit: string, label: string) {
95
+ const added = await git(root, ["worktree", "add", "--detach", "--no-checkout", directory, commit]);
96
+ if (added.code) throw new Error(`could not materialize ${label}: ${added.err || added.out}`);
97
+ const checkedOut = await git(directory, ["checkout", "--detach", commit]);
98
+ if (checkedOut.code) throw new Error(`could not checkout ${label}: ${checkedOut.err || checkedOut.out}`);
99
+ }
100
+
101
+ /** Compare exactly the remote tip and advertised local tip for one reject-new ref update. */
102
+ async function deletionPushGate(root: string, remote: string | undefined) {
103
+ if (!remote || !/^[0-9a-f]{40}$/i.test(remote)) return bad("pre-push deletion requires an exact remote commit");
104
+ const resolved = await git(root, ["rev-parse", "--verify", `${remote}^{commit}`]);
105
+ return resolved.code || resolved.out.toLowerCase() !== remote.toLowerCase() ? bad(`pre-push deletion remote commit ${remote} is unavailable or not exact`) : { ok: true, message: "deletion validated" };
106
+ }
107
+
108
+ async function strictPushGate(root: string, candidate: string, range: string) {
109
+ if (!/^[0-9a-f]{40}$/i.test(candidate)) return bad("strict pre-push validation requires an exact local candidate commit");
110
+ const resolved = await git(root, ["rev-parse", "--verify", `${candidate}^{commit}`]);
111
+ if (resolved.code || resolved.out.toLowerCase() !== candidate.toLowerCase()) return bad(`strict pre-push candidate ${candidate} is unavailable or not a full exact commit`);
112
+ const candidateRoot = await mkdtemp(join(tmpdir(), "atdd-strict-push-candidate-"));
113
+ try {
114
+ await checkoutCommit(root, candidateRoot, candidate, "strict pre-push candidate");
115
+ const changed = await files(candidateRoot, ["diff", "--name-only", range]);
116
+ return validation(candidateRoot, changed, true);
117
+ } catch (error) { return bad(`strict pre-push gate failed closed: ${String(error)}`); }
118
+ finally { await git(root, ["worktree", "remove", "--force", candidateRoot]); await rm(candidateRoot, { recursive: true, force: true }); }
119
+ }
120
+
121
+ async function ratchetPushGate(root: string, base: string, candidate: string): Promise<{ ok: boolean; message: string } | null> {
122
+ if (!/^[0-9a-f]{40}$/i.test(candidate)) return bad("ratchet reject-new requires an exact local candidate commit");
123
+ const resolvedCandidate = await git(root, ["rev-parse", "--verify", `${candidate}^{commit}`]);
124
+ if (resolvedCandidate.code || resolvedCandidate.out.toLowerCase() !== candidate.toLowerCase()) return bad(`ratchet candidate ${candidate} is unavailable or not a full exact commit`);
125
+ const baseRoot = await mkdtemp(join(tmpdir(), "atdd-ratchet-push-base-")), candidateRoot = await mkdtemp(join(tmpdir(), "atdd-ratchet-push-candidate-"));
126
+ try {
127
+ await checkoutCommit(root, candidateRoot, candidate, "ratchet candidate");
128
+ const candidateConfig = await configAt(candidateRoot);
129
+ let policy;
130
+ try { policy = parseRatchetPolicy(candidateConfig); }
131
+ catch (error) { return bad(`ratchet pushed candidate policy is invalid: ${String(error)}`); }
132
+ if (!policy || policy.mode !== "reject-new") return null;
133
+ if (!/^[0-9a-f]{40}$/i.test(base)) return bad("ratchet reject-new requires an exact non-zero remote commit base");
134
+ const resolvedBase = await git(root, ["rev-parse", "--verify", `${base}^{commit}`]);
135
+ if (resolvedBase.code || resolvedBase.out.toLowerCase() !== base.toLowerCase()) return bad(`ratchet remote base ${base} is unavailable or not a full exact commit`);
136
+ if ((await git(root, ["merge-base", "--is-ancestor", base, candidate])).code) return bad("ratchet remote base must be an ancestor of the pushed candidate");
137
+ await checkoutCommit(root, baseRoot, base, "ratchet remote base");
138
+ const baseConfig = await configAt(baseRoot);
139
+ let basePolicy;
140
+ try { basePolicy = baseConfig.ratchet === undefined ? null : parseRatchetPolicy(baseConfig); }
141
+ catch (error) { return bad(`ratchet remote base policy is invalid: ${String(error)}`); }
142
+ let baseProfiles: string[], candidateProfiles: string[], policyProfiles: string[], basePolicyProfiles: string[] | null;
143
+ try {
144
+ baseProfiles = exactProfiles(baseConfig, "ratchet remote base");
145
+ candidateProfiles = exactProfiles(candidateConfig, "ratchet pushed candidate");
146
+ policyProfiles = exactProfiles({ profiles: policy.profiles }, "ratchet policy");
147
+ basePolicyProfiles = basePolicy ? exactProfiles({ profiles: basePolicy.profiles }, "ratchet remote base policy") : null;
148
+ } catch (error) { return bad(`ratchet pushed range profile state is invalid: ${String(error)}`); }
149
+ if (baseProfiles.join(",") !== candidateProfiles.join(",") || policyProfiles.join(",") !== candidateProfiles.join(",") || (basePolicyProfiles && basePolicyProfiles.join(",") !== baseProfiles.join(",")) || contextDigest(baseConfig, baseProfiles) !== contextDigest(candidateConfig, candidateProfiles)) return bad("ratchet remote base and pushed candidate profile/context differ");
150
+ const normalize = (findings: Awaited<ReturnType<typeof enforce>>, directory: string) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(directory, finding.file) }));
151
+ const [baseFindings, candidateFindings] = await Promise.all([
152
+ enforce({ root: baseRoot, profiles: candidateProfiles as any }),
153
+ enforce({ root: candidateRoot, profiles: candidateProfiles as any }),
154
+ ]);
155
+ const delta = compareFindings(normalize(baseFindings, baseRoot), normalize(candidateFindings, candidateRoot));
156
+ return { ok: delta.new.length === 0, message: JSON.stringify({ schema: "atdd-bun.ratchet-report/v1", mode: policy.mode, base, candidate, profiles: candidateProfiles, context: contextDigest(candidateConfig, candidateProfiles), ...delta }) };
157
+ } catch (error) { return bad(`ratchet pre-push gate failed closed: ${String(error)}`); }
158
+ finally {
159
+ await git(root, ["worktree", "remove", "--force", baseRoot]);
160
+ await git(root, ["worktree", "remove", "--force", candidateRoot]);
161
+ await Promise.all([rm(baseRoot, { recursive: true, force: true }), rm(candidateRoot, { recursive: true, force: true })]);
162
+ }
163
+ }
164
+
85
165
  const inside = (parent: string, child: string) => { const path = relative(parent, child); return path === "" || (path !== ".." && !path.startsWith("../") && !path.startsWith("..\\")); };
86
166
  async function primaryRoot(root: string): Promise<string | null> { const result = await git(root, ["rev-parse", "--git-common-dir"]); if (result.code) return null; return dirname(resolve(root, result.out)); }
87
167
  export type WorktreeLayout = { primary: string; worktreeRoot: string; current: string; branch: string };
@@ -94,7 +174,7 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
94
174
  if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
95
175
  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" }; }
96
176
  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" }; }
97
- if (event === "pre-push") { for (const row of stdin.split("\n").filter(Boolean).map(row => row.split(/\s+/))) { const [,,remote, remoteSha] = row, target = remote?.replace("refs/heads/", ""); if (target && cfg.protected_branches.includes(target)) return bad(`protected branch ${target} is blocked`); const local = row[1]; if (local && !/^0+$/.test(local)) { const range = !remoteSha || /^0+$/.test(remoteSha) ? `${local}^..${local}` : `${remoteSha}..${local}`, count = Number((await git(root, ["rev-list", "--count", range])).out); if (count > cfg.max_commits_per_push) return bad(`commits per push ${count} exceed ${cfg.max_commits_per_push}`); } } return validation(root, await files(root, ["diff", "--name-only", "HEAD~1..HEAD"]), true); }
177
+ if (event === "pre-push") { const reports: string[] = []; for (const row of stdin.split("\n").filter(Boolean).map(row => row.split(/\s+/))) { const [localRef, 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 range = !remoteSha || /^0+$/.test(remoteSha) ? `${local}^..${local}` : `${remoteSha}..${local}`, counted = await git(root, ["rev-list", "--count", 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 = localRef?.startsWith("refs/heads/") ? await ratchetPushGate(root, remoteSha, local) : 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" }; }
98
178
  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}` }; }
99
179
  return { ok: true, message: "ok" };
100
180
  }