@shrkcrft/cli 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.
Files changed (50) hide show
  1. package/dist/command-registry.d.ts +10 -0
  2. package/dist/command-registry.d.ts.map +1 -1
  3. package/dist/command-registry.js +7 -1
  4. package/dist/commands/command-catalog.d.ts.map +1 -1
  5. package/dist/commands/command-catalog.js +12 -0
  6. package/dist/commands/compress.command.d.ts +0 -7
  7. package/dist/commands/compress.command.d.ts.map +1 -1
  8. package/dist/commands/compress.command.js +7 -0
  9. package/dist/commands/delegate.command.d.ts +65 -0
  10. package/dist/commands/delegate.command.d.ts.map +1 -0
  11. package/dist/commands/delegate.command.js +657 -0
  12. package/dist/commands/deps-audit.command.js +1 -1
  13. package/dist/commands/doctor.command.d.ts.map +1 -1
  14. package/dist/commands/doctor.command.js +24 -3
  15. package/dist/commands/gen.command.d.ts.map +1 -1
  16. package/dist/commands/gen.command.js +13 -1
  17. package/dist/commands/graph-code-subverbs.d.ts +22 -0
  18. package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
  19. package/dist/commands/graph-code-subverbs.js +476 -55
  20. package/dist/commands/graph.command.d.ts.map +1 -1
  21. package/dist/commands/graph.command.js +9 -3
  22. package/dist/commands/help.command.d.ts.map +1 -1
  23. package/dist/commands/help.command.js +7 -18
  24. package/dist/commands/knowledge-author.command.d.ts.map +1 -1
  25. package/dist/commands/knowledge-author.command.js +9 -0
  26. package/dist/commands/knowledge-propose.command.d.ts.map +1 -1
  27. package/dist/commands/knowledge-propose.command.js +4 -2
  28. package/dist/commands/knowledge.command.d.ts.map +1 -1
  29. package/dist/commands/knowledge.command.js +22 -2
  30. package/dist/commands/move-plan.command.js +1 -1
  31. package/dist/commands/preflight.command.d.ts.map +1 -1
  32. package/dist/commands/preflight.command.js +15 -0
  33. package/dist/commands/recommend.command.d.ts +6 -0
  34. package/dist/commands/recommend.command.d.ts.map +1 -1
  35. package/dist/commands/recommend.command.js +72 -0
  36. package/dist/commands/rules.command.d.ts.map +1 -1
  37. package/dist/commands/rules.command.js +20 -3
  38. package/dist/commands/smart-context.command.d.ts +26 -17
  39. package/dist/commands/smart-context.command.d.ts.map +1 -1
  40. package/dist/commands/smart-context.command.js +113 -16
  41. package/dist/commands/tests.command.d.ts.map +1 -1
  42. package/dist/commands/tests.command.js +13 -2
  43. package/dist/dashboard/code-intelligence-data.d.ts.map +1 -1
  44. package/dist/dashboard/code-intelligence-data.js +25 -3
  45. package/dist/main.d.ts.map +1 -1
  46. package/dist/main.js +3 -1
  47. package/dist/output/ccr-store-config.d.ts +1 -1
  48. package/dist/output/ccr-store-config.d.ts.map +1 -1
  49. package/dist/output/ccr-store-config.js +21 -2
  50. package/package.json +33 -33
@@ -5,14 +5,123 @@
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 — auto-refresh is on by default (you passed --no-refresh / SHRK_GRAPH_NO_REFRESH). Drop the opt-out, or run `shrk graph index --changed`, for fresh results.';
62
+ /**
63
+ * Refresh-by-default: incrementally reindex changed/deleted files BEFORE
64
+ * querying so an agent's just-saved edits are reflected, then print a one-line
65
+ * `(refreshed, N files)` notice to stderr. The incremental updater is
66
+ * sub-second on SharkCraft-sized indexes, so this removes the manual `shrk
67
+ * graph index --changed` step that otherwise leaves every read command
68
+ * answering from a silently-stale index — the #1 daily-friction tax.
69
+ *
70
+ * Opt out with `--no-refresh` or `SHRK_GRAPH_NO_REFRESH=1` (e.g. to keep a read
71
+ * perfectly side-effect-free, or on a huge repo where the rewrite is felt).
72
+ * `--refresh` is still accepted as a harmless explicit-on alias.
73
+ *
74
+ * CLI-only — it writes the gitignored `.sharkcraft` cache; MCP never calls this
75
+ * (the read-only contract). When there is no index yet, `detectChangedAndDeleted`
76
+ * returns nothing, so `updateChanged` (which requires an existing store) is
77
+ * never reached. The notice goes to stderr so it never corrupts a `--json`
78
+ * payload on stdout.
79
+ */
80
+ function maybeRefresh(args, cwd) {
81
+ if (flagBool(args, 'no-refresh'))
82
+ return;
83
+ if ((process.env.SHRK_GRAPH_NO_REFRESH ?? '').trim().length > 0)
84
+ return;
85
+ const d = detectChangedAndDeleted(cwd);
86
+ if (d.changed.length === 0 && d.deleted.length === 0)
87
+ return;
88
+ const result = updateChanged({ projectRoot: cwd, changedFiles: d.changed, deletedFiles: d.deleted });
89
+ const n = result.updated.length + result.deleted.length;
90
+ if (n > 0)
91
+ process.stderr.write(`(refreshed, ${n} file${n === 1 ? '' : 's'})\n`);
92
+ }
93
+ /**
94
+ * Targeted staleness over a query's result file paths: which changed (flag)
95
+ * and which were deleted (drop). Cheap — stats only the result files.
96
+ */
97
+ function resultStaleness(api, cwd, paths) {
98
+ const rel = paths.filter((p) => !!p);
99
+ const stale = api.staleFilesAmong(cwd, rel);
100
+ const has = stale.modified.length > 0 || stale.deleted.length > 0;
101
+ return {
102
+ deletedSet: new Set(stale.deleted),
103
+ modified: stale.modified,
104
+ deleted: stale.deleted,
105
+ field: has
106
+ ? { stale: { modified: stale.modified, deleted: stale.deleted }, staleHint: STALE_RESULT_HINT }
107
+ : null,
108
+ };
109
+ }
110
+ /**
111
+ * A "the index is N files behind" qualifier for a not-found / empty result, so
112
+ * an agent doesn't read a bare "not-found" as "this symbol doesn't exist / is
113
+ * safe to create" when the truth is "it's in a file the index hasn't seen yet."
114
+ * Runs the full freshness walk — only call it on the rare miss path.
115
+ */
116
+ function indexBehindHint(cwd) {
117
+ const f = detectGraphFreshness(cwd);
118
+ if (!f.hasIndex)
119
+ return null;
120
+ const behind = f.modified.length + f.added.length + f.deleted.length;
121
+ if (behind === 0)
122
+ return null;
123
+ 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.`;
124
+ }
16
125
  // ─── shrk graph index ─────────────────────────────────────────────────
17
126
  export async function runGraphIndex(args) {
18
127
  // --watch: run the index once, then re-run on file changes. Every
@@ -289,6 +398,17 @@ export async function runGraphDeps(args) {
289
398
  }
290
399
  const api = GraphQueryApi.fromStore(cwd);
291
400
  const pkgId = `package:${pkg}`;
401
+ // Existence guard (mirrors the MCP tool): without it, an unknown package
402
+ // name returns a confidently-wrong empty `dependsOn/dependedOnBy` that reads
403
+ // as "this package has no edges" rather than "this package isn't here".
404
+ if (!api.neighbours(pkgId)?.node) {
405
+ if (wantJson) {
406
+ process.stdout.write(asJson({ ok: false, error: 'not-found', package: pkg }) + '\n');
407
+ return 1;
408
+ }
409
+ process.stderr.write(`No workspace package "${pkg}" in the graph.\n`);
410
+ return 1;
411
+ }
292
412
  // outbound: packages this one depends on
293
413
  const outbound = api.packageDeps(pkg).map((n) => n.id.replace(/^package:/, ''));
294
414
  // inbound: packages that depend on this one
@@ -352,9 +472,15 @@ export async function runGraphStatus(args) {
352
472
  const snap = store.loadSnapshot();
353
473
  const manifestNodeCount = sumValues(snap.manifest.nodesByKind);
354
474
  const manifestEdgeCount = sumValues(snap.manifest.edgesByKind);
475
+ // Honest freshness vs the working tree. `corrupt` (store self-integrity) and
476
+ // `stale` (disk drift) are orthogonal — a store can be digest-valid yet
477
+ // stale — so precedence is corrupt > stale > fresh.
478
+ const fresh = detectGraphFreshness(cwd);
479
+ const behind = fresh.modified.length + fresh.added.length + fresh.deleted.length;
480
+ const state = !verify.ok ? 'corrupt' : behind > 0 ? 'stale' : 'fresh';
355
481
  const payload = {
356
482
  ok: verify.ok,
357
- state: verify.ok ? 'fresh' : 'corrupt',
483
+ state,
358
484
  schema: snap.manifest.schema,
359
485
  fileCount: snap.manifest.filesIndexed,
360
486
  nodeCount: manifestNodeCount,
@@ -369,6 +495,10 @@ export async function runGraphStatus(args) {
369
495
  filesWithUnresolvedImports: snap.manifest.filesWithUnresolvedImports ?? null,
370
496
  unresolvedImportSamples: snap.manifest.unresolvedImportSamples ?? null,
371
497
  digest: verify.ok ? snap.manifest.digest : { expected: verify.expected, actual: verify.actual },
498
+ modifiedSinceIndex: fresh.modified.length,
499
+ newSinceIndex: fresh.added.length,
500
+ deletedSinceIndex: fresh.deleted.length,
501
+ ...(behind > 0 ? { nextCommand: 'shrk graph index --changed' } : {}),
372
502
  };
373
503
  if (wantJson) {
374
504
  process.stdout.write(asJson(payload) + '\n');
@@ -389,6 +519,9 @@ export async function runGraphStatus(args) {
389
519
  }
390
520
  process.stdout.write(kv('last indexed', payload.lastIndexedAt) + '\n');
391
521
  process.stdout.write(kv('state', payload.state) + '\n');
522
+ if (behind > 0) {
523
+ 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');
524
+ }
392
525
  return verify.ok ? 0 : 1;
393
526
  }
394
527
  // ─── shrk graph search ────────────────────────────────────────────────
@@ -404,6 +537,7 @@ export async function runGraphSearch(args) {
404
537
  }
405
538
  const kindFlag = flagString(args, 'kind');
406
539
  const limit = Number(flagString(args, 'limit') ?? '20');
540
+ maybeRefresh(args, cwd);
407
541
  const api = loadOrFail(cwd, wantJson);
408
542
  if (!api)
409
543
  return 1;
@@ -450,17 +584,19 @@ export async function runGraphContext(args) {
450
584
  const depth = Math.max(1, Math.min(3, Number(flagString(args, 'depth') ?? '1')));
451
585
  const includeBridge = !flagBool(args, 'no-bridge');
452
586
  const includeFramework = !flagBool(args, 'no-framework');
587
+ maybeRefresh(args, cwd);
453
588
  const api = loadOrFail(cwd, wantJson);
454
589
  if (!api)
455
590
  return 1;
456
591
  const anchor = resolveAnchor(api, target);
457
592
  if (!anchor) {
458
- const payload = { ok: false, error: 'not-found', target };
593
+ const hint = indexBehindHint(cwd);
594
+ const payload = { ok: false, error: 'not-found', target, ...(hint ? { hint } : {}) };
459
595
  if (wantJson) {
460
596
  process.stdout.write(asJson(payload) + '\n');
461
597
  return 1;
462
598
  }
463
- process.stderr.write(`No graph node matched "${target}".\n`);
599
+ process.stderr.write(`No graph node matched "${target}".${hint ? ' ' + hint : ''}\n`);
464
600
  return 1;
465
601
  }
466
602
  const anchorFile = anchor.kind === NodeKind.File
@@ -471,6 +607,10 @@ export async function runGraphContext(args) {
471
607
  const symbols = anchor.kind === NodeKind.File ? api.symbolsIn(anchor.id) : [];
472
608
  const references = anchor.kind === NodeKind.Symbol ? dedupeNodes(api.referencesOf(anchor.id)) : [];
473
609
  const callers = anchor.kind === NodeKind.Symbol ? dedupeNodes(api.callersOf(anchor.id)) : [];
610
+ // Typed subtype/supertype relationships (extends / implements) — the precise
611
+ // "who implements this interface" answer, distinct from a generic reference.
612
+ const subtypes = anchor.kind === NodeKind.Symbol ? dedupeNodes(api.subtypesOf(anchor.id)) : [];
613
+ const supertypes = anchor.kind === NodeKind.Symbol ? dedupeNodes(api.supertypesOf(anchor.id)) : [];
474
614
  // Optional bridge enrichment: rules / paths / templates applying to
475
615
  // the anchor file (or a symbol's containing file).
476
616
  const bridgeStore = new BridgeStore(cwd);
@@ -482,22 +622,40 @@ export async function runGraphContext(args) {
482
622
  const frameworkEntities = (includeFramework && frameworkStore.exists() && anchorFile?.path)
483
623
  ? FrameworkQueryApi.fromStore(cwd).forFile(anchorFile.path)
484
624
  : [];
625
+ const importsFromList = neighbours.out
626
+ .filter((o) => o.edge.kind === 'imports-file')
627
+ .slice(0, 50)
628
+ .map((o) => ('target' in o ? targetSummary(o.target) : { id: 'unknown', resolved: false }));
629
+ const importedByList = neighbours.in
630
+ .filter((i) => i.edge.kind === 'imports-file')
631
+ .slice(0, 50)
632
+ .map((i) => ('source' in i ? sourceSummary(i.source) : { id: 'unknown', resolved: false }));
633
+ const referencedByList = references.slice(0, 50).map(nodeSummary);
634
+ const calledByList = callers.slice(0, 50).map(nodeSummary);
635
+ // Staleness over the anchor + every referenced file: drop dead paths from the
636
+ // usage lists, flag changed ones.
637
+ const ctxPathOf = (x) => x.path;
638
+ const fresh = resultStaleness(api, cwd, [
639
+ anchor.path,
640
+ ...importsFromList.map(ctxPathOf),
641
+ ...importedByList.map(ctxPathOf),
642
+ ...referencedByList.map(ctxPathOf),
643
+ ...calledByList.map(ctxPathOf),
644
+ ]);
645
+ const ctxDropDel = (rows) => rows.filter((r) => !r.path || !fresh.deletedSet.has(r.path));
485
646
  const payload = {
486
647
  schema: 'sharkcraft.graph-context/v1',
487
648
  anchor: nodeSummary(anchor),
488
649
  declaredIn: anchor.kind === NodeKind.Symbol && anchorFile ? nodeSummary(anchorFile) : null,
489
650
  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 }),
651
+ importsFrom: ctxDropDel(importsFromList),
652
+ importedBy: ctxDropDel(importedByList),
498
653
  symbols: symbols.slice(0, 50).map(nodeSummary),
499
- referencedBy: references.slice(0, 50).map(nodeSummary),
500
- calledBy: callers.slice(0, 50).map(nodeSummary),
654
+ referencedBy: ctxDropDel(referencedByList),
655
+ calledBy: ctxDropDel(calledByList),
656
+ ...(subtypes.length > 0 ? { subtypes: subtypes.slice(0, 50).map(nodeSummary) } : {}),
657
+ ...(supertypes.length > 0 ? { supertypes: supertypes.slice(0, 50).map(nodeSummary) } : {}),
658
+ ...(fresh.field ?? {}),
501
659
  bridge: bridgeFor
502
660
  ? {
503
661
  rules: bridgeFor.rules.map((h) => ({
@@ -521,7 +679,7 @@ export async function runGraphContext(args) {
521
679
  : null,
522
680
  };
523
681
  if (wantJson) {
524
- process.stdout.write(asJson(payload) + '\n');
682
+ process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
525
683
  return 0;
526
684
  }
527
685
  process.stdout.write(header(`Graph context: ${anchor.kind}:${anchor.label}`));
@@ -549,6 +707,18 @@ export async function runGraphContext(args) {
549
707
  process.stdout.write(` ← ${c.path ?? c.id}\n`);
550
708
  }
551
709
  }
710
+ if (supertypes.length > 0) {
711
+ process.stdout.write(`\nExtends / implements (${supertypes.length}):\n`);
712
+ for (const s of supertypes.slice(0, 20)) {
713
+ process.stdout.write(` ▲ ${s.label}${s.path ? ' ' + s.path : ''}${s.line ? ':' + s.line : ''}\n`);
714
+ }
715
+ }
716
+ if (subtypes.length > 0) {
717
+ process.stdout.write(`\nExtended / implemented by (${subtypes.length}):\n`);
718
+ for (const s of subtypes.slice(0, 20)) {
719
+ process.stdout.write(` ▼ ${s.label}${s.path ? ' ' + s.path : ''}${s.line ? ':' + s.line : ''}\n`);
720
+ }
721
+ }
552
722
  if (payload.importsFrom.length > 0) {
553
723
  process.stdout.write(`\nImports from (${payload.importsFrom.length}):\n`);
554
724
  for (const o of payload.importsFrom.slice(0, 20)) {
@@ -587,6 +757,9 @@ export async function runGraphContext(args) {
587
757
  process.stdout.write(` • ${e.framework}:${e.subtype} ${e.label}\n`);
588
758
  }
589
759
  }
760
+ if (fresh.field) {
761
+ process.stdout.write(`\n ⚠ ${fresh.modified.length} referenced file(s) changed, ${fresh.deleted.length} deleted since indexing — run \`shrk graph index --changed\`.\n`);
762
+ }
590
763
  return 0;
591
764
  }
592
765
  // ─── shrk graph impact ────────────────────────────────────────────────
@@ -601,15 +774,30 @@ export async function runGraphImpact(args) {
601
774
  }
602
775
  const maxDepth = Math.max(1, Math.min(10, Number(flagString(args, 'max-depth') ?? '5')));
603
776
  const limit = Math.max(1, Number(flagString(args, 'limit') ?? '200'));
777
+ maybeRefresh(args, cwd);
604
778
  // --full → delegate to the impact-engine for a richer v3 payload.
605
779
  if (wantFull) {
606
780
  const isSymbol = target.startsWith('symbol:') || /^[A-Za-z_][\w$]*$/.test(target);
607
781
  const input = isSymbol && !target.includes('/')
608
782
  ? { kind: 'symbol', symbolId: target }
609
783
  : { kind: 'files', files: [target] };
610
- const analysis = analyzeGraphImpact(input, { projectRoot: cwd, limit, maxDepth });
784
+ const raw = analyzeGraphImpact(input, { projectRoot: cwd, limit, maxDepth });
785
+ // Drop dependents/tests whose file was deleted on disk so a stale index
786
+ // never tells the agent a dead file is in the blast radius or routes it to
787
+ // run a test that no longer exists.
788
+ const analysis = {
789
+ ...raw,
790
+ directDependents: pruneDeletedRefs(raw.directDependents, cwd),
791
+ transitiveDependents: pruneDeletedRefs(raw.transitiveDependents, cwd),
792
+ affectedCallerFiles: pruneDeletedRefs(raw.affectedCallerFiles, cwd),
793
+ likelyTests: pruneDeletedRefs(raw.likelyTests, cwd),
794
+ };
795
+ // Pre-merge blast radius drives which tests an agent runs — so it must also
796
+ // say when the index is behind the working tree (repo-level: a stale --full
797
+ // analysis can still MISS new dependents the prune can't see).
798
+ const behind = indexBehindHint(cwd);
611
799
  if (wantJson) {
612
- process.stdout.write(asJson(analysis) + '\n');
800
+ process.stdout.write(asJson(behind ? { ...analysis, staleHint: behind } : analysis) + '\n');
613
801
  return 0;
614
802
  }
615
803
  process.stdout.write(header(`Graph impact (full): ${target}`));
@@ -635,6 +823,8 @@ export async function runGraphImpact(args) {
635
823
  }
636
824
  for (const d of analysis.diagnostics.slice(0, 5))
637
825
  process.stdout.write(`! ${d}\n`);
826
+ if (behind)
827
+ process.stdout.write(`\n ⚠ ${behind}\n`);
638
828
  return 0;
639
829
  }
640
830
  const api = loadOrFail(cwd, wantJson);
@@ -642,42 +832,108 @@ export async function runGraphImpact(args) {
642
832
  return 1;
643
833
  const anchor = resolveAnchor(api, target);
644
834
  if (!anchor) {
645
- const payload = { ok: false, error: 'not-found', target };
835
+ const hint = indexBehindHint(cwd);
836
+ const payload = { ok: false, error: 'not-found', target, ...(hint ? { hint } : {}) };
646
837
  if (wantJson) {
647
838
  process.stdout.write(asJson(payload) + '\n');
648
839
  return 1;
649
840
  }
650
- process.stderr.write(`No graph node matched "${target}".\n`);
841
+ process.stderr.write(`No graph node matched "${target}".${hint ? ' ' + hint : ''}\n`);
651
842
  return 1;
652
843
  }
653
844
  const closure = reverseClosure(api, anchor, maxDepth, limit);
654
845
  const direct = closure.layer[1] ?? [];
655
846
  const transitive = closure.all.filter((id) => id !== anchor.id && !direct.includes(id));
847
+ const directNodes = direct.map((id) => nodeSummary(api.neighbours(id).node));
848
+ const transitiveNodes = transitive.slice(0, limit).map((id) => nodeSummary(api.neighbours(id).node));
849
+ // Drop dependents whose file was deleted (they can't break); flag modified.
850
+ const fresh = resultStaleness(api, cwd, [
851
+ anchor.path,
852
+ ...directNodes.map((n) => n.path),
853
+ ...transitiveNodes.map((n) => n.path),
854
+ ]);
855
+ const liveDirect = directNodes.filter((n) => !n.path || !fresh.deletedSet.has(n.path));
856
+ const liveTransitive = transitiveNodes.filter((n) => !n.path || !fresh.deletedSet.has(n.path));
656
857
  const payload = {
657
858
  schema: 'sharkcraft.graph-impact/v1',
658
859
  anchor: nodeSummary(anchor),
659
860
  maxDepth,
660
861
  limit,
661
862
  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)),
863
+ directDependents: liveDirect,
864
+ transitiveDependents: liveTransitive,
666
865
  totalReached: closure.all.length - 1,
866
+ ...(fresh.field ?? {}),
667
867
  };
668
868
  if (wantJson) {
669
- process.stdout.write(asJson(payload) + '\n');
869
+ process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
670
870
  return 0;
671
871
  }
672
872
  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');
873
+ process.stdout.write(kv('direct', String(liveDirect.length)) + '\n');
874
+ process.stdout.write(kv('transitive', String(liveTransitive.length)) + '\n');
675
875
  process.stdout.write(kv('max-depth', String(maxDepth)) + '\n');
676
876
  if (closure.truncated)
677
877
  process.stdout.write(kv('truncated', 'yes') + '\n');
678
- for (const d of payload.directDependents.slice(0, 30)) {
878
+ for (const d of liveDirect.slice(0, 30)) {
679
879
  process.stdout.write(` ${d.path ?? d.id}\n`);
680
880
  }
881
+ if (fresh.field) {
882
+ process.stdout.write(`\n ⚠ ${fresh.modified.length} dependent file(s) changed, ${fresh.deleted.length} deleted since indexing — run \`shrk graph index --changed\`.\n`);
883
+ }
884
+ return 0;
885
+ }
886
+ // ─── shrk graph hubs ──────────────────────────────────────────────────
887
+ /**
888
+ * `shrk graph hubs` — the most-depended-on code: symbols ranked by how many
889
+ * DISTINCT files reference them, files by how many import them. The
890
+ * "load-bearing code" an agent should change most carefully and a human should
891
+ * understand first — the natural companion to `graph impact` (impact = blast
892
+ * radius of ONE node; hubs = the nodes with the biggest blast radius).
893
+ */
894
+ export async function runGraphHubs(args) {
895
+ const cwd = resolveCwd(args);
896
+ const wantJson = flagBool(args, 'json');
897
+ const limit = Math.max(1, Math.min(100, Number(flagString(args, 'limit') ?? '15')));
898
+ const pathScope = flagString(args, 'path');
899
+ maybeRefresh(args, cwd);
900
+ const api = loadOrFail(cwd, wantJson);
901
+ if (!api)
902
+ return 1;
903
+ const hubs = api.topHubs(limit, pathScope);
904
+ const toRow = (h) => ({
905
+ ...nodeSummary(h.node),
906
+ inDegree: h.inDegree,
907
+ });
908
+ const payload = {
909
+ schema: 'sharkcraft.graph-hubs/v1',
910
+ ...(pathScope ? { path: pathScope } : {}),
911
+ symbols: hubs.symbols.map(toRow),
912
+ files: hubs.files.map(toRow),
913
+ };
914
+ if (wantJson) {
915
+ process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
916
+ return 0;
917
+ }
918
+ process.stdout.write(header(`Graph hubs (most-depended-on)${pathScope ? ` under ${pathScope}` : ''}`));
919
+ if (hubs.symbols.length === 0 && hubs.files.length === 0) {
920
+ process.stdout.write(pathScope
921
+ ? ` No referenced/imported code under "${pathScope}" (check the path, or the call/reference graph is TS/JS-only).\n`
922
+ : ' No reference/import edges yet (call/reference graph is TS/JS-only — run `shrk graph index`).\n');
923
+ return 0;
924
+ }
925
+ if (hubs.symbols.length > 0) {
926
+ process.stdout.write('\nMost-referenced symbols (distinct dependent files):\n');
927
+ for (const h of hubs.symbols) {
928
+ process.stdout.write(` ${String(h.inDegree).padStart(4)} ${h.node.label}${h.node.path ? ' ' + h.node.path : ''}${h.node.line ? ':' + h.node.line : ''}\n`);
929
+ }
930
+ }
931
+ if (hubs.files.length > 0) {
932
+ process.stdout.write('\nMost-imported files (distinct importers):\n');
933
+ for (const h of hubs.files) {
934
+ process.stdout.write(` ${String(h.inDegree).padStart(4)} ${h.node.path ?? h.node.id}\n`);
935
+ }
936
+ }
681
937
  return 0;
682
938
  }
683
939
  // ─── shrk graph callers ───────────────────────────────────────────────
@@ -686,54 +942,217 @@ export async function runGraphCallers(args) {
686
942
  const wantJson = flagBool(args, 'json');
687
943
  const target = args.positional[1];
688
944
  if (!target) {
689
- process.stderr.write('Usage: shrk graph callers <symbol> [--mode call|reference]\n');
945
+ process.stderr.write('Usage: shrk graph callers <symbol> [--mode call|reference] [--limit N] [--no-refresh]\n');
690
946
  return 2;
691
947
  }
692
948
  const mode = (flagString(args, 'mode') ?? 'call');
949
+ // --limit N: cap the returned call sites (default 200). `total` still reports
950
+ // the true uncapped count, so a truncated result stays honest. Guard against
951
+ // non-numeric input — `Number('foo')` is NaN and `slice(0, NaN)` would zero
952
+ // the callers list while `total` kept showing the real count.
953
+ const parsedLimit = Number.parseInt(flagString(args, 'limit') ?? '200', 10);
954
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 200;
955
+ maybeRefresh(args, cwd);
693
956
  const api = loadOrFail(cwd, wantJson);
694
957
  if (!api)
695
958
  return 1;
696
- const sym = resolveSymbolTarget(api, target);
697
- if (!sym) {
698
- const payload = { ok: false, error: 'not-found', target };
959
+ const resolved = resolveSymbolTarget(api, target);
960
+ if (!resolved) {
961
+ const behind = indexBehindHint(cwd);
962
+ const payload = { ok: false, error: 'not-found', target, ...(behind ? { hint: behind } : {}) };
699
963
  if (wantJson) {
700
964
  process.stdout.write(asJson(payload) + '\n');
701
965
  return 1;
702
966
  }
703
- process.stderr.write(`No symbol matched "${target}".\n`);
967
+ process.stderr.write(`No symbol matched "${target}".${behind ? ' ' + behind : ''}\n`);
704
968
  return 1;
705
969
  }
706
- const hits = mode === 'reference' ? api.referencesOf(sym.id) : api.callersOf(sym.id);
970
+ const { sym, alsoNamed } = resolved;
971
+ const sites = mode === 'reference' ? api.referenceSitesOf(sym.id) : api.callerSitesOf(sym.id);
972
+ // Targeted staleness over the result files (declaring file + caller files):
973
+ // drop callers whose file was deleted, flag those whose content changed.
974
+ const fresh = resultStaleness(api, cwd, [sym.path, ...sites.map((s) => s.node.path)]);
975
+ const liveSites = sites.filter((s) => !s.node.path || !fresh.deletedSet.has(s.node.path));
976
+ const langNote = callGraphLanguageNote(api, sym);
977
+ // When several symbols share the name, callers are reported for ONE of them
978
+ // (the chosen — exported-preferred — declaration). Say so, otherwise the
979
+ // agent reads a narrow result as the whole picture for that name.
980
+ const ambiguityNote = alsoNamed > 0
981
+ ? `${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.`
982
+ : undefined;
983
+ const note = [ambiguityNote, langNote].filter(Boolean).join(' ');
707
984
  const payload = {
708
985
  schema: 'sharkcraft.graph-callers/v1',
709
986
  symbol: nodeSummary(sym),
710
987
  mode,
711
- total: hits.length,
712
- callers: hits.slice(0, 200).map(nodeSummary),
988
+ total: liveSites.length,
989
+ callers: liveSites.slice(0, limit).map((s) => ({
990
+ ...nodeSummary(s.node),
991
+ ...(s.line ? { line: s.line } : {}),
992
+ })),
993
+ ...(note ? { note } : {}),
994
+ ...(fresh.field ?? {}),
713
995
  };
714
996
  if (wantJson) {
715
- process.stdout.write(asJson(payload) + '\n');
997
+ process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
716
998
  return 0;
717
999
  }
718
1000
  process.stdout.write(header(`Graph callers: ${sym.label} (${mode})`));
719
- process.stdout.write(kv('total', String(hits.length)) + '\n');
720
- for (const c of payload.callers.slice(0, 50)) {
721
- process.stdout.write(` ${c.path ?? c.id}\n`);
1001
+ process.stdout.write(kv('total', String(liveSites.length)) + '\n');
1002
+ if (note)
1003
+ process.stdout.write(` ${note}\n`);
1004
+ // Render `path:line` so the agent jumps straight to the call site instead
1005
+ // of having to grep inside each returned file.
1006
+ for (const c of payload.callers.slice(0, Math.min(50, limit))) {
1007
+ process.stdout.write(` ${c.path ?? c.id}${c.line ? ':' + c.line : ''}\n`);
1008
+ }
1009
+ if (fresh.field) {
1010
+ process.stdout.write(`\n ⚠ ${fresh.modified.length} result file(s) changed, ${fresh.deleted.length} deleted since indexing — run \`shrk graph index --changed\`.\n`);
722
1011
  }
723
1012
  return 0;
724
1013
  }
1014
+ /**
1015
+ * Resolve a callers target to a single symbol, reporting how many OTHER symbols
1016
+ * share the name (`alsoNamed`) so the caller can disclose the ambiguity instead
1017
+ * of silently picking one.
1018
+ */
725
1019
  function resolveSymbolTarget(api, target) {
726
1020
  if (target.startsWith('symbol:')) {
727
- return api.neighbours(target)?.node;
1021
+ const node = api.neighbours(target)?.node;
1022
+ return node ? { sym: node, alsoNamed: 0 } : undefined;
728
1023
  }
729
1024
  const syms = api.findSymbol(target, { exact: true, limit: 5 });
730
1025
  if (syms.length === 0)
731
1026
  return undefined;
732
1027
  if (syms.length === 1)
733
- return syms[0];
1028
+ return { sym: syms[0], alsoNamed: 0 };
734
1029
  // Multiple symbols with the same name. Prefer an exported one if any.
735
1030
  const exported = syms.find((s) => (s.data?.['isExported'] ?? false) === true);
736
- return exported ?? syms[0];
1031
+ return { sym: exported ?? syms[0], alsoNamed: syms.length - 1 };
1032
+ }
1033
+ // ─── shrk graph path ──────────────────────────────────────────────────
1034
+ /**
1035
+ * `shrk graph path <from> <to>` — does code A actually reach code B?
1036
+ *
1037
+ * The question the original feedback fell back to grep for ("is billing
1038
+ * actually WIRED to checkout?"). `callers` = direct callers, `impact` =
1039
+ * reverse closure, `graph why` = the KNOWLEDGE graph — none answers the
1040
+ * forward CODE path between two symbols/files. This BFS does, over the
1041
+ * import/call/reference/declare/re-export/extends/implements edges, and
1042
+ * prints each hop with its edge kind (and call-site line) so the answer
1043
+ * shows HOW they are wired, not just that they are. When A→B has no path it
1044
+ * also checks B→A so "the dependency runs the other way" is reported instead
1045
+ * of a bare "no".
1046
+ */
1047
+ export async function runGraphPath(args) {
1048
+ const cwd = resolveCwd(args);
1049
+ const wantJson = flagBool(args, 'json');
1050
+ const fromArg = args.positional[1];
1051
+ const toArg = args.positional[2];
1052
+ if (!fromArg || !toArg) {
1053
+ process.stderr.write('Usage: shrk graph path <from> <to> [--max-depth N] [--no-refresh] [--json]\n');
1054
+ return 2;
1055
+ }
1056
+ const maxDepth = Math.max(1, Math.min(32, Number(flagString(args, 'max-depth') ?? '16')));
1057
+ maybeRefresh(args, cwd);
1058
+ const api = loadOrFail(cwd, wantJson);
1059
+ if (!api)
1060
+ return 1;
1061
+ const from = resolveAnchor(api, fromArg);
1062
+ const to = resolveAnchor(api, toArg);
1063
+ if (!from || !to) {
1064
+ const missing = !from ? fromArg : toArg;
1065
+ const behind = indexBehindHint(cwd);
1066
+ const payload = { ok: false, error: 'not-found', target: missing, ...(behind ? { hint: behind } : {}) };
1067
+ if (wantJson) {
1068
+ process.stdout.write(asJson(payload) + '\n');
1069
+ return 1;
1070
+ }
1071
+ process.stderr.write(`No graph node matched "${missing}".${behind ? ' ' + behind : ''}\n`);
1072
+ return 1;
1073
+ }
1074
+ // A symbol node has no OUTGOING code edges — references/calls are recorded
1075
+ // file→symbol, so the out-edges live on the symbol's DECLARING FILE. To trace
1076
+ // "does A reach B" when A is a symbol, start the BFS from that file (and note
1077
+ // it), since per-symbol out-edges aren't tracked. The target may stay a symbol
1078
+ // (file→symbol edges land on it).
1079
+ const fromStart = bfsStartNode(api, from);
1080
+ const toStart = bfsStartNode(api, to);
1081
+ const forward = api.pathBetween(fromStart.id, to.id, { maxDepth });
1082
+ // If A doesn't reach B, the agent usually still wants to know whether B
1083
+ // reaches A (the dependency runs the other way) — so check the reverse and
1084
+ // report direction rather than a bare "no".
1085
+ const reverse = forward.found ? null : api.pathBetween(toStart.id, from.id, { maxDepth });
1086
+ const direction = forward.found
1087
+ ? 'forward'
1088
+ : reverse?.found
1089
+ ? 'reverse'
1090
+ : 'none';
1091
+ const chosen = forward.found ? forward : reverse?.found ? reverse : forward;
1092
+ // The endpoint the user asked for at the start of the chosen direction, plus
1093
+ // the file the BFS actually started from (differs only for a symbol endpoint).
1094
+ const startEndpoint = direction === 'reverse' ? to : from;
1095
+ const startFile = direction === 'reverse' ? toStart : fromStart;
1096
+ const startNote = direction !== 'none' && startFile.id !== startEndpoint.id && startEndpoint.kind === NodeKind.Symbol
1097
+ ? `\`${startEndpoint.label}\` is declared in ${startFile.path ?? startFile.id}; path traced from that file (per-symbol out-edges are not tracked).`
1098
+ : null;
1099
+ const hopRows = chosen.hops.map((h) => ({
1100
+ from: h.from.path ?? h.from.id,
1101
+ to: h.to.path ?? h.to.id,
1102
+ kind: h.kind,
1103
+ label: h.to.label,
1104
+ ...(h.line ? { line: h.line } : {}),
1105
+ }));
1106
+ const fresh = resultStaleness(api, cwd, [
1107
+ from.path,
1108
+ to.path,
1109
+ ...chosen.hops.map((h) => h.from.path),
1110
+ ...chosen.hops.map((h) => h.to.path),
1111
+ ]);
1112
+ // A no-path answer between non-TS endpoints may just be missing call edges
1113
+ // (call/reference graph is TS/JS-only), NOT proof they are unwired.
1114
+ const langNote = direction === 'none' ? callGraphLanguageNote(api, from) ?? callGraphLanguageNote(api, to) : null;
1115
+ const note = startNote ?? langNote;
1116
+ const payload = {
1117
+ schema: 'sharkcraft.graph-path/v1',
1118
+ from: nodeSummary(from),
1119
+ to: nodeSummary(to),
1120
+ found: direction !== 'none',
1121
+ direction,
1122
+ ...(direction !== 'none' && startFile.id !== startEndpoint.id ? { tracedFrom: nodeSummary(startFile) } : {}),
1123
+ hops: hopRows,
1124
+ hopCount: hopRows.length,
1125
+ explored: forward.found ? forward.explored : reverse?.explored ?? forward.explored,
1126
+ ...(direction === 'none' && chosen.reason ? { reason: chosen.reason } : {}),
1127
+ ...(note ? { note } : {}),
1128
+ ...(fresh.field ?? {}),
1129
+ };
1130
+ if (wantJson) {
1131
+ process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
1132
+ return 0;
1133
+ }
1134
+ process.stdout.write(header(`Graph path: ${from.label} → ${to.label}`));
1135
+ if (direction === 'none') {
1136
+ process.stdout.write(` No code path ${from.label} → ${to.label} (or back) within ${maxDepth} hops.\n`);
1137
+ process.stdout.write(` explored ${payload.explored} node(s).\n`);
1138
+ if (langNote)
1139
+ process.stdout.write(` ⓘ ${langNote}\n`);
1140
+ return 0;
1141
+ }
1142
+ if (direction === 'reverse') {
1143
+ process.stdout.write(` No ${from.label} → ${to.label} path, but ${to.label} reaches ${from.label} (dependency runs the other way):\n\n`);
1144
+ }
1145
+ if (startNote)
1146
+ process.stdout.write(` ⓘ ${startNote}\n`);
1147
+ process.stdout.write(` ${startFile.path ?? startFile.label}\n`);
1148
+ for (const h of hopRows) {
1149
+ process.stdout.write(` ──${h.kind}──▶ ${h.to}${h.line ? ':' + h.line : ''}\n`);
1150
+ }
1151
+ process.stdout.write(`\n ${hopRows.length} hop(s).\n`);
1152
+ if (fresh.field) {
1153
+ 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`);
1154
+ }
1155
+ return 0;
737
1156
  }
738
1157
  // ─── helpers ──────────────────────────────────────────────────────────
739
1158
  function loadOrFail(cwd, wantJson) {
@@ -851,19 +1270,10 @@ function reverseClosure(api, anchor, maxDepth, limit) {
851
1270
  return { all: [...seen], layer, truncated };
852
1271
  }
853
1272
  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);
1273
+ // Kind-aware direct dependents (symbol refs/calls + subtype files, file →
1274
+ // importers, package dependents) — the ONE shared implementation in the
1275
+ // graph query API, so the CLI + MCP impact closures never disagree.
1276
+ return api.directDependentsOf(anchor).map((n) => n.id);
867
1277
  }
868
1278
  function nextDependents(api, anchorKind, nodeId) {
869
1279
  if (anchorKind === NodeKind.Package) {
@@ -874,6 +1284,17 @@ function nextDependents(api, anchorKind, nodeId) {
874
1284
  }
875
1285
  return api.importersOf(nodeId);
876
1286
  }
1287
+ /**
1288
+ * The node a code-path BFS should START from. Files carry their own outgoing
1289
+ * import/call/reference edges, so a file is its own start. A symbol does NOT —
1290
+ * those edges are recorded on its declaring file — so a symbol resolves to that
1291
+ * file (falling back to the symbol itself if the declaring file is unknown).
1292
+ */
1293
+ function bfsStartNode(api, node) {
1294
+ if (node.kind !== NodeKind.Symbol)
1295
+ return node;
1296
+ return declaringFileOf(api, node.id) ?? (node.path ? api.findFile(node.path) : undefined) ?? node;
1297
+ }
877
1298
  function declaringFileOf(api, symbolId) {
878
1299
  const neighbours = api.neighbours(symbolId);
879
1300
  if (!neighbours)