@sdsrs/code-graph 0.163.0 → 0.164.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.
@@ -25,7 +25,9 @@ if (require.main === module) require('./hook-fail-open').installHookFailOpen('St
25
25
  //
26
26
  // Silent when: `stop_hook_active` (a continuation this or another Stop hook
27
27
  // caused — never loop), no session edit log, no index, not a git work tree,
28
- // no signature changed, the language has no exact header reading (LANGS),
28
+ // no signature changed (optional parameters appended at the end of a list do
29
+ // not count: every existing call stays valid), the language has no exact
30
+ // header reading (LANGS),
29
31
  // every caller's file was touched this turn, or the symbol was already
30
32
  // reported this session. CODE_GRAPH_QUIET_HOOKS=1 silences it like the other
31
33
  // injecting hooks.
@@ -141,8 +143,54 @@ function extractSignatures(text, symbol, ext = '') {
141
143
  const lang = LANGS.get(String(ext).toLowerCase());
142
144
  if (!lang) return null;
143
145
  if (typeof text !== 'string' || !symbol) return [];
144
- const lineStarts = new Set();
145
- for (const { re, typed } of definitionPatterns(escapeRe(symbol), lang)) {
146
+ const starts = definitionStarts(text, lang, escapeRe(symbol), symbol);
147
+ if (starts === null) return null;
148
+ return headersAt(text, starts.get(symbol) || [], lang);
149
+ }
150
+
151
+ // The name slot of definitionPatterns when every definition is wanted.
152
+ const ANY_NAME = '([A-Za-z_$][\\w$]*)';
153
+ // Words the name slot must not take: the control words that open a
154
+ // `word (…) {` line, which the JS one-line-method pattern reads as a method.
155
+ // Not NOT_A_TYPE: `from`, `use`, `match` are ordinary method names (`fn from`).
156
+ const NOT_A_NAME = new Set([
157
+ 'if', 'for', 'while', 'switch', 'catch', 'with', 'function', 'foreach', 'elseif',
158
+ 'synchronized', 'using', 'lock', 'fixed', 'until', 'unless', 'return', 'await',
159
+ 'yield', 'typeof', 'sizeof', 'new', 'super', 'this',
160
+ ]);
161
+
162
+ /**
163
+ * Every definition in `text` with its signatures (Q1): Map name → sorted keys,
164
+ * in order of first appearance in the file — for an edit that names no
165
+ * definition (Write, `sed -i`). One pass per pattern; each name reads exactly
166
+ * as extractSignatures(text, name) reads it, and a name whose header has no
167
+ * reading is left out. null: the language is not in LANGS, or a definition
168
+ * sits on a line over MAX_DEFINITION_LINE_CHARS (for every name at once: the
169
+ * scan stops there, as extractSignatures does).
170
+ * @returns {Map<string,string[]>|null}
171
+ */
172
+ function allSignatures(text, ext = '') {
173
+ const lang = LANGS.get(String(ext).toLowerCase());
174
+ if (!lang || typeof text !== 'string') return null;
175
+ const starts = definitionStarts(text, lang, ANY_NAME, null);
176
+ if (starts === null) return null;
177
+ const byFirst = [...starts].sort((a, b) => Math.min(...a[1]) - Math.min(...b[1]));
178
+ const out = new Map();
179
+ for (const [name, lineStarts] of byFirst) {
180
+ const sigs = headersAt(text, lineStarts, lang);
181
+ if (sigs && sigs.length > 0) out.set(name, sigs);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ /**
187
+ * Where definitions start: Map name → Set of line-start offsets. With `symbol`
188
+ * the patterns carry it escaped in `S`; without, `S` is ANY_NAME and the name
189
+ * is the pattern's last group. null when a match sits on an over-long line.
190
+ */
191
+ function definitionStarts(text, lang, S, symbol) {
192
+ const out = new Map();
193
+ for (const { re, typed } of definitionPatterns(S, lang)) {
146
194
  for (const m of text.matchAll(re)) {
147
195
  const at = text.lastIndexOf('\n', m.index) + 1;
148
196
  const lineEnd = text.indexOf('\n', at);
@@ -158,9 +206,16 @@ function extractSignatures(text, symbol, ext = '') {
158
206
  // `:` is a label or access specifier, never part of a return type.
159
207
  if (m[1].split(/[ \t]+/).some((tok) => /(?:^|[^:]):$/.test(tok))) continue;
160
208
  }
161
- lineStarts.add(at);
209
+ const name = symbol || m[m.length - 1];
210
+ if (!symbol && NOT_A_NAME.has(name)) continue;
211
+ if (!out.has(name)) out.set(name, new Set());
212
+ out.get(name).add(at);
162
213
  }
163
214
  }
215
+ return out;
216
+ }
217
+
218
+ function headersAt(text, lineStarts, lang) {
164
219
  const out = [];
165
220
  for (const at of lineStarts) {
166
221
  const header = readHeader(text, at, lang);
@@ -212,15 +267,120 @@ function signatureKey(header) {
212
267
  * True when a definition that existed before is gone: its header changed, or
213
268
  * it was removed while others of the same name remain. A definition ADDED
214
269
  * beside unchanged ones (a new overload, a new `impl From<B>`) changes no
215
- * existing caller's target, so it is not a change (review M2).
270
+ * existing caller's target, so it is not a change (review M2). Nor is a header
271
+ * whose only difference is optional parameters appended to its parameter list
272
+ * (Q2, see appendsOptionalParams) — every existing call stays valid. That
273
+ * reading needs `symbol` to find the parameter list; without it every
274
+ * difference counts.
216
275
  */
217
- function signatureChanged(oldSigs, newSigs) {
276
+ function signatureChanged(oldSigs, newSigs, symbol = null) {
218
277
  const remaining = new Map();
219
278
  for (const s of newSigs) remaining.set(s, (remaining.get(s) || 0) + 1);
279
+ const unmatched = [];
220
280
  for (const s of oldSigs) {
221
281
  const n = remaining.get(s) || 0;
222
- if (n === 0) return true;
223
- remaining.set(s, n - 1);
282
+ if (n === 0) unmatched.push(s);
283
+ else remaining.set(s, n - 1);
284
+ }
285
+ if (unmatched.length === 0) return false;
286
+ if (!symbol) return true;
287
+ // Each old header may pair with one new header that only extends it.
288
+ const pool = [];
289
+ for (const [s, n] of remaining) for (let k = 0; k < n; k++) pool.push(s);
290
+ for (const s of unmatched) {
291
+ const at = pool.findIndex((t) => appendsOptionalParams(s, t, symbol));
292
+ if (at === -1) return true;
293
+ pool.splice(at, 1);
294
+ }
295
+ return false;
296
+ }
297
+
298
+ /**
299
+ * True when signature key `newKey` is `oldKey` with parameters inserted just
300
+ * before the `)` that closes `symbol`'s parameter list, and every inserted
301
+ * parameter is optional at a call site (isOptionalParam). Anything else that
302
+ * moved — a return type, a parameter before the end, a parameter list inside
303
+ * a type — makes it false. Keys are whitespace-free (signatureKey).
304
+ */
305
+ function appendsOptionalParams(oldKey, newKey, symbol) {
306
+ if (newKey.length <= oldKey.length) return false;
307
+ let i = 0;
308
+ while (i < oldKey.length && oldKey[i] === newKey[i]) i++;
309
+ if (oldKey[i] !== ')') return false;
310
+ const tail = oldKey.slice(i);
311
+ if (!newKey.endsWith(tail)) return false;
312
+ // The `(` this `)` closes.
313
+ let open = -1;
314
+ for (let j = i - 1, depth = 0; j >= 0; j--) {
315
+ if (oldKey[j] === ')') depth++;
316
+ else if (oldKey[j] === '(') {
317
+ if (depth === 0) { open = j; break; }
318
+ depth--;
319
+ }
320
+ }
321
+ if (open === -1) return false;
322
+ // A parameter list opens right after the name, its generics, or — for a JS
323
+ // binding — `name = [async] [function]`. A `(` after `->`, `:` or `)` opens
324
+ // a return type or a Go receiver/result list, never the parameters.
325
+ const S = escapeRe(symbol);
326
+ const opener = new RegExp(`${S}(?:<.*>|\\[.*\\])?(?:=(?:async)?(?:function\\*?)?)?$`);
327
+ if (!opener.test(oldKey.slice(0, open))) return false;
328
+ let inserted = newKey.slice(i, newKey.length - tail.length);
329
+ if (open < i - 1) {
330
+ if (inserted[0] !== ',') return false;
331
+ inserted = inserted.slice(1);
332
+ }
333
+ const params = splitTopLevel(inserted);
334
+ return params.length > 0 && params.every(isOptionalParam);
335
+ }
336
+
337
+ // +1 for an opening bracket at s[i], -1 for a closing one, else 0. The `>` of
338
+ // `=>` / `->` closes nothing.
339
+ function bracketStep(s, i) {
340
+ const c = s[i];
341
+ if ('([{<'.includes(c)) return 1;
342
+ if (')]}'.includes(c) || (c === '>' && s[i - 1] !== '=' && s[i - 1] !== '-')) return -1;
343
+ return 0;
344
+ }
345
+
346
+ // Splits at commas outside brackets.
347
+ function splitTopLevel(s) {
348
+ const out = [];
349
+ let depth = 0;
350
+ let start = 0;
351
+ for (let i = 0; i < s.length; i++) {
352
+ const step = bracketStep(s, i);
353
+ if (step !== 0) depth = Math.max(0, depth + step);
354
+ else if (s[i] === ',' && depth === 0) {
355
+ out.push(s.slice(start, i));
356
+ start = i + 1;
357
+ }
358
+ }
359
+ out.push(s.slice(start));
360
+ return out;
361
+ }
362
+
363
+ /**
364
+ * A parameter a caller may leave out: a default value (`x = 1`, `int y = 0`,
365
+ * `strict: bool = False`), TS `x?: T`, a variadic (`*args`, `**kwargs`, a bare
366
+ * `*` marker, `...rest`, Go `opts ...T`, Java `T... xs`). Whitespace is
367
+ * already gone from the key.
368
+ */
369
+ function isOptionalParam(p) {
370
+ if (!p) return false;
371
+ if (p[0] === '*') return true;
372
+ let depth = 0;
373
+ for (let i = 0; i < p.length; i++) {
374
+ const c = p[i];
375
+ const step = bracketStep(p, i);
376
+ if (step !== 0) { depth = Math.max(0, depth + step); continue; }
377
+ if (depth !== 0) continue;
378
+ if (c === '.' && p.startsWith('...', i)) return true;
379
+ if (c === '?' && (p[i + 1] === ':' || i === p.length - 1)) return true;
380
+ // The first depth-0 `=` is the default, unless it starts `=>` (an arrow
381
+ // type). No `==`/`>=` can come earlier; `number>=new Map()` is a generic's
382
+ // `>` then a default.
383
+ if (c === '=' && p[i + 1] !== '>') return true;
224
384
  }
225
385
  return false;
226
386
  }
@@ -286,7 +446,8 @@ function computeStopReport({ edits, state, now, workText, callers, mtimeMs }) {
286
446
  if (!Array.isArray(e.sigs)) continue;
287
447
  const after = extractSignatures(workText(e.file), e.symbol, path.extname(e.file).toLowerCase());
288
448
  if (!after || after.length === 0) continue; // removed, or no reading now
289
- if (!signatureChanged(e.sigs, after)) continue; // body-only this turn
449
+ // Body-only this turn, or only optional parameters appended (Q2).
450
+ if (!signatureChanged(e.sigs, after, e.symbol)) continue;
290
451
  changed.push({ key, file: e.file, symbol: e.symbol });
291
452
  }
292
453
 
@@ -491,6 +652,6 @@ function runMain() {
491
652
  if (require.main === module) runMain();
492
653
 
493
654
  module.exports = {
494
- extractSignatures, signatureChanged, findCallSiteLine, computeStopReport, formatStopContext,
655
+ extractSignatures, allSignatures, signatureChanged, findCallSiteLine, computeStopReport, formatStopContext,
495
656
  followUpOf, reportRecords, MAX_SYMBOLS_CHECKED, MAX_CALLERS_LISTED, MAX_FOLLOWUP_FILES,
496
657
  };
@@ -42,6 +42,7 @@ const { hidden } = require('./proc-opts');
42
42
  // must not load (startup budget); it used to be a third hand-spelled copy of the
43
43
  // same three path segments (JS-05).
44
44
  const { MANIFEST_FILE: MANIFEST_PATH } = require('./cache-paths');
45
+ const { capContext } = require('./hook-emit');
45
46
 
46
47
  // --- Per-type rate limiting (replaces single global cooldown) ---
47
48
  const COOLDOWNS = {
@@ -56,20 +57,26 @@ const COOLDOWNS = {
56
57
  // here, so an un-scoped flag was effectively a machine-wide mute: one `impact`
57
58
  // push in any repo silenced the next 30s of impact pushes in every other repo,
58
59
  // and `overview` did it for five minutes.
59
- function ctxFlagPath(type, cwd) {
60
- return path.join(cgTmpDir(), `.code-graph-ctx-${cwdHash(cwd)}-${type}`);
60
+ //
61
+ // Keyed on (type, subject) when there is a subject — the symbol or path the
62
+ // query is about. A type-only key let an impact push for one symbol swallow
63
+ // the impact query for a DIFFERENT symbol asked a second later (hook audit
64
+ // 2026-09-28). The subject is hashed: symbols and paths are not file-name safe.
65
+ function ctxFlagPath(type, cwd, key) {
66
+ const suffix = key ? `-${cwdHash(String(key)).slice(0, 8)}` : '';
67
+ return path.join(cgTmpDir(), `.code-graph-ctx-${cwdHash(cwd)}-${type}${suffix}`);
61
68
  }
62
69
 
63
- function isCoolingDown(type, cwd = process.cwd()) {
70
+ function isCoolingDown(type, cwd = process.cwd(), key) {
64
71
  try {
65
- const stat = fs.statSync(ctxFlagPath(type, cwd));
72
+ const stat = fs.statSync(ctxFlagPath(type, cwd, key));
66
73
  return Date.now() - stat.mtimeMs < (COOLDOWNS[type] || 60000);
67
74
  } catch { return false; }
68
75
  }
69
76
 
70
- function markCooldown(type, cwd = process.cwd()) {
77
+ function markCooldown(type, cwd = process.cwd(), key) {
71
78
  try {
72
- fs.writeFileSync(ctxFlagPath(type, cwd), '');
79
+ fs.writeFileSync(ctxFlagPath(type, cwd, key), '');
73
80
  } catch { /* ok */ }
74
81
  }
75
82
 
@@ -111,8 +118,14 @@ const STOP_WORDS = new Set([
111
118
 
112
119
  const PLAIN_WORD_EXCLUDE = /^(possible|together|actually|something|different|important|following|available|necessary|currently|implement|operation|otherwise|beginning|knowledge|attention|according|certainly|sometimes|direction|recommend|structure|describe|question|complete|generate|anything|continue|consider|response|approach|happened|recently|probably|expected|previous|original|specific|directly|received|required|supposed|separate|designed|finished|provided|included|prepared|combined|properly|remember|whatever|although|document|handling|existing|everyone|standard|research|personal|relative|absolute|practice|language|thousand|national|evidence|refactor|understand|validate|analysis|debugging|configure|improving|resolving|creating|building|checking|updating|removing|changing|searching|cleaning|optimize|migration|overview|introduce|reviewing|thinking|managing|starting|yourself|features|problems|breaking|requires|argument|settings|includes|examples|comments|patterns|tutorial|concepts|supports|priority|organize|scenario|tracking|internal|external|abstract|concrete|strategy|evaluate|diagnose|platform|variable|optional|multiple)$/;
113
120
 
121
+ // Text the harness submits on the user's behalf: background-task
122
+ // notifications, teammate / agent / cross-session messages, reminders. 42 of
123
+ // 175 real injections (24%) fired on these, answering a question nobody asked.
124
+ const HARNESS_PROMPT = /^(?:<(?:task-notification|teammate-message|agent-message|cross-session-message|system-reminder|local-command-(?:stdout|stderr|caveat)|command-(?:name|message|args)|bash-(?:input|stdout|stderr))\b|Another Claude session sent a message:)/;
125
+
114
126
  function shouldSkip(msg) {
115
127
  const trimmed = msg.trim();
128
+ if (HARNESS_PROMPT.test(trimmed)) return 'harness';
116
129
  if (/^(yes|no|ok|commit|push|y|n|done|thanks|thank you|继续|确认|好的|好|是的|不|可以|行|对|提交|推送|没问题|谢谢|发布|更新|编译|安装|卸载|重启|重连|清理)\s*[.!?。!?]?\s*$/i.test(trimmed)) return 'simple';
117
130
  if (/^(修复|实施|执行|开始|按|实测|进入|用|重新)/.test(trimmed) && !/[a-zA-Z_]{3,}/.test(trimmed)) return 'action-only';
118
131
  return false;
@@ -397,11 +410,23 @@ function determineQueryType(intents, symbols, filePaths, isCoolingDownFn, messag
397
410
 
398
411
  const cd = isCoolingDownFn || (() => false);
399
412
 
400
- if ((intents.impact || intents.modify) && hasStrict && !cd('impact')) return { type: 'impact', symbol: symbols.symbols[0] };
401
- if (intents.callgraph && hasStrict && !cd('callgraph')) return { type: 'callgraph', symbol: symbols.symbols[0] };
402
- if (filePaths.length > 0 && !cd('overview')) return { type: 'overview', path: filePaths[0].replace(/\/[^/]+$/, '/') };
403
- if ((intents.search || intents.implement || hasQualified) && symbols.symbols.length > 0 && !cd('search')) return { type: 'search', symbol: symbols.symbols[0] };
404
- if ((intents.understand || !hasAny) && symbols.symbols.length > 0 && !cd('search')) return { type: 'search', symbol: symbols.symbols[0] };
413
+ const sym = symbols.symbols[0];
414
+ // The file the prompt names disambiguates a same-named symbol (`impact
415
+ // url_for` is ambiguous in flask; `--file src/flask/helpers.py` answers).
416
+ const file = filePaths[0];
417
+
418
+ if ((intents.impact || intents.modify) && hasStrict && !cd('impact', sym)) return { type: 'impact', symbol: sym, ...(file ? { file } : {}) };
419
+ if (intents.callgraph && hasStrict && !cd('callgraph', sym)) return { type: 'callgraph', symbol: sym };
420
+ // The named file itself, not its parent dir: `src/context.ts` used to yield
421
+ // an overview of all of src/ — 18,497 chars, none of it about that file.
422
+ if (file && !cd('overview', file)) return { type: 'overview', path: file };
423
+ // Search only for an identifier-shaped name, like impact and callgraph. A
424
+ // plain English word (`lowConfidence`) searched as a concept injected code
425
+ // unrelated to the prompt (`undefined` → JSX attribute types, `networkx` →
426
+ // doc headings; 2026-09-28 usage evaluation B8), and UPS search injections
427
+ // were followed up 2 times in 51.
428
+ if ((intents.search || intents.implement || hasQualified) && hasStrict && !cd('search', sym)) return { type: 'search', symbol: sym };
429
+ if ((intents.understand || !hasAny) && hasStrict && !cd('search', sym)) return { type: 'search', symbol: sym };
405
430
 
406
431
  // Phase E fallback: nothing actionable above, but message has symptom phrasing.
407
432
  // Emit ONE-LINE prose hint (no CLI execution). Default-empty `message` keeps
@@ -501,7 +526,7 @@ function runMain() {
501
526
  const intents = detectIntents(message);
502
527
  // Key the cooldowns on the resolved project root, not the raw shell cwd, so a
503
528
  // `cd` into a subdir does not read as a different project and re-fire.
504
- const query = determineQueryType(intents, symbols, filePaths, (type) => isCoolingDown(type, cwd), message);
529
+ const query = determineQueryType(intents, symbols, filePaths, (type, key) => isCoolingDown(type, cwd, key), message);
505
530
 
506
531
  if (!query) return;
507
532
 
@@ -510,12 +535,16 @@ function runMain() {
510
535
  if (query.type === 'symptom-hint') {
511
536
  markCooldown('symptom', cwd);
512
537
  process.stdout.write(
513
- '[code-graph:hint] indexed repo — for vague-symptom prompts, try `semantic_code_search "<symptom>"` ' +
538
+ '[code-graph:hint] indexed repo — for vague-symptom prompts, try `code-graph-mcp search "<symptom words>"` ' +
514
539
  'or `module_overview <suspected-dir>` to surface candidate code structurally. Skip if not searching code.\n'
515
540
  );
516
541
  return;
517
542
  }
518
543
 
544
+ // A partial index answers wrongly, not just briefly (D5). Return before the
545
+ // cooldown so the same question is answered once the build is done.
546
+ if (require('./project-root').indexBuildInProgress(cwd)) return;
547
+
519
548
  const PREFIXES = {
520
549
  impact: '[code-graph:impact] Blast radius — review before editing:',
521
550
  overview: '[code-graph:structure] Module structure:',
@@ -556,17 +585,20 @@ function runMain() {
556
585
  // the same shape re-ran it — a 3s execFileSync timeout on EVERY turn, which is
557
586
  // the worst case wearing the cheapest disguise. The cooldown's job is rate
558
587
  // limiting; whether the run had something to say is a separate question.
559
- markCooldown(query.type, cwd);
588
+ markCooldown(query.type, cwd, query.symbol || query.path);
560
589
 
561
590
  try {
562
591
  let result = '';
563
- if (query.type === 'impact') result = run(['impact', query.symbol]);
592
+ if (query.type === 'impact') result = run(['impact', query.symbol, ...(query.file ? ['--file', query.file] : [])]);
564
593
  else if (query.type === 'callgraph') result = run(['callgraph', query.symbol, '--depth', '2']);
565
594
  else if (query.type === 'overview') result = run(['overview', query.path]);
566
595
  else if (query.type === 'search') result = run(['search', query.symbol, '--limit', '8']);
567
596
 
568
597
  if (result && result.trim()) {
569
- process.stdout.write(`${PREFIXES[query.type]}\n${result.trim()}\n`);
598
+ // The shared 4,000-byte context budget. Written raw, a dir overview came
599
+ // back at 18,497 chars; past 10,000 Claude Code swaps the whole output
600
+ // for a file path and a preview.
601
+ process.stdout.write(capContext(`${PREFIXES[query.type]}\n${result.trim()}\n`));
570
602
  }
571
603
  } catch {
572
604
  /* return silently */
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: explore
3
3
  description: |
4
- Understand code structure efficiently using the AST index. Use BEFORE reading
5
- files one by one — when starting work in unfamiliar code, exploring a module
6
- before changes, or finding the right file to edit. One overview call replaces
7
- 5+ Read calls and saves significant context.
4
+ Understand code structure with the AST index before reading files one by
5
+ one — when starting work in unfamiliar code, exploring a module before a
6
+ change, or finding which file to edit. An overview lists a directory's
7
+ symbols and their callers in one call.
8
8
  ---
9
9
 
10
10
  # Explore Code (indexed project)
@@ -16,7 +16,10 @@ Use these BEFORE reading individual files:
16
16
  | Module structure | `code-graph-mcp overview <dir>` | 5+ Read calls |
17
17
  | Project architecture | `code-graph-mcp map --compact` | ls + README |
18
18
  | Who calls / what calls | `code-graph-mcp callgraph <symbol>` | Grep + manual trace |
19
- | Find by concept | `code-graph-mcp search "concept"` | 3+ Grep attempts |
20
- | Impact before edit | `code-graph-mcp impact <symbol>` | Grep for callers |
19
+ | Find by concept | `code-graph-mcp search "concept"` (full-text) | 3+ Grep attempts |
20
+ | Impact before a signature change | `code-graph-mcp impact <symbol>` | Grep for callers |
21
+ | Every use (rename / remove) | `code-graph-mcp refs <symbol>`, then `grep -w` | Grep alone |
21
22
 
22
- **Workflow**: overview first → Read only the file you will edit.
23
+ **Workflow**: overview first → Read only the file you will edit. Calls the graph could
24
+ not resolve (dynamic dispatch, reflection, unresolved imports) are not in it, so confirm
25
+ an empty answer with grep.
@@ -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.163.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.164.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