@afokapu/atdd-bun 0.10.61 → 0.10.62

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.62",
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": "4816318553e8f6a96716d8f72f6a1976734fe8333ac4f1d2d8144ac71fefad81",
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.62",
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));
@@ -88,7 +92,7 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
88
92
  const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
89
93
  if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
90
94
  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" }; }
95
+ 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
96
  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
97
  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); }
94
98
  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/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