@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,553 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { elementService, loadFile, serviceOf, serviceResolver, } from "./likec4.js";
4
+ import { closeIds } from "./arch.js";
5
+ import { deltaShapeIssues } from "./delta.js";
6
+ import { repoPath } from "./json.js";
7
+ import { docsRepoState, featurePaths, featureSpecPaths, featureSpecServices, landscapePath, listFeatures, listServices, servicePaths, SPEC_AXES, } from "./repo.js";
8
+ import { parseRequirements } from "./spec.js";
9
+ import { OPENAPI_BASELINE_KEY, OPERATION_DIGEST_LENGTH, OPERATION_DIGEST_RE, classifyBaselineDigests, operations, readOpenapi, serviceOperationIds, } from "./openapi.js";
10
+ import { documentConflictFinding, landscapeConflictFinding, } from "./fleet-context.js";
11
+ /**
12
+ * Cross-axis consistency for a feature: do C4 (architecture), requirements (behaviour),
13
+ * and OpenAPI (contract) agree? Errors are hard (would corrupt the living docs on archive);
14
+ * warnings surface softer misalignments.
15
+ *
16
+ * `preloadedDelta` is the feature's already-parsed delta.likec4, when the caller
17
+ * has it — loading a LikeC4 document spins up a fresh Langium workspace, the
18
+ * dominant per-feature cost, and validate/archive have both parsed the same file
19
+ * moments earlier. Standalone calls omit it and nothing changes: the file is
20
+ * loaded here, exactly as before.
21
+ */
22
+ export async function featureCoherence(docsDir, featureDir, featureId, preloadedDelta, context) {
23
+ // Delta shape first: a diff that does not apply to the living spec explains
24
+ // everything downstream, and it is the one breach that is silent without a check.
25
+ const issues = await deltaShapeIssues(docsDir, featureDir, featureId, context);
26
+ // --- C4 delta ---
27
+ let elements = [];
28
+ let taggedEls = [];
29
+ let taggedRels = [];
30
+ const deltaPath = featurePaths(featureDir).delta;
31
+ if (existsSync(deltaPath)) {
32
+ const res = preloadedDelta ?? (context === undefined ? await loadFile(deltaPath) : await context.loadLikeC4(deltaPath));
33
+ if (res.errors.length > 0) {
34
+ // An unreadable architecture axis can prove nothing — it must never count as coherent.
35
+ issues.push({
36
+ severity: "error",
37
+ code: "delta.invalid",
38
+ message: `delta.likec4 has ${res.errors.length} parse error(s) — architecture axis unreadable (run \`loam validate --feature ${featureId}\`)`,
39
+ });
40
+ }
41
+ else {
42
+ elements = res.elements;
43
+ taggedEls = res.elements.filter((e) => e.tags.includes(featureId));
44
+ taggedRels = res.relationships.filter((r) => r.tags.includes(featureId));
45
+ // Every rule below filters by the feature tag, so a delta whose author
46
+ // forgot the tags entirely passes every check and archives while merging
47
+ // nothing. Whole-file only: once ANYTHING is tagged, deciding that an
48
+ // untagged edge "looks intended" would be guessing.
49
+ if ((res.elements.length > 0 || res.relationships.length > 0) &&
50
+ taggedEls.length === 0 &&
51
+ taggedRels.length === 0) {
52
+ issues.push({
53
+ severity: "error",
54
+ code: "delta.nothing-tagged",
55
+ message: `delta.likec4 declares elements/relationships but none are tagged #${featureId}; loam cannot see untagged changes`,
56
+ });
57
+ }
58
+ }
59
+ }
60
+ // Every axis below joins on the service id, so endpoints resolve through the
61
+ // element's `metadata { service }` binding — never through what the box is called.
62
+ const svcOf = (id) => serviceOf(elements, id);
63
+ // --- per-service specs (requirement operations) + openapi deltas ---
64
+ const svcNames = await featureSpecServices(featureDir, context);
65
+ const reqOps = new Map();
66
+ const removedReqOps = new Map();
67
+ /** Per service: operations this feature genuinely retires (markers minus relocations). */
68
+ const removingOps = new Map();
69
+ /** Per service: every id a removal marker names, relocations included. */
70
+ const allMarkerIds = new Map();
71
+ /** Per service: does the feature contract carry a marker with no operationId? */
72
+ const anonymousMarkers = new Map();
73
+ const featureApiOps = new Set();
74
+ for (const svc of svcNames) {
75
+ const paths = featureSpecPaths(featureDir, svc);
76
+ if (existsSync(paths.spec)) {
77
+ const reqs = context === undefined
78
+ ? parseRequirements(await readFile(paths.spec, "utf8"))
79
+ : await context.readRequirements(paths.spec);
80
+ // REMOVED requirements are being retired along with their operations — their
81
+ // ops neither claim the contract (E1) nor govern anything after the merge.
82
+ reqOps.set(svc, reqs.filter((r) => r.kind !== "REMOVED").flatMap((r) => r.operations));
83
+ removedReqOps.set(svc, new Set(reqs.filter((r) => r.kind === "REMOVED").flatMap((r) => r.operations)));
84
+ }
85
+ // Only operations genuinely NEW to this service count as feature-added: authors
86
+ // restate the full living API in the delta file (it is a complete document, not a patch).
87
+ const featDoc = await readOpenapi(paths.openapi, context);
88
+ const featOps = featDoc.ops;
89
+ const removals = featOps.filter((op) => op.remove);
90
+ // A relocation — same operationId, removal marker on the old slot, upsert
91
+ // on the new one — retires nothing. Every rule that asks "is this operation
92
+ // going away" must therefore ask the NET set (markers this feature does not
93
+ // redefine), while "did the author write a marker at all" asks the raw one.
94
+ // Conflating them made moving an endpoint fail three checks at once for a
95
+ // change that removes nothing.
96
+ const redefined = new Set(featOps.filter((op) => !op.remove).map((op) => op.id));
97
+ const markerIds = new Set(removals.map((op) => op.id));
98
+ const netRemoved = new Set([...markerIds].filter((id) => !redefined.has(id)));
99
+ removingOps.set(svc, netRemoved);
100
+ allMarkerIds.set(svc, markerIds);
101
+ anonymousMarkers.set(svc, featDoc.anonymousRemovals.length > 0);
102
+ // A marker with no operationId names a slot but no operation. Every
103
+ // id-keyed check is blind to it, so it used to travel all the way into the
104
+ // living contract as a literal `x-loam-remove: true` — the one feature-only
105
+ // key that must never be published.
106
+ for (const marker of featDoc.anonymousRemovals) {
107
+ issues.push({
108
+ severity: "error",
109
+ code: "openapi.remove-marker-anonymous",
110
+ subject: svc,
111
+ message: `${svc}: ${marker.method} ${marker.path} carries x-loam-remove: true but declares no operationId — loam cannot tell which operation it retires; name the operation the living contract has at that slot`,
112
+ });
113
+ }
114
+ const livingDoc = await readOpenapi(servicePaths(docsDir, svc).openapi, context);
115
+ const livingOps = livingDoc.ops;
116
+ for (const id of livingDoc.duplicateIds) {
117
+ const slots = livingOps.filter((op) => op.id === id).map((op) => `${op.method} ${op.path}`);
118
+ issues.push({
119
+ severity: "warn",
120
+ code: "openapi.duplicate-operationid",
121
+ subject: svc,
122
+ message: `${svc}: 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`,
123
+ });
124
+ }
125
+ // The baseline axis: which of the operations this delta SPELLS does it
126
+ // actually edit? A feature's openapi.yaml is a complete document, so most of
127
+ // it is quotation — and until the pin existed the merge upserted quotations
128
+ // too, which reverted whatever had landed on them since. Slot-keyed, exactly
129
+ // as the merge writes (path + method), never by operationId.
130
+ const livingBySlot = new Map(livingOps.map((op) => [`${op.method} ${op.path}`, op]));
131
+ let unpinned = 0;
132
+ for (const op of featOps) {
133
+ // A removal marker is not a restatement of anything; its own exactness
134
+ // check (`openapi.remove-target-mismatch`) already guards the slot.
135
+ if (op.remove)
136
+ continue;
137
+ const where = `${op.method.toUpperCase()} ${op.path}`;
138
+ const livingOp = livingBySlot.get(`${op.method} ${op.path}`);
139
+ if (op.basedOn !== undefined && !OPERATION_DIGEST_RE.test(op.basedOn)) {
140
+ issues.push({
141
+ severity: "error",
142
+ code: "openapi.baseline-invalid",
143
+ subject: svc,
144
+ message: `${svc}: ${where} has invalid ${OPENAPI_BASELINE_KEY} '${op.basedOn}' — expected ${OPERATION_DIGEST_LENGTH} lowercase hex characters, as \`loam rebase\` writes them`,
145
+ });
146
+ continue;
147
+ }
148
+ const verdict = classifyBaselineDigests(op.basedOn, op.digest, livingOp?.digest);
149
+ if (verdict === "unfounded") {
150
+ issues.push({
151
+ severity: "error",
152
+ code: "openapi.baseline-invalid",
153
+ subject: svc,
154
+ message: `${svc}: ${where} ('${op.id}') carries ${OPENAPI_BASELINE_KEY}, but the living contract has no operation at that slot — a new operation has no living version to be based on; drop the marker`,
155
+ });
156
+ }
157
+ else if (verdict === "stale") {
158
+ issues.push({
159
+ severity: "error",
160
+ code: "openapi.baseline-stale",
161
+ subject: svc,
162
+ message: `${svc}: ${where} ('${op.id}') was written against living version ${op.basedOn}, but the living contract now holds ${livingOp.digest} — somebody landed a change to it in between, and merging this would replace theirs outright. Re-read the living operation, fold in what you still mean, then run \`loam rebase ${featureId}\`.`,
163
+ });
164
+ }
165
+ else if (verdict === "unpinned" && livingOp !== undefined) {
166
+ // Counted, not listed. A delta over a thirty-operation service quotes
167
+ // twenty-nine of them, and twenty-nine identical warnings naming a fix
168
+ // that is ONE command teaches people to filter the code out.
169
+ unpinned += 1;
170
+ }
171
+ }
172
+ if (unpinned > 0) {
173
+ issues.push({
174
+ severity: "warn",
175
+ code: "openapi.baseline-missing",
176
+ subject: svc,
177
+ message: `${svc}: ${unpinned} operation(s) in this feature's openapi.yaml carry no ${OPENAPI_BASELINE_KEY}, so the merge cannot tell which ones this delta EDITS from the ones it merely restates — it will upsert all of them, reverting anything that landed on the restated ones. Run \`loam rebase ${featureId} --service ${svc}\`.`,
178
+ });
179
+ }
180
+ if (featOps.length > 0) {
181
+ const living = new Set(livingOps.filter((op) => !op.remove).map((op) => op.id));
182
+ for (const op of featOps) {
183
+ if (!op.remove && !living.has(op.id))
184
+ featureApiOps.add(op.id);
185
+ }
186
+ // Removal is exact: the feature names both an operationId and the
187
+ // path+method slot it expects to delete. That catches stale deltas whose
188
+ // target moved or was already retired before the archive planner runs.
189
+ const justified = removedReqOps.get(svc) ?? new Set();
190
+ for (const marker of removals) {
191
+ const target = livingOps.find((op) => op.path === marker.path && op.method === marker.method);
192
+ if (target === undefined) {
193
+ issues.push({
194
+ severity: "error",
195
+ code: "openapi.remove-target-missing",
196
+ subject: svc,
197
+ message: `${svc}: removal marker for '${marker.id}' addresses ${marker.method} ${marker.path}, but no living operation exists there`,
198
+ });
199
+ }
200
+ else if (target.id !== marker.id) {
201
+ issues.push({
202
+ severity: "error",
203
+ code: "openapi.remove-target-mismatch",
204
+ subject: svc,
205
+ message: `${svc}: removal marker names '${marker.id}' at ${marker.method} ${marker.path}, but the living operation there is '${target.id}'`,
206
+ });
207
+ }
208
+ // A relocation needs no REMOVED requirement: the requirement governing
209
+ // the operation stays, the operation only changes address.
210
+ if (!netRemoved.has(marker.id))
211
+ continue;
212
+ if (!justified.has(marker.id)) {
213
+ issues.push({
214
+ severity: "error",
215
+ code: "openapi.remove-marker-unjustified",
216
+ subject: svc,
217
+ message: `${svc}: removal marker for '${marker.id}' is not governed by a REMOVED requirement's Operations: line`,
218
+ });
219
+ }
220
+ }
221
+ }
222
+ }
223
+ for (const [svc, required] of removedReqOps) {
224
+ const marked = allMarkerIds.get(svc) ?? new Set();
225
+ for (const op of required) {
226
+ if (marked.has(op))
227
+ continue;
228
+ // Distinguish "no marker at all" from "a marker is there but loam cannot
229
+ // read which operation it names" — the first asks the author to write the
230
+ // marker, the second to write the operationId, and telling somebody who
231
+ // already wrote the marker that there is none sends them looking for a
232
+ // file they are staring at.
233
+ issues.push({
234
+ severity: "error",
235
+ code: "openapi.remove-marker-missing",
236
+ subject: svc,
237
+ message: anonymousMarkers.get(svc) === true
238
+ ? `${svc}: REMOVED requirement governs '${op}', and its feature openapi.yaml carries an x-loam-remove: true marker with no operationId — name '${op}' on that marker`
239
+ : `${svc}: REMOVED requirement governs '${op}', but its feature openapi.yaml has no matching x-loam-remove: true marker`,
240
+ });
241
+ }
242
+ }
243
+ const declaredOps = new Set([...reqOps.values()].flat());
244
+ // Living specs also govern: an edge calling a pre-existing endpoint is coherent if the
245
+ // target's living spec.md declares the op — the feature need not restate the requirement.
246
+ const livingReqs = new Map();
247
+ const livingRequirements = async (service) => {
248
+ let reqs = livingReqs.get(service);
249
+ if (reqs === undefined) {
250
+ const p = servicePaths(docsDir, service).spec;
251
+ reqs = existsSync(p)
252
+ ? context === undefined
253
+ ? parseRequirements(await readFile(p, "utf8"))
254
+ : await context.readRequirements(p)
255
+ : [];
256
+ livingReqs.set(service, reqs);
257
+ }
258
+ return reqs;
259
+ };
260
+ const governedByLivingSpec = async (service, op) => {
261
+ return (await livingRequirements(service)).some((r) => r.operations.includes(op));
262
+ };
263
+ // --- retiring an operation the fleet still calls ---
264
+ //
265
+ // A removal marker is checked against the living CONTRACT (does the slot
266
+ // exist) and the feature's own requirements (is the retirement governed).
267
+ // Neither question asks the one that matters to the other ninety-nine repos:
268
+ // is anybody still calling it? The living landscape is the fleet's own answer
269
+ // — an edge with `metadata { op }` is a consumer somebody drew — and another
270
+ // service's living requirements naming the operation are a second. Both are
271
+ // gating: the merge deletes the operation from the contract while the edge
272
+ // and the requirement stay, so the very next `validate --all` reports a
273
+ // broken contract on a repository whose author was never in this feature.
274
+ //
275
+ // Lazy on purpose: the landscape is a full LikeC4 workspace spin-up and the
276
+ // requirement scan reads every service's living spec, and a feature that
277
+ // removes nothing must pay for neither.
278
+ let livingLandscape;
279
+ const edgeConsumers = async (service, op) => {
280
+ if (livingLandscape === undefined) {
281
+ const path = landscapePath(docsDir);
282
+ livingLandscape = existsSync(path)
283
+ ? context === undefined
284
+ ? await loadFile(path)
285
+ : await context.loadLikeC4(path)
286
+ : null;
287
+ }
288
+ // An unreadable landscape proves nothing either way; `landscape.invalid`
289
+ // is validate's finding to make, and inventing a removal refusal out of a
290
+ // parse error would point the author at the wrong file.
291
+ if (livingLandscape === null || livingLandscape.errors.length > 0)
292
+ return [];
293
+ const resolve = serviceResolver(livingLandscape.elements);
294
+ return livingLandscape.relationships
295
+ .filter((r) => r.op === op && resolve(r.target) === service)
296
+ .map((r) => `edge ${resolve(r.source)} → ${resolve(r.target)}${r.title === undefined ? "" : ` ("${r.title}")`}`);
297
+ };
298
+ const requirementConsumers = async (service, op) => {
299
+ let others;
300
+ try {
301
+ others = (await (context === undefined ? listServices(docsDir) : context.listServices(docsDir)))
302
+ .map((s) => s.id)
303
+ .filter((id) => id !== service);
304
+ }
305
+ catch {
306
+ // No enumerable services/ — validate's `services-missing` is that
307
+ // repository's finding, not this feature's.
308
+ return [];
309
+ }
310
+ const out = [];
311
+ for (const other of others) {
312
+ for (const r of await livingRequirements(other)) {
313
+ if (r.operations.includes(op))
314
+ out.push(`${other}'s living requirement '${r.name}'`);
315
+ }
316
+ }
317
+ return out;
318
+ };
319
+ for (const [svc, retiring] of removingOps) {
320
+ for (const op of retiring) {
321
+ const consumers = [...(await edgeConsumers(svc, op)), ...(await requirementConsumers(svc, op))];
322
+ if (consumers.length === 0)
323
+ continue;
324
+ issues.push({
325
+ severity: "error",
326
+ code: "openapi.remove-op-consumed",
327
+ subject: svc,
328
+ message: `${svc}: this feature retires '${op}', but the living fleet still consumes it — ${consumers.join("; ")}. Retire the consumer in the same feature (drop the edge from architecture/landscape.likec4, or the requirement that names it), or archive with --approve to break it deliberately.`,
329
+ });
330
+ }
331
+ }
332
+ // What OTHER features in flight define, per (service, op). Cross-service work
333
+ // normally lands as feature A calling an op that in-flight feature B introduces;
334
+ // that is an ordering dependency, not a broken contract, and the requirements
335
+ // axis already grades the same shape as a warn (delta.modified-pending). Lazy
336
+ // like delta.ts's activeAdditions — the common case (op resolves) never pays
337
+ // for the fleet scan.
338
+ let inFlightOps = null;
339
+ const definedElsewhere = async (service, op) => {
340
+ inFlightOps ??= await activeOpAdditions(docsDir, featureId, context);
341
+ return inFlightOps.get(`${service} ${op}`);
342
+ };
343
+ // E1: Spec -> API — every operation a requirement governs must exist in that service's OpenAPI.
344
+ for (const [svc, ops] of reqOps) {
345
+ const available = await serviceOperationIds(docsDir, svc, featureDir, context);
346
+ for (const op of ops) {
347
+ if (available.includes(op))
348
+ continue;
349
+ const other = await definedElsewhere(svc, op);
350
+ if (other !== undefined) {
351
+ issues.push({ severity: "warn", code: "spec-api.op-pending", message: `requirement in ${svc} governs '${op}', defined by in-flight ${other} — archive it first` });
352
+ continue;
353
+ }
354
+ issues.push({ severity: "error", code: "spec-api.op-undefined", message: `requirement in ${svc} governs '${op}', not defined in ${svc}'s OpenAPI` });
355
+ }
356
+ }
357
+ // What the LIVING provider contracts mark `deprecated: true`, read lazily
358
+ // per service. Living only, on purpose: the feature's own openapi delta
359
+ // restates the full API, and the question here is whether the fleet as
360
+ // shipped is already retiring the op this feature starts leaning on.
361
+ const livingDeprecated = new Map();
362
+ const deprecatedInLiving = async (service, op) => {
363
+ let set = livingDeprecated.get(service);
364
+ if (!set) {
365
+ const list = await operations(servicePaths(docsDir, service).openapi, context);
366
+ set = new Set(list.filter((o) => o.deprecated).map((o) => o.id));
367
+ livingDeprecated.set(service, set);
368
+ }
369
+ return set.has(op);
370
+ };
371
+ // ...unless this feature IS the un-deprecation: an openapi delta that
372
+ // restates the op WITHOUT `deprecated: true` retires the flag on archive
373
+ // (the path-item overwrite is wholesale), so "prefer the replacement
374
+ // operation" would point the author away from the exact change they are
375
+ // shipping. A delta that restates the op still deprecated — or has no
376
+ // delta for the service at all — keeps the warning.
377
+ const featureUndeprecated = new Map();
378
+ const undeprecatedByFeature = async (service, op) => {
379
+ let set = featureUndeprecated.get(service);
380
+ if (!set) {
381
+ const list = await operations(featureSpecPaths(featureDir, service).openapi, context);
382
+ set = new Set(list.filter((o) => !o.deprecated).map((o) => o.id));
383
+ featureUndeprecated.set(service, set);
384
+ }
385
+ return set.has(op);
386
+ };
387
+ // E2 / W1 / W4: C4 edges vs API + requirements.
388
+ for (const r of taggedRels) {
389
+ if (r.op === undefined) {
390
+ if ((r.title ?? "").toLowerCase().startsWith("call")) {
391
+ issues.push({ severity: "warn", code: "c4.op-link-missing", message: `edge ${svcOf(r.source)} → ${svcOf(r.target)} ("${r.title}") has no operation link (metadata { op })` });
392
+ }
393
+ continue;
394
+ }
395
+ const target = svcOf(r.target);
396
+ const available = await serviceOperationIds(docsDir, target, featureDir, context);
397
+ if (removingOps.get(target)?.has(r.op) === true) {
398
+ issues.push({ severity: "error", code: "c4-api.op-removing", message: `${svcOf(r.source)} builds new consumption on '${r.op}', which this feature removes from ${target}` });
399
+ }
400
+ else if (!available.includes(r.op)) {
401
+ const other = await definedElsewhere(target, r.op);
402
+ if (other !== undefined) {
403
+ issues.push({ severity: "warn", code: "c4-api.op-pending", message: `${svcOf(r.source)} calls '${r.op}' on ${target}, defined by in-flight ${other} — archive it first` });
404
+ }
405
+ else {
406
+ issues.push({ severity: "error", code: "c4-api.op-undefined", message: `${svcOf(r.source)} calls '${r.op}' on ${target}, but ${target}'s OpenAPI does not define it (contract broken)` });
407
+ }
408
+ }
409
+ if (!declaredOps.has(r.op) && !(await governedByLivingSpec(target, r.op))) {
410
+ issues.push({ severity: "warn", code: "c4.op-ungoverned", message: `'${r.op}' is called by ${svcOf(r.source)} but no requirement governs it` });
411
+ }
412
+ // Lifecycle: this NEW tagged edge builds consumption on an operation the
413
+ // living provider contract already marks deprecated. Advisory, never an
414
+ // archive gate — the edge is legal and the contract holds — but new
415
+ // consumption of a dying op deserves an eye before it ships. Quiet when
416
+ // the feature's own openapi delta drops the flag: that state is the fix
417
+ // in progress, not new consumption of a dying op.
418
+ if ((await deprecatedInLiving(target, r.op)) && !(await undeprecatedByFeature(target, r.op))) {
419
+ issues.push({ severity: "warn", code: "c4-api.op-deprecated", message: `${svcOf(r.source)} builds new consumption on '${r.op}', which ${target}'s living OpenAPI marks deprecated — prefer the replacement operation` });
420
+ }
421
+ }
422
+ // W2: API -> C4 — every operation the feature adds should be consumed by some edge.
423
+ const consumed = new Set(taggedRels.map((r) => r.op).filter((o) => o !== undefined));
424
+ for (const op of featureApiOps) {
425
+ if (!consumed.has(op)) {
426
+ issues.push({ severity: "warn", code: "api.op-unconsumed", message: `operation '${op}' is added but no architecture edge consumes it (provider-only or unmodeled)` });
427
+ }
428
+ }
429
+ // W3: a new service should carry a requirement delta.
430
+ for (const e of taggedEls) {
431
+ const svc = elementService(e);
432
+ if (e.kind === "softwareSystem" && !svcNames.includes(svc)) {
433
+ issues.push({ severity: "warn", code: "service.no-requirement-delta", message: `new service ${svc} has no requirement delta under specs/` });
434
+ }
435
+ }
436
+ return issues;
437
+ }
438
+ /* ------------------------------------------------------------------ */
439
+ /* What the merge would do to the LIVING documents */
440
+ /* ------------------------------------------------------------------ */
441
+ /**
442
+ * Per-service deltas addressed to a service that exists nowhere: no
443
+ * `services/<svc>/`, and nothing in this feature's own tagged delta introducing
444
+ * one.
445
+ *
446
+ * `validate` grades this (`delta.service-unknown`); `archive` is where it costs
447
+ * something, because `specs/<svc>/` is what the merge MATERIALISES
448
+ * `services/<svc>/` from — one wrong character in `--touches` and the fleet
449
+ * acquires a living service nobody meant to adopt, under a banner saying the
450
+ * living docs are complete. Both gates read this function so they cannot drift
451
+ * into disagreeing about which service ids are real; the `code:` literal lives
452
+ * here, once, where the drift collector sees it.
453
+ *
454
+ * A delta that did not parse proves nothing either way — whether it introduces
455
+ * the service is exactly what nobody can read — so the question is suspended
456
+ * there rather than answered by guessing. `delta.invalid` is that file's
457
+ * finding, and coherence already makes it.
458
+ *
459
+ * `services/<svc>/` is asked for directly rather than through the enumeration:
460
+ * a feature may be graded in a docs repo with no `services/` at all (repo.ts
461
+ * takes the same position), where enumerating is a refusal, not an answer.
462
+ */
463
+ export async function unknownDeltaServices(docsDir, featureDir, featureId, preloadedDelta, context) {
464
+ const deltaPath = featurePaths(featureDir).delta;
465
+ let delta;
466
+ if (existsSync(deltaPath)) {
467
+ delta = preloadedDelta ?? (context === undefined ? await loadFile(deltaPath) : await context.loadLikeC4(deltaPath));
468
+ }
469
+ if (delta !== undefined && delta.errors.length > 0)
470
+ return [];
471
+ const introduces = new Set((delta?.elements ?? []).filter((e) => e.tags.includes(featureId)).map(elementService));
472
+ const unknown = (await featureSpecServices(featureDir, context)).filter((svc) => !existsSync(servicePaths(docsDir, svc).dir) && !introduces.has(svc));
473
+ if (unknown.length === 0)
474
+ return [];
475
+ // The near-miss hint, on the same rule `service.unknown` uses — a typo is
476
+ // only diagnosable against the ids that DO exist.
477
+ const closeTo = docsRepoState(docsDir).kind === "ok" ? (await listServices(docsDir, context)).map((s) => s.id) : [];
478
+ return unknown.map((svc) => {
479
+ const close = closeIds(svc, closeTo);
480
+ return {
481
+ severity: "error",
482
+ code: "delta.service-unknown",
483
+ subject: svc,
484
+ message: `specs/${svc}/ addresses a service that does not exist: there is no services/${svc}/ and this feature's ` +
485
+ `delta.likec4 does not introduce one — archiving would create it out of the typo.` +
486
+ (close.length > 0 ? ` Did you mean: ${close.join(", ")}?` : " `loam list services` shows what exists."),
487
+ };
488
+ });
489
+ }
490
+ /**
491
+ * Git conflict markers in the LIVING documents a merge of this feature would
492
+ * rewrite: each touched service's requirement files, and the fleet map.
493
+ *
494
+ * The rule and the sentence are fleet-context's — one spelling of the breach,
495
+ * wherever it is found. What this adds is the SET: the documents `archive`
496
+ * rewrites. A conflicted `services/<svc>/spec.md` parses as prose, so every
497
+ * check upstream of the merge reads it as a valid document with some odd
498
+ * headings in it; the rewrite then drops whichever marker lines fall inside the
499
+ * requirements run it owns, and a conflict anyone could see becomes a file
500
+ * nobody can tell is wrong. For a shared docs repo that a fleet lands in
501
+ * through PRs, that is the default failure and not an edge.
502
+ *
503
+ * `openapi.yaml` is deliberately absent: markers make it unparseable, so the
504
+ * YAML reader already refuses it by name and a second finding would only say
505
+ * the same thing later.
506
+ */
507
+ export async function livingMergeConflicts(docsDir, services, context) {
508
+ const out = [];
509
+ const read = async (path) => context === undefined ? readFile(path, "utf8") : context.readText(path);
510
+ for (const svc of services) {
511
+ const paths = servicePaths(docsDir, svc);
512
+ for (const axis of SPEC_AXES) {
513
+ const path = paths[axis.key];
514
+ if (!existsSync(path))
515
+ continue;
516
+ const finding = documentConflictFinding(repoPath(docsDir, path), svc, await read(path));
517
+ if (finding !== null)
518
+ out.push(finding);
519
+ }
520
+ }
521
+ const landscape = landscapePath(docsDir);
522
+ if (existsSync(landscape)) {
523
+ const finding = landscapeConflictFinding(repoPath(docsDir, landscape), await read(landscape));
524
+ if (finding !== null)
525
+ out.push(finding);
526
+ }
527
+ return out;
528
+ }
529
+ /**
530
+ * (service, operationId) pairs other ACTIVE features' openapi deltas define,
531
+ * mapped to the feature id — the openapi mirror of delta.ts's activeAdditions.
532
+ * Archived features are excluded: their ops are in the living openapi already or
533
+ * gone for good, and neither is "pending". First feature wins a clash — one name
534
+ * to archive first is enough to make progress.
535
+ */
536
+ async function activeOpAdditions(docsDir, exclude, context) {
537
+ const map = new Map();
538
+ for (const feature of await listFeatures(docsDir, {}, context)) {
539
+ if (feature.id === exclude)
540
+ continue;
541
+ for (const service of feature.services) {
542
+ for (const op of await operations(featureSpecPaths(feature.dir, service).openapi, context)) {
543
+ if (op.remove)
544
+ continue;
545
+ const k = `${service} ${op.id}`;
546
+ if (!map.has(k))
547
+ map.set(k, feature.id);
548
+ }
549
+ }
550
+ }
551
+ return map;
552
+ }
553
+ //# sourceMappingURL=coherence.js.map