@sdsrs/code-graph 0.98.1 → 0.99.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.98.1",
7
+ "version": "0.99.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -8,12 +8,34 @@
8
8
  // relative-path matching) fails silently for the rest of the session (daagu
9
9
  // 2026-06-11: 38/40 head-greps dark; the read hook never recorded AT ALL).
10
10
  // Walk up to the nearest ancestor holding `.code-graph/index.db`; stop at
11
- // $HOME (checked, not crossed) and fs root.
11
+ // $HOME and at `.git` boundaries (neither is crossed, and home's own index is
12
+ // never adopted from below).
12
13
 
13
14
  const fs = require('fs');
14
15
  const os = require('os');
15
16
  const path = require('path');
16
17
 
18
+ // A linked git worktree's `.git` is a FILE containing
19
+ // `gitdir: <main>/.git/worktrees/<name>` — resolve it to the main checkout so
20
+ // worktree sessions (Claude Code's EnterWorktree puts them under
21
+ // <main>/.claude/worktrees/<slug>) reuse the main index instead of going dark.
22
+ // A submodule's `.git` file points at `.git/modules/…` and stays a hard
23
+ // boundary: its content is a DIFFERENT codebase, not a branch copy of the
24
+ // parent. Returns null for a regular `.git` directory (EISDIR), a missing
25
+ // `.git` (ENOENT), or any gitdir not under `.git/worktrees/`.
26
+ function worktreeMainRoot(dir) {
27
+ let raw;
28
+ try { raw = fs.readFileSync(path.join(dir, '.git'), 'utf8'); }
29
+ catch { return null; }
30
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(raw);
31
+ if (!m) return null;
32
+ const gitdir = path.resolve(dir, m[1]);
33
+ const marker = `${path.sep}.git${path.sep}worktrees${path.sep}`;
34
+ const at = gitdir.lastIndexOf(marker);
35
+ if (at < 0) return null;
36
+ return gitdir.slice(0, at) || null;
37
+ }
38
+
17
39
  // Resolves to the project's CANONICAL index dir, skipping STRAY nested indexes.
18
40
  // A monorepo subdir (`daagu/backend`, `daagu/frontend`) can carry its own
19
41
  // `.code-graph/index.db` — a relic an older binary created — nested under the
@@ -22,8 +44,17 @@ const path = require('path');
22
44
  // `✗ 0 nodes` in an empty subdir index). Mirror the Rust resolver: the start's
23
45
  // own index wins only if it is NOT a stray nested index (no indexed ancestor) OR
24
46
  // start is itself a project boundary (`.git`, i.e. a real submodule). Otherwise
25
- // prefer the project root: the nearest indexed `.git` root, else the outermost
26
- // indexed dir on the chain. `null` when nothing on start→…→home is indexed.
47
+ // prefer the project root: the nearest indexed `.git` root, else the nearest
48
+ // indexed ancestor INSIDE the boundary (git root if any, home otherwise — the
49
+ // walk stops at both, so a nested repo never adopts the outer project's index
50
+ // and a stray `~/.code-graph` never leaks into un-indexed dirs under home).
51
+ // `null` when nothing on start→…→boundary is indexed.
52
+ //
53
+ // Rust parity note (cli::resolve_project_root_from): the Rust resolver is the
54
+ // WRITE side — in a worktree it returns the worktree itself and builds a local
55
+ // index there. This reader prefers such a local index when present (own-index
56
+ // rule), falling back to the main checkout's index only while the worktree has
57
+ // none. Divergence is intentional; keep it documented on both sides.
27
58
  function resolveProjectRoot(startDir, opts = {}) {
28
59
  const home = opts.home !== undefined ? opts.home : os.homedir();
29
60
  const exists = opts.exists || fs.existsSync;
@@ -32,17 +63,28 @@ function resolveProjectRoot(startDir, opts = {}) {
32
63
  const start = path.resolve(startDir || '.');
33
64
 
34
65
  // start's own `.git` is a hard project boundary (a real submodule / distinct
35
- // repo): use its index if present, else `null` never escape to an ancestor's
36
- // index. Mirrors the Rust resolver's rule 1 (which returns cwd even without an
37
- // index because it CREATES one; the JS reader has nothing to read → null).
38
- if (hasGit(start)) return hasIndex(start) ? start : null;
66
+ // repo): use its index if present. With none, a linked WORKTREE falls back to
67
+ // its main checkout's index (a worktree is a branch copy of that codebase);
68
+ // anything else `null` never escape to an ancestor's index. Mirrors the
69
+ // Rust resolver's rule 1 (which returns cwd even without an index because it
70
+ // CREATES one; the JS reader has nothing to read → null).
71
+ if (hasGit(start)) {
72
+ if (hasIndex(start)) return start;
73
+ const main = worktreeMainRoot(start);
74
+ return main && hasIndex(main) ? main : null;
75
+ }
76
+
77
+ // start IS home: own-index rule only (deliberately indexed home dirs keep
78
+ // working from home itself), and never scan ancestors ABOVE home — without
79
+ // this, the strict-ancestor walk below starts past the home bound entirely.
80
+ if (start === home) return hasIndex(start) ? start : null;
39
81
 
40
82
  // Detect whether `start` is a STRAY nested index: walk STRICT ancestors up to
41
83
  // the nearest `.git` root (project boundary), bounded at home. An indexed
42
84
  // ancestor within that boundary means start's own index is a monorepo-subdir
43
85
  // relic. Stop AT the git root — an index above it (e.g. `~/.code-graph`) is an
44
86
  // unrelated outer project and must not poison this one.
45
- let gitRootIndexed = null;
87
+ let gitRoot = null;
46
88
  let ancestorIndexed = false;
47
89
  let dir = start;
48
90
  for (;;) {
@@ -53,23 +95,33 @@ function resolveProjectRoot(startDir, opts = {}) {
53
95
  if (parent === dir || parent === home) break;
54
96
  dir = parent;
55
97
  if (hasIndex(dir)) ancestorIndexed = true;
56
- if (hasGit(dir)) { if (hasIndex(dir)) gitRootIndexed = dir; break; }
98
+ if (hasGit(dir)) { gitRoot = dir; break; }
57
99
  }
58
100
 
59
101
  // start's own index wins unless it is stray (an indexed ancestor within the
60
102
  // git boundary). start's own `.git` was already handled above.
61
103
  if (hasIndex(start) && !ancestorIndexed) return start;
62
- if (gitRootIndexed) return gitRootIndexed;
63
- // Otherwise the nearest indexed ancestor (skipping a stray start), bounded at
64
- // home; null if nothing on the chain is indexed. Mirrors the original walk.
104
+ if (gitRoot && hasIndex(gitRoot)) return gitRoot;
105
+
106
+ // Nearest indexed ancestor strictly INSIDE the boundary (git root / home are
107
+ // stops, never candidates — see header). Covers the legit shape where only a
108
+ // sub-project of an unindexed repo was indexed (repo/packages/foo).
65
109
  let d = hasIndex(start) ? path.dirname(start) : start;
66
110
  for (;;) {
111
+ if (d === home || d === gitRoot) break;
67
112
  if (hasIndex(d)) return d;
68
- if (d === home) return null;
69
113
  const parent = path.dirname(d);
70
- if (parent === d) return null;
114
+ if (parent === d) break;
71
115
  d = parent;
72
116
  }
117
+
118
+ // Nothing indexed inside the boundary. If that boundary is a linked worktree
119
+ // root, subdirs resolve like the root itself does: to the main checkout.
120
+ if (gitRoot) {
121
+ const main = worktreeMainRoot(gitRoot);
122
+ if (main && hasIndex(main)) return main;
123
+ }
124
+ return null;
73
125
  }
74
126
 
75
127
  module.exports = { resolveProjectRoot };
@@ -316,7 +316,12 @@ function ensureIndexFresh() {
316
316
  const bin = findBinary();
317
317
  if (!bin) return 'skipped';
318
318
 
319
- const cwd = process.cwd();
319
+ // Canonical index root, not the bare session cwd: a session launched in a
320
+ // linked worktree (resolves to the main checkout) or a subdir otherwise
321
+ // gate-fails here and freshness never runs (sibling of the statusline/hook
322
+ // subdir-cwd dark class).
323
+ const { resolveProjectRoot } = require('./project-root');
324
+ const cwd = resolveProjectRoot(process.cwd()) || process.cwd();
320
325
  const dbPath = path.join(cwd, '.code-graph', 'index.db');
321
326
  if (!fs.existsSync(dbPath)) return 'skipped';
322
327
 
@@ -592,7 +597,8 @@ function runSessionInit({ source } = {}) {
592
597
  */
593
598
  function injectProjectMap() {
594
599
  try {
595
- const cwd = process.cwd();
600
+ const { resolveProjectRoot } = require('./project-root');
601
+ const cwd = resolveProjectRoot(process.cwd()) || process.cwd();
596
602
  const dbPath = path.join(cwd, '.code-graph', 'index.db');
597
603
  if (!fs.existsSync(dbPath)) return false;
598
604
 
@@ -636,7 +642,14 @@ function injectProjectMap() {
636
642
  */
637
643
  function injectRecentImpact({ source } = {}) {
638
644
  try {
639
- const cwd = process.cwd();
645
+ // Index + telemetry live at the canonical root (worktree → main checkout,
646
+ // subdir → project root); git WIP detection stays in the SESSION dir — the
647
+ // worktree's branch state is what this session edits. Repo-relative git
648
+ // paths translate 1:1 to root-relative index paths (a worktree mirrors the
649
+ // checkout layout).
650
+ const { resolveProjectRoot } = require('./project-root');
651
+ const sessionDir = process.cwd();
652
+ const cwd = resolveProjectRoot(sessionDir) || sessionDir;
640
653
  const dbPath = path.join(cwd, '.code-graph', 'index.db');
641
654
  if (!fs.existsSync(dbPath)) return false;
642
655
 
@@ -646,7 +659,7 @@ function injectRecentImpact({ source } = {}) {
646
659
  // last commit. Timeouts tightened (finding #1): worst-case cap sum is now
647
660
  // status(1s) + HEAD~1(1s) + affected(1.5s) = 3.5s, comfortably under the 5s
648
661
  // SessionStart hook budget; the old 2+2+3=7s could get the whole hook killed.
649
- const gitOpts = { cwd, timeout: 1000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
662
+ const gitOpts = { cwd: sessionDir, timeout: 1000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
650
663
  let changed = [];
651
664
  let isWip = false;
652
665
  try {
@@ -431,8 +431,11 @@ function runMain() {
431
431
  // Chinese chars are ~3 bytes but 1 char; "看看 fts5_search" is only 16 chars
432
432
  if (!message || message.length < 8) return;
433
433
 
434
- // --- Check index ---
435
- const cwd = process.cwd();
434
+ // --- Check index --- (canonical root: worktree → main checkout, subdir →
435
+ // project root — the bare cwd gate left this hook dark there, sibling of the
436
+ // pre-*-guide subdir-cwd class)
437
+ const { resolveProjectRoot } = require('./project-root');
438
+ const cwd = resolveProjectRoot(process.cwd()) || process.cwd();
436
439
  const dbPath = path.join(cwd, '.code-graph', 'index.db');
437
440
  if (!fs.existsSync(dbPath)) return;
438
441
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.98.1",
3
+ "version": "0.99.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": {
@@ -36,10 +36,10 @@
36
36
  "node": ">=16"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@sdsrs/code-graph-linux-x64": "0.98.1",
40
- "@sdsrs/code-graph-linux-arm64": "0.98.1",
41
- "@sdsrs/code-graph-darwin-x64": "0.98.1",
42
- "@sdsrs/code-graph-darwin-arm64": "0.98.1",
43
- "@sdsrs/code-graph-win32-x64": "0.98.1"
39
+ "@sdsrs/code-graph-linux-x64": "0.99.0",
40
+ "@sdsrs/code-graph-linux-arm64": "0.99.0",
41
+ "@sdsrs/code-graph-darwin-x64": "0.99.0",
42
+ "@sdsrs/code-graph-darwin-arm64": "0.99.0",
43
+ "@sdsrs/code-graph-win32-x64": "0.99.0"
44
44
  }
45
45
  }