@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.
- package/CHANGELOG.md +138 -0
- package/COMPARISON.md +63 -0
- package/LICENSE +21 -0
- package/MIGRATING-from-OpenSpec.md +202 -0
- package/README.md +246 -9
- package/SCHEMA.md +425 -0
- package/SECURITY.md +9 -0
- package/dist/cli.js +142 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/adopt.js +240 -0
- package/dist/commands/adopt.js.map +1 -0
- package/dist/commands/archive.js +1313 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/delta.js +388 -0
- package/dist/commands/delta.js.map +1 -0
- package/dist/commands/dependencies.js +83 -0
- package/dist/commands/dependencies.js.map +1 -0
- package/dist/commands/docs-repo-gate.js +104 -0
- package/dist/commands/docs-repo-gate.js.map +1 -0
- package/dist/commands/doctor.js +85 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/explore.js +195 -0
- package/dist/commands/explore.js.map +1 -0
- package/dist/commands/format.js +17 -0
- package/dist/commands/format.js.map +1 -0
- package/dist/commands/gherkin.js +309 -0
- package/dist/commands/gherkin.js.map +1 -0
- package/dist/commands/init.js +250 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/instructions.js +70 -0
- package/dist/commands/instructions.js.map +1 -0
- package/dist/commands/list.js +291 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/migrate-openspec.js +950 -0
- package/dist/commands/migrate-openspec.js.map +1 -0
- package/dist/commands/new.js +413 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/rebase.js +356 -0
- package/dist/commands/rebase.js.map +1 -0
- package/dist/commands/show.js +320 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/status.js +254 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/unarchive.js +317 -0
- package/dist/commands/unarchive.js.map +1 -0
- package/dist/commands/validate.js +1591 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/verify.js +775 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/vouch.js +343 -0
- package/dist/commands/vouch.js.map +1 -0
- package/dist/core/agent.js +2065 -0
- package/dist/core/agent.js.map +1 -0
- package/dist/core/agents-stamp.js +68 -0
- package/dist/core/agents-stamp.js.map +1 -0
- package/dist/core/arch.js +130 -0
- package/dist/core/arch.js.map +1 -0
- package/dist/core/brief.js +733 -0
- package/dist/core/brief.js.map +1 -0
- package/dist/core/coherence.js +577 -0
- package/dist/core/coherence.js.map +1 -0
- package/dist/core/concurrency.js +51 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/config.js +180 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/delta.js +476 -0
- package/dist/core/delta.js.map +1 -0
- package/dist/core/dependencies.js +356 -0
- package/dist/core/dependencies.js.map +1 -0
- package/dist/core/docs.js +144 -0
- package/dist/core/docs.js.map +1 -0
- package/dist/core/doctor.js +559 -0
- package/dist/core/doctor.js.map +1 -0
- package/dist/core/document-bytes.js +59 -0
- package/dist/core/document-bytes.js.map +1 -0
- package/dist/core/explore.js +225 -0
- package/dist/core/explore.js.map +1 -0
- package/dist/core/fleet-context.js +225 -0
- package/dist/core/fleet-context.js.map +1 -0
- package/dist/core/frontmatter.js +199 -0
- package/dist/core/frontmatter.js.map +1 -0
- package/dist/core/gherkin.js +463 -0
- package/dist/core/gherkin.js.map +1 -0
- package/dist/core/health.js +67 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/ids.js +122 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/issue.js +9 -0
- package/dist/core/issue.js.map +1 -0
- package/dist/core/json.js +74 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/likec4.js +479 -0
- package/dist/core/likec4.js.map +1 -0
- package/dist/core/maturity.js +66 -0
- package/dist/core/maturity.js.map +1 -0
- package/dist/core/openapi-merge.js +552 -0
- package/dist/core/openapi-merge.js.map +1 -0
- package/dist/core/openapi.js +255 -0
- package/dist/core/openapi.js.map +1 -0
- package/dist/core/openspec-inventory.js +1440 -0
- package/dist/core/openspec-inventory.js.map +1 -0
- package/dist/core/path-safety.js +168 -0
- package/dist/core/path-safety.js.map +1 -0
- package/dist/core/provenance.js +720 -0
- package/dist/core/provenance.js.map +1 -0
- package/dist/core/records.js +33 -0
- package/dist/core/records.js.map +1 -0
- package/dist/core/repo.js +436 -0
- package/dist/core/repo.js.map +1 -0
- package/dist/core/report.js +47 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/results.js +199 -0
- package/dist/core/results.js.map +1 -0
- package/dist/core/spec.js +481 -0
- package/dist/core/spec.js.map +1 -0
- package/dist/core/staging.js +890 -0
- package/dist/core/staging.js.map +1 -0
- package/dist/core/status.js +1104 -0
- package/dist/core/status.js.map +1 -0
- package/dist/core/steps.js +72 -0
- package/dist/core/steps.js.map +1 -0
- package/dist/core/verify.js +807 -0
- package/dist/core/verify.js.map +1 -0
- package/dist/core/version.js +24 -0
- package/dist/core/version.js.map +1 -0
- package/package.json +63 -4
|
@@ -0,0 +1,577 @@
|
|
|
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
|
+
// Living specs also govern: an edge calling a pre-existing endpoint is coherent if the
|
|
244
|
+
// target's living spec.md declares the op — the feature need not restate the requirement.
|
|
245
|
+
const livingReqs = new Map();
|
|
246
|
+
const livingRequirements = async (service) => {
|
|
247
|
+
let reqs = livingReqs.get(service);
|
|
248
|
+
if (reqs === undefined) {
|
|
249
|
+
const p = servicePaths(docsDir, service).spec;
|
|
250
|
+
reqs = existsSync(p)
|
|
251
|
+
? context === undefined
|
|
252
|
+
? parseRequirements(await readFile(p, "utf8"))
|
|
253
|
+
: await context.readRequirements(p)
|
|
254
|
+
: [];
|
|
255
|
+
livingReqs.set(service, reqs);
|
|
256
|
+
}
|
|
257
|
+
return reqs;
|
|
258
|
+
};
|
|
259
|
+
const governedByLivingSpec = async (service, op) => {
|
|
260
|
+
return (await livingRequirements(service)).some((r) => r.operations.includes(op));
|
|
261
|
+
};
|
|
262
|
+
// --- retiring an operation the fleet still calls ---
|
|
263
|
+
//
|
|
264
|
+
// A removal marker is checked against the living CONTRACT (does the slot
|
|
265
|
+
// exist) and the feature's own requirements (is the retirement governed).
|
|
266
|
+
// Neither question asks the one that matters to the other ninety-nine repos:
|
|
267
|
+
// is anybody still calling it? The living landscape is the fleet's own answer
|
|
268
|
+
// — an edge with `metadata { op }` is a consumer somebody drew — and another
|
|
269
|
+
// service's living requirements naming the operation are a second. Both are
|
|
270
|
+
// gating: the merge deletes the operation from the contract while the edge
|
|
271
|
+
// and the requirement stay, so the very next `validate --all` reports a
|
|
272
|
+
// broken contract on a repository whose author was never in this feature.
|
|
273
|
+
//
|
|
274
|
+
// Lazy on purpose: the landscape is a full LikeC4 workspace spin-up and the
|
|
275
|
+
// requirement scan reads every service's living spec, and a feature that
|
|
276
|
+
// removes nothing must pay for neither.
|
|
277
|
+
let livingLandscape;
|
|
278
|
+
const edgeConsumers = async (service, op) => {
|
|
279
|
+
if (livingLandscape === undefined) {
|
|
280
|
+
const path = landscapePath(docsDir);
|
|
281
|
+
livingLandscape = existsSync(path)
|
|
282
|
+
? context === undefined
|
|
283
|
+
? await loadFile(path)
|
|
284
|
+
: await context.loadLikeC4(path)
|
|
285
|
+
: null;
|
|
286
|
+
}
|
|
287
|
+
// An unreadable landscape proves nothing either way; `landscape.invalid`
|
|
288
|
+
// is validate's finding to make, and inventing a removal refusal out of a
|
|
289
|
+
// parse error would point the author at the wrong file.
|
|
290
|
+
if (livingLandscape === null || livingLandscape.errors.length > 0)
|
|
291
|
+
return [];
|
|
292
|
+
const resolve = serviceResolver(livingLandscape.elements);
|
|
293
|
+
return livingLandscape.relationships
|
|
294
|
+
.filter((r) => r.op === op && resolve(r.target) === service)
|
|
295
|
+
.map((r) => `edge ${resolve(r.source)} → ${resolve(r.target)}${r.title === undefined ? "" : ` ("${r.title}")`}`);
|
|
296
|
+
};
|
|
297
|
+
const requirementConsumers = async (service, op) => {
|
|
298
|
+
let others;
|
|
299
|
+
try {
|
|
300
|
+
others = (await (context === undefined ? listServices(docsDir) : context.listServices(docsDir)))
|
|
301
|
+
.map((s) => s.id)
|
|
302
|
+
.filter((id) => id !== service);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// No enumerable services/ — validate's `services-missing` is that
|
|
306
|
+
// repository's finding, not this feature's.
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
const out = [];
|
|
310
|
+
for (const other of others) {
|
|
311
|
+
for (const r of await livingRequirements(other)) {
|
|
312
|
+
if (r.operations.includes(op))
|
|
313
|
+
out.push(`${other}'s living requirement '${r.name}'`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
};
|
|
318
|
+
for (const [svc, retiring] of removingOps) {
|
|
319
|
+
for (const op of retiring) {
|
|
320
|
+
const consumers = [...(await edgeConsumers(svc, op)), ...(await requirementConsumers(svc, op))];
|
|
321
|
+
if (consumers.length === 0)
|
|
322
|
+
continue;
|
|
323
|
+
issues.push({
|
|
324
|
+
severity: "error",
|
|
325
|
+
code: "openapi.remove-op-consumed",
|
|
326
|
+
subject: svc,
|
|
327
|
+
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.`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// What OTHER features in flight define, per (service, op). Cross-service work
|
|
332
|
+
// normally lands as feature A calling an op that in-flight feature B introduces;
|
|
333
|
+
// that is an ordering dependency, not a broken contract, and the requirements
|
|
334
|
+
// axis already grades the same shape as a warn (delta.modified-pending). Lazy
|
|
335
|
+
// like delta.ts's activeAdditions — the common case (op resolves) never pays
|
|
336
|
+
// for the fleet scan.
|
|
337
|
+
let inFlightOps = null;
|
|
338
|
+
const definedElsewhere = async (service, op) => {
|
|
339
|
+
inFlightOps ??= await activeOpAdditions(docsDir, featureId, context);
|
|
340
|
+
return inFlightOps.get(`${service} ${op}`);
|
|
341
|
+
};
|
|
342
|
+
// E1: Spec -> API — every operation a requirement governs must exist in that service's OpenAPI.
|
|
343
|
+
for (const [svc, ops] of reqOps) {
|
|
344
|
+
const available = await serviceOperationIds(docsDir, svc, featureDir, context);
|
|
345
|
+
for (const op of ops) {
|
|
346
|
+
if (available.includes(op))
|
|
347
|
+
continue;
|
|
348
|
+
const other = await definedElsewhere(svc, op);
|
|
349
|
+
if (other !== undefined) {
|
|
350
|
+
issues.push({ severity: "warn", code: "spec-api.op-pending", message: `requirement in ${svc} governs '${op}', defined by in-flight ${other} — archive it first` });
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
issues.push({ severity: "error", code: "spec-api.op-undefined", message: `requirement in ${svc} governs '${op}', not defined in ${svc}'s OpenAPI` });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// What the LIVING provider contracts mark `deprecated: true`, read lazily
|
|
357
|
+
// per service. Living only, on purpose: the feature's own openapi delta
|
|
358
|
+
// restates the full API, and the question here is whether the fleet as
|
|
359
|
+
// shipped is already retiring the op this feature starts leaning on.
|
|
360
|
+
const livingDeprecated = new Map();
|
|
361
|
+
const deprecatedInLiving = async (service, op) => {
|
|
362
|
+
let set = livingDeprecated.get(service);
|
|
363
|
+
if (!set) {
|
|
364
|
+
const list = await operations(servicePaths(docsDir, service).openapi, context);
|
|
365
|
+
set = new Set(list.filter((o) => o.deprecated).map((o) => o.id));
|
|
366
|
+
livingDeprecated.set(service, set);
|
|
367
|
+
}
|
|
368
|
+
return set.has(op);
|
|
369
|
+
};
|
|
370
|
+
// ...unless this feature IS the un-deprecation: an openapi delta that
|
|
371
|
+
// restates the op WITHOUT `deprecated: true` retires the flag on archive
|
|
372
|
+
// (the path-item overwrite is wholesale), so "prefer the replacement
|
|
373
|
+
// operation" would point the author away from the exact change they are
|
|
374
|
+
// shipping. A delta that restates the op still deprecated — or has no
|
|
375
|
+
// delta for the service at all — keeps the warning.
|
|
376
|
+
const featureUndeprecated = new Map();
|
|
377
|
+
const undeprecatedByFeature = async (service, op) => {
|
|
378
|
+
let set = featureUndeprecated.get(service);
|
|
379
|
+
if (!set) {
|
|
380
|
+
const list = await operations(featureSpecPaths(featureDir, service).openapi, context);
|
|
381
|
+
set = new Set(list.filter((o) => !o.deprecated).map((o) => o.id));
|
|
382
|
+
featureUndeprecated.set(service, set);
|
|
383
|
+
}
|
|
384
|
+
return set.has(op);
|
|
385
|
+
};
|
|
386
|
+
// E2 / W1 / W4: C4 edges vs API + requirements.
|
|
387
|
+
for (const r of taggedRels) {
|
|
388
|
+
if (r.op === undefined) {
|
|
389
|
+
if ((r.title ?? "").toLowerCase().startsWith("call")) {
|
|
390
|
+
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 })` });
|
|
391
|
+
}
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const target = svcOf(r.target);
|
|
395
|
+
const available = await serviceOperationIds(docsDir, target, featureDir, context);
|
|
396
|
+
if (removingOps.get(target)?.has(r.op) === true) {
|
|
397
|
+
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}` });
|
|
398
|
+
}
|
|
399
|
+
else if (!available.includes(r.op)) {
|
|
400
|
+
const other = await definedElsewhere(target, r.op);
|
|
401
|
+
if (other !== undefined) {
|
|
402
|
+
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` });
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
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)` });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Governed by THIS target's requirements, not by anybody's. An operationId
|
|
409
|
+
// is unique only inside one contract, so the fleet-wide set this used to
|
|
410
|
+
// ask meant a `getStatus` governed in service A silenced the question for
|
|
411
|
+
// service B's entirely unrelated `getStatus` — the warning went quiet for
|
|
412
|
+
// the edge that needed it, and only in repos where some other service
|
|
413
|
+
// happened to reuse the name. The living half on the same line has always
|
|
414
|
+
// joined per service (`governedByLivingSpec(target, …)`); this is the delta
|
|
415
|
+
// half asking it the same way.
|
|
416
|
+
if (!(reqOps.get(target) ?? []).includes(r.op) && !(await governedByLivingSpec(target, r.op))) {
|
|
417
|
+
issues.push({ severity: "warn", code: "c4.op-ungoverned", message: `'${r.op}' is called by ${svcOf(r.source)} but no requirement governs it` });
|
|
418
|
+
}
|
|
419
|
+
// Lifecycle: this NEW tagged edge builds consumption on an operation the
|
|
420
|
+
// living provider contract already marks deprecated. Advisory, never an
|
|
421
|
+
// archive gate — the edge is legal and the contract holds — but new
|
|
422
|
+
// consumption of a dying op deserves an eye before it ships. Quiet when
|
|
423
|
+
// the feature's own openapi delta drops the flag: that state is the fix
|
|
424
|
+
// in progress, not new consumption of a dying op.
|
|
425
|
+
if ((await deprecatedInLiving(target, r.op)) && !(await undeprecatedByFeature(target, r.op))) {
|
|
426
|
+
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` });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// W2: API -> C4 — every operation the feature adds should be consumed by some edge.
|
|
430
|
+
const consumed = new Set(taggedRels.map((r) => r.op).filter((o) => o !== undefined));
|
|
431
|
+
for (const op of featureApiOps) {
|
|
432
|
+
if (!consumed.has(op)) {
|
|
433
|
+
issues.push({ severity: "warn", code: "api.op-unconsumed", message: `operation '${op}' is added but no architecture edge consumes it (provider-only or unmodeled)` });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// W3: a new service should carry a requirement delta.
|
|
437
|
+
for (const e of taggedEls) {
|
|
438
|
+
const svc = elementService(e);
|
|
439
|
+
if (e.kind === "softwareSystem" && !svcNames.includes(svc)) {
|
|
440
|
+
issues.push({ severity: "warn", code: "service.no-requirement-delta", message: `new service ${svc} has no requirement delta under specs/` });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return issues;
|
|
444
|
+
}
|
|
445
|
+
/* ------------------------------------------------------------------ */
|
|
446
|
+
/* What the merge would do to the LIVING documents */
|
|
447
|
+
/* ------------------------------------------------------------------ */
|
|
448
|
+
/**
|
|
449
|
+
* Per-service deltas addressed to a service that exists nowhere: no
|
|
450
|
+
* `services/<svc>/`, and nothing in this feature's own tagged delta introducing
|
|
451
|
+
* one.
|
|
452
|
+
*
|
|
453
|
+
* `validate` grades this (`delta.service-unknown`); `archive` is where it costs
|
|
454
|
+
* something, because `specs/<svc>/` is what the merge MATERIALISES
|
|
455
|
+
* `services/<svc>/` from — one wrong character in `--touches` and the fleet
|
|
456
|
+
* acquires a living service nobody meant to adopt, under a banner saying the
|
|
457
|
+
* living docs are complete. Both gates read this function so they cannot drift
|
|
458
|
+
* into disagreeing about which service ids are real; the `code:` literal lives
|
|
459
|
+
* here, once, where the drift collector sees it.
|
|
460
|
+
*
|
|
461
|
+
* A delta that did not parse proves nothing either way — whether it introduces
|
|
462
|
+
* the service is exactly what nobody can read — so the question is suspended
|
|
463
|
+
* there rather than answered by guessing. `delta.invalid` is that file's
|
|
464
|
+
* finding, and coherence already makes it.
|
|
465
|
+
*
|
|
466
|
+
* `services/<svc>/` is asked for directly rather than through the enumeration:
|
|
467
|
+
* a feature may be graded in a docs repo with no `services/` at all (repo.ts
|
|
468
|
+
* takes the same position), where enumerating is a refusal, not an answer.
|
|
469
|
+
*/
|
|
470
|
+
export async function unknownDeltaServices(docsDir, featureDir, featureId, preloadedDelta, context) {
|
|
471
|
+
const deltaPath = featurePaths(featureDir).delta;
|
|
472
|
+
let delta;
|
|
473
|
+
if (existsSync(deltaPath)) {
|
|
474
|
+
delta = preloadedDelta ?? (context === undefined ? await loadFile(deltaPath) : await context.loadLikeC4(deltaPath));
|
|
475
|
+
}
|
|
476
|
+
if (delta !== undefined && delta.errors.length > 0)
|
|
477
|
+
return [];
|
|
478
|
+
const introduces = new Set((delta?.elements ?? []).filter((e) => e.tags.includes(featureId)).map(elementService));
|
|
479
|
+
const unknown = (await featureSpecServices(featureDir, context)).filter((svc) => !existsSync(servicePaths(docsDir, svc).dir) && !introduces.has(svc));
|
|
480
|
+
if (unknown.length === 0)
|
|
481
|
+
return [];
|
|
482
|
+
// The near-miss hint, on the same rule `service.unknown` uses — a typo is
|
|
483
|
+
// only diagnosable against the ids that DO exist.
|
|
484
|
+
const closeTo = docsRepoState(docsDir).kind === "ok" ? (await listServices(docsDir, context)).map((s) => s.id) : [];
|
|
485
|
+
return unknown.map((svc) => deltaServiceUnknownFinding(svc, closeTo));
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* The finding itself, for the two gates that reach this conclusion by different
|
|
489
|
+
* routes.
|
|
490
|
+
*
|
|
491
|
+
* `archive` gets there through `unknownDeltaServices` above; `validate` gets
|
|
492
|
+
* there through its own enumeration, because it suspends the question on an
|
|
493
|
+
* unreadable delta rather than returning early on it, and that difference is
|
|
494
|
+
* deliberate. What must NOT differ is the sentence: the two were spelled out
|
|
495
|
+
* word for word in two files, so a reworded hint in one of them would have made
|
|
496
|
+
* `loam validate` and `loam archive` describe the same typo differently, on the
|
|
497
|
+
* same code, in the same repo.
|
|
498
|
+
*
|
|
499
|
+
* `knownIds` is the fleet's real service ids — already enumerated by the caller,
|
|
500
|
+
* which is also the caller's chance to skip the enumeration entirely when there
|
|
501
|
+
* is nothing to diagnose.
|
|
502
|
+
*/
|
|
503
|
+
export function deltaServiceUnknownFinding(svc, knownIds) {
|
|
504
|
+
const close = closeIds(svc, knownIds);
|
|
505
|
+
return {
|
|
506
|
+
severity: "error",
|
|
507
|
+
code: "delta.service-unknown",
|
|
508
|
+
subject: svc,
|
|
509
|
+
message: `specs/${svc}/ addresses a service that does not exist: there is no services/${svc}/ and this feature's ` +
|
|
510
|
+
`delta.likec4 does not introduce one — archiving would create it out of the typo.` +
|
|
511
|
+
(close.length > 0 ? ` Did you mean: ${close.join(", ")}?` : " `loam list services` shows what exists."),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Git conflict markers in the LIVING documents a merge of this feature would
|
|
516
|
+
* rewrite: each touched service's requirement files, and the fleet map.
|
|
517
|
+
*
|
|
518
|
+
* The rule and the sentence are fleet-context's — one spelling of the breach,
|
|
519
|
+
* wherever it is found. What this adds is the SET: the documents `archive`
|
|
520
|
+
* rewrites. A conflicted `services/<svc>/spec.md` parses as prose, so every
|
|
521
|
+
* check upstream of the merge reads it as a valid document with some odd
|
|
522
|
+
* headings in it; the rewrite then drops whichever marker lines fall inside the
|
|
523
|
+
* requirements run it owns, and a conflict anyone could see becomes a file
|
|
524
|
+
* nobody can tell is wrong. For a shared docs repo that a fleet lands in
|
|
525
|
+
* through PRs, that is the default failure and not an edge.
|
|
526
|
+
*
|
|
527
|
+
* `openapi.yaml` is deliberately absent: markers make it unparseable, so the
|
|
528
|
+
* YAML reader already refuses it by name and a second finding would only say
|
|
529
|
+
* the same thing later.
|
|
530
|
+
*/
|
|
531
|
+
export async function livingMergeConflicts(docsDir, services, context) {
|
|
532
|
+
const out = [];
|
|
533
|
+
const read = async (path) => context === undefined ? readFile(path, "utf8") : context.readText(path);
|
|
534
|
+
for (const svc of services) {
|
|
535
|
+
const paths = servicePaths(docsDir, svc);
|
|
536
|
+
for (const axis of SPEC_AXES) {
|
|
537
|
+
const path = paths[axis.key];
|
|
538
|
+
if (!existsSync(path))
|
|
539
|
+
continue;
|
|
540
|
+
const finding = documentConflictFinding(repoPath(docsDir, path), svc, await read(path));
|
|
541
|
+
if (finding !== null)
|
|
542
|
+
out.push(finding);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const landscape = landscapePath(docsDir);
|
|
546
|
+
if (existsSync(landscape)) {
|
|
547
|
+
const finding = landscapeConflictFinding(repoPath(docsDir, landscape), await read(landscape));
|
|
548
|
+
if (finding !== null)
|
|
549
|
+
out.push(finding);
|
|
550
|
+
}
|
|
551
|
+
return out;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* (service, operationId) pairs other ACTIVE features' openapi deltas define,
|
|
555
|
+
* mapped to the feature id — the openapi mirror of delta.ts's activeAdditions.
|
|
556
|
+
* Archived features are excluded: their ops are in the living openapi already or
|
|
557
|
+
* gone for good, and neither is "pending". First feature wins a clash — one name
|
|
558
|
+
* to archive first is enough to make progress.
|
|
559
|
+
*/
|
|
560
|
+
async function activeOpAdditions(docsDir, exclude, context) {
|
|
561
|
+
const map = new Map();
|
|
562
|
+
for (const feature of await listFeatures(docsDir, {}, context)) {
|
|
563
|
+
if (feature.id === exclude)
|
|
564
|
+
continue;
|
|
565
|
+
for (const service of feature.services) {
|
|
566
|
+
for (const op of await operations(featureSpecPaths(feature.dir, service).openapi, context)) {
|
|
567
|
+
if (op.remove)
|
|
568
|
+
continue;
|
|
569
|
+
const k = `${service} ${op.id}`;
|
|
570
|
+
if (!map.has(k))
|
|
571
|
+
map.set(k, feature.id);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return map;
|
|
576
|
+
}
|
|
577
|
+
//# sourceMappingURL=coherence.js.map
|