@afokapu/atdd-bun 0.10.1 → 0.10.3

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
@@ -156,6 +156,14 @@ recorded work. Independence now defaults to `different-model`: a repository that
156
156
  `fresh-process` default sets it explicitly. A repository with no `stages:` gets the new default
157
157
  operating model, without the integrity check reporting the change.
158
158
 
159
+ From 0.10.2 the pre-commit hook no longer counts uncommitted work: it refused exactly the small
160
+ commits that reduce it. `max_uncommitted_files` is no longer read; commit size stays limited by
161
+ `max_staged_files` and `max_staged_changed_lines`.
162
+
163
+ From 0.10.3 each ready record is judged by the delivery policy in `atdd-bun.yaml` at its `approved_sha`:
164
+ a later policy change (dropping or reordering a model, removing a review) never makes a merged record
165
+ fail. Records in progress are judged by the current policy.
166
+
159
167
  Every key is optional; these are the defaults:
160
168
 
161
169
  ```yaml
@@ -17,6 +17,8 @@ content:
17
17
  fix_hint: |
18
18
  Run the missing review, or set status back to open until it has run. Set approved_sha to the SHA the final review approved.
19
19
  exceptions:
20
+ - >-
21
+ A ready record is judged by the policy in atdd-bun.yaml at its approved_sha, the policy it was approved under; a later policy change does not re-judge it. A record in progress is judged by the current policy.
20
22
  - >-
21
23
  A stage the policy omits is not required. Omitting one loosens the policy and is reported by the integrity check until a human approves it.
22
24
  metadata:
package/integrity.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.10.1",
2
+ "version": "0.10.3",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
6
- "README.md": "e6bd0deb0c31bfcdb514a8b7626b2782a3ab12d2cccb9558bc907f7f8173b979",
6
+ "README.md": "cb2d6e38a9375242be1b77453082ec835120cd19946af0f73293ae801d21c1dd",
7
7
  "bunfig.toml": "b9fc65eca9014c5179380259d70e76385a6a80788fa9a2df5fb3eaa5554fd2fe",
8
8
  "conventions/atdd-bun.planner/atdd-bun.planner.acceptance-identity.convention.yaml": "81c5c773d5ee15e8d99a2c237aa9846b110b2533df45ebf407d0e3ec6ed3dd03",
9
9
  "conventions/atdd-bun.planner/atdd-bun.planner.identity-required.convention.yaml": "e96d7c1455d0c221072d82e7f2b55da1c4f9ceb17718eb6ed96affdeef37675b",
@@ -101,7 +101,7 @@
101
101
  "conventions/delivery/delivery.operating-model.convention.yaml": "980858b735974093c98e850d8f6dd83cb21eea86a8b6e8249de678f0fc336f1b",
102
102
  "conventions/delivery/delivery.review.convention.yaml": "d71b889a92ca695bc7bc989636ef16272860a4d3389a2f1e2cf3776257e97af8",
103
103
  "conventions/delivery/delivery.reviewer-independent.convention.yaml": "264df63e768a2a17dca782dddbba6ba47a21fa3dd9cd39ed150f9a00dd04bdb0",
104
- "conventions/delivery/delivery.stages-complete.convention.yaml": "2658cb9f4a4cd8939231e4ebb0ed4db2d0982c94690d25e4577de9dad4c2f742",
104
+ "conventions/delivery/delivery.stages-complete.convention.yaml": "18f1e1e4a4774cc922f9c86f5d08e0b3b1d992967816277066efec648741f71c",
105
105
  "conventions/planner.docs/planner.docs.adr-registry-derived.convention.yaml": "0ea4232beba90280c664f93d097cafa44c67c2919c1468d4823fdc9e686aa5d2",
106
106
  "conventions/planner.docs/planner.docs.area-index-required.convention.yaml": "fd931e9f225bfbb5e78436d0d8a2a89cc0f5114493579962c72dd971301becce",
107
107
  "conventions/planner.docs/planner.docs.artifact-path-shape.convention.yaml": "a9f6178d29abe9385342a46a2c57470a3631aaf5e26f01e3660b7088b042418e",
@@ -1195,12 +1195,12 @@
1195
1195
  "src/board.ts": "f04c425f74edc003609baec7b5b2d75c1bc98b0e177d702de865dd42d9dd95e5",
1196
1196
  "src/ci.ts": "67e54d2cbfc44a9e42837d5e750af526d6255c539d3c452a4d5fca3226c0d9ac",
1197
1197
  "src/cli.ts": "b1618d7a7b3b0d42238cb1160aea4ee7962fef8135a7c75b89b0f3fa30dc1f45",
1198
- "src/delivery.ts": "f78cb15e1eabeca1492bd8f57b2029370d9c2dbc3d3fb7d25ed3a18ea9de87cb",
1198
+ "src/delivery.ts": "f36550be2a7e2f45a8026612b48542d040bb0ac27f7e950c91d980bc69763ddc",
1199
1199
  "src/docs-capability.ts": "115cf27049a5cf19c133bac5ec237072be6f969a59a2ef6767e054108a21dc5c",
1200
1200
  "src/enforce.ts": "41a6f8c058a2a034996c817f224191403cc66ccc4ed4e04bbefc430014c783d8",
1201
- "src/hooks.ts": "6766342c14b6f5a8f937433e15f04230436e3025a8316606e630481c96d9f35a",
1201
+ "src/hooks.ts": "9779eb48f9d0cb6234c33f6616f98dde6583d2ee03ac3c19f3ace2d156af26d0",
1202
1202
  "src/index.ts": "8ee4d7716794f6990ae8580e471b98256a0d0223dad063cd3b4542e598b6e744",
1203
- "src/integrity.ts": "ac516b1d3a73f872d0ed2b20a27ef0ac14ecc015293bd8e51954f322f8a063bb",
1203
+ "src/integrity.ts": "ba3de29e12e62a1b3e0149e243183fdfbce33d88efc9d39605b54996c01426cd",
1204
1204
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
1205
1205
  "src/planner-kernel.ts": "5d3f5305fb59becb2cc97d8a03aca6b104f385805d91cc0410856e289dd6fbb1",
1206
1206
  "src/planner-schema-validator.ts": "ce529c0936171075e93c7539b3655fe895a98dd2973ebda5775958e6ebc316d9",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/delivery.ts CHANGED
@@ -145,6 +145,18 @@ export function loosenedDelivery(base: unknown, current: unknown): string[] {
145
145
  return out;
146
146
  }
147
147
 
148
+ /** The delivery policy a ready record was approved under: atdd-bun.yaml at its approved_sha. A merged record proves it met
149
+ * the policy of its time, so a later policy change never makes it fail. When that commit has no readable, adopting
150
+ * config, the current policy applies. The root stays today's: it only locates the records. */
151
+ async function policyAt(root: string, sha: string, current: DeliveryPolicy): Promise<DeliveryPolicy> {
152
+ const prefix = (await git(root, ["rev-parse", "--show-prefix"])).out, shown = await git(root, ["show", `${sha}:${prefix}atdd-bun.yaml`]);
153
+ if (shown.code) return current;
154
+ try {
155
+ const data = record(Bun.YAML.parse(shown.out));
156
+ return data && deliveryAdopted(data) ? { ...deliveryPolicy(data.delivery ?? {}), root: current.root } : current;
157
+ } catch { return current; }
158
+ }
159
+
148
160
  async function readConfig(root: string): Promise<{ data: Record<string, unknown> | null; error?: string }> {
149
161
  const file = join(root, "atdd-bun.yaml");
150
162
  if (!existsSync(file)) return { data: null };
@@ -455,7 +467,8 @@ export async function validateDelivery(root = process.cwd(), options: DeliveryOp
455
467
  if (seen.has(review.report)) findings.push(finding("delivery.evidence-schema", entry.file, `reviews[${index}] reuses report ${review.report} from reviews[${seen.get(review.report)}]; every review retains its own report`));
456
468
  else seen.set(review.report, index);
457
469
  }
458
- findings.push(...checkEvidence(entry.file, entry.data, policy));
470
+ const judged = entry.data.status === "ready" && entry.data.approved_sha ? await policyAt(absolute, entry.data.approved_sha, policy) : policy;
471
+ findings.push(...checkEvidence(entry.file, entry.data, judged));
459
472
  if (entry.data.status === "ready") {
460
473
  // Ready means auditable: every review's raw output is retained and named.
461
474
  for (const [index, review] of entry.data.reviews.entries()) if (!review.report || !regularFile(join(absolute, review.report))) findings.push(finding("delivery.stages-complete", entry.file, `status is ready, but reviews[${index}] (${review.stage}) ${review.report ? `names report ${review.report}, which is not a regular file` : "retains no report"}`));
@@ -468,7 +481,7 @@ export async function validateDelivery(root = process.cwd(), options: DeliveryOp
468
481
  else if (!await inHistory(item.sha)) findings.push(finding("delivery.approved-sha-resolves", entry.file, `work[${index}] (${item.stage}) names ${item.sha.slice(0, 7)}, which is not in the history of approved_sha ${approved.slice(0, 7)}`));
469
482
  }
470
483
  let previous: { stage: Stage; sha: string } | null = null;
471
- for (const stage of STAGES.filter(name => policy.stages[name]?.reviewer.length)) {
484
+ for (const stage of STAGES.filter(name => judged.stages[name]?.reviewer.length)) {
472
485
  const last = entry.data.reviews.filter(review => review.stage === stage).at(-1);
473
486
  if (!last || last.verdict !== "approve") continue; // reported by delivery.stages-complete
474
487
  if ((await git(absolute, ["cat-file", "-e", `${last.sha}^{commit}`])).code) { findings.push(finding("delivery.approved-sha-resolves", entry.file, `${stage} approved ${last.sha}, which is not a commit in this repository's history`)); continue; }
package/src/hooks.ts CHANGED
@@ -6,9 +6,9 @@ import { topologyFor } from "./topology";
6
6
  import { JOURNEY_DOCS_DIR } from "./journey-docs";
7
7
 
8
8
  export type WorktreePolicy = { enabled: boolean; root: string; primary_directory: string; primary_branch: string; require_linked_worktree: boolean };
9
- 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 };
9
+ export type HookPolicy = { max_staged_files: number; max_staged_changed_lines: 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 };
10
10
  export const defaultWorktreePolicy: WorktreePolicy = { enabled: false, root: "../worktrees", primary_directory: "main", primary_branch: "main", require_linked_worktree: true };
11
- 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 };
11
+ export const defaultHookPolicy: HookPolicy = { max_staged_files: 20, max_staged_changed_lines: 350, 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 };
12
12
  export const hookEvents = ["pre-commit", "commit-msg", "pre-push", "pre-merge-commit", "post-commit", "post-merge"] as const;
13
13
  export type HookEvent = typeof hookEvents[number];
14
14
  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() }; };
@@ -40,7 +40,7 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
40
40
  const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
41
41
  if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
42
42
  if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
43
- if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), dirty = [...new Set([...await files(root, ["diff", "--name-only"]), ...await files(root, ["ls-files", "--others", "--exclude-standard"])])], 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 (dirty.length > cfg.max_uncommitted_files) return bad(`unstaged or untracked files ${dirty.length} exceed ${cfg.max_uncommitted_files}; the staged commit itself is not counted`); 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 || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
43
+ 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}`); return cfg.require_traceability || registries.length || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
44
44
  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" }; }
45
45
  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); }
46
46
  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/integrity.ts CHANGED
@@ -132,7 +132,7 @@ function withoutNulls(config: Record<string, unknown>): Record<string, unknown>
132
132
  * where a list is expected) or compare them as the defaults. An empty or null document is the default policy, and fine. */
133
133
  export function policyShapeErrors(config: Record<string, unknown>): string[] {
134
134
  const out: string[] = [];
135
- for (const key of ["max_staged_files", "max_staged_changed_lines", "max_uncommitted_files", "max_commits_per_push", "max_registry_removed_lines"])
135
+ for (const key of ["max_staged_files", "max_staged_changed_lines", "max_commits_per_push", "max_registry_removed_lines"])
136
136
  if (config[key] !== undefined && !(typeof config[key] === "number" && Number.isFinite(config[key]))) out.push(`${key} must be a finite number`);
137
137
  for (const key of ["require_plan_reference", "require_traceability"]) if (config[key] !== undefined && typeof config[key] !== "boolean") out.push(`${key} must be true or false`);
138
138
  for (const key of ["protected_branches", "registry_paths"]) if (config[key] !== undefined && !(Array.isArray(config[key]) && (config[key] as unknown[]).every(item => typeof item === "string"))) out.push(`${key} must be a list of strings`);
@@ -152,7 +152,7 @@ export function policyShapeErrors(config: Record<string, unknown>): string[] {
152
152
  export function loosenedPolicy(base: Partial<HookPolicy> & { profiles?: unknown; delivery?: unknown }, current: Partial<HookPolicy> & { profiles?: unknown; delivery?: unknown }): string[] {
153
153
  const b = { ...defaultHookPolicy, ...base, worktrees: { ...defaultHookPolicy.worktrees, ...base.worktrees } }, c = { ...defaultHookPolicy, ...current, worktrees: { ...defaultHookPolicy.worktrees, ...current.worktrees } };
154
154
  const out: string[] = [];
155
- 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]}`);
155
+ for (const key of ["max_staged_files", "max_staged_changed_lines", "max_commits_per_push", "max_registry_removed_lines"] as const) if (Number(c[key]) > Number(b[key])) out.push(`${key} ${b[key]} → ${c[key]}`);
156
156
  for (const key of ["require_plan_reference", "require_traceability"] as const) if (b[key] && !c[key]) out.push(`${key} true → false`);
157
157
  for (const key of ["enabled", "require_linked_worktree"] as const) if (b.worktrees[key] && !c.worktrees[key]) out.push(`worktrees.${key} true → false`);
158
158
  const removed = b.protected_branches.filter(x => !c.protected_branches.includes(x)), added = c.registry_paths.filter(x => !b.registry_paths.includes(x));