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,422 @@
1
+ // Tests the CLI wire-up: exit-code mapping, command routing, --format flag, the
2
+ // check e2e loop, dedup command registration, and the npm-appropriate update
3
+ // command. Port of cmd/docgov/{main,dedup_cli,update}_test.go, adapted to the
4
+ // commander framework and npm distribution.
5
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import * as path from "node:path";
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import { run, newApp, readVersion, ViolationsError, DuplicatesError, exitOK, exitViolation, exitUsage, } from "./main.js";
10
+ // makeNewSchemaRepo builds a minimal temp repo with a .docgov/docgov.yaml and
11
+ // the provided docs files (relPath→content). Returns the temp dir path. Mirrors
12
+ // the Go makeNewSchemaRepo helper.
13
+ function makeNewSchemaRepo(docgovYAML, docs) {
14
+ const tmp = mkdtempSync(path.join(tmpdir(), "docgov-cmd-"));
15
+ mkdirSync(path.join(tmp, ".docgov"), { recursive: true });
16
+ writeFileSync(path.join(tmp, ".docgov", "docgov.yaml"), docgovYAML);
17
+ for (const [relPath, content] of Object.entries(docs)) {
18
+ const abs = path.join(tmp, ...relPath.split("/"));
19
+ mkdirSync(path.dirname(abs), { recursive: true });
20
+ writeFileSync(abs, content);
21
+ }
22
+ return tmp;
23
+ }
24
+ // withCwd runs fn with process.cwd() temporarily set to dir, restoring it (and
25
+ // removing the temp dir) afterwards. run() reads process.cwd(), so commands
26
+ // that touch the filesystem must be driven from the target repo. Mirrors the Go
27
+ // chdirCleanup helper.
28
+ async function withCwd(dir, fn) {
29
+ const orig = process.cwd();
30
+ process.chdir(dir);
31
+ try {
32
+ return await fn();
33
+ }
34
+ finally {
35
+ process.chdir(orig);
36
+ rmSync(dir, { recursive: true, force: true });
37
+ }
38
+ }
39
+ // captureStdout collects everything written to process.stdout while fn runs and
40
+ // returns it. WHY: run()'s check action writes the report to stdout; tests must
41
+ // intercept that stream to verify render wiring. Mirrors the Go captureStdout.
42
+ async function captureStdout(fn) {
43
+ let out = "";
44
+ const spy = vi
45
+ .spyOn(process.stdout, "write")
46
+ .mockImplementation((chunk) => {
47
+ out += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
48
+ return true;
49
+ });
50
+ try {
51
+ await fn();
52
+ }
53
+ finally {
54
+ spy.mockRestore();
55
+ }
56
+ return out;
57
+ }
58
+ // silenceStderr suppresses commander's framework error messages (it writes to
59
+ // stderr on unknown command / bad usage); tests assert exit codes, not the
60
+ // framework's own text.
61
+ function silenceStderr() {
62
+ const spy = vi
63
+ .spyOn(process.stderr, "write")
64
+ .mockImplementation(() => true);
65
+ return () => spy.mockRestore();
66
+ }
67
+ afterEach(() => {
68
+ vi.restoreAllMocks();
69
+ });
70
+ describe("exit-code mapping", () => {
71
+ // The exit-code contract is the primary interface between the CLI and
72
+ // CI/agent callers — a wrong mapping silently breaks automation. Mirrors Go's
73
+ // TestExitCodeMapping.
74
+ it("no args shows help and exits 0", async () => {
75
+ const restore = silenceStderr();
76
+ await captureStdout(async () => {
77
+ const code = await run([]);
78
+ expect(code).toBe(exitOK);
79
+ });
80
+ restore();
81
+ });
82
+ it("unknown command exits 2 (usage)", async () => {
83
+ const restore = silenceStderr();
84
+ const code = await run(["notacommand"]);
85
+ restore();
86
+ expect(code).toBe(exitUsage);
87
+ });
88
+ // --help must never be confused with a usage error: it exits 0. Mirrors Go's
89
+ // TestExitCodeViolationSentinel.
90
+ it("--help exits 0", async () => {
91
+ const restore = silenceStderr();
92
+ await captureStdout(async () => {
93
+ const code = await run(["--help"]);
94
+ expect(code).toBe(exitOK);
95
+ });
96
+ restore();
97
+ });
98
+ // --version exits 0 (semantics preserved from Go's Version wiring).
99
+ it("--version exits 0", async () => {
100
+ await captureStdout(async () => {
101
+ const code = await run(["--version"]);
102
+ expect(code).toBe(exitOK);
103
+ });
104
+ });
105
+ });
106
+ describe("check command", () => {
107
+ // Invalid --format values are usage errors per the exit-code contract.
108
+ // Mirrors Go's TestRunCheckBadFormat.
109
+ it("invalid --format exits 2", async () => {
110
+ const tmp = makeNewSchemaRepo("doc:\n boundary:\n - docs/**\n", {
111
+ "docs/guide.md": "# Guide\n\nHello.\n",
112
+ });
113
+ const restore = silenceStderr();
114
+ const code = await withCwd(tmp, () => run(["check", "--format", "xml"]));
115
+ restore();
116
+ expect(code).toBe(exitUsage);
117
+ });
118
+ // The pipeline (loadConfig + check.run) must produce a clean result when
119
+ // there are no violations, not a config error or spurious exit 1. Mirrors
120
+ // Go's TestCheckNewSchema_CleanExitsZero.
121
+ it("clean repo exits 0", async () => {
122
+ const tmp = makeNewSchemaRepo("doc:\n boundary:\n - docs/**\n", {
123
+ "docs/guide.md": "# Guide\n\nSee [overview](./overview.md).\n",
124
+ "docs/overview.md": "# Overview\n\nHello.\n",
125
+ });
126
+ let code = -1;
127
+ await withCwd(tmp, () => captureStdout(async () => {
128
+ code = await run(["check"]);
129
+ }));
130
+ expect(code).toBe(exitOK);
131
+ });
132
+ // A dangling link must surface as exit 1 — proves runCheck is wired to
133
+ // loadConfig + check.run (the doc: boundary is recognized and the missing
134
+ // target is detected). Mirrors Go's TestCheckNewSchema_DanglingLinkExitsOne.
135
+ it("dangling link exits 1", async () => {
136
+ const tmp = makeNewSchemaRepo("doc:\n boundary:\n - docs/**\n", {
137
+ "docs/guide.md": "# Guide\n\nSee [missing](./nonexistent.md) for details.\n",
138
+ });
139
+ let code = -1;
140
+ await withCwd(tmp, () => captureStdout(async () => {
141
+ code = await run(["check"]);
142
+ }));
143
+ expect(code).toBe(exitViolation);
144
+ });
145
+ // Missing .docgov/docgov.yaml is a usage/config error → exit 2. Mirrors Go's
146
+ // TestCheckNewSchema_MissingConfigExitsTwo.
147
+ it("missing docgov.yaml exits 2", async () => {
148
+ const tmp = mkdtempSync(path.join(tmpdir(), "docgov-cmd-"));
149
+ mkdirSync(path.join(tmp, ".docgov"), { recursive: true });
150
+ const restore = silenceStderr();
151
+ const code = await withCwd(tmp, () => run(["check"]));
152
+ restore();
153
+ expect(code).toBe(exitUsage);
154
+ });
155
+ // --format json must be accepted and exit 0 on a clean repo. Mirrors Go's
156
+ // TestCheckNewSchema_FormatJSON.
157
+ it("--format json on clean repo exits 0", async () => {
158
+ const tmp = makeNewSchemaRepo("doc:\n boundary:\n - docs/**\n", {
159
+ "docs/guide.md": "# Guide\n\nHello.\n",
160
+ });
161
+ let code = -1;
162
+ await withCwd(tmp, () => captureStdout(async () => {
163
+ code = await run(["check", "--format", "json"]);
164
+ }));
165
+ expect(code).toBe(exitOK);
166
+ });
167
+ // An empty boundary list produces no violations (no files in scope → guard
168
+ // silently skipped). Mirrors Go's TestCheckNewSchema_EmptySectionSkipped.
169
+ it("empty doc boundary exits 0", async () => {
170
+ const tmp = makeNewSchemaRepo("doc:\n boundary: []\n", {
171
+ "docs/guide.md": "# Guide\n\n[dangling](./gone.md)\n",
172
+ });
173
+ let code = -1;
174
+ await withCwd(tmp, () => captureStdout(async () => {
175
+ code = await run(["check"]);
176
+ }));
177
+ expect(code).toBe(exitOK);
178
+ });
179
+ // runCheck writes report.text(vs) to stdout — if that wiring were removed,
180
+ // violations would be silently swallowed despite exit 1. Falsifiable: removing
181
+ // the stdout write makes this fail. Mirrors Go's
182
+ // TestCheckNewSchema_DanglingLinkTextOutput.
183
+ it("renders guard-docs violation to stdout (text)", async () => {
184
+ const tmp = makeNewSchemaRepo("doc:\n boundary:\n - docs/**\n", {
185
+ "docs/guide.md": "# Guide\n\nSee [missing](./nonexistent.md) for details.\n",
186
+ });
187
+ let code = -1;
188
+ const out = await withCwd(tmp, () => captureStdout(async () => {
189
+ code = await run(["check"]);
190
+ }));
191
+ expect(code).toBe(exitViolation);
192
+ expect(out).toContain("guard-docs");
193
+ expect(out).toContain("referenced file does not exist");
194
+ });
195
+ // runCheck writes report.json(vs) for json format — removing it would silently
196
+ // drop the violation. Falsifiable. Mirrors Go's
197
+ // TestCheckNewSchema_FormatJSONRendersViolation.
198
+ it("renders guard-docs violation as valid JSON", async () => {
199
+ const tmp = makeNewSchemaRepo("doc:\n boundary:\n - docs/**\n", {
200
+ "docs/guide.md": "# Guide\n\nSee [missing](./nonexistent.md) for details.\n",
201
+ });
202
+ let code = -1;
203
+ const out = await withCwd(tmp, () => captureStdout(async () => {
204
+ code = await run(["check", "--format", "json"]);
205
+ }));
206
+ expect(code).toBe(exitViolation);
207
+ expect(() => JSON.parse(out.trim())).not.toThrow();
208
+ expect(out).toContain("guard-docs");
209
+ expect(out).toContain("referenced file does not exist");
210
+ });
211
+ });
212
+ describe("command registration", () => {
213
+ // gen/add/list are retired; check has no --flip flag. Their continued presence
214
+ // would allow invoking removed functionality. Mirrors Go's
215
+ // TestRetiredCommandsRemoved.
216
+ it("retired commands (gen/add/list) are absent; check has no --flip", () => {
217
+ const app = newApp("test");
218
+ const names = app.commands.map((c) => c.name());
219
+ for (const retired of ["gen", "add", "list"]) {
220
+ expect(names).not.toContain(retired);
221
+ }
222
+ const check = app.commands.find((c) => c.name() === "check");
223
+ expect(check).toBeDefined();
224
+ const flagNames = check.options.map((o) => o.long);
225
+ expect(flagNames).not.toContain("--flip");
226
+ });
227
+ // dedup is wired as a single command — no separate "index" subcommand — with a
228
+ // merged action (index folded into the default action). Mirrors Go's
229
+ // TestDedupCommandRegistered.
230
+ it("dedup is a single command with no subcommands", () => {
231
+ const app = newApp("test");
232
+ const dedup = app.commands.find((c) => c.name() === "dedup");
233
+ expect(dedup).toBeDefined();
234
+ expect(dedup.commands.length).toBe(0);
235
+ });
236
+ });
237
+ describe("error sentinels", () => {
238
+ // ViolationsError and DuplicatesError are distinct sentinels: ViolationsError
239
+ // gates on doc violations, DuplicatesError on dedup duplicates. Both map to
240
+ // exit 1, but conflating them would mean CI gates on the wrong thing. Mirrors
241
+ // Go's TestErrDuplicatesMapsToViolation.
242
+ it("ViolationsError and DuplicatesError are distinct types", () => {
243
+ const v = new ViolationsError();
244
+ const d = new DuplicatesError();
245
+ expect(v).toBeInstanceOf(ViolationsError);
246
+ expect(d).toBeInstanceOf(DuplicatesError);
247
+ expect(d).not.toBeInstanceOf(ViolationsError);
248
+ expect(v).not.toBeInstanceOf(DuplicatesError);
249
+ });
250
+ });
251
+ describe("update command (npm)", () => {
252
+ // DEVIATION from Go: the GitLab-binary-download + self-replace logic is N/A for
253
+ // npm. update --check reports current and latest versions, degrading gracefully
254
+ // when the npm registry is unreachable (offline-safe). Replaces Go's
255
+ // TestAssetName / TestReplaceExecutable, which have no npm analogue.
256
+ it("--check handles a failed registry fetch gracefully (exit 0)", async () => {
257
+ const origFetch = globalThis.fetch;
258
+ globalThis.fetch = vi.fn(async () => {
259
+ throw new Error("offline");
260
+ });
261
+ try {
262
+ let code = -1;
263
+ const out = await captureStdout(async () => {
264
+ code = await run(["update", "--check"]);
265
+ });
266
+ expect(code).toBe(exitOK);
267
+ expect(out).toContain("current:");
268
+ expect(out).toContain("latest:");
269
+ // Offline path reports an unreachable-registry placeholder, not a crash.
270
+ expect(out).toContain("registry unreachable");
271
+ }
272
+ finally {
273
+ globalThis.fetch = origFetch;
274
+ }
275
+ });
276
+ // --check reports the registry's latest version when reachable, and notes
277
+ // "already up to date" when it equals the current version.
278
+ it("--check reports the npm registry latest version", async () => {
279
+ const current = readVersion();
280
+ const origFetch = globalThis.fetch;
281
+ globalThis.fetch = vi.fn(async () => {
282
+ return new Response(JSON.stringify({ version: current }), {
283
+ status: 200,
284
+ headers: { "content-type": "application/json" },
285
+ });
286
+ });
287
+ try {
288
+ let code = -1;
289
+ const out = await captureStdout(async () => {
290
+ code = await run(["update", "--check"]);
291
+ });
292
+ expect(code).toBe(exitOK);
293
+ expect(out).toContain(`latest: ${current}`);
294
+ expect(out).toContain("already up to date");
295
+ }
296
+ finally {
297
+ globalThis.fetch = origFetch;
298
+ }
299
+ });
300
+ // Plain `update` prints the npm install instruction and exits 0 — it does not
301
+ // attempt any self-replace (the Go behavior is intentionally not ported).
302
+ it("plain update prints the npm install instruction (exit 0)", async () => {
303
+ let code = -1;
304
+ const out = await captureStdout(async () => {
305
+ code = await run(["update"]);
306
+ });
307
+ expect(code).toBe(exitOK);
308
+ expect(out).toContain("npm install -g docgov@latest");
309
+ });
310
+ // `update --version <tag>` is shadowed by commander's program-level --version
311
+ // flag (added by .version()), which prints the program version and exits 0
312
+ // before the subcommand action runs. WHY this matters: it pins the real,
313
+ // surprising precedence so a future change that re-enables the subcommand
314
+ // tag (and would alter exit semantics) is caught — the flag does NOT print
315
+ // install guidance today.
316
+ it("update --version <tag> hits the program version flag (prints version, exit 0)", async () => {
317
+ let code = -1;
318
+ const out = await captureStdout(async () => {
319
+ code = await run(["update", "--version", "v1.2.3"]);
320
+ });
321
+ expect(code).toBe(exitOK);
322
+ expect(out.trim()).toBe(readVersion());
323
+ expect(out).not.toContain("npm install");
324
+ });
325
+ // --check reports a NEWER registry version (different from current) WITHOUT
326
+ // the "already up to date" line — that line must appear only on an exact match
327
+ // or the user would be told they are current when an upgrade exists.
328
+ it("--check reports a newer version and omits 'already up to date'", async () => {
329
+ const origFetch = globalThis.fetch;
330
+ globalThis.fetch = vi.fn(async () => {
331
+ return new Response(JSON.stringify({ version: "999.999.999" }), {
332
+ status: 200,
333
+ headers: { "content-type": "application/json" },
334
+ });
335
+ });
336
+ try {
337
+ let code = -1;
338
+ const out = await captureStdout(async () => {
339
+ code = await run(["update", "--check"]);
340
+ });
341
+ expect(code).toBe(exitOK);
342
+ expect(out).toContain("latest: 999.999.999");
343
+ expect(out).not.toContain("already up to date");
344
+ }
345
+ finally {
346
+ globalThis.fetch = origFetch;
347
+ }
348
+ });
349
+ // A non-OK HTTP status (resp.ok === false) is treated as unreachable, not a
350
+ // crash — fetchLatestVersion's status guard. A 500 from the registry must
351
+ // degrade to the unreachable placeholder, exit 0.
352
+ it("--check treats a non-OK HTTP status as unreachable (exit 0)", async () => {
353
+ const origFetch = globalThis.fetch;
354
+ globalThis.fetch = vi.fn(async () => {
355
+ return new Response("oops", { status: 500 });
356
+ });
357
+ try {
358
+ let code = -1;
359
+ const out = await captureStdout(async () => {
360
+ code = await run(["update", "--check"]);
361
+ });
362
+ expect(code).toBe(exitOK);
363
+ expect(out).toContain("registry unreachable");
364
+ }
365
+ finally {
366
+ globalThis.fetch = origFetch;
367
+ }
368
+ });
369
+ // A 200 body that lacks a string "version" field yields null (not a thrown
370
+ // error) — the typeof guard in fetchLatestVersion. Malformed registry JSON
371
+ // must not crash the update command.
372
+ it("--check treats a version-less 200 body as unreachable (exit 0)", async () => {
373
+ const origFetch = globalThis.fetch;
374
+ globalThis.fetch = vi.fn(async () => {
375
+ return new Response(JSON.stringify({ notversion: 1 }), {
376
+ status: 200,
377
+ headers: { "content-type": "application/json" },
378
+ });
379
+ });
380
+ try {
381
+ let code = -1;
382
+ const out = await captureStdout(async () => {
383
+ code = await run(["update", "--check"]);
384
+ });
385
+ expect(code).toBe(exitOK);
386
+ expect(out).toContain("registry unreachable");
387
+ }
388
+ finally {
389
+ globalThis.fetch = origFetch;
390
+ }
391
+ });
392
+ });
393
+ describe("dedup command", () => {
394
+ // The dedup action runs Index (which loads the dedup config) before anything
395
+ // else. A malformed .docgov/dedup/config.yml makes Load throw, which must map
396
+ // to a usage/config error (exit 2) — NOT exit 1 (which means duplicates were
397
+ // found) and NOT a crash. This exercises runDedupAction's Index call and the
398
+ // engine-error → exit-2 mapping without triggering a model download (Load
399
+ // throws first). Falsifiable: swallowing the error would change the code to 0.
400
+ it("malformed dedup config exits 2 (usage)", async () => {
401
+ const tmp = makeNewSchemaRepo("doc:\n boundary:\n - docs/**\n", {
402
+ ".docgov/dedup/config.yml": ": : : not valid yaml ][\n",
403
+ "docs/guide.md": "# Guide\n\nHello.\n",
404
+ });
405
+ const restore = silenceStderr();
406
+ let code = -1;
407
+ await withCwd(tmp, () => captureStdout(async () => {
408
+ code = await run(["dedup"]);
409
+ }));
410
+ restore();
411
+ expect(code).toBe(exitUsage);
412
+ });
413
+ });
414
+ // readVersion returns a non-empty string (package.json version or the "dev"
415
+ // fallback) — the npm analogue of Go's ldflags-stamped main.version.
416
+ describe("readVersion", () => {
417
+ it("returns the package.json version", () => {
418
+ const v = readVersion();
419
+ expect(typeof v).toBe("string");
420
+ expect(v.length).toBeGreaterThan(0);
421
+ });
422
+ });
@@ -0,0 +1,71 @@
1
+ import { isNotExist } from "../repo/fs.js";
2
+ import { FileNotFoundError } from "./errors.js";
3
+ // cache.ts ports internal/codeq/cache.go: the per-file source cache for the
4
+ // lifetime of one resolver instance (one check run). Each file is read at most
5
+ // once, even under concurrent Resolve calls.
6
+ //
7
+ // DEVIATION (recorded): the Go cache stores raw []byte and operates on bytes
8
+ // (tree-sitter-go slices []byte directly). web-tree-sitter operates on JS
9
+ // strings (node.text is UTF-16; offsets are UTF-16, see treesitter.ts), so this
10
+ // cache DECODES the file to a STRING once and caches the string. The decode is
11
+ // the cache's job so every resolver receives ready-to-parse source.
12
+ //
13
+ // CONCURRENCY: Go holds a mutex across the I/O so two goroutines racing on a
14
+ // miss both wait for the single read. The JS analogue caches the in-flight
15
+ // Promise (not just the resolved value): concurrent reads of the same path share
16
+ // one Promise, so readFile runs exactly once per path. This preserves the
17
+ // behaviour TestSourceCache encodes (8 concurrent reads → 1 underlying read).
18
+ const decoder = new TextDecoder("utf-8");
19
+ /**
20
+ * SourceCache caches decoded source strings by file path. Construct one per
21
+ * resolver instance (one check run).
22
+ */
23
+ export class SourceCache {
24
+ // Caches the in-flight or settled Promise per path. Caching the Promise (not
25
+ // the string) is what gives the single-read guarantee under concurrency: a
26
+ // second caller on a miss finds the first caller's pending Promise and awaits
27
+ // it rather than issuing its own read.
28
+ byPath = new Map();
29
+ /**
30
+ * read returns the decoded source for the file at `path` in `root`. The result
31
+ * is cached: subsequent calls with the same path return the cached string
32
+ * without performing another read.
33
+ *
34
+ * A missing file (FS readFile throws ENOENT) is mapped to FileNotFoundError,
35
+ * mirroring Go's ErrFileNotFound wrap. Any other I/O fault propagates.
36
+ *
37
+ * This is async because FS.readFile is async (the TS FS abstraction is
38
+ * Promise-based); the dispatch layer awaits it before calling the SYNC
39
+ * per-language resolver. See resolver.ts for why resolve itself stays sync.
40
+ */
41
+ read(root, path) {
42
+ const cached = this.byPath.get(path);
43
+ if (cached !== undefined) {
44
+ return cached;
45
+ }
46
+ const p = this.load(root, path);
47
+ this.byPath.set(path, p);
48
+ // If the read fails, evict so a later call can retry rather than caching the
49
+ // rejection forever (Go re-reads on the next call after an error too — it
50
+ // only caches on success).
51
+ p.catch(() => {
52
+ if (this.byPath.get(path) === p) {
53
+ this.byPath.delete(path);
54
+ }
55
+ });
56
+ return p;
57
+ }
58
+ async load(root, path) {
59
+ let bytes;
60
+ try {
61
+ bytes = await root.readFile(path);
62
+ }
63
+ catch (err) {
64
+ if (isNotExist(err)) {
65
+ throw new FileNotFoundError(path);
66
+ }
67
+ throw err;
68
+ }
69
+ return decoder.decode(bytes);
70
+ }
71
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { SourceCache } from "./cache.js";
3
+ import { FileNotFoundError } from "./errors.js";
4
+ // WHY: without caching, every Resolve issues a readFile — wasteful, and it
5
+ // compounds latency for runs that check many refs within one file. The cache
6
+ // must read each file at MOST once per instance, even under concurrent access:
7
+ // the Go version holds a mutex across the I/O so racing callers share one read.
8
+ // The TS analogue caches the in-flight Promise; this test pins that 8 concurrent
9
+ // reads of the same path cause exactly ONE underlying readFile. (Port of
10
+ // internal/codeq/cache_test.go TestSourceCache, adapted to the async FS.)
11
+ const enc = new TextEncoder();
12
+ /** countingFS counts readFile calls and serves an in-memory file table. */
13
+ class CountingFS {
14
+ files;
15
+ reads = 0;
16
+ constructor(files) {
17
+ this.files = files;
18
+ }
19
+ async readFile(name) {
20
+ this.reads++;
21
+ const content = this.files[name];
22
+ if (content === undefined) {
23
+ // Mirror Node's ENOENT so isNotExist() recognises it.
24
+ const err = new Error(`ENOENT: ${name}`);
25
+ err.code = "ENOENT";
26
+ throw err;
27
+ }
28
+ return enc.encode(content);
29
+ }
30
+ // Unused by SourceCache; present to satisfy the FS contract.
31
+ async readDir() {
32
+ return [];
33
+ }
34
+ sub() {
35
+ return this;
36
+ }
37
+ }
38
+ describe("SourceCache", () => {
39
+ it("reads each path at most once across sequential calls", async () => {
40
+ const fs = new CountingFS({ "pkg/foo.go": "package pkg\nfunc A(){}\n" });
41
+ const cache = new SourceCache();
42
+ const a = await cache.read(fs, "pkg/foo.go");
43
+ const b = await cache.read(fs, "pkg/foo.go");
44
+ expect(a).toBe(b); // identical cached value
45
+ expect(a).toBe("package pkg\nfunc A(){}\n"); // decoded to a STRING (deviation)
46
+ expect(fs.reads).toBe(1);
47
+ });
48
+ it("reads exactly once under 8 concurrent readers of the same path", async () => {
49
+ const fs = new CountingFS({ "pkg/foo.go": "package pkg\nfunc A(){}\n" });
50
+ const cache = new SourceCache();
51
+ // Fire all 8 before awaiting any: they all hit the same in-flight Promise.
52
+ const results = await Promise.all(Array.from({ length: 8 }, () => cache.read(fs, "pkg/foo.go")));
53
+ for (const r of results)
54
+ expect(r).toBe("package pkg\nfunc A(){}\n");
55
+ expect(fs.reads).toBe(1);
56
+ });
57
+ it("maps a missing file to FileNotFoundError and does not cache the failure", async () => {
58
+ const fs = new CountingFS({});
59
+ const cache = new SourceCache();
60
+ // WHY: absence must surface as FileNotFoundError (Go's ErrFileNotFound), not
61
+ // a generic throw — the check layer distinguishes "file gone" from "symbol
62
+ // absent". And a failed read must not be cached, so a later call retries.
63
+ await expect(cache.read(fs, "missing.go")).rejects.toBeInstanceOf(FileNotFoundError);
64
+ await expect(cache.read(fs, "missing.go")).rejects.toBeInstanceOf(FileNotFoundError);
65
+ expect(fs.reads).toBe(2); // retried, not cached
66
+ });
67
+ });
@@ -0,0 +1,52 @@
1
+ // Package codeq resolves code references against the project source tree.
2
+ // It is the only place tree-sitter touches docgov; every consumer above this
3
+ // layer sees the Resolver interface as a boolean oracle.
4
+ //
5
+ // Go's codeq uses sentinel `var Err… = errors.New(…)` values wrapped with
6
+ // `fmt.Errorf("%w: %q", …)`. We mirror each sentinel with a named Error
7
+ // subclass so callers test `err instanceof XError` instead of string-matching,
8
+ // and the offending path is carried in the message exactly as Go wraps it.
9
+ /**
10
+ * FileNotFoundError is thrown when the referenced file does not exist in the
11
+ * given FS. It is distinct from ParseFailedError: "the file isn't there" vs
12
+ * "the file is there but couldn't be parsed".
13
+ *
14
+ * Go original: `ErrFileNotFound`, wrapped as `fmt.Errorf("%w: %q", …, path)`.
15
+ */
16
+ export class FileNotFoundError extends Error {
17
+ constructor(path) {
18
+ super(`codeq: file not found: ${JSON.stringify(path)}`);
19
+ this.name = "FileNotFoundError";
20
+ }
21
+ }
22
+ /**
23
+ * ParseFailedError is thrown when tree-sitter produces a tree that contains
24
+ * ERROR nodes (i.e. the source is not valid for its language). The path is
25
+ * always included so callers can surface it to the user.
26
+ *
27
+ * Go original: `ErrParseFailed`, wrapped as `fmt.Errorf("%w: %q", …, path)`.
28
+ */
29
+ export class ParseFailedError extends Error {
30
+ constructor(path) {
31
+ super(`codeq: parse failed: ${JSON.stringify(path)}`);
32
+ this.name = "ParseFailedError";
33
+ }
34
+ }
35
+ /**
36
+ * UnsupportedLanguageError is thrown when the file extension has no registered
37
+ * resolver. It is distinct from an operational failure — it means docgov has no
38
+ * grammar for that language, not that the symbol is absent.
39
+ *
40
+ * Go original: `ErrUnsupportedLanguage`, wrapped as `fmt.Errorf("%w: %q", …, ext)`.
41
+ */
42
+ export class UnsupportedLanguageError extends Error {
43
+ constructor(ext) {
44
+ super(`codeq: unsupported language: ${JSON.stringify(ext)}`);
45
+ this.name = "UnsupportedLanguageError";
46
+ }
47
+ }
48
+ // DEVIATION (recorded): Go's ErrUnsupportedBuild / the nocodecheck build tag
49
+ // (codeq_off.go) has no TS analogue. TS has no build tags; tree-sitter is always
50
+ // available as the web-tree-sitter npm dep, so the "code-resolution support not
51
+ // compiled in" path collapses to always-on. ErrUnsupportedBuild is intentionally
52
+ // NOT ported.
@@ -0,0 +1,11 @@
1
+ // Public surface of the codeq package — the only place tree-sitter touches
2
+ // docgov. Consumers above this layer see the Resolver/DefaultResolver boolean
3
+ // oracle and the typed errors, never the binding.
4
+ export { FileNotFoundError, ParseFailedError, UnsupportedLanguageError, } from "./errors.js";
5
+ export { DefaultResolver, createDefaultResolver } from "./resolver.js";
6
+ export { SourceCache } from "./cache.js";
7
+ export { normalizeType, signaturesMatch } from "./signature.js";
8
+ // Re-export the tree-sitter helpers the language resolver modules build on, so
9
+ // resolvers import everything codeq-binding-related from "../index.js" (or the
10
+ // specific module) and never reach for web-tree-sitter directly.
11
+ export { Language, Node, Parser, compileQuery, loadGrammar, nodeText, parseTree, runQuery, } from "./treesitter.js";