@ybotok/loam 0.1.0-beta.1 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +6 -1
  3. package/dist/cli.js +4 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/adopt.js +17 -3
  6. package/dist/commands/adopt.js.map +1 -1
  7. package/dist/commands/archive.js +24 -8
  8. package/dist/commands/archive.js.map +1 -1
  9. package/dist/commands/delta.js +74 -47
  10. package/dist/commands/delta.js.map +1 -1
  11. package/dist/commands/dependencies.js +7 -2
  12. package/dist/commands/dependencies.js.map +1 -1
  13. package/dist/commands/docs-repo-gate.js +104 -0
  14. package/dist/commands/docs-repo-gate.js.map +1 -0
  15. package/dist/commands/explore.js +195 -0
  16. package/dist/commands/explore.js.map +1 -0
  17. package/dist/commands/format.js +17 -0
  18. package/dist/commands/format.js.map +1 -0
  19. package/dist/commands/gherkin.js +87 -48
  20. package/dist/commands/gherkin.js.map +1 -1
  21. package/dist/commands/init.js +3 -3
  22. package/dist/commands/init.js.map +1 -1
  23. package/dist/commands/instructions.js +70 -0
  24. package/dist/commands/instructions.js.map +1 -0
  25. package/dist/commands/list.js +6 -76
  26. package/dist/commands/list.js.map +1 -1
  27. package/dist/commands/migrate-openspec.js +45 -28
  28. package/dist/commands/migrate-openspec.js.map +1 -1
  29. package/dist/commands/new.js +35 -34
  30. package/dist/commands/new.js.map +1 -1
  31. package/dist/commands/rebase.js +2 -4
  32. package/dist/commands/rebase.js.map +1 -1
  33. package/dist/commands/show.js +26 -31
  34. package/dist/commands/show.js.map +1 -1
  35. package/dist/commands/status.js +8 -24
  36. package/dist/commands/status.js.map +1 -1
  37. package/dist/commands/unarchive.js +12 -5
  38. package/dist/commands/unarchive.js.map +1 -1
  39. package/dist/commands/validate.js +115 -133
  40. package/dist/commands/validate.js.map +1 -1
  41. package/dist/commands/verify.js +33 -17
  42. package/dist/commands/verify.js.map +1 -1
  43. package/dist/commands/vouch.js +77 -59
  44. package/dist/commands/vouch.js.map +1 -1
  45. package/dist/core/agent.js +273 -31
  46. package/dist/core/agent.js.map +1 -1
  47. package/dist/core/arch.js +28 -2
  48. package/dist/core/arch.js.map +1 -1
  49. package/dist/core/brief.js +3 -3
  50. package/dist/core/brief.js.map +1 -1
  51. package/dist/core/coherence.js +37 -13
  52. package/dist/core/coherence.js.map +1 -1
  53. package/dist/core/concurrency.js +51 -0
  54. package/dist/core/concurrency.js.map +1 -0
  55. package/dist/core/config.js +10 -2
  56. package/dist/core/config.js.map +1 -1
  57. package/dist/core/delta.js +21 -2
  58. package/dist/core/delta.js.map +1 -1
  59. package/dist/core/dependencies.js +46 -42
  60. package/dist/core/dependencies.js.map +1 -1
  61. package/dist/core/docs.js +2 -1
  62. package/dist/core/docs.js.map +1 -1
  63. package/dist/core/doctor.js +41 -26
  64. package/dist/core/doctor.js.map +1 -1
  65. package/dist/core/document-bytes.js +59 -0
  66. package/dist/core/document-bytes.js.map +1 -0
  67. package/dist/core/explore.js +225 -0
  68. package/dist/core/explore.js.map +1 -0
  69. package/dist/core/fleet-context.js +16 -63
  70. package/dist/core/fleet-context.js.map +1 -1
  71. package/dist/core/frontmatter.js +1 -5
  72. package/dist/core/frontmatter.js.map +1 -1
  73. package/dist/core/gherkin.js +3 -61
  74. package/dist/core/gherkin.js.map +1 -1
  75. package/dist/core/health.js +1 -5
  76. package/dist/core/health.js.map +1 -1
  77. package/dist/core/ids.js +34 -5
  78. package/dist/core/ids.js.map +1 -1
  79. package/dist/core/json.js +8 -1
  80. package/dist/core/json.js.map +1 -1
  81. package/dist/core/likec4.js +6 -1
  82. package/dist/core/likec4.js.map +1 -1
  83. package/dist/core/maturity.js +66 -0
  84. package/dist/core/maturity.js.map +1 -0
  85. package/dist/core/openapi-merge.js +39 -6
  86. package/dist/core/openapi-merge.js.map +1 -1
  87. package/dist/core/openapi.js +17 -5
  88. package/dist/core/openapi.js.map +1 -1
  89. package/dist/core/openspec-inventory.js +64 -27
  90. package/dist/core/openspec-inventory.js.map +1 -1
  91. package/dist/core/path-safety.js +59 -0
  92. package/dist/core/path-safety.js.map +1 -1
  93. package/dist/core/provenance.js +35 -10
  94. package/dist/core/provenance.js.map +1 -1
  95. package/dist/core/records.js +33 -0
  96. package/dist/core/records.js.map +1 -0
  97. package/dist/core/repo.js +75 -1
  98. package/dist/core/repo.js.map +1 -1
  99. package/dist/core/report.js +9 -0
  100. package/dist/core/report.js.map +1 -1
  101. package/dist/core/results.js +1 -3
  102. package/dist/core/results.js.map +1 -1
  103. package/dist/core/spec.js +3 -3
  104. package/dist/core/spec.js.map +1 -1
  105. package/dist/core/staging.js +124 -17
  106. package/dist/core/staging.js.map +1 -1
  107. package/dist/core/status.js +122 -33
  108. package/dist/core/status.js.map +1 -1
  109. package/dist/core/steps.js +72 -0
  110. package/dist/core/steps.js.map +1 -0
  111. package/dist/core/verify.js +18 -4
  112. package/dist/core/verify.js.map +1 -1
  113. package/dist/core/version.js +17 -1
  114. package/dist/core/version.js.map +1 -1
  115. package/package.json +1 -1
@@ -1,17 +1,16 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
- import { cpus } from "node:os";
4
- import { join } from "node:path";
5
3
  import { agentsStaleFinding } from "../core/agents-stamp.js";
4
+ import { inOrder } from "../core/concurrency.js";
6
5
  import { loadConfig } from "../core/config.js";
7
6
  import { listField, readFrontmatter } from "../core/frontmatter.js";
8
- import { emitJson, fail, reportNoConfig } from "../core/json.js";
9
- import { elementService, loadFile, 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";
7
+ import { emitJson, fail, NO_SERVICE_MESSAGE, reportNoConfig } from "../core/json.js";
8
+ import { elementService, loadFile, serviceResolver, } from "../core/likec4.js";
9
+ import { DocsRepoUnavailableError, agentsPath as agentsFile, docsRepoState, featurePaths, featureSpecPaths, featureSpecServices, landscapePath as landscapeFile, listFeatures, listServices, missingFeatureMessage, resolveFeature, serviceIdFindings, servicePaths, SPEC_AXES, } from "../core/repo.js";
10
+ import { countSeverity, reportValid, SEVERITY_MARK, targetJson, targetValid, } from "../core/report.js";
12
11
  import { parseRequirements, requirementIdProblems, requirementsMissingScenarios, steplessFindings, } from "../core/spec.js";
13
12
  import { readOpenapi } from "../core/openapi.js";
14
- import { featureCoherence } from "../core/coherence.js";
13
+ import { deltaServiceUnknownFinding, featureCoherence } from "../core/coherence.js";
15
14
  import { gatesArchive } from "../core/issue.js";
16
15
  import { emptySourcesMessage, expandSourceFiles, featureProvenance, missingSources, patternSources, serviceProvenance, unsafeSources, } from "../core/provenance.js";
17
16
  import { closeIds, coversCandidates, coversEdge, coversElement, entryResolves, parseCoversEntry, } from "../core/arch.js";
@@ -19,56 +18,8 @@ import { gherkinFindings } from "../core/gherkin.js";
19
18
  import { readHealth } from "../core/health.js";
20
19
  import { LOAM_VERSION } from "../core/version.js";
21
20
  import { FleetContext, documentConflictFinding, landscapeConflictFinding, } from "../core/fleet-context.js";
22
- /**
23
- * 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
- }
21
+ import { docsRepoReady, reportDocsRepoError, reportRepositoryUnavailable, } from "./docs-repo-gate.js";
22
+ import { plural } from "./format.js";
72
23
  export function registerValidate(program) {
73
24
  program
74
25
  .command("validate")
@@ -123,7 +74,7 @@ export function registerValidate(program) {
123
74
  // a fresh LikeC4 workspace per call, and paying that per service makes
124
75
  // the fleet's main CI command O(services) re-parses of the same file.
125
76
  const lp = landscapeFile(docsDir);
126
- const land = existsSync(lp) ? await fleet.loadLikeC4(lp) : null;
77
+ const land = existsSync(lp) ? await readLandscape(() => fleet.loadLikeC4(lp)) : null;
127
78
  // The fleet-level cross-check first: it frames everything below it, and a
128
79
  // service nobody drew is worth knowing before its own findings scroll past.
129
80
  targets.push(await validateLandscape(docsDir, land, fleet));
@@ -134,7 +85,7 @@ export function registerValidate(program) {
134
85
  // binary means agents are branching on tables the binary no longer
135
86
  // honours. It grades the repo, not any service, so it rides on the
136
87
  // landscape target.
137
- const agentsPath = join(docsDir, "AGENTS.md");
88
+ const agentsPath = agentsFile(docsDir);
138
89
  const agents = agentsStaleFinding(existsSync(agentsPath) ? await readFile(agentsPath, "utf8") : null, LOAM_VERSION);
139
90
  if (agents !== null)
140
91
  landscape.findings.push(agents);
@@ -193,7 +144,7 @@ export function registerValidate(program) {
193
144
  else {
194
145
  const service = opts.service ?? config.service;
195
146
  if (!service) {
196
- fail(json, "invalid-option", "No service. Pass --service <id> or set it in loam.json.");
147
+ fail(json, "invalid-option", NO_SERVICE_MESSAGE);
197
148
  return;
198
149
  }
199
150
  targets.push(await guarded({ kind: "service", id: service }, () => validateService({
@@ -218,10 +169,7 @@ export function registerValidate(program) {
218
169
  // that are fine. Say which file, and refuse; the one thing that must
219
170
  // not happen is a stack trace on stdout's sibling stream and an
220
171
  // `internal` envelope that names nothing.
221
- 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)}`);
172
+ reportRepositoryUnavailable(json, err, "the service list itself is unknown and nothing was validated", docsDir);
225
173
  return;
226
174
  }
227
175
  // Counted off the findings rather than alongside them, so the rollup line
@@ -262,42 +210,6 @@ export function registerValidate(program) {
262
210
  process.exitCode = 1;
263
211
  });
264
212
  }
265
- /**
266
- * How many targets `--all` may have in flight at once.
267
- *
268
- * Capped as well as scaled: each in-flight target holds a whole LikeC4/Langium
269
- * workspace, so a 64-core box would open sixty-four of them at once and buy
270
- * memory pressure rather than throughput (peak RSS on an 80-service fleet:
271
- * 385 MB serial, ~500 MB pooled).
272
- *
273
- * Do not quote a speedup for this. The measured cost of `--all` is CPU inside
274
- * the Langium parse, which one thread cannot divide however the loop is
275
- * shaped; what the pool overlaps is the I/O half — the spec, contract and
276
- * health reads around each parse. On an 80-service fleet it measures as a wash
277
- * (11–13 s either way, on a box that was itself loaded). The number that moves
278
- * this command is workspaces per run, not awaits per loop.
279
- */
280
- const TARGET_CONCURRENCY = Math.max(1, Math.min(cpus().length, 8));
281
- /**
282
- * Run `work` over `items` with a bounded number in flight, returning the
283
- * results in INPUT order.
284
- *
285
- * The order is the contract, not an accident: the text report and the `--json`
286
- * envelope are diffed between runs and between machines, so a concurrent run
287
- * has to be byte-identical to the serial one for the same input. That is why
288
- * results are written by index — nothing is appended as it finishes, and a slow
289
- * service cannot reorder the fleet around it.
290
- */
291
- async function inOrder(items, work) {
292
- const out = [];
293
- let next = 0;
294
- const workers = Math.min(TARGET_CONCURRENCY, items.length);
295
- await Promise.all(Array.from({ length: workers }, async () => {
296
- for (let i = next++; i < items.length; i = next++)
297
- out[i] = await work(items[i]);
298
- }));
299
- return out;
300
- }
301
213
  function summary(targets) {
302
214
  return {
303
215
  services: targets.filter((t) => t.kind === "service").length,
@@ -396,6 +308,57 @@ function ambiguousTarget(arg, chosen) {
396
308
  const ACTOR_KINDS = new Set(["person", "actor", "user"]);
397
309
  /** Tag marking an element as somebody else's system — undocumented on purpose. */
398
310
  const EXTERNAL_TAG = "external";
311
+ /**
312
+ * A landscape that could not be READ, shaped as one that did not PARSE.
313
+ *
314
+ * The landscape is the one artifact no target owns: it is read once for the
315
+ * whole run and it is graded on a target of its own, which runs OUTSIDE
316
+ * `guarded`. So a landscape.likec4 that is a directory, or that carries a
317
+ * permission bit this process cannot open, escaped every per-target catch and
318
+ * became the whole run's `repository-unavailable` — one file, and a fleet gate
319
+ * that said nothing about the ninety-nine services that are fine. A DANGLING
320
+ * symlink is not one of those shapes and never was: every read of the file is
321
+ * gated on an `existsSync` that follows the link, so a broken one resolves as
322
+ * `landscape.missing` long before anything opens it.
323
+ *
324
+ * What this contains is the FLEET-level reads, and only those: the `--all`
325
+ * preload, and both reads inside `validateLandscape` — the conflict-marker
326
+ * `readFile` and the parse. `loam validate --service <id>` and
327
+ * `loam validate --feature <id>` hand in no preloaded doc, so `validateService`
328
+ * and `validateFeature` open the same file again on demand, unwrapped and
329
+ * INSIDE `guarded`: an EISDIR there is still reported as `service.unreadable`
330
+ * or `feature.unreadable`, which files the fleet map's failure against the one
331
+ * target the caller happened to name. That is the wrong subject on a finding
332
+ * that does at least carry the offending path, and it costs those runs nothing
333
+ * further — they grade a single target either way, so there is no report left
334
+ * unwritten — which is why the remainder was left for the change that gives the
335
+ * landscape one load path instead of three.
336
+ *
337
+ * `guarded` is deliberately NOT widened to cover the landscape target instead.
338
+ * Its code ternary knows only services and features, so it would file the fleet
339
+ * map's failure as `feature.unreadable`; and a guarded failure yields no
340
+ * document, so all N services would go on to re-open the same broken file
341
+ * inside their own guards and emit N copies of it. Containing the IO here needs
342
+ * no new code and no new sentence: "could not be read" and "did not parse" have
343
+ * the same consequence — nothing may be concluded from this file — and
344
+ * `landscape.invalid` is already how that is said.
345
+ */
346
+ function unreadableLandscape(err) {
347
+ return {
348
+ errors: [{ message: err instanceof Error ? err.message : String(err) }],
349
+ elements: [],
350
+ relationships: [],
351
+ };
352
+ }
353
+ /** One landscape load, answering with `unreadableLandscape` rather than throwing. */
354
+ async function readLandscape(load) {
355
+ try {
356
+ return await load();
357
+ }
358
+ catch (err) {
359
+ return unreadableLandscape(err);
360
+ }
361
+ }
399
362
  /**
400
363
  * The fleet cross-check: `services/` and the landscape both claim to name the
401
364
  * fleet, and nothing used to compare them. A directory nobody drew and an element
@@ -458,12 +421,23 @@ async function validateLandscape(docsDir, preloaded, fleet) {
458
421
  // Read plainly rather than through FleetContext: this target runs outside
459
422
  // `guarded`, and a document refused for its encoding would surface here as
460
423
  // the whole run's `repository-unavailable` instead of one finding.
461
- const conflict = landscapeConflictFinding("architecture/landscape.likec4", await readFile(path, "utf8"));
462
- if (conflict !== null) {
463
- findings.push(conflict);
464
- return report;
424
+ //
425
+ // Both reads are contained for that same reason — see `unreadableLandscape`.
426
+ // The conflict-marker read is the FIRST touch of the file, so it is the one
427
+ // that fails when the file cannot be opened at all, and it runs before
428
+ // `preloaded ??` can spare it.
429
+ let land;
430
+ try {
431
+ const conflict = landscapeConflictFinding("architecture/landscape.likec4", await readFile(path, "utf8"));
432
+ if (conflict !== null) {
433
+ findings.push(conflict);
434
+ return report;
435
+ }
436
+ land = preloaded ?? (fleet === undefined ? await loadFile(path) : await fleet.loadLikeC4(path));
437
+ }
438
+ catch (err) {
439
+ land = unreadableLandscape(err);
465
440
  }
466
- const land = preloaded ?? (fleet === undefined ? await loadFile(path) : await fleet.loadLikeC4(path));
467
441
  if (land.errors.length > 0) {
468
442
  // Nothing may be concluded from a document that did not parse — in particular
469
443
  // not that every service is unmodelled.
@@ -713,6 +687,17 @@ async function validateService(check) {
713
687
  message: `No living spec at ${paths.spec} — requirement coverage and API governance are unchecked`,
714
688
  });
715
689
  }
690
+ // The requirements that still govern anything. A REMOVED requirement is on
691
+ // its way out together with the operations it names: it makes no claim on the
692
+ // contract and governs nothing once the retiring feature archives — the same
693
+ // position coherence.ts takes on the delta side.
694
+ //
695
+ // Spelled once because it was spelled three times and dropped on the fourth:
696
+ // `governedOps` filtered, `spec-api.op-undefined` filtered, and the
697
+ // `api.covered` join did not — so an operation whose only requirement was
698
+ // REMOVED counted as governed, and the one operation about to be left without
699
+ // a requirement was the one the report called covered.
700
+ const livingReqs = reqs.filter((r) => r.kind !== "REMOVED");
716
701
  // API coverage: every operation in openapi.yaml is governed by a requirement.
717
702
  const api = await readOpenapi(paths.openapi, fleet);
718
703
  const removeMarkers = api.ops.filter((o) => o.remove);
@@ -733,7 +718,7 @@ async function validateService(check) {
733
718
  : land.relationships.filter((r) => r.op !== undefined && landSvcOf(r.target) === service);
734
719
  // The other half of the evidence a contract is owed: the living spec's own
735
720
  // `Operations:` lines. A requirement on its way out governs nothing.
736
- const governedOps = reqs.filter((r) => r.kind !== "REMOVED").flatMap((r) => r.operations);
721
+ const governedOps = livingReqs.flatMap((r) => r.operations);
737
722
  /** No contract to read: deleted, renamed, or never written. */
738
723
  const contractMissing = !existsSync(paths.openapi);
739
724
  if (contractMissing) {
@@ -835,9 +820,7 @@ async function validateService(check) {
835
820
  // green forever, and every downstream join through that id silently empty.
836
821
  // Same code and severity as the feature-scope check on purpose: one breach,
837
822
  // one name, wherever it is found.
838
- for (const r of reqs) {
839
- if (r.kind === "REMOVED")
840
- continue;
823
+ for (const r of livingReqs) {
841
824
  for (const op of r.operations) {
842
825
  if (defined.has(op))
843
826
  continue;
@@ -852,7 +835,7 @@ async function validateService(check) {
852
835
  }
853
836
  }
854
837
  if (ops.length > 0) {
855
- const governed = new Set(reqs.flatMap((r) => r.operations));
838
+ const governed = new Set(livingReqs.flatMap((r) => r.operations));
856
839
  const orphans = ops.filter((op) => !governed.has(op));
857
840
  if (orphans.length === 0) {
858
841
  findings.push({
@@ -1251,17 +1234,10 @@ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
1251
1234
  const closeTo = unknownServices.length > 0 && docsRepoState(docsDir).kind === "ok"
1252
1235
  ? (await listServices(docsDir, fleet)).map((s) => s.id)
1253
1236
  : [];
1254
- 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
- }
1237
+ // The finding is coherence.ts's — the same words archive refuses with, because
1238
+ // it is the same conclusion about the same directory.
1239
+ for (const svc of unknownServices)
1240
+ findings.push(deltaServiceUnknownFinding(svc, closeTo));
1265
1241
  // Requirement coverage across every per-service delta — the business spec and
1266
1242
  // the arch spec through the same check — and collect scenario text.
1267
1243
  let scenarioText = "";
@@ -1299,15 +1275,23 @@ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
1299
1275
  findings.push(...repeatedListLineFindings(reqs, `${svc}: arch.spec.md`, svc));
1300
1276
  }
1301
1277
  }
1278
+ // The delta's own element→service resolver, built once for the whole feature.
1279
+ // `serviceOf` is a one-shot wrapper that rebuilds its index on every call, and
1280
+ // the two loops below ask it up to five times per tagged edge — so a delta
1281
+ // over a large model paid for one Map of every element per question asked.
1282
+ // Identical answers by construction: `serviceOf(elements, id)` IS
1283
+ // `serviceResolver(elements)(id)`, and nothing reassigns `elements` after the
1284
+ // delta parse.
1285
+ const svcOf = serviceResolver(elements);
1302
1286
  // Arch-edge coverage (heuristic, warn-only): each new tagged edge should be named by a scenario.
1303
1287
  for (const r of taggedRels) {
1304
- const target = serviceOf(elements, r.target);
1288
+ const target = svcOf(r.target);
1305
1289
  const covered = edgeCovered(target, r.title, scenarioText);
1306
1290
  findings.push({
1307
1291
  severity: covered ? "ok" : "warn",
1308
1292
  code: covered ? "archedge.covered" : "archedge.uncovered",
1309
1293
  subject: target,
1310
- message: `${serviceOf(elements, r.source)} → ${target} "${r.title ?? ""}"${covered ? "" : " — no scenario names it"}`,
1294
+ message: `${svcOf(r.source)} → ${target} "${r.title ?? ""}"${covered ? "" : " — no scenario names it"}`,
1311
1295
  text: { indent: 4, header: "arch-edge coverage (heuristic):" },
1312
1296
  });
1313
1297
  }
@@ -1380,15 +1364,15 @@ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
1380
1364
  });
1381
1365
  }
1382
1366
  for (const r of taggedRels) {
1383
- if (baseEdges.has(edgeKey(serviceOf(elements, r.source), serviceOf(elements, r.target))))
1367
+ if (baseEdges.has(edgeKey(svcOf(r.source), svcOf(r.target))))
1384
1368
  continue;
1385
1369
  if (activeCovers.some((c) => coversEdge(c, r, elements)))
1386
1370
  continue;
1387
1371
  findings.push({
1388
1372
  severity: "warn",
1389
1373
  code: "c4.uncovered",
1390
- subject: 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`,
1374
+ subject: svcOf(r.target),
1375
+ message: `delta adds edge ${svcOf(r.source)} → ${svcOf(r.target)} ("${r.title ?? ""}") but no arch requirement covers it — add 'Covers: ${r.source} -> ${r.target}' to a specs/<svc>/arch.spec.md delta`,
1392
1376
  });
1393
1377
  }
1394
1378
  // covers.unknown, feature scope. Resolution looks at the delta itself, the
@@ -1471,7 +1455,6 @@ function coverageFinding(label, reqs) {
1471
1455
  /* ------------------------------------------------------------------ */
1472
1456
  /* Text renderer */
1473
1457
  /* ------------------------------------------------------------------ */
1474
- const MARKER = { ok: "✓", warn: "⚠", error: "✗" };
1475
1458
  /**
1476
1459
  * `--errors-only` is a RENDERING lever, the way `--strict` is an exit-code
1477
1460
  * lever: neither changes the report, and the `--json` payload is unaffected by
@@ -1499,7 +1482,7 @@ function renderText(targets, all, unverifiable, errorsOnly) {
1499
1482
  // onto stderr meant a piped stdout silently lost them from the middle of
1500
1483
  // the report and 2>&1 could reorder it; the exit code carries failure,
1501
1484
  // and stderr stays reserved for refusals (the fail() path).
1502
- const marker = hint.marker === false ? "" : `${MARKER[f.severity]} `;
1485
+ const marker = hint.marker === false ? "" : `${SEVERITY_MARK[f.severity]} `;
1503
1486
  console.log(`${" ".repeat(hint.indent ?? 0)}${marker}${f.message}`);
1504
1487
  for (const d of f.details ?? [])
1505
1488
  console.log(` ${hint.detailPrefix ?? ""}${d}`);
@@ -1515,7 +1498,6 @@ function renderText(targets, all, unverifiable, errorsOnly) {
1515
1498
  return;
1516
1499
  }
1517
1500
  const s = summary(targets);
1518
- const plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
1519
1501
  console.log(`\n${plural(s.services, "service")}, ${plural(s.features, "feature")} — ` +
1520
1502
  `${plural(s.errors, "error")}, ${plural(s.warnings, "warning")}`);
1521
1503
  // One line for the whole fleet, never one per service: honest about the blind
@@ -1551,10 +1533,10 @@ function renderText(targets, all, unverifiable, errorsOnly) {
1551
1533
  async function sourceScopeFindings(docsDir, service, repoDir) {
1552
1534
  const paths = servicePaths(docsDir, service);
1553
1535
  const out = [];
1554
- for (const { path, file } of [
1555
- { path: paths.spec, file: "spec.md" },
1556
- { path: paths.archSpec, file: "arch.spec.md" },
1557
- ]) {
1536
+ // The axis pair is SPEC_AXES', not this function's: `serviceProvenance` grades
1537
+ // the same two files from the same list, and a scope check that walked a
1538
+ // shorter list than the grading would go quiet on the axis it forgot.
1539
+ for (const { path, file } of SPEC_AXES.map((axis) => ({ path: paths[axis.key], file: axis.file }))) {
1558
1540
  if (!existsSync(path))
1559
1541
  continue;
1560
1542
  const sources = listField(await readFrontmatter(path), "sources");