@afokapu/atdd-bun 0.10.62 → 0.10.64
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/cli.ts +4 -3
- package/src/hooks.ts +81 -1
package/integrity.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.10.
|
|
2
|
+
"version": "0.10.64",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
|
|
@@ -1258,10 +1258,10 @@
|
|
|
1258
1258
|
"relationships.yaml": "5860a308ebc3ee7fa8172fc2c6633984000ab3003f0ae4b6fa28db48879eda22",
|
|
1259
1259
|
"src/agent.ts": "6d6c6e75908af8fbab13432f0001b0374bb25cf3dee46ba218dfd67e0090cb56",
|
|
1260
1260
|
"src/ci.ts": "646e72ff576d3921d85ecdd1d8c7821693832735be8949f5da254f3c345be032",
|
|
1261
|
-
"src/cli.ts": "
|
|
1261
|
+
"src/cli.ts": "fc8a78b31d6630671bb2ebd51363741cb4cf786801a95b8da8c879144683a049",
|
|
1262
1262
|
"src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
|
|
1263
1263
|
"src/enforce.ts": "7e6b3a1fc7c90f8e1836ab3e4655778fb569a63be5e7c199de44024fc003e6be",
|
|
1264
|
-
"src/hooks.ts": "
|
|
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
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { enforce, enabledProfiles, flowCompatibilityDiagnostic, normalizeProfileName, profileNames, type Profile } from "./enforce";
|
|
2
|
+
import { concreteProfiles, enforce, enabledProfiles, flowCompatibilityDiagnostic, normalizeProfileName, profileNames, type Profile } from "./enforce";
|
|
3
3
|
import { activationContextDigest, canonicalFindingPath, compareFindings, contextDigest, isStrictProfileExpansion, parseRatchetPolicy } from "./ratchet";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
@@ -154,7 +154,8 @@ if (!args.includes("--ratchet")) {
|
|
|
154
154
|
if (!policy) fail("--ratchet requires an explicit atdd-bun.yaml ratchet policy");
|
|
155
155
|
const candidateProfiles = normalizedRequested.includes("all") ? await enabledProfiles(candidateRoot) : normalizedRequested;
|
|
156
156
|
if (stable(policy.profiles).join(",") !== stable(candidateProfiles).join(",")) fail("ratchet.profiles must exactly equal the selected profiles");
|
|
157
|
-
|
|
157
|
+
const explicitCandidateProfiles = Array.isArray(candidateConfig.profiles) && candidateConfig.profiles.every(profile => concreteProfiles.includes(profile as typeof concreteProfiles[number])) && new Set(candidateConfig.profiles).size === candidateConfig.profiles.length;
|
|
158
|
+
if (profileActivation && (!explicitCandidateProfiles || stable(await enabledProfiles(candidateRoot)).join(",") !== stable(candidateProfiles).join(","))) fail("profile activation requires the explicitly configured candidate profile set to be selected");
|
|
158
159
|
if ((await git(candidateRoot, ["status", "--porcelain"])).out) fail("--ratchet requires a porcelain-clean candidate worktree");
|
|
159
160
|
const resolved = await git(candidateRoot, ["rev-parse", "--verify", `${base}^{commit}`]);
|
|
160
161
|
if (resolved.code || resolved.out.toLowerCase() !== base.toLowerCase()) fail(`ratchet base ${base} is unavailable or not a full exact commit`);
|
|
@@ -169,7 +170,7 @@ if (!args.includes("--ratchet")) {
|
|
|
169
170
|
else {
|
|
170
171
|
const baseConfig = await configAt(scratch), baseProfiles = profileActivation ? await enabledProfiles(scratch) : normalizedRequested.includes("all") ? await enabledProfiles(scratch) : normalizedRequested;
|
|
171
172
|
const ordinaryContextMatches = stable(baseProfiles).join(",") === stable(candidateProfiles).join(",") && contextDigest(baseConfig, baseProfiles) === contextDigest(candidateConfig, candidateProfiles);
|
|
172
|
-
const activationIsValid =
|
|
173
|
+
const activationIsValid = isStrictProfileExpansion(baseProfiles, candidateProfiles) && activationContextDigest(baseConfig) === activationContextDigest(candidateConfig);
|
|
173
174
|
if (!(profileActivation ? activationIsValid : ordinaryContextMatches)) { console.error(profileActivation ? "ratchet profile activation requires an explicit monotonic profile expansion and identical non-profile context" : "ratchet base and candidate profile/context differ; use a separately governed adoption path"); process.exitCode = 1; }
|
|
174
175
|
else {
|
|
175
176
|
const [baseFindings, candidateFindings] = await Promise.all([enforce({ root: scratch, profiles: candidateProfiles as Profile[] }), enforce({ root: candidateRoot, profiles: candidateProfiles as Profile[] })]);
|
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 [
|
|
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
|
}
|