docsgov 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +242 -0
  2. package/dist/apispec/apispec.js +401 -0
  3. package/dist/apispec/apispec.test.js +444 -0
  4. package/dist/apispec/errors.js +17 -0
  5. package/dist/apispec/index.js +2 -0
  6. package/dist/check/doclinks.js +167 -0
  7. package/dist/check/index.js +8 -0
  8. package/dist/check/run.js +391 -0
  9. package/dist/check/run.test.js +513 -0
  10. package/dist/check/suggest.js +134 -0
  11. package/dist/check/suggest.test.js +92 -0
  12. package/dist/check/tokens.js +125 -0
  13. package/dist/cmd/main.js +330 -0
  14. package/dist/cmd/main.test.js +422 -0
  15. package/dist/codeq/cache.js +71 -0
  16. package/dist/codeq/cache.test.js +67 -0
  17. package/dist/codeq/errors.js +52 -0
  18. package/dist/codeq/grammars/tree-sitter-go.wasm +0 -0
  19. package/dist/codeq/grammars/tree-sitter-java.wasm +0 -0
  20. package/dist/codeq/grammars/tree-sitter-javascript.wasm +0 -0
  21. package/dist/codeq/grammars/tree-sitter-tsx.wasm +0 -0
  22. package/dist/codeq/grammars/tree-sitter-typescript.wasm +0 -0
  23. package/dist/codeq/index.js +11 -0
  24. package/dist/codeq/resolve.test.js +109 -0
  25. package/dist/codeq/resolver.js +128 -0
  26. package/dist/codeq/resolver.test.js +124 -0
  27. package/dist/codeq/resolvers/go.js +242 -0
  28. package/dist/codeq/resolvers/go.test.js +143 -0
  29. package/dist/codeq/resolvers/java.js +349 -0
  30. package/dist/codeq/resolvers/java.test.js +138 -0
  31. package/dist/codeq/resolvers/java_queries.js +63 -0
  32. package/dist/codeq/resolvers/javascript.js +412 -0
  33. package/dist/codeq/resolvers/javascript.test.js +125 -0
  34. package/dist/codeq/resolvers/javascript_queries.js +46 -0
  35. package/dist/codeq/resolvers/typescript.js +366 -0
  36. package/dist/codeq/resolvers/typescript.test.js +180 -0
  37. package/dist/codeq/resolvers/typescript_queries.js +78 -0
  38. package/dist/codeq/signature.js +50 -0
  39. package/dist/codeq/signature.test.js +50 -0
  40. package/dist/codeq/suggest.js +96 -0
  41. package/dist/codeq/treesitter.js +122 -0
  42. package/dist/codeq/treesitter.test.js +118 -0
  43. package/dist/config/config.js +74 -0
  44. package/dist/config/config.test.js +98 -0
  45. package/dist/config/fs.js +116 -0
  46. package/dist/config/glob.js +82 -0
  47. package/dist/config/glob.test.js +61 -0
  48. package/dist/config/index.js +4 -0
  49. package/dist/dedup/analyzer/analyzer.js +533 -0
  50. package/dist/dedup/analyzer/analyzer.test.js +530 -0
  51. package/dist/dedup/analyzer/canonical.js +74 -0
  52. package/dist/dedup/analyzer/canonical.test.js +70 -0
  53. package/dist/dedup/analyzer/cosine_clusters.js +169 -0
  54. package/dist/dedup/analyzer/cosine_clusters.test.js +131 -0
  55. package/dist/dedup/analyzer/distinctive.js +85 -0
  56. package/dist/dedup/analyzer/distinctive.test.js +49 -0
  57. package/dist/dedup/analyzer/exact_clusters.js +63 -0
  58. package/dist/dedup/analyzer/exact_clusters.test.js +81 -0
  59. package/dist/dedup/analyzer/index.js +14 -0
  60. package/dist/dedup/analyzer/multiplicity.js +110 -0
  61. package/dist/dedup/analyzer/multiplicity.test.js +123 -0
  62. package/dist/dedup/analyzer/order.js +22 -0
  63. package/dist/dedup/analyzer/partial_overlaps.js +65 -0
  64. package/dist/dedup/analyzer/partial_overlaps.test.js +161 -0
  65. package/dist/dedup/analyzer/preview.js +84 -0
  66. package/dist/dedup/analyzer/preview.test.js +46 -0
  67. package/dist/dedup/analyzer/safety.js +27 -0
  68. package/dist/dedup/analyzer/safety.test.js +39 -0
  69. package/dist/dedup/config.js +18 -0
  70. package/dist/dedup/configload.js +299 -0
  71. package/dist/dedup/configload.test.js +410 -0
  72. package/dist/dedup/dedup.index.test.js +203 -0
  73. package/dist/dedup/dedup.js +143 -0
  74. package/dist/dedup/dedup.test.js +212 -0
  75. package/dist/dedup/dedupcfg/config.js +112 -0
  76. package/dist/dedup/dedupcfg/config.test.js +70 -0
  77. package/dist/dedup/dedupcfg/index.js +1 -0
  78. package/dist/dedup/deduptypes/index.js +1 -0
  79. package/dist/dedup/deduptypes/types.js +9 -0
  80. package/dist/dedup/deduptypes/types.test.js +34 -0
  81. package/dist/dedup/embedder/cache.js +23 -0
  82. package/dist/dedup/embedder/cache.test.js +50 -0
  83. package/dist/dedup/embedder/constants.js +10 -0
  84. package/dist/dedup/embedder/embedder.js +76 -0
  85. package/dist/dedup/embedder/embedder.mock.test.js +128 -0
  86. package/dist/dedup/embedder/embedder.test.js +96 -0
  87. package/dist/dedup/embedder/errors.js +20 -0
  88. package/dist/dedup/embedder/errors.test.js +35 -0
  89. package/dist/dedup/embedder/index.js +4 -0
  90. package/dist/dedup/embedder/session.js +78 -0
  91. package/dist/dedup/embedder/session.test.js +172 -0
  92. package/dist/dedup/gitignore.js +97 -0
  93. package/dist/dedup/gitignore.test.js +98 -0
  94. package/dist/dedup/index.js +11 -0
  95. package/dist/dedup/indexdb/errors.js +48 -0
  96. package/dist/dedup/indexdb/index.js +6 -0
  97. package/dist/dedup/indexdb/indexdb.js +302 -0
  98. package/dist/dedup/indexdb/indexdb.test.js +739 -0
  99. package/dist/dedup/indexdb/load.js +110 -0
  100. package/dist/dedup/indexdb/migrations.js +58 -0
  101. package/dist/dedup/indexdb/schema.js +83 -0
  102. package/dist/dedup/indexer/index.js +9 -0
  103. package/dist/dedup/indexer/indexer.js +501 -0
  104. package/dist/dedup/indexer/indexer.test.js +510 -0
  105. package/dist/dedup/indexer/links.js +89 -0
  106. package/dist/dedup/mdsection/anchor.js +60 -0
  107. package/dist/dedup/mdsection/anchor.test.js +39 -0
  108. package/dist/dedup/mdsection/blocks.js +409 -0
  109. package/dist/dedup/mdsection/blocks.test.js +359 -0
  110. package/dist/dedup/mdsection/index.js +4 -0
  111. package/dist/dedup/mdsection/parse.js +21 -0
  112. package/dist/dedup/mdsection/section.js +234 -0
  113. package/dist/dedup/mdsection/section.test.js +221 -0
  114. package/dist/dedup/report/floatfmt.js +71 -0
  115. package/dist/dedup/report/floatfmt.test.js +42 -0
  116. package/dist/dedup/report/index.js +8 -0
  117. package/dist/dedup/report/quote.js +77 -0
  118. package/dist/dedup/report/quote.test.js +67 -0
  119. package/dist/dedup/report/text.js +251 -0
  120. package/dist/dedup/report/text.test.js +420 -0
  121. package/dist/dedup/report_types.js +8 -0
  122. package/dist/dedup/sectionid/index.js +1 -0
  123. package/dist/dedup/sectionid/sectionid.js +16 -0
  124. package/dist/dedup/sectionid/sectionid.test.js +49 -0
  125. package/dist/guard/api/errors.js +12 -0
  126. package/dist/guard/api/index.js +2 -0
  127. package/dist/guard/api/parser.js +81 -0
  128. package/dist/guard/api/parser.test.js +58 -0
  129. package/dist/guard/api/types.js +1 -0
  130. package/dist/guard/code/errors.js +16 -0
  131. package/dist/guard/code/index.js +2 -0
  132. package/dist/guard/code/parser.js +54 -0
  133. package/dist/guard/code/parser.test.js +111 -0
  134. package/dist/guard/code/types.js +6 -0
  135. package/dist/index.js +1 -0
  136. package/dist/index.test.js +5 -0
  137. package/dist/repo/boundary.js +92 -0
  138. package/dist/repo/boundary.test.js +65 -0
  139. package/dist/repo/errors.js +56 -0
  140. package/dist/repo/errors.test.js +85 -0
  141. package/dist/repo/exists.test.js +72 -0
  142. package/dist/repo/filename.js +46 -0
  143. package/dist/repo/filename.test.js +39 -0
  144. package/dist/repo/fs.js +53 -0
  145. package/dist/repo/index.js +7 -0
  146. package/dist/repo/overlay.js +36 -0
  147. package/dist/repo/overlay.test.js +80 -0
  148. package/dist/repo/repo.js +353 -0
  149. package/dist/repo/repo.test.js +255 -0
  150. package/dist/repo/testutil.js +27 -0
  151. package/dist/repo/write.test.js +125 -0
  152. package/dist/report/color.js +73 -0
  153. package/dist/report/index.js +1 -0
  154. package/dist/report/report.js +112 -0
  155. package/dist/report/report.test.js +368 -0
  156. package/dist/violation/index.js +1 -0
  157. package/dist/violation/types.js +22 -0
  158. package/dist/violation/types.test.js +70 -0
  159. package/package.json +48 -0
@@ -0,0 +1,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
+ }