@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.
- 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/gen.command.d.ts.map +1 -1
- package/dist/commands/gen.command.js +13 -1
- 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 +476 -55
- package/dist/commands/graph.command.d.ts.map +1 -1
- package/dist/commands/graph.command.js +9 -3
- package/dist/commands/help.command.d.ts.map +1 -1
- package/dist/commands/help.command.js +7 -18
- package/dist/commands/knowledge-author.command.d.ts.map +1 -1
- package/dist/commands/knowledge-author.command.js +9 -0
- package/dist/commands/knowledge-propose.command.d.ts.map +1 -1
- package/dist/commands/knowledge-propose.command.js +4 -2
- package/dist/commands/knowledge.command.d.ts.map +1 -1
- package/dist/commands/knowledge.command.js +22 -2
- package/dist/commands/move-plan.command.js +1 -1
- package/dist/commands/preflight.command.d.ts.map +1 -1
- package/dist/commands/preflight.command.js +15 -0
- package/dist/commands/recommend.command.d.ts +6 -0
- package/dist/commands/recommend.command.d.ts.map +1 -1
- package/dist/commands/recommend.command.js +72 -0
- package/dist/commands/rules.command.d.ts.map +1 -1
- package/dist/commands/rules.command.js +20 -3
- 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,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
|
|
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
|
|
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}"
|
|
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:
|
|
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 }),
|
|
651
|
+
importsFrom: ctxDropDel(importsFromList),
|
|
652
|
+
importedBy: ctxDropDel(importedByList),
|
|
498
653
|
symbols: symbols.slice(0, 50).map(nodeSummary),
|
|
499
|
-
referencedBy:
|
|
500
|
-
calledBy:
|
|
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
|
|
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
|
|
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}"
|
|
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:
|
|
663
|
-
transitiveDependents:
|
|
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(
|
|
674
|
-
process.stdout.write(kv('transitive', String(
|
|
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
|
|
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
|
|
697
|
-
if (!
|
|
698
|
-
const
|
|
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}"
|
|
967
|
+
process.stderr.write(`No symbol matched "${target}".${behind ? ' ' + behind : ''}\n`);
|
|
704
968
|
return 1;
|
|
705
969
|
}
|
|
706
|
-
const
|
|
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:
|
|
712
|
-
callers:
|
|
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(
|
|
720
|
-
|
|
721
|
-
process.stdout.write(` ${
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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)
|