@ybotok/loam 0.0.0 → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/COMPARISON.md +63 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING-from-OpenSpec.md +202 -0
  5. package/README.md +241 -9
  6. package/SCHEMA.md +425 -0
  7. package/SECURITY.md +9 -0
  8. package/dist/cli.js +138 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/adopt.js +226 -0
  11. package/dist/commands/adopt.js.map +1 -0
  12. package/dist/commands/archive.js +1297 -0
  13. package/dist/commands/archive.js.map +1 -0
  14. package/dist/commands/delta.js +361 -0
  15. package/dist/commands/delta.js.map +1 -0
  16. package/dist/commands/dependencies.js +78 -0
  17. package/dist/commands/dependencies.js.map +1 -0
  18. package/dist/commands/doctor.js +85 -0
  19. package/dist/commands/doctor.js.map +1 -0
  20. package/dist/commands/gherkin.js +270 -0
  21. package/dist/commands/gherkin.js.map +1 -0
  22. package/dist/commands/init.js +250 -0
  23. package/dist/commands/init.js.map +1 -0
  24. package/dist/commands/list.js +361 -0
  25. package/dist/commands/list.js.map +1 -0
  26. package/dist/commands/migrate-openspec.js +933 -0
  27. package/dist/commands/migrate-openspec.js.map +1 -0
  28. package/dist/commands/new.js +412 -0
  29. package/dist/commands/new.js.map +1 -0
  30. package/dist/commands/rebase.js +358 -0
  31. package/dist/commands/rebase.js.map +1 -0
  32. package/dist/commands/show.js +325 -0
  33. package/dist/commands/show.js.map +1 -0
  34. package/dist/commands/status.js +270 -0
  35. package/dist/commands/status.js.map +1 -0
  36. package/dist/commands/unarchive.js +310 -0
  37. package/dist/commands/unarchive.js.map +1 -0
  38. package/dist/commands/validate.js +1609 -0
  39. package/dist/commands/validate.js.map +1 -0
  40. package/dist/commands/verify.js +759 -0
  41. package/dist/commands/verify.js.map +1 -0
  42. package/dist/commands/vouch.js +325 -0
  43. package/dist/commands/vouch.js.map +1 -0
  44. package/dist/core/agent.js +1823 -0
  45. package/dist/core/agent.js.map +1 -0
  46. package/dist/core/agents-stamp.js +68 -0
  47. package/dist/core/agents-stamp.js.map +1 -0
  48. package/dist/core/arch.js +104 -0
  49. package/dist/core/arch.js.map +1 -0
  50. package/dist/core/brief.js +733 -0
  51. package/dist/core/brief.js.map +1 -0
  52. package/dist/core/coherence.js +553 -0
  53. package/dist/core/coherence.js.map +1 -0
  54. package/dist/core/config.js +172 -0
  55. package/dist/core/config.js.map +1 -0
  56. package/dist/core/delta.js +457 -0
  57. package/dist/core/delta.js.map +1 -0
  58. package/dist/core/dependencies.js +352 -0
  59. package/dist/core/dependencies.js.map +1 -0
  60. package/dist/core/docs.js +143 -0
  61. package/dist/core/docs.js.map +1 -0
  62. package/dist/core/doctor.js +544 -0
  63. package/dist/core/doctor.js.map +1 -0
  64. package/dist/core/fleet-context.js +272 -0
  65. package/dist/core/fleet-context.js.map +1 -0
  66. package/dist/core/frontmatter.js +203 -0
  67. package/dist/core/frontmatter.js.map +1 -0
  68. package/dist/core/gherkin.js +521 -0
  69. package/dist/core/gherkin.js.map +1 -0
  70. package/dist/core/health.js +71 -0
  71. package/dist/core/health.js.map +1 -0
  72. package/dist/core/ids.js +93 -0
  73. package/dist/core/ids.js.map +1 -0
  74. package/dist/core/issue.js +9 -0
  75. package/dist/core/issue.js.map +1 -0
  76. package/dist/core/json.js +67 -0
  77. package/dist/core/json.js.map +1 -0
  78. package/dist/core/likec4.js +474 -0
  79. package/dist/core/likec4.js.map +1 -0
  80. package/dist/core/openapi-merge.js +519 -0
  81. package/dist/core/openapi-merge.js.map +1 -0
  82. package/dist/core/openapi.js +243 -0
  83. package/dist/core/openapi.js.map +1 -0
  84. package/dist/core/openspec-inventory.js +1403 -0
  85. package/dist/core/openspec-inventory.js.map +1 -0
  86. package/dist/core/path-safety.js +109 -0
  87. package/dist/core/path-safety.js.map +1 -0
  88. package/dist/core/provenance.js +695 -0
  89. package/dist/core/provenance.js.map +1 -0
  90. package/dist/core/repo.js +362 -0
  91. package/dist/core/repo.js.map +1 -0
  92. package/dist/core/report.js +38 -0
  93. package/dist/core/report.js.map +1 -0
  94. package/dist/core/results.js +201 -0
  95. package/dist/core/results.js.map +1 -0
  96. package/dist/core/spec.js +481 -0
  97. package/dist/core/spec.js.map +1 -0
  98. package/dist/core/staging.js +783 -0
  99. package/dist/core/staging.js.map +1 -0
  100. package/dist/core/status.js +1015 -0
  101. package/dist/core/status.js.map +1 -0
  102. package/dist/core/verify.js +793 -0
  103. package/dist/core/verify.js.map +1 -0
  104. package/dist/core/version.js +8 -0
  105. package/dist/core/version.js.map +1 -0
  106. package/package.json +63 -4
@@ -0,0 +1,243 @@
1
+ import { isUtf8 } from "node:buffer";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
5
+ import { parse } from "yaml";
6
+ import { featureSpecPaths, servicePaths } from "./repo.js";
7
+ /**
8
+ * The path-item keys that hold operations. A path item also carries `summary`,
9
+ * `parameters`, `servers` and vendor `x-*` extensions — an object-valued one with
10
+ * an `operationId` inside (x-legacy and friends) is not an operation, and a
11
+ * phantom id from it would make a broken contract look "available" to the
12
+ * op-exists checks. Shared with archive's merge, which asks the same question
13
+ * of the same keys.
14
+ */
15
+ export const HTTP_METHODS = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]);
16
+ /**
17
+ * The baseline marker: which living version of this operation a FEATURE delta
18
+ * was written against. Same idea as a requirement's `Based-On:` (core/spec.ts),
19
+ * in the vendor-extension shape this axis already uses for `x-loam-remove`.
20
+ *
21
+ * This axis needs it MORE than the requirement axis does, because a feature's
22
+ * openapi.yaml is a COMPLETE document rather than a patch: authors restate the
23
+ * living contract around the slot they are changing, and the merge upserts every
24
+ * operation the document spells. So a feature that never meant to touch
25
+ * `refundOrder` — it merely quoted it — pushes its authoring-time copy back over
26
+ * whatever landed in between, reverting another team's shipped change without
27
+ * either feature overlapping the other at all. The pin is what lets the merge
28
+ * tell a QUOTE from an EDIT: a quote equals its own baseline, and is skipped.
29
+ */
30
+ export const OPENAPI_BASELINE_KEY = "x-loam-based-on";
31
+ /** How much of the sha256 an `x-loam-based-on` carries — the length every loam digest uses. */
32
+ export const OPERATION_DIGEST_LENGTH = 16;
33
+ /** The value shape of an `x-loam-based-on`, exactly as `operationDigest` writes it. */
34
+ export const OPERATION_DIGEST_RE = new RegExp(`^[0-9a-f]{${OPERATION_DIGEST_LENGTH}}$`);
35
+ /**
36
+ * Canonical JSON for a resolved YAML value: object keys sorted, arrays left in
37
+ * order, everything else as JSON writes it.
38
+ *
39
+ * Key ORDER must not matter, because the merge's own idea of "this operation
40
+ * differs" is `isDeepStrictEqual` (core/openapi-merge.ts), which ignores it. A
41
+ * digest that disagreed with that comparison would go stale over a reordered
42
+ * `summary`/`operationId` pair the merge itself calls identical — a false
43
+ * collision on the one check whose worth is that it fires only for real ones.
44
+ */
45
+ function canonicalJson(node) {
46
+ if (node === null || typeof node !== "object")
47
+ return JSON.stringify(node) ?? "null";
48
+ if (Array.isArray(node))
49
+ return `[${node.map(canonicalJson).join(",")}]`;
50
+ const entries = Object.entries(node)
51
+ .filter(([, value]) => value !== undefined)
52
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
53
+ return `{${entries.map(([key, value]) => `${JSON.stringify(key)}:${canonicalJson(value)}`).join(",")}}`;
54
+ }
55
+ /**
56
+ * The operation without its own baseline marker — what a digest is taken over,
57
+ * and what the merge writes.
58
+ *
59
+ * A pin is a statement ABOUT a delta, never part of the operation it describes.
60
+ * Inside the digest input, an operation's identity would depend on the pin
61
+ * pointing at it and no baseline could ever be self-consistent; inside the
62
+ * merged contract, a living operation would carry a pin to a version of itself
63
+ * and the next feature's baseline would hash the previous feature's bookkeeping.
64
+ */
65
+ export function withoutOperationBaseline(node) {
66
+ if (node === null || typeof node !== "object" || Array.isArray(node))
67
+ return node;
68
+ const record = node;
69
+ if (!(OPENAPI_BASELINE_KEY in record))
70
+ return node;
71
+ const rest = { ...record };
72
+ delete rest[OPENAPI_BASELINE_KEY];
73
+ return rest;
74
+ }
75
+ /** The identity of an operation's CONTENT: sha256 of its canonical form, pin excluded. */
76
+ export function operationDigest(node) {
77
+ return createHash("sha256")
78
+ .update(canonicalJson(withoutOperationBaseline(node)), "utf8")
79
+ .digest("hex")
80
+ .slice(0, OPERATION_DIGEST_LENGTH);
81
+ }
82
+ /**
83
+ * What a pin says, from digests alone — the one definition both the gate
84
+ * (core/coherence.ts, which has parsed Operations) and the merge
85
+ * (core/openapi-merge.ts, which has raw YAML trees) decide by. Two spellings of
86
+ * this rule would eventually disagree about which operations a merge writes,
87
+ * and the disagreement would be invisible until a contract came back wrong.
88
+ *
89
+ * Order matters: `quote` is settled against the delta's OWN content before the
90
+ * living contract is consulted. An operation equal to its own baseline was not
91
+ * edited, so what living holds now is nobody's business but the feature that
92
+ * put it there.
93
+ */
94
+ export function classifyBaselineDigests(pin, ownDigest, livingDigest) {
95
+ if (pin === undefined)
96
+ return "unpinned";
97
+ if (livingDigest === undefined)
98
+ return "unfounded";
99
+ if (pin === ownDigest)
100
+ return "quote";
101
+ if (pin === livingDigest)
102
+ return "edit";
103
+ return "stale";
104
+ }
105
+ /** The `x-loam-based-on` a node declares, or undefined — non-strings included, so validate can refuse them. */
106
+ export function operationBaselineOf(node) {
107
+ if (node === null || typeof node !== "object" || Array.isArray(node))
108
+ return undefined;
109
+ const value = node[OPENAPI_BASELINE_KEY];
110
+ if (value === undefined)
111
+ return undefined;
112
+ return typeof value === "string" ? value.trim() : String(value);
113
+ }
114
+ /**
115
+ * Extract operations from an OpenAPI document by walking the parsed YAML
116
+ * structure (paths.<path>.<method>, method filtered to the HTTP set).
117
+ * Structure-aware on purpose: a regex scan both drops legal ids (kebab-case,
118
+ * dotted) and picks up phantom ids from description text. Each operation
119
+ * carries its id and its `deprecated: true` flag (exactly `true` counts —
120
+ * strings and truthy noise do not). `ops` is the defined set, keyed by the
121
+ * (path, method) SLOT — not by operationId.
122
+ *
123
+ * Keying by id used to drop the second slot claiming a name, which made the
124
+ * single most common contract edit invisible: relocating an endpoint is a
125
+ * removal marker at the old slot plus an upsert at the new one, the SAME
126
+ * operationId twice. Whichever came second in document order vanished, so
127
+ * either the marker addressed a slot loam could not see (`remove-target-missing`
128
+ * on a perfectly good delta) or the new definition did. Two slots claiming one
129
+ * id in a LIVING contract is a real ambiguity rather than a merge failure, so
130
+ * it rides out as `duplicateIds` for the caller to grade.
131
+ *
132
+ * An unreadable document yields [] ops PLUS the `unreadable` flag. Parsers
133
+ * never diagnose — the policy of every reader here — so the failure travels as
134
+ * a flag for validate to grade (`openapi.invalid`). Swallowing it into a bare
135
+ * [] made a broken contract indistinguishable from an EMPTY one, and the spine
136
+ * check then reported every inbound edge broken (`spine.op-undefined`) — a
137
+ * false diagnosis pointing at the landscape when the truth was this file.
138
+ */
139
+ export async function readOpenapi(openapiPath, context) {
140
+ if (context !== undefined)
141
+ return context.readOpenapi(openapiPath);
142
+ if (!existsSync(openapiPath))
143
+ return empty();
144
+ const bytes = await readFile(openapiPath);
145
+ // A contract whose bytes are not UTF-8 is as unreadable as broken YAML, and
146
+ // for a worse reason: `toString("utf8")` would substitute U+FFFD and hand the
147
+ // parser a document nobody wrote. It travels as `unreadable` so validate
148
+ // grades it `openapi.invalid` — the file is the error, and every check that
149
+ // reads the contract stays suspended.
150
+ if (!isUtf8(bytes))
151
+ return { ...empty(), unreadable: true, error: "file is not valid UTF-8" };
152
+ let doc;
153
+ try {
154
+ doc = parse(bytes.toString("utf8"));
155
+ }
156
+ catch (e) {
157
+ return { ...empty(), unreadable: true, error: e instanceof Error ? e.message : String(e) };
158
+ }
159
+ // A scalar or sequence document is as unreadable as broken YAML: there is no
160
+ // mapping to look `paths` up in, so nothing can be concluded from it. null
161
+ // (an empty file) stays readable — it defines nothing, and says so honestly.
162
+ if (doc !== null && (typeof doc !== "object" || Array.isArray(doc))) {
163
+ return { ...empty(), unreadable: true, error: "document is not a YAML mapping" };
164
+ }
165
+ const paths = doc?.paths;
166
+ if (!paths || typeof paths !== "object")
167
+ return empty();
168
+ const ops = [];
169
+ const anonymousRemovals = [];
170
+ const pathLevelRemovals = [];
171
+ const seen = new Map();
172
+ for (const [path, item] of Object.entries(paths)) {
173
+ if (!item || typeof item !== "object")
174
+ continue;
175
+ if (item["x-loam-remove"] === true)
176
+ pathLevelRemovals.push(path);
177
+ for (const [method, op] of Object.entries(item)) {
178
+ if (!HTTP_METHODS.has(method))
179
+ continue;
180
+ if (!op || typeof op !== "object")
181
+ continue;
182
+ const id = op["operationId"];
183
+ const remove = op["x-loam-remove"] === true;
184
+ if (typeof id !== "string" || id.length === 0) {
185
+ if (remove)
186
+ anonymousRemovals.push({ path, method });
187
+ continue;
188
+ }
189
+ seen.set(id, (seen.get(id) ?? 0) + 1);
190
+ const basedOn = operationBaselineOf(op);
191
+ ops.push({
192
+ id,
193
+ deprecated: op["deprecated"] === true,
194
+ remove,
195
+ path,
196
+ method,
197
+ digest: operationDigest(op),
198
+ ...(basedOn === undefined ? {} : { basedOn }),
199
+ });
200
+ }
201
+ }
202
+ const duplicateIds = [...seen].filter(([, n]) => n > 1).map(([id]) => id);
203
+ return { ops, duplicateIds, anonymousRemovals, pathLevelRemovals, unreadable: false };
204
+ }
205
+ function empty() {
206
+ return { ops: [], duplicateIds: [], anonymousRemovals: [], pathLevelRemovals: [], unreadable: false };
207
+ }
208
+ /** The operations alone — for every caller whose own finding already covers the unreadable case. */
209
+ export async function operations(openapiPath, context) {
210
+ return (await readOpenapi(openapiPath, context)).ops;
211
+ }
212
+ /** The operationIds alone — `operations` for every caller that asks only "does it exist". */
213
+ export async function operationIds(openapiPath, context) {
214
+ return (await operations(openapiPath, context)).map((o) => o.id);
215
+ }
216
+ /**
217
+ * The operationIds a service will provide after a feature is applied: living
218
+ * operations, plus feature upserts, minus explicit feature removals.
219
+ *
220
+ * Removals are applied BEFORE upserts, which is what makes the answer
221
+ * independent of the order the feature's document happens to spell them in.
222
+ * Interleaving them one operation at a time meant a relocation — the same id
223
+ * removed from `/old` and defined on `/new` — answered "defined" or "gone"
224
+ * depending on which path sorted first in the YAML, and the requirement
225
+ * governing it was reported undefined (`spec-api.op-undefined`) on exactly one
226
+ * of the two spellings of the same change.
227
+ */
228
+ export async function serviceOperationIds(docsDir, service, featureDir, context) {
229
+ if (context !== undefined)
230
+ return context.serviceOperationIds(docsDir, service, featureDir);
231
+ const ids = new Set((await operations(servicePaths(docsDir, service).openapi)).filter((op) => !op.remove).map((op) => op.id));
232
+ if (featureDir) {
233
+ const featOps = await operations(featureSpecPaths(featureDir, service).openapi);
234
+ for (const op of featOps)
235
+ if (op.remove)
236
+ ids.delete(op.id);
237
+ for (const op of featOps)
238
+ if (!op.remove)
239
+ ids.add(op.id);
240
+ }
241
+ return [...ids];
242
+ }
243
+ //# sourceMappingURL=openapi.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openapi.js","sourceRoot":"","sources":["../../src/core/openapi.ts"],"names":[],"mappings":"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","sourcesContent":["import { isUtf8 } from \"node:buffer\";\nimport { createHash } from \"node:crypto\";\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport { parse } from \"yaml\";\nimport type { FleetContext } from \"./fleet-context.js\";\nimport { featureSpecPaths, servicePaths } from \"./repo.js\";\n\n/**\n * The path-item keys that hold operations. A path item also carries `summary`,\n * `parameters`, `servers` and vendor `x-*` extensions — an object-valued one with\n * an `operationId` inside (x-legacy and friends) is not an operation, and a\n * phantom id from it would make a broken contract look \"available\" to the\n * op-exists checks. Shared with archive's merge, which asks the same question\n * of the same keys.\n */\nexport const HTTP_METHODS = new Set([\"get\", \"put\", \"post\", \"delete\", \"options\", \"head\", \"patch\", \"trace\"]);\n\n/**\n * The baseline marker: which living version of this operation a FEATURE delta\n * was written against. Same idea as a requirement's `Based-On:` (core/spec.ts),\n * in the vendor-extension shape this axis already uses for `x-loam-remove`.\n *\n * This axis needs it MORE than the requirement axis does, because a feature's\n * openapi.yaml is a COMPLETE document rather than a patch: authors restate the\n * living contract around the slot they are changing, and the merge upserts every\n * operation the document spells. So a feature that never meant to touch\n * `refundOrder` — it merely quoted it — pushes its authoring-time copy back over\n * whatever landed in between, reverting another team's shipped change without\n * either feature overlapping the other at all. The pin is what lets the merge\n * tell a QUOTE from an EDIT: a quote equals its own baseline, and is skipped.\n */\nexport const OPENAPI_BASELINE_KEY = \"x-loam-based-on\";\n\n/** How much of the sha256 an `x-loam-based-on` carries — the length every loam digest uses. */\nexport const OPERATION_DIGEST_LENGTH = 16;\n\n/** The value shape of an `x-loam-based-on`, exactly as `operationDigest` writes it. */\nexport const OPERATION_DIGEST_RE = new RegExp(`^[0-9a-f]{${OPERATION_DIGEST_LENGTH}}$`);\n\n/**\n * Canonical JSON for a resolved YAML value: object keys sorted, arrays left in\n * order, everything else as JSON writes it.\n *\n * Key ORDER must not matter, because the merge's own idea of \"this operation\n * differs\" is `isDeepStrictEqual` (core/openapi-merge.ts), which ignores it. A\n * digest that disagreed with that comparison would go stale over a reordered\n * `summary`/`operationId` pair the merge itself calls identical — a false\n * collision on the one check whose worth is that it fires only for real ones.\n */\nfunction canonicalJson(node: unknown): string {\n if (node === null || typeof node !== \"object\") return JSON.stringify(node) ?? \"null\";\n if (Array.isArray(node)) return `[${node.map(canonicalJson).join(\",\")}]`;\n const entries = Object.entries(node as Record<string, unknown>)\n .filter(([, value]) => value !== undefined)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));\n return `{${entries.map(([key, value]) => `${JSON.stringify(key)}:${canonicalJson(value)}`).join(\",\")}}`;\n}\n\n/**\n * The operation without its own baseline marker — what a digest is taken over,\n * and what the merge writes.\n *\n * A pin is a statement ABOUT a delta, never part of the operation it describes.\n * Inside the digest input, an operation's identity would depend on the pin\n * pointing at it and no baseline could ever be self-consistent; inside the\n * merged contract, a living operation would carry a pin to a version of itself\n * and the next feature's baseline would hash the previous feature's bookkeeping.\n */\nexport function withoutOperationBaseline(node: unknown): unknown {\n if (node === null || typeof node !== \"object\" || Array.isArray(node)) return node;\n const record = node as Record<string, unknown>;\n if (!(OPENAPI_BASELINE_KEY in record)) return node;\n const rest = { ...record };\n delete rest[OPENAPI_BASELINE_KEY];\n return rest;\n}\n\n/** The identity of an operation's CONTENT: sha256 of its canonical form, pin excluded. */\nexport function operationDigest(node: unknown): string {\n return createHash(\"sha256\")\n .update(canonicalJson(withoutOperationBaseline(node)), \"utf8\")\n .digest(\"hex\")\n .slice(0, OPERATION_DIGEST_LENGTH);\n}\n\n/**\n * What a pin says, from digests alone — the one definition both the gate\n * (core/coherence.ts, which has parsed Operations) and the merge\n * (core/openapi-merge.ts, which has raw YAML trees) decide by. Two spellings of\n * this rule would eventually disagree about which operations a merge writes,\n * and the disagreement would be invisible until a contract came back wrong.\n *\n * Order matters: `quote` is settled against the delta's OWN content before the\n * living contract is consulted. An operation equal to its own baseline was not\n * edited, so what living holds now is nobody's business but the feature that\n * put it there.\n */\nexport function classifyBaselineDigests(\n pin: string | undefined,\n ownDigest: string,\n livingDigest: string | undefined,\n): \"unpinned\" | \"quote\" | \"edit\" | \"stale\" | \"unfounded\" {\n if (pin === undefined) return \"unpinned\";\n if (livingDigest === undefined) return \"unfounded\";\n if (pin === ownDigest) return \"quote\";\n if (pin === livingDigest) return \"edit\";\n return \"stale\";\n}\n\n/** The `x-loam-based-on` a node declares, or undefined — non-strings included, so validate can refuse them. */\nexport function operationBaselineOf(node: unknown): string | undefined {\n if (node === null || typeof node !== \"object\" || Array.isArray(node)) return undefined;\n const value = (node as Record<string, unknown>)[OPENAPI_BASELINE_KEY];\n if (value === undefined) return undefined;\n return typeof value === \"string\" ? value.trim() : String(value);\n}\n\n/** One operation as the reader sees it, including loam's feature-only removal marker. */\nexport interface Operation {\n id: string;\n /**\n * The standard per-operation OpenAPI `deprecated` flag — lifecycle\n * visibility, the documented first step of retiring an op.\n */\n deprecated: boolean;\n /**\n * `x-loam-remove: true` asks a FEATURE delta to remove this exact operation\n * on archive. The marker is never a callable operation and is illegal in a\n * living contract.\n */\n remove: boolean;\n /** Exact slot the marker/upsert addresses; removal is deliberately path+method-safe. */\n path: string;\n method: string;\n /**\n * `operationDigest` of this operation as it stands in ITS OWN document —\n * computed here so every caller compares content the one way the merge does.\n */\n digest: string;\n /**\n * The living version this operation was written against, from\n * `x-loam-based-on`. Feature deltas only: a living contract never carries one\n * (the merge strips it), and an adopted contract never had one.\n */\n basedOn?: string;\n}\n\n/** A `x-loam-remove: true` marker with no usable operationId — a slot named but no operation named. */\nexport interface AnonymousRemoval {\n path: string;\n method: string;\n}\n\n/** The parse of one OpenAPI document: its operations, and whether the file could be read at all. */\nexport interface OpenapiDoc {\n ops: Operation[];\n /**\n * operationIds this document defines in more than one (path, method) slot.\n * A LIVING contract with two slots claiming one id is ambiguous to every\n * check that joins on the id — a requirement's `Operations:` line, an edge's\n * `metadata { op }`, a removal marker — and the join silently picks one.\n */\n duplicateIds: string[];\n /**\n * Removal markers the operation reader cannot name. They are invisible to\n * every id-keyed check, so a delta carrying one used to archive cleanly and\n * write `x-loam-remove: true` straight into a living contract.\n */\n anonymousRemovals: AnonymousRemoval[];\n /**\n * Paths carrying `x-loam-remove: true` at PATH level, beside the methods\n * rather than inside one.\n *\n * Not an operation, so nothing keyed by (path, method) could see it — and this\n * reader is method-keyed like the merge was. A marker written there addressed\n * no operation, so archive copied it into the living contract verbatim, where\n * it was invisible to `openapi.remove-marker-living` for the same reason and\n * kept the empty-path cleanup from ever firing. Reported as its own list\n * because the answer differs by document: in a FEATURE delta it is an\n * authoring mistake, in a LIVING contract it is a marker that must never have\n * been published.\n */\n pathLevelRemovals: string[];\n /**\n * True when the file EXISTS but cannot be read as an OpenAPI document —\n * broken YAML, or a document that is not a mapping. A missing file is not\n * unreadable (absence is `service.no-openapi`'s question), and an empty one\n * parses to null and honestly defines nothing.\n */\n unreadable: boolean;\n /** The parser's own message, when there is one to quote back. */\n error?: string;\n}\n\n/**\n * Extract operations from an OpenAPI document by walking the parsed YAML\n * structure (paths.<path>.<method>, method filtered to the HTTP set).\n * Structure-aware on purpose: a regex scan both drops legal ids (kebab-case,\n * dotted) and picks up phantom ids from description text. Each operation\n * carries its id and its `deprecated: true` flag (exactly `true` counts —\n * strings and truthy noise do not). `ops` is the defined set, keyed by the\n * (path, method) SLOT — not by operationId.\n *\n * Keying by id used to drop the second slot claiming a name, which made the\n * single most common contract edit invisible: relocating an endpoint is a\n * removal marker at the old slot plus an upsert at the new one, the SAME\n * operationId twice. Whichever came second in document order vanished, so\n * either the marker addressed a slot loam could not see (`remove-target-missing`\n * on a perfectly good delta) or the new definition did. Two slots claiming one\n * id in a LIVING contract is a real ambiguity rather than a merge failure, so\n * it rides out as `duplicateIds` for the caller to grade.\n *\n * An unreadable document yields [] ops PLUS the `unreadable` flag. Parsers\n * never diagnose — the policy of every reader here — so the failure travels as\n * a flag for validate to grade (`openapi.invalid`). Swallowing it into a bare\n * [] made a broken contract indistinguishable from an EMPTY one, and the spine\n * check then reported every inbound edge broken (`spine.op-undefined`) — a\n * false diagnosis pointing at the landscape when the truth was this file.\n */\nexport async function readOpenapi(openapiPath: string, context?: FleetContext): Promise<OpenapiDoc> {\n if (context !== undefined) return context.readOpenapi(openapiPath);\n if (!existsSync(openapiPath)) return empty();\n const bytes = await readFile(openapiPath);\n // A contract whose bytes are not UTF-8 is as unreadable as broken YAML, and\n // for a worse reason: `toString(\"utf8\")` would substitute U+FFFD and hand the\n // parser a document nobody wrote. It travels as `unreadable` so validate\n // grades it `openapi.invalid` — the file is the error, and every check that\n // reads the contract stays suspended.\n if (!isUtf8(bytes)) return { ...empty(), unreadable: true, error: \"file is not valid UTF-8\" };\n let doc: unknown;\n try {\n doc = parse(bytes.toString(\"utf8\"));\n } catch (e) {\n return { ...empty(), unreadable: true, error: e instanceof Error ? e.message : String(e) };\n }\n // A scalar or sequence document is as unreadable as broken YAML: there is no\n // mapping to look `paths` up in, so nothing can be concluded from it. null\n // (an empty file) stays readable — it defines nothing, and says so honestly.\n if (doc !== null && (typeof doc !== \"object\" || Array.isArray(doc))) {\n return { ...empty(), unreadable: true, error: \"document is not a YAML mapping\" };\n }\n const paths = (doc as { paths?: unknown } | null)?.paths;\n if (!paths || typeof paths !== \"object\") return empty();\n const ops: Operation[] = [];\n const anonymousRemovals: AnonymousRemoval[] = [];\n const pathLevelRemovals: string[] = [];\n const seen = new Map<string, number>();\n for (const [path, item] of Object.entries(paths as Record<string, unknown>)) {\n if (!item || typeof item !== \"object\") continue;\n if ((item as Record<string, unknown>)[\"x-loam-remove\"] === true) pathLevelRemovals.push(path);\n for (const [method, op] of Object.entries(item as Record<string, unknown>)) {\n if (!HTTP_METHODS.has(method)) continue;\n if (!op || typeof op !== \"object\") continue;\n const id = (op as Record<string, unknown>)[\"operationId\"];\n const remove = (op as Record<string, unknown>)[\"x-loam-remove\"] === true;\n if (typeof id !== \"string\" || id.length === 0) {\n if (remove) anonymousRemovals.push({ path, method });\n continue;\n }\n seen.set(id, (seen.get(id) ?? 0) + 1);\n const basedOn = operationBaselineOf(op);\n ops.push({\n id,\n deprecated: (op as Record<string, unknown>)[\"deprecated\"] === true,\n remove,\n path,\n method,\n digest: operationDigest(op),\n ...(basedOn === undefined ? {} : { basedOn }),\n });\n }\n }\n const duplicateIds = [...seen].filter(([, n]) => n > 1).map(([id]) => id);\n return { ops, duplicateIds, anonymousRemovals, pathLevelRemovals, unreadable: false };\n}\n\nfunction empty(): OpenapiDoc {\n return { ops: [], duplicateIds: [], anonymousRemovals: [], pathLevelRemovals: [], unreadable: false };\n}\n\n/** The operations alone — for every caller whose own finding already covers the unreadable case. */\nexport async function operations(openapiPath: string, context?: FleetContext): Promise<Operation[]> {\n return (await readOpenapi(openapiPath, context)).ops;\n}\n\n/** The operationIds alone — `operations` for every caller that asks only \"does it exist\". */\nexport async function operationIds(openapiPath: string, context?: FleetContext): Promise<string[]> {\n return (await operations(openapiPath, context)).map((o) => o.id);\n}\n\n/**\n * The operationIds a service will provide after a feature is applied: living\n * operations, plus feature upserts, minus explicit feature removals.\n *\n * Removals are applied BEFORE upserts, which is what makes the answer\n * independent of the order the feature's document happens to spell them in.\n * Interleaving them one operation at a time meant a relocation — the same id\n * removed from `/old` and defined on `/new` — answered \"defined\" or \"gone\"\n * depending on which path sorted first in the YAML, and the requirement\n * governing it was reported undefined (`spec-api.op-undefined`) on exactly one\n * of the two spellings of the same change.\n */\nexport async function serviceOperationIds(\n docsDir: string,\n service: string,\n featureDir?: string,\n context?: FleetContext,\n): Promise<string[]> {\n if (context !== undefined) return context.serviceOperationIds(docsDir, service, featureDir);\n const ids = new Set(\n (await operations(servicePaths(docsDir, service).openapi)).filter((op) => !op.remove).map((op) => op.id),\n );\n if (featureDir) {\n const featOps = await operations(featureSpecPaths(featureDir, service).openapi);\n for (const op of featOps) if (op.remove) ids.delete(op.id);\n for (const op of featOps) if (!op.remove) ids.add(op.id);\n }\n return [...ids];\n}\n"]}