@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
|
@@ -13,16 +13,53 @@ export function agentsStampVersion(text) {
|
|
|
13
13
|
const m = STAMP_RE.exec(text);
|
|
14
14
|
return m !== null && parseVersion(m[1]) !== null ? m[1] : null;
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
16
|
+
/** `X.Y.Z[-pre][+build]`, or null when the leading triple does not read as numbers. */
|
|
17
17
|
function parseVersion(v) {
|
|
18
|
-
const m = /^(\d+)\.(\d+)\.(\d+)(
|
|
19
|
-
|
|
18
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(v);
|
|
19
|
+
if (m === null)
|
|
20
|
+
return null;
|
|
21
|
+
return {
|
|
22
|
+
release: [Number(m[1]), Number(m[2]), Number(m[3])],
|
|
23
|
+
pre: m[4] === undefined ? [] : m[4].split("."),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Semver identifier precedence: numeric identifiers compare numerically and
|
|
28
|
+
* rank below alphanumeric ones, and a longer identifier list outranks a prefix
|
|
29
|
+
* of itself. -1 / 0 / 1, so the caller reads like the triple comparison above.
|
|
30
|
+
*/
|
|
31
|
+
function comparePre(a, b) {
|
|
32
|
+
// No prerelease outranks any prerelease — `1.2.0` is newer than `1.2.0-beta.9`
|
|
33
|
+
// — which is why this cannot be the plain lexicographic walk the identifiers get.
|
|
34
|
+
if (a.length === 0 || b.length === 0)
|
|
35
|
+
return a.length === b.length ? 0 : a.length === 0 ? 1 : -1;
|
|
36
|
+
for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
|
|
37
|
+
const [x, y] = [a[i], b[i]];
|
|
38
|
+
if (x === y)
|
|
39
|
+
continue;
|
|
40
|
+
const [nx, ny] = [/^\d+$/.test(x), /^\d+$/.test(y)];
|
|
41
|
+
if (nx && ny)
|
|
42
|
+
return Number(x) < Number(y) ? -1 : 1;
|
|
43
|
+
if (nx !== ny)
|
|
44
|
+
return nx ? -1 : 1;
|
|
45
|
+
return x < y ? -1 : 1;
|
|
46
|
+
}
|
|
47
|
+
return a.length === b.length ? 0 : a.length < b.length ? -1 : 1;
|
|
20
48
|
}
|
|
21
49
|
/**
|
|
22
|
-
* Does `stamp` name an older version than `binary`?
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
50
|
+
* Does `stamp` name an older version than `binary`? A stamp NEWER than the
|
|
51
|
+
* binary does not trail: that is an old binary, not a stale file, and the fix
|
|
52
|
+
* (upgrade loam) is not an AGENTS.md edit.
|
|
53
|
+
*
|
|
54
|
+
* Prerelease identifiers are compared, not ignored, and the difference is the
|
|
55
|
+
* whole reason this check exists. Comparing the numeric triple alone made
|
|
56
|
+
* `0.1.0-beta.1` and `0.1.0-beta.2` indistinguishable — and beta.2 is precisely
|
|
57
|
+
* the release that changed the FORM of every generated file (embedded protocol
|
|
58
|
+
* text became a pointer at `loam instructions`), with a CHANGELOG telling
|
|
59
|
+
* readers to delete the files and re-run `loam init`. The one upgrade that most
|
|
60
|
+
* needed the warning was the one bump shape that could not raise it. A
|
|
61
|
+
* prerelease line is where a generated file's form actually moves, because that
|
|
62
|
+
* is what a prerelease is for.
|
|
26
63
|
*/
|
|
27
64
|
export function versionTrails(stamp, binary) {
|
|
28
65
|
const s = parseVersion(stamp);
|
|
@@ -30,10 +67,10 @@ export function versionTrails(stamp, binary) {
|
|
|
30
67
|
if (s === null || b === null)
|
|
31
68
|
return false;
|
|
32
69
|
for (let i = 0; i < 3; i += 1) {
|
|
33
|
-
if (s[i] !== b[i])
|
|
34
|
-
return s[i] < b[i];
|
|
70
|
+
if (s.release[i] !== b.release[i])
|
|
71
|
+
return s.release[i] < b.release[i];
|
|
35
72
|
}
|
|
36
|
-
return
|
|
73
|
+
return comparePre(s.pre, b.pre) < 0;
|
|
37
74
|
}
|
|
38
75
|
/**
|
|
39
76
|
* The one finding `loam validate --all` owes the agent contract. `text` is the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agents-stamp.js","sourceRoot":"","sources":["../../src/core/agents-stamp.ts"],"names":[],"mappings":"AAgBA,4DAA4D;AAC5D,MAAM,UAAU,eAAe,CAAC,OAAe;IAC7C,OAAO,2BAA2B,OAAO,MAAM,CAAC;AAClD,CAAC;AAED,kFAAkF;AAClF,MAAM,QAAQ,GAAG,wCAAwC,CAAC;AAE1D;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAY;IAC7C,MAAM,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC9B,OAAO,CAAC,KAAK,IAAI,IAAI,YAAY,CAAC,CAAC,CAAC,CAAC,CAAE,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACnE,CAAC;
|
|
1
|
+
{"version":3,"file":"agents-stamp.js","sourceRoot":"","sources":["../../src/core/agents-stamp.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The AGENTS.md version stamp: detection, never refresh.\n *\n * AGENTS.md is written once by `loam init` and never touched again — that is\n * the ownership contract (agent.ts): it is a starting point, and a team's edits\n * to it outrank ours. But the file tells agents to branch on flag and code\n * tables, and those tables describe the binary that wrote them. OpenSpec hit\n * the same wall and answered with auto-refresh, which teaches everyone the file\n * belongs to the tool after all; loam answers with the smallest honest thing —\n * a version stamp in the generated file, and one warning when the stamp is\n * missing or trails the running binary. Reviewing the file and bumping the\n * stamp is a human act, and a hand-curated file silences the warning the same\n * way: by saying, in the stamp, that someone has looked.\n */\nimport type { Finding } from \"./report.js\";\n\n/** The stamp `loam init` writes at the top of AGENTS.md. */\nexport function agentsStampLine(version: string): string {\n return `<!-- generated by loam v${version} -->`;\n}\n\n/** Matches a stamp line anywhere in the file; the capture is the version text. */\nconst STAMP_RE = /<!--\\s*generated by loam v(\\S+?)\\s*-->/;\n\n/**\n * The version the stamp names, or null when there is no stamp (or one whose\n * version does not read as numbers — unparseable is indistinguishable from\n * absent, and both get the same advice).\n */\nexport function agentsStampVersion(text: string): string | null {\n const m = STAMP_RE.exec(text);\n return m !== null && parseVersion(m[1]!) !== null ? m[1]! : null;\n}\n\n/** A version split the way precedence is decided: the triple, then the prerelease identifiers. */\ninterface ParsedVersion {\n release: [number, number, number];\n /**\n * The dot-separated identifiers of a `-beta.2` suffix, empty for a final\n * release. Build metadata (`+sha`) is dropped — semver excludes it from\n * precedence, and two builds of one version are the same instructions.\n */\n pre: string[];\n}\n\n/** `X.Y.Z[-pre][+build]`, or null when the leading triple does not read as numbers. */\nfunction parseVersion(v: string): ParsedVersion | null {\n const m = /^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([0-9A-Za-z.-]+))?(?:\\+[0-9A-Za-z.-]+)?$/.exec(v);\n if (m === null) return null;\n return {\n release: [Number(m[1]), Number(m[2]), Number(m[3])],\n pre: m[4] === undefined ? [] : m[4].split(\".\"),\n };\n}\n\n/**\n * Semver identifier precedence: numeric identifiers compare numerically and\n * rank below alphanumeric ones, and a longer identifier list outranks a prefix\n * of itself. -1 / 0 / 1, so the caller reads like the triple comparison above.\n */\nfunction comparePre(a: string[], b: string[]): number {\n // No prerelease outranks any prerelease — `1.2.0` is newer than `1.2.0-beta.9`\n // — which is why this cannot be the plain lexicographic walk the identifiers get.\n if (a.length === 0 || b.length === 0) return a.length === b.length ? 0 : a.length === 0 ? 1 : -1;\n for (let i = 0; i < Math.min(a.length, b.length); i += 1) {\n const [x, y] = [a[i]!, b[i]!];\n if (x === y) continue;\n const [nx, ny] = [/^\\d+$/.test(x), /^\\d+$/.test(y)];\n if (nx && ny) return Number(x) < Number(y) ? -1 : 1;\n if (nx !== ny) return nx ? -1 : 1;\n return x < y ? -1 : 1;\n }\n return a.length === b.length ? 0 : a.length < b.length ? -1 : 1;\n}\n\n/**\n * Does `stamp` name an older version than `binary`? A stamp NEWER than the\n * binary does not trail: that is an old binary, not a stale file, and the fix\n * (upgrade loam) is not an AGENTS.md edit.\n *\n * Prerelease identifiers are compared, not ignored, and the difference is the\n * whole reason this check exists. Comparing the numeric triple alone made\n * `0.1.0-beta.1` and `0.1.0-beta.2` indistinguishable — and beta.2 is precisely\n * the release that changed the FORM of every generated file (embedded protocol\n * text became a pointer at `loam instructions`), with a CHANGELOG telling\n * readers to delete the files and re-run `loam init`. The one upgrade that most\n * needed the warning was the one bump shape that could not raise it. A\n * prerelease line is where a generated file's form actually moves, because that\n * is what a prerelease is for.\n */\nexport function versionTrails(stamp: string, binary: string): boolean {\n const s = parseVersion(stamp);\n const b = parseVersion(binary);\n if (s === null || b === null) return false;\n for (let i = 0; i < 3; i += 1) {\n if (s.release[i]! !== b.release[i]!) return s.release[i]! < b.release[i]!;\n }\n return comparePre(s.pre, b.pre) < 0;\n}\n\n/**\n * The one finding `loam validate --all` owes the agent contract. `text` is the\n * docs repo's AGENTS.md, or null when there is none — no file means no contract\n * to have drifted, and this check stays silent rather than inventing a second\n * meaning for its code. Returns null when the stamp is current (or newer).\n */\nexport function agentsStaleFinding(text: string | null, binary: string): Finding | null {\n if (text === null) return null;\n const stamp = agentsStampVersion(text);\n if (stamp === null) {\n return {\n severity: \"warn\",\n code: \"agents.stale\",\n message:\n \"AGENTS.md has no version stamp — it predates stamping or was hand-created, so nothing says \" +\n \"which loam its flag and code tables describe. Review it against the current `loam --help`, \" +\n `then add \\`${agentsStampLine(binary)}\\` near the top; a hand-curated file keeps the warning ` +\n \"off the same way, by keeping the stamp current.\",\n };\n }\n if (!versionTrails(stamp, binary)) return null;\n return {\n severity: \"warn\",\n code: \"agents.stale\",\n message:\n `AGENTS.md was written by loam v${stamp}; this binary is v${binary} — the flag and code tables ` +\n \"agents branch on may have moved. Review the file against the current `loam --help`, then \" +\n `update the stamp line to v${binary} (a hand-curated file silences this by bumping the stamp itself).`,\n };\n}\n"]}
|
package/dist/core/arch.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* guard (`covers.unknown`, warn); the emitters live with the other validate
|
|
25
25
|
* checks, this module owns only the grammar and the matching.
|
|
26
26
|
*/
|
|
27
|
-
import { elementService,
|
|
27
|
+
import { elementService, serviceResolver } from "./likec4.js";
|
|
28
28
|
/** Parse one comma-separated `Covers:` entry into its form. Never fails: an
|
|
29
29
|
* unclassifiable string is an element entry that will not resolve, and the
|
|
30
30
|
* covers.unknown message is a better diagnosis than a parse refusal. */
|
|
@@ -44,9 +44,35 @@ export function parseCoversEntry(raw) {
|
|
|
44
44
|
function namesElement(name, e) {
|
|
45
45
|
return e.id === name || elementService(e) === name;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* One `id -> service` resolver per document, not one per lookup.
|
|
49
|
+
*
|
|
50
|
+
* `serviceOf` builds a fresh id map on every call, and this axis calls it twice
|
|
51
|
+
* for every relationship in scope — inside loops that already run over every
|
|
52
|
+
* relationship a service can see. `serviceResolver`'s own doc says it is "built
|
|
53
|
+
* once per document and shared"; this is where the Covers axis keeps that
|
|
54
|
+
* promise, since `coversEdge` is exported and its callers hold only the element
|
|
55
|
+
* array.
|
|
56
|
+
*
|
|
57
|
+
* A module-level cache is normally the wrong shape here, because a command runs
|
|
58
|
+
* against whatever directory it was invoked in. This one is safe: the key is the
|
|
59
|
+
* per-invocation `Elem[]` a parse produced, its value is a pure function of that
|
|
60
|
+
* key alone, and nothing in it is derived from the working directory — so two
|
|
61
|
+
* runs can never see each other's answer, and the entry dies with the document.
|
|
62
|
+
* Nothing mutates an `Elem[]` after parse.
|
|
63
|
+
*/
|
|
64
|
+
const RESOLVERS = new WeakMap();
|
|
65
|
+
function resolverFor(elements) {
|
|
66
|
+
const cached = RESOLVERS.get(elements);
|
|
67
|
+
if (cached !== undefined)
|
|
68
|
+
return cached;
|
|
69
|
+
const resolver = serviceResolver(elements);
|
|
70
|
+
RESOLVERS.set(elements, resolver);
|
|
71
|
+
return resolver;
|
|
72
|
+
}
|
|
47
73
|
/** Does an edge entry's side name this relationship endpoint? */
|
|
48
74
|
function namesEndpoint(name, endpointId, elements) {
|
|
49
|
-
return endpointId === name ||
|
|
75
|
+
return endpointId === name || resolverFor(elements)(endpointId) === name;
|
|
50
76
|
}
|
|
51
77
|
/** Does a Covers entry match this element? (Only element entries can.) */
|
|
52
78
|
export function coversElement(entry, e) {
|
package/dist/core/arch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"arch.js","sourceRoot":"","sources":["../../src/core/arch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EAAE,cAAc,EAAE,SAAS,EAAuB,MAAM,aAAa,CAAC;AAQ7E;;wEAEwE;AACxE,MAAM,UAAU,gBAAgB,CAAC,GAAW;IAC1C,MAAM,KAAK,GAAG,iBAAiB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC1C,IAAI,KAAK;QAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC,CAAE,CAAC,IAAI,EAAE,EAAE,GAAG,EAAE,CAAC;IAC/D,MAAM,GAAG,GAAG,eAAe,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACtC,IAAI,GAAG;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,GAAG,CAAC,CAAC,CAAE,CAAC,IAAI,EAAE,EAAE,GAAG,EAAE,CAAC;IACzD,MAAM,IAAI,GAAG,qBAAqB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC7C,IAAI,IAAI;QAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC,CAAE,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC,CAAE,CAAC,IAAI,EAAE,EAAE,GAAG,EAAE,CAAC;IACzF,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC;AAC3C,CAAC;AAaD,qFAAqF;AACrF,SAAS,YAAY,CAAC,IAAY,EAAE,CAAO;IACzC,OAAO,CAAC,CAAC,EAAE,KAAK,IAAI,IAAI,cAAc,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;AACrD,CAAC;AAED,iEAAiE;AACjE,SAAS,aAAa,CAAC,IAAY,EAAE,UAAkB,EAAE,QAAgB;IACvE,OAAO,UAAU,KAAK,IAAI,IAAI,SAAS,CAAC,QAAQ,EAAE,UAAU,CAAC,KAAK,IAAI,CAAC;AACzE,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,aAAa,CAAC,KAAkB,EAAE,CAAO;IACvD,OAAO,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,YAAY,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;AAC/D,CAAC;AAED,yFAAyF;AACzF,MAAM,UAAU,UAAU,CAAC,KAAkB,EAAE,CAAM,EAAE,QAAgB;IACrE,OAAO,CACL,KAAK,CAAC,IAAI,KAAK,MAAM;QACrB,aAAa,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,QAAQ,CAAC;QAC/C,aAAa,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,QAAQ,CAAC,CAChD,CAAC;AACJ,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,aAAa,CAAC,KAAkB,EAAE,KAAoB;IACpE,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;QACnB,KAAK,OAAO;YACV,OAAO,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAChD,KAAK,KAAK;YACR,OAAO,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAC9C,KAAK,MAAM;YACT,OAAO,KAAK,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;QAC/E,KAAK,SAAS;YACZ,OAAO,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,aAAa,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC;IAC/D,CAAC;AACH,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,gBAAgB,CAAC,KAAkB,EAAE,KAAoB;IACvE,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;QACnB,KAAK,OAAO;YACV,OAAO,QAAQ,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;QAC5E,KAAK,KAAK;YACR,OAAO,QAAQ,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QACxE,KAAK,MAAM;YACT,qEAAqE;YACrE,gDAAgD;YAChD,OAAO,EAAE,CAAC;QACZ,KAAK,SAAS;YACZ,OAAO,QAAQ,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7E,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,QAAQ,CAAC,IAAY,EAAE,GAAa;IAClD,MAAM,CAAC,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;IAC7B,OAAO,GAAG;SACP,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE;QACb,MAAM,CAAC,GAAG,EAAE,CAAC,WAAW,EAAE,CAAC;QAC3B,OAAO,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1F,CAAC,CAAC;SACD,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AACjB,CAAC","sourcesContent":["/**\n * The architecture spec axis — the grammar and resolution of `Covers:`.\n *\n * A business spec will never mention the transactional outbox; that is\n * architecture, and it lives in `arch.spec.md` — same requirement/scenario\n * grammar as spec.md, same delta algebra, same merge. What is new is the\n * `Covers:` line: where a business requirement declares the OPERATIONS it\n * governs, an architecture requirement declares the MODEL OBJECTS its\n * scenarios exercise, so coverage can be derived mechanically instead of\n * trusted to an author who was never going to write the outbox down.\n *\n * Three entry forms, resolved against the documents loam already reads:\n *\n * paymentService.db a C4 element id — resolved against the\n * in-scope elements the way every check joins\n * them: the id itself, or the service the\n * element stands for (binding, then title);\n * paymentService -> kafka an edge — each side resolved the same way,\n * against the declared relationships;\n * alert:<id> / sli:<id> a health signal — ids `health.yaml`\n * declares (core/health.ts).\n *\n * Resolution never reads code. An entry that resolves to nothing is the typo\n * guard (`covers.unknown`, warn); the emitters live with the other validate\n * checks, this module owns only the grammar and the matching.\n */\nimport { elementService, serviceOf, type Elem, type Rel } from \"./likec4.js\";\nimport type { HealthIds } from \"./health.js\";\n\nexport type CoversEntry =\n | { form: \"element\"; id: string; raw: string }\n | { form: \"edge\"; source: string; target: string; raw: string }\n | { form: \"alert\" | \"sli\"; id: string; raw: string };\n\n/** Parse one comma-separated `Covers:` entry into its form. Never fails: an\n * unclassifiable string is an element entry that will not resolve, and the\n * covers.unknown message is a better diagnosis than a parse refusal. */\nexport function parseCoversEntry(raw: string): CoversEntry {\n const alert = /^alert:\\s*(.+)$/.exec(raw);\n if (alert) return { form: \"alert\", id: alert[1]!.trim(), raw };\n const sli = /^sli:\\s*(.+)$/.exec(raw);\n if (sli) return { form: \"sli\", id: sli[1]!.trim(), raw };\n const edge = /^(.+?)\\s*->\\s*(.+)$/.exec(raw);\n if (edge) return { form: \"edge\", source: edge[1]!.trim(), target: edge[2]!.trim(), raw };\n return { form: \"element\", id: raw, raw };\n}\n\n/**\n * What `Covers:` entries resolve against: every element and relationship in\n * view (a service's own model plus the landscape; for a feature, the delta\n * plus both), and the health ids of the service whose spec is being read.\n */\nexport interface CoverageScope {\n elements: Elem[];\n relationships: Rel[];\n health: HealthIds;\n}\n\n/** Does an element entry name this element? Its id, or the service it stands for. */\nfunction namesElement(name: string, e: Elem): boolean {\n return e.id === name || elementService(e) === name;\n}\n\n/** Does an edge entry's side name this relationship endpoint? */\nfunction namesEndpoint(name: string, endpointId: string, elements: Elem[]): boolean {\n return endpointId === name || serviceOf(elements, endpointId) === name;\n}\n\n/** Does a Covers entry match this element? (Only element entries can.) */\nexport function coversElement(entry: CoversEntry, e: Elem): boolean {\n return entry.form === \"element\" && namesElement(entry.id, e);\n}\n\n/** Does a Covers entry match this relationship? Endpoints resolved within `elements`. */\nexport function coversEdge(entry: CoversEntry, r: Rel, elements: Elem[]): boolean {\n return (\n entry.form === \"edge\" &&\n namesEndpoint(entry.source, r.source, elements) &&\n namesEndpoint(entry.target, r.target, elements)\n );\n}\n\n/** Does the entry resolve to ANYTHING in scope? False is `covers.unknown`. */\nexport function entryResolves(entry: CoversEntry, scope: CoverageScope): boolean {\n switch (entry.form) {\n case \"alert\":\n return scope.health.alerts.includes(entry.id);\n case \"sli\":\n return scope.health.slis.includes(entry.id);\n case \"edge\":\n return scope.relationships.some((r) => coversEdge(entry, r, scope.elements));\n case \"element\":\n return scope.elements.some((e) => coversElement(entry, e));\n }\n}\n\n/** Ids a covers.unknown hint may offer for a mistyped entry — always real ones. */\nexport function coversCandidates(entry: CoversEntry, scope: CoverageScope): string[] {\n switch (entry.form) {\n case \"alert\":\n return closeIds(entry.id, scope.health.alerts).map((id) => `alert:${id}`);\n case \"sli\":\n return closeIds(entry.id, scope.health.slis).map((id) => `sli:${id}`);\n case \"edge\":\n // Cheap on purpose: no pairwise edge fuzzing — the sides are element\n // names, so the element hint is the useful one.\n return [];\n case \"element\":\n return closeIds(entry.id, [...new Set(scope.elements.map((e) => e.id))]);\n }\n}\n\n/**\n * Existing ids near a misspelling — substring containment either way, else a\n * shared 3-character prefix. Deliberately dumb: no fuzzy library for one hint,\n * and every id offered is real, so the hint can never point at the typo itself.\n * (Moved here from validate.ts once `covers.unknown` became its second user.)\n */\nexport function closeIds(typo: string, ids: string[]): string[] {\n const t = typo.toLowerCase();\n return ids\n .filter((id) => {\n const i = id.toLowerCase();\n return i.includes(t) || t.includes(i) || (t.length >= 3 && i.startsWith(t.slice(0, 3)));\n })\n .slice(0, 5);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"arch.js","sourceRoot":"","sources":["../../src/core/arch.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The architecture spec axis — the grammar and resolution of `Covers:`.\n *\n * A business spec will never mention the transactional outbox; that is\n * architecture, and it lives in `arch.spec.md` — same requirement/scenario\n * grammar as spec.md, same delta algebra, same merge. What is new is the\n * `Covers:` line: where a business requirement declares the OPERATIONS it\n * governs, an architecture requirement declares the MODEL OBJECTS its\n * scenarios exercise, so coverage can be derived mechanically instead of\n * trusted to an author who was never going to write the outbox down.\n *\n * Three entry forms, resolved against the documents loam already reads:\n *\n * paymentService.db a C4 element id — resolved against the\n * in-scope elements the way every check joins\n * them: the id itself, or the service the\n * element stands for (binding, then title);\n * paymentService -> kafka an edge — each side resolved the same way,\n * against the declared relationships;\n * alert:<id> / sli:<id> a health signal — ids `health.yaml`\n * declares (core/health.ts).\n *\n * Resolution never reads code. An entry that resolves to nothing is the typo\n * guard (`covers.unknown`, warn); the emitters live with the other validate\n * checks, this module owns only the grammar and the matching.\n */\nimport { elementService, serviceResolver, type Elem, type Rel } from \"./likec4.js\";\nimport type { HealthIds } from \"./health.js\";\n\nexport type CoversEntry =\n | { form: \"element\"; id: string; raw: string }\n | { form: \"edge\"; source: string; target: string; raw: string }\n | { form: \"alert\" | \"sli\"; id: string; raw: string };\n\n/** Parse one comma-separated `Covers:` entry into its form. Never fails: an\n * unclassifiable string is an element entry that will not resolve, and the\n * covers.unknown message is a better diagnosis than a parse refusal. */\nexport function parseCoversEntry(raw: string): CoversEntry {\n const alert = /^alert:\\s*(.+)$/.exec(raw);\n if (alert) return { form: \"alert\", id: alert[1]!.trim(), raw };\n const sli = /^sli:\\s*(.+)$/.exec(raw);\n if (sli) return { form: \"sli\", id: sli[1]!.trim(), raw };\n const edge = /^(.+?)\\s*->\\s*(.+)$/.exec(raw);\n if (edge) return { form: \"edge\", source: edge[1]!.trim(), target: edge[2]!.trim(), raw };\n return { form: \"element\", id: raw, raw };\n}\n\n/**\n * What `Covers:` entries resolve against: every element and relationship in\n * view (a service's own model plus the landscape; for a feature, the delta\n * plus both), and the health ids of the service whose spec is being read.\n */\nexport interface CoverageScope {\n elements: Elem[];\n relationships: Rel[];\n health: HealthIds;\n}\n\n/** Does an element entry name this element? Its id, or the service it stands for. */\nfunction namesElement(name: string, e: Elem): boolean {\n return e.id === name || elementService(e) === name;\n}\n\n/**\n * One `id -> service` resolver per document, not one per lookup.\n *\n * `serviceOf` builds a fresh id map on every call, and this axis calls it twice\n * for every relationship in scope — inside loops that already run over every\n * relationship a service can see. `serviceResolver`'s own doc says it is \"built\n * once per document and shared\"; this is where the Covers axis keeps that\n * promise, since `coversEdge` is exported and its callers hold only the element\n * array.\n *\n * A module-level cache is normally the wrong shape here, because a command runs\n * against whatever directory it was invoked in. This one is safe: the key is the\n * per-invocation `Elem[]` a parse produced, its value is a pure function of that\n * key alone, and nothing in it is derived from the working directory — so two\n * runs can never see each other's answer, and the entry dies with the document.\n * Nothing mutates an `Elem[]` after parse.\n */\nconst RESOLVERS = new WeakMap<Elem[], (id: string) => string>();\n\nfunction resolverFor(elements: Elem[]): (id: string) => string {\n const cached = RESOLVERS.get(elements);\n if (cached !== undefined) return cached;\n const resolver = serviceResolver(elements);\n RESOLVERS.set(elements, resolver);\n return resolver;\n}\n\n/** Does an edge entry's side name this relationship endpoint? */\nfunction namesEndpoint(name: string, endpointId: string, elements: Elem[]): boolean {\n return endpointId === name || resolverFor(elements)(endpointId) === name;\n}\n\n/** Does a Covers entry match this element? (Only element entries can.) */\nexport function coversElement(entry: CoversEntry, e: Elem): boolean {\n return entry.form === \"element\" && namesElement(entry.id, e);\n}\n\n/** Does a Covers entry match this relationship? Endpoints resolved within `elements`. */\nexport function coversEdge(entry: CoversEntry, r: Rel, elements: Elem[]): boolean {\n return (\n entry.form === \"edge\" &&\n namesEndpoint(entry.source, r.source, elements) &&\n namesEndpoint(entry.target, r.target, elements)\n );\n}\n\n/** Does the entry resolve to ANYTHING in scope? False is `covers.unknown`. */\nexport function entryResolves(entry: CoversEntry, scope: CoverageScope): boolean {\n switch (entry.form) {\n case \"alert\":\n return scope.health.alerts.includes(entry.id);\n case \"sli\":\n return scope.health.slis.includes(entry.id);\n case \"edge\":\n return scope.relationships.some((r) => coversEdge(entry, r, scope.elements));\n case \"element\":\n return scope.elements.some((e) => coversElement(entry, e));\n }\n}\n\n/** Ids a covers.unknown hint may offer for a mistyped entry — always real ones. */\nexport function coversCandidates(entry: CoversEntry, scope: CoverageScope): string[] {\n switch (entry.form) {\n case \"alert\":\n return closeIds(entry.id, scope.health.alerts).map((id) => `alert:${id}`);\n case \"sli\":\n return closeIds(entry.id, scope.health.slis).map((id) => `sli:${id}`);\n case \"edge\":\n // Cheap on purpose: no pairwise edge fuzzing — the sides are element\n // names, so the element hint is the useful one.\n return [];\n case \"element\":\n return closeIds(entry.id, [...new Set(scope.elements.map((e) => e.id))]);\n }\n}\n\n/**\n * Existing ids near a misspelling — substring containment either way, else a\n * shared 3-character prefix. Deliberately dumb: no fuzzy library for one hint,\n * and every id offered is real, so the hint can never point at the typo itself.\n * (Moved here from validate.ts once `covers.unknown` became its second user.)\n */\nexport function closeIds(typo: string, ids: string[]): string[] {\n const t = typo.toLowerCase();\n return ids\n .filter((id) => {\n const i = id.toLowerCase();\n return i.includes(t) || t.includes(i) || (t.length >= 3 && i.startsWith(t.slice(0, 3)));\n })\n .slice(0, 5);\n}\n"]}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { isUtf8 } from "node:buffer";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import { servicePaths } from "./repo.js";
|
|
6
|
+
/**
|
|
7
|
+
* The async contract axis: AsyncAPI 3.0, read the way `core/openapi.ts` reads
|
|
8
|
+
* OpenAPI — a shallow structural walk over the `yaml` parse, never a real
|
|
9
|
+
* AsyncAPI parser. `@asyncapi/parser` would be a fourth runtime dependency
|
|
10
|
+
* (AGENTS.md: adding one is a product decision), and loam needs a slice of the
|
|
11
|
+
* document so thin that the parser's whole model would be dead weight.
|
|
12
|
+
*
|
|
13
|
+
* 3.0 only, deliberately. Its operations are named top-level objects carrying
|
|
14
|
+
* `action: send|receive`, which is the exact analog of an `operationId`; 2.x
|
|
15
|
+
* spells the same thing as `publish`/`subscribe` nested under a channel, where
|
|
16
|
+
* the two words are notoriously ambiguous about whose perspective they take.
|
|
17
|
+
* Supporting one dialect is cheaper than supporting two, and a 2.x document
|
|
18
|
+
* declares no `operations` at all — so it reads as a contract with no messages
|
|
19
|
+
* rather than as a silently mis-parsed one.
|
|
20
|
+
*
|
|
21
|
+
* WHAT THIS READER MUST NEVER DO: look inside a message's `payload`. The payload
|
|
22
|
+
* is JSON Schema today and may be Avro later, and that switch is meant to be a
|
|
23
|
+
* line in the document (`schemaFormat`) rather than a branch in loam. Every
|
|
24
|
+
* check on this axis joins on a message NAME; nothing joins on a field. The day
|
|
25
|
+
* something here reads a `required` array or a `fields` list, the Avro migration
|
|
26
|
+
* stops being free.
|
|
27
|
+
*/
|
|
28
|
+
/** How deep a `$ref` chain is followed before the reader gives up. */
|
|
29
|
+
const MAX_REF_DEPTH = 8;
|
|
30
|
+
/**
|
|
31
|
+
* Resolve an internal JSON Pointer (`#/a/b/c`) against the document root.
|
|
32
|
+
*
|
|
33
|
+
* Internal only, matching the OpenAPI axis's documented stance: external
|
|
34
|
+
* references — URLs, file paths, anything not starting `#/` — are out of scope,
|
|
35
|
+
* left untouched and never graded. That is also why authors are told to keep
|
|
36
|
+
* payloads inside the document rather than `$ref`-ing an external `.avsc`: the
|
|
37
|
+
* merge cannot carry what this reader cannot see.
|
|
38
|
+
*/
|
|
39
|
+
function resolvePointer(root, ref) {
|
|
40
|
+
if (typeof ref !== "string" || !ref.startsWith("#/"))
|
|
41
|
+
return undefined;
|
|
42
|
+
let node = root;
|
|
43
|
+
for (const raw of ref.slice(2).split("/")) {
|
|
44
|
+
// JSON Pointer escaping, `~1` before `~0` so an encoded `~1` survives.
|
|
45
|
+
const seg = raw.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
46
|
+
if (node === null || typeof node !== "object")
|
|
47
|
+
return undefined;
|
|
48
|
+
node = node[seg];
|
|
49
|
+
}
|
|
50
|
+
return node;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Follow `$ref` hops until a node that is not a reference, or the depth budget
|
|
54
|
+
* runs out. The budget is the cycle guard: a document whose `$ref` points at
|
|
55
|
+
* itself is legal YAML, and an unbounded walk would hang the whole validate run
|
|
56
|
+
* rather than reporting a contract nobody can read.
|
|
57
|
+
*
|
|
58
|
+
* Returns the resolved node and the last pointer segment that reached it — the
|
|
59
|
+
* segment is the declaration key, which is the message-name fallback, and it is
|
|
60
|
+
* lost by the time a caller holds only the node.
|
|
61
|
+
*/
|
|
62
|
+
function deref(root, node, key) {
|
|
63
|
+
let current = node;
|
|
64
|
+
let currentKey = key;
|
|
65
|
+
for (let hop = 0; hop < MAX_REF_DEPTH; hop += 1) {
|
|
66
|
+
if (current === null || typeof current !== "object" || Array.isArray(current))
|
|
67
|
+
break;
|
|
68
|
+
const ref = current["$ref"];
|
|
69
|
+
if (typeof ref !== "string")
|
|
70
|
+
break;
|
|
71
|
+
const resolved = resolvePointer(root, ref);
|
|
72
|
+
if (resolved === undefined)
|
|
73
|
+
return { node: undefined, key: currentKey };
|
|
74
|
+
const seg = ref.slice(ref.lastIndexOf("/") + 1);
|
|
75
|
+
current = resolved;
|
|
76
|
+
// An alias keeps the TARGET's key, not the local one it was filed under: a
|
|
77
|
+
// channel may list `#/components/messages/PaymentAuthorized` under any alias
|
|
78
|
+
// it likes, and the fallback name has to be the declaration's own identity
|
|
79
|
+
// or two services aliasing one message differently would stop joining.
|
|
80
|
+
currentKey = seg.length > 0 ? seg : currentKey;
|
|
81
|
+
}
|
|
82
|
+
return { node: current, key: currentKey };
|
|
83
|
+
}
|
|
84
|
+
/** A message's join token: its `name`, or the key it is declared under. */
|
|
85
|
+
function messageName(node, key) {
|
|
86
|
+
if (node !== null && typeof node === "object" && !Array.isArray(node)) {
|
|
87
|
+
const name = node["name"];
|
|
88
|
+
if (typeof name === "string" && name.trim().length > 0)
|
|
89
|
+
return name.trim();
|
|
90
|
+
}
|
|
91
|
+
return key;
|
|
92
|
+
}
|
|
93
|
+
/** Entries of a mapping node, or nothing — every structural read here goes through it. */
|
|
94
|
+
function entriesOf(node) {
|
|
95
|
+
if (node === null || typeof node !== "object" || Array.isArray(node))
|
|
96
|
+
return [];
|
|
97
|
+
return Object.entries(node);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Read an AsyncAPI 3.0 document into the slice loam joins on: the messages it
|
|
101
|
+
* declares, and which of them it sends and receives.
|
|
102
|
+
*
|
|
103
|
+
* The send/receive sets are what make the spine directional, and the direction
|
|
104
|
+
* is not decoration: on the HTTP axis the PROVIDER owns the contract and a
|
|
105
|
+
* consumer's edge is checked against it, while here the PRODUCER owns the
|
|
106
|
+
* message and a consumer joins to it from another repository entirely. That
|
|
107
|
+
* inversion is why `spine.message-unproduced` has no OpenAPI analog.
|
|
108
|
+
*
|
|
109
|
+
* An unreadable document yields empty sets PLUS the `unreadable` flag, the
|
|
110
|
+
* policy every reader here follows: parsers never diagnose. Swallowing the
|
|
111
|
+
* failure into empty sets would make a broken contract indistinguishable from
|
|
112
|
+
* one that declares nothing, and every inbound edge would be graded
|
|
113
|
+
* `spine.message-undefined` — a false diagnosis pointing at the landscape when
|
|
114
|
+
* the truth is this file.
|
|
115
|
+
*/
|
|
116
|
+
export async function readAsyncapi(asyncapiPath, context) {
|
|
117
|
+
if (context !== undefined)
|
|
118
|
+
return context.readAsyncapi(asyncapiPath);
|
|
119
|
+
if (!existsSync(asyncapiPath))
|
|
120
|
+
return empty();
|
|
121
|
+
const bytes = await readFile(asyncapiPath);
|
|
122
|
+
// Bytes that are not UTF-8 are as unreadable as broken YAML and for a worse
|
|
123
|
+
// reason: `toString("utf8")` substitutes U+FFFD and hands the parser a
|
|
124
|
+
// document nobody wrote. The openapi axis learned this the same way.
|
|
125
|
+
if (!isUtf8(bytes))
|
|
126
|
+
return { ...empty(), unreadable: true, error: "file is not valid UTF-8" };
|
|
127
|
+
let doc;
|
|
128
|
+
try {
|
|
129
|
+
doc = parse(bytes.toString("utf8"));
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return { ...empty(), unreadable: true, error: e instanceof Error ? e.message : String(e) };
|
|
133
|
+
}
|
|
134
|
+
// A scalar or sequence document has no mapping to look `channels` up in, so
|
|
135
|
+
// nothing can be concluded from it. null (an empty file) stays readable — it
|
|
136
|
+
// declares nothing, and says so honestly.
|
|
137
|
+
if (doc !== null && (typeof doc !== "object" || Array.isArray(doc))) {
|
|
138
|
+
return { ...empty(), unreadable: true, error: "document is not a YAML mapping" };
|
|
139
|
+
}
|
|
140
|
+
if (doc === null)
|
|
141
|
+
return empty();
|
|
142
|
+
const root = doc;
|
|
143
|
+
const messages = [];
|
|
144
|
+
const seen = new Map();
|
|
145
|
+
const declare = (name, slot) => {
|
|
146
|
+
messages.push({ name, slot });
|
|
147
|
+
seen.set(name, [...(seen.get(name) ?? []), slot]);
|
|
148
|
+
};
|
|
149
|
+
// `components.messages` — the ordinary place a message is declared.
|
|
150
|
+
for (const [key, node] of entriesOf(root["components"]?.["messages"])) {
|
|
151
|
+
declare(messageName(node, key), `components.messages.${key}`);
|
|
152
|
+
}
|
|
153
|
+
// `channels.<ck>.messages.<mk>` — an entry here is either an ALIAS of a
|
|
154
|
+
// components declaration (a `$ref`) or an inline declaration of its own. Only
|
|
155
|
+
// the second is a new slot: counting an alias would report every properly
|
|
156
|
+
// factored document as declaring each message twice, which is the shape the
|
|
157
|
+
// spec's own examples use.
|
|
158
|
+
const channelMessages = new Map();
|
|
159
|
+
for (const [ck, channel] of entriesOf(root["channels"])) {
|
|
160
|
+
const names = [];
|
|
161
|
+
for (const [mk, entry] of entriesOf(channel?.["messages"])) {
|
|
162
|
+
const isAlias = entry !== null && typeof entry === "object" && !Array.isArray(entry) && "$ref" in entry;
|
|
163
|
+
const { node, key } = deref(root, entry, mk);
|
|
164
|
+
const name = messageName(node, key);
|
|
165
|
+
names.push(name);
|
|
166
|
+
if (!isAlias)
|
|
167
|
+
declare(name, `channels.${ck}.messages.${mk}`);
|
|
168
|
+
}
|
|
169
|
+
channelMessages.set(ck, names);
|
|
170
|
+
}
|
|
171
|
+
const sent = new Set();
|
|
172
|
+
const received = new Set();
|
|
173
|
+
for (const [, operation] of entriesOf(root["operations"])) {
|
|
174
|
+
const op = operation;
|
|
175
|
+
const action = op?.["action"];
|
|
176
|
+
if (action !== "send" && action !== "receive")
|
|
177
|
+
continue;
|
|
178
|
+
const into = action === "send" ? sent : received;
|
|
179
|
+
// The channel this operation acts on, by pointer — its key is what the
|
|
180
|
+
// fallback below indexes.
|
|
181
|
+
const channelRef = op?.["channel"]?.["$ref"];
|
|
182
|
+
const channelKey = typeof channelRef === "string" ? channelRef.slice(channelRef.lastIndexOf("/") + 1) : undefined;
|
|
183
|
+
const listed = op?.["messages"];
|
|
184
|
+
if (Array.isArray(listed) && listed.length > 0) {
|
|
185
|
+
for (const entry of listed) {
|
|
186
|
+
const { node, key } = deref(root, entry, "");
|
|
187
|
+
// A pointer that resolves nowhere contributes no name rather than an
|
|
188
|
+
// empty one: a phantom "" would join to nothing and read as a message
|
|
189
|
+
// the service produces.
|
|
190
|
+
if (node === undefined && key === "")
|
|
191
|
+
continue;
|
|
192
|
+
const name = messageName(node, key);
|
|
193
|
+
if (name.length > 0)
|
|
194
|
+
into.add(name);
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// AsyncAPI 3: an operation that lists no messages applies to every message
|
|
199
|
+
// of its channel. Omitting the list is the normal shape of a minimal
|
|
200
|
+
// hand-written contract — exactly what a fleet with no registry writes — so
|
|
201
|
+
// reading it as "produces nothing" would report the common case broken.
|
|
202
|
+
if (channelKey !== undefined)
|
|
203
|
+
for (const name of channelMessages.get(channelKey) ?? [])
|
|
204
|
+
into.add(name);
|
|
205
|
+
}
|
|
206
|
+
const duplicateNames = [...seen].filter(([, slots]) => slots.length > 1).map(([name]) => name);
|
|
207
|
+
return { messages, sent: [...sent], received: [...received], duplicateNames, unreadable: false };
|
|
208
|
+
}
|
|
209
|
+
function empty() {
|
|
210
|
+
return { messages: [], sent: [], received: [], duplicateNames: [], unreadable: false };
|
|
211
|
+
}
|
|
212
|
+
/** The slots one message name is declared in — what `asyncapi.duplicate-message` names. */
|
|
213
|
+
export function slotsOf(doc, name) {
|
|
214
|
+
return doc.messages.filter((m) => m.name === name).map((m) => m.slot);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Which services in the fleet declare an `action: send` for each message name.
|
|
218
|
+
*
|
|
219
|
+
* The one question on this axis that no single repository can answer, and the
|
|
220
|
+
* reason `validate --service` reads more than its own service here: a consumer
|
|
221
|
+
* joins to a message the PRODUCER owns, so "does anybody publish this" is a
|
|
222
|
+
* fleet fact. Zero producers is `spine.message-unproduced`; two are
|
|
223
|
+
* `asyncapi.message-contested`, where every consumer's join picks one
|
|
224
|
+
* arbitrarily.
|
|
225
|
+
*
|
|
226
|
+
* The two answers rest on different evidence, and only one of them survives an
|
|
227
|
+
* unreadable contract. Finding two producers is POSITIVE evidence and stays
|
|
228
|
+
* true whatever else in the fleet fails to parse. Finding none is an argument
|
|
229
|
+
* from absence, and one broken file makes it worthless — skipping that service
|
|
230
|
+
* silently would turn its outage into one `spine.message-unproduced` per
|
|
231
|
+
* consuming edge across the whole fleet, every one of them pointing at the
|
|
232
|
+
* wrong repository. So the unreadable services are reported rather than
|
|
233
|
+
* skipped, and the caller suspends the negative answer while any exist.
|
|
234
|
+
*
|
|
235
|
+
* The cost is N small YAML parses, not N LikeC4 workspace spins — the latter is
|
|
236
|
+
* what makes `validate --all` expensive (SCHEMA.md, "Operating at fleet scale"),
|
|
237
|
+
* and a context memoizes these reads across the run.
|
|
238
|
+
*/
|
|
239
|
+
export async function producersByMessage(docsDir, services, context) {
|
|
240
|
+
const byMessage = new Map();
|
|
241
|
+
const unreadable = [];
|
|
242
|
+
for (const service of services) {
|
|
243
|
+
const doc = await readAsyncapi(servicePaths(docsDir, service).asyncapi, context);
|
|
244
|
+
if (doc.unreadable) {
|
|
245
|
+
unreadable.push(service);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
for (const name of doc.sent)
|
|
249
|
+
byMessage.set(name, [...(byMessage.get(name) ?? []), service]);
|
|
250
|
+
}
|
|
251
|
+
return { byMessage, unreadable };
|
|
252
|
+
}
|
|
253
|
+
//# sourceMappingURL=asyncapi.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"asyncapi.js","sourceRoot":"","sources":["../../src/core/asyncapi.ts"],"names":[],"mappings":"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","sourcesContent":["import { isUtf8 } from \"node:buffer\";\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport { parse } from \"yaml\";\nimport type { FleetContext } from \"./fleet-context.js\";\nimport { servicePaths } from \"./repo.js\";\n\n/**\n * The async contract axis: AsyncAPI 3.0, read the way `core/openapi.ts` reads\n * OpenAPI — a shallow structural walk over the `yaml` parse, never a real\n * AsyncAPI parser. `@asyncapi/parser` would be a fourth runtime dependency\n * (AGENTS.md: adding one is a product decision), and loam needs a slice of the\n * document so thin that the parser's whole model would be dead weight.\n *\n * 3.0 only, deliberately. Its operations are named top-level objects carrying\n * `action: send|receive`, which is the exact analog of an `operationId`; 2.x\n * spells the same thing as `publish`/`subscribe` nested under a channel, where\n * the two words are notoriously ambiguous about whose perspective they take.\n * Supporting one dialect is cheaper than supporting two, and a 2.x document\n * declares no `operations` at all — so it reads as a contract with no messages\n * rather than as a silently mis-parsed one.\n *\n * WHAT THIS READER MUST NEVER DO: look inside a message's `payload`. The payload\n * is JSON Schema today and may be Avro later, and that switch is meant to be a\n * line in the document (`schemaFormat`) rather than a branch in loam. Every\n * check on this axis joins on a message NAME; nothing joins on a field. The day\n * something here reads a `required` array or a `fields` list, the Avro migration\n * stops being free.\n */\n\n/** How deep a `$ref` chain is followed before the reader gives up. */\nconst MAX_REF_DEPTH = 8;\n\n/** One message declaration, as the reader sees it. */\nexport interface EventMessage {\n /**\n * The join token — what a landscape edge's `metadata { publishes }` and a\n * requirement's `Publishes:` line name.\n *\n * The Message Object's own `name` when it has one, otherwise the key it is\n * declared under. Both are legal AsyncAPI, and the key is what an author who\n * never wrote a `name` would reasonably expect to reference; falling back to\n * it means a minimal hand-written contract still joins to the spine.\n */\n name: string;\n /**\n * Where it was declared — `components.messages.<key>` or\n * `channels.<key>.messages.<key>`. Carried so `asyncapi.duplicate-message` can\n * name both slots instead of only saying that a name repeats, the way\n * `openapi.duplicate-operationid` names both `METHOD /path` slots.\n */\n slot: string;\n}\n\n/** The parse of one AsyncAPI document: what it declares, and whether it could be read at all. */\nexport interface AsyncapiDoc {\n /** Every message declaration in the document, in document order. */\n messages: EventMessage[];\n /** Message names reachable from an `action: send` operation — what this service PRODUCES. */\n sent: string[];\n /** Message names reachable from an `action: receive` operation — what this service CONSUMES. */\n received: string[];\n /**\n * Names declared in more than one slot. Every join on this axis is by name, so\n * two slots claiming one name is the same ambiguity `openapi.duplicate-operationid`\n * grades: the join picks one arbitrarily and the other declaration is invisible.\n */\n duplicateNames: string[];\n /**\n * True when the file EXISTS but cannot be read as an AsyncAPI document —\n * broken YAML, non-UTF-8 bytes, or a document that is not a mapping. A missing\n * file is not unreadable (absence is `service.no-asyncapi`'s question), and an\n * empty one parses to null and honestly declares nothing.\n */\n unreadable: boolean;\n /** The parser's own message, when there is one to quote back. */\n error?: string;\n}\n\n/**\n * Resolve an internal JSON Pointer (`#/a/b/c`) against the document root.\n *\n * Internal only, matching the OpenAPI axis's documented stance: external\n * references — URLs, file paths, anything not starting `#/` — are out of scope,\n * left untouched and never graded. That is also why authors are told to keep\n * payloads inside the document rather than `$ref`-ing an external `.avsc`: the\n * merge cannot carry what this reader cannot see.\n */\nfunction resolvePointer(root: unknown, ref: unknown): unknown {\n if (typeof ref !== \"string\" || !ref.startsWith(\"#/\")) return undefined;\n let node = root;\n for (const raw of ref.slice(2).split(\"/\")) {\n // JSON Pointer escaping, `~1` before `~0` so an encoded `~1` survives.\n const seg = raw.replace(/~1/g, \"/\").replace(/~0/g, \"~\");\n if (node === null || typeof node !== \"object\") return undefined;\n node = (node as Record<string, unknown>)[seg];\n }\n return node;\n}\n\n/**\n * Follow `$ref` hops until a node that is not a reference, or the depth budget\n * runs out. The budget is the cycle guard: a document whose `$ref` points at\n * itself is legal YAML, and an unbounded walk would hang the whole validate run\n * rather than reporting a contract nobody can read.\n *\n * Returns the resolved node and the last pointer segment that reached it — the\n * segment is the declaration key, which is the message-name fallback, and it is\n * lost by the time a caller holds only the node.\n */\nfunction deref(root: unknown, node: unknown, key: string): { node: unknown; key: string } {\n let current = node;\n let currentKey = key;\n for (let hop = 0; hop < MAX_REF_DEPTH; hop += 1) {\n if (current === null || typeof current !== \"object\" || Array.isArray(current)) break;\n const ref = (current as Record<string, unknown>)[\"$ref\"];\n if (typeof ref !== \"string\") break;\n const resolved = resolvePointer(root, ref);\n if (resolved === undefined) return { node: undefined, key: currentKey };\n const seg = ref.slice(ref.lastIndexOf(\"/\") + 1);\n current = resolved;\n // An alias keeps the TARGET's key, not the local one it was filed under: a\n // channel may list `#/components/messages/PaymentAuthorized` under any alias\n // it likes, and the fallback name has to be the declaration's own identity\n // or two services aliasing one message differently would stop joining.\n currentKey = seg.length > 0 ? seg : currentKey;\n }\n return { node: current, key: currentKey };\n}\n\n/** A message's join token: its `name`, or the key it is declared under. */\nfunction messageName(node: unknown, key: string): string {\n if (node !== null && typeof node === \"object\" && !Array.isArray(node)) {\n const name = (node as Record<string, unknown>)[\"name\"];\n if (typeof name === \"string\" && name.trim().length > 0) return name.trim();\n }\n return key;\n}\n\n/** Entries of a mapping node, or nothing — every structural read here goes through it. */\nfunction entriesOf(node: unknown): [string, unknown][] {\n if (node === null || typeof node !== \"object\" || Array.isArray(node)) return [];\n return Object.entries(node as Record<string, unknown>);\n}\n\n/**\n * Read an AsyncAPI 3.0 document into the slice loam joins on: the messages it\n * declares, and which of them it sends and receives.\n *\n * The send/receive sets are what make the spine directional, and the direction\n * is not decoration: on the HTTP axis the PROVIDER owns the contract and a\n * consumer's edge is checked against it, while here the PRODUCER owns the\n * message and a consumer joins to it from another repository entirely. That\n * inversion is why `spine.message-unproduced` has no OpenAPI analog.\n *\n * An unreadable document yields empty sets PLUS the `unreadable` flag, the\n * policy every reader here follows: parsers never diagnose. Swallowing the\n * failure into empty sets would make a broken contract indistinguishable from\n * one that declares nothing, and every inbound edge would be graded\n * `spine.message-undefined` — a false diagnosis pointing at the landscape when\n * the truth is this file.\n */\nexport async function readAsyncapi(asyncapiPath: string, context?: FleetContext): Promise<AsyncapiDoc> {\n if (context !== undefined) return context.readAsyncapi(asyncapiPath);\n if (!existsSync(asyncapiPath)) return empty();\n const bytes = await readFile(asyncapiPath);\n // Bytes that are not UTF-8 are as unreadable as broken YAML and for a worse\n // reason: `toString(\"utf8\")` substitutes U+FFFD and hands the parser a\n // document nobody wrote. The openapi axis learned this the same way.\n if (!isUtf8(bytes)) return { ...empty(), unreadable: true, error: \"file is not valid UTF-8\" };\n let doc: unknown;\n try {\n doc = parse(bytes.toString(\"utf8\"));\n } catch (e) {\n return { ...empty(), unreadable: true, error: e instanceof Error ? e.message : String(e) };\n }\n // A scalar or sequence document has no mapping to look `channels` up in, so\n // nothing can be concluded from it. null (an empty file) stays readable — it\n // declares nothing, and says so honestly.\n if (doc !== null && (typeof doc !== \"object\" || Array.isArray(doc))) {\n return { ...empty(), unreadable: true, error: \"document is not a YAML mapping\" };\n }\n if (doc === null) return empty();\n\n const root = doc as Record<string, unknown>;\n const messages: EventMessage[] = [];\n const seen = new Map<string, string[]>();\n const declare = (name: string, slot: string): void => {\n messages.push({ name, slot });\n seen.set(name, [...(seen.get(name) ?? []), slot]);\n };\n\n // `components.messages` — the ordinary place a message is declared.\n for (const [key, node] of entriesOf((root[\"components\"] as Record<string, unknown> | undefined)?.[\"messages\"])) {\n declare(messageName(node, key), `components.messages.${key}`);\n }\n\n // `channels.<ck>.messages.<mk>` — an entry here is either an ALIAS of a\n // components declaration (a `$ref`) or an inline declaration of its own. Only\n // the second is a new slot: counting an alias would report every properly\n // factored document as declaring each message twice, which is the shape the\n // spec's own examples use.\n const channelMessages = new Map<string, string[]>();\n for (const [ck, channel] of entriesOf(root[\"channels\"])) {\n const names: string[] = [];\n for (const [mk, entry] of entriesOf((channel as Record<string, unknown> | null)?.[\"messages\"])) {\n const isAlias =\n entry !== null && typeof entry === \"object\" && !Array.isArray(entry) && \"$ref\" in (entry as object);\n const { node, key } = deref(root, entry, mk);\n const name = messageName(node, key);\n names.push(name);\n if (!isAlias) declare(name, `channels.${ck}.messages.${mk}`);\n }\n channelMessages.set(ck, names);\n }\n\n const sent = new Set<string>();\n const received = new Set<string>();\n for (const [, operation] of entriesOf(root[\"operations\"])) {\n const op = operation as Record<string, unknown> | null;\n const action = op?.[\"action\"];\n if (action !== \"send\" && action !== \"receive\") continue;\n const into = action === \"send\" ? sent : received;\n // The channel this operation acts on, by pointer — its key is what the\n // fallback below indexes.\n const channelRef = (op?.[\"channel\"] as Record<string, unknown> | undefined)?.[\"$ref\"];\n const channelKey = typeof channelRef === \"string\" ? channelRef.slice(channelRef.lastIndexOf(\"/\") + 1) : undefined;\n const listed = op?.[\"messages\"];\n if (Array.isArray(listed) && listed.length > 0) {\n for (const entry of listed) {\n const { node, key } = deref(root, entry, \"\");\n // A pointer that resolves nowhere contributes no name rather than an\n // empty one: a phantom \"\" would join to nothing and read as a message\n // the service produces.\n if (node === undefined && key === \"\") continue;\n const name = messageName(node, key);\n if (name.length > 0) into.add(name);\n }\n continue;\n }\n // AsyncAPI 3: an operation that lists no messages applies to every message\n // of its channel. Omitting the list is the normal shape of a minimal\n // hand-written contract — exactly what a fleet with no registry writes — so\n // reading it as \"produces nothing\" would report the common case broken.\n if (channelKey !== undefined) for (const name of channelMessages.get(channelKey) ?? []) into.add(name);\n }\n\n const duplicateNames = [...seen].filter(([, slots]) => slots.length > 1).map(([name]) => name);\n return { messages, sent: [...sent], received: [...received], duplicateNames, unreadable: false };\n}\n\nfunction empty(): AsyncapiDoc {\n return { messages: [], sent: [], received: [], duplicateNames: [], unreadable: false };\n}\n\n/** The slots one message name is declared in — what `asyncapi.duplicate-message` names. */\nexport function slotsOf(doc: AsyncapiDoc, name: string): string[] {\n return doc.messages.filter((m) => m.name === name).map((m) => m.slot);\n}\n\n/** Who produces what across the fleet, and whether that view is complete. */\nexport interface FleetProducers {\n /** Message name → the services declaring an `action: send` for it. */\n byMessage: Map<string, string[]>;\n /**\n * Services whose `asyncapi.yaml` exists and could not be read. Non-empty means\n * the ABSENCE of a producer proves nothing: one of these files may well\n * declare the message, and nobody can tell without fixing it first.\n */\n unreadable: string[];\n}\n\n/**\n * Which services in the fleet declare an `action: send` for each message name.\n *\n * The one question on this axis that no single repository can answer, and the\n * reason `validate --service` reads more than its own service here: a consumer\n * joins to a message the PRODUCER owns, so \"does anybody publish this\" is a\n * fleet fact. Zero producers is `spine.message-unproduced`; two are\n * `asyncapi.message-contested`, where every consumer's join picks one\n * arbitrarily.\n *\n * The two answers rest on different evidence, and only one of them survives an\n * unreadable contract. Finding two producers is POSITIVE evidence and stays\n * true whatever else in the fleet fails to parse. Finding none is an argument\n * from absence, and one broken file makes it worthless — skipping that service\n * silently would turn its outage into one `spine.message-unproduced` per\n * consuming edge across the whole fleet, every one of them pointing at the\n * wrong repository. So the unreadable services are reported rather than\n * skipped, and the caller suspends the negative answer while any exist.\n *\n * The cost is N small YAML parses, not N LikeC4 workspace spins — the latter is\n * what makes `validate --all` expensive (SCHEMA.md, \"Operating at fleet scale\"),\n * and a context memoizes these reads across the run.\n */\nexport async function producersByMessage(\n docsDir: string,\n services: readonly string[],\n context?: FleetContext,\n): Promise<FleetProducers> {\n const byMessage = new Map<string, string[]>();\n const unreadable: string[] = [];\n for (const service of services) {\n const doc = await readAsyncapi(servicePaths(docsDir, service).asyncapi, context);\n if (doc.unreadable) {\n unreadable.push(service);\n continue;\n }\n for (const name of doc.sent) byMessage.set(name, [...(byMessage.get(name) ?? []), service]);\n }\n return { byMessage, unreadable };\n}\n"]}
|
package/dist/core/brief.js
CHANGED
|
@@ -125,8 +125,12 @@ model {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
// Views are LikeC4's, not loam's: loam parses this file and computes no view,
|
|
128
|
-
// so nothing below is read by any check. Keep it for
|
|
129
|
-
//
|
|
128
|
+
// so nothing below is read by any check. Keep it for the renderer, which does
|
|
129
|
+
// draw it — 'npx likec4 start services/<id>' from the docs repo, pointed at THIS
|
|
130
|
+
// directory. Not the repo root: every service model declares its own
|
|
131
|
+
// 'specification' block, so the root is scoped to architecture/ (likec4.config.json)
|
|
132
|
+
// and one model at a time is how these files are meant to be read.
|
|
133
|
+
// Scoped to one service 'include *' is cheap; the same line in
|
|
130
134
|
// architecture/landscape.likec4 walks every call in the fleet and takes minutes.
|
|
131
135
|
views {
|
|
132
136
|
view of paymentService {
|
|
@@ -226,6 +230,24 @@ Covers: paymentService.db, paymentService -> kafka
|
|
|
226
230
|
"If this service exposes no HTTP API, there is no file to write. Do not invent one — an empty or imagined contract is a claim the whole fleet then joins against. `required` above is derived from the fleet map: it is false exactly when the landscape parses and no edge calls an operation on this service, which is the same evidence that keeps `service.no-openapi` quiet.",
|
|
227
231
|
],
|
|
228
232
|
},
|
|
233
|
+
{
|
|
234
|
+
artifact: "asyncapi.yaml",
|
|
235
|
+
key: "asyncapi",
|
|
236
|
+
// Never required. Unlike the HTTP contract, whose necessity the fleet map
|
|
237
|
+
// can PROVE (an op-linked edge points here), most services in a legacy fleet
|
|
238
|
+
// touch no topic at all — and a required artifact nobody owes is how a
|
|
239
|
+
// whole adoption wave learns to ignore the brief.
|
|
240
|
+
required: false,
|
|
241
|
+
purpose: "the async contract — the messages this service puts on and takes off the bus",
|
|
242
|
+
shape: [
|
|
243
|
+
"AsyncAPI **3.0** with `channels`, `operations` and `components.messages`. Only 3.0 is read: its operations are named top-level objects carrying `action: send|receive`, which is what makes a message's direction legible. A 2.x document declares no `operations` and reads as a contract with no messages.",
|
|
244
|
+
"EVERY message carries a `name` (or is declared under the key you intend to reference). That token is the spine: the C4 edge's `metadata { publishes }` / `metadata { consumes }`, the requirement's `Publishes:` / `Consumes:` line and this name are one name spelled three times.",
|
|
245
|
+
"Namespace the name by the domain that owns it — `payment.PaymentAuthorized`. One message has exactly one producer; two services declaring they send one name is `asyncapi.message-contested`, and then which contract a consumer reads is a coin flip.",
|
|
246
|
+
"loam reads message names and the `action` of the operation carrying them. **It never looks inside `payload`.** Write the payload as JSON Schema today; if the fleet later adopts Avro, that is a `schemaFormat` line here and nothing in loam changes.",
|
|
247
|
+
"Keep payloads in `components.schemas` in THIS file rather than `$ref`-ing an external `.avsc`. External references are out of scope for the merge, exactly as on the OpenAPI axis, so a schema in another file will not travel with the message that needs it.",
|
|
248
|
+
"If this service publishes and consumes nothing, there is no file to write. Do not invent one.",
|
|
249
|
+
],
|
|
250
|
+
},
|
|
229
251
|
{
|
|
230
252
|
artifact: "adrs/",
|
|
231
253
|
key: "adrsDir",
|
|
@@ -258,7 +280,7 @@ Covers: paymentService.db, paymentService -> kafka
|
|
|
258
280
|
],
|
|
259
281
|
},
|
|
260
282
|
];
|
|
261
|
-
|
|
283
|
+
const FRONTMATTER_BRIEF = {
|
|
262
284
|
fields: {
|
|
263
285
|
service: "the directory this file lives under, exactly. A mismatch is an error (`frontmatter.field-mismatch`).",
|
|
264
286
|
status: "`draft`, always. `verified` is a human's word, stamped by `loam vouch --service <id>` run inside the service's own repo — never written by hand.",
|
|
@@ -275,7 +297,7 @@ export const FRONTMATTER_BRIEF = {
|
|
|
275
297
|
* The two invocations a baseline meets. `<id>` is a placeholder for the service
|
|
276
298
|
* id — the JSON contract keeps it symbolic, the text renderer substitutes it.
|
|
277
299
|
*/
|
|
278
|
-
|
|
300
|
+
const VIA_SERVICE = "loam validate --service <id>";
|
|
279
301
|
export const VIA_ALL = "loam validate --all";
|
|
280
302
|
/** What `loam validate` will run against the result — each check names the invocation that surfaces it. */
|
|
281
303
|
export const VALIDATE_CHECKS = [
|
|
@@ -514,7 +536,7 @@ export const UNCHECKED = [
|
|
|
514
536
|
// renderer; loam computes none. Saying otherwise taught agents that a views
|
|
515
537
|
// block was owed to loam, and an `include *` over the FLEET map is the one
|
|
516
538
|
// shape that costs minutes rather than milliseconds.
|
|
517
|
-
"Whether model.likec4 declares a `views { ... }` block, or any view at all — and nothing in loam ever will. loam reads elements and relationships out of the PARSED model and renders nothing, so it computes no view and a model without one is missing nothing loam wants. Views belong to LikeC4's own renderer: write them if you want diagrams, and read them with `npx likec4 start`.
|
|
539
|
+
"Whether model.likec4 declares a `views { ... }` block, or any view at all — and nothing in loam ever will. loam reads elements and relationships out of the PARSED model and renders nothing, so it computes no view and a model without one is missing nothing loam wants. Views belong to LikeC4's own renderer: write them if you want diagrams, and read them with `npx likec4 start <dir>` pointed at ONE directory — `services/<id>` for a service model, the docs repo root for the fleet map (`likec4.config.json` scopes that root project to `architecture/`). The renderer merges every `.likec4` file it is given into one model, and loam parses each of them alone, so each declares its own `specification` block: point it at a directory holding two of them and every declaration reads as a duplicate. Scope your views too — computing a view is superlinear in the number of edges, and an `include *` over `architecture/landscape.likec4` is the expensive one, because that file holds every call in the fleet.",
|
|
518
540
|
"Whether the model has exactly ONE top-level element for the service, with its containers nested inside. Five top-level boxes for one service parse, validate and bind exactly as well as one — and then the fleet map, which joins elements to directories, has five candidates for the same service and no way to say which is wrong.",
|
|
519
541
|
"Whether the model is the architecture the code actually has. loam parses model.likec4; it never reads a line of the service.",
|
|
520
542
|
"Whether a requirement is TRUE of the service. `loam validate` checks that a requirement has a scenario, never that either one describes real behaviour.",
|
|
@@ -526,7 +548,7 @@ export const UNCHECKED = [
|
|
|
526
548
|
"COMPLETENESS. Forty behaviours documented as one requirement passes every check loam has. So does a service with one endpoint documented out of thirty.",
|
|
527
549
|
"Whether `sources` names the files you read. loam checks those paths exist — not that they are the ones the document came from.",
|
|
528
550
|
];
|
|
529
|
-
|
|
551
|
+
const NEVER_OVERWRITE = "Do not overwrite an artifact that already exists. Read it, diff your findings against it, and report what disagrees — a document somebody wrote is evidence, and replacing it destroys the only copy of what they knew.";
|
|
530
552
|
/** Assemble the brief for one service. Reads the docs repo; writes nothing. */
|
|
531
553
|
export async function serviceBrief(docsDir, service) {
|
|
532
554
|
const paths = servicePaths(docsDir, service);
|