@ybotok/loam 0.0.0 → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/COMPARISON.md +63 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING-from-OpenSpec.md +202 -0
  5. package/README.md +241 -9
  6. package/SCHEMA.md +425 -0
  7. package/SECURITY.md +9 -0
  8. package/dist/cli.js +138 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/adopt.js +226 -0
  11. package/dist/commands/adopt.js.map +1 -0
  12. package/dist/commands/archive.js +1297 -0
  13. package/dist/commands/archive.js.map +1 -0
  14. package/dist/commands/delta.js +361 -0
  15. package/dist/commands/delta.js.map +1 -0
  16. package/dist/commands/dependencies.js +78 -0
  17. package/dist/commands/dependencies.js.map +1 -0
  18. package/dist/commands/doctor.js +85 -0
  19. package/dist/commands/doctor.js.map +1 -0
  20. package/dist/commands/gherkin.js +270 -0
  21. package/dist/commands/gherkin.js.map +1 -0
  22. package/dist/commands/init.js +250 -0
  23. package/dist/commands/init.js.map +1 -0
  24. package/dist/commands/list.js +361 -0
  25. package/dist/commands/list.js.map +1 -0
  26. package/dist/commands/migrate-openspec.js +933 -0
  27. package/dist/commands/migrate-openspec.js.map +1 -0
  28. package/dist/commands/new.js +412 -0
  29. package/dist/commands/new.js.map +1 -0
  30. package/dist/commands/rebase.js +358 -0
  31. package/dist/commands/rebase.js.map +1 -0
  32. package/dist/commands/show.js +325 -0
  33. package/dist/commands/show.js.map +1 -0
  34. package/dist/commands/status.js +270 -0
  35. package/dist/commands/status.js.map +1 -0
  36. package/dist/commands/unarchive.js +310 -0
  37. package/dist/commands/unarchive.js.map +1 -0
  38. package/dist/commands/validate.js +1609 -0
  39. package/dist/commands/validate.js.map +1 -0
  40. package/dist/commands/verify.js +759 -0
  41. package/dist/commands/verify.js.map +1 -0
  42. package/dist/commands/vouch.js +325 -0
  43. package/dist/commands/vouch.js.map +1 -0
  44. package/dist/core/agent.js +1823 -0
  45. package/dist/core/agent.js.map +1 -0
  46. package/dist/core/agents-stamp.js +68 -0
  47. package/dist/core/agents-stamp.js.map +1 -0
  48. package/dist/core/arch.js +104 -0
  49. package/dist/core/arch.js.map +1 -0
  50. package/dist/core/brief.js +733 -0
  51. package/dist/core/brief.js.map +1 -0
  52. package/dist/core/coherence.js +553 -0
  53. package/dist/core/coherence.js.map +1 -0
  54. package/dist/core/config.js +172 -0
  55. package/dist/core/config.js.map +1 -0
  56. package/dist/core/delta.js +457 -0
  57. package/dist/core/delta.js.map +1 -0
  58. package/dist/core/dependencies.js +352 -0
  59. package/dist/core/dependencies.js.map +1 -0
  60. package/dist/core/docs.js +143 -0
  61. package/dist/core/docs.js.map +1 -0
  62. package/dist/core/doctor.js +544 -0
  63. package/dist/core/doctor.js.map +1 -0
  64. package/dist/core/fleet-context.js +272 -0
  65. package/dist/core/fleet-context.js.map +1 -0
  66. package/dist/core/frontmatter.js +203 -0
  67. package/dist/core/frontmatter.js.map +1 -0
  68. package/dist/core/gherkin.js +521 -0
  69. package/dist/core/gherkin.js.map +1 -0
  70. package/dist/core/health.js +71 -0
  71. package/dist/core/health.js.map +1 -0
  72. package/dist/core/ids.js +93 -0
  73. package/dist/core/ids.js.map +1 -0
  74. package/dist/core/issue.js +9 -0
  75. package/dist/core/issue.js.map +1 -0
  76. package/dist/core/json.js +67 -0
  77. package/dist/core/json.js.map +1 -0
  78. package/dist/core/likec4.js +474 -0
  79. package/dist/core/likec4.js.map +1 -0
  80. package/dist/core/openapi-merge.js +519 -0
  81. package/dist/core/openapi-merge.js.map +1 -0
  82. package/dist/core/openapi.js +243 -0
  83. package/dist/core/openapi.js.map +1 -0
  84. package/dist/core/openspec-inventory.js +1403 -0
  85. package/dist/core/openspec-inventory.js.map +1 -0
  86. package/dist/core/path-safety.js +109 -0
  87. package/dist/core/path-safety.js.map +1 -0
  88. package/dist/core/provenance.js +695 -0
  89. package/dist/core/provenance.js.map +1 -0
  90. package/dist/core/repo.js +362 -0
  91. package/dist/core/repo.js.map +1 -0
  92. package/dist/core/report.js +38 -0
  93. package/dist/core/report.js.map +1 -0
  94. package/dist/core/results.js +201 -0
  95. package/dist/core/results.js.map +1 -0
  96. package/dist/core/spec.js +481 -0
  97. package/dist/core/spec.js.map +1 -0
  98. package/dist/core/staging.js +783 -0
  99. package/dist/core/staging.js.map +1 -0
  100. package/dist/core/status.js +1015 -0
  101. package/dist/core/status.js.map +1 -0
  102. package/dist/core/verify.js +793 -0
  103. package/dist/core/verify.js.map +1 -0
  104. package/dist/core/version.js +8 -0
  105. package/dist/core/version.js.map +1 -0
  106. package/package.json +63 -4
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Request-scoped read index for commands that walk a whole loam repository.
3
+ *
4
+ * The filesystem remains the source of truth. This class deliberately has no
5
+ * singleton/global instance: a command creates one after loading loam.json and
6
+ * drops it when that invocation finishes. Within that lifetime identical
7
+ * reads share their in-flight Promise, which removes both duplicate I/O and
8
+ * duplicate parsing without making a later CLI invocation observe stale data.
9
+ *
10
+ * Because every markdown artifact is read HERE, this is also where the two
11
+ * failures a read must never swallow are answered: bytes that are not UTF-8
12
+ * (which `readFile(path, "utf8")` silently turns into U+FFFD, so a UTF-16
13
+ * spec.md parses as zero requirements and grades green), and git conflict
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.
16
+ */
17
+ import { isUtf8 } from "node:buffer";
18
+ import { readFile } from "node:fs/promises";
19
+ import { resolve } from "node:path";
20
+ import { loadFile } from "./likec4.js";
21
+ import { readOpenapi, } from "./openapi.js";
22
+ import { featureSpecServices, listFeatures, listServices, } from "./repo.js";
23
+ import { featureSpecPaths, servicePaths } from "./repo.js";
24
+ import { parseRequirements } from "./spec.js";
25
+ function key(path) {
26
+ return resolve(path);
27
+ }
28
+ /* ------------------------------------------------------------------ */
29
+ /* What a read must never swallow */
30
+ /* ------------------------------------------------------------------ */
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
+ /**
78
+ * The three-way merge left its markers in the file — 1-based line numbers.
79
+ *
80
+ * The rule is `loam doctor`'s, verbatim, and it lives here now because doctor
81
+ * checked exactly one file (`architecture/landscape.likec4`) from a command
82
+ * that gates nothing. The same markers in a living `services/<svc>/spec.md`
83
+ * parse as prose: `loam validate --all` reported `valid: true`, and the next
84
+ * `loam archive` rewrote the requirements section and dropped the `=======`
85
+ * line with it, turning a conflict anyone can see into a document nobody can
86
+ * tell is wrong. For a shared docs repo where a fleet lands through PRs this is
87
+ * the DEFAULT failure, not an edge.
88
+ */
89
+ const CONFLICT_MARKERS = ["<<<<<<<", "=======", ">>>>>>>"];
90
+ export function conflictMarkerLines(source) {
91
+ const out = [];
92
+ source.split(/\r?\n/).forEach((line, i) => {
93
+ if (CONFLICT_MARKERS.some((m) => line.startsWith(m)))
94
+ out.push(i + 1);
95
+ });
96
+ return out;
97
+ }
98
+ /** One sentence for the breach, wherever it is found — doctor, validate, either axis. */
99
+ function conflictMessage(label, lines) {
100
+ return (`${label} still contains git conflict markers (line${lines.length === 1 ? "" : "s"} ${lines.join(", ")}) — ` +
101
+ `both sides of somebody's merge are in the file, so nothing it says is anyone's text. ` +
102
+ `Resolve the conflict before anything else: loam rewrites this document on archive, and a rewrite deletes ` +
103
+ `whichever marker lines fall inside the section it owns — a visible conflict becomes a silently malformed file.`);
104
+ }
105
+ /**
106
+ * The conflict-marker finding for a requirement document (living spec.md /
107
+ * arch.spec.md, or a feature's delta of one). Null when the text is clean.
108
+ *
109
+ * An error because the document is not valid — nobody wrote what it now says —
110
+ * and because the merge that would consume it is unsafe. It does not carry
111
+ * `gates`: that field is coherence's (issue.ts), and the archive gate has to
112
+ * ask this question itself.
113
+ */
114
+ export function documentConflictFinding(label, subject, text) {
115
+ const lines = conflictMarkerLines(text);
116
+ if (lines.length === 0)
117
+ return null;
118
+ return {
119
+ severity: "error",
120
+ code: "spec.merge-conflict",
121
+ subject,
122
+ message: conflictMessage(label, lines),
123
+ };
124
+ }
125
+ /** The same breach in the fleet map, where it costs every cross-service check at once. */
126
+ export function landscapeConflictFinding(label, text) {
127
+ const lines = conflictMarkerLines(text);
128
+ if (lines.length === 0)
129
+ return null;
130
+ return {
131
+ severity: "error",
132
+ code: "landscape.merge-conflict",
133
+ message: conflictMessage(label, lines),
134
+ };
135
+ }
136
+ /** One coherent filesystem snapshot for one command invocation. */
137
+ export class FleetContext {
138
+ services = new Map();
139
+ features = new Map();
140
+ featureServices = new Map();
141
+ texts = new Map();
142
+ requirements = new Map();
143
+ openapis = new Map();
144
+ likec4 = new Map();
145
+ counts = {
146
+ serviceEnumerations: 0,
147
+ featureEnumerations: 0,
148
+ featureServiceEnumerations: 0,
149
+ textReads: 0,
150
+ requirementParses: 0,
151
+ openapiParses: 0,
152
+ likec4Loads: 0,
153
+ };
154
+ /** A copy suitable for diagnostics/tests; callers cannot mutate the counters. */
155
+ stats() {
156
+ return { ...this.counts };
157
+ }
158
+ listServices(docsDir) {
159
+ const k = key(docsDir);
160
+ let pending = this.services.get(k);
161
+ if (pending === undefined) {
162
+ this.counts.serviceEnumerations += 1;
163
+ pending = listServices(docsDir);
164
+ this.services.set(k, pending);
165
+ }
166
+ return pending;
167
+ }
168
+ listFeatures(docsDir, opts = {}) {
169
+ const k = `${key(docsDir)}\0${opts.includeArchived === true ? "all" : "active"}`;
170
+ let pending = this.features.get(k);
171
+ if (pending === undefined) {
172
+ this.counts.featureEnumerations += 1;
173
+ pending = listFeatures(docsDir, opts).then((entries) => {
174
+ // listFeatures has already enumerated every feature's specs/ directory;
175
+ // seed the narrower lookup so validation/coherence do not read it again.
176
+ for (const entry of entries) {
177
+ const featureKey = key(entry.dir);
178
+ if (!this.featureServices.has(featureKey)) {
179
+ this.featureServices.set(featureKey, Promise.resolve(entry.services));
180
+ }
181
+ }
182
+ return entries;
183
+ });
184
+ this.features.set(k, pending);
185
+ }
186
+ return pending;
187
+ }
188
+ featureSpecServices(featureDir) {
189
+ const k = key(featureDir);
190
+ let pending = this.featureServices.get(k);
191
+ if (pending === undefined) {
192
+ this.counts.featureServiceEnumerations += 1;
193
+ pending = featureSpecServices(featureDir);
194
+ this.featureServices.set(k, pending);
195
+ }
196
+ return pending;
197
+ }
198
+ /**
199
+ * A markdown artifact as text. Bytes in, decoded once, and refused rather
200
+ * than degraded when they are not UTF-8 — see NotUtf8DocumentError.
201
+ */
202
+ readText(path) {
203
+ const k = key(path);
204
+ let pending = this.texts.get(k);
205
+ if (pending === undefined) {
206
+ this.counts.textReads += 1;
207
+ pending = readFile(path).then((bytes) => decodeDocument(bytes, path));
208
+ this.texts.set(k, pending);
209
+ }
210
+ return pending;
211
+ }
212
+ /**
213
+ * Git conflict markers in an already-read document, by line. Free for any
214
+ * caller that was going to read the file anyway — which is every caller,
215
+ * which is the point: the marker check used to cost a whole extra read of one
216
+ * hand-picked file, so it was done for one hand-picked file.
217
+ */
218
+ async conflictMarkers(path) {
219
+ return conflictMarkerLines(await this.readText(path));
220
+ }
221
+ readRequirements(path) {
222
+ const k = key(path);
223
+ let pending = this.requirements.get(k);
224
+ if (pending === undefined) {
225
+ this.counts.requirementParses += 1;
226
+ pending = this.readText(path).then(parseRequirements);
227
+ this.requirements.set(k, pending);
228
+ }
229
+ return pending;
230
+ }
231
+ readOpenapi(path) {
232
+ const k = key(path);
233
+ let pending = this.openapis.get(k);
234
+ if (pending === undefined) {
235
+ this.counts.openapiParses += 1;
236
+ pending = readOpenapi(path);
237
+ this.openapis.set(k, pending);
238
+ }
239
+ return pending;
240
+ }
241
+ async operations(path) {
242
+ return (await this.readOpenapi(path)).ops;
243
+ }
244
+ async operationIds(path) {
245
+ return (await this.readOpenapi(path)).ops.map((op) => op.id);
246
+ }
247
+ async serviceOperationIds(docsDir, service, featureDir) {
248
+ const ids = new Set((await this.operations(servicePaths(docsDir, service).openapi))
249
+ .filter((op) => !op.remove)
250
+ .map((op) => op.id));
251
+ if (featureDir !== undefined) {
252
+ for (const op of await this.operations(featureSpecPaths(featureDir, service).openapi)) {
253
+ if (op.remove)
254
+ ids.delete(op.id);
255
+ else
256
+ ids.add(op.id);
257
+ }
258
+ }
259
+ return [...ids];
260
+ }
261
+ loadLikeC4(path) {
262
+ const k = key(path);
263
+ let pending = this.likec4.get(k);
264
+ if (pending === undefined) {
265
+ this.counts.likec4Loads += 1;
266
+ pending = loadFile(path);
267
+ this.likec4.set(k, pending);
268
+ }
269
+ return pending;
270
+ }
271
+ }
272
+ //# sourceMappingURL=fleet-context.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,203 @@
1
+ /**
2
+ * YAML frontmatter — the provenance of an artifact.
3
+ *
4
+ * `status` says whether a human has vouched for the document, `sources` says
5
+ * which code it was written from. When the prose is written by an agent, these
6
+ * are the only deterministic tie to reality: everything else loam checks is
7
+ * internal consistency, which a fluent enough fiction satisfies perfectly.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { readFile } from "node:fs/promises";
11
+ import { isMap, parse, parseDocument } from "yaml";
12
+ // The decode rule lives with the reads it was written for (fleet-context.ts);
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";
17
+ /**
18
+ * Drop a leading byte-order mark.
19
+ *
20
+ * With one in front, the opening `---` is no longer at position 0, so the whole
21
+ * header reads as absent: a documented artifact silently becomes an
22
+ * undocumented one — no owner, no status, no `sources` — and nothing says why.
23
+ * The same blindness `spec.ts` had at `^##`.
24
+ *
25
+ * Only at position 0: elsewhere U+FEFF is a zero-width no-break space and is the
26
+ * author's content. Callers strip before measuring, so the offsets `bounds`
27
+ * returns index the same string the caller then slices.
28
+ */
29
+ function stripBom(text) {
30
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
31
+ }
32
+ /**
33
+ * A fence is a LINE of exactly three dashes. Trailing whitespace is tolerated
34
+ * (`\s` also swallows the CR of a CRLF file), trailing text is not: `--- title`
35
+ * is prose, `----` is a horizontal rule. Matching the substring `---` instead —
36
+ * as this used to — made both of those fences, so a stray rule closed the
37
+ * header early and a `--- note` closer silently ate its own trailing text.
38
+ */
39
+ const FENCE_RE = /^---\s*$/;
40
+ /**
41
+ * Locate the frontmatter block. The opener is the very first line, the closer
42
+ * the first subsequent fence line — a later horizontal rule is body, not a
43
+ * second fence. No closer means an unterminated opener: not frontmatter at all.
44
+ *
45
+ * The reader and the writer both go through this, so they can never disagree
46
+ * about which bytes are the header — the writer's promise to leave the body
47
+ * untouched depends on that being one decision, not two.
48
+ */
49
+ function bounds(md) {
50
+ const firstNl = md.indexOf("\n");
51
+ if (!FENCE_RE.test(firstNl === -1 ? md : md.slice(0, firstNl)))
52
+ return null;
53
+ // A lone `---` with nothing after it is an unterminated opener.
54
+ if (firstNl === -1)
55
+ return null;
56
+ const start = firstNl + 1;
57
+ for (let lineStart = start;;) {
58
+ const nl = md.indexOf("\n", lineStart);
59
+ const line = nl === -1 ? md.slice(lineStart) : md.slice(lineStart, nl);
60
+ if (FENCE_RE.test(line)) {
61
+ // An empty block (`---\n---`) has no text between the fences: start === end.
62
+ return { start, end: lineStart, bodyStart: nl === -1 ? md.length : nl + 1 };
63
+ }
64
+ if (nl === -1)
65
+ return null;
66
+ lineStart = nl + 1;
67
+ }
68
+ }
69
+ /**
70
+ * Split a markdown document into its frontmatter and body. A document with no
71
+ * frontmatter is returned whole rather than truncated.
72
+ */
73
+ export function parseFrontmatter(source) {
74
+ const md = stripBom(source);
75
+ const at = bounds(md);
76
+ if (at === null)
77
+ return { present: false, malformed: false, data: {}, body: md };
78
+ const yamlText = md.slice(at.start, at.end);
79
+ const body = md.slice(at.bodyStart).trimStart();
80
+ let data = {};
81
+ let malformed = false;
82
+ try {
83
+ const parsed = parse(yamlText);
84
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
85
+ data = parsed;
86
+ }
87
+ else if (parsed !== null) {
88
+ // A scalar or sequence header holds no fields to read — as unreadable as
89
+ // broken YAML. null is the empty block (`---\n---`): legal, and empty.
90
+ malformed = true;
91
+ }
92
+ }
93
+ catch {
94
+ // A bad header must not make the document unreadable (the body is still the
95
+ // body, and never crashes a command) — but the failure travels as a flag,
96
+ // so the caller can say `frontmatter.malformed` instead of the false
97
+ // cascade "owner/status missing" the silent {} used to produce.
98
+ malformed = true;
99
+ }
100
+ return { present: true, malformed, data, body };
101
+ }
102
+ /** A field as text. Non-string scalars (a bare date, a number) are stringified. */
103
+ export function stringField(fm, key) {
104
+ const v = fm.data[key];
105
+ if (v === undefined || v === null)
106
+ return undefined;
107
+ if (typeof v === "string")
108
+ return v;
109
+ if (typeof v === "number" || typeof v === "boolean")
110
+ return String(v);
111
+ if (v instanceof Date)
112
+ return v.toISOString().slice(0, 10);
113
+ return undefined;
114
+ }
115
+ /** A field as a list. A lone scalar counts as a list of one. */
116
+ export function listField(fm, key) {
117
+ const v = fm.data[key];
118
+ if (v === undefined || v === null)
119
+ return [];
120
+ if (Array.isArray(v))
121
+ return v.map((x) => String(x)).filter((s) => s.length > 0);
122
+ const one = stringField(fm, key);
123
+ return one === undefined ? [] : [one];
124
+ }
125
+ /**
126
+ * Return `md` with these frontmatter fields set and everything below the closing
127
+ * fence byte-identical.
128
+ *
129
+ * The header is edited through yaml's document model rather than rebuilt from
130
+ * the parsed data: keys keep their order and their comments, fields nobody named
131
+ * are left exactly as they were, and a value that would read back as something
132
+ * else — a digest of all digits, say — gets quoted for us. Stamping a document
133
+ * is not a licence to reformat it: the diff a reviewer sees should be the lines
134
+ * that changed, and nothing else.
135
+ *
136
+ * A document with no frontmatter gets one, and the text that was there becomes
137
+ * the body. A header that does not parse (or is not a mapping) is replaced
138
+ * rather than merged into — it cannot be edited without guessing what it meant.
139
+ */
140
+ export function withFrontmatterFields(source, fields) {
141
+ // Stamping a BOM-prefixed document drops the BOM. It sits above the header, so
142
+ // the promise about the body still holds — and leaving it would re-break the
143
+ // file for every parser anchored at position 0, including ours.
144
+ const md = stripBom(source);
145
+ const at = bounds(md);
146
+ const parsed = parseDocument(at === null ? "" : md.slice(at.start, at.end));
147
+ const editable = parsed.errors.length === 0 && (parsed.contents === null || isMap(parsed.contents));
148
+ const doc = editable ? parsed : parseDocument("");
149
+ for (const [key, value] of Object.entries(fields))
150
+ doc.set(key, value);
151
+ const yamlText = doc.toString();
152
+ if (at === null)
153
+ return `---\n${yamlText}---\n\n${md}`;
154
+ return md.slice(0, at.start) + yamlText + md.slice(at.end);
155
+ }
156
+ /**
157
+ * The document's body, byte-exact: everything after the frontmatter block —
158
+ * the closing `---` line and its newline belong to the HEADER, every byte
159
+ * after them (blank lines included) is body. A document with no frontmatter is
160
+ * all body. This is what `content_digest` hashes, and it deliberately does NOT
161
+ * go through `parseFrontmatter` (whose `body` is trimmed for callers that want
162
+ * text): the digest's writer and its checker must slice the same bytes, so
163
+ * both go through here.
164
+ */
165
+ export function rawBody(source) {
166
+ const md = stripBom(source);
167
+ const at = bounds(md);
168
+ return at === null ? md : md.slice(at.bodyStart);
169
+ }
170
+ /**
171
+ * Read a markdown file's frontmatter; a missing file reads as absent.
172
+ *
173
+ * Bytes in, decoded through `decodeDocument`, because `readFile(path, "utf8")`
174
+ * turns a UTF-16 spec.md into mojibake whose opening `---` is no longer at
175
+ * position 0 — so the header reads as ABSENT, and `status`, `owner` and
176
+ * `sources` all come back missing over a file that spells every one of them.
177
+ * The same blindness `stripBom` exists to stop, one encoding wider.
178
+ *
179
+ * The failure travels as a FLAG rather than an exception, which is how every
180
+ * reader here degrades (`readOpenapi`'s `unreadable`) and is load-bearing at
181
+ * exactly this call: this is the read behind the fleet ENUMERATION
182
+ * (repo.ts `listServices`), so throwing would turn one PowerShell-saved file
183
+ * into a `loam list` that reports nothing about the other 119 services.
184
+ * Parsers never diagnose; a checker grades the flag.
185
+ */
186
+ export async function readFrontmatter(path) {
187
+ if (!existsSync(path))
188
+ return { present: false, malformed: false, data: {}, body: "" };
189
+ let text;
190
+ try {
191
+ text = decodeDocument(await readFile(path), path);
192
+ }
193
+ catch (err) {
194
+ if (!(err instanceof NotUtf8DocumentError))
195
+ throw err;
196
+ return { present: false, malformed: false, unreadable: err.message, data: {}, body: "" };
197
+ }
198
+ return parseFrontmatter(text);
199
+ }
200
+ /** The statuses each kind of artifact may carry. */
201
+ export const SERVICE_STATUSES = ["draft", "verified"];
202
+ export const FEATURE_STATUSES = ["proposed", "in_progress", "built", "done"];
203
+ //# sourceMappingURL=frontmatter.js.map
@@ -0,0 +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"]}