ucn 3.0.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.

Potentially problematic release.


This version of ucn might be problematic. Click here for more details.

Files changed (45) hide show
  1. package/.claude/skills/ucn/SKILL.md +77 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -0
  4. package/cli/index.js +2437 -0
  5. package/core/discovery.js +513 -0
  6. package/core/imports.js +558 -0
  7. package/core/output.js +1274 -0
  8. package/core/parser.js +279 -0
  9. package/core/project.js +3261 -0
  10. package/index.js +52 -0
  11. package/languages/go.js +653 -0
  12. package/languages/index.js +267 -0
  13. package/languages/java.js +826 -0
  14. package/languages/javascript.js +1346 -0
  15. package/languages/python.js +667 -0
  16. package/languages/rust.js +950 -0
  17. package/languages/utils.js +457 -0
  18. package/package.json +42 -0
  19. package/test/fixtures/go/go.mod +3 -0
  20. package/test/fixtures/go/main.go +257 -0
  21. package/test/fixtures/go/service.go +187 -0
  22. package/test/fixtures/java/DataService.java +279 -0
  23. package/test/fixtures/java/Main.java +287 -0
  24. package/test/fixtures/java/Utils.java +199 -0
  25. package/test/fixtures/java/pom.xml +6 -0
  26. package/test/fixtures/javascript/main.js +109 -0
  27. package/test/fixtures/javascript/package.json +1 -0
  28. package/test/fixtures/javascript/service.js +88 -0
  29. package/test/fixtures/javascript/utils.js +67 -0
  30. package/test/fixtures/python/main.py +198 -0
  31. package/test/fixtures/python/pyproject.toml +3 -0
  32. package/test/fixtures/python/service.py +166 -0
  33. package/test/fixtures/python/utils.py +118 -0
  34. package/test/fixtures/rust/Cargo.toml +3 -0
  35. package/test/fixtures/rust/main.rs +253 -0
  36. package/test/fixtures/rust/service.rs +210 -0
  37. package/test/fixtures/rust/utils.rs +154 -0
  38. package/test/fixtures/typescript/main.ts +154 -0
  39. package/test/fixtures/typescript/package.json +1 -0
  40. package/test/fixtures/typescript/repository.ts +149 -0
  41. package/test/fixtures/typescript/types.ts +114 -0
  42. package/test/parser.test.js +3661 -0
  43. package/test/public-repos-test.js +477 -0
  44. package/test/systematic-test.js +619 -0
  45. package/ucn.js +8 -0
@@ -0,0 +1,457 @@
1
+ /**
2
+ * languages/utils.js - Shared tree-sitter AST utilities
3
+ */
4
+
5
+ /**
6
+ * Traverse tree-sitter AST depth-first
7
+ * @param {object} node - Tree-sitter node
8
+ * @param {function} callback - Called with each node, return false to stop traversal of children
9
+ * @param {object} [options] - Optional traversal options
10
+ * @param {function} [options.onLeave] - Called when leaving each node (after children processed)
11
+ */
12
+ function traverseTree(node, callback, options) {
13
+ if (callback(node) === false) return;
14
+ for (let i = 0; i < node.namedChildCount; i++) {
15
+ traverseTree(node.namedChild(i), callback, options);
16
+ }
17
+ if (options?.onLeave) {
18
+ options.onLeave(node);
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Get line locations from tree-sitter node
24
+ * Returns 1-indexed lines to match UCN output format
25
+ * @param {object} node - Tree-sitter node
26
+ * @param {string} code - Original source code
27
+ * @returns {{ startLine: number, endLine: number, indent: number }}
28
+ */
29
+ function nodeToLocation(node, code) {
30
+ const startLine = node.startPosition.row + 1; // tree-sitter is 0-indexed
31
+ const endLine = node.endPosition.row + 1;
32
+
33
+ // Calculate indent from start of line
34
+ const lines = code.split('\n');
35
+ const firstLine = lines[node.startPosition.row] || '';
36
+ const indentMatch = firstLine.match(/^(\s*)/);
37
+ const indent = indentMatch ? indentMatch[1].length : 0;
38
+
39
+ return { startLine, endLine, indent };
40
+ }
41
+
42
+ /**
43
+ * Extract parameter string from parameters node
44
+ * @param {object} paramsNode - Tree-sitter parameters node
45
+ * @returns {string}
46
+ */
47
+ function extractParams(paramsNode) {
48
+ if (!paramsNode) return '...';
49
+ const text = paramsNode.text;
50
+ // Remove outer parens and trim
51
+ return text.replace(/^\(|\)$/g, '').trim() || '...';
52
+ }
53
+
54
+ /**
55
+ * Parse parameters into structured format
56
+ * @param {object} paramsNode - Tree-sitter parameters node
57
+ * @param {string} language - Language name
58
+ * @returns {Array<{name: string, type?: string, optional?: boolean, default?: string, rest?: boolean}>}
59
+ */
60
+ function parseStructuredParams(paramsNode, language) {
61
+ if (!paramsNode) return [];
62
+
63
+ const params = [];
64
+
65
+ for (let i = 0; i < paramsNode.namedChildCount; i++) {
66
+ const param = paramsNode.namedChild(i);
67
+ const paramInfo = {};
68
+
69
+ // Different handling per language
70
+ if (language === 'javascript' || language === 'typescript' || language === 'tsx') {
71
+ parseJSParam(param, paramInfo);
72
+ } else if (language === 'python') {
73
+ parsePythonParam(param, paramInfo);
74
+ } else if (language === 'go') {
75
+ parseGoParam(param, paramInfo);
76
+ } else if (language === 'rust') {
77
+ parseRustParam(param, paramInfo);
78
+ } else if (language === 'java') {
79
+ parseJavaParam(param, paramInfo);
80
+ }
81
+
82
+ if (paramInfo.name) {
83
+ params.push(paramInfo);
84
+ }
85
+ }
86
+
87
+ return params;
88
+ }
89
+
90
+ function parseJSParam(param, info) {
91
+ if (param.type === 'identifier') {
92
+ info.name = param.text;
93
+ } else if (param.type === 'required_parameter' || param.type === 'optional_parameter') {
94
+ const patternNode = param.childForFieldName('pattern');
95
+ const typeNode = param.childForFieldName('type');
96
+ if (patternNode) info.name = patternNode.text;
97
+ if (typeNode) info.type = typeNode.text.replace(/^:\s*/, '');
98
+ if (param.type === 'optional_parameter') info.optional = true;
99
+ } else if (param.type === 'rest_parameter') {
100
+ const patternNode = param.childForFieldName('pattern');
101
+ if (patternNode) info.name = patternNode.text;
102
+ info.rest = true;
103
+ } else if (param.type === 'assignment_pattern') {
104
+ const leftNode = param.childForFieldName('left');
105
+ const rightNode = param.childForFieldName('right');
106
+ if (leftNode) info.name = leftNode.text;
107
+ if (rightNode) info.default = rightNode.text;
108
+ }
109
+ }
110
+
111
+ function parsePythonParam(param, info) {
112
+ if (param.type === 'identifier') {
113
+ info.name = param.text;
114
+ } else if (param.type === 'typed_parameter') {
115
+ const nameNode = param.namedChild(0);
116
+ const typeNode = param.childForFieldName('type');
117
+ if (nameNode) info.name = nameNode.text;
118
+ if (typeNode) info.type = typeNode.text;
119
+ } else if (param.type === 'default_parameter' || param.type === 'typed_default_parameter') {
120
+ const nameNode = param.childForFieldName('name');
121
+ const valueNode = param.childForFieldName('value');
122
+ if (nameNode) info.name = nameNode.text;
123
+ if (valueNode) info.default = valueNode.text;
124
+ info.optional = true;
125
+ } else if (param.type === 'list_splat_pattern' || param.type === 'dictionary_splat_pattern') {
126
+ info.name = param.text;
127
+ info.rest = true;
128
+ }
129
+ }
130
+
131
+ function parseGoParam(param, info) {
132
+ if (param.type === 'parameter_declaration') {
133
+ const nameNode = param.childForFieldName('name');
134
+ const typeNode = param.childForFieldName('type');
135
+ if (nameNode) info.name = nameNode.text;
136
+ if (typeNode) info.type = typeNode.text;
137
+ }
138
+ }
139
+
140
+ function parseRustParam(param, info) {
141
+ if (param.type === 'parameter') {
142
+ const patternNode = param.childForFieldName('pattern');
143
+ const typeNode = param.childForFieldName('type');
144
+ if (patternNode) info.name = patternNode.text;
145
+ if (typeNode) info.type = typeNode.text;
146
+ } else if (param.type === 'self_parameter') {
147
+ info.name = param.text;
148
+ }
149
+ }
150
+
151
+ function parseJavaParam(param, info) {
152
+ if (param.type === 'formal_parameter' || param.type === 'spread_parameter') {
153
+ const nameNode = param.childForFieldName('name');
154
+ const typeNode = param.childForFieldName('type');
155
+ if (nameNode) info.name = nameNode.text;
156
+ if (typeNode) info.type = typeNode.text;
157
+ if (param.type === 'spread_parameter') info.rest = true;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Extract JSDoc docstring from JavaScript/TypeScript code
163
+ * Looks for /** ... *\/ comment block above the given line
164
+ * @param {string} code - Source code
165
+ * @param {number} startLine - 1-indexed line number of the function/class
166
+ * @returns {string|null} First line of docstring or null
167
+ */
168
+ function extractJSDocstring(code, startLine) {
169
+ const lines = code.split('\n');
170
+ const lineIndex = startLine - 1;
171
+ if (lineIndex <= 0) return null;
172
+
173
+ // Scan upward, skipping empty lines and decorators
174
+ let i = lineIndex - 1;
175
+ while (i >= 0 && (lines[i].trim() === '' || lines[i].trim().startsWith('@'))) {
176
+ i--;
177
+ }
178
+ if (i < 0) return null;
179
+
180
+ // Check if this line ends with */ (end of JSDoc)
181
+ const trimmed = lines[i].trim();
182
+ if (trimmed.endsWith('*/')) {
183
+ // Find the start of the JSDoc block
184
+ let docEnd = i;
185
+ while (i >= 0 && !lines[i].includes('/**')) {
186
+ i--;
187
+ }
188
+ if (i < 0 || !lines[i].includes('/**')) return null;
189
+
190
+ // Extract the first meaningful line from the JSDoc
191
+ for (let j = i; j <= docEnd; j++) {
192
+ const line = lines[j]
193
+ .replace(/^\s*\/\*\*\s*/, '') // Remove /** at start
194
+ .replace(/\s*\*\/\s*$/, '') // Remove */ at end
195
+ .replace(/^\s*\*\s?/, '') // Remove leading *
196
+ .trim();
197
+ // Skip empty lines and @param/@returns tags
198
+ if (line && !line.startsWith('@')) {
199
+ return line;
200
+ }
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * Extract Python docstring from code
208
+ * Looks for """...""" or '''...''' as first statement after def/class
209
+ * @param {string} code - Source code
210
+ * @param {number} defLine - 1-indexed line number of the def/class (not decorator)
211
+ * @returns {string|null} First line of docstring or null
212
+ */
213
+ function extractPythonDocstring(code, defLine) {
214
+ const lines = code.split('\n');
215
+ // Python docstring is INSIDE the function, on lines after the def:
216
+ let i = defLine; // Start after the def line (defLine is 1-indexed)
217
+ // Skip to find the first non-empty line inside the function
218
+ while (i < lines.length && lines[i].trim() === '') {
219
+ i++;
220
+ }
221
+ if (i >= lines.length) return null;
222
+
223
+ const trimmed = lines[i].trim();
224
+ // Check for triple-quoted string
225
+ if (trimmed.startsWith('"""') || trimmed.startsWith("'''")) {
226
+ const quote = trimmed.startsWith('"""') ? '"""' : "'''";
227
+ // Single-line docstring
228
+ if (trimmed.endsWith(quote) && trimmed.length > 6) {
229
+ return trimmed.slice(3, -3).trim();
230
+ }
231
+ // Multi-line docstring: return first line
232
+ const firstLine = trimmed.slice(3).trim();
233
+ if (firstLine) return firstLine;
234
+ // First line was just quotes, get next line
235
+ if (i + 1 < lines.length) {
236
+ return lines[i + 1].trim();
237
+ }
238
+ }
239
+ return null;
240
+ }
241
+
242
+ /**
243
+ * Extract Go documentation comment from code
244
+ * Looks for // comments directly above the func
245
+ * @param {string} code - Source code
246
+ * @param {number} startLine - 1-indexed line number of the function
247
+ * @returns {string|null} First line of doc comment or null
248
+ */
249
+ function extractGoDocstring(code, startLine) {
250
+ const lines = code.split('\n');
251
+ const lineIndex = startLine - 1;
252
+ if (lineIndex <= 0) return null;
253
+
254
+ // Scan upward, skipping empty lines
255
+ let i = lineIndex - 1;
256
+ while (i >= 0 && lines[i].trim() === '') {
257
+ i--;
258
+ }
259
+ if (i < 0) return null;
260
+
261
+ const trimmed = lines[i].trim();
262
+ if (trimmed.startsWith('//')) {
263
+ // Find the start of the comment block
264
+ let commentStart = i;
265
+ while (commentStart > 0 && lines[commentStart - 1].trim().startsWith('//')) {
266
+ commentStart--;
267
+ }
268
+ // Return first line of comment block
269
+ const firstLine = lines[commentStart].trim().replace(/^\/\/\s?/, '');
270
+ if (firstLine) return firstLine;
271
+ }
272
+ return null;
273
+ }
274
+
275
+ /**
276
+ * Extract Rust documentation comment from code
277
+ * Looks for /// or //! comments directly above the item
278
+ * @param {string} code - Source code
279
+ * @param {number} startLine - 1-indexed line number of the item
280
+ * @returns {string|null} First line of doc comment or null
281
+ */
282
+ function extractRustDocstring(code, startLine) {
283
+ const lines = code.split('\n');
284
+ const lineIndex = startLine - 1;
285
+ if (lineIndex <= 0) return null;
286
+
287
+ // Scan upward, skipping empty lines and attributes (#[...])
288
+ let i = lineIndex - 1;
289
+ while (i >= 0 && (lines[i].trim() === '' || lines[i].trim().startsWith('#['))) {
290
+ i--;
291
+ }
292
+ if (i < 0) return null;
293
+
294
+ const trimmed = lines[i].trim();
295
+ if (trimmed.startsWith('///') || trimmed.startsWith('//!')) {
296
+ // Find the start of the doc comment block
297
+ let commentStart = i;
298
+ while (commentStart > 0 &&
299
+ (lines[commentStart - 1].trim().startsWith('///') ||
300
+ lines[commentStart - 1].trim().startsWith('//!'))) {
301
+ commentStart--;
302
+ }
303
+ // Return first line of comment block
304
+ const firstLine = lines[commentStart].trim().replace(/^\/\/[\/!]\s?/, '');
305
+ if (firstLine) return firstLine;
306
+ }
307
+ return null;
308
+ }
309
+
310
+ /**
311
+ * Extract Java Javadoc comment from code
312
+ * Looks for /** ... *\/ comment block above the method/class
313
+ * @param {string} code - Source code
314
+ * @param {number} startLine - 1-indexed line number of the method/class
315
+ * @returns {string|null} First line of docstring or null
316
+ */
317
+ function extractJavaDocstring(code, startLine) {
318
+ // Java uses same format as JS - /** ... */
319
+ return extractJSDocstring(code, startLine);
320
+ }
321
+
322
+ /**
323
+ * Get the token type at a specific position using AST
324
+ * @param {object} rootNode - Tree-sitter root node
325
+ * @param {number} line - 1-indexed line number
326
+ * @param {number} column - 0-indexed column number
327
+ * @returns {string} Token type: 'comment', 'string', or 'code'
328
+ */
329
+ function getTokenTypeAtPosition(rootNode, line, column) {
330
+ // Convert to 0-indexed row for tree-sitter
331
+ const row = line - 1;
332
+
333
+ // Find the smallest node at this position
334
+ const node = rootNode.descendantForPosition({ row, column });
335
+ if (!node) return 'code';
336
+
337
+ // Walk up the tree to check if we're in a comment or string
338
+ let current = node;
339
+ while (current) {
340
+ const type = current.type;
341
+
342
+ // Comment types across languages
343
+ if (type === 'comment' ||
344
+ type === 'line_comment' ||
345
+ type === 'block_comment' ||
346
+ type === 'doc_comment' ||
347
+ type === 'documentation_comment') {
348
+ return 'comment';
349
+ }
350
+
351
+ // String types across languages
352
+ if (type === 'string' ||
353
+ type === 'string_literal' ||
354
+ type === 'template_string' ||
355
+ type === 'template_literal' ||
356
+ type === 'raw_string_literal' ||
357
+ type === 'interpreted_string_literal' ||
358
+ type === 'concatenated_string') {
359
+ return 'string';
360
+ }
361
+
362
+ // For template strings, check if we're in the literal part vs expression
363
+ if (type === 'template_substitution' || type === 'interpolation') {
364
+ // Inside ${...}, this is code
365
+ return 'code';
366
+ }
367
+
368
+ current = current.parent;
369
+ }
370
+
371
+ return 'code';
372
+ }
373
+
374
+ /**
375
+ * Check if a match at a specific position is inside a comment or string
376
+ * @param {object} rootNode - Tree-sitter root node
377
+ * @param {number} line - 1-indexed line number
378
+ * @param {string} lineContent - The line content
379
+ * @param {string} term - The search term
380
+ * @returns {boolean} True if the match is in a comment or string
381
+ */
382
+ function isMatchInCommentOrString(rootNode, line, lineContent, term) {
383
+ // Find where the term appears in the line
384
+ const termLower = term.toLowerCase();
385
+ const lineLower = lineContent.toLowerCase();
386
+ const index = lineLower.indexOf(termLower);
387
+
388
+ if (index === -1) return false;
389
+
390
+ // Check the token type at the match position
391
+ const tokenType = getTokenTypeAtPosition(rootNode, line, index);
392
+ return tokenType === 'comment' || tokenType === 'string';
393
+ }
394
+
395
+ /**
396
+ * Find all matches of a term in a file, filtering by token type
397
+ * @param {string} content - File content
398
+ * @param {string} term - Search term
399
+ * @param {object} parser - Tree-sitter parser
400
+ * @param {object} options - { codeOnly: boolean }
401
+ * @returns {Array<{line: number, content: string, column: number}>}
402
+ */
403
+ function findMatchesWithASTFilter(content, term, parser, options = {}) {
404
+ const { PARSE_OPTIONS } = require('./index');
405
+ const tree = parser.parse(content, undefined, PARSE_OPTIONS);
406
+ const lines = content.split('\n');
407
+ const matches = [];
408
+
409
+ // Escape special regex characters and create pattern
410
+ const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
411
+ const regex = new RegExp(escapedTerm, 'gi');
412
+
413
+ lines.forEach((line, idx) => {
414
+ const lineNum = idx + 1;
415
+ let match;
416
+
417
+ // Reset regex for each line
418
+ regex.lastIndex = 0;
419
+
420
+ while ((match = regex.exec(line)) !== null) {
421
+ const column = match.index;
422
+
423
+ if (options.codeOnly) {
424
+ const tokenType = getTokenTypeAtPosition(tree.rootNode, lineNum, column);
425
+ if (tokenType !== 'code') {
426
+ continue; // Skip comments and strings
427
+ }
428
+ }
429
+
430
+ // Check if we already added this line (avoid duplicates)
431
+ if (!matches.some(m => m.line === lineNum)) {
432
+ matches.push({
433
+ line: lineNum,
434
+ content: line,
435
+ column
436
+ });
437
+ }
438
+ }
439
+ });
440
+
441
+ return matches;
442
+ }
443
+
444
+ module.exports = {
445
+ traverseTree,
446
+ nodeToLocation,
447
+ extractParams,
448
+ parseStructuredParams,
449
+ extractJSDocstring,
450
+ extractPythonDocstring,
451
+ extractGoDocstring,
452
+ extractRustDocstring,
453
+ extractJavaDocstring,
454
+ getTokenTypeAtPosition,
455
+ isMatchInCommentOrString,
456
+ findMatchesWithASTFilter
457
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "ucn",
3
+ "version": "3.0.0",
4
+ "description": "Code navigation built by AI, for AI. Reduces context usage by 90%+ when working with large codebases.",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "ucn": "./cli/index.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/parser.test.js"
11
+ },
12
+ "keywords": [
13
+ "code-navigation",
14
+ "ast",
15
+ "parser",
16
+ "tree-sitter",
17
+ "javascript",
18
+ "typescript",
19
+ "python",
20
+ "go",
21
+ "rust",
22
+ "java",
23
+ "ai",
24
+ "agent"
25
+ ],
26
+ "author": "Constantin-Mihail Leoca (https://github.com/mleoca)",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/mleoca/ucn.git"
30
+ },
31
+ "homepage": "https://github.com/mleoca/ucn#readme",
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "tree-sitter": "^0.21.0",
35
+ "tree-sitter-go": "^0.21.0",
36
+ "tree-sitter-java": "^0.21.0",
37
+ "tree-sitter-javascript": "^0.21.0",
38
+ "tree-sitter-python": "^0.21.0",
39
+ "tree-sitter-rust": "^0.21.0",
40
+ "tree-sitter-typescript": "^0.21.0"
41
+ }
42
+ }
@@ -0,0 +1,3 @@
1
+ module python-fixtures
2
+
3
+ go 1.21