@ybotok/loam 0.0.0 → 0.1.0-beta.1

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 (106) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/COMPARISON.md +63 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING-from-OpenSpec.md +202 -0
  5. package/README.md +241 -9
  6. package/SCHEMA.md +425 -0
  7. package/SECURITY.md +9 -0
  8. package/dist/cli.js +138 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/adopt.js +226 -0
  11. package/dist/commands/adopt.js.map +1 -0
  12. package/dist/commands/archive.js +1297 -0
  13. package/dist/commands/archive.js.map +1 -0
  14. package/dist/commands/delta.js +361 -0
  15. package/dist/commands/delta.js.map +1 -0
  16. package/dist/commands/dependencies.js +78 -0
  17. package/dist/commands/dependencies.js.map +1 -0
  18. package/dist/commands/doctor.js +85 -0
  19. package/dist/commands/doctor.js.map +1 -0
  20. package/dist/commands/gherkin.js +270 -0
  21. package/dist/commands/gherkin.js.map +1 -0
  22. package/dist/commands/init.js +250 -0
  23. package/dist/commands/init.js.map +1 -0
  24. package/dist/commands/list.js +361 -0
  25. package/dist/commands/list.js.map +1 -0
  26. package/dist/commands/migrate-openspec.js +933 -0
  27. package/dist/commands/migrate-openspec.js.map +1 -0
  28. package/dist/commands/new.js +412 -0
  29. package/dist/commands/new.js.map +1 -0
  30. package/dist/commands/rebase.js +358 -0
  31. package/dist/commands/rebase.js.map +1 -0
  32. package/dist/commands/show.js +325 -0
  33. package/dist/commands/show.js.map +1 -0
  34. package/dist/commands/status.js +270 -0
  35. package/dist/commands/status.js.map +1 -0
  36. package/dist/commands/unarchive.js +310 -0
  37. package/dist/commands/unarchive.js.map +1 -0
  38. package/dist/commands/validate.js +1609 -0
  39. package/dist/commands/validate.js.map +1 -0
  40. package/dist/commands/verify.js +759 -0
  41. package/dist/commands/verify.js.map +1 -0
  42. package/dist/commands/vouch.js +325 -0
  43. package/dist/commands/vouch.js.map +1 -0
  44. package/dist/core/agent.js +1823 -0
  45. package/dist/core/agent.js.map +1 -0
  46. package/dist/core/agents-stamp.js +68 -0
  47. package/dist/core/agents-stamp.js.map +1 -0
  48. package/dist/core/arch.js +104 -0
  49. package/dist/core/arch.js.map +1 -0
  50. package/dist/core/brief.js +733 -0
  51. package/dist/core/brief.js.map +1 -0
  52. package/dist/core/coherence.js +553 -0
  53. package/dist/core/coherence.js.map +1 -0
  54. package/dist/core/config.js +172 -0
  55. package/dist/core/config.js.map +1 -0
  56. package/dist/core/delta.js +457 -0
  57. package/dist/core/delta.js.map +1 -0
  58. package/dist/core/dependencies.js +352 -0
  59. package/dist/core/dependencies.js.map +1 -0
  60. package/dist/core/docs.js +143 -0
  61. package/dist/core/docs.js.map +1 -0
  62. package/dist/core/doctor.js +544 -0
  63. package/dist/core/doctor.js.map +1 -0
  64. package/dist/core/fleet-context.js +272 -0
  65. package/dist/core/fleet-context.js.map +1 -0
  66. package/dist/core/frontmatter.js +203 -0
  67. package/dist/core/frontmatter.js.map +1 -0
  68. package/dist/core/gherkin.js +521 -0
  69. package/dist/core/gherkin.js.map +1 -0
  70. package/dist/core/health.js +71 -0
  71. package/dist/core/health.js.map +1 -0
  72. package/dist/core/ids.js +93 -0
  73. package/dist/core/ids.js.map +1 -0
  74. package/dist/core/issue.js +9 -0
  75. package/dist/core/issue.js.map +1 -0
  76. package/dist/core/json.js +67 -0
  77. package/dist/core/json.js.map +1 -0
  78. package/dist/core/likec4.js +474 -0
  79. package/dist/core/likec4.js.map +1 -0
  80. package/dist/core/openapi-merge.js +519 -0
  81. package/dist/core/openapi-merge.js.map +1 -0
  82. package/dist/core/openapi.js +243 -0
  83. package/dist/core/openapi.js.map +1 -0
  84. package/dist/core/openspec-inventory.js +1403 -0
  85. package/dist/core/openspec-inventory.js.map +1 -0
  86. package/dist/core/path-safety.js +109 -0
  87. package/dist/core/path-safety.js.map +1 -0
  88. package/dist/core/provenance.js +695 -0
  89. package/dist/core/provenance.js.map +1 -0
  90. package/dist/core/repo.js +362 -0
  91. package/dist/core/repo.js.map +1 -0
  92. package/dist/core/report.js +38 -0
  93. package/dist/core/report.js.map +1 -0
  94. package/dist/core/results.js +201 -0
  95. package/dist/core/results.js.map +1 -0
  96. package/dist/core/spec.js +481 -0
  97. package/dist/core/spec.js.map +1 -0
  98. package/dist/core/staging.js +783 -0
  99. package/dist/core/staging.js.map +1 -0
  100. package/dist/core/status.js +1015 -0
  101. package/dist/core/status.js.map +1 -0
  102. package/dist/core/verify.js +793 -0
  103. package/dist/core/verify.js.map +1 -0
  104. package/dist/core/version.js +8 -0
  105. package/dist/core/version.js.map +1 -0
  106. package/package.json +63 -4
@@ -0,0 +1,1609 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { cpus } from "node:os";
4
+ import { join } from "node:path";
5
+ import { agentsStaleFinding } from "../core/agents-stamp.js";
6
+ import { loadConfig } from "../core/config.js";
7
+ 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";
12
+ import { parseRequirements, requirementIdProblems, requirementsMissingScenarios, steplessFindings, } from "../core/spec.js";
13
+ import { readOpenapi } from "../core/openapi.js";
14
+ import { featureCoherence } from "../core/coherence.js";
15
+ import { gatesArchive } from "../core/issue.js";
16
+ import { emptySourcesMessage, expandSourceFiles, featureProvenance, missingSources, patternSources, serviceProvenance, unsafeSources, } from "../core/provenance.js";
17
+ import { closeIds, coversCandidates, coversEdge, coversElement, entryResolves, parseCoversEntry, } from "../core/arch.js";
18
+ import { gherkinFindings } from "../core/gherkin.js";
19
+ import { readHealth } from "../core/health.js";
20
+ import { LOAM_VERSION } from "../core/version.js";
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
+ }
72
+ export function registerValidate(program) {
73
+ program
74
+ .command("validate")
75
+ .argument("[target]", "service or feature id (a feature wins when both match; --service/--feature force the reading)")
76
+ .description("Validate a service (C4 + requirement coverage) or a feature (delta + coverage)")
77
+ .option("--service <id>", "service to validate (defaults to the configured service)")
78
+ .option("--feature <id>", "validate a feature delta instead of a service")
79
+ .option("--all", "validate every service and every active feature")
80
+ .option("--strict", "exit 1 on any warning too — a per-invocation CI lever; the report and --json payload do not change")
81
+ .option("--errors-only", "print only errors and warnings — a rendering lever; the --json payload does not change")
82
+ .option("--json", "emit the machine contract instead of the human view")
83
+ .action(async (target, opts) => {
84
+ const json = opts.json === true;
85
+ if (target !== undefined && (opts.all || opts.service || opts.feature)) {
86
+ fail(json, "invalid-option", `'${target}' already names the target; drop --all/--service/--feature.`);
87
+ return;
88
+ }
89
+ if (opts.all && (opts.service || opts.feature)) {
90
+ fail(json, "invalid-option", "--all validates everything; drop --service/--feature.");
91
+ return;
92
+ }
93
+ // Two targets is no target: silently validating only the feature taught
94
+ // callers that --service had been honoured when it had been dropped.
95
+ if (opts.service && opts.feature) {
96
+ fail(json, "invalid-option", "--service and --feature name different targets; pass one or the other.");
97
+ return;
98
+ }
99
+ const config = await loadConfig();
100
+ if (!config) {
101
+ reportNoConfig(json);
102
+ return;
103
+ }
104
+ const { docsDir } = config;
105
+ // Before anything is enumerated: a docsDir that is not a docs repo must
106
+ // refuse, not report an empty fleet. See docsRepoReady. `--feature` is the
107
+ // one mode that never enumerates services, so it asks for less.
108
+ if (!docsRepoReady(json, docsDir, opts.feature ? "docs" : "services"))
109
+ return;
110
+ // One invocation, one filesystem snapshot. Nothing is global: a later
111
+ // command gets a fresh index and therefore cannot observe stale files.
112
+ const fleet = new FleetContext();
113
+ // `sources` are paths into a service's own repository, so they only mean
114
+ // something when loam is standing in that repository — which is exactly
115
+ // what loam.json's `service` records.
116
+ const repoOf = (service) => config.service === service ? process.cwd() : undefined;
117
+ const targets = [];
118
+ /** What the positional argument turned out to name, for the JSON payload. */
119
+ let resolvedKind;
120
+ try {
121
+ if (opts.all) {
122
+ // Parse the living landscape ONCE for the whole run: loadFile spins up
123
+ // a fresh LikeC4 workspace per call, and paying that per service makes
124
+ // the fleet's main CI command O(services) re-parses of the same file.
125
+ const lp = landscapeFile(docsDir);
126
+ const land = existsSync(lp) ? await fleet.loadLikeC4(lp) : null;
127
+ // The fleet-level cross-check first: it frames everything below it, and a
128
+ // service nobody drew is worth knowing before its own findings scroll past.
129
+ targets.push(await validateLandscape(docsDir, land, fleet));
130
+ const landscape = targets[0];
131
+ // The agent contract check, --all only: AGENTS.md is written once and
132
+ // never refreshed (the ownership contract), so the one thing the
133
+ // docs-repo-wide mode owes it is detection — a stamp older than the
134
+ // binary means agents are branching on tables the binary no longer
135
+ // honours. It grades the repo, not any service, so it rides on the
136
+ // landscape target.
137
+ const agentsPath = join(docsDir, "AGENTS.md");
138
+ const agents = agentsStaleFinding(existsSync(agentsPath) ? await readFile(agentsPath, "utf8") : null, LOAM_VERSION);
139
+ if (agents !== null)
140
+ landscape.findings.push(agents);
141
+ targets.push(...(await inOrder(await listServices(docsDir, fleet), (svc) => guarded({ kind: "service", id: svc.id }, () => validateService({
142
+ docsDir,
143
+ service: svc.id,
144
+ repoDir: repoOf(svc.id),
145
+ preloaded: land,
146
+ gherkinDir: config.gherkinDir,
147
+ fleet,
148
+ landscapeReported: true,
149
+ })))));
150
+ targets.push(...(await inOrder(await listFeatures(docsDir, {}, fleet), (feat) => guarded({ kind: "feature", id: feat.id }, () => validateFeature(docsDir, feat, land, fleet)))));
151
+ }
152
+ else if (opts.feature) {
153
+ const feature = await resolveFeature(docsDir, opts.feature, "exclude", fleet);
154
+ if (!feature) {
155
+ fail(json, "unknown-target", await missingFeatureMessage(docsDir, opts.feature, fleet));
156
+ return;
157
+ }
158
+ targets.push(await guarded({ kind: "feature", id: feature.id }, () => validateFeature(docsDir, feature, undefined, fleet)));
159
+ }
160
+ else if (target !== undefined) {
161
+ // The positional reads the way `show` reads one: try the feature first
162
+ // (ids like FEAT-101 are distinctive, service names are arbitrary), then
163
+ // the service. --service/--feature stay as the explicit spellings for a
164
+ // name that could be both — and when both readings exist, the run says
165
+ // so out loud instead of silently taking one (`target.ambiguous`).
166
+ const feature = await resolveFeature(docsDir, target, "exclude", fleet);
167
+ const isService = (await listServices(docsDir, fleet)).some((s) => s.id === target);
168
+ if (feature) {
169
+ resolvedKind = "feature";
170
+ const report = await guarded({ kind: "feature", id: feature.id }, () => validateFeature(docsDir, feature, undefined, fleet));
171
+ if (isService)
172
+ report.findings.unshift(ambiguousTarget(target, "feature"));
173
+ targets.push(report);
174
+ }
175
+ else {
176
+ // Neither reading exists. An archived feature is its own diagnosis
177
+ // ("already archived", not "no such thing"); anything else reads as a
178
+ // service, so the did-you-mean hints in service.unknown fire.
179
+ if (!isService && (await resolveFeature(docsDir, target, "only", fleet)) !== null) {
180
+ fail(json, "unknown-target", await missingFeatureMessage(docsDir, target, fleet));
181
+ return;
182
+ }
183
+ resolvedKind = "service";
184
+ targets.push(await guarded({ kind: "service", id: target }, () => validateService({
185
+ docsDir,
186
+ service: target,
187
+ repoDir: repoOf(target),
188
+ gherkinDir: config.gherkinDir,
189
+ fleet,
190
+ })));
191
+ }
192
+ }
193
+ else {
194
+ const service = opts.service ?? config.service;
195
+ if (!service) {
196
+ fail(json, "invalid-option", "No service. Pass --service <id> or set it in loam.json.");
197
+ return;
198
+ }
199
+ targets.push(await guarded({ kind: "service", id: service }, () => validateService({
200
+ docsDir,
201
+ service,
202
+ repoDir: repoOf(service),
203
+ gherkinDir: config.gherkinDir,
204
+ fleet,
205
+ })));
206
+ }
207
+ }
208
+ catch (err) {
209
+ // The docs repo went away between the gate above and the enumeration.
210
+ if (err instanceof DocsRepoUnavailableError) {
211
+ reportDocsRepoError(json, err);
212
+ return;
213
+ }
214
+ // An IO failure that `guarded` could not localise to one target — it
215
+ // happened in the ENUMERATION, which reads every living spec's
216
+ // frontmatter to build the service list. There is no partial report to
217
+ // give: without the list, no service was checked, including the ones
218
+ // that are fine. Say which file, and refuse; the one thing that must
219
+ // not happen is a stack trace on stdout's sibling stream and an
220
+ // `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)}`);
225
+ return;
226
+ }
227
+ // Counted off the findings rather than alongside them, so the rollup line
228
+ // and the per-service findings can never disagree about how many services
229
+ // this run could not check.
230
+ //
231
+ // Counted by SUBJECT, not by finding: a service whose spec.md and
232
+ // arch.spec.md both name `sources` raises two findings — each document
233
+ // has its own list and its own answer — but it is one service nobody
234
+ // here can check, and "2 services' sources" over a fleet of one is a
235
+ // number that sends the reader looking for a service that does not exist.
236
+ const unverifiable = new Set(targets.flatMap((t) => t.findings.filter((f) => f.code === UNVERIFIABLE).map((f) => f.subject ?? t.id))).size;
237
+ const valid = reportValid(targets);
238
+ const capped = targets.map(capDetails);
239
+ if (json) {
240
+ emitJson({
241
+ valid,
242
+ summary: summary(targets),
243
+ ...(resolvedKind === undefined ? {} : { resolvedKind }),
244
+ // Emitted in every mode now that it is derived per service: a
245
+ // single-service run knows exactly as much about its own blind spot
246
+ // as `--all` knows about the fleet's.
247
+ sourcesUnverifiableFromHere: unverifiable,
248
+ targets: capped.map(targetJson),
249
+ });
250
+ }
251
+ else {
252
+ renderText(capped, opts.all === true, unverifiable, opts.errorsOnly === true);
253
+ }
254
+ // --strict is a per-invocation CI lever: it fails the run on any error or
255
+ // warning (ok-severity confirmations never trip it — virtually every
256
+ // clean target emits some), and changes nothing else — `valid` keeps meaning "no
257
+ // errors" in text and JSON alike, so two pipelines reading the same repo
258
+ // may grade the same report differently and both are telling the truth.
259
+ const strictFailed = opts.strict === true &&
260
+ countSeverity(targets, "error") + countSeverity(targets, "warn") > 0;
261
+ if (!valid || strictFailed)
262
+ process.exitCode = 1;
263
+ });
264
+ }
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
+ function summary(targets) {
302
+ return {
303
+ services: targets.filter((t) => t.kind === "service").length,
304
+ features: targets.filter((t) => t.kind === "feature").length,
305
+ errors: countSeverity(targets, "error"),
306
+ warnings: countSeverity(targets, "warn"),
307
+ };
308
+ }
309
+ /** The one code the rollup line counts; spelled once so the two cannot drift. */
310
+ const UNVERIFIABLE = "sources.unverifiable-from-here";
311
+ /**
312
+ * How many `details` lines any one finding may print before the rest are
313
+ * summarised away.
314
+ *
315
+ * A finding's details are evidence, not a log: LikeC4 reports one syntax error
316
+ * as dozens of cascading diagnostics, and a fleet-sized repo multiplies that by
317
+ * every target that mentions the file. The report is read by a person scrolling
318
+ * a CI log and by an agent with a context window, and neither of them is helped
319
+ * by the four-hundredth copy. The cap is applied to the JSON payload too, on
320
+ * purpose: `--json` is the interface an agent pipes, and an unbounded array is
321
+ * the same denial-of-attention there, just machine-readable.
322
+ */
323
+ const DETAIL_LIMIT = 10;
324
+ /** Truncate every finding's details, marking what was dropped so nothing looks complete when it is not. */
325
+ function capDetails(t) {
326
+ return {
327
+ ...t,
328
+ findings: t.findings.map((f) => {
329
+ const details = f.details ?? [];
330
+ if (details.length <= DETAIL_LIMIT)
331
+ return f;
332
+ return {
333
+ ...f,
334
+ details: [...details.slice(0, DETAIL_LIMIT), `… (+${details.length - DETAIL_LIMIT} more)`],
335
+ };
336
+ }),
337
+ };
338
+ }
339
+ /**
340
+ * One target's checks, with an IO exception turned into a finding ON that
341
+ * target instead of aborting the run.
342
+ *
343
+ * A fleet gate that dies on the first unreadable file reports nothing about the
344
+ * other ninety-nine services — one bad permission bit, one file that is a
345
+ * dangling symlink, and CI's answer to "how is the fleet" becomes a stack
346
+ * trace. The failure is real and it is an error, so the run still exits 1; what
347
+ * changes is that everything else is still graded, and the finding names the
348
+ * service and the path instead of arriving as the `internal` catch-all.
349
+ */
350
+ async function guarded(target, run) {
351
+ try {
352
+ return await run();
353
+ }
354
+ catch (err) {
355
+ // A docs repo that vanished mid-run is not one target's problem — it is the
356
+ // whole run's, and the action's own catch reports it.
357
+ if (err instanceof DocsRepoUnavailableError)
358
+ throw err;
359
+ const path = err.path;
360
+ const reason = err instanceof Error ? err.message : String(err);
361
+ return {
362
+ kind: target.kind,
363
+ id: target.id,
364
+ findings: [
365
+ {
366
+ severity: "error",
367
+ code: target.kind === "service" ? "service.unreadable" : "feature.unreadable",
368
+ subject: target.id,
369
+ message: `${target.id}: ${path === undefined ? "an artifact" : path} could not be read — ` +
370
+ `nothing about this ${target.kind} was checked. ${reason}`,
371
+ },
372
+ ],
373
+ };
374
+ }
375
+ }
376
+ /**
377
+ * `target.ambiguous` — the positional named a service AND a feature. The tie is
378
+ * still broken the way it always was (the feature wins), because changing which
379
+ * one is picked would silently re-target every script that relies on it; what
380
+ * is new is that the run says which reading it took and how to force the other.
381
+ */
382
+ function ambiguousTarget(arg, chosen) {
383
+ const other = chosen === "feature" ? "service" : "feature";
384
+ return {
385
+ severity: "warn",
386
+ code: "target.ambiguous",
387
+ subject: arg,
388
+ message: `'${arg}' names both a service and a feature — validated as the ${chosen}. ` +
389
+ `Pass --${other} ${arg} for the other reading.`,
390
+ };
391
+ }
392
+ /* ------------------------------------------------------------------ */
393
+ /* Checks — every one produces findings, none of them print */
394
+ /* ------------------------------------------------------------------ */
395
+ /** C4 kinds that model people. A person is never a service directory. */
396
+ const ACTOR_KINDS = new Set(["person", "actor", "user"]);
397
+ /** Tag marking an element as somebody else's system — undocumented on purpose. */
398
+ const EXTERNAL_TAG = "external";
399
+ /**
400
+ * The fleet cross-check: `services/` and the landscape both claim to name the
401
+ * fleet, and nothing used to compare them. A directory nobody drew and an element
402
+ * with nothing behind it were equally invisible.
403
+ *
404
+ * The two directions are graded differently because the evidence differs. A
405
+ * directory that exists is a fact, so a landscape missing it is an error — every
406
+ * view derived from that landscape is then incomplete. An element with no
407
+ * directory may legitimately be someone else's system, so it warns, and
408
+ * `#external` says "deliberately not ours" and silences it. An explicit
409
+ * `metadata { service '<id>' }` naming nothing is an error either way: a binding
410
+ * is a claim about this repo, not a guess at one.
411
+ *
412
+ * An ABSENT landscape is a finding, not a skipped check. It used to return null
413
+ * — no target, no findings, and a fleet gate that went green over a docs repo
414
+ * with no fleet map at all, which is the single artifact every derived view and
415
+ * every spine check is computed from. It is graded by what its absence proves:
416
+ * with services in `services/` it is an ERROR (a fleet that exists is undrawn),
417
+ * with none it is a WARNING (a docs repo before its first adopt legitimately
418
+ * has nothing to draw, but the file still belongs there — `loam init` scaffolds
419
+ * it, and a repo missing it will silently accept never getting one).
420
+ *
421
+ * `preloaded` is the already-parsed landscape under --all — the same doc every
422
+ * service check gets.
423
+ */
424
+ async function validateLandscape(docsDir, preloaded, fleet) {
425
+ const path = landscapeFile(docsDir);
426
+ const findings = [];
427
+ const report = { kind: "landscape", id: "landscape", findings };
428
+ // The SET of service directories is this target's other subject, so it is
429
+ // graded before the map is even opened: `service.id-invalid` is a fact about
430
+ // `services/` that holds whether or not a landscape exists or parses, and
431
+ // both of those return early below. Emitted here and nowhere else — the
432
+ // enumeration is what makes the id a question, and one finding per fleet is
433
+ // what the rename fixes.
434
+ const entries = await listServices(docsDir, fleet);
435
+ findings.push(...serviceIdFindings(entries));
436
+ if (!existsSync(path)) {
437
+ const count = entries.length;
438
+ findings.push({
439
+ severity: count > 0 ? "error" : "warn",
440
+ code: "landscape.missing",
441
+ message: `landscape: architecture/landscape.likec4 does not exist — ` +
442
+ (count > 0
443
+ ? `${count} service(s) are adopted and nothing draws the fleet. `
444
+ : "nothing draws the fleet. ") +
445
+ "It is the one map every derived view and the C4↔API spine are computed from: " +
446
+ "write a `specification { element softwareSystem }` + `model { … }` document there " +
447
+ "with one element per services/<id>/, bound with `metadata { service '<id>' }`.",
448
+ });
449
+ return report;
450
+ }
451
+ // Conflict markers before the parse — `loam doctor`'s order, and for its
452
+ // reason: the markers are the cause and the parse errors are the cascade.
453
+ // Nothing may be concluded from a file that is two halves of two different
454
+ // maps, least of all that a service nobody drew is unmodelled, so this
455
+ // returns the way `landscape.invalid` does. An error, gating by the default
456
+ // rule and deliberately not carrying `gates` — that field is coherence's,
457
+ // and the archive gate has to ask this question itself (it does not yet).
458
+ // Read plainly rather than through FleetContext: this target runs outside
459
+ // `guarded`, and a document refused for its encoding would surface here as
460
+ // 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;
465
+ }
466
+ const land = preloaded ?? (fleet === undefined ? await loadFile(path) : await fleet.loadLikeC4(path));
467
+ if (land.errors.length > 0) {
468
+ // Nothing may be concluded from a document that did not parse — in particular
469
+ // not that every service is unmodelled.
470
+ findings.push({
471
+ severity: "error",
472
+ code: "landscape.invalid",
473
+ message: `landscape: architecture/landscape.likec4 has ${land.errors.length} error(s) — cross-check with services/ impossible`,
474
+ details: land.errors.map(errorText),
475
+ });
476
+ return report;
477
+ }
478
+ const services = new Set(entries.map((s) => s.id));
479
+ // Depth is not a fact about a service. This used to keep only top-level
480
+ // elements (`!e.id.includes(".")`), which ordinary C4 breaks the moment it
481
+ // groups services under a parent: every nested element was thrown away, so a
482
+ // bound service read as unmodelled — an ERROR, on every service in the fleet
483
+ // — and a binding written one level down was never checked at all. The tree
484
+ // is walked instead, the way `serviceResolver` walks it for edges: a binding
485
+ // wins at any depth, then a title naming a real services/<id>/, and what sits
486
+ // INSIDE one of those is that service's container, not a service of its own.
487
+ const byId = new Map(land.elements.map((e) => [e.id, e]));
488
+ /** Declared ancestors of an element, nearest first. */
489
+ const ancestorsOf = (e) => {
490
+ const out = [];
491
+ for (let dot = e.id.lastIndexOf("."); dot !== -1; dot = e.id.lastIndexOf(".", dot - 1)) {
492
+ const parent = byId.get(e.id.slice(0, dot));
493
+ if (parent !== undefined)
494
+ out.push(parent);
495
+ }
496
+ return out;
497
+ };
498
+ const standsForService = (e) => e.service !== undefined || services.has(e.title);
499
+ /** Service-LEVEL elements: everything not drawn inside something that is already a service. */
500
+ const drawn = land.elements.filter((e) => !ancestorsOf(e).some(standsForService));
501
+ // Which services the landscape models, answered by the same resolver every
502
+ // edge join uses — so "modelled" here and "inbound edge" in the spine check
503
+ // can never disagree about what an element stands for.
504
+ const landSvcOf = serviceResolver(land.elements, services);
505
+ const modelled = new Set(land.elements.map((e) => landSvcOf(e.id)));
506
+ // Two boxes standing for one service directory. Every join in loam is
507
+ // `element -> service`, computed by picking the FIRST element that resolves —
508
+ // so with two of them, which one wins is readdir order, and the edges of the
509
+ // loser are attributed to a service they do not belong to. Silent until now,
510
+ // and unfixable by staring at either element on its own.
511
+ const perService = new Map();
512
+ for (const e of drawn) {
513
+ if (e.tags.includes(EXTERNAL_TAG))
514
+ continue;
515
+ const id = elementService(e);
516
+ perService.set(id, [...(perService.get(id) ?? []), e]);
517
+ }
518
+ for (const [id, elems] of perService) {
519
+ if (elems.length < 2)
520
+ continue;
521
+ // A collision only matters where it decides something: a real directory to
522
+ // attribute to, or a binding somebody wrote down on purpose.
523
+ if (!services.has(id) && !elems.some((e) => e.service !== undefined))
524
+ continue;
525
+ findings.push({
526
+ severity: "warn",
527
+ code: "landscape.binding-duplicate",
528
+ subject: id,
529
+ message: `landscape: ${elems.length} elements resolve to service '${id}' (${elems.map((e) => e.id).join(", ")}) — every element→service join picks one of them arbitrarily, so the others' edges are filed under a service that does not own them; keep one element per services/<id>/`,
530
+ });
531
+ }
532
+ for (const id of services) {
533
+ if (modelled.has(id))
534
+ continue;
535
+ findings.push({
536
+ severity: "error",
537
+ code: "landscape.service-unmodelled",
538
+ subject: id,
539
+ message: `landscape: services/${id}/ exists but nothing in architecture/landscape.likec4 models it — add an element, or bind one with metadata { service '${id}' }`,
540
+ });
541
+ }
542
+ // A binding is a claim about this repo wherever it is written — including
543
+ // inside another element, which the old top-level filter never looked at, so
544
+ // a typo one level down bound an edge to a service that does not exist and
545
+ // nothing said so. Every element with a binding answers for it, at any depth.
546
+ for (const e of land.elements) {
547
+ if (e.tags.includes(EXTERNAL_TAG) || e.service === undefined)
548
+ continue;
549
+ if (services.has(e.service))
550
+ continue;
551
+ findings.push({
552
+ severity: "error",
553
+ code: "landscape.binding-unknown",
554
+ subject: e.service,
555
+ message: `landscape: '${e.title}' binds to service '${e.service}', but services/${e.service}/ does not exist`,
556
+ });
557
+ }
558
+ for (const e of drawn) {
559
+ if (e.tags.includes(EXTERNAL_TAG))
560
+ continue;
561
+ if (e.service !== undefined)
562
+ continue; // graded by the binding pass above
563
+ if (ACTOR_KINDS.has(e.kind.toLowerCase()))
564
+ continue;
565
+ if (services.has(e.title))
566
+ continue;
567
+ // An element that CONTAINS a service is a grouping — a domain, a boundary,
568
+ // an enterprise — not a service nobody adopted. There is nothing to bind on
569
+ // it and nothing to tag #external, so asking for either would be a warning
570
+ // with no correct fix; the services under it answer for themselves.
571
+ if (land.elements.some((c) => c.id.startsWith(`${e.id}.`) && standsForService(c)))
572
+ continue;
573
+ findings.push({
574
+ severity: "warn",
575
+ code: "landscape.service-undocumented",
576
+ subject: e.title,
577
+ message: `landscape: '${e.title}' has no services/${e.title}/ — bind it with metadata { service '<id>' }, or tag it #${EXTERNAL_TAG} if it is not ours`,
578
+ });
579
+ }
580
+ if (findings.length === 0) {
581
+ findings.push({
582
+ severity: "ok",
583
+ code: "landscape.matched",
584
+ message: `landscape: ${services.size} service(s) modelled — architecture/landscape.likec4 and services/ agree`,
585
+ });
586
+ }
587
+ return report;
588
+ }
589
+ async function validateService(check) {
590
+ const { docsDir, service, repoDir, preloaded, gherkinDir, fleet } = check;
591
+ const findings = [];
592
+ const report = { kind: "service", id: service, findings };
593
+ const paths = servicePaths(docsDir, service);
594
+ // A directory that does not exist is a different fact from a directory with
595
+ // everything missing: validating a typo must say "typo", not "unadopted".
596
+ if (!existsSync(paths.dir)) {
597
+ const close = closeIds(service, (await listServices(docsDir, fleet)).map((s) => s.id));
598
+ findings.push({
599
+ severity: "error",
600
+ code: "service.unknown",
601
+ message: `No service directory at ${paths.dir}.` +
602
+ (close.length > 0
603
+ ? ` Did you mean: ${close.join(", ")}?`
604
+ : " `loam list services` shows what exists."),
605
+ text: { marker: false },
606
+ });
607
+ return report;
608
+ }
609
+ // C4 model. Its absence is an error — this is where `adopt` comes in — but it
610
+ // must NOT silence the rest of the gate stack: `loam archive` of a feature
611
+ // introducing a new service creates exactly this state (spec.md, arch.spec.md,
612
+ // openapi.yaml, no model.likec4), and an early return here suspended arch
613
+ // coverage, health.uncovered, provenance (content_digest included) and the
614
+ // gherkin staleness chain for the very services vouch had just promised them
615
+ // to. So the finding is emitted and the walk CONTINUES; only the checks that
616
+ // read the model itself are guarded.
617
+ const hasModel = existsSync(paths.model);
618
+ let elements = [];
619
+ let relationships = [];
620
+ if (!hasModel) {
621
+ findings.push({
622
+ severity: "error",
623
+ code: "service.no-model",
624
+ message: `No C4 model at ${paths.model}. Run \`loam adopt\` for '${service}' first.`,
625
+ text: { marker: false },
626
+ });
627
+ }
628
+ else {
629
+ const model = fleet === undefined ? await loadFile(paths.model) : await fleet.loadLikeC4(paths.model);
630
+ elements = model.elements;
631
+ relationships = model.relationships;
632
+ if (model.errors.length > 0) {
633
+ findings.push({
634
+ severity: "error",
635
+ code: "c4.invalid",
636
+ message: `${service}: C4 model has ${model.errors.length} error(s)`,
637
+ details: model.errors.map(errorText),
638
+ });
639
+ }
640
+ else {
641
+ findings.push({
642
+ severity: "ok",
643
+ code: "c4.valid",
644
+ message: `${service}: C4 model valid (${elements.length} elements · ${relationships.length} relationships)`,
645
+ });
646
+ }
647
+ }
648
+ // The living landscape, parsed at most once per run: under --all the caller
649
+ // hands in the doc it already loaded, single-service runs load on demand. It
650
+ // serves two checks below — the no-openapi grace and the spine.
651
+ const land = preloaded ??
652
+ (existsSync(landscapeFile(docsDir))
653
+ ? fleet === undefined
654
+ ? await loadFile(landscapeFile(docsDir))
655
+ : await fleet.loadLikeC4(landscapeFile(docsDir))
656
+ : null);
657
+ // Which service directories actually exist — the positive evidence
658
+ // `serviceOf` needs to resolve an edge drawn into a modelled CONTAINER
659
+ // (`paymentService.api`) back to the service that owns it. Without it every
660
+ // such edge resolves to the container's own title, i.e. to a service nobody
661
+ // has ever adopted, and drops out of the spine unnoticed.
662
+ const known = new Set((await listServices(docsDir, fleet)).map((s) => s.id));
663
+ // Requirement coverage.
664
+ let reqs = [];
665
+ if (existsSync(paths.spec)) {
666
+ const specText = fleet === undefined ? await readFile(paths.spec, "utf8") : await fleet.readText(paths.spec);
667
+ reqs = fleet === undefined ? parseRequirements(specText) : await fleet.readRequirements(paths.spec);
668
+ // Conflict markers first, because they say that nothing below this line is
669
+ // anyone's text. An error, gating by the default rule and deliberately
670
+ // without `gates`: that field is coherence's (issue.ts) and archive does
671
+ // not read validate's findings, so claiming it here would be a promise
672
+ // about a gate that is not wired. What makes it an error rather than a
673
+ // warning is what the next merge does — `loam archive` rewrites the
674
+ // requirements section of this very file and takes whichever marker lines
675
+ // fall inside it, turning a conflict anyone can see into a document nobody
676
+ // can tell is wrong.
677
+ const conflict = documentConflictFinding(`${service}: spec.md`, service, specText);
678
+ if (conflict !== null)
679
+ findings.push(conflict);
680
+ // A living spec with no `### Requirement:` block at all is the baseline
681
+ // `loam adopt` scaffolds and nobody ever filled in. "requirements covered
682
+ // (0 requirements, all with scenarios)" is true of it and says the opposite
683
+ // of what a reader needs: every cross-axis check downstream — API
684
+ // governance, the Operations: spine, gherkin generation — is vacuous here,
685
+ // and the green tick is what let a whole fleet score `sourced` over empty
686
+ // files.
687
+ if (reqs.length === 0) {
688
+ findings.push({
689
+ severity: "warn",
690
+ code: "spec.no-requirements",
691
+ subject: service,
692
+ message: `${service}: spec.md holds no '### Requirement:' blocks — every requirement-driven check below is vacuous, and nothing here can go out of date because nothing is written down`,
693
+ });
694
+ }
695
+ else {
696
+ findings.push(coverageFinding(`${service}: requirements`, reqs));
697
+ }
698
+ // The other half of the coverage question, and an error for the same
699
+ // reason the first half is: the whole claim of coverage is that a
700
+ // requirement has a scenario something can RUN. A heading with no steps
701
+ // satisfies the count and tests nothing — cucumber runs it vacuously green
702
+ // and `loam verify --results` can never confirm it — so a fleet gate that
703
+ // called it covered was certifying the absence of a test.
704
+ findings.push(...steplessFindings(`${service}: requirements`, service, reqs));
705
+ findings.push(...duplicateRequirementFindings(reqs, `${service}: spec.md`, service));
706
+ findings.push(...requirementIdFindings(reqs, `${service}: spec.md`, service));
707
+ findings.push(...repeatedListLineFindings(reqs, `${service}: spec.md`, service));
708
+ }
709
+ else {
710
+ findings.push({
711
+ severity: "warn",
712
+ code: "service.no-spec",
713
+ message: `No living spec at ${paths.spec} — requirement coverage and API governance are unchecked`,
714
+ });
715
+ }
716
+ // API coverage: every operation in openapi.yaml is governed by a requirement.
717
+ const api = await readOpenapi(paths.openapi, fleet);
718
+ const removeMarkers = api.ops.filter((o) => o.remove);
719
+ const liveOps = api.ops.filter((o) => !o.remove);
720
+ const ops = liveOps.map((o) => o.id);
721
+ const deprecatedOps = new Set(liveOps.filter((o) => o.deprecated).map((o) => o.id));
722
+ // The living landscape's element→service resolver, container-aware and
723
+ // memoized. Every question below that asks "does this edge point at me?"
724
+ // asks it through this one function, so the no-openapi grace and the spine
725
+ // check can never disagree about which edges are inbound.
726
+ const landSvcOf = land === null ? null : serviceResolver(land.elements, known);
727
+ // Inbound calls the landscape can PROVE: op-linked edges whose target
728
+ // resolves to this service. Null when the landscape proves nothing at all
729
+ // (absent, or it did not parse) — which is not the same fact as "nobody
730
+ // calls me", and the two must not be graded alike.
731
+ const inboundOps = land === null || land.errors.length > 0
732
+ ? null
733
+ : land.relationships.filter((r) => r.op !== undefined && landSvcOf(r.target) === service);
734
+ // The other half of the evidence a contract is owed: the living spec's own
735
+ // `Operations:` lines. A requirement on its way out governs nothing.
736
+ const governedOps = reqs.filter((r) => r.kind !== "REMOVED").flatMap((r) => r.operations);
737
+ /** No contract to read: deleted, renamed, or never written. */
738
+ const contractMissing = !existsSync(paths.openapi);
739
+ if (contractMissing) {
740
+ // A contract that is gone is not a reason to stop grading the API axis —
741
+ // it IS the axis's answer, and every check below used to live inside the
742
+ // file-exists branch, so deleting or misspelling the file turned the whole
743
+ // axis (including the documented `spec-api.op-undefined`) green.
744
+ //
745
+ // What decides the severity is whether anything already written down is
746
+ // holding a join into it: a living `Operations:` line, or an op-linked
747
+ // landscape edge. With one of those, the absence breaks a link somebody
748
+ // authored — an error. With neither, this is a service that legitimately
749
+ // has no HTTP surface (a worker nobody calls), and a fleet mid-rollout
750
+ // must not go red for it — a warning, or, when the landscape PROVES nobody
751
+ // calls it, the documented silence.
752
+ //
753
+ // One finding, not one per dangling link: the fix is a single file, and
754
+ // the links it strands ride along as details.
755
+ const dangling = [...new Set([...governedOps, ...(inboundOps ?? []).map((r) => r.op)])].sort();
756
+ if (dangling.length > 0) {
757
+ findings.push({
758
+ severity: "error",
759
+ code: "service.no-openapi",
760
+ subject: service,
761
+ message: `No OpenAPI contract at ${paths.openapi}, and ${dangling.length} operation link(s) already point into it — ` +
762
+ `every requirement and landscape edge naming one of them resolves to nothing until the file is back`,
763
+ details: dangling,
764
+ });
765
+ }
766
+ else if (inboundOps === null) {
767
+ findings.push({
768
+ severity: "warn",
769
+ code: "service.no-openapi",
770
+ message: `No OpenAPI contract at ${paths.openapi} — API coverage and the landscape spine are unchecked`,
771
+ });
772
+ }
773
+ }
774
+ else if (api.unreadable) {
775
+ // A contract that EXISTS but does not read is a broken source of truth, not
776
+ // an empty one: swallowing it into zero operations used to grade every
777
+ // inbound landscape edge `spine.op-undefined` — a false diagnosis pointing
778
+ // at the landscape when the truth was this file. So the file is the error,
779
+ // and every check that reads the contract (api.*, the spine's op
780
+ // resolution) is suspended below, the landscape.invalid discipline.
781
+ findings.push({
782
+ severity: "error",
783
+ code: "openapi.invalid",
784
+ message: `${service}: openapi.yaml does not parse — API coverage and the landscape spine are unchecked`,
785
+ ...(api.error === undefined ? {} : { details: [api.error] }),
786
+ });
787
+ }
788
+ else {
789
+ if (removeMarkers.length > 0) {
790
+ findings.push({
791
+ severity: "error",
792
+ code: "openapi.remove-marker-living",
793
+ message: `${service}: living openapi.yaml contains ${removeMarkers.length} x-loam-remove marker(s) (${removeMarkers.map((op) => op.id).join(", ")}) — removal markers are valid only in feature deltas`,
794
+ });
795
+ }
796
+ // The same marker written at PATH level, beside the methods. `readOpenapi`
797
+ // is keyed by (path, method), which is precisely why the check above cannot
798
+ // see this one and precisely how one reached a living contract in the first
799
+ // place: it addresses no operation, so it retired nothing, and it stayed
800
+ // invisible afterwards. Error, like its method-level sibling — a
801
+ // feature-only key published to every consumer of the fleet, and one that
802
+ // keeps the empty-path cleanup from ever firing. Same code as the archive
803
+ // plan's: one breach, one name, wherever it is found.
804
+ for (const removed of api.pathLevelRemovals) {
805
+ findings.push({
806
+ severity: "error",
807
+ code: "openapi.remove-marker-path-level",
808
+ subject: service,
809
+ message: `${service}: living openapi.yaml carries x-loam-remove at PATH level on '${removed}' — ` +
810
+ `a removal marker names ONE operation, so beside the methods it retires nothing and is not a contract key either, ` +
811
+ `and no id-keyed check can see it. Delete it from the living contract; retire an operation through a feature delta whose marker sits inside the operation.`,
812
+ });
813
+ }
814
+ // Two slots claiming one operationId in a LIVING contract. `readOpenapi`
815
+ // has computed this since the merge needed it, and only the feature path
816
+ // (coherence) ever read it — so the ambiguity was reachable only when some
817
+ // unrelated feature happened to carry a delta for this service, and the
818
+ // fleet gate that actually runs in CI was blind to it. Same code and same
819
+ // sentence as the feature-scope check: one breach, one name.
820
+ for (const id of api.duplicateIds) {
821
+ const slots = api.ops.filter((op) => op.id === id).map((op) => `${op.method} ${op.path}`);
822
+ findings.push({
823
+ severity: "warn",
824
+ code: "openapi.duplicate-operationid",
825
+ subject: service,
826
+ message: `${service}: the living OpenAPI defines operationId '${id}' at ${slots.join(" and ")} — every join on the id (a requirement's Operations: line, an edge's metadata { op }, a removal marker) picks one of those slots arbitrarily`,
827
+ });
828
+ }
829
+ const defined = new Set(ops);
830
+ // `Operations:` on a LIVING requirement, resolved against this service's own
831
+ // contract. Nothing did this before: the same spine is checked inside a
832
+ // feature delta (coherence's spec-api.op-undefined) and then never again, so
833
+ // a typo that shipped, or an operation later renamed out of openapi.yaml,
834
+ // left a living requirement governing an operation that does not exist —
835
+ // green forever, and every downstream join through that id silently empty.
836
+ // Same code and severity as the feature-scope check on purpose: one breach,
837
+ // one name, wherever it is found.
838
+ for (const r of reqs) {
839
+ if (r.kind === "REMOVED")
840
+ continue;
841
+ for (const op of r.operations) {
842
+ if (defined.has(op))
843
+ continue;
844
+ const close = closeIds(op, ops);
845
+ findings.push({
846
+ severity: "error",
847
+ code: "spec-api.op-undefined",
848
+ subject: service,
849
+ message: `${service}: requirement '${r.name}' governs '${op}', not defined in ${service}'s OpenAPI` +
850
+ (close.length > 0 ? `. Did you mean: ${close.join(", ")}?` : ""),
851
+ });
852
+ }
853
+ }
854
+ if (ops.length > 0) {
855
+ const governed = new Set(reqs.flatMap((r) => r.operations));
856
+ const orphans = ops.filter((op) => !governed.has(op));
857
+ if (orphans.length === 0) {
858
+ findings.push({
859
+ severity: "ok",
860
+ code: "api.covered",
861
+ message: `${service}: API covered (${ops.length} operation(s) governed by requirements)`,
862
+ });
863
+ }
864
+ else {
865
+ findings.push({
866
+ severity: "warn",
867
+ code: "api.ungoverned",
868
+ message: `${service}: ${orphans.length} operation(s) not governed by any requirement — ${orphans.join(", ")}`,
869
+ });
870
+ }
871
+ // The migration-debt case: requirements exist, the API exists, and no
872
+ // `Operations:` line ties them — every cross-axis check above and in
873
+ // feature mode is vacuously green. Once per service, not per operation;
874
+ // with zero requirements the spec (or its absence) is the finding instead.
875
+ if (reqs.length > 0 && reqs.every((r) => r.operations.length === 0)) {
876
+ findings.push({
877
+ severity: "warn",
878
+ code: "api.ops-unlinked",
879
+ message: `${service}: openapi.yaml defines ${ops.length} operation(s) but no requirement links any — the API axis is unchecked for this service`,
880
+ });
881
+ }
882
+ // Lifecycle: a requirement whose `Operations:` list resolves ONLY to
883
+ // deprecated operations governs behaviour the contract is retiring.
884
+ // Deprecation is the documented first step of removing an op; the explicit
885
+ // feature marker is the final step. Until that delta archives, the op stays
886
+ // live, so the fix is migration or a coordinated retirement. Ops the contract does not define at all
887
+ // prove nothing here and are left to spec-api.op-undefined above.
888
+ for (const r of reqs) {
889
+ const resolved = r.operations.filter((op) => defined.has(op));
890
+ if (resolved.length === 0 || !resolved.every((op) => deprecatedOps.has(op)))
891
+ continue;
892
+ findings.push({
893
+ severity: "warn",
894
+ code: "api.requirement-deprecated",
895
+ message: `${service}: requirement '${r.name}' governs only deprecated operation(s) (${resolved.join(", ")}) — the behaviour it describes is on its way out; migrate it to the replacement operation, or retire it`,
896
+ });
897
+ }
898
+ }
899
+ }
900
+ // Landscape spine: cross-system edges calling THIS service must resolve to a real
901
+ // operation in its OpenAPI — the C4↔API contract, checked in the living landscape,
902
+ // not only in feature mode. Catches dangling / de-linked op edges.
903
+ if (land !== null) {
904
+ if (land.errors.length > 0) {
905
+ // A living landscape that does not parse disables the C4↔API spine check —
906
+ // that is a broken source of truth, not a skippable detail.
907
+ //
908
+ // The parser's own output is attached ONCE per run, and not here when the
909
+ // run has a landscape target to carry it. One syntax error in one file
910
+ // becomes N copies of a dozen cascading diagnostics on a fleet of N
911
+ // services — the report stops being readable at exactly the moment
912
+ // somebody needs to read it, and the fix is one file either way.
913
+ findings.push({
914
+ severity: "error",
915
+ code: "spine.landscape-invalid",
916
+ subject: service,
917
+ message: `${service}: landscape.likec4 has ${land.errors.length} error(s) — spine check impossible` +
918
+ (check.landscapeReported === true
919
+ ? "; the parser output is reported once, on the landscape target"
920
+ : ""),
921
+ ...(check.landscapeReported === true ? {} : { details: land.errors.map(errorText) }),
922
+ });
923
+ }
924
+ else {
925
+ // Which element IS this service is the binding's call, then a title that
926
+ // names a real services/<id>/, and an edge into a modelled container
927
+ // counts as an edge into its service — matching the exact id alone meant a
928
+ // container edge left the spine without a word.
929
+ const svcOf = landSvcOf;
930
+ const opset = new Set(ops);
931
+ let checked = 0;
932
+ let broken = 0;
933
+ for (const r of land.relationships) {
934
+ if (svcOf(r.target) !== service)
935
+ continue;
936
+ if (r.op !== undefined) {
937
+ // A contract that cannot be read — broken, or not there at all —
938
+ // proves nothing about this edge, neither broken nor resolved.
939
+ // Grading the edges against an empty operation set turned ONE root
940
+ // cause (the file) into one `spine.op-undefined` per inbound edge, so
941
+ // a service with twelve consumers reported twelve landscape defects
942
+ // and never named the file. `openapi.invalid` / `service.no-openapi`
943
+ // already did; only op-link-missing (which never reads the contract)
944
+ // stays live, and `checked` stays 0 so no false spine.resolved is
945
+ // claimed either.
946
+ if (api.unreadable || contractMissing)
947
+ continue;
948
+ checked += 1;
949
+ if (!opset.has(r.op)) {
950
+ broken += 1;
951
+ findings.push({
952
+ severity: "error",
953
+ code: "spine.op-undefined",
954
+ message: `${service}: landscape edge ${svcOf(r.source)} → ${service} calls '${r.op}', not defined in ${service}'s OpenAPI`,
955
+ });
956
+ }
957
+ else if (deprecatedOps.has(r.op)) {
958
+ // The contract holds — the op is defined — but it is marked
959
+ // `deprecated: true`: the consumer is standing on a contract being
960
+ // retired, and should be migrating off it. Warn per inbound edge;
961
+ // a deprecated op nobody calls raises no spine finding at all.
962
+ findings.push({
963
+ severity: "warn",
964
+ code: "spine.op-deprecated",
965
+ message: `${service}: landscape edge ${svcOf(r.source)} → ${service} calls '${r.op}', which ${service}'s OpenAPI marks deprecated — the consumer should migrate off it`,
966
+ });
967
+ }
968
+ }
969
+ else if ((r.title ?? "").toLowerCase().startsWith("call")) {
970
+ findings.push({
971
+ severity: "warn",
972
+ code: "spine.op-link-missing",
973
+ message: `${service}: landscape edge ${svcOf(r.source)} → ${service} ("${r.title}") has no operation link (metadata { op })`,
974
+ });
975
+ }
976
+ }
977
+ if (broken === 0 && checked > 0) {
978
+ findings.push({
979
+ severity: "ok",
980
+ code: "spine.resolved",
981
+ message: `${service}: landscape spine (${checked} inbound call(s) resolve to OpenAPI)`,
982
+ });
983
+ }
984
+ }
985
+ }
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);
995
+ const archText = existsSync(paths.archSpec)
996
+ ? fleet === undefined
997
+ ? await readFile(paths.archSpec, "utf8")
998
+ : await fleet.readText(paths.archSpec)
999
+ : null;
1000
+ const archReqs = archText === null
1001
+ ? []
1002
+ : fleet === undefined
1003
+ ? parseRequirements(archText)
1004
+ : await fleet.readRequirements(paths.archSpec);
1005
+ if (archText !== null) {
1006
+ // The arch axis is advisory in what it ASKS for (covers.unknown,
1007
+ // health.uncovered are warnings) but not in whether the file is readable:
1008
+ // both breaches below are about the document itself, and they are graded
1009
+ // exactly as they are on the business axis — one file, one rule.
1010
+ const conflict = documentConflictFinding(`${service}: arch.spec.md`, service, archText);
1011
+ if (conflict !== null)
1012
+ findings.push(conflict);
1013
+ findings.push(coverageFinding(`${service}: arch requirements`, archReqs));
1014
+ findings.push(...steplessFindings(`${service}: arch requirements`, service, archReqs));
1015
+ findings.push(...duplicateRequirementFindings(archReqs, `${service}: arch.spec.md`, service));
1016
+ findings.push(...requirementIdFindings(archReqs, `${service}: arch.spec.md`, service));
1017
+ findings.push(...repeatedListLineFindings(archReqs, `${service}: arch.spec.md`, service));
1018
+ }
1019
+ // A health.yaml that exists but does not read is reported once, and the
1020
+ // checks that read it go quiet BOTH ways: its ids are unknown, so no
1021
+ // health.uncovered obligation exists (the empty id set below is already
1022
+ // silent), and no `Covers: alert:/sli:` entry may be graded a typo against
1023
+ // ids nobody could read (coversUnknownFindings mutes on the flag). A warn,
1024
+ // not an error: the axis it feeds is advisory end to end.
1025
+ if (health.unreadable) {
1026
+ findings.push({
1027
+ severity: "warn",
1028
+ code: "health.invalid",
1029
+ subject: service,
1030
+ message: `${service}: health.yaml does not parse — alert/SLI ids are unreadable, so Covers: alert:/sli: entries and health coverage are unchecked`,
1031
+ ...(health.error === undefined ? {} : { details: [health.error] }),
1032
+ });
1033
+ }
1034
+ const landParses = land !== null && land.errors.length === 0 ? land : null;
1035
+ const scope = {
1036
+ elements: [...elements, ...(landParses?.elements ?? [])],
1037
+ relationships: [...relationships, ...(landParses?.relationships ?? [])],
1038
+ health: health.ids,
1039
+ };
1040
+ findings.push(...coversUnknownFindings(archReqs, `${service}: arch.spec.md`, service, scope, health.unreadable));
1041
+ const activeCovers = coversEntries(archReqs);
1042
+ for (const { form, ids } of [
1043
+ { form: "alert", ids: health.ids.alerts },
1044
+ { form: "sli", ids: health.ids.slis },
1045
+ ]) {
1046
+ for (const id of ids) {
1047
+ if (activeCovers.some((e) => e.form === form && e.id === id))
1048
+ continue;
1049
+ findings.push({
1050
+ severity: "warn",
1051
+ code: "health.uncovered",
1052
+ subject: service,
1053
+ message: `${service}: health.yaml declares ${form === "alert" ? "alert" : "SLI"} '${id}' but no arch.spec.md requirement covers it — write one with 'Covers: ${form}:${id}', or the signal ships with nothing testing it`,
1054
+ });
1055
+ }
1056
+ }
1057
+ // Provenance last: who vouched for this, and what code it was written from.
1058
+ findings.push(...(await serviceProvenance(docsDir, service, { repoDir })));
1059
+ findings.push(...(await sourceScopeFindings(docsDir, service, repoDir)));
1060
+ // The generated-gherkin freshness chain, service-repo-scoped like sources.*:
1061
+ // it needs the repo (the suite lives there), and it stays quiet until
1062
+ // <gherkinDir>/loam/ exists — a service that never generated has not opted in.
1063
+ findings.push(...(await gherkinFindings({ docsDir, service, repoDir, gherkinDir, fleet })));
1064
+ return report;
1065
+ }
1066
+ /** The parsed Covers entries of every requirement that will live (REMOVED covers nothing). */
1067
+ function coversEntries(reqs) {
1068
+ return reqs.filter((r) => r.kind !== "REMOVED").flatMap((r) => r.covers.map(parseCoversEntry));
1069
+ }
1070
+ /**
1071
+ * `spec.duplicate-requirement` — two `### Requirement:` blocks with one name in
1072
+ * one LIVING document. Nothing else catches it, and the merge algebra
1073
+ * (applyRequirementDelta) matches by name and edits only the FIRST match: a
1074
+ * later archive rewrites one copy and the other survives as a stale snapshot
1075
+ * of whatever the requirement used to say. Per file on purpose — spec.md and
1076
+ * arch.spec.md are separate requirement namespaces (their merges never cross
1077
+ * files), so one name appearing in both is legal and unflagged.
1078
+ */
1079
+ function duplicateRequirementFindings(reqs, where, subject) {
1080
+ const counts = new Map();
1081
+ for (const r of reqs)
1082
+ counts.set(r.name, (counts.get(r.name) ?? 0) + 1);
1083
+ return [...counts]
1084
+ .filter(([, n]) => n > 1)
1085
+ .map(([name, n]) => ({
1086
+ severity: "error",
1087
+ code: "spec.duplicate-requirement",
1088
+ subject,
1089
+ message: `${where}: requirement '${name}' is defined ${n} times — a merge edits only the first, every other copy lives on stale; keep exactly one`,
1090
+ }));
1091
+ }
1092
+ /** Stable IDs are optional, but once authored they must select exactly one requirement. */
1093
+ function requirementIdFindings(reqs, where, subject) {
1094
+ return requirementIdProblems(reqs).map((problem) => {
1095
+ if (problem.kind === "invalid") {
1096
+ return {
1097
+ severity: "error",
1098
+ code: "spec.requirement-id-invalid",
1099
+ subject,
1100
+ message: `${where}: requirement '${problem.requirement}' has invalid Requirement-ID '${problem.value}' — use 1-128 characters matching [A-Za-z][A-Za-z0-9._-]*`,
1101
+ };
1102
+ }
1103
+ if (problem.kind === "repeated") {
1104
+ return {
1105
+ severity: "error",
1106
+ code: "spec.requirement-id-repeated",
1107
+ subject,
1108
+ message: `${where}: requirement '${problem.requirement}' declares Requirement-ID ${problem.values.length} times — identity must be declared exactly once`,
1109
+ };
1110
+ }
1111
+ return {
1112
+ severity: "error",
1113
+ code: "spec.requirement-id-duplicate",
1114
+ subject,
1115
+ message: `${where}: Requirement-ID '${problem.id}' is shared by ${problem.requirements.map((name) => `'${name}'`).join(", ")} — one ID may identify only one requirement`,
1116
+ };
1117
+ });
1118
+ }
1119
+ /**
1120
+ * The two list lines of the requirement grammar, exactly as core/spec.ts spells
1121
+ * them (mirrored here, not exported from there, because the parser's grammar is
1122
+ * its own; a drift shows up as this check counting differently than the parser
1123
+ * assigns). A SECOND matching line in one requirement body REPLACES the first —
1124
+ * assignment, not append, the documented keep-last quirk — so the author's
1125
+ * "long list in two lines" pattern silently loses its first line.
1126
+ */
1127
+ const OPERATIONS_LINE_RE = /^\s*Operations?:\s*(.+?)\s*$/i;
1128
+ const COVERS_LINE_RE = /^\s*Covers?:\s*(.+?)\s*$/i;
1129
+ /**
1130
+ * `spec.repeated-operations` / `spec.repeated-covers` — warn on the silent
1131
+ * loss, keep the keep-last semantics (changing them would re-read every spec
1132
+ * in the fleet). Scenario bodies never count: the parser only assigns from the
1133
+ * requirement's own body lines, and `Requirement.text` is exactly those.
1134
+ * REMOVED requirements are exempt the way coversEntries exempts them — content
1135
+ * on its way out obliges nothing.
1136
+ */
1137
+ function repeatedListLineFindings(reqs, where, subject) {
1138
+ const out = [];
1139
+ for (const r of reqs) {
1140
+ if (r.kind === "REMOVED")
1141
+ continue;
1142
+ for (const { re, label, code } of [
1143
+ { re: OPERATIONS_LINE_RE, label: "Operations:", code: "spec.repeated-operations" },
1144
+ { re: COVERS_LINE_RE, label: "Covers:", code: "spec.repeated-covers" },
1145
+ ]) {
1146
+ const n = r.text.filter((line) => re.test(line)).length;
1147
+ if (n < 2)
1148
+ continue;
1149
+ out.push({
1150
+ severity: "warn",
1151
+ code,
1152
+ subject,
1153
+ message: `${where}: requirement '${r.name}' has ${n} '${label}' lines — the last REPLACES the others (assignment, not append), the earlier list is silently lost; merge them into one comma-separated line`,
1154
+ });
1155
+ }
1156
+ }
1157
+ return out;
1158
+ }
1159
+ /**
1160
+ * `covers.unknown` — the typo guard on the Covers: line. Warn, not error: the
1161
+ * axis is advisory end to end, and a wrong id already costs its author the
1162
+ * coverage they wrote the line for. The hint offers only real ids (closeIds's
1163
+ * rule), and says where resolution looked when there is nothing close.
1164
+ * `healthUnreadable` mutes the alert:/sli: forms only: against a health.yaml
1165
+ * nobody could read, "did you mean" is a false diagnosis of a typo —
1166
+ * health.invalid (emitted by the service target) is the honest one.
1167
+ */
1168
+ function coversUnknownFindings(reqs, where, subject, scope, healthUnreadable = false) {
1169
+ const out = [];
1170
+ for (const r of reqs) {
1171
+ if (r.kind === "REMOVED")
1172
+ continue;
1173
+ for (const raw of r.covers) {
1174
+ const entry = parseCoversEntry(raw);
1175
+ if (healthUnreadable && (entry.form === "alert" || entry.form === "sli"))
1176
+ continue;
1177
+ if (entryResolves(entry, scope))
1178
+ continue;
1179
+ const close = coversCandidates(entry, scope);
1180
+ out.push({
1181
+ severity: "warn",
1182
+ code: "covers.unknown",
1183
+ subject,
1184
+ message: `${where}: requirement '${r.name}' — Covers: '${raw}' resolves to nothing` +
1185
+ (close.length > 0
1186
+ ? `. Did you mean: ${close.join(", ")}?`
1187
+ : " in the model, the landscape or health.yaml"),
1188
+ });
1189
+ }
1190
+ }
1191
+ return out;
1192
+ }
1193
+ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
1194
+ const findings = [];
1195
+ const featureDir = feature.dir;
1196
+ const featureId = feature.id;
1197
+ // delta.likec4 parse + collect tagged edges. The loaded doc is kept and
1198
+ // handed to featureCoherence below — loading it is a Langium workspace spin,
1199
+ // and paying it twice per feature was the dominant cost of `validate --all`.
1200
+ let taggedEls = [];
1201
+ let taggedRels = [];
1202
+ let elements = [];
1203
+ let deltaRels = [];
1204
+ let deltaDoc;
1205
+ const deltaPath = featurePaths(featureDir).delta;
1206
+ if (existsSync(deltaPath)) {
1207
+ const res = fleet === undefined ? await loadFile(deltaPath) : await fleet.loadLikeC4(deltaPath);
1208
+ deltaDoc = res;
1209
+ if (res.errors.length > 0) {
1210
+ findings.push({
1211
+ severity: "error",
1212
+ code: "delta.invalid",
1213
+ message: `delta.likec4 has ${res.errors.length} error(s)`,
1214
+ details: res.errors.map(errorText),
1215
+ });
1216
+ }
1217
+ else {
1218
+ elements = res.elements;
1219
+ deltaRels = res.relationships;
1220
+ taggedEls = res.elements.filter((e) => e.tags.includes(featureId));
1221
+ taggedRels = res.relationships.filter((r) => r.tags.includes(featureId));
1222
+ findings.push({
1223
+ severity: "ok",
1224
+ code: "delta.valid",
1225
+ message: `delta.likec4 valid (${res.elements.length} elements · ${res.relationships.length} relationships)`,
1226
+ });
1227
+ }
1228
+ }
1229
+ findings.push(...(await featureProvenance(featureDir, featureId)));
1230
+ // Who this feature is allowed to address. `specs/<svc>/` is what the archive
1231
+ // materialises `services/<svc>/` from, and nothing used to ask whether that
1232
+ // name means anything: one wrong character in `--touches` passed
1233
+ // `validate --all` with zero errors, and archive then created the phantom
1234
+ // directory. A delta may legitimately name a service that does not exist yet
1235
+ // — but only one it INTRODUCES itself, in its own tagged C4. A delta that did
1236
+ // not parse proves neither (`delta.invalid` is that finding), so the question
1237
+ // is suspended there rather than answered by guessing.
1238
+ //
1239
+ // `services/<svc>/` is asked for directly rather than through the
1240
+ // enumeration: `validate --feature` is allowed to run in a docs repo with no
1241
+ // services/ at all (repo.ts takes the same position), where enumerating is a
1242
+ // refusal, not an answer.
1243
+ const featureServices = await featureSpecServices(featureDir, fleet);
1244
+ const introduces = new Set(taggedEls.map(elementService));
1245
+ const deltaReadable = deltaDoc === undefined || deltaDoc.errors.length === 0;
1246
+ const unknownServices = deltaReadable
1247
+ ? featureServices.filter((svc) => !existsSync(servicePaths(docsDir, svc).dir) && !introduces.has(svc))
1248
+ : [];
1249
+ // The near-miss hint, on the same rule `service.unknown` uses — a typo is
1250
+ // only diagnosable against the ids that DO exist.
1251
+ const closeTo = unknownServices.length > 0 && docsRepoState(docsDir).kind === "ok"
1252
+ ? (await listServices(docsDir, fleet)).map((s) => s.id)
1253
+ : [];
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
+ }
1265
+ // Requirement coverage across every per-service delta — the business spec and
1266
+ // the arch spec through the same check — and collect scenario text.
1267
+ let scenarioText = "";
1268
+ const archDeltas = [];
1269
+ for (const svc of featureServices) {
1270
+ const p = featureSpecPaths(featureDir, svc);
1271
+ if (existsSync(p.spec)) {
1272
+ const raw = fleet === undefined ? await readFile(p.spec, "utf8") : await fleet.readText(p.spec);
1273
+ scenarioText += "\n" + raw.toLowerCase();
1274
+ const reqs = fleet === undefined ? parseRequirements(raw) : await fleet.readRequirements(p.spec);
1275
+ // Both document-level breaches carry into the living spec through the
1276
+ // merge, so a delta is graded for them exactly as a living document is:
1277
+ // conflict markers merge as prose under someone's requirement, and a
1278
+ // stepless scenario merges as a requirement the coverage rule calls
1279
+ // covered forever after.
1280
+ const conflict = documentConflictFinding(`${svc}: spec.md`, svc, raw);
1281
+ if (conflict !== null)
1282
+ findings.push(conflict);
1283
+ findings.push({ ...coverageFinding(`${svc}: requirements`, reqs), subject: svc });
1284
+ findings.push(...steplessFindings(`${svc}: requirements`, svc, reqs));
1285
+ // The keep-last quirk loses lines in a delta exactly as in a living spec
1286
+ // — and a delta's lost Operations: line then merges into the living one.
1287
+ findings.push(...repeatedListLineFindings(reqs, `${svc}: spec.md`, svc));
1288
+ }
1289
+ if (existsSync(p.archSpec)) {
1290
+ const raw = fleet === undefined ? await readFile(p.archSpec, "utf8") : await fleet.readText(p.archSpec);
1291
+ scenarioText += "\n" + raw.toLowerCase();
1292
+ const reqs = fleet === undefined ? parseRequirements(raw) : await fleet.readRequirements(p.archSpec);
1293
+ archDeltas.push({ service: svc, reqs });
1294
+ const conflict = documentConflictFinding(`${svc}: arch.spec.md`, svc, raw);
1295
+ if (conflict !== null)
1296
+ findings.push(conflict);
1297
+ findings.push({ ...coverageFinding(`${svc}: arch requirements`, reqs), subject: svc });
1298
+ findings.push(...steplessFindings(`${svc}: arch requirements`, svc, reqs));
1299
+ findings.push(...repeatedListLineFindings(reqs, `${svc}: arch.spec.md`, svc));
1300
+ }
1301
+ }
1302
+ // Arch-edge coverage (heuristic, warn-only): each new tagged edge should be named by a scenario.
1303
+ for (const r of taggedRels) {
1304
+ const target = serviceOf(elements, r.target);
1305
+ const covered = edgeCovered(target, r.title, scenarioText);
1306
+ findings.push({
1307
+ severity: covered ? "ok" : "warn",
1308
+ code: covered ? "archedge.covered" : "archedge.uncovered",
1309
+ subject: target,
1310
+ message: `${serviceOf(elements, r.source)} → ${target} "${r.title ?? ""}"${covered ? "" : " — no scenario names it"}`,
1311
+ text: { indent: 4, header: "arch-edge coverage (heuristic):" },
1312
+ });
1313
+ }
1314
+ // The architecture spec axis, feature scope — the mechanical counterpart of
1315
+ // the heuristic above. Every NEW tagged element and edge in the delta wants a
1316
+ // `Covers:` line in one of the feature's arch.spec.md deltas (c4.uncovered):
1317
+ // this is where agent-built code cuts its corners — the outbox, the retries,
1318
+ // the alerts — because no business scenario was ever going to mention them.
1319
+ // Grouping-only elements follow the landscape checks' exemptions (person
1320
+ // kinds, #external). Warnings, never archive gates; `--strict` escalates.
1321
+ //
1322
+ // Only requirements the archive will MERGE grant coverage here. In a delta,
1323
+ // BASE means "the living state, quoted": it merges nothing, emits no
1324
+ // .feature, and yields no scenario.tested claim — so a Covers: line under a
1325
+ // plain `## Requirements` quote is an obligation that ships nowhere, and
1326
+ // counting it silenced c4.uncovered for free. (The service-scope pass keeps
1327
+ // the unfiltered call: a LIVING spec is legitimately all BASE.)
1328
+ const activeCovers = archDeltas.flatMap(({ reqs }) => coversEntries(reqs.filter((r) => r.kind === "ADDED" || r.kind === "MODIFIED")));
1329
+ // What the living landscape ALREADY holds. A delta has to re-declare the
1330
+ // elements its new edges attach to, and authors tag those re-declarations
1331
+ // along with everything else — so a requirements-only feature that touches an
1332
+ // existing service was told to write `Covers:` lines for architecture it is
1333
+ // not adding. c4.uncovered is an obligation on NEW architecture; an element
1334
+ // the living landscape already resolves is not new, whatever the tag says.
1335
+ // Loaded lazily: a delta with nothing tagged never pays for the parse.
1336
+ let living = preloadedLand;
1337
+ const livingLandscape = async () => {
1338
+ if (living === undefined) {
1339
+ const lp = landscapeFile(docsDir);
1340
+ living = existsSync(lp)
1341
+ ? fleet === undefined
1342
+ ? await loadFile(lp)
1343
+ : await fleet.loadLikeC4(lp)
1344
+ : null;
1345
+ }
1346
+ return living;
1347
+ };
1348
+ const alreadyLiving = async () => {
1349
+ if (taggedEls.length === 0 && taggedRels.length === 0)
1350
+ return null;
1351
+ const doc = await livingLandscape();
1352
+ return doc !== null && doc.errors.length === 0 ? doc : null;
1353
+ };
1354
+ const base = await alreadyLiving();
1355
+ const baseSvcOf = base === null ? null : serviceResolver(base.elements);
1356
+ const baseIds = new Set(base?.elements.map((e) => e.id) ?? []);
1357
+ const baseServices = new Set((base?.elements ?? []).map(elementService));
1358
+ // How a service→service pair is keyed, spelled ONCE. The two sides used to
1359
+ // join with different separators — a NUL where the set was built, a space
1360
+ // where it was queried — so the exemption never matched anything, and every
1361
+ // edge a delta re-declares verbatim (which it must, to attach anything to
1362
+ // it) was reported as new architecture nobody covers. The join is structural
1363
+ // rather than a separator character: the last one was a NUL, and a raw NUL in
1364
+ // a template literal makes the source read as binary to `file` and invisible
1365
+ // to `grep` — which is how a one-character mismatch survived review.
1366
+ const edgeKey = (source, target) => JSON.stringify([source, target]);
1367
+ const baseEdges = new Set((base?.relationships ?? []).map((r) => edgeKey(baseSvcOf(r.source), baseSvcOf(r.target))));
1368
+ for (const e of taggedEls) {
1369
+ if (ACTOR_KINDS.has(e.kind.toLowerCase()) || e.tags.includes(EXTERNAL_TAG))
1370
+ continue;
1371
+ if (baseIds.has(e.id) || baseServices.has(elementService(e)))
1372
+ continue;
1373
+ if (activeCovers.some((c) => coversElement(c, e)))
1374
+ continue;
1375
+ findings.push({
1376
+ severity: "warn",
1377
+ code: "c4.uncovered",
1378
+ subject: elementService(e),
1379
+ message: `delta adds '${e.title}' (${e.id}) but no arch requirement covers it — add 'Covers: ${e.id}' to a specs/<svc>/arch.spec.md delta, or its architectural obligations ship unchecked`,
1380
+ });
1381
+ }
1382
+ for (const r of taggedRels) {
1383
+ if (baseEdges.has(edgeKey(serviceOf(elements, r.source), serviceOf(elements, r.target))))
1384
+ continue;
1385
+ if (activeCovers.some((c) => coversEdge(c, r, elements)))
1386
+ continue;
1387
+ findings.push({
1388
+ severity: "warn",
1389
+ 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`,
1392
+ });
1393
+ }
1394
+ // covers.unknown, feature scope. Resolution looks at the delta itself, the
1395
+ // living landscape, the service's own model and its health.yaml — a delta's
1396
+ // arch requirement may cover an element it adds, one that already exists, or
1397
+ // an alert the service declares. The landscape and each model are loaded
1398
+ // lazily, and only when an entry fails against what is already in hand: the
1399
+ // clean path never pays for a workspace spin.
1400
+ if (archDeltas.some(({ reqs }) => coversEntries(reqs).length > 0)) {
1401
+ const land = await livingLandscape();
1402
+ const landParses = land !== null && land.errors.length === 0 ? land : null;
1403
+ const baseElements = [...elements, ...(landParses?.elements ?? [])];
1404
+ const baseRels = [...deltaRels, ...(landParses?.relationships ?? [])];
1405
+ for (const { service: svc, reqs } of archDeltas) {
1406
+ // An unreadable living health.yaml mutes the alert:/sli: entries here
1407
+ // exactly as in service scope — the health.invalid finding itself
1408
+ // belongs to the service target, which owns the file's diagnosis.
1409
+ const health = await readHealth(servicePaths(docsDir, svc).health);
1410
+ let scope = { elements: baseElements, relationships: baseRels, health: health.ids };
1411
+ const unresolved = coversUnknownFindings(reqs, `${svc}: arch.spec.md`, svc, scope, health.unreadable);
1412
+ if (unresolved.length > 0) {
1413
+ const modelPath = servicePaths(docsDir, svc).model;
1414
+ const model = existsSync(modelPath)
1415
+ ? fleet === undefined
1416
+ ? await loadFile(modelPath)
1417
+ : await fleet.loadLikeC4(modelPath)
1418
+ : null;
1419
+ if (model !== null && model.errors.length === 0) {
1420
+ scope = {
1421
+ elements: [...baseElements, ...model.elements],
1422
+ relationships: [...baseRels, ...model.relationships],
1423
+ health: health.ids,
1424
+ };
1425
+ }
1426
+ findings.push(...coversUnknownFindings(reqs, `${svc}: arch.spec.md`, svc, scope, health.unreadable));
1427
+ }
1428
+ }
1429
+ }
1430
+ // Coherence — cross-axis consistency (C4 ↔ requirements ↔ OpenAPI).
1431
+ const issues = await featureCoherence(docsDir, featureDir, featureId, deltaDoc, fleet);
1432
+ if (issues.length === 0) {
1433
+ findings.push({
1434
+ severity: "ok",
1435
+ code: "coherence.ok",
1436
+ message: "coherence: ✓ C4 · requirements · OpenAPI agree",
1437
+ text: { indent: 2, marker: false },
1438
+ });
1439
+ }
1440
+ else {
1441
+ for (const i of issues) {
1442
+ findings.push({
1443
+ severity: i.severity,
1444
+ code: i.code,
1445
+ gates: gatesArchive(i),
1446
+ ...(i.subject === undefined ? {} : { subject: i.subject }),
1447
+ message: i.message,
1448
+ text: { indent: 4, header: "coherence:" },
1449
+ });
1450
+ }
1451
+ }
1452
+ return { kind: "feature", id: featureId, findings };
1453
+ }
1454
+ function coverageFinding(label, reqs) {
1455
+ const missing = requirementsMissingScenarios(reqs);
1456
+ if (missing.length === 0) {
1457
+ return {
1458
+ severity: "ok",
1459
+ code: "requirements.covered",
1460
+ message: `${label} covered (${reqs.length} requirement${reqs.length === 1 ? "" : "s"}, all with scenarios)`,
1461
+ };
1462
+ }
1463
+ return {
1464
+ severity: "error",
1465
+ code: "requirements.missing-scenarios",
1466
+ message: `${label}: ${missing.length} requirement(s) without a scenario`,
1467
+ details: missing.map((r) => r.name),
1468
+ text: { detailPrefix: "- " },
1469
+ };
1470
+ }
1471
+ /* ------------------------------------------------------------------ */
1472
+ /* Text renderer */
1473
+ /* ------------------------------------------------------------------ */
1474
+ const MARKER = { ok: "✓", warn: "⚠", error: "✗" };
1475
+ /**
1476
+ * `--errors-only` is a RENDERING lever, the way `--strict` is an exit-code
1477
+ * lever: neither changes the report, and the `--json` payload is unaffected by
1478
+ * both. On a fleet of a hundred services the clean run prints several hundred
1479
+ * `ok` confirmations, and the two warnings that matter are somewhere inside it;
1480
+ * anyone reading a CI log wants the exceptions, and anyone auditing wants all
1481
+ * of it. Both are available, from the same run, and neither is the default.
1482
+ */
1483
+ function renderText(targets, all, unverifiable, errorsOnly) {
1484
+ for (const t of targets) {
1485
+ const shown = errorsOnly ? t.findings.filter((f) => f.severity !== "ok") : t.findings;
1486
+ if (shown.length === 0)
1487
+ continue;
1488
+ // A feature announces itself; a service's findings already carry its name.
1489
+ if (t.kind === "feature")
1490
+ console.log(t.id);
1491
+ let header;
1492
+ for (const f of shown) {
1493
+ const hint = f.text ?? {};
1494
+ if (hint.header && hint.header !== header) {
1495
+ header = hint.header;
1496
+ console.log(` ${header}`);
1497
+ }
1498
+ // The whole report goes to stdout, in document order. Splitting errors
1499
+ // onto stderr meant a piped stdout silently lost them from the middle of
1500
+ // the report and 2>&1 could reorder it; the exit code carries failure,
1501
+ // and stderr stays reserved for refusals (the fail() path).
1502
+ const marker = hint.marker === false ? "" : `${MARKER[f.severity]} `;
1503
+ console.log(`${" ".repeat(hint.indent ?? 0)}${marker}${f.message}`);
1504
+ for (const d of f.details ?? [])
1505
+ console.log(` ${hint.detailPrefix ?? ""}${d}`);
1506
+ }
1507
+ }
1508
+ if (!all) {
1509
+ // Without the --all footer there would be nothing at all to print for a
1510
+ // clean single target under --errors-only — and silence is the one output
1511
+ // that must never mean "checked, fine".
1512
+ if (errorsOnly && targets.every((t) => t.findings.every((f) => f.severity === "ok"))) {
1513
+ console.log(`${targets.map((t) => t.id).join(", ")}: no errors or warnings`);
1514
+ }
1515
+ return;
1516
+ }
1517
+ const s = summary(targets);
1518
+ const plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
1519
+ console.log(`\n${plural(s.services, "service")}, ${plural(s.features, "feature")} — ` +
1520
+ `${plural(s.errors, "error")}, ${plural(s.warnings, "warning")}`);
1521
+ // One line for the whole fleet, never one per service: honest about the blind
1522
+ // spot without drowning the report in a hundred copies of it.
1523
+ if (unverifiable > 0) {
1524
+ const whose = unverifiable === 1 ? "1 service's" : `${unverifiable} services'`;
1525
+ console.log(`⚠ sources.unverifiable-from-here: ${whose} sources can only be checked from their own repos`);
1526
+ }
1527
+ }
1528
+ /* ------------------------------------------------------------------ */
1529
+ /**
1530
+ * The two things a `sources` list can be that `serviceProvenance` cannot say.
1531
+ *
1532
+ * `sources.unverifiable-from-here` — the spec names sources and loam is NOT in
1533
+ * that service's repository, so every sources check (existence, digest,
1534
+ * staleness) is skipped. serviceProvenance returns an empty list in that case
1535
+ * and the silence read as "checked and fine". It used to be counted only under
1536
+ * `--all` and printed as one rollup line, which meant `validate --service X`
1537
+ * run from the docs repo — the single most common way anyone looks at one
1538
+ * service — reported nothing at all about its own blind spot. Severity `ok`,
1539
+ * deliberately: nothing is WRONG with the docs, the check simply cannot run
1540
+ * here, and grading it a warning would make a correctly-adopted fleet
1541
+ * permanently yellow and `--strict` permanently red in the docs repo's CI.
1542
+ *
1543
+ * `sources.empty` — the paths exist, and expand to no files at all: an empty
1544
+ * directory, or a tree the repository itself ignores. A digest over nothing
1545
+ * never changes, so the stamp would read as current forever. `loam vouch`
1546
+ * already refuses to stamp it; until now `validate` said nothing, so an author
1547
+ * got a green run followed by a refusal, two commands contradicting each other
1548
+ * about one document. The sentence comes from `emptySourcesMessage`, the same
1549
+ * definition vouch refuses with, under the label vouch uses.
1550
+ */
1551
+ async function sourceScopeFindings(docsDir, service, repoDir) {
1552
+ const paths = servicePaths(docsDir, service);
1553
+ const out = [];
1554
+ for (const { path, file } of [
1555
+ { path: paths.spec, file: "spec.md" },
1556
+ { path: paths.archSpec, file: "arch.spec.md" },
1557
+ ]) {
1558
+ if (!existsSync(path))
1559
+ continue;
1560
+ const sources = listField(await readFrontmatter(path), "sources");
1561
+ if (sources.length === 0)
1562
+ continue;
1563
+ // vouch's own labelling: a bare service id for spec.md, qualified for the
1564
+ // arch axis. The refusal and the finding must be the same sentence.
1565
+ const label = file === "spec.md" ? service : `${service}: ${file}`;
1566
+ if (repoDir === undefined) {
1567
+ out.push({
1568
+ severity: "ok",
1569
+ code: UNVERIFIABLE,
1570
+ subject: service,
1571
+ message: `${label}: ${sources.length} source(s) declared, but this is not ${service}'s repository — nothing here can resolve them. Run \`loam validate --service ${service}\` from inside it.`,
1572
+ });
1573
+ continue;
1574
+ }
1575
+ // Every other shape of broken list is serviceProvenance's to grade, and
1576
+ // grading them twice would send an author fixing one thing from two
1577
+ // findings. "Covers no files" is only meaningful once the paths are real.
1578
+ if (patternSources(sources).length > 0 ||
1579
+ unsafeSources(repoDir, sources).length > 0 ||
1580
+ missingSources(repoDir, sources).length > 0) {
1581
+ continue;
1582
+ }
1583
+ const expansion = await expandSourceFiles(repoDir, sources, label);
1584
+ if (expansion.files.length > 0)
1585
+ continue;
1586
+ out.push({
1587
+ severity: "warn",
1588
+ code: "sources.empty",
1589
+ subject: service,
1590
+ message: expansion.empty ?? emptySourcesMessage(label, sources),
1591
+ });
1592
+ }
1593
+ return out;
1594
+ }
1595
+ /** Heuristic: an edge is "covered" if a scenario names the target or a keyword from the edge title. */
1596
+ function edgeCovered(target, title, scenarioText) {
1597
+ if (scenarioText.includes(target.toLowerCase()))
1598
+ return true;
1599
+ for (const token of (title ?? "").split(/[^A-Za-z0-9]+/)) {
1600
+ if (token.length >= 5 && scenarioText.includes(token.toLowerCase()))
1601
+ return true;
1602
+ }
1603
+ return false;
1604
+ }
1605
+ function errorText(e) {
1606
+ return typeof e.line === "number" ? `L${e.line}: ${e.message}` : e.message;
1607
+ }
1608
+ export { targetValid };
1609
+ //# sourceMappingURL=validate.js.map