@shrkcrft/cli 0.1.0-alpha.17 → 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.
- package/dist/command-registry.d.ts +10 -0
- package/dist/command-registry.d.ts.map +1 -1
- package/dist/command-registry.js +7 -1
- package/dist/commands/command-catalog.d.ts.map +1 -1
- package/dist/commands/command-catalog.js +12 -0
- package/dist/commands/compress.command.d.ts +0 -7
- package/dist/commands/compress.command.d.ts.map +1 -1
- package/dist/commands/compress.command.js +7 -0
- package/dist/commands/delegate.command.d.ts +65 -0
- package/dist/commands/delegate.command.d.ts.map +1 -0
- package/dist/commands/delegate.command.js +657 -0
- package/dist/commands/deps-audit.command.js +1 -1
- package/dist/commands/doctor.command.d.ts.map +1 -1
- package/dist/commands/doctor.command.js +24 -3
- package/dist/commands/graph-code-subverbs.d.ts +22 -0
- package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
- package/dist/commands/graph-code-subverbs.js +450 -54
- package/dist/commands/graph.command.d.ts.map +1 -1
- package/dist/commands/graph.command.js +9 -3
- package/dist/commands/move-plan.command.js +1 -1
- package/dist/commands/smart-context.command.d.ts +26 -17
- package/dist/commands/smart-context.command.d.ts.map +1 -1
- package/dist/commands/smart-context.command.js +113 -16
- package/dist/commands/tests.command.d.ts.map +1 -1
- package/dist/commands/tests.command.js +13 -2
- package/dist/dashboard/code-intelligence-data.d.ts.map +1 -1
- package/dist/dashboard/code-intelligence-data.js +25 -3
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +3 -1
- package/dist/output/ccr-store-config.d.ts +1 -1
- package/dist/output/ccr-store-config.d.ts.map +1 -1
- package/dist/output/ccr-store-config.js +21 -2
- 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
|
|
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
|
|
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}"
|
|
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:
|
|
491
|
-
|
|
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:
|
|
500
|
-
calledBy:
|
|
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
|
|
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
|
|
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}"
|
|
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:
|
|
663
|
-
transitiveDependents:
|
|
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(
|
|
674
|
-
process.stdout.write(kv('transitive', String(
|
|
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
|
|
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
|
|
697
|
-
if (!
|
|
698
|
-
const
|
|
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}"
|
|
942
|
+
process.stderr.write(`No symbol matched "${target}".${behind ? ' ' + behind : ''}\n`);
|
|
704
943
|
return 1;
|
|
705
944
|
}
|
|
706
|
-
const
|
|
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:
|
|
712
|
-
callers:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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)
|