@ybotok/loam 0.1.0-beta.1 → 0.1.0-beta.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 +49 -0
- package/README.md +6 -1
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/adopt.js +17 -3
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/archive.js +24 -8
- package/dist/commands/archive.js.map +1 -1
- package/dist/commands/delta.js +74 -47
- package/dist/commands/delta.js.map +1 -1
- package/dist/commands/dependencies.js +7 -2
- package/dist/commands/dependencies.js.map +1 -1
- package/dist/commands/docs-repo-gate.js +104 -0
- package/dist/commands/docs-repo-gate.js.map +1 -0
- package/dist/commands/explore.js +195 -0
- package/dist/commands/explore.js.map +1 -0
- package/dist/commands/format.js +17 -0
- package/dist/commands/format.js.map +1 -0
- package/dist/commands/gherkin.js +87 -48
- package/dist/commands/gherkin.js.map +1 -1
- package/dist/commands/init.js +3 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/instructions.js +70 -0
- package/dist/commands/instructions.js.map +1 -0
- package/dist/commands/list.js +6 -76
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/migrate-openspec.js +45 -28
- package/dist/commands/migrate-openspec.js.map +1 -1
- package/dist/commands/new.js +35 -34
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/rebase.js +2 -4
- package/dist/commands/rebase.js.map +1 -1
- package/dist/commands/show.js +26 -31
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/status.js +8 -24
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/unarchive.js +12 -5
- package/dist/commands/unarchive.js.map +1 -1
- package/dist/commands/validate.js +115 -133
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/verify.js +33 -17
- package/dist/commands/verify.js.map +1 -1
- package/dist/commands/vouch.js +77 -59
- package/dist/commands/vouch.js.map +1 -1
- package/dist/core/agent.js +273 -31
- package/dist/core/agent.js.map +1 -1
- package/dist/core/arch.js +28 -2
- package/dist/core/arch.js.map +1 -1
- package/dist/core/brief.js +3 -3
- package/dist/core/brief.js.map +1 -1
- package/dist/core/coherence.js +37 -13
- package/dist/core/coherence.js.map +1 -1
- package/dist/core/concurrency.js +51 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/config.js +10 -2
- package/dist/core/config.js.map +1 -1
- package/dist/core/delta.js +21 -2
- package/dist/core/delta.js.map +1 -1
- package/dist/core/dependencies.js +46 -42
- package/dist/core/dependencies.js.map +1 -1
- package/dist/core/docs.js +2 -1
- package/dist/core/docs.js.map +1 -1
- package/dist/core/doctor.js +41 -26
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/document-bytes.js +59 -0
- package/dist/core/document-bytes.js.map +1 -0
- package/dist/core/explore.js +225 -0
- package/dist/core/explore.js.map +1 -0
- package/dist/core/fleet-context.js +16 -63
- package/dist/core/fleet-context.js.map +1 -1
- package/dist/core/frontmatter.js +1 -5
- package/dist/core/frontmatter.js.map +1 -1
- package/dist/core/gherkin.js +3 -61
- package/dist/core/gherkin.js.map +1 -1
- package/dist/core/health.js +1 -5
- package/dist/core/health.js.map +1 -1
- package/dist/core/ids.js +34 -5
- package/dist/core/ids.js.map +1 -1
- package/dist/core/json.js +8 -1
- package/dist/core/json.js.map +1 -1
- package/dist/core/likec4.js +6 -1
- package/dist/core/likec4.js.map +1 -1
- package/dist/core/maturity.js +66 -0
- package/dist/core/maturity.js.map +1 -0
- package/dist/core/openapi-merge.js +39 -6
- package/dist/core/openapi-merge.js.map +1 -1
- package/dist/core/openapi.js +17 -5
- package/dist/core/openapi.js.map +1 -1
- package/dist/core/openspec-inventory.js +64 -27
- package/dist/core/openspec-inventory.js.map +1 -1
- package/dist/core/path-safety.js +59 -0
- package/dist/core/path-safety.js.map +1 -1
- package/dist/core/provenance.js +35 -10
- package/dist/core/provenance.js.map +1 -1
- package/dist/core/records.js +33 -0
- package/dist/core/records.js.map +1 -0
- package/dist/core/repo.js +75 -1
- package/dist/core/repo.js.map +1 -1
- package/dist/core/report.js +9 -0
- package/dist/core/report.js.map +1 -1
- package/dist/core/results.js +1 -3
- package/dist/core/results.js.map +1 -1
- package/dist/core/spec.js +3 -3
- package/dist/core/spec.js.map +1 -1
- package/dist/core/staging.js +124 -17
- package/dist/core/staging.js.map +1 -1
- package/dist/core/status.js +122 -33
- package/dist/core/status.js.map +1 -1
- package/dist/core/steps.js +72 -0
- package/dist/core/steps.js.map +1 -0
- package/dist/core/verify.js +18 -4
- package/dist/core/verify.js.map +1 -1
- package/dist/core/version.js +17 -1
- package/dist/core/version.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The blast radius of a change nobody has written down yet.
|
|
3
|
+
*
|
|
4
|
+
* Everything else in loam answers a question about documents that exist. This
|
|
5
|
+
* module answers the one that comes BEFORE the first document: which services
|
|
6
|
+
* does this feature actually touch? On a fleet of a hundred that call is the
|
|
7
|
+
* hardest one in the cycle and the only one made with no tool in hand —
|
|
8
|
+
* `loam new FEAT-101 --touches a --touches b` takes the answer as an argument
|
|
9
|
+
* and scaffolds whatever it was told. A `--touches` list short by one service
|
|
10
|
+
* is not caught by anything: the feature validates, archives, and ships with a
|
|
11
|
+
* consumer nobody updated.
|
|
12
|
+
*
|
|
13
|
+
* So this states the work the same way `brief.ts` does. It reads the fleet map
|
|
14
|
+
* and the living contracts, reports the ring of services around the seeds, the
|
|
15
|
+
* features already in flight over the same ground, and how far each service's
|
|
16
|
+
* documentation has got — and stops. Which of those neighbours the feature
|
|
17
|
+
* really touches is a judgement about intent, and loam does not make those.
|
|
18
|
+
*
|
|
19
|
+
* The landscape is parsed ONCE for the whole exploration. Per-service reads of
|
|
20
|
+
* it exist (`brief.ts`'s `landscapeContext`) and are correct for one service;
|
|
21
|
+
* calling one of those per seed is how `validate --all` came to cost 13 seconds
|
|
22
|
+
* on 120 services, and this command sits in front of authoring, where that is
|
|
23
|
+
* the difference between a habit and a thing people skip.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync } from "node:fs";
|
|
26
|
+
import { FleetContext } from "./fleet-context.js";
|
|
27
|
+
import { serviceResolver } from "./likec4.js";
|
|
28
|
+
import { maturityGaps, serviceMaturity, } from "./maturity.js";
|
|
29
|
+
import { compareIds, landscapePath, nearestIds, servicePaths, } from "./repo.js";
|
|
30
|
+
/**
|
|
31
|
+
* Read the fleet around a proposed change. Reads only; writes nothing, and
|
|
32
|
+
* refuses nothing — every miss is reported as a field.
|
|
33
|
+
*/
|
|
34
|
+
export async function explore(req) {
|
|
35
|
+
const { docsDir, featureId } = req;
|
|
36
|
+
const context = req.context ?? new FleetContext();
|
|
37
|
+
const entries = await context.listServices(docsDir);
|
|
38
|
+
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
39
|
+
const known = new Set(byId.keys());
|
|
40
|
+
const landPath = landscapePath(docsDir);
|
|
41
|
+
const present = existsSync(landPath);
|
|
42
|
+
const land = present ? await context.loadLikeC4(landPath) : null;
|
|
43
|
+
const parses = land !== null && land.errors.length === 0;
|
|
44
|
+
const elements = parses ? land.elements : [];
|
|
45
|
+
const relationships = parses ? land.relationships : [];
|
|
46
|
+
// One resolver for the whole scan, built with the real service ids so an edge
|
|
47
|
+
// drawn into a modelled container (`paymentService.api`) counts for the
|
|
48
|
+
// service that owns it — the same resolution `list`, `show` and `validate`
|
|
49
|
+
// use, so no two commands can disagree about who calls whom.
|
|
50
|
+
const svcOf = serviceResolver(elements, known);
|
|
51
|
+
// Operation seeds resolve through the living contracts, not the map: the map
|
|
52
|
+
// says who CALLS an operation, and the question here is who DEFINES it.
|
|
53
|
+
const opSeeds = [];
|
|
54
|
+
const unresolvedOperations = [];
|
|
55
|
+
for (const op of req.operations) {
|
|
56
|
+
const owner = await operationOwner(docsDir, entries, op, context);
|
|
57
|
+
if (owner === null)
|
|
58
|
+
unresolvedOperations.push(op);
|
|
59
|
+
else
|
|
60
|
+
opSeeds.push(owner);
|
|
61
|
+
}
|
|
62
|
+
const seeds = [...new Set([...req.services, ...opSeeds])];
|
|
63
|
+
const unknown = seeds
|
|
64
|
+
.filter((id) => !known.has(id))
|
|
65
|
+
.map((id) => ({ id, nearest: nearestIds(id, [...known]) }));
|
|
66
|
+
// The ring: one hop, both directions. A seed that is not modelled contributes
|
|
67
|
+
// nothing here and that is the honest answer — an invisible service has no
|
|
68
|
+
// neighbours anyone can derive, which is what `landscape.service-unmodelled`
|
|
69
|
+
// is about.
|
|
70
|
+
//
|
|
71
|
+
// The two directions are collected separately rather than written into one
|
|
72
|
+
// map as the loop goes, because a neighbour can be BOTH — it calls a seed and
|
|
73
|
+
// the seed calls it — and a single-map version answers whichever edge the
|
|
74
|
+
// parser happened to yield last. That is a field whose value depends on the
|
|
75
|
+
// order lines appear in a `.likec4` file, which is the kind of answer that
|
|
76
|
+
// reads as derived and is not.
|
|
77
|
+
const seedSet = new Set(seeds);
|
|
78
|
+
const callsSeed = new Set();
|
|
79
|
+
const calledBySeed = new Set();
|
|
80
|
+
for (const r of relationships) {
|
|
81
|
+
const from = svcOf(r.source);
|
|
82
|
+
const to = svcOf(r.target);
|
|
83
|
+
if (seedSet.has(from) && !seedSet.has(to))
|
|
84
|
+
calledBySeed.add(to);
|
|
85
|
+
if (seedSet.has(to) && !seedSet.has(from))
|
|
86
|
+
callsSeed.add(from);
|
|
87
|
+
}
|
|
88
|
+
// `calls-seed` wins the overlap, deterministically. Of the two directions it
|
|
89
|
+
// is the one that more often has to be touched: a consumer depends on the
|
|
90
|
+
// seed's contract, so a change to that contract is what breaks it, while a
|
|
91
|
+
// provider the seed merely calls is usually unaffected by the seed changing.
|
|
92
|
+
const ring = new Map([
|
|
93
|
+
...[...calledBySeed].map((id) => [id, "called-by-seed"]),
|
|
94
|
+
...[...callsSeed].map((id) => [id, "calls-seed"]),
|
|
95
|
+
]);
|
|
96
|
+
const ordered = [
|
|
97
|
+
...[...seeds].sort(compareIds).map((id) => ({ id, reason: "seed" })),
|
|
98
|
+
...[...ring]
|
|
99
|
+
.sort(([a], [b]) => compareIds(a, b))
|
|
100
|
+
.map(([id, reason]) => ({ id, reason })),
|
|
101
|
+
];
|
|
102
|
+
const services = [];
|
|
103
|
+
for (const { id, reason } of ordered) {
|
|
104
|
+
services.push(await describe({
|
|
105
|
+
id,
|
|
106
|
+
reason,
|
|
107
|
+
entry: byId.get(id),
|
|
108
|
+
docsDir,
|
|
109
|
+
relationships,
|
|
110
|
+
svcOf,
|
|
111
|
+
elements,
|
|
112
|
+
parses,
|
|
113
|
+
context,
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
const covered = new Set(services.map((s) => s.id));
|
|
117
|
+
const features = await context.listFeatures(docsDir);
|
|
118
|
+
const overlaps = features
|
|
119
|
+
.map((f) => ({ feature: f.id, services: f.services.filter((s) => covered.has(s)) }))
|
|
120
|
+
.filter((o) => o.services.length > 0)
|
|
121
|
+
.sort((a, b) => compareIds(a.feature, b.feature));
|
|
122
|
+
return {
|
|
123
|
+
seeds,
|
|
124
|
+
unknown,
|
|
125
|
+
unresolvedOperations,
|
|
126
|
+
services,
|
|
127
|
+
neighbours: [...ring.keys()].sort(compareIds),
|
|
128
|
+
overlaps,
|
|
129
|
+
landscape: { present, parses },
|
|
130
|
+
scaffold: newCommand(featureId, seeds, known),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async function describe(req) {
|
|
134
|
+
const { id, reason, entry, docsDir, relationships, svcOf } = req;
|
|
135
|
+
const inbound = [];
|
|
136
|
+
const outbound = [];
|
|
137
|
+
for (const r of relationships) {
|
|
138
|
+
const edge = { op: r.op ?? null, title: r.title ?? null };
|
|
139
|
+
if (svcOf(r.target) === id)
|
|
140
|
+
inbound.push({ service: svcOf(r.source), ...edge });
|
|
141
|
+
else if (svcOf(r.source) === id)
|
|
142
|
+
outbound.push({ service: svcOf(r.target), ...edge });
|
|
143
|
+
}
|
|
144
|
+
const modelled = req.elements.some((e) => svcOf(e.id) === id);
|
|
145
|
+
// A service that does not exist has no rung: `empty` would be a claim about a
|
|
146
|
+
// directory nobody has made, and it reads as "exists, nothing in it" — the
|
|
147
|
+
// one state a caller most needs to tell it apart from.
|
|
148
|
+
if (entry === undefined) {
|
|
149
|
+
return {
|
|
150
|
+
id,
|
|
151
|
+
reason,
|
|
152
|
+
known: false,
|
|
153
|
+
maturity: null,
|
|
154
|
+
missing: [],
|
|
155
|
+
modelled,
|
|
156
|
+
operations: [],
|
|
157
|
+
// Not `unreadable`: there is no contract here to fail to read. A service
|
|
158
|
+
// with no directory owes nothing, and reporting a parse failure over an
|
|
159
|
+
// absent file would send a reader looking for a document to fix.
|
|
160
|
+
openapi: { unreadable: false },
|
|
161
|
+
inbound,
|
|
162
|
+
outbound,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const archSpec = existsSync(servicePaths(docsDir, id).archSpec);
|
|
166
|
+
// Positive evidence only, and the rule is `list`'s verbatim: an inbound edge
|
|
167
|
+
// carrying an operation is proof somebody calls this service, while a
|
|
168
|
+
// landscape that is absent or does not parse proves nothing about who calls
|
|
169
|
+
// it — so the contract is still owed. Spelling this differently here is how
|
|
170
|
+
// the same service would grade `partial` in one command and `vouched` in the
|
|
171
|
+
// other.
|
|
172
|
+
const apiExpected = !req.parses || inbound.some((e) => e.op !== null);
|
|
173
|
+
const input = { entry, archSpec, apiExpected };
|
|
174
|
+
// `readOpenapi`, not `operationIds`: a contract that exists but does not
|
|
175
|
+
// parse comes back from the id list as an EMPTY set, indistinguishable from a
|
|
176
|
+
// service with no endpoints — and "this service offers nothing" is the worst
|
|
177
|
+
// possible lie to tell somebody deciding whether to call it.
|
|
178
|
+
const api = await req.context.readOpenapi(servicePaths(docsDir, id).openapi);
|
|
179
|
+
const operations = api.ops.filter((o) => !o.remove).map((o) => o.id);
|
|
180
|
+
return {
|
|
181
|
+
id,
|
|
182
|
+
reason,
|
|
183
|
+
known: true,
|
|
184
|
+
maturity: serviceMaturity(input),
|
|
185
|
+
missing: maturityGaps(input),
|
|
186
|
+
modelled,
|
|
187
|
+
operations,
|
|
188
|
+
openapi: {
|
|
189
|
+
unreadable: api.unreadable,
|
|
190
|
+
...(api.error === undefined ? {} : { error: api.error }),
|
|
191
|
+
},
|
|
192
|
+
inbound,
|
|
193
|
+
outbound,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The service whose living contract defines an operationId, or null.
|
|
198
|
+
*
|
|
199
|
+
* Reads every service's `openapi.yaml`, which is the cost of asking a question
|
|
200
|
+
* nothing indexes. It is YAML, not LikeC4 — the expensive parse in this
|
|
201
|
+
* codebase — and it happens only when somebody passes `--op`.
|
|
202
|
+
*/
|
|
203
|
+
async function operationOwner(docsDir, entries, op, context) {
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
if (!entry.has.openapi)
|
|
206
|
+
continue;
|
|
207
|
+
const api = await context.readOpenapi(servicePaths(docsDir, entry.id).openapi);
|
|
208
|
+
if (api.ops.some((o) => o.id === op && !o.remove))
|
|
209
|
+
return entry.id;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The `loam new` line the seeds imply. A seed that names no existing service is
|
|
215
|
+
* `--new-service`, not `--touches`: those are different scaffolds, and getting
|
|
216
|
+
* it wrong is what leaves a feature with a requirement delta for a service that
|
|
217
|
+
* has no directory to archive into.
|
|
218
|
+
*/
|
|
219
|
+
function newCommand(featureId, seeds, known) {
|
|
220
|
+
const flags = [...seeds]
|
|
221
|
+
.sort(compareIds)
|
|
222
|
+
.map((id) => (known.has(id) ? `--touches ${id}` : `--new-service ${id}`));
|
|
223
|
+
return `loam new ${featureId}${flags.length > 0 ? ` ${flags.join(" ")}` : ""}`;
|
|
224
|
+
}
|
|
225
|
+
//# sourceMappingURL=explore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"explore.js","sourceRoot":"","sources":["../../src/core/explore.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The blast radius of a change nobody has written down yet.\n *\n * Everything else in loam answers a question about documents that exist. This\n * module answers the one that comes BEFORE the first document: which services\n * does this feature actually touch? On a fleet of a hundred that call is the\n * hardest one in the cycle and the only one made with no tool in hand —\n * `loam new FEAT-101 --touches a --touches b` takes the answer as an argument\n * and scaffolds whatever it was told. A `--touches` list short by one service\n * is not caught by anything: the feature validates, archives, and ships with a\n * consumer nobody updated.\n *\n * So this states the work the same way `brief.ts` does. It reads the fleet map\n * and the living contracts, reports the ring of services around the seeds, the\n * features already in flight over the same ground, and how far each service's\n * documentation has got — and stops. Which of those neighbours the feature\n * really touches is a judgement about intent, and loam does not make those.\n *\n * The landscape is parsed ONCE for the whole exploration. Per-service reads of\n * it exist (`brief.ts`'s `landscapeContext`) and are correct for one service;\n * calling one of those per seed is how `validate --all` came to cost 13 seconds\n * on 120 services, and this command sits in front of authoring, where that is\n * the difference between a habit and a thing people skip.\n */\nimport { existsSync } from \"node:fs\";\nimport { FleetContext } from \"./fleet-context.js\";\nimport { serviceResolver, type Elem, type Rel } from \"./likec4.js\";\nimport {\n maturityGaps,\n serviceMaturity,\n type Maturity,\n} from \"./maturity.js\";\nimport {\n compareIds,\n landscapePath,\n nearestIds,\n servicePaths,\n type ServiceEntry,\n} from \"./repo.js\";\n\n/** One end of a call the fleet map already draws. */\nexport interface ExploreEdge {\n /** The service at the other end. */\n service: string;\n /** The operationId the call uses, or null for an edge nobody tied to one. */\n op: string | null;\n title: string | null;\n}\n\n/**\n * Why a service is in the answer. Seeds were asked for; the rest were derived.\n *\n * A neighbour on both sides of a seed reports `calls-seed` — see the overlap\n * rule in `explore`. The field is a hint about where to look, not a complete\n * description of the edges; `inbound`/`outbound` are that.\n */\nexport type ExploreReason = \"seed\" | \"calls-seed\" | \"called-by-seed\";\n\nexport interface ExploreService {\n id: string;\n reason: ExploreReason;\n /** Whether `services/<id>/` exists at all. */\n known: boolean;\n /**\n * How far this service's documentation has got, or null when it does not\n * exist yet. A `--touches` naming a service at `empty` or `partial` is a\n * different job from one naming a vouched service, and the difference decides\n * whether the feature starts with `loam adopt`.\n */\n maturity: Maturity | null;\n /** What stands between it and `vouched` — `maturityGaps`, so it matches `list --needs-work`. */\n missing: string[];\n /** Whether anything in the fleet map resolves to it. */\n modelled: boolean;\n /** operationIds its living contract exposes today, `x-loam-remove` markers excluded. */\n operations: string[];\n /**\n * Whether that contract could be read at all, beside the `error` the parser\n * gave when it could not.\n *\n * It rides next to `operations` rather than inside it for the reason `delta`\n * carries the same pair: an unreadable contract yields an EMPTY operation\n * list, indistinguishable from a service with no endpoints — and this is the\n * field the workflow tells an agent to read before deciding whether an\n * operation it is about to add already exists. Answering `[]` over a YAML\n * error is how a MODIFIED requirement gets authored as ADDED.\n */\n openapi: { unreadable: boolean; error?: string };\n inbound: ExploreEdge[];\n outbound: ExploreEdge[];\n}\n\n/** An active feature already working over ground the exploration covers. */\nexport interface ExploreOverlap {\n feature: string;\n /** The explored services it already carries a delta for. */\n services: string[];\n}\n\nexport interface Exploration {\n /** The seed ids, as asked for. */\n seeds: string[];\n /**\n * Seeds that name no `services/<id>/`, each with the closest real ids. A seed\n * may legitimately be a service the feature INTRODUCES, so this is reported\n * rather than refused — but a typo produces exactly the same shape, and the\n * near-miss list is what tells the two apart.\n */\n unknown: Array<{ id: string; nearest: string[] }>;\n /** Operation seeds that resolved to no service's living contract. */\n unresolvedOperations: string[];\n /** Seeds first, then the ring, each in id order. */\n services: ExploreService[];\n /** Ids the ring added that were not asked for — the candidates to weigh. */\n neighbours: string[];\n overlaps: ExploreOverlap[];\n landscape: { present: boolean; parses: boolean };\n /**\n * The literal `loam new` line the seeds imply — seeds only. The ring is\n * deliberately NOT folded in: this module's whole claim is that it does not\n * know which neighbours the feature touches, and a command line that quietly\n * included them would make the guess on the reader's behalf and look derived\n * while doing it.\n *\n * Named for what it does rather than `command`, which is the envelope's key\n * for the command that RAN. The rename is not cosmetic: `emitJson` spreads\n * the payload LAST, so a payload field called `command` would silently\n * OVERRIDE the envelope's — `command` would come back as\n * `\"loam new FEAT-000 …\"`, and every consumer branching on\n * `command === \"explore\"` (the same key `status`, `doctor`, `dependencies`,\n * `archive` and `unarchive` emit) would quietly stop matching, with tsc and\n * the linter green throughout.\n */\n scaffold: string;\n}\n\nexport interface ExploreRequest {\n docsDir: string;\n /** Service ids to explore around. */\n services: string[];\n /** operationIds to explore around; each resolves to the service that defines it. */\n operations: string[];\n /** Placeholder feature id for the suggested command line. */\n featureId: string;\n context?: FleetContext;\n}\n\n/**\n * Read the fleet around a proposed change. Reads only; writes nothing, and\n * refuses nothing — every miss is reported as a field.\n */\nexport async function explore(req: ExploreRequest): Promise<Exploration> {\n const { docsDir, featureId } = req;\n const context = req.context ?? new FleetContext();\n const entries = await context.listServices(docsDir);\n const byId = new Map(entries.map((e) => [e.id, e]));\n const known = new Set(byId.keys());\n\n const landPath = landscapePath(docsDir);\n const present = existsSync(landPath);\n const land = present ? await context.loadLikeC4(landPath) : null;\n const parses = land !== null && land.errors.length === 0;\n const elements = parses ? land.elements : [];\n const relationships: Rel[] = parses ? land.relationships : [];\n // One resolver for the whole scan, built with the real service ids so an edge\n // drawn into a modelled container (`paymentService.api`) counts for the\n // service that owns it — the same resolution `list`, `show` and `validate`\n // use, so no two commands can disagree about who calls whom.\n const svcOf = serviceResolver(elements, known);\n\n // Operation seeds resolve through the living contracts, not the map: the map\n // says who CALLS an operation, and the question here is who DEFINES it.\n const opSeeds: string[] = [];\n const unresolvedOperations: string[] = [];\n for (const op of req.operations) {\n const owner = await operationOwner(docsDir, entries, op, context);\n if (owner === null) unresolvedOperations.push(op);\n else opSeeds.push(owner);\n }\n\n const seeds = [...new Set([...req.services, ...opSeeds])];\n const unknown = seeds\n .filter((id) => !known.has(id))\n .map((id) => ({ id, nearest: nearestIds(id, [...known]) }));\n\n // The ring: one hop, both directions. A seed that is not modelled contributes\n // nothing here and that is the honest answer — an invisible service has no\n // neighbours anyone can derive, which is what `landscape.service-unmodelled`\n // is about.\n //\n // The two directions are collected separately rather than written into one\n // map as the loop goes, because a neighbour can be BOTH — it calls a seed and\n // the seed calls it — and a single-map version answers whichever edge the\n // parser happened to yield last. That is a field whose value depends on the\n // order lines appear in a `.likec4` file, which is the kind of answer that\n // reads as derived and is not.\n const seedSet = new Set(seeds);\n const callsSeed = new Set<string>();\n const calledBySeed = new Set<string>();\n for (const r of relationships) {\n const from = svcOf(r.source);\n const to = svcOf(r.target);\n if (seedSet.has(from) && !seedSet.has(to)) calledBySeed.add(to);\n if (seedSet.has(to) && !seedSet.has(from)) callsSeed.add(from);\n }\n\n // `calls-seed` wins the overlap, deterministically. Of the two directions it\n // is the one that more often has to be touched: a consumer depends on the\n // seed's contract, so a change to that contract is what breaks it, while a\n // provider the seed merely calls is usually unaffected by the seed changing.\n const ring = new Map<string, ExploreReason>([\n ...[...calledBySeed].map((id) => [id, \"called-by-seed\" as const] as const),\n ...[...callsSeed].map((id) => [id, \"calls-seed\" as const] as const),\n ]);\n\n const ordered: Array<{ id: string; reason: ExploreReason }> = [\n ...[...seeds].sort(compareIds).map((id) => ({ id, reason: \"seed\" as const })),\n ...[...ring]\n .sort(([a], [b]) => compareIds(a, b))\n .map(([id, reason]) => ({ id, reason })),\n ];\n\n const services: ExploreService[] = [];\n for (const { id, reason } of ordered) {\n services.push(\n await describe({\n id,\n reason,\n entry: byId.get(id),\n docsDir,\n relationships,\n svcOf,\n elements,\n parses,\n context,\n }),\n );\n }\n\n const covered = new Set(services.map((s) => s.id));\n const features = await context.listFeatures(docsDir);\n const overlaps: ExploreOverlap[] = features\n .map((f) => ({ feature: f.id, services: f.services.filter((s) => covered.has(s)) }))\n .filter((o) => o.services.length > 0)\n .sort((a, b) => compareIds(a.feature, b.feature));\n\n return {\n seeds,\n unknown,\n unresolvedOperations,\n services,\n neighbours: [...ring.keys()].sort(compareIds),\n overlaps,\n landscape: { present, parses },\n scaffold: newCommand(featureId, seeds, known),\n };\n}\n\n/* ------------------------------------------------------------------ */\n\ninterface DescribeRequest {\n id: string;\n reason: ExploreReason;\n entry: ServiceEntry | undefined;\n docsDir: string;\n relationships: Rel[];\n svcOf: (id: string) => string;\n elements: Elem[];\n /** Whether the landscape parsed — what makes its silence evidence rather than absence. */\n parses: boolean;\n context: FleetContext;\n}\n\nasync function describe(req: DescribeRequest): Promise<ExploreService> {\n const { id, reason, entry, docsDir, relationships, svcOf } = req;\n\n const inbound: ExploreEdge[] = [];\n const outbound: ExploreEdge[] = [];\n for (const r of relationships) {\n const edge = { op: r.op ?? null, title: r.title ?? null };\n if (svcOf(r.target) === id) inbound.push({ service: svcOf(r.source), ...edge });\n else if (svcOf(r.source) === id) outbound.push({ service: svcOf(r.target), ...edge });\n }\n const modelled = req.elements.some((e) => svcOf(e.id) === id);\n\n // A service that does not exist has no rung: `empty` would be a claim about a\n // directory nobody has made, and it reads as \"exists, nothing in it\" — the\n // one state a caller most needs to tell it apart from.\n if (entry === undefined) {\n return {\n id,\n reason,\n known: false,\n maturity: null,\n missing: [],\n modelled,\n operations: [],\n // Not `unreadable`: there is no contract here to fail to read. A service\n // with no directory owes nothing, and reporting a parse failure over an\n // absent file would send a reader looking for a document to fix.\n openapi: { unreadable: false },\n inbound,\n outbound,\n };\n }\n\n const archSpec = existsSync(servicePaths(docsDir, id).archSpec);\n // Positive evidence only, and the rule is `list`'s verbatim: an inbound edge\n // carrying an operation is proof somebody calls this service, while a\n // landscape that is absent or does not parse proves nothing about who calls\n // it — so the contract is still owed. Spelling this differently here is how\n // the same service would grade `partial` in one command and `vouched` in the\n // other.\n const apiExpected = !req.parses || inbound.some((e) => e.op !== null);\n const input = { entry, archSpec, apiExpected };\n\n // `readOpenapi`, not `operationIds`: a contract that exists but does not\n // parse comes back from the id list as an EMPTY set, indistinguishable from a\n // service with no endpoints — and \"this service offers nothing\" is the worst\n // possible lie to tell somebody deciding whether to call it.\n const api = await req.context.readOpenapi(servicePaths(docsDir, id).openapi);\n const operations = api.ops.filter((o) => !o.remove).map((o) => o.id);\n\n return {\n id,\n reason,\n known: true,\n maturity: serviceMaturity(input),\n missing: maturityGaps(input),\n modelled,\n operations,\n openapi: {\n unreadable: api.unreadable,\n ...(api.error === undefined ? {} : { error: api.error }),\n },\n inbound,\n outbound,\n };\n}\n\n/**\n * The service whose living contract defines an operationId, or null.\n *\n * Reads every service's `openapi.yaml`, which is the cost of asking a question\n * nothing indexes. It is YAML, not LikeC4 — the expensive parse in this\n * codebase — and it happens only when somebody passes `--op`.\n */\nasync function operationOwner(\n docsDir: string,\n entries: ServiceEntry[],\n op: string,\n context: FleetContext,\n): Promise<string | null> {\n for (const entry of entries) {\n if (!entry.has.openapi) continue;\n const api = await context.readOpenapi(servicePaths(docsDir, entry.id).openapi);\n if (api.ops.some((o) => o.id === op && !o.remove)) return entry.id;\n }\n return null;\n}\n\n/**\n * The `loam new` line the seeds imply. A seed that names no existing service is\n * `--new-service`, not `--touches`: those are different scaffolds, and getting\n * it wrong is what leaves a feature with a requirement delta for a service that\n * has no directory to archive into.\n */\nfunction newCommand(featureId: string, seeds: string[], known: ReadonlySet<string>): string {\n const flags = [...seeds]\n .sort(compareIds)\n .map((id) => (known.has(id) ? `--touches ${id}` : `--new-service ${id}`));\n return `loam new ${featureId}${flags.length > 0 ? ` ${flags.join(\" \")}` : \"\"}`;\n}\n"]}
|
|
@@ -12,15 +12,17 @@
|
|
|
12
12
|
* (which `readFile(path, "utf8")` silently turns into U+FFFD, so a UTF-16
|
|
13
13
|
* spec.md parses as zero requirements and grades green), and git conflict
|
|
14
14
|
* markers left in a living document (which parse as prose, and which the next
|
|
15
|
-
* `loam archive` rewrites away). One place, so no reader can forget.
|
|
15
|
+
* `loam archive` rewrites away). One place, so no reader can forget. The
|
|
16
|
+
* decode itself lives in `document-bytes.ts`, a leaf: the parsers that read a
|
|
17
|
+
* single file without an index owe the same rule, and must not have to import
|
|
18
|
+
* this module — and the fleet it pulls in behind it — to obey it.
|
|
16
19
|
*/
|
|
17
|
-
import { isUtf8 } from "node:buffer";
|
|
18
20
|
import { readFile } from "node:fs/promises";
|
|
19
21
|
import { resolve } from "node:path";
|
|
22
|
+
import { decodeDocument } from "./document-bytes.js";
|
|
20
23
|
import { loadFile } from "./likec4.js";
|
|
21
24
|
import { readOpenapi, } from "./openapi.js";
|
|
22
25
|
import { featureSpecServices, listFeatures, listServices, } from "./repo.js";
|
|
23
|
-
import { featureSpecPaths, servicePaths } from "./repo.js";
|
|
24
26
|
import { parseRequirements } from "./spec.js";
|
|
25
27
|
function key(path) {
|
|
26
28
|
return resolve(path);
|
|
@@ -28,52 +30,6 @@ function key(path) {
|
|
|
28
30
|
/* ------------------------------------------------------------------ */
|
|
29
31
|
/* What a read must never swallow */
|
|
30
32
|
/* ------------------------------------------------------------------ */
|
|
31
|
-
/**
|
|
32
|
-
* A document loam was about to parse that is not UTF-8 text.
|
|
33
|
-
*
|
|
34
|
-
* Thrown rather than returned, and named as the file rather than as an empty
|
|
35
|
-
* document, because every parser in the codebase reads "no requirements, no
|
|
36
|
-
* frontmatter" as a legitimate state — a fresh baseline looks exactly like it.
|
|
37
|
-
* A `spec.md` saved as UTF-16 (PowerShell's `>` writes UTF-16LE by default)
|
|
38
|
-
* therefore validated green, with every requirement in it invisible.
|
|
39
|
-
*
|
|
40
|
-
* `path` is spelled the way Node spells it on an ErrnoException so that
|
|
41
|
-
* validate's per-target guard names the file without knowing this type; the
|
|
42
|
-
* write path refuses the same way for the same reason (staging.ts's
|
|
43
|
-
* NotUtf8Error), and that message is about what a rewrite would destroy, which
|
|
44
|
-
* is not what a reader needs to hear.
|
|
45
|
-
*/
|
|
46
|
-
export class NotUtf8DocumentError extends Error {
|
|
47
|
-
path;
|
|
48
|
-
name = "NotUtf8DocumentError";
|
|
49
|
-
constructor(path, reason) {
|
|
50
|
-
super(`${path} is not a UTF-8 text document — ${reason}. Read as UTF-8 its requirements, frontmatter and headings ` +
|
|
51
|
-
`all come out absent rather than wrong, so the file grades as an empty baseline instead of an unreadable one. ` +
|
|
52
|
-
`Nothing here was checked. Re-save it as UTF-8 (PowerShell's \`>\` and \`Out-File\` write UTF-16 unless told ` +
|
|
53
|
-
`otherwise: use \`Out-File -Encoding utf8\`), then re-run.`);
|
|
54
|
-
this.path = path;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Decode a document loam is about to parse, or refuse naming it.
|
|
59
|
-
*
|
|
60
|
-
* Two tests, because one is not enough. `isUtf8` catches the byte sequences
|
|
61
|
-
* that cannot be UTF-8 at all — which is what a byte-order mark makes UTF-16
|
|
62
|
-
* into, and PowerShell writes one. Without a BOM, UTF-16LE of ASCII is a
|
|
63
|
-
* sequence of valid UTF-8 bytes: every other byte is NUL, which decodes to
|
|
64
|
-
* U+0000 and nothing complains. That file parsed as zero requirements and no
|
|
65
|
-
* frontmatter, and validated green. A NUL in a markdown document loam owns has
|
|
66
|
-
* no legitimate spelling, so it is the second test.
|
|
67
|
-
*/
|
|
68
|
-
export function decodeDocument(bytes, path) {
|
|
69
|
-
if (!isUtf8(bytes))
|
|
70
|
-
throw new NotUtf8DocumentError(path, "its bytes are not a valid UTF-8 sequence");
|
|
71
|
-
const text = bytes.toString("utf8");
|
|
72
|
-
if (text.includes("\u0000")) {
|
|
73
|
-
throw new NotUtf8DocumentError(path, "it holds NUL characters, which no markdown document does — almost always UTF-16 saved without a byte-order mark");
|
|
74
|
-
}
|
|
75
|
-
return text;
|
|
76
|
-
}
|
|
77
33
|
/**
|
|
78
34
|
* The three-way merge left its markers in the file — 1-based line numbers.
|
|
79
35
|
*
|
|
@@ -244,20 +200,17 @@ export class FleetContext {
|
|
|
244
200
|
async operationIds(path) {
|
|
245
201
|
return (await this.readOpenapi(path)).ops.map((op) => op.id);
|
|
246
202
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
return [...ids];
|
|
260
|
-
}
|
|
203
|
+
/*
|
|
204
|
+
* Deliberately no `serviceOperationIds` here. The living-plus-feature union is
|
|
205
|
+
* a RULE — every removal applied before any upsert — not a read, and a second
|
|
206
|
+
* copy of a rule is a second chance to spell it differently. This one did: it
|
|
207
|
+
* interleaved removals with upserts, so a relocated operation answered
|
|
208
|
+
* "defined" through `archive` (no context) and "gone" through `validate` and
|
|
209
|
+
* `status` (context), or the reverse, depending on the order the feature's
|
|
210
|
+
* YAML happened to spell the two slots in. `core/openapi.ts` owns the rule and
|
|
211
|
+
* threads a context through the reads underneath it, which is the only part
|
|
212
|
+
* this class was ever needed for.
|
|
213
|
+
*/
|
|
261
214
|
loadLikeC4(path) {
|
|
262
215
|
const k = key(path);
|
|
263
216
|
let pending = this.likec4.get(k);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fleet-context.js","sourceRoot":"","sources":["../../src/core/fleet-context.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Request-scoped read index for commands that walk a whole loam repository.\n *\n * The filesystem remains the source of truth. This class deliberately has no\n * singleton/global instance: a command creates one after loading loam.json and\n * drops it when that invocation finishes. Within that lifetime identical\n * reads share their in-flight Promise, which removes both duplicate I/O and\n * duplicate parsing without making a later CLI invocation observe stale data.\n *\n * Because every markdown artifact is read HERE, this is also where the two\n * failures a read must never swallow are answered: bytes that are not UTF-8\n * (which `readFile(path, \"utf8\")` silently turns into U+FFFD, so a UTF-16\n * spec.md parses as zero requirements and grades green), and git conflict\n * markers left in a living document (which parse as prose, and which the next\n * `loam archive` rewrites away). One place, so no reader can forget.\n */\nimport { isUtf8 } from \"node:buffer\";\nimport { readFile } from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport { loadFile, type LoadedDoc } from \"./likec4.js\";\nimport type { Finding } from \"./report.js\";\nimport {\n readOpenapi,\n type OpenapiDoc,\n type Operation,\n} from \"./openapi.js\";\nimport {\n featureSpecServices,\n listFeatures,\n listServices,\n type FeatureEntry,\n type ServiceEntry,\n} from \"./repo.js\";\nimport { featureSpecPaths, servicePaths } from \"./repo.js\";\nimport { parseRequirements, type Requirement } from \"./spec.js\";\n\nexport interface FleetContextStats {\n serviceEnumerations: number;\n featureEnumerations: number;\n featureServiceEnumerations: number;\n textReads: number;\n requirementParses: number;\n openapiParses: number;\n likec4Loads: number;\n}\n\nfunction key(path: string): string {\n return resolve(path);\n}\n\n/* ------------------------------------------------------------------ */\n/* What a read must never swallow */\n/* ------------------------------------------------------------------ */\n\n/**\n * A document loam was about to parse that is not UTF-8 text.\n *\n * Thrown rather than returned, and named as the file rather than as an empty\n * document, because every parser in the codebase reads \"no requirements, no\n * frontmatter\" as a legitimate state — a fresh baseline looks exactly like it.\n * A `spec.md` saved as UTF-16 (PowerShell's `>` writes UTF-16LE by default)\n * therefore validated green, with every requirement in it invisible.\n *\n * `path` is spelled the way Node spells it on an ErrnoException so that\n * validate's per-target guard names the file without knowing this type; the\n * write path refuses the same way for the same reason (staging.ts's\n * NotUtf8Error), and that message is about what a rewrite would destroy, which\n * is not what a reader needs to hear.\n */\nexport class NotUtf8DocumentError extends Error {\n override readonly name = \"NotUtf8DocumentError\";\n\n constructor(\n readonly path: string,\n reason: string,\n ) {\n super(\n `${path} is not a UTF-8 text document — ${reason}. Read as UTF-8 its requirements, frontmatter and headings ` +\n `all come out absent rather than wrong, so the file grades as an empty baseline instead of an unreadable one. ` +\n `Nothing here was checked. Re-save it as UTF-8 (PowerShell's \\`>\\` and \\`Out-File\\` write UTF-16 unless told ` +\n `otherwise: use \\`Out-File -Encoding utf8\\`), then re-run.`,\n );\n }\n}\n\n/**\n * Decode a document loam is about to parse, or refuse naming it.\n *\n * Two tests, because one is not enough. `isUtf8` catches the byte sequences\n * that cannot be UTF-8 at all — which is what a byte-order mark makes UTF-16\n * into, and PowerShell writes one. Without a BOM, UTF-16LE of ASCII is a\n * sequence of valid UTF-8 bytes: every other byte is NUL, which decodes to\n * U+0000 and nothing complains. That file parsed as zero requirements and no\n * frontmatter, and validated green. A NUL in a markdown document loam owns has\n * no legitimate spelling, so it is the second test.\n */\nexport function decodeDocument(bytes: Buffer, path: string): string {\n if (!isUtf8(bytes)) throw new NotUtf8DocumentError(path, \"its bytes are not a valid UTF-8 sequence\");\n const text = bytes.toString(\"utf8\");\n if (text.includes(\"\\u0000\")) {\n throw new NotUtf8DocumentError(\n path,\n \"it holds NUL characters, which no markdown document does — almost always UTF-16 saved without a byte-order mark\",\n );\n }\n return text;\n}\n\n/**\n * The three-way merge left its markers in the file — 1-based line numbers.\n *\n * The rule is `loam doctor`'s, verbatim, and it lives here now because doctor\n * checked exactly one file (`architecture/landscape.likec4`) from a command\n * that gates nothing. The same markers in a living `services/<svc>/spec.md`\n * parse as prose: `loam validate --all` reported `valid: true`, and the next\n * `loam archive` rewrote the requirements section and dropped the `=======`\n * line with it, turning a conflict anyone can see into a document nobody can\n * tell is wrong. For a shared docs repo where a fleet lands through PRs this is\n * the DEFAULT failure, not an edge.\n */\nconst CONFLICT_MARKERS = [\"<<<<<<<\", \"=======\", \">>>>>>>\"];\n\nexport function conflictMarkerLines(source: string): number[] {\n const out: number[] = [];\n source.split(/\\r?\\n/).forEach((line, i) => {\n if (CONFLICT_MARKERS.some((m) => line.startsWith(m))) out.push(i + 1);\n });\n return out;\n}\n\n/** One sentence for the breach, wherever it is found — doctor, validate, either axis. */\nfunction conflictMessage(label: string, lines: number[]): string {\n return (\n `${label} still contains git conflict markers (line${lines.length === 1 ? \"\" : \"s\"} ${lines.join(\", \")}) — ` +\n `both sides of somebody's merge are in the file, so nothing it says is anyone's text. ` +\n `Resolve the conflict before anything else: loam rewrites this document on archive, and a rewrite deletes ` +\n `whichever marker lines fall inside the section it owns — a visible conflict becomes a silently malformed file.`\n );\n}\n\n/**\n * The conflict-marker finding for a requirement document (living spec.md /\n * arch.spec.md, or a feature's delta of one). Null when the text is clean.\n *\n * An error because the document is not valid — nobody wrote what it now says —\n * and because the merge that would consume it is unsafe. It does not carry\n * `gates`: that field is coherence's (issue.ts), and the archive gate has to\n * ask this question itself.\n */\nexport function documentConflictFinding(label: string, subject: string, text: string): Finding | null {\n const lines = conflictMarkerLines(text);\n if (lines.length === 0) return null;\n return {\n severity: \"error\",\n code: \"spec.merge-conflict\",\n subject,\n message: conflictMessage(label, lines),\n };\n}\n\n/** The same breach in the fleet map, where it costs every cross-service check at once. */\nexport function landscapeConflictFinding(label: string, text: string): Finding | null {\n const lines = conflictMarkerLines(text);\n if (lines.length === 0) return null;\n return {\n severity: \"error\",\n code: \"landscape.merge-conflict\",\n message: conflictMessage(label, lines),\n };\n}\n\n/** One coherent filesystem snapshot for one command invocation. */\nexport class FleetContext {\n private readonly services = new Map<string, Promise<ServiceEntry[]>>();\n private readonly features = new Map<string, Promise<FeatureEntry[]>>();\n private readonly featureServices = new Map<string, Promise<string[]>>();\n private readonly texts = new Map<string, Promise<string>>();\n private readonly requirements = new Map<string, Promise<Requirement[]>>();\n private readonly openapis = new Map<string, Promise<OpenapiDoc>>();\n private readonly likec4 = new Map<string, Promise<LoadedDoc>>();\n\n private readonly counts: FleetContextStats = {\n serviceEnumerations: 0,\n featureEnumerations: 0,\n featureServiceEnumerations: 0,\n textReads: 0,\n requirementParses: 0,\n openapiParses: 0,\n likec4Loads: 0,\n };\n\n /** A copy suitable for diagnostics/tests; callers cannot mutate the counters. */\n stats(): Readonly<FleetContextStats> {\n return { ...this.counts };\n }\n\n listServices(docsDir: string): Promise<ServiceEntry[]> {\n const k = key(docsDir);\n let pending = this.services.get(k);\n if (pending === undefined) {\n this.counts.serviceEnumerations += 1;\n pending = listServices(docsDir);\n this.services.set(k, pending);\n }\n return pending;\n }\n\n listFeatures(docsDir: string, opts: { includeArchived?: boolean } = {}): Promise<FeatureEntry[]> {\n const k = `${key(docsDir)}\\0${opts.includeArchived === true ? \"all\" : \"active\"}`;\n let pending = this.features.get(k);\n if (pending === undefined) {\n this.counts.featureEnumerations += 1;\n pending = listFeatures(docsDir, opts).then((entries) => {\n // listFeatures has already enumerated every feature's specs/ directory;\n // seed the narrower lookup so validation/coherence do not read it again.\n for (const entry of entries) {\n const featureKey = key(entry.dir);\n if (!this.featureServices.has(featureKey)) {\n this.featureServices.set(featureKey, Promise.resolve(entry.services));\n }\n }\n return entries;\n });\n this.features.set(k, pending);\n }\n return pending;\n }\n\n featureSpecServices(featureDir: string): Promise<string[]> {\n const k = key(featureDir);\n let pending = this.featureServices.get(k);\n if (pending === undefined) {\n this.counts.featureServiceEnumerations += 1;\n pending = featureSpecServices(featureDir);\n this.featureServices.set(k, pending);\n }\n return pending;\n }\n\n /**\n * A markdown artifact as text. Bytes in, decoded once, and refused rather\n * than degraded when they are not UTF-8 — see NotUtf8DocumentError.\n */\n readText(path: string): Promise<string> {\n const k = key(path);\n let pending = this.texts.get(k);\n if (pending === undefined) {\n this.counts.textReads += 1;\n pending = readFile(path).then((bytes) => decodeDocument(bytes, path));\n this.texts.set(k, pending);\n }\n return pending;\n }\n\n /**\n * Git conflict markers in an already-read document, by line. Free for any\n * caller that was going to read the file anyway — which is every caller,\n * which is the point: the marker check used to cost a whole extra read of one\n * hand-picked file, so it was done for one hand-picked file.\n */\n async conflictMarkers(path: string): Promise<number[]> {\n return conflictMarkerLines(await this.readText(path));\n }\n\n readRequirements(path: string): Promise<Requirement[]> {\n const k = key(path);\n let pending = this.requirements.get(k);\n if (pending === undefined) {\n this.counts.requirementParses += 1;\n pending = this.readText(path).then(parseRequirements);\n this.requirements.set(k, pending);\n }\n return pending;\n }\n\n readOpenapi(path: string): Promise<OpenapiDoc> {\n const k = key(path);\n let pending = this.openapis.get(k);\n if (pending === undefined) {\n this.counts.openapiParses += 1;\n pending = readOpenapi(path);\n this.openapis.set(k, pending);\n }\n return pending;\n }\n\n async operations(path: string): Promise<Operation[]> {\n return (await this.readOpenapi(path)).ops;\n }\n\n async operationIds(path: string): Promise<string[]> {\n return (await this.readOpenapi(path)).ops.map((op) => op.id);\n }\n\n async serviceOperationIds(docsDir: string, service: string, featureDir?: string): Promise<string[]> {\n const ids = new Set(\n (await this.operations(servicePaths(docsDir, service).openapi))\n .filter((op) => !op.remove)\n .map((op) => op.id),\n );\n if (featureDir !== undefined) {\n for (const op of await this.operations(featureSpecPaths(featureDir, service).openapi)) {\n if (op.remove) ids.delete(op.id);\n else ids.add(op.id);\n }\n }\n return [...ids];\n }\n\n loadLikeC4(path: string): Promise<LoadedDoc> {\n const k = key(path);\n let pending = this.likec4.get(k);\n if (pending === undefined) {\n this.counts.likec4Loads += 1;\n pending = loadFile(path);\n this.likec4.set(k, pending);\n }\n return pending;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"fleet-context.js","sourceRoot":"","sources":["../../src/core/fleet-context.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Request-scoped read index for commands that walk a whole loam repository.\n *\n * The filesystem remains the source of truth. This class deliberately has no\n * singleton/global instance: a command creates one after loading loam.json and\n * drops it when that invocation finishes. Within that lifetime identical\n * reads share their in-flight Promise, which removes both duplicate I/O and\n * duplicate parsing without making a later CLI invocation observe stale data.\n *\n * Because every markdown artifact is read HERE, this is also where the two\n * failures a read must never swallow are answered: bytes that are not UTF-8\n * (which `readFile(path, \"utf8\")` silently turns into U+FFFD, so a UTF-16\n * spec.md parses as zero requirements and grades green), and git conflict\n * markers left in a living document (which parse as prose, and which the next\n * `loam archive` rewrites away). One place, so no reader can forget. The\n * decode itself lives in `document-bytes.ts`, a leaf: the parsers that read a\n * single file without an index owe the same rule, and must not have to import\n * this module — and the fleet it pulls in behind it — to obey it.\n */\nimport { readFile } from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport { decodeDocument } from \"./document-bytes.js\";\nimport { loadFile, type LoadedDoc } from \"./likec4.js\";\nimport type { Finding } from \"./report.js\";\nimport {\n readOpenapi,\n type OpenapiDoc,\n type Operation,\n} from \"./openapi.js\";\nimport {\n featureSpecServices,\n listFeatures,\n listServices,\n type FeatureEntry,\n type ServiceEntry,\n} from \"./repo.js\";\nimport { parseRequirements, type Requirement } from \"./spec.js\";\n\nexport interface FleetContextStats {\n serviceEnumerations: number;\n featureEnumerations: number;\n featureServiceEnumerations: number;\n textReads: number;\n requirementParses: number;\n openapiParses: number;\n likec4Loads: number;\n}\n\nfunction key(path: string): string {\n return resolve(path);\n}\n\n/* ------------------------------------------------------------------ */\n/* What a read must never swallow */\n/* ------------------------------------------------------------------ */\n\n/**\n * The three-way merge left its markers in the file — 1-based line numbers.\n *\n * The rule is `loam doctor`'s, verbatim, and it lives here now because doctor\n * checked exactly one file (`architecture/landscape.likec4`) from a command\n * that gates nothing. The same markers in a living `services/<svc>/spec.md`\n * parse as prose: `loam validate --all` reported `valid: true`, and the next\n * `loam archive` rewrote the requirements section and dropped the `=======`\n * line with it, turning a conflict anyone can see into a document nobody can\n * tell is wrong. For a shared docs repo where a fleet lands through PRs this is\n * the DEFAULT failure, not an edge.\n */\nconst CONFLICT_MARKERS = [\"<<<<<<<\", \"=======\", \">>>>>>>\"];\n\nexport function conflictMarkerLines(source: string): number[] {\n const out: number[] = [];\n source.split(/\\r?\\n/).forEach((line, i) => {\n if (CONFLICT_MARKERS.some((m) => line.startsWith(m))) out.push(i + 1);\n });\n return out;\n}\n\n/** One sentence for the breach, wherever it is found — doctor, validate, either axis. */\nfunction conflictMessage(label: string, lines: number[]): string {\n return (\n `${label} still contains git conflict markers (line${lines.length === 1 ? \"\" : \"s\"} ${lines.join(\", \")}) — ` +\n `both sides of somebody's merge are in the file, so nothing it says is anyone's text. ` +\n `Resolve the conflict before anything else: loam rewrites this document on archive, and a rewrite deletes ` +\n `whichever marker lines fall inside the section it owns — a visible conflict becomes a silently malformed file.`\n );\n}\n\n/**\n * The conflict-marker finding for a requirement document (living spec.md /\n * arch.spec.md, or a feature's delta of one). Null when the text is clean.\n *\n * An error because the document is not valid — nobody wrote what it now says —\n * and because the merge that would consume it is unsafe. It does not carry\n * `gates`: that field is coherence's (issue.ts), and the archive gate has to\n * ask this question itself.\n */\nexport function documentConflictFinding(label: string, subject: string, text: string): Finding | null {\n const lines = conflictMarkerLines(text);\n if (lines.length === 0) return null;\n return {\n severity: \"error\",\n code: \"spec.merge-conflict\",\n subject,\n message: conflictMessage(label, lines),\n };\n}\n\n/** The same breach in the fleet map, where it costs every cross-service check at once. */\nexport function landscapeConflictFinding(label: string, text: string): Finding | null {\n const lines = conflictMarkerLines(text);\n if (lines.length === 0) return null;\n return {\n severity: \"error\",\n code: \"landscape.merge-conflict\",\n message: conflictMessage(label, lines),\n };\n}\n\n/** One coherent filesystem snapshot for one command invocation. */\nexport class FleetContext {\n private readonly services = new Map<string, Promise<ServiceEntry[]>>();\n private readonly features = new Map<string, Promise<FeatureEntry[]>>();\n private readonly featureServices = new Map<string, Promise<string[]>>();\n private readonly texts = new Map<string, Promise<string>>();\n private readonly requirements = new Map<string, Promise<Requirement[]>>();\n private readonly openapis = new Map<string, Promise<OpenapiDoc>>();\n private readonly likec4 = new Map<string, Promise<LoadedDoc>>();\n\n private readonly counts: FleetContextStats = {\n serviceEnumerations: 0,\n featureEnumerations: 0,\n featureServiceEnumerations: 0,\n textReads: 0,\n requirementParses: 0,\n openapiParses: 0,\n likec4Loads: 0,\n };\n\n /** A copy suitable for diagnostics/tests; callers cannot mutate the counters. */\n stats(): Readonly<FleetContextStats> {\n return { ...this.counts };\n }\n\n listServices(docsDir: string): Promise<ServiceEntry[]> {\n const k = key(docsDir);\n let pending = this.services.get(k);\n if (pending === undefined) {\n this.counts.serviceEnumerations += 1;\n pending = listServices(docsDir);\n this.services.set(k, pending);\n }\n return pending;\n }\n\n listFeatures(docsDir: string, opts: { includeArchived?: boolean } = {}): Promise<FeatureEntry[]> {\n const k = `${key(docsDir)}\\0${opts.includeArchived === true ? \"all\" : \"active\"}`;\n let pending = this.features.get(k);\n if (pending === undefined) {\n this.counts.featureEnumerations += 1;\n pending = listFeatures(docsDir, opts).then((entries) => {\n // listFeatures has already enumerated every feature's specs/ directory;\n // seed the narrower lookup so validation/coherence do not read it again.\n for (const entry of entries) {\n const featureKey = key(entry.dir);\n if (!this.featureServices.has(featureKey)) {\n this.featureServices.set(featureKey, Promise.resolve(entry.services));\n }\n }\n return entries;\n });\n this.features.set(k, pending);\n }\n return pending;\n }\n\n featureSpecServices(featureDir: string): Promise<string[]> {\n const k = key(featureDir);\n let pending = this.featureServices.get(k);\n if (pending === undefined) {\n this.counts.featureServiceEnumerations += 1;\n pending = featureSpecServices(featureDir);\n this.featureServices.set(k, pending);\n }\n return pending;\n }\n\n /**\n * A markdown artifact as text. Bytes in, decoded once, and refused rather\n * than degraded when they are not UTF-8 — see NotUtf8DocumentError.\n */\n readText(path: string): Promise<string> {\n const k = key(path);\n let pending = this.texts.get(k);\n if (pending === undefined) {\n this.counts.textReads += 1;\n pending = readFile(path).then((bytes) => decodeDocument(bytes, path));\n this.texts.set(k, pending);\n }\n return pending;\n }\n\n /**\n * Git conflict markers in an already-read document, by line. Free for any\n * caller that was going to read the file anyway — which is every caller,\n * which is the point: the marker check used to cost a whole extra read of one\n * hand-picked file, so it was done for one hand-picked file.\n */\n async conflictMarkers(path: string): Promise<number[]> {\n return conflictMarkerLines(await this.readText(path));\n }\n\n readRequirements(path: string): Promise<Requirement[]> {\n const k = key(path);\n let pending = this.requirements.get(k);\n if (pending === undefined) {\n this.counts.requirementParses += 1;\n pending = this.readText(path).then(parseRequirements);\n this.requirements.set(k, pending);\n }\n return pending;\n }\n\n readOpenapi(path: string): Promise<OpenapiDoc> {\n const k = key(path);\n let pending = this.openapis.get(k);\n if (pending === undefined) {\n this.counts.openapiParses += 1;\n pending = readOpenapi(path);\n this.openapis.set(k, pending);\n }\n return pending;\n }\n\n async operations(path: string): Promise<Operation[]> {\n return (await this.readOpenapi(path)).ops;\n }\n\n async operationIds(path: string): Promise<string[]> {\n return (await this.readOpenapi(path)).ops.map((op) => op.id);\n }\n\n /*\n * Deliberately no `serviceOperationIds` here. The living-plus-feature union is\n * a RULE — every removal applied before any upsert — not a read, and a second\n * copy of a rule is a second chance to spell it differently. This one did: it\n * interleaved removals with upserts, so a relocated operation answered\n * \"defined\" through `archive` (no context) and \"gone\" through `validate` and\n * `status` (context), or the reverse, depending on the order the feature's\n * YAML happened to spell the two slots in. `core/openapi.ts` owns the rule and\n * threads a context through the reads underneath it, which is the only part\n * this class was ever needed for.\n */\n\n loadLikeC4(path: string): Promise<LoadedDoc> {\n const k = key(path);\n let pending = this.likec4.get(k);\n if (pending === undefined) {\n this.counts.likec4Loads += 1;\n pending = loadFile(path);\n this.likec4.set(k, pending);\n }\n return pending;\n }\n}\n"]}
|
package/dist/core/frontmatter.js
CHANGED
|
@@ -9,11 +9,7 @@
|
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { readFile } from "node:fs/promises";
|
|
11
11
|
import { isMap, parse, parseDocument } from "yaml";
|
|
12
|
-
|
|
13
|
-
// importing it back here closes a cycle through repo.ts, which is harmless for
|
|
14
|
-
// the same reason spec.ts↔gherkin.ts is: nothing is used at module scope, so no
|
|
15
|
-
// evaluation order can observe a half-built module.
|
|
16
|
-
import { decodeDocument, NotUtf8DocumentError } from "./fleet-context.js";
|
|
12
|
+
import { decodeDocument, NotUtf8DocumentError } from "./document-bytes.js";
|
|
17
13
|
/**
|
|
18
14
|
* Drop a leading byte-order mark.
|
|
19
15
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"frontmatter.js","sourceRoot":"","sources":["../../src/core/frontmatter.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * YAML frontmatter — the provenance of an artifact.\n *\n * `status` says whether a human has vouched for the document, `sources` says\n * which code it was written from. When the prose is written by an agent, these\n * are the only deterministic tie to reality: everything else loam checks is\n * internal consistency, which a fluent enough fiction satisfies perfectly.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport { isMap, parse, parseDocument } from \"yaml\";\n// The decode rule lives with the reads it was written for (fleet-context.ts);\n// importing it back here closes a cycle through repo.ts, which is harmless for\n// the same reason spec.ts↔gherkin.ts is: nothing is used at module scope, so no\n// evaluation order can observe a half-built module.\nimport { decodeDocument, NotUtf8DocumentError } from \"./fleet-context.js\";\n\nexport interface Frontmatter {\n /** True when a terminated `---` block was found, even if it was empty. */\n present: boolean;\n /**\n * True when the block was found but its YAML cannot be read as a mapping —\n * a parse error, or a scalar/sequence document. `data` is then {}, which is\n * NOT the same fact as an empty header: the fields are unreadable, not\n * absent, and a checker that grades them \"missing\" sends the author adding\n * lines to a block YAML refuses to parse. Validate turns this flag into\n * `frontmatter.malformed`; the writer (withFrontmatterFields) has its own\n * replace-don't-merge rule for the same state.\n */\n malformed: boolean;\n /**\n * Why the FILE could not be read as text at all, or absent when it could —\n * set only by `readFrontmatter`, since a caller that already holds a string\n * has nothing left to decode.\n *\n * A third state, not a shade of the other two: `present: false` here means\n * nobody could look, where everywhere else it means nobody wrote a header.\n * A checker must ask this first, or it reports \"owner/status/sources missing\"\n * over a file whose lines are right there — the same false cascade\n * `malformed` exists to stop, one layer lower down.\n */\n unreadable?: string;\n /** The parsed mapping, or {} when absent or malformed. */\n data: Record<string, unknown>;\n /** The document with the frontmatter removed. */\n body: string;\n}\n\n/** Where the header sits in the raw text. */\ninterface Bounds {\n /** First character of the YAML text. */\n start: number;\n /** One past its last character — the newline before the closing fence included. */\n end: number;\n /** First character of the body. */\n bodyStart: number;\n}\n\n/**\n * Drop a leading byte-order mark.\n *\n * With one in front, the opening `---` is no longer at position 0, so the whole\n * header reads as absent: a documented artifact silently becomes an\n * undocumented one — no owner, no status, no `sources` — and nothing says why.\n * The same blindness `spec.ts` had at `^##`.\n *\n * Only at position 0: elsewhere U+FEFF is a zero-width no-break space and is the\n * author's content. Callers strip before measuring, so the offsets `bounds`\n * returns index the same string the caller then slices.\n */\nfunction stripBom(text: string): string {\n return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n}\n\n/**\n * A fence is a LINE of exactly three dashes. Trailing whitespace is tolerated\n * (`\\s` also swallows the CR of a CRLF file), trailing text is not: `--- title`\n * is prose, `----` is a horizontal rule. Matching the substring `---` instead —\n * as this used to — made both of those fences, so a stray rule closed the\n * header early and a `--- note` closer silently ate its own trailing text.\n */\nconst FENCE_RE = /^---\\s*$/;\n\n/**\n * Locate the frontmatter block. The opener is the very first line, the closer\n * the first subsequent fence line — a later horizontal rule is body, not a\n * second fence. No closer means an unterminated opener: not frontmatter at all.\n *\n * The reader and the writer both go through this, so they can never disagree\n * about which bytes are the header — the writer's promise to leave the body\n * untouched depends on that being one decision, not two.\n */\nfunction bounds(md: string): Bounds | null {\n const firstNl = md.indexOf(\"\\n\");\n if (!FENCE_RE.test(firstNl === -1 ? md : md.slice(0, firstNl))) return null;\n // A lone `---` with nothing after it is an unterminated opener.\n if (firstNl === -1) return null;\n const start = firstNl + 1;\n for (let lineStart = start; ; ) {\n const nl = md.indexOf(\"\\n\", lineStart);\n const line = nl === -1 ? md.slice(lineStart) : md.slice(lineStart, nl);\n if (FENCE_RE.test(line)) {\n // An empty block (`---\\n---`) has no text between the fences: start === end.\n return { start, end: lineStart, bodyStart: nl === -1 ? md.length : nl + 1 };\n }\n if (nl === -1) return null;\n lineStart = nl + 1;\n }\n}\n\n/**\n * Split a markdown document into its frontmatter and body. A document with no\n * frontmatter is returned whole rather than truncated.\n */\nexport function parseFrontmatter(source: string): Frontmatter {\n const md = stripBom(source);\n const at = bounds(md);\n if (at === null) return { present: false, malformed: false, data: {}, body: md };\n\n const yamlText = md.slice(at.start, at.end);\n const body = md.slice(at.bodyStart).trimStart();\n\n let data: Record<string, unknown> = {};\n let malformed = false;\n try {\n const parsed: unknown = parse(yamlText);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n data = parsed as Record<string, unknown>;\n } else if (parsed !== null) {\n // A scalar or sequence header holds no fields to read — as unreadable as\n // broken YAML. null is the empty block (`---\\n---`): legal, and empty.\n malformed = true;\n }\n } catch {\n // A bad header must not make the document unreadable (the body is still the\n // body, and never crashes a command) — but the failure travels as a flag,\n // so the caller can say `frontmatter.malformed` instead of the false\n // cascade \"owner/status missing\" the silent {} used to produce.\n malformed = true;\n }\n return { present: true, malformed, data, body };\n}\n\n/** A field as text. Non-string scalars (a bare date, a number) are stringified. */\nexport function stringField(fm: Frontmatter, key: string): string | undefined {\n const v = fm.data[key];\n if (v === undefined || v === null) return undefined;\n if (typeof v === \"string\") return v;\n if (typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n if (v instanceof Date) return v.toISOString().slice(0, 10);\n return undefined;\n}\n\n/** A field as a list. A lone scalar counts as a list of one. */\nexport function listField(fm: Frontmatter, key: string): string[] {\n const v = fm.data[key];\n if (v === undefined || v === null) return [];\n if (Array.isArray(v)) return v.map((x) => String(x)).filter((s) => s.length > 0);\n const one = stringField(fm, key);\n return one === undefined ? [] : [one];\n}\n\n/**\n * Return `md` with these frontmatter fields set and everything below the closing\n * fence byte-identical.\n *\n * The header is edited through yaml's document model rather than rebuilt from\n * the parsed data: keys keep their order and their comments, fields nobody named\n * are left exactly as they were, and a value that would read back as something\n * else — a digest of all digits, say — gets quoted for us. Stamping a document\n * is not a licence to reformat it: the diff a reviewer sees should be the lines\n * that changed, and nothing else.\n *\n * A document with no frontmatter gets one, and the text that was there becomes\n * the body. A header that does not parse (or is not a mapping) is replaced\n * rather than merged into — it cannot be edited without guessing what it meant.\n */\nexport function withFrontmatterFields(source: string, fields: Record<string, string>): string {\n // Stamping a BOM-prefixed document drops the BOM. It sits above the header, so\n // the promise about the body still holds — and leaving it would re-break the\n // file for every parser anchored at position 0, including ours.\n const md = stripBom(source);\n const at = bounds(md);\n const parsed = parseDocument(at === null ? \"\" : md.slice(at.start, at.end));\n const editable = parsed.errors.length === 0 && (parsed.contents === null || isMap(parsed.contents));\n const doc = editable ? parsed : parseDocument(\"\");\n\n for (const [key, value] of Object.entries(fields)) doc.set(key, value);\n const yamlText = doc.toString();\n\n if (at === null) return `---\\n${yamlText}---\\n\\n${md}`;\n return md.slice(0, at.start) + yamlText + md.slice(at.end);\n}\n\n/**\n * The document's body, byte-exact: everything after the frontmatter block —\n * the closing `---` line and its newline belong to the HEADER, every byte\n * after them (blank lines included) is body. A document with no frontmatter is\n * all body. This is what `content_digest` hashes, and it deliberately does NOT\n * go through `parseFrontmatter` (whose `body` is trimmed for callers that want\n * text): the digest's writer and its checker must slice the same bytes, so\n * both go through here.\n */\nexport function rawBody(source: string): string {\n const md = stripBom(source);\n const at = bounds(md);\n return at === null ? md : md.slice(at.bodyStart);\n}\n\n/**\n * Read a markdown file's frontmatter; a missing file reads as absent.\n *\n * Bytes in, decoded through `decodeDocument`, because `readFile(path, \"utf8\")`\n * turns a UTF-16 spec.md into mojibake whose opening `---` is no longer at\n * position 0 — so the header reads as ABSENT, and `status`, `owner` and\n * `sources` all come back missing over a file that spells every one of them.\n * The same blindness `stripBom` exists to stop, one encoding wider.\n *\n * The failure travels as a FLAG rather than an exception, which is how every\n * reader here degrades (`readOpenapi`'s `unreadable`) and is load-bearing at\n * exactly this call: this is the read behind the fleet ENUMERATION\n * (repo.ts `listServices`), so throwing would turn one PowerShell-saved file\n * into a `loam list` that reports nothing about the other 119 services.\n * Parsers never diagnose; a checker grades the flag.\n */\nexport async function readFrontmatter(path: string): Promise<Frontmatter> {\n if (!existsSync(path)) return { present: false, malformed: false, data: {}, body: \"\" };\n let text: string;\n try {\n text = decodeDocument(await readFile(path), path);\n } catch (err) {\n if (!(err instanceof NotUtf8DocumentError)) throw err;\n return { present: false, malformed: false, unreadable: err.message, data: {}, body: \"\" };\n }\n return parseFrontmatter(text);\n}\n\n/** The statuses each kind of artifact may carry. */\nexport const SERVICE_STATUSES = [\"draft\", \"verified\"] as const;\nexport const FEATURE_STATUSES = [\"proposed\", \"in_progress\", \"built\", \"done\"] as const;\n"]}
|
|
1
|
+
{"version":3,"file":"frontmatter.js","sourceRoot":"","sources":["../../src/core/frontmatter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,MAAM,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AA2C3E;;;;;;;;;;;GAWG;AACH,SAAS,QAAQ,CAAC,IAAY;IAC5B,OAAO,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAC9D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,QAAQ,GAAG,UAAU,CAAC;AAE5B;;;;;;;;GAQG;AACH,SAAS,MAAM,CAAC,EAAU;IACxB,MAAM,OAAO,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACjC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IAC5E,gEAAgE;IAChE,IAAI,OAAO,KAAK,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IAChC,MAAM,KAAK,GAAG,OAAO,GAAG,CAAC,CAAC;IAC1B,KAAK,IAAI,SAAS,GAAG,KAAK,IAAM,CAAC;QAC/B,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;QACvC,MAAM,IAAI,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;QACvE,IAAI,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YACxB,6EAA6E;YAC7E,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,SAAS,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;QAC9E,CAAC;QACD,IAAI,EAAE,KAAK,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;QAC3B,SAAS,GAAG,EAAE,GAAG,CAAC,CAAC;IACrB,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAc;IAC7C,MAAM,EAAE,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC5B,MAAM,EAAE,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC;IACtB,IAAI,EAAE,KAAK,IAAI;QAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;IAEjF,MAAM,QAAQ,GAAG,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC;IAC5C,MAAM,IAAI,GAAG,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC,SAAS,EAAE,CAAC;IAEhD,IAAI,IAAI,GAA4B,EAAE,CAAC;IACvC,IAAI,SAAS,GAAG,KAAK,CAAC;IACtB,IAAI,CAAC;QACH,MAAM,MAAM,GAAY,KAAK,CAAC,QAAQ,CAAC,CAAC;QACxC,IAAI,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YACnE,IAAI,GAAG,MAAiC,CAAC;QAC3C,CAAC;aAAM,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;YAC3B,yEAAyE;YACzE,uEAAuE;YACvE,SAAS,GAAG,IAAI,CAAC;QACnB,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,4EAA4E;QAC5E,0EAA0E;QAC1E,qEAAqE;QACrE,gEAAgE;QAChE,SAAS,GAAG,IAAI,CAAC;IACnB,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAClD,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,WAAW,CAAC,EAAe,EAAE,GAAW;IACtD,MAAM,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACvB,IAAI,CAAC,KAAK,SAAS,IAAI,CAAC,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IACpD,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,CAAC,CAAC;IACpC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,OAAO,CAAC,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC;IACtE,IAAI,CAAC,YAAY,IAAI;QAAE,OAAO,CAAC,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAC3D,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,SAAS,CAAC,EAAe,EAAE,GAAW;IACpD,MAAM,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACvB,IAAI,CAAC,KAAK,SAAS,IAAI,CAAC,KAAK,IAAI;QAAE,OAAO,EAAE,CAAC;IAC7C,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IACjF,MAAM,GAAG,GAAG,WAAW,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC;IACjC,OAAO,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;AACxC,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,qBAAqB,CAAC,MAAc,EAAE,MAA8B;IAClF,+EAA+E;IAC/E,6EAA6E;IAC7E,gEAAgE;IAChE,MAAM,EAAE,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC5B,MAAM,EAAE,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC;IACtB,MAAM,MAAM,GAAG,aAAa,CAAC,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAC5E,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,KAAK,IAAI,IAAI,KAAK,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;IACpG,MAAM,GAAG,GAAG,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC;IAElD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;QAAE,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACvE,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,EAAE,CAAC;IAEhC,IAAI,EAAE,KAAK,IAAI;QAAE,OAAO,QAAQ,QAAQ,UAAU,EAAE,EAAE,CAAC;IACvD,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,QAAQ,GAAG,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;AAC7D,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,OAAO,CAAC,MAAc;IACpC,MAAM,EAAE,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC5B,MAAM,EAAE,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC;IACtB,OAAO,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC;AACnD,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAY;IAChD,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;IACvF,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,cAAc,CAAC,MAAM,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,CAAC;IACpD,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,CAAC,GAAG,YAAY,oBAAoB,CAAC;YAAE,MAAM,GAAG,CAAC;QACtD,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,CAAC,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;IAC3F,CAAC;IACD,OAAO,gBAAgB,CAAC,IAAI,CAAC,CAAC;AAChC,CAAC;AAED,oDAAoD;AACpD,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,UAAU,CAAU,CAAC;AAC/D,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,UAAU,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,CAAU,CAAC","sourcesContent":["/**\n * YAML frontmatter — the provenance of an artifact.\n *\n * `status` says whether a human has vouched for the document, `sources` says\n * which code it was written from. When the prose is written by an agent, these\n * are the only deterministic tie to reality: everything else loam checks is\n * internal consistency, which a fluent enough fiction satisfies perfectly.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport { isMap, parse, parseDocument } from \"yaml\";\nimport { decodeDocument, NotUtf8DocumentError } from \"./document-bytes.js\";\n\nexport interface Frontmatter {\n /** True when a terminated `---` block was found, even if it was empty. */\n present: boolean;\n /**\n * True when the block was found but its YAML cannot be read as a mapping —\n * a parse error, or a scalar/sequence document. `data` is then {}, which is\n * NOT the same fact as an empty header: the fields are unreadable, not\n * absent, and a checker that grades them \"missing\" sends the author adding\n * lines to a block YAML refuses to parse. Validate turns this flag into\n * `frontmatter.malformed`; the writer (withFrontmatterFields) has its own\n * replace-don't-merge rule for the same state.\n */\n malformed: boolean;\n /**\n * Why the FILE could not be read as text at all, or absent when it could —\n * set only by `readFrontmatter`, since a caller that already holds a string\n * has nothing left to decode.\n *\n * A third state, not a shade of the other two: `present: false` here means\n * nobody could look, where everywhere else it means nobody wrote a header.\n * A checker must ask this first, or it reports \"owner/status/sources missing\"\n * over a file whose lines are right there — the same false cascade\n * `malformed` exists to stop, one layer lower down.\n */\n unreadable?: string;\n /** The parsed mapping, or {} when absent or malformed. */\n data: Record<string, unknown>;\n /** The document with the frontmatter removed. */\n body: string;\n}\n\n/** Where the header sits in the raw text. */\ninterface Bounds {\n /** First character of the YAML text. */\n start: number;\n /** One past its last character — the newline before the closing fence included. */\n end: number;\n /** First character of the body. */\n bodyStart: number;\n}\n\n/**\n * Drop a leading byte-order mark.\n *\n * With one in front, the opening `---` is no longer at position 0, so the whole\n * header reads as absent: a documented artifact silently becomes an\n * undocumented one — no owner, no status, no `sources` — and nothing says why.\n * The same blindness `spec.ts` had at `^##`.\n *\n * Only at position 0: elsewhere U+FEFF is a zero-width no-break space and is the\n * author's content. Callers strip before measuring, so the offsets `bounds`\n * returns index the same string the caller then slices.\n */\nfunction stripBom(text: string): string {\n return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n}\n\n/**\n * A fence is a LINE of exactly three dashes. Trailing whitespace is tolerated\n * (`\\s` also swallows the CR of a CRLF file), trailing text is not: `--- title`\n * is prose, `----` is a horizontal rule. Matching the substring `---` instead —\n * as this used to — made both of those fences, so a stray rule closed the\n * header early and a `--- note` closer silently ate its own trailing text.\n */\nconst FENCE_RE = /^---\\s*$/;\n\n/**\n * Locate the frontmatter block. The opener is the very first line, the closer\n * the first subsequent fence line — a later horizontal rule is body, not a\n * second fence. No closer means an unterminated opener: not frontmatter at all.\n *\n * The reader and the writer both go through this, so they can never disagree\n * about which bytes are the header — the writer's promise to leave the body\n * untouched depends on that being one decision, not two.\n */\nfunction bounds(md: string): Bounds | null {\n const firstNl = md.indexOf(\"\\n\");\n if (!FENCE_RE.test(firstNl === -1 ? md : md.slice(0, firstNl))) return null;\n // A lone `---` with nothing after it is an unterminated opener.\n if (firstNl === -1) return null;\n const start = firstNl + 1;\n for (let lineStart = start; ; ) {\n const nl = md.indexOf(\"\\n\", lineStart);\n const line = nl === -1 ? md.slice(lineStart) : md.slice(lineStart, nl);\n if (FENCE_RE.test(line)) {\n // An empty block (`---\\n---`) has no text between the fences: start === end.\n return { start, end: lineStart, bodyStart: nl === -1 ? md.length : nl + 1 };\n }\n if (nl === -1) return null;\n lineStart = nl + 1;\n }\n}\n\n/**\n * Split a markdown document into its frontmatter and body. A document with no\n * frontmatter is returned whole rather than truncated.\n */\nexport function parseFrontmatter(source: string): Frontmatter {\n const md = stripBom(source);\n const at = bounds(md);\n if (at === null) return { present: false, malformed: false, data: {}, body: md };\n\n const yamlText = md.slice(at.start, at.end);\n const body = md.slice(at.bodyStart).trimStart();\n\n let data: Record<string, unknown> = {};\n let malformed = false;\n try {\n const parsed: unknown = parse(yamlText);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n data = parsed as Record<string, unknown>;\n } else if (parsed !== null) {\n // A scalar or sequence header holds no fields to read — as unreadable as\n // broken YAML. null is the empty block (`---\\n---`): legal, and empty.\n malformed = true;\n }\n } catch {\n // A bad header must not make the document unreadable (the body is still the\n // body, and never crashes a command) — but the failure travels as a flag,\n // so the caller can say `frontmatter.malformed` instead of the false\n // cascade \"owner/status missing\" the silent {} used to produce.\n malformed = true;\n }\n return { present: true, malformed, data, body };\n}\n\n/** A field as text. Non-string scalars (a bare date, a number) are stringified. */\nexport function stringField(fm: Frontmatter, key: string): string | undefined {\n const v = fm.data[key];\n if (v === undefined || v === null) return undefined;\n if (typeof v === \"string\") return v;\n if (typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n if (v instanceof Date) return v.toISOString().slice(0, 10);\n return undefined;\n}\n\n/** A field as a list. A lone scalar counts as a list of one. */\nexport function listField(fm: Frontmatter, key: string): string[] {\n const v = fm.data[key];\n if (v === undefined || v === null) return [];\n if (Array.isArray(v)) return v.map((x) => String(x)).filter((s) => s.length > 0);\n const one = stringField(fm, key);\n return one === undefined ? [] : [one];\n}\n\n/**\n * Return `md` with these frontmatter fields set and everything below the closing\n * fence byte-identical.\n *\n * The header is edited through yaml's document model rather than rebuilt from\n * the parsed data: keys keep their order and their comments, fields nobody named\n * are left exactly as they were, and a value that would read back as something\n * else — a digest of all digits, say — gets quoted for us. Stamping a document\n * is not a licence to reformat it: the diff a reviewer sees should be the lines\n * that changed, and nothing else.\n *\n * A document with no frontmatter gets one, and the text that was there becomes\n * the body. A header that does not parse (or is not a mapping) is replaced\n * rather than merged into — it cannot be edited without guessing what it meant.\n */\nexport function withFrontmatterFields(source: string, fields: Record<string, string>): string {\n // Stamping a BOM-prefixed document drops the BOM. It sits above the header, so\n // the promise about the body still holds — and leaving it would re-break the\n // file for every parser anchored at position 0, including ours.\n const md = stripBom(source);\n const at = bounds(md);\n const parsed = parseDocument(at === null ? \"\" : md.slice(at.start, at.end));\n const editable = parsed.errors.length === 0 && (parsed.contents === null || isMap(parsed.contents));\n const doc = editable ? parsed : parseDocument(\"\");\n\n for (const [key, value] of Object.entries(fields)) doc.set(key, value);\n const yamlText = doc.toString();\n\n if (at === null) return `---\\n${yamlText}---\\n\\n${md}`;\n return md.slice(0, at.start) + yamlText + md.slice(at.end);\n}\n\n/**\n * The document's body, byte-exact: everything after the frontmatter block —\n * the closing `---` line and its newline belong to the HEADER, every byte\n * after them (blank lines included) is body. A document with no frontmatter is\n * all body. This is what `content_digest` hashes, and it deliberately does NOT\n * go through `parseFrontmatter` (whose `body` is trimmed for callers that want\n * text): the digest's writer and its checker must slice the same bytes, so\n * both go through here.\n */\nexport function rawBody(source: string): string {\n const md = stripBom(source);\n const at = bounds(md);\n return at === null ? md : md.slice(at.bodyStart);\n}\n\n/**\n * Read a markdown file's frontmatter; a missing file reads as absent.\n *\n * Bytes in, decoded through `decodeDocument`, because `readFile(path, \"utf8\")`\n * turns a UTF-16 spec.md into mojibake whose opening `---` is no longer at\n * position 0 — so the header reads as ABSENT, and `status`, `owner` and\n * `sources` all come back missing over a file that spells every one of them.\n * The same blindness `stripBom` exists to stop, one encoding wider.\n *\n * The failure travels as a FLAG rather than an exception, which is how every\n * reader here degrades (`readOpenapi`'s `unreadable`) and is load-bearing at\n * exactly this call: this is the read behind the fleet ENUMERATION\n * (repo.ts `listServices`), so throwing would turn one PowerShell-saved file\n * into a `loam list` that reports nothing about the other 119 services.\n * Parsers never diagnose; a checker grades the flag.\n */\nexport async function readFrontmatter(path: string): Promise<Frontmatter> {\n if (!existsSync(path)) return { present: false, malformed: false, data: {}, body: \"\" };\n let text: string;\n try {\n text = decodeDocument(await readFile(path), path);\n } catch (err) {\n if (!(err instanceof NotUtf8DocumentError)) throw err;\n return { present: false, malformed: false, unreadable: err.message, data: {}, body: \"\" };\n }\n return parseFrontmatter(text);\n}\n\n/** The statuses each kind of artifact may carry. */\nexport const SERVICE_STATUSES = [\"draft\", \"verified\"] as const;\nexport const FEATURE_STATUSES = [\"proposed\", \"in_progress\", \"built\", \"done\"] as const;\n"]}
|