@ybotok/loam 0.0.0 → 0.1.0-beta.2

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