@oh-my-pi/pi-coding-agent 15.10.4 → 15.10.6
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/CHANGELOG.md +74 -0
- package/dist/types/capability/rule-buckets.d.ts +1 -1
- package/dist/types/capability/rule.d.ts +6 -1
- package/dist/types/cli/update-cli.d.ts +11 -1
- package/dist/types/config/model-registry.d.ts +18 -1
- package/dist/types/discovery/at-imports.d.ts +15 -0
- package/dist/types/edit/diff.d.ts +3 -2
- package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +1 -0
- package/dist/types/eval/backend.d.ts +7 -0
- package/dist/types/eval/js/context-manager.d.ts +1 -0
- package/dist/types/eval/js/executor.d.ts +2 -0
- package/dist/types/eval/js/index.d.ts +1 -1
- package/dist/types/eval/js/shared/helpers.d.ts +6 -0
- package/dist/types/eval/js/shared/runtime.d.ts +5 -0
- package/dist/types/eval/js/worker-protocol.d.ts +6 -0
- package/dist/types/eval/py/executor.d.ts +7 -0
- package/dist/types/eval/py/index.d.ts +1 -1
- package/dist/types/exa/index.d.ts +1 -19
- package/dist/types/exa/mcp-client.d.ts +10 -3
- package/dist/types/exa/types.d.ts +0 -83
- package/dist/types/export/ttsr.d.ts +14 -0
- package/dist/types/extensibility/extensions/types.d.ts +8 -1
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
- package/dist/types/internal-urls/local-protocol.d.ts +10 -0
- package/dist/types/mcp/oauth-flow.d.ts +2 -2
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/{status-line.d.ts → status-line/component.d.ts} +2 -32
- package/dist/types/modes/components/status-line/index.d.ts +1 -0
- package/dist/types/modes/components/status-line/types.d.ts +31 -2
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +8 -0
- package/dist/types/modes/image-references.d.ts +8 -3
- package/dist/types/modes/interactive-mode.d.ts +9 -1
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +0 -2
- package/dist/types/task/render.d.ts +1 -0
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/browser/tab-worker.d.ts +15 -0
- package/dist/types/tools/index.d.ts +17 -2
- package/dist/types/tools/render-utils.d.ts +1 -1
- package/dist/types/tools/tool-timeouts.d.ts +1 -1
- package/dist/types/utils/block-context.d.ts +35 -0
- package/dist/types/utils/git.d.ts +6 -0
- package/dist/types/utils/image-loading.d.ts +12 -0
- package/package.json +29 -9
- package/src/capability/rule-buckets.ts +4 -2
- package/src/capability/rule.ts +10 -1
- package/src/cli/auth-broker-cli.ts +6 -7
- package/src/cli/auth-gateway-cli.ts +4 -3
- package/src/cli/list-models.ts +5 -0
- package/src/cli/update-cli.ts +138 -16
- package/src/commit/agentic/tools/split-commit.ts +8 -1
- package/src/config/model-provider-priority.ts +1 -0
- package/src/config/model-registry.ts +81 -2
- package/src/debug/index.ts +4 -8
- package/src/discovery/at-imports.ts +273 -0
- package/src/discovery/builtin-rules/index.ts +4 -0
- package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
- package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
- package/src/discovery/helpers.ts +2 -1
- package/src/edit/diff.ts +114 -4
- package/src/edit/hashline/diff.ts +1 -1
- package/src/edit/hashline/execute.ts +1 -1
- package/src/edit/modes/patch.ts +6 -2
- package/src/edit/modes/replace.ts +1 -1
- package/src/edit/renderer.ts +12 -2
- package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
- package/src/eval/backend.ts +15 -0
- package/src/eval/js/context-manager.ts +4 -2
- package/src/eval/js/executor.ts +3 -0
- package/src/eval/js/index.ts +7 -1
- package/src/eval/js/shared/helpers.ts +53 -6
- package/src/eval/js/shared/runtime.ts +8 -0
- package/src/eval/js/worker-core.ts +1 -0
- package/src/eval/js/worker-protocol.ts +6 -0
- package/src/eval/py/executor.ts +12 -0
- package/src/eval/py/index.ts +7 -1
- package/src/eval/py/prelude.py +43 -4
- package/src/eval/py/runner.py +1 -0
- package/src/exa/index.ts +1 -26
- package/src/exa/mcp-client.ts +10 -10
- package/src/exa/types.ts +0 -97
- package/src/export/ttsr.ts +122 -1
- package/src/extensibility/extensions/types.ts +8 -1
- package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
- package/src/extensibility/plugins/doctor.ts +1 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +6 -5
- package/src/goals/tools/goal-tool.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/internal-urls/local-protocol.ts +13 -0
- package/src/lsp/render.ts +8 -6
- package/src/mcp/oauth-flow.ts +3 -3
- package/src/mcp/render.ts +7 -1
- package/src/modes/components/agent-dashboard.ts +6 -4
- package/src/modes/components/custom-editor.ts +12 -6
- package/src/modes/components/login-dialog.ts +1 -1
- package/src/modes/components/oauth-selector.ts +4 -4
- package/src/modes/components/read-tool-group.ts +10 -3
- package/src/modes/components/{status-line.ts → status-line/component.ts} +18 -40
- package/src/modes/components/status-line/index.ts +1 -0
- package/src/modes/components/status-line/types.ts +23 -8
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/modes/components/transcript-container.ts +17 -10
- package/src/modes/components/user-message.ts +6 -3
- package/src/modes/components/welcome.ts +1 -1
- package/src/modes/controllers/event-controller.ts +8 -0
- package/src/modes/controllers/extension-ui-controller.ts +143 -127
- package/src/modes/controllers/input-controller.ts +60 -11
- package/src/modes/controllers/mcp-command-controller.ts +52 -17
- package/src/modes/controllers/selector-controller.ts +4 -11
- package/src/modes/controllers/ssh-command-controller.ts +2 -2
- package/src/modes/image-references.ts +13 -7
- package/src/modes/interactive-mode.ts +35 -3
- package/src/modes/rpc/rpc-mode.ts +1 -1
- package/src/modes/setup-wizard/scenes/sign-in.ts +3 -11
- package/src/modes/theme/theme.ts +95 -1
- package/src/modes/types.ts +3 -1
- package/src/modes/utils/ui-helpers.ts +14 -5
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/eval.md +4 -4
- package/src/sdk.ts +31 -14
- package/src/session/agent-session.ts +290 -196
- package/src/session/session-manager.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +9 -1
- package/src/system-prompt.ts +15 -9
- package/src/task/index.ts +9 -1
- package/src/task/render.ts +36 -14
- package/src/tools/ask.ts +14 -5
- package/src/tools/bash-interactive.ts +1 -1
- package/src/tools/bash.ts +14 -2
- package/src/tools/browser/render.ts +5 -2
- package/src/tools/browser/tab-worker.ts +211 -91
- package/src/tools/debug.ts +5 -2
- package/src/tools/eval-render.ts +6 -3
- package/src/tools/eval.ts +1 -1
- package/src/tools/gh-renderer.ts +29 -15
- package/src/tools/index.ts +32 -4
- package/src/tools/inspect-image-renderer.ts +12 -5
- package/src/tools/job.ts +9 -6
- package/src/tools/memory-render.ts +19 -5
- package/src/tools/read.ts +165 -18
- package/src/tools/render-utils.ts +3 -1
- package/src/tools/resolve.ts +1 -1
- package/src/tools/review.ts +1 -1
- package/src/tools/ssh.ts +4 -1
- package/src/tools/todo.ts +8 -1
- package/src/tools/tool-timeouts.ts +1 -1
- package/src/tools/write.ts +1 -1
- package/src/tui/code-cell.ts +1 -1
- package/src/utils/block-context.ts +312 -0
- package/src/utils/git.ts +41 -0
- package/src/utils/image-loading.ts +31 -1
- package/src/web/search/providers/codex.ts +1 -1
- package/src/web/search/render.ts +14 -6
- package/dist/types/exa/factory.d.ts +0 -13
- package/dist/types/exa/render.d.ts +0 -19
- package/dist/types/exa/researcher.d.ts +0 -9
- package/dist/types/exa/search.d.ts +0 -9
- package/dist/types/exa/websets.d.ts +0 -9
- package/src/exa/factory.ts +0 -60
- package/src/exa/render.ts +0 -244
- package/src/exa/researcher.ts +0 -36
- package/src/exa/search.ts +0 -47
- package/src/exa/websets.ts +0 -248
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @-import expansion for context files (AGENTS.md / CLAUDE.md / GEMINI.md / …).
|
|
3
|
+
*
|
|
4
|
+
* Other coding agents (Claude Code, Goose, Cline, …) treat `@path/to/file`
|
|
5
|
+
* references inside their markdown memory files as inline includes. omp
|
|
6
|
+
* loads the same files in their native shape, so this module performs the
|
|
7
|
+
* same expansion before content lands in the system prompt.
|
|
8
|
+
*
|
|
9
|
+
* Semantics mirror Claude Code's documented behavior:
|
|
10
|
+
* - `@` must sit at start of line or after whitespace (so `git@github.com`
|
|
11
|
+
* and `user@example.com` are not treated as imports).
|
|
12
|
+
* - Relative paths resolve against the importing file's directory, not the
|
|
13
|
+
* working directory.
|
|
14
|
+
* - `~/...` resolves to the user's home directory.
|
|
15
|
+
* - Imports inside fenced code blocks (` ``` ` / `~~~`) and inline code
|
|
16
|
+
* spans (`` `…` ``) are preserved verbatim so technical examples like
|
|
17
|
+
* `npm install @types/node` survive intact.
|
|
18
|
+
* - Recursive imports are followed up to {@link MAX_AT_IMPORT_DEPTH} hops;
|
|
19
|
+
* cycles are broken silently.
|
|
20
|
+
* - When the referenced file cannot be read, the original `@token` is
|
|
21
|
+
* left untouched and a debug log is emitted.
|
|
22
|
+
*
|
|
23
|
+
* @see https://docs.claude.com/en/docs/claude-code/memory#import-additional-files
|
|
24
|
+
*/
|
|
25
|
+
import * as os from "node:os";
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
28
|
+
import { readFile } from "../capability/fs";
|
|
29
|
+
|
|
30
|
+
/** Maximum number of recursive `@`-import hops. Matches Claude Code's documented cap. */
|
|
31
|
+
export const MAX_AT_IMPORT_DEPTH = 5;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Matches a candidate `@import` token: a leading boundary (start-of-string
|
|
35
|
+
* or single whitespace char) and a token whose first character is path-like.
|
|
36
|
+
*
|
|
37
|
+
* The boundary character is captured separately so the slice arithmetic in
|
|
38
|
+
* {@link expandLine} aligns with the `@` position, not the whitespace.
|
|
39
|
+
*/
|
|
40
|
+
const AT_IMPORT_REGEX = /(^|[ \t])@([./~A-Za-z0-9_-][^\s]*)/g;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Trailing characters stripped from a captured path token: sentence-ending
|
|
44
|
+
* punctuation, closing brackets, quotes. A lone trailing period is treated
|
|
45
|
+
* as sentence grammar (e.g. `See @AGENTS.md.`) — legitimate file extensions
|
|
46
|
+
* still match because the stripped set is anchored at the very end of the
|
|
47
|
+
* token, so `@AGENTS.md` keeps the `.md` (the `d` is not in the set).
|
|
48
|
+
*/
|
|
49
|
+
const TRAILING_PUNCT = /[.,;:!?)\]}"']+$/;
|
|
50
|
+
|
|
51
|
+
export interface ExpandAtImportsOptions {
|
|
52
|
+
/** Maximum hop depth (default: {@link MAX_AT_IMPORT_DEPTH}). */
|
|
53
|
+
maxDepth?: number;
|
|
54
|
+
/** Override the home directory used to resolve `~/...` (default: `os.homedir()`). */
|
|
55
|
+
home?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Expand `@path/to/file` references in `content` against `filePath`'s directory.
|
|
60
|
+
*
|
|
61
|
+
* Returns the expanded text. When no imports match, the original string is
|
|
62
|
+
* returned unchanged.
|
|
63
|
+
*/
|
|
64
|
+
export async function expandAtImports(
|
|
65
|
+
content: string,
|
|
66
|
+
filePath: string,
|
|
67
|
+
options: ExpandAtImportsOptions = {},
|
|
68
|
+
): Promise<string> {
|
|
69
|
+
const maxDepth = options.maxDepth ?? MAX_AT_IMPORT_DEPTH;
|
|
70
|
+
const home = options.home ?? os.homedir();
|
|
71
|
+
const absoluteSource = path.resolve(filePath);
|
|
72
|
+
const visited = new Set<string>([absoluteSource]);
|
|
73
|
+
return await expand(content, path.dirname(absoluteSource), 0, maxDepth, home, visited);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function expand(
|
|
77
|
+
content: string,
|
|
78
|
+
baseDir: string,
|
|
79
|
+
depth: number,
|
|
80
|
+
maxDepth: number,
|
|
81
|
+
home: string,
|
|
82
|
+
visited: Set<string>,
|
|
83
|
+
): Promise<string> {
|
|
84
|
+
if (depth >= maxDepth) return content;
|
|
85
|
+
|
|
86
|
+
const segments = splitMarkdownSegments(content);
|
|
87
|
+
const out: string[] = [];
|
|
88
|
+
for (const segment of segments) {
|
|
89
|
+
if (segment.kind === "code") {
|
|
90
|
+
out.push(segment.text);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
out.push(await expandTextSegment(segment.text, baseDir, depth, maxDepth, home, visited));
|
|
94
|
+
}
|
|
95
|
+
return out.join("");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function expandTextSegment(
|
|
99
|
+
text: string,
|
|
100
|
+
baseDir: string,
|
|
101
|
+
depth: number,
|
|
102
|
+
maxDepth: number,
|
|
103
|
+
home: string,
|
|
104
|
+
visited: Set<string>,
|
|
105
|
+
): Promise<string> {
|
|
106
|
+
const lines = text.split("\n");
|
|
107
|
+
for (let i = 0; i < lines.length; i++) {
|
|
108
|
+
lines[i] = await expandLine(lines[i], baseDir, depth, maxDepth, home, visited);
|
|
109
|
+
}
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function expandLine(
|
|
114
|
+
line: string,
|
|
115
|
+
baseDir: string,
|
|
116
|
+
depth: number,
|
|
117
|
+
maxDepth: number,
|
|
118
|
+
home: string,
|
|
119
|
+
visited: Set<string>,
|
|
120
|
+
): Promise<string> {
|
|
121
|
+
if (!line.includes("@")) return line;
|
|
122
|
+
|
|
123
|
+
const matches: Array<{ start: number; end: number; importPath: string }> = [];
|
|
124
|
+
for (const m of line.matchAll(AT_IMPORT_REGEX)) {
|
|
125
|
+
const matchIndex = m.index ?? 0;
|
|
126
|
+
const leading = m[1];
|
|
127
|
+
const rawToken = m[2];
|
|
128
|
+
const atPos = matchIndex + leading.length;
|
|
129
|
+
if (isInsideInlineCode(line, atPos)) continue;
|
|
130
|
+
|
|
131
|
+
const trimmedToken = rawToken.replace(TRAILING_PUNCT, "");
|
|
132
|
+
if (trimmedToken.length === 0) continue;
|
|
133
|
+
|
|
134
|
+
matches.push({
|
|
135
|
+
start: atPos,
|
|
136
|
+
end: atPos + 1 + trimmedToken.length,
|
|
137
|
+
importPath: trimmedToken,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (matches.length === 0) return line;
|
|
142
|
+
|
|
143
|
+
const parts: string[] = [];
|
|
144
|
+
let cursor = 0;
|
|
145
|
+
for (const m of matches) {
|
|
146
|
+
parts.push(line.slice(cursor, m.start));
|
|
147
|
+
const expanded = await resolveAndExpand(m.importPath, baseDir, depth, maxDepth, home, visited);
|
|
148
|
+
parts.push(expanded ?? line.slice(m.start, m.end));
|
|
149
|
+
cursor = m.end;
|
|
150
|
+
}
|
|
151
|
+
parts.push(line.slice(cursor));
|
|
152
|
+
return parts.join("");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function resolveAndExpand(
|
|
156
|
+
importPath: string,
|
|
157
|
+
baseDir: string,
|
|
158
|
+
depth: number,
|
|
159
|
+
maxDepth: number,
|
|
160
|
+
home: string,
|
|
161
|
+
visited: Set<string>,
|
|
162
|
+
): Promise<string | null> {
|
|
163
|
+
const resolved = resolveImportPath(importPath, baseDir, home);
|
|
164
|
+
if (visited.has(resolved)) {
|
|
165
|
+
logger.debug("@-import: skipping cyclic include", { path: resolved });
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const content = await readFile(resolved);
|
|
170
|
+
if (content === null) {
|
|
171
|
+
logger.debug("@-import: file not found", { path: resolved });
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Visited is shared across the whole expansion tree to break cycles,
|
|
176
|
+
// even cycles that span multiple importing files.
|
|
177
|
+
visited.add(resolved);
|
|
178
|
+
return await expand(content, path.dirname(resolved), depth + 1, maxDepth, home, visited);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function resolveImportPath(importPath: string, baseDir: string, home: string): string {
|
|
182
|
+
if (importPath === "~") return path.resolve(home);
|
|
183
|
+
if (importPath.startsWith("~/")) return path.resolve(home, importPath.slice(2));
|
|
184
|
+
if (path.isAbsolute(importPath)) return path.resolve(importPath);
|
|
185
|
+
return path.resolve(baseDir, importPath);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface MarkdownSegment {
|
|
189
|
+
kind: "text" | "code";
|
|
190
|
+
text: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Split markdown into alternating text/code segments by tracking fenced
|
|
195
|
+
* code blocks. Inline code spans are handled per-line by {@link isInsideInlineCode}.
|
|
196
|
+
*
|
|
197
|
+
* A fence is recognized as a line whose first non-whitespace run is three or
|
|
198
|
+
* more backticks (or tildes). The closing fence must use the same character
|
|
199
|
+
* with at least as many marks as the opener.
|
|
200
|
+
*/
|
|
201
|
+
function splitMarkdownSegments(content: string): MarkdownSegment[] {
|
|
202
|
+
const segments: MarkdownSegment[] = [];
|
|
203
|
+
const lines = content.split("\n");
|
|
204
|
+
let buffer: string[] = [];
|
|
205
|
+
let bufferKind: MarkdownSegment["kind"] = "text";
|
|
206
|
+
let fenceChar = "";
|
|
207
|
+
let fenceLen = 0;
|
|
208
|
+
|
|
209
|
+
const flush = (): void => {
|
|
210
|
+
if (buffer.length === 0) return;
|
|
211
|
+
segments.push({ kind: bufferKind, text: buffer.join("") });
|
|
212
|
+
buffer = [];
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
for (let i = 0; i < lines.length; i++) {
|
|
216
|
+
const line = lines[i];
|
|
217
|
+
const isLast = i === lines.length - 1;
|
|
218
|
+
// Re-attach each line's trailing newline so adjacent segments
|
|
219
|
+
// concatenate without losing the boundary `\n`.
|
|
220
|
+
const lineText = isLast ? line : `${line}\n`;
|
|
221
|
+
const fence = matchFence(line);
|
|
222
|
+
|
|
223
|
+
if (fence && bufferKind === "text") {
|
|
224
|
+
flush();
|
|
225
|
+
bufferKind = "code";
|
|
226
|
+
buffer.push(lineText);
|
|
227
|
+
fenceChar = fence.char;
|
|
228
|
+
fenceLen = fence.len;
|
|
229
|
+
} else if (fence && bufferKind === "code" && fence.char === fenceChar && fence.len >= fenceLen) {
|
|
230
|
+
buffer.push(lineText);
|
|
231
|
+
flush();
|
|
232
|
+
bufferKind = "text";
|
|
233
|
+
fenceChar = "";
|
|
234
|
+
fenceLen = 0;
|
|
235
|
+
} else {
|
|
236
|
+
buffer.push(lineText);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (isLast) flush();
|
|
240
|
+
}
|
|
241
|
+
return segments;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function matchFence(line: string): { char: string; len: number } | null {
|
|
245
|
+
let i = 0;
|
|
246
|
+
while (i < line.length && (line[i] === " " || line[i] === "\t")) i++;
|
|
247
|
+
const char = line[i];
|
|
248
|
+
if (char !== "`" && char !== "~") return null;
|
|
249
|
+
let len = 0;
|
|
250
|
+
while (i + len < line.length && line[i + len] === char) len++;
|
|
251
|
+
if (len < 3) return null;
|
|
252
|
+
return { char, len };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Returns `true` when `position` falls inside an unclosed inline-code span on
|
|
257
|
+
* this line. Implemented as a backtick-parity scan so it handles repeated
|
|
258
|
+
* delimiters like `` `` literal ` backtick `` `` correctly enough for the
|
|
259
|
+
* "@-imports inside `code` should not expand" case.
|
|
260
|
+
*/
|
|
261
|
+
function isInsideInlineCode(line: string, position: number): boolean {
|
|
262
|
+
let inSpan = false;
|
|
263
|
+
let i = 0;
|
|
264
|
+
while (i < position && i < line.length) {
|
|
265
|
+
if (line[i] === "`") {
|
|
266
|
+
while (i < line.length && line[i] === "`") i++;
|
|
267
|
+
inSpan = !inSpan;
|
|
268
|
+
} else {
|
|
269
|
+
i++;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return inSpan;
|
|
273
|
+
}
|
|
@@ -20,8 +20,10 @@ import tsNoAny from "./ts-no-any.md" with { type: "text" };
|
|
|
20
20
|
import tsNoDeprecatedLeftovers from "./ts-no-deprecated-leftovers.md" with { type: "text" };
|
|
21
21
|
import tsNoDynamicImport from "./ts-no-dynamic-import.md" with { type: "text" };
|
|
22
22
|
import tsNoReturnType from "./ts-no-return-type.md" with { type: "text" };
|
|
23
|
+
import tsNoTestTimers from "./ts-no-test-timers.md" with { type: "text" };
|
|
23
24
|
import tsNoTinyFunctions from "./ts-no-tiny-functions.md" with { type: "text" };
|
|
24
25
|
import tsPromiseWithResolvers from "./ts-promise-with-resolvers.md" with { type: "text" };
|
|
26
|
+
import tsRedundantClearGuard from "./ts-redundant-clear-guard.md" with { type: "text" };
|
|
25
27
|
import tsSetMap from "./ts-set-map.md" with { type: "text" };
|
|
26
28
|
|
|
27
29
|
/** A bundled rule's stable name and raw markdown (frontmatter + body). */
|
|
@@ -44,7 +46,9 @@ export const BUILTIN_RULE_SOURCES: readonly BuiltinRuleSource[] = [
|
|
|
44
46
|
{ name: "ts-no-deprecated-leftovers", content: tsNoDeprecatedLeftovers },
|
|
45
47
|
{ name: "ts-no-dynamic-import", content: tsNoDynamicImport },
|
|
46
48
|
{ name: "ts-no-return-type", content: tsNoReturnType },
|
|
49
|
+
{ name: "ts-no-test-timers", content: tsNoTestTimers },
|
|
47
50
|
{ name: "ts-no-tiny-functions", content: tsNoTinyFunctions },
|
|
48
51
|
{ name: "ts-promise-with-resolvers", content: tsPromiseWithResolvers },
|
|
52
|
+
{ name: "ts-redundant-clear-guard", content: tsRedundantClearGuard },
|
|
49
53
|
{ name: "ts-set-map", content: tsSetMap },
|
|
50
54
|
];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Do not use real timers (Bun.sleep, setTimeout, setInterval) in tests — drive time with fake timers instead
|
|
3
|
+
condition:
|
|
4
|
+
- "Bun\\.sleep\\("
|
|
5
|
+
- "\\bsetInterval\\("
|
|
6
|
+
- "\\bsetTimeout\\("
|
|
7
|
+
scope: "tool:edit(*.test.ts), tool:write(*.test.ts)"
|
|
8
|
+
interruptMode: never
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
**Do not reach for real wall-clock timers in test files.** `Bun.sleep(...)`, `setTimeout(...)`, and `setInterval(...)` tie a test's duration to real time: they slow the suite on every run, and any delay tuned to "long enough" eventually races on a loaded machine and flakes.
|
|
12
|
+
|
|
13
|
+
## Why it's wrong
|
|
14
|
+
|
|
15
|
+
- Real delays add fixed latency to every invocation; CI pays it on every run.
|
|
16
|
+
- A sleep sized to mask a race is a guess — the race resurfaces under load.
|
|
17
|
+
- A fixed wait hides *what* you are waiting for, so a failure points at a timeout instead of the real cause.
|
|
18
|
+
|
|
19
|
+
## Avoid
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
test("debounce fires once", async () => {
|
|
23
|
+
const fn = debounce(handler, 100);
|
|
24
|
+
fn();
|
|
25
|
+
await Bun.sleep(150); // real delay — slow and timing-dependent
|
|
26
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Use
|
|
31
|
+
|
|
32
|
+
Drive time deterministically with fake timers:
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { expect, test, vi } from "bun:test";
|
|
36
|
+
|
|
37
|
+
test("debounce fires once", () => {
|
|
38
|
+
vi.useFakeTimers();
|
|
39
|
+
const fn = debounce(handler, 100);
|
|
40
|
+
fn();
|
|
41
|
+
vi.advanceTimersByTime(150); // advance the clock, no real wait
|
|
42
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
When the code under test resolves a promise or emits an event, await that signal directly instead of guessing a duration:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
await once(emitter, "done"); // await the real event
|
|
50
|
+
const value = await pending; // await the promise the code already exposes
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Exceptions
|
|
54
|
+
|
|
55
|
+
An integration test that deliberately exercises real timer behavior against the platform clock may need a genuine delay. Keep it rare, and add a short comment naming why deterministic time control will not work.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Do not guard clearTimeout/clearInterval/clearImmediate with a truthiness or null/undefined check — they accept null and undefined
|
|
3
|
+
scope: "tool:edit(*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}), tool:write(*.{ts,tsx,js,jsx,mts,cts,mjs,cjs})"
|
|
4
|
+
interruptMode: never
|
|
5
|
+
astCondition:
|
|
6
|
+
- "if ($X) clearTimeout($X)"
|
|
7
|
+
- "if ($X) { clearTimeout($X) }"
|
|
8
|
+
- "if ($X) clearInterval($X)"
|
|
9
|
+
- "if ($X) { clearInterval($X) }"
|
|
10
|
+
- "if ($X) clearImmediate($X)"
|
|
11
|
+
- "if ($X) { clearImmediate($X) }"
|
|
12
|
+
- "if ($X !== null) clearTimeout($X)"
|
|
13
|
+
- "if ($X !== null) { clearTimeout($X) }"
|
|
14
|
+
- "if ($X !== null) clearInterval($X)"
|
|
15
|
+
- "if ($X !== null) { clearInterval($X) }"
|
|
16
|
+
- "if ($X !== null) clearImmediate($X)"
|
|
17
|
+
- "if ($X !== null) { clearImmediate($X) }"
|
|
18
|
+
- "if ($X != null) clearTimeout($X)"
|
|
19
|
+
- "if ($X != null) { clearTimeout($X) }"
|
|
20
|
+
- "if ($X != null) clearInterval($X)"
|
|
21
|
+
- "if ($X != null) { clearInterval($X) }"
|
|
22
|
+
- "if ($X != null) clearImmediate($X)"
|
|
23
|
+
- "if ($X != null) { clearImmediate($X) }"
|
|
24
|
+
- "if ($X !== undefined) clearTimeout($X)"
|
|
25
|
+
- "if ($X !== undefined) { clearTimeout($X) }"
|
|
26
|
+
- "if ($X !== undefined) clearInterval($X)"
|
|
27
|
+
- "if ($X !== undefined) { clearInterval($X) }"
|
|
28
|
+
- "if ($X !== undefined) clearImmediate($X)"
|
|
29
|
+
- "if ($X !== undefined) { clearImmediate($X) }"
|
|
30
|
+
- "if ($X != undefined) clearTimeout($X)"
|
|
31
|
+
- "if ($X != undefined) { clearTimeout($X) }"
|
|
32
|
+
- "if ($X != undefined) clearInterval($X)"
|
|
33
|
+
- "if ($X != undefined) { clearInterval($X) }"
|
|
34
|
+
- "if ($X != undefined) clearImmediate($X)"
|
|
35
|
+
- "if ($X != undefined) { clearImmediate($X) }"
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
**Do not guard `clearTimeout` / `clearInterval` / `clearImmediate` with a truthiness or `null`/`undefined` check.** Per the WHATWG/Node timers spec these functions are no-ops when handed `null`, `undefined`, or any value that doesn't correspond to a live timer. The guard adds a redundant branch that the reader must still reason about.
|
|
39
|
+
|
|
40
|
+
## Why it's wrong
|
|
41
|
+
|
|
42
|
+
- The branch can never change behavior — clearing a missing/`null`/`undefined` handle does nothing.
|
|
43
|
+
- Extra branches inflate the code and hide the one line that matters.
|
|
44
|
+
- It signals a misunderstanding of the timer API to future readers.
|
|
45
|
+
|
|
46
|
+
## Avoid
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
if (this.timer) clearTimeout(this.timer);
|
|
50
|
+
if (handle !== null) clearInterval(handle);
|
|
51
|
+
if (id != undefined) {
|
|
52
|
+
clearImmediate(id);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Use
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
clearTimeout(this.timer);
|
|
60
|
+
clearInterval(handle);
|
|
61
|
+
clearImmediate(id);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## When a guard *is* warranted
|
|
65
|
+
|
|
66
|
+
Keep the check only when the body does more than clear — e.g. it also reassigns the handle or runs other cleanup:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
if (this.timer) {
|
|
70
|
+
clearTimeout(this.timer);
|
|
71
|
+
this.timer = undefined; // extra work → guard is not purely redundant
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
This rule only fires when the clear call is the sole statement in the guarded branch, so those legitimate cases are left alone.
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -163,7 +163,7 @@ export function buildRuleFromMarkdown(
|
|
|
163
163
|
},
|
|
164
164
|
): Rule {
|
|
165
165
|
const { frontmatter, body } = parseFrontmatter(content, { source: filePath });
|
|
166
|
-
const { condition, scope } = parseRuleConditionAndScope(frontmatter as RuleFrontmatter);
|
|
166
|
+
const { condition, astCondition, scope } = parseRuleConditionAndScope(frontmatter as RuleFrontmatter);
|
|
167
167
|
|
|
168
168
|
let globs: string[] | undefined;
|
|
169
169
|
if (Array.isArray(frontmatter.globs)) {
|
|
@@ -186,6 +186,7 @@ export function buildRuleFromMarkdown(
|
|
|
186
186
|
alwaysApply: frontmatter.alwaysApply === true,
|
|
187
187
|
description: typeof frontmatter.description === "string" ? frontmatter.description : undefined,
|
|
188
188
|
condition,
|
|
189
|
+
astCondition,
|
|
189
190
|
scope,
|
|
190
191
|
interruptMode,
|
|
191
192
|
_source: source,
|
package/src/edit/diff.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as Diff from "diff";
|
|
8
8
|
import { resolveToCwd } from "../tools/path-utils";
|
|
9
|
+
import { type BlockContextSource, findBlockContextLines } from "../utils/block-context";
|
|
9
10
|
import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace";
|
|
10
11
|
import { adjustIndentation, normalizeToLF, stripBom } from "./normalize";
|
|
11
12
|
import { readEditFileText } from "./read-file";
|
|
@@ -54,11 +55,109 @@ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, conten
|
|
|
54
55
|
return `${prefix}${lineNum}|${content}`;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
type DiffSource = "old" | "new";
|
|
59
|
+
|
|
60
|
+
interface ParsedNumberedDiffRow {
|
|
61
|
+
prefix: "+" | "-" | " ";
|
|
62
|
+
lineNumber: number;
|
|
63
|
+
content: string;
|
|
64
|
+
source: DiffSource;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseNumberedDiffRow(row: string): ParsedNumberedDiffRow | undefined {
|
|
68
|
+
const match = /^([+\- ])(\d+)\|(.*)$/s.exec(row);
|
|
69
|
+
if (!match) return undefined;
|
|
70
|
+
const prefix = match[1] as "+" | "-" | " ";
|
|
71
|
+
const lineNumber = Number.parseInt(match[2], 10);
|
|
72
|
+
if (!Number.isFinite(lineNumber)) return undefined;
|
|
73
|
+
return {
|
|
74
|
+
prefix,
|
|
75
|
+
lineNumber,
|
|
76
|
+
content: match[3] ?? "",
|
|
77
|
+
source: prefix === "+" ? "new" : "old",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isDiffChangeRow(row: string | undefined): boolean {
|
|
82
|
+
return row !== undefined && (row.startsWith("+") || row.startsWith("-"));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function adjustedContextInsertIndex(rows: readonly string[], index: number): number {
|
|
86
|
+
let start = index;
|
|
87
|
+
while (start > 0 && isDiffChangeRow(rows[start - 1])) start--;
|
|
88
|
+
let end = index;
|
|
89
|
+
while (end < rows.length && isDiffChangeRow(rows[end])) end++;
|
|
90
|
+
return index > start && index < end ? end : index;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function insertBracketContextRows(
|
|
94
|
+
rows: string[],
|
|
95
|
+
source: DiffSource,
|
|
96
|
+
contextLines: ReadonlyMap<number, string>,
|
|
97
|
+
seenRows: Set<string>,
|
|
98
|
+
): void {
|
|
99
|
+
const context = [...contextLines].sort(([left], [right]) => left - right);
|
|
100
|
+
for (const [lineNumber, text] of context) {
|
|
101
|
+
const row = formatNumberedDiffLine(" ", lineNumber, text);
|
|
102
|
+
if (seenRows.has(row)) continue;
|
|
103
|
+
|
|
104
|
+
let insertIndex = rows.length;
|
|
105
|
+
let previousSourceLine: number | undefined;
|
|
106
|
+
let nextSourceLine: number | undefined;
|
|
107
|
+
for (let i = 0; i < rows.length; i++) {
|
|
108
|
+
const parsed = parseNumberedDiffRow(rows[i]);
|
|
109
|
+
if (!parsed || parsed.source !== source) continue;
|
|
110
|
+
if (parsed.lineNumber < lineNumber) {
|
|
111
|
+
previousSourceLine = parsed.lineNumber;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
nextSourceLine = parsed.lineNumber;
|
|
115
|
+
insertIndex = i;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const chunk: string[] = [];
|
|
120
|
+
if (previousSourceLine !== undefined && lineNumber > previousSourceLine + 1) chunk.push("...");
|
|
121
|
+
chunk.push(row);
|
|
122
|
+
if (nextSourceLine !== undefined && nextSourceLine > lineNumber + 1) chunk.push("...");
|
|
123
|
+
|
|
124
|
+
const adjustedIndex = adjustedContextInsertIndex(rows, insertIndex);
|
|
125
|
+
rows.splice(adjustedIndex, 0, ...chunk);
|
|
126
|
+
for (const inserted of chunk) seenRows.add(inserted);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function addMatchingBracketContextRows(
|
|
131
|
+
rows: string[],
|
|
132
|
+
oldLines: readonly string[],
|
|
133
|
+
newLines: readonly string[],
|
|
134
|
+
source: BlockContextSource,
|
|
135
|
+
): void {
|
|
136
|
+
const oldVisible: number[] = [];
|
|
137
|
+
const newVisible: number[] = [];
|
|
138
|
+
const seenRows = new Set(rows);
|
|
139
|
+
|
|
140
|
+
for (const row of rows) {
|
|
141
|
+
const parsed = parseNumberedDiffRow(row);
|
|
142
|
+
if (!parsed) continue;
|
|
143
|
+
if (parsed.source === "old") oldVisible.push(parsed.lineNumber);
|
|
144
|
+
else newVisible.push(parsed.lineNumber);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
insertBracketContextRows(rows, "old", findBlockContextLines(oldLines, oldVisible, source), seenRows);
|
|
148
|
+
insertBracketContextRows(rows, "new", findBlockContextLines(newLines, newVisible, source), seenRows);
|
|
149
|
+
}
|
|
150
|
+
|
|
57
151
|
/**
|
|
58
152
|
* Generate a unified diff string with line numbers and context.
|
|
59
153
|
* Returns both the diff string and the first changed line number (in the new file).
|
|
60
154
|
*/
|
|
61
|
-
export function generateDiffString(
|
|
155
|
+
export function generateDiffString(
|
|
156
|
+
oldContent: string,
|
|
157
|
+
newContent: string,
|
|
158
|
+
contextLines = 2,
|
|
159
|
+
source: BlockContextSource = {},
|
|
160
|
+
): DiffResult {
|
|
62
161
|
const parts = Diff.diffLines(oldContent, newContent);
|
|
63
162
|
const output: string[] = [];
|
|
64
163
|
|
|
@@ -133,8 +232,10 @@ export function generateDiffString(oldContent: string, newContent: string, conte
|
|
|
133
232
|
newLineNum++;
|
|
134
233
|
}
|
|
135
234
|
|
|
235
|
+
// Mid-skip placeholder is omitted too: the jump between the trailing
|
|
236
|
+
// number of the leading context and the leading number of the
|
|
237
|
+
// trailing context conveys the gap, just like leading/trailing skips.
|
|
136
238
|
if (middleSkip > 0) {
|
|
137
|
-
output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
|
|
138
239
|
oldLineNum += middleSkip;
|
|
139
240
|
newLineNum += middleSkip;
|
|
140
241
|
for (const line of linesToShow.slice(firstChunkLength)) {
|
|
@@ -160,6 +261,8 @@ export function generateDiffString(oldContent: string, newContent: string, conte
|
|
|
160
261
|
}
|
|
161
262
|
}
|
|
162
263
|
|
|
264
|
+
addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n"), source);
|
|
265
|
+
|
|
163
266
|
return { diff: output.join("\n"), firstChangedLine };
|
|
164
267
|
}
|
|
165
268
|
|
|
@@ -187,7 +290,12 @@ export interface ReplaceResult {
|
|
|
187
290
|
* Generate a unified diff string without file headers.
|
|
188
291
|
* Returns both the diff string and the first changed line number (in the new file).
|
|
189
292
|
*/
|
|
190
|
-
export function generateUnifiedDiffString(
|
|
293
|
+
export function generateUnifiedDiffString(
|
|
294
|
+
oldContent: string,
|
|
295
|
+
newContent: string,
|
|
296
|
+
contextLines = 3,
|
|
297
|
+
source: BlockContextSource = {},
|
|
298
|
+
): DiffResult {
|
|
191
299
|
const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines });
|
|
192
300
|
const output: string[] = [];
|
|
193
301
|
let firstChangedLine: number | undefined;
|
|
@@ -218,6 +326,8 @@ export function generateUnifiedDiffString(oldContent: string, newContent: string
|
|
|
218
326
|
}
|
|
219
327
|
}
|
|
220
328
|
|
|
329
|
+
addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n"), source);
|
|
330
|
+
|
|
221
331
|
return { diff: output.join("\n"), firstChangedLine };
|
|
222
332
|
}
|
|
223
333
|
|
|
@@ -805,7 +915,7 @@ export async function computeEditDiff(
|
|
|
805
915
|
};
|
|
806
916
|
}
|
|
807
917
|
|
|
808
|
-
return generateDiffString(normalizedContent, result.content);
|
|
918
|
+
return generateDiffString(normalizedContent, result.content, undefined, { path });
|
|
809
919
|
} catch (err) {
|
|
810
920
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
811
921
|
}
|
|
@@ -230,7 +230,7 @@ export async function computeHashlineSectionDiff(
|
|
|
230
230
|
if (options.streaming) return buildStreamingSectionDiff(section, normalized);
|
|
231
231
|
const result = applyPreviewEdits({ section, absolutePath, normalized, snapshots, options });
|
|
232
232
|
if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
|
|
233
|
-
return generateDiffString(normalized, result.text);
|
|
233
|
+
return generateDiffString(normalized, result.text, undefined, { path: section.path });
|
|
234
234
|
} catch (err) {
|
|
235
235
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
236
236
|
}
|
|
@@ -97,7 +97,7 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR
|
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
const diff = generateDiffString(result.before, result.after);
|
|
100
|
+
const diff = generateDiffString(result.before, result.after, undefined, { path: result.path });
|
|
101
101
|
const preview = buildCompactDiffPreview(diff.diff);
|
|
102
102
|
const meta = outputMeta()
|
|
103
103
|
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
package/src/edit/modes/patch.ts
CHANGED
|
@@ -1571,7 +1571,9 @@ export async function computePatchDiff(
|
|
|
1571
1571
|
if (!normalizedOld && !normalizedNew) {
|
|
1572
1572
|
return { diff: "", firstChangedLine: undefined };
|
|
1573
1573
|
}
|
|
1574
|
-
return generateUnifiedDiffString(normalizedOld, normalizedNew
|
|
1574
|
+
return generateUnifiedDiffString(normalizedOld, normalizedNew, undefined, {
|
|
1575
|
+
path: result.change.newPath ?? result.change.path,
|
|
1576
|
+
});
|
|
1575
1577
|
} catch (err) {
|
|
1576
1578
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
1577
1579
|
}
|
|
@@ -1785,7 +1787,9 @@ export async function executePatchSingle(
|
|
|
1785
1787
|
if (result.change.type === "update" && result.change.oldContent && result.change.newContent) {
|
|
1786
1788
|
const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text);
|
|
1787
1789
|
const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text);
|
|
1788
|
-
diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew
|
|
1790
|
+
diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew, undefined, {
|
|
1791
|
+
path: result.change.newPath ?? result.change.path,
|
|
1792
|
+
});
|
|
1789
1793
|
}
|
|
1790
1794
|
|
|
1791
1795
|
let resultText: string;
|
|
@@ -1078,7 +1078,7 @@ export async function executeReplaceSingle(
|
|
|
1078
1078
|
);
|
|
1079
1079
|
invalidateFsScanAfterWrite(absolutePath);
|
|
1080
1080
|
|
|
1081
|
-
const diffResult = generateDiffString(normalizedContent, result.content);
|
|
1081
|
+
const diffResult = generateDiffString(normalizedContent, result.content, undefined, { path });
|
|
1082
1082
|
const resultText =
|
|
1083
1083
|
result.count > 1
|
|
1084
1084
|
? `Successfully replaced ${result.count} occurrences in ${path}.`
|