@ybotok/loam 0.1.0-beta.2 → 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.
@@ -10,6 +10,7 @@ import { DocsRepoUnavailableError, agentsPath as agentsFile, docsRepoState, feat
10
10
  import { countSeverity, reportValid, SEVERITY_MARK, targetJson, targetValid, } from "../core/report.js";
11
11
  import { parseRequirements, requirementIdProblems, requirementsMissingScenarios, steplessFindings, } from "../core/spec.js";
12
12
  import { readOpenapi } from "../core/openapi.js";
13
+ import { producersByMessage, readAsyncapi, slotsOf } from "../core/asyncapi.js";
13
14
  import { deltaServiceUnknownFinding, featureCoherence } from "../core/coherence.js";
14
15
  import { gatesArchive } from "../core/issue.js";
15
16
  import { emptySourcesMessage, expandSourceFiles, featureProvenance, missingSources, patternSources, serviceProvenance, unsafeSources, } from "../core/provenance.js";
@@ -966,15 +967,23 @@ async function validateService(check) {
966
967
  }
967
968
  }
968
969
  }
969
- // The architecture spec axis — the obligations a business spec never carries
970
- // (the transactional outbox, retries, metrics, alerts). arch.spec.md is
971
- // optional and its ABSENCE is not a finding (partial adoption is supported);
972
- // what exists must hold together: every requirement needs a scenario, every
973
- // `Covers:` entry must resolve (covers.unknown), and every alert/SLI that
974
- // health.yaml declares wants a covering requirement (health.uncovered) — the
975
- // moment health.yaml stops being inert. Warnings, never gates: `--strict` is
976
- // the CI escalation.
977
- const health = await readHealth(paths.health);
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.
978
987
  const archText = existsSync(paths.archSpec)
979
988
  ? fleet === undefined
980
989
  ? await readFile(paths.archSpec, "utf8")
@@ -985,6 +994,179 @@ async function validateService(check) {
985
994
  : fleet === undefined
986
995
  ? parseRequirements(archText)
987
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);
988
1170
  if (archText !== null) {
989
1171
  // The arch axis is advisory in what it ASKS for (covers.unknown,
990
1172
  // health.uncovered are warnings) but not in whether the file is readable: