@ybotok/loam 0.2.0-alpha.1 → 0.2.0-alpha.4
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 +165 -0
- package/README.md +6 -6
- package/ROADMAP.md +155 -18
- package/SCHEMA.md +33 -1
- package/dist/commands/archive/plan/gate.js +18 -12
- package/dist/commands/archive/plan/gate.js.map +1 -1
- package/dist/commands/archive/plan/landscape.js +19 -0
- package/dist/commands/archive/plan/landscape.js.map +1 -1
- package/dist/commands/archive/run.js +48 -3
- package/dist/commands/archive/run.js.map +1 -1
- package/dist/commands/seed/plan.js +1 -1
- package/dist/commands/seed/plan.js.map +1 -1
- package/dist/commands/subsystem/sync.js +1 -1
- package/dist/commands/subsystem/sync.js.map +1 -1
- package/dist/commands/subsystem/txn/txn.js +1 -1
- package/dist/commands/subsystem/txn/txn.js.map +1 -1
- package/dist/commands/subsystem/txn/views.js +23 -11
- package/dist/commands/subsystem/txn/views.js.map +1 -1
- package/dist/commands/validate/arch-coverage.js +11 -1
- package/dist/commands/validate/arch-coverage.js.map +1 -1
- package/dist/commands/validate/fleet/obligations.js +43 -9
- package/dist/commands/validate/fleet/obligations.js.map +1 -1
- package/dist/commands/validate/fleet/usecases/steps.js +3 -3
- package/dist/commands/validate/fleet/usecases/steps.js.map +1 -1
- package/dist/commands/validate/fleet/views/ids.js +6 -1
- package/dist/commands/validate/fleet/views/ids.js.map +1 -1
- package/dist/commands/validate/fleet/views/stale.js +15 -39
- package/dist/commands/validate/fleet/views/stale.js.map +1 -1
- package/dist/commands/validate/service/service.js +1 -0
- package/dist/commands/validate/service/service.js.map +1 -1
- package/dist/commands/validate/service/specs.js +33 -1
- package/dist/commands/validate/service/specs.js.map +1 -1
- package/dist/core/agent/agents-md/map/subsystem.js +8 -1
- package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
- package/dist/core/agent/agents-md.js +4 -0
- package/dist/core/agent/agents-md.js.map +1 -1
- package/dist/core/agent/workflows/check.js +3 -0
- package/dist/core/agent/workflows/check.js.map +1 -1
- package/dist/core/brief/unchecked.js +1 -0
- package/dist/core/brief/unchecked.js.map +1 -1
- package/dist/core/c4/arch.js +72 -180
- package/dist/core/c4/arch.js.map +1 -1
- package/dist/core/c4/likec4.js +2 -0
- package/dist/core/c4/likec4.js.map +1 -1
- package/dist/core/c4/parsed/deployment.js +158 -0
- package/dist/core/c4/parsed/deployment.js.map +1 -0
- package/dist/core/c4/project/load.js +4 -0
- package/dist/core/c4/project/load.js.map +1 -1
- package/dist/core/c4/project/staged.js +220 -0
- package/dist/core/c4/project/staged.js.map +1 -0
- package/dist/core/c4/resolve/service.js +46 -0
- package/dist/core/c4/resolve/service.js.map +1 -1
- package/dist/core/c4/resolve/steps.js +144 -0
- package/dist/core/c4/resolve/steps.js.map +1 -0
- package/dist/core/c4/splice/delta-blocks.js +15 -2
- package/dist/core/c4/splice/delta-blocks.js.map +1 -1
- package/dist/core/c4/workspace.js +2 -0
- package/dist/core/c4/workspace.js.map +1 -1
- package/dist/core/coherence/coherence.js +36 -0
- package/dist/core/coherence/coherence.js.map +1 -1
- package/dist/core/deployment/delta.js +188 -0
- package/dist/core/deployment/delta.js.map +1 -0
- package/dist/core/deployment/objects.js +145 -0
- package/dist/core/deployment/objects.js.map +1 -0
- package/dist/core/pack/living.js +19 -0
- package/dist/core/pack/living.js.map +1 -1
- package/dist/core/repo/authored/paths.js +18 -0
- package/dist/core/repo/authored/paths.js.map +1 -1
- package/dist/core/repo/paths.js +44 -3
- package/dist/core/repo/paths.js.map +1 -1
- package/dist/core/repo/tree/render/body.js +219 -0
- package/dist/core/repo/tree/render/body.js.map +1 -0
- package/dist/core/repo/tree/render/stale.js +26 -0
- package/dist/core/repo/tree/render/stale.js.map +1 -0
- package/dist/core/repo/tree/render/text.js +84 -0
- package/dist/core/repo/tree/render/text.js.map +1 -0
- package/dist/core/repo/tree/{views.js → render/views.js} +38 -34
- package/dist/core/repo/tree/render/views.js.map +1 -0
- package/dist/core/status/feature/artifacts.js +29 -4
- package/dist/core/status/feature/artifacts.js.map +1 -1
- package/dist/core/status/fleet/fleet.js +11 -1
- package/dist/core/status/fleet/fleet.js.map +1 -1
- package/dist/core/usecases/delta/overlay.js +28 -191
- package/dist/core/usecases/delta/overlay.js.map +1 -1
- package/dist/core/usecases/fleet.js.map +1 -1
- package/dist/core/usecases/operations.js +2 -2
- package/dist/core/usecases/operations.js.map +1 -1
- package/dist/core/vocabulary/issue.js +3 -0
- package/dist/core/vocabulary/issue.js.map +1 -1
- package/examples/docs/architecture/subsystems.likec4 +4 -0
- package/package.json +1 -1
- package/dist/core/repo/tree/views.js.map +0 -1
package/dist/core/repo/paths.js
CHANGED
|
@@ -140,7 +140,7 @@ export function featureSpecPaths(featureDir, service) {
|
|
|
140
140
|
};
|
|
141
141
|
}
|
|
142
142
|
export function landscapePath(docsDir) {
|
|
143
|
-
return join(docsDir, "architecture",
|
|
143
|
+
return join(docsDir, "architecture", LANDSCAPE_FILE);
|
|
144
144
|
}
|
|
145
145
|
/** The fleet's authorization vocabulary — beside the fleet map, for the same reason. */
|
|
146
146
|
export function permissionsPath(docsDir) {
|
|
@@ -186,12 +186,53 @@ export function fleetAdrsDir(docsDir) {
|
|
|
186
186
|
* The GENERATED subsystem views — beside the fleet map because the LikeC4
|
|
187
187
|
* renderer merges the whole `architecture/` project, and a view can only
|
|
188
188
|
* `include` elements the landscape beside it defines. `loam subsystem sync`
|
|
189
|
-
* owns the bytes (`core/repo/tree/views.ts` renders them); nothing in loam
|
|
189
|
+
* owns the bytes (`core/repo/tree/render/views.ts` renders them); nothing in loam
|
|
190
190
|
* ever parses the file, and staleness against the tree is a byte compare.
|
|
191
191
|
*/
|
|
192
192
|
export function subsystemViewsPath(docsDir) {
|
|
193
|
-
return join(docsDir, "architecture",
|
|
193
|
+
return join(docsDir, "architecture", SUBSYSTEM_VIEWS_FILE);
|
|
194
194
|
}
|
|
195
|
+
/** The generated views' file name, spelled once because the reservation below reads it too. */
|
|
196
|
+
const SUBSYSTEM_VIEWS_FILE = "subsystems.likec4";
|
|
197
|
+
/**
|
|
198
|
+
* Names inside `architecture/` that LOAM owns, and what goes wrong if a
|
|
199
|
+
* feature's own document claims one.
|
|
200
|
+
*
|
|
201
|
+
* `features/<FEAT>/deployment/<name>.likec4` merges as a whole-file copy
|
|
202
|
+
* straight into `architecture/` — unlike a flow, which lands under
|
|
203
|
+
* `usecases/` — so its `<name>` is free to be any of these, and two of them
|
|
204
|
+
* fail in ways nothing downstream would report:
|
|
205
|
+
*
|
|
206
|
+
* - `subsystems.likec4` is GENERATED and is EXCLUDED from every project load
|
|
207
|
+
* (docs/DESIGN.md rule 26 keeps it a byte compare), so topology landing
|
|
208
|
+
* there is invisible to loam from the moment it archives — and the next
|
|
209
|
+
* `loam subsystem sync` overwrites the file wholesale. Silent both ways,
|
|
210
|
+
* which is the worst shape a failure can have;
|
|
211
|
+
* - `landscape.likec4` is the living map the archive SPLICES; a whole-file
|
|
212
|
+
* copy over it would discard the fleet;
|
|
213
|
+
* - `usecases/` is the flow tree, walked by the use-case scan. A deployment
|
|
214
|
+
* document filed there parses (one project) and is then looked for by a
|
|
215
|
+
* reader asking a different question.
|
|
216
|
+
*
|
|
217
|
+
* Returns the reason, or null when the name is the author's to choose. Takes
|
|
218
|
+
* the `/`-separated rel a directory walk produced, never a string from argv.
|
|
219
|
+
*/
|
|
220
|
+
export function reservedArchitectureName(rel) {
|
|
221
|
+
const parts = rel.split("/");
|
|
222
|
+
if (parts[0] === USECASE_DIR) {
|
|
223
|
+
return `\`${USECASE_DIR}/\` is the use-case tree — a flow belongs in \`features/<FEAT>/usecases/\`, which merges there`;
|
|
224
|
+
}
|
|
225
|
+
if (rel === SUBSYSTEM_VIEWS_FILE) {
|
|
226
|
+
return "`subsystems.likec4` is generated by `loam subsystem sync`, which overwrites it wholesale — and nothing in loam ever parses it, so anything landing there is invisible from the moment it archives";
|
|
227
|
+
}
|
|
228
|
+
if (rel === LANDSCAPE_FILE) {
|
|
229
|
+
return "`landscape.likec4` is the living fleet map, which the archive SPLICES into rather than replaces";
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
/** The living map's file name, and the flow tree's directory — spelled once, read by the reservation. */
|
|
234
|
+
const LANDSCAPE_FILE = "landscape.likec4";
|
|
235
|
+
const USECASE_DIR = "usecases";
|
|
195
236
|
export function featuresDir(docsDir) {
|
|
196
237
|
return join(docsDir, "features");
|
|
197
238
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"paths.js","sourceRoot":"","sources":["../../../src/core/repo/paths.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Where every artifact lives. Artifact filenames are spelled here and nowhere\n * else, so a rename is one edit rather than a grep.\n *\n * This is also the module that carries loam's one path guarantee.\n * `unfiledServicePaths(docsDir, service)` spells `<docsDir>/services/<service>/`,\n * so both parts are caller-controlled path input, and `node:path` cannot help:\n * `join(...paths: string[])` accepts every string there is. The guarantee IS\n * the parameter brands and nothing else — `PathableService` for the id (a name\n * whose provenance is the repository, unconstructible from document text),\n * `DocsDir`/`FeatureDir` for the roots (a resolved config or validated\n * `--docs`; a directory an enumeration read), and `ServiceDir` for\n * `servicePathsAt` (the enumeration's own resolved directory, narrower still)\n * — the `kernel/ids/` package holds the only casts, so an unchecked value at\n * these call sites does not compile.\n * Code that spells `services/<id>/` or a feature layout with a bare join is\n * outside the guarantee — `commands/new.ts` and the openspec migrator both do,\n * held instead by `resolveInside` at the write.\n */\nimport { join } from \"node:path\";\nimport { serviceDirOf, type DocsDir, type FeatureDir, type ServiceDir } from \"../kernel/ids/dirs.js\";\nimport type { PathableService } from \"../kernel/ids/service.js\";\nimport type { SubsystemName } from \"../kernel/ids/subsystem.js\";\n\n/** Directory under features/ holding shipped features. Never a feature itself. */\nexport const ARCHIVE_DIR = \"archive\";\n\n/* ------------------------------------------------------------------ */\n/* Paths — artifact filenames are spelled here and nowhere else */\n/* ------------------------------------------------------------------ */\n\n/**\n * The living artifact filenames, as one table, because three questions read\n * them: `servicePathsAt` below spells the paths, the tree walk classifies\n * a directory as a service by whether any of these names sit in it, and\n * `core/diff/base-state.ts` classifies the SAME way over a base git ref's\n * tree listing (exported for that reader — a filename respelled there would\n * be the drift this table exists to prevent). The walk cannot import this\n * module — `repo → repo/tree` is the package direction, and an edge back\n * would be the cycle `scripts/package-graph.mjs` refuses — so `repo.ts`\n * injects `isServiceArtifactName` into the walk request instead.\n */\nexport const ARTIFACT_FILES = {\n model: \"model.likec4\",\n spec: \"spec.md\",\n archSpec: \"arch.spec.md\",\n openapi: \"openapi.yaml\",\n asyncapi: \"asyncapi.yaml\",\n runbook: \"runbook.md\",\n health: \"health.yaml\",\n steps: \"steps.yaml\",\n} as const;\nconst ADRS_DIR = \"adrs\";\n\n/** Does a directory entry name a living service artifact? The classification half of the table above. */\nexport function isServiceArtifactName(name: string, kind: \"file\" | \"dir\"): boolean {\n if (kind === \"dir\") return name === ADRS_DIR;\n return Object.values(ARTIFACT_FILES).some((file) => file === name);\n}\n\nexport interface ServicePaths {\n dir: ServiceDir;\n model: string;\n spec: string;\n archSpec: string;\n openapi: string;\n /** The async contract — the event axis's sibling of `openapi`. */\n asyncapi: string;\n runbook: string;\n health: string;\n /**\n * The step catalogue — which phrases this service's suite has DECIDED to\n * define. Authored and opt-in; its CONTENTS are read by `loam steps` alone and\n * nothing in `validate` consults them\n * (`core/gherkin/steps/catalogue.ts` records why).\n *\n * Its EXISTENCE is read more widely, and that is not the same statement: the\n * filename is in `ARTIFACT_FILES`, so `isServiceArtifactName` classifies it\n * and the tree walk treats a directory holding one as a service. That is\n * correct — it is a service artifact — and it is worth writing down, because\n * it means a `steps.yaml` written at the wrong path mints a service.\n */\n steps: string;\n adrsDir: string;\n}\n\n/**\n * The artifact paths of a service whose directory is already KNOWN — the\n * enumeration's `dir`, at whatever depth the tree walk found it. This is the\n * spelling every reader of an existing service must use: joining\n * `services/<id>/` at the root is only true for a fleet nobody has filed, and\n * a moved service would silently grade as absent through it.\n */\nexport function servicePathsAt(dir: ServiceDir): ServicePaths {\n return {\n dir,\n model: join(dir, ARTIFACT_FILES.model),\n spec: join(dir, ARTIFACT_FILES.spec),\n archSpec: join(dir, ARTIFACT_FILES.archSpec),\n openapi: join(dir, ARTIFACT_FILES.openapi),\n asyncapi: join(dir, ARTIFACT_FILES.asyncapi),\n runbook: join(dir, ARTIFACT_FILES.runbook),\n health: join(dir, ARTIFACT_FILES.health),\n steps: join(dir, ARTIFACT_FILES.steps),\n adrsDir: join(dir, ADRS_DIR),\n };\n}\n\n/**\n * The UNFILED spelling — `<docsDir>/services/<id>/`, the tree's root level.\n * This is where creation lands (`adopt`'s brief, a new service materialised by\n * `archive`, the OpenSpec migration) and the honest fallback for a service the\n * enumeration does not answer; for a service that EXISTS, resolve through the\n * enumeration instead (`servicePathsAt(entry.dir)`, or\n * `locateServicePaths` in `service-target.ts`, which chooses between the two).\n * Renamed from `servicePaths` when the tree landed, precisely so the compiler\n * would put every remaining caller in front of a reviewer: a root join that\n * survives unreviewed is a moved service silently grading as absent.\n */\nexport function unfiledServicePaths(docsDir: DocsDir, service: PathableService): ServicePaths {\n return servicePathsAt(serviceDirOf(join(docsDir, \"services\", service)));\n}\n\n/**\n * The creation spelling INSIDE a subsystem — `<subsystemDir>/<id>/`, the\n * target `adopt --subsystem` briefs so an adoption need not land unfiled and\n * cost a second command. The same two provenances the unfiled spelling\n * combines, one level down: the subsystem directory came off the tree walk's\n * readdir, and the id passed the service grammar before it reached here.\n */\nexport function servicePathsUnder(subsystemDir: string, service: PathableService): ServicePaths {\n return servicePathsAt(serviceDirOf(join(subsystemDir, service)));\n}\n\n/**\n * The directory a SUBSYSTEM occupies (or will occupy) under a parent — the\n * spelling `subsystem new` creates and `subsystem rename` renames into. The\n * same guarantee-by-brand as the service spellings above: the parent came off\n * the tree walk's readdir (or is `servicesDir` itself), and demanding\n * `SubsystemName` here is what makes the validator unskippable — a raw argv\n * string at this call site does not compile, so no future verb can hoist the\n * join above the check that earns it.\n */\nexport function subsystemPathUnder(parentDir: string, name: SubsystemName): string {\n return join(parentDir, name);\n}\n\n/**\n * The pair of requirement-carrying spec files, living and delta alike: the\n * business spec and the architecture spec. Same grammar, same delta algebra,\n * same merge — everything that walks \"the spec files\" of a service walks this\n * list, so the two axes cannot drift apart in what handles them. `key` indexes\n * ServicePaths/FeatureSpecPaths; `label` is how the axis names itself in prose.\n */\nexport const SPEC_AXES = [\n { key: \"spec\", file: \"spec.md\", label: \"requirements\" },\n { key: \"archSpec\", file: \"arch.spec.md\", label: \"arch requirements\" },\n] as const;\nexport type SpecAxis = (typeof SPEC_AXES)[number];\n\nexport interface FeaturePaths {\n dir: string;\n intent: string;\n delta: string;\n specsDir: string;\n adrsDir: string;\n}\n\nexport function featurePaths(featureDir: FeatureDir): FeaturePaths {\n return {\n dir: featureDir,\n intent: join(featureDir, \"intent.md\"),\n delta: join(featureDir, \"delta.likec4\"),\n specsDir: join(featureDir, \"specs\"),\n adrsDir: join(featureDir, \"adrs\"),\n };\n}\n\nexport interface FeatureSpecPaths {\n dir: string;\n spec: string;\n archSpec: string;\n openapi: string;\n /** The async contract delta — the event axis's sibling of `openapi`. */\n asyncapi: string;\n}\n\nexport function featureSpecPaths(featureDir: FeatureDir, service: PathableService): FeatureSpecPaths {\n const dir = join(featureDir, \"specs\", service);\n return {\n dir,\n spec: join(dir, \"spec.md\"),\n archSpec: join(dir, \"arch.spec.md\"),\n openapi: join(dir, \"openapi.yaml\"),\n asyncapi: join(dir, \"asyncapi.yaml\"),\n };\n}\n\nexport function landscapePath(docsDir: DocsDir): string {\n return join(docsDir, \"architecture\", \"landscape.likec4\");\n}\n\n/** The fleet's authorization vocabulary — beside the fleet map, for the same reason. */\nexport function permissionsPath(docsDir: DocsDir): string {\n return join(docsDir, \"architecture\", \"permissions.yaml\");\n}\n\n/** The fleet's capability vocabulary — beside the other two fleet documents, for the same reason. */\nexport function capabilitiesPath(docsDir: DocsDir): string {\n return join(docsDir, \"architecture\", \"capabilities.yaml\");\n}\n\n/**\n * The fleet's ARCHITECTURAL OBLIGATION vocabulary — beside the landscape it tags\n * and the two vocabularies it sits with, for their reason: it is a fact about\n * the fleet that no single service owns.\n */\nexport function obligationsPath(docsDir: DocsDir): string {\n return join(docsDir, \"architecture\", \"obligations.yaml\");\n}\n\n/**\n * The FLEET's decision records — `architecture/adrs/`, beside the landscape and\n * the two vocabularies.\n *\n * ADRs used to exist at two altitudes only, `services/<id>/adrs/` and\n * `features/<FEAT>/adrs/`, so a decision about the fleet itself — \"event\n * publishers use a transactional outbox\", \"cross-service calls carry a circuit\n * breaker\" — had no home but one arbitrary service's directory, where the next\n * reader of the other fifty services never finds it. This is not a new axis: it\n * is the same `ADRS_DIR` name one level up, spelled through the same constant so\n * the two altitudes cannot drift into `adrs/` and `decisions/`.\n *\n * PRESENCE-TRACKED AND NOTHING MORE, exactly like the service directory it\n * mirrors: `loam list` counts the files (`repo.ts`'s `fleetAdrCount`) and no\n * check anywhere reads one. A fleet with no fleet-level ADRs owes nothing and\n * must produce no finding — which is also why `loam init` does NOT scaffold the\n * directory. git does not carry an empty one, so it would vanish on the first\n * clone and come back as a diff on the next `init`; and an empty `adrs/` in a\n * fresh repo reads as an obligation nobody has met, when there is no obligation\n * at all.\n */\nexport function fleetAdrsDir(docsDir: DocsDir): string {\n return join(docsDir, \"architecture\", ADRS_DIR);\n}\n\n/**\n * The GENERATED subsystem views — beside the fleet map because the LikeC4\n * renderer merges the whole `architecture/` project, and a view can only\n * `include` elements the landscape beside it defines. `loam subsystem sync`\n * owns the bytes (`core/repo/tree/views.ts` renders them); nothing in loam\n * ever parses the file, and staleness against the tree is a byte compare.\n */\nexport function subsystemViewsPath(docsDir: DocsDir): string {\n return join(docsDir, \"architecture\", \"subsystems.likec4\");\n}\n\nexport function featuresDir(docsDir: DocsDir): string {\n return join(docsDir, \"features\");\n}\n\n/**\n * The root of the tree that IS the fleet — where an unfiled service and a\n * root-level subsystem both sit. The `subsystem` verbs join validated names\n * under it; the enumerating walk spells the same join for itself\n * (`repo/tree/walk.ts`), because it cannot import this module.\n */\nexport function servicesDir(docsDir: DocsDir): string {\n return join(docsDir, \"services\");\n}\n\n/**\n * The agent contract's filename, and where it sits in a docs repo.\n *\n * Both spellings are exported because the two questions are different: `init`,\n * `doctor` and `validate` want a path under a docsDir they already hold, while\n * `docs.ts` builds the scaffold as `[relative path, bytes]` pairs and needs the\n * bare name. Four modules spelled the literal out for themselves, and one of\n * them — `init` — uses it to decide whether a directory IS a docs repo at all,\n * so a rename that reached three of the four would leave `init` recognising a\n * file nothing else writes any more.\n *\n * `openspec-inventory.ts`'s \"AGENTS.md\" is deliberately NOT this constant: it\n * names a file in a foreign OpenSpec tree that happens to share the name.\n */\nexport const AGENTS_FILENAME = \"AGENTS.md\";\n\nexport function agentsPath(docsDir: DocsDir): string {\n return join(docsDir, AGENTS_FILENAME);\n}\n\n/**\n * Where shipped features live. `features/archive/` is spelled here and nowhere\n * else for the same reason every other artifact path is: `archive` and\n * `unarchive` move directories in and out of it, and a layout string that two\n * commands re-derive is a layout two commands can disagree about. The\n * `features/archive/…` strings in refusal PROSE are not this path — they are\n * what a reader types into `ls`, and they stay spelled out.\n */\nexport function archiveDir(docsDir: DocsDir): string {\n return join(featuresDir(docsDir), ARCHIVE_DIR);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"paths.js","sourceRoot":"","sources":["../../../src/core/repo/paths.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Where every artifact lives. Artifact filenames are spelled here and nowhere\n * else, so a rename is one edit rather than a grep.\n *\n * This is also the module that carries loam's one path guarantee.\n * `unfiledServicePaths(docsDir, service)` spells `<docsDir>/services/<service>/`,\n * so both parts are caller-controlled path input, and `node:path` cannot help:\n * `join(...paths: string[])` accepts every string there is. The guarantee IS\n * the parameter brands and nothing else — `PathableService` for the id (a name\n * whose provenance is the repository, unconstructible from document text),\n * `DocsDir`/`FeatureDir` for the roots (a resolved config or validated\n * `--docs`; a directory an enumeration read), and `ServiceDir` for\n * `servicePathsAt` (the enumeration's own resolved directory, narrower still)\n * — the `kernel/ids/` package holds the only casts, so an unchecked value at\n * these call sites does not compile.\n * Code that spells `services/<id>/` or a feature layout with a bare join is\n * outside the guarantee — `commands/new.ts` and the openspec migrator both do,\n * held instead by `resolveInside` at the write.\n */\nimport { join } from \"node:path\";\nimport { serviceDirOf, type DocsDir, type FeatureDir, type ServiceDir } from \"../kernel/ids/dirs.js\";\nimport type { PathableService } from \"../kernel/ids/service.js\";\nimport type { SubsystemName } from \"../kernel/ids/subsystem.js\";\n\n/** Directory under features/ holding shipped features. Never a feature itself. */\nexport const ARCHIVE_DIR = \"archive\";\n\n/* ------------------------------------------------------------------ */\n/* Paths — artifact filenames are spelled here and nowhere else */\n/* ------------------------------------------------------------------ */\n\n/**\n * The living artifact filenames, as one table, because three questions read\n * them: `servicePathsAt` below spells the paths, the tree walk classifies\n * a directory as a service by whether any of these names sit in it, and\n * `core/diff/base-state.ts` classifies the SAME way over a base git ref's\n * tree listing (exported for that reader — a filename respelled there would\n * be the drift this table exists to prevent). The walk cannot import this\n * module — `repo → repo/tree` is the package direction, and an edge back\n * would be the cycle `scripts/package-graph.mjs` refuses — so `repo.ts`\n * injects `isServiceArtifactName` into the walk request instead.\n */\nexport const ARTIFACT_FILES = {\n model: \"model.likec4\",\n spec: \"spec.md\",\n archSpec: \"arch.spec.md\",\n openapi: \"openapi.yaml\",\n asyncapi: \"asyncapi.yaml\",\n runbook: \"runbook.md\",\n health: \"health.yaml\",\n steps: \"steps.yaml\",\n} as const;\nconst ADRS_DIR = \"adrs\";\n\n/** Does a directory entry name a living service artifact? The classification half of the table above. */\nexport function isServiceArtifactName(name: string, kind: \"file\" | \"dir\"): boolean {\n if (kind === \"dir\") return name === ADRS_DIR;\n return Object.values(ARTIFACT_FILES).some((file) => file === name);\n}\n\nexport interface ServicePaths {\n dir: ServiceDir;\n model: string;\n spec: string;\n archSpec: string;\n openapi: string;\n /** The async contract — the event axis's sibling of `openapi`. */\n asyncapi: string;\n runbook: string;\n health: string;\n /**\n * The step catalogue — which phrases this service's suite has DECIDED to\n * define. Authored and opt-in; its CONTENTS are read by `loam steps` alone and\n * nothing in `validate` consults them\n * (`core/gherkin/steps/catalogue.ts` records why).\n *\n * Its EXISTENCE is read more widely, and that is not the same statement: the\n * filename is in `ARTIFACT_FILES`, so `isServiceArtifactName` classifies it\n * and the tree walk treats a directory holding one as a service. That is\n * correct — it is a service artifact — and it is worth writing down, because\n * it means a `steps.yaml` written at the wrong path mints a service.\n */\n steps: string;\n adrsDir: string;\n}\n\n/**\n * The artifact paths of a service whose directory is already KNOWN — the\n * enumeration's `dir`, at whatever depth the tree walk found it. This is the\n * spelling every reader of an existing service must use: joining\n * `services/<id>/` at the root is only true for a fleet nobody has filed, and\n * a moved service would silently grade as absent through it.\n */\nexport function servicePathsAt(dir: ServiceDir): ServicePaths {\n return {\n dir,\n model: join(dir, ARTIFACT_FILES.model),\n spec: join(dir, ARTIFACT_FILES.spec),\n archSpec: join(dir, ARTIFACT_FILES.archSpec),\n openapi: join(dir, ARTIFACT_FILES.openapi),\n asyncapi: join(dir, ARTIFACT_FILES.asyncapi),\n runbook: join(dir, ARTIFACT_FILES.runbook),\n health: join(dir, ARTIFACT_FILES.health),\n steps: join(dir, ARTIFACT_FILES.steps),\n adrsDir: join(dir, ADRS_DIR),\n };\n}\n\n/**\n * The UNFILED spelling — `<docsDir>/services/<id>/`, the tree's root level.\n * This is where creation lands (`adopt`'s brief, a new service materialised by\n * `archive`, the OpenSpec migration) and the honest fallback for a service the\n * enumeration does not answer; for a service that EXISTS, resolve through the\n * enumeration instead (`servicePathsAt(entry.dir)`, or\n * `locateServicePaths` in `service-target.ts`, which chooses between the two).\n * Renamed from `servicePaths` when the tree landed, precisely so the compiler\n * would put every remaining caller in front of a reviewer: a root join that\n * survives unreviewed is a moved service silently grading as absent.\n */\nexport function unfiledServicePaths(docsDir: DocsDir, service: PathableService): ServicePaths {\n return servicePathsAt(serviceDirOf(join(docsDir, \"services\", service)));\n}\n\n/**\n * The creation spelling INSIDE a subsystem — `<subsystemDir>/<id>/`, the\n * target `adopt --subsystem` briefs so an adoption need not land unfiled and\n * cost a second command. The same two provenances the unfiled spelling\n * combines, one level down: the subsystem directory came off the tree walk's\n * readdir, and the id passed the service grammar before it reached here.\n */\nexport function servicePathsUnder(subsystemDir: string, service: PathableService): ServicePaths {\n return servicePathsAt(serviceDirOf(join(subsystemDir, service)));\n}\n\n/**\n * The directory a SUBSYSTEM occupies (or will occupy) under a parent — the\n * spelling `subsystem new` creates and `subsystem rename` renames into. The\n * same guarantee-by-brand as the service spellings above: the parent came off\n * the tree walk's readdir (or is `servicesDir` itself), and demanding\n * `SubsystemName` here is what makes the validator unskippable — a raw argv\n * string at this call site does not compile, so no future verb can hoist the\n * join above the check that earns it.\n */\nexport function subsystemPathUnder(parentDir: string, name: SubsystemName): string {\n return join(parentDir, name);\n}\n\n/**\n * The pair of requirement-carrying spec files, living and delta alike: the\n * business spec and the architecture spec. Same grammar, same delta algebra,\n * same merge — everything that walks \"the spec files\" of a service walks this\n * list, so the two axes cannot drift apart in what handles them. `key` indexes\n * ServicePaths/FeatureSpecPaths; `label` is how the axis names itself in prose.\n */\nexport const SPEC_AXES = [\n { key: \"spec\", file: \"spec.md\", label: \"requirements\" },\n { key: \"archSpec\", file: \"arch.spec.md\", label: \"arch requirements\" },\n] as const;\nexport type SpecAxis = (typeof SPEC_AXES)[number];\n\nexport interface FeaturePaths {\n dir: string;\n intent: string;\n delta: string;\n specsDir: string;\n adrsDir: string;\n}\n\nexport function featurePaths(featureDir: FeatureDir): FeaturePaths {\n return {\n dir: featureDir,\n intent: join(featureDir, \"intent.md\"),\n delta: join(featureDir, \"delta.likec4\"),\n specsDir: join(featureDir, \"specs\"),\n adrsDir: join(featureDir, \"adrs\"),\n };\n}\n\nexport interface FeatureSpecPaths {\n dir: string;\n spec: string;\n archSpec: string;\n openapi: string;\n /** The async contract delta — the event axis's sibling of `openapi`. */\n asyncapi: string;\n}\n\nexport function featureSpecPaths(featureDir: FeatureDir, service: PathableService): FeatureSpecPaths {\n const dir = join(featureDir, \"specs\", service);\n return {\n dir,\n spec: join(dir, \"spec.md\"),\n archSpec: join(dir, \"arch.spec.md\"),\n openapi: join(dir, \"openapi.yaml\"),\n asyncapi: join(dir, \"asyncapi.yaml\"),\n };\n}\n\nexport function landscapePath(docsDir: DocsDir): string {\n return join(docsDir, \"architecture\", LANDSCAPE_FILE);\n}\n\n/** The fleet's authorization vocabulary — beside the fleet map, for the same reason. */\nexport function permissionsPath(docsDir: DocsDir): string {\n return join(docsDir, \"architecture\", \"permissions.yaml\");\n}\n\n/** The fleet's capability vocabulary — beside the other two fleet documents, for the same reason. */\nexport function capabilitiesPath(docsDir: DocsDir): string {\n return join(docsDir, \"architecture\", \"capabilities.yaml\");\n}\n\n/**\n * The fleet's ARCHITECTURAL OBLIGATION vocabulary — beside the landscape it tags\n * and the two vocabularies it sits with, for their reason: it is a fact about\n * the fleet that no single service owns.\n */\nexport function obligationsPath(docsDir: DocsDir): string {\n return join(docsDir, \"architecture\", \"obligations.yaml\");\n}\n\n/**\n * The FLEET's decision records — `architecture/adrs/`, beside the landscape and\n * the two vocabularies.\n *\n * ADRs used to exist at two altitudes only, `services/<id>/adrs/` and\n * `features/<FEAT>/adrs/`, so a decision about the fleet itself — \"event\n * publishers use a transactional outbox\", \"cross-service calls carry a circuit\n * breaker\" — had no home but one arbitrary service's directory, where the next\n * reader of the other fifty services never finds it. This is not a new axis: it\n * is the same `ADRS_DIR` name one level up, spelled through the same constant so\n * the two altitudes cannot drift into `adrs/` and `decisions/`.\n *\n * PRESENCE-TRACKED AND NOTHING MORE, exactly like the service directory it\n * mirrors: `loam list` counts the files (`repo.ts`'s `fleetAdrCount`) and no\n * check anywhere reads one. A fleet with no fleet-level ADRs owes nothing and\n * must produce no finding — which is also why `loam init` does NOT scaffold the\n * directory. git does not carry an empty one, so it would vanish on the first\n * clone and come back as a diff on the next `init`; and an empty `adrs/` in a\n * fresh repo reads as an obligation nobody has met, when there is no obligation\n * at all.\n */\nexport function fleetAdrsDir(docsDir: DocsDir): string {\n return join(docsDir, \"architecture\", ADRS_DIR);\n}\n\n/**\n * The GENERATED subsystem views — beside the fleet map because the LikeC4\n * renderer merges the whole `architecture/` project, and a view can only\n * `include` elements the landscape beside it defines. `loam subsystem sync`\n * owns the bytes (`core/repo/tree/render/views.ts` renders them); nothing in loam\n * ever parses the file, and staleness against the tree is a byte compare.\n */\nexport function subsystemViewsPath(docsDir: DocsDir): string {\n return join(docsDir, \"architecture\", SUBSYSTEM_VIEWS_FILE);\n}\n\n/** The generated views' file name, spelled once because the reservation below reads it too. */\nconst SUBSYSTEM_VIEWS_FILE = \"subsystems.likec4\";\n\n/**\n * Names inside `architecture/` that LOAM owns, and what goes wrong if a\n * feature's own document claims one.\n *\n * `features/<FEAT>/deployment/<name>.likec4` merges as a whole-file copy\n * straight into `architecture/` — unlike a flow, which lands under\n * `usecases/` — so its `<name>` is free to be any of these, and two of them\n * fail in ways nothing downstream would report:\n *\n * - `subsystems.likec4` is GENERATED and is EXCLUDED from every project load\n * (docs/DESIGN.md rule 26 keeps it a byte compare), so topology landing\n * there is invisible to loam from the moment it archives — and the next\n * `loam subsystem sync` overwrites the file wholesale. Silent both ways,\n * which is the worst shape a failure can have;\n * - `landscape.likec4` is the living map the archive SPLICES; a whole-file\n * copy over it would discard the fleet;\n * - `usecases/` is the flow tree, walked by the use-case scan. A deployment\n * document filed there parses (one project) and is then looked for by a\n * reader asking a different question.\n *\n * Returns the reason, or null when the name is the author's to choose. Takes\n * the `/`-separated rel a directory walk produced, never a string from argv.\n */\nexport function reservedArchitectureName(rel: string): string | null {\n const parts = rel.split(\"/\");\n if (parts[0] === USECASE_DIR) {\n return `\\`${USECASE_DIR}/\\` is the use-case tree — a flow belongs in \\`features/<FEAT>/usecases/\\`, which merges there`;\n }\n if (rel === SUBSYSTEM_VIEWS_FILE) {\n return \"`subsystems.likec4` is generated by `loam subsystem sync`, which overwrites it wholesale — and nothing in loam ever parses it, so anything landing there is invisible from the moment it archives\";\n }\n if (rel === LANDSCAPE_FILE) {\n return \"`landscape.likec4` is the living fleet map, which the archive SPLICES into rather than replaces\";\n }\n return null;\n}\n\n/** The living map's file name, and the flow tree's directory — spelled once, read by the reservation. */\nconst LANDSCAPE_FILE = \"landscape.likec4\";\nconst USECASE_DIR = \"usecases\";\n\nexport function featuresDir(docsDir: DocsDir): string {\n return join(docsDir, \"features\");\n}\n\n/**\n * The root of the tree that IS the fleet — where an unfiled service and a\n * root-level subsystem both sit. The `subsystem` verbs join validated names\n * under it; the enumerating walk spells the same join for itself\n * (`repo/tree/walk.ts`), because it cannot import this module.\n */\nexport function servicesDir(docsDir: DocsDir): string {\n return join(docsDir, \"services\");\n}\n\n/**\n * The agent contract's filename, and where it sits in a docs repo.\n *\n * Both spellings are exported because the two questions are different: `init`,\n * `doctor` and `validate` want a path under a docsDir they already hold, while\n * `docs.ts` builds the scaffold as `[relative path, bytes]` pairs and needs the\n * bare name. Four modules spelled the literal out for themselves, and one of\n * them — `init` — uses it to decide whether a directory IS a docs repo at all,\n * so a rename that reached three of the four would leave `init` recognising a\n * file nothing else writes any more.\n *\n * `openspec-inventory.ts`'s \"AGENTS.md\" is deliberately NOT this constant: it\n * names a file in a foreign OpenSpec tree that happens to share the name.\n */\nexport const AGENTS_FILENAME = \"AGENTS.md\";\n\nexport function agentsPath(docsDir: DocsDir): string {\n return join(docsDir, AGENTS_FILENAME);\n}\n\n/**\n * Where shipped features live. `features/archive/` is spelled here and nowhere\n * else for the same reason every other artifact path is: `archive` and\n * `unarchive` move directories in and out of it, and a layout string that two\n * commands re-derive is a layout two commands can disagree about. The\n * `features/archive/…` strings in refusal PROSE are not this path — they are\n * what a reader types into `ls`, and they stay spelled out.\n */\nexport function archiveDir(docsDir: DocsDir): string {\n return join(featuresDir(docsDir), ARCHIVE_DIR);\n}\n"]}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one subsystem's generated view SHOWS: which boxes it draws around the
|
|
3
|
+
* services beneath a directory, and the one thing it may say about the map.
|
|
4
|
+
*
|
|
5
|
+
* Split from `../views.ts` when that file crossed the 400-line limit: the file
|
|
6
|
+
* render (header, view ids, staleness comparison) and the body render (the
|
|
7
|
+
* boxes) are two subjects, and the seam is exactly where the second one
|
|
8
|
+
* consults the LANDSCAPE. Everything here reads element ids and nothing else —
|
|
9
|
+
* no view is ever parsed (docs/DESIGN.md rule 26), and the generated file
|
|
10
|
+
* stays a pure function of the tree plus the committed map.
|
|
11
|
+
*/
|
|
12
|
+
import {} from "../../../c4/likec4.js";
|
|
13
|
+
import { serviceResolver } from "../../../c4/resolve/service.js";
|
|
14
|
+
import { servicesUnder, withinSubsystem } from "../find.js";
|
|
15
|
+
import { subsystemLabel } from "./text.js";
|
|
16
|
+
export function renderContext(tree, elements) {
|
|
17
|
+
const known = new Set(tree.services.map((s) => s.id));
|
|
18
|
+
const resolve = serviceResolver(elements, known);
|
|
19
|
+
return {
|
|
20
|
+
tree,
|
|
21
|
+
elementOf: elementByService(tree, elements, resolve),
|
|
22
|
+
elementIds: new Set(elements.map((e) => e.id)),
|
|
23
|
+
isServiceElement: (id) => known.has(resolve(id)),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Three shapes, and which one applies is a fact about the landscape rather
|
|
28
|
+
* than a setting:
|
|
29
|
+
*
|
|
30
|
+
* 1. **The model draws this group.** Some ancestor element holds exactly this
|
|
31
|
+
* subsystem's members and nothing else, so the view includes it beside the
|
|
32
|
+
* leaves and the renderer nests them inside the box the AUTHOR drew, with
|
|
33
|
+
* the author's own title. Measured: including the ancestor alone renders
|
|
34
|
+
* one empty box and the leaves alone render flat — both lines are needed.
|
|
35
|
+
* 2. **The model is flat.** No member element is nested at all, so there is
|
|
36
|
+
* no authored box to agree with, and the view wraps the members in a
|
|
37
|
+
* `group '<title>'` of its own — a real named box in the renderer, derived
|
|
38
|
+
* from the DIRECTORY the services already sit in, mirroring the subtree so
|
|
39
|
+
* a parent subsystem shows its children as boxes rather than as one pile.
|
|
40
|
+
* Nothing is invented: the grouping is the tree, and the tree is what this
|
|
41
|
+
* file has always mirrored.
|
|
42
|
+
* 3. **The model nests them, differently.** The members ARE nested but no
|
|
43
|
+
* ancestor owns exactly them — the directory tree and the map disagree
|
|
44
|
+
* about this group. loam draws no box (a synthetic one would silently
|
|
45
|
+
* replace the author's boundary with the folder's, and the picture would
|
|
46
|
+
* look the same whether or not the two agree) and writes ONE comment line
|
|
47
|
+
* saying so, outside the view body. That comment is the whole of loam's
|
|
48
|
+
* answer to the drift: it grades nothing, it fails nothing, and it appears
|
|
49
|
+
* in the diff of the very commit that caused it — placement stays a fact
|
|
50
|
+
* nothing branches on.
|
|
51
|
+
*/
|
|
52
|
+
export function viewBody(ctx, sub) {
|
|
53
|
+
const members = memberElements(ctx, sub);
|
|
54
|
+
if (members.length === 0)
|
|
55
|
+
return { lines: [], disclosure: null };
|
|
56
|
+
if (members.every((id) => !id.includes(".")))
|
|
57
|
+
return { lines: groupedBody(ctx, sub, " "), disclosure: null };
|
|
58
|
+
const lines = [...ownedAncestors(ctx, sub), ...members].map((id) => ` include ${id}`);
|
|
59
|
+
if (owningAncestor(ctx, sub) !== null)
|
|
60
|
+
return { lines, disclosure: null };
|
|
61
|
+
const shared = sharedAncestor(ctx, members);
|
|
62
|
+
return {
|
|
63
|
+
lines,
|
|
64
|
+
disclosure: shared === null
|
|
65
|
+
? "model: no boundary — these members do not share a containing element."
|
|
66
|
+
: `model: no boundary — ${shared} also holds services outside this subsystem.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The flat-model body: this subsystem as a box, its child subsystems as boxes
|
|
71
|
+
* inside it, its own services as leaves. Recursive, and the recursion is the
|
|
72
|
+
* point — a parent subsystem's view is otherwise one undifferentiated pile of
|
|
73
|
+
* every service beneath it.
|
|
74
|
+
*
|
|
75
|
+
* An empty child renders an empty named box, which is what the tree says: the
|
|
76
|
+
* group exists and holds nothing yet.
|
|
77
|
+
*/
|
|
78
|
+
function groupedBody(ctx, sub, indent) {
|
|
79
|
+
const inner = `${indent} `;
|
|
80
|
+
const out = [`${indent}group '${subsystemLabel(sub)}' {`];
|
|
81
|
+
for (const child of childSubsystems(ctx, sub))
|
|
82
|
+
out.push(...groupedBody(ctx, child, inner));
|
|
83
|
+
for (const id of directMemberElements(ctx, sub))
|
|
84
|
+
out.push(`${inner}include ${id}`);
|
|
85
|
+
out.push(`${indent}}`);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** The child subsystems one level down, ordered by name. */
|
|
89
|
+
function childSubsystems(ctx, sub) {
|
|
90
|
+
return ctx.tree.subsystems
|
|
91
|
+
.filter((c) => c.path.length === sub.path.length + 1 && withinSubsystem(sub.path, c.path))
|
|
92
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
93
|
+
}
|
|
94
|
+
/** The drawn elements of the services filed in THIS directory, not in a child. */
|
|
95
|
+
function directMemberElements(ctx, sub) {
|
|
96
|
+
return drawnElements(ctx, membersOf(ctx.tree, sub).filter((s) => s.subsystem.length === sub.path.length));
|
|
97
|
+
}
|
|
98
|
+
/** The drawn elements of every service beneath this subsystem, ordered by service id. */
|
|
99
|
+
function memberElements(ctx, sub) {
|
|
100
|
+
return drawnElements(ctx, membersOf(ctx.tree, sub));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The element ids these services resolve to. A service the map does not draw
|
|
104
|
+
* is omitted rather than invented: the omission is already an error
|
|
105
|
+
* (`landscape.service-unmodelled`), and rendering only what the committed
|
|
106
|
+
* documents resolve keeps the output a function of committed bytes alone.
|
|
107
|
+
*/
|
|
108
|
+
function drawnElements(ctx, services) {
|
|
109
|
+
return services.map((s) => ctx.elementOf.get(s.id)).filter((id) => id !== undefined);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The boxes a nested-model view draws: this subsystem's own owning ancestor
|
|
113
|
+
* when it has one, plus the owning ancestor of every subsystem beneath it — so
|
|
114
|
+
* a parent view keeps its children's boundaries instead of flattening them
|
|
115
|
+
* into one box. Sorted and de-duplicated, because two subsystems resolve to
|
|
116
|
+
* one element only in a tree that already reports a collision.
|
|
117
|
+
*/
|
|
118
|
+
function ownedAncestors(ctx, sub) {
|
|
119
|
+
const found = new Set();
|
|
120
|
+
for (const candidate of ctx.tree.subsystems) {
|
|
121
|
+
if (!withinSubsystem(sub.path, candidate.path))
|
|
122
|
+
continue;
|
|
123
|
+
const owner = owningAncestor(ctx, candidate);
|
|
124
|
+
if (owner !== null)
|
|
125
|
+
found.add(owner);
|
|
126
|
+
}
|
|
127
|
+
return [...found].sort();
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The deepest ancestor element that holds exactly this subsystem's members —
|
|
131
|
+
* or null when no element does.
|
|
132
|
+
*
|
|
133
|
+
* "Exactly" is the whole guard, and dropping it was measured on loam's own
|
|
134
|
+
* `examples/docs`: the members' longest common ancestor there is a group of
|
|
135
|
+
* five services while the subsystem holds two, so an unguarded include would
|
|
136
|
+
* generate a box labelled for the group with two of its five children drawn
|
|
137
|
+
* inside it — a diagram asserting something false, in a file the reader is
|
|
138
|
+
* told not to edit. Tightness is judged over ELEMENTS rather than over
|
|
139
|
+
* documented services: mid-adoption a group commonly holds systems no
|
|
140
|
+
* `services/<id>/` exists for yet, and counting only the documented ones would
|
|
141
|
+
* draw that same false box for the whole of an adoption.
|
|
142
|
+
*
|
|
143
|
+
* An ancestor that is itself a service's element is refused separately: one
|
|
144
|
+
* service's box containing another's is a picture of nothing.
|
|
145
|
+
*/
|
|
146
|
+
function owningAncestor(ctx, sub) {
|
|
147
|
+
const members = memberElements(ctx, sub);
|
|
148
|
+
if (members.length === 0)
|
|
149
|
+
return null;
|
|
150
|
+
const owned = new Set(members);
|
|
151
|
+
for (const candidate of ancestorChain(members)) {
|
|
152
|
+
if (!ctx.elementIds.has(candidate) || ctx.isServiceElement(candidate))
|
|
153
|
+
continue;
|
|
154
|
+
if (ownsExactly(ctx, candidate, owned))
|
|
155
|
+
return candidate;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
/** The deepest common ancestor the map declares at all — the disclosure's subject. */
|
|
160
|
+
function sharedAncestor(ctx, members) {
|
|
161
|
+
return ancestorChain(members).find((id) => ctx.elementIds.has(id)) ?? null;
|
|
162
|
+
}
|
|
163
|
+
/** The common proper ancestors of every member id, deepest first. */
|
|
164
|
+
function ancestorChain(members) {
|
|
165
|
+
const segments = members.map((id) => id.split("."));
|
|
166
|
+
let common = Math.min(...segments.map((s) => s.length - 1));
|
|
167
|
+
for (let i = 0; i < common; i++) {
|
|
168
|
+
if (!segments.every((s) => s[i] === segments[0][i])) {
|
|
169
|
+
common = i;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const out = [];
|
|
174
|
+
for (let depth = common; depth > 0; depth--)
|
|
175
|
+
out.push(segments[0].slice(0, depth).join("."));
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Does every element beneath `ancestor` belong to this subsystem — as a
|
|
180
|
+
* member, as one of a member's own containers, or as a container of one?
|
|
181
|
+
*/
|
|
182
|
+
function ownsExactly(ctx, ancestor, members) {
|
|
183
|
+
const prefix = `${ancestor}.`;
|
|
184
|
+
for (const id of ctx.elementIds) {
|
|
185
|
+
if (!id.startsWith(prefix))
|
|
186
|
+
continue;
|
|
187
|
+
const claimed = [...members].some((m) => id === m || id.startsWith(`${m}.`) || m.startsWith(`${id}.`));
|
|
188
|
+
if (!claimed)
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The landscape element standing for each service id: for every element,
|
|
195
|
+
* resolve it through the shared join, and let the SHALLOWEST element win
|
|
196
|
+
* (document order breaking ties). Shallowest, because `serviceResolver`
|
|
197
|
+
* resolves a service's own containers to the service too, and a view should
|
|
198
|
+
* include the service-level box, not whichever nested container happened to be
|
|
199
|
+
* declared first.
|
|
200
|
+
*/
|
|
201
|
+
function elementByService(tree, elements, resolve) {
|
|
202
|
+
const known = new Set(tree.services.map((s) => s.id));
|
|
203
|
+
const depth = (id) => id.split(".").length;
|
|
204
|
+
const out = new Map();
|
|
205
|
+
for (const e of elements) {
|
|
206
|
+
const service = resolve(e.id);
|
|
207
|
+
if (!known.has(service))
|
|
208
|
+
continue;
|
|
209
|
+
const held = out.get(service);
|
|
210
|
+
if (held === undefined || depth(e.id) < depth(held))
|
|
211
|
+
out.set(service, e.id);
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
/** Services beneath this subsystem at any depth, ordered by id — the transitive membership. */
|
|
216
|
+
function membersOf(tree, sub) {
|
|
217
|
+
return [...servicesUnder(tree, sub)].sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
218
|
+
}
|
|
219
|
+
//# sourceMappingURL=body.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"body.js","sourceRoot":"","sources":["../../../../../src/core/repo/tree/render/body.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * What one subsystem's generated view SHOWS: which boxes it draws around the\n * services beneath a directory, and the one thing it may say about the map.\n *\n * Split from `../views.ts` when that file crossed the 400-line limit: the file\n * render (header, view ids, staleness comparison) and the body render (the\n * boxes) are two subjects, and the seam is exactly where the second one\n * consults the LANDSCAPE. Everything here reads element ids and nothing else —\n * no view is ever parsed (docs/DESIGN.md rule 26), and the generated file\n * stays a pure function of the tree plus the committed map.\n */\nimport { type Elem } from \"../../../c4/likec4.js\";\nimport { serviceResolver } from \"../../../c4/resolve/service.js\";\nimport { servicesUnder, withinSubsystem } from \"../find.js\";\nimport type { FleetTree, SubsystemEntry, WalkedService } from \"../walk.js\";\nimport { subsystemLabel } from \"./text.js\";\n\n/**\n * Everything a view's body needs from the landscape, resolved ONCE: the\n * element standing for each service, every element id the map declares, and\n * whether an element is a service's own box. A record rather than four\n * threaded parameters because the body render recurses down the subsystem\n * subtree and would otherwise sit on the parameter limit at every hop\n * (`docs/CODE-STYLE.md`'s own prescription).\n */\nexport interface RenderContext {\n tree: FleetTree;\n elementOf: Map<string, string>;\n elementIds: ReadonlySet<string>;\n isServiceElement: (id: string) => boolean;\n}\n\nexport function renderContext(tree: FleetTree, elements: Elem[]): RenderContext {\n const known: ReadonlySet<string> = new Set(tree.services.map((s) => s.id));\n const resolve = serviceResolver(elements, known);\n return {\n tree,\n elementOf: elementByService(tree, elements, resolve),\n elementIds: new Set(elements.map((e) => e.id)),\n isServiceElement: (id: string): boolean => known.has(resolve(id)),\n };\n}\n\n/** A view's body lines, and the one comment the render may write above it. */\nexport interface ViewBody {\n lines: string[];\n disclosure: string | null;\n}\n\n/**\n * Three shapes, and which one applies is a fact about the landscape rather\n * than a setting:\n *\n * 1. **The model draws this group.** Some ancestor element holds exactly this\n * subsystem's members and nothing else, so the view includes it beside the\n * leaves and the renderer nests them inside the box the AUTHOR drew, with\n * the author's own title. Measured: including the ancestor alone renders\n * one empty box and the leaves alone render flat — both lines are needed.\n * 2. **The model is flat.** No member element is nested at all, so there is\n * no authored box to agree with, and the view wraps the members in a\n * `group '<title>'` of its own — a real named box in the renderer, derived\n * from the DIRECTORY the services already sit in, mirroring the subtree so\n * a parent subsystem shows its children as boxes rather than as one pile.\n * Nothing is invented: the grouping is the tree, and the tree is what this\n * file has always mirrored.\n * 3. **The model nests them, differently.** The members ARE nested but no\n * ancestor owns exactly them — the directory tree and the map disagree\n * about this group. loam draws no box (a synthetic one would silently\n * replace the author's boundary with the folder's, and the picture would\n * look the same whether or not the two agree) and writes ONE comment line\n * saying so, outside the view body. That comment is the whole of loam's\n * answer to the drift: it grades nothing, it fails nothing, and it appears\n * in the diff of the very commit that caused it — placement stays a fact\n * nothing branches on.\n */\nexport function viewBody(ctx: RenderContext, sub: SubsystemEntry): ViewBody {\n const members = memberElements(ctx, sub);\n if (members.length === 0) return { lines: [], disclosure: null };\n if (members.every((id) => !id.includes(\".\"))) return { lines: groupedBody(ctx, sub, \" \"), disclosure: null };\n const lines = [...ownedAncestors(ctx, sub), ...members].map((id) => ` include ${id}`);\n if (owningAncestor(ctx, sub) !== null) return { lines, disclosure: null };\n const shared = sharedAncestor(ctx, members);\n return {\n lines,\n disclosure:\n shared === null\n ? \"model: no boundary — these members do not share a containing element.\"\n : `model: no boundary — ${shared} also holds services outside this subsystem.`,\n };\n}\n\n/**\n * The flat-model body: this subsystem as a box, its child subsystems as boxes\n * inside it, its own services as leaves. Recursive, and the recursion is the\n * point — a parent subsystem's view is otherwise one undifferentiated pile of\n * every service beneath it.\n *\n * An empty child renders an empty named box, which is what the tree says: the\n * group exists and holds nothing yet.\n */\nfunction groupedBody(ctx: RenderContext, sub: SubsystemEntry, indent: string): string[] {\n const inner = `${indent} `;\n const out = [`${indent}group '${subsystemLabel(sub)}' {`];\n for (const child of childSubsystems(ctx, sub)) out.push(...groupedBody(ctx, child, inner));\n for (const id of directMemberElements(ctx, sub)) out.push(`${inner}include ${id}`);\n out.push(`${indent}}`);\n return out;\n}\n\n/** The child subsystems one level down, ordered by name. */\nfunction childSubsystems(ctx: RenderContext, sub: SubsystemEntry): SubsystemEntry[] {\n return ctx.tree.subsystems\n .filter((c) => c.path.length === sub.path.length + 1 && withinSubsystem(sub.path, c.path))\n .sort((a, b) => (a.name < b.name ? -1 : 1));\n}\n\n/** The drawn elements of the services filed in THIS directory, not in a child. */\nfunction directMemberElements(ctx: RenderContext, sub: SubsystemEntry): string[] {\n return drawnElements(\n ctx,\n membersOf(ctx.tree, sub).filter((s) => s.subsystem.length === sub.path.length),\n );\n}\n\n/** The drawn elements of every service beneath this subsystem, ordered by service id. */\nfunction memberElements(ctx: RenderContext, sub: SubsystemEntry): string[] {\n return drawnElements(ctx, membersOf(ctx.tree, sub));\n}\n\n/**\n * The element ids these services resolve to. A service the map does not draw\n * is omitted rather than invented: the omission is already an error\n * (`landscape.service-unmodelled`), and rendering only what the committed\n * documents resolve keeps the output a function of committed bytes alone.\n */\nfunction drawnElements(ctx: RenderContext, services: WalkedService[]): string[] {\n return services.map((s) => ctx.elementOf.get(s.id)).filter((id): id is string => id !== undefined);\n}\n\n/**\n * The boxes a nested-model view draws: this subsystem's own owning ancestor\n * when it has one, plus the owning ancestor of every subsystem beneath it — so\n * a parent view keeps its children's boundaries instead of flattening them\n * into one box. Sorted and de-duplicated, because two subsystems resolve to\n * one element only in a tree that already reports a collision.\n */\nfunction ownedAncestors(ctx: RenderContext, sub: SubsystemEntry): string[] {\n const found = new Set<string>();\n for (const candidate of ctx.tree.subsystems) {\n if (!withinSubsystem(sub.path, candidate.path)) continue;\n const owner = owningAncestor(ctx, candidate);\n if (owner !== null) found.add(owner);\n }\n return [...found].sort();\n}\n\n/**\n * The deepest ancestor element that holds exactly this subsystem's members —\n * or null when no element does.\n *\n * \"Exactly\" is the whole guard, and dropping it was measured on loam's own\n * `examples/docs`: the members' longest common ancestor there is a group of\n * five services while the subsystem holds two, so an unguarded include would\n * generate a box labelled for the group with two of its five children drawn\n * inside it — a diagram asserting something false, in a file the reader is\n * told not to edit. Tightness is judged over ELEMENTS rather than over\n * documented services: mid-adoption a group commonly holds systems no\n * `services/<id>/` exists for yet, and counting only the documented ones would\n * draw that same false box for the whole of an adoption.\n *\n * An ancestor that is itself a service's element is refused separately: one\n * service's box containing another's is a picture of nothing.\n */\nfunction owningAncestor(ctx: RenderContext, sub: SubsystemEntry): string | null {\n const members = memberElements(ctx, sub);\n if (members.length === 0) return null;\n const owned = new Set(members);\n for (const candidate of ancestorChain(members)) {\n if (!ctx.elementIds.has(candidate) || ctx.isServiceElement(candidate)) continue;\n if (ownsExactly(ctx, candidate, owned)) return candidate;\n }\n return null;\n}\n\n/** The deepest common ancestor the map declares at all — the disclosure's subject. */\nfunction sharedAncestor(ctx: RenderContext, members: string[]): string | null {\n return ancestorChain(members).find((id) => ctx.elementIds.has(id)) ?? null;\n}\n\n/** The common proper ancestors of every member id, deepest first. */\nfunction ancestorChain(members: string[]): string[] {\n const segments = members.map((id) => id.split(\".\"));\n let common = Math.min(...segments.map((s) => s.length - 1));\n for (let i = 0; i < common; i++) {\n if (!segments.every((s) => s[i] === segments[0]![i])) {\n common = i;\n break;\n }\n }\n const out: string[] = [];\n for (let depth = common; depth > 0; depth--) out.push(segments[0]!.slice(0, depth).join(\".\"));\n return out;\n}\n\n/**\n * Does every element beneath `ancestor` belong to this subsystem — as a\n * member, as one of a member's own containers, or as a container of one?\n */\nfunction ownsExactly(ctx: RenderContext, ancestor: string, members: ReadonlySet<string>): boolean {\n const prefix = `${ancestor}.`;\n for (const id of ctx.elementIds) {\n if (!id.startsWith(prefix)) continue;\n const claimed = [...members].some((m) => id === m || id.startsWith(`${m}.`) || m.startsWith(`${id}.`));\n if (!claimed) return false;\n }\n return true;\n}\n\n/**\n * The landscape element standing for each service id: for every element,\n * resolve it through the shared join, and let the SHALLOWEST element win\n * (document order breaking ties). Shallowest, because `serviceResolver`\n * resolves a service's own containers to the service too, and a view should\n * include the service-level box, not whichever nested container happened to be\n * declared first.\n */\nfunction elementByService(\n tree: FleetTree,\n elements: Elem[],\n resolve: (id: string) => string,\n): Map<string, string> {\n const known: ReadonlySet<string> = new Set(tree.services.map((s) => s.id));\n const depth = (id: string): number => id.split(\".\").length;\n const out = new Map<string, string>();\n for (const e of elements) {\n const service = resolve(e.id);\n if (!known.has(service)) continue;\n const held = out.get(service);\n if (held === undefined || depth(e.id) < depth(held)) out.set(service, e.id);\n }\n return out;\n}\n\n/** Services beneath this subsystem at any depth, ordered by id — the transitive membership. */\nfunction membersOf(tree: FleetTree, sub: SubsystemEntry): WalkedService[] {\n return [...servicesUnder(tree, sub)].sort((a, b) => (a.id < b.id ? -1 : 1));\n}\n"]}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the generated file on disk what this tree and this map render to?
|
|
3
|
+
*
|
|
4
|
+
* One function, because the question now has three askers — `validate --all`
|
|
5
|
+
* (which turns a disagreement into `subsystem.views-stale`), `loam subsystem
|
|
6
|
+
* sync` and the write verbs (which repair it), and `loam archive` (which must
|
|
7
|
+
* not report a repo current when it has just made the file stale). Three
|
|
8
|
+
* private compares would be three chances to answer differently about one
|
|
9
|
+
* file, and the module that mints the bytes is the one that owes the answer.
|
|
10
|
+
*
|
|
11
|
+
* The PATH arrives as a parameter rather than a `DocsDir`: `repo/paths.ts`
|
|
12
|
+
* owns the spelling of every file in a docs repo, and importing it here would
|
|
13
|
+
* close the package cycle `repo → repo/tree → repo/tree/render → repo` that
|
|
14
|
+
* `scripts/package-graph.mjs` exists to refuse. `walk.ts` pays the same price
|
|
15
|
+
* for the same reason and says so.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import {} from "../../../c4/likec4.js";
|
|
20
|
+
import { renderSubsystemViews, viewsAgree } from "./views.js";
|
|
21
|
+
export async function viewsState(path, tree, elements) {
|
|
22
|
+
const expected = renderSubsystemViews(tree, elements);
|
|
23
|
+
const actual = existsSync(path) ? await readFile(path, "utf8") : null;
|
|
24
|
+
return { actual, expected, agrees: viewsAgree(actual, expected) };
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=stale.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stale.js","sourceRoot":"","sources":["../../../../../src/core/repo/tree/render/stale.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAa,MAAM,uBAAuB,CAAC;AAElD,OAAO,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAgB9D,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAY,EAAE,IAAe,EAAE,QAAgB;IAC9E,MAAM,QAAQ,GAAG,oBAAoB,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IACtD,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACtE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE,CAAC;AACpE,CAAC","sourcesContent":["/**\n * Is the generated file on disk what this tree and this map render to?\n *\n * One function, because the question now has three askers — `validate --all`\n * (which turns a disagreement into `subsystem.views-stale`), `loam subsystem\n * sync` and the write verbs (which repair it), and `loam archive` (which must\n * not report a repo current when it has just made the file stale). Three\n * private compares would be three chances to answer differently about one\n * file, and the module that mints the bytes is the one that owes the answer.\n *\n * The PATH arrives as a parameter rather than a `DocsDir`: `repo/paths.ts`\n * owns the spelling of every file in a docs repo, and importing it here would\n * close the package cycle `repo → repo/tree → repo/tree/render → repo` that\n * `scripts/package-graph.mjs` exists to refuse. `walk.ts` pays the same price\n * for the same reason and says so.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport { type Elem } from \"../../../c4/likec4.js\";\nimport type { FleetTree } from \"../walk.js\";\nimport { renderSubsystemViews, viewsAgree } from \"./views.js\";\n\n/**\n * The three states, kept apart: absent, must-be-absent, and\n * present-but-different are three different sentences, and a fleet whose views\n * file does not exist is not a fleet whose views file is blank.\n */\nexport interface ViewsState {\n /** The bytes on disk, or null when the file is not there. */\n actual: string | null;\n /** The bytes the tree renders to, or null when the file must be absent. */\n expected: string | null;\n /** Do the two say the same thing? Content, not bytes — `viewsAgree` says why. */\n agrees: boolean;\n}\n\nexport async function viewsState(path: string, tree: FleetTree, elements: Elem[]): Promise<ViewsState> {\n const expected = renderSubsystemViews(tree, elements);\n const actual = existsSync(path) ? await readFile(path, \"utf8\") : null;\n return { actual, expected, agrees: viewsAgree(actual, expected) };\n}\n"]}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One string, safely spelled as a LikeC4 single-quoted literal.
|
|
3
|
+
*
|
|
4
|
+
* The marker reader validates that a `title` is a string and nothing else
|
|
5
|
+
* (`../marker.ts`), so every measurement below is reachable from a
|
|
6
|
+
* `subsystem.yaml` a human may legally write, against the pinned likec4:
|
|
7
|
+
*
|
|
8
|
+
* - a raw `'` is 2 parse errors, and a parse error in ONE document of the
|
|
9
|
+
* `architecture/` project blanks the model for every document in it — a
|
|
10
|
+
* fleet would lose its whole map over one apostrophe in a marker;
|
|
11
|
+
* - a trailing lone `\` is 3 errors (it escapes the closing quote);
|
|
12
|
+
* - `C:\path` written raw is 0 errors and a quietly wrong `C:path` label;
|
|
13
|
+
* - `Ops' } view evil { include x` is 0 errors and INJECTS a view — and an
|
|
14
|
+
* injected id equal to an authored one silently replaces that author's
|
|
15
|
+
* view in the rendered project. That one is the reason this function is
|
|
16
|
+
* not optional.
|
|
17
|
+
*
|
|
18
|
+
* Normalize, then escape, and in that order: every run of control or space
|
|
19
|
+
* characters collapses to one space, because the file's contract is
|
|
20
|
+
* line-oriented (`../views.ts`'s header states the git-merge property in
|
|
21
|
+
* lines) and a literal newline inside an emitted string would split
|
|
22
|
+
* `title '…'` in two. Then `\` before `'`, because doubling the backslashes
|
|
23
|
+
* afterwards would double the ones this function itself inserted.
|
|
24
|
+
*/
|
|
25
|
+
export function likec4String(text) {
|
|
26
|
+
return text
|
|
27
|
+
.replace(/[\p{Cc}\p{Zs}\p{Zl}\p{Zp}]+/gu, " ")
|
|
28
|
+
.trim()
|
|
29
|
+
.split("\\")
|
|
30
|
+
.join("\\\\")
|
|
31
|
+
.split("'")
|
|
32
|
+
.join("\\'");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* ONE subsystem's label: the marker's `title`, or the DIRECTORY NAME when the
|
|
36
|
+
* marker carries none.
|
|
37
|
+
*
|
|
38
|
+
* The fallback is not a nicety. Without a title LikeC4 shows the view id, and
|
|
39
|
+
* the id is hex-escaped for injectivity (`subsystemViewId` says why), so every
|
|
40
|
+
* subsystem whose directory name holds a hyphen — the common case — rendered
|
|
41
|
+
* as `subsystem_platform__order_2dflow`. A directory name is something a human
|
|
42
|
+
* chose; the escaped id never is.
|
|
43
|
+
*
|
|
44
|
+
* A blank or whitespace-only title counts as absent: measured, `title ''`
|
|
45
|
+
* collapses to null at the model layer, so emitting one would put back exactly
|
|
46
|
+
* the unlabelled view this exists to remove.
|
|
47
|
+
*
|
|
48
|
+
* A `/` inside the label becomes `∕` (U+2215), and that substitution is the
|
|
49
|
+
* price of `viewTitle` below composing a path out of these. LikeC4 splits a
|
|
50
|
+
* view title on `/` into browser folders and displays only the last segment —
|
|
51
|
+
* measured: `Payments / refunds` renders as `refunds` with `Payments` demoted
|
|
52
|
+
* to a folder — so an authored slash left alone would silently hide
|
|
53
|
+
* half of what somebody wrote AND invent a folder level the tree does not
|
|
54
|
+
* have. Since loam is the one choosing to compose with `/`, loam owns that
|
|
55
|
+
* consequence rather than passing it on. The whole title stays visible and the
|
|
56
|
+
* folder path stays exactly the subsystem tree. A directory name cannot
|
|
57
|
+
* contain `/`, so the fallback never needs it.
|
|
58
|
+
*/
|
|
59
|
+
export function subsystemLabel(sub) {
|
|
60
|
+
const segment = (text) => likec4String(text).split("/").join("∕");
|
|
61
|
+
const authored = segment(sub.meta.title ?? "");
|
|
62
|
+
return authored === "" ? segment(sub.name) : authored;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The title a view carries: every marked ancestor's label and its own, joined
|
|
66
|
+
* with ` / ` — `Platform / Order flow` for `services/platform/order-flow`.
|
|
67
|
+
*
|
|
68
|
+
* LikeC4 reads that as a path: the browser shows one folder per ancestor and
|
|
69
|
+
* the leaf's own label as the title, so the view list mirrors the directory
|
|
70
|
+
* tree a human already navigates, and two subsystems whose leaf labels are
|
|
71
|
+
* equal are told apart by their parents instead of appearing twice under one
|
|
72
|
+
* name. Nothing is invented — the chain is the tree — and a subsystem directly
|
|
73
|
+
* under `services/` has a chain of one and reads exactly as its own label.
|
|
74
|
+
*
|
|
75
|
+
* The cost, stated because it is real: renaming a PARENT's title rewrites the
|
|
76
|
+
* bytes of every view beneath it, so one marker edit restales more of the file
|
|
77
|
+
* than it used to. The alternative — a fixed `loam subsystems /` prefix —
|
|
78
|
+
* buys the same folder without the churn and puts loam's own name in a
|
|
79
|
+
* reader's UI; the tree's own words won.
|
|
80
|
+
*/
|
|
81
|
+
export function viewTitle(chain) {
|
|
82
|
+
return chain.map(subsystemLabel).join(" / ");
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=text.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"text.js","sourceRoot":"","sources":["../../../../../src/core/repo/tree/render/text.ts"],"names":[],"mappings":"AAcA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,OAAO,IAAI;SACR,OAAO,CAAC,+BAA+B,EAAE,GAAG,CAAC;SAC7C,IAAI,EAAE;SACN,KAAK,CAAC,IAAI,CAAC;SACX,IAAI,CAAC,MAAM,CAAC;SACZ,KAAK,CAAC,GAAG,CAAC;SACV,IAAI,CAAC,KAAK,CAAC,CAAC;AACjB,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,UAAU,cAAc,CAAC,GAAmB;IAChD,MAAM,OAAO,GAAG,CAAC,IAAY,EAAU,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAClF,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;IAC/C,OAAO,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;AACxD,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,SAAS,CAAC,KAAgC;IACxD,OAAO,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAC/C,CAAC","sourcesContent":["/**\n * Somebody else's words, made safe to write into a `.likec4` file.\n *\n * The generated views file carries FREE TEXT for the first time — a\n * subsystem's `title` and `description`, straight out of `subsystem.yaml` —\n * and this module is the only place in loam where a person's prose becomes\n * syntax. It sits in its own module beside the body renderer because both the\n * file-level render (`../views.ts`, the view's own `title` line) and the box\n * render (`./body.ts`, a group's label) must escape the same way; two\n * spellings would be two chances to differ, and the one that forgot would ship\n * a fleet-wide parse error.\n */\nimport type { SubsystemEntry } from \"../walk.js\";\n\n/**\n * One string, safely spelled as a LikeC4 single-quoted literal.\n *\n * The marker reader validates that a `title` is a string and nothing else\n * (`../marker.ts`), so every measurement below is reachable from a\n * `subsystem.yaml` a human may legally write, against the pinned likec4:\n *\n * - a raw `'` is 2 parse errors, and a parse error in ONE document of the\n * `architecture/` project blanks the model for every document in it — a\n * fleet would lose its whole map over one apostrophe in a marker;\n * - a trailing lone `\\` is 3 errors (it escapes the closing quote);\n * - `C:\\path` written raw is 0 errors and a quietly wrong `C:path` label;\n * - `Ops' } view evil { include x` is 0 errors and INJECTS a view — and an\n * injected id equal to an authored one silently replaces that author's\n * view in the rendered project. That one is the reason this function is\n * not optional.\n *\n * Normalize, then escape, and in that order: every run of control or space\n * characters collapses to one space, because the file's contract is\n * line-oriented (`../views.ts`'s header states the git-merge property in\n * lines) and a literal newline inside an emitted string would split\n * `title '…'` in two. Then `\\` before `'`, because doubling the backslashes\n * afterwards would double the ones this function itself inserted.\n */\nexport function likec4String(text: string): string {\n return text\n .replace(/[\\p{Cc}\\p{Zs}\\p{Zl}\\p{Zp}]+/gu, \" \")\n .trim()\n .split(\"\\\\\")\n .join(\"\\\\\\\\\")\n .split(\"'\")\n .join(\"\\\\'\");\n}\n\n/**\n * ONE subsystem's label: the marker's `title`, or the DIRECTORY NAME when the\n * marker carries none.\n *\n * The fallback is not a nicety. Without a title LikeC4 shows the view id, and\n * the id is hex-escaped for injectivity (`subsystemViewId` says why), so every\n * subsystem whose directory name holds a hyphen — the common case — rendered\n * as `subsystem_platform__order_2dflow`. A directory name is something a human\n * chose; the escaped id never is.\n *\n * A blank or whitespace-only title counts as absent: measured, `title ''`\n * collapses to null at the model layer, so emitting one would put back exactly\n * the unlabelled view this exists to remove.\n *\n * A `/` inside the label becomes `∕` (U+2215), and that substitution is the\n * price of `viewTitle` below composing a path out of these. LikeC4 splits a\n * view title on `/` into browser folders and displays only the last segment —\n * measured: `Payments / refunds` renders as `refunds` with `Payments` demoted\n * to a folder — so an authored slash left alone would silently hide\n * half of what somebody wrote AND invent a folder level the tree does not\n * have. Since loam is the one choosing to compose with `/`, loam owns that\n * consequence rather than passing it on. The whole title stays visible and the\n * folder path stays exactly the subsystem tree. A directory name cannot\n * contain `/`, so the fallback never needs it.\n */\nexport function subsystemLabel(sub: SubsystemEntry): string {\n const segment = (text: string): string => likec4String(text).split(\"/\").join(\"∕\");\n const authored = segment(sub.meta.title ?? \"\");\n return authored === \"\" ? segment(sub.name) : authored;\n}\n\n/**\n * The title a view carries: every marked ancestor's label and its own, joined\n * with ` / ` — `Platform / Order flow` for `services/platform/order-flow`.\n *\n * LikeC4 reads that as a path: the browser shows one folder per ancestor and\n * the leaf's own label as the title, so the view list mirrors the directory\n * tree a human already navigates, and two subsystems whose leaf labels are\n * equal are told apart by their parents instead of appearing twice under one\n * name. Nothing is invented — the chain is the tree — and a subsystem directly\n * under `services/` has a chain of one and reads exactly as its own label.\n *\n * The cost, stated because it is real: renaming a PARENT's title rewrites the\n * bytes of every view beneath it, so one marker edit restales more of the file\n * than it used to. The alternative — a fixed `loam subsystems /` prefix —\n * buys the same folder without the churn and puts loam's own name in a\n * reader's UI; the tree's own words won.\n */\nexport function viewTitle(chain: readonly SubsystemEntry[]): string {\n return chain.map(subsystemLabel).join(\" / \");\n}\n"]}
|