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
@@ -0,0 +1,444 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
5
+ import { load, parse } from "./apispec.js";
6
+ import { SpecNotFoundError, SpecParseError } from "./errors.js";
7
+ // Inline copy of internal/apispec/testdata/openapi.json. Covers normalisation,
8
+ // requestBody descent, allOf/oneOf composition, and a $ref cycle.
9
+ const OPENAPI = `{
10
+ "openapi": "3.1.0",
11
+ "info": { "title": "Test API", "version": "1.0.0" },
12
+ "paths": {
13
+ "/api/admin/teams": {
14
+ "get": {
15
+ "operationId": "listAdminTeams",
16
+ "parameters": [
17
+ { "name": "team_type", "in": "query", "schema": { "type": "string" } },
18
+ { "name": "page_size", "in": "query", "schema": { "type": "integer" } }
19
+ ],
20
+ "responses": {
21
+ "200": {
22
+ "description": "OK",
23
+ "content": {
24
+ "application/json": {
25
+ "schema": { "$ref": "#/components/schemas/AdminTeamsPage" }
26
+ }
27
+ }
28
+ }
29
+ }
30
+ },
31
+ "post": {
32
+ "operationId": "createAdminTeam",
33
+ "requestBody": {
34
+ "content": {
35
+ "application/json": {
36
+ "schema": {
37
+ "type": "object",
38
+ "properties": { "req_field": { "type": "string" } }
39
+ }
40
+ }
41
+ }
42
+ },
43
+ "responses": { "201": { "description": "created" } }
44
+ }
45
+ },
46
+ "/api/admin/teams/{teamId}": {
47
+ "get": {
48
+ "operationId": "getAdminTeam",
49
+ "parameters": [
50
+ { "name": "teamId", "in": "path", "required": true, "schema": { "type": "string" } }
51
+ ],
52
+ "responses": {
53
+ "200": {
54
+ "description": "OK",
55
+ "content": {
56
+ "application/json": {
57
+ "schema": { "$ref": "#/components/schemas/TeamSummary" }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ },
64
+ "/api/admin/items": {
65
+ "get": {
66
+ "operationId": "listAdminItems",
67
+ "responses": {
68
+ "200": {
69
+ "description": "OK",
70
+ "content": {
71
+ "application/json": {
72
+ "schema": { "$ref": "#/components/schemas/ItemsResult" }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ },
79
+ "/api/admin/tree": {
80
+ "get": {
81
+ "operationId": "getAdminTree",
82
+ "responses": {
83
+ "200": {
84
+ "description": "OK",
85
+ "content": {
86
+ "application/json": {
87
+ "schema": { "$ref": "#/components/schemas/TreeNode" }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ },
95
+ "components": {
96
+ "schemas": {
97
+ "AdminTeamsPage": {
98
+ "type": "object",
99
+ "properties": {
100
+ "items": {
101
+ "type": "array",
102
+ "items": { "$ref": "#/components/schemas/TeamSummary" }
103
+ },
104
+ "next_page_token": { "type": "string" }
105
+ }
106
+ },
107
+ "TeamSummary": {
108
+ "type": "object",
109
+ "properties": {
110
+ "team_id": { "type": "string" },
111
+ "team_key": { "type": "string" },
112
+ "name": { "type": "string" },
113
+ "team_type": { "type": "string" },
114
+ "owner_user_id": { "type": "string" },
115
+ "owner_display_name": { "type": "string" },
116
+ "member_count": { "type": "integer" }
117
+ }
118
+ },
119
+ "Base": {
120
+ "type": "object",
121
+ "properties": { "base_field": { "type": "string" } }
122
+ },
123
+ "ItemsResult": {
124
+ "allOf": [
125
+ { "$ref": "#/components/schemas/Base" },
126
+ { "type": "object", "properties": { "extra": { "type": "string" } } },
127
+ { "oneOf": [
128
+ { "type": "object", "properties": { "variant_field": { "type": "string" } } }
129
+ ] }
130
+ ]
131
+ },
132
+ "TreeNode": {
133
+ "type": "object",
134
+ "properties": {
135
+ "node_id": { "type": "string" },
136
+ "label": { "type": "string" },
137
+ "parent": { "$ref": "#/components/schemas/TreeNode" }
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }`;
143
+ // Inline copy of internal/apispec/testdata/nested.json. Covers param/header/body/
144
+ // response queries through $ref, allOf, and array-items descent.
145
+ const NESTED = `{
146
+ "openapi": "3.1.0",
147
+ "info": { "title": "Nested Test API", "version": "1.0.0" },
148
+ "paths": {
149
+ "/nested/{id}": {
150
+ "post": {
151
+ "operationId": "createNested",
152
+ "parameters": [
153
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
154
+ { "name": "lang", "in": "query", "schema": { "type": "string" } },
155
+ { "name": "X-Token","in": "header", "schema": { "type": "string" } }
156
+ ],
157
+ "requestBody": {
158
+ "content": {
159
+ "application/json": {
160
+ "schema": { "$ref": "#/components/schemas/CreateBody" }
161
+ }
162
+ }
163
+ },
164
+ "responses": {
165
+ "200": {
166
+ "description": "OK",
167
+ "headers": {
168
+ "X-Trace": { "schema": { "type": "string" } }
169
+ },
170
+ "content": {
171
+ "application/json": {
172
+ "schema": {
173
+ "type": "object",
174
+ "properties": {
175
+ "result": { "type": "string" }
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+ },
185
+ "components": {
186
+ "schemas": {
187
+ "Address": {
188
+ "type": "object",
189
+ "properties": {
190
+ "city": { "type": "string" },
191
+ "street": { "type": "string" }
192
+ }
193
+ },
194
+ "Tag": {
195
+ "type": "object",
196
+ "properties": {
197
+ "name": { "type": "string" },
198
+ "value": { "type": "string" }
199
+ }
200
+ },
201
+ "UserBase": {
202
+ "type": "object",
203
+ "properties": {
204
+ "email": { "type": "string" }
205
+ }
206
+ },
207
+ "UserFull": {
208
+ "allOf": [
209
+ { "$ref": "#/components/schemas/UserBase" },
210
+ {
211
+ "type": "object",
212
+ "properties": {
213
+ "address": { "$ref": "#/components/schemas/Address" },
214
+ "tags": {
215
+ "type": "array",
216
+ "items": { "$ref": "#/components/schemas/Tag" }
217
+ }
218
+ }
219
+ }
220
+ ]
221
+ },
222
+ "CreateBody": {
223
+ "type": "object",
224
+ "properties": {
225
+ "user": { "$ref": "#/components/schemas/UserFull" }
226
+ }
227
+ },
228
+ "SelfRef": {
229
+ "type": "object",
230
+ "properties": {
231
+ "self_id": { "type": "string" },
232
+ "child": { "$ref": "#/components/schemas/SelfRef" }
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }`;
238
+ function openapiSpec() {
239
+ return parse(OPENAPI, "openapi.json");
240
+ }
241
+ function nestedSpec() {
242
+ return parse(NESTED, "nested.json");
243
+ }
244
+ describe("hasOperation: path-template normalisation", () => {
245
+ // The guard must match a doc's {id} against the spec's {teamId}: operation
246
+ // existence keys on METHOD + normalised path, so var names are irrelevant.
247
+ test("exact method+path exists", () => {
248
+ expect(openapiSpec().hasOperation("GET", "/api/admin/teams")).toBe(true);
249
+ });
250
+ test("doc {id} normalises to spec {teamId}", () => {
251
+ expect(openapiSpec().hasOperation("GET", "/api/admin/teams/{id}")).toBe(true);
252
+ });
253
+ test("absent method on an existing path is false", () => {
254
+ expect(openapiSpec().hasOperation("DELETE", "/api/admin/teams")).toBe(false);
255
+ });
256
+ test("method is matched case-insensitively", () => {
257
+ expect(openapiSpec().hasOperation("get", "/api/admin/teams")).toBe(true);
258
+ });
259
+ });
260
+ describe("operationFields: contract field set", () => {
261
+ // The api guard checks {{api:...}} field references against the union of
262
+ // params + requestBody props + every response's props, walked recursively.
263
+ test("unions param names with response schema props (via $ref + array items)", () => {
264
+ const fields = openapiSpec().operationFields("GET", "/api/admin/teams");
265
+ expect(fields).toBeDefined();
266
+ for (const want of [
267
+ "team_type",
268
+ "page_size",
269
+ "items",
270
+ "next_page_token",
271
+ "team_id",
272
+ "owner_user_id",
273
+ ]) {
274
+ expect(fields?.has(want)).toBe(true);
275
+ }
276
+ });
277
+ test("does not invent fields that the contract never declares", () => {
278
+ expect(openapiSpec().operationFields("GET", "/api/admin/teams")?.has("does_not_exist")).toBe(false);
279
+ });
280
+ test("absent operation returns undefined (distinct from empty set)", () => {
281
+ expect(openapiSpec().operationFields("DELETE", "/api/admin/teams")).toBeUndefined();
282
+ });
283
+ // Exercises the requestBody.content.<media>.schema descent branch.
284
+ test("collects props from requestBody content schema", () => {
285
+ expect(openapiSpec().operationFields("POST", "/api/admin/teams")?.has("req_field")).toBe(true);
286
+ });
287
+ // allOf must contribute from every member (a $ref'd base + an inline object),
288
+ // and a nested oneOf branch must also be collected without breaking the walk.
289
+ test("resolves allOf members and nested oneOf branch", () => {
290
+ const fields = openapiSpec().operationFields("GET", "/api/admin/items");
291
+ expect(fields?.has("base_field")).toBe(true);
292
+ expect(fields?.has("extra")).toBe(true);
293
+ expect(fields?.has("variant_field")).toBe(true);
294
+ });
295
+ // A $ref cycle (TreeNode.parent -> TreeNode) must not infinite-loop; the
296
+ // visited-set lets collection terminate while still yielding direct props.
297
+ test("terminates on a $ref cycle and returns the node's own props", () => {
298
+ const fields = openapiSpec().operationFields("GET", "/api/admin/tree");
299
+ expect(fields?.has("node_id")).toBe(true);
300
+ expect(fields?.has("label")).toBe(true);
301
+ });
302
+ });
303
+ describe("hasParam: only path/query/cookie count", () => {
304
+ // The param facet of {{api:...}} addresses path/query/cookie params — headers
305
+ // and body fields are addressed by other facets and must not match here.
306
+ test("path param is a param", () => {
307
+ expect(nestedSpec().hasParam("POST", "/nested/{id}", "id")).toBe(true);
308
+ });
309
+ test("query param is a param", () => {
310
+ expect(nestedSpec().hasParam("POST", "/nested/{id}", "lang")).toBe(true);
311
+ });
312
+ test("header param is not a param", () => {
313
+ expect(nestedSpec().hasParam("POST", "/nested/{id}", "X-Token")).toBe(false);
314
+ });
315
+ test("a body-only name is not a param", () => {
316
+ expect(nestedSpec().hasParam("POST", "/nested/{id}", "email")).toBe(false);
317
+ });
318
+ });
319
+ describe("hasHeader: in:header params and response headers", () => {
320
+ // A header reference is satisfied either by an in:header parameter or by a
321
+ // response object's headers map.
322
+ test("in:header parameter matches", () => {
323
+ expect(nestedSpec().hasHeader("POST", "/nested/{id}", "X-Token")).toBe(true);
324
+ });
325
+ test("response headers map entry matches", () => {
326
+ expect(nestedSpec().hasHeader("POST", "/nested/{id}", "X-Trace")).toBe(true);
327
+ });
328
+ test("a body field name is not a header", () => {
329
+ expect(nestedSpec().hasHeader("POST", "/nested/{id}", "email")).toBe(false);
330
+ });
331
+ });
332
+ describe("hasBodyField: dotted path reachability in requestBody", () => {
333
+ // Body field references walk the request schema through $ref chains, allOf
334
+ // branches, and transparent array-items descent.
335
+ test("top-level field", () => {
336
+ expect(nestedSpec().hasBodyField("POST", "/nested/{id}", ["user"])).toBe(true);
337
+ });
338
+ test("reachable via $ref chain user -> UserFull(allOf) -> UserBase -> email", () => {
339
+ expect(nestedSpec().hasBodyField("POST", "/nested/{id}", ["user", "email"])).toBe(true);
340
+ });
341
+ test("deep nested user.address.city through $ref and allOf", () => {
342
+ expect(nestedSpec().hasBodyField("POST", "/nested/{id}", ["user", "address", "city"])).toBe(true);
343
+ });
344
+ // tags is an array; its items have a 'name' prop — array items are traversed
345
+ // without consuming a path segment.
346
+ test("array-items descent: user.tags.name", () => {
347
+ expect(nestedSpec().hasBodyField("POST", "/nested/{id}", ["user", "tags", "name"])).toBe(true);
348
+ });
349
+ test("bogus nested path is false", () => {
350
+ expect(nestedSpec().hasBodyField("POST", "/nested/{id}", ["user", "bogus"])).toBe(false);
351
+ });
352
+ test("a param-only name is not a body field", () => {
353
+ expect(nestedSpec().hasBodyField("POST", "/nested/{id}", ["lang"])).toBe(false);
354
+ });
355
+ // A self-referential schema must terminate for both a hit and a miss.
356
+ test("terminates on a $ref cycle (hit and miss)", () => {
357
+ const cycle = `{
358
+ "openapi": "3.1.0",
359
+ "info": {"title":"t","version":"1"},
360
+ "paths": {
361
+ "/cycle": {
362
+ "post": {
363
+ "requestBody": {
364
+ "content": {
365
+ "application/json": {
366
+ "schema": {"$ref": "#/components/schemas/SelfRef"}
367
+ }
368
+ }
369
+ },
370
+ "responses": {"200": {"description": "ok"}}
371
+ }
372
+ }
373
+ },
374
+ "components": {
375
+ "schemas": {
376
+ "SelfRef": {
377
+ "type": "object",
378
+ "properties": {
379
+ "self_id": {"type": "string"},
380
+ "child": {"$ref": "#/components/schemas/SelfRef"}
381
+ }
382
+ }
383
+ }
384
+ }
385
+ }`;
386
+ const s = parse(cycle, "cycle.json");
387
+ expect(s.hasBodyField("POST", "/cycle", ["self_id"])).toBe(true);
388
+ expect(s.hasBodyField("POST", "/cycle", ["nonexistent"])).toBe(false);
389
+ });
390
+ });
391
+ describe("hasResponseField: dotted path reachability in any response", () => {
392
+ test("top-level response field", () => {
393
+ expect(nestedSpec().hasResponseField("POST", "/nested/{id}", ["result"])).toBe(true);
394
+ });
395
+ test("a body-only field is not in the response schema", () => {
396
+ expect(nestedSpec().hasResponseField("POST", "/nested/{id}", ["email"])).toBe(false);
397
+ });
398
+ });
399
+ describe("load/parse error semantics", () => {
400
+ // I/O and parse failures are exceptional (thrown sentinels), unlike domain
401
+ // results which are returned data.
402
+ test("missing file throws SpecNotFoundError", async () => {
403
+ await expect(load(join(tmpdir(), "docgov-apispec-does-not-exist.json"))).rejects.toBeInstanceOf(SpecNotFoundError);
404
+ });
405
+ test("malformed syntax throws SpecParseError", () => {
406
+ expect(() => parse("{not json", "x.json")).toThrow(SpecParseError);
407
+ });
408
+ test("a non-object document throws SpecParseError", () => {
409
+ // YAML accepts a bare scalar; a spec must be a mapping at the top level.
410
+ expect(() => parse("42", "x.json")).toThrow(SpecParseError);
411
+ });
412
+ });
413
+ describe("load: reads YAML and JSON from disk", () => {
414
+ let dir;
415
+ beforeAll(async () => {
416
+ dir = await mkdtemp(join(tmpdir(), "docgov-apispec-"));
417
+ });
418
+ afterAll(async () => {
419
+ await rm(dir, { recursive: true, force: true });
420
+ });
421
+ // The guard accepts YAML specs too; load must parse YAML equivalently to JSON.
422
+ test("a YAML spec loads and queries like the JSON fixture", async () => {
423
+ const yamlSpec = `
424
+ openapi: "3.1.0"
425
+ info:
426
+ title: y
427
+ version: "1"
428
+ paths:
429
+ /y/{id}:
430
+ get:
431
+ parameters:
432
+ - name: id
433
+ in: path
434
+ responses:
435
+ "200":
436
+ description: ok
437
+ `;
438
+ const p = join(dir, "spec.yaml");
439
+ await writeFile(p, yamlSpec, "utf8");
440
+ const s = await load(p);
441
+ expect(s.hasOperation("GET", "/y/{anything}")).toBe(true);
442
+ expect(s.hasParam("GET", "/y/{id}", "id")).toBe(true);
443
+ });
444
+ });
@@ -0,0 +1,17 @@
1
+ // Sentinel error classes thrown by load/parse. These mirror Go's
2
+ // ErrSpecNotFound and ErrSpecParse, which are wrapped exceptional conditions
3
+ // (I/O and parse failures) — not domain results.
4
+ /** Thrown by load when the spec file is not present. */
5
+ export class SpecNotFoundError extends Error {
6
+ constructor(specPath) {
7
+ super(`apispec: spec not found: ${JSON.stringify(specPath)}`);
8
+ this.name = "SpecNotFoundError";
9
+ }
10
+ }
11
+ /** Thrown by load/parse when the spec file is not valid YAML/JSON (or not an object). */
12
+ export class SpecParseError extends Error {
13
+ constructor(specPath, detail) {
14
+ super(`apispec: spec parse error: ${JSON.stringify(specPath)}: ${detail}`);
15
+ this.name = "SpecParseError";
16
+ }
17
+ }
@@ -0,0 +1,2 @@
1
+ export { Spec, normalizePath, parse, load } from "./apispec.js";
2
+ export { SpecNotFoundError, SpecParseError } from "./errors.js";
@@ -0,0 +1,167 @@
1
+ // Port of internal/check/run_doclinks.go plus the doc-existence helpers that
2
+ // live at the bottom of internal/check/run.go.
3
+ //
4
+ // The DOC guard: parse markdown, walk the mdast tree collecting every link and
5
+ // image destination, and check each via the existence algorithm — skip external
6
+ // URLs (http/https/mailto…) and pure "#fragment" anchors; reject absolute paths
7
+ // and paths that escape the repo root; otherwise the destination must exist on
8
+ // disk. A "#fragment" appended to a real file path is stripped, and the file is
9
+ // still checked.
10
+ //
11
+ // The Go original walks the goldmark AST; this port walks the mdast tree
12
+ // (remark-parse + remark-gfm). Line numbers come from the node's
13
+ // position.start.line (mdast carries 1-indexed positions on every node), which
14
+ // replaces Go's manual first-Text-descendant offset search and its inline-node
15
+ // Lines() panic workaround.
16
+ import * as posix from "node:path/posix";
17
+ import { parseMarkdown } from "../dedup/mdsection/index.js";
18
+ import { isNotExist } from "../repo/fs.js";
19
+ import { Rules } from "../violation/index.js";
20
+ /**
21
+ * checkDocLinks parses src as markdown, walks the mdast tree collecting all link
22
+ * and image destination strings, and checks each via checkDocLink. Line numbers
23
+ * come from each node's position.start.line.
24
+ */
25
+ export async function checkDocLinks(file, src, fsys) {
26
+ const tree = parseMarkdown(src);
27
+ const vs = [];
28
+ const targets = [];
29
+ const visit = (n) => {
30
+ if (n.type === "link" || n.type === "image") {
31
+ const dest = n.url ?? "";
32
+ targets.push({ dest, line: n.position?.start.line ?? 1 });
33
+ }
34
+ const children = n.children;
35
+ if (children !== undefined) {
36
+ for (const c of children) {
37
+ visit(c);
38
+ }
39
+ }
40
+ };
41
+ visit(tree);
42
+ for (const t of targets) {
43
+ const v = await checkDocLink(file, t.dest, fsys, t.line);
44
+ if (v !== null) {
45
+ vs.push(v);
46
+ }
47
+ }
48
+ return vs;
49
+ }
50
+ /**
51
+ * runHasURLScheme returns true when target begins with a URL scheme like
52
+ * "http:", "https:", "mailto:", etc. Mirrors guard/docs.go.
53
+ */
54
+ function runHasURLScheme(target) {
55
+ for (let i = 0; i < target.length; i++) {
56
+ const ch = target[i];
57
+ if (ch === ":" && i > 0) {
58
+ return true;
59
+ }
60
+ if (!runIsAlphanumOrPlus(ch)) {
61
+ return false;
62
+ }
63
+ }
64
+ return false;
65
+ }
66
+ function runIsAlphanumOrPlus(ch) {
67
+ return ((ch >= "a" && ch <= "z") ||
68
+ (ch >= "A" && ch <= "Z") ||
69
+ (ch >= "0" && ch <= "9") ||
70
+ ch === "+" ||
71
+ ch === "-" ||
72
+ ch === ".");
73
+ }
74
+ /** runIsPureFragment returns true when target is a bare "#..." reference. */
75
+ function runIsPureFragment(target) {
76
+ return target.startsWith("#");
77
+ }
78
+ /** runStripFragment removes the #fragment suffix from target, if present. */
79
+ function runStripFragment(target) {
80
+ const idx = target.indexOf("#");
81
+ if (idx >= 0) {
82
+ return target.slice(0, idx);
83
+ }
84
+ return target;
85
+ }
86
+ /** runEscapesRoot reports whether a cleaned repo-relative path escapes the root. */
87
+ function runEscapesRoot(cleaned) {
88
+ return cleaned === ".." || cleaned.startsWith("../");
89
+ }
90
+ /**
91
+ * statExists reports whether repoRel resolves to an existing file OR directory
92
+ * on fsys. This mirrors Go's fs.Stat success (which is satisfied by both files
93
+ * and directories): a link target may legitimately be a directory. A genuinely
94
+ * missing path returns false; any other I/O fault propagates.
95
+ */
96
+ async function statExists(fsys, repoRel) {
97
+ if (repoRel === "." || repoRel === "") {
98
+ return true; // the root always exists
99
+ }
100
+ const segments = repoRel.split("/");
101
+ const dir = segments.slice(0, -1).join("/");
102
+ const last = segments[segments.length - 1];
103
+ let entries;
104
+ try {
105
+ entries = await fsys.readDir(dir === "" ? "." : dir);
106
+ }
107
+ catch (err) {
108
+ if (isNotExist(err)) {
109
+ return false;
110
+ }
111
+ throw err;
112
+ }
113
+ return entries.some((e) => e.name() === last);
114
+ }
115
+ /**
116
+ * checkDocLink applies the existence algorithm for one link destination from
117
+ * file. Returns null when the link passes.
118
+ */
119
+ async function checkDocLink(file, rawTarget, fsys, line) {
120
+ let target = rawTarget.trim();
121
+ if (target === "") {
122
+ return null;
123
+ }
124
+ if (runHasURLScheme(target) || runIsPureFragment(target)) {
125
+ return null;
126
+ }
127
+ target = runStripFragment(target);
128
+ if (target === "") {
129
+ return null;
130
+ }
131
+ if (posix.isAbsolute(target)) {
132
+ return {
133
+ rule: Rules.guardDocs,
134
+ file,
135
+ line,
136
+ sectionID: "",
137
+ expected: "",
138
+ actual: target,
139
+ message: "absolute path is not allowed",
140
+ };
141
+ }
142
+ // path.Clean(path.Join(path.Dir(file), target)) — posix throughout, slash paths.
143
+ const repoRel = posix.normalize(posix.join(posix.dirname(file), target));
144
+ if (runEscapesRoot(repoRel)) {
145
+ return {
146
+ rule: Rules.guardDocs,
147
+ file,
148
+ line,
149
+ sectionID: "",
150
+ expected: "",
151
+ actual: target,
152
+ message: "path escapes repo root",
153
+ };
154
+ }
155
+ if (!(await statExists(fsys, repoRel))) {
156
+ return {
157
+ rule: Rules.guardDocs,
158
+ file,
159
+ line,
160
+ sectionID: "",
161
+ expected: repoRel,
162
+ actual: "",
163
+ message: "referenced file does not exist",
164
+ };
165
+ }
166
+ return null;
167
+ }
@@ -0,0 +1,8 @@
1
+ // Public surface of the check package — the guard orchestrator.
2
+ //
3
+ // run() discovers governed scopes from the loaded Config, walks markdown in
4
+ // scope, and runs the code / doc / api guards over the repo, returning the
5
+ // collected violation Records sorted by (file, line).
6
+ export { run } from "./run.js";
7
+ export { iterCodeTokens, iterApiTokens } from "./tokens.js";
8
+ export { checkDocLinks } from "./doclinks.js";