@afokapu/atdd-bun 0.9.0 → 0.9.2

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
@@ -52,7 +52,7 @@ registerEnforcementTest({ root: import.meta.dir + "/..", profiles: ["traceabilit
52
52
  | Profile | Checks |
53
53
  |---|---|
54
54
  | `traceability` | acceptance → Bun test → source closure: every acceptance tested, every binding and `Tested-By` resolving |
55
- | `topology` | feature decomposition and the plan, source, test and E2E locations |
55
+ | `topology` | feature decomposition and the plan, source, test and E2E locations. Missing source, tests and E2E suites are reported only when a run also selects `coder` or `tester`, so a repository in the PLAN stage can enforce `planner` and `topology` before RED |
56
56
  | `planner` | schemas for every plan artifact, graph integrity, the scoped planner rules |
57
57
  | `telemetry` | the telemetry tracking plan: item shape, path-mirrored identity and versioning under `telemetry/`, wagon ownership of logical artifacts, the per-acceptance telemetry decision, metric label cardinality, source `Telemetry:` references, raw-string and forbidden-property emission, the vendor-SDK boundary around the TelemetryPort, and telemetry tests that bind the acceptance and item, assert the exact identity on a captured sink, cover every required item, and exercise declared timing semantics |
58
58
  | `delivery` | the review record of each tranche under `docs/delivery/tranches/`: allowed author and reviewer models with recorded fallbacks, reviewer independence, every finding fixed, withdrawn after one dispute or ruled on by a human, every configured stage approved, and, at the gate, no change without a record and a merged head that contains exactly the approved commit. Inert until adopted |
@@ -125,9 +125,22 @@ docs/delivery/tranches/<tranche>/evidence.yaml one tranche's review record (
125
125
  docs/delivery/tranches/<tranche>/*.json the retained raw reviewer reports
126
126
  ```
127
127
 
128
- Where delivery is adopted, the docs profile leaves the records folder to the delivery profile: its
129
- YAML and reports are not authored documentation, and changing them needs no docs declaration. Every
130
- key is optional; these are the defaults:
128
+ Where delivery is adopted, the docs profile leaves the records folder's records and data files to
129
+ the delivery profile: they are not authored documentation, and changing them needs no docs
130
+ declaration. AsciiDoc there stays documentation. Where the docs profile is active, the first tranche
131
+ also brings `docs/index.adoc` and `docs/delivery/index.adoc`, each with `:doc-id:` and `:status:`,
132
+ since every docs area needs an index.
133
+
134
+ Upgrading from 0.8.0: records under `delivery/` are reported until `delivery.root: delivery` is set
135
+ (a reported root change, approved once) or they are moved. A data file in an old tranche that no
136
+ record there names is reported on local runs, not at the merge gate; removing it is a gate change a
137
+ human approves, or a new tranche's record can name it.
138
+
139
+ Upgrading from 0.9.0: an `atdd-bun.yaml` field with the wrong type (a quoted number, `yes`/`no`, a
140
+ non-string list item, `.inf`) is now reported, and on the base branch it blocks every pull request,
141
+ since the policy cannot be compared. Correct such fields on the base branch before upgrading.
142
+
143
+ Every key is optional; these are the defaults:
131
144
 
132
145
  ```yaml
133
146
  delivery:
@@ -160,7 +173,9 @@ behind); it does not verify them
160
173
  cryptographically.
161
174
 
162
175
  The hooks enforce protected-branch blocking, micro-commit limits, mass-delete approval and
163
- validation of the affected area. Git can bypass them, so CI is the authority.
176
+ validation of the affected area. Git can bypass them, so CI is the authority. The changed-line
177
+ limit counts a moved file by the edits it carries, so relocating a directory is not measured as
178
+ rewriting it; mass-delete and registry-removal approval still count a move in full.
164
179
 
165
180
  ## Agents and integrity
166
181
 
@@ -42,6 +42,8 @@ content:
42
42
  calls, and train.sequence executor loops from JourneyRunner.
43
43
  exceptions:
44
44
  - Consumers with no plan/_journeys topology carry no JourneyRunner obligation.
45
+ - Consumers with no Station Master composition root yet (a repository in the PLAN stage) carry no
46
+ JourneyRunner obligation, as coder.bun.interlocking-runner-exists owes no InterlockingRunner before one.
45
47
  metadata:
46
48
  aliases:
47
49
  - BUN-JOURNEY-RUNNER-BOUNDARY-001
@@ -36,6 +36,8 @@ content:
36
36
  declared journey document and dispatch it through JourneyRunner.
37
37
  exceptions:
38
38
  - Journeys with entrypoint.exposed:false are intentionally not Station-Master-reachable.
39
+ - Consumers with no Station Master composition root yet (a repository in the PLAN stage) carry no
40
+ reachability obligation, as for exposed interlockings.
39
41
  metadata:
40
42
  aliases:
41
43
  - BUN-STATION-MASTER-JOURNEY-ROUTING-001
@@ -12,6 +12,12 @@ const slug = "[a-z][a-z0-9-]*";
12
12
  const componentUrn = new RegExp(`^component:(${slug}):(${slug}):[A-Za-z0-9.]+:(frontend|backend):(domain|application|integration|presentation)$`);
13
13
  const testUrn = new RegExp(`^test:(${slug}):(${slug}):([A-Za-z0-9][A-Za-z0-9._-]*)$`);
14
14
  const violations = [];
15
+ // Missing evidence is due only when a run asks for the stages that produce it. A planning run (planner or
16
+ // topology alone) still checks where every artifact lives and how every existing test binds, but it does not
17
+ // demand source, tests or E2E suites the lifecycle has not reached: RED writes the tests and GREEN the source.
18
+ // A run that names no profiles (a direct call) keeps the old, fail-closed behaviour.
19
+ const requestedProfiles = process.env.ATDD_PROFILES ? JSON.parse(process.env.ATDD_PROFILES) : null;
20
+ const evidenceDue = !Array.isArray(requestedProfiles) || requestedProfiles.some(profile => ["coder", "tester", "all"].includes(profile));
15
21
  const rel = (root, path) => relative(root, path).split(sep).join("/");
16
22
  const read = path => { try { return readFileSync(path, "utf8"); } catch { return ""; } };
17
23
  const text = (data, key) => data && typeof data === "object" && !Array.isArray(data) && typeof data[key] === "string" ? data[key] : "";
@@ -104,8 +110,8 @@ for (const root of roots) {
104
110
  }
105
111
  for (const feature of features) if (list(feature.data, "wmbts").length) {
106
112
  const key = `${feature.wagon}:${feature.slug}`;
107
- if (!sourceByFeature.get(key)?.length) add("atdd-bun.topology.feature-source-coverage", root, feature.path, `${feature.urn} owns WMBTs but has no source component beneath ${cfg.source_root}/${feature.wagon}/features/${feature.slug}/`);
108
- if (!testByFeature.get(key)?.length) add("atdd-bun.topology.feature-test-coverage", root, feature.path, `${feature.urn} owns WMBTs but has no test bound to one of its acceptances`);
113
+ if (evidenceDue && !sourceByFeature.get(key)?.length) add("atdd-bun.topology.feature-source-coverage", root, feature.path, `${feature.urn} owns WMBTs but has no source component beneath ${cfg.source_root}/${feature.wagon}/features/${feature.slug}/`);
114
+ if (evidenceDue && !testByFeature.get(key)?.length) add("atdd-bun.topology.feature-test-coverage", root, feature.path, `${feature.urn} owns WMBTs but has no test bound to one of its acceptances`);
109
115
  }
110
116
  const e2eRoot = join(root, cfg.e2e_root);
111
117
  // A browser test is the E2E proof for a frontend behavior. It deliberately
@@ -148,7 +154,7 @@ for (const root of roots) {
148
154
  const expected = join(e2eRoot, "interlockings", id, `${routeId}.routes.test.ts`);
149
155
  interlockingRoutes.add(rel(root, expected));
150
156
  const browserEligible = frontendInterlockings.has(text(interlocking.data, "interlocking_id"));
151
- if (!existsSync(expected) && !(browserEligible && browserTrains.has(text(route, "train_id")))) add("atdd-bun.topology.e2e-location", root, interlocking.path, browserEligible
157
+ if (evidenceDue && !existsSync(expected) && !(browserEligible && browserTrains.has(text(route, "train_id")))) add("atdd-bun.topology.e2e-location", root, interlocking.path, browserEligible
152
158
  ? `${text(interlocking.data, "interlocking_id")} route ${routeId} requires ${rel(root, expected)} or a Playwright E2E spec bound to Train: ${text(route, "train_id")}`
153
159
  : `${text(interlocking.data, "interlocking_id")} route ${routeId} is not on an exposed frontend journey and requires ${rel(root, expected)}`);
154
160
  }
@@ -157,7 +163,7 @@ for (const root of roots) {
157
163
  const entry = journey.data.entrypoint, id = text(journey.data, "journey_id").slice(8), path = join(e2eRoot, "journeys", `${id}.journey.test.ts`);
158
164
  const reachable = journeyReachability(journey), journeyTrains = new Set([...reachable].flatMap(interlockingId => list(interlockings.get(interlockingId)?.data, "routes").map(route => text(route, "train_id")).filter(Boolean)));
159
165
  const browserEligible = entry && typeof entry === "object" && hasFrontendSurface(entry);
160
- if (entry && typeof entry === "object" && entry.exposed === true && !existsSync(path) && !(browserEligible && browserJourneys.has(`journey:${id}`))) add("atdd-bun.topology.e2e-location", root, journey.path, browserEligible
166
+ if (evidenceDue && entry && typeof entry === "object" && entry.exposed === true && !existsSync(path) && !(browserEligible && browserJourneys.has(`journey:${id}`))) add("atdd-bun.topology.e2e-location", root, journey.path, browserEligible
161
167
  ? `exposed journey:${id} requires ${cfg.e2e_root}/journeys/${id}.journey.test.ts or a Playwright E2E spec bound to Journey: journey:${id}`
162
168
  : `exposed backend journey:${id} requires ${cfg.e2e_root}/journeys/${id}.journey.test.ts`);
163
169
  else if (entry && typeof entry === "object" && entry.exposed === true && existsSync(path)) {
@@ -6,6 +6,7 @@ import {
6
6
  parseJsonEnv,
7
7
  readText,
8
8
  findConsumerRoots,
9
+ appFile,
9
10
  runtimeFiles,
10
11
  referencesToken,
11
12
  importsWagon,
@@ -87,6 +88,8 @@ for (const scanRoot of roots) {
87
88
  .filter(item => /\bclass\s+JourneyRunner\b/.test(maskComments(item.text)));
88
89
 
89
90
  if (modules.length === 0) {
91
+ // As interlocking-runner-exists: the runner is owed once a Station Master exists to call it, not before.
92
+ if (!appFile(croot)) continue;
90
93
  violations.push(mk(
91
94
  RULE,
92
95
  `${PLAN_ROOT}/_journeys`,
@@ -62,17 +62,9 @@ for (const scanRoot of roots) {
62
62
  if (journeys.length === 0) continue;
63
63
 
64
64
  const app = appFile(croot);
65
- if (!app) {
66
- for (const journey of journeys) violations.push(mk(
67
- RULE,
68
- journey.file,
69
- 1,
70
- 0,
71
- "journey-station-missing: exposed journey has no server.ts Station Master composition root",
72
- "",
73
- ));
74
- continue;
75
- }
65
+ // As for interlockings: with no Station Master composition root yet (a repository in the PLAN stage),
66
+ // there is nothing to route through, so reachability is not yet due.
67
+ if (!app) continue;
76
68
 
77
69
  const text = readText(app);
78
70
  const appPath = relative(croot, app).replaceAll("\\", "/");
package/integrity.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.9.0",
2
+ "version": "0.9.2",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
6
- "README.md": "f21a04f8688ce6bcc4d2dbf065eb24e886971546871935067c6f96314418312f",
6
+ "README.md": "efc3f9f4c8cf13a62b6f17d2a41a17d871d2b14c551617a8b49a832edcb77d04",
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",
@@ -60,7 +60,7 @@
60
60
  "conventions/coder.bun/coder.bun.interlocking-does-not-carry-cargo.convention.yaml": "c8f906c4aa6709aab7dc91e719331fd1865c229708acd9838373712a777138b1",
61
61
  "conventions/coder.bun/coder.bun.interlocking-resolution-model-exists.convention.yaml": "dccd0fa99686811d2befc32861f995635b3e194004865710d5b9064a3feea8a8",
62
62
  "conventions/coder.bun/coder.bun.interlocking-runner-exists.convention.yaml": "f7fadc580d2896f7d8341f555e4447480809329f3901209d30c2e54181032f15",
63
- "conventions/coder.bun/coder.bun.journey-runner-boundary.convention.yaml": "e62d541d8932c0fba6a85d7a0fa8155eba53b41e2e35f8b52c720f840c2263a6",
63
+ "conventions/coder.bun/coder.bun.journey-runner-boundary.convention.yaml": "51df6caa810d887a84fc8196bc103465685400f12ba8a0504efef9b88c10fe33",
64
64
  "conventions/coder.bun/coder.bun.layer-naming.convention.yaml": "009c21f6adbbc1e08d9781d7466b06e3d7fb7d935bf45b1df07fe37639a60a82",
65
65
  "conventions/coder.bun/coder.bun.logging-console.convention.yaml": "b0e5765b8efba245cdd133f8321ba2364acbfae26a302f09e25cc905d652ae28",
66
66
  "conventions/coder.bun/coder.bun.logging-structured.convention.yaml": "a7c8bd90762bf1516d2af63d4fe731720bec9f58431009f69af1bfebc0398759",
@@ -77,7 +77,7 @@
77
77
  "conventions/coder.bun/coder.bun.security-missing-auth.convention.yaml": "2b937475e7a1f3f2718be6a3f0d94fa261ca65e72bbf8e3f2ca39ba143b90544",
78
78
  "conventions/coder.bun/coder.bun.security-sql-injection.convention.yaml": "b4cbc3a446bbbf874762fd13c873305eaf7417c45a6206133163509b97267faa",
79
79
  "conventions/coder.bun/coder.bun.station-master-interlocking-routing.convention.yaml": "b0b0572a79b4dd6f35db350980652afbdd797646d613c552b7c5595f72efdee8",
80
- "conventions/coder.bun/coder.bun.station-master-journey-routing.convention.yaml": "5f0c26b860c85ae5a22f3d63dd0a243b7cdda39812dc291591ecc5fe75a894a7",
80
+ "conventions/coder.bun/coder.bun.station-master-journey-routing.convention.yaml": "f03cb85e1a86d4811aa0a0e7e211900e482d16d168d5fd05d128a3e5cda4648e",
81
81
  "conventions/coder.bun/coder.bun.telemetry-forbidden-properties.convention.yaml": "7dd411af2180e70a84f4bad99c568060504bb5ff5e7ccb9fc1b6fd4b3aadcbcd",
82
82
  "conventions/coder.bun/coder.bun.telemetry-implementation-binding.convention.yaml": "5eb854948cdb8a1510fe808e3e5be13840d02b6bf07b4aa6d6ca94739128e7a7",
83
83
  "conventions/coder.bun/coder.bun.telemetry-raw-string-emit.convention.yaml": "7fb7c83a916078184862c89edf1bf3bc6332ffd6bc8e2f17d96a221af1a40007",
@@ -161,7 +161,7 @@
161
161
  "conventions/traceability/traceability.source.tested-by-resolves.convention.yaml": "ad4516670934dc82f1ebf9dc057110313059110302f88b4747dde89dd060870b",
162
162
  "conventions/traceability/traceability.test.binding-resolves.convention.yaml": "03c118f498b1e824d457d25c060c8f9689ccbcca49c58d8bcd158e3cd3144ee9",
163
163
  "detectors/atdd_topology/atdd.implementation.yaml": "7879f441a62691bf041b0fd8220feb12996d4a036d74d6c79d0985ee20992efb",
164
- "detectors/atdd_topology/detect.mjs": "33cb5584ce334352324e4a8331f75697cc9c09f7ebc07e59c5f917ad9879f447",
164
+ "detectors/atdd_topology/detect.mjs": "ae03378b2354c794fc865ff66436abbdf11aacff29cef9284e4f90648dcbc6f9",
165
165
  "detectors/atdd_topology/fixtures/clean/atdd-bun.yaml": "85049c05fb624037e1e2200a117b07d6c8d089d4e81fd784ade15a860a444f59",
166
166
  "detectors/atdd_topology/fixtures/clean/e2e/interlockings/checkout/nominal.routes.test.ts": "be769e192e8165d4d36be743e8df578c1790b900c6b89acc7d2046ba40105135",
167
167
  "detectors/atdd_topology/fixtures/clean/e2e/journeys/checkout.journey.test.ts": "345c391888e3e8c93d0437428dc1a3358968c7871ef54d313d6e083717254bbe",
@@ -431,8 +431,8 @@
431
431
  "detectors/bun_interlocking_infrastructure/checks/interlocking_resolution_model.mjs": "e761495b19eed0af09fcb4db34126d0e2398e91f086e04f5ef84d0b5c1c3ec16",
432
432
  "detectors/bun_interlocking_infrastructure/checks/interlocking_runner_exists.mjs": "6b63d3f26a51f2dd27847edd27c4d9c9f15c1f02747dba611cebccfc76f13169",
433
433
  "detectors/bun_interlocking_infrastructure/checks/interlocking_station_routing.mjs": "0221f5a8b49763388affefff670bc2a452e539e7658b08f6230a52f13edd36bd",
434
- "detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs": "8c7225f5ee387cbf7ba1e536f91c67c331a86025b4b2b38b961488d949cbdb21",
435
- "detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs": "b297f3f677e0239c5d14cf36218fc39ec23024a0f29620dd02945601aad20516",
434
+ "detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs": "570e4aef423881aed982b8f25a8d553f939d1ab82271236525c14efe3a20cd4a",
435
+ "detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs": "bfd4899a604c34de621475ce155e527b9281d1174c6daa13a2abc7bebabf2cde",
436
436
  "detectors/bun_interlocking_infrastructure/checks/wagon_contract_honoured.mjs": "728f710827295472dadec182fcdf6c3e8fc44cdedeae53a669754be8435fdd7f",
437
437
  "detectors/bun_interlocking_infrastructure/detect.mjs": "54c02f428596e057f828def0d68360b220b55dbf962b46694a21bc423ce57218",
438
438
  "detectors/bun_interlocking_infrastructure/fixtures/clean/interlocking_delegates/server.ts": "36913357fdd3c5e375829a0554c605d70f5c05e2e83349f47505741598f6eb15",
@@ -1190,12 +1190,12 @@
1190
1190
  "src/agent.ts": "b2015873940ca83bb8fdc683dbb85c2d53fe6f8c3e2b6b0b71bda895f0c1eb2f",
1191
1191
  "src/ci.ts": "67e54d2cbfc44a9e42837d5e750af526d6255c539d3c452a4d5fca3226c0d9ac",
1192
1192
  "src/cli.ts": "bb2047cee60f48bfe3eaee4c1b3dcd3538a0e1edc170cf58e87686117bea07a7",
1193
- "src/delivery.ts": "938af0efee874888b20d0f862cccd3a492dafa69352649a6a584972583df361c",
1193
+ "src/delivery.ts": "8c2f964a4acc4471b6541f980c9c6f270b33cd64c95f5fe852a401060d507975",
1194
1194
  "src/docs-capability.ts": "115cf27049a5cf19c133bac5ec237072be6f969a59a2ef6767e054108a21dc5c",
1195
- "src/enforce.ts": "58ad0593895912bdc1effe97430b07acf8cda066052397957558cbb331c16778",
1196
- "src/hooks.ts": "adbb72f7a83f53d596fd217f01f4ce32c5a59a23ab81993fed226fb96fd1cbd1",
1195
+ "src/enforce.ts": "41a6f8c058a2a034996c817f224191403cc66ccc4ed4e04bbefc430014c783d8",
1196
+ "src/hooks.ts": "bafda87a1cb1352133ec7e69712cd8b7acd7767dd6c87ad072f1b5596baaee0e",
1197
1197
  "src/index.ts": "8ee4d7716794f6990ae8580e471b98256a0d0223dad063cd3b4542e598b6e744",
1198
- "src/integrity.ts": "7cbb1ad4991079a4852d2d131c0769f66e13dc8b5fa0e637780a1b70a75eaf78",
1198
+ "src/integrity.ts": "ac516b1d3a73f872d0ed2b20a27ef0ac14ecc015293bd8e51954f322f8a063bb",
1199
1199
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
1200
1200
  "src/planner-kernel.ts": "5d3f5305fb59becb2cc97d8a03aca6b104f385805d91cc0410856e289dd6fbb1",
1201
1201
  "src/planner-schema-validator.ts": "ce529c0936171075e93c7539b3655fe895a98dd2973ebda5775958e6ebc316d9",
@@ -1208,7 +1208,7 @@
1208
1208
  "templates/agents/AGENTS.block.md": "7e9687b1eff245b66da4127b336a9e20af2f5fa273e08895188c0a0dffebd77d",
1209
1209
  "templates/agents/atdd-bun.integrity.test.ts": "dec6f6e5f65a08d9512fc703b367c163f8c9da1f6aa7fd466835c1ec30348a41",
1210
1210
  "templates/agents/atdd/SKILL.md": "b621abe22851a75b30fc0e6c339a2c0a3ed6bbf78b8c17000a4ce301e05df96d",
1211
- "templates/agents/delivery/SKILL.md": "aa9833d1ea4dbeb04a639f8a9dcd38803ae6ed10370da2d5298778c4636e25c9",
1211
+ "templates/agents/delivery/SKILL.md": "9d9b80394929220f6ec1521137df02976e3240601bbc92ee6513e078442856a6",
1212
1212
  "templates/agents/delivery/review.md": "289ee5d55ba0f321e5703a6fd8d5417d55bf0d9c6dc3103ea0f79af11c159d9c",
1213
1213
  "templates/github/atdd-bun-release.yml": "d9547e9e6ef3ae010d53d55314f6a54567f88bd900f7e50d994cd308601d2684",
1214
1214
  "templates/github/atdd-bun.yml": "bb42414f72f4b9a2fb1eb69530695e193f105a984542f4ca124c898a5f1489be"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/delivery.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import Ajv, { type ValidateFunction } from "ajv";
2
2
  import addFormats from "ajv-formats";
3
- import { existsSync, lstatSync } from "node:fs";
3
+ import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
4
4
  import { readdir, readFile } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
6
  import type { PlanFinding } from "./planner-kernel";
@@ -230,6 +230,13 @@ const namedReports = (data: unknown): string[] => {
230
230
  return Array.isArray(reviews) ? reviews.flatMap(review => { const report = record(review)?.report; return typeof report === "string" && report ? [report] : []; }) : [];
231
231
  };
232
232
  const isFolder = (path: string) => { try { return lstatSync(path).isDirectory(); } catch { return false; } };
233
+ /** A folder, through a symlink too: the legacy and hidden-records probes look wherever records could be. */
234
+ const isLink = (path: string) => { try { return lstatSync(path).isSymbolicLink(); } catch { return false; } };
235
+ /** Whether any folder on the way to `relative` (below `root`) is a symlink. */
236
+ const throughLink = (root: string, relative: string) => relative.split("/").some((_, i, parts) => isLink(join(root, ...parts.slice(0, i + 1))));
237
+ /** Two paths that resolve to one folder (a compatibility symlink to the root): its records are the root's, not outside it. */
238
+ const sameFolder = (a: string, b: string) => { try { return realpathSync(a) === realpathSync(b); } catch { return false; } };
239
+ const reachesFolder = (path: string) => { try { return statSync(path).isDirectory(); } catch { return false; } };
233
240
  const regularFile = (path: string) => { try { return lstatSync(path).isFile(); } catch { return false; } };
234
241
 
235
242
  /** Two spellings of one commit: an abbreviated SHA is a prefix of the full one. */
@@ -353,12 +360,12 @@ export async function validateDelivery(root = process.cwd(), options: DeliveryOp
353
360
  policy = { ...policy, root: DEFAULT_ROOT };
354
361
  }
355
362
  // 0.8.0 kept records under delivery/ by default. Records left there under any other effective root would be unseen.
356
- if (policy.root !== LEGACY_ROOT && isFolder(join(absolute, LEGACY_ROOT))) {
363
+ if (policy.root !== LEGACY_ROOT && reachesFolder(join(absolute, LEGACY_ROOT)) && !sameFolder(join(absolute, LEGACY_ROOT), join(absolute, policy.root))) {
357
364
  const legacy = (await readdir(join(absolute, LEGACY_ROOT), { withFileTypes: true })).filter(entry => entry.isDirectory() && existsSync(join(absolute, LEGACY_ROOT, entry.name, "evidence.yaml")));
358
365
  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`));
359
366
  }
360
367
  // The other direction: records under the default root while another root is configured are outside what is judged.
361
- if (policy.root !== DEFAULT_ROOT && isFolder(join(absolute, DEFAULT_ROOT))) {
368
+ if (policy.root !== DEFAULT_ROOT && reachesFolder(join(absolute, DEFAULT_ROOT)) && !sameFolder(join(absolute, DEFAULT_ROOT), join(absolute, policy.root))) {
362
369
  const hidden = (await readdir(join(absolute, DEFAULT_ROOT), { withFileTypes: true })).filter(entry => entry.isDirectory() && existsSync(join(absolute, DEFAULT_ROOT, entry.name, "evidence.yaml")));
363
370
  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`));
364
371
  }
@@ -374,7 +381,11 @@ export async function validateDelivery(root = process.cwd(), options: DeliveryOp
374
381
  // Per tranche: a data file is a report only when a record in its own tranche names it.
375
382
  const reported = new Set(files.flatMap(entry => namedReports(entry.data).filter(report => report.startsWith(`${policy.root}/${entry.tranche}/`))));
376
383
  const unnamed = (await dataFiles(absolute, policy)).filter(path => !reported.has(path));
377
- if (existsSync(join(absolute, policy.root)) && !isFolder(join(absolute, policy.root))) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery.root ${policy.root} is a file, not a folder; the records cannot be read`));
384
+ const rootPath = join(absolute, policy.root);
385
+ if (existsSync(rootPath) || isLink(rootPath)) {
386
+ 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`));
387
+ 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`));
388
+ }
378
389
  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`));
379
390
  for (const entry of files) {
380
391
  if (onBase && existsSync(join(absolute, entry.file)) && !(await git(absolute, ["diff", "--quiet", onBase, "--", `${policy.root}/${entry.tranche}`])).code) continue;
package/src/enforce.ts CHANGED
@@ -101,7 +101,7 @@ export async function undeclaredEmissions(implementation: string, violations: Pi
101
101
 
102
102
  export async function runImplementation(
103
103
  implementation: string,
104
- config: Required<Pick<EnforcementConfig, "scanRoots" | "excludes">>,
104
+ config: Required<Pick<EnforcementConfig, "scanRoots" | "excludes">> & { profiles?: ConcreteProfile[] },
105
105
  ): Promise<Violation[]> {
106
106
  const detector = join(detectorRoot, implementation, "detect.mjs");
107
107
  const scratch = await mkdtemp(join(tmpdir(), "atdd-bun-"));
@@ -119,6 +119,9 @@ export async function runImplementation(
119
119
  ATDD_SCAN_EXCLUDES: JSON.stringify(config.excludes),
120
120
  ATDD_PLAN_ROOT: topology.planRoot,
121
121
  ATDD_VIOLATIONS_REPORT: report,
122
+ // The profiles this run selected, so a detector shared by several profiles can tell a planning run from
123
+ // one that has reached RED or GREEN. Absent on a direct call, where detectors keep their full behaviour.
124
+ ...(config.profiles ? { ATDD_PROFILES: JSON.stringify(config.profiles) } : {}),
122
125
  },
123
126
  });
124
127
  const exitCode = await child.exited;
@@ -147,7 +150,7 @@ export async function enforce(config: EnforcementConfig = {}): Promise<Violation
147
150
  const requested = config.profiles ?? ["all"], enabled = await enabledProfiles(root);
148
151
  const selected = requested.includes("all") ? [...new Set([...requested.filter(p => p !== "all"), ...enabled])] : requested;
149
152
  const results = await Promise.all(
150
- implementationsFor(selected).map((implementation) => runImplementation(implementation, { scanRoots, excludes })),
153
+ implementationsFor(selected).map((implementation) => runImplementation(implementation, { scanRoots, excludes, profiles: selected as ConcreteProfile[] })),
151
154
  );
152
155
  return results.flat().sort((left, right) =>
153
156
  left.rule_id.localeCompare(right.rule_id) || left.file.localeCompare(right.file) || left.line - right.line,
package/src/hooks.ts CHANGED
@@ -16,6 +16,10 @@ const files = async (root: string, args: string[]) => (await git(root, args)).ou
16
16
  const strings = (value: unknown) => Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : undefined;
17
17
  // Declarative registries are exempt from the micro-commit size caps but never from validation or removal approval.
18
18
  const isRegistry = (cfg: HookPolicy, path: string) => cfg.registry_paths.some(pattern => new Bun.Glob(pattern).match(path));
19
+ /** The path a rename row of `git diff --numstat -M` lands on: `a => b`, or `dir/{a => b}/file`. */
20
+ const renamedTo = (path: string) => { const braced = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/); return braced ? `${braced[1]}${braced[3]}${braced[4]}`.replace(/\/{2,}/g, "/") : path.includes(" => ") ? path.split(" => ")[1]! : path; };
21
+ /** Lines a commit changes, for the size cap: a moved file counts only the edits it carries, not its whole body twice. */
22
+ const stagedLineStats = async (root: string) => (await git(root, ["diff", "--cached", "--numstat", "-M"])).out.split("\n").filter(Boolean).map(row => { const [added, removed, ...path] = row.split("\t"); return { added: Number(added) || 0, removed: Number(removed) || 0, path: renamedTo(path.join("\t")) }; });
19
23
  const stagedStats = async (root: string) => (await git(root, ["diff", "--cached", "--numstat", "--no-renames"])).out.split("\n").filter(Boolean).map(row => { const [added, removed, ...path] = row.split("\t"); return { added: Number(added) || 0, removed: Number(removed) || 0, path: path.join("\t") }; });
20
24
 
21
25
  export async function policy(root: string): Promise<HookPolicy> { const file = join(root, "atdd-bun.yaml"); if (!existsSync(file)) return defaultHookPolicy; const data = Bun.YAML.parse(await readFile(file, "utf8")) as Record<string, unknown>; const configuredWorktrees = data?.worktrees && typeof data.worktrees === "object" && !Array.isArray(data.worktrees) ? data.worktrees as Record<string, unknown> : {}; return { ...defaultHookPolicy, ...Object.fromEntries(Object.entries(data ?? {}).filter(([key, value]) => key in defaultHookPolicy && key !== "worktrees" && typeof value === typeof (defaultHookPolicy as any)[key])), protected_branches: strings(data?.protected_branches) ?? defaultHookPolicy.protected_branches, registry_paths: strings(data?.registry_paths) ?? defaultHookPolicy.registry_paths, worktrees: { ...defaultWorktreePolicy, ...Object.fromEntries(Object.entries(configuredWorktrees).filter(([key, value]) => key in defaultWorktreePolicy && typeof value === typeof (defaultWorktreePolicy as any)[key])) } }; }
@@ -31,7 +35,7 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
31
35
  const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
32
36
  if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
33
37
  if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
34
- 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)), counted = staged.length - registries.length, lines = (await stagedStats(root)).filter(row => !isRegistry(cfg, 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 ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
38
+ 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)), counted = staged.length - registries.length, lines = (await stagedLineStats(root)).filter(row => !isRegistry(cfg, 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 ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
35
39
  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" }; }
36
40
  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); }
37
41
  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
@@ -118,6 +118,36 @@ async function checkGenerated(root: string, packageRoot: string, skipWorkflow =
118
118
  * its absent-means-all default is right for execution and wrong for deciding whether a policy was ever declared. */
119
119
  const explicitProfiles = (config: { profiles?: unknown }): string[] | null => Array.isArray(config.profiles) ? config.profiles.map(String) : null;
120
120
 
121
+ /** A policy with null-valued hook keys (and worktrees children) removed: YAML gives null for a key with no value, and the
122
+ * hooks read null as absent, so the comparison must too. Other keys keep their null: delivery and profiles are read by
123
+ * readers that tell null apart from absent (`delivery:` with no value adopts delivery). */
124
+ function withoutNulls(config: Record<string, unknown>): Record<string, unknown> {
125
+ const out = Object.fromEntries(Object.entries(config).filter(([key, value]) => value !== null || !(key in defaultHookPolicy)));
126
+ const worktrees = out.worktrees;
127
+ if (typeof worktrees === "object" && worktrees !== null && !Array.isArray(worktrees)) out.worktrees = Object.fromEntries(Object.entries(worktrees).filter(([, value]) => value !== null));
128
+ return out;
129
+ }
130
+
131
+ /** The hook policy fields whose value has the wrong type. The comparison below would otherwise crash on them (a string
132
+ * where a list is expected) or compare them as the defaults. An empty or null document is the default policy, and fine. */
133
+ export function policyShapeErrors(config: Record<string, unknown>): string[] {
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"])
136
+ if (config[key] !== undefined && !(typeof config[key] === "number" && Number.isFinite(config[key]))) out.push(`${key} must be a finite number`);
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
+ 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`);
139
+ const worktrees = config.worktrees;
140
+ if (worktrees !== undefined) {
141
+ if (typeof worktrees !== "object" || worktrees === null || Array.isArray(worktrees)) out.push("worktrees must be a mapping");
142
+ else {
143
+ const layout = worktrees as Record<string, unknown>;
144
+ for (const key of ["enabled", "require_linked_worktree"]) if (layout[key] !== undefined && typeof layout[key] !== "boolean") out.push(`worktrees.${key} must be true or false`);
145
+ for (const key of ["root", "primary_directory", "primary_branch"]) if (layout[key] !== undefined && typeof layout[key] !== "string") out.push(`worktrees.${key} must be a string`);
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+
121
151
  /** Names of the policy fields in `current` that are looser than in `base`. */
122
152
  export function loosenedPolicy(base: Partial<HookPolicy> & { profiles?: unknown; delivery?: unknown }, current: Partial<HookPolicy> & { profiles?: unknown; delivery?: unknown }): string[] {
123
153
  const b = { ...defaultHookPolicy, ...base, worktrees: { ...defaultHookPolicy.worktrees, ...base.worktrees } }, c = { ...defaultHookPolicy, ...current, worktrees: { ...defaultHookPolicy.worktrees, ...current.worktrees } };
@@ -174,11 +204,20 @@ async function checkPolicy(root: string, base?: string, push = process.env.GITHU
174
204
  const unreadable = (why: string) => [{ file: "atdd-bun.yaml", detail: `the baseline atdd-bun.yaml at ${against.slice(0, 7)} ${why}, so the policy cannot be compared`, restore: push
175
205
  ? `this push is compared with the tip it replaced (${against.slice(0, 7)}), whose atdd-bun.yaml is broken; once the repaired atdd-bun.yaml is on the branch, the next push is compared with a readable tip`
176
206
  : `repair the atdd-bun.yaml on the base branch (git show ${against.slice(0, 7)}:atdd-bun.yaml) and land it there, which may need a maintainer; then bring that repair into this branch (rebase onto the base, or merge it in: a pull request is compared with its merge base) and re-run the check` }];
177
- let baseline: Partial<HookPolicy>;
178
- try { baseline = await read(before.code ? null : before.out); }
207
+ let baseline: Record<string, unknown>;
208
+ try { baseline = await read(before.code ? null : before.out) as Record<string, unknown>; }
179
209
  catch (error) { return unreadable(`could not be parsed (${String(error)})`); }
180
210
  if (typeof baseline !== "object" || baseline === null || Array.isArray(baseline)) return unreadable(`is not a policy mapping (${JSON.stringify(baseline)})`);
181
- const loosened = loosenedPolicy(baseline, await read(existsSync(path) ? await readFile(path, "utf8") : null));
211
+ // A key with no value (or only commented-out children) parses to null; the hooks read it as absent, and so does this.
212
+ baseline = withoutNulls(baseline);
213
+ const baseShape = policyShapeErrors(baseline);
214
+ if (baseShape.length) return unreadable(`has wrongly typed fields (${baseShape.join("; ")})`);
215
+ const raw = await read(existsSync(path) ? await readFile(path, "utf8") : null);
216
+ const current = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? withoutNulls(raw) : raw;
217
+ // A wrongly typed field in the working tree is read by the hooks as its default, silently; it is reported instead.
218
+ const shape = typeof current === "object" && current !== null && !Array.isArray(current) ? policyShapeErrors(current) : ["the document is not a policy mapping"];
219
+ if (shape.length) return [{ file: "atdd-bun.yaml", detail: `has wrongly typed fields, which the hooks would ignore or misread: ${shape.join("; ")}`, restore: "correct the field types in atdd-bun.yaml, then re-run the check" }];
220
+ const loosened = loosenedPolicy(baseline, current);
182
221
  return loosened.length ? [{ file: "atdd-bun.yaml", detail: `loosens the policy of ${against.slice(0, 7)}: ${loosened.join("; ")}`, restore: `git checkout ${against.slice(0, 7)} -- atdd-bun.yaml` }] : [];
183
222
  }
184
223
 
@@ -12,7 +12,7 @@ A **tranche** is one independently mergeable piece of the program, on its own br
12
12
 
13
13
  Its job is throughput: every worker slot busy, every tranche moving. It never implements, repairs tests, reviews, or merges a tranche.
14
14
 
15
- 1. Split the program into tranches with explicit dependencies, and write why the program exists, its scope and how it was split in `docs/delivery/index.adoc` (a docs-profile document). Activate a tranche as soon as its own dependencies have merged; do not wait for a whole wave. A tranche whose dependencies are still open may run PLAN and `plan_review` but nothing after; revalidate its plan once they merge.
15
+ 1. Split the program into tranches with explicit dependencies, and write why the program exists, its scope and how it was split in `docs/delivery/index.adoc` (a docs-profile document; where the docs profile is active, `docs/index.adoc` is needed too, each with `:doc-id:` and `:status:`). Activate a tranche as soon as its own dependencies have merged; do not wait for a whole wave. A tranche whose dependencies are still open may run PLAN and `plan_review` but nothing after; revalidate its plan once they merge.
16
16
  2. For each active tranche, create a worktree from the owning repository's workspace and start a driver in it through the multiplexer (see Multiplexer). Send the mandate, submit it, wait 4–6 s, and read the pane: a working indicator or agent output means it landed; an empty prompt or placeholder means retry before waiting on anything.
17
17
  3. Wait on the multiplexer's events, not polling loops, and on every driver at once. When a slot frees, give it to the next ready tranche or to planning ahead.
18
18
  4. Keep provider health for the whole program. When a driver reports a model unavailable, tell every driver to go straight to the next model in its lists until it recovers, so no tranche spends time rediscovering an outage.