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

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 (273) hide show
  1. package/CHANGELOG.md +901 -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 +264 -11
  6. package/SCHEMA.md +439 -38
  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 +5 -2
  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/plan.js +97 -3
  25. package/dist/commands/seed/plan.js.map +1 -1
  26. package/dist/commands/seed/seed.js +7 -2
  27. package/dist/commands/seed/seed.js.map +1 -1
  28. package/dist/commands/show/feature.js +2 -1
  29. package/dist/commands/show/feature.js.map +1 -1
  30. package/dist/commands/show/marks.js +3 -5
  31. package/dist/commands/show/marks.js.map +1 -1
  32. package/dist/commands/show/service.js +39 -18
  33. package/dist/commands/show/service.js.map +1 -1
  34. package/dist/commands/subsystem/sync.js +128 -74
  35. package/dist/commands/subsystem/sync.js.map +1 -1
  36. package/dist/commands/subsystem/txn/exclude.js +132 -0
  37. package/dist/commands/subsystem/txn/exclude.js.map +1 -0
  38. package/dist/commands/subsystem/txn/report.js +124 -0
  39. package/dist/commands/subsystem/txn/report.js.map +1 -0
  40. package/dist/commands/subsystem/txn/txn.js +10 -3
  41. package/dist/commands/subsystem/txn/txn.js.map +1 -1
  42. package/dist/commands/subsystem/txn/views.js +113 -16
  43. package/dist/commands/subsystem/txn/views.js.map +1 -1
  44. package/dist/commands/validate/arch-coverage.js +16 -9
  45. package/dist/commands/validate/arch-coverage.js.map +1 -1
  46. package/dist/commands/validate/checks/fleet-shape.js +30 -77
  47. package/dist/commands/validate/checks/fleet-shape.js.map +1 -1
  48. package/dist/commands/validate/checks/vocabulary.js +4 -19
  49. package/dist/commands/validate/checks/vocabulary.js.map +1 -1
  50. package/dist/commands/validate/feature.js +1 -1
  51. package/dist/commands/validate/feature.js.map +1 -1
  52. package/dist/commands/validate/fleet/census.js +11 -10
  53. package/dist/commands/validate/fleet/census.js.map +1 -1
  54. package/dist/commands/validate/fleet/landscape.js +127 -119
  55. package/dist/commands/validate/fleet/landscape.js.map +1 -1
  56. package/dist/commands/validate/fleet/load.js +66 -0
  57. package/dist/commands/validate/fleet/load.js.map +1 -1
  58. package/dist/commands/validate/fleet/map/attest.js +103 -0
  59. package/dist/commands/validate/fleet/map/attest.js.map +1 -0
  60. package/dist/commands/validate/fleet/map/bindings.js +96 -0
  61. package/dist/commands/validate/fleet/map/bindings.js.map +1 -0
  62. package/dist/commands/validate/fleet/map/consumers.js +298 -0
  63. package/dist/commands/validate/fleet/map/consumers.js.map +1 -0
  64. package/dist/commands/validate/fleet/map/isolation.js +116 -0
  65. package/dist/commands/validate/fleet/map/isolation.js.map +1 -0
  66. package/dist/commands/validate/fleet/map/usecases.js +56 -0
  67. package/dist/commands/validate/fleet/map/usecases.js.map +1 -0
  68. package/dist/commands/validate/fleet/usecases/steps.js +49 -17
  69. package/dist/commands/validate/fleet/usecases/steps.js.map +1 -1
  70. package/dist/commands/validate/fleet/views/fleet-project.js +217 -0
  71. package/dist/commands/validate/fleet/views/fleet-project.js.map +1 -0
  72. package/dist/commands/validate/fleet/views/ids.js +13 -9
  73. package/dist/commands/validate/fleet/views/ids.js.map +1 -1
  74. package/dist/commands/validate/fleet/views/projects.js +258 -70
  75. package/dist/commands/validate/fleet/views/projects.js.map +1 -1
  76. package/dist/commands/validate/fleet/views/root-documents.js +97 -0
  77. package/dist/commands/validate/fleet/views/root-documents.js.map +1 -0
  78. package/dist/commands/validate/fleet/views/stale.js +2 -2
  79. package/dist/commands/validate/fleet/views/stale.js.map +1 -1
  80. package/dist/commands/validate/service/model/diverged.js +226 -0
  81. package/dist/commands/validate/service/model/diverged.js.map +1 -0
  82. package/dist/commands/validate/service/model/grade.js +163 -0
  83. package/dist/commands/validate/service/model/grade.js.map +1 -0
  84. package/dist/commands/validate/service/service.js +110 -34
  85. package/dist/commands/validate/service/service.js.map +1 -1
  86. package/dist/commands/validate/service/specs.js +39 -5
  87. package/dist/commands/validate/service/specs.js.map +1 -1
  88. package/dist/commands/validate/service/spine.js +41 -3
  89. package/dist/commands/validate/service/spine.js.map +1 -1
  90. package/dist/commands/validate/service/usecases/tags.js +113 -0
  91. package/dist/commands/validate/service/usecases/tags.js.map +1 -0
  92. package/dist/commands/validate/service/usecases/usecases.js +209 -0
  93. package/dist/commands/validate/service/usecases/usecases.js.map +1 -0
  94. package/dist/commands/validate/validate.js +10 -24
  95. package/dist/commands/validate/validate.js.map +1 -1
  96. package/dist/core/agent/agents-md/artifacts.js +1 -0
  97. package/dist/core/agent/agents-md/artifacts.js.map +1 -1
  98. package/dist/core/agent/agents-md/command-map.js +18 -4
  99. package/dist/core/agent/agents-md/command-map.js.map +1 -1
  100. package/dist/core/agent/agents-md/cycle.js +24 -9
  101. package/dist/core/agent/agents-md/cycle.js.map +1 -1
  102. package/dist/core/agent/agents-md/map/lenses/context.js +4 -3
  103. package/dist/core/agent/agents-md/map/lenses/context.js.map +1 -1
  104. package/dist/core/agent/agents-md/map/subsystem.js +51 -11
  105. package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
  106. package/dist/core/agent/agents-md/map/usecases.js +45 -3
  107. package/dist/core/agent/agents-md/map/usecases.js.map +1 -1
  108. package/dist/core/agent/agents-md.js +14 -4
  109. package/dist/core/agent/agents-md.js.map +1 -1
  110. package/dist/core/agent/support/report.js +39 -5
  111. package/dist/core/agent/support/report.js.map +1 -1
  112. package/dist/core/agent/workflows/adopt.js +123 -14
  113. package/dist/core/agent/workflows/adopt.js.map +1 -1
  114. package/dist/core/agent/workflows/check.js +35 -20
  115. package/dist/core/agent/workflows/check.js.map +1 -1
  116. package/dist/core/agent/workflows/closing.js +3 -1
  117. package/dist/core/agent/workflows/closing.js.map +1 -1
  118. package/dist/core/brief/brief.js +63 -11
  119. package/dist/core/brief/brief.js.map +1 -1
  120. package/dist/core/brief/checks.js +133 -7
  121. package/dist/core/brief/checks.js.map +1 -1
  122. package/dist/core/brief/landscape.js +212 -129
  123. package/dist/core/brief/landscape.js.map +1 -1
  124. package/dist/core/brief/map/extend.js +81 -0
  125. package/dist/core/brief/map/extend.js.map +1 -0
  126. package/dist/core/brief/map/owed.js +284 -0
  127. package/dist/core/brief/map/owed.js.map +1 -0
  128. package/dist/core/brief/targets.js +30 -29
  129. package/dist/core/brief/targets.js.map +1 -1
  130. package/dist/core/brief/unchecked.js +12 -2
  131. package/dist/core/brief/unchecked.js.map +1 -1
  132. package/dist/core/c4/likec4.js +23 -1
  133. package/dist/core/c4/likec4.js.map +1 -1
  134. package/dist/core/c4/parsed/deployment.js +5 -3
  135. package/dist/core/c4/parsed/deployment.js.map +1 -1
  136. package/dist/core/c4/parsed/view-ids.js +39 -1
  137. package/dist/core/c4/parsed/view-ids.js.map +1 -1
  138. package/dist/core/c4/prefetch/fleet.js +142 -0
  139. package/dist/core/c4/prefetch/fleet.js.map +1 -0
  140. package/dist/core/c4/project/architecture.js +52 -4
  141. package/dist/core/c4/project/architecture.js.map +1 -1
  142. package/dist/core/c4/project/batch.js +140 -0
  143. package/dist/core/c4/project/batch.js.map +1 -0
  144. package/dist/core/c4/project/documents.js +9 -4
  145. package/dist/core/c4/project/documents.js.map +1 -1
  146. package/dist/core/c4/project/load.js +77 -38
  147. package/dist/core/c4/project/load.js.map +1 -1
  148. package/dist/core/c4/project/staged.js +46 -12
  149. package/dist/core/c4/project/staged.js.map +1 -1
  150. package/dist/core/c4/resolve/attested.js +0 -0
  151. package/dist/core/c4/resolve/attested.js.map +1 -0
  152. package/dist/core/c4/resolve/service.js +8 -17
  153. package/dist/core/c4/resolve/service.js.map +1 -1
  154. package/dist/core/c4/resolve/steps.js +18 -0
  155. package/dist/core/c4/resolve/steps.js.map +1 -1
  156. package/dist/core/c4/root-project/exclude.js +352 -0
  157. package/dist/core/c4/root-project/exclude.js.map +1 -0
  158. package/dist/core/c4/service-model/documents.js +47 -0
  159. package/dist/core/c4/service-model/documents.js.map +1 -0
  160. package/dist/core/c4/service-model/fleet/extending.js +82 -0
  161. package/dist/core/c4/service-model/fleet/extending.js.map +1 -0
  162. package/dist/core/c4/service-model/load.js +165 -0
  163. package/dist/core/c4/service-model/load.js.map +1 -0
  164. package/dist/core/c4/service-model/renderer.js +128 -0
  165. package/dist/core/c4/service-model/renderer.js.map +1 -0
  166. package/dist/core/c4/service-model/shape.js +126 -0
  167. package/dist/core/c4/service-model/shape.js.map +1 -0
  168. package/dist/core/c4/service-model/slice.js +188 -0
  169. package/dist/core/c4/service-model/slice.js.map +1 -0
  170. package/dist/core/c4/source-scan.js +26 -0
  171. package/dist/core/c4/source-scan.js.map +1 -1
  172. package/dist/core/c4/splice/authored-source.js +60 -13
  173. package/dist/core/c4/splice/authored-source.js.map +1 -1
  174. package/dist/core/c4/splice/contract.js.map +1 -1
  175. package/dist/core/c4/splice/identity/declared.js +87 -0
  176. package/dist/core/c4/splice/identity/declared.js.map +1 -0
  177. package/dist/core/c4/splice/identity/edges.js +17 -0
  178. package/dist/core/c4/splice/identity/edges.js.map +1 -1
  179. package/dist/core/c4/splice/identity/existing.js +80 -0
  180. package/dist/core/c4/splice/identity/existing.js.map +1 -0
  181. package/dist/core/c4/splice/identity/interior.js +80 -0
  182. package/dist/core/c4/splice/identity/interior.js.map +1 -0
  183. package/dist/core/c4/splice/identity/owner.js +50 -0
  184. package/dist/core/c4/splice/identity/owner.js.map +1 -0
  185. package/dist/core/c4/splice/landscape-merge.js +154 -100
  186. package/dist/core/c4/splice/landscape-merge.js.map +1 -1
  187. package/dist/core/c4/splice/model/merge.js +140 -0
  188. package/dist/core/c4/splice/model/merge.js.map +1 -0
  189. package/dist/core/c4/splice/placement.js +14 -4
  190. package/dist/core/c4/splice/placement.js.map +1 -1
  191. package/dist/core/c4/workspace.js +2 -1
  192. package/dist/core/c4/workspace.js.map +1 -1
  193. package/dist/core/coherence/coherence.js +7 -0
  194. package/dist/core/coherence/coherence.js.map +1 -1
  195. package/dist/core/delta/delta.js +9 -0
  196. package/dist/core/delta/delta.js.map +1 -1
  197. package/dist/core/deployment/delta.js +4 -0
  198. package/dist/core/deployment/delta.js.map +1 -1
  199. package/dist/core/diff/base-state.js +21 -5
  200. package/dist/core/diff/base-state.js.map +1 -1
  201. package/dist/core/diff/victims.js +10 -1
  202. package/dist/core/diff/victims.js.map +1 -1
  203. package/dist/core/docs.js +29 -24
  204. package/dist/core/docs.js.map +1 -1
  205. package/dist/core/doctor/config.js +7 -3
  206. package/dist/core/doctor/config.js.map +1 -1
  207. package/dist/core/doctor/doctor.js +36 -8
  208. package/dist/core/doctor/doctor.js.map +1 -1
  209. package/dist/core/doctor/report.js.map +1 -1
  210. package/dist/core/doctor/reports/scan.js +242 -0
  211. package/dist/core/doctor/reports/scan.js.map +1 -0
  212. package/dist/core/explain/refusals.js +1 -1
  213. package/dist/core/explain/refusals.js.map +1 -1
  214. package/dist/core/explore/explore.js +44 -9
  215. package/dist/core/explore/explore.js.map +1 -1
  216. package/dist/core/fleet-context.js +47 -0
  217. package/dist/core/fleet-context.js.map +1 -1
  218. package/dist/core/gate/partners.js +5 -9
  219. package/dist/core/gate/partners.js.map +1 -1
  220. package/dist/core/kernel/ids/fqn/ancestors.js +65 -0
  221. package/dist/core/kernel/ids/fqn/ancestors.js.map +1 -0
  222. package/dist/core/pack/living.js +47 -13
  223. package/dist/core/pack/living.js.map +1 -1
  224. package/dist/core/pack/pack.js +9 -6
  225. package/dist/core/pack/pack.js.map +1 -1
  226. package/dist/core/projection/arch-slice.js +2 -2
  227. package/dist/core/projection/arch-slice.js.map +1 -1
  228. package/dist/core/repo/paths.js +11 -9
  229. package/dist/core/repo/paths.js.map +1 -1
  230. package/dist/core/repo/tree/render/projects.js +93 -35
  231. package/dist/core/repo/tree/render/projects.js.map +1 -1
  232. package/dist/core/repo/tree/render/stale.js +2 -3
  233. package/dist/core/repo/tree/render/stale.js.map +1 -1
  234. package/dist/core/repo/tree/render/views.js +51 -3
  235. package/dist/core/repo/tree/render/views.js.map +1 -1
  236. package/dist/core/repo/tree/walk.js +45 -2
  237. package/dist/core/repo/tree/walk.js.map +1 -1
  238. package/dist/core/scaffold/landscape.js +219 -9
  239. package/dist/core/scaffold/landscape.js.map +1 -1
  240. package/dist/core/staging/writes.js.map +1 -1
  241. package/dist/core/usecases/delta/claims.js +1 -0
  242. package/dist/core/usecases/delta/claims.js.map +1 -1
  243. package/dist/core/usecases/delta/overlay.js +4 -0
  244. package/dist/core/usecases/delta/overlay.js.map +1 -1
  245. package/dist/core/usecases/fleet.js +18 -6
  246. package/dist/core/usecases/fleet.js.map +1 -1
  247. package/dist/core/usecases/operations.js +7 -0
  248. package/dist/core/usecases/operations.js.map +1 -1
  249. package/dist/core/usecases/service/exclude.js +119 -0
  250. package/dist/core/usecases/service/exclude.js.map +1 -0
  251. package/dist/core/usecases/service/flows.js +161 -0
  252. package/dist/core/usecases/service/flows.js.map +1 -0
  253. package/dist/core/vocabulary/maturity.js +10 -0
  254. package/dist/core/vocabulary/maturity.js.map +1 -1
  255. package/examples/README.md +24 -6
  256. package/examples/docs/architecture/landscape.likec4 +41 -0
  257. package/examples/docs/architecture/usecases/order-notification.likec4 +5 -2
  258. package/examples/docs/likec4.config.json +0 -1
  259. package/examples/docs/services/checkout-web/model.likec4 +39 -56
  260. package/examples/docs/services/order-service/arch.spec.md +13 -6
  261. package/examples/docs/services/order-service/model.likec4 +53 -67
  262. package/examples/docs/services/payment-service/arch.spec.md +7 -4
  263. package/examples/docs/services/payment-service/model.likec4 +32 -35
  264. package/examples/docs/services/platform/identity-service/arch.spec.md +7 -4
  265. package/examples/docs/services/platform/identity-service/model.likec4 +29 -33
  266. package/examples/docs/services/platform/notification-service/health.yaml +8 -3
  267. package/examples/docs/services/platform/notification-service/model.likec4 +39 -59
  268. package/package.json +1 -1
  269. package/examples/docs/services/checkout-web/likec4.config.json +0 -4
  270. package/examples/docs/services/order-service/likec4.config.json +0 -4
  271. package/examples/docs/services/payment-service/likec4.config.json +0 -4
  272. package/examples/docs/services/platform/identity-service/likec4.config.json +0 -4
  273. package/examples/docs/services/platform/notification-service/likec4.config.json +0 -4
@@ -5,9 +5,18 @@
5
5
  * The attribution itself is `core/c4/resolve/steps.ts`'s `attributeStep` — which declared
6
6
  * relationship backs a hop, oriented on `isBackward` and decided by the
7
7
  * DISTINCT-op count — and none of that reasoning is repeated here. This module
8
- * owns three things: which verdict earns which code, the three guards that keep
8
+ * owns three things: which verdict earns which code, the four guards that keep
9
9
  * the warn honest, and the sentences.
10
10
  *
11
+ * TWO SCOPES, ONE GRADER. The fleet target grades the `architecture/` project's
12
+ * views and the service target grades a service's own project — every `.likec4`
13
+ * beside `model.likec4`, read the way the renderer reads it
14
+ * (`commands/validate/service/usecases/`). Both call `stepFindings` below with
15
+ * their own `StepGrading`; what differs is only where a finding says the view
16
+ * is (`place`). A second copy of the verdict→code mapping per scope is the
17
+ * drift this package's banner forbids, which is why the intra-service case is
18
+ * a fourth guard here rather than a grader of its own.
19
+ *
11
20
  * WHY `step-unbacked` IS THE CHECK THAT EARNS THE FEATURE. A hop between two
12
21
  * elements that both exist, with no relationship between them anywhere in the
13
22
  * model, is ZERO LikeC4 errors: a `dynamic view` is allowed to show a call the
@@ -28,9 +37,16 @@ import { attributeStep } from "../../../../core/c4/resolve/steps.js";
28
37
  import { ACTOR_KINDS } from "../../../../core/vocabulary/maturity.js";
29
38
  import { stepPlace } from "../../../../core/usecases/place.js";
30
39
  import { viewPlace } from "./place.js";
31
- /** `…checkout.likec4 — dynamic view 'uc_checkout' step 2 'authorizes the payment'`. */
32
- function placeOf(hop) {
33
- return `${viewPlace(hop.view)} ${stepPlace(hop.step)}`;
40
+ /**
41
+ * `…checkout.likec4 — dynamic view 'uc_checkout' step 2 'authorizes the payment'`.
42
+ *
43
+ * Spelled once per hop in `stepFindings` and handed to each sentence builder as
44
+ * a string, rather than recomputed from the grading inside each of them: three
45
+ * builders reaching for `grading.place ?? viewPlace` on their own would be three
46
+ * places for one scope to forget the injection.
47
+ */
48
+ function placeOf(hop, grading) {
49
+ return `${(grading.place ?? viewPlace)(hop.view)} ${stepPlace(hop.step)}`;
34
50
  }
35
51
  /**
36
52
  * A hop nothing in the model backs.
@@ -44,7 +60,7 @@ function placeOf(hop) {
44
60
  * a reply to the CALL it answers rather than to the direction the message
45
61
  * travels (`callPair` in core/c4/resolve/steps.ts holds the measurement).
46
62
  */
47
- function unbackedFinding(hop, attribution) {
63
+ function unbackedFinding(hop, attribution, place) {
48
64
  const other = hop.step.isBackward
49
65
  ? { spelling: `${hop.step.source} -> ${hop.step.target}`, when: "is not a return step" }
50
66
  : { spelling: `${hop.step.target} <- ${hop.step.source}`, when: "is a return step" };
@@ -52,7 +68,7 @@ function unbackedFinding(hop, attribution) {
52
68
  severity: "error",
53
69
  code: "usecase.step-unbacked",
54
70
  subject: hop.view.id,
55
- message: `${placeOf(hop)}: nothing in the model declares ${attribution.from} -> ${attribution.to}, so this hop is ` +
71
+ message: `${place}: nothing in the model declares ${attribution.from} -> ${attribution.to}, so this hop is ` +
56
72
  "backed by no relationship — and LikeC4 reports no error for it, so the view still renders and the " +
57
73
  "project still parses. Draw the edge in `model { }` (with `metadata { op '<operationId>' }` where it is " +
58
74
  `a call), or, if this hop ${other.when}, write it as \`${other.spelling}\` — a reply is attributed to the ` +
@@ -79,12 +95,12 @@ function candidateLine(rel) {
79
95
  * that is. An absent `metadata { op }` prints as `no op` and is a candidate like
80
96
  * any other — it is exactly as much of a disagreement as a second name.
81
97
  */
82
- function contestedFinding(hop, attribution) {
98
+ function contestedFinding(hop, attribution, place) {
83
99
  return {
84
100
  severity: "warn",
85
101
  code: "usecase.step-contested",
86
102
  subject: hop.view.id,
87
- message: `${placeOf(hop)}: ${attribution.rels.length} relationships back ${attribution.from} -> ` +
103
+ message: `${place}: ${attribution.rels.length} relationships back ${attribution.from} -> ` +
88
104
  `${attribution.to} and they name ${attribution.ops.length} different operations, so nothing can say ` +
89
105
  "which one this hop exercises. loam names the candidates instead of picking one — a guessed operation " +
90
106
  "reads exactly like a right one in every rollup built over this flow. Give the candidates one operation, " +
@@ -94,12 +110,13 @@ function contestedFinding(hop, attribution) {
94
110
  }
95
111
  /**
96
112
  * The service this hop calls, when the hop is a `step-unlinked` candidate at
97
- * all — `null` when any of the three guards refuses. ALL THREE are mechanical,
113
+ * all — `null` when any of the four guards refuses. ALL FOUR are mechanical,
98
114
  * and each closes a whole class of warnings that would otherwise be simply
99
115
  * wrong. The count is stated because a reader auditing this function is meant to
100
- * find three bullets and three `return null`s below and conclude that none of
101
- * them is surplus: the one a miscount invites deleting is the last, and deleting
102
- * it puts the warning back on the first hop of almost every flow.
116
+ * find four bullets and four `return null`s below and conclude that none of
117
+ * them is surplus: the one a miscount invites deleting is the actor guard, and
118
+ * deleting it puts the warning back on the first hop of almost every flow;
119
+ * deleting the last puts it on EVERY hop of every flow drawn inside a service.
103
120
  *
104
121
  * - A `publishes` or `consumes` on ANY candidate means the hop does reach a
105
122
  * declared API — an AsyncAPI one. The event spine already grades it
@@ -123,6 +140,18 @@ function contestedFinding(hop, attribution) {
123
140
  * `arch-coverage.ts`, `core/gate/partners.ts` and `core/verify/checklist.ts`
124
141
  * each already apply, through the same `ACTOR_KINDS` — a person was never a
125
142
  * participant in any of loam's other censuses either.
143
+ * - The CALLER must not resolve to the provider itself. A hop between two
144
+ * containers of one service — `api -> db` inside a service's own project,
145
+ * or the same pair on a landscape that draws the service as containers —
146
+ * names no operation of that service's contract: an operationId is what a
147
+ * caller OUTSIDE the boundary uses, and a service owes none to itself. The
148
+ * three guards above all pass such a hop (the edge carries no message, the
149
+ * target IS a real `services/<id>/`, the caller is a container), so without
150
+ * this one the warning fired on every hop of every intra-service flow the
151
+ * moment loam started reading them — and the message ("names no operation
152
+ * of X's contract") was about X calling X. Through the same shared
153
+ * resolver as the provider, so the two sides of the comparison cannot
154
+ * disagree about which directory an element stands for.
126
155
  *
127
156
  * The op test reads the ATTRIBUTION rather than the relationships: an
128
157
  * `attributed` verdict with no `op` means every candidate agreed the hop names
@@ -138,6 +167,8 @@ function unlinkedProvider(attribution, grading) {
138
167
  if (isActor(attribution.from, grading.model.elements))
139
168
  return null;
140
169
  const provider = grading.resolve(attribution.to);
170
+ if (grading.resolve(attribution.from) === provider)
171
+ return null;
141
172
  return grading.services.has(provider) ? provider : null;
142
173
  }
143
174
  /**
@@ -161,13 +192,13 @@ function isActor(id, elements) {
161
192
  * provider's contract the step exercises — and the whole point of grading a use
162
193
  * case is that the flow joins through to the API it drives.
163
194
  */
164
- function unlinkedFinding(hop, attribution, provider) {
195
+ function unlinkedFinding(hop, attribution, provider, place) {
165
196
  const backing = attribution.rels.length === 1 ? "one relationship" : `${attribution.rels.length} relationships`;
166
197
  return {
167
198
  severity: "warn",
168
199
  code: "usecase.step-unlinked",
169
200
  subject: hop.view.id,
170
- message: `${placeOf(hop)}: ${attribution.from} -> ${attribution.to} is backed by ${backing} carrying no ` +
201
+ message: `${place}: ${attribution.from} -> ${attribution.to} is backed by ${backing} carrying no ` +
171
202
  "`metadata { op }` and no `publishes`/`consumes`, so this hop names no operation of " +
172
203
  `${provider}'s contract — the use case reaches the fleet map and stops there. Add ` +
173
204
  "`metadata { op '<operationId>' }` to the edge in `model { }`, or `metadata { publishes '<message>' }` / " +
@@ -183,14 +214,15 @@ function unlinkedFinding(hop, attribution, provider) {
183
214
  */
184
215
  export function stepFindings(hop, grading) {
185
216
  const attribution = attributeStep(hop.step, grading.model);
217
+ const place = placeOf(hop, grading);
186
218
  switch (attribution.verdict) {
187
219
  case "unbacked":
188
- return [unbackedFinding(hop, attribution)];
220
+ return [unbackedFinding(hop, attribution, place)];
189
221
  case "contested":
190
- return [contestedFinding(hop, attribution)];
222
+ return [contestedFinding(hop, attribution, place)];
191
223
  case "attributed": {
192
224
  const provider = unlinkedProvider(attribution, grading);
193
- return provider === null ? [] : [unlinkedFinding(hop, attribution, provider)];
225
+ return provider === null ? [] : [unlinkedFinding(hop, attribution, provider, place)];
194
226
  }
195
227
  }
196
228
  }
@@ -1 +1 @@
1
- {"version":3,"file":"steps.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/usecases/steps.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The three grades one hop of a use case can earn: `usecase.step-unbacked`,\n * `usecase.step-contested` and `usecase.step-unlinked`.\n *\n * The attribution itself is `core/c4/resolve/steps.ts`'s `attributeStep` — which declared\n * relationship backs a hop, oriented on `isBackward` and decided by the\n * DISTINCT-op count — and none of that reasoning is repeated here. This module\n * owns three things: which verdict earns which code, the three guards that keep\n * the warn honest, and the sentences.\n *\n * WHY `step-unbacked` IS THE CHECK THAT EARNS THE FEATURE. A hop between two\n * elements that both exist, with no relationship between them anywhere in the\n * model, is ZERO LikeC4 errors: a `dynamic view` is allowed to show a call the\n * model never declared, so the diagram renders, the project parses, and every\n * other check in loam stays green. The flow reads as fine in every tool the\n * fleet has. If loam does not convict it, nothing does — which is why it is an\n * error rather than an advisory.\n *\n * ONE BREACH, ONE FINDING, and here that is structural rather than remembered.\n * `contested` and `attributed` are disjoint verdicts, so deriving\n * `step-unlinked` from the `attributed` arm — never from the candidate\n * relationships — is what makes a contested step report once. A grader that\n * walked `rels` looking for a missing `op` would convict the same hop twice:\n * once for the disagreement, and once for whichever candidate happened to carry\n * no operation at all.\n */\nimport { attributeStep, type StepAttribution, type StepScope } from \"../../../../core/c4/resolve/steps.js\";\nimport type { Elem, Rel } from \"../../../../core/c4/likec4.js\";\nimport type { ParsedStep, ParsedView } from \"../../../../core/c4/parsed/dynamic-views.js\";\nimport { ACTOR_KINDS } from \"../../../../core/vocabulary/maturity.js\";\nimport type { Finding } from \"../../../../core/vocabulary/report.js\";\nimport { stepPlace } from \"../../../../core/usecases/place.js\";\nimport { viewPlace } from \"./place.js\";\n\n/** One hop being graded and the view it belongs to — never passed apart. */\nexport interface GradedStep {\n view: ParsedView;\n step: ParsedStep;\n}\n\n/**\n * What a hop is graded against, built ONCE per run.\n *\n * `services` and `model.known` are the same set by construction (`./usecases.ts`\n * builds both from one value), which is what stops \"which service is this\n * element\" being answered one way by the attribution and another by the provider\n * guard below. `resolve` is injected for the same reason `fleetShapeFindings`\n * takes it: the fleet target already holds the shared element→service resolver,\n * and a second one built here could not disagree today but would be a second\n * place to change.\n */\nexport interface StepGrading {\n model: StepScope;\n /** The `services/<id>/` directories that exist. */\n services: ReadonlySet<string>;\n /** The shared element→service resolver every other edge join uses. */\n resolve: (id: string) => string;\n}\n\ntype Unbacked = Extract<StepAttribution, { verdict: \"unbacked\" }>;\ntype Contested = Extract<StepAttribution, { verdict: \"contested\" }>;\ntype Attributed = Extract<StepAttribution, { verdict: \"attributed\" }>;\n\n/** `…checkout.likec4 — dynamic view 'uc_checkout' step 2 'authorizes the payment'`. */\nfunction placeOf(hop: GradedStep): string {\n return `${viewPlace(hop.view)} ${stepPlace(hop.step)}`;\n}\n\n/**\n * A hop nothing in the model backs.\n *\n * Two fixes, because there are genuinely two mistakes that land here and the\n * author is the only one who can say which they made: the edge really is\n * missing, or the hop is a reply written the long way round. The second is\n * offered as the OTHER spelling of what they already typed — suggesting the\n * spelling they used would be a hint that restates the defect — and the sentence\n * says which pair that spelling looks the hop up under, because `<-` attributes\n * a reply to the CALL it answers rather than to the direction the message\n * travels (`callPair` in core/c4/resolve/steps.ts holds the measurement).\n */\nfunction unbackedFinding(hop: GradedStep, attribution: Unbacked): Finding {\n const other = hop.step.isBackward\n ? { spelling: `${hop.step.source} -> ${hop.step.target}`, when: \"is not a return step\" }\n : { spelling: `${hop.step.target} <- ${hop.step.source}`, when: \"is a return step\" };\n return {\n severity: \"error\",\n code: \"usecase.step-unbacked\",\n subject: hop.view.id,\n message:\n `${placeOf(hop)}: nothing in the model declares ${attribution.from} -> ${attribution.to}, so this hop is ` +\n \"backed by no relationship — and LikeC4 reports no error for it, so the view still renders and the \" +\n \"project still parses. Draw the edge in `model { }` (with `metadata { op '<operationId>' }` where it is \" +\n `a call), or, if this hop ${other.when}, write it as \\`${other.spelling}\\` — a reply is attributed to the ` +\n `call it answers, so that spelling looks the hop up under ${attribution.to} -> ${attribution.from} instead.`,\n };\n}\n\n/** `web -> orders.api \"Calls createOrder\" (op: createOrder)`, or `(no op)` where the edge declares none. */\nfunction candidateLine(rel: Rel): string {\n const op = rel.op === undefined ? \"no op\" : `op: ${rel.op}`;\n return `${rel.source} -> ${rel.target} \"${rel.title ?? \"\"}\" (${op})`;\n}\n\n/**\n * A hop whose candidates disagree about the operation.\n *\n * WARN, not error, because the model is complete — every candidate is a\n * relationship somebody drew on purpose — and the honest answer is sometimes\n * \"two paths, one flow\". What loam refuses to do is pick: a guessed operation is\n * indistinguishable from a right one in every rollup built over the use case,\n * and it stays wrong invisibly.\n *\n * `details` carries every candidate rather than the distinct ops, because the\n * repair is made on a RELATIONSHIP: the author has to find the edge that names\n * the wrong operation, and a list of operation names does not say which edge\n * that is. An absent `metadata { op }` prints as `no op` and is a candidate like\n * any other — it is exactly as much of a disagreement as a second name.\n */\nfunction contestedFinding(hop: GradedStep, attribution: Contested): Finding {\n return {\n severity: \"warn\",\n code: \"usecase.step-contested\",\n subject: hop.view.id,\n message:\n `${placeOf(hop)}: ${attribution.rels.length} relationships back ${attribution.from} -> ` +\n `${attribution.to} and they name ${attribution.ops.length} different operations, so nothing can say ` +\n \"which one this hop exercises. loam names the candidates instead of picking one — a guessed operation \" +\n \"reads exactly like a right one in every rollup built over this flow. Give the candidates one operation, \" +\n \"or draw the hop between the elements that name exactly one.\",\n details: attribution.rels.map(candidateLine),\n };\n}\n\n/**\n * The service this hop calls, when the hop is a `step-unlinked` candidate at\n * all — `null` when any of the three guards refuses. ALL THREE are mechanical,\n * and each closes a whole class of warnings that would otherwise be simply\n * wrong. The count is stated because a reader auditing this function is meant to\n * find three bullets and three `return null`s below and conclude that none of\n * them is surplus: the one a miscount invites deleting is the last, and deleting\n * it puts the warning back on the first hop of almost every flow.\n *\n * - A `publishes` or `consumes` on ANY candidate means the hop does reach a\n * declared API — an AsyncAPI one. The event spine already grades it\n * (`spine.message-undefined` and its family), and asking for an `op` as well\n * would be loam demanding an HTTP operationId for a Kafka message. Read\n * across every candidate rather than the first, because on the service tier\n * the candidates are one call drawn as containers.\n * - The target must resolve to a real `services/<id>/`. Without it every hop\n * into an `#external` system, an actor, a database, a cache or a queue warns\n * — none of which owns an `openapi.yaml`, so none of them CAN carry the\n * operationId the message would be asking for. Through the shared resolver,\n * so \"the provider\" here and \"the provider\" in the spine check cannot\n * disagree about which element stands for which directory.\n * - The CALLER must not be an actor. A person is not a caller: `customer ->\n * checkoutWeb 'opens the basket'` is somebody using the app, and the app owes\n * no operationId for a click. The guard above does not cover this, because it\n * reads the TARGET — and the target here is one of ours, so without this the\n * warning fired on the FIRST HOP OF ALMOST EVERY FLOW, which is where a\n * sequence diagram puts its actor. Measured on the published example fleet\n * before it was added. This is the same exemption `fleet/census.ts`,\n * `arch-coverage.ts`, `core/gate/partners.ts` and `core/verify/checklist.ts`\n * each already apply, through the same `ACTOR_KINDS` — a person was never a\n * participant in any of loam's other censuses either.\n *\n * The op test reads the ATTRIBUTION rather than the relationships: an\n * `attributed` verdict with no `op` means every candidate agreed the hop names\n * no operation, which is the state this warn is about. Candidates that disagree\n * never reach here at all — that is the suppression, and it is why it cannot be\n * forgotten.\n */\nfunction unlinkedProvider(attribution: Attributed, grading: StepGrading): string | null {\n if (attribution.op !== undefined) return null;\n if (attribution.rels.some((rel) => rel.publishes !== undefined || rel.consumes !== undefined)) return null;\n if (isActor(attribution.from, grading.model.elements)) return null;\n const provider = grading.resolve(attribution.to);\n return grading.services.has(provider) ? provider : null;\n}\n\n/**\n * Is this endpoint a person rather than a system?\n *\n * Exact id, not the ancestor walk the service resolver does: an actor is a\n * top-level element in every landscape loam has seen, and a nested one would be\n * a person inside a system, which is not a shape the model has. An endpoint that\n * names no element cannot occur — a step naming one is a LikeC4 reference error,\n * so the document never reaches a check.\n */\nfunction isActor(id: string, elements: Elem[]): boolean {\n const element = elements.find((candidate) => candidate.id === id);\n return element !== undefined && ACTOR_KINDS.has(element.kind.toLowerCase());\n}\n\n/**\n * A hop backed by the model and joined to no contract.\n *\n * The mirror of `spine.op-link-missing` at flow altitude: the use case reaches\n * the fleet map and stops there, so nothing can say which operation of the\n * provider's contract the step exercises — and the whole point of grading a use\n * case is that the flow joins through to the API it drives.\n */\nfunction unlinkedFinding(hop: GradedStep, attribution: Attributed, provider: string): Finding {\n const backing =\n attribution.rels.length === 1 ? \"one relationship\" : `${attribution.rels.length} relationships`;\n return {\n severity: \"warn\",\n code: \"usecase.step-unlinked\",\n subject: hop.view.id,\n message:\n `${placeOf(hop)}: ${attribution.from} -> ${attribution.to} is backed by ${backing} carrying no ` +\n \"`metadata { op }` and no `publishes`/`consumes`, so this hop names no operation of \" +\n `${provider}'s contract — the use case reaches the fleet map and stops there. Add ` +\n \"`metadata { op '<operationId>' }` to the edge in `model { }`, or `metadata { publishes '<message>' }` / \" +\n \"`consumes` where the hop is an event.\",\n };\n}\n\n/**\n * The findings one hop earns — at most one, whatever is wrong with it.\n *\n * A `switch` over the closed verdict union with every arm returning, so a fourth\n * verdict added upstream is a compile error here rather than a hop that silently\n * stops being graded.\n */\nexport function stepFindings(hop: GradedStep, grading: StepGrading): Finding[] {\n const attribution = attributeStep(hop.step, grading.model);\n switch (attribution.verdict) {\n case \"unbacked\":\n return [unbackedFinding(hop, attribution)];\n case \"contested\":\n return [contestedFinding(hop, attribution)];\n case \"attributed\": {\n const provider = unlinkedProvider(attribution, grading);\n return provider === null ? [] : [unlinkedFinding(hop, attribution, provider)];\n }\n }\n}\n"]}
1
+ {"version":3,"file":"steps.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/usecases/steps.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The three grades one hop of a use case can earn: `usecase.step-unbacked`,\n * `usecase.step-contested` and `usecase.step-unlinked`.\n *\n * The attribution itself is `core/c4/resolve/steps.ts`'s `attributeStep` — which declared\n * relationship backs a hop, oriented on `isBackward` and decided by the\n * DISTINCT-op count — and none of that reasoning is repeated here. This module\n * owns three things: which verdict earns which code, the four guards that keep\n * the warn honest, and the sentences.\n *\n * TWO SCOPES, ONE GRADER. The fleet target grades the `architecture/` project's\n * views and the service target grades a service's own project — every `.likec4`\n * beside `model.likec4`, read the way the renderer reads it\n * (`commands/validate/service/usecases/`). Both call `stepFindings` below with\n * their own `StepGrading`; what differs is only where a finding says the view\n * is (`place`). A second copy of the verdict→code mapping per scope is the\n * drift this package's banner forbids, which is why the intra-service case is\n * a fourth guard here rather than a grader of its own.\n *\n * WHY `step-unbacked` IS THE CHECK THAT EARNS THE FEATURE. A hop between two\n * elements that both exist, with no relationship between them anywhere in the\n * model, is ZERO LikeC4 errors: a `dynamic view` is allowed to show a call the\n * model never declared, so the diagram renders, the project parses, and every\n * other check in loam stays green. The flow reads as fine in every tool the\n * fleet has. If loam does not convict it, nothing does — which is why it is an\n * error rather than an advisory.\n *\n * ONE BREACH, ONE FINDING, and here that is structural rather than remembered.\n * `contested` and `attributed` are disjoint verdicts, so deriving\n * `step-unlinked` from the `attributed` arm — never from the candidate\n * relationships — is what makes a contested step report once. A grader that\n * walked `rels` looking for a missing `op` would convict the same hop twice:\n * once for the disagreement, and once for whichever candidate happened to carry\n * no operation at all.\n */\nimport { attributeStep, type StepAttribution, type StepScope } from \"../../../../core/c4/resolve/steps.js\";\nimport type { Elem, Rel } from \"../../../../core/c4/likec4.js\";\nimport type { ParsedStep, ParsedView } from \"../../../../core/c4/parsed/dynamic-views.js\";\nimport { ACTOR_KINDS } from \"../../../../core/vocabulary/maturity.js\";\nimport type { Finding } from \"../../../../core/vocabulary/report.js\";\nimport { stepPlace } from \"../../../../core/usecases/place.js\";\nimport { viewPlace } from \"./place.js\";\n\n/** One hop being graded and the view it belongs to — never passed apart. */\nexport interface GradedStep {\n view: ParsedView;\n step: ParsedStep;\n}\n\n/**\n * What a hop is graded against, built ONCE per run.\n *\n * `services` and `model.known` are the same set by construction (`./usecases.ts`\n * builds both from one value), which is what stops \"which service is this\n * element\" being answered one way by the attribution and another by the provider\n * guard below. `resolve` is injected for the same reason `fleetShapeFindings`\n * takes it: the fleet target already holds the shared element→service resolver,\n * and a second one built here could not disagree today but would be a second\n * place to change.\n */\nexport interface StepGrading {\n model: StepScope;\n /**\n * The `services/<id>/` directories that exist. The SAME enumerated set in\n * both scopes — the service target hands over the whole fleet too, never an\n * empty set — so the documented invariant above (`services` and `model.known`\n * are one value) holds wherever this grader runs, and the intra-service\n * exemption is the fourth guard in `unlinkedProvider` rather than a scope\n * that quietly disables the provider test.\n */\n services: ReadonlySet<string>;\n /** The shared element→service resolver every other edge join uses. */\n resolve: (id: string) => string;\n /**\n * Where a finding says the view is — the opening of every message in this\n * family. Defaults to `viewPlace`, the fleet target's\n * `landscape: architecture/<file> — dynamic view '<id>'`. The service target\n * injects its own, because a view read out of a service's project carries a\n * `sourcePath` relative to the SERVICE directory, and the `landscape:`\n * opening is the fleet target's name — a finding filed on a service must not\n * claim it (`../place.ts` records why the prefix stayed command vocabulary).\n */\n place?: (view: ParsedView) => string;\n}\n\ntype Unbacked = Extract<StepAttribution, { verdict: \"unbacked\" }>;\ntype Contested = Extract<StepAttribution, { verdict: \"contested\" }>;\ntype Attributed = Extract<StepAttribution, { verdict: \"attributed\" }>;\n\n/**\n * `…checkout.likec4 — dynamic view 'uc_checkout' step 2 'authorizes the payment'`.\n *\n * Spelled once per hop in `stepFindings` and handed to each sentence builder as\n * a string, rather than recomputed from the grading inside each of them: three\n * builders reaching for `grading.place ?? viewPlace` on their own would be three\n * places for one scope to forget the injection.\n */\nfunction placeOf(hop: GradedStep, grading: StepGrading): string {\n return `${(grading.place ?? viewPlace)(hop.view)} ${stepPlace(hop.step)}`;\n}\n\n/**\n * A hop nothing in the model backs.\n *\n * Two fixes, because there are genuinely two mistakes that land here and the\n * author is the only one who can say which they made: the edge really is\n * missing, or the hop is a reply written the long way round. The second is\n * offered as the OTHER spelling of what they already typed — suggesting the\n * spelling they used would be a hint that restates the defect — and the sentence\n * says which pair that spelling looks the hop up under, because `<-` attributes\n * a reply to the CALL it answers rather than to the direction the message\n * travels (`callPair` in core/c4/resolve/steps.ts holds the measurement).\n */\nfunction unbackedFinding(hop: GradedStep, attribution: Unbacked, place: string): Finding {\n const other = hop.step.isBackward\n ? { spelling: `${hop.step.source} -> ${hop.step.target}`, when: \"is not a return step\" }\n : { spelling: `${hop.step.target} <- ${hop.step.source}`, when: \"is a return step\" };\n return {\n severity: \"error\",\n code: \"usecase.step-unbacked\",\n subject: hop.view.id,\n message:\n `${place}: nothing in the model declares ${attribution.from} -> ${attribution.to}, so this hop is ` +\n \"backed by no relationship — and LikeC4 reports no error for it, so the view still renders and the \" +\n \"project still parses. Draw the edge in `model { }` (with `metadata { op '<operationId>' }` where it is \" +\n `a call), or, if this hop ${other.when}, write it as \\`${other.spelling}\\` — a reply is attributed to the ` +\n `call it answers, so that spelling looks the hop up under ${attribution.to} -> ${attribution.from} instead.`,\n };\n}\n\n/** `web -> orders.api \"Calls createOrder\" (op: createOrder)`, or `(no op)` where the edge declares none. */\nfunction candidateLine(rel: Rel): string {\n const op = rel.op === undefined ? \"no op\" : `op: ${rel.op}`;\n return `${rel.source} -> ${rel.target} \"${rel.title ?? \"\"}\" (${op})`;\n}\n\n/**\n * A hop whose candidates disagree about the operation.\n *\n * WARN, not error, because the model is complete — every candidate is a\n * relationship somebody drew on purpose — and the honest answer is sometimes\n * \"two paths, one flow\". What loam refuses to do is pick: a guessed operation is\n * indistinguishable from a right one in every rollup built over the use case,\n * and it stays wrong invisibly.\n *\n * `details` carries every candidate rather than the distinct ops, because the\n * repair is made on a RELATIONSHIP: the author has to find the edge that names\n * the wrong operation, and a list of operation names does not say which edge\n * that is. An absent `metadata { op }` prints as `no op` and is a candidate like\n * any other — it is exactly as much of a disagreement as a second name.\n */\nfunction contestedFinding(hop: GradedStep, attribution: Contested, place: string): Finding {\n return {\n severity: \"warn\",\n code: \"usecase.step-contested\",\n subject: hop.view.id,\n message:\n `${place}: ${attribution.rels.length} relationships back ${attribution.from} -> ` +\n `${attribution.to} and they name ${attribution.ops.length} different operations, so nothing can say ` +\n \"which one this hop exercises. loam names the candidates instead of picking one — a guessed operation \" +\n \"reads exactly like a right one in every rollup built over this flow. Give the candidates one operation, \" +\n \"or draw the hop between the elements that name exactly one.\",\n details: attribution.rels.map(candidateLine),\n };\n}\n\n/**\n * The service this hop calls, when the hop is a `step-unlinked` candidate at\n * all — `null` when any of the four guards refuses. ALL FOUR are mechanical,\n * and each closes a whole class of warnings that would otherwise be simply\n * wrong. The count is stated because a reader auditing this function is meant to\n * find four bullets and four `return null`s below and conclude that none of\n * them is surplus: the one a miscount invites deleting is the actor guard, and\n * deleting it puts the warning back on the first hop of almost every flow;\n * deleting the last puts it on EVERY hop of every flow drawn inside a service.\n *\n * - A `publishes` or `consumes` on ANY candidate means the hop does reach a\n * declared API — an AsyncAPI one. The event spine already grades it\n * (`spine.message-undefined` and its family), and asking for an `op` as well\n * would be loam demanding an HTTP operationId for a Kafka message. Read\n * across every candidate rather than the first, because on the service tier\n * the candidates are one call drawn as containers.\n * - The target must resolve to a real `services/<id>/`. Without it every hop\n * into an `#external` system, an actor, a database, a cache or a queue warns\n * — none of which owns an `openapi.yaml`, so none of them CAN carry the\n * operationId the message would be asking for. Through the shared resolver,\n * so \"the provider\" here and \"the provider\" in the spine check cannot\n * disagree about which element stands for which directory.\n * - The CALLER must not be an actor. A person is not a caller: `customer ->\n * checkoutWeb 'opens the basket'` is somebody using the app, and the app owes\n * no operationId for a click. The guard above does not cover this, because it\n * reads the TARGET — and the target here is one of ours, so without this the\n * warning fired on the FIRST HOP OF ALMOST EVERY FLOW, which is where a\n * sequence diagram puts its actor. Measured on the published example fleet\n * before it was added. This is the same exemption `fleet/census.ts`,\n * `arch-coverage.ts`, `core/gate/partners.ts` and `core/verify/checklist.ts`\n * each already apply, through the same `ACTOR_KINDS` — a person was never a\n * participant in any of loam's other censuses either.\n * - The CALLER must not resolve to the provider itself. A hop between two\n * containers of one service — `api -> db` inside a service's own project,\n * or the same pair on a landscape that draws the service as containers —\n * names no operation of that service's contract: an operationId is what a\n * caller OUTSIDE the boundary uses, and a service owes none to itself. The\n * three guards above all pass such a hop (the edge carries no message, the\n * target IS a real `services/<id>/`, the caller is a container), so without\n * this one the warning fired on every hop of every intra-service flow the\n * moment loam started reading them — and the message (\"names no operation\n * of X's contract\") was about X calling X. Through the same shared\n * resolver as the provider, so the two sides of the comparison cannot\n * disagree about which directory an element stands for.\n *\n * The op test reads the ATTRIBUTION rather than the relationships: an\n * `attributed` verdict with no `op` means every candidate agreed the hop names\n * no operation, which is the state this warn is about. Candidates that disagree\n * never reach here at all — that is the suppression, and it is why it cannot be\n * forgotten.\n */\nfunction unlinkedProvider(attribution: Attributed, grading: StepGrading): string | null {\n if (attribution.op !== undefined) return null;\n if (attribution.rels.some((rel) => rel.publishes !== undefined || rel.consumes !== undefined)) return null;\n if (isActor(attribution.from, grading.model.elements)) return null;\n const provider = grading.resolve(attribution.to);\n if (grading.resolve(attribution.from) === provider) return null;\n return grading.services.has(provider) ? provider : null;\n}\n\n/**\n * Is this endpoint a person rather than a system?\n *\n * Exact id, not the ancestor walk the service resolver does: an actor is a\n * top-level element in every landscape loam has seen, and a nested one would be\n * a person inside a system, which is not a shape the model has. An endpoint that\n * names no element cannot occur — a step naming one is a LikeC4 reference error,\n * so the document never reaches a check.\n */\nfunction isActor(id: string, elements: Elem[]): boolean {\n const element = elements.find((candidate) => candidate.id === id);\n return element !== undefined && ACTOR_KINDS.has(element.kind.toLowerCase());\n}\n\n/**\n * A hop backed by the model and joined to no contract.\n *\n * The mirror of `spine.op-link-missing` at flow altitude: the use case reaches\n * the fleet map and stops there, so nothing can say which operation of the\n * provider's contract the step exercises — and the whole point of grading a use\n * case is that the flow joins through to the API it drives.\n */\nfunction unlinkedFinding(hop: GradedStep, attribution: Attributed, provider: string, place: string): Finding {\n const backing =\n attribution.rels.length === 1 ? \"one relationship\" : `${attribution.rels.length} relationships`;\n return {\n severity: \"warn\",\n code: \"usecase.step-unlinked\",\n subject: hop.view.id,\n message:\n `${place}: ${attribution.from} -> ${attribution.to} is backed by ${backing} carrying no ` +\n \"`metadata { op }` and no `publishes`/`consumes`, so this hop names no operation of \" +\n `${provider}'s contract — the use case reaches the fleet map and stops there. Add ` +\n \"`metadata { op '<operationId>' }` to the edge in `model { }`, or `metadata { publishes '<message>' }` / \" +\n \"`consumes` where the hop is an event.\",\n };\n}\n\n/**\n * The findings one hop earns — at most one, whatever is wrong with it.\n *\n * A `switch` over the closed verdict union with every arm returning, so a fourth\n * verdict added upstream is a compile error here rather than a hop that silently\n * stops being graded.\n */\nexport function stepFindings(hop: GradedStep, grading: StepGrading): Finding[] {\n const attribution = attributeStep(hop.step, grading.model);\n const place = placeOf(hop, grading);\n switch (attribution.verdict) {\n case \"unbacked\":\n return [unbackedFinding(hop, attribution, place)];\n case \"contested\":\n return [contestedFinding(hop, attribution, place)];\n case \"attributed\": {\n const provider = unlinkedProvider(attribution, grading);\n return provider === null ? [] : [unlinkedFinding(hop, attribution, provider, place)];\n }\n }\n}\n"]}
@@ -0,0 +1,217 @@
1
+ /**
2
+ * `c4.fleet-project-invalid` — the one thing no per-document grade can see:
3
+ * every document loam reads is clean, and the project the RENDERER builds out of
4
+ * them is not.
5
+ *
6
+ * loam grades a fleet document by document, and that is deliberate: a service's
7
+ * model is graded on its own merits, and one broken model must not blank the
8
+ * fleet. The renderer has no such rule. Point `npx likec4 start` at the docs
9
+ * root and it merges every `.likec4` the root project reads into ONE model — the
10
+ * map, every extending model, and every `.likec4` sitting beside one — so a
11
+ * declaration made in two of those documents is a duplicate there and nowhere
12
+ * else. Measured at the 1.59.2 pin: a tags-only `specification { tag req-X }` in
13
+ * an extending model is legal, and the SAME tag declared in a second extending
14
+ * model is a duplicate blamed on both files. Two services, two clean grades, and
15
+ * a renderer that draws nothing.
16
+ *
17
+ * So this is one extra load, and it is the renderer's reading rather than
18
+ * loam's. What it reports is the SET DIFFERENCE: an error already reported where
19
+ * loam grades that document — the map's own parse, a per-service project's
20
+ * `c4.invalid`, a service flow project's use-case failure — is not repeated
21
+ * here, because the fix is one file either way and N copies of one cascade is
22
+ * the report. What is left over is exactly the class above: errors that exist
23
+ * only because these documents were merged.
24
+ *
25
+ * WHICH DOCUMENTS ARE IN IT is `./root-documents.ts`'s question, and it is a
26
+ * walk of the docs root rather than a list of the shapes loam knows about: a
27
+ * `.likec4` in a SUBSYSTEM directory — the parent of service directories — was
28
+ * in none of the three roots this used to merge, and went unreported while the
29
+ * renderer refused the whole project over it (verification 2026-09-04, R2). That
30
+ * module also carries why the generated `architecture/subsystems.likec4`,
31
+ * every standalone service directory and `features/` stay out.
32
+ *
33
+ * `--all` ONLY, and only when at least one model extends the map, the map itself
34
+ * parses, and the root `exclude` does not hide it. A single-target run has not
35
+ * enumerated the fleet, so it cannot build the project at all; a fleet whose
36
+ * models all stand alone has no such project (every one of them is excluded from
37
+ * the root); and a map that does not parse would report its own cascade here
38
+ * under a second code while `landscape.invalid` already names the file.
39
+ *
40
+ * THE LAST OF THOSE GATES IS THE CASCADE RULE, the one `permissions.invalid` and
41
+ * `capability.invalid` already state: a grade that resolves against a
42
+ * document nobody loaded is not a diagnosis. `architectureProjectDocuments`
43
+ * keeps the map whatever the root `exclude` says — that floor is what stopped
44
+ * loam asserting a fleet's map declares nothing — but this load has no such
45
+ * floor ON PURPOSE, because its whole claim is to read what the renderer reads.
46
+ * So an entry covering `architecture/landscape.likec4` leaves every extending
47
+ * model in the project with its `extend <fqn>` resolving against nothing:
48
+ * measured on `examples/docs` with `architecture/*.likec4` in the list, one
49
+ * authored line came back as 161 warnings under this code. `landscape.excluded`
50
+ * (`./projects.ts`) is the one finding that names the cause, and it says the
51
+ * fleet project is not graded until the entry goes. The fact arrives as
52
+ * `mapExcluded` rather than being re-read here, so the two can never disagree
53
+ * about which state they are in.
54
+ *
55
+ * WHY A WARNING. Nothing loam concludes changes: every model still parses where
56
+ * loam reads it, every binding still resolves, the spine is unaffected. What is
57
+ * broken is the picture, which is what `service.likec4-config-missing` and its
58
+ * three siblings in `./projects.ts` also grade, at the same severity.
59
+ */
60
+ import { existsSync } from "node:fs";
61
+ import { relative, resolve, sep } from "node:path";
62
+ import { errorText } from "../../../../core/c4/likec4.js";
63
+ import { asLoadedDoc, loadProject } from "../../../../core/c4/project/load.js";
64
+ import { FleetContext } from "../../../../core/fleet-context.js";
65
+ import { inOrder } from "../../../../core/kernel/concurrency.js";
66
+ import { servicePathsAt } from "../../../../core/repo/paths.js";
67
+ import { readServiceFlows } from "../../../../core/usecases/service/flows.js";
68
+ import { rootProjectDocuments } from "./root-documents.js";
69
+ export async function fleetProjectFindings(check) {
70
+ // A map that did not parse is `landscape.invalid`'s, and this load would only
71
+ // repeat its cascade under a second code.
72
+ if (check.architecture.errors.length > 0)
73
+ return { kind: "skipped" };
74
+ // A map the root `exclude` hides is `landscape.excluded`'s, for the same
75
+ // reason one line up — see the banner's cascade-rule paragraph. The view-id
76
+ // census goes with it: `clean` is a claim about a project that parsed, and
77
+ // there is none.
78
+ if (check.mapExcluded)
79
+ return { kind: "skipped" };
80
+ const shapeOf = (entry) => {
81
+ const model = servicePathsAt(entry.dir).model;
82
+ return existsSync(model) ? (check.shapes.get(resolve(model)) ?? null) : null;
83
+ };
84
+ const extending = check.entries.filter((entry) => shapeOf(entry) === "extending");
85
+ if (extending.length === 0)
86
+ return { kind: "skipped" };
87
+ const paths = extending.map((entry) => servicePathsAt(entry.dir));
88
+ // The TREE, not three roots. `./root-documents.ts` carries what comes out of
89
+ // it and why; the standalone directories are handed in because their shape is
90
+ // this run's memo and re-reading every model here would be a second scanner.
91
+ const documents = await rootProjectDocuments(check.docsDir, check.entries.filter((entry) => shapeOf(entry) === "standalone").map((entry) => entry.dir));
92
+ const project = await loadProject(check.docsDir, documents);
93
+ if (project.clean) {
94
+ // The census `subsystem.view-id-collision` needs and could not have until
95
+ // now: an extending model's authored view ids live in the SAME flat
96
+ // namespace as the map's, because both documents are in this project. The
97
+ // claims arrive docs-relative, which is the spelling `viewIdFindings` takes.
98
+ return { kind: "clean", viewIds: project.viewIds };
99
+ }
100
+ const reported = await alreadyReported(check, paths);
101
+ const findings = [];
102
+ for (const err of asLoadedDoc(project).errors) {
103
+ if (reported.has(errorKey(err)))
104
+ continue;
105
+ const owner = extending.find((entry) => err.sourceFsPath !== undefined && err.sourceFsPath.startsWith(`${entry.dir}${sep}`));
106
+ const file = err.sourceFsPath === undefined ? null : relative(check.docsDir, err.sourceFsPath).split(/[\\/]/).join("/");
107
+ findings.push({
108
+ severity: "warn",
109
+ code: "c4.fleet-project-invalid",
110
+ ...(owner === undefined ? {} : { subject: owner.id }),
111
+ // The file the error is IN, never the map by default: `locations[0].path`
112
+ // was `architecture/landscape.likec4` on every one of these, so a reader
113
+ // acting on the payload opened a document that was not the one at fault
114
+ // (verification 2026-09-04, D10). Absent when LikeC4 declined to attribute
115
+ // the error to a document at all — a location loam cannot spell is left
116
+ // unspelled rather than guessed.
117
+ ...(file === null ? {} : { locations: [{ path: file, role: "primary" }] }),
118
+ details: [file === null ? errorText(err) : `${file}: ${errorText(err)}`],
119
+ message: `fleet project: ${file ?? "the merged project"}: ${errorText(err)} — each document reads clean where ` +
120
+ "loam grades it, but the renderer merges every `.likec4` the root project reads except the generated " +
121
+ "subsystems.likec4 — the map, every extending model and every `.likec4` beside one — into ONE project, " +
122
+ `and that project does not parse. ${remedy(err.message)}`,
123
+ });
124
+ }
125
+ return { kind: "invalid", findings };
126
+ }
127
+ /**
128
+ * The tail that names WHICH failure this is.
129
+ *
130
+ * It used to say "a tag or an element declared in two of those documents is
131
+ * declared twice there" for every error, including the ones that are not a
132
+ * double declaration at all (verification 2026-09-04, D10) — an unresolved
133
+ * reference sent its reader hunting for a second declaration that does not
134
+ * exist. LikeC4's own wording is the evidence, matched loosely on purpose: an
135
+ * unrecognised message falls to the third arm, which asserts nothing beyond
136
+ * where to look.
137
+ */
138
+ function remedy(message) {
139
+ if (/duplicat|already (declared|defined)|declared (more than once|twice)/i.test(message)) {
140
+ return ("A tag or an element declared in two of those documents is declared twice there; declare it once " +
141
+ "(the map, or the one service that owns it)");
142
+ }
143
+ if (/could not resolve|unresolved|unknown/i.test(message)) {
144
+ return ("A name resolves against ALL of those documents here, so a reference the renderer cannot place is one " +
145
+ "no document in the project declares; declare it where the reader can see it, or drop the reference");
146
+ }
147
+ return "Open the file and line named above — every reader who points a renderer at the docs root sees this";
148
+ }
149
+ /**
150
+ * Every parse error this run has ALREADY reported against one of these
151
+ * documents, keyed so the merged project's copy can be recognised.
152
+ *
153
+ * Two sources, and both are loads the run made anyway or would make: each
154
+ * extending model's own per-service project (`c4.invalid` on the service
155
+ * target) and, where nothing was wrong there, each service's FLOW project
156
+ * (`readServiceFlows` — the use-case grade names the file). The flow read goes
157
+ * through that function rather than a second `loadProject` here so the OPT-IN
158
+ * gate is the same one: a broken sibling that nothing tagged is loaded by
159
+ * neither, which is exactly why the merged project has to report it.
160
+ *
161
+ * Computed only when the merged project actually failed, which is why it is a
162
+ * function rather than an input: on a healthy fleet this whole file costs one
163
+ * load and nothing else.
164
+ *
165
+ * THE FLOW PROJECTS ARE PARSED AGAIN by `validateService` later in the same run,
166
+ * and that is accepted rather than unnoticed: it happens only on the path where
167
+ * the merged project already failed, so a healthy fleet pays nothing, and the
168
+ * `ServiceModel` half above IS the run's memo. If that path ever becomes hot,
169
+ * the follow-up is to memoise the flow scan on `FleetContext` beside the
170
+ * per-service projects — not to hand the scan in here, which would make this
171
+ * grade depend on an argument the caller has no reason to have loaded.
172
+ */
173
+ async function alreadyReported(check, paths) {
174
+ // Both sets reach the same documents through `architectureProjectDocuments`,
175
+ // which now drops what the root `exclude` covers exactly as the merged load's
176
+ // own walk does — so a document out of one is out of both, and the generated
177
+ // `subsystems.likec4` is out of all of them without being remembered twice.
178
+ //
179
+ // Pooled rather than awaited one at a time: each entry is an independent
180
+ // LikeC4 workspace over one service's own documents, which is exactly the
181
+ // work `inOrder` rations — and a serial loop here made the ONE path that
182
+ // already costs a fleet its whole set of flow projects pay for them in
183
+ // sequence.
184
+ const perService = await inOrder(paths, async (service) => {
185
+ const model = await check.fleet.serviceModel(check.docsDir, service);
186
+ const errors = [...model.doc.errors];
187
+ if (errors.length > 0 || model.mapUnreadable)
188
+ return errors;
189
+ // `model.project` is non-null here by construction — these are extending
190
+ // models whose own load came back clean, which is the arm that carries it —
191
+ // and the guard is the compiler's rather than an assertion: a null would
192
+ // mean grading a flow against a document that did not load.
193
+ if (model.project === null)
194
+ return [];
195
+ const flows = await readServiceFlows({
196
+ paths: service,
197
+ model: model.doc,
198
+ known: check.known,
199
+ extending: { docsDir: check.docsDir, project: model.project },
200
+ });
201
+ return flows.kind === "unreadable" ? flows.errors : [];
202
+ });
203
+ return new Set(perService.flat().map(errorKey));
204
+ }
205
+ /**
206
+ * One error's identity across two loads of overlapping document sets: the real
207
+ * file, the line, and the message.
208
+ *
209
+ * There is no id to join on — LikeC4 mints diagnostics per parse — and the
210
+ * message has to be part of the key: two DIFFERENT problems on one line of one
211
+ * file are two errors, and collapsing them would let a merged-project-only
212
+ * failure hide behind a per-service one that happens to share a line.
213
+ */
214
+ function errorKey(err) {
215
+ return JSON.stringify([err.sourceFsPath ?? "", err.line ?? -1, err.message]);
216
+ }
217
+ //# sourceMappingURL=fleet-project.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fleet-project.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/views/fleet-project.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `c4.fleet-project-invalid` — the one thing no per-document grade can see:\n * every document loam reads is clean, and the project the RENDERER builds out of\n * them is not.\n *\n * loam grades a fleet document by document, and that is deliberate: a service's\n * model is graded on its own merits, and one broken model must not blank the\n * fleet. The renderer has no such rule. Point `npx likec4 start` at the docs\n * root and it merges every `.likec4` the root project reads into ONE model — the\n * map, every extending model, and every `.likec4` sitting beside one — so a\n * declaration made in two of those documents is a duplicate there and nowhere\n * else. Measured at the 1.59.2 pin: a tags-only `specification { tag req-X }` in\n * an extending model is legal, and the SAME tag declared in a second extending\n * model is a duplicate blamed on both files. Two services, two clean grades, and\n * a renderer that draws nothing.\n *\n * So this is one extra load, and it is the renderer's reading rather than\n * loam's. What it reports is the SET DIFFERENCE: an error already reported where\n * loam grades that document — the map's own parse, a per-service project's\n * `c4.invalid`, a service flow project's use-case failure — is not repeated\n * here, because the fix is one file either way and N copies of one cascade is\n * the report. What is left over is exactly the class above: errors that exist\n * only because these documents were merged.\n *\n * WHICH DOCUMENTS ARE IN IT is `./root-documents.ts`'s question, and it is a\n * walk of the docs root rather than a list of the shapes loam knows about: a\n * `.likec4` in a SUBSYSTEM directory — the parent of service directories — was\n * in none of the three roots this used to merge, and went unreported while the\n * renderer refused the whole project over it (verification 2026-09-04, R2). That\n * module also carries why the generated `architecture/subsystems.likec4`,\n * every standalone service directory and `features/` stay out.\n *\n * `--all` ONLY, and only when at least one model extends the map, the map itself\n * parses, and the root `exclude` does not hide it. A single-target run has not\n * enumerated the fleet, so it cannot build the project at all; a fleet whose\n * models all stand alone has no such project (every one of them is excluded from\n * the root); and a map that does not parse would report its own cascade here\n * under a second code while `landscape.invalid` already names the file.\n *\n * THE LAST OF THOSE GATES IS THE CASCADE RULE, the one `permissions.invalid` and\n * `capability.invalid` already state: a grade that resolves against a\n * document nobody loaded is not a diagnosis. `architectureProjectDocuments`\n * keeps the map whatever the root `exclude` says — that floor is what stopped\n * loam asserting a fleet's map declares nothing — but this load has no such\n * floor ON PURPOSE, because its whole claim is to read what the renderer reads.\n * So an entry covering `architecture/landscape.likec4` leaves every extending\n * model in the project with its `extend <fqn>` resolving against nothing:\n * measured on `examples/docs` with `architecture/*.likec4` in the list, one\n * authored line came back as 161 warnings under this code. `landscape.excluded`\n * (`./projects.ts`) is the one finding that names the cause, and it says the\n * fleet project is not graded until the entry goes. The fact arrives as\n * `mapExcluded` rather than being re-read here, so the two can never disagree\n * about which state they are in.\n *\n * WHY A WARNING. Nothing loam concludes changes: every model still parses where\n * loam reads it, every binding still resolves, the spine is unaffected. What is\n * broken is the picture, which is what `service.likec4-config-missing` and its\n * three siblings in `./projects.ts` also grade, at the same severity.\n */\nimport { existsSync } from \"node:fs\";\nimport { relative, resolve, sep } from \"node:path\";\nimport { errorText, type LikeC4Error, type LoadedDoc } from \"../../../../core/c4/likec4.js\";\nimport { asLoadedDoc, loadProject } from \"../../../../core/c4/project/load.js\";\nimport type { ViewIdClaim } from \"../../../../core/c4/parsed/view-ids.js\";\nimport type { ModelShape } from \"../../../../core/c4/service-model/shape.js\";\nimport { FleetContext } from \"../../../../core/fleet-context.js\";\nimport { inOrder } from \"../../../../core/kernel/concurrency.js\";\nimport type { DocsDir } from \"../../../../core/kernel/ids/dirs.js\";\nimport type { ServiceEntry } from \"../../../../core/repo/entries.js\";\nimport { servicePathsAt, type ServicePaths } from \"../../../../core/repo/paths.js\";\nimport type { FleetTree } from \"../../../../core/repo/tree/walk.js\";\nimport { readServiceFlows } from \"../../../../core/usecases/service/flows.js\";\nimport type { Finding } from \"../../../../core/vocabulary/report.js\";\nimport { rootProjectDocuments } from \"./root-documents.js\";\n\nexport interface FleetProjectCheck {\n docsDir: DocsDir;\n /** The FULL enumeration — the project the renderer builds holds every model, not a narrowed run's subset. */\n entries: readonly ServiceEntry[];\n /** Each model's shape, keyed by resolved path (`FleetContext.modelShapes`). */\n shapes: ReadonlyMap<string, ModelShape>;\n /** The run's read index: the per-service projects it already parsed are half of the set difference. */\n fleet: FleetContext;\n /** The map parsed ALONE — the gate, and the other half of the difference. */\n architecture: LoadedDoc;\n /**\n * Whether the root `exclude` hides the map itself — `landscape.excluded`'s own\n * condition, computed once in `./projects.ts` and carried here rather than\n * re-derived. It is a GATE, for the reason the banner's cascade-rule paragraph\n * gives: this load has no floor under the map, so the entry that hides it\n * turns one authored line into an unresolved reference in every extending\n * model.\n */\n mapExcluded: boolean;\n /** The walked tree, for the view-id census this load makes answerable. */\n tree: FleetTree;\n /** Every service directory that exists — the flow scan's resolver evidence. */\n known: ReadonlySet<string>;\n}\n\n/**\n * What the merged load found, as three answers rather than one list — because\n * the caller has a second question to ask of it and the answers are mutually\n * exclusive.\n *\n * `skipped` is \"there is no such project, or loam declines to read one\": no model\n * extends the map, the map does not parse, the root `exclude` hides the map\n * (`landscape.excluded` names that entry and this grade stands down behind it),\n * or the run has no fleet index. `clean` carries the view-id\n * CENSUS, which is the only thing a caller may take from a project that parsed —\n * and the reason this is not just an empty finding list: an empty list and a\n * clean census are the same thing to `.length`, and one of them licenses\n * `subsystem.view-id-collision` to be graded over every extending model's\n * authored ids while the other does not.\n */\nexport type FleetProjectGrade =\n | { kind: \"skipped\" }\n | { kind: \"invalid\"; findings: Finding[] }\n | { kind: \"clean\"; viewIds: ViewIdClaim[] };\n\nexport async function fleetProjectFindings(check: FleetProjectCheck): Promise<FleetProjectGrade> {\n // A map that did not parse is `landscape.invalid`'s, and this load would only\n // repeat its cascade under a second code.\n if (check.architecture.errors.length > 0) return { kind: \"skipped\" };\n // A map the root `exclude` hides is `landscape.excluded`'s, for the same\n // reason one line up — see the banner's cascade-rule paragraph. The view-id\n // census goes with it: `clean` is a claim about a project that parsed, and\n // there is none.\n if (check.mapExcluded) return { kind: \"skipped\" };\n const shapeOf = (entry: ServiceEntry): ModelShape | null => {\n const model = servicePathsAt(entry.dir).model;\n return existsSync(model) ? (check.shapes.get(resolve(model)) ?? null) : null;\n };\n const extending = check.entries.filter((entry) => shapeOf(entry) === \"extending\");\n if (extending.length === 0) return { kind: \"skipped\" };\n\n const paths = extending.map((entry) => servicePathsAt(entry.dir));\n // The TREE, not three roots. `./root-documents.ts` carries what comes out of\n // it and why; the standalone directories are handed in because their shape is\n // this run's memo and re-reading every model here would be a second scanner.\n const documents = await rootProjectDocuments(\n check.docsDir,\n check.entries.filter((entry) => shapeOf(entry) === \"standalone\").map((entry) => entry.dir),\n );\n const project = await loadProject(check.docsDir, documents);\n\n if (project.clean) {\n // The census `subsystem.view-id-collision` needs and could not have until\n // now: an extending model's authored view ids live in the SAME flat\n // namespace as the map's, because both documents are in this project. The\n // claims arrive docs-relative, which is the spelling `viewIdFindings` takes.\n return { kind: \"clean\", viewIds: project.viewIds };\n }\n\n const reported = await alreadyReported(check, paths);\n const findings: Finding[] = [];\n for (const err of asLoadedDoc(project).errors) {\n if (reported.has(errorKey(err))) continue;\n const owner = extending.find(\n (entry) => err.sourceFsPath !== undefined && err.sourceFsPath.startsWith(`${entry.dir}${sep}`),\n );\n const file =\n err.sourceFsPath === undefined ? null : relative(check.docsDir, err.sourceFsPath).split(/[\\\\/]/).join(\"/\");\n findings.push({\n severity: \"warn\",\n code: \"c4.fleet-project-invalid\",\n ...(owner === undefined ? {} : { subject: owner.id }),\n // The file the error is IN, never the map by default: `locations[0].path`\n // was `architecture/landscape.likec4` on every one of these, so a reader\n // acting on the payload opened a document that was not the one at fault\n // (verification 2026-09-04, D10). Absent when LikeC4 declined to attribute\n // the error to a document at all — a location loam cannot spell is left\n // unspelled rather than guessed.\n ...(file === null ? {} : { locations: [{ path: file, role: \"primary\" as const }] }),\n details: [file === null ? errorText(err) : `${file}: ${errorText(err)}`],\n message:\n `fleet project: ${file ?? \"the merged project\"}: ${errorText(err)} — each document reads clean where ` +\n \"loam grades it, but the renderer merges every `.likec4` the root project reads except the generated \" +\n \"subsystems.likec4 — the map, every extending model and every `.likec4` beside one — into ONE project, \" +\n `and that project does not parse. ${remedy(err.message)}`,\n });\n }\n return { kind: \"invalid\", findings };\n}\n\n/**\n * The tail that names WHICH failure this is.\n *\n * It used to say \"a tag or an element declared in two of those documents is\n * declared twice there\" for every error, including the ones that are not a\n * double declaration at all (verification 2026-09-04, D10) — an unresolved\n * reference sent its reader hunting for a second declaration that does not\n * exist. LikeC4's own wording is the evidence, matched loosely on purpose: an\n * unrecognised message falls to the third arm, which asserts nothing beyond\n * where to look.\n */\nfunction remedy(message: string): string {\n if (/duplicat|already (declared|defined)|declared (more than once|twice)/i.test(message)) {\n return (\n \"A tag or an element declared in two of those documents is declared twice there; declare it once \" +\n \"(the map, or the one service that owns it)\"\n );\n }\n if (/could not resolve|unresolved|unknown/i.test(message)) {\n return (\n \"A name resolves against ALL of those documents here, so a reference the renderer cannot place is one \" +\n \"no document in the project declares; declare it where the reader can see it, or drop the reference\"\n );\n }\n return \"Open the file and line named above — every reader who points a renderer at the docs root sees this\";\n}\n\n/**\n * Every parse error this run has ALREADY reported against one of these\n * documents, keyed so the merged project's copy can be recognised.\n *\n * Two sources, and both are loads the run made anyway or would make: each\n * extending model's own per-service project (`c4.invalid` on the service\n * target) and, where nothing was wrong there, each service's FLOW project\n * (`readServiceFlows` — the use-case grade names the file). The flow read goes\n * through that function rather than a second `loadProject` here so the OPT-IN\n * gate is the same one: a broken sibling that nothing tagged is loaded by\n * neither, which is exactly why the merged project has to report it.\n *\n * Computed only when the merged project actually failed, which is why it is a\n * function rather than an input: on a healthy fleet this whole file costs one\n * load and nothing else.\n *\n * THE FLOW PROJECTS ARE PARSED AGAIN by `validateService` later in the same run,\n * and that is accepted rather than unnoticed: it happens only on the path where\n * the merged project already failed, so a healthy fleet pays nothing, and the\n * `ServiceModel` half above IS the run's memo. If that path ever becomes hot,\n * the follow-up is to memoise the flow scan on `FleetContext` beside the\n * per-service projects — not to hand the scan in here, which would make this\n * grade depend on an argument the caller has no reason to have loaded.\n */\nasync function alreadyReported(\n check: FleetProjectCheck,\n paths: readonly ServicePaths[],\n): Promise<ReadonlySet<string>> {\n // Both sets reach the same documents through `architectureProjectDocuments`,\n // which now drops what the root `exclude` covers exactly as the merged load's\n // own walk does — so a document out of one is out of both, and the generated\n // `subsystems.likec4` is out of all of them without being remembered twice.\n //\n // Pooled rather than awaited one at a time: each entry is an independent\n // LikeC4 workspace over one service's own documents, which is exactly the\n // work `inOrder` rations — and a serial loop here made the ONE path that\n // already costs a fleet its whole set of flow projects pay for them in\n // sequence.\n const perService = await inOrder(paths, async (service) => {\n const model = await check.fleet.serviceModel(check.docsDir, service);\n const errors = [...model.doc.errors];\n if (errors.length > 0 || model.mapUnreadable) return errors;\n // `model.project` is non-null here by construction — these are extending\n // models whose own load came back clean, which is the arm that carries it —\n // and the guard is the compiler's rather than an assertion: a null would\n // mean grading a flow against a document that did not load.\n if (model.project === null) return [];\n const flows = await readServiceFlows({\n paths: service,\n model: model.doc,\n known: check.known,\n extending: { docsDir: check.docsDir, project: model.project },\n });\n return flows.kind === \"unreadable\" ? flows.errors : [];\n });\n return new Set(perService.flat().map(errorKey));\n}\n\n/**\n * One error's identity across two loads of overlapping document sets: the real\n * file, the line, and the message.\n *\n * There is no id to join on — LikeC4 mints diagnostics per parse — and the\n * message has to be part of the key: two DIFFERENT problems on one line of one\n * file are two errors, and collapsing them would let a merged-project-only\n * failure hide behind a per-service one that happens to share a line.\n */\nfunction errorKey(err: LikeC4Error): string {\n return JSON.stringify([err.sourceFsPath ?? \"\", err.line ?? -1, err.message]);\n}\n"]}
@@ -1,10 +1,10 @@
1
1
  import { SUBSYSTEM_VIEW_PREFIX, subsystemViewId } from "../../../../core/repo/tree/render/views.js";
2
2
  /**
3
- * `authored` is every view id the `architecture/` project claims, each with the
4
- * file that claims it — static views included, because a static view claims an
5
- * id exactly as firmly as a dynamic one. Absent (the document did not parse, or
6
- * an older loader did not read them) is treated as "nothing to compare", never
7
- * as "nothing collides".
3
+ * `authored` is every view id the project claims, each with the DOCS-RELATIVE
4
+ * path of the file that claims it — static views included, because a static view
5
+ * claims an id exactly as firmly as a dynamic one. Absent (the document did not
6
+ * parse, or an older loader did not read them) is treated as "nothing to
7
+ * compare", never as "nothing collides".
8
8
  */
9
9
  export function viewIdFindings(authored, tree) {
10
10
  if (authored === undefined || authored.length === 0)
@@ -32,11 +32,15 @@ export function viewIdFindings(authored, tree) {
32
32
  // the one the report's target fallback gets wrong: it files this under
33
33
  // the landscape, which is exactly the file a reader must NOT rename the
34
34
  // view in when the claim came from a document of their own.
35
- locations: [{ path: `architecture/${file}`, role: "primary" }],
36
- message: `subsystems: architecture/${file} declares \`view ${id}\`, which is the id loam generates ` +
35
+ locations: [{ path: file, role: "primary" }],
36
+ message: `subsystems: ${file} declares \`view ${id}\`, which is the id loam generates ` +
37
37
  `into architecture/subsystems.likec4 for services/${owner}/ — LikeC4 merges both files into one project, ` +
38
- `so it refuses the whole architecture/ project and renders nothing. ` +
39
- `Rename the view in architecture/${file}; ids beginning \`${SUBSYSTEM_VIEW_PREFIX}\` are loam's ` +
38
+ // The ROOT project, not `architecture/`: the claimant may be a model's
39
+ // own view now (an extending model is a document of the root project),
40
+ // and telling that author the `architecture/` project is refused sends
41
+ // them to look for the breakage in a directory they never touched.
42
+ `so it refuses the whole root project and renders nothing. ` +
43
+ `Rename the view in ${file}; ids beginning \`${SUBSYSTEM_VIEW_PREFIX}\` are loam's ` +
40
44
  "to mint, and architecture/subsystems.likec4 is generated (`loam subsystem sync`) and must never be hand-edited.",
41
45
  });
42
46
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ids.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/views/ids.ts"],"names":[],"mappings":"AAsCA,OAAO,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,4CAA4C,CAAC;AAIpG;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,QAAmC,EAAE,IAAe;IACjF,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAC/D,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,eAAe,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IACpG,MAAM,QAAQ,GAAc,EAAE,CAAC;IAC/B,2EAA2E;IAC3E,8EAA8E;IAC9E,+EAA+E;IAC/E,2EAA2E;IAC3E,iDAAiD;IACjD,MAAM,SAAS,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC5C,KAAK,MAAM,KAAK,IAAI,QAAQ;QAAE,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YAAE,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC;IACtG,KAAK,MAAM,CAAC,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7E,MAAM,KAAK,GAAG,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChC,IAAI,KAAK,KAAK,SAAS;YAAE,SAAS;QAClC,QAAQ,CAAC,IAAI,CAAC;YACZ,QAAQ,EAAE,OAAO;YACjB,IAAI,EAAE,6BAA6B;YACnC,OAAO,EAAE,EAAE;YACX,wEAAwE;YACxE,uEAAuE;YACvE,wEAAwE;YACxE,4DAA4D;YAC5D,SAAS,EAAE,CAAC,EAAE,IAAI,EAAE,gBAAgB,IAAI,EAAE,EAAE,IAAI,EAAE,SAAkB,EAAE,CAAC;YACvE,OAAO,EACL,4BAA4B,IAAI,oBAAoB,EAAE,qCAAqC;gBAC3F,oDAAoD,KAAK,iDAAiD;gBAC1G,qEAAqE;gBACrE,mCAAmC,IAAI,qBAAqB,qBAAqB,gBAAgB;gBACjG,iHAAiH;SACpH,CAAC,CAAC;IACL,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC","sourcesContent":["/**\n * An authored view id that collides with one loam GENERATES into\n * `architecture/subsystems.likec4`.\n *\n * This closes a green-while-broken hole, and the shape of it is measured rather\n * than argued. The LikeC4 renderer merges every `.likec4` file in a project into\n * one model, and `likec4.config.json` scopes the root project to\n * `architecture/` — so `landscape.likec4` and the generated `subsystems.likec4`\n * share ONE flat view-id namespace. Author `view subsystem_commerce { }` in the\n * landscape while the tree has a `commerce` subsystem and the renderer refuses\n * the ENTIRE `architecture/` project with two `Duplicate view` diagnostics, one\n * per file — while `loam validate --all` printed `landscape: N service(s)\n * modelled — architecture/landscape.likec4 and services/ agree`. Reproduced at\n * the 1.59.2 pin.\n *\n * Nothing else in a loam repo can produce this. A duplicate INSIDE one document\n * is already 2 LikeC4 errors, so `landscape.invalid` has it (measured). A\n * service's `model.likec4` and a feature's `delta.likec4` are each their own\n * LikeC4 project — `likec4.config.json` excludes `services/**` and\n * `features/**` from the root — so their view ids cannot collide with the\n * landscape's, and a delta's views never travel through `archive` anyway. That\n * leaves exactly one pair, which is why this check compares exactly one pair\n * rather than taking a census across every document loam parses.\n *\n * ONLY the real collision is refused, never the merely reserved-looking name.\n * A landscape view called `subsystem_overview` with no `overview` subsystem\n * breaks nothing today, and refusing it would be loam inventing a rule about a\n * name that harms no one. The latent case is not lost: the moment somebody\n * creates that subsystem, the generator mints the id, and this same check fires\n * on the same run. The message names the reservation anyway, so an author fixing\n * one collision learns not to make the next.\n *\n * Graded on `validate --all` only, and the caller holds that gate together with\n * the landscape-parsed one: under a single-target run the fleet tree is not\n * walked and the generated ids are unknown, so the question is unanswerable and\n * the check stays silent rather than half-answering.\n */\nimport type { ViewIdClaim } from \"../../../../core/c4/parsed/view-ids.js\";\nimport { SUBSYSTEM_VIEW_PREFIX, subsystemViewId } from \"../../../../core/repo/tree/render/views.js\";\nimport type { FleetTree } from \"../../../../core/repo/tree/walk.js\";\nimport type { Finding } from \"../../../../core/vocabulary/report.js\";\n\n/**\n * `authored` is every view id the `architecture/` project claims, each with the\n * file that claims it — static views included, because a static view claims an\n * id exactly as firmly as a dynamic one. Absent (the document did not parse, or\n * an older loader did not read them) is treated as \"nothing to compare\", never\n * as \"nothing collides\".\n */\nexport function viewIdFindings(authored: ViewIdClaim[] | undefined, tree: FleetTree): Finding[] {\n if (authored === undefined || authored.length === 0) return [];\n const generated = new Map(tree.subsystems.map((sub) => [subsystemViewId(sub), sub.path.join(\"/\")]));\n const findings: Finding[] = [];\n // Sorted and de-duplicated by id so a project claiming the same id twice —\n // itself already a parse error, but reachable through a partial read — yields\n // one finding per id rather than one per occurrence. The first claimant's file\n // is the one named: with a duplicate the project does not parse at all, so\n // there is never a second one to choose between.\n const claimants = new Map<string, string>();\n for (const claim of authored) if (!claimants.has(claim.id)) claimants.set(claim.id, claim.sourcePath);\n for (const [id, file] of [...claimants].sort(([a], [b]) => (a < b ? -1 : 1))) {\n const owner = generated.get(id);\n if (owner === undefined) continue;\n findings.push({\n severity: \"error\",\n code: \"subsystem.view-id-collision\",\n subject: id,\n // The CLAIMANT file — the one the message tells the reader to edit, and\n // the one the report's target fallback gets wrong: it files this under\n // the landscape, which is exactly the file a reader must NOT rename the\n // view in when the claim came from a document of their own.\n locations: [{ path: `architecture/${file}`, role: \"primary\" as const }],\n message:\n `subsystems: architecture/${file} declares \\`view ${id}\\`, which is the id loam generates ` +\n `into architecture/subsystems.likec4 for services/${owner}/ — LikeC4 merges both files into one project, ` +\n `so it refuses the whole architecture/ project and renders nothing. ` +\n `Rename the view in architecture/${file}; ids beginning \\`${SUBSYSTEM_VIEW_PREFIX}\\` are loam's ` +\n \"to mint, and architecture/subsystems.likec4 is generated (`loam subsystem sync`) and must never be hand-edited.\",\n });\n }\n return findings;\n}\n"]}
1
+ {"version":3,"file":"ids.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/views/ids.ts"],"names":[],"mappings":"AAqDA,OAAO,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,4CAA4C,CAAC;AAIpG;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,QAAmC,EAAE,IAAe;IACjF,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAC/D,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,eAAe,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IACpG,MAAM,QAAQ,GAAc,EAAE,CAAC;IAC/B,2EAA2E;IAC3E,8EAA8E;IAC9E,+EAA+E;IAC/E,2EAA2E;IAC3E,iDAAiD;IACjD,MAAM,SAAS,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC5C,KAAK,MAAM,KAAK,IAAI,QAAQ;QAAE,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YAAE,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC;IACtG,KAAK,MAAM,CAAC,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7E,MAAM,KAAK,GAAG,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChC,IAAI,KAAK,KAAK,SAAS;YAAE,SAAS;QAClC,QAAQ,CAAC,IAAI,CAAC;YACZ,QAAQ,EAAE,OAAO;YACjB,IAAI,EAAE,6BAA6B;YACnC,OAAO,EAAE,EAAE;YACX,wEAAwE;YACxE,uEAAuE;YACvE,wEAAwE;YACxE,4DAA4D;YAC5D,SAAS,EAAE,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,SAAkB,EAAE,CAAC;YACrD,OAAO,EACL,eAAe,IAAI,oBAAoB,EAAE,qCAAqC;gBAC9E,oDAAoD,KAAK,iDAAiD;gBAC1G,uEAAuE;gBACvE,uEAAuE;gBACvE,uEAAuE;gBACvE,mEAAmE;gBACnE,4DAA4D;gBAC5D,sBAAsB,IAAI,qBAAqB,qBAAqB,gBAAgB;gBACpF,iHAAiH;SACpH,CAAC,CAAC;IACL,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC","sourcesContent":["/**\n * An authored view id that collides with one loam GENERATES into\n * `architecture/subsystems.likec4`.\n *\n * This closes a green-while-broken hole, and the shape of it is measured rather\n * than argued. The LikeC4 renderer merges every `.likec4` file in a project into\n * one model, and `architecture/landscape.likec4` and the generated\n * `architecture/subsystems.likec4` are two documents of the ROOT project — so\n * they share ONE flat view-id namespace. (This file used to say the reason was\n * that `likec4.config.json` scopes the root project to `architecture/`; it does\n * not, and never had to for the collision to exist. The scaffold's `exclude`\n * names `features/**` and node_modules, and a fleet whose models extend the map\n * has `services/` in the root project too — see `./fleet-project.ts`.) Author\n * `view subsystem_commerce { }` in the landscape while the tree has a `commerce`\n * subsystem and the renderer refuses the ENTIRE root project with two\n * `Duplicate view` diagnostics, one per file — while `loam validate --all`\n * printed `landscape: N service(s) modelled — architecture/landscape.likec4 and\n * services/ agree`. Reproduced at the 1.59.2 pin.\n *\n * WHICH DOCUMENTS CLAIM IDS IN THAT NAMESPACE, corrected. A duplicate INSIDE one\n * document is already 2 LikeC4 errors, so `landscape.invalid` has it (measured).\n * A feature's `delta.likec4` is its own LikeC4 project — the root excludes\n * `features/**` — and a delta's views never travel through `archive` anyway. A\n * service's `model.likec4` used to be in the same position and no longer is:\n * this file's original premise, that `likec4.config.json` excludes `services/**`\n * so a model's view ids cannot collide with the landscape's, holds only for a\n * model that STANDS ALONE. A model that EXTENDS the map lives in the root\n * project beside it, so its authored view ids — and those of every `.likec4`\n * beside it — share exactly this namespace. That census is made by the merged\n * load in `./fleet-project.ts` and handed here; the landscape's own claims are\n * still graded from the `architecture/` project, which is the only census a run\n * with no extending model can make.\n *\n * The claims therefore arrive spelled DOCS-RELATIVE, and the caller does the\n * spelling: the `architecture/` project reports a view's `sourcePath` relative\n * to `architecture/`, the merged project reports it relative to the docs root,\n * and a prefix pasted on here would name `architecture/services/…` for every\n * model. The message and the location both print the path verbatim.\n *\n * ONLY the real collision is refused, never the merely reserved-looking name.\n * A landscape view called `subsystem_overview` with no `overview` subsystem\n * breaks nothing today, and refusing it would be loam inventing a rule about a\n * name that harms no one. The latent case is not lost: the moment somebody\n * creates that subsystem, the generator mints the id, and this same check fires\n * on the same run. The message names the reservation anyway, so an author fixing\n * one collision learns not to make the next.\n *\n * Graded on `validate --all` only, and the caller holds that gate together with\n * the landscape-parsed one: under a single-target run the fleet tree is not\n * walked and the generated ids are unknown, so the question is unanswerable and\n * the check stays silent rather than half-answering.\n */\nimport type { ViewIdClaim } from \"../../../../core/c4/parsed/view-ids.js\";\nimport { SUBSYSTEM_VIEW_PREFIX, subsystemViewId } from \"../../../../core/repo/tree/render/views.js\";\nimport type { FleetTree } from \"../../../../core/repo/tree/walk.js\";\nimport type { Finding } from \"../../../../core/vocabulary/report.js\";\n\n/**\n * `authored` is every view id the project claims, each with the DOCS-RELATIVE\n * path of the file that claims it — static views included, because a static view\n * claims an id exactly as firmly as a dynamic one. Absent (the document did not\n * parse, or an older loader did not read them) is treated as \"nothing to\n * compare\", never as \"nothing collides\".\n */\nexport function viewIdFindings(authored: ViewIdClaim[] | undefined, tree: FleetTree): Finding[] {\n if (authored === undefined || authored.length === 0) return [];\n const generated = new Map(tree.subsystems.map((sub) => [subsystemViewId(sub), sub.path.join(\"/\")]));\n const findings: Finding[] = [];\n // Sorted and de-duplicated by id so a project claiming the same id twice —\n // itself already a parse error, but reachable through a partial read — yields\n // one finding per id rather than one per occurrence. The first claimant's file\n // is the one named: with a duplicate the project does not parse at all, so\n // there is never a second one to choose between.\n const claimants = new Map<string, string>();\n for (const claim of authored) if (!claimants.has(claim.id)) claimants.set(claim.id, claim.sourcePath);\n for (const [id, file] of [...claimants].sort(([a], [b]) => (a < b ? -1 : 1))) {\n const owner = generated.get(id);\n if (owner === undefined) continue;\n findings.push({\n severity: \"error\",\n code: \"subsystem.view-id-collision\",\n subject: id,\n // The CLAIMANT file — the one the message tells the reader to edit, and\n // the one the report's target fallback gets wrong: it files this under\n // the landscape, which is exactly the file a reader must NOT rename the\n // view in when the claim came from a document of their own.\n locations: [{ path: file, role: \"primary\" as const }],\n message:\n `subsystems: ${file} declares \\`view ${id}\\`, which is the id loam generates ` +\n `into architecture/subsystems.likec4 for services/${owner}/ — LikeC4 merges both files into one project, ` +\n // The ROOT project, not `architecture/`: the claimant may be a model's\n // own view now (an extending model is a document of the root project),\n // and telling that author the `architecture/` project is refused sends\n // them to look for the breakage in a directory they never touched.\n `so it refuses the whole root project and renders nothing. ` +\n `Rename the view in ${file}; ids beginning \\`${SUBSYSTEM_VIEW_PREFIX}\\` are loam's ` +\n \"to mint, and architecture/subsystems.likec4 is generated (`loam subsystem sync`) and must never be hand-edited.\",\n });\n }\n return findings;\n}\n"]}