@afokapu/atdd-bun 0.10.31 → 0.10.33

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.
Files changed (66) hide show
  1. package/README.md +14 -120
  2. package/conventions/_profiles/architecture.yaml +190 -0
  3. package/conventions/_profiles/coder.yaml +804 -0
  4. package/conventions/_profiles/design.yaml +219 -0
  5. package/conventions/_profiles/docs.yaml +136 -0
  6. package/conventions/_profiles/htmx.yaml +436 -0
  7. package/conventions/_profiles/interlocking.yaml +248 -0
  8. package/conventions/_profiles/metrics.yaml +80 -0
  9. package/conventions/_profiles/planner.yaml +789 -0
  10. package/conventions/_profiles/runtime.yaml +62 -0
  11. package/conventions/_profiles/security.yaml +70 -0
  12. package/conventions/_profiles/telemetry.yaml +196 -0
  13. package/conventions/_profiles/tester.yaml +734 -0
  14. package/conventions/_profiles/topology.yaml +192 -0
  15. package/conventions/_profiles/traceability.yaml +94 -0
  16. package/conventions/atdd-bun.profiles/atdd-bun.profiles.presets.convention.yaml +21 -3
  17. package/conventions/planner.docs/planner.docs.area-index-required.convention.yaml +2 -2
  18. package/conventions/planner.docs/planner.docs.artifact-path-shape.convention.yaml +1 -1
  19. package/conventions/planner.docs/planner.docs.asciidoc-only.convention.yaml +0 -1
  20. package/conventions/planner.docs/planner.docs.identity-required.convention.yaml +1 -1
  21. package/conventions/planner.docs/planner.docs.undeclared-change.convention.yaml +1 -2
  22. package/detectors/bun_interlocking_coverage/checks/interlocking_train_sequence.mjs +7 -5
  23. package/detectors/planner_docs_capability/README.md +1 -1
  24. package/integrity.json +36 -50
  25. package/package.json +1 -1
  26. package/relationships.yaml +0 -151
  27. package/src/agent.ts +15 -31
  28. package/src/cli.ts +7 -8
  29. package/src/docs-capability.ts +6 -24
  30. package/src/enforce.ts +8 -9
  31. package/src/hooks.ts +4 -8
  32. package/src/index.ts +1 -2
  33. package/src/integrity.ts +3 -11
  34. package/src/profile-registry.ts +74 -0
  35. package/src/setup.ts +3 -5
  36. package/templates/agents/AGENTS.block.md +4 -8
  37. package/templates/github/atdd-bun.yml +0 -5
  38. package/conventions/delivery/delivery.approved-sha-resolves.convention.yaml +0 -33
  39. package/conventions/delivery/delivery.board.convention.yaml +0 -60
  40. package/conventions/delivery/delivery.config-schema.convention.yaml +0 -49
  41. package/conventions/delivery/delivery.evidence-schema.convention.yaml +0 -34
  42. package/conventions/delivery/delivery.findings-resolved.convention.yaml +0 -42
  43. package/conventions/delivery/delivery.merge-gate.convention.yaml +0 -46
  44. package/conventions/delivery/delivery.model-allowed.convention.yaml +0 -29
  45. package/conventions/delivery/delivery.operating-model.convention.yaml +0 -97
  46. package/conventions/delivery/delivery.review.convention.yaml +0 -42
  47. package/conventions/delivery/delivery.reviewer-independent.convention.yaml +0 -34
  48. package/conventions/delivery/delivery.stages-complete.convention.yaml +0 -33
  49. package/detectors/delivery_evidence/atdd.implementation.yaml +0 -33
  50. package/detectors/delivery_evidence/detect.mjs +0 -9
  51. package/detectors/delivery_evidence/fixtures/clean/atdd-bun.yaml +0 -8
  52. package/detectors/delivery_evidence/fixtures/clean/docs/delivery/tranches/api/evidence.yaml +0 -54
  53. package/detectors/delivery_evidence/fixtures/dirty/atdd-bun.yaml +0 -3
  54. package/detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/api/evidence.yaml +0 -31
  55. package/detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/data/evidence.yaml +0 -8
  56. package/detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/orphan/README.md +0 -1
  57. package/detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/ui/evidence.yaml +0 -5
  58. package/detectors/planner_docs_capability/fixtures/dirty_missing_index/docs/delivery/release.adoc +0 -10
  59. package/detectors/planner_docs_capability/fixtures/dirty_missing_index/docs/purpose/index.adoc +0 -5
  60. package/planner-schemas/delivery-config.schema.json +0 -95
  61. package/planner-schemas/delivery-evidence.schema.json +0 -103
  62. package/src/board-ui.ts +0 -91
  63. package/src/board.ts +0 -222
  64. package/src/delivery.ts +0 -567
  65. package/templates/agents/atdd/SKILL.md +0 -20
  66. package/templates/agents/delivery/SKILL.md +0 -15
package/src/delivery.ts DELETED
@@ -1,567 +0,0 @@
1
- import Ajv, { type ValidateFunction } from "ajv";
2
- import addFormats from "ajv-formats";
3
- import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
4
- import { readdir, readFile } from "node:fs/promises";
5
- import { join, resolve } from "node:path";
6
- import type { PlanFinding } from "./planner-kernel";
7
- import { topologyFor } from "./topology";
8
-
9
- /** The delivery profile: the record a tranche leaves of who wrote each stage and of its reviews, checked against the
10
- * policy in atdd-bun.yaml.
11
- *
12
- * A tranche is one independently mergeable piece of a program. Its driver appends each stage's work and each review
13
- * to `<root>/<tranche>/evidence.yaml`; this validator judges that record, never the running agents. It holds on every
14
- * run: each writer and reviewer is an allowed model, a fallback names why the preferred one was unavailable, every
15
- * reviewer is independent of the writers its review answers for, and every finding is fixed, withdrawn after a written
16
- * dispute, or ruled on by a human. A `ready` record must also have every written stage recorded, every reviewed stage
17
- * approved, and an approved SHA that exists. At the merge gate (a pull request or merge queue in CI), every record the
18
- * branch changes must be ready and the branch head may differ from its approved SHA only under the delivery root.
19
- *
20
- * The capability is inert until adopted in atdd-bun.yaml: a `delivery:` block, or `delivery` named in `profiles:`. */
21
-
22
- /** The lifecycle stages, in order. Each names who may write it and who reviews it; either may be absent. */
23
- export const STAGES = ["plan", "red", "green", "refactor", "final"] as const;
24
- export type Stage = (typeof STAGES)[number];
25
- /** Stage names before 0.10, still read in configs and records: each review stage maps to the stage it reviewed. */
26
- export const LEGACY_STAGES: Record<string, Stage> = { plan_review: "plan", test_review: "red", code_review: "refactor", final_review: "final" };
27
- /** A legacy stage that named reviewers but no authors took these package defaults for its authors. */
28
- const LEGACY_AUTHORS: Record<string, string[]> = { plan_review: ["codex"], test_review: ["glm", "claude"], code_review: ["glm", "claude"], final_review: ["codex"] };
29
- export type Independence = "fresh-process" | "different-model";
30
- export type StagePolicy = { writer: string[]; reviewer: string[]; independence: Independence };
31
- export type DeliveryPolicy = {
32
- root: string;
33
- independence: Independence;
34
- stages: Partial<Record<Stage, StagePolicy>>;
35
- fallback: { after_failures: number; within_minutes: number; when_exhausted: "block" | "wait" };
36
- commands: Record<string, { author?: string; review?: string }>;
37
- require_record: boolean;
38
- multiplexer: string;
39
- };
40
-
41
- /** The default operating model: two reviews, the plan and the whole change; the stages between them are written and
42
- * held by their deterministic gates. Each list is the set of models allowed for the role, in no order; "*" allows any. */
43
- const DEFAULT_STAGES: Partial<Record<Stage, { writer?: string[]; reviewer?: string[] }>> = {
44
- plan: { writer: ["codex", "claude-opus", "kimi"], reviewer: ["glm", "claude-opus", "codex", "kimi"] },
45
- red: { writer: ["glm", "claude-sonnet", "deepseek-flash", "claude-opus", "codex", "kimi"] },
46
- green: { writer: ["glm", "claude-sonnet", "deepseek-flash", "claude-opus", "codex", "kimi"] },
47
- refactor: { writer: ["glm", "claude-sonnet", "deepseek-flash", "claude-opus", "codex", "kimi"] },
48
- final: { reviewer: ["codex", "glm", "claude-opus", "kimi"] },
49
- };
50
- /** The stage a record or config name refers to: a current name, or a legacy review stage's. */
51
- export const stageOf = (name: string): Stage | undefined => (STAGES as readonly string[]).includes(name) ? name as Stage : LEGACY_STAGES[name];
52
- /** Tranche records live with the program's reasoning, under the docs profile's delivery area; the docs profile leaves
53
- * this folder to the delivery profile (records are YAML and data, never authored AsciiDoc). */
54
- export const DEFAULT_ROOT = "docs/delivery/tranches";
55
- /** The default before 0.9.0. */
56
- const LEGACY_ROOT = "delivery";
57
- const inDocs = (root: string) => root === "docs" || root.startsWith("docs/");
58
- const DEFAULT_FALLBACK: DeliveryPolicy["fallback"] = { after_failures: 3, within_minutes: 10, when_exhausted: "block" };
59
- const packageRoot = resolve(import.meta.dir, "..");
60
-
61
- const finding = (rule_id: string, file: string, evidence: string): PlanFinding => ({ rule_id, file, evidence });
62
- const record = (value: unknown): Record<string, unknown> | null => (value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null);
63
-
64
- /** Whether a parsed atdd-bun.yaml adopts delivery: named in `profiles:`, or, with no list (every profile active),
65
- * a `delivery:` block is present. Adoption is a positive act; an upgraded package changes nothing until then. */
66
- export function deliveryAdopted(config: unknown): boolean {
67
- const data = record(config);
68
- if (!data) return false;
69
- return Array.isArray(data.profiles) ? data.profiles.includes("delivery") : data.delivery !== undefined;
70
- }
71
-
72
- /** The effective policy: package defaults under whatever the `delivery:` block sets. `stages`, when given,
73
- * replaces the default stage set, so a stage it omits is not required. */
74
- export function deliveryPolicy(block: unknown): DeliveryPolicy {
75
- // Every value is type-guarded: a malformed block is the config-schema rule's to report, never a crash here (the
76
- // validator and the integrity check both read the policy through this function). A wrong-typed value falls back
77
- // to its default.
78
- const raw = record(block) ?? {}, text = (value: unknown, fallback: string) => (typeof value === "string" ? value : fallback);
79
- const models = (value: unknown, fallback: string[]) => (Array.isArray(value) && value.every(item => typeof item === "string") ? value as string[] : fallback);
80
- const mode = (value: unknown, fallback: Independence): Independence => (value === "fresh-process" || value === "different-model" ? value : fallback);
81
- const count = (value: unknown, fallback: number) => (Number.isInteger(value) ? value as number : fallback);
82
- const independence = mode(raw.independence, "different-model"), given = record(raw.stages), stages: DeliveryPolicy["stages"] = {};
83
- // `stages`, when given, replaces the default set. A legacy name (plan_review, …) is read as the stage it reviewed,
84
- // its authors as the writers and its reviewers as the reviewers; a current name wins over a legacy one.
85
- const entries: Array<[Stage, Record<string, unknown>, string]> = given
86
- ? Object.entries(given).flatMap(([name, value]) => { const stage = stageOf(name), entry = record(value); return stage && entry ? [[stage, entry, name] as [Stage, Record<string, unknown>, string]] : []; })
87
- .sort((a, b) => Number(a[2] === a[0]) - Number(b[2] === b[0]))
88
- : Object.entries(DEFAULT_STAGES).map(([stage, entry]) => [stage as Stage, entry as Record<string, unknown>, stage]);
89
- for (const [stage, entry, name] of entries) {
90
- const legacy = name !== stage;
91
- stages[stage] = {
92
- writer: models(legacy ? entry.authors : entry.writer, legacy ? LEGACY_AUTHORS[name] : []),
93
- reviewer: models(legacy ? entry.reviewers : entry.reviewer, []),
94
- independence: mode(entry.independence, independence),
95
- };
96
- }
97
- const fallback = record(raw.fallback) ?? {};
98
- return {
99
- root: canonicalRoot(text(raw.root, DEFAULT_ROOT)), independence, stages,
100
- fallback: { after_failures: count(fallback.after_failures, DEFAULT_FALLBACK.after_failures), within_minutes: count(fallback.within_minutes, DEFAULT_FALLBACK.within_minutes), when_exhausted: fallback.when_exhausted === "wait" ? "wait" : "block" },
101
- commands: (record(raw.commands) ?? {}) as DeliveryPolicy["commands"], require_record: raw.require_record === false ? false : true, multiplexer: text(raw.multiplexer, "herdr"),
102
- };
103
- }
104
-
105
- /** One spelling per root, so filesystem discovery, Git pathspecs and drift filtering agree ("delivery/" is "delivery"). */
106
- export const canonicalRoot = (root: string) => root.replaceAll("\\", "/").split("/").filter(part => part && part !== ".").join("/") || DEFAULT_ROOT;
107
-
108
- /** Why `current` enforces less than `base`, for the integrity check's loosening report. Tightening is silent. */
109
- export function loosenedDelivery(base: unknown, current: unknown): string[] {
110
- if (!deliveryAdopted(base)) return [];
111
- // When the base lists its profiles, the profiles rule reports every way out (a dropped profile, a removed list). When
112
- // delivery was adopted by its block alone, it is reported here: removing the block, or a first explicit list that
113
- // leaves delivery out (the block was itself an explicit adoption, so the first-list rule does not excuse it).
114
- if (!deliveryAdopted(current)) return Array.isArray(record(base)?.profiles) ? [] : ["delivery is no longer adopted"];
115
- const before = deliveryPolicy(record(base)!.delivery), after = deliveryPolicy(record(current)!.delivery), out: string[] = [];
116
- for (const stage of STAGES) {
117
- const b = before.stages[stage], c = after.stages[stage];
118
- if (!b) continue;
119
- if (!c) { out.push(`delivery.stages drops ${stage}`); continue; }
120
- if (b.independence === "different-model" && c.independence === "fresh-process") out.push(`delivery.stages.${stage}.independence different-model → fresh-process`);
121
- if (b.reviewer.length && !c.reviewer.length) out.push(`delivery.stages.${stage} is no longer reviewed`);
122
- for (const role of ["reviewer", "writer"] as const) {
123
- const added = c[role].filter(model => !b[role].includes(model));
124
- if (added.length && b[role].length) out.push(`delivery.stages.${stage}.${role} adds ${added.join(", ")}`);
125
- // The lists are allowed sets: reordering one changes nothing; only an added model (or "*") is a loosening.
126
- }
127
- // A stage that had no writer list now accepting writers is a loosening too: before, nothing could be written there.
128
- if (!b.writer.length && c.writer.length) out.push(`delivery.stages.${stage}.writer adds ${c.writer.join(", ")}`);
129
- }
130
- // Moving the root hides every earlier record from the validator and the gate. No exception, not even pinning the 0.8.0
131
- // default: from the config alone it cannot be told apart from moving a 0.9 repository's records out of view, so a
132
- // human approves it.
133
- if (before.root !== after.root) out.push(`delivery.root ${before.root} → ${after.root}`);
134
- if (before.require_record && !after.require_record) out.push("delivery.require_record true → false");
135
- if (before.fallback.when_exhausted === "block" && after.fallback.when_exhausted === "wait") out.push("delivery.fallback.when_exhausted block → wait");
136
- // The commands decide how reviews run: adding or changing one, including over the skill's protected defaults, can
137
- // weaken review isolation. Removing one returns to the default, which is not a loosening.
138
- for (const [model, command] of Object.entries(after.commands)) for (const role of ["author", "review"] as const)
139
- if (command?.[role] !== undefined && before.commands[model]?.[role] !== command[role]) out.push(`delivery.commands.${model}.${role} ${before.commands[model]?.[role] === undefined ? "overrides the default" : "changed"}`);
140
- if (after.fallback.after_failures < before.fallback.after_failures) out.push(`delivery.fallback.after_failures ${before.fallback.after_failures} → ${after.fallback.after_failures}`);
141
- if (after.fallback.within_minutes > before.fallback.within_minutes) out.push(`delivery.fallback.within_minutes ${before.fallback.within_minutes} → ${after.fallback.within_minutes}`);
142
- return out;
143
- }
144
-
145
- /** The delivery policy a ready record was approved under: atdd-bun.yaml at its approved_sha. A merged record proves it met
146
- * the policy of its time, so a later policy change never makes it fail. When that commit has no readable, adopting
147
- * config, the current policy applies. The root stays today's: it only locates the records. */
148
- async function policyAt(root: string, sha: string, current: DeliveryPolicy): Promise<DeliveryPolicy> {
149
- const prefix = (await git(root, ["rev-parse", "--show-prefix"])).out, shown = await git(root, ["show", `${sha}:${prefix}atdd-bun.yaml`]);
150
- if (shown.code) return current;
151
- try {
152
- const data = record(Bun.YAML.parse(shown.out));
153
- return data && deliveryAdopted(data) ? { ...deliveryPolicy(data.delivery ?? {}), root: current.root } : current;
154
- } catch { return current; }
155
- }
156
-
157
- async function readConfig(root: string): Promise<{ data: Record<string, unknown> | null; error?: string }> {
158
- const file = join(root, "atdd-bun.yaml");
159
- if (!existsSync(file)) return { data: null };
160
- try { return { data: record(Bun.YAML.parse(await readFile(file, "utf8"))) }; } catch (error) { return { data: null, error: String(error) }; }
161
- }
162
-
163
- async function schema(name: string): Promise<ValidateFunction> {
164
- const ajv = new Ajv({ allErrors: true, strict: false });
165
- addFormats(ajv);
166
- return ajv.compile(JSON.parse(await readFile(join(packageRoot, "planner-schemas", name), "utf8")));
167
- }
168
-
169
- const git = async (cwd: string, args: string[]) => {
170
- try {
171
- const child = Bun.spawn({ cmd: ["git", ...args], cwd, stdout: "pipe", stderr: "pipe" });
172
- return { code: await child.exited, out: (await new Response(child.stdout).text()).trim() };
173
- } catch { return { code: 1, out: "" }; }
174
- };
175
-
176
- type Actor = { model: string; run: string };
177
- // A failure fallback counts observed failures; an operator fallback references the operator's ruling instead.
178
- type Fallback = { role?: "author" | "writer" | "reviewer"; from: string; kind: string; failures?: number; window?: { from: string; to: string }; decided_by?: string; decided_at?: string; ruling?: string; reason: string };
179
- type Work = { stage: Stage; sha: string; writer: Actor; fallback?: Fallback[] };
180
- type Review = { stage: Stage; sha: string; author?: Actor; reviewer: Actor; fallback?: Fallback[]; verdict: "approve" | "request_changes"; findings?: Finding[]; report?: string };
181
- type Finding = { id: string; severity: string; rebuttal?: string; outcome?: "fixed" | "withdrawn" | "human"; decision?: string };
182
- type Evidence = { tranche: string; status: "open" | "ready"; base_sha: string; approved_sha?: string; work?: Work[]; reviews: Review[] };
183
- export type EvidenceFile = { file: string; tranche: string; data: Evidence | null; error?: string };
184
-
185
- /** Files in the records folder that are neither a tranche's evidence.yaml nor a data file (a report): authored documents
186
- * or code would otherwise sit in a folder no other profile judges. Loose files directly under the root count too. */
187
- export async function strayFiles(root: string, policy: DeliveryPolicy): Promise<string[]> {
188
- const dir = join(root, policy.root), out: string[] = [];
189
- if (!isFolder(dir)) return out;
190
- const walk = async (path: string, depth: number): Promise<void> => {
191
- for (const entry of await readdir(path, { withFileTypes: true })) {
192
- const child = join(path, entry.name), rel = `${policy.root}/${child.slice(dir.length + 1).replaceAll("\\", "/")}`;
193
- if (entry.isSymbolicLink() && depth === 0) continue; // a symlinked tranche folder is reported by loadEvidence
194
- if (entry.isSymbolicLink()) { out.push(rel); continue; } // a symlinked file points outside what any rule judges
195
- if (entry.isDirectory()) await walk(child, depth + 1);
196
- else if (depth === 0 || !(entry.name === "evidence.yaml" && depth === 1 || REPORT_EXTENSION.test(entry.name))) out.push(rel);
197
- }
198
- };
199
- await walk(dir, 0);
200
- return out.sort();
201
- }
202
-
203
- /** Data files inside tranche folders (depth 1 and below, other than evidence.yaml): candidate reports. */
204
- async function dataFiles(root: string, policy: DeliveryPolicy): Promise<string[]> {
205
- const dir = join(root, policy.root), out: string[] = [];
206
- if (!isFolder(dir)) return out;
207
- const walk = async (path: string, depth: number): Promise<void> => {
208
- for (const entry of await readdir(path, { withFileTypes: true })) {
209
- const child = join(path, entry.name);
210
- if (entry.isSymbolicLink()) continue;
211
- if (entry.isDirectory()) await walk(child, depth + 1);
212
- else if (depth >= 1 && !(entry.name === "evidence.yaml" && depth === 1) && REPORT_EXTENSION.test(entry.name)) out.push(`${policy.root}/${child.slice(dir.length + 1).replaceAll("\\", "/")}`);
213
- }
214
- };
215
- await walk(dir, 0);
216
- return out;
217
- }
218
-
219
- /** Every tranche folder under the delivery root with its parsed evidence, or why it has none. */
220
- export async function loadEvidence(root: string, policy: DeliveryPolicy): Promise<EvidenceFile[]> {
221
- const dir = join(root, policy.root);
222
- if (!isFolder(dir)) return [];
223
- const out: EvidenceFile[] = [];
224
- for (const entry of (await readdir(dir, { withFileTypes: true })).filter(entry => entry.isDirectory() || entry.isSymbolicLink()).sort((a, b) => a.name.localeCompare(b.name))) {
225
- // A symlinked tranche folder could point anywhere, and would otherwise be skipped unread.
226
- if (entry.isSymbolicLink()) { out.push({ file: `${policy.root}/${entry.name}`, tranche: entry.name, data: null, error: `${policy.root}/${entry.name} is a symlink; a tranche folder is a real folder holding its own evidence.yaml` }); continue; }
227
- const file = `${policy.root}/${entry.name}/evidence.yaml`, path = join(root, file);
228
- if (!existsSync(path)) { out.push({ file, tranche: entry.name, data: null, error: `tranche folder ${policy.root}/${entry.name} has no evidence.yaml` }); continue; }
229
- try { out.push({ file, tranche: entry.name, data: Bun.YAML.parse(await readFile(path, "utf8")) as Evidence }); }
230
- catch (error) { out.push({ file, tranche: entry.name, data: null, error: `could not parse ${file}: ${String(error)}` }); }
231
- }
232
- return out;
233
- }
234
-
235
- /** A model must be one the stage allows: its list is the set of allowed models, in no order, and "*" allows any. Which
236
- * listed model does the work is the operator's choice at any moment, so no fallback has to explain it; the record still
237
- * names the model that ran, and reviewer independence is judged separately (delivery.reviewer-independent). */
238
- function checkModel(file: string, at: string, stage: Stage, role: "writer" | "reviewer", actor: Actor, list: string[], _fallbacks: Fallback[], _defaultRole: "writer" | "reviewer", _fallback: DeliveryPolicy["fallback"], _ineligible: string[] = []): PlanFinding[] {
239
- if (list.includes("*") || list.includes(actor.model)) return [];
240
- return [finding("delivery.model-allowed", file, `${at}: ${role} model '${actor.model}' is not allowed for ${stage}.${role} [${list.join(", ")}]`)];
241
- }
242
-
243
- /** The count and window of every recorded fallback are the driver's claims, but explicit ones: outside the policy is out of policy. */
244
- function checkFallbacks(file: string, at: string, fallbacks: Fallback[], fallback: DeliveryPolicy["fallback"]): PlanFinding[] {
245
- const out: PlanFinding[] = [];
246
- for (const entry of fallbacks) {
247
- // The operator ordered the substitution: nothing was counted, and the schema has required the ruling it names.
248
- if (entry.kind === "operator" || !entry.window || entry.failures === undefined) continue;
249
- if (entry.failures < fallback.after_failures) out.push(finding("delivery.model-allowed", file, `${at}: fallback from '${entry.from}' after ${entry.failures} failure(s); the policy requires ${fallback.after_failures} (delivery.fallback.after_failures)`));
250
- const span = (Date.parse(entry.window.to) - Date.parse(entry.window.from)) / 60_000;
251
- if (!(span >= 0)) out.push(finding("delivery.model-allowed", file, `${at}: fallback from '${entry.from}' has a window that ends before it starts`));
252
- else if (span > fallback.within_minutes) out.push(finding("delivery.model-allowed", file, `${at}: fallback from '${entry.from}' counts failures over ${Math.round(span)} minutes; the policy allows ${fallback.within_minutes} (delivery.fallback.within_minutes)`));
253
- }
254
- return out;
255
- }
256
-
257
- /** The stages a review of `stage` answers for: its own, and every stage after the previous reviewed one (the final review
258
- * covers red, green and refactor when none of them is reviewed). A stage with nothing written records nothing; when no
259
- * work is recorded in that range, the review answers for the latest recorded work before it, so a plan-only final review
260
- * is still independent of the plan's writer. */
261
- function coveredStages(stage: Stage, policy: DeliveryPolicy, work: Work[]): Stage[] {
262
- const position = STAGES.indexOf(stage);
263
- const previous = STAGES.slice(0, position).findLastIndex(name => (policy.stages[name]?.reviewer.length ?? 0) > 0);
264
- const range = STAGES.slice(previous + 1, position + 1);
265
- if (work.some(entry => range.includes(entry.stage))) return range;
266
- const latest = STAGES.slice(0, previous + 1).findLast(name => work.some(entry => entry.stage === name));
267
- return latest ? [latest] : range;
268
- }
269
-
270
- /** Reports are data, never code: a report path is exempt from drift, so it must not be able to name a source file. */
271
- const REPORT_EXTENSION = /\.(json|jsonl|yaml|yml|txt|md|log)$/;
272
- /** The reports a record names, read defensively: this runs before schema validation, so a malformed record (reviews not
273
- * a list, an entry not a mapping, a report not a string) names nothing and is left to the schema rule to report. */
274
- const namedReports = (data: unknown): string[] => {
275
- const reviews = record(data)?.reviews;
276
- return Array.isArray(reviews) ? reviews.flatMap(review => { const report = record(review)?.report; return typeof report === "string" && report ? [report] : []; }) : [];
277
- };
278
- const isFolder = (path: string) => { try { return lstatSync(path).isDirectory(); } catch { return false; } };
279
- /** A folder, through a symlink too: the legacy and hidden-records probes look wherever records could be. */
280
- const isLink = (path: string) => { try { return lstatSync(path).isSymbolicLink(); } catch { return false; } };
281
- /** Whether any folder on the way to `relative` (below `root`) is a symlink. */
282
- const throughLink = (root: string, relative: string) => relative.split("/").some((_, i, parts) => isLink(join(root, ...parts.slice(0, i + 1))));
283
- /** Two paths that resolve to one folder (a compatibility symlink to the root): its records are the root's, not outside it. */
284
- const sameFolder = (a: string, b: string) => { try { return realpathSync(a) === realpathSync(b); } catch { return false; } };
285
- const reachesFolder = (path: string) => { try { return statSync(path).isDirectory(); } catch { return false; } };
286
- const regularFile = (path: string) => { try { return lstatSync(path).isFile(); } catch { return false; } };
287
-
288
- /** Two spellings of one commit: an abbreviated SHA is a prefix of the full one. */
289
- const sameCommit = (a: string, b: string) => a.length <= b.length ? b.startsWith(a) : a.startsWith(b);
290
-
291
- /** The judgement over one well-formed record. */
292
- function checkEvidence(file: string, evidence: Evidence, policy: DeliveryPolicy): PlanFinding[] {
293
- const out: PlanFinding[] = [], reviews = evidence.reviews, work = evidence.work ?? [];
294
- const writerRuns = new Set([...work.map(entry => entry.writer.run), ...reviews.flatMap(review => review.author ? [review.author.run] : [])]), reviewerRuns = new Map<string, number>();
295
- work.forEach((entry, index) => {
296
- const at = `work[${index}] (${entry.stage} @ ${entry.sha})`, stage = policy.stages[entry.stage];
297
- if (!stage?.writer.length) { out.push(finding("delivery.evidence-schema", file, `${at}: ${entry.stage} has no writer in delivery.stages`)); return; }
298
- out.push(...checkFallbacks(file, at, entry.fallback ?? [], policy.fallback), ...checkModel(file, at, entry.stage, "writer", entry.writer, stage.writer, entry.fallback ?? [], "writer", policy.fallback));
299
- });
300
- reviews.forEach((review, index) => {
301
- const at = `reviews[${index}] (${review.stage} @ ${review.sha})`, stage = policy.stages[review.stage];
302
- if (!stage?.reviewer.length) { out.push(finding("delivery.evidence-schema", file, `${at}: ${review.stage} has no reviewer in delivery.stages`)); return; }
303
- const fallbacks = review.fallback ?? [];
304
- // Independence is judged against everyone who wrote what this review answers for.
305
- const covered = coveredStages(review.stage, policy, work), writers = [...work.filter(entry => covered.includes(entry.stage)).map(entry => entry.writer), ...(review.author ? [review.author] : [])];
306
- const ineligible = stage.independence === "different-model" ? writers.map(writer => writer.model) : [];
307
- out.push(...checkFallbacks(file, at, fallbacks, policy.fallback), ...checkModel(file, at, review.stage, "reviewer", review.reviewer, stage.reviewer, fallbacks, "reviewer", policy.fallback, ineligible));
308
- if (review.author && stage.writer.length) out.push(...checkModel(file, at, review.stage, "writer", review.author, stage.writer, fallbacks, "reviewer", policy.fallback));
309
- if (!writers.length) out.push(finding("delivery.evidence-schema", file, `${at}: names no writer of the work it reviews (${covered.join(", ")}); record each stage's work, or the review's author, so independence can be judged`));
310
- if (writerRuns.has(review.reviewer.run)) out.push(finding("delivery.reviewer-independent", file, `${at}: reviewer run '${review.reviewer.run}' also wrote in this tranche; a reviewer that edits becomes a writer`));
311
- if (reviewerRuns.has(review.reviewer.run)) out.push(finding("delivery.reviewer-independent", file, `${at}: reviewer run '${review.reviewer.run}' already reviewed reviews[${reviewerRuns.get(review.reviewer.run)}]; every review is a fresh process`));
312
- else reviewerRuns.set(review.reviewer.run, index);
313
- const own = writers.find(writer => writer.model === review.reviewer.model);
314
- if (stage.independence === "different-model" && own) out.push(finding("delivery.reviewer-independent", file, `${at}: ${review.stage} requires a different model, but '${review.reviewer.model}' reviewed work its own model wrote`));
315
- });
316
-
317
- // Findings: each one on a request-changes review is fixed, withdrawn after one written dispute, or ruled on by a
318
- // human, once the stage has moved on (a later review of it exists) or the record is ready.
319
- reviews.forEach((review, index) => {
320
- if (review.verdict === "approve") {
321
- for (const item of review.findings ?? []) if (item.severity === "critical" || item.severity === "high") out.push(finding("delivery.findings-resolved", file, `reviews[${index}] (${review.stage}) approves with ${item.severity} finding ${item.id}; a critical or high finding requests changes`));
322
- return;
323
- }
324
- const later = reviews.slice(index + 1).filter(next => next.stage === review.stage);
325
- for (const item of review.findings ?? []) {
326
- const at = `reviews[${index}] (${review.stage}) finding ${item.id}`, upheld = later.some(next => (next.findings ?? []).some(other => other.id === item.id));
327
- if (!item.outcome) { if (later.length || evidence.status === "ready") out.push(finding("delivery.findings-resolved", file, `${at} has no outcome; record fixed, withdrawn (after a rebuttal) or human (with the decision)`)); continue; }
328
- if (item.outcome !== "human" && !later.length) out.push(finding("delivery.findings-resolved", file, `${at} is ${item.outcome}, but no later ${review.stage} confirms it; a fresh reviewer re-reviews every repair and every dispute`));
329
- if (item.outcome === "withdrawn" && !item.rebuttal) out.push(finding("delivery.findings-resolved", file, `${at} is withdrawn without a rebuttal; only a written dispute with evidence can withdraw a finding`));
330
- if (item.outcome === "withdrawn" && upheld) out.push(finding("delivery.findings-resolved", file, `${at} is withdrawn, but a later ${review.stage} raises it again; an upheld dispute goes to a human`));
331
- if (item.outcome === "human" && !item.decision) out.push(finding("delivery.findings-resolved", file, `${at} is resolved by a human but records no decision`));
332
- }
333
- });
334
- const disputes = new Map<string, { rounds: number; human: boolean; stage: Stage }>();
335
- for (const review of reviews) for (const item of review.findings ?? []) {
336
- const key = `${review.stage}/${item.id}`, entry = disputes.get(key) ?? { rounds: 0, human: false, stage: review.stage };
337
- entry.rounds += item.rebuttal ? 1 : 0; entry.human ||= item.outcome === "human"; disputes.set(key, entry);
338
- }
339
- for (const [key, entry] of disputes) if (entry.rounds > 1 && !entry.human) out.push(finding("delivery.findings-resolved", file, `finding ${key} was disputed ${entry.rounds} times; after one round a disputed finding goes to a human (outcome: human, with the decision)`));
340
-
341
- if (evidence.status === "ready") {
342
- const configured = STAGES.filter(stage => policy.stages[stage]);
343
- for (const stage of configured) {
344
- if (!policy.stages[stage]!.reviewer.length) continue;
345
- const last = reviews.filter(review => review.stage === stage).at(-1);
346
- if (!last) out.push(finding("delivery.stages-complete", file, `status is ready, but ${stage} has no review`));
347
- else if (last.verdict !== "approve") out.push(finding("delivery.stages-complete", file, `status is ready, but the last ${stage} review requests changes`));
348
- }
349
- const closing = configured.filter(stage => policy.stages[stage]!.reviewer.length).at(-1), approval = closing && reviews.filter(review => review.stage === closing).at(-1);
350
- if (approval && approval.verdict === "approve" && !sameCommit(approval.sha, evidence.approved_sha ?? "")) out.push(finding("delivery.stages-complete", file, `approved_sha ${evidence.approved_sha} is not the SHA the last ${closing} review approved (${approval.sha})`));
351
- }
352
- return out;
353
- }
354
-
355
- export type GateMode = "merge" | "post-merge";
356
- /** The gate: ATDD_DELIVERY_GATE, which the generated CI sets to `merge` on pull requests and the merge queue and to
357
- * `post-merge` on pushes to the base branch. Explicit rather than inferred from the CI event, so a repository's own
358
- * tests are never judged as tranches. */
359
- export function gateMode(env: Record<string, string | undefined> = process.env): GateMode | null {
360
- return env.ATDD_DELIVERY_GATE === "merge" || env.ATDD_DELIVERY_GATE === "post-merge" ? env.ATDD_DELIVERY_GATE : null;
361
- }
362
-
363
- /** The commits the gate compares: a merge checkout (pull request, merge queue) is judged as base = first parent,
364
- * head = second parent; otherwise HEAD against the configured base ref. */
365
- async function gateRange(root: string, mode: GateMode, base?: string): Promise<{ base: string; head: string } | null> {
366
- // After the merge, the pushed commit is judged against its first parent: what this push brought in.
367
- // With the push's before-SHA (the generated CI passes it as ATDD_BASE_REF), everything the push brought in is judged,
368
- // not only its last commit; a new branch's first push (all zeros) falls back to the first parent.
369
- if (mode === "post-merge") {
370
- const head = (await git(root, ["rev-parse", "HEAD"])).out, before = base ?? process.env.ATDD_BASE_REF;
371
- if (before && !/^0+$/.test(before)) {
372
- // An explicit baseline that cannot be resolved fails closed, as the integrity check does, rather than narrowing.
373
- const resolved = await git(root, ["rev-parse", "--verify", "--quiet", `${before}^{commit}`]);
374
- return resolved.code || !resolved.out ? null : { base: resolved.out, head };
375
- }
376
- const parent = await git(root, ["rev-parse", "--verify", "--quiet", "HEAD^1"]);
377
- return parent.code || !parent.out ? null : { base: parent.out, head };
378
- }
379
- // An explicit base wins: a branch that merged its base in locally is still judged against that base.
380
- const explicit = base ?? process.env.ATDD_BASE_REF;
381
- if (explicit && !/^0+$/.test(explicit)) return (await git(root, ["rev-parse", "--verify", "--quiet", `${explicit}^{commit}`])).code ? null : againstRef(root, explicit);
382
- // CI checks a pull request out as a merge commit: first parent the base, second the tranche head.
383
- const merge = await git(root, ["rev-parse", "--verify", "--quiet", "HEAD^2"]);
384
- if (!merge.code && merge.out) return { base: (await git(root, ["rev-parse", "HEAD^1"])).out, head: merge.out };
385
- const ref = process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : "origin/HEAD";
386
- return (await git(root, ["rev-parse", "--verify", "--quiet", ref])).code ? null : againstRef(root, ref);
387
- }
388
-
389
- async function againstRef(root: string, ref: string): Promise<{ base: string; head: string } | null> {
390
- const since = (await git(root, ["merge-base", "HEAD", ref])).out, head = (await git(root, ["rev-parse", "HEAD"])).out;
391
- return since && head ? { base: since, head } : null;
392
- }
393
-
394
- /** `gate: true` is the pre-merge gate; `false` disables it; unset reads ATDD_DELIVERY_GATE. `since` (unset: reads
395
- * ATDD_DELIVERY_SINCE, which only the pre-push hook sets) scopes a run outside the gate as the gate is scoped: a record
396
- * unchanged since that commit is not judged again. An unresolvable commit scopes nothing, so every record is judged. */
397
- export type DeliveryOptions = { gate?: boolean | GateMode; base?: string; since?: string };
398
-
399
- export async function validateDelivery(root = process.cwd(), options: DeliveryOptions = {}): Promise<PlanFinding[]> {
400
- const absolute = resolve(root), config = await readConfig(absolute);
401
- if (config.error) return [finding("delivery.config-schema", "atdd-bun.yaml", `atdd-bun.yaml could not be parsed, so the delivery policy cannot be read: ${config.error}`)];
402
- if (!deliveryAdopted(config.data)) return [];
403
- const findings: PlanFinding[] = [], validConfig = await schema("delivery-config.schema.json"), block = "delivery" in config.data! ? config.data!.delivery : {};
404
- let policy = deliveryPolicy(block);
405
- if (!validConfig(block)) {
406
- for (const error of validConfig.errors ?? []) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery${error.instancePath.replaceAll("/", ".")} ${error.message ?? error.keyword}`));
407
- policy = deliveryPolicy({});
408
- }
409
- // The root is an evidence namespace only. Overlapping a plan, source, test, e2e or telemetry root would let a product
410
- // file be labelled a report and escape review; such a root is reported, and the default is used instead.
411
- const topology = await topologyFor(absolute), owned = [topology.planRoot, topology.sourceRoot, topology.testRoot, topology.e2eRoot, topology.telemetryRoot];
412
- const overlap = owned.find(other => policy.root === other || policy.root.startsWith(`${other}/`) || other.startsWith(`${policy.root}/`));
413
- if (overlap) {
414
- findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery.root ${policy.root} overlaps the ${overlap} root; the delivery root holds only records and reports`));
415
- policy = { ...policy, root: DEFAULT_ROOT };
416
- } else if (inDocs(policy.root) && policy.root !== DEFAULT_ROOT) {
417
- // The docs profile gives up exactly one folder under docs/; any other root there would take authored documentation
418
- // out of its rules.
419
- findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery.root ${policy.root} is inside docs/; the only delivery root there is ${DEFAULT_ROOT}, the one folder the docs profile leaves to delivery`));
420
- policy = { ...policy, root: DEFAULT_ROOT };
421
- }
422
- // 0.8.0 kept records under delivery/ by default. Records left there under any other effective root would be unseen.
423
- if (policy.root !== LEGACY_ROOT && reachesFolder(join(absolute, LEGACY_ROOT)) && !sameFolder(join(absolute, LEGACY_ROOT), join(absolute, policy.root))) {
424
- const legacy = (await readdir(join(absolute, LEGACY_ROOT), { withFileTypes: true })).filter(entry => entry.isDirectory() && existsSync(join(absolute, LEGACY_ROOT, entry.name, "evidence.yaml")));
425
- if (legacy.length) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `tranche records under delivery/ (${legacy.map(entry => entry.name).join(", ")}) are outside the root ${policy.root}; 0.8.0 kept them there by default; set delivery.root: delivery (a root change the integrity check reports for a human to approve once), or move them there, which rewrites merged records and so needs a human-supervised merge`));
426
- }
427
- // The other direction: records under the default root while another root is configured are outside what is judged.
428
- if (policy.root !== DEFAULT_ROOT && reachesFolder(join(absolute, DEFAULT_ROOT)) && !sameFolder(join(absolute, DEFAULT_ROOT), join(absolute, policy.root))) {
429
- const hidden = (await readdir(join(absolute, DEFAULT_ROOT), { withFileTypes: true })).filter(entry => entry.isDirectory() && existsSync(join(absolute, DEFAULT_ROOT, entry.name, "evidence.yaml")));
430
- if (hidden.length) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `tranche records under ${DEFAULT_ROOT} (${hidden.map(entry => entry.name).join(", ")}) are outside the configured root ${policy.root}; move them there, or remove delivery.root`));
431
- }
432
- const validEvidence = await schema("delivery-evidence.schema.json"), files = await loadEvidence(absolute, policy);
433
- const mode = options.gate === undefined ? gateMode() : options.gate === true ? "merge" : options.gate || null;
434
- // At the gate, a record the change does not touch was judged when it merged. It is not judged again, against a
435
- // later policy, schema or history: records are append-only and could never be repaired, so one tightening would
436
- // fail every later change. Outside the gate every record is judged, unless a `since` commit scopes the run the same
437
- // way (the pre-push hook). The gate itself still rejects any change to an untouched record's folder (mergeGate).
438
- const since = options.since ?? process.env.ATDD_DELIVERY_SINCE;
439
- const onBase = mode ? (await gateRange(absolute, mode, options.base))?.base ?? null
440
- : since ? (await git(absolute, ["rev-parse", "--verify", "--quiet", `${since}^{commit}`])).out || null : null;
441
- // At the gate, like records, a stray the change does not touch was there when it merged and is not judged again.
442
- // A data file belongs only as a report some record in its tranche names; an unnamed one is authored content by another name.
443
- // Per tranche: a data file is a report only when a record in its own tranche names it.
444
- const reported = new Set(files.flatMap(entry => namedReports(entry.data).filter(report => report.startsWith(`${policy.root}/${entry.tranche}/`))));
445
- const unnamed = (await dataFiles(absolute, policy)).filter(path => !reported.has(path));
446
- const rootPath = join(absolute, policy.root);
447
- if (existsSync(rootPath) || isLink(rootPath)) {
448
- if (isLink(rootPath) || throughLink(absolute, policy.root)) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery.root ${policy.root} is, or is reached through, a symlink; the delivery root must be a real folder, whose records Git tracks`));
449
- else if (!isFolder(rootPath)) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery.root ${policy.root} is a file, not a folder; the records cannot be read`));
450
- }
451
- for (const path of [...await strayFiles(absolute, policy), ...unnamed].sort()) if (!onBase || (await git(absolute, ["diff", "--quiet", onBase, "--", path])).code) findings.push(finding("delivery.evidence-schema", path, `${path} is neither a tranche's evidence.yaml nor a report a record in its tranche names (a data file: ${REPORT_EXTENSION.source.slice(3, -2).replaceAll("|", ", ")}); the records folder holds only records and their reports`));
452
- for (const entry of files) {
453
- if (onBase && existsSync(join(absolute, entry.file)) && !(await git(absolute, ["diff", "--quiet", onBase, "--", `${policy.root}/${entry.tranche}`])).code) continue;
454
- if (!entry.data) { findings.push(finding("delivery.evidence-schema", entry.file, entry.error ?? "evidence is missing")); continue; }
455
- if (!validEvidence(entry.data)) {
456
- for (const error of validEvidence.errors ?? []) findings.push(finding("delivery.evidence-schema", entry.file, `${entry.file} violates delivery-evidence.schema.json: ${error.instancePath || "/"} ${error.message ?? error.keyword}`));
457
- continue;
458
- }
459
- // Legacy stage names (plan_review, …) are read as the stages they reviewed.
460
- for (const item of [...entry.data.reviews, ...(entry.data.work ?? [])]) item.stage = stageOf(item.stage) ?? item.stage;
461
- if (entry.data.tranche !== entry.tranche) findings.push(finding("delivery.evidence-schema", entry.file, `tranche '${entry.data.tranche}' does not match its folder '${entry.tranche}'`));
462
- // A report lives in its tranche's folder: a report path is exempt from drift, so it may never name other files.
463
- const folder = `${policy.root}/${entry.tranche}/`;
464
- const seen = new Map<string, number>();
465
- for (const [index, review] of entry.data.reviews.entries()) {
466
- if (review.report === undefined) continue;
467
- if (!(review.report.startsWith(folder) && review.report !== entry.file && review.report.split("/").every(part => part && part !== "." && part !== "..") && REPORT_EXTENSION.test(review.report)))
468
- findings.push(finding("delivery.evidence-schema", entry.file, `reviews[${index}] report ${review.report} must be a data file (${REPORT_EXTENSION.source.slice(3, -2).replaceAll("|", ", ")}) inside ${folder}, other than evidence.yaml`));
469
- // Each review retains its own raw output: one file cannot stand for several reviews.
470
- 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`));
471
- else seen.set(review.report, index);
472
- }
473
- const judged = entry.data.status === "ready" && entry.data.approved_sha ? await policyAt(absolute, entry.data.approved_sha, policy) : policy;
474
- findings.push(...checkEvidence(entry.file, entry.data, judged));
475
- if (entry.data.status === "ready") {
476
- // Ready means auditable: every review's raw output is retained and named.
477
- 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"}`));
478
- if ((await git(absolute, ["cat-file", "-e", `${entry.data.approved_sha}^{commit}`])).code) { findings.push(finding("delivery.approved-sha-resolves", entry.file, `approved_sha ${entry.data.approved_sha} is not a commit in this repository's history`)); continue; }
479
- // Every stage's approval and every recorded piece of work names a real commit in the approved history, and
480
- // approvals follow the lifecycle.
481
- const approved = entry.data.approved_sha!, inHistory = async (sha: string) => !(await git(absolute, ["merge-base", "--is-ancestor", sha, approved])).code;
482
- for (const [index, item] of (entry.data.work ?? []).entries()) {
483
- if ((await git(absolute, ["cat-file", "-e", `${item.sha}^{commit}`])).code) findings.push(finding("delivery.approved-sha-resolves", entry.file, `work[${index}] (${item.stage}) names ${item.sha}, which is not a commit in this repository's history`));
484
- 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)}`));
485
- }
486
- let previous: { stage: Stage; sha: string } | null = null;
487
- for (const stage of STAGES.filter(name => judged.stages[name]?.reviewer.length)) {
488
- const last = entry.data.reviews.filter(review => review.stage === stage).at(-1);
489
- if (!last || last.verdict !== "approve") continue; // reported by delivery.stages-complete
490
- 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; }
491
- if (!await inHistory(last.sha)) { findings.push(finding("delivery.approved-sha-resolves", entry.file, `${stage} approved ${last.sha.slice(0, 7)}, which is not in the history of approved_sha ${approved.slice(0, 7)}`)); continue; }
492
- if (previous && (await git(absolute, ["merge-base", "--is-ancestor", previous.sha, last.sha])).code) findings.push(finding("delivery.approved-sha-resolves", entry.file, `${stage} approved ${last.sha.slice(0, 7)}, which does not contain what ${previous.stage} approved (${previous.sha.slice(0, 7)}); stages follow the lifecycle`));
493
- previous = { stage, sha: last.sha };
494
- }
495
- }
496
- }
497
- if (mode) findings.push(...await mergeGate(absolute, policy, files, mode, options.base));
498
- return findings;
499
- }
500
-
501
- /** Every record the branch changes must be ready and approve a commit the head contains, the head may differ from
502
- * that commit only under the delivery root, and (require_record) a change outside the root needs a record at all.
503
- * Post-merge, the pushed commit must still contain the approved one: a squash or rebase merge rewrites it. */
504
- async function mergeGate(root: string, policy: DeliveryPolicy, files: EvidenceFile[], mode: GateMode, base?: string): Promise<PlanFinding[]> {
505
- const range = await gateRange(root, mode, base);
506
- if (!range) return [finding("delivery.merge-gate", "atdd-bun.yaml", "the merge gate cannot resolve the base branch; fetch full history (fetch-depth: 0) or set ATDD_BASE_REF")];
507
- const span = mode === "merge" ? [`${range.base}...${range.head}`] : [range.base, range.head];
508
- const changedHere = (await git(root, ["diff", "--no-renames", "--relative", "--name-only", ...span])).out.split("\n").filter(Boolean);
509
- // A record is exactly <root>/<tranche>/evidence.yaml and was loaded: an evidence.yaml at any other depth, or a
510
- // deleted one (reported below), never counts as the record that covers a change.
511
- const isRecord = (path: string) => path.split("/").length === policy.root.split("/").length + 2 && path.startsWith(`${policy.root}/`) && path.endsWith("/evidence.yaml");
512
- const changed = changedHere.filter(path => isRecord(path) && files.some(file => file.file === path && file.data)), outside = changedHere.filter(path => !path.startsWith(`${policy.root}/`));
513
- // Drift is judged over the whole repository, not just this root: a change anywhere after approval counts.
514
- const prefix = (await git(root, ["rev-parse", "--show-prefix"])).out, out: PlanFinding[] = [];
515
- const inRange = new Set((await git(root, ["diff", "--no-renames", "--name-only", ...span])).out.split("\n").filter(Boolean));
516
- const deleted = (await git(root, ["diff", "--no-renames", "--relative", "--name-only", "--diff-filter=D", ...span, "--", policy.root])).out.split("\n").filter(path => path.endsWith("/evidence.yaml"));
517
- for (const path of deleted) out.push(finding("delivery.merge-gate", path, `${mode === "merge" ? "the branch" : "this push"} deletes ${path}; records are append-only, and deleting one would escape its findings and the gate`));
518
- // A record or report already on the base branch is final: editing an old record would make its reports "named by a
519
- // changed record" and so exempt from drift. A later change to a tranche is a new tranche with its own record.
520
- const final = (await git(root, ["diff", "--no-renames", "--relative", "--name-only", "--diff-filter=MRTC", ...span, "--", policy.root])).out.split("\n").filter(Boolean);
521
- // Records 0.8.0 kept under delivery/ stay append-only after the root moves: deleting or editing one is reported even
522
- // though it is outside the current root. Only exact <tranche>/evidence.yaml paths, so an unrelated delivery/ folder
523
- // in the project is not touched.
524
- if (policy.root !== LEGACY_ROOT) {
525
- const legacy = (await git(root, ["diff", "--no-renames", "--relative", "--name-only", "--diff-filter=DMRTC", ...span, "--", LEGACY_ROOT])).out.split("\n").filter(path => /^delivery\/[^/]+\/evidence\.yaml$/.test(path));
526
- for (const path of legacy) out.push(finding("delivery.merge-gate", path, `${mode === "merge" ? "the branch" : "this push"} ${existsSync(join(root, path)) ? "modifies" : "deletes"} ${path}, a record 0.8.0 kept under delivery/; records stay append-only when the root moves`));
527
- }
528
- for (const path of final) out.push(finding("delivery.merge-gate", path, `${mode === "merge" ? "the branch" : "this push"} modifies ${path}, which is already on the base branch; merged records and reports are final, so a later change needs a new tranche`));
529
- // Under the root, only records and the reports a changed record names may change: anything else is unbound.
530
- const named = new Set(changed.flatMap(path => namedReports(files.find(file => file.file === path)?.data)));
531
- for (const path of changedHere.filter(path => path.startsWith(`${policy.root}/`) && !isRecord(path) && !named.has(path)))
532
- out.push(finding("delivery.merge-gate", path, `${mode === "merge" ? "the branch" : "this push"} changes ${path} under ${policy.root}/, and no record it changes names it as a report; only <tranche>/evidence.yaml records and their reports live there`));
533
- // A change to the policy alone needs no tranche: the integrity check reports any loosening for a human to approve,
534
- // and a tightening needs no review. A policy change that comes with anything else is reviewed with it.
535
- const reviewable = outside.filter(path => path !== "atdd-bun.yaml");
536
- if (policy.require_record && reviewable.length && !changed.length) out.push(finding("delivery.merge-gate", "atdd-bun.yaml", `${mode === "merge" ? "the branch" : "this push"} changes ${outside.slice(0, 5).join(", ")}${outside.length > 5 ? ` and ${outside.length - 5} more` : ""} with no tranche record under ${policy.root}/; every change merges through a reviewed tranche (delivery.require_record)`));
537
- // Every changed ready record, its reports, and the approved SHAs that may cover each other's files.
538
- const readyChanged = changed.map(path => files.find(file => file.file === path)!).filter(entry => entry.data?.status === "ready");
539
- const siblings = readyChanged.map(entry => entry.data!.approved_sha!).filter(Boolean);
540
- const allEvidence = new Set(readyChanged.flatMap(entry => [entry.file, ...entry.data!.reviews.flatMap(review => review.report ? [review.report] : [])]).map(file => `${prefix}${file}`));
541
- for (const path of changed) {
542
- const entry = files.find(file => file.file === path);
543
- if (!entry?.data) continue; // deleted (reported above), or already reported by the schema rule
544
- if (entry.data.status !== "ready") { out.push(finding("delivery.merge-gate", path, `${path} is ${entry.data.status}; a tranche merges only when its record is ready`)); continue; }
545
- const sha = entry.data.approved_sha!;
546
- if ((await git(root, ["cat-file", "-e", `${sha}^{commit}`])).code) continue; // reported by delivery.approved-sha-resolves
547
- if ((await git(root, ["merge-base", "--is-ancestor", sha, range.head])).code) { out.push(finding("delivery.merge-gate", path, `approved_sha ${sha.slice(0, 7)} is not contained in ${mode === "merge" ? "the branch head" : "the merged commit"}${mode === "post-merge" ? "; a squash or rebase merge rewrites the approved commit, so merge with a merge commit" : "; the branch was rewritten after approval"}`)); continue; }
548
- // Only the record and the reports it names may postdate the approval; anything else under the root is drift too.
549
- const exempt = new Set([path, ...entry.data.reviews.flatMap(review => review.report ? [review.report] : [])].map(file => `${prefix}${file}`));
550
- // Drift: what this change brings in that differs from the approved commit. After a merge commit, the tranche's
551
- // side is the second parent; a direct push is judged at its head. Intersecting with the change's own files keeps
552
- // other tranches merged since the approval out of it.
553
- const merged = mode === "post-merge" ? await git(root, ["rev-parse", "--verify", "--quiet", `${range.head}^2`]) : { code: 1, out: "" };
554
- const tip = !merged.code && merged.out && !(await git(root, ["merge-base", "--is-ancestor", sha, merged.out])).code ? merged.out : range.head;
555
- const candidates = (await git(root, ["diff", "--no-renames", "--name-only", sha, tip])).out.split("\n").filter(Boolean).filter(file => inRange.has(file) && !exempt.has(file) && !allEvidence.has(file));
556
- // Several tranches may land in one change (a merge-queue batch): a file another changed ready record approved
557
- // with exactly this content is that tranche's, not drift.
558
- const drift: string[] = [];
559
- for (const file of candidates) {
560
- let covered = false;
561
- for (const other of siblings) if (other !== sha && !(await git(root, ["diff", "--quiet", other, tip, "--", `:(top)${file}`])).code) { covered = true; break; }
562
- if (!covered) drift.push(file);
563
- }
564
- if (drift.length) out.push(finding("delivery.merge-gate", path, `${mode === "merge" ? "the branch head" : "this push"} changes ${drift.slice(0, 5).join(", ")}${drift.length > 5 ? ` and ${drift.length - 5} more` : ""} after approved_sha ${sha.slice(0, 7)}; any change after approval needs a fresh review`));
565
- }
566
- return out;
567
- }
@@ -1,20 +0,0 @@
1
- ---
2
- name: atdd
3
- description: Use before writing or changing code, tests, or plan/ files in this repository. New intent is decomposed and delivered through the ATDD lifecycle PLAN → RED → GREEN → SMOKE → REFACTOR → TRACE, each stage gated by atdd-bun. Skip PLAN only when the change fits an acceptance that already exists.
4
- ---
5
- <!-- Generated by @afokapu/atdd-bun {{VERSION}}. Do not edit; run `atdd-bun agent init --replace`. -->
6
-
7
- Conventions live in `node_modules/@afokapu/atdd-bun/` (`planner-nodes/nodes/`, `conventions/`). Read the ones a stage names; do not restate them. Finish each stage by passing its gate before starting the next.
8
-
9
- Technology and architecture choices already made are the accepted ADRs listed in `docs/architecture/decisions/index.adoc`. Follow them in PLAN and GREEN; a new choice gets its own ADR under `docs/architecture/decisions/` (`planner.docs.*`).
10
-
11
- 1. PLAN — Decompose the intent into wagon → WMBT → acceptance → train/interlocking → journey → contract under `plan/`, following `planner.decomposition.*`; every WMBT declares a SMOKE acceptance. Gate: `bun run atdd-bun planner`.
12
- 2. RED — For each acceptance, write a test headed `// URN: test:{wagon}:{feature}:{ACC-ID}` and `// Phase: RED` that fails for the missing behaviour (`tester.bun.red-*`). Gate: `bun run atdd-bun tester`.
13
- 3. GREEN — Write the least code that passes; each source file carries `URN: component:{wagon}:{feature}:{Name}:{side}:{layer}` and a `Tested-By:` block (`coder.bun.green-*`). Gate: `bun test`.
14
- 4. SMOKE — Prove the SMOKE acceptance through the real entry point with no mocks or spies, asserting only on observable output: HTTP, markup, stdout, exit code (`tester.bun.smoke-*`, `planner.smoke.*`). Gate: `bun run atdd-bun tester`.
15
- 5. REFACTOR — With tests green, reduce complexity, fix layering, and remove security faults until the rules pass, without changing behaviour (`coder.bun.complexity-*`, `quality-*`, `composition-*`, `design-*`, `security-*`). Gate: `bun run atdd-bun coder security`.
16
- 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`.
17
-
18
- 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.
19
-
20
- 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.