@afokapu/atdd-bun 0.3.2 → 0.5.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 (208) hide show
  1. package/PLANNER_PORT.md +1 -1
  2. package/README.md +111 -4
  3. package/conventions/coder.bun/coder.bun.complexity-cyclomatic.convention.yaml +1 -3
  4. package/conventions/coder.bun/coder.bun.complexity-length.convention.yaml +1 -2
  5. package/conventions/coder.bun/coder.bun.complexity-nesting.convention.yaml +1 -2
  6. package/conventions/coder.bun/coder.bun.dead-code-reachability.convention.yaml +1 -2
  7. package/conventions/coder.bun/coder.bun.design-token-color.convention.yaml +1 -1
  8. package/conventions/coder.bun/coder.bun.design-token-hardcoded.convention.yaml +1 -1
  9. package/conventions/coder.bun/coder.bun.dto-mapper.convention.yaml +2 -1
  10. package/conventions/coder.bun/coder.bun.dto-placement.convention.yaml +2 -1
  11. package/conventions/coder.bun/coder.bun.dto-purity.convention.yaml +2 -1
  12. package/conventions/coder.bun/coder.bun.duplication-intra-layer.convention.yaml +1 -2
  13. package/conventions/coder.bun/coder.bun.green-header-order.convention.yaml +2 -11
  14. package/conventions/coder.bun/coder.bun.green-header-purpose.convention.yaml +2 -11
  15. package/conventions/coder.bun/coder.bun.green-header-runtime.convention.yaml +2 -11
  16. package/conventions/coder.bun/coder.bun.green-header-tested-by.convention.yaml +2 -11
  17. package/conventions/coder.bun/coder.bun.green-urn-layer.convention.yaml +2 -11
  18. package/conventions/coder.bun/coder.bun.green-urn-marker.convention.yaml +2 -12
  19. package/conventions/coder.bun/coder.bun.green-urn-name-matches.convention.yaml +3 -12
  20. package/conventions/coder.bun/coder.bun.green-urn-pattern.convention.yaml +2 -11
  21. package/conventions/coder.bun/coder.bun.green-urn-side.convention.yaml +2 -11
  22. package/conventions/coder.bun/coder.bun.green-urn-wagon-feature.convention.yaml +2 -11
  23. package/conventions/coder.bun/coder.bun.layer-naming.convention.yaml +2 -1
  24. package/conventions/coder.bun/coder.bun.logging-structured.convention.yaml +2 -4
  25. package/conventions/coder.bun/coder.bun.quality-comments.convention.yaml +1 -2
  26. package/conventions/coder.bun/coder.bun.quality-mi.convention.yaml +1 -2
  27. package/conventions/coder.bun/coder.bun.responsive-breakpoints-declared.convention.yaml +31 -0
  28. package/conventions/coder.bun/coder.bun.responsive-no-fixed-width.convention.yaml +30 -0
  29. package/conventions/coder.bun/coder.bun.responsive-viewport-meta.convention.yaml +31 -0
  30. package/conventions/coder.bun/coder.bun.runtime-executes-the-declaration.convention.yaml +2 -18
  31. package/conventions/coder.bun/coder.bun.runtime-typecheck-enforced.convention.yaml +2 -15
  32. package/conventions/coder.htmx/coder.htmx.swap-no-inline-handler.convention.yaml +3 -3
  33. package/conventions/coder.htmx/coder.htmx.verb-endpoint-is-routed.convention.yaml +2 -2
  34. package/conventions/coder.htmx/coder.htmx.verb-mutation-signals-progress.convention.yaml +3 -2
  35. package/conventions/planner.docs/planner.docs.adr-registry-derived.convention.yaml +2 -12
  36. package/conventions/planner.docs/planner.docs.area-index-required.convention.yaml +2 -12
  37. package/conventions/planner.docs/planner.docs.artifact-path-shape.convention.yaml +2 -12
  38. package/conventions/planner.docs/planner.docs.asciidoc-only.convention.yaml +2 -13
  39. package/conventions/planner.docs/planner.docs.doc-id-unique.convention.yaml +2 -12
  40. package/conventions/planner.docs/planner.docs.graph-target-resolves.convention.yaml +2 -12
  41. package/conventions/planner.docs/planner.docs.identity-required.convention.yaml +2 -12
  42. package/conventions/planner.docs/planner.docs.journey-view-current.convention.yaml +53 -0
  43. package/conventions/planner.docs/planner.docs.reference-integrity.convention.yaml +2 -12
  44. package/conventions/planner.docs/planner.docs.undeclared-change.convention.yaml +2 -12
  45. package/conventions/tester.bun/tester.bun.acceptance-binding-declared.convention.yaml +2 -6
  46. package/conventions/tester.bun/tester.bun.acceptance-covers-tag-well-formed.convention.yaml +1 -5
  47. package/conventions/tester.bun/tester.bun.interlocking-train-sequence-is-exercised.convention.yaml +2 -14
  48. package/conventions/tester.bun/tester.bun.red-behavioral-assertion.convention.yaml +1 -5
  49. package/conventions/tester.bun/tester.bun.routing-runtime-family.convention.yaml +2 -1
  50. package/conventions/tester.bun/tester.bun.smoke-no-collaborator-substitution.convention.yaml +2 -7
  51. package/conventions/tester.bun/tester.bun.smoke-observable-assertion.convention.yaml +2 -7
  52. package/conventions/tester.bun/tester.bun.telemetry-emit.convention.yaml +2 -1
  53. package/conventions/tester.bun/tester.bun.test-carries-urn-identity.convention.yaml +2 -6
  54. package/conventions/tester.bun/tester.bun.test-isolation-no-live-state.convention.yaml +1 -5
  55. package/conventions/tester.bun/tester.bun.test-no-self-skip.convention.yaml +1 -5
  56. package/conventions/tester.bun/tester.bun.test-phase-declared.convention.yaml +2 -6
  57. package/conventions/tester.htmx/tester.htmx.a11y-harness.convention.yaml +32 -0
  58. package/conventions/tester.htmx/tester.htmx.covered-train-is-routed.convention.yaml +30 -0
  59. package/conventions/tester.htmx/tester.htmx.e2e-binds-declared-subject.convention.yaml +31 -0
  60. package/conventions/tester.htmx/tester.htmx.e2e-spec-naming.convention.yaml +32 -0
  61. package/conventions/tester.htmx/tester.htmx.exposed-journey-e2e-coverage.convention.yaml +31 -0
  62. package/conventions/tester.htmx/tester.htmx.fragment-asserts-returned-markup.convention.yaml +2 -6
  63. package/conventions/tester.htmx/tester.htmx.journey-binding-header.convention.yaml +32 -0
  64. package/conventions/tester.htmx/tester.htmx.journey-layer-assembly.convention.yaml +29 -0
  65. package/conventions/tester.htmx/tester.htmx.journey-no-acceptance-marker.convention.yaml +29 -0
  66. package/conventions/tester.htmx/tester.htmx.journey-urn-format.convention.yaml +32 -0
  67. package/conventions/tester.htmx/tester.htmx.presentation-smoke-coverage.convention.yaml +31 -0
  68. package/conventions/tester.htmx/tester.htmx.responsive-harness.convention.yaml +33 -0
  69. package/conventions/tester.htmx/tester.htmx.responsive-journey-coverage.convention.yaml +30 -0
  70. package/conventions/tester.htmx/tester.htmx.swap-oob-asserts-destination-id.convention.yaml +2 -1
  71. package/conventions/tester.htmx/tester.htmx.train-e2e-coverage.convention.yaml +32 -0
  72. package/conventions/tester.htmx/tester.htmx.verb-endpoint-coverage.convention.yaml +2 -1
  73. package/conventions/tester.htmx/tester.htmx.visual-harness.convention.yaml +28 -0
  74. package/conventions/traceability/traceability.plan.executable-acceptance-has-test.convention.yaml +5 -0
  75. package/conventions/traceability/traceability.source.tested-by-present.convention.yaml +5 -0
  76. package/conventions/traceability/traceability.source.tested-by-resolves.convention.yaml +5 -0
  77. package/conventions/traceability/traceability.test.binding-resolves.convention.yaml +5 -0
  78. package/detectors/bun_green_traceability_detector/urn_header.mjs +2 -1
  79. package/detectors/bun_responsive_detector/atdd.implementation.yaml +18 -0
  80. package/detectors/bun_responsive_detector/checks/_map.json +5 -0
  81. package/detectors/bun_responsive_detector/checks/_responsive.mjs +20 -0
  82. package/detectors/bun_responsive_detector/checks/responsive_breakpoints_declared.mjs +21 -0
  83. package/detectors/bun_responsive_detector/checks/responsive_no_fixed_width.mjs +13 -0
  84. package/detectors/bun_responsive_detector/checks/responsive_viewport_meta.mjs +20 -0
  85. package/detectors/bun_responsive_detector/detect.mjs +46 -0
  86. package/detectors/bun_responsive_detector/fixtures/clean/public/index.html +9 -0
  87. package/detectors/bun_responsive_detector/fixtures/clean/src/app.css +13 -0
  88. package/detectors/bun_responsive_detector/fixtures/clean/src/document.ts +7 -0
  89. package/detectors/bun_responsive_detector/fixtures/clean/src/fragment.html +1 -0
  90. package/detectors/bun_responsive_detector/fixtures/clean/src/head.ts +1 -0
  91. package/detectors/bun_responsive_detector/fixtures/clean/src/layout.tsx +8 -0
  92. package/detectors/bun_responsive_detector/fixtures/clean/src/range.css +3 -0
  93. package/detectors/bun_responsive_detector/fixtures/clean/src/viewport-expression.tsx +5 -0
  94. package/detectors/bun_responsive_detector/fixtures/dirty/public/fixed-viewport.html +5 -0
  95. package/detectors/bun_responsive_detector/fixtures/dirty/public/no-meta.html +5 -0
  96. package/detectors/bun_responsive_detector/fixtures/dirty/public/no-zoom.html +5 -0
  97. package/detectors/bun_responsive_detector/fixtures/dirty/public/scale.html +5 -0
  98. package/detectors/bun_responsive_detector/fixtures/dirty/src/app.css +4 -0
  99. package/detectors/bun_responsive_detector/fixtures/dirty/src/render.ts +2 -0
  100. package/detectors/bun_responsive_detector/fixtures/dirty/src/units.css +3 -0
  101. package/detectors/bun_responsive_detector/fixtures/dirty/src/wide.tsx +3 -0
  102. package/detectors/htmx_e2e_detector/atdd.implementation.yaml +40 -0
  103. package/detectors/htmx_e2e_detector/checks/_e2e.mjs +98 -0
  104. package/detectors/htmx_e2e_detector/checks/_map.json +16 -0
  105. package/detectors/htmx_e2e_detector/checks/a11y_harness.mjs +21 -0
  106. package/detectors/htmx_e2e_detector/checks/covered_train_is_routed.mjs +6 -0
  107. package/detectors/htmx_e2e_detector/checks/e2e_binds_declared_subject.mjs +11 -0
  108. package/detectors/htmx_e2e_detector/checks/e2e_spec_naming.mjs +6 -0
  109. package/detectors/htmx_e2e_detector/checks/exposed_journey_e2e_coverage.mjs +7 -0
  110. package/detectors/htmx_e2e_detector/checks/journey_binding_header.mjs +13 -0
  111. package/detectors/htmx_e2e_detector/checks/journey_layer_assembly.mjs +11 -0
  112. package/detectors/htmx_e2e_detector/checks/journey_no_acceptance_marker.mjs +6 -0
  113. package/detectors/htmx_e2e_detector/checks/journey_urn_format.mjs +18 -0
  114. package/detectors/htmx_e2e_detector/checks/presentation_smoke_coverage.mjs +15 -0
  115. package/detectors/htmx_e2e_detector/checks/responsive_harness.mjs +30 -0
  116. package/detectors/htmx_e2e_detector/checks/responsive_journey_coverage.mjs +7 -0
  117. package/detectors/htmx_e2e_detector/checks/train_e2e_coverage.mjs +7 -0
  118. package/detectors/htmx_e2e_detector/checks/visual_harness.mjs +7 -0
  119. package/detectors/htmx_e2e_detector/detect.mjs +46 -0
  120. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/buy-alias.a11y.e2e.ts +12 -0
  121. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/buy-inline.responsive.e2e.ts +11 -0
  122. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/buy.a11y.e2e.ts +12 -0
  123. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/buy.responsive.e2e.ts +14 -0
  124. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/buy.visual.e2e.ts +10 -0
  125. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/checkout-buy.smoke.e2e.ts +10 -0
  126. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/crlf.e2e.ts +10 -0
  127. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/place-order.e2e.ts +10 -0
  128. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/reject-empty-cart.e2e.ts +10 -0
  129. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/support/fixtures.ts +3 -0
  130. package/detectors/htmx_e2e_detector/fixtures/clean/e2e/take-payment.e2e.ts +10 -0
  131. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_journeys/buy.yaml +19 -0
  132. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_journeys/settle.yaml +14 -0
  133. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/_interlockings/checkout.yaml +21 -0
  134. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/_interlockings/payment.yaml +16 -0
  135. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/_interlockings/settlement.yaml +16 -0
  136. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/orders/place-order.yaml +11 -0
  137. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/orders/reject-empty-cart.yaml +11 -0
  138. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/orders/settle-ledger.yaml +11 -0
  139. package/detectors/htmx_e2e_detector/fixtures/clean/plan/_trains/orders/take-payment.yaml +11 -0
  140. package/detectors/htmx_e2e_detector/fixtures/clean/playwright.config.ts +3 -0
  141. package/detectors/htmx_e2e_detector/fixtures/clean/src/wagons/checkout/presentation/cart.tsx +3 -0
  142. package/detectors/htmx_e2e_detector/fixtures/clean/tests/urn-parser.test.ts +7 -0
  143. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/acceptance.e2e.ts +6 -0
  144. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/bad-id.e2e.ts +10 -0
  145. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/bad-urn.e2e.ts +10 -0
  146. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/both.e2e.ts +6 -0
  147. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/buy.a11y.e2e.ts +10 -0
  148. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/buy.responsive.e2e.ts +11 -0
  149. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/buy.visual.e2e.ts +10 -0
  150. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/covered.e2e.ts +10 -0
  151. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/first-only.responsive.e2e.ts +13 -0
  152. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/ghost.e2e.ts +10 -0
  153. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/misnamed.spec.ts +10 -0
  154. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/no-binding.e2e.ts +4 -0
  155. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/no-layer.e2e.ts +4 -0
  156. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/no-urn.e2e.ts +4 -0
  157. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/tautology.a11y.e2e.ts +11 -0
  158. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/two-trains.e2e.ts +6 -0
  159. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/unrouted.e2e.ts +10 -0
  160. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/wrong-layer.e2e.ts +5 -0
  161. package/detectors/htmx_e2e_detector/fixtures/dirty/e2e/wrong-urn.e2e.ts +10 -0
  162. package/detectors/htmx_e2e_detector/fixtures/dirty/plan/_journeys/browse.yaml +16 -0
  163. package/detectors/htmx_e2e_detector/fixtures/dirty/plan/_journeys/buy.yaml +16 -0
  164. package/detectors/htmx_e2e_detector/fixtures/dirty/plan/_trains/_interlockings/checkout.yaml +21 -0
  165. package/detectors/htmx_e2e_detector/fixtures/dirty/plan/_trains/orders/covered.yaml +11 -0
  166. package/detectors/htmx_e2e_detector/fixtures/dirty/plan/_trains/orders/uncovered.yaml +11 -0
  167. package/detectors/htmx_e2e_detector/fixtures/dirty/plan/_trains/orders/unrouted.yaml +11 -0
  168. package/detectors/htmx_e2e_detector/fixtures/dirty/src/wagons/billing/presentation/invoice.tsx +3 -0
  169. package/detectors/planner_docs_capability/atdd.implementation.yaml +2 -0
  170. package/detectors/planner_docs_capability/fixtures/clean/docs/purpose/journeys/index.adoc +170 -0
  171. package/detectors/planner_docs_capability/fixtures/clean/docs/purpose/journeys/svg/journey-buy.svg +61 -0
  172. package/detectors/planner_docs_capability/fixtures/clean/docs/purpose/journeys/svg/path-buy-nominal.svg +81 -0
  173. package/detectors/planner_docs_capability/fixtures/clean/docs/purpose/journeys/svg/train-orders-archive-order.svg +13 -0
  174. package/detectors/planner_docs_capability/fixtures/clean/docs/purpose/journeys/svg/train-orders-place-order.svg +31 -0
  175. package/detectors/planner_docs_capability/fixtures/clean/docs/purpose/journeys/svg/train-orders-reject-empty-cart.svg +22 -0
  176. package/detectors/planner_docs_capability/fixtures/clean/docs/purpose/journeys/svg/train-orders-retry-payment.svg +22 -0
  177. package/detectors/planner_docs_capability/fixtures/clean/docs/purpose/journeys/svg/train-orders-ship-order.svg +35 -0
  178. package/detectors/planner_docs_capability/fixtures/clean/docs/purpose/journeys/svg/train-orders-take-payment.svg +27 -0
  179. package/detectors/planner_docs_capability/fixtures/clean/plan/_journeys/buy.yaml +24 -0
  180. package/detectors/planner_docs_capability/fixtures/clean/plan/_themes.yaml +3 -0
  181. package/detectors/planner_docs_capability/fixtures/clean/plan/_trains/_interlockings/checkout.yaml +43 -0
  182. package/detectors/planner_docs_capability/fixtures/clean/plan/_trains/_interlockings/fulfilment.yaml +33 -0
  183. package/detectors/planner_docs_capability/fixtures/clean/plan/_trains/_interlockings/payment.yaml +43 -0
  184. package/detectors/planner_docs_capability/fixtures/clean/plan/_trains/_interlockings/returns.yaml +33 -0
  185. package/detectors/planner_docs_capability/fixtures/clean/plan/_trains/orders/archive-order.yaml +11 -0
  186. package/detectors/planner_docs_capability/fixtures/clean/plan/_trains/orders/place-order.yaml +21 -0
  187. package/detectors/planner_docs_capability/fixtures/clean/plan/_trains/orders/reject-empty-cart.yaml +16 -0
  188. package/detectors/planner_docs_capability/fixtures/clean/plan/_trains/orders/retry-payment.yaml +16 -0
  189. package/detectors/planner_docs_capability/fixtures/clean/plan/_trains/orders/ship-order.yaml +21 -0
  190. package/detectors/planner_docs_capability/fixtures/clean/plan/_trains/orders/take-payment.yaml +21 -0
  191. package/detectors/planner_docs_capability/fixtures/clean/plan/_trains/orders/unrouted-refund.yaml +11 -0
  192. package/detectors/planner_docs_capability/fixtures/dirty_journey_view/docs/index.adoc +3 -0
  193. package/detectors/planner_docs_capability/fixtures/dirty_journey_view/plan/_trains/_interlockings/fulfilment.yaml +33 -0
  194. package/detectors/planner_static_validators/atdd.implementation.yaml +2 -0
  195. package/detectors/planner_static_validators/fixtures/dirty/plan/_trains/_interlockings/uncomposed.yaml +4 -0
  196. package/integrity.json +207 -70
  197. package/lib/frontend.mjs +17 -0
  198. package/lib/scan.mjs +6 -2
  199. package/package.json +7 -2
  200. package/planner-nodes/ENFORCEMENT_SCOPE.yaml +25 -2
  201. package/planner-nodes/nodes/planner.journey.interlocking-composed.convention.yaml +43 -0
  202. package/relationships.yaml +4790 -0
  203. package/src/cli.ts +9 -0
  204. package/src/docs-capability.ts +10 -1
  205. package/src/enforce.ts +4 -4
  206. package/src/index.ts +2 -0
  207. package/src/journey-docs.ts +355 -0
  208. package/src/planner-validators.ts +29 -0
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import { finishWorktree, hookEvents, hooksStatus, installHooks, runHook, startWo
4
4
  import { ciInit, ciStatus } from "./ci";
5
5
  import { agentInit, agentStatus } from "./agent";
6
6
  import { checkIntegrity, formatIntegrity, integrityInit, integrityStatus } from "./integrity";
7
+ import { journeyDocs } from "./journey-docs";
7
8
  import { releaseCheck } from "./release";
8
9
  import { initializeRepository } from "./setup";
9
10
 
@@ -18,6 +19,7 @@ const usage = {
18
19
  "atdd-bun ci <init|status> [--replace]",
19
20
  "atdd-bun agent <init|status> [--replace]",
20
21
  "atdd-bun integrity [init|status] [--replace]",
22
+ "atdd-bun docs journeys [--out <dir>] [--check] [--force]",
21
23
  "atdd-bun release check",
22
24
  ],
23
25
  profiles: profileNames,
@@ -68,6 +70,13 @@ if (args[0] === "ci") {
68
70
  const result = args[1] === "init" ? await ciInit(process.cwd(), args.includes("--replace")) : args[1] === "status" ? await ciStatus() : fail("ci requires init or status");
69
71
  console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
70
72
  }
73
+ if (args[0] === "docs") {
74
+ if (args[1] !== "journeys") fail("docs requires journeys");
75
+ const at = args.indexOf("--out"), out = at === -1 ? undefined : args[at + 1];
76
+ if (at !== -1 && !out) fail("--out requires a directory");
77
+ const result = await journeyDocs({ out, check: args.includes("--check"), force: args.includes("--force") });
78
+ console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
79
+ }
71
80
  if (args[0] === "integrity") {
72
81
  if (args[1] === "init" || args[1] === "status") { const result = args[1] === "init" ? await integrityInit(process.cwd(), args.includes("--replace")) : await integrityStatus(); console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1); }
73
82
  if (args[1] !== undefined) fail("integrity takes no argument, or init/status");
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { journeyDocs, journeyDocsApply } from "./journey-docs";
2
3
  import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
5
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
@@ -6,7 +7,7 @@ import { basename, isAbsolute, join, relative, resolve } from "node:path";
6
7
  export const DOC_RULE_IDS = [
7
8
  "planner.docs.asciidoc-only", "planner.docs.identity-required", "planner.docs.doc-id-unique",
8
9
  "planner.docs.graph-target-resolves", "planner.docs.area-index-required", "planner.docs.adr-registry-derived",
9
- "planner.docs.artifact-path-shape", "planner.docs.undeclared-change", "planner.docs.reference-integrity",
10
+ "planner.docs.artifact-path-shape", "planner.docs.undeclared-change", "planner.docs.reference-integrity", "planner.docs.journey-view-current",
10
11
  ] as const;
11
12
  export type DocumentationRuleId = typeof DOC_RULE_IDS[number];
12
13
  export type DocumentationViolation = { rule_id: DocumentationRuleId; file: string; line: number; col: number; evidence: string; source_line: string };
@@ -94,9 +95,17 @@ export async function scanDocumentation(root: string): Promise<DocumentationViol
94
95
  output.push(...graphViolations(docs));
95
96
  for (const area of AREAS) if (existsSync(join(absolute, area)) && !existsSync(join(absolute, area, "index.adoc"))) output.push(violation("planner.docs.area-index-required", `${area}/index.adoc`, 1, `canonical area ${area}/ exists and carries no index.adoc. The rendered site cannot navigate into an area with no entry point.`));
96
97
  output.push(...adrViolations(docs));
98
+ output.push(...await journeyViewViolations(absolute));
97
99
  return output;
98
100
  }
99
101
 
102
+ /** Where the plan has journeys or interlockings, the committed journey view must be exactly what plan/ generates. */
103
+ async function journeyViewViolations(root: string): Promise<DocumentationViolation[]> {
104
+ if (!await journeyDocsApply(root)) return [];
105
+ const result = await journeyDocs({ root, check: true });
106
+ return result.stale.map(file => violation("planner.docs.journey-view-current", file, 1, `${file} does not match what plan/ generates, so the journey documentation no longer shows the plan. Regenerate it with \`atdd-bun docs journeys\` and commit the result; never edit it by hand.`));
107
+ }
108
+
100
109
  export function declarationViolations(declaration: DocumentationDeclaration | null, changeSet?: string[]): DocumentationViolation[] {
101
110
  if (!declaration) return [];
102
111
  const artifacts = Array.isArray(declaration.artifacts) ? declaration.artifacts : []; const output: DocumentationViolation[] = [];
package/src/enforce.ts CHANGED
@@ -24,15 +24,15 @@ const profiles: Record<Exclude<Profile, "all">, string[]> = {
24
24
  traceability: ["atdd_traceability_closure"],
25
25
  docs: ["planner_docs_capability"],
26
26
  planner: ["planner_plan_integrity", "planner_schema_validation", "planner_static_validators"],
27
- coder: ["bun_green_traceability_detector", "bun_clean_architecture_detector", "bun_ts_metrics_detector", "bun_fullstack_detector", "bun_design_system_detector"],
28
- tester: ["bun_tester_discipline_detector"],
27
+ coder: ["bun_green_traceability_detector", "bun_clean_architecture_detector", "bun_ts_metrics_detector", "bun_fullstack_detector", "bun_design_system_detector", "bun_responsive_detector"],
28
+ tester: ["bun_tester_discipline_detector", "htmx_e2e_detector"],
29
29
  security: ["bun_security_hygiene_detector"],
30
30
  architecture: ["bun_clean_architecture_detector"],
31
31
  metrics: ["bun_ts_metrics_detector"],
32
32
  runtime: ["bun_fullstack_detector"],
33
33
  interlocking: ["bun_interlocking_binding", "bun_interlocking_coverage", "bun_interlocking_infrastructure"],
34
- htmx: ["htmx_hypermedia_detector", "htmx_tester_detector"],
35
- design: ["bun_design_system_detector"],
34
+ htmx: ["htmx_hypermedia_detector", "htmx_tester_detector", "htmx_e2e_detector"],
35
+ design: ["bun_design_system_detector", "bun_responsive_detector"],
36
36
  };
37
37
 
38
38
  /** Profile names accepted by the CLI and public integrations. */
package/src/index.ts CHANGED
@@ -10,6 +10,8 @@ export { defaultHookPolicy, hookEvents, hooksStatus, installHooks, runHook, unin
10
10
  export type { HookEvent, HookPolicy } from "./hooks";
11
11
  export { ciInit, ciStatus } from "./ci";
12
12
  export { initializeRepository } from "./setup";
13
+ export { buildModel, journeyDocs, journeyDocsApply, journeyMapSvg, journeyPaths, nominalPath, renderJourneyDocs, sequenceSvg } from "./journey-docs";
14
+ export type { Gap, Journey, JourneyModel, JourneyPath } from "./journey-docs";
13
15
  export { checkIntegrity, checkInstalledPackage, formatIntegrity, integrityInit, loosenedPolicy, writeManifest } from "./integrity";
14
16
  export type { IntegrityFinding, IntegrityOptions } from "./integrity";
15
17
  export { releaseCheck } from "./release";
@@ -0,0 +1,355 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+ import { loadPlan, type PlanGraph } from "./planner-kernel";
5
+
6
+ /**
7
+ * JOURNEY DOCUMENTATION, DRAWN FROM plan/.
8
+ *
9
+ * Three levels, top down: a JOURNEY enters at one interlocking and continues, through an artifact
10
+ * the selected train produces, into the next; an INTERLOCKING chooses one route by its guards; a
11
+ * ROUTE runs one TRAIN, a linear sequence of handovers. The page draws each journey as a map,
12
+ * walks its nominal path end to end as one sequence, drills into every interlocking and train, and
13
+ * tables what the plan leaves unconnected. It renders planned behaviour, not acceptance evidence.
14
+ *
15
+ * Output is deterministic (sorted, no timestamps, no package version) so `--check` can compare it
16
+ * byte for byte, and SVG colours are `var(--atdd-*, fallback)` so a doc site can theme them.
17
+ */
18
+
19
+ export type Step = { step: number; intent: string; from: string; to: string; artifact: string };
20
+ export type Train = { id: string; title: string; file: string; participants: string[]; sequence: Step[] };
21
+ export type Route = { id: string; category: string; priority: number; guardRef: string; guard?: string; trainId: string };
22
+ export type Interlocking = { id: string; title: string; file: string; exposed: boolean; actions: string[]; routes: Route[] };
23
+ type RouteRef = { interlocking: string; route: string };
24
+ export type Journey = {
25
+ id: string; title: string; file: string; status: string; entry: string; exposed: boolean; actions: string[]; reason: string;
26
+ continuations: Array<{ from: RouteRef; artifact: string; to: string }>; terminals: Array<{ from: RouteRef; outcome: string }>;
27
+ };
28
+ export type Gap = { kind: string; subject: string; detail: string };
29
+ export type JourneyModel = { journeys: Journey[]; interlockings: Map<string, Interlocking>; trains: Map<string, Train>; gaps: Gap[] };
30
+ export type PathLeg = { interlocking: string; route: Route; artifact?: string };
31
+ export type JourneyPath = { legs: PathLeg[]; end: { kind: "terminal"; outcome: string } | { kind: "loop"; to: string } | { kind: "open" } };
32
+
33
+ const str = (value: unknown) => typeof value === "string" ? value : "";
34
+ const list = (value: unknown): Record<string, unknown>[] => Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object") : [];
35
+ const rec = (value: unknown) => (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
36
+ const byId = <T extends { id: string }>(a: T, b: T) => a.id.localeCompare(b.id);
37
+ const CATEGORY_ORDER = ["nominal", "alternate", "error", "exception"];
38
+ const categoryRank = (category: string) => { const i = CATEGORY_ORDER.indexOf(category); return i === -1 ? CATEGORY_ORDER.length : i; };
39
+
40
+ export function buildModel(graph: PlanGraph): JourneyModel {
41
+ const trains = new Map<string, Train>(), interlockings = new Map<string, Interlocking>(), journeys: Journey[] = [], gaps: Gap[] = [];
42
+ for (const artifact of graph.artifacts) {
43
+ const d = artifact.data;
44
+ if (artifact.kind === "train") trains.set(artifact.id, {
45
+ id: artifact.id, title: str(d.title) || artifact.id, file: artifact.file,
46
+ participants: Array.isArray(d.participants) ? d.participants.map(String) : [],
47
+ sequence: list(d.sequence).map((s, i) => ({ step: Number(s.step) || i + 1, intent: str(s.intent), from: str(s.from), to: str(s.to), artifact: str(s.artifact) })),
48
+ });
49
+ if (artifact.kind === "interlocking") {
50
+ const guards = new Map(list(d.fragments).flatMap(f => list(f.guards)).map(g => [str(g.id), str(g.expression)] as const));
51
+ const entry = rec(d.entrypoint);
52
+ interlockings.set(artifact.id, {
53
+ id: artifact.id, title: str(d.title) || artifact.id, file: artifact.file, exposed: entry.exposed === true,
54
+ actions: Array.isArray(entry.actions) ? entry.actions.map(String) : [],
55
+ routes: list(d.routes).map(r => ({ id: str(r.route_id), category: str(r.category) || "nominal", priority: Number(r.priority) || 0, guardRef: str(r.guard_ref), guard: guards.get(str(r.guard_ref)) || undefined, trainId: str(r.train_id) }))
56
+ .sort((a, b) => a.priority - b.priority || categoryRank(a.category) - categoryRank(b.category) || a.id.localeCompare(b.id)),
57
+ });
58
+ }
59
+ if (artifact.kind === "journey") {
60
+ const entry = rec(d.entrypoint), from = (value: unknown) => { const f = rec(rec(value).from); return { interlocking: str(f.interlocking_id), route: str(f.route_id) }; };
61
+ journeys.push({
62
+ id: artifact.id, title: str(d.title) || artifact.id, file: artifact.file, status: str(d.status), entry: str(entry.interlocking_id),
63
+ exposed: entry.exposed === true, actions: Array.isArray(entry.actions) ? entry.actions.map(String) : [], reason: str(entry.reason),
64
+ continuations: list(d.continuations).map(c => ({ from: from(c), artifact: str(c.artifact), to: str(rec(c.to).interlocking_id) })),
65
+ terminals: list(d.terminals).map(t => ({ from: from(t), outcome: str(t.outcome) })),
66
+ });
67
+ }
68
+ }
69
+ journeys.sort(byId);
70
+ // What the plan leaves unconnected. Each is a decision somebody has not made yet, so it is shown, never dropped.
71
+ const reached = new Set<string>(), selected = new Set<string>();
72
+ for (const journey of journeys) {
73
+ if (!interlockings.has(journey.entry)) gaps.push({ kind: "unknown interlocking", subject: journey.id, detail: `enters at ${journey.entry}, which no interlocking declares` });
74
+ for (const leg of reachable(journey, interlockings)) {
75
+ reached.add(leg.interlocking);
76
+ const accounted = journey.continuations.some(c => c.from.interlocking === leg.interlocking && c.from.route === leg.route.id) || journey.terminals.some(t => t.from.interlocking === leg.interlocking && t.from.route === leg.route.id);
77
+ if (!accounted) gaps.push({ kind: "open route", subject: journey.id, detail: `${leg.interlocking} route ${leg.route.id} has no continuation and no terminal` });
78
+ }
79
+ if (!journey.exposed && !journey.reason) gaps.push({ kind: "unexplained internal journey", subject: journey.id, detail: "is not exposed and gives no reason" });
80
+ }
81
+ for (const il of [...interlockings.values()].sort(byId)) {
82
+ if (!reached.has(il.id)) gaps.push({ kind: "unreached interlocking", subject: il.id, detail: journeys.length ? "no journey enters or continues into it" : "the plan declares no journey" });
83
+ for (const route of il.routes) {
84
+ if (trains.has(route.trainId)) selected.add(route.trainId); else gaps.push({ kind: "unknown train", subject: il.id, detail: `route ${route.id} runs ${route.trainId || "no train"}, which no train declares` });
85
+ if (!route.guard) gaps.push({ kind: "unresolved guard", subject: il.id, detail: `route ${route.id} names guard ${route.guardRef || "(none)"}, which no fragment defines` });
86
+ }
87
+ }
88
+ for (const train of [...trains.values()].sort(byId)) if (!selected.has(train.id)) gaps.push({ kind: "unrouted train", subject: train.id, detail: "no interlocking route selects it" });
89
+ return { journeys, interlockings, trains, gaps };
90
+ }
91
+
92
+ /** Every (interlocking, route) a journey can reach from its entry, breadth first, once each. */
93
+ function reachable(journey: Journey, interlockings: Map<string, Interlocking>): Array<{ interlocking: string; route: Route }> {
94
+ const out: Array<{ interlocking: string; route: Route }> = [], seen = new Set<string>(), queue = [journey.entry];
95
+ while (queue.length) {
96
+ const id = queue.shift()!; if (seen.has(id)) continue; seen.add(id);
97
+ for (const route of interlockings.get(id)?.routes ?? []) {
98
+ out.push({ interlocking: id, route });
99
+ for (const c of journey.continuations) if (c.from.interlocking === id && c.from.route === route.id) queue.push(c.to);
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /** Every path from the entry to an outcome. A path never revisits an interlocking; a loop is recorded as its end. */
106
+ export function journeyPaths(journey: Journey, model: JourneyModel, limit = 50): JourneyPath[] {
107
+ const paths: JourneyPath[] = [];
108
+ const walk = (id: string, legs: PathLeg[], visited: string[]) => {
109
+ if (paths.length >= limit) return;
110
+ const il = model.interlockings.get(id); if (!il || !il.routes.length) { paths.push({ legs, end: { kind: "open" } }); return; }
111
+ for (const route of il.routes) {
112
+ const next = journey.continuations.find(c => c.from.interlocking === id && c.from.route === route.id);
113
+ const terminal = journey.terminals.find(t => t.from.interlocking === id && t.from.route === route.id);
114
+ const leg = { interlocking: id, route, artifact: next?.artifact };
115
+ if (next && visited.includes(next.to)) paths.push({ legs: [...legs, leg], end: { kind: "loop", to: next.to } });
116
+ else if (next) walk(next.to, [...legs, leg], [...visited, next.to]);
117
+ else paths.push({ legs: [...legs, leg], end: terminal ? { kind: "terminal", outcome: terminal.outcome } : { kind: "open" } });
118
+ }
119
+ };
120
+ walk(journey.entry, [], [journey.entry]);
121
+ return paths;
122
+ }
123
+
124
+ /** The path a journey takes when nothing goes wrong: the first route by category then priority at each interlocking. */
125
+ export function nominalPath(journey: Journey, model: JourneyModel): JourneyPath | undefined {
126
+ const rank = (p: JourneyPath) => p.legs.map(l => String(categoryRank(l.route.category)).padStart(2, "0") + String(l.route.priority).padStart(4, "0")).join("|");
127
+ return journeyPaths(journey, model).filter(p => p.end.kind === "terminal").sort((a, b) => rank(a).localeCompare(rank(b)))[0];
128
+ }
129
+
130
+ // ---- drawing ----------------------------------------------------------------------------------
131
+
132
+ /** Every colour is a themeable token with a light fallback, so a standalone SVG still reads. */
133
+ const C = {
134
+ ink: "var(--atdd-ink, #1f2937)", ink2: "var(--atdd-ink-2, #475569)", ink3: "var(--atdd-ink-3, #64748b)", line: "var(--atdd-line, #cbd5e1)", paper: "var(--atdd-paper, #ffffff)",
135
+ accent: "var(--atdd-accent, #0f766e)", wagon: "var(--atdd-wagon, #2563eb)", person: "var(--atdd-person, #b45309)", system: "var(--atdd-system, #7c3aed)",
136
+ nominal: "var(--atdd-nominal, #15803d)", alternate: "var(--atdd-alternate, #2563eb)", error: "var(--atdd-error, #b91c1c)", exception: "var(--atdd-exception, #a16207)",
137
+ terminal: "var(--atdd-terminal, #334155)", gap: "var(--atdd-gap, #dc2626)",
138
+ };
139
+ const SANS = "font-family: var(--atdd-sans, system-ui, sans-serif)", MONO = "font-family: var(--atdd-mono, ui-monospace, monospace)";
140
+ const categoryColor = (category: string) => (C as Record<string, string>)[category] ?? C.ink3;
141
+ export const partKind = (ref: string) => ref.startsWith("user:") ? "person" : ref.startsWith("system:") ? "system" : "wagon";
142
+ const partName = (ref: string) => ref.slice(ref.indexOf(":") + 1);
143
+ const kindColor = (ref: string) => C[partKind(ref) as "person" | "system" | "wagon"];
144
+ const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
145
+ export const slug = (id: string) => id.replace(/^[a-z]+:/, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "unnamed";
146
+ const text = (x: number, y: number, s: string, style: string, anchor = "start") => `<text x="${x}" y="${y}" text-anchor="${anchor}" style="${style}">${esc(s)}</text>`;
147
+ function wrap(value: string, max: number): string[] {
148
+ const out: string[] = []; let line = "";
149
+ for (const word of value.split(/\s+/).filter(Boolean)) { if (line && line.length + 1 + word.length > max) { out.push(line); line = word; } else line = line ? `${line} ${word}` : word; }
150
+ return line ? [...out, line] : out.length ? out : [""];
151
+ }
152
+ const svg = (id: string, title: string, desc: string, w: number, h: number, body: string[]) =>
153
+ `<svg xmlns="http://www.w3.org/2000/svg" class="atdd-diagram" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img" aria-labelledby="t-${id} d-${id}">\n<title id="t-${id}">${esc(title)}</title><desc id="d-${id}">${esc(desc)}</desc>\n${body.join("\n")}\n</svg>\n`;
154
+ const marker = (id: string, name: string, color: string) => `<marker id="${id}-${name}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" style="fill: ${color}"/></marker>`;
155
+
156
+ type SeqRow = { kind: "step"; step: Step; label: string } | { kind: "divider"; label: string; color: string };
157
+
158
+ /** A sequence diagram. Participants appear in order of first use, so a nominal path reads left to right. */
159
+ export function sequenceSvg(id: string, title: string, rows: SeqRow[]): string {
160
+ const order: string[] = [];
161
+ for (const r of rows) if (r.kind === "step") for (const p of [r.step.from, r.step.to]) if (p && !order.includes(p)) order.push(p);
162
+ const GUT = 34, COL = 132, BOX_W = 118, BOX_H = 44, HEAD = 10, LINE_H = 13;
163
+ const x = (ref: string) => GUT + COL / 2 + order.indexOf(ref) * COL, W = Math.max(GUT + COL * Math.max(order.length, 1) + 24, 360);
164
+ const edges = { wagon: C.ink3, person: C.person, system: C.system };
165
+ const edgeOf = (s: Step) => [s.from, s.to].some(p => partKind(p) === "system") ? "system" : [s.from, s.to].some(p => partKind(p) === "person") ? "person" : "wagon";
166
+ const body: string[] = [`<defs>${Object.entries(edges).map(([k, c]) => marker(id, k, c)).join("")}</defs>`];
167
+ let y = HEAD + BOX_H + 20;
168
+ const laneTop = HEAD + BOX_H, drawn: string[] = [];
169
+ for (const r of rows) {
170
+ if (r.kind === "divider") {
171
+ drawn.push(`<rect x="4" y="${y}" width="${W - 8}" height="22" rx="4" style="fill: ${C.paper}; stroke: ${r.color}; stroke-width: 1.2"/>`, text(14, y + 15, r.label, `${MONO}; font-size: 10px; fill: ${r.color}`));
172
+ y += 36; continue;
173
+ }
174
+ const s = r.step, lines = wrap(s.intent, 58), x1 = x(s.from), x2 = x(s.to), color = edges[edgeOf(s)], arrowY = y + lines.length * LINE_H + 20;
175
+ const labelX = Math.max(Math.min(x1, x2) - 4, GUT + 4);
176
+ drawn.push(text(GUT - 14, arrowY + 4, r.label, `${MONO}; font-size: 9.5px; fill: ${C.ink3}`, "middle"));
177
+ lines.forEach((l, i) => drawn.push(text(labelX, y + 10 + i * LINE_H, l, `${SANS}; font-size: 11px; fill: ${C.ink2}`)));
178
+ drawn.push(text(labelX, y + 10 + lines.length * LINE_H, s.artifact, `${MONO}; font-size: 9px; fill: ${C.accent}`));
179
+ if (s.from === s.to) drawn.push(`<circle cx="${x1}" cy="${arrowY}" r="3" style="fill: ${color}"/>`, `<path d="M ${x1} ${arrowY} h 32 v 18 h -26" style="fill: none; stroke: ${color}; stroke-width: 1.7" marker-end="url(#${id}-${edgeOf(s)})"/>`);
180
+ else { const dir = x2 > x1 ? 1 : -1; drawn.push(`<circle cx="${x1}" cy="${arrowY}" r="3" style="fill: ${color}"/>`, `<line x1="${x1 + dir * 3}" y1="${arrowY}" x2="${x2 - dir * 5}" y2="${arrowY}" style="stroke: ${color}; stroke-width: 1.7" marker-end="url(#${id}-${edgeOf(s)})"/>`); }
181
+ y = arrowY + (s.from === s.to ? 34 : 16);
182
+ }
183
+ const H = y + 8;
184
+ for (const ref of order) {
185
+ const cx = x(ref), color = kindColor(ref), names = partName(ref).length > 17 && partName(ref).includes("-") ? [partName(ref).slice(0, partName(ref).indexOf("-") + 1), partName(ref).slice(partName(ref).indexOf("-") + 1)] : [partName(ref)];
186
+ body.push(`<line x1="${cx}" y1="${laneTop}" x2="${cx}" y2="${H - 6}" style="stroke: ${C.line}; stroke-width: 1.4; stroke-dasharray: 3 4"/>`,
187
+ `<rect x="${cx - BOX_W / 2}" y="${HEAD}" width="${BOX_W}" height="${BOX_H}" rx="8" style="fill: ${C.paper}; stroke: ${color}; stroke-width: 1.8"/>`,
188
+ ...names.map((n, i) => text(cx, HEAD + (names.length === 1 ? 22 : 17) + i * 11, n, `${SANS}; font-size: 10.5px; font-weight: 600; fill: ${C.ink}`, "middle")),
189
+ text(cx, HEAD + 37, partKind(ref).toUpperCase(), `${MONO}; font-size: 8px; letter-spacing: .1em; fill: ${C.ink3}`, "middle"));
190
+ }
191
+ const desc = rows.map(r => r.kind === "step" ? `${r.label}. ${partName(r.step.from)} to ${partName(r.step.to)}, ${r.step.artifact}: ${r.step.intent}` : r.label).join(" ");
192
+ return svg(id, title, desc, W, H, [...body, ...drawn]);
193
+ }
194
+
195
+ /** A journey as a map: entry, interlockings, the routes they choose, the trains they run, and where each leads. */
196
+ export function journeyMapSvg(journey: Journey, model: JourneyModel): string {
197
+ type Node = { key: string; kind: "entry" | "interlocking" | "train" | "terminal" | "open"; label: string; sub: string; color: string; level: number; x?: number };
198
+ const nodes = new Map<string, Node>(), edges: Array<{ from: string; to: string; label: string; color: string }> = [];
199
+ const add = (node: Node) => { const existing = nodes.get(node.key); if (!existing) nodes.set(node.key, node); else existing.level = Math.min(existing.level, node.level); return node.key; };
200
+ const entry = add({ key: "entry", kind: "entry", label: journey.actions.length ? journey.actions.join(", ") : journey.exposed ? "exposed" : "internal", sub: journey.exposed ? "STATION MASTER ACTION" : "INTERNAL ENTRY", color: C.ink2, level: 0 });
201
+ const seen = new Set<string>(), queue: Array<[string, number]> = [[journey.entry, 1]];
202
+ edges.push({ from: entry, to: `il:${journey.entry}`, label: "", color: C.ink3 });
203
+ while (queue.length) {
204
+ const [id, level] = queue.shift()!; if (seen.has(id)) continue; seen.add(id);
205
+ const il = model.interlockings.get(id);
206
+ add({ key: `il:${id}`, kind: "interlocking", label: il?.title ?? id, sub: il ? `INTERLOCKING · ${il.routes.length} ROUTE${il.routes.length === 1 ? "" : "S"}` : "UNDECLARED INTERLOCKING", color: il ? C.ink : C.gap, level });
207
+ for (const route of il?.routes ?? []) {
208
+ const train = model.trains.get(route.trainId), key = `route:${id}:${route.id}`;
209
+ add({ key, kind: "train", label: train?.title ?? (route.trainId || "no train"), sub: `${route.category.toUpperCase()} · ${train ? `${train.sequence.length} STEPS` : "UNKNOWN TRAIN"}`, color: train ? categoryColor(route.category) : C.gap, level: level + 1 });
210
+ edges.push({ from: `il:${id}`, to: key, label: route.guard ? `${route.id}: ${route.guard}` : route.id, color: categoryColor(route.category) });
211
+ const next = journey.continuations.find(c => c.from.interlocking === id && c.from.route === route.id), terminal = journey.terminals.find(t => t.from.interlocking === id && t.from.route === route.id);
212
+ if (next) { edges.push({ from: key, to: `il:${next.to}`, label: next.artifact, color: C.accent }); queue.push([next.to, level + 2]); }
213
+ else if (terminal) edges.push({ from: key, to: add({ key: `end:${terminal.outcome}`, kind: "terminal", label: terminal.outcome, sub: "OUTCOME", color: C.terminal, level: level + 2 }), label: "", color: C.terminal });
214
+ else edges.push({ from: key, to: add({ key: `open:${key}`, kind: "open", label: "no continuation", sub: "OPEN ROUTE", color: C.gap, level: level + 2 }), label: "", color: C.gap });
215
+ }
216
+ }
217
+ const NODE_W = 200, NODE_H = 50, PITCH = 228, ROW = 122, PAD = 24, LOOP = 150;
218
+ const levels = new Map<number, Node[]>();
219
+ for (const node of nodes.values()) levels.set(node.level, [...(levels.get(node.level) ?? []), node]);
220
+ // Order each row by where its parents sit, so an edge runs down to its own child instead of across the page.
221
+ const slot = new Map<string, number>();
222
+ for (const level of [...levels.keys()].sort((x, y) => x - y)) {
223
+ const row = levels.get(level)!, parentAt = (n: Node) => { const xs = edges.filter(e => e.to === n.key && nodes.get(e.from)!.level < n.level).map(e => slot.get(e.from) ?? 0); return xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : 0; };
224
+ row.map((n, i) => ({ n, i, p: parentAt(n) })).sort((x, y) => x.p - y.p || x.i - y.i).forEach(({ n }, i) => { levels.get(level)![i] = n; slot.set(n.key, i - (row.length - 1) / 2); });
225
+ }
226
+ const widest = Math.max(...[...levels.values()].map(row => row.length)), hasLoop = edges.some(e => nodes.has(e.to) && nodes.get(e.to)!.level <= nodes.get(e.from)!.level);
227
+ const W = Math.max(PAD * 2 + widest * PITCH + (hasLoop ? LOOP * 2 : 0), 420), H = PAD * 2 + (Math.max(...levels.keys()) + 1) * ROW - (ROW - NODE_H) + 14;
228
+ const centre = hasLoop ? (W - LOOP * 2) / 2 : W / 2;
229
+ for (const [, row] of levels) row.forEach((node, i) => { node.x = centre + (i - (row.length - 1) / 2) * PITCH; });
230
+ const at = (key: string) => { const n = nodes.get(key)!; return { x: n.x!, y: PAD + 14 + n.level * ROW }; };
231
+ const right = Math.max(...[...nodes.values()].map(n => n.x! + NODE_W / 2));
232
+ const id = `map-${slug(journey.id)}`, body: string[] = [`<defs>${marker(id, "arrow", C.ink3)}</defs>`], labels: string[] = [];
233
+ const incoming = new Map<string, number>();
234
+ for (const e of edges) {
235
+ if (!nodes.has(e.to)) continue;
236
+ const a = at(e.from), b = at(e.to);
237
+ if (b.y > a.y) {
238
+ body.push(`<path d="M ${a.x} ${a.y + NODE_H} C ${a.x} ${a.y + NODE_H + 44}, ${b.x} ${b.y - 44}, ${b.x} ${b.y - 2}" style="fill: none; stroke: ${e.color}; stroke-width: 1.6" marker-end="url(#${id}-arrow)"/>`);
239
+ // A label sits just above the node it leads to, never on the curve, where it would collide with siblings.
240
+ if (e.label) { const n = incoming.get(e.to) ?? 0; incoming.set(e.to, n + 1); labels.push(text(b.x + 7, b.y - 9 - n * 12, wrap(e.label, 40)[0] + (wrap(e.label, 40).length > 1 ? " …" : ""), `${MONO}; font-size: 9px; fill: ${e.color}`, "start")); }
241
+ } else {
242
+ // A continuation back to an earlier (or the same) interlocking leaves to the right of every node,
243
+ // climbs above the target's row, and enters it from the top, so it never seems to leave a neighbour.
244
+ const x1 = a.x + NODE_W / 2, y1 = a.y + NODE_H / 2, out = right + LOOP * 0.45, top = b.y - 30, r = 10;
245
+ body.push(`<path d="M ${x1} ${y1} H ${out - r} Q ${out} ${y1} ${out} ${y1 - r} V ${top + r} Q ${out} ${top} ${out - r} ${top} H ${b.x + NODE_W / 2 - 16 + r} Q ${b.x + NODE_W / 2 - 16} ${top} ${b.x + NODE_W / 2 - 16} ${top + r} V ${b.y - 2}" style="fill: none; stroke: ${e.color}; stroke-width: 1.6; stroke-dasharray: 5 3" marker-end="url(#${id}-arrow)"/>`);
246
+ if (e.label) labels.push(text(out + 6, (y1 + top) / 2 + 3, e.label, `${MONO}; font-size: 9px; fill: ${e.color}`, "start"));
247
+ }
248
+ }
249
+ for (const node of nodes.values()) {
250
+ const { x, y } = at(node.key), left = x - NODE_W / 2, rx = node.kind === "entry" || node.kind === "terminal" || node.kind === "open" ? NODE_H / 2 : 6;
251
+ body.push(`<rect x="${left}" y="${y}" width="${NODE_W}" height="${NODE_H}" rx="${rx}" style="fill: ${C.paper}; stroke: ${node.color}; stroke-width: ${node.kind === "interlocking" ? 2.2 : 1.6}${node.kind === "open" ? "; stroke-dasharray: 4 3" : ""}"/>`);
252
+ if (node.kind === "interlocking") body.push(`<path d="M ${left + 14} ${y + NODE_H / 2} l 7 -7 l 7 7 l -7 7 z" style="fill: ${node.color}"/>`);
253
+ const lines = wrap(node.label, node.kind === "interlocking" ? 26 : 30).slice(0, 2), tx = node.kind === "interlocking" ? x + 10 : x;
254
+ lines.forEach((l, i) => body.push(text(tx, y + (lines.length === 1 ? 23 : 18) + i * 12, l, `${SANS}; font-size: 11px; font-weight: 600; fill: ${C.ink}`, "middle")));
255
+ body.push(text(tx, y + NODE_H - 8, node.sub, `${MONO}; font-size: 8px; letter-spacing: .08em; fill: ${node.color}`, "middle"));
256
+ }
257
+ body.push(...labels);
258
+ const desc = `Journey ${journey.title}: enters at ${journey.entry}; ${journey.continuations.length} continuation(s), ${journey.terminals.length} terminal outcome(s).`;
259
+ return svg(id, `${journey.title} — journey map`, desc, Math.round(W), Math.round(H), body);
260
+ }
261
+
262
+ // ---- the page ---------------------------------------------------------------------------------
263
+
264
+ export const JOURNEY_DOCS_DIR = "docs/purpose/journeys";
265
+ export const GENERATED_MARK = "// Generated by @afokapu/atdd-bun from plan/. Do not edit; run `atdd-bun docs journeys`.";
266
+ const lit = (s: string) => `\`+${s.replaceAll("+", "{plus}")}+\``;
267
+ const cell = (s: string) => s.replaceAll("|", "\\|");
268
+
269
+ export function trainRows(train: Train): SeqRow[] { return train.sequence.map(step => ({ kind: "step" as const, step, label: String(step.step) })); }
270
+ export function pathRows(path: JourneyPath, model: JourneyModel): SeqRow[] {
271
+ const rows: SeqRow[] = [];
272
+ path.legs.forEach((leg, i) => {
273
+ const il = model.interlockings.get(leg.interlocking), train = model.trains.get(leg.route.trainId);
274
+ rows.push({ kind: "divider", label: `◇ ${il?.title ?? leg.interlocking} → ${leg.route.id} (${leg.route.category})${train ? ` → ${train.title}` : ""}`, color: categoryColor(leg.route.category) });
275
+ for (const step of train?.sequence ?? []) rows.push({ kind: "step", step, label: `${i + 1}.${step.step}` });
276
+ });
277
+ const end = path.end;
278
+ rows.push({ kind: "divider", label: end.kind === "terminal" ? `■ outcome: ${end.outcome}` : end.kind === "loop" ? `↺ continues back into ${end.to}` : "⚠ open: no continuation or terminal", color: end.kind === "open" ? C.gap : C.terminal });
279
+ return rows;
280
+ }
281
+ const describePath = (path: JourneyPath, model: JourneyModel) => path.legs.map(l => `${model.interlockings.get(l.interlocking)?.title ?? l.interlocking} → ${l.route.id}`).join(" ⇒ ");
282
+ const describeEnd = (path: JourneyPath) => path.end.kind === "terminal" ? path.end.outcome : path.end.kind === "loop" ? `loops to ${path.end.to}` : "open";
283
+
284
+ /** Every file the journey documentation consists of, keyed by path relative to the output directory. */
285
+ export function renderJourneyDocs(model: JourneyModel, docId = "purpose.journeys"): Map<string, string> {
286
+ const files = new Map<string, string>(), svgPath = (name: string) => `svg/${name}.svg`;
287
+ const interlockings = [...model.interlockings.values()].sort(byId);
288
+ const routeCount = interlockings.reduce((n, il) => n + il.routes.length, 0);
289
+ const a: string[] = [
290
+ "= Journeys", `:doc-id: ${docId}`, ":status: generated", ":toc: left", ":toclevels: 2", ":sectanchors:", "", GENERATED_MARK, "",
291
+ "[.headline]", "The journeys this plan declares, the interlockings they pass through, and the trains those run, drawn from `plan/`. These are planned behaviour, not acceptance evidence.", "",
292
+ "== Coverage", "", '[cols="1,1,1,1,1",options="header"]', "|===", "| Journeys | Interlockings reached | Routes | Trains | Gaps", "",
293
+ `| ${model.journeys.length}`, `| ${interlockings.length - model.gaps.filter(g => g.kind === "unreached interlocking").length} of ${interlockings.length}`, `| ${routeCount}`, `| ${model.trains.size}`, `| ${model.gaps.length}`, "|===", "",
294
+ ];
295
+ a.push("== Gaps", "");
296
+ if (!model.gaps.length) a.push("Every interlocking is reached by a journey, every reachable route ends in a continuation or an outcome, and every train is routed.", "");
297
+ else a.push("What the plan leaves unconnected. Each row is a decision still to be made in `plan/`.", "", '[cols="1,2,3",options="header"]', "|===", "| Gap | Subject | Detail", "", ...model.gaps.flatMap(g => [`| ${g.kind}`, `| ${lit(g.subject)}`, `| ${cell(g.detail)}`, ""]), "|===", "");
298
+ for (const journey of model.journeys) {
299
+ const s = slug(journey.id), map = `journey-${s}`, paths = journeyPaths(journey, model), nominal = nominalPath(journey, model);
300
+ files.set(svgPath(map), journeyMapSvg(journey, model));
301
+ a.push(`== ${journey.title}`, "", `${lit(journey.id)} · ${journey.status || "no status"} · enters at ${lit(journey.entry)} · ${journey.exposed ? `exposed through ${journey.actions.map(lit).join(", ") || "no action"}` : `internal (${journey.reason || "no reason given"})`} · source ${lit(journey.file)}`, "",
302
+ `image::${svgPath(map)}[${journey.title} journey map,opts=inline]`, "");
303
+ if (nominal) {
304
+ const walk = `path-${s}-nominal`;
305
+ files.set(svgPath(walk), sequenceSvg(`walk-${s}`, `${journey.title} — nominal path`, pathRows(nominal, model)));
306
+ a.push("=== Nominal path, end to end", "", `${describePath(nominal, model)} ⇒ *${describeEnd(nominal)}*`, "", `image::${svgPath(walk)}[${journey.title} nominal path,opts=inline]`, "");
307
+ } else a.push("=== Nominal path, end to end", "", "No path from the entry reaches a terminal outcome.", "");
308
+ a.push("=== Every path", "", '[cols="1,6,2",options="header"]', "|===", "| # | Interlocking → route, in order | Ends", "", ...paths.flatMap((p, i) => [`| ${i + 1}`, `| ${cell(describePath(p, model))}`, `| ${cell(describeEnd(p))}`, ""]), "|===", "");
309
+ }
310
+ a.push("== Interlockings and their trains", "");
311
+ for (const il of interlockings) {
312
+ a.push(`=== ${il.title}`, "", `${lit(il.id)} · ${il.exposed ? `exposed through ${il.actions.map(lit).join(", ") || "no action"}` : "internal"} · source ${lit(il.file)}`, "",
313
+ '[cols="1,1,3,3",options="header"]', "|===", "| Priority | Category | Taken when | Runs", "",
314
+ ...il.routes.flatMap(r => [`| ${r.priority}`, `| ${r.category}`, `| ${r.guard ? lit(r.guard) : `_unresolved ${lit(r.guardRef || "guard")}_`}`, `| ${lit(r.trainId)}`, ""]), "|===", "");
315
+ for (const route of il.routes) {
316
+ const train = model.trains.get(route.trainId); if (!train) continue;
317
+ const name = `train-${slug(train.id)}`;
318
+ if (!files.has(svgPath(name))) files.set(svgPath(name), sequenceSvg(`seq-${slug(train.id)}`, train.title, trainRows(train)));
319
+ a.push(`==== ${route.id} (${route.category}): ${train.title}`, "", `image::${svgPath(name)}[${train.title},opts=inline]`, "");
320
+ }
321
+ }
322
+ files.set("index.adoc", a.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n");
323
+ return new Map([...files.entries()].sort(([x], [y]) => x.localeCompare(y)));
324
+ }
325
+
326
+ export type JourneyDocsResult = { ok: boolean; written: string[]; stale: string[]; message: string };
327
+
328
+ /** Whether the repository is in scope: it documents itself (docs/) and its plan has journeys or interlockings. */
329
+ export async function journeyDocsApply(root: string, graph?: PlanGraph) {
330
+ if (!existsSync(join(root, "docs"))) return false;
331
+ return (graph ?? await loadPlan(root)).artifacts.some(a => a.kind === "journey" || a.kind === "interlocking");
332
+ }
333
+
334
+ async function existing(dir: string): Promise<string[]> {
335
+ if (!existsSync(join(dir, "svg"))) return [];
336
+ return (await readdir(join(dir, "svg"))).filter(f => f.endsWith(".svg")).map(f => `svg/${f}`);
337
+ }
338
+
339
+ /** Write (or with `check`, compare) the journey documentation. Refuses to overwrite a hand-written index. */
340
+ export async function journeyDocs(options: { root?: string; out?: string; check?: boolean; force?: boolean } = {}): Promise<JourneyDocsResult> {
341
+ const root = resolve(options.root ?? process.cwd()), dir = resolve(root, options.out ?? JOURNEY_DOCS_DIR), graph = await loadPlan(root);
342
+ const expected = renderJourneyDocs(buildModel(graph)), rel = (f: string) => relative(root, join(dir, f)).replaceAll("\\", "/");
343
+ const stale: string[] = [];
344
+ for (const [file, content] of expected) { const path = join(dir, file); if (!existsSync(path) || await readFile(path, "utf8") !== content) stale.push(rel(file)); }
345
+ const orphans = (await existing(dir)).filter(f => !expected.has(f));
346
+ if (options.check) {
347
+ const all = [...stale, ...orphans.map(rel)];
348
+ return { ok: all.length === 0, written: [], stale: all, message: all.length ? `journey documentation is out of date with plan/: ${all.join(", ")}. Regenerate with \`atdd-bun docs journeys\` and commit the result.` : "journey documentation matches plan/" };
349
+ }
350
+ const index = join(dir, "index.adoc");
351
+ if (existsSync(index) && !options.force && !(await readFile(index, "utf8")).includes(GENERATED_MARK)) return { ok: false, written: [], stale, message: `${rel("index.adoc")} exists and was not generated by atdd-bun; move it, choose --out, or pass --force to replace it` };
352
+ for (const f of orphans) await rm(join(dir, f));
353
+ for (const file of stale.map(s => relative(dir, join(root, s)).replaceAll("\\", "/"))) { await mkdir(dirname(join(dir, file)), { recursive: true }); await writeFile(join(dir, file), expected.get(file)!); }
354
+ return { ok: true, written: stale, stale: [], message: stale.length || orphans.length ? `wrote ${stale.length} file(s), removed ${orphans.length} stale diagram(s) in ${relative(root, dir) || "."}` : "journey documentation already matches plan/" };
355
+ }
@@ -246,6 +246,34 @@ function journeyContinuationFindings(graph: Awaited<ReturnType<typeof validatePl
246
246
  return findings;
247
247
  }
248
248
 
249
+ /** Every interlocking is reached by a journey: as an entrypoint or through a continuation reachable from one. */
250
+ function journeyCompositionFindings(graph: Awaited<ReturnType<typeof validatePlan>>): PlanFinding[] {
251
+ const interlockings = graph.artifacts.filter(item => item.kind === "interlocking"), journeys = graph.artifacts.filter(item => item.kind === "journey");
252
+ const reached = new Set<string>();
253
+ for (const journey of journeys) {
254
+ const entrypoint = journey.data.entrypoint && typeof journey.data.entrypoint === "object" ? journey.data.entrypoint as Record<string, unknown> : {};
255
+ const edges = records(journey.data.continuations).map(continuation => {
256
+ const from = continuation.from && typeof continuation.from === "object" ? continuation.from as Record<string, unknown> : {};
257
+ const to = continuation.to && typeof continuation.to === "object" ? continuation.to as Record<string, unknown> : {};
258
+ return { from: text(from.interlocking_id), to: text(to.interlocking_id) };
259
+ });
260
+ const queue = [text(entrypoint.interlocking_id)].filter(Boolean), seen = new Set<string>();
261
+ while (queue.length) {
262
+ const id = queue.shift()!;
263
+ if (seen.has(id)) continue;
264
+ seen.add(id); reached.add(id);
265
+ for (const edge of edges) if (edge.from === id && edge.to) queue.push(edge.to);
266
+ }
267
+ }
268
+ return interlockings.filter(interlocking => !reached.has(interlocking.id)).map(interlocking => finding(
269
+ "planner.journey.interlocking-composed",
270
+ interlocking.file,
271
+ journeys.length
272
+ ? `${interlocking.id} is reached by none of the ${journeys.length} journey(s): no journey enters at it and no reachable continuation leads to it`
273
+ : `${interlocking.id} is not composed into any journey: the plan declares ${interlockings.length} interlocking(s) and no journey under plan/_journeys/`,
274
+ ));
275
+ }
276
+
249
277
  /** Bun realization of the planner validators that depend only on committed plan
250
278
  * artifacts. Runtime/session/GitHub validators deliberately stay outside this package. */
251
279
  export async function validateStaticPlannerConventions(root = process.cwd()): Promise<PlanFinding[]> {
@@ -283,6 +311,7 @@ export async function validateStaticPlannerConventions(root = process.cwd()): Pr
283
311
  findings.push(...await themeRegistryFindings(root, graph, registry));
284
312
  findings.push(...await trainRegistryFindings(root));
285
313
  findings.push(...journeyContinuationFindings(graph));
314
+ findings.push(...journeyCompositionFindings(graph));
286
315
 
287
316
  return findings;
288
317
  }