@ybotok/loam 0.2.0-alpha.4 → 0.2.0-alpha.5

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/MIGRATING-from-OpenSpec.md +4 -1
  3. package/README.md +11 -10
  4. package/ROADMAP.md +70 -16
  5. package/SCHEMA.md +53 -6
  6. package/dist/commands/subsystem/sync.js +110 -19
  7. package/dist/commands/subsystem/sync.js.map +1 -1
  8. package/dist/commands/subsystem/txn/txn.js +19 -0
  9. package/dist/commands/subsystem/txn/txn.js.map +1 -1
  10. package/dist/commands/validate/fleet/landscape.js +6 -0
  11. package/dist/commands/validate/fleet/landscape.js.map +1 -1
  12. package/dist/commands/validate/fleet/views/projects.js +90 -0
  13. package/dist/commands/validate/fleet/views/projects.js.map +1 -0
  14. package/dist/core/agent/agents-md/command-map.js +3 -1
  15. package/dist/core/agent/agents-md/command-map.js.map +1 -1
  16. package/dist/core/agent/agents-md/map/subsystem.js +10 -1
  17. package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
  18. package/dist/core/agent/workflows/adopt.js +13 -3
  19. package/dist/core/agent/workflows/adopt.js.map +1 -1
  20. package/dist/core/agent/workflows/check.js +1 -0
  21. package/dist/core/agent/workflows/check.js.map +1 -1
  22. package/dist/core/brief/checks.js +9 -0
  23. package/dist/core/brief/checks.js.map +1 -1
  24. package/dist/core/brief/targets.js +7 -4
  25. package/dist/core/brief/targets.js.map +1 -1
  26. package/dist/core/brief/unchecked.js +1 -1
  27. package/dist/core/brief/unchecked.js.map +1 -1
  28. package/dist/core/docs.js +11 -7
  29. package/dist/core/docs.js.map +1 -1
  30. package/dist/core/doctor/doctor.js +17 -6
  31. package/dist/core/doctor/doctor.js.map +1 -1
  32. package/dist/core/explain/families.js +1 -1
  33. package/dist/core/explain/families.js.map +1 -1
  34. package/dist/core/repo/paths.js +38 -0
  35. package/dist/core/repo/paths.js.map +1 -1
  36. package/dist/core/repo/tree/render/projects.js +124 -0
  37. package/dist/core/repo/tree/render/projects.js.map +1 -0
  38. package/examples/README.md +6 -1
  39. package/examples/docs/services/checkout-web/likec4.config.json +4 -0
  40. package/examples/docs/services/order-service/likec4.config.json +4 -0
  41. package/examples/docs/services/payment-service/likec4.config.json +4 -0
  42. package/examples/docs/services/payment-service/model.likec4 +4 -2
  43. package/examples/docs/services/platform/identity-service/likec4.config.json +4 -0
  44. package/examples/docs/services/platform/notification-service/likec4.config.json +4 -0
  45. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -10,6 +10,52 @@ case for a change — the alternative that was rejected, the defect it came from
10
10
  generalises — lives where it is maintained: [SCHEMA.md](SCHEMA.md) for the rule,
11
11
  [ROADMAP.md](ROADMAP.md) for the priority and its exit criteria, and the commit that landed it._
12
12
 
13
+ ## [0.2.0-alpha.5] - 2026-09-03
14
+
15
+ An adopted service's model renders from the docs root. Still a prerelease on the `alpha` line:
16
+ `npm i @ybotok/loam@alpha` installs it. Nothing below is breaking. What a repo with adopted services
17
+ NOTICES: one `service.likec4-config-missing` warning per service on its first `validate --all`
18
+ until one `loam subsystem sync`, and from that sync on `--project` for `likec4 validate` at the
19
+ docs root.
20
+
21
+ ### Added — an adopted service's model renders from the docs root
22
+
23
+ The root `likec4.config.json` excludes `services/**`, and has to; what that cost was never stated:
24
+ an adopted service's `model.likec4` — the artifact `loam adopt` spends the most effort on — was a
25
+ box on the fleet map with nothing renderable inside it in the renderer opened at the docs root, and
26
+ the only documented way to see its containers was one `npx likec4 start services/<id>` per service.
27
+ One problem report; one file per service closes it.
28
+
29
+ - `loam subsystem sync` now writes `services/<…>/<svc>/likec4.config.json` beside every service
30
+ `model.likec4` that has none, whenever the root `likec4.config.json` exists — **create-only**: a
31
+ file that exists is yours and is never compared, rewritten or removed. Measured at the pinned
32
+ likec4, a project file inside a service directory registers the model as a project of its own
33
+ regardless of the root's exclusion, so `npx likec4 start` at the docs root shows `fleet` plus one
34
+ project per service. Without the root file nothing is written and the text says `loam doctor`
35
+ prints it. `subsystem sync --json` carries an additive `projects` key, `{ root, created, current }`
36
+ — whether the root file exists, the repo-relative paths written this run (sorted by id), and how
37
+ many model-bearing services already had one. `action` still describes
38
+ `architecture/subsystems.likec4` alone.
39
+ - `loam validate --all` gains one warning per service, `service.likec4-config-missing`, on the
40
+ `landscape` target with the service's tree path as its `scope`: `model.likec4` exists and the
41
+ project file does not. Never an error, never gating, never in `--service`, `status` or `doctor`;
42
+ the fix is `loam subsystem sync`, never a hand-written file. `doctor.likec4-config-missing`'s fix
43
+ text now hands over to `sync` once the root file is written.
44
+ - The project name: the id with every `.` folded to `-` (LikeC4's name grammar refuses the byte),
45
+ prefixed `service-` when that would equal the root project's `fleet` or LikeC4's reserved
46
+ `default`; `title` is the id verbatim, and the title is what the renderer's picker shows. Not
47
+ injective — an id spelled billing.core beside one spelled billing-core, or `service-fleet` beside
48
+ `fleet` — and LikeC4 suffixes the later one `-1`; disclosed, never graded.
49
+
50
+ What a repo NOTICES on upgrade: one warning per adopted service on the first `validate --all` until
51
+ one `loam subsystem sync` — under `--strict`, CI is red until then; after that `sync`, N untracked
52
+ files (`sync` does not `git add` them) and `move-uncommitted` on the next `loam subsystem move` of
53
+ such a service until they are committed; from the first `sync` on, `likec4 validate` at the docs
54
+ root needs `--project <name>` (measured: with more than one project it refuses bare, while
55
+ `likec4 build` and `likec4 export` take every project and need no flag); and the renderer's project
56
+ list grows by one per adopted service, beside `fleet`. No command, flag, exit code or envelope key
57
+ changes; `projects` is additive.
58
+
13
59
  ## [0.2.0-alpha.4] - 2026-09-03
14
60
 
15
61
  The view title becomes the subsystem's path. `0.2.0-alpha.3` was tagged and **never published** — it
@@ -312,7 +312,10 @@ services, loam frontmatter, Operations/Covers links and feature identity still r
312
312
  specs intentionally remain `status: draft`.
313
313
  - `Operations:` links to provider OpenAPI `operationId`s, and `Covers:` links where architecture
314
314
  requirements are created.
315
- - `architecture/landscape.likec4`, service `model.likec4`, and explicit fleet relationships.
315
+ - `architecture/landscape.likec4`, service `model.likec4`, and explicit fleet relationships — then
316
+ `loam subsystem sync`, which writes each model's `likec4.config.json` (the staged repo already
317
+ carries the root one) so the models render beside the map; `validate --all` warns
318
+ `service.likec4-config-missing` for any model still without one.
316
319
  - Service OpenAPI contracts and provider-before-consumer sequencing where inbound edges already name
317
320
  operations.
318
321
  - LikeC4 and OpenAPI deltas for the already mapped active feature ids (`FEAT-12`, `BUG-42`, and so
package/README.md CHANGED
@@ -5,13 +5,14 @@
5
5
  ![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)
6
6
  ![node: >=22.22.3](https://img.shields.io/badge/node-%3E%3D22.22.3-brightgreen.svg)
7
7
  [![CI](https://github.com/ybotok/loam/actions/workflows/ci.yml/badge.svg)](https://github.com/ybotok/loam/actions/workflows/ci.yml)
8
- [![npm](https://img.shields.io/npm/v/@ybotok/loam.svg)](https://www.npmjs.com/package/@ybotok/loam)
8
+ [![npm](https://img.shields.io/npm/v/@ybotok/loam/alpha.svg?label=npm%20alpha)](https://www.npmjs.com/package/@ybotok/loam)
9
9
 
10
- > **Pre-release: `0.2.0-alpha.4`**, the third published release of the 0.2 line and the newest published
10
+ > **Pre-release: `0.2.0-alpha.5`**, the fourth published release of the 0.2 line and the newest published
11
11
  > version of [`@ybotok/loam`](https://www.npmjs.com/package/@ybotok/loam). It publishes under the
12
- > `alpha` npm dist-tag: `npm i @ybotok/loam@alpha`, or the exact version, always installs this
13
- > line. `latest` is moved onto a release by hand once it has published, so a plain
14
- > `npm i @ybotok/loam` may still resolve to `0.1.0-beta.3` until it is. See [Status](#status).
12
+ > `alpha` npm dist-tag — which is what the badge above reads: `npm i @ybotok/loam@alpha`, or the
13
+ > exact version, always installs this line. `latest` is moved onto a release by hand once it has
14
+ > published, so a plain `npm i @ybotok/loam` resolves to an earlier release until it is moved. See
15
+ > [Status](#status).
15
16
 
16
17
  `loam` keeps an authored C4 model, requirements, API and event contracts, permissions, domain
17
18
  vocabulary and verification evidence in explicit agreement as a system changes. The same lifecycle
@@ -486,7 +487,7 @@ always preserved byte for byte, with the entry printed for manual merge.
486
487
  maps, what is lost, what must be added.
487
488
  - [ROADMAP.md](ROADMAP.md) — the evidence-backed improvement plan, priorities, non-goals and exit
488
489
  criteria.
489
- - [CHANGELOG.md](CHANGELOG.md) — released beta.1–alpha.4 plus the changes on `main` under
490
+ - [CHANGELOG.md](CHANGELOG.md) — released beta.1–alpha.5 plus the changes on `main` under
490
491
  `[Unreleased]`.
491
492
  - [docs/BENCHMARKS.md](https://github.com/ybotok/loam/blob/main/docs/BENCHMARKS.md) — what a
492
493
  fleet-sized run costs, with the method, the machine and the committed runs behind every number.
@@ -520,14 +521,14 @@ component baselines, the `architecture/permissions.yaml` authorization vocabular
520
521
  authored-capability axes with the `Realizes:` join, use cases graded as `dynamic view`s, the
521
522
  `glossary/<term>.md` domain vocabulary checked through the links that cite it, and the
522
523
  `architecture/obligations.yaml` architectural obligations checked through `#obl-` tags and
523
- `Covers:`. Known limits, each with its owner in [ROADMAP.md](ROADMAP.md): the complete gate still
524
- needs repeatable CI and installed-package evidence observed from an actual push. Speculative
525
- `render`, health composition and UI generation come later. Behind that status stand **157 test files** (counted 2026-09-02): the count is graded
524
+ `Covers:`. Known limits, each with its owner in [ROADMAP.md](ROADMAP.md): the scheduled CI
525
+ `stability` job has still to be observed green. Speculative `render`, health composition and UI
526
+ generation come later. Behind that status stand **159 test files** (counted 2026-09-02): the count is graded
526
527
  against a live readdir by `test/docs-facts.test.ts`, so this sentence fails the suite the moment it
527
528
  trails the tree.
528
529
 
529
530
  The package is published as [`@ybotok/loam`](https://www.npmjs.com/package/@ybotok/loam), currently
530
- `0.2.0-alpha.4`: the unscoped `loam` name is taken by an unrelated GDAL wrapper, so the package ships
531
+ `0.2.0-alpha.5`: the unscoped `loam` name is taken by an unrelated GDAL wrapper, so the package ships
531
532
  under the maintainer's own npm user scope. Releases are tag-driven and maintainer-only
532
533
  ([CONTRIBUTING.md](CONTRIBUTING.md)); the version this line names is pinned to `package.json` by a
533
534
  drift test, so it cannot silently trail a release.
package/ROADMAP.md CHANGED
@@ -18,27 +18,30 @@ acceptance tests, and implementation evidence remain ordinary files in repositor
18
18
  state inside a service. The CLI is small enough to audit, and every writer — not only archive — now
19
19
  commits through a locked, journaled transaction that a crash cannot leave half-applied.
20
20
 
21
- The tree contains **427 TypeScript modules in 130 source packages**, with an acyclic package graph
21
+ The tree contains **429 TypeScript modules in 130 source packages**, with an acyclic package graph
22
22
  checked by
23
23
  [scripts/package-graph.mjs](https://github.com/ybotok/loam/blob/main/scripts/package-graph.mjs). The
24
24
  CLI exposes **29 commands** from [src/cli.ts](https://github.com/ybotok/loam/blob/main/src/cli.ts),
25
- and the suite stands at **157 test files**. Those four counts are deliberately stated OUTSIDE the
25
+ and the suite stands at **159 test files**. Those four counts are deliberately stated OUTSIDE the
26
26
  dated snapshot below: each derives from the tree in one readdir, so
27
27
  [test/docs-facts.test.ts](https://github.com/ybotok/loam/blob/main/test/docs-facts.test.ts) grades
28
28
  them live and this paragraph cannot quietly trail the code the way its predecessor did.
29
29
 
30
- _Measured 2026-09-03 at `0.2.0-alpha.4`, and dated because neither number has a cheap derivation:_
30
+ _Measured 2026-09-03 at `0.2.0-alpha.5`, and dated because neither number has a cheap derivation:_
31
31
  lint, typecheck, `npm run arch:check` and `npm run meta:check` green; `npm test` passing
32
- **3,410/3,410 tests**, with two `skipIf(asRoot)` cases the root gate container cannot run; the
33
- coverage gate passing with **92.26% statements, 84.06% branches, 96.62% functions, and 94.20%
32
+ **3,434/3,434 tests**, with two `skipIf(asRoot)` cases the root gate container cannot run; the
33
+ coverage gate passing with **92.30% statements, 84.11% branches, 96.64% functions, and 94.24%
34
34
  lines** against its thresholds of 91 / 82 / 95 / 93; and `npm run release:check` plus
35
35
  `npm run test:package` green against the real tarball.
36
36
 
37
- One qualification remains, and it is observation rather than code: nothing has been pushed since the
38
- CI `stability` job and the tarball-reading package smoke landed, so repeatable CI executions — the
39
- stability job seen green, and the installed-package smoke run from a pushed commit — are still
40
- release evidence to collect, even though the gate-stress proof passed locally on its landing tree
41
- and the smoke passes locally against the real tarball. The runner class recorded on 2026-08-18 is
37
+ Most of that qualification is now closed by observation rather than by code. Measured 2026-09-03 on
38
+ pushed commits: `ci` at `1de7c97` green — `test` on Node 22.22.3 and 24, and the installed-package
39
+ `package` job on ubuntu (both Node lines) and windows — and the `release` workflow on tag
40
+ `v0.2.0-alpha.4` green end to end, `release-gate` (lint, typecheck, coverage, the packed candidate,
41
+ the smoke of that exact candidate, `release:check`) followed by `publish`, which put the tarball on
42
+ npm under the `alpha` dist-tag. What is left is one job: `stability` is SKIPPED on a push — it runs
43
+ on the weekly schedule and on dispatch — so it is still release evidence to collect, even though the
44
+ gate-stress proof passes locally. The runner class recorded on 2026-08-18 is
42
45
  still the reading to apply to a red of that shape: a restricted filesystem sandbox that denies `tsx`
43
46
  its local IPC socket fails only the
44
47
  [test/cli-entry.test.ts](https://github.com/ybotok/loam/blob/main/test/cli-entry.test.ts) cases with
@@ -103,12 +106,15 @@ has landed (see [Recently landed](#recently-landed)) — the authored business a
103
106
  axis, and as of 2026-09-02 the deployment axis, which is the first item this file ever wrote
104
107
  directly into this section rather than promoting from [Later](#later--promote-only-from-evidence).
105
108
 
106
- ### Release evidence, unchanged
109
+ ### Release evidence — one job left
107
110
 
108
- What is left is the one thing this file cannot close by writing code, and it stays
109
- where it is rather than being promoted for the shape of it: the CI `stability` job and the
110
- installed-package smoke have still to be OBSERVED green from a pushed commit. That is release
111
- evidence to collect, not an improvement to make.
111
+ What is left is the one thing this file cannot close by writing code, and it stays where it is
112
+ rather than being promoted for the shape of it. The installed-package half closed on 2026-09-03:
113
+ the `package` job ran green on a pushed commit across three runner/Node combinations, and the whole
114
+ publication path ran green on tag `v0.2.0-alpha.4`. The CI `stability` job has still to be OBSERVED
115
+ green — it is skipped on a push and runs on the weekly schedule — so it is still release evidence to
116
+ collect, not an improvement to make. `workflow_dispatch` is the way to collect it deliberately
117
+ rather than waiting for Monday.
112
118
 
113
119
  An item returns to this section from [Later](#later--promote-only-from-evidence) when fleet evidence
114
120
  names its operator, repeated task, failure mode, frequency, current workaround and measurable
@@ -125,6 +131,52 @@ changes, exit criteria, and what each review surfaced — are in this file's his
125
131
  working tree and not committed, and the range goes in with the commit rather than being guessed at
126
132
  here.
127
133
 
134
+ #### The service model, renderable from the fleet root
135
+
136
+ **Landed 2026-09-03**, from one problem report. The root `likec4.config.json` that
137
+ `loam init --create` writes excludes `services/**` on purpose — every `model.likec4` declares its
138
+ own `specification` block, and a renderer merging the tree calls each one a duplicate — and the
139
+ cost of that exclusion had gone unstated for a release: the model `loam adopt` spends the most
140
+ effort on was a box with nothing inside it in the renderer opened at the docs root, and the only
141
+ documented path to its containers was one renderer invocation per service directory. The report's
142
+ hand-written workaround, one project file per service, is what this entry makes loam's.
143
+
144
+ **Measured** at the pinned likec4 before a line was written. A `services/<svc>/likec4.config.json`
145
+ is registered as a project of its own even though the root excludes the directory: the workspace
146
+ loads `fleet` plus one project per service with zero errors, the root project sees exactly the
147
+ files it saw before, and each service project carries an auto `index` view of the containers beside
148
+ the authored view. A nested project named `fleet` is silently registered as `fleet-1`. With more
149
+ than one project, `likec4 validate` at the root refuses with `Specify exact project` until given
150
+ `--project`. A nested project holding no `.likec4` file is silently absent from the project list. A
151
+ service model that does not parse breaks only its own project; siblings and `fleet` still compute.
152
+ And the project picker shows `title ?? id`, so the title is what a human reads.
153
+
154
+ **What landed.** `loam subsystem sync` writes the file — create-only, two keys, bytes a function of
155
+ the id alone — beside every service model that has none, once the root project file exists, and
156
+ reports them under an additive `projects` key. `loam validate --all` grades presence with one
157
+ warning per service, `service.likec4-config-missing`, whose fix is `sync`. No new command: the
158
+ [Later](#later--promote-only-from-evidence) Rendering row's evidence bar was met for the SCOPING
159
+ half of its trigger and answered by wiring — one file that tells the renderer where a project is —
160
+ not by a drawing verb; drawing stays LikeC4's, and the row says so now.
161
+
162
+ **Rejected.** A new command, because the report's problem was a missing file, not a missing verb,
163
+ and a verb would have put loam between a team and its renderer for good. The agent writing the file
164
+ from the adopt brief, because a file written by hand per service is the state the report found —
165
+ one of four adopted services had one — and loam already writes the root file and knows every
166
+ service directory. A generated-and-compared file with a `-stale` grade, because the bytes cannot go
167
+ stale and a compare would fight the `title` and styles LikeC4 lets a team add. Grading in `doctor`
168
+ alone, because the gap is per service and `doctor` reports on the repository, not on its services;
169
+ the docs repo's CI runs `validate --all`.
170
+
171
+ **Costs**, each where a user meets it. A repo with adopted services reports one warning per service
172
+ on its first `validate --all` after upgrading, until one `sync`; that `sync` leaves N untracked
173
+ files it does not `git add`; from the first service project on, `likec4 validate` at the docs root
174
+ needs `--project <name>` (`build` and `export` take every project without one — measured); the
175
+ renderer's project list grows by one per adopted service, and a fleet-map box is not a link into
176
+ that project (LikeC4 projects are separate models); and the project name is not injective (an id spelled billing.core beside one spelled
177
+ billing-core, `service-fleet` beside `fleet`), disclosed in SCHEMA rather than escaped away,
178
+ because the picker shows the id.
179
+
128
180
  #### The generated subsystem views — a label, a boundary, and one reader of the map
129
181
 
130
182
  **Landed 2026-09-03.** Three problem reports from a fleet running loam, one axis. The generated
@@ -365,7 +417,9 @@ item gets promoted — it went from here to `## Now` and from there to
365
417
  invocation, scoping, or artifact reproducibility is the recurring problem, and it must stay
366
418
  outside validation so view computation cannot slow or change the gate. This item is about
367
419
  **drawing**, and LikeC4 keeps that job. Reading a `dynamic view`'s declared steps is not rendering
368
- and is not this item — see docs/DESIGN.md rule 26.
420
+ and is not this item — see docs/DESIGN.md rule 26. The scoping half of that bar was met and
421
+ answered on 2026-09-03 by the per-service project file `loam subsystem sync` writes (see
422
+ [Recently landed](#recently-landed)) — wiring rather than a verb; drawing stays LikeC4's.
369
423
  - **UI generation:** begin with a disposable projection over the stable JSON contract only after CLI
370
424
  consumers demonstrate a repeated navigation problem. It must not introduce a second mutable state,
371
425
  hidden workflow state, or a required service.
package/SCHEMA.md CHANGED
@@ -72,6 +72,7 @@ docs/
72
72
  glossary/<term>.md the system's domain vocabulary, one file per term; nesting allowed [authored, opt-in]
73
73
  services/<svc>/ at the root (unfiled), or inside subsystem directories at any depth
74
74
  model.likec4 boundary C4 (containers/components) [adopt]
75
+ likec4.config.json one LikeC4 project per service model, create-only [GENERATED: loam subsystem sync]
75
76
  spec.md living requirements (Requirement/Scenario) [adopt]
76
77
  arch.spec.md living ARCHITECTURE requirements (outbox, retries, alerts; Covers:) [authored]
77
78
  openapi.yaml API contract [adopt / authored]
@@ -204,6 +205,44 @@ does not parse standalone (its `include` lines reference elements the landscape
204
205
  holds **static** views, whose contents loam never reads in any document. Only the LikeC4 renderer,
205
206
  which merges the whole `architecture/` project, resolves its includes.
206
207
 
208
+ The tree's **second renderer file** is per service — `services/<…>/<svc>/likec4.config.json`,
209
+ which `loam subsystem sync` also writes, beside every `model.likec4` that has none. The root
210
+ project is scoped to `architecture/` and excludes `services/**`, and must be, since each model
211
+ declares its own `specification` block; what that exclusion cost went unstated for a release: an
212
+ adopted service was a box on the fleet map with nothing renderable inside it. Measured at the
213
+ pinned likec4, a project file INSIDE a service directory is registered as a project of its own
214
+ regardless of that exclusion, so one `npx likec4 start` at the docs root loads `fleet` plus one
215
+ project per service, each carrying LikeC4's own `index` view of the containers beside the authored
216
+ one, and a model that does not parse breaks only its own project. The file is **create-only** —
217
+ never compared, never rewritten, never removed — under the root config's ownership rule, not the
218
+ views file's: there is no `-stale` grade because nothing can make it stale. Its bytes are a
219
+ function of the id alone (two keys, `name` and `title`), so placement never changes them and a
220
+ `subsystem move` carries the file with no write of its own — it rides inside the rename the move's
221
+ transaction already journals (in a git-backed repo, commit the files `sync` wrote first: `move`
222
+ refuses over untracked paths, `move-uncommitted`); a compare would only fight the keys LikeC4 lets a
223
+ team add (`title`, styles). `name` is the id with every
224
+ `.` folded to `-` (LikeC4's name grammar refuses the byte; loam's id grammar admits it), prefixed
225
+ `service-` when the result would equal the root project's `fleet` (a nested project of that name
226
+ is silently registered as `fleet-1`) or LikeC4's reserved `default`; `title` is the id verbatim,
227
+ and the title is what the renderer's project picker shows. The name is **not injective**, and
228
+ that is disclosed rather than escaped away: an id spelled billing.core beside a service literally
229
+ named billing-core, or `service-fleet` beside `fleet`, share a name and LikeC4 suffixes the later
230
+ one `-1`; no loam
231
+ finding grades it, because the picker still reads the id. Order is a gate — the root file first
232
+ (`loam init --create` writes it, `loam doctor` prints it), then `sync`: without the root file
233
+ nothing is written and the text output says so. No model, no file: a spec-only service is owed
234
+ nothing and gets its file on the first `sync` after its model lands. No legal id, no file either: a
235
+ directory whose name breaks the service-id grammar (`service.id-invalid`) would get a `name` the
236
+ renderer silently drops, and a create-only file the renderer ignores would silence the grade for
237
+ good, so `sync` skips it and the rename that clears `service.id-invalid` earns the file. A file left behind by a
238
+ deleted model is the team's and harmless (measured: a nested project holding no `.likec4` file is
239
+ absent from the project list). `validate --all` grades presence only — one warning per service,
240
+ `service.likec4-config-missing`, on the fleet `landscape` target, never an error, never in
241
+ `--service`, `status` or `doctor`; the fix is `loam subsystem sync`, never a hand-written file. A
242
+ feature's `delta.likec4` is deliberately out of scope: the directory is transient and
243
+ `loam archive` moves it where a project file would still be registered, so every shipped feature
244
+ would keep a dead project in the list forever.
245
+
207
246
  A move is **one journaled transaction over N directory renames plus the regenerated views file**
208
247
  (`loam subsystem move <name>... --into <sub|.>`; `rename` is a one-rename move; whole subtrees move
209
248
  as one rename). It refuses only when git reports uncommitted changes under a directory being moved
@@ -2295,9 +2334,17 @@ through the forge, not through loam features:
2295
2334
  **parsed** model and never computes a view — it reads what a view **declares** (a `dynamic view`'s
2296
2335
  tags and ordered steps, already materialized by the parse loam pays for) and never what a view
2297
2336
  **shows**. It renders nothing, and view *computation* is what is superlinear in edges. (If you
2298
- want diagrams, add a `views` block and render it with LikeC4's own tooling — `npx likec4 start` in
2299
- the docs repo for the fleet map, `npx likec4 start services/<id>` for one service model — and
2300
- scope the views, because an `include *` over a fleet-sized landscape takes minutes. `loam init`
2337
+ want diagrams, add a `views` block and render it with LikeC4's own tooling — `npx likec4 start`
2338
+ at the docs root, which renders the fleet map AND every adopted service's model, each as the
2339
+ project its own `services/<svc>/likec4.config.json` declares (written by `loam subsystem sync`;
2340
+ the Layout section has the rule), and `npx likec4 start features/<FEAT>` for a delta, which is
2341
+ transient and gets no project — and scope the views, because an `include *` over a fleet-sized
2342
+ landscape takes minutes. Two costs arrive with the first service project. Measured at the
2343
+ pinned likec4: with more than one project, `likec4 validate` at the docs root refuses without
2344
+ `--project <name>`, so a CI step that ran it bare changes (`likec4 build` and `likec4 export` take
2345
+ every project and need no flag). And the renderer's project list grows by one per adopted service
2346
+ beside `fleet` — LikeC4 projects are separate models, so a box on the fleet map is not a link into
2347
+ that service's project. `loam init`
2301
2348
  scaffolds the landscape with no `views` block for that reason: loam should not plant a diagram it
2302
2349
  never draws **— and never asks for one: a fleet that draws nothing owes no views block.** It also
2303
2350
  scaffolds a `likec4.config.json` that scopes the root LikeC4 project to `architecture/`: loam
@@ -2556,9 +2603,9 @@ component-surface pins), `verify`,
2556
2603
  transactional `archive` / `unarchive`, `vouch`, and the OpenSpec audit/migration on-ramp are
2557
2604
  implemented, each with a `--json` contract.
2558
2605
 
2559
- Known and unclosed, so nobody has to discover them: the complete gate still needs repeatable CI and
2560
- installed-package evidence observed from an actual push, even though behavior, coverage and the
2561
- local stress runner pass. Both contract axes now merge a components-only delta: a feature
2606
+ Known and unclosed, so nobody has to discover them: the scheduled CI `stability` job has still to be
2607
+ observed green, even though behavior, coverage, the installed-package smoke and the local stress
2608
+ runner all pass. Both contract axes now merge a components-only delta: a feature
2562
2609
  asyncapi.yaml declaring no channel/operation/message slot (`components.schemas`-only, say) merges
2563
2610
  the component surfaces it declares, pinned by the root `x-loam-baselines` record, and they enter the
2564
2611
  ref sweep like anything else the merge wrote — so a `$ref` whose target the delta itself defines
@@ -1,29 +1,58 @@
1
1
  /**
2
- * `loam subsystem sync` — the ONE repair for `subsystem.views-stale`:
3
- * recompute the generated views file from the tree, write it when it
4
- * differs, remove it when the tree has no subsystems, and say which.
2
+ * `loam subsystem sync` — the writer of everything under the tree that exists
3
+ * only to be RENDERED, which is the renderer's reading of the tree twice over:
5
4
  *
6
- * The write goes through the same lock + journaled transaction every docs
7
- * writer uses (`commitStaged`), so a predecessor's interrupted commit is
8
- * recovered first and a sync killed mid-swap is rolled forward by the next
9
- * writer. Idempotent by construction: the expected bytes are a pure function
10
- * of the tree and the committed landscape, so a second run reports `current`
11
- * and writes nothing.
5
+ * - `architecture/subsystems.likec4`, the GENERATED views file. Recomputed from
6
+ * the tree on every run, compared, written when it differs, removed when the
7
+ * tree has no subsystems. The ONE repair for `subsystem.views-stale`.
8
+ * - `services/<…>/<id>/likec4.config.json`, one LikeC4 project file beside
9
+ * every `model.likec4`. CREATE-ONLY: never compared, never rewritten, never
10
+ * removed.
12
11
  *
13
- * The expected bytes come from `./txn/views.ts` — the same module every
12
+ * The two rules differ because the two files answer to different owners. The
13
+ * views file is a pure function of the tree and the committed landscape, so
14
+ * every byte of it is loam's and a hand edit is by definition stale. The
15
+ * project file's bytes are a function of the service id ALONE — the identity
16
+ * no verb ever changes — so nothing can make it stale, and the keys LikeC4
17
+ * lets a team add to it (`title`, styles, a contact) are the team's; a compare
18
+ * would fight them, and a rewrite would take them. `core/repo/tree/render/projects.ts`
19
+ * records the measurement behind the file and the naming rule; here, presence
20
+ * is the one question asked of it, through the SAME predicate `validate --all`
21
+ * grades with (`missingProjects`), so the grader can never name a gap this
22
+ * writer would not fill.
23
+ *
24
+ * The project files are written only when the ROOT `likec4.config.json` exists.
25
+ * Without it the tree is not a loadable workspace at all — every model would
26
+ * merge into one project and report every declaration as a duplicate — and a
27
+ * nested project beside a missing root is a promise about a renderer that
28
+ * cannot be opened. `loam doctor` prints the root file; sync says so and
29
+ * writes nothing for the services until it is there.
30
+ *
31
+ * Every write goes through the same lock + journaled transaction every docs
32
+ * writer uses (`commitStaged`), and both kinds land in ONE commit: a
33
+ * predecessor's interrupted commit is recovered first and a sync killed
34
+ * mid-swap is rolled forward by the next writer. Idempotent by construction:
35
+ * the expected views bytes are a pure function of the tree, and a project
36
+ * file, once present, is never a gap again — so a second run reports
37
+ * `current` and writes nothing.
38
+ *
39
+ * The expected views bytes come from `./txn/views.ts` — the same module every
14
40
  * writer verb renders from, so sync and the transactions cannot disagree
15
- * about what the file should say.
41
+ * about what the file should say. The subsystem verbs' own commit window
42
+ * (`./txn/txn.ts`) does NOT write project files, and records why.
16
43
  */
17
44
  import { existsSync } from "node:fs";
18
45
  import { readFile } from "node:fs/promises";
19
- import { emitJson, fail } from "../../core/envelope/json.js";
20
- import { subsystemViewsPath } from "../../core/repo/paths.js";
46
+ import { emitJson, fail, repoPath } from "../../core/envelope/json.js";
47
+ import { LIKEC4_ROOT_PROJECT, rootProjectPath, serviceRenderPaths, subsystemViewsPath, } from "../../core/repo/paths.js";
21
48
  import { listFleetTree } from "../../core/repo/repo.js";
49
+ import { renderServiceProject, surveyProjects } from "../../core/repo/tree/render/projects.js";
22
50
  import { stageWrites } from "../../core/staging/commit.js";
23
51
  import { InterruptedCommitError } from "../../core/staging/interrupted.js";
24
52
  import { acquireDocsLockWaiting, DocsBusyError, LOCK_WAIT_MS } from "../../core/staging/lock.js";
25
53
  import { recoverInterruptedCommit } from "../../core/staging/recovery/recover.js";
26
54
  import { commitStaged } from "../../core/staging/txn/transaction.js";
55
+ import { planWrite } from "../../core/staging/writes.js";
27
56
  import { viewsAgree } from "../../core/repo/tree/render/views.js";
28
57
  import { expectedViews } from "./txn/views.js";
29
58
  export async function runSync(docsDir, json) {
@@ -61,23 +90,71 @@ export async function runSync(docsDir, json) {
61
90
  // CRLF back. `validate` stopped demanding that rewrite; `sync` must stop
62
91
  // offering it, or the two disagree about the same file.
63
92
  const actual = existsSync(path) ? await readFile(path, "utf8") : null;
64
- if (viewsAgree(actual, expected)) {
65
- report(json, { action: "current", subsystems: tree.subsystems.length, recovered });
93
+ const agree = viewsAgree(actual, expected);
94
+ const plan = planProjects(docsDir, tree.services);
95
+ const projects = (created) => ({
96
+ root: plan.root,
97
+ created,
98
+ current: plan.current,
99
+ modelled: plan.modelled,
100
+ });
101
+ if (agree && plan.gaps.length === 0) {
102
+ report(json, { action: "current", subsystems: tree.subsystems.length, recovered, projects: projects([]) });
66
103
  return;
67
104
  }
68
- const action = actual === null ? "created" : expectedBytes === null ? "removed" : "updated";
69
- const staged = await stageWrites([{ path, content: expectedBytes }]);
105
+ // `action` still describes the views file alone: a run that only created
106
+ // project files answers `current` for it, exactly as before the projects
107
+ // existed, so nothing reading the key sees a new meaning.
108
+ const action = agree
109
+ ? "current"
110
+ : actual === null
111
+ ? "created"
112
+ : expectedBytes === null
113
+ ? "removed"
114
+ : "updated";
115
+ const writes = agree ? [] : [{ path, content: expectedBytes }];
116
+ // `planWrite` marks a non-existent target exclusive — a no-clobber create
117
+ // that fails EEXIST rather than replacing a file another writer landed in
118
+ // the same instant. That IS the create-only rule, enforced by the write
119
+ // path rather than by the `existsSync` the predicate already ran: a file
120
+ // that appears between the check and the swap is somebody's, and stays.
121
+ for (const gap of plan.gaps) {
122
+ writes.push(planWrite(gap.path, renderServiceProject(gap.service.id, LIKEC4_ROOT_PROJECT)));
123
+ }
124
+ // One lock, one journal, one commit for both kinds of file — a sync killed
125
+ // between the views swap and the project creates would otherwise leave a
126
+ // half-state no later run could tell from a team's deliberate deletion.
127
+ const staged = await stageWrites(writes);
70
128
  const committed = await commitStaged({ root: docsDir, command: "subsystem", rerun: "loam subsystem sync", target: "subsystems" }, staged, "synced");
71
129
  if (!committed.ok) {
72
130
  fail(json, committed.code, committed.message);
73
131
  return;
74
132
  }
75
- report(json, { action, subsystems: tree.subsystems.length, recovered });
133
+ const created = plan.gaps.map((gap) => repoPath(docsDir, gap.path));
134
+ report(json, { action, subsystems: tree.subsystems.length, recovered, projects: projects(created) });
76
135
  }
77
136
  finally {
78
137
  await releaseLock();
79
138
  }
80
139
  }
140
+ /**
141
+ * What the project half of this run owes, from ONE predicate. `surveyProjects`
142
+ * is the shared spelling of "has a model, a legal id, and no file", and it
143
+ * hands back the size of the domain it counted against, so `current` is a
144
+ * subtraction rather than a second reading of the same two paths — a loop
145
+ * here re-spelling "has a model" was the drift the shared predicate exists to
146
+ * prevent, and it would have kept counting a directory the survey had
147
+ * skipped. Without the root file the gaps are real but stay unfilled —
148
+ * writing them would promise a renderer nobody can open — and `current` is 0
149
+ * because "already had a file" is only a fact about a workspace that exists.
150
+ */
151
+ function planProjects(docsDir, services) {
152
+ const root = existsSync(rootProjectPath(docsDir));
153
+ const survey = surveyProjects(services, serviceRenderPaths);
154
+ if (!root)
155
+ return { root, gaps: [], current: 0, modelled: survey.modelled };
156
+ return { root, gaps: survey.gaps, current: survey.modelled - survey.gaps.length, modelled: survey.modelled };
157
+ }
81
158
  function report(json, out) {
82
159
  if (json) {
83
160
  emitJson({
@@ -86,18 +163,32 @@ function report(json, out) {
86
163
  action: out.action,
87
164
  subsystems: out.subsystems,
88
165
  ...(out.recovered === null ? {} : { recovered: out.recovered }),
166
+ projects: { root: out.projects.root, created: out.projects.created, current: out.projects.current },
89
167
  });
90
168
  return;
91
169
  }
92
170
  if (out.recovered !== null && out.recovered.outcome !== "consistent") {
93
171
  console.log(`note: recovered an interrupted \`loam ${out.recovered.command}\` commit first (${out.recovered.outcome}).`);
94
172
  }
173
+ const wrote = out.projects.created.length;
174
+ // "nothing to write" is a claim about the whole run, and it is false the
175
+ // moment a project file landed beside a current views file.
95
176
  const sentence = {
96
- current: `architecture/subsystems.likec4 is current (${out.subsystems} subsystem(s)) — nothing to write.`,
177
+ current: `architecture/subsystems.likec4 is current (${out.subsystems} subsystem(s))${wrote === 0 ? " — nothing to write." : "."}`,
97
178
  created: `wrote architecture/subsystems.likec4 — ${out.subsystems} subsystem view(s).`,
98
179
  updated: `updated architecture/subsystems.likec4 — ${out.subsystems} subsystem view(s).`,
99
180
  removed: `removed architecture/subsystems.likec4 — the tree has no subsystems, so the generated file must be absent.`,
100
181
  };
101
182
  console.log(sentence[out.action]);
183
+ if (wrote > 0) {
184
+ console.log(`wrote ${wrote} services/<…>/likec4.config.json — one LikeC4 project per service model, create-only ` +
185
+ "(a file that already exists is yours and is never touched; commit the new ones before a " +
186
+ "`loam subsystem move`, which refuses over untracked paths). With more than one project, " +
187
+ "likec4 validate at the docs root needs --project <name>; build and export take every project.");
188
+ }
189
+ if (!out.projects.root && out.projects.modelled > 0) {
190
+ console.log("note: no likec4.config.json at the docs root, so no service project files were written — " +
191
+ "`loam doctor` prints the root file.");
192
+ }
102
193
  }
103
194
  //# sourceMappingURL=sync.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"sync.js","sourceRoot":"","sources":["../../../src/commands/subsystem/sync.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `loam subsystem sync` — the ONE repair for `subsystem.views-stale`:\n * recompute the generated views file from the tree, write it when it\n * differs, remove it when the tree has no subsystems, and say which.\n *\n * The write goes through the same lock + journaled transaction every docs\n * writer uses (`commitStaged`), so a predecessor's interrupted commit is\n * recovered first and a sync killed mid-swap is rolled forward by the next\n * writer. Idempotent by construction: the expected bytes are a pure function\n * of the tree and the committed landscape, so a second run reports `current`\n * and writes nothing.\n *\n * The expected bytes come from `./txn/views.ts` — the same module every\n * writer verb renders from, so sync and the transactions cannot disagree\n * about what the file should say.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport type { DocsDir } from \"../../core/kernel/ids/dirs.js\";\nimport { emitJson, fail } from \"../../core/envelope/json.js\";\nimport { subsystemViewsPath } from \"../../core/repo/paths.js\";\nimport { listFleetTree } from \"../../core/repo/repo.js\";\nimport { stageWrites } from \"../../core/staging/commit.js\";\nimport { type CommitRecovery, InterruptedCommitError } from \"../../core/staging/interrupted.js\";\nimport { acquireDocsLockWaiting, DocsBusyError, LOCK_WAIT_MS } from \"../../core/staging/lock.js\";\nimport { recoverInterruptedCommit } from \"../../core/staging/recovery/recover.js\";\nimport { commitStaged } from \"../../core/staging/txn/transaction.js\";\nimport { viewsAgree } from \"../../core/repo/tree/render/views.js\";\nimport { expectedViews } from \"./txn/views.js\";\n\n/** What one sync did to the file. */\ntype SyncAction = \"current\" | \"created\" | \"updated\" | \"removed\";\n\nexport async function runSync(docsDir: DocsDir, json: boolean): Promise<void> {\n let releaseLock: () => Promise<void>;\n try {\n releaseLock = await acquireDocsLockWaiting(docsDir, LOCK_WAIT_MS);\n } catch (err) {\n if (!(err instanceof DocsBusyError)) throw err;\n fail(json, \"docs-busy\", err.message);\n return;\n }\n try {\n // A predecessor's journal is recovered (or refused) before the tree is\n // read: the views file itself may be the half-written one.\n let recovered: CommitRecovery | null;\n try {\n recovered = await recoverInterruptedCommit(docsDir);\n } catch (err) {\n if (!(err instanceof InterruptedCommitError)) throw err;\n fail(json, \"commit-interrupted\", err.message);\n return;\n }\n\n const tree = await listFleetTree(docsDir);\n const expected = await expectedViews(docsDir, tree);\n const path = subsystemViewsPath(docsDir);\n const expectedBytes = expected === null ? null : Buffer.from(expected, \"utf8\");\n // Content, not bytes: `viewsAgree` records why, beside the generator. A\n // Windows clone hands this file back CRLF with not one fact changed, and a\n // byte compare made `sync` answer `updated` and rewrite it LF — after which\n // git shows the file permanently modified and the next checkout puts the\n // CRLF back. `validate` stopped demanding that rewrite; `sync` must stop\n // offering it, or the two disagree about the same file.\n const actual = existsSync(path) ? await readFile(path, \"utf8\") : null;\n if (viewsAgree(actual, expected)) {\n report(json, { action: \"current\", subsystems: tree.subsystems.length, recovered });\n return;\n }\n const action: SyncAction = actual === null ? \"created\" : expectedBytes === null ? \"removed\" : \"updated\";\n const staged = await stageWrites([{ path, content: expectedBytes }]);\n const committed = await commitStaged(\n { root: docsDir, command: \"subsystem\", rerun: \"loam subsystem sync\", target: \"subsystems\" },\n staged,\n \"synced\",\n );\n if (!committed.ok) {\n fail(json, committed.code, committed.message);\n return;\n }\n report(json, { action, subsystems: tree.subsystems.length, recovered });\n } finally {\n await releaseLock();\n }\n}\n\nfunction report(\n json: boolean,\n out: { action: SyncAction; subsystems: number; recovered: CommitRecovery | null },\n): void {\n if (json) {\n emitJson({\n command: \"subsystem\",\n path: \"architecture/subsystems.likec4\",\n action: out.action,\n subsystems: out.subsystems,\n ...(out.recovered === null ? {} : { recovered: out.recovered }),\n });\n return;\n }\n if (out.recovered !== null && out.recovered.outcome !== \"consistent\") {\n console.log(\n `note: recovered an interrupted \\`loam ${out.recovered.command}\\` commit first (${out.recovered.outcome}).`,\n );\n }\n const sentence: Record<SyncAction, string> = {\n current: `architecture/subsystems.likec4 is current (${out.subsystems} subsystem(s)) — nothing to write.`,\n created: `wrote architecture/subsystems.likec4 — ${out.subsystems} subsystem view(s).`,\n updated: `updated architecture/subsystems.likec4 — ${out.subsystems} subsystem view(s).`,\n removed: `removed architecture/subsystems.likec4 — the tree has no subsystems, so the generated file must be absent.`,\n };\n console.log(sentence[out.action]);\n}\n"]}
1
+ {"version":3,"file":"sync.js","sourceRoot":"","sources":["../../../src/commands/subsystem/sync.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `loam subsystem sync` — the writer of everything under the tree that exists\n * only to be RENDERED, which is the renderer's reading of the tree twice over:\n *\n * - `architecture/subsystems.likec4`, the GENERATED views file. Recomputed from\n * the tree on every run, compared, written when it differs, removed when the\n * tree has no subsystems. The ONE repair for `subsystem.views-stale`.\n * - `services/<…>/<id>/likec4.config.json`, one LikeC4 project file beside\n * every `model.likec4`. CREATE-ONLY: never compared, never rewritten, never\n * removed.\n *\n * The two rules differ because the two files answer to different owners. The\n * views file is a pure function of the tree and the committed landscape, so\n * every byte of it is loam's and a hand edit is by definition stale. The\n * project file's bytes are a function of the service id ALONE — the identity\n * no verb ever changes — so nothing can make it stale, and the keys LikeC4\n * lets a team add to it (`title`, styles, a contact) are the team's; a compare\n * would fight them, and a rewrite would take them. `core/repo/tree/render/projects.ts`\n * records the measurement behind the file and the naming rule; here, presence\n * is the one question asked of it, through the SAME predicate `validate --all`\n * grades with (`missingProjects`), so the grader can never name a gap this\n * writer would not fill.\n *\n * The project files are written only when the ROOT `likec4.config.json` exists.\n * Without it the tree is not a loadable workspace at all — every model would\n * merge into one project and report every declaration as a duplicate — and a\n * nested project beside a missing root is a promise about a renderer that\n * cannot be opened. `loam doctor` prints the root file; sync says so and\n * writes nothing for the services until it is there.\n *\n * Every write goes through the same lock + journaled transaction every docs\n * writer uses (`commitStaged`), and both kinds land in ONE commit: a\n * predecessor's interrupted commit is recovered first and a sync killed\n * mid-swap is rolled forward by the next writer. Idempotent by construction:\n * the expected views bytes are a pure function of the tree, and a project\n * file, once present, is never a gap again — so a second run reports\n * `current` and writes nothing.\n *\n * The expected views bytes come from `./txn/views.ts` — the same module every\n * writer verb renders from, so sync and the transactions cannot disagree\n * about what the file should say. The subsystem verbs' own commit window\n * (`./txn/txn.ts`) does NOT write project files, and records why.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport type { DocsDir } from \"../../core/kernel/ids/dirs.js\";\nimport { emitJson, fail, repoPath } from \"../../core/envelope/json.js\";\nimport {\n LIKEC4_ROOT_PROJECT,\n rootProjectPath,\n serviceRenderPaths,\n subsystemViewsPath,\n} from \"../../core/repo/paths.js\";\nimport { listFleetTree } from \"../../core/repo/repo.js\";\nimport { type ProjectGap, renderServiceProject, surveyProjects } from \"../../core/repo/tree/render/projects.js\";\nimport type { WalkedService } from \"../../core/repo/tree/walk.js\";\nimport { stageWrites } from \"../../core/staging/commit.js\";\nimport { type CommitRecovery, InterruptedCommitError } from \"../../core/staging/interrupted.js\";\nimport { acquireDocsLockWaiting, DocsBusyError, LOCK_WAIT_MS } from \"../../core/staging/lock.js\";\nimport { recoverInterruptedCommit } from \"../../core/staging/recovery/recover.js\";\nimport { commitStaged } from \"../../core/staging/txn/transaction.js\";\nimport { planWrite, type PlannedWrite } from \"../../core/staging/writes.js\";\nimport { viewsAgree } from \"../../core/repo/tree/render/views.js\";\nimport { expectedViews } from \"./txn/views.js\";\n\n/** What one sync did to the views file — and ONLY that file; the projects report separately. */\ntype SyncAction = \"current\" | \"created\" | \"updated\" | \"removed\";\n\n/**\n * The per-service project half of one sync, before the write: what the\n * predicate found, and the two counts the report needs beside it.\n */\ninterface ProjectPlan {\n /** Whether the root project file exists — the gate on every service write. */\n root: boolean;\n /** The files this run will create. Empty whenever `root` is false. */\n gaps: ProjectGap<WalkedService>[];\n /** Model-bearing services that ALREADY had a file; 0 whenever `root` is false. */\n current: number;\n /** Model-bearing services, root or no root — what the root-missing note is owed to. */\n modelled: number;\n}\n\n/** The additive `projects` payload key, and what the text lines are composed from. */\ninterface ProjectsReport {\n root: boolean;\n /** Repo-relative POSIX paths written this run, sorted by id. */\n created: string[];\n current: number;\n modelled: number;\n}\n\nexport async function runSync(docsDir: DocsDir, json: boolean): Promise<void> {\n let releaseLock: () => Promise<void>;\n try {\n releaseLock = await acquireDocsLockWaiting(docsDir, LOCK_WAIT_MS);\n } catch (err) {\n if (!(err instanceof DocsBusyError)) throw err;\n fail(json, \"docs-busy\", err.message);\n return;\n }\n try {\n // A predecessor's journal is recovered (or refused) before the tree is\n // read: the views file itself may be the half-written one.\n let recovered: CommitRecovery | null;\n try {\n recovered = await recoverInterruptedCommit(docsDir);\n } catch (err) {\n if (!(err instanceof InterruptedCommitError)) throw err;\n fail(json, \"commit-interrupted\", err.message);\n return;\n }\n\n const tree = await listFleetTree(docsDir);\n const expected = await expectedViews(docsDir, tree);\n const path = subsystemViewsPath(docsDir);\n const expectedBytes = expected === null ? null : Buffer.from(expected, \"utf8\");\n // Content, not bytes: `viewsAgree` records why, beside the generator. A\n // Windows clone hands this file back CRLF with not one fact changed, and a\n // byte compare made `sync` answer `updated` and rewrite it LF — after which\n // git shows the file permanently modified and the next checkout puts the\n // CRLF back. `validate` stopped demanding that rewrite; `sync` must stop\n // offering it, or the two disagree about the same file.\n const actual = existsSync(path) ? await readFile(path, \"utf8\") : null;\n const agree = viewsAgree(actual, expected);\n const plan = planProjects(docsDir, tree.services);\n const projects = (created: string[]): ProjectsReport => ({\n root: plan.root,\n created,\n current: plan.current,\n modelled: plan.modelled,\n });\n if (agree && plan.gaps.length === 0) {\n report(json, { action: \"current\", subsystems: tree.subsystems.length, recovered, projects: projects([]) });\n return;\n }\n // `action` still describes the views file alone: a run that only created\n // project files answers `current` for it, exactly as before the projects\n // existed, so nothing reading the key sees a new meaning.\n const action: SyncAction = agree\n ? \"current\"\n : actual === null\n ? \"created\"\n : expectedBytes === null\n ? \"removed\"\n : \"updated\";\n const writes: PlannedWrite[] = agree ? [] : [{ path, content: expectedBytes }];\n // `planWrite` marks a non-existent target exclusive — a no-clobber create\n // that fails EEXIST rather than replacing a file another writer landed in\n // the same instant. That IS the create-only rule, enforced by the write\n // path rather than by the `existsSync` the predicate already ran: a file\n // that appears between the check and the swap is somebody's, and stays.\n for (const gap of plan.gaps) {\n writes.push(planWrite(gap.path, renderServiceProject(gap.service.id, LIKEC4_ROOT_PROJECT)));\n }\n // One lock, one journal, one commit for both kinds of file — a sync killed\n // between the views swap and the project creates would otherwise leave a\n // half-state no later run could tell from a team's deliberate deletion.\n const staged = await stageWrites(writes);\n const committed = await commitStaged(\n { root: docsDir, command: \"subsystem\", rerun: \"loam subsystem sync\", target: \"subsystems\" },\n staged,\n \"synced\",\n );\n if (!committed.ok) {\n fail(json, committed.code, committed.message);\n return;\n }\n const created = plan.gaps.map((gap) => repoPath(docsDir, gap.path));\n report(json, { action, subsystems: tree.subsystems.length, recovered, projects: projects(created) });\n } finally {\n await releaseLock();\n }\n}\n\n/**\n * What the project half of this run owes, from ONE predicate. `surveyProjects`\n * is the shared spelling of \"has a model, a legal id, and no file\", and it\n * hands back the size of the domain it counted against, so `current` is a\n * subtraction rather than a second reading of the same two paths — a loop\n * here re-spelling \"has a model\" was the drift the shared predicate exists to\n * prevent, and it would have kept counting a directory the survey had\n * skipped. Without the root file the gaps are real but stay unfilled —\n * writing them would promise a renderer nobody can open — and `current` is 0\n * because \"already had a file\" is only a fact about a workspace that exists.\n */\nfunction planProjects(docsDir: DocsDir, services: readonly WalkedService[]): ProjectPlan {\n const root = existsSync(rootProjectPath(docsDir));\n const survey = surveyProjects(services, serviceRenderPaths);\n if (!root) return { root, gaps: [], current: 0, modelled: survey.modelled };\n return { root, gaps: survey.gaps, current: survey.modelled - survey.gaps.length, modelled: survey.modelled };\n}\n\nfunction report(\n json: boolean,\n out: { action: SyncAction; subsystems: number; recovered: CommitRecovery | null; projects: ProjectsReport },\n): void {\n if (json) {\n emitJson({\n command: \"subsystem\",\n path: \"architecture/subsystems.likec4\",\n action: out.action,\n subsystems: out.subsystems,\n ...(out.recovered === null ? {} : { recovered: out.recovered }),\n projects: { root: out.projects.root, created: out.projects.created, current: out.projects.current },\n });\n return;\n }\n if (out.recovered !== null && out.recovered.outcome !== \"consistent\") {\n console.log(\n `note: recovered an interrupted \\`loam ${out.recovered.command}\\` commit first (${out.recovered.outcome}).`,\n );\n }\n const wrote = out.projects.created.length;\n // \"nothing to write\" is a claim about the whole run, and it is false the\n // moment a project file landed beside a current views file.\n const sentence: Record<SyncAction, string> = {\n current: `architecture/subsystems.likec4 is current (${out.subsystems} subsystem(s))${wrote === 0 ? \" — nothing to write.\" : \".\"}`,\n created: `wrote architecture/subsystems.likec4 — ${out.subsystems} subsystem view(s).`,\n updated: `updated architecture/subsystems.likec4 — ${out.subsystems} subsystem view(s).`,\n removed: `removed architecture/subsystems.likec4 — the tree has no subsystems, so the generated file must be absent.`,\n };\n console.log(sentence[out.action]);\n if (wrote > 0) {\n console.log(\n `wrote ${wrote} services/<…>/likec4.config.json — one LikeC4 project per service model, create-only ` +\n \"(a file that already exists is yours and is never touched; commit the new ones before a \" +\n \"`loam subsystem move`, which refuses over untracked paths). With more than one project, \" +\n \"likec4 validate at the docs root needs --project <name>; build and export take every project.\",\n );\n }\n if (!out.projects.root && out.projects.modelled > 0) {\n console.log(\n \"note: no likec4.config.json at the docs root, so no service project files were written — \" +\n \"`loam doctor` prints the root file.\",\n );\n }\n}\n"]}
@@ -7,6 +7,25 @@
7
7
  * the same commit as whatever changed the tree — a marker without its view,
8
8
  * or renames without theirs, is exactly the `subsystem.views-stale` state
9
9
  * the generated file's contract refuses to leave between two commits.
10
+ *
11
+ * This window does NOT write the per-service project files
12
+ * (`services/<…>/<id>/likec4.config.json`); only `../sync.ts` does, and the
13
+ * asymmetry is deliberate rather than an omission. The views file must ride
14
+ * in the verb's commit because its bytes depend on the PLACEMENT the verb is
15
+ * changing. A project file's bytes depend on the service id alone, which no
16
+ * verb here changes — and the file lives INSIDE the service directory, so a
17
+ * `move` carries it to the new location as part of the rename it already
18
+ * journals (in a git-backed repo that move REFUSES first, `move-uncommitted`,
19
+ * until the files `sync` created are committed — the refusal names them, and
20
+ * `sync`'s own output says to commit them), and `rm` never reaches one,
21
+ * because it refuses any subsystem that still holds a member
22
+ * (`subsystem-not-empty`, `../edit.ts`). No verb can
23
+ * therefore leave a project file stale or orphaned, and a verb that filled
24
+ * gaps in passing would silently write files a `--json` reader of `new` or
25
+ * `rename` was never told to expect. The one state a verb can leave is a
26
+ * model with no file beside it, and that is the state `validate --all` names
27
+ * as `service.likec4-config-missing`, with `loam subsystem sync` as the
28
+ * repair — one writer, so the create-only rule has one place to hold.
10
29
  */
11
30
  import { existsSync } from "node:fs";
12
31
  import { readFile } from "node:fs/promises";