@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,521 @@
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 { listFeatures, servicePaths, SPEC_AXES } from "./repo.js";
31
+ import { scenarioBodyHash } from "./verify.js";
32
+ import { resolveInside, UnsafePathError } from "./path-safety.js";
33
+ /** Where Gherkin lives in a service repo when loam.json does not say. */
34
+ export const DEFAULT_GHERKIN_DIR = "features";
35
+ /** The subdirectory loam owns outright. Everything loam writes stays inside it. */
36
+ export const GHERKIN_SUBDIR = "loam";
37
+ /** How much of the scenario body hash goes into a `@loam-digest-…` tag. */
38
+ export const GHERKIN_DIGEST_LENGTH = 16;
39
+ /** The one directory `loam gherkin` may touch: `<repo>/<gherkinDir>/loam/`. */
40
+ export function gherkinRoot(repoDir, gherkinDir) {
41
+ return resolveInside(repoDir, join(gherkinDir ?? DEFAULT_GHERKIN_DIR, GHERKIN_SUBDIR), "gherkin output directory");
42
+ }
43
+ /**
44
+ * The digest stamped onto every generated scenario: verify's scenario body
45
+ * hash, at the sources-digest length. Content identity WITHIN one service and
46
+ * one axis, not provenance — two identically-worded scenarios of one service's
47
+ * one axis share a digest, and that is the point: the stamp answers "does this
48
+ * suite still contain a test for THESE words".
49
+ *
50
+ * Both salts ride in the hash (see `scenarioBodyHash`), so the namespaces stay
51
+ * separate all the way into a cucumber report: a business run can never answer
52
+ * for an arch scenario, and one service's green run can never answer another
53
+ * service's claim just because the two specs spell the sentence the same way.
54
+ * `service` is required and never defaulted — a stamp that forgot it would be
55
+ * a tag no claim matches, and the fleet bug it exists to close would come back
56
+ * silently.
57
+ */
58
+ export function scenarioDigest(service, lines, axis = "business") {
59
+ return scenarioBodyHash(service, lines, axis).slice(0, GHERKIN_DIGEST_LENGTH);
60
+ }
61
+ /** The digest as the tag on the line above each `Scenario:`. */
62
+ export function scenarioDigestTag(digest) {
63
+ return `@loam-digest-${digest}`;
64
+ }
65
+ /**
66
+ * A tag token carrying a scenario digest — in a generated file, or in a
67
+ * cucumber JSON report. The leading `@` is optional because some runners
68
+ * (behave) strip it from the tags they report.
69
+ */
70
+ export const DIGEST_TAG_RE = /^@?loam-digest-([0-9a-f]{16})$/;
71
+ /**
72
+ * Line 1 of every generated file — the AGENTS.md stamp pattern, in Gherkin
73
+ * comment syntax. The version records which loam wrote the file; the rest is
74
+ * the ownership rule in the reader's face, because `loam/` files are rewritten
75
+ * wholesale on regeneration and a hand edit would be silently destroyed.
76
+ */
77
+ export function gherkinStampLine(version) {
78
+ return `# generated by loam v${version} — \`loam gherkin\`; do not hand-edit, regenerate instead`;
79
+ }
80
+ const STAMP_RE = /^#\s*generated by loam v(\S+)/;
81
+ /* ------------------------------------------------------------------ */
82
+ /* Step conversion */
83
+ /* ------------------------------------------------------------------ */
84
+ const STEP_KEYWORD_RE = /^(given|when|then|and|but)$/i;
85
+ /**
86
+ * Is this scenario body line a step? A step is a LIST BULLET (`-`, `*`, `+`)
87
+ * whose text opens with Given/When/Then/And/But — case-insensitive, so the
88
+ * OpenSpec `- **WHEN** ...` convention counts — where the keyword may be
89
+ * wrapped in `**bold**` and may carry a trailing colon. The keyword must stand
90
+ * alone before the step text (`- Givenx` is prose). Everything that is not a
91
+ * step stays scenario description: prose is never dropped, only steps are
92
+ * promoted.
93
+ */
94
+ export function stepFromLine(line) {
95
+ const bullet = /^\s*[-*+]\s+(.*)$/.exec(line);
96
+ if (!bullet)
97
+ return null;
98
+ const rest = bullet[1];
99
+ const bold = /^\*\*([^*]+?)\*\*\s*(.*)$/.exec(rest);
100
+ let lead;
101
+ let tail;
102
+ if (bold) {
103
+ lead = bold[1].trim();
104
+ tail = bold[2];
105
+ }
106
+ else {
107
+ const sp = /^(\S+)\s*(.*)$/.exec(rest);
108
+ if (!sp)
109
+ return null;
110
+ lead = sp[1];
111
+ tail = sp[2];
112
+ }
113
+ const kw = STEP_KEYWORD_RE.exec(lead.replace(/:$/, ""));
114
+ if (!kw)
115
+ return null;
116
+ const word = kw[1].toLowerCase();
117
+ return { keyword: word[0].toUpperCase() + word.slice(1), text: tail.trim() };
118
+ }
119
+ /**
120
+ * A scenario body split for rendering: bullet lines that read as steps become
121
+ * steps, every other non-blank line is kept (edge-trimmed) as the scenario's
122
+ * description. Description renders BEFORE the steps whatever the source order —
123
+ * Gherkin's grammar ends a description at the first step keyword — so prose
124
+ * survives verbatim but interleaving does not.
125
+ */
126
+ export function scenarioGherkin(lines) {
127
+ const description = [];
128
+ const steps = [];
129
+ for (const line of lines) {
130
+ if (line.trim().length === 0)
131
+ continue;
132
+ const step = stepFromLine(line);
133
+ if (step)
134
+ steps.push(step.text.length > 0 ? `${step.keyword} ${step.text}` : step.keyword);
135
+ else
136
+ description.push(line.trim());
137
+ }
138
+ return { description, steps };
139
+ }
140
+ /* ------------------------------------------------------------------ */
141
+ /* Emission */
142
+ /* ------------------------------------------------------------------ */
143
+ /** The axis label the machine contract speaks. */
144
+ export function axisLabel(axis) {
145
+ return axis.key === "archSpec" ? "arch" : "business";
146
+ }
147
+ /**
148
+ * A requirement name as a file name: lowercase, every non-letter/digit run one
149
+ * hyphen, edges trimmed.
150
+ *
151
+ * The keep-set is `\p{L}\p{N}`, not `[a-z0-9]`, because an ASCII-only slug is
152
+ * not a slug of a non-Latin fleet — it is the empty string. Every requirement
153
+ * written in Chinese, Japanese, Korean, Greek, Cyrillic or Hebrew slugged to
154
+ * nothing, fell back to `requirement`, and the whole service's suite came out
155
+ * as `requirement.feature`, `requirement-2.feature`, … : file names that name
156
+ * nothing, in the one place the mapping is supposed to be legible. So letters
157
+ * of every script survive as themselves.
158
+ *
159
+ * Accents are folded rather than kept, and that is the reason for NFKD first:
160
+ * decomposing splits `é` into `e` + a combining acute, and dropping the
161
+ * combining marks (`\p{M}`) leaves `e`. Without the fold `émojis` and `emojis`
162
+ * would be two files that look identical in a directory listing — and on a
163
+ * filesystem that normalises names (APFS, and HFS+ before it) they would be one
164
+ * file, silently, with the second emission overwriting the first.
165
+ *
166
+ * A name this still leaves empty (all punctuation or emoji) falls back to
167
+ * "requirement" and lets the collision counter number it. Runs collapse to a
168
+ * SINGLE hyphen, which is what keeps the `arch--` prefix an axis marker no
169
+ * business slug can spell.
170
+ */
171
+ export function slugOf(name) {
172
+ const s = name
173
+ .normalize("NFKD")
174
+ .replace(/\p{M}+/gu, "")
175
+ .toLowerCase()
176
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
177
+ .replace(/^-+|-+$/g, "");
178
+ return s.length > 0 ? s : "requirement";
179
+ }
180
+ /**
181
+ * The emission plan for one scope: one `.feature` per requirement.
182
+ *
183
+ * File naming is the mapping's contract, so it is spelled here once:
184
+ * `<slug>.feature` for the business axis, `arch--<slug>.feature` for the
185
+ * architecture axis — flat and diffable, and collision-proof across axes
186
+ * because slugs collapse punctuation runs to a single hyphen, so no business
187
+ * slug can ever begin `arch--`. Two requirements slugging identically are
188
+ * disambiguated in document order: the first keeps the bare slug, later ones
189
+ * take `-2`, `-3`, … (counting up past any name that already owns the suffix).
190
+ *
191
+ * Tags ride the `Feature:` line and are inherited by every scenario in the
192
+ * file: `@<FEAT>` in feature mode (living emissions carry no feature tag),
193
+ * `@architecture` on the arch axis.
194
+ *
195
+ * `opts.service` is the suite's owner and salts every digest stamp — the same
196
+ * service `loam verify` files the matching claims under. Required, not
197
+ * defaulted: an emission that guessed it would stamp tags no claim matches.
198
+ */
199
+ export function planEmission(byAxis, opts) {
200
+ const used = new Set();
201
+ const out = [];
202
+ for (const { axis, reqs } of byAxis) {
203
+ for (const r of reqs) {
204
+ const base = (axis.key === "archSpec" ? "arch--" : "") + slugOf(r.name);
205
+ let fileName = `${base}.feature`;
206
+ for (let n = 2; used.has(fileName); n += 1)
207
+ fileName = `${base}-${n}.feature`;
208
+ used.add(fileName);
209
+ const tags = [
210
+ ...(opts.featureTag === undefined ? [] : [`@${opts.featureTag}`]),
211
+ ...(axis.key === "archSpec" ? ["@architecture"] : []),
212
+ ];
213
+ const { content, digests, stepless } = renderFeature(r, tags, opts.version, opts.service, axisLabel(axis));
214
+ out.push({ fileName, axis, requirement: r, digests, stepless, content });
215
+ }
216
+ }
217
+ return out;
218
+ }
219
+ /**
220
+ * One requirement as one `.feature` file: `Feature:` is the requirement name,
221
+ * its body text (Operations:/Covers: lines included — they are part of what
222
+ * the requirement says) becomes the feature description, and each scenario
223
+ * renders under its `@loam-digest-…` tag — the tag line sits directly above
224
+ * the `Scenario:` keyword, where Gherkin puts scenario-level tags, so the
225
+ * runner attaches it to the scenario and carries it into the JSON report.
226
+ * Deterministic to the byte.
227
+ *
228
+ * `service` is the owner of the suite being written, and it salts every digest
229
+ * — the same service `loam verify` files the matching claims under. The two
230
+ * have to be the same string or the stamps this writes match no claim at all.
231
+ */
232
+ export function renderFeature(r, tags, version, service, axis = "business") {
233
+ const lines = [gherkinStampLine(version)];
234
+ if (tags.length > 0)
235
+ lines.push(tags.join(" "));
236
+ lines.push(`Feature: ${r.name}`);
237
+ const text = r.text.join("\n").trim();
238
+ if (text.length > 0) {
239
+ lines.push("");
240
+ for (const l of text.split("\n"))
241
+ lines.push(l.length > 0 ? ` ${l}` : "");
242
+ }
243
+ const digests = [];
244
+ const stepless = [];
245
+ for (const s of r.scenarios) {
246
+ const digest = scenarioDigest(service, s.lines, axis);
247
+ digests.push(digest);
248
+ lines.push("", ` ${scenarioDigestTag(digest)}`, ` Scenario: ${s.name}`);
249
+ const { description, steps } = scenarioGherkin(s.lines);
250
+ if (steps.length === 0)
251
+ stepless.push(s.name);
252
+ for (const d of description)
253
+ lines.push(` ${d}`);
254
+ for (const st of steps)
255
+ lines.push(` ${st}`);
256
+ }
257
+ return { content: lines.join("\n") + "\n", digests, stepless };
258
+ }
259
+ /**
260
+ * Parse a `.feature` file for its loam stamps. Null when line 1 is not the
261
+ * stamp — an unstamped file is not loam's writing, and no staleness judgment
262
+ * may be built on it. Tag lines above `Feature:` are the file's tags; a tag
263
+ * line after it that carries a `@loam-digest-…` token binds that digest to the
264
+ * immediately following `Scenario:` line (tag lines stack, as Gherkin's do,
265
+ * without breaking the bond); any other non-blank line between them breaks it.
266
+ */
267
+ export function parseStampedFeature(text) {
268
+ const lines = text.split(/\r?\n/);
269
+ const m = STAMP_RE.exec(lines[0] ?? "");
270
+ if (!m)
271
+ return null;
272
+ const tags = [];
273
+ let featureName = null;
274
+ const scenarios = [];
275
+ let pending = null;
276
+ for (const line of lines.slice(1)) {
277
+ if (/^\s*@/.test(line)) {
278
+ for (const t of line.trim().split(/\s+/)) {
279
+ if (featureName === null) {
280
+ if (t.startsWith("@"))
281
+ tags.push(t.slice(1));
282
+ continue;
283
+ }
284
+ const d = DIGEST_TAG_RE.exec(t);
285
+ if (d)
286
+ pending = d[1];
287
+ }
288
+ continue;
289
+ }
290
+ const f = /^\s*Feature:\s*(.*)$/.exec(line);
291
+ if (f && featureName === null) {
292
+ featureName = f[1].trim();
293
+ continue;
294
+ }
295
+ const s = /^\s*Scenario(?: Outline)?:\s*(.*)$/.exec(line);
296
+ if (s) {
297
+ if (pending !== null)
298
+ scenarios.push({ name: s[1].trim(), digest: pending });
299
+ pending = null;
300
+ continue;
301
+ }
302
+ if (pending !== null && line.trim().length > 0)
303
+ pending = null;
304
+ }
305
+ return { version: m[1], tags, featureName, scenarios };
306
+ }
307
+ /* ------------------------------------------------------------------ */
308
+ /* Staleness — what `loam validate` reports about a generated suite */
309
+ /* ------------------------------------------------------------------ */
310
+ /**
311
+ * The gherkin freshness chain, service-repo-scoped like `sources.*`: it needs
312
+ * the repo (the suite lives there), and it stays quiet until
313
+ * `<gherkinDir>/loam/` exists — a service that never generated has not opted
314
+ * in, and silence there is honest, not vacuous.
315
+ *
316
+ * The LIVING specs are the reference, with one carve-out. A stamped file
317
+ * tagged with a feature still in flight answers to that feature's delta, not
318
+ * to the living spec its requirements have not merged into yet — so it is
319
+ * skipped here (its regeneration is `loam gherkin <FEAT>`), and the moment the
320
+ * feature archives, its requirements ARE living requirements, digests
321
+ * unchanged, and the same file grades current with no rewrite. A tag naming
322
+ * no active feature (the feature was archived, or abandoned) gets no
323
+ * exemption: the living spec is all there is to answer to.
324
+ *
325
+ * Three warnings, one per way the suite and the spec can disagree, judged
326
+ * digest-first and by requirement name second:
327
+ *
328
+ * - `gherkin.missing` — a living scenario whose digest no stamped scenario of
329
+ * its axis carries: the suite has no test for these words.
330
+ * - `gherkin.stale` — a stamped scenario whose digest matches no living
331
+ * scenario while its file's requirement still exists: the spec moved under
332
+ * the generated file. (A reworded scenario therefore reports both: the old
333
+ * words are stale, the new words are missing — one regeneration clears
334
+ * both.)
335
+ * - `gherkin.orphaned` — a whole file whose requirement no longer exists in
336
+ * its axis's living spec; reported once per file, because every scenario in
337
+ * it is moot together.
338
+ *
339
+ * Digests are content identity within one service and one axis, deliberately:
340
+ * two identically-worded scenarios of one service's one axis share one digest,
341
+ * so one stamped copy covers both — while the axes stay separate namespaces
342
+ * twice over (every comparison here is axis-scoped, and the recipe salts the
343
+ * arch axis), and the services stay separate because the recipe salts the
344
+ * service too. That last salt is why a stamped file copied out of another
345
+ * service's repository grades `gherkin.orphaned`/`gherkin.stale` here instead
346
+ * of quietly counting as this service's coverage.
347
+ *
348
+ * All warnings — `--strict` is the CI escalation — plus one `ok` confirmation
349
+ * (`gherkin.current`, the `sources.current` pattern) when a generated suite
350
+ * exists and nothing disagrees.
351
+ */
352
+ export async function gherkinFindings(ctx) {
353
+ if (ctx.repoDir === undefined)
354
+ return [];
355
+ let root;
356
+ try {
357
+ root = gherkinRoot(ctx.repoDir, ctx.gherkinDir);
358
+ }
359
+ catch (err) {
360
+ if (!(err instanceof UnsafePathError))
361
+ throw err;
362
+ return [
363
+ {
364
+ severity: "error",
365
+ code: "gherkin.path-outside",
366
+ subject: ctx.service,
367
+ message: `The configured Gherkin output is unsafe: ${err.message}. It must stay inside the service repo.`,
368
+ },
369
+ ];
370
+ }
371
+ if (!existsSync(root))
372
+ return [];
373
+ const paths = servicePaths(ctx.docsDir, ctx.service);
374
+ const axes = new Map();
375
+ for (const axis of SPEC_AXES) {
376
+ const label = axisLabel(axis);
377
+ const state = { file: axis.file, reqNames: new Set(), digests: new Set(), scenarios: [] };
378
+ const path = paths[axis.key];
379
+ if (existsSync(path)) {
380
+ const reqs = ctx.fleet === undefined
381
+ ? parseRequirements(await readFile(path, "utf8"))
382
+ : await ctx.fleet.readRequirements(path);
383
+ for (const r of reqs) {
384
+ if (r.kind === "REMOVED")
385
+ continue;
386
+ state.reqNames.add(r.name);
387
+ for (const s of r.scenarios) {
388
+ const digest = scenarioDigest(ctx.service, s.lines, label);
389
+ state.digests.add(digest);
390
+ state.scenarios.push({ req: r.name, name: s.name, digest });
391
+ }
392
+ }
393
+ }
394
+ axes.set(label, state);
395
+ }
396
+ // The stamped side. Every stamped digest counts toward coverage — an
397
+ // in-flight file whose scenario matches the living words IS a test for them.
398
+ const active = new Set((await listFeatures(ctx.docsDir, {}, ctx.fleet)).map((f) => f.id));
399
+ const stampedDigests = { business: new Set(), arch: new Set() };
400
+ const parsed = [];
401
+ let stampedScenarios = 0;
402
+ for (const abs of await featureFilesUnder(root)) {
403
+ const file = parseStampedFeature(await readFile(abs, "utf8"));
404
+ if (file === null)
405
+ continue;
406
+ const axis = file.tags.includes("architecture") ? "arch" : "business";
407
+ for (const s of file.scenarios)
408
+ stampedDigests[axis].add(s.digest);
409
+ stampedScenarios += file.scenarios.length;
410
+ parsed.push({ rel: relative(ctx.repoDir, abs).split(sep).join("/"), axis, file });
411
+ }
412
+ const findings = [];
413
+ const regen = `\`loam gherkin --service ${ctx.service}\``;
414
+ for (const [axis, state] of axes) {
415
+ for (const sc of state.scenarios) {
416
+ if (stampedDigests[axis].has(sc.digest))
417
+ continue;
418
+ findings.push({
419
+ severity: "warn",
420
+ code: "gherkin.missing",
421
+ subject: ctx.service,
422
+ 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`,
423
+ });
424
+ }
425
+ }
426
+ for (const { rel, axis, file } of parsed) {
427
+ if (file.tags.some((t) => active.has(t)))
428
+ continue;
429
+ const state = axes.get(axis);
430
+ if (file.featureName === null || !state.reqNames.has(file.featureName)) {
431
+ findings.push({
432
+ severity: "warn",
433
+ code: "gherkin.orphaned",
434
+ subject: ctx.service,
435
+ message: `${ctx.service}: ${rel}: requirement '${file.featureName ?? "(unnamed)"}' no longer exists in the living ${state.file} — the whole file is orphaned; ${regen} removes it`,
436
+ });
437
+ continue;
438
+ }
439
+ for (const s of file.scenarios) {
440
+ if (state.digests.has(s.digest))
441
+ continue;
442
+ findings.push({
443
+ severity: "warn",
444
+ code: "gherkin.stale",
445
+ subject: ctx.service,
446
+ 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`,
447
+ });
448
+ }
449
+ }
450
+ if (findings.length === 0) {
451
+ findings.push({
452
+ severity: "ok",
453
+ code: "gherkin.current",
454
+ message: `${ctx.service}: generated gherkin current (${parsed.length} file(s), ${stampedScenarios} stamped scenario(s) agree with the living specs)`,
455
+ });
456
+ }
457
+ return findings;
458
+ }
459
+ /**
460
+ * Every `.feature` file at or under `root`, sorted by repo-relative path —
461
+ * enumeration is part of the output contract, so the order cannot depend on
462
+ * the filesystem's. Dot-entries are skipped, like every other walk in loam.
463
+ *
464
+ * Symlinks are FOLLOWED — repo.ts's `entryIs` and brief.ts's `hasMarkdown` are
465
+ * the same rule for the same reason. A `Dirent` from `readdir(withFileTypes)`
466
+ * never follows one, so `isFile()` and `isDirectory()` are both false for a
467
+ * symlinked `.feature`: it was invisible to the orphan scan (`loam gherkin`
468
+ * left it behind forever) and invisible to the staleness scan (it graded as no
469
+ * coverage at all). Composing a suite out of links is legitimate, so they are
470
+ * followed rather than refused, and a dangling one is skipped exactly as an
471
+ * absent file is.
472
+ */
473
+ export async function featureFilesUnder(root) {
474
+ const out = [];
475
+ // Following links means a cycle is now possible; every directory is entered
476
+ // once by its resolved path, so a loop terminates instead of hanging.
477
+ const entered = new Set();
478
+ const walk = async (dir) => {
479
+ if (!existsSync(dir))
480
+ return;
481
+ let resolved;
482
+ try {
483
+ resolved = await realpath(dir);
484
+ }
485
+ catch {
486
+ return;
487
+ }
488
+ if (entered.has(resolved))
489
+ return;
490
+ entered.add(resolved);
491
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
492
+ if (entry.name.startsWith("."))
493
+ continue;
494
+ const abs = join(dir, entry.name);
495
+ const what = entryKind(abs, entry);
496
+ if (what === "dir")
497
+ await walk(abs);
498
+ else if (what === "file" && entry.name.endsWith(".feature"))
499
+ out.push(abs);
500
+ }
501
+ };
502
+ await walk(root);
503
+ return out.sort((a, b) => {
504
+ const ra = a.split(sep).join("/");
505
+ const rb = b.split(sep).join("/");
506
+ return ra < rb ? -1 : ra > rb ? 1 : 0;
507
+ });
508
+ }
509
+ /** What an entry IS, resolving a symlink to find out. Nothing when it dangles. */
510
+ function entryKind(abs, e) {
511
+ if (!e.isSymbolicLink())
512
+ return e.isDirectory() ? "dir" : e.isFile() ? "file" : null;
513
+ try {
514
+ const s = statSync(abs);
515
+ return s.isDirectory() ? "dir" : s.isFile() ? "file" : null;
516
+ }
517
+ catch {
518
+ return null;
519
+ }
520
+ }
521
+ //# 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 { 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. */\nexport const DEFAULT_GHERKIN_DIR = \"features\";\n\n/** The subdirectory loam owns outright. Everything loam writes stays inside it. */\nexport const 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/* Step conversion */\n/* ------------------------------------------------------------------ */\n\nconst STEP_KEYWORD_RE = /^(given|when|then|and|but)$/i;\n\nexport interface Step {\n /** Canonical Gherkin keyword: Given / When / Then / And / But. */\n keyword: string;\n /** The rest of the line, trimmed. May be empty. */\n text: string;\n}\n\n/**\n * Is this scenario body line a step? A step is a LIST BULLET (`-`, `*`, `+`)\n * whose text opens with Given/When/Then/And/But — case-insensitive, so the\n * OpenSpec `- **WHEN** ...` convention counts — where the keyword may be\n * wrapped in `**bold**` and may carry a trailing colon. The keyword must stand\n * alone before the step text (`- Givenx` is prose). Everything that is not a\n * step stays scenario description: prose is never dropped, only steps are\n * promoted.\n */\nexport function stepFromLine(line: string): Step | null {\n const bullet = /^\\s*[-*+]\\s+(.*)$/.exec(line);\n if (!bullet) return null;\n const rest = bullet[1]!;\n const bold = /^\\*\\*([^*]+?)\\*\\*\\s*(.*)$/.exec(rest);\n let lead: string;\n let tail: string;\n if (bold) {\n lead = bold[1]!.trim();\n tail = bold[2]!;\n } else {\n const sp = /^(\\S+)\\s*(.*)$/.exec(rest);\n if (!sp) return null;\n lead = sp[1]!;\n tail = sp[2]!;\n }\n const kw = STEP_KEYWORD_RE.exec(lead.replace(/:$/, \"\"));\n if (!kw) return null;\n const word = kw[1]!.toLowerCase();\n return { keyword: word[0]!.toUpperCase() + word.slice(1), text: tail.trim() };\n}\n\n/**\n * A scenario body split for rendering: bullet lines that read as steps become\n * steps, every other non-blank line is kept (edge-trimmed) as the scenario's\n * description. Description renders BEFORE the steps whatever the source order —\n * Gherkin's grammar ends a description at the first step keyword — so prose\n * survives verbatim but interleaving does not.\n */\nexport function scenarioGherkin(lines: string[]): { description: string[]; steps: string[] } {\n const description: string[] = [];\n const steps: string[] = [];\n for (const line of lines) {\n if (line.trim().length === 0) continue;\n const step = stepFromLine(line);\n if (step) steps.push(step.text.length > 0 ? `${step.keyword} ${step.text}` : step.keyword);\n else description.push(line.trim());\n }\n return { description, steps };\n}\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"]}