@sdsrs/code-graph 0.111.1 → 0.112.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -32
- package/bin/cli.js +5 -0
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/auto-update.js +21 -0
- package/claude-plugin/scripts/doctor.js +2 -2
- package/claude-plugin/scripts/lifecycle.js +70 -16
- package/claude-plugin/scripts/post-grep-inject.js +11 -9
- package/claude-plugin/scripts/pre-edit-guide.js +6 -2
- package/claude-plugin/scripts/pre-grep-guide.js +11 -9
- package/claude-plugin/scripts/session-init.js +20 -7
- package/claude-plugin/scripts/statusline-composite.js +8 -1
- package/claude-plugin/scripts/statusline.js +11 -1
- package/claude-plugin/scripts/tmp-dir.js +58 -1
- package/claude-plugin/scripts/user-prompt-context.js +46 -17
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -5,14 +5,16 @@ A high-performance code knowledge graph server implementing the [Model Context P
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Multi-language parsing** — Tree-sitter AST extraction across tiers of depth:
|
|
8
|
-
- **Full** (calls + imports + inheritance
|
|
8
|
+
- **Full** (calls + imports + inheritance): TypeScript/TSX, JavaScript, Go, Python, Rust, Java. HTTP route extraction additionally covers TypeScript/TSX + JavaScript (Express/Connect), Go (`net/http`), Python (Flask/FastAPI), and Rust axum (`.route()` chains with inline `.nest()` prefixes; named handlers only) — actix/rocket and Java Spring are not yet route-extracted
|
|
9
9
|
- **Smoke-tested** (calls + imports + inheritance): C#, Kotlin, Ruby, PHP, Swift, Dart
|
|
10
|
-
- **Limited** (functions + calls + `#include` imports + gtest test markers + C++ base-class inheritance
|
|
10
|
+
- **Limited** (functions + calls + `#include` imports + gtest test markers + C++ base-class inheritance + `Class::method` scope qualification): C, C++
|
|
11
11
|
- **Scripting**: Bash (functions + commands + `source`/`.` imports), Markdown (headings)
|
|
12
12
|
- **File-FTS only** (no AST symbol extraction): HTML, CSS, JSON
|
|
13
|
+
- **Test markers** — detected from the AST for Rust (`#[test]` / `#[cfg(test)]`), JavaScript/TypeScript/TSX (`describe`/`it`/`test` blocks and their callbacks) and C/C++ (gtest `TEST` macros). Go, Python, Java and the rest fall back to path/name heuristics (`tests/`, `src/test/java/`, `*_test.*`, `*.test.js`, `test_*`, `*Test`)
|
|
14
|
+
- **Value/type references** — a `references` relation for symbols that are used without being called, imported or inherited (path-qualified constants, type-position uses, functions passed as values): Rust, TypeScript/TSX, JavaScript, Python, Go, Java, C, C++
|
|
13
15
|
- **Semantic code search** — Hybrid BM25 full-text + vector semantic search with Reciprocal Rank Fusion (RRF), powered by sqlite-vec
|
|
14
16
|
- **Call graph traversal** — Recursive CTE queries to trace callers/callees with cycle detection
|
|
15
|
-
- **HTTP route tracing** — Map route paths to backend handler functions (Express, Flask/FastAPI, Go `net/http
|
|
17
|
+
- **HTTP route tracing** — Map route paths to backend handler functions (Express, Flask/FastAPI, Go `net/http`, Rust axum)
|
|
16
18
|
- **Dead code detection** — Find unreferenced symbols with smart Orphan/Exported-Unused classification
|
|
17
19
|
- **Impact analysis** — Determine the blast radius of code changes by tracing all dependents
|
|
18
20
|
- **Incremental indexing** — Merkle tree change detection with file system watcher for real-time updates. Smart event filtering skips metadata-only changes (chmod, xattr)
|
|
@@ -36,11 +38,11 @@ Combines BM25 full-text ranking (FTS5) with vector semantic similarity (sqlite-v
|
|
|
36
38
|
|
|
37
39
|
### Scope-Aware Relation Extraction
|
|
38
40
|
|
|
39
|
-
The parser doesn't just find function calls — it tracks them within their proper scope context. Extracts calls, imports, inheritance, interface implementations, exports, and HTTP route bindings. Same-file targets are preferred over cross-file matches to minimize false-positive edges.
|
|
41
|
+
The parser doesn't just find function calls — it tracks them within their proper scope context. Extracts calls, imports, inheritance, interface implementations, exports (ESM `export` **and** CommonJS `module.exports = { … }` / `exports.name = …`), value/type references, and HTTP route bindings. Same-file targets are preferred over cross-file matches to minimize false-positive edges.
|
|
40
42
|
|
|
41
43
|
### HTTP Request Flow Tracing
|
|
42
44
|
|
|
43
|
-
Unique to code-graph-mcp: trace from `GET /api/users` → route handler → service layer → database call in a single query. Supports Express, Flask/FastAPI, and
|
|
45
|
+
Unique to code-graph-mcp: trace from `GET /api/users` → route handler → service layer → database call in a single query. Supports Express/Connect, Flask/FastAPI, Go `net/http`, and Rust axum.
|
|
44
46
|
|
|
45
47
|
### Zero External Dependencies at Runtime
|
|
46
48
|
|
|
@@ -73,10 +75,10 @@ Real-world benchmarks comparing code-graph-mcp tools against traditional approac
|
|
|
73
75
|
| Project architecture overview | 5-8 calls | 1 call (`project_map`) | **~85%** |
|
|
74
76
|
| Find function by concept | 3-5 calls | 1 call (`semantic_code_search`) | **~75%** |
|
|
75
77
|
| Trace 2-level call chain | 8-15 calls | 1 call (`get_call_graph`) | **~90%** |
|
|
76
|
-
| Pre-change impact analysis | 10-20+ calls | 1 call (`
|
|
78
|
+
| Pre-change impact analysis | 10-20+ calls | 1 call (`get_ast_node` + `include_impact`) | **~95%** |
|
|
77
79
|
| Module structure & exports | 5+ calls | 1 call (`module_overview`) | **~80%** |
|
|
78
|
-
| File dependency mapping | 3-5 calls | 1 call (`
|
|
79
|
-
| Similar code detection | N/A | 1 call (`
|
|
80
|
+
| File dependency mapping | 3-5 calls | 1 call (`module_overview` + `include_deps`) | **~75%** |
|
|
81
|
+
| Similar code detection | N/A | 1 call (`get_ast_node` + `include_similar`) | **unique** |
|
|
80
82
|
|
|
81
83
|
### Overall Session Efficiency
|
|
82
84
|
|
|
@@ -263,20 +265,19 @@ npm uninstall -g @sdsrs/code-graph
|
|
|
263
265
|
|
|
264
266
|
## MCP Tools
|
|
265
267
|
|
|
268
|
+
`tools/list` advertises exactly these seven. Several older niche tools were folded into flags on them, so one call now covers what used to take a separate tool:
|
|
269
|
+
|
|
266
270
|
| Tool | Description |
|
|
267
271
|
|------|-------------|
|
|
268
|
-
| `project_map` | Full project architecture: modules, dependencies, entry points, hot functions |
|
|
269
|
-
| `semantic_code_search` | Hybrid BM25 + vector
|
|
270
|
-
| `get_call_graph` | Trace upstream/downstream call chains for a function |
|
|
271
|
-
| `
|
|
272
|
-
| `
|
|
273
|
-
| `module_overview` | High-level overview of a module's structure and exports |
|
|
274
|
-
| `dependency_graph` | Visualize dependency relationships between modules. Supports `compact` mode |
|
|
275
|
-
| `find_similar_code` | Find semantically similar code via embeddings. Requires `symbol_name` or `node_id` |
|
|
276
|
-
| `get_ast_node` | Extract a specific code symbol with signature, body, and relations. Supports `compact` mode |
|
|
272
|
+
| `project_map` | Full project architecture: modules, dependencies, entry points, hot functions. `include_centrality` adds architectural chokepoints |
|
|
273
|
+
| `semantic_code_search` | Hybrid BM25 + vector search (RRF) for AST nodes. Supports `compact` mode |
|
|
274
|
+
| `get_call_graph` | Trace upstream/downstream call chains for a function. Pass `route_path='GET /api/x'` to trace an HTTP route → handler → downstream instead |
|
|
275
|
+
| `get_ast_node` | One symbol with signature, body and relations. `include_impact` adds the blast radius, `include_similar` embedding-similar nodes, `include_references` callers/callees. Supports `compact` mode |
|
|
276
|
+
| `module_overview` | Symbols in a directory or file, grouped by type and caller count. `include_dead` lists unreferenced symbols under the path; `include_deps` adds the file dependency graph (single-file paths only) |
|
|
277
277
|
| `ast_search` | Search AST nodes by text and/or structural filters (type, return type, params) |
|
|
278
|
-
| `find_references` | Find all references to a symbol (callers, importers, inheritors). Supports `compact` mode |
|
|
279
|
-
|
|
278
|
+
| `find_references` | Find all references to a symbol (callers, importers, inheritors, implementors, value/type references). Supports `compact` mode |
|
|
279
|
+
|
|
280
|
+
**Hidden aliases.** These names are not in `tools/list` but still dispatch via `tools/call`, so existing clients keep working: `trace_http_chain` / `find_http_route` (→ `get_call_graph` with `route_path`), `read_snippet` (→ `get_ast_node`), `dependency_graph`, `find_similar_code`, `find_dead_code`, plus the management tools `start_watch`, `stop_watch`, `get_index_status` and `rebuild_index`. `impact_analysis` is **removed** — calling it returns `Unknown tool`; use `get_ast_node` with `include_impact=true`, or the CLI's `impact --json` for the full report.
|
|
280
281
|
|
|
281
282
|
## CLI Commands
|
|
282
283
|
|
|
@@ -287,15 +288,15 @@ All tools are also available as CLI subcommands for shell scripts, hooks, and te
|
|
|
287
288
|
| `search <query>` | `semantic_code_search` | FTS5 search by concept |
|
|
288
289
|
| `ast-search [query]` | `ast_search` | Structural search with `--type`/`--returns`/`--params` filters |
|
|
289
290
|
| `callgraph <symbol>` | `get_call_graph` | Show call graph (callers/callees) |
|
|
290
|
-
| `impact <symbol>` | `
|
|
291
|
+
| `impact <symbol>` | `get_ast_node` (`include_impact=true`) | Impact analysis (callers, routes, risk level) |
|
|
291
292
|
| `show <symbol>` | `get_ast_node` | Show symbol details (code, type, signature) |
|
|
292
293
|
| `map` | `project_map` | Project architecture map |
|
|
293
294
|
| `overview <path>` | `module_overview` | Module symbols grouped by file and type |
|
|
294
|
-
| `deps <file>` | `
|
|
295
|
-
| `trace <route>` | `
|
|
296
|
-
| `similar <symbol>` | `
|
|
295
|
+
| `deps <file>` | `module_overview` (`include_deps=true`) | File-level dependency graph |
|
|
296
|
+
| `trace <route>` | `get_call_graph` (`route_path=…`) | Trace HTTP route → handler → downstream calls |
|
|
297
|
+
| `similar <symbol>` | `get_ast_node` (`include_similar=true`) | Find semantically similar code (requires embeddings) |
|
|
297
298
|
| `refs <symbol>` | `find_references` | Find all references to a symbol |
|
|
298
|
-
| `dead-code [path]` | `
|
|
299
|
+
| `dead-code [path]` | `module_overview` (`include_dead=true`) | Find unused code (orphans and exported-unused) |
|
|
299
300
|
| `grep <pattern>` | — | AST-context grep (ripgrep + containing function/class) |
|
|
300
301
|
| `incremental-index` | — | Run incremental index update (auto-creates DB if needed) |
|
|
301
302
|
| `health-check` | `get_index_status` | Query index status and freshness |
|
|
@@ -321,20 +322,20 @@ Available when installed as a Claude Code plugin:
|
|
|
321
322
|
|
|
322
323
|
| Language | Extensions | Relations Extracted |
|
|
323
324
|
|----------|-----------|-------------------|
|
|
324
|
-
| TypeScript | .ts, .tsx | calls, imports, exports, inherits, implements, routes_to |
|
|
325
|
-
| JavaScript | .js, .jsx, .mjs, .cjs | calls, imports, exports, inherits, routes_to |
|
|
326
|
-
| Go | .go | calls, imports, inherits, routes_to |
|
|
327
|
-
| Python | .py, .pyi | calls, imports, inherits, routes_to |
|
|
328
|
-
| Rust | .rs | calls, imports,
|
|
329
|
-
| Java | .java | calls, imports, inherits, implements |
|
|
325
|
+
| TypeScript | .ts, .tsx | calls, imports, exports, inherits, implements, routes_to, references |
|
|
326
|
+
| JavaScript | .js, .jsx, .mjs, .cjs | calls, imports, exports (ESM + CommonJS), inherits, routes_to, references |
|
|
327
|
+
| Go | .go | calls, imports, inherits, routes_to, references |
|
|
328
|
+
| Python | .py, .pyi | calls, imports, inherits, routes_to, references |
|
|
329
|
+
| Rust | .rs | calls, imports, implements, routes_to (axum), references |
|
|
330
|
+
| Java | .java | calls, imports, inherits, implements, references |
|
|
330
331
|
| C# | .cs | calls, imports, inherits, implements |
|
|
331
332
|
| Kotlin | .kt, .kts | calls, imports, inherits |
|
|
332
333
|
| Ruby | .rb | calls, imports, inherits |
|
|
333
334
|
| PHP | .php | calls, imports, inherits, implements |
|
|
334
335
|
| Swift | .swift | calls, imports, inherits |
|
|
335
336
|
| Dart | .dart | calls, imports, inherits, implements |
|
|
336
|
-
| C | .c, .h | calls, imports |
|
|
337
|
-
| C++ | .cpp, .cc, .cxx, .hpp | calls, imports, inherits |
|
|
337
|
+
| C | .c, .h | calls, imports, references |
|
|
338
|
+
| C++ | .cpp, .cc, .cxx, .hpp, .hh, .hxx | calls, imports, inherits, references |
|
|
338
339
|
| Bash | .sh, .bash | functions, commands, `source`/`.` imports |
|
|
339
340
|
| Markdown | .md, .mdx, .markdown | headings |
|
|
340
341
|
| HTML | .html, .htm | file-FTS only (no AST symbols) |
|
|
@@ -342,6 +343,7 @@ Available when installed as a Claude Code plugin:
|
|
|
342
343
|
| JSON | .json | file-FTS only (no AST symbols) |
|
|
343
344
|
|
|
344
345
|
**Known limitations:**
|
|
346
|
+
- **Rust has no `inherits` edges** — the language has no class inheritance, so `impl Trait for Type` is recorded as `implements` and an `inherits`-filtered query returns empty for Rust.
|
|
345
347
|
- **Kotlin/Swift interface conformance** is recorded as `inherits` (both use a single `: Type` grammar for base classes and protocols/interfaces), so `implements`-filtered queries return empty for these two languages.
|
|
346
348
|
- **Cross-file dead-code detection** may false-positive a type whose only cross-file reference sits beyond the 4096-byte stored-content cap per node (documented accepted limitation, v0.97.1).
|
|
347
349
|
|
package/bin/cli.js
CHANGED
|
@@ -131,9 +131,14 @@ if (!binary) {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
// Spawn the binary, forwarding stdio for MCP JSON-RPC communication
|
|
134
|
+
// windowsHide as a literal rather than via claude-plugin/scripts/proc-opts:
|
|
135
|
+
// this file is the npm package's bin entry and ships in tarballs that may not
|
|
136
|
+
// carry claude-plugin/. Same meaning — CREATE_NO_WINDOW, so a console-less
|
|
137
|
+
// parent (the MCP client) does not flash a window; inherited stdio still works.
|
|
134
138
|
const child = spawn(binary, process.argv.slice(2), {
|
|
135
139
|
stdio: "inherit",
|
|
136
140
|
env: process.env,
|
|
141
|
+
windowsHide: true,
|
|
137
142
|
});
|
|
138
143
|
|
|
139
144
|
child.on("error", (err) => {
|
|
@@ -945,6 +945,17 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
945
945
|
else if (!sameTarget) nextSuspendedAt = null;
|
|
946
946
|
else nextSuspendedAt = state.suspendedAt || null;
|
|
947
947
|
const newState = {
|
|
948
|
+
// Carry the prior state forward. Every OTHER saveState in this function
|
|
949
|
+
// spreads `...state`; this one rebuilt from scratch, so any key it does
|
|
950
|
+
// not name was dropped on every update check that found a new release.
|
|
951
|
+
// The keys that matter are selfHealGlobalPkgs' — globalPkgHealAttempts /
|
|
952
|
+
// globalPkgHealVersion — because that function returns `{}` once the
|
|
953
|
+
// attempt cap is hit, meaning the spread below contributes nothing and
|
|
954
|
+
// the counter reset to zero. A capped-out global heal therefore got a
|
|
955
|
+
// fresh budget on every release, which is precisely the retry treadmill
|
|
956
|
+
// the cap exists to stop. Keys this object sets are overwritten below;
|
|
957
|
+
// nothing stale leaks through.
|
|
958
|
+
...state,
|
|
948
959
|
lastCheck: new Date().toISOString(),
|
|
949
960
|
installedVersion: success ? latest.version : installedVersion,
|
|
950
961
|
latestVersion: latest.version,
|
|
@@ -993,6 +1004,16 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
993
1004
|
lastCheck: new Date().toISOString(),
|
|
994
1005
|
latestVersion: latest.version,
|
|
995
1006
|
updateAvailable: false,
|
|
1007
|
+
// Reaching here means the installed shell IS the latest release, so any
|
|
1008
|
+
// failure record describes an update that is no longer pending. Leaving it
|
|
1009
|
+
// set kept `doctor` warning "vX failed to install 5× — auto-retry
|
|
1010
|
+
// throttled" about a version already installed, and left a suspension
|
|
1011
|
+
// stamp that the next release then had to age out. The common way to get
|
|
1012
|
+
// here from a suspended state is the manual route the suspension notice
|
|
1013
|
+
// itself recommends (`npm install -g` / `/plugin update`) — the updater
|
|
1014
|
+
// has to notice that its advice was taken.
|
|
1015
|
+
updateAttempts: 0,
|
|
1016
|
+
suspendedAt: null,
|
|
996
1017
|
rateLimited: false,
|
|
997
1018
|
binaryUpdated: selfHealedBinary || state.binaryUpdated,
|
|
998
1019
|
...globalHeal,
|
|
@@ -482,9 +482,9 @@ function relicRepairGuard({ log = console.log, relic = undefined } = {}) {
|
|
|
482
482
|
function detectEmbedModel(binary, run = execFileSync) {
|
|
483
483
|
if (!binary) return null;
|
|
484
484
|
try {
|
|
485
|
-
const out = run(binary, ['health-check', '--json'], {
|
|
485
|
+
const out = run(binary, ['health-check', '--json'], hidden({
|
|
486
486
|
timeout: 10000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
487
|
-
});
|
|
487
|
+
}));
|
|
488
488
|
return JSON.parse(out).model_available === true;
|
|
489
489
|
} catch { return null; }
|
|
490
490
|
}
|
|
@@ -114,7 +114,13 @@ function readJsonResult(filePath) {
|
|
|
114
114
|
|
|
115
115
|
// Lenient reader kept exactly as-is for its 20+ callers (manifests, registries,
|
|
116
116
|
// plugin.json …) where "absent" and "unreadable" genuinely mean the same thing.
|
|
117
|
-
//
|
|
117
|
+
// A caller that will WRITE settings.json back must use readSettingsForWrite()
|
|
118
|
+
// + tryWriteSettings() instead — the pair that detects the lossy/corrupt cases
|
|
119
|
+
// and preserves the original bytes. Reading settings.json with THIS function is
|
|
120
|
+
// fine as long as nothing is written back (isPluginInactive, syncLifecycleConfig's
|
|
121
|
+
// self-heal probe); the destructive combination is `readJson(settingsPath())`
|
|
122
|
+
// followed by a write, which is how cleanupDisabledStatusline and uninstall
|
|
123
|
+
// destroyed non-UTF-8 bytes for four releases after the detector landed.
|
|
118
124
|
function readJson(filePath) {
|
|
119
125
|
try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
|
|
120
126
|
}
|
|
@@ -158,9 +164,18 @@ function backupCorruptFile(filePath, raw) {
|
|
|
158
164
|
// printing "Hooks ✅ 1 issue(s) auto-repaired" for a run that moved the user's
|
|
159
165
|
// model / env / permissions into a `.corrupt-*` file it never mentioned. A
|
|
160
166
|
// destructive repair has to be reported as one, with the path to get it back.
|
|
161
|
-
|
|
167
|
+
//
|
|
168
|
+
// `pre` lets a caller that ALREADY probed the file with readJsonResult reuse
|
|
169
|
+
// that result instead of reading twice. It matters for more than speed: the
|
|
170
|
+
// backup this function may take is a side effect, and a caller which only
|
|
171
|
+
// writes conditionally (cleanupDisabledStatusline runs on every statusline
|
|
172
|
+
// render) must be able to probe first and pay the backup only on the write
|
|
173
|
+
// path — otherwise a lossy settings.json accumulates one `.corrupt-*` copy per
|
|
174
|
+
// prompt. Passing `pre` also keeps the returned `settings` object IDENTICAL to
|
|
175
|
+
// the one the caller already mutated.
|
|
176
|
+
function readSettingsForWrite(pre) {
|
|
162
177
|
const p = settingsPath();
|
|
163
|
-
const res = readJsonResult(p);
|
|
178
|
+
const res = pre || readJsonResult(p);
|
|
164
179
|
if (res.value && res.lossy) {
|
|
165
180
|
// Usable JSON whose bytes will not survive our rewrite (see readJsonResult).
|
|
166
181
|
// Preserve the true bytes, then proceed with the parsed value — refusing
|
|
@@ -413,7 +428,11 @@ function detachStatuslineIntegration(settings, { compositeDoomed = true } = {})
|
|
|
413
428
|
}
|
|
414
429
|
|
|
415
430
|
function cleanupDisabledStatusline() {
|
|
416
|
-
|
|
431
|
+
// Probe without side effects first — this runs on every statusline render and
|
|
432
|
+
// usually finds nothing to do. A corrupt/unreadable file yields no value and
|
|
433
|
+
// is left strictly alone (same outcome the old lenient readJson produced).
|
|
434
|
+
const probe = readJsonResult(settingsPath());
|
|
435
|
+
const settings = probe.value;
|
|
417
436
|
if (!settings || !isPluginInactive(settings)) {
|
|
418
437
|
return { cleaned: false, settingsChanged: false };
|
|
419
438
|
}
|
|
@@ -425,7 +444,15 @@ function cleanupDisabledStatusline() {
|
|
|
425
444
|
let settingsChanged = detachStatuslineIntegration(settings, { compositeDoomed: uninstalled });
|
|
426
445
|
if (removeHooksFromSettings(settings)) settingsChanged = true;
|
|
427
446
|
if (settingsChanged) {
|
|
428
|
-
|
|
447
|
+
// Now that a write is certain, take the guarded path: readSettingsForWrite
|
|
448
|
+
// preserves bytes JSON.stringify would destroy (the lossy-UTF8 case) and
|
|
449
|
+
// returns null when it could not, and tryWriteSettings turns a read-only
|
|
450
|
+
// ~/.claude into a diagnosed no-op instead of an uncaught EACCES — this
|
|
451
|
+
// function is called at the top of statusline.js, where a throw blanks the
|
|
452
|
+
// user's status line. `guarded` is the same object we just mutated.
|
|
453
|
+
const { settings: guarded } = readSettingsForWrite(probe);
|
|
454
|
+
if (!guarded) return { cleaned: false, settingsChanged: false };
|
|
455
|
+
if (tryWriteSettings(guarded)) settingsChanged = false;
|
|
429
456
|
}
|
|
430
457
|
|
|
431
458
|
// Genuine uninstall (not a temporary disable): reclaim ~/.cache/code-graph
|
|
@@ -1071,7 +1098,13 @@ function defaultRunNpm(args) {
|
|
|
1071
1098
|
}
|
|
1072
1099
|
|
|
1073
1100
|
function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRunNpm, scanGlobalPkgs = installedGlobalPkgs } = {}) {
|
|
1074
|
-
|
|
1101
|
+
// Same guarded pair as install()/update(): probe without side effects, and
|
|
1102
|
+
// only take the backup + write path once there is something to write. Reading
|
|
1103
|
+
// this file with the lenient readJson and writing it back raw is what
|
|
1104
|
+
// destroyed non-UTF-8 bytes here — a teardown has even less license to lose
|
|
1105
|
+
// the user's model / env / permissions than an install does.
|
|
1106
|
+
const probe = readJsonResult(settingsPath());
|
|
1107
|
+
const settings = probe.value;
|
|
1075
1108
|
let settingsChanged = false;
|
|
1076
1109
|
|
|
1077
1110
|
if (settings) {
|
|
@@ -1097,7 +1130,8 @@ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRu
|
|
|
1097
1130
|
|
|
1098
1131
|
// 4. Write settings if changed
|
|
1099
1132
|
if (settingsChanged) {
|
|
1100
|
-
|
|
1133
|
+
const { settings: guarded } = readSettingsForWrite(probe);
|
|
1134
|
+
if (!guarded || tryWriteSettings(guarded)) settingsChanged = false;
|
|
1101
1135
|
}
|
|
1102
1136
|
}
|
|
1103
1137
|
|
|
@@ -1155,6 +1189,14 @@ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRu
|
|
|
1155
1189
|
// 6. Remove cache directory
|
|
1156
1190
|
try { fs.rmSync(CACHE_DIR, { recursive: true, force: true }); } catch { /* ok */ }
|
|
1157
1191
|
|
|
1192
|
+
// 6.5. The shared tmp dir (cooldown flags, read-fanout state, interrupted
|
|
1193
|
+
// `update-*` staging). Nothing in it outlives an uninstall, and the periodic
|
|
1194
|
+
// prune that keeps it bounded stops running the moment the hooks are gone —
|
|
1195
|
+
// so without this it is residue with no remaining owner.
|
|
1196
|
+
try {
|
|
1197
|
+
fs.rmSync(require('./tmp-dir').CG_TMP_DIR, { recursive: true, force: true });
|
|
1198
|
+
} catch { /* ok */ }
|
|
1199
|
+
|
|
1158
1200
|
// 7. Remove plugin files from cache (all known paths, including parent dirs)
|
|
1159
1201
|
const cacheRoot = pluginsCacheDir();
|
|
1160
1202
|
const pluginCacheDirs = [
|
|
@@ -1354,11 +1396,22 @@ function scanForBrokenPaths() {
|
|
|
1354
1396
|
const settings = settingsRead.value || {};
|
|
1355
1397
|
const issues = [];
|
|
1356
1398
|
|
|
1399
|
+
// Every path extraction below goes through hookCmdScript, the same parser
|
|
1400
|
+
// compositeSlotIsStale and surveyHookCoverage use. These three sites each had
|
|
1401
|
+
// their own inline `/node\s+"([^"]+)"/`, which requires the literal word
|
|
1402
|
+
// `node` followed by whitespace — so a command naming the interpreter by
|
|
1403
|
+
// absolute path, the spelling a Windows install produces
|
|
1404
|
+
// (`"C:\Program Files\nodejs\node.exe" "C:\…\script.js"`), matched nothing.
|
|
1405
|
+
// A path this scan cannot READ is reported as no path at all, i.e. as healthy,
|
|
1406
|
+
// which is the failure mode the whole function exists to prevent. Six sites,
|
|
1407
|
+
// two spellings, one of them blind: collapse to one.
|
|
1408
|
+
const scriptOf = (cmd) => hookCmdScript(cmd);
|
|
1409
|
+
|
|
1357
1410
|
// Check statusLine path
|
|
1358
1411
|
if (isOurComposite(settings)) {
|
|
1359
|
-
const
|
|
1360
|
-
if (
|
|
1361
|
-
issues.push({ type: 'statusLine', path:
|
|
1412
|
+
const script = scriptOf(settings.statusLine.command);
|
|
1413
|
+
if (script && !fs.existsSync(script)) {
|
|
1414
|
+
issues.push({ type: 'statusLine', path: script });
|
|
1362
1415
|
}
|
|
1363
1416
|
}
|
|
1364
1417
|
|
|
@@ -1369,9 +1422,9 @@ function scanForBrokenPaths() {
|
|
|
1369
1422
|
for (const entry of entries) {
|
|
1370
1423
|
if (!isOurHookEntry(entry) || !entry.hooks) continue;
|
|
1371
1424
|
for (const h of entry.hooks) {
|
|
1372
|
-
const
|
|
1373
|
-
if (
|
|
1374
|
-
issues.push({ type: 'hook', event, path:
|
|
1425
|
+
const script = h.command && scriptOf(h.command);
|
|
1426
|
+
if (script && !fs.existsSync(script)) {
|
|
1427
|
+
issues.push({ type: 'hook', event, path: script });
|
|
1375
1428
|
}
|
|
1376
1429
|
}
|
|
1377
1430
|
}
|
|
@@ -1382,9 +1435,9 @@ function scanForBrokenPaths() {
|
|
|
1382
1435
|
const registry = readRegistry();
|
|
1383
1436
|
for (const provider of registry) {
|
|
1384
1437
|
if (provider.id === '_previous') continue;
|
|
1385
|
-
const
|
|
1386
|
-
if (
|
|
1387
|
-
issues.push({ type: 'registry', id: provider.id, path:
|
|
1438
|
+
const script = provider.command && scriptOf(provider.command);
|
|
1439
|
+
if (script && !fs.existsSync(script)) {
|
|
1440
|
+
issues.push({ type: 'registry', id: provider.id, path: script });
|
|
1388
1441
|
}
|
|
1389
1442
|
}
|
|
1390
1443
|
|
|
@@ -1480,6 +1533,7 @@ module.exports = {
|
|
|
1480
1533
|
removeHooksFromSettings, isOurHookEntry,
|
|
1481
1534
|
registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
|
|
1482
1535
|
surveyHookCoverage, compositeCommand, compositeSlotIsStale, // v0.49.1 — version-aware self-heal
|
|
1536
|
+
hookCmdScript, // the ONE hook-command path parser (session-init reuses it)
|
|
1483
1537
|
cacheDirVersion, // exported for the separator-agnostic test
|
|
1484
1538
|
|
|
1485
1539
|
verifyHooksFire, defaultHookFireProbes, // v0.67.0 — firing self-test
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
const fs = require('fs');
|
|
24
24
|
const path = require('path');
|
|
25
25
|
const crypto = require('crypto');
|
|
26
|
-
const { cgTmpDir } = require('./tmp-dir');
|
|
26
|
+
const { cgTmpDir, cwdHash } = require('./tmp-dir');
|
|
27
27
|
const { recordRecommendation } = require('./recommendation-log');
|
|
28
28
|
const { runGrepAnswer, runShowAnswer, runCallgraphAnswer, sanitizeSearchPath } = require('./cg-answer');
|
|
29
29
|
const { emitPostToolContext } = require('./hook-emit');
|
|
@@ -217,18 +217,20 @@ function commandHash(cmd) {
|
|
|
217
217
|
return crypto.createHash('sha1').update(String(cmd)).digest('hex').slice(0, 12);
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
// Project-scoped for the same reason as pre-grep-guide's (see cwdHash in
|
|
221
|
+
// tmp-dir.js): one shared tmp dir means an un-scoped flag is machine-global.
|
|
222
|
+
function flagPath(cmd, cwd = process.cwd()) {
|
|
223
|
+
return path.join(cgTmpDir(), `.code-graph-postinject-${cwdHash(cwd)}-${commandHash(cmd)}`);
|
|
222
224
|
}
|
|
223
225
|
|
|
224
|
-
function isOnCooldown(cmd, now = Date.now(), windowMs = 60000) {
|
|
226
|
+
function isOnCooldown(cmd, now = Date.now(), windowMs = 60000, cwd = process.cwd()) {
|
|
225
227
|
try {
|
|
226
|
-
return now - fs.statSync(flagPath(cmd)).mtimeMs < windowMs;
|
|
228
|
+
return now - fs.statSync(flagPath(cmd, cwd)).mtimeMs < windowMs;
|
|
227
229
|
} catch { return false; }
|
|
228
230
|
}
|
|
229
231
|
|
|
230
|
-
function markCooldown(cmd) {
|
|
231
|
-
try { fs.writeFileSync(flagPath(cmd), ''); } catch { /* ok */ }
|
|
232
|
+
function markCooldown(cmd, cwd = process.cwd()) {
|
|
233
|
+
try { fs.writeFileSync(flagPath(cmd, cwd), ''); } catch { /* ok */ }
|
|
232
234
|
}
|
|
233
235
|
|
|
234
236
|
// --- Main execution ---
|
|
@@ -259,8 +261,8 @@ function runMain() {
|
|
|
259
261
|
const found = findFoldableGrepSegment(cmd);
|
|
260
262
|
if (!found) return;
|
|
261
263
|
|
|
262
|
-
if (isOnCooldown(rawCmd)) return;
|
|
263
|
-
markCooldown(rawCmd);
|
|
264
|
+
if (isOnCooldown(rawCmd, Date.now(), 60000, root)) return;
|
|
265
|
+
markCooldown(rawCmd, root);
|
|
264
266
|
|
|
265
267
|
const { segment, block } = found;
|
|
266
268
|
// Run the answer exactly like the deny path.
|
|
@@ -10,7 +10,7 @@ const { execFileSync } = require('child_process');
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { findBinary } = require('./find-binary');
|
|
13
|
-
const { cgTmpDir } = require('./tmp-dir');
|
|
13
|
+
const { cgTmpDir, cwdHash } = require('./tmp-dir');
|
|
14
14
|
const { resolveProjectRoot } = require('./project-root');
|
|
15
15
|
const { recordRecommendation } = require('./recommendation-log');
|
|
16
16
|
const { formatCoveringTests } = require('./covering-tests');
|
|
@@ -118,7 +118,11 @@ function isCommonKeyword(s) {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
// --- Per-symbol cooldown: 2 minutes ---
|
|
121
|
-
|
|
121
|
+
// Project-scoped (see cwdHash in tmp-dir.js). A symbol name is the LEAST
|
|
122
|
+
// project-unique key there is — `main`, `run`, `new`, `parse` collide across
|
|
123
|
+
// every repo on the machine, so editing `parse` in one project suppressed the
|
|
124
|
+
// impact push for a completely different `parse` in another for two minutes.
|
|
125
|
+
const cooldownFile = path.join(cgTmpDir(), `.cg-impact-${cwdHash(cwd)}-${symbol}`);
|
|
122
126
|
try {
|
|
123
127
|
if (Date.now() - fs.statSync(cooldownFile).mtimeMs < 120000) process.exit(0);
|
|
124
128
|
} catch { /* first time for this symbol */ }
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
const fs = require('fs');
|
|
40
40
|
const path = require('path');
|
|
41
41
|
const crypto = require('crypto');
|
|
42
|
-
const { cgTmpDir } = require('./tmp-dir');
|
|
42
|
+
const { cgTmpDir, cwdHash } = require('./tmp-dir');
|
|
43
43
|
const { recordRecommendation } = require('./recommendation-log');
|
|
44
44
|
const { runGrepAnswer, runShowAnswer, sanitizeSearchPath } = require('./cg-answer');
|
|
45
45
|
|
|
@@ -513,18 +513,20 @@ function commandHash(cmd) {
|
|
|
513
513
|
return crypto.createHash('sha1').update(cmd).digest('hex').slice(0, 12);
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
-
|
|
517
|
-
|
|
516
|
+
// Project-scoped: the same `grep -rn "foo" src/` in two repos is two different
|
|
517
|
+
// questions and must not share one 60s cooldown (see cwdHash in tmp-dir.js).
|
|
518
|
+
function flagPath(cmd, cwd = process.cwd()) {
|
|
519
|
+
return path.join(cgTmpDir(), `.code-graph-bash-${cwdHash(cwd)}-${commandHash(cmd)}`);
|
|
518
520
|
}
|
|
519
521
|
|
|
520
|
-
function isOnCooldown(cmd, now = Date.now(), windowMs = 60000) {
|
|
522
|
+
function isOnCooldown(cmd, now = Date.now(), windowMs = 60000, cwd = process.cwd()) {
|
|
521
523
|
try {
|
|
522
|
-
return now - fs.statSync(flagPath(cmd)).mtimeMs < windowMs;
|
|
524
|
+
return now - fs.statSync(flagPath(cmd, cwd)).mtimeMs < windowMs;
|
|
523
525
|
} catch { return false; }
|
|
524
526
|
}
|
|
525
527
|
|
|
526
|
-
function markCooldown(cmd) {
|
|
527
|
-
try { fs.writeFileSync(flagPath(cmd), ''); } catch { /* ok */ }
|
|
528
|
+
function markCooldown(cmd, cwd = process.cwd()) {
|
|
529
|
+
try { fs.writeFileSync(flagPath(cmd, cwd), ''); } catch { /* ok */ }
|
|
528
530
|
}
|
|
529
531
|
|
|
530
532
|
function buildHint() {
|
|
@@ -720,7 +722,7 @@ function runMain() {
|
|
|
720
722
|
return;
|
|
721
723
|
}
|
|
722
724
|
|
|
723
|
-
if (isOnCooldown(rawCmd)) {
|
|
725
|
+
if (isOnCooldown(rawCmd, Date.now(), 60000, root)) {
|
|
724
726
|
// Outcome proxy: a source grep re-issued within the cooldown window runs
|
|
725
727
|
// silently (no deny/hint). Record it so `stats` sees the model's grep
|
|
726
728
|
// fan-out — especially a re-grep right after cg answered the same query.
|
|
@@ -728,7 +730,7 @@ function runMain() {
|
|
|
728
730
|
return;
|
|
729
731
|
}
|
|
730
732
|
|
|
731
|
-
markCooldown(rawCmd);
|
|
733
|
+
markCooldown(rawCmd, root);
|
|
732
734
|
|
|
733
735
|
const block = isBlockDisabled() ? null : classifyBlock(cmd);
|
|
734
736
|
if (block) {
|
|
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const {
|
|
7
7
|
install, update, readManifest, getPluginVersion, checkScopeConflict,
|
|
8
8
|
cleanupDisabledStatusline, isPluginInactive, isPluginUninstalled, removeCacheResidue,
|
|
9
|
-
readJson, CACHE_DIR, settingsPath, isStaleRelicContext,
|
|
9
|
+
readJson, CACHE_DIR, settingsPath, isStaleRelicContext, hookCmdScript,
|
|
10
10
|
} = require('./lifecycle');
|
|
11
11
|
const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
|
|
12
12
|
const { maybeAutoAdopt, isAdopted, unadopt } = require('./adopt');
|
|
@@ -251,9 +251,13 @@ function syncLifecycleConfig() {
|
|
|
251
251
|
installReporting();
|
|
252
252
|
return 'self-healed';
|
|
253
253
|
}
|
|
254
|
-
// Also self-heal if composite path points to a non-existent script (path
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
// Also self-heal if composite path points to a non-existent script (path
|
|
255
|
+
// pollution). hookCmdScript, not another inline `/node\s+"…"/`: that spelling
|
|
256
|
+
// cannot read a command whose interpreter is an absolute path
|
|
257
|
+
// (`"C:\Program Files\nodejs\node.exe" "…\statusline-composite.js"`), and an
|
|
258
|
+
// unreadable command silently reads as a healthy one.
|
|
259
|
+
const compositeScript = hookCmdScript(settings.statusLine.command);
|
|
260
|
+
if (compositeScript && !fs.existsSync(compositeScript)) {
|
|
257
261
|
installReporting();
|
|
258
262
|
return 'self-healed-bad-path';
|
|
259
263
|
}
|
|
@@ -282,8 +286,8 @@ function syncLifecycleConfig() {
|
|
|
282
286
|
for (const entry of entries) {
|
|
283
287
|
if (!entry.hooks) continue;
|
|
284
288
|
for (const h of entry.hooks) {
|
|
285
|
-
const
|
|
286
|
-
if (
|
|
289
|
+
const script = h.command && hookCmdScript(h.command);
|
|
290
|
+
if (script && script.includes('code-graph') && !fs.existsSync(script)) {
|
|
287
291
|
installReporting();
|
|
288
292
|
return 'self-healed-bad-hook';
|
|
289
293
|
}
|
|
@@ -509,13 +513,22 @@ function consistencyCheck(binary) {
|
|
|
509
513
|
}
|
|
510
514
|
|
|
511
515
|
function runSessionInit({ source } = {}) {
|
|
516
|
+
// GC the shared tmp dir before anything else, so it happens even on the
|
|
517
|
+
// inactive / non-project early returns below — those sessions still wrote
|
|
518
|
+
// cooldown flags on the way in. Cheap (one readdir + a stat per entry) and
|
|
519
|
+
// fully swallowed: reclaiming disk must never be able to fail a SessionStart.
|
|
520
|
+
try { require('./tmp-dir').pruneCgTmp(); } catch { /* best-effort GC */ }
|
|
521
|
+
|
|
512
522
|
if (isPluginInactive()) {
|
|
513
523
|
// Capture the uninstalled-vs-disabled verdict BEFORE cleanupDisabledStatusline()
|
|
514
524
|
// runs — it removes our composite + registry entry, which are the very signals
|
|
515
525
|
// isPluginUninstalled()/isPluginInactive() read, so calling it afterwards would
|
|
516
526
|
// always see "no composite/registry" and report not-uninstalled (teardown skipped).
|
|
517
527
|
const uninstalled = isPluginUninstalled();
|
|
518
|
-
|
|
528
|
+
// Third caller of the same unguarded teardown (statusline.js and
|
|
529
|
+
// statusline-composite.js are the others): a read-only ~/.claude turns this
|
|
530
|
+
// into an uncaught throw that takes down the whole SessionStart hook.
|
|
531
|
+
try { cleanupDisabledStatusline(); } catch { /* best-effort teardown */ }
|
|
519
532
|
// Genuine uninstall (not a temporary disable) leaves residue the settings-only
|
|
520
533
|
// self-heal can't reach: ~/.cache/code-graph (the ~40MB binary + state) and the
|
|
521
534
|
// current project's CLAUDE.md adoption block. CC fires no uninstall hook, AND it
|
|
@@ -16,7 +16,14 @@ const cleanupDisabledStatusline = lifecycle.cleanupDisabledStatusline || (() =>
|
|
|
16
16
|
const SEPARATOR = ' \x1b[2m|\x1b[0m ';
|
|
17
17
|
|
|
18
18
|
function main() {
|
|
19
|
-
|
|
19
|
+
// Same reasoning as statusline.js: the teardown writes settings.json and the
|
|
20
|
+
// registry, both of which throw on a read-only config dir, and this is THE
|
|
21
|
+
// command Claude Code runs for the status line — an uncaught throw here blanks
|
|
22
|
+
// every provider's segment, not just ours.
|
|
23
|
+
let disabledCleanup = { cleaned: false };
|
|
24
|
+
try {
|
|
25
|
+
disabledCleanup = cleanupDisabledStatusline();
|
|
26
|
+
} catch { /* teardown is optional; rendering is not */ }
|
|
20
27
|
if (disabledCleanup.cleaned) process.exit(0);
|
|
21
28
|
|
|
22
29
|
// Collect stdin (Claude Code pipes JSON context)
|
|
@@ -43,7 +43,17 @@ function updateStuck(st = readUpdateState()) {
|
|
|
43
43
|
return !!(st && st.updateAvailable && (st.updateAttempts || 0) >= STUCK_UPDATE_ATTEMPTS);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// The teardown is best-effort housekeeping, never a precondition for rendering.
|
|
47
|
+
// It writes ~/.claude/settings.json and ~/.cache/code-graph/statusline-registry.json,
|
|
48
|
+
// and on a read-only config dir (EROFS container mount, a `sudo` that left root
|
|
49
|
+
// ownership behind, restrictive umask) those writes throw — from module scope,
|
|
50
|
+
// where nothing catches them. The user's whole status line then goes blank plus a
|
|
51
|
+
// node stack trace, for a cleanup they never asked for. Swallow it: the next run
|
|
52
|
+
// with a writable dir does the work.
|
|
53
|
+
let disabledCleanup = { cleaned: false };
|
|
54
|
+
try {
|
|
55
|
+
disabledCleanup = cleanupDisabledStatusline();
|
|
56
|
+
} catch { /* teardown is optional; rendering is not */ }
|
|
47
57
|
if (disabledCleanup.cleaned) process.exit(0);
|
|
48
58
|
|
|
49
59
|
// Only show status in projects that have a code-graph directory. The statusLine
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
//
|
|
19
19
|
// Fix: pin all hook + auto-update artifacts to a `code-graph-mcp/` subdir of
|
|
20
20
|
// whatever `os.tmpdir()` resolves to. Contained, deterministic, easy to GC.
|
|
21
|
+
const crypto = require('crypto');
|
|
21
22
|
const fs = require('fs');
|
|
22
23
|
const os = require('os');
|
|
23
24
|
const path = require('path');
|
|
@@ -29,4 +30,60 @@ function cgTmpDir() {
|
|
|
29
30
|
return CG_TMP_DIR;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
// Short, filename-safe digest of a project root. Cooldown flags live in ONE
|
|
34
|
+
// shared tmp dir for the whole machine, so a flag named only after its subject
|
|
35
|
+
// (a command, a symbol, a context type) is global: an `impact` push in project A
|
|
36
|
+
// silenced the identical prompt in project B for the next 30s, and a grep
|
|
37
|
+
// cooldown followed the developer across every repo they had open. Folding the
|
|
38
|
+
// cwd in makes the flag mean what it always claimed to — "recently done HERE".
|
|
39
|
+
// pre-read-guide.js already did this; the other four hooks did not.
|
|
40
|
+
function cwdHash(cwd) {
|
|
41
|
+
return crypto.createHash('sha1').update(String(cwd)).digest('hex').slice(0, 12);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Nothing ever deleted what cgTmpDir() collects. Every cooldown flag, read-fanout
|
|
45
|
+
// state file and interrupted `update-*` download stayed forever: measured 281
|
|
46
|
+
// entries on a working dev box, 232 of them older than a day. They are 0-byte
|
|
47
|
+
// flags, so the cost is inode churn and a directory that makes `ls` in the shared
|
|
48
|
+
// tmp useless — but the `update-*` dirs hold a partly-extracted release tarball,
|
|
49
|
+
// which is megabytes each.
|
|
50
|
+
//
|
|
51
|
+
// The window has to exceed the longest cooldown that reads these files, or the
|
|
52
|
+
// prune would silently shorten it. The longest is user-prompt-context's
|
|
53
|
+
// `overview` at 5 minutes, so 24h is ~288× the margin — this is garbage
|
|
54
|
+
// collection, not expiry.
|
|
55
|
+
const PRUNE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Delete cgTmpDir() entries older than `maxAgeMs`. Best-effort and total: any
|
|
59
|
+
* failure (missing dir, a file another process just removed, a permission
|
|
60
|
+
* problem) is skipped rather than raised — this runs from a SessionStart hook,
|
|
61
|
+
* where a throw is a visible failure and unreclaimed disk is not.
|
|
62
|
+
* @returns {number} entries removed
|
|
63
|
+
*/
|
|
64
|
+
function pruneCgTmp({ now = Date.now(), maxAgeMs = PRUNE_MAX_AGE_MS, dir = CG_TMP_DIR } = {}) {
|
|
65
|
+
// The recursive delete below is the reason for this check. `dir` is a
|
|
66
|
+
// parameter only so tests can point it at a sandbox, and a future caller
|
|
67
|
+
// passing the wrong path must not be able to turn this into `rm -rf` on
|
|
68
|
+
// something that isn't ours.
|
|
69
|
+
if (path.basename(dir) !== 'code-graph-mcp') return 0;
|
|
70
|
+
let removed = 0;
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
74
|
+
} catch { return 0; }
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const target = path.join(dir, entry.name);
|
|
77
|
+
try {
|
|
78
|
+
if (now - fs.statSync(target).mtimeMs <= maxAgeMs) continue;
|
|
79
|
+
// `update-*` staging dirs from an interrupted auto-update are the only
|
|
80
|
+
// subdirectories here, and they are the ones that actually hold bytes.
|
|
81
|
+
if (entry.isDirectory()) fs.rmSync(target, { recursive: true, force: true });
|
|
82
|
+
else fs.unlinkSync(target);
|
|
83
|
+
removed++;
|
|
84
|
+
} catch { /* raced, unreadable, or already gone — the next run retries */ }
|
|
85
|
+
}
|
|
86
|
+
return removed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { cgTmpDir, CG_TMP_DIR, cwdHash, pruneCgTmp, PRUNE_MAX_AGE_MS };
|
|
@@ -12,7 +12,7 @@ const os = require('os');
|
|
|
12
12
|
// ~/.claude/tmp/, so bare-tmp flags interleave with transcript captures —
|
|
13
13
|
// diagnostic blindness + the §8 recursive-grep footgun (see tmp-dir.js). The
|
|
14
14
|
// other hook scripts already route through here; this one was the lone holdout.
|
|
15
|
-
const { cgTmpDir } = require('./tmp-dir');
|
|
15
|
+
const { cgTmpDir, cwdHash } = require('./tmp-dir');
|
|
16
16
|
const { hidden } = require('./proc-opts');
|
|
17
17
|
|
|
18
18
|
// Mid-session install detection: hook fires but no manifest yet.
|
|
@@ -27,17 +27,24 @@ const COOLDOWNS = {
|
|
|
27
27
|
symptom: 10 * 60 * 1000, // 10min — Phase E meta-advisory hint, low value to repeat
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// Project-scoped (see cwdHash in tmp-dir.js). There are only five flag names
|
|
31
|
+
// here, so an un-scoped flag was effectively a machine-wide mute: one `impact`
|
|
32
|
+
// push in any repo silenced the next 30s of impact pushes in every other repo,
|
|
33
|
+
// and `overview` did it for five minutes.
|
|
34
|
+
function ctxFlagPath(type, cwd) {
|
|
35
|
+
return path.join(cgTmpDir(), `.code-graph-ctx-${cwdHash(cwd)}-${type}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isCoolingDown(type, cwd = process.cwd()) {
|
|
31
39
|
try {
|
|
32
|
-
const
|
|
33
|
-
const stat = fs.statSync(flag);
|
|
40
|
+
const stat = fs.statSync(ctxFlagPath(type, cwd));
|
|
34
41
|
return Date.now() - stat.mtimeMs < (COOLDOWNS[type] || 60000);
|
|
35
42
|
} catch { return false; }
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
function markCooldown(type) {
|
|
45
|
+
function markCooldown(type, cwd = process.cwd()) {
|
|
39
46
|
try {
|
|
40
|
-
fs.writeFileSync(
|
|
47
|
+
fs.writeFileSync(ctxFlagPath(type, cwd), '');
|
|
41
48
|
} catch { /* ok */ }
|
|
42
49
|
}
|
|
43
50
|
|
|
@@ -424,7 +431,12 @@ function runMain() {
|
|
|
424
431
|
// --- Read user message ---
|
|
425
432
|
let message;
|
|
426
433
|
try {
|
|
427
|
-
|
|
434
|
+
// fd 0, not '/dev/stdin': the path form open(2)s the symlink target, which
|
|
435
|
+
// fails with ENXIO when stdin is a socketpair (e.g. spawnSync {input}).
|
|
436
|
+
// Reading the fd directly works for pipes, sockets, and files alike. Five
|
|
437
|
+
// sibling hooks already did this; this one was the holdout, so it read
|
|
438
|
+
// nothing under exactly the conditions verifyHooksFire spawns it with.
|
|
439
|
+
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
428
440
|
message = (input && input.message) || '';
|
|
429
441
|
} catch {
|
|
430
442
|
return;
|
|
@@ -445,14 +457,16 @@ function runMain() {
|
|
|
445
457
|
const filePaths = extractFilePaths(message);
|
|
446
458
|
const symbols = extractSymbols(message);
|
|
447
459
|
const intents = detectIntents(message);
|
|
448
|
-
|
|
460
|
+
// Key the cooldowns on the resolved project root, not the raw shell cwd, so a
|
|
461
|
+
// `cd` into a subdir does not read as a different project and re-fire.
|
|
462
|
+
const query = determineQueryType(intents, symbols, filePaths, (type) => isCoolingDown(type, cwd), message);
|
|
449
463
|
|
|
450
464
|
if (!query) return;
|
|
451
465
|
|
|
452
466
|
// Phase E: symptom-hint is prose-only (no CLI execution). Emit + cooldown
|
|
453
467
|
// before the result-fetching paths so it can short-circuit cleanly.
|
|
454
468
|
if (query.type === 'symptom-hint') {
|
|
455
|
-
markCooldown('symptom');
|
|
469
|
+
markCooldown('symptom', cwd);
|
|
456
470
|
process.stdout.write(
|
|
457
471
|
'[code-graph:hint] indexed repo — for vague-symptom prompts, try `semantic_code_search "<symptom>"` ' +
|
|
458
472
|
'or `module_overview <suspected-dir>` to surface candidate code structurally. Skip if not searching code.\n'
|
|
@@ -467,8 +481,17 @@ function runMain() {
|
|
|
467
481
|
search: '[code-graph:search] Relevant code:',
|
|
468
482
|
};
|
|
469
483
|
|
|
470
|
-
|
|
471
|
-
|
|
484
|
+
// Resolve the binary the way every other hook does. The bare name relied on
|
|
485
|
+
// `code-graph-mcp` being on PATH, which it is not for plugin-only installs
|
|
486
|
+
// (the binary lives in ~/.cache/code-graph/bin), and when it IS on PATH it may
|
|
487
|
+
// be an unrelated or years-stale global shim — the same finding cg-answer.js
|
|
488
|
+
// and session-init.js were already fixed for. Null means no engine: nothing to
|
|
489
|
+
// run, and nothing to say about it.
|
|
490
|
+
const binary = require('./find-binary').findBinary();
|
|
491
|
+
if (!binary) return;
|
|
492
|
+
|
|
493
|
+
function run(args) {
|
|
494
|
+
return execFileSync(binary, args, hidden({
|
|
472
495
|
cwd,
|
|
473
496
|
timeout: 3000,
|
|
474
497
|
encoding: 'utf8',
|
|
@@ -477,15 +500,21 @@ function runMain() {
|
|
|
477
500
|
}));
|
|
478
501
|
}
|
|
479
502
|
|
|
503
|
+
// Stamp the cooldown on ATTEMPT, not on success. Keyed on the outcome, a
|
|
504
|
+
// binary that fails or times out produced no flag, so the very next prompt of
|
|
505
|
+
// the same shape re-ran it — a 3s execFileSync timeout on EVERY turn, which is
|
|
506
|
+
// the worst case wearing the cheapest disguise. The cooldown's job is rate
|
|
507
|
+
// limiting; whether the run had something to say is a separate question.
|
|
508
|
+
markCooldown(query.type, cwd);
|
|
509
|
+
|
|
480
510
|
try {
|
|
481
511
|
let result = '';
|
|
482
|
-
if (query.type === 'impact') result = run(
|
|
483
|
-
else if (query.type === 'callgraph') result = run(
|
|
484
|
-
else if (query.type === 'overview') result = run(
|
|
485
|
-
else if (query.type === 'search') result = run(
|
|
512
|
+
if (query.type === 'impact') result = run(['impact', query.symbol]);
|
|
513
|
+
else if (query.type === 'callgraph') result = run(['callgraph', query.symbol, '--depth', '2']);
|
|
514
|
+
else if (query.type === 'overview') result = run(['overview', query.path]);
|
|
515
|
+
else if (query.type === 'search') result = run(['search', query.symbol, '--limit', '8']);
|
|
486
516
|
|
|
487
517
|
if (result && result.trim()) {
|
|
488
|
-
markCooldown(query.type);
|
|
489
518
|
process.stdout.write(`${PREFIXES[query.type]}\n${result.trim()}\n`);
|
|
490
519
|
}
|
|
491
520
|
} catch {
|
|
@@ -497,4 +526,4 @@ if (require.main === module) {
|
|
|
497
526
|
runMain();
|
|
498
527
|
}
|
|
499
528
|
|
|
500
|
-
module.exports = { shouldSkip, extractFilePaths, extractSymbols, detectIntents, scoreIntent, INTENT_PATTERNS, INTENT_THRESHOLD, determineQueryType, computeQuietHooks, STOP_WORDS, PLAIN_WORD_EXCLUDE, hasSymptom, SYMPTOM_PATTERNS, buildRunEnv };
|
|
529
|
+
module.exports = { COOLDOWNS, shouldSkip, extractFilePaths, extractSymbols, detectIntents, scoreIntent, INTENT_PATTERNS, INTENT_THRESHOLD, determineQueryType, computeQuietHooks, STOP_WORDS, PLAIN_WORD_EXCLUDE, hasSymptom, SYMPTOM_PATTERNS, buildRunEnv };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.112.0",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"node": ">=16"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.112.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.112.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.112.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.112.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.112.0"
|
|
43
43
|
}
|
|
44
44
|
}
|