ucn 5.5.2 → 5.6.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 (74) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -12
  2. package/.claude/skills/ucn/references/commands.md +21 -17
  3. package/.claude/skills/ucn/references/resolution-rules.md +4 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +4 -4
  5. package/CHANGELOG.md +474 -0
  6. package/README.md +71 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +138 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +273 -107
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +29 -65
  13. package/core/cache.js +101 -16
  14. package/core/callers.js +352 -70
  15. package/core/capture.js +331 -0
  16. package/core/change-kinds.js +888 -0
  17. package/core/check.js +1117 -139
  18. package/core/client-receivers.js +189 -0
  19. package/core/command-contracts.js +7 -6
  20. package/core/constructed-class.js +1195 -0
  21. package/core/contract-membership.js +142 -0
  22. package/core/deadcode.js +388 -85
  23. package/core/deletion.js +401 -0
  24. package/core/discovery.js +104 -9
  25. package/core/entrypoints.js +12 -6
  26. package/core/evidence-bands.js +68 -0
  27. package/core/evidence-bands.json +44 -0
  28. package/core/execute.js +172 -161
  29. package/core/hot-summary.js +241 -0
  30. package/core/index-ir.js +3 -1
  31. package/core/inprocess-clients.js +41 -15
  32. package/core/ir.js +5 -1
  33. package/core/member-signatures.js +789 -0
  34. package/core/output/analysis-ext.js +20 -10
  35. package/core/output/analysis.js +98 -60
  36. package/core/output/check.js +98 -37
  37. package/core/output/doctor.js +34 -1
  38. package/core/output/find.js +2 -1
  39. package/core/output/lines.js +63 -3
  40. package/core/output/public.js +30 -11
  41. package/core/output/refactoring.js +32 -0
  42. package/core/output/reporting.js +21 -2
  43. package/core/output/search.js +11 -2
  44. package/core/output/tracing.js +35 -7
  45. package/core/output-budget.js +124 -57
  46. package/core/parallel-build.js +3 -3
  47. package/core/project.js +106 -23
  48. package/core/provenance-type.js +12 -5
  49. package/core/public-command.js +35 -9
  50. package/core/python-modules.js +6 -0
  51. package/core/reflection.js +166 -23
  52. package/core/registry.js +623 -72
  53. package/core/reporting.js +69 -34
  54. package/core/request-helpers.js +63 -7
  55. package/core/review-items.js +75 -0
  56. package/core/search.js +89 -128
  57. package/core/shared.js +0 -45
  58. package/core/snapshot.js +350 -0
  59. package/core/source-universe.js +707 -0
  60. package/core/test-units.js +3624 -0
  61. package/core/tracing.js +150 -96
  62. package/core/verify.js +27 -11
  63. package/languages/adapter.js +8 -3
  64. package/languages/c-family.js +12 -0
  65. package/languages/csharp.js +634 -0
  66. package/languages/go.js +120 -0
  67. package/languages/index.js +422 -4
  68. package/languages/java.js +712 -0
  69. package/languages/javascript.js +1460 -2
  70. package/languages/lexical-scope.js +68 -0
  71. package/languages/python.js +1672 -1
  72. package/languages/rust.js +91 -0
  73. package/mcp/server.js +44 -110
  74. package/package.json +11 -6
@@ -0,0 +1,401 @@
1
+ /**
2
+ * core/deletion.js - Can this declaration be deleted?
3
+ *
4
+ * One eligibility rule answers that for every surface: the dead-code
5
+ * pipeline (core/deadcode.js). The audit runs it over every name; an
6
+ * assessment runs the SAME code over one name and reads why a declaration
7
+ * was or was not claimed. A declaration the audit lists is exactly a
8
+ * declaration this module answers `no-static-dependents`.
9
+ *
10
+ * The answer is a statement of static evidence with its basis, never a
11
+ * promise that deletion is safe:
12
+ *
13
+ * no-static-dependents the name has no use in project source outside its
14
+ * own declarations, and no shield applies: nothing
15
+ * the engine can see depends on it;
16
+ * has-static-dependents a call or other use of the name exists OUTSIDE
17
+ * the declaration. The first such site is given.
18
+ * Evidence is by name, as in the audit: with two
19
+ * declarations of one name it does not say which
20
+ * of them the use reaches;
21
+ * review no use outside the declaration was found, but a
22
+ * zero is not evidence here: a shield applies
23
+ * (consumers outside the repository, a contract, a
24
+ * registration, reflection, source that could not
25
+ * be searched) or the name is used inside the
26
+ * declaration itself. The reason is named.
27
+ *
28
+ * A use inside the declaration is deleted with it and is never a dependent:
29
+ * `fn len(&self) { self.inner.len() }` and an override that calls `super()`
30
+ * do not depend on themselves.
31
+ *
32
+ * Two things that look alike are kept apart:
33
+ *
34
+ * downstream exposure POLICY. An exported symbol may have consumers
35
+ * outside the repository. It blocks a claim in a
36
+ * library and not in a project its `.ucn.json`
37
+ * declares an application (or under
38
+ * --include-exported).
39
+ * external contract FACT about the declaration: it overrides or
40
+ * implements something defined outside the project,
41
+ * is registered, reflected or dispatched by name. It
42
+ * holds for unexported symbols and in applications.
43
+ */
44
+
45
+ 'use strict';
46
+
47
+ const path = require('path');
48
+
49
+ /** The kind a project declares for itself. Only `application` changes
50
+ * anything; everything else (absent, unknown) is a library. */
51
+ function projectKind(index) {
52
+ return index && index.config && index.config.kind === 'application'
53
+ ? 'application' : 'library';
54
+ }
55
+
56
+ /**
57
+ * Does the possibility of consumers outside the repository keep this
58
+ * exported symbol out of a deletion claim? `isExported` is the fact, read
59
+ * from the declaration; this is the policy over it.
60
+ */
61
+ function downstreamExposureBlocks(index, isExported, options = {}) {
62
+ return !!isExported && !options.includeExported && projectKind(index) === 'library';
63
+ }
64
+
65
+ /** A use of the name exists: the declaration has dependents. */
66
+ const DEPENDENT_REASONS = new Set(['has-callers', 'has-usages', 'member-in-use']);
67
+
68
+ /** Why the pipeline did or did not claim a declaration, in one sentence. */
69
+ const REASON_TEXT = Object.freeze({
70
+ 'claimed': 'the name has no use in project source outside its own declarations, and no shield applies',
71
+ 'has-callers': 'a call of this name exists outside the declaration',
72
+ 'has-usages': 'the name is used outside the declaration',
73
+ 'member-in-use': 'a member of this type is called outside the declaration',
74
+ 'used-only-within-itself': 'every use found is inside the declaration itself, and the audit does not clear a name that is in use',
75
+ 'downstream-exposure': 'exported from a library: consumers outside the repository may use it',
76
+ 'external-contract': 'implements or overrides something defined outside the project',
77
+ 'required-slot': 'required by a contract of the project: removing it breaks the type that implements it',
78
+ 'structural-contract': 'its type has every member a project interface or protocol requires, so it can be used as one without naming it',
79
+ 'owner-ambiguous': 'several types of its owner\'s name share the file, so its contracts cannot be checked',
80
+ 'runtime-contract': 'invoked by the language runtime, the compiler or an assignment, not by name',
81
+ 'registered': 'decorated or registered with a framework, which may invoke it without naming it',
82
+ 'entry-point': 'an entry point the runtime or a test runner invokes',
83
+ 'constructor': 'a constructor: part of its type\'s allocation protocol, assessed with the type',
84
+ 'reflection': 'reflection in the project can reach this name',
85
+ 'dynamic-dispatch': 'member of a registry that is called through a computed key',
86
+ 'macro-reference': 'a macro expansion names it',
87
+ 'unanalyzed-occurrence': 'the name occurs in source that is searched as text and not analyzed',
88
+ 'unsearchable-source': 'part of the project could not be searched, so no name can be cleared',
89
+ 'test-file': 'declared in a test file, which the audit does not cover by default',
90
+ 'out-of-scope': 'outside the requested file scope',
91
+ 'kind-not-audited': 'this kind of declaration is not audited for deletion',
92
+ 'expression-scoped-name': 'a named function expression: its position invokes it, not its name',
93
+ 'macro-generated': 'generated by a macro invocation, not a declaration of its own',
94
+ 'bundled-file': 'declared in a bundled file',
95
+ 'operator-surface': 'invoked by operator or indexer syntax that never contains its name',
96
+ 'ambient-declaration': 'an ambient declaration that describes code outside the project',
97
+ 'declaration-not-found': 'no declaration matches this handle',
98
+ });
99
+
100
+ function verdictOf(reason) {
101
+ if (reason === 'claimed') return 'no-static-dependents';
102
+ if (DEPENDENT_REASONS.has(reason)) return 'has-static-dependents';
103
+ return 'review';
104
+ }
105
+
106
+ function sameDeclaration(symbol, declaration) {
107
+ return symbol.name === declaration.name &&
108
+ symbol.file === declaration.file &&
109
+ symbol.startLine === declaration.startLine;
110
+ }
111
+
112
+ function relativeOf(index, file) {
113
+ return path.relative(index.root, file).split(path.sep).join('/');
114
+ }
115
+
116
+ /** Code-unit order, never locale order: the first site must not depend on
117
+ * the machine that prints it. */
118
+ function compareSites(a, b) {
119
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
120
+ return (a.line || 0) - (b.line || 0);
121
+ }
122
+
123
+ /** Is this site inside the declaration, and so deleted with it? Only a
124
+ * declaration the index holds has lines in the current text. */
125
+ function withinDeclaration(declaration, indexed, file, line) {
126
+ if (!indexed || file !== declaration.file) return false;
127
+ const end = declaration.endLine != null ? declaration.endLine : declaration.startLine;
128
+ return line >= declaration.startLine && line <= end;
129
+ }
130
+
131
+ /**
132
+ * The first call of any of `names` outside the declaration, in
133
+ * (path, line) order, or null. A file whose call records cannot be read
134
+ * counts as a site with no line: an unknown is never read as "inside".
135
+ */
136
+ function callSiteOutside(index, declaration, indexed, names) {
137
+ if (!index.calleeIndex) return null;
138
+ const { getCachedCalls } = require('./callers');
139
+ const files = new Map(); // relative path -> absolute path
140
+ for (const name of names) {
141
+ for (const file of index.calleeIndex.get(name) || []) files.set(relativeOf(index, file), file);
142
+ }
143
+ const wanted = new Set(names);
144
+ for (const relativePath of [...files.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))) {
145
+ const file = files.get(relativePath);
146
+ const calls = getCachedCalls(index, file);
147
+ if (!calls) return { file: relativePath };
148
+ let line = null;
149
+ for (const call of calls) {
150
+ if (!wanted.has(call.name) && !wanted.has(call.resolvedName) &&
151
+ !(call.resolvedNames && call.resolvedNames.some(name => wanted.has(name)))) continue;
152
+ if (withinDeclaration(declaration, indexed, file, call.line)) continue;
153
+ if (line === null || call.line < line) line = call.line;
154
+ }
155
+ if (line !== null) return { file: relativePath, line };
156
+ }
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * The first use of the name outside the declaration, with the pipeline's
162
+ * reason for it, or null.
163
+ *
164
+ * The pipeline stops at the first thing that keeps a declaration out of the
165
+ * audit, which is right for an audit and wrong for a verdict in two ways.
166
+ * A shield hides what lies behind it: an exported symbol is never scanned
167
+ * for uses in a library, and "consumers outside may use it" must not hide
168
+ * that code inside the repository does. And a call of the name anywhere
169
+ * ends the chain, including a call inside the declaration itself.
170
+ *
171
+ * So calls are read from the callee index directly, and other uses come
172
+ * from one more pass with every shield a flag can lift lifted. When no call
173
+ * lies outside the declaration, that pass sends the name through the text
174
+ * scan under the pipeline's recursion carve-out, so the pipeline's own
175
+ * usage rule still decides what a use is.
176
+ */
177
+ function useOutsideDeclaration(index, declaration, indexed, deadcode, options) {
178
+ const name = declaration.name;
179
+ const call = callSiteOutside(index, declaration, indexed, [name]);
180
+ if (call) return { reason: 'has-callers', site: call };
181
+ let found = null;
182
+ deadcode(index, {
183
+ names: new Set([name]),
184
+ includeExported: true,
185
+ includeDecorated: true,
186
+ includeTests: true,
187
+ ownBodyOnly: new Set([name]),
188
+ ...(!indexed && {
189
+ declarations: [{ symbol: declaration, fileEntry: options.fileEntry || null }],
190
+ }),
191
+ explain: (symbol, reason, detail) => {
192
+ if (!sameDeclaration(symbol, declaration)) return;
193
+ if (reason === 'member-in-use') {
194
+ const site = callSiteOutside(index, declaration, indexed, (detail && detail.members) || []);
195
+ if (site) found = { reason, site };
196
+ } else if (reason === 'has-usages') {
197
+ const sites = ((detail && detail.usages) || [])
198
+ .filter(usage => !withinDeclaration(declaration, indexed, usage.file, usage.line))
199
+ .map(usage => ({ file: relativeOf(index, usage.file), line: usage.line }))
200
+ .sort(compareSites);
201
+ if (sites.length > 0) found = { reason, site: sites[0] };
202
+ }
203
+ },
204
+ });
205
+ return found;
206
+ }
207
+
208
+ /**
209
+ * Assess one declaration for deletion.
210
+ *
211
+ * @param {object} index - ProjectIndex of the snapshot the declaration's
212
+ * USES are looked for in
213
+ * @param {object} declaration - a symbol record: { name, file (absolute),
214
+ * startLine, ... }. One the index holds is assessed in place. One it does
215
+ * not hold (deleted since, or taken from a base revision) is assessed
216
+ * from the record itself; pass `fileEntry` with the file facts of the
217
+ * revision it was declared in when they differ from the index's.
218
+ * @param {object} [options] - { includeExported, includeDecorated,
219
+ * includeTests, fileEntry }
220
+ */
221
+ function assessDeletion(index, declaration, options = {}) {
222
+ const { deadcode } = require('./deadcode');
223
+ const indexed = (index.symbols.get(declaration.name) || [])
224
+ .some(symbol => sameDeclaration(symbol, declaration));
225
+ const outcomes = [];
226
+ const audit = deadcode(index, {
227
+ names: new Set([declaration.name]),
228
+ includeExported: !!options.includeExported,
229
+ includeDecorated: !!options.includeDecorated,
230
+ includeTests: !!options.includeTests,
231
+ ...(!indexed && {
232
+ declarations: [{ symbol: declaration, fileEntry: options.fileEntry || null }],
233
+ }),
234
+ explain: (symbol, reason, row) => outcomes.push({ symbol, reason, row }),
235
+ });
236
+ const outcome = outcomes.find(entry => sameDeclaration(entry.symbol, declaration));
237
+ let reason = outcome ? outcome.reason : 'declaration-not-found';
238
+ // --include-exported LISTS a declaration bound to an external contract,
239
+ // tagged, for review. Listed is not cleared: the contract is a fact
240
+ // about the declaration and no flag removes it.
241
+ if (reason === 'claimed' && outcome.row && outcome.row.externalContract) {
242
+ reason = 'external-contract';
243
+ }
244
+ // A use outside the declaration outranks everything but a claim. A
245
+ // dependent reason with no such use is no dependent: the name is used
246
+ // only inside what would be deleted.
247
+ let site = null;
248
+ if (outcome && reason !== 'claimed') {
249
+ const use = useOutsideDeclaration(index, declaration, indexed, deadcode, options);
250
+ if (use) {
251
+ reason = use.reason;
252
+ site = use.site;
253
+ } else if (DEPENDENT_REASONS.has(reason)) {
254
+ reason = 'used-only-within-itself';
255
+ }
256
+ }
257
+ const relativePath = declaration.relativePath ||
258
+ path.relative(index.root, declaration.file).split(path.sep).join('/');
259
+ return {
260
+ declaration: {
261
+ name: declaration.name,
262
+ file: relativePath,
263
+ startLine: declaration.startLine,
264
+ ...(declaration.endLine != null && { endLine: declaration.endLine }),
265
+ ...(declaration.type && { type: declaration.type }),
266
+ ...(declaration.className && { className: declaration.className }),
267
+ },
268
+ verdict: verdictOf(reason),
269
+ reason,
270
+ basis: REASON_TEXT[reason] || reason,
271
+ ...(site && { site }),
272
+ projectKind: projectKind(index),
273
+ indexed,
274
+ coverage: {
275
+ complete: audit.coverage.complete,
276
+ claimsWithdrawn: audit.coverage.claimsWithdrawn,
277
+ },
278
+ // Static evidence only. Aliases, reflection the engine does not
279
+ // model, generated code and consumers outside the repository are
280
+ // beyond it: the toolchain and the tests decide.
281
+ safeToDelete: false,
282
+ semanticComplete: false,
283
+ requiresCompilerAndTests: true,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Dependents of a declaration that CANNOT survive its deletion: sites the
289
+ * toolchain will reject, not sites that might be affected.
290
+ *
291
+ * import another file imports the name from the module that declared
292
+ * it, and that module no longer declares or exports a name like
293
+ * it;
294
+ * call the declaring file (or, where a directory is the package, a
295
+ * file beside it) still calls the name bare, and no declaration,
296
+ * import or parameter of that name remains for the call to mean.
297
+ *
298
+ * A method call on a receiver, a call through an alias, a reference by a
299
+ * module path: those are dependents the engine reports, never definite
300
+ * ones. Meant for a declaration the index no longer holds; one it holds
301
+ * still satisfies every site, so the answer is empty.
302
+ *
303
+ * @returns {{file, line, kind: 'import'|'call'}[]} sorted (file, line)
304
+ */
305
+ /** Is `module.name` itself a module the importer resolved (a submodule)? */
306
+ function submoduleOf(resolved, module, name) {
307
+ const spelled = String(module || '');
308
+ return !!resolved[spelled.endsWith('.') ? spelled + name : `${spelled}.${name}`];
309
+ }
310
+
311
+ function definiteDependentsOf(index, declaration) {
312
+ const name = declaration.name;
313
+ const declaredIn = declaration.relativePath || relativeOf(index, declaration.file);
314
+ // Declarations of the name that a module-scope reference can mean: a
315
+ // member, or a function local to another function, supplies nothing
316
+ // there.
317
+ const stillDeclared = (index.symbols.get(name) || []).filter(symbol =>
318
+ !symbol.className && symbol.lexicalScopeStartLine == null && !symbol.isNested);
319
+ const sites = [];
320
+ // Imports of the name from its former module. A module that still has
321
+ // a declaration of the name, still lists it among its exports, or
322
+ // re-exports everything from elsewhere may still provide it.
323
+ const formerModule = index.files.get(declaration.file);
324
+ const moduleStillProvides = !!formerModule && (
325
+ stillDeclared.some(symbol => symbol.file === declaration.file) ||
326
+ (formerModule.exports || []).includes(name) ||
327
+ (formerModule.exportDetails || []).some(detail => detail && (detail.name === '*' || detail.type === 'star')));
328
+ if (!moduleStillProvides) {
329
+ for (const entry of index.files.values()) {
330
+ const resolved = entry.moduleResolved || {};
331
+ for (const binding of entry.importBindings || []) {
332
+ if (binding.name !== name || binding.dynamic) continue;
333
+ if (resolved[binding.module] !== declaredIn) continue;
334
+ // `from pkg import name` where `pkg/name` is a module of
335
+ // the package imports that module, not a name of pkg.
336
+ if (submoduleOf(resolved, binding.module, name)) continue;
337
+ sites.push(Object.defineProperty({ file: entry.relativePath, line: binding.line || 0, kind: 'import' },
338
+ 'module', { value: binding.module, enumerable: false }));
339
+ }
340
+ }
341
+ }
342
+ // Bare calls with nothing left to mean. Only a free function: a member
343
+ // is called through a receiver. The language's own scoping decides
344
+ // what a bare name means at each call (languages/lexical-scope.js):
345
+ // only a name that resolves at module scope, in a file where no import
346
+ // and no other binding supplies it, is left without a meaning.
347
+ if (!declaration.className) {
348
+ const { langTraits } = require('../languages');
349
+ const { referenceScope } = require('../languages/lexical-scope');
350
+ const { parseRevision } = require('./change-kinds');
351
+ const traits = langTraits(formerModule?.language || index.languageOf?.(declaration.file)) || {};
352
+ const directoryPackage = traits.packageScope === 'directory';
353
+ const beside = file => file === declaration.file ||
354
+ (directoryPackage && path.dirname(file) === path.dirname(declaration.file));
355
+ // What a module-scope name can still mean in `file`: a declaration
356
+ // in the file, in its package directory where that is the scope,
357
+ // or anywhere at all where files see each other through textual
358
+ // inclusion.
359
+ // A declaration that describes code outside the project (an
360
+ // ambient one) supplies the name to every file.
361
+ const ambient = symbol => !!symbol.ambientAugmentation || /\.d\.[cm]?ts$/.test(symbol.relativePath || '');
362
+ const suppliedTo = file => stillDeclared.some(symbol => traits.textualIncludes || ambient(symbol) ||
363
+ symbol.file === file || (directoryPackage && path.dirname(symbol.file) === path.dirname(file)));
364
+ // A name the language itself supplies always means something.
365
+ const builtin = !!(traits.ambientCallables && traits.ambientCallables.has(name));
366
+ for (const file of builtin ? [] : [...(index.calleeIndex?.get(name) || [])].sort()) {
367
+ if (!beside(file) || suppliedTo(file)) continue;
368
+ const entry = index.files.get(file);
369
+ if (!entry) continue;
370
+ if ((entry.importBindings || []).some(binding => (binding.alias || binding.name) === name)) continue;
371
+ // An import of everything a module has may bring the name.
372
+ if ((entry.importDetails || []).some(detail => (detail.names || []).includes('*'))) continue;
373
+ let root;
374
+ try { root = parseRevision(index._readFile(file), entry.language); } catch (_) { root = null; }
375
+ if (!root) continue;
376
+ const memo = new Map();
377
+ const lines = new Set();
378
+ for (const node of root.descendantsOfType('identifier')) {
379
+ if (node.text !== name) continue;
380
+ const callee = node.parent?.childForFieldName('function');
381
+ if (!callee || callee.id !== node.id) continue;
382
+ let scope;
383
+ try { scope = referenceScope(node, entry.language, memo); } catch (_) { scope = null; }
384
+ if (scope === 'module') lines.add(node.startPosition.row + 1);
385
+ }
386
+ for (const line of lines) sites.push({ file: entry.relativePath, line, kind: 'call' });
387
+ }
388
+ }
389
+ return sites.sort(compareSites);
390
+ }
391
+
392
+ module.exports = {
393
+ definiteDependentsOf,
394
+ submoduleOf,
395
+ projectKind,
396
+ downstreamExposureBlocks,
397
+ assessDeletion,
398
+ verdictOf,
399
+ REASON_TEXT,
400
+ DEPENDENT_REASONS,
401
+ };
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 { paramDefault } = require('./registry');
11
12
  const { IgnoreRules } = require('./ignore-rules');
12
13
 
13
14
  // Always ignore - unambiguous, never user code
@@ -61,6 +62,17 @@ const DEFAULT_IGNORES = [
61
62
  // These can contain user code: disclose their exclusion, unlike dependency
62
63
  // and VCS directories. Source maps are data and cannot be parsed as code.
63
64
  const BUNDLED_PATTERNS = ['*.min.js', '*.bundle.js', '*.map'];
65
+ let _defaultPolicy = null;
66
+ function defaultPolicy() {
67
+ if (!_defaultPolicy) _defaultPolicy = compileIgnoreList(DEFAULT_IGNORES);
68
+ return _defaultPolicy;
69
+ }
70
+
71
+ // Source files under paths the project's config excludes from indexing are
72
+ // still listed, so a name can be searched in them. Past this many files the
73
+ // excluded root is reported as one unscanned gap instead.
74
+ const MAX_TEXT_ONLY_FILES = 20000;
75
+
64
76
  let _bundledList = null;
65
77
  function bundledList() {
66
78
  if (!_bundledList) _bundledList = compileIgnoreList(BUNDLED_PATTERNS);
@@ -304,14 +316,14 @@ function compareNames(a, b) {
304
316
  * @param {string} options.root - Root directory (defaults to cwd)
305
317
  * @param {string[]} options.ignores - Patterns to ignore
306
318
  * @param {number} options.maxDepth - Maximum directory depth (default: 20)
307
- * @param {number} options.maxFiles - Maximum files to return (default: 10000)
319
+ * @param {number} options.maxFiles - Maximum files to return (default: the parameter table's)
308
320
  * @returns {string[]} - Array of absolute file paths
309
321
  */
310
322
  function expandGlob(pattern, options = {}) {
311
323
  const root = path.resolve(options.root || process.cwd());
312
324
  const ignores = options.ignores || DEFAULT_IGNORES;
313
325
  const maxDepth = options.maxDepth ?? 1000;
314
- const maxFiles = options.maxFiles ?? 50000;
326
+ const maxFiles = options.maxFiles ?? paramDefault('maxFiles');
315
327
  const maxFileSize = options.maxFileSize ?? (8 * 1024 * 1024);
316
328
  const followSymlinks = options.followSymlinks !== false; // default true
317
329
  const gitignoreRules = options.gitignoreRules || null;
@@ -345,6 +357,8 @@ function expandGlob(pattern, options = {}) {
345
357
  onDiscoveryIssue: options.onDiscoveryIssue,
346
358
  disclosureIgnores: options.disclosureIgnores,
347
359
  disclosure: options.disclosureIgnores?.length ? compileIgnoreList(options.disclosureIgnores) : null,
360
+ outside: options.outsideIgnores?.length ? compileIgnoreList(options.outsideIgnores) : null,
361
+ textOnlyState: { files: 0 },
348
362
  onFile: (filePath) => {
349
363
  if (files.length < maxFiles) {
350
364
  files.push(filePath);
@@ -580,6 +594,7 @@ function walkDir(dir, options, depth = 0, visited = new Set(), knownRealDir = nu
580
594
  context = walkContext(options.anchorRoot || dir, dir, options.gitignoreRules || null);
581
595
  }
582
596
  const policy = options.policy || (options.policy = compileIgnoreList(options.ignores || DEFAULT_IGNORES));
597
+ const customIgnores = !!options.ignores && options.ignores !== DEFAULT_IGNORES;
583
598
  const rules = context.inside ? options.gitignoreRules || null : null;
584
599
  const trackedFiles = options.trackedPaths?.files;
585
600
  const trackedDirectories = options.trackedPaths?.directories;
@@ -592,14 +607,19 @@ function walkDir(dir, options, depth = 0, visited = new Set(), knownRealDir = nu
592
607
  const relative = context.rel ? `${context.rel}/${name}` : name;
593
608
  const policyIgnored = policy.matches(name, relative, entryIsDirectory) ||
594
609
  conditionallyIgnored(name, dir);
610
+ // Without project ignores the policy IS the built-in list: one match.
611
+ const defaultIgnored = customIgnores
612
+ ? defaultPolicy().matches(name, relative, entryIsDirectory) ||
613
+ conditionallyIgnored(name, dir)
614
+ : policyIgnored;
595
615
  // .gitignore verdict for the untracked view of this entry. Tracked
596
616
  // paths are never excluded by rules (git keeps tracking them), but a
597
617
  // tracked DIRECTORY still needs the verdict: its untracked contents
598
618
  // inherit an exclusion (git cannot re-include below an excluded
599
- // directory).
619
+ // directory). An entry the built-in ignores already drop needs none.
600
620
  let tracked = false;
601
621
  let excluded = false;
602
- if (!policyIgnored && context.inside) {
622
+ if (context.inside && !(policyIgnored && defaultIgnored)) {
603
623
  tracked = entryIsDirectory
604
624
  ? !!trackedDirectories?.has(relative)
605
625
  : !!trackedFiles?.has(relative);
@@ -608,17 +628,33 @@ function walkDir(dir, options, depth = 0, visited = new Set(), knownRealDir = nu
608
628
  excluded = rules.decide(context.scopes, relative, context.segments, name, entryIsDirectory);
609
629
  }
610
630
  }
611
- if (policyIgnored || (excluded && !tracked)) {
612
- if (options.disclosure && options.disclosure.matches(name, relative, entryIsDirectory)) {
631
+ const gitExcluded = excluded && !tracked;
632
+ // Why an entry is not indexed decides what it means for a claim:
633
+ // declared outside the project no part of any claim, stated once;
634
+ // built-in ignores, .gitignore no part of any claim (as ever);
635
+ // excluded by config ALONE "do not index": its source is
636
+ // still listed so a name can be
637
+ // searched in it (text-only).
638
+ let textOnly = context.textOnly || null;
639
+ if (policyIgnored || gitExcluded || textOnly) {
640
+ if (options.outside && options.outside.matches(name, relative, entryIsDirectory)) {
613
641
  options.onDiscoveryIssue?.({
614
642
  path: fullPath,
615
643
  kind: entryIsDirectory ? 'directory' : 'file',
616
- reason: 'config-exclude',
617
- detail: 'excluded by .ucn.json',
644
+ reason: 'outside-project',
645
+ detail: 'declared outside the project by .ucn.json',
618
646
  });
647
+ continue;
648
+ }
649
+ if (defaultIgnored || gitExcluded) continue;
650
+ if (!textOnly) {
651
+ const configOnly = options.disclosure &&
652
+ options.disclosure.matches(name, relative, entryIsDirectory);
653
+ if (!configOnly) continue;
654
+ textOnly = 'config-exclude';
619
655
  }
620
- continue;
621
656
  }
657
+ const textOnlyRoot = textOnly ? (context.textOnlyRoot || fullPath) : null;
622
658
 
623
659
  let isDir = entry.isDirectory();
624
660
  let isFile = entry.isFile();
@@ -643,8 +679,32 @@ function walkDir(dir, options, depth = 0, visited = new Set(), knownRealDir = nu
643
679
  segments: context.segments + 1,
644
680
  scopes: rules && !excluded ? rules.scopesFor(context.scopes, relative) : context.scopes,
645
681
  excluded,
682
+ textOnly,
683
+ textOnlyRoot,
646
684
  });
647
685
  }
686
+ } else if (isFile && textOnly) {
687
+ // Listed, never indexed: only what discovery would have treated
688
+ // as source (a supported or known unsupported language, a
689
+ // bundle). Source maps and other data stay out, as everywhere.
690
+ const source = options.filePattern.test(entry.name) ||
691
+ !!classifyUnsupportedSourceFile(fullPath) ||
692
+ (bundledList().matches(entry.name, null, false) && !entry.name.endsWith('.map'));
693
+ if (!source) continue;
694
+ const state = options.textOnlyState || (options.textOnlyState = { files: 0 });
695
+ if (state.files >= MAX_TEXT_ONLY_FILES) {
696
+ options.onDiscoveryIssue?.({
697
+ path: textOnlyRoot, kind: 'directory', reason: 'text-scan-budget',
698
+ detail: `more than ${MAX_TEXT_ONLY_FILES} source files under paths ` +
699
+ 'excluded by .ucn.json; declare vendored trees in outsideProject',
700
+ });
701
+ continue;
702
+ }
703
+ state.files++;
704
+ options.onDiscoveryIssue?.({
705
+ path: fullPath, kind: 'file', reason: textOnly,
706
+ detail: 'excluded by .ucn.json',
707
+ });
648
708
  } else if (isFile) {
649
709
  if (bundledList().matches(entry.name, null, false) &&
650
710
  (!options.includeBundled || entry.name.endsWith('.map'))) {
@@ -1008,8 +1068,43 @@ function findTestFileFor(sourceFile, language) {
1008
1068
  return null;
1009
1069
  }
1010
1070
 
1071
+ function patternList(value) {
1072
+ if (Array.isArray(value)) return value.map(String);
1073
+ return value ? [String(value)] : [];
1074
+ }
1075
+
1076
+ /**
1077
+ * The discovery options a project's configuration implies. The build and
1078
+ * the staleness walk both start here: a walk that reads the config
1079
+ * differently sees a different file set and calls a fresh index stale (or a
1080
+ * stale one fresh).
1081
+ *
1082
+ * @param {object} config - parsed .ucn.json (may be empty)
1083
+ * @param {object} [overrides] - explicit maxFiles / maxDepth / maxFileSize /
1084
+ * followSymlinks / includeBundled; undefined keeps the config or default
1085
+ */
1086
+ function projectDiscoveryOptions(root, config = {}, overrides = {}) {
1087
+ const exclude = patternList(config.exclude);
1088
+ const outsideProject = patternList(config.outsideProject);
1089
+ const options = {
1090
+ root,
1091
+ maxFiles: overrides.maxFiles || config.maxFiles || paramDefault('maxFiles'),
1092
+ maxDepth: overrides.maxDepth ?? config.maxDepth,
1093
+ maxFileSize: overrides.maxFileSize ?? config.maxFileSize,
1094
+ followSymlinks: overrides.followSymlinks,
1095
+ includeBundled: overrides.includeBundled === true,
1096
+ disclosureIgnores: exclude,
1097
+ outsideIgnores: outsideProject,
1098
+ };
1099
+ if (exclude.length > 0 || outsideProject.length > 0) {
1100
+ options.ignores = [...DEFAULT_IGNORES, ...exclude, ...outsideProject];
1101
+ }
1102
+ return options;
1103
+ }
1104
+
1011
1105
  module.exports = {
1012
1106
  expandGlob,
1107
+ projectDiscoveryOptions,
1013
1108
  parseGlobPattern,
1014
1109
  globToRegex,
1015
1110
  walkDir,
@@ -595,6 +595,12 @@ const FRAMEWORK_PATTERNS = [
595
595
 
596
596
  ];
597
597
 
598
+ // What the `type` and `framework` filters accept: the kinds and the labels a
599
+ // pattern above carries, and nothing else.
600
+ const ENTRYPOINT_TYPES = Object.freeze([...new Set(FRAMEWORK_PATTERNS.map(p => p.type))].sort());
601
+ const ENTRYPOINT_FRAMEWORKS = Object.freeze(
602
+ [...new Set(FRAMEWORK_PATTERNS.map(p => p.framework.toLowerCase()))].sort());
603
+
598
604
  // ============================================================================
599
605
  // DETECTION
600
606
  // ============================================================================
@@ -864,11 +870,10 @@ function detectEntrypoints(index, options = {}) {
864
870
  // Validate --type against the pattern registry up front — an unknown
865
871
  // value used to fall through to the filter and silently return nothing.
866
872
  if (options.type) {
867
- const validTypes = new Set(FRAMEWORK_PATTERNS.map(p => p.type));
868
- if (!validTypes.has(options.type)) {
873
+ if (!ENTRYPOINT_TYPES.includes(options.type)) {
869
874
  return {
870
875
  error: 'invalid-type',
871
- message: `Unknown type "${options.type}". Valid: ${[...validTypes].sort().join(', ')}.`,
876
+ message: `Unknown type "${options.type}". Valid: ${ENTRYPOINT_TYPES.join(', ')}.`,
872
877
  };
873
878
  }
874
879
  }
@@ -876,13 +881,12 @@ function detectEntrypoints(index, options = {}) {
876
881
  // Same discipline for --framework (fix #243) — a typo like 'flsk'
877
882
  // silently filtered everything to an empty result.
878
883
  if (options.framework) {
879
- const validFrameworks = new Set(FRAMEWORK_PATTERNS.map(p => p.framework.toLowerCase()));
880
884
  const wanted = String(options.framework).split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
881
- const unknown = wanted.filter(f => !validFrameworks.has(f));
885
+ const unknown = wanted.filter(f => !ENTRYPOINT_FRAMEWORKS.includes(f));
882
886
  if (unknown.length > 0) {
883
887
  return {
884
888
  error: 'invalid-framework',
885
- message: `Unknown framework "${unknown.join('", "')}". Valid: ${[...validFrameworks].sort().join(', ')}.`,
889
+ message: `Unknown framework "${unknown.join('", "')}". Valid: ${ENTRYPOINT_FRAMEWORKS.join(', ')}.`,
886
890
  };
887
891
  }
888
892
  }
@@ -1462,6 +1466,8 @@ function isFrameworkEntrypoint(symbol, index) {
1462
1466
  }
1463
1467
 
1464
1468
  module.exports = {
1469
+ ENTRYPOINT_TYPES,
1470
+ ENTRYPOINT_FRAMEWORKS,
1465
1471
  FRAMEWORK_PATTERNS,
1466
1472
  detectEntrypoints,
1467
1473
  isFrameworkEntrypoint,