@ybotok/loam 0.2.0-alpha.5 → 0.2.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +901 -0
- package/MIGRATING-from-OpenSpec.md +7 -4
- package/QUICKSTART.md +8 -3
- package/README.md +13 -7
- package/ROADMAP.md +264 -11
- package/SCHEMA.md +439 -38
- package/WORKFLOW.md +23 -10
- package/dist/commands/adopt/render.js +47 -8
- package/dist/commands/adopt/render.js.map +1 -1
- package/dist/commands/archive/plan/landscape.js +45 -1
- package/dist/commands/archive/plan/landscape.js.map +1 -1
- package/dist/commands/archive/plan/model/extending.js +85 -0
- package/dist/commands/archive/plan/model/extending.js.map +1 -0
- package/dist/commands/archive/run.js +5 -2
- package/dist/commands/archive/run.js.map +1 -1
- package/dist/commands/context/print.js +11 -1
- package/dist/commands/context/print.js.map +1 -1
- package/dist/commands/doctor.js +46 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/explore.js +12 -2
- package/dist/commands/explore.js.map +1 -1
- package/dist/commands/list/views.js +5 -1
- package/dist/commands/list/views.js.map +1 -1
- package/dist/commands/seed/plan.js +97 -3
- package/dist/commands/seed/plan.js.map +1 -1
- package/dist/commands/seed/seed.js +7 -2
- package/dist/commands/seed/seed.js.map +1 -1
- package/dist/commands/show/feature.js +2 -1
- package/dist/commands/show/feature.js.map +1 -1
- package/dist/commands/show/marks.js +3 -5
- package/dist/commands/show/marks.js.map +1 -1
- package/dist/commands/show/service.js +39 -18
- package/dist/commands/show/service.js.map +1 -1
- package/dist/commands/subsystem/sync.js +128 -74
- package/dist/commands/subsystem/sync.js.map +1 -1
- package/dist/commands/subsystem/txn/exclude.js +132 -0
- package/dist/commands/subsystem/txn/exclude.js.map +1 -0
- package/dist/commands/subsystem/txn/report.js +124 -0
- package/dist/commands/subsystem/txn/report.js.map +1 -0
- package/dist/commands/subsystem/txn/txn.js +10 -3
- package/dist/commands/subsystem/txn/txn.js.map +1 -1
- package/dist/commands/subsystem/txn/views.js +113 -16
- package/dist/commands/subsystem/txn/views.js.map +1 -1
- package/dist/commands/validate/arch-coverage.js +16 -9
- package/dist/commands/validate/arch-coverage.js.map +1 -1
- package/dist/commands/validate/checks/fleet-shape.js +30 -77
- package/dist/commands/validate/checks/fleet-shape.js.map +1 -1
- package/dist/commands/validate/checks/vocabulary.js +4 -19
- package/dist/commands/validate/checks/vocabulary.js.map +1 -1
- package/dist/commands/validate/feature.js +1 -1
- package/dist/commands/validate/feature.js.map +1 -1
- package/dist/commands/validate/fleet/census.js +11 -10
- package/dist/commands/validate/fleet/census.js.map +1 -1
- package/dist/commands/validate/fleet/landscape.js +127 -119
- package/dist/commands/validate/fleet/landscape.js.map +1 -1
- package/dist/commands/validate/fleet/load.js +66 -0
- package/dist/commands/validate/fleet/load.js.map +1 -1
- package/dist/commands/validate/fleet/map/attest.js +103 -0
- package/dist/commands/validate/fleet/map/attest.js.map +1 -0
- package/dist/commands/validate/fleet/map/bindings.js +96 -0
- package/dist/commands/validate/fleet/map/bindings.js.map +1 -0
- package/dist/commands/validate/fleet/map/consumers.js +298 -0
- package/dist/commands/validate/fleet/map/consumers.js.map +1 -0
- package/dist/commands/validate/fleet/map/isolation.js +116 -0
- package/dist/commands/validate/fleet/map/isolation.js.map +1 -0
- package/dist/commands/validate/fleet/map/usecases.js +56 -0
- package/dist/commands/validate/fleet/map/usecases.js.map +1 -0
- package/dist/commands/validate/fleet/usecases/steps.js +49 -17
- package/dist/commands/validate/fleet/usecases/steps.js.map +1 -1
- package/dist/commands/validate/fleet/views/fleet-project.js +217 -0
- package/dist/commands/validate/fleet/views/fleet-project.js.map +1 -0
- package/dist/commands/validate/fleet/views/ids.js +13 -9
- package/dist/commands/validate/fleet/views/ids.js.map +1 -1
- package/dist/commands/validate/fleet/views/projects.js +258 -70
- package/dist/commands/validate/fleet/views/projects.js.map +1 -1
- package/dist/commands/validate/fleet/views/root-documents.js +97 -0
- package/dist/commands/validate/fleet/views/root-documents.js.map +1 -0
- package/dist/commands/validate/fleet/views/stale.js +2 -2
- package/dist/commands/validate/fleet/views/stale.js.map +1 -1
- package/dist/commands/validate/service/model/diverged.js +226 -0
- package/dist/commands/validate/service/model/diverged.js.map +1 -0
- package/dist/commands/validate/service/model/grade.js +163 -0
- package/dist/commands/validate/service/model/grade.js.map +1 -0
- package/dist/commands/validate/service/service.js +110 -34
- package/dist/commands/validate/service/service.js.map +1 -1
- package/dist/commands/validate/service/specs.js +39 -5
- package/dist/commands/validate/service/specs.js.map +1 -1
- package/dist/commands/validate/service/spine.js +41 -3
- package/dist/commands/validate/service/spine.js.map +1 -1
- package/dist/commands/validate/service/usecases/tags.js +113 -0
- package/dist/commands/validate/service/usecases/tags.js.map +1 -0
- package/dist/commands/validate/service/usecases/usecases.js +209 -0
- package/dist/commands/validate/service/usecases/usecases.js.map +1 -0
- package/dist/commands/validate/validate.js +10 -24
- package/dist/commands/validate/validate.js.map +1 -1
- package/dist/core/agent/agents-md/artifacts.js +1 -0
- package/dist/core/agent/agents-md/artifacts.js.map +1 -1
- package/dist/core/agent/agents-md/command-map.js +18 -4
- package/dist/core/agent/agents-md/command-map.js.map +1 -1
- package/dist/core/agent/agents-md/cycle.js +24 -9
- package/dist/core/agent/agents-md/cycle.js.map +1 -1
- package/dist/core/agent/agents-md/map/lenses/context.js +4 -3
- package/dist/core/agent/agents-md/map/lenses/context.js.map +1 -1
- package/dist/core/agent/agents-md/map/subsystem.js +51 -11
- package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
- package/dist/core/agent/agents-md/map/usecases.js +45 -3
- package/dist/core/agent/agents-md/map/usecases.js.map +1 -1
- package/dist/core/agent/agents-md.js +14 -4
- package/dist/core/agent/agents-md.js.map +1 -1
- package/dist/core/agent/support/report.js +39 -5
- package/dist/core/agent/support/report.js.map +1 -1
- package/dist/core/agent/workflows/adopt.js +123 -14
- package/dist/core/agent/workflows/adopt.js.map +1 -1
- package/dist/core/agent/workflows/check.js +35 -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/brief/brief.js +63 -11
- 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 +30 -29
- package/dist/core/brief/targets.js.map +1 -1
- package/dist/core/brief/unchecked.js +12 -2
- package/dist/core/brief/unchecked.js.map +1 -1
- package/dist/core/c4/likec4.js +23 -1
- package/dist/core/c4/likec4.js.map +1 -1
- package/dist/core/c4/parsed/deployment.js +5 -3
- package/dist/core/c4/parsed/deployment.js.map +1 -1
- package/dist/core/c4/parsed/view-ids.js +39 -1
- package/dist/core/c4/parsed/view-ids.js.map +1 -1
- package/dist/core/c4/prefetch/fleet.js +142 -0
- package/dist/core/c4/prefetch/fleet.js.map +1 -0
- package/dist/core/c4/project/architecture.js +52 -4
- package/dist/core/c4/project/architecture.js.map +1 -1
- package/dist/core/c4/project/batch.js +140 -0
- package/dist/core/c4/project/batch.js.map +1 -0
- package/dist/core/c4/project/documents.js +9 -4
- package/dist/core/c4/project/documents.js.map +1 -1
- package/dist/core/c4/project/load.js +77 -38
- package/dist/core/c4/project/load.js.map +1 -1
- package/dist/core/c4/project/staged.js +46 -12
- package/dist/core/c4/project/staged.js.map +1 -1
- package/dist/core/c4/resolve/attested.js +0 -0
- package/dist/core/c4/resolve/attested.js.map +1 -0
- package/dist/core/c4/resolve/service.js +8 -17
- package/dist/core/c4/resolve/service.js.map +1 -1
- package/dist/core/c4/resolve/steps.js +18 -0
- package/dist/core/c4/resolve/steps.js.map +1 -1
- package/dist/core/c4/root-project/exclude.js +352 -0
- package/dist/core/c4/root-project/exclude.js.map +1 -0
- package/dist/core/c4/service-model/documents.js +47 -0
- package/dist/core/c4/service-model/documents.js.map +1 -0
- package/dist/core/c4/service-model/fleet/extending.js +82 -0
- package/dist/core/c4/service-model/fleet/extending.js.map +1 -0
- package/dist/core/c4/service-model/load.js +165 -0
- package/dist/core/c4/service-model/load.js.map +1 -0
- package/dist/core/c4/service-model/renderer.js +128 -0
- package/dist/core/c4/service-model/renderer.js.map +1 -0
- package/dist/core/c4/service-model/shape.js +126 -0
- package/dist/core/c4/service-model/shape.js.map +1 -0
- package/dist/core/c4/service-model/slice.js +188 -0
- package/dist/core/c4/service-model/slice.js.map +1 -0
- package/dist/core/c4/source-scan.js +26 -0
- package/dist/core/c4/source-scan.js.map +1 -1
- package/dist/core/c4/splice/authored-source.js +60 -13
- package/dist/core/c4/splice/authored-source.js.map +1 -1
- package/dist/core/c4/splice/contract.js.map +1 -1
- package/dist/core/c4/splice/identity/declared.js +87 -0
- package/dist/core/c4/splice/identity/declared.js.map +1 -0
- package/dist/core/c4/splice/identity/edges.js +17 -0
- package/dist/core/c4/splice/identity/edges.js.map +1 -1
- package/dist/core/c4/splice/identity/existing.js +80 -0
- package/dist/core/c4/splice/identity/existing.js.map +1 -0
- package/dist/core/c4/splice/identity/interior.js +80 -0
- package/dist/core/c4/splice/identity/interior.js.map +1 -0
- package/dist/core/c4/splice/identity/owner.js +50 -0
- package/dist/core/c4/splice/identity/owner.js.map +1 -0
- package/dist/core/c4/splice/landscape-merge.js +154 -100
- package/dist/core/c4/splice/landscape-merge.js.map +1 -1
- package/dist/core/c4/splice/model/merge.js +140 -0
- package/dist/core/c4/splice/model/merge.js.map +1 -0
- package/dist/core/c4/splice/placement.js +14 -4
- package/dist/core/c4/splice/placement.js.map +1 -1
- package/dist/core/c4/workspace.js +2 -1
- package/dist/core/c4/workspace.js.map +1 -1
- package/dist/core/coherence/coherence.js +7 -0
- package/dist/core/coherence/coherence.js.map +1 -1
- package/dist/core/delta/delta.js +9 -0
- package/dist/core/delta/delta.js.map +1 -1
- package/dist/core/deployment/delta.js +4 -0
- package/dist/core/deployment/delta.js.map +1 -1
- package/dist/core/diff/base-state.js +21 -5
- package/dist/core/diff/base-state.js.map +1 -1
- package/dist/core/diff/victims.js +10 -1
- package/dist/core/diff/victims.js.map +1 -1
- package/dist/core/docs.js +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/partners.js +5 -9
- package/dist/core/gate/partners.js.map +1 -1
- package/dist/core/kernel/ids/fqn/ancestors.js +65 -0
- package/dist/core/kernel/ids/fqn/ancestors.js.map +1 -0
- package/dist/core/pack/living.js +47 -13
- package/dist/core/pack/living.js.map +1 -1
- package/dist/core/pack/pack.js +9 -6
- package/dist/core/pack/pack.js.map +1 -1
- package/dist/core/projection/arch-slice.js +2 -2
- package/dist/core/projection/arch-slice.js.map +1 -1
- package/dist/core/repo/paths.js +11 -9
- package/dist/core/repo/paths.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/stale.js +2 -3
- package/dist/core/repo/tree/render/stale.js.map +1 -1
- package/dist/core/repo/tree/render/views.js +51 -3
- package/dist/core/repo/tree/render/views.js.map +1 -1
- package/dist/core/repo/tree/walk.js +45 -2
- package/dist/core/repo/tree/walk.js.map +1 -1
- package/dist/core/scaffold/landscape.js +219 -9
- package/dist/core/scaffold/landscape.js.map +1 -1
- package/dist/core/staging/writes.js.map +1 -1
- package/dist/core/usecases/delta/claims.js +1 -0
- package/dist/core/usecases/delta/claims.js.map +1 -1
- package/dist/core/usecases/delta/overlay.js +4 -0
- package/dist/core/usecases/delta/overlay.js.map +1 -1
- package/dist/core/usecases/fleet.js +18 -6
- package/dist/core/usecases/fleet.js.map +1 -1
- package/dist/core/usecases/operations.js +7 -0
- package/dist/core/usecases/operations.js.map +1 -1
- package/dist/core/usecases/service/exclude.js +119 -0
- package/dist/core/usecases/service/exclude.js.map +1 -0
- package/dist/core/usecases/service/flows.js +161 -0
- package/dist/core/usecases/service/flows.js.map +1 -0
- package/dist/core/vocabulary/maturity.js +10 -0
- package/dist/core/vocabulary/maturity.js.map +1 -1
- package/examples/README.md +24 -6
- package/examples/docs/architecture/landscape.likec4 +41 -0
- package/examples/docs/architecture/usecases/order-notification.likec4 +5 -2
- package/examples/docs/likec4.config.json +0 -1
- package/examples/docs/services/checkout-web/model.likec4 +39 -56
- package/examples/docs/services/order-service/arch.spec.md +13 -6
- package/examples/docs/services/order-service/model.likec4 +53 -67
- package/examples/docs/services/payment-service/arch.spec.md +7 -4
- package/examples/docs/services/payment-service/model.likec4 +32 -35
- package/examples/docs/services/platform/identity-service/arch.spec.md +7 -4
- package/examples/docs/services/platform/identity-service/model.likec4 +29 -33
- package/examples/docs/services/platform/notification-service/health.yaml +8 -3
- package/examples/docs/services/platform/notification-service/model.likec4 +39 -59
- package/package.json +1 -1
- 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
|
@@ -2,34 +2,71 @@
|
|
|
2
2
|
* The renderer's project file for ONE service — `services/<…>/<id>/likec4.config.json`
|
|
3
3
|
* — the second thing under `services/` that exists only to be rendered.
|
|
4
4
|
*
|
|
5
|
-
* Why it exists
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* a
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
5
|
+
* Why it exists, and WHICH MODELS IT IS FOR. A model that STANDS ALONE declares
|
|
6
|
+
* its own `specification` block, so it is parsed alone and the root project must
|
|
7
|
+
* exclude its directory — a renderer merging it into the map reports every kind
|
|
8
|
+
* and element it declares as a duplicate, blamed on both files. The cost of that
|
|
9
|
+
* exclusion went unstated for a release: an adopted service's model — the
|
|
10
|
+
* artifact `loam adopt` spends the most effort on — was a box on the fleet map
|
|
11
|
+
* with nothing renderable inside it from the docs root, and the documented way
|
|
12
|
+
* to see it was one renderer invocation per service directory. Measured at the
|
|
13
|
+
* 1.59.2 pin: a `likec4.config.json` INSIDE a service directory is registered as
|
|
14
|
+
* a project of its own even though the root excludes the directory (the root's
|
|
15
|
+
* `exclude` does not hide a nested project file), the workspace then loads
|
|
16
|
+
* `fleet` plus one project per service with zero errors, and each service
|
|
17
|
+
* project carries LikeC4's own `index` view of the containers beside whatever
|
|
18
|
+
* view the model authors. One two-key file per such directory is therefore what
|
|
19
|
+
* makes that model renderable from the same root, in the same renderer, beside
|
|
20
|
+
* the map.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
22
|
+
* A model that EXTENDS the map is owed NO file, and that is the same
|
|
23
|
+
* measurement read the other way: it declares no kinds, it lives in the ROOT
|
|
24
|
+
* project beside the map, and a nested project file beside it TAKES it out of
|
|
25
|
+
* there — measured at the 1.59.2 pin, the workspace loads two projects and, where
|
|
26
|
+
* the nested one claims the model, it holds it alone (the map's kinds are not in
|
|
27
|
+
* there with it, so it does not resolve) and `export json --project fleet` loses
|
|
28
|
+
* the service's containers. So the survey below takes the shape as a predicate, the writer
|
|
29
|
+
* creates a file only for the standalone half and removes one beside the
|
|
30
|
+
* extending half, and `service.likec4-config-stray` grades what is left.
|
|
31
|
+
*
|
|
32
|
+
* NEVER COMPARED, NEVER REWRITTEN — the root config's ownership rule
|
|
33
|
+
* (`core/docs.ts`: written once, never re-read, the team owns it), NOT the
|
|
34
|
+
* generated views file's. The bytes are a function of the service id alone,
|
|
35
|
+
* which SCHEMA.md defines as the identity no verb ever changes, so nothing can
|
|
36
|
+
* make the file stale; a compare would only fight the per-project keys LikeC4
|
|
37
|
+
* lets a team add (`title`, styles, a contact), and would restale every service
|
|
38
|
+
* on any change to this rule. Presence is the one question loam owes the file,
|
|
39
|
+
* and presence needs no parse: rule 26 stands, and loam still reads none of it.
|
|
40
|
+
*
|
|
41
|
+
* REMOVED IN EXACTLY ONE STATE: beside a model that EXTENDS the map. That is a
|
|
42
|
+
* correction, and it is measured rather than reasoned. The design note said a
|
|
43
|
+
* nested project beside an extending model was registered EMPTY and therefore
|
|
44
|
+
* harmless; on likec4 1.59.2 two agents measured the opposite (verification
|
|
45
|
+
* 2026-09-04, W1) — the nested project CLAIMS the model, the workspace loads
|
|
46
|
+
* "2 projects", the model is Invalid inside its own one (`Could not resolve
|
|
47
|
+
* reference to Element named 'svc_svc_a'`, because the map is not in there with
|
|
48
|
+
* it), and `export json --project fleet` drops the service's containers. So the
|
|
49
|
+
* file is not a leftover, it is a working renderer taken away from the fleet,
|
|
50
|
+
* and `loam subsystem sync` deletes it — which is what SCHEMA, the message and
|
|
51
|
+
* `/loam-adopt` step 5 had been promising all along.
|
|
52
|
+
*
|
|
53
|
+
* THE EXPORT LOSS IS THE WORSE HALF, NOT THE CERTAIN ONE, and the finding's
|
|
54
|
+
* message says so since it was re-measured (re-verification 2026-09-04, area C
|
|
55
|
+
* item 7). Same pin, two fleets: on the seeded fleet above the containers went
|
|
56
|
+
* and came back when the file was deleted; on `examples/docs` a stray beside the
|
|
57
|
+
* extending order-service left `export json --project fleet` byte-identical —
|
|
58
|
+
* 33 elements, every `marketplace.orderService.*` child present — because there
|
|
59
|
+
* the ROOT project kept the model. What is true in every state measured is the
|
|
60
|
+
* second project itself: the docs root stops being a one-project workspace, and
|
|
61
|
+
* `likec4 validate .` goes from ✓ Valid to ✗ Invalid ("Specify exact project,
|
|
62
|
+
* known: [order-service, fleet]"). A message that asserted the export loss
|
|
63
|
+
* unconditionally was refutable on the repository's own example fleet.
|
|
64
|
+
*
|
|
65
|
+
* A leftover beside a model that is GONE stays. That one really is the team's:
|
|
66
|
+
* a nested project holding no `.likec4` file is silently absent from the
|
|
67
|
+
* renderer's project list (measured), so it costs nothing, and a survey that
|
|
68
|
+
* deleted files in directories it found no model in would be a writer acting on
|
|
69
|
+
* an absence.
|
|
33
70
|
*
|
|
34
71
|
* THE NAME. LikeC4's project-name grammar (measured at the pin) refuses an
|
|
35
72
|
* empty name, the word `default`, and the bytes `.`, `@` and `#`; loam's
|
|
@@ -103,9 +140,8 @@ export function renderServiceProject(id, rootProject) {
|
|
|
103
140
|
* plain lexicographic like every list this package emits, so the writer's
|
|
104
141
|
* report and the grader's findings come out in one stable order.
|
|
105
142
|
*/
|
|
106
|
-
export function surveyProjects(services, pathsOf) {
|
|
107
|
-
const
|
|
108
|
-
let modelled = 0;
|
|
143
|
+
export function surveyProjects(services, pathsOf, isStandalone) {
|
|
144
|
+
const models = [];
|
|
109
145
|
for (const service of services) {
|
|
110
146
|
// The banner's rule: a name the renderer would refuse earns no file, and
|
|
111
147
|
// `service.id-invalid` — not this survey — is what names the repair.
|
|
@@ -114,11 +150,33 @@ export function surveyProjects(services, pathsOf) {
|
|
|
114
150
|
const paths = pathsOf(service.dir);
|
|
115
151
|
if (!existsSync(paths.model))
|
|
116
152
|
continue;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
153
|
+
models.push({
|
|
154
|
+
service,
|
|
155
|
+
path: paths.project,
|
|
156
|
+
// INJECTED rather than read here, and the reason is this package's
|
|
157
|
+
// standing one: `render/` may not import `c4/` (see `./stale.ts` on the
|
|
158
|
+
// cycle), and the shape is a fact about the model's GRAMMAR that
|
|
159
|
+
// `c4/service-model/shape.ts` owns. A predicate crossing the boundary
|
|
160
|
+
// keeps one scanner for the writer and the grader alike.
|
|
161
|
+
standalone: isStandalone(paths.model),
|
|
162
|
+
configured: existsSync(paths.project),
|
|
163
|
+
});
|
|
120
164
|
}
|
|
121
|
-
|
|
122
|
-
return {
|
|
165
|
+
models.sort((a, b) => (a.service.id < b.service.id ? -1 : a.service.id > b.service.id ? 1 : 0));
|
|
166
|
+
return {
|
|
167
|
+
// A model that EXTENDS the map is owed no project file: it belongs to the
|
|
168
|
+
// root project, and a nested project file beside it CLAIMS it — the model
|
|
169
|
+
// then parses alone, where the map's kinds are not, and the fleet project
|
|
170
|
+
// loses the service's interior. So the gap set narrowed to the standalone
|
|
171
|
+
// shape, and `strays` below is the grade's and the writer's other direction.
|
|
172
|
+
gaps: models.filter((m) => m.standalone && !m.configured).map(({ service, path }) => ({ service, path })),
|
|
173
|
+
// The other direction, and the one the banner's correction is about: the
|
|
174
|
+
// nested project claims the model and the fleet project loses the service's
|
|
175
|
+
// interior, so the file is removed rather than tolerated.
|
|
176
|
+
strays: models.filter((m) => !m.standalone && m.configured).map(({ service, path }) => ({ service, path })),
|
|
177
|
+
models,
|
|
178
|
+
modelled: models.length,
|
|
179
|
+
standalone: models.filter((m) => m.standalone).length,
|
|
180
|
+
};
|
|
123
181
|
}
|
|
124
182
|
//# sourceMappingURL=projects.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"projects.js","sourceRoot":"","sources":["../../../../../src/core/repo/tree/render/projects.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoEG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AAE/D,gFAAgF;AAChF,MAAM,uBAAuB,GAAG,SAAS,CAAC;AAE1C;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,EAAU,EAAE,WAAmB;IAChE,MAAM,MAAM,GAAG,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;IACtC,OAAO,MAAM,KAAK,WAAW,IAAI,MAAM,KAAK,uBAAuB,CAAC,CAAC,CAAC,WAAW,MAAM,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;AACrG,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,EAAU,EAAE,WAAmB;IAClE,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,kBAAkB,CAAC,EAAE,EAAE,WAAW,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC;AAClG,CAAC;AA6BD;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,cAAc,CAC5B,QAAsB,EACtB,OAAgE;IAEhE,MAAM,IAAI,GAAoB,EAAE,CAAC;IACjC,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,yEAAyE;QACzE,qEAAqE;QACrE,IAAI,aAAa,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,IAAI;YAAE,SAAS;QACjD,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACnC,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC;YAAE,SAAS;QACvC,QAAQ,IAAI,CAAC,CAAC;QACd,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,OAAO,CAAC;YAAE,IAAI,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;IAC9E,CAAC;IACD,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9F,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;AAC5B,CAAC","sourcesContent":["/**\n * The renderer's project file for ONE service — `services/<…>/<id>/likec4.config.json`\n * — the second thing under `services/` that exists only to be rendered.\n *\n * Why it exists. The root `likec4.config.json` scopes the fleet project to\n * `architecture/` and excludes `services/**`, and must: loam parses every\n * `.likec4` file ALONE, so each `model.likec4` declares its own `specification`\n * block, and a renderer merging the tree reports every declaration as a\n * duplicate. The cost of that exclusion went unstated for a release: an\n * adopted service's model — the artifact `loam adopt` spends the most effort\n * on — was a box on the fleet map with nothing renderable inside it from the\n * docs root, and the documented way to see it was one renderer invocation per\n * service directory. Measured at the 1.59.2 pin: a `likec4.config.json` INSIDE\n * a service directory is registered as a project of its own even though the\n * root excludes the directory (the root's `exclude` does not hide a nested\n * project file), the workspace then loads `fleet` plus one project per service\n * with zero errors, and each service project carries LikeC4's own `index` view\n * of the containers beside whatever view the model authors. One two-key file\n * per service directory is therefore what makes the model renderable from the\n * same root, in the same renderer, beside the map.\n *\n * CREATE-ONLY, never compared, never rewritten, never removed — the root\n * config's ownership rule (`core/docs.ts`: written once, never re-read, the\n * team owns it), NOT the generated views file's. The bytes are a function of\n * the service id alone, which SCHEMA.md defines as the identity no verb ever\n * changes, so nothing can make the file stale; a compare would only fight the\n * per-project keys LikeC4 lets a team add (`title`, styles, a contact), and\n * would restale every service on any change to this rule. Presence is the one\n * question loam owes the file, and presence needs no parse: rule 26 stands,\n * and loam still reads none of it. A leftover file after a model is deleted is\n * the team's, and harmless — measured, a nested project holding no `.likec4`\n * file is silently absent from the renderer's project list.\n *\n * THE NAME. LikeC4's project-name grammar (measured at the pin) refuses an\n * empty name, the word `default`, and the bytes `.`, `@` and `#`; loam's\n * service-id grammar admits `.`. So every `.` becomes `-`, and a name that\n * would then equal the ROOT project's (`fleet` — a nested project of that name\n * is silently registered as `fleet-1`) or LikeC4's reserved `default` is\n * prefixed `service-`. Readable and typeable after `--project`, and NOT\n * injective: an id spelled a.b beside a service literally named a-b, or\n * `service-fleet` beside `fleet`, share a name and the renderer suffixes the\n * later one `-1`. That is disclosed rather than escaped away, because the\n * picker shows the `title` (measured: `title ?? id`), which is ALWAYS the id\n * verbatim, and a collision costs one suffixed label rather than the blanked\n * workspace that made `subsystemViewId` injective. The root's name arrives as\n * a parameter, like every spelling this package needs from `repo/`: `render/`\n * cannot import `repo/paths.ts` (`stale.ts` records the cycle), and a second\n * copy of the word `fleet` here would be the drift the injection prevents.\n *\n * A DIRECTORY THAT IS NOT A LEGAL SERVICE ID IS OWED NO FILE. The tree walk\n * enumerates every directory on purpose — the fleet is never reported smaller\n * than it is — and `service.id-invalid` names the illegal ones. Folding only\n * `.` is right for every LEGAL id, whose remaining bytes LikeC4 accepts; a\n * name like `pay@1` would produce a project file the renderer silently drops\n * (measured: zero errors, the project simply absent from the list), and\n * because the file is create-only the grade below would then be silent\n * forever while the model stayed unrendered — the exact state this file\n * exists to remove, with a label saying it was fixed. So the survey skips\n * such a service: the writer creates nothing, the grader says nothing, and\n * `service.id-invalid`'s own repair — the rename — is what earns the file on\n * the first sync after it.\n *\n * NO MODEL, NO FILE: a service directory without `model.likec4` — one an\n * archive materialised, a spec-only baseline — is owed nothing, and gets its\n * file on the first `loam subsystem sync` after the model lands. A feature's\n * `delta.likec4` is deliberately out of scope: the directory is transient and\n * `loam archive` moves it where a project file would still be registered, so\n * every shipped feature would keep a dead project in the list forever.\n */\nimport { existsSync } from \"node:fs\";\nimport type { ServiceDir } from \"../../../kernel/ids/dirs.js\";\nimport { dirNameHazard } from \"../../../kernel/ids/service.js\";\n\n/** LikeC4's own reserved project name; a file naming it is refused outright. */\nconst LIKEC4_RESERVED_PROJECT = \"default\";\n\n/**\n * The project name for a service id — the id itself for every id made of\n * letters, digits, `_` and `-`, which is nearly all of them.\n */\nexport function serviceProjectName(id: string, rootProject: string): string {\n const folded = id.replace(/\\./g, \"-\");\n return folded === rootProject || folded === LIKEC4_RESERVED_PROJECT ? `service-${folded}` : folded;\n}\n\n/**\n * The exact bytes of a service's project file: two keys, two-space JSON, LF,\n * one trailing newline — the root file's own serialisation. `title` is the id\n * verbatim, always, so whatever the renderer labels a project with is the\n * service's real identity even where `name` had to bend.\n */\nexport function renderServiceProject(id: string, rootProject: string): string {\n return `${JSON.stringify({ name: serviceProjectName(id, rootProject), title: id }, null, 2)}\\n`;\n}\n\n/**\n * A service whose model has no project file beside it, and where the file\n * goes. The SERVICE travels whole — a walked service or a full entry,\n * whichever the caller surveyed — rather than its id: the grader spells the\n * finding's path from the entry's own placement, and a join back by id would\n * name the wrong directory the moment two services share a leaf name under\n * different subsystems (`subsystem.name-collision` — a broken tree the walk\n * still enumerates in full, and one this grade is emitted on by design).\n */\nexport interface ProjectGap<T> {\n service: T;\n /** Absolute path of the file `loam subsystem sync` will create. */\n path: string;\n}\n\n/** What one survey of the tree found: the gaps, and the domain they were counted against. */\nexport interface ProjectSurvey<T> {\n /** Every service owed a file and lacking one, sorted by id. */\n gaps: ProjectGap<T>[];\n /**\n * Every service the survey considered at all — a model on disk, a legal id\n * — file or no file. `modelled - gaps.length` is therefore \"already had\n * one\", derived from the same walk rather than counted a second way.\n */\n modelled: number;\n}\n\n/**\n * ONE predicate for the writer (`subsystem sync`) and the grader\n * (`validate --all`), on `viewsState`'s precedent — two private spellings of\n * \"is this model a project\" would be two chances to answer differently about\n * one file, and a grader naming a gap the writer would not fill is a loop no\n * command can clear. Both the gaps AND the count live here for the same\n * reason: a caller re-deriving \"has a model\" beside this function is the\n * second spelling in another coat.\n *\n * Structural over `{ id, dir }` so a walked service and a full entry both fit,\n * and the two file spellings arrive injected (`repo/paths.ts`'s\n * `serviceRenderPaths`) for the package reason the banner gives. Sorted by id,\n * plain lexicographic like every list this package emits, so the writer's\n * report and the grader's findings come out in one stable order.\n */\nexport function surveyProjects<T extends { id: string; dir: ServiceDir }>(\n services: readonly T[],\n pathsOf: (dir: ServiceDir) => { model: string; project: string },\n): ProjectSurvey<T> {\n const gaps: ProjectGap<T>[] = [];\n let modelled = 0;\n for (const service of services) {\n // The banner's rule: a name the renderer would refuse earns no file, and\n // `service.id-invalid` — not this survey — is what names the repair.\n if (dirNameHazard(service.id) !== null) continue;\n const paths = pathsOf(service.dir);\n if (!existsSync(paths.model)) continue;\n modelled += 1;\n if (!existsSync(paths.project)) gaps.push({ service, path: paths.project });\n }\n gaps.sort((a, b) => (a.service.id < b.service.id ? -1 : a.service.id > b.service.id ? 1 : 0));\n return { gaps, modelled };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"projects.js","sourceRoot":"","sources":["../../../../../src/core/repo/tree/render/projects.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AAE/D,gFAAgF;AAChF,MAAM,uBAAuB,GAAG,SAAS,CAAC;AAE1C;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,EAAU,EAAE,WAAmB;IAChE,MAAM,MAAM,GAAG,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;IACtC,OAAO,MAAM,KAAK,WAAW,IAAI,MAAM,KAAK,uBAAuB,CAAC,CAAC,CAAC,WAAW,MAAM,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;AACrG,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,EAAU,EAAE,WAAmB;IAClE,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,kBAAkB,CAAC,EAAE,EAAE,WAAW,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC;AAClG,CAAC;AA2ED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,cAAc,CAC5B,QAAsB,EACtB,OAAgE,EAChE,YAA4C;IAE5C,MAAM,MAAM,GAAuB,EAAE,CAAC;IACtC,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,yEAAyE;QACzE,qEAAqE;QACrE,IAAI,aAAa,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,IAAI;YAAE,SAAS;QACjD,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACnC,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC;YAAE,SAAS;QACvC,MAAM,CAAC,IAAI,CAAC;YACV,OAAO;YACP,IAAI,EAAE,KAAK,CAAC,OAAO;YACnB,mEAAmE;YACnE,wEAAwE;YACxE,iEAAiE;YACjE,sEAAsE;YACtE,yDAAyD;YACzD,UAAU,EAAE,YAAY,CAAC,KAAK,CAAC,KAAK,CAAC;YACrC,UAAU,EAAE,UAAU,CAAC,KAAK,CAAC,OAAO,CAAC;SACtC,CAAC,CAAC;IACL,CAAC;IACD,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAChG,OAAO;QACL,0EAA0E;QAC1E,0EAA0E;QAC1E,0EAA0E;QAC1E,0EAA0E;QAC1E,6EAA6E;QAC7E,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;QACzG,yEAAyE;QACzE,4EAA4E;QAC5E,0DAA0D;QAC1D,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;QAC3G,MAAM;QACN,QAAQ,EAAE,MAAM,CAAC,MAAM;QACvB,UAAU,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,MAAM;KACtD,CAAC;AACJ,CAAC","sourcesContent":["/**\n * The renderer's project file for ONE service — `services/<…>/<id>/likec4.config.json`\n * — the second thing under `services/` that exists only to be rendered.\n *\n * Why it exists, and WHICH MODELS IT IS FOR. A model that STANDS ALONE declares\n * its own `specification` block, so it is parsed alone and the root project must\n * exclude its directory — a renderer merging it into the map reports every kind\n * and element it declares as a duplicate, blamed on both files. The cost of that\n * exclusion went unstated for a release: an adopted service's model — the\n * artifact `loam adopt` spends the most effort on — was a box on the fleet map\n * with nothing renderable inside it from the docs root, and the documented way\n * to see it was one renderer invocation per service directory. Measured at the\n * 1.59.2 pin: a `likec4.config.json` INSIDE a service directory is registered as\n * a project of its own even though the root excludes the directory (the root's\n * `exclude` does not hide a nested project file), the workspace then loads\n * `fleet` plus one project per service with zero errors, and each service\n * project carries LikeC4's own `index` view of the containers beside whatever\n * view the model authors. One two-key file per such directory is therefore what\n * makes that model renderable from the same root, in the same renderer, beside\n * the map.\n *\n * A model that EXTENDS the map is owed NO file, and that is the same\n * measurement read the other way: it declares no kinds, it lives in the ROOT\n * project beside the map, and a nested project file beside it TAKES it out of\n * there — measured at the 1.59.2 pin, the workspace loads two projects and, where\n * the nested one claims the model, it holds it alone (the map's kinds are not in\n * there with it, so it does not resolve) and `export json --project fleet` loses\n * the service's containers. So the survey below takes the shape as a predicate, the writer\n * creates a file only for the standalone half and removes one beside the\n * extending half, and `service.likec4-config-stray` grades what is left.\n *\n * NEVER COMPARED, NEVER REWRITTEN — the root config's ownership rule\n * (`core/docs.ts`: written once, never re-read, the team owns it), NOT the\n * generated views file's. The bytes are a function of the service id alone,\n * which SCHEMA.md defines as the identity no verb ever changes, so nothing can\n * make the file stale; a compare would only fight the per-project keys LikeC4\n * lets a team add (`title`, styles, a contact), and would restale every service\n * on any change to this rule. Presence is the one question loam owes the file,\n * and presence needs no parse: rule 26 stands, and loam still reads none of it.\n *\n * REMOVED IN EXACTLY ONE STATE: beside a model that EXTENDS the map. That is a\n * correction, and it is measured rather than reasoned. The design note said a\n * nested project beside an extending model was registered EMPTY and therefore\n * harmless; on likec4 1.59.2 two agents measured the opposite (verification\n * 2026-09-04, W1) — the nested project CLAIMS the model, the workspace loads\n * \"2 projects\", the model is Invalid inside its own one (`Could not resolve\n * reference to Element named 'svc_svc_a'`, because the map is not in there with\n * it), and `export json --project fleet` drops the service's containers. So the\n * file is not a leftover, it is a working renderer taken away from the fleet,\n * and `loam subsystem sync` deletes it — which is what SCHEMA, the message and\n * `/loam-adopt` step 5 had been promising all along.\n *\n * THE EXPORT LOSS IS THE WORSE HALF, NOT THE CERTAIN ONE, and the finding's\n * message says so since it was re-measured (re-verification 2026-09-04, area C\n * item 7). Same pin, two fleets: on the seeded fleet above the containers went\n * and came back when the file was deleted; on `examples/docs` a stray beside the\n * extending order-service left `export json --project fleet` byte-identical —\n * 33 elements, every `marketplace.orderService.*` child present — because there\n * the ROOT project kept the model. What is true in every state measured is the\n * second project itself: the docs root stops being a one-project workspace, and\n * `likec4 validate .` goes from ✓ Valid to ✗ Invalid (\"Specify exact project,\n * known: [order-service, fleet]\"). A message that asserted the export loss\n * unconditionally was refutable on the repository's own example fleet.\n *\n * A leftover beside a model that is GONE stays. That one really is the team's:\n * a nested project holding no `.likec4` file is silently absent from the\n * renderer's project list (measured), so it costs nothing, and a survey that\n * deleted files in directories it found no model in would be a writer acting on\n * an absence.\n *\n * THE NAME. LikeC4's project-name grammar (measured at the pin) refuses an\n * empty name, the word `default`, and the bytes `.`, `@` and `#`; loam's\n * service-id grammar admits `.`. So every `.` becomes `-`, and a name that\n * would then equal the ROOT project's (`fleet` — a nested project of that name\n * is silently registered as `fleet-1`) or LikeC4's reserved `default` is\n * prefixed `service-`. Readable and typeable after `--project`, and NOT\n * injective: an id spelled a.b beside a service literally named a-b, or\n * `service-fleet` beside `fleet`, share a name and the renderer suffixes the\n * later one `-1`. That is disclosed rather than escaped away, because the\n * picker shows the `title` (measured: `title ?? id`), which is ALWAYS the id\n * verbatim, and a collision costs one suffixed label rather than the blanked\n * workspace that made `subsystemViewId` injective. The root's name arrives as\n * a parameter, like every spelling this package needs from `repo/`: `render/`\n * cannot import `repo/paths.ts` (`stale.ts` records the cycle), and a second\n * copy of the word `fleet` here would be the drift the injection prevents.\n *\n * A DIRECTORY THAT IS NOT A LEGAL SERVICE ID IS OWED NO FILE. The tree walk\n * enumerates every directory on purpose — the fleet is never reported smaller\n * than it is — and `service.id-invalid` names the illegal ones. Folding only\n * `.` is right for every LEGAL id, whose remaining bytes LikeC4 accepts; a\n * name like `pay@1` would produce a project file the renderer silently drops\n * (measured: zero errors, the project simply absent from the list), and\n * because the file is create-only the grade below would then be silent\n * forever while the model stayed unrendered — the exact state this file\n * exists to remove, with a label saying it was fixed. So the survey skips\n * such a service: the writer creates nothing, the grader says nothing, and\n * `service.id-invalid`'s own repair — the rename — is what earns the file on\n * the first sync after it.\n *\n * NO MODEL, NO FILE: a service directory without `model.likec4` — one an\n * archive materialised, a spec-only baseline — is owed nothing, and gets its\n * file on the first `loam subsystem sync` after the model lands. A feature's\n * `delta.likec4` is deliberately out of scope: the directory is transient and\n * `loam archive` moves it where a project file would still be registered, so\n * every shipped feature would keep a dead project in the list forever.\n */\nimport { existsSync } from \"node:fs\";\nimport type { ServiceDir } from \"../../../kernel/ids/dirs.js\";\nimport { dirNameHazard } from \"../../../kernel/ids/service.js\";\n\n/** LikeC4's own reserved project name; a file naming it is refused outright. */\nconst LIKEC4_RESERVED_PROJECT = \"default\";\n\n/**\n * The project name for a service id — the id itself for every id made of\n * letters, digits, `_` and `-`, which is nearly all of them.\n */\nexport function serviceProjectName(id: string, rootProject: string): string {\n const folded = id.replace(/\\./g, \"-\");\n return folded === rootProject || folded === LIKEC4_RESERVED_PROJECT ? `service-${folded}` : folded;\n}\n\n/**\n * The exact bytes of a service's project file: two keys, two-space JSON, LF,\n * one trailing newline — the root file's own serialisation. `title` is the id\n * verbatim, always, so whatever the renderer labels a project with is the\n * service's real identity even where `name` had to bend.\n */\nexport function renderServiceProject(id: string, rootProject: string): string {\n return `${JSON.stringify({ name: serviceProjectName(id, rootProject), title: id }, null, 2)}\\n`;\n}\n\n/**\n * A service whose model has no project file beside it, and where the file\n * goes. The SERVICE travels whole — a walked service or a full entry,\n * whichever the caller surveyed — rather than its id: the grader spells the\n * finding's path from the entry's own placement, and a join back by id would\n * name the wrong directory the moment two services share a leaf name under\n * different subsystems (`subsystem.name-collision` — a broken tree the walk\n * still enumerates in full, and one this grade is emitted on by design).\n */\nexport interface ProjectGap<T> {\n service: T;\n /** Absolute path of the file `loam subsystem sync` will create. */\n path: string;\n}\n\n/**\n * One surveyed model: everything the writer and the four renderer grades ask\n * about a single `model.likec4`, answered in one walk.\n *\n * The SHAPE is on here rather than re-derived per grade because it decides\n * opposite things for the two of them: a model that stands alone is owed a\n * project file and must be excluded from the root project, and a model that\n * extends the map is owed neither and must NOT be excluded. Two readers of the\n * same bytes would be two chances to answer that backwards, and backwards is\n * not a warning — it is a renderer that shows nothing.\n */\nexport interface SurveyedModel<T> {\n service: T;\n /** Absolute path of the per-service project file, whether or not it exists. */\n path: string;\n /** True when the model declares its own element kinds — the caller's byte scan, injected. */\n standalone: boolean;\n /** True when a `likec4.config.json` already sits beside the model. */\n configured: boolean;\n}\n\n/** What one survey of the tree found: the gaps, the models behind them, and the domain they were counted against. */\nexport interface ProjectSurvey<T> {\n /** Every service owed a file and lacking one, sorted by id. */\n gaps: ProjectGap<T>[];\n /**\n * Every service owed NO file that has one anyway — an extending model with a\n * `likec4.config.json` beside it, sorted by id. The mirror of `gaps`, and the\n * same shape, because the writer does the same thing with it: `subsystem sync`\n * plans a delete where it plans a create, and `service.likec4-config-stray`\n * grades what is left. One predicate for both, so the grader can never name a\n * stray the writer would decline to remove.\n */\n strays: ProjectGap<T>[];\n /** Every model the survey considered, sorted by id — the four renderer grades walk this. */\n models: SurveyedModel<T>[];\n /**\n * Every service the survey considered at all — a model on disk, a legal id\n * — file or no file, whichever shape the model has. It is what the\n * root-missing note is owed to: without the root project file NOTHING under\n * `services/` is written, and the count a person needs there is how many\n * models the run walked past.\n */\n modelled: number;\n /**\n * The subset of those whose model STANDS ALONE — the only services owed a\n * project file at all, and therefore the domain `gaps` was drawn from.\n *\n * Apart from `modelled` because the two stopped meaning the same thing when a\n * model could extend the map, and one subtraction rested on their being\n * equal: `modelled - gaps.length` was \"already had a file\", and it counted\n * every extending model as one — a fleet that had just migrated reported N\n * services holding a file that must not exist. `standalone - gaps.length` is\n * the same subtraction over the domain that is actually owed the file.\n */\n standalone: number;\n}\n\n/**\n * ONE predicate for the writer (`subsystem sync`) and the grader\n * (`validate --all`), on `viewsState`'s precedent — two private spellings of\n * \"is this model a project\" would be two chances to answer differently about\n * one file, and a grader naming a gap the writer would not fill is a loop no\n * command can clear. Both the gaps AND the count live here for the same\n * reason: a caller re-deriving \"has a model\" beside this function is the\n * second spelling in another coat.\n *\n * Structural over `{ id, dir }` so a walked service and a full entry both fit,\n * and the two file spellings arrive injected (`repo/paths.ts`'s\n * `serviceRenderPaths`) for the package reason the banner gives. Sorted by id,\n * plain lexicographic like every list this package emits, so the writer's\n * report and the grader's findings come out in one stable order.\n */\nexport function surveyProjects<T extends { id: string; dir: ServiceDir }>(\n services: readonly T[],\n pathsOf: (dir: ServiceDir) => { model: string; project: string },\n isStandalone: (modelPath: string) => boolean,\n): ProjectSurvey<T> {\n const models: SurveyedModel<T>[] = [];\n for (const service of services) {\n // The banner's rule: a name the renderer would refuse earns no file, and\n // `service.id-invalid` — not this survey — is what names the repair.\n if (dirNameHazard(service.id) !== null) continue;\n const paths = pathsOf(service.dir);\n if (!existsSync(paths.model)) continue;\n models.push({\n service,\n path: paths.project,\n // INJECTED rather than read here, and the reason is this package's\n // standing one: `render/` may not import `c4/` (see `./stale.ts` on the\n // cycle), and the shape is a fact about the model's GRAMMAR that\n // `c4/service-model/shape.ts` owns. A predicate crossing the boundary\n // keeps one scanner for the writer and the grader alike.\n standalone: isStandalone(paths.model),\n configured: existsSync(paths.project),\n });\n }\n models.sort((a, b) => (a.service.id < b.service.id ? -1 : a.service.id > b.service.id ? 1 : 0));\n return {\n // A model that EXTENDS the map is owed no project file: it belongs to the\n // root project, and a nested project file beside it CLAIMS it — the model\n // then parses alone, where the map's kinds are not, and the fleet project\n // loses the service's interior. So the gap set narrowed to the standalone\n // shape, and `strays` below is the grade's and the writer's other direction.\n gaps: models.filter((m) => m.standalone && !m.configured).map(({ service, path }) => ({ service, path })),\n // The other direction, and the one the banner's correction is about: the\n // nested project claims the model and the fleet project loses the service's\n // interior, so the file is removed rather than tolerated.\n strays: models.filter((m) => !m.standalone && m.configured).map(({ service, path }) => ({ service, path })),\n models,\n modelled: models.length,\n standalone: models.filter((m) => m.standalone).length,\n };\n}\n"]}
|
|
@@ -16,10 +16,9 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { existsSync } from "node:fs";
|
|
18
18
|
import { readFile } from "node:fs/promises";
|
|
19
|
-
import {} from "../../../c4/likec4.js";
|
|
20
19
|
import { renderSubsystemViews, viewsAgree } from "./views.js";
|
|
21
|
-
export async function viewsState(path, tree,
|
|
22
|
-
const expected = renderSubsystemViews(tree,
|
|
20
|
+
export async function viewsState(path, tree, map) {
|
|
21
|
+
const expected = renderSubsystemViews(tree, map);
|
|
23
22
|
const actual = existsSync(path) ? await readFile(path, "utf8") : null;
|
|
24
23
|
return { actual, expected, agrees: viewsAgree(actual, expected) };
|
|
25
24
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stale.js","sourceRoot":"","sources":["../../../../../src/core/repo/tree/render/stale.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"stale.js","sourceRoot":"","sources":["../../../../../src/core/repo/tree/render/stale.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,OAAO,EAAiB,oBAAoB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAgB7E,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAY,EAAE,IAAe,EAAE,GAAa;IAC3E,MAAM,QAAQ,GAAG,oBAAoB,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACjD,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACtE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE,CAAC;AACpE,CAAC","sourcesContent":["/**\n * Is the generated file on disk what this tree and this map render to?\n *\n * One function, because the question now has three askers — `validate --all`\n * (which turns a disagreement into `subsystem.views-stale`), `loam subsystem\n * sync` and the write verbs (which repair it), and `loam archive` (which must\n * not report a repo current when it has just made the file stale). Three\n * private compares would be three chances to answer differently about one\n * file, and the module that mints the bytes is the one that owes the answer.\n *\n * The PATH arrives as a parameter rather than a `DocsDir`: `repo/paths.ts`\n * owns the spelling of every file in a docs repo, and importing it here would\n * close the package cycle `repo → repo/tree → repo/tree/render → repo` that\n * `scripts/package-graph.mjs` exists to refuse. `walk.ts` pays the same price\n * for the same reason and says so.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport type { FleetTree } from \"../walk.js\";\nimport { type MapFacts, renderSubsystemViews, viewsAgree } from \"./views.js\";\n\n/**\n * The three states, kept apart: absent, must-be-absent, and\n * present-but-different are three different sentences, and a fleet whose views\n * file does not exist is not a fleet whose views file is blank.\n */\nexport interface ViewsState {\n /** The bytes on disk, or null when the file is not there. */\n actual: string | null;\n /** The bytes the tree renders to, or null when the file must be absent. */\n expected: string | null;\n /** Do the two say the same thing? Content, not bytes — `viewsAgree` says why. */\n agrees: boolean;\n}\n\nexport async function viewsState(path: string, tree: FleetTree, map: MapFacts): Promise<ViewsState> {\n const expected = renderSubsystemViews(tree, map);\n const actual = existsSync(path) ? await readFile(path, \"utf8\") : null;\n return { actual, expected, agrees: viewsAgree(actual, expected) };\n}\n"]}
|
|
@@ -50,6 +50,27 @@
|
|
|
50
50
|
import {} from "../../../c4/likec4.js";
|
|
51
51
|
import { renderContext, viewBody } from "./body.js";
|
|
52
52
|
import { likec4String, viewTitle } from "./text.js";
|
|
53
|
+
/**
|
|
54
|
+
* The global style group a generated view references when the map declares
|
|
55
|
+
* one: `global { styleGroup subsystems { … } }` (or the single-rule form
|
|
56
|
+
* `global { style subsystems … }` — LikeC4 files both under one id table).
|
|
57
|
+
* Presence is the opt-in and it is read off a DECLARATION, never a view: a
|
|
58
|
+
* generated view cannot carry an inline `style` rule of its own (the file is
|
|
59
|
+
* regenerated wholesale), and the one mechanism LikeC4 offers a view for
|
|
60
|
+
* borrowing a repository's palette is a reference to a global style by id.
|
|
61
|
+
* Referencing one that is not declared is a parse error that blanks the whole
|
|
62
|
+
* `architecture/` project in the renderer, which is why the line is written
|
|
63
|
+
* only when the id is declared and why its absence leaves the file exactly
|
|
64
|
+
* what it was before this constant existed.
|
|
65
|
+
*
|
|
66
|
+
* One conventional name rather than "the single declared group": a second
|
|
67
|
+
* group somebody declares for a hand-drawn view would otherwise silently
|
|
68
|
+
* switch the generated views' styling OFF and restale the file on a change
|
|
69
|
+
* that touched no subsystem — behaviour flipping on an unrelated declaration
|
|
70
|
+
* is the shape `body.ts` calls "a picture that looks the same whether or not
|
|
71
|
+
* the two agree". The name mirrors the file it styles.
|
|
72
|
+
*/
|
|
73
|
+
export const SUBSYSTEM_STYLE_GROUP = "subsystems";
|
|
53
74
|
/**
|
|
54
75
|
* The header every generated file opens with — stable bytes, part of the
|
|
55
76
|
* byte-compare contract, naming the regenerator so a reader who found the
|
|
@@ -59,16 +80,35 @@ const HEADER = "// generated by loam — the subsystem tree under services/, one
|
|
|
59
80
|
"// Do not edit: `loam subsystem sync` regenerates this file from the directories,\n" +
|
|
60
81
|
"// and `loam validate --all` reports `subsystem.views-stale` when it drifts.\n" +
|
|
61
82
|
"// A scoping aid, not the fleet's architecture: author your own diagrams elsewhere.\n";
|
|
83
|
+
/**
|
|
84
|
+
* The one file-level comment the render may write: the map declares global
|
|
85
|
+
* style groups, and none is the conventional one. Written between `HEADER`
|
|
86
|
+
* and `views {` — outside every view body, graded by nothing — so a team
|
|
87
|
+
* that already declared a palette under another name reads the fix in the
|
|
88
|
+
* diff of the sync that regenerated the file, instead of wondering why the
|
|
89
|
+
* generated views ignore it. Silent when the map declares no group at all: a
|
|
90
|
+
* fleet with no palette is owed no sentence about one. Ids go unescaped
|
|
91
|
+
* because LikeC4's grammar admits only identifier bytes in a style id.
|
|
92
|
+
*/
|
|
93
|
+
function stylesDisclosure(declared) {
|
|
94
|
+
if (declared.length === 0 || declared.includes(SUBSYSTEM_STYLE_GROUP))
|
|
95
|
+
return null;
|
|
96
|
+
return (`// styles: the project declares global style group(s) ${[...declared].sort().join(", ")}, ` +
|
|
97
|
+
`and none named \`${SUBSYSTEM_STYLE_GROUP}\` — these views carry the renderer's defaults until one is.`);
|
|
98
|
+
}
|
|
62
99
|
/**
|
|
63
100
|
* Render the views file for a walked tree, or null when the tree has no
|
|
64
101
|
* subsystems — the file must then be ABSENT, so a flat fleet never carries a
|
|
65
102
|
* generated artifact that says nothing.
|
|
66
103
|
*/
|
|
67
|
-
export function renderSubsystemViews(tree,
|
|
104
|
+
export function renderSubsystemViews(tree, map) {
|
|
68
105
|
if (tree.subsystems.length === 0)
|
|
69
106
|
return null;
|
|
70
|
-
const ctx = renderContext(tree, elements);
|
|
71
|
-
const
|
|
107
|
+
const ctx = renderContext(tree, map.elements);
|
|
108
|
+
const declared = map.globalStyles ?? [];
|
|
109
|
+
const styled = declared.includes(SUBSYSTEM_STYLE_GROUP);
|
|
110
|
+
const disclosure = stylesDisclosure(declared);
|
|
111
|
+
const lines = [HEADER, ...(disclosure === null ? [] : [disclosure]), "views {"];
|
|
72
112
|
const subsystems = [...tree.subsystems].sort((a, b) => (pathKey(a) < pathKey(b) ? -1 : 1));
|
|
73
113
|
for (const [i, sub] of subsystems.entries()) {
|
|
74
114
|
if (i > 0)
|
|
@@ -87,6 +127,14 @@ export function renderSubsystemViews(tree, elements) {
|
|
|
87
127
|
const description = likec4String(sub.meta.description ?? "");
|
|
88
128
|
if (description !== "")
|
|
89
129
|
lines.push(` description '${description}'`);
|
|
130
|
+
// After the label, before anything that includes: measured at the pin
|
|
131
|
+
// (test/likec4-view-shape.test.ts), a `global style <id>` line here
|
|
132
|
+
// parses with zero errors, while the same line before `title` is the
|
|
133
|
+
// parse error the comment above records. The palette is the map's own —
|
|
134
|
+
// the group is declared in the same project this file renders into — so
|
|
135
|
+
// the reference resolves exactly when the presence test says it does.
|
|
136
|
+
if (styled)
|
|
137
|
+
lines.push(` global style ${SUBSYSTEM_STYLE_GROUP}`);
|
|
90
138
|
lines.push(...body.lines);
|
|
91
139
|
lines.push(" }");
|
|
92
140
|
}
|
|
@@ -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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,OAAO,EAAkB,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAGpD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,YAAY,CAAC;AAYlD;;;;GAIG;AACH,MAAM,MAAM,GACV,sFAAsF;IACtF,qFAAqF;IACrF,gFAAgF;IAChF,uFAAuF,CAAC;AAE1F;;;;;;;;;GASG;AACH,SAAS,gBAAgB,CAAC,QAA2B;IACnD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,qBAAqB,CAAC;QAAE,OAAO,IAAI,CAAC;IACnF,OAAO,CACL,yDAAyD,CAAC,GAAG,QAAQ,CAAC,CAAC,IAAI,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI;QAC5F,oBAAoB,qBAAqB,8DAA8D,CACxG,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAAe,EAAE,GAAa;IACjE,IAAI,IAAI,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAC9C,MAAM,GAAG,GAAG,aAAa,CAAC,IAAI,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC9C,MAAM,QAAQ,GAAG,GAAG,CAAC,YAAY,IAAI,EAAE,CAAC;IACxC,MAAM,MAAM,GAAG,QAAQ,CAAC,QAAQ,CAAC,qBAAqB,CAAC,CAAC;IACxD,MAAM,UAAU,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAC;IAC9C,MAAM,KAAK,GAAa,CAAC,MAAM,EAAE,GAAG,CAAC,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC;IAC1F,MAAM,UAAU,GAAG,CAAC,GAAG,IAAI,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC3F,KAAK,MAAM,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC;QAC5C,IAAI,CAAC,GAAG,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC1B,KAAK,CAAC,IAAI,CAAC,iBAAiB,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAC5C,MAAM,IAAI,GAAG,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QAChC,IAAI,IAAI,CAAC,UAAU,KAAK,IAAI;YAAE,KAAK,CAAC,IAAI,CAAC,QAAQ,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC;QACpE,KAAK,CAAC,IAAI,CAAC,UAAU,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC/C,uEAAuE;QACvE,oEAAoE;QACpE,mEAAmE;QACnE,+DAA+D;QAC/D,2CAA2C;QAC3C,KAAK,CAAC,IAAI,CAAC,cAAc,SAAS,CAAC,UAAU,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC;QAC9D,MAAM,WAAW,GAAG,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC;QAC7D,IAAI,WAAW,KAAK,EAAE;YAAE,KAAK,CAAC,IAAI,CAAC,oBAAoB,WAAW,GAAG,CAAC,CAAC;QACvE,sEAAsE;QACtE,oEAAoE;QACpE,qEAAqE;QACrE,wEAAwE;QACxE,wEAAwE;QACxE,sEAAsE;QACtE,IAAI,MAAM;YAAE,KAAK,CAAC,IAAI,CAAC,oBAAoB,qBAAqB,EAAE,CAAC,CAAC;QACpE,KAAK,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;QAC1B,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACpB,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAChB,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;AACjC,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,UAAU,CAAC,IAAe,EAAE,GAAmB;IACtD,OAAO,IAAI,CAAC,UAAU;SACnB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC;SAClG,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AACnD,CAAC;AAED,0FAA0F;AAC1F,SAAS,OAAO,CAAC,GAAmB;IAClC,OAAO,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC5B,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,YAAY,CAAC;AAElD,MAAM,UAAU,eAAe,CAAC,GAAmB;IACjD,MAAM,OAAO,GAAG,CAAC,IAAY,EAAU,EAAE,CACvC,CAAC,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,CAAC,CAAC,CAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACzH,OAAO,aAAa,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;AACzD,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,UAAU,UAAU,CAAC,MAAqB,EAAE,QAAuB;IACvE,MAAM,EAAE,GAAG,CAAC,IAAmB,EAAiB,EAAE,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC,CAAC;IAC3G,OAAO,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,QAAQ,CAAC,CAAC;AACrC,CAAC","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 { 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, and the global style ids a view may reference. A record rather\n * than a third parameter because the two 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 */\nexport type MapFacts = Pick<LoadedDoc, \"elements\" | \"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 */\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 * 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 */\nfunction stylesDisclosure(declared: readonly string[]): string | null {\n if (declared.length === 0 || declared.includes(SUBSYSTEM_STYLE_GROUP)) return null;\n return (\n `// styles: the project declares global style group(s) ${[...declared].sort().join(\", \")}, ` +\n `and none named \\`${SUBSYSTEM_STYLE_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);\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 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 // 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 * 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"]}
|
|
@@ -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"]}
|