@afokapu/atdd-bun 0.4.0 → 0.6.0

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 (209) hide show
  1. package/README.md +85 -3
  2. package/conventions/coder.bun/coder.bun.complexity-cyclomatic.convention.yaml +1 -3
  3. package/conventions/coder.bun/coder.bun.complexity-length.convention.yaml +1 -2
  4. package/conventions/coder.bun/coder.bun.complexity-nesting.convention.yaml +1 -2
  5. package/conventions/coder.bun/coder.bun.dead-code-reachability.convention.yaml +1 -2
  6. package/conventions/coder.bun/coder.bun.design-token-color.convention.yaml +1 -1
  7. package/conventions/coder.bun/coder.bun.design-token-hardcoded.convention.yaml +1 -1
  8. package/conventions/coder.bun/coder.bun.dto-mapper.convention.yaml +2 -1
  9. package/conventions/coder.bun/coder.bun.dto-placement.convention.yaml +2 -1
  10. package/conventions/coder.bun/coder.bun.dto-purity.convention.yaml +2 -1
  11. package/conventions/coder.bun/coder.bun.duplication-intra-layer.convention.yaml +1 -2
  12. package/conventions/coder.bun/coder.bun.green-header-order.convention.yaml +2 -11
  13. package/conventions/coder.bun/coder.bun.green-header-purpose.convention.yaml +2 -11
  14. package/conventions/coder.bun/coder.bun.green-header-runtime.convention.yaml +2 -11
  15. package/conventions/coder.bun/coder.bun.green-header-tested-by.convention.yaml +2 -11
  16. package/conventions/coder.bun/coder.bun.green-urn-layer.convention.yaml +2 -11
  17. package/conventions/coder.bun/coder.bun.green-urn-marker.convention.yaml +2 -12
  18. package/conventions/coder.bun/coder.bun.green-urn-name-matches.convention.yaml +3 -12
  19. package/conventions/coder.bun/coder.bun.green-urn-pattern.convention.yaml +2 -11
  20. package/conventions/coder.bun/coder.bun.green-urn-side.convention.yaml +2 -11
  21. package/conventions/coder.bun/coder.bun.green-urn-wagon-feature.convention.yaml +2 -11
  22. package/conventions/coder.bun/coder.bun.layer-naming.convention.yaml +2 -1
  23. package/conventions/coder.bun/coder.bun.logging-structured.convention.yaml +2 -4
  24. package/conventions/coder.bun/coder.bun.quality-comments.convention.yaml +1 -2
  25. package/conventions/coder.bun/coder.bun.quality-mi.convention.yaml +1 -2
  26. package/conventions/coder.bun/coder.bun.responsive-breakpoints-declared.convention.yaml +31 -0
  27. package/conventions/coder.bun/coder.bun.responsive-no-fixed-width.convention.yaml +30 -0
  28. package/conventions/coder.bun/coder.bun.responsive-viewport-meta.convention.yaml +31 -0
  29. package/conventions/coder.bun/coder.bun.runtime-executes-the-declaration.convention.yaml +2 -18
  30. package/conventions/coder.bun/coder.bun.runtime-typecheck-enforced.convention.yaml +2 -15
  31. package/conventions/coder.htmx/coder.htmx.swap-no-inline-handler.convention.yaml +3 -3
  32. package/conventions/coder.htmx/coder.htmx.verb-endpoint-is-routed.convention.yaml +2 -2
  33. package/conventions/coder.htmx/coder.htmx.verb-mutation-signals-progress.convention.yaml +3 -2
  34. package/conventions/planner.docs/planner.docs.adr-registry-derived.convention.yaml +2 -12
  35. package/conventions/planner.docs/planner.docs.area-index-required.convention.yaml +2 -12
  36. package/conventions/planner.docs/planner.docs.artifact-path-shape.convention.yaml +2 -12
  37. package/conventions/planner.docs/planner.docs.asciidoc-only.convention.yaml +2 -13
  38. package/conventions/planner.docs/planner.docs.doc-id-unique.convention.yaml +2 -12
  39. package/conventions/planner.docs/planner.docs.graph-target-resolves.convention.yaml +2 -12
  40. package/conventions/planner.docs/planner.docs.identity-required.convention.yaml +2 -12
  41. package/conventions/planner.docs/planner.docs.reference-integrity.convention.yaml +2 -12
  42. package/conventions/planner.docs/planner.docs.undeclared-change.convention.yaml +2 -12
  43. package/conventions/tester.bun/tester.bun.acceptance-binding-declared.convention.yaml +2 -6
  44. package/conventions/tester.bun/tester.bun.acceptance-covers-tag-well-formed.convention.yaml +1 -5
  45. package/conventions/tester.bun/tester.bun.interlocking-train-sequence-is-exercised.convention.yaml +2 -14
  46. package/conventions/tester.bun/tester.bun.red-behavioral-assertion.convention.yaml +1 -5
  47. package/conventions/tester.bun/tester.bun.routing-runtime-family.convention.yaml +2 -1
  48. package/conventions/tester.bun/tester.bun.smoke-no-collaborator-substitution.convention.yaml +2 -7
  49. package/conventions/tester.bun/tester.bun.smoke-observable-assertion.convention.yaml +2 -7
  50. package/conventions/tester.bun/tester.bun.telemetry-emit.convention.yaml +2 -1
  51. package/conventions/tester.bun/tester.bun.test-carries-urn-identity.convention.yaml +2 -6
  52. package/conventions/tester.bun/tester.bun.test-isolation-no-live-state.convention.yaml +1 -5
  53. package/conventions/tester.bun/tester.bun.test-no-self-skip.convention.yaml +1 -5
  54. package/conventions/tester.bun/tester.bun.test-phase-declared.convention.yaml +2 -6
  55. package/conventions/tester.htmx/tester.htmx.a11y-harness.convention.yaml +32 -0
  56. package/conventions/tester.htmx/tester.htmx.covered-train-is-routed.convention.yaml +30 -0
  57. package/conventions/tester.htmx/tester.htmx.e2e-binds-declared-subject.convention.yaml +31 -0
  58. package/conventions/tester.htmx/tester.htmx.e2e-spec-naming.convention.yaml +32 -0
  59. package/conventions/tester.htmx/tester.htmx.exposed-journey-e2e-coverage.convention.yaml +31 -0
  60. package/conventions/tester.htmx/tester.htmx.fragment-asserts-returned-markup.convention.yaml +2 -6
  61. package/conventions/tester.htmx/tester.htmx.journey-binding-header.convention.yaml +32 -0
  62. package/conventions/tester.htmx/tester.htmx.journey-layer-assembly.convention.yaml +29 -0
  63. package/conventions/tester.htmx/tester.htmx.journey-no-acceptance-marker.convention.yaml +29 -0
  64. package/conventions/tester.htmx/tester.htmx.journey-urn-format.convention.yaml +32 -0
  65. package/conventions/tester.htmx/tester.htmx.presentation-smoke-coverage.convention.yaml +31 -0
  66. package/conventions/tester.htmx/tester.htmx.responsive-harness.convention.yaml +33 -0
  67. package/conventions/tester.htmx/tester.htmx.responsive-journey-coverage.convention.yaml +30 -0
  68. package/conventions/tester.htmx/tester.htmx.swap-oob-asserts-destination-id.convention.yaml +2 -1
  69. package/conventions/tester.htmx/tester.htmx.train-e2e-coverage.convention.yaml +32 -0
  70. package/conventions/tester.htmx/tester.htmx.verb-endpoint-coverage.convention.yaml +2 -1
  71. package/conventions/tester.htmx/tester.htmx.visual-harness.convention.yaml +28 -0
  72. package/conventions/traceability/traceability.plan.executable-acceptance-has-test.convention.yaml +5 -0
  73. package/conventions/traceability/traceability.source.tested-by-present.convention.yaml +5 -0
  74. package/conventions/traceability/traceability.source.tested-by-resolves.convention.yaml +5 -0
  75. package/conventions/traceability/traceability.test.binding-resolves.convention.yaml +5 -0
  76. package/detectors/atdd_topology/atdd.implementation.yaml +13 -0
  77. package/detectors/atdd_topology/detect.mjs +171 -0
  78. package/detectors/atdd_topology/fixtures/clean/atdd-bun.yaml +2 -0
  79. package/detectors/atdd_topology/fixtures/clean/e2e/interlockings/checkout/nominal.routes.test.ts +4 -0
  80. package/detectors/atdd_topology/fixtures/clean/e2e/journeys/checkout.journey.test.ts +4 -0
  81. package/detectors/atdd_topology/fixtures/clean/implementation/orders/features/place-order/domain/order.ts +4 -0
  82. package/detectors/atdd_topology/fixtures/clean/plan/_journeys/checkout.yaml +4 -0
  83. package/detectors/atdd_topology/fixtures/clean/plan/_trains/_interlockings/checkout.yaml +4 -0
  84. package/detectors/atdd_topology/fixtures/clean/plan/_trains/checkout.yaml +1 -0
  85. package/detectors/atdd_topology/fixtures/clean/plan/orders/E001.yaml +6 -0
  86. package/detectors/atdd_topology/fixtures/clean/plan/orders/_orders.yaml +5 -0
  87. package/detectors/atdd_topology/fixtures/clean/plan/orders/place-order.yaml +3 -0
  88. package/detectors/atdd_topology/fixtures/clean/tests/wagons/orders/features/place-order/unit/order.test.ts +4 -0
  89. package/detectors/atdd_topology/fixtures/dirty/plan/_journeys/checkout.yaml +3 -0
  90. package/detectors/atdd_topology/fixtures/dirty/plan/orders/E001.yaml +4 -0
  91. package/detectors/atdd_topology/fixtures/dirty/plan/orders/_orders.yaml +4 -0
  92. package/detectors/atdd_topology/fixtures/dirty/plan/orders/features/place-order.yaml +3 -0
  93. package/detectors/atdd_topology/fixtures/dirty/src/wagons/orders/features/place-order/domain/forged.ts +2 -0
  94. package/detectors/atdd_topology/fixtures/dirty/src/wagons/orders/features/place-order/domain/order.ts +2 -0
  95. package/detectors/atdd_topology/fixtures/dirty/tests/wagons/orders/features/place-order/unit/order.test.ts +4 -0
  96. package/detectors/atdd_traceability_closure/detect.mjs +5 -2
  97. package/detectors/bun_green_traceability_detector/urn_header.mjs +2 -1
  98. package/detectors/bun_interlocking_binding/checks/interlocking_runtime_executes.mjs +3 -2
  99. package/detectors/bun_interlocking_binding/detect.mjs +6 -5
  100. package/detectors/bun_interlocking_coverage/_shared/interlocking.mjs +4 -3
  101. package/detectors/bun_interlocking_coverage/checks/interlocking_train_sequence.mjs +2 -2
  102. package/detectors/bun_interlocking_infrastructure/_shared/interlocking.mjs +2 -1
  103. package/detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs +3 -2
  104. package/detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs +2 -1
  105. package/detectors/bun_interlocking_infrastructure/checks/wagon_contract_honoured.mjs +3 -2
  106. package/detectors/bun_responsive_detector/atdd.implementation.yaml +18 -0
  107. package/detectors/bun_responsive_detector/checks/_map.json +5 -0
  108. package/detectors/bun_responsive_detector/checks/_responsive.mjs +20 -0
  109. package/detectors/bun_responsive_detector/checks/responsive_breakpoints_declared.mjs +21 -0
  110. package/detectors/bun_responsive_detector/checks/responsive_no_fixed_width.mjs +13 -0
  111. package/detectors/bun_responsive_detector/checks/responsive_viewport_meta.mjs +20 -0
  112. package/detectors/bun_responsive_detector/detect.mjs +46 -0
  113. package/detectors/bun_responsive_detector/fixtures/clean/public/index.html +9 -0
  114. package/detectors/bun_responsive_detector/fixtures/clean/src/app.css +13 -0
  115. package/detectors/bun_responsive_detector/fixtures/clean/src/document.ts +7 -0
  116. package/detectors/bun_responsive_detector/fixtures/clean/src/fragment.html +1 -0
  117. package/detectors/bun_responsive_detector/fixtures/clean/src/head.ts +1 -0
  118. package/detectors/bun_responsive_detector/fixtures/clean/src/layout.tsx +8 -0
  119. package/detectors/bun_responsive_detector/fixtures/clean/src/range.css +3 -0
  120. package/detectors/bun_responsive_detector/fixtures/clean/src/viewport-expression.tsx +5 -0
  121. package/detectors/bun_responsive_detector/fixtures/dirty/public/fixed-viewport.html +5 -0
  122. package/detectors/bun_responsive_detector/fixtures/dirty/public/no-meta.html +5 -0
  123. package/detectors/bun_responsive_detector/fixtures/dirty/public/no-zoom.html +5 -0
  124. package/detectors/bun_responsive_detector/fixtures/dirty/public/scale.html +5 -0
  125. package/detectors/bun_responsive_detector/fixtures/dirty/src/app.css +4 -0
  126. package/detectors/bun_responsive_detector/fixtures/dirty/src/render.ts +2 -0
  127. package/detectors/bun_responsive_detector/fixtures/dirty/src/units.css +3 -0
  128. package/detectors/bun_responsive_detector/fixtures/dirty/src/wide.tsx +3 -0
  129. package/detectors/bun_tester_discipline_detector/checks/t_acceptance_resolves.mjs +7 -6
  130. package/detectors/htmx_e2e_detector/atdd.implementation.yaml +40 -0
  131. package/detectors/htmx_e2e_detector/checks/_e2e.mjs +98 -0
  132. package/detectors/htmx_e2e_detector/checks/_map.json +16 -0
  133. package/detectors/htmx_e2e_detector/checks/a11y_harness.mjs +21 -0
  134. package/detectors/htmx_e2e_detector/checks/covered_train_is_routed.mjs +6 -0
  135. package/detectors/htmx_e2e_detector/checks/e2e_binds_declared_subject.mjs +11 -0
  136. package/detectors/htmx_e2e_detector/checks/e2e_spec_naming.mjs +6 -0
  137. package/detectors/htmx_e2e_detector/checks/exposed_journey_e2e_coverage.mjs +7 -0
  138. package/detectors/htmx_e2e_detector/checks/journey_binding_header.mjs +13 -0
  139. package/detectors/htmx_e2e_detector/checks/journey_layer_assembly.mjs +11 -0
  140. package/detectors/htmx_e2e_detector/checks/journey_no_acceptance_marker.mjs +6 -0
  141. package/detectors/htmx_e2e_detector/checks/journey_urn_format.mjs +18 -0
  142. package/detectors/htmx_e2e_detector/checks/presentation_smoke_coverage.mjs +15 -0
  143. package/detectors/htmx_e2e_detector/checks/responsive_harness.mjs +30 -0
  144. package/detectors/htmx_e2e_detector/checks/responsive_journey_coverage.mjs +7 -0
  145. package/detectors/htmx_e2e_detector/checks/train_e2e_coverage.mjs +7 -0
  146. package/detectors/htmx_e2e_detector/checks/visual_harness.mjs +7 -0
  147. package/detectors/htmx_e2e_detector/detect.mjs +46 -0
  148. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/buy-alias.a11y.e2e.ts +12 -0
  149. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/buy-inline.responsive.e2e.ts +11 -0
  150. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/buy.a11y.e2e.ts +12 -0
  151. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/buy.responsive.e2e.ts +14 -0
  152. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/buy.visual.e2e.ts +10 -0
  153. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/checkout-buy.smoke.e2e.ts +10 -0
  154. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/crlf.e2e.ts +10 -0
  155. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/place-order.e2e.ts +10 -0
  156. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/reject-empty-cart.e2e.ts +10 -0
  157. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/support/fixtures.ts +3 -0
  158. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/take-payment.e2e.ts +10 -0
  159. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_journeys/buy.yaml +19 -0
  160. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_journeys/settle.yaml +14 -0
  161. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/_interlockings/checkout.yaml +21 -0
  162. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/_interlockings/payment.yaml +16 -0
  163. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/_interlockings/settlement.yaml +16 -0
  164. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/orders/place-order.yaml +11 -0
  165. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/orders/reject-empty-cart.yaml +11 -0
  166. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/orders/settle-ledger.yaml +11 -0
  167. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/orders/take-payment.yaml +11 -0
  168. package/detectors/htmx_e2e_detector/fixtures/clean/playwright.config.ts +3 -0
  169. package/detectors/htmx_e2e_detector/fixtures/clean/src/wagons/checkout/presentation/cart.tsx +3 -0
  170. package/detectors/htmx_e2e_detector/fixtures/clean/tests/urn-parser.test.ts +7 -0
  171. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/acceptance.e2e.ts +6 -0
  172. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/bad-id.e2e.ts +10 -0
  173. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/bad-urn.e2e.ts +10 -0
  174. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/both.e2e.ts +6 -0
  175. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/buy.a11y.e2e.ts +10 -0
  176. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/buy.responsive.e2e.ts +11 -0
  177. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/buy.visual.e2e.ts +10 -0
  178. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/covered.e2e.ts +10 -0
  179. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/first-only.responsive.e2e.ts +13 -0
  180. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/ghost.e2e.ts +10 -0
  181. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/misnamed.spec.ts +10 -0
  182. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/no-binding.e2e.ts +4 -0
  183. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/no-layer.e2e.ts +4 -0
  184. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/no-urn.e2e.ts +4 -0
  185. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/tautology.a11y.e2e.ts +11 -0
  186. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/two-trains.e2e.ts +6 -0
  187. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/unrouted.e2e.ts +10 -0
  188. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/wrong-layer.e2e.ts +5 -0
  189. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/wrong-urn.e2e.ts +10 -0
  190. package/detectors/htmx_e2e_detector/fixtures/dirty/plan/_journeys/browse.yaml +16 -0
  191. package/detectors/htmx_e2e_detector/fixtures/dirty/plan/_journeys/buy.yaml +16 -0
  192. package/detectors/htmx_e2e_detector/fixtures/dirty/plan/_trains/_interlockings/checkout.yaml +21 -0
  193. package/detectors/htmx_e2e_detector/fixtures/dirty/plan/_trains/orders/covered.yaml +11 -0
  194. package/detectors/htmx_e2e_detector/fixtures/dirty/plan/_trains/orders/uncovered.yaml +11 -0
  195. package/detectors/htmx_e2e_detector/fixtures/dirty/plan/_trains/orders/unrouted.yaml +11 -0
  196. package/detectors/htmx_e2e_detector/fixtures/dirty/src/wagons/billing/presentation/invoice.tsx +3 -0
  197. package/integrity.json +208 -79
  198. package/lib/frontend.mjs +17 -0
  199. package/lib/scan.mjs +6 -2
  200. package/package.json +5 -1
  201. package/planner-nodes/ENFORCEMENT_SCOPE.yaml +18 -1
  202. package/relationships.yaml +4782 -4506
  203. package/src/enforce.ts +10 -7
  204. package/src/hooks.ts +2 -1
  205. package/src/index.ts +2 -0
  206. package/src/planner-kernel.ts +2 -1
  207. package/src/planner-schema-validator.ts +12 -10
  208. package/src/planner-validators.ts +16 -15
  209. package/src/topology.ts +40 -0
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env bun
2
+ // Configurable plan/source/test/E2E topology and feature-decomposition gate.
3
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join, relative, sep } from "node:path";
5
+ import { topologyFor } from "../../src/topology.ts";
6
+
7
+ const roots = JSON.parse(process.env.ATDD_SCAN_ROOTS || "[]"), report = process.env.ATDD_VIOLATIONS_REPORT;
8
+ if (!report) process.exit(2);
9
+ const sourceExt = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts", ".cts", ".cjs", ".html", ".htm"]);
10
+ const testName = /(?:^test_.*|.*(?:\.test|\.spec|_test))\.(?:[cm]?[jt]sx?)$/;
11
+ const slug = "[a-z][a-z0-9-]*";
12
+ const componentUrn = new RegExp(`^component:(${slug}):(${slug}):[A-Za-z0-9.]+:(frontend|backend):(domain|application|integration|presentation)$`);
13
+ const testUrn = new RegExp(`^test:(${slug}):(${slug}):([A-Za-z0-9][A-Za-z0-9._-]*)$`);
14
+ const violations = [];
15
+ const rel = (root, path) => relative(root, path).split(sep).join("/");
16
+ const read = path => { try { return readFileSync(path, "utf8"); } catch { return ""; } };
17
+ const text = (data, key) => data && typeof data === "object" && !Array.isArray(data) && typeof data[key] === "string" ? data[key] : "";
18
+ const list = (data, key) => data && typeof data === "object" && Array.isArray(data[key]) ? data[key] : [];
19
+ const urns = values => values.map(value => typeof value === "string" ? value : text(value, "urn")).filter(Boolean);
20
+ function add(rule_id, root, path, evidence, line = 1, source_line = "") { violations.push({ rule_id, file: rel(root, path), line, col: 1, evidence, source_line }); }
21
+ function walk(dir, predicate, found = []) {
22
+ let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return found; }
23
+ for (const entry of entries) {
24
+ if (["node_modules", ".git", ".atdd", "dist", "build", ".next"].includes(entry.name)) continue;
25
+ const path = join(dir, entry.name);
26
+ if (entry.isDirectory()) walk(path, predicate, found); else if (predicate(path)) found.push(path);
27
+ }
28
+ return found;
29
+ }
30
+ async function topology(root) { const value = await topologyFor(root); return { plan_root: value.planRoot, source_root: value.sourceRoot, test_root: value.testRoot, e2e_root: value.e2eRoot }; }
31
+ function header(path) {
32
+ const lines = read(path).split(/\r?\n/).slice(0, 60);
33
+ const field = name => {
34
+ const index = lines.findIndex(line => new RegExp(`^\\s*//\\s*${name}:\\s*(\\S.*?)\\s*$`).test(line));
35
+ return index < 0 ? { value: "", line: 1, raw: "" } : { value: lines[index].replace(new RegExp(`^\\s*//\\s*${name}:\\s*`), "").trim(), line: index + 1, raw: lines[index].trim() };
36
+ };
37
+ return { urn: field("URN"), acceptance: field("Acceptance"), train: field("Train"), journey: field("Journey") };
38
+ }
39
+
40
+ for (const root of roots) {
41
+ const cfg = await topology(root), planRoot = join(root, cfg.plan_root), docs = [];
42
+ for (const path of walk(planRoot, path => /\.ya?ml$/.test(path))) {
43
+ try { const data = Bun.YAML.parse(read(path)); if (data && typeof data === "object" && !Array.isArray(data)) docs.push({ path, data }); } catch { /* schema validation owns parse errors */ }
44
+ }
45
+ const wagons = docs.filter(doc => text(doc.data, "urn").startsWith("wagon:")).map(doc => ({ ...doc, slug: text(doc.data, "urn").slice(6) }));
46
+ const features = docs.filter(doc => text(doc.data, "urn").startsWith("feature:")).map(doc => { const [,, wagon, slug] = ["", ...text(doc.data, "urn").split(":")]; return { ...doc, wagon, slug, urn: text(doc.data, "urn") }; });
47
+ const wmbts = docs.filter(doc => text(doc.data, "urn").startsWith("wmbt:")).map(doc => { const [, wagon, code] = text(doc.data, "urn").split(":"); return { ...doc, wagon, code, urn: text(doc.data, "urn") }; });
48
+ const featureByUrn = new Map(features.map(feature => [feature.urn, feature])), memberships = new Map(), wmbtOwners = new Map(), sourceByFeature = new Map(), testByFeature = new Map();
49
+ const acceptances = new Set(wmbts.flatMap(wmbt => list(wmbt.data, "acceptances").map(acceptance => text(acceptance?.identity, "urn")).filter(Boolean)));
50
+ const trains = new Set(docs.map(doc => text(doc.data, "train_id")).filter(Boolean));
51
+
52
+ for (const wagon of wagons) {
53
+ const expected = `${cfg.plan_root}/${wagon.slug}/_${wagon.slug}.yaml`;
54
+ if (rel(root, wagon.path) !== expected) add("atdd-bun.topology.plan-location", root, wagon.path, `wagon:${wagon.slug} must be declared at ${expected}`);
55
+ const declared = urns(list(wagon.data, "features"));
56
+ if ((Number(wagon.data.wmbt?.total) > 0 || wmbts.some(wmbt => wmbt.wagon === wagon.slug)) && !declared.length) add("planner.wagon.features", root, wagon.path, `wagon:${wagon.slug} has WMBTs but no feature decomposition`);
57
+ for (const urn of declared) {
58
+ const feature = featureByUrn.get(urn);
59
+ if (!feature) { add("planner.wagon.features", root, wagon.path, `wagon:${wagon.slug} lists ${urn}, but no feature document declares it`); continue; }
60
+ memberships.set(urn, [...(memberships.get(urn) || []), wagon.slug]);
61
+ if (feature.wagon !== wagon.slug || text(feature.data, "wagon") !== `wagon:${wagon.slug}`) add("planner.feature.wagon-link", root, feature.path, `${urn} must belong to wagon:${wagon.slug}`);
62
+ }
63
+ }
64
+ for (const feature of features) {
65
+ const expected = `${cfg.plan_root}/${feature.wagon}/${feature.slug}.yaml`, owners = memberships.get(feature.urn) || [];
66
+ if (rel(root, feature.path) !== expected) add("atdd-bun.topology.plan-location", root, feature.path, `${feature.urn} must be declared at ${expected}`);
67
+ if (owners.length !== 1) add("planner.feature.wagon-link", root, feature.path, `${feature.urn} must be listed by exactly one wagon; found ${owners.length}`);
68
+ for (const urn of urns(list(feature.data, "wmbts"))) wmbtOwners.set(urn, [...(wmbtOwners.get(urn) || []), feature.urn]);
69
+ }
70
+ for (const wmbt of wmbts) {
71
+ const expected = `${cfg.plan_root}/${wmbt.wagon}/${wmbt.code}.yaml`, owners = wmbtOwners.get(wmbt.urn) || [];
72
+ if (rel(root, wmbt.path) !== expected) add("atdd-bun.topology.plan-location", root, wmbt.path, `${wmbt.urn} must be declared at ${expected}`);
73
+ if (owners.length !== 1) add("planner.wagon.features", root, wmbt.path, `${wmbt.urn} must belong to exactly one feature; found ${owners.length}`);
74
+ }
75
+
76
+ const declaredFeatures = new Set(features.map(feature => `${feature.wagon}:${feature.slug}`));
77
+ // A repository can ship an independent frontend or infrastructure fixture with journeys but
78
+ // no wagon/feature plan. Feature layout begins only when the plan models a feature; this
79
+ // preserves that boundary while making every modeled feature strict.
80
+ const modelsFeatures = wagons.length > 0 || features.length > 0 || wmbts.length > 0;
81
+ if (modelsFeatures) for (const path of walk(join(root, cfg.source_root), path => sourceExt.has(path.slice(path.lastIndexOf("."))) && !testName.test(path))) {
82
+ const h = header(path), match = componentUrn.exec(h.urn.value);
83
+ if (!match) { add("atdd-bun.topology.source-location", root, path, "source requires URN: component:{wagon}:{feature}:{name}:{frontend|backend}:{domain|application|integration|presentation}", h.urn.line, h.urn.raw); continue; }
84
+ const wagon = match[1], feature = match[2], layer = match[4], expected = `${cfg.source_root}/${wagon}/features/${feature}/${layer === "integration" ? "infrastructure" : layer}/`;
85
+ if (!rel(root, path).startsWith(expected)) add("atdd-bun.topology.source-location", root, path, `${h.urn.value} must live beneath ${expected}`, h.urn.line, h.urn.raw);
86
+ else sourceByFeature.set(`${wagon}:${feature}`, [...(sourceByFeature.get(`${wagon}:${feature}`) || []), path]);
87
+ if (wagon && feature && !declaredFeatures.has(`${wagon}:${feature}`)) add("planner.feature.wagon-link", root, path, `${h.urn.value} names undeclared feature:${wagon}:${feature}`, h.urn.line, h.urn.raw);
88
+ }
89
+ if (modelsFeatures) for (const path of walk(join(root, cfg.test_root), path => testName.test(path))) {
90
+ const h = header(path), match = testUrn.exec(h.urn.value);
91
+ if (!match) { add("atdd-bun.topology.test-location", root, path, "test requires URN: test:{wagon}:{feature}:{acceptance}", h.urn.line, h.urn.raw); continue; }
92
+ const [, wagon, feature, acceptance] = match, expected = `${cfg.test_root}/${wagon}/features/${feature}/`;
93
+ if (!rel(root, path).startsWith(expected) || !/(unit|contract|integration)\//.test(rel(root, path).slice(expected.length))) add("atdd-bun.topology.test-location", root, path, `${h.urn.value} must live beneath ${expected}{unit,contract,integration}/`, h.urn.line, h.urn.raw);
94
+ if (wagon && feature && !declaredFeatures.has(`${wagon}:${feature}`)) add("planner.feature.wagon-link", root, path, `${h.urn.value} names undeclared feature:${wagon}:${feature}`, h.urn.line, h.urn.raw);
95
+ const binding = `acc:${wagon}:${acceptance}`;
96
+ if (h.acceptance.value !== binding) add("atdd-bun.topology.test-location", root, path, `${h.urn.value} must bind Acceptance: ${binding}`, h.acceptance.line, h.acceptance.raw);
97
+ else if (!acceptances.has(binding)) add("atdd-bun.topology.test-location", root, path, `${binding} is not declared by a WMBT in ${cfg.plan_root}/`, h.acceptance.line, h.acceptance.raw);
98
+ else testByFeature.set(`${wagon}:${feature}`, [...(testByFeature.get(`${wagon}:${feature}`) || []), path]);
99
+ }
100
+ for (const feature of features) if (list(feature.data, "wmbts").length) {
101
+ const key = `${feature.wagon}:${feature.slug}`;
102
+ 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}/`);
103
+ 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`);
104
+ }
105
+ const e2eRoot = join(root, cfg.e2e_root);
106
+ // A browser test is the E2E proof for a frontend behavior. It deliberately
107
+ // substitutes for the Bun E2E path below, never supplements it: one behavior,
108
+ // one runner. htmx_e2e_detector (included in the tester profile) owns the
109
+ // stricter Playwright naming, header, subject, and harness validation.
110
+ const browserTrains = new Set(), browserJourneys = new Set();
111
+ for (const path of walk(e2eRoot, path => /\.e2e\.[cm]?[jt]sx?$/.test(path))) {
112
+ const content = read(path), h = header(path);
113
+ if (!/from\s+["']@playwright\/test["']/.test(content) || !/(^|[^.\w])test(\.(describe|only|skip|fixme|fail|slow|step))?\s*\(/m.test(content)) continue;
114
+ if (h.train.value) browserTrains.add(h.train.value);
115
+ if (h.journey.value) browserJourneys.add(h.journey.value);
116
+ }
117
+ const interlockingRoutes = new Set();
118
+ const interlockings = new Map(docs.filter(doc => text(doc.data, "interlocking_id").startsWith("interlocking:")).map(doc => [text(doc.data, "interlocking_id"), doc]));
119
+ const journeys = docs.filter(doc => text(doc.data, "journey_id").startsWith("journey:"));
120
+ const journeyReachability = journey => {
121
+ const continuations = list(journey.data, "continuations"), reachable = new Set(), queue = [text(journey.data.entrypoint, "interlocking_id")].filter(Boolean);
122
+ while (queue.length) {
123
+ const interlockingId = queue.shift(); if (!interlockingId || reachable.has(interlockingId)) continue;
124
+ reachable.add(interlockingId);
125
+ for (const continuation of continuations) if (text(continuation?.from, "interlocking_id") === interlockingId && text(continuation?.to, "interlocking_id")) queue.push(text(continuation.to, "interlocking_id"));
126
+ }
127
+ return reachable;
128
+ };
129
+ const hasFrontendSurface = entry => !Array.isArray(entry?.surfaces) || entry.surfaces.map(String).includes("frontend");
130
+ const frontendInterlockings = new Set(journeys.filter(journey => {
131
+ const entry = journey.data.entrypoint;
132
+ return entry && typeof entry === "object" && entry.exposed === true && hasFrontendSurface(entry);
133
+ }).flatMap(journey => [...journeyReachability(journey)]));
134
+ for (const interlocking of interlockings.values()) {
135
+ const id = text(interlocking.data, "interlocking_id").slice(13);
136
+ for (const route of list(interlocking.data, "routes")) {
137
+ const routeId = text(route, "route_id"); if (!routeId) continue;
138
+ const expected = join(e2eRoot, "interlockings", id, `${routeId}.routes.test.ts`);
139
+ interlockingRoutes.add(rel(root, expected));
140
+ const browserEligible = frontendInterlockings.has(text(interlocking.data, "interlocking_id"));
141
+ if (!existsSync(expected) && !(browserEligible && browserTrains.has(text(route, "train_id")))) add("atdd-bun.topology.e2e-location", root, interlocking.path, browserEligible
142
+ ? `${text(interlocking.data, "interlocking_id")} route ${routeId} requires ${rel(root, expected)} or a Playwright E2E spec bound to Train: ${text(route, "train_id")}`
143
+ : `${text(interlocking.data, "interlocking_id")} route ${routeId} is not on an exposed frontend journey and requires ${rel(root, expected)}`);
144
+ }
145
+ }
146
+ for (const journey of journeys) {
147
+ const entry = journey.data.entrypoint, id = text(journey.data, "journey_id").slice(8), path = join(e2eRoot, "journeys", `${id}.journey.test.ts`);
148
+ const reachable = journeyReachability(journey), journeyTrains = new Set([...reachable].flatMap(interlockingId => list(interlockings.get(interlockingId)?.data, "routes").map(route => text(route, "train_id")).filter(Boolean)));
149
+ const browserEligible = entry && typeof entry === "object" && hasFrontendSurface(entry);
150
+ 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
151
+ ? `exposed journey:${id} requires ${cfg.e2e_root}/journeys/${id}.journey.test.ts or a Playwright E2E spec bound to Journey: journey:${id}`
152
+ : `exposed backend journey:${id} requires ${cfg.e2e_root}/journeys/${id}.journey.test.ts`);
153
+ else if (entry && typeof entry === "object" && entry.exposed === true && existsSync(path)) {
154
+ const h = header(path), match = testUrn.exec(h.urn.value);
155
+ if (!match || !declaredFeatures.has(`${match[1]}:${match[2]}`)) add("atdd-bun.topology.e2e-location", root, path, "journey E2E test requires a test:{wagon}:{feature}:{acceptance} URN naming a declared feature", h.urn.line, h.urn.raw);
156
+ if (!trains.has(h.train.value) || !journeyTrains.has(h.train.value)) add("atdd-bun.topology.e2e-location", root, path, `journey E2E Train: must resolve to a reachable selected train; found ${h.train.value || "<missing>"}`, h.train.line, h.train.raw);
157
+ }
158
+ }
159
+ for (const path of walk(e2eRoot, path => testName.test(path))) {
160
+ const location = rel(root, path), h = header(path);
161
+ if (location.startsWith(`${cfg.e2e_root}/journeys/`) && !/\.journey\.test\.[cm]?[jt]sx?$/.test(path)) add("atdd-bun.topology.e2e-location", root, path, "journey E2E tests must end in .journey.test.ts", h.urn.line, h.urn.raw);
162
+ if (location.startsWith(`${cfg.e2e_root}/interlockings/`) && !/\.routes\.test\.[cm]?[jt]sx?$/.test(path)) add("atdd-bun.topology.e2e-location", root, path, "interlocking E2E tests must end in .routes.test.ts", h.urn.line, h.urn.raw);
163
+ if (location.startsWith(`${cfg.e2e_root}/interlockings/`) && /\.routes\.test\.[cm]?[jt]sx?$/.test(path) && interlockingRoutes.size && !interlockingRoutes.has(location)) add("atdd-bun.topology.e2e-location", root, path, "interlocking E2E test does not name a declared route", h.urn.line, h.urn.raw);
164
+ if (location.startsWith(`${cfg.e2e_root}/interlockings/`) && /\.routes\.test\.[cm]?[jt]sx?$/.test(path)) {
165
+ const match = testUrn.exec(h.urn.value);
166
+ if (!match || !declaredFeatures.has(`${match[1]}:${match[2]}`)) add("atdd-bun.topology.e2e-location", root, path, "interlocking E2E test requires a test:{wagon}:{feature}:{acceptance} URN naming a declared feature", h.urn.line, h.urn.raw);
167
+ else if (h.acceptance.value !== `acc:${match[1]}:${match[3]}` || !acceptances.has(h.acceptance.value)) add("atdd-bun.topology.e2e-location", root, path, "interlocking E2E test requires an Acceptance: binding declared by a WMBT", h.acceptance.line, h.acceptance.raw);
168
+ }
169
+ }
170
+ }
171
+ writeFileSync(report, JSON.stringify({ violations }, null, 2));
@@ -0,0 +1,2 @@
1
+ topology:
2
+ source_root: implementation
@@ -0,0 +1,4 @@
1
+ // URN: test:orders:place-order:E001-E2E-002
2
+ // Acceptance: acc:orders:E001-E2E-002
3
+ import { test } from "bun:test";
4
+ test("nominal", () => {});
@@ -0,0 +1,4 @@
1
+ // URN: test:orders:place-order:E001-E2E-001
2
+ // Train: train:orders:checkout
3
+ import { test } from "bun:test";
4
+ test("checkout", () => {});
@@ -0,0 +1,4 @@
1
+ // URN: component:orders:place-order:Order:backend:domain
2
+ // Tested-By:
3
+ // - test:orders:place-order:E001-UNIT-001
4
+ export class Order {}
@@ -0,0 +1,4 @@
1
+ journey_id: journey:checkout
2
+ entrypoint:
3
+ exposed: true
4
+ interlocking_id: interlocking:checkout
@@ -0,0 +1,4 @@
1
+ interlocking_id: interlocking:checkout
2
+ routes:
3
+ - route_id: nominal
4
+ train_id: train:orders:checkout
@@ -0,0 +1 @@
1
+ train_id: train:orders:checkout
@@ -0,0 +1,6 @@
1
+ urn: wmbt:orders:E001
2
+ acceptances:
3
+ - identity:
4
+ urn: acc:orders:E001-UNIT-001
5
+ - identity:
6
+ urn: acc:orders:E001-E2E-002
@@ -0,0 +1,5 @@
1
+ urn: wagon:orders
2
+ wmbt:
3
+ total: 1
4
+ features:
5
+ - urn: feature:orders:place-order
@@ -0,0 +1,3 @@
1
+ urn: feature:orders:place-order
2
+ wagon: wagon:orders
3
+ wmbts: [wmbt:orders:E001]
@@ -0,0 +1,4 @@
1
+ // URN: test:orders:place-order:E001-UNIT-001
2
+ // Acceptance: acc:orders:E001-UNIT-001
3
+ import { test } from "bun:test";
4
+ test("order", () => {});
@@ -0,0 +1,3 @@
1
+ journey_id: journey:checkout
2
+ entrypoint:
3
+ exposed: true
@@ -0,0 +1,4 @@
1
+ urn: wmbt:orders:E001
2
+ acceptances:
3
+ - identity:
4
+ urn: acc:orders:E001-UNIT-001
@@ -0,0 +1,4 @@
1
+ urn: wagon:orders
2
+ wmbt:
3
+ total: 1
4
+ features: []
@@ -0,0 +1,3 @@
1
+ urn: feature:orders:place-order
2
+ wagon: wagon:orders
3
+ wmbts: [wmbt:orders:E001]
@@ -0,0 +1,2 @@
1
+ // URN: forged:orders:place-order:Forged:backend:domain
2
+ export class Forged {}
@@ -0,0 +1,2 @@
1
+ // URN: component:orders:missing:Order:backend:domain
2
+ export class Order {}
@@ -0,0 +1,4 @@
1
+ // URN: test:orders:place-order:E001-UNIT-001
2
+ // Acceptance: acc:orders:WRONG
3
+ import { test } from "bun:test";
4
+ test("order", () => {});
@@ -2,6 +2,7 @@
2
2
  // Bun-native closure gate for plan -> test -> implementation traceability.
3
3
  import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
4
  import { join, relative } from "node:path";
5
+ import { topologyFor } from "../../src/topology.ts";
5
6
 
6
7
  const roots = JSON.parse(process.env.ATDD_SCAN_ROOTS || "[]");
7
8
  const excludes = new Set(JSON.parse(process.env.ATDD_SCAN_EXCLUDES || "[]"));
@@ -14,6 +15,7 @@ const violations = [];
14
15
  const plans = { acc: new Set(), wmbt: new Set(), train: new Set() };
15
16
  const tests = new Map();
16
17
  const sources = [];
18
+ let planDisplay = "plan/";
17
19
 
18
20
  function ignored(path) {
19
21
  return [...excludes].some((needle) => path.includes(needle));
@@ -33,10 +35,11 @@ function add(rule_id, file, line, evidence, source_line = "") {
33
35
  function lineOf(content, token) { return content.slice(0, content.indexOf(token)).split("\n").length; }
34
36
 
35
37
  for (const root of roots) {
38
+ const topology = await topologyFor(root), planPrefix = topology.planRoot.replace(/\/$/, "") + "/"; planDisplay = planPrefix;
36
39
  walk(root, (path) => {
37
40
  const name = path.split("/").pop() || "";
38
41
  const content = text(path);
39
- if (/\.ya?ml$/i.test(name) && /(?:^|\/)plan(?:\/|$)/.test(path)) {
42
+ if (/\.ya?ml$/i.test(name) && relative(root, path).replaceAll("\\", "/").startsWith(planPrefix)) {
40
43
  for (const [kind, re] of Object.entries({
41
44
  acc: /\bacc:[A-Za-z0-9_.:-]+/g,
42
45
  wmbt: /\bwmbt:[A-Za-z0-9_.:-]+/g,
@@ -70,7 +73,7 @@ for (const test of tests.values()) {
70
73
  }
71
74
  const boundAcceptances = new Set([...tests.values()].map((test) => test.binding).filter((id) => id?.startsWith("acc:")));
72
75
  for (const acceptance of plans.acc) {
73
- if (!boundAcceptances.has(acceptance)) add("traceability.plan.executable-acceptance-has-test", "plan/", 1, `${acceptance} has no Bun test binding`, acceptance);
76
+ if (!boundAcceptances.has(acceptance)) add("traceability.plan.executable-acceptance-has-test", planDisplay, 1, `${acceptance} has no Bun test binding`, acceptance);
74
77
  }
75
78
  for (const source of sources) {
76
79
  if (!source.testedBy.length) {
@@ -56,7 +56,8 @@ export function isExcluded(path, excludes) {
56
56
 
57
57
  function isScannable(file) {
58
58
  const ext = extname(file);
59
- return (SOURCE_EXT.has(ext) || TEMPLATE_EXT.has(ext)) && !TEST_RE.test(file);
59
+ // Playwright browser specs (*.e2e.*) are tests, not implementation: they carry journey headers, not URN headers.
60
+ return (SOURCE_EXT.has(ext) || TEMPLATE_EXT.has(ext)) && !TEST_RE.test(file) && !/\.e2e\.[cm]?[jt]sx?$/.test(file);
60
61
  }
61
62
 
62
63
  export function* walk(root, excludes) {
@@ -30,6 +30,7 @@ const LOADS_DECLARATION =
30
30
  /\breadFileSync\s*\(|\breadFile\s*\(|\bBun\s*\.\s*file\s*\(|\bcreateReadStream\s*\(|\bfs\s*\.\s*promises\s*\.\s*readFile\b/;
31
31
  const RESOLVES = /\bInterlockingResolution\b|\bresolveTrain\s*\(/;
32
32
  const EXCLUDES = ["node_modules", "dist", "build", ".next", "_generated"];
33
+ const PLAN_ROOT = process.env.ATDD_PLAN_ROOT || "plan";
33
34
 
34
35
  const readText = (p) => { try { return readFileSync(p, "utf8"); } catch { return ""; } };
35
36
 
@@ -53,7 +54,7 @@ export function findConsumerRoots(scanRoot) {
53
54
  (function rec(d) {
54
55
  let st; try { st = statSync(d); } catch { return; }
55
56
  if (!st.isDirectory()) return;
56
- for (const marker of ["src", "plan", "e2e"]) {
57
+ for (const marker of ["src", PLAN_ROOT, "e2e"]) {
57
58
  try { if (statSync(join(d, marker)).isDirectory()) { roots.add(d); break; } } catch {}
58
59
  }
59
60
  let entries; try { entries = readdirSync(d).sort(); } catch { return; }
@@ -111,7 +112,7 @@ export function maskComments(text) {
111
112
  export function scanExecution(scanRoot) {
112
113
  const violations = [];
113
114
  for (const croot of findConsumerRoots(scanRoot)) {
114
- const ilFiles = walk(join(croot, "plan"), (f) => /_interlockings[/\\].*\.ya?ml$/.test(f));
115
+ const ilFiles = walk(join(croot, PLAN_ROOT), (f) => /_interlockings[/\\].*\.ya?ml$/.test(f));
115
116
  const declared = [...new Set(ilFiles.flatMap((f) => declaredValues(readText(f))))];
116
117
  if (!declared.length) continue;
117
118
  const rtFiles = walk(join(croot, "src/trains"), (f) => /\.(ts|tsx|mjs|js)$/.test(f))
@@ -28,6 +28,7 @@ import { join, sep, resolve } from "node:path";
28
28
  const RULE = "coder.bun.interlocking-bilateral-binding";
29
29
  const DEFAULT_EXCLUDES = ["_generated", "node_modules", "dist", "build", ".next"];
30
30
  const PARALLEL_FIELDS = ["entrypoints", "runtime_exposure", "station_actions", "exposed_actions", "reachability"];
31
+ const PLAN_ROOT = process.env.ATDD_PLAN_ROOT || "plan";
31
32
 
32
33
  // ── generic IO / masking ─────────────────────────────────────────────────────
33
34
 
@@ -147,7 +148,7 @@ function* walkDirs(root) {
147
148
  function findConsumerRoots(scanRoot) {
148
149
  const roots = new Set();
149
150
  for (const d of walkDirs(scanRoot)) {
150
- if (hasChildDir(d, "src") || hasChildDir(d, "plan") || hasChildDir(d, "e2e")) roots.add(d);
151
+ if (hasChildDir(d, "src") || hasChildDir(d, PLAN_ROOT) || hasChildDir(d, "e2e")) roots.add(d);
151
152
  }
152
153
  return [...roots];
153
154
  }
@@ -181,9 +182,9 @@ const isYaml = (f) => f.endsWith(".yaml") || f.endsWith(".yml");
181
182
  const isTs = (f) => f.endsWith(".ts") || f.endsWith(".tsx");
182
183
 
183
184
  function interlockingFiles(croot) {
184
- const base = join(croot, "plan", "_trains", "_interlockings");
185
+ const base = join(croot, PLAN_ROOT, "_trains", "_interlockings");
185
186
  const out = [...walkFiles(base, isYaml)];
186
- const idx = join(croot, "plan", "_trains", "_interlockings.yaml");
187
+ const idx = join(croot, PLAN_ROOT, "_trains", "_interlockings.yaml");
187
188
  try {
188
189
  if (statSync(idx).isFile()) out.push(idx);
189
190
  } catch {
@@ -377,7 +378,7 @@ function trainArtifactExists(croot, route) {
377
378
  }
378
379
  if (route.trainId) {
379
380
  try {
380
- return statSync(join(croot, "plan", "_trains", `${route.trainId}.yaml`)).isFile();
381
+ return statSync(join(croot, PLAN_ROOT, "_trains", `${route.trainId}.yaml`)).isFile();
381
382
  } catch {
382
383
  return false;
383
384
  }
@@ -413,7 +414,7 @@ function scanConsumerRoot(croot) {
413
414
  for (const rec of records) {
414
415
  for (const route of rec.routes) {
415
416
  if (trainArtifactExists(croot, route)) continue;
416
- const target = route.trainPath || (route.trainId ? `plan/_trains/${route.trainId}.yaml` : "<no train_id>");
417
+ const target = route.trainPath || (route.trainId ? `${PLAN_ROOT}/_trains/${route.trainId}.yaml` : "<no train_id>");
417
418
  violations.push(
418
419
  mk(
419
420
  rel(rec.file, croot),
@@ -10,6 +10,7 @@ import { readFileSync, statSync, readdirSync, writeFileSync } from "node:fs";
10
10
  import { join, sep } from "node:path";
11
11
 
12
12
  export const DEFAULT_EXCLUDES = ["_generated", "node_modules", "dist", "build", ".next"];
13
+ export const PLAN_ROOT = process.env.ATDD_PLAN_ROOT || "plan";
13
14
 
14
15
  // Production runner symbols (core #1251 call model).
15
16
  export const PROD_INTERLOCKING = "InterlockingRunner";
@@ -220,7 +221,7 @@ function* walkDirs(root) {
220
221
  export function findConsumerRoots(scanRoot) {
221
222
  const roots = new Set();
222
223
  for (const d of walkDirs(scanRoot)) {
223
- if (hasChildDir(d, "plan") || hasChildDir(d, "e2e") || hasChildDir(d, "src")) roots.add(d);
224
+ if (hasChildDir(d, PLAN_ROOT) || hasChildDir(d, "e2e") || hasChildDir(d, "src")) roots.add(d);
224
225
  }
225
226
  return [...roots];
226
227
  }
@@ -254,9 +255,9 @@ const isYaml = (f) => f.endsWith(".yaml") || f.endsWith(".yml");
254
255
  const isTs = (f) => f.endsWith(".ts") || f.endsWith(".tsx");
255
256
 
256
257
  export function interlockingFiles(croot) {
257
- const base = join(croot, "plan", "_trains", "_interlockings");
258
+ const base = join(croot, PLAN_ROOT, "_trains", "_interlockings");
258
259
  const out = [...walkFiles(base, isYaml)];
259
- const idx = join(croot, "plan", "_trains", "_interlockings.yaml");
260
+ const idx = join(croot, PLAN_ROOT, "_trains", "_interlockings.yaml");
260
261
  try {
261
262
  if (statSync(idx).isFile()) out.push(idx);
262
263
  } catch {
@@ -16,7 +16,7 @@
16
16
  // suite green. The runtime obeyed the new plan; no test looked.
17
17
  import {
18
18
  parseJsonEnv, readText, findConsumerRoots, interlockingFiles, e2eFiles,
19
- parseInterlocking, tokenCovered, rel, mk,
19
+ parseInterlocking, tokenCovered, rel, mk, PLAN_ROOT,
20
20
  } from "../_shared/interlocking.mjs";
21
21
  import { writeFileSync, readdirSync, statSync } from "node:fs";
22
22
  import { join } from "node:path";
@@ -69,7 +69,7 @@ export function declaresASequence(croot, trainId) {
69
69
  if (st.isDirectory()) { if (name !== "_interlockings") rec(full); continue; }
70
70
  if (/\.ya?ml$/.test(name)) found.push(full);
71
71
  }
72
- })(join(croot, "plan"));
72
+ })(join(croot, PLAN_ROOT));
73
73
  for (const f of found) {
74
74
  const text = readText(f);
75
75
  if (!text.includes(trainId)) continue;
@@ -25,6 +25,7 @@ import { readFileSync, statSync, readdirSync, writeFileSync } from "node:fs";
25
25
  import { join, sep, basename } from "node:path";
26
26
 
27
27
  export const DEFAULT_EXCLUDES = ["_generated", "node_modules", "dist", "build", ".next"];
28
+ export const PLAN_ROOT = process.env.ATDD_PLAN_ROOT || "plan";
28
29
 
29
30
  // Structured-resolution model contract, TS camelCase, transcribed from core's own
30
31
  // InterlockingResolution (src/atdd/runtime/interlocking/runner.py).
@@ -127,7 +128,7 @@ function* walkDirs(root) {
127
128
  export function findConsumerRoots(scanRoot) {
128
129
  const roots = new Set();
129
130
  for (const d of walkDirs(scanRoot)) {
130
- if (hasChildDir(d, "src") || hasChildDir(d, "plan") || hasChildDir(d, "e2e")) roots.add(d);
131
+ if (hasChildDir(d, "src") || hasChildDir(d, PLAN_ROOT) || hasChildDir(d, "e2e")) roots.add(d);
131
132
  }
132
133
  return [...roots];
133
134
  }
@@ -18,6 +18,7 @@ import {
18
18
  mk,
19
19
  writeReport,
20
20
  maskComments,
21
+ PLAN_ROOT,
21
22
  } from "../_shared/interlocking.mjs";
22
23
 
23
24
  const RULE = "coder.bun.journey-runner-boundary";
@@ -25,7 +26,7 @@ const roots = parseJsonEnv("ATDD_SCAN_ROOTS", []);
25
26
  const violations = [];
26
27
 
27
28
  function hasJourneyTopology(root) {
28
- const base = join(root, "plan", "_journeys");
29
+ const base = join(root, PLAN_ROOT, "_journeys");
29
30
  if (!existsSync(base)) return false;
30
31
  const stack = [base];
31
32
  while (stack.length) {
@@ -88,7 +89,7 @@ for (const scanRoot of roots) {
88
89
  if (modules.length === 0) {
89
90
  violations.push(mk(
90
91
  RULE,
91
- "plan/_journeys",
92
+ `${PLAN_ROOT}/_journeys`,
92
93
  1,
93
94
  0,
94
95
  "missing-journey-runner: journey topology is declared but no JourneyRunner class exists under src/trains/",
@@ -13,6 +13,7 @@ import {
13
13
  mk,
14
14
  writeReport,
15
15
  maskComments,
16
+ PLAN_ROOT,
16
17
  } from "../_shared/interlocking.mjs";
17
18
 
18
19
  const RULE = "coder.bun.station-master-journey-routing";
@@ -24,7 +25,7 @@ function escapeRegExp(value) {
24
25
  }
25
26
 
26
27
  function journeyDocuments(croot) {
27
- const base = join(croot, "plan", "_journeys");
28
+ const base = join(croot, PLAN_ROOT, "_journeys");
28
29
  if (!existsSync(base)) return [];
29
30
  const out = [], stack = [base];
30
31
  while (stack.length) {
@@ -30,6 +30,7 @@ const RULE = "coder.bun.wagon-honours-its-contract";
30
30
  const EXCLUDES = ["node_modules", "dist", "build", ".next", ".git", "_generated"];
31
31
  const TS = /\.(ts|tsx|mjs|js)$/;
32
32
  const TEST = /\.(test|spec)\.[cm]?[jt]sx?$/;
33
+ const PLAN_ROOT = process.env.ATDD_PLAN_ROOT || "plan";
33
34
 
34
35
  const read = (p) => { try { return readFileSync(p, "utf8"); } catch { return ""; } };
35
36
 
@@ -61,7 +62,7 @@ function consumerRoots(root, excludes) {
61
62
  let st;
62
63
  try { st = statSync(d); } catch { return; }
63
64
  if (!st.isDirectory()) return;
64
- try { if (statSync(join(d, "plan")).isDirectory()) { roots.push(d); return; } } catch {}
65
+ try { if (statSync(join(d, PLAN_ROOT)).isDirectory()) { roots.push(d); return; } } catch {}
65
66
  let entries;
66
67
  try { entries = readdirSync(d).sort(); } catch { return; }
67
68
  for (const n of entries) if (!excludes.includes(n)) rec(join(d, n));
@@ -111,7 +112,7 @@ const violations = [];
111
112
  for (const root of parseJsonEnv("ATDD_SCAN_ROOTS", [])) {
112
113
  for (const croot of consumerRoots(root, excludes)) {
113
114
  const contracts = [];
114
- for (const f of walk(join(croot, "plan"), (p) => /\.ya?ml$/.test(p), excludes)) {
115
+ for (const f of walk(join(croot, PLAN_ROOT), (p) => /\.ya?ml$/.test(p), excludes)) {
115
116
  const c = wagonContract(read(f));
116
117
  if (c) contracts.push({ file: f, ...c });
117
118
  }
@@ -0,0 +1,18 @@
1
+ schema_version: 1.1.0
2
+ kind: implementation
3
+ subtype: validator
4
+ implementation_id: bun_responsive_detector
5
+ targets_workspace: atdd.workspace.bun
6
+ contract_version: 1.1.0
7
+ emits_rule_ids:
8
+ - coder.bun.responsive-viewport-meta
9
+ - coder.bun.responsive-no-fixed-width
10
+ - coder.bun.responsive-breakpoints-declared
11
+ entrypoint: detect.mjs
12
+ report: detect.mjs
13
+ realizes_convention:
14
+ # Static responsiveness: the structural causes of screens that break on small devices.
15
+ # The browser proof is tester.htmx.responsive-harness and -journey-coverage.
16
+ - coder.bun.responsive-viewport-meta
17
+ - coder.bun.responsive-no-fixed-width
18
+ - coder.bun.responsive-breakpoints-declared
@@ -0,0 +1,5 @@
1
+ {
2
+ "responsive_viewport_meta": "coder.bun.responsive-viewport-meta",
3
+ "responsive_no_fixed_width": "coder.bun.responsive-no-fixed-width",
4
+ "responsive_breakpoints_declared": "coder.bun.responsive-breakpoints-declared"
5
+ }
@@ -0,0 +1,20 @@
1
+ // Shared scaffolding for the bun_responsive_detector family: every UI file under the scan roots.
2
+ import { resolve } from "node:path";
3
+ import { walk, readRoots, readExcludes, readText, emit, locate } from "../../../lib/scan.mjs";
4
+ export { frontendConfig } from "../../../lib/frontend.mjs";
5
+ export { declarations } from "../../bun_design_system_detector/checks/_design.mjs";
6
+
7
+ // Bun/htmx pages are often built in .ts/.js template strings, so those are judged like markup.
8
+ const UI = new Set([".html", ".htm", ".css", ".tsx", ".jsx", ".ts", ".js", ".mjs"]);
9
+
10
+ /** Run a check over each scan root: `judge(root, files, report)` where files are { file, text }. */
11
+ export function runCheck(tag, judge) {
12
+ const violations = [];
13
+ for (const root of readRoots()) {
14
+ const files = [];
15
+ for (const file of walk(root, readExcludes(), UI)) { const text = readText(file); if (text !== null) files.push({ file, text }); }
16
+ judge(resolve(root), files, (rule_id, file, text, index, evidence) => violations.push({ rule_id, file, ...locate(text, index), evidence }));
17
+ }
18
+ process.stderr.write(`bun-detector[${tag}]: ${violations.length} violation(s)\n`);
19
+ emit(violations);
20
+ }
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bun
2
+ // coder.bun.responsive-breakpoints-declared: every @media width is one of the declared breakpoints.
3
+ import { runCheck, frontendConfig } from "./_responsive.mjs";
4
+ const RULE = "coder.bun.responsive-breakpoints-declared";
5
+ runCheck("responsive-breakpoints-declared", (root, files, report) => {
6
+ const { breakpoints } = frontendConfig(root);
7
+ // max-width queries conventionally sit just below a breakpoint (767px, 767.98px); anything within 1px of one is that breakpoint.
8
+ const declared = (px, kind) => breakpoints.some((b) => px === b || (kind === "max" && px < b && b - px <= 1));
9
+ for (const { file, text } of files) for (const media of text.matchAll(/@media[^{]*/gi)) {
10
+ const conditions = [
11
+ ...[...media[0].matchAll(/\b(min|max)-width\s*:\s*(\d*\.?\d+)(px|em|rem)\b/gi)].map((m) => ({ kind: m[1].toLowerCase(), value: m[2], unit: m[3], index: m.index })),
12
+ // Media Queries level 4 range syntax: (width >= 768px), (width < 768px), (768px <= width).
13
+ ...[...media[0].matchAll(/\bwidth\s*(>=|>|<=|<)\s*(\d*\.?\d+)(px|em|rem)\b/gi)].map((m) => ({ kind: m[1].startsWith(">") ? "min" : "max", value: m[2], unit: m[3], index: m.index })),
14
+ ...[...media[0].matchAll(/(\d*\.?\d+)(px|em|rem)\s*(>=|>|<=|<)\s*width\b/gi)].map((m) => ({ kind: m[3].startsWith("<") ? "min" : "max", value: m[1], unit: m[2], index: m.index })),
15
+ ];
16
+ for (const c of conditions) {
17
+ const px = c.unit.toLowerCase() === "px" ? Number(c.value) : Number(c.value) * 16;
18
+ if (!declared(px, c.kind)) report(RULE, file, text, media.index + c.index, `@media ${c.kind === "min" ? "lower" : "upper"} bound ${c.value}${c.unit} is not a declared breakpoint (${breakpoints.join(", ")}); declare it under frontend.breakpoints in atdd-bun.yaml or use one of these`);
19
+ }
20
+ }
21
+ });
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bun
2
+ // coder.bun.responsive-no-fixed-width: no width or min-width wider than the smallest declared viewport.
3
+ import { runCheck, declarations, frontendConfig } from "./_responsive.mjs";
4
+ const RULE = "coder.bun.responsive-no-fixed-width";
5
+ runCheck("responsive-no-fixed-width", (root, files, report) => {
6
+ const limit = frontendConfig(root).viewports[0];
7
+ for (const { file, text } of files) for (const d of declarations(file, text)) {
8
+ if (d.prop !== "width" && d.prop !== "min-width") continue;
9
+ // rem and em count at 16px: `width: 40rem` is 640px on every default browser.
10
+ const px = d.numeric ? Number(d.value) : Math.max(0, ...[...d.value.matchAll(/(-?\d*\.?\d+)(px|rem|em)\b/g)].map((m) => Number(m[1]) * (m[2] === "px" ? 1 : 16)));
11
+ if (px > limit) report(RULE, file, text, d.index, `${d.prop}: ${d.value.trim()} (${px}px) is wider than the smallest viewport (${limit}px); use max-width, a percentage, or a layout that wraps`);
12
+ }
13
+ });