@afokapu/atdd-bun 0.2.0 → 0.3.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
@@ -23,7 +23,8 @@
23
23
  "bunfig.toml",
24
24
  "PLANNER_PORT.md",
25
25
  "HOOK_AUDIT.md",
26
- "templates"
26
+ "templates",
27
+ "integrity.json"
27
28
  ],
28
29
  "scripts": {
29
30
  "test": "bun test",
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ import { enforce, profileNames, type Profile } from "./enforce";
3
3
  import { finishWorktree, hookEvents, hooksStatus, installHooks, runHook, startWorktree, uninstallHooks, worktreeStatus } from "./hooks";
4
4
  import { ciInit, ciStatus } from "./ci";
5
5
  import { agentInit, agentStatus } from "./agent";
6
+ import { checkIntegrity, formatIntegrity, integrityInit, integrityStatus } from "./integrity";
6
7
  import { releaseCheck } from "./release";
7
8
  import { initializeRepository } from "./setup";
8
9
 
@@ -16,6 +17,7 @@ const usage = {
16
17
  "atdd-bun worktree <start|finish|status>",
17
18
  "atdd-bun ci <init|status> [--replace]",
18
19
  "atdd-bun agent <init|status> [--replace]",
20
+ "atdd-bun integrity [init|status] [--replace]",
19
21
  "atdd-bun release check",
20
22
  ],
21
23
  profiles: profileNames,
@@ -66,6 +68,13 @@ if (args[0] === "ci") {
66
68
  const result = args[1] === "init" ? await ciInit(process.cwd(), args.includes("--replace")) : args[1] === "status" ? await ciStatus() : fail("ci requires init or status");
67
69
  console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
68
70
  }
71
+ if (args[0] === "integrity") {
72
+ if (args[1] === "init" || args[1] === "status") { const result = args[1] === "init" ? await integrityInit(process.cwd(), args.includes("--replace")) : await integrityStatus(); console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1); }
73
+ if (args[1] !== undefined) fail("integrity takes no argument, or init/status");
74
+ const findings = await checkIntegrity();
75
+ if (findings.length) { console.error(formatIntegrity(findings)); process.exit(1); }
76
+ console.log("atdd-bun integrity: canonical"); process.exit(0);
77
+ }
69
78
  if (args[0] === "agent") {
70
79
  const result = args[1] === "init" ? await agentInit(process.cwd(), args.includes("--replace")) : args[1] === "status" ? await agentStatus() : fail("agent requires init or status");
71
80
  console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
package/src/index.ts CHANGED
@@ -10,4 +10,6 @@ export { defaultHookPolicy, hookEvents, hooksStatus, installHooks, runHook, unin
10
10
  export type { HookEvent, HookPolicy } from "./hooks";
11
11
  export { ciInit, ciStatus } from "./ci";
12
12
  export { initializeRepository } from "./setup";
13
+ export { checkIntegrity, checkInstalledPackage, formatIntegrity, integrityInit, loosenedPolicy, writeManifest } from "./integrity";
14
+ export type { IntegrityFinding, IntegrityOptions } from "./integrity";
13
15
  export { releaseCheck } from "./release";
@@ -0,0 +1,165 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
+ import { defaultHookPolicy, type HookPolicy } from "./hooks";
5
+
6
+ /**
7
+ * Integrity: the files an agent could change to weaken enforcement must stay canonical.
8
+ * The same check runs as a local test (a loud reminder the agent sees) and in CI on a
9
+ * clean install (the verdict the agent cannot fake).
10
+ */
11
+ export type IntegrityFinding = { file: string; detail: string; restore: string };
12
+ /** `push`: judge the newest commit against its parent (a push to the base branch). Defaults to GITHUB_EVENT_NAME === "push". */
13
+ export type IntegrityOptions = { root?: string; packageRoot?: string; base?: string; push?: boolean };
14
+
15
+ const PACKAGE = "@afokapu/atdd-bun";
16
+ const ownRoot = resolve(import.meta.dir, "..");
17
+ export const MANIFEST = "integrity.json";
18
+ export const TEST_FILE = "atdd-bun.integrity.test.ts";
19
+ const SKILLS = [".agents/skills/atdd/SKILL.md", ".claude/skills/atdd/SKILL.md"];
20
+ const WORKFLOW = ".github/workflows/atdd-bun.yml";
21
+ const BLOCK = /<!-- atdd-bun:start[\s\S]*?<!-- atdd-bun:end -->/;
22
+ // Generated files carry the version that wrote them; an upgrade must not read as tampering.
23
+ 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();
24
+ const sha256 = (data: string | Uint8Array) => new Bun.CryptoHasher("sha256").update(data).digest("hex");
25
+ /** Where `bun test` will find the generated test: the repository's `[test] root` from bunfig.toml, else the repo root. */
26
+ export async function testFilePath(repo: string) {
27
+ const bunfig = join(repo, "bunfig.toml");
28
+ const text = existsSync(bunfig) ? await readFile(bunfig, "utf8") : "";
29
+ const root = text.split(/^\[/m).find(section => section.startsWith("test]"))?.match(/^\s*root\s*=\s*["']([^"']+)["']/m)?.[1];
30
+ return join(repo, root ?? ".", TEST_FILE);
31
+ }
32
+ const git = async (root: string, args: string[]) => { const child = Bun.spawn({ cmd: ["git", ...args], cwd: root, stdout: "pipe", stderr: "pipe" }); const out = (await new Response(child.stdout).text()).trim(); return { code: await child.exited, out }; };
33
+
34
+ async function filesBelow(dir: string, base = dir): Promise<string[]> {
35
+ const out: string[] = [];
36
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
37
+ const path = join(dir, entry.name);
38
+ if (entry.isDirectory()) { if (entry.name !== "node_modules") out.push(...await filesBelow(path, base)); }
39
+ else out.push(relative(base, path).split(sep).join("/"));
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** Hash every shipped file except the manifest itself and package.json, which npm normalizes on publish. */
45
+ export async function writeManifest(packageRoot = ownRoot, files?: string[]) {
46
+ const pkg = await Bun.file(join(packageRoot, "package.json")).json() as { version: string };
47
+ const list = (files ?? await filesBelow(packageRoot)).filter(file => file !== MANIFEST && file !== "package.json").sort();
48
+ const hashes = Object.fromEntries(await Promise.all(list.map(async file => [file, sha256(await Bun.file(join(packageRoot, file)).bytes())])));
49
+ await writeFile(join(packageRoot, MANIFEST), JSON.stringify({ version: pkg.version, files: hashes }, null, 2) + "\n");
50
+ return list.length;
51
+ }
52
+
53
+ /** The installed package's files match the manifest published with it. Skipped when running from a source checkout. */
54
+ export async function checkInstalledPackage(packageRoot = ownRoot): Promise<IntegrityFinding[]> {
55
+ if (!packageRoot.split(sep).includes("node_modules")) return [];
56
+ const where = (file: string) => `node_modules/${PACKAGE}/${file}`, restore = "bun install --force";
57
+ if (!existsSync(join(packageRoot, MANIFEST))) return [{ file: where(MANIFEST), detail: `the installed package has no ${MANIFEST}; it was not installed from the npm registry`, restore }];
58
+ const manifest = await Bun.file(join(packageRoot, MANIFEST)).json() as { version: string; files: Record<string, string> };
59
+ const findings: IntegrityFinding[] = [];
60
+ const version = (await Bun.file(join(packageRoot, "package.json")).json() as { version: string }).version;
61
+ if (version !== manifest.version) findings.push({ file: where("package.json"), detail: `installed version ${version} does not match the published manifest ${manifest.version}`, restore });
62
+ for (const [file, hash] of Object.entries(manifest.files)) {
63
+ const path = join(packageRoot, file);
64
+ if (!existsSync(path)) findings.push({ file: where(file), detail: "was deleted from the installed package", restore });
65
+ else if (sha256(await Bun.file(path).bytes()) !== hash) findings.push({ file: where(file), detail: `differs from the published ${manifest.version} package`, restore });
66
+ }
67
+ for (const file of await filesBelow(packageRoot)) if (file !== MANIFEST && file !== "package.json" && !(file in manifest.files)) findings.push({ file: where(file), detail: "was added to the installed package", restore });
68
+ return findings;
69
+ }
70
+
71
+ /** The repository installs the package from npm, pinned by a registry integrity hash. */
72
+ async function checkDependency(root: string): Promise<IntegrityFinding[]> {
73
+ if (!existsSync(join(root, "package.json"))) return [];
74
+ const pkg = await Bun.file(join(root, "package.json")).json() as { name?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
75
+ if (pkg.name === PACKAGE) return [];
76
+ const spec = pkg.devDependencies?.[PACKAGE] ?? pkg.dependencies?.[PACKAGE];
77
+ if (spec === undefined) return [{ file: "package.json", detail: `${PACKAGE} is not a dependency`, restore: `bun add -d ${PACKAGE}` }];
78
+ const findings: IntegrityFinding[] = [];
79
+ 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}` });
80
+ const lock = existsSync(join(root, "bun.lock")) ? await readFile(join(root, "bun.lock"), "utf8") : "";
81
+ const entry = lock.split("\n").find(line => line.trimStart().startsWith(`"${PACKAGE}": [`));
82
+ if (!entry) findings.push({ file: "bun.lock", detail: `bun.lock does not pin ${PACKAGE}`, restore: "bun install" });
83
+ else if (!/"@afokapu\/atdd-bun@\d+\.\d+\.\d+[^"]*", ""/.test(entry) || !entry.includes('"sha512-')) findings.push({ file: "bun.lock", detail: `bun.lock does not resolve ${PACKAGE} from the npm registry with an integrity hash`, restore: `bun add -d ${PACKAGE}` });
84
+ return findings;
85
+ }
86
+
87
+ /** Generated files are byte-identical to what the installed version generates (ignoring its version stamp). */
88
+ async function checkGenerated(root: string, packageRoot: string): Promise<IntegrityFinding[]> {
89
+ const findings: IntegrityFinding[] = [];
90
+ const same = async (file: string, template: string, restore: string) => {
91
+ const path = join(root, file);
92
+ if (!existsSync(path)) return findings.push({ file, detail: "is missing", restore });
93
+ if (unstamp(await readFile(path, "utf8")) !== unstamp(await readFile(join(packageRoot, template), "utf8"))) findings.push({ file, detail: "was edited; it must match what the package generates", restore });
94
+ };
95
+ await same(WORKFLOW, "templates/github/atdd-bun.yml", "bun run atdd-bun ci init --replace");
96
+ for (const skill of SKILLS) await same(skill, "templates/agents/atdd/SKILL.md", "bun run atdd-bun agent init --replace");
97
+ await same(relative(root, await testFilePath(root)), "templates/agents/atdd-bun.integrity.test.ts", "bun run atdd-bun integrity init --replace");
98
+ const agents = existsSync(join(root, "AGENTS.md")) ? await readFile(join(root, "AGENTS.md"), "utf8") : "";
99
+ const block = agents.match(BLOCK)?.[0], canonical = (await readFile(join(packageRoot, "templates/agents/AGENTS.block.md"), "utf8")).match(BLOCK)![0];
100
+ if (!block) findings.push({ file: "AGENTS.md", detail: "is missing the atdd-bun block", restore: "bun run atdd-bun agent init --replace" });
101
+ else if (unstamp(block) !== unstamp(canonical)) findings.push({ file: "AGENTS.md", detail: "atdd-bun block was edited", restore: "bun run atdd-bun agent init --replace" });
102
+ return findings;
103
+ }
104
+
105
+ /** Names of the policy fields in `current` that are looser than in `base`. */
106
+ export function loosenedPolicy(base: Partial<HookPolicy>, current: Partial<HookPolicy>): string[] {
107
+ const b = { ...defaultHookPolicy, ...base, worktrees: { ...defaultHookPolicy.worktrees, ...base.worktrees } }, c = { ...defaultHookPolicy, ...current, worktrees: { ...defaultHookPolicy.worktrees, ...current.worktrees } };
108
+ const out: string[] = [];
109
+ for (const key of ["max_staged_files", "max_staged_changed_lines", "max_uncommitted_files", "max_commits_per_push", "max_registry_removed_lines"] as const) if (Number(c[key]) > Number(b[key])) out.push(`${key} ${b[key]} → ${c[key]}`);
110
+ for (const key of ["require_plan_reference", "require_traceability"] as const) if (b[key] && !c[key]) out.push(`${key} true → false`);
111
+ for (const key of ["enabled", "require_linked_worktree"] as const) if (b.worktrees[key] && !c.worktrees[key]) out.push(`worktrees.${key} true → false`);
112
+ const removed = b.protected_branches.filter(x => !c.protected_branches.includes(x)), added = c.registry_paths.filter(x => !b.registry_paths.includes(x));
113
+ if (removed.length) out.push(`protected_branches drops ${removed.join(", ")}`);
114
+ if (added.length) out.push(`registry_paths adds ${added.join(", ")}`);
115
+ return out;
116
+ }
117
+
118
+ /** atdd-bun.yaml is not looser than on the branch being merged into. */
119
+ async function checkPolicy(root: string, base?: string, push = process.env.GITHUB_EVENT_NAME === "push"): Promise<IntegrityFinding[]> {
120
+ const ref = base ?? process.env.ATDD_BASE_REF ?? (process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : "origin/HEAD");
121
+ if ((await git(root, ["rev-parse", "--verify", "--quiet", ref])).code) return [];
122
+ let against = (await git(root, ["merge-base", "HEAD", ref])).out;
123
+ // A CI push to the base branch has nothing to merge into: judge the pushed commit against its parent.
124
+ if (push && against === (await git(root, ["rev-parse", "HEAD"])).out) against = (await git(root, ["rev-parse", "--verify", "--quiet", "HEAD~1"])).out;
125
+ if (!against) return [];
126
+ const read = async (text: string | null) => (text ? Bun.YAML.parse(text) ?? {} : {}) as Partial<HookPolicy>;
127
+ const before = await git(root, ["show", `${against}:atdd-bun.yaml`]), path = join(root, "atdd-bun.yaml");
128
+ const loosened = loosenedPolicy(await read(before.code ? null : before.out), await read(existsSync(path) ? await readFile(path, "utf8") : null));
129
+ 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` }] : [];
130
+ }
131
+
132
+ export async function checkIntegrity(options: IntegrityOptions = {}): Promise<IntegrityFinding[]> {
133
+ const root = resolve(options.root ?? process.cwd()), packageRoot = options.packageRoot ?? ownRoot;
134
+ return [...await checkInstalledPackage(packageRoot), ...await checkDependency(root), ...await checkGenerated(root, packageRoot), ...await checkPolicy(root, options.base, options.push)];
135
+ }
136
+
137
+ /** The message both the local test and CI print: addressed to the agent, with the way back for every file. */
138
+ export function formatIntegrity(findings: IntegrityFinding[]): string {
139
+ return [
140
+ "",
141
+ "⛔ ATDD INTEGRITY VIOLATION: protected files were changed.",
142
+ "",
143
+ `These files are owned by ${PACKAGE}. You are not allowed to edit them, weaken them,`,
144
+ "or work around this check. Changing them means you are not following this repository's limits.",
145
+ "",
146
+ ...findings.flatMap(f => [` ✗ ${f.file}: ${f.detail}`, ` restore: ${f.restore}`]),
147
+ "",
148
+ "Put every file above back to its canonical state before doing anything else.",
149
+ "If a change here is genuinely needed, stop and ask the human. It belongs in the",
150
+ `${PACKAGE} package or in a separate change the human approves, never in this one.`,
151
+ "CI runs this same check on a clean install, so reverting only locally will not pass.",
152
+ "",
153
+ ].join("\n");
154
+ }
155
+
156
+ /** Write the generated integrity test into a repository. */
157
+ export async function integrityInit(repo = process.cwd(), replace = false) {
158
+ const output = await testFilePath(repo);
159
+ if (existsSync(output) && !replace) return { ok: false, message: `${output} exists; use --replace` };
160
+ const version = (await Bun.file(join(ownRoot, "package.json")).json() as { version: string }).version;
161
+ await mkdir(dirname(output), { recursive: true });
162
+ await writeFile(output, (await readFile(join(ownRoot, "templates/agents/atdd-bun.integrity.test.ts"), "utf8")).replace("{{VERSION}}", version));
163
+ return { ok: true, message: output };
164
+ }
165
+ export async function integrityStatus(repo = process.cwd()) { const output = await testFilePath(repo); return { ok: existsSync(output), message: output }; }
package/src/register.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { enforce, type EnforcementConfig } from "./enforce";
3
+ import { checkIntegrity, formatIntegrity, type IntegrityOptions } from "./integrity";
3
4
 
4
5
  export function registerEnforcementTest(config: EnforcementConfig = {}): void {
5
6
  test("ATDD Bun enforcement", async () => {
6
7
  expect(await enforce(config)).toEqual([]);
7
8
  });
8
9
  }
10
+
11
+ /** A test that fails loudly, with the way back, when protected atdd-bun files were changed. */
12
+ export function registerIntegrityTest(options: IntegrityOptions = {}): void {
13
+ test("ATDD integrity: atdd-bun and its generated files are canonical", async () => {
14
+ const findings = await checkIntegrity(options);
15
+ if (findings.length) throw new Error(formatIntegrity(findings));
16
+ });
17
+ }
package/src/setup.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { ciInit, ciStatus } from "./ci";
2
2
  import { hooksStatus, installHooks } from "./hooks";
3
3
  import { agentInit, agentStatus } from "./agent";
4
+ import { integrityInit, integrityStatus } from "./integrity";
4
5
 
5
- /** Install the package's three opt-in local surfaces without touching unrelated
6
+ /** Install the package's opt-in local surfaces without touching unrelated
6
7
  * workflows or hook paths. Dependency installation itself never calls this. */
7
8
  export async function initializeRepository(root = process.cwd(), replace = false) {
8
9
  const existingHooks = await hooksStatus(root);
@@ -16,5 +17,9 @@ export async function initializeRepository(root = process.cwd(), replace = false
16
17
  const existingAgent = await agentStatus(root);
17
18
  const agent = replace || !existingAgent.ok ? await agentInit(root, replace) : existingAgent;
18
19
  if (!agent.ok) return { ok: false, message: `agent skill: ${agent.message}` };
19
- return { ok: true, message: `hooks: ${hooks.message}\nCI workflow: ${ci.message}\nagent skill: ${agent.message}` };
20
+
21
+ const existingIntegrity = await integrityStatus(root);
22
+ const integrity = replace || !existingIntegrity.ok ? await integrityInit(root, replace) : existingIntegrity;
23
+ if (!integrity.ok) return { ok: false, message: `integrity test: ${integrity.message}` };
24
+ return { ok: true, message: `hooks: ${hooks.message}\nCI workflow: ${ci.message}\nagent skill: ${agent.message}\nintegrity test: ${integrity.message}` };
20
25
  }
@@ -1,5 +1,5 @@
1
1
  <!-- atdd-bun:start — generated by @afokapu/atdd-bun {{VERSION}}; run `atdd-bun agent init --replace` to refresh -->
2
2
  ## ATDD lifecycle
3
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.
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. Never edit `node_modules/@afokapu/atdd-bun` or atdd-bun's generated files, and never loosen `atdd-bun.yaml`; the integrity test and CI fail if you do.
5
5
  <!-- atdd-bun:end -->
@@ -14,3 +14,5 @@ Conventions live in `node_modules/@afokapu/atdd-bun/` (`planner-nodes/nodes/`, `
14
14
  6. TRACE — Every acceptance has a test, every test resolves to a declared acceptance, every source file resolves to its tests. Gate: `bun run atdd-bun traceability`, then `bun run atdd-bun all`.
15
15
 
16
16
  When a gate fails, open `<rule_id>.convention.yaml` for the reported rule ID and fix the artifact. Never skip, suppress, or edit a convention to get green.
17
+
18
+ Never edit `node_modules/@afokapu/atdd-bun`, the files atdd-bun generates (this skill, `AGENTS.md` block, `.github/workflows/atdd-bun.yml`, `atdd-bun.integrity.test.ts`), or loosen `atdd-bun.yaml`. `atdd-bun.integrity.test.ts` fails if you do, and CI repeats the check on a clean install. If one of them needs to change, stop and ask the human.
@@ -0,0 +1,5 @@
1
+ // Generated by @afokapu/atdd-bun {{VERSION}}. Do not edit; run `atdd-bun integrity init --replace`.
2
+ // Fails loudly when the installed package, its generated files, or atdd-bun.yaml were changed.
3
+ import { registerIntegrityTest } from "@afokapu/atdd-bun/register";
4
+
5
+ registerIntegrityTest();
@@ -14,6 +14,7 @@ jobs:
14
14
  with: { fetch-depth: 0 }
15
15
  - uses: oven-sh/setup-bun@v2
16
16
  - run: bun install --frozen-lockfile
17
+ - run: bun run atdd-bun integrity
17
18
  - run: bun run atdd-bun all
18
19
  - run: bun test
19
20
  - run: bun run atdd-bun release check