ucn 3.8.23 → 3.8.25
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/.claude/skills/ucn/SKILL.md +114 -11
- package/README.md +152 -156
- package/cli/index.js +363 -37
- package/core/analysis.js +936 -32
- package/core/bridge.js +1111 -0
- package/core/brief.js +408 -0
- package/core/cache.js +105 -5
- package/core/callers.js +72 -18
- package/core/check.js +200 -0
- package/core/discovery.js +57 -34
- package/core/entrypoints.js +638 -4
- package/core/execute.js +304 -5
- package/core/git-enrich.js +130 -0
- package/core/graph.js +24 -2
- package/core/output/analysis.js +157 -25
- package/core/output/brief.js +100 -0
- package/core/output/check.js +79 -0
- package/core/output/doctor.js +85 -0
- package/core/output/endpoints.js +239 -0
- package/core/output/extraction.js +2 -0
- package/core/output/find.js +126 -39
- package/core/output/graph.js +48 -15
- package/core/output/refactoring.js +103 -5
- package/core/output/reporting.js +63 -23
- package/core/output/search.js +110 -17
- package/core/output/shared.js +56 -2
- package/core/output.js +4 -0
- package/core/parser.js +8 -2
- package/core/project.js +39 -3
- package/core/registry.js +30 -14
- package/core/reporting.js +465 -2
- package/core/search.js +130 -10
- package/core/shared.js +101 -5
- package/core/tracing.js +16 -6
- package/core/verify.js +982 -95
- package/languages/go.js +91 -6
- package/languages/html.js +10 -0
- package/languages/java.js +151 -35
- package/languages/javascript.js +290 -33
- package/languages/python.js +78 -11
- package/languages/rust.js +267 -12
- package/languages/utils.js +315 -3
- package/mcp/server.js +91 -16
- package/package.json +9 -1
package/core/execute.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
|
-
const { addTestExclusions, pickBestDefinition } = require('./shared');
|
|
15
|
+
const { addTestExclusions, pickBestDefinition, parseSymbolHandle, looksLikeHandle } = require('./shared');
|
|
16
16
|
const { cleanHtmlScriptTags, detectLanguage } = require('./parser');
|
|
17
17
|
const { renderExpandItem } = require('./expand-cache');
|
|
18
18
|
|
|
@@ -66,6 +66,8 @@ function splitClassMethod(name) {
|
|
|
66
66
|
* part (explicit --class-name takes precedence over the class from dot notation).
|
|
67
67
|
*/
|
|
68
68
|
function applyClassMethodSyntax(p) {
|
|
69
|
+
// Run handle normalization first — handles can be passed where a name is expected.
|
|
70
|
+
applyHandleSyntax(p);
|
|
69
71
|
const split = splitClassMethod(p.name);
|
|
70
72
|
if (split) {
|
|
71
73
|
p.name = split.methodName;
|
|
@@ -73,6 +75,28 @@ function applyClassMethodSyntax(p) {
|
|
|
73
75
|
}
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
/**
|
|
79
|
+
* If p.name is a stable handle (file:line[:name]), parse it and set p.name,
|
|
80
|
+
* p.file, p.line so downstream resolution targets the exact symbol. Idempotent.
|
|
81
|
+
*
|
|
82
|
+
* Why: lets multi-step workflows roundtrip without name disambiguation. A
|
|
83
|
+
* `find` result emits `lib/api.ts:42:handler`; piping that to `brief`/`impact`
|
|
84
|
+
* pins resolution to that exact definition even if 5 other `handler`s exist.
|
|
85
|
+
*/
|
|
86
|
+
function applyHandleSyntax(p) {
|
|
87
|
+
if (!p || !p.name) return;
|
|
88
|
+
if (!looksLikeHandle(p.name)) return;
|
|
89
|
+
const h = parseSymbolHandle(p.name);
|
|
90
|
+
if (!h) return;
|
|
91
|
+
// Pull name out of handle. If the handle has no name suffix, we need to
|
|
92
|
+
// recover it from the index — but at this layer we only have params.
|
|
93
|
+
// The downstream resolveSymbol path will look up by file+line if name is empty.
|
|
94
|
+
if (h.name) p.name = h.name;
|
|
95
|
+
// Only override p.file/p.line if they weren't explicitly set by the user
|
|
96
|
+
if (h.file && !p.file) p.file = h.file;
|
|
97
|
+
if (h.line && !p.line) p.line = h.line;
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
/** Normalize exclude to an array (accepts string CSV, array, or falsy). */
|
|
77
101
|
function toExcludeArray(exclude) {
|
|
78
102
|
if (!exclude) return [];
|
|
@@ -94,6 +118,7 @@ function buildCallerOptions(p) {
|
|
|
94
118
|
return {
|
|
95
119
|
file: p.file,
|
|
96
120
|
className: p.className,
|
|
121
|
+
...(p.line && { line: p.line }),
|
|
97
122
|
includeMethods: p.includeMethods,
|
|
98
123
|
includeUncertain: p.includeUncertain || false,
|
|
99
124
|
includeTests: p.includeTests,
|
|
@@ -232,6 +257,8 @@ const HANDLERS = {
|
|
|
232
257
|
all: p.all,
|
|
233
258
|
maxCallers: num(p.top, undefined),
|
|
234
259
|
maxCallees: num(p.top, undefined),
|
|
260
|
+
unreachableOnly: !!p.unreachableOnly,
|
|
261
|
+
git: !!p.git,
|
|
235
262
|
});
|
|
236
263
|
if (!result) {
|
|
237
264
|
// Give better error if file/className filter is the problem
|
|
@@ -264,6 +291,7 @@ const HANDLERS = {
|
|
|
264
291
|
if (classErr) return { ok: false, error: classErr };
|
|
265
292
|
const result = index.context(p.name, {
|
|
266
293
|
...buildCallerOptions(p),
|
|
294
|
+
unreachableOnly: !!p.unreachableOnly,
|
|
267
295
|
});
|
|
268
296
|
if (!result) return { ok: false, error: `Symbol "${p.name}" not found.` };
|
|
269
297
|
const tNote = truncationNote(index);
|
|
@@ -283,6 +311,13 @@ const HANDLERS = {
|
|
|
283
311
|
className: p.className,
|
|
284
312
|
exclude: toExcludeArray(p.exclude),
|
|
285
313
|
top: num(p.top, undefined),
|
|
314
|
+
unreachableOnly: !!p.unreachableOnly,
|
|
315
|
+
// BUG-H3: pass through user-supplied flags. impact defaults to including
|
|
316
|
+
// method calls because "what breaks if I change this" should include
|
|
317
|
+
// every callable site, not just bare-name calls. User can disable with
|
|
318
|
+
// --no-include-methods.
|
|
319
|
+
...(p.includeMethods !== undefined && { includeMethods: p.includeMethods }),
|
|
320
|
+
...(p.includeUncertain !== undefined && { includeUncertain: p.includeUncertain }),
|
|
286
321
|
});
|
|
287
322
|
if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
|
|
288
323
|
const tNote = truncationNote(index);
|
|
@@ -377,8 +412,25 @@ const HANDLERS = {
|
|
|
377
412
|
if (fileErr) return { ok: false, error: fileErr };
|
|
378
413
|
const classErr = validateClassName(index, p.name, p.className);
|
|
379
414
|
if (classErr) return { ok: false, error: classErr };
|
|
380
|
-
const result = index.example(p.name, {
|
|
415
|
+
const result = index.example(p.name, {
|
|
416
|
+
file: p.file,
|
|
417
|
+
className: p.className,
|
|
418
|
+
diverse: !!p.diverse,
|
|
419
|
+
top: num(p.top, undefined),
|
|
420
|
+
// MEDIUM-8: thread includeTests so test-file callers are included
|
|
421
|
+
// when the user asks for them.
|
|
422
|
+
includeTests: !!p.includeTests,
|
|
423
|
+
});
|
|
381
424
|
if (!result) return { ok: false, error: `No examples found for "${p.name}".` };
|
|
425
|
+
// MEDIUM-8: when no non-test examples found but test-file usages
|
|
426
|
+
// exist, return success with a note so the user knows to retry.
|
|
427
|
+
if (!result.best && result.excludedTestCalls > 0) {
|
|
428
|
+
return {
|
|
429
|
+
ok: true,
|
|
430
|
+
result,
|
|
431
|
+
note: `0 examples found (excluded ${result.excludedTestCalls} test-file usage${result.excludedTestCalls === 1 ? '' : 's'} — pass --include-tests to include them)`,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
382
434
|
return { ok: true, result };
|
|
383
435
|
},
|
|
384
436
|
|
|
@@ -408,6 +460,46 @@ const HANDLERS = {
|
|
|
408
460
|
return { ok: true, result, ...(combined && { note: combined }) };
|
|
409
461
|
},
|
|
410
462
|
|
|
463
|
+
brief: (index, p) => {
|
|
464
|
+
const err = requireName(p.name);
|
|
465
|
+
if (err) return { ok: false, error: err };
|
|
466
|
+
applyClassMethodSyntax(p);
|
|
467
|
+
const fileErr = checkFilePatternMatch(index, p.file);
|
|
468
|
+
if (fileErr) return { ok: false, error: fileErr };
|
|
469
|
+
const classErr = validateClassName(index, p.name, p.className);
|
|
470
|
+
if (classErr) return { ok: false, error: classErr };
|
|
471
|
+
const { brief } = require('./brief');
|
|
472
|
+
const result = brief(index, p.name, { file: p.file, className: p.className, git: !!p.git });
|
|
473
|
+
if (!result) return { ok: false, error: `Symbol "${p.name}" not found.` };
|
|
474
|
+
return { ok: true, result };
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
doctor: (index, p) => {
|
|
478
|
+
const { doctor } = require('./reporting');
|
|
479
|
+
const result = doctor(index, {
|
|
480
|
+
in: p.in,
|
|
481
|
+
file: p.file,
|
|
482
|
+
deep: !!p.deep,
|
|
483
|
+
sampleSize: num(p.limit, undefined),
|
|
484
|
+
});
|
|
485
|
+
return { ok: true, result };
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
check: (index, p) => {
|
|
489
|
+
const { check } = require('./check');
|
|
490
|
+
try {
|
|
491
|
+
const result = check(index, {
|
|
492
|
+
base: p.base || 'HEAD',
|
|
493
|
+
staged: !!p.staged,
|
|
494
|
+
file: p.file,
|
|
495
|
+
limit: num(p.limit, undefined),
|
|
496
|
+
});
|
|
497
|
+
return { ok: true, result };
|
|
498
|
+
} catch (e) {
|
|
499
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
|
|
411
503
|
// ── Finding Code ────────────────────────────────────────────────────
|
|
412
504
|
|
|
413
505
|
find: (index, p) => {
|
|
@@ -669,7 +761,17 @@ const HANDLERS = {
|
|
|
669
761
|
const fileErr = checkFilePatternMatch(index, p.file);
|
|
670
762
|
if (fileErr) return { ok: false, error: fileErr };
|
|
671
763
|
const { detectEntrypoints } = require('./entrypoints');
|
|
672
|
-
|
|
764
|
+
// JAVA-2: tests ARE entry points (JUnit @Test, pytest fixtures,
|
|
765
|
+
// Rust #[test], etc.) — show them by default. Previously this command
|
|
766
|
+
// applied addTestExclusions() unconditionally, which stripped Java
|
|
767
|
+
// *Tests.java entries while letting Rust #[test] through (asymmetric).
|
|
768
|
+
// Now consistent: default = include test entries; user opts out via
|
|
769
|
+
// --exclude-tests (or --include-tests=false for back-compat).
|
|
770
|
+
const userExclude = Array.isArray(p.exclude)
|
|
771
|
+
? p.exclude
|
|
772
|
+
: (p.exclude ? p.exclude.split(',').map(s => s.trim()).filter(Boolean) : []);
|
|
773
|
+
const wantsExcludeTests = p.excludeTests === true || p.includeTests === false;
|
|
774
|
+
const exclude = wantsExcludeTests ? addTestExclusions(userExclude) : userExclude;
|
|
673
775
|
let result = detectEntrypoints(index, {
|
|
674
776
|
type: p.type,
|
|
675
777
|
framework: p.framework,
|
|
@@ -685,6 +787,99 @@ const HANDLERS = {
|
|
|
685
787
|
return { ok: true, result, note };
|
|
686
788
|
},
|
|
687
789
|
|
|
790
|
+
endpoints: (index, p) => {
|
|
791
|
+
const fileErr = checkFilePatternMatch(index, p.file);
|
|
792
|
+
if (fileErr) return { ok: false, error: fileErr };
|
|
793
|
+
// Minor polish: validate --method against known HTTP verbs to catch
|
|
794
|
+
// typos that would otherwise silently filter out everything.
|
|
795
|
+
// 'ALL' / 'USE' are downstream route labels that can be queried explicitly.
|
|
796
|
+
const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'ALL', 'USE']);
|
|
797
|
+
let normMethod = null;
|
|
798
|
+
if (p.method != null && String(p.method).trim() !== '') {
|
|
799
|
+
normMethod = String(p.method).trim().toUpperCase();
|
|
800
|
+
if (!HTTP_METHODS.has(normMethod)) {
|
|
801
|
+
return {
|
|
802
|
+
ok: false,
|
|
803
|
+
error: `Invalid --method value: "${p.method}". Expected one of ${[...HTTP_METHODS].join(', ')}.`,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const { endpoints } = require('./bridge');
|
|
808
|
+
// HIGH-2: --unmatched implies --bridge (we need bridges to know what's
|
|
809
|
+
// unmatched). Without bridges computed, we can't separate matched from
|
|
810
|
+
// unmatched on either side.
|
|
811
|
+
const wantUnmatched = !!p.unmatched;
|
|
812
|
+
const wantBridge = !!p.bridge || wantUnmatched;
|
|
813
|
+
const result = endpoints(index, {
|
|
814
|
+
bridge: wantBridge,
|
|
815
|
+
serverOnly: !!p.serverOnly,
|
|
816
|
+
clientOnly: !!p.clientOnly,
|
|
817
|
+
unmatched: wantUnmatched,
|
|
818
|
+
method: normMethod,
|
|
819
|
+
prefix: p.prefix || null,
|
|
820
|
+
showUncertain: !p.hideUncertain,
|
|
821
|
+
});
|
|
822
|
+
// Apply --file pattern as an additional filter on routes/requests
|
|
823
|
+
if (p.file) {
|
|
824
|
+
const sub = String(p.file);
|
|
825
|
+
result.routes = result.routes.filter(r => r.file.includes(sub));
|
|
826
|
+
result.requests = result.requests.filter(r => r.file.includes(sub));
|
|
827
|
+
result.bridges = result.bridges.filter(b =>
|
|
828
|
+
b.route.file.includes(sub) || b.request.file.includes(sub)
|
|
829
|
+
);
|
|
830
|
+
result.unmatchedRoutes = result.unmatchedRoutes.filter(r => r.file.includes(sub));
|
|
831
|
+
result.unmatchedRequests = result.unmatchedRequests.filter(r => r.file.includes(sub));
|
|
832
|
+
}
|
|
833
|
+
// Apply --exclude patterns to route/request files (deadcode-style boundary matching)
|
|
834
|
+
const exclude = toExcludeArray(p.exclude);
|
|
835
|
+
if (exclude.length > 0) {
|
|
836
|
+
const regexes = exclude.map(pat =>
|
|
837
|
+
new RegExp('(^|[/._-])' + pat + 's?([/._-]|$)', 'i'));
|
|
838
|
+
const matches = (file) => regexes.some(rx => rx.test(file));
|
|
839
|
+
result.routes = result.routes.filter(r => !matches(r.file));
|
|
840
|
+
result.requests = result.requests.filter(r => !matches(r.file));
|
|
841
|
+
result.bridges = result.bridges.filter(b => !matches(b.route.file) && !matches(b.request.file));
|
|
842
|
+
result.unmatchedRoutes = result.unmatchedRoutes.filter(r => !matches(r.file));
|
|
843
|
+
result.unmatchedRequests = result.unmatchedRequests.filter(r => !matches(r.file));
|
|
844
|
+
}
|
|
845
|
+
// Apply --limit
|
|
846
|
+
const limit = num(p.limit, undefined);
|
|
847
|
+
let note;
|
|
848
|
+
if (limit && limit > 0) {
|
|
849
|
+
const totalListed = result.routes.length + result.requests.length;
|
|
850
|
+
if (totalListed > limit) {
|
|
851
|
+
// Limit each list proportionally — but simpler: hard-cap each.
|
|
852
|
+
const halfLim = Math.max(1, Math.floor(limit / 2));
|
|
853
|
+
if (result.routes.length > halfLim) {
|
|
854
|
+
result.routes = result.routes.slice(0, halfLim);
|
|
855
|
+
}
|
|
856
|
+
if (result.requests.length > halfLim) {
|
|
857
|
+
result.requests = result.requests.slice(0, halfLim);
|
|
858
|
+
}
|
|
859
|
+
note = limitNote(limit, totalListed);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
// Recompute meta after filtering
|
|
863
|
+
result.meta = {
|
|
864
|
+
totalRoutes: result.routes.length,
|
|
865
|
+
totalRequests: result.requests.length,
|
|
866
|
+
totalBridges: result.bridges.length,
|
|
867
|
+
unmatchedRoutes: result.unmatchedRoutes.length,
|
|
868
|
+
unmatchedRequests: result.unmatchedRequests.length,
|
|
869
|
+
byFramework: result.routes.reduce((acc, r) => {
|
|
870
|
+
acc[r.framework] = (acc[r.framework] || 0) + 1;
|
|
871
|
+
return acc;
|
|
872
|
+
}, {}),
|
|
873
|
+
};
|
|
874
|
+
// Pass display flags through to formatter via result properties.
|
|
875
|
+
// (HIGH-2: --unmatched implies --bridge for computation, but the
|
|
876
|
+
// formatter needs to know which mode the user ASKED for so it can
|
|
877
|
+
// suppress the "Matched" section in unmatched-only mode.)
|
|
878
|
+
result._bridge = wantBridge;
|
|
879
|
+
result._unmatched = wantUnmatched;
|
|
880
|
+
return { ok: true, result, note };
|
|
881
|
+
},
|
|
882
|
+
|
|
688
883
|
// ── Extracting Code ─────────────────────────────────────────────────
|
|
689
884
|
|
|
690
885
|
fn: (index, p) => {
|
|
@@ -909,6 +1104,9 @@ const HANDLERS = {
|
|
|
909
1104
|
direction: p.direction || 'both',
|
|
910
1105
|
maxDepth: num(p.depth, 2),
|
|
911
1106
|
});
|
|
1107
|
+
if (result && result.error === 'invalid-direction') {
|
|
1108
|
+
return { ok: false, error: result.message };
|
|
1109
|
+
}
|
|
912
1110
|
const fileErr = checkFileError(result, p.file);
|
|
913
1111
|
if (fileErr) return { ok: false, error: fileErr };
|
|
914
1112
|
return { ok: true, result };
|
|
@@ -932,7 +1130,16 @@ const HANDLERS = {
|
|
|
932
1130
|
if (fileErr) return { ok: false, error: fileErr };
|
|
933
1131
|
const classErr = validateClassName(index, p.name, p.className);
|
|
934
1132
|
if (classErr) return { ok: false, error: classErr };
|
|
935
|
-
const result = index.verify(p.name, {
|
|
1133
|
+
const result = index.verify(p.name, {
|
|
1134
|
+
file: p.file,
|
|
1135
|
+
className: p.className,
|
|
1136
|
+
// BUG-H3: pass through user-supplied flags. Verify defaults to including
|
|
1137
|
+
// method calls (current behavior) so call-arity checks reach all forms,
|
|
1138
|
+
// including obj.method() invocations. User can disable with
|
|
1139
|
+
// --no-include-methods.
|
|
1140
|
+
...(p.includeMethods !== undefined && { includeMethods: p.includeMethods }),
|
|
1141
|
+
...(p.includeUncertain !== undefined && { includeUncertain: p.includeUncertain }),
|
|
1142
|
+
});
|
|
936
1143
|
return { ok: true, result };
|
|
937
1144
|
},
|
|
938
1145
|
|
|
@@ -1017,10 +1224,69 @@ const HANDLERS = {
|
|
|
1017
1224
|
},
|
|
1018
1225
|
|
|
1019
1226
|
stats: (index, p) => {
|
|
1227
|
+
// MEDIUM-7: validate `--top`. Previously non-numeric, zero, and
|
|
1228
|
+
// negative values silently fell back to 10 — confusing when a typo
|
|
1229
|
+
// hides a request to show MORE entries.
|
|
1230
|
+
// BUG-2: reject 0 and negative explicitly — the rendering ("top 0 of
|
|
1231
|
+
// 718 called: (no inbound calls detected)") was misleading because
|
|
1232
|
+
// it implied no calls exist when the user simply asked for nothing.
|
|
1233
|
+
let top;
|
|
1234
|
+
let note;
|
|
1235
|
+
if (p.top != null) {
|
|
1236
|
+
const raw = String(p.top).trim();
|
|
1237
|
+
const n = Number(raw);
|
|
1238
|
+
if (raw === '' || isNaN(n) || !isFinite(n)) {
|
|
1239
|
+
return {
|
|
1240
|
+
ok: false,
|
|
1241
|
+
error: `Invalid --top value: must be a positive integer (got "${p.top}")`,
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
if (!Number.isInteger(n)) {
|
|
1245
|
+
return {
|
|
1246
|
+
ok: false,
|
|
1247
|
+
error: `Invalid --top value: must be an integer (got ${n})`,
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
if (n <= 0) {
|
|
1251
|
+
return {
|
|
1252
|
+
ok: false,
|
|
1253
|
+
error: `Invalid --top value: must be a positive integer (got ${n})`,
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
if (n > 10000) {
|
|
1257
|
+
top = 10000;
|
|
1258
|
+
note = `--top capped at 10000 (requested ${n})`;
|
|
1259
|
+
} else {
|
|
1260
|
+
top = n;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1020
1263
|
const result = index.getStats({
|
|
1021
1264
|
functions: p.functions || false,
|
|
1265
|
+
hot: p.hot || false,
|
|
1266
|
+
top,
|
|
1022
1267
|
});
|
|
1023
|
-
return { ok: true, result };
|
|
1268
|
+
return note ? { ok: true, result, note } : { ok: true, result };
|
|
1269
|
+
},
|
|
1270
|
+
|
|
1271
|
+
auditAsync: (index, p) => {
|
|
1272
|
+
if (p.file) {
|
|
1273
|
+
const fileErr = checkFilePatternMatch(index, p.file);
|
|
1274
|
+
if (fileErr) return { ok: false, error: fileErr };
|
|
1275
|
+
}
|
|
1276
|
+
let result = index.auditAsync({
|
|
1277
|
+
file: p.file,
|
|
1278
|
+
exclude: toExcludeArray(p.exclude),
|
|
1279
|
+
});
|
|
1280
|
+
// Apply limit to the issues array.
|
|
1281
|
+
const limit = num(p.limit, undefined);
|
|
1282
|
+
let note;
|
|
1283
|
+
if (limit && limit > 0 && result && Array.isArray(result.issues) && result.issues.length > limit) {
|
|
1284
|
+
note = limitNote(limit, result.issues.length);
|
|
1285
|
+
result = { ...result, issues: result.issues.slice(0, limit) };
|
|
1286
|
+
}
|
|
1287
|
+
const tNote = truncationNote(index);
|
|
1288
|
+
if (tNote) note = note ? `${note}\n${tNote}` : tNote;
|
|
1289
|
+
return { ok: true, result, note };
|
|
1024
1290
|
},
|
|
1025
1291
|
|
|
1026
1292
|
// ── Expand (context drill-down) ──────────────────────────────────────
|
|
@@ -1060,10 +1326,43 @@ function execute(index, command, params = {}) {
|
|
|
1060
1326
|
return { ok: false, error: `Unknown command: ${command}` };
|
|
1061
1327
|
}
|
|
1062
1328
|
try {
|
|
1329
|
+
// Resolve name-less handles (e.g. `lib.js:42`) via index lookup before dispatch.
|
|
1330
|
+
// Handles WITH a name suffix are handled later by applyClassMethodSyntax.
|
|
1331
|
+
if (params && params.name && looksLikeHandle(params.name)) {
|
|
1332
|
+
const h = parseSymbolHandle(params.name);
|
|
1333
|
+
if (h && !h.name && h.file && h.line) {
|
|
1334
|
+
const sym = lookupByLocation(index, h.file, h.line);
|
|
1335
|
+
if (sym) {
|
|
1336
|
+
params.name = sym.name;
|
|
1337
|
+
if (!params.file) params.file = h.file;
|
|
1338
|
+
if (!params.line) params.line = h.line;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1063
1342
|
return handler(index, params);
|
|
1064
1343
|
} catch (e) {
|
|
1065
1344
|
return { ok: false, error: e.message };
|
|
1066
1345
|
}
|
|
1067
1346
|
}
|
|
1068
1347
|
|
|
1348
|
+
/**
|
|
1349
|
+
* Look up a symbol record by file path + start line. Used to recover a name
|
|
1350
|
+
* from a name-less handle (`relativePath:line`). Returns the first matching
|
|
1351
|
+
* symbol or null. Path matching is permissive (substring) so handles emitted
|
|
1352
|
+
* with relative paths still resolve when callers pass partial paths.
|
|
1353
|
+
*/
|
|
1354
|
+
function lookupByLocation(index, file, line) {
|
|
1355
|
+
if (!index || !index.symbols || !file || !line) return null;
|
|
1356
|
+
for (const arr of index.symbols.values()) {
|
|
1357
|
+
for (const sym of arr) {
|
|
1358
|
+
if (sym.startLine !== line) continue;
|
|
1359
|
+
const rp = sym.relativePath || '';
|
|
1360
|
+
if (rp === file || rp.endsWith('/' + file) || file.endsWith('/' + rp) || rp.includes(file)) {
|
|
1361
|
+
return sym;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
return null;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1069
1368
|
module.exports = { execute };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/git-enrich.js — Optional git enrichment for about/brief output.
|
|
3
|
+
*
|
|
4
|
+
* Pure shell-out to `git log` — no parsing libraries, no LLM. Returns
|
|
5
|
+
* `{ available: false }` for any failure (not a repo, file untracked,
|
|
6
|
+
* git missing) so callers can render gracefully.
|
|
7
|
+
*
|
|
8
|
+
* Cached per (root, relPath) for the lifetime of the process — git history
|
|
9
|
+
* doesn't change mid-command, and `about` / `brief` may be invoked many
|
|
10
|
+
* times against the same files in interactive/MCP sessions.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const { execFileSync } = require('child_process');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
// Module-level cache: `${projectRoot}::${relPath}` → enrichment object.
|
|
19
|
+
// Process-lifetime is correct here — across-process invocations re-shell-out,
|
|
20
|
+
// which is fine (git is fast enough), and within a process the cache prevents
|
|
21
|
+
// hammering git for the same file when an agent runs `about` then `brief`.
|
|
22
|
+
const _cache = new Map();
|
|
23
|
+
|
|
24
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get git info for a file path inside a project.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} projectRoot - Absolute path to the project root
|
|
30
|
+
* @param {string} relativeFilePath - Relative path inside the project
|
|
31
|
+
* @returns {{ available: boolean, lastModified?: string, author?: string, recentChanges?: number, error?: string }}
|
|
32
|
+
*/
|
|
33
|
+
function getGitInfo(projectRoot, relativeFilePath) {
|
|
34
|
+
if (!projectRoot || !relativeFilePath) {
|
|
35
|
+
return { available: false, error: 'missing projectRoot or path' };
|
|
36
|
+
}
|
|
37
|
+
// Normalize to forward slashes for git on Windows
|
|
38
|
+
const relPath = relativeFilePath.split(path.sep).join('/');
|
|
39
|
+
const cacheKey = `${projectRoot}::${relPath}`;
|
|
40
|
+
if (_cache.has(cacheKey)) return _cache.get(cacheKey);
|
|
41
|
+
|
|
42
|
+
let info;
|
|
43
|
+
try {
|
|
44
|
+
// Last commit touching this file: ISO timestamp + author name.
|
|
45
|
+
// Use --follow to track renames, but only if the file exists in history.
|
|
46
|
+
// We capture stderr so we can classify failure modes (not-a-repo,
|
|
47
|
+
// file-not-tracked, timeout) instead of leaking the raw shell error
|
|
48
|
+
// to the caller (MEDIUM-9).
|
|
49
|
+
const lastLine = execFileSync(
|
|
50
|
+
'git',
|
|
51
|
+
['log', '-1', '--format=%aI|%an', '--', relPath],
|
|
52
|
+
{
|
|
53
|
+
cwd: projectRoot,
|
|
54
|
+
encoding: 'utf-8',
|
|
55
|
+
timeout: GIT_TIMEOUT_MS,
|
|
56
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
57
|
+
}
|
|
58
|
+
).trim();
|
|
59
|
+
|
|
60
|
+
if (!lastLine) {
|
|
61
|
+
// File exists but no git history — likely untracked
|
|
62
|
+
info = { available: false, error: 'File not tracked' };
|
|
63
|
+
} else {
|
|
64
|
+
const [lastModified, author] = lastLine.split('|');
|
|
65
|
+
|
|
66
|
+
// Count of commits in the last 30 days that touched this file.
|
|
67
|
+
// We use a one-line --format and count rather than --oneline | wc -l
|
|
68
|
+
// to avoid platform shell differences.
|
|
69
|
+
const recentLines = execFileSync(
|
|
70
|
+
'git',
|
|
71
|
+
['log', '--since=30 days ago', '--format=%H', '--', relPath],
|
|
72
|
+
{
|
|
73
|
+
cwd: projectRoot,
|
|
74
|
+
encoding: 'utf-8',
|
|
75
|
+
timeout: GIT_TIMEOUT_MS,
|
|
76
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
77
|
+
}
|
|
78
|
+
).trim();
|
|
79
|
+
const recentChanges = recentLines === '' ? 0 : recentLines.split('\n').length;
|
|
80
|
+
|
|
81
|
+
info = {
|
|
82
|
+
available: true,
|
|
83
|
+
lastModified: lastModified || null,
|
|
84
|
+
author: author || null,
|
|
85
|
+
recentChanges,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// MEDIUM-9: never leak the raw shell command back to the user (which
|
|
90
|
+
// includes the path being queried — looks like a stack trace and is
|
|
91
|
+
// surface-level confusing in JSON output). Classify and translate.
|
|
92
|
+
info = { available: false, error: classifyGitError(e) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_cache.set(cacheKey, info);
|
|
96
|
+
return info;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Translate a git execFileSync exception into a user-friendly error string.
|
|
101
|
+
* Inspects exit code, signal, and (when captured) stderr text to pick the
|
|
102
|
+
* right category. Falls back to a generic "Git unavailable" rather than
|
|
103
|
+
* leaking the underlying command — the caller renders this as JSON / text.
|
|
104
|
+
*/
|
|
105
|
+
function classifyGitError(e) {
|
|
106
|
+
if (!e) return 'Git unavailable';
|
|
107
|
+
// Timeout: child_process throws ENOENT-like errors with `signal: 'SIGTERM'`
|
|
108
|
+
// or `killed: true` when the timeout fires.
|
|
109
|
+
if (e.signal === 'SIGTERM' || e.killed === true) return 'Git timed out';
|
|
110
|
+
// git binary not installed
|
|
111
|
+
if (e.code === 'ENOENT') return 'Git not installed';
|
|
112
|
+
// Inspect stderr if captured
|
|
113
|
+
const stderr = e.stderr ? String(e.stderr) : '';
|
|
114
|
+
const lower = stderr.toLowerCase();
|
|
115
|
+
if (lower.includes('not a git repository')) return 'Not a git repository';
|
|
116
|
+
if (lower.includes("did not match any file") ||
|
|
117
|
+
lower.includes('pathspec') && lower.includes('did not match')) {
|
|
118
|
+
return 'File not tracked';
|
|
119
|
+
}
|
|
120
|
+
// Exit 128 commonly means "fatal" git error — we already caught the
|
|
121
|
+
// common cases above, so anything else is generic.
|
|
122
|
+
return 'Git unavailable';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Test helper: clear the in-process cache. */
|
|
126
|
+
function _clearCache() {
|
|
127
|
+
_cache.clear();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { getGitInfo, _clearCache };
|
package/core/graph.js
CHANGED
|
@@ -179,11 +179,18 @@ function fileExports(index, filePath, _visited) {
|
|
|
179
179
|
const results = [];
|
|
180
180
|
const exportedNames = new Set(fileEntry.exports);
|
|
181
181
|
|
|
182
|
+
// Python convention: when a module declares no `__all__`, every top-level
|
|
183
|
+
// non-`_` name is considered public. We don't want this in the underlying
|
|
184
|
+
// export list (deadcode would think everything is exported), so fileExports
|
|
185
|
+
// applies it locally for display.
|
|
186
|
+
const isPythonImplicit = fileEntry.language === 'python' && exportedNames.size === 0;
|
|
187
|
+
|
|
182
188
|
for (const symbol of fileEntry.symbols) {
|
|
183
189
|
const isExported = exportedNames.has(symbol.name) ||
|
|
184
190
|
(symbol.modifiers && symbol.modifiers.includes('export')) ||
|
|
185
191
|
(symbol.modifiers && symbol.modifiers.includes('public')) ||
|
|
186
|
-
(langTraits(fileEntry.language)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(symbol.name))
|
|
192
|
+
(langTraits(fileEntry.language)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(symbol.name)) ||
|
|
193
|
+
(isPythonImplicit && symbol.name && !symbol.name.startsWith('_') && !symbol.className && !symbol.isMethod);
|
|
187
194
|
|
|
188
195
|
if (isExported) {
|
|
189
196
|
results.push({
|
|
@@ -443,7 +450,22 @@ function api(index, filePath, options = {}) {
|
|
|
443
450
|
* @returns {object} - Graph structure with root, nodes, edges
|
|
444
451
|
*/
|
|
445
452
|
function graph(index, filePath, options = {}) {
|
|
446
|
-
|
|
453
|
+
// Normalize direction. Accept aliases (`in` ≡ importers, `out` ≡ imports),
|
|
454
|
+
// reject anything else with an explicit error so users don't get a silent
|
|
455
|
+
// empty-graph answer.
|
|
456
|
+
const rawDirection = options.direction || 'both';
|
|
457
|
+
const DIRECTION_ALIASES = {
|
|
458
|
+
'imports': 'imports', 'out': 'imports', 'outgoing': 'imports', 'downstream': 'imports',
|
|
459
|
+
'importers': 'importers', 'in': 'importers', 'incoming': 'importers', 'upstream': 'importers',
|
|
460
|
+
'both': 'both',
|
|
461
|
+
};
|
|
462
|
+
const direction = DIRECTION_ALIASES[rawDirection];
|
|
463
|
+
if (!direction) {
|
|
464
|
+
return {
|
|
465
|
+
error: 'invalid-direction',
|
|
466
|
+
message: `Unknown direction "${rawDirection}". Valid: imports/out/outgoing/downstream, importers/in/incoming/upstream, both.`,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
447
469
|
// Sanitize depth: use default for null/undefined, clamp negative to 0
|
|
448
470
|
const rawDepth = options.maxDepth ?? 5;
|
|
449
471
|
const maxDepth = Math.max(0, rawDepth);
|