ucn 5.4.2 → 5.5.1
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 +58 -236
- package/.claude/skills/ucn/references/commands.md +144 -68
- package/.claude/skills/ucn/references/resolution-rules.md +171 -0
- package/.claude/skills/ucn/references/trust-contract.md +27 -33
- package/README.md +27 -16
- package/cli/index.js +45 -14
- package/core/accessors.js +118 -3
- package/core/account.js +62 -11
- package/core/analysis.js +517 -266
- package/core/ast-analysis.js +76 -117
- package/core/async-producers.js +1729 -0
- package/core/bridge.js +1877 -268
- package/core/brief.js +6 -0
- package/core/build-worker.js +78 -14
- package/core/cache.js +529 -31
- package/core/callers.js +12228 -921
- package/core/check.js +30 -38
- package/core/class-identity.js +1028 -0
- package/core/command-contracts.js +6 -6
- package/core/configuration-items.js +86 -0
- package/core/contract-membership.js +2262 -0
- package/core/cpp-scope.js +532 -0
- package/core/deadcode.js +171 -49
- package/core/decorator-binding.js +260 -0
- package/core/discovery.js +281 -164
- package/core/errors.js +52 -0
- package/core/execute.js +21 -9
- package/core/external-macros.js +326 -0
- package/core/graph-build.js +277 -8
- package/core/graph.js +35 -2
- package/core/ignore-rules.js +472 -0
- package/core/imports.js +504 -42
- package/core/index-ir.js +94 -11
- package/core/inprocess-clients.js +636 -0
- package/core/ir.js +87 -8
- package/core/macro-expansion.js +1416 -0
- package/core/output/analysis.js +18 -2
- package/core/output/brief.js +3 -0
- package/core/output/doctor.js +4 -0
- package/core/output/endpoints.js +15 -6
- package/core/output/find.js +7 -2
- package/core/output/refactoring.js +121 -14
- package/core/output/reporting.js +31 -3
- package/core/output/shared.js +14 -0
- package/core/output-budget.js +305 -122
- package/core/parallel-build.js +124 -47
- package/core/parser.js +3 -2
- package/core/project.js +472 -66
- package/core/provenance-facts.js +156 -12
- package/core/provenance-overload.js +30 -3
- package/core/provenance.js +104 -30
- package/core/pytest-fixtures.js +434 -0
- package/core/python-fixture-flow.js +17 -3
- package/core/python-modules.js +173 -0
- package/core/reflection.js +877 -0
- package/core/registry.js +1 -1
- package/core/reporting.js +234 -52
- package/core/request-helpers.js +561 -0
- package/core/route-graph.js +1922 -0
- package/core/route-lists.js +2414 -0
- package/core/rust-macro-expansion.js +3026 -0
- package/core/rust-modules.js +203 -0
- package/core/search.js +121 -29
- package/core/shared.js +19 -1
- package/core/test-name-bindings.js +256 -0
- package/core/type-denotation.js +1185 -0
- package/core/type-references.js +3229 -0
- package/core/verify.js +3360 -672
- package/languages/adapter.js +31 -5
- package/languages/c-family.js +4061 -212
- package/languages/c-macro-invocations.js +380 -0
- package/languages/c-preprocessor.js +254 -0
- package/languages/csharp.js +1406 -49
- package/languages/go.js +1243 -70
- package/languages/index.js +728 -6
- package/languages/java.js +473 -61
- package/languages/javascript.js +1270 -197
- package/languages/lexical-scope.js +1280 -0
- package/languages/python.js +1557 -72
- package/languages/rust-macro-rules.js +1020 -0
- package/languages/rust-value-flow.js +416 -0
- package/languages/rust.js +2265 -112
- package/languages/utils.js +298 -4
- package/mcp/server.js +22 -8
- 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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
*
|
|
250
|
-
*
|
|
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
|
|
201
|
+
function gitListing(projectRoot) {
|
|
253
202
|
const root = path.resolve(projectRoot);
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
271
|
-
'-C',
|
|
272
|
-
], {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
467
|
-
|
|
588
|
+
const name = entry.name;
|
|
589
|
+
const fullPath = path.join(dir, name);
|
|
468
590
|
const entryIsDirectory = entry.isDirectory();
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
dir
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
)
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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 (
|
|
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
|
+
};
|