@ybotok/loam 0.1.0-beta.1 → 0.1.0-beta.3

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 (120) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +6 -1
  3. package/SCHEMA.md +22 -4
  4. package/dist/cli.js +4 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/commands/adopt.js +17 -3
  7. package/dist/commands/adopt.js.map +1 -1
  8. package/dist/commands/archive.js +24 -8
  9. package/dist/commands/archive.js.map +1 -1
  10. package/dist/commands/delta.js +74 -47
  11. package/dist/commands/delta.js.map +1 -1
  12. package/dist/commands/dependencies.js +7 -2
  13. package/dist/commands/dependencies.js.map +1 -1
  14. package/dist/commands/docs-repo-gate.js +104 -0
  15. package/dist/commands/docs-repo-gate.js.map +1 -0
  16. package/dist/commands/explore.js +195 -0
  17. package/dist/commands/explore.js.map +1 -0
  18. package/dist/commands/format.js +17 -0
  19. package/dist/commands/format.js.map +1 -0
  20. package/dist/commands/gherkin.js +87 -48
  21. package/dist/commands/gherkin.js.map +1 -1
  22. package/dist/commands/init.js +3 -3
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/instructions.js +80 -0
  25. package/dist/commands/instructions.js.map +1 -0
  26. package/dist/commands/list.js +10 -76
  27. package/dist/commands/list.js.map +1 -1
  28. package/dist/commands/migrate-openspec.js +45 -28
  29. package/dist/commands/migrate-openspec.js.map +1 -1
  30. package/dist/commands/new.js +35 -34
  31. package/dist/commands/new.js.map +1 -1
  32. package/dist/commands/rebase.js +2 -4
  33. package/dist/commands/rebase.js.map +1 -1
  34. package/dist/commands/show.js +26 -31
  35. package/dist/commands/show.js.map +1 -1
  36. package/dist/commands/status.js +17 -25
  37. package/dist/commands/status.js.map +1 -1
  38. package/dist/commands/unarchive.js +12 -5
  39. package/dist/commands/unarchive.js.map +1 -1
  40. package/dist/commands/validate.js +306 -142
  41. package/dist/commands/validate.js.map +1 -1
  42. package/dist/commands/verify.js +33 -17
  43. package/dist/commands/verify.js.map +1 -1
  44. package/dist/commands/vouch.js +156 -60
  45. package/dist/commands/vouch.js.map +1 -1
  46. package/dist/core/agent.js +392 -42
  47. package/dist/core/agent.js.map +1 -1
  48. package/dist/core/agents-stamp.js +47 -10
  49. package/dist/core/agents-stamp.js.map +1 -1
  50. package/dist/core/arch.js +28 -2
  51. package/dist/core/arch.js.map +1 -1
  52. package/dist/core/asyncapi.js +253 -0
  53. package/dist/core/asyncapi.js.map +1 -0
  54. package/dist/core/brief.js +28 -6
  55. package/dist/core/brief.js.map +1 -1
  56. package/dist/core/coherence.js +37 -13
  57. package/dist/core/coherence.js.map +1 -1
  58. package/dist/core/concurrency.js +51 -0
  59. package/dist/core/concurrency.js.map +1 -0
  60. package/dist/core/config.js +10 -2
  61. package/dist/core/config.js.map +1 -1
  62. package/dist/core/delta.js +21 -2
  63. package/dist/core/delta.js.map +1 -1
  64. package/dist/core/dependencies.js +46 -42
  65. package/dist/core/dependencies.js.map +1 -1
  66. package/dist/core/docs.js +65 -3
  67. package/dist/core/docs.js.map +1 -1
  68. package/dist/core/doctor.js +79 -27
  69. package/dist/core/doctor.js.map +1 -1
  70. package/dist/core/document-bytes.js +59 -0
  71. package/dist/core/document-bytes.js.map +1 -0
  72. package/dist/core/explore.js +225 -0
  73. package/dist/core/explore.js.map +1 -0
  74. package/dist/core/fleet-context.js +29 -63
  75. package/dist/core/fleet-context.js.map +1 -1
  76. package/dist/core/frontmatter.js +1 -5
  77. package/dist/core/frontmatter.js.map +1 -1
  78. package/dist/core/gherkin.js +3 -61
  79. package/dist/core/gherkin.js.map +1 -1
  80. package/dist/core/health.js +1 -5
  81. package/dist/core/health.js.map +1 -1
  82. package/dist/core/ids.js +34 -5
  83. package/dist/core/ids.js.map +1 -1
  84. package/dist/core/json.js +8 -1
  85. package/dist/core/json.js.map +1 -1
  86. package/dist/core/likec4.js +19 -4
  87. package/dist/core/likec4.js.map +1 -1
  88. package/dist/core/maturity.js +66 -0
  89. package/dist/core/maturity.js.map +1 -0
  90. package/dist/core/openapi-merge.js +39 -6
  91. package/dist/core/openapi-merge.js.map +1 -1
  92. package/dist/core/openapi.js +17 -5
  93. package/dist/core/openapi.js.map +1 -1
  94. package/dist/core/openspec-inventory.js +64 -27
  95. package/dist/core/openspec-inventory.js.map +1 -1
  96. package/dist/core/path-safety.js +59 -0
  97. package/dist/core/path-safety.js.map +1 -1
  98. package/dist/core/provenance.js +102 -10
  99. package/dist/core/provenance.js.map +1 -1
  100. package/dist/core/records.js +33 -0
  101. package/dist/core/records.js.map +1 -0
  102. package/dist/core/repo.js +77 -1
  103. package/dist/core/repo.js.map +1 -1
  104. package/dist/core/report.js +9 -0
  105. package/dist/core/report.js.map +1 -1
  106. package/dist/core/results.js +1 -3
  107. package/dist/core/results.js.map +1 -1
  108. package/dist/core/spec.js +28 -4
  109. package/dist/core/spec.js.map +1 -1
  110. package/dist/core/staging.js +124 -17
  111. package/dist/core/staging.js.map +1 -1
  112. package/dist/core/status.js +152 -35
  113. package/dist/core/status.js.map +1 -1
  114. package/dist/core/steps.js +72 -0
  115. package/dist/core/steps.js.map +1 -0
  116. package/dist/core/verify.js +18 -4
  117. package/dist/core/verify.js.map +1 -1
  118. package/dist/core/version.js +17 -1
  119. package/dist/core/version.js.map +1 -1
  120. package/package.json +1 -1
@@ -1,17 +1,17 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
- import { cpus } from "node:os";
4
- import { join } from "node:path";
5
3
  import { agentsStaleFinding } from "../core/agents-stamp.js";
4
+ import { inOrder } from "../core/concurrency.js";
6
5
  import { loadConfig } from "../core/config.js";
7
6
  import { listField, readFrontmatter } from "../core/frontmatter.js";
8
- import { emitJson, fail, reportNoConfig } from "../core/json.js";
9
- import { elementService, loadFile, serviceOf, serviceResolver, } from "../core/likec4.js";
10
- import { DocsRepoUnavailableError, docsRepoState, featurePaths, featureSpecPaths, featureSpecServices, landscapePath as landscapeFile, listFeatures, listServices, missingFeatureMessage, resolveFeature, serviceIdFindings, servicePaths, } from "../core/repo.js";
11
- import { countSeverity, reportValid, targetJson, targetValid, } from "../core/report.js";
7
+ import { emitJson, fail, NO_SERVICE_MESSAGE, reportNoConfig } from "../core/json.js";
8
+ import { elementService, loadFile, serviceResolver, } from "../core/likec4.js";
9
+ import { DocsRepoUnavailableError, agentsPath as agentsFile, docsRepoState, featurePaths, featureSpecPaths, featureSpecServices, landscapePath as landscapeFile, listFeatures, listServices, missingFeatureMessage, resolveFeature, serviceIdFindings, servicePaths, SPEC_AXES, } from "../core/repo.js";
10
+ import { countSeverity, reportValid, SEVERITY_MARK, targetJson, targetValid, } from "../core/report.js";
12
11
  import { parseRequirements, requirementIdProblems, requirementsMissingScenarios, steplessFindings, } from "../core/spec.js";
13
12
  import { readOpenapi } from "../core/openapi.js";
14
- import { featureCoherence } from "../core/coherence.js";
13
+ import { producersByMessage, readAsyncapi, slotsOf } from "../core/asyncapi.js";
14
+ import { deltaServiceUnknownFinding, featureCoherence } from "../core/coherence.js";
15
15
  import { gatesArchive } from "../core/issue.js";
16
16
  import { emptySourcesMessage, expandSourceFiles, featureProvenance, missingSources, patternSources, serviceProvenance, unsafeSources, } from "../core/provenance.js";
17
17
  import { closeIds, coversCandidates, coversEdge, coversElement, entryResolves, parseCoversEntry, } from "../core/arch.js";
@@ -19,56 +19,8 @@ import { gherkinFindings } from "../core/gherkin.js";
19
19
  import { readHealth } from "../core/health.js";
20
20
  import { LOAM_VERSION } from "../core/version.js";
21
21
  import { FleetContext, documentConflictFinding, landscapeConflictFinding, } from "../core/fleet-context.js";
22
- /**
23
- * The refusal every READ command owes a `docsDir` that is not a docs repo.
24
- *
25
- * It is not a formality. `validate --all` and `list` used to answer a docsDir
26
- * that does not exist with a green report over zero services, which is the one
27
- * output a fleet gate must never produce: "nothing is wrong" and "I could not
28
- * look" are opposite facts, and in CI the first one merges. The two codes are
29
- * kept apart because the fixes point in different directions — a wrong path in
30
- * loam.json versus a docs repo that was never cloned or scaffolded — exactly
31
- * the way `no-config` and `config-invalid` are kept apart.
32
- *
33
- * "Zero services" stays reachable, and only from a REAL docs repo whose
34
- * `services/` is empty: that is a legitimate state (before the first
35
- * `loam adopt`) and it must keep exiting 0.
36
- *
37
- * `need` says how much of a docs repo this particular run has to have. A run
38
- * that only reads features genuinely does not need `services/` — repo.ts takes
39
- * the same position for `listFeatures` — and refusing there would turn one
40
- * diagnosis into two contradictory ones. A run that enumerates services needs
41
- * the directory that IS the list of services.
42
- *
43
- * It lives in validate.ts because validate is the fleet gate this doctrine is
44
- * about; `list` and `show` import it rather than growing two more copies that
45
- * could drift into three different sentences for one fact.
46
- */
47
- export function docsRepoReady(json, docsDir, need) {
48
- const state = docsRepoState(docsDir);
49
- if (state.kind === "ok" || (state.kind === "no-services" && need === "docs"))
50
- return true;
51
- if (state.kind === "missing") {
52
- fail(json, "docs-missing", `The configured docs repo does not exist: ${state.path}. ` +
53
- "Fix `docsDir` in loam.json, clone the docs repo there, " +
54
- "or run `loam init --docs <dir> --create` to make a new one.");
55
- return false;
56
- }
57
- fail(json, "services-missing", `${state.path} is not a docs repo — it has no services/ directory. ` +
58
- "Point `docsDir` in loam.json at the shared docs repo, " +
59
- "or run `loam init --docs <dir> --create` to make one.");
60
- return false;
61
- }
62
- /**
63
- * The same refusal, reached the other way: `listServices`/`listFeatures` throw
64
- * `DocsRepoUnavailableError` when the repo goes away mid-run (or when a caller
65
- * reaches enumeration without passing the gate above). Mapping it back onto the
66
- * same two codes keeps one breach spelled one way, instead of surfacing as the
67
- * `internal` catch-all in cli.ts.
68
- */
69
- export function reportDocsRepoError(json, err) {
70
- fail(json, err.state.kind === "missing" ? "docs-missing" : "services-missing", err.message);
71
- }
22
+ import { docsRepoReady, reportDocsRepoError, reportRepositoryUnavailable, } from "./docs-repo-gate.js";
23
+ import { plural } from "./format.js";
72
24
  export function registerValidate(program) {
73
25
  program
74
26
  .command("validate")
@@ -123,7 +75,7 @@ export function registerValidate(program) {
123
75
  // a fresh LikeC4 workspace per call, and paying that per service makes
124
76
  // the fleet's main CI command O(services) re-parses of the same file.
125
77
  const lp = landscapeFile(docsDir);
126
- const land = existsSync(lp) ? await fleet.loadLikeC4(lp) : null;
78
+ const land = existsSync(lp) ? await readLandscape(() => fleet.loadLikeC4(lp)) : null;
127
79
  // The fleet-level cross-check first: it frames everything below it, and a
128
80
  // service nobody drew is worth knowing before its own findings scroll past.
129
81
  targets.push(await validateLandscape(docsDir, land, fleet));
@@ -134,7 +86,7 @@ export function registerValidate(program) {
134
86
  // binary means agents are branching on tables the binary no longer
135
87
  // honours. It grades the repo, not any service, so it rides on the
136
88
  // landscape target.
137
- const agentsPath = join(docsDir, "AGENTS.md");
89
+ const agentsPath = agentsFile(docsDir);
138
90
  const agents = agentsStaleFinding(existsSync(agentsPath) ? await readFile(agentsPath, "utf8") : null, LOAM_VERSION);
139
91
  if (agents !== null)
140
92
  landscape.findings.push(agents);
@@ -193,7 +145,7 @@ export function registerValidate(program) {
193
145
  else {
194
146
  const service = opts.service ?? config.service;
195
147
  if (!service) {
196
- fail(json, "invalid-option", "No service. Pass --service <id> or set it in loam.json.");
148
+ fail(json, "invalid-option", NO_SERVICE_MESSAGE);
197
149
  return;
198
150
  }
199
151
  targets.push(await guarded({ kind: "service", id: service }, () => validateService({
@@ -218,10 +170,7 @@ export function registerValidate(program) {
218
170
  // that are fine. Say which file, and refuse; the one thing that must
219
171
  // not happen is a stack trace on stdout's sibling stream and an
220
172
  // `internal` envelope that names nothing.
221
- const path = err.path;
222
- if (path === undefined)
223
- throw err;
224
- fail(json, "repository-unavailable", `${path} could not be read, so the service list itself is unknown and nothing was validated. ${err instanceof Error ? err.message : String(err)}`);
173
+ reportRepositoryUnavailable(json, err, "the service list itself is unknown and nothing was validated", docsDir);
225
174
  return;
226
175
  }
227
176
  // Counted off the findings rather than alongside them, so the rollup line
@@ -262,42 +211,6 @@ export function registerValidate(program) {
262
211
  process.exitCode = 1;
263
212
  });
264
213
  }
265
- /**
266
- * How many targets `--all` may have in flight at once.
267
- *
268
- * Capped as well as scaled: each in-flight target holds a whole LikeC4/Langium
269
- * workspace, so a 64-core box would open sixty-four of them at once and buy
270
- * memory pressure rather than throughput (peak RSS on an 80-service fleet:
271
- * 385 MB serial, ~500 MB pooled).
272
- *
273
- * Do not quote a speedup for this. The measured cost of `--all` is CPU inside
274
- * the Langium parse, which one thread cannot divide however the loop is
275
- * shaped; what the pool overlaps is the I/O half — the spec, contract and
276
- * health reads around each parse. On an 80-service fleet it measures as a wash
277
- * (11–13 s either way, on a box that was itself loaded). The number that moves
278
- * this command is workspaces per run, not awaits per loop.
279
- */
280
- const TARGET_CONCURRENCY = Math.max(1, Math.min(cpus().length, 8));
281
- /**
282
- * Run `work` over `items` with a bounded number in flight, returning the
283
- * results in INPUT order.
284
- *
285
- * The order is the contract, not an accident: the text report and the `--json`
286
- * envelope are diffed between runs and between machines, so a concurrent run
287
- * has to be byte-identical to the serial one for the same input. That is why
288
- * results are written by index — nothing is appended as it finishes, and a slow
289
- * service cannot reorder the fleet around it.
290
- */
291
- async function inOrder(items, work) {
292
- const out = [];
293
- let next = 0;
294
- const workers = Math.min(TARGET_CONCURRENCY, items.length);
295
- await Promise.all(Array.from({ length: workers }, async () => {
296
- for (let i = next++; i < items.length; i = next++)
297
- out[i] = await work(items[i]);
298
- }));
299
- return out;
300
- }
301
214
  function summary(targets) {
302
215
  return {
303
216
  services: targets.filter((t) => t.kind === "service").length,
@@ -396,6 +309,57 @@ function ambiguousTarget(arg, chosen) {
396
309
  const ACTOR_KINDS = new Set(["person", "actor", "user"]);
397
310
  /** Tag marking an element as somebody else's system — undocumented on purpose. */
398
311
  const EXTERNAL_TAG = "external";
312
+ /**
313
+ * A landscape that could not be READ, shaped as one that did not PARSE.
314
+ *
315
+ * The landscape is the one artifact no target owns: it is read once for the
316
+ * whole run and it is graded on a target of its own, which runs OUTSIDE
317
+ * `guarded`. So a landscape.likec4 that is a directory, or that carries a
318
+ * permission bit this process cannot open, escaped every per-target catch and
319
+ * became the whole run's `repository-unavailable` — one file, and a fleet gate
320
+ * that said nothing about the ninety-nine services that are fine. A DANGLING
321
+ * symlink is not one of those shapes and never was: every read of the file is
322
+ * gated on an `existsSync` that follows the link, so a broken one resolves as
323
+ * `landscape.missing` long before anything opens it.
324
+ *
325
+ * What this contains is the FLEET-level reads, and only those: the `--all`
326
+ * preload, and both reads inside `validateLandscape` — the conflict-marker
327
+ * `readFile` and the parse. `loam validate --service <id>` and
328
+ * `loam validate --feature <id>` hand in no preloaded doc, so `validateService`
329
+ * and `validateFeature` open the same file again on demand, unwrapped and
330
+ * INSIDE `guarded`: an EISDIR there is still reported as `service.unreadable`
331
+ * or `feature.unreadable`, which files the fleet map's failure against the one
332
+ * target the caller happened to name. That is the wrong subject on a finding
333
+ * that does at least carry the offending path, and it costs those runs nothing
334
+ * further — they grade a single target either way, so there is no report left
335
+ * unwritten — which is why the remainder was left for the change that gives the
336
+ * landscape one load path instead of three.
337
+ *
338
+ * `guarded` is deliberately NOT widened to cover the landscape target instead.
339
+ * Its code ternary knows only services and features, so it would file the fleet
340
+ * map's failure as `feature.unreadable`; and a guarded failure yields no
341
+ * document, so all N services would go on to re-open the same broken file
342
+ * inside their own guards and emit N copies of it. Containing the IO here needs
343
+ * no new code and no new sentence: "could not be read" and "did not parse" have
344
+ * the same consequence — nothing may be concluded from this file — and
345
+ * `landscape.invalid` is already how that is said.
346
+ */
347
+ function unreadableLandscape(err) {
348
+ return {
349
+ errors: [{ message: err instanceof Error ? err.message : String(err) }],
350
+ elements: [],
351
+ relationships: [],
352
+ };
353
+ }
354
+ /** One landscape load, answering with `unreadableLandscape` rather than throwing. */
355
+ async function readLandscape(load) {
356
+ try {
357
+ return await load();
358
+ }
359
+ catch (err) {
360
+ return unreadableLandscape(err);
361
+ }
362
+ }
399
363
  /**
400
364
  * The fleet cross-check: `services/` and the landscape both claim to name the
401
365
  * fleet, and nothing used to compare them. A directory nobody drew and an element
@@ -458,12 +422,23 @@ async function validateLandscape(docsDir, preloaded, fleet) {
458
422
  // Read plainly rather than through FleetContext: this target runs outside
459
423
  // `guarded`, and a document refused for its encoding would surface here as
460
424
  // the whole run's `repository-unavailable` instead of one finding.
461
- const conflict = landscapeConflictFinding("architecture/landscape.likec4", await readFile(path, "utf8"));
462
- if (conflict !== null) {
463
- findings.push(conflict);
464
- return report;
425
+ //
426
+ // Both reads are contained for that same reason — see `unreadableLandscape`.
427
+ // The conflict-marker read is the FIRST touch of the file, so it is the one
428
+ // that fails when the file cannot be opened at all, and it runs before
429
+ // `preloaded ??` can spare it.
430
+ let land;
431
+ try {
432
+ const conflict = landscapeConflictFinding("architecture/landscape.likec4", await readFile(path, "utf8"));
433
+ if (conflict !== null) {
434
+ findings.push(conflict);
435
+ return report;
436
+ }
437
+ land = preloaded ?? (fleet === undefined ? await loadFile(path) : await fleet.loadLikeC4(path));
438
+ }
439
+ catch (err) {
440
+ land = unreadableLandscape(err);
465
441
  }
466
- const land = preloaded ?? (fleet === undefined ? await loadFile(path) : await fleet.loadLikeC4(path));
467
442
  if (land.errors.length > 0) {
468
443
  // Nothing may be concluded from a document that did not parse — in particular
469
444
  // not that every service is unmodelled.
@@ -713,6 +688,17 @@ async function validateService(check) {
713
688
  message: `No living spec at ${paths.spec} — requirement coverage and API governance are unchecked`,
714
689
  });
715
690
  }
691
+ // The requirements that still govern anything. A REMOVED requirement is on
692
+ // its way out together with the operations it names: it makes no claim on the
693
+ // contract and governs nothing once the retiring feature archives — the same
694
+ // position coherence.ts takes on the delta side.
695
+ //
696
+ // Spelled once because it was spelled three times and dropped on the fourth:
697
+ // `governedOps` filtered, `spec-api.op-undefined` filtered, and the
698
+ // `api.covered` join did not — so an operation whose only requirement was
699
+ // REMOVED counted as governed, and the one operation about to be left without
700
+ // a requirement was the one the report called covered.
701
+ const livingReqs = reqs.filter((r) => r.kind !== "REMOVED");
716
702
  // API coverage: every operation in openapi.yaml is governed by a requirement.
717
703
  const api = await readOpenapi(paths.openapi, fleet);
718
704
  const removeMarkers = api.ops.filter((o) => o.remove);
@@ -733,7 +719,7 @@ async function validateService(check) {
733
719
  : land.relationships.filter((r) => r.op !== undefined && landSvcOf(r.target) === service);
734
720
  // The other half of the evidence a contract is owed: the living spec's own
735
721
  // `Operations:` lines. A requirement on its way out governs nothing.
736
- const governedOps = reqs.filter((r) => r.kind !== "REMOVED").flatMap((r) => r.operations);
722
+ const governedOps = livingReqs.flatMap((r) => r.operations);
737
723
  /** No contract to read: deleted, renamed, or never written. */
738
724
  const contractMissing = !existsSync(paths.openapi);
739
725
  if (contractMissing) {
@@ -835,9 +821,7 @@ async function validateService(check) {
835
821
  // green forever, and every downstream join through that id silently empty.
836
822
  // Same code and severity as the feature-scope check on purpose: one breach,
837
823
  // one name, wherever it is found.
838
- for (const r of reqs) {
839
- if (r.kind === "REMOVED")
840
- continue;
824
+ for (const r of livingReqs) {
841
825
  for (const op of r.operations) {
842
826
  if (defined.has(op))
843
827
  continue;
@@ -852,7 +836,7 @@ async function validateService(check) {
852
836
  }
853
837
  }
854
838
  if (ops.length > 0) {
855
- const governed = new Set(reqs.flatMap((r) => r.operations));
839
+ const governed = new Set(livingReqs.flatMap((r) => r.operations));
856
840
  const orphans = ops.filter((op) => !governed.has(op));
857
841
  if (orphans.length === 0) {
858
842
  findings.push({
@@ -983,15 +967,23 @@ async function validateService(check) {
983
967
  }
984
968
  }
985
969
  }
986
- // The architecture spec axis — the obligations a business spec never carries
987
- // (the transactional outbox, retries, metrics, alerts). arch.spec.md is
988
- // optional and its ABSENCE is not a finding (partial adoption is supported);
989
- // what exists must hold together: every requirement needs a scenario, every
990
- // `Covers:` entry must resolve (covers.unknown), and every alert/SLI that
991
- // health.yaml declares wants a covering requirement (health.uncovered) — the
992
- // moment health.yaml stops being inert. Warnings, never gates: `--strict` is
993
- // the CI escalation.
994
- const health = await readHealth(paths.health);
970
+ /* ------------------------------------------------------------------ */
971
+ /* The async contract axis — AsyncAPI messages on the landscape spine */
972
+ /* ------------------------------------------------------------------ */
973
+ //
974
+ // The HTTP spine above and this one are mirror images, and the mirror is the
975
+ // whole reason this is a second axis rather than a second `metadata` key. On
976
+ // the HTTP axis the PROVIDER owns the contract: an edge names an operation and
977
+ // the target's openapi.yaml settles it, locally, in one file. Here the
978
+ // PRODUCER owns the message and the consumer joins to it from another
979
+ // repository — so "is this message declared" is local, while "does anybody
980
+ // publish it" is a fleet question with no local answer at all.
981
+ // The architecture spec, read HERE rather than with the rest of its axis
982
+ // below, because the event links are a requirement-level join and both
983
+ // requirement files carry them. Events are architecture as often as they are
984
+ // business — the outbox requirement in a service's arch.spec.md is the
985
+ // canonical place a `Publishes:` line belongs — so reading only spec.md would
986
+ // silently ignore the lines most authors write.
995
987
  const archText = existsSync(paths.archSpec)
996
988
  ? fleet === undefined
997
989
  ? await readFile(paths.archSpec, "utf8")
@@ -1002,6 +994,179 @@ async function validateService(check) {
1002
994
  : fleet === undefined
1003
995
  ? parseRequirements(archText)
1004
996
  : await fleet.readRequirements(paths.archSpec);
997
+ // Where this axis started, so the closing ok-finding can be "nothing here
998
+ // broke" rather than a second, independently-computed claim about the same
999
+ // facts. A positive finding derived from the same walk cannot disagree with it.
1000
+ const eventFindingsAt = findings.length;
1001
+ const events = await readAsyncapi(paths.asyncapi, fleet);
1002
+ const sentHere = new Set(events.sent);
1003
+ const receivedHere = new Set(events.received);
1004
+ // Every edge that binds THIS service to a message: `publishes` binds the
1005
+ // edge's source (a service produces what it declares an `action: send` for),
1006
+ // `consumes` binds its target. One edge may legitimately carry both — a relay
1007
+ // that reads one topic and writes another is a single arrow in most fleet maps.
1008
+ const eventEdges = land === null || land.errors.length > 0
1009
+ ? []
1010
+ : land.relationships.flatMap((r) => {
1011
+ const bound = [];
1012
+ if (r.publishes !== undefined && landSvcOf(r.source) === service) {
1013
+ bound.push({ direction: "publishes", message: r.publishes, other: landSvcOf(r.target) });
1014
+ }
1015
+ if (r.consumes !== undefined && landSvcOf(r.target) === service) {
1016
+ bound.push({ direction: "consumes", message: r.consumes, other: landSvcOf(r.source) });
1017
+ }
1018
+ return bound;
1019
+ });
1020
+ // Both requirement namespaces, and a REMOVED requirement links nothing —
1021
+ // the `livingReqs` rule the API axis already follows, applied to the pair.
1022
+ const eventLinks = [...livingReqs, ...archReqs.filter((r) => r.kind !== "REMOVED")].flatMap((r) => [
1023
+ ...r.publishes.map((m) => ({ direction: "publishes", message: m, requirement: r.name })),
1024
+ ...r.consumes.map((m) => ({ direction: "consumes", message: m, requirement: r.name })),
1025
+ ]);
1026
+ if (!existsSync(paths.asyncapi)) {
1027
+ // Deliberately TWO grades, not the three `service.no-openapi` uses. Its
1028
+ // middle grade — warn when the landscape cannot prove nobody calls this
1029
+ // service — rests on HTTP being the default: most services expose one, so
1030
+ // "I could not look" is worth saying. An event contract is genuinely
1031
+ // optional; most services in a legacy fleet touch no topic at all. Warning
1032
+ // every one of them the moment a landscape fails to parse would put a
1033
+ // finding on the whole fleet that names a file nobody owes.
1034
+ //
1035
+ // So: something already joins into the absent file, or silence.
1036
+ const dangling = [
1037
+ ...new Set([...eventEdges.map((e) => e.message), ...eventLinks.map((e) => e.message)]),
1038
+ ].sort();
1039
+ if (dangling.length > 0) {
1040
+ findings.push({
1041
+ severity: "error",
1042
+ code: "service.no-asyncapi",
1043
+ subject: service,
1044
+ message: `No async contract at ${paths.asyncapi}, and ${dangling.length} message link(s) already point into it — ` +
1045
+ `every landscape edge and requirement naming one of them resolves to nothing until the file is back`,
1046
+ details: dangling,
1047
+ });
1048
+ }
1049
+ }
1050
+ else if (events.unreadable) {
1051
+ // The openapi.invalid discipline: a contract that EXISTS but does not read
1052
+ // is a broken source of truth, not an empty one. Grading the links against
1053
+ // an empty message set would turn one file into one `spine.message-undefined`
1054
+ // per edge, every one of them pointing at the landscape.
1055
+ findings.push({
1056
+ severity: "error",
1057
+ code: "asyncapi.invalid",
1058
+ subject: service,
1059
+ message: `${service}: asyncapi.yaml does not parse — the event spine is unchecked for this service`,
1060
+ ...(events.error === undefined ? {} : { details: [events.error] }),
1061
+ });
1062
+ }
1063
+ else {
1064
+ for (const name of events.duplicateNames) {
1065
+ findings.push({
1066
+ severity: "warn",
1067
+ code: "asyncapi.duplicate-message",
1068
+ subject: service,
1069
+ message: `${service}: asyncapi.yaml declares message '${name}' in ${slotsOf(events, name).join(" and ")} — every join on the name (an edge's metadata { publishes }, a requirement's Publishes: line) picks one of those slots arbitrarily`,
1070
+ });
1071
+ }
1072
+ // Local resolution, both directions and both sources of a claim. An edge and
1073
+ // a requirement making the same broken claim get the same sentence under
1074
+ // different codes, matching how `spine.op-undefined` and
1075
+ // `spec-api.op-undefined` split the HTTP axis: one is the fleet map's
1076
+ // mistake, the other is this document's, and the fix is in a different file.
1077
+ const declares = (direction, message) => direction === "publishes" ? sentHere.has(message) : receivedHere.has(message);
1078
+ const action = (direction) => direction === "publishes" ? "send" : "receive";
1079
+ for (const e of eventEdges) {
1080
+ if (declares(e.direction, e.message))
1081
+ continue;
1082
+ findings.push({
1083
+ severity: "error",
1084
+ code: "spine.message-undefined",
1085
+ subject: service,
1086
+ message: `${service}: landscape edge ${e.direction === "publishes" ? `${service} → ${e.other}` : `${e.other} → ${service}`} ` +
1087
+ `${e.direction} '${e.message}', but ${service}'s asyncapi.yaml declares no operation with action: ${action(e.direction)} for it`,
1088
+ });
1089
+ }
1090
+ for (const e of eventLinks) {
1091
+ if (declares(e.direction, e.message))
1092
+ continue;
1093
+ findings.push({
1094
+ severity: "error",
1095
+ code: "spec-event.message-undefined",
1096
+ subject: service,
1097
+ message: `${service}: requirement '${e.requirement}' ${e.direction} '${e.message}', but ${service}'s asyncapi.yaml declares no operation with action: ${action(e.direction)} for it`,
1098
+ });
1099
+ }
1100
+ // The migration-debt case, `api.ops-unlinked`'s twin: a contract and
1101
+ // requirements that never name each other leave this axis vacuously green.
1102
+ if (events.messages.length > 0 && reqs.length + archReqs.length > 0 && eventLinks.length === 0) {
1103
+ findings.push({
1104
+ severity: "warn",
1105
+ code: "event.messages-unlinked",
1106
+ subject: service,
1107
+ message: `${service}: asyncapi.yaml declares ${events.messages.length} message(s) but no requirement links any — the event axis is unchecked for this service`,
1108
+ });
1109
+ }
1110
+ }
1111
+ // The fleet question, and the one check on either spine that a single
1112
+ // repository cannot answer: a consumer joins to a message whose schema lives
1113
+ // in the producer's contract, in the producer's repo. Paid only when something
1114
+ // actually consumes — a fleet where nobody has adopted the axis walks nothing.
1115
+ const consumed = [
1116
+ ...new Set([
1117
+ ...eventEdges.filter((e) => e.direction === "consumes").map((e) => e.message),
1118
+ ...eventLinks.filter((e) => e.direction === "consumes").map((e) => e.message),
1119
+ ]),
1120
+ ].sort();
1121
+ if (consumed.length > 0) {
1122
+ const producers = await producersByMessage(docsDir, [...known], fleet);
1123
+ for (const message of consumed) {
1124
+ const who = producers.byMessage.get(message) ?? [];
1125
+ // "Nobody produces this" is an argument from absence, and it is only
1126
+ // sound over a fleet loam could actually read. One unreadable contract
1127
+ // anywhere suspends it — that service may be the producer, and the
1128
+ // alternative is blaming every consumer for somebody else's broken YAML.
1129
+ // The contested answer below rests on positive evidence and needs no such
1130
+ // guard.
1131
+ if (who.length === 0 && producers.unreadable.length === 0) {
1132
+ findings.push({
1133
+ severity: "error",
1134
+ code: "spine.message-unproduced",
1135
+ subject: service,
1136
+ message: `${service}: consumes '${message}', but no service in the fleet declares an operation with action: send for it — the message has no producer, so nothing defines its payload`,
1137
+ });
1138
+ }
1139
+ else if (who.length > 1) {
1140
+ findings.push({
1141
+ severity: "warn",
1142
+ code: "asyncapi.message-contested",
1143
+ subject: service,
1144
+ message: `${service}: consumes '${message}', which ${who.length} services declare they send (${who.join(", ")}) — every consumer's join picks one of them arbitrarily, so the payload this service reads is whichever one happens to win`,
1145
+ });
1146
+ }
1147
+ }
1148
+ }
1149
+ // Positive confirmation, on the same rule `spine.resolved` follows: claimed
1150
+ // only where the axis actually checked something. A service that touches no
1151
+ // topic says nothing here — an "event axis clean" on every worker in the fleet
1152
+ // would be a green tick for work nobody did.
1153
+ const eventChecks = eventEdges.length + eventLinks.length;
1154
+ if (eventChecks > 0 && findings.length === eventFindingsAt) {
1155
+ findings.push({
1156
+ severity: "ok",
1157
+ code: "event.covered",
1158
+ message: `${service}: event spine (${eventChecks} message link(s) resolve to asyncapi.yaml)`,
1159
+ });
1160
+ }
1161
+ // The architecture spec axis — the obligations a business spec never carries
1162
+ // (the transactional outbox, retries, metrics, alerts). arch.spec.md is
1163
+ // optional and its ABSENCE is not a finding (partial adoption is supported);
1164
+ // what exists must hold together: every requirement needs a scenario, every
1165
+ // `Covers:` entry must resolve (covers.unknown), and every alert/SLI that
1166
+ // health.yaml declares wants a covering requirement (health.uncovered) — the
1167
+ // moment health.yaml stops being inert. Warnings, never gates: `--strict` is
1168
+ // the CI escalation.
1169
+ const health = await readHealth(paths.health);
1005
1170
  if (archText !== null) {
1006
1171
  // The arch axis is advisory in what it ASKS for (covers.unknown,
1007
1172
  // health.uncovered are warnings) but not in whether the file is readable:
@@ -1251,17 +1416,10 @@ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
1251
1416
  const closeTo = unknownServices.length > 0 && docsRepoState(docsDir).kind === "ok"
1252
1417
  ? (await listServices(docsDir, fleet)).map((s) => s.id)
1253
1418
  : [];
1254
- for (const svc of unknownServices) {
1255
- const close = closeIds(svc, closeTo);
1256
- findings.push({
1257
- severity: "error",
1258
- code: "delta.service-unknown",
1259
- subject: svc,
1260
- message: `specs/${svc}/ addresses a service that does not exist: there is no services/${svc}/ and this feature's ` +
1261
- `delta.likec4 does not introduce one — archiving would create it out of the typo.` +
1262
- (close.length > 0 ? ` Did you mean: ${close.join(", ")}?` : " `loam list services` shows what exists."),
1263
- });
1264
- }
1419
+ // The finding is coherence.ts's — the same words archive refuses with, because
1420
+ // it is the same conclusion about the same directory.
1421
+ for (const svc of unknownServices)
1422
+ findings.push(deltaServiceUnknownFinding(svc, closeTo));
1265
1423
  // Requirement coverage across every per-service delta — the business spec and
1266
1424
  // the arch spec through the same check — and collect scenario text.
1267
1425
  let scenarioText = "";
@@ -1299,15 +1457,23 @@ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
1299
1457
  findings.push(...repeatedListLineFindings(reqs, `${svc}: arch.spec.md`, svc));
1300
1458
  }
1301
1459
  }
1460
+ // The delta's own element→service resolver, built once for the whole feature.
1461
+ // `serviceOf` is a one-shot wrapper that rebuilds its index on every call, and
1462
+ // the two loops below ask it up to five times per tagged edge — so a delta
1463
+ // over a large model paid for one Map of every element per question asked.
1464
+ // Identical answers by construction: `serviceOf(elements, id)` IS
1465
+ // `serviceResolver(elements)(id)`, and nothing reassigns `elements` after the
1466
+ // delta parse.
1467
+ const svcOf = serviceResolver(elements);
1302
1468
  // Arch-edge coverage (heuristic, warn-only): each new tagged edge should be named by a scenario.
1303
1469
  for (const r of taggedRels) {
1304
- const target = serviceOf(elements, r.target);
1470
+ const target = svcOf(r.target);
1305
1471
  const covered = edgeCovered(target, r.title, scenarioText);
1306
1472
  findings.push({
1307
1473
  severity: covered ? "ok" : "warn",
1308
1474
  code: covered ? "archedge.covered" : "archedge.uncovered",
1309
1475
  subject: target,
1310
- message: `${serviceOf(elements, r.source)} → ${target} "${r.title ?? ""}"${covered ? "" : " — no scenario names it"}`,
1476
+ message: `${svcOf(r.source)} → ${target} "${r.title ?? ""}"${covered ? "" : " — no scenario names it"}`,
1311
1477
  text: { indent: 4, header: "arch-edge coverage (heuristic):" },
1312
1478
  });
1313
1479
  }
@@ -1380,15 +1546,15 @@ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
1380
1546
  });
1381
1547
  }
1382
1548
  for (const r of taggedRels) {
1383
- if (baseEdges.has(edgeKey(serviceOf(elements, r.source), serviceOf(elements, r.target))))
1549
+ if (baseEdges.has(edgeKey(svcOf(r.source), svcOf(r.target))))
1384
1550
  continue;
1385
1551
  if (activeCovers.some((c) => coversEdge(c, r, elements)))
1386
1552
  continue;
1387
1553
  findings.push({
1388
1554
  severity: "warn",
1389
1555
  code: "c4.uncovered",
1390
- subject: serviceOf(elements, r.target),
1391
- message: `delta adds edge ${serviceOf(elements, r.source)} → ${serviceOf(elements, r.target)} ("${r.title ?? ""}") but no arch requirement covers it — add 'Covers: ${r.source} -> ${r.target}' to a specs/<svc>/arch.spec.md delta`,
1556
+ subject: svcOf(r.target),
1557
+ message: `delta adds edge ${svcOf(r.source)} → ${svcOf(r.target)} ("${r.title ?? ""}") but no arch requirement covers it — add 'Covers: ${r.source} -> ${r.target}' to a specs/<svc>/arch.spec.md delta`,
1392
1558
  });
1393
1559
  }
1394
1560
  // covers.unknown, feature scope. Resolution looks at the delta itself, the
@@ -1471,7 +1637,6 @@ function coverageFinding(label, reqs) {
1471
1637
  /* ------------------------------------------------------------------ */
1472
1638
  /* Text renderer */
1473
1639
  /* ------------------------------------------------------------------ */
1474
- const MARKER = { ok: "✓", warn: "⚠", error: "✗" };
1475
1640
  /**
1476
1641
  * `--errors-only` is a RENDERING lever, the way `--strict` is an exit-code
1477
1642
  * lever: neither changes the report, and the `--json` payload is unaffected by
@@ -1499,7 +1664,7 @@ function renderText(targets, all, unverifiable, errorsOnly) {
1499
1664
  // onto stderr meant a piped stdout silently lost them from the middle of
1500
1665
  // the report and 2>&1 could reorder it; the exit code carries failure,
1501
1666
  // and stderr stays reserved for refusals (the fail() path).
1502
- const marker = hint.marker === false ? "" : `${MARKER[f.severity]} `;
1667
+ const marker = hint.marker === false ? "" : `${SEVERITY_MARK[f.severity]} `;
1503
1668
  console.log(`${" ".repeat(hint.indent ?? 0)}${marker}${f.message}`);
1504
1669
  for (const d of f.details ?? [])
1505
1670
  console.log(` ${hint.detailPrefix ?? ""}${d}`);
@@ -1515,7 +1680,6 @@ function renderText(targets, all, unverifiable, errorsOnly) {
1515
1680
  return;
1516
1681
  }
1517
1682
  const s = summary(targets);
1518
- const plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
1519
1683
  console.log(`\n${plural(s.services, "service")}, ${plural(s.features, "feature")} — ` +
1520
1684
  `${plural(s.errors, "error")}, ${plural(s.warnings, "warning")}`);
1521
1685
  // One line for the whole fleet, never one per service: honest about the blind
@@ -1551,10 +1715,10 @@ function renderText(targets, all, unverifiable, errorsOnly) {
1551
1715
  async function sourceScopeFindings(docsDir, service, repoDir) {
1552
1716
  const paths = servicePaths(docsDir, service);
1553
1717
  const out = [];
1554
- for (const { path, file } of [
1555
- { path: paths.spec, file: "spec.md" },
1556
- { path: paths.archSpec, file: "arch.spec.md" },
1557
- ]) {
1718
+ // The axis pair is SPEC_AXES', not this function's: `serviceProvenance` grades
1719
+ // the same two files from the same list, and a scope check that walked a
1720
+ // shorter list than the grading would go quiet on the axis it forgot.
1721
+ for (const { path, file } of SPEC_AXES.map((axis) => ({ path: paths[axis.key], file: axis.file }))) {
1558
1722
  if (!existsSync(path))
1559
1723
  continue;
1560
1724
  const sources = listField(await readFrontmatter(path), "sources");