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