@afokapu/atdd-bun 0.10.88 → 0.10.90
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 +7 -7
- package/package.json +1 -1
- package/src/ci.ts +4 -3
- package/src/cli.ts +16 -1
- package/src/hooks.ts +54 -5
- package/src/integrity.ts +58 -27
- package/templates/agents/atdd-bun.integrity.test.ts +1 -0
- package/templates/github/atdd-bun.yml +1 -0
package/integrity.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.10.
|
|
2
|
+
"version": "0.10.90",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
|
|
@@ -1257,13 +1257,13 @@
|
|
|
1257
1257
|
"planner-schemas/wmbt.schema.json": "7a3a5693bfcf01340667c2be4a3c48e74f34b1af7e8dc13372c0de06b7f4fe12",
|
|
1258
1258
|
"relationships.yaml": "5860a308ebc3ee7fa8172fc2c6633984000ab3003f0ae4b6fa28db48879eda22",
|
|
1259
1259
|
"src/agent.ts": "6d6c6e75908af8fbab13432f0001b0374bb25cf3dee46ba218dfd67e0090cb56",
|
|
1260
|
-
"src/ci.ts": "
|
|
1261
|
-
"src/cli.ts": "
|
|
1260
|
+
"src/ci.ts": "b5c57dd87fc780b2b272156b68830d14e695a1a28300dec46a06f71864e0c59e",
|
|
1261
|
+
"src/cli.ts": "d93c3aebb78e752fd96347a5b177c0ed0697010c6e2533e116de93e772cd65a8",
|
|
1262
1262
|
"src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
|
|
1263
1263
|
"src/enforce.ts": "e0c10519aeecd7080f2900ea230fad2f09482af47c1cee885d5de71573e29732",
|
|
1264
|
-
"src/hooks.ts": "
|
|
1264
|
+
"src/hooks.ts": "ed1550a02ed9d61828e540ce8d7f1e01d0baea8dcebc74b4ea53d4a49532856b",
|
|
1265
1265
|
"src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
|
|
1266
|
-
"src/integrity.ts": "
|
|
1266
|
+
"src/integrity.ts": "19066185f99839c9e95c57f9bfbcd34df58cf8f4c33de86264c89e03b1085abf",
|
|
1267
1267
|
"src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
|
|
1268
1268
|
"src/planner-kernel.ts": "5d3f5305fb59becb2cc97d8a03aca6b104f385805d91cc0410856e289dd6fbb1",
|
|
1269
1269
|
"src/planner-schema-validator.ts": "ce529c0936171075e93c7539b3655fe895a98dd2973ebda5775958e6ebc316d9",
|
|
@@ -1276,9 +1276,9 @@
|
|
|
1276
1276
|
"src/telemetry-plan.ts": "cdb1e8bfff414f3ea81977388973da2cf6d89567d9e060dc9d5989a33600f153",
|
|
1277
1277
|
"src/topology.ts": "9779ee48e1c453abb76363163d4ab363463fa14e359571139f6d1f1fcc51ae2c",
|
|
1278
1278
|
"templates/agents/AGENTS.block.md": "84d19dfabedb1e05ccfb7b19a38644e87f193ae2b74755897f75f13971bb687e",
|
|
1279
|
-
"templates/agents/atdd-bun.integrity.test.ts": "
|
|
1279
|
+
"templates/agents/atdd-bun.integrity.test.ts": "76eb5f59c50d2fc3f4835c0adf85db4046597c25b1deb0432e6acb2ee1a617d8",
|
|
1280
1280
|
"templates/docs/site.css": "482b3427e3a3cb02a85dc85cfbeac80592dfd53abf42fc92d6ea7071f22afc95",
|
|
1281
1281
|
"templates/github/atdd-bun-release.yml": "d9547e9e6ef3ae010d53d55314f6a54567f88bd900f7e50d994cd308601d2684",
|
|
1282
|
-
"templates/github/atdd-bun.yml": "
|
|
1282
|
+
"templates/github/atdd-bun.yml": "6b766ae25ac86f3c87c91b31726461d0ba13d3383e55bbc9c9d1fe79ec26fcba"
|
|
1283
1283
|
}
|
|
1284
1284
|
}
|
package/package.json
CHANGED
package/src/ci.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
-
import { policy } from "./hooks";
|
|
4
|
+
import { enforcementIdentity, policy } from "./hooks";
|
|
5
5
|
|
|
6
6
|
const root = resolve(import.meta.dir, "..");
|
|
7
7
|
const template = join(root, "templates/github/atdd-bun.yml");
|
|
@@ -27,7 +27,8 @@ export async function renderWorkflow(repo = process.cwd()) {
|
|
|
27
27
|
const config = Bun.YAML.parse(await readFile(configPath, "utf8"));
|
|
28
28
|
rejectNew = Boolean(config && typeof config === "object" && !Array.isArray(config) && (config as { ratchet?: { mode?: unknown } }).ratchet?.mode === "reject-new");
|
|
29
29
|
} catch { /* policy(repo) below gives its existing parse diagnostic */ }
|
|
30
|
-
|
|
30
|
+
const identity = await enforcementIdentity(repo);
|
|
31
|
+
return (await readFile(template, "utf8")).replace("{{PLAYWRIGHT}}\n", await usesPlaywright(repo) ? playwrightStep : "").replace("{{VERSION}}", version).replace("{{IDENTITY}}", identity.digest).replace("{{PROTECTED_BRANCHES}}", (await policy(repo)).protected_branches.map(branch => JSON.stringify(branch)).join(", ")).replace("{{ENFORCEMENT}}", rejectNew ? 'bun run atdd-bun all --ratchet --base "$ATDD_BASE_REF" --ratchet-activate-profiles' : "bun run atdd-bun all");
|
|
31
32
|
}
|
|
32
33
|
const githubPackagesRegistry = /^\s*@[\w.-]+:registry\s*=\s*https:\/\/npm\.pkg\.github\.com\/?\s*$/im;
|
|
33
34
|
const githubPackagesAuth = /^\s*\/\/npm\.pkg\.github\.com\/:_authToken\s*=\s*(.*?)\s*$/gim;
|
|
@@ -61,4 +62,4 @@ export async function ciInit(repo = process.cwd(), replace = false) {
|
|
|
61
62
|
await writeFile(output, rendered);
|
|
62
63
|
return { ok: true, message: npmrc.content === undefined ? output : `${output}\n${npmrc.path}` };
|
|
63
64
|
}
|
|
64
|
-
export async function ciStatus(repo = process.cwd()) { const output = join(repo, ".github/workflows/atdd-bun.yml"); return { ok:
|
|
65
|
+
export async function ciStatus(repo = process.cwd()) { const output = join(repo, ".github/workflows/atdd-bun.yml"); if (!existsSync(output)) return { ok: false, message: `${output} is missing; run: bun run atdd-bun init --replace` }; try { return await readFile(output, "utf8") === await renderWorkflow(repo) ? { ok: true, message: output } : { ok: false, message: `${output} is stale; run: bun run atdd-bun init --replace` }; } catch (error) { return { ok: false, message: `${output} cannot be verified: ${String(error)}; run: bun run atdd-bun init --replace` }; } }
|
package/src/cli.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { join, resolve } from "node:path";
|
|
|
8
8
|
import { finishWorktree, hookEvents, hooksStatus, installHooks, runHook, startWorktree, uninstallHooks, worktreeStatus } from "./hooks";
|
|
9
9
|
import { ciInit, ciStatus } from "./ci";
|
|
10
10
|
import { agentInit, agentStatus } from "./agent";
|
|
11
|
-
import { checkIntegrity, formatIntegrity, integrityInit, integrityStatus } from "./integrity";
|
|
11
|
+
import { checkEnforcementCoherence, checkIntegrity, formatIntegrity, integrityInit, integrityStatus } from "./integrity";
|
|
12
12
|
import { journeyDocs } from "./journey-docs";
|
|
13
13
|
import { adrRegister } from "./docs-capability";
|
|
14
14
|
import { releaseCheck } from "./release";
|
|
@@ -54,6 +54,19 @@ function fail(message: string): never {
|
|
|
54
54
|
process.exit(1);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
async function governedRepository(root: string) {
|
|
58
|
+
const manifest = join(root, "package.json");
|
|
59
|
+
if (!existsSync(manifest)) return false;
|
|
60
|
+
const pkg = await Bun.file(manifest).json() as { name?: string; dependencies?: Record<string, unknown>; devDependencies?: Record<string, unknown> };
|
|
61
|
+
return pkg.name === "@afokapu/atdd-bun" || Object.hasOwn(pkg.dependencies ?? {}, "@afokapu/atdd-bun") || Object.hasOwn(pkg.devDependencies ?? {}, "@afokapu/atdd-bun");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function requireCoherence(root: string, requireHookBinding: boolean) {
|
|
65
|
+
if (!await governedRepository(root)) return;
|
|
66
|
+
const findings = await checkEnforcementCoherence({ root, requireHookBinding });
|
|
67
|
+
if (findings.length) { console.error(formatIntegrity(findings)); process.exit(1); }
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
if (args[0] === "help" || args[0] === "--help" || args[0] === "-h") {
|
|
58
71
|
printHelp();
|
|
59
72
|
process.exit(0);
|
|
@@ -68,6 +81,7 @@ if (args[0] === "init") {
|
|
|
68
81
|
}
|
|
69
82
|
if (args[0] === "hook") {
|
|
70
83
|
if (!hookEvents.includes(args[1] as typeof hookEvents[number])) fail("unknown hook event: " + (args[1] ?? "(missing)"));
|
|
84
|
+
await requireCoherence(process.cwd(), true);
|
|
71
85
|
const result = await runHook(args[1] as typeof hookEvents[number], process.cwd(), args.slice(2), await new Response(Bun.stdin.stream()).text());
|
|
72
86
|
if (result.message) console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
|
|
73
87
|
}
|
|
@@ -142,6 +156,7 @@ const git = async (directory: string, gitArgs: string[]) => {
|
|
|
142
156
|
const stable = (items: string[]) => [...new Set(items)].sort();
|
|
143
157
|
const profileActivation = args.includes("--ratchet-activate-profiles");
|
|
144
158
|
if (profileActivation && !args.includes("--ratchet")) fail("--ratchet-activate-profiles requires --ratchet");
|
|
159
|
+
await requireCoherence(root, process.env.GITHUB_ACTIONS !== "true");
|
|
145
160
|
if (!args.includes("--ratchet")) {
|
|
146
161
|
const violations = await enforce({ root, profiles: normalizedRequested as Profile[] });
|
|
147
162
|
printViolations(violations);
|
package/src/hooks.ts
CHANGED
|
@@ -192,6 +192,40 @@ async function existingBranchProtectedBase(root: string, candidate: string, remo
|
|
|
192
192
|
/** Ratchet findings depend only on the scanned tree, the toolkit scanning it and the selected profiles, so they are cached
|
|
193
193
|
* under the Git common directory. An entry is advisory: unreadable, malformed or differently keyed means recompute, never clean. */
|
|
194
194
|
const packageRoot = resolve(import.meta.dir, ".."), toolkitInputs = ["package.json", "relationships.yaml", "src", "lib", "detectors", "conventions", "planner-nodes", "planner-schemas"];
|
|
195
|
+
const PACKAGE = "@afokapu/atdd-bun";
|
|
196
|
+
export type LockedToolkit = { version: string; integrity: string };
|
|
197
|
+
export type EnforcementIdentity = LockedToolkit & { installedVersion: string; profiles: string[]; digest: string; sourceCheckout: boolean };
|
|
198
|
+
|
|
199
|
+
/** The exact registry tuple approved by this repository's Bun lockfile. No registry or network lookup is permitted. */
|
|
200
|
+
export async function lockedToolkit(root: string): Promise<LockedToolkit | null> {
|
|
201
|
+
const lock = join(root, "bun.lock");
|
|
202
|
+
if (!existsSync(lock)) return null;
|
|
203
|
+
const row = (await readFile(lock, "utf8")).split("\n").find(line => line.trimStart().startsWith(`"${PACKAGE}": [`));
|
|
204
|
+
if (!row) return null;
|
|
205
|
+
const version = row.match(/"@afokapu\/atdd-bun@(\d+\.\d+\.\d+(?:-[\w.]+)?)",\s*""/)?.[1];
|
|
206
|
+
const integrity = row.match(/"(sha512-[^"]+)"\s*\],?\s*$/)?.[1];
|
|
207
|
+
return version && integrity ? { version, integrity } : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Only the running toolkit's actual repository root receives the source-checkout exception. */
|
|
211
|
+
export async function isToolkitSourceCheckout(root = process.cwd()) {
|
|
212
|
+
return realpath(resolve(root)).then(repositoryRoot => realpath(packageRoot).then(runningRoot => repositoryRoot === runningRoot)).catch(() => false);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** One identity binds the approved lock tuple, the package that will execute, and the exact activated config/profile set. */
|
|
216
|
+
export async function enforcementIdentity(root = process.cwd()): Promise<EnforcementIdentity> {
|
|
217
|
+
const sourceCheckout = await isToolkitSourceCheckout(root);
|
|
218
|
+
const installedRoot = sourceCheckout ? packageRoot : join(root, "node_modules", PACKAGE);
|
|
219
|
+
const installedManifest = join(installedRoot, "package.json");
|
|
220
|
+
const providerVersion = (await Bun.file(join(packageRoot, "package.json")).json() as { version: string }).version;
|
|
221
|
+
const installedVersion = existsSync(installedManifest) ? (await Bun.file(installedManifest).json() as { version: string }).version : providerVersion;
|
|
222
|
+
const locked = await lockedToolkit(root) ?? { version: installedVersion, integrity: sourceCheckout ? "source" : "unresolved" };
|
|
223
|
+
const configPath = join(root, "atdd-bun.yaml"), config = existsSync(configPath) ? (await readFile(configPath, "utf8")).replace(/\r\n/g, "\n") : "";
|
|
224
|
+
const profiles = stable(await enabledProfiles(root));
|
|
225
|
+
const digest = new Bun.CryptoHasher("sha256").update(JSON.stringify({ schema: "atdd-bun.enforcement-identity/v1", ...locked, installedVersion, config, profiles })).digest("hex");
|
|
226
|
+
return { ...locked, installedVersion, profiles, digest, sourceCheckout };
|
|
227
|
+
}
|
|
228
|
+
|
|
195
229
|
export type ToolkitIdentity = { version: string; digest: string };
|
|
196
230
|
async function toolkitFiles() {
|
|
197
231
|
const found = new Set<string>();
|
|
@@ -318,17 +352,32 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
|
|
|
318
352
|
return { ok: true, message: "ok" };
|
|
319
353
|
}
|
|
320
354
|
|
|
321
|
-
// Resolve from the checkout at hook time: installed consumers use their local package;
|
|
322
|
-
const dispatcher = (event: HookEvent
|
|
323
|
-
|
|
355
|
+
// Resolve from the checkout at hook time: installed consumers use only their worktree-local package; this package uses its own source checkout.
|
|
356
|
+
const dispatcher = (event: HookEvent, identity: EnforcementIdentity) => {
|
|
357
|
+
const sourceFallback = identity.sourceCheckout ? `elif [ -f "$root/src/cli.ts" ]; then\n entry="$root/src/cli.ts"\n` : "";
|
|
358
|
+
return `#!/bin/sh\n# atdd-bun enforcement identity: ${identity.digest}\nroot="$(git rev-parse --show-toplevel)" || exit $?\nentry="$root/node_modules/@afokapu/atdd-bun/src/cli.ts"\nmanifest="$root/node_modules/@afokapu/atdd-bun/package.json"\nif [ -f "$entry" ] && [ -f "$manifest" ]; then\n installed="$(grep -m 1 -E '\"version\"[[:space:]]*:' "$manifest" | sed -E 's/.*\"version\"[[:space:]]*:[[:space:]]*\"([^\"]+)\".*/\\1/')"\n if [ "$installed" != "${identity.version}" ]; then\n echo "atdd-bun hook: installed package $installed does not match exact bun.lock resolution ${identity.version}; run: bun install --frozen-lockfile && bun run atdd-bun init --replace" >&2\n exit 1\n fi\n${sourceFallback}else\n echo "atdd-bun hook: worktree-local @afokapu/atdd-bun is missing; run: bun install --frozen-lockfile && bun run atdd-bun init --replace" >&2\n exit 1\nfi\nexec bun "$entry" hook ${event} "$@"\n`;
|
|
359
|
+
};
|
|
360
|
+
export async function renderHook(event: HookEvent, root = process.cwd()) { return dispatcher(event, await enforcementIdentity(root)); }
|
|
361
|
+
export async function installHooks(root = process.cwd(), replace = false) { const current = (await git(root, ["config", "--worktree", "--get", "core.hooksPath"])).out || (await git(root, ["config", "--get", "core.hooksPath"])).out; if (current && current !== ".githooks" && !replace) return bad(`core.hooksPath is ${current}; use --replace`); const identity = await enforcementIdentity(root); await git(root, ["config", "extensions.worktreeConfig", "true"]); await mkdir(join(root, ".githooks"), { recursive: true }); for (const event of hookEvents) { const file = join(root, ".githooks", event); await writeFile(file, dispatcher(event, identity)); await chmod(file, 0o755); } const result = await git(root, ["config", "--worktree", "core.hooksPath", ".githooks"]); return result.code ? bad(result.err) : { ok: true, message: "hooks installed" }; }
|
|
324
362
|
export async function uninstallHooks(root = process.cwd()) { if ((await git(root, ["config", "--worktree", "--get", "core.hooksPath"])).out === ".githooks") await git(root, ["config", "--worktree", "--unset", "core.hooksPath"]); for (const event of hookEvents) await rm(join(root, ".githooks", event), { force: true }); return { ok: true, message: "hooks uninstalled" }; }
|
|
325
|
-
export async function hooksStatus(root = process.cwd()) {
|
|
363
|
+
export async function hooksStatus(root = process.cwd()) {
|
|
364
|
+
const path = (await git(root, ["config", "--worktree", "--get", "core.hooksPath"])).out;
|
|
365
|
+
if (path !== ".githooks") return bad(`core.hooksPath is ${path || "not set"}; run: bun run atdd-bun init --replace`);
|
|
366
|
+
try {
|
|
367
|
+
for (const event of hookEvents) {
|
|
368
|
+
const file = join(root, ".githooks", event);
|
|
369
|
+
if (!existsSync(file)) return bad(`${relative(root, file)} is missing; run: bun run atdd-bun init --replace`);
|
|
370
|
+
if (await readFile(file, "utf8") !== await renderHook(event, root)) return bad(`${relative(root, file)} is stale; run: bun run atdd-bun init --replace`);
|
|
371
|
+
}
|
|
372
|
+
} catch (error) { return bad(`generated hooks cannot be verified: ${String(error)}; run: bun run atdd-bun init --replace`); }
|
|
373
|
+
return { ok: true, message: path };
|
|
374
|
+
}
|
|
326
375
|
|
|
327
376
|
const branchPath = (branch: string) => branch.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
328
377
|
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}` }; }
|
|
329
378
|
|
|
330
379
|
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 }; }
|
|
331
380
|
|
|
332
|
-
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")) ===
|
|
381
|
+
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")) === await renderHook(event, root)))).every(Boolean); }
|
|
333
382
|
|
|
334
383
|
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"); const tip = (await git(state.primary, ["rev-parse", "--verify", `refs/heads/${state.branch}`])).out.trim(); if (!tip || (await git(state.primary, ["merge-base", "--is-ancestor", tip, 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 now = (await git(state.primary, ["rev-parse", "--verify", `refs/heads/${state.branch}`])).out.trim(); if (now !== tip || (await git(state.primary, ["merge-base", "--is-ancestor", tip, cfg.worktrees.primary_branch])).code) return bad(`worktree removed, but branch ${state.branch} was not deleted: its tip changed or is no longer merged into ${cfg.worktrees.primary_branch}`); 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}` : ""}` }; }
|
package/src/integrity.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { instructionPaths } from "./agent";
|
|
5
5
|
import { renderWorkflow } from "./ci";
|
|
6
|
-
import { defaultHookPolicy, type HookPolicy } from "./hooks";
|
|
6
|
+
import { defaultHookPolicy, enforcementIdentity, hookEvents, hooksStatus, isToolkitSourceCheckout, lockedToolkit, renderHook, type HookPolicy } from "./hooks";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Integrity: the files an agent could change to weaken enforcement must stay canonical.
|
|
@@ -20,8 +20,9 @@ export const MANIFEST = "integrity.json";
|
|
|
20
20
|
export const TEST_FILE = "atdd-bun.integrity.test.ts";
|
|
21
21
|
const WORKFLOW = ".github/workflows/atdd-bun.yml";
|
|
22
22
|
const BLOCK = /<!-- atdd-bun:start[\s\S]*?<!-- atdd-bun:end -->/;
|
|
23
|
-
//
|
|
23
|
+
// Agent blocks are guidance rather than enforcement surfaces, so their writer version remains upgrade-tolerant.
|
|
24
24
|
const unstamp = (text: string) => text.replace(/@afokapu\/atdd-bun (?:\d+\.\d+\.\d+(?:-[\w.]+)?|\{\{VERSION\}\})/g, "@afokapu/atdd-bun <version>").replace(/\r\n/g, "\n").trimEnd();
|
|
25
|
+
const canonical = (text: string) => text.replace(/\r\n/g, "\n").trimEnd();
|
|
25
26
|
const sha256 = (data: string | Uint8Array) => new Bun.CryptoHasher("sha256").update(data).digest("hex");
|
|
26
27
|
/** Where `bun test` will find the generated test: the repository's `[test] root` from bunfig.toml, else the repo root. */
|
|
27
28
|
export async function testFilePath(repo: string) {
|
|
@@ -69,41 +70,58 @@ export async function checkInstalledPackage(packageRoot = ownRoot): Promise<Inte
|
|
|
69
70
|
return findings;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
/** The repository installs the package
|
|
73
|
+
/** The repository installs the package version approved by its exact npm-registry lock tuple. */
|
|
73
74
|
async function checkDependency(root: string): Promise<IntegrityFinding[]> {
|
|
74
75
|
if (!existsSync(join(root, "package.json"))) return [];
|
|
75
76
|
const pkg = await Bun.file(join(root, "package.json")).json() as { name?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
76
|
-
if (
|
|
77
|
+
if (await isToolkitSourceCheckout(root)) return [];
|
|
77
78
|
const spec = pkg.devDependencies?.[PACKAGE] ?? pkg.dependencies?.[PACKAGE];
|
|
78
|
-
if (spec === undefined) return [{ file: "package.json", detail: `${PACKAGE} is not a dependency`, restore: `bun add -d ${PACKAGE}` }];
|
|
79
|
+
if (spec === undefined) return [{ file: "package.json", detail: `${PACKAGE} is not a dependency`, restore: `bun add -d ${PACKAGE}@<operator-approved-version> --exact` }];
|
|
79
80
|
const findings: IntegrityFinding[] = [];
|
|
80
|
-
if (!/^[~^]?\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(spec)) findings.push({ file: "package.json", detail: `${PACKAGE} is "${spec}"; it must be an npm version range, not a git, file, or link source`, restore: `bun add -d ${PACKAGE}` });
|
|
81
|
-
const lock = existsSync(join(root, "bun.lock")) ? await readFile(join(root, "bun.lock"), "utf8") : "";
|
|
81
|
+
if (!/^[~^]?\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(spec)) findings.push({ file: "package.json", detail: `${PACKAGE} is "${spec}"; it must be an npm version range, not a git, file, or link source`, restore: `bun add -d ${PACKAGE}@<operator-approved-version> --exact` });
|
|
82
|
+
const locked = await lockedToolkit(root), lock = existsSync(join(root, "bun.lock")) ? await readFile(join(root, "bun.lock"), "utf8") : "";
|
|
82
83
|
const entry = lock.split("\n").find(line => line.trimStart().startsWith(`"${PACKAGE}": [`));
|
|
83
|
-
if (!entry) findings.push({ file: "bun.lock", detail: `bun.lock does not pin ${PACKAGE}`, restore:
|
|
84
|
-
else if (
|
|
84
|
+
if (!entry) findings.push({ file: "bun.lock", detail: `bun.lock does not pin ${PACKAGE}; select the exact operator-approved version to create the reviewed lock resolution`, restore: `bun add -d ${PACKAGE}@<operator-approved-version> --exact` });
|
|
85
|
+
else if (!locked) findings.push({ file: "bun.lock", detail: `bun.lock does not resolve ${PACKAGE} from the npm registry with an integrity hash; select the exact operator-approved version to repair the reviewed lock resolution`, restore: `bun add -d ${PACKAGE}@<operator-approved-version> --exact` });
|
|
86
|
+
const installedManifest = join(root, "node_modules", PACKAGE, "package.json");
|
|
87
|
+
if (!existsSync(installedManifest)) findings.push({ file: `node_modules/${PACKAGE}/package.json`, detail: "the worktree-local installed package is missing", restore: "bun install --frozen-lockfile" });
|
|
88
|
+
else if (locked) {
|
|
89
|
+
const installedVersion = (await Bun.file(installedManifest).json() as { version?: unknown }).version;
|
|
90
|
+
if (installedVersion !== locked.version) findings.push({ file: `node_modules/${PACKAGE}/package.json`, detail: `installed version ${String(installedVersion)} does not match exact bun.lock resolution ${locked.version}`, restore: "bun install --frozen-lockfile" });
|
|
91
|
+
}
|
|
85
92
|
return findings;
|
|
86
93
|
}
|
|
87
94
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
async function renderIntegrityTest(root: string, packageRoot: string) {
|
|
96
|
+
const version = (await Bun.file(join(packageRoot, "package.json")).json() as { version: string }).version;
|
|
97
|
+
const { digest } = await enforcementIdentity(root);
|
|
98
|
+
return (await readFile(join(packageRoot, "templates/agents/atdd-bun.integrity.test.ts"), "utf8")).replace("{{VERSION}}", version).replace("{{IDENTITY}}", digest);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The generated enforcement surfaces are byte-identical to this exact lock/config/profile identity. */
|
|
102
|
+
async function checkEnforcementGenerated(root: string, packageRoot: string, compare = true): Promise<IntegrityFinding[]> {
|
|
103
|
+
const findings: IntegrityFinding[] = [], restore = "bun run atdd-bun init --replace";
|
|
104
|
+
for (const event of hookEvents) {
|
|
105
|
+
const file = `.githooks/${event}`, path = join(root, file);
|
|
106
|
+
if (!existsSync(path)) findings.push({ file, detail: "is missing", restore });
|
|
107
|
+
else if (compare && canonical(await readFile(path, "utf8")) !== canonical(await renderHook(event, root))) findings.push({ file, detail: "is stale; it must match the exact lock/config/profile identity", restore });
|
|
108
|
+
}
|
|
97
109
|
const workflow = join(root, WORKFLOW);
|
|
98
|
-
if (
|
|
99
|
-
else if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
if (!existsSync(workflow)) findings.push({ file: WORKFLOW, detail: "is missing", restore });
|
|
111
|
+
else if (compare && canonical(await readFile(workflow, "utf8")) !== canonical(await renderWorkflow(root))) findings.push({ file: WORKFLOW, detail: "is stale; it must match the exact lock/config/profile identity", restore });
|
|
112
|
+
const test = relative(root, await testFilePath(root)), testPath = join(root, test);
|
|
113
|
+
if (!existsSync(testPath)) findings.push({ file: test, detail: "is missing", restore });
|
|
114
|
+
else if (compare && canonical(await readFile(testPath, "utf8")) !== canonical(await renderIntegrityTest(root, packageRoot))) findings.push({ file: test, detail: "is stale; it must match the exact lock/config/profile identity", restore });
|
|
115
|
+
return findings;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function checkGenerated(root: string, packageRoot: string, skipComparisons = false): Promise<IntegrityFinding[]> {
|
|
119
|
+
const findings = await checkEnforcementGenerated(root, packageRoot, !skipComparisons);
|
|
120
|
+
const agentCanonical = (await readFile(join(packageRoot, "templates/agents/AGENTS.block.md"), "utf8")).match(BLOCK)![0];
|
|
103
121
|
for (const file of instructionPaths) {
|
|
104
122
|
const agents = existsSync(join(root, file)) ? await readFile(join(root, file), "utf8") : "", block = agents.match(BLOCK)?.[0];
|
|
105
123
|
if (!block) findings.push({ file, detail: "is missing the atdd-bun block", restore: "bun run atdd-bun agent init --replace" });
|
|
106
|
-
else if (unstamp(block) !== unstamp(
|
|
124
|
+
else if (unstamp(block) !== unstamp(agentCanonical)) findings.push({ file, detail: "atdd-bun block was edited", restore: "bun run atdd-bun agent init --replace" });
|
|
107
125
|
}
|
|
108
126
|
return findings;
|
|
109
127
|
}
|
|
@@ -216,6 +234,20 @@ async function checkPolicy(root: string, base?: string, push = process.env.GITHU
|
|
|
216
234
|
return loosened.length ? [{ file: "atdd-bun.yaml", detail: `loosens the policy of ${against.slice(0, 7)}: ${loosened.join("; ")}`, restore: `git checkout ${against.slice(0, 7)} -- atdd-bun.yaml` }] : [];
|
|
217
235
|
}
|
|
218
236
|
|
|
237
|
+
export async function checkEnforcementCoherence(options: IntegrityOptions & { requireHookBinding?: boolean } = {}): Promise<IntegrityFinding[]> {
|
|
238
|
+
const root = resolve(options.root ?? process.cwd()), packageRoot = options.packageRoot ?? ownRoot;
|
|
239
|
+
try {
|
|
240
|
+
const findings = [...await checkInstalledPackage(packageRoot), ...await checkDependency(root), ...await checkEnforcementGenerated(root, packageRoot)];
|
|
241
|
+
if (options.requireHookBinding) {
|
|
242
|
+
const status = await hooksStatus(root);
|
|
243
|
+
if (!status.ok) findings.push({ file: ".githooks", detail: status.message, restore: "bun run atdd-bun init --replace" });
|
|
244
|
+
}
|
|
245
|
+
return findings;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return [{ file: "atdd-bun.yaml", detail: `the enforcement identity cannot be resolved: ${String(error)}`, restore: "correct atdd-bun.yaml and run: bun run atdd-bun init --replace" }];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
219
251
|
export async function checkIntegrity(options: IntegrityOptions = {}): Promise<IntegrityFinding[]> {
|
|
220
252
|
const root = resolve(options.root ?? process.cwd()), packageRoot = options.packageRoot ?? ownRoot;
|
|
221
253
|
// An unreadable atdd-bun.yaml is a finding, not a crash: the policy and the workflow rendered from it cannot be
|
|
@@ -250,9 +282,8 @@ export function formatIntegrity(findings: IntegrityFinding[]): string {
|
|
|
250
282
|
export async function integrityInit(repo = process.cwd(), replace = false) {
|
|
251
283
|
const output = await testFilePath(repo);
|
|
252
284
|
if (existsSync(output) && !replace) return { ok: false, message: `${output} exists; use --replace` };
|
|
253
|
-
const version = (await Bun.file(join(ownRoot, "package.json")).json() as { version: string }).version;
|
|
254
285
|
await mkdir(dirname(output), { recursive: true });
|
|
255
|
-
await writeFile(output,
|
|
286
|
+
await writeFile(output, await renderIntegrityTest(repo, ownRoot));
|
|
256
287
|
return { ok: true, message: output };
|
|
257
288
|
}
|
|
258
|
-
export async function integrityStatus(repo = process.cwd()) { const output = await testFilePath(repo); return { ok:
|
|
289
|
+
export async function integrityStatus(repo = process.cwd()) { const output = await testFilePath(repo); if (!existsSync(output)) return { ok: false, message: `${output} is missing; run: bun run atdd-bun init --replace` }; try { return canonical(await readFile(output, "utf8")) === canonical(await renderIntegrityTest(repo, ownRoot)) ? { ok: true, message: output } : { ok: false, message: `${output} is stale; run: bun run atdd-bun init --replace` }; } catch (error) { return { ok: false, message: `${output} cannot be verified: ${String(error)}; run: bun run atdd-bun init --replace` }; } }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Generated by @afokapu/atdd-bun {{VERSION}}. Do not edit; run `atdd-bun integrity init --replace`.
|
|
2
|
+
// atdd-bun enforcement identity: {{IDENTITY}}
|
|
2
3
|
// Fails loudly when the installed package, its generated files, or atdd-bun.yaml were changed.
|
|
3
4
|
import { registerIntegrityTest } from "@afokapu/atdd-bun/register";
|
|
4
5
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# Generated by @afokapu/atdd-bun {{VERSION}}. Do not edit; run `atdd-bun ci init --replace`.
|
|
2
|
+
# atdd-bun enforcement identity: {{IDENTITY}}
|
|
2
3
|
name: ATdd-Bun
|
|
3
4
|
# The generated gate only needs checkout and package-download access. All other
|
|
4
5
|
# token capabilities are explicitly disabled.
|