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.
Files changed (72) hide show
  1. package/.claude/skills/ucn/SKILL.md +89 -77
  2. package/.claude/skills/ucn/references/commands.md +62 -68
  3. package/.claude/skills/ucn/references/trust-contract.md +31 -6
  4. package/README.md +438 -305
  5. package/assets/demo.svg +31 -0
  6. package/cli/index.js +430 -1385
  7. package/core/account.js +144 -34
  8. package/core/analysis.js +182 -72
  9. package/core/ast-analysis.js +279 -0
  10. package/core/bridge.js +205 -24
  11. package/core/brief.js +27 -58
  12. package/core/build-worker.js +21 -140
  13. package/core/cache.js +513 -11
  14. package/core/callers.js +4920 -456
  15. package/core/check.js +13 -4
  16. package/core/command-contracts.js +402 -0
  17. package/core/compilation-database.js +276 -0
  18. package/core/confidence.js +4 -1
  19. package/core/deadcode.js +397 -19
  20. package/core/discovery.js +359 -46
  21. package/core/entrypoints.js +195 -41
  22. package/core/execute.js +887 -81
  23. package/core/graph-build.js +162 -7
  24. package/core/graph.js +53 -77
  25. package/core/imports.js +65 -6
  26. package/core/index-ir.js +138 -0
  27. package/core/ir.js +195 -0
  28. package/core/output/analysis.js +212 -22
  29. package/core/output/brief.js +23 -0
  30. package/core/output/check.js +4 -0
  31. package/core/output/doctor.js +37 -6
  32. package/core/output/endpoints.js +5 -2
  33. package/core/output/extraction.js +24 -12
  34. package/core/output/find.js +141 -36
  35. package/core/output/graph.js +11 -5
  36. package/core/output/public.js +462 -0
  37. package/core/output/refactoring.js +42 -10
  38. package/core/output/reporting.js +97 -20
  39. package/core/output/search.js +24 -16
  40. package/core/output/shared.js +22 -1
  41. package/core/output/tracing.js +30 -15
  42. package/core/output-budget.js +295 -0
  43. package/core/output.js +1 -0
  44. package/core/parallel-build.js +44 -11
  45. package/core/parser.js +3 -3
  46. package/core/project.js +384 -187
  47. package/core/public-command.js +47 -0
  48. package/core/registry.js +247 -117
  49. package/core/reporting.js +312 -290
  50. package/core/search.js +317 -185
  51. package/core/semantic-provider.js +110 -0
  52. package/core/stacktrace.js +25 -0
  53. package/core/tracing.js +101 -51
  54. package/core/trust-matrix.js +19 -40
  55. package/core/verify.js +534 -37
  56. package/languages/adapter.js +218 -0
  57. package/languages/c-family.js +2791 -0
  58. package/languages/c.js +3 -0
  59. package/languages/cpp.js +3 -0
  60. package/languages/csharp.js +1402 -0
  61. package/languages/go.js +60 -21
  62. package/languages/html.js +2 -2
  63. package/languages/index.js +85 -7
  64. package/languages/java.js +396 -13
  65. package/languages/javascript.js +199 -19
  66. package/languages/python.js +964 -22
  67. package/languages/rust.js +1317 -152
  68. package/languages/utils.js +40 -3
  69. package/mcp/server.js +254 -636
  70. package/package.json +39 -22
  71. package/eslint.config.js +0 -43
  72. 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
- // Build outputs
33
- 'dist',
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
- /__tests__\//
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 parseGitignore(projectRoot) {
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
- content = fs.readFileSync(gitignorePath, 'utf-8');
138
- } catch (e) {
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
- // Skip empty lines, comments, negation patterns
146
- if (!line || line.startsWith('#') || line.startsWith('!')) continue;
160
+ // Keep negations and ordering: gitignore is a last-match-wins rule.
161
+ if (!line || line.startsWith('#')) continue;
147
162
 
148
- // Strip trailing slash (directory indicator) — shouldIgnore checks names, not types
149
- let pattern = line.endsWith('/') ? line.slice(0, -1) : line;
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
- patterns.push(anchored ? '/' + pattern : pattern);
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 || 20;
203
- const maxFiles = options.maxFiles || 50000;
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) return;
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
- if (shouldIgnore(entry.name, options.ignores, dir, dir === options.anchorRoot)) continue;
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(name, ignores, parentDir, atAnchorRoot = false) {
364
- // Check unconditional ignores
365
- for (let pattern of ignores) {
366
- if (pattern.charCodeAt(0) === 47 /* '/' */) {
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
- if (pattern.includes('*')) {
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)) return true;
616
+ if (regex.test(name)) matched = true;
377
617
  } else if (name === pattern) {
378
- return true;
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 for languages UCN supports as code analysis (excludes .rb/.php/.c/.cpp etc.
416
- // which are extensions UCN scans but doesn't analyze). When build manifests can't tell us
417
- // what's in a project, we scan all of these — the file extension alone determines language.
418
- const ALL_SUPPORTED_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'py', 'go', 'java', 'rs', 'html', 'htm'];
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
  };