@shrkcrft/cli 0.1.0-alpha.16 → 0.1.0-alpha.18

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.
Files changed (45) hide show
  1. package/dist/command-registry.d.ts +28 -0
  2. package/dist/command-registry.d.ts.map +1 -1
  3. package/dist/command-registry.js +91 -1
  4. package/dist/commands/apply.command.d.ts.map +1 -1
  5. package/dist/commands/apply.command.js +10 -2
  6. package/dist/commands/command-catalog.d.ts.map +1 -1
  7. package/dist/commands/command-catalog.js +14 -0
  8. package/dist/commands/compress.command.d.ts +0 -7
  9. package/dist/commands/compress.command.d.ts.map +1 -1
  10. package/dist/commands/compress.command.js +35 -13
  11. package/dist/commands/delegate.command.d.ts +65 -0
  12. package/dist/commands/delegate.command.d.ts.map +1 -0
  13. package/dist/commands/delegate.command.js +657 -0
  14. package/dist/commands/deps-audit.command.js +5 -1
  15. package/dist/commands/dev.command.d.ts.map +1 -1
  16. package/dist/commands/dev.command.js +5 -1
  17. package/dist/commands/doctor.command.d.ts.map +1 -1
  18. package/dist/commands/doctor.command.js +24 -3
  19. package/dist/commands/graph-code-subverbs.d.ts +22 -0
  20. package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
  21. package/dist/commands/graph-code-subverbs.js +450 -54
  22. package/dist/commands/graph.command.d.ts.map +1 -1
  23. package/dist/commands/graph.command.js +9 -3
  24. package/dist/commands/move-plan.command.js +1 -1
  25. package/dist/commands/smart-context.command.d.ts +26 -17
  26. package/dist/commands/smart-context.command.d.ts.map +1 -1
  27. package/dist/commands/smart-context.command.js +113 -16
  28. package/dist/commands/tests.command.d.ts.map +1 -1
  29. package/dist/commands/tests.command.js +13 -2
  30. package/dist/dashboard/code-intelligence-data.d.ts.map +1 -1
  31. package/dist/dashboard/code-intelligence-data.js +25 -3
  32. package/dist/main.d.ts.map +1 -1
  33. package/dist/main.js +21 -3
  34. package/dist/output/ccr-store-config.d.ts +18 -0
  35. package/dist/output/ccr-store-config.d.ts.map +1 -0
  36. package/dist/output/ccr-store-config.js +41 -0
  37. package/dist/output/output-compression.d.ts +15 -0
  38. package/dist/output/output-compression.d.ts.map +1 -0
  39. package/dist/output/output-compression.js +60 -0
  40. package/dist/output/resolve-compress-type.d.ts +22 -0
  41. package/dist/output/resolve-compress-type.d.ts.map +1 -0
  42. package/dist/output/resolve-compress-type.js +21 -0
  43. package/dist/validation/run-validation-loop.d.ts.map +1 -1
  44. package/dist/validation/run-validation-loop.js +5 -1
  45. package/package.json +33 -33
@@ -5,14 +5,105 @@
5
5
  * focused. The entry command imports each `run*` and routes when the
6
6
  * first positional matches the subverb name.
7
7
  */
8
- import { buildFullIndex, changedFilesSince, detectChangedAndDeleted, EdgeKind, GraphQueryApi, GraphStore, NodeKind, updateChanged, } from '@shrkcrft/graph';
8
+ import { buildFullIndex, changedFilesSince, detectChangedAndDeleted, detectGraphFreshness, EdgeKind, GraphQueryApi, GraphStore, hasCallGraphReferences, NodeKind, updateChanged, } from '@shrkcrft/graph';
9
9
  import { analyzeGraphImpact } from '@shrkcrft/impact-engine';
10
10
  import { BridgeStore, RuleGraphQueryApi } from '@shrkcrft/rule-graph';
11
11
  import { FrameworkQueryApi, FrameworkStore } from '@shrkcrft/framework-scanners';
12
+ import { existsSync } from 'node:fs';
13
+ import * as nodePath from 'node:path';
14
+ import { compactArrayToColumnar } from '@shrkcrft/compress';
12
15
  import { flagBool, flagString, resolveCwd } from "../command-registry.js";
13
16
  import { asJson, header, kv } from "../output/format-output.js";
14
17
  import { maybeRunInWatchMode } from "../output/watch-loop.js";
18
+ /**
19
+ * Opt-in `--table`/`--compact`: columnarise each homogeneous object-array field
20
+ * of a graph `--json` payload (compact, still valid JSON, reversible via
21
+ * `expandColumnar` — and stacks with the round-8 derived-column pass to drop
22
+ * id/kind/label). Off by default so the bare-array wire shape is unchanged.
23
+ * Ships the columnar form only when it is actually smaller (net-loss guard).
24
+ */
25
+ /** Drop refs whose file no longer exists on disk (a deleted dependent can't be
26
+ * affected by an edit and a deleted test shouldn't be run). */
27
+ function pruneDeletedRefs(refs, cwd) {
28
+ return refs.filter((r) => !r.path || existsSync(nodePath.isAbsolute(r.path) ? r.path : nodePath.join(cwd, r.path)));
29
+ }
30
+ /**
31
+ * A note when a symbol's file language has no call-graph extraction (Go,
32
+ * Python, Java, …) — only TS/JS build the call graph — so an EMPTY caller list
33
+ * isn't read by the agent as "nothing calls it".
34
+ */
35
+ function callGraphLanguageNote(api, sym) {
36
+ const file = sym.path ? api.findFile(sym.path) : undefined;
37
+ const lang = file?.data?.['language'];
38
+ if (hasCallGraphReferences(lang))
39
+ return null;
40
+ return `Call/reference edges are extracted for TS/JS only — \`${sym.label}\` is in a ${lang} file, so its callers are not tracked here (an empty result does NOT mean nothing calls it).`;
41
+ }
42
+ function maybeColumnarize(payload, args) {
43
+ if (!flagBool(args, 'table') && !flagBool(args, 'compact'))
44
+ return payload;
45
+ const out = {};
46
+ for (const [k, v] of Object.entries(payload)) {
47
+ if (Array.isArray(v) &&
48
+ v.length > 0 &&
49
+ v.every((x) => x !== null && typeof x === 'object' && !Array.isArray(x))) {
50
+ const col = compactArrayToColumnar(v);
51
+ if (col && JSON.stringify(col).length < JSON.stringify(v).length) {
52
+ out[k] = col;
53
+ continue;
54
+ }
55
+ }
56
+ out[k] = v;
57
+ }
58
+ return out;
59
+ }
15
60
  const STALE_HINT = `Index is missing or stale. Run 'shrk graph index' to build it.`;
61
+ const STALE_RESULT_HINT = 'Some result files changed since the index was built — run `shrk graph index --changed` (or pass --refresh) for fresh results.';
62
+ /**
63
+ * `--refresh`: incrementally reindex changed/deleted files BEFORE querying so
64
+ * the agent's just-saved edits are reflected. CLI-only — it writes the
65
+ * gitignored `.sharkcraft` cache; MCP never does this (read-only contract).
66
+ */
67
+ function maybeRefresh(args, cwd) {
68
+ if (!flagBool(args, 'refresh'))
69
+ return;
70
+ const d = detectChangedAndDeleted(cwd);
71
+ if (d.changed.length > 0 || d.deleted.length > 0) {
72
+ updateChanged({ projectRoot: cwd, changedFiles: d.changed, deletedFiles: d.deleted });
73
+ }
74
+ }
75
+ /**
76
+ * Targeted staleness over a query's result file paths: which changed (flag)
77
+ * and which were deleted (drop). Cheap — stats only the result files.
78
+ */
79
+ function resultStaleness(api, cwd, paths) {
80
+ const rel = paths.filter((p) => !!p);
81
+ const stale = api.staleFilesAmong(cwd, rel);
82
+ const has = stale.modified.length > 0 || stale.deleted.length > 0;
83
+ return {
84
+ deletedSet: new Set(stale.deleted),
85
+ modified: stale.modified,
86
+ deleted: stale.deleted,
87
+ field: has
88
+ ? { stale: { modified: stale.modified, deleted: stale.deleted }, staleHint: STALE_RESULT_HINT }
89
+ : null,
90
+ };
91
+ }
92
+ /**
93
+ * A "the index is N files behind" qualifier for a not-found / empty result, so
94
+ * an agent doesn't read a bare "not-found" as "this symbol doesn't exist / is
95
+ * safe to create" when the truth is "it's in a file the index hasn't seen yet."
96
+ * Runs the full freshness walk — only call it on the rare miss path.
97
+ */
98
+ function indexBehindHint(cwd) {
99
+ const f = detectGraphFreshness(cwd);
100
+ if (!f.hasIndex)
101
+ return null;
102
+ const behind = f.modified.length + f.added.length + f.deleted.length;
103
+ if (behind === 0)
104
+ return null;
105
+ return `Index is ${behind} file(s) behind (${f.modified.length} modified, ${f.added.length} new, ${f.deleted.length} deleted) — run \`shrk graph index --changed\` and retry.`;
106
+ }
16
107
  // ─── shrk graph index ─────────────────────────────────────────────────
17
108
  export async function runGraphIndex(args) {
18
109
  // --watch: run the index once, then re-run on file changes. Every
@@ -289,6 +380,17 @@ export async function runGraphDeps(args) {
289
380
  }
290
381
  const api = GraphQueryApi.fromStore(cwd);
291
382
  const pkgId = `package:${pkg}`;
383
+ // Existence guard (mirrors the MCP tool): without it, an unknown package
384
+ // name returns a confidently-wrong empty `dependsOn/dependedOnBy` that reads
385
+ // as "this package has no edges" rather than "this package isn't here".
386
+ if (!api.neighbours(pkgId)?.node) {
387
+ if (wantJson) {
388
+ process.stdout.write(asJson({ ok: false, error: 'not-found', package: pkg }) + '\n');
389
+ return 1;
390
+ }
391
+ process.stderr.write(`No workspace package "${pkg}" in the graph.\n`);
392
+ return 1;
393
+ }
292
394
  // outbound: packages this one depends on
293
395
  const outbound = api.packageDeps(pkg).map((n) => n.id.replace(/^package:/, ''));
294
396
  // inbound: packages that depend on this one
@@ -352,9 +454,15 @@ export async function runGraphStatus(args) {
352
454
  const snap = store.loadSnapshot();
353
455
  const manifestNodeCount = sumValues(snap.manifest.nodesByKind);
354
456
  const manifestEdgeCount = sumValues(snap.manifest.edgesByKind);
457
+ // Honest freshness vs the working tree. `corrupt` (store self-integrity) and
458
+ // `stale` (disk drift) are orthogonal — a store can be digest-valid yet
459
+ // stale — so precedence is corrupt > stale > fresh.
460
+ const fresh = detectGraphFreshness(cwd);
461
+ const behind = fresh.modified.length + fresh.added.length + fresh.deleted.length;
462
+ const state = !verify.ok ? 'corrupt' : behind > 0 ? 'stale' : 'fresh';
355
463
  const payload = {
356
464
  ok: verify.ok,
357
- state: verify.ok ? 'fresh' : 'corrupt',
465
+ state,
358
466
  schema: snap.manifest.schema,
359
467
  fileCount: snap.manifest.filesIndexed,
360
468
  nodeCount: manifestNodeCount,
@@ -369,6 +477,10 @@ export async function runGraphStatus(args) {
369
477
  filesWithUnresolvedImports: snap.manifest.filesWithUnresolvedImports ?? null,
370
478
  unresolvedImportSamples: snap.manifest.unresolvedImportSamples ?? null,
371
479
  digest: verify.ok ? snap.manifest.digest : { expected: verify.expected, actual: verify.actual },
480
+ modifiedSinceIndex: fresh.modified.length,
481
+ newSinceIndex: fresh.added.length,
482
+ deletedSinceIndex: fresh.deleted.length,
483
+ ...(behind > 0 ? { nextCommand: 'shrk graph index --changed' } : {}),
372
484
  };
373
485
  if (wantJson) {
374
486
  process.stdout.write(asJson(payload) + '\n');
@@ -389,6 +501,9 @@ export async function runGraphStatus(args) {
389
501
  }
390
502
  process.stdout.write(kv('last indexed', payload.lastIndexedAt) + '\n');
391
503
  process.stdout.write(kv('state', payload.state) + '\n');
504
+ if (behind > 0) {
505
+ process.stdout.write(kv('drift', `${fresh.modified.length} modified, ${fresh.added.length} new, ${fresh.deleted.length} deleted since index — run \`shrk graph index --changed\``) + '\n');
506
+ }
392
507
  return verify.ok ? 0 : 1;
393
508
  }
394
509
  // ─── shrk graph search ────────────────────────────────────────────────
@@ -450,17 +565,19 @@ export async function runGraphContext(args) {
450
565
  const depth = Math.max(1, Math.min(3, Number(flagString(args, 'depth') ?? '1')));
451
566
  const includeBridge = !flagBool(args, 'no-bridge');
452
567
  const includeFramework = !flagBool(args, 'no-framework');
568
+ maybeRefresh(args, cwd);
453
569
  const api = loadOrFail(cwd, wantJson);
454
570
  if (!api)
455
571
  return 1;
456
572
  const anchor = resolveAnchor(api, target);
457
573
  if (!anchor) {
458
- const payload = { ok: false, error: 'not-found', target };
574
+ const hint = indexBehindHint(cwd);
575
+ const payload = { ok: false, error: 'not-found', target, ...(hint ? { hint } : {}) };
459
576
  if (wantJson) {
460
577
  process.stdout.write(asJson(payload) + '\n');
461
578
  return 1;
462
579
  }
463
- process.stderr.write(`No graph node matched "${target}".\n`);
580
+ process.stderr.write(`No graph node matched "${target}".${hint ? ' ' + hint : ''}\n`);
464
581
  return 1;
465
582
  }
466
583
  const anchorFile = anchor.kind === NodeKind.File
@@ -471,6 +588,10 @@ export async function runGraphContext(args) {
471
588
  const symbols = anchor.kind === NodeKind.File ? api.symbolsIn(anchor.id) : [];
472
589
  const references = anchor.kind === NodeKind.Symbol ? dedupeNodes(api.referencesOf(anchor.id)) : [];
473
590
  const callers = anchor.kind === NodeKind.Symbol ? dedupeNodes(api.callersOf(anchor.id)) : [];
591
+ // Typed subtype/supertype relationships (extends / implements) — the precise
592
+ // "who implements this interface" answer, distinct from a generic reference.
593
+ const subtypes = anchor.kind === NodeKind.Symbol ? dedupeNodes(api.subtypesOf(anchor.id)) : [];
594
+ const supertypes = anchor.kind === NodeKind.Symbol ? dedupeNodes(api.supertypesOf(anchor.id)) : [];
474
595
  // Optional bridge enrichment: rules / paths / templates applying to
475
596
  // the anchor file (or a symbol's containing file).
476
597
  const bridgeStore = new BridgeStore(cwd);
@@ -482,22 +603,40 @@ export async function runGraphContext(args) {
482
603
  const frameworkEntities = (includeFramework && frameworkStore.exists() && anchorFile?.path)
483
604
  ? FrameworkQueryApi.fromStore(cwd).forFile(anchorFile.path)
484
605
  : [];
606
+ const importsFromList = neighbours.out
607
+ .filter((o) => o.edge.kind === 'imports-file')
608
+ .slice(0, 50)
609
+ .map((o) => ('target' in o ? targetSummary(o.target) : { id: 'unknown', resolved: false }));
610
+ const importedByList = neighbours.in
611
+ .filter((i) => i.edge.kind === 'imports-file')
612
+ .slice(0, 50)
613
+ .map((i) => ('source' in i ? sourceSummary(i.source) : { id: 'unknown', resolved: false }));
614
+ const referencedByList = references.slice(0, 50).map(nodeSummary);
615
+ const calledByList = callers.slice(0, 50).map(nodeSummary);
616
+ // Staleness over the anchor + every referenced file: drop dead paths from the
617
+ // usage lists, flag changed ones.
618
+ const ctxPathOf = (x) => x.path;
619
+ const fresh = resultStaleness(api, cwd, [
620
+ anchor.path,
621
+ ...importsFromList.map(ctxPathOf),
622
+ ...importedByList.map(ctxPathOf),
623
+ ...referencedByList.map(ctxPathOf),
624
+ ...calledByList.map(ctxPathOf),
625
+ ]);
626
+ const ctxDropDel = (rows) => rows.filter((r) => !r.path || !fresh.deletedSet.has(r.path));
485
627
  const payload = {
486
628
  schema: 'sharkcraft.graph-context/v1',
487
629
  anchor: nodeSummary(anchor),
488
630
  declaredIn: anchor.kind === NodeKind.Symbol && anchorFile ? nodeSummary(anchorFile) : null,
489
631
  depth,
490
- importsFrom: neighbours.out
491
- .filter((o) => o.edge.kind === 'imports-file')
492
- .slice(0, 50)
493
- .map((o) => 'target' in o ? targetSummary(o.target) : { id: 'unknown', resolved: false }),
494
- importedBy: neighbours.in
495
- .filter((i) => i.edge.kind === 'imports-file')
496
- .slice(0, 50)
497
- .map((i) => 'source' in i ? sourceSummary(i.source) : { id: 'unknown', resolved: false }),
632
+ importsFrom: ctxDropDel(importsFromList),
633
+ importedBy: ctxDropDel(importedByList),
498
634
  symbols: symbols.slice(0, 50).map(nodeSummary),
499
- referencedBy: references.slice(0, 50).map(nodeSummary),
500
- calledBy: callers.slice(0, 50).map(nodeSummary),
635
+ referencedBy: ctxDropDel(referencedByList),
636
+ calledBy: ctxDropDel(calledByList),
637
+ ...(subtypes.length > 0 ? { subtypes: subtypes.slice(0, 50).map(nodeSummary) } : {}),
638
+ ...(supertypes.length > 0 ? { supertypes: supertypes.slice(0, 50).map(nodeSummary) } : {}),
639
+ ...(fresh.field ?? {}),
501
640
  bridge: bridgeFor
502
641
  ? {
503
642
  rules: bridgeFor.rules.map((h) => ({
@@ -521,7 +660,7 @@ export async function runGraphContext(args) {
521
660
  : null,
522
661
  };
523
662
  if (wantJson) {
524
- process.stdout.write(asJson(payload) + '\n');
663
+ process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
525
664
  return 0;
526
665
  }
527
666
  process.stdout.write(header(`Graph context: ${anchor.kind}:${anchor.label}`));
@@ -549,6 +688,18 @@ export async function runGraphContext(args) {
549
688
  process.stdout.write(` ← ${c.path ?? c.id}\n`);
550
689
  }
551
690
  }
691
+ if (supertypes.length > 0) {
692
+ process.stdout.write(`\nExtends / implements (${supertypes.length}):\n`);
693
+ for (const s of supertypes.slice(0, 20)) {
694
+ process.stdout.write(` ▲ ${s.label}${s.path ? ' ' + s.path : ''}${s.line ? ':' + s.line : ''}\n`);
695
+ }
696
+ }
697
+ if (subtypes.length > 0) {
698
+ process.stdout.write(`\nExtended / implemented by (${subtypes.length}):\n`);
699
+ for (const s of subtypes.slice(0, 20)) {
700
+ process.stdout.write(` ▼ ${s.label}${s.path ? ' ' + s.path : ''}${s.line ? ':' + s.line : ''}\n`);
701
+ }
702
+ }
552
703
  if (payload.importsFrom.length > 0) {
553
704
  process.stdout.write(`\nImports from (${payload.importsFrom.length}):\n`);
554
705
  for (const o of payload.importsFrom.slice(0, 20)) {
@@ -587,6 +738,9 @@ export async function runGraphContext(args) {
587
738
  process.stdout.write(` • ${e.framework}:${e.subtype} ${e.label}\n`);
588
739
  }
589
740
  }
741
+ if (fresh.field) {
742
+ process.stdout.write(`\n ⚠ ${fresh.modified.length} referenced file(s) changed, ${fresh.deleted.length} deleted since indexing — run \`shrk graph index --changed\`.\n`);
743
+ }
590
744
  return 0;
591
745
  }
592
746
  // ─── shrk graph impact ────────────────────────────────────────────────
@@ -601,15 +755,30 @@ export async function runGraphImpact(args) {
601
755
  }
602
756
  const maxDepth = Math.max(1, Math.min(10, Number(flagString(args, 'max-depth') ?? '5')));
603
757
  const limit = Math.max(1, Number(flagString(args, 'limit') ?? '200'));
758
+ maybeRefresh(args, cwd);
604
759
  // --full → delegate to the impact-engine for a richer v3 payload.
605
760
  if (wantFull) {
606
761
  const isSymbol = target.startsWith('symbol:') || /^[A-Za-z_][\w$]*$/.test(target);
607
762
  const input = isSymbol && !target.includes('/')
608
763
  ? { kind: 'symbol', symbolId: target }
609
764
  : { kind: 'files', files: [target] };
610
- const analysis = analyzeGraphImpact(input, { projectRoot: cwd, limit, maxDepth });
765
+ const raw = analyzeGraphImpact(input, { projectRoot: cwd, limit, maxDepth });
766
+ // Drop dependents/tests whose file was deleted on disk so a stale index
767
+ // never tells the agent a dead file is in the blast radius or routes it to
768
+ // run a test that no longer exists.
769
+ const analysis = {
770
+ ...raw,
771
+ directDependents: pruneDeletedRefs(raw.directDependents, cwd),
772
+ transitiveDependents: pruneDeletedRefs(raw.transitiveDependents, cwd),
773
+ affectedCallerFiles: pruneDeletedRefs(raw.affectedCallerFiles, cwd),
774
+ likelyTests: pruneDeletedRefs(raw.likelyTests, cwd),
775
+ };
776
+ // Pre-merge blast radius drives which tests an agent runs — so it must also
777
+ // say when the index is behind the working tree (repo-level: a stale --full
778
+ // analysis can still MISS new dependents the prune can't see).
779
+ const behind = indexBehindHint(cwd);
611
780
  if (wantJson) {
612
- process.stdout.write(asJson(analysis) + '\n');
781
+ process.stdout.write(asJson(behind ? { ...analysis, staleHint: behind } : analysis) + '\n');
613
782
  return 0;
614
783
  }
615
784
  process.stdout.write(header(`Graph impact (full): ${target}`));
@@ -635,6 +804,8 @@ export async function runGraphImpact(args) {
635
804
  }
636
805
  for (const d of analysis.diagnostics.slice(0, 5))
637
806
  process.stdout.write(`! ${d}\n`);
807
+ if (behind)
808
+ process.stdout.write(`\n ⚠ ${behind}\n`);
638
809
  return 0;
639
810
  }
640
811
  const api = loadOrFail(cwd, wantJson);
@@ -642,42 +813,108 @@ export async function runGraphImpact(args) {
642
813
  return 1;
643
814
  const anchor = resolveAnchor(api, target);
644
815
  if (!anchor) {
645
- const payload = { ok: false, error: 'not-found', target };
816
+ const hint = indexBehindHint(cwd);
817
+ const payload = { ok: false, error: 'not-found', target, ...(hint ? { hint } : {}) };
646
818
  if (wantJson) {
647
819
  process.stdout.write(asJson(payload) + '\n');
648
820
  return 1;
649
821
  }
650
- process.stderr.write(`No graph node matched "${target}".\n`);
822
+ process.stderr.write(`No graph node matched "${target}".${hint ? ' ' + hint : ''}\n`);
651
823
  return 1;
652
824
  }
653
825
  const closure = reverseClosure(api, anchor, maxDepth, limit);
654
826
  const direct = closure.layer[1] ?? [];
655
827
  const transitive = closure.all.filter((id) => id !== anchor.id && !direct.includes(id));
828
+ const directNodes = direct.map((id) => nodeSummary(api.neighbours(id).node));
829
+ const transitiveNodes = transitive.slice(0, limit).map((id) => nodeSummary(api.neighbours(id).node));
830
+ // Drop dependents whose file was deleted (they can't break); flag modified.
831
+ const fresh = resultStaleness(api, cwd, [
832
+ anchor.path,
833
+ ...directNodes.map((n) => n.path),
834
+ ...transitiveNodes.map((n) => n.path),
835
+ ]);
836
+ const liveDirect = directNodes.filter((n) => !n.path || !fresh.deletedSet.has(n.path));
837
+ const liveTransitive = transitiveNodes.filter((n) => !n.path || !fresh.deletedSet.has(n.path));
656
838
  const payload = {
657
839
  schema: 'sharkcraft.graph-impact/v1',
658
840
  anchor: nodeSummary(anchor),
659
841
  maxDepth,
660
842
  limit,
661
843
  truncated: closure.truncated,
662
- directDependents: direct.map((id) => nodeSummary(api.neighbours(id).node)),
663
- transitiveDependents: transitive
664
- .slice(0, limit)
665
- .map((id) => nodeSummary(api.neighbours(id).node)),
844
+ directDependents: liveDirect,
845
+ transitiveDependents: liveTransitive,
666
846
  totalReached: closure.all.length - 1,
847
+ ...(fresh.field ?? {}),
667
848
  };
668
849
  if (wantJson) {
669
- process.stdout.write(asJson(payload) + '\n');
850
+ process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
670
851
  return 0;
671
852
  }
672
853
  process.stdout.write(header(`Graph impact: ${anchor.label}`));
673
- process.stdout.write(kv('direct', String(direct.length)) + '\n');
674
- process.stdout.write(kv('transitive', String(transitive.length)) + '\n');
854
+ process.stdout.write(kv('direct', String(liveDirect.length)) + '\n');
855
+ process.stdout.write(kv('transitive', String(liveTransitive.length)) + '\n');
675
856
  process.stdout.write(kv('max-depth', String(maxDepth)) + '\n');
676
857
  if (closure.truncated)
677
858
  process.stdout.write(kv('truncated', 'yes') + '\n');
678
- for (const d of payload.directDependents.slice(0, 30)) {
859
+ for (const d of liveDirect.slice(0, 30)) {
679
860
  process.stdout.write(` ${d.path ?? d.id}\n`);
680
861
  }
862
+ if (fresh.field) {
863
+ process.stdout.write(`\n ⚠ ${fresh.modified.length} dependent file(s) changed, ${fresh.deleted.length} deleted since indexing — run \`shrk graph index --changed\`.\n`);
864
+ }
865
+ return 0;
866
+ }
867
+ // ─── shrk graph hubs ──────────────────────────────────────────────────
868
+ /**
869
+ * `shrk graph hubs` — the most-depended-on code: symbols ranked by how many
870
+ * DISTINCT files reference them, files by how many import them. The
871
+ * "load-bearing code" an agent should change most carefully and a human should
872
+ * understand first — the natural companion to `graph impact` (impact = blast
873
+ * radius of ONE node; hubs = the nodes with the biggest blast radius).
874
+ */
875
+ export async function runGraphHubs(args) {
876
+ const cwd = resolveCwd(args);
877
+ const wantJson = flagBool(args, 'json');
878
+ const limit = Math.max(1, Math.min(100, Number(flagString(args, 'limit') ?? '15')));
879
+ const pathScope = flagString(args, 'path');
880
+ maybeRefresh(args, cwd);
881
+ const api = loadOrFail(cwd, wantJson);
882
+ if (!api)
883
+ return 1;
884
+ const hubs = api.topHubs(limit, pathScope);
885
+ const toRow = (h) => ({
886
+ ...nodeSummary(h.node),
887
+ inDegree: h.inDegree,
888
+ });
889
+ const payload = {
890
+ schema: 'sharkcraft.graph-hubs/v1',
891
+ ...(pathScope ? { path: pathScope } : {}),
892
+ symbols: hubs.symbols.map(toRow),
893
+ files: hubs.files.map(toRow),
894
+ };
895
+ if (wantJson) {
896
+ process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
897
+ return 0;
898
+ }
899
+ process.stdout.write(header(`Graph hubs (most-depended-on)${pathScope ? ` under ${pathScope}` : ''}`));
900
+ if (hubs.symbols.length === 0 && hubs.files.length === 0) {
901
+ process.stdout.write(pathScope
902
+ ? ` No referenced/imported code under "${pathScope}" (check the path, or the call/reference graph is TS/JS-only).\n`
903
+ : ' No reference/import edges yet (call/reference graph is TS/JS-only — run `shrk graph index`).\n');
904
+ return 0;
905
+ }
906
+ if (hubs.symbols.length > 0) {
907
+ process.stdout.write('\nMost-referenced symbols (distinct dependent files):\n');
908
+ for (const h of hubs.symbols) {
909
+ process.stdout.write(` ${String(h.inDegree).padStart(4)} ${h.node.label}${h.node.path ? ' ' + h.node.path : ''}${h.node.line ? ':' + h.node.line : ''}\n`);
910
+ }
911
+ }
912
+ if (hubs.files.length > 0) {
913
+ process.stdout.write('\nMost-imported files (distinct importers):\n');
914
+ for (const h of hubs.files) {
915
+ process.stdout.write(` ${String(h.inDegree).padStart(4)} ${h.node.path ?? h.node.id}\n`);
916
+ }
917
+ }
681
918
  return 0;
682
919
  }
683
920
  // ─── shrk graph callers ───────────────────────────────────────────────
@@ -686,54 +923,211 @@ export async function runGraphCallers(args) {
686
923
  const wantJson = flagBool(args, 'json');
687
924
  const target = args.positional[1];
688
925
  if (!target) {
689
- process.stderr.write('Usage: shrk graph callers <symbol> [--mode call|reference]\n');
926
+ process.stderr.write('Usage: shrk graph callers <symbol> [--mode call|reference] [--refresh]\n');
690
927
  return 2;
691
928
  }
692
929
  const mode = (flagString(args, 'mode') ?? 'call');
930
+ maybeRefresh(args, cwd);
693
931
  const api = loadOrFail(cwd, wantJson);
694
932
  if (!api)
695
933
  return 1;
696
- const sym = resolveSymbolTarget(api, target);
697
- if (!sym) {
698
- const payload = { ok: false, error: 'not-found', target };
934
+ const resolved = resolveSymbolTarget(api, target);
935
+ if (!resolved) {
936
+ const behind = indexBehindHint(cwd);
937
+ const payload = { ok: false, error: 'not-found', target, ...(behind ? { hint: behind } : {}) };
699
938
  if (wantJson) {
700
939
  process.stdout.write(asJson(payload) + '\n');
701
940
  return 1;
702
941
  }
703
- process.stderr.write(`No symbol matched "${target}".\n`);
942
+ process.stderr.write(`No symbol matched "${target}".${behind ? ' ' + behind : ''}\n`);
704
943
  return 1;
705
944
  }
706
- const hits = mode === 'reference' ? api.referencesOf(sym.id) : api.callersOf(sym.id);
945
+ const { sym, alsoNamed } = resolved;
946
+ const sites = mode === 'reference' ? api.referenceSitesOf(sym.id) : api.callerSitesOf(sym.id);
947
+ // Targeted staleness over the result files (declaring file + caller files):
948
+ // drop callers whose file was deleted, flag those whose content changed.
949
+ const fresh = resultStaleness(api, cwd, [sym.path, ...sites.map((s) => s.node.path)]);
950
+ const liveSites = sites.filter((s) => !s.node.path || !fresh.deletedSet.has(s.node.path));
951
+ const langNote = callGraphLanguageNote(api, sym);
952
+ // When several symbols share the name, callers are reported for ONE of them
953
+ // (the chosen — exported-preferred — declaration). Say so, otherwise the
954
+ // agent reads a narrow result as the whole picture for that name.
955
+ const ambiguityNote = alsoNamed > 0
956
+ ? `${alsoNamed + 1} symbols named "${sym.label}"; showing callers of the one at ${sym.path ?? sym.id}${sym.line ? ':' + sym.line : ''}. Pass a symbol: id to disambiguate.`
957
+ : undefined;
958
+ const note = [ambiguityNote, langNote].filter(Boolean).join(' ');
707
959
  const payload = {
708
960
  schema: 'sharkcraft.graph-callers/v1',
709
961
  symbol: nodeSummary(sym),
710
962
  mode,
711
- total: hits.length,
712
- callers: hits.slice(0, 200).map(nodeSummary),
963
+ total: liveSites.length,
964
+ callers: liveSites.slice(0, 200).map((s) => ({
965
+ ...nodeSummary(s.node),
966
+ ...(s.line ? { line: s.line } : {}),
967
+ })),
968
+ ...(note ? { note } : {}),
969
+ ...(fresh.field ?? {}),
713
970
  };
714
971
  if (wantJson) {
715
- process.stdout.write(asJson(payload) + '\n');
972
+ process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
716
973
  return 0;
717
974
  }
718
975
  process.stdout.write(header(`Graph callers: ${sym.label} (${mode})`));
719
- process.stdout.write(kv('total', String(hits.length)) + '\n');
976
+ process.stdout.write(kv('total', String(liveSites.length)) + '\n');
977
+ if (note)
978
+ process.stdout.write(` ⓘ ${note}\n`);
979
+ // Render `path:line` so the agent jumps straight to the call site instead
980
+ // of having to grep inside each returned file.
720
981
  for (const c of payload.callers.slice(0, 50)) {
721
- process.stdout.write(` ${c.path ?? c.id}\n`);
982
+ process.stdout.write(` ${c.path ?? c.id}${c.line ? ':' + c.line : ''}\n`);
983
+ }
984
+ if (fresh.field) {
985
+ process.stdout.write(`\n ⚠ ${fresh.modified.length} result file(s) changed, ${fresh.deleted.length} deleted since indexing — run \`shrk graph index --changed\`.\n`);
722
986
  }
723
987
  return 0;
724
988
  }
989
+ /**
990
+ * Resolve a callers target to a single symbol, reporting how many OTHER symbols
991
+ * share the name (`alsoNamed`) so the caller can disclose the ambiguity instead
992
+ * of silently picking one.
993
+ */
725
994
  function resolveSymbolTarget(api, target) {
726
995
  if (target.startsWith('symbol:')) {
727
- return api.neighbours(target)?.node;
996
+ const node = api.neighbours(target)?.node;
997
+ return node ? { sym: node, alsoNamed: 0 } : undefined;
728
998
  }
729
999
  const syms = api.findSymbol(target, { exact: true, limit: 5 });
730
1000
  if (syms.length === 0)
731
1001
  return undefined;
732
1002
  if (syms.length === 1)
733
- return syms[0];
1003
+ return { sym: syms[0], alsoNamed: 0 };
734
1004
  // Multiple symbols with the same name. Prefer an exported one if any.
735
1005
  const exported = syms.find((s) => (s.data?.['isExported'] ?? false) === true);
736
- return exported ?? syms[0];
1006
+ return { sym: exported ?? syms[0], alsoNamed: syms.length - 1 };
1007
+ }
1008
+ // ─── shrk graph path ──────────────────────────────────────────────────
1009
+ /**
1010
+ * `shrk graph path <from> <to>` — does code A actually reach code B?
1011
+ *
1012
+ * The question the original feedback fell back to grep for ("is billing
1013
+ * actually WIRED to checkout?"). `callers` = direct callers, `impact` =
1014
+ * reverse closure, `graph why` = the KNOWLEDGE graph — none answers the
1015
+ * forward CODE path between two symbols/files. This BFS does, over the
1016
+ * import/call/reference/declare/re-export/extends/implements edges, and
1017
+ * prints each hop with its edge kind (and call-site line) so the answer
1018
+ * shows HOW they are wired, not just that they are. When A→B has no path it
1019
+ * also checks B→A so "the dependency runs the other way" is reported instead
1020
+ * of a bare "no".
1021
+ */
1022
+ export async function runGraphPath(args) {
1023
+ const cwd = resolveCwd(args);
1024
+ const wantJson = flagBool(args, 'json');
1025
+ const fromArg = args.positional[1];
1026
+ const toArg = args.positional[2];
1027
+ if (!fromArg || !toArg) {
1028
+ process.stderr.write('Usage: shrk graph path <from> <to> [--max-depth N] [--refresh] [--json]\n');
1029
+ return 2;
1030
+ }
1031
+ const maxDepth = Math.max(1, Math.min(32, Number(flagString(args, 'max-depth') ?? '16')));
1032
+ maybeRefresh(args, cwd);
1033
+ const api = loadOrFail(cwd, wantJson);
1034
+ if (!api)
1035
+ return 1;
1036
+ const from = resolveAnchor(api, fromArg);
1037
+ const to = resolveAnchor(api, toArg);
1038
+ if (!from || !to) {
1039
+ const missing = !from ? fromArg : toArg;
1040
+ const behind = indexBehindHint(cwd);
1041
+ const payload = { ok: false, error: 'not-found', target: missing, ...(behind ? { hint: behind } : {}) };
1042
+ if (wantJson) {
1043
+ process.stdout.write(asJson(payload) + '\n');
1044
+ return 1;
1045
+ }
1046
+ process.stderr.write(`No graph node matched "${missing}".${behind ? ' ' + behind : ''}\n`);
1047
+ return 1;
1048
+ }
1049
+ // A symbol node has no OUTGOING code edges — references/calls are recorded
1050
+ // file→symbol, so the out-edges live on the symbol's DECLARING FILE. To trace
1051
+ // "does A reach B" when A is a symbol, start the BFS from that file (and note
1052
+ // it), since per-symbol out-edges aren't tracked. The target may stay a symbol
1053
+ // (file→symbol edges land on it).
1054
+ const fromStart = bfsStartNode(api, from);
1055
+ const toStart = bfsStartNode(api, to);
1056
+ const forward = api.pathBetween(fromStart.id, to.id, { maxDepth });
1057
+ // If A doesn't reach B, the agent usually still wants to know whether B
1058
+ // reaches A (the dependency runs the other way) — so check the reverse and
1059
+ // report direction rather than a bare "no".
1060
+ const reverse = forward.found ? null : api.pathBetween(toStart.id, from.id, { maxDepth });
1061
+ const direction = forward.found
1062
+ ? 'forward'
1063
+ : reverse?.found
1064
+ ? 'reverse'
1065
+ : 'none';
1066
+ const chosen = forward.found ? forward : reverse?.found ? reverse : forward;
1067
+ // The endpoint the user asked for at the start of the chosen direction, plus
1068
+ // the file the BFS actually started from (differs only for a symbol endpoint).
1069
+ const startEndpoint = direction === 'reverse' ? to : from;
1070
+ const startFile = direction === 'reverse' ? toStart : fromStart;
1071
+ const startNote = direction !== 'none' && startFile.id !== startEndpoint.id && startEndpoint.kind === NodeKind.Symbol
1072
+ ? `\`${startEndpoint.label}\` is declared in ${startFile.path ?? startFile.id}; path traced from that file (per-symbol out-edges are not tracked).`
1073
+ : null;
1074
+ const hopRows = chosen.hops.map((h) => ({
1075
+ from: h.from.path ?? h.from.id,
1076
+ to: h.to.path ?? h.to.id,
1077
+ kind: h.kind,
1078
+ label: h.to.label,
1079
+ ...(h.line ? { line: h.line } : {}),
1080
+ }));
1081
+ const fresh = resultStaleness(api, cwd, [
1082
+ from.path,
1083
+ to.path,
1084
+ ...chosen.hops.map((h) => h.from.path),
1085
+ ...chosen.hops.map((h) => h.to.path),
1086
+ ]);
1087
+ // A no-path answer between non-TS endpoints may just be missing call edges
1088
+ // (call/reference graph is TS/JS-only), NOT proof they are unwired.
1089
+ const langNote = direction === 'none' ? callGraphLanguageNote(api, from) ?? callGraphLanguageNote(api, to) : null;
1090
+ const note = startNote ?? langNote;
1091
+ const payload = {
1092
+ schema: 'sharkcraft.graph-path/v1',
1093
+ from: nodeSummary(from),
1094
+ to: nodeSummary(to),
1095
+ found: direction !== 'none',
1096
+ direction,
1097
+ ...(direction !== 'none' && startFile.id !== startEndpoint.id ? { tracedFrom: nodeSummary(startFile) } : {}),
1098
+ hops: hopRows,
1099
+ hopCount: hopRows.length,
1100
+ explored: forward.found ? forward.explored : reverse?.explored ?? forward.explored,
1101
+ ...(direction === 'none' && chosen.reason ? { reason: chosen.reason } : {}),
1102
+ ...(note ? { note } : {}),
1103
+ ...(fresh.field ?? {}),
1104
+ };
1105
+ if (wantJson) {
1106
+ process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
1107
+ return 0;
1108
+ }
1109
+ process.stdout.write(header(`Graph path: ${from.label} → ${to.label}`));
1110
+ if (direction === 'none') {
1111
+ process.stdout.write(` No code path ${from.label} → ${to.label} (or back) within ${maxDepth} hops.\n`);
1112
+ process.stdout.write(` explored ${payload.explored} node(s).\n`);
1113
+ if (langNote)
1114
+ process.stdout.write(` ⓘ ${langNote}\n`);
1115
+ return 0;
1116
+ }
1117
+ if (direction === 'reverse') {
1118
+ process.stdout.write(` No ${from.label} → ${to.label} path, but ${to.label} reaches ${from.label} (dependency runs the other way):\n\n`);
1119
+ }
1120
+ if (startNote)
1121
+ process.stdout.write(` ⓘ ${startNote}\n`);
1122
+ process.stdout.write(` ${startFile.path ?? startFile.label}\n`);
1123
+ for (const h of hopRows) {
1124
+ process.stdout.write(` ──${h.kind}──▶ ${h.to}${h.line ? ':' + h.line : ''}\n`);
1125
+ }
1126
+ process.stdout.write(`\n ${hopRows.length} hop(s).\n`);
1127
+ if (fresh.field) {
1128
+ process.stdout.write(`\n ⚠ ${fresh.modified.length} file(s) on the path changed, ${fresh.deleted.length} deleted since indexing — run \`shrk graph index --changed\`.\n`);
1129
+ }
1130
+ return 0;
737
1131
  }
738
1132
  // ─── helpers ──────────────────────────────────────────────────────────
739
1133
  function loadOrFail(cwd, wantJson) {
@@ -851,19 +1245,10 @@ function reverseClosure(api, anchor, maxDepth, limit) {
851
1245
  return { all: [...seen], layer, truncated };
852
1246
  }
853
1247
  function directDependentsForAnchor(api, anchor) {
854
- if (anchor.kind === NodeKind.Symbol) {
855
- const owner = declaringFileOf(api, anchor.id);
856
- const refs = dedupeNodes([...api.referencesOf(anchor.id), ...api.callersOf(anchor.id)])
857
- .filter((n) => n.kind === NodeKind.File && n.id !== owner?.id)
858
- .map((n) => n.id);
859
- if (refs.length > 0)
860
- return refs;
861
- return owner ? api.importersOf(owner.id).map((n) => n.id) : [];
862
- }
863
- if (anchor.kind === NodeKind.Package) {
864
- return api.packageDependents(packageNameFor(anchor)).map((n) => n.id);
865
- }
866
- return api.importersOf(anchor.id).map((n) => n.id);
1248
+ // Kind-aware direct dependents (symbol refs/calls + subtype files, file →
1249
+ // importers, package dependents) — the ONE shared implementation in the
1250
+ // graph query API, so the CLI + MCP impact closures never disagree.
1251
+ return api.directDependentsOf(anchor).map((n) => n.id);
867
1252
  }
868
1253
  function nextDependents(api, anchorKind, nodeId) {
869
1254
  if (anchorKind === NodeKind.Package) {
@@ -874,6 +1259,17 @@ function nextDependents(api, anchorKind, nodeId) {
874
1259
  }
875
1260
  return api.importersOf(nodeId);
876
1261
  }
1262
+ /**
1263
+ * The node a code-path BFS should START from. Files carry their own outgoing
1264
+ * import/call/reference edges, so a file is its own start. A symbol does NOT —
1265
+ * those edges are recorded on its declaring file — so a symbol resolves to that
1266
+ * file (falling back to the symbol itself if the declaring file is unknown).
1267
+ */
1268
+ function bfsStartNode(api, node) {
1269
+ if (node.kind !== NodeKind.Symbol)
1270
+ return node;
1271
+ return declaringFileOf(api, node.id) ?? (node.path ? api.findFile(node.path) : undefined) ?? node;
1272
+ }
877
1273
  function declaringFileOf(api, symbolId) {
878
1274
  const neighbours = api.neighbours(symbolId);
879
1275
  if (!neighbours)