@shrkcrft/graph 0.1.0-alpha.17 → 0.1.0-alpha.19

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.
@@ -1,7 +1,31 @@
1
+ import { statSync } from 'node:fs';
2
+ import * as nodePath from 'node:path';
1
3
  import { EdgeKind } from "../schema/edge-kind.js";
2
4
  import { NodeKind } from "../schema/node-kind.js";
3
5
  import { GraphStore } from "../store/graph-store.js";
6
+ import { fingerprintFile } from "../store/file-fingerprint.js";
4
7
  import { findFileCycles } from "./cycle-detection.js";
8
+ /** Read the representative source line stored on a reference/call edge. */
9
+ function edgeLine(e) {
10
+ const v = e.data?.['line'];
11
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
12
+ }
13
+ /**
14
+ * Forward "code uses code" edge kinds traversed by {@link GraphQueryApi.pathBetween}.
15
+ * Structural source-level dependencies only — package-aggregate edges
16
+ * (`belongs-to-package` / `package-depends-on`) and asset-bridge edges
17
+ * (rules / framework) are deliberately excluded so a path is always a real
18
+ * import/call/reference/heritage chain, not a routing through a package node.
19
+ */
20
+ const CODE_PATH_EDGE_KINDS = new Set([
21
+ EdgeKind.ImportsFile,
22
+ EdgeKind.CallsSymbol,
23
+ EdgeKind.ReferencesSymbol,
24
+ EdgeKind.DeclaresSymbol,
25
+ EdgeKind.ReExportsSymbol,
26
+ EdgeKind.ExtendsSymbol,
27
+ EdgeKind.ImplementsSymbol,
28
+ ]);
5
29
  /**
6
30
  * Read-only query layer over an in-memory graph snapshot.
7
31
  *
@@ -187,6 +211,117 @@ export class GraphQueryApi {
187
211
  }
188
212
  return out;
189
213
  }
214
+ /**
215
+ * Symbols that EXTEND or IMPLEMENT the given symbol — the precise "who
216
+ * implements this interface / who subclasses this" answer that a generic
217
+ * reference cannot give (a reference might be a call, a type annotation, or
218
+ * a heritage clause; these are typed `extends-symbol`/`implements-symbol`
219
+ * edges only). Returns the subtype symbol nodes.
220
+ */
221
+ subtypesOf(symbolNodeId) {
222
+ const edges = this.inByTo.get(symbolNodeId) ?? [];
223
+ const out = [];
224
+ for (const e of edges) {
225
+ if (e.kind !== EdgeKind.ExtendsSymbol && e.kind !== EdgeKind.ImplementsSymbol)
226
+ continue;
227
+ const n = this.snap.nodes.get(e.from);
228
+ if (n)
229
+ out.push(n);
230
+ }
231
+ return out;
232
+ }
233
+ /** Symbols the given symbol EXTENDS or IMPLEMENTS (its supertypes). */
234
+ supertypesOf(symbolNodeId) {
235
+ const edges = this.outByFrom.get(symbolNodeId) ?? [];
236
+ const out = [];
237
+ for (const e of edges) {
238
+ if (e.kind !== EdgeKind.ExtendsSymbol && e.kind !== EdgeKind.ImplementsSymbol)
239
+ continue;
240
+ const n = this.snap.nodes.get(e.to);
241
+ if (n)
242
+ out.push(n);
243
+ }
244
+ return out;
245
+ }
246
+ /**
247
+ * Like {@link callersOf}, but keeps the call-site line carried on each
248
+ * edge so a caller can render `path:line` (jump-straight-to-source)
249
+ * instead of just the file path — the difference between "grep, then grep
250
+ * again inside each file" and a direct hit.
251
+ */
252
+ callerSitesOf(symbolNodeId) {
253
+ const edges = this.inByTo.get(symbolNodeId) ?? [];
254
+ const out = [];
255
+ for (const e of edges) {
256
+ if (e.kind !== EdgeKind.CallsSymbol)
257
+ continue;
258
+ const n = this.snap.nodes.get(e.from);
259
+ if (n)
260
+ out.push({ node: n, line: edgeLine(e) });
261
+ }
262
+ return out;
263
+ }
264
+ /** Like {@link referencesOf}, but keeps the representative use-site line. */
265
+ referenceSitesOf(symbolNodeId) {
266
+ const edges = this.inByTo.get(symbolNodeId) ?? [];
267
+ const out = [];
268
+ const seen = new Set();
269
+ for (const e of edges) {
270
+ if (e.kind !== EdgeKind.ReferencesSymbol && e.kind !== EdgeKind.CallsSymbol)
271
+ continue;
272
+ if (seen.has(e.from))
273
+ continue;
274
+ seen.add(e.from);
275
+ const n = this.snap.nodes.get(e.from);
276
+ if (n)
277
+ out.push({ node: n, line: edgeLine(e) });
278
+ }
279
+ return out;
280
+ }
281
+ /**
282
+ * Targeted, cheap staleness check over a handful of RESULT file paths (the
283
+ * declaring file + caller/dependent files of a query) — NOT a full tree
284
+ * walk. For each project-relative path that the index knows about: stat it
285
+ * (mtime+size gate, sha1 only on mismatch) and classify as `modified`
286
+ * (content changed → results may be wrong: stale lines / a removed caller
287
+ * still listed) or `deleted` (gone from disk → drop it). Lets a query flag
288
+ * or prune a silently-stale answer for a file the agent just edited without
289
+ * paying for a whole-repo freshness walk. `cwd` is passed explicitly: the
290
+ * snapshot's index-time absolute root is wrong if the repo moved.
291
+ */
292
+ staleFilesAmong(cwd, paths) {
293
+ const modified = [];
294
+ const deleted = [];
295
+ const seen = new Set();
296
+ for (const rel of paths) {
297
+ if (seen.has(rel))
298
+ continue;
299
+ seen.add(rel);
300
+ const fp = this.snap.files.get(rel);
301
+ if (!fp)
302
+ continue; // not an indexed file — nothing to compare against
303
+ const abs = nodePath.isAbsolute(rel) ? rel : nodePath.join(cwd, rel);
304
+ let st;
305
+ try {
306
+ st = statSync(abs);
307
+ }
308
+ catch {
309
+ deleted.push(rel);
310
+ continue;
311
+ }
312
+ if (!st.isFile()) {
313
+ deleted.push(rel);
314
+ continue;
315
+ }
316
+ if (Math.floor(st.mtimeMs) === fp.mtime && st.size === fp.sizeBytes)
317
+ continue; // fresh
318
+ if (fingerprintFile(abs, cwd).sha1 !== fp.sha1)
319
+ modified.push(rel);
320
+ }
321
+ modified.sort((a, b) => a.localeCompare(b));
322
+ deleted.sort((a, b) => a.localeCompare(b));
323
+ return { modified, deleted };
324
+ }
190
325
  /** Packages that this package depends on (PackageDependsOn). */
191
326
  packageDeps(packageName) {
192
327
  const edges = this.outByFrom.get(`package:${packageName}`) ?? [];
@@ -248,6 +383,205 @@ export class GraphQueryApi {
248
383
  }),
249
384
  };
250
385
  }
386
+ /**
387
+ * Shortest directed code path from `fromId` to `toId` over the
388
+ * "code uses code" edges (imports / calls / references / declares /
389
+ * re-exports / extends / implements). Answers "is A actually wired to B?"
390
+ * deterministically: a found path lists every hop with its edge kind (and
391
+ * call-site line where the edge carries one) so a caller sees HOW they are
392
+ * wired — a chain of `imports-file` hops reads very differently from a
393
+ * `calls-symbol` one. Breadth-first, so the returned path is minimal-hop;
394
+ * `explored` reports how many nodes were visited so a "no path" answer is
395
+ * honestly bounded rather than read as "definitely unrelated".
396
+ */
397
+ pathBetween(fromId, toId, opts = {}) {
398
+ const maxDepth = opts.maxDepth && opts.maxDepth > 0 ? opts.maxDepth : 16;
399
+ const from = this.snap.nodes.get(fromId);
400
+ const to = this.snap.nodes.get(toId);
401
+ if (!from || !to) {
402
+ return {
403
+ found: false,
404
+ ...(from ? { from } : {}),
405
+ ...(to ? { to } : {}),
406
+ hops: [],
407
+ explored: 0,
408
+ reason: !from && !to
409
+ ? 'neither endpoint is in the graph'
410
+ : !from
411
+ ? 'source node is not in the graph'
412
+ : 'target node is not in the graph',
413
+ };
414
+ }
415
+ if (fromId === toId)
416
+ return { found: true, from, to, hops: [], explored: 1 };
417
+ // BFS. `parent` maps a discovered node id to the edge that first reached it.
418
+ const parent = new Map();
419
+ const depth = new Map([[fromId, 0]]);
420
+ const queue = [fromId];
421
+ let head = 0;
422
+ let explored = 0;
423
+ while (head < queue.length) {
424
+ const cur = queue[head++];
425
+ explored++;
426
+ const d = depth.get(cur);
427
+ if (d >= maxDepth)
428
+ continue;
429
+ for (const e of this.outByFrom.get(cur) ?? []) {
430
+ if (!CODE_PATH_EDGE_KINDS.has(e.kind))
431
+ continue;
432
+ if (e.to === cur || e.to === fromId || parent.has(e.to))
433
+ continue;
434
+ parent.set(e.to, e);
435
+ depth.set(e.to, d + 1);
436
+ if (e.to === toId) {
437
+ return { found: true, from, to, hops: this.reconstructPath(parent, fromId, toId), explored };
438
+ }
439
+ queue.push(e.to);
440
+ }
441
+ }
442
+ return { found: false, from, to, hops: [], explored, reason: `no code path within ${maxDepth} hops` };
443
+ }
444
+ /** Walk the BFS `parent` map back from `toId` to `fromId` into ordered hops. */
445
+ reconstructPath(parent, fromId, toId) {
446
+ const chain = [];
447
+ let cur = toId;
448
+ while (cur !== fromId) {
449
+ const e = parent.get(cur);
450
+ if (!e)
451
+ break;
452
+ chain.push(e);
453
+ cur = e.from;
454
+ }
455
+ chain.reverse();
456
+ const hops = [];
457
+ for (const e of chain) {
458
+ const f = this.snap.nodes.get(e.from);
459
+ const t = this.snap.nodes.get(e.to);
460
+ if (!f || !t)
461
+ continue;
462
+ const line = edgeLine(e);
463
+ hops.push({ from: f, to: t, kind: e.kind, ...(line !== undefined ? { line } : {}) });
464
+ }
465
+ return hops;
466
+ }
467
+ /**
468
+ * The most-depended-on code in the snapshot: symbols ranked by how many
469
+ * distinct files reference/call them and files ranked by how many distinct
470
+ * files import them. This is the "load-bearing code" view — the surface an
471
+ * agent should change most carefully and a human should understand first.
472
+ * In-degree counts DISTINCT dependent files (a file that calls a symbol
473
+ * ten times counts once), so the rank reflects blast radius, not call volume.
474
+ */
475
+ /**
476
+ * The DIRECT dependents of a node — what breaks if you change it, one hop out.
477
+ * Kind-aware, because the edge that carries "depends on" differs by kind:
478
+ * - Symbol: the files that reference/call it, PLUS the files declaring its
479
+ * subtypes (a class that `import type`s an interface has no value-reference
480
+ * edge, so subtypes must be added explicitly or implementers are missed).
481
+ * Falls back to the importers of its declaring file when nothing references
482
+ * the symbol directly.
483
+ * - File: its importers (`imports-file`).
484
+ * - Package: the packages that depend on it.
485
+ * The shared building block for impact reverse-closures (CLI + MCP) — keep this
486
+ * the ONE source of truth so the two surfaces never disagree on a symbol's
487
+ * blast radius (they once did: an importers-only closure returned NOTHING for a
488
+ * symbol, a confidently-wrong "nothing breaks").
489
+ */
490
+ directDependentsOf(anchor) {
491
+ if (anchor.kind === NodeKind.Symbol) {
492
+ const owner = this.declaringFileOf(anchor.id);
493
+ const out = new Map();
494
+ for (const n of this.referencesOf(anchor.id)) {
495
+ if (n.kind === NodeKind.File && n.id !== owner?.id)
496
+ out.set(n.id, n);
497
+ }
498
+ for (const n of this.callersOf(anchor.id)) {
499
+ if (n.kind === NodeKind.File && n.id !== owner?.id)
500
+ out.set(n.id, n);
501
+ }
502
+ for (const s of this.subtypesOf(anchor.id)) {
503
+ if (!s.path)
504
+ continue;
505
+ const fileNode = this.fileByPath.get(s.path);
506
+ if (fileNode && fileNode.id !== owner?.id)
507
+ out.set(fileNode.id, fileNode);
508
+ }
509
+ if (out.size > 0)
510
+ return [...out.values()];
511
+ return owner ? [...this.importersOf(owner.id)] : [];
512
+ }
513
+ if (anchor.kind === NodeKind.Package) {
514
+ return this.packageDependents(anchor.id.replace(/^package:/, ''));
515
+ }
516
+ return [...this.importersOf(anchor.id)];
517
+ }
518
+ /** The file that declares a symbol (the `declares-symbol` source), if known. */
519
+ declaringFileOf(symbolId) {
520
+ for (const e of this.inByTo.get(symbolId) ?? []) {
521
+ if (e.kind !== EdgeKind.DeclaresSymbol)
522
+ continue;
523
+ const n = this.snap.nodes.get(e.from);
524
+ if (n && n.kind === NodeKind.File)
525
+ return n;
526
+ }
527
+ return undefined;
528
+ }
529
+ topHubs(limit = 10, pathPrefix) {
530
+ // Optional path scope: rank only the load-bearing code WITHIN a subsystem
531
+ // (e.g. `packages/inspector`) — the global hubs are dominated by the biggest
532
+ // packages, but an agent working in one area wants that area's hubs. The
533
+ // in-degree still counts ALL dependents (anyone in the repo), answering
534
+ // "within this dir, what is most depended-on?".
535
+ const prefix = pathPrefix ? pathPrefix.replace(/\\/g, '/').replace(/\/+$/, '') : undefined;
536
+ const inScope = (path) => !prefix || (path !== undefined && (path === prefix || path.startsWith(prefix + '/')));
537
+ const symbolDeps = new Map();
538
+ const fileDeps = new Map();
539
+ for (const [toId, edges] of this.inByTo) {
540
+ const target = this.snap.nodes.get(toId);
541
+ if (!target)
542
+ continue;
543
+ if (!inScope(target.path))
544
+ continue;
545
+ if (target.kind === NodeKind.Symbol) {
546
+ const set = new Set();
547
+ for (const e of edges) {
548
+ if (e.kind === EdgeKind.ReferencesSymbol || e.kind === EdgeKind.CallsSymbol) {
549
+ set.add(e.from); // reference/call edges originate on a file node
550
+ }
551
+ else if (e.kind === EdgeKind.ExtendsSymbol || e.kind === EdgeKind.ImplementsSymbol) {
552
+ // Heritage edges originate on the SUBTYPE symbol — count its file so
553
+ // an interface implemented only via `import type` (no reference edge)
554
+ // still ranks as load-bearing, at the same file granularity.
555
+ const sub = this.snap.nodes.get(e.from);
556
+ set.add(sub?.path ? `file:${sub.path}` : e.from);
557
+ }
558
+ }
559
+ if (set.size > 0)
560
+ symbolDeps.set(toId, set);
561
+ }
562
+ else if (target.kind === NodeKind.File) {
563
+ const set = new Set();
564
+ for (const e of edges) {
565
+ if (e.kind === EdgeKind.ImportsFile)
566
+ set.add(e.from);
567
+ }
568
+ if (set.size > 0)
569
+ fileDeps.set(toId, set);
570
+ }
571
+ }
572
+ const cap = Math.max(0, limit);
573
+ const toHubs = (m) => {
574
+ const arr = [];
575
+ for (const [id, set] of m) {
576
+ const node = this.snap.nodes.get(id);
577
+ if (node)
578
+ arr.push({ node, inDegree: set.size });
579
+ }
580
+ arr.sort((a, b) => b.inDegree - a.inDegree || a.node.label.localeCompare(b.node.label) || a.node.id.localeCompare(b.node.id));
581
+ return arr.slice(0, cap);
582
+ };
583
+ return { symbols: toHubs(symbolDeps), files: toHubs(fileDeps) };
584
+ }
251
585
  }
252
586
  function filterByPackage(list, pkg) {
253
587
  if (!pkg)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shrkcrft/graph",
3
- "version": "0.1.0-alpha.17",
3
+ "version": "0.1.0-alpha.19",
4
4
  "description": "SharkCraft code intelligence graph: files, symbols, packages, imports and exports. Persistent JSONL store; on-disk index queried by every other code-intelligence package.",
5
5
  "license": "MIT",
6
6
  "author": "SharkCraft contributors",
@@ -45,9 +45,9 @@
45
45
  "typecheck": "tsc --noEmit -p tsconfig.json"
46
46
  },
47
47
  "dependencies": {
48
- "@shrkcrft/core": "^0.1.0-alpha.17",
49
- "@shrkcrft/boundaries": "^0.1.0-alpha.17",
50
- "@shrkcrft/inspector": "^0.1.0-alpha.17",
48
+ "@shrkcrft/core": "^0.1.0-alpha.19",
49
+ "@shrkcrft/boundaries": "^0.1.0-alpha.19",
50
+ "@shrkcrft/inspector": "^0.1.0-alpha.19",
51
51
  "typescript": "^5.6.0"
52
52
  },
53
53
  "publishConfig": {