graph-indexer 3.1.0 → 3.1.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/SECURITY.md +35 -20
- package/integrations/claude-code/.claude-plugin/plugin.json +1 -1
- package/integrations/claude-code/skills/setup/SKILL.md +2 -2
- package/integrations/claude-code-local/.claude-plugin/plugin.json +1 -1
- package/integrations/claude-code-local/.mcp.json +1 -1
- package/integrations/claude-code-local/README.md +1 -1
- package/integrations/claude-code-local/hooks/gi-hook.mjs +14 -5
- package/integrations/opencode/graph-indexer.js +10 -3
- package/package.json +1 -1
- package/src/cli/hook.mjs +4 -2
- package/src/cli/init.mjs +16 -4
- package/src/cli/resident.mjs +18 -3
- package/src/index/discover.mjs +52 -30
- package/src/index/indexer.mjs +1 -1
- package/src/index/modules.mjs +3 -4
- package/src/mcp/server.mjs +27 -1
- package/src/mcp/tools.mjs +1 -1
- package/src/parse/lang/sfc.mjs +3 -2
- package/src/query/injection-spring.mjs +5 -4
- package/src/query/injection.mjs +2 -1
- package/src/query/intel.mjs +9 -1
- package/src/query/orm.mjs +5 -3
- package/src/query/textsearch.mjs +30 -9
- package/src/store/db.mjs +11 -0
- package/src/util/git.mjs +60 -11
- package/src/util/paths.mjs +10 -3
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.1.1] — 2026-11-13
|
|
8
|
+
|
|
9
|
+
A security release. Upgrading is recommended for everyone; nothing else changes. Most of these need a
|
|
10
|
+
repository that was copied together with its `.git` directory (an archive, a shared folder) or that
|
|
11
|
+
commits symbolic links; one needs only a hostile tool call.
|
|
12
|
+
|
|
13
|
+
### Security
|
|
14
|
+
|
|
15
|
+
- **git runs nothing the indexed repository's config names, on every call.** Two git calls bypassed
|
|
16
|
+
the hardening added in 3.0.1, so a repository with `core.fsmonitor` set in its `.git/config` ran
|
|
17
|
+
that command: the post-tool hook's `git status` (after every shell command the agent ran) and the
|
|
18
|
+
`models` view's `git ls-files`. Every git call now goes through one hardened function.
|
|
19
|
+
- **Filter and diff drivers named with `=` are switched off too.** git splits a `-c` argument at the
|
|
20
|
+
first `=`, so the override for a driver named `x=y` set another key and left its clean filter or
|
|
21
|
+
textconv command on, running it from `check_changes`, `change_impact` and the hook. The overrides
|
|
22
|
+
now go through git's environment (`GIT_CONFIG_COUNT`), which holds any name; a git older than 2.31
|
|
23
|
+
is not run at all on such a repository. git no longer descends into submodules, whose own config
|
|
24
|
+
the overrides do not cover.
|
|
25
|
+
- **`.git/` is not served under another letter case.** On a case-insensitive file system (macOS,
|
|
26
|
+
Windows) `read_code ".GIT/config"` read `.git/config`, which can hold credentials in remote URLs.
|
|
27
|
+
- **Nothing is written through links committed inside `.graph-indexer/`.** A repository that
|
|
28
|
+
shipped `.graph-indexer/index.db-wal` or `resident.started` as a link to `~/.zshrc` had that file
|
|
29
|
+
overwritten. Links inside the directory are now removed before anything is written, and an index
|
|
30
|
+
committed to the repository is not opened (the database also runs in SQLite's defensive mode with
|
|
31
|
+
`trusted_schema` off).
|
|
32
|
+
- **Files under a linked directory stay out.** A tracked `link/secret.ts`, where `link` points
|
|
33
|
+
outside the repository, was indexed and served, because only the last component of a path was
|
|
34
|
+
checked. Every component is now resolved, and devices and FIFOs are skipped instead of read.
|
|
35
|
+
- **`Cargo.toml`, `svelte.config.*`, `pom.xml`, `build.gradle`, `.gitignore`, Prisma schemas and
|
|
36
|
+
migrations are read only as small regular files inside the repository**, like `package.json` and
|
|
37
|
+
`tsconfig.json` already were: a `Cargo.toml` linked to `/dev/zero` grew the indexer past 5 GB, and a
|
|
38
|
+
`schema.prisma` linked to a file elsewhere was read and shown.
|
|
39
|
+
- **`find_files` stops a glob that backtracks catastrophically** (`*a*a*a*a*a*a*a*a*b`: 97 s on one
|
|
40
|
+
file name) after 5 s, as `search_text` does for regular expressions.
|
|
41
|
+
- **`.gitignore` patterns are matched without backtracking** (repositories without git), and
|
|
42
|
+
`search_text` annotates its matches in linear time and keeps at most 50,000 of them: 10,000 matches
|
|
43
|
+
in one file took 14 s, now about 1 s.
|
|
44
|
+
- **Tool arguments are held to their schema's bounds** (`depth`, `limit`, list lengths) whatever
|
|
45
|
+
the MCP client sends.
|
|
46
|
+
- **Windows: nothing named `git`, `npx` or `graph-indexer` is run from the repository.** A bare
|
|
47
|
+
command is looked up in the working directory first there, so a committed `git.exe` or `npx.cmd`
|
|
48
|
+
ran. git is looked up on PATH only; the Claude Code and OpenCode plugins start npx from the home
|
|
49
|
+
directory; `init` writes npx's absolute path; and `init` no longer takes a `graph-indexer` found
|
|
50
|
+
through a relative or `node_modules/.bin` PATH entry.
|
|
51
|
+
- **Windows: another user cannot answer for the resident.** The named pipe's name now carries a
|
|
52
|
+
random id kept in `.graph-indexer/`, so it cannot be created first by someone who only knows the
|
|
53
|
+
repository's path.
|
|
54
|
+
- `SECURITY.md` now states the one network access there is (the opt-in, hash-checked
|
|
55
|
+
`semantic install`) and lists every file read outside the index.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
7
59
|
## [3.1.0] — 2026-11-08
|
|
8
60
|
|
|
9
61
|
### Added
|
package/SECURITY.md
CHANGED
|
@@ -25,32 +25,43 @@ are not maintained; please upgrade to the latest version.
|
|
|
25
25
|
|
|
26
26
|
graph-indexer is a local developer tool. Its threat model follows from a few deliberate choices:
|
|
27
27
|
|
|
28
|
-
- **No network.** graph-indexer makes no outbound connections: no telemetry, no
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
- **No network.** graph-indexer makes no outbound connections: no telemetry, no grammar
|
|
29
|
+
installs. The tree-sitter runtime and all grammars ship inside the package as WebAssembly
|
|
30
|
+
(versions and SHA-256 checksums in `vendor/grammars/manifest.json`), so there is no install-time
|
|
31
|
+
or first-run download and no native build step. The one exception is explicit and opt-in:
|
|
32
|
+
`graph-indexer semantic install` downloads the optional embedding model from huggingface.co at a
|
|
33
|
+
pinned revision and installs it only if every file's SHA-256 matches the digest in the source.
|
|
32
34
|
- **No dependencies.** The package has no npm dependencies; it uses only Node.js built-ins
|
|
33
35
|
(`node:sqlite`, `node:fs`, `node:child_process`, …) and the vendored WebAssembly runtime.
|
|
34
36
|
- **No code execution.** Source files are parsed into syntax trees; the indexed code is never
|
|
35
37
|
imported, evaluated or run. The only subprocesses are read-only git commands
|
|
36
|
-
(`git rev-parse`, `git ls-files`, `git diff`, `git log`, `git show`) run in the
|
|
37
|
-
with fixed arguments
|
|
38
|
-
|
|
39
|
-
repository's own git config could
|
|
40
|
-
clean/smudge/process filters, `diff.external`
|
|
38
|
+
(`git rev-parse`, `git ls-files`, `git status`, `git diff`, `git log`, `git show`) run in the
|
|
39
|
+
indexed repository with fixed arguments, all through one function (`src/util/git.mjs`). The only
|
|
40
|
+
tool input they take is the `base` revision of `check_changes`, which is refused when git could
|
|
41
|
+
read it as an option. Every git call switches off what the repository's own git config could
|
|
42
|
+
run: `core.fsmonitor`, hooks, signature verification, clean/smudge/process filters, `diff.external`
|
|
43
|
+
and textconv drivers. The overrides are passed through git's environment (`GIT_CONFIG_COUNT`), which
|
|
44
|
+
holds any driver name verbatim, including one containing `=`; with a git older than 2.31 that
|
|
45
|
+
cannot, a repository naming such a driver gets no git call at all. git does not descend into
|
|
46
|
+
submodules (`--ignore-submodules=all`), whose own config the overrides do not cover. On Windows,
|
|
47
|
+
`git` and `npx` are looked up on PATH only, never in the repository (the working directory).
|
|
41
48
|
- **No code from the repository through npx.** Every `npx` command graph-indexer runs or writes into
|
|
42
49
|
an agent's configuration passes `--prefix`, so a `node_modules/graph-indexer` that the repository
|
|
43
50
|
ships is never run in place of the published package.
|
|
44
51
|
- **Stays inside the repository.** Only files listed by git (or found by a `.gitignore`-aware
|
|
45
|
-
walk) under the repository root are indexed.
|
|
46
|
-
|
|
47
|
-
indexed or served. Paths given to the tools
|
|
48
|
-
`change_impact`) are resolved
|
|
49
|
-
`.git/` and `.graph-indexer/` are never served
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
walk) under the repository root are indexed. Every component of a file's path is resolved, so
|
|
53
|
+
neither a symbolic link to a file elsewhere on the machine nor a file under a linked directory is
|
|
54
|
+
read, indexed or served; devices and FIFOs are skipped. Paths given to the tools
|
|
55
|
+
(`read_code "file.ts:42"`, `outline`, `change_impact`) are resolved the same way and must stay
|
|
56
|
+
inside the repository; `.git/` and `.graph-indexer/` are never served, in any letter case on a
|
|
57
|
+
case-insensitive file system. Manifests and other files read outside the index (`package.json`,
|
|
58
|
+
`tsconfig.json`, `go.mod`, `Cargo.toml`, `svelte.config.*`, `pom.xml`, `build.gradle`, `.gitignore`,
|
|
59
|
+
Prisma schemas, migrations) are read only as small regular files inside the repository.
|
|
60
|
+
- **Bounded work.** A regular expression given to `search_text` and a glob given to `find_files`
|
|
61
|
+
run under a 5 s budget, `.gitignore` patterns are matched without backtracking, `search_text`
|
|
62
|
+
annotates at most 50,000 matching lines, and a file whose syntax makes the structural queries
|
|
63
|
+
super-linear is given up after 15 s, so none of them can block the server. Tool arguments are held
|
|
64
|
+
to the bounds their schema declares (depths, limits, list lengths) whatever the client sends.
|
|
54
65
|
- **Read-only tools.** All MCP tools are read-only (`readOnlyHint: true`) and never modify
|
|
55
66
|
the repository.
|
|
56
67
|
- **stdio transport.** The MCP server talks to the local client over stdin/stdout. It does not
|
|
@@ -59,10 +70,14 @@ graph-indexer is a local developer tool. Its threat model follows from a few del
|
|
|
59
70
|
hooks, also answers hook and CLI queries over a Unix domain socket (a named pipe on Windows),
|
|
60
71
|
never TCP. The socket lives in `<repo>/.graph-indexer/` with mode 0600; when that path is too
|
|
61
72
|
long for a socket, in a per-user directory in the temp dir with mode 0700 whose owner and mode
|
|
62
|
-
both sides check before using it.
|
|
73
|
+
both sides check before using it. On Windows the pipe's name carries a random id that each
|
|
74
|
+
resident writes to `.graph-indexer/`, so another user of the machine cannot create the pipe first
|
|
75
|
+
and answer in its place. It answers the same read-only queries as the CLI.
|
|
63
76
|
`GRAPH_INDEXER_RESIDENT=0` disables it.
|
|
64
77
|
- **Local artifacts.** The index is a SQLite database in `<repo>/.graph-indexer/` (git-ignored by
|
|
65
|
-
`init`, mode 0700; a `.graph-indexer` that is a symbolic link is refused
|
|
78
|
+
`init`, mode 0700; a `.graph-indexer` that is a symbolic link is refused, symbolic links inside it
|
|
79
|
+
are removed before anything is written there, and an index committed to the repository is not
|
|
80
|
+
opened). It contains excerpts of your source code — treat it with the same sensitivity as the
|
|
66
81
|
repository itself and do not commit or share it. Deleting the directory is always safe; it is
|
|
67
82
|
rebuilt on the next run.
|
|
68
83
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graph-indexer",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.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",
|
|
@@ -12,13 +12,13 @@ allow plugins to start local programs, so the user must approve this step.
|
|
|
12
12
|
2. Ask the user to confirm, then run from the repository root:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
claude mcp add graph-indexer -- npx -y --prefix / graph-indexer@3.1.
|
|
15
|
+
claude mcp add graph-indexer -- npx -y --prefix / graph-indexer@3.1.1 serve
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
`--prefix /` makes npx run only the published package, never a copy shipped inside the repository.
|
|
19
19
|
The version is pinned on purpose: update it by hand when a newer graph-indexer is released.
|
|
20
20
|
|
|
21
|
-
3. Optional, with the user's consent: `npx -y --prefix / graph-indexer@3.1.
|
|
21
|
+
3. Optional, with the user's consent: `npx -y --prefix / graph-indexer@3.1.1 init --hooks` also writes the
|
|
22
22
|
Claude Code hooks (an edit check after each edit, grep disambiguation) to `.claude/settings.json`.
|
|
23
23
|
4. Restart Claude Code, or run `/mcp`, and confirm `graph-indexer` is connected.
|
|
24
24
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graph-indexer",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.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",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
This folder is the plugin with everything wired in: the MCP server (`.mcp.json`), the hooks
|
|
4
4
|
(`hooks/`) and the `code-structure` sub-agent. The plugin directory lists only plugins made of
|
|
5
5
|
Markdown and JSON (see `../claude-code`), so this one is not listed there. To use it, copy the
|
|
6
|
-
folder or point a local marketplace at it. `npx -y --prefix / graph-indexer@3.1.
|
|
6
|
+
folder or point a local marketplace at it. `npx -y --prefix / graph-indexer@3.1.1 init --hooks`
|
|
7
7
|
gives the same result without a plugin.
|
|
8
8
|
|
|
9
9
|
Each release must update the version in `.mcp.json`, `hooks/gi-hook.mjs` and
|
|
@@ -21,7 +21,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
21
21
|
|
|
22
22
|
const PROTOCOL = 1;
|
|
23
23
|
// the exact release this plugin version runs through npx (never a range): keep it equal to package.json's version
|
|
24
|
-
export const VERSION = '3.1.
|
|
24
|
+
export const VERSION = '3.1.1';
|
|
25
25
|
const WIN = process.platform === 'win32';
|
|
26
26
|
|
|
27
27
|
export function findRepoRoot(start) {
|
|
@@ -37,7 +37,13 @@ export function findRepoRoot(start) {
|
|
|
37
37
|
export function socketPath(root) {
|
|
38
38
|
const abs = path.resolve(root);
|
|
39
39
|
const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 16);
|
|
40
|
-
|
|
40
|
+
// a pipe name anyone could compute would let another user of the machine create it first and
|
|
41
|
+
// answer as the resident: the name carries a random id kept in the private data directory
|
|
42
|
+
if (WIN) {
|
|
43
|
+
let id = '';
|
|
44
|
+
try { id = fs.readFileSync(path.join(abs, '.graph-indexer', 'resident.pipe-id'), 'utf8').trim(); } catch { /* no resident yet */ }
|
|
45
|
+
return /^[0-9a-f]{32}$/.test(id) ? `\\\\.\\pipe\\graph-indexer-${hash}-${id}` : null;
|
|
46
|
+
}
|
|
41
47
|
const inRepo = path.join(abs, '.graph-indexer', 'resident.sock');
|
|
42
48
|
if (Buffer.byteLength(inRepo) <= 100) return inRepo;
|
|
43
49
|
return path.join(os.tmpdir(), `graph-indexer-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
|
|
@@ -57,7 +63,7 @@ function trusted(file, root) {
|
|
|
57
63
|
/** The resident's answer ({ out }) or null. `any: true`: this client has no logic of its own to match. */
|
|
58
64
|
function ask(root, request, totalMs) {
|
|
59
65
|
const file = socketPath(root);
|
|
60
|
-
if (!trusted(file, root)) return Promise.resolve(null);
|
|
66
|
+
if (!file || !trusted(file, root)) return Promise.resolve(null);
|
|
61
67
|
return new Promise((resolve) => {
|
|
62
68
|
let done = false, buf = '';
|
|
63
69
|
const sock = net.createConnection(file);
|
|
@@ -91,8 +97,11 @@ async function main() {
|
|
|
91
97
|
// no resident yet: the full hook through npx (it starts one for the next calls); --prefix keeps
|
|
92
98
|
// npx from running a graph-indexer that the repository ships in its own node_modules
|
|
93
99
|
// (on Windows npx.cmd needs a shell, so the home directory is quoted for cmd.exe)
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
// It runs from the home directory with the repository passed explicitly: cmd.exe looks for
|
|
101
|
+
// npx.cmd in the working directory first, which here would be the repository's.
|
|
102
|
+
if (WIN && /["%\r\n]/.test(root)) return;
|
|
103
|
+
const q = (a) => (WIN ? `"${a}"` : a);
|
|
104
|
+
const child = spawn(WIN ? 'npx.cmd' : 'npx', ['-y', '--prefix', q(os.homedir()), `graph-indexer@${VERSION}`, 'hook', event, '--repo', q(root)], { cwd: os.homedir(), stdio: ['pipe', 'inherit', 'ignore'], shell: WIN });
|
|
96
105
|
child.on('error', () => { /* fail open */ });
|
|
97
106
|
child.stdin.on('error', () => { /* fail open */ });
|
|
98
107
|
child.stdin.end(raw);
|
|
@@ -39,7 +39,13 @@ function findRepoRoot(start) {
|
|
|
39
39
|
function socketPath(root) {
|
|
40
40
|
const abs = path.resolve(root);
|
|
41
41
|
const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 16);
|
|
42
|
-
|
|
42
|
+
// a pipe name anyone could compute would let another user of the machine create it first and
|
|
43
|
+
// answer as the resident: the name carries a random id kept in the private data directory
|
|
44
|
+
if (WIN) {
|
|
45
|
+
let id = '';
|
|
46
|
+
try { id = fs.readFileSync(path.join(abs, '.graph-indexer', 'resident.pipe-id'), 'utf8').trim(); } catch { /* no resident yet */ }
|
|
47
|
+
return /^[0-9a-f]{32}$/.test(id) ? `\\\\.\\pipe\\graph-indexer-${hash}-${id}` : null;
|
|
48
|
+
}
|
|
43
49
|
const inRepo = path.join(abs, '.graph-indexer', 'resident.sock');
|
|
44
50
|
if (Buffer.byteLength(inRepo) <= 100) return inRepo;
|
|
45
51
|
return path.join(os.tmpdir(), `graph-indexer-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
|
|
@@ -58,7 +64,7 @@ function trusted(file, root) {
|
|
|
58
64
|
|
|
59
65
|
function ask(root, request, totalMs) {
|
|
60
66
|
const file = socketPath(root);
|
|
61
|
-
if (!trusted(file, root)) return Promise.resolve(null);
|
|
67
|
+
if (!file || !trusted(file, root)) return Promise.resolve(null);
|
|
62
68
|
return new Promise((resolve) => {
|
|
63
69
|
let done = false, buf = '';
|
|
64
70
|
const sock = net.createConnection(file);
|
|
@@ -112,7 +118,8 @@ export const GraphIndexer = async ({ directory, worktree } = {}) => {
|
|
|
112
118
|
// every argument, and give up on one that a quote cannot hold
|
|
113
119
|
const args = [...DAEMON.slice(1), '--repo', root];
|
|
114
120
|
if (WIN && args.some(a => /["%\r\n]/.test(a))) return;
|
|
115
|
-
|
|
121
|
+
// from the home directory: cmd.exe would look for npx.cmd in the repository first
|
|
122
|
+
const child = spawn(DAEMON[0], WIN ? args.map(a => `"${a}"`) : args, { cwd: os.homedir(), detached: true, stdio: 'ignore', windowsHide: true, shell: WIN });
|
|
116
123
|
child.on('error', () => { /* optional */ });
|
|
117
124
|
child.unref();
|
|
118
125
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graph-indexer",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.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",
|
package/src/cli/hook.mjs
CHANGED
|
@@ -29,9 +29,10 @@
|
|
|
29
29
|
import fs from 'node:fs';
|
|
30
30
|
import os from 'node:os';
|
|
31
31
|
import path from 'node:path';
|
|
32
|
-
import { spawn
|
|
32
|
+
import { spawn } from 'node:child_process';
|
|
33
33
|
import { fileURLToPath } from 'node:url';
|
|
34
34
|
import { findRepoRoot, dataDir, DATA_DIR_NAME, readRepoFile } from '../util/paths.mjs';
|
|
35
|
+
import { gitSync } from '../util/git.mjs';
|
|
35
36
|
import { codeSpecForPath as specForPath } from '../parse/languages.mjs'; // hooks report on code files only
|
|
36
37
|
|
|
37
38
|
const MAX_CONTEXT = 1500;
|
|
@@ -333,7 +334,8 @@ async function afterRead(root, input, state, crawling, acquire) {
|
|
|
333
334
|
*/
|
|
334
335
|
function shellEdits(root, state) {
|
|
335
336
|
let r;
|
|
336
|
-
|
|
337
|
+
// hardened (gitSync): a repository's core.fsmonitor or filters would otherwise run
|
|
338
|
+
try { r = gitSync(root, ['status', '--porcelain', '--untracked-files=all', '-z'], { timeout: 1500 }); } catch { return []; }
|
|
337
339
|
if (r.status !== 0 || typeof r.stdout !== 'string') return [];
|
|
338
340
|
const seen = state.st.mtimes ??= {};
|
|
339
341
|
const changed = [];
|
package/src/cli/init.mjs
CHANGED
|
@@ -58,13 +58,23 @@ function readJson(p) {
|
|
|
58
58
|
// (code the repository ships) instead of the published package
|
|
59
59
|
const NPX_ARGS = ['-y', '--prefix', '/', 'graph-indexer@3'];
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* npx, by absolute path on Windows: there a bare `npx` is looked up in the working directory (the
|
|
63
|
+
* repository) before PATH, so an `npx.cmd`/`npx.exe` the repository ships would run instead.
|
|
64
|
+
*/
|
|
65
|
+
function npxCommand() {
|
|
66
|
+
if (process.platform !== 'win32') return 'npx';
|
|
67
|
+
const f = path.join(path.dirname(process.execPath), 'npx.cmd');
|
|
68
|
+
return fs.existsSync(f) ? f : 'npx';
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
function serverEntry(agent, { local }) {
|
|
62
72
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
63
73
|
const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
|
|
64
74
|
const repoArgs = agent.workspaceVar ? ['--repo', agent.workspaceVar] : [];
|
|
65
75
|
const base = local
|
|
66
76
|
? { command: process.execPath, args: [bin, 'serve', ...repoArgs] }
|
|
67
|
-
: { command:
|
|
77
|
+
: { command: npxCommand(), args: [...NPX_ARGS, 'serve', ...repoArgs] };
|
|
68
78
|
if (agent.opencode) return { type: 'local', command: [base.command, ...base.args], enabled: true };
|
|
69
79
|
if (agent.zed) return { ...base, env: {} };
|
|
70
80
|
return agent.vscode ? { type: 'stdio', ...base } : base;
|
|
@@ -110,7 +120,9 @@ function upsertBlock(repo, file, dryRun) {
|
|
|
110
120
|
/** An installed `graph-indexer` executable on PATH, if any (hooks start in ~0.1 s instead of npx's ~1 s). */
|
|
111
121
|
function installedBin() {
|
|
112
122
|
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
|
113
|
-
|
|
123
|
+
// only absolute entries, never a project's own bin directory: a PATH with `.` or
|
|
124
|
+
// `node_modules/.bin` would otherwise find a graph-indexer the repository ships
|
|
125
|
+
if (!dir || !path.isAbsolute(dir) || /(^|[\\/])node_modules[\\/]\.bin[\\/]?$/.test(dir)) continue;
|
|
114
126
|
const f = path.join(dir, process.platform === 'win32' ? 'graph-indexer.cmd' : 'graph-indexer');
|
|
115
127
|
try { if (fs.statSync(f).isFile()) return f; } catch { /* not here */ }
|
|
116
128
|
}
|
|
@@ -123,7 +135,7 @@ function cliCommand(local) {
|
|
|
123
135
|
const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
|
|
124
136
|
if (local) return `${JSON.stringify(process.execPath)} ${JSON.stringify(bin)}`;
|
|
125
137
|
const installed = installedBin();
|
|
126
|
-
return installed ? JSON.stringify(installed) : ['npx', ...NPX_ARGS].join(' ');
|
|
138
|
+
return installed ? JSON.stringify(installed) : [npxCommand() === 'npx' ? 'npx' : JSON.stringify(npxCommand()), ...NPX_ARGS].join(' ');
|
|
127
139
|
}
|
|
128
140
|
|
|
129
141
|
/** The command that starts graph-indexer, as an argv array (for the OpenCode/Kilo Code plugin). */
|
|
@@ -131,7 +143,7 @@ function cliArgv(local) {
|
|
|
131
143
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
132
144
|
if (local) return [process.execPath, path.resolve(here, '../../bin/graph-indexer.mjs')];
|
|
133
145
|
const installed = installedBin();
|
|
134
|
-
return installed ? [installed] : [
|
|
146
|
+
return installed ? [installed] : [npxCommand(), ...NPX_ARGS];
|
|
135
147
|
}
|
|
136
148
|
|
|
137
149
|
/**
|
package/src/cli/resident.mjs
CHANGED
|
@@ -37,7 +37,13 @@ const WIN = process.platform === 'win32';
|
|
|
37
37
|
export function socketPath(root) {
|
|
38
38
|
const abs = path.resolve(root);
|
|
39
39
|
const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 16);
|
|
40
|
-
|
|
40
|
+
// a pipe name anyone could compute would let another user of the machine create it first and
|
|
41
|
+
// answer as the resident: the name carries a random id kept in the private data directory
|
|
42
|
+
if (WIN) {
|
|
43
|
+
let id = '';
|
|
44
|
+
try { id = fs.readFileSync(path.join(abs, '.graph-indexer', 'resident.pipe-id'), 'utf8').trim(); } catch { /* no resident yet */ }
|
|
45
|
+
return /^[0-9a-f]{32}$/.test(id) ? `\\\\.\\pipe\\graph-indexer-${hash}-${id}` : null;
|
|
46
|
+
}
|
|
41
47
|
const inRepo = path.join(abs, DATA_DIR_NAME, 'resident.sock');
|
|
42
48
|
// Unix socket paths are limited to 104–108 bytes
|
|
43
49
|
if (Buffer.byteLength(inRepo) <= 100) return inRepo;
|
|
@@ -74,7 +80,7 @@ export const residentRequired = () => process.env.GRAPH_INDEXER_RESIDENT === 're
|
|
|
74
80
|
export function askResident(root, request, { version, connectMs = 250, totalMs = 5000 } = {}) {
|
|
75
81
|
if (disabled()) return Promise.resolve(null);
|
|
76
82
|
const file = socketPath(root);
|
|
77
|
-
if (!WIN && (!ownSocket(file) || !privateDir(path.dirname(file), root))) return Promise.resolve(null);
|
|
83
|
+
if (!file || (!WIN && (!ownSocket(file) || !privateDir(path.dirname(file), root)))) return Promise.resolve(null);
|
|
78
84
|
return new Promise((resolve) => {
|
|
79
85
|
let done = false, connected = false, buf = '';
|
|
80
86
|
const sock = net.createConnection(file);
|
|
@@ -114,7 +120,7 @@ export function askResident(root, request, { version, connectMs = 250, totalMs =
|
|
|
114
120
|
*/
|
|
115
121
|
export async function startResident({ root, version, handle, log = () => {}, idleMs = 0, onIdle = null, wanted = () => true }) {
|
|
116
122
|
if (disabled()) return null;
|
|
117
|
-
|
|
123
|
+
let file = socketPath(root);
|
|
118
124
|
if (!WIN) {
|
|
119
125
|
const dir = path.dirname(file);
|
|
120
126
|
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* checked below */ }
|
|
@@ -123,6 +129,15 @@ export async function startResident({ root, version, handle, log = () => {}, idl
|
|
|
123
129
|
// another resident may serve this repository already; a dead one leaves its socket file behind
|
|
124
130
|
if (await askResident(root, { op: 'ping' }, { version, connectMs: 300, totalMs: 1500 })) return null;
|
|
125
131
|
if (!WIN) { try { fs.unlinkSync(file); } catch { /* none */ } }
|
|
132
|
+
else {
|
|
133
|
+
// a fresh pipe id for this resident (see socketPath)
|
|
134
|
+
const idFile = path.join(path.resolve(root), DATA_DIR_NAME, 'resident.pipe-id');
|
|
135
|
+
try {
|
|
136
|
+
fs.mkdirSync(path.dirname(idFile), { recursive: true });
|
|
137
|
+
fs.writeFileSync(idFile, crypto.randomBytes(16).toString('hex'), { mode: 0o600 });
|
|
138
|
+
} catch (e) { log(`resident: cannot write ${idFile} (${e.message}); not listening`); return null; }
|
|
139
|
+
file = socketPath(root);
|
|
140
|
+
}
|
|
126
141
|
|
|
127
142
|
let last = Date.now();
|
|
128
143
|
const server = net.createServer((sock) => {
|
package/src/index/discover.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { gitSync } from '../util/git.mjs';
|
|
10
|
+
import { readRepoFile, withinRoot } from '../util/paths.mjs';
|
|
10
11
|
import { specForPath } from '../parse/languages.mjs';
|
|
11
12
|
|
|
12
13
|
export const MAX_FILE_BYTES = 1_500_000;
|
|
@@ -34,26 +35,56 @@ function gitListFiles(root) {
|
|
|
34
35
|
return r.stdout.split('\0').filter(Boolean);
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A .gitignore pattern as a matcher that cannot backtrack: `*` (within a name), `?`, `**` (across
|
|
40
|
+
* names), anything else literal. A regular expression of many stars (`*a*a*a*a*a*b`) takes seconds
|
|
41
|
+
* per long path; this keeps the set of positions reachable after each token, so a match costs
|
|
42
|
+
* pattern length × path length. Unanchored, it may start at any name; it must end at the end of
|
|
43
|
+
* the path or at a `/` (a directory and everything under it).
|
|
44
|
+
*/
|
|
45
|
+
function globMatcher(glob, anchored) {
|
|
46
|
+
const tokens = [];
|
|
47
|
+
for (let i = 0; i < glob.length; i++) {
|
|
48
|
+
if (glob[i] === '*') {
|
|
49
|
+
let j = i; while (glob[j + 1] === '*') j++;
|
|
50
|
+
tokens.push(j > i ? '**' : '*'); i = j;
|
|
51
|
+
} else tokens.push(glob[i] === '?' ? '?' : { c: glob[i] });
|
|
52
|
+
}
|
|
53
|
+
return (s) => {
|
|
54
|
+
const n = s.length;
|
|
55
|
+
let cur = new Uint8Array(n + 1);
|
|
56
|
+
cur[0] = 1;
|
|
57
|
+
if (!anchored) for (let k = 0; k < n; k++) if (s[k] === '/') cur[k + 1] = 1;
|
|
58
|
+
for (const t of tokens) {
|
|
59
|
+
const next = new Uint8Array(n + 1);
|
|
60
|
+
let any = false;
|
|
61
|
+
if (t === '**') { let on = 0; for (let p = 0; p <= n; p++) { on |= cur[p]; next[p] = on; any ||= !!on; } }
|
|
62
|
+
else if (t === '*') { let on = 0; for (let p = 0; p <= n; p++) { on |= cur[p]; next[p] = on; any ||= !!on; if (s[p] === '/') on = 0; } }
|
|
63
|
+
else if (t === '?') { for (let p = 0; p < n; p++) if (cur[p] && s[p] !== '/') { next[p + 1] = 1; any = true; } }
|
|
64
|
+
else { for (let p = 0; p < n; p++) if (cur[p] && s[p] === t.c) { next[p + 1] = 1; any = true; } }
|
|
65
|
+
if (!any) return false;
|
|
66
|
+
cur = next;
|
|
67
|
+
}
|
|
68
|
+
for (let p = 0; p <= n; p++) if (cur[p] && (p === n || s[p] === '/')) return true;
|
|
69
|
+
return false;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
37
73
|
/** Minimal .gitignore → predicate (used only outside git repos). */
|
|
38
74
|
function loadGitignore(root) {
|
|
39
75
|
const rules = [];
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
line = line.replace(/^\//, '');
|
|
50
|
-
const re = line.split('**').map(seg => seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')).join('.*');
|
|
51
|
-
rules.push({ neg, re: new RegExp(anchored ? `^${re}(/|$)` : `(^|/)${re}(/|$)`) });
|
|
52
|
-
}
|
|
53
|
-
} catch { /* no .gitignore */ }
|
|
76
|
+
for (let line of (readRepoFile(root, '.gitignore') ?? '').split(/\r?\n/)) {
|
|
77
|
+
line = line.trim();
|
|
78
|
+
if (!line || line.startsWith('#')) continue;
|
|
79
|
+
const neg = line.startsWith('!');
|
|
80
|
+
if (neg) line = line.slice(1);
|
|
81
|
+
if (line.endsWith('/')) line = line.slice(0, -1);
|
|
82
|
+
const anchored = line.startsWith('/') || line.includes('/');
|
|
83
|
+
rules.push({ neg, test: globMatcher(line.replace(/^\//, ''), anchored) });
|
|
84
|
+
}
|
|
54
85
|
return (rel) => {
|
|
55
86
|
let ignored = false;
|
|
56
|
-
for (const r of rules) if (r.
|
|
87
|
+
for (const r of rules) if (r.test(rel)) ignored = !r.neg;
|
|
57
88
|
return ignored;
|
|
58
89
|
};
|
|
59
90
|
}
|
|
@@ -95,28 +126,19 @@ export function discoverFiles(root, opts = {}) {
|
|
|
95
126
|
viaGit = rels !== null;
|
|
96
127
|
}
|
|
97
128
|
if (!rels) rels = walk(root, loadGitignore(root));
|
|
98
|
-
const files = rels.map(r => r.replace(/\\/g, '/')).filter(r => isIndexablePath(r, opts) &&
|
|
129
|
+
const files = rels.map(r => r.replace(/\\/g, '/')).filter(r => isIndexablePath(r, opts) && insideRoot(root, r));
|
|
99
130
|
files.sort();
|
|
100
131
|
return { files, viaGit };
|
|
101
132
|
}
|
|
102
133
|
|
|
103
|
-
const realRoots = new Map();
|
|
104
|
-
|
|
105
134
|
/**
|
|
106
|
-
* A tracked file must resolve inside the repository:
|
|
107
|
-
*
|
|
135
|
+
* A tracked file must resolve inside the repository: every component of its path is resolved, so
|
|
136
|
+
* neither a link to /etc/… or ~/.ssh/… nor a file under a linked directory (`link/secret.ts` with
|
|
137
|
+
* `link` → elsewhere, which git lists from its index without looking) is read, indexed or served.
|
|
108
138
|
*/
|
|
109
139
|
export function insideRoot(root, rel) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
try { st = fs.lstatSync(abs); } catch { return true; } // vanished: the caller handles deletion
|
|
113
|
-
if (!st.isSymbolicLink()) return true;
|
|
114
|
-
let realRoot = realRoots.get(root);
|
|
115
|
-
if (!realRoot) { try { realRoot = fs.realpathSync(root); } catch { realRoot = root; } realRoots.set(root, realRoot); }
|
|
116
|
-
try {
|
|
117
|
-
const target = fs.realpathSync(abs);
|
|
118
|
-
return target === realRoot || target.startsWith(realRoot + path.sep);
|
|
119
|
-
} catch { return false; } // dangling link
|
|
140
|
+
try { fs.lstatSync(path.join(root, rel)); } catch { return true; } // vanished: the caller handles deletion
|
|
141
|
+
return withinRoot(root, rel);
|
|
120
142
|
}
|
|
121
143
|
|
|
122
144
|
/** Heuristic: minified / generated content that slipped past the name filters. */
|
package/src/index/indexer.mjs
CHANGED
|
@@ -98,7 +98,7 @@ export class Indexer {
|
|
|
98
98
|
const toParse = [];
|
|
99
99
|
for (const rel of files) {
|
|
100
100
|
const st = safeStat(path.join(this.root, rel));
|
|
101
|
-
if (!st || st.size > MAX_FILE_BYTES) { if (known.has(rel)) removed.push(rel); continue; }
|
|
101
|
+
if (!st || !st.isFile() || st.size > MAX_FILE_BYTES) { if (known.has(rel)) removed.push(rel); continue; }
|
|
102
102
|
const k = known.get(rel);
|
|
103
103
|
if (k && k.size === st.size && k.mtime_ms === Math.floor(st.mtimeMs)) continue;
|
|
104
104
|
toParse.push({ rel, st, prev: k ?? null });
|
package/src/index/modules.mjs
CHANGED
|
@@ -86,8 +86,7 @@ export class ModuleResolver {
|
|
|
86
86
|
// .svelte-kit/tsconfig.json that a repository does not carry, so read svelte.config itself
|
|
87
87
|
for (const rel of [...this.#findManifests('svelte.config.js'), ...this.#findManifests('svelte.config.ts')]) {
|
|
88
88
|
const dir = path.posix.dirname(rel), at = (p) => path.posix.normalize(dir === '.' ? p : `${dir}/${p}`);
|
|
89
|
-
|
|
90
|
-
try { text = fs.readFileSync(path.join(this.root, rel), 'utf8'); } catch { /* unreadable */ }
|
|
89
|
+
const text = readRepoFile(this.root, rel) ?? '';
|
|
91
90
|
const lib = /\bfiles\s*:\s*\{[^}]*\blib\s*:\s*['"]([^'"]+)['"]/.exec(text)?.[1] ?? 'src/lib';
|
|
92
91
|
const aliases = [['$lib', lib]];
|
|
93
92
|
const block = /\balias\s*:\s*\{([^}]*)\}/.exec(text)?.[1] ?? '';
|
|
@@ -110,8 +109,8 @@ export class ModuleResolver {
|
|
|
110
109
|
this.rustCrates = new Map();
|
|
111
110
|
this.rustPackages = [];
|
|
112
111
|
for (const rel of this.#findManifests('Cargo.toml')) {
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
const text = readRepoFile(this.root, rel);
|
|
113
|
+
if (text == null) continue;
|
|
115
114
|
const dir = path.posix.dirname(rel), at = (p) => (dir === '.' ? p : `${dir}/${p}`);
|
|
116
115
|
if (/^\[package\]\s*$/m.test(text)) this.rustPackages.push(dir);
|
|
117
116
|
const table = (name) => { const m = new RegExp(`^\\[${name}\\]\\s*$([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, 'm').exec(text); return m?.[1] ?? ''; };
|
package/src/mcp/server.mjs
CHANGED
|
@@ -10,6 +10,32 @@
|
|
|
10
10
|
import readline from 'node:readline';
|
|
11
11
|
import { TOOLS, LEGACY_TOOLS, SERVER_INSTRUCTIONS, callTool } from './tools.mjs';
|
|
12
12
|
|
|
13
|
+
const MAX_ITEMS = 100; // an array argument with no maxItems of its own
|
|
14
|
+
const MAX_STRING = 200_000; // a string argument (an issue pasted as a query is long, not this long)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The arguments as the tool's schema bounds them: numbers clamped to minimum/maximum, arrays cut
|
|
18
|
+
* to maxItems (else MAX_ITEMS), strings to MAX_STRING. A client is not trusted to respect the schema,
|
|
19
|
+
* and `depth: 1e9` or ten thousand queries would otherwise hold the server.
|
|
20
|
+
*/
|
|
21
|
+
export function boundArgs(schema, raw) {
|
|
22
|
+
const args = raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw } : {};
|
|
23
|
+
const props = schema?.properties ?? {};
|
|
24
|
+
const str = (v) => (typeof v === 'string' && v.length > MAX_STRING ? v.slice(0, MAX_STRING) : v);
|
|
25
|
+
for (const [k, v] of Object.entries(args)) {
|
|
26
|
+
const p = props[k];
|
|
27
|
+
if (typeof v === 'number' && p && (p.type === 'integer' || p.type === 'number')) {
|
|
28
|
+
let n = Number.isFinite(v) ? v : (p.default ?? p.minimum ?? 0);
|
|
29
|
+
if (p.type === 'integer') n = Math.trunc(n);
|
|
30
|
+
if (p.minimum != null) n = Math.max(p.minimum, n);
|
|
31
|
+
if (p.maximum != null) n = Math.min(p.maximum, n);
|
|
32
|
+
args[k] = n;
|
|
33
|
+
} else if (Array.isArray(v)) args[k] = v.slice(0, p?.maxItems ?? MAX_ITEMS).map(str);
|
|
34
|
+
else args[k] = str(v);
|
|
35
|
+
}
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
38
|
+
|
|
13
39
|
export const SUPPORTED_VERSIONS = ['2026-07-28', '2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
14
40
|
const LATEST_HANDSHAKE = '2025-11-25';
|
|
15
41
|
const META_VERSION = 'io.modelcontextprotocol/protocolVersion';
|
|
@@ -105,7 +131,7 @@ export class McpServer {
|
|
|
105
131
|
const name = params?.name;
|
|
106
132
|
const tool = TOOLS.find(t => t.name === name) ?? LEGACY_TOOLS.find(t => t.name === name);
|
|
107
133
|
if (!tool) { this.#error(id, -32602, `Unknown tool: ${name}`); return; }
|
|
108
|
-
const args = params?.arguments
|
|
134
|
+
const args = boundArgs(tool.inputSchema, params?.arguments);
|
|
109
135
|
const missing = (tool.inputSchema.required ?? []).filter(k => args[k] === undefined || args[k] === null || args[k] === '');
|
|
110
136
|
if (missing.length) {
|
|
111
137
|
done({ content: [{ type: 'text', text: `Missing required argument(s): ${missing.join(', ')}.` }], isError: true });
|
package/src/mcp/tools.mjs
CHANGED
|
@@ -412,7 +412,7 @@ async function toolText(intel, { pattern, path: p = null, literal = false, ignor
|
|
|
412
412
|
if (sm.unbound) parts.push(`${sm.unbound} unbound`);
|
|
413
413
|
if (sm.text) parts.push(`${sm.text} in comments/strings/other text`);
|
|
414
414
|
if (sm.nonCode) parts.push(`${sm.nonCode} in non-code files`);
|
|
415
|
-
out.push(`"${sm.ident}" across all ${r.total} matches: ${parts.join(', ')}.`);
|
|
415
|
+
out.push(`"${sm.ident}" across ${r.annotated < r.total ? `the first ${r.annotated} of` : 'all'} ${r.total} matches: ${parts.join(', ')}.`);
|
|
416
416
|
}
|
|
417
417
|
out.push(...definitionRescue(intel, pattern, literal, r));
|
|
418
418
|
return out.join('\n');
|
package/src/parse/lang/sfc.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* out, as are HTML elements and the frameworks' own (`<Transition>`, `<svelte:self>`).
|
|
15
15
|
*/
|
|
16
16
|
import { typescript } from './ecma.mjs';
|
|
17
|
+
import { escapeRegExp } from '../../util/regex.mjs';
|
|
17
18
|
|
|
18
19
|
const BUILTIN = new Set(['Transition', 'TransitionGroup', 'KeepAlive', 'Teleport', 'Suspense', 'Component', 'Slot', 'Template', 'RouterView', 'RouterLink']);
|
|
19
20
|
|
|
@@ -65,7 +66,7 @@ function rewrite(source, kind) {
|
|
|
65
66
|
ranges = ranges.filter(([a, b]) => source.slice(a, b).trim());
|
|
66
67
|
}
|
|
67
68
|
if (!ranges.length) return lines.join('\n');
|
|
68
|
-
const known = (name) => !BUILTIN.has(name) && new RegExp(`\\b${name}\\b`).test(scriptText);
|
|
69
|
+
const known = (name) => !BUILTIN.has(name) && new RegExp(`\\b${escapeRegExp(name)}\\b`).test(scriptText);
|
|
69
70
|
const tags = [];
|
|
70
71
|
for (const [a, b] of ranges) {
|
|
71
72
|
const markup = source.slice(a, b).replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, ' '));
|
|
@@ -115,7 +116,7 @@ const sfc = (id, extensions) => ({
|
|
|
115
116
|
// its template's tags are that import, so the component itself takes another name
|
|
116
117
|
const root = d.node.tree?.rootNode ?? d.node.parent;
|
|
117
118
|
const imports = (root?.namedChildren ?? []).filter(c => c.type === 'import_statement').map(c => c.text).join('\n');
|
|
118
|
-
return new RegExp(`\\b${comp}\\b`).test(imports) ? `${comp}Component` : comp;
|
|
119
|
+
return new RegExp(`\\b${escapeRegExp(comp)}\\b`).test(imports) ? `${comp}Component` : comp;
|
|
119
120
|
},
|
|
120
121
|
rewriteSource: (source) => rewrite(source, id),
|
|
121
122
|
testFile: /(^|\/)(__tests__|tests?|spec|e2e)\//,
|
|
@@ -42,6 +42,7 @@ const ANNOTATION = /@[\w.]+(?:\s*\((?:[^()"]|"[^"]*"|\((?:[^()"]|"[^"]*")*\))*\)
|
|
|
42
42
|
|
|
43
43
|
import fs from 'node:fs';
|
|
44
44
|
import path from 'node:path';
|
|
45
|
+
import { readRepoFile } from '../util/paths.mjs';
|
|
45
46
|
|
|
46
47
|
const BUILD_FILES = ['pom.xml', 'build.gradle', 'build.gradle.kts'];
|
|
47
48
|
|
|
@@ -53,8 +54,7 @@ const strip = (s) => String(s ?? '').trim().replace(/\s+/g, ' ');
|
|
|
53
54
|
function moduleDeps(root, mods) {
|
|
54
55
|
const info = new Map();
|
|
55
56
|
for (const m of mods) {
|
|
56
|
-
const
|
|
57
|
-
const pom = read(path.join(dir, 'pom.xml'));
|
|
57
|
+
const pom = read(root, m, 'pom.xml');
|
|
58
58
|
if (pom != null) {
|
|
59
59
|
let body = pom;
|
|
60
60
|
for (let prev; prev !== body;) { prev = body; body = body.replace(/<!--[\s\S]*?-->/g, ''); }
|
|
@@ -63,7 +63,7 @@ function moduleDeps(root, mods) {
|
|
|
63
63
|
info.set(m, { names: [own, path.posix.basename(m)].filter(Boolean), deps: ds });
|
|
64
64
|
continue;
|
|
65
65
|
}
|
|
66
|
-
const gradle = read(
|
|
66
|
+
const gradle = read(root, m, 'build.gradle') ?? read(root, m, 'build.gradle.kts');
|
|
67
67
|
if (gradle != null) info.set(m, { names: [path.posix.basename(m)], deps: [...gradle.matchAll(/project\(\s*(?:path\s*[:=]\s*)?['"]:?([\w:.-]+)['"]/g)].map(x => x[1].split(':').pop()) });
|
|
68
68
|
}
|
|
69
69
|
const byName = new Map();
|
|
@@ -73,7 +73,8 @@ function moduleDeps(root, mods) {
|
|
|
73
73
|
return out;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
/** A build file of module `m`: a small regular file inside the repository, or null. */
|
|
77
|
+
const read = (root, m, name) => readRepoFile(root, path.posix.join(m || '.', name));
|
|
77
78
|
|
|
78
79
|
/** Spring's default bean name: `FooService` → `fooService`, but `URLMapper` stays `URLMapper`. */
|
|
79
80
|
const beanName = (cls) => /^[A-Z]{2}/.test(cls) ? cls : cls[0].toLowerCase() + cls.slice(1);
|
package/src/query/injection.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { collectSpring } from './injection-spring.mjs';
|
|
22
|
+
import { escapeRegExp } from '../util/regex.mjs';
|
|
22
23
|
|
|
23
24
|
const PROVIDE = /\bprovide\s*:\s*([^,}\n]+?)\s*(?:,|}|$)/;
|
|
24
25
|
const USE = /\buse(Class|Existing|Factory|Value)\s*:\s*([^,}\n]*)/;
|
|
@@ -42,7 +43,7 @@ function symRow(intel, id) {
|
|
|
42
43
|
function stringValue(intel, s) {
|
|
43
44
|
if (s.kind !== 'constant' && s.kind !== 'variable') return null;
|
|
44
45
|
const l = intel.fileLines(s.path)?.[s.start_line - 1] ?? '';
|
|
45
|
-
return new RegExp(`\\b${s.name}\\s*(?::[^=]+)?=\\s*(['"\`])([^'"\`]*)\\1\\s*;?\\s*$`).exec(l)?.[2] ?? null;
|
|
46
|
+
return new RegExp(`\\b${escapeRegExp(s.name)}\\s*(?::[^=]+)?=\\s*(['"\`])([^'"\`]*)\\1\\s*;?\\s*$`).exec(l)?.[2] ?? null;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/**
|
package/src/query/intel.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { Indexer } from '../index/indexer.mjs';
|
|
|
12
12
|
import { SearchEngine } from '../search/search.mjs';
|
|
13
13
|
import { computeCentrality, loadGraph } from '../index/graph.mjs';
|
|
14
14
|
import { confidenceLabel } from '../index/resolver.mjs';
|
|
15
|
-
import { dataDir, withinRoot, isRevision } from '../util/paths.mjs';
|
|
15
|
+
import { dataDir, withinRoot, isRevision, DATA_DIR_NAME } from '../util/paths.mjs';
|
|
16
16
|
import { isConstructor } from '../parse/languages.mjs';
|
|
17
17
|
import { sigArity } from '../parse/arity.mjs';
|
|
18
18
|
import { escapeRegExp } from '../util/regex.mjs';
|
|
@@ -27,6 +27,7 @@ const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials
|
|
|
27
27
|
export class CodeIntel {
|
|
28
28
|
constructor({ root, dbPath = null, log = () => {}, watch = false }) {
|
|
29
29
|
this.root = path.resolve(root);
|
|
30
|
+
this.ownDb = dbPath == null;
|
|
30
31
|
this.dbPath = dbPath ?? path.join(dataDir(this.root), 'index.db');
|
|
31
32
|
this.log = log;
|
|
32
33
|
this.watchEnabled = watch;
|
|
@@ -49,6 +50,13 @@ export class CodeIntel {
|
|
|
49
50
|
* MCP server so the first tool call never times out on a large repository.
|
|
50
51
|
*/
|
|
51
52
|
async open({ onProgress = null, background = false } = {}) {
|
|
53
|
+
// an index the repository ships (a crafted index.db, its -wal) is never opened: it was not
|
|
54
|
+
// built here and would be answered from as if it had been
|
|
55
|
+
if (this.ownDb) {
|
|
56
|
+
const r = gitSync(this.root, ['ls-files', '-z', '--', DATA_DIR_NAME]);
|
|
57
|
+
const tracked = r.status === 0 ? r.stdout.split('\0').filter(Boolean) : [];
|
|
58
|
+
if (tracked.length) throw new Error(`${DATA_DIR_NAME}/ is committed to this repository (${tracked.length} file${tracked.length === 1 ? '' : 's'}); graph-indexer does not use an index it did not build. Remove it from the repository (git rm -r --cached ${DATA_DIR_NAME}) and run again.`);
|
|
59
|
+
}
|
|
52
60
|
this.store = new Store(this.dbPath);
|
|
53
61
|
this.ix = new Indexer({ root: this.root, store: this.store, log: this.log });
|
|
54
62
|
this.ix.load();
|
package/src/query/orm.mjs
CHANGED
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import fs from 'node:fs';
|
|
30
30
|
import path from 'node:path';
|
|
31
|
-
import {
|
|
31
|
+
import { gitSync } from '../util/git.mjs';
|
|
32
|
+
import { readRepoFile } from '../util/paths.mjs';
|
|
32
33
|
|
|
33
34
|
const PRISMA_OPS = 'findUnique|findUniqueOrThrow|findFirst|findFirstOrThrow|findMany|create|createMany|createManyAndReturn|update|updateMany|updateManyAndReturn|upsert|delete|deleteMany|count|aggregate|groupBy';
|
|
34
35
|
const PRISMA_CALL = new RegExp(`\\b(prisma\\w*|\\w*Prisma|db|tx|trx|client|transaction)\\s*\\.\\s*([a-z]\\w*)\\s*\\.\\s*(${PRISMA_OPS})\\s*[(<]`, 'g');
|
|
@@ -45,7 +46,7 @@ const decorators = (s) => { try { return JSON.parse(s.decorators || '[]'); } cat
|
|
|
45
46
|
|
|
46
47
|
/** Repository files matching a test, indexed or not (`*.prisma`, migration `.sql`): git's list, else a bounded walk. */
|
|
47
48
|
function repoFiles(root, test) {
|
|
48
|
-
const r =
|
|
49
|
+
const r = gitSync(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { timeout: 5000, maxBuffer: 64 << 20 });
|
|
49
50
|
if (r.status === 0 && r.stdout) return r.stdout.split('\0').filter(f => f && test(f));
|
|
50
51
|
const out = [];
|
|
51
52
|
const walk = (dir, depth) => {
|
|
@@ -65,7 +66,8 @@ function repoFiles(root, test) {
|
|
|
65
66
|
function readLines(intel, rel) {
|
|
66
67
|
const l = intel.fileLines(rel);
|
|
67
68
|
if (l) return l;
|
|
68
|
-
|
|
69
|
+
// not indexed (a .prisma file, a migration): still only a regular file inside the repository
|
|
70
|
+
return readRepoFile(intel.root, rel)?.split(/\r?\n/) ?? null;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
/** Prisma models of every `.prisma` file. */
|
package/src/query/textsearch.mjs
CHANGED
|
@@ -15,12 +15,14 @@ const MAX_FILE_BYTES = 1_500_000;
|
|
|
15
15
|
// a caller's pattern gets this much matching time per search (reading files does not count): one
|
|
16
16
|
// with catastrophic backtracking (`(a+)+$`) is stopped instead of blocking the server
|
|
17
17
|
const REGEX_BUDGET_MS = 5000;
|
|
18
|
+
const MAX_KEPT = 50_000;
|
|
18
19
|
const SCAN = new vm.Script(`(() => {
|
|
19
20
|
if (!re.test(text)) return null;
|
|
20
21
|
const lines = text.split(/\\r?\\n/), hits = [];
|
|
21
22
|
for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) hits.push(i);
|
|
22
23
|
return { lines, hits };
|
|
23
24
|
})()`);
|
|
25
|
+
const GLOB_FILTER = new vm.Script(`paths.filter(p => re.test(onName ? p.slice(p.lastIndexOf('/') + 1) : p))`);
|
|
24
26
|
const SKIP_DIRS = /(^|\/)(node_modules|\.git|\.graph-indexer|dist|build|out|target|coverage|__pycache__|\.venv|venv|\.next|\.nuxt|\.cache|vendor)\//;
|
|
25
27
|
const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials?|benchmarks?|playground)\//i;
|
|
26
28
|
const COMMENT_LINE = /^\s*(\/\/|#(?![!\[])|\*|\/\*|<!--|--\s|;;|%)/;
|
|
@@ -54,7 +56,7 @@ function globSource(glob) {
|
|
|
54
56
|
i++;
|
|
55
57
|
if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
|
|
56
58
|
} else if (c === '?') re += '[^/]';
|
|
57
|
-
else if (c === '[' && glob.indexOf(']', i + 2) > i) { const j = glob.indexOf(']', i + 2); re += glob.slice(i
|
|
59
|
+
else if (c === '[' && glob.indexOf(']', i + 2) > i) { const j = glob.indexOf(']', i + 2); re += '[' + glob.slice(i + 1, j).replace(/^!/, '^').replace(/\\/g, '\\\\') + ']'; i = j; }
|
|
58
60
|
else if (c === '{' && glob.indexOf('}', i) > i) { const j = glob.indexOf('}', i); re += `(?:${glob.slice(i + 1, j).split(',').map(escapeRe).join('|')})`; i = j; }
|
|
59
61
|
else re += escapeRe(c);
|
|
60
62
|
}
|
|
@@ -71,7 +73,16 @@ export function findFiles(root, pattern, { path: prefix = null } = {}) {
|
|
|
71
73
|
if (/[*?[{]/.test(pattern)) {
|
|
72
74
|
const onName = !pattern.includes('/');
|
|
73
75
|
const re = new RegExp(onName ? `^${globSource(pattern)}$` : `(^|/)${globSource(pattern.replace(/^\.?\//, ''))}$`);
|
|
74
|
-
|
|
76
|
+
// a glob of many stars (`*a*a*a*a*a*a*b`) backtracks for minutes on one long name: matched
|
|
77
|
+
// under the same budget as search_text's expressions, so it cannot block the server
|
|
78
|
+
const paths = listFiles(root).filter(p => !pre || p === pre || p.startsWith(pre + '/'));
|
|
79
|
+
const ctx = vm.createContext({ re, paths, onName });
|
|
80
|
+
try {
|
|
81
|
+
return GLOB_FILTER.runInContext(ctx, { timeout: REGEX_BUDGET_MS }).sort();
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (e.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw e;
|
|
84
|
+
throw new Error(`The glob took more than ${REGEX_BUDGET_MS / 1000} s to match (too many wildcards?). Use fewer wildcards.`);
|
|
85
|
+
}
|
|
75
86
|
} else {
|
|
76
87
|
const t = pattern.toLowerCase();
|
|
77
88
|
test = (p) => p.toLowerCase().includes(t);
|
|
@@ -133,7 +144,8 @@ export async function textSearch(intel, { pattern, path: prefix = null, literal
|
|
|
133
144
|
const f = indexed.get(rel);
|
|
134
145
|
for (const i of scan.hits) {
|
|
135
146
|
total++;
|
|
136
|
-
|
|
147
|
+
// counted, but kept (and annotated) only up to a bound: memory and time stay bounded
|
|
148
|
+
if (matches.length < MAX_KEPT) matches.push({ path: rel, line: i + 1, text: scan.lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
|
|
137
149
|
}
|
|
138
150
|
}
|
|
139
151
|
const ident = identifierOf(pattern, literal);
|
|
@@ -149,13 +161,22 @@ export async function textSearch(intel, { pattern, path: prefix = null, literal
|
|
|
149
161
|
WHERE r.file_id = ? AND r.name = ?`, f.id, ident) : [];
|
|
150
162
|
const refsAt = new Map();
|
|
151
163
|
for (const r of refs) (refsAt.get(r.line) ?? refsAt.set(r.line, []).get(r.line)).push(r);
|
|
164
|
+
// innermost definition around each matched line, ignoring ones that start on it (inline
|
|
165
|
+
// classes, the def itself): one sweep over the definitions in start order, writing only the
|
|
166
|
+
// matched lines, instead of every definition for every match
|
|
167
|
+
const wanted = new Map(ms.map(m => [m.line, null]));
|
|
168
|
+
const lines = [...wanted.keys()].sort((a, b) => a - b);
|
|
169
|
+
for (const s of syms) {
|
|
170
|
+
let lo = 0, hi = lines.length;
|
|
171
|
+
while (lo < hi) { const mid = (lo + hi) >> 1; if (lines[mid] <= s.start_line) lo = mid + 1; else hi = mid; }
|
|
172
|
+
for (let k = lo; k < lines.length && lines[k] <= s.end_line; k++) wanted.set(lines[k], s);
|
|
173
|
+
}
|
|
174
|
+
const defAt = new Map();
|
|
175
|
+
if (summary) for (const s of syms) if (s.name === ident && !defAt.has(s.name_line)) defAt.set(s.name_line, s);
|
|
152
176
|
for (const m of ms) {
|
|
153
|
-
|
|
154
|
-
let encl = null;
|
|
155
|
-
for (const s of syms) if (s.start_line < m.line && s.end_line >= m.line && (!encl || s.start_line >= encl.start_line)) encl = s;
|
|
156
|
-
m.encl = encl?.qname ?? null;
|
|
177
|
+
m.encl = wanted.get(m.line)?.qname ?? null;
|
|
157
178
|
if (!summary) continue;
|
|
158
|
-
const def =
|
|
179
|
+
const def = defAt.get(m.line);
|
|
159
180
|
const rs = refsAt.get(m.line) ?? [];
|
|
160
181
|
if (def) { m.what = 'def'; m.target = def.qname; summary.defs++; continue; }
|
|
161
182
|
const bound = rs.filter(r => r.dst_id != null);
|
|
@@ -174,5 +195,5 @@ export async function textSearch(intel, { pattern, path: prefix = null, literal
|
|
|
174
195
|
}
|
|
175
196
|
}
|
|
176
197
|
matches.sort((a, b) => rank(a.path, a.isTest) + (a.code ? 0 : 1) - (rank(b.path, b.isTest) + (b.code ? 0 : 1)) || a.path.localeCompare(b.path) || a.line - b.line);
|
|
177
|
-
return { total, files: nfiles, shown: matches.slice(0, limit), all: matches, summary, truncated:
|
|
198
|
+
return { total, files: nfiles, shown: matches.slice(0, limit), all: matches, summary, truncated: total > limit, annotated: matches.length };
|
|
178
199
|
}
|
package/src/store/db.mjs
CHANGED
|
@@ -128,11 +128,21 @@ CREATE VIRTUAL TABLE IF NOT EXISTS file_fts USING fts5(
|
|
|
128
128
|
`;
|
|
129
129
|
|
|
130
130
|
export class Store {
|
|
131
|
+
/**
|
|
132
|
+
* An index.db the repository shipped is not trusted: defensive mode refuses the statements that
|
|
133
|
+
* can corrupt the file (writable_schema, …) and an untrusted schema may not call functions.
|
|
134
|
+
*/
|
|
135
|
+
#harden() {
|
|
136
|
+
try { this.db.enableDefensive?.(true); } catch { /* older Node */ }
|
|
137
|
+
this.db.exec('PRAGMA trusted_schema = OFF;');
|
|
138
|
+
}
|
|
139
|
+
|
|
131
140
|
constructor(dbPath) {
|
|
132
141
|
this.path = dbPath;
|
|
133
142
|
this.stmts = new Map();
|
|
134
143
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
135
144
|
this.db = new DatabaseSync(dbPath);
|
|
145
|
+
this.#harden();
|
|
136
146
|
this.db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA temp_store = MEMORY; PRAGMA cache_size = -65536; PRAGMA busy_timeout = 5000;');
|
|
137
147
|
const v = this.#schemaVersion();
|
|
138
148
|
if (v !== null && v !== SCHEMA_VERSION) {
|
|
@@ -140,6 +150,7 @@ export class Store {
|
|
|
140
150
|
this.db.close();
|
|
141
151
|
for (const suffix of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + suffix); } catch { /* none */ } }
|
|
142
152
|
this.db = new DatabaseSync(dbPath);
|
|
153
|
+
this.#harden();
|
|
143
154
|
this.db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA temp_store = MEMORY; PRAGMA cache_size = -65536; PRAGMA busy_timeout = 5000;');
|
|
144
155
|
}
|
|
145
156
|
this.db.exec(SCHEMA);
|
package/src/util/git.mjs
CHANGED
|
@@ -1,30 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
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,
|
|
4
|
-
* clean/smudge/process filter switched off
|
|
5
|
-
*
|
|
3
|
+
* fsmonitor, no hooks, no signature verification, no external diff or textconv, every
|
|
4
|
+
* clean/smudge/process filter switched off, and no descent into submodules (whose own config the
|
|
5
|
+
* overrides below do not cover). A repository copied with its .git directory (an archive, a shared
|
|
6
|
+
* folder) is not trusted. Every git call graph-indexer makes goes through gitSync.
|
|
6
7
|
*/
|
|
7
8
|
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
8
11
|
|
|
9
12
|
const COMMAND_KEY = /^(filter\.[^\n]+)\.(clean|smudge|process)$|^diff\.[^\n]+\.(textconv|command)$/;
|
|
10
13
|
|
|
14
|
+
let gitBin = null;
|
|
15
|
+
/**
|
|
16
|
+
* The git executable. On Windows a bare `git` is looked up in the working directory before PATH,
|
|
17
|
+
* so a `git.exe` committed at the repository root would run instead: look it up on PATH only.
|
|
18
|
+
*/
|
|
19
|
+
export function gitExecutable() {
|
|
20
|
+
if (gitBin) return gitBin;
|
|
21
|
+
gitBin = 'git';
|
|
22
|
+
if (process.platform === 'win32') {
|
|
23
|
+
const exts = (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';').filter(Boolean);
|
|
24
|
+
for (const dir of (process.env.PATH || process.env.Path || '').split(path.delimiter)) {
|
|
25
|
+
if (!dir || !path.isAbsolute(dir)) continue;
|
|
26
|
+
const hit = exts.map(e => path.join(dir, 'git' + e.toLowerCase())).find(f => { try { return fs.statSync(f).isFile(); } catch { return false; } });
|
|
27
|
+
if (hit) { gitBin = hit; break; }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return gitBin;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let envConfig = null;
|
|
34
|
+
/** git ≥ 2.31 takes config from GIT_CONFIG_COUNT/KEY/VALUE, which carry a key verbatim. */
|
|
35
|
+
function supportsEnvConfig() {
|
|
36
|
+
if (envConfig != null) return envConfig;
|
|
37
|
+
const m = /(\d+)\.(\d+)/.exec(spawnSync(gitExecutable(), ['--version'], { encoding: 'utf8' }).stdout ?? '');
|
|
38
|
+
return (envConfig = !!m && (+m[1] > 2 || (+m[1] === 2 && +m[2] >= 31)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The overrides for `root`: fixed ones, plus one blanking each command its config names. Those go
|
|
43
|
+
* in the environment, not as `-c key=`: git splits a `-c` at the first `=`, and a driver may be
|
|
44
|
+
* named `x=y`, so `-c filter.x=y.clean=` would set `filter.x` and leave the real command on.
|
|
45
|
+
* Returns null when the overrides cannot be expressed (an old git and such a name): git is not run.
|
|
46
|
+
*/
|
|
11
47
|
function hardening(root) {
|
|
12
|
-
const
|
|
13
|
-
const r = spawnSync(
|
|
14
|
-
for (const key of (r.status === 0 ? r.stdout : '').split('\
|
|
48
|
+
const pairs = [['core.fsmonitor', 'false'], ['core.hooksPath', '/dev/null'], ['diff.external', ''], ['log.showSignature', 'false']];
|
|
49
|
+
const r = spawnSync(gitExecutable(), ['config', '-z', '--name-only', '--get-regexp', '^(filter|diff)\\.'], { cwd: root, encoding: 'utf8' });
|
|
50
|
+
for (const key of (r.status === 0 ? r.stdout : '').split('\0')) {
|
|
15
51
|
const m = COMMAND_KEY.exec(key);
|
|
16
52
|
if (!m) continue;
|
|
17
|
-
|
|
53
|
+
pairs.push([key, '']);
|
|
18
54
|
// a required filter (Git LFS sets one) would otherwise fail the diff once its command is blank
|
|
19
|
-
if (m[1])
|
|
55
|
+
if (m[1]) pairs.push([`${m[1]}.required`, 'false']);
|
|
56
|
+
}
|
|
57
|
+
if (supportsEnvConfig()) {
|
|
58
|
+
const base = Number(process.env.GIT_CONFIG_COUNT) || 0;
|
|
59
|
+
const env = { GIT_CONFIG_COUNT: String(base + pairs.length) };
|
|
60
|
+
pairs.forEach(([k, v], i) => { env[`GIT_CONFIG_KEY_${base + i}`] = k; env[`GIT_CONFIG_VALUE_${base + i}`] = v; });
|
|
61
|
+
return { args: [], env };
|
|
20
62
|
}
|
|
21
|
-
return
|
|
63
|
+
if (pairs.some(([k]) => k.includes('='))) return null;
|
|
64
|
+
return { args: pairs.flatMap(([k, v]) => ['-c', `${k}=${v}`]), env: {} };
|
|
22
65
|
}
|
|
23
66
|
|
|
24
|
-
const NO_TOOLS = {
|
|
67
|
+
const NO_TOOLS = {
|
|
68
|
+
diff: ['--no-ext-diff', '--no-textconv', '--ignore-submodules=all'],
|
|
69
|
+
log: ['--no-ext-diff', '--no-textconv', '--no-show-signature'],
|
|
70
|
+
status: ['--ignore-submodules=all'],
|
|
71
|
+
};
|
|
25
72
|
|
|
26
73
|
/** spawnSync('git', args) in `root`, hardened as above; diff and log never run external tools. */
|
|
27
74
|
export function gitSync(root, args, opts = {}) {
|
|
28
75
|
const sub = NO_TOOLS[args[0]] ? [args[0], ...NO_TOOLS[args[0]], ...args.slice(1)] : args;
|
|
29
|
-
|
|
76
|
+
const h = hardening(root);
|
|
77
|
+
if (!h) return { status: 1, stdout: '', stderr: 'git not run: the repository config names a filter or diff driver this git cannot switch off', error: null };
|
|
78
|
+
return spawnSync(gitExecutable(), [...h.args, ...sub], { cwd: root, encoding: 'utf8', ...opts, env: { ...process.env, ...opts.env, ...h.env } });
|
|
30
79
|
}
|
package/src/util/paths.mjs
CHANGED
|
@@ -12,6 +12,11 @@ export function dataDir(root) {
|
|
|
12
12
|
const st = fs.lstatSync(dir);
|
|
13
13
|
if (!st.isDirectory()) throw new Error(`${dir} is not a directory (a symbolic link?); remove it and run again`);
|
|
14
14
|
if ((st.mode & 0o077) !== 0) { try { fs.chmodSync(dir, 0o700); } catch { /* read-only */ } }
|
|
15
|
+
// nor may a link inside it (`index.db-wal` → ~/.zshrc, `resident.started` → ~/.ssh/…) send the
|
|
16
|
+
// index's writes elsewhere: everything in here is ours, so a link is never legitimate
|
|
17
|
+
try {
|
|
18
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) if (e.isSymbolicLink()) fs.unlinkSync(path.join(dir, e.name));
|
|
19
|
+
} catch { /* read-only */ }
|
|
15
20
|
const gi = path.join(dir, '.gitignore');
|
|
16
21
|
if (!fs.existsSync(gi)) { try { fs.writeFileSync(gi, '*\n'); } catch { /* read-only */ } }
|
|
17
22
|
return dir;
|
|
@@ -37,11 +42,13 @@ const realRoots = new Map();
|
|
|
37
42
|
*/
|
|
38
43
|
export function withinRoot(root, rel) {
|
|
39
44
|
let realRoot = realRoots.get(root);
|
|
40
|
-
if (!realRoot) { try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); } realRoots.set(root, realRoot); }
|
|
45
|
+
if (!realRoot) { try { realRoot = fs.realpathSync.native(root); } catch { realRoot = path.resolve(root); } realRoots.set(root, realRoot); }
|
|
41
46
|
let real;
|
|
42
|
-
|
|
47
|
+
// the native realpath gives the name as stored on disk, so on a case-insensitive file system
|
|
48
|
+
// (macOS, Windows) `.GIT/config` resolves to `.git/config` and is refused like it
|
|
49
|
+
try { real = fs.realpathSync.native(path.join(root, rel)); } catch { return false; }
|
|
43
50
|
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return false;
|
|
44
|
-
const top = path.relative(realRoot, real).split(path.sep)[0];
|
|
51
|
+
const top = path.relative(realRoot, real).split(path.sep)[0].toLowerCase();
|
|
45
52
|
return top !== '.git' && top !== DATA_DIR_NAME;
|
|
46
53
|
}
|
|
47
54
|
|