@vyuhlabs/dxkit 2.6.0 → 2.7.1

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 (221) hide show
  1. package/CHANGELOG.md +103 -13
  2. package/README.md +208 -459
  3. package/dist/analyzers/bom/discovery.d.ts +3 -4
  4. package/dist/analyzers/bom/discovery.d.ts.map +1 -1
  5. package/dist/analyzers/bom/discovery.js +3 -4
  6. package/dist/analyzers/bom/discovery.js.map +1 -1
  7. package/dist/analyzers/bom/types.d.ts +1 -1
  8. package/dist/analyzers/dashboard/index.d.ts.map +1 -1
  9. package/dist/analyzers/dashboard/index.js +42 -5
  10. package/dist/analyzers/dashboard/index.js.map +1 -1
  11. package/dist/analyzers/developer/gather.d.ts.map +1 -1
  12. package/dist/analyzers/developer/gather.js +9 -9
  13. package/dist/analyzers/developer/gather.js.map +1 -1
  14. package/dist/analyzers/quality/detailed.d.ts +8 -1
  15. package/dist/analyzers/quality/detailed.d.ts.map +1 -1
  16. package/dist/analyzers/quality/detailed.js +43 -10
  17. package/dist/analyzers/quality/detailed.js.map +1 -1
  18. package/dist/analyzers/quality/gather.js +3 -3
  19. package/dist/analyzers/quality/gather.js.map +1 -1
  20. package/dist/analyzers/security/detailed.d.ts +8 -1
  21. package/dist/analyzers/security/detailed.d.ts.map +1 -1
  22. package/dist/analyzers/security/detailed.js +14 -1
  23. package/dist/analyzers/security/detailed.js.map +1 -1
  24. package/dist/analyzers/security/gather.d.ts.map +1 -1
  25. package/dist/analyzers/security/gather.js +12 -3
  26. package/dist/analyzers/security/gather.js.map +1 -1
  27. package/dist/analyzers/tests/detailed.d.ts +8 -1
  28. package/dist/analyzers/tests/detailed.d.ts.map +1 -1
  29. package/dist/analyzers/tests/detailed.js +26 -7
  30. package/dist/analyzers/tests/detailed.js.map +1 -1
  31. package/dist/analyzers/tools/cloc.js +5 -5
  32. package/dist/analyzers/tools/cloc.js.map +1 -1
  33. package/dist/analyzers/tools/exclusions.d.ts +12 -12
  34. package/dist/analyzers/tools/exclusions.d.ts.map +1 -1
  35. package/dist/analyzers/tools/exclusions.js +27 -13
  36. package/dist/analyzers/tools/exclusions.js.map +1 -1
  37. package/dist/analyzers/tools/generic.d.ts.map +1 -1
  38. package/dist/analyzers/tools/generic.js +52 -14
  39. package/dist/analyzers/tools/generic.js.map +1 -1
  40. package/dist/analyzers/tools/gitleaks.d.ts.map +1 -1
  41. package/dist/analyzers/tools/gitleaks.js +28 -3
  42. package/dist/analyzers/tools/gitleaks.js.map +1 -1
  43. package/dist/analyzers/tools/graphify.d.ts +39 -5
  44. package/dist/analyzers/tools/graphify.d.ts.map +1 -1
  45. package/dist/analyzers/tools/graphify.js +609 -45
  46. package/dist/analyzers/tools/graphify.js.map +1 -1
  47. package/dist/analyzers/tools/grep-secrets.d.ts.map +1 -1
  48. package/dist/analyzers/tools/grep-secrets.js +1 -1
  49. package/dist/analyzers/tools/grep-secrets.js.map +1 -1
  50. package/dist/analyzers/tools/jscpd.d.ts.map +1 -1
  51. package/dist/analyzers/tools/jscpd.js +2 -1
  52. package/dist/analyzers/tools/jscpd.js.map +1 -1
  53. package/dist/analyzers/tools/nuget-package-reference.d.ts +4 -4
  54. package/dist/analyzers/tools/nuget-package-reference.js +4 -4
  55. package/dist/analyzers/tools/osv-scanner-deps.d.ts.map +1 -1
  56. package/dist/analyzers/tools/osv-scanner-deps.js +1 -1
  57. package/dist/analyzers/tools/osv-scanner-deps.js.map +1 -1
  58. package/dist/analyzers/tools/osv-scanner-fix.d.ts +4 -5
  59. package/dist/analyzers/tools/osv-scanner-fix.d.ts.map +1 -1
  60. package/dist/analyzers/tools/osv-scanner-fix.js +4 -5
  61. package/dist/analyzers/tools/osv-scanner-fix.js.map +1 -1
  62. package/dist/analyzers/tools/parallel.d.ts.map +1 -1
  63. package/dist/analyzers/tools/parallel.js +7 -0
  64. package/dist/analyzers/tools/parallel.js.map +1 -1
  65. package/dist/analyzers/tools/runner.d.ts +35 -2
  66. package/dist/analyzers/tools/runner.d.ts.map +1 -1
  67. package/dist/analyzers/tools/runner.js +112 -3
  68. package/dist/analyzers/tools/runner.js.map +1 -1
  69. package/dist/analyzers/tools/semgrep.d.ts.map +1 -1
  70. package/dist/analyzers/tools/semgrep.js +3 -1
  71. package/dist/analyzers/tools/semgrep.js.map +1 -1
  72. package/dist/analyzers/tools/tool-registry.d.ts +18 -0
  73. package/dist/analyzers/tools/tool-registry.d.ts.map +1 -1
  74. package/dist/analyzers/tools/tool-registry.js +140 -53
  75. package/dist/analyzers/tools/tool-registry.js.map +1 -1
  76. package/dist/analyzers/tools/tools-config.d.ts +46 -0
  77. package/dist/analyzers/tools/tools-config.d.ts.map +1 -0
  78. package/dist/analyzers/tools/tools-config.js +129 -0
  79. package/dist/analyzers/tools/tools-config.js.map +1 -0
  80. package/dist/analyzers/tools/vendored-advisor.d.ts.map +1 -1
  81. package/dist/analyzers/tools/vendored-advisor.js +3 -4
  82. package/dist/analyzers/tools/vendored-advisor.js.map +1 -1
  83. package/dist/analyzers/tools/walk-source-files.d.ts +8 -0
  84. package/dist/analyzers/tools/walk-source-files.d.ts.map +1 -1
  85. package/dist/analyzers/tools/walk-source-files.js +49 -4
  86. package/dist/analyzers/tools/walk-source-files.js.map +1 -1
  87. package/dist/analyzers/xlsx/licenses.d.ts +7 -7
  88. package/dist/analyzers/xlsx/licenses.js +7 -7
  89. package/dist/baseline/baseline-file.d.ts +8 -0
  90. package/dist/baseline/baseline-file.d.ts.map +1 -1
  91. package/dist/baseline/baseline-file.js.map +1 -1
  92. package/dist/baseline/check-renderers.d.ts.map +1 -1
  93. package/dist/baseline/check-renderers.js +10 -0
  94. package/dist/baseline/check-renderers.js.map +1 -1
  95. package/dist/baseline/check.d.ts +7 -0
  96. package/dist/baseline/check.d.ts.map +1 -1
  97. package/dist/baseline/check.js +2 -0
  98. package/dist/baseline/check.js.map +1 -1
  99. package/dist/baseline/coverage.d.ts +57 -0
  100. package/dist/baseline/coverage.d.ts.map +1 -0
  101. package/dist/baseline/coverage.js +62 -0
  102. package/dist/baseline/coverage.js.map +1 -0
  103. package/dist/baseline/create.d.ts +13 -0
  104. package/dist/baseline/create.d.ts.map +1 -1
  105. package/dist/baseline/create.js +21 -0
  106. package/dist/baseline/create.js.map +1 -1
  107. package/dist/cli.d.ts.map +1 -1
  108. package/dist/cli.js +123 -4
  109. package/dist/cli.js.map +1 -1
  110. package/dist/dashboard/graph-adapter.d.ts +151 -0
  111. package/dist/dashboard/graph-adapter.d.ts.map +1 -0
  112. package/dist/dashboard/graph-adapter.js +415 -0
  113. package/dist/dashboard/graph-adapter.js.map +1 -0
  114. package/dist/dashboard/graph-tab.d.ts +109 -0
  115. package/dist/dashboard/graph-tab.d.ts.map +1 -0
  116. package/dist/dashboard/graph-tab.js +297 -0
  117. package/dist/dashboard/graph-tab.js.map +1 -0
  118. package/dist/dashboard/vendor/vis-network.min.js +34 -0
  119. package/dist/doctor.d.ts.map +1 -1
  120. package/dist/doctor.js +6 -7
  121. package/dist/doctor.js.map +1 -1
  122. package/dist/explore/cli/api-surface.d.ts +12 -0
  123. package/dist/explore/cli/api-surface.d.ts.map +1 -0
  124. package/dist/explore/cli/api-surface.js +57 -0
  125. package/dist/explore/cli/api-surface.js.map +1 -0
  126. package/dist/explore/cli/communities.d.ts +10 -0
  127. package/dist/explore/cli/communities.d.ts.map +1 -0
  128. package/dist/explore/cli/communities.js +47 -0
  129. package/dist/explore/cli/communities.js.map +1 -0
  130. package/dist/explore/cli/context.d.ts +16 -0
  131. package/dist/explore/cli/context.d.ts.map +1 -0
  132. package/dist/explore/cli/context.js +118 -0
  133. package/dist/explore/cli/context.js.map +1 -0
  134. package/dist/explore/cli/entry-points.d.ts +12 -0
  135. package/dist/explore/cli/entry-points.d.ts.map +1 -0
  136. package/dist/explore/cli/entry-points.js +85 -0
  137. package/dist/explore/cli/entry-points.js.map +1 -0
  138. package/dist/explore/cli/feature.d.ts +16 -0
  139. package/dist/explore/cli/feature.d.ts.map +1 -0
  140. package/dist/explore/cli/feature.js +89 -0
  141. package/dist/explore/cli/feature.js.map +1 -0
  142. package/dist/explore/cli/file.d.ts +12 -0
  143. package/dist/explore/cli/file.d.ts.map +1 -0
  144. package/dist/explore/cli/file.js +139 -0
  145. package/dist/explore/cli/file.js.map +1 -0
  146. package/dist/explore/cli/hot-files.d.ts +11 -0
  147. package/dist/explore/cli/hot-files.d.ts.map +1 -0
  148. package/dist/explore/cli/hot-files.js +63 -0
  149. package/dist/explore/cli/hot-files.js.map +1 -0
  150. package/dist/explore/context-hook.d.ts +42 -0
  151. package/dist/explore/context-hook.d.ts.map +1 -0
  152. package/dist/explore/context-hook.js +131 -0
  153. package/dist/explore/context-hook.js.map +1 -0
  154. package/dist/explore/finding-context.d.ts +69 -0
  155. package/dist/explore/finding-context.d.ts.map +1 -0
  156. package/dist/explore/finding-context.js +102 -0
  157. package/dist/explore/finding-context.js.map +1 -0
  158. package/dist/explore/format.d.ts +64 -0
  159. package/dist/explore/format.d.ts.map +1 -0
  160. package/dist/explore/format.js +99 -0
  161. package/dist/explore/format.js.map +1 -0
  162. package/dist/explore/load.d.ts +50 -0
  163. package/dist/explore/load.d.ts.map +1 -0
  164. package/dist/explore/load.js +197 -0
  165. package/dist/explore/load.js.map +1 -0
  166. package/dist/explore/queries.d.ts +413 -0
  167. package/dist/explore/queries.d.ts.map +1 -0
  168. package/dist/explore/queries.js +855 -0
  169. package/dist/explore/queries.js.map +1 -0
  170. package/dist/explore/types.d.ts +130 -0
  171. package/dist/explore/types.d.ts.map +1 -0
  172. package/dist/explore/types.js +28 -0
  173. package/dist/explore/types.js.map +1 -0
  174. package/dist/explore-cli.d.ts +45 -0
  175. package/dist/explore-cli.d.ts.map +1 -0
  176. package/dist/explore-cli.js +213 -0
  177. package/dist/explore-cli.js.map +1 -0
  178. package/dist/generator.d.ts.map +1 -1
  179. package/dist/generator.js +19 -0
  180. package/dist/generator.js.map +1 -1
  181. package/dist/languages/csharp.d.ts.map +1 -1
  182. package/dist/languages/csharp.js +58 -26
  183. package/dist/languages/csharp.js.map +1 -1
  184. package/dist/languages/go.d.ts.map +1 -1
  185. package/dist/languages/go.js +17 -14
  186. package/dist/languages/go.js.map +1 -1
  187. package/dist/languages/index.d.ts +27 -0
  188. package/dist/languages/index.d.ts.map +1 -1
  189. package/dist/languages/index.js +35 -0
  190. package/dist/languages/index.js.map +1 -1
  191. package/dist/languages/java.d.ts.map +1 -1
  192. package/dist/languages/java.js +13 -10
  193. package/dist/languages/java.js.map +1 -1
  194. package/dist/languages/kotlin.d.ts.map +1 -1
  195. package/dist/languages/kotlin.js +13 -10
  196. package/dist/languages/kotlin.js.map +1 -1
  197. package/dist/languages/python.d.ts.map +1 -1
  198. package/dist/languages/python.js +31 -20
  199. package/dist/languages/python.js.map +1 -1
  200. package/dist/languages/ruby.d.ts.map +1 -1
  201. package/dist/languages/ruby.js +30 -16
  202. package/dist/languages/ruby.js.map +1 -1
  203. package/dist/languages/rust.d.ts.map +1 -1
  204. package/dist/languages/rust.js +16 -13
  205. package/dist/languages/rust.js.map +1 -1
  206. package/dist/languages/types.d.ts +54 -0
  207. package/dist/languages/types.d.ts.map +1 -1
  208. package/dist/languages/typescript.d.ts.map +1 -1
  209. package/dist/languages/typescript.js +22 -19
  210. package/dist/languages/typescript.js.map +1 -1
  211. package/dist/tools-cli.d.ts.map +1 -1
  212. package/dist/tools-cli.js +10 -4
  213. package/dist/tools-cli.js.map +1 -1
  214. package/dist/upgrade.js +2 -2
  215. package/dist/upgrade.js.map +1 -1
  216. package/package.json +2 -1
  217. package/templates/.claude/skills/dxkit-action/SKILL.md +21 -1
  218. package/templates/.claude/skills/dxkit-config/SKILL.md +26 -0
  219. package/templates/.claude/skills/dxkit-fix/SKILL.md +10 -0
  220. package/templates/.claude/skills/dxkit-reports/SKILL.md +3 -1
  221. package/templates/AGENTS.md.template +8 -1
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.graphifyProvider = void 0;
37
37
  exports.gatherGraphifyResult = gatherGraphifyResult;
38
+ exports.gatherGraphifyGraph = gatherGraphifyGraph;
38
39
  exports.buildGraphifyEnvelope = buildGraphifyEnvelope;
39
40
  /**
40
41
  * Graphify integration — deterministic AST extraction via tree-sitter.
@@ -60,6 +61,7 @@ const runner_1 = require("./runner");
60
61
  const tool_registry_1 = require("./tool-registry");
61
62
  const exclusions_1 = require("./exclusions");
62
63
  const paths_1 = require("./paths");
64
+ const types_1 = require("../../explore/types");
63
65
  /** Build the graphify Python script with cwd-specific exclusions baked in. */
64
66
  function buildGraphifyScript(cwd) {
65
67
  const { dirsSet, pathsList, fileGlobsList } = (0, exclusions_1.getPythonExcludeFilter)(cwd);
@@ -93,7 +95,7 @@ target = Path(sys.argv[1])
93
95
 
94
96
  # Three-axis exclusion. EXCLUDE_DIRS is basename-only (any path
95
97
  # segment matching skips the file). EXCLUDE_PATHS holds multi-segment
96
- # relative paths from .dxkit-ignore (e.g. 'Dev/Addons/VendorAddon/SAPB1')
98
+ # relative paths from .dxkit-ignore (e.g. 'app/modules/plugins/VendorPlugin')
97
99
  # and matches via substring on the file's relpath. EXCLUDE_FILE_GLOBS
98
100
  # carries basename-glob patterns from bundled defaults + .gitignore
99
101
  # ('*.min.js', '*.bundle.js', '*.chunk.js', '*.generated.ts', '*.d.ts')
@@ -148,6 +150,130 @@ def _is_excluded(f):
148
150
  return True
149
151
  return False
150
152
 
153
+
154
+ # ── Per-language symbol-level enrichment ─────────────────────────────────────
155
+ # 2.7 Sprint 1: extract per-node line numbers + exported flags so the
156
+ # graph JSON downstream consumers (explore CLI api-surface query, dashboard
157
+ # viz "exported only" filter, future 2.8 reachability) can answer "is this
158
+ # symbol part of the public API?" The reliability tier is per-pack (see
159
+ # LanguageSupport.exportDetection in src/languages/types.ts) — packs
160
+ # declared 'unreliable' (today: ruby) get \`exported: absent\` per the
161
+ # schema's "absent = unknown" convention. Tiers 'full' and 'partial' are
162
+ # checked here via line-scan against per-extension patterns.
163
+
164
+ import re as _re
165
+
166
+ # Maps file extension → (pack-id, reliability-tier). Mirrors
167
+ # LanguageSupport.exportDetection declarations across the 8 packs. Ruby
168
+ # (.rb) is intentionally absent because the pack declares
169
+ # 'unreliable' — nodes from .rb files inherit \`exported: absent\`.
170
+ _EXT_TO_PACK = {
171
+ '.ts': 'typescript', '.tsx': 'typescript', '.js': 'typescript',
172
+ '.jsx': 'typescript', '.mjs': 'typescript', '.cjs': 'typescript',
173
+ '.py': 'python',
174
+ '.go': 'go',
175
+ '.rs': 'rust',
176
+ '.cs': 'csharp',
177
+ '.kt': 'kotlin', '.kts': 'kotlin',
178
+ '.java': 'java',
179
+ '.rb': 'ruby',
180
+ }
181
+
182
+ # Reliability tier per pack (mirrors LanguageSupport declarations).
183
+ # Used to skip line-scan for unreliable packs entirely.
184
+ _PACK_RELIABILITY = {
185
+ 'typescript': 'full', 'python': 'partial', 'go': 'full',
186
+ 'rust': 'full', 'csharp': 'full', 'kotlin': 'full',
187
+ 'java': 'full', 'ruby': 'unreliable',
188
+ }
189
+
190
+ # File-line cache so each source file is read at most once during
191
+ # the per-node enrichment pass. ~5MB for a 600-file repo; acceptable
192
+ # for a one-shot CLI invocation.
193
+ _FILE_LINES = {}
194
+
195
+ def _get_source_line(file_path, line_no):
196
+ if file_path not in _FILE_LINES:
197
+ try:
198
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as fh:
199
+ _FILE_LINES[file_path] = fh.readlines()
200
+ except OSError:
201
+ _FILE_LINES[file_path] = []
202
+ lines = _FILE_LINES[file_path]
203
+ if 1 <= line_no <= len(lines):
204
+ return lines[line_no - 1].rstrip('\\n').rstrip('\\r')
205
+ return ''
206
+
207
+ def _ext_of(source_file):
208
+ if not source_file:
209
+ return ''
210
+ i = source_file.rfind('.')
211
+ return source_file[i:].lower() if i >= 0 else ''
212
+
213
+ def _detect_exported(source_file, line_no, name):
214
+ """Return True / False / None per the GraphNode.exported semantics.
215
+ None = absent (we don't know; pack is 'unreliable' or detection failed).
216
+ """
217
+ ext = _ext_of(source_file)
218
+ pack = _EXT_TO_PACK.get(ext)
219
+ if not pack:
220
+ return None
221
+ if _PACK_RELIABILITY.get(pack) == 'unreliable':
222
+ return None
223
+ line = _get_source_line(source_file, line_no) if line_no else ''
224
+ if pack == 'typescript':
225
+ # TypeScript / JavaScript: line starts with \`export\` keyword
226
+ # (covers \`export function\`, \`export class\`, \`export default\`,
227
+ # \`export const\`, \`export { foo }\`, \`export * from ...\`).
228
+ return bool(_re.match(r'^\\s*export\\b', line))
229
+ if pack == 'python':
230
+ # Public-name heuristic. \`__all__\` lookup would be stricter
231
+ # but requires module-level state; v1 ships the simpler form.
232
+ # Names starting with \`_\` are conventionally private.
233
+ return bool(name) and not name.startswith('_')
234
+ if pack == 'go':
235
+ # Identifier starts with uppercase = exported (idiomatic Go).
236
+ return bool(name) and name[0:1].isupper()
237
+ if pack == 'rust':
238
+ # \`pub fn\`, \`pub struct\`, \`pub(crate) fn\`, \`pub(super) fn\`
239
+ return bool(_re.match(r'^\\s*pub(\\s|\\()', line))
240
+ if pack == 'csharp':
241
+ # \`public\` modifier somewhere in the declaration line
242
+ return bool(_re.search(r'\\bpublic\\b', line))
243
+ if pack == 'kotlin':
244
+ # Kotlin: public-by-default unless an explicit narrower modifier
245
+ return not bool(_re.search(r'\\b(private|internal|protected)\\b', line))
246
+ if pack == 'java':
247
+ # \`public\` modifier somewhere in the declaration line
248
+ return bool(_re.search(r'\\bpublic\\b', line))
249
+ return None
250
+
251
+ def _parse_line_no(node_attrs):
252
+ """Graphify stores source_location as \`L<line>\` (string) or sometimes the
253
+ raw number. Return int line or 0 when absent/malformed."""
254
+ loc = node_attrs.get('source_location')
255
+ if loc is None:
256
+ return 0
257
+ if isinstance(loc, int):
258
+ return loc
259
+ s = str(loc)
260
+ if s.startswith('L'):
261
+ s = s[1:]
262
+ try:
263
+ return int(s)
264
+ except (ValueError, TypeError):
265
+ return 0
266
+
267
+ def _strip_paren_suffix(label):
268
+ """\`createUser()\` → \`createUser\`, \`UserRepository.findById()\` → \`findById\`."""
269
+ if not label:
270
+ return ''
271
+ s = label.rstrip(')').rstrip('(')
272
+ # Method labels are \`Class.method\` — keep only the right-hand side.
273
+ if '.' in s:
274
+ s = s.rsplit('.', 1)[1]
275
+ return s
276
+
151
277
  all_files = collect_files(target)
152
278
  files = [f for f in all_files if not _is_excluded(f)]
153
279
  if not files:
@@ -216,6 +342,319 @@ total_src = len(source_files_set)
216
342
  empty_files = total_src - len(files_with_nodes)
217
343
  commented_ratio = empty_files / total_src if total_src > 0 else 0.0
218
344
 
345
+
346
+ # ── Build the full graph artifact ────────────────────────────────────────────
347
+ # 2.7 Sprint 1: emit nodes / edges / communities / symbolIndex alongside
348
+ # the aggregate metrics. Consumers (explore CLI, dashboard viz, future
349
+ # 2.8 context CLI + reachability) read this via src/explore/load.ts.
350
+ # Schema contract documented in tmp/2.7-graph-json-schema.md.
351
+
352
+ # Determine class membership: a module-shaped node is a CLASS if it has
353
+ # outbound 'method' edges to other nodes (it's the owner). A function-
354
+ # shaped node ("()" in label) is a METHOD if it has inbound 'method'
355
+ # edges from a class node; otherwise it's a free FUNCTION.
356
+ _class_owners = set()
357
+ _method_members = set()
358
+ for u, v, data in G.edges(data=True):
359
+ if data.get("relation") == "method":
360
+ _class_owners.add(u)
361
+ _method_members.add(v)
362
+
363
+ def _node_kind(nid, attrs):
364
+ label = attrs.get('label', '')
365
+ is_callable = '()' in label
366
+ if is_callable:
367
+ return 'method' if nid in _method_members else 'function'
368
+ return 'class' if nid in _class_owners else 'module'
369
+
370
+ # Make node sourceFile paths project-relative (graphify emits absolute
371
+ # paths derived from \`target = sys.argv[1]\`). Mirrors the existing
372
+ # maxFunctionsFilePath path-normalization at the TS layer.
373
+ def _rel(p):
374
+ if not p:
375
+ return ''
376
+ s = str(p).replace(os.sep, '/')
377
+ t = str(target).replace(os.sep, '/').rstrip('/')
378
+ if s.startswith(t + '/'):
379
+ return s[len(t) + 1:]
380
+ if s == t:
381
+ return ''
382
+ return s
383
+
384
+ # Assign stable in-run ids: n0, n1, n2, ... in extraction order. The
385
+ # graphify-internal id strings (long underscored slugs) work but bloat
386
+ # the JSON by ~20 bytes per node; the n<idx> shortening saves ~50KB on
387
+ # a 13k-node repo. IDs are NOT stable across runs (per schema doc).
388
+ _id_remap = {}
389
+ graph_nodes = []
390
+ for idx, (nid, attrs) in enumerate(nodes):
391
+ short_id = f'n{idx}'
392
+ _id_remap[nid] = short_id
393
+ line_no = _parse_line_no(attrs)
394
+ rel_source = _rel(attrs.get('source_file', ''))
395
+ label = attrs.get('label', '')
396
+ name = _strip_paren_suffix(label)
397
+ kind = _node_kind(nid, attrs)
398
+ node_obj = {
399
+ 'id': short_id,
400
+ 'kind': kind,
401
+ 'label': label,
402
+ 'sourceFile': rel_source,
403
+ }
404
+ if line_no:
405
+ node_obj['line'] = line_no
406
+ # Export detection only meaningful for symbol-bearing kinds
407
+ # (functions, classes, methods). Module-level "is this file
408
+ # exported?" isn't a useful question — exclude.
409
+ if kind in ('function', 'class', 'method'):
410
+ # Resolve to absolute path for the file-line cache (we read
411
+ # the raw source content; the cache key is the actual path
412
+ # on disk, not the project-relative form).
413
+ abs_source = attrs.get('source_file', '')
414
+ exported = _detect_exported(abs_source, line_no, name)
415
+ if exported is not None:
416
+ node_obj['exported'] = exported
417
+ graph_nodes.append(node_obj)
418
+
419
+ # Edges remapped to short ids. Drop self-loops and edges where either
420
+ # endpoint was filtered out (defensive — graphify shouldn't produce them
421
+ # but be tolerant). Graphify emits both 'imports' (broad form: \`import X\`)
422
+ # and 'imports_from' (\`from X import Y\` / \`import {Y} from X\`); both
423
+ # carry the same semantic for our schema ("A imports from B"). Merge
424
+ # both into the canonical 'imports_from' edge relation. The 'contains'
425
+ # and 'inherits' relations graphify also produces are intentionally
426
+ # dropped — 'contains' duplicates the file/symbol-membership info
427
+ # already encoded in nodes' sourceFile field, and 'inherits' is
428
+ # class-inheritance which isn't yet a first-class schema relation.
429
+ graph_edges = []
430
+ for u, v, data in G.edges(data=True):
431
+ if u not in _id_remap or v not in _id_remap:
432
+ continue
433
+ graphify_relation = data.get('relation', '')
434
+ if graphify_relation == 'calls':
435
+ relation = 'calls'
436
+ elif graphify_relation in ('imports', 'imports_from'):
437
+ relation = 'imports_from'
438
+ elif graphify_relation == 'method':
439
+ relation = 'method'
440
+ else:
441
+ continue
442
+ edge_obj = {
443
+ 'from': _id_remap[u],
444
+ 'to': _id_remap[v],
445
+ 'relation': relation,
446
+ }
447
+ graph_edges.append(edge_obj)
448
+
449
+ # Communities: for each cluster compute dominantSourceDir + dominantPack.
450
+ # dominantSourceDir = most common ancestor directory (the longest
451
+ # leading-segment path that >= 40% of members share); empty string when
452
+ # no clear dominant. dominantPack = most common pack id among member
453
+ # files' extensions; empty when no dominant pack.
454
+ def _ancestor_dir(rel_path):
455
+ if not rel_path or '/' not in rel_path:
456
+ return ''
457
+ return rel_path.rsplit('/', 1)[0] + '/'
458
+
459
+ graph_communities = []
460
+ # Graphify's cluster() returns dict[community_id: list[node_id]].
461
+ # Iterate via .items(); the community_id is the actual cluster
462
+ # identifier (used to look up cohesion in scores), members is the
463
+ # node-id list.
464
+ _node_attrs_by_id = dict(nodes)
465
+ for cidx, member_list in communities.items():
466
+ member_ids = sorted(_id_remap.get(n, '') for n in member_list if n in _id_remap)
467
+ member_ids = [m for m in member_ids if m]
468
+ if not member_ids:
469
+ continue
470
+ # Per-member source files (project-relative)
471
+ member_files = []
472
+ for nid in member_list:
473
+ if nid in _id_remap:
474
+ sf = _rel(_node_attrs_by_id.get(nid, {}).get('source_file', ''))
475
+ if sf:
476
+ member_files.append(sf)
477
+ # Dominant directory: longest common ancestor that >= 40% of
478
+ # members share (or empty if no clear winner).
479
+ dir_counter = Counter(_ancestor_dir(f) for f in member_files)
480
+ dir_counter.pop('', None)
481
+ dominant_dir = ''
482
+ if dir_counter:
483
+ top_dir, top_count = dir_counter.most_common(1)[0]
484
+ if top_count / len(member_files) >= 0.4:
485
+ dominant_dir = top_dir
486
+ # Dominant pack
487
+ pack_counter = Counter()
488
+ for f in member_files:
489
+ pk = _EXT_TO_PACK.get(_ext_of(f))
490
+ if pk:
491
+ pack_counter[pk] += 1
492
+ dominant_pack = ''
493
+ if pack_counter:
494
+ top_pack, top_pack_count = pack_counter.most_common(1)[0]
495
+ if top_pack_count / max(1, len(member_files)) >= 0.5:
496
+ dominant_pack = top_pack
497
+ cohesion = float(scores.get(cidx, 0.0)) if scores else 0.0
498
+ graph_communities.append({
499
+ 'id': cidx,
500
+ 'nodeIds': member_ids,
501
+ 'cohesion': round(cohesion, 3),
502
+ 'dominantSourceDir': dominant_dir,
503
+ 'dominantPack': dominant_pack,
504
+ })
505
+
506
+ # Symbol index: lowercased label (without trailing ()) → list of nodeIds.
507
+ _symbol_index = {}
508
+ for node_obj in graph_nodes:
509
+ key = _strip_paren_suffix(node_obj['label']).lower()
510
+ if not key:
511
+ continue
512
+ _symbol_index.setdefault(key, []).append(node_obj['id'])
513
+
514
+ # Active-pack detection: derive from extensions seen in source files.
515
+ _packs_seen = sorted({_EXT_TO_PACK[e] for e in (_ext_of(_rel(d.get('source_file', '')))
516
+ for _, d in nodes)
517
+ if e in _EXT_TO_PACK})
518
+
519
+ # Size-budget enforcement. Hard cap 50MB serialized. If we exceed,
520
+ # drop method edges first (densest class — structural noise, doesn't
521
+ # affect call-graph queries).
522
+ import datetime as _dt
523
+ _meta = {
524
+ 'tool': 'graphify',
525
+ 'graphifyVersion': '', # filled by TS-side post-parse (read from graphifyy package version)
526
+ 'dxkitVersion': '', # filled by TS-side post-parse (read from package.json)
527
+ 'generatedAt': _dt.datetime.now(_dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
528
+ 'sourceFilesInGraph': total_src,
529
+ 'excludedFileCount': len(all_files) - len(files),
530
+ 'packs': _packs_seen,
531
+ 'truncated': False,
532
+ 'truncatedReason': '',
533
+ }
534
+
535
+ _graph_payload = {
536
+ 'schemaVersion': 1,
537
+ 'meta': _meta,
538
+ 'nodes': graph_nodes,
539
+ 'edges': graph_edges,
540
+ 'communities': graph_communities,
541
+ 'symbolIndex': _symbol_index,
542
+ }
543
+
544
+ # Cheap pre-check on size: serialize once, measure, drop method edges
545
+ # if over the cap, re-serialize. The 50MB cap matches the schema
546
+ # contract; 10MB soft target is informational only (no enforcement).
547
+ _BYTES_HARD_CAP = 50 * 1024 * 1024
548
+
549
+ def _serialize(payload):
550
+ return json.dumps(payload, separators=(',', ':'))
551
+
552
+ _graph_json = _serialize(_graph_payload)
553
+ if len(_graph_json.encode('utf-8')) > _BYTES_HARD_CAP:
554
+ # Drop method edges first; they're structural (class-owns-method),
555
+ # not behavioral. Call + import edges carry the actionable info.
556
+ pre_count = len(_graph_payload['edges'])
557
+ _graph_payload['edges'] = [e for e in _graph_payload['edges']
558
+ if e['relation'] != 'method']
559
+ post_count = len(_graph_payload['edges'])
560
+ _meta['truncated'] = True
561
+ _meta['truncatedReason'] = (
562
+ f"dropped {pre_count - post_count} method edges to fit under "
563
+ f"the 50MB hard cap"
564
+ )
565
+
566
+ # Render the interactive viewer alongside graph.json so the dashboard
567
+ # Graph tab can embed it. graphify ships its own vis.js-based renderer
568
+ # (graphify.export.to_html). Two emission paths:
569
+ #
570
+ # - Full graph (G.number_of_nodes() <= MAX_NODES_FOR_VIZ = 5000):
571
+ # pass the original G + communities. The viewer renders every
572
+ # symbol; the user can zoom + drill.
573
+ #
574
+ # - Aggregated community view (G > MAX_NODES_FOR_VIZ): build a
575
+ # networkx super-graph whose nodes ARE the communities. Sized by
576
+ # member count via graphify member_counts parameter. Inter-
577
+ # community edges aggregated to weighted edges. This lets a
578
+ # customer-scale repo still get a meaningful "what does this
579
+ # codebase look like" viz instead of a dead empty-state.
580
+ #
581
+ # Either way failures are non-fatal: the dashboard surfaces a clear
582
+ # empty-state when graph.html isn't on disk.
583
+ try:
584
+ from graphify.export import to_html as _to_html, MAX_NODES_FOR_VIZ as _MAX_VIZ
585
+ import networkx as _nx
586
+ _html_dir = target / '.dxkit' / 'reports'
587
+ _html_dir.mkdir(parents=True, exist_ok=True)
588
+ _html_path = _html_dir / 'graph.html'
589
+
590
+ if G.number_of_nodes() <= _MAX_VIZ:
591
+ _labels = {
592
+ c['id']: (c.get('dominantSourceDir') or f"community-{c['id']}")
593
+ for c in graph_communities
594
+ }
595
+ _to_html(G, communities, str(_html_path), community_labels=_labels)
596
+ _viz_mode = 'full'
597
+ else:
598
+ # Aggregated community super-graph.
599
+ _node_to_comm = {}
600
+ for _cid, _members in communities.items():
601
+ for _nid in _members:
602
+ _node_to_comm[_nid] = _cid
603
+
604
+ _G_agg = _nx.DiGraph()
605
+ _member_counts = {}
606
+ _labels = {}
607
+ for _c in graph_communities:
608
+ _cid = _c['id']
609
+ _label = _c.get('dominantSourceDir') or f"community-{_cid}"
610
+ # vis.js node attrs: label drives display; file_type is
611
+ # surfaced in graphify's sidebar so we set a sentinel
612
+ # value the dashboard can grep on.
613
+ _G_agg.add_node(_cid, label=_label, source_file='', file_type='community')
614
+ _member_counts[_cid] = len(_c['nodeIds'])
615
+ _labels[_cid] = _label
616
+
617
+ # Cross-community edge aggregation. Counter keyed on
618
+ # (smaller_id, larger_id) for undirected aggregation; we then
619
+ # add a directed edge in one canonical direction so vis.js
620
+ # has a definite source/target. The viewer doesn't show
621
+ # arrows on these (they're community connections, not calls).
622
+ from collections import Counter as _CommCounter
623
+ _edge_w = _CommCounter()
624
+ for _u, _v, _ in G.edges(data=True):
625
+ _cu = _node_to_comm.get(_u)
626
+ _cv = _node_to_comm.get(_v)
627
+ if _cu is None or _cv is None or _cu == _cv:
628
+ continue
629
+ _key = (_cu, _cv) if _cu < _cv else (_cv, _cu)
630
+ _edge_w[_key] += 1
631
+ for (_a, _b), _w in _edge_w.items():
632
+ _G_agg.add_edge(_a, _b, relation='inter_community', occurrences=_w)
633
+
634
+ # to_html requires a communities dict; one-element groups
635
+ # treat each aggregated node as its own community so each
636
+ # community keeps a distinct color in graphify's palette.
637
+ _agg_groups = {_cid: [_cid] for _cid in communities}
638
+
639
+ _to_html(
640
+ _G_agg, _agg_groups, str(_html_path),
641
+ community_labels=_labels, member_counts=_member_counts,
642
+ )
643
+ _viz_mode = 'aggregated'
644
+
645
+ # Sidecar so the dashboard renderer can label the view honestly.
646
+ # JSON is tiny (~120B); avoids parsing graph.json twice from TS.
647
+ _meta_path = _html_dir / 'graph.html.meta.json'
648
+ _meta_path.write_text(json.dumps({
649
+ 'mode': _viz_mode,
650
+ 'totalNodes': G.number_of_nodes(),
651
+ 'totalEdges': G.number_of_edges(),
652
+ 'communities': len(communities),
653
+ 'aggregatedNodeCount': len(communities) if _viz_mode == 'aggregated' else None,
654
+ }))
655
+ except Exception as _html_err:
656
+ sys.stderr.write(f"dxkit: graph.html not generated ({_html_err})\\n")
657
+
219
658
  # Clean up temp cache
220
659
  import shutil
221
660
  shutil.rmtree(str(_cache_dir), ignore_errors=True)
@@ -234,38 +673,108 @@ print(json.dumps({
234
673
  "deadImportCount": len(dead),
235
674
  "commentedCodeRatio": round(commented_ratio, 3),
236
675
  "sourceFilesInGraph": total_src,
676
+ "graph": _graph_payload,
237
677
  }))
238
678
  `;
239
679
  }
240
680
  /**
241
- * Per-cwd memoization of the graphify outcome. Graphify is the heaviest
242
- * external tool dxkit shells out to (~10-60s depending on repo size);
243
- * memoizing ensures the Layer 2 reshape path + the capability
244
- * dispatcher's `graphifyProvider` share one invocation per analyzer run.
681
+ * Per-cwd memoization. Graphify is the heaviest external tool dxkit
682
+ * shells out to (~10-60s depending on repo size); the two outcomes
683
+ * share one Python invocation, populated atomically via the run
684
+ * promise cache below.
245
685
  *
246
- * Same constraints as the gitleaks cache: module-scoped, no automatic
247
- * invalidation, safe for the one-shot CLI shape.
686
+ * Module-scoped, no automatic invalidation, safe for the one-shot
687
+ * CLI shape (same constraints as the gitleaks cache).
688
+ */
689
+ const aggregatesCache = new Map();
690
+ const graphCache = new Map();
691
+ /**
692
+ * Run-coalescing promise cache. Concurrent callers (e.g. the parallel
693
+ * capability dispatcher AND a CLI subcommand both kicking off graphify
694
+ * gather) share a single in-flight Python invocation instead of racing
695
+ * to start their own. Without this, on first-access the cache check
696
+ * returns empty for both callers, both fire the Python subprocess,
697
+ * and the second one's result silently overwrites the first.
248
698
  */
249
- const graphifyOutcomeCache = new Map();
699
+ const runPromises = new Map();
250
700
  /**
251
- * Single source of truth for the graphify subprocess invocation.
252
- * Consumed by `graphifyProvider` (capability dispatcher) and by the
253
- * Layer 2 legacy reshape in `tools/parallel.ts` both paths share the
254
- * memoized per-cwd outcome so graphify shells out at most once per
255
- * analyzer run.
701
+ * Aggregate-metrics outcome the existing API. Consumed by
702
+ * `graphifyProvider` (capability dispatcher) + the Layer 2 legacy
703
+ * reshape in `tools/parallel.ts`. Signature unchanged from pre-2.7.
256
704
  */
257
705
  async function gatherGraphifyResult(cwd) {
258
- const cached = graphifyOutcomeCache.get(cwd);
259
- if (cached)
260
- return cached;
261
- const outcome = await computeGraphifyOutcome(cwd);
262
- graphifyOutcomeCache.set(cwd, outcome);
706
+ await runGraphifyOnce(cwd);
707
+ // runGraphifyOnce guarantees the cache is populated on completion;
708
+ // the `!` is safe.
709
+ return aggregatesCache.get(cwd);
710
+ }
711
+ /**
712
+ * Graph-artifact outcome — new in 2.7. Consumed by the explore CLI's
713
+ * `loadGraph` path (via the `.dxkit/reports/graph.json` write), by
714
+ * direct in-memory consumers that want the graph without a disk
715
+ * roundtrip, and by future 2.8 context-CLI + reachability gathers.
716
+ *
717
+ * Shares one Python invocation with `gatherGraphifyResult` per the
718
+ * runPromises cache — concurrent calls coalesce; sequential calls
719
+ * read from memoized caches.
720
+ *
721
+ * Side effect: when the gather succeeds AND `opts.writeToDisk` is
722
+ * truthy (default), the graph is written to
723
+ * `.dxkit/reports/graph.json`. Set `writeToDisk: false` for one-shot
724
+ * in-memory consumers (tests, ephemeral CLI flows) that don't want
725
+ * the file-system side effect. The write is idempotent — repeated
726
+ * calls overwrite atomically.
727
+ */
728
+ async function gatherGraphifyGraph(cwd, opts = {}) {
729
+ await runGraphifyOnce(cwd);
730
+ const outcome = graphCache.get(cwd);
731
+ const shouldWrite = opts.writeToDisk !== false;
732
+ if (shouldWrite && outcome.kind === 'success') {
733
+ writeGraphArtifact(cwd, outcome.graph);
734
+ }
263
735
  return outcome;
264
736
  }
265
- async function computeGraphifyOutcome(cwd) {
737
+ /**
738
+ * Persist the graph JSON to its canonical disk location. Failures
739
+ * are swallowed with a warning to stderr — graph.json is a
740
+ * convenience artifact, not load-bearing for any analyzer flow that
741
+ * could fail because of a missing report file.
742
+ */
743
+ function writeGraphArtifact(cwd, graph) {
744
+ const absPath = path.join(cwd, types_1.GRAPH_REPORT_PATH);
745
+ try {
746
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
747
+ fs.writeFileSync(absPath, JSON.stringify(graph));
748
+ }
749
+ catch (err) {
750
+ const msg = err instanceof Error ? err.message : String(err);
751
+ process.stderr.write(`dxkit: failed to write ${types_1.GRAPH_REPORT_PATH}: ${msg}\n`);
752
+ }
753
+ }
754
+ async function runGraphifyOnce(cwd) {
755
+ // Fast path: both caches already populated → nothing to do.
756
+ if (aggregatesCache.has(cwd) && graphCache.has(cwd))
757
+ return;
758
+ let p = runPromises.get(cwd);
759
+ if (!p) {
760
+ p = computeAndCache(cwd).finally(() => {
761
+ // Drop the promise once it settles so a future cache-miss
762
+ // (e.g. after manual cache eviction in tests) can re-run.
763
+ // The aggregate + graph caches remain authoritative.
764
+ runPromises.delete(cwd);
765
+ });
766
+ runPromises.set(cwd, p);
767
+ }
768
+ return p;
769
+ }
770
+ async function computeAndCache(cwd) {
266
771
  const pythonCmd = findPython(cwd);
267
- if (!pythonCmd)
268
- return { kind: 'unavailable', reason: 'not installed' };
772
+ if (!pythonCmd) {
773
+ const reason = 'not installed';
774
+ aggregatesCache.set(cwd, { kind: 'unavailable', reason });
775
+ graphCache.set(cwd, { kind: 'unavailable', reason });
776
+ return;
777
+ }
269
778
  // Per-run tempdir via mkdtempSync — unique random suffix eliminates
270
779
  // the `Date.now()` collision risk when two dxkit processes fire
271
780
  // within the same millisecond. The whole dir is rm'd on exit so we
@@ -297,44 +806,99 @@ async function computeGraphifyOutcome(cwd) {
297
806
  const output = outcome.stdout;
298
807
  const stderrCapture = outcome.stderr.trim();
299
808
  if (!output) {
809
+ let reason;
300
810
  if (outcome.timedOut) {
301
- return {
302
- kind: 'unavailable',
303
- reason: 'timed out at 300s (try narrowing scan scope via .dxkit-ignore)',
304
- };
811
+ reason = 'timed out at 300s (try narrowing scan scope via .dxkit-ignore)';
812
+ }
813
+ else {
814
+ // Surface the first meaningful stderr line so the customer can
815
+ // see what broke (tree-sitter parse error, Python ImportError,
816
+ // OOM kill, etc.). Truncate aggressively — toolsUnavailable[]
817
+ // entries shouldn't carry multi-line tracebacks.
818
+ const firstStderrLine = stderrCapture
819
+ .split('\n')
820
+ .find((l) => l.trim().length > 0)
821
+ ?.trim();
822
+ reason = firstStderrLine
823
+ ? `failed: ${firstStderrLine.length > 200 ? firstStderrLine.slice(0, 197) + '...' : firstStderrLine}`
824
+ : outcome.code !== 0 && outcome.code !== null
825
+ ? `failed with exit code ${outcome.code} (no stderr captured — likely killed by the OS, e.g. OOM)`
826
+ : 'failed to run (no stderr captured)';
305
827
  }
306
- // Surface the first meaningful stderr line so the customer can
307
- // see what broke (tree-sitter parse error, Python ImportError,
308
- // OOM kill, etc.). Truncate aggressively — toolsUnavailable[]
309
- // entries shouldn't carry multi-line tracebacks.
310
- const firstStderrLine = stderrCapture
311
- .split('\n')
312
- .find((l) => l.trim().length > 0)
313
- ?.trim();
314
- const reason = firstStderrLine
315
- ? `failed: ${firstStderrLine.length > 200 ? firstStderrLine.slice(0, 197) + '...' : firstStderrLine}`
316
- : outcome.code !== 0 && outcome.code !== null
317
- ? `failed with exit code ${outcome.code} (no stderr captured — likely killed by the OS, e.g. OOM)`
318
- : 'failed to run (no stderr captured)';
319
- return { kind: 'unavailable', reason };
828
+ aggregatesCache.set(cwd, { kind: 'unavailable', reason });
829
+ graphCache.set(cwd, { kind: 'unavailable', reason });
830
+ return;
320
831
  }
321
832
  // Graphify prints progress to stdout before the JSON — extract only the JSON line.
322
833
  const jsonLine = output
323
834
  .split('\n')
324
835
  .filter((l) => l.startsWith('{'))
325
836
  .pop();
326
- if (!jsonLine)
327
- return { kind: 'unavailable', reason: 'no JSON output' };
837
+ if (!jsonLine) {
838
+ const reason = 'no JSON output';
839
+ aggregatesCache.set(cwd, { kind: 'unavailable', reason });
840
+ graphCache.set(cwd, { kind: 'unavailable', reason });
841
+ return;
842
+ }
328
843
  let data;
329
844
  try {
330
845
  data = JSON.parse(jsonLine);
331
846
  }
332
847
  catch {
333
- return { kind: 'unavailable', reason: 'parse error' };
848
+ const reason = 'parse error';
849
+ aggregatesCache.set(cwd, { kind: 'unavailable', reason });
850
+ graphCache.set(cwd, { kind: 'unavailable', reason });
851
+ return;
852
+ }
853
+ if (data.error) {
854
+ const reason = data.error;
855
+ aggregatesCache.set(cwd, { kind: 'unavailable', reason });
856
+ graphCache.set(cwd, { kind: 'unavailable', reason });
857
+ return;
858
+ }
859
+ // Populate the aggregates cache (existing behavior).
860
+ aggregatesCache.set(cwd, { kind: 'success', envelope: buildGraphifyEnvelope(data, cwd) });
861
+ // Populate the graph cache. Backfill the dxkitVersion in meta;
862
+ // graphifyVersion left empty in v1 (Python-side version probe
863
+ // declined to keep the script self-contained). Consumers tolerate
864
+ // empty version strings.
865
+ if (data.graph) {
866
+ const dxkitVersion = readDxkitVersion();
867
+ const enrichedGraph = {
868
+ ...data.graph,
869
+ meta: {
870
+ ...data.graph.meta,
871
+ dxkitVersion,
872
+ },
873
+ };
874
+ graphCache.set(cwd, { kind: 'success', graph: enrichedGraph });
875
+ }
876
+ else {
877
+ // Aggregates parsed but graph field missing — old script output
878
+ // or a malformed JSON. Surface as graph-unavailable so the
879
+ // existing aggregates path keeps working.
880
+ graphCache.set(cwd, {
881
+ kind: 'unavailable',
882
+ reason: 'graph field missing from script output (older script?)',
883
+ });
884
+ }
885
+ }
886
+ /**
887
+ * Read the dxkit version string from the package.json bundled into
888
+ * the installed package. Resolved at runtime via a relative path from
889
+ * this module's directory; works for both `npm install -g` and local
890
+ * `npm link` flows. Returns 'unknown' on any failure (caller tolerates).
891
+ */
892
+ function readDxkitVersion() {
893
+ try {
894
+ // dist/analyzers/tools/graphify.js → ../../../package.json
895
+ const pkgPath = path.resolve(__dirname, '..', '..', '..', 'package.json');
896
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
897
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
898
+ }
899
+ catch {
900
+ return 'unknown';
334
901
  }
335
- if (data.error)
336
- return { kind: 'unavailable', reason: data.error };
337
- return { kind: 'success', envelope: buildGraphifyEnvelope(data, cwd) };
338
902
  }
339
903
  /**
340
904
  * Pure JSON-to-envelope reshape so the normalization contract is