@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.
- package/CHANGELOG.md +46 -0
- package/MIGRATING-from-OpenSpec.md +4 -1
- package/README.md +11 -10
- package/ROADMAP.md +70 -16
- package/SCHEMA.md +53 -6
- package/dist/commands/subsystem/sync.js +110 -19
- package/dist/commands/subsystem/sync.js.map +1 -1
- package/dist/commands/subsystem/txn/txn.js +19 -0
- package/dist/commands/subsystem/txn/txn.js.map +1 -1
- package/dist/commands/validate/fleet/landscape.js +6 -0
- package/dist/commands/validate/fleet/landscape.js.map +1 -1
- package/dist/commands/validate/fleet/views/projects.js +90 -0
- package/dist/commands/validate/fleet/views/projects.js.map +1 -0
- package/dist/core/agent/agents-md/command-map.js +3 -1
- package/dist/core/agent/agents-md/command-map.js.map +1 -1
- package/dist/core/agent/agents-md/map/subsystem.js +10 -1
- package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
- package/dist/core/agent/workflows/adopt.js +13 -3
- package/dist/core/agent/workflows/adopt.js.map +1 -1
- package/dist/core/agent/workflows/check.js +1 -0
- package/dist/core/agent/workflows/check.js.map +1 -1
- package/dist/core/brief/checks.js +9 -0
- package/dist/core/brief/checks.js.map +1 -1
- package/dist/core/brief/targets.js +7 -4
- package/dist/core/brief/targets.js.map +1 -1
- package/dist/core/brief/unchecked.js +1 -1
- package/dist/core/brief/unchecked.js.map +1 -1
- package/dist/core/docs.js +11 -7
- package/dist/core/docs.js.map +1 -1
- package/dist/core/doctor/doctor.js +17 -6
- package/dist/core/doctor/doctor.js.map +1 -1
- package/dist/core/explain/families.js +1 -1
- package/dist/core/explain/families.js.map +1 -1
- package/dist/core/repo/paths.js +38 -0
- package/dist/core/repo/paths.js.map +1 -1
- package/dist/core/repo/tree/render/projects.js +124 -0
- package/dist/core/repo/tree/render/projects.js.map +1 -0
- package/examples/README.md +6 -1
- package/examples/docs/services/checkout-web/likec4.config.json +4 -0
- package/examples/docs/services/order-service/likec4.config.json +4 -0
- package/examples/docs/services/payment-service/likec4.config.json +4 -0
- package/examples/docs/services/payment-service/model.likec4 +4 -2
- package/examples/docs/services/platform/identity-service/likec4.config.json +4 -0
- package/examples/docs/services/platform/notification-service/likec4.config.json +4 -0
- 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
|

|
|
6
6
|

|
|
7
7
|
[](https://github.com/ybotok/loam/actions/workflows/ci.yml)
|
|
8
|
-
[](https://www.npmjs.com/package/@ybotok/loam)
|
|
8
|
+
[](https://www.npmjs.com/package/@ybotok/loam)
|
|
9
9
|
|
|
10
|
-
> **Pre-release: `0.2.0-alpha.
|
|
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
|
|
13
|
-
> line. `latest` is moved onto a release by hand once it has
|
|
14
|
-
> `npm i @ybotok/loam`
|
|
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.
|
|
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
|
|
524
|
-
|
|
525
|
-
|
|
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.
|
|
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 **
|
|
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 **
|
|
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.
|
|
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,
|
|
33
|
-
coverage gate passing with **92.
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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`
|
|
2299
|
-
the docs
|
|
2300
|
-
|
|
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
|
|
2560
|
-
|
|
2561
|
-
|
|
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
|
|
3
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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";
|