@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 +44 -11
- package/package.json +1 -1
- package/src/agent.ts +26 -5
- package/src/hooks.ts +10 -6
- package/templates/agents/AGENTS.block.md +5 -0
- /package/templates/{claude → agents}/atdd/SKILL.md +0 -0
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
|
|
141
|
-
|
|
142
|
-
|
|
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`
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
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
|
|
9
|
-
|
|
10
|
-
|
|
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:
|
|
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"]),
|
|
30
|
-
if (event === "commit-msg") { const deleted = (await files(root, ["diff", "--cached", "--name-only", "--diff-filter=D"])).length,
|
|
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
|