@sdsrs/code-graph 0.164.0 → 0.165.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.164.0",
7
+ "version": "0.165.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -667,6 +667,19 @@ function needsRefresh({ cwd, templatePath } = {}) {
667
667
  } catch { return false; }
668
668
  }
669
669
 
670
+ // What a stale notice is about: the shipped template plus the block this
671
+ // project would get now. SessionStart shows the notice once per value, so the
672
+ // user's own copy is deliberately not an input — a hand edit is not news, a
673
+ // newer shipped template is. null when the template cannot be read.
674
+ function shippedFingerprint({ cwd, templatePath } = {}) {
675
+ const effectiveCwd = cwd || process.cwd();
676
+ let tpl;
677
+ try { tpl = fs.readFileSync(templatePath || TEMPLATE_PATH); } catch { return null; }
678
+ return require('crypto').createHash('sha256')
679
+ .update(tpl).update('\0').update(buildBlock(detectProjectType(effectiveCwd)))
680
+ .digest('hex').slice(0, 16);
681
+ }
682
+
670
683
  // 检测脚本是否从 Claude Code 插件 cache 运行。
671
684
  // 走 __dirname 而非 CLAUDE_PLUGIN_ROOT — 后者在多插件共存时会互相污染
672
685
  // (见 feedback_plugin_env_isolation.md)。
@@ -752,7 +765,7 @@ function maybeAutoAdopt({ cwd, home, env, scriptPath } = {}) {
752
765
  // name is kept: SessionStart, tests and the uninstall paths all call it.
753
766
  if (isAdopted({ cwd })) {
754
767
  if (env.CODE_GRAPH_NO_TEMPLATE_REFRESH !== '1' && needsRefresh({ cwd })) {
755
- return { attempted: false, reason: 'stale', migrated };
768
+ return { attempted: false, reason: 'stale', fingerprint: shippedFingerprint({ cwd }), migrated };
756
769
  }
757
770
  return { attempted: false, reason: 'already-adopted', migrated };
758
771
  }
@@ -990,7 +1003,7 @@ if (require.main === module) {
990
1003
  module.exports = {
991
1004
  adopt, unadopt, memoryDir, formatResult, unadoptCommand, shellQuote, stripSentinelBlock,
992
1005
  readAdoptedProjects, readAdoptedResult, recordAdopted, removeAdopted, adoptedRegistryFile,
993
- isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot, adoptCommand,
1006
+ isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, shippedFingerprint, isProjectRoot, adoptCommand,
994
1007
  detectProjectType, buildBlock, buildTriggerRows, migrateLegacyMemoryDir,
995
1008
  claudeMdPath, detailDir, detailPath,
996
1009
  extractCargoRuntimeDeps, extractPyRuntimeDeps, extractGoDirectRequires,
@@ -39,11 +39,11 @@ const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
39
39
  // this is ever required from something long-lived.
40
40
  const warnedPaths = new Set();
41
41
 
42
- function warnNotOwned(p, why) {
42
+ function warnNotOwned(p, why, what = 'adoption metrics') {
43
43
  if (warnedPaths.has(p)) return;
44
44
  warnedPaths.add(p);
45
45
  try {
46
- process.stderr.write(`[code-graph] skipping adoption metrics: ${p} ${why}\n`);
46
+ process.stderr.write(`[code-graph] skipping ${what}: ${p} ${why}\n`);
47
47
  } catch {
48
48
  /* stderr closed → nothing to do */
49
49
  }
@@ -67,9 +67,10 @@ function warnNotOwned(p, why) {
67
67
  * @param {string} p absolute path
68
68
  * @param {'file'|'dir'} kind
69
69
  * @param {fs.Stats} [known] an lstat the caller already took, to avoid a second
70
+ * @param {string} [what] what is skipped, for the diagnostic
70
71
  * @returns {boolean} true if `p` is safe to write
71
72
  */
72
- function isOwnedPath(p, kind, known) {
73
+ function isOwnedPath(p, kind, known, what) {
73
74
  let st = known;
74
75
  if (!st) {
75
76
  try {
@@ -79,11 +80,11 @@ function isOwnedPath(p, kind, known) {
79
80
  }
80
81
  }
81
82
  if (st.isSymbolicLink()) {
82
- warnNotOwned(p, 'is a symlink (following it would write outside the project)');
83
+ warnNotOwned(p, 'is a symlink (following it would write outside the project)', what);
83
84
  return false;
84
85
  }
85
86
  if (kind === 'dir' ? st.isDirectory() : st.isFile()) return true;
86
- warnNotOwned(p, 'is not a regular file');
87
+ warnNotOwned(p, 'is not a regular file', what);
87
88
  return false;
88
89
  }
89
90
 
@@ -102,9 +103,10 @@ function isOwnedPath(p, kind, known) {
102
103
  *
103
104
  * Callers must NOT pass `O_TRUNC`: truncation would happen on the open, before
104
105
  * any of this could look. Truncate through the returned descriptor instead.
106
+ * @param {string} [what] what is skipped, for the diagnostic
105
107
  * @returns {number|null} an open fd the caller must close, or null if refused
106
108
  */
107
- function openOwned(file, flags) {
109
+ function openOwned(file, flags, what) {
108
110
  const fd = fs.openSync(file, flags | O_NOFOLLOW);
109
111
  let st;
110
112
  try {
@@ -114,12 +116,12 @@ function openOwned(file, flags) {
114
116
  throw e;
115
117
  }
116
118
  if (!st.isFile()) {
117
- warnNotOwned(file, 'is not a regular file');
119
+ warnNotOwned(file, 'is not a regular file', what);
118
120
  fs.closeSync(fd);
119
121
  return null;
120
122
  }
121
123
  if (st.nlink > 1) {
122
- warnNotOwned(file, 'has more than one hard link (the write would reach another path)');
124
+ warnNotOwned(file, 'has more than one hard link (the write would reach another path)', what);
123
125
  fs.closeSync(fd);
124
126
  return null;
125
127
  }
@@ -202,4 +204,4 @@ function recordRecommendation(cwd, event = {}) {
202
204
  }
203
205
  }
204
206
 
205
- module.exports = { recordRecommendation, REC_FILE };
207
+ module.exports = { recordRecommendation, REC_FILE, isOwnedPath, openOwned };
@@ -19,6 +19,7 @@ const { installHookFailOpen, remainingMs } = require('./hook-fail-open');
19
19
  // treats any throw as "nothing to conclude", so a lazy require in there would
20
20
  // turn a resolution failure into a silent disable (pre-tag review, JS-08).
21
21
  const { resolveProjectRoot } = require('./project-root');
22
+ const { isOwnedPath, openOwned } = require('./recommendation-log');
22
23
 
23
24
  // ── SessionStart budget (audit 2026-09-05 NEW-05) ─────────────────────────
24
25
  //
@@ -565,6 +566,44 @@ function samePath(a, b) {
565
566
  return real(a) === real(b);
566
567
  }
567
568
 
569
+ /**
570
+ * Whether to show the out-of-date-block notice in this project, recording it
571
+ * as shown. Once per shipped-template fingerprint: 0.164.0 showed it at every
572
+ * session start. Recorded in `.code-graph/` only when that directory already
573
+ * exists — creating it would put an unexcluded directory in `git status`, the
574
+ * side effect D3/D4 removed. Anything unrecordable (no fingerprint, no
575
+ * directory, a failed write) shows the notice: a failure must never silence it.
576
+ */
577
+ function staleNoticeDue(cwd, fingerprint) {
578
+ if (!fingerprint) return true;
579
+ const dir = path.join(cwd, '.code-graph');
580
+ const marker = path.join(dir, 'stale-block-notice');
581
+ // `.code-graph/` is repo content: a clone can put a symlink or a hard link
582
+ // where the record goes (tar a FIFO), and a plain read or write follows it
583
+ // out of the project. Only a single-link regular file in a real directory is
584
+ // read or written — recommendation-log.js's guards; anything else is
585
+ // unrecordable.
586
+ const what = 'the stale-block notice record';
587
+ let dirStat;
588
+ try { dirStat = fs.lstatSync(dir); } catch { return true; }
589
+ if (!isOwnedPath(dir, 'dir', dirStat, what) || !isOwnedPath(marker, 'file', undefined, what)) return true;
590
+ let fd;
591
+ try {
592
+ fd = openOwned(marker,
593
+ fs.constants.O_RDWR | fs.constants.O_CREAT | (fs.constants.O_NONBLOCK || 0), what);
594
+ } catch { return true; }
595
+ if (fd === null) return true;
596
+ try {
597
+ const buf = Buffer.alloc(64);
598
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
599
+ if (buf.toString('utf8', 0, n).trim() === fingerprint) return false;
600
+ fs.ftruncateSync(fd, 0);
601
+ fs.writeSync(fd, fingerprint + '\n', 0);
602
+ } catch { /* unrecorded: shown again next session */ }
603
+ finally { fs.closeSync(fd); }
604
+ return true;
605
+ }
606
+
568
607
  function runSessionInit({ source } = {}) {
569
608
  // Fresh per run: this is module state, and the test suite calls this function
570
609
  // many times in one process. A carried-over array would report last run's
@@ -684,12 +723,13 @@ function runSessionInit({ source } = {}) {
684
723
  autoAdopt = { attempted: false, reason: 'threw', error: (e && e.message) || String(e) };
685
724
  }
686
725
  }
687
- if (autoAdopt.reason === 'stale') {
726
+ if (autoAdopt.reason === 'stale' && staleNoticeDue(process.cwd(), autoAdopt.fingerprint)) {
688
727
  notices.push(
689
728
  "[code-graph] This project's CLAUDE.md carries an out-of-date code-graph block.\n" +
690
729
  ` Refresh it: ${adoptCommand()}\n` +
691
730
  ` Remove it: ${unadoptCommand()}\n` +
692
- ' (SessionStart no longer edits CLAUDE.md itself; CODE_GRAPH_NO_TEMPLATE_REFRESH=1 silences this.)'
731
+ ' (Shown once per shipped template. SessionStart no longer edits CLAUDE.md itself;\n' +
732
+ ' CODE_GRAPH_NO_TEMPLATE_REFRESH=1 silences this.)'
693
733
  );
694
734
  }
695
735
 
@@ -855,7 +895,7 @@ module.exports = {
855
895
  injectProjectMap,
856
896
  verifyBinary, missingBinaryMessage, unadoptCommand,
857
897
  consistencyCheck,
858
- runSessionInit,
898
+ runSessionInit, staleNoticeDue,
859
899
  computeQuietHooks,
860
900
  shouldInjectMap,
861
901
  hookFireWarning, checkHookFiring, analyzeHookDark, detectHookDark, // v0.67.0
@@ -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.164.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.165.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.164.0",
3
+ "version": "0.165.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.164.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.164.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.164.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.164.0",
42
- "@sdsrs/code-graph-win32-x64": "0.164.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.165.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.165.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.165.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.165.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.165.0"
43
43
  }
44
44
  }