@afokapu/atdd-bun 0.1.2 → 0.1.4

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
@@ -137,10 +137,9 @@ bun run atdd-bun init
137
137
  ```
138
138
 
139
139
  It creates `.githooks/` dispatchers, sets a worktree-local `core.hooksPath`,
140
- generates `.github/workflows/atdd-bun.yml`, and writes the coding-agent skill
141
- `.claude/skills/atdd/SKILL.md` when they are absent. It never overwrites another
142
- hook path, an existing generated workflow, or an existing skill unless you
143
- explicitly pass `--replace`. Installing the dependency alone deliberately does
140
+ generates `.github/workflows/atdd-bun.yml`, and installs the coding-agent skill
141
+ when they are absent. It never overwrites another hook path, an existing
142
+ generated workflow, or an existing skill unless you explicitly pass `--replace`. Installing the dependency alone deliberately does
144
143
  neither: package installation must not mutate a repository through postinstall.
145
144
 
146
145
  Use `hooks install`, `ci init`, or `agent init` when only one surface is wanted:
@@ -163,15 +162,49 @@ bun run atdd-bun hooks uninstall
163
162
  Hooks can be bypassed by Git and therefore are never the merge gate. The CI
164
163
  workflow and GitHub branch ruleset are the authority for merging.
165
164
 
165
+ ### Declarative registries
166
+
167
+ Micro-commit limits (`max_staged_files`, `max_staged_changed_lines`, default
168
+ 350) exist to keep imperative code changes small. They do not apply to
169
+ declarative registries, which can legitimately be hundreds or thousands of lines
170
+ and must not be split into invalid intermediate states. Registries are matched by
171
+ `registry_paths` (default `plan/_*.yaml`, `plan/_*.yml`, `contracts/_*.yaml`,
172
+ `contracts/_*.yml`).
173
+
174
+ A staged registry is exempt from the size caps only. It is still:
175
+
176
+ - validated by the `planner` and `traceability` profiles on every commit that
177
+ touches it, even when `require_traceability` is `false`;
178
+ - subject to removal approval: a net removal above `max_registry_removed_lines`
179
+ (default 350) needs `[mass-delete-approved]` in the commit message. Rewriting
180
+ or reordering entries in place is not a removal.
181
+
182
+ ```yaml
183
+ # atdd-bun.yaml
184
+ registry_paths: ["plan/_*.yaml", "contracts/_*.yaml", "telemetry/_*.yaml"]
185
+ max_registry_removed_lines: 200
186
+ ```
187
+
188
+ Duplicate, stale, and ownership checks come from the planner rules, and
189
+ independent review comes from the CI workflow and branch ruleset.
190
+
166
191
  ## Agent skill: the lifecycle in the agent's context
167
192
 
168
- `agent init` writes `.claude/skills/atdd/SKILL.md`, a short skill that coding
169
- agents load before changing code, tests, or `plan/`. It names the lifecycle
170
- PLAN → RED → GREEN → SMOKE → REFACTOR → TRACE, the conventions each stage
171
- follows, and the `atdd-bun` profile that gates it. It points at the conventions
172
- shipped in this package instead of restating them, so it stays correct as they
173
- change; after upgrading, refresh it with `bun run atdd-bun agent init --replace`.
174
- The skill steers the agent; the profiles, hooks, and CI remain the enforcement.
193
+ `agent init` gives every coding agent the same short ATDD skill:
194
+
195
+ - `.agents/skills/atdd/SKILL.md`: the vendor-neutral Agent Skills path (Codex,
196
+ GitHub Copilot, Cursor, Gemini CLI, and others);
197
+ - `.claude/skills/atdd/SKILL.md`: Claude Code;
198
+ - a managed `<!-- atdd-bun:start -->` block in `AGENTS.md` pointing at the skill,
199
+ for agents that read `AGENTS.md` but not skills. The rest of `AGENTS.md` is
200
+ never touched.
201
+
202
+ The skill names the lifecycle PLAN → RED → GREEN → SMOKE → REFACTOR → TRACE, the
203
+ conventions each stage follows, and the `atdd-bun` profile that gates it. It
204
+ points at the conventions shipped in this package instead of restating them, so
205
+ it stays correct as they change; after upgrading, refresh it with
206
+ `bun run atdd-bun agent init --replace`. The skill steers the agent; the
207
+ profiles, hooks, and CI remain the enforcement.
175
208
 
176
209
  ## CI: the merge gate
177
210
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/agent.ts CHANGED
@@ -1,10 +1,31 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
- import { join, resolve } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
 
5
5
  const root = resolve(import.meta.dir, "..");
6
- const template = join(root, "templates/claude/atdd/SKILL.md");
7
6
  const version = (await Bun.file(join(root, "package.json")).json() as { version: string }).version;
8
- const skillDir = (repo: string) => join(repo, ".claude/skills/atdd");
9
- export async function agentInit(repo = process.cwd(), replace = false) { const output = join(skillDir(repo), "SKILL.md"); if (existsSync(output) && !replace) return { ok: false, message: `${output} exists; use --replace` }; await mkdir(skillDir(repo), { recursive: true }); await writeFile(output, (await readFile(template, "utf8")).replace("{{VERSION}}", version)); return { ok: true, message: output }; }
10
- export async function agentStatus(repo = process.cwd()) { const output = join(skillDir(repo), "SKILL.md"); return { ok: existsSync(output), message: output }; }
7
+ const render = async (path: string) => (await readFile(join(root, "templates/agents", path), "utf8")).replace("{{VERSION}}", version);
8
+ // .agents/skills is the vendor-neutral Agent Skills path (Codex, Copilot, Cursor, Gemini CLI, …); .claude/skills is Claude Code's.
9
+ const skillPaths = [".agents/skills/atdd/SKILL.md", ".claude/skills/atdd/SKILL.md"];
10
+ const block = /<!-- atdd-bun:start[\s\S]*?<!-- atdd-bun:end -->\n?/;
11
+
12
+ /** Write the ATDD skill for every agent and a managed pointer block in AGENTS.md. Existing files and an
13
+ * existing block are kept unless `replace`; the rest of AGENTS.md is never touched. */
14
+ export async function agentInit(repo = process.cwd(), replace = false) {
15
+ const written: string[] = [], kept: string[] = [];
16
+ for (const path of skillPaths) {
17
+ const output = join(repo, path);
18
+ if (existsSync(output) && !replace) { kept.push(output); continue; }
19
+ await mkdir(dirname(output), { recursive: true }); await writeFile(output, await render("atdd/SKILL.md")); written.push(output);
20
+ }
21
+ const agents = join(repo, "AGENTS.md"), current = existsSync(agents) ? await readFile(agents, "utf8") : "", managed = await render("AGENTS.block.md");
22
+ if (block.test(current) && !replace) kept.push(agents);
23
+ else { await writeFile(agents, block.test(current) ? current.replace(block, managed) : current + (current && !current.endsWith("\n\n") ? (current.endsWith("\n") ? "\n" : "\n\n") : "") + managed); written.push(agents); }
24
+ if (!written.length) return { ok: false, message: `${kept.join(", ")} exist; use --replace` };
25
+ return { ok: true, message: written.join("\n") };
26
+ }
27
+ export async function agentStatus(repo = process.cwd()) {
28
+ const agents = join(repo, "AGENTS.md"), missing = skillPaths.map(path => join(repo, path)).filter(path => !existsSync(path));
29
+ if (!existsSync(agents) || !block.test(await readFile(agents, "utf8"))) missing.push(`${agents} (atdd-bun block)`);
30
+ return { ok: !missing.length, message: missing.length ? `missing: ${missing.join(", ")}` : [...skillPaths, "AGENTS.md"].map(path => join(repo, path)).join("\n") };
31
+ }
package/src/hooks.ts CHANGED
@@ -4,17 +4,21 @@ import { dirname, join, relative, resolve } from "node:path";
4
4
  import { enforce } from "./enforce";
5
5
 
6
6
  export type WorktreePolicy = { enabled: boolean; root: string; primary_directory: string; primary_branch: string; require_linked_worktree: boolean };
7
- export type HookPolicy = { max_staged_files: number; max_staged_changed_lines: number; max_uncommitted_files: number; max_commits_per_push: number; protected_branches: string[]; require_plan_reference: boolean; require_traceability: boolean; worktrees: WorktreePolicy };
7
+ export type HookPolicy = { max_staged_files: number; max_staged_changed_lines: number; max_uncommitted_files: number; max_commits_per_push: number; max_registry_removed_lines: number; registry_paths: string[]; protected_branches: string[]; require_plan_reference: boolean; require_traceability: boolean; worktrees: WorktreePolicy };
8
8
  export const defaultWorktreePolicy: WorktreePolicy = { enabled: false, root: "../worktrees", primary_directory: "main", primary_branch: "main", require_linked_worktree: true };
9
- export const defaultHookPolicy: HookPolicy = { max_staged_files: 20, max_staged_changed_lines: 350, max_uncommitted_files: 10, max_commits_per_push: 10, protected_branches: ["main", "master"], require_plan_reference: true, require_traceability: true, worktrees: defaultWorktreePolicy };
9
+ export const defaultHookPolicy: HookPolicy = { max_staged_files: 20, max_staged_changed_lines: 350, max_uncommitted_files: 10, 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 };
10
10
  export const hookEvents = ["pre-commit", "commit-msg", "pre-push", "pre-merge-commit", "post-commit", "post-merge"] as const;
11
11
  export type HookEvent = typeof hookEvents[number];
12
12
  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() }; };
13
13
  const bad = (message: string) => ({ ok: false, message });
14
14
  const files = async (root: string, args: string[]) => (await git(root, args)).out.split("\n").filter(Boolean);
15
+ const strings = (value: unknown) => Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : undefined;
16
+ // Declarative registries are exempt from the micro-commit size caps but never from validation or removal approval.
17
+ const isRegistry = (cfg: HookPolicy, path: string) => cfg.registry_paths.some(pattern => new Bun.Glob(pattern).match(path));
18
+ 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") }; });
15
19
 
16
- 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: Array.isArray(data?.protected_branches) ? data.protected_branches.filter((x): x is string => typeof x === "string") : defaultHookPolicy.protected_branches, worktrees: { ...defaultWorktreePolicy, ...Object.fromEntries(Object.entries(configuredWorktrees).filter(([key, value]) => key in defaultWorktreePolicy && typeof value === typeof (defaultWorktreePolicy as any)[key])) } }; }
17
- async function validation(root: string, changed: string[], full: boolean) { const profiles: any[] = []; if (changed.some(path => path.startsWith("plan/"))) profiles.push("planner", "traceability"); if (changed.some(path => /\.(?:[cm]?[jt]sx?|html)$/.test(path))) profiles.push("coder", "tester"); if (!profiles.length) return { ok: true, message: "no affected area" }; try { const findings = await enforce({ root, profiles: full ? ["all"] : [...new Set(profiles)] }); 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)}`); } }
20
+ 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])) } }; }
21
+ async function validation(root: string, changed: string[], full: boolean, registryChanged = false) { const profiles: any[] = []; if (registryChanged || changed.some(path => path.startsWith("plan/"))) profiles.push("planner", "traceability"); if (changed.some(path => /\.(?:[cm]?[jt]sx?|html)$/.test(path))) profiles.push("coder", "tester"); if (!profiles.length) return { ok: true, message: "no affected area" }; try { const findings = await enforce({ root, profiles: full ? ["all"] : [...new Set(profiles)] }); 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)}`); } }
18
22
 
19
23
  const inside = (parent: string, child: string) => { const path = relative(parent, child); return path === "" || (path !== ".." && !path.startsWith("../") && !path.startsWith("..\\")); };
20
24
  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)); }
@@ -26,8 +30,8 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
26
30
  const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
27
31
  if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
28
32
  if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
29
- if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), dirty = await files(root, ["status", "--porcelain"]), lines = (await git(root, ["diff", "--cached", "--numstat"])).out.split("\n").reduce((n, row) => n + (Number(row.split("\t")[0]) || 0) + (Number(row.split("\t")[1]) || 0), 0); if (staged.length > cfg.max_staged_files) return bad(`staged files ${staged.length} exceed ${cfg.max_staged_files}`); if (dirty.length > cfg.max_uncommitted_files) return bad(`uncommitted files ${dirty.length} exceed ${cfg.max_uncommitted_files}`); if (lines > cfg.max_staged_changed_lines) return bad(`staged changed lines ${lines} exceed ${cfg.max_staged_changed_lines}`); return cfg.require_traceability ? validation(root, staged, false) : { ok: true, message: "ok" }; }
30
- if (event === "commit-msg") { const deleted = (await files(root, ["diff", "--cached", "--name-only", "--diff-filter=D"])).length, lines = (await git(root, ["diff", "--cached", "--numstat"])).out.split("\n").reduce((n, row) => n + (Number(row.split("\t")[1]) || 0), 0), message = args[0] && existsSync(args[0]) ? await readFile(args[0], "utf8") : ""; return (deleted > 50 || lines > 10_000) && !message.includes("[mass-delete-approved]") ? bad("mass delete requires [mass-delete-approved]") : { ok: true, message: "ok" }; }
33
+ if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), dirty = await files(root, ["status", "--porcelain"]), registries = staged.filter(path => isRegistry(cfg, path)), counted = staged.length - registries.length, lines = (await stagedStats(root)).filter(row => !isRegistry(cfg, 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 (dirty.length > cfg.max_uncommitted_files) return bad(`uncommitted files ${dirty.length} exceed ${cfg.max_uncommitted_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 ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
34
+ 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" }; }
31
35
  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); }
32
36
  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}` }; }
33
37
  return { ok: true, message: "ok" };
@@ -0,0 +1,5 @@
1
+ <!-- atdd-bun:start — generated by @afokapu/atdd-bun {{VERSION}}; run `atdd-bun agent init --replace` to refresh -->
2
+ ## ATDD lifecycle
3
+
4
+ Before changing code, tests, or `plan/`, follow `.agents/skills/atdd/SKILL.md`: PLAN → RED → GREEN → SMOKE → REFACTOR → TRACE, passing each stage's `atdd-bun` gate before starting the next.
5
+ <!-- atdd-bun:end -->
File without changes