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,125 @@
|
|
|
1
|
+
import * as nodefs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
import { find } from "./repo.js";
|
|
5
|
+
import { makeDir } from "./testutil.js";
|
|
6
|
+
// WHY these tests matter: repo is the SOLE owner of OS path handling and all OS
|
|
7
|
+
// writes. Any path-handling bug here (slash->OS mapping, escaping the root,
|
|
8
|
+
// missing parent dirs) corrupts the user's repository. These verify the OS
|
|
9
|
+
// boundary is honoured structurally, not just by convention.
|
|
10
|
+
const enc = new TextEncoder();
|
|
11
|
+
const dec = new TextDecoder();
|
|
12
|
+
async function statSafe(p) {
|
|
13
|
+
try {
|
|
14
|
+
await nodefs.stat(p);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
describe("write capability", () => {
|
|
22
|
+
// WHY: gen writes to nested paths; without parent creation every subdirectory
|
|
23
|
+
// write fails and gen can never produce a multi-file tree.
|
|
24
|
+
test("writeFile creates all missing parent directories", async () => {
|
|
25
|
+
const root = await makeDir(".docgov");
|
|
26
|
+
const r = await find(root);
|
|
27
|
+
const content = enc.encode("hello\n");
|
|
28
|
+
await r.writeFile(".claude/skills/write-docs/adr/adr.md", content);
|
|
29
|
+
const got = await nodefs.readFile(path.join(root, ".claude", "skills", "write-docs", "adr", "adr.md"));
|
|
30
|
+
expect(dec.decode(got)).toBe("hello\n");
|
|
31
|
+
});
|
|
32
|
+
// WHY: a slash path must map onto the OS separator so files land at the right
|
|
33
|
+
// location on every platform.
|
|
34
|
+
test("writeFile maps a slash path to the nested OS path", async () => {
|
|
35
|
+
const root = await makeDir(".docgov");
|
|
36
|
+
const r = await find(root);
|
|
37
|
+
await r.writeFile("subdir/file.txt", enc.encode("data\n"));
|
|
38
|
+
const got = await nodefs.readFile(path.join(root, "subdir", "file.txt"));
|
|
39
|
+
expect(dec.decode(got)).toBe("data\n");
|
|
40
|
+
});
|
|
41
|
+
// WHY: allowing ".." traversal would let gen overwrite arbitrary files on the
|
|
42
|
+
// user's system — a security and correctness boundary.
|
|
43
|
+
test("writeFile refuses a path that escapes the repo root", async () => {
|
|
44
|
+
const root = await makeDir(".docgov");
|
|
45
|
+
const r = await find(root);
|
|
46
|
+
await expect(r.writeFile("../escape.txt", enc.encode("should not be written"))).rejects.toThrow();
|
|
47
|
+
});
|
|
48
|
+
// WHY: write-with-backup renames the existing skill dir to <skill>.bak; if
|
|
49
|
+
// renameDir is broken the backup protocol can't protect the user's tree.
|
|
50
|
+
test("renameDir moves a directory and its contents", async () => {
|
|
51
|
+
const root = await makeDir(".docgov", ".claude/skills/write-docs");
|
|
52
|
+
await nodefs.writeFile(path.join(root, ".claude", "skills", "write-docs", "sentinel.txt"), "x");
|
|
53
|
+
const r = await find(root);
|
|
54
|
+
await r.renameDir(".claude/skills/write-docs", ".claude/skills/write-docs.bak");
|
|
55
|
+
expect(await statSafe(path.join(root, ".claude", "skills", "write-docs"))).toBe(false);
|
|
56
|
+
expect(await statSafe(path.join(root, ".claude", "skills", "write-docs.bak", "sentinel.txt"))).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
// WHY: rollback deletes the partial skill dir before restoring the backup; an
|
|
59
|
+
// incomplete removeAll leaves partial state in the user's repository.
|
|
60
|
+
test("removeAll removes a directory tree", async () => {
|
|
61
|
+
const root = await makeDir(".docgov", ".claude/skills/partial/nested");
|
|
62
|
+
await nodefs.writeFile(path.join(root, ".claude", "skills", "partial", "nested", "f.txt"), "y");
|
|
63
|
+
const r = await find(root);
|
|
64
|
+
await r.removeAll(".claude/skills/partial");
|
|
65
|
+
expect(await statSafe(path.join(root, ".claude", "skills", "partial"))).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
// WHY: stale-bak cleanup calls removeAll unconditionally on first run when no
|
|
68
|
+
// .bak exists — it must silently succeed.
|
|
69
|
+
test("removeAll on a nonexistent path is a no-op", async () => {
|
|
70
|
+
const root = await makeDir(".docgov");
|
|
71
|
+
const r = await find(root);
|
|
72
|
+
await expect(r.removeAll(".claude/skills/does-not-exist.bak")).resolves.toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
// WHY: write-with-backup must know whether the skill dir exists before
|
|
75
|
+
// deciding to back up; a wrong answer skips a needed backup or crashes a
|
|
76
|
+
// rename of a nonexistent dir.
|
|
77
|
+
test("dirExists reports true for an existing dir and false otherwise", async () => {
|
|
78
|
+
const root = await makeDir(".docgov", ".claude/skills/write-docs");
|
|
79
|
+
const r = await find(root);
|
|
80
|
+
expect(await r.dirExists(".claude/skills/write-docs")).toBe(true);
|
|
81
|
+
expect(await r.dirExists(".claude/skills/no-such-dir")).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
// WHY: the --flip pipeline relies on the two-phase commit to keep on-disk state
|
|
85
|
+
// binary (fully pre-flip or fully post-flip-and-verified). A rollback that
|
|
86
|
+
// mutates the original or leaves a stray temp file breaks that invariant.
|
|
87
|
+
describe("atomicWriteFile two-phase commit", () => {
|
|
88
|
+
test("commit(false) rolls back leaving the original untouched and no temp", async () => {
|
|
89
|
+
const root = await makeDir(".docgov", "docs");
|
|
90
|
+
const r = await find(root);
|
|
91
|
+
const target = "docs/a.md";
|
|
92
|
+
await r.writeFile(target, enc.encode("ORIGINAL\n"));
|
|
93
|
+
const pw = await r.atomicWriteFile(target, enc.encode("NEW\n"));
|
|
94
|
+
await pw.commit(false);
|
|
95
|
+
expect(dec.decode(await r.readFile(target))).toBe("ORIGINAL\n");
|
|
96
|
+
expect(await statSafe(path.join(root, "docs", "a.md.docgov-flip.tmp"))).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
test("commit(true) atomically renames the temp into place", async () => {
|
|
99
|
+
const root = await makeDir(".docgov", "docs");
|
|
100
|
+
const r = await find(root);
|
|
101
|
+
const target = "docs/a.md";
|
|
102
|
+
await r.writeFile(target, enc.encode("ORIGINAL\n"));
|
|
103
|
+
const pw = await r.atomicWriteFile(target, enc.encode("NEW2\n"));
|
|
104
|
+
await pw.commit(true);
|
|
105
|
+
expect(dec.decode(await r.readFile(target))).toBe("NEW2\n");
|
|
106
|
+
});
|
|
107
|
+
// WHY: the temp must be a SIBLING of the target so the rename stays atomic on
|
|
108
|
+
// one filesystem. If it ever moved to the OS temp dir (a separate filesystem
|
|
109
|
+
// on many hosts), the rename would fall back to copy+remove, observable
|
|
110
|
+
// mid-flight. The real temp path comes from PendingWrite, so this genuinely
|
|
111
|
+
// fails if the impl changes to use the OS temp dir.
|
|
112
|
+
test("the temp file is a sibling of the target, not in the OS temp dir", async () => {
|
|
113
|
+
const root = await makeDir(".docgov", "docs");
|
|
114
|
+
const r = await find(root);
|
|
115
|
+
const target = "docs/a.md";
|
|
116
|
+
await r.writeFile(target, enc.encode("ORIGINAL\n"));
|
|
117
|
+
const pw = await r.atomicWriteFile(target, enc.encode("PROBE\n"));
|
|
118
|
+
const actualTmpPath = pw.tmpAbsForTest();
|
|
119
|
+
const targetDir = path.join(root, "docs");
|
|
120
|
+
expect(path.dirname(actualTmpPath)).toBe(targetDir);
|
|
121
|
+
// The temp file must exist on disk before commit is called.
|
|
122
|
+
expect(await statSafe(path.join(targetDir, "a.md.docgov-flip.tmp"))).toBe(true);
|
|
123
|
+
await pw.commit(false); // rollback
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Color-enable detection and span styling for the text report.
|
|
2
|
+
//
|
|
3
|
+
// WHY this exists: the Go report styles its text output with charmbracelet/
|
|
4
|
+
// lipgloss, whose default renderer decides whether to emit ANSI escapes from
|
|
5
|
+
// termenv's EnvColorProfile (os.Stdout). When stdout is NOT a color-capable TTY
|
|
6
|
+
// — piped output, `go test`, the golden-test oracle's captured stdout — the
|
|
7
|
+
// profile is Ascii and lipgloss renders the spans as PLAIN TEXT (no escapes).
|
|
8
|
+
// That plain path is the byte-checked surface, so this module replicates Go's
|
|
9
|
+
// color-enable gate exactly: on the no-color path the style helpers return the
|
|
10
|
+
// input unchanged, byte-for-byte identical to lipgloss's Ascii output.
|
|
11
|
+
//
|
|
12
|
+
// The TTY (color-on) path cannot be made byte-identical to lipgloss — lipgloss
|
|
13
|
+
// emits its own escape sequences (and even wraps each rune individually for an
|
|
14
|
+
// underlined style). We approximate it with plain ANSI when color is enabled;
|
|
15
|
+
// see DEVIATIONS in this file. The oracle never exercises the color-on path.
|
|
16
|
+
import pc from "picocolors";
|
|
17
|
+
// colorEnabled mirrors termenv.EnvColorProfile() reduced to "is the profile
|
|
18
|
+
// better than Ascii?". The precedence below matches termenv:
|
|
19
|
+
//
|
|
20
|
+
// 1. NO_COLOR (any non-empty value) → no color. (no-color.org)
|
|
21
|
+
// 2. CLICOLOR_FORCE (non-empty, != "0") → force color on, even off a TTY.
|
|
22
|
+
// 3. CLICOLOR == "0" (without a force) → no color.
|
|
23
|
+
// 4. otherwise: color only if stdout is a TTY whose TERM is not "" or "dumb".
|
|
24
|
+
//
|
|
25
|
+
// Determined once at module load, like lipgloss's sync.Once-cached profile.
|
|
26
|
+
function detectColorEnabled() {
|
|
27
|
+
const env = process.env;
|
|
28
|
+
// 1. NO_COLOR wins outright (termenv.EnvNoColor checks NO_COLOR first).
|
|
29
|
+
if (env["NO_COLOR"] !== undefined && env["NO_COLOR"] !== "") {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
// 2. CLICOLOR_FORCE bumps even a non-TTY up out of Ascii.
|
|
33
|
+
const force = env["CLICOLOR_FORCE"];
|
|
34
|
+
const forced = force !== undefined && force !== "" && force !== "0";
|
|
35
|
+
if (forced) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
// 3. CLICOLOR == "0" (no force) disables color.
|
|
39
|
+
if (env["CLICOLOR"] === "0") {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
// 4. TTY + non-dumb TERM. Off a TTY (pipe, file, test capture) → Ascii,
|
|
43
|
+
// which is the byte-checked plain path.
|
|
44
|
+
const term = env["TERM"];
|
|
45
|
+
const isDumb = term === undefined || term === "" || term === "dumb";
|
|
46
|
+
const isTTY = process.stdout !== undefined && process.stdout.isTTY === true;
|
|
47
|
+
return isTTY && !isDumb;
|
|
48
|
+
}
|
|
49
|
+
// colorEnabled is the cached decision. Exported so tests can assert the gate.
|
|
50
|
+
export const colorEnabled = detectColorEnabled();
|
|
51
|
+
// A picocolors instance bound to colorEnabled so its formatters honor our gate
|
|
52
|
+
// (picocolors' own auto-detection differs from termenv: it also turns color on
|
|
53
|
+
// for win32/CI, which would diverge from Go). createColors(false) makes every
|
|
54
|
+
// formatter the identity function — exactly lipgloss's Ascii behavior.
|
|
55
|
+
const colors = pc.createColors(colorEnabled);
|
|
56
|
+
// fileStyle styles the file-path header: lipgloss Bold(true).Underline(true).
|
|
57
|
+
// On the no-color path this returns s unchanged (byte-identical to Go).
|
|
58
|
+
//
|
|
59
|
+
// DEVIATION (color-on path only): lipgloss renders Bold+Underline per-rune as
|
|
60
|
+
// `\x1b[1;4;4m<rune>\x1b[0m` for each rune; this emits picocolors' single
|
|
61
|
+
// `\x1b[1m\x1b[4m…\x1b[24m\x1b[22m` wrap. Not byte-equal to lipgloss when a TTY
|
|
62
|
+
// is attached. The oracle captures non-TTY stdout, so it never sees this path.
|
|
63
|
+
export function fileStyle(s) {
|
|
64
|
+
return colors.bold(colors.underline(s));
|
|
65
|
+
}
|
|
66
|
+
// ruleStyle styles the rule identifier: lipgloss Italic(true).
|
|
67
|
+
// On the no-color path this returns s unchanged (byte-identical to Go).
|
|
68
|
+
//
|
|
69
|
+
// DEVIATION (color-on path only): lipgloss emits `\x1b[3m…\x1b[0m`; picocolors
|
|
70
|
+
// emits `\x1b[3m…\x1b[23m`. Differs only in the reset code, and only on a TTY.
|
|
71
|
+
export function ruleStyle(s) {
|
|
72
|
+
return colors.italic(s);
|
|
73
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { text, json } from "./report.js";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Renders a list of violation records as a human-readable text report (styled
|
|
2
|
+
// like Go's lipgloss output) or as a JSON array for machine consumption.
|
|
3
|
+
//
|
|
4
|
+
// Port of internal/report/report.go. Determinism: violations are sorted by file
|
|
5
|
+
// path with a STABLE sort, so within each file they keep their input order
|
|
6
|
+
// (the document-position order emitted by the check orchestration).
|
|
7
|
+
import { fileStyle, ruleStyle } from "./color.js";
|
|
8
|
+
// text renders vs as a human-readable, styled text report grouped by file.
|
|
9
|
+
// Violations within each file are printed in document-position order. Returns an
|
|
10
|
+
// empty string when vs is empty.
|
|
11
|
+
export function text(vs) {
|
|
12
|
+
if (vs.length === 0) {
|
|
13
|
+
return "";
|
|
14
|
+
}
|
|
15
|
+
const sorted = sortedByFile(vs);
|
|
16
|
+
let out = "";
|
|
17
|
+
// Group by file and render each group.
|
|
18
|
+
let i = 0;
|
|
19
|
+
while (i < sorted.length) {
|
|
20
|
+
const first = sorted[i];
|
|
21
|
+
// first is defined: i < sorted.length and noUncheckedIndexedAccess guard.
|
|
22
|
+
if (first === undefined) {
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
const file = first.file;
|
|
26
|
+
// File header.
|
|
27
|
+
out += fileStyle(file);
|
|
28
|
+
out += "\n";
|
|
29
|
+
// All violations for this file.
|
|
30
|
+
for (; i < sorted.length; i++) {
|
|
31
|
+
const v = sorted[i];
|
|
32
|
+
if (v === undefined || v.file !== file) {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
out += " ";
|
|
36
|
+
if (v.line > 0) {
|
|
37
|
+
out += `line ${v.line} `;
|
|
38
|
+
}
|
|
39
|
+
out += ruleStyle(v.rule);
|
|
40
|
+
if (v.sectionID !== "") {
|
|
41
|
+
out += ` [${v.sectionID}]`;
|
|
42
|
+
}
|
|
43
|
+
if (v.expected !== "") {
|
|
44
|
+
out += ` expected: ${v.expected}`;
|
|
45
|
+
}
|
|
46
|
+
if (v.actual !== "") {
|
|
47
|
+
out += ` actual: ${v.actual}`;
|
|
48
|
+
}
|
|
49
|
+
if (v.message !== "") {
|
|
50
|
+
out += " ";
|
|
51
|
+
out += v.message;
|
|
52
|
+
}
|
|
53
|
+
out += "\n";
|
|
54
|
+
}
|
|
55
|
+
out += "\n";
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
// json renders vs as a JSON array of violation records. Returns "[]" when vs is
|
|
60
|
+
// empty. Field order is fixed (file, line, section_id, rule, expected, actual,
|
|
61
|
+
// message) — matching Go's struct declaration order — and the bytes match Go's
|
|
62
|
+
// encoding/json: compact (no spaces), with HTML-special characters escaped to
|
|
63
|
+
// \uXXXX exactly as Go's encoder does by default.
|
|
64
|
+
export function json(vs) {
|
|
65
|
+
if (vs.length === 0) {
|
|
66
|
+
return "[]";
|
|
67
|
+
}
|
|
68
|
+
const sorted = sortedByFile(vs);
|
|
69
|
+
// Build plain objects with the exact key order Go's jsonRecord struct emits.
|
|
70
|
+
const records = sorted.map((v) => ({
|
|
71
|
+
file: v.file,
|
|
72
|
+
line: v.line,
|
|
73
|
+
section_id: v.sectionID,
|
|
74
|
+
rule: v.rule,
|
|
75
|
+
expected: v.expected,
|
|
76
|
+
actual: v.actual,
|
|
77
|
+
message: v.message,
|
|
78
|
+
}));
|
|
79
|
+
return goJSONStringify(records);
|
|
80
|
+
}
|
|
81
|
+
// goJSONStringify serializes value the way Go's encoding/json.Marshal does:
|
|
82
|
+
// JSON.stringify produces the same compact form and key order (insertion
|
|
83
|
+
// order), but Go additionally HTML-escapes <, >, & and the line/paragraph
|
|
84
|
+
// separators U+2028/U+2029 to their \uXXXX forms. Replicating that escaping is
|
|
85
|
+
// required for byte-for-byte oracle equality when a violation field carries one
|
|
86
|
+
// of those characters.
|
|
87
|
+
function goJSONStringify(value) {
|
|
88
|
+
const s = JSON.stringify(value);
|
|
89
|
+
return s
|
|
90
|
+
.replace(/</g, "\\u003c")
|
|
91
|
+
.replace(/>/g, "\\u003e")
|
|
92
|
+
.replace(/&/g, "\\u0026")
|
|
93
|
+
.replace(/\u2028/g, "\\u2028")
|
|
94
|
+
.replace(/\u2029/g, "\\u2029");
|
|
95
|
+
}
|
|
96
|
+
// sortedByFile returns a stable-sorted copy of vs, sorted by file path. Within
|
|
97
|
+
// each file, violations preserve their original order (document position).
|
|
98
|
+
// JS's Array.prototype.sort is stable (spec since ES2019), matching Go's
|
|
99
|
+
// sort.SliceStable.
|
|
100
|
+
function sortedByFile(vs) {
|
|
101
|
+
const out = vs.slice();
|
|
102
|
+
out.sort((a, b) => {
|
|
103
|
+
if (a.file < b.file) {
|
|
104
|
+
return -1;
|
|
105
|
+
}
|
|
106
|
+
if (a.file > b.file) {
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
return 0;
|
|
110
|
+
});
|
|
111
|
+
return out;
|
|
112
|
+
}
|