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,368 @@
|
|
|
1
|
+
// Exercises the text and JSON renderers in the report package.
|
|
2
|
+
//
|
|
3
|
+
// WHY each test group exists (ported from internal/report/report_test.go):
|
|
4
|
+
//
|
|
5
|
+
// - Text output is deterministic: violations must be sorted by file then by
|
|
6
|
+
// the order emitted (position). Non-deterministic output would make CI
|
|
7
|
+
// reports undiffable across runs, hiding real regressions.
|
|
8
|
+
//
|
|
9
|
+
// - JSON output is stable-ordered: the JSON array must reflect the same sort
|
|
10
|
+
// order as text, and each object must have stable field order. Unstable JSON
|
|
11
|
+
// breaks the agent's ability to parse the correction signal reliably.
|
|
12
|
+
//
|
|
13
|
+
// - Both formats must produce the same violation set — a violation visible in
|
|
14
|
+
// text but not in JSON (or vice-versa) would silently drop the agent's
|
|
15
|
+
// correction signal.
|
|
16
|
+
//
|
|
17
|
+
// - Empty input produces valid (empty) output in both formats — a clean doc
|
|
18
|
+
// tree must not produce spurious output that triggers an exit 1.
|
|
19
|
+
//
|
|
20
|
+
// The exact-byte tests additionally pin the wire format because the golden-test
|
|
21
|
+
// oracle compares this output byte-for-byte against the Go binary's output. The
|
|
22
|
+
// oracle captures non-TTY stdout, where lipgloss renders spans as plain text;
|
|
23
|
+
// these tests guard on colorEnabled === false so they assert the same plain
|
|
24
|
+
// path and fail loudly if the environment unexpectedly turns color on.
|
|
25
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
26
|
+
import { text, json } from "./report.js";
|
|
27
|
+
import { colorEnabled } from "./color.js";
|
|
28
|
+
import { Rules } from "../violation/index.js";
|
|
29
|
+
// sampleViolations returns a fixed, deterministic slice of violations used as
|
|
30
|
+
// the canonical input for rendering tests. Mirrors the Go test fixture.
|
|
31
|
+
function sampleViolations() {
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
file: "docs/adr/0001.md",
|
|
35
|
+
line: 10,
|
|
36
|
+
sectionID: "",
|
|
37
|
+
rule: Rules.guardDocs,
|
|
38
|
+
expected: "",
|
|
39
|
+
actual: "",
|
|
40
|
+
message: "referenced file does not exist",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
file: "docs/adr/0002.md",
|
|
44
|
+
line: 5,
|
|
45
|
+
sectionID: "",
|
|
46
|
+
rule: Rules.guardCode,
|
|
47
|
+
expected: "",
|
|
48
|
+
actual: "{{code:internal/foo.go#Bar}}",
|
|
49
|
+
message: "referenced symbol not found",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
file: "docs/adr/0001.md",
|
|
53
|
+
line: 20,
|
|
54
|
+
sectionID: "",
|
|
55
|
+
rule: Rules.guardAPI,
|
|
56
|
+
expected: "",
|
|
57
|
+
actual: "{{api: GET /foo}}",
|
|
58
|
+
message: "endpoint not found in spec",
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
// ── Text renderer tests ────────────────────────────────────────────────────
|
|
63
|
+
describe("text", () => {
|
|
64
|
+
// WHY: the caller (cmd) exits 0 when violations is empty. Spurious output on a
|
|
65
|
+
// clean run is noise.
|
|
66
|
+
it("returns empty string for empty input", () => {
|
|
67
|
+
expect(text([])).toBe("");
|
|
68
|
+
});
|
|
69
|
+
// WHY: grouping by file is the user-facing contract (check.md §5 "grouped by
|
|
70
|
+
// file"). Missing files mean the user cannot locate the violation.
|
|
71
|
+
it("contains every file name", () => {
|
|
72
|
+
const got = text(sampleViolations());
|
|
73
|
+
for (const want of ["docs/adr/0001.md", "docs/adr/0002.md"]) {
|
|
74
|
+
expect(got).toContain(want);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
// WHY: the rule name is the agent's correction signal — the agent identifies
|
|
78
|
+
// what to fix by the rule string. Absent rules make the output unactionable.
|
|
79
|
+
it("contains every rule name", () => {
|
|
80
|
+
const got = text(sampleViolations());
|
|
81
|
+
for (const rule of ["guard-docs", "guard-code", "guard-api"]) {
|
|
82
|
+
expect(got).toContain(rule);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
// WHY: check.md §5 "output is deterministic". Non-deterministic output makes
|
|
86
|
+
// the report undiffable in CI (every run looks like a change).
|
|
87
|
+
it("is deterministic across runs", () => {
|
|
88
|
+
const vs = sampleViolations();
|
|
89
|
+
expect(text(vs)).toBe(text(vs));
|
|
90
|
+
});
|
|
91
|
+
// WHY: check.md §5 "ordered by file path". Sorting by file lets the agent
|
|
92
|
+
// process all violations for one file in one pass.
|
|
93
|
+
it("sorts 0001.md before 0002.md", () => {
|
|
94
|
+
const got = text(sampleViolations());
|
|
95
|
+
const idx1 = got.indexOf("docs/adr/0001.md");
|
|
96
|
+
const idx2 = got.indexOf("docs/adr/0002.md");
|
|
97
|
+
expect(idx1).toBeGreaterThanOrEqual(0);
|
|
98
|
+
expect(idx2).toBeGreaterThanOrEqual(0);
|
|
99
|
+
expect(idx1).toBeLessThan(idx2);
|
|
100
|
+
});
|
|
101
|
+
// WHY: the golden oracle checks this output byte-for-byte. This pins the exact
|
|
102
|
+
// plain (non-TTY) wire format: file header, two-space indent, "line N ",
|
|
103
|
+
// rule, optional [section]/expected/actual/message, and a blank line after
|
|
104
|
+
// each file group. Within 0001.md, line 10 precedes line 20 (stable sort
|
|
105
|
+
// keeps input order within a file).
|
|
106
|
+
it("pins the exact plain-text wire format", () => {
|
|
107
|
+
// Guard: the byte-exact assertion only holds on the no-color path. Fail loud
|
|
108
|
+
// if the environment turned color on (would inject ANSI escapes).
|
|
109
|
+
expect(colorEnabled).toBe(false);
|
|
110
|
+
const got = text(sampleViolations());
|
|
111
|
+
const want = "docs/adr/0001.md\n" +
|
|
112
|
+
" line 10 guard-docs referenced file does not exist\n" +
|
|
113
|
+
" line 20 guard-api actual: {{api: GET /foo}} endpoint not found in spec\n" +
|
|
114
|
+
"\n" +
|
|
115
|
+
"docs/adr/0002.md\n" +
|
|
116
|
+
" line 5 guard-code actual: {{code:internal/foo.go#Bar}} referenced symbol not found\n" +
|
|
117
|
+
"\n";
|
|
118
|
+
expect(got).toBe(want);
|
|
119
|
+
});
|
|
120
|
+
// WHY: line 0 means "not applicable" — Go omits the "line N " prefix when
|
|
121
|
+
// Line <= 0. A spurious "line 0 " would mislead the agent about position.
|
|
122
|
+
it("omits the line prefix when line is 0", () => {
|
|
123
|
+
const vs = [
|
|
124
|
+
{
|
|
125
|
+
file: "docs/x.md",
|
|
126
|
+
line: 0,
|
|
127
|
+
sectionID: "",
|
|
128
|
+
rule: Rules.guardDocs,
|
|
129
|
+
expected: "",
|
|
130
|
+
actual: "",
|
|
131
|
+
message: "doc-level problem",
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
expect(text(vs)).toBe("docs/x.md\n guard-docs doc-level problem\n\n");
|
|
135
|
+
});
|
|
136
|
+
// WHY: section_id, expected, and actual are each emitted only when non-empty,
|
|
137
|
+
// in a fixed order ([section] expected: actual: message). This pins the
|
|
138
|
+
// ordering and the bracket/label punctuation the oracle compares.
|
|
139
|
+
it("renders section, expected, actual, and message in order", () => {
|
|
140
|
+
const vs = [
|
|
141
|
+
{
|
|
142
|
+
file: "docs/y.md",
|
|
143
|
+
line: 3,
|
|
144
|
+
sectionID: "context",
|
|
145
|
+
rule: Rules.guardCode,
|
|
146
|
+
expected: "docs/target.md",
|
|
147
|
+
actual: "docs/wrong.md",
|
|
148
|
+
message: "mismatch",
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
expect(text(vs)).toBe("docs/y.md\n line 3 guard-code [context] expected: docs/target.md actual: docs/wrong.md mismatch\n\n");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
// ── JSON renderer tests ────────────────────────────────────────────────────
|
|
155
|
+
describe("json", () => {
|
|
156
|
+
// WHY: a clean run must emit valid JSON an agent can parse. "null" or "" would
|
|
157
|
+
// break parsing on the empty-array path.
|
|
158
|
+
it("returns [] for empty input", () => {
|
|
159
|
+
expect(json([])).toBe("[]");
|
|
160
|
+
});
|
|
161
|
+
// WHY: the JSON format is the machine-readable correction signal (check.md
|
|
162
|
+
// §5). Invalid JSON silently drops all violations — the agent thinks the tree
|
|
163
|
+
// is clean.
|
|
164
|
+
it("emits valid JSON with one record per violation", () => {
|
|
165
|
+
const vs = sampleViolations();
|
|
166
|
+
const records = JSON.parse(json(vs));
|
|
167
|
+
expect(records).toHaveLength(vs.length);
|
|
168
|
+
});
|
|
169
|
+
// WHY: the JSON schema is the agent's contract. Missing fields break parsing;
|
|
170
|
+
// undocumented fields confuse the agent. All seven fields must be present.
|
|
171
|
+
it("includes all seven fields in every record", () => {
|
|
172
|
+
const vs = [
|
|
173
|
+
{
|
|
174
|
+
file: "docs/adr/0001.md",
|
|
175
|
+
line: 10,
|
|
176
|
+
sectionID: "",
|
|
177
|
+
rule: Rules.guardDocs,
|
|
178
|
+
expected: "docs/target.md",
|
|
179
|
+
actual: "",
|
|
180
|
+
message: "referenced file does not exist",
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
const records = JSON.parse(json(vs));
|
|
184
|
+
expect(records).toHaveLength(1);
|
|
185
|
+
const rec = records[0];
|
|
186
|
+
for (const field of [
|
|
187
|
+
"file",
|
|
188
|
+
"line",
|
|
189
|
+
"section_id",
|
|
190
|
+
"rule",
|
|
191
|
+
"expected",
|
|
192
|
+
"actual",
|
|
193
|
+
"message",
|
|
194
|
+
]) {
|
|
195
|
+
expect(rec).toHaveProperty(field);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
// WHY: non-deterministic JSON makes the agent's correction signal unstable
|
|
199
|
+
// across runs, breaking automated diffing and checksum validation.
|
|
200
|
+
it("is deterministic across runs", () => {
|
|
201
|
+
const vs = sampleViolations();
|
|
202
|
+
expect(json(vs)).toBe(json(vs));
|
|
203
|
+
});
|
|
204
|
+
// WHY: the agent processes the array sequentially; sorting by file lets it
|
|
205
|
+
// batch corrections per file in one pass.
|
|
206
|
+
it("sorts records by file path", () => {
|
|
207
|
+
const records = JSON.parse(json(sampleViolations()));
|
|
208
|
+
for (let i = 1; i < records.length; i++) {
|
|
209
|
+
expect(records[i - 1].file <= records[i].file).toBe(true);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
// WHY: the golden oracle checks JSON byte-for-byte. This pins the exact wire
|
|
213
|
+
// bytes: compact (no spaces), fixed field order matching Go's struct, empty
|
|
214
|
+
// strings present, and the stable file sort. Within 0001.md the line-10
|
|
215
|
+
// record precedes the line-20 record (stable sort preserves input order).
|
|
216
|
+
it("pins the exact compact JSON wire format", () => {
|
|
217
|
+
const got = json(sampleViolations());
|
|
218
|
+
const want = "[" +
|
|
219
|
+
'{"file":"docs/adr/0001.md","line":10,"section_id":"","rule":"guard-docs","expected":"","actual":"","message":"referenced file does not exist"},' +
|
|
220
|
+
'{"file":"docs/adr/0001.md","line":20,"section_id":"","rule":"guard-api","expected":"","actual":"{{api: GET /foo}}","message":"endpoint not found in spec"},' +
|
|
221
|
+
'{"file":"docs/adr/0002.md","line":5,"section_id":"","rule":"guard-code","expected":"","actual":"{{code:internal/foo.go#Bar}}","message":"referenced symbol not found"}' +
|
|
222
|
+
"]";
|
|
223
|
+
expect(got).toBe(want);
|
|
224
|
+
});
|
|
225
|
+
// WHY: Go's encoding/json HTML-escapes <, >, & (and U+2028/U+2029) to \uXXXX
|
|
226
|
+
// by default. JSON.stringify does not. Without replicating that escaping, a
|
|
227
|
+
// violation whose message contains an angle bracket or ampersand would produce
|
|
228
|
+
// bytes that differ from Go and fail the oracle.
|
|
229
|
+
it("HTML-escapes <, >, and & like Go's encoder", () => {
|
|
230
|
+
const vs = [
|
|
231
|
+
{
|
|
232
|
+
file: "docs/a<b>.md",
|
|
233
|
+
line: 1,
|
|
234
|
+
sectionID: "",
|
|
235
|
+
rule: Rules.guardDocs,
|
|
236
|
+
expected: "",
|
|
237
|
+
actual: "",
|
|
238
|
+
message: "x < y && z > w",
|
|
239
|
+
},
|
|
240
|
+
];
|
|
241
|
+
const got = json(vs);
|
|
242
|
+
expect(got).toContain("docs/a\\u003cb\\u003e.md");
|
|
243
|
+
expect(got).toContain("x \\u003c y \\u0026\\u0026 z \\u003e w");
|
|
244
|
+
// The raw characters must NOT survive in the output.
|
|
245
|
+
expect(got).not.toContain("<");
|
|
246
|
+
expect(got).not.toContain(">");
|
|
247
|
+
expect(got).not.toContain("&");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
// ── Color-enable gate (color.ts) ────────────────────────────────────────────
|
|
251
|
+
//
|
|
252
|
+
// WHY this group exists: colorEnabled gates whether the text report emits ANSI
|
|
253
|
+
// escapes. The byte-checked golden surface is the PLAIN (no-color) path, so the
|
|
254
|
+
// precedence the report relies on — NO_COLOR > CLICOLOR_FORCE > CLICOLOR=0 >
|
|
255
|
+
// TTY+non-dumb TERM — must hold exactly. A wrong gate would either inject
|
|
256
|
+
// escapes into the oracle-compared output (corrupting it) or suppress color
|
|
257
|
+
// when forced. The decision is cached at module load, so each case re-imports a
|
|
258
|
+
// fresh module with the relevant env / isTTY set.
|
|
259
|
+
describe("color gate (detectColorEnabled precedence)", () => {
|
|
260
|
+
// Snapshot the env keys the gate reads plus stdout.isTTY so each case starts
|
|
261
|
+
// clean and the suite leaves the process untouched.
|
|
262
|
+
const watched = ["NO_COLOR", "CLICOLOR_FORCE", "CLICOLOR", "TERM"];
|
|
263
|
+
const savedEnv = {};
|
|
264
|
+
let savedTTY;
|
|
265
|
+
function clearEnv() {
|
|
266
|
+
for (const k of watched) {
|
|
267
|
+
savedEnv[k] = process.env[k];
|
|
268
|
+
delete process.env[k];
|
|
269
|
+
}
|
|
270
|
+
savedTTY = process.stdout.isTTY;
|
|
271
|
+
}
|
|
272
|
+
function setTTY(v) {
|
|
273
|
+
Object.defineProperty(process.stdout, "isTTY", {
|
|
274
|
+
value: v,
|
|
275
|
+
configurable: true,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
afterEach(() => {
|
|
279
|
+
for (const k of watched) {
|
|
280
|
+
if (savedEnv[k] === undefined)
|
|
281
|
+
delete process.env[k];
|
|
282
|
+
else
|
|
283
|
+
process.env[k] = savedEnv[k];
|
|
284
|
+
}
|
|
285
|
+
Object.defineProperty(process.stdout, "isTTY", {
|
|
286
|
+
value: savedTTY,
|
|
287
|
+
configurable: true,
|
|
288
|
+
});
|
|
289
|
+
vi.resetModules();
|
|
290
|
+
});
|
|
291
|
+
// Load a fresh copy of color.ts so detectColorEnabled re-runs against the env
|
|
292
|
+
// mutated by the test (the real export is cached from initial import).
|
|
293
|
+
async function freshColor() {
|
|
294
|
+
vi.resetModules();
|
|
295
|
+
return import("./color.js");
|
|
296
|
+
}
|
|
297
|
+
// WHY: NO_COLOR (no-color.org) must disable color outright, even on a TTY —
|
|
298
|
+
// it is the highest-precedence override.
|
|
299
|
+
it("NO_COLOR disables color even with a TTY and good TERM", async () => {
|
|
300
|
+
clearEnv();
|
|
301
|
+
process.env["NO_COLOR"] = "1";
|
|
302
|
+
process.env["TERM"] = "xterm";
|
|
303
|
+
setTTY(true);
|
|
304
|
+
const m = await freshColor();
|
|
305
|
+
expect(m.colorEnabled).toBe(false);
|
|
306
|
+
// No-color path: the style helpers are the identity function.
|
|
307
|
+
expect(m.fileStyle("X")).toBe("X");
|
|
308
|
+
expect(m.ruleStyle("X")).toBe("X");
|
|
309
|
+
});
|
|
310
|
+
// WHY: CLICOLOR_FORCE bumps color ON even off a TTY (e.g. piped output),
|
|
311
|
+
// overriding the default Ascii-when-piped behavior.
|
|
312
|
+
it("CLICOLOR_FORCE forces color on even off a TTY", async () => {
|
|
313
|
+
clearEnv();
|
|
314
|
+
process.env["CLICOLOR_FORCE"] = "1";
|
|
315
|
+
setTTY(false);
|
|
316
|
+
const m = await freshColor();
|
|
317
|
+
expect(m.colorEnabled).toBe(true);
|
|
318
|
+
// Color-on path: the style helpers wrap the input (it is no longer identity).
|
|
319
|
+
expect(m.fileStyle("X")).not.toBe("X");
|
|
320
|
+
expect(m.ruleStyle("X")).not.toBe("X");
|
|
321
|
+
});
|
|
322
|
+
// WHY: CLICOLOR_FORCE="0" is NOT a force (matches termenv) — it must fall
|
|
323
|
+
// through to the TTY check rather than forcing color on.
|
|
324
|
+
it("CLICOLOR_FORCE=0 is not a force and falls through to the TTY check", async () => {
|
|
325
|
+
clearEnv();
|
|
326
|
+
process.env["CLICOLOR_FORCE"] = "0";
|
|
327
|
+
setTTY(false);
|
|
328
|
+
const m = await freshColor();
|
|
329
|
+
expect(m.colorEnabled).toBe(false);
|
|
330
|
+
});
|
|
331
|
+
// WHY: CLICOLOR="0" (without a force) disables color — the explicit opt-out
|
|
332
|
+
// below the force level.
|
|
333
|
+
it("CLICOLOR=0 disables color when not forced", async () => {
|
|
334
|
+
clearEnv();
|
|
335
|
+
process.env["CLICOLOR"] = "0";
|
|
336
|
+
process.env["TERM"] = "xterm";
|
|
337
|
+
setTTY(true);
|
|
338
|
+
const m = await freshColor();
|
|
339
|
+
expect(m.colorEnabled).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
// WHY: the default path enables color only on a TTY with a real TERM; this is
|
|
342
|
+
// the one branch where color turns on without an env override.
|
|
343
|
+
it("enables color on a TTY with a non-dumb TERM", async () => {
|
|
344
|
+
clearEnv();
|
|
345
|
+
process.env["TERM"] = "xterm-256color";
|
|
346
|
+
setTTY(true);
|
|
347
|
+
const m = await freshColor();
|
|
348
|
+
expect(m.colorEnabled).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
// WHY: a "dumb" terminal must NOT get color even on a TTY — dumb terminals
|
|
351
|
+
// cannot render escapes, so emitting them would garble the output.
|
|
352
|
+
it("disables color when TERM is dumb even on a TTY", async () => {
|
|
353
|
+
clearEnv();
|
|
354
|
+
process.env["TERM"] = "dumb";
|
|
355
|
+
setTTY(true);
|
|
356
|
+
const m = await freshColor();
|
|
357
|
+
expect(m.colorEnabled).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
// WHY: off a TTY (pipe/file/test capture) with no override, color is OFF —
|
|
360
|
+
// this is the byte-checked plain path the oracle depends on.
|
|
361
|
+
it("disables color off a TTY with no override", async () => {
|
|
362
|
+
clearEnv();
|
|
363
|
+
process.env["TERM"] = "xterm";
|
|
364
|
+
setTTY(false);
|
|
365
|
+
const m = await freshColor();
|
|
366
|
+
expect(m.colorEnabled).toBe(false);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Rules } from "./types.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Package violation defines the Violation Record and the closed Rule enum for
|
|
2
|
+
// the docgov check surface. It is a leaf package: it imports nothing internal.
|
|
3
|
+
// Every rule the check surface can emit is listed in the Rule set; the set is
|
|
4
|
+
// closed and must exactly match the rule registry in docs/flows/check.md §5.
|
|
5
|
+
// Rules is the closed set of all rule identifiers the check surface can emit.
|
|
6
|
+
// The string value is the wire identifier that appears in both the text and JSON
|
|
7
|
+
// report formats — it is the agent's correction signal and must match the names
|
|
8
|
+
// in docs/flows/check.md §5 ("Rule registry") exactly.
|
|
9
|
+
//
|
|
10
|
+
// - guardDocs fires when a referenced file is missing, uses an absolute path,
|
|
11
|
+
// or escapes the repo root.
|
|
12
|
+
// - guardCode fires when a {{code:…}} reference fails — malformed syntax, an
|
|
13
|
+
// unresolved symbol, a ref outside source, or a resolver/operational error.
|
|
14
|
+
// The specific failure mode lives in expected/actual/message.
|
|
15
|
+
// - guardAPI fires when an {{api:…}} endpoint cross-ref fails — malformed
|
|
16
|
+
// syntax, an endpoint absent in the OpenAPI spec, a qualifier not satisfied,
|
|
17
|
+
// or a spec that is missing or unparseable.
|
|
18
|
+
export const Rules = {
|
|
19
|
+
guardDocs: "guard-docs",
|
|
20
|
+
guardCode: "guard-code",
|
|
21
|
+
guardAPI: "guard-api",
|
|
22
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Rules } from "./index.js";
|
|
3
|
+
// documentedRules is the canonical set of rule identifiers from the check flow's
|
|
4
|
+
// rule registry (docs/flows/check.md §5 "Rule registry"). The Rules set in this
|
|
5
|
+
// package must match this set exactly — no more, no fewer — so that the check
|
|
6
|
+
// surface never emits an undocumented rule and never silently drops a documented
|
|
7
|
+
// one. WHY: these rules are the correction signal sent to the agent; an
|
|
8
|
+
// undocumented rule is uninterpretable, a missing rule is unenforceable.
|
|
9
|
+
const documentedRules = ["guard-docs", "guard-code", "guard-api"];
|
|
10
|
+
describe("Rule enum", () => {
|
|
11
|
+
// Asserts the closed Rule set contains exactly the 3 rules in the three-guard
|
|
12
|
+
// pipeline. This prevents the check surface from emitting rules the agent
|
|
13
|
+
// cannot interpret (undocumented) or omitting rules the agent needs.
|
|
14
|
+
it("exposes exactly the three documented registry rules with exact wire values", () => {
|
|
15
|
+
expect(documentedRules).toHaveLength(3);
|
|
16
|
+
// The exact kebab-case wire values the agent receives in the JSON report.
|
|
17
|
+
expect(Rules.guardDocs).toBe("guard-docs");
|
|
18
|
+
expect(Rules.guardCode).toBe("guard-code");
|
|
19
|
+
expect(Rules.guardAPI).toBe("guard-api");
|
|
20
|
+
// No empty/zero-valued rule and no duplicates.
|
|
21
|
+
const values = Object.values(Rules);
|
|
22
|
+
expect(values.every((v) => v.length > 0)).toBe(true);
|
|
23
|
+
expect(new Set(values).size).toBe(values.length);
|
|
24
|
+
});
|
|
25
|
+
// Exhaustiveness in both directions, deriving the enum side from the actual
|
|
26
|
+
// Rules const rather than a second hand-maintained list. Adding a Rule value
|
|
27
|
+
// without documenting it (or removing one) fails this test in both directions.
|
|
28
|
+
//
|
|
29
|
+
// WHY: documentedRules is the anchor for check.md §5; Object.values(Rules) is
|
|
30
|
+
// the anchor for what the binary actually emits. If they diverge, either an
|
|
31
|
+
// undocumented rule leaks into agent reports (uninterpretable) or a documented
|
|
32
|
+
// rule is silently absent from the set (unenforceable).
|
|
33
|
+
it("matches the documented registry exactly in both directions", () => {
|
|
34
|
+
const sourceSet = new Set(Object.values(Rules));
|
|
35
|
+
const docSet = new Set(documentedRules);
|
|
36
|
+
expect(sourceSet.size).toBeGreaterThan(0);
|
|
37
|
+
// Direction 1 — no undocumented values: every source value is documented.
|
|
38
|
+
for (const v of sourceSet) {
|
|
39
|
+
expect(docSet.has(v)).toBe(true);
|
|
40
|
+
}
|
|
41
|
+
// Direction 2 — no missing values: every documented rule has a source value.
|
|
42
|
+
for (const v of docSet) {
|
|
43
|
+
expect(sourceSet.has(v)).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe("Record", () => {
|
|
48
|
+
// Verifies a Record can be constructed with all fields documented in check.md
|
|
49
|
+
// §5 ("file, line, sectionId, rule, expected, actual, message") and that each
|
|
50
|
+
// field round-trips. The check surface and report package read these fields to
|
|
51
|
+
// produce both the human-readable text report and the JSON agent signal.
|
|
52
|
+
it("carries every documented field and round-trips their values", () => {
|
|
53
|
+
const r = {
|
|
54
|
+
file: "docs/adr/0001-foo.md",
|
|
55
|
+
line: 42,
|
|
56
|
+
sectionID: "context",
|
|
57
|
+
rule: Rules.guardDocs,
|
|
58
|
+
expected: "docs/target.md",
|
|
59
|
+
actual: "",
|
|
60
|
+
message: "referenced file does not exist",
|
|
61
|
+
};
|
|
62
|
+
expect(r.file).toBe("docs/adr/0001-foo.md");
|
|
63
|
+
expect(r.line).toBe(42);
|
|
64
|
+
expect(r.sectionID).toBe("context");
|
|
65
|
+
expect(r.rule).toBe("guard-docs");
|
|
66
|
+
expect(r.expected).toBe("docs/target.md");
|
|
67
|
+
expect(r.actual).toBe("");
|
|
68
|
+
expect(r.message).toBe("referenced file does not exist");
|
|
69
|
+
});
|
|
70
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "docsgov",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Config-driven documentation governance: checks that {{code:…}} and {{api:…}} references in your Markdown docs still resolve, that doc links exist, and (via dedup) flags near-duplicate concepts.",
|
|
5
|
+
"license": "GPL-3.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"docgov": "dist/cmd/main.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json && node scripts/copy-assets.mjs",
|
|
18
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:coverage": "vitest run --coverage",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@huggingface/transformers": "^4.2.0",
|
|
25
|
+
"commander": "^15.0.0",
|
|
26
|
+
"mdast-util-from-markdown": "^2.0.3",
|
|
27
|
+
"mdast-util-gfm": "^3.1.0",
|
|
28
|
+
"mdast-util-to-markdown": "^2.1.2",
|
|
29
|
+
"picocolors": "^1.1.1",
|
|
30
|
+
"picomatch": "^4.0.4",
|
|
31
|
+
"remark-gfm": "^4.0.1",
|
|
32
|
+
"remark-parse": "^11.0.0",
|
|
33
|
+
"unified": "^11.0.5",
|
|
34
|
+
"web-tree-sitter": "^0.26.9",
|
|
35
|
+
"yaml": "^2.9.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^25.9.2",
|
|
39
|
+
"@types/picomatch": "^4.0.3",
|
|
40
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
41
|
+
"tsx": "^4.22.4",
|
|
42
|
+
"typescript": "^6.0.3",
|
|
43
|
+
"vitest": "^4.1.8"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|