ucn 5.5.3 → 5.6.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.
Files changed (77) hide show
  1. package/.claude/skills/ucn/SKILL.md +37 -12
  2. package/.claude/skills/ucn/references/commands.md +18 -14
  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 +485 -0
  6. package/README.md +66 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +137 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +274 -108
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +14 -0
  13. package/core/cache.js +103 -16
  14. package/core/call-fit.js +130 -0
  15. package/core/callers.js +353 -71
  16. package/core/capture.js +363 -0
  17. package/core/change-kinds.js +1107 -0
  18. package/core/check.js +1150 -139
  19. package/core/command-contracts.js +8 -7
  20. package/core/constructed-class.js +1195 -0
  21. package/core/contract-membership.js +142 -0
  22. package/core/deadcode.js +455 -106
  23. package/core/deletion.js +421 -0
  24. package/core/discovery.js +104 -9
  25. package/core/entrypoint-toml.js +110 -0
  26. package/core/entrypoints.js +12 -6
  27. package/core/evidence-bands.js +68 -0
  28. package/core/evidence-bands.json +44 -0
  29. package/core/execute.js +172 -161
  30. package/core/hot-summary.js +241 -0
  31. package/core/index-ir.js +5 -3
  32. package/core/ir.js +6 -2
  33. package/core/member-signatures.js +789 -0
  34. package/core/outgoing-check.js +116 -0
  35. package/core/output/analysis-ext.js +20 -10
  36. package/core/output/analysis.js +98 -60
  37. package/core/output/check.js +101 -37
  38. package/core/output/doctor.js +34 -1
  39. package/core/output/find.js +2 -1
  40. package/core/output/lines.js +63 -3
  41. package/core/output/public.js +29 -11
  42. package/core/output/refactoring.js +32 -0
  43. package/core/output/reporting.js +19 -2
  44. package/core/output/search.js +14 -2
  45. package/core/output/tracing.js +36 -8
  46. package/core/output-budget.js +146 -61
  47. package/core/parallel-build.js +3 -3
  48. package/core/project.js +106 -23
  49. package/core/provenance-type.js +12 -5
  50. package/core/public-command.js +35 -9
  51. package/core/python-binding-identity.js +707 -0
  52. package/core/python-modules.js +6 -0
  53. package/core/reflection.js +248 -31
  54. package/core/registry.js +623 -72
  55. package/core/reporting.js +69 -34
  56. package/core/review-items.js +75 -0
  57. package/core/runtime-entrypoints.js +159 -0
  58. package/core/search.js +148 -164
  59. package/core/shared.js +0 -45
  60. package/core/snapshot.js +350 -0
  61. package/core/source-universe.js +707 -0
  62. package/core/test-units.js +3698 -0
  63. package/core/tracing.js +270 -658
  64. package/core/verify.js +63 -22
  65. package/languages/adapter.js +8 -3
  66. package/languages/c-family.js +12 -0
  67. package/languages/csharp.js +634 -0
  68. package/languages/go.js +120 -0
  69. package/languages/index.js +466 -4
  70. package/languages/java.js +712 -0
  71. package/languages/javascript.js +1498 -6
  72. package/languages/lexical-scope.js +68 -0
  73. package/languages/python.js +1708 -3
  74. package/languages/rust.js +209 -104
  75. package/languages/static-js.js +88 -0
  76. package/mcp/server.js +44 -110
  77. package/package.json +11 -5
@@ -0,0 +1,421 @@
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 confirmed call or an unambiguous use exists
17
+ * OUTSIDE the declaration. The first such site is
18
+ * given. Unresolved name-only evidence remains
19
+ * review and never borrows another target's site;
20
+ * review no use outside the declaration was found, but a
21
+ * zero is not evidence here: a shield applies
22
+ * (consumers outside the repository, a contract, a
23
+ * registration, reflection, source that could not
24
+ * be searched) or the name is used inside the
25
+ * declaration itself. The reason is named.
26
+ *
27
+ * A use inside the declaration is deleted with it and is never a dependent:
28
+ * `fn len(&self) { self.inner.len() }` and an override that calls `super()`
29
+ * do not depend on themselves.
30
+ *
31
+ * Two things that look alike are kept apart:
32
+ *
33
+ * downstream exposure POLICY. An exported symbol may have consumers
34
+ * outside the repository. It blocks a claim in a
35
+ * library and not in a project its `.ucn.json`
36
+ * declares an application (or under
37
+ * --include-exported).
38
+ * external contract FACT about the declaration: it overrides or
39
+ * implements something defined outside the project,
40
+ * is registered, reflected or dispatched by name. It
41
+ * holds for unexported symbols and in applications.
42
+ */
43
+
44
+ 'use strict';
45
+
46
+ const path = require('path');
47
+
48
+ /** The kind a project declares for itself. Only `application` changes
49
+ * anything; everything else (absent, unknown) is a library. */
50
+ function projectKind(index) {
51
+ return index && index.config && index.config.kind === 'application'
52
+ ? 'application' : 'library';
53
+ }
54
+
55
+ /**
56
+ * Does the possibility of consumers outside the repository keep this
57
+ * exported symbol out of a deletion claim? `isExported` is the fact, read
58
+ * from the declaration; this is the policy over it.
59
+ */
60
+ function downstreamExposureBlocks(index, isExported, options = {}) {
61
+ return !!isExported && !options.includeExported && projectKind(index) === 'library';
62
+ }
63
+
64
+ /** A use of the name exists: the declaration has dependents. */
65
+ const DEPENDENT_REASONS = new Set(['has-callers', 'has-usages', 'member-in-use']);
66
+
67
+ /** Why the pipeline did or did not claim a declaration, in one sentence. */
68
+ const REASON_TEXT = Object.freeze({
69
+ 'claimed': 'the name has no use in project source outside its own declarations, and no shield applies',
70
+ 'has-callers': 'a call is proven to reach this declaration from outside it',
71
+ 'unresolved-use': 'uses of the name exist, but none is proven to reach this declaration',
72
+ 'identifier-normalization': 'Python normalizes a source identifier spelling that the caller graph does not model',
73
+ 'builtin-namespace-effect': 'project source can change the builtin namespace that this declaration relies on',
74
+ 'has-usages': 'the name is used outside the declaration',
75
+ 'member-in-use': 'a member of this type is called outside the declaration',
76
+ 'used-only-within-itself': 'every use found is inside the declaration itself, and the audit does not clear a name that is in use',
77
+ 'downstream-exposure': 'exported from a library: consumers outside the repository may use it',
78
+ 'external-contract': 'implements or overrides something defined outside the project',
79
+ 'required-slot': 'required by a contract of the project: removing it breaks the type that implements it',
80
+ 'structural-contract': 'its type has every member a project interface or protocol requires, so it can be used as one without naming it',
81
+ 'owner-ambiguous': 'several types of its owner\'s name share the file, so its contracts cannot be checked',
82
+ 'runtime-contract': 'invoked by the language runtime, the compiler or an assignment, not by name',
83
+ 'registered': 'decorated or registered with a framework, which may invoke it without naming it',
84
+ 'entry-point': 'an entry point the runtime or a test runner invokes',
85
+ 'runtime-entrypoint': 'packaging metadata registers this declaration, or its runtime entrypoints cannot be resolved',
86
+ 'constructor': 'a constructor: part of its type\'s allocation protocol, assessed with the type',
87
+ 'reflection': 'reflection in the project can reach this name',
88
+ 'dynamic-dispatch': 'member of a registry that is called through a computed key',
89
+ 'macro-reference': 'a macro expansion names it',
90
+ 'unanalyzed-occurrence': 'the name occurs in source that is searched as text and not analyzed',
91
+ 'unsearchable-source': 'part of the project could not be searched, so no name can be cleared',
92
+ 'test-file': 'declared in a test file, which the audit does not cover by default',
93
+ 'out-of-scope': 'outside the requested file scope',
94
+ 'kind-not-audited': 'this kind of declaration is not audited for deletion',
95
+ 'expression-scoped-name': 'a named function expression: its position invokes it, not its name',
96
+ 'macro-generated': 'generated by a macro invocation, not a declaration of its own',
97
+ 'bundled-file': 'declared in a bundled file',
98
+ 'operator-surface': 'invoked by operator or indexer syntax that never contains its name',
99
+ 'ambient-declaration': 'an ambient declaration that describes code outside the project',
100
+ 'declaration-not-found': 'no declaration matches this handle',
101
+ });
102
+
103
+ function verdictOf(reason) {
104
+ if (reason === 'claimed') return 'no-static-dependents';
105
+ if (DEPENDENT_REASONS.has(reason)) return 'has-static-dependents';
106
+ return 'review';
107
+ }
108
+
109
+ function sameDeclaration(symbol, declaration) {
110
+ return symbol.name === declaration.name &&
111
+ symbol.file === declaration.file &&
112
+ symbol.startLine === declaration.startLine;
113
+ }
114
+
115
+ function relativeOf(index, file) {
116
+ return path.relative(index.root, file).split(path.sep).join('/');
117
+ }
118
+
119
+ /** Code-unit order, never locale order: the first site must not depend on
120
+ * the machine that prints it. */
121
+ function compareSites(a, b) {
122
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
123
+ return (a.line || 0) - (b.line || 0);
124
+ }
125
+
126
+ /** Is this site inside the declaration, and so deleted with it? Only a
127
+ * declaration the index holds has lines in the current text. */
128
+ function withinDeclaration(declaration, indexed, file, line) {
129
+ if (!indexed || file !== declaration.file) return false;
130
+ const end = declaration.endLine != null ? declaration.endLine : declaration.startLine;
131
+ return line >= declaration.startLine && line <= end;
132
+ }
133
+
134
+ /**
135
+ * The first call of any of `names` outside the declaration, in
136
+ * (path, line) order, or null. A file whose call records cannot be read
137
+ * counts as a site with no line: an unknown is never read as "inside".
138
+ */
139
+ function callSiteOutside(index, declaration, indexed, names) {
140
+ if (!index.calleeIndex) return null;
141
+ const { getCachedCalls } = require('./callers');
142
+ const files = new Map(); // relative path -> absolute path
143
+ for (const name of names) {
144
+ for (const file of index.calleeIndex.get(name) || []) files.set(relativeOf(index, file), file);
145
+ }
146
+ const wanted = new Set(names);
147
+ for (const relativePath of [...files.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))) {
148
+ const file = files.get(relativePath);
149
+ const calls = getCachedCalls(index, file);
150
+ if (!calls) return { file: relativePath };
151
+ let line = null;
152
+ for (const call of calls) {
153
+ if (!wanted.has(call.name) && !wanted.has(call.resolvedName) &&
154
+ !(call.resolvedNames && call.resolvedNames.some(name => wanted.has(name)))) continue;
155
+ if (withinDeclaration(declaration, indexed, file, call.line)) continue;
156
+ if (line === null || call.line < line) line = call.line;
157
+ }
158
+ if (line !== null) return { file: relativePath, line };
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * The first use of the name outside the declaration, with the pipeline's
165
+ * reason for it, or null.
166
+ *
167
+ * The pipeline stops at the first thing that keeps a declaration out of the
168
+ * audit, which is right for an audit and wrong for a verdict in two ways.
169
+ * A shield hides what lies behind it: an exported symbol is never scanned
170
+ * for uses in a library, and "consumers outside may use it" must not hide
171
+ * that code inside the repository does. And a call of the name anywhere
172
+ * ends the chain, including a call inside the declaration itself.
173
+ *
174
+ * So calls are read from the callee index directly, and other uses come
175
+ * from one more pass with every shield a flag can lift lifted. When no call
176
+ * lies outside the declaration, that pass sends the name through the text
177
+ * scan under the pipeline's recursion carve-out, so the pipeline's own
178
+ * usage rule still decides what a use is.
179
+ */
180
+ function useOutsideDeclaration(index, declaration, indexed, deadcode, options) {
181
+ const name = declaration.name;
182
+ const call = callSiteOutside(index, declaration, indexed, [name]);
183
+ if (!indexed) {
184
+ const definite = definiteDependentsOf(index, declaration).find(site => site.kind === 'call');
185
+ if (definite) return { reason: 'has-callers', site: { file: definite.file, line: definite.line } };
186
+ }
187
+ const duplicateName = (index.symbols.get(name) || []).some(def => !sameDeclaration(def, declaration));
188
+ if (call || duplicateName) {
189
+ const callers = index.findCallers(name, { targetDefinitions: [declaration], collectAccount: true,
190
+ includeMethods: true, maxResults: Infinity });
191
+ const proven = callers.filter(c => c.tier !== 'unverified' &&
192
+ !withinDeclaration(declaration, indexed, c.file || path.join(index.root, c.relativePath), c.line))
193
+ .map(c => ({ file: c.relativePath || relativeOf(index, c.file), line: c.line })).sort(compareSites);
194
+ if (proven.length) return { reason: 'has-callers', site: proven[0] };
195
+ // A callee index is a name inventory, not selected-target evidence.
196
+ // Its conservative audit veto survives without a misleading site.
197
+ }
198
+ let found = null;
199
+ deadcode(index, {
200
+ names: new Set([name]),
201
+ includeExported: true,
202
+ includeDecorated: true,
203
+ includeTests: true,
204
+ ownBodyOnly: new Set([name]),
205
+ ...(!indexed && {
206
+ declarations: [{ symbol: declaration, fileEntry: options.fileEntry || null }],
207
+ }),
208
+ explain: (symbol, reason, detail) => {
209
+ if (!sameDeclaration(symbol, declaration)) return;
210
+ if (reason === 'member-in-use') {
211
+ const site = callSiteOutside(index, declaration, indexed, (detail && detail.members) || []);
212
+ if (site) found = { reason, site };
213
+ } else if (reason === 'has-usages') {
214
+ const sites = ((detail && detail.usages) || [])
215
+ .filter(usage => !withinDeclaration(declaration, indexed, usage.file, usage.line))
216
+ .filter(usage => !(index.getCachedCalls(usage.file) || []).some(candidate =>
217
+ candidate.line === usage.line && !candidate.isFunctionReference &&
218
+ (candidate.name === name || candidate.resolvedName === name)))
219
+ .map(usage => ({ file: relativeOf(index, usage.file), line: usage.line }))
220
+ .sort(compareSites);
221
+ if (sites.length > 0) found = duplicateName ? { reason: 'unresolved-use' } : { reason, site: sites[0] };
222
+ }
223
+ },
224
+ });
225
+ return found || (call ? { reason: 'unresolved-use' } : null);
226
+ }
227
+
228
+ /**
229
+ * Assess one declaration for deletion.
230
+ *
231
+ * @param {object} index - ProjectIndex of the snapshot the declaration's
232
+ * USES are looked for in
233
+ * @param {object} declaration - a symbol record: { name, file (absolute),
234
+ * startLine, ... }. One the index holds is assessed in place. One it does
235
+ * not hold (deleted since, or taken from a base revision) is assessed
236
+ * from the record itself; pass `fileEntry` with the file facts of the
237
+ * revision it was declared in when they differ from the index's.
238
+ * @param {object} [options] - { includeExported, includeDecorated,
239
+ * includeTests, fileEntry }
240
+ */
241
+ function assessDeletion(index, declaration, options = {}) {
242
+ const { deadcode } = require('./deadcode');
243
+ const indexed = (index.symbols.get(declaration.name) || [])
244
+ .some(symbol => sameDeclaration(symbol, declaration));
245
+ const outcomes = [];
246
+ const audit = deadcode(index, {
247
+ names: new Set([declaration.name]),
248
+ includeExported: !!options.includeExported,
249
+ includeDecorated: !!options.includeDecorated,
250
+ includeTests: !!options.includeTests,
251
+ ...(!indexed && {
252
+ declarations: [{ symbol: declaration, fileEntry: options.fileEntry || null }],
253
+ }),
254
+ explain: (symbol, reason, row) => outcomes.push({ symbol, reason, row }),
255
+ });
256
+ const outcome = outcomes.find(entry => sameDeclaration(entry.symbol, declaration));
257
+ let reason = outcome ? outcome.reason : 'declaration-not-found';
258
+ // --include-exported LISTS a declaration bound to an external contract,
259
+ // tagged, for review. Listed is not cleared: the contract is a fact
260
+ // about the declaration and no flag removes it.
261
+ if (reason === 'claimed' && outcome.row && outcome.row.externalContract) {
262
+ reason = 'external-contract';
263
+ }
264
+ // A use outside the declaration outranks everything but a claim. A
265
+ // dependent reason with no such use is no dependent: the name is used
266
+ // only inside what would be deleted.
267
+ let site = null;
268
+ if (outcome && reason !== 'claimed') {
269
+ const use = useOutsideDeclaration(index, declaration, indexed, deadcode, options);
270
+ if (use) {
271
+ reason = use.reason;
272
+ site = use.site;
273
+ } else if (DEPENDENT_REASONS.has(reason)) {
274
+ reason = 'used-only-within-itself';
275
+ }
276
+ }
277
+ const relativePath = declaration.relativePath ||
278
+ path.relative(index.root, declaration.file).split(path.sep).join('/');
279
+ return {
280
+ declaration: {
281
+ name: declaration.name,
282
+ file: relativePath,
283
+ startLine: declaration.startLine,
284
+ ...(declaration.endLine != null && { endLine: declaration.endLine }),
285
+ ...(declaration.type && { type: declaration.type }),
286
+ ...(declaration.className && { className: declaration.className }),
287
+ },
288
+ verdict: verdictOf(reason),
289
+ reason,
290
+ basis: REASON_TEXT[reason] || reason,
291
+ ...(site && { site }),
292
+ projectKind: projectKind(index),
293
+ indexed,
294
+ coverage: {
295
+ complete: audit.coverage.complete,
296
+ claimsWithdrawn: audit.coverage.claimsWithdrawn,
297
+ },
298
+ // Static evidence only. Aliases, reflection the engine does not
299
+ // model, generated code and consumers outside the repository are
300
+ // beyond it: the toolchain and the tests decide.
301
+ safeToDelete: false,
302
+ semanticComplete: false,
303
+ requiresCompilerAndTests: true,
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Dependents of a declaration that CANNOT survive its deletion: sites the
309
+ * toolchain will reject, not sites that might be affected.
310
+ *
311
+ * import another file imports the name from the module that declared
312
+ * it, and that module no longer declares or exports a name like
313
+ * it;
314
+ * call the declaring file (or, where a directory is the package, a
315
+ * file beside it) still calls the name bare, and no declaration,
316
+ * import or parameter of that name remains for the call to mean.
317
+ *
318
+ * A method call on a receiver, a call through an alias, a reference by a
319
+ * module path: those are dependents the engine reports, never definite
320
+ * ones. Meant for a declaration the index no longer holds; one it holds
321
+ * still satisfies every site, so the answer is empty.
322
+ *
323
+ * @returns {{file, line, kind: 'import'|'call'}[]} sorted (file, line)
324
+ */
325
+ /** Is `module.name` itself a module the importer resolved (a submodule)? */
326
+ function submoduleOf(resolved, module, name) {
327
+ const spelled = String(module || '');
328
+ return !!resolved[spelled.endsWith('.') ? spelled + name : `${spelled}.${name}`];
329
+ }
330
+
331
+ function definiteDependentsOf(index, declaration) {
332
+ const name = declaration.name;
333
+ const declaredIn = declaration.relativePath || relativeOf(index, declaration.file);
334
+ // Declarations of the name that a module-scope reference can mean: a
335
+ // member, or a function local to another function, supplies nothing
336
+ // there.
337
+ const stillDeclared = (index.symbols.get(name) || []).filter(symbol =>
338
+ !symbol.className && symbol.lexicalScopeStartLine == null && !symbol.isNested);
339
+ const sites = [];
340
+ // Imports of the name from its former module. A module that still has
341
+ // a declaration of the name, still lists it among its exports, or
342
+ // re-exports everything from elsewhere may still provide it.
343
+ const formerModule = index.files.get(declaration.file);
344
+ const moduleStillProvides = !!formerModule && (
345
+ stillDeclared.some(symbol => symbol.file === declaration.file) ||
346
+ (formerModule.exports || []).includes(name) ||
347
+ (formerModule.exportDetails || []).some(detail => detail && (detail.name === '*' || detail.type === 'star')));
348
+ if (!moduleStillProvides) {
349
+ for (const entry of index.files.values()) {
350
+ const resolved = entry.moduleResolved || {};
351
+ for (const binding of entry.importBindings || []) {
352
+ if (binding.name !== name || binding.dynamic) continue;
353
+ if (resolved[binding.module] !== declaredIn) continue;
354
+ // `from pkg import name` where `pkg/name` is a module of
355
+ // the package imports that module, not a name of pkg.
356
+ if (submoduleOf(resolved, binding.module, name)) continue;
357
+ sites.push(Object.defineProperty({ file: entry.relativePath, line: binding.line || 0, kind: 'import' },
358
+ 'module', { value: binding.module, enumerable: false }));
359
+ }
360
+ }
361
+ }
362
+ // Bare calls with nothing left to mean. Only a free function: a member
363
+ // is called through a receiver. The language's own scoping decides
364
+ // what a bare name means at each call (languages/lexical-scope.js):
365
+ // only a name that resolves at module scope, in a file where no import
366
+ // and no other binding supplies it, is left without a meaning.
367
+ if (!declaration.className) {
368
+ const { langTraits } = require('../languages');
369
+ const { referenceScope } = require('../languages/lexical-scope');
370
+ const { parseRevision } = require('./change-kinds');
371
+ const traits = langTraits(formerModule?.language || index.languageOf?.(declaration.file)) || {};
372
+ const directoryPackage = traits.packageScope === 'directory';
373
+ const beside = file => file === declaration.file ||
374
+ (directoryPackage && path.dirname(file) === path.dirname(declaration.file));
375
+ // What a module-scope name can still mean in `file`: a declaration
376
+ // in the file, in its package directory where that is the scope,
377
+ // or anywhere at all where files see each other through textual
378
+ // inclusion.
379
+ // A declaration that describes code outside the project (an
380
+ // ambient one) supplies the name to every file.
381
+ const ambient = symbol => !!symbol.ambientAugmentation || /\.d\.[cm]?ts$/.test(symbol.relativePath || '');
382
+ const suppliedTo = file => stillDeclared.some(symbol => traits.textualIncludes || ambient(symbol) ||
383
+ symbol.file === file || (directoryPackage && path.dirname(symbol.file) === path.dirname(file)));
384
+ // A name the language itself supplies always means something.
385
+ const builtin = !!(traits.ambientCallables && traits.ambientCallables.has(name));
386
+ for (const file of builtin ? [] : [...(index.calleeIndex?.get(name) || [])].sort()) {
387
+ if (!beside(file) || suppliedTo(file)) continue;
388
+ const entry = index.files.get(file);
389
+ if (!entry) continue;
390
+ if ((entry.importBindings || []).some(binding => (binding.alias || binding.name) === name)) continue;
391
+ // An import of everything a module has may bring the name.
392
+ if ((entry.importDetails || []).some(detail => (detail.names || []).includes('*'))) continue;
393
+ let root;
394
+ try { root = parseRevision(index._readFile(file), entry.language); } catch (_) { root = null; }
395
+ if (!root) continue;
396
+ const memo = new Map();
397
+ const lines = new Set();
398
+ for (const node of root.descendantsOfType('identifier')) {
399
+ if (node.text !== name) continue;
400
+ const callee = node.parent?.childForFieldName('function');
401
+ if (!callee || callee.id !== node.id) continue;
402
+ let scope;
403
+ try { scope = referenceScope(node, entry.language, memo); } catch (_) { scope = null; }
404
+ if (scope === 'module') lines.add(node.startPosition.row + 1);
405
+ }
406
+ for (const line of lines) sites.push({ file: entry.relativePath, line, kind: 'call' });
407
+ }
408
+ }
409
+ return sites.sort(compareSites);
410
+ }
411
+
412
+ module.exports = {
413
+ definiteDependentsOf,
414
+ submoduleOf,
415
+ projectKind,
416
+ downstreamExposureBlocks,
417
+ assessDeletion,
418
+ verdictOf,
419
+ REASON_TEXT,
420
+ DEPENDENT_REASONS,
421
+ };
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,