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.
Files changed (44) hide show
  1. package/.claude/skills/ucn/SKILL.md +114 -11
  2. package/README.md +152 -156
  3. package/cli/index.js +363 -37
  4. package/core/analysis.js +936 -32
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +105 -5
  8. package/core/callers.js +72 -18
  9. package/core/check.js +200 -0
  10. package/core/discovery.js +57 -34
  11. package/core/entrypoints.js +638 -4
  12. package/core/execute.js +304 -5
  13. package/core/git-enrich.js +130 -0
  14. package/core/graph.js +24 -2
  15. package/core/output/analysis.js +157 -25
  16. package/core/output/brief.js +100 -0
  17. package/core/output/check.js +79 -0
  18. package/core/output/doctor.js +85 -0
  19. package/core/output/endpoints.js +239 -0
  20. package/core/output/extraction.js +2 -0
  21. package/core/output/find.js +126 -39
  22. package/core/output/graph.js +48 -15
  23. package/core/output/refactoring.js +103 -5
  24. package/core/output/reporting.js +63 -23
  25. package/core/output/search.js +110 -17
  26. package/core/output/shared.js +56 -2
  27. package/core/output.js +4 -0
  28. package/core/parser.js +8 -2
  29. package/core/project.js +39 -3
  30. package/core/registry.js +30 -14
  31. package/core/reporting.js +465 -2
  32. package/core/search.js +130 -10
  33. package/core/shared.js +101 -5
  34. package/core/tracing.js +16 -6
  35. package/core/verify.js +982 -95
  36. package/languages/go.js +91 -6
  37. package/languages/html.js +10 -0
  38. package/languages/java.js +151 -35
  39. package/languages/javascript.js +290 -33
  40. package/languages/python.js +78 -11
  41. package/languages/rust.js +267 -12
  42. package/languages/utils.js +315 -3
  43. package/mcp/server.js +91 -16
  44. 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, { file: p.file, className: p.className });
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
- const exclude = applyTestExclusions(p.exclude, p.includeTests);
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, { file: p.file, className: p.className });
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
- const direction = options.direction || 'both';
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);