@afokapu/atdd-bun 0.10.61 → 0.10.63

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
@@ -103,8 +103,11 @@ the adoption that establishes the governed set, so a brownfield repository can d
103
103
  `profiles: [docs]` in an ordinary pull request. From then on, the integrity check reports, against
104
104
  the base branch, removing a profile from the list and removing the list itself. The second closes
105
105
  the two-step bypass `[docs, security]` → no list → `[docs]`. `init` writes a new `atdd-bun.yaml`
106
- with every profile listed, so a greenfield repository is governed from its first commit; trim the
107
- list before that commit to adopt gradually.
106
+ with every profile listed and a matching explicit `reject-new` ratchet policy, so a greenfield
107
+ repository is governed from its first commit. For an existing configuration with an explicit
108
+ profile list but no policy, `init` appends only the matching policy and preserves the selected
109
+ profiles and every other configuration field; keep the two selected-profile lists equal when
110
+ trimming them before the first commit to adopt gradually.
108
111
 
109
112
  ## Configuration
110
113
 
@@ -129,9 +132,13 @@ release: { enabled: false }
129
132
 
130
133
  When enabled without overrides, a primary checkout at `~/Github/<repo>` uses linked worktrees at `~/Github/worktrees/<repo>/<branch>`. The defaults use `primary_directory: .` (the current primary checkout) and `root: ../worktrees/{repo}`; `{repo}` expands to the primary checkout's directory name. Repositories that need another layout can set either field explicitly.
131
134
 
132
- ## Exact-base finding ratchet (opt-in)
135
+ ## Exact-base finding ratchet
133
136
 
134
- Strict enforcement is the default: `bun run atdd-bun all` still fails for every finding. A consumer may opt into an exact-base comparison for one explicit profile set:
137
+ `bun run atdd-bun init` generates an explicit `reject-new` policy whose profiles exactly match
138
+ the generated selected profiles. For an existing explicit selection with no policy, the same
139
+ command appends that matching policy without changing the selection or other configuration.
140
+ Without a policy, strict enforcement remains the default; `bun run atdd-bun all` still fails for
141
+ every finding:
135
142
 
136
143
  ```yaml
137
144
  # atdd-bun.yaml
@@ -160,9 +167,19 @@ bun run atdd-bun --profile flow,traceability --ratchet --ratchet-activate-profil
160
167
 
161
168
  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.
162
169
 
163
- ### Generated activation commit gate
170
+ ### Generated exact-base commit gate
164
171
 
165
- 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.
172
+ A generated `pre-commit` hook recognizes a staged `reject-new` policy and materializes the exact
173
+ committed `HEAD` base plus the staged index tree in an isolated worktree. With an already-active,
174
+ unchanged explicit profile set and identical context, it runs ordinary exact-base comparison and
175
+ emits the carried/new/resolved report; the commit proceeds only when `new` is empty. A strict
176
+ profile expansion uses the same comparison as the one-time activation exception.
177
+
178
+ The gate never writes a baseline or weakens policy outside that exact comparison. It fails closed
179
+ unless the index has the explicit matching ratchet policy, the worktree has no unstaged or
180
+ untracked state, and either the selected profiles/context are unchanged or the base profiles are
181
+ a strict subset with identical non-profile policy. Removals, context drift, unavailable/non-ancestor
182
+ bases, a bypass, waivers, mutable baselines, and new findings remain rejected.
166
183
 
167
184
  ## Docs-site theme
168
185
 
package/integrity.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.10.61",
2
+ "version": "0.10.63",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
6
- "README.md": "97715ea62760048752774c5c6f9a34046e5a019595590fc71d5b007551385c7e",
6
+ "README.md": "207414ffc3947dcdc5fc778df63f3e870a5b6cca283e68751e439eed2c03fb0a",
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": "5992b94e26e93a9d3d8aece1336c5d36fce26682c86f4bf2775a3826ddf2670c",
1264
+ "src/hooks.ts": "6b579c030bb4772f08806e802f4f814444b1878bbac0d6c62bc10b4ee184e449",
1265
1265
  "src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
1266
1266
  "src/integrity.ts": "f5ad22759eec675454df82cdcf791b1f081951f08ca5084ebdcb8cd82aa1ecef",
1267
1267
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
@@ -1272,7 +1272,7 @@
1272
1272
  "src/ratchet.ts": "801038b182615ed883197e0e29c98da9ac5f81bf4db4eb36b989065e32c99b6b",
1273
1273
  "src/register.ts": "6a2f1c6b8df0dc9c8f45df8ec9fac2be29044e048b08c8ffe423410e94dadedd",
1274
1274
  "src/release.ts": "7b0fe444ee81f476f53b9c21b6c347ef588ee616c60211419cf984132be768ba",
1275
- "src/setup.ts": "38233265afedc63499bbef3b5403889651add335a70efd3813254ba53f4fc4fe",
1275
+ "src/setup.ts": "c6804f699808c01a849ea46489b0e9feab7991da4cb62a29edd439423e0a88c0",
1276
1276
  "src/telemetry-plan.ts": "cdb1e8bfff414f3ea81977388973da2cf6d89567d9e060dc9d5989a33600f153",
1277
1277
  "src/topology.ts": "9779ee48e1c453abb76363163d4ab363463fa14e359571139f6d1f1fcc51ae2c",
1278
1278
  "templates/agents/AGENTS.block.md": "84d19dfabedb1e05ccfb7b19a38644e87f193ae2b74755897f75f13971bb687e",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.61",
3
+ "version": "0.10.63",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/hooks.ts CHANGED
@@ -40,8 +40,8 @@ const configAt = async (root: string) => {
40
40
  return existsSync(file) ? Bun.YAML.parse(await readFile(file, "utf8")) as Record<string, unknown> : {};
41
41
  };
42
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> {
43
+ /** A staged reject-new policy compares its exact HEAD: equal active profiles are ordinary ratchet flow; strict expansion is the one bootstrap exception. */
44
+ async function ratchetCommitGate(root: string): Promise<{ ok: boolean; message: string } | null> {
45
45
  const stagedConfig = await git(root, ["show", ":atdd-bun.yaml"]);
46
46
  if (stagedConfig.code) return null;
47
47
  let candidateConfig: Record<string, unknown>, policy;
@@ -59,14 +59,18 @@ async function profileActivationCommitGate(root: string): Promise<{ ok: boolean;
59
59
  if (checkedOut.code) return bad(`could not checkout ratchet activation base: ${checkedOut.err || checkedOut.out}`);
60
60
  const baseConfig = await configAt(scratch), candidateProfiles = stable(candidateConfig.profiles.map(normalizeProfileName));
61
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");
62
+ const policyMatches = stable(policy.profiles.map(normalizeProfileName)).join(",") === candidateProfiles.join(",");
63
+ const activation = isStrictProfileExpansion(baseProfiles, candidateProfiles) && activationContextDigest(baseConfig) === activationContextDigest(candidateConfig);
64
+ const unchanged = baseProfiles.join(",") === candidateProfiles.join(",") && contextDigest(baseConfig, baseProfiles) === contextDigest(candidateConfig, candidateProfiles);
65
+ if (candidateProfiles.some(profile => !concreteProfiles.includes(profile as typeof concreteProfiles[number])) || !policyMatches || (!activation && !unchanged)) return bad("ratchet reject-new requires either unchanged explicit profiles/context or an explicit monotonic profile expansion with identical non-profile context");
63
66
  const baseFindings = await enforce({ root: scratch, profiles: candidateProfiles as any });
64
67
  const patch = await git(root, ["diff", "--cached", "--binary"], undefined, true);
65
68
  if (patch.code) return bad(`could not read staged ratchet activation candidate: ${patch.err || patch.out}`);
66
69
  const applied = await git(scratch, ["apply", "--whitespace=nowarn"], patch.out);
67
70
  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");
71
+ const materialized = await configAt(scratch), materializedProfiles = stable(await enabledProfiles(scratch));
72
+ const materializedContext = activation ? activationContextDigest(materialized) === activationContextDigest(candidateConfig) : materializedProfiles.join(",") === candidateProfiles.join(",") && contextDigest(materialized, materializedProfiles) === contextDigest(candidateConfig, candidateProfiles);
73
+ if (!materializedContext) return bad("staged ratchet candidate did not materialize exactly");
70
74
  const candidateFindings = await enforce({ root: scratch, profiles: candidateProfiles as any });
71
75
  const normalize = (findings: Awaited<ReturnType<typeof enforce>>) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(scratch, finding.file) }));
72
76
  const delta = compareFindings(normalize(baseFindings), normalize(candidateFindings));
@@ -78,6 +82,86 @@ async function profileActivationCommitGate(root: string): Promise<{ ok: boolean;
78
82
  finally { await git(root, ["worktree", "remove", "--force", scratch]); await rm(scratch, { recursive: true, force: true }); }
79
83
  }
80
84
 
85
+ /** A pushed reject-new candidate is judged from its immutable object IDs, never the hook's checkout or index. */
86
+ const exactProfiles = (config: Record<string, unknown>, label: string) => {
87
+ if (!Array.isArray(config.profiles) || !config.profiles.length || !config.profiles.every(profile => typeof profile === "string")) throw new Error(`${label} must declare a non-empty explicit profiles list`);
88
+ const profiles = config.profiles as string[];
89
+ if (profiles.some(profile => normalizeProfileName(profile) !== profile || !concreteProfiles.includes(profile as typeof concreteProfiles[number]))) throw new Error(`${label} lists an unknown or legacy profile`);
90
+ if (new Set(profiles).size !== profiles.length) throw new Error(`${label} lists duplicate profiles`);
91
+ return stable(profiles);
92
+ };
93
+
94
+ async function checkoutCommit(root: string, directory: string, commit: string, label: string) {
95
+ const added = await git(root, ["worktree", "add", "--detach", "--no-checkout", directory, commit]);
96
+ if (added.code) throw new Error(`could not materialize ${label}: ${added.err || added.out}`);
97
+ const checkedOut = await git(directory, ["checkout", "--detach", commit]);
98
+ if (checkedOut.code) throw new Error(`could not checkout ${label}: ${checkedOut.err || checkedOut.out}`);
99
+ }
100
+
101
+ /** Compare exactly the remote tip and advertised local tip for one reject-new ref update. */
102
+ async function deletionPushGate(root: string, remote: string | undefined) {
103
+ if (!remote || !/^[0-9a-f]{40}$/i.test(remote)) return bad("pre-push deletion requires an exact remote commit");
104
+ const resolved = await git(root, ["rev-parse", "--verify", `${remote}^{commit}`]);
105
+ return resolved.code || resolved.out.toLowerCase() !== remote.toLowerCase() ? bad(`pre-push deletion remote commit ${remote} is unavailable or not exact`) : { ok: true, message: "deletion validated" };
106
+ }
107
+
108
+ async function strictPushGate(root: string, candidate: string, range: string) {
109
+ if (!/^[0-9a-f]{40}$/i.test(candidate)) return bad("strict pre-push validation requires an exact local candidate commit");
110
+ const resolved = await git(root, ["rev-parse", "--verify", `${candidate}^{commit}`]);
111
+ if (resolved.code || resolved.out.toLowerCase() !== candidate.toLowerCase()) return bad(`strict pre-push candidate ${candidate} is unavailable or not a full exact commit`);
112
+ const candidateRoot = await mkdtemp(join(tmpdir(), "atdd-strict-push-candidate-"));
113
+ try {
114
+ await checkoutCommit(root, candidateRoot, candidate, "strict pre-push candidate");
115
+ const changed = await files(candidateRoot, ["diff", "--name-only", range]);
116
+ return validation(candidateRoot, changed, true);
117
+ } catch (error) { return bad(`strict pre-push gate failed closed: ${String(error)}`); }
118
+ finally { await git(root, ["worktree", "remove", "--force", candidateRoot]); await rm(candidateRoot, { recursive: true, force: true }); }
119
+ }
120
+
121
+ async function ratchetPushGate(root: string, base: string, candidate: string): Promise<{ ok: boolean; message: string } | null> {
122
+ if (!/^[0-9a-f]{40}$/i.test(candidate)) return bad("ratchet reject-new requires an exact local candidate commit");
123
+ const resolvedCandidate = await git(root, ["rev-parse", "--verify", `${candidate}^{commit}`]);
124
+ if (resolvedCandidate.code || resolvedCandidate.out.toLowerCase() !== candidate.toLowerCase()) return bad(`ratchet candidate ${candidate} is unavailable or not a full exact commit`);
125
+ const baseRoot = await mkdtemp(join(tmpdir(), "atdd-ratchet-push-base-")), candidateRoot = await mkdtemp(join(tmpdir(), "atdd-ratchet-push-candidate-"));
126
+ try {
127
+ await checkoutCommit(root, candidateRoot, candidate, "ratchet candidate");
128
+ const candidateConfig = await configAt(candidateRoot);
129
+ let policy;
130
+ try { policy = parseRatchetPolicy(candidateConfig); }
131
+ catch (error) { return bad(`ratchet pushed candidate policy is invalid: ${String(error)}`); }
132
+ if (!policy || policy.mode !== "reject-new") return null;
133
+ if (!/^[0-9a-f]{40}$/i.test(base)) return bad("ratchet reject-new requires an exact non-zero remote commit base");
134
+ const resolvedBase = await git(root, ["rev-parse", "--verify", `${base}^{commit}`]);
135
+ if (resolvedBase.code || resolvedBase.out.toLowerCase() !== base.toLowerCase()) return bad(`ratchet remote base ${base} is unavailable or not a full exact commit`);
136
+ if ((await git(root, ["merge-base", "--is-ancestor", base, candidate])).code) return bad("ratchet remote base must be an ancestor of the pushed candidate");
137
+ await checkoutCommit(root, baseRoot, base, "ratchet remote base");
138
+ const baseConfig = await configAt(baseRoot);
139
+ let basePolicy;
140
+ try { basePolicy = baseConfig.ratchet === undefined ? null : parseRatchetPolicy(baseConfig); }
141
+ catch (error) { return bad(`ratchet remote base policy is invalid: ${String(error)}`); }
142
+ let baseProfiles: string[], candidateProfiles: string[], policyProfiles: string[], basePolicyProfiles: string[] | null;
143
+ try {
144
+ baseProfiles = exactProfiles(baseConfig, "ratchet remote base");
145
+ candidateProfiles = exactProfiles(candidateConfig, "ratchet pushed candidate");
146
+ policyProfiles = exactProfiles({ profiles: policy.profiles }, "ratchet policy");
147
+ basePolicyProfiles = basePolicy ? exactProfiles({ profiles: basePolicy.profiles }, "ratchet remote base policy") : null;
148
+ } catch (error) { return bad(`ratchet pushed range profile state is invalid: ${String(error)}`); }
149
+ if (baseProfiles.join(",") !== candidateProfiles.join(",") || policyProfiles.join(",") !== candidateProfiles.join(",") || (basePolicyProfiles && basePolicyProfiles.join(",") !== baseProfiles.join(",")) || contextDigest(baseConfig, baseProfiles) !== contextDigest(candidateConfig, candidateProfiles)) return bad("ratchet remote base and pushed candidate profile/context differ");
150
+ const normalize = (findings: Awaited<ReturnType<typeof enforce>>, directory: string) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(directory, finding.file) }));
151
+ const [baseFindings, candidateFindings] = await Promise.all([
152
+ enforce({ root: baseRoot, profiles: candidateProfiles as any }),
153
+ enforce({ root: candidateRoot, profiles: candidateProfiles as any }),
154
+ ]);
155
+ const delta = compareFindings(normalize(baseFindings, baseRoot), normalize(candidateFindings, candidateRoot));
156
+ return { ok: delta.new.length === 0, message: JSON.stringify({ schema: "atdd-bun.ratchet-report/v1", mode: policy.mode, base, candidate, profiles: candidateProfiles, context: contextDigest(candidateConfig, candidateProfiles), ...delta }) };
157
+ } catch (error) { return bad(`ratchet pre-push gate failed closed: ${String(error)}`); }
158
+ finally {
159
+ await git(root, ["worktree", "remove", "--force", baseRoot]);
160
+ await git(root, ["worktree", "remove", "--force", candidateRoot]);
161
+ await Promise.all([rm(baseRoot, { recursive: true, force: true }), rm(candidateRoot, { recursive: true, force: true })]);
162
+ }
163
+ }
164
+
81
165
  const inside = (parent: string, child: string) => { const path = relative(parent, child); return path === "" || (path !== ".." && !path.startsWith("../") && !path.startsWith("..\\")); };
82
166
  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)); }
83
167
  export type WorktreeLayout = { primary: string; worktreeRoot: string; current: string; branch: string };
@@ -88,9 +172,9 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
88
172
  const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
89
173
  if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
90
174
  if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
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" }; }
175
+ 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 ratchet = await ratchetCommitGate(root); if (ratchet) return ratchet; return cfg.require_traceability || registries.length || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
92
176
  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" }; }
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); }
177
+ if (event === "pre-push") { const reports: string[] = []; for (const row of stdin.split("\n").filter(Boolean).map(row => row.split(/\s+/))) { const [localRef, local, remote, remoteSha] = row, target = remote?.replace("refs/heads/", ""); if (target && cfg.protected_branches.includes(target)) return bad(`protected branch ${target} is blocked`); if (!local) return bad("pre-push update has no local object id"); if (/^0+$/.test(local)) { const deletion = await deletionPushGate(root, remoteSha); if (!deletion.ok) return deletion; continue; } const range = !remoteSha || /^0+$/.test(remoteSha) ? `${local}^..${local}` : `${remoteSha}..${local}`, counted = await git(root, ["rev-list", "--count", range]); if (counted.code || !/^\d+$/.test(counted.out)) return bad(`could not count commits in pushed range ${range}: ${counted.err || counted.out}`); if (Number(counted.out) > cfg.max_commits_per_push) return bad(`commits per push ${counted.out} exceed ${cfg.max_commits_per_push}`); const ratchet = localRef?.startsWith("refs/heads/") ? await ratchetPushGate(root, remoteSha, local) : null; if (ratchet) { if (!ratchet.ok) return ratchet; reports.push(ratchet.message); continue; } const strict = await strictPushGate(root, local, range); if (!strict.ok) return strict; } return reports.length ? { ok: true, message: reports.length === 1 ? reports[0]! : `[${reports.join(",")}]` } : { ok: true, message: "validation clean" }; }
94
178
  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}` }; }
95
179
  return { ok: true, message: "ok" };
96
180
  }
package/src/setup.ts CHANGED
@@ -2,19 +2,33 @@ import { ciInit, ciStatus } from "./ci";
2
2
  import { hooksStatus, installHooks } from "./hooks";
3
3
  import { agentInit, agentStatus } from "./agent";
4
4
  import { integrityInit, integrityStatus } from "./integrity";
5
- import { defaultProfiles } from "./enforce";
5
+ import { concreteProfiles, defaultProfiles, normalizeProfileName } from "./enforce";
6
6
  import { existsSync } from "node:fs";
7
- import { writeFile } from "node:fs/promises";
7
+ import { readFile, writeFile } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
 
10
10
  /** A new atdd-bun.yaml declares every profile explicitly. An absent list runs every profile but governs none, so
11
11
  * without this a greenfield repository's first `profiles:` line could switch most of them off unreported. A
12
12
  * brownfield repository trims the list before its first commit; after that, dropping one is a reported loosening.
13
- * An existing atdd-bun.yaml is never touched. */
13
+ * An existing explicit profile list gains only its matching generated reject-new policy; all other existing policy
14
+ * remains untouched. */
14
15
  export async function policyInit(root = process.cwd()) {
15
16
  const file = join(root, "atdd-bun.yaml");
16
- if (existsSync(file)) return { ok: true, message: `${file} kept` };
17
- await writeFile(file, `# Generated by atdd-bun init. The profiles this repository enforces; trim the list before the first commit to\n# adopt gradually. Once committed, removing a profile or the list is reported by the integrity check.\nprofiles: [${defaultProfiles.join(", ")}]\n`);
17
+ if (existsSync(file)) {
18
+ const text = await readFile(file, "utf8");
19
+ try {
20
+ const config = Bun.YAML.parse(text) as Record<string, unknown>;
21
+ const profiles = config && typeof config === "object" && !Array.isArray(config) && Array.isArray(config.profiles) && config.profiles.length && config.profiles.every(profile => typeof profile === "string") ? config.profiles as string[] : null;
22
+ const normalized = profiles?.map(normalizeProfileName);
23
+ const validProfiles = normalized && normalized.every(profile => concreteProfiles.includes(profile as typeof concreteProfiles[number])) && new Set(normalized).size === normalized.length;
24
+ if (profiles && validProfiles && config.ratchet === undefined) {
25
+ await writeFile(file, `${text.replace(/\s*$/, "")}\nratchet:\n mode: reject-new\n profiles: ${JSON.stringify(profiles)}\n`);
26
+ return { ok: true, message: `${file} added ratchet policy` };
27
+ }
28
+ } catch { /* Preserve malformed and non-policy consumer configuration for its normal validation path. */ }
29
+ return { ok: true, message: `${file} kept` };
30
+ }
31
+ await writeFile(file, `# Generated by atdd-bun init. The profiles this repository enforces; trim the list before the first commit to\n# adopt gradually. Once committed, removing a profile or the list is reported by the integrity check.\nprofiles: [${defaultProfiles.join(", ")}]\nratchet:\n mode: reject-new\n profiles: [${defaultProfiles.join(", ")}]\n`);
18
32
  return { ok: true, message: file };
19
33
  }
20
34