@maxgfr/codeindex 2.15.0 → 2.17.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/README.md +52 -4
- package/package.json +2 -2
- package/scripts/engine.d.mts +38 -3
- package/scripts/engine.mjs +332 -105
- package/src/ast/grammars-pull.ts +95 -1
- package/src/ast/warm.ts +74 -0
- package/src/engine-cli.ts +83 -85
- package/src/engine.ts +14 -1
- package/src/mcp.ts +34 -4
- package/src/rewrite.ts +169 -0
- package/src/types.ts +1 -1
package/src/rewrite.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Command rewriting: map an expensive full-tree text search onto the engine's
|
|
2
|
+
// indexed equivalent, so an agent shell that runs `grep -r foo .` gets bounded,
|
|
3
|
+
// structured, gitignore-correct hits instead of an unbounded wall of text.
|
|
4
|
+
//
|
|
5
|
+
// The contract is a HOST contract, not a shell one: the caller hands us a full
|
|
6
|
+
// command line and takes our stdout as the command to run instead (iterion's
|
|
7
|
+
// `rewriters` plugin kind, rtk's generalization). That makes a wrong rewrite
|
|
8
|
+
// far worse than no rewrite — it silently changes what the agent asked for.
|
|
9
|
+
// So this module is deliberately, aggressively conservative:
|
|
10
|
+
//
|
|
11
|
+
// * anything the parser cannot prove it understands → NO rewrite
|
|
12
|
+
// * any shell metacharacter at all → NO rewrite (we refuse to reason about
|
|
13
|
+
// pipelines, redirection, substitution or chaining)
|
|
14
|
+
// * any flag not on the explicit allowlist → NO rewrite
|
|
15
|
+
//
|
|
16
|
+
// A refusal is cheap (the original command runs untouched); a bad rewrite is
|
|
17
|
+
// not. Every branch here defaults to refusing.
|
|
18
|
+
|
|
19
|
+
// Shell constructs we will not reason about. Their presence anywhere in the
|
|
20
|
+
// line — quoted or not — refuses the rewrite. Over-refusing is the point: a
|
|
21
|
+
// pattern containing a literal `|` is rare, mis-rewriting a pipeline is fatal.
|
|
22
|
+
const SHELL_METACHARS = /[|&;<>`\n\r$(){}]/;
|
|
23
|
+
|
|
24
|
+
// `grep` output is line-oriented text; `codeindex grep` returns JSON hits. That
|
|
25
|
+
// is the intended trade (bounded + structured), but it means we only rewrite
|
|
26
|
+
// invocations whose INTENT is "search the tree", never "search this one file"
|
|
27
|
+
// (cheap already) and never anything whose flags shape the output format.
|
|
28
|
+
const GREP_BINARIES = new Set(["grep", "egrep", "rg", "ripgrep"]);
|
|
29
|
+
|
|
30
|
+
interface Parsed {
|
|
31
|
+
pattern?: string;
|
|
32
|
+
path?: string;
|
|
33
|
+
ignoreCase: boolean;
|
|
34
|
+
includes: string[];
|
|
35
|
+
recursive: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Split on whitespace honouring single/double quotes. Returns undefined on an
|
|
39
|
+
// unterminated quote — an unparseable line is a refusal, not a guess.
|
|
40
|
+
export function tokenize(line: string): string[] | undefined {
|
|
41
|
+
const out: string[] = [];
|
|
42
|
+
let cur = "";
|
|
43
|
+
let quote: '"' | "'" | undefined;
|
|
44
|
+
let started = false;
|
|
45
|
+
for (let i = 0; i < line.length; i++) {
|
|
46
|
+
const c = line[i];
|
|
47
|
+
if (quote) {
|
|
48
|
+
if (c === quote) quote = undefined;
|
|
49
|
+
else cur += c;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (c === '"' || c === "'") {
|
|
53
|
+
quote = c;
|
|
54
|
+
started = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (c === " " || c === "\t") {
|
|
58
|
+
if (started || cur) out.push(cur);
|
|
59
|
+
cur = "";
|
|
60
|
+
started = false;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
cur += c;
|
|
64
|
+
}
|
|
65
|
+
if (quote) return undefined; // unterminated quote
|
|
66
|
+
if (started || cur) out.push(cur);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Wrap a token so the shell reproduces it verbatim. Single-quoting is total
|
|
71
|
+
// (no escapes are interpreted inside), so the only case needing care is a
|
|
72
|
+
// literal single quote, spliced via the standard '\'' idiom.
|
|
73
|
+
export function shellQuote(s: string): string {
|
|
74
|
+
if (s !== "" && !/[^A-Za-z0-9_\-./=@:]/.test(s)) return s;
|
|
75
|
+
return "'" + s.replace(/'/g, `'\\''`) + "'";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Parse a grep/rg invocation into the fields we can faithfully re-express.
|
|
79
|
+
// Returns undefined the moment anything is unrecognized.
|
|
80
|
+
function parseSearch(bin: string, args: string[]): Parsed | undefined {
|
|
81
|
+
const p: Parsed = { ignoreCase: false, includes: [], recursive: bin !== "grep" && bin !== "egrep" };
|
|
82
|
+
const positionals: string[] = [];
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < args.length; i++) {
|
|
85
|
+
const a = args[i];
|
|
86
|
+
if (a === undefined) continue;
|
|
87
|
+
if (a === "--") {
|
|
88
|
+
// Everything after `--` is positional by definition.
|
|
89
|
+
positionals.push(...args.slice(i + 1));
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (!a.startsWith("-") || a === "-") {
|
|
93
|
+
positionals.push(a);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (a === "-i" || a === "--ignore-case") {
|
|
97
|
+
p.ignoreCase = true;
|
|
98
|
+
} else if (a === "-r" || a === "-R" || a === "--recursive") {
|
|
99
|
+
p.recursive = true;
|
|
100
|
+
} else if (a === "-n" || a === "--line-number" || a === "-H" || a === "--with-filename" || a === "--no-heading") {
|
|
101
|
+
// Pure output-shaping flags whose effect codeindex already provides
|
|
102
|
+
// unconditionally (every hit carries file + line). Safe to drop.
|
|
103
|
+
} else if (a === "-e" || a === "--regexp") {
|
|
104
|
+
const v = args[++i];
|
|
105
|
+
if (v === undefined || p.pattern !== undefined) return undefined;
|
|
106
|
+
p.pattern = v;
|
|
107
|
+
} else if (a.startsWith("--include=")) {
|
|
108
|
+
p.includes.push(a.slice("--include=".length));
|
|
109
|
+
} else if (a === "--include" || a === "-g" || a === "--glob") {
|
|
110
|
+
const v = args[++i];
|
|
111
|
+
if (v === undefined) return undefined;
|
|
112
|
+
p.includes.push(v);
|
|
113
|
+
} else if (a.length > 2 && /^-[a-zA-Z]+$/.test(a)) {
|
|
114
|
+
// A bundled short-flag cluster (-rn, -ri, -rni…). Expand and re-check;
|
|
115
|
+
// any member outside the allowlist refuses the whole line.
|
|
116
|
+
const expanded = a.slice(1).split("").map((c) => `-${c}`);
|
|
117
|
+
args.splice(i, 1, ...expanded);
|
|
118
|
+
i--;
|
|
119
|
+
} else {
|
|
120
|
+
return undefined; // unknown flag → refuse
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// With an -e pattern already bound, every positional is a path; otherwise the
|
|
125
|
+
// first positional is the pattern. Either way at most ONE path may remain —
|
|
126
|
+
// multi-path search has no single-scope equivalent, so it refuses.
|
|
127
|
+
if (p.pattern === undefined) {
|
|
128
|
+
const first = positionals.shift();
|
|
129
|
+
if (first === undefined || first === "") return undefined;
|
|
130
|
+
p.pattern = first;
|
|
131
|
+
}
|
|
132
|
+
if (positionals.length > 1) return undefined;
|
|
133
|
+
p.path = positionals[0];
|
|
134
|
+
return p;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Rewrite `cmd` to its codeindex equivalent, or return undefined to leave it
|
|
138
|
+
// alone. `bin` is the codeindex executable name to emit (the host may have it
|
|
139
|
+
// on PATH under a mount point of its choosing).
|
|
140
|
+
export function rewriteCommand(cmd: string, bin = "codeindex"): string | undefined {
|
|
141
|
+
const line = cmd.trim();
|
|
142
|
+
if (!line || SHELL_METACHARS.test(line)) return undefined;
|
|
143
|
+
|
|
144
|
+
const tokens = tokenize(line);
|
|
145
|
+
if (!tokens || tokens.length < 2) return undefined;
|
|
146
|
+
|
|
147
|
+
// Refuse env-prefixed or path-qualified invocations (`FOO=1 grep …`,
|
|
148
|
+
// `/usr/bin/grep …`): resolving those faithfully is not worth the risk.
|
|
149
|
+
const [head, ...args] = tokens;
|
|
150
|
+
if (head === undefined || !GREP_BINARIES.has(head)) return undefined;
|
|
151
|
+
|
|
152
|
+
const p = parseSearch(head, args);
|
|
153
|
+
if (!p || p.pattern === undefined) return undefined;
|
|
154
|
+
const pattern = p.pattern;
|
|
155
|
+
// A non-recursive `grep pattern file.ts` is already cheap and its semantics
|
|
156
|
+
// (one file, text output) are not what the indexed search provides.
|
|
157
|
+
if (!p.recursive) return undefined;
|
|
158
|
+
|
|
159
|
+
// A path that is not the tree root means "search this subtree"; express it as
|
|
160
|
+
// a scope rather than silently widening to the whole repo.
|
|
161
|
+
const path = p.path;
|
|
162
|
+
const out = [bin, "grep", shellQuote(pattern)];
|
|
163
|
+
if (path && path !== "." && path !== "./") {
|
|
164
|
+
out.push("--scope", shellQuote(path.replace(/\/+$/, "")));
|
|
165
|
+
}
|
|
166
|
+
if (p.ignoreCase) out.push("--ignore-case");
|
|
167
|
+
for (const g of p.includes) out.push("--include", shellQuote(g));
|
|
168
|
+
return out.join(" ");
|
|
169
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Single source of truth for the engine version the bundle reports. Kept in
|
|
2
2
|
// lockstep with package.json by the release pipeline. Do not edit by hand
|
|
3
3
|
// outside a release.
|
|
4
|
-
export const ENGINE_VERSION = "2.
|
|
4
|
+
export const ENGINE_VERSION = "2.17.0";
|
|
5
5
|
|
|
6
6
|
// Bumped whenever the on-disk artifact shape changes, so a consumer can reject
|
|
7
7
|
// an index written by an incompatible engine instead of misreading it. The
|