@afokapu/atdd-bun 0.10.59 → 0.10.60

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/README.md CHANGED
@@ -153,6 +153,10 @@ bun run atdd-bun --profile flow,traceability --ratchet --ratchet-activate-profil
153
153
 
154
154
  This is an activation-only exception to the ordinary equal-context rule, not a baseline migration. It requires an explicit candidate `profiles:` list, an explicit base list that is a strict subset of it, and identical non-profile policy/configuration. A removal, implicit/default-list adoption, unrelated configuration change, dirty tree, unavailable/non-ancestor base, or omission of `--ratchet-activate-profiles` fails closed. The command stores no activation state or accepted findings; after the governed expansion lands, use ordinary `--ratchet` with exact matching context. Strict non-ratchet enforcement remains unchanged.
155
155
 
156
+ ### Generated activation commit gate
157
+
158
+ A generated `pre-commit` hook recognizes only a staged `reject-new` strict profile expansion. It materializes the exact committed `HEAD` base and the staged index tree in an isolated worktree, runs both with the candidate's expanded profile set, and emits the same carried/new/resolved report. The commit proceeds only when `new` is empty. This is the supported bootstrap path for the activation commit; it never writes a baseline or weakens an ordinary hook. It fails closed unless the index has the explicit matching ratchet policy, the worktree has no unstaged or untracked state, the base profile list is a strict subset, and all non-profile policy is identical. All other commits retain ordinary strict hook enforcement.
159
+
156
160
  ## Docs-site theme
157
161
 
158
162
  A repository that publishes its AsciiDoc docs as a site does not need its own theme.
package/integrity.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.10.59",
2
+ "version": "0.10.60",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
6
- "README.md": "e3d4858a387637e74294f5e249ab2c377a57912363136c3e19dc3c1c3f3b3c4c",
6
+ "README.md": "ab10b690a87a3a654e7b16ad2528acdfc682e0b2eca31dddf1fae9dd69ef44c5",
7
7
  "bunfig.toml": "b9fc65eca9014c5179380259d70e76385a6a80788fa9a2df5fb3eaa5554fd2fe",
8
8
  "conventions/_profiles/architecture.yaml": "f1808768b720a4e476047e4a0912002ca673946562cea9aeef5d0c214828d93a",
9
9
  "conventions/_profiles/coder.yaml": "3fb191556a64c9ebcf249516afa189afca24da9b22fd40b2fc66908196cdfb78",
@@ -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": "233d26c0320adea3b315d808811da9b53b989db8effd4f6ed9306a80069101e8",
1264
+ "src/hooks.ts": "5992b94e26e93a9d3d8aece1336c5d36fce26682c86f4bf2775a3826ddf2670c",
1265
1265
  "src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
1266
1266
  "src/integrity.ts": "f5ad22759eec675454df82cdcf791b1f081951f08ca5084ebdcb8cd82aa1ecef",
1267
1267
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
@@ -1269,7 +1269,7 @@
1269
1269
  "src/planner-schema-validator.ts": "ce529c0936171075e93c7539b3655fe895a98dd2973ebda5775958e6ebc316d9",
1270
1270
  "src/planner-validators.ts": "99adeb65f579af923c11f21a8f55cfb9993b007fd8df59c5f764696b55379cfc",
1271
1271
  "src/profile-registry.ts": "5a7761d324181ae27deb51f198d63648a4c99e858b4de98b54d35e6cf8a39fe7",
1272
- "src/ratchet.ts": "16213f88fc09de7911defc185ac6ec45b9ecdfbebbe40a12fb6114fe75e7f896",
1272
+ "src/ratchet.ts": "801038b182615ed883197e0e29c98da9ac5f81bf4db4eb36b989065e32c99b6b",
1273
1273
  "src/register.ts": "6a2f1c6b8df0dc9c8f45df8ec9fac2be29044e048b08c8ffe423410e94dadedd",
1274
1274
  "src/release.ts": "7b0fe444ee81f476f53b9c21b6c347ef588ee616c60211419cf984132be768ba",
1275
1275
  "src/setup.ts": "38233265afedc63499bbef3b5403889651add335a70efd3813254ba53f4fc4fe",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.59",
3
+ "version": "0.10.60",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/hooks.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { chmod, mkdir, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
1
+ import { chmod, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { basename, dirname, join, relative, resolve } from "node:path";
4
- import { enabledProfiles, enforce } from "./enforce";
4
+ import { tmpdir } from "node:os";
5
+ import { concreteProfiles, enabledProfiles, enforce, normalizeProfileName } from "./enforce";
6
+ import { activationContextDigest, canonicalFindingPath, compareFindings, contextDigest, isStrictProfileExpansion, parseRatchetPolicy } from "./ratchet";
5
7
  import { topologyFor } from "./topology";
6
8
  import { JOURNEY_DOCS_DIR } from "./journey-docs";
7
9
 
@@ -11,7 +13,9 @@ export const defaultWorktreePolicy: WorktreePolicy = { enabled: false, root: "..
11
13
  export const defaultHookPolicy: HookPolicy = { max_staged_files: 20, max_staged_changed_lines: 350, max_commits_per_push: 10, max_registry_removed_lines: 350, registry_paths: ["plan/_*.yaml", "plan/_*.yml", "contracts/_*.yaml", "contracts/_*.yml"], protected_branches: ["main", "master"], require_plan_reference: true, require_traceability: true, worktrees: defaultWorktreePolicy };
12
14
  export const hookEvents = ["pre-commit", "commit-msg", "pre-push", "pre-merge-commit", "post-commit", "post-merge"] as const;
13
15
  export type HookEvent = typeof hookEvents[number];
14
- const git = async (root: string, args: string[], input?: string) => { const child = Bun.spawn({ cmd: ["git", ...args], cwd: root, stdin: input ? new Blob([input]) : undefined, stdout: "pipe", stderr: "pipe" }); return { code: await child.exited, out: (await new Response(child.stdout).text()).trim(), err: (await new Response(child.stderr).text()).trim() }; };
16
+ // Git exports these repository-local variables to hooks. A temporary worktree must discover its own Git directory instead.
17
+ const gitEnvironment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_QUARANTINE_PATH"].includes(key)));
18
+ const git = async (root: string, args: string[], input?: string, raw = false) => { const child = Bun.spawn({ cmd: ["git", ...args], cwd: root, env: gitEnvironment, stdin: input ? new Blob([input]) : undefined, stdout: "pipe", stderr: "pipe" }); const out = await new Response(child.stdout).text(), err = await new Response(child.stderr).text(); return { code: await child.exited, out: raw ? out : out.trim(), err: raw ? err : err.trim() }; };
15
19
  const bad = (message: string) => ({ ok: false, message });
16
20
  const files = async (root: string, args: string[]) => (await git(root, args)).out.split("\n").filter(Boolean);
17
21
  const strings = (value: unknown) => Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : undefined;
@@ -30,6 +34,50 @@ const stagedStats = async (root: string) => (await git(root, ["diff", "--cached"
30
34
  export async function policy(root: string): Promise<HookPolicy> { const file = join(root, "atdd-bun.yaml"); if (!existsSync(file)) return defaultHookPolicy; const data = Bun.YAML.parse(await readFile(file, "utf8")) 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])) } }; }
31
35
  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)}`); } }
32
36
 
37
+ const stable = (profiles: string[]) => [...new Set(profiles)].sort();
38
+ const configAt = async (root: string) => {
39
+ const file = join(root, "atdd-bun.yaml");
40
+ return existsSync(file) ? Bun.YAML.parse(await readFile(file, "utf8")) as Record<string, unknown> : {};
41
+ };
42
+
43
+ /** The sole bootstrap exception: a staged strict profile expansion is compared with its exact HEAD, never accepted. */
44
+ async function profileActivationCommitGate(root: string): Promise<{ ok: boolean; message: string } | null> {
45
+ const stagedConfig = await git(root, ["show", ":atdd-bun.yaml"]);
46
+ if (stagedConfig.code) return null;
47
+ let candidateConfig: Record<string, unknown>, policy;
48
+ try { candidateConfig = Bun.YAML.parse(stagedConfig.out) as Record<string, unknown>; policy = parseRatchetPolicy(candidateConfig); }
49
+ catch (error) { return bad(`ratchet activation commit gate configuration is invalid: ${String(error)}`); }
50
+ if (policy?.mode !== "reject-new" || !Array.isArray(candidateConfig.profiles) || !candidateConfig.profiles.every(profile => typeof profile === "string")) return null;
51
+ if ((await git(root, ["diff", "--quiet"])).code !== 0 || (await git(root, ["ls-files", "--others", "--exclude-standard"])).out) return bad("ratchet activation commit gate requires no unstaged or untracked candidate state");
52
+ const head = await git(root, ["rev-parse", "--verify", "HEAD^{commit}"]);
53
+ if (head.code || !/^[0-9a-f]{40}$/i.test(head.out)) return bad("ratchet activation commit gate requires an exact committed HEAD base");
54
+ const scratch = await mkdtemp(join(tmpdir(), "atdd-ratchet-commit-gate-"));
55
+ try {
56
+ const added = await git(root, ["worktree", "add", "--detach", "--no-checkout", scratch, head.out]);
57
+ if (added.code) return bad(`could not materialize ratchet activation base: ${added.err || added.out}`);
58
+ const checkedOut = await git(scratch, ["checkout", "--detach", head.out]);
59
+ if (checkedOut.code) return bad(`could not checkout ratchet activation base: ${checkedOut.err || checkedOut.out}`);
60
+ const baseConfig = await configAt(scratch), candidateProfiles = stable(candidateConfig.profiles.map(normalizeProfileName));
61
+ const baseProfiles = stable(await enabledProfiles(scratch));
62
+ if (candidateProfiles.some(profile => !concreteProfiles.includes(profile as typeof concreteProfiles[number])) || stable(policy.profiles.map(normalizeProfileName)).join(",") !== candidateProfiles.join(",") || !isStrictProfileExpansion(baseProfiles, candidateProfiles) || activationContextDigest(baseConfig) !== activationContextDigest(candidateConfig)) return bad("ratchet profile activation requires an explicit monotonic profile expansion and identical non-profile context");
63
+ const baseFindings = await enforce({ root: scratch, profiles: candidateProfiles as any });
64
+ const patch = await git(root, ["diff", "--cached", "--binary"], undefined, true);
65
+ if (patch.code) return bad(`could not read staged ratchet activation candidate: ${patch.err || patch.out}`);
66
+ const applied = await git(scratch, ["apply", "--whitespace=nowarn"], patch.out);
67
+ if (applied.code) return bad(`could not materialize staged ratchet activation candidate: ${applied.err || applied.out}`);
68
+ const materialized = await configAt(scratch);
69
+ if (activationContextDigest(materialized) !== activationContextDigest(candidateConfig)) return bad("staged ratchet activation candidate did not materialize exactly");
70
+ const candidateFindings = await enforce({ root: scratch, profiles: candidateProfiles as any });
71
+ const normalize = (findings: Awaited<ReturnType<typeof enforce>>) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(scratch, finding.file) }));
72
+ const delta = compareFindings(normalize(baseFindings), normalize(candidateFindings));
73
+ const tree = await git(root, ["write-tree"]);
74
+ if (tree.code || !/^[0-9a-f]{40}$/i.test(tree.out)) return bad(`could not identify staged ratchet activation candidate: ${tree.err || tree.out}`);
75
+ 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 });
76
+ return { ok: delta.new.length === 0, message: report };
77
+ } catch (error) { return bad(`ratchet activation commit gate failed closed: ${String(error)}`); }
78
+ finally { await git(root, ["worktree", "remove", "--force", scratch]); await rm(scratch, { recursive: true, force: true }); }
79
+ }
80
+
33
81
  const inside = (parent: string, child: string) => { const path = relative(parent, child); return path === "" || (path !== ".." && !path.startsWith("../") && !path.startsWith("..\\")); };
34
82
  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)); }
35
83
  export type WorktreeLayout = { primary: string; worktreeRoot: string; current: string; branch: string };
@@ -40,7 +88,7 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
40
88
  const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
41
89
  if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
42
90
  if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
43
- 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}`); return cfg.require_traceability || registries.length || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
91
+ 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 activation = await profileActivationCommitGate(root); if (activation) return activation; return cfg.require_traceability || registries.length || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
44
92
  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" }; }
45
93
  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); }
46
94
  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}` }; }
package/src/ratchet.ts CHANGED
@@ -62,7 +62,8 @@ export function parseRatchetPolicy(config: Record<string, unknown>): RatchetPoli
62
62
  if (config.ratchet === undefined) return null;
63
63
  const value = config.ratchet;
64
64
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("atdd-bun.yaml ratchet must be a mapping");
65
- const mode = (value as Record<string, unknown>).mode, profiles = (value as Record<string, unknown>).profiles;
65
+ const fields = value as Record<string, unknown>, mode = fields.mode, profiles = fields.profiles;
66
+ if (Object.keys(fields).some(key => key !== "mode" && key !== "profiles")) throw new Error("atdd-bun.yaml ratchet permits only mode and profiles; baselines and waivers are unsupported");
66
67
  if (mode !== "report" && mode !== "reject-new") throw new Error("atdd-bun.yaml ratchet.mode must be report or reject-new");
67
68
  if (!Array.isArray(profiles) || !profiles.length || !profiles.every(profile => typeof profile === "string")) throw new Error("atdd-bun.yaml ratchet.profiles must be a non-empty list of profile names");
68
69
  return { mode, profiles: [...new Set(profiles)].sort() };