@ybotok/loam 0.1.0-beta.1 → 0.1.0-beta.3
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 +96 -0
- package/README.md +6 -1
- package/SCHEMA.md +22 -4
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/adopt.js +17 -3
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/archive.js +24 -8
- package/dist/commands/archive.js.map +1 -1
- package/dist/commands/delta.js +74 -47
- package/dist/commands/delta.js.map +1 -1
- package/dist/commands/dependencies.js +7 -2
- package/dist/commands/dependencies.js.map +1 -1
- package/dist/commands/docs-repo-gate.js +104 -0
- package/dist/commands/docs-repo-gate.js.map +1 -0
- package/dist/commands/explore.js +195 -0
- package/dist/commands/explore.js.map +1 -0
- package/dist/commands/format.js +17 -0
- package/dist/commands/format.js.map +1 -0
- package/dist/commands/gherkin.js +87 -48
- package/dist/commands/gherkin.js.map +1 -1
- package/dist/commands/init.js +3 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/instructions.js +80 -0
- package/dist/commands/instructions.js.map +1 -0
- package/dist/commands/list.js +10 -76
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/migrate-openspec.js +45 -28
- package/dist/commands/migrate-openspec.js.map +1 -1
- package/dist/commands/new.js +35 -34
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/rebase.js +2 -4
- package/dist/commands/rebase.js.map +1 -1
- package/dist/commands/show.js +26 -31
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/status.js +17 -25
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/unarchive.js +12 -5
- package/dist/commands/unarchive.js.map +1 -1
- package/dist/commands/validate.js +306 -142
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/verify.js +33 -17
- package/dist/commands/verify.js.map +1 -1
- package/dist/commands/vouch.js +156 -60
- package/dist/commands/vouch.js.map +1 -1
- package/dist/core/agent.js +392 -42
- package/dist/core/agent.js.map +1 -1
- package/dist/core/agents-stamp.js +47 -10
- package/dist/core/agents-stamp.js.map +1 -1
- package/dist/core/arch.js +28 -2
- package/dist/core/arch.js.map +1 -1
- package/dist/core/asyncapi.js +253 -0
- package/dist/core/asyncapi.js.map +1 -0
- package/dist/core/brief.js +28 -6
- package/dist/core/brief.js.map +1 -1
- package/dist/core/coherence.js +37 -13
- package/dist/core/coherence.js.map +1 -1
- package/dist/core/concurrency.js +51 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/config.js +10 -2
- package/dist/core/config.js.map +1 -1
- package/dist/core/delta.js +21 -2
- package/dist/core/delta.js.map +1 -1
- package/dist/core/dependencies.js +46 -42
- package/dist/core/dependencies.js.map +1 -1
- package/dist/core/docs.js +65 -3
- package/dist/core/docs.js.map +1 -1
- package/dist/core/doctor.js +79 -27
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/document-bytes.js +59 -0
- package/dist/core/document-bytes.js.map +1 -0
- package/dist/core/explore.js +225 -0
- package/dist/core/explore.js.map +1 -0
- package/dist/core/fleet-context.js +29 -63
- package/dist/core/fleet-context.js.map +1 -1
- package/dist/core/frontmatter.js +1 -5
- package/dist/core/frontmatter.js.map +1 -1
- package/dist/core/gherkin.js +3 -61
- package/dist/core/gherkin.js.map +1 -1
- package/dist/core/health.js +1 -5
- package/dist/core/health.js.map +1 -1
- package/dist/core/ids.js +34 -5
- package/dist/core/ids.js.map +1 -1
- package/dist/core/json.js +8 -1
- package/dist/core/json.js.map +1 -1
- package/dist/core/likec4.js +19 -4
- package/dist/core/likec4.js.map +1 -1
- package/dist/core/maturity.js +66 -0
- package/dist/core/maturity.js.map +1 -0
- package/dist/core/openapi-merge.js +39 -6
- package/dist/core/openapi-merge.js.map +1 -1
- package/dist/core/openapi.js +17 -5
- package/dist/core/openapi.js.map +1 -1
- package/dist/core/openspec-inventory.js +64 -27
- package/dist/core/openspec-inventory.js.map +1 -1
- package/dist/core/path-safety.js +59 -0
- package/dist/core/path-safety.js.map +1 -1
- package/dist/core/provenance.js +102 -10
- package/dist/core/provenance.js.map +1 -1
- package/dist/core/records.js +33 -0
- package/dist/core/records.js.map +1 -0
- package/dist/core/repo.js +77 -1
- package/dist/core/repo.js.map +1 -1
- package/dist/core/report.js +9 -0
- package/dist/core/report.js.map +1 -1
- package/dist/core/results.js +1 -3
- package/dist/core/results.js.map +1 -1
- package/dist/core/spec.js +28 -4
- package/dist/core/spec.js.map +1 -1
- package/dist/core/staging.js +124 -17
- package/dist/core/staging.js.map +1 -1
- package/dist/core/status.js +152 -35
- package/dist/core/status.js.map +1 -1
- package/dist/core/steps.js +72 -0
- package/dist/core/steps.js.map +1 -0
- package/dist/core/verify.js +18 -4
- package/dist/core/verify.js.map +1 -1
- package/dist/core/version.js +17 -1
- package/dist/core/version.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { cpus } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
3
|
import { agentsStaleFinding } from "../core/agents-stamp.js";
|
|
4
|
+
import { inOrder } from "../core/concurrency.js";
|
|
6
5
|
import { loadConfig } from "../core/config.js";
|
|
7
6
|
import { listField, readFrontmatter } from "../core/frontmatter.js";
|
|
8
|
-
import { emitJson, fail, reportNoConfig } from "../core/json.js";
|
|
9
|
-
import { elementService, loadFile,
|
|
10
|
-
import { DocsRepoUnavailableError, docsRepoState, featurePaths, featureSpecPaths, featureSpecServices, landscapePath as landscapeFile, listFeatures, listServices, missingFeatureMessage, resolveFeature, serviceIdFindings, servicePaths, } from "../core/repo.js";
|
|
11
|
-
import { countSeverity, reportValid, targetJson, targetValid, } from "../core/report.js";
|
|
7
|
+
import { emitJson, fail, NO_SERVICE_MESSAGE, reportNoConfig } from "../core/json.js";
|
|
8
|
+
import { elementService, loadFile, serviceResolver, } from "../core/likec4.js";
|
|
9
|
+
import { DocsRepoUnavailableError, agentsPath as agentsFile, docsRepoState, featurePaths, featureSpecPaths, featureSpecServices, landscapePath as landscapeFile, listFeatures, listServices, missingFeatureMessage, resolveFeature, serviceIdFindings, servicePaths, SPEC_AXES, } from "../core/repo.js";
|
|
10
|
+
import { countSeverity, reportValid, SEVERITY_MARK, targetJson, targetValid, } from "../core/report.js";
|
|
12
11
|
import { parseRequirements, requirementIdProblems, requirementsMissingScenarios, steplessFindings, } from "../core/spec.js";
|
|
13
12
|
import { readOpenapi } from "../core/openapi.js";
|
|
14
|
-
import {
|
|
13
|
+
import { producersByMessage, readAsyncapi, slotsOf } from "../core/asyncapi.js";
|
|
14
|
+
import { deltaServiceUnknownFinding, featureCoherence } from "../core/coherence.js";
|
|
15
15
|
import { gatesArchive } from "../core/issue.js";
|
|
16
16
|
import { emptySourcesMessage, expandSourceFiles, featureProvenance, missingSources, patternSources, serviceProvenance, unsafeSources, } from "../core/provenance.js";
|
|
17
17
|
import { closeIds, coversCandidates, coversEdge, coversElement, entryResolves, parseCoversEntry, } from "../core/arch.js";
|
|
@@ -19,56 +19,8 @@ import { gherkinFindings } from "../core/gherkin.js";
|
|
|
19
19
|
import { readHealth } from "../core/health.js";
|
|
20
20
|
import { LOAM_VERSION } from "../core/version.js";
|
|
21
21
|
import { FleetContext, documentConflictFinding, landscapeConflictFinding, } from "../core/fleet-context.js";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*
|
|
25
|
-
* It is not a formality. `validate --all` and `list` used to answer a docsDir
|
|
26
|
-
* that does not exist with a green report over zero services, which is the one
|
|
27
|
-
* output a fleet gate must never produce: "nothing is wrong" and "I could not
|
|
28
|
-
* look" are opposite facts, and in CI the first one merges. The two codes are
|
|
29
|
-
* kept apart because the fixes point in different directions — a wrong path in
|
|
30
|
-
* loam.json versus a docs repo that was never cloned or scaffolded — exactly
|
|
31
|
-
* the way `no-config` and `config-invalid` are kept apart.
|
|
32
|
-
*
|
|
33
|
-
* "Zero services" stays reachable, and only from a REAL docs repo whose
|
|
34
|
-
* `services/` is empty: that is a legitimate state (before the first
|
|
35
|
-
* `loam adopt`) and it must keep exiting 0.
|
|
36
|
-
*
|
|
37
|
-
* `need` says how much of a docs repo this particular run has to have. A run
|
|
38
|
-
* that only reads features genuinely does not need `services/` — repo.ts takes
|
|
39
|
-
* the same position for `listFeatures` — and refusing there would turn one
|
|
40
|
-
* diagnosis into two contradictory ones. A run that enumerates services needs
|
|
41
|
-
* the directory that IS the list of services.
|
|
42
|
-
*
|
|
43
|
-
* It lives in validate.ts because validate is the fleet gate this doctrine is
|
|
44
|
-
* about; `list` and `show` import it rather than growing two more copies that
|
|
45
|
-
* could drift into three different sentences for one fact.
|
|
46
|
-
*/
|
|
47
|
-
export function docsRepoReady(json, docsDir, need) {
|
|
48
|
-
const state = docsRepoState(docsDir);
|
|
49
|
-
if (state.kind === "ok" || (state.kind === "no-services" && need === "docs"))
|
|
50
|
-
return true;
|
|
51
|
-
if (state.kind === "missing") {
|
|
52
|
-
fail(json, "docs-missing", `The configured docs repo does not exist: ${state.path}. ` +
|
|
53
|
-
"Fix `docsDir` in loam.json, clone the docs repo there, " +
|
|
54
|
-
"or run `loam init --docs <dir> --create` to make a new one.");
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
fail(json, "services-missing", `${state.path} is not a docs repo — it has no services/ directory. ` +
|
|
58
|
-
"Point `docsDir` in loam.json at the shared docs repo, " +
|
|
59
|
-
"or run `loam init --docs <dir> --create` to make one.");
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* The same refusal, reached the other way: `listServices`/`listFeatures` throw
|
|
64
|
-
* `DocsRepoUnavailableError` when the repo goes away mid-run (or when a caller
|
|
65
|
-
* reaches enumeration without passing the gate above). Mapping it back onto the
|
|
66
|
-
* same two codes keeps one breach spelled one way, instead of surfacing as the
|
|
67
|
-
* `internal` catch-all in cli.ts.
|
|
68
|
-
*/
|
|
69
|
-
export function reportDocsRepoError(json, err) {
|
|
70
|
-
fail(json, err.state.kind === "missing" ? "docs-missing" : "services-missing", err.message);
|
|
71
|
-
}
|
|
22
|
+
import { docsRepoReady, reportDocsRepoError, reportRepositoryUnavailable, } from "./docs-repo-gate.js";
|
|
23
|
+
import { plural } from "./format.js";
|
|
72
24
|
export function registerValidate(program) {
|
|
73
25
|
program
|
|
74
26
|
.command("validate")
|
|
@@ -123,7 +75,7 @@ export function registerValidate(program) {
|
|
|
123
75
|
// a fresh LikeC4 workspace per call, and paying that per service makes
|
|
124
76
|
// the fleet's main CI command O(services) re-parses of the same file.
|
|
125
77
|
const lp = landscapeFile(docsDir);
|
|
126
|
-
const land = existsSync(lp) ? await fleet.loadLikeC4(lp) : null;
|
|
78
|
+
const land = existsSync(lp) ? await readLandscape(() => fleet.loadLikeC4(lp)) : null;
|
|
127
79
|
// The fleet-level cross-check first: it frames everything below it, and a
|
|
128
80
|
// service nobody drew is worth knowing before its own findings scroll past.
|
|
129
81
|
targets.push(await validateLandscape(docsDir, land, fleet));
|
|
@@ -134,7 +86,7 @@ export function registerValidate(program) {
|
|
|
134
86
|
// binary means agents are branching on tables the binary no longer
|
|
135
87
|
// honours. It grades the repo, not any service, so it rides on the
|
|
136
88
|
// landscape target.
|
|
137
|
-
const agentsPath =
|
|
89
|
+
const agentsPath = agentsFile(docsDir);
|
|
138
90
|
const agents = agentsStaleFinding(existsSync(agentsPath) ? await readFile(agentsPath, "utf8") : null, LOAM_VERSION);
|
|
139
91
|
if (agents !== null)
|
|
140
92
|
landscape.findings.push(agents);
|
|
@@ -193,7 +145,7 @@ export function registerValidate(program) {
|
|
|
193
145
|
else {
|
|
194
146
|
const service = opts.service ?? config.service;
|
|
195
147
|
if (!service) {
|
|
196
|
-
fail(json, "invalid-option",
|
|
148
|
+
fail(json, "invalid-option", NO_SERVICE_MESSAGE);
|
|
197
149
|
return;
|
|
198
150
|
}
|
|
199
151
|
targets.push(await guarded({ kind: "service", id: service }, () => validateService({
|
|
@@ -218,10 +170,7 @@ export function registerValidate(program) {
|
|
|
218
170
|
// that are fine. Say which file, and refuse; the one thing that must
|
|
219
171
|
// not happen is a stack trace on stdout's sibling stream and an
|
|
220
172
|
// `internal` envelope that names nothing.
|
|
221
|
-
|
|
222
|
-
if (path === undefined)
|
|
223
|
-
throw err;
|
|
224
|
-
fail(json, "repository-unavailable", `${path} could not be read, so the service list itself is unknown and nothing was validated. ${err instanceof Error ? err.message : String(err)}`);
|
|
173
|
+
reportRepositoryUnavailable(json, err, "the service list itself is unknown and nothing was validated", docsDir);
|
|
225
174
|
return;
|
|
226
175
|
}
|
|
227
176
|
// Counted off the findings rather than alongside them, so the rollup line
|
|
@@ -262,42 +211,6 @@ export function registerValidate(program) {
|
|
|
262
211
|
process.exitCode = 1;
|
|
263
212
|
});
|
|
264
213
|
}
|
|
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
214
|
function summary(targets) {
|
|
302
215
|
return {
|
|
303
216
|
services: targets.filter((t) => t.kind === "service").length,
|
|
@@ -396,6 +309,57 @@ function ambiguousTarget(arg, chosen) {
|
|
|
396
309
|
const ACTOR_KINDS = new Set(["person", "actor", "user"]);
|
|
397
310
|
/** Tag marking an element as somebody else's system — undocumented on purpose. */
|
|
398
311
|
const EXTERNAL_TAG = "external";
|
|
312
|
+
/**
|
|
313
|
+
* A landscape that could not be READ, shaped as one that did not PARSE.
|
|
314
|
+
*
|
|
315
|
+
* The landscape is the one artifact no target owns: it is read once for the
|
|
316
|
+
* whole run and it is graded on a target of its own, which runs OUTSIDE
|
|
317
|
+
* `guarded`. So a landscape.likec4 that is a directory, or that carries a
|
|
318
|
+
* permission bit this process cannot open, escaped every per-target catch and
|
|
319
|
+
* became the whole run's `repository-unavailable` — one file, and a fleet gate
|
|
320
|
+
* that said nothing about the ninety-nine services that are fine. A DANGLING
|
|
321
|
+
* symlink is not one of those shapes and never was: every read of the file is
|
|
322
|
+
* gated on an `existsSync` that follows the link, so a broken one resolves as
|
|
323
|
+
* `landscape.missing` long before anything opens it.
|
|
324
|
+
*
|
|
325
|
+
* What this contains is the FLEET-level reads, and only those: the `--all`
|
|
326
|
+
* preload, and both reads inside `validateLandscape` — the conflict-marker
|
|
327
|
+
* `readFile` and the parse. `loam validate --service <id>` and
|
|
328
|
+
* `loam validate --feature <id>` hand in no preloaded doc, so `validateService`
|
|
329
|
+
* and `validateFeature` open the same file again on demand, unwrapped and
|
|
330
|
+
* INSIDE `guarded`: an EISDIR there is still reported as `service.unreadable`
|
|
331
|
+
* or `feature.unreadable`, which files the fleet map's failure against the one
|
|
332
|
+
* target the caller happened to name. That is the wrong subject on a finding
|
|
333
|
+
* that does at least carry the offending path, and it costs those runs nothing
|
|
334
|
+
* further — they grade a single target either way, so there is no report left
|
|
335
|
+
* unwritten — which is why the remainder was left for the change that gives the
|
|
336
|
+
* landscape one load path instead of three.
|
|
337
|
+
*
|
|
338
|
+
* `guarded` is deliberately NOT widened to cover the landscape target instead.
|
|
339
|
+
* Its code ternary knows only services and features, so it would file the fleet
|
|
340
|
+
* map's failure as `feature.unreadable`; and a guarded failure yields no
|
|
341
|
+
* document, so all N services would go on to re-open the same broken file
|
|
342
|
+
* inside their own guards and emit N copies of it. Containing the IO here needs
|
|
343
|
+
* no new code and no new sentence: "could not be read" and "did not parse" have
|
|
344
|
+
* the same consequence — nothing may be concluded from this file — and
|
|
345
|
+
* `landscape.invalid` is already how that is said.
|
|
346
|
+
*/
|
|
347
|
+
function unreadableLandscape(err) {
|
|
348
|
+
return {
|
|
349
|
+
errors: [{ message: err instanceof Error ? err.message : String(err) }],
|
|
350
|
+
elements: [],
|
|
351
|
+
relationships: [],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/** One landscape load, answering with `unreadableLandscape` rather than throwing. */
|
|
355
|
+
async function readLandscape(load) {
|
|
356
|
+
try {
|
|
357
|
+
return await load();
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
return unreadableLandscape(err);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
399
363
|
/**
|
|
400
364
|
* The fleet cross-check: `services/` and the landscape both claim to name the
|
|
401
365
|
* fleet, and nothing used to compare them. A directory nobody drew and an element
|
|
@@ -458,12 +422,23 @@ async function validateLandscape(docsDir, preloaded, fleet) {
|
|
|
458
422
|
// Read plainly rather than through FleetContext: this target runs outside
|
|
459
423
|
// `guarded`, and a document refused for its encoding would surface here as
|
|
460
424
|
// the whole run's `repository-unavailable` instead of one finding.
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
425
|
+
//
|
|
426
|
+
// Both reads are contained for that same reason — see `unreadableLandscape`.
|
|
427
|
+
// The conflict-marker read is the FIRST touch of the file, so it is the one
|
|
428
|
+
// that fails when the file cannot be opened at all, and it runs before
|
|
429
|
+
// `preloaded ??` can spare it.
|
|
430
|
+
let land;
|
|
431
|
+
try {
|
|
432
|
+
const conflict = landscapeConflictFinding("architecture/landscape.likec4", await readFile(path, "utf8"));
|
|
433
|
+
if (conflict !== null) {
|
|
434
|
+
findings.push(conflict);
|
|
435
|
+
return report;
|
|
436
|
+
}
|
|
437
|
+
land = preloaded ?? (fleet === undefined ? await loadFile(path) : await fleet.loadLikeC4(path));
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
land = unreadableLandscape(err);
|
|
465
441
|
}
|
|
466
|
-
const land = preloaded ?? (fleet === undefined ? await loadFile(path) : await fleet.loadLikeC4(path));
|
|
467
442
|
if (land.errors.length > 0) {
|
|
468
443
|
// Nothing may be concluded from a document that did not parse — in particular
|
|
469
444
|
// not that every service is unmodelled.
|
|
@@ -713,6 +688,17 @@ async function validateService(check) {
|
|
|
713
688
|
message: `No living spec at ${paths.spec} — requirement coverage and API governance are unchecked`,
|
|
714
689
|
});
|
|
715
690
|
}
|
|
691
|
+
// The requirements that still govern anything. A REMOVED requirement is on
|
|
692
|
+
// its way out together with the operations it names: it makes no claim on the
|
|
693
|
+
// contract and governs nothing once the retiring feature archives — the same
|
|
694
|
+
// position coherence.ts takes on the delta side.
|
|
695
|
+
//
|
|
696
|
+
// Spelled once because it was spelled three times and dropped on the fourth:
|
|
697
|
+
// `governedOps` filtered, `spec-api.op-undefined` filtered, and the
|
|
698
|
+
// `api.covered` join did not — so an operation whose only requirement was
|
|
699
|
+
// REMOVED counted as governed, and the one operation about to be left without
|
|
700
|
+
// a requirement was the one the report called covered.
|
|
701
|
+
const livingReqs = reqs.filter((r) => r.kind !== "REMOVED");
|
|
716
702
|
// API coverage: every operation in openapi.yaml is governed by a requirement.
|
|
717
703
|
const api = await readOpenapi(paths.openapi, fleet);
|
|
718
704
|
const removeMarkers = api.ops.filter((o) => o.remove);
|
|
@@ -733,7 +719,7 @@ async function validateService(check) {
|
|
|
733
719
|
: land.relationships.filter((r) => r.op !== undefined && landSvcOf(r.target) === service);
|
|
734
720
|
// The other half of the evidence a contract is owed: the living spec's own
|
|
735
721
|
// `Operations:` lines. A requirement on its way out governs nothing.
|
|
736
|
-
const governedOps =
|
|
722
|
+
const governedOps = livingReqs.flatMap((r) => r.operations);
|
|
737
723
|
/** No contract to read: deleted, renamed, or never written. */
|
|
738
724
|
const contractMissing = !existsSync(paths.openapi);
|
|
739
725
|
if (contractMissing) {
|
|
@@ -835,9 +821,7 @@ async function validateService(check) {
|
|
|
835
821
|
// green forever, and every downstream join through that id silently empty.
|
|
836
822
|
// Same code and severity as the feature-scope check on purpose: one breach,
|
|
837
823
|
// one name, wherever it is found.
|
|
838
|
-
for (const r of
|
|
839
|
-
if (r.kind === "REMOVED")
|
|
840
|
-
continue;
|
|
824
|
+
for (const r of livingReqs) {
|
|
841
825
|
for (const op of r.operations) {
|
|
842
826
|
if (defined.has(op))
|
|
843
827
|
continue;
|
|
@@ -852,7 +836,7 @@ async function validateService(check) {
|
|
|
852
836
|
}
|
|
853
837
|
}
|
|
854
838
|
if (ops.length > 0) {
|
|
855
|
-
const governed = new Set(
|
|
839
|
+
const governed = new Set(livingReqs.flatMap((r) => r.operations));
|
|
856
840
|
const orphans = ops.filter((op) => !governed.has(op));
|
|
857
841
|
if (orphans.length === 0) {
|
|
858
842
|
findings.push({
|
|
@@ -983,15 +967,23 @@ async function validateService(check) {
|
|
|
983
967
|
}
|
|
984
968
|
}
|
|
985
969
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
// the
|
|
994
|
-
|
|
970
|
+
/* ------------------------------------------------------------------ */
|
|
971
|
+
/* The async contract axis — AsyncAPI messages on the landscape spine */
|
|
972
|
+
/* ------------------------------------------------------------------ */
|
|
973
|
+
//
|
|
974
|
+
// The HTTP spine above and this one are mirror images, and the mirror is the
|
|
975
|
+
// whole reason this is a second axis rather than a second `metadata` key. On
|
|
976
|
+
// the HTTP axis the PROVIDER owns the contract: an edge names an operation and
|
|
977
|
+
// the target's openapi.yaml settles it, locally, in one file. Here the
|
|
978
|
+
// PRODUCER owns the message and the consumer joins to it from another
|
|
979
|
+
// repository — so "is this message declared" is local, while "does anybody
|
|
980
|
+
// publish it" is a fleet question with no local answer at all.
|
|
981
|
+
// The architecture spec, read HERE rather than with the rest of its axis
|
|
982
|
+
// below, because the event links are a requirement-level join and both
|
|
983
|
+
// requirement files carry them. Events are architecture as often as they are
|
|
984
|
+
// business — the outbox requirement in a service's arch.spec.md is the
|
|
985
|
+
// canonical place a `Publishes:` line belongs — so reading only spec.md would
|
|
986
|
+
// silently ignore the lines most authors write.
|
|
995
987
|
const archText = existsSync(paths.archSpec)
|
|
996
988
|
? fleet === undefined
|
|
997
989
|
? await readFile(paths.archSpec, "utf8")
|
|
@@ -1002,6 +994,179 @@ async function validateService(check) {
|
|
|
1002
994
|
: fleet === undefined
|
|
1003
995
|
? parseRequirements(archText)
|
|
1004
996
|
: await fleet.readRequirements(paths.archSpec);
|
|
997
|
+
// Where this axis started, so the closing ok-finding can be "nothing here
|
|
998
|
+
// broke" rather than a second, independently-computed claim about the same
|
|
999
|
+
// facts. A positive finding derived from the same walk cannot disagree with it.
|
|
1000
|
+
const eventFindingsAt = findings.length;
|
|
1001
|
+
const events = await readAsyncapi(paths.asyncapi, fleet);
|
|
1002
|
+
const sentHere = new Set(events.sent);
|
|
1003
|
+
const receivedHere = new Set(events.received);
|
|
1004
|
+
// Every edge that binds THIS service to a message: `publishes` binds the
|
|
1005
|
+
// edge's source (a service produces what it declares an `action: send` for),
|
|
1006
|
+
// `consumes` binds its target. One edge may legitimately carry both — a relay
|
|
1007
|
+
// that reads one topic and writes another is a single arrow in most fleet maps.
|
|
1008
|
+
const eventEdges = land === null || land.errors.length > 0
|
|
1009
|
+
? []
|
|
1010
|
+
: land.relationships.flatMap((r) => {
|
|
1011
|
+
const bound = [];
|
|
1012
|
+
if (r.publishes !== undefined && landSvcOf(r.source) === service) {
|
|
1013
|
+
bound.push({ direction: "publishes", message: r.publishes, other: landSvcOf(r.target) });
|
|
1014
|
+
}
|
|
1015
|
+
if (r.consumes !== undefined && landSvcOf(r.target) === service) {
|
|
1016
|
+
bound.push({ direction: "consumes", message: r.consumes, other: landSvcOf(r.source) });
|
|
1017
|
+
}
|
|
1018
|
+
return bound;
|
|
1019
|
+
});
|
|
1020
|
+
// Both requirement namespaces, and a REMOVED requirement links nothing —
|
|
1021
|
+
// the `livingReqs` rule the API axis already follows, applied to the pair.
|
|
1022
|
+
const eventLinks = [...livingReqs, ...archReqs.filter((r) => r.kind !== "REMOVED")].flatMap((r) => [
|
|
1023
|
+
...r.publishes.map((m) => ({ direction: "publishes", message: m, requirement: r.name })),
|
|
1024
|
+
...r.consumes.map((m) => ({ direction: "consumes", message: m, requirement: r.name })),
|
|
1025
|
+
]);
|
|
1026
|
+
if (!existsSync(paths.asyncapi)) {
|
|
1027
|
+
// Deliberately TWO grades, not the three `service.no-openapi` uses. Its
|
|
1028
|
+
// middle grade — warn when the landscape cannot prove nobody calls this
|
|
1029
|
+
// service — rests on HTTP being the default: most services expose one, so
|
|
1030
|
+
// "I could not look" is worth saying. An event contract is genuinely
|
|
1031
|
+
// optional; most services in a legacy fleet touch no topic at all. Warning
|
|
1032
|
+
// every one of them the moment a landscape fails to parse would put a
|
|
1033
|
+
// finding on the whole fleet that names a file nobody owes.
|
|
1034
|
+
//
|
|
1035
|
+
// So: something already joins into the absent file, or silence.
|
|
1036
|
+
const dangling = [
|
|
1037
|
+
...new Set([...eventEdges.map((e) => e.message), ...eventLinks.map((e) => e.message)]),
|
|
1038
|
+
].sort();
|
|
1039
|
+
if (dangling.length > 0) {
|
|
1040
|
+
findings.push({
|
|
1041
|
+
severity: "error",
|
|
1042
|
+
code: "service.no-asyncapi",
|
|
1043
|
+
subject: service,
|
|
1044
|
+
message: `No async contract at ${paths.asyncapi}, and ${dangling.length} message link(s) already point into it — ` +
|
|
1045
|
+
`every landscape edge and requirement naming one of them resolves to nothing until the file is back`,
|
|
1046
|
+
details: dangling,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
else if (events.unreadable) {
|
|
1051
|
+
// The openapi.invalid discipline: a contract that EXISTS but does not read
|
|
1052
|
+
// is a broken source of truth, not an empty one. Grading the links against
|
|
1053
|
+
// an empty message set would turn one file into one `spine.message-undefined`
|
|
1054
|
+
// per edge, every one of them pointing at the landscape.
|
|
1055
|
+
findings.push({
|
|
1056
|
+
severity: "error",
|
|
1057
|
+
code: "asyncapi.invalid",
|
|
1058
|
+
subject: service,
|
|
1059
|
+
message: `${service}: asyncapi.yaml does not parse — the event spine is unchecked for this service`,
|
|
1060
|
+
...(events.error === undefined ? {} : { details: [events.error] }),
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
for (const name of events.duplicateNames) {
|
|
1065
|
+
findings.push({
|
|
1066
|
+
severity: "warn",
|
|
1067
|
+
code: "asyncapi.duplicate-message",
|
|
1068
|
+
subject: service,
|
|
1069
|
+
message: `${service}: asyncapi.yaml declares message '${name}' in ${slotsOf(events, name).join(" and ")} — every join on the name (an edge's metadata { publishes }, a requirement's Publishes: line) picks one of those slots arbitrarily`,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
// Local resolution, both directions and both sources of a claim. An edge and
|
|
1073
|
+
// a requirement making the same broken claim get the same sentence under
|
|
1074
|
+
// different codes, matching how `spine.op-undefined` and
|
|
1075
|
+
// `spec-api.op-undefined` split the HTTP axis: one is the fleet map's
|
|
1076
|
+
// mistake, the other is this document's, and the fix is in a different file.
|
|
1077
|
+
const declares = (direction, message) => direction === "publishes" ? sentHere.has(message) : receivedHere.has(message);
|
|
1078
|
+
const action = (direction) => direction === "publishes" ? "send" : "receive";
|
|
1079
|
+
for (const e of eventEdges) {
|
|
1080
|
+
if (declares(e.direction, e.message))
|
|
1081
|
+
continue;
|
|
1082
|
+
findings.push({
|
|
1083
|
+
severity: "error",
|
|
1084
|
+
code: "spine.message-undefined",
|
|
1085
|
+
subject: service,
|
|
1086
|
+
message: `${service}: landscape edge ${e.direction === "publishes" ? `${service} → ${e.other}` : `${e.other} → ${service}`} ` +
|
|
1087
|
+
`${e.direction} '${e.message}', but ${service}'s asyncapi.yaml declares no operation with action: ${action(e.direction)} for it`,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
for (const e of eventLinks) {
|
|
1091
|
+
if (declares(e.direction, e.message))
|
|
1092
|
+
continue;
|
|
1093
|
+
findings.push({
|
|
1094
|
+
severity: "error",
|
|
1095
|
+
code: "spec-event.message-undefined",
|
|
1096
|
+
subject: service,
|
|
1097
|
+
message: `${service}: requirement '${e.requirement}' ${e.direction} '${e.message}', but ${service}'s asyncapi.yaml declares no operation with action: ${action(e.direction)} for it`,
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
// The migration-debt case, `api.ops-unlinked`'s twin: a contract and
|
|
1101
|
+
// requirements that never name each other leave this axis vacuously green.
|
|
1102
|
+
if (events.messages.length > 0 && reqs.length + archReqs.length > 0 && eventLinks.length === 0) {
|
|
1103
|
+
findings.push({
|
|
1104
|
+
severity: "warn",
|
|
1105
|
+
code: "event.messages-unlinked",
|
|
1106
|
+
subject: service,
|
|
1107
|
+
message: `${service}: asyncapi.yaml declares ${events.messages.length} message(s) but no requirement links any — the event axis is unchecked for this service`,
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
// The fleet question, and the one check on either spine that a single
|
|
1112
|
+
// repository cannot answer: a consumer joins to a message whose schema lives
|
|
1113
|
+
// in the producer's contract, in the producer's repo. Paid only when something
|
|
1114
|
+
// actually consumes — a fleet where nobody has adopted the axis walks nothing.
|
|
1115
|
+
const consumed = [
|
|
1116
|
+
...new Set([
|
|
1117
|
+
...eventEdges.filter((e) => e.direction === "consumes").map((e) => e.message),
|
|
1118
|
+
...eventLinks.filter((e) => e.direction === "consumes").map((e) => e.message),
|
|
1119
|
+
]),
|
|
1120
|
+
].sort();
|
|
1121
|
+
if (consumed.length > 0) {
|
|
1122
|
+
const producers = await producersByMessage(docsDir, [...known], fleet);
|
|
1123
|
+
for (const message of consumed) {
|
|
1124
|
+
const who = producers.byMessage.get(message) ?? [];
|
|
1125
|
+
// "Nobody produces this" is an argument from absence, and it is only
|
|
1126
|
+
// sound over a fleet loam could actually read. One unreadable contract
|
|
1127
|
+
// anywhere suspends it — that service may be the producer, and the
|
|
1128
|
+
// alternative is blaming every consumer for somebody else's broken YAML.
|
|
1129
|
+
// The contested answer below rests on positive evidence and needs no such
|
|
1130
|
+
// guard.
|
|
1131
|
+
if (who.length === 0 && producers.unreadable.length === 0) {
|
|
1132
|
+
findings.push({
|
|
1133
|
+
severity: "error",
|
|
1134
|
+
code: "spine.message-unproduced",
|
|
1135
|
+
subject: service,
|
|
1136
|
+
message: `${service}: consumes '${message}', but no service in the fleet declares an operation with action: send for it — the message has no producer, so nothing defines its payload`,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
else if (who.length > 1) {
|
|
1140
|
+
findings.push({
|
|
1141
|
+
severity: "warn",
|
|
1142
|
+
code: "asyncapi.message-contested",
|
|
1143
|
+
subject: service,
|
|
1144
|
+
message: `${service}: consumes '${message}', which ${who.length} services declare they send (${who.join(", ")}) — every consumer's join picks one of them arbitrarily, so the payload this service reads is whichever one happens to win`,
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
// Positive confirmation, on the same rule `spine.resolved` follows: claimed
|
|
1150
|
+
// only where the axis actually checked something. A service that touches no
|
|
1151
|
+
// topic says nothing here — an "event axis clean" on every worker in the fleet
|
|
1152
|
+
// would be a green tick for work nobody did.
|
|
1153
|
+
const eventChecks = eventEdges.length + eventLinks.length;
|
|
1154
|
+
if (eventChecks > 0 && findings.length === eventFindingsAt) {
|
|
1155
|
+
findings.push({
|
|
1156
|
+
severity: "ok",
|
|
1157
|
+
code: "event.covered",
|
|
1158
|
+
message: `${service}: event spine (${eventChecks} message link(s) resolve to asyncapi.yaml)`,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
// The architecture spec axis — the obligations a business spec never carries
|
|
1162
|
+
// (the transactional outbox, retries, metrics, alerts). arch.spec.md is
|
|
1163
|
+
// optional and its ABSENCE is not a finding (partial adoption is supported);
|
|
1164
|
+
// what exists must hold together: every requirement needs a scenario, every
|
|
1165
|
+
// `Covers:` entry must resolve (covers.unknown), and every alert/SLI that
|
|
1166
|
+
// health.yaml declares wants a covering requirement (health.uncovered) — the
|
|
1167
|
+
// moment health.yaml stops being inert. Warnings, never gates: `--strict` is
|
|
1168
|
+
// the CI escalation.
|
|
1169
|
+
const health = await readHealth(paths.health);
|
|
1005
1170
|
if (archText !== null) {
|
|
1006
1171
|
// The arch axis is advisory in what it ASKS for (covers.unknown,
|
|
1007
1172
|
// health.uncovered are warnings) but not in whether the file is readable:
|
|
@@ -1251,17 +1416,10 @@ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
|
|
|
1251
1416
|
const closeTo = unknownServices.length > 0 && docsRepoState(docsDir).kind === "ok"
|
|
1252
1417
|
? (await listServices(docsDir, fleet)).map((s) => s.id)
|
|
1253
1418
|
: [];
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
code: "delta.service-unknown",
|
|
1259
|
-
subject: svc,
|
|
1260
|
-
message: `specs/${svc}/ addresses a service that does not exist: there is no services/${svc}/ and this feature's ` +
|
|
1261
|
-
`delta.likec4 does not introduce one — archiving would create it out of the typo.` +
|
|
1262
|
-
(close.length > 0 ? ` Did you mean: ${close.join(", ")}?` : " `loam list services` shows what exists."),
|
|
1263
|
-
});
|
|
1264
|
-
}
|
|
1419
|
+
// The finding is coherence.ts's — the same words archive refuses with, because
|
|
1420
|
+
// it is the same conclusion about the same directory.
|
|
1421
|
+
for (const svc of unknownServices)
|
|
1422
|
+
findings.push(deltaServiceUnknownFinding(svc, closeTo));
|
|
1265
1423
|
// Requirement coverage across every per-service delta — the business spec and
|
|
1266
1424
|
// the arch spec through the same check — and collect scenario text.
|
|
1267
1425
|
let scenarioText = "";
|
|
@@ -1299,15 +1457,23 @@ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
|
|
|
1299
1457
|
findings.push(...repeatedListLineFindings(reqs, `${svc}: arch.spec.md`, svc));
|
|
1300
1458
|
}
|
|
1301
1459
|
}
|
|
1460
|
+
// The delta's own element→service resolver, built once for the whole feature.
|
|
1461
|
+
// `serviceOf` is a one-shot wrapper that rebuilds its index on every call, and
|
|
1462
|
+
// the two loops below ask it up to five times per tagged edge — so a delta
|
|
1463
|
+
// over a large model paid for one Map of every element per question asked.
|
|
1464
|
+
// Identical answers by construction: `serviceOf(elements, id)` IS
|
|
1465
|
+
// `serviceResolver(elements)(id)`, and nothing reassigns `elements` after the
|
|
1466
|
+
// delta parse.
|
|
1467
|
+
const svcOf = serviceResolver(elements);
|
|
1302
1468
|
// Arch-edge coverage (heuristic, warn-only): each new tagged edge should be named by a scenario.
|
|
1303
1469
|
for (const r of taggedRels) {
|
|
1304
|
-
const target =
|
|
1470
|
+
const target = svcOf(r.target);
|
|
1305
1471
|
const covered = edgeCovered(target, r.title, scenarioText);
|
|
1306
1472
|
findings.push({
|
|
1307
1473
|
severity: covered ? "ok" : "warn",
|
|
1308
1474
|
code: covered ? "archedge.covered" : "archedge.uncovered",
|
|
1309
1475
|
subject: target,
|
|
1310
|
-
message: `${
|
|
1476
|
+
message: `${svcOf(r.source)} → ${target} "${r.title ?? ""}"${covered ? "" : " — no scenario names it"}`,
|
|
1311
1477
|
text: { indent: 4, header: "arch-edge coverage (heuristic):" },
|
|
1312
1478
|
});
|
|
1313
1479
|
}
|
|
@@ -1380,15 +1546,15 @@ async function validateFeature(docsDir, feature, preloadedLand, fleet) {
|
|
|
1380
1546
|
});
|
|
1381
1547
|
}
|
|
1382
1548
|
for (const r of taggedRels) {
|
|
1383
|
-
if (baseEdges.has(edgeKey(
|
|
1549
|
+
if (baseEdges.has(edgeKey(svcOf(r.source), svcOf(r.target))))
|
|
1384
1550
|
continue;
|
|
1385
1551
|
if (activeCovers.some((c) => coversEdge(c, r, elements)))
|
|
1386
1552
|
continue;
|
|
1387
1553
|
findings.push({
|
|
1388
1554
|
severity: "warn",
|
|
1389
1555
|
code: "c4.uncovered",
|
|
1390
|
-
subject:
|
|
1391
|
-
message: `delta adds edge ${
|
|
1556
|
+
subject: svcOf(r.target),
|
|
1557
|
+
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
1558
|
});
|
|
1393
1559
|
}
|
|
1394
1560
|
// covers.unknown, feature scope. Resolution looks at the delta itself, the
|
|
@@ -1471,7 +1637,6 @@ function coverageFinding(label, reqs) {
|
|
|
1471
1637
|
/* ------------------------------------------------------------------ */
|
|
1472
1638
|
/* Text renderer */
|
|
1473
1639
|
/* ------------------------------------------------------------------ */
|
|
1474
|
-
const MARKER = { ok: "✓", warn: "⚠", error: "✗" };
|
|
1475
1640
|
/**
|
|
1476
1641
|
* `--errors-only` is a RENDERING lever, the way `--strict` is an exit-code
|
|
1477
1642
|
* lever: neither changes the report, and the `--json` payload is unaffected by
|
|
@@ -1499,7 +1664,7 @@ function renderText(targets, all, unverifiable, errorsOnly) {
|
|
|
1499
1664
|
// onto stderr meant a piped stdout silently lost them from the middle of
|
|
1500
1665
|
// the report and 2>&1 could reorder it; the exit code carries failure,
|
|
1501
1666
|
// and stderr stays reserved for refusals (the fail() path).
|
|
1502
|
-
const marker = hint.marker === false ? "" : `${
|
|
1667
|
+
const marker = hint.marker === false ? "" : `${SEVERITY_MARK[f.severity]} `;
|
|
1503
1668
|
console.log(`${" ".repeat(hint.indent ?? 0)}${marker}${f.message}`);
|
|
1504
1669
|
for (const d of f.details ?? [])
|
|
1505
1670
|
console.log(` ${hint.detailPrefix ?? ""}${d}`);
|
|
@@ -1515,7 +1680,6 @@ function renderText(targets, all, unverifiable, errorsOnly) {
|
|
|
1515
1680
|
return;
|
|
1516
1681
|
}
|
|
1517
1682
|
const s = summary(targets);
|
|
1518
|
-
const plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
1519
1683
|
console.log(`\n${plural(s.services, "service")}, ${plural(s.features, "feature")} — ` +
|
|
1520
1684
|
`${plural(s.errors, "error")}, ${plural(s.warnings, "warning")}`);
|
|
1521
1685
|
// One line for the whole fleet, never one per service: honest about the blind
|
|
@@ -1551,10 +1715,10 @@ function renderText(targets, all, unverifiable, errorsOnly) {
|
|
|
1551
1715
|
async function sourceScopeFindings(docsDir, service, repoDir) {
|
|
1552
1716
|
const paths = servicePaths(docsDir, service);
|
|
1553
1717
|
const out = [];
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
]) {
|
|
1718
|
+
// The axis pair is SPEC_AXES', not this function's: `serviceProvenance` grades
|
|
1719
|
+
// the same two files from the same list, and a scope check that walked a
|
|
1720
|
+
// shorter list than the grading would go quiet on the axis it forgot.
|
|
1721
|
+
for (const { path, file } of SPEC_AXES.map((axis) => ({ path: paths[axis.key], file: axis.file }))) {
|
|
1558
1722
|
if (!existsSync(path))
|
|
1559
1723
|
continue;
|
|
1560
1724
|
const sources = listField(await readFrontmatter(path), "sources");
|