@ybotok/loam 0.2.0-alpha.5 → 0.2.0-alpha.7

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 (303) hide show
  1. package/CHANGELOG.md +1063 -0
  2. package/MIGRATING-from-OpenSpec.md +7 -4
  3. package/QUICKSTART.md +8 -3
  4. package/README.md +13 -7
  5. package/ROADMAP.md +285 -11
  6. package/SCHEMA.md +566 -45
  7. package/WORKFLOW.md +23 -10
  8. package/dist/commands/adopt/render.js +47 -8
  9. package/dist/commands/adopt/render.js.map +1 -1
  10. package/dist/commands/archive/plan/landscape.js +45 -1
  11. package/dist/commands/archive/plan/landscape.js.map +1 -1
  12. package/dist/commands/archive/plan/model/extending.js +85 -0
  13. package/dist/commands/archive/plan/model/extending.js.map +1 -0
  14. package/dist/commands/archive/run.js +53 -23
  15. package/dist/commands/archive/run.js.map +1 -1
  16. package/dist/commands/context/print.js +11 -1
  17. package/dist/commands/context/print.js.map +1 -1
  18. package/dist/commands/doctor.js +46 -0
  19. package/dist/commands/doctor.js.map +1 -1
  20. package/dist/commands/explore.js +12 -2
  21. package/dist/commands/explore.js.map +1 -1
  22. package/dist/commands/list/views.js +5 -1
  23. package/dist/commands/list/views.js.map +1 -1
  24. package/dist/commands/seed/commit.js +34 -1
  25. package/dist/commands/seed/commit.js.map +1 -1
  26. package/dist/commands/seed/plan.js +158 -13
  27. package/dist/commands/seed/plan.js.map +1 -1
  28. package/dist/commands/seed/seed.js +7 -2
  29. package/dist/commands/seed/seed.js.map +1 -1
  30. package/dist/commands/show/feature.js +2 -1
  31. package/dist/commands/show/feature.js.map +1 -1
  32. package/dist/commands/show/marks.js +3 -5
  33. package/dist/commands/show/marks.js.map +1 -1
  34. package/dist/commands/show/service.js +39 -18
  35. package/dist/commands/show/service.js.map +1 -1
  36. package/dist/commands/subsystem/edit.js +4 -2
  37. package/dist/commands/subsystem/edit.js.map +1 -1
  38. package/dist/commands/subsystem/move.js +55 -17
  39. package/dist/commands/subsystem/move.js.map +1 -1
  40. package/dist/commands/subsystem/sync.js +238 -87
  41. package/dist/commands/subsystem/sync.js.map +1 -1
  42. package/dist/commands/subsystem/txn/exclude.js +132 -0
  43. package/dist/commands/subsystem/txn/exclude.js.map +1 -0
  44. package/dist/commands/subsystem/txn/report.js +174 -0
  45. package/dist/commands/subsystem/txn/report.js.map +1 -0
  46. package/dist/commands/subsystem/txn/txn.js +126 -22
  47. package/dist/commands/subsystem/txn/txn.js.map +1 -1
  48. package/dist/commands/subsystem/txn/views.js +159 -19
  49. package/dist/commands/subsystem/txn/views.js.map +1 -1
  50. package/dist/commands/validate/arch-coverage.js +16 -9
  51. package/dist/commands/validate/arch-coverage.js.map +1 -1
  52. package/dist/commands/validate/checks/fleet-shape.js +30 -77
  53. package/dist/commands/validate/checks/fleet-shape.js.map +1 -1
  54. package/dist/commands/validate/checks/health-deps.js +9 -6
  55. package/dist/commands/validate/checks/health-deps.js.map +1 -1
  56. package/dist/commands/validate/checks/vocabulary.js +4 -19
  57. package/dist/commands/validate/checks/vocabulary.js.map +1 -1
  58. package/dist/commands/validate/feature.js +1 -1
  59. package/dist/commands/validate/feature.js.map +1 -1
  60. package/dist/commands/validate/fleet/census.js +37 -13
  61. package/dist/commands/validate/fleet/census.js.map +1 -1
  62. package/dist/commands/validate/fleet/landscape.js +131 -120
  63. package/dist/commands/validate/fleet/landscape.js.map +1 -1
  64. package/dist/commands/validate/fleet/load.js +66 -0
  65. package/dist/commands/validate/fleet/load.js.map +1 -1
  66. package/dist/commands/validate/fleet/map/attest.js +102 -0
  67. package/dist/commands/validate/fleet/map/attest.js.map +1 -0
  68. package/dist/commands/validate/fleet/map/bindings.js +109 -0
  69. package/dist/commands/validate/fleet/map/bindings.js.map +1 -0
  70. package/dist/commands/validate/fleet/map/consumers.js +219 -0
  71. package/dist/commands/validate/fleet/map/consumers.js.map +1 -0
  72. package/dist/commands/validate/fleet/map/isolation.js +116 -0
  73. package/dist/commands/validate/fleet/map/isolation.js.map +1 -0
  74. package/dist/commands/validate/fleet/map/usecases.js +56 -0
  75. package/dist/commands/validate/fleet/map/usecases.js.map +1 -0
  76. package/dist/commands/validate/fleet/scorecard/scorecard.js.map +1 -1
  77. package/dist/commands/validate/fleet/usecases/steps.js +49 -17
  78. package/dist/commands/validate/fleet/usecases/steps.js.map +1 -1
  79. package/dist/commands/validate/fleet/views/fleet-project.js +217 -0
  80. package/dist/commands/validate/fleet/views/fleet-project.js.map +1 -0
  81. package/dist/commands/validate/fleet/views/ids.js +45 -15
  82. package/dist/commands/validate/fleet/views/ids.js.map +1 -1
  83. package/dist/commands/validate/fleet/views/projects.js +258 -70
  84. package/dist/commands/validate/fleet/views/projects.js.map +1 -1
  85. package/dist/commands/validate/fleet/views/root-documents.js +99 -0
  86. package/dist/commands/validate/fleet/views/root-documents.js.map +1 -0
  87. package/dist/commands/validate/fleet/views/stale.js +32 -15
  88. package/dist/commands/validate/fleet/views/stale.js.map +1 -1
  89. package/dist/commands/validate/service/model/diverged.js +226 -0
  90. package/dist/commands/validate/service/model/diverged.js.map +1 -0
  91. package/dist/commands/validate/service/model/grade.js +163 -0
  92. package/dist/commands/validate/service/model/grade.js.map +1 -0
  93. package/dist/commands/validate/service/service.js +110 -34
  94. package/dist/commands/validate/service/service.js.map +1 -1
  95. package/dist/commands/validate/service/specs.js +39 -5
  96. package/dist/commands/validate/service/specs.js.map +1 -1
  97. package/dist/commands/validate/service/spine.js +41 -3
  98. package/dist/commands/validate/service/spine.js.map +1 -1
  99. package/dist/commands/validate/service/usecases/tags.js +113 -0
  100. package/dist/commands/validate/service/usecases/tags.js.map +1 -0
  101. package/dist/commands/validate/service/usecases/usecases.js +209 -0
  102. package/dist/commands/validate/service/usecases/usecases.js.map +1 -0
  103. package/dist/commands/validate/validate.js +10 -24
  104. package/dist/commands/validate/validate.js.map +1 -1
  105. package/dist/core/agent/agents-md/artifacts.js +1 -0
  106. package/dist/core/agent/agents-md/artifacts.js.map +1 -1
  107. package/dist/core/agent/agents-md/command-map.js +19 -5
  108. package/dist/core/agent/agents-md/command-map.js.map +1 -1
  109. package/dist/core/agent/agents-md/cycle.js +24 -9
  110. package/dist/core/agent/agents-md/cycle.js.map +1 -1
  111. package/dist/core/agent/agents-md/map/lenses/context.js +4 -3
  112. package/dist/core/agent/agents-md/map/lenses/context.js.map +1 -1
  113. package/dist/core/agent/agents-md/map/subsystem.js +70 -17
  114. package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
  115. package/dist/core/agent/agents-md/map/usecases.js +45 -3
  116. package/dist/core/agent/agents-md/map/usecases.js.map +1 -1
  117. package/dist/core/agent/agents-md/refusals.js +5 -3
  118. package/dist/core/agent/agents-md/refusals.js.map +1 -1
  119. package/dist/core/agent/agents-md.js +26 -7
  120. package/dist/core/agent/agents-md.js.map +1 -1
  121. package/dist/core/agent/support/report.js +39 -5
  122. package/dist/core/agent/support/report.js.map +1 -1
  123. package/dist/core/agent/workflows/adopt.js +123 -14
  124. package/dist/core/agent/workflows/adopt.js.map +1 -1
  125. package/dist/core/agent/workflows/check.js +34 -20
  126. package/dist/core/agent/workflows/check.js.map +1 -1
  127. package/dist/core/agent/workflows/closing.js +3 -1
  128. package/dist/core/agent/workflows/closing.js.map +1 -1
  129. package/dist/core/agent/workflows/reference/spine.js +12 -6
  130. package/dist/core/agent/workflows/reference/spine.js.map +1 -1
  131. package/dist/core/brief/brief.js +88 -19
  132. package/dist/core/brief/brief.js.map +1 -1
  133. package/dist/core/brief/checks.js +133 -7
  134. package/dist/core/brief/checks.js.map +1 -1
  135. package/dist/core/brief/landscape.js +212 -129
  136. package/dist/core/brief/landscape.js.map +1 -1
  137. package/dist/core/brief/map/extend.js +81 -0
  138. package/dist/core/brief/map/extend.js.map +1 -0
  139. package/dist/core/brief/map/owed.js +284 -0
  140. package/dist/core/brief/map/owed.js.map +1 -0
  141. package/dist/core/brief/targets.js +94 -29
  142. package/dist/core/brief/targets.js.map +1 -1
  143. package/dist/core/brief/unchecked.js +13 -3
  144. package/dist/core/brief/unchecked.js.map +1 -1
  145. package/dist/core/c4/likec4.js +23 -1
  146. package/dist/core/c4/likec4.js.map +1 -1
  147. package/dist/core/c4/parsed/deployment.js +5 -3
  148. package/dist/core/c4/parsed/deployment.js.map +1 -1
  149. package/dist/core/c4/parsed/view-ids.js +39 -1
  150. package/dist/core/c4/parsed/view-ids.js.map +1 -1
  151. package/dist/core/c4/prefetch/fleet.js +142 -0
  152. package/dist/core/c4/prefetch/fleet.js.map +1 -0
  153. package/dist/core/c4/project/architecture.js +77 -7
  154. package/dist/core/c4/project/architecture.js.map +1 -1
  155. package/dist/core/c4/project/batch.js +140 -0
  156. package/dist/core/c4/project/batch.js.map +1 -0
  157. package/dist/core/c4/project/documents.js +12 -6
  158. package/dist/core/c4/project/documents.js.map +1 -1
  159. package/dist/core/c4/project/load.js +85 -42
  160. package/dist/core/c4/project/load.js.map +1 -1
  161. package/dist/core/c4/project/staged.js +53 -15
  162. package/dist/core/c4/project/staged.js.map +1 -1
  163. package/dist/core/c4/resolve/attested.js +0 -0
  164. package/dist/core/c4/resolve/attested.js.map +1 -0
  165. package/dist/core/c4/resolve/service.js +8 -17
  166. package/dist/core/c4/resolve/service.js.map +1 -1
  167. package/dist/core/c4/resolve/steps.js +18 -0
  168. package/dist/core/c4/resolve/steps.js.map +1 -1
  169. package/dist/core/c4/root-project/exclude.js +352 -0
  170. package/dist/core/c4/root-project/exclude.js.map +1 -0
  171. package/dist/core/c4/service-model/documents.js +47 -0
  172. package/dist/core/c4/service-model/documents.js.map +1 -0
  173. package/dist/core/c4/service-model/fleet/extending.js +82 -0
  174. package/dist/core/c4/service-model/fleet/extending.js.map +1 -0
  175. package/dist/core/c4/service-model/load.js +165 -0
  176. package/dist/core/c4/service-model/load.js.map +1 -0
  177. package/dist/core/c4/service-model/renderer.js +128 -0
  178. package/dist/core/c4/service-model/renderer.js.map +1 -0
  179. package/dist/core/c4/service-model/shape.js +126 -0
  180. package/dist/core/c4/service-model/shape.js.map +1 -0
  181. package/dist/core/c4/service-model/slice.js +188 -0
  182. package/dist/core/c4/service-model/slice.js.map +1 -0
  183. package/dist/core/c4/source-scan.js +26 -0
  184. package/dist/core/c4/source-scan.js.map +1 -1
  185. package/dist/core/c4/splice/authored-source.js +60 -13
  186. package/dist/core/c4/splice/authored-source.js.map +1 -1
  187. package/dist/core/c4/splice/contract.js.map +1 -1
  188. package/dist/core/c4/splice/identity/declared.js +87 -0
  189. package/dist/core/c4/splice/identity/declared.js.map +1 -0
  190. package/dist/core/c4/splice/identity/edges.js +17 -0
  191. package/dist/core/c4/splice/identity/edges.js.map +1 -1
  192. package/dist/core/c4/splice/identity/existing.js +80 -0
  193. package/dist/core/c4/splice/identity/existing.js.map +1 -0
  194. package/dist/core/c4/splice/identity/interior.js +80 -0
  195. package/dist/core/c4/splice/identity/interior.js.map +1 -0
  196. package/dist/core/c4/splice/identity/owner.js +50 -0
  197. package/dist/core/c4/splice/identity/owner.js.map +1 -0
  198. package/dist/core/c4/splice/landscape-merge.js +154 -100
  199. package/dist/core/c4/splice/landscape-merge.js.map +1 -1
  200. package/dist/core/c4/splice/model/merge.js +140 -0
  201. package/dist/core/c4/splice/model/merge.js.map +1 -0
  202. package/dist/core/c4/splice/placement.js +14 -4
  203. package/dist/core/c4/splice/placement.js.map +1 -1
  204. package/dist/core/c4/workspace.js +2 -1
  205. package/dist/core/c4/workspace.js.map +1 -1
  206. package/dist/core/coherence/coherence.js +7 -0
  207. package/dist/core/coherence/coherence.js.map +1 -1
  208. package/dist/core/delta/delta.js +9 -0
  209. package/dist/core/delta/delta.js.map +1 -1
  210. package/dist/core/deployment/delta.js +16 -10
  211. package/dist/core/deployment/delta.js.map +1 -1
  212. package/dist/core/diff/base-state.js +21 -5
  213. package/dist/core/diff/base-state.js.map +1 -1
  214. package/dist/core/diff/victims.js +10 -1
  215. package/dist/core/diff/victims.js.map +1 -1
  216. package/dist/core/docs.js +29 -24
  217. package/dist/core/docs.js.map +1 -1
  218. package/dist/core/doctor/config.js +7 -3
  219. package/dist/core/doctor/config.js.map +1 -1
  220. package/dist/core/doctor/doctor.js +36 -8
  221. package/dist/core/doctor/doctor.js.map +1 -1
  222. package/dist/core/doctor/report.js.map +1 -1
  223. package/dist/core/doctor/reports/scan.js +242 -0
  224. package/dist/core/doctor/reports/scan.js.map +1 -0
  225. package/dist/core/explain/refusals.js +1 -1
  226. package/dist/core/explain/refusals.js.map +1 -1
  227. package/dist/core/explore/explore.js +44 -9
  228. package/dist/core/explore/explore.js.map +1 -1
  229. package/dist/core/fleet-context.js +47 -0
  230. package/dist/core/fleet-context.js.map +1 -1
  231. package/dist/core/gate/checks.js +5 -1
  232. package/dist/core/gate/checks.js.map +1 -1
  233. package/dist/core/gate/partners.js +65 -26
  234. package/dist/core/gate/partners.js.map +1 -1
  235. package/dist/core/kernel/ids/fqn/ancestors.js +65 -0
  236. package/dist/core/kernel/ids/fqn/ancestors.js.map +1 -0
  237. package/dist/core/pack/living.js +47 -13
  238. package/dist/core/pack/living.js.map +1 -1
  239. package/dist/core/pack/pack.js +9 -6
  240. package/dist/core/pack/pack.js.map +1 -1
  241. package/dist/core/projection/arch-slice.js +2 -2
  242. package/dist/core/projection/arch-slice.js.map +1 -1
  243. package/dist/core/repo/paths.js +45 -38
  244. package/dist/core/repo/paths.js.map +1 -1
  245. package/dist/core/repo/tree/render/body.js +45 -6
  246. package/dist/core/repo/tree/render/body.js.map +1 -1
  247. package/dist/core/repo/tree/render/projects.js +93 -35
  248. package/dist/core/repo/tree/render/projects.js.map +1 -1
  249. package/dist/core/repo/tree/render/service/header.js +125 -0
  250. package/dist/core/repo/tree/render/service/header.js.map +1 -0
  251. package/dist/core/repo/tree/render/service/peers.js +28 -0
  252. package/dist/core/repo/tree/render/service/peers.js.map +1 -0
  253. package/dist/core/repo/tree/render/service/view-id.js +67 -0
  254. package/dist/core/repo/tree/render/service/view-id.js.map +1 -0
  255. package/dist/core/repo/tree/render/service/views.js +78 -0
  256. package/dist/core/repo/tree/render/service/views.js.map +1 -0
  257. package/dist/core/repo/tree/render/stale.js +20 -4
  258. package/dist/core/repo/tree/render/stale.js.map +1 -1
  259. package/dist/core/repo/tree/render/views.js +149 -32
  260. package/dist/core/repo/tree/render/views.js.map +1 -1
  261. package/dist/core/repo/tree/walk.js +45 -2
  262. package/dist/core/repo/tree/walk.js.map +1 -1
  263. package/dist/core/scaffold/landscape.js +219 -9
  264. package/dist/core/scaffold/landscape.js.map +1 -1
  265. package/dist/core/staging/writes.js.map +1 -1
  266. package/dist/core/usecases/delta/claims.js +1 -0
  267. package/dist/core/usecases/delta/claims.js.map +1 -1
  268. package/dist/core/usecases/delta/overlay.js +4 -0
  269. package/dist/core/usecases/delta/overlay.js.map +1 -1
  270. package/dist/core/usecases/fleet.js +18 -6
  271. package/dist/core/usecases/fleet.js.map +1 -1
  272. package/dist/core/usecases/operations.js +7 -0
  273. package/dist/core/usecases/operations.js.map +1 -1
  274. package/dist/core/usecases/service/exclude.js +119 -0
  275. package/dist/core/usecases/service/exclude.js.map +1 -0
  276. package/dist/core/usecases/service/flows.js +161 -0
  277. package/dist/core/usecases/service/flows.js.map +1 -0
  278. package/dist/core/verify/checklist.js +42 -10
  279. package/dist/core/verify/checklist.js.map +1 -1
  280. package/dist/core/vocabulary/issue.js.map +1 -1
  281. package/dist/core/vocabulary/maturity.js +51 -1
  282. package/dist/core/vocabulary/maturity.js.map +1 -1
  283. package/examples/README.md +30 -8
  284. package/examples/docs/architecture/landscape.likec4 +47 -0
  285. package/examples/docs/architecture/services.likec4 +70 -0
  286. package/examples/docs/architecture/subsystems.likec4 +3 -0
  287. package/examples/docs/architecture/usecases/order-notification.likec4 +5 -2
  288. package/examples/docs/likec4.config.json +0 -1
  289. package/examples/docs/services/checkout-web/model.likec4 +39 -56
  290. package/examples/docs/services/order-service/arch.spec.md +13 -6
  291. package/examples/docs/services/order-service/model.likec4 +58 -70
  292. package/examples/docs/services/payment-service/arch.spec.md +7 -4
  293. package/examples/docs/services/payment-service/model.likec4 +38 -39
  294. package/examples/docs/services/platform/identity-service/arch.spec.md +7 -4
  295. package/examples/docs/services/platform/identity-service/model.likec4 +35 -37
  296. package/examples/docs/services/platform/notification-service/health.yaml +8 -3
  297. package/examples/docs/services/platform/notification-service/model.likec4 +43 -64
  298. package/package.json +1 -1
  299. package/examples/docs/services/checkout-web/likec4.config.json +0 -4
  300. package/examples/docs/services/order-service/likec4.config.json +0 -4
  301. package/examples/docs/services/payment-service/likec4.config.json +0 -4
  302. package/examples/docs/services/platform/identity-service/likec4.config.json +0 -4
  303. package/examples/docs/services/platform/notification-service/likec4.config.json +0 -4
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Every service whose `model.likec4` EXTENDS the fleet map, with its living
3
+ * source in hand — the list the landscape merge routes a nested addition
4
+ * through.
5
+ *
6
+ * ## Why it is here and not beside the archive
7
+ *
8
+ * It was `commands/archive/plan/model/extending.ts`'s, beside the proof that
9
+ * the merged model parses, and for one merge that was the right seam: reading
10
+ * the fleet and deciding to write belong to the command, `core/c4/splice/` is a
11
+ * pure text-to-text computation, and nothing else needed the list.
12
+ *
13
+ * Then the archive stopped being the only reader. `../../project/staged.ts`
14
+ * previews the map a feature's own merge would leave, and every feature-side
15
+ * gate rests on that preview — `usecase.flow-invalid` and
16
+ * `deployment.doc-invalid` both refuse on it, and `--approve` reaches neither.
17
+ * A preview computed without this list previews the WRONG map: it holds
18
+ * interior the archive routes into `services/<…>/model.likec4`, so a
19
+ * feature-local flow hopping to `<service-fqn>.<container>` resolved against it,
20
+ * passed the gate, and was copied verbatim into `architecture/usecases/`, where
21
+ * it resolves against nothing (verification 2026-09-04, review finding F8;
22
+ * measured: `loam archive --approve` at exit 0, then `loam validate --all` with
23
+ * one `landscape.invalid` and one `spine.landscape-invalid` per service).
24
+ *
25
+ * A reader in `commands/` cannot close that: `core/` may never import
26
+ * `commands/`. Nor could it live in `../../project/` beside the staging that
27
+ * needs it — the shape scan is `../shape.ts`, and `../` imports
28
+ * `../../project/`, so the edge would be a package cycle
29
+ * (`node scripts/package-graph.mjs` refuses it). So it sits one level BELOW the
30
+ * shape scan it calls, in a package that nothing in `core/c4/` imports back:
31
+ * `core/delta/`, `core/coherence/` and the archive all reach it, and the graph
32
+ * stays acyclic.
33
+ *
34
+ * ## The shape decision is not this file's
35
+ *
36
+ * `../shape.ts` owns it: a model declaring an element kind is STANDALONE, is
37
+ * parsed alone, and owns no share of the merge — the map draws its containers
38
+ * exactly as it did before the extending shape existed. A model that cannot be
39
+ * read is not in the list either, which is the same conservative arm the shape
40
+ * reader takes.
41
+ */
42
+ import { readFile } from "node:fs/promises";
43
+ import { resolve } from "node:path";
44
+ import { inOrder } from "../../../kernel/concurrency.js";
45
+ import { repoPath } from "../../../envelope/json.js";
46
+ import { servicePathsAt } from "../../../repo/paths.js";
47
+ import { enumeratedServices } from "../../../repo/service-target.js";
48
+ import { readModelShapes } from "../shape.js";
49
+ /**
50
+ * The fleet's extending models, ordered by the enumeration.
51
+ *
52
+ * `fleet` is OPTIONAL for the reason `enumeratedServices` makes it optional:
53
+ * the callers are three, and only two of them run inside an invocation index.
54
+ * With one, the enumeration and the shape scan are memo hits and this costs one
55
+ * small read per extending model; without one, the same bulk scan runs directly
56
+ * (`readModelShapes`) and the answer is identical. What must never differ
57
+ * between the two is the LIST — a gate that routed differently depending on
58
+ * whether its caller happened to hold a read index would be a gate nobody could
59
+ * reproduce.
60
+ */
61
+ export async function extendingModels(docsDir, fleet) {
62
+ const services = await enumeratedServices(docsDir, fleet);
63
+ if (services.length === 0)
64
+ return [];
65
+ const paths = services.map((entry) => servicePathsAt(entry.dir).model);
66
+ const shapes = fleet === undefined ? await readModelShapes(paths) : await fleet.modelShapes(paths);
67
+ const found = await inOrder(services, async (entry) => {
68
+ const path = servicePathsAt(entry.dir).model;
69
+ if (shapes.get(resolve(path)) !== "extending")
70
+ return null;
71
+ try {
72
+ return { service: entry.id, path: repoPath(docsDir, path), text: await readFile(path, "utf8") };
73
+ }
74
+ catch {
75
+ // Unreadable is not extending: the merge would be splicing into bytes it
76
+ // never saw, and the map's arm is the honest fallback.
77
+ return null;
78
+ }
79
+ });
80
+ return found.filter((model) => model !== null);
81
+ }
82
+ //# sourceMappingURL=extending.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extending.js","sourceRoot":"","sources":["../../../../../src/core/c4/service-model/fleet/extending.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAIzD,OAAO,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,OAAgB,EAAE,KAAoB;IAC1E,MAAM,QAAQ,GAAG,MAAM,kBAAkB,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IAC1D,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACrC,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,cAAc,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;IACvE,MAAM,MAAM,GAAG,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;IACnG,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAkC,EAAE;QACpF,MAAM,IAAI,GAAG,cAAc,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC;QAC7C,IAAI,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,WAAW;YAAE,OAAO,IAAI,CAAC;QAC3D,IAAI,CAAC;YACH,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,EAAE,IAAI,EAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC;QAClG,CAAC;QAAC,MAAM,CAAC;YACP,yEAAyE;YACzE,uDAAuD;YACvD,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC,CAAC,CAAC;IACH,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,KAAK,EAA2B,EAAE,CAAC,KAAK,KAAK,IAAI,CAAC,CAAC;AAC1E,CAAC","sourcesContent":["/**\n * Every service whose `model.likec4` EXTENDS the fleet map, with its living\n * source in hand — the list the landscape merge routes a nested addition\n * through.\n *\n * ## Why it is here and not beside the archive\n *\n * It was `commands/archive/plan/model/extending.ts`'s, beside the proof that\n * the merged model parses, and for one merge that was the right seam: reading\n * the fleet and deciding to write belong to the command, `core/c4/splice/` is a\n * pure text-to-text computation, and nothing else needed the list.\n *\n * Then the archive stopped being the only reader. `../../project/staged.ts`\n * previews the map a feature's own merge would leave, and every feature-side\n * gate rests on that preview — `usecase.flow-invalid` and\n * `deployment.doc-invalid` both refuse on it, and `--approve` reaches neither.\n * A preview computed without this list previews the WRONG map: it holds\n * interior the archive routes into `services/<…>/model.likec4`, so a\n * feature-local flow hopping to `<service-fqn>.<container>` resolved against it,\n * passed the gate, and was copied verbatim into `architecture/usecases/`, where\n * it resolves against nothing (verification 2026-09-04, review finding F8;\n * measured: `loam archive --approve` at exit 0, then `loam validate --all` with\n * one `landscape.invalid` and one `spine.landscape-invalid` per service).\n *\n * A reader in `commands/` cannot close that: `core/` may never import\n * `commands/`. Nor could it live in `../../project/` beside the staging that\n * needs it — the shape scan is `../shape.ts`, and `../` imports\n * `../../project/`, so the edge would be a package cycle\n * (`node scripts/package-graph.mjs` refuses it). So it sits one level BELOW the\n * shape scan it calls, in a package that nothing in `core/c4/` imports back:\n * `core/delta/`, `core/coherence/` and the archive all reach it, and the graph\n * stays acyclic.\n *\n * ## The shape decision is not this file's\n *\n * `../shape.ts` owns it: a model declaring an element kind is STANDALONE, is\n * parsed alone, and owns no share of the merge — the map draws its containers\n * exactly as it did before the extending shape existed. A model that cannot be\n * read is not in the list either, which is the same conservative arm the shape\n * reader takes.\n */\nimport { readFile } from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport { inOrder } from \"../../../kernel/concurrency.js\";\nimport type { ExtendingModel } from \"../../splice/contract.js\";\nimport type { DocsDir } from \"../../../kernel/ids/dirs.js\";\nimport type { FleetContext } from \"../../../fleet-context.js\";\nimport { repoPath } from \"../../../envelope/json.js\";\nimport { servicePathsAt } from \"../../../repo/paths.js\";\nimport { enumeratedServices } from \"../../../repo/service-target.js\";\nimport { readModelShapes } from \"../shape.js\";\n\n/**\n * The fleet's extending models, ordered by the enumeration.\n *\n * `fleet` is OPTIONAL for the reason `enumeratedServices` makes it optional:\n * the callers are three, and only two of them run inside an invocation index.\n * With one, the enumeration and the shape scan are memo hits and this costs one\n * small read per extending model; without one, the same bulk scan runs directly\n * (`readModelShapes`) and the answer is identical. What must never differ\n * between the two is the LIST — a gate that routed differently depending on\n * whether its caller happened to hold a read index would be a gate nobody could\n * reproduce.\n */\nexport async function extendingModels(docsDir: DocsDir, fleet?: FleetContext): Promise<ExtendingModel[]> {\n const services = await enumeratedServices(docsDir, fleet);\n if (services.length === 0) return [];\n const paths = services.map((entry) => servicePathsAt(entry.dir).model);\n const shapes = fleet === undefined ? await readModelShapes(paths) : await fleet.modelShapes(paths);\n const found = await inOrder(services, async (entry): Promise<ExtendingModel | null> => {\n const path = servicePathsAt(entry.dir).model;\n if (shapes.get(resolve(path)) !== \"extending\") return null;\n try {\n return { service: entry.id, path: repoPath(docsDir, path), text: await readFile(path, \"utf8\") };\n } catch {\n // Unreadable is not extending: the merge would be splicing into bytes it\n // never saw, and the map's arm is the honest fallback.\n return null;\n }\n });\n return found.filter((model): model is ExtendingModel => model !== null);\n}\n"]}
@@ -0,0 +1,165 @@
1
+ /**
2
+ * How a `model.likec4` is read — the entry point every reader of a service
3
+ * model goes through, whichever shape the file has.
4
+ *
5
+ * One function rather than a branch at each call site, because the branch is
6
+ * not local: a standalone model is a file, an extending model is a project, and
7
+ * the six readers that open `model.likec4` (the service target, the isolation
8
+ * scan, arch coverage, `loam show`, the adopt brief, the service's use cases)
9
+ * all want the same thing out of either — the errors, the service's own
10
+ * elements and relationships, and the views the file itself declares. Making
11
+ * each of them ask "which shape is this?" is how five of them would come to
12
+ * answer it differently.
13
+ *
14
+ * The FOUR arms below are the whole of the contract, and each exists because
15
+ * one of them is a state a fleet is actually in:
16
+ *
17
+ * - standalone: today's behaviour, byte for byte. The file is parsed alone.
18
+ * - the map does not parse: the model extends something unreadable, so it
19
+ * cannot be read at all. NOT an error against the model — the fleet already
20
+ * has `spine.landscape-invalid` for this, and a second finding here would
21
+ * blame every service for one broken document.
22
+ * - the project has errors: every one of them is this model's, including an
23
+ * error LikeC4 blames on the landscape. That is sound because the map was
24
+ * parsed alone first and came back clean — measured: a duplicate declaration
25
+ * is reported against BOTH files, so the map's copy of the error exists only
26
+ * because this model was added to the set.
27
+ * - clean: the model's own slice (`./slice.ts`), its own views, and the
28
+ * project's specification, in the shape a standalone model's `LoadedDoc`
29
+ * already had.
30
+ *
31
+ * Nothing here is memoised. `../prefetch/fleet.ts` is the request-scoped memo
32
+ * over all of it — one instance per command invocation, owned by
33
+ * `core/fleet-context.ts` — because a loader that caches is a loader a test
34
+ * cannot ask twice.
35
+ */
36
+ import { basename, relative, resolve } from "node:path";
37
+ import { asLoadedDoc } from "../project/load.js";
38
+ import { loadProjectBatch } from "../project/batch.js";
39
+ import { modelProjectSets } from "./documents.js";
40
+ import { sliceForService } from "./slice.js";
41
+ /**
42
+ * `loadServiceModel` addressed by a service's paths — the two derivations that
43
+ * decide whether the slice and the views filter find anything at all, spelled
44
+ * here rather than at the caller.
45
+ *
46
+ * The SERVICE is the leaf directory name, which `ServiceEntry` defines as the
47
+ * canonical id at whatever depth the tree walk found the service; it is what an
48
+ * element's `metadata { service '…' }` binding names and what the element→
49
+ * service resolver answers, so a slice keyed on anything else is empty.
50
+ *
51
+ * The MODEL PATH is made docs-relative and `/`-separated, because that is how
52
+ * LikeC4 spells a view's `sourcePath` inside a project staged from the docs
53
+ * root — on Windows the native separator would miss every view, silently
54
+ * turning "this model declares no diagrams" into a fact about the operating
55
+ * system.
56
+ */
57
+ export function serviceModelAt(sources) {
58
+ return loadServiceModel({
59
+ shape: sources.shape,
60
+ service: basename(sources.paths.dir),
61
+ modelPath: relative(sources.docsDir, sources.paths.model).split(/[\\/]/).join("/"),
62
+ known: sources.known,
63
+ architecture: sources.architecture,
64
+ standalone: sources.standalone,
65
+ project: sources.project,
66
+ });
67
+ }
68
+ /**
69
+ * A `LoadedDoc` carrying nothing — the shape a model that could not be read
70
+ * answers with. A FUNCTION rather than a shared constant: the arrays reach
71
+ * every caller, and one that pushed a finding into a module-level literal would
72
+ * corrupt the answer for every other service in the run.
73
+ */
74
+ function noModel() {
75
+ return { errors: [], elements: [], relationships: [] };
76
+ }
77
+ export async function loadServiceModel(req) {
78
+ if (req.shape === "standalone") {
79
+ // Before the map is asked for, deliberately: a standalone model is a file
80
+ // parsed alone, so a reader of one must not pay for the `architecture/`
81
+ // project — nor inherit its failures.
82
+ return { shape: req.shape, doc: await req.standalone(), project: null, unowned: [], mapUnreadable: false };
83
+ }
84
+ const architecture = await req.architecture();
85
+ if (architecture.errors.length > 0) {
86
+ return { shape: req.shape, doc: noModel(), project: null, unowned: [], mapUnreadable: true };
87
+ }
88
+ const project = await req.project();
89
+ if (project.errors.length > 0) {
90
+ // Every error, not the ones whose path is the model's. The map parsed alone
91
+ // and came back clean, so an error now blamed on the landscape exists only
92
+ // because this model joined the set — and dropping it would grade the model
93
+ // valid while the renderer, reading the same two documents together,
94
+ // refuses to draw anything.
95
+ return {
96
+ shape: req.shape,
97
+ doc: { errors: project.errors, elements: [], relationships: [] },
98
+ project: null,
99
+ unowned: [],
100
+ mapUnreadable: false,
101
+ };
102
+ }
103
+ const slice = sliceForService({
104
+ project,
105
+ architecture,
106
+ service: req.service,
107
+ known: req.known,
108
+ });
109
+ return {
110
+ shape: req.shape,
111
+ doc: {
112
+ errors: [],
113
+ elements: slice.elements,
114
+ relationships: slice.relationships,
115
+ // The model file's OWN views, by the file each was written in. A service
116
+ // renders its own diagrams and the fleet's use cases are drawn over the
117
+ // same elements, so without the filter every service in the fleet would
118
+ // report every fleet-level view as one of its own.
119
+ views: (project.views ?? []).filter((view) => view.sourcePath === req.modelPath),
120
+ // The PROJECT's specification, deliberately: an extending model takes the
121
+ // map's kinds and tags, so "what this model can use" is the project's
122
+ // table, and a tags-only block of its own has already been merged into it.
123
+ ...(project.specification === undefined ? {} : { specification: project.specification }),
124
+ },
125
+ project,
126
+ unowned: slice.unowned,
127
+ mapUnreadable: false,
128
+ };
129
+ }
130
+ /**
131
+ * Every extending model's per-service project, parsed in ONE workspace — the
132
+ * engine under `../prefetch/fleet.ts`'s `prefetch`.
133
+ *
134
+ * Keyed by the RESOLVED model path, which is the key that memo files
135
+ * per-service projects under; a second spelling would be a memo the prefetch
136
+ * fills and no reader ever hits.
137
+ *
138
+ * A batch-infrastructure failure answers with an EMPTY map rather than
139
+ * rejecting: the prefetch is an accelerator and nothing more, so a workspace
140
+ * that could not be created must cost the run its speed and never one finding
141
+ * (`FleetContext.prefetchLikeC4` has behaved this way since the batch loader
142
+ * landed, and the two must not differ — a run that degrades one way and not the
143
+ * other is a run whose report depends on the tmpdir).
144
+ */
145
+ export async function batchServiceProjects(docsDir, models) {
146
+ const out = new Map();
147
+ if (models.length === 0)
148
+ return out;
149
+ const requests = (await modelProjectSets(docsDir, models)).map((set) => ({
150
+ key: resolve(set.model),
151
+ base: docsDir,
152
+ paths: set.paths,
153
+ }));
154
+ try {
155
+ for (const [key, doc] of await loadProjectBatch(requests))
156
+ out.set(key, asLoadedDoc(doc));
157
+ }
158
+ catch {
159
+ // Unreadable is not invalid: the caller seeds nothing and every reader
160
+ // falls back to its own per-project load.
161
+ return new Map();
162
+ }
163
+ return out;
164
+ }
165
+ //# sourceMappingURL=load.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"load.js","sourceRoot":"","sources":["../../../../src/core/c4/service-model/load.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAIxD,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAgF7C;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,cAAc,CAAC,OAA4B;IACzD,OAAO,gBAAgB,CAAC;QACtB,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC;QACpC,SAAS,EAAE,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC;QAClF,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,YAAY,EAAE,OAAO,CAAC,YAAY;QAClC,UAAU,EAAE,OAAO,CAAC,UAAU;QAC9B,OAAO,EAAE,OAAO,CAAC,OAAO;KACzB,CAAC,CAAC;AACL,CAAC;AAED;;;;;GAKG;AACH,SAAS,OAAO;IACd,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE,CAAC;AACzD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,GAAwB;IAC7D,IAAI,GAAG,CAAC,KAAK,KAAK,YAAY,EAAE,CAAC;QAC/B,0EAA0E;QAC1E,wEAAwE;QACxE,sCAAsC;QACtC,OAAO,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,UAAU,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,aAAa,EAAE,KAAK,EAAE,CAAC;IAC7G,CAAC;IACD,MAAM,YAAY,GAAG,MAAM,GAAG,CAAC,YAAY,EAAE,CAAC;IAC9C,IAAI,YAAY,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACnC,OAAO,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC;IAC/F,CAAC;IACD,MAAM,OAAO,GAAG,MAAM,GAAG,CAAC,OAAO,EAAE,CAAC;IACpC,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC9B,4EAA4E;QAC5E,2EAA2E;QAC3E,4EAA4E;QAC5E,qEAAqE;QACrE,4BAA4B;QAC5B,OAAO;YACL,KAAK,EAAE,GAAG,CAAC,KAAK;YAChB,GAAG,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,QAAQ,EAAE,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE;YAChE,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,EAAE;YACX,aAAa,EAAE,KAAK;SACrB,CAAC;IACJ,CAAC;IACD,MAAM,KAAK,GAAG,eAAe,CAAC;QAC5B,OAAO;QACP,YAAY;QACZ,OAAO,EAAE,GAAG,CAAC,OAAO;QACpB,KAAK,EAAE,GAAG,CAAC,KAAK;KACjB,CAAC,CAAC;IACH,OAAO;QACL,KAAK,EAAE,GAAG,CAAC,KAAK;QAChB,GAAG,EAAE;YACH,MAAM,EAAE,EAAE;YACV,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,aAAa,EAAE,KAAK,CAAC,aAAa;YAClC,yEAAyE;YACzE,wEAAwE;YACxE,wEAAwE;YACxE,mDAAmD;YACnD,KAAK,EAAE,CAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,UAAU,KAAK,GAAG,CAAC,SAAS,CAAC;YAChF,0EAA0E;YAC1E,sEAAsE;YACtE,2EAA2E;YAC3E,GAAG,CAAC,OAAO,CAAC,aAAa,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,OAAO,CAAC,aAAa,EAAE,CAAC;SACzF;QACD,OAAO;QACP,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,aAAa,EAAE,KAAK;KACrB,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,OAAgB,EAChB,MAAyB;IAEzB,MAAM,GAAG,GAAG,IAAI,GAAG,EAAqB,CAAC;IACzC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,GAAG,CAAC;IACpC,MAAM,QAAQ,GAAG,CAAC,MAAM,gBAAgB,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QACvE,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC;QACvB,IAAI,EAAE,OAAO;QACb,KAAK,EAAE,GAAG,CAAC,KAAK;KACjB,CAAC,CAAC,CAAC;IACJ,IAAI,CAAC;QACH,KAAK,MAAM,CAAC,GAAG,EAAE,GAAG,CAAC,IAAI,MAAM,gBAAgB,CAAC,QAAQ,CAAC;YAAE,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC;IAC5F,CAAC;IAAC,MAAM,CAAC;QACP,uEAAuE;QACvE,0CAA0C;QAC1C,OAAO,IAAI,GAAG,EAAE,CAAC;IACnB,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC","sourcesContent":["/**\n * How a `model.likec4` is read — the entry point every reader of a service\n * model goes through, whichever shape the file has.\n *\n * One function rather than a branch at each call site, because the branch is\n * not local: a standalone model is a file, an extending model is a project, and\n * the six readers that open `model.likec4` (the service target, the isolation\n * scan, arch coverage, `loam show`, the adopt brief, the service's use cases)\n * all want the same thing out of either — the errors, the service's own\n * elements and relationships, and the views the file itself declares. Making\n * each of them ask \"which shape is this?\" is how five of them would come to\n * answer it differently.\n *\n * The FOUR arms below are the whole of the contract, and each exists because\n * one of them is a state a fleet is actually in:\n *\n * - standalone: today's behaviour, byte for byte. The file is parsed alone.\n * - the map does not parse: the model extends something unreadable, so it\n * cannot be read at all. NOT an error against the model — the fleet already\n * has `spine.landscape-invalid` for this, and a second finding here would\n * blame every service for one broken document.\n * - the project has errors: every one of them is this model's, including an\n * error LikeC4 blames on the landscape. That is sound because the map was\n * parsed alone first and came back clean — measured: a duplicate declaration\n * is reported against BOTH files, so the map's copy of the error exists only\n * because this model was added to the set.\n * - clean: the model's own slice (`./slice.ts`), its own views, and the\n * project's specification, in the shape a standalone model's `LoadedDoc`\n * already had.\n *\n * Nothing here is memoised. `../prefetch/fleet.ts` is the request-scoped memo\n * over all of it — one instance per command invocation, owned by\n * `core/fleet-context.ts` — because a loader that caches is a loader a test\n * cannot ask twice.\n */\nimport { basename, relative, resolve } from \"node:path\";\nimport type { DocsDir } from \"../../kernel/ids/dirs.js\";\nimport type { ServicePaths } from \"../../repo/paths.js\";\nimport type { Elem, LoadedDoc } from \"../likec4.js\";\nimport { asLoadedDoc } from \"../project/load.js\";\nimport { loadProjectBatch } from \"../project/batch.js\";\nimport { modelProjectSets } from \"./documents.js\";\nimport type { ModelShape } from \"./shape.js\";\nimport { sliceForService } from \"./slice.js\";\n\nexport interface ServiceModel {\n shape: ModelShape;\n /**\n * Errors, the OWN elements and relationships, and the model file's own views\n * — what every model-dependent check reads, in the shape it read when a model\n * was always one self-contained file. For a standalone model it is exactly\n * what parsing that file returns.\n */\n doc: LoadedDoc;\n /**\n * Extending only: the whole per-service project, map and model together —\n * the scope a `Covers:` line and a use-case step resolve against, because\n * both may legitimately name an element the map declares. Null for a\n * standalone model, for a model that did not parse, and when the map itself\n * is unreadable.\n */\n project: LoadedDoc | null;\n /**\n * Extending only: the elements the model adds OUTSIDE the element that\n * resolves to it — `c4.element-unowned`, one finding each. Empty otherwise,\n * including for a standalone model, whose partner copies are a different\n * grade (`c4.declaration-diverged`) asking a different question.\n */\n unowned: Elem[];\n /**\n * The architecture project itself does not parse, so an extending model could\n * not be read at all. `doc` then carries no errors and no model, and the\n * finding is the fleet's `spine.landscape-invalid` rather than anything about\n * this service's file.\n */\n mapUnreadable: boolean;\n}\n\nexport interface ServiceModelRequest {\n shape: ModelShape;\n /** The `services/<…>/<id>` directory name — what the model's elements must resolve to. */\n service: string;\n /**\n * The model's path RELATIVE TO THE DOCS ROOT, `/`-separated: the spelling\n * LikeC4 gives a view's `sourcePath` inside a project staged from that root,\n * and therefore the only spelling the views filter below can join on.\n */\n modelPath: string;\n /** Every service directory that exists, for the element→service resolver. */\n known: ReadonlySet<string>;\n /**\n * Parse the architecture documents WITHOUT this model — both the gate on\n * whether an extending model can be read and the diff's other half.\n *\n * A THUNK, like the two below, and awaited only in the extending branch. It\n * arrived as a value and every caller therefore paid for the whole\n * `architecture/` project before the shape was even consulted: `loam show` and\n * the adopt brief over a fleet whose models all stand alone spun a LikeC4\n * workspace they never read a byte of, and a `loadProject` failure in it\n * propagated out of commands whose model read is one `readFile`. A standalone\n * model is parsed alone, so the map is not part of its answer at all.\n */\n architecture: () => Promise<LoadedDoc>;\n /** Parse the model file alone. Called for a standalone model and never otherwise. */\n standalone: () => Promise<LoadedDoc>;\n /** Parse the per-service project (map + model). Memoised and batched by the caller. */\n project: () => Promise<LoadedDoc>;\n}\n\n/**\n * The DOCS-REPO facts a request is derived from, for the caller that holds\n * paths rather than the derived spellings.\n */\nexport interface ServiceModelSources {\n docsDir: DocsDir;\n paths: ServicePaths;\n shape: ModelShape;\n known: ReadonlySet<string>;\n architecture: () => Promise<LoadedDoc>;\n standalone: () => Promise<LoadedDoc>;\n project: () => Promise<LoadedDoc>;\n}\n\n/**\n * `loadServiceModel` addressed by a service's paths — the two derivations that\n * decide whether the slice and the views filter find anything at all, spelled\n * here rather than at the caller.\n *\n * The SERVICE is the leaf directory name, which `ServiceEntry` defines as the\n * canonical id at whatever depth the tree walk found the service; it is what an\n * element's `metadata { service '…' }` binding names and what the element→\n * service resolver answers, so a slice keyed on anything else is empty.\n *\n * The MODEL PATH is made docs-relative and `/`-separated, because that is how\n * LikeC4 spells a view's `sourcePath` inside a project staged from the docs\n * root — on Windows the native separator would miss every view, silently\n * turning \"this model declares no diagrams\" into a fact about the operating\n * system.\n */\nexport function serviceModelAt(sources: ServiceModelSources): Promise<ServiceModel> {\n return loadServiceModel({\n shape: sources.shape,\n service: basename(sources.paths.dir),\n modelPath: relative(sources.docsDir, sources.paths.model).split(/[\\\\/]/).join(\"/\"),\n known: sources.known,\n architecture: sources.architecture,\n standalone: sources.standalone,\n project: sources.project,\n });\n}\n\n/**\n * A `LoadedDoc` carrying nothing — the shape a model that could not be read\n * answers with. A FUNCTION rather than a shared constant: the arrays reach\n * every caller, and one that pushed a finding into a module-level literal would\n * corrupt the answer for every other service in the run.\n */\nfunction noModel(): LoadedDoc {\n return { errors: [], elements: [], relationships: [] };\n}\n\nexport async function loadServiceModel(req: ServiceModelRequest): Promise<ServiceModel> {\n if (req.shape === \"standalone\") {\n // Before the map is asked for, deliberately: a standalone model is a file\n // parsed alone, so a reader of one must not pay for the `architecture/`\n // project — nor inherit its failures.\n return { shape: req.shape, doc: await req.standalone(), project: null, unowned: [], mapUnreadable: false };\n }\n const architecture = await req.architecture();\n if (architecture.errors.length > 0) {\n return { shape: req.shape, doc: noModel(), project: null, unowned: [], mapUnreadable: true };\n }\n const project = await req.project();\n if (project.errors.length > 0) {\n // Every error, not the ones whose path is the model's. The map parsed alone\n // and came back clean, so an error now blamed on the landscape exists only\n // because this model joined the set — and dropping it would grade the model\n // valid while the renderer, reading the same two documents together,\n // refuses to draw anything.\n return {\n shape: req.shape,\n doc: { errors: project.errors, elements: [], relationships: [] },\n project: null,\n unowned: [],\n mapUnreadable: false,\n };\n }\n const slice = sliceForService({\n project,\n architecture,\n service: req.service,\n known: req.known,\n });\n return {\n shape: req.shape,\n doc: {\n errors: [],\n elements: slice.elements,\n relationships: slice.relationships,\n // The model file's OWN views, by the file each was written in. A service\n // renders its own diagrams and the fleet's use cases are drawn over the\n // same elements, so without the filter every service in the fleet would\n // report every fleet-level view as one of its own.\n views: (project.views ?? []).filter((view) => view.sourcePath === req.modelPath),\n // The PROJECT's specification, deliberately: an extending model takes the\n // map's kinds and tags, so \"what this model can use\" is the project's\n // table, and a tags-only block of its own has already been merged into it.\n ...(project.specification === undefined ? {} : { specification: project.specification }),\n },\n project,\n unowned: slice.unowned,\n mapUnreadable: false,\n };\n}\n\n/**\n * Every extending model's per-service project, parsed in ONE workspace — the\n * engine under `../prefetch/fleet.ts`'s `prefetch`.\n *\n * Keyed by the RESOLVED model path, which is the key that memo files\n * per-service projects under; a second spelling would be a memo the prefetch\n * fills and no reader ever hits.\n *\n * A batch-infrastructure failure answers with an EMPTY map rather than\n * rejecting: the prefetch is an accelerator and nothing more, so a workspace\n * that could not be created must cost the run its speed and never one finding\n * (`FleetContext.prefetchLikeC4` has behaved this way since the batch loader\n * landed, and the two must not differ — a run that degrades one way and not the\n * other is a run whose report depends on the tmpdir).\n */\nexport async function batchServiceProjects(\n docsDir: DocsDir,\n models: readonly string[],\n): Promise<Map<string, LoadedDoc>> {\n const out = new Map<string, LoadedDoc>();\n if (models.length === 0) return out;\n const requests = (await modelProjectSets(docsDir, models)).map((set) => ({\n key: resolve(set.model),\n base: docsDir,\n paths: set.paths,\n }));\n try {\n for (const [key, doc] of await loadProjectBatch(requests)) out.set(key, asLoadedDoc(doc));\n } catch {\n // Unreadable is not invalid: the caller seeds nothing and every reader\n // falls back to its own per-project load.\n return new Map();\n }\n return out;\n}\n"]}
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The `exclude` list `loam subsystem sync` WRITES back into the root
3
+ * `likec4.config.json` — the write half of the one file loam otherwise only
4
+ * reads. The read half, and the matcher that decides whether an entry hides a
5
+ * directory, are `../root-project/exclude.ts`: they moved down there when the
6
+ * project loader became a reader of the same list, and this module is the only
7
+ * thing left that computes new bytes for it.
8
+ *
9
+ * The ownership rule is stated once and holds for both halves: `exclude` is a
10
+ * glob list in a tool's own config, the team owns the file, and loam maintains
11
+ * exactly the `services/` entries that decide whether a model is renderable. An
12
+ * entry loam does not recognise is left exactly where the team put it.
13
+ */
14
+ import { excludedDirectory, excludingEntry } from "../root-project/exclude.js";
15
+ /** The `services/` prefix every tree path is spelled under, in the config and on disk alike. */
16
+ const SERVICES = "services";
17
+ /**
18
+ * The `exclude` list `loam subsystem sync` writes: loam's own entries
19
+ * recomputed from the shapes on disk, everybody else's kept exactly as written
20
+ * and in that order.
21
+ *
22
+ * WHICH ENTRIES ARE LOAM'S is the careful half, and it is decided by two
23
+ * different questions rather than one.
24
+ *
25
+ * The first is AUTHORSHIP: the whole `services/` tree is loam's (it wrote that
26
+ * entry into every scaffold before this axis), and so is any entry naming a
27
+ * `services/<tree>` the enumeration actually found — those are the entries this
28
+ * function produced on an earlier sync, and one of them must disappear the
29
+ * moment that service's model becomes extending. This is why the enumeration is
30
+ * a parameter rather than something inferred from the entries themselves: an
31
+ * entry cannot say who wrote it, but the tree can say whether it names a service.
32
+ *
33
+ * The second is EFFECT, and it is the half that was missing. An entry only has
34
+ * to cover an ANCESTOR of a tree to hide it — `excludingEntry` says so, because
35
+ * that is how the renderer reads the list — so a root config saying
36
+ * `services/platform/**` hides `services/platform/identity-service` just as
37
+ * completely as `services/**` does, while naming a directory the enumeration
38
+ * never returns as a service. Left alone it warned `service.model-excluded`
39
+ * forever and `sync` answered `updated: false`: a repair loop that never closed,
40
+ * with the message naming the command that would not perform it. So an entry
41
+ * that covers a tree whose model EXTENDS the map is dropped as well — the
42
+ * standalone trees such an entry also covered get their own entries back through
43
+ * the append below.
44
+ *
45
+ * THE TWO QUESTIONS ARE ASKED THROUGH DIFFERENT FUNCTIONS, and that is the
46
+ * correction this round made (verification 2026-09-04, review C). AUTHORSHIP is
47
+ * about the directory an entry NAMES, so it goes through `excludedDirectory`,
48
+ * which answers null for an entry naming no plain directory at all (a starred
49
+ * middle segment, the `node_modules` glob). EFFECT is about what an entry HIDES,
50
+ * so it goes through `excludingEntry` — the same matcher the read side uses,
51
+ * whatever the entry's spelling. Asking effect through `excludedDirectory` too
52
+ * meant an entry with a star where the subsystem name goes was `kept`
53
+ * unconditionally: it hid an extending model forever while
54
+ * `service.model-excluded` named this command as the repair, which is the very
55
+ * loop the effect rule was added to close.
56
+ *
57
+ * Everything else survives untouched, INCLUDING a `services/legacy/**` for a
58
+ * directory that is not a service at all, and including a covering entry that
59
+ * hides only standalone trees: those are the team's own globs about their own
60
+ * tree, they hide nothing that needs to be visible, and a sync that ate one
61
+ * would be loam deleting a line it never wrote. Only the entries that make a
62
+ * renderable model unrenderable are taken.
63
+ *
64
+ * A standalone model MUST be excluded: it declares its own kinds, so inside the
65
+ * root project every one of them is a duplicate blamed on the fleet map as
66
+ * well, which blanks the whole project. An extending model must NOT be: the
67
+ * root project is the only place it parses at all.
68
+ *
69
+ * NOTHING IS APPENDED FOR A TREE THE SURVIVING LIST ALREADY COVERS. A second
70
+ * entry would be a line a person did not write appearing in a file they own,
71
+ * for a directory the renderer already skips — and SCHEMA.md promises exactly
72
+ * that ("a directory the list already covers in any of those spellings earns no
73
+ * second entry"). The risk it leaves is real and it is the recoverable one: if
74
+ * the team narrows their covering glob tomorrow, the standalone model becomes
75
+ * visible in the root project, `validate --all` says so as
76
+ * `service.model-unexcluded`, and the next sync writes the entry. A duplicate
77
+ * cannot be un-written by any command at all.
78
+ *
79
+ * The appended entries are sorted rather than kept in tree order because the
80
+ * file is a diff a person reads: a service adopted into the middle of a
81
+ * subsystem would otherwise move every line after it.
82
+ */
83
+ export function standaloneExclude(exclude, trees) {
84
+ const enumerated = new Set(trees.enumerated.map(treeUnderServices));
85
+ const kept = exclude.filter((entry) => {
86
+ // EFFECT first, and through the matcher rather than through the directory
87
+ // the entry names: an entry that hides a model which only parses inside the
88
+ // root project has to go whatever its spelling, and the authorship half
89
+ // below cannot see half of those spellings.
90
+ if (trees.extending.some((tree) => excludingEntry([entry], tree) !== null))
91
+ return false;
92
+ const dir = servicesTreeOf(entry);
93
+ if (dir === null)
94
+ return true;
95
+ // The whole `services/` tree, however spelled: loam wrote that entry into
96
+ // every scaffold before the extending shape existed, and it is the one this
97
+ // rewrite exists to take back.
98
+ if (dir === "")
99
+ return false;
100
+ return !enumerated.has(dir);
101
+ });
102
+ const owed = [...new Set(trees.standalone.map(treeUnderServices))].sort();
103
+ return [...kept, ...owed.filter((tree) => excludingEntry(kept, tree) === null).map((tree) => `${SERVICES}/${tree}/**`)];
104
+ }
105
+ /**
106
+ * The service tree an entry names — the part under `services/` — or null when
107
+ * the entry is about something else entirely. `""` is the `services/` root
108
+ * itself.
109
+ *
110
+ * An entry that names no plain directory — a wildcard segment between two
111
+ * literal ones, or the leading doubled star of the `node_modules` glob —
112
+ * answers null: loam cannot say which trees such a line is about, so it is
113
+ * never loam's to rewrite, whatever it happens to cover.
114
+ */
115
+ function servicesTreeOf(entry) {
116
+ const dir = excludedDirectory(entry);
117
+ if (dir === null)
118
+ return null;
119
+ if (dir === SERVICES)
120
+ return "";
121
+ return dir.startsWith(`${SERVICES}/`) ? dir.slice(SERVICES.length + 1) : null;
122
+ }
123
+ /** A tree path with the `services/` prefix stripped if the caller spelled it — `excludingEntry`'s normalisation, one level down. */
124
+ function treeUnderServices(tree) {
125
+ const spelled = tree.split(/[\\/]/).filter((s) => s.length > 0).join("/");
126
+ return spelled.startsWith(`${SERVICES}/`) ? spelled.slice(SERVICES.length + 1) : spelled;
127
+ }
128
+ //# sourceMappingURL=renderer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"renderer.js","sourceRoot":"","sources":["../../../../src/core/c4/service-model/renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAE/E,gGAAgG;AAChG,MAAM,QAAQ,GAAG,UAAU,CAAC;AAe5B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AACH,MAAM,UAAU,iBAAiB,CAAC,OAA0B,EAAE,KAAmB;IAC/E,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC,CAAC;IACpE,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE;QACpC,0EAA0E;QAC1E,4EAA4E;QAC5E,wEAAwE;QACxE,4CAA4C;QAC5C,IAAI,KAAK,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,cAAc,CAAC,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC,KAAK,IAAI,CAAC;YAAE,OAAO,KAAK,CAAC;QACzF,MAAM,GAAG,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;QAClC,IAAI,GAAG,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QAC9B,0EAA0E;QAC1E,4EAA4E;QAC5E,+BAA+B;QAC/B,IAAI,GAAG,KAAK,EAAE;YAAE,OAAO,KAAK,CAAC;QAC7B,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC9B,CAAC,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC1E,OAAO,CAAC,GAAG,IAAI,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,cAAc,CAAC,IAAI,EAAE,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,QAAQ,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC;AAC1H,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,cAAc,CAAC,KAAa;IACnC,MAAM,GAAG,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;IACrC,IAAI,GAAG,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAC9B,IAAI,GAAG,KAAK,QAAQ;QAAE,OAAO,EAAE,CAAC;IAChC,OAAO,GAAG,CAAC,UAAU,CAAC,GAAG,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAChF,CAAC;AAED,oIAAoI;AACpI,SAAS,iBAAiB,CAAC,IAAY;IACrC,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC1E,OAAO,OAAO,CAAC,UAAU,CAAC,GAAG,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;AAC3F,CAAC","sourcesContent":["/**\n * The `exclude` list `loam subsystem sync` WRITES back into the root\n * `likec4.config.json` — the write half of the one file loam otherwise only\n * reads. The read half, and the matcher that decides whether an entry hides a\n * directory, are `../root-project/exclude.ts`: they moved down there when the\n * project loader became a reader of the same list, and this module is the only\n * thing left that computes new bytes for it.\n *\n * The ownership rule is stated once and holds for both halves: `exclude` is a\n * glob list in a tool's own config, the team owns the file, and loam maintains\n * exactly the `services/` entries that decide whether a model is renderable. An\n * entry loam does not recognise is left exactly where the team put it.\n */\nimport { excludedDirectory, excludingEntry } from \"../root-project/exclude.js\";\n\n/** The `services/` prefix every tree path is spelled under, in the config and on disk alike. */\nconst SERVICES = \"services\";\n\n/** The service trees a rewrite is computed against: which stand alone, and which exist at all. */\nexport interface ExcludeTrees {\n /** Every service whose model declares its own kinds, and so must be excluded. */\n standalone: readonly string[];\n /**\n * Every service whose model EXTENDS the map, and so must NOT be excluded by\n * anything — the trees a covering entry is measured against below.\n */\n extending: readonly string[];\n /** Every service the enumeration found — the domain loam's own entries are drawn from. */\n enumerated: readonly string[];\n}\n\n/**\n * The `exclude` list `loam subsystem sync` writes: loam's own entries\n * recomputed from the shapes on disk, everybody else's kept exactly as written\n * and in that order.\n *\n * WHICH ENTRIES ARE LOAM'S is the careful half, and it is decided by two\n * different questions rather than one.\n *\n * The first is AUTHORSHIP: the whole `services/` tree is loam's (it wrote that\n * entry into every scaffold before this axis), and so is any entry naming a\n * `services/<tree>` the enumeration actually found — those are the entries this\n * function produced on an earlier sync, and one of them must disappear the\n * moment that service's model becomes extending. This is why the enumeration is\n * a parameter rather than something inferred from the entries themselves: an\n * entry cannot say who wrote it, but the tree can say whether it names a service.\n *\n * The second is EFFECT, and it is the half that was missing. An entry only has\n * to cover an ANCESTOR of a tree to hide it — `excludingEntry` says so, because\n * that is how the renderer reads the list — so a root config saying\n * `services/platform/**` hides `services/platform/identity-service` just as\n * completely as `services/**` does, while naming a directory the enumeration\n * never returns as a service. Left alone it warned `service.model-excluded`\n * forever and `sync` answered `updated: false`: a repair loop that never closed,\n * with the message naming the command that would not perform it. So an entry\n * that covers a tree whose model EXTENDS the map is dropped as well — the\n * standalone trees such an entry also covered get their own entries back through\n * the append below.\n *\n * THE TWO QUESTIONS ARE ASKED THROUGH DIFFERENT FUNCTIONS, and that is the\n * correction this round made (verification 2026-09-04, review C). AUTHORSHIP is\n * about the directory an entry NAMES, so it goes through `excludedDirectory`,\n * which answers null for an entry naming no plain directory at all (a starred\n * middle segment, the `node_modules` glob). EFFECT is about what an entry HIDES,\n * so it goes through `excludingEntry` — the same matcher the read side uses,\n * whatever the entry's spelling. Asking effect through `excludedDirectory` too\n * meant an entry with a star where the subsystem name goes was `kept`\n * unconditionally: it hid an extending model forever while\n * `service.model-excluded` named this command as the repair, which is the very\n * loop the effect rule was added to close.\n *\n * Everything else survives untouched, INCLUDING a `services/legacy/**` for a\n * directory that is not a service at all, and including a covering entry that\n * hides only standalone trees: those are the team's own globs about their own\n * tree, they hide nothing that needs to be visible, and a sync that ate one\n * would be loam deleting a line it never wrote. Only the entries that make a\n * renderable model unrenderable are taken.\n *\n * A standalone model MUST be excluded: it declares its own kinds, so inside the\n * root project every one of them is a duplicate blamed on the fleet map as\n * well, which blanks the whole project. An extending model must NOT be: the\n * root project is the only place it parses at all.\n *\n * NOTHING IS APPENDED FOR A TREE THE SURVIVING LIST ALREADY COVERS. A second\n * entry would be a line a person did not write appearing in a file they own,\n * for a directory the renderer already skips — and SCHEMA.md promises exactly\n * that (\"a directory the list already covers in any of those spellings earns no\n * second entry\"). The risk it leaves is real and it is the recoverable one: if\n * the team narrows their covering glob tomorrow, the standalone model becomes\n * visible in the root project, `validate --all` says so as\n * `service.model-unexcluded`, and the next sync writes the entry. A duplicate\n * cannot be un-written by any command at all.\n *\n * The appended entries are sorted rather than kept in tree order because the\n * file is a diff a person reads: a service adopted into the middle of a\n * subsystem would otherwise move every line after it.\n */\nexport function standaloneExclude(exclude: readonly string[], trees: ExcludeTrees): string[] {\n const enumerated = new Set(trees.enumerated.map(treeUnderServices));\n const kept = exclude.filter((entry) => {\n // EFFECT first, and through the matcher rather than through the directory\n // the entry names: an entry that hides a model which only parses inside the\n // root project has to go whatever its spelling, and the authorship half\n // below cannot see half of those spellings.\n if (trees.extending.some((tree) => excludingEntry([entry], tree) !== null)) return false;\n const dir = servicesTreeOf(entry);\n if (dir === null) return true;\n // The whole `services/` tree, however spelled: loam wrote that entry into\n // every scaffold before the extending shape existed, and it is the one this\n // rewrite exists to take back.\n if (dir === \"\") return false;\n return !enumerated.has(dir);\n });\n const owed = [...new Set(trees.standalone.map(treeUnderServices))].sort();\n return [...kept, ...owed.filter((tree) => excludingEntry(kept, tree) === null).map((tree) => `${SERVICES}/${tree}/**`)];\n}\n\n/**\n * The service tree an entry names — the part under `services/` — or null when\n * the entry is about something else entirely. `\"\"` is the `services/` root\n * itself.\n *\n * An entry that names no plain directory — a wildcard segment between two\n * literal ones, or the leading doubled star of the `node_modules` glob —\n * answers null: loam cannot say which trees such a line is about, so it is\n * never loam's to rewrite, whatever it happens to cover.\n */\nfunction servicesTreeOf(entry: string): string | null {\n const dir = excludedDirectory(entry);\n if (dir === null) return null;\n if (dir === SERVICES) return \"\";\n return dir.startsWith(`${SERVICES}/`) ? dir.slice(SERVICES.length + 1) : null;\n}\n\n/** A tree path with the `services/` prefix stripped if the caller spelled it — `excludingEntry`'s normalisation, one level down. */\nfunction treeUnderServices(tree: string): string {\n const spelled = tree.split(/[\\\\/]/).filter((s) => s.length > 0).join(\"/\");\n return spelled.startsWith(`${SERVICES}/`) ? spelled.slice(SERVICES.length + 1) : spelled;\n}\n"]}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Which of the two shapes a `model.likec4` has, decided by a byte scan of the
3
+ * file itself.
4
+ *
5
+ * A STANDALONE model declares at least one `element <kind>` inside a top-level
6
+ * `specification { }` block. It is parsed ALONE, exactly as every model was
7
+ * before this axis: it may re-declare the partners it talks to, and it renders
8
+ * only as a project of its own. An EXTENDING model declares no element kind —
9
+ * it takes the map's — and says what is inside the service with
10
+ * `extend <fqn> { … }`; it is parsed INSIDE the fleet's `architecture/`
11
+ * project, beside the map that declares those kinds.
12
+ *
13
+ * THE FILE'S OWN GRAMMAR DECIDES, never a config key, and that is the whole
14
+ * design of this module. A flag in `loam.json` would be a second authority on a
15
+ * fact the document already states, free to disagree with it — and the
16
+ * disagreement is not a warning but a parse: a model read the wrong way is
17
+ * either eleven `c4.invalid` errors (an extending model parsed alone: measured,
18
+ * deleting a model's `specification` under the old loader) or a pile of
19
+ * duplicate-declaration errors blamed on BOTH files (a standalone model staged
20
+ * beside the landscape). Nothing about a model has to be migrated for loam to
21
+ * read it correctly; the bytes say which it is.
22
+ *
23
+ * Every other answer is `extending`, and each of those is legal on purpose: a
24
+ * tags-only `specification { tag req-X }` (measured legal inside one project,
25
+ * and it lands the tag in the project's specification), a bare `model {}`, an
26
+ * empty file, a file that is only `views { }`. The conservative direction is
27
+ * therefore towards `extending` — which is why an unreadable file is NOT
28
+ * classified here at all (see `FleetContext.serviceModel`): absence is a
29
+ * question for the reader that establishes the file exists, not for a scanner
30
+ * looking at bytes it does not have.
31
+ */
32
+ import { readFile } from "node:fs/promises";
33
+ import { resolve } from "node:path";
34
+ import { inOrder } from "../../kernel/concurrency.js";
35
+ import { maskSource, matchBrace } from "../source-mask.js";
36
+ /**
37
+ * A kind declaration inside a specification block — `element service`,
38
+ * `element softwareSystem`. Deliberately only `element`: `tag`,
39
+ * `relationship`, `deploymentNode` and `color` declare no ELEMENT kind, and a
40
+ * model whose specification holds only those still takes every element kind it
41
+ * uses from the map, which is exactly what makes it extending.
42
+ */
43
+ const ELEMENT_KIND = /\belement\s+[A-Za-z_][\w-]*/;
44
+ /**
45
+ * The shape of a model, from its source text.
46
+ *
47
+ * The scan runs over MASKED source (`../source-mask.ts`), so a comment or a
48
+ * string containing `specification { element x }` counts for nothing — and that
49
+ * is not a hypothetical: the brief loam writes for an adopted service explains
50
+ * the other shape in a comment, so an authored model can easily carry those
51
+ * exact words in prose. Reading them as a declaration would flip a whole
52
+ * service to the wrong loader.
53
+ *
54
+ * Only TOP-LEVEL blocks are considered — depth zero in the masked source — for
55
+ * the same reason: `specification` nested inside another block is not a
56
+ * declaration LikeC4 would honour, so loam must not honour it either.
57
+ *
58
+ * An unbalanced block (no closing brace) is read to end of file and still
59
+ * answers `standalone` when it declares a kind. That file does not parse at
60
+ * all, and both loaders will say so; answering `standalone` sends the reader to
61
+ * the one whose errors name only this file, which is the honest half of a
62
+ * broken document.
63
+ */
64
+ export function modelShape(text) {
65
+ const { code } = maskSource(text);
66
+ // Built per call rather than hoisted to module scope: a `/g` regex carries
67
+ // `lastIndex`, which is mutable state a second caller would inherit — the
68
+ // module-level-state hazard AGENTS.md names, arriving through a constant that
69
+ // looks immutable.
70
+ const specification = /\bspecification\s*\{/g;
71
+ for (let match = specification.exec(code); match !== null; match = specification.exec(code)) {
72
+ const open = match.index + match[0].length - 1;
73
+ if (depthBefore(code, match.index) !== 0)
74
+ continue;
75
+ const close = matchBrace(code, open);
76
+ const body = close === -1 ? code.slice(open + 1) : code.slice(open + 1, close);
77
+ if (ELEMENT_KIND.test(body))
78
+ return "standalone";
79
+ }
80
+ return "extending";
81
+ }
82
+ /**
83
+ * One model's shape, from its bytes — the shape of a file that cannot be read
84
+ * is `standalone`.
85
+ *
86
+ * That is the conservative arm rather than a guess. The standalone path hands
87
+ * the model to the per-file loader, so a caller asking about a file that is not
88
+ * there (or is not readable) gets today's exact error — `c4.invalid`, naming
89
+ * that file — instead of a slice of the fleet map, which is what the extending
90
+ * path would answer about a document nobody could open.
91
+ */
92
+ export async function readModelShape(path) {
93
+ return readFile(path, "utf8").then(modelShape, () => "standalone");
94
+ }
95
+ /**
96
+ * Many models' shapes at once, keyed by RESOLVED path.
97
+ *
98
+ * The fleet needs this list before it can do anything else: which models are
99
+ * batched as projects and which as documents, which directories are owed a
100
+ * per-service `likec4.config.json` and which must not have one. Every one of
101
+ * those questions is asked over the FULL service enumeration rather than over
102
+ * whatever subset a run is grading, so the read is a bulk one through the
103
+ * shared pool — a fleet of 56 models is 56 small reads, and the pool is what
104
+ * keeps them from being 56 sequential round-trips on a network-mounted docs
105
+ * repo.
106
+ */
107
+ export async function readModelShapes(paths) {
108
+ const entries = await inOrder(paths, async (path) => [resolve(path), await readModelShape(path)]);
109
+ return new Map(entries);
110
+ }
111
+ /**
112
+ * How many blocks are open at `index` in masked code. A plain count, because
113
+ * the mask has already blanked every brace inside a string or a comment — the
114
+ * one thing that would make counting lie.
115
+ */
116
+ function depthBefore(code, index) {
117
+ let depth = 0;
118
+ for (let i = 0; i < index; i += 1) {
119
+ if (code[i] === "{")
120
+ depth += 1;
121
+ else if (code[i] === "}")
122
+ depth -= 1;
123
+ }
124
+ return depth;
125
+ }
126
+ //# sourceMappingURL=shape.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shape.js","sourceRoot":"","sources":["../../../../src/core/c4/service-model/shape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,OAAO,EAAE,MAAM,6BAA6B,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAI3D;;;;;;GAMG;AACH,MAAM,YAAY,GAAG,6BAA6B,CAAC;AAEnD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,MAAM,EAAE,IAAI,EAAE,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;IAClC,2EAA2E;IAC3E,0EAA0E;IAC1E,8EAA8E;IAC9E,mBAAmB;IACnB,MAAM,aAAa,GAAG,uBAAuB,CAAC;IAC9C,KAAK,IAAI,KAAK,GAAG,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,KAAK,KAAK,IAAI,EAAE,KAAK,GAAG,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC5F,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;QAC/C,IAAI,WAAW,CAAC,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC;YAAE,SAAS;QACnD,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACrC,MAAM,IAAI,GAAG,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC;QAC/E,IAAI,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,OAAO,YAAY,CAAC;IACnD,CAAC;IACD,OAAO,WAAW,CAAC;AACrB,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAY;IAC/C,OAAO,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,IAAI,CAAC,UAAU,EAAE,GAAe,EAAE,CAAC,YAAY,CAAC,CAAC;AACjF,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,KAAwB;IAC5D,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,MAAM,cAAc,CAAC,IAAI,CAAC,CAAU,CAAC,CAAC;IAC3G,OAAO,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC;AAC1B,CAAC;AAED;;;;GAIG;AACH,SAAS,WAAW,CAAC,IAAY,EAAE,KAAa;IAC9C,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAClC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,GAAG;YAAE,KAAK,IAAI,CAAC,CAAC;aAC3B,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,GAAG;YAAE,KAAK,IAAI,CAAC,CAAC;IACvC,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC","sourcesContent":["/**\n * Which of the two shapes a `model.likec4` has, decided by a byte scan of the\n * file itself.\n *\n * A STANDALONE model declares at least one `element <kind>` inside a top-level\n * `specification { }` block. It is parsed ALONE, exactly as every model was\n * before this axis: it may re-declare the partners it talks to, and it renders\n * only as a project of its own. An EXTENDING model declares no element kind —\n * it takes the map's — and says what is inside the service with\n * `extend <fqn> { … }`; it is parsed INSIDE the fleet's `architecture/`\n * project, beside the map that declares those kinds.\n *\n * THE FILE'S OWN GRAMMAR DECIDES, never a config key, and that is the whole\n * design of this module. A flag in `loam.json` would be a second authority on a\n * fact the document already states, free to disagree with it — and the\n * disagreement is not a warning but a parse: a model read the wrong way is\n * either eleven `c4.invalid` errors (an extending model parsed alone: measured,\n * deleting a model's `specification` under the old loader) or a pile of\n * duplicate-declaration errors blamed on BOTH files (a standalone model staged\n * beside the landscape). Nothing about a model has to be migrated for loam to\n * read it correctly; the bytes say which it is.\n *\n * Every other answer is `extending`, and each of those is legal on purpose: a\n * tags-only `specification { tag req-X }` (measured legal inside one project,\n * and it lands the tag in the project's specification), a bare `model {}`, an\n * empty file, a file that is only `views { }`. The conservative direction is\n * therefore towards `extending` — which is why an unreadable file is NOT\n * classified here at all (see `FleetContext.serviceModel`): absence is a\n * question for the reader that establishes the file exists, not for a scanner\n * looking at bytes it does not have.\n */\nimport { readFile } from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport { inOrder } from \"../../kernel/concurrency.js\";\nimport { maskSource, matchBrace } from \"../source-mask.js\";\n\nexport type ModelShape = \"standalone\" | \"extending\";\n\n/**\n * A kind declaration inside a specification block — `element service`,\n * `element softwareSystem`. Deliberately only `element`: `tag`,\n * `relationship`, `deploymentNode` and `color` declare no ELEMENT kind, and a\n * model whose specification holds only those still takes every element kind it\n * uses from the map, which is exactly what makes it extending.\n */\nconst ELEMENT_KIND = /\\belement\\s+[A-Za-z_][\\w-]*/;\n\n/**\n * The shape of a model, from its source text.\n *\n * The scan runs over MASKED source (`../source-mask.ts`), so a comment or a\n * string containing `specification { element x }` counts for nothing — and that\n * is not a hypothetical: the brief loam writes for an adopted service explains\n * the other shape in a comment, so an authored model can easily carry those\n * exact words in prose. Reading them as a declaration would flip a whole\n * service to the wrong loader.\n *\n * Only TOP-LEVEL blocks are considered — depth zero in the masked source — for\n * the same reason: `specification` nested inside another block is not a\n * declaration LikeC4 would honour, so loam must not honour it either.\n *\n * An unbalanced block (no closing brace) is read to end of file and still\n * answers `standalone` when it declares a kind. That file does not parse at\n * all, and both loaders will say so; answering `standalone` sends the reader to\n * the one whose errors name only this file, which is the honest half of a\n * broken document.\n */\nexport function modelShape(text: string): ModelShape {\n const { code } = maskSource(text);\n // Built per call rather than hoisted to module scope: a `/g` regex carries\n // `lastIndex`, which is mutable state a second caller would inherit — the\n // module-level-state hazard AGENTS.md names, arriving through a constant that\n // looks immutable.\n const specification = /\\bspecification\\s*\\{/g;\n for (let match = specification.exec(code); match !== null; match = specification.exec(code)) {\n const open = match.index + match[0].length - 1;\n if (depthBefore(code, match.index) !== 0) continue;\n const close = matchBrace(code, open);\n const body = close === -1 ? code.slice(open + 1) : code.slice(open + 1, close);\n if (ELEMENT_KIND.test(body)) return \"standalone\";\n }\n return \"extending\";\n}\n\n/**\n * One model's shape, from its bytes — the shape of a file that cannot be read\n * is `standalone`.\n *\n * That is the conservative arm rather than a guess. The standalone path hands\n * the model to the per-file loader, so a caller asking about a file that is not\n * there (or is not readable) gets today's exact error — `c4.invalid`, naming\n * that file — instead of a slice of the fleet map, which is what the extending\n * path would answer about a document nobody could open.\n */\nexport async function readModelShape(path: string): Promise<ModelShape> {\n return readFile(path, \"utf8\").then(modelShape, (): ModelShape => \"standalone\");\n}\n\n/**\n * Many models' shapes at once, keyed by RESOLVED path.\n *\n * The fleet needs this list before it can do anything else: which models are\n * batched as projects and which as documents, which directories are owed a\n * per-service `likec4.config.json` and which must not have one. Every one of\n * those questions is asked over the FULL service enumeration rather than over\n * whatever subset a run is grading, so the read is a bulk one through the\n * shared pool — a fleet of 56 models is 56 small reads, and the pool is what\n * keeps them from being 56 sequential round-trips on a network-mounted docs\n * repo.\n */\nexport async function readModelShapes(paths: readonly string[]): Promise<Map<string, ModelShape>> {\n const entries = await inOrder(paths, async (path) => [resolve(path), await readModelShape(path)] as const);\n return new Map(entries);\n}\n\n/**\n * How many blocks are open at `index` in masked code. A plain count, because\n * the mask has already blanked every brace inside a string or a comment — the\n * one thing that would make counting lie.\n */\nfunction depthBefore(code: string, index: number): number {\n let depth = 0;\n for (let i = 0; i < index; i += 1) {\n if (code[i] === \"{\") depth += 1;\n else if (code[i] === \"}\") depth -= 1;\n }\n return depth;\n}\n"]}