@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
|
@@ -49,26 +49,79 @@
|
|
|
49
49
|
*/
|
|
50
50
|
import {} from "../../../c4/likec4.js";
|
|
51
51
|
import { renderContext, viewBody } from "./body.js";
|
|
52
|
+
import { GENERATED_MARK } from "./service/header.js";
|
|
53
|
+
import { escapeViewIdSegment } from "./service/view-id.js";
|
|
54
|
+
import { renderServiceViews, SERVICE_STYLE_GROUP, } from "./service/views.js";
|
|
52
55
|
import { likec4String, viewTitle } from "./text.js";
|
|
56
|
+
/**
|
|
57
|
+
* The global style group a generated view references when the map declares
|
|
58
|
+
* one: `global { styleGroup subsystems { … } }` (or the single-rule form
|
|
59
|
+
* `global { style subsystems … }` — LikeC4 files both under one id table).
|
|
60
|
+
* Presence is the opt-in and it is read off a DECLARATION, never a view: a
|
|
61
|
+
* generated view cannot carry an inline `style` rule of its own (the file is
|
|
62
|
+
* regenerated wholesale), and the one mechanism LikeC4 offers a view for
|
|
63
|
+
* borrowing a repository's palette is a reference to a global style by id.
|
|
64
|
+
* Referencing one that is not declared is a parse error that blanks the whole
|
|
65
|
+
* `architecture/` project in the renderer, which is why the line is written
|
|
66
|
+
* only when the id is declared and why its absence leaves the file exactly
|
|
67
|
+
* what it was before this constant existed.
|
|
68
|
+
*
|
|
69
|
+
* One conventional name rather than "the single declared group": a second
|
|
70
|
+
* group somebody declares for a hand-drawn view would otherwise silently
|
|
71
|
+
* switch the generated views' styling OFF and restale the file on a change
|
|
72
|
+
* that touched no subsystem — behaviour flipping on an unrelated declaration
|
|
73
|
+
* is the shape `body.ts` calls "a picture that looks the same whether or not
|
|
74
|
+
* the two agree". The name mirrors the file it styles.
|
|
75
|
+
*/
|
|
76
|
+
export const SUBSYSTEM_STYLE_GROUP = "subsystems";
|
|
53
77
|
/**
|
|
54
78
|
* The header every generated file opens with — stable bytes, part of the
|
|
55
79
|
* byte-compare contract, naming the regenerator so a reader who found the
|
|
56
80
|
* file first knows what owns it.
|
|
81
|
+
*
|
|
82
|
+
* It OPENS with the shared `GENERATED_MARK` rather than spelling it, because
|
|
83
|
+
* the same bytes are what `loam subsystem sync` reads back off disk to decide
|
|
84
|
+
* whether the file standing at this reserved path is loam's to rewrite at all
|
|
85
|
+
* (`./service/header.ts` carries the whole reason). A second literal here is
|
|
86
|
+
* how a file loam wrote stops being recognised as loam's.
|
|
57
87
|
*/
|
|
58
|
-
const HEADER =
|
|
88
|
+
const HEADER = `${GENERATED_MARK} — the subsystem tree under services/, one view per subsystem.\n` +
|
|
59
89
|
"// Do not edit: `loam subsystem sync` regenerates this file from the directories,\n" +
|
|
60
90
|
"// and `loam validate --all` reports `subsystem.views-stale` when it drifts.\n" +
|
|
61
91
|
"// A scoping aid, not the fleet's architecture: author your own diagrams elsewhere.\n";
|
|
92
|
+
/**
|
|
93
|
+
* The one file-level comment the render may write: the map declares global
|
|
94
|
+
* style groups, and none is the conventional one. Written between `HEADER`
|
|
95
|
+
* and `views {` — outside every view body, graded by nothing — so a team
|
|
96
|
+
* that already declared a palette under another name reads the fix in the
|
|
97
|
+
* diff of the sync that regenerated the file, instead of wondering why the
|
|
98
|
+
* generated views ignore it. Silent when the map declares no group at all: a
|
|
99
|
+
* fleet with no palette is owed no sentence about one. Ids go unescaped
|
|
100
|
+
* because LikeC4's grammar admits only identifier bytes in a style id.
|
|
101
|
+
*
|
|
102
|
+
* The group is a PARAMETER because there are two generated files now and each
|
|
103
|
+
* reserves its own name (`subsystems`, `services`); a map declaring one and not
|
|
104
|
+
* the other must get the sentence in exactly the file that went unstyled.
|
|
105
|
+
*/
|
|
106
|
+
function stylesDisclosure(declared, group) {
|
|
107
|
+
if (declared.length === 0 || declared.includes(group))
|
|
108
|
+
return null;
|
|
109
|
+
return (`// styles: the project declares global style group(s) ${[...declared].sort().join(", ")}, ` +
|
|
110
|
+
`and none named \`${group}\` — these views carry the renderer's defaults until one is.`);
|
|
111
|
+
}
|
|
62
112
|
/**
|
|
63
113
|
* Render the views file for a walked tree, or null when the tree has no
|
|
64
114
|
* subsystems — the file must then be ABSENT, so a flat fleet never carries a
|
|
65
115
|
* generated artifact that says nothing.
|
|
66
116
|
*/
|
|
67
|
-
export function renderSubsystemViews(tree,
|
|
117
|
+
export function renderSubsystemViews(tree, map) {
|
|
68
118
|
if (tree.subsystems.length === 0)
|
|
69
119
|
return null;
|
|
70
|
-
const ctx = renderContext(tree, elements);
|
|
71
|
-
const
|
|
120
|
+
const ctx = renderContext(tree, map.elements);
|
|
121
|
+
const declared = map.globalStyles ?? [];
|
|
122
|
+
const styled = declared.includes(SUBSYSTEM_STYLE_GROUP);
|
|
123
|
+
const disclosure = stylesDisclosure(declared, SUBSYSTEM_STYLE_GROUP);
|
|
124
|
+
const lines = [HEADER, ...(disclosure === null ? [] : [disclosure]), "views {"];
|
|
72
125
|
const subsystems = [...tree.subsystems].sort((a, b) => (pathKey(a) < pathKey(b) ? -1 : 1));
|
|
73
126
|
for (const [i, sub] of subsystems.entries()) {
|
|
74
127
|
if (i > 0)
|
|
@@ -77,16 +130,34 @@ export function renderSubsystemViews(tree, elements) {
|
|
|
77
130
|
const body = viewBody(ctx, sub);
|
|
78
131
|
if (body.disclosure !== null)
|
|
79
132
|
lines.push(` // ${body.disclosure}`);
|
|
80
|
-
|
|
133
|
+
// `of <ancestor>` when — and only when — the map holds an element that owns
|
|
134
|
+
// exactly this subsystem's members (`../render/body.ts` argues the
|
|
135
|
+
// "exactly"). It is the same one keyword the per-service views spend, and it
|
|
136
|
+
// buys the same thing: LikeC4 computes `navigateTo` into this view from
|
|
137
|
+
// every other view that draws that element, so the fleet map's group boxes
|
|
138
|
+
// become click-through and a neighbouring group box inside one subsystem
|
|
139
|
+
// view opens THAT subsystem's view. Measured at 58 services in 11 groups,
|
|
140
|
+
// 12 of 14 nodes navigable. With no owning ancestor there is no element to
|
|
141
|
+
// be `of`, so there is no navigation — honest, and one more reason to nest
|
|
142
|
+
// the map that loam already discloses one line above.
|
|
143
|
+
lines.push(` view ${subsystemViewId(sub)}${body.of === null ? "" : ` of ${body.of}`} {`);
|
|
81
144
|
// Metadata FIRST, and that ordering is a hard constraint rather than a
|
|
82
145
|
// taste: measured, an `include` OR a `group { … }` line followed by
|
|
83
146
|
// `title` is a parse error ("Expecting token of type '}' but found
|
|
84
147
|
// `title`"), and one parse error in this file blanks the whole
|
|
85
148
|
// `architecture/` project in the renderer.
|
|
86
|
-
lines.push(` title '${viewTitle(titleChain(tree, sub))}'`);
|
|
149
|
+
lines.push(` title '${viewTitle(titleChain(tree, sub.path))}'`);
|
|
87
150
|
const description = likec4String(sub.meta.description ?? "");
|
|
88
151
|
if (description !== "")
|
|
89
152
|
lines.push(` description '${description}'`);
|
|
153
|
+
// After the label, before anything that includes: measured at the pin
|
|
154
|
+
// (test/likec4-view-shape.test.ts), a `global style <id>` line here
|
|
155
|
+
// parses with zero errors, while the same line before `title` is the
|
|
156
|
+
// parse error the comment above records. The palette is the map's own —
|
|
157
|
+
// the group is declared in the same project this file renders into — so
|
|
158
|
+
// the reference resolves exactly when the presence test says it does.
|
|
159
|
+
if (styled)
|
|
160
|
+
lines.push(` global style ${SUBSYSTEM_STYLE_GROUP}`);
|
|
90
161
|
lines.push(...body.lines);
|
|
91
162
|
lines.push(" }");
|
|
92
163
|
}
|
|
@@ -94,8 +165,62 @@ export function renderSubsystemViews(tree, elements) {
|
|
|
94
165
|
return lines.join("\n") + "\n";
|
|
95
166
|
}
|
|
96
167
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
168
|
+
* The GENERATED per-service overviews — `architecture/services.likec4`, one
|
|
169
|
+
* view per service the map resolves. Null when it resolves none, so the file is
|
|
170
|
+
* then absent exactly as `subsystems.likec4` is for a tree with no subsystems.
|
|
171
|
+
*
|
|
172
|
+
* This function is the composer and nothing else: `./service/` owns the view
|
|
173
|
+
* shape and its measurements, and everything about the TREE is resolved here,
|
|
174
|
+
* because that package may not import `./body.ts` or `./text.ts` (the edge runs
|
|
175
|
+
* the other way — `./service/views.ts` says why) and duplicating either would
|
|
176
|
+
* put a second element→service join and a second string escaper in the tree.
|
|
177
|
+
*
|
|
178
|
+
* Emission is gated on the SAME join the subsystem views use: a service the map
|
|
179
|
+
* does not draw is omitted rather than invented, because that omission is
|
|
180
|
+
* already `landscape.service-unmodelled` and rendering only what the committed
|
|
181
|
+
* map resolves keeps the output a function of committed bytes alone.
|
|
182
|
+
*/
|
|
183
|
+
export function renderServiceOverviews(tree, map) {
|
|
184
|
+
const ctx = renderContext(tree, map.elements);
|
|
185
|
+
const declared = map.globalStyles ?? [];
|
|
186
|
+
const subjects = [];
|
|
187
|
+
for (const service of tree.services) {
|
|
188
|
+
const fqn = ctx.elementOf.get(service.id);
|
|
189
|
+
if (fqn === undefined)
|
|
190
|
+
continue;
|
|
191
|
+
subjects.push({ id: service.id, fqn, title: serviceViewTitle(tree, service.subsystem, service.id) });
|
|
192
|
+
}
|
|
193
|
+
return renderServiceViews({
|
|
194
|
+
subjects,
|
|
195
|
+
relationships: map.relationships,
|
|
196
|
+
styled: declared.includes(SERVICE_STYLE_GROUP),
|
|
197
|
+
disclosure: stylesDisclosure(declared, SERVICE_STYLE_GROUP),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* A per-service view's title: its subsystem path and then its own id, joined
|
|
202
|
+
* with ` / ` — `Platform / order-flow / checkout-web`, or bare `checkout-web`
|
|
203
|
+
* for a service filed directly under `services/`.
|
|
204
|
+
*
|
|
205
|
+
* LikeC4 reads that as a folder path (`./text.ts` measured it), so a service's
|
|
206
|
+
* overview lands in the SAME browser folder as its subsystem's view rather than
|
|
207
|
+
* in a flat list of fifty-eight titles — which is the whole readability answer
|
|
208
|
+
* at fleet scale, and it costs the same helpers the subsystem views already use.
|
|
209
|
+
*
|
|
210
|
+
* The leaf is the service's `services/<id>/` DIRECTORY id and deliberately not
|
|
211
|
+
* the map element's title: it is the name every loam command addresses the
|
|
212
|
+
* service by, it cannot drift when somebody renames a box on the map, and a
|
|
213
|
+
* directory name cannot contain `/`, so it needs no folder-separator
|
|
214
|
+
* substitution of its own. It still goes through `likec4String` — an id is a
|
|
215
|
+
* directory name, and a directory name is somebody else's word.
|
|
216
|
+
*/
|
|
217
|
+
function serviceViewTitle(tree, subsystem, id) {
|
|
218
|
+
return [viewTitle(titleChain(tree, subsystem)), likec4String(id)].filter((part) => part !== "").join(" / ");
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The MARKED subsystems along a path, outermost first — the chain `viewTitle`
|
|
222
|
+
* composes a folder path out of, for a subsystem's own view and for the view of
|
|
223
|
+
* a service filed beneath it alike.
|
|
99
224
|
*
|
|
100
225
|
* Marked, because an intermediate directory holding no `subsystem.yaml` is
|
|
101
226
|
* `subsystem.unmarked`, an error the walk already names: it is not a group,
|
|
@@ -103,9 +228,9 @@ export function renderSubsystemViews(tree, elements) {
|
|
|
103
228
|
* rather than off the path segments, so what the title says and what the
|
|
104
229
|
* finding says about the same directory cannot disagree.
|
|
105
230
|
*/
|
|
106
|
-
function titleChain(tree,
|
|
231
|
+
function titleChain(tree, path) {
|
|
107
232
|
return tree.subsystems
|
|
108
|
-
.filter((s) => s.path.length <=
|
|
233
|
+
.filter((s) => s.path.length <= path.length && s.path.every((name, i) => path[i] === name))
|
|
109
234
|
.sort((a, b) => a.path.length - b.path.length);
|
|
110
235
|
}
|
|
111
236
|
/** The sort key and the comment spelling: the subsystem's path from services/, joined. */
|
|
@@ -116,31 +241,23 @@ function pathKey(sub) {
|
|
|
116
241
|
* A LikeC4 identifier for the view, derived from the subsystem's path so
|
|
117
242
|
* nesting reads in the name — and INJECTIVE over every path the name grammar
|
|
118
243
|
* admits, because the renderer refuses the whole `architecture/` project over
|
|
119
|
-
* a duplicate view id.
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* whose longer hex could in principle re-collide; it is deterministic still,
|
|
133
|
-
* and that directory is already an error finding by name.
|
|
134
|
-
*
|
|
135
|
-
* EXPORTED, with its prefix, because `subsystem.view-id-collision` has to ask
|
|
136
|
-
* the generator what it mints rather than re-deriving it. A check carrying its
|
|
137
|
-
* own copy of the escaping above would go quiet the day the encoding changed,
|
|
138
|
-
* and going quiet is the failure this whole family exists to prevent.
|
|
244
|
+
* a duplicate view id.
|
|
245
|
+
*
|
|
246
|
+
* The per-BYTE encoding, the collision it was written against, and the reason
|
|
247
|
+
* segments join on `__` all live with the encoder in `./service/view-id.ts` —
|
|
248
|
+
* which is also where the per-service ids are minted, so the two generated
|
|
249
|
+
* files cannot drift into two spellings of the same escape. That module sits
|
|
250
|
+
* BELOW this one on purpose: it is the leaf, and the import may only run
|
|
251
|
+
* downward (`./service/views.ts`'s banner records the package direction).
|
|
252
|
+
*
|
|
253
|
+
* EXPORTED here still, with its prefix, because `subsystem.view-id-collision`
|
|
254
|
+
* has to ask the generator what it mints rather than re-deriving it. A check
|
|
255
|
+
* carrying its own copy of the escaping would go quiet the day the encoding
|
|
256
|
+
* changed, and going quiet is the failure this whole family exists to prevent.
|
|
139
257
|
*/
|
|
140
258
|
export const SUBSYSTEM_VIEW_PREFIX = "subsystem_";
|
|
141
259
|
export function subsystemViewId(sub) {
|
|
142
|
-
|
|
143
|
-
return `subsystem_${sub.path.map(segment).join("__")}`;
|
|
260
|
+
return `${SUBSYSTEM_VIEW_PREFIX}${sub.path.map(escapeViewIdSegment).join("__")}`;
|
|
144
261
|
}
|
|
145
262
|
/**
|
|
146
263
|
* Does the file on disk already SAY what `renderSubsystemViews` renders?
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"views.js","sourceRoot":"","sources":["../../../../../src/core/repo/tree/render/views.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The generated views file: `architecture/subsystems.likec4`, one LikeC4 view\n * per subsystem enumerating the services beneath it. GENERATED, never\n * authored — authored bytes stay authored (archive's verbatim-splice promise),\n * so the tree is mirrored into a file of its own that `loam subsystem sync`\n * owns outright, and staleness against the tree is a byte compare\n * (`subsystem.views-stale` in `validate --all`), never a parse: a spike\n * confirmed a views-only document does NOT parse standalone (its `include`\n * lines reference elements the landscape defines), so nothing in loam ever\n * loads this file (and rule 26 does not change that: this file is GENERATED and\n * holds STATIC views, whose contents loam may not read in any document) — only\n * the LikeC4 renderer, which merges the whole `architecture/` project per\n * likec4.config.json, resolves it.\n *\n * Each view opens with the LABEL a human reads — `title`, and `description`\n * when the marker carries one — because this file exists only to be rendered\n * and an unlabelled view is shown by its id, which is hex-escaped for\n * injectivity and reads as `subsystem_platform__order_2dflow`. Both\n * strings are somebody else's words and go through `likec4String`, which\n * records what a raw one does.\n *\n * The output is deterministic and line-oriented, and both halves are the\n * contract: subsystems sorted by path, members sorted by id, ONE `include`\n * per line — so two concurrent moves into different groups touch different\n * lines and git merges the file without intervention, exactly as the\n * service-grouped landscape splice already achieves. Byte-reproducible on any\n * machine: no timestamps, no absolute paths, no readdir-order dependence (the\n * walk sorts, and every list is re-sorted here). Plain lexicographic sorts on\n * purpose — the ids' own grammar is ASCII, an INVALID name is still ordered\n * deterministically, and `compareIds`' numeric tokenizing lives in `repo/`,\n * which this package must not import (`walk.ts` says why the edge only points\n * the other way).\n *\n * Membership is TRANSITIVE: a subsystem's view includes every service beneath\n * it at any depth, so a parent's view is the whole group's map rather than an\n * empty frame around its children. An empty view body is legal LikeC4 (same\n * spike), so an empty subsystem renders as one — `subsystem new` must be\n * usable before anything moves in.\n *\n * Include lines name the landscape ELEMENT id, resolved through the same\n * element→service join `validate` uses (`serviceResolver`: explicit\n * `metadata { service }` binding first, then a title naming a real service) —\n * because element ids in real landscapes are not service ids, and a view of\n * raw service ids would silently render empty for every such fleet. A member\n * with no landscape element is omitted from the includes: the omission is\n * already an error (`landscape.service-unmodelled`), and rendering only what\n * the committed landscape resolves keeps the output a function of committed\n * bytes alone.\n */\nimport { type Elem } from \"../../../c4/likec4.js\";\nimport { renderContext, viewBody } from \"./body.js\";\nimport { likec4String, viewTitle } from \"./text.js\";\nimport type { FleetTree, SubsystemEntry } from \"../walk.js\";\n\n/**\n * The header every generated file opens with — stable bytes, part of the\n * byte-compare contract, naming the regenerator so a reader who found the\n * file first knows what owns it.\n */\nconst HEADER =\n \"// generated by loam — the subsystem tree under services/, one view per subsystem.\\n\" +\n \"// Do not edit: `loam subsystem sync` regenerates this file from the directories,\\n\" +\n \"// and `loam validate --all` reports `subsystem.views-stale` when it drifts.\\n\" +\n \"// A scoping aid, not the fleet's architecture: author your own diagrams elsewhere.\\n\";\n\n/**\n * Render the views file for a walked tree, or null when the tree has no\n * subsystems — the file must then be ABSENT, so a flat fleet never carries a\n * generated artifact that says nothing.\n */\nexport function renderSubsystemViews(tree: FleetTree, elements: Elem[]): string | null {\n if (tree.subsystems.length === 0) return null;\n const ctx = renderContext(tree, elements);\n const lines: string[] = [HEADER, \"views {\"];\n const subsystems = [...tree.subsystems].sort((a, b) => (pathKey(a) < pathKey(b) ? -1 : 1));\n for (const [i, sub] of subsystems.entries()) {\n if (i > 0) lines.push(\"\");\n lines.push(` // services/${pathKey(sub)}`);\n const body = viewBody(ctx, sub);\n if (body.disclosure !== null) lines.push(` // ${body.disclosure}`);\n lines.push(` view ${subsystemViewId(sub)} {`);\n // Metadata FIRST, and that ordering is a hard constraint rather than a\n // taste: measured, an `include` OR a `group { … }` line followed by\n // `title` is a parse error (\"Expecting token of type '}' but found\n // `title`\"), and one parse error in this file blanks the whole\n // `architecture/` project in the renderer.\n lines.push(` title '${viewTitle(titleChain(tree, sub))}'`);\n const description = likec4String(sub.meta.description ?? \"\");\n if (description !== \"\") lines.push(` description '${description}'`);\n lines.push(...body.lines);\n lines.push(\" }\");\n }\n lines.push(\"}\");\n return lines.join(\"\\n\") + \"\\n\";\n}\n\n/**\n * This subsystem and every MARKED ancestor above it, outermost first — the\n * chain `viewTitle` composes a path out of.\n *\n * Marked, because an intermediate directory holding no `subsystem.yaml` is\n * `subsystem.unmarked`, an error the walk already names: it is not a group,\n * so it contributes no folder level here either. Read off the walked tree\n * rather than off the path segments, so what the title says and what the\n * finding says about the same directory cannot disagree.\n */\nfunction titleChain(tree: FleetTree, sub: SubsystemEntry): SubsystemEntry[] {\n return tree.subsystems\n .filter((s) => s.path.length <= sub.path.length && s.path.every((name, i) => sub.path[i] === name))\n .sort((a, b) => a.path.length - b.path.length);\n}\n\n/** The sort key and the comment spelling: the subsystem's path from services/, joined. */\nfunction pathKey(sub: SubsystemEntry): string {\n return sub.path.join(\"/\");\n}\n\n/**\n * A LikeC4 identifier for the view, derived from the subsystem's path so\n * nesting reads in the name — and INJECTIVE over every path the name grammar\n * admits, because the renderer refuses the whole `architecture/` project over\n * a duplicate view id. The naive folding (every non-identifier byte and the\n * join separator alike becoming `_`) collided three DISTINCT healthy names\n * onto one identifier — `a_b`, and `b` under `a`, both rendered\n * `subsystem_a_b` — so `validate --all` was green while the renderer refused\n * the fleet map; `subsystem.name-collision` never fires because the flat\n * namespace really is intact. Hence the encoding: an identifier byte\n * (`[A-Za-z0-9]`) passes through, any other byte becomes `_` + two lowercase\n * hex digits (`.` → `_2e`, `-` → `_2d`, `_` itself → `_5f` — the underscore\n * must be escaped precisely because it is BOTH legal in a name and the\n * separator), and segments join on `__`. Underscore runs then decode\n * uniquely — one is an escape, two a separator, three a separator followed\n * by an escape — so no two distinct legal paths share an identifier. A name\n * `subsystem.name-invalid` already flagged may hold a code point above 0xff\n * whose longer hex could in principle re-collide; it is deterministic still,\n * and that directory is already an error finding by name.\n *\n * EXPORTED, with its prefix, because `subsystem.view-id-collision` has to ask\n * the generator what it mints rather than re-deriving it. A check carrying its\n * own copy of the escaping above would go quiet the day the encoding changed,\n * and going quiet is the failure this whole family exists to prevent.\n */\nexport const SUBSYSTEM_VIEW_PREFIX = \"subsystem_\";\n\nexport function subsystemViewId(sub: SubsystemEntry): string {\n const segment = (name: string): string =>\n [...name].map((ch) => (/[A-Za-z0-9]/.test(ch) ? ch : `_${ch.codePointAt(0)!.toString(16).padStart(2, \"0\")}`)).join(\"\");\n return `subsystem_${sub.path.map(segment).join(\"__\")}`;\n}\n\n/**\n * Does the file on disk already SAY what `renderSubsystemViews` renders?\n *\n * The generator above only ever emits LF. `core.autocrlf=true` is Git for\n * Windows' installer default and a docs repo ships no `.gitattributes`, so an\n * ordinary Windows clone hands the file back with CRLF and not one fact\n * changed. Four call sites asked this question with a byte equality and all\n * four answered \"stale\" on such a clone: `loam validate` reported an error,\n * `loam subsystem sync` reported `updated` and rewrote with LF, git then showed\n * the file permanently modified, and the next checkout restored the CRLF. That\n * is a loop rather than a diagnosis, which is why the comparison lives here\n * now — the module that mints the bytes is the one that owes an answer about\n * them, and three near-copies of this rule would go stale one at a time.\n *\n * Still never a parse: a views-only document does not parse standalone, and no\n * check anywhere reads this file's content. The narrowing is from BYTES to\n * CONTENT, which is what `subsystem.views-stale`'s own message (\"does not match\n * the tree\") already claims. The symmetric cost is accepted and is the one\n * `core/verify/pins/pin.ts` documents for `pinnedDigest`: a pure line-ending\n * change is invisible, correctly, because it is not a change to what the file\n * says.\n *\n * `null` is carried through as itself rather than collapsing into `\"\"`: absent,\n * must-be-absent and present-but-different are three distinct states with three\n * distinct messages, and a fleet whose views file does not exist is not a fleet\n * whose views file is blank.\n */\nexport function viewsAgree(actual: string | null, expected: string | null): boolean {\n const lf = (text: string | null): string | null => (text === null ? null : text.replace(/\\r\\n|\\r/g, \"\\n\"));\n return lf(actual) === lf(expected);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"views.js","sourceRoot":"","sources":["../../../../../src/core/repo/tree/render/views.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The generated views file: `architecture/subsystems.likec4`, one LikeC4 view\n * per subsystem enumerating the services beneath it. GENERATED, never\n * authored — authored bytes stay authored (archive's verbatim-splice promise),\n * so the tree is mirrored into a file of its own that `loam subsystem sync`\n * owns outright, and staleness against the tree is a byte compare\n * (`subsystem.views-stale` in `validate --all`), never a parse: a spike\n * confirmed a views-only document does NOT parse standalone (its `include`\n * lines reference elements the landscape defines), so nothing in loam ever\n * loads this file (and rule 26 does not change that: this file is GENERATED and\n * holds STATIC views, whose contents loam may not read in any document) — only\n * the LikeC4 renderer, which merges the whole `architecture/` project per\n * likec4.config.json, resolves it.\n *\n * Each view opens with the LABEL a human reads — `title`, and `description`\n * when the marker carries one — because this file exists only to be rendered\n * and an unlabelled view is shown by its id, which is hex-escaped for\n * injectivity and reads as `subsystem_platform__order_2dflow`. Both\n * strings are somebody else's words and go through `likec4String`, which\n * records what a raw one does.\n *\n * The output is deterministic and line-oriented, and both halves are the\n * contract: subsystems sorted by path, members sorted by id, ONE `include`\n * per line — so two concurrent moves into different groups touch different\n * lines and git merges the file without intervention, exactly as the\n * service-grouped landscape splice already achieves. Byte-reproducible on any\n * machine: no timestamps, no absolute paths, no readdir-order dependence (the\n * walk sorts, and every list is re-sorted here). Plain lexicographic sorts on\n * purpose — the ids' own grammar is ASCII, an INVALID name is still ordered\n * deterministically, and `compareIds`' numeric tokenizing lives in `repo/`,\n * which this package must not import (`walk.ts` says why the edge only points\n * the other way).\n *\n * Membership is TRANSITIVE: a subsystem's view includes every service beneath\n * it at any depth, so a parent's view is the whole group's map rather than an\n * empty frame around its children. An empty view body is legal LikeC4 (same\n * spike), so an empty subsystem renders as one — `subsystem new` must be\n * usable before anything moves in.\n *\n * Include lines name the landscape ELEMENT id, resolved through the same\n * element→service join `validate` uses (`serviceResolver`: explicit\n * `metadata { service }` binding first, then a title naming a real service) —\n * because element ids in real landscapes are not service ids, and a view of\n * raw service ids would silently render empty for every such fleet. A member\n * with no landscape element is omitted from the includes: the omission is\n * already an error (`landscape.service-unmodelled`), and rendering only what\n * the committed landscape resolves keeps the output a function of committed\n * bytes alone.\n */\nimport { type LoadedDoc } from \"../../../c4/likec4.js\";\nimport { renderContext, viewBody } from \"./body.js\";\nimport { GENERATED_MARK } from \"./service/header.js\";\nimport { escapeViewIdSegment } from \"./service/view-id.js\";\nimport {\n renderServiceViews,\n SERVICE_STYLE_GROUP,\n type ServiceViewSubject,\n} from \"./service/views.js\";\nimport { likec4String, viewTitle } from \"./text.js\";\nimport type { FleetTree, SubsystemEntry } from \"../walk.js\";\n\n/**\n * The global style group a generated view references when the map declares\n * one: `global { styleGroup subsystems { … } }` (or the single-rule form\n * `global { style subsystems … }` — LikeC4 files both under one id table).\n * Presence is the opt-in and it is read off a DECLARATION, never a view: a\n * generated view cannot carry an inline `style` rule of its own (the file is\n * regenerated wholesale), and the one mechanism LikeC4 offers a view for\n * borrowing a repository's palette is a reference to a global style by id.\n * Referencing one that is not declared is a parse error that blanks the whole\n * `architecture/` project in the renderer, which is why the line is written\n * only when the id is declared and why its absence leaves the file exactly\n * what it was before this constant existed.\n *\n * One conventional name rather than \"the single declared group\": a second\n * group somebody declares for a hand-drawn view would otherwise silently\n * switch the generated views' styling OFF and restale the file on a change\n * that touched no subsystem — behaviour flipping on an unrelated declaration\n * is the shape `body.ts` calls \"a picture that looks the same whether or not\n * the two agree\". The name mirrors the file it styles.\n */\nexport const SUBSYSTEM_STYLE_GROUP = \"subsystems\";\n\n/**\n * What the render reads off the parsed map: the elements the member join\n * resolves, the RELATIONSHIPS a per-service view computes its peer set from,\n * and the global style ids a view may reference. A record rather than three\n * threaded parameters because all three come out of ONE parsed document, and\n * passing them apart would make an inconsistent pair representable. Every\n * `LoadedDoc` is structurally a `MapFacts`, so a caller holding the document\n * passes the document.\n *\n * `relationships` is REQUIRED, and that is the type doing work rather than a\n * shape falling out of `LoadedDoc`. It was optional once, on the reasoning that\n * the `LoadedDoc` literals standing in for a map that is absent or did not parse\n * have no edges to offer — and the consequence of omitting it is SILENT: every\n * per-service view renders with NO peers, the service and its own interior and\n * nothing it touches, with no error anywhere. Two independent readers found the\n * same caller composing a literal without the field beside a document that\n * carried one (`commands/subsystem/txn/views.ts`'s landscape-only fallback), so\n * documenting the hazard was measurably not enough: a caller with no edges must\n * now say `relationships: []` and mean it, and a caller holding a parsed\n * document passes the document.\n *\n * `globalStyles` stays optional because `LoadedDoc` declares it so and its\n * absence is not silent in the same way: a missing palette costs one `global\n * style` line, which the file-level disclosure comment then names.\n */\nexport type MapFacts = Pick<LoadedDoc, \"elements\" | \"relationships\" | \"globalStyles\">;\n\n/**\n * The header every generated file opens with — stable bytes, part of the\n * byte-compare contract, naming the regenerator so a reader who found the\n * file first knows what owns it.\n *\n * It OPENS with the shared `GENERATED_MARK` rather than spelling it, because\n * the same bytes are what `loam subsystem sync` reads back off disk to decide\n * whether the file standing at this reserved path is loam's to rewrite at all\n * (`./service/header.ts` carries the whole reason). A second literal here is\n * how a file loam wrote stops being recognised as loam's.\n */\nconst HEADER =\n `${GENERATED_MARK} — the subsystem tree under services/, one view per subsystem.\\n` +\n \"// Do not edit: `loam subsystem sync` regenerates this file from the directories,\\n\" +\n \"// and `loam validate --all` reports `subsystem.views-stale` when it drifts.\\n\" +\n \"// A scoping aid, not the fleet's architecture: author your own diagrams elsewhere.\\n\";\n\n/**\n * The one file-level comment the render may write: the map declares global\n * style groups, and none is the conventional one. Written between `HEADER`\n * and `views {` — outside every view body, graded by nothing — so a team\n * that already declared a palette under another name reads the fix in the\n * diff of the sync that regenerated the file, instead of wondering why the\n * generated views ignore it. Silent when the map declares no group at all: a\n * fleet with no palette is owed no sentence about one. Ids go unescaped\n * because LikeC4's grammar admits only identifier bytes in a style id.\n *\n * The group is a PARAMETER because there are two generated files now and each\n * reserves its own name (`subsystems`, `services`); a map declaring one and not\n * the other must get the sentence in exactly the file that went unstyled.\n */\nfunction stylesDisclosure(declared: readonly string[], group: string): string | null {\n if (declared.length === 0 || declared.includes(group)) return null;\n return (\n `// styles: the project declares global style group(s) ${[...declared].sort().join(\", \")}, ` +\n `and none named \\`${group}\\` — these views carry the renderer's defaults until one is.`\n );\n}\n\n/**\n * Render the views file for a walked tree, or null when the tree has no\n * subsystems — the file must then be ABSENT, so a flat fleet never carries a\n * generated artifact that says nothing.\n */\nexport function renderSubsystemViews(tree: FleetTree, map: MapFacts): string | null {\n if (tree.subsystems.length === 0) return null;\n const ctx = renderContext(tree, map.elements);\n const declared = map.globalStyles ?? [];\n const styled = declared.includes(SUBSYSTEM_STYLE_GROUP);\n const disclosure = stylesDisclosure(declared, SUBSYSTEM_STYLE_GROUP);\n const lines: string[] = [HEADER, ...(disclosure === null ? [] : [disclosure]), \"views {\"];\n const subsystems = [...tree.subsystems].sort((a, b) => (pathKey(a) < pathKey(b) ? -1 : 1));\n for (const [i, sub] of subsystems.entries()) {\n if (i > 0) lines.push(\"\");\n lines.push(` // services/${pathKey(sub)}`);\n const body = viewBody(ctx, sub);\n if (body.disclosure !== null) lines.push(` // ${body.disclosure}`);\n // `of <ancestor>` when — and only when — the map holds an element that owns\n // exactly this subsystem's members (`../render/body.ts` argues the\n // \"exactly\"). It is the same one keyword the per-service views spend, and it\n // buys the same thing: LikeC4 computes `navigateTo` into this view from\n // every other view that draws that element, so the fleet map's group boxes\n // become click-through and a neighbouring group box inside one subsystem\n // view opens THAT subsystem's view. Measured at 58 services in 11 groups,\n // 12 of 14 nodes navigable. With no owning ancestor there is no element to\n // be `of`, so there is no navigation — honest, and one more reason to nest\n // the map that loam already discloses one line above.\n lines.push(` view ${subsystemViewId(sub)}${body.of === null ? \"\" : ` of ${body.of}`} {`);\n // Metadata FIRST, and that ordering is a hard constraint rather than a\n // taste: measured, an `include` OR a `group { … }` line followed by\n // `title` is a parse error (\"Expecting token of type '}' but found\n // `title`\"), and one parse error in this file blanks the whole\n // `architecture/` project in the renderer.\n lines.push(` title '${viewTitle(titleChain(tree, sub.path))}'`);\n const description = likec4String(sub.meta.description ?? \"\");\n if (description !== \"\") lines.push(` description '${description}'`);\n // After the label, before anything that includes: measured at the pin\n // (test/likec4-view-shape.test.ts), a `global style <id>` line here\n // parses with zero errors, while the same line before `title` is the\n // parse error the comment above records. The palette is the map's own —\n // the group is declared in the same project this file renders into — so\n // the reference resolves exactly when the presence test says it does.\n if (styled) lines.push(` global style ${SUBSYSTEM_STYLE_GROUP}`);\n lines.push(...body.lines);\n lines.push(\" }\");\n }\n lines.push(\"}\");\n return lines.join(\"\\n\") + \"\\n\";\n}\n\n/**\n * The GENERATED per-service overviews — `architecture/services.likec4`, one\n * view per service the map resolves. Null when it resolves none, so the file is\n * then absent exactly as `subsystems.likec4` is for a tree with no subsystems.\n *\n * This function is the composer and nothing else: `./service/` owns the view\n * shape and its measurements, and everything about the TREE is resolved here,\n * because that package may not import `./body.ts` or `./text.ts` (the edge runs\n * the other way — `./service/views.ts` says why) and duplicating either would\n * put a second element→service join and a second string escaper in the tree.\n *\n * Emission is gated on the SAME join the subsystem views use: a service the map\n * does not draw is omitted rather than invented, because that omission is\n * already `landscape.service-unmodelled` and rendering only what the committed\n * map resolves keeps the output a function of committed bytes alone.\n */\nexport function renderServiceOverviews(tree: FleetTree, map: MapFacts): string | null {\n const ctx = renderContext(tree, map.elements);\n const declared = map.globalStyles ?? [];\n const subjects: ServiceViewSubject[] = [];\n for (const service of tree.services) {\n const fqn = ctx.elementOf.get(service.id);\n if (fqn === undefined) continue;\n subjects.push({ id: service.id, fqn, title: serviceViewTitle(tree, service.subsystem, service.id) });\n }\n return renderServiceViews({\n subjects,\n relationships: map.relationships,\n styled: declared.includes(SERVICE_STYLE_GROUP),\n disclosure: stylesDisclosure(declared, SERVICE_STYLE_GROUP),\n });\n}\n\n/**\n * A per-service view's title: its subsystem path and then its own id, joined\n * with ` / ` — `Platform / order-flow / checkout-web`, or bare `checkout-web`\n * for a service filed directly under `services/`.\n *\n * LikeC4 reads that as a folder path (`./text.ts` measured it), so a service's\n * overview lands in the SAME browser folder as its subsystem's view rather than\n * in a flat list of fifty-eight titles — which is the whole readability answer\n * at fleet scale, and it costs the same helpers the subsystem views already use.\n *\n * The leaf is the service's `services/<id>/` DIRECTORY id and deliberately not\n * the map element's title: it is the name every loam command addresses the\n * service by, it cannot drift when somebody renames a box on the map, and a\n * directory name cannot contain `/`, so it needs no folder-separator\n * substitution of its own. It still goes through `likec4String` — an id is a\n * directory name, and a directory name is somebody else's word.\n */\nfunction serviceViewTitle(tree: FleetTree, subsystem: string[], id: string): string {\n return [viewTitle(titleChain(tree, subsystem)), likec4String(id)].filter((part) => part !== \"\").join(\" / \");\n}\n\n/**\n * The MARKED subsystems along a path, outermost first — the chain `viewTitle`\n * composes a folder path out of, for a subsystem's own view and for the view of\n * a service filed beneath it alike.\n *\n * Marked, because an intermediate directory holding no `subsystem.yaml` is\n * `subsystem.unmarked`, an error the walk already names: it is not a group,\n * so it contributes no folder level here either. Read off the walked tree\n * rather than off the path segments, so what the title says and what the\n * finding says about the same directory cannot disagree.\n */\nfunction titleChain(tree: FleetTree, path: readonly string[]): SubsystemEntry[] {\n return tree.subsystems\n .filter((s) => s.path.length <= path.length && s.path.every((name, i) => path[i] === name))\n .sort((a, b) => a.path.length - b.path.length);\n}\n\n/** The sort key and the comment spelling: the subsystem's path from services/, joined. */\nfunction pathKey(sub: SubsystemEntry): string {\n return sub.path.join(\"/\");\n}\n\n/**\n * A LikeC4 identifier for the view, derived from the subsystem's path so\n * nesting reads in the name — and INJECTIVE over every path the name grammar\n * admits, because the renderer refuses the whole `architecture/` project over\n * a duplicate view id.\n *\n * The per-BYTE encoding, the collision it was written against, and the reason\n * segments join on `__` all live with the encoder in `./service/view-id.ts` —\n * which is also where the per-service ids are minted, so the two generated\n * files cannot drift into two spellings of the same escape. That module sits\n * BELOW this one on purpose: it is the leaf, and the import may only run\n * downward (`./service/views.ts`'s banner records the package direction).\n *\n * EXPORTED here still, with its prefix, because `subsystem.view-id-collision`\n * has to ask the generator what it mints rather than re-deriving it. A check\n * carrying its own copy of the escaping would go quiet the day the encoding\n * changed, and going quiet is the failure this whole family exists to prevent.\n */\nexport const SUBSYSTEM_VIEW_PREFIX = \"subsystem_\";\n\nexport function subsystemViewId(sub: SubsystemEntry): string {\n return `${SUBSYSTEM_VIEW_PREFIX}${sub.path.map(escapeViewIdSegment).join(\"__\")}`;\n}\n\n/**\n * Does the file on disk already SAY what `renderSubsystemViews` renders?\n *\n * The generator above only ever emits LF. `core.autocrlf=true` is Git for\n * Windows' installer default and a docs repo ships no `.gitattributes`, so an\n * ordinary Windows clone hands the file back with CRLF and not one fact\n * changed. Four call sites asked this question with a byte equality and all\n * four answered \"stale\" on such a clone: `loam validate` reported an error,\n * `loam subsystem sync` reported `updated` and rewrote with LF, git then showed\n * the file permanently modified, and the next checkout restored the CRLF. That\n * is a loop rather than a diagnosis, which is why the comparison lives here\n * now — the module that mints the bytes is the one that owes an answer about\n * them, and three near-copies of this rule would go stale one at a time.\n *\n * Still never a parse: a views-only document does not parse standalone, and no\n * check anywhere reads this file's content. The narrowing is from BYTES to\n * CONTENT, which is what `subsystem.views-stale`'s own message (\"does not match\n * the tree\") already claims. The symmetric cost is accepted and is the one\n * `core/verify/pins/pin.ts` documents for `pinnedDigest`: a pure line-ending\n * change is invisible, correctly, because it is not a change to what the file\n * says.\n *\n * `null` is carried through as itself rather than collapsing into `\"\"`: absent,\n * must-be-absent and present-but-different are three distinct states with three\n * distinct messages, and a fleet whose views file does not exist is not a fleet\n * whose views file is blank.\n */\nexport function viewsAgree(actual: string | null, expected: string | null): boolean {\n const lf = (text: string | null): string | null => (text === null ? null : text.replace(/\\r\\n|\\r/g, \"\\n\"));\n return lf(actual) === lf(expected);\n}\n"]}
|
|
@@ -23,6 +23,21 @@
|
|
|
23
23
|
* this walk would close the exact package cycle `scripts/package-graph.mjs`
|
|
24
24
|
* exists to refuse.
|
|
25
25
|
*
|
|
26
|
+
* A SERVICE'S OWN SUBDIRECTORIES ARE NEVER CLASSIFIED (`RESERVED_INTERIOR`).
|
|
27
|
+
* That is a different question from `isServiceArtifact`, which asks whether a
|
|
28
|
+
* name makes the directory holding it a service; this asks whether a child
|
|
29
|
+
* directory may be READ as one at all, and the two answers diverged the moment
|
|
30
|
+
* SCHEMA's service layout grew a slot the artifact table does not carry. A
|
|
31
|
+
* `services/<svc>/` holding only the documented `usecases/` flow directory was
|
|
32
|
+
* classified as a group: the walk descended, filed `usecases` as a service, and
|
|
33
|
+
* one `loam validate --all` reported `subsystem.unmarked`,
|
|
34
|
+
* `landscape.service-unmodelled` for `services/<svc>/usecases/` and
|
|
35
|
+
* `landscape.binding-unknown` for the real service — three errors whose text
|
|
36
|
+
* says the fleet is losing services, earned by following the instruction `loam
|
|
37
|
+
* init` had just scaffolded (verification 2026-09-04). The names are SCHEMA's
|
|
38
|
+
* service layout, and a directory named for one is service interior wherever it
|
|
39
|
+
* sits, so no branch of the classification descends into one.
|
|
40
|
+
*
|
|
26
41
|
* Cost: exactly one readdir per directory walked, service directories included
|
|
27
42
|
* (their own listing is what classifies them), plus one realpath per directory
|
|
28
43
|
* for the symlink-cycle guard — `entryIs` follows symlinks on purpose, and a
|
|
@@ -36,6 +51,29 @@ import { rawServiceId } from "../../kernel/ids/service.js";
|
|
|
36
51
|
import { subsystemNameProblem } from "../../kernel/ids/subsystem.js";
|
|
37
52
|
import { entryIs } from "./fs.js";
|
|
38
53
|
import { readSubsystemMarker, SUBSYSTEM_MARKER } from "./marker.js";
|
|
54
|
+
/**
|
|
55
|
+
* The subdirectory names SCHEMA's service layout reserves for a service's own
|
|
56
|
+
* interior (`SCHEMA.md`, "services/<svc>/"): `adrs/` the decisions, `usecases/`
|
|
57
|
+
* the flows drawn over its containers, `ui/` the page-specs. A directory with
|
|
58
|
+
* one of these names is never a service and never a subsystem — it is inside
|
|
59
|
+
* one — so the walk neither classifies it nor counts it as a subdirectory that
|
|
60
|
+
* makes its parent a group.
|
|
61
|
+
*
|
|
62
|
+
* Kept here rather than folded into `isServiceArtifact` because the two
|
|
63
|
+
* questions are not the same one: `adrs/` answers both (it is an artifact AND
|
|
64
|
+
* interior) and `usecases/` answers only this one today. Widening the injected
|
|
65
|
+
* artifact table instead would also make a lone `usecases/` classify its parent
|
|
66
|
+
* as a service by ARTIFACT, which hides a real service filed beside it — the
|
|
67
|
+
* silent shrink this module refuses.
|
|
68
|
+
*
|
|
69
|
+
* Exported for the one other reader of this rule: `core/diff/base-state.ts`
|
|
70
|
+
* classifies a base git ref's tree listing, which no walk can readdir, and its
|
|
71
|
+
* banner already commits it to restating this module's branches rather than
|
|
72
|
+
* inventing its own. It shares the SET for the reason it shares the artifact
|
|
73
|
+
* table — a name respelled there is the drift both modules are written to
|
|
74
|
+
* avoid.
|
|
75
|
+
*/
|
|
76
|
+
export const RESERVED_INTERIOR = new Set(["adrs", "usecases", "ui"]);
|
|
39
77
|
export async function walkServicesTree(request) {
|
|
40
78
|
const ctx = {
|
|
41
79
|
request,
|
|
@@ -82,10 +120,15 @@ async function visitDir(spot, ctx) {
|
|
|
82
120
|
const rel = ["services", ...spot.chain, spot.name].join("/");
|
|
83
121
|
const entries = (await readdir(spot.dir, { withFileTypes: true })).filter((e) => !e.name.startsWith("."));
|
|
84
122
|
const files = entries.filter((e) => entryIs(spot.dir, e, "file")).map((e) => e.name);
|
|
85
|
-
const
|
|
123
|
+
const all = entries.filter((e) => entryIs(spot.dir, e, "dir")).map((e) => e.name).sort();
|
|
124
|
+
// The classifiable children — a service's own interior removed once, here, so
|
|
125
|
+
// every branch below agrees about it (see `RESERVED_INTERIOR`). `all` is still
|
|
126
|
+
// what the artifact probe reads: `adrs/` makes its parent a service, and
|
|
127
|
+
// `usecases/` is interior the parent may hold without becoming a group.
|
|
128
|
+
const dirs = all.filter((d) => !RESERVED_INTERIOR.has(d));
|
|
86
129
|
const marker = files.includes(SUBSYSTEM_MARKER);
|
|
87
130
|
const artifact = files.some((f) => ctx.request.isServiceArtifact(f, "file")) ||
|
|
88
|
-
|
|
131
|
+
all.some((d) => ctx.request.isServiceArtifact(d, "dir"));
|
|
89
132
|
if (marker && artifact) {
|
|
90
133
|
// Artifacts win the classification: the documents are somebody's work and
|
|
91
134
|
// the marker is one stray file, so the directory stays an addressable
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"walk.js","sourceRoot":"","sources":["../../../../src/core/repo/tree/walk.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The tree read model: three-way classification of everything under\n * `services/`, in one walk.\n *\n * A directory holding `subsystem.yaml` is a SUBSYSTEM and is walked; one\n * holding any service artifact is a SERVICE and is not walked deeper — unless\n * a marker sits BESIDE the artifacts, where the walk descends anyway (the\n * marker says a group was here, and its members must not vanish); one holding\n * neither while containing subdirectories is an ERROR that still names every\n * service found beneath it. The third branch is the whole point:\n * two-way classification loses a service silently on an ordinary clean merge —\n * one branch deletes an emptied subsystem's marker while another moves a\n * service into it, and the group directory is then read as a service whose\n * real services vanish from the fleet with no finding. No directory under\n * `services/` may ever be reinterpreted as a different kind without saying so,\n * and the fleet is never reported smaller: the walk descends into the broken\n * group precisely so the stranded services stay enumerated and named.\n *\n * The package direction is `repo → repo/tree`, never back. That is why the\n * artifact-name question arrives INJECTED (`TreeWalkRequest.isServiceArtifact`\n * — `repo/paths.ts` owns the spellings and `repo/repo.ts` builds the request):\n * importing `paths.ts` from here while `repo.ts` delegates its enumeration to\n * this walk would close the exact package cycle `scripts/package-graph.mjs`\n * exists to refuse.\n *\n * Cost: exactly one readdir per directory walked, service directories included\n * (their own listing is what classifies them), plus one realpath per directory\n * for the symlink-cycle guard — `entryIs` follows symlinks on purpose, and a\n * followed cycle without the visited-set is an unbounded recursion.\n */\nimport { realpathSync } from \"node:fs\";\nimport { readdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { serviceDirOf, serviceTreePath, type DocsDir, type ServiceDir } from \"../../kernel/ids/dirs.js\";\nimport { rawServiceId, type RawServiceId } from \"../../kernel/ids/service.js\";\nimport { subsystemNameProblem } from \"../../kernel/ids/subsystem.js\";\nimport type { Finding } from \"../../vocabulary/report.js\";\nimport { entryIs } from \"./fs.js\";\nimport { readSubsystemMarker, SUBSYSTEM_MARKER, type SubsystemMeta } from \"./marker.js\";\n\nexport interface WalkedService {\n id: RawServiceId;\n dir: ServiceDir;\n /**\n * Names of the directories between `services/` and the service, outermost\n * first. Empty = unfiled — directly under `services/`, which is permanent,\n * normal and SILENT. For a service stranded under an unmarked group the\n * chain still records the physical placement: the path is a fact even while\n * `subsystem.unmarked` says the grouping is broken.\n */\n subsystem: string[];\n}\n\nexport interface SubsystemEntry {\n /** The directory name — the subsystem's name in the flat namespace. */\n name: string;\n /** Names from `services/` down to and including this one. */\n path: string[];\n /** Absolute path of the subsystem directory. */\n dir: string;\n meta: SubsystemMeta;\n}\n\nexport interface FleetTree {\n services: WalkedService[];\n subsystems: SubsystemEntry[];\n findings: Finding[];\n}\n\nexport interface TreeWalkRequest {\n docsDir: DocsDir;\n /** Does this entry name a service artifact? `repo/paths.ts`'s table, injected — the module doc says why. */\n isServiceArtifact: (name: string, kind: \"file\" | \"dir\") => boolean;\n}\n\ninterface WalkContext {\n request: TreeWalkRequest;\n tree: FleetTree;\n /** Realpaths already DESCENDED into — the symlink-cycle guard. */\n visited: Set<string>;\n /** Every name in the flat namespace → the directories that claim it. */\n names: Map<string, string[]>;\n}\n\n/** One directory the walk is looking at: where it is, and how it is reached. */\ninterface Spot {\n dir: string;\n name: string;\n /** Directory names between `services/` and this one, outermost first. */\n chain: string[];\n}\n\nexport async function walkServicesTree(request: TreeWalkRequest): Promise<FleetTree> {\n const ctx: WalkContext = {\n request,\n tree: { services: [], subsystems: [], findings: [] },\n visited: new Set(),\n names: new Map(),\n };\n const root = join(request.docsDir, \"services\");\n guardDescent(root, ctx);\n for (const name of await subdirNames(root)) {\n await visitDir({ dir: join(root, name), name, chain: [] }, ctx);\n }\n namespaceFindings(ctx);\n return ctx.tree;\n}\n\n/**\n * Mark a directory as descended-into; false when it (or another name for it)\n * already was. Guards SUBTREES, not entries: a directory reachable under two\n * names is classified twice — both names exist and hiding the second would be\n * the silent shrink this module refuses — but its children are enumerated\n * once, so a symlink cycle terminates instead of recursing forever. A path\n * realpath cannot resolve is one whose children cannot be read either.\n */\nfunction guardDescent(dir: string, ctx: WalkContext): boolean {\n let real: string;\n try {\n real = realpathSync(dir);\n } catch {\n return false;\n }\n if (ctx.visited.has(real)) return false;\n ctx.visited.add(real);\n return true;\n}\n\n/** Sorted non-dot subdirectory names — the walk's deterministic child order. */\nasync function subdirNames(dir: string): Promise<string[]> {\n const entries = await readdir(dir, { withFileTypes: true });\n return entries.filter((e) => !e.name.startsWith(\".\") && entryIs(dir, e, \"dir\")).map((e) => e.name).sort();\n}\n\n/** Classify one directory and, for the group kinds, walk what is inside it. */\nasync function visitDir(spot: Spot, ctx: WalkContext): Promise<void> {\n const rel = [\"services\", ...spot.chain, spot.name].join(\"/\");\n const entries = (await readdir(spot.dir, { withFileTypes: true })).filter((e) => !e.name.startsWith(\".\"));\n const files = entries.filter((e) => entryIs(spot.dir, e, \"file\")).map((e) => e.name);\n const dirs = entries.filter((e) => entryIs(spot.dir, e, \"dir\")).map((e) => e.name).sort();\n const marker = files.includes(SUBSYSTEM_MARKER);\n const artifact =\n files.some((f) => ctx.request.isServiceArtifact(f, \"file\")) ||\n dirs.some((d) => ctx.request.isServiceArtifact(d, \"dir\"));\n\n if (marker && artifact) {\n // Artifacts win the classification: the documents are somebody's work and\n // the marker is one stray file, so the directory stays an addressable\n // service and the finding names the contradiction instead of the walk\n // quietly picking a reading. But the walk STILL descends (the module\n // invariant: no reinterpretation may shrink the fleet) — the marker says\n // a GROUP was here, and returning without walking made every service\n // beneath it vanish with only this finding to hint why. Child directories\n // that are themselves artifacts (`adrs/`) are service interior, not walked.\n recordService(spot, ctx);\n const stranded = await descendInto(spot, dirs.filter((d) => !ctx.request.isServiceArtifact(d, \"dir\")), ctx);\n ctx.tree.findings.push({\n severity: \"error\",\n code: \"subsystem.marker-misplaced\",\n subject: spot.name,\n message:\n `${rel}/ holds ${SUBSYSTEM_MARKER} BESIDE service artifacts — a directory is a subsystem or a service, never both. ` +\n `It stays classified as a service; delete ${rel}/${SUBSYSTEM_MARKER}, ` +\n `or move the artifacts into a service directory of their own inside the group.` +\n (stranded.length > 0 ? ` ${stranded.length} service(s) beneath it stay enumerated meanwhile: ${stranded.map((s) => s.id).join(\", \")}.` : \"\"),\n ...(stranded.length > 0 ? { details: stranded.map(serviceTreePath) } : {}),\n });\n return;\n }\n if (marker) {\n await visitSubsystem(spot, { rel, dirs }, ctx);\n return;\n }\n if (artifact) {\n recordService(spot, ctx);\n return;\n }\n if (dirs.length > 0) {\n await visitUnmarked(spot, { rel, dirs }, ctx);\n return;\n }\n // Neither marker nor artifact, and nothing beneath: today's flat behaviour —\n // an empty (or not-yet-adopted) directory under services/ is a service.\n recordService(spot, ctx);\n}\n\nfunction recordService(spot: Spot, ctx: WalkContext): void {\n ctx.tree.services.push({\n id: rawServiceId(spot.name),\n dir: serviceDirOf(spot.dir),\n subsystem: spot.chain,\n });\n claimName(spot, ctx);\n}\n\n/** A subsystem: record it, report a broken marker or an illegal name, walk in. */\nasync function visitSubsystem(spot: Spot, at: { rel: string; dirs: string[] }, ctx: WalkContext): Promise<void> {\n const meta = await readSubsystemMarker(join(spot.dir, SUBSYSTEM_MARKER));\n if (meta.invalid !== undefined) {\n // Exactly one finding per marker file: presence already classified the\n // directory, so the content failure is metadata, not identity.\n ctx.tree.findings.push({\n severity: \"error\",\n code: \"subsystem.invalid\",\n subject: at.rel,\n message:\n `${at.rel}/${SUBSYSTEM_MARKER} cannot be read as subsystem metadata — ${meta.invalid}. ` +\n `The directory still counts as a subsystem (presence classifies; content is metadata). ` +\n `Fix the file: a YAML mapping with optional title, description and owner — an empty file is also a valid marker.`,\n });\n }\n const problem = subsystemNameProblem(spot.name, \"subsystem directory name\");\n if (problem !== null) {\n // Walked anyway, mirroring how an illegal service id stays enumerated\n // (`idProblem`): the directory exists and hiding its subtree would shrink\n // the fleet over a naming mistake.\n ctx.tree.findings.push({\n severity: \"error\",\n code: \"subsystem.name-invalid\",\n subject: spot.name,\n message: `${at.rel}/ — ${problem} Rename the directory; its services stay enumerated meanwhile.`,\n });\n }\n ctx.tree.subsystems.push({\n name: spot.name,\n path: [...spot.chain, spot.name],\n dir: spot.dir,\n meta,\n });\n claimName(spot, ctx);\n await descendInto(spot, at.dirs, ctx);\n}\n\n/**\n * Walk the children of a group directory (symlink-cycle guarded), returning\n * the services newly recorded beneath it — the ONE spelling of descent, so\n * the three descending branches agree about the guard and the chain.\n */\nasync function descendInto(spot: Spot, dirs: string[], ctx: WalkContext): Promise<WalkedService[]> {\n const before = ctx.tree.services.length;\n if (guardDescent(spot.dir, ctx)) {\n for (const child of dirs) {\n await visitDir({ dir: join(spot.dir, child), name: child, chain: [...spot.chain, spot.name] }, ctx);\n }\n }\n return ctx.tree.services.slice(before);\n}\n\n/**\n * The refusal branch — a group directory nothing marks. The walk STILL\n * descends, so the services beneath stay in the enumeration and the finding\n * can name them: the fleet must never be reported smaller than it is.\n */\nasync function visitUnmarked(spot: Spot, at: { rel: string; dirs: string[] }, ctx: WalkContext): Promise<void> {\n const stranded = await descendInto(spot, at.dirs, ctx);\n ctx.tree.findings.push({\n severity: \"error\",\n code: \"subsystem.unmarked\",\n subject: at.rel,\n message:\n `${at.rel}/ is neither a subsystem (no ${SUBSYSTEM_MARKER}) nor a service (no service artifact), ` +\n `but ${stranded.length} service(s) sit beneath it` +\n (stranded.length > 0 ? `: ${stranded.map((s) => s.id).join(\", \")}. ` : `. `) +\n `Without the marker this directory would be read as a service and everything under it would vanish from the fleet — ` +\n `the shape an ordinary merge produces when one branch deletes an emptied subsystem's marker while another moves a service in. ` +\n `Restore ${at.rel}/${SUBSYSTEM_MARKER} (an empty file is a valid marker), or move the directories out.`,\n ...(stranded.length > 0 ? { details: stranded.map(serviceTreePath) } : {}),\n });\n}\n\nfunction claimName(spot: Spot, ctx: WalkContext): void {\n const rel = [\"services\", ...spot.chain, spot.name].join(\"/\");\n ctx.names.set(spot.name, [...(ctx.names.get(spot.name) ?? []), rel]);\n}\n\n/**\n * One flat namespace: every subsystem name and service id, unique across the\n * whole tree at any depth, never colliding with each other. The identity\n * decision demands it — a service id is its leaf directory name ALONE, so two\n * leaves spelling one name are one identity claimed twice, wherever they sit.\n * One finding per name, naming every claimant, so the fix is a choice rather\n * than a scavenger hunt.\n */\nfunction namespaceFindings(ctx: WalkContext): void {\n for (const [name, dirs] of [...ctx.names].sort(([a], [b]) => (a < b ? -1 : 1))) {\n if (dirs.length < 2) continue;\n ctx.tree.findings.push({\n severity: \"error\",\n code: \"subsystem.name-collision\",\n subject: name,\n message:\n `'${name}' is claimed by ${dirs.length} directories in the services/ tree: ${dirs.join(\", \")} — ` +\n `service ids and subsystem names share one flat namespace, unique at any depth, ` +\n `because a service's identity is its leaf directory name alone and placement never disambiguates it. ` +\n `Rename all but one.`,\n details: dirs,\n });\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"walk.js","sourceRoot":"","sources":["../../../../src/core/repo/tree/walk.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The tree read model: three-way classification of everything under\n * `services/`, in one walk.\n *\n * A directory holding `subsystem.yaml` is a SUBSYSTEM and is walked; one\n * holding any service artifact is a SERVICE and is not walked deeper — unless\n * a marker sits BESIDE the artifacts, where the walk descends anyway (the\n * marker says a group was here, and its members must not vanish); one holding\n * neither while containing subdirectories is an ERROR that still names every\n * service found beneath it. The third branch is the whole point:\n * two-way classification loses a service silently on an ordinary clean merge —\n * one branch deletes an emptied subsystem's marker while another moves a\n * service into it, and the group directory is then read as a service whose\n * real services vanish from the fleet with no finding. No directory under\n * `services/` may ever be reinterpreted as a different kind without saying so,\n * and the fleet is never reported smaller: the walk descends into the broken\n * group precisely so the stranded services stay enumerated and named.\n *\n * The package direction is `repo → repo/tree`, never back. That is why the\n * artifact-name question arrives INJECTED (`TreeWalkRequest.isServiceArtifact`\n * — `repo/paths.ts` owns the spellings and `repo/repo.ts` builds the request):\n * importing `paths.ts` from here while `repo.ts` delegates its enumeration to\n * this walk would close the exact package cycle `scripts/package-graph.mjs`\n * exists to refuse.\n *\n * A SERVICE'S OWN SUBDIRECTORIES ARE NEVER CLASSIFIED (`RESERVED_INTERIOR`).\n * That is a different question from `isServiceArtifact`, which asks whether a\n * name makes the directory holding it a service; this asks whether a child\n * directory may be READ as one at all, and the two answers diverged the moment\n * SCHEMA's service layout grew a slot the artifact table does not carry. A\n * `services/<svc>/` holding only the documented `usecases/` flow directory was\n * classified as a group: the walk descended, filed `usecases` as a service, and\n * one `loam validate --all` reported `subsystem.unmarked`,\n * `landscape.service-unmodelled` for `services/<svc>/usecases/` and\n * `landscape.binding-unknown` for the real service — three errors whose text\n * says the fleet is losing services, earned by following the instruction `loam\n * init` had just scaffolded (verification 2026-09-04). The names are SCHEMA's\n * service layout, and a directory named for one is service interior wherever it\n * sits, so no branch of the classification descends into one.\n *\n * Cost: exactly one readdir per directory walked, service directories included\n * (their own listing is what classifies them), plus one realpath per directory\n * for the symlink-cycle guard — `entryIs` follows symlinks on purpose, and a\n * followed cycle without the visited-set is an unbounded recursion.\n */\nimport { realpathSync } from \"node:fs\";\nimport { readdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { serviceDirOf, serviceTreePath, type DocsDir, type ServiceDir } from \"../../kernel/ids/dirs.js\";\nimport { rawServiceId, type RawServiceId } from \"../../kernel/ids/service.js\";\nimport { subsystemNameProblem } from \"../../kernel/ids/subsystem.js\";\nimport type { Finding } from \"../../vocabulary/report.js\";\nimport { entryIs } from \"./fs.js\";\nimport { readSubsystemMarker, SUBSYSTEM_MARKER, type SubsystemMeta } from \"./marker.js\";\n\n/**\n * The subdirectory names SCHEMA's service layout reserves for a service's own\n * interior (`SCHEMA.md`, \"services/<svc>/\"): `adrs/` the decisions, `usecases/`\n * the flows drawn over its containers, `ui/` the page-specs. A directory with\n * one of these names is never a service and never a subsystem — it is inside\n * one — so the walk neither classifies it nor counts it as a subdirectory that\n * makes its parent a group.\n *\n * Kept here rather than folded into `isServiceArtifact` because the two\n * questions are not the same one: `adrs/` answers both (it is an artifact AND\n * interior) and `usecases/` answers only this one today. Widening the injected\n * artifact table instead would also make a lone `usecases/` classify its parent\n * as a service by ARTIFACT, which hides a real service filed beside it — the\n * silent shrink this module refuses.\n *\n * Exported for the one other reader of this rule: `core/diff/base-state.ts`\n * classifies a base git ref's tree listing, which no walk can readdir, and its\n * banner already commits it to restating this module's branches rather than\n * inventing its own. It shares the SET for the reason it shares the artifact\n * table — a name respelled there is the drift both modules are written to\n * avoid.\n */\nexport const RESERVED_INTERIOR: ReadonlySet<string> = new Set([\"adrs\", \"usecases\", \"ui\"]);\n\nexport interface WalkedService {\n id: RawServiceId;\n dir: ServiceDir;\n /**\n * Names of the directories between `services/` and the service, outermost\n * first. Empty = unfiled — directly under `services/`, which is permanent,\n * normal and SILENT. For a service stranded under an unmarked group the\n * chain still records the physical placement: the path is a fact even while\n * `subsystem.unmarked` says the grouping is broken.\n */\n subsystem: string[];\n}\n\nexport interface SubsystemEntry {\n /** The directory name — the subsystem's name in the flat namespace. */\n name: string;\n /** Names from `services/` down to and including this one. */\n path: string[];\n /** Absolute path of the subsystem directory. */\n dir: string;\n meta: SubsystemMeta;\n}\n\nexport interface FleetTree {\n services: WalkedService[];\n subsystems: SubsystemEntry[];\n findings: Finding[];\n}\n\nexport interface TreeWalkRequest {\n docsDir: DocsDir;\n /** Does this entry name a service artifact? `repo/paths.ts`'s table, injected — the module doc says why. */\n isServiceArtifact: (name: string, kind: \"file\" | \"dir\") => boolean;\n}\n\ninterface WalkContext {\n request: TreeWalkRequest;\n tree: FleetTree;\n /** Realpaths already DESCENDED into — the symlink-cycle guard. */\n visited: Set<string>;\n /** Every name in the flat namespace → the directories that claim it. */\n names: Map<string, string[]>;\n}\n\n/** One directory the walk is looking at: where it is, and how it is reached. */\ninterface Spot {\n dir: string;\n name: string;\n /** Directory names between `services/` and this one, outermost first. */\n chain: string[];\n}\n\nexport async function walkServicesTree(request: TreeWalkRequest): Promise<FleetTree> {\n const ctx: WalkContext = {\n request,\n tree: { services: [], subsystems: [], findings: [] },\n visited: new Set(),\n names: new Map(),\n };\n const root = join(request.docsDir, \"services\");\n guardDescent(root, ctx);\n for (const name of await subdirNames(root)) {\n await visitDir({ dir: join(root, name), name, chain: [] }, ctx);\n }\n namespaceFindings(ctx);\n return ctx.tree;\n}\n\n/**\n * Mark a directory as descended-into; false when it (or another name for it)\n * already was. Guards SUBTREES, not entries: a directory reachable under two\n * names is classified twice — both names exist and hiding the second would be\n * the silent shrink this module refuses — but its children are enumerated\n * once, so a symlink cycle terminates instead of recursing forever. A path\n * realpath cannot resolve is one whose children cannot be read either.\n */\nfunction guardDescent(dir: string, ctx: WalkContext): boolean {\n let real: string;\n try {\n real = realpathSync(dir);\n } catch {\n return false;\n }\n if (ctx.visited.has(real)) return false;\n ctx.visited.add(real);\n return true;\n}\n\n/** Sorted non-dot subdirectory names — the walk's deterministic child order. */\nasync function subdirNames(dir: string): Promise<string[]> {\n const entries = await readdir(dir, { withFileTypes: true });\n return entries.filter((e) => !e.name.startsWith(\".\") && entryIs(dir, e, \"dir\")).map((e) => e.name).sort();\n}\n\n/** Classify one directory and, for the group kinds, walk what is inside it. */\nasync function visitDir(spot: Spot, ctx: WalkContext): Promise<void> {\n const rel = [\"services\", ...spot.chain, spot.name].join(\"/\");\n const entries = (await readdir(spot.dir, { withFileTypes: true })).filter((e) => !e.name.startsWith(\".\"));\n const files = entries.filter((e) => entryIs(spot.dir, e, \"file\")).map((e) => e.name);\n const all = entries.filter((e) => entryIs(spot.dir, e, \"dir\")).map((e) => e.name).sort();\n // The classifiable children — a service's own interior removed once, here, so\n // every branch below agrees about it (see `RESERVED_INTERIOR`). `all` is still\n // what the artifact probe reads: `adrs/` makes its parent a service, and\n // `usecases/` is interior the parent may hold without becoming a group.\n const dirs = all.filter((d) => !RESERVED_INTERIOR.has(d));\n const marker = files.includes(SUBSYSTEM_MARKER);\n const artifact =\n files.some((f) => ctx.request.isServiceArtifact(f, \"file\")) ||\n all.some((d) => ctx.request.isServiceArtifact(d, \"dir\"));\n\n if (marker && artifact) {\n // Artifacts win the classification: the documents are somebody's work and\n // the marker is one stray file, so the directory stays an addressable\n // service and the finding names the contradiction instead of the walk\n // quietly picking a reading. But the walk STILL descends (the module\n // invariant: no reinterpretation may shrink the fleet) — the marker says\n // a GROUP was here, and returning without walking made every service\n // beneath it vanish with only this finding to hint why. Child directories\n // that are themselves artifacts (`adrs/`) are service interior, not walked.\n recordService(spot, ctx);\n const stranded = await descendInto(spot, dirs.filter((d) => !ctx.request.isServiceArtifact(d, \"dir\")), ctx);\n ctx.tree.findings.push({\n severity: \"error\",\n code: \"subsystem.marker-misplaced\",\n subject: spot.name,\n message:\n `${rel}/ holds ${SUBSYSTEM_MARKER} BESIDE service artifacts — a directory is a subsystem or a service, never both. ` +\n `It stays classified as a service; delete ${rel}/${SUBSYSTEM_MARKER}, ` +\n `or move the artifacts into a service directory of their own inside the group.` +\n (stranded.length > 0 ? ` ${stranded.length} service(s) beneath it stay enumerated meanwhile: ${stranded.map((s) => s.id).join(\", \")}.` : \"\"),\n ...(stranded.length > 0 ? { details: stranded.map(serviceTreePath) } : {}),\n });\n return;\n }\n if (marker) {\n await visitSubsystem(spot, { rel, dirs }, ctx);\n return;\n }\n if (artifact) {\n recordService(spot, ctx);\n return;\n }\n if (dirs.length > 0) {\n await visitUnmarked(spot, { rel, dirs }, ctx);\n return;\n }\n // Neither marker nor artifact, and nothing beneath: today's flat behaviour —\n // an empty (or not-yet-adopted) directory under services/ is a service.\n recordService(spot, ctx);\n}\n\nfunction recordService(spot: Spot, ctx: WalkContext): void {\n ctx.tree.services.push({\n id: rawServiceId(spot.name),\n dir: serviceDirOf(spot.dir),\n subsystem: spot.chain,\n });\n claimName(spot, ctx);\n}\n\n/** A subsystem: record it, report a broken marker or an illegal name, walk in. */\nasync function visitSubsystem(spot: Spot, at: { rel: string; dirs: string[] }, ctx: WalkContext): Promise<void> {\n const meta = await readSubsystemMarker(join(spot.dir, SUBSYSTEM_MARKER));\n if (meta.invalid !== undefined) {\n // Exactly one finding per marker file: presence already classified the\n // directory, so the content failure is metadata, not identity.\n ctx.tree.findings.push({\n severity: \"error\",\n code: \"subsystem.invalid\",\n subject: at.rel,\n message:\n `${at.rel}/${SUBSYSTEM_MARKER} cannot be read as subsystem metadata — ${meta.invalid}. ` +\n `The directory still counts as a subsystem (presence classifies; content is metadata). ` +\n `Fix the file: a YAML mapping with optional title, description and owner — an empty file is also a valid marker.`,\n });\n }\n const problem = subsystemNameProblem(spot.name, \"subsystem directory name\");\n if (problem !== null) {\n // Walked anyway, mirroring how an illegal service id stays enumerated\n // (`idProblem`): the directory exists and hiding its subtree would shrink\n // the fleet over a naming mistake.\n ctx.tree.findings.push({\n severity: \"error\",\n code: \"subsystem.name-invalid\",\n subject: spot.name,\n message: `${at.rel}/ — ${problem} Rename the directory; its services stay enumerated meanwhile.`,\n });\n }\n ctx.tree.subsystems.push({\n name: spot.name,\n path: [...spot.chain, spot.name],\n dir: spot.dir,\n meta,\n });\n claimName(spot, ctx);\n await descendInto(spot, at.dirs, ctx);\n}\n\n/**\n * Walk the children of a group directory (symlink-cycle guarded), returning\n * the services newly recorded beneath it — the ONE spelling of descent, so\n * the three descending branches agree about the guard and the chain.\n */\nasync function descendInto(spot: Spot, dirs: string[], ctx: WalkContext): Promise<WalkedService[]> {\n const before = ctx.tree.services.length;\n if (guardDescent(spot.dir, ctx)) {\n for (const child of dirs) {\n await visitDir({ dir: join(spot.dir, child), name: child, chain: [...spot.chain, spot.name] }, ctx);\n }\n }\n return ctx.tree.services.slice(before);\n}\n\n/**\n * The refusal branch — a group directory nothing marks. The walk STILL\n * descends, so the services beneath stay in the enumeration and the finding\n * can name them: the fleet must never be reported smaller than it is.\n */\nasync function visitUnmarked(spot: Spot, at: { rel: string; dirs: string[] }, ctx: WalkContext): Promise<void> {\n const stranded = await descendInto(spot, at.dirs, ctx);\n ctx.tree.findings.push({\n severity: \"error\",\n code: \"subsystem.unmarked\",\n subject: at.rel,\n message:\n `${at.rel}/ is neither a subsystem (no ${SUBSYSTEM_MARKER}) nor a service (no service artifact), ` +\n `but ${stranded.length} service(s) sit beneath it` +\n (stranded.length > 0 ? `: ${stranded.map((s) => s.id).join(\", \")}. ` : `. `) +\n `Without the marker this directory would be read as a service and everything under it would vanish from the fleet — ` +\n `the shape an ordinary merge produces when one branch deletes an emptied subsystem's marker while another moves a service in. ` +\n `Restore ${at.rel}/${SUBSYSTEM_MARKER} (an empty file is a valid marker), or move the directories out.`,\n ...(stranded.length > 0 ? { details: stranded.map(serviceTreePath) } : {}),\n });\n}\n\nfunction claimName(spot: Spot, ctx: WalkContext): void {\n const rel = [\"services\", ...spot.chain, spot.name].join(\"/\");\n ctx.names.set(spot.name, [...(ctx.names.get(spot.name) ?? []), rel]);\n}\n\n/**\n * One flat namespace: every subsystem name and service id, unique across the\n * whole tree at any depth, never colliding with each other. The identity\n * decision demands it — a service id is its leaf directory name ALONE, so two\n * leaves spelling one name are one identity claimed twice, wherever they sit.\n * One finding per name, naming every claimant, so the fix is a choice rather\n * than a scavenger hunt.\n */\nfunction namespaceFindings(ctx: WalkContext): void {\n for (const [name, dirs] of [...ctx.names].sort(([a], [b]) => (a < b ? -1 : 1))) {\n if (dirs.length < 2) continue;\n ctx.tree.findings.push({\n severity: \"error\",\n code: \"subsystem.name-collision\",\n subject: name,\n message:\n `'${name}' is claimed by ${dirs.length} directories in the services/ tree: ${dirs.join(\", \")} — ` +\n `service ids and subsystem names share one flat namespace, unique at any depth, ` +\n `because a service's identity is its leaf directory name alone and placement never disambiguates it. ` +\n `Rename all but one.`,\n details: dirs,\n });\n }\n}\n"]}
|