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/check.js ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * core/check.js — Pre-commit summary command.
3
+ *
4
+ * Composes diff-impact + verify + affected-tests into a single output.
5
+ * Tells the caller, in one shot:
6
+ * - which functions changed
7
+ * - which call sites might break (signature drift)
8
+ * - which tests are likely affected
9
+ * - which new functions look orphaned
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const { diffImpact } = require('./analysis');
15
+
16
+ /**
17
+ * Run the pre-commit check.
18
+ *
19
+ * @param {object} index - ProjectIndex
20
+ * @param {object} options - { base, staged, file, limit }
21
+ * @returns {object}
22
+ */
23
+ function check(index, options = {}) {
24
+ let dr;
25
+ try {
26
+ dr = diffImpact(index, {
27
+ base: options.base || 'HEAD',
28
+ staged: !!options.staged,
29
+ file: options.file,
30
+ });
31
+ } catch (e) {
32
+ // Not a git repo, or git command failed — treat as empty
33
+ return {
34
+ base: options.base || 'HEAD',
35
+ staged: !!options.staged,
36
+ empty: true,
37
+ reason: e && e.message ? e.message : 'diff failed',
38
+ };
39
+ }
40
+
41
+ // diffImpact returns { base, functions, newFunctions, deletedFunctions }
42
+ const modified = (dr && Array.isArray(dr.functions)) ? dr.functions : [];
43
+ const added = (dr && Array.isArray(dr.newFunctions)) ? dr.newFunctions : [];
44
+ const deleted = (dr && Array.isArray(dr.deletedFunctions)) ? dr.deletedFunctions : [];
45
+
46
+ const allChanged = [
47
+ ...modified.map(f => ({ ...f, _kind: 'modified' })),
48
+ ...added.map(f => ({ ...f, _kind: 'added' })),
49
+ ];
50
+
51
+ if (!dr || (modified.length === 0 && added.length === 0 && deleted.length === 0)) {
52
+ return {
53
+ base: options.base || 'HEAD',
54
+ staged: !!options.staged,
55
+ empty: true,
56
+ reason: dr && dr.error ? dr.error : 'no changes detected',
57
+ };
58
+ }
59
+
60
+ const limit = options.limit && options.limit > 0 ? options.limit : null;
61
+ const changed = limit ? allChanged.slice(0, limit) : allChanged;
62
+
63
+ const items = [];
64
+ const reachable = computeReachableSet(index);
65
+
66
+ // For each changed function, run verify and gather caller summary
67
+ for (const fn of changed) {
68
+ const filePath = fn.relativePath || fn.file || '';
69
+ let verifyResult = null;
70
+ try {
71
+ verifyResult = index.verify(fn.name, { file: filePath });
72
+ } catch (e) {
73
+ verifyResult = null;
74
+ }
75
+ // Note: verify() returns `mismatches` as a COUNT and `mismatchDetails` as the array.
76
+ const mismatches = verifyResult && Array.isArray(verifyResult.mismatchDetails)
77
+ ? verifyResult.mismatchDetails
78
+ : [];
79
+
80
+ // For modified functions, the existing diffImpact result has `callers` already
81
+ let callers = Array.isArray(fn.callers) ? fn.callers : [];
82
+ if (callers.length === 0 && fn._kind === 'added') {
83
+ try {
84
+ callers = index.findCallers(fn.name, { includeMethods: true, includeUncertain: false }) || [];
85
+ } catch (e) { /* skip */ }
86
+ }
87
+
88
+ const item = {
89
+ name: fn.name,
90
+ file: filePath,
91
+ line: fn.startLine || fn.line,
92
+ kind: fn._kind,
93
+ callerCount: callers.length,
94
+ signatureMismatches: mismatches.length,
95
+ ...(mismatches.length > 0 && { mismatches: mismatches.slice(0, 5) }),
96
+ };
97
+
98
+ // Orphan = newly added with zero callers AND not detected as an entry point.
99
+ // (A new helper called by another new helper is still NOT orphan; reachability
100
+ // is rebuilt as the user iterates, and false positives here cause noise.)
101
+ if (item.kind === 'added' && callers.length === 0) {
102
+ // Check entry points: if the symbol is a known entry-point pattern, not orphan
103
+ let isEntry = false;
104
+ try {
105
+ const ep = require('./entrypoints');
106
+ if (typeof ep.detectEntrypoints === 'function') {
107
+ const eps = ep.detectEntrypoints(index) || [];
108
+ isEntry = eps.some(e => e.name === fn.name && (e.file === filePath || e.relativePath === filePath));
109
+ }
110
+ } catch (e) { /* skip */ }
111
+ item.orphan = !isEntry;
112
+ }
113
+
114
+ items.push(item);
115
+ }
116
+
117
+ // Surface deleted functions inline — they don't have line/file but still matter
118
+ for (const d of deleted) {
119
+ items.push({
120
+ name: d.name || '(unnamed)',
121
+ file: d.relativePath || d.file || '',
122
+ line: d.startLine || 0,
123
+ kind: 'deleted',
124
+ callerCount: 0,
125
+ signatureMismatches: 0,
126
+ });
127
+ }
128
+
129
+ // Affected tests (top-level summary, capped)
130
+ let testFiles = [];
131
+ let testCount = 0;
132
+ for (const fn of changed.slice(0, 10)) {
133
+ try {
134
+ const t = index.affectedTests(fn.name, { depth: 2 });
135
+ if (t && t.testFiles) {
136
+ for (const tf of t.testFiles) {
137
+ if (!testFiles.find(x => x.file === tf.file)) {
138
+ testFiles.push(tf);
139
+ testCount += tf.testCount || 0;
140
+ }
141
+ }
142
+ }
143
+ } catch (e) { /* skip */ }
144
+ }
145
+
146
+ // Action items
147
+ const actions = [];
148
+ for (const it of items) {
149
+ if (it.signatureMismatches > 0) {
150
+ actions.push({
151
+ severity: 'warn',
152
+ kind: 'signature_drift',
153
+ message: `${it.name}: ${it.signatureMismatches} call site(s) need updating`,
154
+ });
155
+ }
156
+ if (it.orphan) {
157
+ actions.push({
158
+ severity: 'warn',
159
+ kind: 'orphan_new',
160
+ message: `${it.name} is new but has no callers and is not an entry point`,
161
+ });
162
+ }
163
+ }
164
+ if (testFiles.length > 0) {
165
+ const filesList = testFiles.slice(0, 5).map(t => t.file).join(' ');
166
+ actions.push({
167
+ severity: 'info',
168
+ kind: 'tests_to_run',
169
+ message: `Run tests: ${filesList}`,
170
+ });
171
+ }
172
+
173
+ return {
174
+ base: options.base || 'HEAD',
175
+ staged: !!options.staged,
176
+ changed: items,
177
+ totalChanged: allChanged.length + deleted.length,
178
+ truncated: !!(limit && allChanged.length > limit),
179
+ testFiles,
180
+ totalTestFiles: testFiles.length,
181
+ totalTests: testCount,
182
+ actions,
183
+ };
184
+ }
185
+
186
+ function symbolKey(file, line) {
187
+ return `${file}:${line}`;
188
+ }
189
+
190
+ function computeReachableSet(index) {
191
+ try {
192
+ const ep = require('./entrypoints');
193
+ if (typeof ep.computeReachability === 'function') {
194
+ return ep.computeReachability(index);
195
+ }
196
+ } catch (e) { /* fall through */ }
197
+ return null;
198
+ }
199
+
200
+ module.exports = { check };
package/core/discovery.js CHANGED
@@ -393,43 +393,68 @@ function findProjectRoot(startDir) {
393
393
  return path.resolve(startDir);
394
394
  }
395
395
 
396
+ // All file extensions for languages UCN supports as code analysis (excludes .rb/.php/.c/.cpp etc.
397
+ // which are extensions UCN scans but doesn't analyze). When build manifests can't tell us
398
+ // what's in a project, we scan all of these — the file extension alone determines language.
399
+ const ALL_SUPPORTED_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'py', 'go', 'java', 'rs', 'html', 'htm'];
400
+
401
+ // Build-manifest hints: when present, we know the project has files of that language
402
+ // regardless of whether sources are visible at the time of scan. Used as hints, not gates —
403
+ // any source file extension is included whether or not its manifest is present.
404
+ const MANIFEST_HINTS = {
405
+ 'package.json': ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'html', 'htm'],
406
+ 'pyproject.toml': ['py'],
407
+ 'setup.py': ['py'],
408
+ 'requirements.txt': ['py'],
409
+ 'go.mod': ['go'],
410
+ 'Cargo.toml': ['rs'],
411
+ 'pom.xml': ['java'],
412
+ 'build.gradle': ['java'],
413
+ 'build.gradle.kts': ['java'],
414
+ };
415
+
396
416
  /**
397
- * Auto-detect the glob pattern for a project based on its type
398
- * Checks both project root and immediate subdirectories for config files
417
+ * Auto-detect the glob pattern for a project.
418
+ *
419
+ * Discovery rule: build manifests are HINTS, not gates. We always scan ALL supported
420
+ * language extensions (JS/TS/Python/Go/Rust/Java/HTML). Manifests only inform metadata
421
+ * (e.g., flagging a project as "has Go") and never exclude files.
422
+ *
423
+ * This means a polyglot project with only `package.json` still discovers .py/.go/.rs
424
+ * files — language is determined by extension, not by manifest presence.
425
+ *
426
+ * Manifests are still useful for:
427
+ * - Project root detection (see findProjectRoot / PROJECT_MARKERS)
428
+ * - Conditional ignores (vendor/target/Pods/etc. — see CONDITIONAL_IGNORES)
429
+ * - Language hints in stats output
399
430
  */
400
431
  function detectProjectPattern(projectRoot) {
401
- const extensions = [];
402
-
403
- // Helper to check for config files in a directory
404
- const checkDir = (dir) => {
405
- if (fs.existsSync(path.join(dir, 'package.json'))) {
406
- extensions.push('js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'html', 'htm');
407
- }
408
-
409
- if (fs.existsSync(path.join(dir, 'pyproject.toml')) ||
410
- fs.existsSync(path.join(dir, 'setup.py')) ||
411
- fs.existsSync(path.join(dir, 'requirements.txt'))) {
412
- extensions.push('py');
413
- }
414
-
415
- if (fs.existsSync(path.join(dir, 'go.mod'))) {
416
- extensions.push('go');
417
- }
432
+ // Always scan all supported language extensions. Build manifests no longer gate
433
+ // language inclusion — file extension alone determines what gets analyzed.
434
+ return `**/*.{${ALL_SUPPORTED_EXTENSIONS.join(',')}}`;
435
+ }
418
436
 
419
- if (fs.existsSync(path.join(dir, 'Cargo.toml'))) {
420
- extensions.push('rs');
421
- }
437
+ /**
438
+ * Detect which manifest hints are present in a project root or immediate subdirectories.
439
+ * Returns array of detected language extension hints. Used purely informationally —
440
+ * not as a gate on which files get scanned.
441
+ *
442
+ * @param {string} projectRoot - Project root directory
443
+ * @returns {string[]} Array of language extension strings (e.g., ['js', 'py', 'go'])
444
+ */
445
+ function detectManifestHints(projectRoot) {
446
+ const hints = new Set();
422
447
 
423
- if (fs.existsSync(path.join(dir, 'pom.xml')) ||
424
- fs.existsSync(path.join(dir, 'build.gradle'))) {
425
- extensions.push('java');
448
+ const checkDir = (dir) => {
449
+ for (const [marker, exts] of Object.entries(MANIFEST_HINTS)) {
450
+ if (fs.existsSync(path.join(dir, marker))) {
451
+ for (const ext of exts) hints.add(ext);
452
+ }
426
453
  }
427
454
  };
428
455
 
429
- // Check project root
430
456
  checkDir(projectRoot);
431
457
 
432
- // Also check immediate subdirectories for multi-language projects (e.g., web/, frontend/, server/)
433
458
  try {
434
459
  const entries = fs.readdirSync(projectRoot, { withFileTypes: true });
435
460
  for (const entry of entries) {
@@ -442,12 +467,7 @@ function detectProjectPattern(projectRoot) {
442
467
  // Ignore errors reading directory
443
468
  }
444
469
 
445
- if (extensions.length > 0) {
446
- const unique = [...new Set(extensions)];
447
- return `**/*.{${unique.join(',')}}`;
448
- }
449
-
450
- return '**/*.{js,jsx,ts,tsx,py,go,java,rs,rb,php,c,cpp,h,hpp,html,htm}';
470
+ return [...hints];
451
471
  }
452
472
 
453
473
  /**
@@ -541,11 +561,14 @@ module.exports = {
541
561
  shouldIgnore,
542
562
  findProjectRoot,
543
563
  detectProjectPattern,
564
+ detectManifestHints,
544
565
  getFileStats,
545
566
  isTestFile,
546
567
  findTestFileFor,
547
568
  parseGitignore,
548
569
  DEFAULT_IGNORES,
549
570
  PROJECT_MARKERS,
550
- TEST_PATTERNS
571
+ TEST_PATTERNS,
572
+ ALL_SUPPORTED_EXTENSIONS,
573
+ MANIFEST_HINTS
551
574
  };