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,513 @@
1
+ // Tests for the three-pass run pipeline (port of internal/check/run_test.go).
2
+ //
3
+ // These are behavior-encoding tests: each asserts WHY a guard fires (or stays
4
+ // silent) for a specific drift shape, not merely that some count is produced.
5
+ // A stub Resolver stands in for the codeq oracle (Go's runStubResolver) so the
6
+ // code pass is exercised without a real tree-sitter parse.
7
+ import * as nodefs from "node:fs/promises";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import { afterEach, describe, expect, it } from "vitest";
11
+ import { loadConfig } from "../config/index.js";
12
+ import { find } from "../repo/index.js";
13
+ import { Rules } from "../violation/index.js";
14
+ import { run } from "./run.js";
15
+ // --- stub resolver ---
16
+ /**
17
+ * StubResolver implements the check Resolver without tree-sitter. It returns the
18
+ * configured found value (or rejects with err) regardless of the ref — the
19
+ * analogue of Go's runStubResolver. This isolates the code pass's
20
+ * parse → source-scope → resolve orchestration from the real symbol oracle.
21
+ */
22
+ class StubResolver {
23
+ found;
24
+ err;
25
+ constructor(found, err) {
26
+ this.found = found;
27
+ this.err = err;
28
+ }
29
+ resolve(_root, _ref) {
30
+ if (this.err !== undefined) {
31
+ return Promise.reject(this.err);
32
+ }
33
+ return Promise.resolve(this.found);
34
+ }
35
+ }
36
+ /**
37
+ * SuggestingResolver always reports a symbol absent and returns a fixed
38
+ * Suggestion, so the not-found path's "did you mean" wiring is exercised without
39
+ * a real tree-sitter parse.
40
+ */
41
+ class SuggestingResolver {
42
+ sug;
43
+ constructor(sug) {
44
+ this.sug = sug;
45
+ }
46
+ resolve(_root, _ref) {
47
+ return Promise.resolve(false);
48
+ }
49
+ suggest(_root, _ref) {
50
+ return Promise.resolve(this.sug);
51
+ }
52
+ }
53
+ // --- temp-repo helper ---
54
+ const createdRoots = [];
55
+ afterEach(async () => {
56
+ for (const root of createdRoots.splice(0)) {
57
+ await nodefs.rm(root, { recursive: true, force: true });
58
+ }
59
+ });
60
+ /**
61
+ * tempRepo creates a temp dir, writes the given files (map path→content), and
62
+ * creates a .docgov/ sentinel dir so find() anchors there. Mirrors the Go
63
+ * tempRepo helper.
64
+ */
65
+ async function tempRepo(files) {
66
+ const root = await nodefs.realpath(await nodefs.mkdtemp(path.join(os.tmpdir(), "docgov-check-")));
67
+ createdRoots.push(root);
68
+ await nodefs.mkdir(path.join(root, ".docgov"), { recursive: true });
69
+ for (const [rel, content] of Object.entries(files)) {
70
+ const abs = path.join(root, ...rel.split("/"));
71
+ await nodefs.mkdir(path.dirname(abs), { recursive: true });
72
+ await nodefs.writeFile(abs, content);
73
+ }
74
+ return find(root);
75
+ }
76
+ // --- config builders ---
77
+ function cfgCode(boundary, source) {
78
+ return { Code: { boundary, source } };
79
+ }
80
+ function cfgDoc(boundary) {
81
+ return { Doc: { boundary, source: [] } };
82
+ }
83
+ function cfgAPI(boundary, source) {
84
+ return { API: { boundary, source } };
85
+ }
86
+ // openAPIFixtureJSON: GET /api/v1/items with query param "filter", header
87
+ // "X-Trace", body field "name" (+ nested.field), and response field "id".
88
+ const openAPIFixtureJSON = `{
89
+ "openapi": "3.1.0",
90
+ "info": {"title": "Test", "version": "1"},
91
+ "paths": {
92
+ "/api/v1/items": {
93
+ "get": {
94
+ "parameters": [
95
+ {"name": "filter", "in": "query", "schema": {"type": "string"}},
96
+ {"name": "X-Trace", "in": "header", "schema": {"type": "string"}}
97
+ ],
98
+ "requestBody": {
99
+ "content": {
100
+ "application/json": {
101
+ "schema": {
102
+ "type": "object",
103
+ "properties": {
104
+ "name": {"type": "string"},
105
+ "nested": {
106
+ "type": "object",
107
+ "properties": {"field": {"type": "string"}}
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ },
114
+ "responses": {
115
+ "200": {
116
+ "content": {
117
+ "application/json": {
118
+ "schema": {
119
+ "type": "object",
120
+ "properties": {"id": {"type": "string"}}
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }`;
130
+ // --- CODE PASS ---
131
+ describe("run / code pass", () => {
132
+ // A well-formed code ref that resolves must produce no code violation — the
133
+ // happy path the whole pipeline exists to certify.
134
+ it("emits no code violation for a valid resolved ref", async () => {
135
+ const r = await tempRepo({
136
+ "docs/guide.md": "See {{code:internal/foo.go#Bar}} for details.\n",
137
+ });
138
+ const vs = await run(cfgCode(["docs/**"], ["internal/**"]), r, new StubResolver(true));
139
+ expect(vs.filter((v) => v.rule === Rules.guardCode)).toEqual([]);
140
+ });
141
+ // A ref whose path is outside code.source must fail BEFORE resolution — the
142
+ // source-scope rule keeps docs from citing code the guard isn't told about.
143
+ it("flags a ref pointing outside code.source", async () => {
144
+ const r = await tempRepo({
145
+ "docs/guide.md": "See {{code:vendor/lib.go#Foo}} for details.\n",
146
+ });
147
+ const vs = await run(cfgCode(["docs/**"], ["internal/**"]), r, new StubResolver(true));
148
+ const codeVs = vs.filter((v) => v.rule === Rules.guardCode);
149
+ expect(codeVs).toHaveLength(1);
150
+ expect(codeVs[0]?.message).toBe("ref points outside source");
151
+ });
152
+ // resolver found=false means the symbol is gone from the code — the core
153
+ // drift the code guard catches.
154
+ it("flags a symbol the resolver cannot find", async () => {
155
+ const r = await tempRepo({
156
+ "docs/guide.md": "See {{code:internal/foo.go#Missing}} for details.\n",
157
+ });
158
+ const vs = await run(cfgCode(["docs/**"], ["internal/**"]), r, new StubResolver(false));
159
+ const codeVs = vs.filter((v) => v.rule === Rules.guardCode);
160
+ expect(codeVs).toHaveLength(1);
161
+ expect(codeVs[0]?.message).toBe("referenced symbol not found");
162
+ expect(codeVs[0]?.expected).toBe("symbol exists");
163
+ });
164
+ // When the resolver supplies a suggestion, the not-found message carries a
165
+ // "did you mean" — proving run wires resolver.suggest into the message.
166
+ it("appends a did-you-mean clause when the resolver suggests candidates", async () => {
167
+ const r = await tempRepo({
168
+ "docs/guide.md": "See {{code:internal/foo.go#Missing}} for details.\n",
169
+ });
170
+ const resolver = new SuggestingResolver({
171
+ kind: "symbol",
172
+ candidates: ["Mission", "Other"],
173
+ });
174
+ const vs = await run(cfgCode(["docs/**"], ["internal/**"]), r, resolver);
175
+ const codeVs = vs.filter((v) => v.rule === Rules.guardCode);
176
+ expect(codeVs).toHaveLength(1);
177
+ expect(codeVs[0]?.message).toBe("referenced symbol not found (did you mean: Mission?)");
178
+ });
179
+ // A malformed token must fail at parse time with a malformed-ref message, so
180
+ // an author gets a syntax signal rather than a misleading "not found".
181
+ it("flags a malformed code token", async () => {
182
+ const r = await tempRepo({ "docs/guide.md": "See {{code:}} for details.\n" });
183
+ const vs = await run(cfgCode(["docs/**"], ["internal/**"]), r, new StubResolver(true));
184
+ const codeVs = vs.filter((v) => v.rule === Rules.guardCode);
185
+ expect(codeVs).toHaveLength(1);
186
+ expect(codeVs[0]?.message.startsWith("malformed code ref")).toBe(true);
187
+ });
188
+ // An absent cfg.Code section must skip the code pass entirely — undefined is
189
+ // the "this guard is not configured" signal.
190
+ it("runs no code pass when cfg.Code is undefined", async () => {
191
+ const r = await tempRepo({
192
+ "docs/guide.md": "See {{code:internal/foo.go#Bar}} for details.\n",
193
+ });
194
+ const vs = await run({}, r, new StubResolver(false));
195
+ expect(vs.filter((v) => v.rule === Rules.guardCode)).toEqual([]);
196
+ });
197
+ // A {{code:…}} inside an inline code span documents the GRAMMAR and must NOT
198
+ // be checked, while the same token in prose IS — the code-span rule that keeps
199
+ // the guard's own grammar docs from self-tripping.
200
+ it("checks a prose code token but skips one in a code span", async () => {
201
+ const r = await tempRepo({
202
+ "docs/guide.md": "Grammar is `{{code:vendor/lib.go#X}}`.\n\nLive ref {{code:vendor/lib.go#Y}} here.\n",
203
+ });
204
+ const vs = await run(cfgCode(["docs/**"], ["internal/**"]), r, new StubResolver(true));
205
+ const codeVs = vs.filter((v) => v.rule === Rules.guardCode);
206
+ // Only the prose token is live; it is outside source → exactly one violation.
207
+ expect(codeVs).toHaveLength(1);
208
+ expect(codeVs[0]?.actual).toBe("{{code:vendor/lib.go#Y}}");
209
+ });
210
+ // A {{code:…}} inside a fenced code block is grammar documentation and must
211
+ // not be checked, even though it sits outside source.
212
+ it("skips a code token inside a fenced code block", async () => {
213
+ const r = await tempRepo({
214
+ "docs/guide.md": "```\n{{code:vendor/lib.go#Z}}\n```\n",
215
+ });
216
+ const vs = await run(cfgCode(["docs/**"], ["internal/**"]), r, new StubResolver(true));
217
+ expect(vs.filter((v) => v.rule === Rules.guardCode)).toEqual([]);
218
+ });
219
+ });
220
+ // --- DOC PASS ---
221
+ describe("run / doc pass", () => {
222
+ // A relative link to an existing file must pass — the doc guard only fires on
223
+ // real breakage.
224
+ it("emits no doc violation for a valid relative link", async () => {
225
+ const r = await tempRepo({
226
+ "docs/guide.md": "See [other](other.md) for details.\n",
227
+ "docs/other.md": "# Other\n",
228
+ });
229
+ const vs = await run(cfgDoc(["docs/**"]), r, new StubResolver(false));
230
+ expect(vs.filter((v) => v.rule === Rules.guardDocs)).toEqual([]);
231
+ });
232
+ // A relative link to a nonexistent file is a dangling reference — the canonical
233
+ // doc-guard violation.
234
+ it("flags a dangling relative link", async () => {
235
+ const r = await tempRepo({
236
+ "docs/guide.md": "See [missing](missing.md) for details.\n",
237
+ });
238
+ const vs = await run(cfgDoc(["docs/**"]), r, new StubResolver(false));
239
+ const docVs = vs.filter((v) => v.rule === Rules.guardDocs);
240
+ expect(docVs).toHaveLength(1);
241
+ expect(docVs[0]?.message).toBe("referenced file does not exist");
242
+ });
243
+ // External URLs are not files to check; skipping them keeps the guard scoped
244
+ // to in-repo references.
245
+ it("skips external https URLs", async () => {
246
+ const r = await tempRepo({
247
+ "docs/guide.md": "See [external](https://example.com) for info.\n",
248
+ });
249
+ const vs = await run(cfgDoc(["docs/**"]), r, new StubResolver(false));
250
+ expect(vs.filter((v) => v.rule === Rules.guardDocs)).toEqual([]);
251
+ });
252
+ // A bare "#anchor" is an in-page link, not a file reference, so it is skipped.
253
+ it("skips pure fragment links", async () => {
254
+ const r = await tempRepo({
255
+ "docs/guide.md": "See [section](#heading) for info.\n",
256
+ });
257
+ const vs = await run(cfgDoc(["docs/**"]), r, new StubResolver(false));
258
+ expect(vs.filter((v) => v.rule === Rules.guardDocs)).toEqual([]);
259
+ });
260
+ // An absolute path is rejected outright (not stat'd): it cannot be repo-relative
261
+ // and would leak the host layout.
262
+ it("rejects an absolute link path", async () => {
263
+ const r = await tempRepo({
264
+ "docs/guide.md": "See [absolute](/etc/passwd) for info.\n",
265
+ });
266
+ const vs = await run(cfgDoc(["docs/**"]), r, new StubResolver(false));
267
+ const docVs = vs.filter((v) => v.rule === Rules.guardDocs);
268
+ expect(docVs).toHaveLength(1);
269
+ expect(docVs[0]?.message).toBe("absolute path is not allowed");
270
+ });
271
+ // A path that climbs above the repo root is rejected — references must stay
272
+ // inside the governed tree.
273
+ it("rejects a path escaping the repo root", async () => {
274
+ const r = await tempRepo({
275
+ "docs/guide.md": "See [escape](../../outside.md) for info.\n",
276
+ });
277
+ const vs = await run(cfgDoc(["docs/**"]), r, new StubResolver(false));
278
+ const docVs = vs.filter((v) => v.rule === Rules.guardDocs);
279
+ expect(docVs).toHaveLength(1);
280
+ expect(docVs[0]?.message).toBe("path escapes repo root");
281
+ });
282
+ // A "file.md#frag" link must strip the fragment and still pass when the base
283
+ // file exists — fragments name in-page anchors, not separate files.
284
+ it("strips a fragment and passes when the base file exists", async () => {
285
+ const r = await tempRepo({
286
+ "docs/guide.md": "See [other](other.md#section) for details.\n",
287
+ "docs/other.md": "# Other\n## Section\n",
288
+ });
289
+ const vs = await run(cfgDoc(["docs/**"]), r, new StubResolver(false));
290
+ expect(vs.filter((v) => v.rule === Rules.guardDocs)).toEqual([]);
291
+ });
292
+ // Stripping the fragment must still leave the base path subject to existence
293
+ // checking — a dangling "missing.md#x" is still dangling.
294
+ it("flags a fragment link whose base file is missing", async () => {
295
+ const r = await tempRepo({
296
+ "docs/guide.md": "See [other](nonexistent.md#section) for details.\n",
297
+ });
298
+ const vs = await run(cfgDoc(["docs/**"]), r, new StubResolver(false));
299
+ expect(vs.filter((v) => v.rule === Rules.guardDocs)).toHaveLength(1);
300
+ });
301
+ // An image src is existence-checked exactly like a link — broken images are
302
+ // doc drift too.
303
+ it("checks image destinations like links", async () => {
304
+ const r = await tempRepo({
305
+ "docs/guide.md": "![logo](../assets/logo.png)\n",
306
+ });
307
+ const vs = await run(cfgDoc(["docs/**"]), r, new StubResolver(false));
308
+ expect(vs.filter((v) => v.rule === Rules.guardDocs)).toHaveLength(1);
309
+ });
310
+ // Regression: an empty-alt image to a missing file must yield a violation with
311
+ // a sane line, not crash (the Go port had an inline-node Lines() panic here).
312
+ it("handles an empty-alt image without crashing", async () => {
313
+ const r = await tempRepo({ "docs/guide.md": "![](./missing.png)\n" });
314
+ const vs = await run(cfgDoc(["docs/**"]), r, new StubResolver(false));
315
+ const docVs = vs.filter((v) => v.rule === Rules.guardDocs);
316
+ expect(docVs).toHaveLength(1);
317
+ expect(docVs[0]?.line).toBeGreaterThanOrEqual(1);
318
+ });
319
+ // Regression: a link whose text is an inline-code span (not plain text) must
320
+ // not crash and must still be existence-checked.
321
+ it("handles an inline-code link text without crashing", async () => {
322
+ const r = await tempRepo({ "docs/guide.md": "[`code`](./missing.md)\n" });
323
+ const vs = await run(cfgDoc(["docs/**"]), r, new StubResolver(false));
324
+ const docVs = vs.filter((v) => v.rule === Rules.guardDocs);
325
+ expect(docVs).toHaveLength(1);
326
+ expect(docVs[0]?.line).toBeGreaterThanOrEqual(1);
327
+ });
328
+ });
329
+ // --- API PASS ---
330
+ describe("run / api pass", () => {
331
+ // A token for a real operation passes — the api guard certifies live endpoints.
332
+ it("emits no api violation for a real operation", async () => {
333
+ const r = await tempRepo({
334
+ "api/spec/openapi.json": openAPIFixtureJSON,
335
+ "docs/api.md": "Use {{api: GET /api/v1/items}} to list items.\n",
336
+ });
337
+ const vs = await run(cfgAPI(["docs/**"], ["api/spec/**"]), r, new StubResolver(false));
338
+ expect(vs.filter((v) => v.rule === Rules.guardAPI)).toEqual([]);
339
+ });
340
+ // An operation absent from the spec is the canonical api drift.
341
+ it("flags an unknown operation", async () => {
342
+ const r = await tempRepo({
343
+ "api/spec/openapi.json": openAPIFixtureJSON,
344
+ "docs/api.md": "Use {{api: DELETE /api/v1/items}} to delete.\n",
345
+ });
346
+ const vs = await run(cfgAPI(["docs/**"], ["api/spec/**"]), r, new StubResolver(false));
347
+ const apiVs = vs.filter((v) => v.rule === Rules.guardAPI);
348
+ expect(apiVs).toHaveLength(1);
349
+ expect(apiVs[0]?.message).toBe("operation DELETE /api/v1/items not found in spec");
350
+ });
351
+ // A nested body field that exists must pass — the qualifier walks the schema.
352
+ it("passes a body:nested.field that exists", async () => {
353
+ const r = await tempRepo({
354
+ "api/spec/openapi.json": openAPIFixtureJSON,
355
+ "docs/api.md": "Field {{api: GET /api/v1/items body:nested.field}} docs.\n",
356
+ });
357
+ const vs = await run(cfgAPI(["docs/**"], ["api/spec/**"]), r, new StubResolver(false));
358
+ expect(vs.filter((v) => v.rule === Rules.guardAPI)).toEqual([]);
359
+ });
360
+ // A body field absent from the schema fails the qualifier.
361
+ it("flags a body field that does not exist", async () => {
362
+ const r = await tempRepo({
363
+ "api/spec/openapi.json": openAPIFixtureJSON,
364
+ "docs/api.md": "Field {{api: GET /api/v1/items body:bogus}} docs.\n",
365
+ });
366
+ const vs = await run(cfgAPI(["docs/**"], ["api/spec/**"]), r, new StubResolver(false));
367
+ expect(vs.filter((v) => v.rule === Rules.guardAPI)).toHaveLength(1);
368
+ });
369
+ // A param qualifier naming a nonexistent param fails.
370
+ it("flags a param that does not exist", async () => {
371
+ const r = await tempRepo({
372
+ "api/spec/openapi.json": openAPIFixtureJSON,
373
+ "docs/api.md": "Use {{api: GET /api/v1/items param:nonexistent}} param.\n",
374
+ });
375
+ const vs = await run(cfgAPI(["docs/**"], ["api/spec/**"]), r, new StubResolver(false));
376
+ expect(vs.filter((v) => v.rule === Rules.guardAPI)).toHaveLength(1);
377
+ });
378
+ // body:filter must fail: "filter" is a QUERY param, not a body field — proving
379
+ // the body region is enforced, not just any-name-anywhere.
380
+ it("rejects a param name used in the body region", async () => {
381
+ const r = await tempRepo({
382
+ "api/spec/openapi.json": openAPIFixtureJSON,
383
+ "docs/api.md": "Field {{api: GET /api/v1/items body:filter}} is a param.\n",
384
+ });
385
+ const vs = await run(cfgAPI(["docs/**"], ["api/spec/**"]), r, new StubResolver(false));
386
+ expect(vs.filter((v) => v.rule === Rules.guardAPI)).toHaveLength(1);
387
+ });
388
+ // param:name must fail: "name" is a BODY field, not a param — proving the
389
+ // param region is enforced.
390
+ it("rejects a body field name used in the param region", async () => {
391
+ const r = await tempRepo({
392
+ "api/spec/openapi.json": openAPIFixtureJSON,
393
+ "docs/api.md": "Param {{api: GET /api/v1/items param:name}} is a body field.\n",
394
+ });
395
+ const vs = await run(cfgAPI(["docs/**"], ["api/spec/**"]), r, new StubResolver(false));
396
+ expect(vs.filter((v) => v.rule === Rules.guardAPI)).toHaveLength(1);
397
+ });
398
+ // A non-JSON spec under api.source must surface a load violation, not silently
399
+ // pass references against an empty model.
400
+ it("flags a bad JSON spec", async () => {
401
+ const r = await tempRepo({
402
+ "api/spec/openapi.json": "not valid json{{",
403
+ "docs/api.md": "Use {{api: GET /api/v1/items}} here.\n",
404
+ });
405
+ const vs = await run(cfgAPI(["docs/**"], ["api/spec/**"]), r, new StubResolver(false));
406
+ expect(vs.filter((v) => v.rule === Rules.guardAPI).length).toBeGreaterThanOrEqual(1);
407
+ });
408
+ // api.source matching no spec files must produce a zero-spec violation — a
409
+ // configured api guard with no spec is a misconfiguration, not a silent pass.
410
+ it("flags zero specs found", async () => {
411
+ const r = await tempRepo({
412
+ "docs/api.md": "Use {{api: GET /api/v1/items}} here.\n",
413
+ });
414
+ const vs = await run(cfgAPI(["docs/**"], ["api/spec/**"]), r, new StubResolver(false));
415
+ expect(vs.filter((v) => v.rule === Rules.guardAPI).length).toBeGreaterThanOrEqual(1);
416
+ });
417
+ // A malformed token surfaces a malformed-ref message even with a valid spec.
418
+ it("flags a malformed api token", async () => {
419
+ const r = await tempRepo({
420
+ "api/spec/openapi.json": openAPIFixtureJSON,
421
+ "docs/api.md": "Use {{api:}} here.\n",
422
+ });
423
+ const vs = await run(cfgAPI(["docs/**"], ["api/spec/**"]), r, new StubResolver(false));
424
+ const apiVs = vs.filter((v) => v.rule === Rules.guardAPI);
425
+ expect(apiVs).toHaveLength(1);
426
+ expect(apiVs[0]?.message.startsWith("malformed api ref")).toBe(true);
427
+ });
428
+ });
429
+ // --- E2E + ORDERING ---
430
+ describe("run / e2e", () => {
431
+ // A clean tree configured with all three guards must produce zero violations
432
+ // when loaded from a real docgov.yaml — the end-to-end happy path.
433
+ it("returns no violations for a clean tree across all three guards", async () => {
434
+ const r = await tempRepo({
435
+ ".docgov/docgov.yaml": `
436
+ code:
437
+ boundary: [docs/**]
438
+ source: [src/**]
439
+ doc:
440
+ boundary: [docs/**]
441
+ api:
442
+ boundary: [docs/**]
443
+ source: [api/**]
444
+ `,
445
+ "src/main.go": "package main\n",
446
+ "api/openapi.json": openAPIFixtureJSON,
447
+ "docs/guide.md": "# Guide\n\nSee [api](api.md) for API docs.\n",
448
+ "docs/api.md": "# API\n\nUse {{api: GET /api/v1/items}} to list items.\n",
449
+ });
450
+ const cfg = await loadConfig(r.fs());
451
+ const vs = await run(cfg, r, new StubResolver(true));
452
+ expect(vs).toEqual([]);
453
+ });
454
+ // A drifted tree must yield exactly one violation per guard — proving the three
455
+ // passes run independently and collect together.
456
+ it("returns one violation per guard for a drifted tree", async () => {
457
+ const r = await tempRepo({
458
+ ".docgov/docgov.yaml": `
459
+ code:
460
+ boundary: [docs/**]
461
+ source: [src/**]
462
+ doc:
463
+ boundary: [docs/**]
464
+ api:
465
+ boundary: [docs/**]
466
+ source: [api/**]
467
+ `,
468
+ "src/main.go": "package main\n",
469
+ "api/openapi.json": openAPIFixtureJSON,
470
+ "docs/guide.md": "# Guide\n\nSee [missing-file](nonexistent.md) for details.\n\nCheck {{code:vendor/lib.go#Foo}} which is outside source.\n",
471
+ "docs/api.md": "# API\n\nUse {{api: DELETE /api/v1/items}} to delete — this op doesn't exist.\n",
472
+ });
473
+ const cfg = await loadConfig(r.fs());
474
+ const vs = await run(cfg, r, new StubResolver(true));
475
+ const codeCount = vs.filter((v) => v.rule === Rules.guardCode).length;
476
+ const docCount = vs.filter((v) => v.rule === Rules.guardDocs).length;
477
+ const apiCount = vs.filter((v) => v.rule === Rules.guardAPI).length;
478
+ expect(codeCount).toBe(1);
479
+ expect(docCount).toBe(1);
480
+ expect(apiCount).toBe(1);
481
+ // Deterministic (file, line) ordering.
482
+ for (let i = 1; i < vs.length; i++) {
483
+ const prev = vs[i - 1];
484
+ const curr = vs[i];
485
+ const ordered = prev.file < curr.file ||
486
+ (prev.file === curr.file && prev.line <= curr.line);
487
+ expect(ordered).toBe(true);
488
+ }
489
+ });
490
+ // An all-undefined config must short-circuit to zero violations and no error.
491
+ it("returns no violations for an all-undefined config", async () => {
492
+ const r = await tempRepo({ "docs/guide.md": "# Hello\n" });
493
+ const vs = await run({}, r, new StubResolver(false));
494
+ expect(vs).toEqual([]);
495
+ });
496
+ // Violations from multiple files are returned sorted by (file, line), so the
497
+ // report is deterministic regardless of walk order.
498
+ it("sorts violations by file then line", async () => {
499
+ const r = await tempRepo({
500
+ "docs/b.md": "See [b1](b-missing.md).\nSee [b2](b-missing2.md).\n",
501
+ "docs/a.md": "See [a1](a-missing.md).\n",
502
+ });
503
+ const vs = await run(cfgDoc(["docs/**"]), r, new StubResolver(false));
504
+ expect(vs.length).toBeGreaterThanOrEqual(3);
505
+ for (let i = 1; i < vs.length; i++) {
506
+ const prev = vs[i - 1];
507
+ const curr = vs[i];
508
+ const ordered = prev.file < curr.file ||
509
+ (prev.file === curr.file && prev.line <= curr.line);
510
+ expect(ordered).toBe(true);
511
+ }
512
+ });
513
+ });
@@ -0,0 +1,134 @@
1
+ // suggest.ts turns a codeq Suggestion (the raw candidate set behind a not-found
2
+ // {{code:…}} ref) into the human "did you mean" / owner-missing clause appended
3
+ // to the guard-code violation message. codeq extracts the candidates from the
4
+ // parse tree; this module owns the ranking and the wording.
5
+ //
6
+ // Ranking is edit-distance "did you mean", top-3: only candidates within
7
+ // MAX_DISTANCE of the missing name are offered, so a genuinely-deleted symbol
8
+ // (no near name) adds nothing rather than suggesting noise. An exact match is
9
+ // excluded — it signals the name is right and a deeper facet (signature, param)
10
+ // is what failed, where listing the name back would mislead.
11
+ //
12
+ // When nothing is within distance, member/param refs fall back to listing what
13
+ // IS available on the container ("<owner> has: …") — a member set is small and
14
+ // bounded, so a wholly-wrong name (e.g. Status.TEST vs ACTIVE/INACTIVE) still
15
+ // gets a useful hint. Bare symbol refs stay distance-gated only: a file's symbol
16
+ // set can be large, so listing it all would flood the message.
17
+ // MAX_DISTANCE is the Levenshtein cutoff for a "did you mean" — the classic
18
+ // spell-check threshold. A candidate further than this is treated as unrelated.
19
+ const MAX_DISTANCE = 2;
20
+ // MAX_SUGGESTIONS caps how many candidates are offered, closest first.
21
+ const MAX_SUGGESTIONS = 3;
22
+ // MAX_AVAILABLE caps the "<owner> has: …" fallback list; the rest collapse to a
23
+ // "+N more" marker so a large class/enum never floods the line.
24
+ const MAX_AVAILABLE = 8;
25
+ /**
26
+ * suggestionSuffix renders the trailing clause for a not-found code ref, or ""
27
+ * when there is nothing useful to say. The leading space is included so callers
28
+ * append it directly to the base message.
29
+ *
30
+ * owner missing → " (owner type Foo not found)" / " (function foo not found)"
31
+ * near matches → " (did you mean: bar, baz?)"
32
+ * member/param, no near match → " (Foo has: a, b, c)" / " (foo has params: a, b)"
33
+ * bare symbol, no near match → ""
34
+ */
35
+ export function suggestionSuffix(ref, sug) {
36
+ if (sug.ownerMissing !== undefined) {
37
+ const what = sug.kind === "member" ? "owner type" : "function";
38
+ return ` (${what} ${sug.ownerMissing} not found)`;
39
+ }
40
+ const target = targetName(ref, sug.kind);
41
+ const picks = rankCandidates(target, sug.candidates);
42
+ if (picks.length > 0) {
43
+ return ` (did you mean: ${picks.join(", ")}?)`;
44
+ }
45
+ // No near match. A member/param ref names ONE container, so its candidate set
46
+ // is small — list what's available rather than go silent (the Status.TEST
47
+ // case). A bare symbol ref can span a whole file, so it stays distance-gated.
48
+ // The target being AMONG the candidates means the name is right and a deeper
49
+ // facet (signature/param) failed — suppress there, don't echo the container.
50
+ if (sug.kind !== "symbol" &&
51
+ sug.candidates.length > 0 &&
52
+ !sug.candidates.includes(target)) {
53
+ return ` (${availableClause(ref.Symbol, sug.kind, sug.candidates)})`;
54
+ }
55
+ return "";
56
+ }
57
+ /**
58
+ * availableClause lists the names on a container whose member/param had no near
59
+ * match — "<owner> has: a, b, c" (members) or "<owner> has params: a, b" — capped
60
+ * at MAX_AVAILABLE with a "+N more" overflow marker. Names are de-duplicated and
61
+ * sorted so the output is deterministic.
62
+ */
63
+ function availableClause(owner, kind, candidates) {
64
+ const names = [...new Set(candidates)].sort();
65
+ const shown = names.slice(0, MAX_AVAILABLE);
66
+ const extra = names.length - shown.length;
67
+ const list = shown.join(", ") + (extra > 0 ? `, +${extra} more` : "");
68
+ const verb = kind === "member" ? "has" : "has params";
69
+ return `${owner} ${verb}: ${list}`;
70
+ }
71
+ /** targetName is the name the author got wrong, by ref kind. */
72
+ function targetName(ref, kind) {
73
+ switch (kind) {
74
+ case "member":
75
+ return ref.Member;
76
+ case "param":
77
+ return ref.Param;
78
+ case "symbol":
79
+ return ref.Symbol;
80
+ }
81
+ }
82
+ /**
83
+ * rankCandidates returns up to MAX_SUGGESTIONS candidate names within
84
+ * MAX_DISTANCE of `target`, closest first. Duplicates and exact matches are
85
+ * dropped; ties break alphabetically so the output is deterministic.
86
+ */
87
+ function rankCandidates(target, candidates) {
88
+ const scored = [];
89
+ const seen = new Set();
90
+ for (const c of candidates) {
91
+ if (seen.has(c)) {
92
+ continue;
93
+ }
94
+ seen.add(c);
95
+ if (c === target) {
96
+ continue; // name is right; a deeper facet failed — don't echo it back.
97
+ }
98
+ const dist = levenshtein(target, c);
99
+ if (dist <= MAX_DISTANCE) {
100
+ scored.push({ name: c, dist });
101
+ }
102
+ }
103
+ scored.sort((a, b) => a.dist !== b.dist ? a.dist - b.dist : a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
104
+ return scored.slice(0, MAX_SUGGESTIONS).map((s) => s.name);
105
+ }
106
+ /**
107
+ * levenshtein is the standard two-row edit-distance DP (insert/delete/substitute,
108
+ * cost 1 each). Case-sensitive, since the languages docgov resolves treat
109
+ * identifiers as case-sensitive.
110
+ */
111
+ function levenshtein(a, b) {
112
+ if (a === b) {
113
+ return 0;
114
+ }
115
+ if (a.length === 0) {
116
+ return b.length;
117
+ }
118
+ if (b.length === 0) {
119
+ return a.length;
120
+ }
121
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
122
+ let curr = new Array(b.length + 1);
123
+ for (let i = 1; i <= a.length; i++) {
124
+ curr[0] = i;
125
+ for (let j = 1; j <= b.length; j++) {
126
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
127
+ curr[j] = Math.min(prev[j] + 1, // delete
128
+ curr[j - 1] + 1, // insert
129
+ prev[j - 1] + cost);
130
+ }
131
+ [prev, curr] = [curr, prev];
132
+ }
133
+ return prev[b.length];
134
+ }