@ybotok/loam 0.2.0-alpha.1 → 0.2.0-alpha.2
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 +87 -0
- package/README.md +6 -6
- package/ROADMAP.md +123 -18
- 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 +2 -1
- package/dist/commands/archive/run.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/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/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 +43 -2
- package/dist/core/repo/paths.js.map +1 -1
- 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/package.json +1 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DEPLOYMENT model a document declares — which physical nodes exist, which
|
|
3
|
+
* containers are instanced where, and what talks to what across them.
|
|
4
|
+
*
|
|
5
|
+
* A `deployment { }` block has been legal in a docs repo since LikeC4 could
|
|
6
|
+
* parse one, and until this reader existed loam saw none of it. The parser
|
|
7
|
+
* resolved every `instanceOf` — so a container renamed out from under a
|
|
8
|
+
* deployment node already failed the gate — and after that no requirement could
|
|
9
|
+
* name a node, no `#obl-` tag on a datacenter was graded, and the context pack
|
|
10
|
+
* an agent implements from did not mention topology at all. Integrity held at
|
|
11
|
+
* the level of syntax and was absent at the level of requirements. ROADMAP's
|
|
12
|
+
* deployment axis and `meta/docs/features/FEAT-1-the-deployment-axis/` are the
|
|
13
|
+
* item and the specification; this module is its first half.
|
|
14
|
+
*
|
|
15
|
+
* ## Why this is a `parsed/` reader and not a package of its own
|
|
16
|
+
*
|
|
17
|
+
* It is the same job the three modules beside it do: take one corner of
|
|
18
|
+
* LikeC4's parse output and hand back a loam-neutral record, defensively, with
|
|
19
|
+
* its shape assumptions owned outright. `readSpecification`, `readDynamicViews`
|
|
20
|
+
* and `readViewIds` are all called on the line above `flattenModel(model)` in
|
|
21
|
+
* each of loam's three loaders, and so is this. FEAT-1's delta said
|
|
22
|
+
* `src/core/c4/deploy/`; a package holding this alone would be a directory
|
|
23
|
+
* pretending to be a subject (docs/DESIGN.md), and the delta was corrected
|
|
24
|
+
* rather than the code bent to it — which is what an unarchived feature is for.
|
|
25
|
+
*
|
|
26
|
+
* **`parsed/` is now AT its five-file limit.** The next reader here needs a
|
|
27
|
+
* split first, and the seam to split on is the one this file does not share:
|
|
28
|
+
* `dynamic-views.ts` and `view-ids.ts` read `$data`, this one and
|
|
29
|
+
* `specification.ts` read the model surface.
|
|
30
|
+
*
|
|
31
|
+
* ## What is read, and what is deliberately left
|
|
32
|
+
*
|
|
33
|
+
* Nodes, instances and relationships, with the ids, titles and tags each
|
|
34
|
+
* carries. MEASURED at the 1.59.2 pin, and every line of it needed measuring
|
|
35
|
+
* rather than assuming:
|
|
36
|
+
*
|
|
37
|
+
* - `nodes()` returns deployment NODES only — an `instanceOf` is not among
|
|
38
|
+
* them, which is why instances are a second list rather than a filter.
|
|
39
|
+
* - an instance carries the LOGICAL element it deploys as `element.id`, and
|
|
40
|
+
* that is the whole join back to the C4 model: `eu.a.dbA -> orders.db`.
|
|
41
|
+
* - both nodes and instances carry `tags`, so `#obl-geo` on a datacenter is
|
|
42
|
+
* readable — the fail-open case that made this an item.
|
|
43
|
+
* - a relationship carries `tags` and `metadata` DIRECTLY (`r.metadata`, no
|
|
44
|
+
* `$relationship` indirection), and both model stages return byte-identical
|
|
45
|
+
* results, which is what lets `test/likec4-model-parity.test.ts` cover this
|
|
46
|
+
* read with the substitution it already pins.
|
|
47
|
+
* - a document with NO `deployment { }` block still returns a deployment
|
|
48
|
+
* object whose three iterators are empty. Absent and empty are therefore the
|
|
49
|
+
* same answer here, deliberately: no reader may distinguish "declares none"
|
|
50
|
+
* from "could not be read", because the second is what the defensive returns
|
|
51
|
+
* below produce and a fleet that draws no topology owes loam nothing.
|
|
52
|
+
*
|
|
53
|
+
* `metadata` is NOT carried on the records below, and neither is an instance's
|
|
54
|
+
* `kind`. Both are readable; no check reads them yet, and a field carried
|
|
55
|
+
* before a caller needs it is a field nobody notices going wrong. Add one with
|
|
56
|
+
* the check that consumes it.
|
|
57
|
+
*/
|
|
58
|
+
/** No topology — what an absent block and an unreadable one both come back as. */
|
|
59
|
+
export const NO_DEPLOYMENT = { nodes: [], instances: [], relationships: [] };
|
|
60
|
+
/** Is there anything here? The opt-in every deployment check is gated on. */
|
|
61
|
+
export function hasDeployment(d) {
|
|
62
|
+
return d.nodes.length > 0 || d.instances.length > 0 || d.relationships.length > 0;
|
|
63
|
+
}
|
|
64
|
+
/** A record, or nothing — the parse surface hands back `unknown`. */
|
|
65
|
+
function record(v) {
|
|
66
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
67
|
+
}
|
|
68
|
+
/** An iterable produced by `name()`, or an empty one when the surface is not what we measured. */
|
|
69
|
+
function iterate(source, name) {
|
|
70
|
+
const fn = source[name];
|
|
71
|
+
if (typeof fn !== "function")
|
|
72
|
+
return [];
|
|
73
|
+
try {
|
|
74
|
+
const out = fn.call(source);
|
|
75
|
+
// A generator satisfies this; so does an array. Anything else is a shape
|
|
76
|
+
// change upstream, and the honest answer to one is "no topology read".
|
|
77
|
+
return out !== null && typeof out === "object" && Symbol.iterator in out
|
|
78
|
+
? [...out]
|
|
79
|
+
: [];
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** A string field, or the empty string — never `undefined` leaking into a record. */
|
|
86
|
+
function str(o, key) {
|
|
87
|
+
const v = o[key];
|
|
88
|
+
return typeof v === "string" ? v : "";
|
|
89
|
+
}
|
|
90
|
+
/** Tags as loam models them: a plain array of strings, always present. */
|
|
91
|
+
function tags(o) {
|
|
92
|
+
const v = o["tags"];
|
|
93
|
+
return Array.isArray(v) ? v.filter((t) => typeof t === "string") : [];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Read the deployment model off an awaited `parsedModel()`.
|
|
97
|
+
*
|
|
98
|
+
* Defensive throughout, in the shape `readViewIds` established: an entry
|
|
99
|
+
* missing the one field that identifies it is DROPPED rather than reported with
|
|
100
|
+
* a hole, and a surface that is not what was measured yields an empty model
|
|
101
|
+
* instead of throwing. A check that cannot see the topology must say
|
|
102
|
+
* could-not-look through its own silence, never fail the fleet's parse.
|
|
103
|
+
*/
|
|
104
|
+
export function readDeployment(model) {
|
|
105
|
+
const root = record(model);
|
|
106
|
+
if (root === undefined)
|
|
107
|
+
return NO_DEPLOYMENT;
|
|
108
|
+
const deployment = record(root["deployment"]);
|
|
109
|
+
if (deployment === undefined)
|
|
110
|
+
return NO_DEPLOYMENT;
|
|
111
|
+
const nodes = [];
|
|
112
|
+
for (const raw of iterate(deployment, "nodes")) {
|
|
113
|
+
const n = record(raw);
|
|
114
|
+
if (n === undefined)
|
|
115
|
+
continue;
|
|
116
|
+
const id = str(n, "id");
|
|
117
|
+
if (id === "")
|
|
118
|
+
continue;
|
|
119
|
+
nodes.push({ id, kind: str(n, "kind"), title: str(n, "title"), tags: tags(n) });
|
|
120
|
+
}
|
|
121
|
+
const instances = [];
|
|
122
|
+
for (const raw of iterate(deployment, "instances")) {
|
|
123
|
+
const i = record(raw);
|
|
124
|
+
if (i === undefined)
|
|
125
|
+
continue;
|
|
126
|
+
const id = str(i, "id");
|
|
127
|
+
// The logical element is the whole point of the record. An instance whose
|
|
128
|
+
// element cannot be read joins to nothing, so reporting it would put a
|
|
129
|
+
// deployed thing on the map that no requirement could ever cover.
|
|
130
|
+
const element = record(i["element"]);
|
|
131
|
+
if (id === "" || element === undefined)
|
|
132
|
+
continue;
|
|
133
|
+
const elementId = str(element, "id");
|
|
134
|
+
if (elementId === "")
|
|
135
|
+
continue;
|
|
136
|
+
instances.push({ id, element: elementId, title: str(i, "title"), tags: tags(i) });
|
|
137
|
+
}
|
|
138
|
+
const relationships = [];
|
|
139
|
+
for (const raw of iterate(deployment, "relationships")) {
|
|
140
|
+
const r = record(raw);
|
|
141
|
+
if (r === undefined)
|
|
142
|
+
continue;
|
|
143
|
+
const source = record(r["source"]);
|
|
144
|
+
const target = record(r["target"]);
|
|
145
|
+
if (source === undefined || target === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
const from = str(source, "id");
|
|
148
|
+
const to = str(target, "id");
|
|
149
|
+
if (from === "" || to === "")
|
|
150
|
+
continue;
|
|
151
|
+
// LikeC4 reports an untitled edge as `title: null` — normalized to the
|
|
152
|
+
// declared optional exactly as `flattenModel` does for the logical model.
|
|
153
|
+
const title = typeof r["title"] === "string" ? r["title"] : undefined;
|
|
154
|
+
relationships.push({ source: from, target: to, ...(title === undefined ? {} : { title }), tags: tags(r) });
|
|
155
|
+
}
|
|
156
|
+
return { nodes, instances, relationships };
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=deployment.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deployment.js","sourceRoot":"","sources":["../../../../src/core/c4/parsed/deployment.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The DEPLOYMENT model a document declares — which physical nodes exist, which\n * containers are instanced where, and what talks to what across them.\n *\n * A `deployment { }` block has been legal in a docs repo since LikeC4 could\n * parse one, and until this reader existed loam saw none of it. The parser\n * resolved every `instanceOf` — so a container renamed out from under a\n * deployment node already failed the gate — and after that no requirement could\n * name a node, no `#obl-` tag on a datacenter was graded, and the context pack\n * an agent implements from did not mention topology at all. Integrity held at\n * the level of syntax and was absent at the level of requirements. ROADMAP's\n * deployment axis and `meta/docs/features/FEAT-1-the-deployment-axis/` are the\n * item and the specification; this module is its first half.\n *\n * ## Why this is a `parsed/` reader and not a package of its own\n *\n * It is the same job the three modules beside it do: take one corner of\n * LikeC4's parse output and hand back a loam-neutral record, defensively, with\n * its shape assumptions owned outright. `readSpecification`, `readDynamicViews`\n * and `readViewIds` are all called on the line above `flattenModel(model)` in\n * each of loam's three loaders, and so is this. FEAT-1's delta said\n * `src/core/c4/deploy/`; a package holding this alone would be a directory\n * pretending to be a subject (docs/DESIGN.md), and the delta was corrected\n * rather than the code bent to it — which is what an unarchived feature is for.\n *\n * **`parsed/` is now AT its five-file limit.** The next reader here needs a\n * split first, and the seam to split on is the one this file does not share:\n * `dynamic-views.ts` and `view-ids.ts` read `$data`, this one and\n * `specification.ts` read the model surface.\n *\n * ## What is read, and what is deliberately left\n *\n * Nodes, instances and relationships, with the ids, titles and tags each\n * carries. MEASURED at the 1.59.2 pin, and every line of it needed measuring\n * rather than assuming:\n *\n * - `nodes()` returns deployment NODES only — an `instanceOf` is not among\n * them, which is why instances are a second list rather than a filter.\n * - an instance carries the LOGICAL element it deploys as `element.id`, and\n * that is the whole join back to the C4 model: `eu.a.dbA -> orders.db`.\n * - both nodes and instances carry `tags`, so `#obl-geo` on a datacenter is\n * readable — the fail-open case that made this an item.\n * - a relationship carries `tags` and `metadata` DIRECTLY (`r.metadata`, no\n * `$relationship` indirection), and both model stages return byte-identical\n * results, which is what lets `test/likec4-model-parity.test.ts` cover this\n * read with the substitution it already pins.\n * - a document with NO `deployment { }` block still returns a deployment\n * object whose three iterators are empty. Absent and empty are therefore the\n * same answer here, deliberately: no reader may distinguish \"declares none\"\n * from \"could not be read\", because the second is what the defensive returns\n * below produce and a fleet that draws no topology owes loam nothing.\n *\n * `metadata` is NOT carried on the records below, and neither is an instance's\n * `kind`. Both are readable; no check reads them yet, and a field carried\n * before a caller needs it is a field nobody notices going wrong. Add one with\n * the check that consumes it.\n */\n\n/** One deployment node — a region, a datacenter, a cluster; the kinds are the fleet's own. */\nexport interface DeployNode {\n /** The dotted path LikeC4 gives it: `eu.dcA.k8sA`. */\n id: string;\n /** The `deploymentNode` kind the specification declares. */\n kind: string;\n title: string;\n tags: string[];\n}\n\n/** One deployed instance of a logical element. */\nexport interface DeployInstance {\n id: string;\n /**\n * The id of the C4 element this instance deploys — the join back to the\n * logical model, and the only reason an instance is worth reading at all.\n */\n element: string;\n title: string;\n tags: string[];\n}\n\n/** One edge between deployment objects — replication, failover, a network hop. */\nexport interface DeployRel {\n source: string;\n target: string;\n title?: string;\n tags: string[];\n}\n\n/** A document's whole deployment model, flattened. */\nexport interface DeploymentModel {\n nodes: DeployNode[];\n instances: DeployInstance[];\n relationships: DeployRel[];\n}\n\n/** No topology — what an absent block and an unreadable one both come back as. */\nexport const NO_DEPLOYMENT: DeploymentModel = { nodes: [], instances: [], relationships: [] };\n\n/** Is there anything here? The opt-in every deployment check is gated on. */\nexport function hasDeployment(d: DeploymentModel): boolean {\n return d.nodes.length > 0 || d.instances.length > 0 || d.relationships.length > 0;\n}\n\n/** A record, or nothing — the parse surface hands back `unknown`. */\nfunction record(v: unknown): Record<string, unknown> | undefined {\n return v && typeof v === \"object\" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;\n}\n\n/** An iterable produced by `name()`, or an empty one when the surface is not what we measured. */\nfunction iterate(source: Record<string, unknown>, name: string): unknown[] {\n const fn = source[name];\n if (typeof fn !== \"function\") return [];\n try {\n const out: unknown = (fn as () => unknown).call(source);\n // A generator satisfies this; so does an array. Anything else is a shape\n // change upstream, and the honest answer to one is \"no topology read\".\n return out !== null && typeof out === \"object\" && Symbol.iterator in (out as object)\n ? [...(out as Iterable<unknown>)]\n : [];\n } catch {\n return [];\n }\n}\n\n/** A string field, or the empty string — never `undefined` leaking into a record. */\nfunction str(o: Record<string, unknown>, key: string): string {\n const v = o[key];\n return typeof v === \"string\" ? v : \"\";\n}\n\n/** Tags as loam models them: a plain array of strings, always present. */\nfunction tags(o: Record<string, unknown>): string[] {\n const v = o[\"tags\"];\n return Array.isArray(v) ? v.filter((t): t is string => typeof t === \"string\") : [];\n}\n\n/**\n * Read the deployment model off an awaited `parsedModel()`.\n *\n * Defensive throughout, in the shape `readViewIds` established: an entry\n * missing the one field that identifies it is DROPPED rather than reported with\n * a hole, and a surface that is not what was measured yields an empty model\n * instead of throwing. A check that cannot see the topology must say\n * could-not-look through its own silence, never fail the fleet's parse.\n */\nexport function readDeployment(model: unknown): DeploymentModel {\n const root = record(model);\n if (root === undefined) return NO_DEPLOYMENT;\n const deployment = record(root[\"deployment\"]);\n if (deployment === undefined) return NO_DEPLOYMENT;\n\n const nodes: DeployNode[] = [];\n for (const raw of iterate(deployment, \"nodes\")) {\n const n = record(raw);\n if (n === undefined) continue;\n const id = str(n, \"id\");\n if (id === \"\") continue;\n nodes.push({ id, kind: str(n, \"kind\"), title: str(n, \"title\"), tags: tags(n) });\n }\n\n const instances: DeployInstance[] = [];\n for (const raw of iterate(deployment, \"instances\")) {\n const i = record(raw);\n if (i === undefined) continue;\n const id = str(i, \"id\");\n // The logical element is the whole point of the record. An instance whose\n // element cannot be read joins to nothing, so reporting it would put a\n // deployed thing on the map that no requirement could ever cover.\n const element = record(i[\"element\"]);\n if (id === \"\" || element === undefined) continue;\n const elementId = str(element, \"id\");\n if (elementId === \"\") continue;\n instances.push({ id, element: elementId, title: str(i, \"title\"), tags: tags(i) });\n }\n\n const relationships: DeployRel[] = [];\n for (const raw of iterate(deployment, \"relationships\")) {\n const r = record(raw);\n if (r === undefined) continue;\n const source = record(r[\"source\"]);\n const target = record(r[\"target\"]);\n if (source === undefined || target === undefined) continue;\n const from = str(source, \"id\");\n const to = str(target, \"id\");\n if (from === \"\" || to === \"\") continue;\n // LikeC4 reports an untitled edge as `title: null` — normalized to the\n // declared optional exactly as `flattenModel` does for the logical model.\n const title = typeof r[\"title\"] === \"string\" ? r[\"title\"] : undefined;\n relationships.push({ source: from, target: to, ...(title === undefined ? {} : { title }), tags: tags(r) });\n }\n\n return { nodes, instances, relationships };\n}\n"]}
|
|
@@ -20,6 +20,7 @@ import { flattenModel } from "../likec4.js";
|
|
|
20
20
|
import { readDynamicViews } from "../parsed/dynamic-views.js";
|
|
21
21
|
import { readSpecification } from "../parsed/specification.js";
|
|
22
22
|
import { readViewIds } from "../parsed/view-ids.js";
|
|
23
|
+
import { readDeployment, NO_DEPLOYMENT } from "../parsed/deployment.js";
|
|
23
24
|
/**
|
|
24
25
|
* Stage a set of documents as ONE project, mirroring their paths relative to
|
|
25
26
|
* `base`.
|
|
@@ -92,6 +93,7 @@ export async function loadProject(base, paths) {
|
|
|
92
93
|
relationships: [],
|
|
93
94
|
views: [],
|
|
94
95
|
viewIds: [],
|
|
96
|
+
deployment: NO_DEPLOYMENT,
|
|
95
97
|
});
|
|
96
98
|
const targets = [...new Set(paths.map((path) => resolve(path)))];
|
|
97
99
|
if (targets.length === 0)
|
|
@@ -113,6 +115,7 @@ export async function loadProject(base, paths) {
|
|
|
113
115
|
specification: readSpecification(model.specification),
|
|
114
116
|
views: readDynamicViews(model),
|
|
115
117
|
viewIds: readViewIds(model),
|
|
118
|
+
deployment: readDeployment(model),
|
|
116
119
|
...flattenModel(model),
|
|
117
120
|
};
|
|
118
121
|
}
|
|
@@ -176,6 +179,7 @@ export function asLoadedDoc(doc) {
|
|
|
176
179
|
...(doc.specification === undefined ? {} : { specification: doc.specification }),
|
|
177
180
|
views: doc.views,
|
|
178
181
|
viewIds: doc.viewIds,
|
|
182
|
+
deployment: doc.deployment,
|
|
179
183
|
elements: doc.elements,
|
|
180
184
|
relationships: doc.relationships,
|
|
181
185
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"load.js","sourceRoot":"","sources":["../../../../src/core/c4/project/load.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * One directory of `.likec4` documents, loaded as ONE LikeC4 project — the way\n * the renderer already reads `architecture/`.\n *\n * A package of its own rather than more of `../workspace.ts`, and the seam is\n * the isolation rule, not the line count. That module stages every document as\n * its own single-file project ON PURPOSE, so an author-written `import` can\n * never resolve against a sibling; this one does the opposite deliberately, for\n * the set of documents that are meant to see each other. Two loaders with\n * opposite contracts should not share a file: the next reader has to be able to\n * tell at a glance which one they are looking at.\n */\nimport { randomBytes } from \"node:crypto\";\nimport { copyFile, mkdir, mkdtemp, rm, writeFile } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, relative, resolve } from \"node:path\";\nimport { LikeC4 } from \"likec4\";\nimport { inOrder } from \"../../kernel/concurrency.js\";\nimport { flattenModel, type Elem, type LikeC4Error, type LoadedDoc, type ReadableModel, type Rel } from \"../likec4.js\";\nimport { readDynamicViews, type ParsedView } from \"../parsed/dynamic-views.js\";\nimport { readSpecification, type DocSpecification } from \"../parsed/specification.js\";\nimport { readViewIds, type ViewIdClaim } from \"../parsed/view-ids.js\";\n\n/** A directory of `.likec4` documents loaded as one LikeC4 project. */\nexport interface ProjectDoc {\n /** Errors by the REAL absolute path of the document that raised them. */\n errors: Map<string, LikeC4Error[]>;\n /** True when no document in the project raised one — loam's \"errors mean no model\" gate. */\n clean: boolean;\n elements: Elem[];\n relationships: Rel[];\n specification?: DocSpecification;\n /** Every dynamic view the project declares, each carrying the file it is written in. */\n views: ParsedView[];\n /** Every authored view id the project claims, each with its file. */\n viewIds: ViewIdClaim[];\n}\n\n/**\n * Stage a set of documents as ONE project, mirroring their paths relative to\n * `base`.\n *\n * The relative structure is preserved rather than flattened to basenames, and\n * that is load-bearing twice over: two use cases named `checkout.likec4` in\n * different subdirectories would overwrite each other on copy, and LikeC4\n * reports a view's `sourcePath` relative to the project root — so flattening\n * would make every finding name a file the author cannot find.\n */\nasync function stageProject(root: string, base: string, targets: string[]): Promise<Map<string, string>> {\n const project = `arch_${randomBytes(8).toString(\"hex\")}`;\n const folder = join(root, project);\n await mkdir(folder, { recursive: true });\n await writeFile(join(folder, \"likec4.config.json\"), JSON.stringify({ name: project }), \"utf8\");\n // rel -> real path, so an error carrying the staged path can be handed back\n // spelled the way the author wrote it.\n const byRel = new Map<string, string>();\n await inOrder(targets, async (path) => {\n const rel = relative(base, path).split(/[\\\\/]/).join(\"/\");\n const dest = join(folder, ...rel.split(\"/\"));\n try {\n await mkdir(dirname(dest), { recursive: true });\n await copyFile(path, dest);\n byRel.set(rel, path);\n } catch {\n // Dropped, exactly as `stage` drops one: the caller's ordinary per-path\n // load owns the error, and a project missing one document still answers\n // about the rest.\n }\n return null;\n });\n return byRel;\n}\n\n/**\n * Load a directory of `.likec4` documents as ONE LikeC4 project — the way the\n * renderer already reads `architecture/`, and the only way a document that\n * declares views over ANOTHER file's elements can be read at all.\n *\n * Measured at the 1.59.2 pin: a views-only `usecases/checkout.likec4` parsed\n * alone is five errors (unresolved tag, unresolved elements); the same file\n * beside its landscape in one project is zero, and each view comes back with\n * its own `sourcePath`. That is the whole reason this function exists — a use\n * case lives in a file of its own, and `loadBatch`'s one-project-per-document\n * isolation makes such a file unreadable by construction.\n *\n * Two rules the caller must know, because both invert what holds elsewhere:\n *\n * - **Exactly one document in the set declares the `specification` block.**\n * Every `.likec4` file loam parses alone must declare its own; inside one\n * project a second declaration is a duplicate error blamed on BOTH files\n * (measured).\n * - **Errors are per document, but the MODEL is all-or-nothing.** A typo in\n * one file is attributed to that file — so the finding names it, which is\n * better than today, where a use case in the landscape would blame the\n * landscape — but loam's standing rule is that errors mean no model, and\n * here the model is the project's. `clean` says which case the caller is in.\n *\n * The generated `architecture/subsystems.likec4` must NOT be in `targets`:\n * docs/DESIGN.md rule 26 keeps it a byte compare, and a stale one whose\n * `include` names a removed element would otherwise be a parse error that\n * blanks the whole fleet map.\n */\nexport async function loadProject(base: string, paths: string[]): Promise<ProjectDoc> {\n const empty = (errors: Map<string, LikeC4Error[]>, clean: boolean): ProjectDoc => ({\n errors,\n clean,\n elements: [],\n relationships: [],\n views: [],\n viewIds: [],\n });\n const targets = [...new Set(paths.map((path) => resolve(path)))];\n if (targets.length === 0) return empty(new Map(), true);\n const root = await mkdtemp(join(tmpdir(), \"loam-c4-\"));\n try {\n const byRel = await stageProject(root, resolve(base), targets);\n if (byRel.size === 0) return empty(new Map(), true);\n const likec4 = await LikeC4.fromWorkspace(root, { logger: false });\n try {\n const errors = groupProjectErrors(likec4, byRel);\n if (errors.size > 0) return empty(errors, false);\n const model = (await likec4.parsedModel()) as ReadableModel;\n return {\n errors,\n clean: true,\n specification: readSpecification(model.specification),\n views: readDynamicViews(model),\n viewIds: readViewIds(model),\n ...flattenModel(model),\n };\n } finally {\n await likec4.dispose();\n }\n } finally {\n await rm(root, { recursive: true, force: true });\n }\n}\n\n/**\n * `getErrors()` split per DOCUMENT inside one project, each error's\n * `sourceFsPath` rewritten to the document's real absolute path.\n *\n * `groupErrors` above keys on the project folder segment, which cannot work\n * here — every document shares one project. This keys on the staged relative\n * path instead, longest first so `usecases/checkout.likec4` is matched before a\n * hypothetical `checkout.likec4` at the root. An error nobody can attribute\n * throws for the same reason it does there: a dropped error grades its document\n * clean, which is failing open in the fleet gate.\n */\nfunction groupProjectErrors(likec4: LikeC4, byRel: Map<string, string>): Map<string, LikeC4Error[]> {\n const rels = [...byRel.keys()].sort((a, b) => b.length - a.length);\n const grouped = new Map<string, LikeC4Error[]>();\n for (const err of likec4.getErrors()) {\n const spelled = err.sourceFsPath.split(/[\\\\/]/).join(\"/\");\n const rel = rels.find((candidate) => spelled.endsWith(`/${candidate}`) || spelled === candidate);\n if (rel === undefined) {\n throw new Error(`unattributable LikeC4 error in project workspace: ${err.message}`);\n }\n const real = byRel.get(rel)!;\n const list = grouped.get(real) ?? [];\n list.push({ ...err, sourceFsPath: real });\n grouped.set(real, list);\n }\n return grouped;\n}\n\n/**\n * A `ProjectDoc` in the shape every existing landscape reader already takes.\n *\n * The per-file error map flattens into one list, which loses nothing: each\n * error still carries the real path of the document that raised it, so a\n * finding can name the file even though the list is flat. Sorted by that path\n * so a project with two broken documents reports them in a stable order —\n * `getErrors()` returns whatever order the workspace scan produced.\n *\n * The model travels only when the project is clean, which is loam's standing\n * rule (`errors mean no model`) applied at project altitude: a use-case file\n * with an unresolved element leaves the landscape unusable, and pretending\n * otherwise would grade a fleet against half a map.\n */\nexport function asLoadedDoc(doc: ProjectDoc): LoadedDoc {\n const errors = [...doc.errors]\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .flatMap(([, list]) => list);\n if (!doc.clean) return { errors, elements: [], relationships: [] };\n return {\n errors,\n ...(doc.specification === undefined ? {} : { specification: doc.specification }),\n views: doc.views,\n viewIds: doc.viewIds,\n elements: doc.elements,\n relationships: doc.relationships,\n };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"load.js","sourceRoot":"","sources":["../../../../src/core/c4/project/load.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * One directory of `.likec4` documents, loaded as ONE LikeC4 project — the way\n * the renderer already reads `architecture/`.\n *\n * A package of its own rather than more of `../workspace.ts`, and the seam is\n * the isolation rule, not the line count. That module stages every document as\n * its own single-file project ON PURPOSE, so an author-written `import` can\n * never resolve against a sibling; this one does the opposite deliberately, for\n * the set of documents that are meant to see each other. Two loaders with\n * opposite contracts should not share a file: the next reader has to be able to\n * tell at a glance which one they are looking at.\n */\nimport { randomBytes } from \"node:crypto\";\nimport { copyFile, mkdir, mkdtemp, rm, writeFile } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, relative, resolve } from \"node:path\";\nimport { LikeC4 } from \"likec4\";\nimport { inOrder } from \"../../kernel/concurrency.js\";\nimport { flattenModel, type Elem, type LikeC4Error, type LoadedDoc, type ReadableModel, type Rel } from \"../likec4.js\";\nimport { readDynamicViews, type ParsedView } from \"../parsed/dynamic-views.js\";\nimport { readSpecification, type DocSpecification } from \"../parsed/specification.js\";\nimport { readViewIds, type ViewIdClaim } from \"../parsed/view-ids.js\";\nimport { readDeployment, NO_DEPLOYMENT, type DeploymentModel } from \"../parsed/deployment.js\";\n\n/** A directory of `.likec4` documents loaded as one LikeC4 project. */\nexport interface ProjectDoc {\n /** Errors by the REAL absolute path of the document that raised them. */\n errors: Map<string, LikeC4Error[]>;\n /** True when no document in the project raised one — loam's \"errors mean no model\" gate. */\n clean: boolean;\n elements: Elem[];\n relationships: Rel[];\n specification?: DocSpecification;\n /** Every dynamic view the project declares, each carrying the file it is written in. */\n views: ParsedView[];\n /** Every authored view id the project claims, each with its file. */\n viewIds: ViewIdClaim[];\n /** The project's deployment model — empty when it declares none, and when it did not parse. */\n deployment: DeploymentModel;\n}\n\n/**\n * Stage a set of documents as ONE project, mirroring their paths relative to\n * `base`.\n *\n * The relative structure is preserved rather than flattened to basenames, and\n * that is load-bearing twice over: two use cases named `checkout.likec4` in\n * different subdirectories would overwrite each other on copy, and LikeC4\n * reports a view's `sourcePath` relative to the project root — so flattening\n * would make every finding name a file the author cannot find.\n */\nasync function stageProject(root: string, base: string, targets: string[]): Promise<Map<string, string>> {\n const project = `arch_${randomBytes(8).toString(\"hex\")}`;\n const folder = join(root, project);\n await mkdir(folder, { recursive: true });\n await writeFile(join(folder, \"likec4.config.json\"), JSON.stringify({ name: project }), \"utf8\");\n // rel -> real path, so an error carrying the staged path can be handed back\n // spelled the way the author wrote it.\n const byRel = new Map<string, string>();\n await inOrder(targets, async (path) => {\n const rel = relative(base, path).split(/[\\\\/]/).join(\"/\");\n const dest = join(folder, ...rel.split(\"/\"));\n try {\n await mkdir(dirname(dest), { recursive: true });\n await copyFile(path, dest);\n byRel.set(rel, path);\n } catch {\n // Dropped, exactly as `stage` drops one: the caller's ordinary per-path\n // load owns the error, and a project missing one document still answers\n // about the rest.\n }\n return null;\n });\n return byRel;\n}\n\n/**\n * Load a directory of `.likec4` documents as ONE LikeC4 project — the way the\n * renderer already reads `architecture/`, and the only way a document that\n * declares views over ANOTHER file's elements can be read at all.\n *\n * Measured at the 1.59.2 pin: a views-only `usecases/checkout.likec4` parsed\n * alone is five errors (unresolved tag, unresolved elements); the same file\n * beside its landscape in one project is zero, and each view comes back with\n * its own `sourcePath`. That is the whole reason this function exists — a use\n * case lives in a file of its own, and `loadBatch`'s one-project-per-document\n * isolation makes such a file unreadable by construction.\n *\n * Two rules the caller must know, because both invert what holds elsewhere:\n *\n * - **Exactly one document in the set declares the `specification` block.**\n * Every `.likec4` file loam parses alone must declare its own; inside one\n * project a second declaration is a duplicate error blamed on BOTH files\n * (measured).\n * - **Errors are per document, but the MODEL is all-or-nothing.** A typo in\n * one file is attributed to that file — so the finding names it, which is\n * better than today, where a use case in the landscape would blame the\n * landscape — but loam's standing rule is that errors mean no model, and\n * here the model is the project's. `clean` says which case the caller is in.\n *\n * The generated `architecture/subsystems.likec4` must NOT be in `targets`:\n * docs/DESIGN.md rule 26 keeps it a byte compare, and a stale one whose\n * `include` names a removed element would otherwise be a parse error that\n * blanks the whole fleet map.\n */\nexport async function loadProject(base: string, paths: string[]): Promise<ProjectDoc> {\n const empty = (errors: Map<string, LikeC4Error[]>, clean: boolean): ProjectDoc => ({\n errors,\n clean,\n elements: [],\n relationships: [],\n views: [],\n viewIds: [],\n deployment: NO_DEPLOYMENT,\n });\n const targets = [...new Set(paths.map((path) => resolve(path)))];\n if (targets.length === 0) return empty(new Map(), true);\n const root = await mkdtemp(join(tmpdir(), \"loam-c4-\"));\n try {\n const byRel = await stageProject(root, resolve(base), targets);\n if (byRel.size === 0) return empty(new Map(), true);\n const likec4 = await LikeC4.fromWorkspace(root, { logger: false });\n try {\n const errors = groupProjectErrors(likec4, byRel);\n if (errors.size > 0) return empty(errors, false);\n const model = (await likec4.parsedModel()) as ReadableModel;\n return {\n errors,\n clean: true,\n specification: readSpecification(model.specification),\n views: readDynamicViews(model),\n viewIds: readViewIds(model),\n deployment: readDeployment(model),\n ...flattenModel(model),\n };\n } finally {\n await likec4.dispose();\n }\n } finally {\n await rm(root, { recursive: true, force: true });\n }\n}\n\n/**\n * `getErrors()` split per DOCUMENT inside one project, each error's\n * `sourceFsPath` rewritten to the document's real absolute path.\n *\n * `groupErrors` above keys on the project folder segment, which cannot work\n * here — every document shares one project. This keys on the staged relative\n * path instead, longest first so `usecases/checkout.likec4` is matched before a\n * hypothetical `checkout.likec4` at the root. An error nobody can attribute\n * throws for the same reason it does there: a dropped error grades its document\n * clean, which is failing open in the fleet gate.\n */\nfunction groupProjectErrors(likec4: LikeC4, byRel: Map<string, string>): Map<string, LikeC4Error[]> {\n const rels = [...byRel.keys()].sort((a, b) => b.length - a.length);\n const grouped = new Map<string, LikeC4Error[]>();\n for (const err of likec4.getErrors()) {\n const spelled = err.sourceFsPath.split(/[\\\\/]/).join(\"/\");\n const rel = rels.find((candidate) => spelled.endsWith(`/${candidate}`) || spelled === candidate);\n if (rel === undefined) {\n throw new Error(`unattributable LikeC4 error in project workspace: ${err.message}`);\n }\n const real = byRel.get(rel)!;\n const list = grouped.get(real) ?? [];\n list.push({ ...err, sourceFsPath: real });\n grouped.set(real, list);\n }\n return grouped;\n}\n\n/**\n * A `ProjectDoc` in the shape every existing landscape reader already takes.\n *\n * The per-file error map flattens into one list, which loses nothing: each\n * error still carries the real path of the document that raised it, so a\n * finding can name the file even though the list is flat. Sorted by that path\n * so a project with two broken documents reports them in a stable order —\n * `getErrors()` returns whatever order the workspace scan produced.\n *\n * The model travels only when the project is clean, which is loam's standing\n * rule (`errors mean no model`) applied at project altitude: a use-case file\n * with an unresolved element leaves the landscape unusable, and pretending\n * otherwise would grade a fleet against half a map.\n */\nexport function asLoadedDoc(doc: ProjectDoc): LoadedDoc {\n const errors = [...doc.errors]\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .flatMap(([, list]) => list);\n if (!doc.clean) return { errors, elements: [], relationships: [] };\n return {\n errors,\n ...(doc.specification === undefined ? {} : { specification: doc.specification }),\n views: doc.views,\n viewIds: doc.viewIds,\n deployment: doc.deployment,\n elements: doc.elements,\n relationships: doc.relationships,\n };\n}\n"]}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `architecture/` project AS THIS FEATURE'S MERGE WOULD LEAVE IT, staged
|
|
3
|
+
* and parsed, with a feature's own documents standing beside it.
|
|
4
|
+
*
|
|
5
|
+
* WHY THE POST-MERGE MAP AND NOT THE LIVING ONE. A document a feature brings
|
|
6
|
+
* names elements, and a feature that adds a service adds the element its own
|
|
7
|
+
* document names. Graded against the LIVING landscape that name is unresolved —
|
|
8
|
+
* LikeC4 refuses the document, the project blanks, and loam reports a hole
|
|
9
|
+
* against a file whose only fault is arriving with the change that makes it
|
|
10
|
+
* true. Graded against the merge it resolves exactly when the merge lands the
|
|
11
|
+
* element it names, which is `ARCH-LOAM-FEATURE-CORPUS`: inside a feature
|
|
12
|
+
* window, the corpus is feature ∪ living.
|
|
13
|
+
*
|
|
14
|
+
* IT IS ALSO THE CHECK. The merge preview is the only reading under which "a
|
|
15
|
+
* document naming an element the merge does not land" is answerable at all, and
|
|
16
|
+
* that refusal is why it runs before `loam archive` writes rather than after:
|
|
17
|
+
* without it the document is copied into `architecture/` and the NEXT reader's
|
|
18
|
+
* `loam validate --all` carries the failure, against a file they did not write.
|
|
19
|
+
*
|
|
20
|
+
* ## Why it lives here rather than in one axis
|
|
21
|
+
*
|
|
22
|
+
* It was `core/usecases/delta/overlay.ts`'s private machinery while the
|
|
23
|
+
* use-case slot was the only feature-local `.likec4` document. The deployment
|
|
24
|
+
* slot is the second, and a second copy of this staging is the copy that
|
|
25
|
+
* drifts — the two would disagree first about the merge preview, which is the
|
|
26
|
+
* one thing both refusals rest on. So the staging moved down to the package
|
|
27
|
+
* that owns the project loader and the merge, and each axis keeps only its own
|
|
28
|
+
* INTERPRETATION of the parse: which views are the feature's, or which
|
|
29
|
+
* documents failed.
|
|
30
|
+
*
|
|
31
|
+
* ## Why it stages its own tree
|
|
32
|
+
*
|
|
33
|
+
* `loadProject` copies documents from disk, keyed on their path relative to a
|
|
34
|
+
* base — so it can be handed neither a landscape that exists only in memory nor
|
|
35
|
+
* a document living outside `architecture/`, and both are exactly what this
|
|
36
|
+
* needs. Teaching it to take content would put a second, rarely-exercised
|
|
37
|
+
* staging rule inside a loader whose docstring already carries two rules that
|
|
38
|
+
* invert what holds elsewhere. Staging here costs one copy of a handful of
|
|
39
|
+
* small files and keeps that loader's contract at one.
|
|
40
|
+
*
|
|
41
|
+
* THE PRICE OF THAT CHOICE IS PATHS, and it is paid here rather than left to
|
|
42
|
+
* the caller: every path LikeC4 reports — a parse error's `sourceFsPath`, a
|
|
43
|
+
* view's `sourcePath` — names a temp tree, and a finding naming a temp
|
|
44
|
+
* directory is a finding nobody can act on. `real()` maps them back to the file
|
|
45
|
+
* the author wrote, and the temp tree is gone before this returns.
|
|
46
|
+
*
|
|
47
|
+
* FAIL CLOSED, and not silently. Every failure is the `unreadable` arm carrying
|
|
48
|
+
* LikeC4's own messages: a document this cannot read is never one that grades
|
|
49
|
+
* clean.
|
|
50
|
+
*/
|
|
51
|
+
import { existsSync } from "node:fs";
|
|
52
|
+
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
53
|
+
import { tmpdir } from "node:os";
|
|
54
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
55
|
+
import { loadFile } from "../likec4.js";
|
|
56
|
+
import { planLandscapeMerge } from "../splice/landscape-merge.js";
|
|
57
|
+
import { inOrder } from "../../kernel/concurrency.js";
|
|
58
|
+
import { decodeDocument } from "../../kernel/document-bytes.js";
|
|
59
|
+
import { featurePaths, landscapePath, subsystemViewsPath } from "../../repo/paths.js";
|
|
60
|
+
import { architectureDir } from "./architecture.js";
|
|
61
|
+
import { architectureDocuments } from "./documents.js";
|
|
62
|
+
import { asLoadedDoc, loadProject } from "./load.js";
|
|
63
|
+
/**
|
|
64
|
+
* Stage the living `architecture/` with the merge preview in place of the
|
|
65
|
+
* landscape, add the feature's own documents, and parse the result.
|
|
66
|
+
*
|
|
67
|
+
* A feature bringing no documents is not this function's business: both callers
|
|
68
|
+
* return their own empty answer before reaching it, at the cost of one walk
|
|
69
|
+
* over a directory that is not there. That walk is the axis's per-feature
|
|
70
|
+
* opt-in and the whole price a fleet that has not adopted it pays.
|
|
71
|
+
*/
|
|
72
|
+
export async function stageMergedProject(req) {
|
|
73
|
+
const merged = await mergedLandscape(req);
|
|
74
|
+
if (merged.kind === "failed")
|
|
75
|
+
return { kind: "unreadable", errors: [merged.reason] };
|
|
76
|
+
const arch = architectureDir(req.docsDir);
|
|
77
|
+
const living = await architectureDocuments(arch, [subsystemViewsPath(req.docsDir)]);
|
|
78
|
+
const root = await mkdtemp(join(tmpdir(), "loam-staged-"));
|
|
79
|
+
try {
|
|
80
|
+
// Staged rel -> the path the AUTHOR wrote, so every message that leaves
|
|
81
|
+
// here names a file they can open. Built as the tree is staged, because
|
|
82
|
+
// that is the one moment both spellings are in hand.
|
|
83
|
+
const authored = new Map();
|
|
84
|
+
const landscape = resolve(landscapePath(req.docsDir));
|
|
85
|
+
await inOrder(living, async (path) => {
|
|
86
|
+
// The living landscape is staged from the merge preview when there is
|
|
87
|
+
// one; copying it as well would declare every element twice.
|
|
88
|
+
if (merged.kind === "merged" && resolve(path) === landscape)
|
|
89
|
+
return null;
|
|
90
|
+
await stage(root, spell(relative(arch, path)), path, authored);
|
|
91
|
+
return null;
|
|
92
|
+
});
|
|
93
|
+
if (merged.kind === "merged") {
|
|
94
|
+
const rel = spell(relative(arch, landscape));
|
|
95
|
+
await write(root, rel, merged.content);
|
|
96
|
+
authored.set(rel, landscape);
|
|
97
|
+
}
|
|
98
|
+
// The feature's own documents LAST, so one whose living twin exists wins
|
|
99
|
+
// the staging rather than colliding with it. That case is already an error
|
|
100
|
+
// the gate refuses before any merge — `usecase.flow-exists`,
|
|
101
|
+
// `deployment.doc-exists` — and grading the feature's version is the more
|
|
102
|
+
// useful of the two answers to give an author who is about to be told to
|
|
103
|
+
// delete the file.
|
|
104
|
+
const mine = new Set();
|
|
105
|
+
for (const document of req.documents) {
|
|
106
|
+
await stage(root, document.rel, document.path, authored);
|
|
107
|
+
mine.add(document.rel);
|
|
108
|
+
}
|
|
109
|
+
const doc = asLoadedDoc(await loadProject(root, await architectureDocuments(root)));
|
|
110
|
+
// TWO SPELLINGS REACH HERE and both must map, which is the one thing this
|
|
111
|
+
// helper cannot be allowed to get half right. A parse error's
|
|
112
|
+
// `sourceFsPath` is an ABSOLUTE path into the temp tree; a view's
|
|
113
|
+
// `sourcePath` is already the project-RELATIVE rel LikeC4 reports. The
|
|
114
|
+
// rel-first lookup answers the second, and the relative() computation the
|
|
115
|
+
// first. Handling only one leaves the other silently returning the temp
|
|
116
|
+
// spelling, which is a finding naming a directory that no longer exists.
|
|
117
|
+
const real = (path) => authored.get(spell(path)) ?? authored.get(spell(relative(root, path))) ?? path;
|
|
118
|
+
if (doc.errors.length > 0) {
|
|
119
|
+
// An error naming no document is LikeC4 declining to attribute one. It
|
|
120
|
+
// still travels: a hole loam cannot place is still a hole, and dropping
|
|
121
|
+
// it here would grade the feature clean on the strength of a missing
|
|
122
|
+
// field.
|
|
123
|
+
return {
|
|
124
|
+
kind: "unreadable",
|
|
125
|
+
errors: doc.errors.map((e) => (e.sourceFsPath === undefined ? e.message : `${real(e.sourceFsPath)}: ${e.message}`)),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { kind: "read", doc, real, mine };
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
// The `unreadable` arm is a PROMISE, not a description of the happy path.
|
|
132
|
+
// Two things under here throw rather than return: `loadProject` refuses an
|
|
133
|
+
// error it cannot attribute to a document, and every `mkdir`/`copyFile` in
|
|
134
|
+
// the staging above can fail on a full disk or a denied temp directory.
|
|
135
|
+
// Callers run inside `loam validate --feature` and the archive gate — and
|
|
136
|
+
// the one behaviour a check added to those may never have is failing the
|
|
137
|
+
// run it was added to. So a throw becomes the same could-not-look answer a
|
|
138
|
+
// parse error already produces, which each caller turns into its own
|
|
139
|
+
// refusal that writes nothing.
|
|
140
|
+
return { kind: "unreadable", errors: [err instanceof Error ? err.message : String(err)] };
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await rm(root, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The merge preview, computed with the SAME function `loam archive` commits
|
|
148
|
+
* with.
|
|
149
|
+
*
|
|
150
|
+
* Reusing `planLandscapeMerge` rather than approximating it is what makes this
|
|
151
|
+
* an answer about the archive rather than a second opinion beside it: an
|
|
152
|
+
* element placed differently, a tag stripped differently, or a splice refused
|
|
153
|
+
* there and allowed here would be a document graded against a map the merge
|
|
154
|
+
* never writes.
|
|
155
|
+
*
|
|
156
|
+
* THE PARITY IS THE MERGE, NOT THE WHOLE GATE, and the difference is worth
|
|
157
|
+
* naming rather than leaving for a reader to discover. `planLandscape` runs one
|
|
158
|
+
* check BEFORE it computes the merge and this does not: a delta declaring the
|
|
159
|
+
* feature tag on a specification KIND is refused there, because every element
|
|
160
|
+
* of that kind inherits the tag and the merge would splice the whole document.
|
|
161
|
+
* Such a delta reaches `newEls` here unfiltered, so the preview this grades
|
|
162
|
+
* against is one the archive would never write. It changes no verdict — that
|
|
163
|
+
* archive refuses either way, before anything is written — and it is recorded
|
|
164
|
+
* because the next reader to widen either side needs to know the two are not
|
|
165
|
+
* the same gate.
|
|
166
|
+
*
|
|
167
|
+
* A throw is `failed` rather than propagated. Every `LandscapeSpliceError` this
|
|
168
|
+
* can raise is one `planLandscape` raises again — with its own message, its own
|
|
169
|
+
* refusal code, and before anything is written — so throwing here would only
|
|
170
|
+
* decide which of two identical refusals an author reads first.
|
|
171
|
+
*/
|
|
172
|
+
async function mergedLandscape(req) {
|
|
173
|
+
const deltaPath = featurePaths(req.featureDir).delta;
|
|
174
|
+
const landscape = landscapePath(req.docsDir);
|
|
175
|
+
if (!existsSync(deltaPath) || !existsSync(landscape))
|
|
176
|
+
return { kind: "living" };
|
|
177
|
+
const delta = await (req.load ?? loadFile)(deltaPath);
|
|
178
|
+
if (delta.errors.length > 0) {
|
|
179
|
+
return {
|
|
180
|
+
kind: "failed",
|
|
181
|
+
reason: `delta.likec4 has ${delta.errors.length} parse error(s) — a feature's own documents cannot be graded against a map that does not parse`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const plan = await planLandscapeMerge({
|
|
186
|
+
landscapeText: await text(landscape),
|
|
187
|
+
deltaText: await text(deltaPath),
|
|
188
|
+
deltaElements: delta.elements,
|
|
189
|
+
newEls: delta.elements.filter((e) => e.tags.includes(req.featureId)),
|
|
190
|
+
newRels: delta.relationships.filter((r) => r.tags.includes(req.featureId)),
|
|
191
|
+
featureId: req.featureId,
|
|
192
|
+
});
|
|
193
|
+
return plan.content === null ? { kind: "living" } : { kind: "merged", content: plan.content };
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
return { kind: "failed", reason: err instanceof Error ? err.message : String(err) };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Read a document loam is about to hand LikeC4, refusing the encodings that decode as empty. */
|
|
200
|
+
async function text(path) {
|
|
201
|
+
return decodeDocument(await readFile(path), path);
|
|
202
|
+
}
|
|
203
|
+
/** A path spelled the way a staged rel is spelled — `/`-separated, on every platform. */
|
|
204
|
+
function spell(path) {
|
|
205
|
+
return path.split(/[\\/]/).join("/");
|
|
206
|
+
}
|
|
207
|
+
/** Copy one authored document into the staged tree, remembering where it came from. */
|
|
208
|
+
async function stage(root, rel, path, authored) {
|
|
209
|
+
const dest = join(root, ...rel.split("/"));
|
|
210
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
211
|
+
await copyFile(path, dest);
|
|
212
|
+
authored.set(rel, resolve(path));
|
|
213
|
+
}
|
|
214
|
+
/** Write one document into the staged tree from bytes loam holds rather than a file. */
|
|
215
|
+
async function write(root, rel, content) {
|
|
216
|
+
const dest = join(root, ...rel.split("/"));
|
|
217
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
218
|
+
await writeFile(dest, content, "utf8");
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=staged.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"staged.js","sourceRoot":"","sources":["../../../../src/core/c4/project/staged.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The `architecture/` project AS THIS FEATURE'S MERGE WOULD LEAVE IT, staged\n * and parsed, with a feature's own documents standing beside it.\n *\n * WHY THE POST-MERGE MAP AND NOT THE LIVING ONE. A document a feature brings\n * names elements, and a feature that adds a service adds the element its own\n * document names. Graded against the LIVING landscape that name is unresolved —\n * LikeC4 refuses the document, the project blanks, and loam reports a hole\n * against a file whose only fault is arriving with the change that makes it\n * true. Graded against the merge it resolves exactly when the merge lands the\n * element it names, which is `ARCH-LOAM-FEATURE-CORPUS`: inside a feature\n * window, the corpus is feature ∪ living.\n *\n * IT IS ALSO THE CHECK. The merge preview is the only reading under which \"a\n * document naming an element the merge does not land\" is answerable at all, and\n * that refusal is why it runs before `loam archive` writes rather than after:\n * without it the document is copied into `architecture/` and the NEXT reader's\n * `loam validate --all` carries the failure, against a file they did not write.\n *\n * ## Why it lives here rather than in one axis\n *\n * It was `core/usecases/delta/overlay.ts`'s private machinery while the\n * use-case slot was the only feature-local `.likec4` document. The deployment\n * slot is the second, and a second copy of this staging is the copy that\n * drifts — the two would disagree first about the merge preview, which is the\n * one thing both refusals rest on. So the staging moved down to the package\n * that owns the project loader and the merge, and each axis keeps only its own\n * INTERPRETATION of the parse: which views are the feature's, or which\n * documents failed.\n *\n * ## Why it stages its own tree\n *\n * `loadProject` copies documents from disk, keyed on their path relative to a\n * base — so it can be handed neither a landscape that exists only in memory nor\n * a document living outside `architecture/`, and both are exactly what this\n * needs. Teaching it to take content would put a second, rarely-exercised\n * staging rule inside a loader whose docstring already carries two rules that\n * invert what holds elsewhere. Staging here costs one copy of a handful of\n * small files and keeps that loader's contract at one.\n *\n * THE PRICE OF THAT CHOICE IS PATHS, and it is paid here rather than left to\n * the caller: every path LikeC4 reports — a parse error's `sourceFsPath`, a\n * view's `sourcePath` — names a temp tree, and a finding naming a temp\n * directory is a finding nobody can act on. `real()` maps them back to the file\n * the author wrote, and the temp tree is gone before this returns.\n *\n * FAIL CLOSED, and not silently. Every failure is the `unreadable` arm carrying\n * LikeC4's own messages: a document this cannot read is never one that grades\n * clean.\n */\nimport { existsSync } from \"node:fs\";\nimport { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, relative, resolve } from \"node:path\";\nimport { loadFile, type LoadedDoc } from \"../likec4.js\";\nimport { planLandscapeMerge } from \"../splice/landscape-merge.js\";\nimport { inOrder } from \"../../kernel/concurrency.js\";\nimport { decodeDocument } from \"../../kernel/document-bytes.js\";\nimport type { DocsDir, FeatureDir } from \"../../kernel/ids/dirs.js\";\nimport { featurePaths, landscapePath, subsystemViewsPath } from \"../../repo/paths.js\";\nimport { architectureDir } from \"./architecture.js\";\nimport { architectureDocuments } from \"./documents.js\";\nimport { asLoadedDoc, loadProject } from \"./load.js\";\n\n/** One document a feature brings, and where it would live under `architecture/`. */\nexport interface StagedDocument {\n /** Its path relative to `architecture/`, `/`-separated — where the merge would write it. */\n rel: string;\n /** The absolute path of the authored file. */\n path: string;\n}\n\nexport interface StagedProjectRequest {\n docsDir: DocsDir;\n featureDir: FeatureDir;\n /** The feature's id — the tag that selects the delta's own additions. */\n featureId: string;\n /** The feature's own documents, staged LAST so they win over a living twin. */\n documents: readonly StagedDocument[];\n /**\n * The caller's memoised LikeC4 read, when it has one. Only `delta.likec4`\n * goes through it, and only to compute the merge preview — but a caller\n * inside `featureCoherence` has already loaded that exact document, and\n * loading one spins a fresh Langium workspace, which\n * `core/coherence/coherence.ts` calls the dominant per-feature cost.\n */\n load?: (path: string) => Promise<LoadedDoc>;\n}\n\n/** The staged parse, or the honest refusal to say. */\nexport type StagedProject =\n | {\n kind: \"read\";\n /** The whole staged project, parsed. */\n doc: LoadedDoc;\n /** A path LikeC4 reported, spelled as the author wrote it. */\n real: (path: string) => string;\n /** The staged rels that are THIS feature's documents — everything else is living. */\n mine: ReadonlySet<string>;\n }\n | { kind: \"unreadable\"; errors: string[] };\n\n/**\n * Stage the living `architecture/` with the merge preview in place of the\n * landscape, add the feature's own documents, and parse the result.\n *\n * A feature bringing no documents is not this function's business: both callers\n * return their own empty answer before reaching it, at the cost of one walk\n * over a directory that is not there. That walk is the axis's per-feature\n * opt-in and the whole price a fleet that has not adopted it pays.\n */\nexport async function stageMergedProject(req: StagedProjectRequest): Promise<StagedProject> {\n const merged = await mergedLandscape(req);\n if (merged.kind === \"failed\") return { kind: \"unreadable\", errors: [merged.reason] };\n\n const arch = architectureDir(req.docsDir);\n const living = await architectureDocuments(arch, [subsystemViewsPath(req.docsDir)]);\n const root = await mkdtemp(join(tmpdir(), \"loam-staged-\"));\n try {\n // Staged rel -> the path the AUTHOR wrote, so every message that leaves\n // here names a file they can open. Built as the tree is staged, because\n // that is the one moment both spellings are in hand.\n const authored = new Map<string, string>();\n const landscape = resolve(landscapePath(req.docsDir));\n await inOrder(living, async (path) => {\n // The living landscape is staged from the merge preview when there is\n // one; copying it as well would declare every element twice.\n if (merged.kind === \"merged\" && resolve(path) === landscape) return null;\n await stage(root, spell(relative(arch, path)), path, authored);\n return null;\n });\n if (merged.kind === \"merged\") {\n const rel = spell(relative(arch, landscape));\n await write(root, rel, merged.content);\n authored.set(rel, landscape);\n }\n // The feature's own documents LAST, so one whose living twin exists wins\n // the staging rather than colliding with it. That case is already an error\n // the gate refuses before any merge — `usecase.flow-exists`,\n // `deployment.doc-exists` — and grading the feature's version is the more\n // useful of the two answers to give an author who is about to be told to\n // delete the file.\n const mine = new Set<string>();\n for (const document of req.documents) {\n await stage(root, document.rel, document.path, authored);\n mine.add(document.rel);\n }\n const doc = asLoadedDoc(await loadProject(root, await architectureDocuments(root)));\n // TWO SPELLINGS REACH HERE and both must map, which is the one thing this\n // helper cannot be allowed to get half right. A parse error's\n // `sourceFsPath` is an ABSOLUTE path into the temp tree; a view's\n // `sourcePath` is already the project-RELATIVE rel LikeC4 reports. The\n // rel-first lookup answers the second, and the relative() computation the\n // first. Handling only one leaves the other silently returning the temp\n // spelling, which is a finding naming a directory that no longer exists.\n const real = (path: string): string =>\n authored.get(spell(path)) ?? authored.get(spell(relative(root, path))) ?? path;\n if (doc.errors.length > 0) {\n // An error naming no document is LikeC4 declining to attribute one. It\n // still travels: a hole loam cannot place is still a hole, and dropping\n // it here would grade the feature clean on the strength of a missing\n // field.\n return {\n kind: \"unreadable\",\n errors: doc.errors.map((e) => (e.sourceFsPath === undefined ? e.message : `${real(e.sourceFsPath)}: ${e.message}`)),\n };\n }\n return { kind: \"read\", doc, real, mine };\n } catch (err) {\n // The `unreadable` arm is a PROMISE, not a description of the happy path.\n // Two things under here throw rather than return: `loadProject` refuses an\n // error it cannot attribute to a document, and every `mkdir`/`copyFile` in\n // the staging above can fail on a full disk or a denied temp directory.\n // Callers run inside `loam validate --feature` and the archive gate — and\n // the one behaviour a check added to those may never have is failing the\n // run it was added to. So a throw becomes the same could-not-look answer a\n // parse error already produces, which each caller turns into its own\n // refusal that writes nothing.\n return { kind: \"unreadable\", errors: [err instanceof Error ? err.message : String(err)] };\n } finally {\n await rm(root, { recursive: true, force: true });\n }\n}\n\n/** What the landscape looks like after this feature's C4 delta is spliced in. */\ntype MergedLandscape =\n /** The delta adds nothing, or there is none — the living landscape stands. */\n | { kind: \"living\" }\n /** The merge preview, as authored bytes. */\n | { kind: \"merged\"; content: string }\n /** The merge could not be computed; `planLandscape` owns the refusal and says why. */\n | { kind: \"failed\"; reason: string };\n\n/**\n * The merge preview, computed with the SAME function `loam archive` commits\n * with.\n *\n * Reusing `planLandscapeMerge` rather than approximating it is what makes this\n * an answer about the archive rather than a second opinion beside it: an\n * element placed differently, a tag stripped differently, or a splice refused\n * there and allowed here would be a document graded against a map the merge\n * never writes.\n *\n * THE PARITY IS THE MERGE, NOT THE WHOLE GATE, and the difference is worth\n * naming rather than leaving for a reader to discover. `planLandscape` runs one\n * check BEFORE it computes the merge and this does not: a delta declaring the\n * feature tag on a specification KIND is refused there, because every element\n * of that kind inherits the tag and the merge would splice the whole document.\n * Such a delta reaches `newEls` here unfiltered, so the preview this grades\n * against is one the archive would never write. It changes no verdict — that\n * archive refuses either way, before anything is written — and it is recorded\n * because the next reader to widen either side needs to know the two are not\n * the same gate.\n *\n * A throw is `failed` rather than propagated. Every `LandscapeSpliceError` this\n * can raise is one `planLandscape` raises again — with its own message, its own\n * refusal code, and before anything is written — so throwing here would only\n * decide which of two identical refusals an author reads first.\n */\nasync function mergedLandscape(req: StagedProjectRequest): Promise<MergedLandscape> {\n const deltaPath = featurePaths(req.featureDir).delta;\n const landscape = landscapePath(req.docsDir);\n if (!existsSync(deltaPath) || !existsSync(landscape)) return { kind: \"living\" };\n const delta = await (req.load ?? loadFile)(deltaPath);\n if (delta.errors.length > 0) {\n return {\n kind: \"failed\",\n reason: `delta.likec4 has ${delta.errors.length} parse error(s) — a feature's own documents cannot be graded against a map that does not parse`,\n };\n }\n try {\n const plan = await planLandscapeMerge({\n landscapeText: await text(landscape),\n deltaText: await text(deltaPath),\n deltaElements: delta.elements,\n newEls: delta.elements.filter((e) => e.tags.includes(req.featureId)),\n newRels: delta.relationships.filter((r) => r.tags.includes(req.featureId)),\n featureId: req.featureId,\n });\n return plan.content === null ? { kind: \"living\" } : { kind: \"merged\", content: plan.content };\n } catch (err) {\n return { kind: \"failed\", reason: err instanceof Error ? err.message : String(err) };\n }\n}\n\n/** Read a document loam is about to hand LikeC4, refusing the encodings that decode as empty. */\nasync function text(path: string): Promise<string> {\n return decodeDocument(await readFile(path), path);\n}\n\n/** A path spelled the way a staged rel is spelled — `/`-separated, on every platform. */\nfunction spell(path: string): string {\n return path.split(/[\\\\/]/).join(\"/\");\n}\n\n/** Copy one authored document into the staged tree, remembering where it came from. */\nasync function stage(root: string, rel: string, path: string, authored: Map<string, string>): Promise<void> {\n const dest = join(root, ...rel.split(\"/\"));\n await mkdir(dirname(dest), { recursive: true });\n await copyFile(path, dest);\n authored.set(rel, resolve(path));\n}\n\n/** Write one document into the staged tree from bytes loam holds rather than a file. */\nasync function write(root: string, rel: string, content: string): Promise<void> {\n const dest = join(root, ...rel.split(\"/\"));\n await mkdir(dirname(dest), { recursive: true });\n await writeFile(dest, content, \"utf8\");\n}\n"]}
|
|
@@ -110,4 +110,50 @@ export function serviceResolver(elements, known) {
|
|
|
110
110
|
export function serviceOf(elements, id, known) {
|
|
111
111
|
return serviceResolver(elements, known)(id);
|
|
112
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* One `id -> service` resolver per document, not one per lookup.
|
|
115
|
+
*
|
|
116
|
+
* `serviceOf` builds a fresh id map on every call, and this axis calls it twice
|
|
117
|
+
* for every relationship in scope — inside loops that already run over every
|
|
118
|
+
* relationship a service can see. `serviceResolver`'s own doc says it is "built
|
|
119
|
+
* once per document and shared"; this is where both axes that need it keep
|
|
120
|
+
* that promise — `../arch.ts`'s `coversEdge` and `./steps.ts`'s `resolvedTier`,
|
|
121
|
+
* each exported and each handed only the element array.
|
|
122
|
+
*
|
|
123
|
+
* A module-level cache is normally the wrong shape here, because a command runs
|
|
124
|
+
* against whatever directory it was invoked in. This one is safe: the key is the
|
|
125
|
+
* per-invocation `Elem[]` a parse produced, its value is a pure function of that
|
|
126
|
+
* key alone, and nothing in it is derived from the working directory — so two
|
|
127
|
+
* runs can never see each other's answer, and the entry dies with the document.
|
|
128
|
+
* Nothing mutates an `Elem[]` after parse.
|
|
129
|
+
*
|
|
130
|
+
* Two tiers because the fleet set is part of the answer: the same document
|
|
131
|
+
* resolves differently with and without `known`, so a cache keyed on the
|
|
132
|
+
* elements alone handed a with-fleet caller the without-fleet resolver.
|
|
133
|
+
* Callers build ONE set per run and pass the same instance through, which is
|
|
134
|
+
* what lets the inner WeakMap key on the set's identity.
|
|
135
|
+
*/
|
|
136
|
+
const RESOLVERS = new WeakMap();
|
|
137
|
+
const FLEET_RESOLVERS = new WeakMap();
|
|
138
|
+
export function resolverFor(elements, known) {
|
|
139
|
+
if (known === undefined) {
|
|
140
|
+
const cached = RESOLVERS.get(elements);
|
|
141
|
+
if (cached !== undefined)
|
|
142
|
+
return cached;
|
|
143
|
+
const resolver = serviceResolver(elements);
|
|
144
|
+
RESOLVERS.set(elements, resolver);
|
|
145
|
+
return resolver;
|
|
146
|
+
}
|
|
147
|
+
let perSet = FLEET_RESOLVERS.get(elements);
|
|
148
|
+
if (perSet === undefined) {
|
|
149
|
+
perSet = new WeakMap();
|
|
150
|
+
FLEET_RESOLVERS.set(elements, perSet);
|
|
151
|
+
}
|
|
152
|
+
const cached = perSet.get(known);
|
|
153
|
+
if (cached !== undefined)
|
|
154
|
+
return cached;
|
|
155
|
+
const resolver = serviceResolver(elements, known);
|
|
156
|
+
perSet.set(known, resolver);
|
|
157
|
+
return resolver;
|
|
158
|
+
}
|
|
113
159
|
//# sourceMappingURL=service.js.map
|