@maxgfr/codeindex 2.16.0 → 2.17.1

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 CHANGED
@@ -192,17 +192,65 @@ Full details incl. the HTTP protocol (build your own server):
192
192
 
193
193
  ## Use as an MCP server
194
194
 
195
- `codeindex mcp` (or `node scripts/cli.mjs mcp`) serves the engine over stdio
196
- tools: `scan_summary`, `graph`, `symbols`, `callers`, `workspaces`, `churn`,
197
- `grep`. Register it in Claude Code with:
195
+ `codeindex mcp` (or `node scripts/cli.mjs mcp`) serves the engine over stdio.
196
+ Register it in Claude Code with:
198
197
 
199
198
  ```sh
200
199
  claude mcp add codeindex -- codeindex mcp
201
200
  ```
202
201
 
202
+ **26 tools**, grouped by what they answer:
203
+
204
+ | group | tools |
205
+ |---|---|
206
+ | orient | `scan_summary`, `repo_map`, `graph`, `mermaid`, `workspaces` |
207
+ | find | `search`, `grep`, `find_symbol`, `symbols`, `symbols_overview` |
208
+ | impact | `find_references`, `callers`, `dead_code` |
209
+ | risk | `hotspots`, `churn`, `coupling`, `complexity`, `check_rules` |
210
+ | edit *(write)* | `replace_symbol_body`, `insert_after_symbol`, `insert_before_symbol` |
211
+ | memory | `write_memory`, `read_memory`, `list_memories`, `delete_memory` *(write except reads)* |
212
+ | embeddings | `embed_status` |
213
+
214
+ ### Pinning the server to one repository
215
+
216
+ Every tool takes a `repo` argument. A host that runs one server per workspace
217
+ can pin it instead, so `repo` becomes optional on every tool — the pin is
218
+ reflected in the advertised schema, not merely tolerated at call time:
219
+
220
+ ```sh
221
+ codeindex mcp --repo /path/to/workspace
222
+ ```
223
+
224
+ An explicit per-call `repo` still wins, so a pinned server can still answer
225
+ about another checkout. `--server-name <name>` overrides the announced
226
+ `serverInfo.name` for hosts that embed the server under their own identity.
227
+
228
+ **Prime the index first** and activation becomes a load, not a rebuild:
229
+ `codeindex index --repo <dir> --out <dir>/.codeindex`. The first tool call
230
+ deserializes those artifacts when the engine version, commit and artifact
231
+ hashes all match.
232
+
203
233
  `engine.mjs` is a pure side-effect-free library (safe for consumers to inline
204
234
  into their own CLIs); `cli.mjs` is the thin standalone CLI/MCP wrapper.
205
235
 
236
+ ## Command rewriting
237
+
238
+ `codeindex rewrite '<command line>'` maps an expensive tree-wide search onto
239
+ its indexed equivalent, for agent harnesses that intercept shell commands
240
+ (iterion's `rewriters` plugin kind, generalizing rtk):
241
+
242
+ ```sh
243
+ $ codeindex rewrite 'grep -rn TODO src'
244
+ codeindex grep TODO --scope src
245
+ ```
246
+
247
+ It prints the replacement and exits `0`, or exits `1` with empty stdout when it
248
+ has no opinion — run the original. The parser is deliberately conservative: any
249
+ shell metacharacter (pipe, redirect, substitution, chaining), any unrecognized
250
+ flag, a non-recursive `grep`, or more than one search path all refuse the
251
+ rewrite. A refusal costs nothing; a wrong rewrite silently changes what the
252
+ agent asked for.
253
+
206
254
  ## Versioning
207
255
 
208
256
  - `ENGINE_VERSION` — the release tag, embedded greppably in the bundle.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxgfr/codeindex",
3
- "version": "2.16.0",
3
+ "version": "2.17.1",
4
4
  "description": "Self-contained, deterministic repo-indexing engine: walk + language detection + symbol/import extraction (tree-sitter AST with regex fallback) + import resolution + typed cross-file link-graph + analytics. Ships as a single zero-dependency engine.mjs that consumer tools vendor.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
@@ -77,7 +77,7 @@
77
77
  "tsup": "^8.3.0",
78
78
  "typescript": "^5.5.0",
79
79
  "vitest": "^4.1.0",
80
- "web-tree-sitter": "^0.26.10"
80
+ "web-tree-sitter": "^0.26.11"
81
81
  },
82
82
  "pnpm": {
83
83
  "onlyBuiltDependencies": [
@@ -1,4 +1,4 @@
1
- declare const ENGINE_VERSION = "2.16.0";
1
+ declare const ENGINE_VERSION = "2.17.1";
2
2
  declare const SCHEMA_VERSION = 4;
3
3
  declare const EXTRACTOR_VERSION = 10;
4
4
  type FileKind = "code" | "doc" | "config" | "asset" | "other";
@@ -255,7 +255,7 @@ declare function extractAst(rel: string, ext: string, content: string, opts?: {
255
255
  maxCalls?: number;
256
256
  }): AstResult | undefined;
257
257
 
258
- declare const DEFAULT_GRAMMARS_URL = "https://github.com/maxgfr/codeindex/releases/download/v2.16.0/grammars-2.16.0.tar.gz";
258
+ declare const DEFAULT_GRAMMARS_URL = "https://github.com/maxgfr/codeindex/releases/download/v2.17.1/grammars-2.17.1.tar.gz";
259
259
  interface GrammarsPullTarget {
260
260
  url: string;
261
261
  sha256Url?: string;
@@ -745,9 +745,12 @@ interface McpServerOptions {
745
745
  name?: string;
746
746
  version?: string;
747
747
  };
748
+ defaultRepo?: string;
748
749
  }
749
750
  declare function runMcpServer(opts?: McpServerOptions): Promise<void>;
750
751
 
752
+ declare function rewriteCommand(cmd: string, bin?: string): string | undefined;
753
+
751
754
  declare function sha1(s: string | Uint8Array): string;
752
755
  declare function shortHash(s: string, n?: number): string;
753
756
 
@@ -777,6 +780,6 @@ declare function keywords(question: string): string[];
777
780
  declare function rankedKeywords(question: string): string[];
778
781
  declare function rrf<T>(lists: T[][], keyOf: (item: T) => string, k?: number): Map<string, number>;
779
782
 
780
- declare function runCli(argv: string[]): Promise<void>;
783
+ declare function runCli(rawArgv: string[]): Promise<void>;
781
784
 
782
- export { type ArchRule, type BuildIndexOptions, type BuiltinRule, type CallerEntry, type CallerIndex, type CallerIndexOptions, type CallerSite, type ChangeCoupling, type CodeInfo, type CodeSymbol, type CouplingOptions, DEFAULT_GRAMMARS_URL, DEFAULT_MAX_FILES, type DeadSymbol, type DiffFile, type DiffSpec, EMBED_VERSION, ENGINE_VERSION, EXTRACTOR_VERSION, type Edge, type EdgeKind, type EditResult, type EmbedEndpointOptions, type EmbedPullTarget, type EmbeddingIndex, type EmbeddingRecord, type EmbeddingUnit, type FileCategory, type FileKind, type FileNode, type FileRecord, type FindSymbolOptions, type ForbiddenEdgeRule, type GrammarsPullResult, type GrammarsPullTarget, type GrammarsTier, type GrammarsTierName, type Graph, type GrepOptions, type Hotspot, type Hunk, type IgnoreRule, type IndexArtifacts, MARKDOWN_EXT, type MarkdownInfo, type McpServerOptions, type MermaidOptions, type ModuleInfo, type ModuleNode, type RawCallerIndex, type RawCallerSite, type RawRef, type RenderScipOptions, type RepoMapOptions, type RepoScan, type Resolution, type ResolveContext, type RiskHotspot, type RuleSeverity, type RuleViolation, SCHEMA_VERSION, type ScanOptions, type SearchHit, type SearchOptions, type SearchResult, type SemanticSearchOptions, type SemanticSearchResult, type ShResult, type StaticEmbedModel, type SurpriseEdge, type SymbolComplexity, type SymbolIndex, type SymbolMatch, type SymbolReferences, type TestMap, type Tier, type WalkOptions, type WalkResult, type WalkedFile, type WarmGrammarsOptions, type WarmGrammarsResult, type WorkspaceInfo, type WorkspaceKind, type WorkspacePackage, allGrammarKeys, applyCentrality, basicTokenize, betweennessOf, buildArtifactsFromScan, buildCallerIndex, buildEmbeddingIndex, buildEndpointIndex, buildGraph, buildIndexArtifacts, buildModules, buildRawCallerIndex, buildResolveContext, buildSymbolIndex, byKey, byStr, categorize, changeCoupling, changedSince, checkRules, classify, clip, clipInline, communityOf, compileGlobs, complexityOfSource, computeImportPairs, computeSurprises, computeSymbolRefs, computeTestMap, deleteMemory, deserializeEmbeddings, detectCommunities, detectWorkspaces, diffFiles, diffHunks, embedEndpointUrl, embedViaEndpoint, embeddingUnits, enclosingSymbol, encode, encodeQueryViaEndpoint, ensureGrammars, escapeRegExp, extToLang, extractAst, extractCode, extractGrammarsTarball, extractMarkdown, extractSymbols, extractTarInto, fetchExpectedSha256, fetchGrammarsTarball, findDeadCode, findReferences, findSymbol, foldText, gitChurn, grammarKeyForExt, grammarKeysForExts, grammarReady, grepRepo, hasEmbedModel, have, headCommit, healthzUrl, insertAfterSymbol, insertBeforeSymbol, intDot, isCode, isDoc, isGitWorktree, isIgnored, isSurprising, isTestFile, isTestPath, keywords, languageOf, listMemories, loadEmbedModel, pagerankOf, parseGitignore, parseRules, probeEndpoint, pullGrammars, quantize, rankHotspots, rankedKeywords, readMemory, readText, renderGraphJson, renderMermaid, renderRepoMap, renderScip, renderSymbolsJson, replaceSymbolBody, resolveBaseRef, resolveCallEdges, resolveDocLink, resolveEmbedEndpoint, resolveEmbedModelDir, resolveEmbedPullUrl, resolveGrammarsDir, resolveGrammarsPullTarget, resolveGrammarsTier, resolveImport, resolveUniqueSymbol, riskHotspots, roundHalfToEven, rrf, runCli, runMcpServer, scanRepo, searchIndex, searchSemantic, serializeEmbeddings, sh, sha1, sharedGrammarsCacheDir, shortHash, slugify, subtokens, symbolComplexity, symbolsOverview, testsForModule, tierForPath, tokenize, uniqueSymbolDefs, untestedModules, untrackedFiles, walk, warmGrammars, wordpiece, writeMemory };
785
+ export { type ArchRule, type BuildIndexOptions, type BuiltinRule, type CallerEntry, type CallerIndex, type CallerIndexOptions, type CallerSite, type ChangeCoupling, type CodeInfo, type CodeSymbol, type CouplingOptions, DEFAULT_GRAMMARS_URL, DEFAULT_MAX_FILES, type DeadSymbol, type DiffFile, type DiffSpec, EMBED_VERSION, ENGINE_VERSION, EXTRACTOR_VERSION, type Edge, type EdgeKind, type EditResult, type EmbedEndpointOptions, type EmbedPullTarget, type EmbeddingIndex, type EmbeddingRecord, type EmbeddingUnit, type FileCategory, type FileKind, type FileNode, type FileRecord, type FindSymbolOptions, type ForbiddenEdgeRule, type GrammarsPullResult, type GrammarsPullTarget, type GrammarsTier, type GrammarsTierName, type Graph, type GrepOptions, type Hotspot, type Hunk, type IgnoreRule, type IndexArtifacts, MARKDOWN_EXT, type MarkdownInfo, type McpServerOptions, type MermaidOptions, type ModuleInfo, type ModuleNode, type RawCallerIndex, type RawCallerSite, type RawRef, type RenderScipOptions, type RepoMapOptions, type RepoScan, type Resolution, type ResolveContext, type RiskHotspot, type RuleSeverity, type RuleViolation, SCHEMA_VERSION, type ScanOptions, type SearchHit, type SearchOptions, type SearchResult, type SemanticSearchOptions, type SemanticSearchResult, type ShResult, type StaticEmbedModel, type SurpriseEdge, type SymbolComplexity, type SymbolIndex, type SymbolMatch, type SymbolReferences, type TestMap, type Tier, type WalkOptions, type WalkResult, type WalkedFile, type WarmGrammarsOptions, type WarmGrammarsResult, type WorkspaceInfo, type WorkspaceKind, type WorkspacePackage, allGrammarKeys, applyCentrality, basicTokenize, betweennessOf, buildArtifactsFromScan, buildCallerIndex, buildEmbeddingIndex, buildEndpointIndex, buildGraph, buildIndexArtifacts, buildModules, buildRawCallerIndex, buildResolveContext, buildSymbolIndex, byKey, byStr, categorize, changeCoupling, changedSince, checkRules, classify, clip, clipInline, communityOf, compileGlobs, complexityOfSource, computeImportPairs, computeSurprises, computeSymbolRefs, computeTestMap, deleteMemory, deserializeEmbeddings, detectCommunities, detectWorkspaces, diffFiles, diffHunks, embedEndpointUrl, embedViaEndpoint, embeddingUnits, enclosingSymbol, encode, encodeQueryViaEndpoint, ensureGrammars, escapeRegExp, extToLang, extractAst, extractCode, extractGrammarsTarball, extractMarkdown, extractSymbols, extractTarInto, fetchExpectedSha256, fetchGrammarsTarball, findDeadCode, findReferences, findSymbol, foldText, gitChurn, grammarKeyForExt, grammarKeysForExts, grammarReady, grepRepo, hasEmbedModel, have, headCommit, healthzUrl, insertAfterSymbol, insertBeforeSymbol, intDot, isCode, isDoc, isGitWorktree, isIgnored, isSurprising, isTestFile, isTestPath, keywords, languageOf, listMemories, loadEmbedModel, pagerankOf, parseGitignore, parseRules, probeEndpoint, pullGrammars, quantize, rankHotspots, rankedKeywords, readMemory, readText, renderGraphJson, renderMermaid, renderRepoMap, renderScip, renderSymbolsJson, replaceSymbolBody, resolveBaseRef, resolveCallEdges, resolveDocLink, resolveEmbedEndpoint, resolveEmbedModelDir, resolveEmbedPullUrl, resolveGrammarsDir, resolveGrammarsPullTarget, resolveGrammarsTier, resolveImport, resolveUniqueSymbol, rewriteCommand, riskHotspots, roundHalfToEven, rrf, runCli, runMcpServer, scanRepo, searchIndex, searchSemantic, serializeEmbeddings, sh, sha1, sharedGrammarsCacheDir, shortHash, slugify, subtokens, symbolComplexity, symbolsOverview, testsForModule, tierForPath, tokenize, uniqueSymbolDefs, untestedModules, untrackedFiles, walk, warmGrammars, wordpiece, writeMemory };
@@ -14,7 +14,7 @@ var ENGINE_VERSION, SCHEMA_VERSION, EXTRACTOR_VERSION;
14
14
  var init_types = __esm({
15
15
  "src/types.ts"() {
16
16
  "use strict";
17
- ENGINE_VERSION = "2.16.0";
17
+ ENGINE_VERSION = "2.17.1";
18
18
  SCHEMA_VERSION = 4;
19
19
  EXTRACTOR_VERSION = 10;
20
20
  }
@@ -10440,8 +10440,8 @@ async function warmGrammarsForRepo(repo) {
10440
10440
  const { files } = walk(repo, {});
10441
10441
  await ensureGrammars(grammarKeysForExts(files.map((f) => f.ext)));
10442
10442
  }
10443
- async function callTool(name2, args2) {
10444
- const repo = str(args2.repo);
10443
+ async function callTool(name2, args2, defaultRepo) {
10444
+ const repo = str(args2.repo) ?? defaultRepo;
10445
10445
  if (!repo) throw new Error("`repo` is required (absolute path to the repository root)");
10446
10446
  const scanOpts = { scope: str(args2.scope), include: strArray(args2.include), exclude: strArray(args2.exclude) };
10447
10447
  if (!SCANLESS_TOOLS.has(name2)) await warmGrammarsForRepo(repo);
@@ -10636,11 +10636,26 @@ async function callTool(name2, args2) {
10636
10636
  }
10637
10637
  throw new Error(`unknown tool: ${name2}`);
10638
10638
  }
10639
+ function toolsFor(defaultRepo) {
10640
+ if (!defaultRepo) return TOOLS;
10641
+ return TOOLS.map((t) => ({
10642
+ ...t,
10643
+ inputSchema: {
10644
+ ...t.inputSchema,
10645
+ properties: {
10646
+ ...t.inputSchema.properties,
10647
+ repo: { type: "string", description: `Absolute path to the repository root (optional \u2014 defaults to ${defaultRepo})` }
10648
+ },
10649
+ required: t.inputSchema.required.filter((r) => r !== "repo")
10650
+ }
10651
+ }));
10652
+ }
10639
10653
  async function runMcpServer(opts = {}) {
10640
10654
  const serverInfo = {
10641
10655
  name: opts.serverInfo?.name ?? "codeindex",
10642
10656
  version: opts.serverInfo?.version ?? ENGINE_VERSION
10643
10657
  };
10658
+ const tools = toolsFor(opts.defaultRepo);
10644
10659
  const send = (msg) => {
10645
10660
  process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n");
10646
10661
  };
@@ -10673,13 +10688,13 @@ async function runMcpServer(opts = {}) {
10673
10688
  } else if (req.method === "ping") {
10674
10689
  send({ id: req.id, result: {} });
10675
10690
  } else if (req.method === "tools/list") {
10676
- send({ id: req.id, result: { tools: TOOLS } });
10691
+ send({ id: req.id, result: { tools } });
10677
10692
  } else if (req.method === "tools/call") {
10678
10693
  const params = req.params ?? {};
10679
10694
  const name2 = str(params.name) ?? "";
10680
10695
  const args2 = params.arguments ?? {};
10681
10696
  try {
10682
- const text = await callTool(name2, args2);
10697
+ const text = await callTool(name2, args2, opts.defaultRepo);
10683
10698
  send({ id: req.id, result: { content: [{ type: "text", text }] } });
10684
10699
  } catch (e) {
10685
10700
  send({
@@ -10989,6 +11004,121 @@ var init_mcp = __esm({
10989
11004
  }
10990
11005
  });
10991
11006
 
11007
+ // src/rewrite.ts
11008
+ var rewrite_exports = {};
11009
+ __export(rewrite_exports, {
11010
+ rewriteCommand: () => rewriteCommand,
11011
+ shellQuote: () => shellQuote,
11012
+ tokenize: () => tokenize2
11013
+ });
11014
+ function tokenize2(line) {
11015
+ const out2 = [];
11016
+ let cur = "";
11017
+ let quote;
11018
+ let started = false;
11019
+ for (let i2 = 0; i2 < line.length; i2++) {
11020
+ const c2 = line[i2];
11021
+ if (quote) {
11022
+ if (c2 === quote) quote = void 0;
11023
+ else cur += c2;
11024
+ continue;
11025
+ }
11026
+ if (c2 === '"' || c2 === "'") {
11027
+ quote = c2;
11028
+ started = true;
11029
+ continue;
11030
+ }
11031
+ if (c2 === " " || c2 === " ") {
11032
+ if (started || cur) out2.push(cur);
11033
+ cur = "";
11034
+ started = false;
11035
+ continue;
11036
+ }
11037
+ cur += c2;
11038
+ }
11039
+ if (quote) return void 0;
11040
+ if (started || cur) out2.push(cur);
11041
+ return out2;
11042
+ }
11043
+ function shellQuote(s) {
11044
+ if (s !== "" && !/[^A-Za-z0-9_\-./=@:]/.test(s)) return s;
11045
+ return "'" + s.replace(/'/g, `'\\''`) + "'";
11046
+ }
11047
+ function parseSearch(bin, args2) {
11048
+ const p = { ignoreCase: false, includes: [], recursive: bin !== "grep" && bin !== "egrep" };
11049
+ const positionals = [];
11050
+ for (let i2 = 0; i2 < args2.length; i2++) {
11051
+ const a = args2[i2];
11052
+ if (a === void 0) continue;
11053
+ if (a === "--") {
11054
+ positionals.push(...args2.slice(i2 + 1));
11055
+ break;
11056
+ }
11057
+ if (!a.startsWith("-") || a === "-") {
11058
+ positionals.push(a);
11059
+ continue;
11060
+ }
11061
+ if (a === "-i" || a === "--ignore-case") {
11062
+ p.ignoreCase = true;
11063
+ } else if (a === "-r" || a === "-R" || a === "--recursive") {
11064
+ p.recursive = true;
11065
+ } else if (a === "-n" || a === "--line-number" || a === "-H" || a === "--with-filename" || a === "--no-heading") {
11066
+ } else if (a === "-e" || a === "--regexp") {
11067
+ const v = args2[++i2];
11068
+ if (v === void 0 || p.pattern !== void 0) return void 0;
11069
+ p.pattern = v;
11070
+ } else if (a.startsWith("--include=")) {
11071
+ p.includes.push(a.slice("--include=".length));
11072
+ } else if (a === "--include" || a === "-g" || a === "--glob") {
11073
+ const v = args2[++i2];
11074
+ if (v === void 0) return void 0;
11075
+ p.includes.push(v);
11076
+ } else if (a.length > 2 && /^-[a-zA-Z]+$/.test(a)) {
11077
+ const expanded = a.slice(1).split("").map((c2) => `-${c2}`);
11078
+ args2.splice(i2, 1, ...expanded);
11079
+ i2--;
11080
+ } else {
11081
+ return void 0;
11082
+ }
11083
+ }
11084
+ if (p.pattern === void 0) {
11085
+ const first = positionals.shift();
11086
+ if (first === void 0 || first === "") return void 0;
11087
+ p.pattern = first;
11088
+ }
11089
+ if (positionals.length > 1) return void 0;
11090
+ p.path = positionals[0];
11091
+ return p;
11092
+ }
11093
+ function rewriteCommand(cmd, bin = "codeindex") {
11094
+ const line = cmd.trim();
11095
+ if (!line || SHELL_METACHARS.test(line)) return void 0;
11096
+ const tokens = tokenize2(line);
11097
+ if (!tokens || tokens.length < 2) return void 0;
11098
+ const [head, ...args2] = tokens;
11099
+ if (head === void 0 || !GREP_BINARIES.has(head)) return void 0;
11100
+ const p = parseSearch(head, args2);
11101
+ if (!p || p.pattern === void 0) return void 0;
11102
+ const pattern = p.pattern;
11103
+ if (!p.recursive) return void 0;
11104
+ const path = p.path;
11105
+ const out2 = [bin, "grep", shellQuote(pattern)];
11106
+ if (path && path !== "." && path !== "./") {
11107
+ out2.push("--scope", shellQuote(path.replace(/\/+$/, "")));
11108
+ }
11109
+ if (p.ignoreCase) out2.push("--ignore-case");
11110
+ for (const g of p.includes) out2.push("--include", shellQuote(g));
11111
+ return out2.join(" ");
11112
+ }
11113
+ var SHELL_METACHARS, GREP_BINARIES;
11114
+ var init_rewrite = __esm({
11115
+ "src/rewrite.ts"() {
11116
+ "use strict";
11117
+ SHELL_METACHARS = /[|&;<>`\n\r$(){}]/;
11118
+ GREP_BINARIES = /* @__PURE__ */ new Set(["grep", "egrep", "rg", "ripgrep"]);
11119
+ }
11120
+ });
11121
+
10992
11122
  // src/engine.ts
10993
11123
  init_types();
10994
11124
  init_walk();
@@ -11601,6 +11731,7 @@ init_deadcode();
11601
11731
  init_complexity();
11602
11732
  init_viz();
11603
11733
  init_mcp();
11734
+ init_rewrite();
11604
11735
  init_hash();
11605
11736
  init_sort();
11606
11737
  init_util();
@@ -11677,11 +11808,31 @@ Commands:
11677
11808
  repomap Token-budgeted map of the highest-PageRank files (--budget-tokens)
11678
11809
  hotspots Churn \xD7 size ranking of the files where work concentrates (JSON)
11679
11810
  coupling Change coupling: files that change together (JSON; --since <ref>)
11680
- mcp Run as an MCP server over stdio (tools: scan_summary, graph,
11681
- symbols, callers, workspaces, churn, grep)
11811
+ deadcode Dead-code candidates in two labeled tiers: 'unreferenced' (no
11812
+ call site binds AND nothing references the name) and 'uncalled'
11813
+ (referenced \u2014 re-export, type position \u2014 but never called)
11814
+ complexity Cyclomatic-complexity estimates, most-complex first. Pass a file
11815
+ positional for one file; omit for the repo-wide top
11816
+ risk Complexity \xD7 git-churn ranking (JSON; --since <ref> to bound)
11817
+ mermaid Mermaid diagram of the module graph; pass a module positional to
11818
+ focus on one neighborhood
11819
+ rewrite Map an expensive tree-wide search onto its indexed equivalent:
11820
+ cli.mjs rewrite '<command line>'. Prints the replacement command
11821
+ and exits 0, or exits 1 when it has no opinion (run the original).
11822
+ Deliberately conservative \u2014 any shell metacharacter or unknown
11823
+ flag refuses the rewrite
11824
+ mcp Run as an MCP server over stdio (26 tools: scan_summary, graph,
11825
+ symbols, callers, workspaces, churn, symbols_overview,
11826
+ find_symbol, find_references, repo_map, hotspots, coupling,
11827
+ dead_code, complexity, mermaid, grep, search, embed_status,
11828
+ check_rules, the memory quartet and the three symbolic-edit
11829
+ writes). Flags: --repo <dir> pins ONE repository so the per-tool
11830
+ repo argument becomes optional (an explicit per-call repo still
11831
+ wins); --server-name <name> overrides the announced serverInfo
11682
11832
  version Print the engine version
11683
11833
 
11684
- Flags:
11834
+ Flags (accepted before OR after the subcommand: '--repo X scan' and
11835
+ 'scan --repo X' are equivalent):
11685
11836
  --repo <dir> Repo root (default: cwd)
11686
11837
  --out <file> Write output to a file instead of stdout (\`scip\`: --out -
11687
11838
  writes the binary index to stdout)
@@ -11708,6 +11859,8 @@ Flags:
11708
11859
  --recall \`callers\`: recall-oriented binding (issue #7) \u2014 relaxes
11709
11860
  the JS/TS import gate to unique repo-wide names and labels
11710
11861
  each site corroborated|unique-name
11862
+ --ignore-case \`grep\`: case-insensitive matching
11863
+ --max-hits <n> \`grep\`: cap returned hits (default 200)
11711
11864
  `;
11712
11865
  function parseFlags(args2) {
11713
11866
  const flags2 = { repo: process.cwd(), include: [], exclude: [], gitignore: true, ignoreDirs: [], noAst: false, fuzzy: true, semantic: false };
@@ -11774,7 +11927,62 @@ function scanOptions(flags2, precomputedWalk) {
11774
11927
  };
11775
11928
  }
11776
11929
  var SCANLESS_COMMANDS = /* @__PURE__ */ new Set(["grep", "churn", "coupling", "workspaces", "grammars"]);
11777
- async function runCli(argv) {
11930
+ function parseMcpFlags(argv) {
11931
+ let defaultRepo;
11932
+ let name2;
11933
+ for (let i2 = 0; i2 < argv.length; i2++) {
11934
+ const a = argv[i2];
11935
+ if (a === "--repo") {
11936
+ const v = argv[++i2];
11937
+ if (!v) throw new Error("--repo requires a directory");
11938
+ defaultRepo = resolve2(v);
11939
+ } else if (a === "--server-name") {
11940
+ const v = argv[++i2];
11941
+ if (!v) throw new Error("--server-name requires a value");
11942
+ name2 = v;
11943
+ } else {
11944
+ throw new Error(`unknown flag for \`mcp\`: ${a}`);
11945
+ }
11946
+ }
11947
+ if (defaultRepo && !existsSync5(defaultRepo)) throw new Error(`--repo path does not exist: ${defaultRepo}`);
11948
+ return { defaultRepo, serverInfo: name2 ? { name: name2 } : void 0 };
11949
+ }
11950
+ var VALUE_FLAGS = /* @__PURE__ */ new Set([
11951
+ "--repo",
11952
+ "--out",
11953
+ "--project-root",
11954
+ "--include",
11955
+ "--exclude",
11956
+ "--scope",
11957
+ "--ignore-dir",
11958
+ "--max-files",
11959
+ "--max-bytes",
11960
+ "--max-calls",
11961
+ "--max-hits",
11962
+ "--budget-tokens",
11963
+ "--since",
11964
+ "--config",
11965
+ "--limit",
11966
+ "--server-name"
11967
+ ]);
11968
+ function hoistLeadingFlags(argv) {
11969
+ const lead = [];
11970
+ let i2 = 0;
11971
+ while (i2 < argv.length) {
11972
+ const a = argv[i2];
11973
+ if (a === void 0 || !a.startsWith("-")) break;
11974
+ lead.push(a);
11975
+ i2++;
11976
+ if (VALUE_FLAGS.has(a) && i2 < argv.length) {
11977
+ lead.push(argv[i2]);
11978
+ i2++;
11979
+ }
11980
+ }
11981
+ if (lead.length === 0 || i2 >= argv.length) return argv;
11982
+ return [argv[i2], ...lead, ...argv.slice(i2 + 1)];
11983
+ }
11984
+ async function runCli(rawArgv) {
11985
+ const argv = hoistLeadingFlags(rawArgv);
11778
11986
  const [cmd, ...rest] = argv;
11779
11987
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
11780
11988
  process.stdout.write(HELP);
@@ -11784,9 +11992,19 @@ async function runCli(argv) {
11784
11992
  process.stdout.write(ENGINE_VERSION + "\n");
11785
11993
  return;
11786
11994
  }
11995
+ if (cmd === "rewrite") {
11996
+ const { rewriteCommand: rewriteCommand2 } = await Promise.resolve().then(() => (init_rewrite(), rewrite_exports));
11997
+ const rewritten = rewriteCommand2(rest.join(" "));
11998
+ if (!rewritten) {
11999
+ process.exitCode = 1;
12000
+ return;
12001
+ }
12002
+ process.stdout.write(rewritten + "\n");
12003
+ return;
12004
+ }
11787
12005
  if (cmd === "mcp") {
11788
12006
  const { runMcpServer: runMcpServer2 } = await Promise.resolve().then(() => (init_mcp(), mcp_exports));
11789
- await runMcpServer2();
12007
+ await runMcpServer2(parseMcpFlags(rest));
11790
12008
  return;
11791
12009
  }
11792
12010
  const flags2 = parseFlags(rest);
@@ -12117,7 +12335,8 @@ async function runCli(argv) {
12117
12335
  emit(renderMermaid(graph, { module: flags2.positional }), flags2.out);
12118
12336
  } else if (cmd === "grep") {
12119
12337
  if (!flags2.positional) throw new Error("grep needs a pattern: cli.mjs grep <pattern> --repo <dir>");
12120
- const globs = [...flags2.include, ...flags2.exclude.map((g) => `!${g}`)];
12338
+ const scopeGlobs = flags2.scope ? [`${flags2.scope.replace(/\/+$/, "")}/**`] : [];
12339
+ const globs = [...scopeGlobs, ...flags2.include, ...flags2.exclude.map((g) => `!${g}`)];
12121
12340
  const hits = grepRepo(flags2.repo, flags2.positional, {
12122
12341
  globs: globs.length ? globs : void 0,
12123
12342
  ignoreCase: flags2.ignoreCase,
@@ -12246,6 +12465,7 @@ export {
12246
12465
  resolveGrammarsTier,
12247
12466
  resolveImport,
12248
12467
  resolveUniqueSymbol,
12468
+ rewriteCommand,
12249
12469
  riskHotspots,
12250
12470
  roundHalfToEven,
12251
12471
  rrf,
package/src/engine-cli.ts CHANGED
@@ -77,11 +77,31 @@ Commands:
77
77
  repomap Token-budgeted map of the highest-PageRank files (--budget-tokens)
78
78
  hotspots Churn × size ranking of the files where work concentrates (JSON)
79
79
  coupling Change coupling: files that change together (JSON; --since <ref>)
80
- mcp Run as an MCP server over stdio (tools: scan_summary, graph,
81
- symbols, callers, workspaces, churn, grep)
80
+ deadcode Dead-code candidates in two labeled tiers: 'unreferenced' (no
81
+ call site binds AND nothing references the name) and 'uncalled'
82
+ (referenced — re-export, type position — but never called)
83
+ complexity Cyclomatic-complexity estimates, most-complex first. Pass a file
84
+ positional for one file; omit for the repo-wide top
85
+ risk Complexity × git-churn ranking (JSON; --since <ref> to bound)
86
+ mermaid Mermaid diagram of the module graph; pass a module positional to
87
+ focus on one neighborhood
88
+ rewrite Map an expensive tree-wide search onto its indexed equivalent:
89
+ cli.mjs rewrite '<command line>'. Prints the replacement command
90
+ and exits 0, or exits 1 when it has no opinion (run the original).
91
+ Deliberately conservative — any shell metacharacter or unknown
92
+ flag refuses the rewrite
93
+ mcp Run as an MCP server over stdio (26 tools: scan_summary, graph,
94
+ symbols, callers, workspaces, churn, symbols_overview,
95
+ find_symbol, find_references, repo_map, hotspots, coupling,
96
+ dead_code, complexity, mermaid, grep, search, embed_status,
97
+ check_rules, the memory quartet and the three symbolic-edit
98
+ writes). Flags: --repo <dir> pins ONE repository so the per-tool
99
+ repo argument becomes optional (an explicit per-call repo still
100
+ wins); --server-name <name> overrides the announced serverInfo
82
101
  version Print the engine version
83
102
 
84
- Flags:
103
+ Flags (accepted before OR after the subcommand: '--repo X scan' and
104
+ 'scan --repo X' are equivalent):
85
105
  --repo <dir> Repo root (default: cwd)
86
106
  --out <file> Write output to a file instead of stdout (\`scip\`: --out -
87
107
  writes the binary index to stdout)
@@ -108,6 +128,8 @@ Flags:
108
128
  --recall \`callers\`: recall-oriented binding (issue #7) — relaxes
109
129
  the JS/TS import gate to unique repo-wide names and labels
110
130
  each site corroborated|unique-name
131
+ --ignore-case \`grep\`: case-insensitive matching
132
+ --max-hits <n> \`grep\`: cap returned hits (default 200)
111
133
  `;
112
134
 
113
135
  interface CliFlags {
@@ -212,7 +234,85 @@ function scanOptions(flags: CliFlags, precomputedWalk?: WalkResult): BuildIndexO
212
234
  // version/help/mcp return before we get there.
213
235
  const SCANLESS_COMMANDS = new Set(["grep", "churn", "coupling", "workspaces", "grammars"]);
214
236
 
215
- export async function runCli(argv: string[]): Promise<void> {
237
+ // Flags for `codeindex mcp`. Kept separate from parseFlags on purpose (see the
238
+ // dispatch site). `--repo` is resolved to an absolute path and must exist: a
239
+ // server pinned to a typo'd directory would otherwise answer every tool call
240
+ // with the same confusing per-call error instead of failing at startup.
241
+ export function parseMcpFlags(argv: string[]): { defaultRepo?: string; serverInfo?: { name?: string } } {
242
+ let defaultRepo: string | undefined;
243
+ let name: string | undefined;
244
+ for (let i = 0; i < argv.length; i++) {
245
+ const a = argv[i];
246
+ if (a === "--repo") {
247
+ const v = argv[++i];
248
+ if (!v) throw new Error("--repo requires a directory");
249
+ defaultRepo = resolve(v);
250
+ } else if (a === "--server-name") {
251
+ const v = argv[++i];
252
+ if (!v) throw new Error("--server-name requires a value");
253
+ name = v;
254
+ } else {
255
+ throw new Error(`unknown flag for \`mcp\`: ${a}`);
256
+ }
257
+ }
258
+ if (defaultRepo && !existsSync(defaultRepo)) throw new Error(`--repo path does not exist: ${defaultRepo}`);
259
+ return { defaultRepo, serverInfo: name ? { name } : undefined };
260
+ }
261
+
262
+ // Flags that consume the following argv element. Needed to hoist leading flags
263
+ // past the subcommand without mistaking a flag's VALUE for the command name
264
+ // (`--repo /x scan`: `/x` must not be read as the command).
265
+ const VALUE_FLAGS = new Set([
266
+ "--repo",
267
+ "--out",
268
+ "--project-root",
269
+ "--include",
270
+ "--exclude",
271
+ "--scope",
272
+ "--ignore-dir",
273
+ "--max-files",
274
+ "--max-bytes",
275
+ "--max-calls",
276
+ "--max-hits",
277
+ "--budget-tokens",
278
+ "--since",
279
+ "--config",
280
+ "--limit",
281
+ "--server-name",
282
+ ]);
283
+
284
+ // Accept global flags BEFORE the subcommand as well as after, so
285
+ // `codeindex --repo /x scan` and `codeindex scan --repo /x` agree. A strict
286
+ // subcommand-first parser reads the leading flag as the command name and fails
287
+ // with a baffling "unknown flag: scan".
288
+ //
289
+ // This is not only ergonomics. A host that wraps the CLI may splice a flag in
290
+ // right after the binary name — iterion's rewriter `inject_flag` does exactly
291
+ // that, turning `codeindex grep foo` into `codeindex --max-hits 40 grep foo` —
292
+ // and without hoisting that command cannot run at all.
293
+ //
294
+ // Returns argv unchanged when there is nothing to hoist, so `--help`,
295
+ // `--version` and a bare subcommand all keep their existing behaviour.
296
+ export function hoistLeadingFlags(argv: string[]): string[] {
297
+ const lead: string[] = [];
298
+ let i = 0;
299
+ while (i < argv.length) {
300
+ const a = argv[i];
301
+ if (a === undefined || !a.startsWith("-")) break;
302
+ lead.push(a);
303
+ i++;
304
+ if (VALUE_FLAGS.has(a) && i < argv.length) {
305
+ lead.push(argv[i] as string);
306
+ i++;
307
+ }
308
+ }
309
+ // No leading flags, or they were the whole line (`--help`, `--version`).
310
+ if (lead.length === 0 || i >= argv.length) return argv;
311
+ return [argv[i] as string, ...lead, ...argv.slice(i + 1)];
312
+ }
313
+
314
+ export async function runCli(rawArgv: string[]): Promise<void> {
315
+ const argv = hoistLeadingFlags(rawArgv);
216
316
  const [cmd, ...rest] = argv;
217
317
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
218
318
  process.stdout.write(HELP);
@@ -222,9 +322,28 @@ export async function runCli(argv: string[]): Promise<void> {
222
322
  process.stdout.write(ENGINE_VERSION + "\n");
223
323
  return;
224
324
  }
325
+ if (cmd === "rewrite") {
326
+ // Host contract (iterion's `rewriters` kind, rtk's generalization): stdin
327
+ // is nothing, argv is ONE full command line, stdout is the command to run
328
+ // instead, and the exit code says whether to use it. Exit 1 = "no opinion,
329
+ // run the original" — the overwhelmingly common, deliberately cheap case.
330
+ const { rewriteCommand } = await import("./rewrite.js");
331
+ const rewritten = rewriteCommand(rest.join(" "));
332
+ if (!rewritten) {
333
+ process.exitCode = 1;
334
+ return;
335
+ }
336
+ process.stdout.write(rewritten + "\n");
337
+ return;
338
+ }
225
339
  if (cmd === "mcp") {
340
+ // `mcp` takes a deliberately tiny, self-contained flag set rather than
341
+ // going through parseFlags: the shared parser owns positional/scope
342
+ // semantics that mean nothing to a long-lived server, and every unknown
343
+ // flag there is fatal. Pinning is OPT-IN — a bare `codeindex mcp` keeps
344
+ // the historical contract where each tool call carries its own `repo`.
226
345
  const { runMcpServer } = await import("./mcp.js");
227
- await runMcpServer();
346
+ await runMcpServer(parseMcpFlags(rest));
228
347
  return;
229
348
  }
230
349
 
@@ -646,7 +765,12 @@ export async function runCli(argv: string[]): Promise<void> {
646
765
  emit(renderMermaid(graph, { module: flags.positional }), flags.out);
647
766
  } else if (cmd === "grep") {
648
767
  if (!flags.positional) throw new Error("grep needs a pattern: cli.mjs grep <pattern> --repo <dir>");
649
- const globs = [...flags.include, ...flags.exclude.map((g) => `!${g}`)];
768
+ // `--scope <dir>` is documented as global sugar for `--include '<dir>/**'`;
769
+ // every other command gets it via scanOptions, but grep bypasses the scan
770
+ // and builds its own glob list — so it has to fold the sugar in itself, or
771
+ // the flag would be silently ignored here alone.
772
+ const scopeGlobs = flags.scope ? [`${flags.scope.replace(/\/+$/, "")}/**`] : [];
773
+ const globs = [...scopeGlobs, ...flags.include, ...flags.exclude.map((g) => `!${g}`)];
650
774
  const hits = grepRepo(flags.repo, flags.positional, {
651
775
  globs: globs.length ? globs : undefined,
652
776
  ignoreCase: flags.ignoreCase,
package/src/engine.ts CHANGED
@@ -185,6 +185,13 @@ export type { RepoMapOptions } from "./repomap.js";
185
185
  export { runMcpServer } from "./mcp.js";
186
186
  export type { McpServerOptions } from "./mcp.js";
187
187
 
188
+ // Command rewriting: an expensive tree-wide search → its indexed equivalent.
189
+ // Exported so a host can decide for itself rather than shelling out to
190
+ // `engine.mjs rewrite` (same conservative refusal semantics either way).
191
+ // Only the decision function is public — the tokenizer/quoter are internal
192
+ // details, and `tokenize` is already taken here by the wordpiece tokenizer.
193
+ export { rewriteCommand } from "./rewrite.js";
194
+
188
195
  // General-purpose helpers shared by consumers (deterministic, dependency-free).
189
196
  export { sha1, shortHash } from "./hash.js";
190
197
  export { byStr, byKey } from "./sort.js";
package/src/mcp.ts CHANGED
@@ -667,8 +667,11 @@ const SCANLESS_TOOLS = new Set([
667
667
  "embed_status",
668
668
  ]);
669
669
 
670
- async function callTool(name: string, args: Record<string, unknown>): Promise<string> {
671
- const repo = str(args.repo);
670
+ async function callTool(name: string, args: Record<string, unknown>, defaultRepo?: string): Promise<string> {
671
+ // An explicit per-call `repo` always wins; `defaultRepo` is the server-level
672
+ // pin (`codeindex mcp --repo <dir>`) that lets a host bind one server process
673
+ // to one workspace, so agents need not know — or restate — the absolute path.
674
+ const repo = str(args.repo) ?? defaultRepo;
672
675
  if (!repo) throw new Error("`repo` is required (absolute path to the repository root)");
673
676
  const scanOpts = { scope: str(args.scope), include: strArray(args.include), exclude: strArray(args.exclude) };
674
677
  // Scan-needing tools warm the present-language grammars (re-derived per call)
@@ -886,11 +889,36 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
886
889
  throw new Error(`unknown tool: ${name}`);
887
890
  }
888
891
 
892
+ // The advertised tool list. Without a server-level repo pin this is TOOLS
893
+ // verbatim (byte-compat for existing consumers). With one, every tool drops
894
+ // `repo` from its `required` set and documents the default — so a client that
895
+ // omits it is spec-correct rather than relying on the server being lenient.
896
+ // The pin is reflected in the schema, not just honoured at call time.
897
+ function toolsFor(defaultRepo?: string): readonly unknown[] {
898
+ if (!defaultRepo) return TOOLS;
899
+ return TOOLS.map((t) => ({
900
+ ...t,
901
+ inputSchema: {
902
+ ...t.inputSchema,
903
+ properties: {
904
+ ...t.inputSchema.properties,
905
+ repo: { type: "string", description: `Absolute path to the repository root (optional — defaults to ${defaultRepo})` },
906
+ },
907
+ required: (t.inputSchema.required as readonly string[]).filter((r) => r !== "repo"),
908
+ },
909
+ }));
910
+ }
911
+
889
912
  export interface McpServerOptions {
890
913
  // Override the serverInfo announced in the initialize response — for
891
914
  // downstream consumers embedding this server under their own identity.
892
915
  // Omitted fields keep the defaults (name "codeindex", ENGINE_VERSION).
893
916
  serverInfo?: { name?: string; version?: string };
917
+ // Bind the server to ONE repository, so `repo` becomes optional on every
918
+ // tool (an explicit per-call `repo` still wins). This is what lets a host
919
+ // spawn one server per workspace — `codeindex mcp --repo <dir>` — instead of
920
+ // requiring the agent to thread an absolute path through every single call.
921
+ defaultRepo?: string;
894
922
  }
895
923
 
896
924
  export async function runMcpServer(opts: McpServerOptions = {}): Promise<void> {
@@ -898,6 +926,8 @@ export async function runMcpServer(opts: McpServerOptions = {}): Promise<void> {
898
926
  name: opts.serverInfo?.name ?? "codeindex",
899
927
  version: opts.serverInfo?.version ?? ENGINE_VERSION,
900
928
  };
929
+ // Derived ONCE: the pin cannot change mid-session, so neither can the list.
930
+ const tools = toolsFor(opts.defaultRepo);
901
931
  // No startup warm: each scan-needing tool warms the present-language grammars
902
932
  // for its repo before it runs (warmGrammarsForRepo re-derives them per call),
903
933
  // so a session that never scans — or only touches one language — loads no
@@ -940,13 +970,13 @@ export async function runMcpServer(opts: McpServerOptions = {}): Promise<void> {
940
970
  } else if (req.method === "ping") {
941
971
  send({ id: req.id, result: {} });
942
972
  } else if (req.method === "tools/list") {
943
- send({ id: req.id, result: { tools: TOOLS } });
973
+ send({ id: req.id, result: { tools } });
944
974
  } else if (req.method === "tools/call") {
945
975
  const params = req.params ?? {};
946
976
  const name = str(params.name) ?? "";
947
977
  const args = (params.arguments ?? {}) as Record<string, unknown>;
948
978
  try {
949
- const text = await callTool(name, args);
979
+ const text = await callTool(name, args, opts.defaultRepo);
950
980
  send({ id: req.id, result: { content: [{ type: "text", text }] } });
951
981
  } catch (e) {
952
982
  send({
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.16.0";
4
+ export const ENGINE_VERSION = "2.17.1";
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