ucn 5.4.2 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.claude/skills/ucn/SKILL.md +58 -236
  2. package/.claude/skills/ucn/references/commands.md +144 -68
  3. package/.claude/skills/ucn/references/resolution-rules.md +171 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +27 -33
  5. package/README.md +18 -12
  6. package/cli/index.js +40 -14
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +30 -0
  9. package/core/analysis.js +508 -264
  10. package/core/ast-analysis.js +76 -117
  11. package/core/async-producers.js +1729 -0
  12. package/core/bridge.js +1877 -268
  13. package/core/brief.js +6 -0
  14. package/core/build-worker.js +78 -14
  15. package/core/cache.js +522 -31
  16. package/core/callers.js +12053 -1047
  17. package/core/check.js +30 -38
  18. package/core/class-identity.js +1028 -0
  19. package/core/command-contracts.js +6 -6
  20. package/core/configuration-items.js +86 -0
  21. package/core/contract-membership.js +2262 -0
  22. package/core/cpp-scope.js +532 -0
  23. package/core/deadcode.js +171 -49
  24. package/core/decorator-binding.js +260 -0
  25. package/core/discovery.js +281 -164
  26. package/core/errors.js +52 -0
  27. package/core/execute.js +21 -9
  28. package/core/external-macros.js +326 -0
  29. package/core/graph-build.js +277 -8
  30. package/core/graph.js +35 -2
  31. package/core/ignore-rules.js +472 -0
  32. package/core/imports.js +504 -42
  33. package/core/index-ir.js +94 -11
  34. package/core/inprocess-clients.js +636 -0
  35. package/core/ir.js +87 -8
  36. package/core/macro-expansion.js +1416 -0
  37. package/core/output/analysis.js +18 -2
  38. package/core/output/brief.js +3 -0
  39. package/core/output/doctor.js +4 -0
  40. package/core/output/endpoints.js +15 -6
  41. package/core/output/find.js +7 -2
  42. package/core/output/refactoring.js +121 -14
  43. package/core/output/reporting.js +31 -3
  44. package/core/output/shared.js +14 -0
  45. package/core/output-budget.js +305 -122
  46. package/core/parallel-build.js +124 -47
  47. package/core/parser.js +3 -2
  48. package/core/project.js +457 -60
  49. package/core/provenance-facts.js +156 -12
  50. package/core/provenance-overload.js +30 -3
  51. package/core/provenance.js +104 -30
  52. package/core/pytest-fixtures.js +434 -0
  53. package/core/python-fixture-flow.js +17 -3
  54. package/core/python-modules.js +173 -0
  55. package/core/reflection.js +877 -0
  56. package/core/registry.js +1 -1
  57. package/core/reporting.js +234 -52
  58. package/core/request-helpers.js +561 -0
  59. package/core/route-graph.js +1922 -0
  60. package/core/route-lists.js +2414 -0
  61. package/core/rust-macro-expansion.js +2113 -0
  62. package/core/rust-modules.js +185 -0
  63. package/core/search.js +121 -29
  64. package/core/shared.js +19 -1
  65. package/core/test-name-bindings.js +256 -0
  66. package/core/type-denotation.js +1161 -0
  67. package/core/type-references.js +3229 -0
  68. package/core/verify.js +3360 -672
  69. package/languages/adapter.js +31 -5
  70. package/languages/c-family.js +4061 -212
  71. package/languages/c-macro-invocations.js +380 -0
  72. package/languages/c-preprocessor.js +254 -0
  73. package/languages/csharp.js +1406 -49
  74. package/languages/go.js +1243 -70
  75. package/languages/index.js +728 -6
  76. package/languages/java.js +473 -61
  77. package/languages/javascript.js +1270 -197
  78. package/languages/lexical-scope.js +1280 -0
  79. package/languages/python.js +1557 -72
  80. package/languages/rust-macro-rules.js +1020 -0
  81. package/languages/rust-value-flow.js +416 -0
  82. package/languages/rust.js +2237 -115
  83. package/languages/utils.js +298 -4
  84. package/mcp/server.js +22 -8
  85. package/package.json +1 -1
package/core/discovery.js CHANGED
@@ -8,6 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const { execFileSync } = require('child_process');
10
10
  const { langTraits } = require('../languages');
11
+ const { IgnoreRules } = require('./ignore-rules');
11
12
 
12
13
  // Always ignore - unambiguous, never user code
13
14
  const DEFAULT_IGNORES = [
@@ -60,6 +61,11 @@ const DEFAULT_IGNORES = [
60
61
  // These can contain user code: disclose their exclusion, unlike dependency
61
62
  // and VCS directories. Source maps are data and cannot be parsed as code.
62
63
  const BUNDLED_PATTERNS = ['*.min.js', '*.bundle.js', '*.map'];
64
+ let _bundledList = null;
65
+ function bundledList() {
66
+ if (!_bundledList) _bundledList = compileIgnoreList(BUNDLED_PATTERNS);
67
+ return _bundledList;
68
+ }
63
69
 
64
70
  // Conditional ignores - only ignore when marker file exists in same directory
65
71
  // Maps directory name -> array of marker files that indicate it's a vendor dir
@@ -141,69 +147,6 @@ const TEST_PATTERNS = {
141
147
  ]
142
148
  };
143
149
 
144
- /**
145
- * Parse .gitignore file and return patterns compatible with shouldIgnore().
146
- * Handles simple directory/file patterns. Skips negation patterns and comments.
147
- *
148
- * @param {string} projectRoot - Project root directory
149
- * @returns {string[]} - Array of ignore patterns (directory/file names and globs)
150
- */
151
- function parseGitignoreFile(projectRoot, gitignorePath) {
152
- let content;
153
- try { content = fs.readFileSync(gitignorePath, 'utf-8'); } catch { return []; }
154
- const baseRelative = path.relative(projectRoot, path.dirname(gitignorePath))
155
- .replaceAll(path.sep, '/');
156
- const patterns = [];
157
- for (const rawLine of content.split('\n')) {
158
- const line = rawLine.trim();
159
- // Keep negations and ordering: gitignore is a last-match-wins rule.
160
- if (!line || line.startsWith('#')) continue;
161
-
162
- const negated = line.startsWith('!');
163
- const body = negated ? line.slice(1) : line;
164
- if (!body) continue;
165
-
166
- // Strip trailing slash (directory indicator) — shouldIgnore checks
167
- // directories before descending, so an exact directory match covers
168
- // its whole subtree.
169
- let pattern = body.endsWith('/') ? body.slice(0, -1) : body;
170
-
171
- // Leading slash = ANCHORED to the .gitignore's directory (fix #226).
172
- // git semantics: `/locale` ignores only the root-level locale, never
173
- // src/locale. Stripping the slash and matching by bare name silently
174
- // excluded real source trees (dayjs ignores its BUILD outputs /locale
175
- // /plugin — src/locale and src/plugin are ~160 tracked source files
176
- // UCN never indexed, with no warning). Anchored patterns keep the
177
- // slash; shouldIgnore applies them only at the walk's anchor root.
178
- const anchored = pattern.startsWith('/');
179
- if (anchored) pattern = pattern.slice(1);
180
-
181
- // Skip empty after stripping
182
- if (!pattern) continue;
183
-
184
- // Avoid duplicating built-in ignores
185
- if (!negated && DEFAULT_IGNORES.includes(pattern)) continue;
186
-
187
- if (!baseRelative) {
188
- const normalized = anchored ? '/' + pattern : pattern;
189
- patterns.push((negated ? '!' : '') + normalized);
190
- continue;
191
- }
192
-
193
- // Nested .gitignore rules are scoped to their own directory. Flatten
194
- // that scope into root-relative patterns for shouldIgnore(). A rule
195
- // without a slash matches at every depth below its .gitignore; emit
196
- // both the direct and descendant forms so the glob stays exact.
197
- const scoped = `${baseRelative}/${pattern}`;
198
- patterns.push((negated ? '!' : '') + scoped);
199
- if (!anchored && !pattern.includes('/')) {
200
- patterns.push((negated ? '!' : '') + `${baseRelative}/**/${pattern}`);
201
- }
202
- }
203
-
204
- return patterns;
205
- }
206
-
207
150
  function fallbackGitignoreFiles(projectRoot) {
208
151
  const found = [];
209
152
  const visit = dir => {
@@ -230,63 +173,117 @@ function hasGitMetadata(projectRoot) {
230
173
  }
231
174
  }
232
175
 
233
- function gitignoreFiles(projectRoot) {
234
- if (!hasGitMetadata(projectRoot)) return fallbackGitignoreFiles(projectRoot);
235
- try {
236
- const output = execFileSync('git', [
237
- '-C', projectRoot, 'ls-files', '-z', '--cached', '--others',
238
- '--exclude-standard', '--', '.gitignore', '**/.gitignore',
239
- ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
240
- return output.split('\0').filter(Boolean)
241
- .map(relative => path.resolve(projectRoot, relative))
242
- .filter(file => file === projectRoot || file.startsWith(projectRoot + path.sep));
243
- } catch {
244
- return fallbackGitignoreFiles(projectRoot);
245
- }
176
+ /** First line of a failed git invocation, for disclosure. */
177
+ function describeGitFailure(error) {
178
+ if (error && error.code === 'ENOENT') return 'git is not installed or not on PATH';
179
+ if (error && error.code === 'ENOBUFS') return 'git ls-files output exceeded the process output limit';
180
+ const stderr = error && error.stderr ? String(error.stderr).trim().split('\n')[0] : '';
181
+ if (stderr) return stderr;
182
+ if (error && typeof error.status === 'number') return `git ls-files exited with status ${error.status}`;
183
+ return (error && error.message) ? error.message.split('\n')[0] : 'git ls-files failed';
246
184
  }
247
185
 
248
186
  /**
249
- * Parse the root and every applicable nested .gitignore, preserving Git's
250
- * parent-before-child override order.
187
+ * One `git ls-files` for everything discovery asks git (fix #375): the
188
+ * tracked paths (which .gitignore rules never exclude) and the .gitignore
189
+ * files git honors, tracked or untracked-and-not-ignored. Tags tell tracked
190
+ * entries (`H`, `S`, `M`...) from untracked ones (`?`).
191
+ *
192
+ * Returns null when the project is not inside a git work tree. Otherwise the
193
+ * listing is complete whatever its size (fix #382: the output is read as a
194
+ * buffer with no size cap; a 1MB cap used to empty the tracked set on large
195
+ * repositories, silently dropping tracked files that match a .gitignore
196
+ * rule). When git itself fails, `failure` says why: the tracked set is then
197
+ * unknown, discovery falls back to the rules alone, and the caller records a
198
+ * `git-listing-failed` discovery issue, so the index is disclosed as
199
+ * possibly partial instead of silently changing its file set.
251
200
  */
252
- function parseGitignore(projectRoot) {
201
+ function gitListing(projectRoot) {
253
202
  const root = path.resolve(projectRoot);
254
- const files = gitignoreFiles(root).sort((a, b) => {
255
- const depthA = path.relative(root, a).split(path.sep).length;
256
- const depthB = path.relative(root, b).split(path.sep).length;
257
- return depthA - depthB || compareNames(a, b);
258
- });
259
- const patterns = [];
260
- for (const file of files) patterns.push(...parseGitignoreFile(root, file));
261
- return patterns;
262
- }
263
-
264
- /** Return tracked files and their ancestor directories, relative to root. */
265
- function gitTrackedPaths(projectRoot) {
266
- if (!hasGitMetadata(projectRoot)) {
267
- return { files: new Set(), directories: new Set() };
268
- }
203
+ if (!hasGitMetadata(root)) return null;
204
+ let output;
269
205
  try {
270
- const output = execFileSync('git', [
271
- '-C', projectRoot, 'ls-files', '-z', '--cached',
272
- ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
273
- const files = new Set();
274
- const directories = new Set();
275
- for (const raw of output.split('\0')) {
276
- if (!raw) continue;
277
- const relative = path.normalize(raw).replaceAll(path.sep, '/');
278
- if (relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) continue;
279
- files.add(relative);
280
- let parent = path.posix.dirname(relative);
281
- while (parent && parent !== '.') {
282
- directories.add(parent);
283
- parent = path.posix.dirname(parent);
206
+ output = execFileSync('git', [
207
+ '-C', root, 'ls-files', '-z', '-t', '--cached', '--others', '--exclude-standard',
208
+ ], { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: Infinity, windowsHide: true });
209
+ } catch (error) {
210
+ return {
211
+ tracked: { files: new Set(), directories: new Set() },
212
+ gitignores: null,
213
+ failure: { detail: describeGitFailure(error) },
214
+ };
215
+ }
216
+ const files = new Set();
217
+ const directories = new Set();
218
+ const gitignores = [];
219
+ let start = 0;
220
+ const length = output.length;
221
+ while (start < length) {
222
+ let end = output.indexOf(0, start);
223
+ if (end < 0) end = length;
224
+ // `<tag> <path>`: at least one tag byte, a space, one path byte.
225
+ if (end - start >= 3 && output[start + 1] === 0x20) {
226
+ const untracked = output[start] === 0x3f; // '?'
227
+ const raw = output.toString('utf8', start + 2, end);
228
+ // gitignoreFiles' pathspecs `.gitignore` (literal: the file, or a
229
+ // directory of that name) and `**/.gitignore`.
230
+ if (raw === '.gitignore' || raw.startsWith('.gitignore/') || raw.endsWith('/.gitignore')) {
231
+ const file = path.resolve(root, raw);
232
+ if (file === root || file.startsWith(root + path.sep)) gitignores.push(file);
233
+ }
234
+ if (!untracked) {
235
+ // git prints '/'-separated paths on every platform.
236
+ const relative = path.posix.normalize(raw);
237
+ if (relative !== '..' && !relative.startsWith('../') && !path.isAbsolute(relative)) {
238
+ files.add(relative);
239
+ let slash = relative.lastIndexOf('/');
240
+ while (slash > 0) {
241
+ const parent = relative.slice(0, slash);
242
+ if (directories.has(parent)) break;
243
+ directories.add(parent);
244
+ slash = parent.lastIndexOf('/');
245
+ }
246
+ }
284
247
  }
285
248
  }
286
- return { files, directories };
287
- } catch {
288
- return { files: new Set(), directories: new Set() };
249
+ start = end + 1;
289
250
  }
251
+ return { tracked: { files, directories }, gitignores, failure: null };
252
+ }
253
+
254
+ /**
255
+ * The project's .gitignore rules, compiled (core/ignore-rules.js): the root
256
+ * file and every applicable nested one, parent before child. `listing`
257
+ * (gitListing) supplies the .gitignore files when the caller already asked
258
+ * git; without git metadata (or when git failed) the tree is walked for them.
259
+ * @returns {IgnoreRules}
260
+ */
261
+ function parseGitignore(projectRoot, listing) {
262
+ const root = path.resolve(projectRoot);
263
+ if (listing === undefined) listing = gitListing(root);
264
+ const files = (listing?.gitignores ? [...listing.gitignores] : fallbackGitignoreFiles(root))
265
+ .map(file => ({ file, base: path.relative(root, path.dirname(file)).split(path.sep).join('/') }))
266
+ .sort((a, b) => {
267
+ const depthA = a.base ? a.base.split('/').length : 0;
268
+ const depthB = b.base ? b.base.split('/').length : 0;
269
+ return depthA - depthB || compareNames(a.file, b.file);
270
+ });
271
+ const sources = [];
272
+ for (const { file, base } of files) {
273
+ let content;
274
+ try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
275
+ sources.push({ base, content });
276
+ }
277
+ return new IgnoreRules(sources);
278
+ }
279
+
280
+ /**
281
+ * Return tracked files and their ancestor directories, relative to root
282
+ * (from `listing`, a gitListing result, when given).
283
+ */
284
+ function gitTrackedPaths(projectRoot, listing) {
285
+ if (listing === undefined) listing = gitListing(projectRoot);
286
+ return listing ? listing.tracked : { files: new Set(), directories: new Set() };
290
287
  }
291
288
 
292
289
  function compareNames(a, b) {
@@ -317,7 +314,7 @@ function expandGlob(pattern, options = {}) {
317
314
  const maxFiles = options.maxFiles ?? 50000;
318
315
  const maxFileSize = options.maxFileSize ?? (8 * 1024 * 1024);
319
316
  const followSymlinks = options.followSymlinks !== false; // default true
320
- const gitignorePatterns = options.gitignorePatterns || [];
317
+ const gitignoreRules = options.gitignoreRules || null;
321
318
  const trackedPaths = options.trackedPaths || { files: new Set(), directories: new Set() };
322
319
 
323
320
  // Handle home directory expansion
@@ -334,7 +331,8 @@ function expandGlob(pattern, options = {}) {
334
331
  filePattern,
335
332
  recursive,
336
333
  ignores,
337
- gitignorePatterns,
334
+ policy: compileIgnoreList(ignores),
335
+ gitignoreRules,
338
336
  trackedPaths,
339
337
  maxDepth,
340
338
  maxFileSize,
@@ -346,6 +344,7 @@ function expandGlob(pattern, options = {}) {
346
344
  onSkippedFile: options.onSkippedFile,
347
345
  onDiscoveryIssue: options.onDiscoveryIssue,
348
346
  disclosureIgnores: options.disclosureIgnores,
347
+ disclosure: options.disclosureIgnores?.length ? compileIgnoreList(options.disclosureIgnores) : null,
349
348
  onFile: (filePath) => {
350
349
  if (files.length < maxFiles) {
351
350
  files.push(filePath);
@@ -358,11 +357,122 @@ function expandGlob(pattern, options = {}) {
358
357
  });
359
358
  }
360
359
  }
361
- });
360
+ }, 0, new Set(), null, walkContext(root, baseDir, gitignoreRules));
362
361
 
363
362
  return files.sort(compareNames);
364
363
  }
365
364
 
365
+ /**
366
+ * Where a walk starts, relative to the project root: the '/'-separated path
367
+ * every entry's root-relative path extends (computed once per directory, never
368
+ * per rule), the .gitignore scope chain in effect, and whether a parent
369
+ * directory is already excluded.
370
+ */
371
+ function walkContext(root, baseDir, gitignoreRules) {
372
+ const relative = path.relative(root, path.resolve(baseDir)).split(path.sep).join('/');
373
+ const inside = relative === '' ||
374
+ (relative !== '..' && !relative.startsWith('../') && !path.isAbsolute(relative));
375
+ if (!inside) return { rel: relative, inside: false, segments: 0, scopes: [], excluded: false };
376
+ return {
377
+ rel: relative,
378
+ inside: true,
379
+ segments: relative ? relative.split('/').length : 0,
380
+ scopes: gitignoreRules ? gitignoreRules.chainFor(relative) : [],
381
+ excluded: !!(relative && gitignoreRules && gitignoreRules.isIgnored(relative, true)),
382
+ };
383
+ }
384
+
385
+ const LITERAL_NAME = 0;
386
+ const GLOB_NAME = 1;
387
+ const PATH_GLOB = 2;
388
+
389
+ /**
390
+ * A UCN ignore list (DEFAULT_IGNORES, .ucn.json `exclude`, bundled names)
391
+ * compiled once with shouldIgnore()'s semantics minus the conditional vendor
392
+ * checks: a pattern with '/' is a glob over the root-relative path (leading
393
+ * slashes dropped, `dir/**` also naming `dir`), others match the basename
394
+ * (literal or glob); the last match wins and a later `!pattern` re-includes.
395
+ */
396
+ function compileIgnoreList(patterns) {
397
+ const rules = [];
398
+ const literals = new Set();
399
+ const globs = [];
400
+ const paths = [];
401
+ const reincludes = [];
402
+ let hasNegation = false;
403
+ for (const raw of patterns || []) {
404
+ const negated = raw.charCodeAt(0) === 33 /* ! */;
405
+ const pattern = negated ? raw.slice(1) : raw;
406
+ let rule;
407
+ if (pattern.includes('/')) {
408
+ const normalized = pattern.replace(/^\/+/, '').replaceAll('\\', '/');
409
+ rule = {
410
+ negated, kind: PATH_GLOB, regex: cachedGlobRegex(`path:${normalized}`, normalized),
411
+ dirPrefix: normalized.endsWith('/**') ? normalized.slice(0, -3).replace(/\/+$/, '') : null,
412
+ };
413
+ if (!negated) paths.push(rule);
414
+ } else if (pattern.includes('*')) {
415
+ rule = { negated, kind: GLOB_NAME, regex: cachedGlobRegex(pattern, pattern) };
416
+ if (!negated) globs.push(rule);
417
+ } else {
418
+ rule = { negated, kind: LITERAL_NAME, name: pattern };
419
+ if (!negated) literals.add(pattern);
420
+ }
421
+ if (negated) {
422
+ hasNegation = true;
423
+ const reinclude = pattern.replace(/^\/+/, '');
424
+ if (reinclude.includes('/')) reincludes.push(reinclude);
425
+ }
426
+ rules.push(rule);
427
+ }
428
+ const ruleMatches = (rule, name, rel) => {
429
+ if (rule.kind === LITERAL_NAME) return rule.name === name;
430
+ if (rule.kind === GLOB_NAME) return rule.regex.test(name);
431
+ return rel !== null && (rule.regex.test(rel) || (rule.dirPrefix !== null && rel === rule.dirPrefix));
432
+ };
433
+ return {
434
+ size: rules.length,
435
+ /** @param {string|null} rel - root-relative path ('..'-prefixed outside the root) */
436
+ matches(name, rel, isDirectory) {
437
+ if (!hasNegation) {
438
+ if (literals.has(name)) return true;
439
+ for (const rule of globs) if (rule.regex.test(name)) return true;
440
+ for (const rule of paths) if (ruleMatches(rule, name, rel)) return true;
441
+ return false;
442
+ }
443
+ let ignored = false;
444
+ for (const rule of rules) {
445
+ if (ruleMatches(rule, name, rel)) ignored = !rule.negated;
446
+ }
447
+ // A later negation can re-include a descendant: keep walking a
448
+ // matched directory whenever such a rule might apply below it.
449
+ if (ignored && isDirectory && rel !== null &&
450
+ reincludes.some(p => p.startsWith(rel + '/') || p.includes('**'))) {
451
+ ignored = false;
452
+ }
453
+ return ignored;
454
+ },
455
+ };
456
+ }
457
+
458
+ function cachedGlobRegex(key, glob) {
459
+ let regex = _globRegexCache.get(key);
460
+ if (!regex) {
461
+ regex = globToRegex(glob);
462
+ _globRegexCache.set(key, regex);
463
+ }
464
+ return regex;
465
+ }
466
+
467
+ /** Conditional vendor-style ignores: the name plus a marker file beside it. */
468
+ function conditionallyIgnored(name, parentDir) {
469
+ if (!Array.isArray(CONDITIONAL_IGNORES[name])) return false;
470
+ for (const marker of CONDITIONAL_IGNORES[name]) {
471
+ if (fs.existsSync(path.join(parentDir, marker))) return true;
472
+ }
473
+ return false;
474
+ }
475
+
366
476
  /**
367
477
  * Parse a glob pattern into components
368
478
  */
@@ -423,7 +533,7 @@ function globToRegex(glob) {
423
533
  /**
424
534
  * Walk a directory tree, calling onFile for each matching file
425
535
  */
426
- function walkDir(dir, options, depth = 0, visited = new Set()) {
536
+ function walkDir(dir, options, depth = 0, visited = new Set(), knownRealDir = null, context = null) {
427
537
  if (depth > options.maxDepth) {
428
538
  options.onDiscoveryIssue?.({
429
539
  path: dir, kind: 'directory', reason: 'max-depth',
@@ -431,18 +541,23 @@ function walkDir(dir, options, depth = 0, visited = new Set()) {
431
541
  });
432
542
  return;
433
543
  }
434
- if (!fs.existsSync(dir)) return;
544
+ // A plain (non-symlink) subdirectory of a resolved directory is its own
545
+ // real path: only the root and symlinked entries need realpath (fix #372,
546
+ // one lstat per path component per directory on every staleness walk).
547
+ if (!knownRealDir && !fs.existsSync(dir)) return;
435
548
 
436
549
  // Track visited directories to avoid circular symlinks
437
- let realDir;
438
- try {
439
- realDir = fs.realpathSync(dir);
440
- } catch (e) {
441
- options.onDiscoveryIssue?.({
442
- path: dir, kind: 'directory', reason: 'unreadable-directory',
443
- detail: e.message,
444
- });
445
- return; // broken symlink
550
+ let realDir = knownRealDir;
551
+ if (!realDir) {
552
+ try {
553
+ realDir = fs.realpathSync(dir);
554
+ } catch (e) {
555
+ options.onDiscoveryIssue?.({
556
+ path: dir, kind: 'directory', reason: 'unreadable-directory',
557
+ detail: e.message,
558
+ });
559
+ return; // broken symlink
560
+ }
446
561
  }
447
562
  if (visited.has(realDir)) return;
448
563
  visited.add(realDir);
@@ -461,46 +576,40 @@ function walkDir(dir, options, depth = 0, visited = new Set()) {
461
576
  entries.sort((a, b) => compareNames(a.name, b.name));
462
577
 
463
578
  const followSymlinks = options.followSymlinks !== false; // default true
579
+ if (!context) {
580
+ context = walkContext(options.anchorRoot || dir, dir, options.gitignoreRules || null);
581
+ }
582
+ const policy = options.policy || (options.policy = compileIgnoreList(options.ignores || DEFAULT_IGNORES));
583
+ const rules = context.inside ? options.gitignoreRules || null : null;
584
+ const trackedFiles = options.trackedPaths?.files;
585
+ const trackedDirectories = options.trackedPaths?.directories;
464
586
 
465
587
  for (const entry of entries) {
466
- const fullPath = path.join(dir, entry.name);
467
-
588
+ const name = entry.name;
589
+ const fullPath = path.join(dir, name);
468
590
  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
- )) {
591
+ // Root-relative path, extended once per entry (never per rule).
592
+ const relative = context.rel ? `${context.rel}/${name}` : name;
593
+ const policyIgnored = policy.matches(name, relative, entryIsDirectory) ||
594
+ conditionallyIgnored(name, dir);
595
+ // .gitignore verdict for the untracked view of this entry. Tracked
596
+ // paths are never excluded by rules (git keeps tracking them), but a
597
+ // tracked DIRECTORY still needs the verdict: its untracked contents
598
+ // inherit an exclusion (git cannot re-include below an excluded
599
+ // directory).
600
+ let tracked = false;
601
+ let excluded = false;
602
+ if (!policyIgnored && context.inside) {
603
+ tracked = entryIsDirectory
604
+ ? !!trackedDirectories?.has(relative)
605
+ : !!trackedFiles?.has(relative);
606
+ if (context.excluded) excluded = true;
607
+ else if (rules && (!tracked || entryIsDirectory)) {
608
+ excluded = rules.decide(context.scopes, relative, context.segments, name, entryIsDirectory);
609
+ }
610
+ }
611
+ if (policyIgnored || (excluded && !tracked)) {
612
+ if (options.disclosure && options.disclosure.matches(name, relative, entryIsDirectory)) {
504
613
  options.onDiscoveryIssue?.({
505
614
  path: fullPath,
506
615
  kind: entryIsDirectory ? 'directory' : 'file',
@@ -527,10 +636,17 @@ function walkDir(dir, options, depth = 0, visited = new Set()) {
527
636
 
528
637
  if (isDir) {
529
638
  if (options.recursive) {
530
- walkDir(fullPath, options, depth + 1, visited);
639
+ walkDir(fullPath, options, depth + 1, visited,
640
+ entry.isSymbolicLink() ? null : path.join(realDir, entry.name), {
641
+ rel: relative,
642
+ inside: context.inside,
643
+ segments: context.segments + 1,
644
+ scopes: rules && !excluded ? rules.scopesFor(context.scopes, relative) : context.scopes,
645
+ excluded,
646
+ });
531
647
  }
532
648
  } else if (isFile) {
533
- if (shouldIgnore(entry.name, BUNDLED_PATTERNS) &&
649
+ if (bundledList().matches(entry.name, null, false) &&
534
650
  (!options.includeBundled || entry.name.endsWith('.map'))) {
535
651
  options.onDiscoveryIssue?.({
536
652
  path: fullPath, kind: 'file', reason: 'bundled',
@@ -906,6 +1022,7 @@ module.exports = {
906
1022
  findTestFileFor,
907
1023
  parseGitignore,
908
1024
  gitTrackedPaths,
1025
+ gitListing,
909
1026
  DEFAULT_IGNORES,
910
1027
  PROJECT_MARKERS,
911
1028
  TEST_PATTERNS,
package/core/errors.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * core/errors.js - Refusals versus internal errors.
3
+ *
4
+ * A refusal is an answer about the request: bad input, a missing git
5
+ * repository, an invalid regular expression, an unreadable file. An internal
6
+ * error is an exception inside UCN (a TypeError in the engine, a broken
7
+ * invariant): a defect, never an answer about the code. Every surface marks
8
+ * the second kind so a caller can tell "UCN declined" from "UCN failed".
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ /** A deliberate refusal raised as an exception deep inside the engine. */
14
+ class UcnError extends Error {
15
+ constructor(message, options) {
16
+ super(message, options);
17
+ this.name = 'UcnError';
18
+ }
19
+ }
20
+
21
+ const INTERNAL_ERROR_PREFIX = 'Internal error:';
22
+
23
+ /**
24
+ * True when `error` is a defect inside UCN rather than a refusal or an
25
+ * operating-system failure (unreadable file, permission, missing binary).
26
+ */
27
+ function isInternalError(error) {
28
+ if (!error || typeof error !== 'object') return true;
29
+ if (error instanceof UcnError || error.name === 'UcnError') return false;
30
+ if (typeof error.code === 'string' && typeof error.syscall === 'string') return false;
31
+ return true;
32
+ }
33
+
34
+ /** The message every surface prints for an internal error. */
35
+ function internalErrorMessage(error) {
36
+ const detail = error && typeof error === 'object' && error.message ? error.message : String(error);
37
+ return `${INTERNAL_ERROR_PREFIX} ${detail} (a defect in UCN, not an answer about the code; the command did not complete)`;
38
+ }
39
+
40
+ /** `{ error, internalError? }` for an exception caught at a surface. */
41
+ function describeError(error) {
42
+ if (isInternalError(error)) return { error: internalErrorMessage(error), internalError: true };
43
+ return { error: error.message };
44
+ }
45
+
46
+ module.exports = {
47
+ UcnError,
48
+ INTERNAL_ERROR_PREFIX,
49
+ isInternalError,
50
+ internalErrorMessage,
51
+ describeError,
52
+ };