ucn 3.7.3 → 3.7.5
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.
- package/README.md +56 -28
- package/cli/index.js +102 -1
- package/core/output.js +1 -1
- package/core/project.js +69 -60
- package/mcp/server.js +70 -9
- package/package.json +1 -1
- package/test/mcp-edge-cases.js +115 -0
- package/test/parser.test.js +54 -0
package/README.md
CHANGED
|
@@ -4,9 +4,37 @@ UCN gives AI agents call-graph-level understanding of code. Instead of reading e
|
|
|
4
4
|
|
|
5
5
|
Designed for large codebases where agents waste context on reading large files. UCN's surgical output means agents spend tokens on reasoning, not on ingesting thousands of lines to find three callers, discourages agents from cutting corners, as without UCN, agents working with large codebases tend to skip parts of the code structure, assuming they have "enough data".
|
|
6
6
|
|
|
7
|
+
Everything runs locally on your machine and nothing leaves your project.
|
|
8
|
+
|
|
7
9
|
---
|
|
8
10
|
|
|
9
|
-
##
|
|
11
|
+
## What UCN does
|
|
12
|
+
|
|
13
|
+
Precise answers without reading files.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
TASK COMMAND
|
|
17
|
+
───────────────────── ─────────────────────
|
|
18
|
+
|
|
19
|
+
Pull one function from $ ucn fn handleRequest
|
|
20
|
+
a 2000-line file → 20 lines, just that function
|
|
21
|
+
|
|
22
|
+
Who calls this? Will they $ ucn impact handleRequest
|
|
23
|
+
break if I change it? → 8 call sites, with arguments
|
|
24
|
+
|
|
25
|
+
What happens when $ ucn trace main --depth=3
|
|
26
|
+
main() runs? → full call tree, no file reads
|
|
27
|
+
|
|
28
|
+
What can I safely delete? $ ucn deadcode
|
|
29
|
+
→ unused functions, AST-verified
|
|
30
|
+
|
|
31
|
+
What depends on this file $ ucn graph src/routes.ts
|
|
32
|
+
before I move it? → imports and importers tree
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Three Ways to it: ucn mcp, ucn skill, ucn cli
|
|
10
38
|
|
|
11
39
|
```
|
|
12
40
|
┌──────────────────────────────────────────────────────────────────────┐
|
|
@@ -15,7 +43,7 @@ Designed for large codebases where agents waste context on reading large files.
|
|
|
15
43
|
│ $ ucn about myFunc Works standalone, no agent required. │
|
|
16
44
|
│ │
|
|
17
45
|
│ 2. MCP Server Any MCP-compatible AI agent connects │
|
|
18
|
-
│ $ ucn --mcp and gets 28
|
|
46
|
+
│ $ ucn --mcp and gets 28 commands automatically. │
|
|
19
47
|
│ │
|
|
20
48
|
│ 3. Agent Skill Drop-in skill for Claude Code and │
|
|
21
49
|
│ /ucn about myFunc OpenAI Codex CLI. No server needed. │
|
|
@@ -66,7 +94,7 @@ AI agents working with code typically do this:
|
|
|
66
94
|
▼
|
|
67
95
|
┌───────────────────┐
|
|
68
96
|
│ UCN MCP Server │
|
|
69
|
-
│ 28
|
|
97
|
+
│ 28 commands │
|
|
70
98
|
│ runs locally │
|
|
71
99
|
└────────┬──────────┘
|
|
72
100
|
│
|
|
@@ -78,8 +106,6 @@ AI agents working with code typically do this:
|
|
|
78
106
|
└─────────────────────────────────────┘
|
|
79
107
|
```
|
|
80
108
|
|
|
81
|
-
No cloud. No API keys. Parses locally, stays local.
|
|
82
|
-
|
|
83
109
|
---
|
|
84
110
|
|
|
85
111
|
## Before and after UCN
|
|
@@ -88,7 +114,7 @@ No cloud. No API keys. Parses locally, stays local.
|
|
|
88
114
|
WITHOUT UCN WITH UCN
|
|
89
115
|
────────────────────── ──────────────────────
|
|
90
116
|
|
|
91
|
-
grep "processOrder"
|
|
117
|
+
grep "processOrder" ucn impact "processOrder"
|
|
92
118
|
│ │
|
|
93
119
|
▼ ▼
|
|
94
120
|
34 matches, mostly noise 8 call sites, grouped by file,
|
|
@@ -97,7 +123,7 @@ No cloud. No API keys. Parses locally, stays local.
|
|
|
97
123
|
read service.ts (800 lines) │
|
|
98
124
|
│ │
|
|
99
125
|
▼ │
|
|
100
|
-
read handler.ts (600 lines)
|
|
126
|
+
read handler.ts (600 lines) ucn smart "processOrder"
|
|
101
127
|
│ │
|
|
102
128
|
▼ ▼
|
|
103
129
|
read batch.ts (400 lines) function + all dependencies
|
|
@@ -126,7 +152,7 @@ After editing code, before committing:
|
|
|
126
152
|
WITHOUT UCN WITH UCN
|
|
127
153
|
────────────────────── ──────────────────────
|
|
128
154
|
|
|
129
|
-
git diff
|
|
155
|
+
git diff ucn diff_impact
|
|
130
156
|
│ │
|
|
131
157
|
▼ ▼
|
|
132
158
|
see changed lines, but which 13 modified functions
|
|
@@ -137,7 +163,7 @@ After editing code, before committing:
|
|
|
137
163
|
to function boundaries Each function shown with:
|
|
138
164
|
│ • which lines changed
|
|
139
165
|
▼ • every downstream caller
|
|
140
|
-
|
|
166
|
+
ucn impact on each function • caller context
|
|
141
167
|
you identified (repeat 5-10x) │
|
|
142
168
|
│ ▼
|
|
143
169
|
▼ Done. Full blast radius.
|
|
@@ -169,7 +195,7 @@ After editing code, before committing:
|
|
|
169
195
|
|
|
170
196
|
|
|
171
197
|
┌─────────────────────────────────────────────────────────────────┐
|
|
172
|
-
│
|
|
198
|
+
│ ucn context "processOrder" │
|
|
173
199
|
│ │
|
|
174
200
|
│ Callers: │
|
|
175
201
|
│ handleCheckout src/api/checkout.ts:45 │
|
|
@@ -515,38 +541,40 @@ ucn toc # Project overview
|
|
|
515
541
|
|
|
516
542
|
---
|
|
517
543
|
|
|
518
|
-
## All 28
|
|
544
|
+
## All 28 Commands
|
|
545
|
+
|
|
546
|
+
All commands are accessible through a single `ucn` MCP tool with a `command` parameter.
|
|
519
547
|
|
|
520
548
|
```
|
|
521
549
|
UNDERSTAND MODIFY SAFELY
|
|
522
550
|
───────────────────── ─────────────────────
|
|
523
|
-
|
|
551
|
+
about everything in one impact all call sites
|
|
524
552
|
call: definition, with arguments
|
|
525
553
|
callers, callees,
|
|
526
|
-
tests, source
|
|
554
|
+
tests, source diff_impact what changed in a
|
|
527
555
|
git diff + who
|
|
528
|
-
|
|
556
|
+
context callers + callees calls it
|
|
529
557
|
(quick overview)
|
|
530
|
-
|
|
531
|
-
|
|
558
|
+
verify check all sites
|
|
559
|
+
smart function + helpers match signature
|
|
532
560
|
expanded inline
|
|
533
|
-
|
|
534
|
-
|
|
561
|
+
plan preview a refactor
|
|
562
|
+
trace call tree — map before doing it
|
|
535
563
|
a whole pipeline
|
|
536
564
|
|
|
537
565
|
|
|
538
566
|
FIND & NAVIGATE ARCHITECTURE
|
|
539
567
|
───────────────────── ─────────────────────
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
568
|
+
find locate definitions imports file dependencies
|
|
569
|
+
usages all occurrences exporters who depends on it
|
|
570
|
+
fn extract a function graph dependency tree
|
|
571
|
+
class extract a class related sibling functions
|
|
572
|
+
toc project overview tests find tests
|
|
573
|
+
deadcode unused functions stacktrace error trace context
|
|
574
|
+
search text search api public API surface
|
|
575
|
+
example best usage example typedef type definitions
|
|
576
|
+
lines extract line range file_exports file's exports
|
|
577
|
+
expand drill into context stats project size stats
|
|
550
578
|
```
|
|
551
579
|
|
|
552
580
|
---
|
package/cli/index.js
CHANGED
|
@@ -1819,14 +1819,21 @@ Commands:
|
|
|
1819
1819
|
about <name> Everything about a symbol
|
|
1820
1820
|
usages <name> All usages grouped by type
|
|
1821
1821
|
context <name> Callers + callees
|
|
1822
|
+
expand <N> Show code for item N from context
|
|
1822
1823
|
smart <name> Function + dependencies
|
|
1823
1824
|
impact <name> What breaks if changed
|
|
1824
1825
|
trace <name> Call tree
|
|
1826
|
+
example <name> Best usage example
|
|
1827
|
+
related <name> Sibling functions
|
|
1825
1828
|
imports <file> What file imports
|
|
1826
1829
|
exporters <file> Who imports file
|
|
1827
1830
|
tests <name> Find tests
|
|
1828
1831
|
search <term> Text search
|
|
1829
1832
|
typedef <name> Find type definitions
|
|
1833
|
+
deadcode Find unused functions/classes
|
|
1834
|
+
verify <name> Check call sites match signature
|
|
1835
|
+
plan <name> Preview refactoring
|
|
1836
|
+
stacktrace <text> Parse a stack trace
|
|
1830
1837
|
api Show public symbols
|
|
1831
1838
|
diff-impact What changed and who's affected
|
|
1832
1839
|
stats Index statistics
|
|
@@ -1913,7 +1920,7 @@ function executeInteractiveCommand(index, command, arg) {
|
|
|
1913
1920
|
console.log('Usage: context <name>');
|
|
1914
1921
|
return;
|
|
1915
1922
|
}
|
|
1916
|
-
const ctx = index.context(arg, { includeUncertain: flags.includeUncertain });
|
|
1923
|
+
const ctx = index.context(arg, { includeUncertain: flags.includeUncertain, includeMethods: flags.includeMethods });
|
|
1917
1924
|
if (!ctx) {
|
|
1918
1925
|
console.log(`Symbol "${arg}" not found.`);
|
|
1919
1926
|
} else {
|
|
@@ -2036,6 +2043,100 @@ function executeInteractiveCommand(index, command, arg) {
|
|
|
2036
2043
|
break;
|
|
2037
2044
|
}
|
|
2038
2045
|
|
|
2046
|
+
case 'expand': {
|
|
2047
|
+
if (!arg) {
|
|
2048
|
+
console.log('Usage: expand <number>');
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
const expandNum = parseInt(arg, 10);
|
|
2052
|
+
if (isNaN(expandNum)) {
|
|
2053
|
+
console.log(`Invalid item number: "${arg}"`);
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
const cached = loadExpandableItems(index.root);
|
|
2057
|
+
if (!cached || !cached.items || cached.items.length === 0) {
|
|
2058
|
+
console.log('No expandable items. Run context first.');
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
const expandMatch = cached.items.find(i => i.num === expandNum);
|
|
2062
|
+
if (!expandMatch) {
|
|
2063
|
+
console.log(`Item ${expandNum} not found. Available: 1-${cached.items.length}`);
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
printExpandedItem(expandMatch, cached.root || index.root);
|
|
2067
|
+
break;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
case 'deadcode': {
|
|
2071
|
+
const deadResult = index.deadcode({
|
|
2072
|
+
includeExported: flags.includeExported,
|
|
2073
|
+
includeDecorated: flags.includeDecorated,
|
|
2074
|
+
includeTests: flags.includeTests
|
|
2075
|
+
});
|
|
2076
|
+
console.log(output.formatDeadcode(deadResult));
|
|
2077
|
+
break;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
case 'related': {
|
|
2081
|
+
if (!arg) {
|
|
2082
|
+
console.log('Usage: related <name>');
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
const relResult = index.related(arg, { file: flags.file });
|
|
2086
|
+
console.log(output.formatRelated(relResult));
|
|
2087
|
+
break;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
case 'example': {
|
|
2091
|
+
if (!arg) {
|
|
2092
|
+
console.log('Usage: example <name>');
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
console.log(output.formatExample(index.example(arg), arg));
|
|
2096
|
+
break;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
case 'plan': {
|
|
2100
|
+
if (!arg) {
|
|
2101
|
+
console.log('Usage: plan <name> [--add-param=x] [--remove-param=x] [--rename-to=x]');
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
if (!flags.addParam && !flags.removeParam && !flags.renameTo) {
|
|
2105
|
+
console.log('Plan requires an operation: --add-param, --remove-param, or --rename-to');
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
const planResult = index.plan(arg, {
|
|
2109
|
+
addParam: flags.addParam,
|
|
2110
|
+
removeParam: flags.removeParam,
|
|
2111
|
+
renameTo: flags.renameTo,
|
|
2112
|
+
defaultValue: flags.defaultValue,
|
|
2113
|
+
file: flags.file
|
|
2114
|
+
});
|
|
2115
|
+
console.log(output.formatPlan(planResult));
|
|
2116
|
+
break;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
case 'verify': {
|
|
2120
|
+
if (!arg) {
|
|
2121
|
+
console.log('Usage: verify <name>');
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
const verifyResult = index.verify(arg, { file: flags.file });
|
|
2125
|
+
console.log(output.formatVerify(verifyResult));
|
|
2126
|
+
break;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
case 'stacktrace':
|
|
2130
|
+
case 'stack': {
|
|
2131
|
+
if (!arg) {
|
|
2132
|
+
console.log('Usage: stacktrace <stack text>');
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
const stackResult = index.parseStackTrace(arg);
|
|
2136
|
+
console.log(output.formatStackTrace(stackResult));
|
|
2137
|
+
break;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2039
2140
|
default:
|
|
2040
2141
|
console.log(`Unknown command: ${command}. Type "help" for available commands.`);
|
|
2041
2142
|
}
|
package/core/output.js
CHANGED
|
@@ -829,7 +829,7 @@ function formatRelated(related, options = {}) {
|
|
|
829
829
|
// Same file
|
|
830
830
|
let relatedTruncated = false;
|
|
831
831
|
if (related.sameFile.length > 0) {
|
|
832
|
-
const maxSameFile = options.showAll ? Infinity : 8;
|
|
832
|
+
const maxSameFile = options.top || (options.showAll ? Infinity : 8);
|
|
833
833
|
lines.push(`SAME FILE (${related.sameFile.length}):`);
|
|
834
834
|
for (const f of related.sameFile.slice(0, maxSameFile)) {
|
|
835
835
|
const params = f.params ? `(${f.params})` : '';
|
package/core/project.js
CHANGED
|
@@ -1412,7 +1412,8 @@ class ProjectIndex {
|
|
|
1412
1412
|
});
|
|
1413
1413
|
}
|
|
1414
1414
|
} catch (e) {
|
|
1415
|
-
//
|
|
1415
|
+
// Expected: minified files exceed tree-sitter buffer, binary files fail to parse.
|
|
1416
|
+
// These are not actionable errors — silently skip.
|
|
1416
1417
|
}
|
|
1417
1418
|
}
|
|
1418
1419
|
|
|
@@ -1848,6 +1849,8 @@ class ProjectIndex {
|
|
|
1848
1849
|
|
|
1849
1850
|
return result;
|
|
1850
1851
|
} catch (e) {
|
|
1852
|
+
// Expected: file read/parse failures (minified, binary, buffer exceeded).
|
|
1853
|
+
// Return empty callees rather than crashing the entire query.
|
|
1851
1854
|
return [];
|
|
1852
1855
|
}
|
|
1853
1856
|
}
|
|
@@ -1956,6 +1959,8 @@ class ProjectIndex {
|
|
|
1956
1959
|
cleanHtmlScriptTags(extracted, detectLanguage(symbol.file));
|
|
1957
1960
|
return extracted.join('\n');
|
|
1958
1961
|
} catch (e) {
|
|
1962
|
+
// Expected: file may have been deleted or become unreadable since indexing.
|
|
1963
|
+
// Return empty string rather than crashing.
|
|
1959
1964
|
return '';
|
|
1960
1965
|
}
|
|
1961
1966
|
}
|
|
@@ -2188,18 +2193,12 @@ class ProjectIndex {
|
|
|
2188
2193
|
* @returns {Array} Imports with resolved paths
|
|
2189
2194
|
*/
|
|
2190
2195
|
imports(filePath) {
|
|
2191
|
-
const
|
|
2192
|
-
|
|
2193
|
-
: path.join(this.root, filePath);
|
|
2196
|
+
const resolved = this.resolveFilePathForQuery(filePath);
|
|
2197
|
+
if (typeof resolved !== 'string') return resolved;
|
|
2194
2198
|
|
|
2199
|
+
const normalizedPath = resolved;
|
|
2195
2200
|
const fileEntry = this.files.get(normalizedPath);
|
|
2196
2201
|
if (!fileEntry) {
|
|
2197
|
-
// Try to find by relative path
|
|
2198
|
-
for (const [absPath, entry] of this.files) {
|
|
2199
|
-
if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
|
|
2200
|
-
return this.imports(absPath);
|
|
2201
|
-
}
|
|
2202
|
-
}
|
|
2203
2202
|
return { error: 'file-not-found', filePath };
|
|
2204
2203
|
}
|
|
2205
2204
|
|
|
@@ -2275,24 +2274,10 @@ class ProjectIndex {
|
|
|
2275
2274
|
* @returns {Array} Files that import this file
|
|
2276
2275
|
*/
|
|
2277
2276
|
exporters(filePath) {
|
|
2278
|
-
const
|
|
2279
|
-
|
|
2280
|
-
: path.join(this.root, filePath);
|
|
2277
|
+
const resolved = this.resolveFilePathForQuery(filePath);
|
|
2278
|
+
if (typeof resolved !== 'string') return resolved;
|
|
2281
2279
|
|
|
2282
|
-
|
|
2283
|
-
let targetPath = normalizedPath;
|
|
2284
|
-
if (!this.files.has(normalizedPath)) {
|
|
2285
|
-
for (const [absPath, entry] of this.files) {
|
|
2286
|
-
if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
|
|
2287
|
-
targetPath = absPath;
|
|
2288
|
-
break;
|
|
2289
|
-
}
|
|
2290
|
-
}
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
if (!this.files.has(targetPath)) {
|
|
2294
|
-
return { error: 'file-not-found', filePath };
|
|
2295
|
-
}
|
|
2280
|
+
const targetPath = resolved;
|
|
2296
2281
|
|
|
2297
2282
|
const importers = this.exportGraph.get(targetPath) || [];
|
|
2298
2283
|
|
|
@@ -2374,6 +2359,9 @@ class ProjectIndex {
|
|
|
2374
2359
|
// Note: no 'g' flag - we only need to test for presence per line
|
|
2375
2360
|
// The 'i' flag is kept for case-insensitive matching
|
|
2376
2361
|
const regex = new RegExp('\\b' + escapeRegExp(searchTerm) + '\\b', 'i');
|
|
2362
|
+
// Pre-compile patterns used inside per-line loop
|
|
2363
|
+
const callPattern = new RegExp(escapeRegExp(searchTerm) + '\\s*\\(');
|
|
2364
|
+
const strPattern = new RegExp("['\"`]" + escapeRegExp(searchTerm) + "['\"`]");
|
|
2377
2365
|
|
|
2378
2366
|
for (const { path: testPath, entry } of testFiles) {
|
|
2379
2367
|
try {
|
|
@@ -2388,12 +2376,11 @@ class ProjectIndex {
|
|
|
2388
2376
|
matchType = 'test-case';
|
|
2389
2377
|
} else if (/\b(import|require|from)\b/.test(line)) {
|
|
2390
2378
|
matchType = 'import';
|
|
2391
|
-
} else if (
|
|
2379
|
+
} else if (callPattern.test(line)) {
|
|
2392
2380
|
matchType = 'call';
|
|
2393
2381
|
}
|
|
2394
2382
|
// Detect if the match is inside a string literal (e.g., 'parseFile' or "parseFile")
|
|
2395
2383
|
if (matchType === 'reference' || matchType === 'call') {
|
|
2396
|
-
const strPattern = new RegExp("['\"`]" + escapeRegExp(searchTerm) + "['\"`]");
|
|
2397
2384
|
if (strPattern.test(line)) {
|
|
2398
2385
|
matchType = 'string-ref';
|
|
2399
2386
|
}
|
|
@@ -2432,7 +2419,18 @@ class ProjectIndex {
|
|
|
2432
2419
|
api(filePath, options = {}) {
|
|
2433
2420
|
const results = [];
|
|
2434
2421
|
|
|
2435
|
-
|
|
2422
|
+
let fileIterator;
|
|
2423
|
+
if (filePath) {
|
|
2424
|
+
const resolved = this.resolveFilePathForQuery(filePath);
|
|
2425
|
+
if (typeof resolved !== 'string') return resolved;
|
|
2426
|
+
const fileEntry = this.files.get(resolved);
|
|
2427
|
+
if (!fileEntry) return { error: 'file-not-found', filePath };
|
|
2428
|
+
fileIterator = [[resolved, fileEntry]];
|
|
2429
|
+
} else {
|
|
2430
|
+
fileIterator = this.files.entries();
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
for (const [absPath, fileEntry] of fileIterator) {
|
|
2436
2434
|
if (!fileEntry) continue;
|
|
2437
2435
|
|
|
2438
2436
|
// Skip test files by default (test classes aren't part of public API)
|
|
@@ -2487,9 +2485,12 @@ class ProjectIndex {
|
|
|
2487
2485
|
}
|
|
2488
2486
|
|
|
2489
2487
|
/**
|
|
2490
|
-
*
|
|
2488
|
+
* Resolve a file path query to an indexed file (with ambiguity detection)
|
|
2489
|
+
* @param {string} filePath - File path to resolve
|
|
2490
|
+
* @returns {string|{error: string, filePath: string, candidates?: string[]}}
|
|
2491
2491
|
*/
|
|
2492
|
-
|
|
2492
|
+
resolveFilePathForQuery(filePath) {
|
|
2493
|
+
// 1. Exact absolute/relative path match
|
|
2493
2494
|
const normalizedPath = path.isAbsolute(filePath)
|
|
2494
2495
|
? filePath
|
|
2495
2496
|
: path.join(this.root, filePath);
|
|
@@ -2498,13 +2499,34 @@ class ProjectIndex {
|
|
|
2498
2499
|
return normalizedPath;
|
|
2499
2500
|
}
|
|
2500
2501
|
|
|
2501
|
-
//
|
|
2502
|
+
// 2. Collect ALL suffix/partial candidates
|
|
2503
|
+
const candidates = [];
|
|
2502
2504
|
for (const [absPath, entry] of this.files) {
|
|
2503
|
-
if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
|
|
2504
|
-
|
|
2505
|
+
if (entry.relativePath === filePath || absPath.endsWith('/' + filePath)) {
|
|
2506
|
+
candidates.push(absPath);
|
|
2505
2507
|
}
|
|
2506
2508
|
}
|
|
2507
2509
|
|
|
2510
|
+
if (candidates.length === 0) {
|
|
2511
|
+
return { error: 'file-not-found', filePath };
|
|
2512
|
+
}
|
|
2513
|
+
if (candidates.length === 1) {
|
|
2514
|
+
return candidates[0];
|
|
2515
|
+
}
|
|
2516
|
+
return {
|
|
2517
|
+
error: 'file-ambiguous',
|
|
2518
|
+
filePath,
|
|
2519
|
+
candidates: candidates.map(c => this.files.get(c)?.relativePath || path.relative(this.root, c))
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
/**
|
|
2524
|
+
* Find a file by path (supports partial paths)
|
|
2525
|
+
* Backward-compatible wrapper — returns null on error.
|
|
2526
|
+
*/
|
|
2527
|
+
findFile(filePath) {
|
|
2528
|
+
const result = this.resolveFilePathForQuery(filePath);
|
|
2529
|
+
if (typeof result === 'string') return result;
|
|
2508
2530
|
return null;
|
|
2509
2531
|
}
|
|
2510
2532
|
|
|
@@ -2514,11 +2536,10 @@ class ProjectIndex {
|
|
|
2514
2536
|
* @returns {Array} Exported symbols from that file
|
|
2515
2537
|
*/
|
|
2516
2538
|
fileExports(filePath) {
|
|
2517
|
-
const
|
|
2518
|
-
if (
|
|
2519
|
-
return { error: 'file-not-found', filePath };
|
|
2520
|
-
}
|
|
2539
|
+
const resolved = this.resolveFilePathForQuery(filePath);
|
|
2540
|
+
if (typeof resolved !== 'string') return resolved;
|
|
2521
2541
|
|
|
2542
|
+
const absPath = resolved;
|
|
2522
2543
|
const fileEntry = this.files.get(absPath);
|
|
2523
2544
|
if (!fileEntry) {
|
|
2524
2545
|
return [];
|
|
@@ -2960,24 +2981,10 @@ class ProjectIndex {
|
|
|
2960
2981
|
const rawDepth = options.maxDepth ?? 5;
|
|
2961
2982
|
const maxDepth = Math.max(0, rawDepth);
|
|
2962
2983
|
|
|
2963
|
-
const
|
|
2964
|
-
|
|
2965
|
-
: path.resolve(this.root, filePath);
|
|
2966
|
-
|
|
2967
|
-
// Try to find file if not exact match
|
|
2968
|
-
let targetPath = absPath;
|
|
2969
|
-
if (!this.files.has(absPath)) {
|
|
2970
|
-
for (const [p, entry] of this.files) {
|
|
2971
|
-
if (entry.relativePath === filePath || p.endsWith(filePath)) {
|
|
2972
|
-
targetPath = p;
|
|
2973
|
-
break;
|
|
2974
|
-
}
|
|
2975
|
-
}
|
|
2976
|
-
}
|
|
2984
|
+
const resolved = this.resolveFilePathForQuery(filePath);
|
|
2985
|
+
if (typeof resolved !== 'string') return resolved;
|
|
2977
2986
|
|
|
2978
|
-
|
|
2979
|
-
return { error: 'file-not-found', filePath };
|
|
2980
|
-
}
|
|
2987
|
+
const targetPath = resolved;
|
|
2981
2988
|
|
|
2982
2989
|
const buildSubgraph = (dir) => {
|
|
2983
2990
|
const visited = new Set();
|
|
@@ -3176,7 +3183,8 @@ class ProjectIndex {
|
|
|
3176
3183
|
}
|
|
3177
3184
|
// Sort by number of shared parts
|
|
3178
3185
|
related.similarNames.sort((a, b) => b.sharedParts.length - a.sharedParts.length);
|
|
3179
|
-
|
|
3186
|
+
const similarLimit = options.top || (options.all ? Infinity : 10);
|
|
3187
|
+
if (related.similarNames.length > similarLimit) related.similarNames = related.similarNames.slice(0, similarLimit);
|
|
3180
3188
|
|
|
3181
3189
|
// 3. Shared callers - functions called by the same callers
|
|
3182
3190
|
const myCallers = new Set(this.findCallers(name).map(c => c.callerName).filter(Boolean));
|
|
@@ -3194,7 +3202,7 @@ class ProjectIndex {
|
|
|
3194
3202
|
}
|
|
3195
3203
|
}
|
|
3196
3204
|
// Sort by shared caller count
|
|
3197
|
-
const maxShared = options.all ? Infinity : 5;
|
|
3205
|
+
const maxShared = options.top || (options.all ? Infinity : 5);
|
|
3198
3206
|
const sorted = Array.from(callerCounts.entries())
|
|
3199
3207
|
.sort((a, b) => b[1] - a[1])
|
|
3200
3208
|
.slice(0, maxShared);
|
|
@@ -3230,7 +3238,7 @@ class ProjectIndex {
|
|
|
3230
3238
|
// Sort by shared callee count
|
|
3231
3239
|
const sorted = Array.from(calleeCounts.entries())
|
|
3232
3240
|
.sort((a, b) => b[1] - a[1])
|
|
3233
|
-
.slice(0, options.all ? Infinity : 5);
|
|
3241
|
+
.slice(0, options.top || (options.all ? Infinity : 5));
|
|
3234
3242
|
for (const [symName, count] of sorted) {
|
|
3235
3243
|
const sym = this.symbols.get(symName)?.[0];
|
|
3236
3244
|
if (sym) {
|
|
@@ -4408,7 +4416,8 @@ class ProjectIndex {
|
|
|
4408
4416
|
});
|
|
4409
4417
|
}
|
|
4410
4418
|
} catch (e) {
|
|
4411
|
-
//
|
|
4419
|
+
// Expected: binary/minified files fail to read or parse.
|
|
4420
|
+
// These are not actionable errors — silently skip.
|
|
4412
4421
|
}
|
|
4413
4422
|
}
|
|
4414
4423
|
|
package/mcp/server.js
CHANGED
|
@@ -158,6 +158,31 @@ function toolError(message) {
|
|
|
158
158
|
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Resolve a file path via index and validate it's within the project root.
|
|
163
|
+
* Returns the resolved absolute path string, or a toolError response.
|
|
164
|
+
*/
|
|
165
|
+
function resolveAndValidatePath(index, file) {
|
|
166
|
+
const resolved = index.resolveFilePathForQuery(file);
|
|
167
|
+
if (typeof resolved !== 'string') {
|
|
168
|
+
if (resolved.error === 'file-ambiguous') {
|
|
169
|
+
return toolError(`Ambiguous file "${file}". Candidates:\n${resolved.candidates.map(c => ' ' + c).join('\n')}`);
|
|
170
|
+
}
|
|
171
|
+
return toolError(`File not found: ${file}`);
|
|
172
|
+
}
|
|
173
|
+
// Path boundary check: ensure resolved path is within the project root
|
|
174
|
+
try {
|
|
175
|
+
const realPath = fs.realpathSync(resolved);
|
|
176
|
+
const realRoot = fs.realpathSync(index.root);
|
|
177
|
+
if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
|
|
178
|
+
return toolError(`File is outside project root: ${file}`);
|
|
179
|
+
}
|
|
180
|
+
} catch (e) {
|
|
181
|
+
return toolError(`Cannot resolve file path: ${file}`);
|
|
182
|
+
}
|
|
183
|
+
return resolved;
|
|
184
|
+
}
|
|
185
|
+
|
|
161
186
|
function requireName(name) {
|
|
162
187
|
if (!name || !name.trim()) {
|
|
163
188
|
return toolError('Symbol name is required.');
|
|
@@ -371,9 +396,10 @@ server.registerTool(
|
|
|
371
396
|
const err = requireName(name);
|
|
372
397
|
if (err) return err;
|
|
373
398
|
const index = getIndex(project_dir);
|
|
374
|
-
const result = index.related(name, { file, all: top !== undefined });
|
|
399
|
+
const result = index.related(name, { file, top, all: top !== undefined });
|
|
375
400
|
return toolResult(output.formatRelated(result, {
|
|
376
401
|
showAll: top !== undefined,
|
|
402
|
+
top,
|
|
377
403
|
allHint: 'Repeat with top set higher to show all.'
|
|
378
404
|
}));
|
|
379
405
|
}
|
|
@@ -464,6 +490,9 @@ server.registerTool(
|
|
|
464
490
|
}
|
|
465
491
|
|
|
466
492
|
const match = matches.length > 1 ? pickBestDefinition(matches) : matches[0];
|
|
493
|
+
// Validate file is within project root
|
|
494
|
+
const fnPathCheck = resolveAndValidatePath(index, match.relativePath || path.relative(index.root, match.file));
|
|
495
|
+
if (typeof fnPathCheck !== 'string') return fnPathCheck;
|
|
467
496
|
const code = fs.readFileSync(match.file, 'utf-8');
|
|
468
497
|
const codeLines = code.split('\n');
|
|
469
498
|
const fnCode = codeLines.slice(match.startLine - 1, match.endLine).join('\n');
|
|
@@ -489,6 +518,9 @@ server.registerTool(
|
|
|
489
518
|
}
|
|
490
519
|
|
|
491
520
|
const match = matches.length > 1 ? pickBestDefinition(matches) : matches[0];
|
|
521
|
+
// Validate file is within project root
|
|
522
|
+
const clsPathCheck = resolveAndValidatePath(index, match.relativePath || path.relative(index.root, match.file));
|
|
523
|
+
if (typeof clsPathCheck !== 'string') return clsPathCheck;
|
|
492
524
|
|
|
493
525
|
const code = fs.readFileSync(match.file, 'utf-8');
|
|
494
526
|
const codeLines = code.split('\n');
|
|
@@ -499,6 +531,10 @@ server.registerTool(
|
|
|
499
531
|
note = `Note: Found ${matches.length} definitions for "${name}". Showing ${match.relativePath}:${match.startLine}. Use file parameter to disambiguate.\n\n`;
|
|
500
532
|
}
|
|
501
533
|
|
|
534
|
+
if (max_lines !== undefined && (!Number.isInteger(max_lines) || max_lines < 1)) {
|
|
535
|
+
return toolError(`Invalid max_lines: ${max_lines}. Must be a positive integer.`);
|
|
536
|
+
}
|
|
537
|
+
|
|
502
538
|
const classLineCount = match.endLine - match.startLine + 1;
|
|
503
539
|
|
|
504
540
|
// Large class: show summary by default, truncated source with max_lines
|
|
@@ -537,10 +573,9 @@ server.registerTool(
|
|
|
537
573
|
return toolError('Line range is required (e.g. "10-20" or "15").');
|
|
538
574
|
}
|
|
539
575
|
const index = getIndex(project_dir);
|
|
540
|
-
const
|
|
541
|
-
if (
|
|
542
|
-
|
|
543
|
-
}
|
|
576
|
+
const resolved = resolveAndValidatePath(index, file);
|
|
577
|
+
if (typeof resolved !== 'string') return resolved; // toolError response
|
|
578
|
+
const filePath = resolved;
|
|
544
579
|
|
|
545
580
|
const parts = range.split('-');
|
|
546
581
|
const start = parseInt(parts[0], 10);
|
|
@@ -552,12 +587,18 @@ server.registerTool(
|
|
|
552
587
|
if (start < 1) {
|
|
553
588
|
return toolError(`Invalid start line: ${start}. Line numbers must be >= 1`);
|
|
554
589
|
}
|
|
590
|
+
if (end < 1) {
|
|
591
|
+
return toolError(`Invalid end line: ${end}. Line numbers must be >= 1`);
|
|
592
|
+
}
|
|
593
|
+
if (end < start) {
|
|
594
|
+
return toolError(`Invalid range: end line (${end}) must be >= start line (${start})`);
|
|
595
|
+
}
|
|
555
596
|
|
|
556
597
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
557
598
|
const fileLines = content.split('\n');
|
|
558
599
|
|
|
559
|
-
const startLine =
|
|
560
|
-
const endLine =
|
|
600
|
+
const startLine = start;
|
|
601
|
+
const endLine = end;
|
|
561
602
|
|
|
562
603
|
if (startLine > fileLines.length) {
|
|
563
604
|
return toolError(`Line ${startLine} is out of bounds. File has ${fileLines.length} lines.`);
|
|
@@ -616,6 +657,16 @@ server.registerTool(
|
|
|
616
657
|
if (!filePath || !fs.existsSync(filePath)) {
|
|
617
658
|
return toolError(`Cannot locate file for ${match.name}`);
|
|
618
659
|
}
|
|
660
|
+
// Validate file is within project root
|
|
661
|
+
try {
|
|
662
|
+
const realPath = fs.realpathSync(filePath);
|
|
663
|
+
const realRoot = fs.realpathSync(index.root);
|
|
664
|
+
if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
|
|
665
|
+
return toolError(`File is outside project root: ${match.name}`);
|
|
666
|
+
}
|
|
667
|
+
} catch (e) {
|
|
668
|
+
return toolError(`Cannot resolve file path for ${match.name}`);
|
|
669
|
+
}
|
|
619
670
|
|
|
620
671
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
621
672
|
const fileLines = content.split('\n');
|
|
@@ -645,6 +696,7 @@ server.registerTool(
|
|
|
645
696
|
const index = getIndex(project_dir);
|
|
646
697
|
const result = index.imports(file);
|
|
647
698
|
if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
|
|
699
|
+
if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
|
|
648
700
|
return toolResult(output.formatImports(result, file));
|
|
649
701
|
}
|
|
650
702
|
|
|
@@ -655,6 +707,7 @@ server.registerTool(
|
|
|
655
707
|
const index = getIndex(project_dir);
|
|
656
708
|
const result = index.exporters(file);
|
|
657
709
|
if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
|
|
710
|
+
if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
|
|
658
711
|
return toolResult(output.formatExporters(result, file));
|
|
659
712
|
}
|
|
660
713
|
|
|
@@ -665,6 +718,7 @@ server.registerTool(
|
|
|
665
718
|
const index = getIndex(project_dir);
|
|
666
719
|
const result = index.fileExports(file);
|
|
667
720
|
if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
|
|
721
|
+
if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
|
|
668
722
|
return toolResult(output.formatFileExports(result, file));
|
|
669
723
|
}
|
|
670
724
|
|
|
@@ -675,6 +729,7 @@ server.registerTool(
|
|
|
675
729
|
const index = getIndex(project_dir);
|
|
676
730
|
const result = index.graph(file, { direction: direction || 'both', maxDepth: depth ?? 2 });
|
|
677
731
|
if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
|
|
732
|
+
if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
|
|
678
733
|
return toolResult(output.formatGraph(result, {
|
|
679
734
|
showAll: depth !== undefined,
|
|
680
735
|
file,
|
|
@@ -713,6 +768,10 @@ server.registerTool(
|
|
|
713
768
|
}
|
|
714
769
|
|
|
715
770
|
case 'diff_impact': {
|
|
771
|
+
// Validate git ref format to prevent argument injection
|
|
772
|
+
if (base && !/^[a-zA-Z0-9._\-~\/^@{}:]+$/.test(base)) {
|
|
773
|
+
return toolError(`Invalid git ref format: ${base}`);
|
|
774
|
+
}
|
|
716
775
|
const index = getIndex(project_dir);
|
|
717
776
|
const result = index.diffImpact({
|
|
718
777
|
base: base || 'HEAD',
|
|
@@ -745,8 +804,10 @@ server.registerTool(
|
|
|
745
804
|
|
|
746
805
|
case 'api': {
|
|
747
806
|
const index = getIndex(project_dir);
|
|
748
|
-
const
|
|
749
|
-
|
|
807
|
+
const result = index.api(file || undefined);
|
|
808
|
+
if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
|
|
809
|
+
if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
|
|
810
|
+
return toolResult(output.formatApi(result, file || '.'));
|
|
750
811
|
}
|
|
751
812
|
|
|
752
813
|
case 'stats': {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucn",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.5",
|
|
4
4
|
"description": "Universal Code Navigator — AST-based call graph analysis for AI agents. Find callers, trace impact, detect dead code across JS/TS, Python, Go, Rust, Java, and HTML. CLI, MCP server, and agent skill.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/test/mcp-edge-cases.js
CHANGED
|
@@ -358,6 +358,112 @@ const tests = [
|
|
|
358
358
|
desc: 'lines - extract lines 1-5 from discovery.js',
|
|
359
359
|
args: { command: 'lines', project_dir: PROJECT_DIR, file: 'core/discovery.js', range: '1-5' }
|
|
360
360
|
},
|
|
361
|
+
|
|
362
|
+
// ========================================================================
|
|
363
|
+
// Correctness Assertions
|
|
364
|
+
// ========================================================================
|
|
365
|
+
{
|
|
366
|
+
category: 'Correctness',
|
|
367
|
+
tool: 'ucn',
|
|
368
|
+
desc: 'api(file=nonexistent) returns isError',
|
|
369
|
+
args: { command: 'api', project_dir: PROJECT_DIR, file: 'nonexistent/path/to/file.js' },
|
|
370
|
+
assert: (res, text, isError) => isError === true || 'Expected isError: true for nonexistent file'
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
category: 'Correctness',
|
|
374
|
+
tool: 'ucn',
|
|
375
|
+
desc: 'api(file=nonexistent) message contains "not found"',
|
|
376
|
+
args: { command: 'api', project_dir: PROJECT_DIR, file: 'nonexistent.js' },
|
|
377
|
+
assert: (res, text, isError) => (isError && /not found/i.test(text)) || 'Expected file-not-found error message'
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
category: 'Correctness',
|
|
381
|
+
tool: 'ucn',
|
|
382
|
+
desc: 'lines(range="5-0") returns validation error',
|
|
383
|
+
args: { command: 'lines', project_dir: PROJECT_DIR, file: 'core/discovery.js', range: '5-0' },
|
|
384
|
+
assert: (res, text, isError) => isError === true || 'Expected isError: true for invalid range'
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
category: 'Correctness',
|
|
388
|
+
tool: 'ucn',
|
|
389
|
+
desc: 'class(max_lines=-1) returns validation error',
|
|
390
|
+
args: { command: 'class', project_dir: PROJECT_DIR, name: 'ProjectIndex', max_lines: -1 },
|
|
391
|
+
assert: (res, text, isError) => isError === true || 'Expected isError: true for negative max_lines'
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
category: 'Correctness',
|
|
395
|
+
tool: 'ucn',
|
|
396
|
+
desc: 'lines - unique partial file resolves successfully',
|
|
397
|
+
args: { command: 'lines', project_dir: PROJECT_DIR, file: 'core/discovery.js', range: '1-3' },
|
|
398
|
+
assert: (res, text, isError) => isError === false || 'Expected success for unique partial file'
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
category: 'Correctness',
|
|
402
|
+
tool: 'ucn',
|
|
403
|
+
desc: 'file_exports(file=utils.js) returns ambiguity error',
|
|
404
|
+
args: { command: 'file_exports', project_dir: PROJECT_DIR, file: 'utils.js' },
|
|
405
|
+
assert: (res, text, isError) => (isError && /ambiguous/i.test(text)) || 'Expected file-ambiguous error for utils.js'
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
category: 'Correctness',
|
|
409
|
+
tool: 'ucn',
|
|
410
|
+
desc: 'imports(file=utils.js) returns ambiguity error',
|
|
411
|
+
args: { command: 'imports', project_dir: PROJECT_DIR, file: 'utils.js' },
|
|
412
|
+
assert: (res, text, isError) => (isError && /ambiguous/i.test(text)) || 'Expected file-ambiguous error for utils.js'
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
// ========================================================================
|
|
416
|
+
// CATEGORY 3: Security (path traversal, argument injection)
|
|
417
|
+
// ========================================================================
|
|
418
|
+
{
|
|
419
|
+
category: 'Security',
|
|
420
|
+
tool: 'ucn',
|
|
421
|
+
desc: 'lines rejects path traversal (../../../../etc/passwd)',
|
|
422
|
+
args: { command: 'lines', project_dir: PROJECT_DIR, file: '../../../../etc/passwd', range: '1-5' },
|
|
423
|
+
assert: (res, text, isError) => (isError && (/not found/i.test(text) || /outside project/i.test(text))) || 'Expected error for path traversal'
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
category: 'Security',
|
|
427
|
+
tool: 'ucn',
|
|
428
|
+
desc: 'lines rejects path traversal (../../other-project/secret.js)',
|
|
429
|
+
args: { command: 'lines', project_dir: PROJECT_DIR, file: '../../other-project/secret.js', range: '1-5' },
|
|
430
|
+
assert: (res, text, isError) => (isError && (/not found/i.test(text) || /outside project/i.test(text))) || 'Expected error for path traversal'
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
category: 'Security',
|
|
434
|
+
tool: 'ucn',
|
|
435
|
+
desc: 'lines works with valid file',
|
|
436
|
+
args: { command: 'lines', project_dir: PROJECT_DIR, file: 'core/discovery.js', range: '1-3' },
|
|
437
|
+
assert: (res, text, isError) => (!isError && text.length > 0) || 'Expected valid output for core/discovery.js'
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
category: 'Security',
|
|
441
|
+
tool: 'ucn',
|
|
442
|
+
desc: 'diff_impact rejects --config argument injection',
|
|
443
|
+
args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: '--config=malicious' },
|
|
444
|
+
assert: (res, text, isError) => (isError && /invalid git ref/i.test(text)) || 'Expected error for argument injection in base'
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
category: 'Security',
|
|
448
|
+
tool: 'ucn',
|
|
449
|
+
desc: 'diff_impact rejects -o flag injection',
|
|
450
|
+
args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: '-o /tmp/evil' },
|
|
451
|
+
assert: (res, text, isError) => (isError && /invalid git ref/i.test(text)) || 'Expected error for flag injection in base'
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
category: 'Security',
|
|
455
|
+
tool: 'ucn',
|
|
456
|
+
desc: 'diff_impact accepts valid ref HEAD~3',
|
|
457
|
+
args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: 'HEAD~3' },
|
|
458
|
+
assert: (res, text, isError) => true // Should not error on valid ref format
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
category: 'Security',
|
|
462
|
+
tool: 'ucn',
|
|
463
|
+
desc: 'diff_impact accepts valid ref origin/main',
|
|
464
|
+
args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: 'origin/main' },
|
|
465
|
+
assert: (res, text, isError) => true // Should not error on valid ref format
|
|
466
|
+
},
|
|
361
467
|
];
|
|
362
468
|
|
|
363
469
|
// ============================================================================
|
|
@@ -406,6 +512,15 @@ async function run() {
|
|
|
406
512
|
const preview = text.substring(0, 120).replace(/\n/g, '\\n');
|
|
407
513
|
status = 'PASS';
|
|
408
514
|
detail = `${isError ? 'ERROR response' : 'OK'}: "${preview}" (${elapsed}ms)`;
|
|
515
|
+
|
|
516
|
+
// Run assertion if provided
|
|
517
|
+
if (t.assert && status === 'PASS') {
|
|
518
|
+
const assertResult = t.assert(res, text, isError);
|
|
519
|
+
if (assertResult !== true) {
|
|
520
|
+
status = 'FAIL';
|
|
521
|
+
detail = `ASSERTION: ${assertResult} (${elapsed}ms)`;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
409
524
|
} else {
|
|
410
525
|
status = 'PASS';
|
|
411
526
|
detail = `Empty result (${elapsed}ms)`;
|
package/test/parser.test.js
CHANGED
|
@@ -13025,5 +13025,59 @@ it('FIX 99 — parseDiff handles quoted paths with special characters', () => {
|
|
|
13025
13025
|
'Literal backslash-n in filename must be preserved, not converted to newline');
|
|
13026
13026
|
});
|
|
13027
13027
|
|
|
13028
|
+
// ============================================================================
|
|
13029
|
+
// Interactive Mode Tests (Fix #100 — missing commands)
|
|
13030
|
+
// ============================================================================
|
|
13031
|
+
|
|
13032
|
+
describe('Interactive Mode', () => {
|
|
13033
|
+
const { execFileSync } = require('child_process');
|
|
13034
|
+
const cliPath = path.join(__dirname, '..', 'cli', 'index.js');
|
|
13035
|
+
|
|
13036
|
+
it('supports all commands without errors', () => {
|
|
13037
|
+
// Test each previously-missing command by piping into interactive mode
|
|
13038
|
+
// Each should not crash and should produce some output (not "Unknown command")
|
|
13039
|
+
const commands = [
|
|
13040
|
+
'deadcode',
|
|
13041
|
+
'related processData',
|
|
13042
|
+
'example processData',
|
|
13043
|
+
'verify processData',
|
|
13044
|
+
'expand 1', // Will say "no expandable items" but won't crash
|
|
13045
|
+
];
|
|
13046
|
+
|
|
13047
|
+
const input = commands.join('\n') + '\nquit\n';
|
|
13048
|
+
|
|
13049
|
+
const result = execFileSync('node', [cliPath, '--interactive', '.'], {
|
|
13050
|
+
input,
|
|
13051
|
+
encoding: 'utf-8',
|
|
13052
|
+
cwd: path.join(__dirname, '..'),
|
|
13053
|
+
timeout: 30000,
|
|
13054
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
13055
|
+
});
|
|
13056
|
+
|
|
13057
|
+
// Verify no "Unknown command" errors for the previously-missing commands
|
|
13058
|
+
assert.ok(!result.includes('Unknown command: deadcode'), 'deadcode should be recognized in interactive mode');
|
|
13059
|
+
assert.ok(!result.includes('Unknown command: related'), 'related should be recognized in interactive mode');
|
|
13060
|
+
assert.ok(!result.includes('Unknown command: example'), 'example should be recognized in interactive mode');
|
|
13061
|
+
assert.ok(!result.includes('Unknown command: verify'), 'verify should be recognized in interactive mode');
|
|
13062
|
+
assert.ok(!result.includes('Unknown command: expand'), 'expand should be recognized in interactive mode');
|
|
13063
|
+
});
|
|
13064
|
+
|
|
13065
|
+
it('help lists all commands', () => {
|
|
13066
|
+
const result = execFileSync('node', [cliPath, '--interactive', '.'], {
|
|
13067
|
+
input: 'help\nquit\n',
|
|
13068
|
+
encoding: 'utf-8',
|
|
13069
|
+
cwd: path.join(__dirname, '..'),
|
|
13070
|
+
timeout: 30000,
|
|
13071
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
13072
|
+
});
|
|
13073
|
+
|
|
13074
|
+
// Verify the help text includes all commands (including newly added ones)
|
|
13075
|
+
const expectedCommands = ['expand', 'deadcode', 'related', 'example', 'verify', 'plan', 'stacktrace'];
|
|
13076
|
+
for (const cmd of expectedCommands) {
|
|
13077
|
+
assert.ok(result.includes(cmd), `Interactive help should list "${cmd}"`);
|
|
13078
|
+
}
|
|
13079
|
+
});
|
|
13080
|
+
});
|
|
13081
|
+
|
|
13028
13082
|
console.log('UCN v3 Test Suite');
|
|
13029
13083
|
console.log('Run with: node --test test/parser.test.js');
|