ucn 4.2.3 → 5.0.2
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/.claude/skills/ucn/SKILL.md +89 -77
- package/.claude/skills/ucn/references/commands.md +62 -68
- package/.claude/skills/ucn/references/trust-contract.md +31 -6
- package/README.md +438 -305
- package/assets/demo.svg +31 -0
- package/cli/index.js +430 -1385
- package/core/account.js +144 -34
- package/core/analysis.js +182 -72
- package/core/ast-analysis.js +279 -0
- package/core/bridge.js +205 -24
- package/core/brief.js +27 -58
- package/core/build-worker.js +21 -140
- package/core/cache.js +513 -11
- package/core/callers.js +4920 -456
- package/core/check.js +13 -4
- package/core/command-contracts.js +402 -0
- package/core/compilation-database.js +276 -0
- package/core/confidence.js +4 -1
- package/core/deadcode.js +397 -19
- package/core/discovery.js +359 -46
- package/core/entrypoints.js +195 -41
- package/core/execute.js +887 -81
- package/core/graph-build.js +162 -7
- package/core/graph.js +53 -77
- package/core/imports.js +65 -6
- package/core/index-ir.js +138 -0
- package/core/ir.js +195 -0
- package/core/output/analysis.js +212 -22
- package/core/output/brief.js +23 -0
- package/core/output/check.js +4 -0
- package/core/output/doctor.js +37 -6
- package/core/output/endpoints.js +5 -2
- package/core/output/extraction.js +24 -12
- package/core/output/find.js +141 -36
- package/core/output/graph.js +11 -5
- package/core/output/public.js +462 -0
- package/core/output/refactoring.js +42 -10
- package/core/output/reporting.js +97 -20
- package/core/output/search.js +24 -16
- package/core/output/shared.js +22 -1
- package/core/output/tracing.js +30 -15
- package/core/output-budget.js +295 -0
- package/core/output.js +1 -0
- package/core/parallel-build.js +44 -11
- package/core/parser.js +3 -3
- package/core/project.js +384 -187
- package/core/public-command.js +47 -0
- package/core/registry.js +247 -117
- package/core/reporting.js +312 -290
- package/core/search.js +317 -185
- package/core/semantic-provider.js +110 -0
- package/core/stacktrace.js +25 -0
- package/core/tracing.js +101 -51
- package/core/trust-matrix.js +19 -40
- package/core/verify.js +534 -37
- package/languages/adapter.js +218 -0
- package/languages/c-family.js +2791 -0
- package/languages/c.js +3 -0
- package/languages/cpp.js +3 -0
- package/languages/csharp.js +1402 -0
- package/languages/go.js +60 -21
- package/languages/html.js +2 -2
- package/languages/index.js +85 -7
- package/languages/java.js +396 -13
- package/languages/javascript.js +199 -19
- package/languages/python.js +964 -22
- package/languages/rust.js +1317 -152
- package/languages/utils.js +40 -3
- package/mcp/server.js +254 -636
- package/package.json +39 -22
- package/eslint.config.js +0 -43
- package/jsconfig.json +0 -10
package/core/discovery.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
|
+
const { execFileSync } = require('child_process');
|
|
9
10
|
const { langTraits } = require('../languages');
|
|
10
11
|
|
|
11
12
|
// Always ignore - unambiguous, never user code
|
|
@@ -29,11 +30,10 @@ const DEFAULT_IGNORES = [
|
|
|
29
30
|
'.eggs',
|
|
30
31
|
'*.egg-info',
|
|
31
32
|
|
|
32
|
-
//
|
|
33
|
-
|
|
33
|
+
// Framework-owned build outputs with unambiguous names. Generic names
|
|
34
|
+
// such as build/dist/out/coverage are intentionally NOT here: real,
|
|
35
|
+
// git-tracked source trees use them at arbitrary depths.
|
|
34
36
|
'*-dist',
|
|
35
|
-
'build',
|
|
36
|
-
'out',
|
|
37
37
|
'.next',
|
|
38
38
|
'next.lock',
|
|
39
39
|
'.nuxt',
|
|
@@ -47,8 +47,7 @@ const DEFAULT_IGNORES = [
|
|
|
47
47
|
'storybook-static',
|
|
48
48
|
'_site',
|
|
49
49
|
|
|
50
|
-
// Test/coverage
|
|
51
|
-
'coverage',
|
|
50
|
+
// Test/coverage tool metadata
|
|
52
51
|
'.nyc_output',
|
|
53
52
|
'.pytest_cache',
|
|
54
53
|
'.mypy_cache',
|
|
@@ -85,7 +84,10 @@ const PROJECT_MARKERS = [
|
|
|
85
84
|
'Cargo.toml',
|
|
86
85
|
'pom.xml',
|
|
87
86
|
'build.gradle',
|
|
88
|
-
'Makefile'
|
|
87
|
+
'Makefile',
|
|
88
|
+
'CMakeLists.txt',
|
|
89
|
+
'compile_commands.json',
|
|
90
|
+
'meson.build'
|
|
89
91
|
];
|
|
90
92
|
|
|
91
93
|
// Test file patterns by language
|
|
@@ -93,13 +95,17 @@ const TEST_PATTERNS = {
|
|
|
93
95
|
javascript: [
|
|
94
96
|
/\.test\.(js|jsx|ts|tsx|mjs|cjs)$/,
|
|
95
97
|
/\.spec\.(js|jsx|ts|tsx|mjs|cjs)$/,
|
|
98
|
+
/(^|\/)(test|spec)\.(js|jsx|ts|tsx|mjs|cjs)$/,
|
|
96
99
|
/__tests__\//,
|
|
100
|
+
/(^|\/)tests?\//,
|
|
97
101
|
/\.test$/
|
|
98
102
|
],
|
|
99
103
|
typescript: [
|
|
100
104
|
/\.test\.(ts|tsx)$/,
|
|
101
105
|
/\.spec\.(ts|tsx)$/,
|
|
102
|
-
/
|
|
106
|
+
/(^|\/)(test|spec)\.(ts|tsx)$/,
|
|
107
|
+
/__tests__\//,
|
|
108
|
+
/(^|\/)tests?\//
|
|
103
109
|
],
|
|
104
110
|
python: [
|
|
105
111
|
/^test_.*\.py$/,
|
|
@@ -118,6 +124,21 @@ const TEST_PATTERNS = {
|
|
|
118
124
|
rust: [
|
|
119
125
|
/.*_test\.rs$/,
|
|
120
126
|
/(^|\/)tests\//
|
|
127
|
+
],
|
|
128
|
+
c: [
|
|
129
|
+
/(^|\/)tests?\//,
|
|
130
|
+
/(^|\/)test_.*\.(c|h)$/,
|
|
131
|
+
/.*(_test|\.test)\.(c|h)$/
|
|
132
|
+
],
|
|
133
|
+
cpp: [
|
|
134
|
+
/(^|\/)tests?\//,
|
|
135
|
+
/(^|\/)test_.*\.(cc|cpp|cxx|c\+\+|hpp|hh|hxx|h\+\+)$/,
|
|
136
|
+
/.*(_test|\.test)\.(cc|cpp|cxx|c\+\+|hpp|hh|hxx|h\+\+)$/
|
|
137
|
+
],
|
|
138
|
+
csharp: [
|
|
139
|
+
/(^|\/)tests?\//,
|
|
140
|
+
/.*Tests?\.cs$/,
|
|
141
|
+
/.*\.Tests\.cs$/
|
|
121
142
|
]
|
|
122
143
|
};
|
|
123
144
|
|
|
@@ -128,25 +149,25 @@ const TEST_PATTERNS = {
|
|
|
128
149
|
* @param {string} projectRoot - Project root directory
|
|
129
150
|
* @returns {string[]} - Array of ignore patterns (directory/file names and globs)
|
|
130
151
|
*/
|
|
131
|
-
function
|
|
132
|
-
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
133
|
-
if (!fs.existsSync(gitignorePath)) return [];
|
|
134
|
-
|
|
152
|
+
function parseGitignoreFile(projectRoot, gitignorePath) {
|
|
135
153
|
let content;
|
|
136
|
-
try {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return [];
|
|
140
|
-
}
|
|
141
|
-
|
|
154
|
+
try { content = fs.readFileSync(gitignorePath, 'utf-8'); } catch { return []; }
|
|
155
|
+
const baseRelative = path.relative(projectRoot, path.dirname(gitignorePath))
|
|
156
|
+
.replaceAll(path.sep, '/');
|
|
142
157
|
const patterns = [];
|
|
143
158
|
for (const rawLine of content.split('\n')) {
|
|
144
159
|
const line = rawLine.trim();
|
|
145
|
-
//
|
|
146
|
-
if (!line || line.startsWith('#')
|
|
160
|
+
// Keep negations and ordering: gitignore is a last-match-wins rule.
|
|
161
|
+
if (!line || line.startsWith('#')) continue;
|
|
147
162
|
|
|
148
|
-
|
|
149
|
-
|
|
163
|
+
const negated = line.startsWith('!');
|
|
164
|
+
const body = negated ? line.slice(1) : line;
|
|
165
|
+
if (!body) continue;
|
|
166
|
+
|
|
167
|
+
// Strip trailing slash (directory indicator) — shouldIgnore checks
|
|
168
|
+
// directories before descending, so an exact directory match covers
|
|
169
|
+
// its whole subtree.
|
|
170
|
+
let pattern = body.endsWith('/') ? body.slice(0, -1) : body;
|
|
150
171
|
|
|
151
172
|
// Leading slash = ANCHORED to the .gitignore's directory (fix #226).
|
|
152
173
|
// git semantics: `/locale` ignores only the root-level locale, never
|
|
@@ -158,23 +179,117 @@ function parseGitignore(projectRoot) {
|
|
|
158
179
|
const anchored = pattern.startsWith('/');
|
|
159
180
|
if (anchored) pattern = pattern.slice(1);
|
|
160
181
|
|
|
161
|
-
// Skip patterns with interior path separators — shouldIgnore matches
|
|
162
|
-
// single name segments, not full paths. Patterns like "foo/bar" would
|
|
163
|
-
// need walkDir-level support.
|
|
164
|
-
if (pattern.includes('/')) continue;
|
|
165
|
-
|
|
166
182
|
// Skip empty after stripping
|
|
167
183
|
if (!pattern) continue;
|
|
168
184
|
|
|
169
185
|
// Avoid duplicating built-in ignores
|
|
170
|
-
if (DEFAULT_IGNORES.includes(pattern)) continue;
|
|
186
|
+
if (!negated && DEFAULT_IGNORES.includes(pattern)) continue;
|
|
171
187
|
|
|
172
|
-
|
|
188
|
+
if (!baseRelative) {
|
|
189
|
+
const normalized = anchored ? '/' + pattern : pattern;
|
|
190
|
+
patterns.push((negated ? '!' : '') + normalized);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Nested .gitignore rules are scoped to their own directory. Flatten
|
|
195
|
+
// that scope into root-relative patterns for shouldIgnore(). A rule
|
|
196
|
+
// without a slash matches at every depth below its .gitignore; emit
|
|
197
|
+
// both the direct and descendant forms so the glob stays exact.
|
|
198
|
+
const scoped = `${baseRelative}/${pattern}`;
|
|
199
|
+
patterns.push((negated ? '!' : '') + scoped);
|
|
200
|
+
if (!anchored && !pattern.includes('/')) {
|
|
201
|
+
patterns.push((negated ? '!' : '') + `${baseRelative}/**/${pattern}`);
|
|
202
|
+
}
|
|
173
203
|
}
|
|
174
204
|
|
|
175
205
|
return patterns;
|
|
176
206
|
}
|
|
177
207
|
|
|
208
|
+
function fallbackGitignoreFiles(projectRoot) {
|
|
209
|
+
const found = [];
|
|
210
|
+
const visit = dir => {
|
|
211
|
+
let entries;
|
|
212
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
const fullPath = path.join(dir, entry.name);
|
|
215
|
+
if (entry.isFile() && entry.name === '.gitignore') found.push(fullPath);
|
|
216
|
+
if (!entry.isDirectory() || shouldIgnore(entry.name, DEFAULT_IGNORES, dir)) continue;
|
|
217
|
+
visit(fullPath);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
visit(projectRoot);
|
|
221
|
+
return found;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function hasGitMetadata(projectRoot) {
|
|
225
|
+
let dir = path.resolve(projectRoot);
|
|
226
|
+
const filesystemRoot = path.parse(dir).root;
|
|
227
|
+
while (true) {
|
|
228
|
+
if (fs.existsSync(path.join(dir, '.git'))) return true;
|
|
229
|
+
if (dir === filesystemRoot) return false;
|
|
230
|
+
dir = path.dirname(dir);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function gitignoreFiles(projectRoot) {
|
|
235
|
+
if (!hasGitMetadata(projectRoot)) return fallbackGitignoreFiles(projectRoot);
|
|
236
|
+
try {
|
|
237
|
+
const output = execFileSync('git', [
|
|
238
|
+
'-C', projectRoot, 'ls-files', '-z', '--cached', '--others',
|
|
239
|
+
'--exclude-standard', '--', '.gitignore', '**/.gitignore',
|
|
240
|
+
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
241
|
+
return output.split('\0').filter(Boolean)
|
|
242
|
+
.map(relative => path.resolve(projectRoot, relative))
|
|
243
|
+
.filter(file => file === projectRoot || file.startsWith(projectRoot + path.sep));
|
|
244
|
+
} catch {
|
|
245
|
+
return fallbackGitignoreFiles(projectRoot);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Parse the root and every applicable nested .gitignore, preserving Git's
|
|
251
|
+
* parent-before-child override order.
|
|
252
|
+
*/
|
|
253
|
+
function parseGitignore(projectRoot) {
|
|
254
|
+
const root = path.resolve(projectRoot);
|
|
255
|
+
const files = gitignoreFiles(root).sort((a, b) => {
|
|
256
|
+
const depthA = path.relative(root, a).split(path.sep).length;
|
|
257
|
+
const depthB = path.relative(root, b).split(path.sep).length;
|
|
258
|
+
return depthA - depthB || compareNames(a, b);
|
|
259
|
+
});
|
|
260
|
+
const patterns = [];
|
|
261
|
+
for (const file of files) patterns.push(...parseGitignoreFile(root, file));
|
|
262
|
+
return patterns;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Return tracked files and their ancestor directories, relative to root. */
|
|
266
|
+
function gitTrackedPaths(projectRoot) {
|
|
267
|
+
if (!hasGitMetadata(projectRoot)) {
|
|
268
|
+
return { files: new Set(), directories: new Set() };
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const output = execFileSync('git', [
|
|
272
|
+
'-C', projectRoot, 'ls-files', '-z', '--cached',
|
|
273
|
+
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
274
|
+
const files = new Set();
|
|
275
|
+
const directories = new Set();
|
|
276
|
+
for (const raw of output.split('\0')) {
|
|
277
|
+
if (!raw) continue;
|
|
278
|
+
const relative = path.normalize(raw).replaceAll(path.sep, '/');
|
|
279
|
+
if (relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) continue;
|
|
280
|
+
files.add(relative);
|
|
281
|
+
let parent = path.posix.dirname(relative);
|
|
282
|
+
while (parent && parent !== '.') {
|
|
283
|
+
directories.add(parent);
|
|
284
|
+
parent = path.posix.dirname(parent);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { files, directories };
|
|
288
|
+
} catch {
|
|
289
|
+
return { files: new Set(), directories: new Set() };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
178
293
|
function compareNames(a, b) {
|
|
179
294
|
const aLower = a.toLowerCase();
|
|
180
295
|
const bLower = b.toLowerCase();
|
|
@@ -199,9 +314,12 @@ function compareNames(a, b) {
|
|
|
199
314
|
function expandGlob(pattern, options = {}) {
|
|
200
315
|
const root = path.resolve(options.root || process.cwd());
|
|
201
316
|
const ignores = options.ignores || DEFAULT_IGNORES;
|
|
202
|
-
const maxDepth = options.maxDepth
|
|
203
|
-
const maxFiles = options.maxFiles
|
|
317
|
+
const maxDepth = options.maxDepth ?? 1000;
|
|
318
|
+
const maxFiles = options.maxFiles ?? 50000;
|
|
319
|
+
const maxFileSize = options.maxFileSize ?? (8 * 1024 * 1024);
|
|
204
320
|
const followSymlinks = options.followSymlinks !== false; // default true
|
|
321
|
+
const gitignorePatterns = options.gitignorePatterns || [];
|
|
322
|
+
const trackedPaths = options.trackedPaths || { files: new Set(), directories: new Set() };
|
|
205
323
|
|
|
206
324
|
// Handle home directory expansion
|
|
207
325
|
if (pattern.startsWith('~/')) {
|
|
@@ -217,14 +335,27 @@ function expandGlob(pattern, options = {}) {
|
|
|
217
335
|
filePattern,
|
|
218
336
|
recursive,
|
|
219
337
|
ignores,
|
|
338
|
+
gitignorePatterns,
|
|
339
|
+
trackedPaths,
|
|
220
340
|
maxDepth,
|
|
341
|
+
maxFileSize,
|
|
221
342
|
followSymlinks,
|
|
222
343
|
// Anchored gitignore patterns ('/name') apply only to entries directly
|
|
223
344
|
// under the project root — the .gitignore's own directory (fix #226).
|
|
224
345
|
anchorRoot: root,
|
|
346
|
+
onSkippedFile: options.onSkippedFile,
|
|
347
|
+
onDiscoveryIssue: options.onDiscoveryIssue,
|
|
348
|
+
disclosureIgnores: options.disclosureIgnores,
|
|
225
349
|
onFile: (filePath) => {
|
|
226
350
|
if (files.length < maxFiles) {
|
|
227
351
|
files.push(filePath);
|
|
352
|
+
} else if (options.onDiscoveryIssue) {
|
|
353
|
+
options.onDiscoveryIssue({
|
|
354
|
+
path: filePath,
|
|
355
|
+
kind: 'file',
|
|
356
|
+
reason: 'max-files',
|
|
357
|
+
detail: `discovery exceeded maxFiles=${maxFiles}`,
|
|
358
|
+
});
|
|
228
359
|
}
|
|
229
360
|
}
|
|
230
361
|
});
|
|
@@ -293,7 +424,13 @@ function globToRegex(glob) {
|
|
|
293
424
|
* Walk a directory tree, calling onFile for each matching file
|
|
294
425
|
*/
|
|
295
426
|
function walkDir(dir, options, depth = 0, visited = new Set()) {
|
|
296
|
-
if (depth > options.maxDepth)
|
|
427
|
+
if (depth > options.maxDepth) {
|
|
428
|
+
options.onDiscoveryIssue?.({
|
|
429
|
+
path: dir, kind: 'directory', reason: 'max-depth',
|
|
430
|
+
detail: `discovery exceeded maxDepth=${options.maxDepth}`,
|
|
431
|
+
});
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
297
434
|
if (!fs.existsSync(dir)) return;
|
|
298
435
|
|
|
299
436
|
// Track visited directories to avoid circular symlinks
|
|
@@ -301,6 +438,10 @@ function walkDir(dir, options, depth = 0, visited = new Set()) {
|
|
|
301
438
|
try {
|
|
302
439
|
realDir = fs.realpathSync(dir);
|
|
303
440
|
} catch (e) {
|
|
441
|
+
options.onDiscoveryIssue?.({
|
|
442
|
+
path: dir, kind: 'directory', reason: 'unreadable-directory',
|
|
443
|
+
detail: e.message,
|
|
444
|
+
});
|
|
304
445
|
return; // broken symlink
|
|
305
446
|
}
|
|
306
447
|
if (visited.has(realDir)) return;
|
|
@@ -310,6 +451,10 @@ function walkDir(dir, options, depth = 0, visited = new Set()) {
|
|
|
310
451
|
try {
|
|
311
452
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
312
453
|
} catch (e) {
|
|
454
|
+
options.onDiscoveryIssue?.({
|
|
455
|
+
path: dir, kind: 'directory', reason: 'unreadable-directory',
|
|
456
|
+
detail: e.message,
|
|
457
|
+
});
|
|
313
458
|
return;
|
|
314
459
|
}
|
|
315
460
|
|
|
@@ -320,7 +465,51 @@ function walkDir(dir, options, depth = 0, visited = new Set()) {
|
|
|
320
465
|
for (const entry of entries) {
|
|
321
466
|
const fullPath = path.join(dir, entry.name);
|
|
322
467
|
|
|
323
|
-
|
|
468
|
+
const entryIsDirectory = entry.isDirectory();
|
|
469
|
+
const policyIgnored = shouldIgnore(
|
|
470
|
+
entry.name,
|
|
471
|
+
options.ignores,
|
|
472
|
+
dir,
|
|
473
|
+
dir === options.anchorRoot,
|
|
474
|
+
options.anchorRoot,
|
|
475
|
+
fullPath,
|
|
476
|
+
entryIsDirectory,
|
|
477
|
+
);
|
|
478
|
+
const relative = options.anchorRoot
|
|
479
|
+
? path.relative(options.anchorRoot, fullPath).replaceAll(path.sep, '/')
|
|
480
|
+
: '';
|
|
481
|
+
const tracked = entryIsDirectory
|
|
482
|
+
? options.trackedPaths?.directories?.has(relative)
|
|
483
|
+
: options.trackedPaths?.files?.has(relative);
|
|
484
|
+
const gitIgnored = !tracked && options.gitignorePatterns?.length > 0 && shouldIgnore(
|
|
485
|
+
entry.name,
|
|
486
|
+
options.gitignorePatterns,
|
|
487
|
+
dir,
|
|
488
|
+
dir === options.anchorRoot,
|
|
489
|
+
options.anchorRoot,
|
|
490
|
+
fullPath,
|
|
491
|
+
entryIsDirectory,
|
|
492
|
+
);
|
|
493
|
+
if (policyIgnored || gitIgnored) {
|
|
494
|
+
if (options.disclosureIgnores && shouldIgnore(
|
|
495
|
+
entry.name,
|
|
496
|
+
options.disclosureIgnores,
|
|
497
|
+
dir,
|
|
498
|
+
dir === options.anchorRoot,
|
|
499
|
+
options.anchorRoot,
|
|
500
|
+
fullPath,
|
|
501
|
+
entryIsDirectory,
|
|
502
|
+
false,
|
|
503
|
+
)) {
|
|
504
|
+
options.onDiscoveryIssue?.({
|
|
505
|
+
path: fullPath,
|
|
506
|
+
kind: entryIsDirectory ? 'directory' : 'file',
|
|
507
|
+
reason: 'config-exclude',
|
|
508
|
+
detail: 'excluded by .ucn.json',
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
324
513
|
|
|
325
514
|
let isDir = entry.isDirectory();
|
|
326
515
|
let isFile = entry.isFile();
|
|
@@ -342,7 +531,25 @@ function walkDir(dir, options, depth = 0, visited = new Set()) {
|
|
|
342
531
|
}
|
|
343
532
|
} else if (isFile) {
|
|
344
533
|
if (options.filePattern.test(entry.name)) {
|
|
534
|
+
let size;
|
|
535
|
+
try { size = fs.statSync(fullPath).size; } catch (e) {
|
|
536
|
+
options.onDiscoveryIssue?.({
|
|
537
|
+
path: fullPath, kind: 'file', reason: 'unreadable-file',
|
|
538
|
+
detail: e.message,
|
|
539
|
+
});
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (size > options.maxFileSize) {
|
|
543
|
+
options.onDiscoveryIssue?.({
|
|
544
|
+
path: fullPath, kind: 'file', reason: 'max-file-size',
|
|
545
|
+
detail: `${size} bytes exceeds maxFileSize=${options.maxFileSize}`,
|
|
546
|
+
size,
|
|
547
|
+
});
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
345
550
|
options.onFile(fullPath);
|
|
551
|
+
} else if (options.onSkippedFile) {
|
|
552
|
+
options.onSkippedFile(fullPath);
|
|
346
553
|
}
|
|
347
554
|
}
|
|
348
555
|
}
|
|
@@ -357,31 +564,86 @@ function walkDir(dir, options, depth = 0, visited = new Set()) {
|
|
|
357
564
|
* the .gitignore belongs to. Default false errs toward KEEPING files.
|
|
358
565
|
* @param {string} [parentDir] - Parent directory path (for conditional checks)
|
|
359
566
|
* @param {boolean} [atAnchorRoot] - Whether parentDir IS the anchor root
|
|
567
|
+
* @param {string} [anchorRoot] - Discovery root for path-aware config ignores
|
|
568
|
+
* @param {string} [fullPath] - Candidate path for root-relative matching
|
|
360
569
|
*/
|
|
361
570
|
const _globRegexCache = new Map();
|
|
362
571
|
|
|
363
|
-
function shouldIgnore(
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
572
|
+
function shouldIgnore(
|
|
573
|
+
name,
|
|
574
|
+
ignores,
|
|
575
|
+
parentDir = null,
|
|
576
|
+
atAnchorRoot = false,
|
|
577
|
+
anchorRoot = null,
|
|
578
|
+
fullPath = null,
|
|
579
|
+
isDirectory = false,
|
|
580
|
+
includeConditional = true,
|
|
581
|
+
) {
|
|
582
|
+
let ignored = false;
|
|
583
|
+
for (const rawPattern of ignores) {
|
|
584
|
+
const negated = rawPattern.charCodeAt(0) === 33 /* ! */;
|
|
585
|
+
let pattern = negated ? rawPattern.slice(1) : rawPattern;
|
|
586
|
+
let matched = false;
|
|
587
|
+
if (pattern.includes('/') && anchorRoot && fullPath) {
|
|
588
|
+
const normalizedPattern = pattern.replace(/^\/+/, '')
|
|
589
|
+
.replaceAll('\\', '/');
|
|
590
|
+
const relative = path.relative(anchorRoot, fullPath)
|
|
591
|
+
.replaceAll(path.sep, '/');
|
|
592
|
+
let regex = _globRegexCache.get(`path:${normalizedPattern}`);
|
|
593
|
+
if (!regex) {
|
|
594
|
+
regex = globToRegex(normalizedPattern);
|
|
595
|
+
_globRegexCache.set(`path:${normalizedPattern}`, regex);
|
|
596
|
+
}
|
|
597
|
+
const directoryPrefix = normalizedPattern.endsWith('/**')
|
|
598
|
+
? normalizedPattern.slice(0, -3).replace(/\/+$/, '')
|
|
599
|
+
: null;
|
|
600
|
+
if (regex.test(relative) ||
|
|
601
|
+
(directoryPrefix && relative === directoryPrefix)) {
|
|
602
|
+
matched = true;
|
|
603
|
+
}
|
|
604
|
+
} else if (pattern.charCodeAt(0) === 47 /* '/' */) {
|
|
367
605
|
if (!atAnchorRoot) continue;
|
|
368
606
|
pattern = pattern.slice(1);
|
|
369
|
-
|
|
370
|
-
|
|
607
|
+
matched = pattern.includes('*')
|
|
608
|
+
? globToRegex(pattern).test(name)
|
|
609
|
+
: name === pattern;
|
|
610
|
+
} else if (pattern.includes('*')) {
|
|
371
611
|
let regex = _globRegexCache.get(pattern);
|
|
372
612
|
if (!regex) {
|
|
373
613
|
regex = globToRegex(pattern);
|
|
374
614
|
_globRegexCache.set(pattern, regex);
|
|
375
615
|
}
|
|
376
|
-
if (regex.test(name))
|
|
616
|
+
if (regex.test(name)) matched = true;
|
|
377
617
|
} else if (name === pattern) {
|
|
378
|
-
|
|
618
|
+
matched = true;
|
|
379
619
|
}
|
|
620
|
+
if (matched) ignored = !negated;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// A later negation can re-include a descendant. Keep walking a currently
|
|
624
|
+
// ignored directory whenever such a rule might apply below it.
|
|
625
|
+
if (ignored && isDirectory && anchorRoot && fullPath) {
|
|
626
|
+
const relative = path.relative(anchorRoot, fullPath).replaceAll(path.sep, '/');
|
|
627
|
+
const mayReincludeDescendant = ignores.some(raw => {
|
|
628
|
+
if (!raw.startsWith('!')) return false;
|
|
629
|
+
const pattern = raw.slice(1).replace(/^\/+/, '');
|
|
630
|
+
return pattern.includes('/') &&
|
|
631
|
+
(pattern.startsWith(relative + '/') || pattern.includes('**'));
|
|
632
|
+
});
|
|
633
|
+
if (mayReincludeDescendant) ignored = false;
|
|
634
|
+
}
|
|
635
|
+
if (ignored) {
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// A negated rule is only meaningful after a positive rule matched.
|
|
640
|
+
if (ignores.some(pattern => pattern.startsWith('!'))) {
|
|
641
|
+
// Fall through to conditional ignores.
|
|
380
642
|
}
|
|
381
643
|
|
|
382
644
|
// Check conditional ignores (only if parentDir provided)
|
|
383
645
|
// Use Array.isArray to avoid matching Object.prototype properties (e.g. dir named "constructor")
|
|
384
|
-
if (parentDir && Array.isArray(CONDITIONAL_IGNORES[name])) {
|
|
646
|
+
if (includeConditional && parentDir && Array.isArray(CONDITIONAL_IGNORES[name])) {
|
|
385
647
|
const markers = CONDITIONAL_IGNORES[name];
|
|
386
648
|
for (const marker of markers) {
|
|
387
649
|
if (fs.existsSync(path.join(parentDir, marker))) {
|
|
@@ -406,16 +668,60 @@ function findProjectRoot(startDir) {
|
|
|
406
668
|
return dir;
|
|
407
669
|
}
|
|
408
670
|
}
|
|
671
|
+
try {
|
|
672
|
+
const entries = fs.readdirSync(dir);
|
|
673
|
+
if (entries.some(name => /\.(csproj|sln|vcxproj)$/i.test(name))) {
|
|
674
|
+
return dir;
|
|
675
|
+
}
|
|
676
|
+
} catch (_) {
|
|
677
|
+
// Keep walking toward the filesystem root.
|
|
678
|
+
}
|
|
409
679
|
dir = path.dirname(dir);
|
|
410
680
|
}
|
|
411
681
|
|
|
412
682
|
return path.resolve(startDir);
|
|
413
683
|
}
|
|
414
684
|
|
|
415
|
-
// All file extensions
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
685
|
+
// All file extensions UCN analyzes. When manifests cannot identify the
|
|
686
|
+
// project, extension-based discovery remains the source of truth.
|
|
687
|
+
const ALL_SUPPORTED_EXTENSIONS = [
|
|
688
|
+
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'py', 'pyi', 'go', 'java', 'rs',
|
|
689
|
+
'c', 'h', 'cc', 'cpp', 'cxx', 'c++', 'hpp', 'hh', 'hxx', 'h++',
|
|
690
|
+
'cs', 'csx', 'html', 'htm',
|
|
691
|
+
];
|
|
692
|
+
|
|
693
|
+
// Source-like extensions that UCN deliberately does not parse. This is not an
|
|
694
|
+
// attempt to recognize every file in existence: it is the honest handoff set
|
|
695
|
+
// used by project-wide discovery so `repo`/`doctor` cannot report a clean,
|
|
696
|
+
// complete index while silently omitting a common programming language.
|
|
697
|
+
const UNSUPPORTED_SOURCE_EXTENSIONS = {
|
|
698
|
+
rb: 'Ruby', rake: 'Ruby',
|
|
699
|
+
php: 'PHP',
|
|
700
|
+
swift: 'Swift',
|
|
701
|
+
kt: 'Kotlin', kts: 'Kotlin',
|
|
702
|
+
scala: 'Scala', sc: 'Scala',
|
|
703
|
+
lua: 'Lua',
|
|
704
|
+
dart: 'Dart',
|
|
705
|
+
ex: 'Elixir', exs: 'Elixir',
|
|
706
|
+
erl: 'Erlang', hrl: 'Erlang',
|
|
707
|
+
clj: 'Clojure', cljs: 'ClojureScript', cljc: 'Clojure',
|
|
708
|
+
groovy: 'Groovy',
|
|
709
|
+
vb: 'Visual Basic', vbs: 'Visual Basic',
|
|
710
|
+
fs: 'F#', fsx: 'F#', fsi: 'F#',
|
|
711
|
+
hs: 'Haskell', lhs: 'Haskell',
|
|
712
|
+
zig: 'Zig',
|
|
713
|
+
sol: 'Solidity',
|
|
714
|
+
vue: 'Vue', svelte: 'Svelte',
|
|
715
|
+
sh: 'Shell', bash: 'Shell', zsh: 'Shell', fish: 'Shell',
|
|
716
|
+
ps1: 'PowerShell',
|
|
717
|
+
sql: 'SQL',
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
function classifyUnsupportedSourceFile(filePath) {
|
|
721
|
+
const extension = path.extname(filePath).slice(1).toLowerCase();
|
|
722
|
+
const language = UNSUPPORTED_SOURCE_EXTENSIONS[extension];
|
|
723
|
+
return language ? { extension, language } : null;
|
|
724
|
+
}
|
|
419
725
|
|
|
420
726
|
// Build-manifest hints: when present, we know the project has files of that language
|
|
421
727
|
// regardless of whether sources are visible at the time of scan. Used as hints, not gates —
|
|
@@ -430,6 +736,10 @@ const MANIFEST_HINTS = {
|
|
|
430
736
|
'pom.xml': ['java'],
|
|
431
737
|
'build.gradle': ['java'],
|
|
432
738
|
'build.gradle.kts': ['java'],
|
|
739
|
+
'Makefile': ['c', 'h', 'cc', 'cpp', 'cxx', 'hpp'],
|
|
740
|
+
'CMakeLists.txt': ['c', 'h', 'cc', 'cpp', 'cxx', 'hpp'],
|
|
741
|
+
'compile_commands.json': ['c', 'h', 'cc', 'cpp', 'cxx', 'hpp'],
|
|
742
|
+
'meson.build': ['c', 'h', 'cc', 'cpp', 'cxx', 'hpp'],
|
|
433
743
|
};
|
|
434
744
|
|
|
435
745
|
/**
|
|
@@ -585,10 +895,13 @@ module.exports = {
|
|
|
585
895
|
isTestFile,
|
|
586
896
|
findTestFileFor,
|
|
587
897
|
parseGitignore,
|
|
898
|
+
gitTrackedPaths,
|
|
588
899
|
DEFAULT_IGNORES,
|
|
589
900
|
PROJECT_MARKERS,
|
|
590
901
|
TEST_PATTERNS,
|
|
591
902
|
ALL_SUPPORTED_EXTENSIONS,
|
|
903
|
+
UNSUPPORTED_SOURCE_EXTENSIONS,
|
|
904
|
+
classifyUnsupportedSourceFile,
|
|
592
905
|
MANIFEST_HINTS,
|
|
593
906
|
compareNames
|
|
594
907
|
};
|