svn-agent-mcp 1.0.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 (165) hide show
  1. package/.editorconfig +12 -0
  2. package/.gitattributes +7 -0
  3. package/.github/workflows/ci.yml +39 -0
  4. package/.svn-mcp-policy.json +9 -0
  5. package/CHANGELOG.md +248 -0
  6. package/CONTRIBUTING.md +49 -0
  7. package/LICENSE +201 -0
  8. package/README.md +143 -0
  9. package/SECURITY.md +21 -0
  10. package/THIRD_PARTY_CHECKSUMS.txt +52 -0
  11. package/THIRD_PARTY_NOTICES.md +46 -0
  12. package/bin/SlikSvn-DB44-20-x64.dll +0 -0
  13. package/bin/SlikSvn-libapr-1.dll +0 -0
  14. package/bin/SlikSvn-libaprutil-1.dll +0 -0
  15. package/bin/SlikSvn-libcrypto-3-x64.dll +0 -0
  16. package/bin/SlikSvn-libintl.dll +0 -0
  17. package/bin/SlikSvn-libsasl21.dll +0 -0
  18. package/bin/SlikSvn-libssl-3-x64.dll +0 -0
  19. package/bin/SlikSvn-libsvn_client-1.dll +0 -0
  20. package/bin/SlikSvn-libsvn_delta-1.dll +0 -0
  21. package/bin/SlikSvn-libsvn_diff-1.dll +0 -0
  22. package/bin/SlikSvn-libsvn_fs-1.dll +0 -0
  23. package/bin/SlikSvn-libsvn_fs_base-1.dll +0 -0
  24. package/bin/SlikSvn-libsvn_fs_fs-1.dll +0 -0
  25. package/bin/SlikSvn-libsvn_fs_util-1.dll +0 -0
  26. package/bin/SlikSvn-libsvn_fs_x-1.dll +0 -0
  27. package/bin/SlikSvn-libsvn_ra-1.dll +0 -0
  28. package/bin/SlikSvn-libsvn_repos-1.dll +0 -0
  29. package/bin/SlikSvn-libsvn_subr-1.dll +0 -0
  30. package/bin/SlikSvn-libsvn_wc-1.dll +0 -0
  31. package/bin/System64/concrt140.dll +0 -0
  32. package/bin/System64/msvcp140.dll +0 -0
  33. package/bin/System64/msvcp140_1.dll +0 -0
  34. package/bin/System64/msvcp140_2.dll +0 -0
  35. package/bin/System64/msvcp140_atomic_wait.dll +0 -0
  36. package/bin/System64/msvcp140_codecvt_ids.dll +0 -0
  37. package/bin/System64/vccorlib140.dll +0 -0
  38. package/bin/System64/vcruntime140.dll +0 -0
  39. package/bin/System64/vcruntime140_1.dll +0 -0
  40. package/bin/dos2unix.exe +0 -0
  41. package/bin/engines/capi.dll +0 -0
  42. package/bin/libsvnjavahl-1.dll +0 -0
  43. package/bin/mac2unix.exe +0 -0
  44. package/bin/svn-populate-node-origins-index.exe +0 -0
  45. package/bin/svn.exe +0 -0
  46. package/bin/svnadmin.exe +0 -0
  47. package/bin/svnauthz-validate.exe +0 -0
  48. package/bin/svnauthz.exe +0 -0
  49. package/bin/svnbench.exe +0 -0
  50. package/bin/svndumpfilter.exe +0 -0
  51. package/bin/svnfsfs.exe +0 -0
  52. package/bin/svnlook.exe +0 -0
  53. package/bin/svnmucc.exe +0 -0
  54. package/bin/svnrdump.exe +0 -0
  55. package/bin/svnserve.exe +0 -0
  56. package/bin/svnsync.exe +0 -0
  57. package/bin/svnversion.exe +0 -0
  58. package/bin/unix2dos.exe +0 -0
  59. package/bin/unix2mac.exe +0 -0
  60. package/dist/envelope.d.ts +39 -0
  61. package/dist/envelope.d.ts.map +1 -0
  62. package/dist/envelope.js +137 -0
  63. package/dist/envelope.js.map +1 -0
  64. package/dist/eol.d.ts +15 -0
  65. package/dist/eol.d.ts.map +1 -0
  66. package/dist/eol.js +119 -0
  67. package/dist/eol.js.map +1 -0
  68. package/dist/guards.d.ts +26 -0
  69. package/dist/guards.d.ts.map +1 -0
  70. package/dist/guards.js +306 -0
  71. package/dist/guards.js.map +1 -0
  72. package/dist/index.d.ts +8 -0
  73. package/dist/index.d.ts.map +1 -0
  74. package/dist/index.js +221 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/parse/commitText.d.ts +2 -0
  77. package/dist/parse/commitText.d.ts.map +1 -0
  78. package/dist/parse/commitText.js +9 -0
  79. package/dist/parse/commitText.js.map +1 -0
  80. package/dist/parse/diffText.d.ts +7 -0
  81. package/dist/parse/diffText.d.ts.map +1 -0
  82. package/dist/parse/diffText.js +55 -0
  83. package/dist/parse/diffText.js.map +1 -0
  84. package/dist/parse/infoXml.d.ts +3 -0
  85. package/dist/parse/infoXml.d.ts.map +1 -0
  86. package/dist/parse/infoXml.js +35 -0
  87. package/dist/parse/infoXml.js.map +1 -0
  88. package/dist/parse/logXml.d.ts +10 -0
  89. package/dist/parse/logXml.d.ts.map +1 -0
  90. package/dist/parse/logXml.js +39 -0
  91. package/dist/parse/logXml.js.map +1 -0
  92. package/dist/parse/statusXml.d.ts +6 -0
  93. package/dist/parse/statusXml.d.ts.map +1 -0
  94. package/dist/parse/statusXml.js +64 -0
  95. package/dist/parse/statusXml.js.map +1 -0
  96. package/dist/parse/updateText.d.ts +6 -0
  97. package/dist/parse/updateText.d.ts.map +1 -0
  98. package/dist/parse/updateText.js +67 -0
  99. package/dist/parse/updateText.js.map +1 -0
  100. package/dist/runner.d.ts +41 -0
  101. package/dist/runner.d.ts.map +1 -0
  102. package/dist/runner.js +270 -0
  103. package/dist/runner.js.map +1 -0
  104. package/dist/tools/composite.d.ts +15 -0
  105. package/dist/tools/composite.d.ts.map +1 -0
  106. package/dist/tools/composite.js +238 -0
  107. package/dist/tools/composite.js.map +1 -0
  108. package/dist/tools/diagnose.d.ts +6 -0
  109. package/dist/tools/diagnose.d.ts.map +1 -0
  110. package/dist/tools/diagnose.js +131 -0
  111. package/dist/tools/diagnose.js.map +1 -0
  112. package/dist/tools/mutating.d.ts +80 -0
  113. package/dist/tools/mutating.d.ts.map +1 -0
  114. package/dist/tools/mutating.js +558 -0
  115. package/dist/tools/mutating.js.map +1 -0
  116. package/dist/tools/readonly.d.ts +52 -0
  117. package/dist/tools/readonly.d.ts.map +1 -0
  118. package/dist/tools/readonly.js +638 -0
  119. package/dist/tools/readonly.js.map +1 -0
  120. package/dist/tools/selfcheck.d.ts +5 -0
  121. package/dist/tools/selfcheck.d.ts.map +1 -0
  122. package/dist/tools/selfcheck.js +91 -0
  123. package/dist/tools/selfcheck.js.map +1 -0
  124. package/dist/types.d.ts +66 -0
  125. package/dist/types.d.ts.map +1 -0
  126. package/dist/types.js +2 -0
  127. package/dist/types.js.map +1 -0
  128. package/docs/README.md +11 -0
  129. package/docs/SPEC.md +964 -0
  130. package/docs/decisions/ADR-001-use-spec-v1-architecture.md +25 -0
  131. package/docs/decisions/ADR-002-use-capability-based-generic-svn-policy.md +31 -0
  132. package/docs/decisions/ADR-003-use-plug-and-play-global-registration.md +39 -0
  133. package/docs/decisions/ADR-004-borrow-diagnostics-not-external-mcp-surface.md +39 -0
  134. package/docs/svnrules.md +84 -0
  135. package/docs/toolrules.md +34 -0
  136. package/jest.config.cjs +14 -0
  137. package/package.json +51 -0
  138. package/scripts/clean.mjs +27 -0
  139. package/scripts/prepare-release.mjs +84 -0
  140. package/src/envelope.ts +182 -0
  141. package/src/eol.ts +137 -0
  142. package/src/guards.ts +362 -0
  143. package/src/index.ts +364 -0
  144. package/src/parse/commitText.ts +9 -0
  145. package/src/parse/diffText.ts +66 -0
  146. package/src/parse/infoXml.ts +51 -0
  147. package/src/parse/logXml.ts +64 -0
  148. package/src/parse/statusXml.ts +87 -0
  149. package/src/parse/updateText.ts +76 -0
  150. package/src/runner.ts +344 -0
  151. package/src/tools/composite.ts +289 -0
  152. package/src/tools/diagnose.ts +150 -0
  153. package/src/tools/mutating.ts +694 -0
  154. package/src/tools/readonly.ts +786 -0
  155. package/src/tools/selfcheck.ts +97 -0
  156. package/src/types.ts +76 -0
  157. package/tests/README.md +9 -0
  158. package/tests/diff-redaction.test.ts +146 -0
  159. package/tests/entrypoint.test.ts +49 -0
  160. package/tests/guards-eol.test.ts +170 -0
  161. package/tests/integration-svn.test.ts +709 -0
  162. package/tests/parsers.test.ts +119 -0
  163. package/tests/runner.test.ts +159 -0
  164. package/tests/selfcheck.test.ts +22 -0
  165. package/tsconfig.json +30 -0
@@ -0,0 +1,97 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createEnvelope } from "../envelope.js";
5
+ import { startupProbe } from "../runner.js";
6
+ import type { ToolEnvelope } from "../types.js";
7
+
8
+ export async function svnSelfCheck(input: { cwd?: string }): Promise<ToolEnvelope> {
9
+ const root = await projectRoot();
10
+ const packageJson = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")) as {
11
+ version: string;
12
+ scripts?: Record<string, string>;
13
+ };
14
+ const currentPath = path.join(root, "current");
15
+ const currentTarget = await readLinkTarget(currentPath);
16
+ const currentRelease = currentTarget ? path.basename(currentTarget) : null;
17
+ const releaseRoot = path.join(root, "releases", `v${packageJson.version}`);
18
+ const probe = await startupProbe(input.cwd ?? root);
19
+
20
+ const binFileCount = await countFiles(path.join(releaseRoot, "bin"));
21
+ const distFileCount = await countFiles(path.join(releaseRoot, "dist"));
22
+ const releasePrepare = packageJson.scripts?.["release:prepare"] ?? "";
23
+ const clean = packageJson.scripts?.clean ?? "";
24
+ const currentMatchesPackage = currentRelease === `v${packageJson.version}`;
25
+ const toolchainOk = probe.svn.ok && probe.dos2unix.ok && probe.unix2dos.ok;
26
+ const notes = [
27
+ currentMatchesPackage ? "" : "current release pointer does not match package version",
28
+ toolchainOk ? "" : "bundled SVN/EOL toolchain probe failed"
29
+ ].filter(Boolean);
30
+
31
+ return {
32
+ ...createEnvelope({
33
+ ok: currentMatchesPackage && binFileCount > 0 && distFileCount > 0 && toolchainOk,
34
+ command: "svn_self_check",
35
+ cwd: root,
36
+ note: notes.join("; ")
37
+ }),
38
+ server_version: packageJson.version,
39
+ package_version: packageJson.version,
40
+ current_path: currentPath,
41
+ current_target: currentTarget,
42
+ current_release: currentRelease,
43
+ current_matches_package: currentMatchesPackage,
44
+ release_root: releaseRoot,
45
+ bin_file_count: binFileCount,
46
+ dist_file_count: distFileCount,
47
+ release_prepare_available: releasePrepare.includes("scripts/prepare-release.mjs"),
48
+ clean_uses_node: clean.startsWith("node "),
49
+ toolchain_ok: toolchainOk,
50
+ startup_probe: probe
51
+ };
52
+ }
53
+
54
+ async function projectRoot(): Promise<string> {
55
+ let current = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
56
+
57
+ while (true) {
58
+ const candidate = path.join(current, "package.json");
59
+ try {
60
+ await fs.access(candidate);
61
+ return current;
62
+ } catch {
63
+ const parent = path.dirname(current);
64
+ if (parent === current) {
65
+ throw new Error("could not locate package.json");
66
+ }
67
+ current = parent;
68
+ }
69
+ }
70
+ }
71
+
72
+ async function readLinkTarget(value: string): Promise<string | null> {
73
+ try {
74
+ const target = await fs.readlink(value);
75
+ return path.resolve(path.dirname(value), target);
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ async function countFiles(directory: string): Promise<number> {
82
+ try {
83
+ const entries = await fs.readdir(directory, { withFileTypes: true });
84
+ let count = 0;
85
+ for (const entry of entries) {
86
+ const child = path.join(directory, entry.name);
87
+ if (entry.isDirectory()) {
88
+ count += await countFiles(child);
89
+ } else if (entry.isFile()) {
90
+ count += 1;
91
+ }
92
+ }
93
+ return count;
94
+ } catch {
95
+ return 0;
96
+ }
97
+ }
package/src/types.ts ADDED
@@ -0,0 +1,76 @@
1
+ export type SvnStatusCode = "M" | "A" | "D" | "R" | "C" | "?" | "!" | "~" | "G" | "U" | "E";
2
+
3
+ export interface ChangedPath {
4
+ status: string;
5
+ path: string;
6
+ }
7
+
8
+ export interface Conflict {
9
+ path: string;
10
+ type: "text" | "tree" | "prop";
11
+ }
12
+
13
+ export interface Envelope {
14
+ [key: string]: unknown;
15
+ ok: boolean;
16
+ command: string;
17
+ cwd: string;
18
+ revision: number | null;
19
+ changed_paths: ChangedPath[];
20
+ conflicts: Conflict[];
21
+ stdout_summary: string;
22
+ stderr_summary: string;
23
+ truncated: boolean;
24
+ note: string;
25
+ }
26
+
27
+ export type ToolEnvelope = Envelope;
28
+
29
+ export interface WcInfo {
30
+ url: string | null;
31
+ repo_root: string | null;
32
+ wc_root: string | null;
33
+ revision: number | null;
34
+ }
35
+
36
+ export interface RunResult {
37
+ command: string;
38
+ cwd: string;
39
+ executable: string;
40
+ args: string[];
41
+ exitCode: number | null;
42
+ signal: NodeJS.Signals | null;
43
+ stdout: string;
44
+ stderr: string;
45
+ timedOut: boolean;
46
+ errorCode?: string | undefined;
47
+ truncated?: boolean | undefined;
48
+ }
49
+
50
+ export interface DiffFileSummary {
51
+ path: string;
52
+ added: number;
53
+ removed: number;
54
+ binary: boolean;
55
+ }
56
+
57
+ export interface DiffSummary {
58
+ per_file: DiffFileSummary[];
59
+ diff_excerpt: string;
60
+ truncated: boolean;
61
+ }
62
+
63
+ export type EolKind = "crlf" | "lf" | "mixed" | "none" | "binary" | "skipped-too-large" | "not-a-file";
64
+
65
+ export interface EolSniff {
66
+ path: string;
67
+ kind: EolKind;
68
+ has_bom: boolean;
69
+ size: number;
70
+ sniff: "ok" | "skipped-too-large" | "not-a-file";
71
+ }
72
+
73
+ export interface EolCheckResult extends EolSniff {
74
+ eol_style: string | null;
75
+ mismatch: boolean;
76
+ }
@@ -0,0 +1,9 @@
1
+ # Tests
2
+
3
+ Use unit tests for guards, parsers, envelopes, and startup probe behavior.
4
+
5
+ Use throwaway temp SVN repositories for integration tests that mutate SVN state. Never point mutating tests at a real project working copy.
6
+
7
+ Diagnostic coverage should prove both sides of `svn_diagnose`: healthy temp working copies and
8
+ structured failure envelopes for non-working-copy paths or classified SVN errors. Runtime smoke
9
+ checks against this MCP working copy must stay read-only.
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import { createEnvelope, noteFromRun, redactArgv, redactText } from "../src/envelope.js";
3
+ import { createDiffAccumulator, parseDiffText } from "../src/parse/diffText.js";
4
+ import type { RunResult } from "../src/types.js";
5
+
6
+ describe("diff parsing and command redaction", () => {
7
+ it("keeps complete per-file counts even when the excerpt is truncated", () => {
8
+ const diff = parseDiffText([
9
+ "Index: big.txt",
10
+ "===================================================================",
11
+ "--- big.txt",
12
+ "+++ big.txt",
13
+ "+one",
14
+ "+two",
15
+ "-old",
16
+ " context",
17
+ "+three"
18
+ ].join("\n"), 4);
19
+
20
+ expect(diff.truncated).toBe(true);
21
+ expect(diff.diff_excerpt.split("\n")).toHaveLength(4);
22
+ expect(diff.per_file).toEqual([{ path: "big.txt", added: 3, removed: 1, binary: false }]);
23
+ });
24
+
25
+ it("counts streamed diff lines after the excerpt cap", () => {
26
+ const diff = createDiffAccumulator(2);
27
+ for (const line of ["Index: streamed.txt", "+one", "+two", "-old", "+three"]) {
28
+ diff.pushLine(line);
29
+ }
30
+
31
+ expect(diff.summary()).toEqual({
32
+ per_file: [{ path: "streamed.txt", added: 3, removed: 1, binary: false }],
33
+ diff_excerpt: "Index: streamed.txt\n+one",
34
+ truncated: true
35
+ });
36
+ });
37
+
38
+ it("redacts URL userinfo and sensitive query parameters", () => {
39
+ const command = redactArgv("svn", [
40
+ "import",
41
+ "src",
42
+ "https://user:secret@example.com/repo?token=abc123&apikey=def456&x=ok"
43
+ ]);
44
+
45
+ expect(command).toContain("https://***:***@example.com/repo?token=***&apikey=***&x=ok");
46
+ expect(command).not.toContain("secret");
47
+ expect(command).not.toContain("abc123");
48
+ expect(command).not.toContain("def456");
49
+ });
50
+
51
+ it("redacts inline credential flags and malformed URL userinfo fragments", () => {
52
+ const command = redactArgv("svn", [
53
+ "--password=secret",
54
+ "--username=bob",
55
+ "info",
56
+ "https://user:p@ss@example.com/repo?token=abc123"
57
+ ]);
58
+
59
+ expect(command).toContain("--password=***");
60
+ expect(command).toContain("--username=***");
61
+ expect(command).toContain("https://***:***@example.com/repo?token=***");
62
+ expect(command).not.toContain("secret");
63
+ expect(command).not.toContain("bob");
64
+ expect(command).not.toContain("p@ss");
65
+ expect(command).not.toContain("abc123");
66
+ });
67
+
68
+ it("redacts URL secrets from stdout and stderr summaries", () => {
69
+ const envelope = createEnvelope({
70
+ ok: false,
71
+ command: "svn info",
72
+ cwd: process.cwd(),
73
+ stdout: "checking https://user:secret@example.com/repo?token=abc123",
74
+ stderr: "failed https://user:secret@example.com/repo?apikey=def456"
75
+ });
76
+
77
+ expect(envelope.stdout_summary).toContain("https://***:***@example.com/repo?token=***");
78
+ expect(envelope.stderr_summary).toContain("https://***:***@example.com/repo?apikey=***");
79
+ expect(envelope.stdout_summary).not.toContain("secret");
80
+ expect(envelope.stdout_summary).not.toContain("abc123");
81
+ expect(envelope.stderr_summary).not.toContain("secret");
82
+ expect(envelope.stderr_summary).not.toContain("def456");
83
+ });
84
+
85
+ it("redacts reusable text fields such as diff excerpts", () => {
86
+ const redacted = redactText("+https://user:secret@example.com/repo?token=abc123&apikey=def456\n");
87
+
88
+ expect(redacted).toContain("https://***:***@example.com/repo?token=***&apikey=***");
89
+ expect(redacted).not.toContain("secret");
90
+ expect(redacted).not.toContain("abc123");
91
+ expect(redacted).not.toContain("def456");
92
+ });
93
+
94
+ it("does not treat user source lines containing binary words as SVN binary markers", () => {
95
+ const diff = parseDiffText([
96
+ "Index: src/a.ts",
97
+ "===================================================================",
98
+ "--- src/a.ts",
99
+ "+++ src/a.ts",
100
+ "@@ -1 +1,2 @@",
101
+ "+const isBinary = true;",
102
+ "+console.log(\"Cannot display in UI\");"
103
+ ].join("\n"), 100);
104
+
105
+ expect(diff.per_file).toEqual([{ path: "src/a.ts", added: 2, removed: 0, binary: false }]);
106
+ });
107
+
108
+ it("marks exact SVN binary diff messages as binary", () => {
109
+ const diff = parseDiffText([
110
+ "Index: image.bin",
111
+ "===================================================================",
112
+ "Cannot display: file marked as a binary type."
113
+ ].join("\n"), 100);
114
+
115
+ expect(diff.per_file).toEqual([{ path: "image.bin", added: 0, removed: 0, binary: true }]);
116
+ });
117
+
118
+ it("categorizes common SVN auth, network, lock, and database failures", () => {
119
+ expect(noteFromRun(fakeRun("svn: E215004: No more credentials or we tried too many times"))).toContain("authentication failed");
120
+ expect(noteFromRun(fakeRun("svn: E175002: Unable to connect to a repository"))).toContain("network");
121
+ expect(noteFromRun(fakeRun("svn: E155036: working copy locked"))).toContain("svn_cleanup");
122
+ expect(noteFromRun(fakeRun("svn: E200030: sqlite[S5]: database is locked"))).toContain("database problem");
123
+ });
124
+
125
+ it("points agents at the CLI failsafe when the svn executable cannot launch", () => {
126
+ const note = noteFromRun({ ...fakeRun(""), errorCode: "ENOENT" });
127
+ expect(note).toContain("MCP svn runtime unavailable");
128
+ expect(note).toContain("failsafe");
129
+ expect(note).toContain("must never be bypassed");
130
+ expect(noteFromRun(fakeRun("svn: E170001: authorization failed"))).not.toContain("failsafe");
131
+ });
132
+ });
133
+
134
+ function fakeRun(stderr: string): RunResult {
135
+ return {
136
+ command: "svn",
137
+ cwd: process.cwd(),
138
+ executable: "svn",
139
+ args: [],
140
+ exitCode: 1,
141
+ signal: null,
142
+ stdout: "",
143
+ stderr,
144
+ timedOut: false
145
+ };
146
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import { spawn } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ const distIndex = path.resolve("dist", "index.js");
8
+
9
+ describe("server entrypoint launch detection", () => {
10
+ it("starts the server when launched through a directory junction", async () => {
11
+ if (!fs.existsSync(distIndex)) {
12
+ // dist/ is produced by `npm run build`; without it there is nothing to launch.
13
+ return;
14
+ }
15
+
16
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "svn-agent-entry-"));
17
+ const junction = path.join(tmp, "current");
18
+ try {
19
+ fs.symlinkSync(path.resolve("dist"), junction, "junction");
20
+ const banner = await startupBanner(path.join(junction, "index.js"));
21
+ expect(banner).toContain("running on stdio");
22
+ } finally {
23
+ fs.rmSync(tmp, { recursive: true, force: true });
24
+ }
25
+ }, 20000);
26
+ });
27
+
28
+ function startupBanner(entry: string): Promise<string> {
29
+ return new Promise((resolve) => {
30
+ const child = spawn(process.execPath, [entry], { stdio: ["pipe", "pipe", "pipe"] });
31
+ let stderr = "";
32
+ const finish = () => {
33
+ child.kill();
34
+ resolve(stderr);
35
+ };
36
+ const timer = setTimeout(finish, 15000);
37
+ child.stderr.on("data", (chunk: Buffer) => {
38
+ stderr += chunk.toString();
39
+ if (stderr.includes("running on stdio")) {
40
+ clearTimeout(timer);
41
+ finish();
42
+ }
43
+ });
44
+ child.on("exit", () => {
45
+ clearTimeout(timer);
46
+ resolve(stderr);
47
+ });
48
+ });
49
+ }
@@ -0,0 +1,170 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { messageFormatWarning, neverCommitHit, readonlyMode, resolveTargetsInsideWc, riskySignals } from "../src/guards.js";
6
+ import { sniffEol } from "../src/eol.js";
7
+
8
+ describe("guards and EOL sniffing", () => {
9
+ it("detects never-commit paths and risky slices", () => {
10
+ const root = path.resolve("C:/repo");
11
+ expect(neverCommitHit(path.join(root, "bin", "app.dll"), root)).toBe("**/bin/**");
12
+ expect(neverCommitHit(path.join(root, "src", "App", "bin", "Debug", "app.dll"), root)).toBe("**/bin/**");
13
+ expect(neverCommitHit(path.join(root, "src", "App", "obj", "Debug", "app.dll"), root)).toBe("**/obj/**");
14
+ expect(neverCommitHit(path.join(root, "dist", "index.js"), root)).toBe("**/dist/**");
15
+ expect(neverCommitHit(path.join(root, "src", "dist", "bundle.js"), root)).toBe("**/dist/**");
16
+ expect(neverCommitHit(path.join(root, "node_modules", "pkg", "index.js"), root)).toBe("**/node_modules/**");
17
+ expect(neverCommitHit(path.join(root, "coverage", "lcov.info"), root)).toBe("**/coverage/**");
18
+ expect(neverCommitHit(path.join(root, "scratch", "notes.txt"), root)).toBe("scratch/**");
19
+ expect(neverCommitHit(path.join(root, "tsconfig.tsbuildinfo"), root)).toBe("**/*.tsbuildinfo");
20
+ expect(neverCommitHit(path.join(root, "binary.txt"), root)).toBeNull();
21
+ expect(neverCommitHit(path.join(root, "distillery", "notes.txt"), root)).toBeNull();
22
+ expect(neverCommitHit(path.join(root, "src", "app.ts"), root)).toBeNull();
23
+
24
+ const paths = Array.from({ length: 9 }, (_, index) => path.join(root, "src", `f${index}.ts`));
25
+ expect(riskySignals(paths, root)).toContain("more than 8 paths");
26
+ expect(riskySignals([path.join(root, "app.csproj")], root)).toContain("build-system file touched");
27
+ });
28
+
29
+ it("allows repository-local never-commit exceptions without weakening nested defaults", () => {
30
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "svn-agent-policy-"));
31
+ try {
32
+ fs.writeFileSync(
33
+ path.join(root, ".svn-mcp-policy.json"),
34
+ JSON.stringify({
35
+ neverCommit: {
36
+ allow: ["bin/**", "releases/v*/bin/**", "releases/v*/dist/**"]
37
+ }
38
+ }),
39
+ "utf8"
40
+ );
41
+
42
+ expect(neverCommitHit(path.join(root, "bin", "svn.exe"), root)).toBeNull();
43
+ expect(neverCommitHit(path.join(root, "releases", "v1.2.3", "dist", "index.js"), root)).toBeNull();
44
+ expect(neverCommitHit(path.join(root, "src", "App", "bin", "Debug", "app.dll"), root)).toBe("**/bin/**");
45
+ } finally {
46
+ fs.rmSync(root, { recursive: true, force: true });
47
+ }
48
+ });
49
+
50
+ it("keeps repository-local deny rules stronger than broad allow exceptions", () => {
51
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "svn-agent-policy-deny-"));
52
+ try {
53
+ fs.writeFileSync(
54
+ path.join(root, ".svn-mcp-policy.json"),
55
+ JSON.stringify({
56
+ neverCommit: {
57
+ allow: ["releases/**"],
58
+ deny: ["releases/**/secrets/**"]
59
+ }
60
+ }),
61
+ "utf8"
62
+ );
63
+
64
+ expect(neverCommitHit(path.join(root, "releases", "v1.2.3", "dist", "index.js"), root)).toBeNull();
65
+ expect(neverCommitHit(path.join(root, "releases", "v1.2.3", "dist", "secrets", "key.pem"), root)).toBe("releases/**/secrets/**");
66
+ } finally {
67
+ fs.rmSync(root, { recursive: true, force: true });
68
+ }
69
+ });
70
+
71
+ it("reports invalid and unsafe repository-local policies as policy errors", () => {
72
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "svn-agent-policy-invalid-"));
73
+ try {
74
+ fs.writeFileSync(path.join(root, ".svn-mcp-policy.json"), "{not json", "utf8");
75
+ expect(neverCommitHit(path.join(root, "src", "app.ts"), root)).toBe("policy-error: invalid .svn-mcp-policy.json");
76
+
77
+ fs.writeFileSync(
78
+ path.join(root, ".svn-mcp-policy.json"),
79
+ JSON.stringify({ neverCommit: { deny: ["**/**/**/**/**/**/**/**/**/**/*.ts"] } }),
80
+ "utf8"
81
+ );
82
+ expect(neverCommitHit(path.join(root, "src", "app.ts"), root)).toContain("policy-error:");
83
+
84
+ fs.writeFileSync(
85
+ path.join(root, ".svn-mcp-policy.json"),
86
+ JSON.stringify({ neverCommit: { deny: ["*a*a*a*a*a*a*a*a*aZ"] } }),
87
+ "utf8"
88
+ );
89
+ expect(neverCommitHit(path.join(root, "src", "app.ts"), root)).toContain("policy-error:");
90
+ } finally {
91
+ fs.rmSync(root, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ it("warns on non-template commit messages", () => {
96
+ expect(messageFormatWarning("Short\n\n- verification")).toBeNull();
97
+ expect(messageFormatWarning("Short only")).toBe("commit message format warning");
98
+ });
99
+
100
+ it("returns realpath-expanded paths for SVN command targets", () => {
101
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "svn-agent-realpath-"));
102
+ try {
103
+ fs.mkdirSync(path.join(root, "nested"));
104
+ const cwd = process.platform === "win32" ? root.toUpperCase() : root;
105
+ const resolved = resolveTargetsInsideWc(cwd, cwd, ["nested/new-file.txt"]);
106
+
107
+ expect(resolved.ok).toBe(true);
108
+ if (resolved.ok) {
109
+ expect(resolved.paths).toEqual([
110
+ path.join(fs.realpathSync.native(root), "nested", "new-file.txt")
111
+ ]);
112
+ }
113
+ } finally {
114
+ fs.rmSync(root, { recursive: true, force: true });
115
+ }
116
+ });
117
+
118
+ it("enables readonly mode from a CLI flag without end-user environment variables", () => {
119
+ const savedArgv = [...process.argv];
120
+ const savedReadonly = process.env.SVN_AGENT_READONLY;
121
+ delete process.env.SVN_AGENT_READONLY;
122
+
123
+ try {
124
+ expect(readonlyMode()).toBe(false);
125
+ process.argv.push("--readonly");
126
+ expect(readonlyMode()).toBe(true);
127
+ } finally {
128
+ process.argv.splice(0, process.argv.length, ...savedArgv);
129
+ if (savedReadonly === undefined) {
130
+ delete process.env.SVN_AGENT_READONLY;
131
+ } else {
132
+ process.env.SVN_AGENT_READONLY = savedReadonly;
133
+ }
134
+ }
135
+ });
136
+
137
+ it("classifies CRLF, LF, mixed, BOM, and binary files", async () => {
138
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "svn-agent-eol-"));
139
+ try {
140
+ const crlf = path.join(dir, "crlf.txt");
141
+ const lf = path.join(dir, "lf.txt");
142
+ const mixed = path.join(dir, "mixed.txt");
143
+ const bom = path.join(dir, "bom.txt");
144
+ const binary = path.join(dir, "binary.bin");
145
+
146
+ fs.writeFileSync(crlf, "a\r\nb\r\n", "utf8");
147
+ fs.writeFileSync(lf, "a\nb\n", "utf8");
148
+ fs.writeFileSync(mixed, "a\r\nb\n", "utf8");
149
+ fs.writeFileSync(bom, Buffer.from([0xef, 0xbb, 0xbf, 0x61, 0x0d, 0x0a]));
150
+ fs.writeFileSync(binary, Buffer.from([0x61, 0x00, 0x62]));
151
+
152
+ await expect(sniffEol(crlf)).resolves.toMatchObject({ kind: "crlf" });
153
+ await expect(sniffEol(lf)).resolves.toMatchObject({ kind: "lf" });
154
+ await expect(sniffEol(mixed)).resolves.toMatchObject({ kind: "mixed" });
155
+ await expect(sniffEol(bom)).resolves.toMatchObject({ has_bom: true });
156
+ await expect(sniffEol(binary)).resolves.toMatchObject({ kind: "binary" });
157
+ } finally {
158
+ fs.rmSync(dir, { recursive: true, force: true });
159
+ }
160
+ });
161
+
162
+ it("returns a structured not-a-file sniff for directories instead of throwing", async () => {
163
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "svn-agent-eol-dir-"));
164
+ try {
165
+ await expect(sniffEol(dir)).resolves.toMatchObject({ kind: "not-a-file", sniff: "not-a-file" });
166
+ } finally {
167
+ fs.rmSync(dir, { recursive: true, force: true });
168
+ }
169
+ });
170
+ });