@sdsrs/code-graph 0.32.2 → 0.33.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.32.2",
7
+ "version": "0.33.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -8,6 +8,7 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const os = require('os');
11
+ const { PROJECT_MARKERS, isProjectRoot, isNonProjectCwd } = require('./project-detect');
11
12
 
12
13
  const SENTINEL_BEGIN = '<!-- code-graph-mcp:begin v1 -->';
13
14
  const SENTINEL_END = '<!-- code-graph-mcp:end -->';
@@ -314,32 +315,26 @@ function platformGuard() {
314
315
  return null;
315
316
  }
316
317
 
317
- // Project-marker check: cwd looks like a real project (not /tmp / $HOME).
318
- // Used to gate auto-mkdir of the auto-memory dir so adopt doesn't pollute
319
- // random directories. Mirrors the markers Claude Code itself recognizes.
320
- const PROJECT_MARKERS = [
321
- '.git', '.code-graph', 'package.json', 'Cargo.toml',
322
- 'pyproject.toml', 'go.mod', 'pom.xml', 'build.gradle',
323
- ];
324
- function isProjectRoot(cwd) {
325
- return PROJECT_MARKERS.some(m => fs.existsSync(path.join(cwd, m)));
326
- }
318
+ // Project-marker detection (PROJECT_MARKERS / isProjectRoot / isNonProjectCwd)
319
+ // now lives in project-detect.js the single activation gate shared with
320
+ // mcp-launcher.js and session-init.js. Imported above and re-exported below.
327
321
 
328
322
  function adopt({ cwd, home, templatePath } = {}) {
329
323
  const blocked = platformGuard();
330
324
  if (blocked) return blocked;
331
325
 
332
326
  const effectiveCwd = cwd || process.cwd();
333
- const dir = memoryDir(cwd, home);
334
- if (!fs.existsSync(dir)) {
335
- // Auto-create only when cwd has a project marker. Without markers the
336
- // user is likely in /tmp or $HOME, where adopt would litter
337
- // ~/.claude/projects/ with bogus slugs.
338
- if (!isProjectRoot(effectiveCwd)) {
339
- return { ok: false, reason: 'not-a-project', dir, cwd: effectiveCwd };
340
- }
341
- fs.mkdirSync(dir, { recursive: true });
327
+ // Gate adoption on a real-project cwd BEFORE touching the filesystem. The
328
+ // check must run even when the memory dir already exists: Claude Code
329
+ // pre-creates ~/.claude/projects/<slug>/memory for every session (including
330
+ // the ~2035 headless /tmp mem-lite calls), and the old guard — nested inside
331
+ // `if (!fs.existsSync(dir))` was bypassed in exactly that case, letting
332
+ // /tmp get adopted (sentinel written into its MEMORY.md). See project-detect.js.
333
+ if (isNonProjectCwd(effectiveCwd)) {
334
+ return { ok: false, reason: 'not-a-project', dir: memoryDir(cwd, home), cwd: effectiveCwd };
342
335
  }
336
+ const dir = memoryDir(cwd, home);
337
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
343
338
  const target = path.join(dir, TARGET_NAME);
344
339
  const tpl = templatePath || TEMPLATE_PATH;
345
340
  if (!fs.existsSync(tpl)) {
@@ -549,5 +544,5 @@ module.exports = {
549
544
  detectProjectType, buildIndexLine,
550
545
  extractCargoRuntimeDeps, extractPyRuntimeDeps, extractGoDirectRequires,
551
546
  SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH, TARGET_NAME,
552
- PROJECT_MARKERS, PROJECT_TYPES,
547
+ PROJECT_MARKERS, PROJECT_TYPES, isNonProjectCwd,
553
548
  };
@@ -15,6 +15,10 @@ const {
15
15
  function makeSandbox() {
16
16
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
17
17
  const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
18
+ // Mark the sandbox cwd as a real project — adopt() now gates on a project
19
+ // marker unconditionally (see project-detect.js), so a bare mkdtemp would be
20
+ // treated as a non-project and refused.
21
+ fs.mkdirSync(path.join(cwd, '.git'));
18
22
  // Pre-create the memory dir (claude-mem convention — we don't create it).
19
23
  const dir = memoryDir(cwd, home);
20
24
  fs.mkdirSync(dir, { recursive: true });
@@ -86,6 +90,31 @@ test('adopt preserves existing MEMORY.md content and appends', () => {
86
90
  } finally { sb.cleanup(); }
87
91
  });
88
92
 
93
+ test('adopt refuses a non-project cwd even when the memory dir already exists (regression: /tmp adoption)', () => {
94
+ // Bug: the isProjectRoot guard was nested inside `if (!fs.existsSync(dir))`,
95
+ // so when Claude Code had already created ~/.claude/projects/<slug>/memory
96
+ // (it does this for every session, incl. the ~2035 headless /tmp mem-lite
97
+ // calls), adopt() sailed past the guard and wrote its sentinel into /tmp's
98
+ // MEMORY.md. Pre-fix this test FAILS (adopt returns ok:true and writes).
99
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
100
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-')); // no project marker
101
+ const dir = memoryDir(cwd, home);
102
+ fs.mkdirSync(dir, { recursive: true }); // simulate CC pre-creating the memory dir
103
+ try {
104
+ const res = adopt({ cwd, home });
105
+ assert.strictEqual(res.ok, false);
106
+ assert.strictEqual(res.reason, 'not-a-project');
107
+ const indexPath = path.join(dir, 'MEMORY.md');
108
+ assert.ok(
109
+ !fs.existsSync(indexPath) || !fs.readFileSync(indexPath, 'utf8').includes(SENTINEL_BEGIN),
110
+ 'must NOT write the code-graph sentinel into a non-project MEMORY.md'
111
+ );
112
+ } finally {
113
+ fs.rmSync(home, { recursive: true, force: true });
114
+ fs.rmSync(cwd, { recursive: true, force: true });
115
+ }
116
+ });
117
+
89
118
  test('adopt fails gracefully when cwd is not a project root', () => {
90
119
  // v0.16.9: behavior change — adopt now mkdir's the memory dir when cwd has
91
120
  // a project marker (.git / Cargo.toml / package.json / ...). Bare mkdtemp
@@ -10,6 +10,7 @@
10
10
  const { spawn, spawnSync } = require('child_process');
11
11
  const path = require('path');
12
12
  const fs = require('fs');
13
+ const { isNonProjectCwd } = require('./project-detect');
13
14
 
14
15
  // Set plugin root so find-binary.js can locate bundled/dev binaries
15
16
  // Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
@@ -88,6 +89,22 @@ if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && projectHasLocalCodeGraphM
88
89
  return; // top-level function scope of mcp-launcher.js
89
90
  }
90
91
 
92
+ // --- Non-project cwd gate ---------------------------------------------------
93
+ // In a non-project working directory (no .git/manifest — e.g. /tmp, where
94
+ // claude-mem-lite spawns ~2035 headless `claude -p` JSON-extraction calls that
95
+ // never use code-graph), don't spawn the binary at all: serve the same 0-tool
96
+ // stub. Eliminates the MCP-server spin-up + the ~780B `instructions` block +
97
+ // an empty .code-graph/index.db being created in throwaway dirs. Same
98
+ // CODE_GRAPH_FORCE_PLUGIN_MCP=1 override as the dedup gate above.
99
+ if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && isNonProjectCwd(process.cwd())) {
100
+ process.stderr.write(
101
+ '[code-graph] non-project cwd (no .git/manifest); plugin MCP serving 0 tools, ' +
102
+ 'no index created. Set CODE_GRAPH_FORCE_PLUGIN_MCP=1 to override.\n'
103
+ );
104
+ serveEmptyMcpStub();
105
+ return;
106
+ }
107
+
91
108
  const { findBinary, clearCache } = require('./find-binary');
92
109
 
93
110
  let binary = findBinary();
@@ -33,12 +33,12 @@ function hasBuiltBinary() {
33
33
  * Run the launcher, send one MCP message on stdin, collect stdout/stderr,
34
34
  * resolve once we either see a JSON-RPC response on stdout or hit timeout.
35
35
  */
36
- function runLauncherInitialize(timeoutMs = 15000, extraEnv = {}) {
36
+ function runLauncherInitialize(timeoutMs = 15000, extraEnv = {}, cwd = REPO_ROOT) {
37
37
  return new Promise((resolve, reject) => {
38
38
  const child = spawn(process.execPath, [LAUNCHER], {
39
39
  stdio: ['pipe', 'pipe', 'pipe'],
40
40
  env: { ...process.env, ...extraEnv },
41
- cwd: REPO_ROOT,
41
+ cwd,
42
42
  });
43
43
 
44
44
  let stdout = '';
@@ -115,6 +115,30 @@ test('mcp-launcher enters dedup stub when project .mcp.json registers a code-gra
115
115
  `stderr should explain the dedup, got: ${stderr.slice(0, 400)}`);
116
116
  });
117
117
 
118
+ test('mcp-launcher serves 0-tool stub in a non-project cwd (no binary spawn, no index created)', async (t) => {
119
+ const os = require('os');
120
+ // A bare temp dir with no .git/manifest → isNonProjectCwd → the launcher
121
+ // serves the 0-tool stub WITHOUT spawning the binary, so no .code-graph is
122
+ // created and no `instructions` block is injected. This is the fix for the
123
+ // ~2035 headless /tmp mem-lite calls that half-activated code-graph.
124
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-launcher-nonproj-'));
125
+ t.after(() => fs.rmSync(cwd, { recursive: true, force: true }));
126
+
127
+ const { stdout, stderr } = await runLauncherInitialize(15000, {}, cwd);
128
+ const respLine = stdout.trim().split('\n').find((l) => l.includes('"result"'));
129
+ assert.ok(respLine,
130
+ `expected stub JSON-RPC result on stdout, got: ${stdout.slice(0, 400)} | stderr: ${stderr.slice(0, 400)}`);
131
+ const resp = JSON.parse(respLine);
132
+ assert.match(resp.result.serverInfo.name, /stub/i,
133
+ `serverInfo.name should indicate stub mode, got ${JSON.stringify(resp.result.serverInfo)}`);
134
+ assert.equal(resp.result.instructions, undefined,
135
+ 'stub initialize must NOT carry an instructions block (the ~780B NOISY tax)');
136
+ assert.match(stderr, /non-project cwd/,
137
+ `stderr should explain the non-project gate, got: ${stderr.slice(0, 400)}`);
138
+ assert.ok(!fs.existsSync(path.join(cwd, '.code-graph')),
139
+ 'must NOT create .code-graph in a non-project cwd');
140
+ });
141
+
118
142
  test('mcp-launcher sets _FIND_BINARY_ROOT from __dirname (does not trust CLAUDE_PLUGIN_ROOT)', () => {
119
143
  // Static check: the source must derive _FIND_BINARY_ROOT from __dirname so a
120
144
  // sibling plugin's CLAUDE_PLUGIN_ROOT can't redirect us to the wrong binary.
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Shared "is this a real project?" detector for the plugin's activation gates.
4
+ //
5
+ // Why this exists: code-graph half-activates in non-project working
6
+ // directories — most visibly the ~2035 headless `claude -p` calls
7
+ // claude-mem-lite spawns with cwd=/tmp ("Return ONLY valid JSON"), none of
8
+ // which ever use code-graph. Each one paid an MCP-server spin-up + a ~780B
9
+ // `instructions` block + a SessionStart map probe + an empty
10
+ // /tmp/.code-graph/index.db, plus adopt() writing a decision-table sentinel
11
+ // into ~/.claude/projects/-tmp/memory/MEMORY.md. This module is the single
12
+ // gate the launcher (mcp-launcher.js), the SessionStart hook (session-init.js),
13
+ // and adopt (adopt.js) consult to fully no-op there.
14
+ //
15
+ // Detection is project-MARKER based, NOT a literal "is cwd under os.tmpdir()"
16
+ // check. Rationale: (1) /tmp and Claude Code's $TMPDIR have no .git/manifest,
17
+ // so the marker check already classifies every temp / headless cwd as
18
+ // non-project; (2) a literal under-tmpdir test would wrongly skip a real git
19
+ // repo that happens to be cloned under /tmp AND would break this repo's own
20
+ // tmpdir-based test sandboxes. Markers mirror what Claude Code itself
21
+ // recognizes. `.code-graph` is deliberately NOT a marker — it is created BY
22
+ // this tool, so counting it would let a once-polluted /tmp self-certify as a
23
+ // project on the next session (circular).
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const PROJECT_MARKERS = [
28
+ '.git', 'package.json', 'Cargo.toml',
29
+ 'pyproject.toml', 'go.mod', 'pom.xml', 'build.gradle',
30
+ ];
31
+
32
+ function isProjectRoot(cwd) {
33
+ return PROJECT_MARKERS.some(m => fs.existsSync(path.join(cwd, m)));
34
+ }
35
+
36
+ // A cwd is "non-project" when it carries none of the recognized project
37
+ // markers. The plugin's activation gates short-circuit there: no MCP tools,
38
+ // no index creation, no SessionStart map injection, no auto-adoption.
39
+ function isNonProjectCwd(cwd = process.cwd()) {
40
+ return !isProjectRoot(cwd);
41
+ }
42
+
43
+ module.exports = { PROJECT_MARKERS, isProjectRoot, isNonProjectCwd };
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ // Tests for project-detect.js — the activation gate shared by mcp-launcher.js,
3
+ // session-init.js, and adopt.js. Run: node --test claude-plugin/scripts/project-detect.test.js
4
+ const test = require('node:test');
5
+ const assert = require('node:assert/strict');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+
10
+ const { PROJECT_MARKERS, isProjectRoot, isNonProjectCwd } = require('./project-detect');
11
+
12
+ function mkTmp(t) {
13
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-pd-'));
14
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
15
+ return dir;
16
+ }
17
+
18
+ test('isNonProjectCwd: bare tmp dir (no markers) → non-project', (t) => {
19
+ const dir = mkTmp(t);
20
+ assert.equal(isNonProjectCwd(dir), true);
21
+ });
22
+
23
+ test('isNonProjectCwd: /tmp root (the mem-lite headless cwd) → non-project', () => {
24
+ // claude-mem-lite spawns `claude -p` with cwd=/tmp; /tmp has no project marker.
25
+ assert.equal(isNonProjectCwd('/tmp'), true);
26
+ });
27
+
28
+ test('isNonProjectCwd: cwd with .git → project (false)', (t) => {
29
+ const dir = mkTmp(t);
30
+ fs.mkdirSync(path.join(dir, '.git'));
31
+ assert.equal(isNonProjectCwd(dir), false);
32
+ });
33
+
34
+ test('isNonProjectCwd: cwd with package.json → project (false)', (t) => {
35
+ const dir = mkTmp(t);
36
+ fs.writeFileSync(path.join(dir, 'package.json'), '{}');
37
+ assert.equal(isNonProjectCwd(dir), false);
38
+ });
39
+
40
+ test('isNonProjectCwd: a real git repo under /tmp is still a project (marker wins over location)', (t) => {
41
+ // Deliberate: we do NOT do a literal under-tmpdir check, so a repo cloned
42
+ // into /tmp/<x> with .git is correctly treated as a project.
43
+ const dir = mkTmp(t);
44
+ fs.mkdirSync(path.join(dir, '.git'));
45
+ assert.equal(isNonProjectCwd(dir), false);
46
+ });
47
+
48
+ test('isNonProjectCwd: cwd with only .code-graph → non-project (self-created dir is not a marker)', (t) => {
49
+ // Circularity guard: once code-graph (pre-fix) created /tmp/.code-graph, a
50
+ // naive marker set counting .code-graph would self-certify /tmp as a project.
51
+ const dir = mkTmp(t);
52
+ fs.mkdirSync(path.join(dir, '.code-graph'));
53
+ assert.equal(isProjectRoot(dir), false, '.code-graph alone must not qualify as a project');
54
+ assert.equal(isNonProjectCwd(dir), true);
55
+ });
56
+
57
+ test('PROJECT_MARKERS excludes .code-graph and includes the standard anchors', () => {
58
+ assert.ok(!PROJECT_MARKERS.includes('.code-graph'), '.code-graph must not be a project marker');
59
+ for (const m of ['.git', 'package.json', 'Cargo.toml', 'pyproject.toml', 'go.mod']) {
60
+ assert.ok(PROJECT_MARKERS.includes(m), `${m} should be a marker`);
61
+ }
62
+ });
63
+
64
+ test('isProjectRoot detects each marker', (t) => {
65
+ for (const marker of PROJECT_MARKERS) {
66
+ const dir = mkTmp(t);
67
+ assert.equal(isProjectRoot(dir), false, 'bare cwd should not be a project');
68
+ const markerPath = path.join(dir, marker);
69
+ if (marker.startsWith('.')) fs.mkdirSync(markerPath);
70
+ else fs.writeFileSync(markerPath, '');
71
+ assert.equal(isProjectRoot(dir), true, `${marker} should make cwd a project`);
72
+ }
73
+ });
@@ -10,6 +10,7 @@ const {
10
10
  } = require('./lifecycle');
11
11
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
12
12
  const { maybeAutoAdopt, isAdopted } = require('./adopt');
13
+ const { isNonProjectCwd } = require('./project-detect');
13
14
 
14
15
  // v0.17.0 — quietHooks: unconditional quiet 默认。
15
16
  // 项目地图与 MEMORY.md plugin contract + on-demand `project_map` 工具高度重叠,
@@ -268,6 +269,16 @@ function runSessionInit() {
268
269
  return { inactive: true, lifecycle: 'noop', autoUpdateLaunched: false };
269
270
  }
270
271
 
272
+ // Non-project cwd (no .git/manifest — e.g. /tmp, where claude-mem-lite
273
+ // spawns headless `claude -p` calls that never use code-graph): fully no-op.
274
+ // Returns BEFORE syncLifecycleConfig / verifyBinary / ensureIndexFresh /
275
+ // maybeAutoAdopt / injectProjectMap so the plugin leaves zero footprint
276
+ // (no incremental-index spawn, no map injection, no adoption). The MCP
277
+ // launcher applies the same gate — see project-detect.js.
278
+ if (isNonProjectCwd(process.cwd())) {
279
+ return { inactive: false, nonProject: true, lifecycle: 'noop', autoUpdateLaunched: false };
280
+ }
281
+
271
282
  const conflict = checkScopeConflict();
272
283
  if (conflict) {
273
284
  process.stderr.write(
@@ -72,12 +72,33 @@ test('launchBackgroundAutoUpdate spawns detached silent updater', () => {
72
72
  assert.equal(calls[0].unrefCalled, true);
73
73
  });
74
74
 
75
- const { consistencyCheck } = require('./session-init');
75
+ const { consistencyCheck, runSessionInit } = require('./session-init');
76
76
 
77
77
  test('consistencyCheck is exported as a function', () => {
78
78
  assert.equal(typeof consistencyCheck, 'function');
79
79
  });
80
80
 
81
+ test('runSessionInit no-ops (nonProject) in a non-project cwd', (t) => {
82
+ // /tmp-style cwd (no .git/manifest) → the gate returns BEFORE
83
+ // syncLifecycleConfig / verifyBinary / ensureIndexFresh / maybeAutoAdopt /
84
+ // injectProjectMap, leaving zero footprint. Safe to call: the early return
85
+ // precedes every side-effectful step.
86
+ const os = require('os');
87
+ const origCwd = process.cwd();
88
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-si-nonproj-'));
89
+ process.chdir(tmp);
90
+ try {
91
+ const res = runSessionInit();
92
+ if (res.inactive) { t.skip('plugin seen inactive in this env — gate not reached'); return; }
93
+ assert.equal(res.nonProject, true);
94
+ assert.equal(res.lifecycle, 'noop');
95
+ assert.equal(res.autoUpdateLaunched, false);
96
+ } finally {
97
+ process.chdir(origCwd);
98
+ fs.rmSync(tmp, { recursive: true, force: true });
99
+ }
100
+ });
101
+
81
102
  test('consistencyCheck returns empty array when binary version matches plugin', () => {
82
103
  const result = consistencyCheck('/tmp/nonexistent-binary');
83
104
  assert.ok(Array.isArray(result));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.32.2",
3
+ "version": "0.33.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.32.2",
39
- "@sdsrs/code-graph-linux-arm64": "0.32.2",
40
- "@sdsrs/code-graph-darwin-x64": "0.32.2",
41
- "@sdsrs/code-graph-darwin-arm64": "0.32.2",
42
- "@sdsrs/code-graph-win32-x64": "0.32.2"
38
+ "@sdsrs/code-graph-linux-x64": "0.33.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.33.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.33.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.33.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.33.0"
43
43
  }
44
44
  }