@shrkcrft/cli 0.1.0-alpha.2 → 0.1.0-alpha.20
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/audit/knowledge-audit-llm.d.ts +19 -0
- package/dist/audit/knowledge-audit-llm.d.ts.map +1 -0
- package/dist/audit/knowledge-audit-llm.js +164 -0
- package/dist/audit/knowledge-audit.d.ts +61 -0
- package/dist/audit/knowledge-audit.d.ts.map +1 -0
- package/dist/audit/knowledge-audit.js +203 -0
- package/dist/audit/knowledge-fix-plan-llm.d.ts +11 -0
- package/dist/audit/knowledge-fix-plan-llm.d.ts.map +1 -0
- package/dist/audit/knowledge-fix-plan-llm.js +141 -0
- package/dist/audit/knowledge-fix-plan.d.ts +41 -0
- package/dist/audit/knowledge-fix-plan.d.ts.map +1 -0
- package/dist/audit/knowledge-fix-plan.js +125 -0
- package/dist/audit/pipeline-audit-llm.d.ts +11 -0
- package/dist/audit/pipeline-audit-llm.d.ts.map +1 -0
- package/dist/audit/pipeline-audit-llm.js +134 -0
- package/dist/audit/pipeline-audit.d.ts +69 -0
- package/dist/audit/pipeline-audit.d.ts.map +1 -0
- package/dist/audit/pipeline-audit.js +166 -0
- package/dist/audit/templates-audit-llm.d.ts +19 -0
- package/dist/audit/templates-audit-llm.d.ts.map +1 -0
- package/dist/audit/templates-audit-llm.js +207 -0
- package/dist/audit/templates-audit.d.ts +63 -0
- package/dist/audit/templates-audit.d.ts.map +1 -0
- package/dist/audit/templates-audit.js +171 -0
- package/dist/audit/templates-fix-plan-llm.d.ts +19 -0
- package/dist/audit/templates-fix-plan-llm.d.ts.map +1 -0
- package/dist/audit/templates-fix-plan-llm.js +162 -0
- package/dist/audit/templates-fix-plan.d.ts +37 -0
- package/dist/audit/templates-fix-plan.d.ts.map +1 -0
- package/dist/audit/templates-fix-plan.js +174 -0
- package/dist/command-registry.d.ts +28 -0
- package/dist/command-registry.d.ts.map +1 -1
- package/dist/command-registry.js +91 -1
- package/dist/commands/ai-status.command.d.ts +19 -0
- package/dist/commands/ai-status.command.d.ts.map +1 -0
- package/dist/commands/ai-status.command.js +94 -0
- package/dist/commands/api-diff.command.d.ts +11 -0
- package/dist/commands/api-diff.command.d.ts.map +1 -0
- package/dist/commands/api-diff.command.js +144 -0
- package/dist/commands/apply.command.d.ts.map +1 -1
- package/dist/commands/apply.command.js +10 -2
- package/dist/commands/arch.command.d.ts +9 -0
- package/dist/commands/arch.command.d.ts.map +1 -0
- package/dist/commands/arch.command.js +186 -0
- package/dist/commands/ask.command.d.ts.map +1 -1
- package/dist/commands/ask.command.js +10 -9
- package/dist/commands/cache-align.command.d.ts +12 -0
- package/dist/commands/cache-align.command.d.ts.map +1 -0
- package/dist/commands/cache-align.command.js +78 -0
- package/dist/commands/check.command.d.ts.map +1 -1
- package/dist/commands/check.command.js +19 -2
- package/dist/commands/code-intel.command.d.ts +18 -0
- package/dist/commands/code-intel.command.d.ts.map +1 -0
- package/dist/commands/code-intel.command.js +146 -0
- package/dist/commands/codemod.command.d.ts.map +1 -1
- package/dist/commands/codemod.command.js +27 -6
- package/dist/commands/command-catalog.d.ts +15 -3
- package/dist/commands/command-catalog.d.ts.map +1 -1
- package/dist/commands/command-catalog.js +387 -34
- package/dist/commands/commands.command.d.ts.map +1 -1
- package/dist/commands/commands.command.js +4 -4
- package/dist/commands/completion.command.d.ts +10 -0
- package/dist/commands/completion.command.d.ts.map +1 -0
- package/dist/commands/completion.command.js +121 -0
- package/dist/commands/compress.command.d.ts +8 -0
- package/dist/commands/compress.command.d.ts.map +1 -0
- package/dist/commands/compress.command.js +147 -0
- package/dist/commands/constructs.command.d.ts.map +1 -1
- package/dist/commands/constructs.command.js +89 -23
- package/dist/commands/context.command.d.ts.map +1 -1
- package/dist/commands/context.command.js +121 -1
- package/dist/commands/contract-gate.command.d.ts.map +1 -1
- package/dist/commands/contract-gate.command.js +5 -1
- 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.d.ts +23 -0
- package/dist/commands/deps-audit.command.d.ts.map +1 -0
- package/dist/commands/deps-audit.command.js +270 -0
- package/dist/commands/dev.command.d.ts.map +1 -1
- package/dist/commands/dev.command.js +5 -1
- package/dist/commands/diff-check.command.d.ts +30 -0
- package/dist/commands/diff-check.command.d.ts.map +1 -0
- package/dist/commands/diff-check.command.js +210 -0
- package/dist/commands/doctor.command.d.ts.map +1 -1
- package/dist/commands/doctor.command.js +162 -10
- package/dist/commands/export.command.d.ts.map +1 -1
- package/dist/commands/export.command.js +76 -3
- package/dist/commands/framework.command.d.ts +12 -0
- package/dist/commands/framework.command.d.ts.map +1 -0
- package/dist/commands/framework.command.js +180 -0
- package/dist/commands/gate.command.d.ts +15 -0
- package/dist/commands/gate.command.d.ts.map +1 -0
- package/dist/commands/gate.command.js +300 -0
- 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 +33 -0
- package/dist/commands/graph-code-subverbs.d.ts.map +1 -0
- package/dist/commands/graph-code-subverbs.js +1366 -0
- package/dist/commands/graph.command.d.ts.map +1 -1
- package/dist/commands/graph.command.js +31 -2
- package/dist/commands/help.command.d.ts +4 -3
- package/dist/commands/help.command.d.ts.map +1 -1
- package/dist/commands/help.command.js +86 -18
- package/dist/commands/helper.command.js +1 -1
- package/dist/commands/impact.command.d.ts.map +1 -1
- package/dist/commands/impact.command.js +171 -1
- package/dist/commands/import.command.d.ts.map +1 -1
- package/dist/commands/import.command.js +121 -5
- package/dist/commands/ingest.command.d.ts.map +1 -1
- package/dist/commands/ingest.command.js +5 -1
- package/dist/commands/init.command.d.ts.map +1 -1
- package/dist/commands/init.command.js +174 -7
- 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 +26 -3
- package/dist/commands/migrate.command.d.ts +13 -0
- package/dist/commands/migrate.command.d.ts.map +1 -0
- package/dist/commands/migrate.command.js +152 -0
- package/dist/commands/move-plan.command.d.ts +23 -0
- package/dist/commands/move-plan.command.d.ts.map +1 -0
- package/dist/commands/move-plan.command.js +360 -0
- package/dist/commands/packs-new.d.ts +1 -1
- package/dist/commands/packs-new.d.ts.map +1 -1
- package/dist/commands/packs-new.js +5 -36
- package/dist/commands/packs.command.d.ts.map +1 -1
- package/dist/commands/packs.command.js +2 -10
- package/dist/commands/plan-context.command.d.ts +11 -0
- package/dist/commands/plan-context.command.d.ts.map +1 -0
- package/dist/commands/plan-context.command.js +85 -0
- package/dist/commands/preflight.command.d.ts.map +1 -1
- package/dist/commands/preflight.command.js +15 -0
- package/dist/commands/profiles.command.js +4 -4
- 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 +119 -5
- package/dist/commands/release.command.js +13 -13
- package/dist/commands/rule-graph-subverbs.d.ts +3 -0
- package/dist/commands/rule-graph-subverbs.d.ts.map +1 -0
- package/dist/commands/rule-graph-subverbs.js +132 -0
- package/dist/commands/rules.command.d.ts.map +1 -1
- package/dist/commands/rules.command.js +20 -3
- package/dist/commands/scaffold-validate.command.d.ts +22 -0
- package/dist/commands/scaffold-validate.command.d.ts.map +1 -0
- package/dist/commands/scaffold-validate.command.js +215 -0
- package/dist/commands/search-structural.command.d.ts +18 -0
- package/dist/commands/search-structural.command.d.ts.map +1 -0
- package/dist/commands/search-structural.command.js +376 -0
- package/dist/commands/search.command.js +1 -1
- package/dist/commands/smart-context.command.d.ts +67 -0
- package/dist/commands/smart-context.command.d.ts.map +1 -0
- package/dist/commands/smart-context.command.js +4728 -0
- package/dist/commands/spike.command.d.ts +22 -0
- package/dist/commands/spike.command.d.ts.map +1 -0
- package/dist/commands/spike.command.js +235 -0
- package/dist/commands/surface.command.d.ts +1 -0
- package/dist/commands/surface.command.d.ts.map +1 -1
- package/dist/commands/surface.command.js +10 -3
- package/dist/commands/task-context.command.d.ts.map +1 -1
- package/dist/commands/task-context.command.js +5 -17
- package/dist/commands/task.command.d.ts.map +1 -1
- package/dist/commands/task.command.js +8 -2
- package/dist/commands/template-quality.command.d.ts.map +1 -1
- package/dist/commands/template-quality.command.js +39 -3
- package/dist/commands/templates.command.d.ts.map +1 -1
- package/dist/commands/templates.command.js +37 -2
- package/dist/commands/tests.command.d.ts.map +1 -1
- package/dist/commands/tests.command.js +13 -2
- package/dist/commands/watch.command.d.ts +26 -0
- package/dist/commands/watch.command.d.ts.map +1 -0
- package/dist/commands/watch.command.js +456 -0
- package/dist/dashboard/code-intelligence-data.d.ts +33 -0
- package/dist/dashboard/code-intelligence-data.d.ts.map +1 -0
- package/dist/dashboard/code-intelligence-data.js +329 -0
- package/dist/dashboard/dashboard-api-server.d.ts.map +1 -1
- package/dist/dashboard/dashboard-api-server.js +256 -2
- package/dist/dashboard/knowledge-ask.d.ts +4 -0
- package/dist/dashboard/knowledge-ask.d.ts.map +1 -0
- package/dist/dashboard/knowledge-ask.js +112 -0
- package/dist/env/load-dotenv.d.ts +15 -0
- package/dist/env/load-dotenv.d.ts.map +1 -0
- package/dist/env/load-dotenv.js +70 -0
- package/dist/export/claude-commands-export.d.ts +60 -0
- package/dist/export/claude-commands-export.d.ts.map +1 -0
- package/dist/export/claude-commands-export.js +276 -0
- package/dist/export/export-formats.d.ts +1 -1
- package/dist/export/export-formats.d.ts.map +1 -1
- package/dist/export/export-formats.js +139 -12
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/init/init-templates.d.ts.map +1 -1
- package/dist/init/init-templates.js +133 -113
- package/dist/init/paths-advisory.d.ts +20 -0
- package/dist/init/paths-advisory.d.ts.map +1 -0
- package/dist/init/paths-advisory.js +88 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +331 -17
- package/dist/output/ccr-store-config.d.ts +18 -0
- package/dist/output/ccr-store-config.d.ts.map +1 -0
- package/dist/output/ccr-store-config.js +41 -0
- package/dist/output/format-output.d.ts.map +1 -1
- package/dist/output/format-output.js +6 -1
- package/dist/output/output-compression.d.ts +15 -0
- package/dist/output/output-compression.d.ts.map +1 -0
- package/dist/output/output-compression.js +60 -0
- package/dist/output/resolve-compress-type.d.ts +22 -0
- package/dist/output/resolve-compress-type.d.ts.map +1 -0
- package/dist/output/resolve-compress-type.js +21 -0
- package/dist/output/watch-loop.d.ts +9 -1
- package/dist/output/watch-loop.d.ts.map +1 -1
- package/dist/output/watch-loop.js +13 -3
- package/dist/schemas/json-schemas.d.ts +384 -36
- package/dist/schemas/json-schemas.d.ts.map +1 -1
- package/dist/schemas/json-schemas.js +247 -36
- package/dist/surface/profiles.d.ts.map +1 -1
- package/dist/surface/profiles.js +54 -9
- package/dist/surface/surface-config-writer.d.ts.map +1 -1
- package/dist/surface/surface-config-writer.js +23 -11
- package/dist/validation/run-validation-loop.d.ts.map +1 -1
- package/dist/validation/run-validation-loop.js +5 -1
- package/package.json +35 -21
- package/dist/commands/plugin.command.d.ts +0 -11
- package/dist/commands/plugin.command.d.ts.map +0 -1
- package/dist/commands/plugin.command.js +0 -394
|
@@ -0,0 +1,1366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI subverbs for the `@shrkcrft/graph` code-intelligence layer.
|
|
3
|
+
*
|
|
4
|
+
* Lives separately from `graph.command.ts` to keep the dispatch file
|
|
5
|
+
* focused. The entry command imports each `run*` and routes when the
|
|
6
|
+
* first positional matches the subverb name.
|
|
7
|
+
*/
|
|
8
|
+
import { buildFullIndex, changedFilesSince, detectChangedAndDeleted, detectGraphFreshness, EdgeKind, GraphQueryApi, GraphStore, hasCallGraphReferences, NodeKind, updateChanged, } from '@shrkcrft/graph';
|
|
9
|
+
import { analyzeGraphImpact } from '@shrkcrft/impact-engine';
|
|
10
|
+
import { BridgeStore, RuleGraphQueryApi } from '@shrkcrft/rule-graph';
|
|
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';
|
|
15
|
+
import { flagBool, flagString, resolveCwd } from "../command-registry.js";
|
|
16
|
+
import { asJson, header, kv } from "../output/format-output.js";
|
|
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
|
+
}
|
|
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
|
+
}
|
|
125
|
+
// ─── shrk graph index ─────────────────────────────────────────────────
|
|
126
|
+
export async function runGraphIndex(args) {
|
|
127
|
+
// --watch: run the index once, then re-run on file changes. Every
|
|
128
|
+
// tick after the first uses the incremental updater so a 5-file edit
|
|
129
|
+
// takes < 100ms. Default watch path is the project root; pass
|
|
130
|
+
// `--paths a,b,c` to narrow.
|
|
131
|
+
const watchExit = await maybeRunInWatchMode(args, async (inner) => {
|
|
132
|
+
const innerFlags = new Map(inner.flags);
|
|
133
|
+
innerFlags.set('changed', true);
|
|
134
|
+
return runGraphIndexOnce({ ...inner, flags: innerFlags });
|
|
135
|
+
}, { defaultPaths: ['.'] });
|
|
136
|
+
if (watchExit !== null)
|
|
137
|
+
return watchExit;
|
|
138
|
+
return runGraphIndexOnce(args);
|
|
139
|
+
}
|
|
140
|
+
async function runGraphIndexOnce(args) {
|
|
141
|
+
const cwd = resolveCwd(args);
|
|
142
|
+
const wantJson = flagBool(args, 'json');
|
|
143
|
+
const wantChanged = flagBool(args, 'changed');
|
|
144
|
+
const since = flagString(args, 'since');
|
|
145
|
+
const wantFull = flagBool(args, 'full');
|
|
146
|
+
// Incremental path: --changed OR --since OR no store yet but the user
|
|
147
|
+
// asked for incremental — fall through to a full build in that case.
|
|
148
|
+
const store = new GraphStore(cwd);
|
|
149
|
+
const isIncremental = (wantChanged || since) && !wantFull;
|
|
150
|
+
if (isIncremental && store.exists()) {
|
|
151
|
+
let changed = [];
|
|
152
|
+
let deleted = [];
|
|
153
|
+
if (since) {
|
|
154
|
+
changed = changedFilesSince(cwd, since);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const detected = detectChangedAndDeleted(cwd);
|
|
158
|
+
changed = detected.changed;
|
|
159
|
+
deleted = detected.deleted;
|
|
160
|
+
}
|
|
161
|
+
const result = updateChanged({ projectRoot: cwd, changedFiles: changed, deletedFiles: deleted });
|
|
162
|
+
if (wantJson) {
|
|
163
|
+
process.stdout.write(asJson({
|
|
164
|
+
ok: true,
|
|
165
|
+
mode: 'incremental',
|
|
166
|
+
manifest: result.manifest,
|
|
167
|
+
durationMs: result.durationMs,
|
|
168
|
+
updated: result.updated,
|
|
169
|
+
deleted: result.deleted,
|
|
170
|
+
skipped: result.skipped,
|
|
171
|
+
}) + '\n');
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
process.stdout.write(header('Graph index (incremental)'));
|
|
175
|
+
process.stdout.write(kv('updated', String(result.updated.length)) + '\n');
|
|
176
|
+
process.stdout.write(kv('deleted', String(result.deleted.length)) + '\n');
|
|
177
|
+
process.stdout.write(kv('skipped', String(result.skipped.length)) + '\n');
|
|
178
|
+
process.stdout.write(kv('files total', String(result.manifest.filesIndexed)) + '\n');
|
|
179
|
+
process.stdout.write(kv('duration', `${result.durationMs}ms`) + '\n');
|
|
180
|
+
process.stdout.write(kv('digest', result.manifest.digest.slice(0, 12) + '…') + '\n');
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
// Full path.
|
|
184
|
+
const result = buildFullIndex({ projectRoot: cwd });
|
|
185
|
+
if (wantJson) {
|
|
186
|
+
process.stdout.write(asJson({
|
|
187
|
+
ok: true,
|
|
188
|
+
mode: 'full',
|
|
189
|
+
manifest: result.manifest,
|
|
190
|
+
durationMs: result.durationMs,
|
|
191
|
+
}) + '\n');
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
process.stdout.write(header('Graph index'));
|
|
195
|
+
process.stdout.write(kv('files', String(result.manifest.filesIndexed)) + '\n');
|
|
196
|
+
process.stdout.write(kv('nodes', String(sumValues(result.manifest.nodesByKind))) + '\n');
|
|
197
|
+
process.stdout.write(kv('edges', String(sumValues(result.manifest.edgesByKind))) + '\n');
|
|
198
|
+
process.stdout.write(kv('packages', String(result.manifest.workspacePackages.length)) + '\n');
|
|
199
|
+
if (typeof result.manifest.cycleCount === 'number') {
|
|
200
|
+
const largest = typeof result.manifest.largestCycleSize === 'number' && result.manifest.largestCycleSize > 0
|
|
201
|
+
? ` (largest ${result.manifest.largestCycleSize})`
|
|
202
|
+
: '';
|
|
203
|
+
process.stdout.write(kv('cycles', `${result.manifest.cycleCount}${largest}`) + '\n');
|
|
204
|
+
}
|
|
205
|
+
if (typeof result.manifest.unresolvedImportCount === 'number' &&
|
|
206
|
+
result.manifest.unresolvedImportCount > 0) {
|
|
207
|
+
process.stdout.write(kv('unresolved imports', `${result.manifest.unresolvedImportCount} across ${result.manifest.filesWithUnresolvedImports ?? 0} file(s)`) + '\n');
|
|
208
|
+
}
|
|
209
|
+
process.stdout.write(kv('duration', `${result.durationMs}ms`) + '\n');
|
|
210
|
+
process.stdout.write(kv('digest', result.manifest.digest.slice(0, 12) + '…') + '\n');
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
// ─── shrk graph cycles ────────────────────────────────────────────────
|
|
214
|
+
export async function runGraphCycles(args) {
|
|
215
|
+
const cwd = resolveCwd(args);
|
|
216
|
+
const wantJson = flagBool(args, 'json');
|
|
217
|
+
const limit = parseLimit(args);
|
|
218
|
+
const minSize = parseMinSize(args);
|
|
219
|
+
const store = new GraphStore(cwd);
|
|
220
|
+
if (!store.exists()) {
|
|
221
|
+
if (wantJson) {
|
|
222
|
+
process.stdout.write(asJson({
|
|
223
|
+
ok: false,
|
|
224
|
+
state: 'missing',
|
|
225
|
+
nextCommand: 'shrk graph index',
|
|
226
|
+
message: STALE_HINT,
|
|
227
|
+
}) + '\n');
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
230
|
+
process.stderr.write(STALE_HINT + '\n');
|
|
231
|
+
return 1;
|
|
232
|
+
}
|
|
233
|
+
const api = GraphQueryApi.fromStore(cwd);
|
|
234
|
+
const allCycles = api.cycles();
|
|
235
|
+
const filtered = allCycles.filter((c) => c.size >= minSize);
|
|
236
|
+
const limited = filtered.slice(0, limit);
|
|
237
|
+
if (wantJson) {
|
|
238
|
+
process.stdout.write(asJson({
|
|
239
|
+
ok: true,
|
|
240
|
+
total: filtered.length,
|
|
241
|
+
truncated: filtered.length > limit,
|
|
242
|
+
cycles: limited.map((c) => ({
|
|
243
|
+
size: c.size,
|
|
244
|
+
paths: c.paths ?? c.nodeIds.map((id) => id.replace(/^file:/, '')),
|
|
245
|
+
})),
|
|
246
|
+
}) + '\n');
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
process.stdout.write(header('Graph cycles'));
|
|
250
|
+
process.stdout.write(kv('total', String(filtered.length)) + '\n');
|
|
251
|
+
if (filtered.length === 0) {
|
|
252
|
+
process.stdout.write('\nNo cycles in the file-import graph. ✓\n');
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
process.stdout.write(kv('shown', `${limited.length}/${filtered.length}`) + '\n');
|
|
256
|
+
process.stdout.write('\n');
|
|
257
|
+
for (let i = 0; i < limited.length; i += 1) {
|
|
258
|
+
const c = limited[i];
|
|
259
|
+
const paths = c.paths ?? c.nodeIds.map((id) => id.replace(/^file:/, ''));
|
|
260
|
+
process.stdout.write(`#${i + 1} (size ${c.size}):\n`);
|
|
261
|
+
for (const p of paths)
|
|
262
|
+
process.stdout.write(` ${p}\n`);
|
|
263
|
+
// Closing arrow indicates the cycle wraps back to the first node.
|
|
264
|
+
if (paths[0])
|
|
265
|
+
process.stdout.write(` → ${paths[0]}\n`);
|
|
266
|
+
if (i + 1 < limited.length)
|
|
267
|
+
process.stdout.write('\n');
|
|
268
|
+
}
|
|
269
|
+
if (filtered.length > limit) {
|
|
270
|
+
process.stdout.write(`\n(${filtered.length - limit} more — pass --limit ${filtered.length} to see all)\n`);
|
|
271
|
+
}
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
function parseLimit(args) {
|
|
275
|
+
const raw = flagString(args, 'limit');
|
|
276
|
+
if (!raw)
|
|
277
|
+
return 20;
|
|
278
|
+
const n = Number.parseInt(raw, 10);
|
|
279
|
+
return Number.isFinite(n) && n > 0 ? n : 20;
|
|
280
|
+
}
|
|
281
|
+
function parseMinSize(args) {
|
|
282
|
+
const raw = flagString(args, 'min-size');
|
|
283
|
+
if (!raw)
|
|
284
|
+
return 2;
|
|
285
|
+
const n = Number.parseInt(raw, 10);
|
|
286
|
+
return Number.isFinite(n) && n >= 2 ? n : 2;
|
|
287
|
+
}
|
|
288
|
+
// ─── shrk graph unresolved ────────────────────────────────────────────
|
|
289
|
+
export async function runGraphUnresolved(args) {
|
|
290
|
+
const cwd = resolveCwd(args);
|
|
291
|
+
const wantJson = flagBool(args, 'json');
|
|
292
|
+
const limit = parseLimit(args);
|
|
293
|
+
const store = new GraphStore(cwd);
|
|
294
|
+
if (!store.exists()) {
|
|
295
|
+
if (wantJson) {
|
|
296
|
+
process.stdout.write(asJson({
|
|
297
|
+
ok: false,
|
|
298
|
+
state: 'missing',
|
|
299
|
+
nextCommand: 'shrk graph index',
|
|
300
|
+
message: STALE_HINT,
|
|
301
|
+
}) + '\n');
|
|
302
|
+
return 1;
|
|
303
|
+
}
|
|
304
|
+
process.stderr.write(STALE_HINT + '\n');
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
const snap = store.loadSnapshot();
|
|
308
|
+
const groups = new Map();
|
|
309
|
+
for (const e of snap.edges.values()) {
|
|
310
|
+
if (e.kind !== EdgeKind.ImportsFile)
|
|
311
|
+
continue;
|
|
312
|
+
if (!e.to.startsWith('unresolved:'))
|
|
313
|
+
continue;
|
|
314
|
+
const fromNode = snap.nodes.get(e.from);
|
|
315
|
+
const g = groups.get(e.from);
|
|
316
|
+
const spec = e.to.slice('unresolved:'.length);
|
|
317
|
+
if (g) {
|
|
318
|
+
g.specifiers.push(spec);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
groups.set(e.from, {
|
|
322
|
+
from: e.from,
|
|
323
|
+
...(fromNode?.path ? { path: fromNode.path } : {}),
|
|
324
|
+
specifiers: [spec],
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const list = [...groups.values()].sort((a, b) => {
|
|
329
|
+
if (b.specifiers.length !== a.specifiers.length) {
|
|
330
|
+
return b.specifiers.length - a.specifiers.length;
|
|
331
|
+
}
|
|
332
|
+
return (a.path ?? a.from).localeCompare(b.path ?? b.from);
|
|
333
|
+
});
|
|
334
|
+
// De-dupe specifiers per file + sort, so the output is stable.
|
|
335
|
+
for (const g of list)
|
|
336
|
+
g.specifiers = [...new Set(g.specifiers)].sort();
|
|
337
|
+
const total = list.reduce((n, g) => n + g.specifiers.length, 0);
|
|
338
|
+
const limited = list.slice(0, limit);
|
|
339
|
+
if (wantJson) {
|
|
340
|
+
process.stdout.write(asJson({
|
|
341
|
+
ok: true,
|
|
342
|
+
totalEdges: total,
|
|
343
|
+
totalFiles: list.length,
|
|
344
|
+
truncated: list.length > limit,
|
|
345
|
+
files: limited.map((g) => ({
|
|
346
|
+
path: g.path ?? g.from.replace(/^file:/, ''),
|
|
347
|
+
unresolved: g.specifiers,
|
|
348
|
+
})),
|
|
349
|
+
}) + '\n');
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
process.stdout.write(header('Unresolved imports'));
|
|
353
|
+
process.stdout.write(kv('total edges', String(total)) + '\n');
|
|
354
|
+
process.stdout.write(kv('files', String(list.length)) + '\n');
|
|
355
|
+
if (list.length === 0) {
|
|
356
|
+
process.stdout.write('\nNo unresolved imports. ✓\n');
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
process.stdout.write(kv('shown', `${limited.length}/${list.length}`) + '\n');
|
|
360
|
+
process.stdout.write('\n');
|
|
361
|
+
for (const g of limited) {
|
|
362
|
+
process.stdout.write(`${g.path ?? g.from.replace(/^file:/, '')}\n`);
|
|
363
|
+
for (const s of g.specifiers) {
|
|
364
|
+
process.stdout.write(` • ${s}\n`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (list.length > limit) {
|
|
368
|
+
process.stdout.write(`\n(${list.length - limit} more — pass --limit ${list.length} to see all)\n`);
|
|
369
|
+
}
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
// ─── shrk graph deps ──────────────────────────────────────────────────
|
|
373
|
+
export async function runGraphDeps(args) {
|
|
374
|
+
const cwd = resolveCwd(args);
|
|
375
|
+
const wantJson = flagBool(args, 'json');
|
|
376
|
+
const pkg = args.positional[0];
|
|
377
|
+
if (!pkg) {
|
|
378
|
+
if (wantJson) {
|
|
379
|
+
process.stdout.write(asJson({ ok: false, error: 'missing-package' }) + '\n');
|
|
380
|
+
return 2;
|
|
381
|
+
}
|
|
382
|
+
process.stderr.write('Usage: shrk graph deps <package-name> [--json]\n');
|
|
383
|
+
return 2;
|
|
384
|
+
}
|
|
385
|
+
const store = new GraphStore(cwd);
|
|
386
|
+
if (!store.exists()) {
|
|
387
|
+
if (wantJson) {
|
|
388
|
+
process.stdout.write(asJson({
|
|
389
|
+
ok: false,
|
|
390
|
+
state: 'missing',
|
|
391
|
+
nextCommand: 'shrk graph index',
|
|
392
|
+
message: STALE_HINT,
|
|
393
|
+
}) + '\n');
|
|
394
|
+
return 1;
|
|
395
|
+
}
|
|
396
|
+
process.stderr.write(STALE_HINT + '\n');
|
|
397
|
+
return 1;
|
|
398
|
+
}
|
|
399
|
+
const api = GraphQueryApi.fromStore(cwd);
|
|
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
|
+
}
|
|
412
|
+
// outbound: packages this one depends on
|
|
413
|
+
const outbound = api.packageDeps(pkg).map((n) => n.id.replace(/^package:/, ''));
|
|
414
|
+
// inbound: packages that depend on this one
|
|
415
|
+
const inbound = [];
|
|
416
|
+
for (const p of api.allPackages()) {
|
|
417
|
+
const name = p.id.replace(/^package:/, '');
|
|
418
|
+
if (name === pkg)
|
|
419
|
+
continue;
|
|
420
|
+
if (api.packageDeps(name).some((n) => n.id === pkgId))
|
|
421
|
+
inbound.push(name);
|
|
422
|
+
}
|
|
423
|
+
outbound.sort();
|
|
424
|
+
inbound.sort();
|
|
425
|
+
if (wantJson) {
|
|
426
|
+
process.stdout.write(asJson({
|
|
427
|
+
ok: true,
|
|
428
|
+
package: pkg,
|
|
429
|
+
dependsOn: outbound,
|
|
430
|
+
dependedOnBy: inbound,
|
|
431
|
+
}) + '\n');
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
434
|
+
process.stdout.write(header(`Package deps: ${pkg}`));
|
|
435
|
+
process.stdout.write(kv('depends on', String(outbound.length)) + '\n');
|
|
436
|
+
process.stdout.write(kv('depended on by', String(inbound.length)) + '\n');
|
|
437
|
+
if (outbound.length > 0) {
|
|
438
|
+
process.stdout.write('\nDepends on:\n');
|
|
439
|
+
for (const n of outbound)
|
|
440
|
+
process.stdout.write(` → ${n}\n`);
|
|
441
|
+
}
|
|
442
|
+
if (inbound.length > 0) {
|
|
443
|
+
process.stdout.write('\nDepended on by:\n');
|
|
444
|
+
for (const n of inbound)
|
|
445
|
+
process.stdout.write(` ← ${n}\n`);
|
|
446
|
+
}
|
|
447
|
+
if (outbound.length === 0 && inbound.length === 0) {
|
|
448
|
+
process.stdout.write('\n(no workspace-internal edges)\n');
|
|
449
|
+
}
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
// ─── shrk graph status ────────────────────────────────────────────────
|
|
453
|
+
export async function runGraphStatus(args) {
|
|
454
|
+
const cwd = resolveCwd(args);
|
|
455
|
+
const wantJson = flagBool(args, 'json');
|
|
456
|
+
const store = new GraphStore(cwd);
|
|
457
|
+
if (!store.exists()) {
|
|
458
|
+
const payload = {
|
|
459
|
+
ok: false,
|
|
460
|
+
state: 'missing',
|
|
461
|
+
nextCommand: 'shrk graph index',
|
|
462
|
+
message: STALE_HINT,
|
|
463
|
+
};
|
|
464
|
+
if (wantJson) {
|
|
465
|
+
process.stdout.write(asJson(payload) + '\n');
|
|
466
|
+
return 1;
|
|
467
|
+
}
|
|
468
|
+
process.stderr.write(STALE_HINT + '\n');
|
|
469
|
+
return 1;
|
|
470
|
+
}
|
|
471
|
+
const verify = store.verifyDigest();
|
|
472
|
+
const snap = store.loadSnapshot();
|
|
473
|
+
const manifestNodeCount = sumValues(snap.manifest.nodesByKind);
|
|
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';
|
|
481
|
+
const payload = {
|
|
482
|
+
ok: verify.ok,
|
|
483
|
+
state,
|
|
484
|
+
schema: snap.manifest.schema,
|
|
485
|
+
fileCount: snap.manifest.filesIndexed,
|
|
486
|
+
nodeCount: manifestNodeCount,
|
|
487
|
+
edgeCount: manifestEdgeCount,
|
|
488
|
+
lastIndexedAt: snap.manifest.lastIndexedAt,
|
|
489
|
+
lastIndexDurationMs: snap.manifest.lastIndexDurationMs,
|
|
490
|
+
workspacePackages: snap.manifest.workspacePackages,
|
|
491
|
+
cycleCount: snap.manifest.cycleCount ?? null,
|
|
492
|
+
largestCycleSize: snap.manifest.largestCycleSize ?? null,
|
|
493
|
+
filesInCycles: snap.manifest.filesInCycles ?? null,
|
|
494
|
+
unresolvedImportCount: snap.manifest.unresolvedImportCount ?? null,
|
|
495
|
+
filesWithUnresolvedImports: snap.manifest.filesWithUnresolvedImports ?? null,
|
|
496
|
+
unresolvedImportSamples: snap.manifest.unresolvedImportSamples ?? null,
|
|
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' } : {}),
|
|
502
|
+
};
|
|
503
|
+
if (wantJson) {
|
|
504
|
+
process.stdout.write(asJson(payload) + '\n');
|
|
505
|
+
return verify.ok ? 0 : 1;
|
|
506
|
+
}
|
|
507
|
+
process.stdout.write(header('Graph status'));
|
|
508
|
+
process.stdout.write(kv('schema', payload.schema) + '\n');
|
|
509
|
+
process.stdout.write(kv('files', String(payload.fileCount)) + '\n');
|
|
510
|
+
process.stdout.write(kv('nodes', String(payload.nodeCount)) + '\n');
|
|
511
|
+
process.stdout.write(kv('edges', String(payload.edgeCount)) + '\n');
|
|
512
|
+
process.stdout.write(kv('packages', String(payload.workspacePackages.length)) + '\n');
|
|
513
|
+
if (typeof payload.cycleCount === 'number') {
|
|
514
|
+
const largest = payload.largestCycleSize ? ` (largest ${payload.largestCycleSize})` : '';
|
|
515
|
+
process.stdout.write(kv('cycles', `${payload.cycleCount}${largest}`) + '\n');
|
|
516
|
+
}
|
|
517
|
+
if (typeof payload.unresolvedImportCount === 'number' && payload.unresolvedImportCount > 0) {
|
|
518
|
+
process.stdout.write(kv('unresolved imports', `${payload.unresolvedImportCount} across ${payload.filesWithUnresolvedImports ?? 0} file(s)`) + '\n');
|
|
519
|
+
}
|
|
520
|
+
process.stdout.write(kv('last indexed', payload.lastIndexedAt) + '\n');
|
|
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
|
+
}
|
|
525
|
+
return verify.ok ? 0 : 1;
|
|
526
|
+
}
|
|
527
|
+
// ─── shrk graph search ────────────────────────────────────────────────
|
|
528
|
+
export async function runGraphSearch(args) {
|
|
529
|
+
const cwd = resolveCwd(args);
|
|
530
|
+
const wantJson = flagBool(args, 'json');
|
|
531
|
+
const query = args.positional[1];
|
|
532
|
+
const hasUnresolved = flagBool(args, 'has-unresolved-imports');
|
|
533
|
+
if (!query && !hasUnresolved) {
|
|
534
|
+
process.stderr.write('Usage: shrk graph search <query> [--kind file|symbol|package] [--limit N]\n' +
|
|
535
|
+
' shrk graph search --kind file --has-unresolved-imports [--limit N]\n');
|
|
536
|
+
return 2;
|
|
537
|
+
}
|
|
538
|
+
const kindFlag = flagString(args, 'kind');
|
|
539
|
+
const limit = Number(flagString(args, 'limit') ?? '20');
|
|
540
|
+
maybeRefresh(args, cwd);
|
|
541
|
+
const api = loadOrFail(cwd, wantJson);
|
|
542
|
+
if (!api)
|
|
543
|
+
return 1;
|
|
544
|
+
let matches;
|
|
545
|
+
if (hasUnresolved) {
|
|
546
|
+
const all = api.filesWithUnresolvedImports();
|
|
547
|
+
matches = (query
|
|
548
|
+
? all.filter((n) => (n.path ?? '').toLowerCase().includes(query.toLowerCase()))
|
|
549
|
+
: [...all]).slice(0, limit);
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
matches = collectSearchMatches(api, query, kindFlag, limit);
|
|
553
|
+
}
|
|
554
|
+
if (wantJson) {
|
|
555
|
+
process.stdout.write(asJson({
|
|
556
|
+
schema: 'sharkcraft.graph-search/v1',
|
|
557
|
+
query,
|
|
558
|
+
kind: kindFlag ?? 'any',
|
|
559
|
+
total: matches.length,
|
|
560
|
+
matches: matches.map(toSearchHit),
|
|
561
|
+
}) + '\n');
|
|
562
|
+
return 0;
|
|
563
|
+
}
|
|
564
|
+
const headerLabel = query ?? (hasUnresolved ? 'files with unresolved imports' : '');
|
|
565
|
+
if (matches.length === 0) {
|
|
566
|
+
process.stdout.write(`No matches for "${headerLabel}".\n`);
|
|
567
|
+
return 0;
|
|
568
|
+
}
|
|
569
|
+
process.stdout.write(header(`Graph search: ${headerLabel}`));
|
|
570
|
+
for (const m of matches) {
|
|
571
|
+
process.stdout.write(` ${m.kind.padEnd(8)} ${m.label}${m.path ? ' ' + m.path : ''}${m.line ? ':' + m.line : ''}\n`);
|
|
572
|
+
}
|
|
573
|
+
return 0;
|
|
574
|
+
}
|
|
575
|
+
// ─── shrk graph context ───────────────────────────────────────────────
|
|
576
|
+
export async function runGraphContext(args) {
|
|
577
|
+
const cwd = resolveCwd(args);
|
|
578
|
+
const wantJson = flagBool(args, 'json');
|
|
579
|
+
const target = args.positional[1];
|
|
580
|
+
if (!target) {
|
|
581
|
+
process.stderr.write('Usage: shrk graph context <fileOrSymbol> [--depth N] [--no-bridge] [--no-framework]\n');
|
|
582
|
+
return 2;
|
|
583
|
+
}
|
|
584
|
+
const depth = Math.max(1, Math.min(3, Number(flagString(args, 'depth') ?? '1')));
|
|
585
|
+
const includeBridge = !flagBool(args, 'no-bridge');
|
|
586
|
+
const includeFramework = !flagBool(args, 'no-framework');
|
|
587
|
+
maybeRefresh(args, cwd);
|
|
588
|
+
const api = loadOrFail(cwd, wantJson);
|
|
589
|
+
if (!api)
|
|
590
|
+
return 1;
|
|
591
|
+
const anchor = resolveAnchor(api, target);
|
|
592
|
+
if (!anchor) {
|
|
593
|
+
const hint = indexBehindHint(cwd);
|
|
594
|
+
const payload = { ok: false, error: 'not-found', target, ...(hint ? { hint } : {}) };
|
|
595
|
+
if (wantJson) {
|
|
596
|
+
process.stdout.write(asJson(payload) + '\n');
|
|
597
|
+
return 1;
|
|
598
|
+
}
|
|
599
|
+
process.stderr.write(`No graph node matched "${target}".${hint ? ' ' + hint : ''}\n`);
|
|
600
|
+
return 1;
|
|
601
|
+
}
|
|
602
|
+
const anchorFile = anchor.kind === NodeKind.File
|
|
603
|
+
? anchor
|
|
604
|
+
: declaringFileOf(api, anchor.id) ?? (anchor.path ? api.findFile(anchor.path) : undefined);
|
|
605
|
+
const subjectNodeId = anchorFile?.id ?? anchor.id;
|
|
606
|
+
const neighbours = api.neighbours(subjectNodeId);
|
|
607
|
+
const symbols = anchor.kind === NodeKind.File ? api.symbolsIn(anchor.id) : [];
|
|
608
|
+
const references = anchor.kind === NodeKind.Symbol ? dedupeNodes(api.referencesOf(anchor.id)) : [];
|
|
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)) : [];
|
|
614
|
+
// Optional bridge enrichment: rules / paths / templates applying to
|
|
615
|
+
// the anchor file (or a symbol's containing file).
|
|
616
|
+
const bridgeStore = new BridgeStore(cwd);
|
|
617
|
+
const bridgeFor = (includeBridge && bridgeStore.exists() && anchorFile?.path)
|
|
618
|
+
? RuleGraphQueryApi.fromStores(cwd).forFile(anchorFile.path)
|
|
619
|
+
: undefined;
|
|
620
|
+
// Optional framework enrichment.
|
|
621
|
+
const frameworkStore = new FrameworkStore(cwd);
|
|
622
|
+
const frameworkEntities = (includeFramework && frameworkStore.exists() && anchorFile?.path)
|
|
623
|
+
? FrameworkQueryApi.fromStore(cwd).forFile(anchorFile.path)
|
|
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));
|
|
646
|
+
const payload = {
|
|
647
|
+
schema: 'sharkcraft.graph-context/v1',
|
|
648
|
+
anchor: nodeSummary(anchor),
|
|
649
|
+
declaredIn: anchor.kind === NodeKind.Symbol && anchorFile ? nodeSummary(anchorFile) : null,
|
|
650
|
+
depth,
|
|
651
|
+
importsFrom: ctxDropDel(importsFromList),
|
|
652
|
+
importedBy: ctxDropDel(importedByList),
|
|
653
|
+
symbols: symbols.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 ?? {}),
|
|
659
|
+
bridge: bridgeFor
|
|
660
|
+
? {
|
|
661
|
+
rules: bridgeFor.rules.map((h) => ({
|
|
662
|
+
id: h.target.id,
|
|
663
|
+
label: h.target.label,
|
|
664
|
+
severity: h.edge.data?.['severity'] ?? undefined,
|
|
665
|
+
})),
|
|
666
|
+
paths: bridgeFor.paths.map((h) => ({ id: h.target.id, label: h.target.label })),
|
|
667
|
+
templates: bridgeFor.templates.map((h) => ({ id: h.target.id, label: h.target.label })),
|
|
668
|
+
}
|
|
669
|
+
: null,
|
|
670
|
+
framework: frameworkEntities.length > 0
|
|
671
|
+
? {
|
|
672
|
+
entities: frameworkEntities.map((n) => ({
|
|
673
|
+
id: n.id,
|
|
674
|
+
label: n.label,
|
|
675
|
+
framework: n.data?.['framework'] ?? null,
|
|
676
|
+
subtype: n.data?.['subtype'] ?? null,
|
|
677
|
+
})),
|
|
678
|
+
}
|
|
679
|
+
: null,
|
|
680
|
+
};
|
|
681
|
+
if (wantJson) {
|
|
682
|
+
process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
|
|
683
|
+
return 0;
|
|
684
|
+
}
|
|
685
|
+
process.stdout.write(header(`Graph context: ${anchor.kind}:${anchor.label}`));
|
|
686
|
+
process.stdout.write(kv('path', anchor.path ?? '(none)') + '\n');
|
|
687
|
+
if (anchor.line)
|
|
688
|
+
process.stdout.write(kv('line', String(anchor.line)) + '\n');
|
|
689
|
+
if (payload.declaredIn) {
|
|
690
|
+
process.stdout.write(kv('declared in', payload.declaredIn.path ?? payload.declaredIn.id) + '\n');
|
|
691
|
+
}
|
|
692
|
+
if (payload.symbols.length > 0) {
|
|
693
|
+
process.stdout.write(`\nDeclares ${payload.symbols.length} symbols:\n`);
|
|
694
|
+
for (const s of payload.symbols.slice(0, 20)) {
|
|
695
|
+
process.stdout.write(` ${s.label}${s.line ? ':' + s.line : ''}\n`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (payload.referencedBy.length > 0) {
|
|
699
|
+
process.stdout.write(`\nReferenced by (${payload.referencedBy.length}):\n`);
|
|
700
|
+
for (const r of payload.referencedBy.slice(0, 20)) {
|
|
701
|
+
process.stdout.write(` ← ${r.path ?? r.id}\n`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (payload.calledBy.length > 0) {
|
|
705
|
+
process.stdout.write(`\nCalled by (${payload.calledBy.length}):\n`);
|
|
706
|
+
for (const c of payload.calledBy.slice(0, 20)) {
|
|
707
|
+
process.stdout.write(` ← ${c.path ?? c.id}\n`);
|
|
708
|
+
}
|
|
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
|
+
}
|
|
722
|
+
if (payload.importsFrom.length > 0) {
|
|
723
|
+
process.stdout.write(`\nImports from (${payload.importsFrom.length}):\n`);
|
|
724
|
+
for (const o of payload.importsFrom.slice(0, 20)) {
|
|
725
|
+
process.stdout.write(` → ${describeTarget(o)}\n`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (payload.importedBy.length > 0) {
|
|
729
|
+
process.stdout.write(`\nImported by (${payload.importedBy.length}):\n`);
|
|
730
|
+
for (const i of payload.importedBy.slice(0, 20)) {
|
|
731
|
+
process.stdout.write(` ← ${describeTarget(i)}\n`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (payload.bridge) {
|
|
735
|
+
if (payload.bridge.rules.length > 0) {
|
|
736
|
+
process.stdout.write(`\nApplies rules (${payload.bridge.rules.length}):\n`);
|
|
737
|
+
for (const r of payload.bridge.rules.slice(0, 10)) {
|
|
738
|
+
process.stdout.write(` • ${r.id}${r.severity ? ` [${r.severity}]` : ''} — ${r.label}\n`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (payload.bridge.paths.length > 0) {
|
|
742
|
+
process.stdout.write(`\nPath conventions (${payload.bridge.paths.length}):\n`);
|
|
743
|
+
for (const p of payload.bridge.paths.slice(0, 10)) {
|
|
744
|
+
process.stdout.write(` • ${p.id} — ${p.label}\n`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (payload.bridge.templates.length > 0) {
|
|
748
|
+
process.stdout.write(`\nCovered by templates (${payload.bridge.templates.length}):\n`);
|
|
749
|
+
for (const t of payload.bridge.templates.slice(0, 10)) {
|
|
750
|
+
process.stdout.write(` • ${t.id} — ${t.label}\n`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (payload.framework && payload.framework.entities.length > 0) {
|
|
755
|
+
process.stdout.write(`\nFramework entities (${payload.framework.entities.length}):\n`);
|
|
756
|
+
for (const e of payload.framework.entities.slice(0, 10)) {
|
|
757
|
+
process.stdout.write(` • ${e.framework}:${e.subtype} ${e.label}\n`);
|
|
758
|
+
}
|
|
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
|
+
}
|
|
763
|
+
return 0;
|
|
764
|
+
}
|
|
765
|
+
// ─── shrk graph impact ────────────────────────────────────────────────
|
|
766
|
+
export async function runGraphImpact(args) {
|
|
767
|
+
const cwd = resolveCwd(args);
|
|
768
|
+
const wantJson = flagBool(args, 'json');
|
|
769
|
+
const wantFull = flagBool(args, 'full');
|
|
770
|
+
const target = args.positional[1];
|
|
771
|
+
if (!target) {
|
|
772
|
+
process.stderr.write('Usage: shrk graph impact <fileOrSymbol> [--max-depth N] [--limit N] [--full]\n');
|
|
773
|
+
return 2;
|
|
774
|
+
}
|
|
775
|
+
const maxDepth = Math.max(1, Math.min(10, Number(flagString(args, 'max-depth') ?? '5')));
|
|
776
|
+
const limit = Math.max(1, Number(flagString(args, 'limit') ?? '200'));
|
|
777
|
+
maybeRefresh(args, cwd);
|
|
778
|
+
// --full → delegate to the impact-engine for a richer v3 payload.
|
|
779
|
+
if (wantFull) {
|
|
780
|
+
const isSymbol = target.startsWith('symbol:') || /^[A-Za-z_][\w$]*$/.test(target);
|
|
781
|
+
const input = isSymbol && !target.includes('/')
|
|
782
|
+
? { kind: 'symbol', symbolId: target }
|
|
783
|
+
: { kind: 'files', files: [target] };
|
|
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);
|
|
799
|
+
if (wantJson) {
|
|
800
|
+
process.stdout.write(asJson(behind ? { ...analysis, staleHint: behind } : analysis) + '\n');
|
|
801
|
+
return 0;
|
|
802
|
+
}
|
|
803
|
+
process.stdout.write(header(`Graph impact (full): ${target}`));
|
|
804
|
+
process.stdout.write(kv('risk', analysis.risk) + '\n');
|
|
805
|
+
process.stdout.write(kv('direct', String(analysis.directDependents.length)) + '\n');
|
|
806
|
+
process.stdout.write(kv('transitive', String(analysis.transitiveDependents.length)) + '\n');
|
|
807
|
+
process.stdout.write(kv('symbols', String(analysis.affectedSymbols.length)) + '\n');
|
|
808
|
+
process.stdout.write(kv('caller files', String(analysis.affectedCallerFiles.length)) + '\n');
|
|
809
|
+
process.stdout.write(kv('packages', String(analysis.affectedPackages.length)) + '\n');
|
|
810
|
+
process.stdout.write(kv('rules', String(analysis.affectedRules.length)) + '\n');
|
|
811
|
+
process.stdout.write(kv('templates', String(analysis.affectedTemplates.length)) + '\n');
|
|
812
|
+
process.stdout.write(kv('likely tests', String(analysis.likelyTests.length)) + '\n');
|
|
813
|
+
process.stdout.write(kv('public API touched', analysis.publicApiTouched ? 'yes' : 'no') + '\n');
|
|
814
|
+
if (analysis.riskReasons.length > 0) {
|
|
815
|
+
process.stdout.write('\nRisk reasons:\n');
|
|
816
|
+
for (const r of analysis.riskReasons)
|
|
817
|
+
process.stdout.write(` • ${r}\n`);
|
|
818
|
+
}
|
|
819
|
+
if (analysis.validationScope.length > 0) {
|
|
820
|
+
process.stdout.write('\nRun before merging:\n');
|
|
821
|
+
for (const c of analysis.validationScope)
|
|
822
|
+
process.stdout.write(` $ ${c}\n`);
|
|
823
|
+
}
|
|
824
|
+
for (const d of analysis.diagnostics.slice(0, 5))
|
|
825
|
+
process.stdout.write(`! ${d}\n`);
|
|
826
|
+
if (behind)
|
|
827
|
+
process.stdout.write(`\n ⚠ ${behind}\n`);
|
|
828
|
+
return 0;
|
|
829
|
+
}
|
|
830
|
+
const api = loadOrFail(cwd, wantJson);
|
|
831
|
+
if (!api)
|
|
832
|
+
return 1;
|
|
833
|
+
const anchor = resolveAnchor(api, target);
|
|
834
|
+
if (!anchor) {
|
|
835
|
+
const hint = indexBehindHint(cwd);
|
|
836
|
+
const payload = { ok: false, error: 'not-found', target, ...(hint ? { hint } : {}) };
|
|
837
|
+
if (wantJson) {
|
|
838
|
+
process.stdout.write(asJson(payload) + '\n');
|
|
839
|
+
return 1;
|
|
840
|
+
}
|
|
841
|
+
process.stderr.write(`No graph node matched "${target}".${hint ? ' ' + hint : ''}\n`);
|
|
842
|
+
return 1;
|
|
843
|
+
}
|
|
844
|
+
const closure = reverseClosure(api, anchor, maxDepth, limit);
|
|
845
|
+
const direct = closure.layer[1] ?? [];
|
|
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));
|
|
857
|
+
const payload = {
|
|
858
|
+
schema: 'sharkcraft.graph-impact/v1',
|
|
859
|
+
anchor: nodeSummary(anchor),
|
|
860
|
+
maxDepth,
|
|
861
|
+
limit,
|
|
862
|
+
truncated: closure.truncated,
|
|
863
|
+
directDependents: liveDirect,
|
|
864
|
+
transitiveDependents: liveTransitive,
|
|
865
|
+
totalReached: closure.all.length - 1,
|
|
866
|
+
...(fresh.field ?? {}),
|
|
867
|
+
};
|
|
868
|
+
if (wantJson) {
|
|
869
|
+
process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
|
|
870
|
+
return 0;
|
|
871
|
+
}
|
|
872
|
+
process.stdout.write(header(`Graph impact: ${anchor.label}`));
|
|
873
|
+
process.stdout.write(kv('direct', String(liveDirect.length)) + '\n');
|
|
874
|
+
process.stdout.write(kv('transitive', String(liveTransitive.length)) + '\n');
|
|
875
|
+
process.stdout.write(kv('max-depth', String(maxDepth)) + '\n');
|
|
876
|
+
if (closure.truncated)
|
|
877
|
+
process.stdout.write(kv('truncated', 'yes') + '\n');
|
|
878
|
+
for (const d of liveDirect.slice(0, 30)) {
|
|
879
|
+
process.stdout.write(` ${d.path ?? d.id}\n`);
|
|
880
|
+
}
|
|
881
|
+
// No silent caps: when the reverse closure hit the limit, say so explicitly so
|
|
882
|
+
// the reader knows the blast radius is larger than what's shown.
|
|
883
|
+
if (closure.truncated) {
|
|
884
|
+
process.stdout.write(`\n ⓘ Showing ${liveDirect.length + liveTransitive.length} of ${payload.totalReached} dependents (capped at --limit ${limit}); raise --limit to see the full blast radius.\n`);
|
|
885
|
+
}
|
|
886
|
+
if (fresh.field) {
|
|
887
|
+
process.stdout.write(`\n ⚠ ${fresh.modified.length} dependent file(s) changed, ${fresh.deleted.length} deleted since indexing — run \`shrk graph index --changed\`.\n`);
|
|
888
|
+
}
|
|
889
|
+
return 0;
|
|
890
|
+
}
|
|
891
|
+
// ─── shrk graph hubs ──────────────────────────────────────────────────
|
|
892
|
+
/**
|
|
893
|
+
* `shrk graph hubs` — the most-depended-on code: symbols ranked by how many
|
|
894
|
+
* DISTINCT files reference them, files by how many import them. The
|
|
895
|
+
* "load-bearing code" an agent should change most carefully and a human should
|
|
896
|
+
* understand first — the natural companion to `graph impact` (impact = blast
|
|
897
|
+
* radius of ONE node; hubs = the nodes with the biggest blast radius).
|
|
898
|
+
*/
|
|
899
|
+
export async function runGraphHubs(args) {
|
|
900
|
+
const cwd = resolveCwd(args);
|
|
901
|
+
const wantJson = flagBool(args, 'json');
|
|
902
|
+
const limit = Math.max(1, Math.min(100, Number(flagString(args, 'limit') ?? '15')));
|
|
903
|
+
const pathScope = flagString(args, 'path');
|
|
904
|
+
maybeRefresh(args, cwd);
|
|
905
|
+
const api = loadOrFail(cwd, wantJson);
|
|
906
|
+
if (!api)
|
|
907
|
+
return 1;
|
|
908
|
+
const hubs = api.topHubs(limit, pathScope);
|
|
909
|
+
const toRow = (h) => ({
|
|
910
|
+
...nodeSummary(h.node),
|
|
911
|
+
inDegree: h.inDegree,
|
|
912
|
+
});
|
|
913
|
+
const payload = {
|
|
914
|
+
schema: 'sharkcraft.graph-hubs/v1',
|
|
915
|
+
...(pathScope ? { path: pathScope } : {}),
|
|
916
|
+
symbols: hubs.symbols.map(toRow),
|
|
917
|
+
files: hubs.files.map(toRow),
|
|
918
|
+
};
|
|
919
|
+
if (wantJson) {
|
|
920
|
+
process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
|
|
921
|
+
return 0;
|
|
922
|
+
}
|
|
923
|
+
process.stdout.write(header(`Graph hubs (most-depended-on)${pathScope ? ` under ${pathScope}` : ''}`));
|
|
924
|
+
if (hubs.symbols.length === 0 && hubs.files.length === 0) {
|
|
925
|
+
process.stdout.write(pathScope
|
|
926
|
+
? ` No referenced/imported code under "${pathScope}" (check the path, or the call/reference graph is TS/JS-only).\n`
|
|
927
|
+
: ' No reference/import edges yet (call/reference graph is TS/JS-only — run `shrk graph index`).\n');
|
|
928
|
+
return 0;
|
|
929
|
+
}
|
|
930
|
+
if (hubs.symbols.length > 0) {
|
|
931
|
+
process.stdout.write('\nMost-referenced symbols (distinct dependent files):\n');
|
|
932
|
+
for (const h of hubs.symbols) {
|
|
933
|
+
process.stdout.write(` ${String(h.inDegree).padStart(4)} ${h.node.label}${h.node.path ? ' ' + h.node.path : ''}${h.node.line ? ':' + h.node.line : ''}\n`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (hubs.files.length > 0) {
|
|
937
|
+
process.stdout.write('\nMost-imported files (distinct importers):\n');
|
|
938
|
+
for (const h of hubs.files) {
|
|
939
|
+
process.stdout.write(` ${String(h.inDegree).padStart(4)} ${h.node.path ?? h.node.id}\n`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return 0;
|
|
943
|
+
}
|
|
944
|
+
// ─── shrk graph callers ───────────────────────────────────────────────
|
|
945
|
+
export async function runGraphCallers(args) {
|
|
946
|
+
const cwd = resolveCwd(args);
|
|
947
|
+
const wantJson = flagBool(args, 'json');
|
|
948
|
+
const target = args.positional[1];
|
|
949
|
+
if (!target) {
|
|
950
|
+
process.stderr.write('Usage: shrk graph callers <symbol> [--mode call|reference] [--limit N] [--no-refresh]\n');
|
|
951
|
+
return 2;
|
|
952
|
+
}
|
|
953
|
+
const mode = (flagString(args, 'mode') ?? 'call');
|
|
954
|
+
// --limit N: cap the returned call sites (default 200). `total` still reports
|
|
955
|
+
// the true uncapped count, so a truncated result stays honest. Guard against
|
|
956
|
+
// non-numeric input — `Number('foo')` is NaN and `slice(0, NaN)` would zero
|
|
957
|
+
// the callers list while `total` kept showing the real count.
|
|
958
|
+
const parsedLimit = Number.parseInt(flagString(args, 'limit') ?? '200', 10);
|
|
959
|
+
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 200;
|
|
960
|
+
maybeRefresh(args, cwd);
|
|
961
|
+
const api = loadOrFail(cwd, wantJson);
|
|
962
|
+
if (!api)
|
|
963
|
+
return 1;
|
|
964
|
+
const resolved = resolveSymbolTarget(api, target);
|
|
965
|
+
if (!resolved) {
|
|
966
|
+
const behind = indexBehindHint(cwd);
|
|
967
|
+
const payload = { ok: false, error: 'not-found', target, ...(behind ? { hint: behind } : {}) };
|
|
968
|
+
if (wantJson) {
|
|
969
|
+
process.stdout.write(asJson(payload) + '\n');
|
|
970
|
+
return 1;
|
|
971
|
+
}
|
|
972
|
+
process.stderr.write(`No symbol matched "${target}".${behind ? ' ' + behind : ''}\n`);
|
|
973
|
+
return 1;
|
|
974
|
+
}
|
|
975
|
+
const { sym, alsoNamed } = resolved;
|
|
976
|
+
const sites = mode === 'reference' ? api.referenceSitesOf(sym.id) : api.callerSitesOf(sym.id);
|
|
977
|
+
// Targeted staleness over the result files (declaring file + caller files):
|
|
978
|
+
// drop callers whose file was deleted, flag those whose content changed.
|
|
979
|
+
const fresh = resultStaleness(api, cwd, [sym.path, ...sites.map((s) => s.node.path)]);
|
|
980
|
+
const liveSites = sites.filter((s) => !s.node.path || !fresh.deletedSet.has(s.node.path));
|
|
981
|
+
const langNote = callGraphLanguageNote(api, sym);
|
|
982
|
+
// When several symbols share the name, callers are reported for ONE of them
|
|
983
|
+
// (the chosen — exported-preferred — declaration). Say so, otherwise the
|
|
984
|
+
// agent reads a narrow result as the whole picture for that name.
|
|
985
|
+
const ambiguityNote = alsoNamed > 0
|
|
986
|
+
? `${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.`
|
|
987
|
+
: undefined;
|
|
988
|
+
// `total` is distinct caller FILES: at index time the graph collapses many
|
|
989
|
+
// call/reference sites in one file to a single edge. Say so, otherwise `total`
|
|
990
|
+
// reads as a raw invocation count and under-reports.
|
|
991
|
+
const dedupNote = 'total counts distinct caller files — multiple sites within one file collapse to a single entry.';
|
|
992
|
+
const note = [ambiguityNote, langNote, dedupNote].filter(Boolean).join(' ');
|
|
993
|
+
const payload = {
|
|
994
|
+
schema: 'sharkcraft.graph-callers/v1',
|
|
995
|
+
symbol: nodeSummary(sym),
|
|
996
|
+
mode,
|
|
997
|
+
total: liveSites.length,
|
|
998
|
+
callers: liveSites.slice(0, limit).map((s) => ({
|
|
999
|
+
...nodeSummary(s.node),
|
|
1000
|
+
...(s.line ? { line: s.line } : {}),
|
|
1001
|
+
})),
|
|
1002
|
+
...(note ? { note } : {}),
|
|
1003
|
+
...(fresh.field ?? {}),
|
|
1004
|
+
};
|
|
1005
|
+
if (wantJson) {
|
|
1006
|
+
process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
|
|
1007
|
+
return 0;
|
|
1008
|
+
}
|
|
1009
|
+
process.stdout.write(header(`Graph callers: ${sym.label} (${mode})`));
|
|
1010
|
+
process.stdout.write(kv('total', String(liveSites.length)) + '\n');
|
|
1011
|
+
if (note)
|
|
1012
|
+
process.stdout.write(` ⓘ ${note}\n`);
|
|
1013
|
+
// Render `path:line` so the agent jumps straight to the call site instead
|
|
1014
|
+
// of having to grep inside each returned file.
|
|
1015
|
+
for (const c of payload.callers.slice(0, Math.min(50, limit))) {
|
|
1016
|
+
process.stdout.write(` ${c.path ?? c.id}${c.line ? ':' + c.line : ''}\n`);
|
|
1017
|
+
}
|
|
1018
|
+
if (fresh.field) {
|
|
1019
|
+
process.stdout.write(`\n ⚠ ${fresh.modified.length} result file(s) changed, ${fresh.deleted.length} deleted since indexing — run \`shrk graph index --changed\`.\n`);
|
|
1020
|
+
}
|
|
1021
|
+
return 0;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Resolve a callers target to a single symbol, reporting how many OTHER symbols
|
|
1025
|
+
* share the name (`alsoNamed`) so the caller can disclose the ambiguity instead
|
|
1026
|
+
* of silently picking one.
|
|
1027
|
+
*/
|
|
1028
|
+
function resolveSymbolTarget(api, target) {
|
|
1029
|
+
if (target.startsWith('symbol:')) {
|
|
1030
|
+
const node = api.neighbours(target)?.node;
|
|
1031
|
+
return node ? { sym: node, alsoNamed: 0 } : undefined;
|
|
1032
|
+
}
|
|
1033
|
+
const syms = api.findSymbol(target, { exact: true, limit: 5 });
|
|
1034
|
+
if (syms.length === 0)
|
|
1035
|
+
return undefined;
|
|
1036
|
+
if (syms.length === 1)
|
|
1037
|
+
return { sym: syms[0], alsoNamed: 0 };
|
|
1038
|
+
// Multiple symbols with the same name. Prefer an exported one if any.
|
|
1039
|
+
const exported = syms.find((s) => (s.data?.['isExported'] ?? false) === true);
|
|
1040
|
+
return { sym: exported ?? syms[0], alsoNamed: syms.length - 1 };
|
|
1041
|
+
}
|
|
1042
|
+
// ─── shrk graph path ──────────────────────────────────────────────────
|
|
1043
|
+
/**
|
|
1044
|
+
* `shrk graph path <from> <to>` — does code A actually reach code B?
|
|
1045
|
+
*
|
|
1046
|
+
* The question the original feedback fell back to grep for ("is billing
|
|
1047
|
+
* actually WIRED to checkout?"). `callers` = direct callers, `impact` =
|
|
1048
|
+
* reverse closure, `graph why` = the KNOWLEDGE graph — none answers the
|
|
1049
|
+
* forward CODE path between two symbols/files. This BFS does, over the
|
|
1050
|
+
* import/call/reference/declare/re-export/extends/implements edges, and
|
|
1051
|
+
* prints each hop with its edge kind (and call-site line) so the answer
|
|
1052
|
+
* shows HOW they are wired, not just that they are. When A→B has no path it
|
|
1053
|
+
* also checks B→A so "the dependency runs the other way" is reported instead
|
|
1054
|
+
* of a bare "no".
|
|
1055
|
+
*/
|
|
1056
|
+
export async function runGraphPath(args) {
|
|
1057
|
+
const cwd = resolveCwd(args);
|
|
1058
|
+
const wantJson = flagBool(args, 'json');
|
|
1059
|
+
const fromArg = args.positional[1];
|
|
1060
|
+
const toArg = args.positional[2];
|
|
1061
|
+
if (!fromArg || !toArg) {
|
|
1062
|
+
process.stderr.write('Usage: shrk graph path <from> <to> [--max-depth N] [--no-refresh] [--json]\n');
|
|
1063
|
+
return 2;
|
|
1064
|
+
}
|
|
1065
|
+
const maxDepth = Math.max(1, Math.min(32, Number(flagString(args, 'max-depth') ?? '16')));
|
|
1066
|
+
maybeRefresh(args, cwd);
|
|
1067
|
+
const api = loadOrFail(cwd, wantJson);
|
|
1068
|
+
if (!api)
|
|
1069
|
+
return 1;
|
|
1070
|
+
const from = resolveAnchor(api, fromArg);
|
|
1071
|
+
const to = resolveAnchor(api, toArg);
|
|
1072
|
+
if (!from || !to) {
|
|
1073
|
+
const missing = !from ? fromArg : toArg;
|
|
1074
|
+
const behind = indexBehindHint(cwd);
|
|
1075
|
+
const payload = { ok: false, error: 'not-found', target: missing, ...(behind ? { hint: behind } : {}) };
|
|
1076
|
+
if (wantJson) {
|
|
1077
|
+
process.stdout.write(asJson(payload) + '\n');
|
|
1078
|
+
return 1;
|
|
1079
|
+
}
|
|
1080
|
+
process.stderr.write(`No graph node matched "${missing}".${behind ? ' ' + behind : ''}\n`);
|
|
1081
|
+
return 1;
|
|
1082
|
+
}
|
|
1083
|
+
// A symbol node has no OUTGOING code edges — references/calls are recorded
|
|
1084
|
+
// file→symbol, so the out-edges live on the symbol's DECLARING FILE. To trace
|
|
1085
|
+
// "does A reach B" when A is a symbol, start the BFS from that file (and note
|
|
1086
|
+
// it), since per-symbol out-edges aren't tracked. The target may stay a symbol
|
|
1087
|
+
// (file→symbol edges land on it).
|
|
1088
|
+
const fromStart = bfsStartNode(api, from);
|
|
1089
|
+
const toStart = bfsStartNode(api, to);
|
|
1090
|
+
const forward = api.pathBetween(fromStart.id, to.id, { maxDepth });
|
|
1091
|
+
// If A doesn't reach B, the agent usually still wants to know whether B
|
|
1092
|
+
// reaches A (the dependency runs the other way) — so check the reverse and
|
|
1093
|
+
// report direction rather than a bare "no".
|
|
1094
|
+
const reverse = forward.found ? null : api.pathBetween(toStart.id, from.id, { maxDepth });
|
|
1095
|
+
const direction = forward.found
|
|
1096
|
+
? 'forward'
|
|
1097
|
+
: reverse?.found
|
|
1098
|
+
? 'reverse'
|
|
1099
|
+
: 'none';
|
|
1100
|
+
const chosen = forward.found ? forward : reverse?.found ? reverse : forward;
|
|
1101
|
+
// The endpoint the user asked for at the start of the chosen direction, plus
|
|
1102
|
+
// the file the BFS actually started from (differs only for a symbol endpoint).
|
|
1103
|
+
const startEndpoint = direction === 'reverse' ? to : from;
|
|
1104
|
+
const startFile = direction === 'reverse' ? toStart : fromStart;
|
|
1105
|
+
const startNote = direction !== 'none' && startFile.id !== startEndpoint.id && startEndpoint.kind === NodeKind.Symbol
|
|
1106
|
+
? `\`${startEndpoint.label}\` is declared in ${startFile.path ?? startFile.id}; path traced from that file (per-symbol out-edges are not tracked).`
|
|
1107
|
+
: null;
|
|
1108
|
+
const hopRows = chosen.hops.map((h) => ({
|
|
1109
|
+
from: h.from.path ?? h.from.id,
|
|
1110
|
+
to: h.to.path ?? h.to.id,
|
|
1111
|
+
kind: h.kind,
|
|
1112
|
+
label: h.to.label,
|
|
1113
|
+
...(h.line ? { line: h.line } : {}),
|
|
1114
|
+
}));
|
|
1115
|
+
const fresh = resultStaleness(api, cwd, [
|
|
1116
|
+
from.path,
|
|
1117
|
+
to.path,
|
|
1118
|
+
...chosen.hops.map((h) => h.from.path),
|
|
1119
|
+
...chosen.hops.map((h) => h.to.path),
|
|
1120
|
+
]);
|
|
1121
|
+
// A no-path answer between non-TS endpoints may just be missing call edges
|
|
1122
|
+
// (call/reference graph is TS/JS-only), NOT proof they are unwired.
|
|
1123
|
+
const langNote = direction === 'none' ? callGraphLanguageNote(api, from) ?? callGraphLanguageNote(api, to) : null;
|
|
1124
|
+
const note = startNote ?? langNote;
|
|
1125
|
+
const payload = {
|
|
1126
|
+
schema: 'sharkcraft.graph-path/v1',
|
|
1127
|
+
from: nodeSummary(from),
|
|
1128
|
+
to: nodeSummary(to),
|
|
1129
|
+
found: direction !== 'none',
|
|
1130
|
+
direction,
|
|
1131
|
+
...(direction !== 'none' && startFile.id !== startEndpoint.id ? { tracedFrom: nodeSummary(startFile) } : {}),
|
|
1132
|
+
hops: hopRows,
|
|
1133
|
+
hopCount: hopRows.length,
|
|
1134
|
+
explored: forward.found ? forward.explored : reverse?.explored ?? forward.explored,
|
|
1135
|
+
...(direction === 'none' && chosen.reason ? { reason: chosen.reason } : {}),
|
|
1136
|
+
...(note ? { note } : {}),
|
|
1137
|
+
...(fresh.field ?? {}),
|
|
1138
|
+
};
|
|
1139
|
+
if (wantJson) {
|
|
1140
|
+
process.stdout.write(asJson(maybeColumnarize(payload, args)) + '\n');
|
|
1141
|
+
return 0;
|
|
1142
|
+
}
|
|
1143
|
+
process.stdout.write(header(`Graph path: ${from.label} → ${to.label}`));
|
|
1144
|
+
if (direction === 'none') {
|
|
1145
|
+
process.stdout.write(` No code path ${from.label} → ${to.label} (or back) within ${maxDepth} hops.\n`);
|
|
1146
|
+
process.stdout.write(` explored ${payload.explored} node(s).\n`);
|
|
1147
|
+
if (langNote)
|
|
1148
|
+
process.stdout.write(` ⓘ ${langNote}\n`);
|
|
1149
|
+
return 0;
|
|
1150
|
+
}
|
|
1151
|
+
if (direction === 'reverse') {
|
|
1152
|
+
process.stdout.write(` No ${from.label} → ${to.label} path, but ${to.label} reaches ${from.label} (dependency runs the other way):\n\n`);
|
|
1153
|
+
}
|
|
1154
|
+
if (startNote)
|
|
1155
|
+
process.stdout.write(` ⓘ ${startNote}\n`);
|
|
1156
|
+
process.stdout.write(` ${startFile.path ?? startFile.label}\n`);
|
|
1157
|
+
for (const h of hopRows) {
|
|
1158
|
+
process.stdout.write(` ──${h.kind}──▶ ${h.to}${h.line ? ':' + h.line : ''}\n`);
|
|
1159
|
+
}
|
|
1160
|
+
process.stdout.write(`\n ${hopRows.length} hop(s).\n`);
|
|
1161
|
+
if (fresh.field) {
|
|
1162
|
+
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`);
|
|
1163
|
+
}
|
|
1164
|
+
return 0;
|
|
1165
|
+
}
|
|
1166
|
+
// ─── helpers ──────────────────────────────────────────────────────────
|
|
1167
|
+
function loadOrFail(cwd, wantJson) {
|
|
1168
|
+
const store = new GraphStore(cwd);
|
|
1169
|
+
if (!store.exists()) {
|
|
1170
|
+
if (wantJson) {
|
|
1171
|
+
process.stdout.write(asJson({
|
|
1172
|
+
ok: false,
|
|
1173
|
+
state: 'missing',
|
|
1174
|
+
nextCommand: 'shrk graph index',
|
|
1175
|
+
message: STALE_HINT,
|
|
1176
|
+
}) + '\n');
|
|
1177
|
+
}
|
|
1178
|
+
else {
|
|
1179
|
+
process.stderr.write(STALE_HINT + '\n');
|
|
1180
|
+
}
|
|
1181
|
+
return undefined;
|
|
1182
|
+
}
|
|
1183
|
+
return GraphQueryApi.fromStore(cwd);
|
|
1184
|
+
}
|
|
1185
|
+
function resolveAnchor(api, target) {
|
|
1186
|
+
// Exact node id wins.
|
|
1187
|
+
const direct = api.neighbours(target);
|
|
1188
|
+
if (direct)
|
|
1189
|
+
return direct.node;
|
|
1190
|
+
// Prefixed id forms.
|
|
1191
|
+
for (const prefix of ['file:', 'symbol:', 'package:']) {
|
|
1192
|
+
if (target.startsWith(prefix))
|
|
1193
|
+
return undefined;
|
|
1194
|
+
}
|
|
1195
|
+
// File path?
|
|
1196
|
+
const f = api.findFile(target);
|
|
1197
|
+
if (f)
|
|
1198
|
+
return f;
|
|
1199
|
+
// Symbol by name (exact).
|
|
1200
|
+
const syms = api.findSymbol(target, { exact: true, limit: 1 });
|
|
1201
|
+
if (syms.length > 0)
|
|
1202
|
+
return syms[0];
|
|
1203
|
+
return undefined;
|
|
1204
|
+
}
|
|
1205
|
+
function collectSearchMatches(api, query, kind, limit) {
|
|
1206
|
+
const out = [];
|
|
1207
|
+
if (!kind || kind === 'file') {
|
|
1208
|
+
const f = api.findFile(query);
|
|
1209
|
+
if (f)
|
|
1210
|
+
out.push(f);
|
|
1211
|
+
// Fuzzy fallback: substring match on path/basename so `shrk graph
|
|
1212
|
+
// search Foo --kind file` finds `libs/x/y/Foo.ts` without forcing the
|
|
1213
|
+
// caller to type the full path. Skips the node if exact match already
|
|
1214
|
+
// included it.
|
|
1215
|
+
if (out.length < limit) {
|
|
1216
|
+
const q = query.toLowerCase();
|
|
1217
|
+
const seen = new Set(out.map((n) => n.id));
|
|
1218
|
+
for (const node of api.allFiles()) {
|
|
1219
|
+
if (seen.has(node.id))
|
|
1220
|
+
continue;
|
|
1221
|
+
const p = node.path?.toLowerCase() ?? '';
|
|
1222
|
+
const base = p.includes('/') ? p.slice(p.lastIndexOf('/') + 1) : p;
|
|
1223
|
+
if (base.includes(q) || p.includes(q)) {
|
|
1224
|
+
out.push(node);
|
|
1225
|
+
seen.add(node.id);
|
|
1226
|
+
if (out.length >= limit)
|
|
1227
|
+
break;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
if (!kind || kind === 'symbol') {
|
|
1233
|
+
for (const s of api.findSymbol(query, { exact: false, limit }))
|
|
1234
|
+
out.push(s);
|
|
1235
|
+
}
|
|
1236
|
+
if (!kind || kind === 'package') {
|
|
1237
|
+
const p = api.neighbours(`package:${query}`);
|
|
1238
|
+
if (p)
|
|
1239
|
+
out.push(p.node);
|
|
1240
|
+
}
|
|
1241
|
+
return out.slice(0, limit);
|
|
1242
|
+
}
|
|
1243
|
+
function reverseClosure(api, anchor, maxDepth, limit) {
|
|
1244
|
+
const seen = new Set([anchor.id]);
|
|
1245
|
+
const layer = {};
|
|
1246
|
+
let frontier = directDependentsForAnchor(api, anchor);
|
|
1247
|
+
let truncated = false;
|
|
1248
|
+
frontier = frontier.filter((id) => !seen.has(id));
|
|
1249
|
+
if (frontier.length > limit) {
|
|
1250
|
+
frontier = frontier.slice(0, limit);
|
|
1251
|
+
truncated = true;
|
|
1252
|
+
}
|
|
1253
|
+
for (const id of frontier)
|
|
1254
|
+
seen.add(id);
|
|
1255
|
+
if (frontier.length > 0)
|
|
1256
|
+
layer[1] = frontier;
|
|
1257
|
+
let depth = 2;
|
|
1258
|
+
while (depth <= maxDepth && frontier.length > 0 && !truncated) {
|
|
1259
|
+
const next = [];
|
|
1260
|
+
for (const id of frontier) {
|
|
1261
|
+
for (const dep of nextDependents(api, anchor.kind, id)) {
|
|
1262
|
+
if (seen.has(dep.id))
|
|
1263
|
+
continue;
|
|
1264
|
+
seen.add(dep.id);
|
|
1265
|
+
next.push(dep.id);
|
|
1266
|
+
if (seen.size - 1 >= limit) {
|
|
1267
|
+
truncated = true;
|
|
1268
|
+
break;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
if (truncated)
|
|
1272
|
+
break;
|
|
1273
|
+
}
|
|
1274
|
+
if (next.length > 0)
|
|
1275
|
+
layer[depth] = next;
|
|
1276
|
+
frontier = next;
|
|
1277
|
+
depth += 1;
|
|
1278
|
+
}
|
|
1279
|
+
return { all: [...seen], layer, truncated };
|
|
1280
|
+
}
|
|
1281
|
+
function directDependentsForAnchor(api, anchor) {
|
|
1282
|
+
// Kind-aware direct dependents (symbol → refs/calls + subtype files, file →
|
|
1283
|
+
// importers, package → dependents) — the ONE shared implementation in the
|
|
1284
|
+
// graph query API, so the CLI + MCP impact closures never disagree.
|
|
1285
|
+
return api.directDependentsOf(anchor).map((n) => n.id);
|
|
1286
|
+
}
|
|
1287
|
+
function nextDependents(api, anchorKind, nodeId) {
|
|
1288
|
+
if (anchorKind === NodeKind.Package) {
|
|
1289
|
+
const node = api.neighbours(nodeId)?.node;
|
|
1290
|
+
if (!node)
|
|
1291
|
+
return [];
|
|
1292
|
+
return api.packageDependents(packageNameFor(node));
|
|
1293
|
+
}
|
|
1294
|
+
return api.importersOf(nodeId);
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* The node a code-path BFS should START from. Files carry their own outgoing
|
|
1298
|
+
* import/call/reference edges, so a file is its own start. A symbol does NOT —
|
|
1299
|
+
* those edges are recorded on its declaring file — so a symbol resolves to that
|
|
1300
|
+
* file (falling back to the symbol itself if the declaring file is unknown).
|
|
1301
|
+
*/
|
|
1302
|
+
function bfsStartNode(api, node) {
|
|
1303
|
+
if (node.kind !== NodeKind.Symbol)
|
|
1304
|
+
return node;
|
|
1305
|
+
return declaringFileOf(api, node.id) ?? (node.path ? api.findFile(node.path) : undefined) ?? node;
|
|
1306
|
+
}
|
|
1307
|
+
function declaringFileOf(api, symbolId) {
|
|
1308
|
+
const neighbours = api.neighbours(symbolId);
|
|
1309
|
+
if (!neighbours)
|
|
1310
|
+
return undefined;
|
|
1311
|
+
for (const incoming of neighbours.in) {
|
|
1312
|
+
if (incoming.edge.kind !== EdgeKind.DeclaresSymbol)
|
|
1313
|
+
continue;
|
|
1314
|
+
if ('resolved' in incoming.source)
|
|
1315
|
+
continue;
|
|
1316
|
+
if (incoming.source.kind === NodeKind.File)
|
|
1317
|
+
return incoming.source;
|
|
1318
|
+
}
|
|
1319
|
+
return undefined;
|
|
1320
|
+
}
|
|
1321
|
+
function packageNameFor(node) {
|
|
1322
|
+
return node.id.startsWith('package:') ? node.id.slice('package:'.length) : node.label;
|
|
1323
|
+
}
|
|
1324
|
+
function nodeSummary(n) {
|
|
1325
|
+
return {
|
|
1326
|
+
id: n.id,
|
|
1327
|
+
kind: n.kind,
|
|
1328
|
+
label: n.label,
|
|
1329
|
+
...(n.path ? { path: n.path } : {}),
|
|
1330
|
+
...(n.line ? { line: n.line } : {}),
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
function targetSummary(target) {
|
|
1334
|
+
if ('resolved' in target) {
|
|
1335
|
+
return { id: target.id, resolved: false };
|
|
1336
|
+
}
|
|
1337
|
+
return { id: target.id, resolved: true, kind: target.kind, label: target.label, ...(target.path ? { path: target.path } : {}) };
|
|
1338
|
+
}
|
|
1339
|
+
function sourceSummary(source) {
|
|
1340
|
+
return targetSummary(source);
|
|
1341
|
+
}
|
|
1342
|
+
function toSearchHit(n) {
|
|
1343
|
+
return nodeSummary(n);
|
|
1344
|
+
}
|
|
1345
|
+
function describeTarget(t) {
|
|
1346
|
+
if (!t.resolved)
|
|
1347
|
+
return t.id;
|
|
1348
|
+
return `${t.path ?? t.label ?? t.id}`;
|
|
1349
|
+
}
|
|
1350
|
+
function sumValues(record) {
|
|
1351
|
+
let n = 0;
|
|
1352
|
+
for (const v of Object.values(record))
|
|
1353
|
+
n += v;
|
|
1354
|
+
return n;
|
|
1355
|
+
}
|
|
1356
|
+
function dedupeNodes(nodes) {
|
|
1357
|
+
const seen = new Set();
|
|
1358
|
+
const out = [];
|
|
1359
|
+
for (const node of nodes) {
|
|
1360
|
+
if (seen.has(node.id))
|
|
1361
|
+
continue;
|
|
1362
|
+
seen.add(node.id);
|
|
1363
|
+
out.push(node);
|
|
1364
|
+
}
|
|
1365
|
+
return out;
|
|
1366
|
+
}
|