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,92 @@
1
+ // Tests for suggestionSuffix — the "did you mean" / owner-missing clause appended
2
+ // to a not-found guard-code violation. These encode WHY the ranking behaves as it
3
+ // does: closest-first within a bounded edit distance, exact matches suppressed,
4
+ // noise (no near name) producing nothing, and owner-missing phrased distinctly.
5
+ import { describe, expect, it } from "vitest";
6
+ import { suggestionSuffix } from "./suggest.js";
7
+ /** ref builds a minimal CodeRef; only the facet under test needs a value. */
8
+ function ref(parts) {
9
+ return { Path: "x.go", Symbol: "", Member: "", Param: "", ...parts };
10
+ }
11
+ describe("suggestionSuffix", () => {
12
+ // A single near miss is the headline case: a one-edit typo gets the real name
13
+ // back, and unrelated names are not offered.
14
+ it("offers the closest symbol for a one-edit typo and drops far names", () => {
15
+ const sug = {
16
+ kind: "symbol",
17
+ candidates: ["Bar", "Compute", "Qux"],
18
+ };
19
+ expect(suggestionSuffix(ref({ Symbol: "Baz" }), sug)).toBe(" (did you mean: Bar?)");
20
+ });
21
+ // Ties at equal distance break alphabetically and the list is capped at three,
22
+ // so the output is deterministic and never floods the message.
23
+ it("returns the three closest, tie-broken alphabetically", () => {
24
+ const sug = {
25
+ kind: "symbol",
26
+ // all one edit from "bar": bare, barx, bat, car; "zzzzz" is far.
27
+ candidates: ["car", "bat", "barx", "bare", "zzzzz"],
28
+ };
29
+ expect(suggestionSuffix(ref({ Symbol: "bar" }), sug)).toBe(" (did you mean: bare, barx, bat?)");
30
+ });
31
+ // A bare symbol ref with no near name must add nothing — a file's symbol set
32
+ // can be large, so listing it all would flood the message (symbols stay
33
+ // distance-gated; only member/param refs fall back to a list).
34
+ it("returns no clause for a symbol ref when nothing is within edit distance", () => {
35
+ const sug = { kind: "symbol", candidates: ["alpha", "beta"] };
36
+ expect(suggestionSuffix(ref({ Symbol: "xyzzy" }), sug)).toBe("");
37
+ });
38
+ // A wholly-wrong member name (nothing within edit distance) falls back to
39
+ // listing the owner's members — the Status.TEST case.
40
+ it("lists available members when the member name is wholly wrong", () => {
41
+ const sug = {
42
+ kind: "member",
43
+ candidates: ["ACTIVE", "INACTIVE", "PENDING"],
44
+ };
45
+ expect(suggestionSuffix(ref({ Symbol: "Status", Member: "TEST" }), sug)).toBe(" (Status has: ACTIVE, INACTIVE, PENDING)");
46
+ });
47
+ // The param fallback names the function's params distinctly from members.
48
+ it("lists available params when the param name is wholly wrong", () => {
49
+ const sug = { kind: "param", candidates: ["input", "token"] };
50
+ expect(suggestionSuffix(ref({ Symbol: "Compute", Param: "xyz" }), sug)).toBe(" (Compute has params: input, token)");
51
+ });
52
+ // A large container collapses the tail into "+N more" so the line stays short.
53
+ it("caps the available list with a +N more marker", () => {
54
+ const sug = {
55
+ kind: "member",
56
+ candidates: ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"],
57
+ };
58
+ expect(suggestionSuffix(ref({ Symbol: "E", Member: "ZZZZ" }), sug)).toBe(" (E has: a, b, c, d, e, f, g, h, +2 more)");
59
+ });
60
+ // An exact match means the NAME is right and a deeper facet (signature, param)
61
+ // failed; both the "did you mean" AND the available-list are suppressed there,
62
+ // so the message never echoes the container the author already named.
63
+ it("suppresses everything when the name is exact (deeper facet failed)", () => {
64
+ const sug = { kind: "member", candidates: ["run", "walk"] };
65
+ expect(suggestionSuffix(ref({ Symbol: "T", Member: "run" }), sug)).toBe("");
66
+ });
67
+ // A member typo ranks against the owner's members (keyed on the member name,
68
+ // NOT the symbol — "Ctl" is far from both), closest first.
69
+ it("ranks member candidates against the member name", () => {
70
+ const sug = { kind: "member", candidates: ["Start", "Stop"] };
71
+ expect(suggestionSuffix(ref({ Symbol: "Ctl", Member: "Strt" }), sug)).toBe(" (did you mean: Start, Stop?)"); // Start d1 before Stop d2
72
+ });
73
+ // A missing owner TYPE is a different failure than a missing member: say so,
74
+ // rather than listing members of a type that isn't there.
75
+ it("reports a missing owner type distinctly for member refs", () => {
76
+ const sug = {
77
+ kind: "member",
78
+ ownerMissing: "Ctlx",
79
+ candidates: [],
80
+ };
81
+ expect(suggestionSuffix(ref({ Symbol: "Ctlx", Member: "Start" }), sug)).toBe(" (owner type Ctlx not found)");
82
+ });
83
+ // The same owner-missing signal on a param ref names the function, not a type.
84
+ it("reports a missing function distinctly for param refs", () => {
85
+ const sug = {
86
+ kind: "param",
87
+ ownerMissing: "prase",
88
+ candidates: [],
89
+ };
90
+ expect(suggestionSuffix(ref({ Symbol: "prase", Param: "input" }), sug)).toBe(" (function prase not found)");
91
+ });
92
+ });
@@ -0,0 +1,125 @@
1
+ // Port of internal/check/codetokens.go + internal/check/apitokens.go.
2
+ //
3
+ // These scanners read raw markdown source, find every {{code:…}} / {{api:…}}
4
+ // token, and exclude those whose start offset falls inside a code region —
5
+ // fenced code block, indented code block, or inline code span. Tokens inside a
6
+ // code region are GRAMMAR DOCUMENTATION, not live refs (the "code-span /
7
+ // source-scope" rule), and must NOT be checked.
8
+ //
9
+ // Raw-source scanning (vs per-AST-text-node) is required so a token whose body
10
+ // contains characters markdown would fragment across inline nodes ([]/<>/_) is
11
+ // matched whole. The AST is used ONLY to compute the code regions to exclude.
12
+ //
13
+ // The Go original walks the goldmark AST and uses byte offsets; this port walks
14
+ // the mdast tree (remark-parse + remark-gfm) and uses string character offsets.
15
+ // Everything operates on the same decoded string, so offsets are internally
16
+ // consistent (and identical to bytes for the ASCII token bodies docgov scans).
17
+ import { parseMarkdown } from "../dedup/mdsection/index.js";
18
+ /**
19
+ * codeTokenRE matches a {{code:…}} token anywhere in a text span. It does not
20
+ * validate the grammar — parseCodeRef is responsible for that. The match
21
+ * captures the full token text. Mirrors Go's `\{\{code:[^}]*\}\}`.
22
+ */
23
+ const codeTokenRE = /\{\{code:[^}]*\}\}/g;
24
+ /**
25
+ * apiTokenRE matches a whole {{api:…}} token in prose. The body permits
26
+ * single-level "{var}" groups (OpenAPI path templates) so `{{api: GET /x/{id}}}`
27
+ * matches up to the final "}}", unlike a naive `[^}]*`. Mirrors Go's
28
+ * `\{\{api:[^{}]*(?:\{[^{}]*\}[^{}]*)*\}\}`.
29
+ */
30
+ const apiTokenRE = /\{\{api:[^{}]*(?:\{[^{}]*\}[^{}]*)*\}\}/g;
31
+ /**
32
+ * lineOfOffset returns the 1-based line number for a given character offset in
33
+ * src. It counts the newlines before the offset and adds 1. Mirrors Go's
34
+ * lineOfOffset (which counts bytes; identical for the ASCII content scanned).
35
+ */
36
+ function lineOfOffset(src, offset) {
37
+ if (offset <= 0) {
38
+ return 1;
39
+ }
40
+ if (offset > src.length) {
41
+ offset = src.length;
42
+ }
43
+ let count = 0;
44
+ for (let i = 0; i < offset; i++) {
45
+ if (src.charCodeAt(i) === 0x0a) {
46
+ count++;
47
+ }
48
+ }
49
+ return count + 1;
50
+ }
51
+ /**
52
+ * codeRegions walks the mdast tree and returns the source char ranges of fenced
53
+ * code blocks, indented code blocks, and inline code spans — the regions whose
54
+ * {{code:…}} / {{api:…}} tokens document the grammar and must not be scanned as
55
+ * live refs.
56
+ *
57
+ * mdast collapses goldmark's KindFencedCodeBlock and KindCodeBlock into a single
58
+ * "code" node and KindCodeSpan into "inlineCode". Each node's position covers the
59
+ * whole construct (including fences / backtick delimiters), which is a superset
60
+ * of goldmark's content-only Lines() range; a token inside the construct still
61
+ * falls within that range, so the exclusion is faithful.
62
+ */
63
+ function codeRegions(tree) {
64
+ const regions = [];
65
+ const visit = (n) => {
66
+ if (n.type === "code" || n.type === "inlineCode") {
67
+ const pos = n.position;
68
+ if (pos !== undefined &&
69
+ pos.start.offset !== undefined &&
70
+ pos.end.offset !== undefined) {
71
+ regions.push({ start: pos.start.offset, stop: pos.end.offset });
72
+ }
73
+ // Do not descend into code nodes (mirrors WalkSkipChildren).
74
+ return;
75
+ }
76
+ const children = n.children;
77
+ if (children !== undefined) {
78
+ for (const c of children) {
79
+ visit(c);
80
+ }
81
+ }
82
+ };
83
+ visit(tree);
84
+ return regions;
85
+ }
86
+ /**
87
+ * scanTokens scans the raw source with re, skipping any match whose start offset
88
+ * falls inside a code region. Shared by iterCodeTokens and iterApiTokens.
89
+ */
90
+ function scanTokens(src, re) {
91
+ const tree = parseMarkdown(src);
92
+ const regions = codeRegions(tree);
93
+ const inRegion = (off) => {
94
+ for (const r of regions) {
95
+ if (off >= r.start && off < r.stop) {
96
+ return true;
97
+ }
98
+ }
99
+ return false;
100
+ };
101
+ const locs = [];
102
+ for (const m of src.matchAll(re)) {
103
+ const start = m.index;
104
+ if (inRegion(start)) {
105
+ continue;
106
+ }
107
+ locs.push({ token: m[0], line: lineOfOffset(src, start) });
108
+ }
109
+ return locs;
110
+ }
111
+ /**
112
+ * iterCodeTokens scans the raw source for every {{code:…}} token, skipping any
113
+ * inside a code region (fence / indented block / inline span). Tokens in code
114
+ * regions document the grammar, not live refs. Tokens in prose are checked.
115
+ */
116
+ export function iterCodeTokens(src) {
117
+ return scanTokens(src, codeTokenRE);
118
+ }
119
+ /**
120
+ * iterApiTokens scans the raw source for every {{api:…}} token, skipping any
121
+ * inside a code region. Reuses the same region logic as iterCodeTokens.
122
+ */
123
+ export function iterApiTokens(src) {
124
+ return scanTokens(src, apiTokenRE);
125
+ }
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2
+ // Command docgov is the CLI entrypoint for the docgov documentation-governance
3
+ // engine. Port of cmd/docgov/{main,dedup_on,dedup_off,update}.go.
4
+ //
5
+ // PORTING NOTES (deviations recorded prominently):
6
+ //
7
+ // 1. CLI library: Go used urfave/cli/v3; this port uses `commander`. The
8
+ // command names, usage strings, flag names, and the 0/1/2 EXIT-CODE
9
+ // contract are preserved exactly; only the framework differs.
10
+ //
11
+ // 2. Version source: Go injected the version via -ldflags "-X main.version".
12
+ // npm has no ldflags, so the version is read from package.json at runtime
13
+ // (see readVersion). The Go default "dev" is used as a fallback when
14
+ // package.json cannot be read.
15
+ //
16
+ // 3. dedup build tags: Go gated the real dedup impl (dedup_on.go) behind
17
+ // !nodedup and shipped a stub (dedup_off.go) for cross-compiled binaries.
18
+ // TS has no build tags, so dedup is ALWAYS ON — the off-stub is dropped.
19
+ //
20
+ // 4. update command: Go's update.go downloaded a GitLab release asset and
21
+ // replaced the running binary in place. That is incompatible with npm
22
+ // distribution (the binary is managed by npm, not self-replaced), so the
23
+ // GitLab-download + self-replace logic is NOT ported. Instead `update`
24
+ // is an npm-appropriate command:
25
+ // - `update --check` reports the current version and the latest version
26
+ // from the npm registry (best-effort; handles offline gracefully);
27
+ // - plain `update` prints guidance to run `npm install -g docgov@latest`.
28
+ // The `--version <tag>` flag is kept for compatibility (it is reported back
29
+ // in the install guidance). The asset-name mapping and replaceExecutable
30
+ // logic from update.go have no npm analogue and are omitted.
31
+ //
32
+ // 5. Shebang flag: the dedup index uses the built-in node:sqlite module, which
33
+ // is experimental and makes Node print an "ExperimentalWarning: SQLite ..."
34
+ // line to stderr on every invocation. node:sqlite is a STATIC import, so a
35
+ // userland process.emitWarning wrapper cannot suppress it — the builtin is
36
+ // initialized at module-link time, before any of our code runs. The shebang
37
+ // therefore launches node with `--disable-warning=ExperimentalWarning`
38
+ // (env -S splits the args) so the installed `docgov` runs clean. Requires
39
+ // Node >=22 (engines) where the flag exists.
40
+ import { fileURLToPath } from "node:url";
41
+ import { readFileSync, realpathSync } from "node:fs";
42
+ import * as path from "node:path";
43
+ import { Command, CommanderError } from "commander";
44
+ import { run as checkRun } from "../check/index.js";
45
+ import { createDefaultResolver } from "../codeq/index.js";
46
+ import { loadConfig } from "../config/index.js";
47
+ import { find } from "../repo/index.js";
48
+ import { text as reportText, json as reportJSON } from "../report/index.js";
49
+ import { Index, Analyze, Load } from "../dedup/index.js";
50
+ import { Render } from "../dedup/report/index.js";
51
+ // Exit-code convention (mirrors Go's main.go exactly):
52
+ //
53
+ // 0 — success
54
+ // 1 — check completed and found violations (or dedup found duplicates)
55
+ // 2 — usage or configuration error
56
+ const exitOK = 0;
57
+ const exitViolation = 1;
58
+ const exitUsage = 2;
59
+ // ViolationsError is the cmd-owned sentinel returned by the check action when a
60
+ // doc fails governance (violations found). It is the only path (alongside
61
+ // DuplicatesError) to exitViolation; every other error maps to exitUsage, so
62
+ // the engine never inherits the CLI framework's own exit codes. Analogue of
63
+ // Go's errViolations.
64
+ class ViolationsError extends Error {
65
+ constructor() {
66
+ super("violations found");
67
+ this.name = "ViolationsError";
68
+ }
69
+ }
70
+ // DuplicatesError is the cmd-owned sentinel returned by the dedup action when at
71
+ // least one HIGH-confidence duplicate group is found. Maps to exitViolation so
72
+ // CI can gate on duplicates found — parallel role to ViolationsError. Analogue
73
+ // of Go's errDuplicates.
74
+ class DuplicatesError extends Error {
75
+ constructor() {
76
+ super("duplicates found");
77
+ this.name = "DuplicatesError";
78
+ }
79
+ }
80
+ // readVersion reads the package version from package.json, walking up from this
81
+ // module's directory until a package.json is found (works both from src/ under
82
+ // tsx and from dist/ after a build). Falls back to Go's "dev" sentinel if it
83
+ // cannot be located — the same default Go used before ldflags stamped it.
84
+ export function readVersion() {
85
+ let dir = path.dirname(fileURLToPath(import.meta.url));
86
+ for (;;) {
87
+ const candidate = path.join(dir, "package.json");
88
+ try {
89
+ const raw = readFileSync(candidate, "utf8");
90
+ const pkg = JSON.parse(raw);
91
+ if (typeof pkg.version === "string" && pkg.version !== "") {
92
+ return pkg.version;
93
+ }
94
+ }
95
+ catch {
96
+ // not here; keep walking up
97
+ }
98
+ const parent = path.dirname(dir);
99
+ if (parent === dir) {
100
+ return "dev";
101
+ }
102
+ dir = parent;
103
+ }
104
+ }
105
+ // findRepo locates the repo root from CWD. It is the shared first step for every
106
+ // command that touches the filesystem (Go's findRepo).
107
+ async function findRepo() {
108
+ return find(process.cwd());
109
+ }
110
+ // runCheckAction implements the check command: load config, run check.Run, and
111
+ // emit the report in the chosen format. Throws ViolationsError when violations
112
+ // are found (mapped to exitViolation by run()); any other error maps to
113
+ // exitUsage. Mirrors Go's runCheck.
114
+ async function runCheckAction(format) {
115
+ switch (format) {
116
+ case "text":
117
+ case "json":
118
+ break;
119
+ default:
120
+ throw new Error(`check: unknown --format "${format}" (want text or json)`);
121
+ }
122
+ const r = await findRepo();
123
+ const cfg = await loadConfig(r.fs());
124
+ const resolver = await createDefaultResolver();
125
+ const vs = await checkRun(cfg, r, resolver);
126
+ // Emit the report.
127
+ if (format === "json") {
128
+ process.stdout.write(reportJSON(vs) + "\n");
129
+ }
130
+ else {
131
+ const out = reportText(vs);
132
+ if (out !== "") {
133
+ process.stdout.write(out);
134
+ }
135
+ }
136
+ if (vs.length > 0) {
137
+ throw new ViolationsError();
138
+ }
139
+ }
140
+ // runDedupAction implements "docgov dedup": find repo root → refresh the
141
+ // embedding index → analyze → render the report. The index is rebuilt
142
+ // automatically on every run, so there is no separate index step. Index
143
+ // progress goes to stderr; the report goes to stdout. Throws DuplicatesError
144
+ // when at least one HIGH-confidence group is found. Mirrors Go's runDedup.
145
+ async function runDedupAction() {
146
+ const r = await findRepo();
147
+ await Index(r.root(), (msg) => {
148
+ process.stderr.write(msg.endsWith("\n") ? msg : msg + "\n");
149
+ });
150
+ const rep = await Analyze(r.root());
151
+ // Load config separately to obtain ReportConfig for the renderer (matching
152
+ // Go's second cheap Load to avoid changing the Analyze signature).
153
+ let cfg;
154
+ try {
155
+ cfg = await Load(r.root());
156
+ }
157
+ catch (err) {
158
+ throw new Error(`dedup: load config for render: ${errMsg(err)}`);
159
+ }
160
+ process.stdout.write(Render(rep, cfg.Report));
161
+ if (rep.HighGroups.length > 0) {
162
+ throw new DuplicatesError();
163
+ }
164
+ }
165
+ // runUpdateAction implements the npm-appropriate update command (see the
166
+ // deviation note at the top of this file). With --check it reports the current
167
+ // and latest versions; otherwise it prints install guidance. `version` is the
168
+ // optional target tag, kept for compatibility with Go's --version flag.
169
+ async function runUpdateAction(opts) {
170
+ const current = readVersion();
171
+ if (opts.check) {
172
+ const latest = await fetchLatestVersion();
173
+ process.stdout.write(`current: ${current}\n`);
174
+ process.stdout.write(`latest: ${latest ?? "unknown (registry unreachable)"}\n`);
175
+ if (latest !== null && latest === current) {
176
+ process.stdout.write("already up to date\n");
177
+ }
178
+ return;
179
+ }
180
+ const target = opts.version !== undefined && opts.version !== ""
181
+ ? `docgov@${opts.version}`
182
+ : "docgov@latest";
183
+ process.stdout.write(`current: ${current}\n`);
184
+ process.stdout.write(`docgov is installed via npm; update it with:\n npm install -g ${target}\n`);
185
+ }
186
+ // fetchLatestVersion queries the npm registry for the latest published version.
187
+ // Best-effort: returns null on any network/parse failure (offline-safe) so the
188
+ // update command degrades gracefully rather than erroring out.
189
+ async function fetchLatestVersion() {
190
+ try {
191
+ const resp = await fetch("https://registry.npmjs.org/docgov/latest");
192
+ if (!resp.ok) {
193
+ return null;
194
+ }
195
+ const body = (await resp.json());
196
+ return typeof body.version === "string" ? body.version : null;
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ }
202
+ // newApp wires all commands to their engine entry points. Mirrors Go's newApp.
203
+ // Each action throws on failure; run() owns the 0/1/2 exit-code mapping.
204
+ export function newApp(version) {
205
+ const app = new Command();
206
+ app
207
+ .name("docgov")
208
+ .usage("[command]")
209
+ .description("config-driven documentation governance")
210
+ .version(version)
211
+ // exitOverride makes commander THROW a CommanderError instead of calling
212
+ // process.exit, so run() is the single owner of the exit-code convention.
213
+ .exitOverride()
214
+ // Suppress commander's own "(see --help)" suggestion noise on errors; run()
215
+ // reports usage errors via stderr "docgov: ...".
216
+ .showSuggestionAfterError(false);
217
+ app
218
+ .command("check")
219
+ .description("validate docs/ against their governance config")
220
+ .option("--format <format>", "report format: text (styled) or json", "text")
221
+ .action(async (opts) => {
222
+ await runCheckAction(opts.format);
223
+ });
224
+ app
225
+ .command("update")
226
+ .description("report the latest docgov release (install via npm)")
227
+ .option("--version <tag>", "report guidance for a specific tag (e.g. v1.2.3)")
228
+ .option("--check", "report current and latest versions without installing", false)
229
+ .action(async (opts) => {
230
+ await runUpdateAction({
231
+ check: opts.check === true,
232
+ version: opts.version,
233
+ });
234
+ });
235
+ app
236
+ .command("dedup")
237
+ .description("detect and report duplicate documentation concepts in docs/")
238
+ .action(async () => {
239
+ await runDedupAction();
240
+ });
241
+ return app;
242
+ }
243
+ // run builds the CLI, executes it, and maps the result to an exit code. The CLI
244
+ // framework neither prints generic usage dumps nor exits on its own (exitOverride
245
+ // + the catch below) — this function is the single owner of the 0/1/2 convention.
246
+ // Mirrors Go's run([]string) int.
247
+ export async function run(argv) {
248
+ const app = newApp(readVersion());
249
+ // No subcommand → show help and exit 0 (Go's "no args shows help" → exitOK).
250
+ if (argv.length === 0) {
251
+ app.outputHelp();
252
+ return exitOK;
253
+ }
254
+ try {
255
+ await app.parseAsync(argv, { from: "user" });
256
+ return exitOK;
257
+ }
258
+ catch (err) {
259
+ if (err instanceof ViolationsError || err instanceof DuplicatesError) {
260
+ return exitViolation;
261
+ }
262
+ if (err instanceof CommanderError) {
263
+ // Help/version display are successful terminations (Go: --help exits 0).
264
+ if (err.code === "commander.helpDisplayed" ||
265
+ err.code === "commander.help" ||
266
+ err.code === "commander.version") {
267
+ return exitOK;
268
+ }
269
+ // Every other framework error (unknown command/option, missing arg) is a
270
+ // usage error → exit 2. Commander already wrote the message to stderr.
271
+ return exitUsage;
272
+ }
273
+ // Engine/config/operational error → exit 2 with the Go-style prefix.
274
+ process.stderr.write(`docgov: ${errMsg(err)}\n`);
275
+ return exitUsage;
276
+ }
277
+ }
278
+ function errMsg(err) {
279
+ return err instanceof Error ? err.message : String(err);
280
+ }
281
+ // main is the process entrypoint: run() with process.argv (minus node + script)
282
+ // and exit with the mapped code. Mirrors Go's main(){ os.Exit(run(os.Args)) }.
283
+ // Only invoked when this module is the entry script, so tests can import run()
284
+ // without triggering process.exit.
285
+ async function main() {
286
+ const code = await run(process.argv.slice(2));
287
+ // Flush stdout/stderr before exiting. When stdout is a pipe or file (not a
288
+ // TTY), Node buffers writes asynchronously; calling process.exit() directly
289
+ // would terminate before the buffer drains and silently truncate the report
290
+ // (a piped/redirected `docgov check` would print nothing). Draining both
291
+ // streams first guarantees the output is delivered; process.exit then forces
292
+ // termination so lingering tree-sitter/onnx handles cannot keep the process
293
+ // alive (Go used os.Exit for the same immediacy).
294
+ await Promise.all([drainStream(process.stdout), drainStream(process.stderr)]);
295
+ process.exit(code);
296
+ }
297
+ // drainStream resolves once everything already written to the stream has been
298
+ // handed to the OS. Stream writes are ordered (FIFO), so the empty final chunk's
299
+ // callback fires only after every prior write has flushed.
300
+ function drainStream(stream) {
301
+ return new Promise((resolve) => {
302
+ stream.write("", () => resolve());
303
+ });
304
+ }
305
+ // Detect "run as the entry script" (NodeNext/ESM). When imported by tests,
306
+ // import.meta.url !== the entry file URL, so main() is not called.
307
+ //
308
+ // The entry (process.argv[1]) must be resolved through realpathSync before
309
+ // comparing: an npm-installed bin is reached via symlinks (bin/docgov →
310
+ // lib/node_modules/docgov → this file), so path.resolve alone leaves it as the
311
+ // symlink path and the comparison fails — which would make main() silently never
312
+ // run when invoked as `docgov`. import.meta.url is already a realpath (Node
313
+ // resolves module specifiers through realpath), so we realpath the entry to match.
314
+ const invokedDirectly = (() => {
315
+ const entry = process.argv[1];
316
+ if (entry === undefined) {
317
+ return false;
318
+ }
319
+ try {
320
+ return fileURLToPath(import.meta.url) === realpathSync(path.resolve(entry));
321
+ }
322
+ catch {
323
+ return false;
324
+ }
325
+ })();
326
+ if (invokedDirectly) {
327
+ void main();
328
+ }
329
+ // Re-exported for tests (exit-code/version/wiring assertions).
330
+ export { exitOK, exitViolation, exitUsage, ViolationsError, DuplicatesError };