@sdsrs/code-graph 0.124.0 → 0.126.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.124.0",
7
+ "version": "0.126.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -7,7 +7,7 @@ const http = require('http');
7
7
  const crypto = require('crypto');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
- const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, readJsonResult, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
10
+ const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, readJsonResult, backupCorruptFile, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
11
11
  const { claudeHome } = require('./claude-config');
12
12
  const { clearCache: clearBinaryCache, globalNodeModulesCandidates, nvmNodeModulesDirs, PLATFORM_PKG, detectLibc } = require('./find-binary');
13
13
  const { readBinaryVersion, compareVersions, isDevMode } = require('./version-utils');
@@ -785,25 +785,96 @@ async function downloadAndInstall(latest, {
785
785
  // fragile), pluginDst was never created. Advancing installPath/manifest to it anyway
786
786
  // pointed Claude Code at a nonexistent install dir while state read "up to date".
787
787
  if (pluginUpdated) {
788
- // Update installed_plugins.json to point to new version
788
+ // Update installed_plugins.json to point to new version.
789
+ //
790
+ // Through the same three-way read the lifecycle.js site uses. The lenient
791
+ // `readJson` returns null for ENOENT, EACCES and unparseable alike, and
792
+ // the `if (installed && …)` guard below then skipped the repoint in
793
+ // SILENCE — while the plugin copy had landed and the manifest below is
794
+ // about to be advanced. Claude Code keeps launching the old install dir
795
+ // with state reading "up to date": the split-brain shape the binary-pin
796
+ // incident was made of, and one this file cannot fix by guessing at bytes
797
+ // it could not read. So it says so instead, which keeps `/plugin update`
798
+ // reachable as the manual way out.
789
799
  const installedPath = installedPluginsPath();
790
- try {
791
- const installed = readJson(installedPath);
800
+ const installedRead = readJsonResult(installedPath);
801
+ // Whether the registry entry is STILL pointing at the old version when this
802
+ // block ends. It gates the manifest advance below, and that gate is the
803
+ // whole difference between a report and a fix: `checkForUpdate` reads
804
+ // `readManifest().version` as the authoritative installed version, so
805
+ // advancing it past a repoint that did not happen makes the next session
806
+ // compute "up to date" — the message below prints ONCE, into a SessionStart
807
+ // hook's stderr, and the split-brain then has nothing behind it. Left
808
+ // behind, the ordinary check interval retries the whole install and
809
+ // re-reports, and the repoint lands by itself the moment the file is
810
+ // repaired. `missing` is not blocked: no registry means nothing to repoint.
811
+ let repointBlocked = false;
812
+ if (installedRead.corrupt) {
813
+ // Value unusable — the bytes are not ours to guess at.
814
+ const why = installedRead.error
815
+ ? (installedRead.error.code || installedRead.error.message)
816
+ : 'it does not contain a JSON object';
817
+ console.error(
818
+ `[code-graph] plugin ${latest.version} is installed, but ${installedPath} ` +
819
+ `could not be read (${why}) — its entry for this plugin still points at the ` +
820
+ 'previous version. Run `/plugin update` or repair that file by hand.'
821
+ );
822
+ repointBlocked = true;
823
+ } else {
824
+ let installed = installedRead.value;
825
+ // `lossy` is NOT `corrupt`: the value parsed and is usable, it is the
826
+ // BYTES that will not survive our rewrite (a cp1252 byte inside a path,
827
+ // see readJsonResult). lifecycle.js's readSettingsForWrite route applies
828
+ // here for the same reason — preserve the true bytes, then proceed, since
829
+ // refusing outright strands the install over a byte we can work around.
830
+ // Collapsing this into the corrupt arm also misreported it: a lossy result
831
+ // carries no `error`, so the message called a parseable file unparseable.
832
+ if (installed && installedRead.lossy) {
833
+ const backup = backupCorruptFile(installedPath, installedRead.raw);
834
+ if (backup) {
835
+ console.error(
836
+ `[code-graph] ${installedPath} contains bytes that are not valid UTF-8; ` +
837
+ `repointing it at ${latest.version} will replace them. Saved the original ` +
838
+ `to ${backup} first.`
839
+ );
840
+ } else {
841
+ console.error(
842
+ `[code-graph] plugin ${latest.version} is installed, but ${installedPath} ` +
843
+ 'contains bytes that are not valid UTF-8 and no backup copy could be made — ' +
844
+ 'its entry for this plugin still points at the previous version. Rewriting it ' +
845
+ 'would replace those bytes permanently. Run `/plugin update` after repairing it.'
846
+ );
847
+ installed = null;
848
+ repointBlocked = true;
849
+ }
850
+ }
792
851
  if (installed && installed.plugins && installed.plugins[PLUGIN_ID]) {
793
852
  installed.plugins[PLUGIN_ID][0].installPath = pluginDst;
794
853
  installed.plugins[PLUGIN_ID][0].version = latest.version;
795
854
  installed.plugins[PLUGIN_ID][0].lastUpdated = new Date().toISOString();
796
- writeJsonAtomic(installedPath, installed);
855
+ try {
856
+ writeJsonAtomic(installedPath, installed);
857
+ } catch (err) {
858
+ console.error(
859
+ `[code-graph] plugin ${latest.version} is installed, but ${installedPath} ` +
860
+ `could not be written (${err.code || err.name}) — its entry for this plugin ` +
861
+ 'still points at the previous version. Run `/plugin update`.'
862
+ );
863
+ repointBlocked = true;
864
+ }
797
865
  }
798
- } catch { /* not fatal */ }
866
+ }
799
867
 
800
- // Update install manifest
801
- try {
802
- const manifest = readManifest();
803
- manifest.version = latest.version;
804
- manifest.updatedAt = new Date().toISOString();
805
- writeJsonAtomic(path.join(CACHE_DIR, 'install-manifest.json'), manifest);
806
- } catch { /* not fatal */ }
868
+ // Update install manifest — only when nothing is left pointing at the old
869
+ // version. See `repointBlocked` above: this value IS the update gate.
870
+ if (!repointBlocked) {
871
+ try {
872
+ const manifest = readManifest();
873
+ manifest.version = latest.version;
874
+ manifest.updatedAt = new Date().toISOString();
875
+ writeJsonAtomic(path.join(CACHE_DIR, 'install-manifest.json'), manifest);
876
+ } catch { /* not fatal */ }
877
+ }
807
878
 
808
879
  // Run the NEW lifecycle.js to update settings.json hooks with new paths.
809
880
  // Without this, settings.json hooks still point to the old version directory
@@ -61,7 +61,14 @@ function truncateAtLine(text, maxBytes) {
61
61
  if (lastNl > 0) {
62
62
  return { text: head.slice(0, lastNl), truncated: true };
63
63
  }
64
- return { text: buf.subarray(0, maxBytes).toString('latin1'), truncated: true };
64
+ // Hard cut, when even the first line does not fit. Back the cut off to a
65
+ // UTF-8 character boundary instead of re-decoding the bytes: `latin1` maps
66
+ // each byte to its own character, so a CJK line came back as mojibake rather
67
+ // than as a shortened line, and `utf8` alone would leave a U+FFFD where the
68
+ // cut landed mid-character. A continuation byte is `10xxxxxx`.
69
+ let end = maxBytes;
70
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) end--;
71
+ return { text: buf.subarray(0, end).toString('utf8'), truncated: true };
65
72
  }
66
73
 
67
74
  /**
@@ -1817,6 +1817,7 @@ module.exports = {
1817
1817
  isPluginExplicitlyDisabled, isPluginInactive, isPluginUninstalled, removeCacheResidue,
1818
1818
  cleanupDisabledStatusline, unadoptRegisteredProjects,
1819
1819
  readManifest, readJson, readJsonResult, readSettingsForWrite, writeJsonAtomic,
1820
+ backupCorruptFile, // auto-update.js repoints installed_plugins.json and owes the same preserve-then-proceed route
1820
1821
  migrateOldPluginIds, // exported so its failure arms are testable (audit 2026-08-22 P2-10)
1821
1822
  readRegistry, readRegistryForWrite, writeRegistry,
1822
1823
  getPluginVersion, cleanupOldCacheVersions,
@@ -42,7 +42,7 @@ function run(stdin) {
42
42
  const registry = readRegistry();
43
43
  if (registry.length === 0) {
44
44
  // Fallback: no registry, run code-graph only
45
- const cg = runProvider(codeGraphCommand(), false, stdin);
45
+ const cg = runProvider(codeGraphCommand(), false, stdin, 'code-graph');
46
46
  if (cg) process.stdout.write(cg);
47
47
  return;
48
48
  }
@@ -57,7 +57,7 @@ function run(stdin) {
57
57
 
58
58
  const outputs = [];
59
59
  for (const provider of sorted) {
60
- const out = runProvider(provider.command, provider.needsStdin, stdin);
60
+ const out = runProvider(provider.command, provider.needsStdin, stdin, provider.id);
61
61
  if (out) outputs.push(out);
62
62
  }
63
63
  if (outputs.length > 0) {
@@ -65,26 +65,41 @@ function run(stdin) {
65
65
  }
66
66
  }
67
67
 
68
- /// True when the command needs a shell to mean what it says. Claude Code runs
69
- /// `statusLine.command` through one, so a captured `_previous` legitimately
70
- /// contains pipelines and a pipeline cannot run through `execFileSync` under
71
- /// any splitting, so today it produces ENOENT and a silently missing segment.
68
+ /// True when the command needs a shell to mean what it says.
69
+ ///
70
+ /// Gated on the entry being `_previous`, and that gate is the whole point.
71
+ /// `_previous` IS the user's `statusLine.command`, which Claude Code runs
72
+ /// through a shell — so a captured pipeline is legitimate there, and a pipeline
73
+ /// cannot run through `execFileSync` under any splitting: it produces ENOENT and
74
+ /// a silently missing segment.
75
+ ///
76
+ /// The other two registry classes were never shell strings. `codeGraphCommand()`
77
+ /// composes `node "<__dirname>/statusline.js"`, and third-party entries arrive
78
+ /// through `statusline-chain.js register`, whose only executor has ever been
79
+ /// `execFileSync`. Handing those to a shell imposes semantics they never had,
80
+ /// and OUR segment is the one that dies: measured, a plugin installed under a
81
+ /// directory named `dev$work` produced `node "…/dev$work/statusline.js"`, which
82
+ /// a shell reads as `…/dev/statusline.js` — segment gone, `catch` swallows it.
83
+ /// Inside double quotes only `$` and a backtick break, which is why this stayed
84
+ /// invisible until someone had one in an install path.
72
85
  ///
73
86
  /// Trade-off, stated because it is a real one: through `sh -c`, the timeout's
74
87
  /// SIGKILL reaches the SHELL, not necessarily a grandchild that traps signals
75
- /// (the hazard the direct-exec path was hardened against). It applies only to
76
- /// commands that cannot run at all today, so nothing that currently works loses
77
- /// the guarantee. Windows has no `sh`, so there the command stays on the direct
78
- /// path and a pipeline keeps failing as before rather than failing differently.
79
- function needsShell(command) {
80
- return process.platform !== 'win32' && SHELL_METACHARS.test(command);
88
+ /// (the hazard the direct-exec path was hardened against). Confining the shell
89
+ /// to `_previous` also confines that loss to the entry that cannot work without
90
+ /// it. Windows keeps everything on the direct path note that Claude Code
91
+ /// itself runs statusline commands through Git Bash there, so a `_previous`
92
+ /// pipeline works in Claude Code and still dies here; the fix is half-applied by
93
+ /// platform, which is a gap rather than a regression (it never worked here).
94
+ function needsShell(command, id) {
95
+ return id === '_previous' && process.platform !== 'win32' && SHELL_METACHARS.test(command);
81
96
  }
82
97
 
83
- function runProvider(command, needsStdin, stdin) {
98
+ function runProvider(command, needsStdin, stdin, id) {
84
99
  if (!command) return null;
85
100
  try {
86
101
  // Parse command into executable + args
87
- const parts = needsShell(command) ? ['/bin/sh', '-c', command] : parseCommand(command);
102
+ const parts = needsShell(command, id) ? ['/bin/sh', '-c', command] : parseCommand(command);
88
103
  if (!parts) return null;
89
104
 
90
105
  // Claude Code runs statusLine.command through a shell, so a leading `~`
@@ -94,7 +109,7 @@ function runProvider(command, needsStdin, stdin) {
94
109
  // swallowed below, silently dropping the user's original statusline.
95
110
  // `sh -c` does its own tilde expansion; expanding our own would corrupt the
96
111
  // script text (`~` inside a quoted string is not a home directory).
97
- const argv = needsShell(command) ? parts : parts.map(expandTilde);
112
+ const argv = needsShell(command, id) ? parts : parts.map(expandTilde);
98
113
 
99
114
  // Forward Claude Code's authoritative current dir (from the stdin payload) as
100
115
  // a plugin-scoped env var. The code-graph provider gates on it instead of its
@@ -35,7 +35,7 @@ jobs:
35
35
  node-version: '20'
36
36
  - name: Build snapshot
37
37
  run: |
38
- npx -y -p @sdsrs/code-graph@0.124.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.126.0 code-graph-mcp snapshot create --out snapshot.db
39
39
  zstd -9 snapshot.db -o snapshot.db.zst
40
40
  mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
41
41
  - name: Upload to release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.124.0",
3
+ "version": "0.126.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.124.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.124.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.124.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.124.0",
42
- "@sdsrs/code-graph-win32-x64": "0.124.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.126.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.126.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.126.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.126.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.126.0"
43
43
  }
44
44
  }