@afokapu/atdd-bun 0.10.70 → 0.10.72
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/hooks.ts +42 -13
package/integrity.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.10.
|
|
2
|
+
"version": "0.10.72",
|
|
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": "
|
|
1264
|
+
"src/hooks.ts": "03eadd10e97cb4c369b89668b86be6d3e7f50b1402c1e1293d822c414581a30c",
|
|
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/hooks.ts
CHANGED
|
@@ -31,7 +31,8 @@ const stagedLineStats = async (root: string) => (await git(root, ["diff", "--cac
|
|
|
31
31
|
const isGeneratedView = (path: string) => path.startsWith(`${JOURNEY_DOCS_DIR}/`);
|
|
32
32
|
const stagedStats = async (root: string) => (await git(root, ["diff", "--cached", "--numstat", "--no-renames"])).out.split("\n").filter(Boolean).map(row => { const [added, removed, ...path] = row.split("\t"); return { added: Number(added) || 0, removed: Number(removed) || 0, path: path.join("\t") }; });
|
|
33
33
|
|
|
34
|
-
export async function policy(root: string): Promise<HookPolicy> { const file = join(root, "atdd-bun.yaml"); if (!existsSync(file)) return defaultHookPolicy;
|
|
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
|
+
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])) } }; }
|
|
35
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
37
|
|
|
37
38
|
const stable = (profiles: string[]) => [...new Set(profiles)].sort();
|
|
@@ -113,13 +114,13 @@ async function strictPushGate(root: string, candidate: string, range: string) {
|
|
|
113
114
|
try {
|
|
114
115
|
await checkoutCommit(root, candidateRoot, candidate, "strict pre-push candidate");
|
|
115
116
|
const changed = await files(candidateRoot, ["diff", "--name-only", range]);
|
|
116
|
-
return validation(candidateRoot, changed, true);
|
|
117
|
+
return await validation(candidateRoot, changed, true);
|
|
117
118
|
} catch (error) { return bad(`strict pre-push gate failed closed: ${String(error)}`); }
|
|
118
119
|
finally { await git(root, ["worktree", "remove", "--force", candidateRoot]); await rm(candidateRoot, { recursive: true, force: true }); }
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
/** A brand-new remote branch is ratcheted against the one protected remote-tracking tip that is an exact ancestor of the candidate. */
|
|
122
|
-
async function
|
|
123
|
+
async function protectedAncestors(root: string, candidate: string, protectedBranches: string[], remote: string | undefined): Promise<{ ancestors: string[] } | { error: string }> {
|
|
123
124
|
const listed = await git(root, ["for-each-ref", "--format=%(refname) %(objectname)", "refs/remotes/"]);
|
|
124
125
|
if (listed.code) return { error: `could not list protected branch tips for the new remote branch: ${listed.err || listed.out}` };
|
|
125
126
|
const remotes = (await git(root, ["remote"])).out.split("\n").filter(Boolean), pushRemote = remote && remotes.includes(remote) ? remote : undefined;
|
|
@@ -129,9 +130,24 @@ async function newBranchBase(root: string, candidate: string, protectedBranches:
|
|
|
129
130
|
});
|
|
130
131
|
const ancestors = new Set<string>();
|
|
131
132
|
for (const tip of new Set(tips)) if ((await git(root, ["merge-base", "--is-ancestor", tip, candidate])).code === 0) ancestors.add(tip);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
return { ancestors: [...ancestors] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function newBranchBase(root: string, candidate: string, protectedBranches: string[], remote: string | undefined): Promise<{ base: string } | { error: string }> {
|
|
137
|
+
const found = await protectedAncestors(root, candidate, protectedBranches, remote);
|
|
138
|
+
if ("error" in found) return found;
|
|
139
|
+
if (!found.ancestors.length) return { error: "ratchet new remote branch requires a protected branch tip that is an exact ancestor of the pushed candidate; none found" };
|
|
140
|
+
if (found.ancestors.length > 1) return { error: `ratchet new remote branch base is ambiguous: ${found.ancestors.length} protected branch tips qualify with different commits` };
|
|
141
|
+
return { base: found.ancestors[0]! };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** For an existing remote branch the protected ancestor tip also counts as carried: CI judges against the protected base. */
|
|
145
|
+
async function existingBranchProtectedBase(root: string, candidate: string, remoteBase: string, protectedBranches: string[], remote: string | undefined): Promise<{ base: string | null } | { error: string }> {
|
|
146
|
+
const found = await protectedAncestors(root, candidate, protectedBranches, remote);
|
|
147
|
+
if ("error" in found) return found;
|
|
148
|
+
if (found.ancestors.length > 1) return { error: `ratchet existing remote branch protected base is ambiguous: ${found.ancestors.length} protected branch tips qualify with different commits` };
|
|
149
|
+
const tip = found.ancestors[0];
|
|
150
|
+
return { base: tip && tip !== remoteBase.toLowerCase() ? tip : null };
|
|
135
151
|
}
|
|
136
152
|
|
|
137
153
|
async function ratchetPushGate(root: string, remoteBase: string, candidate: string, protectedBranches: string[], remote: string | undefined): Promise<{ ok: boolean; message: string } | null> {
|
|
@@ -139,7 +155,7 @@ async function ratchetPushGate(root: string, remoteBase: string, candidate: stri
|
|
|
139
155
|
if (!/^[0-9a-f]{40}$/i.test(candidate)) return bad("ratchet reject-new requires an exact local candidate commit");
|
|
140
156
|
const resolvedCandidate = await git(root, ["rev-parse", "--verify", `${candidate}^{commit}`]);
|
|
141
157
|
if (resolvedCandidate.code || resolvedCandidate.out.toLowerCase() !== candidate.toLowerCase()) return bad(`ratchet candidate ${candidate} is unavailable or not a full exact commit`);
|
|
142
|
-
const baseRoot = await mkdtemp(join(tmpdir(), "atdd-ratchet-push-base-")), candidateRoot = await mkdtemp(join(tmpdir(), "atdd-ratchet-push-candidate-"));
|
|
158
|
+
const baseRoot = await mkdtemp(join(tmpdir(), "atdd-ratchet-push-base-")), protectedRoot = await mkdtemp(join(tmpdir(), "atdd-ratchet-push-protected-")), candidateRoot = await mkdtemp(join(tmpdir(), "atdd-ratchet-push-candidate-"));
|
|
143
159
|
try {
|
|
144
160
|
await checkoutCommit(root, candidateRoot, candidate, "ratchet candidate");
|
|
145
161
|
const candidateConfig = await configAt(candidateRoot);
|
|
@@ -147,6 +163,8 @@ async function ratchetPushGate(root: string, remoteBase: string, candidate: stri
|
|
|
147
163
|
try { policy = parseRatchetPolicy(candidateConfig); }
|
|
148
164
|
catch (error) { return bad(`ratchet pushed candidate policy is invalid: ${String(error)}`); }
|
|
149
165
|
if (!policy || policy.mode !== "reject-new") return null;
|
|
166
|
+
let protectedBase: string | null = null;
|
|
167
|
+
if (!/^0+$/.test(remoteBase)) { const found = await existingBranchProtectedBase(root, candidate, remoteBase, protectedBranches, remote); if ("error" in found) return bad(found.error); protectedBase = found.base; }
|
|
150
168
|
if (/^0+$/.test(remoteBase)) { const found = await newBranchBase(root, candidate, protectedBranches, remote); if ("error" in found) return bad(found.error); base = found.base; }
|
|
151
169
|
if (!/^[0-9a-f]{40}$/i.test(base)) return bad("ratchet reject-new requires an exact non-zero remote commit base");
|
|
152
170
|
const resolvedBase = await git(root, ["rev-parse", "--verify", `${base}^{commit}`]);
|
|
@@ -168,22 +186,33 @@ async function ratchetPushGate(root: string, remoteBase: string, candidate: stri
|
|
|
168
186
|
const unchanged = baseProfiles.join(",") === candidateProfiles.join(",") && (!basePolicyProfiles || basePolicyProfiles.join(",") === baseProfiles.join(",")) && contextDigest(baseConfig, baseProfiles) === contextDigest(candidateConfig, candidateProfiles);
|
|
169
187
|
if (policyProfiles.join(",") !== candidateProfiles.join(",") || (!activation && !unchanged)) return bad("ratchet remote base and pushed candidate profile/context differ");
|
|
170
188
|
const normalize = (findings: Awaited<ReturnType<typeof enforce>>, directory: string) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(directory, finding.file) }));
|
|
171
|
-
|
|
189
|
+
let protectedUsed: string | null = null;
|
|
190
|
+
if (protectedBase) {
|
|
191
|
+
await checkoutCommit(root, protectedRoot, protectedBase, "ratchet protected base");
|
|
192
|
+
const protectedConfig = await configAt(protectedRoot);
|
|
193
|
+
// Only a protected tip judged under the same profile/context as the candidate is comparable; otherwise the remote tip alone decides.
|
|
194
|
+
if (contextDigest(protectedConfig, candidateProfiles) === contextDigest(candidateConfig, candidateProfiles)) protectedUsed = protectedBase;
|
|
195
|
+
}
|
|
196
|
+
const [baseFindings, candidateFindings, protectedFindings] = await Promise.all([
|
|
172
197
|
enforce({ root: baseRoot, profiles: candidateProfiles as any }),
|
|
173
198
|
enforce({ root: candidateRoot, profiles: candidateProfiles as any }),
|
|
199
|
+
protectedUsed ? enforce({ root: protectedRoot, profiles: candidateProfiles as any }) : Promise.resolve([]),
|
|
174
200
|
]);
|
|
175
|
-
const
|
|
176
|
-
|
|
201
|
+
const carriedBase = [...normalize(baseFindings, baseRoot), ...normalize(protectedFindings, protectedRoot)];
|
|
202
|
+
const delta = compareFindings(carriedBase, normalize(candidateFindings, candidateRoot));
|
|
203
|
+
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 }) };
|
|
177
204
|
} catch (error) { return bad(`ratchet pre-push gate failed closed: ${String(error)}`); }
|
|
178
205
|
finally {
|
|
179
206
|
await git(root, ["worktree", "remove", "--force", baseRoot]);
|
|
207
|
+
await git(root, ["worktree", "remove", "--force", protectedRoot]);
|
|
180
208
|
await git(root, ["worktree", "remove", "--force", candidateRoot]);
|
|
181
|
-
await Promise.all([rm(baseRoot, { recursive: true, force: true }), rm(candidateRoot, { recursive: true, force: true })]);
|
|
209
|
+
await Promise.all([rm(baseRoot, { recursive: true, force: true }), rm(protectedRoot, { recursive: true, force: true }), rm(candidateRoot, { recursive: true, force: true })]);
|
|
182
210
|
}
|
|
183
211
|
}
|
|
184
212
|
|
|
185
213
|
const inside = (parent: string, child: string) => { const path = relative(parent, child); return path === "" || (path !== ".." && !path.startsWith("../") && !path.startsWith("..\\")); };
|
|
186
214
|
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)); }
|
|
215
|
+
async function primaryPolicy(root: string): Promise<HookPolicy> { const primary = await primaryRoot(await realpath(resolve(root))); if (!primary) return defaultHookPolicy; const committed = await git(primary, ["show", "HEAD:atdd-bun.yaml"]); return committed.code ? defaultHookPolicy : parsePolicy(committed.out); }
|
|
187
216
|
export type WorktreeLayout = { primary: string; worktreeRoot: string; current: string; branch: string };
|
|
188
217
|
async function layout(root: string, cfg: WorktreePolicy): Promise<WorktreeLayout | { error: string }> { const current = await realpath(resolve(root)), commonRoot = await primaryRoot(current); if (!commonRoot) return { error: "not inside a Git worktree" }; const primary = await realpath(commonRoot), expectedPrimary = cfg.primary_directory === "." ? primary : join(dirname(primary), cfg.primary_directory); if (primary !== expectedPrimary) return { error: `primary checkout must be ${expectedPrimary}, not ${primary}` }; const branch = (await git(primary, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out; if (branch !== cfg.primary_branch) return { error: `primary checkout ${primary} must be on ${cfg.primary_branch}, not ${branch || "a detached HEAD"}` }; return { primary, worktreeRoot: resolve(primary, cfg.root.replaceAll("{repo}", basename(primary))), current, branch: (await git(current, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out }; }
|
|
189
218
|
async function worktreeCommitPolicy(root: string, cfg: WorktreePolicy): Promise<string | null> { if (!cfg.enabled || !cfg.require_linked_worktree) return null; const state = await layout(root, cfg); if ("error" in state) return state.error; if (state.current === state.primary) return `commits must use a linked worktree beneath ${state.worktreeRoot}; run: atdd-bun worktree start <branch>`; if (!inside(state.worktreeRoot, state.current)) return `linked worktree ${state.current} is outside required root ${state.worktreeRoot}`; if (!state.branch) return "commits require a named branch, not a detached HEAD"; return null; }
|
|
@@ -208,8 +237,8 @@ export async function hooksStatus(root = process.cwd()) { const path = (await gi
|
|
|
208
237
|
const branchPath = (branch: string) => branch.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
209
238
|
export async function startWorktree(root = process.cwd(), branch = "") { const cfg = await policy(root); if (!cfg.worktrees.enabled) return bad("worktree policy is disabled; set worktrees.enabled: true in atdd-bun.yaml"); const state = await layout(root, cfg.worktrees); if ("error" in state) return bad(state.error); if (state.current !== state.primary) return bad(`start must run from the primary checkout ${state.primary}`); if (!branch) return bad("branch is required: atdd-bun worktree start <branch>"); if ((await git(root, ["check-ref-format", "--branch", branch])).code) return bad(`invalid branch name ${JSON.stringify(branch)}`); if (cfg.protected_branches.includes(branch)) return bad(`cannot create a worktree for protected branch ${branch}`); const leaf = branchPath(branch); if (!leaf) return bad(`branch ${JSON.stringify(branch)} cannot form a safe worktree directory`); const target = join(state.worktreeRoot, leaf); if (existsSync(target)) return bad(`worktree path already exists: ${target}`); await mkdir(state.worktreeRoot, { recursive: true }); const created = await git(state.primary, ["worktree", "add", target, "-b", branch]); if (created.code) return bad(created.err || created.out); const hooks = await installHooks(target); if (!hooks.ok) return bad(`created ${target}, but could not install hooks: ${hooks.message}`); return { ok: true, message: `created linked worktree ${target} on ${branch}` }; }
|
|
210
239
|
|
|
211
|
-
export async function worktreeStatus(root = process.cwd()) { const cfg = await
|
|
240
|
+
export async function worktreeStatus(root = process.cwd()) { const cfg = await primaryPolicy(root); if (!cfg.worktrees.enabled) return bad("worktree policy is disabled"); const state = await layout(root, cfg.worktrees); if ("error" in state) return bad(state.error); const result = await git(state.primary, ["worktree", "list", "--porcelain"]); return result.code ? bad(result.err) : { ok: true, message: result.out }; }
|
|
212
241
|
|
|
213
242
|
async function generatedHooksOnly(root: string): Promise<boolean> { const directory = join(root, ".githooks"); if (!existsSync(directory)) return false; const names = (await readdir(directory)).sort(); if (names.join("\n") !== [...hookEvents].sort().join("\n")) return false; return (await Promise.all(hookEvents.map(async event => (await readFile(join(directory, event), "utf8")) === dispatcher(event)))).every(Boolean); }
|
|
214
243
|
|
|
215
|
-
export async function finishWorktree(root = process.cwd(), deleteBranch = false) { const cfg = await
|
|
244
|
+
export async function finishWorktree(root = process.cwd(), deleteBranch = false) { const cfg = await primaryPolicy(root); if (!cfg.worktrees.enabled) return bad("worktree policy is disabled"); const state = await layout(root, cfg.worktrees); if ("error" in state) return bad(state.error); if (state.current === state.primary || !inside(state.worktreeRoot, state.current)) return bad(`finish must run from a linked worktree beneath ${state.worktreeRoot}`); if (!state.branch || cfg.protected_branches.includes(state.branch)) return bad("finish requires a non-protected named branch"); const status = (await git(state.current, ["status", "--porcelain"])).out, generatedHooks = await generatedHooksOnly(state.current), onlyGeneratedHooks = Boolean(status) && status.split("\n").every(line => line.endsWith(" .githooks/") || line.endsWith(" .githooks")); if (status && !onlyGeneratedHooks) return bad("worktree is dirty; commit, stash, or discard changes before finish"); if ((await git(state.primary, ["merge-base", "--is-ancestor", state.branch, cfg.worktrees.primary_branch])).code) return bad(`branch ${state.branch} is not merged into ${cfg.worktrees.primary_branch} in the primary checkout`); const removed = await git(state.primary, ["worktree", "remove", ...(onlyGeneratedHooks && generatedHooks ? ["--force"] : []), state.current]); if (removed.code) return bad(removed.err || removed.out); if (deleteBranch) { const deleted = await git(state.primary, ["branch", "-d", state.branch]); if (deleted.code) return bad(`worktree removed, but branch deletion failed: ${deleted.err || deleted.out}`); } return { ok: true, message: `removed merged worktree ${state.current}${deleteBranch ? ` and branch ${state.branch}` : ""}` }; }
|