@mrclrchtr/supi-code-intelligence 0.2.0 → 1.1.2
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/README.md +16 -5
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -5
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +1 -5
- package/node_modules/@mrclrchtr/supi-lsp/package.json +2 -6
- package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/stale-diagnostics.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/src/lsp.ts +11 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-stale-resync.ts +47 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/settings-registration.ts +1 -1
- package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +10 -30
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/runtime.ts +9 -0
- package/package.json +4 -9
- package/src/actions/affected-action.ts +153 -24
- package/src/actions/callees-action.ts +17 -0
- package/src/actions/callers-action.ts +178 -111
- package/src/actions/implementations-action.ts +18 -0
- package/src/actions/pattern-action.ts +167 -7
- package/src/brief-focused.ts +189 -9
- package/src/code-intelligence.ts +10 -2
- package/src/git-context.ts +11 -0
- package/src/guidance.ts +11 -8
- package/src/pattern-structured.ts +196 -0
- package/src/prioritization-signals.ts +188 -0
- package/src/resolve-target.ts +11 -3
- package/src/semantic-action-helpers.ts +28 -0
- package/src/target-resolution.ts +215 -0
- package/src/tool-actions.ts +8 -0
- package/src/types.ts +4 -0
package/src/brief-focused.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// Focused brief generation — directory and file briefs.
|
|
2
|
+
// biome-ignore-all lint/nursery/noExcessiveLinesPerFile: focused brief formatting and recursive directory summarization are kept together to share local helpers
|
|
2
3
|
|
|
3
4
|
import * as fs from "node:fs";
|
|
4
5
|
import * as path from "node:path";
|
|
5
6
|
import type { ArchitectureModel } from "./architecture.ts";
|
|
6
7
|
import { findModuleForPath, getDependencies, getDependents } from "./architecture.ts";
|
|
7
8
|
import { formatGitContext, gatherGitContext } from "./git-context.ts";
|
|
9
|
+
import {
|
|
10
|
+
appendPrioritySignalsSection,
|
|
11
|
+
summarizePrioritySignalsForFiles,
|
|
12
|
+
} from "./prioritization-signals.ts";
|
|
8
13
|
import type { BriefDetails, ConfidenceMode } from "./types.ts";
|
|
9
14
|
|
|
10
15
|
/**
|
|
@@ -55,7 +60,15 @@ function generateDirectoryBrief(
|
|
|
55
60
|
if (mod && mod.root === resolvedPath) {
|
|
56
61
|
formatModuleBrief({ mod, model, lines, startHere, publicSurfaces, nextQueries, resolvedPath });
|
|
57
62
|
} else {
|
|
58
|
-
formatNonModuleDir({
|
|
63
|
+
formatNonModuleDir({
|
|
64
|
+
model,
|
|
65
|
+
mod,
|
|
66
|
+
resolvedPath,
|
|
67
|
+
originalPath,
|
|
68
|
+
lines,
|
|
69
|
+
publicSurfaces,
|
|
70
|
+
nextQueries,
|
|
71
|
+
});
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
if (nextQueries.length > 0) {
|
|
@@ -66,6 +79,12 @@ function generateDirectoryBrief(
|
|
|
66
79
|
}
|
|
67
80
|
}
|
|
68
81
|
|
|
82
|
+
const prioritySignals = summarizePrioritySignalsForFiles(
|
|
83
|
+
model.root,
|
|
84
|
+
summarizeDirectoryRecursively(resolvedPath).allFiles,
|
|
85
|
+
);
|
|
86
|
+
appendPrioritySignalsSection(lines, prioritySignals);
|
|
87
|
+
|
|
69
88
|
const gitCtx = gatherGitContext(model.root);
|
|
70
89
|
if (gitCtx) {
|
|
71
90
|
lines.push(formatGitContext(gitCtx));
|
|
@@ -83,6 +102,7 @@ function generateDirectoryBrief(
|
|
|
83
102
|
dependencySummary: mod ? { moduleCount: 1, edgeCount: mod.internalDeps.length } : null,
|
|
84
103
|
omittedCount: 0,
|
|
85
104
|
nextQueries,
|
|
105
|
+
prioritySignals,
|
|
86
106
|
},
|
|
87
107
|
};
|
|
88
108
|
}
|
|
@@ -195,10 +215,13 @@ interface NonModuleDirContext {
|
|
|
195
215
|
resolvedPath: string;
|
|
196
216
|
originalPath: string;
|
|
197
217
|
lines: string[];
|
|
218
|
+
publicSurfaces: string[];
|
|
219
|
+
nextQueries: string[];
|
|
198
220
|
}
|
|
199
221
|
|
|
222
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: nested-directory brief formatting is clearer as one staged formatter than as many tiny helpers
|
|
200
223
|
function formatNonModuleDir(ctx: NonModuleDirContext): void {
|
|
201
|
-
const { model, mod, resolvedPath, originalPath, lines } = ctx;
|
|
224
|
+
const { model, mod, resolvedPath, originalPath, lines, publicSurfaces, nextQueries } = ctx;
|
|
202
225
|
const relPath = path.relative(model.root, resolvedPath);
|
|
203
226
|
lines.push(`# Directory: ${relPath || originalPath}`);
|
|
204
227
|
lines.push("");
|
|
@@ -209,17 +232,55 @@ function formatNonModuleDir(ctx: NonModuleDirContext): void {
|
|
|
209
232
|
lines.push("");
|
|
210
233
|
}
|
|
211
234
|
|
|
212
|
-
const
|
|
213
|
-
if (
|
|
214
|
-
const shown = files.slice(0, 10);
|
|
235
|
+
const summary = summarizeDirectoryRecursively(resolvedPath);
|
|
236
|
+
if (summary.directFiles.length > 0) {
|
|
215
237
|
lines.push("## Source Files");
|
|
216
|
-
for (const f of
|
|
238
|
+
for (const f of summary.directFiles.slice(0, 10)) {
|
|
217
239
|
lines.push(`- \`${f}\``);
|
|
218
240
|
}
|
|
219
|
-
if (
|
|
220
|
-
lines.push(`- _+${
|
|
241
|
+
if (summary.directFiles.length > 10) {
|
|
242
|
+
lines.push(`- _+${summary.directFiles.length - 10} more files_`);
|
|
221
243
|
}
|
|
222
|
-
|
|
244
|
+
lines.push("");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (summary.totalSourceFiles > 0) {
|
|
248
|
+
lines.push("## Descendant Source Files");
|
|
249
|
+
lines.push(`- Total: ${summary.totalSourceFiles}`);
|
|
250
|
+
for (const subdir of summary.subdirs.slice(0, 8)) {
|
|
251
|
+
lines.push(
|
|
252
|
+
`- \`${subdir.name}/\` — ${subdir.fileCount} file${subdir.fileCount !== 1 ? "s" : ""}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (summary.subdirs.length > 8) {
|
|
256
|
+
lines.push(`- _+${summary.subdirs.length - 8} more subdirectories_`);
|
|
257
|
+
}
|
|
258
|
+
lines.push("");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (summary.publicSurfaces.length > 0) {
|
|
262
|
+
lines.push("## Public Surfaces");
|
|
263
|
+
for (const surface of summary.publicSurfaces.slice(0, 8)) {
|
|
264
|
+
lines.push(`- ${surface}`);
|
|
265
|
+
publicSurfaces.push(surface);
|
|
266
|
+
}
|
|
267
|
+
if (summary.publicSurfaces.length > 8) {
|
|
268
|
+
lines.push(`- _+${summary.publicSurfaces.length - 8} more exports_`);
|
|
269
|
+
}
|
|
270
|
+
lines.push("");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (summary.totalSourceFiles > 0) {
|
|
274
|
+
lines.push("## Import / Export Summary");
|
|
275
|
+
lines.push(`- Imports: ${summary.importCount}`);
|
|
276
|
+
lines.push(`- Exports: ${summary.exportCount}`);
|
|
277
|
+
lines.push("");
|
|
278
|
+
nextQueries.push(
|
|
279
|
+
`\`code_intel pattern\` with \`path: "${relPath || originalPath}"\` to inspect a specific nested symbol`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (summary.totalSourceFiles === 0) {
|
|
223
284
|
lines.push("No recognized source files in this directory.");
|
|
224
285
|
}
|
|
225
286
|
}
|
|
@@ -266,6 +327,12 @@ function generateFileBrief(
|
|
|
266
327
|
lines.push("_Could not read file contents._");
|
|
267
328
|
}
|
|
268
329
|
|
|
330
|
+
const prioritySignals = summarizePrioritySignalsForFiles(model.root, [resolvedPath]);
|
|
331
|
+
if (prioritySignals) {
|
|
332
|
+
lines.push("");
|
|
333
|
+
appendPrioritySignalsSection(lines, prioritySignals);
|
|
334
|
+
}
|
|
335
|
+
|
|
269
336
|
nextQueries.push(
|
|
270
337
|
`\`code_intel callers\` with \`file: "${relPath}"\` and a line/character for call-site analysis`,
|
|
271
338
|
);
|
|
@@ -300,6 +367,7 @@ function generateFileBrief(
|
|
|
300
367
|
dependencySummary: null,
|
|
301
368
|
omittedCount: 0,
|
|
302
369
|
nextQueries,
|
|
370
|
+
prioritySignals,
|
|
303
371
|
},
|
|
304
372
|
};
|
|
305
373
|
}
|
|
@@ -365,6 +433,16 @@ const SOURCE_EXTENSIONS = new Set([
|
|
|
365
433
|
".sql",
|
|
366
434
|
]);
|
|
367
435
|
|
|
436
|
+
interface RecursiveDirectorySummary {
|
|
437
|
+
directFiles: string[];
|
|
438
|
+
allFiles: string[];
|
|
439
|
+
totalSourceFiles: number;
|
|
440
|
+
subdirs: Array<{ name: string; fileCount: number }>;
|
|
441
|
+
publicSurfaces: string[];
|
|
442
|
+
importCount: number;
|
|
443
|
+
exportCount: number;
|
|
444
|
+
}
|
|
445
|
+
|
|
368
446
|
function listSourceFiles(dir: string): string[] {
|
|
369
447
|
const files: string[] = [];
|
|
370
448
|
try {
|
|
@@ -381,3 +459,105 @@ function listSourceFiles(dir: string): string[] {
|
|
|
381
459
|
}
|
|
382
460
|
return files.sort((a, b) => a.localeCompare(b));
|
|
383
461
|
}
|
|
462
|
+
|
|
463
|
+
function summarizeDirectoryRecursively(dir: string): RecursiveDirectorySummary {
|
|
464
|
+
const directFiles = listSourceFiles(dir);
|
|
465
|
+
const summary: RecursiveDirectorySummary = {
|
|
466
|
+
directFiles,
|
|
467
|
+
allFiles: directFiles.map((file) => path.join(dir, file)),
|
|
468
|
+
totalSourceFiles: directFiles.length,
|
|
469
|
+
subdirs: [],
|
|
470
|
+
publicSurfaces: [],
|
|
471
|
+
importCount: 0,
|
|
472
|
+
exportCount: 0,
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
accumulateJsTsSignals(summary, dir, directFiles, "");
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
479
|
+
for (const entry of entries) {
|
|
480
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
481
|
+
const childDir = path.join(dir, entry.name);
|
|
482
|
+
const childSummary = summarizeDirectoryRecursively(childDir);
|
|
483
|
+
if (childSummary.totalSourceFiles === 0) continue;
|
|
484
|
+
summary.totalSourceFiles += childSummary.totalSourceFiles;
|
|
485
|
+
summary.allFiles.push(...childSummary.allFiles);
|
|
486
|
+
summary.subdirs.push({ name: entry.name, fileCount: childSummary.totalSourceFiles });
|
|
487
|
+
summary.publicSurfaces.push(
|
|
488
|
+
...childSummary.publicSurfaces.map((surface) => `${entry.name}/${surface}`),
|
|
489
|
+
);
|
|
490
|
+
summary.importCount += childSummary.importCount;
|
|
491
|
+
summary.exportCount += childSummary.exportCount;
|
|
492
|
+
}
|
|
493
|
+
} catch {
|
|
494
|
+
// Directory not readable
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
summary.subdirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
498
|
+
return summary;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function accumulateJsTsSignals(
|
|
502
|
+
summary: RecursiveDirectorySummary,
|
|
503
|
+
dir: string,
|
|
504
|
+
files: string[],
|
|
505
|
+
prefix: string,
|
|
506
|
+
): void {
|
|
507
|
+
for (const file of files) {
|
|
508
|
+
if (!isJsTsFile(file)) continue;
|
|
509
|
+
try {
|
|
510
|
+
const relFile = prefix ? `${prefix}/${file}` : file;
|
|
511
|
+
const content = fs.readFileSync(path.join(dir, file), "utf-8");
|
|
512
|
+
summary.importCount += countMatches(content, /^import\s/gm);
|
|
513
|
+
const exports = extractNamedExports(content);
|
|
514
|
+
summary.exportCount += exports.length;
|
|
515
|
+
for (const exportedName of exports) {
|
|
516
|
+
summary.publicSurfaces.push(`\`${exportedName}\` — \`${relFile}\``);
|
|
517
|
+
}
|
|
518
|
+
} catch {
|
|
519
|
+
// File not readable
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function isJsTsFile(file: string): boolean {
|
|
525
|
+
return [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"].includes(
|
|
526
|
+
path.extname(file),
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Lightweight exported-name extraction for recursive directory briefs.
|
|
532
|
+
*
|
|
533
|
+
* Best-effort only: handles exported declarations and simple named export lists,
|
|
534
|
+
* but intentionally does not try to cover default exports, export-all forms,
|
|
535
|
+
* re-exported defaults, or every multiline formatting variant.
|
|
536
|
+
*/
|
|
537
|
+
function extractNamedExports(content: string): string[] {
|
|
538
|
+
const names = new Set<string>();
|
|
539
|
+
const declarationRegex =
|
|
540
|
+
/export\s+(?:async\s+)?(?:function|class|interface|type|const|let|var|enum)\s+([A-Za-z_$][\w$]*)/g;
|
|
541
|
+
const namedExportRegex = /export\s*\{\s*([^}]+)\s*\}/g;
|
|
542
|
+
|
|
543
|
+
for (const match of content.matchAll(declarationRegex)) {
|
|
544
|
+
if (match[1]) names.add(match[1]);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
for (const match of content.matchAll(namedExportRegex)) {
|
|
548
|
+
const parts = match[1]?.split(",") ?? [];
|
|
549
|
+
for (const part of parts) {
|
|
550
|
+
const candidate = part
|
|
551
|
+
.trim()
|
|
552
|
+
.split(/\s+as\s+/i)[0]
|
|
553
|
+
?.trim();
|
|
554
|
+
if (candidate) names.add(candidate);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return [...names].sort((a, b) => a.localeCompare(b));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function countMatches(content: string, regex: RegExp): number {
|
|
562
|
+
return [...content.matchAll(regex)].length;
|
|
563
|
+
}
|
package/src/code-intelligence.ts
CHANGED
|
@@ -73,7 +73,10 @@ export default function codeIntelligenceExtension(pi: ExtensionAPI) {
|
|
|
73
73
|
Type.String({ description: "Scope or focus path (package, directory, or file)" }),
|
|
74
74
|
),
|
|
75
75
|
file: Type.Optional(
|
|
76
|
-
Type.String({
|
|
76
|
+
Type.String({
|
|
77
|
+
description:
|
|
78
|
+
"Anchored target file (use with line/character) or a file-level semantic target for brief/callers/affected",
|
|
79
|
+
}),
|
|
77
80
|
),
|
|
78
81
|
line: Type.Optional(Type.Number({ description: "1-based line number for anchored target" })),
|
|
79
82
|
character: Type.Optional(
|
|
@@ -92,7 +95,12 @@ export default function codeIntelligenceExtension(pi: ExtensionAPI) {
|
|
|
92
95
|
description: "Use regex semantics for pattern action (default: false, literal search)",
|
|
93
96
|
}),
|
|
94
97
|
),
|
|
95
|
-
kind: Type.Optional(
|
|
98
|
+
kind: Type.Optional(
|
|
99
|
+
Type.String({
|
|
100
|
+
description:
|
|
101
|
+
"Symbol kind filter for discovery, or pattern kind (`definition` | `export` | `import`) for structured searches",
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
96
104
|
exportedOnly: Type.Optional(
|
|
97
105
|
Type.Boolean({ description: "Limit discovery to exported symbols" }),
|
|
98
106
|
),
|
package/src/git-context.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
|
|
3
|
+
function scrubGitEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
4
|
+
const next = { ...env };
|
|
5
|
+
for (const key of Object.keys(next)) {
|
|
6
|
+
if (key.startsWith("GIT_")) {
|
|
7
|
+
delete next[key];
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return next;
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
function execGit(cwd: string, args: string[]): string {
|
|
4
14
|
return execFileSync("git", args, {
|
|
5
15
|
cwd,
|
|
6
16
|
encoding: "utf-8",
|
|
17
|
+
env: scrubGitEnv(process.env),
|
|
7
18
|
stdio: ["ignore", "pipe", "ignore"],
|
|
8
19
|
timeout: 5000,
|
|
9
20
|
});
|
package/src/guidance.ts
CHANGED
|
@@ -4,11 +4,11 @@ export const toolDescription = `Code intelligence tool — architecture briefs,
|
|
|
4
4
|
|
|
5
5
|
Actions:
|
|
6
6
|
- brief: Architecture overview or focused brief for a project, package, directory, file, or anchored symbol
|
|
7
|
-
- callers: Find call sites for a symbol
|
|
7
|
+
- callers: Find call sites for a symbol, or analyze a file-level export surface when only \`file\` is provided
|
|
8
8
|
- callees: Best-effort outgoing calls from a symbol (structural tree-sitter analysis across supported grammars)
|
|
9
9
|
- implementations: Find concrete implementations of an interface or abstract type
|
|
10
|
-
- affected: Blast-radius analysis — direct references, downstream dependents, risk level, likely tests
|
|
11
|
-
- pattern: Bounded text search with grouped matches, context lines,
|
|
10
|
+
- affected: Blast-radius analysis for a symbol or exported file surface — direct references, downstream dependents, risk level, likely tests
|
|
11
|
+
- pattern: Bounded text search with grouped matches, context lines, structured \`kind\` filters (\`definition\` | \`export\` | \`import\`), partial-result warnings on oversized structured scans, and regex opt-in via \`regex: true\`
|
|
12
12
|
- index: Factual project map — file counts by language, top-level directory tree, landmark config files
|
|
13
13
|
|
|
14
14
|
Coordinates are 1-based (line, character) with UTF-16 character columns, matching lsp and tree_sitter conventions.
|
|
@@ -18,21 +18,24 @@ Examples:
|
|
|
18
18
|
{ "action": "brief" }
|
|
19
19
|
{ "action": "brief", "path": "packages/supi-lsp/" }
|
|
20
20
|
{ "action": "brief", "file": "packages/supi-lsp/lsp.ts", "line": 42, "character": 7 }
|
|
21
|
+
{ "action": "callers", "file": "packages/supi-core/index.ts" }
|
|
21
22
|
{ "action": "callers", "symbol": "registerSettings", "path": "packages/supi-core/" }
|
|
22
23
|
{ "action": "callees", "file": "src/handler.ts", "line": 88, "character": 12 }
|
|
23
24
|
{ "action": "implementations", "symbol": "SessionLspService", "path": "packages/" }
|
|
24
|
-
{ "action": "affected", "file": "packages/supi-core/index.ts"
|
|
25
|
+
{ "action": "affected", "file": "packages/supi-core/index.ts" }
|
|
25
26
|
{ "action": "pattern", "pattern": "registerSettings", "path": "packages/", "maxResults": 10 }
|
|
26
|
-
{ "action": "pattern", "pattern": "register(Settings|Config)", "path": "packages/", "regex": true, "maxResults": 10 }
|
|
27
|
+
{ "action": "pattern", "pattern": "register(Settings|Config)", "path": "packages/", "regex": true, "maxResults": 10 }
|
|
28
|
+
{ "action": "pattern", "pattern": "payment", "kind": "definition", "path": "src/" }`;
|
|
27
29
|
|
|
28
30
|
export const promptSnippet =
|
|
29
31
|
"Use the code_intel tool for architecture orientation, semantic relationships, impact analysis, and structured search before broad file reads.";
|
|
30
32
|
|
|
31
33
|
export const promptGuidelines = [
|
|
32
34
|
"Use `code_intel brief` before editing an unfamiliar package, directory, or file to get architecture context and reduce blind reads.",
|
|
33
|
-
"Use `code_intel affected` before changing exported APIs, shared helpers, config surfaces, or cross-package contracts to check blast radius and risk.",
|
|
34
|
-
"Use `code_intel callers` before modifying a function to verify all call sites; use `callees` and `implementations` for dependency and interface analysis.",
|
|
35
|
-
|
|
35
|
+
"Use `code_intel affected` before changing exported APIs, shared helpers, config surfaces, or cross-package contracts to check blast radius and risk; file-only requests now expand across exported targets when possible.",
|
|
36
|
+
"Use `code_intel callers` before modifying a function to verify all call sites; use `callees` and `implementations` for dependency and interface analysis, and use file-only `callers` when you need the export surface of a module.",
|
|
37
|
+
'Use `code_intel pattern` for bounded, scope-aware text search when the question is textual rather than semantic; it treats patterns as literal strings by default, supports `regex: true`, supports `kind: "definition" | "export" | "import"` for structured searches, and may return a partial-result warning when a structured scan is too broad.',
|
|
38
|
+
"Use `code_intel brief` and `code_intel affected` priority signals to notice diagnostics, low coverage, or unused-code hints before editing risky files.",
|
|
36
39
|
"Use `code_intel index` for a factual project map (file counts, directory structure, landmark files) when you need to orient yourself in a new codebase.",
|
|
37
40
|
"After `code_intel` narrows the target, use raw `lsp` and `tree_sitter` tools for precise drill-down on exact symbols, types, or AST nodes.",
|
|
38
41
|
"Do not prefer `code_intel` over direct file reads or lower-level tools for trivial, already-localized edits or exact symbol/AST drill-down tasks.",
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { createTreeSitterSession } from "@mrclrchtr/supi-tree-sitter";
|
|
4
|
+
import type { ActionParams } from "./tool-actions.ts";
|
|
5
|
+
|
|
6
|
+
export const STRUCTURED_PATTERN_FILE_CAP = 200;
|
|
7
|
+
const STRUCTURED_PATTERN_TIMEOUT_MS = 10_000;
|
|
8
|
+
|
|
9
|
+
export type StructuredPatternKind = "definition" | "export" | "import";
|
|
10
|
+
|
|
11
|
+
export interface StructuredMatch {
|
|
12
|
+
file: string;
|
|
13
|
+
name: string;
|
|
14
|
+
kind: string;
|
|
15
|
+
line: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StructuredPatternResult {
|
|
19
|
+
matches: StructuredMatch[];
|
|
20
|
+
omittedCount: number;
|
|
21
|
+
partialReason: "file-cap" | "timeout" | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isStructuredPatternKind(kind: string | undefined): kind is StructuredPatternKind {
|
|
25
|
+
return kind === "definition" || kind === "export" || kind === "import";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getStructuredPatternMatches(
|
|
29
|
+
params: ActionParams & { pattern: string; kind: StructuredPatternKind },
|
|
30
|
+
scopePath: string,
|
|
31
|
+
cwd: string,
|
|
32
|
+
relScope: string,
|
|
33
|
+
): Promise<StructuredPatternResult | string | null> {
|
|
34
|
+
const deadline = Date.now() + STRUCTURED_PATTERN_TIMEOUT_MS;
|
|
35
|
+
const collected = collectStructuredFiles(scopePath, deadline);
|
|
36
|
+
if (collected.files.length === 0) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const matcher = createStructuredMatcher(params.pattern, params.regex ?? false);
|
|
41
|
+
if (typeof matcher === "string") {
|
|
42
|
+
return matcher;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let tsSession: ReturnType<typeof createTreeSitterSession> | null = null;
|
|
46
|
+
try {
|
|
47
|
+
tsSession = createTreeSitterSession(cwd);
|
|
48
|
+
const matches: StructuredMatch[] = [];
|
|
49
|
+
let timedOut = collected.timedOut;
|
|
50
|
+
|
|
51
|
+
for (const [index, file] of collected.files.entries()) {
|
|
52
|
+
if (Date.now() > deadline) {
|
|
53
|
+
collected.omittedCount += collected.files.length - index;
|
|
54
|
+
timedOut = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
const relFile = path.relative(cwd, file);
|
|
58
|
+
await collectMatchesForFile(matches, tsSession, relFile, params.kind, matcher);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
matches,
|
|
63
|
+
omittedCount: timedOut ? Math.max(1, collected.omittedCount) : collected.omittedCount,
|
|
64
|
+
partialReason: timedOut ? "timeout" : collected.omittedCount > 0 ? "file-cap" : null,
|
|
65
|
+
};
|
|
66
|
+
} catch {
|
|
67
|
+
return `No structured ${params.kind} search data available in \`${relScope}\`. Try omitting \`kind\` for plain text search.`;
|
|
68
|
+
} finally {
|
|
69
|
+
tsSession?.dispose();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// biome-ignore lint/complexity/useMaxParams: helper takes explicit collection inputs to avoid intermediate objects in the hot path
|
|
74
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: kind-specific tree-sitter matching is clearest as one helper
|
|
75
|
+
async function collectMatchesForFile(
|
|
76
|
+
matches: StructuredMatch[],
|
|
77
|
+
tsSession: ReturnType<typeof createTreeSitterSession>,
|
|
78
|
+
relFile: string,
|
|
79
|
+
kind: StructuredPatternKind,
|
|
80
|
+
matcher: (value: string) => boolean,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
if (kind === "definition") {
|
|
83
|
+
const outline = await tsSession.outline(relFile);
|
|
84
|
+
if (outline.kind !== "success") return;
|
|
85
|
+
for (const item of outline.data) {
|
|
86
|
+
if (!matcher(item.name)) continue;
|
|
87
|
+
matches.push({ file: relFile, name: item.name, kind: item.kind, line: item.range.startLine });
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (kind === "export") {
|
|
93
|
+
const exportsResult = await tsSession.exports(relFile);
|
|
94
|
+
if (exportsResult.kind !== "success") return;
|
|
95
|
+
for (const item of exportsResult.data) {
|
|
96
|
+
if (!matcher(item.name)) continue;
|
|
97
|
+
matches.push({ file: relFile, name: item.name, kind: item.kind, line: item.range.startLine });
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const importsResult = await tsSession.imports(relFile);
|
|
103
|
+
if (importsResult.kind !== "success") return;
|
|
104
|
+
for (const item of importsResult.data) {
|
|
105
|
+
if (!matcher(item.moduleSpecifier)) continue;
|
|
106
|
+
matches.push({
|
|
107
|
+
file: relFile,
|
|
108
|
+
name: item.moduleSpecifier,
|
|
109
|
+
kind: "import",
|
|
110
|
+
line: item.range.startLine,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function collectStructuredFiles(
|
|
116
|
+
scopePath: string,
|
|
117
|
+
deadline: number,
|
|
118
|
+
): { files: string[]; omittedCount: number; timedOut: boolean } {
|
|
119
|
+
const files: string[] = [];
|
|
120
|
+
let omittedCount = 0;
|
|
121
|
+
let timedOut = false;
|
|
122
|
+
const skipDirs = new Set(["node_modules", ".git", "dist", "build", "coverage"]);
|
|
123
|
+
|
|
124
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: timeout, file, and directory branches stay adjacent for predictable short-circuiting
|
|
125
|
+
function walk(currentPath: string) {
|
|
126
|
+
if (timedOut) return;
|
|
127
|
+
if (Date.now() > deadline) {
|
|
128
|
+
timedOut = true;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let stat: fs.Stats;
|
|
133
|
+
try {
|
|
134
|
+
stat = fs.statSync(currentPath);
|
|
135
|
+
} catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (stat.isFile()) {
|
|
140
|
+
if (isStructuredFile(currentPath)) {
|
|
141
|
+
if (files.length < STRUCTURED_PATTERN_FILE_CAP) {
|
|
142
|
+
files.push(currentPath);
|
|
143
|
+
} else {
|
|
144
|
+
omittedCount++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let entries: fs.Dirent[];
|
|
151
|
+
try {
|
|
152
|
+
entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
153
|
+
} catch {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
if (entry.name.startsWith(".")) continue;
|
|
159
|
+
if (entry.isDirectory() && skipDirs.has(entry.name)) continue;
|
|
160
|
+
walk(path.join(currentPath, entry.name));
|
|
161
|
+
if (timedOut) return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
walk(scopePath);
|
|
166
|
+
return { files: files.sort((a, b) => a.localeCompare(b)), omittedCount, timedOut };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isStructuredFile(file: string): boolean {
|
|
170
|
+
return [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"].includes(
|
|
171
|
+
path.extname(file),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function createStructuredMatcher(
|
|
176
|
+
pattern: string,
|
|
177
|
+
regex: boolean,
|
|
178
|
+
): ((value: string) => boolean) | string {
|
|
179
|
+
const ignoreCase = !/[A-Z]/.test(pattern);
|
|
180
|
+
|
|
181
|
+
if (regex) {
|
|
182
|
+
try {
|
|
183
|
+
const compiled = new RegExp(pattern, ignoreCase ? "i" : undefined);
|
|
184
|
+
return (value: string) => compiled.test(value);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const message = error instanceof Error ? error.message : "Invalid regex";
|
|
187
|
+
return `**Error:** Invalid regex pattern \`${pattern}\`: ${message}`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const needle = ignoreCase ? pattern.toLowerCase() : pattern;
|
|
192
|
+
return (value: string) => {
|
|
193
|
+
const haystack = ignoreCase ? value.toLowerCase() : value;
|
|
194
|
+
return haystack.includes(needle);
|
|
195
|
+
};
|
|
196
|
+
}
|