@oh-my-pi/pi-coding-agent 15.10.3 → 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.
- package/CHANGELOG.md +72 -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/__tests__/js-context-manager.test.d.ts +1 -0
- package/dist/types/eval/backend.d.ts +7 -0
- package/dist/types/eval/bridge-timeout.d.ts +1 -1
- package/dist/types/eval/{llm-bridge.d.ts → completion-bridge.d.ts} +8 -8
- package/dist/types/eval/idle-timeout.d.ts +1 -1
- 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/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/image-references.d.ts +8 -3
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -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/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 -0
- 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/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 +1 -1
- package/src/cli/list-models.ts +5 -0
- package/src/cli/update-cli.ts +138 -16
- 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__/agent-bridge.test.ts +13 -0
- package/src/eval/__tests__/{llm-bridge.test.ts → completion-bridge.test.ts} +60 -54
- package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
- package/src/eval/__tests__/js-context-manager.test.ts +241 -0
- package/src/eval/agent-bridge.ts +6 -1
- package/src/eval/backend.ts +15 -0
- package/src/eval/bridge-timeout.ts +1 -1
- package/src/eval/{llm-bridge.ts → completion-bridge.ts} +30 -27
- package/src/eval/idle-timeout.ts +1 -1
- package/src/eval/js/context-manager.ts +70 -8
- 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/prelude.txt +4 -4
- package/src/eval/js/shared/runtime.ts +8 -0
- package/src/eval/js/tool-bridge.ts +3 -3
- package/src/eval/js/worker-core.ts +1 -0
- package/src/eval/js/worker-entry.ts +6 -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 +46 -7
- package/src/eval/py/runner.py +1 -0
- package/src/exa/render.ts +1 -1
- 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 +8 -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/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/tips.txt +1 -1
- 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/extension-ui-controller.ts +143 -127
- package/src/modes/controllers/input-controller.ts +36 -10
- package/src/modes/controllers/mcp-command-controller.ts +28 -12
- 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 +2 -2
- 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 +2 -1
- package/src/modes/utils/ui-helpers.ts +14 -5
- package/src/prompts/system/tiny-title-system.md +1 -1
- package/src/prompts/system/title-system.md +16 -3
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/eval.md +6 -6
- package/src/sdk.ts +31 -14
- package/src/session/agent-session.ts +213 -155
- package/src/session/session-manager.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/system-prompt.ts +15 -9
- package/src/task/render.ts +20 -8
- 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 +8 -5
- package/src/tools/eval.ts +2 -2
- package/src/tools/gh-renderer.ts +29 -15
- package/src/tools/index.ts +32 -0
- 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/image-loading.ts +31 -1
- package/src/utils/title-generator.ts +2 -2
- package/src/web/search/providers/codex.ts +1 -1
- package/src/web/search/render.ts +14 -6
- /package/dist/types/eval/__tests__/{llm-bridge.test.d.ts → completion-bridge.test.d.ts} +0 -0
|
@@ -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/
|
|
100
|
-
import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/
|
|
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;
|
package/src/debug/index.ts
CHANGED
|
@@ -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",
|
|
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",
|
|
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",
|
|
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.
|
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,
|