graph-indexer 3.0.0 → 3.0.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/CHANGELOG.md +52 -0
- package/README.md +11 -7
- package/SECURITY.md +21 -4
- package/integrations/claude-code/.claude-plugin/plugin.json +3 -2
- package/integrations/claude-code/.mcp.json +1 -1
- package/integrations/claude-code/hooks/gi-hook.mjs +10 -6
- package/integrations/opencode/graph-indexer.js +13 -8
- package/package.json +2 -2
- package/src/cli/hook.mjs +17 -7
- package/src/cli/init.mjs +34 -17
- package/src/cli/resident.mjs +14 -4
- package/src/index/discover.mjs +3 -5
- package/src/index/modules.mjs +6 -5
- package/src/index/resolver.mjs +9 -2
- package/src/mcp/tools.mjs +2 -1
- package/src/parse/arity.mjs +2 -1
- package/src/parse/extract.mjs +8 -1
- package/src/query/check.mjs +9 -7
- package/src/query/intel.mjs +11 -8
- package/src/query/testcmd.mjs +25 -14
- package/src/query/textsearch.mjs +29 -11
- package/src/util/git.mjs +30 -0
- package/src/util/paths.mjs +40 -1
- package/src/util/regex.mjs +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,58 @@ All notable changes to graph-indexer are documented here. Dates are in YYYY-MM-D
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [3.0.1] — 2026-10-23
|
|
8
|
+
|
|
9
|
+
A security release. Upgrading is recommended for everyone; nothing else changes.
|
|
10
|
+
|
|
11
|
+
### Security
|
|
12
|
+
|
|
13
|
+
- **Node.js 22.23.4+, 24.22.1+ or 26.12.1+** is now required (`engines`). These are the October
|
|
14
|
+
2026 Node.js security releases, which fix CVE-2026-111497 (out-of-bounds write in the Brotli
|
|
15
|
+
decoder, remote code execution) and nine other vulnerabilities in TLS, DNS, HTTP keep-alive,
|
|
16
|
+
`crypto.randomUUID()` and the Permission Model. graph-indexer does not decompress Brotli itself,
|
|
17
|
+
but it runs inside the Node.js process the user has installed.
|
|
18
|
+
- **npx no longer runs a copy of graph-indexer from the repository.** Started from a repository
|
|
19
|
+
that ships `node_modules/graph-indexer` (3.x) with its `.bin` entry, `npx -y graph-indexer@3`
|
|
20
|
+
ran that code instead of the published package: the Claude Code plugin's hooks and MCP server,
|
|
21
|
+
the OpenCode / Kilo Code plugin and the configurations written by `init` did, on opening the
|
|
22
|
+
repository. Every npx command now passes `--prefix` so only the published package runs. Re-run
|
|
23
|
+
`npx -y --prefix / graph-indexer@3 init` to rewrite existing configurations.
|
|
24
|
+
- **`check_changes` refuses a `base` that git would read as an option.** `base: "--output=<file>"`
|
|
25
|
+
made `git diff` create or truncate any file the user can write. A revision must not start with
|
|
26
|
+
`-` or contain whitespace.
|
|
27
|
+
- **`read_code` reads only files inside the repository.** `../../etc/hosts` and a tracked symbolic
|
|
28
|
+
link to a file elsewhere were read and served. Paths are now resolved (`..` and links) and must
|
|
29
|
+
stay under the root; `.git/` and `.graph-indexer/` are not served. `search_text` and
|
|
30
|
+
`check_changes` skip links out of the repository too.
|
|
31
|
+
- **`search_text` stops a regular expression that backtracks catastrophically** (`(a+)+$`) after
|
|
32
|
+
5 s and says so, instead of blocking the server and every other request.
|
|
33
|
+
- **The suggested test commands quote every file name** (a test file named `t$(cmd).test.ts`
|
|
34
|
+
reaches the runner as a name, not as a command substitution), since agents run those commands
|
|
35
|
+
as they are; test names that are not plain identifiers get no command.
|
|
36
|
+
- **git runs nothing that the indexed repository's own config names**: `core.fsmonitor`, hooks
|
|
37
|
+
(`core.hooksPath`), signature verification (`log.showSignature`), clean/smudge/process filters,
|
|
38
|
+
`diff.external` and textconv drivers are switched off on every git call, for repositories copied
|
|
39
|
+
together with their `.git` directory. Required filters such as Git LFS's no longer fail the diff.
|
|
40
|
+
- **A `.graph-indexer` committed as a symbolic link is refused** instead of having the index (which
|
|
41
|
+
contains excerpts of the source) written wherever it points. The directory is created with mode
|
|
42
|
+
0700. The hook clients only talk to a socket, and a socket directory, owned by the user.
|
|
43
|
+
- **Manifests are read only as small regular files inside the repository**: a `tsconfig.json`,
|
|
44
|
+
`package.json` or `go.mod` linked to `/dev/zero` no longer hangs the indexer.
|
|
45
|
+
- **A file whose syntax makes queries super-linear** (tens of thousands of chained calls) is given
|
|
46
|
+
up after 15 s instead of stalling the index.
|
|
47
|
+
- **A field assigned an element of itself** (`self.impl = self.impl[0]`) no longer overflows the
|
|
48
|
+
indexer's stack.
|
|
49
|
+
- **Names are escaped as regular expressions** wherever they are matched in text, not only `$`, and
|
|
50
|
+
a `file:name` target no longer backtracks polynomially.
|
|
51
|
+
- **`init` does not write through links the repository committed** (`CLAUDE.md` → `~/.zshrc`,
|
|
52
|
+
`.claude` → `~/.claude`); it stops and names the file.
|
|
53
|
+
- **Per-session hook state** lives in a per-user directory with mode 0700 and is written
|
|
54
|
+
atomically, so another user of a shared temp directory cannot read it or redirect the write.
|
|
55
|
+
- On Windows, the hook clients quote the arguments they hand to `cmd.exe`.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
7
59
|
## [3.0.0] — 2026-09-23
|
|
8
60
|
|
|
9
61
|
A rewrite around one goal: give coding agents answers that are exact, current and small.
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
<a href="https://www.npmjs.com/package/graph-indexer"><img src="https://img.shields.io/npm/v/graph-indexer?color=007acc&style=for-the-badge" alt="npm version"></a>
|
|
11
11
|
<a href="https://www.npmjs.com/package/graph-indexer"><img src="https://img.shields.io/npm/dt/graph-indexer?color=4caf50&style=for-the-badge" alt="NPM Downloads"></a>
|
|
12
|
-
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node.js
|
|
12
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node.js-22.23.4%2B%20%7C%2024.22.1%2B%20%7C%2026.12.1%2B-brightgreen?style=for-the-badge&logo=nodedotjs" alt="Node.js 22.23.4+, 24.22.1+ or 26.12.1+"></a>
|
|
13
13
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge" alt="License: MIT"></a>
|
|
14
14
|
</div>
|
|
15
15
|
|
|
@@ -40,14 +40,18 @@ instead of reading whole files and grepping.
|
|
|
40
40
|
|
|
41
41
|
## Quick start
|
|
42
42
|
|
|
43
|
-
Requires **Node.js 22.
|
|
43
|
+
Requires **Node.js 22.23.4+, 24.22.1+ or 26.12.1+** (the built-in `node:sqlite`, and the October 2026
|
|
44
|
+
Node.js security releases, which fix CVE-2026-111497).
|
|
44
45
|
|
|
45
46
|
```bash
|
|
46
47
|
cd your-repo
|
|
47
|
-
npx graph-indexer init
|
|
48
|
-
npx graph-indexer index
|
|
48
|
+
npx -y --prefix / graph-indexer@3 init # wires the MCP server into the agents it detects
|
|
49
|
+
npx -y --prefix / graph-indexer@3 index # optional: build the index now (the server also does it on start)
|
|
49
50
|
```
|
|
50
51
|
|
|
52
|
+
`--prefix /` makes npx run the published package even in a repository that ships its own
|
|
53
|
+
`node_modules/graph-indexer`.
|
|
54
|
+
|
|
51
55
|
`init` writes project-level MCP configuration for the agents it finds (`.mcp.json` for Claude
|
|
52
56
|
Code, `.cursor/mcp.json`, `.vscode/mcp.json`, `.gemini/settings.json`, `opencode.json` for
|
|
53
57
|
OpenCode and Kilo Code, `.junie/mcp/mcp.json`, `.zed/settings.json`; it prints the Codex TOML
|
|
@@ -70,7 +74,7 @@ Any MCP client can run the server over stdio:
|
|
|
70
74
|
```json
|
|
71
75
|
{
|
|
72
76
|
"mcpServers": {
|
|
73
|
-
"graph-indexer": { "command": "npx", "args": ["-y", "graph-indexer@3", "serve", "--repo", "/path/to/repo"] }
|
|
77
|
+
"graph-indexer": { "command": "npx", "args": ["-y", "--prefix", "/", "graph-indexer@3", "serve", "--repo", "/path/to/repo"] }
|
|
74
78
|
}
|
|
75
79
|
}
|
|
76
80
|
```
|
|
@@ -81,7 +85,7 @@ VS Code uses `"servers"` instead of `"mcpServers"` and `"type": "stdio"`. Codex
|
|
|
81
85
|
```toml
|
|
82
86
|
[mcp_servers.graph-indexer]
|
|
83
87
|
command = "npx"
|
|
84
|
-
args = ["-y", "graph-indexer@3", "serve", "--repo", "/path/to/repo"]
|
|
88
|
+
args = ["-y", "--prefix", "/", "graph-indexer@3", "serve", "--repo", "/path/to/repo"]
|
|
85
89
|
```
|
|
86
90
|
|
|
87
91
|
Without `--repo` the server indexes the git repository containing its working directory
|
|
@@ -276,7 +280,7 @@ Version 3 is a rewrite; see the [changelog](CHANGELOG.md). In short: the tools c
|
|
|
276
280
|
`check_changes`, `outline`), the
|
|
277
281
|
embedding, Ollama, reranker and daemon options are gone (search is lexical plus graph and needs
|
|
278
282
|
no model), grammars are bundled instead of installed on first use, and Node.js 22.5+ is required.
|
|
279
|
-
Re-run `npx graph-indexer init` to update agent configuration; old `.graph-indexer/` contents are
|
|
283
|
+
Re-run `npx -y --prefix / graph-indexer@3 init` to update agent configuration; old `.graph-indexer/` contents are
|
|
280
284
|
replaced automatically.
|
|
281
285
|
|
|
282
286
|
## License
|
package/SECURITY.md
CHANGED
|
@@ -33,13 +33,24 @@ graph-indexer is a local developer tool. Its threat model follows from a few del
|
|
|
33
33
|
(`node:sqlite`, `node:fs`, `node:child_process`, …) and the vendored WebAssembly runtime.
|
|
34
34
|
- **No code execution.** Source files are parsed into syntax trees; the indexed code is never
|
|
35
35
|
imported, evaluated or run. The only subprocesses are read-only git commands
|
|
36
|
-
(`git rev-parse`, `git ls-files`, `git diff`, `git log`) run in the indexed repository
|
|
37
|
-
fixed arguments
|
|
36
|
+
(`git rev-parse`, `git ls-files`, `git diff`, `git log`, `git show`) run in the indexed repository
|
|
37
|
+
with fixed arguments. The only tool input they take is the `base` revision of `check_changes`,
|
|
38
|
+
which is refused when git could read it as an option. Every git call switches off what the
|
|
39
|
+
repository's own git config could run: `core.fsmonitor`, hooks, signature verification,
|
|
40
|
+
clean/smudge/process filters, `diff.external` and textconv drivers.
|
|
41
|
+
- **No code from the repository through npx.** Every `npx` command graph-indexer runs or writes into
|
|
42
|
+
an agent's configuration passes `--prefix`, so a `node_modules/graph-indexer` that the repository
|
|
43
|
+
ships is never run in place of the published package.
|
|
38
44
|
- **Stays inside the repository.** Only files listed by git (or found by a `.gitignore`-aware
|
|
39
45
|
walk) under the repository root are indexed. Symlinks are followed only when their target
|
|
40
46
|
resolves inside the repository, so a link to a file elsewhere on the machine is never read,
|
|
41
47
|
indexed or served. Paths given to the tools (`read_code "file.ts:42"`, `outline`,
|
|
42
|
-
`change_impact`) are
|
|
48
|
+
`change_impact`) are resolved (`..` and symbolic links) and must stay inside the repository;
|
|
49
|
+
`.git/` and `.graph-indexer/` are never served. Manifests (`package.json`, `tsconfig.json`,
|
|
50
|
+
`go.mod`) are read only as small regular files inside the repository.
|
|
51
|
+
- **Bounded work.** A regular expression given to `search_text` runs under a 5 s budget, and a file
|
|
52
|
+
whose syntax makes the structural queries super-linear is given up after 15 s, so neither can
|
|
53
|
+
block the server.
|
|
43
54
|
- **Read-only tools.** All MCP tools are read-only (`readOnlyHint: true`) and never modify
|
|
44
55
|
the repository.
|
|
45
56
|
- **stdio transport.** The MCP server talks to the local client over stdin/stdout. It does not
|
|
@@ -51,10 +62,16 @@ graph-indexer is a local developer tool. Its threat model follows from a few del
|
|
|
51
62
|
both sides check before using it. It answers the same read-only queries as the CLI.
|
|
52
63
|
`GRAPH_INDEXER_RESIDENT=0` disables it.
|
|
53
64
|
- **Local artifacts.** The index is a SQLite database in `<repo>/.graph-indexer/` (git-ignored by
|
|
54
|
-
`init`). It contains excerpts of your source code — treat it with the same sensitivity as the
|
|
65
|
+
`init`, mode 0700; a `.graph-indexer` that is a symbolic link is refused). It contains excerpts of your source code — treat it with the same sensitivity as the
|
|
55
66
|
repository itself and do not commit or share it. Deleting the directory is always safe; it is
|
|
56
67
|
rebuilt on the next run.
|
|
57
68
|
|
|
69
|
+
## Runtime
|
|
70
|
+
|
|
71
|
+
graph-indexer runs in the Node.js the user installs. It requires a release that includes the
|
|
72
|
+
October 2026 Node.js security fixes (22.23.4+, 24.22.1+ or 26.12.1+; CVE-2026-111497 and others).
|
|
73
|
+
Keep Node.js updated to the latest patch release of a supported line.
|
|
74
|
+
|
|
58
75
|
## Operational guidance
|
|
59
76
|
|
|
60
77
|
- Run the server only on repositories you trust and intend to index.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graph-indexer",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Live code graph for Claude Code: exact references, structural grep, change impact and a post-edit check, from a local index with no model or network.",
|
|
5
5
|
"author": { "name": "MaquinaTech", "url": "https://github.com/MaquinaTech" },
|
|
6
6
|
"homepage": "https://github.com/MaquinaTech/graph-indexer",
|
|
7
|
-
"license": "MIT"
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"icon": "https://raw.githubusercontent.com/MaquinaTech/graph-indexer/main/assets/logo.jpg"
|
|
8
9
|
}
|
|
@@ -43,11 +43,12 @@ export function socketPath(root) {
|
|
|
43
43
|
|
|
44
44
|
function trusted(file, root) {
|
|
45
45
|
if (WIN) return true;
|
|
46
|
-
const
|
|
47
|
-
if (dir === path.join(path.resolve(root), '.graph-indexer')) return fs.existsSync(file);
|
|
46
|
+
const mine = (st) => !process.getuid || st.uid === process.getuid();
|
|
48
47
|
try {
|
|
49
|
-
const
|
|
50
|
-
|
|
48
|
+
const sock = fs.lstatSync(file), dir = fs.lstatSync(path.dirname(file));
|
|
49
|
+
const inRepo = path.dirname(file) === path.join(path.resolve(root), '.graph-indexer');
|
|
50
|
+
// the socket and its directory are ours, not links or listeners planted by the repository or another user
|
|
51
|
+
return sock.isSocket() && mine(sock) && dir.isDirectory() && mine(dir) && (inRepo || (dir.mode & 0o077) === 0);
|
|
51
52
|
} catch { return false; }
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -85,8 +86,11 @@ async function main() {
|
|
|
85
86
|
const root = findRepoRoot(input.cwd || process.cwd());
|
|
86
87
|
const reply = await ask(root, { op: 'hook', event, input }, 2500);
|
|
87
88
|
if (reply) { if (reply.out) process.stdout.write(reply.out + '\n'); return; }
|
|
88
|
-
// no resident yet: the full hook through npx (it starts one for the next calls)
|
|
89
|
-
|
|
89
|
+
// no resident yet: the full hook through npx (it starts one for the next calls); --prefix keeps
|
|
90
|
+
// npx from running a graph-indexer that the repository ships in its own node_modules
|
|
91
|
+
// (on Windows npx.cmd needs a shell, so the home directory is quoted for cmd.exe)
|
|
92
|
+
const home = WIN ? `"${os.homedir()}"` : os.homedir();
|
|
93
|
+
const child = spawn(WIN ? 'npx.cmd' : 'npx', ['-y', '--prefix', home, 'graph-indexer@3', 'hook', event], { stdio: ['pipe', 'inherit', 'ignore'], shell: WIN });
|
|
90
94
|
child.on('error', () => { /* fail open */ });
|
|
91
95
|
child.stdin.on('error', () => { /* fail open */ });
|
|
92
96
|
child.stdin.end(raw);
|
|
@@ -19,8 +19,9 @@ import { spawn } from 'node:child_process';
|
|
|
19
19
|
|
|
20
20
|
const PROTOCOL = 1;
|
|
21
21
|
const WIN = process.platform === 'win32';
|
|
22
|
-
// how to start a resident when none answers (rewritten by `init --local` and installed binaries)
|
|
23
|
-
|
|
22
|
+
// how to start a resident when none answers (rewritten by `init --local` and installed binaries);
|
|
23
|
+
// --prefix keeps npx from running a graph-indexer that the repository ships in its own node_modules
|
|
24
|
+
const DAEMON = ['npx', '-y', '--prefix', os.homedir(), 'graph-indexer@3', 'daemon'];
|
|
24
25
|
// OpenCode tool ids → the tool names graph-indexer's hook understands
|
|
25
26
|
const TOOLS = { read: 'Read', grep: 'Grep', bash: 'Bash', edit: 'Edit', write: 'Write' };
|
|
26
27
|
|
|
@@ -46,12 +47,12 @@ function socketPath(root) {
|
|
|
46
47
|
|
|
47
48
|
function trusted(file, root) {
|
|
48
49
|
if (WIN) return true;
|
|
49
|
-
|
|
50
|
-
const dir = path.dirname(file);
|
|
51
|
-
if (dir === path.join(path.resolve(root), '.graph-indexer')) return true;
|
|
50
|
+
const mine = (st) => !process.getuid || st.uid === process.getuid();
|
|
52
51
|
try {
|
|
53
|
-
const
|
|
54
|
-
|
|
52
|
+
const sock = fs.lstatSync(file), dir = fs.lstatSync(path.dirname(file));
|
|
53
|
+
const inRepo = path.dirname(file) === path.join(path.resolve(root), '.graph-indexer');
|
|
54
|
+
// the socket and its directory are ours, not links or listeners planted by the repository or another user
|
|
55
|
+
return sock.isSocket() && mine(sock) && dir.isDirectory() && mine(dir) && (inRepo || (dir.mode & 0o077) === 0);
|
|
55
56
|
} catch { return false; }
|
|
56
57
|
}
|
|
57
58
|
|
|
@@ -107,7 +108,11 @@ export const GraphIndexer = async ({ directory, worktree } = {}) => {
|
|
|
107
108
|
if (!reply) {
|
|
108
109
|
if (!started) {
|
|
109
110
|
started = true;
|
|
110
|
-
|
|
111
|
+
// cmd.exe (npx.cmd needs a shell on Windows) would run `&`, `|`… in a path: quote
|
|
112
|
+
// every argument, and give up on one that a quote cannot hold
|
|
113
|
+
const args = [...DAEMON.slice(1), '--repo', root];
|
|
114
|
+
if (WIN && args.some(a => /["%\r\n]/.test(a))) return;
|
|
115
|
+
const child = spawn(DAEMON[0], WIN ? args.map(a => `"${a}"`) : args, { detached: true, stdio: 'ignore', windowsHide: true, shell: WIN });
|
|
111
116
|
child.on('error', () => { /* optional */ });
|
|
112
117
|
child.unref();
|
|
113
118
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graph-indexer",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Live code graph and search for AI coding agents over MCP: tree-sitter (WASM) symbols, scope/import/type-aware references, call graphs, change impact and hybrid search — local, zero-dependency, no native builds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/query/intel.mjs",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"index": "node bin/graph-indexer.mjs index"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
|
37
|
-
"node": "
|
|
37
|
+
"node": "^22.23.4 || ^24.22.1 || >=26.12.1"
|
|
38
38
|
},
|
|
39
39
|
"repository": {
|
|
40
40
|
"type": "git",
|
package/src/cli/hook.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import os from 'node:os';
|
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import { spawn } from 'node:child_process';
|
|
33
33
|
import { fileURLToPath } from 'node:url';
|
|
34
|
-
import { findRepoRoot, dataDir, DATA_DIR_NAME } from '../util/paths.mjs';
|
|
34
|
+
import { findRepoRoot, dataDir, DATA_DIR_NAME, readRepoFile } from '../util/paths.mjs';
|
|
35
35
|
import { specForPath } from '../parse/languages.mjs';
|
|
36
36
|
|
|
37
37
|
const MAX_CONTEXT = 1500;
|
|
@@ -78,16 +78,26 @@ function responseText(input) {
|
|
|
78
78
|
/** Navigation calls since the last edit and definitions already shown, per session (crawl detector). */
|
|
79
79
|
function sessionState(input) {
|
|
80
80
|
const id = String(input.session_id ?? input.sessionId ?? input.conversation_id ?? 'default').replace(/[^\w.-]/g, '_').slice(0, 80);
|
|
81
|
-
|
|
81
|
+
// a per-user directory closed to others: in a shared temp dir another user could otherwise read
|
|
82
|
+
// the state or plant links that the next save writes through
|
|
83
|
+
const dir = path.join(os.tmpdir(), `graph-indexer-hooks-${process.getuid?.() ?? 'user'}`);
|
|
84
|
+
const file = path.join(dir, `${id}.json`);
|
|
85
|
+
const privateDir = () => {
|
|
86
|
+
if (process.platform === 'win32') return true;
|
|
87
|
+
try { const d = fs.lstatSync(dir); return d.isDirectory() && d.uid === process.getuid() && (d.mode & 0o077) === 0; } catch { return false; }
|
|
88
|
+
};
|
|
82
89
|
let st = { nav: 0, shown: [] };
|
|
83
|
-
try { st = { ...st, ...JSON.parse(fs.readFileSync(file, 'utf8')) }; } catch { /* first call */ }
|
|
90
|
+
try { if (privateDir() && fs.lstatSync(file).isFile()) st = { ...st, ...JSON.parse(fs.readFileSync(file, 'utf8')) }; } catch { /* first call */ }
|
|
84
91
|
return {
|
|
85
92
|
st,
|
|
86
93
|
save() {
|
|
87
94
|
try {
|
|
88
|
-
fs.mkdirSync(
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
96
|
+
if (!privateDir()) return;
|
|
89
97
|
st.shown = st.shown.slice(-400);
|
|
90
|
-
|
|
98
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
99
|
+
fs.writeFileSync(tmp, JSON.stringify(st), { flag: 'wx', mode: 0o600 });
|
|
100
|
+
fs.renameSync(tmp, file);
|
|
91
101
|
} catch { /* optional */ }
|
|
92
102
|
},
|
|
93
103
|
};
|
|
@@ -269,7 +279,7 @@ async function afterRead(root, input, state, crawling, acquire) {
|
|
|
269
279
|
const RULES = `How to look code up in this repository (graph-indexer):
|
|
270
280
|
- Several things at once: several tool calls in one message, or one search with alternatives — not one search per turn.
|
|
271
281
|
- The function or the 50–100 lines you need, not whole files; to find a definition, search for its definition line (\`def name\`, \`class Name\`) across the package in one search.
|
|
272
|
-
- Exact uses of a function or class, even when other code shares its name: find_references (\`npx graph-indexer refs NAME\` in a shell). Before changing what other code relies on: change_impact; when you are done: check_changes, then run the tests it names, once.`;
|
|
282
|
+
- Exact uses of a function or class, even when other code shares its name: find_references (\`npx -y --prefix / graph-indexer@3 refs NAME\` in a shell). Before changing what other code relies on: change_impact; when you are done: check_changes, then run the tests it names, once.`;
|
|
273
283
|
|
|
274
284
|
async function sessionLine(root, { rules = false, acquire }) {
|
|
275
285
|
if (!hasIndex(root)) return null; // being built in the background (spawnResident); say nothing yet
|
|
@@ -280,7 +290,7 @@ async function sessionLine(root, { rules = false, acquire }) {
|
|
|
280
290
|
const st = intel.stats();
|
|
281
291
|
const line = `graph-indexer has a live index of this repository (${st.files} files, ${st.symbols} symbols). Its MCP tools answer with exact locations: find_references (exact uses, even when other code shares the name), change_impact (what a change affects and which tests to run), check_changes (what an edit broke, and the closest tests), call_graph, read_code (symbols or ranges by name, with where each name they use is defined), search_text, search_code and outline.`;
|
|
282
292
|
// the managed block of `init` already carries the rules; otherwise add them
|
|
283
|
-
const carried = ['CLAUDE.md', 'AGENTS.md'].some(f =>
|
|
293
|
+
const carried = ['CLAUDE.md', 'AGENTS.md'].some(f => (readRepoFile(root, f) ?? '').includes('<!-- graph-indexer:start -->'));
|
|
284
294
|
return rules || !carried ? `${line}\n${RULES}` : line;
|
|
285
295
|
} finally { ix.release(); }
|
|
286
296
|
}
|
package/src/cli/init.mjs
CHANGED
|
@@ -52,13 +52,17 @@ function readJson(p) {
|
|
|
52
52
|
try { return JSON.parse(fs.readFileSync(p, 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return null; }
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// --prefix /: npx would otherwise run a graph-indexer 3.x found in the repository's own node_modules
|
|
56
|
+
// (code the repository ships) instead of the published package
|
|
57
|
+
const NPX_ARGS = ['-y', '--prefix', '/', 'graph-indexer@3'];
|
|
58
|
+
|
|
55
59
|
function serverEntry(agent, { local }) {
|
|
56
60
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
57
61
|
const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
|
|
58
62
|
const repoArgs = agent.workspaceVar ? ['--repo', agent.workspaceVar] : [];
|
|
59
63
|
const base = local
|
|
60
64
|
? { command: process.execPath, args: [bin, 'serve', ...repoArgs] }
|
|
61
|
-
: { command: 'npx', args: [
|
|
65
|
+
: { command: 'npx', args: [...NPX_ARGS, 'serve', ...repoArgs] };
|
|
62
66
|
if (agent.opencode) return { type: 'local', command: [base.command, ...base.args], enabled: true };
|
|
63
67
|
if (agent.zed) return { ...base, env: {} };
|
|
64
68
|
return agent.vscode ? { type: 'stdio', ...base } : base;
|
|
@@ -70,7 +74,24 @@ function configFile(agent, repo) {
|
|
|
70
74
|
return agent.file;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Write `file` under `repo` without following a link the repository committed (`CLAUDE.md` →
|
|
79
|
+
* ~/.zshrc, `.claude` → ~/.claude): such a target is refused instead of written through.
|
|
80
|
+
*/
|
|
81
|
+
function writeInRepo(repo, file, content) {
|
|
82
|
+
const rootReal = fs.realpathSync(repo);
|
|
83
|
+
const inside = (p) => { const r = fs.realpathSync(p); return r === rootReal || r.startsWith(rootReal + path.sep); };
|
|
84
|
+
let dir = path.dirname(file);
|
|
85
|
+
while (!fs.existsSync(dir)) dir = path.dirname(dir);
|
|
86
|
+
let link = false;
|
|
87
|
+
try { link = fs.lstatSync(file).isSymbolicLink(); } catch { /* a new file */ }
|
|
88
|
+
if (link || !inside(dir)) throw new Error(`${path.relative(repo, file)} leads outside the repository (a symbolic link); not written`);
|
|
89
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
90
|
+
if (!inside(path.dirname(file))) throw new Error(`${path.relative(repo, file)} leads outside the repository (a symbolic link); not written`);
|
|
91
|
+
fs.writeFileSync(file, content);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function upsertBlock(repo, file, dryRun) {
|
|
74
95
|
const exists = fs.existsSync(file);
|
|
75
96
|
const text = exists ? fs.readFileSync(file, 'utf8') : '';
|
|
76
97
|
let next;
|
|
@@ -80,7 +101,7 @@ function upsertBlock(file, dryRun) {
|
|
|
80
101
|
next = (text.trimEnd() ? text.trimEnd() + '\n\n' : '') + SNIPPET + '\n';
|
|
81
102
|
}
|
|
82
103
|
if (next === text) return 'unchanged';
|
|
83
|
-
if (!dryRun)
|
|
104
|
+
if (!dryRun) writeInRepo(repo, file, next);
|
|
84
105
|
return exists ? 'updated' : 'created';
|
|
85
106
|
}
|
|
86
107
|
|
|
@@ -100,7 +121,7 @@ function cliCommand(local) {
|
|
|
100
121
|
const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
|
|
101
122
|
if (local) return `${JSON.stringify(process.execPath)} ${JSON.stringify(bin)}`;
|
|
102
123
|
const installed = installedBin();
|
|
103
|
-
return installed ? JSON.stringify(installed) : 'npx
|
|
124
|
+
return installed ? JSON.stringify(installed) : ['npx', ...NPX_ARGS].join(' ');
|
|
104
125
|
}
|
|
105
126
|
|
|
106
127
|
/** The command that starts graph-indexer, as an argv array (for the OpenCode/Kilo Code plugin). */
|
|
@@ -108,7 +129,7 @@ function cliArgv(local) {
|
|
|
108
129
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
109
130
|
if (local) return [process.execPath, path.resolve(here, '../../bin/graph-indexer.mjs')];
|
|
110
131
|
const installed = installedBin();
|
|
111
|
-
return installed ? [installed] : ['npx',
|
|
132
|
+
return installed ? [installed] : ['npx', ...NPX_ARGS];
|
|
112
133
|
}
|
|
113
134
|
|
|
114
135
|
/**
|
|
@@ -128,8 +149,7 @@ function writeOpenCodePlugin(repo, local, dryRun) {
|
|
|
128
149
|
const file = path.join(repo, rel);
|
|
129
150
|
const same = fs.existsSync(file) && fs.readFileSync(file, 'utf8') === src;
|
|
130
151
|
if (!same && !dryRun) {
|
|
131
|
-
|
|
132
|
-
fs.writeFileSync(file, src);
|
|
152
|
+
writeInRepo(repo, file, src);
|
|
133
153
|
}
|
|
134
154
|
out.push(`${same ? 'already in' : 'written to'} ${rel}`);
|
|
135
155
|
}
|
|
@@ -160,8 +180,7 @@ function upsertClaudeHooks(repo, local, dryRun) {
|
|
|
160
180
|
}
|
|
161
181
|
const changed = before !== JSON.stringify(cfg.hooks);
|
|
162
182
|
if (changed && !dryRun) {
|
|
163
|
-
|
|
164
|
-
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
|
|
183
|
+
writeInRepo(repo, file, JSON.stringify(cfg, null, 2) + '\n');
|
|
165
184
|
}
|
|
166
185
|
return `Claude Code hooks: ${changed ? 'written to' : 'already in'} .claude/settings.json (post-edit check, grep disambiguation, session line)`;
|
|
167
186
|
}
|
|
@@ -178,8 +197,7 @@ function upsertClaudeHelper(repo, dryRun) {
|
|
|
178
197
|
if (have === want) return `Claude Code: structural helper already in ${rel}`;
|
|
179
198
|
if (have != null && !have.includes("graph-indexer's index")) return `Claude Code: ${rel} exists and is not graph-indexer's, so it was left as is`;
|
|
180
199
|
if (!dryRun) {
|
|
181
|
-
|
|
182
|
-
fs.writeFileSync(file, want);
|
|
200
|
+
writeInRepo(repo, file, want);
|
|
183
201
|
}
|
|
184
202
|
return `Claude Code: structural helper ${have == null ? 'written to' : 'updated in'} ${rel} (a sub-agent on a small model that answers call-site, caller, subclass and impact questions from the index)`;
|
|
185
203
|
}
|
|
@@ -221,8 +239,7 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
|
|
|
221
239
|
cfg[a.key]['graph-indexer'] = entry;
|
|
222
240
|
const changed = before !== JSON.stringify(entry);
|
|
223
241
|
if (changed && !dryRun) {
|
|
224
|
-
|
|
225
|
-
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
|
|
242
|
+
writeInRepo(repo, file, JSON.stringify(cfg, null, 2) + '\n');
|
|
226
243
|
}
|
|
227
244
|
report.push(`${a.label}: ${changed ? (before === 'null' ? 'added' : 'updated') : 'already configured'} ${rel}`);
|
|
228
245
|
} else if (key === 'codex') {
|
|
@@ -239,17 +256,17 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
|
|
|
239
256
|
if (hooks && chosen.includes('opencode')) report.push(writeOpenCodePlugin(repo, local, dryRun));
|
|
240
257
|
if (!noInstructions) {
|
|
241
258
|
const targets = [...new Set(chosen.map(k => AGENTS[k].instructions))];
|
|
242
|
-
for (const t of targets) report.push(`${t}: ${upsertBlock(path.join(repo, t), dryRun)} graph-indexer instructions block`);
|
|
259
|
+
for (const t of targets) report.push(`${t}: ${upsertBlock(repo, path.join(repo, t), dryRun)} graph-indexer instructions block`);
|
|
243
260
|
}
|
|
244
261
|
// keep the index out of version control
|
|
245
262
|
const gi = path.join(repo, '.gitignore');
|
|
246
263
|
const giText = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
|
|
247
264
|
if (!/^\/?\.graph-indexer\/?$/m.test(giText)) {
|
|
248
|
-
if (!dryRun)
|
|
265
|
+
if (!dryRun) writeInRepo(repo, gi, (giText.trimEnd() ? giText.trimEnd() + '\n' : '') + '.graph-indexer/\n');
|
|
249
266
|
report.push('.gitignore: added .graph-indexer/');
|
|
250
267
|
}
|
|
251
268
|
process.stdout.write(`graph-indexer init ${dryRun ? '(dry run) ' : ''}in ${repo}\n ${report.join('\n ')}\n\n` +
|
|
252
|
-
`Other agents (Claude Desktop, JetBrains AI Assistant, Cline): add an MCP server with command "npx" and args [
|
|
253
|
-
`Build the index now with: npx
|
|
269
|
+
`Other agents (Claude Desktop, JetBrains AI Assistant, Cline): add an MCP server with command "npx" and args ${JSON.stringify([...NPX_ARGS, 'serve', '--repo', repo])}.\n` +
|
|
270
|
+
`Build the index now with: npx ${NPX_ARGS.join(' ')} index (the server also builds it on first start)\n` +
|
|
254
271
|
(hooks ? '' : `Optional: graph-indexer init --hooks adds Claude Code hooks (edit check after each edit, grep disambiguation).\n`));
|
|
255
272
|
}
|
package/src/cli/resident.mjs
CHANGED
|
@@ -44,15 +44,25 @@ export function socketPath(root) {
|
|
|
44
44
|
return path.join(os.tmpdir(), `graph-indexer-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
const mine = (st) => (process.getuid ? st.uid === process.getuid() : true);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The socket's directory must be a real directory of ours (not a link the repository committed);
|
|
51
|
+
* a per-user directory in the temp dir must also be closed to everyone else.
|
|
52
|
+
*/
|
|
48
53
|
function privateDir(dir, root) {
|
|
49
|
-
if (WIN
|
|
54
|
+
if (WIN) return true;
|
|
50
55
|
try {
|
|
51
56
|
const st = fs.lstatSync(dir);
|
|
52
|
-
return st.isDirectory() && (
|
|
57
|
+
return st.isDirectory() && mine(st) && (dir === path.join(path.resolve(root), DATA_DIR_NAME) || (st.mode & 0o077) === 0);
|
|
53
58
|
} catch { return false; }
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
/** A socket of ours, not a file or a listener someone else put there. */
|
|
62
|
+
function ownSocket(file) {
|
|
63
|
+
try { const st = fs.lstatSync(file); return st.isSocket() && mine(st); } catch { return false; }
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
const disabled = () => process.env.GRAPH_INDEXER_RESIDENT === '0';
|
|
57
67
|
/** GRAPH_INDEXER_RESIDENT=require: answer only through a resident, never by opening the index (tests, debugging). */
|
|
58
68
|
export const residentRequired = () => process.env.GRAPH_INDEXER_RESIDENT === 'require';
|
|
@@ -64,7 +74,7 @@ export const residentRequired = () => process.env.GRAPH_INDEXER_RESIDENT === 're
|
|
|
64
74
|
export function askResident(root, request, { version, connectMs = 250, totalMs = 5000 } = {}) {
|
|
65
75
|
if (disabled()) return Promise.resolve(null);
|
|
66
76
|
const file = socketPath(root);
|
|
67
|
-
if (!WIN && (!
|
|
77
|
+
if (!WIN && (!ownSocket(file) || !privateDir(path.dirname(file), root))) return Promise.resolve(null);
|
|
68
78
|
return new Promise((resolve) => {
|
|
69
79
|
let done = false, connected = false, buf = '';
|
|
70
80
|
const sock = net.createConnection(file);
|
package/src/index/discover.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { gitSync } from '../util/git.mjs';
|
|
10
10
|
import { specForPath } from '../parse/languages.mjs';
|
|
11
11
|
|
|
12
12
|
export const MAX_FILE_BYTES = 1_500_000;
|
|
@@ -24,14 +24,12 @@ const SKIP_DIRS = new Set([
|
|
|
24
24
|
const GENERATED_FILE = /(\.min\.(js|css|mjs)$|\.bundle\.js$|[.-]bundle\.[cm]?js$|\.chunk\.js$|\.map$|\.pb\.go$|_pb2(_grpc)?\.py$|\.pb\.(cc|h)$|\.generated\.\w+$|\.g\.dart$|\.designer\.cs$|(^|\/)generated\/|\.d\.ts\.map$)/i;
|
|
25
25
|
|
|
26
26
|
function isGitRepo(root) {
|
|
27
|
-
const r =
|
|
27
|
+
const r = gitSync(root, ['rev-parse', '--is-inside-work-tree']);
|
|
28
28
|
return r.status === 0 && r.stdout.trim() === 'true';
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function gitListFiles(root) {
|
|
32
|
-
const r =
|
|
33
|
-
cwd: root, encoding: 'utf8', maxBuffer: 1 << 30,
|
|
34
|
-
});
|
|
32
|
+
const r = gitSync(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { maxBuffer: 1 << 30 });
|
|
35
33
|
if (r.status !== 0) return null;
|
|
36
34
|
return r.stdout.split('\0').filter(Boolean);
|
|
37
35
|
}
|
package/src/index/modules.mjs
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import { readRepoFile } from '../util/paths.mjs';
|
|
8
9
|
|
|
9
10
|
const ECMA_EXTS = ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
|
|
10
11
|
|
|
11
|
-
function readJson(
|
|
12
|
+
function readJson(root, rel) {
|
|
12
13
|
try {
|
|
13
|
-
const text =
|
|
14
|
+
const text = readRepoFile(root, rel).replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
|
|
14
15
|
return JSON.parse(text);
|
|
15
16
|
} catch { return null; }
|
|
16
17
|
}
|
|
@@ -53,7 +54,7 @@ export class ModuleResolver {
|
|
|
53
54
|
this.packages = new Map();
|
|
54
55
|
const pkgJsons = [...this.files].length ? this.#findManifests('package.json') : [];
|
|
55
56
|
for (const rel of pkgJsons) {
|
|
56
|
-
const j = readJson(
|
|
57
|
+
const j = readJson(this.root, rel);
|
|
57
58
|
if (j?.name) this.packages.set(j.name, { dir: path.posix.dirname(rel), json: j });
|
|
58
59
|
}
|
|
59
60
|
// tsconfig paths and baseUrl, per project: a file resolves through the nearest tsconfig.json
|
|
@@ -73,7 +74,7 @@ export class ModuleResolver {
|
|
|
73
74
|
this.goModules = [];
|
|
74
75
|
for (const rel of this.#findManifests('go.mod')) {
|
|
75
76
|
try {
|
|
76
|
-
const m = /^module\s+(\S+)/m.exec(
|
|
77
|
+
const m = /^module\s+(\S+)/m.exec(readRepoFile(this.root, rel) ?? '');
|
|
77
78
|
if (m) this.goModules.push({ module: m[1], dir: path.posix.dirname(rel) });
|
|
78
79
|
} catch { /* unreadable */ }
|
|
79
80
|
}
|
|
@@ -82,7 +83,7 @@ export class ModuleResolver {
|
|
|
82
83
|
|
|
83
84
|
/** compilerOptions of a tsconfig file, following relative `extends` (a child's paths replace its parent's). */
|
|
84
85
|
#tsOptions(rel, depth = 0) {
|
|
85
|
-
const j = readJson(
|
|
86
|
+
const j = readJson(this.root, rel);
|
|
86
87
|
if (!j || depth > 5) return null;
|
|
87
88
|
const dir = path.posix.dirname(rel);
|
|
88
89
|
let out = { paths: null, pathsDir: null, baseUrl: null };
|
package/src/index/resolver.mjs
CHANGED
|
@@ -16,8 +16,12 @@
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { normalizeType } from '../parse/extract.mjs';
|
|
18
18
|
import { fits, sigArity } from '../parse/arity.mjs';
|
|
19
|
+
import { escapeRegExp } from '../util/regex.mjs';
|
|
19
20
|
|
|
20
21
|
const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl']);
|
|
22
|
+
// element accesses one descriptor may stack (`xs[i][j]`…): a field assigned an element of itself
|
|
23
|
+
// (`self.impl = self.impl[0]`) would add one on every lookup, with no end
|
|
24
|
+
const MAX_ELEM = 8;
|
|
21
25
|
const VALUE_TYPED_KINDS = new Set(['variable', 'constant', 'field', 'property']);
|
|
22
26
|
const CALLABLE_KINDS = new Set(['function', 'method', 'constructor', 'macro']);
|
|
23
27
|
const MEMBER_HOLDERS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'object', 'impl', 'module']);
|
|
@@ -100,7 +104,7 @@ function paramType(param) {
|
|
|
100
104
|
*/
|
|
101
105
|
export function callbackParamTypeText(sig, name, i, j) {
|
|
102
106
|
if (!sig) return null;
|
|
103
|
-
const m = new RegExp(`(?:^|[^\\w$])${name
|
|
107
|
+
const m = new RegExp(`(?:^|[^\\w$])${escapeRegExp(name)}\\s*(?:<[^()]*>)?\\s*\\(`).exec(sig);
|
|
104
108
|
const list = m ? bracketed(sig, m.index + m[0].length - 1) : null;
|
|
105
109
|
let type = list ? paramType(splitTopLevel(list.inner)[i] ?? '') : null;
|
|
106
110
|
if (!type) return null;
|
|
@@ -376,7 +380,10 @@ export class Resolver {
|
|
|
376
380
|
*/
|
|
377
381
|
typeFromString(str, fileId, elem = 0) {
|
|
378
382
|
if (!str) return null;
|
|
379
|
-
if (str.startsWith('call:') || str.startsWith('cb:') || str.includes('#'))
|
|
383
|
+
if (str.startsWith('call:') || str.startsWith('cb:') || str.includes('#')) {
|
|
384
|
+
const desc = str + '[]'.repeat(elem);
|
|
385
|
+
return desc.split('[]').length - 1 > MAX_ELEM ? null : this.resolveRecvType(desc, fileId, null);
|
|
386
|
+
}
|
|
380
387
|
let s = str;
|
|
381
388
|
for (let i = 0; i < elem; i++) {
|
|
382
389
|
if (!s.endsWith('[]') && !s.endsWith('{}')) {
|
package/src/mcp/tools.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { checkChanges } from '../query/check.mjs';
|
|
|
12
12
|
import { definitionsUsed, testsUsing } from '../query/read.mjs';
|
|
13
13
|
import { factsForRange, renderFacts } from '../query/facts.mjs';
|
|
14
14
|
import fs from 'node:fs';
|
|
15
|
+
import { withinRoot } from '../util/paths.mjs';
|
|
15
16
|
|
|
16
17
|
const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl']);
|
|
17
18
|
const VALUE_KINDS = new Set(['field', 'property', 'variable', 'constant']);
|
|
@@ -421,7 +422,7 @@ async function toolRead(intel, { targets = [], target = null, full = false, max_
|
|
|
421
422
|
}
|
|
422
423
|
|
|
423
424
|
function isFile(intel, rel) {
|
|
424
|
-
try { return fs.statSync(path.join(intel.root, rel)).isFile(); } catch { return false; }
|
|
425
|
+
try { return withinRoot(intel.root, rel) && fs.statSync(path.join(intel.root, rel)).isFile(); } catch { return false; }
|
|
425
426
|
}
|
|
426
427
|
|
|
427
428
|
async function readOne(intel, raw, { full, maxLines, seen }) {
|
package/src/parse/arity.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* ({ n, open }, open = a spread/splat makes n a lower bound). Used by the edit checker to spot
|
|
5
5
|
* call sites that no longer match a changed signature, without compiling anything.
|
|
6
6
|
*/
|
|
7
|
+
import { escapeRegExp } from '../util/regex.mjs';
|
|
7
8
|
|
|
8
9
|
const PARAM_LIST_TYPES = new Set(['formal_parameters', 'parameters', 'parameter_list', 'function_value_parameters', 'method_parameters',
|
|
9
10
|
'lambda_parameters', 'block_parameters', 'parameter_clause']);
|
|
@@ -117,7 +118,7 @@ export function callArgc(call) {
|
|
|
117
118
|
*/
|
|
118
119
|
export function sigArity(sig, name) {
|
|
119
120
|
if (!sig) return null;
|
|
120
|
-
const m = new RegExp(`(?:^|[^\\w$])${name
|
|
121
|
+
const m = new RegExp(`(?:^|[^\\w$])${escapeRegExp(name)}\\s*(?:<[^()]*>)?\\s*\\(`).exec(sig);
|
|
121
122
|
if (!m) return null;
|
|
122
123
|
let depth = 0, cur = '', min = 0, max = 0, done = false;
|
|
123
124
|
const param = (p) => {
|
package/src/parse/extract.mjs
CHANGED
|
@@ -334,9 +334,16 @@ export async function extractFile(spec, source, relPath) {
|
|
|
334
334
|
}
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
// query time can grow super-linearly with the syntax (60,000 chained calls in a 360 KB file take
|
|
338
|
+
// minutes): such a file is given up after this long instead of stalling the whole index
|
|
339
|
+
const QUERY_BUDGET_MS = 15_000;
|
|
340
|
+
|
|
337
341
|
function extractFromTree(spec, query, tree, source, relPath) {
|
|
338
342
|
const root = tree.rootNode;
|
|
339
|
-
const
|
|
343
|
+
const deadline = Date.now() + QUERY_BUDGET_MS;
|
|
344
|
+
let gaveUp = false;
|
|
345
|
+
const matches = query.matches(root, { progressCallback: () => (gaveUp = Date.now() > deadline) });
|
|
346
|
+
if (gaveUp) throw new Error(`gave up after ${QUERY_BUDGET_MS / 1000} s of queries (pathological syntax)`);
|
|
340
347
|
|
|
341
348
|
const defs = []; // { node, nameNode, kind, owner }
|
|
342
349
|
const rawRefs = []; // { node, nameNode, kind, recvNode }
|
package/src/query/check.mjs
CHANGED
|
@@ -11,19 +11,20 @@
|
|
|
11
11
|
* inherited subclasses that inherit a changed member (they run the new code) or override it
|
|
12
12
|
* tests the test files that exercise the changed code, and the command to run them
|
|
13
13
|
*/
|
|
14
|
-
import fs from 'node:fs';
|
|
15
|
-
import path from 'node:path';
|
|
16
|
-
import { spawnSync } from 'node:child_process';
|
|
17
14
|
import { specForPath, isConstructor } from '../parse/languages.mjs';
|
|
18
15
|
import { prepareLanguage, extractFile } from '../parse/extract.mjs';
|
|
19
16
|
import { paramArity, callAt, callArgc, fits, describeArity } from '../parse/arity.mjs';
|
|
20
17
|
import { testCommands, testCaseCommands } from './testcmd.mjs';
|
|
18
|
+
import { escapeRegExp } from '../util/regex.mjs';
|
|
19
|
+
import { readRepoFile, isRevision } from '../util/paths.mjs';
|
|
20
|
+
import { MAX_FILE_BYTES } from '../index/discover.mjs';
|
|
21
|
+
import { gitSync } from '../util/git.mjs';
|
|
21
22
|
|
|
22
23
|
const CALLABLE = new Set(['function', 'method', 'constructor']);
|
|
23
24
|
const MEMBER_KINDS = new Set(['class', 'interface', 'struct', 'trait', 'enum', 'object', 'impl', 'module', 'type']);
|
|
24
25
|
|
|
25
26
|
function git(root, args) {
|
|
26
|
-
const r =
|
|
27
|
+
const r = gitSync(root, args, { maxBuffer: 1 << 28 });
|
|
27
28
|
return r.status === 0 ? r.stdout : null;
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -143,6 +144,7 @@ function unionFits(argc, arities) {
|
|
|
143
144
|
|
|
144
145
|
export async function checkChanges(intel, { base = 'HEAD', files = null, tests = true } = {}) {
|
|
145
146
|
const root = intel.root;
|
|
147
|
+
if (!isRevision(base)) return { error: `Not a git revision: ${base}` };
|
|
146
148
|
const changed = changedFiles(root, base);
|
|
147
149
|
if (!changed) return { error: `git diff against ${base} failed — is this a git repository with that commit?` };
|
|
148
150
|
// the edited files must be re-indexed now, whatever the file watcher has seen so far
|
|
@@ -158,8 +160,8 @@ export async function checkChanges(intel, { base = 'HEAD', files = null, tests =
|
|
|
158
160
|
let v = null;
|
|
159
161
|
const spec = specForPath(rel);
|
|
160
162
|
try {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
const src = spec ? readRepoFile(root, rel, MAX_FILE_BYTES) : null;
|
|
164
|
+
if (src !== null) {
|
|
163
165
|
const { parser } = await prepareLanguage(spec);
|
|
164
166
|
v = { tree: parser.parse(src), lines: src.split(/\r?\n/), src, spec };
|
|
165
167
|
}
|
|
@@ -276,7 +278,7 @@ export async function checkChanges(intel, { base = 'HEAD', files = null, tests =
|
|
|
276
278
|
const files = new Map();
|
|
277
279
|
const mentions = (p, word) => {
|
|
278
280
|
const key = p + '\u0000' + word;
|
|
279
|
-
if (!files.has(key)) { const lines = intel.fileLines(p); files.set(key, !!lines && lines.some(l => new RegExp(`\\b${word
|
|
281
|
+
if (!files.has(key)) { const lines = intel.fileLines(p); files.set(key, !!lines && lines.some(l => new RegExp(`\\b${escapeRegExp(word)}\\b`).test(l))); }
|
|
280
282
|
return files.get(key);
|
|
281
283
|
};
|
|
282
284
|
rows = rows.filter(r => {
|
package/src/query/intel.mjs
CHANGED
|
@@ -7,15 +7,16 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import { spawnSync } from 'node:child_process';
|
|
11
10
|
import { Store } from '../store/db.mjs';
|
|
12
11
|
import { Indexer } from '../index/indexer.mjs';
|
|
13
12
|
import { SearchEngine } from '../search/search.mjs';
|
|
14
13
|
import { computeCentrality, loadGraph } from '../index/graph.mjs';
|
|
15
14
|
import { confidenceLabel } from '../index/resolver.mjs';
|
|
16
|
-
import { dataDir } from '../util/paths.mjs';
|
|
15
|
+
import { dataDir, withinRoot, isRevision } from '../util/paths.mjs';
|
|
17
16
|
import { isConstructor } from '../parse/languages.mjs';
|
|
18
17
|
import { sigArity } from '../parse/arity.mjs';
|
|
18
|
+
import { escapeRegExp } from '../util/regex.mjs';
|
|
19
|
+
import { gitSync } from '../util/git.mjs';
|
|
19
20
|
|
|
20
21
|
const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl']);
|
|
21
22
|
const CALLABLE = new Set(['function', 'method', 'constructor', 'macro']);
|
|
@@ -128,6 +129,7 @@ export class CodeIntel {
|
|
|
128
129
|
|
|
129
130
|
// ── source access ────────────────────────────────────────────────────────────
|
|
130
131
|
fileLines(rel) {
|
|
132
|
+
if (!withinRoot(this.root, rel)) return null;
|
|
131
133
|
const abs = path.join(this.root, rel);
|
|
132
134
|
let st;
|
|
133
135
|
try { st = fs.statSync(abs); } catch { return null; }
|
|
@@ -162,7 +164,7 @@ export class CodeIntel {
|
|
|
162
164
|
ORDER BY (s.end_line - s.start_line) ASC LIMIT 1`, file.replace(/^\.\//, ''), Number(line), Number(line));
|
|
163
165
|
return { matches: rows.map(r => this.sym(r.id)), how: 'location' };
|
|
164
166
|
}
|
|
165
|
-
m = /^(
|
|
167
|
+
m = /^([^:#]+?\.[A-Za-z0-9]+)[:#](.+)$/.exec(t);
|
|
166
168
|
if (m) {
|
|
167
169
|
const [, file, q] = m;
|
|
168
170
|
const rows = this.store.all(`SELECT s.id FROM symbols s JOIN files f ON f.id = s.file_id WHERE f.path = ? AND (s.qname = ? OR s.name = ? OR s.qname LIKE ?) ORDER BY s.qname = ? DESC, s.start_line LIMIT ?`,
|
|
@@ -367,7 +369,7 @@ export class CodeIntel {
|
|
|
367
369
|
const ids = [...family];
|
|
368
370
|
for (const { path: p } of this.store.all(`SELECT DISTINCT f.path FROM refs r JOIN files f ON f.id = r.file_id WHERE r.dst_id IN (${ids.map(() => '?').join(',')})`, ...ids)) files.add(p);
|
|
369
371
|
} else files.add(sym.path);
|
|
370
|
-
const name = sym.name
|
|
372
|
+
const name = escapeRegExp(sym.name);
|
|
371
373
|
const lit = new RegExp(`(['"\`])(?:[\\w.]*\\.)?${name}\\1`);
|
|
372
374
|
const title = /^\s*(?:describe|it|test|context|suite)(?:\.\w+)?\s*\(/;
|
|
373
375
|
const out = [];
|
|
@@ -451,7 +453,7 @@ export class CodeIntel {
|
|
|
451
453
|
return { total: rows.length, plausible, typeNames: [] };
|
|
452
454
|
}
|
|
453
455
|
const names = [...typeNames];
|
|
454
|
-
const wordRe = new RegExp(`\\b(${names.map(n => n
|
|
456
|
+
const wordRe = new RegExp(`\\b(${names.map(n => escapeRegExp(n)).join('|')})\\b`);
|
|
455
457
|
const lower = names.map(n => n.toLowerCase());
|
|
456
458
|
const mentions = new Map();
|
|
457
459
|
const plausible = rows.filter(r => {
|
|
@@ -575,7 +577,8 @@ export class CodeIntel {
|
|
|
575
577
|
// ── impact ───────────────────────────────────────────────────────────────────
|
|
576
578
|
/** Symbols overlapping the working-tree diff (git), as impact seeds. */
|
|
577
579
|
diffSeeds(base = 'HEAD') {
|
|
578
|
-
|
|
580
|
+
if (!isRevision(base)) return { error: `Not a git revision: ${base}`, seeds: [], files: [] };
|
|
581
|
+
const r = gitSync(this.root, ['diff', '-U0', '--no-color', base, '--'], { maxBuffer: 1 << 28 });
|
|
579
582
|
if (r.status !== 0) return { error: (r.stderr || 'git diff failed').trim(), seeds: [], files: [] };
|
|
580
583
|
const changes = new Map();
|
|
581
584
|
let file = null;
|
|
@@ -587,7 +590,7 @@ export class CodeIntel {
|
|
|
587
590
|
(changes.get(file) ?? changes.set(file, []).get(file)).push([start, Math.max(start, start + len - 1)]);
|
|
588
591
|
}
|
|
589
592
|
}
|
|
590
|
-
const untracked =
|
|
593
|
+
const untracked = gitSync(this.root, ['ls-files', '--others', '--exclude-standard']);
|
|
591
594
|
for (const f of (untracked.stdout || '').split('\n').filter(Boolean)) if (!changes.has(f)) changes.set(f, [[1, 1e9]]);
|
|
592
595
|
const seeds = new Set();
|
|
593
596
|
for (const [f, ranges] of changes) {
|
|
@@ -604,7 +607,7 @@ export class CodeIntel {
|
|
|
604
607
|
/** Files that historically change together with the given files (git log, bounded). */
|
|
605
608
|
coChange(files, { commits = 400, limit = 8 } = {}) {
|
|
606
609
|
if (!this._cochange || this._cochangeAt < Date.now() - 600_000) {
|
|
607
|
-
const r =
|
|
610
|
+
const r = gitSync(this.root, ['log', `-n${commits}`, '--name-only', '--pretty=format:%x00'], { maxBuffer: 1 << 28 });
|
|
608
611
|
const counts = new Map(); const pair = new Map();
|
|
609
612
|
if (r.status === 0) {
|
|
610
613
|
for (const block of r.stdout.split('\0')) {
|
package/src/query/testcmd.mjs
CHANGED
|
@@ -6,16 +6,24 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
+
import { readRepoFile } from '../util/paths.mjs';
|
|
9
10
|
|
|
10
|
-
function readJson(
|
|
11
|
+
function readJson(root, rel) { try { return JSON.parse(readRepoFile(root, rel)); } catch { return null; } }
|
|
11
12
|
const exists = (root, rel) => fs.existsSync(path.join(root, rel));
|
|
13
|
+
// commands are pasted into a shell as they are: a file named `t$(cmd).test.ts` (repository content)
|
|
14
|
+
// must reach the test runner as a name, never run as a command substitution
|
|
15
|
+
const plain = (s) => /^[\w./@+=:,-]+$/.test(s);
|
|
16
|
+
/** One shell word: as is when plain, else single-quoted. */
|
|
17
|
+
const q = (s) => (plain(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
|
|
18
|
+
// a name that would read as an option, or a test identifier that is not a plain word, gets no command
|
|
19
|
+
const shellSafe = (s) => !s.startsWith('-') && !/[\0\r\n]/.test(s);
|
|
12
20
|
|
|
13
21
|
/** Nearest package.json above a file (monorepos have one per package). */
|
|
14
22
|
function nearestPackage(root, rel) {
|
|
15
23
|
let dir = path.posix.dirname(rel);
|
|
16
24
|
for (;;) {
|
|
17
|
-
const p = path.join(
|
|
18
|
-
if (
|
|
25
|
+
const p = path.posix.join(dir === '.' ? '' : dir, 'package.json');
|
|
26
|
+
if (exists(root, p)) return { dir: dir === '.' ? '' : dir, pkg: readJson(root, p) ?? {} };
|
|
19
27
|
if (dir === '.' || dir === '' || dir === '/') return null;
|
|
20
28
|
dir = path.posix.dirname(dir);
|
|
21
29
|
}
|
|
@@ -23,7 +31,7 @@ function nearestPackage(root, rel) {
|
|
|
23
31
|
|
|
24
32
|
function jsRunner(root, rel) {
|
|
25
33
|
const near = nearestPackage(root, rel);
|
|
26
|
-
const top = readJson(
|
|
34
|
+
const top = readJson(root, 'package.json') ?? {};
|
|
27
35
|
const pkgs = [near?.pkg, top].filter(Boolean);
|
|
28
36
|
const deps = Object.assign({}, ...pkgs.map(p => ({ ...p.dependencies, ...p.devDependencies })));
|
|
29
37
|
const scripts = pkgs.map(p => p.scripts?.test ?? '').join(' ');
|
|
@@ -38,7 +46,7 @@ function jsRunner(root, rel) {
|
|
|
38
46
|
|
|
39
47
|
function pyRunner(root) {
|
|
40
48
|
if (exists(root, 'tests/runtests.py')) return { kind: 'django' };
|
|
41
|
-
const pyproject = (
|
|
49
|
+
const pyproject = readRepoFile(root, 'pyproject.toml') ?? '';
|
|
42
50
|
if (exists(root, 'pytest.ini') || exists(root, 'conftest.py') || /\[tool\.pytest/.test(pyproject) || exists(root, 'tox.ini')) return { kind: 'pytest' };
|
|
43
51
|
return { kind: 'unittest' };
|
|
44
52
|
}
|
|
@@ -51,7 +59,7 @@ function pyRunner(root) {
|
|
|
51
59
|
export function testCommands(root, files) {
|
|
52
60
|
const groups = new Map();
|
|
53
61
|
const add = (key, f) => (groups.get(key) ?? groups.set(key, []).get(key)).push(f);
|
|
54
|
-
for (const f of files) {
|
|
62
|
+
for (const f of files.filter(shellSafe)) {
|
|
55
63
|
const ext = path.posix.extname(f);
|
|
56
64
|
if (/\.[cm]?[jt]sx?$/.test(ext)) add(`js:${jsRunner(root, f)}`, f);
|
|
57
65
|
else if (ext === '.py') add('py', f);
|
|
@@ -64,19 +72,20 @@ export function testCommands(root, files) {
|
|
|
64
72
|
}
|
|
65
73
|
const out = [];
|
|
66
74
|
for (const [key, fs_] of groups) {
|
|
67
|
-
if (key.startsWith('js:')) out.push(`${key.slice(3)} ${fs_.join(' ')}`);
|
|
75
|
+
if (key.startsWith('js:')) out.push(`${key.slice(3)} ${fs_.map(q).join(' ')}`);
|
|
68
76
|
else if (key === 'py') {
|
|
69
77
|
const r = pyRunner(root);
|
|
70
|
-
if (r.kind === 'django') out.push(`python tests/runtests.py ${[...new Set(fs_.map(f => f.replace(/^tests\//, '').replace(/\/[^/]*$/, '').replace(/\//g, '.')))].join(' ')}`);
|
|
71
|
-
else if (r.kind === 'pytest') out.push(`python -m pytest -q ${fs_.join(' ')}`);
|
|
72
|
-
else out.push(`python -m unittest ${fs_.map(f => f.replace(/\.py$/, '').replace(/\//g, '.')).join(' ')}`);
|
|
73
|
-
} else if (key === 'go') out.push(`go test ${[...new Set(fs_.map(f => './' + path.posix.dirname(f)))].join(' ')}`);
|
|
78
|
+
if (r.kind === 'django') out.push(`python tests/runtests.py ${[...new Set(fs_.map(f => f.replace(/^tests\//, '').replace(/\/[^/]*$/, '').replace(/\//g, '.')))].map(q).join(' ')}`);
|
|
79
|
+
else if (r.kind === 'pytest') out.push(`python -m pytest -q ${fs_.map(q).join(' ')}`);
|
|
80
|
+
else out.push(`python -m unittest ${fs_.map(f => q(f.replace(/\.py$/, '').replace(/\//g, '.'))).join(' ')}`);
|
|
81
|
+
} else if (key === 'go') out.push(`go test ${[...new Set(fs_.map(f => './' + path.posix.dirname(f)))].map(q).join(' ')}`);
|
|
74
82
|
else if (key === 'rs') out.push('cargo test');
|
|
75
83
|
else if (key === 'jvm') {
|
|
76
|
-
const classes = [...new Set(fs_.map(f => path.posix.basename(f).replace(/\.\w+$/, '')))].join(',');
|
|
84
|
+
const classes = [...new Set(fs_.map(f => path.posix.basename(f).replace(/\.\w+$/, '')).filter(c => /^\w+$/.test(c)))].join(',');
|
|
85
|
+
if (!classes) continue;
|
|
77
86
|
out.push(exists(root, 'pom.xml') ? `mvn -q test -Dtest=${classes}` : `./gradlew test ${classes.split(',').map(c => `--tests '*${c}'`).join(' ')}`);
|
|
78
|
-
} else if (key === 'rb') out.push(exists(root, '.rspec') || fs_.some(f => f.endsWith('_spec.rb')) ? `bundle exec rspec ${fs_.join(' ')}` : `bundle exec ruby -Itest ${fs_[0]}`);
|
|
79
|
-
else if (key === 'php') out.push(`vendor/bin/phpunit ${fs_.join(' ')}`);
|
|
87
|
+
} else if (key === 'rb') out.push(exists(root, '.rspec') || fs_.some(f => f.endsWith('_spec.rb')) ? `bundle exec rspec ${fs_.map(q).join(' ')}` : `bundle exec ruby -Itest ${q(fs_[0])}`);
|
|
88
|
+
else if (key === 'php') out.push(`vendor/bin/phpunit ${fs_.map(q).join(' ')}`);
|
|
80
89
|
else if (key === 'cs') out.push('dotnet test');
|
|
81
90
|
}
|
|
82
91
|
return out;
|
|
@@ -90,6 +99,8 @@ export function testCommands(root, files) {
|
|
|
90
99
|
*/
|
|
91
100
|
export function testCaseCommands(root, cases) {
|
|
92
101
|
const out = [];
|
|
102
|
+
const word = (s) => /^\w+$/.test(s);
|
|
103
|
+
cases = cases.filter(c => shellSafe(c.path) && plain(c.path) && word(c.name) && (!c.cls || word(c.cls)));
|
|
93
104
|
const py = cases.filter(c => c.path.endsWith('.py'));
|
|
94
105
|
if (py.length) {
|
|
95
106
|
const r = pyRunner(root);
|
package/src/query/textsearch.mjs
CHANGED
|
@@ -7,15 +7,26 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import
|
|
10
|
+
import vm from 'node:vm';
|
|
11
|
+
import { withinRoot } from '../util/paths.mjs';
|
|
12
|
+
import { gitSync } from '../util/git.mjs';
|
|
11
13
|
|
|
12
14
|
const MAX_FILE_BYTES = 1_500_000;
|
|
15
|
+
// a caller's pattern gets this much matching time per search (reading files does not count): one
|
|
16
|
+
// with catastrophic backtracking (`(a+)+$`) is stopped instead of blocking the server
|
|
17
|
+
const REGEX_BUDGET_MS = 5000;
|
|
18
|
+
const SCAN = new vm.Script(`(() => {
|
|
19
|
+
if (!re.test(text)) return null;
|
|
20
|
+
const lines = text.split(/\\r?\\n/), hits = [];
|
|
21
|
+
for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) hits.push(i);
|
|
22
|
+
return { lines, hits };
|
|
23
|
+
})()`);
|
|
13
24
|
const SKIP_DIRS = /(^|\/)(node_modules|\.git|\.graph-indexer|dist|build|out|target|coverage|__pycache__|\.venv|venv|\.next|\.nuxt|\.cache|vendor)\//;
|
|
14
25
|
const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials?|benchmarks?|playground)\//i;
|
|
15
26
|
const COMMENT_LINE = /^\s*(\/\/|#(?![!\[])|\*|\/\*|<!--|--\s|;;|%)/;
|
|
16
27
|
|
|
17
28
|
function listFiles(root) {
|
|
18
|
-
const r =
|
|
29
|
+
const r = gitSync(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { maxBuffer: 1 << 30 });
|
|
19
30
|
if (r.status === 0) return r.stdout.split('\0').filter(Boolean).filter(p => !SKIP_DIRS.test(p));
|
|
20
31
|
const out = [];
|
|
21
32
|
const stack = [''];
|
|
@@ -98,25 +109,32 @@ export async function textSearch(intel, { pattern, path: prefix = null, literal
|
|
|
98
109
|
const indexed = new Map(intel.store.all('SELECT id, path, is_test FROM files').map(f => [f.path, f]));
|
|
99
110
|
const matches = [];
|
|
100
111
|
let total = 0, nfiles = 0;
|
|
112
|
+
const ctx = vm.createContext({ re, text: '' });
|
|
113
|
+
let budget = REGEX_BUDGET_MS;
|
|
101
114
|
for (const rel of files) {
|
|
102
115
|
let text;
|
|
103
116
|
try {
|
|
117
|
+
if (!withinRoot(root, rel)) continue;
|
|
104
118
|
const st = fs.statSync(path.join(root, rel));
|
|
105
119
|
if (!st.isFile() || st.size > MAX_FILE_BYTES) continue;
|
|
106
120
|
text = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
107
121
|
} catch { continue; }
|
|
108
|
-
if (!re.test(text)) continue;
|
|
109
122
|
if (text.indexOf('\0') >= 0 && text.indexOf('\0') < 8000) continue; // binary
|
|
110
|
-
|
|
111
|
-
let
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
ctx.text = text;
|
|
124
|
+
let scan;
|
|
125
|
+
const t0 = performance.now();
|
|
126
|
+
try { scan = SCAN.runInContext(ctx, { timeout: Math.max(1, Math.ceil(budget)) }); } catch (e) {
|
|
127
|
+
if (e.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw e;
|
|
128
|
+
return { error: `The regular expression took more than ${REGEX_BUDGET_MS / 1000} s (catastrophic backtracking?). Simplify it, or pass literal: true to search for the text as is.` };
|
|
129
|
+
}
|
|
130
|
+
budget -= performance.now() - t0;
|
|
131
|
+
if (!scan?.hits.length) continue;
|
|
132
|
+
nfiles++;
|
|
133
|
+
const f = indexed.get(rel);
|
|
134
|
+
for (const i of scan.hits) {
|
|
115
135
|
total++;
|
|
116
|
-
|
|
117
|
-
matches.push({ path: rel, line: i + 1, text: lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
|
|
136
|
+
matches.push({ path: rel, line: i + 1, text: scan.lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
|
|
118
137
|
}
|
|
119
|
-
if (any) nfiles++;
|
|
120
138
|
}
|
|
121
139
|
const ident = identifierOf(pattern, literal);
|
|
122
140
|
// annotate: enclosing definition, and what an identifier match refers to
|
package/src/util/git.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git, run so that an indexed repository's own config cannot make it execute anything: no
|
|
3
|
+
* fsmonitor, no hooks, no signature verification, no external diff or textconv, and every
|
|
4
|
+
* clean/smudge/process filter switched off. A repository copied with its .git directory (an
|
|
5
|
+
* archive, a shared folder) is not trusted.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
const COMMAND_KEY = /^(filter\.[^\n]+)\.(clean|smudge|process)$|^diff\.[^\n]+\.(textconv|command)$/;
|
|
10
|
+
|
|
11
|
+
function hardening(root) {
|
|
12
|
+
const args = ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-c', 'diff.external=', '-c', 'log.showSignature=false'];
|
|
13
|
+
const r = spawnSync('git', ['config', '--name-only', '--get-regexp', '^(filter|diff)\\.'], { cwd: root, encoding: 'utf8' });
|
|
14
|
+
for (const key of (r.status === 0 ? r.stdout : '').split('\n')) {
|
|
15
|
+
const m = COMMAND_KEY.exec(key);
|
|
16
|
+
if (!m) continue;
|
|
17
|
+
args.push('-c', `${key}=`);
|
|
18
|
+
// a required filter (Git LFS sets one) would otherwise fail the diff once its command is blank
|
|
19
|
+
if (m[1]) args.push('-c', `${m[1]}.required=false`);
|
|
20
|
+
}
|
|
21
|
+
return args;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const NO_TOOLS = { diff: ['--no-ext-diff', '--no-textconv'], log: ['--no-ext-diff', '--no-textconv', '--no-show-signature'] };
|
|
25
|
+
|
|
26
|
+
/** spawnSync('git', args) in `root`, hardened as above; diff and log never run external tools. */
|
|
27
|
+
export function gitSync(root, args, opts = {}) {
|
|
28
|
+
const sub = NO_TOOLS[args[0]] ? [args[0], ...NO_TOOLS[args[0]], ...args.slice(1)] : args;
|
|
29
|
+
return spawnSync('git', [...hardening(root), ...sub], { cwd: root, encoding: 'utf8', ...opts });
|
|
30
|
+
}
|
package/src/util/paths.mjs
CHANGED
|
@@ -6,7 +6,12 @@ export const DATA_DIR_NAME = '.graph-indexer';
|
|
|
6
6
|
|
|
7
7
|
export function dataDir(root) {
|
|
8
8
|
const dir = path.join(root, DATA_DIR_NAME);
|
|
9
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
9
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
10
|
+
// a .graph-indexer the repository committed as a link would put the index (excerpts of the
|
|
11
|
+
// source) wherever it points
|
|
12
|
+
const st = fs.lstatSync(dir);
|
|
13
|
+
if (!st.isDirectory()) throw new Error(`${dir} is not a directory (a symbolic link?); remove it and run again`);
|
|
14
|
+
if ((st.mode & 0o077) !== 0) { try { fs.chmodSync(dir, 0o700); } catch { /* read-only */ } }
|
|
10
15
|
const gi = path.join(dir, '.gitignore');
|
|
11
16
|
if (!fs.existsSync(gi)) { try { fs.writeFileSync(gi, '*\n'); } catch { /* read-only */ } }
|
|
12
17
|
return dir;
|
|
@@ -22,3 +27,37 @@ export function findRepoRoot(start) {
|
|
|
22
27
|
dir = parent;
|
|
23
28
|
}
|
|
24
29
|
}
|
|
30
|
+
|
|
31
|
+
const realRoots = new Map();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `rel` names a file inside the repository once `..` and symlinks are resolved, so a tool target
|
|
35
|
+
* like `../../etc/hosts` or a tracked link to ~/.ssh/… is never read. The index's own data and git's
|
|
36
|
+
* internals are not served either.
|
|
37
|
+
*/
|
|
38
|
+
export function withinRoot(root, rel) {
|
|
39
|
+
let realRoot = realRoots.get(root);
|
|
40
|
+
if (!realRoot) { try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); } realRoots.set(root, realRoot); }
|
|
41
|
+
let real;
|
|
42
|
+
try { real = fs.realpathSync(path.join(root, rel)); } catch { return false; }
|
|
43
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return false;
|
|
44
|
+
const top = path.relative(realRoot, real).split(path.sep)[0];
|
|
45
|
+
return top !== '.git' && top !== DATA_DIR_NAME;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A git revision given by a caller: never an option (`--output=…`), whitespace or NUL. */
|
|
49
|
+
export const isRevision = (s) => typeof s === 'string' && s.length > 0 && s.length <= 256 && !s.startsWith('-') && !/[\s\0]/.test(s);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A small regular file of the repository (a manifest, a config), or null: never a device, a FIFO,
|
|
53
|
+
* a file larger than `maxBytes` or a link out of the root — a `tsconfig.json` linked to /dev/zero
|
|
54
|
+
* would otherwise hang the indexer.
|
|
55
|
+
*/
|
|
56
|
+
export function readRepoFile(root, rel, maxBytes = 2_000_000) {
|
|
57
|
+
if (!withinRoot(root, rel)) return null;
|
|
58
|
+
try {
|
|
59
|
+
const abs = path.join(root, rel);
|
|
60
|
+
const st = fs.statSync(abs);
|
|
61
|
+
return st.isFile() && st.size <= maxBytes ? fs.readFileSync(abs, 'utf8') : null;
|
|
62
|
+
} catch { return null; }
|
|
63
|
+
}
|