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.
- package/README.md +242 -0
- package/dist/apispec/apispec.js +401 -0
- package/dist/apispec/apispec.test.js +444 -0
- package/dist/apispec/errors.js +17 -0
- package/dist/apispec/index.js +2 -0
- package/dist/check/doclinks.js +167 -0
- package/dist/check/index.js +8 -0
- package/dist/check/run.js +391 -0
- package/dist/check/run.test.js +513 -0
- package/dist/check/suggest.js +134 -0
- package/dist/check/suggest.test.js +92 -0
- package/dist/check/tokens.js +125 -0
- package/dist/cmd/main.js +330 -0
- package/dist/cmd/main.test.js +422 -0
- package/dist/codeq/cache.js +71 -0
- package/dist/codeq/cache.test.js +67 -0
- package/dist/codeq/errors.js +52 -0
- package/dist/codeq/grammars/tree-sitter-go.wasm +0 -0
- package/dist/codeq/grammars/tree-sitter-java.wasm +0 -0
- package/dist/codeq/grammars/tree-sitter-javascript.wasm +0 -0
- package/dist/codeq/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/codeq/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/codeq/index.js +11 -0
- package/dist/codeq/resolve.test.js +109 -0
- package/dist/codeq/resolver.js +128 -0
- package/dist/codeq/resolver.test.js +124 -0
- package/dist/codeq/resolvers/go.js +242 -0
- package/dist/codeq/resolvers/go.test.js +143 -0
- package/dist/codeq/resolvers/java.js +349 -0
- package/dist/codeq/resolvers/java.test.js +138 -0
- package/dist/codeq/resolvers/java_queries.js +63 -0
- package/dist/codeq/resolvers/javascript.js +412 -0
- package/dist/codeq/resolvers/javascript.test.js +125 -0
- package/dist/codeq/resolvers/javascript_queries.js +46 -0
- package/dist/codeq/resolvers/typescript.js +366 -0
- package/dist/codeq/resolvers/typescript.test.js +180 -0
- package/dist/codeq/resolvers/typescript_queries.js +78 -0
- package/dist/codeq/signature.js +50 -0
- package/dist/codeq/signature.test.js +50 -0
- package/dist/codeq/suggest.js +96 -0
- package/dist/codeq/treesitter.js +122 -0
- package/dist/codeq/treesitter.test.js +118 -0
- package/dist/config/config.js +74 -0
- package/dist/config/config.test.js +98 -0
- package/dist/config/fs.js +116 -0
- package/dist/config/glob.js +82 -0
- package/dist/config/glob.test.js +61 -0
- package/dist/config/index.js +4 -0
- package/dist/dedup/analyzer/analyzer.js +533 -0
- package/dist/dedup/analyzer/analyzer.test.js +530 -0
- package/dist/dedup/analyzer/canonical.js +74 -0
- package/dist/dedup/analyzer/canonical.test.js +70 -0
- package/dist/dedup/analyzer/cosine_clusters.js +169 -0
- package/dist/dedup/analyzer/cosine_clusters.test.js +131 -0
- package/dist/dedup/analyzer/distinctive.js +85 -0
- package/dist/dedup/analyzer/distinctive.test.js +49 -0
- package/dist/dedup/analyzer/exact_clusters.js +63 -0
- package/dist/dedup/analyzer/exact_clusters.test.js +81 -0
- package/dist/dedup/analyzer/index.js +14 -0
- package/dist/dedup/analyzer/multiplicity.js +110 -0
- package/dist/dedup/analyzer/multiplicity.test.js +123 -0
- package/dist/dedup/analyzer/order.js +22 -0
- package/dist/dedup/analyzer/partial_overlaps.js +65 -0
- package/dist/dedup/analyzer/partial_overlaps.test.js +161 -0
- package/dist/dedup/analyzer/preview.js +84 -0
- package/dist/dedup/analyzer/preview.test.js +46 -0
- package/dist/dedup/analyzer/safety.js +27 -0
- package/dist/dedup/analyzer/safety.test.js +39 -0
- package/dist/dedup/config.js +18 -0
- package/dist/dedup/configload.js +299 -0
- package/dist/dedup/configload.test.js +410 -0
- package/dist/dedup/dedup.index.test.js +203 -0
- package/dist/dedup/dedup.js +143 -0
- package/dist/dedup/dedup.test.js +212 -0
- package/dist/dedup/dedupcfg/config.js +112 -0
- package/dist/dedup/dedupcfg/config.test.js +70 -0
- package/dist/dedup/dedupcfg/index.js +1 -0
- package/dist/dedup/deduptypes/index.js +1 -0
- package/dist/dedup/deduptypes/types.js +9 -0
- package/dist/dedup/deduptypes/types.test.js +34 -0
- package/dist/dedup/embedder/cache.js +23 -0
- package/dist/dedup/embedder/cache.test.js +50 -0
- package/dist/dedup/embedder/constants.js +10 -0
- package/dist/dedup/embedder/embedder.js +76 -0
- package/dist/dedup/embedder/embedder.mock.test.js +128 -0
- package/dist/dedup/embedder/embedder.test.js +96 -0
- package/dist/dedup/embedder/errors.js +20 -0
- package/dist/dedup/embedder/errors.test.js +35 -0
- package/dist/dedup/embedder/index.js +4 -0
- package/dist/dedup/embedder/session.js +78 -0
- package/dist/dedup/embedder/session.test.js +172 -0
- package/dist/dedup/gitignore.js +97 -0
- package/dist/dedup/gitignore.test.js +98 -0
- package/dist/dedup/index.js +11 -0
- package/dist/dedup/indexdb/errors.js +48 -0
- package/dist/dedup/indexdb/index.js +6 -0
- package/dist/dedup/indexdb/indexdb.js +302 -0
- package/dist/dedup/indexdb/indexdb.test.js +739 -0
- package/dist/dedup/indexdb/load.js +110 -0
- package/dist/dedup/indexdb/migrations.js +58 -0
- package/dist/dedup/indexdb/schema.js +83 -0
- package/dist/dedup/indexer/index.js +9 -0
- package/dist/dedup/indexer/indexer.js +501 -0
- package/dist/dedup/indexer/indexer.test.js +510 -0
- package/dist/dedup/indexer/links.js +89 -0
- package/dist/dedup/mdsection/anchor.js +60 -0
- package/dist/dedup/mdsection/anchor.test.js +39 -0
- package/dist/dedup/mdsection/blocks.js +409 -0
- package/dist/dedup/mdsection/blocks.test.js +359 -0
- package/dist/dedup/mdsection/index.js +4 -0
- package/dist/dedup/mdsection/parse.js +21 -0
- package/dist/dedup/mdsection/section.js +234 -0
- package/dist/dedup/mdsection/section.test.js +221 -0
- package/dist/dedup/report/floatfmt.js +71 -0
- package/dist/dedup/report/floatfmt.test.js +42 -0
- package/dist/dedup/report/index.js +8 -0
- package/dist/dedup/report/quote.js +77 -0
- package/dist/dedup/report/quote.test.js +67 -0
- package/dist/dedup/report/text.js +251 -0
- package/dist/dedup/report/text.test.js +420 -0
- package/dist/dedup/report_types.js +8 -0
- package/dist/dedup/sectionid/index.js +1 -0
- package/dist/dedup/sectionid/sectionid.js +16 -0
- package/dist/dedup/sectionid/sectionid.test.js +49 -0
- package/dist/guard/api/errors.js +12 -0
- package/dist/guard/api/index.js +2 -0
- package/dist/guard/api/parser.js +81 -0
- package/dist/guard/api/parser.test.js +58 -0
- package/dist/guard/api/types.js +1 -0
- package/dist/guard/code/errors.js +16 -0
- package/dist/guard/code/index.js +2 -0
- package/dist/guard/code/parser.js +54 -0
- package/dist/guard/code/parser.test.js +111 -0
- package/dist/guard/code/types.js +6 -0
- package/dist/index.js +1 -0
- package/dist/index.test.js +5 -0
- package/dist/repo/boundary.js +92 -0
- package/dist/repo/boundary.test.js +65 -0
- package/dist/repo/errors.js +56 -0
- package/dist/repo/errors.test.js +85 -0
- package/dist/repo/exists.test.js +72 -0
- package/dist/repo/filename.js +46 -0
- package/dist/repo/filename.test.js +39 -0
- package/dist/repo/fs.js +53 -0
- package/dist/repo/index.js +7 -0
- package/dist/repo/overlay.js +36 -0
- package/dist/repo/overlay.test.js +80 -0
- package/dist/repo/repo.js +353 -0
- package/dist/repo/repo.test.js +255 -0
- package/dist/repo/testutil.js +27 -0
- package/dist/repo/write.test.js +125 -0
- package/dist/report/color.js +73 -0
- package/dist/report/index.js +1 -0
- package/dist/report/report.js +112 -0
- package/dist/report/report.test.js +368 -0
- package/dist/violation/index.js +1 -0
- package/dist/violation/types.js +22 -0
- package/dist/violation/types.test.js +70 -0
- 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 .
|
|
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
|
+
}
|