ucn 3.8.23 → 3.8.26

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 +127 -12
  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 +1095 -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 -52
  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
@@ -12,7 +12,10 @@
12
12
 
13
13
  'use strict';
14
14
 
15
+ const fs = require('fs');
16
+ const path = require('path');
15
17
  const { getCachedCalls } = require('./callers');
18
+ const { getLanguageModule } = require('../languages');
16
19
 
17
20
  // ============================================================================
18
21
  // FRAMEWORK PATTERNS
@@ -23,14 +26,36 @@ const JS_LANGS = new Set(['javascript', 'typescript', 'tsx']);
23
26
  const FRAMEWORK_PATTERNS = [
24
27
  // ── HTTP Routes ─────────────────────────────────────────────────────
25
28
 
26
- // Express / Fastify / Koa (JS/TS) — call-pattern: app.get('/path', handler)
29
+ // Express — call-pattern: app.get('/path', handler), router.get(...), etc.
27
30
  {
28
31
  id: 'express-route',
29
32
  languages: JS_LANGS,
30
33
  type: 'http',
31
34
  framework: 'express',
32
35
  detection: 'callPattern',
33
- receiverPattern: /^(app|router|server|fastify)$/i,
36
+ receiverPattern: /^(app|router|server)$/i,
37
+ methodPattern: /^(get|post|put|delete|patch|all|use|options|head)$/,
38
+ },
39
+
40
+ // Fastify — call-pattern: fastify.get('/path', handler)
41
+ {
42
+ id: 'fastify-route',
43
+ languages: JS_LANGS,
44
+ type: 'http',
45
+ framework: 'fastify',
46
+ detection: 'callPattern',
47
+ receiverPattern: /^fastify$/i,
48
+ methodPattern: /^(get|post|put|delete|patch|all|use|options|head|route)$/,
49
+ },
50
+
51
+ // Koa-router — call-pattern: koaRouter.get('/path', handler)
52
+ {
53
+ id: 'koa-route',
54
+ languages: JS_LANGS,
55
+ type: 'http',
56
+ framework: 'koa',
57
+ detection: 'callPattern',
58
+ receiverPattern: /^(koaRouter|koa)$/i,
34
59
  methodPattern: /^(get|post|put|delete|patch|all|use|options|head)$/,
35
60
  },
36
61
 
@@ -125,7 +150,63 @@ const FRAMEWORK_PATTERNS = [
125
150
  type: 'di',
126
151
  framework: 'spring',
127
152
  detection: 'modifier',
128
- pattern: /^(bean|component|service|controller|repository|configuration|restcontroller)$/,
153
+ // JAVA-4: Spring DI / IoC core annotations
154
+ pattern: /^(bean|component|service|controller|repository|configuration|restcontroller|restcontrolleradvice|controlleradvice|springbootapplication|springbootconfiguration|enableautoconfiguration|componentscan|conditional(on\w+)?|profile|primary|qualifier|autowired|inject|value|scope|lazy|order|dependson|import|importresource|propertysource)$/,
155
+ },
156
+
157
+ // JAVA-4: Spring MVC binding/validation annotations on handler-method
158
+ // parameters/methods. Treated as entry points because Spring's
159
+ // DispatcherServlet calls these methods reflectively.
160
+ {
161
+ id: 'spring-mvc-method',
162
+ languages: new Set(['java']),
163
+ type: 'http',
164
+ framework: 'spring-mvc',
165
+ detection: 'modifier',
166
+ pattern: /^(initbinder|modelattribute|exceptionhandler|sessionattributes|requestbody|responsebody|responsestatus|crossorigin|pathvariable|requestparam|requestheader|requestattribute|cookievalue|matrixvariable|validated|valid|validator)$/,
167
+ },
168
+
169
+ // JAVA-4: JPA / Hibernate persistence annotations. The persistence
170
+ // provider instantiates and reads these via reflection.
171
+ {
172
+ id: 'jpa-entity',
173
+ languages: new Set(['java']),
174
+ type: 'di',
175
+ framework: 'jpa',
176
+ detection: 'modifier',
177
+ pattern: /^(entity|mappedsuperclass|embeddable|embedded|table|secondarytable|column|id|generatedvalue|sequencegenerator|tablegenerator|version|enumerated|temporal|lob|basic|transient|access|onetomany|manytoone|onetoone|manytomany|joincolumn|joincolumns|jointable|orderby|orderColumn|inheritance|discriminatorcolumn|discriminatorvalue|namedquery|namedqueries|namednativequery|sqlresultsetmapping|fieldresultsetmapping|attributeoverride|attributeoverrides|associationoverride|cacheable|maptkey|mapkeyenumerated|mapkeycolumn|maptemporal|elementcollection|collectiontable|converter|convert|cascade)$/,
178
+ },
179
+
180
+ // JAVA-4: JPA / Spring Data query annotation.
181
+ {
182
+ id: 'spring-data-query',
183
+ languages: new Set(['java']),
184
+ type: 'di',
185
+ framework: 'spring-data',
186
+ detection: 'modifier',
187
+ pattern: /^(query|modifying|procedure|namedquery|param|lock|querytype|entitygraph|projection)$/,
188
+ },
189
+
190
+ // JAVA-4: Transactional / caching / async / scheduling cross-cutting
191
+ // annotations (Spring AOP / Spring tx).
192
+ {
193
+ id: 'spring-tx',
194
+ languages: new Set(['java']),
195
+ type: 'di',
196
+ framework: 'spring',
197
+ detection: 'modifier',
198
+ pattern: /^(transactional|cacheable|cacheevict|cacheput|caching|enabletransactionmanagement|enablecaching|enableasync|enablescheduling|enableaspectjautoproxy)$/,
199
+ },
200
+
201
+ // JAVA-4: JAX-RS / JAX-B / XML binding annotations. Frameworks
202
+ // (Jersey, JAXB, etc.) instantiate and serialize via reflection.
203
+ {
204
+ id: 'jax-binding',
205
+ languages: new Set(['java']),
206
+ type: 'http',
207
+ framework: 'jax-rs',
208
+ detection: 'modifier',
209
+ pattern: /^(path|produces|consumes|provider|webservice|webmethod|webparam|webresult|xmlrootelement|xmlelement|xmlattribute|xmlaccessortype|xmltype|xmltransient|xmlid|xmlidref|xmlschematype|xmlseealso|xmlanyelement|xmlanyattribute)$/,
129
210
  },
130
211
 
131
212
  // ── Job Schedulers ──────────────────────────────────────────────────
@@ -206,6 +287,75 @@ const FRAMEWORK_PATTERNS = [
206
287
  pattern: /^(Test|Benchmark|Example|Fuzz)[A-Z_]/,
207
288
  },
208
289
 
290
+ // ── Java entry points ─────────────────────────────────────────────
291
+
292
+ // Java main(String[] args) — JVM entry point
293
+ {
294
+ id: 'java-main',
295
+ languages: new Set(['java']),
296
+ type: 'runtime',
297
+ framework: 'java',
298
+ detection: 'namePattern',
299
+ pattern: /^main$/,
300
+ },
301
+
302
+ // JUnit @Test family — Java parser lowercases annotations into `modifiers`,
303
+ // not `decorators`, so detection must run against modifiers.
304
+ // JAVA-4: also include lifecycle (BeforeEach/AfterEach/etc.), nested test
305
+ // classes, extension wiring, and SpringBoot/MVC/Data test slices.
306
+ {
307
+ id: 'java-junit-test',
308
+ languages: new Set(['java']),
309
+ type: 'test',
310
+ framework: 'junit',
311
+ detection: 'modifier',
312
+ pattern: /^(test|parameterizedtest|repeatedtest|testfactory|testtemplate|beforeall|beforeeach|afterall|aftereach|nested|disabled|enabled|enabledon\w*|disabledon\w*|tag|displayname|extendwith|registerextension|testmethodorder|testinstance|timeout|csvsource|valuesource|methodsource|enumsource|argumentssource|csvfilesource)$/,
313
+ },
314
+
315
+ // JAVA-4: Spring Boot test slices and integration test annotations.
316
+ {
317
+ id: 'spring-boot-test',
318
+ languages: new Set(['java']),
319
+ type: 'test',
320
+ framework: 'spring-boot-test',
321
+ detection: 'modifier',
322
+ pattern: /^(springboottest|webmvctest|datajpatest|datamongotest|dataredistest|datacassandratest|jsontest|jdbctest|jooqtest|webfluxtest|restclienttest|graphqltest|autoconfiguremockmvc|autoconfiguredatajpa|autoconfigurewebmvc|mockbean|spybean|mockitobean|spymockitobean|sqlgroup|sql|testpropertysource|activeprofiles|dirtiescontext|recordapplicationevents|contextconfiguration|webappconfiguration|importautoconfiguration|bootstrapwith|testexecutionlisteners|transactionalconfiguration|repeatedtests)$/,
323
+ },
324
+
325
+ // Spring HTTP route annotations — same lowercase-modifier rule
326
+ {
327
+ id: 'spring-http-mapping',
328
+ languages: new Set(['java']),
329
+ type: 'http',
330
+ framework: 'spring',
331
+ detection: 'modifier',
332
+ pattern: /^(getmapping|postmapping|putmapping|deletemapping|patchmapping|requestmapping)$/,
333
+ },
334
+
335
+ // ── Rust entry points ─────────────────────────────────────────────
336
+
337
+ // Rust main() — fn main() is the binary entry point
338
+ {
339
+ id: 'rust-main',
340
+ languages: new Set(['rust']),
341
+ type: 'runtime',
342
+ framework: 'rust',
343
+ detection: 'namePattern',
344
+ pattern: /^main$/,
345
+ },
346
+
347
+ // Rust #[test] attribute — Rust parser stores attributes as `modifiers`,
348
+ // not `decorators`, so detection has to run against modifiers.
349
+ // (The older tokio-main pattern at line 169 already uses 'modifier' correctly.)
350
+ {
351
+ id: 'rust-test-attr',
352
+ languages: new Set(['rust']),
353
+ type: 'test',
354
+ framework: 'rust',
355
+ detection: 'modifier',
356
+ pattern: /^(test|tokio::test|cfg\(test\))$/,
357
+ },
358
+
209
359
  // ── Go Framework Patterns ─────────────────────────────────────────
210
360
 
211
361
  // Cobra CLI framework — RunE, Run, PreRunE etc. assigned to cobra.Command struct fields
@@ -223,6 +373,72 @@ const FRAMEWORK_PATTERNS = [
223
373
  // Go goroutine launch — go func() or go handler()
224
374
  // (detected separately in namePattern since it's a language feature)
225
375
 
376
+ // ── JS/TS Runtime Entry Points ────────────────────────────────────
377
+
378
+ // Node CLI / app main: symbols defined in conventionally-named entry files.
379
+ // - bin/* (npm "bin" entries)
380
+ // - index.{js,ts,mjs,cjs} (package main convention)
381
+ // - main.{js,ts,mjs,cjs} (electron main, etc.)
382
+ // - cli.{js,ts,mjs,cjs}
383
+ // - server.{js,ts,mjs,cjs}
384
+ {
385
+ id: 'js-cli-main',
386
+ languages: JS_LANGS,
387
+ type: 'runtime',
388
+ framework: 'node',
389
+ detection: 'filePath',
390
+ // Match files under bin/ (any depth), or top-level index/main/cli/server in any directory.
391
+ // The matcher is run against the project-relative path with forward slashes.
392
+ pathPattern: /(^|\/)bin\/[^/]+\.(js|ts|mjs|cjs)$|(^|\/)(index|main|cli|server)\.(js|ts|mjs|cjs)$/,
393
+ },
394
+
395
+ // Node shebang entry: any file whose first bytes are `#!/usr/bin/env node`
396
+ // or `#!/path/to/node`. These are runnable scripts, not libraries.
397
+ {
398
+ id: 'js-shebang-main',
399
+ languages: JS_LANGS,
400
+ type: 'runtime',
401
+ framework: 'node',
402
+ detection: 'shebang',
403
+ shebangPattern: /^#![^\n]*\bnode\b/,
404
+ },
405
+
406
+ // Jest/Mocha/Vitest test files: any function defined in a *.test.* /
407
+ // *.spec.* file or under __tests__/, test/, tests/.
408
+ {
409
+ id: 'js-test-file',
410
+ languages: JS_LANGS,
411
+ type: 'test',
412
+ framework: 'jest',
413
+ detection: 'filePath',
414
+ pathPattern: /(^|\/)(__tests__|tests?)\/|\.(test|spec)\.(js|ts|jsx|tsx|mjs|cjs)$/,
415
+ },
416
+
417
+ // Next.js pages/routes: default-exported functions from files under
418
+ // pages/ or app/ are runtime entry points (rendered/served by Next.js).
419
+ {
420
+ id: 'next-page',
421
+ languages: JS_LANGS,
422
+ type: 'runtime',
423
+ framework: 'next',
424
+ detection: 'filePath',
425
+ pathPattern: /(^|\/)(pages|app)\/.*\.(js|ts|jsx|tsx|mjs|cjs)$/,
426
+ },
427
+
428
+ // ── Python Runtime Entry Points ────────────────────────────────────
429
+
430
+ // __main__.py — package executable entry (python -m pkg).
431
+ // The `if __name__ == '__main__':` guard wraps statements not functions,
432
+ // so we treat any function in __main__.py as runtime-reachable.
433
+ {
434
+ id: 'python-main-module',
435
+ languages: new Set(['python']),
436
+ type: 'runtime',
437
+ framework: 'python',
438
+ detection: 'filePath',
439
+ pathPattern: /(^|\/)__main__\.py$/,
440
+ },
441
+
226
442
  // ── Catch-all fallbacks ─────────────────────────────────────────────
227
443
 
228
444
  // Python: any decorator with '.' (attribute access) — framework registration heuristic
@@ -284,6 +500,18 @@ function matchDecoratorOrModifier(symbol, language) {
284
500
  /**
285
501
  * Build a map of symbol names used as callbacks in framework route-registration calls.
286
502
  * Scans the calls cache for call-pattern-based framework detection.
503
+ *
504
+ * BUG M2: only treat a call as a route registration when its first argument is a
505
+ * literal string path. Library implementations such as gin's
506
+ * `group.GET(relativePath, handler)` (where `relativePath` is a parameter, not a
507
+ * literal) would otherwise capture local variable names (`relativePath`,
508
+ * `handler`, `urlPattern`) as if they were handler functions. The string-literal
509
+ * check aligns this with bridge.js's `extractServerRoutes` so the route count in
510
+ * `entrypoints` matches the route count in `endpoints`.
511
+ *
512
+ * For HTTP route patterns we additionally apply Express's dual-purpose API check
513
+ * (1-arg `app.get('env')` is a config getter, not a route registration).
514
+ *
287
515
  * @param {object} index - ProjectIndex
288
516
  * @returns {Map<string, { framework, type, patternId, method, file, line }>}
289
517
  */
@@ -310,6 +538,13 @@ function buildCallbackEntrypointMap(index) {
310
538
  for (const pattern of relevantCallPatterns) {
311
539
  if (pattern.receiverPattern.test(call.receiver) &&
312
540
  pattern.methodPattern.test(call.name)) {
541
+ // BUG M2 (interpolated paths): align with bridge.js's
542
+ // extractServerRoutes — skip routes whose path is interpolated.
543
+ if (pattern.type === 'http' && call.firstStringArg && call.firstStringArgInterp) continue;
544
+ // BUG M5: Express dual-purpose APIs — 1-arg .get('env') is a
545
+ // config getter, not a route registration.
546
+ if (pattern.framework === 'express' &&
547
+ typeof call.argCount === 'number' && call.argCount < 2) continue;
313
548
  routeLines.set(call.line, { pattern, call });
314
549
  break;
315
550
  }
@@ -323,6 +558,15 @@ function buildCallbackEntrypointMap(index) {
323
558
  const route = routeLines.get(call.line);
324
559
  if (!route) continue;
325
560
 
561
+ // BUG M2: only treat as a handler if the name resolves to a
562
+ // project-defined symbol. Library code like gin's
563
+ // `group.GET(relativePath, handler)`
564
+ // (routergroup.go:185) has identifiers that are local parameters,
565
+ // not exported handler functions — they must not be marked as
566
+ // entry points. This aligns the HTTP Routes section with
567
+ // bridge.js's extractServerRoutes.
568
+ if (!index.symbols.has(call.name)) continue;
569
+
326
570
  if (!result.has(call.name)) {
327
571
  result.set(call.name, {
328
572
  framework: route.pattern.framework,
@@ -386,6 +630,56 @@ function detectEntrypoints(index, options = {}) {
386
630
 
387
631
  // Collect name-based patterns for efficient matching
388
632
  const namePatterns = FRAMEWORK_PATTERNS.filter(p => p.detection === 'namePattern');
633
+ const filePathPatterns = FRAMEWORK_PATTERNS.filter(p => p.detection === 'filePath');
634
+ const shebangPatterns = FRAMEWORK_PATTERNS.filter(p => p.detection === 'shebang');
635
+
636
+ // 0. Pre-compute per-file pattern matches for filePath and shebang detection.
637
+ // These mark every symbol in a file as an entry point.
638
+ const fileMatches = new Map(); // absolutePath -> { pattern, evidence }[]
639
+ for (const [filePath, fileEntry] of index.files) {
640
+ const lang = fileEntry.language;
641
+ const relPath = (fileEntry.relativePath || filePath).split(path.sep).join('/');
642
+
643
+ // filePath patterns match against the project-relative path
644
+ for (const fp of filePathPatterns) {
645
+ if (!fp.languages.has(lang)) continue;
646
+ if (!fp.pathPattern.test(relPath)) continue;
647
+ if (!fileMatches.has(filePath)) fileMatches.set(filePath, []);
648
+ fileMatches.get(filePath).push({
649
+ pattern: fp,
650
+ evidence: `entry-file: ${relPath}`,
651
+ });
652
+ }
653
+
654
+ // shebang patterns: read the first ~128 bytes safely.
655
+ if (shebangPatterns.length > 0) {
656
+ const relevant = shebangPatterns.filter(p => p.languages.has(lang));
657
+ if (relevant.length > 0) {
658
+ let head = '';
659
+ try {
660
+ const fd = fs.openSync(filePath, 'r');
661
+ try {
662
+ const buf = Buffer.alloc(128);
663
+ const n = fs.readSync(fd, buf, 0, 128, 0);
664
+ head = buf.slice(0, n).toString('utf8');
665
+ } finally {
666
+ fs.closeSync(fd);
667
+ }
668
+ } catch (_e) { /* unreadable — skip */ }
669
+ if (head) {
670
+ for (const sp of relevant) {
671
+ if (sp.shebangPattern.test(head)) {
672
+ if (!fileMatches.has(filePath)) fileMatches.set(filePath, []);
673
+ fileMatches.get(filePath).push({
674
+ pattern: sp,
675
+ evidence: 'shebang #!node',
676
+ });
677
+ }
678
+ }
679
+ }
680
+ }
681
+ }
682
+ }
389
683
 
390
684
  // 1. Scan all symbols for decorator/modifier/name-based patterns
391
685
  for (const [name, symbols] of index.symbols) {
@@ -439,7 +733,9 @@ function detectEntrypoints(index, options = {}) {
439
733
  }
440
734
  }
441
735
 
442
- // 2. Add call-pattern-based entry points (route handlers)
736
+ // 2. Add call-pattern-based entry points (route handlers).
737
+ // Run BEFORE file-level patterns so framework labels (express, gin, etc.) win
738
+ // over generic file-level labels (e.g. server.js / index.js js-cli-main).
443
739
  for (const [name, info] of callbackMap) {
444
740
  const fileEntry = index.files.get(info.file);
445
741
  const relPath = fileEntry?.relativePath || info.file;
@@ -460,6 +756,139 @@ function detectEntrypoints(index, options = {}) {
460
756
  });
461
757
  }
462
758
 
759
+ // 3. Add file-level entry points (filePath / shebang).
760
+ //
761
+ // BUG M6: previously this marked EVERY top-level symbol in a matched file
762
+ // as an entry point — way too broad for shebang/CLI files (where only the
763
+ // main entry function is the real runtime entry; helpers are just helpers).
764
+ //
765
+ // Tightened rule for `js-cli-main` and `js-shebang-main` patterns:
766
+ // Only mark as entry points:
767
+ // - Function whose name is `main` (case-sensitive — Node CLI idiom)
768
+ // - The default export of the file (if present)
769
+ // - Any function targeted by a top-level invocation (e.g. file calls
770
+ // `main()` at module scope → main is the entry)
771
+ // - Any function that contains a top-level `if (require.main === module)
772
+ // { ... }` block (Node CLI idiom)
773
+ //
774
+ // For all other file-level patterns (js-test-file, next-page, python-main-module),
775
+ // continue using the broad behavior of marking every symbol as an entry.
776
+ //
777
+ // These run last so any more-specific framework label registered above wins;
778
+ // here we only catch symbols not already seen. Dedup by (file, name) — not
779
+ // (file, line, name) — so a generic file-level entry doesn't add a duplicate
780
+ // entry alongside a specific framework callback registered at a different line.
781
+ const NARROW_FILE_PATTERNS = new Set(['js-cli-main', 'js-shebang-main']);
782
+
783
+ // For each "narrow" matched file, compute the allowed entry symbol names.
784
+ // Falls back to permissive when nothing identifies a specific entry, to avoid
785
+ // hiding the entire file from reachability seeding.
786
+ const narrowAllowedByFile = new Map(); // absoluteFile -> Set<name> | null (null = allow all)
787
+ if (fileMatches.size > 0) {
788
+ for (const [filePath, fmatches] of fileMatches) {
789
+ const isNarrow = fmatches.every(fm => NARROW_FILE_PATTERNS.has(fm.pattern.id));
790
+ if (!isNarrow) continue;
791
+
792
+ const fileEntry = index.files.get(filePath);
793
+ if (!fileEntry) continue;
794
+
795
+ const allowed = new Set();
796
+
797
+ // (1) Function literally named 'main' is conventional for Node CLI idiom.
798
+ if (index.symbols.has('main')) {
799
+ for (const sym of index.symbols.get('main')) {
800
+ if (sym.file === filePath) allowed.add('main');
801
+ }
802
+ }
803
+
804
+ // (2) Default export of the file, if any. Across language exporters
805
+ // we look at several shapes:
806
+ // - `module.exports = X` → type 'module.exports', name = X
807
+ // - `export default X` → isDefault / kind === 'default'
808
+ // - Python __all__ single entry → captured per-language
809
+ const exportDetails = fileEntry.exportDetails || [];
810
+ for (const e of exportDetails) {
811
+ if (!e) continue;
812
+ if (e.isDefault === true || e.kind === 'default' || e.name === 'default' ||
813
+ e.type === 'module.exports' || e.type === 'export-default') {
814
+ if (e.localName) allowed.add(e.localName);
815
+ else if (e.name && e.name !== 'default') allowed.add(e.name);
816
+ }
817
+ }
818
+
819
+ // (3) Top-level invocation targets: any non-method call with no
820
+ // enclosingFunction is module-load-time, so its callee is reachable.
821
+ // We treat the callee as an entry point (the function being kicked off).
822
+ try {
823
+ const calls = getCachedCalls(index, filePath);
824
+ if (calls && calls.length > 0) {
825
+ for (const c of calls) {
826
+ if (c.enclosingFunction != null) continue;
827
+ if (c.isMethod) continue;
828
+ // Resolve callee names (handles aliased imports)
829
+ const names = c.resolvedNames || (c.resolvedName ? [c.resolvedName] : [c.name]);
830
+ for (const n of names) {
831
+ if (index.symbols.has(n)) allowed.add(n);
832
+ }
833
+ }
834
+
835
+ // (4) Function containing a top-level `if (require.main === module) { ... }`
836
+ // wrapping calls — captured by treating any non-method call whose
837
+ // enclosingFunction body is at top level. The simplest detection:
838
+ // scan calls inside any function whose body contains the require.main
839
+ // guard. We approximate via the same `enclosingFunction` data: if a
840
+ // function contains top-level invocation-style entries, it's typically
841
+ // identified by the (3) check above. Skip explicit (4) detection here —
842
+ // (3) already covers `if (require.main === module) { main(); }`.
843
+ }
844
+ } catch (_e) { /* best-effort */ }
845
+
846
+ // If we identified at least one specific entry, use that set.
847
+ // Otherwise fall back to permissive (null) so a CLI file with neither
848
+ // `main()` nor a clear default-export is still seeded somehow.
849
+ narrowAllowedByFile.set(filePath, allowed.size > 0 ? allowed : null);
850
+ }
851
+ }
852
+
853
+ if (fileMatches.size > 0) {
854
+ const seenByFileName = new Set();
855
+ for (const r of results) {
856
+ seenByFileName.add(`${r.absoluteFile}:${r.name}`);
857
+ }
858
+ for (const [name, symbols] of index.symbols) {
859
+ for (const symbol of symbols) {
860
+ const fmatches = fileMatches.get(symbol.file);
861
+ if (!fmatches || fmatches.length === 0) continue;
862
+
863
+ // Apply narrow filter: for shebang/cli-main files, only allow
864
+ // identified entry symbols.
865
+ if (narrowAllowedByFile.has(symbol.file)) {
866
+ const allowed = narrowAllowedByFile.get(symbol.file);
867
+ if (allowed && !allowed.has(name)) continue;
868
+ }
869
+
870
+ const fileNameKey = `${symbol.file}:${name}`;
871
+ if (seenByFileName.has(fileNameKey)) continue;
872
+ const key = `${symbol.file}:${symbol.startLine}:${name}`;
873
+ if (seen.has(key)) continue;
874
+ seen.add(key);
875
+ seenByFileName.add(fileNameKey);
876
+ const first = fmatches[0];
877
+ results.push({
878
+ name,
879
+ file: symbol.relativePath || symbol.file,
880
+ absoluteFile: symbol.file,
881
+ line: symbol.startLine,
882
+ type: first.pattern.type,
883
+ framework: first.pattern.framework,
884
+ patternId: first.pattern.id,
885
+ evidence: [first.evidence],
886
+ confidence: 0.85,
887
+ });
888
+ }
889
+ }
890
+ }
891
+
463
892
  // Apply filters
464
893
  let filtered = results;
465
894
 
@@ -494,6 +923,208 @@ function detectEntrypoints(index, options = {}) {
494
923
  return filtered;
495
924
  }
496
925
 
926
+ // ============================================================================
927
+ // REACHABILITY
928
+ // ============================================================================
929
+
930
+ /**
931
+ * Build a stable key for a symbol-like object based on its file path and start line.
932
+ * Two functions cannot start at the same line in the same file, so this is unique.
933
+ *
934
+ * @param {string} file - Absolute file path
935
+ * @param {number} line - Start line of the symbol
936
+ * @returns {string} Symbol key (e.g. "/abs/path/file.js:42")
937
+ */
938
+ function symbolKey(file, line) {
939
+ return `${file}:${line}`;
940
+ }
941
+
942
+ /**
943
+ * Compute the set of symbols transitively reachable from any detected entry point.
944
+ *
945
+ * Performs BFS through the call graph starting from every entry point (framework
946
+ * handlers, main/init, test functions, etc.) and following findCallees recursively.
947
+ *
948
+ * Result is cached on the index instance as `index._reachableSymbols` to avoid
949
+ * recomputation. Subsequent calls return the cached Set.
950
+ *
951
+ * @param {object} index - ProjectIndex instance
952
+ * @returns {Set<string>} Set of symbol keys (file:startLine) reachable from entry points
953
+ */
954
+ function computeReachability(index) {
955
+ // PERF-1: when _reachableSymbols was loaded from the disk cache, verify
956
+ // the index hasn't drifted (e.g. because the cache was stale and a partial
957
+ // rebuild ran after load). If the fingerprint doesn't match, drop the
958
+ // cached set and recompute.
959
+ if (index._reachableSymbols) {
960
+ if (index._reachableFingerprint) {
961
+ const { _computeReachabilityFingerprint } = require('./cache');
962
+ const currentFingerprint = _computeReachabilityFingerprint(index);
963
+ if (currentFingerprint === index._reachableFingerprint) {
964
+ return index._reachableSymbols;
965
+ }
966
+ // Drift: drop stale set, recompute below.
967
+ index._reachableSymbols = null;
968
+ index._reachableFingerprint = null;
969
+ } else {
970
+ // Computed in-process this run (no fingerprint) — already trustworthy.
971
+ return index._reachableSymbols;
972
+ }
973
+ }
974
+
975
+ const reachable = new Set();
976
+ const entryPoints = detectEntrypoints(index);
977
+
978
+ // Seed BFS queue from every entry point's matching symbol(s) in the symbol table.
979
+ // detectEntrypoints returns entry-point hits with absoluteFile + line + name; we resolve
980
+ // each to a real symbol object by matching name and (absoluteFile, line).
981
+ const queue = [];
982
+ for (const ep of entryPoints) {
983
+ const symbols = index.symbols.get(ep.name);
984
+ if (!symbols) continue;
985
+ // Match by absoluteFile + line (entry-point line should match symbol startLine).
986
+ // Fall back to file-only match if line shifted (e.g. file edited after detection).
987
+ const match = symbols.find(s =>
988
+ s.file === ep.absoluteFile && s.startLine === ep.line
989
+ ) || symbols.find(s => s.file === ep.absoluteFile);
990
+ if (match) {
991
+ const key = symbolKey(match.file, match.startLine);
992
+ if (!reachable.has(key)) {
993
+ reachable.add(key);
994
+ queue.push(match);
995
+ }
996
+ }
997
+ }
998
+
999
+ // BUG-BE root cause 1: also seed from per-language getEntryPointKind() predicates.
1000
+ // detectEntrypoints() above only knows about FRAMEWORK_PATTERNS; it does not consult
1001
+ // each language module's getEntryPointKind() (which classifies React lifecycle methods,
1002
+ // @Test annotations, Rust #[cfg(test)] modules, Go Test*/main, etc.). Without this
1003
+ // pass those entries are never seeded, so anything reachable only via them is reported
1004
+ // as unreachable. The reachable Set already dedupes against the framework-pattern pass.
1005
+ const langModuleCache = new Map();
1006
+ for (const [, symbols] of index.symbols) {
1007
+ for (const symbol of symbols) {
1008
+ const fileEntry = index.files.get(symbol.file);
1009
+ if (!fileEntry) continue;
1010
+ const lang = fileEntry.language;
1011
+ let langModule;
1012
+ if (langModuleCache.has(lang)) {
1013
+ langModule = langModuleCache.get(lang);
1014
+ } else {
1015
+ try {
1016
+ langModule = getLanguageModule(lang);
1017
+ } catch (_e) {
1018
+ langModule = null;
1019
+ }
1020
+ langModuleCache.set(lang, langModule);
1021
+ }
1022
+ if (!langModule || !langModule.getEntryPointKind) continue;
1023
+ let kind;
1024
+ try {
1025
+ kind = langModule.getEntryPointKind(symbol);
1026
+ } catch (_e) {
1027
+ continue;
1028
+ }
1029
+ if (kind == null) continue;
1030
+ const key = symbolKey(symbol.file, symbol.startLine);
1031
+ if (!reachable.has(key)) {
1032
+ reachable.add(key);
1033
+ queue.push(symbol);
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ // BUG-BE root cause 3: top-level executable code in JS/TS files is a reachability
1039
+ // source. A call expression at module scope (no enclosing function) is invoked
1040
+ // when the module is loaded; treat its callee as reachable. Walk getCachedCalls
1041
+ // (already AST-derived) and seed callees of enclosingFunction === null calls
1042
+ // for files in JS/TS-language. Resolution mirrors findCallees: name lookup +
1043
+ // disambiguation by import bindings is not necessary here — we just need to seed
1044
+ // the callee symbol(s); the BFS below propagates further reachability.
1045
+ for (const [filePath, fileEntry] of index.files) {
1046
+ const lang = fileEntry.language;
1047
+ if (lang !== 'javascript' && lang !== 'typescript' && lang !== 'tsx') continue;
1048
+ let calls;
1049
+ try {
1050
+ calls = getCachedCalls(index, filePath);
1051
+ } catch (_e) {
1052
+ continue;
1053
+ }
1054
+ if (!calls || calls.length === 0) continue;
1055
+ for (const call of calls) {
1056
+ // Top-level call: AST recorded enclosingFunction === null/undefined.
1057
+ if (call.enclosingFunction != null) continue;
1058
+ // Method calls at top-level (e.g. a.b()) are typically library calls;
1059
+ // we keep the simple identifier case where the callee name is a project symbol.
1060
+ if (call.isMethod) continue;
1061
+ // Resolve possible callee names — supports aliased imports.
1062
+ const names = call.resolvedNames || (call.resolvedName ? [call.resolvedName] : [call.name]);
1063
+ for (const cname of names) {
1064
+ const symbols = index.symbols.get(cname);
1065
+ if (!symbols) continue;
1066
+ for (const sym of symbols) {
1067
+ const key = symbolKey(sym.file, sym.startLine);
1068
+ if (!reachable.has(key)) {
1069
+ reachable.add(key);
1070
+ queue.push(sym);
1071
+ }
1072
+ }
1073
+ }
1074
+ }
1075
+ }
1076
+
1077
+ // BFS: walk callees of every reachable symbol.
1078
+ // findCallees returns full symbol objects for every callee with file/startLine.
1079
+ while (queue.length > 0) {
1080
+ const sym = queue.shift();
1081
+ if (!sym.file || sym.startLine == null) continue;
1082
+
1083
+ let callees;
1084
+ try {
1085
+ callees = index.findCallees(sym, { includeMethods: true });
1086
+ } catch (_e) {
1087
+ continue;
1088
+ }
1089
+ if (!callees || callees.length === 0) continue;
1090
+
1091
+ for (const c of callees) {
1092
+ if (!c.file || c.startLine == null) continue;
1093
+ const key = symbolKey(c.file, c.startLine);
1094
+ if (!reachable.has(key)) {
1095
+ reachable.add(key);
1096
+ queue.push(c);
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ index._reachableSymbols = reachable;
1102
+ // Clear any stale fingerprint — this set was computed in-process and is
1103
+ // authoritative for the rest of the process lifetime. (saveCache will
1104
+ // re-fingerprint when persisting.)
1105
+ index._reachableFingerprint = null;
1106
+ // MED-1 (Round 5): mark the set dirty so the surface knows to persist it.
1107
+ // Without this flag, a cache-hit run that triggers reachability (about,
1108
+ // context, deadcode, etc.) would compute the BFS in-memory but not save
1109
+ // it, forcing every subsequent cold invocation to repeat the 7-11s tax.
1110
+ // Cleared in saveCache after a successful write.
1111
+ index.reachabilityDirty = true;
1112
+ return reachable;
1113
+ }
1114
+
1115
+ /**
1116
+ * Check if a symbol (identified by file + startLine) is reachable from any entry point.
1117
+ * Lazily computes the reachable set on first call.
1118
+ *
1119
+ * @param {object} index - ProjectIndex instance
1120
+ * @param {string} symbolKeyStr - Key of form "file:startLine"
1121
+ * @returns {boolean}
1122
+ */
1123
+ function isReachable(index, symbolKeyStr) {
1124
+ const reachable = computeReachability(index);
1125
+ return reachable.has(symbolKeyStr);
1126
+ }
1127
+
497
1128
  /**
498
1129
  * Check if a specific symbol is a framework entry point.
499
1130
  * Used by deadcode to exclude framework-registered functions.
@@ -526,4 +1157,7 @@ module.exports = {
526
1157
  isFrameworkEntrypoint,
527
1158
  matchDecoratorOrModifier,
528
1159
  buildCallbackEntrypointMap,
1160
+ computeReachability,
1161
+ isReachable,
1162
+ symbolKey,
529
1163
  };