@ybotok/loam 0.0.0 → 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.
Files changed (126) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/COMPARISON.md +63 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING-from-OpenSpec.md +202 -0
  5. package/README.md +246 -9
  6. package/SCHEMA.md +425 -0
  7. package/SECURITY.md +9 -0
  8. package/dist/cli.js +142 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/adopt.js +240 -0
  11. package/dist/commands/adopt.js.map +1 -0
  12. package/dist/commands/archive.js +1313 -0
  13. package/dist/commands/archive.js.map +1 -0
  14. package/dist/commands/delta.js +388 -0
  15. package/dist/commands/delta.js.map +1 -0
  16. package/dist/commands/dependencies.js +83 -0
  17. package/dist/commands/dependencies.js.map +1 -0
  18. package/dist/commands/docs-repo-gate.js +104 -0
  19. package/dist/commands/docs-repo-gate.js.map +1 -0
  20. package/dist/commands/doctor.js +85 -0
  21. package/dist/commands/doctor.js.map +1 -0
  22. package/dist/commands/explore.js +195 -0
  23. package/dist/commands/explore.js.map +1 -0
  24. package/dist/commands/format.js +17 -0
  25. package/dist/commands/format.js.map +1 -0
  26. package/dist/commands/gherkin.js +309 -0
  27. package/dist/commands/gherkin.js.map +1 -0
  28. package/dist/commands/init.js +250 -0
  29. package/dist/commands/init.js.map +1 -0
  30. package/dist/commands/instructions.js +70 -0
  31. package/dist/commands/instructions.js.map +1 -0
  32. package/dist/commands/list.js +291 -0
  33. package/dist/commands/list.js.map +1 -0
  34. package/dist/commands/migrate-openspec.js +950 -0
  35. package/dist/commands/migrate-openspec.js.map +1 -0
  36. package/dist/commands/new.js +413 -0
  37. package/dist/commands/new.js.map +1 -0
  38. package/dist/commands/rebase.js +356 -0
  39. package/dist/commands/rebase.js.map +1 -0
  40. package/dist/commands/show.js +320 -0
  41. package/dist/commands/show.js.map +1 -0
  42. package/dist/commands/status.js +254 -0
  43. package/dist/commands/status.js.map +1 -0
  44. package/dist/commands/unarchive.js +317 -0
  45. package/dist/commands/unarchive.js.map +1 -0
  46. package/dist/commands/validate.js +1591 -0
  47. package/dist/commands/validate.js.map +1 -0
  48. package/dist/commands/verify.js +775 -0
  49. package/dist/commands/verify.js.map +1 -0
  50. package/dist/commands/vouch.js +343 -0
  51. package/dist/commands/vouch.js.map +1 -0
  52. package/dist/core/agent.js +2065 -0
  53. package/dist/core/agent.js.map +1 -0
  54. package/dist/core/agents-stamp.js +68 -0
  55. package/dist/core/agents-stamp.js.map +1 -0
  56. package/dist/core/arch.js +130 -0
  57. package/dist/core/arch.js.map +1 -0
  58. package/dist/core/brief.js +733 -0
  59. package/dist/core/brief.js.map +1 -0
  60. package/dist/core/coherence.js +577 -0
  61. package/dist/core/coherence.js.map +1 -0
  62. package/dist/core/concurrency.js +51 -0
  63. package/dist/core/concurrency.js.map +1 -0
  64. package/dist/core/config.js +180 -0
  65. package/dist/core/config.js.map +1 -0
  66. package/dist/core/delta.js +476 -0
  67. package/dist/core/delta.js.map +1 -0
  68. package/dist/core/dependencies.js +356 -0
  69. package/dist/core/dependencies.js.map +1 -0
  70. package/dist/core/docs.js +144 -0
  71. package/dist/core/docs.js.map +1 -0
  72. package/dist/core/doctor.js +559 -0
  73. package/dist/core/doctor.js.map +1 -0
  74. package/dist/core/document-bytes.js +59 -0
  75. package/dist/core/document-bytes.js.map +1 -0
  76. package/dist/core/explore.js +225 -0
  77. package/dist/core/explore.js.map +1 -0
  78. package/dist/core/fleet-context.js +225 -0
  79. package/dist/core/fleet-context.js.map +1 -0
  80. package/dist/core/frontmatter.js +199 -0
  81. package/dist/core/frontmatter.js.map +1 -0
  82. package/dist/core/gherkin.js +463 -0
  83. package/dist/core/gherkin.js.map +1 -0
  84. package/dist/core/health.js +67 -0
  85. package/dist/core/health.js.map +1 -0
  86. package/dist/core/ids.js +122 -0
  87. package/dist/core/ids.js.map +1 -0
  88. package/dist/core/issue.js +9 -0
  89. package/dist/core/issue.js.map +1 -0
  90. package/dist/core/json.js +74 -0
  91. package/dist/core/json.js.map +1 -0
  92. package/dist/core/likec4.js +479 -0
  93. package/dist/core/likec4.js.map +1 -0
  94. package/dist/core/maturity.js +66 -0
  95. package/dist/core/maturity.js.map +1 -0
  96. package/dist/core/openapi-merge.js +552 -0
  97. package/dist/core/openapi-merge.js.map +1 -0
  98. package/dist/core/openapi.js +255 -0
  99. package/dist/core/openapi.js.map +1 -0
  100. package/dist/core/openspec-inventory.js +1440 -0
  101. package/dist/core/openspec-inventory.js.map +1 -0
  102. package/dist/core/path-safety.js +168 -0
  103. package/dist/core/path-safety.js.map +1 -0
  104. package/dist/core/provenance.js +720 -0
  105. package/dist/core/provenance.js.map +1 -0
  106. package/dist/core/records.js +33 -0
  107. package/dist/core/records.js.map +1 -0
  108. package/dist/core/repo.js +436 -0
  109. package/dist/core/repo.js.map +1 -0
  110. package/dist/core/report.js +47 -0
  111. package/dist/core/report.js.map +1 -0
  112. package/dist/core/results.js +199 -0
  113. package/dist/core/results.js.map +1 -0
  114. package/dist/core/spec.js +481 -0
  115. package/dist/core/spec.js.map +1 -0
  116. package/dist/core/staging.js +890 -0
  117. package/dist/core/staging.js.map +1 -0
  118. package/dist/core/status.js +1104 -0
  119. package/dist/core/status.js.map +1 -0
  120. package/dist/core/steps.js +72 -0
  121. package/dist/core/steps.js.map +1 -0
  122. package/dist/core/verify.js +807 -0
  123. package/dist/core/verify.js.map +1 -0
  124. package/dist/core/version.js +24 -0
  125. package/dist/core/version.js.map +1 -0
  126. package/package.json +63 -4
@@ -0,0 +1,463 @@
1
+ /**
2
+ * The Gherkin emission: spec scenarios become real `.feature` files.
3
+ *
4
+ * Scenarios have been "the source for tests" since SCHEMA.md's first draft, but
5
+ * until now they only left the docs repo as projected markdown — prose a coding
6
+ * agent could paraphrase into something easier to pass. The emission closes the
7
+ * gap mechanically: one `.feature` file per requirement, steps taken from the
8
+ * scenario's own bullet lines, and a digest TAG (`@loam-digest-<16hex>`) on
9
+ * every scenario using the SAME body-hash recipe `loam verify` folds into its
10
+ * claim ids (verify.ts's scenarioBodyHash — salted by the owning service and
11
+ * by the spec axis, so a stamp belongs to one suite and one axis) — so the
12
+ * test skeleton, the claim and the spec can never quietly disagree about what
13
+ * a scenario says, and no two repositories can answer each other's. A tag
14
+ * rather than a comment because tags are what cucumber's JSON report carries
15
+ * per scenario: the stamp rides through the runner untouched, which is what
16
+ * lets `loam verify --results` match a report scenario back to its claim.
17
+ *
18
+ * The files land in the SERVICE repo — the docs repo states the questions, the
19
+ * tests live with the code — under `<gherkinDir>/loam/`. That `loam/`
20
+ * subdirectory is loam's own derived space: regeneration rewrites its scope and
21
+ * removes its orphans, and loam never writes or deletes a byte outside it.
22
+ * Everything here is deterministic on purpose: the same specs yield the same
23
+ * bytes, which is what lets `loam validate` grade staleness by digest instead
24
+ * of by guesswork.
25
+ */
26
+ import { existsSync, statSync } from "node:fs";
27
+ import { readdir, readFile, realpath } from "node:fs/promises";
28
+ import { join, relative, sep } from "node:path";
29
+ import { parseRequirements } from "./spec.js";
30
+ import { scenarioGherkin } from "./steps.js";
31
+ import { listFeatures, servicePaths, SPEC_AXES } from "./repo.js";
32
+ import { scenarioBodyHash } from "./verify.js";
33
+ import { resolveInside, UnsafePathError } from "./path-safety.js";
34
+ /** Where Gherkin lives in a service repo when loam.json does not say. */
35
+ const DEFAULT_GHERKIN_DIR = "features";
36
+ /** The subdirectory loam owns outright. Everything loam writes stays inside it. */
37
+ const GHERKIN_SUBDIR = "loam";
38
+ /** How much of the scenario body hash goes into a `@loam-digest-…` tag. */
39
+ export const GHERKIN_DIGEST_LENGTH = 16;
40
+ /** The one directory `loam gherkin` may touch: `<repo>/<gherkinDir>/loam/`. */
41
+ export function gherkinRoot(repoDir, gherkinDir) {
42
+ return resolveInside(repoDir, join(gherkinDir ?? DEFAULT_GHERKIN_DIR, GHERKIN_SUBDIR), "gherkin output directory");
43
+ }
44
+ /**
45
+ * The digest stamped onto every generated scenario: verify's scenario body
46
+ * hash, at the sources-digest length. Content identity WITHIN one service and
47
+ * one axis, not provenance — two identically-worded scenarios of one service's
48
+ * one axis share a digest, and that is the point: the stamp answers "does this
49
+ * suite still contain a test for THESE words".
50
+ *
51
+ * Both salts ride in the hash (see `scenarioBodyHash`), so the namespaces stay
52
+ * separate all the way into a cucumber report: a business run can never answer
53
+ * for an arch scenario, and one service's green run can never answer another
54
+ * service's claim just because the two specs spell the sentence the same way.
55
+ * `service` is required and never defaulted — a stamp that forgot it would be
56
+ * a tag no claim matches, and the fleet bug it exists to close would come back
57
+ * silently.
58
+ */
59
+ export function scenarioDigest(service, lines, axis = "business") {
60
+ return scenarioBodyHash(service, lines, axis).slice(0, GHERKIN_DIGEST_LENGTH);
61
+ }
62
+ /** The digest as the tag on the line above each `Scenario:`. */
63
+ export function scenarioDigestTag(digest) {
64
+ return `@loam-digest-${digest}`;
65
+ }
66
+ /**
67
+ * A tag token carrying a scenario digest — in a generated file, or in a
68
+ * cucumber JSON report. The leading `@` is optional because some runners
69
+ * (behave) strip it from the tags they report.
70
+ */
71
+ export const DIGEST_TAG_RE = /^@?loam-digest-([0-9a-f]{16})$/;
72
+ /**
73
+ * Line 1 of every generated file — the AGENTS.md stamp pattern, in Gherkin
74
+ * comment syntax. The version records which loam wrote the file; the rest is
75
+ * the ownership rule in the reader's face, because `loam/` files are rewritten
76
+ * wholesale on regeneration and a hand edit would be silently destroyed.
77
+ */
78
+ export function gherkinStampLine(version) {
79
+ return `# generated by loam v${version} — \`loam gherkin\`; do not hand-edit, regenerate instead`;
80
+ }
81
+ const STAMP_RE = /^#\s*generated by loam v(\S+)/;
82
+ /* ------------------------------------------------------------------ */
83
+ /* Emission */
84
+ /* ------------------------------------------------------------------ */
85
+ /** The axis label the machine contract speaks. */
86
+ export function axisLabel(axis) {
87
+ return axis.key === "archSpec" ? "arch" : "business";
88
+ }
89
+ /**
90
+ * A requirement name as a file name: lowercase, every non-letter/digit run one
91
+ * hyphen, edges trimmed.
92
+ *
93
+ * The keep-set is `\p{L}\p{N}`, not `[a-z0-9]`, because an ASCII-only slug is
94
+ * not a slug of a non-Latin fleet — it is the empty string. Every requirement
95
+ * written in Chinese, Japanese, Korean, Greek, Cyrillic or Hebrew slugged to
96
+ * nothing, fell back to `requirement`, and the whole service's suite came out
97
+ * as `requirement.feature`, `requirement-2.feature`, … : file names that name
98
+ * nothing, in the one place the mapping is supposed to be legible. So letters
99
+ * of every script survive as themselves.
100
+ *
101
+ * Accents are folded rather than kept, and that is the reason for NFKD first:
102
+ * decomposing splits `é` into `e` + a combining acute, and dropping the
103
+ * combining marks (`\p{M}`) leaves `e`. Without the fold `émojis` and `emojis`
104
+ * would be two files that look identical in a directory listing — and on a
105
+ * filesystem that normalises names (APFS, and HFS+ before it) they would be one
106
+ * file, silently, with the second emission overwriting the first.
107
+ *
108
+ * A name this still leaves empty (all punctuation or emoji) falls back to
109
+ * "requirement" and lets the collision counter number it. Runs collapse to a
110
+ * SINGLE hyphen, which is what keeps the `arch--` prefix an axis marker no
111
+ * business slug can spell.
112
+ */
113
+ export function slugOf(name) {
114
+ const s = name
115
+ .normalize("NFKD")
116
+ .replace(/\p{M}+/gu, "")
117
+ .toLowerCase()
118
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
119
+ .replace(/^-+|-+$/g, "");
120
+ return s.length > 0 ? s : "requirement";
121
+ }
122
+ /**
123
+ * The emission plan for one scope: one `.feature` per requirement.
124
+ *
125
+ * File naming is the mapping's contract, so it is spelled here once:
126
+ * `<slug>.feature` for the business axis, `arch--<slug>.feature` for the
127
+ * architecture axis — flat and diffable, and collision-proof across axes
128
+ * because slugs collapse punctuation runs to a single hyphen, so no business
129
+ * slug can ever begin `arch--`. Two requirements slugging identically are
130
+ * disambiguated in document order: the first keeps the bare slug, later ones
131
+ * take `-2`, `-3`, … (counting up past any name that already owns the suffix).
132
+ *
133
+ * Tags ride the `Feature:` line and are inherited by every scenario in the
134
+ * file: `@<FEAT>` in feature mode (living emissions carry no feature tag),
135
+ * `@architecture` on the arch axis.
136
+ *
137
+ * `opts.service` is the suite's owner and salts every digest stamp — the same
138
+ * service `loam verify` files the matching claims under. Required, not
139
+ * defaulted: an emission that guessed it would stamp tags no claim matches.
140
+ */
141
+ export function planEmission(byAxis, opts) {
142
+ const used = new Set();
143
+ const out = [];
144
+ for (const { axis, reqs } of byAxis) {
145
+ for (const r of reqs) {
146
+ const base = (axis.key === "archSpec" ? "arch--" : "") + slugOf(r.name);
147
+ let fileName = `${base}.feature`;
148
+ for (let n = 2; used.has(fileName); n += 1)
149
+ fileName = `${base}-${n}.feature`;
150
+ used.add(fileName);
151
+ const tags = [
152
+ ...(opts.featureTag === undefined ? [] : [`@${opts.featureTag}`]),
153
+ ...(axis.key === "archSpec" ? ["@architecture"] : []),
154
+ ];
155
+ const { content, digests, stepless } = renderFeature(r, tags, opts.version, opts.service, axisLabel(axis));
156
+ out.push({ fileName, axis, requirement: r, digests, stepless, content });
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+ /**
162
+ * One requirement as one `.feature` file: `Feature:` is the requirement name,
163
+ * its body text (Operations:/Covers: lines included — they are part of what
164
+ * the requirement says) becomes the feature description, and each scenario
165
+ * renders under its `@loam-digest-…` tag — the tag line sits directly above
166
+ * the `Scenario:` keyword, where Gherkin puts scenario-level tags, so the
167
+ * runner attaches it to the scenario and carries it into the JSON report.
168
+ * Deterministic to the byte.
169
+ *
170
+ * `service` is the owner of the suite being written, and it salts every digest
171
+ * — the same service `loam verify` files the matching claims under. The two
172
+ * have to be the same string or the stamps this writes match no claim at all.
173
+ */
174
+ export function renderFeature(r, tags, version, service, axis = "business") {
175
+ const lines = [gherkinStampLine(version)];
176
+ if (tags.length > 0)
177
+ lines.push(tags.join(" "));
178
+ lines.push(`Feature: ${r.name}`);
179
+ const text = r.text.join("\n").trim();
180
+ if (text.length > 0) {
181
+ lines.push("");
182
+ for (const l of text.split("\n"))
183
+ lines.push(l.length > 0 ? ` ${l}` : "");
184
+ }
185
+ const digests = [];
186
+ const stepless = [];
187
+ for (const s of r.scenarios) {
188
+ const digest = scenarioDigest(service, s.lines, axis);
189
+ digests.push(digest);
190
+ lines.push("", ` ${scenarioDigestTag(digest)}`, ` Scenario: ${s.name}`);
191
+ const { description, steps } = scenarioGherkin(s.lines);
192
+ if (steps.length === 0)
193
+ stepless.push(s.name);
194
+ for (const d of description)
195
+ lines.push(` ${d}`);
196
+ for (const st of steps)
197
+ lines.push(` ${st}`);
198
+ }
199
+ return { content: lines.join("\n") + "\n", digests, stepless };
200
+ }
201
+ /**
202
+ * Parse a `.feature` file for its loam stamps. Null when line 1 is not the
203
+ * stamp — an unstamped file is not loam's writing, and no staleness judgment
204
+ * may be built on it. Tag lines above `Feature:` are the file's tags; a tag
205
+ * line after it that carries a `@loam-digest-…` token binds that digest to the
206
+ * immediately following `Scenario:` line (tag lines stack, as Gherkin's do,
207
+ * without breaking the bond); any other non-blank line between them breaks it.
208
+ */
209
+ export function parseStampedFeature(text) {
210
+ const lines = text.split(/\r?\n/);
211
+ const m = STAMP_RE.exec(lines[0] ?? "");
212
+ if (!m)
213
+ return null;
214
+ const tags = [];
215
+ let featureName = null;
216
+ const scenarios = [];
217
+ let pending = null;
218
+ for (const line of lines.slice(1)) {
219
+ if (/^\s*@/.test(line)) {
220
+ for (const t of line.trim().split(/\s+/)) {
221
+ if (featureName === null) {
222
+ if (t.startsWith("@"))
223
+ tags.push(t.slice(1));
224
+ continue;
225
+ }
226
+ const d = DIGEST_TAG_RE.exec(t);
227
+ if (d)
228
+ pending = d[1];
229
+ }
230
+ continue;
231
+ }
232
+ const f = /^\s*Feature:\s*(.*)$/.exec(line);
233
+ if (f && featureName === null) {
234
+ featureName = f[1].trim();
235
+ continue;
236
+ }
237
+ const s = /^\s*Scenario(?: Outline)?:\s*(.*)$/.exec(line);
238
+ if (s) {
239
+ if (pending !== null)
240
+ scenarios.push({ name: s[1].trim(), digest: pending });
241
+ pending = null;
242
+ continue;
243
+ }
244
+ if (pending !== null && line.trim().length > 0)
245
+ pending = null;
246
+ }
247
+ return { version: m[1], tags, featureName, scenarios };
248
+ }
249
+ /* ------------------------------------------------------------------ */
250
+ /* Staleness — what `loam validate` reports about a generated suite */
251
+ /* ------------------------------------------------------------------ */
252
+ /**
253
+ * The gherkin freshness chain, service-repo-scoped like `sources.*`: it needs
254
+ * the repo (the suite lives there), and it stays quiet until
255
+ * `<gherkinDir>/loam/` exists — a service that never generated has not opted
256
+ * in, and silence there is honest, not vacuous.
257
+ *
258
+ * The LIVING specs are the reference, with one carve-out. A stamped file
259
+ * tagged with a feature still in flight answers to that feature's delta, not
260
+ * to the living spec its requirements have not merged into yet — so it is
261
+ * skipped here (its regeneration is `loam gherkin <FEAT>`), and the moment the
262
+ * feature archives, its requirements ARE living requirements, digests
263
+ * unchanged, and the same file grades current with no rewrite. A tag naming
264
+ * no active feature (the feature was archived, or abandoned) gets no
265
+ * exemption: the living spec is all there is to answer to.
266
+ *
267
+ * Three warnings, one per way the suite and the spec can disagree, judged
268
+ * digest-first and by requirement name second:
269
+ *
270
+ * - `gherkin.missing` — a living scenario whose digest no stamped scenario of
271
+ * its axis carries: the suite has no test for these words.
272
+ * - `gherkin.stale` — a stamped scenario whose digest matches no living
273
+ * scenario while its file's requirement still exists: the spec moved under
274
+ * the generated file. (A reworded scenario therefore reports both: the old
275
+ * words are stale, the new words are missing — one regeneration clears
276
+ * both.)
277
+ * - `gherkin.orphaned` — a whole file whose requirement no longer exists in
278
+ * its axis's living spec; reported once per file, because every scenario in
279
+ * it is moot together.
280
+ *
281
+ * Digests are content identity within one service and one axis, deliberately:
282
+ * two identically-worded scenarios of one service's one axis share one digest,
283
+ * so one stamped copy covers both — while the axes stay separate namespaces
284
+ * twice over (every comparison here is axis-scoped, and the recipe salts the
285
+ * arch axis), and the services stay separate because the recipe salts the
286
+ * service too. That last salt is why a stamped file copied out of another
287
+ * service's repository grades `gherkin.orphaned`/`gherkin.stale` here instead
288
+ * of quietly counting as this service's coverage.
289
+ *
290
+ * All warnings — `--strict` is the CI escalation — plus one `ok` confirmation
291
+ * (`gherkin.current`, the `sources.current` pattern) when a generated suite
292
+ * exists and nothing disagrees.
293
+ */
294
+ export async function gherkinFindings(ctx) {
295
+ if (ctx.repoDir === undefined)
296
+ return [];
297
+ let root;
298
+ try {
299
+ root = gherkinRoot(ctx.repoDir, ctx.gherkinDir);
300
+ }
301
+ catch (err) {
302
+ if (!(err instanceof UnsafePathError))
303
+ throw err;
304
+ return [
305
+ {
306
+ severity: "error",
307
+ code: "gherkin.path-outside",
308
+ subject: ctx.service,
309
+ message: `The configured Gherkin output is unsafe: ${err.message}. It must stay inside the service repo.`,
310
+ },
311
+ ];
312
+ }
313
+ if (!existsSync(root))
314
+ return [];
315
+ const paths = servicePaths(ctx.docsDir, ctx.service);
316
+ const axes = new Map();
317
+ for (const axis of SPEC_AXES) {
318
+ const label = axisLabel(axis);
319
+ const state = { file: axis.file, reqNames: new Set(), digests: new Set(), scenarios: [] };
320
+ const path = paths[axis.key];
321
+ if (existsSync(path)) {
322
+ const reqs = ctx.fleet === undefined
323
+ ? parseRequirements(await readFile(path, "utf8"))
324
+ : await ctx.fleet.readRequirements(path);
325
+ for (const r of reqs) {
326
+ if (r.kind === "REMOVED")
327
+ continue;
328
+ state.reqNames.add(r.name);
329
+ for (const s of r.scenarios) {
330
+ const digest = scenarioDigest(ctx.service, s.lines, label);
331
+ state.digests.add(digest);
332
+ state.scenarios.push({ req: r.name, name: s.name, digest });
333
+ }
334
+ }
335
+ }
336
+ axes.set(label, state);
337
+ }
338
+ // The stamped side. Every stamped digest counts toward coverage — an
339
+ // in-flight file whose scenario matches the living words IS a test for them.
340
+ const active = new Set((await listFeatures(ctx.docsDir, {}, ctx.fleet)).map((f) => f.id));
341
+ const stampedDigests = { business: new Set(), arch: new Set() };
342
+ const parsed = [];
343
+ let stampedScenarios = 0;
344
+ for (const abs of await featureFilesUnder(root)) {
345
+ const file = parseStampedFeature(await readFile(abs, "utf8"));
346
+ if (file === null)
347
+ continue;
348
+ const axis = file.tags.includes("architecture") ? "arch" : "business";
349
+ for (const s of file.scenarios)
350
+ stampedDigests[axis].add(s.digest);
351
+ stampedScenarios += file.scenarios.length;
352
+ parsed.push({ rel: relative(ctx.repoDir, abs).split(sep).join("/"), axis, file });
353
+ }
354
+ const findings = [];
355
+ const regen = `\`loam gherkin --service ${ctx.service}\``;
356
+ for (const [axis, state] of axes) {
357
+ for (const sc of state.scenarios) {
358
+ if (stampedDigests[axis].has(sc.digest))
359
+ continue;
360
+ findings.push({
361
+ severity: "warn",
362
+ code: "gherkin.missing",
363
+ subject: ctx.service,
364
+ message: `${ctx.service}: scenario '${sc.name}' of requirement '${sc.req}' (${state.file}) has no generated .feature scenario — the suite has no test for these words; ${regen} regenerates it`,
365
+ });
366
+ }
367
+ }
368
+ for (const { rel, axis, file } of parsed) {
369
+ if (file.tags.some((t) => active.has(t)))
370
+ continue;
371
+ const state = axes.get(axis);
372
+ if (file.featureName === null || !state.reqNames.has(file.featureName)) {
373
+ findings.push({
374
+ severity: "warn",
375
+ code: "gherkin.orphaned",
376
+ subject: ctx.service,
377
+ message: `${ctx.service}: ${rel}: requirement '${file.featureName ?? "(unnamed)"}' no longer exists in the living ${state.file} — the whole file is orphaned; ${regen} removes it`,
378
+ });
379
+ continue;
380
+ }
381
+ for (const s of file.scenarios) {
382
+ if (state.digests.has(s.digest))
383
+ continue;
384
+ findings.push({
385
+ severity: "warn",
386
+ code: "gherkin.stale",
387
+ subject: ctx.service,
388
+ message: `${ctx.service}: ${rel}: scenario '${s.name}' matches no scenario in the living ${state.file} — the spec moved under the generated file; ${regen} regenerates it`,
389
+ });
390
+ }
391
+ }
392
+ if (findings.length === 0) {
393
+ findings.push({
394
+ severity: "ok",
395
+ code: "gherkin.current",
396
+ message: `${ctx.service}: generated gherkin current (${parsed.length} file(s), ${stampedScenarios} stamped scenario(s) agree with the living specs)`,
397
+ });
398
+ }
399
+ return findings;
400
+ }
401
+ /**
402
+ * Every `.feature` file at or under `root`, sorted by repo-relative path —
403
+ * enumeration is part of the output contract, so the order cannot depend on
404
+ * the filesystem's. Dot-entries are skipped, like every other walk in loam.
405
+ *
406
+ * Symlinks are FOLLOWED — repo.ts's `entryIs` and brief.ts's `hasMarkdown` are
407
+ * the same rule for the same reason. A `Dirent` from `readdir(withFileTypes)`
408
+ * never follows one, so `isFile()` and `isDirectory()` are both false for a
409
+ * symlinked `.feature`: it was invisible to the orphan scan (`loam gherkin`
410
+ * left it behind forever) and invisible to the staleness scan (it graded as no
411
+ * coverage at all). Composing a suite out of links is legitimate, so they are
412
+ * followed rather than refused, and a dangling one is skipped exactly as an
413
+ * absent file is.
414
+ */
415
+ export async function featureFilesUnder(root) {
416
+ const out = [];
417
+ // Following links means a cycle is now possible; every directory is entered
418
+ // once by its resolved path, so a loop terminates instead of hanging.
419
+ const entered = new Set();
420
+ const walk = async (dir) => {
421
+ if (!existsSync(dir))
422
+ return;
423
+ let resolved;
424
+ try {
425
+ resolved = await realpath(dir);
426
+ }
427
+ catch {
428
+ return;
429
+ }
430
+ if (entered.has(resolved))
431
+ return;
432
+ entered.add(resolved);
433
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
434
+ if (entry.name.startsWith("."))
435
+ continue;
436
+ const abs = join(dir, entry.name);
437
+ const what = entryKind(abs, entry);
438
+ if (what === "dir")
439
+ await walk(abs);
440
+ else if (what === "file" && entry.name.endsWith(".feature"))
441
+ out.push(abs);
442
+ }
443
+ };
444
+ await walk(root);
445
+ return out.sort((a, b) => {
446
+ const ra = a.split(sep).join("/");
447
+ const rb = b.split(sep).join("/");
448
+ return ra < rb ? -1 : ra > rb ? 1 : 0;
449
+ });
450
+ }
451
+ /** What an entry IS, resolving a symlink to find out. Nothing when it dangles. */
452
+ function entryKind(abs, e) {
453
+ if (!e.isSymbolicLink())
454
+ return e.isDirectory() ? "dir" : e.isFile() ? "file" : null;
455
+ try {
456
+ const s = statSync(abs);
457
+ return s.isDirectory() ? "dir" : s.isFile() ? "file" : null;
458
+ }
459
+ catch {
460
+ return null;
461
+ }
462
+ }
463
+ //# sourceMappingURL=gherkin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gherkin.js","sourceRoot":"","sources":["../../src/core/gherkin.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The Gherkin emission: spec scenarios become real `.feature` files.\n *\n * Scenarios have been \"the source for tests\" since SCHEMA.md's first draft, but\n * until now they only left the docs repo as projected markdown — prose a coding\n * agent could paraphrase into something easier to pass. The emission closes the\n * gap mechanically: one `.feature` file per requirement, steps taken from the\n * scenario's own bullet lines, and a digest TAG (`@loam-digest-<16hex>`) on\n * every scenario using the SAME body-hash recipe `loam verify` folds into its\n * claim ids (verify.ts's scenarioBodyHash — salted by the owning service and\n * by the spec axis, so a stamp belongs to one suite and one axis) — so the\n * test skeleton, the claim and the spec can never quietly disagree about what\n * a scenario says, and no two repositories can answer each other's. A tag\n * rather than a comment because tags are what cucumber's JSON report carries\n * per scenario: the stamp rides through the runner untouched, which is what\n * lets `loam verify --results` match a report scenario back to its claim.\n *\n * The files land in the SERVICE repo — the docs repo states the questions, the\n * tests live with the code — under `<gherkinDir>/loam/`. That `loam/`\n * subdirectory is loam's own derived space: regeneration rewrites its scope and\n * removes its orphans, and loam never writes or deletes a byte outside it.\n * Everything here is deterministic on purpose: the same specs yield the same\n * bytes, which is what lets `loam validate` grade staleness by digest instead\n * of by guesswork.\n */\nimport { existsSync, statSync, type Dirent } from \"node:fs\";\nimport { readdir, readFile, realpath } from \"node:fs/promises\";\nimport { join, relative, sep } from \"node:path\";\nimport { parseRequirements, type Requirement } from \"./spec.js\";\nimport { scenarioGherkin } from \"./steps.js\";\nimport { listFeatures, servicePaths, SPEC_AXES, type SpecAxis } from \"./repo.js\";\nimport type { FleetContext } from \"./fleet-context.js\";\nimport type { Finding } from \"./report.js\";\nimport { scenarioBodyHash, type ScenarioAxis } from \"./verify.js\";\nimport { resolveInside, UnsafePathError } from \"./path-safety.js\";\n\n/** Where Gherkin lives in a service repo when loam.json does not say. */\nconst DEFAULT_GHERKIN_DIR = \"features\";\n\n/** The subdirectory loam owns outright. Everything loam writes stays inside it. */\nconst GHERKIN_SUBDIR = \"loam\";\n\n/** How much of the scenario body hash goes into a `@loam-digest-…` tag. */\nexport const GHERKIN_DIGEST_LENGTH = 16;\n\n/** The one directory `loam gherkin` may touch: `<repo>/<gherkinDir>/loam/`. */\nexport function gherkinRoot(repoDir: string, gherkinDir?: string): string {\n return resolveInside(\n repoDir,\n join(gherkinDir ?? DEFAULT_GHERKIN_DIR, GHERKIN_SUBDIR),\n \"gherkin output directory\",\n );\n}\n\n/**\n * The digest stamped onto every generated scenario: verify's scenario body\n * hash, at the sources-digest length. Content identity WITHIN one service and\n * one axis, not provenance — two identically-worded scenarios of one service's\n * one axis share a digest, and that is the point: the stamp answers \"does this\n * suite still contain a test for THESE words\".\n *\n * Both salts ride in the hash (see `scenarioBodyHash`), so the namespaces stay\n * separate all the way into a cucumber report: a business run can never answer\n * for an arch scenario, and one service's green run can never answer another\n * service's claim just because the two specs spell the sentence the same way.\n * `service` is required and never defaulted — a stamp that forgot it would be\n * a tag no claim matches, and the fleet bug it exists to close would come back\n * silently.\n */\nexport function scenarioDigest(\n service: string,\n lines: string[],\n axis: ScenarioAxis = \"business\",\n): string {\n return scenarioBodyHash(service, lines, axis).slice(0, GHERKIN_DIGEST_LENGTH);\n}\n\n/** The digest as the tag on the line above each `Scenario:`. */\nexport function scenarioDigestTag(digest: string): string {\n return `@loam-digest-${digest}`;\n}\n\n/**\n * A tag token carrying a scenario digest — in a generated file, or in a\n * cucumber JSON report. The leading `@` is optional because some runners\n * (behave) strip it from the tags they report.\n */\nexport const DIGEST_TAG_RE = /^@?loam-digest-([0-9a-f]{16})$/;\n\n/**\n * Line 1 of every generated file — the AGENTS.md stamp pattern, in Gherkin\n * comment syntax. The version records which loam wrote the file; the rest is\n * the ownership rule in the reader's face, because `loam/` files are rewritten\n * wholesale on regeneration and a hand edit would be silently destroyed.\n */\nexport function gherkinStampLine(version: string): string {\n return `# generated by loam v${version} — \\`loam gherkin\\`; do not hand-edit, regenerate instead`;\n}\n\nconst STAMP_RE = /^#\\s*generated by loam v(\\S+)/;\n\n/* ------------------------------------------------------------------ */\n/* Emission */\n/* ------------------------------------------------------------------ */\n\n/** The axis label the machine contract speaks. */\nexport function axisLabel(axis: SpecAxis): \"business\" | \"arch\" {\n return axis.key === \"archSpec\" ? \"arch\" : \"business\";\n}\n\n/**\n * A requirement name as a file name: lowercase, every non-letter/digit run one\n * hyphen, edges trimmed.\n *\n * The keep-set is `\\p{L}\\p{N}`, not `[a-z0-9]`, because an ASCII-only slug is\n * not a slug of a non-Latin fleet — it is the empty string. Every requirement\n * written in Chinese, Japanese, Korean, Greek, Cyrillic or Hebrew slugged to\n * nothing, fell back to `requirement`, and the whole service's suite came out\n * as `requirement.feature`, `requirement-2.feature`, … : file names that name\n * nothing, in the one place the mapping is supposed to be legible. So letters\n * of every script survive as themselves.\n *\n * Accents are folded rather than kept, and that is the reason for NFKD first:\n * decomposing splits `é` into `e` + a combining acute, and dropping the\n * combining marks (`\\p{M}`) leaves `e`. Without the fold `émojis` and `emojis`\n * would be two files that look identical in a directory listing — and on a\n * filesystem that normalises names (APFS, and HFS+ before it) they would be one\n * file, silently, with the second emission overwriting the first.\n *\n * A name this still leaves empty (all punctuation or emoji) falls back to\n * \"requirement\" and lets the collision counter number it. Runs collapse to a\n * SINGLE hyphen, which is what keeps the `arch--` prefix an axis marker no\n * business slug can spell.\n */\nexport function slugOf(name: string): string {\n const s = name\n .normalize(\"NFKD\")\n .replace(/\\p{M}+/gu, \"\")\n .toLowerCase()\n .replace(/[^\\p{L}\\p{N}]+/gu, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return s.length > 0 ? s : \"requirement\";\n}\n\nexport interface PlannedFeature {\n /** File name under `<gherkinDir>/loam/` — flat, never a subdirectory. */\n fileName: string;\n axis: SpecAxis;\n requirement: Requirement;\n /** One digest per scenario, in scenario order — what the stamps say. */\n digests: string[];\n /**\n * Names of scenarios that yielded ZERO steps — numbered-step or prose-only\n * legacy bodies. Their `Scenario:` blocks are description-only: cucumber\n * runs them vacuously green, while `--results` (which requires at least one\n * passed step) can never confirm them — so the emission must say so out\n * loud, and the fix is rewording the spec bullets, never editing the file.\n */\n stepless: string[];\n content: string;\n}\n\n/**\n * The emission plan for one scope: one `.feature` per requirement.\n *\n * File naming is the mapping's contract, so it is spelled here once:\n * `<slug>.feature` for the business axis, `arch--<slug>.feature` for the\n * architecture axis — flat and diffable, and collision-proof across axes\n * because slugs collapse punctuation runs to a single hyphen, so no business\n * slug can ever begin `arch--`. Two requirements slugging identically are\n * disambiguated in document order: the first keeps the bare slug, later ones\n * take `-2`, `-3`, … (counting up past any name that already owns the suffix).\n *\n * Tags ride the `Feature:` line and are inherited by every scenario in the\n * file: `@<FEAT>` in feature mode (living emissions carry no feature tag),\n * `@architecture` on the arch axis.\n *\n * `opts.service` is the suite's owner and salts every digest stamp — the same\n * service `loam verify` files the matching claims under. Required, not\n * defaulted: an emission that guessed it would stamp tags no claim matches.\n */\nexport function planEmission(\n byAxis: Array<{ axis: SpecAxis; reqs: Requirement[] }>,\n opts: { service: string; featureTag?: string; version: string },\n): PlannedFeature[] {\n const used = new Set<string>();\n const out: PlannedFeature[] = [];\n for (const { axis, reqs } of byAxis) {\n for (const r of reqs) {\n const base = (axis.key === \"archSpec\" ? \"arch--\" : \"\") + slugOf(r.name);\n let fileName = `${base}.feature`;\n for (let n = 2; used.has(fileName); n += 1) fileName = `${base}-${n}.feature`;\n used.add(fileName);\n const tags = [\n ...(opts.featureTag === undefined ? [] : [`@${opts.featureTag}`]),\n ...(axis.key === \"archSpec\" ? [\"@architecture\"] : []),\n ];\n const { content, digests, stepless } = renderFeature(\n r,\n tags,\n opts.version,\n opts.service,\n axisLabel(axis),\n );\n out.push({ fileName, axis, requirement: r, digests, stepless, content });\n }\n }\n return out;\n}\n\n/**\n * One requirement as one `.feature` file: `Feature:` is the requirement name,\n * its body text (Operations:/Covers: lines included — they are part of what\n * the requirement says) becomes the feature description, and each scenario\n * renders under its `@loam-digest-…` tag — the tag line sits directly above\n * the `Scenario:` keyword, where Gherkin puts scenario-level tags, so the\n * runner attaches it to the scenario and carries it into the JSON report.\n * Deterministic to the byte.\n *\n * `service` is the owner of the suite being written, and it salts every digest\n * — the same service `loam verify` files the matching claims under. The two\n * have to be the same string or the stamps this writes match no claim at all.\n */\nexport function renderFeature(\n r: Requirement,\n tags: string[],\n version: string,\n service: string,\n axis: ScenarioAxis = \"business\",\n): { content: string; digests: string[]; stepless: string[] } {\n const lines: string[] = [gherkinStampLine(version)];\n if (tags.length > 0) lines.push(tags.join(\" \"));\n lines.push(`Feature: ${r.name}`);\n const text = r.text.join(\"\\n\").trim();\n if (text.length > 0) {\n lines.push(\"\");\n for (const l of text.split(\"\\n\")) lines.push(l.length > 0 ? ` ${l}` : \"\");\n }\n const digests: string[] = [];\n const stepless: string[] = [];\n for (const s of r.scenarios) {\n const digest = scenarioDigest(service, s.lines, axis);\n digests.push(digest);\n lines.push(\"\", ` ${scenarioDigestTag(digest)}`, ` Scenario: ${s.name}`);\n const { description, steps } = scenarioGherkin(s.lines);\n if (steps.length === 0) stepless.push(s.name);\n for (const d of description) lines.push(` ${d}`);\n for (const st of steps) lines.push(` ${st}`);\n }\n return { content: lines.join(\"\\n\") + \"\\n\", digests, stepless };\n}\n\n/* ------------------------------------------------------------------ */\n/* Reading generated files back */\n/* ------------------------------------------------------------------ */\n\nexport interface StampedScenario {\n name: string;\n digest: string;\n}\n\n/** A generated file as the staleness checks see it. */\nexport interface StampedFeature {\n /** The version in the stamp line — which loam wrote the file. */\n version: string;\n /** Tag tokens (without `@`) from tag lines above `Feature:`. */\n tags: string[];\n /** The `Feature:` line's text — the requirement name, by the mapping. */\n featureName: string | null;\n /** Scenarios carrying a `@loam-digest-…` tag. A hand-added scenario has none and is invisible here. */\n scenarios: StampedScenario[];\n}\n\n/**\n * Parse a `.feature` file for its loam stamps. Null when line 1 is not the\n * stamp — an unstamped file is not loam's writing, and no staleness judgment\n * may be built on it. Tag lines above `Feature:` are the file's tags; a tag\n * line after it that carries a `@loam-digest-…` token binds that digest to the\n * immediately following `Scenario:` line (tag lines stack, as Gherkin's do,\n * without breaking the bond); any other non-blank line between them breaks it.\n */\nexport function parseStampedFeature(text: string): StampedFeature | null {\n const lines = text.split(/\\r?\\n/);\n const m = STAMP_RE.exec(lines[0] ?? \"\");\n if (!m) return null;\n const tags: string[] = [];\n let featureName: string | null = null;\n const scenarios: StampedScenario[] = [];\n let pending: string | null = null;\n for (const line of lines.slice(1)) {\n if (/^\\s*@/.test(line)) {\n for (const t of line.trim().split(/\\s+/)) {\n if (featureName === null) {\n if (t.startsWith(\"@\")) tags.push(t.slice(1));\n continue;\n }\n const d = DIGEST_TAG_RE.exec(t);\n if (d) pending = d[1]!;\n }\n continue;\n }\n const f = /^\\s*Feature:\\s*(.*)$/.exec(line);\n if (f && featureName === null) {\n featureName = f[1]!.trim();\n continue;\n }\n const s = /^\\s*Scenario(?: Outline)?:\\s*(.*)$/.exec(line);\n if (s) {\n if (pending !== null) scenarios.push({ name: s[1]!.trim(), digest: pending });\n pending = null;\n continue;\n }\n if (pending !== null && line.trim().length > 0) pending = null;\n }\n return { version: m[1]!, tags, featureName, scenarios };\n}\n\n/* ------------------------------------------------------------------ */\n/* Staleness — what `loam validate` reports about a generated suite */\n/* ------------------------------------------------------------------ */\n\n/**\n * The gherkin freshness chain, service-repo-scoped like `sources.*`: it needs\n * the repo (the suite lives there), and it stays quiet until\n * `<gherkinDir>/loam/` exists — a service that never generated has not opted\n * in, and silence there is honest, not vacuous.\n *\n * The LIVING specs are the reference, with one carve-out. A stamped file\n * tagged with a feature still in flight answers to that feature's delta, not\n * to the living spec its requirements have not merged into yet — so it is\n * skipped here (its regeneration is `loam gherkin <FEAT>`), and the moment the\n * feature archives, its requirements ARE living requirements, digests\n * unchanged, and the same file grades current with no rewrite. A tag naming\n * no active feature (the feature was archived, or abandoned) gets no\n * exemption: the living spec is all there is to answer to.\n *\n * Three warnings, one per way the suite and the spec can disagree, judged\n * digest-first and by requirement name second:\n *\n * - `gherkin.missing` — a living scenario whose digest no stamped scenario of\n * its axis carries: the suite has no test for these words.\n * - `gherkin.stale` — a stamped scenario whose digest matches no living\n * scenario while its file's requirement still exists: the spec moved under\n * the generated file. (A reworded scenario therefore reports both: the old\n * words are stale, the new words are missing — one regeneration clears\n * both.)\n * - `gherkin.orphaned` — a whole file whose requirement no longer exists in\n * its axis's living spec; reported once per file, because every scenario in\n * it is moot together.\n *\n * Digests are content identity within one service and one axis, deliberately:\n * two identically-worded scenarios of one service's one axis share one digest,\n * so one stamped copy covers both — while the axes stay separate namespaces\n * twice over (every comparison here is axis-scoped, and the recipe salts the\n * arch axis), and the services stay separate because the recipe salts the\n * service too. That last salt is why a stamped file copied out of another\n * service's repository grades `gherkin.orphaned`/`gherkin.stale` here instead\n * of quietly counting as this service's coverage.\n *\n * All warnings — `--strict` is the CI escalation — plus one `ok` confirmation\n * (`gherkin.current`, the `sources.current` pattern) when a generated suite\n * exists and nothing disagrees.\n */\nexport async function gherkinFindings(ctx: {\n docsDir: string;\n service: string;\n /** The service repo, when loam is standing in it. Undefined disables the chain, like sources.*. */\n repoDir?: string;\n gherkinDir?: string;\n fleet?: FleetContext;\n}): Promise<Finding[]> {\n if (ctx.repoDir === undefined) return [];\n let root: string;\n try {\n root = gherkinRoot(ctx.repoDir, ctx.gherkinDir);\n } catch (err) {\n if (!(err instanceof UnsafePathError)) throw err;\n return [\n {\n severity: \"error\",\n code: \"gherkin.path-outside\",\n subject: ctx.service,\n message: `The configured Gherkin output is unsafe: ${err.message}. It must stay inside the service repo.`,\n },\n ];\n }\n if (!existsSync(root)) return [];\n\n // The living reference, per axis: requirement names, and scenario digests.\n interface AxisState {\n file: string;\n reqNames: Set<string>;\n digests: Set<string>;\n scenarios: Array<{ req: string; name: string; digest: string }>;\n }\n const paths = servicePaths(ctx.docsDir, ctx.service);\n const axes = new Map<\"business\" | \"arch\", AxisState>();\n for (const axis of SPEC_AXES) {\n const label = axisLabel(axis);\n const state: AxisState = { file: axis.file, reqNames: new Set(), digests: new Set(), scenarios: [] };\n const path = paths[axis.key];\n if (existsSync(path)) {\n const reqs = ctx.fleet === undefined\n ? parseRequirements(await readFile(path, \"utf8\"))\n : await ctx.fleet.readRequirements(path);\n for (const r of reqs) {\n if (r.kind === \"REMOVED\") continue;\n state.reqNames.add(r.name);\n for (const s of r.scenarios) {\n const digest = scenarioDigest(ctx.service, s.lines, label);\n state.digests.add(digest);\n state.scenarios.push({ req: r.name, name: s.name, digest });\n }\n }\n }\n axes.set(label, state);\n }\n\n // The stamped side. Every stamped digest counts toward coverage — an\n // in-flight file whose scenario matches the living words IS a test for them.\n const active = new Set((await listFeatures(ctx.docsDir, {}, ctx.fleet)).map((f) => f.id));\n const stampedDigests = { business: new Set<string>(), arch: new Set<string>() };\n const parsed: Array<{ rel: string; axis: \"business\" | \"arch\"; file: StampedFeature }> = [];\n let stampedScenarios = 0;\n for (const abs of await featureFilesUnder(root)) {\n const file = parseStampedFeature(await readFile(abs, \"utf8\"));\n if (file === null) continue;\n const axis = file.tags.includes(\"architecture\") ? \"arch\" : \"business\";\n for (const s of file.scenarios) stampedDigests[axis].add(s.digest);\n stampedScenarios += file.scenarios.length;\n parsed.push({ rel: relative(ctx.repoDir, abs).split(sep).join(\"/\"), axis, file });\n }\n\n const findings: Finding[] = [];\n const regen = `\\`loam gherkin --service ${ctx.service}\\``;\n for (const [axis, state] of axes) {\n for (const sc of state.scenarios) {\n if (stampedDigests[axis].has(sc.digest)) continue;\n findings.push({\n severity: \"warn\",\n code: \"gherkin.missing\",\n subject: ctx.service,\n message: `${ctx.service}: scenario '${sc.name}' of requirement '${sc.req}' (${state.file}) has no generated .feature scenario — the suite has no test for these words; ${regen} regenerates it`,\n });\n }\n }\n for (const { rel, axis, file } of parsed) {\n if (file.tags.some((t) => active.has(t))) continue;\n const state = axes.get(axis)!;\n if (file.featureName === null || !state.reqNames.has(file.featureName)) {\n findings.push({\n severity: \"warn\",\n code: \"gherkin.orphaned\",\n subject: ctx.service,\n message: `${ctx.service}: ${rel}: requirement '${file.featureName ?? \"(unnamed)\"}' no longer exists in the living ${state.file} — the whole file is orphaned; ${regen} removes it`,\n });\n continue;\n }\n for (const s of file.scenarios) {\n if (state.digests.has(s.digest)) continue;\n findings.push({\n severity: \"warn\",\n code: \"gherkin.stale\",\n subject: ctx.service,\n message: `${ctx.service}: ${rel}: scenario '${s.name}' matches no scenario in the living ${state.file} — the spec moved under the generated file; ${regen} regenerates it`,\n });\n }\n }\n if (findings.length === 0) {\n findings.push({\n severity: \"ok\",\n code: \"gherkin.current\",\n message: `${ctx.service}: generated gherkin current (${parsed.length} file(s), ${stampedScenarios} stamped scenario(s) agree with the living specs)`,\n });\n }\n return findings;\n}\n\n/**\n * Every `.feature` file at or under `root`, sorted by repo-relative path —\n * enumeration is part of the output contract, so the order cannot depend on\n * the filesystem's. Dot-entries are skipped, like every other walk in loam.\n *\n * Symlinks are FOLLOWED — repo.ts's `entryIs` and brief.ts's `hasMarkdown` are\n * the same rule for the same reason. A `Dirent` from `readdir(withFileTypes)`\n * never follows one, so `isFile()` and `isDirectory()` are both false for a\n * symlinked `.feature`: it was invisible to the orphan scan (`loam gherkin`\n * left it behind forever) and invisible to the staleness scan (it graded as no\n * coverage at all). Composing a suite out of links is legitimate, so they are\n * followed rather than refused, and a dangling one is skipped exactly as an\n * absent file is.\n */\nexport async function featureFilesUnder(root: string): Promise<string[]> {\n const out: string[] = [];\n // Following links means a cycle is now possible; every directory is entered\n // once by its resolved path, so a loop terminates instead of hanging.\n const entered = new Set<string>();\n const walk = async (dir: string): Promise<void> => {\n if (!existsSync(dir)) return;\n let resolved: string;\n try {\n resolved = await realpath(dir);\n } catch {\n return;\n }\n if (entered.has(resolved)) return;\n entered.add(resolved);\n for (const entry of await readdir(dir, { withFileTypes: true })) {\n if (entry.name.startsWith(\".\")) continue;\n const abs = join(dir, entry.name);\n const what = entryKind(abs, entry);\n if (what === \"dir\") await walk(abs);\n else if (what === \"file\" && entry.name.endsWith(\".feature\")) out.push(abs);\n }\n };\n await walk(root);\n return out.sort((a, b) => {\n const ra = a.split(sep).join(\"/\");\n const rb = b.split(sep).join(\"/\");\n return ra < rb ? -1 : ra > rb ? 1 : 0;\n });\n}\n\n/** What an entry IS, resolving a symlink to find out. Nothing when it dangles. */\nfunction entryKind(abs: string, e: Dirent): \"dir\" | \"file\" | null {\n if (!e.isSymbolicLink()) return e.isDirectory() ? \"dir\" : e.isFile() ? \"file\" : null;\n try {\n const s = statSync(abs);\n return s.isDirectory() ? \"dir\" : s.isFile() ? \"file\" : null;\n } catch {\n return null;\n }\n}\n"]}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The minimal reader for `services/<svc>/health.yaml` — the moment that file
3
+ * stops being inert. loam reads exactly one thing out of it: the IDS of the
4
+ * alerts and SLIs it declares, so the architecture spec axis can ask whether a
5
+ * requirement covers each one (`health.uncovered`). Queries, thresholds,
6
+ * checks, dependencies — everything else stays authored prose that no check
7
+ * reads, the same stance `operationIds` takes on OpenAPI schemas.
8
+ *
9
+ * The recognized keys (documented in SCHEMA.md, and deliberately few):
10
+ *
11
+ * slis: a YAML sequence; each entry contributes its `name` (or `id`
12
+ * alerts: when there is no name), and a plain string entry is its own id.
13
+ *
14
+ * Tolerant about absence, honest about breakage: a health.yaml that is missing
15
+ * or declares nothing recognizable yields an empty set — and an empty set
16
+ * produces no findings, because an absent file must not manufacture
17
+ * obligations. A file that EXISTS but cannot be read is different evidence:
18
+ * its ids are unknown, not empty, so the `unreadable` flag travels alongside
19
+ * (parsers never diagnose — validate grades it as `health.invalid`). The old
20
+ * silent empty set turned every `Covers: alert:/sli:` entry of that service
21
+ * into a false `covers.unknown` "typo", when the truth was this file.
22
+ */
23
+ import { existsSync } from "node:fs";
24
+ import { readFile } from "node:fs/promises";
25
+ import { parse } from "yaml";
26
+ const NO_HEALTH_IDS = { slis: [], alerts: [] };
27
+ export async function readHealth(healthPath) {
28
+ if (!existsSync(healthPath))
29
+ return { ids: NO_HEALTH_IDS, unreadable: false };
30
+ let doc;
31
+ try {
32
+ doc = parse(await readFile(healthPath, "utf8"));
33
+ }
34
+ catch (e) {
35
+ return { ids: NO_HEALTH_IDS, unreadable: true, error: e instanceof Error ? e.message : String(e) };
36
+ }
37
+ // An empty file parses to null: it declares nothing, and says so honestly.
38
+ // A scalar or sequence document declares nothing READABLE — flag it.
39
+ if (doc === null)
40
+ return { ids: NO_HEALTH_IDS, unreadable: false };
41
+ if (typeof doc !== "object" || Array.isArray(doc)) {
42
+ return { ids: NO_HEALTH_IDS, unreadable: true, error: "document is not a YAML mapping" };
43
+ }
44
+ const top = doc;
45
+ return { ids: { slis: idsOf(top["slis"]), alerts: idsOf(top["alerts"]) }, unreadable: false };
46
+ }
47
+ /** The ids a `slis:` / `alerts:` sequence declares — deduped, document order. */
48
+ function idsOf(section) {
49
+ if (!Array.isArray(section))
50
+ return [];
51
+ const out = new Set();
52
+ for (const entry of section) {
53
+ if (typeof entry === "string") {
54
+ if (entry.trim().length > 0)
55
+ out.add(entry.trim());
56
+ continue;
57
+ }
58
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry))
59
+ continue;
60
+ const rec = entry;
61
+ const id = typeof rec["name"] === "string" ? rec["name"] : rec["id"];
62
+ if (typeof id === "string" && id.trim().length > 0)
63
+ out.add(id.trim());
64
+ }
65
+ return [...out];
66
+ }
67
+ //# sourceMappingURL=health.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health.js","sourceRoot":"","sources":["../../src/core/health.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,KAAK,EAAE,MAAM,MAAM,CAAC;AAO7B,MAAM,aAAa,GAAc,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;AAW1D,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,UAAkB;IACjD,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC;QAAE,OAAO,EAAE,GAAG,EAAE,aAAa,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;IAC9E,IAAI,GAAY,CAAC;IACjB,IAAI,CAAC;QACH,GAAG,GAAG,KAAK,CAAC,MAAM,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC;IAClD,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,EAAE,GAAG,EAAE,aAAa,EAAE,UAAU,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;IACrG,CAAC;IACD,2EAA2E;IAC3E,qEAAqE;IACrE,IAAI,GAAG,KAAK,IAAI;QAAE,OAAO,EAAE,GAAG,EAAE,aAAa,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;IACnE,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QAClD,OAAO,EAAE,GAAG,EAAE,aAAa,EAAE,UAAU,EAAE,IAAI,EAAE,KAAK,EAAE,gCAAgC,EAAE,CAAC;IAC3F,CAAC;IACD,MAAM,GAAG,GAAG,GAA8B,CAAC;IAC3C,OAAO,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;AAChG,CAAC;AAED,iFAAiF;AACjF,SAAS,KAAK,CAAC,OAAgB;IAC7B,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,CAAC;IACvC,MAAM,GAAG,GAAG,IAAI,GAAG,EAAU,CAAC;IAC9B,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC9B,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC;gBAAE,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;YACnD,SAAS;QACX,CAAC;QACD,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;YAAE,SAAS;QAClF,MAAM,GAAG,GAAG,KAAgC,CAAC;QAC7C,MAAM,EAAE,GAAG,OAAO,GAAG,CAAC,MAAM,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACrE,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC;YAAE,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC;IACzE,CAAC;IACD,OAAO,CAAC,GAAG,GAAG,CAAC,CAAC;AAClB,CAAC","sourcesContent":["/**\n * The minimal reader for `services/<svc>/health.yaml` — the moment that file\n * stops being inert. loam reads exactly one thing out of it: the IDS of the\n * alerts and SLIs it declares, so the architecture spec axis can ask whether a\n * requirement covers each one (`health.uncovered`). Queries, thresholds,\n * checks, dependencies — everything else stays authored prose that no check\n * reads, the same stance `operationIds` takes on OpenAPI schemas.\n *\n * The recognized keys (documented in SCHEMA.md, and deliberately few):\n *\n * slis: a YAML sequence; each entry contributes its `name` (or `id`\n * alerts: when there is no name), and a plain string entry is its own id.\n *\n * Tolerant about absence, honest about breakage: a health.yaml that is missing\n * or declares nothing recognizable yields an empty set — and an empty set\n * produces no findings, because an absent file must not manufacture\n * obligations. A file that EXISTS but cannot be read is different evidence:\n * its ids are unknown, not empty, so the `unreadable` flag travels alongside\n * (parsers never diagnose — validate grades it as `health.invalid`). The old\n * silent empty set turned every `Covers: alert:/sli:` entry of that service\n * into a false `covers.unknown` \"typo\", when the truth was this file.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport { parse } from \"yaml\";\n\nexport interface HealthIds {\n slis: string[];\n alerts: string[];\n}\n\nconst NO_HEALTH_IDS: HealthIds = { slis: [], alerts: [] };\n\n/** The read of one health.yaml: its declared ids, and whether the file could be read at all. */\nexport interface HealthFile {\n ids: HealthIds;\n /** True when the file exists but is not a readable YAML mapping. Missing and empty files read as empty, not broken. */\n unreadable: boolean;\n /** The parser's own message, when there is one to quote back. */\n error?: string;\n}\n\nexport async function readHealth(healthPath: string): Promise<HealthFile> {\n if (!existsSync(healthPath)) return { ids: NO_HEALTH_IDS, unreadable: false };\n let doc: unknown;\n try {\n doc = parse(await readFile(healthPath, \"utf8\"));\n } catch (e) {\n return { ids: NO_HEALTH_IDS, unreadable: true, error: e instanceof Error ? e.message : String(e) };\n }\n // An empty file parses to null: it declares nothing, and says so honestly.\n // A scalar or sequence document declares nothing READABLE — flag it.\n if (doc === null) return { ids: NO_HEALTH_IDS, unreadable: false };\n if (typeof doc !== \"object\" || Array.isArray(doc)) {\n return { ids: NO_HEALTH_IDS, unreadable: true, error: \"document is not a YAML mapping\" };\n }\n const top = doc as Record<string, unknown>;\n return { ids: { slis: idsOf(top[\"slis\"]), alerts: idsOf(top[\"alerts\"]) }, unreadable: false };\n}\n\n/** The ids a `slis:` / `alerts:` sequence declares — deduped, document order. */\nfunction idsOf(section: unknown): string[] {\n if (!Array.isArray(section)) return [];\n const out = new Set<string>();\n for (const entry of section) {\n if (typeof entry === \"string\") {\n if (entry.trim().length > 0) out.add(entry.trim());\n continue;\n }\n if (entry === null || typeof entry !== \"object\" || Array.isArray(entry)) continue;\n const rec = entry as Record<string, unknown>;\n const id = typeof rec[\"name\"] === \"string\" ? rec[\"name\"] : rec[\"id\"];\n if (typeof id === \"string\" && id.trim().length > 0) out.add(id.trim());\n }\n return [...out];\n}\n"]}