@shanepadgett/tau-agent 0.26.0 → 0.28.0
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/docs/subagents.md +1 -1
- package/extensions/appshot/README.md +1 -1
- package/extensions/appshot/index.ts +5 -11
- package/extensions/context/sync.ts +1 -9
- package/extensions/context-pruning/README.md +2 -2
- package/extensions/context-pruning/index.ts +58 -57
- package/extensions/context-pruning/prune.ts +2 -2
- package/extensions/context-pruning/render.ts +51 -39
- package/extensions/context-pruning/settings.ts +4 -5
- package/extensions/explore/README.md +13 -14
- package/extensions/explore/ast/adapter.ts +97 -0
- package/extensions/explore/ast/engine.ts +470 -0
- package/extensions/explore/ast/format/ast-search.ts +97 -0
- package/extensions/explore/ast/format/composite.ts +26 -0
- package/extensions/explore/ast/format/context.ts +66 -0
- package/extensions/explore/ast/format/deps.ts +86 -0
- package/extensions/explore/ast/format/discover.ts +85 -0
- package/extensions/explore/ast/format/impact.ts +105 -0
- package/extensions/explore/ast/format/outline.ts +66 -0
- package/extensions/explore/ast/format/relationships.ts +81 -0
- package/extensions/explore/ast/format/show.ts +38 -0
- package/extensions/explore/ast/grammars/kotlin.wasm +0 -0
- package/extensions/explore/ast/grammars/manifest.json +41 -0
- package/extensions/explore/ast/grammars/manifest.ts +99 -0
- package/extensions/explore/ast/grammars/odin.wasm +0 -0
- package/extensions/explore/ast/grammars/swift.wasm +0 -0
- package/extensions/explore/ast/graph/file-graph.ts +360 -0
- package/extensions/explore/ast/graph/relationships.ts +597 -0
- package/extensions/explore/ast/identity.ts +119 -0
- package/extensions/explore/ast/ir.ts +130 -0
- package/extensions/explore/ast/languages/csharp-file-deps.ts +189 -0
- package/extensions/explore/ast/languages/csharp.ts +493 -0
- package/extensions/explore/ast/languages/file-dep-util.ts +120 -0
- package/extensions/explore/ast/languages/fixtures/sample.cs +17 -0
- package/extensions/explore/ast/languages/fixtures/sample.java +21 -0
- package/extensions/explore/ast/languages/fixtures/sample.kt +15 -0
- package/extensions/explore/ast/languages/fixtures/sample.odin +28 -0
- package/extensions/explore/ast/languages/fixtures/sample.rs +31 -0
- package/extensions/explore/ast/languages/fixtures/sample.swift +25 -0
- package/extensions/explore/ast/languages/go-file-deps.ts +139 -0
- package/extensions/explore/ast/languages/go.ts +545 -0
- package/extensions/explore/ast/languages/java.ts +412 -0
- package/extensions/explore/ast/languages/jvm-file-deps.ts +217 -0
- package/extensions/explore/ast/languages/kotlin.ts +611 -0
- package/extensions/explore/ast/languages/odin-file-deps.ts +175 -0
- package/extensions/explore/ast/languages/odin.ts +407 -0
- package/extensions/explore/ast/languages/rust-file-deps.ts +159 -0
- package/extensions/explore/ast/languages/rust.ts +506 -0
- package/extensions/explore/ast/languages/swift-file-deps.ts +59 -0
- package/extensions/explore/ast/languages/swift.ts +466 -0
- package/extensions/explore/ast/languages/tree.ts +220 -0
- package/extensions/explore/ast/languages/typescript-file-deps.ts +259 -0
- package/extensions/explore/ast/languages/typescript-package-surface.ts +440 -0
- package/extensions/explore/ast/languages/typescript.ts +716 -0
- package/extensions/explore/ast/markdown.ts +145 -0
- package/extensions/explore/ast/package-surface.ts +42 -0
- package/extensions/explore/ast/queries/ast-search.ts +306 -0
- package/extensions/explore/ast/queries/composite-target.ts +77 -0
- package/extensions/explore/ast/queries/context.ts +406 -0
- package/extensions/explore/ast/queries/discover.ts +427 -0
- package/extensions/explore/ast/queries/impact.ts +250 -0
- package/extensions/explore/ast/queries/outline.ts +203 -0
- package/extensions/explore/ast/queries/show.ts +262 -0
- package/extensions/explore/ast/query.ts +48 -0
- package/extensions/explore/ast/read/hook.ts +125 -0
- package/extensions/explore/ast/read/policy.ts +55 -0
- package/extensions/explore/ast/registry.ts +120 -0
- package/extensions/explore/ast/scan.ts +76 -0
- package/extensions/explore/ast/slice.ts +31 -0
- package/extensions/explore/ast/tools/ast-search.ts +138 -0
- package/extensions/explore/ast/tools/context.ts +126 -0
- package/extensions/explore/ast/tools/deps.ts +72 -0
- package/extensions/explore/ast/tools/discover.ts +208 -0
- package/extensions/explore/ast/tools/impact.ts +135 -0
- package/extensions/explore/ast/tools/outline.ts +200 -0
- package/extensions/explore/ast/tools/relationships.ts +160 -0
- package/extensions/explore/ast/tools/render.ts +121 -0
- package/extensions/explore/ast/tools/reverse-deps.ts +72 -0
- package/extensions/explore/ast/tools/show.ts +116 -0
- package/extensions/explore/guidance.ts +52 -0
- package/extensions/explore/index.ts +91 -45
- package/extensions/explore/read/autoread.ts +118 -0
- package/extensions/explore/settings.ts +64 -0
- package/extensions/explore/traverse.ts +260 -86
- package/extensions/handoff/index.ts +1 -1
- package/extensions/patch/executor.ts +95 -1
- package/extensions/patch/index.ts +1 -0
- package/extensions/publish/index.ts +18 -85
- package/extensions/publish/publish-ui.ts +0 -4
- package/extensions/soul/README.md +7 -4
- package/extensions/soul/index.ts +11 -5
- package/extensions/soul/prompt.ts +30 -14
- package/extensions/soul/settings.ts +7 -2
- package/extensions/subagent/agents/context-sync.md +6 -10
- package/extensions/subagent/agents/review.md +44 -24
- package/extensions/subagent/agents/scout.md +53 -34
- package/extensions/subagent/run.ts +1 -1
- package/extensions/tau-help/help.md +2 -2
- package/package.json +6 -5
- package/schemas/tau.schema.json +51 -6
- package/{extensions/explore → shared}/autoread.ts +21 -7
- package/shared/bounded-text-result.ts +304 -0
- package/shared/events.ts +1 -0
- package/shared/full-file-knowledge.ts +49 -0
- package/shared/temporary-output-store.ts +275 -0
- package/extensions/explore/ast-tools.ts +0 -828
- package/extensions/explore/ast-worker.ts +0 -356
- package/extensions/explore/find.ts +0 -147
- package/extensions/explore/full-file-knowledge.ts +0 -234
- package/extensions/explore/grep.ts +0 -749
- package/extensions/explore/limits.ts +0 -22
- package/extensions/explore/ls.ts +0 -113
- package/extensions/explore/path-display.ts +0 -41
- package/extensions/explore/path-tree.ts +0 -150
- package/extensions/explore/read-cache.ts +0 -273
- package/extensions/explore/read-snapshots.ts +0 -57
- package/extensions/explore/read-stats-panel.ts +0 -154
- package/extensions/explore/read-stats.ts +0 -120
- package/extensions/explore/read.ts +0 -335
- package/extensions/explore/result.ts +0 -29
- package/native-bin/darwin-arm64/tau-ast +0 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Tree } from "web-tree-sitter";
|
|
2
|
+
import type { Decl, ImportRef } from "./ir.ts";
|
|
3
|
+
import type { PackageSurfaceResolver } from "./package-surface.ts";
|
|
4
|
+
|
|
5
|
+
export type LanguageCapabilities = {
|
|
6
|
+
shape: boolean;
|
|
7
|
+
search: boolean;
|
|
8
|
+
fileDeps: boolean;
|
|
9
|
+
callEdges: boolean;
|
|
10
|
+
packageSurface: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Contextual pattern wrap for languages where a bare snippet parses as the wrong kind
|
|
15
|
+
* (e.g. Go `fmt.Println($A)` → type_conversion). Shared search substitutes the user
|
|
16
|
+
* pattern for `$PATTERN` and tries each wrap after the bare pattern.
|
|
17
|
+
*/
|
|
18
|
+
export type AstSearchPatternContext = {
|
|
19
|
+
/** Source template containing the literal placeholder `$PATTERN`. */
|
|
20
|
+
readonly context: string;
|
|
21
|
+
/** tree-sitter kind name selected from the parsed context. */
|
|
22
|
+
readonly selector: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ExtractResult = {
|
|
26
|
+
decls: Decl[];
|
|
27
|
+
imports: ImportRef[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Result of adapter-owned import specifier → file resolution. */
|
|
31
|
+
export type FileDepResolution =
|
|
32
|
+
| { kind: "internal"; paths: string[] }
|
|
33
|
+
| { kind: "external"; id: string }
|
|
34
|
+
| { kind: "unresolved" };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Host services for file-dep resolution.
|
|
38
|
+
* Language-agnostic: resolvers never touch the engine type.
|
|
39
|
+
*/
|
|
40
|
+
export type FileDepHost = {
|
|
41
|
+
readonly cwd: string;
|
|
42
|
+
/** Scope root for this query. Resolvers must not walk above it for internal hits. */
|
|
43
|
+
readonly scopeRoot: string;
|
|
44
|
+
pathExists(path: string): Promise<boolean>;
|
|
45
|
+
isFile(path: string): Promise<boolean>;
|
|
46
|
+
/** Basenames; empty if missing or not a directory. */
|
|
47
|
+
readDir(path: string): Promise<string[]>;
|
|
48
|
+
/** True when path is a registered source file this graph should consider. */
|
|
49
|
+
ownsPath(path: string): boolean;
|
|
50
|
+
/** Session-scoped memo bag; cleared with graph invalidation. */
|
|
51
|
+
readonly memo: Map<string, unknown>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type FileDepResolver = (
|
|
55
|
+
fromPath: string,
|
|
56
|
+
specifier: string,
|
|
57
|
+
host: FileDepHost,
|
|
58
|
+
signal: AbortSignal,
|
|
59
|
+
) => Promise<FileDepResolution>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Grammar-backed language. `id` must match a pin in `ast/grammars/manifest`.
|
|
63
|
+
* Engine owns wasm load/parse; adapter only walks the tree.
|
|
64
|
+
* Language-owned hooks (`importNoiseIdentifiers`, `resolveFileDep`, optional `resolvePackageSurface`, callEdges)
|
|
65
|
+
* stay on the adapter — tools/queries never branch on language id.
|
|
66
|
+
*/
|
|
67
|
+
export type GrammarAdapter = {
|
|
68
|
+
readonly mode: "grammar";
|
|
69
|
+
readonly id: string;
|
|
70
|
+
readonly extensions: readonly string[];
|
|
71
|
+
readonly capabilities: LanguageCapabilities;
|
|
72
|
+
/** Keywords/types ignored when intersecting import text with a declaration slice (`show`). */
|
|
73
|
+
readonly importNoiseIdentifiers: ReadonlySet<string>;
|
|
74
|
+
extract(tree: Tree, source: string): ExtractResult;
|
|
75
|
+
/** Required when `capabilities.fileDeps` is true. */
|
|
76
|
+
readonly resolveFileDep?: FileDepResolver;
|
|
77
|
+
readonly resolvePackageSurface?: PackageSurfaceResolver;
|
|
78
|
+
/** Optional ast_search contextual wraps; shared matcher stays language-blind. */
|
|
79
|
+
readonly patternContexts?: readonly AstSearchPatternContext[];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Source-only language (Markdown). No tree-sitter grammar.
|
|
84
|
+
* Discriminant keeps engine free of `language ===` branches.
|
|
85
|
+
*/
|
|
86
|
+
export type SourceAdapter = {
|
|
87
|
+
readonly mode: "source";
|
|
88
|
+
readonly id: string;
|
|
89
|
+
readonly extensions: readonly string[];
|
|
90
|
+
readonly capabilities: LanguageCapabilities;
|
|
91
|
+
readonly importNoiseIdentifiers: ReadonlySet<string>;
|
|
92
|
+
extract(source: string): ExtractResult;
|
|
93
|
+
readonly resolveFileDep?: FileDepResolver;
|
|
94
|
+
readonly resolvePackageSurface?: PackageSurfaceResolver;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type LanguageAdapter = GrammarAdapter | SourceAdapter;
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
initializeTreeSitter,
|
|
6
|
+
parse as astGrepParse,
|
|
7
|
+
registerDynamicLanguage,
|
|
8
|
+
type SgNode,
|
|
9
|
+
type SgRoot,
|
|
10
|
+
} from "@ast-grep/wasm";
|
|
11
|
+
import { Language, Parser } from "web-tree-sitter";
|
|
12
|
+
import type { AdapterRegistry } from "./registry.ts";
|
|
13
|
+
import { createDefaultRegistry } from "./registry.ts";
|
|
14
|
+
import type { FileIr } from "./ir.ts";
|
|
15
|
+
import { grammarWasmPath, loadGrammarManifest, runtimeWasmPath, type GrammarPin } from "./grammars/manifest.ts";
|
|
16
|
+
import { formatPathForDisplay, pathResolutionError, resolveExplorePath } from "../traverse.ts";
|
|
17
|
+
|
|
18
|
+
export type FileSource = {
|
|
19
|
+
ir: FileIr;
|
|
20
|
+
/** Decoded source text this IR's offsets index into (UTF-16 code units). */
|
|
21
|
+
source: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type AstSearchBinding = {
|
|
25
|
+
name: string;
|
|
26
|
+
/** Exact matched text (single node or multi-node join of exact pieces). */
|
|
27
|
+
text: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type AstSearchHit = {
|
|
31
|
+
/** 1-indexed inclusive. */
|
|
32
|
+
startLine: number;
|
|
33
|
+
endLine: number;
|
|
34
|
+
/** Exact match preview (edit-grade). */
|
|
35
|
+
text: string;
|
|
36
|
+
bindings: AstSearchBinding[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type ExploreEngine = {
|
|
40
|
+
/** Absolute session cwd — path resolution and directory scans use this. */
|
|
41
|
+
readonly cwd: string;
|
|
42
|
+
readonly registry: AdapterRegistry;
|
|
43
|
+
/** Resolve path, read bytes, return cached or freshly extracted FileIr. */
|
|
44
|
+
irForFile(path: string): Promise<FileIr>;
|
|
45
|
+
/** One read: IR (cached when hash matches) plus the decoded source for slicing. */
|
|
46
|
+
sourceForFile(path: string): Promise<FileSource>;
|
|
47
|
+
/**
|
|
48
|
+
* Structural pattern search over one source string.
|
|
49
|
+
* Engine owns @ast-grep/wasm init + grammar registration from pinned wasm paths.
|
|
50
|
+
* Does not use the IR cache; does not cache match results.
|
|
51
|
+
*/
|
|
52
|
+
searchInSource(languageId: string, source: string, pattern: string): Promise<AstSearchHit[]>;
|
|
53
|
+
invalidate(paths: readonly string[]): void;
|
|
54
|
+
clear(): void;
|
|
55
|
+
/**
|
|
56
|
+
* Drop IR cache and delete the retained Parser.
|
|
57
|
+
* Loaded Language modules are dropped from JS maps; web-tree-sitter 0.26 has no Language.delete().
|
|
58
|
+
*/
|
|
59
|
+
shutdown(): void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type ExploreEngineOptions = {
|
|
63
|
+
cwd: string;
|
|
64
|
+
registry?: AdapterRegistry;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type CacheEntry = {
|
|
68
|
+
contentHash: string;
|
|
69
|
+
ir: FileIr;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function contentHashOf(bytes: Uint8Array): string {
|
|
73
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function lineCountOf(source: string): number {
|
|
77
|
+
if (source.length === 0) return 0;
|
|
78
|
+
let count = 1;
|
|
79
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
80
|
+
const code = source.charCodeAt(i);
|
|
81
|
+
if (code === 10) count += 1;
|
|
82
|
+
else if (code === 13) {
|
|
83
|
+
count += 1;
|
|
84
|
+
if (source.charCodeAt(i + 1) === 10) i += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return count;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function pinById(id: string): GrammarPin {
|
|
91
|
+
const pin = loadGrammarManifest().grammars.find((entry) => entry.id === id);
|
|
92
|
+
if (pin === undefined) {
|
|
93
|
+
throw new Error(`No grammar artifact for language: ${id}`);
|
|
94
|
+
}
|
|
95
|
+
return pin;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** ast-grep metavars: `$NAME` one node, `$$$NAME` sibling sequence. Skip `$_`. */
|
|
99
|
+
function metaVarNames(pattern: string): { singles: string[]; multis: string[] } {
|
|
100
|
+
const singles: string[] = [];
|
|
101
|
+
const multis: string[] = [];
|
|
102
|
+
let i = 0;
|
|
103
|
+
while (i < pattern.length) {
|
|
104
|
+
if (pattern[i] !== "$") {
|
|
105
|
+
i += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
let dollars = 0;
|
|
109
|
+
while (i < pattern.length && pattern[i] === "$") {
|
|
110
|
+
dollars += 1;
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
let name = "";
|
|
114
|
+
while (i < pattern.length) {
|
|
115
|
+
const c = pattern[i];
|
|
116
|
+
if (c === undefined) break;
|
|
117
|
+
const isAlpha = (c >= "A" && c <= "Z") || c === "_";
|
|
118
|
+
const isAlnum = isAlpha || (c >= "0" && c <= "9");
|
|
119
|
+
if ((name.length === 0 && isAlpha) || (name.length > 0 && isAlnum)) {
|
|
120
|
+
name += c;
|
|
121
|
+
i += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
if (name.length === 0 || name === "_") continue;
|
|
127
|
+
if (dollars >= 3) {
|
|
128
|
+
if (!multis.includes(name)) multis.push(name);
|
|
129
|
+
} else if (dollars === 1) {
|
|
130
|
+
if (!singles.includes(name)) singles.push(name);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { singles, multis };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function hitKey(hit: AstSearchHit): string {
|
|
137
|
+
return `${hit.startLine}:${hit.endLine}:${hit.text}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @ast-grep/wasm Pos.index is Unicode scalar offset, not UTF-16. */
|
|
141
|
+
function sliceByCharOffset(source: string, startChar: number, endChar: number): string {
|
|
142
|
+
let start = 0;
|
|
143
|
+
let end = source.length;
|
|
144
|
+
let charIndex = 0;
|
|
145
|
+
for (let i = 0; i < source.length;) {
|
|
146
|
+
if (charIndex === startChar) start = i;
|
|
147
|
+
const code = source.charCodeAt(i);
|
|
148
|
+
const step = code >= 0xd800 && code <= 0xdbff ? 2 : 1;
|
|
149
|
+
i += step;
|
|
150
|
+
charIndex += 1;
|
|
151
|
+
if (charIndex === endChar) {
|
|
152
|
+
end = i;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (charIndex < endChar) end = source.length;
|
|
157
|
+
return source.slice(start, end);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function bindingsFromNode(node: SgNode, pattern: string, source: string): AstSearchBinding[] {
|
|
161
|
+
const { singles, multis } = metaVarNames(pattern);
|
|
162
|
+
const out: AstSearchBinding[] = [];
|
|
163
|
+
for (const name of singles) {
|
|
164
|
+
const match = node.getMatch(name);
|
|
165
|
+
if (match !== undefined) out.push({ name, text: match.text() });
|
|
166
|
+
}
|
|
167
|
+
for (const name of multis) {
|
|
168
|
+
const parts = node.getMultipleMatches(name);
|
|
169
|
+
if (parts.length === 0) continue;
|
|
170
|
+
const first = parts[0];
|
|
171
|
+
const last = parts[parts.length - 1];
|
|
172
|
+
if (first === undefined || last === undefined) continue;
|
|
173
|
+
const start = first.range().start.index;
|
|
174
|
+
const end = last.range().end.index;
|
|
175
|
+
out.push({ name, text: sliceByCharOffset(source, start, end) });
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function nodeToHit(node: SgNode, pattern: string, source: string): AstSearchHit {
|
|
181
|
+
const range = node.range();
|
|
182
|
+
return {
|
|
183
|
+
startLine: range.start.line + 1,
|
|
184
|
+
endLine: range.end.line + 1,
|
|
185
|
+
text: node.text(),
|
|
186
|
+
bindings: bindingsFromNode(node, pattern, source),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function collectHits(
|
|
191
|
+
root: SgRoot,
|
|
192
|
+
matcher: unknown,
|
|
193
|
+
pattern: string,
|
|
194
|
+
source: string,
|
|
195
|
+
into: Map<string, AstSearchHit>,
|
|
196
|
+
): void {
|
|
197
|
+
const nodes = root.root().findAll(matcher);
|
|
198
|
+
for (const node of nodes) {
|
|
199
|
+
const hit = nodeToHit(node, pattern, source);
|
|
200
|
+
const key = hitKey(hit);
|
|
201
|
+
if (!into.has(key)) into.set(key, hit);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* In-process web-tree-sitter host.
|
|
207
|
+
* Parse → extract plain FileIr → tree.delete() immediately. Cache IR only.
|
|
208
|
+
* All IR offsets are UTF-16 code units into the decoded source string.
|
|
209
|
+
* Only this module resolves wasm paths via the grammar manifest.
|
|
210
|
+
* ast_search uses @ast-grep/wasm on the same pinned grammar paths (decision 11-A).
|
|
211
|
+
*/
|
|
212
|
+
export function createExploreEngine(options: ExploreEngineOptions): ExploreEngine {
|
|
213
|
+
const cwd = resolve(options.cwd);
|
|
214
|
+
const registry = options.registry ?? createDefaultRegistry();
|
|
215
|
+
const irCache = new Map<string, CacheEntry>();
|
|
216
|
+
const languages = new Map<string, Language>();
|
|
217
|
+
const languageLoads = new Map<string, Promise<Language>>();
|
|
218
|
+
const searchLangReady = new Set<string>();
|
|
219
|
+
|
|
220
|
+
let initPromise: Promise<void> | undefined;
|
|
221
|
+
let searchInitPromise: Promise<void> | undefined;
|
|
222
|
+
let parser: Parser | undefined;
|
|
223
|
+
let shutDown = false;
|
|
224
|
+
|
|
225
|
+
const assertLive = (): void => {
|
|
226
|
+
if (shutDown) {
|
|
227
|
+
throw new Error("Explore engine has been shut down");
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const ensureReady = async (): Promise<Parser> => {
|
|
232
|
+
assertLive();
|
|
233
|
+
if (parser !== undefined) return parser;
|
|
234
|
+
if (initPromise === undefined) {
|
|
235
|
+
initPromise = Parser.init({ locateFile: () => runtimeWasmPath() }).then(() => {
|
|
236
|
+
if (shutDown) return;
|
|
237
|
+
parser = new Parser();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
await initPromise;
|
|
241
|
+
assertLive();
|
|
242
|
+
if (parser === undefined) throw new Error("Explore engine failed to initialize parser");
|
|
243
|
+
return parser;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const ensureSearchReady = async (): Promise<void> => {
|
|
247
|
+
assertLive();
|
|
248
|
+
// IR path init first so locateFile pins web-tree-sitter.wasm for the process.
|
|
249
|
+
await ensureReady();
|
|
250
|
+
if (searchInitPromise === undefined) {
|
|
251
|
+
searchInitPromise = initializeTreeSitter();
|
|
252
|
+
}
|
|
253
|
+
await searchInitPromise;
|
|
254
|
+
assertLive();
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const ensureSearchLanguage = async (languageId: string): Promise<void> => {
|
|
258
|
+
assertLive();
|
|
259
|
+
if (searchLangReady.has(languageId)) return;
|
|
260
|
+
const adapter = registry.adapterForId(languageId);
|
|
261
|
+
if (adapter === undefined) {
|
|
262
|
+
throw new Error(`Unregistered language: ${languageId}`);
|
|
263
|
+
}
|
|
264
|
+
if (adapter.mode !== "grammar" || !adapter.capabilities.search) {
|
|
265
|
+
throw new Error(`Language does not support structural search: ${languageId}`);
|
|
266
|
+
}
|
|
267
|
+
await ensureSearchReady();
|
|
268
|
+
assertLive();
|
|
269
|
+
await registerDynamicLanguage({
|
|
270
|
+
[languageId]: {
|
|
271
|
+
libraryPath: grammarWasmPath(pinById(languageId)),
|
|
272
|
+
expandoChar: "µ",
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
assertLive();
|
|
276
|
+
searchLangReady.add(languageId);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const loadLanguage = async (languageId: string): Promise<Language> => {
|
|
280
|
+
assertLive();
|
|
281
|
+
const cached = languages.get(languageId);
|
|
282
|
+
if (cached !== undefined) return cached;
|
|
283
|
+
|
|
284
|
+
let pending = languageLoads.get(languageId);
|
|
285
|
+
if (pending === undefined) {
|
|
286
|
+
pending = Language.load(grammarWasmPath(pinById(languageId))).then(
|
|
287
|
+
(language) => {
|
|
288
|
+
languageLoads.delete(languageId);
|
|
289
|
+
if (!shutDown) {
|
|
290
|
+
languages.set(languageId, language);
|
|
291
|
+
}
|
|
292
|
+
return language;
|
|
293
|
+
},
|
|
294
|
+
(error: unknown) => {
|
|
295
|
+
languageLoads.delete(languageId);
|
|
296
|
+
throw error;
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
languageLoads.set(languageId, pending);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const language = await pending;
|
|
303
|
+
assertLive();
|
|
304
|
+
return language;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const buildIr = async (absolutePath: string, source: string, hash: string): Promise<FileIr> => {
|
|
308
|
+
const adapter = registry.adapterForPath(absolutePath);
|
|
309
|
+
if (adapter === undefined) {
|
|
310
|
+
throw new Error(`Unsupported language for path: ${formatPathForDisplay(absolutePath, cwd)}`);
|
|
311
|
+
}
|
|
312
|
+
const lineCount = lineCountOf(source);
|
|
313
|
+
|
|
314
|
+
if (adapter.mode === "source") {
|
|
315
|
+
const extracted = adapter.extract(source);
|
|
316
|
+
return {
|
|
317
|
+
path: absolutePath,
|
|
318
|
+
contentHash: hash,
|
|
319
|
+
languageId: adapter.id,
|
|
320
|
+
lineCount,
|
|
321
|
+
decls: extracted.decls,
|
|
322
|
+
imports: extracted.imports,
|
|
323
|
+
parseDegraded: false,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const activeParser = await ensureReady();
|
|
328
|
+
const language = await loadLanguage(adapter.id);
|
|
329
|
+
assertLive();
|
|
330
|
+
activeParser.setLanguage(language);
|
|
331
|
+
activeParser.reset();
|
|
332
|
+
const tree = activeParser.parse(source);
|
|
333
|
+
if (tree === null) {
|
|
334
|
+
throw new Error(`Parse failed for ${formatPathForDisplay(absolutePath, cwd)}`);
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const extracted = adapter.extract(tree, source);
|
|
338
|
+
return {
|
|
339
|
+
path: absolutePath,
|
|
340
|
+
contentHash: hash,
|
|
341
|
+
languageId: adapter.id,
|
|
342
|
+
lineCount,
|
|
343
|
+
decls: extracted.decls,
|
|
344
|
+
imports: extracted.imports,
|
|
345
|
+
parseDegraded: tree.rootNode.hasError,
|
|
346
|
+
};
|
|
347
|
+
} finally {
|
|
348
|
+
tree.delete();
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const loadSource = async (path: string): Promise<FileSource> => {
|
|
353
|
+
assertLive();
|
|
354
|
+
const absolutePath = resolveExplorePath(cwd, path);
|
|
355
|
+
let bytes: Buffer;
|
|
356
|
+
try {
|
|
357
|
+
bytes = await readFile(absolutePath);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw pathResolutionError(error, path);
|
|
360
|
+
}
|
|
361
|
+
assertLive();
|
|
362
|
+
const hash = contentHashOf(bytes);
|
|
363
|
+
const source = bytes.toString("utf8");
|
|
364
|
+
const cached = irCache.get(absolutePath);
|
|
365
|
+
if (cached !== undefined && cached.contentHash === hash) {
|
|
366
|
+
return { ir: cached.ir, source };
|
|
367
|
+
}
|
|
368
|
+
const ir = await buildIr(absolutePath, source, hash);
|
|
369
|
+
assertLive();
|
|
370
|
+
irCache.set(absolutePath, { contentHash: hash, ir });
|
|
371
|
+
return { ir, source };
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
cwd,
|
|
376
|
+
registry,
|
|
377
|
+
|
|
378
|
+
async irForFile(path: string): Promise<FileIr> {
|
|
379
|
+
const source = await loadSource(path);
|
|
380
|
+
return source.ir;
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
async sourceForFile(path: string): Promise<FileSource> {
|
|
384
|
+
return loadSource(path);
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
async searchInSource(languageId: string, source: string, pattern: string): Promise<AstSearchHit[]> {
|
|
388
|
+
assertLive();
|
|
389
|
+
const adapter = registry.adapterForId(languageId);
|
|
390
|
+
if (adapter === undefined) {
|
|
391
|
+
throw new Error(`Unregistered language: ${languageId}`);
|
|
392
|
+
}
|
|
393
|
+
if (adapter.mode !== "grammar" || !adapter.capabilities.search) {
|
|
394
|
+
throw new Error(`Language does not support structural search: ${languageId}`);
|
|
395
|
+
}
|
|
396
|
+
await ensureSearchLanguage(languageId);
|
|
397
|
+
assertLive();
|
|
398
|
+
|
|
399
|
+
let root: SgRoot;
|
|
400
|
+
try {
|
|
401
|
+
root = astGrepParse(languageId, source);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
404
|
+
throw new Error(`Parse failed for structural search (${languageId}): ${message}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const byKey = new Map<string, AstSearchHit>();
|
|
409
|
+
const matchers: unknown[] = [pattern];
|
|
410
|
+
const contexts = adapter.patternContexts ?? [];
|
|
411
|
+
for (const wrap of contexts) {
|
|
412
|
+
if (!wrap.context.includes("$PATTERN")) continue;
|
|
413
|
+
matchers.push({
|
|
414
|
+
rule: {
|
|
415
|
+
pattern: {
|
|
416
|
+
context: wrap.context.split("$PATTERN").join(pattern),
|
|
417
|
+
selector: wrap.selector,
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let anyMatcherOk = false;
|
|
424
|
+
let lastError: string | undefined;
|
|
425
|
+
for (const matcher of matchers) {
|
|
426
|
+
try {
|
|
427
|
+
collectHits(root, matcher, pattern, source, byKey);
|
|
428
|
+
anyMatcherOk = true;
|
|
429
|
+
} catch (error) {
|
|
430
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (!anyMatcherOk) {
|
|
434
|
+
throw new Error(`Invalid pattern: ${lastError ?? "unknown pattern error"}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return [...byKey.values()].sort((a, b) => {
|
|
438
|
+
if (a.startLine !== b.startLine) return a.startLine - b.startLine;
|
|
439
|
+
if (a.endLine !== b.endLine) return a.endLine - b.endLine;
|
|
440
|
+
return a.text.localeCompare(b.text);
|
|
441
|
+
});
|
|
442
|
+
} finally {
|
|
443
|
+
root.free();
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
invalidate(paths: readonly string[]): void {
|
|
448
|
+
for (const path of paths) {
|
|
449
|
+
irCache.delete(resolveExplorePath(cwd, path));
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
clear(): void {
|
|
454
|
+
irCache.clear();
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
shutdown(): void {
|
|
458
|
+
if (shutDown) return;
|
|
459
|
+
shutDown = true;
|
|
460
|
+
irCache.clear();
|
|
461
|
+
languages.clear();
|
|
462
|
+
languageLoads.clear();
|
|
463
|
+
searchLangReady.clear();
|
|
464
|
+
parser?.delete();
|
|
465
|
+
parser = undefined;
|
|
466
|
+
initPromise = undefined;
|
|
467
|
+
searchInitPromise = undefined;
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { AstSearchMatch, AstSearchResult } from "../queries/ast-search.ts";
|
|
2
|
+
import type { ScanOutcome } from "../scan.ts";
|
|
3
|
+
import type { TraversalLimit } from "../../traverse.ts";
|
|
4
|
+
import { formatPathForDisplay } from "../../traverse.ts";
|
|
5
|
+
|
|
6
|
+
function lineRange(startLine: number, endLine: number): string {
|
|
7
|
+
return startLine === endLine ? `L${startLine}` : `L${startLine}-${endLine}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function formatMatchRows(match: AstSearchMatch): string[] {
|
|
11
|
+
const lines: string[] = [];
|
|
12
|
+
const preview = match.text.length > 0 ? match.text : "(empty)";
|
|
13
|
+
const previewLines = preview.split("\n");
|
|
14
|
+
const first = previewLines[0] ?? "(empty)";
|
|
15
|
+
lines.push(`${lineRange(match.startLine, match.endLine)} ${first}`);
|
|
16
|
+
for (let i = 1; i < previewLines.length; i += 1) {
|
|
17
|
+
lines.push(` ${previewLines[i] ?? ""}`);
|
|
18
|
+
}
|
|
19
|
+
for (const binding of match.bindings) {
|
|
20
|
+
const bindLines = binding.text.split("\n");
|
|
21
|
+
lines.push(` $${binding.name} = ${bindLines[0] ?? ""}`);
|
|
22
|
+
for (let i = 1; i < bindLines.length; i += 1) {
|
|
23
|
+
lines.push(` ${bindLines[i] ?? ""}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (match.enclosing !== undefined) {
|
|
27
|
+
lines.push(` in ${match.enclosing.kind} ${match.enclosing.name} L${match.enclosing.startLine}`);
|
|
28
|
+
}
|
|
29
|
+
if (match.parseDegraded) {
|
|
30
|
+
lines.push(" warning: parser recovered with errors");
|
|
31
|
+
}
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One complete file group (path header + matches). */
|
|
36
|
+
export function formatAstSearchFile(path: string, matches: readonly AstSearchMatch[], cwd: string): string {
|
|
37
|
+
const lines = [formatPathForDisplay(path, cwd)];
|
|
38
|
+
for (const match of matches) {
|
|
39
|
+
lines.push(...formatMatchRows(match));
|
|
40
|
+
}
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatAstSearchEmpty(): string {
|
|
45
|
+
return "No matches";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function limitLabel(limit: TraversalLimit): string {
|
|
49
|
+
switch (limit) {
|
|
50
|
+
case "maxFiles":
|
|
51
|
+
return "file count";
|
|
52
|
+
case "maxSourceBytes":
|
|
53
|
+
return "source bytes";
|
|
54
|
+
case "maxDepth":
|
|
55
|
+
return "depth";
|
|
56
|
+
case "maxElapsedMs":
|
|
57
|
+
return "elapsed time";
|
|
58
|
+
case "cancelled":
|
|
59
|
+
return "cancelled";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Footers only when something was cut, a budget tripped, or file diagnostics exist. */
|
|
64
|
+
export function formatAstSearchFooters(result: AstSearchResult, cwd: string): string | undefined {
|
|
65
|
+
const limits: string[] = [];
|
|
66
|
+
if (result.resultLimitReached) {
|
|
67
|
+
limits.push(`matches omitted (result limit ${result.resultLimit})`);
|
|
68
|
+
}
|
|
69
|
+
const scan: ScanOutcome | undefined = result.scan;
|
|
70
|
+
if (scan?.limit !== undefined && scan.limit !== "cancelled") {
|
|
71
|
+
limits.push(limitLabel(scan.limit));
|
|
72
|
+
}
|
|
73
|
+
const diagnostics = result.fileErrors.map((err) => `${formatPathForDisplay(err.path, cwd)}: ${err.message}`);
|
|
74
|
+
const parts: string[] = [];
|
|
75
|
+
if (limits.length > 0) parts.push(`limits reached: ${limits.join(", ")}`);
|
|
76
|
+
if (diagnostics.length > 0) parts.push(`file errors: ${diagnostics.join("; ")}`);
|
|
77
|
+
if (parts.length === 0) return undefined;
|
|
78
|
+
return parts.join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Group matches by file (path order of first appearance). */
|
|
82
|
+
export function groupAstSearchByFile(
|
|
83
|
+
matches: readonly AstSearchMatch[],
|
|
84
|
+
): { path: string; matches: AstSearchMatch[] }[] {
|
|
85
|
+
const groups: { path: string; matches: AstSearchMatch[] }[] = [];
|
|
86
|
+
const indexByPath = new Map<string, number>();
|
|
87
|
+
for (const match of matches) {
|
|
88
|
+
const existing = indexByPath.get(match.path);
|
|
89
|
+
if (existing === undefined) {
|
|
90
|
+
indexByPath.set(match.path, groups.length);
|
|
91
|
+
groups.push({ path: match.path, matches: [match] });
|
|
92
|
+
} else {
|
|
93
|
+
groups[existing]?.matches.push(match);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return groups;
|
|
97
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type CompositeBlock = {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
text: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** Shared section packing for impact/context complete-block emission. */
|
|
8
|
+
export function compositeSectionBlocks(args: {
|
|
9
|
+
header: string;
|
|
10
|
+
emptyLabel: string;
|
|
11
|
+
sections: readonly { id: string; label: string; text: string }[];
|
|
12
|
+
footers: readonly string[];
|
|
13
|
+
}): CompositeBlock[] {
|
|
14
|
+
const blocks: CompositeBlock[] = [{ id: "header", label: "header", text: args.header }];
|
|
15
|
+
if (args.sections.length === 0) {
|
|
16
|
+
blocks.push({ id: "empty", label: "empty", text: args.emptyLabel });
|
|
17
|
+
} else {
|
|
18
|
+
for (const section of args.sections) {
|
|
19
|
+
blocks.push({ id: section.id, label: section.label, text: section.text });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (args.footers.length > 0) {
|
|
23
|
+
blocks.push({ id: "footer", label: "footer", text: args.footers.join("\n") });
|
|
24
|
+
}
|
|
25
|
+
return blocks;
|
|
26
|
+
}
|