Skip to content

Commit fbb0e6e

Browse files
sdsrssclaude
andcommitted
fix(plugin): adopt self-heals malformed sentinel + Windows guard (v0.8.1)
Code-review follow-ups for v0.8.0: - adopt.js: stripSentinelBlock now self-heals orphan BEGIN (truncated prior run) and orphan END lines; collapses 3+ blank lines after strip. adopt rewrites the block when content drifts from canonical (was a silent no-op when sentinel present but stale). - adopt.js: platformGuard rejects win32 — claude-mem-lite slug convention on Windows is unverified, and silent dir-mismatch was the failure mode. - adopt.js: formatResult distinguishes healed vs fresh-indexed vs no-op. - main.rs: SAFETY comment on run_node_script — `script` must be a literal, never user input (concatenated into a path then exec'd via node). - user-prompt-context.test.js: quiet-mode test now asserts stderr empty and exit 0, not just stdout (a stderr leak would still surface in Claude's display). - adopt.test.js: 7 new tests — strip well-formed/orphan-BEGIN/orphan-END, adopt heal-on-malformed, true-noop verification (mtime check), unadopt heal, Windows rejection. Tests: 76 plugin pass (was 69); 206 Rust unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b90ba40 commit fbb0e6e

File tree

14 files changed

+185
-36
lines changed

14 files changed

+185
-36
lines changed

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
},
66
"metadata": {
77
"description": "AST knowledge graph plugin for Claude Code — semantic search, call graph, HTTP tracing, impact analysis",
8-
"version": "0.8.0"
8+
"version": "0.8.1"
99
},
1010
"plugins": [
1111
{
1212
"name": "code-graph-mcp",
1313
"source": "./claude-plugin",
1414
"description": "AST knowledge graph for intelligent code navigation — auto-indexes your codebase and provides semantic search, call graph traversal, HTTP route tracing, and impact analysis via MCP tools",
15-
"version": "0.8.0",
15+
"version": "0.8.1",
1616
"author": {
1717
"name": "sdsrs"
1818
},

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "code-graph-mcp"
3-
version = "0.8.0"
3+
version = "0.8.1"
44
edition = "2021"
55

66
[features]

claude-plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"author": {
55
"name": "sdsrs"
66
},
7-
"version": "0.8.0",
7+
"version": "0.8.1",
88
"keywords": [
99
"code-graph",
1010
"ast",

claude-plugin/scripts/adopt.js

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,41 @@ function escapeRegex(s) {
2222
return s.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&');
2323
}
2424

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+
2556
function adopt({ cwd, home, templatePath } = {}) {
57+
const blocked = platformGuard();
58+
if (blocked) return blocked;
59+
2660
const dir = memoryDir(cwd, home);
2761
if (!fs.existsSync(dir)) {
2862
return { ok: false, reason: 'no-memory-dir', dir };
@@ -35,18 +69,25 @@ function adopt({ cwd, home, templatePath } = {}) {
3569
fs.copyFileSync(tpl, target);
3670

3771
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 };
4578
}
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 };
4785
}
4886

4987
function unadopt({ cwd, home } = {}) {
88+
const blocked = platformGuard();
89+
if (blocked) return blocked;
90+
5091
const dir = memoryDir(cwd, home);
5192
const target = path.join(dir, TARGET_NAME);
5293
const indexPath = path.join(dir, 'MEMORY.md');
@@ -59,8 +100,7 @@ function unadopt({ cwd, home } = {}) {
59100
}
60101
if (fs.existsSync(indexPath)) {
61102
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);
64104
if (after !== before) {
65105
fs.writeFileSync(indexPath, after);
66106
indexPruned = true;
@@ -70,6 +110,10 @@ function unadopt({ cwd, home } = {}) {
70110
}
71111

72112
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+
}
73117
if (action === 'adopt') {
74118
if (!result.ok) {
75119
if (result.reason === 'no-memory-dir') {
@@ -82,8 +126,9 @@ function formatResult(action, result) {
82126
return `[code-graph] adopt failed: ${result.reason || 'unknown'}`;
83127
}
84128
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`);
87132
lines.push('[code-graph] Activate: set CODE_GRAPH_QUIET_HOOKS=1 in ~/.claude/settings.json env');
88133
return lines.join('\n');
89134
}
@@ -105,6 +150,6 @@ if (require.main === module) {
105150
}
106151

107152
module.exports = {
108-
adopt, unadopt, memoryDir, formatResult,
153+
adopt, unadopt, memoryDir, formatResult, stripSentinelBlock,
109154
SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH, TARGET_NAME,
110155
};

claude-plugin/scripts/adopt.test.js

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ const fs = require('fs');
55
const path = require('path');
66
const os = require('os');
77
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,
910
} = require('./adopt');
1011

1112
function makeSandbox() {
@@ -112,3 +113,102 @@ test('template file exists and contains decision table', () => {
112113
assert.ok(content.includes('impact_analysis'), 'mentions impact_analysis');
113114
assert.ok(content.includes('CODE_GRAPH_QUIET_HOOKS'), 'mentions env gate');
114115
});
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+
});

claude-plugin/scripts/user-prompt-context.test.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -465,16 +465,17 @@ test('skills: only expected skills exist', () => {
465465
assert.deepEqual(files, ['explore.md', 'index.md']);
466466
});
467467

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');
470470
const script = path.join(__dirname, 'user-prompt-context.js');
471-
const out = execFileSync(process.execPath, [script], {
471+
const proc = spawnSync(process.execPath, [script], {
472472
input: JSON.stringify({ message: 'impact analysis for fn_that_would_trigger_search' }),
473473
env: { ...process.env, CODE_GRAPH_QUIET_HOOKS: '1' },
474474
encoding: 'utf8',
475-
stdio: ['pipe', 'pipe', 'pipe'],
476475
timeout: 2000,
477476
});
478-
// Quiet mode must produce no stdout — no [code-graph:*] prefix, nothing.
479-
assert.equal(out, '');
477+
// Quiet mode must be fully silent — any 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');
480481
});

npm/darwin-arm64/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sdsrs/code-graph-darwin-arm64",
3-
"version": "0.8.0",
3+
"version": "0.8.1",
44
"description": "code-graph-mcp binary for macOS ARM64",
55
"license": "MIT",
66
"repository": {

npm/darwin-x64/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sdsrs/code-graph-darwin-x64",
3-
"version": "0.8.0",
3+
"version": "0.8.1",
44
"description": "code-graph-mcp binary for macOS x64",
55
"license": "MIT",
66
"repository": {

npm/linux-arm64/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sdsrs/code-graph-linux-arm64",
3-
"version": "0.8.0",
3+
"version": "0.8.1",
44
"description": "code-graph-mcp binary for Linux ARM64",
55
"license": "MIT",
66
"repository": {

0 commit comments

Comments
 (0)