@vyuhlabs/dxkit 2.5.2 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +218 -13
- package/README.md +220 -369
- package/dist/allowlist/categories.d.ts +120 -0
- package/dist/allowlist/categories.d.ts.map +1 -0
- package/dist/allowlist/categories.js +194 -0
- package/dist/allowlist/categories.js.map +1 -0
- package/dist/allowlist/cli.d.ts +95 -0
- package/dist/allowlist/cli.d.ts.map +1 -0
- package/dist/allowlist/cli.js +454 -0
- package/dist/allowlist/cli.js.map +1 -0
- package/dist/allowlist/diff.d.ts +67 -0
- package/dist/allowlist/diff.d.ts.map +1 -0
- package/dist/allowlist/diff.js +147 -0
- package/dist/allowlist/diff.js.map +1 -0
- package/dist/allowlist/file.d.ts +249 -0
- package/dist/allowlist/file.d.ts.map +1 -0
- package/dist/allowlist/file.js +497 -0
- package/dist/allowlist/file.js.map +1 -0
- package/dist/allowlist/gather.d.ts +61 -0
- package/dist/allowlist/gather.d.ts.map +1 -0
- package/dist/allowlist/gather.js +143 -0
- package/dist/allowlist/gather.js.map +1 -0
- package/dist/allowlist/hint.d.ts +80 -0
- package/dist/allowlist/hint.d.ts.map +1 -0
- package/dist/allowlist/hint.js +271 -0
- package/dist/allowlist/hint.js.map +1 -0
- package/dist/allowlist/inline.d.ts +149 -0
- package/dist/allowlist/inline.d.ts.map +1 -0
- package/dist/allowlist/inline.js +306 -0
- package/dist/allowlist/inline.js.map +1 -0
- package/dist/analyzers/bom/discovery.d.ts +3 -4
- package/dist/analyzers/bom/discovery.d.ts.map +1 -1
- package/dist/analyzers/bom/discovery.js +3 -4
- package/dist/analyzers/bom/discovery.js.map +1 -1
- package/dist/analyzers/bom/types.d.ts +1 -1
- package/dist/analyzers/dashboard/index.d.ts.map +1 -1
- package/dist/analyzers/dashboard/index.js +42 -5
- package/dist/analyzers/dashboard/index.js.map +1 -1
- package/dist/analyzers/quality/detailed.d.ts +8 -1
- package/dist/analyzers/quality/detailed.d.ts.map +1 -1
- package/dist/analyzers/quality/detailed.js +43 -10
- package/dist/analyzers/quality/detailed.js.map +1 -1
- package/dist/analyzers/security/detailed.d.ts +8 -1
- package/dist/analyzers/security/detailed.d.ts.map +1 -1
- package/dist/analyzers/security/detailed.js +14 -1
- package/dist/analyzers/security/detailed.js.map +1 -1
- package/dist/analyzers/tests/detailed.d.ts +8 -1
- package/dist/analyzers/tests/detailed.d.ts.map +1 -1
- package/dist/analyzers/tests/detailed.js +26 -7
- package/dist/analyzers/tests/detailed.js.map +1 -1
- package/dist/analyzers/tools/cloc.js +3 -3
- package/dist/analyzers/tools/cloc.js.map +1 -1
- package/dist/analyzers/tools/exclusions.d.ts +12 -12
- package/dist/analyzers/tools/exclusions.d.ts.map +1 -1
- package/dist/analyzers/tools/exclusions.js +27 -13
- package/dist/analyzers/tools/exclusions.js.map +1 -1
- package/dist/analyzers/tools/graphify.d.ts +39 -5
- package/dist/analyzers/tools/graphify.d.ts.map +1 -1
- package/dist/analyzers/tools/graphify.js +609 -45
- package/dist/analyzers/tools/graphify.js.map +1 -1
- package/dist/analyzers/tools/nuget-package-reference.d.ts +4 -4
- package/dist/analyzers/tools/nuget-package-reference.js +4 -4
- package/dist/analyzers/tools/osv-scanner-fix.d.ts +4 -5
- package/dist/analyzers/tools/osv-scanner-fix.d.ts.map +1 -1
- package/dist/analyzers/tools/osv-scanner-fix.js +4 -5
- package/dist/analyzers/tools/osv-scanner-fix.js.map +1 -1
- package/dist/analyzers/tools/parallel.d.ts.map +1 -1
- package/dist/analyzers/tools/parallel.js +7 -0
- package/dist/analyzers/tools/parallel.js.map +1 -1
- package/dist/analyzers/tools/vendored-advisor.d.ts.map +1 -1
- package/dist/analyzers/tools/vendored-advisor.js +3 -4
- package/dist/analyzers/tools/vendored-advisor.js.map +1 -1
- package/dist/analyzers/xlsx/licenses.d.ts +7 -7
- package/dist/analyzers/xlsx/licenses.js +7 -7
- package/dist/baseline/baseline-file.d.ts +7 -0
- package/dist/baseline/baseline-file.d.ts.map +1 -1
- package/dist/baseline/baseline-file.js +22 -1
- package/dist/baseline/baseline-file.js.map +1 -1
- package/dist/baseline/check-renderers.d.ts +13 -1
- package/dist/baseline/check-renderers.d.ts.map +1 -1
- package/dist/baseline/check-renderers.js +67 -1
- package/dist/baseline/check-renderers.js.map +1 -1
- package/dist/baseline/check.d.ts +33 -7
- package/dist/baseline/check.d.ts.map +1 -1
- package/dist/baseline/check.js +90 -64
- package/dist/baseline/check.js.map +1 -1
- package/dist/baseline/create.d.ts +35 -7
- package/dist/baseline/create.d.ts.map +1 -1
- package/dist/baseline/create.js +43 -5
- package/dist/baseline/create.js.map +1 -1
- package/dist/baseline/entry-to-located.d.ts +6 -1
- package/dist/baseline/entry-to-located.d.ts.map +1 -1
- package/dist/baseline/entry-to-located.js +20 -2
- package/dist/baseline/entry-to-located.js.map +1 -1
- package/dist/baseline/finding-identity.d.ts.map +1 -1
- package/dist/baseline/finding-identity.js +15 -13
- package/dist/baseline/finding-identity.js.map +1 -1
- package/dist/baseline/modes.d.ts +140 -0
- package/dist/baseline/modes.d.ts.map +1 -0
- package/dist/baseline/modes.js +179 -0
- package/dist/baseline/modes.js.map +1 -0
- package/dist/baseline/policy.d.ts +64 -0
- package/dist/baseline/policy.d.ts.map +1 -1
- package/dist/baseline/policy.js +102 -1
- package/dist/baseline/policy.js.map +1 -1
- package/dist/baseline/producers/health.d.ts +2 -2
- package/dist/baseline/producers/health.d.ts.map +1 -1
- package/dist/baseline/producers/health.js.map +1 -1
- package/dist/baseline/producers/index.d.ts +11 -5
- package/dist/baseline/producers/index.d.ts.map +1 -1
- package/dist/baseline/producers/index.js +12 -9
- package/dist/baseline/producers/index.js.map +1 -1
- package/dist/baseline/producers/quality.d.ts +3 -3
- package/dist/baseline/producers/quality.d.ts.map +1 -1
- package/dist/baseline/producers/quality.js.map +1 -1
- package/dist/baseline/producers/secret-hmac.d.ts +2 -2
- package/dist/baseline/producers/secret-hmac.d.ts.map +1 -1
- package/dist/baseline/producers/secret-hmac.js.map +1 -1
- package/dist/baseline/producers/security.d.ts +2 -2
- package/dist/baseline/producers/security.d.ts.map +1 -1
- package/dist/baseline/producers/security.js.map +1 -1
- package/dist/baseline/producers/stale-allow.d.ts +70 -0
- package/dist/baseline/producers/stale-allow.d.ts.map +1 -0
- package/dist/baseline/producers/stale-allow.js +111 -0
- package/dist/baseline/producers/stale-allow.js.map +1 -0
- package/dist/baseline/producers/tests.d.ts +2 -2
- package/dist/baseline/producers/tests.d.ts.map +1 -1
- package/dist/baseline/producers/tests.js.map +1 -1
- package/dist/baseline/ref-baseline.d.ts +114 -0
- package/dist/baseline/ref-baseline.d.ts.map +1 -0
- package/dist/baseline/ref-baseline.js +260 -0
- package/dist/baseline/ref-baseline.js.map +1 -0
- package/dist/baseline/sanitize.d.ts +80 -0
- package/dist/baseline/sanitize.d.ts.map +1 -0
- package/dist/baseline/sanitize.js +91 -0
- package/dist/baseline/sanitize.js.map +1 -0
- package/dist/baseline/show.d.ts.map +1 -1
- package/dist/baseline/show.js +9 -3
- package/dist/baseline/show.js.map +1 -1
- package/dist/baseline/types.d.ts +73 -26
- package/dist/baseline/types.d.ts.map +1 -1
- package/dist/baseline/types.js +7 -1
- package/dist/baseline/types.js.map +1 -1
- package/dist/baseline/visibility.d.ts +61 -0
- package/dist/baseline/visibility.d.ts.map +1 -0
- package/dist/baseline/visibility.js +121 -0
- package/dist/baseline/visibility.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +168 -6
- package/dist/cli.js.map +1 -1
- package/dist/dashboard/graph-adapter.d.ts +151 -0
- package/dist/dashboard/graph-adapter.d.ts.map +1 -0
- package/dist/dashboard/graph-adapter.js +415 -0
- package/dist/dashboard/graph-adapter.js.map +1 -0
- package/dist/dashboard/graph-tab.d.ts +109 -0
- package/dist/dashboard/graph-tab.d.ts.map +1 -0
- package/dist/dashboard/graph-tab.js +297 -0
- package/dist/dashboard/graph-tab.js.map +1 -0
- package/dist/dashboard/vendor/vis-network.min.js +34 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +106 -16
- package/dist/doctor.js.map +1 -1
- package/dist/explore/cli/api-surface.d.ts +12 -0
- package/dist/explore/cli/api-surface.d.ts.map +1 -0
- package/dist/explore/cli/api-surface.js +57 -0
- package/dist/explore/cli/api-surface.js.map +1 -0
- package/dist/explore/cli/communities.d.ts +10 -0
- package/dist/explore/cli/communities.d.ts.map +1 -0
- package/dist/explore/cli/communities.js +47 -0
- package/dist/explore/cli/communities.js.map +1 -0
- package/dist/explore/cli/context.d.ts +16 -0
- package/dist/explore/cli/context.d.ts.map +1 -0
- package/dist/explore/cli/context.js +118 -0
- package/dist/explore/cli/context.js.map +1 -0
- package/dist/explore/cli/entry-points.d.ts +12 -0
- package/dist/explore/cli/entry-points.d.ts.map +1 -0
- package/dist/explore/cli/entry-points.js +85 -0
- package/dist/explore/cli/entry-points.js.map +1 -0
- package/dist/explore/cli/feature.d.ts +16 -0
- package/dist/explore/cli/feature.d.ts.map +1 -0
- package/dist/explore/cli/feature.js +89 -0
- package/dist/explore/cli/feature.js.map +1 -0
- package/dist/explore/cli/file.d.ts +12 -0
- package/dist/explore/cli/file.d.ts.map +1 -0
- package/dist/explore/cli/file.js +139 -0
- package/dist/explore/cli/file.js.map +1 -0
- package/dist/explore/cli/hot-files.d.ts +11 -0
- package/dist/explore/cli/hot-files.d.ts.map +1 -0
- package/dist/explore/cli/hot-files.js +63 -0
- package/dist/explore/cli/hot-files.js.map +1 -0
- package/dist/explore/context-hook.d.ts +42 -0
- package/dist/explore/context-hook.d.ts.map +1 -0
- package/dist/explore/context-hook.js +131 -0
- package/dist/explore/context-hook.js.map +1 -0
- package/dist/explore/finding-context.d.ts +69 -0
- package/dist/explore/finding-context.d.ts.map +1 -0
- package/dist/explore/finding-context.js +102 -0
- package/dist/explore/finding-context.js.map +1 -0
- package/dist/explore/format.d.ts +64 -0
- package/dist/explore/format.d.ts.map +1 -0
- package/dist/explore/format.js +99 -0
- package/dist/explore/format.js.map +1 -0
- package/dist/explore/load.d.ts +50 -0
- package/dist/explore/load.d.ts.map +1 -0
- package/dist/explore/load.js +197 -0
- package/dist/explore/load.js.map +1 -0
- package/dist/explore/queries.d.ts +413 -0
- package/dist/explore/queries.d.ts.map +1 -0
- package/dist/explore/queries.js +855 -0
- package/dist/explore/queries.js.map +1 -0
- package/dist/explore/types.d.ts +130 -0
- package/dist/explore/types.d.ts.map +1 -0
- package/dist/explore/types.js +28 -0
- package/dist/explore/types.js.map +1 -0
- package/dist/explore-cli.d.ts +45 -0
- package/dist/explore-cli.d.ts.map +1 -0
- package/dist/explore-cli.js +213 -0
- package/dist/explore-cli.js.map +1 -0
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +19 -0
- package/dist/generator.js.map +1 -1
- package/dist/issue-cli.d.ts +62 -0
- package/dist/issue-cli.d.ts.map +1 -0
- package/dist/issue-cli.js +252 -0
- package/dist/issue-cli.js.map +1 -0
- package/dist/languages/csharp.d.ts.map +1 -1
- package/dist/languages/csharp.js +32 -11
- package/dist/languages/csharp.js.map +1 -1
- package/dist/languages/go.d.ts.map +1 -1
- package/dist/languages/go.js +5 -0
- package/dist/languages/go.js.map +1 -1
- package/dist/languages/index.d.ts +27 -0
- package/dist/languages/index.d.ts.map +1 -1
- package/dist/languages/index.js +35 -0
- package/dist/languages/index.js.map +1 -1
- package/dist/languages/java.d.ts.map +1 -1
- package/dist/languages/java.js +5 -0
- package/dist/languages/java.js.map +1 -1
- package/dist/languages/kotlin.d.ts.map +1 -1
- package/dist/languages/kotlin.js +5 -0
- package/dist/languages/kotlin.js.map +1 -1
- package/dist/languages/python.d.ts.map +1 -1
- package/dist/languages/python.js +5 -0
- package/dist/languages/python.js.map +1 -1
- package/dist/languages/ruby.d.ts.map +1 -1
- package/dist/languages/ruby.js +5 -0
- package/dist/languages/ruby.js.map +1 -1
- package/dist/languages/rust.d.ts.map +1 -1
- package/dist/languages/rust.js +5 -0
- package/dist/languages/rust.js.map +1 -1
- package/dist/languages/types.d.ts +79 -0
- package/dist/languages/types.d.ts.map +1 -1
- package/dist/languages/typescript.d.ts.map +1 -1
- package/dist/languages/typescript.js +6 -1
- package/dist/languages/typescript.js.map +1 -1
- package/package.json +2 -1
- package/templates/.claude/skills/dxkit-action/SKILL.md +126 -12
- package/templates/.claude/skills/dxkit-onboard/SKILL.md +31 -3
- package/templates/.claude/skills/dxkit-reports/SKILL.md +3 -1
- package/templates/AGENTS.md.template +8 -1
- package/dist/baseline/producers/licenses.d.ts +0 -23
- package/dist/baseline/producers/licenses.d.ts.map +0 -1
- package/dist/baseline/producers/licenses.js +0 -46
- package/dist/baseline/producers/licenses.js.map +0 -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. '
|
|
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
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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
|
-
*
|
|
247
|
-
*
|
|
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
|
|
699
|
+
const runPromises = new Map();
|
|
250
700
|
/**
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|