@ybotok/loam 0.2.0-alpha.5 → 0.2.0-alpha.7
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 +1063 -0
- package/MIGRATING-from-OpenSpec.md +7 -4
- package/QUICKSTART.md +8 -3
- package/README.md +13 -7
- package/ROADMAP.md +285 -11
- package/SCHEMA.md +566 -45
- 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 +53 -23
- 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/commit.js +34 -1
- package/dist/commands/seed/commit.js.map +1 -1
- package/dist/commands/seed/plan.js +158 -13
- 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/edit.js +4 -2
- package/dist/commands/subsystem/edit.js.map +1 -1
- package/dist/commands/subsystem/move.js +55 -17
- package/dist/commands/subsystem/move.js.map +1 -1
- package/dist/commands/subsystem/sync.js +238 -87
- 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 +174 -0
- package/dist/commands/subsystem/txn/report.js.map +1 -0
- package/dist/commands/subsystem/txn/txn.js +126 -22
- package/dist/commands/subsystem/txn/txn.js.map +1 -1
- package/dist/commands/subsystem/txn/views.js +159 -19
- 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/health-deps.js +9 -6
- package/dist/commands/validate/checks/health-deps.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 +37 -13
- package/dist/commands/validate/fleet/census.js.map +1 -1
- package/dist/commands/validate/fleet/landscape.js +131 -120
- 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 +102 -0
- package/dist/commands/validate/fleet/map/attest.js.map +1 -0
- package/dist/commands/validate/fleet/map/bindings.js +109 -0
- package/dist/commands/validate/fleet/map/bindings.js.map +1 -0
- package/dist/commands/validate/fleet/map/consumers.js +219 -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/scorecard/scorecard.js.map +1 -1
- 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 +45 -15
- package/dist/commands/validate/fleet/views/ids.js.map +1 -1
- package/dist/commands/validate/fleet/views/projects.js +258 -70
- package/dist/commands/validate/fleet/views/projects.js.map +1 -1
- package/dist/commands/validate/fleet/views/root-documents.js +99 -0
- package/dist/commands/validate/fleet/views/root-documents.js.map +1 -0
- package/dist/commands/validate/fleet/views/stale.js +32 -15
- 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 +19 -5
- 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 +70 -17
- 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/refusals.js +5 -3
- package/dist/core/agent/agents-md/refusals.js.map +1 -1
- package/dist/core/agent/agents-md.js +26 -7
- 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 +123 -14
- package/dist/core/agent/workflows/adopt.js.map +1 -1
- package/dist/core/agent/workflows/check.js +34 -20
- 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/agent/workflows/reference/spine.js +12 -6
- package/dist/core/agent/workflows/reference/spine.js.map +1 -1
- package/dist/core/brief/brief.js +88 -19
- package/dist/core/brief/brief.js.map +1 -1
- package/dist/core/brief/checks.js +133 -7
- 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 +94 -29
- package/dist/core/brief/targets.js.map +1 -1
- package/dist/core/brief/unchecked.js +13 -3
- 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 +77 -7
- 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 +12 -6
- package/dist/core/c4/project/documents.js.map +1 -1
- package/dist/core/c4/project/load.js +85 -42
- package/dist/core/c4/project/load.js.map +1 -1
- package/dist/core/c4/project/staged.js +53 -15
- 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 +16 -10
- 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 +29 -24
- 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 +36 -8
- 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/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/checks.js +5 -1
- package/dist/core/gate/checks.js.map +1 -1
- package/dist/core/gate/partners.js +65 -26
- 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 +45 -38
- package/dist/core/repo/paths.js.map +1 -1
- package/dist/core/repo/tree/render/body.js +45 -6
- package/dist/core/repo/tree/render/body.js.map +1 -1
- package/dist/core/repo/tree/render/projects.js +93 -35
- package/dist/core/repo/tree/render/projects.js.map +1 -1
- package/dist/core/repo/tree/render/service/header.js +125 -0
- package/dist/core/repo/tree/render/service/header.js.map +1 -0
- package/dist/core/repo/tree/render/service/peers.js +28 -0
- package/dist/core/repo/tree/render/service/peers.js.map +1 -0
- package/dist/core/repo/tree/render/service/view-id.js +67 -0
- package/dist/core/repo/tree/render/service/view-id.js.map +1 -0
- package/dist/core/repo/tree/render/service/views.js +78 -0
- package/dist/core/repo/tree/render/service/views.js.map +1 -0
- package/dist/core/repo/tree/render/stale.js +20 -4
- package/dist/core/repo/tree/render/stale.js.map +1 -1
- package/dist/core/repo/tree/render/views.js +149 -32
- 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/verify/checklist.js +42 -10
- package/dist/core/verify/checklist.js.map +1 -1
- package/dist/core/vocabulary/issue.js.map +1 -1
- package/dist/core/vocabulary/maturity.js +51 -1
- package/dist/core/vocabulary/maturity.js.map +1 -1
- package/examples/README.md +30 -8
- package/examples/docs/architecture/landscape.likec4 +47 -0
- package/examples/docs/architecture/services.likec4 +70 -0
- package/examples/docs/architecture/subsystems.likec4 +3 -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 +58 -70
- package/examples/docs/services/payment-service/arch.spec.md +7 -4
- package/examples/docs/services/payment-service/model.likec4 +38 -39
- package/examples/docs/services/platform/identity-service/arch.spec.md +7 -4
- package/examples/docs/services/platform/identity-service/model.likec4 +35 -37
- package/examples/docs/services/platform/notification-service/health.yaml +8 -3
- package/examples/docs/services/platform/notification-service/model.likec4 +43 -64
- package/package.json +1 -1
- package/examples/docs/services/checkout-web/likec4.config.json +0 -4
- package/examples/docs/services/order-service/likec4.config.json +0 -4
- package/examples/docs/services/payment-service/likec4.config.json +0 -4
- package/examples/docs/services/platform/identity-service/likec4.config.json +0 -4
- package/examples/docs/services/platform/notification-service/likec4.config.json +0 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stale.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/views/stale.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"stale.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/views/stale.ts"],"names":[],"mappings":"AAmDA,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AACtF,OAAO,EAAE,iBAAiB,EAAE,UAAU,EAAmB,MAAM,4CAA4C,CAAC;AAK5G,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,OAAgB,EAAE,IAAe,EAAE,GAAa;IACvF,MAAM,UAAU,GAAG,MAAM,UAAU,CAAC,kBAAkB,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC;IAC5E,MAAM,QAAQ,GAAG,MAAM,iBAAiB,CAAC,gBAAgB,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC;IAC/E,OAAO;QACL,GAAG,YAAY,CAAC,UAAU,EAAE;YAC1B,IAAI,EAAE,gCAAgC;YACtC,MAAM,EAAE,uDAAuD;YAC/D,QAAQ,EAAE,4EAA4E;YACtF,IAAI,EAAE,wFAAwF;SAC/F,CAAC;QACF,GAAG,YAAY,CAAC,QAAQ,EAAE;YACxB,IAAI,EAAE,8BAA8B;YACpC,MAAM,EAAE,wEAAwE;YAChF,QAAQ,EACN,0GAA0G;YAC5G,IAAI,EACF,yGAAyG;gBACzG,2EAA2E;SAC9E,CAAC;KACH,CAAC;AACJ,CAAC;AAcD;;;GAGG;AACH,SAAS,YAAY,CAAC,KAAiB,EAAE,IAAe;IACtD,IAAI,KAAK,CAAC,MAAM;QAAE,OAAO,EAAE,CAAC;IAC5B,MAAM,IAAI,GACR,KAAK,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,qCAAqC,CAAC;IACxH,OAAO;QACL;YACE,QAAQ,EAAE,OAAO;YACjB,IAAI,EAAE,uBAAuB;YAC7B,OAAO,EAAE,IAAI,CAAC,IAAI;YAClB,wEAAwE;YACxE,uEAAuE;YACvE,wDAAwD;YACxD,qEAAqE;YACrE,sEAAsE;YACtE,sEAAsE;YACtE,mCAAmC;YACnC,SAAS,EAAE;gBACT,KAAK,CAAC,MAAM,KAAK,IAAI;oBACnB,CAAC,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,OAAgB,EAAE;oBAClD,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,SAAkB,EAAE;aAClD;YACD,OAAO,EACL,eAAe,IAAI,CAAC,IAAI,IAAI,IAAI,uBAAuB,IAAI,CAAC,IAAI,IAAI;gBACpE,oEAAoE;SACvE;KACF,CAAC;AACJ,CAAC","sourcesContent":["/**\n * Staleness of the GENERATED documents under `architecture/` against the tree\n * and the map: ONE error per stale file, naming that file, repaired by exactly\n * one command (`loam subsystem sync`).\n *\n * TWO FILES, ONE CODE, and that is a decision rather than an omission.\n * `subsystems.likec4` (one view per subsystem) and `services.likec4` (one\n * overview view per service the map draws) are written by the same command in\n * the same commit, drift for overlapping reasons — a `subsystem move` restales\n * both, because a per-service view's title is `<subsystem path> / <service\n * id>` — and are repaired by the same run. A second code would buy a reader\n * nothing the `subject` and the message do not already say, and every code costs\n * a row in `core/agent/workflows/check.ts`, a `loam explain` entry and a place in\n * the vocabulary. What DOES have to stay apart is which file drifted, so the two\n * are graded separately and each finding carries its own path — folding them\n * into one finding would lose the only thing the message has to say.\n *\n * A content compare, deliberately — never a parse. A views-only document does\n * not parse standalone (its `include` lines reference elements the landscape\n * defines; the spike in `core/repo/tree/render/views.ts` records it), and no check\n * anywhere reads the file's MEANING: the file is a scoping convenience for\n * the LikeC4 renderer, so the only question loam owes it is \"is this what the\n * tree renders to\". Rule 26 leaves this untouched: what loam may\n * read is a `dynamic view`'s declared steps out of a document it already\n * parses — never a static view's `include` predicates, and never this\n * generated file at all. Absent counts as a state of its own: a fleet\n * with subsystems and no file is stale, and a fleet with NO subsystems and a\n * leftover file is stale too — the render contract says the file must then\n * not exist, or a group deleted months ago keeps a view forever.\n *\n * The compare is over CONTENT, not BYTES, and `viewsAgree` in\n * `core/repo/tree/render/views.ts` both performs it and records why: an ordinary\n * Windows clone hands this file back with CRLF and not one fact in it changed,\n * and under a byte compare the advertised repair is a loop rather than a\n * diagnosis. The rule lives beside the generator because the module that mints\n * the bytes is the one that owes an answer about them.\n *\n * Graded only when the landscape PARSED (the caller holds that gate): the\n * expected bytes resolve members through the landscape's element→service\n * join, and grading them against a map that is missing or unreadable would\n * cascade a second error behind `landscape.missing`/`landscape.invalid`,\n * pointing at a file whose repair command would only re-derive the same\n * incomplete answer.\n *\n * The map arrives as the parsed document's facts — its elements AND the\n * global style ids it declares — because the expected bytes now depend on\n * both: a view references `global style subsystems` exactly when the project\n * declares that id, so a fleet that declares it sees this finding once, until\n * the next `sync` writes the line.\n */\nimport type { DocsDir } from \"../../../../core/kernel/ids/dirs.js\";\nimport { serviceViewsPath, subsystemViewsPath } from \"../../../../core/repo/paths.js\";\nimport { serviceViewsState, viewsState, type ViewsState } from \"../../../../core/repo/tree/render/stale.js\";\nimport type { MapFacts } from \"../../../../core/repo/tree/render/views.js\";\nimport type { FleetTree } from \"../../../../core/repo/tree/walk.js\";\nimport type { Finding } from \"../../../../core/vocabulary/report.js\";\n\nexport async function viewsStaleFindings(docsDir: DocsDir, tree: FleetTree, map: MapFacts): Promise<Finding[]> {\n const subsystems = await viewsState(subsystemViewsPath(docsDir), tree, map);\n const services = await serviceViewsState(serviceViewsPath(docsDir), tree, map);\n return [\n ...staleFinding(subsystems, {\n file: \"architecture/subsystems.likec4\",\n absent: \"does not exist, but the tree has subsystems to mirror\",\n leftover: \"exists, but the tree has no subsystems — the generated file must be absent\",\n what: \"one view per subsystem, and every derived scope drawn from it is wrong until it agrees\",\n }),\n ...staleFinding(services, {\n file: \"architecture/services.likec4\",\n absent: \"does not exist, but the fleet map draws services this file must mirror\",\n leftover:\n \"exists, but the fleet map draws none of the services under services/ — the generated file must be absent\",\n what:\n \"one overview view per service the map draws, and every drill-down into a service goes through it, so a \" +\n \"reader following the map lands on a picture that is wrong until it agrees\",\n }),\n ];\n}\n\n/** The four sentences one file's finding differs by — the rest of it is shared. */\ninterface StaleFile {\n /** The docs-relative path, which is the subject, the location and the head of the message. */\n file: string;\n /** Why an absent file is stale. */\n absent: string;\n /** Why a present file that must not exist is stale. */\n leftover: string;\n /** What the file holds, and what is wrong while it disagrees. */\n what: string;\n}\n\n/**\n * One finding, or none — the shape both generated files share, spelled once so\n * they cannot drift into two dialects of the same sentence.\n */\nfunction staleFinding(state: ViewsState, file: StaleFile): Finding[] {\n if (state.agrees) return [];\n const said =\n state.actual === null ? file.absent : state.expected === null ? file.leftover : \"does not match the tree and the map\";\n return [\n {\n severity: \"error\",\n code: \"subsystem.views-stale\",\n subject: file.file,\n // The file the message names, so an editor jumping to the finding lands\n // on it. Without a location the report's fallback files this under the\n // TARGET, which is the landscape — a reader was sent to\n // architecture/landscape.likec4 to fix a file the sentence beside it\n // named as architecture/subsystems.likec4. A file that does not exist\n // yet cannot be `primary`: the actionable place is then the directory\n // the regenerator will write into.\n locations: [\n state.actual === null\n ? { path: \"architecture\", role: \"scope\" as const }\n : { path: file.file, role: \"primary\" as const },\n ],\n message:\n `subsystems: ${file.file} ${said} — it is generated, ${file.what}. ` +\n \"Run `loam subsystem sync` to regenerate it; never edit it by hand.\",\n },\n ];\n}\n"]}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { ARTIFACT_FILES } from "../../../../core/repo/paths.js";
|
|
2
|
+
import { serviceLevelElements } from "../../fleet/census.js";
|
|
3
|
+
/**
|
|
4
|
+
* How the OTHER declaration is named.
|
|
5
|
+
*
|
|
6
|
+
* The document, not the file: `Elem` carries no source path, and the map is a
|
|
7
|
+
* PROJECT — `architecture/landscape.likec4` plus every other `.likec4` under
|
|
8
|
+
* `architecture/` — so naming the landscape file was a guess that sent an author
|
|
9
|
+
* to a document that does not declare the element at all.
|
|
10
|
+
*/
|
|
11
|
+
const MAP = "the fleet map (architecture/)";
|
|
12
|
+
export function divergedFindings(check) {
|
|
13
|
+
const { service, model, architecture, known } = check;
|
|
14
|
+
// An extending model declares each of these elements exactly once by
|
|
15
|
+
// construction — there is no copy to diverge — and the shapes' other grade
|
|
16
|
+
// (`c4.element-unowned`) is what asks its question.
|
|
17
|
+
if (model.shape !== "standalone")
|
|
18
|
+
return [];
|
|
19
|
+
if (model.doc.errors.length > 0)
|
|
20
|
+
return [];
|
|
21
|
+
if (architecture === null || architecture.errors.length > 0)
|
|
22
|
+
return [];
|
|
23
|
+
const index = joinIndex(architecture.elements, known);
|
|
24
|
+
const findings = [];
|
|
25
|
+
for (const mine of model.doc.elements) {
|
|
26
|
+
const theirs = counterpart(index, mine);
|
|
27
|
+
if (theirs === undefined)
|
|
28
|
+
continue;
|
|
29
|
+
const fields = differing(mine, theirs);
|
|
30
|
+
if (fields.length === 0)
|
|
31
|
+
continue;
|
|
32
|
+
// Where "copy the map's declaration verbatim" is a no-op: the tag sets are
|
|
33
|
+
// all that differ (so the two kinds are equal, and the block to open can be
|
|
34
|
+
// named) and copying the map's literal tags across would leave them still
|
|
35
|
+
// differing. `fields` is checked first because copying DOES fix a title or a
|
|
36
|
+
// binding, so the verbatim remedy stays right whenever one of those differs.
|
|
37
|
+
const specs = { mine: model.doc.specification, theirs: architecture.specification };
|
|
38
|
+
const inherited = fields.length === 1 && fields[0] === "tags" && copyIsNoOp(mine, theirs, specs)
|
|
39
|
+
? declaringSide(mine.kind, specs)
|
|
40
|
+
: null;
|
|
41
|
+
findings.push({
|
|
42
|
+
severity: "warn",
|
|
43
|
+
code: "c4.declaration-diverged",
|
|
44
|
+
subject: service,
|
|
45
|
+
message: `${service}: ${ARTIFACT_FILES.model} declares '${mine.id}' as ${describe(mine)} and ` +
|
|
46
|
+
`${MAP} declares ${theirs.id === mine.id ? "it" : `'${theirs.id}'`} as ${describe(theirs)} — ` +
|
|
47
|
+
remedy(fields, mine.kind, inherited),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return findings;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* What the message says after the two declarations: which fields differ, and
|
|
54
|
+
* what to do about it. `inherited` is null when copying the map's declaration
|
|
55
|
+
* fixes this, and otherwise the clause naming which document's `specification`
|
|
56
|
+
* puts the tags on the kind — the side an author has to open.
|
|
57
|
+
*/
|
|
58
|
+
function remedy(fields, kind, inherited) {
|
|
59
|
+
// "the kind differs" / "the title differs" / "the tags differ": the verb
|
|
60
|
+
// agrees with the last field named, and `tags` is the one plural noun in the
|
|
61
|
+
// set. It shipped as "the kind differ", and a sentence a reader trips over is
|
|
62
|
+
// one they stop reading.
|
|
63
|
+
const plural = fields.length > 1 || fields[fields.length - 1] === "tags";
|
|
64
|
+
const named = `the ${fields.join(", ")} differ${plural ? "" : "s"}`;
|
|
65
|
+
// The two kinds are equal whenever `tags` is the only differing field, so the
|
|
66
|
+
// block to open can be named exactly.
|
|
67
|
+
if (inherited !== null) {
|
|
68
|
+
return (`${named}, and copying the map's declaration would not clear it: the sets come apart on ` +
|
|
69
|
+
`\`specification { element ${kind} { … } }\`, ${inherited}. Either match the two specification ` +
|
|
70
|
+
'blocks on that kind, or migrate the model to extend the map (SCHEMA.md, "Two shapes of a service ' +
|
|
71
|
+
'model") so there is one specification and one declaration');
|
|
72
|
+
}
|
|
73
|
+
return (`${named}, so two documents are two authorities on one element. Copy the map's declaration verbatim, or ` +
|
|
74
|
+
'migrate the model to extend the map (SCHEMA.md, "Two shapes of a service model") so it is declared once');
|
|
75
|
+
}
|
|
76
|
+
function joinIndex(elements, known) {
|
|
77
|
+
// Which map elements are the fleet's own boxes rather than somebody's
|
|
78
|
+
// interior, by the ONE predicate every other fleet check asks it with
|
|
79
|
+
// (`fleet/census.ts`): a binding or a title naming a real services/<id>/ makes
|
|
80
|
+
// an element a service at any depth, and what sits inside one is that
|
|
81
|
+
// service's container. Everything else is fleet-level — a top-level element,
|
|
82
|
+
// or a box the map draws inside a plain grouping element — see the header.
|
|
83
|
+
const fleetLevel = new Set(serviceLevelElements([...elements], known).map((e) => e.id));
|
|
84
|
+
return {
|
|
85
|
+
byId: unique(elements, (e) => [e.id]),
|
|
86
|
+
byBinding: unique(elements, (e) => (e.service === undefined ? [] : [e.service])),
|
|
87
|
+
// Every DOTTED tail of the id, so a model's `paymentService.api` reaches the
|
|
88
|
+
// map's `marketplace.paymentService.api`: a tail carrying a dot names the
|
|
89
|
+
// owner as well as the leaf, so the two documents are talking about one
|
|
90
|
+
// element.
|
|
91
|
+
//
|
|
92
|
+
// The bare LAST segment names only the leaf, and it is emitted for
|
|
93
|
+
// FLEET-LEVEL boxes alone. This shipped unconditional, and the comment
|
|
94
|
+
// here claimed it was not: a standalone model's own `db`, `api` or `agent`
|
|
95
|
+
// joined whatever `<something>.<that name>` the map happened to hold — one
|
|
96
|
+
// service's private cache told to copy another service's store, verbatim.
|
|
97
|
+
// loam's own meta/docs hit it (`agent` ↔ `loam.core.agent`, a container of
|
|
98
|
+
// src/core/agent/), which is the case that made the rung wrong rather than
|
|
99
|
+
// merely risky (verification 2026-09-04).
|
|
100
|
+
//
|
|
101
|
+
// A box the map draws inside a plain GROUP still emits its bare segment,
|
|
102
|
+
// and deliberately: `infra.cache` is the fleet's cache, so a model's own
|
|
103
|
+
// top-level `cache` is a second meaning for one name and worth the warning.
|
|
104
|
+
// Grouping is a drawing convenience, not ownership — only a SERVICE owns an
|
|
105
|
+
// interior — so the guard is "no ancestor stands for a service" and not
|
|
106
|
+
// "the id carries no dot".
|
|
107
|
+
bySuffix: unique(elements, (e) => {
|
|
108
|
+
const segments = e.id.split(".");
|
|
109
|
+
const tails = segments.map((_, i) => segments.slice(i).join("."));
|
|
110
|
+
return fleetLevel.has(e.id) ? tails : tails.slice(0, -1);
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** An index holding only the keys exactly ONE element answers to — a shared key joins nothing. */
|
|
115
|
+
function unique(elements, keys) {
|
|
116
|
+
const claims = new Map();
|
|
117
|
+
for (const element of elements) {
|
|
118
|
+
for (const key of keys(element)) {
|
|
119
|
+
const held = claims.get(key);
|
|
120
|
+
if (held === undefined)
|
|
121
|
+
claims.set(key, [element]);
|
|
122
|
+
else
|
|
123
|
+
held.push(element);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const index = new Map();
|
|
127
|
+
for (const [key, held] of claims)
|
|
128
|
+
if (held.length === 1)
|
|
129
|
+
index.set(key, held[0]);
|
|
130
|
+
return index;
|
|
131
|
+
}
|
|
132
|
+
/** The map's declaration of the element this model declares, or undefined when nothing joins. */
|
|
133
|
+
function counterpart(index, mine) {
|
|
134
|
+
const byId = index.byId.get(mine.id);
|
|
135
|
+
if (byId !== undefined)
|
|
136
|
+
return byId;
|
|
137
|
+
// The binding is the strongest evidence after the id: both documents wrote
|
|
138
|
+
// `metadata { service '<id>' }`, so both mean the same directory.
|
|
139
|
+
const bound = mine.service === undefined ? undefined : index.byBinding.get(mine.service);
|
|
140
|
+
if (bound !== undefined)
|
|
141
|
+
return bound;
|
|
142
|
+
return index.bySuffix.get(mine.id);
|
|
143
|
+
}
|
|
144
|
+
/** Which of the four compared fields disagree, in a fixed order so two runs read alike. */
|
|
145
|
+
function differing(mine, theirs) {
|
|
146
|
+
const fields = [];
|
|
147
|
+
if (mine.kind !== theirs.kind)
|
|
148
|
+
fields.push("kind");
|
|
149
|
+
if (mine.title !== theirs.title)
|
|
150
|
+
fields.push("title");
|
|
151
|
+
// A SET, sorted: tag order is authoring order and means nothing, and a model
|
|
152
|
+
// that lists the map's two tags the other way round is the same declaration.
|
|
153
|
+
if (tagList(mine) !== tagList(theirs))
|
|
154
|
+
fields.push("tags");
|
|
155
|
+
if (mine.service !== theirs.service)
|
|
156
|
+
fields.push("service binding");
|
|
157
|
+
return fields;
|
|
158
|
+
}
|
|
159
|
+
function tagList(e) {
|
|
160
|
+
return [...new Set(e.tags)].sort().join(" ");
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Would "copy the map's declaration verbatim" leave the tag sets still
|
|
164
|
+
* differing? Answered by SIMULATING the copy, which is the only way to be right
|
|
165
|
+
* about it in both directions.
|
|
166
|
+
*
|
|
167
|
+
* `Elem.tags` is what LikeC4 hands over, and since 1.59.0 that includes every
|
|
168
|
+
* tag the element's kind declares (`core/c4/parsed/specification.ts` says why
|
|
169
|
+
* loam reads the block at all). Copying the map's DECLARATION copies its literal
|
|
170
|
+
* tags into the model and nothing else — the model keeps its own specification —
|
|
171
|
+
* so the model's element would come back carrying `mine's kind defaults ∪ the
|
|
172
|
+
* map's literal tags`, against the map's `its kind defaults ∪ the same literal
|
|
173
|
+
* tags`. Those differ exactly when the two kind defaults differ outside the
|
|
174
|
+
* copied literals, and then the remedy is a no-op.
|
|
175
|
+
*
|
|
176
|
+
* The first version compared the two LITERAL tag lists and switched arms only
|
|
177
|
+
* when they were equal, which caught one direction of the same case and missed
|
|
178
|
+
* its mirror: a model writing `#external` on an element whose map twin inherits
|
|
179
|
+
* `#external #platform` from `element topic { … }` was told to copy the map's
|
|
180
|
+
* bare line, and doing exactly that produced the OTHER arm of this same message
|
|
181
|
+
* (verification 2026-09-04, second pass, examples/docs order-service).
|
|
182
|
+
*/
|
|
183
|
+
function copyIsNoOp(mine, theirs, specs) {
|
|
184
|
+
const copied = literalTags(theirs, specs.theirs);
|
|
185
|
+
const after = union(kindTags(mine.kind, specs.mine), copied);
|
|
186
|
+
const before = union(kindTags(theirs.kind, specs.theirs), copied);
|
|
187
|
+
return after !== before;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Which document's `specification` puts the tags on the kind — the half of the
|
|
191
|
+
* message that tells an author which file to open. "Both, differently" is a real
|
|
192
|
+
* state (each block declares its own tag on the same kind) and gets its own
|
|
193
|
+
* words rather than a guess at one side.
|
|
194
|
+
*/
|
|
195
|
+
function declaringSide(kind, specs) {
|
|
196
|
+
const mine = kindTags(kind, specs.mine);
|
|
197
|
+
const theirs = kindTags(kind, specs.theirs);
|
|
198
|
+
if (mine.length > 0 && theirs.length > 0)
|
|
199
|
+
return "which the two documents declare different tags on";
|
|
200
|
+
if (theirs.length > 0)
|
|
201
|
+
return `which ${MAP} declares tags on and ${ARTIFACT_FILES.model} does not`;
|
|
202
|
+
if (mine.length > 0)
|
|
203
|
+
return `which ${ARTIFACT_FILES.model} declares tags on and ${MAP} does not`;
|
|
204
|
+
// No kind declares anything and the copy still fails: not reachable from the
|
|
205
|
+
// predicate above (with equal kinds and no defaults the copy always clears
|
|
206
|
+
// it), and a sentence that names no file beats one that names the wrong file.
|
|
207
|
+
return "which the two documents' blocks disagree on";
|
|
208
|
+
}
|
|
209
|
+
/** The tags `spec` puts on every element of `kind`, sorted — the defaults an element cannot see. */
|
|
210
|
+
function kindTags(kind, spec) {
|
|
211
|
+
return [...new Set(spec?.elementKindTags[kind] ?? [])].sort();
|
|
212
|
+
}
|
|
213
|
+
/** A tag SET as one comparable string; see `tagList` for why order is dropped. */
|
|
214
|
+
function union(a, b) {
|
|
215
|
+
return [...new Set([...a, ...b])].sort().join(" ");
|
|
216
|
+
}
|
|
217
|
+
function literalTags(e, spec) {
|
|
218
|
+
const inherited = new Set(kindTags(e.kind, spec));
|
|
219
|
+
return [...new Set(e.tags)].filter((tag) => !inherited.has(tag)).sort();
|
|
220
|
+
}
|
|
221
|
+
/** One element as the message names it — every compared field, in the order `differing` reports them. */
|
|
222
|
+
function describe(e) {
|
|
223
|
+
const tags = tagList(e);
|
|
224
|
+
return `${e.kind} '${e.title}' [${tags === "" ? "no tags" : tags}, bound to ${e.service ?? "unbound"}]`;
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=diverged.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"diverged.js","sourceRoot":"","sources":["../../../../../src/commands/validate/service/model/diverged.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `c4.declaration-diverged` — the check the \"every shared element is declared\n * once per document\" report asked for.\n *\n * A STANDALONE model has to re-declare every element it talks about: it is\n * parsed alone, so `kafka`, `uaa` and each peer service appear a second time,\n * with a second kind, a second title, a second tag list and a second binding.\n * Nothing compared the copies. Measured on the fleet that filed the report: 78\n * double declarations across 56 services, and the copies had already drifted —\n * `#platform` was on the map's elements and missing from the models'.\n *\n * WHAT IS COMPARED, and why exactly these four. `kind`, `title`, the tag SET and\n * the `metadata { service }` binding each change what loam concludes or what a\n * view shows: the kind decides which specification rule applies and which\n * exemptions (`ACTOR_KINDS`, `#external`) fire, the title is a resolver fallback\n * when nothing is bound, a tag is graded (`#external`, `#obl-…`), and the\n * binding decides which directory an edge is filed under. `description` is\n * deliberately NOT compared: it is prose, nothing joins on it, and reporting two\n * differently-worded sentences as a divergence would train a reader to ignore\n * the code.\n *\n * WHICH TWO DECLARATIONS ARE ONE ELEMENT is `counterpart` below, and it is the\n * half the first release got wrong: it joined on the literal id, so in a GROUPED\n * map — one that nests its services under `marketplace`, as loam's own example\n * does — a model's `paymentService` never met the map's\n * `marketplace.paymentService` and the check was silent on every fleet that\n * groups (verification 2026-09-04, W3). The three rungs are ordered by how much\n * they assume: the id, then the `metadata { service }` binding both sides wrote,\n * then a unique dotted TAIL of the map's id. Ambiguity is never resolved by\n * picking one — a key two map elements answer to joins nothing, because a\n * divergence reported against the wrong peer is worse than the silence. And the\n * BARE last segment is a tail only when the map element it names is a\n * FLEET-LEVEL box: see `joinIndex`, where the first release of the tail rung\n * joined one service's `db` to another service's.\n *\n * WHAT \"FLEET-LEVEL\" MEANS, spelled once here because three pages spelled it\n * differently and all three were wrong (verification 2026-09-04, second pass):\n * a map element NO ANCESTOR of which stands for a service — `serviceLevelElements`,\n * the one predicate every fleet check asks this question with. That is a\n * top-level element, and equally a child of a plain grouping element: a map\n * that draws `infra = group 'Infra' { cache = database … }` is drawing the\n * fleet's cache, at fleet level, and a standalone model that declares its own\n * top-level `cache` IS shadowing that box — the two documents give one name two\n * meanings, which is the thing this code exists to report. What the rung must\n * never reach is an element drawn INSIDE a service's own element: that is\n * somebody's interior, it belongs to that service, and a model's private `db`\n * being told to copy another service's `db` verbatim is the wrong-join class\n * the tail rung was restricted for in the first place.\n *\n * SILENT WHEN THE MAP DOES NOT PARSE, and when the model does not: a diff\n * against half a document is invention, and both states already have a finding\n * of their own.\n *\n * The repair has two forms on purpose. Copying the map's declaration verbatim\n * keeps the standalone shape, which is legal forever; migrating the model to\n * extend the map removes the copy altogether, which is the only fix that cannot\n * drift again. Except where the first repair is a no-op, and `copyIsNoOp`\n * decides that by SIMULATING it rather than by pattern-matching one shape of it:\n * a tag difference that survives the copy comes off the two documents'\n * `specification` blocks, so the message names the block instead.\n */\nimport type { Elem, LoadedDoc } from \"../../../../core/c4/likec4.js\";\nimport type { DocSpecification } from \"../../../../core/c4/parsed/specification.js\";\nimport type { ServiceModel } from \"../../../../core/c4/service-model/load.js\";\nimport type { PathableService } from \"../../../../core/kernel/ids/service.js\";\nimport { ARTIFACT_FILES } from \"../../../../core/repo/paths.js\";\nimport type { Finding } from \"../../../../core/vocabulary/report.js\";\nimport { serviceLevelElements } from \"../../fleet/census.js\";\n\n/**\n * How the OTHER declaration is named.\n *\n * The document, not the file: `Elem` carries no source path, and the map is a\n * PROJECT — `architecture/landscape.likec4` plus every other `.likec4` under\n * `architecture/` — so naming the landscape file was a guess that sent an author\n * to a document that does not declare the element at all.\n */\nconst MAP = \"the fleet map (architecture/)\";\n\nexport interface DivergenceCheck {\n service: PathableService;\n model: ServiceModel;\n /** The `architecture/` project — the map as every other fleet check reads it. Null when the repo has none. */\n architecture: LoadedDoc | null;\n /**\n * Every `services/<id>/` that exists — the census's positive evidence, which\n * is what tells a map's fleet-level box from a container drawn inside a\n * service. Only the bare-last-segment tail join reads it; see `joinIndex`.\n */\n known: ReadonlySet<string>;\n}\n\nexport function divergedFindings(check: DivergenceCheck): Finding[] {\n const { service, model, architecture, known } = check;\n // An extending model declares each of these elements exactly once by\n // construction — there is no copy to diverge — and the shapes' other grade\n // (`c4.element-unowned`) is what asks its question.\n if (model.shape !== \"standalone\") return [];\n if (model.doc.errors.length > 0) return [];\n if (architecture === null || architecture.errors.length > 0) return [];\n\n const index = joinIndex(architecture.elements, known);\n const findings: Finding[] = [];\n for (const mine of model.doc.elements) {\n const theirs = counterpart(index, mine);\n if (theirs === undefined) continue;\n const fields = differing(mine, theirs);\n if (fields.length === 0) continue;\n // Where \"copy the map's declaration verbatim\" is a no-op: the tag sets are\n // all that differ (so the two kinds are equal, and the block to open can be\n // named) and copying the map's literal tags across would leave them still\n // differing. `fields` is checked first because copying DOES fix a title or a\n // binding, so the verbatim remedy stays right whenever one of those differs.\n const specs = { mine: model.doc.specification, theirs: architecture.specification };\n const inherited =\n fields.length === 1 && fields[0] === \"tags\" && copyIsNoOp(mine, theirs, specs)\n ? declaringSide(mine.kind, specs)\n : null;\n findings.push({\n severity: \"warn\",\n code: \"c4.declaration-diverged\",\n subject: service,\n message:\n `${service}: ${ARTIFACT_FILES.model} declares '${mine.id}' as ${describe(mine)} and ` +\n `${MAP} declares ${theirs.id === mine.id ? \"it\" : `'${theirs.id}'`} as ${describe(theirs)} — ` +\n remedy(fields, mine.kind, inherited),\n });\n }\n return findings;\n}\n\n/**\n * What the message says after the two declarations: which fields differ, and\n * what to do about it. `inherited` is null when copying the map's declaration\n * fixes this, and otherwise the clause naming which document's `specification`\n * puts the tags on the kind — the side an author has to open.\n */\nfunction remedy(fields: string[], kind: string, inherited: string | null): string {\n // \"the kind differs\" / \"the title differs\" / \"the tags differ\": the verb\n // agrees with the last field named, and `tags` is the one plural noun in the\n // set. It shipped as \"the kind differ\", and a sentence a reader trips over is\n // one they stop reading.\n const plural = fields.length > 1 || fields[fields.length - 1] === \"tags\";\n const named = `the ${fields.join(\", \")} differ${plural ? \"\" : \"s\"}`;\n // The two kinds are equal whenever `tags` is the only differing field, so the\n // block to open can be named exactly.\n if (inherited !== null) {\n return (\n `${named}, and copying the map's declaration would not clear it: the sets come apart on ` +\n `\\`specification { element ${kind} { … } }\\`, ${inherited}. Either match the two specification ` +\n 'blocks on that kind, or migrate the model to extend the map (SCHEMA.md, \"Two shapes of a service ' +\n 'model\") so there is one specification and one declaration'\n );\n }\n return (\n `${named}, so two documents are two authorities on one element. Copy the map's declaration verbatim, or ` +\n 'migrate the model to extend the map (SCHEMA.md, \"Two shapes of a service model\") so it is declared once'\n );\n}\n\n/** The map's elements, indexed by each key `counterpart` may join on — ambiguous keys dropped. */\ninterface JoinIndex {\n byId: Map<string, Elem>;\n byBinding: Map<string, Elem>;\n bySuffix: Map<string, Elem>;\n}\n\nfunction joinIndex(elements: readonly Elem[], known: ReadonlySet<string>): JoinIndex {\n // Which map elements are the fleet's own boxes rather than somebody's\n // interior, by the ONE predicate every other fleet check asks it with\n // (`fleet/census.ts`): a binding or a title naming a real services/<id>/ makes\n // an element a service at any depth, and what sits inside one is that\n // service's container. Everything else is fleet-level — a top-level element,\n // or a box the map draws inside a plain grouping element — see the header.\n const fleetLevel = new Set(serviceLevelElements([...elements], known).map((e) => e.id));\n return {\n byId: unique(elements, (e) => [e.id]),\n byBinding: unique(elements, (e) => (e.service === undefined ? [] : [e.service as string])),\n // Every DOTTED tail of the id, so a model's `paymentService.api` reaches the\n // map's `marketplace.paymentService.api`: a tail carrying a dot names the\n // owner as well as the leaf, so the two documents are talking about one\n // element.\n //\n // The bare LAST segment names only the leaf, and it is emitted for\n // FLEET-LEVEL boxes alone. This shipped unconditional, and the comment\n // here claimed it was not: a standalone model's own `db`, `api` or `agent`\n // joined whatever `<something>.<that name>` the map happened to hold — one\n // service's private cache told to copy another service's store, verbatim.\n // loam's own meta/docs hit it (`agent` ↔ `loam.core.agent`, a container of\n // src/core/agent/), which is the case that made the rung wrong rather than\n // merely risky (verification 2026-09-04).\n //\n // A box the map draws inside a plain GROUP still emits its bare segment,\n // and deliberately: `infra.cache` is the fleet's cache, so a model's own\n // top-level `cache` is a second meaning for one name and worth the warning.\n // Grouping is a drawing convenience, not ownership — only a SERVICE owns an\n // interior — so the guard is \"no ancestor stands for a service\" and not\n // \"the id carries no dot\".\n bySuffix: unique(elements, (e) => {\n const segments = e.id.split(\".\");\n const tails = segments.map((_, i) => segments.slice(i).join(\".\"));\n return fleetLevel.has(e.id) ? tails : tails.slice(0, -1);\n }),\n };\n}\n\n/** An index holding only the keys exactly ONE element answers to — a shared key joins nothing. */\nfunction unique(elements: readonly Elem[], keys: (e: Elem) => string[]): Map<string, Elem> {\n const claims = new Map<string, Elem[]>();\n for (const element of elements) {\n for (const key of keys(element)) {\n const held = claims.get(key);\n if (held === undefined) claims.set(key, [element]);\n else held.push(element);\n }\n }\n const index = new Map<string, Elem>();\n for (const [key, held] of claims) if (held.length === 1) index.set(key, held[0]!);\n return index;\n}\n\n/** The map's declaration of the element this model declares, or undefined when nothing joins. */\nfunction counterpart(index: JoinIndex, mine: Elem): Elem | undefined {\n const byId = index.byId.get(mine.id);\n if (byId !== undefined) return byId;\n // The binding is the strongest evidence after the id: both documents wrote\n // `metadata { service '<id>' }`, so both mean the same directory.\n const bound = mine.service === undefined ? undefined : index.byBinding.get(mine.service as string);\n if (bound !== undefined) return bound;\n return index.bySuffix.get(mine.id);\n}\n\n/** Which of the four compared fields disagree, in a fixed order so two runs read alike. */\nfunction differing(mine: Elem, theirs: Elem): string[] {\n const fields: string[] = [];\n if (mine.kind !== theirs.kind) fields.push(\"kind\");\n if (mine.title !== theirs.title) fields.push(\"title\");\n // A SET, sorted: tag order is authoring order and means nothing, and a model\n // that lists the map's two tags the other way round is the same declaration.\n if (tagList(mine) !== tagList(theirs)) fields.push(\"tags\");\n if (mine.service !== theirs.service) fields.push(\"service binding\");\n return fields;\n}\n\nfunction tagList(e: Elem): string {\n return [...new Set(e.tags)].sort().join(\" \");\n}\n\n/** The two documents' `specification` blocks, as `copyIsNoOp` and `declaringSide` read them. */\ninterface SpecPair {\n mine: DocSpecification | undefined;\n theirs: DocSpecification | undefined;\n}\n\n/**\n * Would \"copy the map's declaration verbatim\" leave the tag sets still\n * differing? Answered by SIMULATING the copy, which is the only way to be right\n * about it in both directions.\n *\n * `Elem.tags` is what LikeC4 hands over, and since 1.59.0 that includes every\n * tag the element's kind declares (`core/c4/parsed/specification.ts` says why\n * loam reads the block at all). Copying the map's DECLARATION copies its literal\n * tags into the model and nothing else — the model keeps its own specification —\n * so the model's element would come back carrying `mine's kind defaults ∪ the\n * map's literal tags`, against the map's `its kind defaults ∪ the same literal\n * tags`. Those differ exactly when the two kind defaults differ outside the\n * copied literals, and then the remedy is a no-op.\n *\n * The first version compared the two LITERAL tag lists and switched arms only\n * when they were equal, which caught one direction of the same case and missed\n * its mirror: a model writing `#external` on an element whose map twin inherits\n * `#external #platform` from `element topic { … }` was told to copy the map's\n * bare line, and doing exactly that produced the OTHER arm of this same message\n * (verification 2026-09-04, second pass, examples/docs order-service).\n */\nfunction copyIsNoOp(mine: Elem, theirs: Elem, specs: SpecPair): boolean {\n const copied = literalTags(theirs, specs.theirs);\n const after = union(kindTags(mine.kind, specs.mine), copied);\n const before = union(kindTags(theirs.kind, specs.theirs), copied);\n return after !== before;\n}\n\n/**\n * Which document's `specification` puts the tags on the kind — the half of the\n * message that tells an author which file to open. \"Both, differently\" is a real\n * state (each block declares its own tag on the same kind) and gets its own\n * words rather than a guess at one side.\n */\nfunction declaringSide(kind: string, specs: SpecPair): string {\n const mine = kindTags(kind, specs.mine);\n const theirs = kindTags(kind, specs.theirs);\n if (mine.length > 0 && theirs.length > 0) return \"which the two documents declare different tags on\";\n if (theirs.length > 0) return `which ${MAP} declares tags on and ${ARTIFACT_FILES.model} does not`;\n if (mine.length > 0) return `which ${ARTIFACT_FILES.model} declares tags on and ${MAP} does not`;\n // No kind declares anything and the copy still fails: not reachable from the\n // predicate above (with equal kinds and no defaults the copy always clears\n // it), and a sentence that names no file beats one that names the wrong file.\n return \"which the two documents' blocks disagree on\";\n}\n\n/** The tags `spec` puts on every element of `kind`, sorted — the defaults an element cannot see. */\nfunction kindTags(kind: string, spec: DocSpecification | undefined): string[] {\n return [...new Set(spec?.elementKindTags[kind] ?? [])].sort();\n}\n\n/** A tag SET as one comparable string; see `tagList` for why order is dropped. */\nfunction union(a: readonly string[], b: readonly string[]): string {\n return [...new Set([...a, ...b])].sort().join(\" \");\n}\n\nfunction literalTags(e: Elem, spec: DocSpecification | undefined): string[] {\n const inherited = new Set(kindTags(e.kind, spec));\n return [...new Set(e.tags)].filter((tag) => !inherited.has(tag)).sort();\n}\n\n/** One element as the message names it — every compared field, in the order `differing` reports them. */\nfunction describe(e: Elem): string {\n const tags = tagList(e);\n return `${e.kind} '${e.title}' [${tags === \"\" ? \"no tags\" : tags}, bound to ${e.service ?? \"unbound\"}]`;\n}\n"]}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a service's `model.likec4` is GRADED, once `c4/service-model/` has
|
|
3
|
+
* decided how to read it.
|
|
4
|
+
*
|
|
5
|
+
* A package of its own beside `../service.ts` because that module is the ORDER
|
|
6
|
+
* — it reads what more than one axis joins through and pushes findings in the
|
|
7
|
+
* sequence the report is read in — and this is one axis's verdicts. Splitting it
|
|
8
|
+
* out is what let the model gain three arms without the order module gaining
|
|
9
|
+
* any: the four states below are the loader's four, and each one has exactly one
|
|
10
|
+
* right answer here.
|
|
11
|
+
*
|
|
12
|
+
* - the map is unreadable (an extending model only): NOTHING. The model could
|
|
13
|
+
* not be read, so neither `c4.invalid` nor `c4.valid` is a claim anybody may
|
|
14
|
+
* make about it, and blaming N services for one broken document is the
|
|
15
|
+
* report. `spine.landscape-invalid` already fires per service and gains a
|
|
16
|
+
* clause saying the model went unread with it (`../spine.ts`).
|
|
17
|
+
* - errors: `c4.invalid`, exactly as before. For an extending model the details
|
|
18
|
+
* name the FILE as well as the line, because a duplicate declaration is
|
|
19
|
+
* reported against the map as well as the model, and a bare `L8:` then points
|
|
20
|
+
* at somebody else's text.
|
|
21
|
+
* - clean, and nothing in the map resolves to this directory:
|
|
22
|
+
* `landscape.service-unmodelled` on the SERVICE target. An extending model
|
|
23
|
+
* says what is inside an element the map binds; with no such element there is
|
|
24
|
+
* nothing to be inside, and every count below would be zero for a file full
|
|
25
|
+
* of architecture.
|
|
26
|
+
* - clean: `c4.valid`, and one `c4.element-unowned` per element the model
|
|
27
|
+
* declared outside its own.
|
|
28
|
+
*/
|
|
29
|
+
import { readFile } from "node:fs/promises";
|
|
30
|
+
import { relative } from "node:path";
|
|
31
|
+
import { maskSource } from "../../../../core/c4/source-mask.js";
|
|
32
|
+
import { serviceResolver } from "../../../../core/c4/resolve/service.js";
|
|
33
|
+
import { ARTIFACT_FILES } from "../../../../core/repo/paths.js";
|
|
34
|
+
import { errorText } from "../../../../core/c4/likec4.js";
|
|
35
|
+
export async function modelFindings(grade) {
|
|
36
|
+
const { service, model } = grade;
|
|
37
|
+
// The map did not parse, so the model was never read. Silence here is the
|
|
38
|
+
// whole point: `spine.landscape-invalid` is the one finding, and it names the
|
|
39
|
+
// map rather than blaming a file nobody opened.
|
|
40
|
+
if (model.mapUnreadable)
|
|
41
|
+
return [];
|
|
42
|
+
if (model.doc.errors.length > 0) {
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
severity: "error",
|
|
46
|
+
code: "c4.invalid",
|
|
47
|
+
message: `${service}: C4 model has ${model.doc.errors.length} error(s)`,
|
|
48
|
+
// The hint FIRST, the errors after it: `capDetails` keeps the first ten
|
|
49
|
+
// lines and drops the rest, and a model written in both shapes at once
|
|
50
|
+
// stops resolving every kind and every reference in the file — dozens of
|
|
51
|
+
// cascading errors — so appending the one line that diagnoses it put it
|
|
52
|
+
// past the cap in exactly the case it exists for (verification
|
|
53
|
+
// 2026-09-04). It is the diagnosis; the errors are the evidence.
|
|
54
|
+
details: [...(await extendHint(grade)), ...model.doc.errors.map((err) => detail(err, grade))],
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
if (model.shape === "extending" && !mapBindsAnElement(grade)) {
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
severity: "error",
|
|
62
|
+
code: "landscape.service-unmodelled",
|
|
63
|
+
subject: service,
|
|
64
|
+
message: `${service}: ${ARTIFACT_FILES.model} extends the fleet map, and no element in ` +
|
|
65
|
+
`architecture/landscape.likec4 resolves to ${grade.treePath}/ — nothing in the model can be graded ` +
|
|
66
|
+
`until the map binds one (metadata { service '${service}' })`,
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
const findings = [
|
|
71
|
+
{
|
|
72
|
+
severity: "ok",
|
|
73
|
+
code: "c4.valid",
|
|
74
|
+
message: `${service}: C4 model valid (` +
|
|
75
|
+
(model.shape === "extending" ? "extends the fleet map — " : "") +
|
|
76
|
+
`${model.doc.elements.length} elements · ${model.doc.relationships.length} relationships)` +
|
|
77
|
+
// Outside the parentheses because the counts inside are facts about
|
|
78
|
+
// model.likec4 alone and the flows are read from the whole project.
|
|
79
|
+
(grade.gradedFlows === 0 ? "" : ` · ${grade.gradedFlows} tagged flow(s) graded`),
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
for (const element of model.unowned) {
|
|
83
|
+
findings.push({
|
|
84
|
+
severity: "warn",
|
|
85
|
+
code: "c4.element-unowned",
|
|
86
|
+
subject: service,
|
|
87
|
+
message: `${service}: ${ARTIFACT_FILES.model} declares '${element.id}' (${element.kind}) outside this service's ` +
|
|
88
|
+
"own element — an extending model adds elements under the element that resolves to " +
|
|
89
|
+
`${service} only. Three remedies, and the FIRST is the one a store or a component usually ` +
|
|
90
|
+
// The nested arm led the other two from 2026-09-04: a private store's own
|
|
91
|
+
// remedy was missing here, so following what this printed moved a store
|
|
92
|
+
// to fleet level and earned `landscape.service-undocumented` (#01/W8).
|
|
93
|
+
`wants: a store or component this service owns goes INSIDE the \`extend <fqn> { }\` block, ` +
|
|
94
|
+
"where its id becomes `<fqn>.<name>`; a system this service reaches belongs in " +
|
|
95
|
+
"architecture/landscape.likec4, declared once; and another service's internals belong in " +
|
|
96
|
+
"that service's model",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return findings;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Does anything in the per-service project resolve to this service?
|
|
103
|
+
*
|
|
104
|
+
* The same question `sliceForService` asks to build `own`, asked again rather
|
|
105
|
+
* than carried on the `ServiceModel`: the slice publishes what a check READS
|
|
106
|
+
* (the elements, the edges, the strays), and "was the set empty" is a fact
|
|
107
|
+
* about the map's bindings rather than a fourth list. Resolved over the
|
|
108
|
+
* PROJECT's elements, because that is the index the slice used and a
|
|
109
|
+
* nearest-ancestor binding must win here exactly as it does there.
|
|
110
|
+
*
|
|
111
|
+
* A standalone model never reaches this: it declares its own elements and
|
|
112
|
+
* answers for them whether or not the map has heard of the directory — the
|
|
113
|
+
* fleet target's own `landscape.service-unmodelled` is where that is said.
|
|
114
|
+
*/
|
|
115
|
+
function mapBindsAnElement(grade) {
|
|
116
|
+
const project = grade.model.project;
|
|
117
|
+
if (project === null)
|
|
118
|
+
return true;
|
|
119
|
+
const resolve = serviceResolver(project.elements, grade.known);
|
|
120
|
+
return project.elements.some((e) => resolve(e.id) === grade.service);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* One parse error, spelled for the shape it came from.
|
|
124
|
+
*
|
|
125
|
+
* A standalone model is one file, so its errors keep the bare `L8: …` form they
|
|
126
|
+
* have always had — every fixture and every reader of that message is pinned to
|
|
127
|
+
* it. An extending model is a PROJECT, and LikeC4 reports a duplicate
|
|
128
|
+
* declaration against BOTH documents: without the file name the reader is sent
|
|
129
|
+
* to a line number in a file they were never told about, which is the exact
|
|
130
|
+
* failure `landscape.invalid` fixed one altitude up.
|
|
131
|
+
*/
|
|
132
|
+
function detail(err, grade) {
|
|
133
|
+
if (grade.model.shape === "standalone" || err.sourceFsPath === undefined)
|
|
134
|
+
return errorText(err);
|
|
135
|
+
return `${relative(grade.docsDir, err.sourceFsPath).split(/[\\/]/).join("/")} ${errorText(err)}`;
|
|
136
|
+
}
|
|
137
|
+
/** An `extend` in a masked model — the bytes, not a parse, because the parse is what failed. */
|
|
138
|
+
const EXTENDS = /\bextend\s+[A-Za-z_][\w.-]*\s*\{/;
|
|
139
|
+
/**
|
|
140
|
+
* The one line that explains a model written in BOTH shapes at once.
|
|
141
|
+
*
|
|
142
|
+
* A file declaring an element kind is standalone (SCHEMA.md, "Two shapes of a
|
|
143
|
+
* service model"), so it is parsed alone — and every `extend <fqn>` in it then
|
|
144
|
+
* resolves nothing, because the fqn belongs to a map this load never opened.
|
|
145
|
+
* LikeC4 says `Could not resolve reference to Element named '<fqn>'`, which
|
|
146
|
+
* reads as a typo; nothing said which of the two lines the author has to drop
|
|
147
|
+
* (verification 2026-09-04, D10). Emitted only for the standalone shape, where
|
|
148
|
+
* the diagnosis is certain; an unreadable file adds nothing, since the loader
|
|
149
|
+
* already answered `standalone` for the same reason and `c4.invalid` names it.
|
|
150
|
+
*/
|
|
151
|
+
async function extendHint(grade) {
|
|
152
|
+
if (grade.model.shape !== "standalone")
|
|
153
|
+
return [];
|
|
154
|
+
const text = await readFile(grade.modelPath, "utf8").catch(() => null);
|
|
155
|
+
if (text === null || !EXTENDS.test(maskSource(text).code))
|
|
156
|
+
return [];
|
|
157
|
+
return [
|
|
158
|
+
`${ARTIFACT_FILES.model} declares an element kind in \`specification { }\`, so it is read as the ` +
|
|
159
|
+
"standalone shape — parsed alone, where `extend` resolves nothing. Drop the kind declaration to " +
|
|
160
|
+
"extend the fleet map, or drop the `extend` and declare the elements here",
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=grade.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"grade.js","sourceRoot":"","sources":["../../../../../src/commands/validate/service/model/grade.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAEhE,OAAO,EAAE,eAAe,EAAE,MAAM,wCAAwC,CAAC;AAIzE,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAEhE,OAAO,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAyB1D,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,KAAiB;IACnD,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,KAAK,CAAC;IACjC,0EAA0E;IAC1E,8EAA8E;IAC9E,gDAAgD;IAChD,IAAI,KAAK,CAAC,aAAa;QAAE,OAAO,EAAE,CAAC;IAEnC,IAAI,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAChC,OAAO;YACL;gBACE,QAAQ,EAAE,OAAO;gBACjB,IAAI,EAAE,YAAY;gBAClB,OAAO,EAAE,GAAG,OAAO,kBAAkB,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,WAAW;gBACvE,wEAAwE;gBACxE,uEAAuE;gBACvE,yEAAyE;gBACzE,wEAAwE;gBACxE,+DAA+D;gBAC/D,iEAAiE;gBACjE,OAAO,EAAE,CAAC,GAAG,CAAC,MAAM,UAAU,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;aAC9F;SACF,CAAC;IACJ,CAAC;IAED,IAAI,KAAK,CAAC,KAAK,KAAK,WAAW,IAAI,CAAC,iBAAiB,CAAC,KAAK,CAAC,EAAE,CAAC;QAC7D,OAAO;YACL;gBACE,QAAQ,EAAE,OAAO;gBACjB,IAAI,EAAE,8BAA8B;gBACpC,OAAO,EAAE,OAAO;gBAChB,OAAO,EACL,GAAG,OAAO,KAAK,cAAc,CAAC,KAAK,4CAA4C;oBAC/E,6CAA6C,KAAK,CAAC,QAAQ,yCAAyC;oBACpG,gDAAgD,OAAO,MAAM;aAChE;SACF,CAAC;IACJ,CAAC;IAED,MAAM,QAAQ,GAAc;QAC1B;YACE,QAAQ,EAAE,IAAI;YACd,IAAI,EAAE,UAAU;YAChB,OAAO,EACL,GAAG,OAAO,oBAAoB;gBAC9B,CAAC,KAAK,CAAC,KAAK,KAAK,WAAW,CAAC,CAAC,CAAC,0BAA0B,CAAC,CAAC,CAAC,EAAE,CAAC;gBAC/D,GAAG,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,MAAM,eAAe,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,MAAM,iBAAiB;gBAC1F,oEAAoE;gBACpE,oEAAoE;gBACpE,CAAC,KAAK,CAAC,WAAW,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,WAAW,wBAAwB,CAAC;SACnF;KACF,CAAC;IACF,KAAK,MAAM,OAAO,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;QACpC,QAAQ,CAAC,IAAI,CAAC;YACZ,QAAQ,EAAE,MAAM;YAChB,IAAI,EAAE,oBAAoB;YAC1B,OAAO,EAAE,OAAO;YAChB,OAAO,EACL,GAAG,OAAO,KAAK,cAAc,CAAC,KAAK,cAAc,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,2BAA2B;gBACxG,oFAAoF;gBACpF,GAAG,OAAO,iFAAiF;gBAC3F,0EAA0E;gBAC1E,wEAAwE;gBACxE,uEAAuE;gBACvE,4FAA4F;gBAC5F,gFAAgF;gBAChF,0FAA0F;gBAC1F,sBAAsB;SACzB,CAAC,CAAC;IACL,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,SAAS,iBAAiB,CAAC,KAAiB;IAC1C,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC;IACpC,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAClC,MAAM,OAAO,GAAG,eAAe,CAAC,OAAO,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;IAC/D,OAAO,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,KAAM,KAAK,CAAC,OAAkB,CAAC,CAAC;AACnF,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,MAAM,CAAC,GAAgB,EAAE,KAAiB;IACjD,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,KAAK,YAAY,IAAI,GAAG,CAAC,YAAY,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC,GAAG,CAAC,CAAC;IAChG,OAAO,GAAG,QAAQ,CAAC,KAAK,CAAC,OAAO,EAAE,GAAG,CAAC,YAAY,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC;AACnG,CAAC;AAED,gGAAgG;AAChG,MAAM,OAAO,GAAG,kCAAkC,CAAC;AAEnD;;;;;;;;;;;GAWG;AACH,KAAK,UAAU,UAAU,CAAC,KAAiB;IACzC,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,KAAK,YAAY;QAAE,OAAO,EAAE,CAAC;IAClD,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,KAAK,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IACvE,IAAI,IAAI,KAAK,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;QAAE,OAAO,EAAE,CAAC;IACrE,OAAO;QACL,GAAG,cAAc,CAAC,KAAK,2EAA2E;YAChG,iGAAiG;YACjG,0EAA0E;KAC7E,CAAC;AACJ,CAAC","sourcesContent":["/**\n * How a service's `model.likec4` is GRADED, once `c4/service-model/` has\n * decided how to read it.\n *\n * A package of its own beside `../service.ts` because that module is the ORDER\n * — it reads what more than one axis joins through and pushes findings in the\n * sequence the report is read in — and this is one axis's verdicts. Splitting it\n * out is what let the model gain three arms without the order module gaining\n * any: the four states below are the loader's four, and each one has exactly one\n * right answer here.\n *\n * - the map is unreadable (an extending model only): NOTHING. The model could\n * not be read, so neither `c4.invalid` nor `c4.valid` is a claim anybody may\n * make about it, and blaming N services for one broken document is the\n * report. `spine.landscape-invalid` already fires per service and gains a\n * clause saying the model went unread with it (`../spine.ts`).\n * - errors: `c4.invalid`, exactly as before. For an extending model the details\n * name the FILE as well as the line, because a duplicate declaration is\n * reported against the map as well as the model, and a bare `L8:` then points\n * at somebody else's text.\n * - clean, and nothing in the map resolves to this directory:\n * `landscape.service-unmodelled` on the SERVICE target. An extending model\n * says what is inside an element the map binds; with no such element there is\n * nothing to be inside, and every count below would be zero for a file full\n * of architecture.\n * - clean: `c4.valid`, and one `c4.element-unowned` per element the model\n * declared outside its own.\n */\nimport { readFile } from \"node:fs/promises\";\nimport { relative } from \"node:path\";\nimport { maskSource } from \"../../../../core/c4/source-mask.js\";\nimport type { ServiceModel } from \"../../../../core/c4/service-model/load.js\";\nimport { serviceResolver } from \"../../../../core/c4/resolve/service.js\";\nimport type { LikeC4Error } from \"../../../../core/c4/likec4.js\";\nimport type { DocsDir } from \"../../../../core/kernel/ids/dirs.js\";\nimport type { PathableService } from \"../../../../core/kernel/ids/service.js\";\nimport { ARTIFACT_FILES } from \"../../../../core/repo/paths.js\";\nimport type { Finding } from \"../../../../core/vocabulary/report.js\";\nimport { errorText } from \"../../../../core/c4/likec4.js\";\n\nexport interface ModelGrade {\n service: PathableService;\n /** Repo-relative, `/`-separated — the directory the map has to bind, named in the unmodelled arm. */\n treePath: string;\n docsDir: DocsDir;\n model: ServiceModel;\n /** Absolute path of `model.likec4` — read only to explain a parse error, see `extendHint`. */\n modelPath: string;\n /** Every service directory that exists — the resolver's positive evidence, exactly as the slice used it. */\n known: ReadonlySet<string>;\n /**\n * How many of this service's own `dynamic view`s carried a reserved tag and\n * were graded (`core/usecases/service/flows.ts`), counted by the caller\n * because the scan is the use-case axis's and this is the model's.\n *\n * It rides on `c4.valid` and nowhere else. A healthy intra-service flow used\n * to be invisible on every loam surface — the axis reports only what is wrong\n * with one — so a team that wrote the slot correctly could not tell it from a\n * team whose views were never read (verification 2026-09-04, D10).\n */\n gradedFlows: number;\n}\n\nexport async function modelFindings(grade: ModelGrade): Promise<Finding[]> {\n const { service, model } = grade;\n // The map did not parse, so the model was never read. Silence here is the\n // whole point: `spine.landscape-invalid` is the one finding, and it names the\n // map rather than blaming a file nobody opened.\n if (model.mapUnreadable) return [];\n\n if (model.doc.errors.length > 0) {\n return [\n {\n severity: \"error\",\n code: \"c4.invalid\",\n message: `${service}: C4 model has ${model.doc.errors.length} error(s)`,\n // The hint FIRST, the errors after it: `capDetails` keeps the first ten\n // lines and drops the rest, and a model written in both shapes at once\n // stops resolving every kind and every reference in the file — dozens of\n // cascading errors — so appending the one line that diagnoses it put it\n // past the cap in exactly the case it exists for (verification\n // 2026-09-04). It is the diagnosis; the errors are the evidence.\n details: [...(await extendHint(grade)), ...model.doc.errors.map((err) => detail(err, grade))],\n },\n ];\n }\n\n if (model.shape === \"extending\" && !mapBindsAnElement(grade)) {\n return [\n {\n severity: \"error\",\n code: \"landscape.service-unmodelled\",\n subject: service,\n message:\n `${service}: ${ARTIFACT_FILES.model} extends the fleet map, and no element in ` +\n `architecture/landscape.likec4 resolves to ${grade.treePath}/ — nothing in the model can be graded ` +\n `until the map binds one (metadata { service '${service}' })`,\n },\n ];\n }\n\n const findings: Finding[] = [\n {\n severity: \"ok\",\n code: \"c4.valid\",\n message:\n `${service}: C4 model valid (` +\n (model.shape === \"extending\" ? \"extends the fleet map — \" : \"\") +\n `${model.doc.elements.length} elements · ${model.doc.relationships.length} relationships)` +\n // Outside the parentheses because the counts inside are facts about\n // model.likec4 alone and the flows are read from the whole project.\n (grade.gradedFlows === 0 ? \"\" : ` · ${grade.gradedFlows} tagged flow(s) graded`),\n },\n ];\n for (const element of model.unowned) {\n findings.push({\n severity: \"warn\",\n code: \"c4.element-unowned\",\n subject: service,\n message:\n `${service}: ${ARTIFACT_FILES.model} declares '${element.id}' (${element.kind}) outside this service's ` +\n \"own element — an extending model adds elements under the element that resolves to \" +\n `${service} only. Three remedies, and the FIRST is the one a store or a component usually ` +\n // The nested arm led the other two from 2026-09-04: a private store's own\n // remedy was missing here, so following what this printed moved a store\n // to fleet level and earned `landscape.service-undocumented` (#01/W8).\n `wants: a store or component this service owns goes INSIDE the \\`extend <fqn> { }\\` block, ` +\n \"where its id becomes `<fqn>.<name>`; a system this service reaches belongs in \" +\n \"architecture/landscape.likec4, declared once; and another service's internals belong in \" +\n \"that service's model\",\n });\n }\n return findings;\n}\n\n/**\n * Does anything in the per-service project resolve to this service?\n *\n * The same question `sliceForService` asks to build `own`, asked again rather\n * than carried on the `ServiceModel`: the slice publishes what a check READS\n * (the elements, the edges, the strays), and \"was the set empty\" is a fact\n * about the map's bindings rather than a fourth list. Resolved over the\n * PROJECT's elements, because that is the index the slice used and a\n * nearest-ancestor binding must win here exactly as it does there.\n *\n * A standalone model never reaches this: it declares its own elements and\n * answers for them whether or not the map has heard of the directory — the\n * fleet target's own `landscape.service-unmodelled` is where that is said.\n */\nfunction mapBindsAnElement(grade: ModelGrade): boolean {\n const project = grade.model.project;\n if (project === null) return true;\n const resolve = serviceResolver(project.elements, grade.known);\n return project.elements.some((e) => resolve(e.id) === (grade.service as string));\n}\n\n/**\n * One parse error, spelled for the shape it came from.\n *\n * A standalone model is one file, so its errors keep the bare `L8: …` form they\n * have always had — every fixture and every reader of that message is pinned to\n * it. An extending model is a PROJECT, and LikeC4 reports a duplicate\n * declaration against BOTH documents: without the file name the reader is sent\n * to a line number in a file they were never told about, which is the exact\n * failure `landscape.invalid` fixed one altitude up.\n */\nfunction detail(err: LikeC4Error, grade: ModelGrade): string {\n if (grade.model.shape === \"standalone\" || err.sourceFsPath === undefined) return errorText(err);\n return `${relative(grade.docsDir, err.sourceFsPath).split(/[\\\\/]/).join(\"/\")} ${errorText(err)}`;\n}\n\n/** An `extend` in a masked model — the bytes, not a parse, because the parse is what failed. */\nconst EXTENDS = /\\bextend\\s+[A-Za-z_][\\w.-]*\\s*\\{/;\n\n/**\n * The one line that explains a model written in BOTH shapes at once.\n *\n * A file declaring an element kind is standalone (SCHEMA.md, \"Two shapes of a\n * service model\"), so it is parsed alone — and every `extend <fqn>` in it then\n * resolves nothing, because the fqn belongs to a map this load never opened.\n * LikeC4 says `Could not resolve reference to Element named '<fqn>'`, which\n * reads as a typo; nothing said which of the two lines the author has to drop\n * (verification 2026-09-04, D10). Emitted only for the standalone shape, where\n * the diagnosis is certain; an unreadable file adds nothing, since the loader\n * already answered `standalone` for the same reason and `c4.invalid` names it.\n */\nasync function extendHint(grade: ModelGrade): Promise<string[]> {\n if (grade.model.shape !== \"standalone\") return [];\n const text = await readFile(grade.modelPath, \"utf8\").catch(() => null);\n if (text === null || !EXTENDS.test(maskSource(text).code)) return [];\n return [\n `${ARTIFACT_FILES.model} declares an element kind in \\`specification { }\\`, so it is read as the ` +\n \"standalone shape — parsed alone, where `extend` resolves nothing. Drop the kind declaration to \" +\n \"extend the fleet map, or drop the `extend` and declare the elements here\",\n ];\n}\n"]}
|