skyloom 1.17.0 → 1.18.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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Code search — a dependency-light, cross-platform engine for "find where X is
3
+ * used and read it in context". Backs the code_search tool and is the fallback
4
+ * for grep when ripgrep/grep aren't installed (common on Windows), so search
5
+ * never silently returns nothing just because a binary is missing.
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { globSync } from 'glob';
11
+
12
+ export interface SearchOptions {
13
+ pattern: string;
14
+ /** Root directory to search (default: cwd). */
15
+ root?: string;
16
+ /** Glob to restrict files (e.g. "**\/*.ts"). Default: all files. */
17
+ glob?: string;
18
+ /** Case-insensitive match (default false). */
19
+ ignoreCase?: boolean;
20
+ /** Treat pattern as a regular expression (default true). */
21
+ regex?: boolean;
22
+ /** Lines of context around each match (default 0). */
23
+ context?: number;
24
+ /** Cap on total matches returned (default 200). */
25
+ maxResults?: number;
26
+ /** Skip files larger than this many bytes (default 2 MiB). */
27
+ maxFileBytes?: number;
28
+ }
29
+
30
+ export interface SearchMatch {
31
+ file: string; // relative to root
32
+ line: number; // 1-based
33
+ text: string;
34
+ before?: string[];
35
+ after?: string[];
36
+ }
37
+
38
+ export interface SearchResult {
39
+ matches: SearchMatch[];
40
+ filesScanned: number;
41
+ truncated: boolean;
42
+ error?: string;
43
+ }
44
+
45
+ /** Directories never worth searching — vendored, generated, or VCS internals. */
46
+ const DEFAULT_IGNORES = [
47
+ '**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**',
48
+ '**/coverage/**', '**/.next/**', '**/out/**', '**/.cache/**',
49
+ '**/vendor/**', '**/.venv/**', '**/__pycache__/**',
50
+ ];
51
+
52
+ function looksBinary(buf: Buffer): boolean {
53
+ const n = Math.min(buf.length, 8000);
54
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true; // NUL byte ⇒ binary
55
+ return false;
56
+ }
57
+
58
+ /** Pure-JS recursive code search. No external process required. */
59
+ export function searchCode(opts: SearchOptions): SearchResult {
60
+ const root = path.resolve(opts.root || process.cwd());
61
+ const maxResults = opts.maxResults ?? 200;
62
+ const maxFileBytes = opts.maxFileBytes ?? 2 * 1024 * 1024;
63
+ const context = Math.max(0, opts.context ?? 0);
64
+
65
+ let matcher: (line: string) => boolean;
66
+ if (opts.regex === false) {
67
+ const needle = opts.ignoreCase ? opts.pattern.toLowerCase() : opts.pattern;
68
+ matcher = (line) => (opts.ignoreCase ? line.toLowerCase() : line).includes(needle);
69
+ } else {
70
+ let re: RegExp;
71
+ try {
72
+ re = new RegExp(opts.pattern, opts.ignoreCase ? 'i' : '');
73
+ } catch (e) {
74
+ return { matches: [], filesScanned: 0, truncated: false, error: `invalid regex: ${e}` };
75
+ }
76
+ matcher = (line) => re.test(line);
77
+ }
78
+
79
+ let files: string[];
80
+ try {
81
+ files = globSync(opts.glob || '**/*', {
82
+ cwd: root, nodir: true, dot: false, ignore: DEFAULT_IGNORES,
83
+ });
84
+ } catch (e) {
85
+ return { matches: [], filesScanned: 0, truncated: false, error: `glob failed: ${e}` };
86
+ }
87
+
88
+ const matches: SearchMatch[] = [];
89
+ let filesScanned = 0;
90
+ let truncated = false;
91
+
92
+ for (const rel of files) {
93
+ if (matches.length >= maxResults) { truncated = true; break; }
94
+ const abs = path.join(root, rel);
95
+ let buf: Buffer;
96
+ try {
97
+ const stat = fs.statSync(abs);
98
+ if (stat.size > maxFileBytes) continue;
99
+ buf = fs.readFileSync(abs);
100
+ } catch { continue; }
101
+ if (looksBinary(buf)) continue;
102
+
103
+ filesScanned++;
104
+ const lines = buf.toString('utf8').split('\n');
105
+ for (let i = 0; i < lines.length; i++) {
106
+ if (!matcher(lines[i])) continue;
107
+ const m: SearchMatch = { file: rel.split(path.sep).join('/'), line: i + 1, text: lines[i] };
108
+ if (context > 0) {
109
+ m.before = lines.slice(Math.max(0, i - context), i);
110
+ m.after = lines.slice(i + 1, i + 1 + context);
111
+ }
112
+ matches.push(m);
113
+ if (matches.length >= maxResults) { truncated = true; break; }
114
+ }
115
+ }
116
+
117
+ return { matches, filesScanned, truncated };
118
+ }
119
+
120
+ /** Render a SearchResult as ripgrep-style `file:line:text` (with context). */
121
+ export function formatSearchResult(res: SearchResult): string {
122
+ if (res.error) return `Search error: ${res.error}`;
123
+ if (res.matches.length === 0) return 'No matches found.';
124
+ const out: string[] = [];
125
+ let lastFile = '';
126
+ for (const m of res.matches) {
127
+ if (m.file !== lastFile) { if (out.length) out.push(''); lastFile = m.file; }
128
+ for (let k = 0; k < (m.before?.length || 0); k++) {
129
+ out.push(`${m.file}:${m.line - (m.before!.length - k)}- ${m.before![k]}`);
130
+ }
131
+ out.push(`${m.file}:${m.line}: ${m.text}`);
132
+ for (let k = 0; k < (m.after?.length || 0); k++) {
133
+ out.push(`${m.file}:${m.line + k + 1}- ${m.after![k]}`);
134
+ }
135
+ }
136
+ if (res.truncated) out.push(`\n…[results truncated at ${res.matches.length} matches — narrow the pattern or glob]`);
137
+ return out.join('\n');
138
+ }
@@ -77,6 +77,7 @@ const TOOL_DANGER_MAP: Record<string, DangerLevel> = {
77
77
 
78
78
  write_file: DangerLevel.LOW,
79
79
  edit_file: DangerLevel.LOW,
80
+ apply_patch: DangerLevel.LOW,
80
81
  copy_file: DangerLevel.LOW,
81
82
  move_file: DangerLevel.LOW,
82
83
  make_directory: DangerLevel.LOW,
@@ -164,7 +165,7 @@ export const PERMISSION_MODE_ALIASES: Record<string, ApprovalMode> = {
164
165
  };
165
166
 
166
167
  /** Tools that mutate the filesystem — the ones acceptEdits waves through. */
167
- const EDIT_TOOL_RE = /^(write_|edit_|append_|replace_|create_|make_|copy_|move_|delete_)/;
168
+ const EDIT_TOOL_RE = /^(write_|edit_|append_|replace_|create_|make_|copy_|move_|delete_)|^apply_patch$/;
168
169
  export function isEditTool(toolName: string): boolean { return EDIT_TOOL_RE.test(toolName); }
169
170
 
170
171
  /**
package/src/core/tool.ts CHANGED
@@ -46,6 +46,14 @@ export interface ToolDefinition {
46
46
  */
47
47
  idempotent?: boolean;
48
48
  timeout?: number;
49
+ /**
50
+ * Optional output guard: inspect the handler's result and return an error
51
+ * message if it's not valid (else null/undefined). A non-null return makes
52
+ * the call fail — routed through the same retry + circuit-breaker path as a
53
+ * thrown error — so a tool/plugin can reject malformed output instead of
54
+ * passing garbage back to the model as "success".
55
+ */
56
+ validateOutput?: (result: string, params: Record<string, unknown>) => string | null | undefined;
49
57
  }
50
58
 
51
59
  /** Order-stable JSON key so {a,b} and {b,a} hash to the same cache/dedup key. */
@@ -430,6 +438,13 @@ export class ToolRegistry extends EventEmitter {
430
438
  if (timer) clearTimeout(timer);
431
439
  }
432
440
 
441
+ // Output guard: a non-null message means the result is invalid. Throw so
442
+ // it flows through the same retry + breaker path as any other failure.
443
+ if (tool.validateOutput) {
444
+ const outErr = tool.validateOutput(result, params);
445
+ if (outErr) throw new Error(`invalid tool output: ${outErr}`);
446
+ }
447
+
433
448
  const duration = Date.now() - startTime;
434
449
 
435
450
  // Cache result
@@ -12,6 +12,8 @@ import { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck } from './guards
12
12
  import { webSearch, formatSearchResults, readPage } from './websearch';
13
13
  import { countOccurrences, unifiedDiff } from '../core/diff';
14
14
  import { getDiagnostics, formatDiagnostics } from '../core/diagnostics';
15
+ import { searchCode, formatSearchResult } from '../core/search';
16
+ import { applyPatch } from '../core/patch';
15
17
 
16
18
  // Re-exported so existing importers/tests keep resolving these from builtin.
17
19
  export { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck };
@@ -142,6 +144,30 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
142
144
  },
143
145
  });
144
146
 
147
+ registry.register({
148
+ name: 'apply_patch',
149
+ description: 'Apply an atomic, multi-file edit in one call — ideal for larger refactors touching several places/files. The whole patch is validated before anything is written, so a bad block aborts cleanly with no half-applied changes. Each SEARCH must match the file exactly and uniquely. Format:\n*** Update File: path\n<<<<<<< SEARCH\nold exact text\n=======\nnew text\n>>>>>>> REPLACE\n(repeat blocks; also *** Add File: path / full content, and *** Delete File: path)',
150
+ parameters: [
151
+ { name: 'patch', type: 'string', description: 'The patch text in the *** Update/Add/Delete File + SEARCH/REPLACE format', required: true },
152
+ ],
153
+ handler: async (params) => {
154
+ let snapshot: ((abs: string) => void) | undefined;
155
+ try {
156
+ const { getFileCheckpoints } = require('../core/file_checkpoint');
157
+ const cp = getFileCheckpoints();
158
+ snapshot = (abs: string) => { try { cp.snapshot(abs); } catch { /* best-effort */ } };
159
+ } catch { /* checkpointing optional */ }
160
+ try {
161
+ return applyPatch(String(params.patch || ''), {
162
+ fenceCheck: (abs: string) => fenceCheck(abs),
163
+ snapshot,
164
+ });
165
+ } catch (e) {
166
+ return `Error applying patch: ${e}`;
167
+ }
168
+ },
169
+ });
170
+
145
171
  registry.register({
146
172
  name: 'delete_file',
147
173
  description: 'Delete a file at the given path.',
@@ -470,10 +496,39 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
470
496
 
471
497
  // ── Utility Tools ──
472
498
 
499
+ registry.register({
500
+ name: 'code_search',
501
+ idempotent: true,
502
+ description: 'Search source code for a regex pattern across files. Returns file:line matches with optional surrounding context. Use this to find where a symbol/string is defined or used. Restrict scope with glob (e.g. "**/*.ts") and add context lines to read around hits.',
503
+ parameters: [
504
+ { name: 'pattern', type: 'string', description: 'Regex (or literal if regex=false) to search for', required: true },
505
+ { name: 'path', type: 'string', description: 'Root directory to search (default: cwd)', required: false },
506
+ { name: 'glob', type: 'string', description: 'Restrict to files matching this glob, e.g. "**/*.ts"', required: false },
507
+ { name: 'context', type: 'number', description: 'Lines of context around each match (default 0)', required: false },
508
+ { name: 'ignore_case', type: 'boolean', description: 'Case-insensitive match (default false)', required: false },
509
+ { name: 'regex', type: 'boolean', description: 'Treat pattern as regex (default true; false = literal substring)', required: false },
510
+ { name: 'max_results', type: 'number', description: 'Max matches to return (default 200)', required: false },
511
+ ],
512
+ handler: async (params) => {
513
+ const root = params.path ? path.resolve(params.path as string) : process.cwd();
514
+ const fenced = fenceCheck(root); if (fenced) return fenced;
515
+ const res = searchCode({
516
+ pattern: String(params.pattern || ''),
517
+ root,
518
+ glob: params.glob ? String(params.glob) : undefined,
519
+ context: params.context != null ? Number(params.context) : 0,
520
+ ignoreCase: params.ignore_case === true,
521
+ regex: params.regex !== false,
522
+ maxResults: params.max_results != null ? Number(params.max_results) : 200,
523
+ });
524
+ return formatSearchResult(res);
525
+ },
526
+ });
527
+
473
528
  registry.register({
474
529
  name: 'grep',
475
530
  idempotent: true,
476
- description: 'Search for a pattern in files using ripgrep or grep.',
531
+ description: 'Search for a regex pattern in files using ripgrep/grep, with a built-in fallback when neither is installed. For richer control (glob, context, ignore-case) prefer code_search.',
477
532
  parameters: [
478
533
  { name: 'pattern', type: 'string', description: 'Regex pattern to search for', required: true },
479
534
  { name: 'path', type: 'string', description: 'Directory to search in', required: false },
@@ -496,12 +551,13 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
496
551
  const out = execFileSync(bin, args, { encoding: 'utf-8', maxBuffer: 1024 * 1024 });
497
552
  return out || 'No matches found.';
498
553
  } catch (e: any) {
499
- // exit status 1 = ran successfully, zero matches; anything else
500
- // (e.g. binary not installed) falls through to the next variant.
554
+ // exit status 1 = ran successfully, zero matches. Any other failure
555
+ // (e.g. binary not installed) falls through to the next variant, then
556
+ // to the pure-JS engine so search works even with no rg/grep.
501
557
  if (e?.status === 1) return 'No matches found.';
502
558
  }
503
559
  }
504
- return 'No matches found.';
560
+ return formatSearchResult(searchCode({ pattern: pat, root: searchDir, maxResults: 200 }));
505
561
  },
506
562
  });
507
563
 
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { parsePatch, applyPatch } from "../src/core/patch";
6
+
7
+ describe("patch · parsePatch", () => {
8
+ it("parses update / add / delete operations", () => {
9
+ const text = [
10
+ "*** Begin Patch",
11
+ "*** Update File: a.ts",
12
+ "<<<<<<< SEARCH",
13
+ "const x = 1;",
14
+ "=======",
15
+ "const x = 2;",
16
+ ">>>>>>> REPLACE",
17
+ "*** Add File: b.ts",
18
+ "export const y = 3;",
19
+ "*** Delete File: c.ts",
20
+ "*** End Patch",
21
+ ].join("\n");
22
+ const r = parsePatch(text) as any;
23
+ expect(r.error).toBeUndefined();
24
+ expect(r.ops).toHaveLength(3);
25
+ expect(r.ops[0]).toMatchObject({ op: "update", path: "a.ts" });
26
+ expect(r.ops[0].blocks[0]).toEqual({ search: "const x = 1;", replace: "const x = 2;" });
27
+ expect(r.ops[1]).toMatchObject({ op: "add", path: "b.ts", content: "export const y = 3;\n" });
28
+ expect(r.ops[2]).toMatchObject({ op: "delete", path: "c.ts" });
29
+ });
30
+
31
+ it("errors on an unterminated SEARCH", () => {
32
+ const text = "*** Update File: a.ts\n<<<<<<< SEARCH\nfoo\n";
33
+ expect((parsePatch(text) as any).error).toContain("Unterminated SEARCH");
34
+ });
35
+
36
+ it("errors on stray content outside a file section", () => {
37
+ expect((parsePatch("hello world") as any).error).toContain("Unexpected line");
38
+ });
39
+ });
40
+
41
+ describe("patch · applyPatch (atomic, multi-file)", () => {
42
+ let dir: string;
43
+ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-patch-")); });
44
+ afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
45
+
46
+ function write(name: string, content: string) {
47
+ const p = path.join(dir, name);
48
+ fs.mkdirSync(path.dirname(p), { recursive: true });
49
+ fs.writeFileSync(p, content);
50
+ }
51
+ const read = (name: string) => fs.readFileSync(path.join(dir, name), "utf8");
52
+
53
+ it("applies update + add + delete in one shot", () => {
54
+ write("a.ts", "const x = 1;\nkeep me\n");
55
+ write("c.ts", "delete me\n");
56
+ const patch = [
57
+ "*** Update File: a.ts",
58
+ "<<<<<<< SEARCH",
59
+ "const x = 1;",
60
+ "=======",
61
+ "const x = 42;",
62
+ ">>>>>>> REPLACE",
63
+ "*** Add File: sub/b.ts",
64
+ "export const y = 3;",
65
+ "*** Delete File: c.ts",
66
+ ].join("\n");
67
+ const out = applyPatch(patch, { cwd: dir });
68
+ expect(out).toContain("Applied patch");
69
+ expect(read("a.ts")).toBe("const x = 42;\nkeep me\n");
70
+ expect(read("sub/b.ts")).toBe("export const y = 3;\n");
71
+ expect(fs.existsSync(path.join(dir, "c.ts"))).toBe(false);
72
+ });
73
+
74
+ it("applies multiple blocks to one file", () => {
75
+ write("m.ts", "alpha\nbeta\ngamma\n");
76
+ const patch = [
77
+ "*** Update File: m.ts",
78
+ "<<<<<<< SEARCH", "alpha", "=======", "ALPHA", ">>>>>>> REPLACE",
79
+ "<<<<<<< SEARCH", "gamma", "=======", "GAMMA", ">>>>>>> REPLACE",
80
+ ].join("\n");
81
+ applyPatch(patch, { cwd: dir });
82
+ expect(read("m.ts")).toBe("ALPHA\nbeta\nGAMMA\n");
83
+ });
84
+
85
+ it("is atomic: a failing block leaves ALL files untouched", () => {
86
+ write("a.ts", "good\n");
87
+ write("b.ts", "target\n");
88
+ const patch = [
89
+ "*** Update File: a.ts",
90
+ "<<<<<<< SEARCH", "good", "=======", "changed", ">>>>>>> REPLACE",
91
+ "*** Update File: b.ts",
92
+ "<<<<<<< SEARCH", "NOT THERE", "=======", "x", ">>>>>>> REPLACE",
93
+ ].join("\n");
94
+ const out = applyPatch(patch, { cwd: dir });
95
+ expect(out).toContain("SEARCH block not found");
96
+ expect(read("a.ts")).toBe("good\n"); // first file NOT written
97
+ expect(read("b.ts")).toBe("target\n");
98
+ });
99
+
100
+ it("rejects an ambiguous SEARCH block", () => {
101
+ write("d.ts", "dup\ndup\n");
102
+ const patch = ["*** Update File: d.ts", "<<<<<<< SEARCH", "dup", "=======", "x", ">>>>>>> REPLACE"].join("\n");
103
+ expect(applyPatch(patch, { cwd: dir })).toContain("ambiguous");
104
+ });
105
+
106
+ it("refuses to Add over an existing file", () => {
107
+ write("exists.ts", "already\n");
108
+ const patch = "*** Add File: exists.ts\nnew content\n";
109
+ const out = applyPatch(patch, { cwd: dir });
110
+ expect(out).toContain("already exists");
111
+ expect(read("exists.ts")).toBe("already\n");
112
+ });
113
+
114
+ it("treats $-patterns in replacement literally", () => {
115
+ write("e.ts", "VAL\n");
116
+ const patch = ["*** Update File: e.ts", "<<<<<<< SEARCH", "VAL", "=======", "$1$&x", ">>>>>>> REPLACE"].join("\n");
117
+ applyPatch(patch, { cwd: dir });
118
+ expect(read("e.ts")).toBe("$1$&x\n");
119
+ });
120
+
121
+ it("honors the fence check by aborting", () => {
122
+ write("a.ts", "good\n");
123
+ const patch = ["*** Update File: a.ts", "<<<<<<< SEARCH", "good", "=======", "bad", ">>>>>>> REPLACE"].join("\n");
124
+ const out = applyPatch(patch, { cwd: dir, fenceCheck: () => "路径越界" });
125
+ expect(out).toContain("路径越界");
126
+ expect(read("a.ts")).toBe("good\n");
127
+ });
128
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { engineeringProtocol } from "../src/core/protocol";
3
+
4
+ describe("protocol · engineeringProtocol", () => {
5
+ it("zh protocol covers the senior-engineer discipline and names real tools", () => {
6
+ const p = engineeringProtocol("zh");
7
+ expect(p).toContain("工程标准");
8
+ expect(p).toContain("根因"); // root-cause, not symptom
9
+ expect(p).toContain("最小"); // minimal diffs
10
+ expect(p).toContain("先理解"); // understand before changing
11
+ expect(p).toContain("get_diagnostics"); // verify loop wired to real tool
12
+ expect(p).toContain("code_search");
13
+ });
14
+
15
+ it("en protocol mirrors the zh one", () => {
16
+ const p = engineeringProtocol("en");
17
+ expect(p).toContain("Engineering Standard");
18
+ expect(p).toContain("Root cause");
19
+ expect(p).toContain("Minimal");
20
+ expect(p).toContain("get_diagnostics");
21
+ expect(p).toContain("code_search");
22
+ });
23
+
24
+ it("defaults to zh", () => {
25
+ expect(engineeringProtocol()).toBe(engineeringProtocol("zh"));
26
+ });
27
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { searchCode, formatSearchResult } from "../src/core/search";
6
+
7
+ describe("search · searchCode (pure JS)", () => {
8
+ let root: string;
9
+ beforeEach(() => {
10
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "sky-search-"));
11
+ fs.writeFileSync(path.join(root, "a.ts"), "const Foo = 1;\nexport function useFoo() { return Foo; }\n");
12
+ fs.writeFileSync(path.join(root, "b.js"), "// foo lower\nconst x = 2;\n");
13
+ fs.mkdirSync(path.join(root, "sub"));
14
+ fs.writeFileSync(path.join(root, "sub", "c.ts"), "import { useFoo } from '../a';\n");
15
+ // should be ignored by default
16
+ fs.mkdirSync(path.join(root, "node_modules", "dep"), { recursive: true });
17
+ fs.writeFileSync(path.join(root, "node_modules", "dep", "x.ts"), "const Foo = 999;\n");
18
+ });
19
+ afterEach(() => { try { fs.rmSync(root, { recursive: true, force: true }); } catch {} });
20
+
21
+ it("finds matches with file:line", () => {
22
+ const res = searchCode({ pattern: "useFoo", root });
23
+ const files = res.matches.map((m) => m.file).sort();
24
+ expect(files).toContain("a.ts");
25
+ expect(files).toContain("sub/c.ts");
26
+ const a = res.matches.find((m) => m.file === "a.ts")!;
27
+ expect(a.line).toBe(2);
28
+ expect(a.text).toContain("useFoo");
29
+ });
30
+
31
+ it("skips node_modules by default", () => {
32
+ const res = searchCode({ pattern: "Foo", root });
33
+ expect(res.matches.some((m) => m.file.includes("node_modules"))).toBe(false);
34
+ });
35
+
36
+ it("restricts by glob", () => {
37
+ const res = searchCode({ pattern: "foo", root, glob: "**/*.ts", ignoreCase: true });
38
+ expect(res.matches.some((m) => m.file === "b.js")).toBe(false);
39
+ expect(res.matches.some((m) => m.file === "a.ts")).toBe(true);
40
+ });
41
+
42
+ it("honors ignoreCase", () => {
43
+ // b.js contains lowercase "foo"; capital "Foo" only matches case-insensitively.
44
+ expect(searchCode({ pattern: "Foo", root, glob: "b.js" }).matches.length).toBe(0);
45
+ expect(searchCode({ pattern: "Foo", root, glob: "b.js", ignoreCase: true }).matches.length).toBe(1);
46
+ });
47
+
48
+ it("returns context lines", () => {
49
+ const res = searchCode({ pattern: "useFoo", root, glob: "a.ts", context: 1 });
50
+ const m = res.matches[0];
51
+ expect(m.before).toEqual(["const Foo = 1;"]);
52
+ });
53
+
54
+ it("treats pattern as literal when regex=false", () => {
55
+ fs.writeFileSync(path.join(root, "d.ts"), "a.b.c\n");
56
+ const asRegex = searchCode({ pattern: "a.b", root, glob: "d.ts" }); // '.' = any char
57
+ const literal = searchCode({ pattern: "a.b", root, glob: "d.ts", regex: false });
58
+ expect(asRegex.matches.length).toBe(1);
59
+ expect(literal.matches.length).toBe(1);
60
+ const noLit = searchCode({ pattern: "axb", root, glob: "d.ts", regex: false });
61
+ expect(noLit.matches.length).toBe(0);
62
+ });
63
+
64
+ it("caps results and flags truncation", () => {
65
+ fs.writeFileSync(path.join(root, "many.ts"), Array.from({ length: 50 }, () => "hit").join("\n"));
66
+ const res = searchCode({ pattern: "hit", root, glob: "many.ts", maxResults: 10 });
67
+ expect(res.matches.length).toBe(10);
68
+ expect(res.truncated).toBe(true);
69
+ });
70
+
71
+ it("reports an invalid regex instead of throwing", () => {
72
+ const res = searchCode({ pattern: "(", root });
73
+ expect(res.error).toContain("invalid regex");
74
+ });
75
+ });
76
+
77
+ describe("search · formatSearchResult", () => {
78
+ it("renders file:line and a no-match message", () => {
79
+ expect(formatSearchResult({ matches: [], filesScanned: 3, truncated: false })).toBe("No matches found.");
80
+ const s = formatSearchResult({
81
+ matches: [{ file: "a.ts", line: 2, text: " return Foo;" }],
82
+ filesScanned: 1, truncated: false,
83
+ });
84
+ expect(s).toContain("a.ts:2:");
85
+ expect(s).toContain("return Foo");
86
+ });
87
+ });
@@ -16,6 +16,7 @@ function makeTool(overrides: Partial<ToolDefinition> & { name: string }): ToolDe
16
16
  maxRetries: overrides.maxRetries,
17
17
  retryDelay: overrides.retryDelay,
18
18
  timeout: overrides.timeout,
19
+ validateOutput: overrides.validateOutput,
19
20
  };
20
21
  }
21
22
 
@@ -184,6 +185,49 @@ describe('ToolRegistry · input validation + coercion', () => {
184
185
  });
185
186
  });
186
187
 
188
+ describe('ToolRegistry · output validation', () => {
189
+ let registry: ToolRegistry;
190
+ beforeEach(() => { registry = new ToolRegistry(); });
191
+
192
+ it('fails the call when validateOutput rejects the result', async () => {
193
+ registry.register(makeTool({
194
+ name: 'guarded',
195
+ maxRetries: 0,
196
+ handler: async () => 'garbage',
197
+ validateOutput: (r) => (r === 'garbage' ? 'looks like garbage' : null),
198
+ }));
199
+ const res = await registry.execute('guarded', {});
200
+ expect(res.success).toBe(false);
201
+ expect(res.error).toContain('invalid tool output');
202
+ expect(res.error).toContain('looks like garbage');
203
+ });
204
+
205
+ it('passes when validateOutput accepts the result', async () => {
206
+ registry.register(makeTool({
207
+ name: 'ok',
208
+ handler: async () => 'fine',
209
+ validateOutput: () => null,
210
+ }));
211
+ const res = await registry.execute('ok', {});
212
+ expect(res.success).toBe(true);
213
+ expect(res.result).toBe('fine');
214
+ });
215
+
216
+ it('retries a rejected output through the normal retry path', async () => {
217
+ let n = 0;
218
+ registry.register(makeTool({
219
+ name: 'retryout',
220
+ maxRetries: 1,
221
+ retryDelay: 0,
222
+ handler: async () => `v${++n}`,
223
+ validateOutput: (r) => (r === 'v1' ? 'first is bad' : null),
224
+ }));
225
+ const res = await registry.execute('retryout', {});
226
+ expect(res.success).toBe(true);
227
+ expect(res.result).toBe('v2');
228
+ });
229
+ });
230
+
187
231
  describe('stableStringify', () => {
188
232
  it('produces an order-independent key for objects', async () => {
189
233
  const { stableStringify } = await import('../src/core/tool');