ucn 4.0.2 → 4.1.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.
Files changed (46) hide show
  1. package/.claude/skills/ucn/SKILL.md +31 -7
  2. package/README.md +89 -50
  3. package/cli/index.js +199 -94
  4. package/core/account.js +3 -1
  5. package/core/analysis.js +322 -304
  6. package/core/bridge.js +13 -8
  7. package/core/cache.js +109 -19
  8. package/core/callers.js +969 -77
  9. package/core/check.js +19 -8
  10. package/core/deadcode.js +368 -40
  11. package/core/discovery.js +31 -11
  12. package/core/entrypoints.js +149 -17
  13. package/core/execute.js +330 -43
  14. package/core/graph-build.js +61 -10
  15. package/core/graph.js +282 -61
  16. package/core/imports.js +72 -3
  17. package/core/output/analysis-ext.js +70 -10
  18. package/core/output/analysis.js +52 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/endpoints.js +8 -3
  21. package/core/output/extraction.js +12 -1
  22. package/core/output/find.js +23 -9
  23. package/core/output/graph.js +32 -9
  24. package/core/output/refactoring.js +147 -49
  25. package/core/output/reporting.js +104 -5
  26. package/core/output/search.js +30 -3
  27. package/core/output/shared.js +31 -4
  28. package/core/output/tracing.js +22 -11
  29. package/core/parser.js +8 -6
  30. package/core/project.js +167 -7
  31. package/core/registry.js +20 -16
  32. package/core/reporting.js +131 -13
  33. package/core/search.js +270 -55
  34. package/core/shared.js +240 -1
  35. package/core/stacktrace.js +23 -1
  36. package/core/tracing.js +278 -36
  37. package/core/verify.js +352 -349
  38. package/languages/go.js +29 -17
  39. package/languages/index.js +56 -0
  40. package/languages/java.js +133 -16
  41. package/languages/javascript.js +215 -27
  42. package/languages/python.js +113 -47
  43. package/languages/rust.js +89 -26
  44. package/languages/utils.js +35 -7
  45. package/mcp/server.js +69 -30
  46. package/package.json +4 -1
package/core/discovery.js CHANGED
@@ -148,11 +148,19 @@ function parseGitignore(projectRoot) {
148
148
  // Strip trailing slash (directory indicator) — shouldIgnore checks names, not types
149
149
  let pattern = line.endsWith('/') ? line.slice(0, -1) : line;
150
150
 
151
- // Strip leading slash (root-relative indicator) we only match by name
152
- if (pattern.startsWith('/')) pattern = pattern.slice(1);
153
-
154
- // Skip patterns with path separators shouldIgnore matches single name segments,
155
- // not full paths. Patterns like "foo/bar" would need walkDir-level support.
151
+ // Leading slash = ANCHORED to the .gitignore's directory (fix #226).
152
+ // git semantics: `/locale` ignores only the root-level locale, never
153
+ // src/locale. Stripping the slash and matching by bare name silently
154
+ // excluded real source trees (dayjs ignores its BUILD outputs /locale
155
+ // /plugin src/locale and src/plugin are ~160 tracked source files
156
+ // UCN never indexed, with no warning). Anchored patterns keep the
157
+ // slash; shouldIgnore applies them only at the walk's anchor root.
158
+ const anchored = pattern.startsWith('/');
159
+ if (anchored) pattern = pattern.slice(1);
160
+
161
+ // Skip patterns with interior path separators — shouldIgnore matches
162
+ // single name segments, not full paths. Patterns like "foo/bar" would
163
+ // need walkDir-level support.
156
164
  if (pattern.includes('/')) continue;
157
165
 
158
166
  // Skip empty after stripping
@@ -161,7 +169,7 @@ function parseGitignore(projectRoot) {
161
169
  // Avoid duplicating built-in ignores
162
170
  if (DEFAULT_IGNORES.includes(pattern)) continue;
163
171
 
164
- patterns.push(pattern);
172
+ patterns.push(anchored ? '/' + pattern : pattern);
165
173
  }
166
174
 
167
175
  return patterns;
@@ -211,6 +219,9 @@ function expandGlob(pattern, options = {}) {
211
219
  ignores,
212
220
  maxDepth,
213
221
  followSymlinks,
222
+ // Anchored gitignore patterns ('/name') apply only to entries directly
223
+ // under the project root — the .gitignore's own directory (fix #226).
224
+ anchorRoot: root,
214
225
  onFile: (filePath) => {
215
226
  if (files.length < maxFiles) {
216
227
  files.push(filePath);
@@ -309,7 +320,7 @@ function walkDir(dir, options, depth = 0, visited = new Set()) {
309
320
  for (const entry of entries) {
310
321
  const fullPath = path.join(dir, entry.name);
311
322
 
312
- if (shouldIgnore(entry.name, options.ignores, dir)) continue;
323
+ if (shouldIgnore(entry.name, options.ignores, dir, dir === options.anchorRoot)) continue;
313
324
 
314
325
  let isDir = entry.isDirectory();
315
326
  let isFile = entry.isFile();
@@ -340,14 +351,22 @@ function walkDir(dir, options, depth = 0, visited = new Set()) {
340
351
  /**
341
352
  * Check if a file/directory name should be ignored
342
353
  * @param {string} name - File/directory name
343
- * @param {string[]} ignores - Patterns to always ignore
354
+ * @param {string[]} ignores - Patterns to always ignore. Patterns with a
355
+ * leading '/' are ANCHORED (git semantics, fix #226): they match only when
356
+ * `atAnchorRoot` is true — i.e. the entry sits directly in the directory
357
+ * the .gitignore belongs to. Default false errs toward KEEPING files.
344
358
  * @param {string} [parentDir] - Parent directory path (for conditional checks)
359
+ * @param {boolean} [atAnchorRoot] - Whether parentDir IS the anchor root
345
360
  */
346
361
  const _globRegexCache = new Map();
347
362
 
348
- function shouldIgnore(name, ignores, parentDir) {
363
+ function shouldIgnore(name, ignores, parentDir, atAnchorRoot = false) {
349
364
  // Check unconditional ignores
350
- for (const pattern of ignores) {
365
+ for (let pattern of ignores) {
366
+ if (pattern.charCodeAt(0) === 47 /* '/' */) {
367
+ if (!atAnchorRoot) continue;
368
+ pattern = pattern.slice(1);
369
+ }
351
370
  if (pattern.includes('*')) {
352
371
  let regex = _globRegexCache.get(pattern);
353
372
  if (!regex) {
@@ -570,5 +589,6 @@ module.exports = {
570
589
  PROJECT_MARKERS,
571
590
  TEST_PATTERNS,
572
591
  ALL_SUPPORTED_EXTENSIONS,
573
- MANIFEST_HINTS
592
+ MANIFEST_HINTS,
593
+ compareNames
574
594
  };
@@ -13,6 +13,7 @@
13
13
  'use strict';
14
14
 
15
15
  const fs = require('fs');
16
+ const { codeUnitCompare, NON_CALLABLE_TYPES } = require('./shared');
16
17
  const path = require('path');
17
18
  const { getCachedCalls } = require('./callers');
18
19
  const { getLanguageModule } = require('../languages');
@@ -69,24 +70,28 @@ const FRAMEWORK_PATTERNS = [
69
70
  pattern: /^(Get|Post|Put|Delete|Patch|Options|Head|All|Controller|Injectable|Module)$/,
70
71
  },
71
72
 
72
- // FastAPI (Python) — decorators: @app.get('/path'), @router.post('/path')
73
+ // Flask (Python) — decorators: @app.route('/path'), @bp.get('/path').
74
+ // Listed BEFORE fastapi: both frameworks share the @app.get/@app.post
75
+ // shortcuts, and @app.route is Flask-only — a file using @app.route +
76
+ // @app.post on the SAME app object got split across two frameworks
77
+ // (fix #245; first match wins in matchDecoratorOrModifier).
73
78
  {
74
- id: 'fastapi-route',
79
+ id: 'flask-route',
75
80
  languages: new Set(['python']),
76
81
  type: 'http',
77
- framework: 'fastapi',
82
+ framework: 'flask',
78
83
  detection: 'decorator',
79
- pattern: /^(app|router)\.(get|post|put|delete|patch|options|head)/,
84
+ pattern: /^(app|bp|blueprint)\.(route|get|post|put|delete|patch)/,
80
85
  },
81
86
 
82
- // Flask (Python) — decorators: @app.route('/path'), @bp.get('/path')
87
+ // FastAPI (Python) — decorators: @app.get('/path'), @router.post('/path')
83
88
  {
84
- id: 'flask-route',
89
+ id: 'fastapi-route',
85
90
  languages: new Set(['python']),
86
91
  type: 'http',
87
- framework: 'flask',
92
+ framework: 'fastapi',
88
93
  detection: 'decorator',
89
- pattern: /^(app|bp|blueprint)\.(route|get|post|put|delete|patch)/,
94
+ pattern: /^(app|router)\.(get|post|put|delete|patch|options|head)/,
90
95
  },
91
96
 
92
97
  // Django (Python) — decorators: @api_view, @action, @permission_classes
@@ -131,6 +136,25 @@ const FRAMEWORK_PATTERNS = [
131
136
  methodPattern: /^(HandleFunc|Handle)$/,
132
137
  },
133
138
 
139
+ // Actix (Rust) — the runtime/test macros are NOT routes (fix #243:
140
+ // #[actix_web::main] was typed http, so --type runtime missed main on
141
+ // actix apps and --type http listed it as a route).
142
+ {
143
+ id: 'actix-main',
144
+ languages: new Set(['rust']),
145
+ type: 'runtime',
146
+ framework: 'actix',
147
+ detection: 'modifier',
148
+ pattern: /^actix_web::main$/,
149
+ },
150
+ {
151
+ id: 'actix-test',
152
+ languages: new Set(['rust']),
153
+ type: 'test',
154
+ framework: 'actix',
155
+ detection: 'modifier',
156
+ pattern: /^actix_web::test$/,
157
+ },
134
158
  // Actix (Rust) — modifiers from #[get("/path")], #[post("/path")]
135
159
  {
136
160
  id: 'actix-route',
@@ -138,7 +162,7 @@ const FRAMEWORK_PATTERNS = [
138
162
  type: 'http',
139
163
  framework: 'actix',
140
164
  detection: 'modifier',
141
- pattern: /^(get|post|put|delete|patch|actix_web::main|actix_web::test)$/,
165
+ pattern: /^(get|post|put|delete|patch)$/,
142
166
  },
143
167
 
144
168
  // ── Dependency Injection ────────────────────────────────────────────
@@ -257,7 +281,8 @@ const FRAMEWORK_PATTERNS = [
257
281
 
258
282
  // ── Go Runtime Entry Points ─────────────────────────────────────────
259
283
 
260
- // Go main function (program entry)
284
+ // Go main function (program entry). symbolFilter: only FREE functions —
285
+ // a method named main on a receiver is an ordinary method (fix #243).
261
286
  {
262
287
  id: 'go-main',
263
288
  languages: new Set(['go']),
@@ -265,6 +290,7 @@ const FRAMEWORK_PATTERNS = [
265
290
  framework: 'go',
266
291
  detection: 'namePattern',
267
292
  pattern: /^main$/,
293
+ symbolFilter: (s) => !s.className && !s.receiver,
268
294
  },
269
295
 
270
296
  // Go init functions (package initialization, called by runtime)
@@ -275,6 +301,7 @@ const FRAMEWORK_PATTERNS = [
275
301
  framework: 'go',
276
302
  detection: 'namePattern',
277
303
  pattern: /^init$/,
304
+ symbolFilter: (s) => !s.className && !s.receiver,
278
305
  },
279
306
 
280
307
  // Go test functions (called by go test)
@@ -289,7 +316,8 @@ const FRAMEWORK_PATTERNS = [
289
316
 
290
317
  // ── Java entry points ─────────────────────────────────────────────
291
318
 
292
- // Java main(String[] args) — JVM entry point
319
+ // Java main(String[] args) — JVM entry point. Java main IS a method, but
320
+ // only a STATIC one is JVM-invocable (fix #243).
293
321
  {
294
322
  id: 'java-main',
295
323
  languages: new Set(['java']),
@@ -297,6 +325,7 @@ const FRAMEWORK_PATTERNS = [
297
325
  framework: 'java',
298
326
  detection: 'namePattern',
299
327
  pattern: /^main$/,
328
+ symbolFilter: (s) => (s.modifiers || []).includes('static'),
300
329
  },
301
330
 
302
331
  // JUnit @Test family — Java parser lowercases annotations into `modifiers`,
@@ -334,7 +363,8 @@ const FRAMEWORK_PATTERNS = [
334
363
 
335
364
  // ── Rust entry points ─────────────────────────────────────────────
336
365
 
337
- // Rust main() — fn main() is the binary entry point
366
+ // Rust main() — the FREE function fn main() is the binary entry point;
367
+ // an impl method named main is an ordinary method (fix #243)
338
368
  {
339
369
  id: 'rust-main',
340
370
  languages: new Set(['rust']),
@@ -342,6 +372,7 @@ const FRAMEWORK_PATTERNS = [
342
372
  framework: 'rust',
343
373
  detection: 'namePattern',
344
374
  pattern: /^main$/,
375
+ symbolFilter: (s) => !s.className && !s.receiver,
345
376
  },
346
377
 
347
378
  // Rust #[test] attribute — Rust parser stores attributes as `modifiers`,
@@ -522,6 +553,20 @@ function buildCallbackEntrypointMap(index) {
522
553
 
523
554
  const result = new Map(); // name -> info
524
555
 
556
+ // Attribute the entry point to the handler's DEFINITION, not the
557
+ // registration call site: `about handler --file X --line N` handles must
558
+ // resolve, and reachability seeding matches (absoluteFile, line) against
559
+ // symbol defs — a handler defined in a different file than its
560
+ // registration was never seeded. The registration site is kept as
561
+ // evidence (registrationFile/registrationLine).
562
+ const resolveHandlerDef = (name, registrationFile) => {
563
+ const defs = index.symbols.get(name);
564
+ if (!defs || defs.length === 0) return null;
565
+ // Prefer a def in the registration file; defs are canonical-sorted,
566
+ // so falling back to the first is deterministic.
567
+ return defs.find(d => d.file === registrationFile) || defs[0];
568
+ };
569
+
525
570
  for (const [filePath, fileEntry] of index.files) {
526
571
  const lang = fileEntry.language;
527
572
 
@@ -552,11 +597,19 @@ function buildCallbackEntrypointMap(index) {
552
597
  }
553
598
 
554
599
  if (routeLines.size > 0) {
600
+ // Pass 2b prep: routes whose handler is an inline NAMED
601
+ // function expression (`app.post('/x', function createUser()
602
+ // {...})`) have no function-reference record — the name is
603
+ // only visible as the enclosingFunction of the handler
604
+ // body's own calls (fix #247; the route vanished entirely).
605
+ const handledLines = new Set();
606
+
555
607
  // Pass 2: find callbacks on route-registration lines
556
608
  for (const call of calls) {
557
609
  if (!call.isFunctionReference && !call.isPotentialCallback) continue;
558
610
  const route = routeLines.get(call.line);
559
611
  if (!route) continue;
612
+ handledLines.add(call.line);
560
613
 
561
614
  // BUG M2: only treat as a handler if the name resolves to a
562
615
  // project-defined symbol. Library code like gin's
@@ -568,13 +621,40 @@ function buildCallbackEntrypointMap(index) {
568
621
  if (!index.symbols.has(call.name)) continue;
569
622
 
570
623
  if (!result.has(call.name)) {
624
+ const def = resolveHandlerDef(call.name, filePath);
571
625
  result.set(call.name, {
626
+ framework: route.pattern.framework,
627
+ type: route.pattern.type,
628
+ patternId: route.pattern.id,
629
+ method: route.call.name.toUpperCase(),
630
+ file: def ? def.file : filePath,
631
+ line: def ? def.startLine : call.line,
632
+ registrationFile: filePath,
633
+ registrationLine: call.line,
634
+ });
635
+ }
636
+ }
637
+
638
+ // Pass 2b: inline NAMED function-expression handlers. The
639
+ // function is defined IN argument position, so the only
640
+ // evidence is the enclosingFunction of calls inside its body,
641
+ // anchored at the registration line (fix #247).
642
+ for (const call of calls) {
643
+ const enc = call.enclosingFunction;
644
+ if (!enc || !enc.name || enc.name === '<anonymous>') continue;
645
+ const route = routeLines.get(enc.startLine);
646
+ if (!route || handledLines.has(enc.startLine)) continue;
647
+ if (index.symbols.has(enc.name)) continue; // real defs took pass 2
648
+ if (!result.has(enc.name)) {
649
+ result.set(enc.name, {
572
650
  framework: route.pattern.framework,
573
651
  type: route.pattern.type,
574
652
  patternId: route.pattern.id,
575
653
  method: route.call.name.toUpperCase(),
576
654
  file: filePath,
577
- line: call.line,
655
+ line: enc.startLine,
656
+ registrationFile: filePath,
657
+ registrationLine: enc.startLine,
578
658
  });
579
659
  }
580
660
  }
@@ -592,13 +672,16 @@ function buildCallbackEntrypointMap(index) {
592
672
  if (pattern.typePattern.test(call.compositeType) &&
593
673
  pattern.fieldPattern.test(call.fieldName)) {
594
674
  if (!result.has(call.name)) {
675
+ const def = resolveHandlerDef(call.name, filePath);
595
676
  result.set(call.name, {
596
677
  framework: pattern.framework,
597
678
  type: pattern.type,
598
679
  patternId: pattern.id,
599
680
  method: call.fieldName,
600
- file: filePath,
601
- line: call.line,
681
+ file: def ? def.file : filePath,
682
+ line: def ? def.startLine : call.line,
683
+ registrationFile: filePath,
684
+ registrationLine: call.line,
602
685
  });
603
686
  }
604
687
  break;
@@ -622,6 +705,32 @@ function buildCallbackEntrypointMap(index) {
622
705
  * @returns {Array<{ name, file, line, type, framework, patternId, evidence, confidence }>}
623
706
  */
624
707
  function detectEntrypoints(index, options = {}) {
708
+ // Validate --type against the pattern registry up front — an unknown
709
+ // value used to fall through to the filter and silently return nothing.
710
+ if (options.type) {
711
+ const validTypes = new Set(FRAMEWORK_PATTERNS.map(p => p.type));
712
+ if (!validTypes.has(options.type)) {
713
+ return {
714
+ error: 'invalid-type',
715
+ message: `Unknown type "${options.type}". Valid: ${[...validTypes].sort().join(', ')}.`,
716
+ };
717
+ }
718
+ }
719
+
720
+ // Same discipline for --framework (fix #243) — a typo like 'flsk'
721
+ // silently filtered everything to an empty result.
722
+ if (options.framework) {
723
+ const validFrameworks = new Set(FRAMEWORK_PATTERNS.map(p => p.framework.toLowerCase()));
724
+ const wanted = String(options.framework).split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
725
+ const unknown = wanted.filter(f => !validFrameworks.has(f));
726
+ if (unknown.length > 0) {
727
+ return {
728
+ error: 'invalid-framework',
729
+ message: `Unknown framework "${unknown.join('", "')}". Valid: ${[...validFrameworks].sort().join(', ')}.`,
730
+ };
731
+ }
732
+ }
733
+
625
734
  // Build callback entrypoint map (call-pattern detection)
626
735
  const callbackMap = buildCallbackEntrypointMap(index);
627
736
 
@@ -711,6 +820,9 @@ function detectEntrypoints(index, options = {}) {
711
820
  // Check name-based patterns (main, init, TestXxx, etc.)
712
821
  for (const np of namePatterns) {
713
822
  if (!np.languages.has(fileEntry.language)) continue;
823
+ // Per-pattern symbol predicate (fix #243) — e.g. main must be
824
+ // a free function (Rust/Go) or a static method (Java)
825
+ if (np.symbolFilter && !np.symbolFilter(symbol)) continue;
714
826
  if (np.pattern.test(name)) {
715
827
  const key = `${symbol.file}:${symbol.startLine}:${name}`;
716
828
  if (seen.has(key)) continue;
@@ -743,6 +855,17 @@ function detectEntrypoints(index, options = {}) {
743
855
  if (seen.has(key)) continue;
744
856
  seen.add(key);
745
857
 
858
+ let evidence = `${info.method} route handler`;
859
+ let registeredAt;
860
+ if (info.registrationFile) {
861
+ const regEntry = index.files.get(info.registrationFile);
862
+ const regRel = regEntry?.relativePath || info.registrationFile;
863
+ registeredAt = { file: regRel, line: info.registrationLine };
864
+ if (info.registrationFile !== info.file || info.registrationLine !== info.line) {
865
+ evidence += ` — registered at ${regRel}:${info.registrationLine}`;
866
+ }
867
+ }
868
+
746
869
  results.push({
747
870
  name,
748
871
  file: relPath,
@@ -751,7 +874,8 @@ function detectEntrypoints(index, options = {}) {
751
874
  type: info.type,
752
875
  framework: info.framework,
753
876
  patternId: info.patternId,
754
- evidence: [`${info.method} route handler`],
877
+ evidence: [evidence],
878
+ ...(registeredAt && { registeredAt }),
755
879
  confidence: 0.90,
756
880
  });
757
881
  }
@@ -860,6 +984,14 @@ function detectEntrypoints(index, options = {}) {
860
984
  const fmatches = fileMatches.get(symbol.file);
861
985
  if (!fmatches || fmatches.length === 0) continue;
862
986
 
987
+ // Entry points are executable: enum members, interface
988
+ // property signatures, type aliases, and fields in a matched
989
+ // file are declarations, not runtime entries (fix #247 — a
990
+ // spec file listed its enum members as test entry points).
991
+ // Callable members carry their own symbols, so reachability
992
+ // seeding loses nothing.
993
+ if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
994
+
863
995
  // Apply narrow filter: for shebang/cli-main files, only allow
864
996
  // identified entry symbols.
865
997
  if (narrowAllowedByFile.has(symbol.file)) {
@@ -916,7 +1048,7 @@ function detectEntrypoints(index, options = {}) {
916
1048
 
917
1049
  // Sort by file, then line
918
1050
  filtered.sort((a, b) => {
919
- if (a.file !== b.file) return a.file.localeCompare(b.file);
1051
+ if (a.file !== b.file) return codeUnitCompare(a.file, b.file);
920
1052
  return a.line - b.line;
921
1053
  });
922
1054