@sdsrs/code-graph 0.8.0 → 0.8.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.8.0",
7
+ "version": "0.8.1",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -22,7 +22,41 @@ function escapeRegex(s) {
22
22
  return s.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&');
23
23
  }
24
24
 
25
+ // Strip our sentinel block — well-formed first, then self-heal orphan begin/end.
26
+ // Shared by adopt (so re-adopt rewrites a stale/malformed block) and unadopt.
27
+ function stripSentinelBlock(text) {
28
+ const wellFormed = new RegExp(
29
+ `${escapeRegex(SENTINEL_BEGIN)}[\\s\\S]*?${escapeRegex(SENTINEL_END)}\\n?`, 'g'
30
+ );
31
+ let out = text.replace(wellFormed, '');
32
+ // Orphan BEGIN with no matching END (truncation / partial edit).
33
+ // Strip from BEGIN to the next blank line or EOF — the file is shared with
34
+ // claude-mem-lite, so we must not eat past a blank-line boundary.
35
+ if (out.includes(SENTINEL_BEGIN)) {
36
+ out = out.replace(
37
+ new RegExp(`${escapeRegex(SENTINEL_BEGIN)}[\\s\\S]*?(?=\\n\\n|$)`, 'g'),
38
+ ''
39
+ );
40
+ }
41
+ // Orphan END line by itself.
42
+ if (out.includes(SENTINEL_END)) {
43
+ out = out.split('\n').filter(l => l.trim() !== SENTINEL_END).join('\n');
44
+ }
45
+ // Collapse blank-line runs introduced by stripping mid-paragraph blocks.
46
+ return out.replace(/\n{3,}/g, '\n\n');
47
+ }
48
+
49
+ function platformGuard() {
50
+ if (process.platform === 'win32') {
51
+ return { ok: false, reason: 'windows-not-supported' };
52
+ }
53
+ return null;
54
+ }
55
+
25
56
  function adopt({ cwd, home, templatePath } = {}) {
57
+ const blocked = platformGuard();
58
+ if (blocked) return blocked;
59
+
26
60
  const dir = memoryDir(cwd, home);
27
61
  if (!fs.existsSync(dir)) {
28
62
  return { ok: false, reason: 'no-memory-dir', dir };
@@ -35,18 +69,25 @@ function adopt({ cwd, home, templatePath } = {}) {
35
69
  fs.copyFileSync(tpl, target);
36
70
 
37
71
  const indexPath = path.join(dir, 'MEMORY.md');
38
- let index = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '# Memory Index\n';
39
- let indexed = false;
40
- if (!index.includes(SENTINEL_BEGIN)) {
41
- if (!index.endsWith('\n')) index += '\n';
42
- index += `${SENTINEL_BEGIN}\n${INDEX_LINE}\n${SENTINEL_END}\n`;
43
- fs.writeFileSync(indexPath, index);
44
- indexed = true;
72
+ const index = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '# Memory Index\n';
73
+ const desiredBlock = `${SENTINEL_BEGIN}\n${INDEX_LINE}\n${SENTINEL_END}`;
74
+
75
+ // Already-adopted-and-well-formed: skip the write entirely.
76
+ if (index.includes(desiredBlock)) {
77
+ return { ok: true, target, indexPath, indexed: false, healed: false };
45
78
  }
46
- return { ok: true, target, indexPath, indexed };
79
+
80
+ const cleaned = stripSentinelBlock(index);
81
+ const healed = cleaned !== index;
82
+ const base = cleaned.endsWith('\n') ? cleaned : cleaned + '\n';
83
+ fs.writeFileSync(indexPath, base + desiredBlock + '\n');
84
+ return { ok: true, target, indexPath, indexed: true, healed };
47
85
  }
48
86
 
49
87
  function unadopt({ cwd, home } = {}) {
88
+ const blocked = platformGuard();
89
+ if (blocked) return blocked;
90
+
50
91
  const dir = memoryDir(cwd, home);
51
92
  const target = path.join(dir, TARGET_NAME);
52
93
  const indexPath = path.join(dir, 'MEMORY.md');
@@ -59,8 +100,7 @@ function unadopt({ cwd, home } = {}) {
59
100
  }
60
101
  if (fs.existsSync(indexPath)) {
61
102
  const before = fs.readFileSync(indexPath, 'utf8');
62
- const re = new RegExp(`${escapeRegex(SENTINEL_BEGIN)}[\\s\\S]*?${escapeRegex(SENTINEL_END)}\\n?`, 'g');
63
- const after = before.replace(re, '');
103
+ const after = stripSentinelBlock(before);
64
104
  if (after !== before) {
65
105
  fs.writeFileSync(indexPath, after);
66
106
  indexPruned = true;
@@ -70,6 +110,10 @@ function unadopt({ cwd, home } = {}) {
70
110
  }
71
111
 
72
112
  function formatResult(action, result) {
113
+ if (!result.ok && result.reason === 'windows-not-supported') {
114
+ return '[code-graph] adopt/unadopt are POSIX-only — claude-mem-lite slug ' +
115
+ 'convention on Windows is unverified. Edit MEMORY.md manually to opt in.';
116
+ }
73
117
  if (action === 'adopt') {
74
118
  if (!result.ok) {
75
119
  if (result.reason === 'no-memory-dir') {
@@ -82,8 +126,9 @@ function formatResult(action, result) {
82
126
  return `[code-graph] adopt failed: ${result.reason || 'unknown'}`;
83
127
  }
84
128
  const lines = [`[code-graph] Adopted → ${result.target}`];
85
- if (result.indexed) lines.push(`[code-graph] Indexed → ${result.indexPath}`);
86
- else lines.push(`[code-graph] Index already contains sentinel — left as-is`);
129
+ if (result.healed) lines.push(`[code-graph] Healed malformed sentinel block → ${result.indexPath}`);
130
+ else if (result.indexed) lines.push(`[code-graph] Indexed ${result.indexPath}`);
131
+ else lines.push(`[code-graph] Index already up-to-date — no write`);
87
132
  lines.push('[code-graph] Activate: set CODE_GRAPH_QUIET_HOOKS=1 in ~/.claude/settings.json env');
88
133
  return lines.join('\n');
89
134
  }
@@ -105,6 +150,6 @@ if (require.main === module) {
105
150
  }
106
151
 
107
152
  module.exports = {
108
- adopt, unadopt, memoryDir, formatResult,
153
+ adopt, unadopt, memoryDir, formatResult, stripSentinelBlock,
109
154
  SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH, TARGET_NAME,
110
155
  };
@@ -5,7 +5,8 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
7
  const {
8
- adopt, unadopt, memoryDir, SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH,
8
+ adopt, unadopt, memoryDir, stripSentinelBlock,
9
+ SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH,
9
10
  } = require('./adopt');
10
11
 
11
12
  function makeSandbox() {
@@ -112,3 +113,102 @@ test('template file exists and contains decision table', () => {
112
113
  assert.ok(content.includes('impact_analysis'), 'mentions impact_analysis');
113
114
  assert.ok(content.includes('CODE_GRAPH_QUIET_HOOKS'), 'mentions env gate');
114
115
  });
116
+
117
+ test('stripSentinelBlock removes well-formed block', () => {
118
+ const before = `# Index\n${SENTINEL_BEGIN}\n${INDEX_LINE}\n${SENTINEL_END}\n- [x.md](x.md)\n`;
119
+ const after = stripSentinelBlock(before);
120
+ assert.ok(!after.includes(SENTINEL_BEGIN));
121
+ assert.ok(!after.includes(SENTINEL_END));
122
+ assert.ok(after.includes('- [x.md](x.md)'), 'preserves neighbors');
123
+ });
124
+
125
+ test('stripSentinelBlock self-heals orphan BEGIN without END', () => {
126
+ // Truncation / partial edit scenario
127
+ const before = `# Index\n- [a.md](a.md) — entry\n${SENTINEL_BEGIN}\n${INDEX_LINE}\n\n- [b.md](b.md) — survivor\n`;
128
+ const after = stripSentinelBlock(before);
129
+ assert.ok(!after.includes(SENTINEL_BEGIN), 'orphan BEGIN removed');
130
+ assert.ok(after.includes('survivor'), 'content past blank-line boundary preserved');
131
+ assert.ok(after.includes('entry'), 'content before BEGIN preserved');
132
+ });
133
+
134
+ test('stripSentinelBlock self-heals orphan END line', () => {
135
+ const before = `# Index\n- [a.md](a.md)\n${SENTINEL_END}\n- [b.md](b.md)\n`;
136
+ const after = stripSentinelBlock(before);
137
+ assert.ok(!after.includes(SENTINEL_END));
138
+ assert.ok(after.includes('- [a.md](a.md)') && after.includes('- [b.md](b.md)'));
139
+ });
140
+
141
+ test('adopt heals malformed sentinel (orphan BEGIN) on re-run', () => {
142
+ const sb = makeSandbox();
143
+ try {
144
+ const indexPath = path.join(sb.dir, 'MEMORY.md');
145
+ // Simulate truncated prior adopt — BEGIN line + stale entry, no END
146
+ fs.writeFileSync(
147
+ indexPath,
148
+ `# Memory Index\n- [old.md](old.md) — preserved\n${SENTINEL_BEGIN}\n- [stale](stale.md) — wrong entry\n\n- [neighbor.md](neighbor.md) — survives\n`
149
+ );
150
+ const res = adopt({ cwd: sb.cwd, home: sb.home });
151
+ assert.strictEqual(res.ok, true);
152
+ assert.strictEqual(res.healed, true, 'reports healed');
153
+ const final = fs.readFileSync(indexPath, 'utf8');
154
+ // Exactly one well-formed block now
155
+ const beginCount = (final.match(new RegExp(SENTINEL_BEGIN.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&'), 'g')) || []).length;
156
+ const endCount = (final.match(new RegExp(SENTINEL_END.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&'), 'g')) || []).length;
157
+ assert.strictEqual(beginCount, 1, 'one BEGIN');
158
+ assert.strictEqual(endCount, 1, 'one END');
159
+ assert.ok(final.includes('preserved'), 'preserves pre-BEGIN content');
160
+ assert.ok(final.includes('neighbor.md'), 'preserves post-malformed-block content');
161
+ assert.ok(!final.includes('stale.md'), 'old wrong entry purged');
162
+ assert.ok(final.includes(INDEX_LINE), 'fresh canonical line written');
163
+ } finally { sb.cleanup(); }
164
+ });
165
+
166
+ test('adopt is a true no-op when desired block is already present verbatim', () => {
167
+ const sb = makeSandbox();
168
+ try {
169
+ adopt({ cwd: sb.cwd, home: sb.home });
170
+ const indexPath = path.join(sb.dir, 'MEMORY.md');
171
+ const before = fs.readFileSync(indexPath, 'utf8');
172
+ const beforeMtime = fs.statSync(indexPath).mtimeMs;
173
+ const res2 = adopt({ cwd: sb.cwd, home: sb.home });
174
+ assert.strictEqual(res2.indexed, false);
175
+ assert.strictEqual(res2.healed, false);
176
+ assert.strictEqual(fs.readFileSync(indexPath, 'utf8'), before, 'file content identical');
177
+ // mtime may equal beforeMtime since we skipped the write
178
+ assert.strictEqual(fs.statSync(indexPath).mtimeMs, beforeMtime, 'no write occurred');
179
+ } finally { sb.cleanup(); }
180
+ });
181
+
182
+ test('unadopt heals malformed sentinel (orphan BEGIN)', () => {
183
+ const sb = makeSandbox();
184
+ try {
185
+ const indexPath = path.join(sb.dir, 'MEMORY.md');
186
+ fs.writeFileSync(
187
+ indexPath,
188
+ `# Index\n${SENTINEL_BEGIN}\n${INDEX_LINE}\n\n- [keep.md](keep.md) — survives\n`
189
+ );
190
+ const res = unadopt({ cwd: sb.cwd, home: sb.home });
191
+ assert.strictEqual(res.indexPruned, true);
192
+ const final = fs.readFileSync(indexPath, 'utf8');
193
+ assert.ok(!final.includes(SENTINEL_BEGIN), 'orphan BEGIN purged');
194
+ assert.ok(final.includes('keep.md'), 'content past blank-line preserved');
195
+ } finally { sb.cleanup(); }
196
+ });
197
+
198
+ test('Windows platform is rejected with clear reason', { skip: process.platform === 'win32' }, () => {
199
+ const orig = process.platform;
200
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
201
+ try {
202
+ const sb = makeSandbox();
203
+ try {
204
+ const adoptRes = adopt({ cwd: sb.cwd, home: sb.home });
205
+ assert.strictEqual(adoptRes.ok, false);
206
+ assert.strictEqual(adoptRes.reason, 'windows-not-supported');
207
+ const unadoptRes = unadopt({ cwd: sb.cwd, home: sb.home });
208
+ assert.strictEqual(unadoptRes.ok, false);
209
+ assert.strictEqual(unadoptRes.reason, 'windows-not-supported');
210
+ } finally { sb.cleanup(); }
211
+ } finally {
212
+ Object.defineProperty(process, 'platform', { value: orig, configurable: true });
213
+ }
214
+ });
@@ -465,16 +465,17 @@ test('skills: only expected skills exist', () => {
465
465
  assert.deepEqual(files, ['explore.md', 'index.md']);
466
466
  });
467
467
 
468
- test('CODE_GRAPH_QUIET_HOOKS=1 short-circuits before reading stdin', () => {
469
- const { execFileSync } = require('node:child_process');
468
+ test('CODE_GRAPH_QUIET_HOOKS=1 short-circuits silently on stdout, stderr, exit 0', () => {
469
+ const { spawnSync } = require('node:child_process');
470
470
  const script = path.join(__dirname, 'user-prompt-context.js');
471
- const out = execFileSync(process.execPath, [script], {
471
+ const proc = spawnSync(process.execPath, [script], {
472
472
  input: JSON.stringify({ message: 'impact analysis for fn_that_would_trigger_search' }),
473
473
  env: { ...process.env, CODE_GRAPH_QUIET_HOOKS: '1' },
474
474
  encoding: 'utf8',
475
- stdio: ['pipe', 'pipe', 'pipe'],
476
475
  timeout: 2000,
477
476
  });
478
- // Quiet mode must produce no stdoutno [code-graph:*] prefix, nothing.
479
- assert.equal(out, '');
477
+ // Quiet mode must be fully silentany stderr leaks into Claude's display.
478
+ assert.equal(proc.stdout, '', 'stdout must be empty');
479
+ assert.equal(proc.stderr, '', 'stderr must be empty');
480
+ assert.equal(proc.status, 0, 'must exit 0');
480
481
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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": {
@@ -34,10 +34,10 @@
34
34
  "node": ">=16"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@sdsrs/code-graph-linux-x64": "0.8.0",
38
- "@sdsrs/code-graph-linux-arm64": "0.8.0",
39
- "@sdsrs/code-graph-darwin-x64": "0.8.0",
40
- "@sdsrs/code-graph-darwin-arm64": "0.8.0",
41
- "@sdsrs/code-graph-win32-x64": "0.8.0"
37
+ "@sdsrs/code-graph-linux-x64": "0.8.1",
38
+ "@sdsrs/code-graph-linux-arm64": "0.8.1",
39
+ "@sdsrs/code-graph-darwin-x64": "0.8.1",
40
+ "@sdsrs/code-graph-darwin-arm64": "0.8.1",
41
+ "@sdsrs/code-graph-win32-x64": "0.8.1"
42
42
  }
43
43
  }