@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.
Files changed (228) hide show
  1. package/dist/audit/knowledge-audit-llm.d.ts +19 -0
  2. package/dist/audit/knowledge-audit-llm.d.ts.map +1 -0
  3. package/dist/audit/knowledge-audit-llm.js +164 -0
  4. package/dist/audit/knowledge-audit.d.ts +61 -0
  5. package/dist/audit/knowledge-audit.d.ts.map +1 -0
  6. package/dist/audit/knowledge-audit.js +203 -0
  7. package/dist/audit/knowledge-fix-plan-llm.d.ts +11 -0
  8. package/dist/audit/knowledge-fix-plan-llm.d.ts.map +1 -0
  9. package/dist/audit/knowledge-fix-plan-llm.js +141 -0
  10. package/dist/audit/knowledge-fix-plan.d.ts +41 -0
  11. package/dist/audit/knowledge-fix-plan.d.ts.map +1 -0
  12. package/dist/audit/knowledge-fix-plan.js +125 -0
  13. package/dist/audit/pipeline-audit-llm.d.ts +11 -0
  14. package/dist/audit/pipeline-audit-llm.d.ts.map +1 -0
  15. package/dist/audit/pipeline-audit-llm.js +134 -0
  16. package/dist/audit/pipeline-audit.d.ts +69 -0
  17. package/dist/audit/pipeline-audit.d.ts.map +1 -0
  18. package/dist/audit/pipeline-audit.js +166 -0
  19. package/dist/audit/templates-audit-llm.d.ts +19 -0
  20. package/dist/audit/templates-audit-llm.d.ts.map +1 -0
  21. package/dist/audit/templates-audit-llm.js +207 -0
  22. package/dist/audit/templates-audit.d.ts +63 -0
  23. package/dist/audit/templates-audit.d.ts.map +1 -0
  24. package/dist/audit/templates-audit.js +171 -0
  25. package/dist/audit/templates-fix-plan-llm.d.ts +19 -0
  26. package/dist/audit/templates-fix-plan-llm.d.ts.map +1 -0
  27. package/dist/audit/templates-fix-plan-llm.js +162 -0
  28. package/dist/audit/templates-fix-plan.d.ts +37 -0
  29. package/dist/audit/templates-fix-plan.d.ts.map +1 -0
  30. package/dist/audit/templates-fix-plan.js +174 -0
  31. package/dist/command-registry.d.ts +28 -0
  32. package/dist/command-registry.d.ts.map +1 -1
  33. package/dist/command-registry.js +91 -1
  34. package/dist/commands/ai-status.command.d.ts +19 -0
  35. package/dist/commands/ai-status.command.d.ts.map +1 -0
  36. package/dist/commands/ai-status.command.js +94 -0
  37. package/dist/commands/api-diff.command.d.ts +11 -0
  38. package/dist/commands/api-diff.command.d.ts.map +1 -0
  39. package/dist/commands/api-diff.command.js +144 -0
  40. package/dist/commands/apply.command.d.ts.map +1 -1
  41. package/dist/commands/apply.command.js +10 -2
  42. package/dist/commands/arch.command.d.ts +9 -0
  43. package/dist/commands/arch.command.d.ts.map +1 -0
  44. package/dist/commands/arch.command.js +186 -0
  45. package/dist/commands/ask.command.d.ts.map +1 -1
  46. package/dist/commands/ask.command.js +10 -9
  47. package/dist/commands/cache-align.command.d.ts +12 -0
  48. package/dist/commands/cache-align.command.d.ts.map +1 -0
  49. package/dist/commands/cache-align.command.js +78 -0
  50. package/dist/commands/check.command.d.ts.map +1 -1
  51. package/dist/commands/check.command.js +19 -2
  52. package/dist/commands/code-intel.command.d.ts +18 -0
  53. package/dist/commands/code-intel.command.d.ts.map +1 -0
  54. package/dist/commands/code-intel.command.js +146 -0
  55. package/dist/commands/codemod.command.d.ts.map +1 -1
  56. package/dist/commands/codemod.command.js +27 -6
  57. package/dist/commands/command-catalog.d.ts +15 -3
  58. package/dist/commands/command-catalog.d.ts.map +1 -1
  59. package/dist/commands/command-catalog.js +387 -34
  60. package/dist/commands/commands.command.d.ts.map +1 -1
  61. package/dist/commands/commands.command.js +4 -4
  62. package/dist/commands/completion.command.d.ts +10 -0
  63. package/dist/commands/completion.command.d.ts.map +1 -0
  64. package/dist/commands/completion.command.js +121 -0
  65. package/dist/commands/compress.command.d.ts +8 -0
  66. package/dist/commands/compress.command.d.ts.map +1 -0
  67. package/dist/commands/compress.command.js +147 -0
  68. package/dist/commands/constructs.command.d.ts.map +1 -1
  69. package/dist/commands/constructs.command.js +89 -23
  70. package/dist/commands/context.command.d.ts.map +1 -1
  71. package/dist/commands/context.command.js +121 -1
  72. package/dist/commands/contract-gate.command.d.ts.map +1 -1
  73. package/dist/commands/contract-gate.command.js +5 -1
  74. package/dist/commands/delegate.command.d.ts +65 -0
  75. package/dist/commands/delegate.command.d.ts.map +1 -0
  76. package/dist/commands/delegate.command.js +657 -0
  77. package/dist/commands/deps-audit.command.d.ts +23 -0
  78. package/dist/commands/deps-audit.command.d.ts.map +1 -0
  79. package/dist/commands/deps-audit.command.js +270 -0
  80. package/dist/commands/dev.command.d.ts.map +1 -1
  81. package/dist/commands/dev.command.js +5 -1
  82. package/dist/commands/diff-check.command.d.ts +30 -0
  83. package/dist/commands/diff-check.command.d.ts.map +1 -0
  84. package/dist/commands/diff-check.command.js +210 -0
  85. package/dist/commands/doctor.command.d.ts.map +1 -1
  86. package/dist/commands/doctor.command.js +162 -10
  87. package/dist/commands/export.command.d.ts.map +1 -1
  88. package/dist/commands/export.command.js +76 -3
  89. package/dist/commands/framework.command.d.ts +12 -0
  90. package/dist/commands/framework.command.d.ts.map +1 -0
  91. package/dist/commands/framework.command.js +180 -0
  92. package/dist/commands/gate.command.d.ts +15 -0
  93. package/dist/commands/gate.command.d.ts.map +1 -0
  94. package/dist/commands/gate.command.js +300 -0
  95. package/dist/commands/gen.command.d.ts.map +1 -1
  96. package/dist/commands/gen.command.js +13 -1
  97. package/dist/commands/graph-code-subverbs.d.ts +33 -0
  98. package/dist/commands/graph-code-subverbs.d.ts.map +1 -0
  99. package/dist/commands/graph-code-subverbs.js +1366 -0
  100. package/dist/commands/graph.command.d.ts.map +1 -1
  101. package/dist/commands/graph.command.js +31 -2
  102. package/dist/commands/help.command.d.ts +4 -3
  103. package/dist/commands/help.command.d.ts.map +1 -1
  104. package/dist/commands/help.command.js +86 -18
  105. package/dist/commands/helper.command.js +1 -1
  106. package/dist/commands/impact.command.d.ts.map +1 -1
  107. package/dist/commands/impact.command.js +171 -1
  108. package/dist/commands/import.command.d.ts.map +1 -1
  109. package/dist/commands/import.command.js +121 -5
  110. package/dist/commands/ingest.command.d.ts.map +1 -1
  111. package/dist/commands/ingest.command.js +5 -1
  112. package/dist/commands/init.command.d.ts.map +1 -1
  113. package/dist/commands/init.command.js +174 -7
  114. package/dist/commands/knowledge-author.command.d.ts.map +1 -1
  115. package/dist/commands/knowledge-author.command.js +9 -0
  116. package/dist/commands/knowledge-propose.command.d.ts.map +1 -1
  117. package/dist/commands/knowledge-propose.command.js +4 -2
  118. package/dist/commands/knowledge.command.d.ts.map +1 -1
  119. package/dist/commands/knowledge.command.js +26 -3
  120. package/dist/commands/migrate.command.d.ts +13 -0
  121. package/dist/commands/migrate.command.d.ts.map +1 -0
  122. package/dist/commands/migrate.command.js +152 -0
  123. package/dist/commands/move-plan.command.d.ts +23 -0
  124. package/dist/commands/move-plan.command.d.ts.map +1 -0
  125. package/dist/commands/move-plan.command.js +360 -0
  126. package/dist/commands/packs-new.d.ts +1 -1
  127. package/dist/commands/packs-new.d.ts.map +1 -1
  128. package/dist/commands/packs-new.js +5 -36
  129. package/dist/commands/packs.command.d.ts.map +1 -1
  130. package/dist/commands/packs.command.js +2 -10
  131. package/dist/commands/plan-context.command.d.ts +11 -0
  132. package/dist/commands/plan-context.command.d.ts.map +1 -0
  133. package/dist/commands/plan-context.command.js +85 -0
  134. package/dist/commands/preflight.command.d.ts.map +1 -1
  135. package/dist/commands/preflight.command.js +15 -0
  136. package/dist/commands/profiles.command.js +4 -4
  137. package/dist/commands/recommend.command.d.ts +6 -0
  138. package/dist/commands/recommend.command.d.ts.map +1 -1
  139. package/dist/commands/recommend.command.js +119 -5
  140. package/dist/commands/release.command.js +13 -13
  141. package/dist/commands/rule-graph-subverbs.d.ts +3 -0
  142. package/dist/commands/rule-graph-subverbs.d.ts.map +1 -0
  143. package/dist/commands/rule-graph-subverbs.js +132 -0
  144. package/dist/commands/rules.command.d.ts.map +1 -1
  145. package/dist/commands/rules.command.js +20 -3
  146. package/dist/commands/scaffold-validate.command.d.ts +22 -0
  147. package/dist/commands/scaffold-validate.command.d.ts.map +1 -0
  148. package/dist/commands/scaffold-validate.command.js +215 -0
  149. package/dist/commands/search-structural.command.d.ts +18 -0
  150. package/dist/commands/search-structural.command.d.ts.map +1 -0
  151. package/dist/commands/search-structural.command.js +376 -0
  152. package/dist/commands/search.command.js +1 -1
  153. package/dist/commands/smart-context.command.d.ts +67 -0
  154. package/dist/commands/smart-context.command.d.ts.map +1 -0
  155. package/dist/commands/smart-context.command.js +4728 -0
  156. package/dist/commands/spike.command.d.ts +22 -0
  157. package/dist/commands/spike.command.d.ts.map +1 -0
  158. package/dist/commands/spike.command.js +235 -0
  159. package/dist/commands/surface.command.d.ts +1 -0
  160. package/dist/commands/surface.command.d.ts.map +1 -1
  161. package/dist/commands/surface.command.js +10 -3
  162. package/dist/commands/task-context.command.d.ts.map +1 -1
  163. package/dist/commands/task-context.command.js +5 -17
  164. package/dist/commands/task.command.d.ts.map +1 -1
  165. package/dist/commands/task.command.js +8 -2
  166. package/dist/commands/template-quality.command.d.ts.map +1 -1
  167. package/dist/commands/template-quality.command.js +39 -3
  168. package/dist/commands/templates.command.d.ts.map +1 -1
  169. package/dist/commands/templates.command.js +37 -2
  170. package/dist/commands/tests.command.d.ts.map +1 -1
  171. package/dist/commands/tests.command.js +13 -2
  172. package/dist/commands/watch.command.d.ts +26 -0
  173. package/dist/commands/watch.command.d.ts.map +1 -0
  174. package/dist/commands/watch.command.js +456 -0
  175. package/dist/dashboard/code-intelligence-data.d.ts +33 -0
  176. package/dist/dashboard/code-intelligence-data.d.ts.map +1 -0
  177. package/dist/dashboard/code-intelligence-data.js +329 -0
  178. package/dist/dashboard/dashboard-api-server.d.ts.map +1 -1
  179. package/dist/dashboard/dashboard-api-server.js +256 -2
  180. package/dist/dashboard/knowledge-ask.d.ts +4 -0
  181. package/dist/dashboard/knowledge-ask.d.ts.map +1 -0
  182. package/dist/dashboard/knowledge-ask.js +112 -0
  183. package/dist/env/load-dotenv.d.ts +15 -0
  184. package/dist/env/load-dotenv.d.ts.map +1 -0
  185. package/dist/env/load-dotenv.js +70 -0
  186. package/dist/export/claude-commands-export.d.ts +60 -0
  187. package/dist/export/claude-commands-export.d.ts.map +1 -0
  188. package/dist/export/claude-commands-export.js +276 -0
  189. package/dist/export/export-formats.d.ts +1 -1
  190. package/dist/export/export-formats.d.ts.map +1 -1
  191. package/dist/export/export-formats.js +139 -12
  192. package/dist/index.d.ts +3 -0
  193. package/dist/index.d.ts.map +1 -1
  194. package/dist/index.js +3 -0
  195. package/dist/init/init-templates.d.ts.map +1 -1
  196. package/dist/init/init-templates.js +133 -113
  197. package/dist/init/paths-advisory.d.ts +20 -0
  198. package/dist/init/paths-advisory.d.ts.map +1 -0
  199. package/dist/init/paths-advisory.js +88 -0
  200. package/dist/main.d.ts.map +1 -1
  201. package/dist/main.js +331 -17
  202. package/dist/output/ccr-store-config.d.ts +18 -0
  203. package/dist/output/ccr-store-config.d.ts.map +1 -0
  204. package/dist/output/ccr-store-config.js +41 -0
  205. package/dist/output/format-output.d.ts.map +1 -1
  206. package/dist/output/format-output.js +6 -1
  207. package/dist/output/output-compression.d.ts +15 -0
  208. package/dist/output/output-compression.d.ts.map +1 -0
  209. package/dist/output/output-compression.js +60 -0
  210. package/dist/output/resolve-compress-type.d.ts +22 -0
  211. package/dist/output/resolve-compress-type.d.ts.map +1 -0
  212. package/dist/output/resolve-compress-type.js +21 -0
  213. package/dist/output/watch-loop.d.ts +9 -1
  214. package/dist/output/watch-loop.d.ts.map +1 -1
  215. package/dist/output/watch-loop.js +13 -3
  216. package/dist/schemas/json-schemas.d.ts +384 -36
  217. package/dist/schemas/json-schemas.d.ts.map +1 -1
  218. package/dist/schemas/json-schemas.js +247 -36
  219. package/dist/surface/profiles.d.ts.map +1 -1
  220. package/dist/surface/profiles.js +54 -9
  221. package/dist/surface/surface-config-writer.d.ts.map +1 -1
  222. package/dist/surface/surface-config-writer.js +23 -11
  223. package/dist/validation/run-validation-loop.d.ts.map +1 -1
  224. package/dist/validation/run-validation-loop.js +5 -1
  225. package/package.json +35 -21
  226. package/dist/commands/plugin.command.d.ts +0 -11
  227. package/dist/commands/plugin.command.d.ts.map +0 -1
  228. 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
+ }