@oh-my-pi/pi-coding-agent 15.10.4 → 15.10.5

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 (141) hide show
  1. package/CHANGELOG.md +52 -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/export/ttsr.d.ts +14 -0
  19. package/dist/types/extensibility/extensions/types.d.ts +8 -1
  20. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
  21. package/dist/types/internal-urls/local-protocol.d.ts +10 -0
  22. package/dist/types/mcp/oauth-flow.d.ts +2 -2
  23. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  24. package/dist/types/modes/components/{status-line.d.ts → status-line/component.d.ts} +2 -32
  25. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  26. package/dist/types/modes/components/status-line/types.d.ts +31 -2
  27. package/dist/types/modes/image-references.d.ts +8 -3
  28. package/dist/types/modes/interactive-mode.d.ts +1 -1
  29. package/dist/types/modes/theme/theme.d.ts +2 -1
  30. package/dist/types/modes/types.d.ts +2 -1
  31. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  32. package/dist/types/session/agent-session.d.ts +0 -2
  33. package/dist/types/tools/ask.d.ts +1 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +15 -0
  35. package/dist/types/tools/index.d.ts +17 -0
  36. package/dist/types/tools/render-utils.d.ts +1 -1
  37. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  38. package/dist/types/utils/block-context.d.ts +35 -0
  39. package/dist/types/utils/image-loading.d.ts +12 -0
  40. package/package.json +29 -9
  41. package/src/capability/rule-buckets.ts +4 -2
  42. package/src/capability/rule.ts +10 -1
  43. package/src/cli/auth-broker-cli.ts +6 -7
  44. package/src/cli/auth-gateway-cli.ts +1 -1
  45. package/src/cli/list-models.ts +5 -0
  46. package/src/cli/update-cli.ts +138 -16
  47. package/src/config/model-registry.ts +81 -2
  48. package/src/debug/index.ts +4 -8
  49. package/src/discovery/at-imports.ts +273 -0
  50. package/src/discovery/builtin-rules/index.ts +4 -0
  51. package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
  52. package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
  53. package/src/discovery/helpers.ts +2 -1
  54. package/src/edit/diff.ts +114 -4
  55. package/src/edit/hashline/diff.ts +1 -1
  56. package/src/edit/hashline/execute.ts +1 -1
  57. package/src/edit/modes/patch.ts +6 -2
  58. package/src/edit/modes/replace.ts +1 -1
  59. package/src/edit/renderer.ts +12 -2
  60. package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
  61. package/src/eval/backend.ts +15 -0
  62. package/src/eval/js/context-manager.ts +4 -2
  63. package/src/eval/js/executor.ts +3 -0
  64. package/src/eval/js/index.ts +7 -1
  65. package/src/eval/js/shared/helpers.ts +53 -6
  66. package/src/eval/js/shared/runtime.ts +8 -0
  67. package/src/eval/js/worker-core.ts +1 -0
  68. package/src/eval/js/worker-protocol.ts +6 -0
  69. package/src/eval/py/executor.ts +12 -0
  70. package/src/eval/py/index.ts +7 -1
  71. package/src/eval/py/prelude.py +43 -4
  72. package/src/eval/py/runner.py +1 -0
  73. package/src/exa/render.ts +1 -1
  74. package/src/export/ttsr.ts +122 -1
  75. package/src/extensibility/extensions/types.ts +8 -1
  76. package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
  77. package/src/extensibility/plugins/doctor.ts +1 -1
  78. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -5
  79. package/src/goals/tools/goal-tool.ts +1 -1
  80. package/src/internal-urls/docs-index.generated.ts +6 -5
  81. package/src/internal-urls/local-protocol.ts +13 -0
  82. package/src/lsp/render.ts +8 -6
  83. package/src/mcp/oauth-flow.ts +3 -3
  84. package/src/mcp/render.ts +7 -1
  85. package/src/modes/components/custom-editor.ts +12 -6
  86. package/src/modes/components/login-dialog.ts +1 -1
  87. package/src/modes/components/oauth-selector.ts +4 -4
  88. package/src/modes/components/read-tool-group.ts +10 -3
  89. package/src/modes/components/{status-line.ts → status-line/component.ts} +18 -40
  90. package/src/modes/components/status-line/index.ts +1 -0
  91. package/src/modes/components/status-line/types.ts +23 -8
  92. package/src/modes/components/tool-execution.ts +1 -1
  93. package/src/modes/components/transcript-container.ts +17 -10
  94. package/src/modes/components/user-message.ts +6 -3
  95. package/src/modes/components/welcome.ts +1 -1
  96. package/src/modes/controllers/extension-ui-controller.ts +143 -127
  97. package/src/modes/controllers/input-controller.ts +36 -10
  98. package/src/modes/controllers/mcp-command-controller.ts +28 -12
  99. package/src/modes/controllers/selector-controller.ts +4 -11
  100. package/src/modes/controllers/ssh-command-controller.ts +2 -2
  101. package/src/modes/image-references.ts +13 -7
  102. package/src/modes/interactive-mode.ts +2 -2
  103. package/src/modes/rpc/rpc-mode.ts +1 -1
  104. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -11
  105. package/src/modes/theme/theme.ts +95 -1
  106. package/src/modes/types.ts +2 -1
  107. package/src/modes/utils/ui-helpers.ts +14 -5
  108. package/src/prompts/tools/bash.md +1 -1
  109. package/src/prompts/tools/eval.md +4 -4
  110. package/src/sdk.ts +31 -14
  111. package/src/session/agent-session.ts +213 -155
  112. package/src/session/session-manager.ts +1 -1
  113. package/src/slash-commands/builtin-registry.ts +1 -1
  114. package/src/system-prompt.ts +15 -9
  115. package/src/task/render.ts +20 -8
  116. package/src/tools/ask.ts +14 -5
  117. package/src/tools/bash-interactive.ts +1 -1
  118. package/src/tools/bash.ts +14 -2
  119. package/src/tools/browser/render.ts +5 -2
  120. package/src/tools/browser/tab-worker.ts +211 -91
  121. package/src/tools/debug.ts +5 -2
  122. package/src/tools/eval-render.ts +6 -3
  123. package/src/tools/eval.ts +1 -1
  124. package/src/tools/gh-renderer.ts +29 -15
  125. package/src/tools/index.ts +32 -0
  126. package/src/tools/inspect-image-renderer.ts +12 -5
  127. package/src/tools/job.ts +9 -6
  128. package/src/tools/memory-render.ts +19 -5
  129. package/src/tools/read.ts +165 -18
  130. package/src/tools/render-utils.ts +3 -1
  131. package/src/tools/resolve.ts +1 -1
  132. package/src/tools/review.ts +1 -1
  133. package/src/tools/ssh.ts +4 -1
  134. package/src/tools/todo.ts +8 -1
  135. package/src/tools/tool-timeouts.ts +1 -1
  136. package/src/tools/write.ts +1 -1
  137. package/src/tui/code-cell.ts +1 -1
  138. package/src/utils/block-context.ts +312 -0
  139. package/src/utils/image-loading.ts +31 -1
  140. package/src/web/search/providers/codex.ts +1 -1
  141. package/src/web/search/render.ts +14 -6
@@ -96,8 +96,8 @@ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [
96
96
  ];
97
97
 
98
98
  import type { ApiKeyResolver } from "@oh-my-pi/pi-ai";
99
- import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
100
- import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
99
+ import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
100
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
101
101
  import { isRecord, logger } from "@oh-my-pi/pi-utils";
102
102
  import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
103
103
  import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
@@ -922,6 +922,9 @@ export class ModelRegistry {
922
922
  #runtimeProviderOverrides: Map<string, ProviderOverride> = new Map();
923
923
  #runtimeProvidersBySource: Map<string, Set<string>> = new Map();
924
924
  #runtimeProviderSourceByName: Map<string, string> = new Map();
925
+ // Runtime model managers registered by extensions via fetchDynamicModels.
926
+ // Keyed by provider name; use the same SQLite cache path as builtins.
927
+ #runtimeModelManagers: Map<string, { options: ModelManagerOptions<Api>; sourceId: string }> = new Map();
925
928
  #rebuildPending: boolean = false;
926
929
  #rebuildSuspended: number = 0;
927
930
 
@@ -999,6 +1002,27 @@ export class ModelRegistry {
999
1002
  }
1000
1003
  }
1001
1004
 
1005
+ /**
1006
+ * Discover models for providers registered at runtime via `fetchDynamicModels`
1007
+ * (extension providers). Merges the discovered catalog into the existing model
1008
+ * set without reloading static models, so dynamically-discovered models from
1009
+ * other providers are preserved. No-op when no runtime providers are registered.
1010
+ *
1011
+ * Drives the same SQLite model cache as built-in providers, so the default
1012
+ * `online-if-uncached` strategy fetches at most once per cache TTL (24 h).
1013
+ */
1014
+ async refreshRuntimeProviders(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
1015
+ if (this.#runtimeModelManagers.size === 0) {
1016
+ return;
1017
+ }
1018
+ this.#suspendRebuild();
1019
+ try {
1020
+ await this.#refreshRuntimeDiscoveries(strategy, new Set(this.#runtimeModelManagers.keys()));
1021
+ } finally {
1022
+ this.#resumeRebuild();
1023
+ }
1024
+ }
1025
+
1002
1026
  #reloadStaticModels(): void {
1003
1027
  const currentMtime = this.#modelsConfigFile.getMtimeMs();
1004
1028
  if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) {
@@ -1665,6 +1689,10 @@ export class ModelRegistry {
1665
1689
  }
1666
1690
  options.push(descriptor.createOptions(key));
1667
1691
  }
1692
+ // Append runtime model managers registered by extensions via fetchDynamicModels.
1693
+ for (const { options: managerOpts } of this.#runtimeModelManagers.values()) {
1694
+ options.push(managerOpts);
1695
+ }
1668
1696
  return options;
1669
1697
  }
1670
1698
 
@@ -2396,6 +2424,7 @@ export class ModelRegistry {
2396
2424
  this.#runtimeProviderApiKeys.delete(providerName);
2397
2425
  this.#runtimeProviderOverrides.delete(providerName);
2398
2426
  this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(overlay => overlay.provider !== providerName);
2427
+ this.#runtimeModelManagers.delete(providerName);
2399
2428
  this.authStorage.removeConfigApiKey(providerName);
2400
2429
  }
2401
2430
 
@@ -2559,6 +2588,47 @@ export class ModelRegistry {
2559
2588
  return;
2560
2589
  }
2561
2590
 
2591
+ if (config.fetchDynamicModels) {
2592
+ const fetcher = config.fetchDynamicModels;
2593
+ const providerBaseUrl = config.baseUrl ?? "";
2594
+ const providerApi = config.api;
2595
+ const providerHeaders = config.headers;
2596
+ const providerApiKey = config.apiKey;
2597
+ const providerAuthHeader = config.authHeader;
2598
+ const providerCompat = config.compat;
2599
+ const managerOptions: ModelManagerOptions<Api> = {
2600
+ providerId: providerName as Parameters<typeof createModelManager>[0]["providerId"],
2601
+ staticModels: [],
2602
+ cacheDbPath: this.#cacheDbPath,
2603
+ cacheTtlMs: 24 * 60 * 60 * 1000,
2604
+ dynamicModelsAuthoritative: true,
2605
+ fetchDynamicModels: async () => {
2606
+ const apiKey = await this.authStorage.peekApiKey(providerName);
2607
+ const resolvedKey = isAuthenticated(apiKey) ? apiKey : undefined;
2608
+ const modelDefs = await fetcher(resolvedKey);
2609
+ const results: Model<Api>[] = [];
2610
+ for (const modelDef of modelDefs) {
2611
+ const overlay = buildCustomModelOverlay(
2612
+ providerName,
2613
+ modelDef.baseUrl ?? providerBaseUrl,
2614
+ modelDef.api ?? providerApi,
2615
+ providerHeaders,
2616
+ providerApiKey,
2617
+ providerAuthHeader,
2618
+ providerCompat,
2619
+ undefined,
2620
+ modelDef as CustomModelDefinitionLike,
2621
+ );
2622
+ if (overlay) results.push(finalizeCustomModel(overlay, { useDefaults: true }));
2623
+ }
2624
+ return results;
2625
+ },
2626
+ };
2627
+ this.#runtimeModelManagers.set(providerName, { options: managerOptions, sourceId: sourceId ?? "" });
2628
+ // Discovery is driven by refreshRuntimeProviders() after the drain — not
2629
+ // here, so registration has no network side effect and callers can await.
2630
+ }
2631
+
2562
2632
  if (
2563
2633
  config.baseUrl ||
2564
2634
  config.headers ||
@@ -2636,6 +2706,15 @@ export interface ProviderConfigInput {
2636
2706
  getApiKey?(credentials: OAuthCredentials): string;
2637
2707
  modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
2638
2708
  };
2709
+ /**
2710
+ * Async factory that fetches the live model list from the provider endpoint.
2711
+ * When present, the result is run through the same SQLite model-cache as
2712
+ * built-in providers (keyed by provider name, default 24 h TTL).
2713
+ * The factory receives the resolved API key (undefined when unauthenticated).
2714
+ */
2715
+ fetchDynamicModels?: (
2716
+ apiKey: string | undefined,
2717
+ ) => Promise<readonly NonNullable<ProviderConfigInput["models"]>[number][]>;
2639
2718
  models?: Array<{
2640
2719
  id: string;
2641
2720
  name: string;
@@ -204,7 +204,7 @@ export class DebugSelectorComponent extends Container {
204
204
  this.ctx.statusContainer.clear();
205
205
 
206
206
  const block = new TranscriptBlock();
207
- block.addChild(new Text(theme.fg("success", `${theme.status.success} Performance report saved`), 1, 0));
207
+ block.addChild(new Text(theme.fg("success", `+ Performance report saved`), 1, 0));
208
208
  block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
209
209
  block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
210
210
  this.ctx.present(block);
@@ -261,7 +261,7 @@ export class DebugSelectorComponent extends Container {
261
261
  this.ctx.statusContainer.clear();
262
262
 
263
263
  const block = new TranscriptBlock();
264
- block.addChild(new Text(theme.fg("success", `${theme.status.success} Report bundle saved`), 1, 0));
264
+ block.addChild(new Text(theme.fg("success", `+ Report bundle saved`), 1, 0));
265
265
  block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
266
266
  block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
267
267
  this.ctx.present(block);
@@ -298,7 +298,7 @@ export class DebugSelectorComponent extends Container {
298
298
  this.ctx.statusContainer.clear();
299
299
 
300
300
  const block = new TranscriptBlock();
301
- block.addChild(new Text(theme.fg("success", `${theme.status.success} Memory report saved`), 1, 0));
301
+ block.addChild(new Text(theme.fg("success", `+ Memory report saved`), 1, 0));
302
302
  block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
303
303
  block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
304
304
  this.ctx.present(block);
@@ -480,11 +480,7 @@ export class DebugSelectorComponent extends Container {
480
480
 
481
481
  this.ctx.present([
482
482
  new Spacer(1),
483
- new Text(
484
- theme.fg("success", `${theme.status.success} Cleared ${result.removed} artifact directories`),
485
- 1,
486
- 0,
487
- ),
483
+ new Text(theme.fg("success", `- Cleared ${result.removed} artifact directories`), 1, 0),
488
484
  ]);
489
485
  } catch (err) {
490
486
  loader.stop();
@@ -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,