@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.
Files changed (165) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/types/capability/rule-buckets.d.ts +1 -1
  3. package/dist/types/capability/rule.d.ts +6 -1
  4. package/dist/types/cli/update-cli.d.ts +11 -1
  5. package/dist/types/config/model-registry.d.ts +18 -1
  6. package/dist/types/discovery/at-imports.d.ts +15 -0
  7. package/dist/types/edit/diff.d.ts +3 -2
  8. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +1 -0
  9. package/dist/types/eval/backend.d.ts +7 -0
  10. package/dist/types/eval/js/context-manager.d.ts +1 -0
  11. package/dist/types/eval/js/executor.d.ts +2 -0
  12. package/dist/types/eval/js/index.d.ts +1 -1
  13. package/dist/types/eval/js/shared/helpers.d.ts +6 -0
  14. package/dist/types/eval/js/shared/runtime.d.ts +5 -0
  15. package/dist/types/eval/js/worker-protocol.d.ts +6 -0
  16. package/dist/types/eval/py/executor.d.ts +7 -0
  17. package/dist/types/eval/py/index.d.ts +1 -1
  18. package/dist/types/exa/index.d.ts +1 -19
  19. package/dist/types/exa/mcp-client.d.ts +10 -3
  20. package/dist/types/exa/types.d.ts +0 -83
  21. package/dist/types/export/ttsr.d.ts +14 -0
  22. package/dist/types/extensibility/extensions/types.d.ts +8 -1
  23. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
  24. package/dist/types/internal-urls/local-protocol.d.ts +10 -0
  25. package/dist/types/mcp/oauth-flow.d.ts +2 -2
  26. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  27. package/dist/types/modes/components/{status-line.d.ts → status-line/component.d.ts} +2 -32
  28. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  29. package/dist/types/modes/components/status-line/types.d.ts +31 -2
  30. package/dist/types/modes/controllers/mcp-command-controller.d.ts +8 -0
  31. package/dist/types/modes/image-references.d.ts +8 -3
  32. package/dist/types/modes/interactive-mode.d.ts +9 -1
  33. package/dist/types/modes/theme/theme.d.ts +2 -1
  34. package/dist/types/modes/types.d.ts +3 -1
  35. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  36. package/dist/types/session/agent-session.d.ts +0 -2
  37. package/dist/types/task/render.d.ts +1 -0
  38. package/dist/types/tools/ask.d.ts +1 -0
  39. package/dist/types/tools/browser/tab-worker.d.ts +15 -0
  40. package/dist/types/tools/index.d.ts +17 -2
  41. package/dist/types/tools/render-utils.d.ts +1 -1
  42. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  43. package/dist/types/utils/block-context.d.ts +35 -0
  44. package/dist/types/utils/git.d.ts +6 -0
  45. package/dist/types/utils/image-loading.d.ts +12 -0
  46. package/package.json +29 -9
  47. package/src/capability/rule-buckets.ts +4 -2
  48. package/src/capability/rule.ts +10 -1
  49. package/src/cli/auth-broker-cli.ts +6 -7
  50. package/src/cli/auth-gateway-cli.ts +4 -3
  51. package/src/cli/list-models.ts +5 -0
  52. package/src/cli/update-cli.ts +138 -16
  53. package/src/commit/agentic/tools/split-commit.ts +8 -1
  54. package/src/config/model-provider-priority.ts +1 -0
  55. package/src/config/model-registry.ts +81 -2
  56. package/src/debug/index.ts +4 -8
  57. package/src/discovery/at-imports.ts +273 -0
  58. package/src/discovery/builtin-rules/index.ts +4 -0
  59. package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
  60. package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
  61. package/src/discovery/helpers.ts +2 -1
  62. package/src/edit/diff.ts +114 -4
  63. package/src/edit/hashline/diff.ts +1 -1
  64. package/src/edit/hashline/execute.ts +1 -1
  65. package/src/edit/modes/patch.ts +6 -2
  66. package/src/edit/modes/replace.ts +1 -1
  67. package/src/edit/renderer.ts +12 -2
  68. package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
  69. package/src/eval/backend.ts +15 -0
  70. package/src/eval/js/context-manager.ts +4 -2
  71. package/src/eval/js/executor.ts +3 -0
  72. package/src/eval/js/index.ts +7 -1
  73. package/src/eval/js/shared/helpers.ts +53 -6
  74. package/src/eval/js/shared/runtime.ts +8 -0
  75. package/src/eval/js/worker-core.ts +1 -0
  76. package/src/eval/js/worker-protocol.ts +6 -0
  77. package/src/eval/py/executor.ts +12 -0
  78. package/src/eval/py/index.ts +7 -1
  79. package/src/eval/py/prelude.py +43 -4
  80. package/src/eval/py/runner.py +1 -0
  81. package/src/exa/index.ts +1 -26
  82. package/src/exa/mcp-client.ts +10 -10
  83. package/src/exa/types.ts +0 -97
  84. package/src/export/ttsr.ts +122 -1
  85. package/src/extensibility/extensions/types.ts +8 -1
  86. package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
  87. package/src/extensibility/plugins/doctor.ts +1 -1
  88. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -5
  89. package/src/goals/tools/goal-tool.ts +1 -1
  90. package/src/internal-urls/docs-index.generated.ts +7 -6
  91. package/src/internal-urls/local-protocol.ts +13 -0
  92. package/src/lsp/render.ts +8 -6
  93. package/src/mcp/oauth-flow.ts +3 -3
  94. package/src/mcp/render.ts +7 -1
  95. package/src/modes/components/agent-dashboard.ts +6 -4
  96. package/src/modes/components/custom-editor.ts +12 -6
  97. package/src/modes/components/login-dialog.ts +1 -1
  98. package/src/modes/components/oauth-selector.ts +4 -4
  99. package/src/modes/components/read-tool-group.ts +10 -3
  100. package/src/modes/components/{status-line.ts → status-line/component.ts} +18 -40
  101. package/src/modes/components/status-line/index.ts +1 -0
  102. package/src/modes/components/status-line/types.ts +23 -8
  103. package/src/modes/components/tool-execution.ts +1 -1
  104. package/src/modes/components/transcript-container.ts +17 -10
  105. package/src/modes/components/user-message.ts +6 -3
  106. package/src/modes/components/welcome.ts +1 -1
  107. package/src/modes/controllers/event-controller.ts +8 -0
  108. package/src/modes/controllers/extension-ui-controller.ts +143 -127
  109. package/src/modes/controllers/input-controller.ts +60 -11
  110. package/src/modes/controllers/mcp-command-controller.ts +52 -17
  111. package/src/modes/controllers/selector-controller.ts +4 -11
  112. package/src/modes/controllers/ssh-command-controller.ts +2 -2
  113. package/src/modes/image-references.ts +13 -7
  114. package/src/modes/interactive-mode.ts +35 -3
  115. package/src/modes/rpc/rpc-mode.ts +1 -1
  116. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -11
  117. package/src/modes/theme/theme.ts +95 -1
  118. package/src/modes/types.ts +3 -1
  119. package/src/modes/utils/ui-helpers.ts +14 -5
  120. package/src/prompts/tools/bash.md +1 -1
  121. package/src/prompts/tools/eval.md +4 -4
  122. package/src/sdk.ts +31 -14
  123. package/src/session/agent-session.ts +290 -196
  124. package/src/session/session-manager.ts +1 -1
  125. package/src/slash-commands/builtin-registry.ts +9 -1
  126. package/src/system-prompt.ts +15 -9
  127. package/src/task/index.ts +9 -1
  128. package/src/task/render.ts +36 -14
  129. package/src/tools/ask.ts +14 -5
  130. package/src/tools/bash-interactive.ts +1 -1
  131. package/src/tools/bash.ts +14 -2
  132. package/src/tools/browser/render.ts +5 -2
  133. package/src/tools/browser/tab-worker.ts +211 -91
  134. package/src/tools/debug.ts +5 -2
  135. package/src/tools/eval-render.ts +6 -3
  136. package/src/tools/eval.ts +1 -1
  137. package/src/tools/gh-renderer.ts +29 -15
  138. package/src/tools/index.ts +32 -4
  139. package/src/tools/inspect-image-renderer.ts +12 -5
  140. package/src/tools/job.ts +9 -6
  141. package/src/tools/memory-render.ts +19 -5
  142. package/src/tools/read.ts +165 -18
  143. package/src/tools/render-utils.ts +3 -1
  144. package/src/tools/resolve.ts +1 -1
  145. package/src/tools/review.ts +1 -1
  146. package/src/tools/ssh.ts +4 -1
  147. package/src/tools/todo.ts +8 -1
  148. package/src/tools/tool-timeouts.ts +1 -1
  149. package/src/tools/write.ts +1 -1
  150. package/src/tui/code-cell.ts +1 -1
  151. package/src/utils/block-context.ts +312 -0
  152. package/src/utils/git.ts +41 -0
  153. package/src/utils/image-loading.ts +31 -1
  154. package/src/web/search/providers/codex.ts +1 -1
  155. package/src/web/search/render.ts +14 -6
  156. package/dist/types/exa/factory.d.ts +0 -13
  157. package/dist/types/exa/render.d.ts +0 -19
  158. package/dist/types/exa/researcher.d.ts +0 -9
  159. package/dist/types/exa/search.d.ts +0 -9
  160. package/dist/types/exa/websets.d.ts +0 -9
  161. package/src/exa/factory.ts +0 -60
  162. package/src/exa/render.ts +0 -244
  163. package/src/exa/researcher.ts +0 -36
  164. package/src/exa/search.ts +0 -47
  165. 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.
@@ -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(oldContent: string, newContent: string, contextLines = 2): DiffResult {
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(oldContent: string, newContent: string, contextLines = 3): DiffResult {
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 ?? [])
@@ -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}.`