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.
- package/.claude/skills/ucn/SKILL.md +37 -12
- package/.claude/skills/ucn/references/commands.md +18 -14
- package/.claude/skills/ucn/references/resolution-rules.md +4 -0
- package/.claude/skills/ucn/references/trust-contract.md +4 -4
- package/CHANGELOG.md +485 -0
- package/README.md +66 -29
- package/cli/flags.js +184 -0
- package/cli/index.js +137 -324
- package/core/account.js +44 -87
- package/core/analysis.js +274 -108
- package/core/ast-analysis.js +2 -0
- package/core/bridge.js +14 -0
- package/core/cache.js +103 -16
- package/core/call-fit.js +130 -0
- package/core/callers.js +353 -71
- package/core/capture.js +363 -0
- package/core/change-kinds.js +1107 -0
- package/core/check.js +1150 -139
- package/core/command-contracts.js +8 -7
- package/core/constructed-class.js +1195 -0
- package/core/contract-membership.js +142 -0
- package/core/deadcode.js +455 -106
- package/core/deletion.js +421 -0
- package/core/discovery.js +104 -9
- package/core/entrypoint-toml.js +110 -0
- package/core/entrypoints.js +12 -6
- package/core/evidence-bands.js +68 -0
- package/core/evidence-bands.json +44 -0
- package/core/execute.js +172 -161
- package/core/hot-summary.js +241 -0
- package/core/index-ir.js +5 -3
- package/core/ir.js +6 -2
- package/core/member-signatures.js +789 -0
- package/core/outgoing-check.js +116 -0
- package/core/output/analysis-ext.js +20 -10
- package/core/output/analysis.js +98 -60
- package/core/output/check.js +101 -37
- package/core/output/doctor.js +34 -1
- package/core/output/find.js +2 -1
- package/core/output/lines.js +63 -3
- package/core/output/public.js +29 -11
- package/core/output/refactoring.js +32 -0
- package/core/output/reporting.js +19 -2
- package/core/output/search.js +14 -2
- package/core/output/tracing.js +36 -8
- package/core/output-budget.js +146 -61
- package/core/parallel-build.js +3 -3
- package/core/project.js +106 -23
- package/core/provenance-type.js +12 -5
- package/core/public-command.js +35 -9
- package/core/python-binding-identity.js +707 -0
- package/core/python-modules.js +6 -0
- package/core/reflection.js +248 -31
- package/core/registry.js +623 -72
- package/core/reporting.js +69 -34
- package/core/review-items.js +75 -0
- package/core/runtime-entrypoints.js +159 -0
- package/core/search.js +148 -164
- package/core/shared.js +0 -45
- package/core/snapshot.js +350 -0
- package/core/source-universe.js +707 -0
- package/core/test-units.js +3698 -0
- package/core/tracing.js +270 -658
- package/core/verify.js +63 -22
- package/languages/adapter.js +8 -3
- package/languages/c-family.js +12 -0
- package/languages/csharp.js +634 -0
- package/languages/go.js +120 -0
- package/languages/index.js +466 -4
- package/languages/java.js +712 -0
- package/languages/javascript.js +1498 -6
- package/languages/lexical-scope.js +68 -0
- package/languages/python.js +1708 -3
- package/languages/rust.js +209 -104
- package/languages/static-js.js +88 -0
- package/mcp/server.js +44 -110
- package/package.json +11 -5
package/core/deletion.js
ADDED
|
@@ -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:
|
|
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 ??
|
|
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 &&
|
|
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
|
-
|
|
612
|
-
|
|
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: '
|
|
617
|
-
detail: '
|
|
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,
|