@sdsrs/code-graph 0.130.0 → 0.132.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.
|
@@ -27,6 +27,105 @@ const NO_METRICS_FILE = '.no-metrics';
|
|
|
27
27
|
const ROTATE_MAX_BYTES = 1048576; // 1 MB
|
|
28
28
|
const ROTATE_KEEP_BYTES = 524288; // 512 KB
|
|
29
29
|
|
|
30
|
+
// Unix-only, atomic: refuse to traverse a final-component symlink on the open
|
|
31
|
+
// itself. Undefined on Windows, where the `lstat` / `fstat` checks below are the
|
|
32
|
+
// only layer — same split as the Rust `utils::owned`. Each layer is pinned by
|
|
33
|
+
// its own test (`refuses … with O_NOFOLLOW unavailable` covers the Windows
|
|
34
|
+
// shape); a suite that only pins the pair lets either half be deleted green.
|
|
35
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
36
|
+
|
|
37
|
+
// Keyed by path, not a bare boolean: every hook is a one-shot process today, but
|
|
38
|
+
// a module-global flag would silence the diagnostic for the SECOND project if
|
|
39
|
+
// this is ever required from something long-lived.
|
|
40
|
+
const warnedPaths = new Set();
|
|
41
|
+
|
|
42
|
+
function warnNotOwned(p, why) {
|
|
43
|
+
if (warnedPaths.has(p)) return;
|
|
44
|
+
warnedPaths.add(p);
|
|
45
|
+
try {
|
|
46
|
+
process.stderr.write(`[code-graph] skipping adoption metrics: ${p} ${why}\n`);
|
|
47
|
+
} catch {
|
|
48
|
+
/* stderr closed → nothing to do */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reject anything at `p` that is not the ordinary file/directory this module
|
|
54
|
+
* expects to own. JS twin of Rust `utils::owned::refuse_non_regular` /
|
|
55
|
+
* `reject_symlinked_dir`: `.code-graph/` is ordinary repo content, so one
|
|
56
|
+
* `git clone` can carry a symlink where this module expects its own file, and
|
|
57
|
+
* `writeFileSync` / `appendFileSync` both follow it out of the project (audit
|
|
58
|
+
* 2026-09-02 P1-1: a 1.2 MB file outside the tree truncated by the rotator).
|
|
59
|
+
*
|
|
60
|
+
* Symlink is tested FIRST so each rejection gets its own diagnostic, matching
|
|
61
|
+
* `reject_symlinked_dir`. The first cut tested `isDirectory()` first, which is
|
|
62
|
+
* false for a symlink-to-directory — so the symlink arm never ran, the distinct
|
|
63
|
+
* message was dead, and deleting the whole call changed nothing (pre-tag review,
|
|
64
|
+
* P3-1).
|
|
65
|
+
*
|
|
66
|
+
* Absent is fine — the open will create it.
|
|
67
|
+
* @param {string} p absolute path
|
|
68
|
+
* @param {'file'|'dir'} kind
|
|
69
|
+
* @param {fs.Stats} [known] an lstat the caller already took, to avoid a second
|
|
70
|
+
* @returns {boolean} true if `p` is safe to write
|
|
71
|
+
*/
|
|
72
|
+
function isOwnedPath(p, kind, known) {
|
|
73
|
+
let st = known;
|
|
74
|
+
if (!st) {
|
|
75
|
+
try {
|
|
76
|
+
st = fs.lstatSync(p);
|
|
77
|
+
} catch {
|
|
78
|
+
return true; // absent (or unreadable) → leave it to the real syscall
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (st.isSymbolicLink()) {
|
|
82
|
+
warnNotOwned(p, 'is a symlink (following it would write outside the project)');
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (kind === 'dir' ? st.isDirectory() : st.isFile()) return true;
|
|
86
|
+
warnNotOwned(p, 'is not a regular file');
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Open `file` for writing only if the thing actually opened is a file this
|
|
92
|
+
* module owns. `fstat` on the returned descriptor — not `lstat` on the path —
|
|
93
|
+
* because that is the only check that describes the object the subsequent write
|
|
94
|
+
* lands on, closing the lstat→open window on the platforms where `O_NOFOLLOW`
|
|
95
|
+
* is 0.
|
|
96
|
+
*
|
|
97
|
+
* `nlink > 1` refuses a HARDLINK. `lstat` reports a hardlinked victim as a
|
|
98
|
+
* plain regular file and `O_NOFOLLOW` says nothing about one, so the symlink
|
|
99
|
+
* guard alone left the identical damage reachable — measured by the pre-tag
|
|
100
|
+
* review at 1,200,020 → 67 bytes, same as the symlink case. `git` cannot
|
|
101
|
+
* deliver a hardlink (its index stores no such mode) but `tar x` can.
|
|
102
|
+
*
|
|
103
|
+
* Callers must NOT pass `O_TRUNC`: truncation would happen on the open, before
|
|
104
|
+
* any of this could look. Truncate through the returned descriptor instead.
|
|
105
|
+
* @returns {number|null} an open fd the caller must close, or null if refused
|
|
106
|
+
*/
|
|
107
|
+
function openOwned(file, flags) {
|
|
108
|
+
const fd = fs.openSync(file, flags | O_NOFOLLOW);
|
|
109
|
+
let st;
|
|
110
|
+
try {
|
|
111
|
+
st = fs.fstatSync(fd);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
fs.closeSync(fd);
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
if (!st.isFile()) {
|
|
117
|
+
warnNotOwned(file, 'is not a regular file');
|
|
118
|
+
fs.closeSync(fd);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
if (st.nlink > 1) {
|
|
122
|
+
warnNotOwned(file, 'has more than one hard link (the write would reach another path)');
|
|
123
|
+
fs.closeSync(fd);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return fd;
|
|
127
|
+
}
|
|
128
|
+
|
|
30
129
|
/**
|
|
31
130
|
* Best-effort size-based rotation: if `file` exceeds ROTATE_MAX_BYTES, rewrite
|
|
32
131
|
* it keeping ~the last ROTATE_KEEP_BYTES, trimmed *forward* to the next line
|
|
@@ -42,7 +141,16 @@ function rotateIfNeeded(file) {
|
|
|
42
141
|
const start = Math.max(0, buf.length - ROTATE_KEEP_BYTES);
|
|
43
142
|
const nl = buf.indexOf(0x0a, start); // first newline at/after start
|
|
44
143
|
const trimStart = nl >= 0 ? nl + 1 : start;
|
|
45
|
-
|
|
144
|
+
// No O_TRUNC: it would empty the target on the open, before `openOwned`
|
|
145
|
+
// could refuse it. Truncate through the descriptor once it is vouched for.
|
|
146
|
+
const fd = openOwned(file, fs.constants.O_WRONLY);
|
|
147
|
+
if (fd === null) return;
|
|
148
|
+
try {
|
|
149
|
+
fs.ftruncateSync(fd, 0);
|
|
150
|
+
fs.writeSync(fd, buf.subarray(trimStart));
|
|
151
|
+
} finally {
|
|
152
|
+
fs.closeSync(fd);
|
|
153
|
+
}
|
|
46
154
|
} catch {
|
|
47
155
|
/* missing file or IO error → skip; the append below still runs */
|
|
48
156
|
}
|
|
@@ -58,17 +166,36 @@ function recordRecommendation(cwd, event = {}) {
|
|
|
58
166
|
try {
|
|
59
167
|
const dir = path.join(cwd, '.code-graph');
|
|
60
168
|
// Append-only: do NOT create .code-graph. Its absence means "not an indexed
|
|
61
|
-
// project" — recording there would pollute non-project cwds.
|
|
62
|
-
|
|
169
|
+
// project" — recording there would pollute non-project cwds. `lstat`, not
|
|
170
|
+
// `existsSync`: a symlinked `.code-graph` holding perfectly ordinary files
|
|
171
|
+
// defeats the per-file guard below, because the write then lands on a real
|
|
172
|
+
// regular file that simply is not where this hook thinks it is.
|
|
173
|
+
let dirStat;
|
|
174
|
+
try {
|
|
175
|
+
dirStat = fs.lstatSync(dir);
|
|
176
|
+
} catch {
|
|
177
|
+
return false; // absent → not an indexed project
|
|
178
|
+
}
|
|
179
|
+
if (!isOwnedPath(dir, 'dir', dirStat)) return false;
|
|
63
180
|
// Opt-in metrics silence for dev/dogfood checkouts: when the project marks
|
|
64
181
|
// itself with `.code-graph/.no-metrics`, the tool's own hook/CLI runs (sims,
|
|
65
182
|
// functionality testing) must not self-pollute its adoption metrics. Mirrors
|
|
66
183
|
// the Rust cli::record_cli_use guard. Reversible: delete the file to re-enable.
|
|
67
184
|
if (fs.existsSync(path.join(dir, NO_METRICS_FILE))) return false;
|
|
68
185
|
const file = path.join(dir, REC_FILE);
|
|
186
|
+
if (!isOwnedPath(file, 'file')) return false;
|
|
69
187
|
rotateIfNeeded(file); // rotate-before-append so the file never exceeds ~max + one line
|
|
70
188
|
const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
|
|
71
|
-
|
|
189
|
+
const fd = openOwned(
|
|
190
|
+
file,
|
|
191
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND
|
|
192
|
+
);
|
|
193
|
+
if (fd === null) return false;
|
|
194
|
+
try {
|
|
195
|
+
fs.writeSync(fd, line);
|
|
196
|
+
} finally {
|
|
197
|
+
fs.closeSync(fd);
|
|
198
|
+
}
|
|
72
199
|
return true;
|
|
73
200
|
} catch {
|
|
74
201
|
return false;
|
|
@@ -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.
|
|
38
|
+
npx -y -p @sdsrs/code-graph@0.132.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.
|
|
3
|
+
"version": "0.132.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.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.132.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.132.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.132.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.132.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.132.0"
|
|
43
43
|
}
|
|
44
44
|
}
|