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,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": "\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": "\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
|
+
}
|