@ybotok/loam 0.1.0-beta.1 → 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 +49 -0
- package/README.md +6 -1
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/adopt.js +17 -3
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/archive.js +24 -8
- package/dist/commands/archive.js.map +1 -1
- package/dist/commands/delta.js +74 -47
- package/dist/commands/delta.js.map +1 -1
- package/dist/commands/dependencies.js +7 -2
- package/dist/commands/dependencies.js.map +1 -1
- package/dist/commands/docs-repo-gate.js +104 -0
- package/dist/commands/docs-repo-gate.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 +87 -48
- package/dist/commands/gherkin.js.map +1 -1
- package/dist/commands/init.js +3 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/instructions.js +70 -0
- package/dist/commands/instructions.js.map +1 -0
- package/dist/commands/list.js +6 -76
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/migrate-openspec.js +45 -28
- package/dist/commands/migrate-openspec.js.map +1 -1
- package/dist/commands/new.js +35 -34
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/rebase.js +2 -4
- package/dist/commands/rebase.js.map +1 -1
- package/dist/commands/show.js +26 -31
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/status.js +8 -24
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/unarchive.js +12 -5
- package/dist/commands/unarchive.js.map +1 -1
- package/dist/commands/validate.js +115 -133
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/verify.js +33 -17
- package/dist/commands/verify.js.map +1 -1
- package/dist/commands/vouch.js +77 -59
- package/dist/commands/vouch.js.map +1 -1
- package/dist/core/agent.js +273 -31
- package/dist/core/agent.js.map +1 -1
- package/dist/core/arch.js +28 -2
- package/dist/core/arch.js.map +1 -1
- package/dist/core/brief.js +3 -3
- package/dist/core/brief.js.map +1 -1
- package/dist/core/coherence.js +37 -13
- package/dist/core/coherence.js.map +1 -1
- package/dist/core/concurrency.js +51 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/config.js +10 -2
- package/dist/core/config.js.map +1 -1
- package/dist/core/delta.js +21 -2
- package/dist/core/delta.js.map +1 -1
- package/dist/core/dependencies.js +46 -42
- package/dist/core/dependencies.js.map +1 -1
- package/dist/core/docs.js +2 -1
- package/dist/core/docs.js.map +1 -1
- package/dist/core/doctor.js +41 -26
- package/dist/core/doctor.js.map +1 -1
- 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 +16 -63
- package/dist/core/fleet-context.js.map +1 -1
- package/dist/core/frontmatter.js +1 -5
- package/dist/core/frontmatter.js.map +1 -1
- package/dist/core/gherkin.js +3 -61
- package/dist/core/gherkin.js.map +1 -1
- package/dist/core/health.js +1 -5
- package/dist/core/health.js.map +1 -1
- package/dist/core/ids.js +34 -5
- package/dist/core/ids.js.map +1 -1
- package/dist/core/json.js +8 -1
- package/dist/core/json.js.map +1 -1
- package/dist/core/likec4.js +6 -1
- package/dist/core/likec4.js.map +1 -1
- package/dist/core/maturity.js +66 -0
- package/dist/core/maturity.js.map +1 -0
- package/dist/core/openapi-merge.js +39 -6
- package/dist/core/openapi-merge.js.map +1 -1
- package/dist/core/openapi.js +17 -5
- package/dist/core/openapi.js.map +1 -1
- package/dist/core/openspec-inventory.js +64 -27
- package/dist/core/openspec-inventory.js.map +1 -1
- package/dist/core/path-safety.js +59 -0
- package/dist/core/path-safety.js.map +1 -1
- package/dist/core/provenance.js +35 -10
- package/dist/core/provenance.js.map +1 -1
- package/dist/core/records.js +33 -0
- package/dist/core/records.js.map +1 -0
- package/dist/core/repo.js +75 -1
- package/dist/core/repo.js.map +1 -1
- package/dist/core/report.js +9 -0
- package/dist/core/report.js.map +1 -1
- package/dist/core/results.js +1 -3
- package/dist/core/results.js.map +1 -1
- package/dist/core/spec.js +3 -3
- package/dist/core/spec.js.map +1 -1
- package/dist/core/staging.js +124 -17
- package/dist/core/staging.js.map +1 -1
- package/dist/core/status.js +122 -33
- package/dist/core/status.js.map +1 -1
- package/dist/core/steps.js +72 -0
- package/dist/core/steps.js.map +1 -0
- package/dist/core/verify.js +18 -4
- package/dist/core/verify.js.map +1 -1
- package/dist/core/version.js +17 -1
- package/dist/core/version.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { cpus } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
3
|
import { agentsStaleFinding } from "../core/agents-stamp.js";
|
|
4
|
+
import { inOrder } from "../core/concurrency.js";
|
|
6
5
|
import { loadConfig } from "../core/config.js";
|
|
7
6
|
import { listField, readFrontmatter } from "../core/frontmatter.js";
|
|
8
|
-
import { emitJson, fail, reportNoConfig } from "../core/json.js";
|
|
9
|
-
import { elementService, loadFile,
|
|
10
|
-
import { DocsRepoUnavailableError, docsRepoState, featurePaths, featureSpecPaths, featureSpecServices, landscapePath as landscapeFile, listFeatures, listServices, missingFeatureMessage, resolveFeature, serviceIdFindings, servicePaths, } from "../core/repo.js";
|
|
11
|
-
import { countSeverity, reportValid, targetJson, targetValid, } from "../core/report.js";
|
|
7
|
+
import { emitJson, fail, NO_SERVICE_MESSAGE, reportNoConfig } from "../core/json.js";
|
|
8
|
+
import { elementService, loadFile, serviceResolver, } from "../core/likec4.js";
|
|
9
|
+
import { DocsRepoUnavailableError, agentsPath as agentsFile, docsRepoState, featurePaths, featureSpecPaths, featureSpecServices, landscapePath as landscapeFile, listFeatures, listServices, missingFeatureMessage, resolveFeature, serviceIdFindings, servicePaths, SPEC_AXES, } from "../core/repo.js";
|
|
10
|
+
import { countSeverity, reportValid, SEVERITY_MARK, targetJson, targetValid, } from "../core/report.js";
|
|
12
11
|
import { parseRequirements, requirementIdProblems, requirementsMissingScenarios, steplessFindings, } from "../core/spec.js";
|
|
13
12
|
import { readOpenapi } from "../core/openapi.js";
|
|
14
|
-
import { featureCoherence } from "../core/coherence.js";
|
|
13
|
+
import { deltaServiceUnknownFinding, featureCoherence } from "../core/coherence.js";
|
|
15
14
|
import { gatesArchive } from "../core/issue.js";
|
|
16
15
|
import { emptySourcesMessage, expandSourceFiles, featureProvenance, missingSources, patternSources, serviceProvenance, unsafeSources, } from "../core/provenance.js";
|
|
17
16
|
import { closeIds, coversCandidates, coversEdge, coversElement, entryResolves, parseCoversEntry, } from "../core/arch.js";
|
|
@@ -19,56 +18,8 @@ import { gherkinFindings } from "../core/gherkin.js";
|
|
|
19
18
|
import { readHealth } from "../core/health.js";
|
|
20
19
|
import { LOAM_VERSION } from "../core/version.js";
|
|
21
20
|
import { FleetContext, documentConflictFinding, landscapeConflictFinding, } from "../core/fleet-context.js";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*
|
|
25
|
-
* It is not a formality. `validate --all` and `list` used to answer a docsDir
|
|
26
|
-
* that does not exist with a green report over zero services, which is the one
|
|
27
|
-
* output a fleet gate must never produce: "nothing is wrong" and "I could not
|
|
28
|
-
* look" are opposite facts, and in CI the first one merges. The two codes are
|
|
29
|
-
* kept apart because the fixes point in different directions — a wrong path in
|
|
30
|
-
* loam.json versus a docs repo that was never cloned or scaffolded — exactly
|
|
31
|
-
* the way `no-config` and `config-invalid` are kept apart.
|
|
32
|
-
*
|
|
33
|
-
* "Zero services" stays reachable, and only from a REAL docs repo whose
|
|
34
|
-
* `services/` is empty: that is a legitimate state (before the first
|
|
35
|
-
* `loam adopt`) and it must keep exiting 0.
|
|
36
|
-
*
|
|
37
|
-
* `need` says how much of a docs repo this particular run has to have. A run
|
|
38
|
-
* that only reads features genuinely does not need `services/` — repo.ts takes
|
|
39
|
-
* the same position for `listFeatures` — and refusing there would turn one
|
|
40
|
-
* diagnosis into two contradictory ones. A run that enumerates services needs
|
|
41
|
-
* the directory that IS the list of services.
|
|
42
|
-
*
|
|
43
|
-
* It lives in validate.ts because validate is the fleet gate this doctrine is
|
|
44
|
-
* about; `list` and `show` import it rather than growing two more copies that
|
|
45
|
-
* could drift into three different sentences for one fact.
|
|
46
|
-
*/
|
|
47
|
-
export function docsRepoReady(json, docsDir, need) {
|
|
48
|
-
const state = docsRepoState(docsDir);
|
|
49
|
-
if (state.kind === "ok" || (state.kind === "no-services" && need === "docs"))
|
|
50
|
-
return true;
|
|
51
|
-
if (state.kind === "missing") {
|
|
52
|
-
fail(json, "docs-missing", `The configured docs repo does not exist: ${state.path}. ` +
|
|
53
|
-
"Fix `docsDir` in loam.json, clone the docs repo there, " +
|
|
54
|
-
"or run `loam init --docs <dir> --create` to make a new one.");
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
fail(json, "services-missing", `${state.path} is not a docs repo — it has no services/ directory. ` +
|
|
58
|
-
"Point `docsDir` in loam.json at the shared docs repo, " +
|
|
59
|
-
"or run `loam init --docs <dir> --create` to make one.");
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* The same refusal, reached the other way: `listServices`/`listFeatures` throw
|
|
64
|
-
* `DocsRepoUnavailableError` when the repo goes away mid-run (or when a caller
|
|
65
|
-
* reaches enumeration without passing the gate above). Mapping it back onto the
|
|
66
|
-
* same two codes keeps one breach spelled one way, instead of surfacing as the
|
|
67
|
-
* `internal` catch-all in cli.ts.
|
|
68
|
-
*/
|
|
69
|
-
export function reportDocsRepoError(json, err) {
|
|
70
|
-
fail(json, err.state.kind === "missing" ? "docs-missing" : "services-missing", err.message);
|
|
71
|
-
}
|
|
21
|
+
import { docsRepoReady, reportDocsRepoError, reportRepositoryUnavailable, } from "./docs-repo-gate.js";
|
|
22
|
+
import { plural } from "./format.js";
|
|
72
23
|
export function registerValidate(program) {
|
|
73
24
|
program
|
|
74
25
|
.command("validate")
|
|
@@ -123,7 +74,7 @@ export function registerValidate(program) {
|
|
|
123
74
|
// a fresh LikeC4 workspace per call, and paying that per service makes
|
|
124
75
|
// the fleet's main CI command O(services) re-parses of the same file.
|
|
125
76
|
const lp = landscapeFile(docsDir);
|
|
126
|
-
const land = existsSync(lp) ? await fleet.loadLikeC4(lp) : null;
|
|
77
|
+
const land = existsSync(lp) ? await readLandscape(() => fleet.loadLikeC4(lp)) : null;
|
|
127
78
|
// The fleet-level cross-check first: it frames everything below it, and a
|
|
128
79
|
// service nobody drew is worth knowing before its own findings scroll past.
|
|
129
80
|
targets.push(await validateLandscape(docsDir, land, fleet));
|
|
@@ -134,7 +85,7 @@ export function registerValidate(program) {
|
|
|
134
85
|
// binary means agents are branching on tables the binary no longer
|
|
135
86
|
// honours. It grades the repo, not any service, so it rides on the
|
|
136
87
|
// landscape target.
|
|
137
|
-
const agentsPath =
|
|
88
|
+
const agentsPath = agentsFile(docsDir);
|
|
138
89
|
const agents = agentsStaleFinding(existsSync(agentsPath) ? await readFile(agentsPath, "utf8") : null, LOAM_VERSION);
|
|
139
90
|
if (agents !== null)
|
|
140
91
|
landscape.findings.push(agents);
|
|
@@ -193,7 +144,7 @@ export function registerValidate(program) {
|
|
|
193
144
|
else {
|
|
194
145
|
const service = opts.service ?? config.service;
|
|
195
146
|
if (!service) {
|
|
196
|
-
fail(json, "invalid-option",
|
|
147
|
+
fail(json, "invalid-option", NO_SERVICE_MESSAGE);
|
|
197
148
|
return;
|
|
198
149
|
}
|
|
199
150
|
targets.push(await guarded({ kind: "service", id: service }, () => validateService({
|
|
@@ -218,10 +169,7 @@ export function registerValidate(program) {
|
|
|
218
169
|
// that are fine. Say which file, and refuse; the one thing that must
|
|
219
170
|
// not happen is a stack trace on stdout's sibling stream and an
|
|
220
171
|
// `internal` envelope that names nothing.
|
|
221
|
-
|
|
222
|
-
if (path === undefined)
|
|
223
|
-
throw err;
|
|
224
|
-
fail(json, "repository-unavailable", `${path} could not be read, so the service list itself is unknown and nothing was validated. ${err instanceof Error ? err.message : String(err)}`);
|
|
172
|
+
reportRepositoryUnavailable(json, err, "the service list itself is unknown and nothing was validated", docsDir);
|
|
225
173
|
return;
|
|
226
174
|
}
|
|
227
175
|
// Counted off the findings rather than alongside them, so the rollup line
|
|
@@ -262,42 +210,6 @@ export function registerValidate(program) {
|
|
|
262
210
|
process.exitCode = 1;
|
|
263
211
|
});
|
|
264
212
|
}
|
|
265
|
-
/**
|
|
266
|
-
* How many targets `--all` may have in flight at once.
|
|
267
|
-
*
|
|
268
|
-
* Capped as well as scaled: each in-flight target holds a whole LikeC4/Langium
|
|
269
|
-
* workspace, so a 64-core box would open sixty-four of them at once and buy
|
|
270
|
-
* memory pressure rather than throughput (peak RSS on an 80-service fleet:
|
|
271
|
-
* 385 MB serial, ~500 MB pooled).
|
|
272
|
-
*
|
|
273
|
-
* Do not quote a speedup for this. The measured cost of `--all` is CPU inside
|
|
274
|
-
* the Langium parse, which one thread cannot divide however the loop is
|
|
275
|
-
* shaped; what the pool overlaps is the I/O half — the spec, contract and
|
|
276
|
-
* health reads around each parse. On an 80-service fleet it measures as a wash
|
|
277
|
-
* (11–13 s either way, on a box that was itself loaded). The number that moves
|
|
278
|
-
* this command is workspaces per run, not awaits per loop.
|
|
279
|
-
*/
|
|
280
|
-
const TARGET_CONCURRENCY = Math.max(1, Math.min(cpus().length, 8));
|
|
281
|
-
/**
|
|
282
|
-
* Run `work` over `items` with a bounded number in flight, returning the
|
|
283
|
-
* results in INPUT order.
|
|
284
|
-
*
|
|
285
|
-
* The order is the contract, not an accident: the text report and the `--json`
|
|
286
|
-
* envelope are diffed between runs and between machines, so a concurrent run
|
|
287
|
-
* has to be byte-identical to the serial one for the same input. That is why
|
|
288
|
-
* results are written by index — nothing is appended as it finishes, and a slow
|
|
289
|
-
* service cannot reorder the fleet around it.
|
|
290
|
-
*/
|
|
291
|
-
async function inOrder(items, work) {
|
|
292
|
-
const out = [];
|
|
293
|
-
let next = 0;
|
|
294
|
-
const workers = Math.min(TARGET_CONCURRENCY, items.length);
|
|
295
|
-
await Promise.all(Array.from({ length: workers }, async () => {
|
|
296
|
-
for (let i = next++; i < items.length; i = next++)
|
|
297
|
-
out[i] = await work(items[i]);
|
|
298
|
-
}));
|
|
299
|
-
return out;
|
|
300
|
-
}
|
|
301
213
|
function summary(targets) {
|
|
302
214
|
return {
|
|
303
215
|
services: targets.filter((t) => t.kind === "service").length,
|
|
@@ -396,6 +308,57 @@ function ambiguousTarget(arg, chosen) {
|
|
|
396
308
|
const ACTOR_KINDS = new Set(["person", "actor", "user"]);
|
|
397
309
|
/** Tag marking an element as somebody else's system — undocumented on purpose. */
|
|
398
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
|
+
}
|
|
399
362
|
/**
|
|
400
363
|
* The fleet cross-check: `services/` and the landscape both claim to name the
|
|
401
364
|
* fleet, and nothing used to compare them. A directory nobody drew and an element
|
|
@@ -458,12 +421,23 @@ async function validateLandscape(docsDir, preloaded, fleet) {
|
|
|
458
421
|
// Read plainly rather than through FleetContext: this target runs outside
|
|
459
422
|
// `guarded`, and a document refused for its encoding would surface here as
|
|
460
423
|
// the whole run's `repository-unavailable` instead of one finding.
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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);
|
|
465
440
|
}
|
|
466
|
-
const land = preloaded ?? (fleet === undefined ? await loadFile(path) : await fleet.loadLikeC4(path));
|
|
467
441
|
if (land.errors.length > 0) {
|
|
468
442
|
// Nothing may be concluded from a document that did not parse — in particular
|
|
469
443
|
// not that every service is unmodelled.
|
|
@@ -713,6 +687,17 @@ async function validateService(check) {
|
|
|
713
687
|
message: `No living spec at ${paths.spec} — requirement coverage and API governance are unchecked`,
|
|
714
688
|
});
|
|
715
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");
|
|
716
701
|
// API coverage: every operation in openapi.yaml is governed by a requirement.
|
|
717
702
|
const api = await readOpenapi(paths.openapi, fleet);
|
|
718
703
|
const removeMarkers = api.ops.filter((o) => o.remove);
|
|
@@ -733,7 +718,7 @@ async function validateService(check) {
|
|
|
733
718
|
: land.relationships.filter((r) => r.op !== undefined && landSvcOf(r.target) === service);
|
|
734
719
|
// The other half of the evidence a contract is owed: the living spec's own
|
|
735
720
|
// `Operations:` lines. A requirement on its way out governs nothing.
|
|
736
|
-
const governedOps =
|
|
721
|
+
const governedOps = livingReqs.flatMap((r) => r.operations);
|
|
737
722
|
/** No contract to read: deleted, renamed, or never written. */
|
|
738
723
|
const contractMissing = !existsSync(paths.openapi);
|
|
739
724
|
if (contractMissing) {
|
|
@@ -835,9 +820,7 @@ async function validateService(check) {
|
|
|
835
820
|
// green forever, and every downstream join through that id silently empty.
|
|
836
821
|
// Same code and severity as the feature-scope check on purpose: one breach,
|
|
837
822
|
// one name, wherever it is found.
|
|
838
|
-
for (const r of
|
|
839
|
-
if (r.kind === "REMOVED")
|
|
840
|
-
continue;
|
|
823
|
+
for (const r of livingReqs) {
|
|
841
824
|
for (const op of r.operations) {
|
|
842
825
|
if (defined.has(op))
|
|
843
826
|
continue;
|
|
@@ -852,7 +835,7 @@ async function validateService(check) {
|
|
|
852
835
|
}
|
|
853
836
|
}
|
|
854
837
|
if (ops.length > 0) {
|
|
855
|
-
const governed = new Set(
|
|
838
|
+
const governed = new Set(livingReqs.flatMap((r) => r.operations));
|
|
856
839
|
const orphans = ops.filter((op) => !governed.has(op));
|
|
857
840
|
if (orphans.length === 0) {
|
|
858
841
|
findings.push({
|
|
@@ -1251,17 +1234,10 @@ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
|
|
|
1251
1234
|
const closeTo = unknownServices.length > 0 && docsRepoState(docsDir).kind === "ok"
|
|
1252
1235
|
? (await listServices(docsDir, fleet)).map((s) => s.id)
|
|
1253
1236
|
: [];
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
code: "delta.service-unknown",
|
|
1259
|
-
subject: svc,
|
|
1260
|
-
message: `specs/${svc}/ addresses a service that does not exist: there is no services/${svc}/ and this feature's ` +
|
|
1261
|
-
`delta.likec4 does not introduce one — archiving would create it out of the typo.` +
|
|
1262
|
-
(close.length > 0 ? ` Did you mean: ${close.join(", ")}?` : " `loam list services` shows what exists."),
|
|
1263
|
-
});
|
|
1264
|
-
}
|
|
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));
|
|
1265
1241
|
// Requirement coverage across every per-service delta — the business spec and
|
|
1266
1242
|
// the arch spec through the same check — and collect scenario text.
|
|
1267
1243
|
let scenarioText = "";
|
|
@@ -1299,15 +1275,23 @@ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
|
|
|
1299
1275
|
findings.push(...repeatedListLineFindings(reqs, `${svc}: arch.spec.md`, svc));
|
|
1300
1276
|
}
|
|
1301
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);
|
|
1302
1286
|
// Arch-edge coverage (heuristic, warn-only): each new tagged edge should be named by a scenario.
|
|
1303
1287
|
for (const r of taggedRels) {
|
|
1304
|
-
const target =
|
|
1288
|
+
const target = svcOf(r.target);
|
|
1305
1289
|
const covered = edgeCovered(target, r.title, scenarioText);
|
|
1306
1290
|
findings.push({
|
|
1307
1291
|
severity: covered ? "ok" : "warn",
|
|
1308
1292
|
code: covered ? "archedge.covered" : "archedge.uncovered",
|
|
1309
1293
|
subject: target,
|
|
1310
|
-
message: `${
|
|
1294
|
+
message: `${svcOf(r.source)} → ${target} "${r.title ?? ""}"${covered ? "" : " — no scenario names it"}`,
|
|
1311
1295
|
text: { indent: 4, header: "arch-edge coverage (heuristic):" },
|
|
1312
1296
|
});
|
|
1313
1297
|
}
|
|
@@ -1380,15 +1364,15 @@ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
|
|
|
1380
1364
|
});
|
|
1381
1365
|
}
|
|
1382
1366
|
for (const r of taggedRels) {
|
|
1383
|
-
if (baseEdges.has(edgeKey(
|
|
1367
|
+
if (baseEdges.has(edgeKey(svcOf(r.source), svcOf(r.target))))
|
|
1384
1368
|
continue;
|
|
1385
1369
|
if (activeCovers.some((c) => coversEdge(c, r, elements)))
|
|
1386
1370
|
continue;
|
|
1387
1371
|
findings.push({
|
|
1388
1372
|
severity: "warn",
|
|
1389
1373
|
code: "c4.uncovered",
|
|
1390
|
-
subject:
|
|
1391
|
-
message: `delta adds edge ${
|
|
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`,
|
|
1392
1376
|
});
|
|
1393
1377
|
}
|
|
1394
1378
|
// covers.unknown, feature scope. Resolution looks at the delta itself, the
|
|
@@ -1471,7 +1455,6 @@ function coverageFinding(label, reqs) {
|
|
|
1471
1455
|
/* ------------------------------------------------------------------ */
|
|
1472
1456
|
/* Text renderer */
|
|
1473
1457
|
/* ------------------------------------------------------------------ */
|
|
1474
|
-
const MARKER = { ok: "✓", warn: "⚠", error: "✗" };
|
|
1475
1458
|
/**
|
|
1476
1459
|
* `--errors-only` is a RENDERING lever, the way `--strict` is an exit-code
|
|
1477
1460
|
* lever: neither changes the report, and the `--json` payload is unaffected by
|
|
@@ -1499,7 +1482,7 @@ function renderText(targets, all, unverifiable, errorsOnly) {
|
|
|
1499
1482
|
// onto stderr meant a piped stdout silently lost them from the middle of
|
|
1500
1483
|
// the report and 2>&1 could reorder it; the exit code carries failure,
|
|
1501
1484
|
// and stderr stays reserved for refusals (the fail() path).
|
|
1502
|
-
const marker = hint.marker === false ? "" : `${
|
|
1485
|
+
const marker = hint.marker === false ? "" : `${SEVERITY_MARK[f.severity]} `;
|
|
1503
1486
|
console.log(`${" ".repeat(hint.indent ?? 0)}${marker}${f.message}`);
|
|
1504
1487
|
for (const d of f.details ?? [])
|
|
1505
1488
|
console.log(` ${hint.detailPrefix ?? ""}${d}`);
|
|
@@ -1515,7 +1498,6 @@ function renderText(targets, all, unverifiable, errorsOnly) {
|
|
|
1515
1498
|
return;
|
|
1516
1499
|
}
|
|
1517
1500
|
const s = summary(targets);
|
|
1518
|
-
const plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
1519
1501
|
console.log(`\n${plural(s.services, "service")}, ${plural(s.features, "feature")} — ` +
|
|
1520
1502
|
`${plural(s.errors, "error")}, ${plural(s.warnings, "warning")}`);
|
|
1521
1503
|
// One line for the whole fleet, never one per service: honest about the blind
|
|
@@ -1551,10 +1533,10 @@ function renderText(targets, all, unverifiable, errorsOnly) {
|
|
|
1551
1533
|
async function sourceScopeFindings(docsDir, service, repoDir) {
|
|
1552
1534
|
const paths = servicePaths(docsDir, service);
|
|
1553
1535
|
const out = [];
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
]) {
|
|
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 }))) {
|
|
1558
1540
|
if (!existsSync(path))
|
|
1559
1541
|
continue;
|
|
1560
1542
|
const sources = listField(await readFrontmatter(path), "sources");
|