docsgov 0.1.0

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 (159) hide show
  1. package/README.md +242 -0
  2. package/dist/apispec/apispec.js +401 -0
  3. package/dist/apispec/apispec.test.js +444 -0
  4. package/dist/apispec/errors.js +17 -0
  5. package/dist/apispec/index.js +2 -0
  6. package/dist/check/doclinks.js +167 -0
  7. package/dist/check/index.js +8 -0
  8. package/dist/check/run.js +391 -0
  9. package/dist/check/run.test.js +513 -0
  10. package/dist/check/suggest.js +134 -0
  11. package/dist/check/suggest.test.js +92 -0
  12. package/dist/check/tokens.js +125 -0
  13. package/dist/cmd/main.js +330 -0
  14. package/dist/cmd/main.test.js +422 -0
  15. package/dist/codeq/cache.js +71 -0
  16. package/dist/codeq/cache.test.js +67 -0
  17. package/dist/codeq/errors.js +52 -0
  18. package/dist/codeq/grammars/tree-sitter-go.wasm +0 -0
  19. package/dist/codeq/grammars/tree-sitter-java.wasm +0 -0
  20. package/dist/codeq/grammars/tree-sitter-javascript.wasm +0 -0
  21. package/dist/codeq/grammars/tree-sitter-tsx.wasm +0 -0
  22. package/dist/codeq/grammars/tree-sitter-typescript.wasm +0 -0
  23. package/dist/codeq/index.js +11 -0
  24. package/dist/codeq/resolve.test.js +109 -0
  25. package/dist/codeq/resolver.js +128 -0
  26. package/dist/codeq/resolver.test.js +124 -0
  27. package/dist/codeq/resolvers/go.js +242 -0
  28. package/dist/codeq/resolvers/go.test.js +143 -0
  29. package/dist/codeq/resolvers/java.js +349 -0
  30. package/dist/codeq/resolvers/java.test.js +138 -0
  31. package/dist/codeq/resolvers/java_queries.js +63 -0
  32. package/dist/codeq/resolvers/javascript.js +412 -0
  33. package/dist/codeq/resolvers/javascript.test.js +125 -0
  34. package/dist/codeq/resolvers/javascript_queries.js +46 -0
  35. package/dist/codeq/resolvers/typescript.js +366 -0
  36. package/dist/codeq/resolvers/typescript.test.js +180 -0
  37. package/dist/codeq/resolvers/typescript_queries.js +78 -0
  38. package/dist/codeq/signature.js +50 -0
  39. package/dist/codeq/signature.test.js +50 -0
  40. package/dist/codeq/suggest.js +96 -0
  41. package/dist/codeq/treesitter.js +122 -0
  42. package/dist/codeq/treesitter.test.js +118 -0
  43. package/dist/config/config.js +74 -0
  44. package/dist/config/config.test.js +98 -0
  45. package/dist/config/fs.js +116 -0
  46. package/dist/config/glob.js +82 -0
  47. package/dist/config/glob.test.js +61 -0
  48. package/dist/config/index.js +4 -0
  49. package/dist/dedup/analyzer/analyzer.js +533 -0
  50. package/dist/dedup/analyzer/analyzer.test.js +530 -0
  51. package/dist/dedup/analyzer/canonical.js +74 -0
  52. package/dist/dedup/analyzer/canonical.test.js +70 -0
  53. package/dist/dedup/analyzer/cosine_clusters.js +169 -0
  54. package/dist/dedup/analyzer/cosine_clusters.test.js +131 -0
  55. package/dist/dedup/analyzer/distinctive.js +85 -0
  56. package/dist/dedup/analyzer/distinctive.test.js +49 -0
  57. package/dist/dedup/analyzer/exact_clusters.js +63 -0
  58. package/dist/dedup/analyzer/exact_clusters.test.js +81 -0
  59. package/dist/dedup/analyzer/index.js +14 -0
  60. package/dist/dedup/analyzer/multiplicity.js +110 -0
  61. package/dist/dedup/analyzer/multiplicity.test.js +123 -0
  62. package/dist/dedup/analyzer/order.js +22 -0
  63. package/dist/dedup/analyzer/partial_overlaps.js +65 -0
  64. package/dist/dedup/analyzer/partial_overlaps.test.js +161 -0
  65. package/dist/dedup/analyzer/preview.js +84 -0
  66. package/dist/dedup/analyzer/preview.test.js +46 -0
  67. package/dist/dedup/analyzer/safety.js +27 -0
  68. package/dist/dedup/analyzer/safety.test.js +39 -0
  69. package/dist/dedup/config.js +18 -0
  70. package/dist/dedup/configload.js +299 -0
  71. package/dist/dedup/configload.test.js +410 -0
  72. package/dist/dedup/dedup.index.test.js +203 -0
  73. package/dist/dedup/dedup.js +143 -0
  74. package/dist/dedup/dedup.test.js +212 -0
  75. package/dist/dedup/dedupcfg/config.js +112 -0
  76. package/dist/dedup/dedupcfg/config.test.js +70 -0
  77. package/dist/dedup/dedupcfg/index.js +1 -0
  78. package/dist/dedup/deduptypes/index.js +1 -0
  79. package/dist/dedup/deduptypes/types.js +9 -0
  80. package/dist/dedup/deduptypes/types.test.js +34 -0
  81. package/dist/dedup/embedder/cache.js +23 -0
  82. package/dist/dedup/embedder/cache.test.js +50 -0
  83. package/dist/dedup/embedder/constants.js +10 -0
  84. package/dist/dedup/embedder/embedder.js +76 -0
  85. package/dist/dedup/embedder/embedder.mock.test.js +128 -0
  86. package/dist/dedup/embedder/embedder.test.js +96 -0
  87. package/dist/dedup/embedder/errors.js +20 -0
  88. package/dist/dedup/embedder/errors.test.js +35 -0
  89. package/dist/dedup/embedder/index.js +4 -0
  90. package/dist/dedup/embedder/session.js +78 -0
  91. package/dist/dedup/embedder/session.test.js +172 -0
  92. package/dist/dedup/gitignore.js +97 -0
  93. package/dist/dedup/gitignore.test.js +98 -0
  94. package/dist/dedup/index.js +11 -0
  95. package/dist/dedup/indexdb/errors.js +48 -0
  96. package/dist/dedup/indexdb/index.js +6 -0
  97. package/dist/dedup/indexdb/indexdb.js +302 -0
  98. package/dist/dedup/indexdb/indexdb.test.js +739 -0
  99. package/dist/dedup/indexdb/load.js +110 -0
  100. package/dist/dedup/indexdb/migrations.js +58 -0
  101. package/dist/dedup/indexdb/schema.js +83 -0
  102. package/dist/dedup/indexer/index.js +9 -0
  103. package/dist/dedup/indexer/indexer.js +501 -0
  104. package/dist/dedup/indexer/indexer.test.js +510 -0
  105. package/dist/dedup/indexer/links.js +89 -0
  106. package/dist/dedup/mdsection/anchor.js +60 -0
  107. package/dist/dedup/mdsection/anchor.test.js +39 -0
  108. package/dist/dedup/mdsection/blocks.js +409 -0
  109. package/dist/dedup/mdsection/blocks.test.js +359 -0
  110. package/dist/dedup/mdsection/index.js +4 -0
  111. package/dist/dedup/mdsection/parse.js +21 -0
  112. package/dist/dedup/mdsection/section.js +234 -0
  113. package/dist/dedup/mdsection/section.test.js +221 -0
  114. package/dist/dedup/report/floatfmt.js +71 -0
  115. package/dist/dedup/report/floatfmt.test.js +42 -0
  116. package/dist/dedup/report/index.js +8 -0
  117. package/dist/dedup/report/quote.js +77 -0
  118. package/dist/dedup/report/quote.test.js +67 -0
  119. package/dist/dedup/report/text.js +251 -0
  120. package/dist/dedup/report/text.test.js +420 -0
  121. package/dist/dedup/report_types.js +8 -0
  122. package/dist/dedup/sectionid/index.js +1 -0
  123. package/dist/dedup/sectionid/sectionid.js +16 -0
  124. package/dist/dedup/sectionid/sectionid.test.js +49 -0
  125. package/dist/guard/api/errors.js +12 -0
  126. package/dist/guard/api/index.js +2 -0
  127. package/dist/guard/api/parser.js +81 -0
  128. package/dist/guard/api/parser.test.js +58 -0
  129. package/dist/guard/api/types.js +1 -0
  130. package/dist/guard/code/errors.js +16 -0
  131. package/dist/guard/code/index.js +2 -0
  132. package/dist/guard/code/parser.js +54 -0
  133. package/dist/guard/code/parser.test.js +111 -0
  134. package/dist/guard/code/types.js +6 -0
  135. package/dist/index.js +1 -0
  136. package/dist/index.test.js +5 -0
  137. package/dist/repo/boundary.js +92 -0
  138. package/dist/repo/boundary.test.js +65 -0
  139. package/dist/repo/errors.js +56 -0
  140. package/dist/repo/errors.test.js +85 -0
  141. package/dist/repo/exists.test.js +72 -0
  142. package/dist/repo/filename.js +46 -0
  143. package/dist/repo/filename.test.js +39 -0
  144. package/dist/repo/fs.js +53 -0
  145. package/dist/repo/index.js +7 -0
  146. package/dist/repo/overlay.js +36 -0
  147. package/dist/repo/overlay.test.js +80 -0
  148. package/dist/repo/repo.js +353 -0
  149. package/dist/repo/repo.test.js +255 -0
  150. package/dist/repo/testutil.js +27 -0
  151. package/dist/repo/write.test.js +125 -0
  152. package/dist/report/color.js +73 -0
  153. package/dist/report/index.js +1 -0
  154. package/dist/report/report.js +112 -0
  155. package/dist/report/report.test.js +368 -0
  156. package/dist/violation/index.js +1 -0
  157. package/dist/violation/types.js +22 -0
  158. package/dist/violation/types.test.js +70 -0
  159. package/package.json +48 -0
package/README.md ADDED
@@ -0,0 +1,242 @@
1
+ # docgov
2
+
3
+ Config-driven **documentation governance**. `docgov` checks that the
4
+ references inside your Markdown docs still point at things that actually exist —
5
+ so docs rot loudly (a failing check) instead of silently.
6
+
7
+ It runs three independent guards over your `docs/`, plus a `dedup` analysis:
8
+
9
+ | Guard | Checks that… |
10
+ | --- | --- |
11
+ | **code** | every `{{code:path#Symbol}}` token resolves to a real symbol in your source tree (via tree-sitter) |
12
+ | **doc** | every Markdown link/image target exists |
13
+ | **api** | every `{{api: METHOD /path}}` token exists in your OpenAPI spec |
14
+
15
+ A fourth command, **dedup**, flags near-duplicate documentation concepts in
16
+ `docs/` using sentence embeddings.
17
+
18
+ The package source lives at the repository root (a TypeScript/Node project).
19
+
20
+ ---
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install -g docgov
26
+ docgov --version
27
+ ```
28
+
29
+ Or run it without installing:
30
+
31
+ ```bash
32
+ npx docgov check
33
+ ```
34
+
35
+ **Requirements**
36
+
37
+ - **Node.js >= 22.** docgov uses the built-in `node:sqlite` module (for the
38
+ `dedup` index), which requires Node 22 or newer.
39
+ - **No system libraries needed.** The tree-sitter grammars ship as bundled
40
+ WebAssembly, and the `dedup` embedder runs through `@huggingface/transformers`
41
+ (which bundles `onnxruntime-node`). There is no CGo, no ONNX Runtime install,
42
+ and no native toolchain to set up.
43
+ - `dedup` downloads its embedding model to a local cache on first use.
44
+
45
+ ---
46
+
47
+ ## Quick start
48
+
49
+ 1. Mark the repo root and declare what to govern by creating
50
+ **`.docgov/docgov.yaml`**:
51
+
52
+ ```yaml
53
+ code:
54
+ boundary: [docs/**] # .md files scanned for {{code:…}} tokens
55
+ source: [internal/**, cmd/**] # where those symbols must live
56
+ doc:
57
+ boundary: [docs/**] # every link/image target must exist
58
+ ```
59
+
60
+ 2. Run the check from anywhere inside the repo:
61
+
62
+ ```bash
63
+ docgov check
64
+ ```
65
+
66
+ Exit `0` = clean, `1` = violations found, `2` = config/usage error.
67
+
68
+ ---
69
+
70
+ ## Commands
71
+
72
+ ### `docgov check [--format text|json]`
73
+
74
+ Runs the code, doc, and api guards over their configured scopes and reports
75
+ every violation. `--format json` emits machine-readable output for CI;
76
+ `--format text` (the default) is styled for the terminal.
77
+
78
+ ### `docgov dedup`
79
+
80
+ Refreshes the embedding index for `docs/` and reports near-duplicate concepts.
81
+ Exits `1` if a high-confidence duplicate group is found.
82
+
83
+ ### `docgov update [--version vX.Y.Z] [--check]`
84
+
85
+ docgov is distributed via npm, so `update` does not self-replace the binary.
86
+
87
+ - `docgov update --check` reports the current version and the latest version
88
+ from the npm registry (best-effort; degrades gracefully when offline).
89
+ - `docgov update` (or with `--version vX.Y.Z`) prints the install command to run,
90
+ e.g. `npm install -g docgov@latest`.
91
+
92
+ ### Exit codes
93
+
94
+ | Code | Meaning |
95
+ | --- | --- |
96
+ | `0` | success — no violations |
97
+ | `1` | `check` found violations (or `dedup` found duplicates) |
98
+ | `2` | usage or configuration error (e.g. no `.docgov/` found, bad `--format`) |
99
+
100
+ These are stable, so CI can gate on them directly.
101
+
102
+ ---
103
+
104
+ ## Configuration
105
+
106
+ docgov reads a **single** file: `.docgov/docgov.yaml`. The `.docgov/` directory
107
+ is also the **repo-root marker** — docgov walks up from the working directory
108
+ until it finds one. A missing file makes `check` exit `2`.
109
+
110
+ The file has three optional top-level sections. Each has a `boundary` (which
111
+ `.md` files to scan) and, for `code`/`api`, a `source` (where referenced
112
+ targets must live). `doc` takes only `boundary`.
113
+
114
+ ```yaml
115
+ code: # omit → code guard skipped
116
+ boundary: [docs/**] # .md files scanned for {{code:…}} tokens
117
+ source: [internal/**, cmd/**] # a token's path must be under source
118
+
119
+ doc: # omit → doc guard skipped
120
+ boundary: [docs/**] # every markdown link/image target is existence-checked
121
+
122
+ api: # omit → api guard skipped
123
+ boundary: [docs/api/contract/**] # .md files scanned for {{api:…}} tokens
124
+ source: [docs/api/openapi/**] # .json OpenAPI specs (union of all matched)
125
+ ```
126
+
127
+ **Semantics**
128
+
129
+ - Glob patterns use `**` to match any depth. A trailing `/` is normalized
130
+ to `/**`.
131
+ - The three scopes are **independent and overlapping**: a file may be in more
132
+ than one boundary, and each guard runs its own pass. There is no precedence.
133
+ - An **omitted** section skips that guard entirely. A **present-but-empty**
134
+ section runs the guard with no files in scope (no violations).
135
+
136
+ Full reference: [`docs/config.md`](docs/config.md) and
137
+ [`docs/guards.md`](docs/guards.md).
138
+
139
+ ---
140
+
141
+ ## Reference syntax
142
+
143
+ These tokens live in your Markdown **prose**. Tokens inside fenced code blocks
144
+ or inline code spans are skipped.
145
+
146
+ ### Code references — `{{code:path#Symbol}}`
147
+
148
+ ```markdown
149
+ Loading is handled by {{code:internal/config/config.go#LoadConfig}}.
150
+ ```
151
+
152
+ - `path` must be under one of `code.source`, else a violation.
153
+ - `Symbol` is resolved with tree-sitter: top-level types, functions, variables,
154
+ and interface members resolve. Pointer-receiver methods do **not** resolve as
155
+ `#Member` — cite the type instead.
156
+
157
+ ### API references — `{{api: METHOD /path [qualifier]}}`
158
+
159
+ ```markdown
160
+ Create a team: {{api: POST /teams}} with body field {{api: POST /teams body:name}}.
161
+ ```
162
+
163
+ Qualifiers: none (operation exists), `param:name`, `header:name`,
164
+ `body:a.b.c`, `response:a.b.c`. Path templates are normalized
165
+ (`{id}` ≡ `{teamId}`).
166
+
167
+ ### Doc links — standard Markdown
168
+
169
+ The doc guard checks ordinary Markdown links and images:
170
+
171
+ ```markdown
172
+ See [the config reference](config.md) and ![the diagram](img/flow.png).
173
+ ```
174
+
175
+ External URLs (`http:`, `https:`, `mailto:`), pure fragments (`#heading`), and
176
+ `file.md#frag` suffixes are handled for you; absolute paths and paths escaping
177
+ the repo root are rejected.
178
+
179
+ ---
180
+
181
+ ## Folder structure
182
+
183
+ A governed repository looks like this — the only required piece is
184
+ `.docgov/docgov.yaml`; the `docs/` layout is yours to choose:
185
+
186
+ ```
187
+ your-repo/
188
+ ├── .docgov/
189
+ │ └── docgov.yaml # the single config file + repo-root marker
190
+ ├── docs/ # whatever structure you like
191
+ │ ├── README.md
192
+ │ ├── config.md
193
+ │ └── ...
194
+ ├── internal/ # source referenced by {{code:…}} tokens
195
+ └── cmd/
196
+ ```
197
+
198
+ This repository governs **its own** `docs/` with docgov; see
199
+ [`docs/README.md`](docs/README.md) for that layout as a worked example.
200
+
201
+ ---
202
+
203
+ ## CI usage
204
+
205
+ `check` is exit-code driven, so gating a pipeline is one line:
206
+
207
+ ```yaml
208
+ docs-governance:
209
+ image: node:22
210
+ script:
211
+ - npm install -g docgov
212
+ - docgov check # exits 1 on violations → job fails
213
+ ```
214
+
215
+ ---
216
+
217
+ ## Development
218
+
219
+ The TypeScript source lives under [`src/`](src/):
220
+
221
+ ```bash
222
+ npm ci
223
+ npm run typecheck # tsc --noEmit
224
+ npm test # vitest run
225
+ npm run build # tsc + copy bundled .wasm grammars into dist/
226
+ ```
227
+
228
+ `npm run build` compiles to `dist/` and copies the vendored tree-sitter
229
+ `.wasm` grammars into `dist/codeq/grammars/` so the published package resolves
230
+ them at runtime. The repo-root [`Makefile`](Makefile) provides thin wrappers
231
+ (`make build`, `make test`, `make typecheck`, `make self-check`, `make clean`)
232
+ that delegate to these npm scripts.
233
+
234
+ ---
235
+
236
+ ## Documentation
237
+
238
+ - [`docs/README.md`](docs/README.md) — documentation index
239
+ - [`docs/config.md`](docs/config.md) — full config schema
240
+ - [`docs/guards.md`](docs/guards.md) — what each guard checks
241
+ - [`docs/command/`](docs/command/) — per-command reference
242
+ - [`docs/architecture/`](docs/architecture/) — system design
@@ -0,0 +1,401 @@
1
+ // Loads and queries a self-contained OpenAPI v3.1 document. It supports
2
+ // operation existence (path-template normalised) and loose field-name set
3
+ // queries used by the api guard passes in internal/check.
4
+ //
5
+ // The Go original keeps schema bodies as json.RawMessage and re-parses them at
6
+ // each recursion step; here the document is parsed once into a plain object
7
+ // tree (via the yaml lib, which accepts JSON as a subset) and the walks operate
8
+ // directly on those already-parsed nodes. Where Go's json.Unmarshal would fail
9
+ // because a blob is not the expected shape, this port type-checks and skips.
10
+ import { readFile } from "node:fs/promises";
11
+ import YAML from "yaml";
12
+ import { SpecNotFoundError, SpecParseError } from "./errors.js";
13
+ const SCHEMA_REF_PREFIX = "#/components/schemas/";
14
+ const COMBINERS = ["allOf", "oneOf", "anyOf"];
15
+ function isObject(v) {
16
+ return typeof v === "object" && v !== null && !Array.isArray(v);
17
+ }
18
+ function asString(v) {
19
+ return typeof v === "string" ? v : "";
20
+ }
21
+ /**
22
+ * Spec is a loaded, self-contained OpenAPI v3.1 document. Only the parts docgov
23
+ * needs are typed; schema bodies stay as generic nodes for recursive walking.
24
+ */
25
+ export class Spec {
26
+ // paths: normalised-or-raw path -> method (lowercase) -> operation.
27
+ paths;
28
+ // components.schemas: schema name -> raw schema node.
29
+ schemas;
30
+ constructor(paths, schemas) {
31
+ this.paths = paths;
32
+ this.schemas = schemas;
33
+ }
34
+ /** Builds a Spec from an already-parsed document object. */
35
+ static fromDocument(doc) {
36
+ const paths = new Map();
37
+ const pathsNode = doc["paths"];
38
+ if (isObject(pathsNode)) {
39
+ for (const [specPath, methodsNode] of Object.entries(pathsNode)) {
40
+ if (!isObject(methodsNode)) {
41
+ continue;
42
+ }
43
+ const methods = new Map();
44
+ for (const [method, opNode] of Object.entries(methodsNode)) {
45
+ if (!isObject(opNode)) {
46
+ continue;
47
+ }
48
+ methods.set(method.toLowerCase(), parseOperation(opNode));
49
+ }
50
+ paths.set(specPath, methods);
51
+ }
52
+ }
53
+ const schemas = new Map();
54
+ const components = doc["components"];
55
+ if (isObject(components)) {
56
+ const schemasNode = components["schemas"];
57
+ if (isObject(schemasNode)) {
58
+ for (const [name, schema] of Object.entries(schemasNode)) {
59
+ schemas.set(name, schema);
60
+ }
61
+ }
62
+ }
63
+ return new Spec(paths, schemas);
64
+ }
65
+ /** Returns the operation for method+path under path-template normalisation. */
66
+ findOp(method, path) {
67
+ const want = normalizePath(path);
68
+ const m = method.toLowerCase();
69
+ for (const [specPath, methods] of this.paths) {
70
+ if (normalizePath(specPath) !== want) {
71
+ continue;
72
+ }
73
+ const op = methods.get(m);
74
+ if (op !== undefined) {
75
+ return op;
76
+ }
77
+ }
78
+ return undefined;
79
+ }
80
+ /** Reports whether method+path exists in the spec (path-template normalised). */
81
+ hasOperation(method, path) {
82
+ return this.findOp(method, path) !== undefined;
83
+ }
84
+ /**
85
+ * Returns every field name reachable in the operation's contract: parameter
86
+ * names ∪ requestBody schema props ∪ all response schema props, recursively
87
+ * through internal $ref, array items, and allOf/oneOf/anyOf. Returns undefined
88
+ * if the operation is absent.
89
+ */
90
+ operationFields(method, path) {
91
+ const op = this.findOp(method, path);
92
+ if (op === undefined) {
93
+ return undefined;
94
+ }
95
+ const out = new Set();
96
+ for (const p of op.parameters) {
97
+ if (p.name !== "") {
98
+ out.add(p.name);
99
+ }
100
+ }
101
+ const visited = new Set();
102
+ this.collectSchemaFields(op.requestBody, out, visited);
103
+ for (const resp of op.responses) {
104
+ this.collectSchemaFields(resp, out, visited);
105
+ }
106
+ return out;
107
+ }
108
+ /**
109
+ * Walks a node and collects every reachable field name into out. Handles
110
+ * content descent, internal $ref (cycle-guarded), properties, array items,
111
+ * and allOf/oneOf/anyOf.
112
+ */
113
+ collectSchemaFields(node, out, visited) {
114
+ if (!isObject(node)) {
115
+ return;
116
+ }
117
+ // content → descend each media-type entry's schema
118
+ const content = node["content"];
119
+ if (isObject(content)) {
120
+ for (const media of Object.values(content)) {
121
+ if (isObject(media) && "schema" in media) {
122
+ this.collectSchemaFields(media["schema"], out, visited);
123
+ }
124
+ }
125
+ }
126
+ // $ref → "#/components/schemas/X"
127
+ const ref = asString(node["$ref"]);
128
+ if (ref.startsWith(SCHEMA_REF_PREFIX)) {
129
+ const name = ref.slice(SCHEMA_REF_PREFIX.length);
130
+ if (!visited.has(name)) {
131
+ visited.add(name);
132
+ if (this.schemas.has(name)) {
133
+ this.collectSchemaFields(this.schemas.get(name), out, visited);
134
+ }
135
+ }
136
+ }
137
+ // properties → add each key, recurse into values
138
+ const props = node["properties"];
139
+ if (isObject(props)) {
140
+ for (const [key, val] of Object.entries(props)) {
141
+ out.add(key);
142
+ this.collectSchemaFields(val, out, visited);
143
+ }
144
+ }
145
+ // items → recurse (array)
146
+ if ("items" in node) {
147
+ this.collectSchemaFields(node["items"], out, visited);
148
+ }
149
+ // allOf / oneOf / anyOf → recurse into each element
150
+ for (const combiner of COMBINERS) {
151
+ const arr = node[combiner];
152
+ if (Array.isArray(arr)) {
153
+ for (const elem of arr) {
154
+ this.collectSchemaFields(elem, out, visited);
155
+ }
156
+ }
157
+ }
158
+ }
159
+ /**
160
+ * Reports whether a parameter named name with in ∈ {path, query, cookie}
161
+ * exists on the given operation (path-template normalised).
162
+ */
163
+ hasParam(method, path, name) {
164
+ const op = this.findOp(method, path);
165
+ if (op === undefined) {
166
+ return false;
167
+ }
168
+ for (const p of op.parameters) {
169
+ if (p.name === name) {
170
+ if (p.in === "path" || p.in === "query" || p.in === "cookie") {
171
+ return true;
172
+ }
173
+ }
174
+ }
175
+ return false;
176
+ }
177
+ /**
178
+ * Reports whether a parameter with in=="header" named name exists, OR any
179
+ * response object declares a header named name.
180
+ */
181
+ hasHeader(method, path, name) {
182
+ const op = this.findOp(method, path);
183
+ if (op === undefined) {
184
+ return false;
185
+ }
186
+ for (const p of op.parameters) {
187
+ if (p.in === "header" && p.name === name) {
188
+ return true;
189
+ }
190
+ }
191
+ for (const resp of op.responses) {
192
+ for (const h of responseHeaderNames(resp)) {
193
+ if (h === name) {
194
+ return true;
195
+ }
196
+ }
197
+ }
198
+ return false;
199
+ }
200
+ /**
201
+ * Reports whether the path segs (e.g. ["user","address","city"]) is reachable
202
+ * inside the requestBody schema.
203
+ */
204
+ hasBodyField(method, path, segs) {
205
+ const op = this.findOp(method, path);
206
+ if (op === undefined) {
207
+ return false;
208
+ }
209
+ return this.walkContentSchemas(op.requestBody, segs);
210
+ }
211
+ /**
212
+ * Reports whether the path segs is reachable inside any response schema.
213
+ */
214
+ hasResponseField(method, path, segs) {
215
+ const op = this.findOp(method, path);
216
+ if (op === undefined) {
217
+ return false;
218
+ }
219
+ for (const resp of op.responses) {
220
+ if (this.walkContentSchemas(resp, segs)) {
221
+ return true;
222
+ }
223
+ }
224
+ return false;
225
+ }
226
+ /**
227
+ * Extracts content[*].schema from node and walks each with segs. Returns true
228
+ * if walkSchemaPath succeeds on any media-type schema.
229
+ */
230
+ walkContentSchemas(node, segs) {
231
+ if (!isObject(node)) {
232
+ return false;
233
+ }
234
+ const content = node["content"];
235
+ if (!isObject(content)) {
236
+ return false;
237
+ }
238
+ for (const media of Object.values(content)) {
239
+ if (!isObject(media) || !("schema" in media)) {
240
+ continue;
241
+ }
242
+ const visited = new Set();
243
+ if (this.walkSchemaPath(media["schema"], segs, visited)) {
244
+ return true;
245
+ }
246
+ }
247
+ return false;
248
+ }
249
+ /**
250
+ * Navigates a schema node following segs as a dotted path. Returns true when
251
+ * all segments have been consumed (target reached). Handles $ref (cycle
252
+ * guard), allOf/oneOf/anyOf (any branch), properties descent, and transparent
253
+ * array items descent (same segs, no segment consumed).
254
+ */
255
+ walkSchemaPath(node, segs, visited) {
256
+ if (segs.length === 0) {
257
+ return true;
258
+ }
259
+ if (!isObject(node)) {
260
+ return false;
261
+ }
262
+ // $ref → follow into component schema (cycle guard)
263
+ const ref = asString(node["$ref"]);
264
+ if (ref.startsWith(SCHEMA_REF_PREFIX)) {
265
+ const name = ref.slice(SCHEMA_REF_PREFIX.length);
266
+ if (!visited.has(name)) {
267
+ visited.add(name);
268
+ if (this.schemas.has(name)) {
269
+ if (this.walkSchemaPath(this.schemas.get(name), segs, visited)) {
270
+ return true;
271
+ }
272
+ }
273
+ }
274
+ }
275
+ // allOf / oneOf / anyOf → true if any branch satisfies segs
276
+ for (const combiner of COMBINERS) {
277
+ const arr = node[combiner];
278
+ if (Array.isArray(arr)) {
279
+ for (const elem of arr) {
280
+ if (this.walkSchemaPath(elem, segs, visited)) {
281
+ return true;
282
+ }
283
+ }
284
+ }
285
+ }
286
+ // properties → if segs[0] matches a property key, recurse with segs[1:]
287
+ const props = node["properties"];
288
+ const head = segs[0];
289
+ if (isObject(props) && head !== undefined && head in props) {
290
+ if (this.walkSchemaPath(props[head], segs.slice(1), visited)) {
291
+ return true;
292
+ }
293
+ }
294
+ // items → transparent array descent: descend into items with same segs
295
+ if ("items" in node) {
296
+ if (this.walkSchemaPath(node["items"], segs, visited)) {
297
+ return true;
298
+ }
299
+ }
300
+ return false;
301
+ }
302
+ }
303
+ /** Builds an Operation from a parsed operation object node. */
304
+ function parseOperation(node) {
305
+ const parameters = [];
306
+ const paramsNode = node["parameters"];
307
+ if (Array.isArray(paramsNode)) {
308
+ for (const p of paramsNode) {
309
+ if (isObject(p)) {
310
+ parameters.push({ name: asString(p["name"]), in: asString(p["in"]) });
311
+ }
312
+ }
313
+ }
314
+ const responses = [];
315
+ const responsesNode = node["responses"];
316
+ if (isObject(responsesNode)) {
317
+ for (const resp of Object.values(responsesNode)) {
318
+ responses.push(resp);
319
+ }
320
+ }
321
+ return { parameters, requestBody: node["requestBody"], responses };
322
+ }
323
+ /**
324
+ * Replaces every {var} segment with {} so a doc's {id} matches the spec's
325
+ * {teamId}. Trailing slash is trimmed (except root "/").
326
+ */
327
+ export function normalizePath(p) {
328
+ let b = "";
329
+ let inVar = false;
330
+ for (const r of p) {
331
+ if (r === "{") {
332
+ inVar = true;
333
+ b += "{}";
334
+ }
335
+ else if (r === "}") {
336
+ inVar = false;
337
+ }
338
+ else if (inVar) {
339
+ // skip var-name characters
340
+ }
341
+ else {
342
+ b += r;
343
+ }
344
+ }
345
+ let out = b;
346
+ if (out.length > 1) {
347
+ out = out.replace(/\/+$/, "");
348
+ }
349
+ return out;
350
+ }
351
+ /**
352
+ * Returns the header keys declared in a response node. The response object
353
+ * shape is: {"headers": {"X-Trace": {...}, ...}, ...}.
354
+ */
355
+ function responseHeaderNames(node) {
356
+ if (!isObject(node)) {
357
+ return [];
358
+ }
359
+ const headers = node["headers"];
360
+ if (!isObject(headers)) {
361
+ return [];
362
+ }
363
+ return Object.keys(headers);
364
+ }
365
+ /**
366
+ * Parses spec text (YAML or JSON) into a Spec. Invalid syntax — or a document
367
+ * whose top level is not an object — throws SpecParseError.
368
+ */
369
+ export function parse(data, specPath) {
370
+ let doc;
371
+ try {
372
+ doc = YAML.parse(data);
373
+ }
374
+ catch (err) {
375
+ const detail = err instanceof Error ? err.message : String(err);
376
+ throw new SpecParseError(specPath, detail);
377
+ }
378
+ if (!isObject(doc)) {
379
+ throw new SpecParseError(specPath, "document is not an object");
380
+ }
381
+ return Spec.fromDocument(doc);
382
+ }
383
+ /**
384
+ * Reads and parses specPath from disk. A missing file throws SpecNotFoundError;
385
+ * invalid YAML/JSON throws SpecParseError.
386
+ */
387
+ export async function load(specPath) {
388
+ let data;
389
+ try {
390
+ data = await readFile(specPath, "utf8");
391
+ }
392
+ catch (err) {
393
+ if (typeof err === "object" &&
394
+ err !== null &&
395
+ err.code === "ENOENT") {
396
+ throw new SpecNotFoundError(specPath);
397
+ }
398
+ throw err;
399
+ }
400
+ return parse(data, specPath);
401
+ }