@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/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.15.0";
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