@ybotok/loam 0.2.0-alpha.4 → 0.2.0-alpha.6
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 +947 -0
- package/MIGRATING-from-OpenSpec.md +7 -1
- package/QUICKSTART.md +8 -3
- package/README.md +20 -13
- package/ROADMAP.md +329 -22
- package/SCHEMA.md +472 -24
- package/WORKFLOW.md +23 -10
- package/dist/commands/adopt/render.js +47 -8
- package/dist/commands/adopt/render.js.map +1 -1
- package/dist/commands/archive/plan/landscape.js +45 -1
- package/dist/commands/archive/plan/landscape.js.map +1 -1
- package/dist/commands/archive/plan/model/extending.js +85 -0
- package/dist/commands/archive/plan/model/extending.js.map +1 -0
- package/dist/commands/archive/run.js +5 -2
- package/dist/commands/archive/run.js.map +1 -1
- package/dist/commands/context/print.js +11 -1
- package/dist/commands/context/print.js.map +1 -1
- package/dist/commands/doctor.js +46 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/explore.js +12 -2
- package/dist/commands/explore.js.map +1 -1
- package/dist/commands/list/views.js +5 -1
- package/dist/commands/list/views.js.map +1 -1
- package/dist/commands/seed/plan.js +97 -3
- package/dist/commands/seed/plan.js.map +1 -1
- package/dist/commands/seed/seed.js +7 -2
- package/dist/commands/seed/seed.js.map +1 -1
- package/dist/commands/show/feature.js +2 -1
- package/dist/commands/show/feature.js.map +1 -1
- package/dist/commands/show/marks.js +3 -5
- package/dist/commands/show/marks.js.map +1 -1
- package/dist/commands/show/service.js +39 -18
- package/dist/commands/show/service.js.map +1 -1
- package/dist/commands/subsystem/sync.js +184 -39
- package/dist/commands/subsystem/sync.js.map +1 -1
- package/dist/commands/subsystem/txn/exclude.js +132 -0
- package/dist/commands/subsystem/txn/exclude.js.map +1 -0
- package/dist/commands/subsystem/txn/report.js +124 -0
- package/dist/commands/subsystem/txn/report.js.map +1 -0
- package/dist/commands/subsystem/txn/txn.js +29 -3
- package/dist/commands/subsystem/txn/txn.js.map +1 -1
- package/dist/commands/subsystem/txn/views.js +113 -16
- package/dist/commands/subsystem/txn/views.js.map +1 -1
- package/dist/commands/validate/arch-coverage.js +16 -9
- package/dist/commands/validate/arch-coverage.js.map +1 -1
- package/dist/commands/validate/checks/fleet-shape.js +30 -77
- package/dist/commands/validate/checks/fleet-shape.js.map +1 -1
- package/dist/commands/validate/checks/vocabulary.js +4 -19
- package/dist/commands/validate/checks/vocabulary.js.map +1 -1
- package/dist/commands/validate/feature.js +1 -1
- package/dist/commands/validate/feature.js.map +1 -1
- package/dist/commands/validate/fleet/census.js +11 -10
- package/dist/commands/validate/fleet/census.js.map +1 -1
- package/dist/commands/validate/fleet/landscape.js +128 -114
- package/dist/commands/validate/fleet/landscape.js.map +1 -1
- package/dist/commands/validate/fleet/load.js +66 -0
- package/dist/commands/validate/fleet/load.js.map +1 -1
- package/dist/commands/validate/fleet/map/attest.js +103 -0
- package/dist/commands/validate/fleet/map/attest.js.map +1 -0
- package/dist/commands/validate/fleet/map/bindings.js +96 -0
- package/dist/commands/validate/fleet/map/bindings.js.map +1 -0
- package/dist/commands/validate/fleet/map/consumers.js +298 -0
- package/dist/commands/validate/fleet/map/consumers.js.map +1 -0
- package/dist/commands/validate/fleet/map/isolation.js +116 -0
- package/dist/commands/validate/fleet/map/isolation.js.map +1 -0
- package/dist/commands/validate/fleet/map/usecases.js +56 -0
- package/dist/commands/validate/fleet/map/usecases.js.map +1 -0
- package/dist/commands/validate/fleet/usecases/steps.js +49 -17
- package/dist/commands/validate/fleet/usecases/steps.js.map +1 -1
- package/dist/commands/validate/fleet/views/fleet-project.js +217 -0
- package/dist/commands/validate/fleet/views/fleet-project.js.map +1 -0
- package/dist/commands/validate/fleet/views/ids.js +13 -9
- package/dist/commands/validate/fleet/views/ids.js.map +1 -1
- package/dist/commands/validate/fleet/views/projects.js +278 -0
- package/dist/commands/validate/fleet/views/projects.js.map +1 -0
- package/dist/commands/validate/fleet/views/root-documents.js +97 -0
- package/dist/commands/validate/fleet/views/root-documents.js.map +1 -0
- package/dist/commands/validate/fleet/views/stale.js +2 -2
- package/dist/commands/validate/fleet/views/stale.js.map +1 -1
- package/dist/commands/validate/service/model/diverged.js +226 -0
- package/dist/commands/validate/service/model/diverged.js.map +1 -0
- package/dist/commands/validate/service/model/grade.js +163 -0
- package/dist/commands/validate/service/model/grade.js.map +1 -0
- package/dist/commands/validate/service/service.js +110 -34
- package/dist/commands/validate/service/service.js.map +1 -1
- package/dist/commands/validate/service/specs.js +39 -5
- package/dist/commands/validate/service/specs.js.map +1 -1
- package/dist/commands/validate/service/spine.js +41 -3
- package/dist/commands/validate/service/spine.js.map +1 -1
- package/dist/commands/validate/service/usecases/tags.js +113 -0
- package/dist/commands/validate/service/usecases/tags.js.map +1 -0
- package/dist/commands/validate/service/usecases/usecases.js +209 -0
- package/dist/commands/validate/service/usecases/usecases.js.map +1 -0
- package/dist/commands/validate/validate.js +10 -24
- package/dist/commands/validate/validate.js.map +1 -1
- package/dist/core/agent/agents-md/artifacts.js +1 -0
- package/dist/core/agent/agents-md/artifacts.js.map +1 -1
- package/dist/core/agent/agents-md/command-map.js +18 -2
- package/dist/core/agent/agents-md/command-map.js.map +1 -1
- package/dist/core/agent/agents-md/cycle.js +24 -9
- package/dist/core/agent/agents-md/cycle.js.map +1 -1
- package/dist/core/agent/agents-md/map/lenses/context.js +4 -3
- package/dist/core/agent/agents-md/map/lenses/context.js.map +1 -1
- package/dist/core/agent/agents-md/map/subsystem.js +51 -2
- package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
- package/dist/core/agent/agents-md/map/usecases.js +45 -3
- package/dist/core/agent/agents-md/map/usecases.js.map +1 -1
- package/dist/core/agent/agents-md.js +14 -4
- package/dist/core/agent/agents-md.js.map +1 -1
- package/dist/core/agent/support/report.js +39 -5
- package/dist/core/agent/support/report.js.map +1 -1
- package/dist/core/agent/workflows/adopt.js +125 -6
- package/dist/core/agent/workflows/adopt.js.map +1 -1
- package/dist/core/agent/workflows/check.js +35 -19
- package/dist/core/agent/workflows/check.js.map +1 -1
- package/dist/core/agent/workflows/closing.js +3 -1
- package/dist/core/agent/workflows/closing.js.map +1 -1
- package/dist/core/brief/brief.js +63 -11
- package/dist/core/brief/brief.js.map +1 -1
- package/dist/core/brief/checks.js +138 -3
- package/dist/core/brief/checks.js.map +1 -1
- package/dist/core/brief/landscape.js +212 -129
- package/dist/core/brief/landscape.js.map +1 -1
- package/dist/core/brief/map/extend.js +81 -0
- package/dist/core/brief/map/extend.js.map +1 -0
- package/dist/core/brief/map/owed.js +284 -0
- package/dist/core/brief/map/owed.js.map +1 -0
- package/dist/core/brief/targets.js +30 -26
- package/dist/core/brief/targets.js.map +1 -1
- package/dist/core/brief/unchecked.js +12 -2
- package/dist/core/brief/unchecked.js.map +1 -1
- package/dist/core/c4/likec4.js +23 -1
- package/dist/core/c4/likec4.js.map +1 -1
- package/dist/core/c4/parsed/deployment.js +5 -3
- package/dist/core/c4/parsed/deployment.js.map +1 -1
- package/dist/core/c4/parsed/view-ids.js +39 -1
- package/dist/core/c4/parsed/view-ids.js.map +1 -1
- package/dist/core/c4/prefetch/fleet.js +142 -0
- package/dist/core/c4/prefetch/fleet.js.map +1 -0
- package/dist/core/c4/project/architecture.js +52 -4
- package/dist/core/c4/project/architecture.js.map +1 -1
- package/dist/core/c4/project/batch.js +140 -0
- package/dist/core/c4/project/batch.js.map +1 -0
- package/dist/core/c4/project/documents.js +9 -4
- package/dist/core/c4/project/documents.js.map +1 -1
- package/dist/core/c4/project/load.js +77 -38
- package/dist/core/c4/project/load.js.map +1 -1
- package/dist/core/c4/project/staged.js +46 -12
- package/dist/core/c4/project/staged.js.map +1 -1
- package/dist/core/c4/resolve/attested.js +0 -0
- package/dist/core/c4/resolve/attested.js.map +1 -0
- package/dist/core/c4/resolve/service.js +8 -17
- package/dist/core/c4/resolve/service.js.map +1 -1
- package/dist/core/c4/resolve/steps.js +18 -0
- package/dist/core/c4/resolve/steps.js.map +1 -1
- package/dist/core/c4/root-project/exclude.js +352 -0
- package/dist/core/c4/root-project/exclude.js.map +1 -0
- package/dist/core/c4/service-model/documents.js +47 -0
- package/dist/core/c4/service-model/documents.js.map +1 -0
- package/dist/core/c4/service-model/fleet/extending.js +82 -0
- package/dist/core/c4/service-model/fleet/extending.js.map +1 -0
- package/dist/core/c4/service-model/load.js +165 -0
- package/dist/core/c4/service-model/load.js.map +1 -0
- package/dist/core/c4/service-model/renderer.js +128 -0
- package/dist/core/c4/service-model/renderer.js.map +1 -0
- package/dist/core/c4/service-model/shape.js +126 -0
- package/dist/core/c4/service-model/shape.js.map +1 -0
- package/dist/core/c4/service-model/slice.js +188 -0
- package/dist/core/c4/service-model/slice.js.map +1 -0
- package/dist/core/c4/source-scan.js +26 -0
- package/dist/core/c4/source-scan.js.map +1 -1
- package/dist/core/c4/splice/authored-source.js +60 -13
- package/dist/core/c4/splice/authored-source.js.map +1 -1
- package/dist/core/c4/splice/contract.js.map +1 -1
- package/dist/core/c4/splice/identity/declared.js +87 -0
- package/dist/core/c4/splice/identity/declared.js.map +1 -0
- package/dist/core/c4/splice/identity/edges.js +17 -0
- package/dist/core/c4/splice/identity/edges.js.map +1 -1
- package/dist/core/c4/splice/identity/existing.js +80 -0
- package/dist/core/c4/splice/identity/existing.js.map +1 -0
- package/dist/core/c4/splice/identity/interior.js +80 -0
- package/dist/core/c4/splice/identity/interior.js.map +1 -0
- package/dist/core/c4/splice/identity/owner.js +50 -0
- package/dist/core/c4/splice/identity/owner.js.map +1 -0
- package/dist/core/c4/splice/landscape-merge.js +154 -100
- package/dist/core/c4/splice/landscape-merge.js.map +1 -1
- package/dist/core/c4/splice/model/merge.js +140 -0
- package/dist/core/c4/splice/model/merge.js.map +1 -0
- package/dist/core/c4/splice/placement.js +14 -4
- package/dist/core/c4/splice/placement.js.map +1 -1
- package/dist/core/c4/workspace.js +2 -1
- package/dist/core/c4/workspace.js.map +1 -1
- package/dist/core/coherence/coherence.js +7 -0
- package/dist/core/coherence/coherence.js.map +1 -1
- package/dist/core/delta/delta.js +9 -0
- package/dist/core/delta/delta.js.map +1 -1
- package/dist/core/deployment/delta.js +4 -0
- package/dist/core/deployment/delta.js.map +1 -1
- package/dist/core/diff/base-state.js +21 -5
- package/dist/core/diff/base-state.js.map +1 -1
- package/dist/core/diff/victims.js +10 -1
- package/dist/core/diff/victims.js.map +1 -1
- package/dist/core/docs.js +31 -22
- package/dist/core/docs.js.map +1 -1
- package/dist/core/doctor/config.js +7 -3
- package/dist/core/doctor/config.js.map +1 -1
- package/dist/core/doctor/doctor.js +48 -9
- package/dist/core/doctor/doctor.js.map +1 -1
- package/dist/core/doctor/report.js.map +1 -1
- package/dist/core/doctor/reports/scan.js +242 -0
- package/dist/core/doctor/reports/scan.js.map +1 -0
- package/dist/core/explain/families.js +1 -1
- package/dist/core/explain/families.js.map +1 -1
- package/dist/core/explain/refusals.js +1 -1
- package/dist/core/explain/refusals.js.map +1 -1
- package/dist/core/explore/explore.js +44 -9
- package/dist/core/explore/explore.js.map +1 -1
- package/dist/core/fleet-context.js +47 -0
- package/dist/core/fleet-context.js.map +1 -1
- package/dist/core/gate/partners.js +5 -9
- package/dist/core/gate/partners.js.map +1 -1
- package/dist/core/kernel/ids/fqn/ancestors.js +65 -0
- package/dist/core/kernel/ids/fqn/ancestors.js.map +1 -0
- package/dist/core/pack/living.js +47 -13
- package/dist/core/pack/living.js.map +1 -1
- package/dist/core/pack/pack.js +9 -6
- package/dist/core/pack/pack.js.map +1 -1
- package/dist/core/projection/arch-slice.js +2 -2
- package/dist/core/projection/arch-slice.js.map +1 -1
- package/dist/core/repo/paths.js +40 -0
- package/dist/core/repo/paths.js.map +1 -1
- package/dist/core/repo/tree/render/projects.js +182 -0
- package/dist/core/repo/tree/render/projects.js.map +1 -0
- package/dist/core/repo/tree/render/stale.js +2 -3
- package/dist/core/repo/tree/render/stale.js.map +1 -1
- package/dist/core/repo/tree/render/views.js +51 -3
- package/dist/core/repo/tree/render/views.js.map +1 -1
- package/dist/core/repo/tree/walk.js +45 -2
- package/dist/core/repo/tree/walk.js.map +1 -1
- package/dist/core/scaffold/landscape.js +219 -9
- package/dist/core/scaffold/landscape.js.map +1 -1
- package/dist/core/staging/writes.js.map +1 -1
- package/dist/core/usecases/delta/claims.js +1 -0
- package/dist/core/usecases/delta/claims.js.map +1 -1
- package/dist/core/usecases/delta/overlay.js +4 -0
- package/dist/core/usecases/delta/overlay.js.map +1 -1
- package/dist/core/usecases/fleet.js +18 -6
- package/dist/core/usecases/fleet.js.map +1 -1
- package/dist/core/usecases/operations.js +7 -0
- package/dist/core/usecases/operations.js.map +1 -1
- package/dist/core/usecases/service/exclude.js +119 -0
- package/dist/core/usecases/service/exclude.js.map +1 -0
- package/dist/core/usecases/service/flows.js +161 -0
- package/dist/core/usecases/service/flows.js.map +1 -0
- package/dist/core/vocabulary/maturity.js +10 -0
- package/dist/core/vocabulary/maturity.js.map +1 -1
- package/examples/README.md +23 -0
- package/examples/docs/architecture/landscape.likec4 +41 -0
- package/examples/docs/architecture/usecases/order-notification.likec4 +5 -2
- package/examples/docs/likec4.config.json +0 -1
- package/examples/docs/services/checkout-web/model.likec4 +39 -56
- package/examples/docs/services/order-service/arch.spec.md +13 -6
- package/examples/docs/services/order-service/model.likec4 +53 -67
- package/examples/docs/services/payment-service/arch.spec.md +7 -4
- package/examples/docs/services/payment-service/model.likec4 +32 -33
- package/examples/docs/services/platform/identity-service/arch.spec.md +7 -4
- package/examples/docs/services/platform/identity-service/model.likec4 +29 -33
- package/examples/docs/services/platform/notification-service/health.yaml +8 -3
- package/examples/docs/services/platform/notification-service/model.likec4 +39 -59
- package/package.json +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `landscape.service-isolated` — a service the fleet map draws and nothing
|
|
3
|
+
* reaches, graded on EVIDENCE rather than on the shape alone.
|
|
4
|
+
*
|
|
5
|
+
* The state is the one `loam seed --from fleet.yaml` leaves for every service
|
|
6
|
+
* nobody listed under `calls:`: a bound element with no edge. Nothing used to
|
|
7
|
+
* report it — `landscape.service-unmodelled` wants a missing element, and the
|
|
8
|
+
* element is present — so a service could be adopted with a full artifact set
|
|
9
|
+
* and stay invisible to every cross-service check while the run ended on
|
|
10
|
+
* `landscape.matched`.
|
|
11
|
+
*
|
|
12
|
+
* Why the evidence gate, and not "every edgeless bound element": a warning with
|
|
13
|
+
* no correct fix is the warning somebody silences. A worker or a cron that
|
|
14
|
+
* genuinely calls nothing has an edgeless element that is TRUE, and there is
|
|
15
|
+
* no tag loam reads for "deliberately isolated" (inventing one is the invented
|
|
16
|
+
* need the non-goals forbid). So this fires only when the service's own
|
|
17
|
+
* `model.likec4` parses and declares at least one call across its boundary —
|
|
18
|
+
* a JOIN between two authored documents: the model attests a call the map
|
|
19
|
+
* does not draw. The list comes from `attestedCalls`, the same derivation the
|
|
20
|
+
* adopt brief names those calls with, so the brief and this check can never
|
|
21
|
+
* disagree about which edges the map owes. Silent, on purpose, for a service
|
|
22
|
+
* with no model (seed's fleet), an unparseable model (`c4.invalid` owns it), a
|
|
23
|
+
* model reaching nothing, and an unmodelled service (`service-unmodelled` owns
|
|
24
|
+
* it).
|
|
25
|
+
*
|
|
26
|
+
* `#external` is never a SUBJECT, and that is now enforced rather than assumed.
|
|
27
|
+
* The claim used to rest on "an `#external` element is not an enumerated
|
|
28
|
+
* service" — true of the ordinary case and false of the reachable one: an
|
|
29
|
+
* element tagged `#external` and bound with `metadata { service '<id>' }` to a
|
|
30
|
+
* directory that exists resolved like any other, so a foreign box answered for
|
|
31
|
+
* one of our services (R4). The two neighbouring binding checks have always
|
|
32
|
+
* skipped the tag; the input set here does too, so "deliberately not ours"
|
|
33
|
+
* means the same thing in all three.
|
|
34
|
+
*
|
|
35
|
+
* The subject set ONLY, and that boundary is the whole of the rule. The filter
|
|
36
|
+
* also sat inside the `touched` walk for one round, which answers a different
|
|
37
|
+
* question — does the map draw any edge on this service — and got it wrong: a
|
|
38
|
+
* kind carrying the tag (`element topic { #external }`, the idiom
|
|
39
|
+
* `examples/docs/architecture/landscape.likec4` defends at length) puts
|
|
40
|
+
* `#external` on every element of that kind, so an edge drawn from a service's
|
|
41
|
+
* OWN topic stopped touching it and the warning fired on a service the map
|
|
42
|
+
* demonstrably reaches — while `loam adopt`, whose `landscape.touched` has no
|
|
43
|
+
* tag filter, said `touched: true` on the same tree. Two answers to one rule
|
|
44
|
+
* is the drift this file's banner promises there cannot be, so the walk below
|
|
45
|
+
* is the brief's predicate exactly, tag and all.
|
|
46
|
+
*
|
|
47
|
+
* A sub-package of its own because `fleet/` sits at the five-file cap, and it
|
|
48
|
+
* imports NOTHING from `fleet/`: a child reaching back into its parent while
|
|
49
|
+
* the parent calls the child is the package cycle `scripts/package-graph.mjs`
|
|
50
|
+
* refuses, which is why the input is one record the caller fills. The models
|
|
51
|
+
* are no longer read here either — `./attest.ts` reads each of them once for
|
|
52
|
+
* this check and the datastore census together.
|
|
53
|
+
*/
|
|
54
|
+
import {} from "../../../../core/c4/likec4.js";
|
|
55
|
+
import { edgeTemplates, spellCall } from "../../../../core/c4/resolve/attested.js";
|
|
56
|
+
import {} from "../../../../core/vocabulary/report.js";
|
|
57
|
+
import {} from "./attest.js";
|
|
58
|
+
/** How many attested calls the message spells out before "…" — three reads; thirty is a wall. */
|
|
59
|
+
const NAMED_CALLS = 3;
|
|
60
|
+
export function isolationFindings(input) {
|
|
61
|
+
const { land, resolve, pathOf } = input;
|
|
62
|
+
// One pass over the relationships: a service is touched when EITHER endpoint
|
|
63
|
+
// resolves to it. An intra-service edge (`svc.api -> svc.db`) therefore
|
|
64
|
+
// counts, which is the brief's `landscape.touched` predicate exactly, and
|
|
65
|
+
// what keeps a self-model drawn as one service of container edges silent.
|
|
66
|
+
// NO tag filter here, deliberately — see the banner: an `#external` endpoint
|
|
67
|
+
// is still an edge the map draws on whatever it resolves to, and skipping one
|
|
68
|
+
// made this check contradict `loam adopt` on a service whose own topic (a
|
|
69
|
+
// kind-tagged `#external` element) was the endpoint.
|
|
70
|
+
const touched = new Set();
|
|
71
|
+
for (const r of land.relationships) {
|
|
72
|
+
for (const endpoint of [r.source, r.target])
|
|
73
|
+
touched.add(resolve(endpoint));
|
|
74
|
+
}
|
|
75
|
+
const findings = [];
|
|
76
|
+
for (const model of input.attested) {
|
|
77
|
+
if (!input.modelled.has(model.service) || touched.has(model.service))
|
|
78
|
+
continue;
|
|
79
|
+
const calls = model.calls;
|
|
80
|
+
if (calls.length === 0)
|
|
81
|
+
continue;
|
|
82
|
+
// `modelled.has` proves a non-external element resolves; the find is the
|
|
83
|
+
// same predicate spelled on the elements, and an empty answer fails closed
|
|
84
|
+
// rather than naming an element that is not there.
|
|
85
|
+
const element = land.elements.find((e) => resolve(e.id) === model.service && !input.external.has(e.id));
|
|
86
|
+
if (element === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
const named = calls.slice(0, NAMED_CALLS).map(spellCall).join(", ") + (calls.length > NAMED_CALLS ? ", …" : "");
|
|
89
|
+
// The repair form follows the calls' direction (`edgeTemplates`): the
|
|
90
|
+
// outbound form alone, spelled here until 2026-09-03, sent an inbound
|
|
91
|
+
// call backwards.
|
|
92
|
+
const forms = edgeTemplates(element.id, calls)
|
|
93
|
+
.map((t) => `\`${t}\``)
|
|
94
|
+
.join(" or ");
|
|
95
|
+
findings.push({
|
|
96
|
+
severity: "warn",
|
|
97
|
+
code: "landscape.service-isolated",
|
|
98
|
+
subject: model.service,
|
|
99
|
+
message:
|
|
100
|
+
// "the fleet map (architecture/)", never the landscape FILE: the input
|
|
101
|
+
// is the whole PROJECT, so an edge added to `architecture/extra.likec4`
|
|
102
|
+
// and never to landscape.likec4 silences this finding — measured on both
|
|
103
|
+
// the seed lab and examples/docs. Naming one file told a reader the edge
|
|
104
|
+
// had to land there, which SCHEMA, `loam explain` and the brief all
|
|
105
|
+
// already contradicted (verification 2026-09-04).
|
|
106
|
+
`landscape: ${pathOf(model.service)}/ resolves to '${element.id}' and no edge in the fleet map ` +
|
|
107
|
+
`(architecture/) touches it, while ${pathOf(model.service)}/model.likec4 declares ${calls.length} ` +
|
|
108
|
+
`call(s) across its boundary ` +
|
|
109
|
+
`(${named}) — the service is drawn and invisible to every cross-service check. Draw each as one edge on ` +
|
|
110
|
+
`${element.id}: ${forms} for a call, ` +
|
|
111
|
+
`\`publishes\`/\`consumes\` for an event, naming the other party as the map already spells it`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return findings;
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=isolation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"isolation.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/map/isolation.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `landscape.service-isolated` — a service the fleet map draws and nothing\n * reaches, graded on EVIDENCE rather than on the shape alone.\n *\n * The state is the one `loam seed --from fleet.yaml` leaves for every service\n * nobody listed under `calls:`: a bound element with no edge. Nothing used to\n * report it — `landscape.service-unmodelled` wants a missing element, and the\n * element is present — so a service could be adopted with a full artifact set\n * and stay invisible to every cross-service check while the run ended on\n * `landscape.matched`.\n *\n * Why the evidence gate, and not \"every edgeless bound element\": a warning with\n * no correct fix is the warning somebody silences. A worker or a cron that\n * genuinely calls nothing has an edgeless element that is TRUE, and there is\n * no tag loam reads for \"deliberately isolated\" (inventing one is the invented\n * need the non-goals forbid). So this fires only when the service's own\n * `model.likec4` parses and declares at least one call across its boundary —\n * a JOIN between two authored documents: the model attests a call the map\n * does not draw. The list comes from `attestedCalls`, the same derivation the\n * adopt brief names those calls with, so the brief and this check can never\n * disagree about which edges the map owes. Silent, on purpose, for a service\n * with no model (seed's fleet), an unparseable model (`c4.invalid` owns it), a\n * model reaching nothing, and an unmodelled service (`service-unmodelled` owns\n * it).\n *\n * `#external` is never a SUBJECT, and that is now enforced rather than assumed.\n * The claim used to rest on \"an `#external` element is not an enumerated\n * service\" — true of the ordinary case and false of the reachable one: an\n * element tagged `#external` and bound with `metadata { service '<id>' }` to a\n * directory that exists resolved like any other, so a foreign box answered for\n * one of our services (R4). The two neighbouring binding checks have always\n * skipped the tag; the input set here does too, so \"deliberately not ours\"\n * means the same thing in all three.\n *\n * The subject set ONLY, and that boundary is the whole of the rule. The filter\n * also sat inside the `touched` walk for one round, which answers a different\n * question — does the map draw any edge on this service — and got it wrong: a\n * kind carrying the tag (`element topic { #external }`, the idiom\n * `examples/docs/architecture/landscape.likec4` defends at length) puts\n * `#external` on every element of that kind, so an edge drawn from a service's\n * OWN topic stopped touching it and the warning fired on a service the map\n * demonstrably reaches — while `loam adopt`, whose `landscape.touched` has no\n * tag filter, said `touched: true` on the same tree. Two answers to one rule\n * is the drift this file's banner promises there cannot be, so the walk below\n * is the brief's predicate exactly, tag and all.\n *\n * A sub-package of its own because `fleet/` sits at the five-file cap, and it\n * imports NOTHING from `fleet/`: a child reaching back into its parent while\n * the parent calls the child is the package cycle `scripts/package-graph.mjs`\n * refuses, which is why the input is one record the caller fills. The models\n * are no longer read here either — `./attest.ts` reads each of them once for\n * this check and the datastore census together.\n */\nimport { type LoadedDoc } from \"../../../../core/c4/likec4.js\";\nimport { edgeTemplates, spellCall } from \"../../../../core/c4/resolve/attested.js\";\nimport { type Finding } from \"../../../../core/vocabulary/report.js\";\nimport { type AttestedModel } from \"./attest.js\";\n\nexport interface IsolationInput {\n /** The parsed landscape. */\n land: LoadedDoc;\n /**\n * The services a NON-`#external` element resolves to — `service-unmodelled`'s\n * set with the tag filter applied, which is what keeps a foreign box from\n * answering for one of our directories.\n */\n modelled: ReadonlySet<string>;\n /** The ids of the map's `#external` elements: never a subject — and never what the message names. */\n external: ReadonlySet<string>;\n /** What each service's own model attests, read once by `./attest.ts` for this check and the store census. */\n attested: readonly AttestedModel[];\n /** The shared element→service resolver every edge join uses. */\n resolve: (id: string) => string;\n /** Where a service's directory sits, repo-relative — the enumeration's answer, never `services/<id>`. */\n pathOf: (id: string) => string;\n}\n\n/** How many attested calls the message spells out before \"…\" — three reads; thirty is a wall. */\nconst NAMED_CALLS = 3;\n\nexport function isolationFindings(input: IsolationInput): Finding[] {\n const { land, resolve, pathOf } = input;\n // One pass over the relationships: a service is touched when EITHER endpoint\n // resolves to it. An intra-service edge (`svc.api -> svc.db`) therefore\n // counts, which is the brief's `landscape.touched` predicate exactly, and\n // what keeps a self-model drawn as one service of container edges silent.\n // NO tag filter here, deliberately — see the banner: an `#external` endpoint\n // is still an edge the map draws on whatever it resolves to, and skipping one\n // made this check contradict `loam adopt` on a service whose own topic (a\n // kind-tagged `#external` element) was the endpoint.\n const touched = new Set<string>();\n for (const r of land.relationships) {\n for (const endpoint of [r.source, r.target]) touched.add(resolve(endpoint));\n }\n\n const findings: Finding[] = [];\n for (const model of input.attested) {\n if (!input.modelled.has(model.service) || touched.has(model.service)) continue;\n const calls = model.calls;\n if (calls.length === 0) continue;\n // `modelled.has` proves a non-external element resolves; the find is the\n // same predicate spelled on the elements, and an empty answer fails closed\n // rather than naming an element that is not there.\n const element = land.elements.find((e) => resolve(e.id) === model.service && !input.external.has(e.id));\n if (element === undefined) continue;\n const named = calls.slice(0, NAMED_CALLS).map(spellCall).join(\", \") + (calls.length > NAMED_CALLS ? \", …\" : \"\");\n // The repair form follows the calls' direction (`edgeTemplates`): the\n // outbound form alone, spelled here until 2026-09-03, sent an inbound\n // call backwards.\n const forms = edgeTemplates(element.id, calls)\n .map((t) => `\\`${t}\\``)\n .join(\" or \");\n findings.push({\n severity: \"warn\",\n code: \"landscape.service-isolated\",\n subject: model.service,\n message:\n // \"the fleet map (architecture/)\", never the landscape FILE: the input\n // is the whole PROJECT, so an edge added to `architecture/extra.likec4`\n // and never to landscape.likec4 silences this finding — measured on both\n // the seed lab and examples/docs. Naming one file told a reader the edge\n // had to land there, which SCHEMA, `loam explain` and the brief all\n // already contradicted (verification 2026-09-04).\n `landscape: ${pathOf(model.service)}/ resolves to '${element.id}' and no edge in the fleet map ` +\n `(architecture/) touches it, while ${pathOf(model.service)}/model.likec4 declares ${calls.length} ` +\n `call(s) across its boundary ` +\n `(${named}) — the service is drawn and invisible to every cross-service check. Draw each as one edge on ` +\n `${element.id}: ${forms} for a call, ` +\n `\\`publishes\\`/\\`consumes\\` for an event, naming the other party as the map already spells it`,\n });\n }\n return findings;\n}\n"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fleet's use cases, graded — the CALL, with the two vocabulary reads it
|
|
3
|
+
* needs, lifted out of `../landscape.ts` when that module reached its line
|
|
4
|
+
* limit.
|
|
5
|
+
*
|
|
6
|
+
* The seam is a phase rather than a subject: everything here happens after the
|
|
7
|
+
* map has parsed and grades documents the map does not own — every
|
|
8
|
+
* `dynamic view` in `architecture/landscape.likec4` and in every
|
|
9
|
+
* `architecture/usecases/*.likec4`. `../usecases/usecases.ts` owns the opt-in and
|
|
10
|
+
* the grades; this owns which inputs they are handed, which is the part that has
|
|
11
|
+
* twice been got wrong and is worth stating in one place.
|
|
12
|
+
*
|
|
13
|
+
* A sub-package of `fleet/` for `map/isolation.ts`'s reason, and with its rule:
|
|
14
|
+
* it imports NOTHING from `fleet/` itself — a child reaching back into its
|
|
15
|
+
* parent while the parent calls the child is the package cycle
|
|
16
|
+
* `scripts/package-graph.mjs` refuses — so everything it needs arrives in one
|
|
17
|
+
* record the caller fills.
|
|
18
|
+
*/
|
|
19
|
+
import { readCapabilityVocabulary } from "../../../../core/capabilities/capabilities.js";
|
|
20
|
+
import { capabilityRequirementIndex, gradableCapabilityIds } from "../../../../core/capabilities/findings.js";
|
|
21
|
+
import { parseRequirements, readRequirementsDocument } from "../../../../core/document/parse.js";
|
|
22
|
+
import { FleetContext } from "../../../../core/fleet-context.js";
|
|
23
|
+
import { useCaseFindings } from "../usecases/usecases.js";
|
|
24
|
+
/**
|
|
25
|
+
* The capability ladder is APPLIED BY ITS OWN FUNCTION rather than re-spelled
|
|
26
|
+
* here: an absent or unreadable capabilities.yaml means silence for the whole
|
|
27
|
+
* family, not a fleet full of unresolved tags, so `null` travels rather than an
|
|
28
|
+
* empty list — which the join would read as "the fleet declares no capabilities"
|
|
29
|
+
* and grade every tag against. `gradableCapabilityIds`
|
|
30
|
+
* (`core/capabilities/findings.ts`) is the one statement of that rule, and this
|
|
31
|
+
* is a caller of it precisely so a fourth un-gradable vocabulary state cannot be
|
|
32
|
+
* fixed in core while the command layer keeps handing the join a whole key set.
|
|
33
|
+
*
|
|
34
|
+
* Both reads are already paid for under `--all`: `capabilityFleetFindings` has
|
|
35
|
+
* asked for the vocabulary, and every requirement document behind the `#req-`
|
|
36
|
+
* tag was parsed by it, so the fleet memo answers from cache. The requirement
|
|
37
|
+
* index is built here rather than inside the use-case package so that package
|
|
38
|
+
* never learns what a capability document is.
|
|
39
|
+
*/
|
|
40
|
+
export async function fleetUseCaseFindings(input) {
|
|
41
|
+
const { docsDir, fleet } = input;
|
|
42
|
+
const vocabulary = fleet === undefined ? await readCapabilityVocabulary(docsDir) : await fleet.capabilities(docsDir);
|
|
43
|
+
const capabilityReqs = await capabilityRequirementIndex(vocabulary, fleet === undefined
|
|
44
|
+
? async (p) => parseRequirements(await readRequirementsDocument(p))
|
|
45
|
+
: (p) => fleet.readRequirements(p));
|
|
46
|
+
return useCaseFindings({
|
|
47
|
+
views: input.views,
|
|
48
|
+
elements: input.elements,
|
|
49
|
+
relationships: input.relationships,
|
|
50
|
+
services: input.services,
|
|
51
|
+
resolve: input.resolve,
|
|
52
|
+
capabilities: gradableCapabilityIds(vocabulary),
|
|
53
|
+
requirementsOf: (capability) => capabilityReqs.byCapability.get(capability),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=usecases.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"usecases.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/map/usecases.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,wBAAwB,EAAE,MAAM,+CAA+C,CAAC;AACzF,OAAO,EAAE,0BAA0B,EAAE,qBAAqB,EAAE,MAAM,2CAA2C,CAAC;AAC9G,OAAO,EAAE,iBAAiB,EAAE,wBAAwB,EAAE,MAAM,oCAAoC,CAAC;AACjG,OAAO,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AAKjE,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAsB1D;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,KAAoB;IAC7D,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,KAAK,CAAC;IACjC,MAAM,UAAU,GACd,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,wBAAwB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;IACpG,MAAM,cAAc,GAAG,MAAM,0BAA0B,CACrD,UAAU,EACV,KAAK,KAAK,SAAS;QACjB,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,MAAM,wBAAwB,CAAC,CAAC,CAAC,CAAC;QACnE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC,CAAC,CACrC,CAAC;IACF,OAAO,eAAe,CAAC;QACrB,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,aAAa,EAAE,KAAK,CAAC,aAAa;QAClC,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,YAAY,EAAE,qBAAqB,CAAC,UAAU,CAAC;QAC/C,cAAc,EAAE,CAAC,UAAU,EAAE,EAAE,CAAC,cAAc,CAAC,YAAY,CAAC,GAAG,CAAC,UAAU,CAAC;KAC5E,CAAC,CAAC;AACL,CAAC","sourcesContent":["/**\n * The fleet's use cases, graded — the CALL, with the two vocabulary reads it\n * needs, lifted out of `../landscape.ts` when that module reached its line\n * limit.\n *\n * The seam is a phase rather than a subject: everything here happens after the\n * map has parsed and grades documents the map does not own — every\n * `dynamic view` in `architecture/landscape.likec4` and in every\n * `architecture/usecases/*.likec4`. `../usecases/usecases.ts` owns the opt-in and\n * the grades; this owns which inputs they are handed, which is the part that has\n * twice been got wrong and is worth stating in one place.\n *\n * A sub-package of `fleet/` for `map/isolation.ts`'s reason, and with its rule:\n * it imports NOTHING from `fleet/` itself — a child reaching back into its\n * parent while the parent calls the child is the package cycle\n * `scripts/package-graph.mjs` refuses — so everything it needs arrives in one\n * record the caller fills.\n */\nimport { readCapabilityVocabulary } from \"../../../../core/capabilities/capabilities.js\";\nimport { capabilityRequirementIndex, gradableCapabilityIds } from \"../../../../core/capabilities/findings.js\";\nimport { parseRequirements, readRequirementsDocument } from \"../../../../core/document/parse.js\";\nimport { FleetContext } from \"../../../../core/fleet-context.js\";\nimport type { DocsDir } from \"../../../../core/kernel/ids/dirs.js\";\nimport type { Elem, Rel } from \"../../../../core/c4/likec4.js\";\nimport type { ParsedView } from \"../../../../core/c4/parsed/dynamic-views.js\";\nimport type { Finding } from \"../../../../core/vocabulary/report.js\";\nimport { useCaseFindings } from \"../usecases/usecases.js\";\n\nexport interface FleetUseCases {\n docsDir: DocsDir;\n /**\n * The views come from the PROJECT load, never from a single-file fallback,\n * and that is the file-naming rule made mechanical instead of remembered:\n * only the project load gives a view the `sourcePath` a finding has to name,\n * while a single-file load calls every document `source.c4` — so a message\n * built from that load would send its reader to `architecture/source.c4`, a\n * file that has never existed. No project, no views, and nothing to get wrong.\n */\n views: readonly ParsedView[];\n /** The map's model — what a hop is resolved against. */\n elements: Elem[];\n relationships: Rel[];\n /** The enumerated fleet, and the resolver every other edge join in the target uses. */\n services: ReadonlySet<string>;\n resolve: (id: string) => string;\n fleet?: FleetContext;\n}\n\n/**\n * The capability ladder is APPLIED BY ITS OWN FUNCTION rather than re-spelled\n * here: an absent or unreadable capabilities.yaml means silence for the whole\n * family, not a fleet full of unresolved tags, so `null` travels rather than an\n * empty list — which the join would read as \"the fleet declares no capabilities\"\n * and grade every tag against. `gradableCapabilityIds`\n * (`core/capabilities/findings.ts`) is the one statement of that rule, and this\n * is a caller of it precisely so a fourth un-gradable vocabulary state cannot be\n * fixed in core while the command layer keeps handing the join a whole key set.\n *\n * Both reads are already paid for under `--all`: `capabilityFleetFindings` has\n * asked for the vocabulary, and every requirement document behind the `#req-`\n * tag was parsed by it, so the fleet memo answers from cache. The requirement\n * index is built here rather than inside the use-case package so that package\n * never learns what a capability document is.\n */\nexport async function fleetUseCaseFindings(input: FleetUseCases): Promise<Finding[]> {\n const { docsDir, fleet } = input;\n const vocabulary =\n fleet === undefined ? await readCapabilityVocabulary(docsDir) : await fleet.capabilities(docsDir);\n const capabilityReqs = await capabilityRequirementIndex(\n vocabulary,\n fleet === undefined\n ? async (p) => parseRequirements(await readRequirementsDocument(p))\n : (p) => fleet.readRequirements(p),\n );\n return useCaseFindings({\n views: input.views,\n elements: input.elements,\n relationships: input.relationships,\n services: input.services,\n resolve: input.resolve,\n capabilities: gradableCapabilityIds(vocabulary),\n requirementsOf: (capability) => capabilityReqs.byCapability.get(capability),\n });\n}\n"]}
|
|
@@ -5,9 +5,18 @@
|
|
|
5
5
|
* The attribution itself is `core/c4/resolve/steps.ts`'s `attributeStep` — which declared
|
|
6
6
|
* relationship backs a hop, oriented on `isBackward` and decided by the
|
|
7
7
|
* DISTINCT-op count — and none of that reasoning is repeated here. This module
|
|
8
|
-
* owns three things: which verdict earns which code, the
|
|
8
|
+
* owns three things: which verdict earns which code, the four guards that keep
|
|
9
9
|
* the warn honest, and the sentences.
|
|
10
10
|
*
|
|
11
|
+
* TWO SCOPES, ONE GRADER. The fleet target grades the `architecture/` project's
|
|
12
|
+
* views and the service target grades a service's own project — every `.likec4`
|
|
13
|
+
* beside `model.likec4`, read the way the renderer reads it
|
|
14
|
+
* (`commands/validate/service/usecases/`). Both call `stepFindings` below with
|
|
15
|
+
* their own `StepGrading`; what differs is only where a finding says the view
|
|
16
|
+
* is (`place`). A second copy of the verdict→code mapping per scope is the
|
|
17
|
+
* drift this package's banner forbids, which is why the intra-service case is
|
|
18
|
+
* a fourth guard here rather than a grader of its own.
|
|
19
|
+
*
|
|
11
20
|
* WHY `step-unbacked` IS THE CHECK THAT EARNS THE FEATURE. A hop between two
|
|
12
21
|
* elements that both exist, with no relationship between them anywhere in the
|
|
13
22
|
* model, is ZERO LikeC4 errors: a `dynamic view` is allowed to show a call the
|
|
@@ -28,9 +37,16 @@ import { attributeStep } from "../../../../core/c4/resolve/steps.js";
|
|
|
28
37
|
import { ACTOR_KINDS } from "../../../../core/vocabulary/maturity.js";
|
|
29
38
|
import { stepPlace } from "../../../../core/usecases/place.js";
|
|
30
39
|
import { viewPlace } from "./place.js";
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
/**
|
|
41
|
+
* `…checkout.likec4 — dynamic view 'uc_checkout' step 2 'authorizes the payment'`.
|
|
42
|
+
*
|
|
43
|
+
* Spelled once per hop in `stepFindings` and handed to each sentence builder as
|
|
44
|
+
* a string, rather than recomputed from the grading inside each of them: three
|
|
45
|
+
* builders reaching for `grading.place ?? viewPlace` on their own would be three
|
|
46
|
+
* places for one scope to forget the injection.
|
|
47
|
+
*/
|
|
48
|
+
function placeOf(hop, grading) {
|
|
49
|
+
return `${(grading.place ?? viewPlace)(hop.view)} ${stepPlace(hop.step)}`;
|
|
34
50
|
}
|
|
35
51
|
/**
|
|
36
52
|
* A hop nothing in the model backs.
|
|
@@ -44,7 +60,7 @@ function placeOf(hop) {
|
|
|
44
60
|
* a reply to the CALL it answers rather than to the direction the message
|
|
45
61
|
* travels (`callPair` in core/c4/resolve/steps.ts holds the measurement).
|
|
46
62
|
*/
|
|
47
|
-
function unbackedFinding(hop, attribution) {
|
|
63
|
+
function unbackedFinding(hop, attribution, place) {
|
|
48
64
|
const other = hop.step.isBackward
|
|
49
65
|
? { spelling: `${hop.step.source} -> ${hop.step.target}`, when: "is not a return step" }
|
|
50
66
|
: { spelling: `${hop.step.target} <- ${hop.step.source}`, when: "is a return step" };
|
|
@@ -52,7 +68,7 @@ function unbackedFinding(hop, attribution) {
|
|
|
52
68
|
severity: "error",
|
|
53
69
|
code: "usecase.step-unbacked",
|
|
54
70
|
subject: hop.view.id,
|
|
55
|
-
message: `${
|
|
71
|
+
message: `${place}: nothing in the model declares ${attribution.from} -> ${attribution.to}, so this hop is ` +
|
|
56
72
|
"backed by no relationship — and LikeC4 reports no error for it, so the view still renders and the " +
|
|
57
73
|
"project still parses. Draw the edge in `model { }` (with `metadata { op '<operationId>' }` where it is " +
|
|
58
74
|
`a call), or, if this hop ${other.when}, write it as \`${other.spelling}\` — a reply is attributed to the ` +
|
|
@@ -79,12 +95,12 @@ function candidateLine(rel) {
|
|
|
79
95
|
* that is. An absent `metadata { op }` prints as `no op` and is a candidate like
|
|
80
96
|
* any other — it is exactly as much of a disagreement as a second name.
|
|
81
97
|
*/
|
|
82
|
-
function contestedFinding(hop, attribution) {
|
|
98
|
+
function contestedFinding(hop, attribution, place) {
|
|
83
99
|
return {
|
|
84
100
|
severity: "warn",
|
|
85
101
|
code: "usecase.step-contested",
|
|
86
102
|
subject: hop.view.id,
|
|
87
|
-
message: `${
|
|
103
|
+
message: `${place}: ${attribution.rels.length} relationships back ${attribution.from} -> ` +
|
|
88
104
|
`${attribution.to} and they name ${attribution.ops.length} different operations, so nothing can say ` +
|
|
89
105
|
"which one this hop exercises. loam names the candidates instead of picking one — a guessed operation " +
|
|
90
106
|
"reads exactly like a right one in every rollup built over this flow. Give the candidates one operation, " +
|
|
@@ -94,12 +110,13 @@ function contestedFinding(hop, attribution) {
|
|
|
94
110
|
}
|
|
95
111
|
/**
|
|
96
112
|
* The service this hop calls, when the hop is a `step-unlinked` candidate at
|
|
97
|
-
* all — `null` when any of the
|
|
113
|
+
* all — `null` when any of the four guards refuses. ALL FOUR are mechanical,
|
|
98
114
|
* and each closes a whole class of warnings that would otherwise be simply
|
|
99
115
|
* wrong. The count is stated because a reader auditing this function is meant to
|
|
100
|
-
* find
|
|
101
|
-
* them is surplus: the one a miscount invites deleting is the
|
|
102
|
-
* it puts the warning back on the first hop of almost every flow
|
|
116
|
+
* find four bullets and four `return null`s below and conclude that none of
|
|
117
|
+
* them is surplus: the one a miscount invites deleting is the actor guard, and
|
|
118
|
+
* deleting it puts the warning back on the first hop of almost every flow;
|
|
119
|
+
* deleting the last puts it on EVERY hop of every flow drawn inside a service.
|
|
103
120
|
*
|
|
104
121
|
* - A `publishes` or `consumes` on ANY candidate means the hop does reach a
|
|
105
122
|
* declared API — an AsyncAPI one. The event spine already grades it
|
|
@@ -123,6 +140,18 @@ function contestedFinding(hop, attribution) {
|
|
|
123
140
|
* `arch-coverage.ts`, `core/gate/partners.ts` and `core/verify/checklist.ts`
|
|
124
141
|
* each already apply, through the same `ACTOR_KINDS` — a person was never a
|
|
125
142
|
* participant in any of loam's other censuses either.
|
|
143
|
+
* - The CALLER must not resolve to the provider itself. A hop between two
|
|
144
|
+
* containers of one service — `api -> db` inside a service's own project,
|
|
145
|
+
* or the same pair on a landscape that draws the service as containers —
|
|
146
|
+
* names no operation of that service's contract: an operationId is what a
|
|
147
|
+
* caller OUTSIDE the boundary uses, and a service owes none to itself. The
|
|
148
|
+
* three guards above all pass such a hop (the edge carries no message, the
|
|
149
|
+
* target IS a real `services/<id>/`, the caller is a container), so without
|
|
150
|
+
* this one the warning fired on every hop of every intra-service flow the
|
|
151
|
+
* moment loam started reading them — and the message ("names no operation
|
|
152
|
+
* of X's contract") was about X calling X. Through the same shared
|
|
153
|
+
* resolver as the provider, so the two sides of the comparison cannot
|
|
154
|
+
* disagree about which directory an element stands for.
|
|
126
155
|
*
|
|
127
156
|
* The op test reads the ATTRIBUTION rather than the relationships: an
|
|
128
157
|
* `attributed` verdict with no `op` means every candidate agreed the hop names
|
|
@@ -138,6 +167,8 @@ function unlinkedProvider(attribution, grading) {
|
|
|
138
167
|
if (isActor(attribution.from, grading.model.elements))
|
|
139
168
|
return null;
|
|
140
169
|
const provider = grading.resolve(attribution.to);
|
|
170
|
+
if (grading.resolve(attribution.from) === provider)
|
|
171
|
+
return null;
|
|
141
172
|
return grading.services.has(provider) ? provider : null;
|
|
142
173
|
}
|
|
143
174
|
/**
|
|
@@ -161,13 +192,13 @@ function isActor(id, elements) {
|
|
|
161
192
|
* provider's contract the step exercises — and the whole point of grading a use
|
|
162
193
|
* case is that the flow joins through to the API it drives.
|
|
163
194
|
*/
|
|
164
|
-
function unlinkedFinding(hop, attribution, provider) {
|
|
195
|
+
function unlinkedFinding(hop, attribution, provider, place) {
|
|
165
196
|
const backing = attribution.rels.length === 1 ? "one relationship" : `${attribution.rels.length} relationships`;
|
|
166
197
|
return {
|
|
167
198
|
severity: "warn",
|
|
168
199
|
code: "usecase.step-unlinked",
|
|
169
200
|
subject: hop.view.id,
|
|
170
|
-
message: `${
|
|
201
|
+
message: `${place}: ${attribution.from} -> ${attribution.to} is backed by ${backing} carrying no ` +
|
|
171
202
|
"`metadata { op }` and no `publishes`/`consumes`, so this hop names no operation of " +
|
|
172
203
|
`${provider}'s contract — the use case reaches the fleet map and stops there. Add ` +
|
|
173
204
|
"`metadata { op '<operationId>' }` to the edge in `model { }`, or `metadata { publishes '<message>' }` / " +
|
|
@@ -183,14 +214,15 @@ function unlinkedFinding(hop, attribution, provider) {
|
|
|
183
214
|
*/
|
|
184
215
|
export function stepFindings(hop, grading) {
|
|
185
216
|
const attribution = attributeStep(hop.step, grading.model);
|
|
217
|
+
const place = placeOf(hop, grading);
|
|
186
218
|
switch (attribution.verdict) {
|
|
187
219
|
case "unbacked":
|
|
188
|
-
return [unbackedFinding(hop, attribution)];
|
|
220
|
+
return [unbackedFinding(hop, attribution, place)];
|
|
189
221
|
case "contested":
|
|
190
|
-
return [contestedFinding(hop, attribution)];
|
|
222
|
+
return [contestedFinding(hop, attribution, place)];
|
|
191
223
|
case "attributed": {
|
|
192
224
|
const provider = unlinkedProvider(attribution, grading);
|
|
193
|
-
return provider === null ? [] : [unlinkedFinding(hop, attribution, provider)];
|
|
225
|
+
return provider === null ? [] : [unlinkedFinding(hop, attribution, provider, place)];
|
|
194
226
|
}
|
|
195
227
|
}
|
|
196
228
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"steps.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/usecases/steps.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EAAE,aAAa,EAAwC,MAAM,sCAAsC,CAAC;AAG3G,OAAO,EAAE,WAAW,EAAE,MAAM,yCAAyC,CAAC;AAEtE,OAAO,EAAE,SAAS,EAAE,MAAM,oCAAoC,CAAC;AAC/D,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AA+BvC,uFAAuF;AACvF,SAAS,OAAO,CAAC,GAAe;IAC9B,OAAO,GAAG,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;AACzD,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAS,eAAe,CAAC,GAAe,EAAE,WAAqB;IAC7D,MAAM,KAAK,GAAG,GAAG,CAAC,IAAI,CAAC,UAAU;QAC/B,CAAC,CAAC,EAAE,QAAQ,EAAE,GAAG,GAAG,CAAC,IAAI,CAAC,MAAM,OAAO,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,sBAAsB,EAAE;QACxF,CAAC,CAAC,EAAE,QAAQ,EAAE,GAAG,GAAG,CAAC,IAAI,CAAC,MAAM,OAAO,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,kBAAkB,EAAE,CAAC;IACvF,OAAO;QACL,QAAQ,EAAE,OAAO;QACjB,IAAI,EAAE,uBAAuB;QAC7B,OAAO,EAAE,GAAG,CAAC,IAAI,CAAC,EAAE;QACpB,OAAO,EACL,GAAG,OAAO,CAAC,GAAG,CAAC,mCAAmC,WAAW,CAAC,IAAI,OAAO,WAAW,CAAC,EAAE,mBAAmB;YAC1G,oGAAoG;YACpG,yGAAyG;YACzG,4BAA4B,KAAK,CAAC,IAAI,mBAAmB,KAAK,CAAC,QAAQ,oCAAoC;YAC3G,4DAA4D,WAAW,CAAC,EAAE,OAAO,WAAW,CAAC,IAAI,WAAW;KAC/G,CAAC;AACJ,CAAC;AAED,4GAA4G;AAC5G,SAAS,aAAa,CAAC,GAAQ;IAC7B,MAAM,EAAE,GAAG,GAAG,CAAC,EAAE,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,GAAG,CAAC,EAAE,EAAE,CAAC;IAC5D,OAAO,GAAG,GAAG,CAAC,MAAM,OAAO,GAAG,CAAC,MAAM,KAAK,GAAG,CAAC,KAAK,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC;AACvE,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,SAAS,gBAAgB,CAAC,GAAe,EAAE,WAAsB;IAC/D,OAAO;QACL,QAAQ,EAAE,MAAM;QAChB,IAAI,EAAE,wBAAwB;QAC9B,OAAO,EAAE,GAAG,CAAC,IAAI,CAAC,EAAE;QACpB,OAAO,EACL,GAAG,OAAO,CAAC,GAAG,CAAC,KAAK,WAAW,CAAC,IAAI,CAAC,MAAM,uBAAuB,WAAW,CAAC,IAAI,MAAM;YACxF,GAAG,WAAW,CAAC,EAAE,kBAAkB,WAAW,CAAC,GAAG,CAAC,MAAM,4CAA4C;YACrG,uGAAuG;YACvG,0GAA0G;YAC1G,6DAA6D;QAC/D,OAAO,EAAE,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,aAAa,CAAC;KAC7C,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,SAAS,gBAAgB,CAAC,WAAuB,EAAE,OAAoB;IACrE,IAAI,WAAW,CAAC,EAAE,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IAC9C,IAAI,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,SAAS,KAAK,SAAS,IAAI,GAAG,CAAC,QAAQ,KAAK,SAAS,CAAC;QAAE,OAAO,IAAI,CAAC;IAC3G,IAAI,OAAO,CAAC,WAAW,CAAC,IAAI,EAAE,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAC;IACnE,MAAM,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;IACjD,OAAO,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC;AAC1D,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,OAAO,CAAC,EAAU,EAAE,QAAgB;IAC3C,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;IAClE,OAAO,OAAO,KAAK,SAAS,IAAI,WAAW,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;AAC9E,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,eAAe,CAAC,GAAe,EAAE,WAAuB,EAAE,QAAgB;IACjF,MAAM,OAAO,GACX,WAAW,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,GAAG,WAAW,CAAC,IAAI,CAAC,MAAM,gBAAgB,CAAC;IAClG,OAAO;QACL,QAAQ,EAAE,MAAM;QAChB,IAAI,EAAE,uBAAuB;QAC7B,OAAO,EAAE,GAAG,CAAC,IAAI,CAAC,EAAE;QACpB,OAAO,EACL,GAAG,OAAO,CAAC,GAAG,CAAC,KAAK,WAAW,CAAC,IAAI,OAAO,WAAW,CAAC,EAAE,iBAAiB,OAAO,eAAe;YAChG,qFAAqF;YACrF,GAAG,QAAQ,wEAAwE;YACnF,0GAA0G;YAC1G,uCAAuC;KAC1C,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,GAAe,EAAE,OAAoB;IAChE,MAAM,WAAW,GAAG,aAAa,CAAC,GAAG,CAAC,IAAI,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IAC3D,QAAQ,WAAW,CAAC,OAAO,EAAE,CAAC;QAC5B,KAAK,UAAU;YACb,OAAO,CAAC,eAAe,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC,CAAC;QAC7C,KAAK,WAAW;YACd,OAAO,CAAC,gBAAgB,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC,CAAC;QAC9C,KAAK,YAAY,EAAE,CAAC;YAClB,MAAM,QAAQ,GAAG,gBAAgB,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;YACxD,OAAO,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,eAAe,CAAC,GAAG,EAAE,WAAW,EAAE,QAAQ,CAAC,CAAC,CAAC;QAChF,CAAC;IACH,CAAC;AACH,CAAC","sourcesContent":["/**\n * The three grades one hop of a use case can earn: `usecase.step-unbacked`,\n * `usecase.step-contested` and `usecase.step-unlinked`.\n *\n * The attribution itself is `core/c4/resolve/steps.ts`'s `attributeStep` — which declared\n * relationship backs a hop, oriented on `isBackward` and decided by the\n * DISTINCT-op count — and none of that reasoning is repeated here. This module\n * owns three things: which verdict earns which code, the three guards that keep\n * the warn honest, and the sentences.\n *\n * WHY `step-unbacked` IS THE CHECK THAT EARNS THE FEATURE. A hop between two\n * elements that both exist, with no relationship between them anywhere in the\n * model, is ZERO LikeC4 errors: a `dynamic view` is allowed to show a call the\n * model never declared, so the diagram renders, the project parses, and every\n * other check in loam stays green. The flow reads as fine in every tool the\n * fleet has. If loam does not convict it, nothing does — which is why it is an\n * error rather than an advisory.\n *\n * ONE BREACH, ONE FINDING, and here that is structural rather than remembered.\n * `contested` and `attributed` are disjoint verdicts, so deriving\n * `step-unlinked` from the `attributed` arm — never from the candidate\n * relationships — is what makes a contested step report once. A grader that\n * walked `rels` looking for a missing `op` would convict the same hop twice:\n * once for the disagreement, and once for whichever candidate happened to carry\n * no operation at all.\n */\nimport { attributeStep, type StepAttribution, type StepScope } from \"../../../../core/c4/resolve/steps.js\";\nimport type { Elem, Rel } from \"../../../../core/c4/likec4.js\";\nimport type { ParsedStep, ParsedView } from \"../../../../core/c4/parsed/dynamic-views.js\";\nimport { ACTOR_KINDS } from \"../../../../core/vocabulary/maturity.js\";\nimport type { Finding } from \"../../../../core/vocabulary/report.js\";\nimport { stepPlace } from \"../../../../core/usecases/place.js\";\nimport { viewPlace } from \"./place.js\";\n\n/** One hop being graded and the view it belongs to — never passed apart. */\nexport interface GradedStep {\n view: ParsedView;\n step: ParsedStep;\n}\n\n/**\n * What a hop is graded against, built ONCE per run.\n *\n * `services` and `model.known` are the same set by construction (`./usecases.ts`\n * builds both from one value), which is what stops \"which service is this\n * element\" being answered one way by the attribution and another by the provider\n * guard below. `resolve` is injected for the same reason `fleetShapeFindings`\n * takes it: the fleet target already holds the shared element→service resolver,\n * and a second one built here could not disagree today but would be a second\n * place to change.\n */\nexport interface StepGrading {\n model: StepScope;\n /** The `services/<id>/` directories that exist. */\n services: ReadonlySet<string>;\n /** The shared element→service resolver every other edge join uses. */\n resolve: (id: string) => string;\n}\n\ntype Unbacked = Extract<StepAttribution, { verdict: \"unbacked\" }>;\ntype Contested = Extract<StepAttribution, { verdict: \"contested\" }>;\ntype Attributed = Extract<StepAttribution, { verdict: \"attributed\" }>;\n\n/** `…checkout.likec4 — dynamic view 'uc_checkout' step 2 'authorizes the payment'`. */\nfunction placeOf(hop: GradedStep): string {\n return `${viewPlace(hop.view)} ${stepPlace(hop.step)}`;\n}\n\n/**\n * A hop nothing in the model backs.\n *\n * Two fixes, because there are genuinely two mistakes that land here and the\n * author is the only one who can say which they made: the edge really is\n * missing, or the hop is a reply written the long way round. The second is\n * offered as the OTHER spelling of what they already typed — suggesting the\n * spelling they used would be a hint that restates the defect — and the sentence\n * says which pair that spelling looks the hop up under, because `<-` attributes\n * a reply to the CALL it answers rather than to the direction the message\n * travels (`callPair` in core/c4/resolve/steps.ts holds the measurement).\n */\nfunction unbackedFinding(hop: GradedStep, attribution: Unbacked): Finding {\n const other = hop.step.isBackward\n ? { spelling: `${hop.step.source} -> ${hop.step.target}`, when: \"is not a return step\" }\n : { spelling: `${hop.step.target} <- ${hop.step.source}`, when: \"is a return step\" };\n return {\n severity: \"error\",\n code: \"usecase.step-unbacked\",\n subject: hop.view.id,\n message:\n `${placeOf(hop)}: nothing in the model declares ${attribution.from} -> ${attribution.to}, so this hop is ` +\n \"backed by no relationship — and LikeC4 reports no error for it, so the view still renders and the \" +\n \"project still parses. Draw the edge in `model { }` (with `metadata { op '<operationId>' }` where it is \" +\n `a call), or, if this hop ${other.when}, write it as \\`${other.spelling}\\` — a reply is attributed to the ` +\n `call it answers, so that spelling looks the hop up under ${attribution.to} -> ${attribution.from} instead.`,\n };\n}\n\n/** `web -> orders.api \"Calls createOrder\" (op: createOrder)`, or `(no op)` where the edge declares none. */\nfunction candidateLine(rel: Rel): string {\n const op = rel.op === undefined ? \"no op\" : `op: ${rel.op}`;\n return `${rel.source} -> ${rel.target} \"${rel.title ?? \"\"}\" (${op})`;\n}\n\n/**\n * A hop whose candidates disagree about the operation.\n *\n * WARN, not error, because the model is complete — every candidate is a\n * relationship somebody drew on purpose — and the honest answer is sometimes\n * \"two paths, one flow\". What loam refuses to do is pick: a guessed operation is\n * indistinguishable from a right one in every rollup built over the use case,\n * and it stays wrong invisibly.\n *\n * `details` carries every candidate rather than the distinct ops, because the\n * repair is made on a RELATIONSHIP: the author has to find the edge that names\n * the wrong operation, and a list of operation names does not say which edge\n * that is. An absent `metadata { op }` prints as `no op` and is a candidate like\n * any other — it is exactly as much of a disagreement as a second name.\n */\nfunction contestedFinding(hop: GradedStep, attribution: Contested): Finding {\n return {\n severity: \"warn\",\n code: \"usecase.step-contested\",\n subject: hop.view.id,\n message:\n `${placeOf(hop)}: ${attribution.rels.length} relationships back ${attribution.from} -> ` +\n `${attribution.to} and they name ${attribution.ops.length} different operations, so nothing can say ` +\n \"which one this hop exercises. loam names the candidates instead of picking one — a guessed operation \" +\n \"reads exactly like a right one in every rollup built over this flow. Give the candidates one operation, \" +\n \"or draw the hop between the elements that name exactly one.\",\n details: attribution.rels.map(candidateLine),\n };\n}\n\n/**\n * The service this hop calls, when the hop is a `step-unlinked` candidate at\n * all — `null` when any of the three guards refuses. ALL THREE are mechanical,\n * and each closes a whole class of warnings that would otherwise be simply\n * wrong. The count is stated because a reader auditing this function is meant to\n * find three bullets and three `return null`s below and conclude that none of\n * them is surplus: the one a miscount invites deleting is the last, and deleting\n * it puts the warning back on the first hop of almost every flow.\n *\n * - A `publishes` or `consumes` on ANY candidate means the hop does reach a\n * declared API — an AsyncAPI one. The event spine already grades it\n * (`spine.message-undefined` and its family), and asking for an `op` as well\n * would be loam demanding an HTTP operationId for a Kafka message. Read\n * across every candidate rather than the first, because on the service tier\n * the candidates are one call drawn as containers.\n * - The target must resolve to a real `services/<id>/`. Without it every hop\n * into an `#external` system, an actor, a database, a cache or a queue warns\n * — none of which owns an `openapi.yaml`, so none of them CAN carry the\n * operationId the message would be asking for. Through the shared resolver,\n * so \"the provider\" here and \"the provider\" in the spine check cannot\n * disagree about which element stands for which directory.\n * - The CALLER must not be an actor. A person is not a caller: `customer ->\n * checkoutWeb 'opens the basket'` is somebody using the app, and the app owes\n * no operationId for a click. The guard above does not cover this, because it\n * reads the TARGET — and the target here is one of ours, so without this the\n * warning fired on the FIRST HOP OF ALMOST EVERY FLOW, which is where a\n * sequence diagram puts its actor. Measured on the published example fleet\n * before it was added. This is the same exemption `fleet/census.ts`,\n * `arch-coverage.ts`, `core/gate/partners.ts` and `core/verify/checklist.ts`\n * each already apply, through the same `ACTOR_KINDS` — a person was never a\n * participant in any of loam's other censuses either.\n *\n * The op test reads the ATTRIBUTION rather than the relationships: an\n * `attributed` verdict with no `op` means every candidate agreed the hop names\n * no operation, which is the state this warn is about. Candidates that disagree\n * never reach here at all — that is the suppression, and it is why it cannot be\n * forgotten.\n */\nfunction unlinkedProvider(attribution: Attributed, grading: StepGrading): string | null {\n if (attribution.op !== undefined) return null;\n if (attribution.rels.some((rel) => rel.publishes !== undefined || rel.consumes !== undefined)) return null;\n if (isActor(attribution.from, grading.model.elements)) return null;\n const provider = grading.resolve(attribution.to);\n return grading.services.has(provider) ? provider : null;\n}\n\n/**\n * Is this endpoint a person rather than a system?\n *\n * Exact id, not the ancestor walk the service resolver does: an actor is a\n * top-level element in every landscape loam has seen, and a nested one would be\n * a person inside a system, which is not a shape the model has. An endpoint that\n * names no element cannot occur — a step naming one is a LikeC4 reference error,\n * so the document never reaches a check.\n */\nfunction isActor(id: string, elements: Elem[]): boolean {\n const element = elements.find((candidate) => candidate.id === id);\n return element !== undefined && ACTOR_KINDS.has(element.kind.toLowerCase());\n}\n\n/**\n * A hop backed by the model and joined to no contract.\n *\n * The mirror of `spine.op-link-missing` at flow altitude: the use case reaches\n * the fleet map and stops there, so nothing can say which operation of the\n * provider's contract the step exercises — and the whole point of grading a use\n * case is that the flow joins through to the API it drives.\n */\nfunction unlinkedFinding(hop: GradedStep, attribution: Attributed, provider: string): Finding {\n const backing =\n attribution.rels.length === 1 ? \"one relationship\" : `${attribution.rels.length} relationships`;\n return {\n severity: \"warn\",\n code: \"usecase.step-unlinked\",\n subject: hop.view.id,\n message:\n `${placeOf(hop)}: ${attribution.from} -> ${attribution.to} is backed by ${backing} carrying no ` +\n \"`metadata { op }` and no `publishes`/`consumes`, so this hop names no operation of \" +\n `${provider}'s contract — the use case reaches the fleet map and stops there. Add ` +\n \"`metadata { op '<operationId>' }` to the edge in `model { }`, or `metadata { publishes '<message>' }` / \" +\n \"`consumes` where the hop is an event.\",\n };\n}\n\n/**\n * The findings one hop earns — at most one, whatever is wrong with it.\n *\n * A `switch` over the closed verdict union with every arm returning, so a fourth\n * verdict added upstream is a compile error here rather than a hop that silently\n * stops being graded.\n */\nexport function stepFindings(hop: GradedStep, grading: StepGrading): Finding[] {\n const attribution = attributeStep(hop.step, grading.model);\n switch (attribution.verdict) {\n case \"unbacked\":\n return [unbackedFinding(hop, attribution)];\n case \"contested\":\n return [contestedFinding(hop, attribution)];\n case \"attributed\": {\n const provider = unlinkedProvider(attribution, grading);\n return provider === null ? [] : [unlinkedFinding(hop, attribution, provider)];\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"steps.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/usecases/steps.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The three grades one hop of a use case can earn: `usecase.step-unbacked`,\n * `usecase.step-contested` and `usecase.step-unlinked`.\n *\n * The attribution itself is `core/c4/resolve/steps.ts`'s `attributeStep` — which declared\n * relationship backs a hop, oriented on `isBackward` and decided by the\n * DISTINCT-op count — and none of that reasoning is repeated here. This module\n * owns three things: which verdict earns which code, the four guards that keep\n * the warn honest, and the sentences.\n *\n * TWO SCOPES, ONE GRADER. The fleet target grades the `architecture/` project's\n * views and the service target grades a service's own project — every `.likec4`\n * beside `model.likec4`, read the way the renderer reads it\n * (`commands/validate/service/usecases/`). Both call `stepFindings` below with\n * their own `StepGrading`; what differs is only where a finding says the view\n * is (`place`). A second copy of the verdict→code mapping per scope is the\n * drift this package's banner forbids, which is why the intra-service case is\n * a fourth guard here rather than a grader of its own.\n *\n * WHY `step-unbacked` IS THE CHECK THAT EARNS THE FEATURE. A hop between two\n * elements that both exist, with no relationship between them anywhere in the\n * model, is ZERO LikeC4 errors: a `dynamic view` is allowed to show a call the\n * model never declared, so the diagram renders, the project parses, and every\n * other check in loam stays green. The flow reads as fine in every tool the\n * fleet has. If loam does not convict it, nothing does — which is why it is an\n * error rather than an advisory.\n *\n * ONE BREACH, ONE FINDING, and here that is structural rather than remembered.\n * `contested` and `attributed` are disjoint verdicts, so deriving\n * `step-unlinked` from the `attributed` arm — never from the candidate\n * relationships — is what makes a contested step report once. A grader that\n * walked `rels` looking for a missing `op` would convict the same hop twice:\n * once for the disagreement, and once for whichever candidate happened to carry\n * no operation at all.\n */\nimport { attributeStep, type StepAttribution, type StepScope } from \"../../../../core/c4/resolve/steps.js\";\nimport type { Elem, Rel } from \"../../../../core/c4/likec4.js\";\nimport type { ParsedStep, ParsedView } from \"../../../../core/c4/parsed/dynamic-views.js\";\nimport { ACTOR_KINDS } from \"../../../../core/vocabulary/maturity.js\";\nimport type { Finding } from \"../../../../core/vocabulary/report.js\";\nimport { stepPlace } from \"../../../../core/usecases/place.js\";\nimport { viewPlace } from \"./place.js\";\n\n/** One hop being graded and the view it belongs to — never passed apart. */\nexport interface GradedStep {\n view: ParsedView;\n step: ParsedStep;\n}\n\n/**\n * What a hop is graded against, built ONCE per run.\n *\n * `services` and `model.known` are the same set by construction (`./usecases.ts`\n * builds both from one value), which is what stops \"which service is this\n * element\" being answered one way by the attribution and another by the provider\n * guard below. `resolve` is injected for the same reason `fleetShapeFindings`\n * takes it: the fleet target already holds the shared element→service resolver,\n * and a second one built here could not disagree today but would be a second\n * place to change.\n */\nexport interface StepGrading {\n model: StepScope;\n /**\n * The `services/<id>/` directories that exist. The SAME enumerated set in\n * both scopes — the service target hands over the whole fleet too, never an\n * empty set — so the documented invariant above (`services` and `model.known`\n * are one value) holds wherever this grader runs, and the intra-service\n * exemption is the fourth guard in `unlinkedProvider` rather than a scope\n * that quietly disables the provider test.\n */\n services: ReadonlySet<string>;\n /** The shared element→service resolver every other edge join uses. */\n resolve: (id: string) => string;\n /**\n * Where a finding says the view is — the opening of every message in this\n * family. Defaults to `viewPlace`, the fleet target's\n * `landscape: architecture/<file> — dynamic view '<id>'`. The service target\n * injects its own, because a view read out of a service's project carries a\n * `sourcePath` relative to the SERVICE directory, and the `landscape:`\n * opening is the fleet target's name — a finding filed on a service must not\n * claim it (`../place.ts` records why the prefix stayed command vocabulary).\n */\n place?: (view: ParsedView) => string;\n}\n\ntype Unbacked = Extract<StepAttribution, { verdict: \"unbacked\" }>;\ntype Contested = Extract<StepAttribution, { verdict: \"contested\" }>;\ntype Attributed = Extract<StepAttribution, { verdict: \"attributed\" }>;\n\n/**\n * `…checkout.likec4 — dynamic view 'uc_checkout' step 2 'authorizes the payment'`.\n *\n * Spelled once per hop in `stepFindings` and handed to each sentence builder as\n * a string, rather than recomputed from the grading inside each of them: three\n * builders reaching for `grading.place ?? viewPlace` on their own would be three\n * places for one scope to forget the injection.\n */\nfunction placeOf(hop: GradedStep, grading: StepGrading): string {\n return `${(grading.place ?? viewPlace)(hop.view)} ${stepPlace(hop.step)}`;\n}\n\n/**\n * A hop nothing in the model backs.\n *\n * Two fixes, because there are genuinely two mistakes that land here and the\n * author is the only one who can say which they made: the edge really is\n * missing, or the hop is a reply written the long way round. The second is\n * offered as the OTHER spelling of what they already typed — suggesting the\n * spelling they used would be a hint that restates the defect — and the sentence\n * says which pair that spelling looks the hop up under, because `<-` attributes\n * a reply to the CALL it answers rather than to the direction the message\n * travels (`callPair` in core/c4/resolve/steps.ts holds the measurement).\n */\nfunction unbackedFinding(hop: GradedStep, attribution: Unbacked, place: string): Finding {\n const other = hop.step.isBackward\n ? { spelling: `${hop.step.source} -> ${hop.step.target}`, when: \"is not a return step\" }\n : { spelling: `${hop.step.target} <- ${hop.step.source}`, when: \"is a return step\" };\n return {\n severity: \"error\",\n code: \"usecase.step-unbacked\",\n subject: hop.view.id,\n message:\n `${place}: nothing in the model declares ${attribution.from} -> ${attribution.to}, so this hop is ` +\n \"backed by no relationship — and LikeC4 reports no error for it, so the view still renders and the \" +\n \"project still parses. Draw the edge in `model { }` (with `metadata { op '<operationId>' }` where it is \" +\n `a call), or, if this hop ${other.when}, write it as \\`${other.spelling}\\` — a reply is attributed to the ` +\n `call it answers, so that spelling looks the hop up under ${attribution.to} -> ${attribution.from} instead.`,\n };\n}\n\n/** `web -> orders.api \"Calls createOrder\" (op: createOrder)`, or `(no op)` where the edge declares none. */\nfunction candidateLine(rel: Rel): string {\n const op = rel.op === undefined ? \"no op\" : `op: ${rel.op}`;\n return `${rel.source} -> ${rel.target} \"${rel.title ?? \"\"}\" (${op})`;\n}\n\n/**\n * A hop whose candidates disagree about the operation.\n *\n * WARN, not error, because the model is complete — every candidate is a\n * relationship somebody drew on purpose — and the honest answer is sometimes\n * \"two paths, one flow\". What loam refuses to do is pick: a guessed operation is\n * indistinguishable from a right one in every rollup built over the use case,\n * and it stays wrong invisibly.\n *\n * `details` carries every candidate rather than the distinct ops, because the\n * repair is made on a RELATIONSHIP: the author has to find the edge that names\n * the wrong operation, and a list of operation names does not say which edge\n * that is. An absent `metadata { op }` prints as `no op` and is a candidate like\n * any other — it is exactly as much of a disagreement as a second name.\n */\nfunction contestedFinding(hop: GradedStep, attribution: Contested, place: string): Finding {\n return {\n severity: \"warn\",\n code: \"usecase.step-contested\",\n subject: hop.view.id,\n message:\n `${place}: ${attribution.rels.length} relationships back ${attribution.from} -> ` +\n `${attribution.to} and they name ${attribution.ops.length} different operations, so nothing can say ` +\n \"which one this hop exercises. loam names the candidates instead of picking one — a guessed operation \" +\n \"reads exactly like a right one in every rollup built over this flow. Give the candidates one operation, \" +\n \"or draw the hop between the elements that name exactly one.\",\n details: attribution.rels.map(candidateLine),\n };\n}\n\n/**\n * The service this hop calls, when the hop is a `step-unlinked` candidate at\n * all — `null` when any of the four guards refuses. ALL FOUR are mechanical,\n * and each closes a whole class of warnings that would otherwise be simply\n * wrong. The count is stated because a reader auditing this function is meant to\n * find four bullets and four `return null`s below and conclude that none of\n * them is surplus: the one a miscount invites deleting is the actor guard, and\n * deleting it puts the warning back on the first hop of almost every flow;\n * deleting the last puts it on EVERY hop of every flow drawn inside a service.\n *\n * - A `publishes` or `consumes` on ANY candidate means the hop does reach a\n * declared API — an AsyncAPI one. The event spine already grades it\n * (`spine.message-undefined` and its family), and asking for an `op` as well\n * would be loam demanding an HTTP operationId for a Kafka message. Read\n * across every candidate rather than the first, because on the service tier\n * the candidates are one call drawn as containers.\n * - The target must resolve to a real `services/<id>/`. Without it every hop\n * into an `#external` system, an actor, a database, a cache or a queue warns\n * — none of which owns an `openapi.yaml`, so none of them CAN carry the\n * operationId the message would be asking for. Through the shared resolver,\n * so \"the provider\" here and \"the provider\" in the spine check cannot\n * disagree about which element stands for which directory.\n * - The CALLER must not be an actor. A person is not a caller: `customer ->\n * checkoutWeb 'opens the basket'` is somebody using the app, and the app owes\n * no operationId for a click. The guard above does not cover this, because it\n * reads the TARGET — and the target here is one of ours, so without this the\n * warning fired on the FIRST HOP OF ALMOST EVERY FLOW, which is where a\n * sequence diagram puts its actor. Measured on the published example fleet\n * before it was added. This is the same exemption `fleet/census.ts`,\n * `arch-coverage.ts`, `core/gate/partners.ts` and `core/verify/checklist.ts`\n * each already apply, through the same `ACTOR_KINDS` — a person was never a\n * participant in any of loam's other censuses either.\n * - The CALLER must not resolve to the provider itself. A hop between two\n * containers of one service — `api -> db` inside a service's own project,\n * or the same pair on a landscape that draws the service as containers —\n * names no operation of that service's contract: an operationId is what a\n * caller OUTSIDE the boundary uses, and a service owes none to itself. The\n * three guards above all pass such a hop (the edge carries no message, the\n * target IS a real `services/<id>/`, the caller is a container), so without\n * this one the warning fired on every hop of every intra-service flow the\n * moment loam started reading them — and the message (\"names no operation\n * of X's contract\") was about X calling X. Through the same shared\n * resolver as the provider, so the two sides of the comparison cannot\n * disagree about which directory an element stands for.\n *\n * The op test reads the ATTRIBUTION rather than the relationships: an\n * `attributed` verdict with no `op` means every candidate agreed the hop names\n * no operation, which is the state this warn is about. Candidates that disagree\n * never reach here at all — that is the suppression, and it is why it cannot be\n * forgotten.\n */\nfunction unlinkedProvider(attribution: Attributed, grading: StepGrading): string | null {\n if (attribution.op !== undefined) return null;\n if (attribution.rels.some((rel) => rel.publishes !== undefined || rel.consumes !== undefined)) return null;\n if (isActor(attribution.from, grading.model.elements)) return null;\n const provider = grading.resolve(attribution.to);\n if (grading.resolve(attribution.from) === provider) return null;\n return grading.services.has(provider) ? provider : null;\n}\n\n/**\n * Is this endpoint a person rather than a system?\n *\n * Exact id, not the ancestor walk the service resolver does: an actor is a\n * top-level element in every landscape loam has seen, and a nested one would be\n * a person inside a system, which is not a shape the model has. An endpoint that\n * names no element cannot occur — a step naming one is a LikeC4 reference error,\n * so the document never reaches a check.\n */\nfunction isActor(id: string, elements: Elem[]): boolean {\n const element = elements.find((candidate) => candidate.id === id);\n return element !== undefined && ACTOR_KINDS.has(element.kind.toLowerCase());\n}\n\n/**\n * A hop backed by the model and joined to no contract.\n *\n * The mirror of `spine.op-link-missing` at flow altitude: the use case reaches\n * the fleet map and stops there, so nothing can say which operation of the\n * provider's contract the step exercises — and the whole point of grading a use\n * case is that the flow joins through to the API it drives.\n */\nfunction unlinkedFinding(hop: GradedStep, attribution: Attributed, provider: string, place: string): Finding {\n const backing =\n attribution.rels.length === 1 ? \"one relationship\" : `${attribution.rels.length} relationships`;\n return {\n severity: \"warn\",\n code: \"usecase.step-unlinked\",\n subject: hop.view.id,\n message:\n `${place}: ${attribution.from} -> ${attribution.to} is backed by ${backing} carrying no ` +\n \"`metadata { op }` and no `publishes`/`consumes`, so this hop names no operation of \" +\n `${provider}'s contract — the use case reaches the fleet map and stops there. Add ` +\n \"`metadata { op '<operationId>' }` to the edge in `model { }`, or `metadata { publishes '<message>' }` / \" +\n \"`consumes` where the hop is an event.\",\n };\n}\n\n/**\n * The findings one hop earns — at most one, whatever is wrong with it.\n *\n * A `switch` over the closed verdict union with every arm returning, so a fourth\n * verdict added upstream is a compile error here rather than a hop that silently\n * stops being graded.\n */\nexport function stepFindings(hop: GradedStep, grading: StepGrading): Finding[] {\n const attribution = attributeStep(hop.step, grading.model);\n const place = placeOf(hop, grading);\n switch (attribution.verdict) {\n case \"unbacked\":\n return [unbackedFinding(hop, attribution, place)];\n case \"contested\":\n return [contestedFinding(hop, attribution, place)];\n case \"attributed\": {\n const provider = unlinkedProvider(attribution, grading);\n return provider === null ? [] : [unlinkedFinding(hop, attribution, provider, place)];\n }\n }\n}\n"]}
|