ucn 3.8.22 → 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 (47) 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 +960 -37
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +213 -59
  8. package/core/callers.js +117 -41
  9. package/core/check.js +200 -0
  10. package/core/deadcode.js +31 -2
  11. package/core/discovery.js +57 -34
  12. package/core/entrypoints.js +638 -4
  13. package/core/execute.js +304 -5
  14. package/core/git-enrich.js +130 -0
  15. package/core/graph-build.js +4 -4
  16. package/core/graph.js +31 -12
  17. package/core/output/analysis.js +157 -25
  18. package/core/output/brief.js +100 -0
  19. package/core/output/check.js +79 -0
  20. package/core/output/doctor.js +85 -0
  21. package/core/output/endpoints.js +239 -0
  22. package/core/output/extraction.js +2 -0
  23. package/core/output/find.js +126 -39
  24. package/core/output/graph.js +48 -15
  25. package/core/output/refactoring.js +103 -5
  26. package/core/output/reporting.js +63 -23
  27. package/core/output/search.js +110 -17
  28. package/core/output/shared.js +56 -2
  29. package/core/output.js +4 -0
  30. package/core/parallel-build.js +10 -7
  31. package/core/parser.js +8 -2
  32. package/core/project.js +147 -41
  33. package/core/registry.js +30 -14
  34. package/core/reporting.js +465 -2
  35. package/core/search.js +139 -15
  36. package/core/shared.js +101 -5
  37. package/core/tracing.js +31 -12
  38. package/core/verify.js +982 -95
  39. package/languages/go.js +91 -6
  40. package/languages/html.js +10 -0
  41. package/languages/java.js +151 -35
  42. package/languages/javascript.js +290 -33
  43. package/languages/python.js +78 -11
  44. package/languages/rust.js +267 -12
  45. package/languages/utils.js +315 -3
  46. package/mcp/server.js +91 -16
  47. package/package.json +10 -2
package/core/brief.js ADDED
@@ -0,0 +1,408 @@
1
+ /**
2
+ * core/brief.js — Brief: AST-only one-screen summary for a symbol.
3
+ *
4
+ * Returns a compact "before-I-touch-this" snapshot:
5
+ * - typed signature
6
+ * - first-sentence docstring
7
+ * - side-effect classification (fs/network/global mutation/process)
8
+ * - complexity (branches, maxDepth, lineCount)
9
+ * - async/generator flags
10
+ *
11
+ * No LLM, no heuristics that pretend to "summarize" intent.
12
+ * Everything here is derivable from the AST and existing symbol fields.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { parse } = require('./parser');
20
+ const { detectLanguage, langTraits } = require('../languages');
21
+ const { formatSymbolHandle } = require('./shared');
22
+
23
+ // ============================================================================
24
+ // Side-effect signal sets (per-language, conservative)
25
+ // ============================================================================
26
+
27
+ // Module/import names that signal a category.
28
+ // Keys are language; values are { fs: Set, network: Set, process: Set }.
29
+ const SIDE_EFFECT_IMPORTS = {
30
+ javascript: {
31
+ fs: new Set(['fs', 'fs/promises', 'graceful-fs', 'node:fs', 'node:fs/promises']),
32
+ network: new Set(['http', 'https', 'net', 'tls', 'dgram', 'axios', 'node-fetch', 'got', 'undici', 'ws', 'node:http', 'node:https', 'node:net']),
33
+ process: new Set(['child_process', 'cluster', 'worker_threads', 'os', 'node:child_process', 'node:cluster', 'node:worker_threads', 'node:os']),
34
+ },
35
+ typescript: {
36
+ fs: new Set(['fs', 'fs/promises', 'graceful-fs', 'node:fs', 'node:fs/promises']),
37
+ network: new Set(['http', 'https', 'net', 'tls', 'dgram', 'axios', 'node-fetch', 'got', 'undici', 'ws', 'node:http', 'node:https', 'node:net']),
38
+ process: new Set(['child_process', 'cluster', 'worker_threads', 'os', 'node:child_process', 'node:cluster', 'node:worker_threads', 'node:os']),
39
+ },
40
+ python: {
41
+ fs: new Set(['os', 'os.path', 'pathlib', 'shutil', 'tempfile', 'io']),
42
+ network: new Set(['urllib', 'urllib.request', 'http', 'http.client', 'socket', 'requests', 'httpx', 'aiohttp']),
43
+ process: new Set(['subprocess', 'multiprocessing', 'os', 'signal', 'threading']),
44
+ },
45
+ go: {
46
+ fs: new Set(['os', 'io', 'io/ioutil', 'path/filepath', 'embed']),
47
+ network: new Set(['net', 'net/http', 'net/url', 'net/rpc']),
48
+ process: new Set(['os/exec', 'syscall', 'runtime']),
49
+ },
50
+ java: {
51
+ fs: new Set(['java.io', 'java.nio', 'java.nio.file']),
52
+ network: new Set(['java.net', 'java.net.http']),
53
+ process: new Set(['java.lang.Runtime', 'java.lang.ProcessBuilder']),
54
+ },
55
+ rust: {
56
+ fs: new Set(['std::fs', 'std::path']),
57
+ network: new Set(['std::net', 'reqwest', 'hyper', 'tokio::net']),
58
+ process: new Set(['std::process']),
59
+ },
60
+ };
61
+
62
+ // Identifier names that signal side effects when called or referenced.
63
+ // Plain identifier match — not regex. We require the call to be the receiver-less form
64
+ // (e.g. `fetch(...)`) OR a member of a recognized object (`fs.readFile`).
65
+ const SIDE_EFFECT_CALLS_BY_LANG = {
66
+ javascript: {
67
+ network: new Set(['fetch', 'XMLHttpRequest']),
68
+ // Top-level browser globals that mutate state
69
+ process: new Set(['exit']),
70
+ },
71
+ typescript: {
72
+ network: new Set(['fetch', 'XMLHttpRequest']),
73
+ process: new Set(['exit']),
74
+ },
75
+ python: {
76
+ fs: new Set(['open']),
77
+ process: new Set(['exit', 'system']),
78
+ },
79
+ // Nominal languages: imports already give a strong signal (e.g. `java.io`),
80
+ // and direct-call tokens like `exit` would cause false positives across
81
+ // generic identifiers. We deliberately keep this empty.
82
+ go: {},
83
+ java: {},
84
+ rust: {},
85
+ };
86
+
87
+ // ============================================================================
88
+ // brief()
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Compute a brief AST summary for a symbol.
93
+ *
94
+ * @param {object} index - ProjectIndex
95
+ * @param {string} name - Symbol name (function/method/class)
96
+ * @param {object} options - { file, className, git }
97
+ * @returns {object|null}
98
+ */
99
+ function brief(index, name, options = {}) {
100
+ index._beginOp();
101
+ try {
102
+ const { def } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
103
+ if (!def) return null;
104
+
105
+ const language = detectLanguage(def.relativePath || def.file);
106
+ const symbol = {
107
+ name: def.name,
108
+ type: def.type,
109
+ file: def.relativePath || def.file,
110
+ startLine: def.startLine,
111
+ endLine: def.endLine,
112
+ handle: formatSymbolHandle(def),
113
+ language,
114
+ ...(def.params != null && { params: def.params }),
115
+ ...(def.paramsStructured && { paramsStructured: def.paramsStructured }),
116
+ ...(def.paramTypes && { paramTypes: def.paramTypes }),
117
+ ...(def.returnType && { returnType: def.returnType }),
118
+ ...(def.modifiers && def.modifiers.length && { modifiers: def.modifiers }),
119
+ ...(def.decorators && def.decorators.length && { decorators: def.decorators }),
120
+ ...(def.docstring && { docstring: firstSentence(def.docstring) }),
121
+ ...(def.className && { className: def.className }),
122
+ ...(def.isAsync && { isAsync: true }),
123
+ ...(def.isGenerator && { isGenerator: true }),
124
+ };
125
+
126
+ // Optional git enrichment for the primary symbol's file.
127
+ // Skipped silently outside git repos; formatters check `git.available`.
128
+ let gitInfo = null;
129
+ if (options.git) {
130
+ const { getGitInfo } = require('./git-enrich');
131
+ gitInfo = getGitInfo(index.root, def.relativePath || def.file);
132
+ }
133
+
134
+ // For non-callable types (class/struct/interface/type), most fields don't apply
135
+ if (['class', 'struct', 'interface', 'type', 'enum'].includes(def.type)) {
136
+ return {
137
+ symbol,
138
+ kind: 'type',
139
+ lineCount: (def.endLine || def.startLine) - def.startLine + 1,
140
+ memberCount: countMembers(index, def),
141
+ ...(gitInfo && { git: gitInfo }),
142
+ };
143
+ }
144
+
145
+ // For callable symbols, scan the body
146
+ const filePath = path.isAbsolute(def.file) ? def.file : path.join(index.root, def.file);
147
+ let bodyText = '';
148
+ try {
149
+ const content = fs.readFileSync(filePath, 'utf-8');
150
+ const lines = content.split('\n');
151
+ const start = Math.max(0, (def.startLine || 1) - 1);
152
+ const end = Math.min(lines.length, def.endLine || def.startLine || 1);
153
+ bodyText = lines.slice(start, end).join('\n');
154
+ } catch (e) {
155
+ return {
156
+ symbol,
157
+ kind: 'function',
158
+ lineCount: 0,
159
+ sideEffects: [],
160
+ complexity: { branches: 0, maxDepth: 0, lineCount: 0 },
161
+ isAsync: !!def.isAsync,
162
+ error: 'Could not read source',
163
+ ...(gitInfo && { git: gitInfo }),
164
+ };
165
+ }
166
+
167
+ const fileEntry = index.files.get(def.file);
168
+ const fileImports = collectImportNames(fileEntry);
169
+
170
+ const sideEffects = classifySideEffects(bodyText, language, fileImports);
171
+ const complexity = computeComplexity(bodyText, language);
172
+
173
+ return {
174
+ symbol,
175
+ kind: 'function',
176
+ lineCount: complexity.lineCount,
177
+ sideEffects,
178
+ complexity,
179
+ isAsync: !!def.isAsync,
180
+ isGenerator: !!def.isGenerator,
181
+ ...(gitInfo && { git: gitInfo }),
182
+ };
183
+ } finally {
184
+ index._endOp();
185
+ }
186
+ }
187
+
188
+ // ============================================================================
189
+ // Helpers
190
+ // ============================================================================
191
+
192
+ function firstSentence(text) {
193
+ if (!text) return null;
194
+ const trimmed = text.trim();
195
+ // Cut on first sentence terminator. Cap at 200 chars to avoid runaway.
196
+ const m = trimmed.match(/^(.+?[.!?])\s/);
197
+ let s = m ? m[1] : trimmed;
198
+ if (s.length > 200) s = s.slice(0, 197) + '...';
199
+ return s;
200
+ }
201
+
202
+ function countMembers(index, def) {
203
+ if (!def || !def.file) return 0;
204
+ let count = 0;
205
+ for (const arr of index.symbols.values()) {
206
+ for (const s of arr) {
207
+ if (s.file === def.file && s.className === def.name && s.isMethod) count++;
208
+ }
209
+ }
210
+ return count;
211
+ }
212
+
213
+ function collectImportNames(fileEntry) {
214
+ if (!fileEntry) return new Set();
215
+ const names = new Set();
216
+ if (fileEntry.exportDetails) {
217
+ // exportDetails are exports — skip
218
+ }
219
+ // imports map: importName → modulePath (or { source, ... })
220
+ if (fileEntry.imports && typeof fileEntry.imports === 'object') {
221
+ for (const v of Object.values(fileEntry.imports)) {
222
+ if (typeof v === 'string') names.add(v);
223
+ else if (v && v.source) names.add(v.source);
224
+ else if (v && v.from) names.add(v.from);
225
+ }
226
+ }
227
+ if (fileEntry.importDetails && Array.isArray(fileEntry.importDetails)) {
228
+ for (const imp of fileEntry.importDetails) {
229
+ if (imp && imp.source) names.add(imp.source);
230
+ if (imp && imp.from) names.add(imp.from);
231
+ }
232
+ }
233
+ return names;
234
+ }
235
+
236
+ /**
237
+ * Classify side-effects from a function body using string scans.
238
+ *
239
+ * Returns: array of categories the function appears to touch:
240
+ * 'fs' — filesystem reads/writes
241
+ * 'network' — outbound network calls
242
+ * 'process' — child processes / OS-level effects
243
+ * 'global_mutation' — assignments to module-level identifiers (heuristic)
244
+ *
245
+ * NOTE: We use textual scanning over the function body — tree-sitter is great
246
+ * for top-level structure but reparsing every function body for the full AST
247
+ * just to detect well-known names is overkill. The signal sets are tight.
248
+ */
249
+ function classifySideEffects(bodyText, language, fileImports) {
250
+ const out = new Set();
251
+ if (!bodyText) return [];
252
+
253
+ const importsBuckets = SIDE_EFFECT_IMPORTS[language] || {};
254
+ const callsBuckets = SIDE_EFFECT_CALLS_BY_LANG[language] || {};
255
+
256
+ // Resolve which categories the file's imports touch (file-level signal).
257
+ // E.g. if the file imports `fs`, ANY function in the file *could* use it.
258
+ // We confirm by looking for the import-binding name being used in the body.
259
+ // For now, surface category as a "potential" signal if the body references
260
+ // ANY imported binding from a category.
261
+ const fileImportLower = new Set([...fileImports].map(s => s.toLowerCase()));
262
+ for (const [cat, modSet] of Object.entries(importsBuckets)) {
263
+ for (const m of modSet) {
264
+ if (fileImportLower.has(m.toLowerCase())) {
265
+ // Also confirm the body references the module name as an identifier
266
+ // (very common: `fs.readFile`, `requests.get(`, etc.).
267
+ const baseName = m.split(/[./]/).pop();
268
+ if (baseName && new RegExp(`\\b${escapeRegExp(baseName)}\\b`).test(bodyText)) {
269
+ out.add(cat);
270
+ break;
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ // Direct-call signals (no import context needed): `fetch(`, `open(`, etc.
277
+ for (const [cat, callSet] of Object.entries(callsBuckets)) {
278
+ for (const fn of callSet) {
279
+ const re = new RegExp(`\\b${escapeRegExp(fn)}\\s*\\(`);
280
+ if (re.test(bodyText)) {
281
+ out.add(cat);
282
+ break;
283
+ }
284
+ }
285
+ }
286
+
287
+ // Process category for JS console.* (informational, not flagged)
288
+ // Skip — too noisy for "side effect" semantics.
289
+
290
+ // Global-mutation heuristic (cheap):
291
+ // - JS/TS: `module.exports.X = ` / `exports.X = ` / global identifier reassignment at top-level of function body
292
+ // - Python: `global X`
293
+ // - Go: package-level ident on lhs (hard without full AST — skip)
294
+ if (language === 'javascript' || language === 'typescript') {
295
+ if (/\b(module\.exports|exports)\.[A-Za-z_]\w*\s*=/.test(bodyText)) {
296
+ out.add('global_mutation');
297
+ }
298
+ } else if (language === 'python') {
299
+ if (/^\s*global\s+\w/m.test(bodyText)) {
300
+ out.add('global_mutation');
301
+ }
302
+ }
303
+
304
+ return [...out].sort();
305
+ }
306
+
307
+ function escapeRegExp(s) {
308
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
309
+ }
310
+
311
+ /**
312
+ * Compute complexity metrics from a function body.
313
+ * Cheap, AST-free counts on tokenized source.
314
+ */
315
+ function computeComplexity(bodyText, language) {
316
+ const lines = bodyText.split('\n');
317
+ const lineCount = lines.length;
318
+
319
+ // Branch count: count keywords that introduce a new branching path.
320
+ // We deliberately ignore final `else` (it's just the alternate of an `if`).
321
+ const branchPatterns = [
322
+ /\bif\s*\(/g, // JS/TS/Java/Rust/Go/C-like
323
+ /\bif\s+/g, // Python (if x:)
324
+ /\belif\b/g, // Python
325
+ /\belse\s+if\b/g, // JS/Java/etc.
326
+ /\bcase\b/g, // switch case
327
+ /\bwhen\b/g, // Rust match arms (and Kotlin/Scala but we don't support those)
328
+ /\bfor\s*\(/g, // C-like for
329
+ /\bfor\s+\w/g, // Python for x in
330
+ /\bwhile\s*\(/g, // C-like while
331
+ /\bwhile\s+/g, // Python while x:
332
+ /\?[^?]/g, // ternary (rough)
333
+ /\bcatch\s*\(/g, // catch
334
+ /\bexcept\b/g, // Python except
335
+ ];
336
+ let branches = 0;
337
+ for (const re of branchPatterns) branches += (bodyText.match(re) || []).length;
338
+
339
+ // maxDepth: indent-based proxy. Fast, language-agnostic, off-by-one safe.
340
+ let maxDepth = 0;
341
+ let firstNonBlankIndent = -1;
342
+ for (const line of lines) {
343
+ if (!line.trim()) continue;
344
+ const m = line.match(/^(\s*)/);
345
+ const spaces = m ? expandIndent(m[1]) : 0;
346
+ if (firstNonBlankIndent === -1) firstNonBlankIndent = spaces;
347
+ // depth = (current - first) / unit; we don't know "unit", so just track
348
+ // raw delta and divide by 2 (conservative — most code is 2 or 4 space indented).
349
+ const rawDepth = Math.max(0, spaces - firstNonBlankIndent);
350
+ if (rawDepth > maxDepth) maxDepth = rawDepth;
351
+ }
352
+ // Translate raw spaces to depth levels (assume 2-space indent baseline)
353
+ const depth = Math.round(maxDepth / 2);
354
+
355
+ return { branches, maxDepth: depth, lineCount };
356
+ }
357
+
358
+ function expandIndent(s) {
359
+ let n = 0;
360
+ for (const c of s) n += (c === '\t') ? 4 : 1;
361
+ return n;
362
+ }
363
+
364
+ /**
365
+ * Lazy classifier: side-effect tags for an arbitrary symbol record.
366
+ * Used by callee output (`context`, `about`) to surface [fs]/[net]/[proc] tags
367
+ * inline. Cached on the index in `_sideEffectCache` (key: file:startLine).
368
+ *
369
+ * Cheap on cache hit; first hit reads + scans the symbol's body. Returns
370
+ * `null` for non-callable types or unreadable files.
371
+ */
372
+ function sideEffectsFor(index, symbol) {
373
+ if (!index || !symbol) return null;
374
+ if (NON_CALLABLE_KIND.has(symbol.type)) return null;
375
+ const key = `${symbol.file || symbol.relativePath}:${symbol.startLine || 0}`;
376
+ if (!index._sideEffectCache) index._sideEffectCache = new Map();
377
+ if (index._sideEffectCache.has(key)) return index._sideEffectCache.get(key);
378
+
379
+ const filePath = path.isAbsolute(symbol.file || '') ? symbol.file : path.join(index.root, symbol.file || symbol.relativePath || '');
380
+ let bodyText = '';
381
+ try {
382
+ const content = fs.readFileSync(filePath, 'utf-8');
383
+ const lines = content.split('\n');
384
+ const start = Math.max(0, (symbol.startLine || 1) - 1);
385
+ const end = Math.min(lines.length, symbol.endLine || symbol.startLine || 1);
386
+ bodyText = lines.slice(start, end).join('\n');
387
+ } catch (e) {
388
+ index._sideEffectCache.set(key, null);
389
+ return null;
390
+ }
391
+ const language = detectLanguage(symbol.relativePath || symbol.file);
392
+ const fileEntry = index.files.get(symbol.file);
393
+ const fileImports = collectImportNames(fileEntry);
394
+ const tags = classifySideEffects(bodyText, language, fileImports);
395
+ index._sideEffectCache.set(key, tags);
396
+ return tags;
397
+ }
398
+
399
+ const NON_CALLABLE_KIND = new Set(['class', 'struct', 'interface', 'type', 'enum', 'trait', 'impl', 'state', 'field']);
400
+
401
+ module.exports = {
402
+ brief,
403
+ sideEffectsFor,
404
+ // exposed for tests
405
+ classifySideEffects,
406
+ computeComplexity,
407
+ firstSentence,
408
+ };