@ybotok/loam 0.1.0-beta.1 → 0.1.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/README.md +6 -1
- package/SCHEMA.md +22 -4
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/adopt.js +17 -3
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/archive.js +24 -8
- package/dist/commands/archive.js.map +1 -1
- package/dist/commands/delta.js +74 -47
- package/dist/commands/delta.js.map +1 -1
- package/dist/commands/dependencies.js +7 -2
- package/dist/commands/dependencies.js.map +1 -1
- package/dist/commands/docs-repo-gate.js +104 -0
- package/dist/commands/docs-repo-gate.js.map +1 -0
- package/dist/commands/explore.js +195 -0
- package/dist/commands/explore.js.map +1 -0
- package/dist/commands/format.js +17 -0
- package/dist/commands/format.js.map +1 -0
- package/dist/commands/gherkin.js +87 -48
- package/dist/commands/gherkin.js.map +1 -1
- package/dist/commands/init.js +3 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/instructions.js +80 -0
- package/dist/commands/instructions.js.map +1 -0
- package/dist/commands/list.js +10 -76
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/migrate-openspec.js +45 -28
- package/dist/commands/migrate-openspec.js.map +1 -1
- package/dist/commands/new.js +35 -34
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/rebase.js +2 -4
- package/dist/commands/rebase.js.map +1 -1
- package/dist/commands/show.js +26 -31
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/status.js +17 -25
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/unarchive.js +12 -5
- package/dist/commands/unarchive.js.map +1 -1
- package/dist/commands/validate.js +306 -142
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/verify.js +33 -17
- package/dist/commands/verify.js.map +1 -1
- package/dist/commands/vouch.js +156 -60
- package/dist/commands/vouch.js.map +1 -1
- package/dist/core/agent.js +392 -42
- package/dist/core/agent.js.map +1 -1
- package/dist/core/agents-stamp.js +47 -10
- package/dist/core/agents-stamp.js.map +1 -1
- package/dist/core/arch.js +28 -2
- package/dist/core/arch.js.map +1 -1
- package/dist/core/asyncapi.js +253 -0
- package/dist/core/asyncapi.js.map +1 -0
- package/dist/core/brief.js +28 -6
- package/dist/core/brief.js.map +1 -1
- package/dist/core/coherence.js +37 -13
- package/dist/core/coherence.js.map +1 -1
- package/dist/core/concurrency.js +51 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/config.js +10 -2
- package/dist/core/config.js.map +1 -1
- package/dist/core/delta.js +21 -2
- package/dist/core/delta.js.map +1 -1
- package/dist/core/dependencies.js +46 -42
- package/dist/core/dependencies.js.map +1 -1
- package/dist/core/docs.js +65 -3
- package/dist/core/docs.js.map +1 -1
- package/dist/core/doctor.js +79 -27
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/document-bytes.js +59 -0
- package/dist/core/document-bytes.js.map +1 -0
- package/dist/core/explore.js +225 -0
- package/dist/core/explore.js.map +1 -0
- package/dist/core/fleet-context.js +29 -63
- package/dist/core/fleet-context.js.map +1 -1
- package/dist/core/frontmatter.js +1 -5
- package/dist/core/frontmatter.js.map +1 -1
- package/dist/core/gherkin.js +3 -61
- package/dist/core/gherkin.js.map +1 -1
- package/dist/core/health.js +1 -5
- package/dist/core/health.js.map +1 -1
- package/dist/core/ids.js +34 -5
- package/dist/core/ids.js.map +1 -1
- package/dist/core/json.js +8 -1
- package/dist/core/json.js.map +1 -1
- package/dist/core/likec4.js +19 -4
- package/dist/core/likec4.js.map +1 -1
- package/dist/core/maturity.js +66 -0
- package/dist/core/maturity.js.map +1 -0
- package/dist/core/openapi-merge.js +39 -6
- package/dist/core/openapi-merge.js.map +1 -1
- package/dist/core/openapi.js +17 -5
- package/dist/core/openapi.js.map +1 -1
- package/dist/core/openspec-inventory.js +64 -27
- package/dist/core/openspec-inventory.js.map +1 -1
- package/dist/core/path-safety.js +59 -0
- package/dist/core/path-safety.js.map +1 -1
- package/dist/core/provenance.js +102 -10
- package/dist/core/provenance.js.map +1 -1
- package/dist/core/records.js +33 -0
- package/dist/core/records.js.map +1 -0
- package/dist/core/repo.js +77 -1
- package/dist/core/repo.js.map +1 -1
- package/dist/core/report.js +9 -0
- package/dist/core/report.js.map +1 -1
- package/dist/core/results.js +1 -3
- package/dist/core/results.js.map +1 -1
- package/dist/core/spec.js +28 -4
- package/dist/core/spec.js.map +1 -1
- package/dist/core/staging.js +124 -17
- package/dist/core/staging.js.map +1 -1
- package/dist/core/status.js +152 -35
- package/dist/core/status.js.map +1 -1
- package/dist/core/steps.js +72 -0
- package/dist/core/steps.js.map +1 -0
- package/dist/core/verify.js +18 -4
- package/dist/core/verify.js.map +1 -1
- package/dist/core/version.js +17 -1
- package/dist/core/version.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"frontmatter.js","sourceRoot":"","sources":["../../src/core/frontmatter.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * YAML frontmatter — the provenance of an artifact.\n *\n * `status` says whether a human has vouched for the document, `sources` says\n * which code it was written from. When the prose is written by an agent, these\n * are the only deterministic tie to reality: everything else loam checks is\n * internal consistency, which a fluent enough fiction satisfies perfectly.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport { isMap, parse, parseDocument } from \"yaml\";\n// The decode rule lives with the reads it was written for (fleet-context.ts);\n// importing it back here closes a cycle through repo.ts, which is harmless for\n// the same reason spec.ts↔gherkin.ts is: nothing is used at module scope, so no\n// evaluation order can observe a half-built module.\nimport { decodeDocument, NotUtf8DocumentError } from \"./fleet-context.js\";\n\nexport interface Frontmatter {\n /** True when a terminated `---` block was found, even if it was empty. */\n present: boolean;\n /**\n * True when the block was found but its YAML cannot be read as a mapping —\n * a parse error, or a scalar/sequence document. `data` is then {}, which is\n * NOT the same fact as an empty header: the fields are unreadable, not\n * absent, and a checker that grades them \"missing\" sends the author adding\n * lines to a block YAML refuses to parse. Validate turns this flag into\n * `frontmatter.malformed`; the writer (withFrontmatterFields) has its own\n * replace-don't-merge rule for the same state.\n */\n malformed: boolean;\n /**\n * Why the FILE could not be read as text at all, or absent when it could —\n * set only by `readFrontmatter`, since a caller that already holds a string\n * has nothing left to decode.\n *\n * A third state, not a shade of the other two: `present: false` here means\n * nobody could look, where everywhere else it means nobody wrote a header.\n * A checker must ask this first, or it reports \"owner/status/sources missing\"\n * over a file whose lines are right there — the same false cascade\n * `malformed` exists to stop, one layer lower down.\n */\n unreadable?: string;\n /** The parsed mapping, or {} when absent or malformed. */\n data: Record<string, unknown>;\n /** The document with the frontmatter removed. */\n body: string;\n}\n\n/** Where the header sits in the raw text. */\ninterface Bounds {\n /** First character of the YAML text. */\n start: number;\n /** One past its last character — the newline before the closing fence included. */\n end: number;\n /** First character of the body. */\n bodyStart: number;\n}\n\n/**\n * Drop a leading byte-order mark.\n *\n * With one in front, the opening `---` is no longer at position 0, so the whole\n * header reads as absent: a documented artifact silently becomes an\n * undocumented one — no owner, no status, no `sources` — and nothing says why.\n * The same blindness `spec.ts` had at `^##`.\n *\n * Only at position 0: elsewhere U+FEFF is a zero-width no-break space and is the\n * author's content. Callers strip before measuring, so the offsets `bounds`\n * returns index the same string the caller then slices.\n */\nfunction stripBom(text: string): string {\n return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n}\n\n/**\n * A fence is a LINE of exactly three dashes. Trailing whitespace is tolerated\n * (`\\s` also swallows the CR of a CRLF file), trailing text is not: `--- title`\n * is prose, `----` is a horizontal rule. Matching the substring `---` instead —\n * as this used to — made both of those fences, so a stray rule closed the\n * header early and a `--- note` closer silently ate its own trailing text.\n */\nconst FENCE_RE = /^---\\s*$/;\n\n/**\n * Locate the frontmatter block. The opener is the very first line, the closer\n * the first subsequent fence line — a later horizontal rule is body, not a\n * second fence. No closer means an unterminated opener: not frontmatter at all.\n *\n * The reader and the writer both go through this, so they can never disagree\n * about which bytes are the header — the writer's promise to leave the body\n * untouched depends on that being one decision, not two.\n */\nfunction bounds(md: string): Bounds | null {\n const firstNl = md.indexOf(\"\\n\");\n if (!FENCE_RE.test(firstNl === -1 ? md : md.slice(0, firstNl))) return null;\n // A lone `---` with nothing after it is an unterminated opener.\n if (firstNl === -1) return null;\n const start = firstNl + 1;\n for (let lineStart = start; ; ) {\n const nl = md.indexOf(\"\\n\", lineStart);\n const line = nl === -1 ? md.slice(lineStart) : md.slice(lineStart, nl);\n if (FENCE_RE.test(line)) {\n // An empty block (`---\\n---`) has no text between the fences: start === end.\n return { start, end: lineStart, bodyStart: nl === -1 ? md.length : nl + 1 };\n }\n if (nl === -1) return null;\n lineStart = nl + 1;\n }\n}\n\n/**\n * Split a markdown document into its frontmatter and body. A document with no\n * frontmatter is returned whole rather than truncated.\n */\nexport function parseFrontmatter(source: string): Frontmatter {\n const md = stripBom(source);\n const at = bounds(md);\n if (at === null) return { present: false, malformed: false, data: {}, body: md };\n\n const yamlText = md.slice(at.start, at.end);\n const body = md.slice(at.bodyStart).trimStart();\n\n let data: Record<string, unknown> = {};\n let malformed = false;\n try {\n const parsed: unknown = parse(yamlText);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n data = parsed as Record<string, unknown>;\n } else if (parsed !== null) {\n // A scalar or sequence header holds no fields to read — as unreadable as\n // broken YAML. null is the empty block (`---\\n---`): legal, and empty.\n malformed = true;\n }\n } catch {\n // A bad header must not make the document unreadable (the body is still the\n // body, and never crashes a command) — but the failure travels as a flag,\n // so the caller can say `frontmatter.malformed` instead of the false\n // cascade \"owner/status missing\" the silent {} used to produce.\n malformed = true;\n }\n return { present: true, malformed, data, body };\n}\n\n/** A field as text. Non-string scalars (a bare date, a number) are stringified. */\nexport function stringField(fm: Frontmatter, key: string): string | undefined {\n const v = fm.data[key];\n if (v === undefined || v === null) return undefined;\n if (typeof v === \"string\") return v;\n if (typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n if (v instanceof Date) return v.toISOString().slice(0, 10);\n return undefined;\n}\n\n/** A field as a list. A lone scalar counts as a list of one. */\nexport function listField(fm: Frontmatter, key: string): string[] {\n const v = fm.data[key];\n if (v === undefined || v === null) return [];\n if (Array.isArray(v)) return v.map((x) => String(x)).filter((s) => s.length > 0);\n const one = stringField(fm, key);\n return one === undefined ? [] : [one];\n}\n\n/**\n * Return `md` with these frontmatter fields set and everything below the closing\n * fence byte-identical.\n *\n * The header is edited through yaml's document model rather than rebuilt from\n * the parsed data: keys keep their order and their comments, fields nobody named\n * are left exactly as they were, and a value that would read back as something\n * else — a digest of all digits, say — gets quoted for us. Stamping a document\n * is not a licence to reformat it: the diff a reviewer sees should be the lines\n * that changed, and nothing else.\n *\n * A document with no frontmatter gets one, and the text that was there becomes\n * the body. A header that does not parse (or is not a mapping) is replaced\n * rather than merged into — it cannot be edited without guessing what it meant.\n */\nexport function withFrontmatterFields(source: string, fields: Record<string, string>): string {\n // Stamping a BOM-prefixed document drops the BOM. It sits above the header, so\n // the promise about the body still holds — and leaving it would re-break the\n // file for every parser anchored at position 0, including ours.\n const md = stripBom(source);\n const at = bounds(md);\n const parsed = parseDocument(at === null ? \"\" : md.slice(at.start, at.end));\n const editable = parsed.errors.length === 0 && (parsed.contents === null || isMap(parsed.contents));\n const doc = editable ? parsed : parseDocument(\"\");\n\n for (const [key, value] of Object.entries(fields)) doc.set(key, value);\n const yamlText = doc.toString();\n\n if (at === null) return `---\\n${yamlText}---\\n\\n${md}`;\n return md.slice(0, at.start) + yamlText + md.slice(at.end);\n}\n\n/**\n * The document's body, byte-exact: everything after the frontmatter block —\n * the closing `---` line and its newline belong to the HEADER, every byte\n * after them (blank lines included) is body. A document with no frontmatter is\n * all body. This is what `content_digest` hashes, and it deliberately does NOT\n * go through `parseFrontmatter` (whose `body` is trimmed for callers that want\n * text): the digest's writer and its checker must slice the same bytes, so\n * both go through here.\n */\nexport function rawBody(source: string): string {\n const md = stripBom(source);\n const at = bounds(md);\n return at === null ? md : md.slice(at.bodyStart);\n}\n\n/**\n * Read a markdown file's frontmatter; a missing file reads as absent.\n *\n * Bytes in, decoded through `decodeDocument`, because `readFile(path, \"utf8\")`\n * turns a UTF-16 spec.md into mojibake whose opening `---` is no longer at\n * position 0 — so the header reads as ABSENT, and `status`, `owner` and\n * `sources` all come back missing over a file that spells every one of them.\n * The same blindness `stripBom` exists to stop, one encoding wider.\n *\n * The failure travels as a FLAG rather than an exception, which is how every\n * reader here degrades (`readOpenapi`'s `unreadable`) and is load-bearing at\n * exactly this call: this is the read behind the fleet ENUMERATION\n * (repo.ts `listServices`), so throwing would turn one PowerShell-saved file\n * into a `loam list` that reports nothing about the other 119 services.\n * Parsers never diagnose; a checker grades the flag.\n */\nexport async function readFrontmatter(path: string): Promise<Frontmatter> {\n if (!existsSync(path)) return { present: false, malformed: false, data: {}, body: \"\" };\n let text: string;\n try {\n text = decodeDocument(await readFile(path), path);\n } catch (err) {\n if (!(err instanceof NotUtf8DocumentError)) throw err;\n return { present: false, malformed: false, unreadable: err.message, data: {}, body: \"\" };\n }\n return parseFrontmatter(text);\n}\n\n/** The statuses each kind of artifact may carry. */\nexport const SERVICE_STATUSES = [\"draft\", \"verified\"] as const;\nexport const FEATURE_STATUSES = [\"proposed\", \"in_progress\", \"built\", \"done\"] as const;\n"]}
|
|
1
|
+
{"version":3,"file":"frontmatter.js","sourceRoot":"","sources":["../../src/core/frontmatter.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * YAML frontmatter — the provenance of an artifact.\n *\n * `status` says whether a human has vouched for the document, `sources` says\n * which code it was written from. When the prose is written by an agent, these\n * are the only deterministic tie to reality: everything else loam checks is\n * internal consistency, which a fluent enough fiction satisfies perfectly.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport { isMap, parse, parseDocument } from \"yaml\";\nimport { decodeDocument, NotUtf8DocumentError } from \"./document-bytes.js\";\n\nexport interface Frontmatter {\n /** True when a terminated `---` block was found, even if it was empty. */\n present: boolean;\n /**\n * True when the block was found but its YAML cannot be read as a mapping —\n * a parse error, or a scalar/sequence document. `data` is then {}, which is\n * NOT the same fact as an empty header: the fields are unreadable, not\n * absent, and a checker that grades them \"missing\" sends the author adding\n * lines to a block YAML refuses to parse. Validate turns this flag into\n * `frontmatter.malformed`; the writer (withFrontmatterFields) has its own\n * replace-don't-merge rule for the same state.\n */\n malformed: boolean;\n /**\n * Why the FILE could not be read as text at all, or absent when it could —\n * set only by `readFrontmatter`, since a caller that already holds a string\n * has nothing left to decode.\n *\n * A third state, not a shade of the other two: `present: false` here means\n * nobody could look, where everywhere else it means nobody wrote a header.\n * A checker must ask this first, or it reports \"owner/status/sources missing\"\n * over a file whose lines are right there — the same false cascade\n * `malformed` exists to stop, one layer lower down.\n */\n unreadable?: string;\n /** The parsed mapping, or {} when absent or malformed. */\n data: Record<string, unknown>;\n /** The document with the frontmatter removed. */\n body: string;\n}\n\n/** Where the header sits in the raw text. */\ninterface Bounds {\n /** First character of the YAML text. */\n start: number;\n /** One past its last character — the newline before the closing fence included. */\n end: number;\n /** First character of the body. */\n bodyStart: number;\n}\n\n/**\n * Drop a leading byte-order mark.\n *\n * With one in front, the opening `---` is no longer at position 0, so the whole\n * header reads as absent: a documented artifact silently becomes an\n * undocumented one — no owner, no status, no `sources` — and nothing says why.\n * The same blindness `spec.ts` had at `^##`.\n *\n * Only at position 0: elsewhere U+FEFF is a zero-width no-break space and is the\n * author's content. Callers strip before measuring, so the offsets `bounds`\n * returns index the same string the caller then slices.\n */\nfunction stripBom(text: string): string {\n return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n}\n\n/**\n * A fence is a LINE of exactly three dashes. Trailing whitespace is tolerated\n * (`\\s` also swallows the CR of a CRLF file), trailing text is not: `--- title`\n * is prose, `----` is a horizontal rule. Matching the substring `---` instead —\n * as this used to — made both of those fences, so a stray rule closed the\n * header early and a `--- note` closer silently ate its own trailing text.\n */\nconst FENCE_RE = /^---\\s*$/;\n\n/**\n * Locate the frontmatter block. The opener is the very first line, the closer\n * the first subsequent fence line — a later horizontal rule is body, not a\n * second fence. No closer means an unterminated opener: not frontmatter at all.\n *\n * The reader and the writer both go through this, so they can never disagree\n * about which bytes are the header — the writer's promise to leave the body\n * untouched depends on that being one decision, not two.\n */\nfunction bounds(md: string): Bounds | null {\n const firstNl = md.indexOf(\"\\n\");\n if (!FENCE_RE.test(firstNl === -1 ? md : md.slice(0, firstNl))) return null;\n // A lone `---` with nothing after it is an unterminated opener.\n if (firstNl === -1) return null;\n const start = firstNl + 1;\n for (let lineStart = start; ; ) {\n const nl = md.indexOf(\"\\n\", lineStart);\n const line = nl === -1 ? md.slice(lineStart) : md.slice(lineStart, nl);\n if (FENCE_RE.test(line)) {\n // An empty block (`---\\n---`) has no text between the fences: start === end.\n return { start, end: lineStart, bodyStart: nl === -1 ? md.length : nl + 1 };\n }\n if (nl === -1) return null;\n lineStart = nl + 1;\n }\n}\n\n/**\n * Split a markdown document into its frontmatter and body. A document with no\n * frontmatter is returned whole rather than truncated.\n */\nexport function parseFrontmatter(source: string): Frontmatter {\n const md = stripBom(source);\n const at = bounds(md);\n if (at === null) return { present: false, malformed: false, data: {}, body: md };\n\n const yamlText = md.slice(at.start, at.end);\n const body = md.slice(at.bodyStart).trimStart();\n\n let data: Record<string, unknown> = {};\n let malformed = false;\n try {\n const parsed: unknown = parse(yamlText);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n data = parsed as Record<string, unknown>;\n } else if (parsed !== null) {\n // A scalar or sequence header holds no fields to read — as unreadable as\n // broken YAML. null is the empty block (`---\\n---`): legal, and empty.\n malformed = true;\n }\n } catch {\n // A bad header must not make the document unreadable (the body is still the\n // body, and never crashes a command) — but the failure travels as a flag,\n // so the caller can say `frontmatter.malformed` instead of the false\n // cascade \"owner/status missing\" the silent {} used to produce.\n malformed = true;\n }\n return { present: true, malformed, data, body };\n}\n\n/** A field as text. Non-string scalars (a bare date, a number) are stringified. */\nexport function stringField(fm: Frontmatter, key: string): string | undefined {\n const v = fm.data[key];\n if (v === undefined || v === null) return undefined;\n if (typeof v === \"string\") return v;\n if (typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n if (v instanceof Date) return v.toISOString().slice(0, 10);\n return undefined;\n}\n\n/** A field as a list. A lone scalar counts as a list of one. */\nexport function listField(fm: Frontmatter, key: string): string[] {\n const v = fm.data[key];\n if (v === undefined || v === null) return [];\n if (Array.isArray(v)) return v.map((x) => String(x)).filter((s) => s.length > 0);\n const one = stringField(fm, key);\n return one === undefined ? [] : [one];\n}\n\n/**\n * Return `md` with these frontmatter fields set and everything below the closing\n * fence byte-identical.\n *\n * The header is edited through yaml's document model rather than rebuilt from\n * the parsed data: keys keep their order and their comments, fields nobody named\n * are left exactly as they were, and a value that would read back as something\n * else — a digest of all digits, say — gets quoted for us. Stamping a document\n * is not a licence to reformat it: the diff a reviewer sees should be the lines\n * that changed, and nothing else.\n *\n * A document with no frontmatter gets one, and the text that was there becomes\n * the body. A header that does not parse (or is not a mapping) is replaced\n * rather than merged into — it cannot be edited without guessing what it meant.\n */\nexport function withFrontmatterFields(source: string, fields: Record<string, string>): string {\n // Stamping a BOM-prefixed document drops the BOM. It sits above the header, so\n // the promise about the body still holds — and leaving it would re-break the\n // file for every parser anchored at position 0, including ours.\n const md = stripBom(source);\n const at = bounds(md);\n const parsed = parseDocument(at === null ? \"\" : md.slice(at.start, at.end));\n const editable = parsed.errors.length === 0 && (parsed.contents === null || isMap(parsed.contents));\n const doc = editable ? parsed : parseDocument(\"\");\n\n for (const [key, value] of Object.entries(fields)) doc.set(key, value);\n const yamlText = doc.toString();\n\n if (at === null) return `---\\n${yamlText}---\\n\\n${md}`;\n return md.slice(0, at.start) + yamlText + md.slice(at.end);\n}\n\n/**\n * The document's body, byte-exact: everything after the frontmatter block —\n * the closing `---` line and its newline belong to the HEADER, every byte\n * after them (blank lines included) is body. A document with no frontmatter is\n * all body. This is what `content_digest` hashes, and it deliberately does NOT\n * go through `parseFrontmatter` (whose `body` is trimmed for callers that want\n * text): the digest's writer and its checker must slice the same bytes, so\n * both go through here.\n */\nexport function rawBody(source: string): string {\n const md = stripBom(source);\n const at = bounds(md);\n return at === null ? md : md.slice(at.bodyStart);\n}\n\n/**\n * Read a markdown file's frontmatter; a missing file reads as absent.\n *\n * Bytes in, decoded through `decodeDocument`, because `readFile(path, \"utf8\")`\n * turns a UTF-16 spec.md into mojibake whose opening `---` is no longer at\n * position 0 — so the header reads as ABSENT, and `status`, `owner` and\n * `sources` all come back missing over a file that spells every one of them.\n * The same blindness `stripBom` exists to stop, one encoding wider.\n *\n * The failure travels as a FLAG rather than an exception, which is how every\n * reader here degrades (`readOpenapi`'s `unreadable`) and is load-bearing at\n * exactly this call: this is the read behind the fleet ENUMERATION\n * (repo.ts `listServices`), so throwing would turn one PowerShell-saved file\n * into a `loam list` that reports nothing about the other 119 services.\n * Parsers never diagnose; a checker grades the flag.\n */\nexport async function readFrontmatter(path: string): Promise<Frontmatter> {\n if (!existsSync(path)) return { present: false, malformed: false, data: {}, body: \"\" };\n let text: string;\n try {\n text = decodeDocument(await readFile(path), path);\n } catch (err) {\n if (!(err instanceof NotUtf8DocumentError)) throw err;\n return { present: false, malformed: false, unreadable: err.message, data: {}, body: \"\" };\n }\n return parseFrontmatter(text);\n}\n\n/** The statuses each kind of artifact may carry. */\nexport const SERVICE_STATUSES = [\"draft\", \"verified\"] as const;\nexport const FEATURE_STATUSES = [\"proposed\", \"in_progress\", \"built\", \"done\"] as const;\n"]}
|
package/dist/core/gherkin.js
CHANGED
|
@@ -27,13 +27,14 @@ import { existsSync, statSync } from "node:fs";
|
|
|
27
27
|
import { readdir, readFile, realpath } from "node:fs/promises";
|
|
28
28
|
import { join, relative, sep } from "node:path";
|
|
29
29
|
import { parseRequirements } from "./spec.js";
|
|
30
|
+
import { scenarioGherkin } from "./steps.js";
|
|
30
31
|
import { listFeatures, servicePaths, SPEC_AXES } from "./repo.js";
|
|
31
32
|
import { scenarioBodyHash } from "./verify.js";
|
|
32
33
|
import { resolveInside, UnsafePathError } from "./path-safety.js";
|
|
33
34
|
/** Where Gherkin lives in a service repo when loam.json does not say. */
|
|
34
|
-
|
|
35
|
+
const DEFAULT_GHERKIN_DIR = "features";
|
|
35
36
|
/** The subdirectory loam owns outright. Everything loam writes stays inside it. */
|
|
36
|
-
|
|
37
|
+
const GHERKIN_SUBDIR = "loam";
|
|
37
38
|
/** How much of the scenario body hash goes into a `@loam-digest-…` tag. */
|
|
38
39
|
export const GHERKIN_DIGEST_LENGTH = 16;
|
|
39
40
|
/** The one directory `loam gherkin` may touch: `<repo>/<gherkinDir>/loam/`. */
|
|
@@ -79,65 +80,6 @@ export function gherkinStampLine(version) {
|
|
|
79
80
|
}
|
|
80
81
|
const STAMP_RE = /^#\s*generated by loam v(\S+)/;
|
|
81
82
|
/* ------------------------------------------------------------------ */
|
|
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
83
|
/* Emission */
|
|
142
84
|
/* ------------------------------------------------------------------ */
|
|
143
85
|
/** The axis label the machine contract speaks. */
|
package/dist/core/gherkin.js.map
CHANGED
|
@@ -1 +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"]}
|
|
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"]}
|
package/dist/core/health.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
24
|
import { readFile } from "node:fs/promises";
|
|
25
25
|
import { parse } from "yaml";
|
|
26
|
-
|
|
26
|
+
const NO_HEALTH_IDS = { slis: [], alerts: [] };
|
|
27
27
|
export async function readHealth(healthPath) {
|
|
28
28
|
if (!existsSync(healthPath))
|
|
29
29
|
return { ids: NO_HEALTH_IDS, unreadable: false };
|
|
@@ -44,10 +44,6 @@ export async function readHealth(healthPath) {
|
|
|
44
44
|
const top = doc;
|
|
45
45
|
return { ids: { slis: idsOf(top["slis"]), alerts: idsOf(top["alerts"]) }, unreadable: false };
|
|
46
46
|
}
|
|
47
|
-
/** The ids alone — for callers whose question is coverage, not readability. */
|
|
48
|
-
export async function readHealthIds(healthPath) {
|
|
49
|
-
return (await readHealth(healthPath)).ids;
|
|
50
|
-
}
|
|
51
47
|
/** The ids a `slis:` / `alerts:` sequence declares — deduped, document order. */
|
|
52
48
|
function idsOf(section) {
|
|
53
49
|
if (!Array.isArray(section))
|
package/dist/core/health.js.map
CHANGED
|
@@ -1 +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,
|
|
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"]}
|
package/dist/core/ids.js
CHANGED
|
@@ -33,7 +33,7 @@ const SERVICE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
|
33
33
|
*/
|
|
34
34
|
const WINDOWS_DEVICE = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/;
|
|
35
35
|
/** Prose form of the rule, shown verbatim in every refusal so the fix is obvious. */
|
|
36
|
-
|
|
36
|
+
const SERVICE_ID_RULE = "a service id must start with a letter or digit and contain only letters, digits, '.', '_' or '-' " +
|
|
37
37
|
"(no slashes, no '..', no spaces) — it becomes the services/<id>/ directory name";
|
|
38
38
|
/** Refusal carrying the offending value, so a caller can quote it back. */
|
|
39
39
|
export class InvalidIdError extends Error {
|
|
@@ -76,10 +76,6 @@ export function serviceIdProblem(id, label = "--service") {
|
|
|
76
76
|
}
|
|
77
77
|
return null;
|
|
78
78
|
}
|
|
79
|
-
/** True when `id` is a usable service id. */
|
|
80
|
-
export function isServiceId(id) {
|
|
81
|
-
return serviceIdProblem(id) === null;
|
|
82
|
-
}
|
|
83
79
|
/**
|
|
84
80
|
* Assert that `id` may be used as a `services/<id>/` directory name. Throws
|
|
85
81
|
* InvalidIdError; commands catch it and report `invalid-option`, because a bad
|
|
@@ -90,4 +86,37 @@ export function assertServiceId(id, label = "--service") {
|
|
|
90
86
|
if (problem !== null)
|
|
91
87
|
throw new InvalidIdError(typeof id === "string" ? id : String(id), problem);
|
|
92
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Feature ids are `<word>-<number>`: the id has to survive being read back off
|
|
91
|
+
* the directory name (`FEAT-101-payment-splitting` -> `FEAT-101`), or the
|
|
92
|
+
* feature would answer to a name it was never given.
|
|
93
|
+
*
|
|
94
|
+
* Here for the same reason the service grammar is — this was spelled twice,
|
|
95
|
+
* privately, in `commands/new.ts` and `core/openspec-inventory.ts`, and
|
|
96
|
+
* docs/DESIGN.md rule 7 recorded the pair as a hazard rather than a fact. The
|
|
97
|
+
* third caller is what made it one: `loam explore --as <FEAT>` interpolates its
|
|
98
|
+
* argument into a `loam new` line that loam PRINTS for an agent to run, so
|
|
99
|
+
* without this check `explore` cheerfully handed back a command `new` refuses.
|
|
100
|
+
* A guard test catches that class only for literal source strings — a command
|
|
101
|
+
* assembled at runtime from argv is invisible to it — which is exactly why the
|
|
102
|
+
* grammar has to be shared rather than re-derived by whoever needs it next.
|
|
103
|
+
*/
|
|
104
|
+
const FEATURE_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
105
|
+
/** Prose form, shown verbatim in every refusal so the fix is obvious. */
|
|
106
|
+
export const FEATURE_ID_RULE = "Expected <word>-<number>, e.g. FEAT-101 or BUG-42.";
|
|
107
|
+
export function isFeatureId(id) {
|
|
108
|
+
return typeof id === "string" && FEATURE_ID.test(id);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Why `id` is not a usable feature id, or null when it is — the `serviceIdProblem`
|
|
112
|
+
* shape, so a caller already collecting findings reports the sentence a refusal
|
|
113
|
+
* would print.
|
|
114
|
+
*/
|
|
115
|
+
export function featureIdProblem(id, label = "feature id") {
|
|
116
|
+
if (!isFeatureId(id)) {
|
|
117
|
+
const shown = typeof id === "string" ? `'${id}'` : String(id);
|
|
118
|
+
return `${shown} is not a usable ${label}. ${FEATURE_ID_RULE}`;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
93
122
|
//# sourceMappingURL=ids.js.map
|
package/dist/core/ids.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ids.js","sourceRoot":"","sources":["../../src/core/ids.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,uFAAuF;AACvF,MAAM,UAAU,GAAG,8BAA8B,CAAC;AAElD;;;;GAIG;AACH,MAAM,cAAc,GAAG,yCAAyC,CAAC;AAEjE,qFAAqF;AACrF,MAAM,
|
|
1
|
+
{"version":3,"file":"ids.js","sourceRoot":"","sources":["../../src/core/ids.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The spelling rules for the ids loam turns into directory names.\n *\n * A service id is not a label: it becomes `services/<id>/` in the shared docs\n * repo, and it is interpolated into paths by adopt, validate, vouch, gherkin and\n * new. That makes it caller-controlled path input, so it is validated in exactly\n * one place — here — rather than by each command inventing its own guard. The\n * failure mode this closes is not theoretical: `--service ../../etc` would have\n * had `servicePaths()` hand a writer a directory outside the docs repo, and\n * `--service ''` would have made `services//spec.md` collapse to a file the\n * enumeration in repo.ts can never see again.\n *\n * The rule is deliberately narrower than \"a path segment that happens to work\":\n * ids are also compared, sorted and printed in tables, so shell metacharacters,\n * spaces and leading dots are refused even where the filesystem would accept\n * them. Widening the alphabet later is a compatible change; narrowing it is not.\n *\n * \"Works\" also means \"works on Windows\", which this project tests in CI: the\n * two rules below are the ones POSIX accepts and Windows does not. They used to\n * live in migrate-openspec.ts alone, as a second, stricter copy of this grammar\n * — so the PRIMARY authoring path (`--service`, `--touches`, `adopt`) accepted\n * ids the migration refused, and `services/CON/` or `services/payments./` got\n * as far as a directory nobody can create or open. Narrowing is not a\n * compatible change, which is exactly why it belongs here, once, rather than in\n * whichever command happened to think of it.\n */\n\n/** The one grammar: alphanumeric head, then alphanumerics, dot, underscore, hyphen. */\nconst SERVICE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;\n\n/**\n * The MS-DOS device names Windows still reserves. Reserved with any extension\n * and in any case (`nul`, `NUL.txt`), so the test is on the stem — the part\n * before the first dot — uppercased.\n */\nconst WINDOWS_DEVICE = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/;\n\n/** Prose form of the rule, shown verbatim in every refusal so the fix is obvious. */\nconst SERVICE_ID_RULE =\n \"a service id must start with a letter or digit and contain only letters, digits, '.', '_' or '-' \" +\n \"(no slashes, no '..', no spaces) — it becomes the services/<id>/ directory name\";\n\n/** Refusal carrying the offending value, so a caller can quote it back. */\nexport class InvalidIdError extends Error {\n constructor(\n readonly value: string,\n message: string,\n ) {\n super(message);\n this.name = \"InvalidIdError\";\n }\n}\n\n/**\n * Why `id` is not a usable service id, or null when it is. Returning the reason\n * instead of a boolean lets a caller that is already collecting findings report\n * the same sentence a refusal would print.\n */\nexport function serviceIdProblem(id: unknown, label = \"--service\"): string | null {\n if (typeof id !== \"string\" || id === \"\") {\n return `${label} must name a service: ${SERVICE_ID_RULE}.`;\n }\n // Checked ahead of the pattern purely for the message: '..' is the case a\n // human is most likely to have typed on purpose, and \"no '..'\" is a more\n // useful answer than \"does not match the id grammar\".\n if (id.split(/[\\\\/]/).includes(\"..\") || id.includes(\"..\")) {\n return `Invalid ${label} '${id}': ${SERVICE_ID_RULE}.`;\n }\n if (!SERVICE_ID.test(id)) {\n return `Invalid ${label} '${id}': ${SERVICE_ID_RULE}.`;\n }\n // Both refusals below are about Windows, and both get their own sentence for\n // the same reason `..` does: \"does not match the id grammar\" is true and\n // useless, because the id LOOKS fine and the problem is what the filesystem\n // does with it.\n if (WINDOWS_DEVICE.test(id.split(\".\")[0]!.toUpperCase())) {\n return (\n `Invalid ${label} '${id}': '${id.split(\".\")[0]}' is a reserved device name on Windows — ` +\n `services/${id}/ cannot be created there at all, so the docs repo would only work on some of the machines that clone it.`\n );\n }\n if (/[. ]$/.test(id)) {\n return (\n `Invalid ${label} '${id}': a service id may not end with '.' or a space — ` +\n `Windows strips both when creating a directory, so '${id}' and '${id.replace(/[. ]+$/, \"\")}' would silently become one directory there and two everywhere else.`\n );\n }\n return null;\n}\n\n/**\n * Assert that `id` may be used as a `services/<id>/` directory name. Throws\n * InvalidIdError; commands catch it and report `invalid-option`, because a bad\n * id is always something the caller typed, never a state of the repository.\n */\nexport function assertServiceId(id: unknown, label = \"--service\"): asserts id is string {\n const problem = serviceIdProblem(id, label);\n if (problem !== null) throw new InvalidIdError(typeof id === \"string\" ? id : String(id), problem);\n}\n\n/**\n * Feature ids are `<word>-<number>`: the id has to survive being read back off\n * the directory name (`FEAT-101-payment-splitting` -> `FEAT-101`), or the\n * feature would answer to a name it was never given.\n *\n * Here for the same reason the service grammar is — this was spelled twice,\n * privately, in `commands/new.ts` and `core/openspec-inventory.ts`, and\n * docs/DESIGN.md rule 7 recorded the pair as a hazard rather than a fact. The\n * third caller is what made it one: `loam explore --as <FEAT>` interpolates its\n * argument into a `loam new` line that loam PRINTS for an agent to run, so\n * without this check `explore` cheerfully handed back a command `new` refuses.\n * A guard test catches that class only for literal source strings — a command\n * assembled at runtime from argv is invisible to it — which is exactly why the\n * grammar has to be shared rather than re-derived by whoever needs it next.\n */\nconst FEATURE_ID = /^[A-Za-z][A-Za-z0-9]*-\\d+$/;\n\n/** Prose form, shown verbatim in every refusal so the fix is obvious. */\nexport const FEATURE_ID_RULE = \"Expected <word>-<number>, e.g. FEAT-101 or BUG-42.\";\n\nexport function isFeatureId(id: unknown): id is string {\n return typeof id === \"string\" && FEATURE_ID.test(id);\n}\n\n/**\n * Why `id` is not a usable feature id, or null when it is — the `serviceIdProblem`\n * shape, so a caller already collecting findings reports the sentence a refusal\n * would print.\n */\nexport function featureIdProblem(id: unknown, label = \"feature id\"): string | null {\n if (!isFeatureId(id)) {\n const shown = typeof id === \"string\" ? `'${id}'` : String(id);\n return `${shown} is not a usable ${label}. ${FEATURE_ID_RULE}`;\n }\n return null;\n}\n"]}
|
package/dist/core/json.js
CHANGED
|
@@ -48,7 +48,14 @@ export function fail(json, code, message) {
|
|
|
48
48
|
process.exitCode = 1;
|
|
49
49
|
}
|
|
50
50
|
/** The message every command prints when there is no loam.json. */
|
|
51
|
-
|
|
51
|
+
const NO_CONFIG_MESSAGE = "No loam.json found. Run `loam init --docs <dir>` first.";
|
|
52
|
+
/**
|
|
53
|
+
* The message the service-scoped commands print when neither `--service` nor
|
|
54
|
+
* loam.json names one. Four commands ask the same question and owe the same two
|
|
55
|
+
* answers, and a fifth would have been written by hand. `loam delta` keeps its
|
|
56
|
+
* own longer sentence on purpose — it can say which feature was being read.
|
|
57
|
+
*/
|
|
58
|
+
export const NO_SERVICE_MESSAGE = "No service. Pass --service <id> or set it in loam.json.";
|
|
52
59
|
/**
|
|
53
60
|
* Report "no config" in whichever mode the caller is in — distinguishing a
|
|
54
61
|
* config that is absent from one that exists but would not load, because the
|
package/dist/core/json.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"json.js","sourceRoot":"","sources":["../../src/core/json.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC;;;;GAIG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,KAAK,CAAC;AAsF3C,MAAM,UAAU,QAAQ,CAAC,OAAgC;IACvD,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,eAAe,EAAE,qBAAqB,EAAE,EAAE,EAAE,IAAI,EAAE,GAAG,OAAO,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;AACzG,CAAC;AAED,+FAA+F;AAC/F,MAAM,UAAU,QAAQ,CAAC,OAAe,EAAE,GAAW;IACnD,OAAO,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACzD,CAAC;AAED,+FAA+F;AAC/F,MAAM,UAAU,aAAa,CAC3B,IAAe,EACf,OAAe,EACf,OAAO,GAA4B,EAAE;IAErC,OAAO,CAAC,GAAG,CACT,IAAI,CAAC,SAAS,CACZ,EAAE,eAAe,EAAE,qBAAqB,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,GAAG,OAAO,EAAE,EAC3F,IAAI,EACJ,CAAC,CACF,CACF,CAAC;IACF,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;IACrB,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,IAAI,CAAC,IAAa,EAAE,IAAe,EAAE,OAAe;IAClE,IAAI,IAAI,EAAE,CAAC;QACT,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAC7B,OAAO;IACT,CAAC;IACD,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACvB,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;AACvB,CAAC;AAED,mEAAmE;AACnE,MAAM,CAAC,MAAM,iBAAiB,GAAG,yDAAyD,CAAC;AAE3F;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,IAAa;IAC1C,IAAI,UAAU,CAAC,UAAU,EAAE,CAAC,EAAE,CAAC;QAC7B,IAAI,CACF,IAAI,EACJ,gBAAgB,EAChB,yFAAyF,CAC1F,CAAC;QACF,OAAO;IACT,CAAC;IACD,IAAI,CAAC,IAAI,EAAE,WAAW,EAAE,iBAAiB,CAAC,CAAC;AAC7C,CAAC","sourcesContent":["/**\n * The `--json` envelope — loam's machine contract.\n *\n * Every JSON response carries `ok`. Success spreads the payload alongside it;\n * failure carries a stable `error.code` a caller can branch on without parsing\n * prose. Errors go to stdout too: a consumer reads one stream and always gets\n * valid JSON, whether the command succeeded or not. The exit code still tells\n * the shell what happened.\n *\n * Key casing: camelCase, with one deliberate exception — a key that mirrors a\n * frontmatter field verbatim keeps that field's snake_case spelling\n * (`last_verified`, `sources_digest` in vouch's payload), so the envelope and\n * the document it describes spell the same fact the same way. snake_case only\n * where a key mirrors a frontmatter field verbatim; camelCase everywhere else.\n */\nimport { existsSync } from \"node:fs\";\nimport { relative } from \"node:path\";\nimport { configPath } from \"./config.js\";\n\n/**\n * Version of the top-level JSON envelope, independent of the CLI/package\n * version. Additive payload fields do not require a bump; incompatible changes\n * to `ok`, `error`, or their semantics do.\n */\nexport const JSON_CONTRACT_VERSION = \"1.0\";\n\n/**\n * Stable failure codes. Prose may change; these may not.\n *\n * The `sources-*` pair mirrors the `sources.*` finding codes on purpose: the\n * same breach is recognisable whether it arrives as a refusal from `vouch` or\n * as a finding from `validate`.\n *\n * The `feature-active` / `snapshot-*` group is `unarchive` refusing to guess:\n * each names a different reason the undo is not one, and a caller has to tell\n * them apart to know whether re-running could ever work. Its commit failures\n * split the same way archive's do: `restore-failed` means nothing was restored\n * or everything was rolled back — the living docs are unchanged and re-running\n * can work; `rollback-incomplete` (shared with archive, same meaning) means\n * some files could not be put back, and the message lists them.\n *\n * The `answers-*` group is `loam verify --record` refusing an answer set that\n * does not answer the current checklist. They are separated for the same reason:\n * an unreadable file is a bug in whatever wrote it, a mismatch means the feature\n * moved and the claims have to be answered again, and an unevidenced\n * confirmation is the one an agent can fix on the spot.\n *\n * The archive group is `loam archive` refusing or failing, and each code is a\n * different answer to \"what do I fix, and can I trust the repo?\": `not-coherent`\n * is the gate — errors in the FEATURE, fix them or override with `--approve`;\n * `living-outside-requirements` is the one refusal `--approve` does not move —\n * the LIVING spec holds requirements outside `## Requirements`, and the merge\n * would duplicate them, so the fix is in the living docs; `archive-exists` is a\n * destination collision under `features/archive/`; `merge-failed` is a merge\n * that could not be computed or was rolled back — either way the living docs\n * are unchanged; `rollback-incomplete` is the one that demands a human: the\n * merge failed AND some files could not be restored.\n *\n * `config-invalid` is distinct from `no-config` because the fixes point in\n * opposite directions: a missing config wants `loam init`, a corrupt one wants\n * repair — and an agent that ran `init` on a corrupt file would silently\n * rewrite it.\n *\n * `internal` is the one code with no stable meaning: an unexpected throw. It\n * exists so a `--json` consumer still receives an envelope instead of a stack\n * trace on stdout's sibling stream.\n */\nexport type ErrorCode =\n | \"no-config\"\n | \"config-invalid\"\n | \"unknown-target\"\n | \"invalid-option\"\n | \"already-exists\"\n | \"sources-absent\"\n | \"sources-path-missing\"\n | \"not-coherent\"\n | \"living-outside-requirements\"\n | \"archive-exists\"\n | \"merge-failed\"\n | \"rollback-incomplete\"\n | \"feature-active\"\n | \"snapshot-missing\"\n | \"snapshot-stale\"\n /** A snapshot pre-image whose bytes no longer match the digest archive recorded for them: `unarchive` will not restore text nobody wrote. */\n | \"snapshot-corrupt\"\n /** A `loam archive`/`unarchive` was killed mid-commit: the living docs are half-written, and this run refuses rather than writing over the evidence. */\n | \"commit-interrupted\"\n | \"restore-failed\"\n | \"answers-unreadable\"\n | \"answers-mismatch\"\n | \"answers-unevidenced\"\n | \"service-mismatch\"\n | \"unknown-service\"\n | \"repository-unavailable\"\n /** `loam verify --record` without `--service` over a federated (schema 2) record: it would erase other repositories' attestations. */\n | \"record-federated\"\n /** A `verification.yaml` that exists but cannot be read as a record — never overwritten, never reported as absent. */\n | \"record-unreadable\"\n /** `loam gherkin <FEAT>` refusing to overwrite a `.feature` file owned by another feature still in flight. */\n | \"gherkin-conflict\"\n /** `loam vouch` found the spec changed under it between reading and stamping — another vouch or an edit landed first, and nothing was written. */\n | \"vouch-raced\"\n /** `docsDir` in loam.json points at nothing: the docs repo was never cloned, or the path is wrong. A read command refuses rather than reporting an empty fleet. */\n | \"docs-missing\"\n /** `docsDir` is a directory but has no `services/`: it is some other directory, most often the service repo itself after a typo. */\n | \"services-missing\"\n /** Another `loam archive`/`unarchive` holds the docs repo's advisory lock: nothing was read or written, and re-running once it finishes works. */\n | \"docs-busy\"\n | \"internal\";\n\nexport function emitJson(payload: Record<string, unknown>): void {\n console.log(JSON.stringify({ contractVersion: JSON_CONTRACT_VERSION, ok: true, ...payload }, null, 2));\n}\n\n/** Paths in the contract are repo-relative, with forward slashes: diffable across machines. */\nexport function repoPath(docsDir: string, abs: string): string {\n return relative(docsDir, abs).split(/[\\\\/]/).join(\"/\");\n}\n\n/** Emit a failure envelope and set the exit code. Returns false, to `return` from a caller. */\nexport function emitJsonError(\n code: ErrorCode,\n message: string,\n details: Record<string, unknown> = {},\n): false {\n console.log(\n JSON.stringify(\n { contractVersion: JSON_CONTRACT_VERSION, ok: false, error: { code, message }, ...details },\n null,\n 2,\n ),\n );\n process.exitCode = 1;\n return false;\n}\n\n/**\n * Report a failure in whichever mode the caller is in. Text mode goes to\n * stderr as it always has; JSON mode goes into the envelope.\n */\nexport function fail(json: boolean, code: ErrorCode, message: string): void {\n if (json) {\n emitJsonError(code, message);\n return;\n }\n console.error(message);\n process.exitCode = 1;\n}\n\n/** The message every command prints when there is no loam.json. */\nexport const NO_CONFIG_MESSAGE = \"No loam.json found. Run `loam init --docs <dir>` first.\";\n\n/**\n * Report \"no config\" in whichever mode the caller is in — distinguishing a\n * config that is absent from one that exists but would not load, because the\n * two point at opposite fixes: a missing config wants `loam init`, a corrupt\n * one wants repair, and `init` would silently rewrite the corrupt file.\n * Callers reach here only after `loadConfig()` returned null, so the file\n * existing is proof it failed to load.\n */\nexport function reportNoConfig(json: boolean): void {\n if (existsSync(configPath())) {\n fail(\n json,\n \"config-invalid\",\n \"loam.json exists but could not be loaded. Fix it (or delete it and re-run `loam init`).\",\n );\n return;\n }\n fail(json, \"no-config\", NO_CONFIG_MESSAGE);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"json.js","sourceRoot":"","sources":["../../src/core/json.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC;;;;GAIG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,KAAK,CAAC;AA4F3C,MAAM,UAAU,QAAQ,CAAC,OAAgC;IACvD,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,eAAe,EAAE,qBAAqB,EAAE,EAAE,EAAE,IAAI,EAAE,GAAG,OAAO,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;AACzG,CAAC;AAED,+FAA+F;AAC/F,MAAM,UAAU,QAAQ,CAAC,OAAe,EAAE,GAAW;IACnD,OAAO,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACzD,CAAC;AAED,+FAA+F;AAC/F,MAAM,UAAU,aAAa,CAC3B,IAAe,EACf,OAAe,EACf,OAAO,GAA4B,EAAE;IAErC,OAAO,CAAC,GAAG,CACT,IAAI,CAAC,SAAS,CACZ,EAAE,eAAe,EAAE,qBAAqB,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,GAAG,OAAO,EAAE,EAC3F,IAAI,EACJ,CAAC,CACF,CACF,CAAC;IACF,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;IACrB,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,IAAI,CAAC,IAAa,EAAE,IAAe,EAAE,OAAe;IAClE,IAAI,IAAI,EAAE,CAAC;QACT,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAC7B,OAAO;IACT,CAAC;IACD,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACvB,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;AACvB,CAAC;AAED,mEAAmE;AACnE,MAAM,iBAAiB,GAAG,yDAAyD,CAAC;AAEpF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,yDAAyD,CAAC;AAE5F;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,IAAa;IAC1C,IAAI,UAAU,CAAC,UAAU,EAAE,CAAC,EAAE,CAAC;QAC7B,IAAI,CACF,IAAI,EACJ,gBAAgB,EAChB,yFAAyF,CAC1F,CAAC;QACF,OAAO;IACT,CAAC;IACD,IAAI,CAAC,IAAI,EAAE,WAAW,EAAE,iBAAiB,CAAC,CAAC;AAC7C,CAAC","sourcesContent":["/**\n * The `--json` envelope — loam's machine contract.\n *\n * Every JSON response carries `ok`. Success spreads the payload alongside it;\n * failure carries a stable `error.code` a caller can branch on without parsing\n * prose. Errors go to stdout too: a consumer reads one stream and always gets\n * valid JSON, whether the command succeeded or not. The exit code still tells\n * the shell what happened.\n *\n * Key casing: camelCase, with one deliberate exception — a key that mirrors a\n * frontmatter field verbatim keeps that field's snake_case spelling\n * (`last_verified`, `sources_digest` in vouch's payload), so the envelope and\n * the document it describes spell the same fact the same way. snake_case only\n * where a key mirrors a frontmatter field verbatim; camelCase everywhere else.\n */\nimport { existsSync } from \"node:fs\";\nimport { relative } from \"node:path\";\nimport { configPath } from \"./config.js\";\n\n/**\n * Version of the top-level JSON envelope, independent of the CLI/package\n * version. Additive payload fields do not require a bump; incompatible changes\n * to `ok`, `error`, or their semantics do.\n */\nexport const JSON_CONTRACT_VERSION = \"1.0\";\n\n/**\n * Stable failure codes. Prose may change; these may not.\n *\n * The `sources-*` pair mirrors the `sources.*` finding codes on purpose: the\n * same breach is recognisable whether it arrives as a refusal from `vouch` or\n * as a finding from `validate`.\n *\n * The `feature-active` / `snapshot-*` group is `unarchive` refusing to guess:\n * each names a different reason the undo is not one, and a caller has to tell\n * them apart to know whether re-running could ever work. Its commit failures\n * split the same way archive's do: `restore-failed` means nothing was restored\n * or everything was rolled back — the living docs are unchanged and re-running\n * can work; `rollback-incomplete` (shared with archive, same meaning) means\n * some files could not be put back, and the message lists them.\n *\n * The `answers-*` group is `loam verify --record` refusing an answer set that\n * does not answer the current checklist. They are separated for the same reason:\n * an unreadable file is a bug in whatever wrote it, a mismatch means the feature\n * moved and the claims have to be answered again, and an unevidenced\n * confirmation is the one an agent can fix on the spot.\n *\n * The archive group is `loam archive` refusing or failing, and each code is a\n * different answer to \"what do I fix, and can I trust the repo?\": `not-coherent`\n * is the gate — errors in the FEATURE, fix them or override with `--approve`;\n * `living-outside-requirements` is the one refusal `--approve` does not move —\n * the LIVING spec holds requirements outside `## Requirements`, and the merge\n * would duplicate them, so the fix is in the living docs; `archive-exists` is a\n * destination collision under `features/archive/`; `merge-failed` is a merge\n * that could not be computed or was rolled back — either way the living docs\n * are unchanged; `rollback-incomplete` is the one that demands a human: the\n * merge failed AND some files could not be restored.\n *\n * `config-invalid` is distinct from `no-config` because the fixes point in\n * opposite directions: a missing config wants `loam init`, a corrupt one wants\n * repair — and an agent that ran `init` on a corrupt file would silently\n * rewrite it.\n *\n * `internal` is the one code with no stable meaning: an unexpected throw. It\n * exists so a `--json` consumer still receives an envelope instead of a stack\n * trace on stdout's sibling stream.\n */\nexport type ErrorCode =\n | \"no-config\"\n | \"config-invalid\"\n | \"unknown-target\"\n | \"invalid-option\"\n | \"already-exists\"\n | \"sources-absent\"\n | \"sources-path-missing\"\n | \"not-coherent\"\n | \"living-outside-requirements\"\n | \"archive-exists\"\n | \"merge-failed\"\n | \"rollback-incomplete\"\n | \"feature-active\"\n | \"snapshot-missing\"\n | \"snapshot-stale\"\n /** A snapshot pre-image whose bytes no longer match the digest archive recorded for them: `unarchive` will not restore text nobody wrote. */\n | \"snapshot-corrupt\"\n /** A `loam archive`/`unarchive` was killed mid-commit: the living docs are half-written, and this run refuses rather than writing over the evidence. */\n | \"commit-interrupted\"\n | \"restore-failed\"\n | \"answers-unreadable\"\n | \"answers-mismatch\"\n | \"answers-unevidenced\"\n | \"service-mismatch\"\n | \"unknown-service\"\n | \"repository-unavailable\"\n /** `loam verify --record` without `--service` over a federated (schema 2) record: it would erase other repositories' attestations. */\n | \"record-federated\"\n /** A `verification.yaml` that exists but cannot be read as a record — never overwritten, never reported as absent. */\n | \"record-unreadable\"\n /** `loam gherkin <FEAT>` refusing to overwrite a `.feature` file owned by another feature still in flight. */\n | \"gherkin-conflict\"\n /** `loam vouch` found the spec changed under it between reading and stamping — another vouch or an edit landed first, and nothing was written. */\n | \"vouch-raced\"\n /** `loam vouch` with nothing on the other end of stdin and no `--yes`: the stamp is a person's claim, and nobody was asked. */\n | \"vouch-unattended\"\n /** `loam vouch` could not learn who is vouching — git names no `user.email` here, so the stamp would record a claim with nobody behind it. */\n | \"vouch-unattributable\"\n /** `loam vouch` was asked to confirm and the answer was no. Nothing was stamped; this is a successful refusal, not a failure. */\n | \"vouch-declined\"\n /** `docsDir` in loam.json points at nothing: the docs repo was never cloned, or the path is wrong. A read command refuses rather than reporting an empty fleet. */\n | \"docs-missing\"\n /** `docsDir` is a directory but has no `services/`: it is some other directory, most often the service repo itself after a typo. */\n | \"services-missing\"\n /** Another `loam archive`/`unarchive` holds the docs repo's advisory lock: nothing was read or written, and re-running once it finishes works. */\n | \"docs-busy\"\n | \"internal\";\n\nexport function emitJson(payload: Record<string, unknown>): void {\n console.log(JSON.stringify({ contractVersion: JSON_CONTRACT_VERSION, ok: true, ...payload }, null, 2));\n}\n\n/** Paths in the contract are repo-relative, with forward slashes: diffable across machines. */\nexport function repoPath(docsDir: string, abs: string): string {\n return relative(docsDir, abs).split(/[\\\\/]/).join(\"/\");\n}\n\n/** Emit a failure envelope and set the exit code. Returns false, to `return` from a caller. */\nexport function emitJsonError(\n code: ErrorCode,\n message: string,\n details: Record<string, unknown> = {},\n): false {\n console.log(\n JSON.stringify(\n { contractVersion: JSON_CONTRACT_VERSION, ok: false, error: { code, message }, ...details },\n null,\n 2,\n ),\n );\n process.exitCode = 1;\n return false;\n}\n\n/**\n * Report a failure in whichever mode the caller is in. Text mode goes to\n * stderr as it always has; JSON mode goes into the envelope.\n */\nexport function fail(json: boolean, code: ErrorCode, message: string): void {\n if (json) {\n emitJsonError(code, message);\n return;\n }\n console.error(message);\n process.exitCode = 1;\n}\n\n/** The message every command prints when there is no loam.json. */\nconst NO_CONFIG_MESSAGE = \"No loam.json found. Run `loam init --docs <dir>` first.\";\n\n/**\n * The message the service-scoped commands print when neither `--service` nor\n * loam.json names one. Four commands ask the same question and owe the same two\n * answers, and a fifth would have been written by hand. `loam delta` keeps its\n * own longer sentence on purpose — it can say which feature was being read.\n */\nexport const NO_SERVICE_MESSAGE = \"No service. Pass --service <id> or set it in loam.json.\";\n\n/**\n * Report \"no config\" in whichever mode the caller is in — distinguishing a\n * config that is absent from one that exists but would not load, because the\n * two point at opposite fixes: a missing config wants `loam init`, a corrupt\n * one wants repair, and `init` would silently rewrite the corrupt file.\n * Callers reach here only after `loadConfig()` returned null, so the file\n * existing is proof it failed to load.\n */\nexport function reportNoConfig(json: boolean): void {\n if (existsSync(configPath())) {\n fail(\n json,\n \"config-invalid\",\n \"loam.json exists but could not be loaded. Fix it (or delete it and re-run `loam init`).\",\n );\n return;\n }\n fail(json, \"no-config\", NO_CONFIG_MESSAGE);\n}\n"]}
|