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