ucn 5.5.3 → 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.
- package/.claude/skills/ucn/SKILL.md +35 -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 +474 -0
- package/README.md +69 -29
- package/cli/flags.js +184 -0
- package/cli/index.js +137 -324
- package/core/account.js +44 -87
- package/core/analysis.js +273 -107
- package/core/ast-analysis.js +2 -0
- package/core/bridge.js +14 -0
- package/core/cache.js +101 -16
- package/core/callers.js +352 -70
- package/core/capture.js +331 -0
- package/core/change-kinds.js +888 -0
- package/core/check.js +1117 -139
- package/core/command-contracts.js +7 -6
- package/core/constructed-class.js +1195 -0
- package/core/contract-membership.js +142 -0
- package/core/deadcode.js +362 -84
- package/core/deletion.js +401 -0
- package/core/discovery.js +104 -9
- 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 +3 -1
- package/core/ir.js +5 -1
- package/core/member-signatures.js +789 -0
- package/core/output/analysis-ext.js +20 -10
- package/core/output/analysis.js +98 -60
- package/core/output/check.js +98 -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 +15 -2
- package/core/output/search.js +11 -2
- package/core/output/tracing.js +35 -7
- package/core/output-budget.js +124 -57
- 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-modules.js +6 -0
- package/core/reflection.js +151 -23
- package/core/registry.js +623 -72
- package/core/reporting.js +69 -34
- package/core/review-items.js +75 -0
- package/core/search.js +89 -128
- package/core/shared.js +0 -45
- package/core/snapshot.js +350 -0
- package/core/source-universe.js +707 -0
- package/core/test-units.js +3624 -0
- package/core/tracing.js +150 -96
- package/core/verify.js +27 -11
- 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 +422 -4
- package/languages/java.js +712 -0
- package/languages/javascript.js +1460 -2
- package/languages/lexical-scope.js +68 -0
- package/languages/python.js +1672 -1
- package/languages/rust.js +91 -0
- package/mcp/server.js +44 -110
- package/package.json +10 -5
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/source-universe.js - What a completeness claim ranges over.
|
|
3
|
+
*
|
|
4
|
+
* An index analyzes the files it parsed. A "text partition complete" claim
|
|
5
|
+
* is about more than those: it says no other occurrence of the name exists
|
|
6
|
+
* in the project's source. This module is the single owner of that second
|
|
7
|
+
* set. Every discovered path that is not an indexed file falls into exactly
|
|
8
|
+
* one class:
|
|
9
|
+
*
|
|
10
|
+
* text-only readable source UCN does not analyze: an unsupported
|
|
11
|
+
* language, a bundled/minified file, a file over the size
|
|
12
|
+
* limit, a file beyond the file limit, a path the project's
|
|
13
|
+
* `.ucn.json` excludes from indexing. It is word-scanned per
|
|
14
|
+
* name, and it weakens a claim only for a name that occurs
|
|
15
|
+
* in it.
|
|
16
|
+
* opaque a path whose contents cannot be read name by name: an
|
|
17
|
+
* unreadable file or directory, a directory past the depth
|
|
18
|
+
* limit, a failed git listing, an unusable config, text beyond
|
|
19
|
+
* the scan budget. Nothing can be said about any name, so it
|
|
20
|
+
* withdraws the claim for every name.
|
|
21
|
+
* outside not project source by declaration or by kind: the default
|
|
22
|
+
* ignores, .gitignore, `.ucn.json` `outsideProject`, source
|
|
23
|
+
* maps (data). It is no part of the claim and is stated in the
|
|
24
|
+
* repository's scope summary.
|
|
25
|
+
*
|
|
26
|
+
* Consumers (accounts, dead-code coverage, command notes, health, check)
|
|
27
|
+
* read this model. None of them interprets raw discovery issues.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
const { StringDecoder } = require('string_decoder');
|
|
35
|
+
const { codeUnitCompare, literalNameRegex } = require('./shared');
|
|
36
|
+
|
|
37
|
+
/** Total bytes of text-only source scanned per name. Text past the budget
|
|
38
|
+
* becomes opaque: the limit is a property of the repository, never of the
|
|
39
|
+
* query, so the same name gets the same answer every time. */
|
|
40
|
+
const DEFAULT_TEXT_SCAN_BUDGET = 256 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
/** A text-only file up to this size is read whole (and shared within one
|
|
43
|
+
* operation); a larger one is streamed and never retained. */
|
|
44
|
+
const WHOLE_FILE_BYTES = 1024 * 1024;
|
|
45
|
+
const STREAM_CHUNK_BYTES = 1024 * 1024;
|
|
46
|
+
|
|
47
|
+
const SITE_CAP = 50;
|
|
48
|
+
const SITE_TEXT_MAX = 160;
|
|
49
|
+
|
|
50
|
+
const TEXT_ONLY_ISSUES = new Set(['bundled', 'config-exclude', 'max-file-size', 'max-files']);
|
|
51
|
+
|
|
52
|
+
/** How a text-only class is named in an answer. An unsupported-language
|
|
53
|
+
* file is named by its language instead. */
|
|
54
|
+
const REASON_LABELS = Object.freeze({
|
|
55
|
+
'bundled': 'bundled/minified',
|
|
56
|
+
'config-exclude': 'excluded by .ucn.json',
|
|
57
|
+
'max-file-size': 'over the file-size limit',
|
|
58
|
+
'max-files': 'beyond the file limit',
|
|
59
|
+
'unsupported-language': 'unsupported language',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function isSourceMap(relativePath) {
|
|
63
|
+
return relativePath.endsWith('.map');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scanBudget(index) {
|
|
67
|
+
const configured = index.config && index.config.textScanBudgetBytes;
|
|
68
|
+
return Number.isFinite(configured) && configured >= 0 ? configured : DEFAULT_TEXT_SCAN_BUDGET;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Classify every non-indexed path of the index. Memoized per build: the
|
|
73
|
+
* result is a function of the discovery facts and the files' sizes.
|
|
74
|
+
*
|
|
75
|
+
* @returns {{
|
|
76
|
+
* textOnly: Array<{relativePath, reason, label, size, mtimeMs}>, // path order
|
|
77
|
+
* opaque: Array<{relativePath, kind, reason, detail}>,
|
|
78
|
+
* outside: Array<{relativePath, kind, reason}>,
|
|
79
|
+
* budget: {limitBytes, usedBytes},
|
|
80
|
+
* }}
|
|
81
|
+
*/
|
|
82
|
+
function universe(index) {
|
|
83
|
+
if (index._sourceUniverse) return index._sourceUniverse;
|
|
84
|
+
const candidates = new Map();
|
|
85
|
+
const opaque = [];
|
|
86
|
+
const outside = [];
|
|
87
|
+
for (const file of index.unsupportedFiles || []) {
|
|
88
|
+
candidates.set(file.relativePath, {
|
|
89
|
+
relativePath: file.relativePath,
|
|
90
|
+
reason: 'unsupported-language',
|
|
91
|
+
label: file.language || file.extension || 'unknown',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
for (const issue of index.discoveryIssues || []) {
|
|
95
|
+
const relativePath = issue.relativePath || '.';
|
|
96
|
+
if (issue.reason === 'outside-project' ||
|
|
97
|
+
(issue.reason === 'bundled' && isSourceMap(relativePath))) {
|
|
98
|
+
outside.push({
|
|
99
|
+
relativePath,
|
|
100
|
+
kind: issue.kind,
|
|
101
|
+
reason: issue.reason === 'bundled' ? 'source-map' : issue.reason,
|
|
102
|
+
});
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (TEXT_ONLY_ISSUES.has(issue.reason) && issue.kind === 'file') {
|
|
106
|
+
if (!candidates.has(relativePath)) {
|
|
107
|
+
candidates.set(relativePath, {
|
|
108
|
+
relativePath,
|
|
109
|
+
reason: issue.reason,
|
|
110
|
+
label: REASON_LABELS[issue.reason],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
opaque.push({
|
|
116
|
+
relativePath, kind: issue.kind, reason: issue.reason, detail: issue.detail,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Sizes decide the budget. A candidate that cannot be read as a file
|
|
121
|
+
// (missing, not a regular file, no read permission) is opaque, like any
|
|
122
|
+
// other unreadable path. Every candidate keeps its stat, whatever class
|
|
123
|
+
// it ends in: an edit to ANY of them can change the budget assignment.
|
|
124
|
+
const sized = [];
|
|
125
|
+
const candidateStats = [];
|
|
126
|
+
for (const entry of candidates.values()) {
|
|
127
|
+
const stat = readableStat(path.join(index.root, entry.relativePath));
|
|
128
|
+
candidateStats.push({
|
|
129
|
+
relativePath: entry.relativePath, reason: entry.reason,
|
|
130
|
+
size: stat.size, mtimeMs: stat.mtimeMs, readable: stat.readable,
|
|
131
|
+
});
|
|
132
|
+
if (stat.readable) {
|
|
133
|
+
sized.push({ ...entry, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
134
|
+
} else {
|
|
135
|
+
opaque.push({
|
|
136
|
+
relativePath: entry.relativePath, kind: 'file',
|
|
137
|
+
reason: 'unreadable-file', detail: stat.detail,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Smallest first, then path: the largest files are the ones a budget
|
|
142
|
+
// gives up, and the assignment depends on nothing but the file table.
|
|
143
|
+
sized.sort((a, b) => a.size - b.size || codeUnitCompare(a.relativePath, b.relativePath));
|
|
144
|
+
const limitBytes = scanBudget(index);
|
|
145
|
+
let usedBytes = 0;
|
|
146
|
+
const textOnly = [];
|
|
147
|
+
for (const entry of sized) {
|
|
148
|
+
if (usedBytes + entry.size > limitBytes) {
|
|
149
|
+
opaque.push({
|
|
150
|
+
relativePath: entry.relativePath, kind: 'file', reason: 'text-scan-budget',
|
|
151
|
+
detail: `text-only source beyond the ${limitBytes}-byte scan budget`,
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
usedBytes += entry.size;
|
|
156
|
+
textOnly.push(entry);
|
|
157
|
+
}
|
|
158
|
+
const byPath = (a, b) => codeUnitCompare(a.relativePath, b.relativePath);
|
|
159
|
+
textOnly.sort(byPath);
|
|
160
|
+
opaque.sort((a, b) => byPath(a, b) || codeUnitCompare(a.reason, b.reason));
|
|
161
|
+
outside.sort(byPath);
|
|
162
|
+
index._sourceUniverse = {
|
|
163
|
+
textOnly, opaque, outside, budget: { limitBytes, usedBytes },
|
|
164
|
+
candidates: candidateStats,
|
|
165
|
+
};
|
|
166
|
+
return index._sourceUniverse;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Size, mtime and readability of a text-only candidate, without throwing. */
|
|
170
|
+
function readableStat(absolutePath) {
|
|
171
|
+
try {
|
|
172
|
+
const stat = fs.statSync(absolutePath);
|
|
173
|
+
if (!stat.isFile()) {
|
|
174
|
+
return { readable: false, size: null, mtimeMs: null, detail: 'not a regular file' };
|
|
175
|
+
}
|
|
176
|
+
fs.accessSync(absolutePath, fs.constants.R_OK);
|
|
177
|
+
return { readable: true, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return { readable: false, size: null, mtimeMs: null, detail: error.message };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A text-only file that could not be read when it was scanned: it is opaque
|
|
185
|
+
* from here on, so the same answer and every later one say that part of the
|
|
186
|
+
* project could not be searched. A read failure never just drops a file
|
|
187
|
+
* from an answer.
|
|
188
|
+
*/
|
|
189
|
+
function demoteToOpaque(index, entry, error) {
|
|
190
|
+
const model = universe(index);
|
|
191
|
+
const at = model.textOnly.findIndex(item => item.relativePath === entry.relativePath);
|
|
192
|
+
if (at === -1) return;
|
|
193
|
+
model.textOnly.splice(at, 1);
|
|
194
|
+
model.budget.usedBytes -= entry.size || 0;
|
|
195
|
+
model.opaque.push({
|
|
196
|
+
relativePath: entry.relativePath, kind: 'file', reason: 'unreadable-file',
|
|
197
|
+
detail: error && error.message ? error.message : 'read failed',
|
|
198
|
+
});
|
|
199
|
+
model.opaque.sort((a, b) => codeUnitCompare(a.relativePath, b.relativePath) ||
|
|
200
|
+
codeUnitCompare(a.reason, b.reason));
|
|
201
|
+
// The candidate is unreadable NOW. Recording that makes the next
|
|
202
|
+
// staleness check see a file that became readable again as a change,
|
|
203
|
+
// even when its size and mtime never moved (a permission that came
|
|
204
|
+
// back); otherwise the path stays unsearchable until someone edits it.
|
|
205
|
+
const candidate = (model.candidates || [])
|
|
206
|
+
.find(item => item.relativePath === entry.relativePath);
|
|
207
|
+
if (candidate) {
|
|
208
|
+
candidate.readable = false;
|
|
209
|
+
candidate.size = null;
|
|
210
|
+
candidate.mtimeMs = null;
|
|
211
|
+
}
|
|
212
|
+
// Scans made while it still counted as searchable are not reusable.
|
|
213
|
+
index._groundSetCache = new Map();
|
|
214
|
+
index._groundSetCacheLines = 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Drop the memoized universe (a build, a cache load, a refreshed file). */
|
|
218
|
+
function resetUniverse(index) {
|
|
219
|
+
index._sourceUniverse = null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Scanning ────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/** Decoded text of a file in bounded pieces. */
|
|
225
|
+
function* streamChunks(absolutePath) {
|
|
226
|
+
const descriptor = fs.openSync(absolutePath, 'r');
|
|
227
|
+
try {
|
|
228
|
+
const buffer = Buffer.allocUnsafe(STREAM_CHUNK_BYTES);
|
|
229
|
+
const decoder = new StringDecoder('utf8');
|
|
230
|
+
for (;;) {
|
|
231
|
+
const read = fs.readSync(descriptor, buffer, 0, buffer.length, null);
|
|
232
|
+
if (read === 0) break;
|
|
233
|
+
yield decoder.write(buffer.subarray(0, read));
|
|
234
|
+
}
|
|
235
|
+
const rest = decoder.end();
|
|
236
|
+
if (rest) yield rest;
|
|
237
|
+
} finally {
|
|
238
|
+
fs.closeSync(descriptor);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function* textOnlyChunks(index, absolutePath, size) {
|
|
243
|
+
if (size <= WHOLE_FILE_BYTES) {
|
|
244
|
+
yield index._readFile(absolutePath);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
yield* streamChunks(absolutePath);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The matched line as an answer shows it: the whole line when it is short,
|
|
251
|
+
* a window around the match when it is not (a minified file is one line). */
|
|
252
|
+
function siteText(text, at, nameLength) {
|
|
253
|
+
const lineStart = text.lastIndexOf('\n', at - 1) + 1;
|
|
254
|
+
let lineEnd = text.indexOf('\n', at);
|
|
255
|
+
if (lineEnd === -1) lineEnd = text.length;
|
|
256
|
+
if (lineEnd - lineStart <= SITE_TEXT_MAX) {
|
|
257
|
+
return detached(text.slice(lineStart, lineEnd).trim().slice(0, SITE_TEXT_MAX));
|
|
258
|
+
}
|
|
259
|
+
let from = Math.max(lineStart, at - 60);
|
|
260
|
+
let to = Math.min(lineEnd, from + SITE_TEXT_MAX);
|
|
261
|
+
// A window edge never falls inside a character.
|
|
262
|
+
if (from > lineStart && isLowSurrogate(text.charCodeAt(from))) from++;
|
|
263
|
+
if (to < lineEnd && isHighSurrogate(text.charCodeAt(to - 1))) to--;
|
|
264
|
+
return detached(text.slice(from, to).trim());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A copy of a short string that shares no storage with the text it was cut
|
|
269
|
+
* from. A substring of a large string can keep the whole string alive; fifty
|
|
270
|
+
* retained site lines must not hold fifty megabyte chunks.
|
|
271
|
+
*/
|
|
272
|
+
function detached(piece) {
|
|
273
|
+
return Buffer.from(piece, 'utf8').toString('utf8');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Lines of a text on which `name` occurs as a whole word, over any chunking
|
|
278
|
+
* of that text. Semantics are those of the ground set (`grep -n -w`): the
|
|
279
|
+
* unit is the line, a line counts once, boundaries are `literalNameRegex`'s.
|
|
280
|
+
*
|
|
281
|
+
* A match is decided only when the characters around it are in hand: the
|
|
282
|
+
* scanner holds back the tail of a chunk until the next one arrives, and
|
|
283
|
+
* keeps a short prefix so a boundary and a site window never straddle a
|
|
284
|
+
* dropped edge.
|
|
285
|
+
*
|
|
286
|
+
* onMatch(line, text) is called once per matching line, in line order.
|
|
287
|
+
* `texts` bounds how many of those calls carry the line's text (the rest
|
|
288
|
+
* get null): a caller that lists fifty sites of a million matching lines
|
|
289
|
+
* must not have a million strings built for it.
|
|
290
|
+
*/
|
|
291
|
+
function scanChunksForName(chunks, name, onMatch, { texts = Infinity } = {}) {
|
|
292
|
+
if (!name || name.includes('\n')) return;
|
|
293
|
+
let textsLeft = texts;
|
|
294
|
+
const wordRe = literalNameRegex(name, 'y');
|
|
295
|
+
// Post-context held back: the match, one boundary character (two code
|
|
296
|
+
// units for an astral one) and the rest of a short line for the site.
|
|
297
|
+
const hold = name.length + SITE_TEXT_MAX + 96;
|
|
298
|
+
const keepBefore = SITE_TEXT_MAX + 96;
|
|
299
|
+
let text = '';
|
|
300
|
+
let cursor = 0; // next index to search from
|
|
301
|
+
let counted = 0; // newlines are counted up to this index
|
|
302
|
+
let line = 1; // line number at `counted`
|
|
303
|
+
let lastLine = 0; // last line reported
|
|
304
|
+
let skipLine = false; // a line already matched extends past the buffer
|
|
305
|
+
|
|
306
|
+
const lineAt = position => {
|
|
307
|
+
let newline = text.indexOf('\n', counted);
|
|
308
|
+
while (newline !== -1 && newline < position) {
|
|
309
|
+
line++;
|
|
310
|
+
newline = text.indexOf('\n', newline + 1);
|
|
311
|
+
}
|
|
312
|
+
counted = position;
|
|
313
|
+
return line;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const drain = final => {
|
|
317
|
+
const limit = final ? text.length : Math.max(0, text.length - hold);
|
|
318
|
+
while (cursor < limit) {
|
|
319
|
+
if (skipLine) {
|
|
320
|
+
const newline = text.indexOf('\n', cursor);
|
|
321
|
+
if (newline === -1 || newline >= limit) { cursor = limit; break; }
|
|
322
|
+
cursor = newline + 1;
|
|
323
|
+
skipLine = false;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const at = text.indexOf(name, cursor);
|
|
327
|
+
if (at === -1 || at >= limit) { cursor = limit; break; }
|
|
328
|
+
wordRe.lastIndex = at;
|
|
329
|
+
if (!wordRe.test(text)) {
|
|
330
|
+
// A rejected candidate can overlap a later valid occurrence.
|
|
331
|
+
cursor = at + 1;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const matchedLine = lineAt(at);
|
|
335
|
+
if (matchedLine !== lastLine) {
|
|
336
|
+
lastLine = matchedLine;
|
|
337
|
+
if (textsLeft > 0) {
|
|
338
|
+
textsLeft--;
|
|
339
|
+
onMatch(matchedLine, siteText(text, at, name.length));
|
|
340
|
+
} else {
|
|
341
|
+
onMatch(matchedLine, null);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// The rest of this line cannot change the account.
|
|
345
|
+
const newline = text.indexOf('\n', at);
|
|
346
|
+
if (newline === -1 || newline >= limit) {
|
|
347
|
+
skipLine = true;
|
|
348
|
+
cursor = Math.min(limit, at + name.length);
|
|
349
|
+
} else {
|
|
350
|
+
cursor = newline + 1;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (final) return;
|
|
354
|
+
// Drop the decided prefix. Newlines in it are counted first unless a
|
|
355
|
+
// match beyond it already counted them.
|
|
356
|
+
const drop = Math.max(0, cursor - keepBefore);
|
|
357
|
+
if (drop > 0) {
|
|
358
|
+
if (drop > counted) lineAt(drop);
|
|
359
|
+
text = text.slice(drop);
|
|
360
|
+
cursor -= drop;
|
|
361
|
+
counted -= drop;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
for (const chunk of chunks) {
|
|
366
|
+
if (!chunk) continue;
|
|
367
|
+
text += chunk;
|
|
368
|
+
drain(false);
|
|
369
|
+
}
|
|
370
|
+
drain(true);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const WORD_RUN = /[\p{L}\p{N}_]+/gu;
|
|
374
|
+
const ALL_WORD = /^[\p{L}\p{N}_]+$/u;
|
|
375
|
+
const WORD_CHARACTER = /^[\p{L}\p{N}_]$/u;
|
|
376
|
+
|
|
377
|
+
function isHighSurrogate(unit) { return unit >= 0xd800 && unit <= 0xdbff; }
|
|
378
|
+
function isLowSurrogate(unit) { return unit >= 0xdc00 && unit <= 0xdfff; }
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Where the word that touches the end of `text` starts: everything from
|
|
382
|
+
* there on may continue in the next piece and must be held back. Walks
|
|
383
|
+
* CODE POINTS, never UTF-16 units: a letter outside the BMP is two units,
|
|
384
|
+
* neither of which is a letter on its own, so a unit-wise walk stops inside
|
|
385
|
+
* it and splits the word (`a𐐀` + `b` read as the words `a𐐀` and `b`).
|
|
386
|
+
* A lone high surrogate at the very end is half of a character still to
|
|
387
|
+
* come and is held back with whatever word precedes it.
|
|
388
|
+
*/
|
|
389
|
+
function trailingWordStart(text) {
|
|
390
|
+
let end = text.length;
|
|
391
|
+
if (end > 0 && isHighSurrogate(text.charCodeAt(end - 1))) end--;
|
|
392
|
+
while (end > 0) {
|
|
393
|
+
const size = end >= 2 && isLowSurrogate(text.charCodeAt(end - 1)) &&
|
|
394
|
+
isHighSurrogate(text.charCodeAt(end - 2)) ? 2 : 1;
|
|
395
|
+
if (!WORD_CHARACTER.test(text.slice(end - size, end))) break;
|
|
396
|
+
end -= size;
|
|
397
|
+
}
|
|
398
|
+
return end;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Which of `names` occur as whole words in a text, in one pass. A name made
|
|
403
|
+
* only of word characters occurs exactly when a maximal word run equals it,
|
|
404
|
+
* which is `literalNameRegex`'s boundary rule; any other name is scanned on
|
|
405
|
+
* its own with that regex. Returns the Set of names found.
|
|
406
|
+
*/
|
|
407
|
+
function scanChunksForNames(makeChunks, names) {
|
|
408
|
+
const wordNames = new Set();
|
|
409
|
+
const otherNames = [];
|
|
410
|
+
for (const name of names) {
|
|
411
|
+
if (ALL_WORD.test(name)) wordNames.add(name);
|
|
412
|
+
else if (name) otherNames.push(name);
|
|
413
|
+
}
|
|
414
|
+
const found = new Set();
|
|
415
|
+
if (wordNames.size > 0) {
|
|
416
|
+
let carry = '';
|
|
417
|
+
const collect = (text, final) => {
|
|
418
|
+
// A run touching the end of a non-final piece may continue.
|
|
419
|
+
const end = final ? text.length : trailingWordStart(text);
|
|
420
|
+
const head = text.slice(0, end);
|
|
421
|
+
for (const run of head.match(WORD_RUN) || []) {
|
|
422
|
+
if (wordNames.has(run)) found.add(run);
|
|
423
|
+
}
|
|
424
|
+
return text.slice(end);
|
|
425
|
+
};
|
|
426
|
+
for (const chunk of makeChunks()) carry = collect(carry + chunk, false);
|
|
427
|
+
collect(carry, true);
|
|
428
|
+
}
|
|
429
|
+
for (const name of otherNames) {
|
|
430
|
+
let hit = false;
|
|
431
|
+
scanChunksForName(makeChunks(), name, () => { hit = true; }, { texts: 0 });
|
|
432
|
+
if (hit) found.add(name);
|
|
433
|
+
}
|
|
434
|
+
// The caller's own strings: a matched run is a substring of a chunk and
|
|
435
|
+
// would keep that chunk alive for as long as the result is held.
|
|
436
|
+
const result = new Set();
|
|
437
|
+
for (const name of names) if (found.has(name)) result.add(name);
|
|
438
|
+
return result;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The `unsupported` account bucket: lines of text-only source on which the
|
|
443
|
+
* name occurs. Shared by computeGroundSet and by commands whose result is
|
|
444
|
+
* not account-shaped (find, usages, tests, search) but must not stay silent
|
|
445
|
+
* about an occurrence UCN cannot analyze.
|
|
446
|
+
*
|
|
447
|
+
* @param {object} index - ProjectIndex
|
|
448
|
+
* @param {string} name
|
|
449
|
+
* @returns {{fileCount, lines, files, languages, reasons, sites, sitesTruncated}}
|
|
450
|
+
*/
|
|
451
|
+
function scanTextOnly(index, name) {
|
|
452
|
+
const bucket = {
|
|
453
|
+
fileCount: 0, lines: 0, files: [], languages: {}, reasons: {},
|
|
454
|
+
sites: [], sitesTruncated: false,
|
|
455
|
+
};
|
|
456
|
+
const { textOnly } = universe(index);
|
|
457
|
+
if (textOnly.length === 0 || !name) return bucket;
|
|
458
|
+
const languages = new Map();
|
|
459
|
+
const reasons = new Map();
|
|
460
|
+
for (const entry of [...textOnly]) {
|
|
461
|
+
const absolutePath = path.join(index.root, entry.relativePath);
|
|
462
|
+
let matched = 0;
|
|
463
|
+
// Count every matching line; keep only as many sites as the listing
|
|
464
|
+
// still has room for. The sample is merged after the file was read
|
|
465
|
+
// to the end, so a read that fails halfway contributes nothing.
|
|
466
|
+
const room = SITE_CAP - bucket.sites.length;
|
|
467
|
+
const sample = [];
|
|
468
|
+
try {
|
|
469
|
+
scanChunksForName(textOnlyChunks(index, absolutePath, entry.size), name, (line, text) => {
|
|
470
|
+
matched++;
|
|
471
|
+
if (sample.length < room) sample.push({ file: entry.relativePath, line, text });
|
|
472
|
+
}, { texts: room });
|
|
473
|
+
} catch (error) {
|
|
474
|
+
// Not searched: the path becomes opaque, the one channel every
|
|
475
|
+
// consumer of this model reports (accounts, lookups, health).
|
|
476
|
+
demoteToOpaque(index, entry, error);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
bucket.sites.push(...sample);
|
|
480
|
+
if (matched > sample.length) bucket.sitesTruncated = true;
|
|
481
|
+
if (matched === 0) continue;
|
|
482
|
+
bucket.fileCount++;
|
|
483
|
+
bucket.lines += matched;
|
|
484
|
+
bucket.files.push(entry.relativePath);
|
|
485
|
+
reasons.set(entry.reason, (reasons.get(entry.reason) || 0) + matched);
|
|
486
|
+
if (entry.reason === 'unsupported-language') {
|
|
487
|
+
languages.set(entry.label, (languages.get(entry.label) || 0) + matched);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
bucket.files.sort(codeUnitCompare);
|
|
491
|
+
bucket.sites.sort((a, b) => codeUnitCompare(a.file, b.file) || a.line - b.line);
|
|
492
|
+
for (const key of [...languages.keys()].sort(codeUnitCompare)) {
|
|
493
|
+
bucket.languages[key] = languages.get(key);
|
|
494
|
+
}
|
|
495
|
+
for (const key of [...reasons.keys()].sort(codeUnitCompare)) {
|
|
496
|
+
bucket.reasons[key] = reasons.get(key);
|
|
497
|
+
}
|
|
498
|
+
return bucket;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Lines of one readable file on which the name occurs (parse failures). */
|
|
502
|
+
function countNameLines(absolutePath, name) {
|
|
503
|
+
let lines = 0;
|
|
504
|
+
const size = fs.statSync(absolutePath).size;
|
|
505
|
+
const chunks = size <= WHOLE_FILE_BYTES
|
|
506
|
+
? [fs.readFileSync(absolutePath, 'utf-8')]
|
|
507
|
+
: streamChunks(absolutePath);
|
|
508
|
+
scanChunksForName(chunks, name, () => { lines++; }, { texts: 0 });
|
|
509
|
+
return lines;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Which of `names` occur anywhere in text-only source, one pass per file.
|
|
514
|
+
* Returns { matchedNames: Set, files: string[], unreadableFiles: string[],
|
|
515
|
+
* reasons: {reason: fileCount} }.
|
|
516
|
+
*/
|
|
517
|
+
function scanTextOnlyNames(index, names, extraFiles = []) {
|
|
518
|
+
const matchedNames = new Set();
|
|
519
|
+
const files = [];
|
|
520
|
+
const unreadableFiles = [];
|
|
521
|
+
const reasons = {};
|
|
522
|
+
const { textOnly } = universe(index);
|
|
523
|
+
const entries = [
|
|
524
|
+
...extraFiles,
|
|
525
|
+
...textOnly.map(entry => ({
|
|
526
|
+
absolutePath: path.join(index.root, entry.relativePath),
|
|
527
|
+
relativePath: entry.relativePath,
|
|
528
|
+
reason: entry.reason,
|
|
529
|
+
size: entry.size,
|
|
530
|
+
textOnly: entry,
|
|
531
|
+
})),
|
|
532
|
+
];
|
|
533
|
+
for (const entry of entries) {
|
|
534
|
+
files.push(entry.relativePath);
|
|
535
|
+
reasons[entry.reason] = (reasons[entry.reason] || 0) + 1;
|
|
536
|
+
try {
|
|
537
|
+
const size = entry.size ?? fs.statSync(entry.absolutePath).size;
|
|
538
|
+
const found = scanChunksForNames(
|
|
539
|
+
() => textOnlyChunks(index, entry.absolutePath, size), names);
|
|
540
|
+
for (const name of found) matchedNames.add(name);
|
|
541
|
+
} catch (error) {
|
|
542
|
+
unreadableFiles.push(entry.relativePath);
|
|
543
|
+
if (entry.textOnly) demoteToOpaque(index, entry.textOnly, error);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
matchedNames,
|
|
548
|
+
files: files.sort(codeUnitCompare),
|
|
549
|
+
unreadableFiles: unreadableFiles.sort(codeUnitCompare),
|
|
550
|
+
reasons,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ── Summaries ───────────────────────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
function countBy(entries, key) {
|
|
557
|
+
const counts = {};
|
|
558
|
+
for (const entry of entries) counts[entry[key]] = (counts[entry[key]] || 0) + 1;
|
|
559
|
+
return Object.fromEntries(Object.entries(counts)
|
|
560
|
+
.sort((a, b) => codeUnitCompare(a[0], b[0])));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* What an account needs to say about paths no name can be checked against:
|
|
565
|
+
* the count, the reasons, and a few paths. Zero means the claim stands or
|
|
566
|
+
* falls on the name's own occurrences.
|
|
567
|
+
*/
|
|
568
|
+
function opaqueSummary(index) {
|
|
569
|
+
const { opaque } = universe(index);
|
|
570
|
+
return {
|
|
571
|
+
count: opaque.length,
|
|
572
|
+
reasons: countBy(opaque, 'reason'),
|
|
573
|
+
sample: opaque.slice(0, 3).map(entry => ({
|
|
574
|
+
path: entry.relativePath, reason: entry.reason,
|
|
575
|
+
})),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** The repository's scope statement (repo / health): said once, in full. */
|
|
580
|
+
function scopeSummary(index, sampleSize = 10) {
|
|
581
|
+
const { textOnly, opaque, outside, budget } = universe(index);
|
|
582
|
+
const sample = entries => entries.slice(0, sampleSize).map(entry => ({
|
|
583
|
+
path: entry.relativePath, reason: entry.reason,
|
|
584
|
+
}));
|
|
585
|
+
return {
|
|
586
|
+
textOnly: {
|
|
587
|
+
count: textOnly.length,
|
|
588
|
+
bytes: budget.usedBytes,
|
|
589
|
+
reasons: countBy(textOnly, 'reason'),
|
|
590
|
+
sample: sample(textOnly),
|
|
591
|
+
},
|
|
592
|
+
opaque: {
|
|
593
|
+
count: opaque.length,
|
|
594
|
+
reasons: countBy(opaque, 'reason'),
|
|
595
|
+
sample: sample(opaque),
|
|
596
|
+
},
|
|
597
|
+
outside: {
|
|
598
|
+
count: outside.length,
|
|
599
|
+
reasons: countBy(outside, 'reason'),
|
|
600
|
+
sample: sample(outside),
|
|
601
|
+
},
|
|
602
|
+
scanBudgetBytes: budget.limitBytes,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Has text-only source changed on disk since it was classified?
|
|
608
|
+
* 'unchanged' nothing moved;
|
|
609
|
+
* 'refreshed' a file's content changed and it is still text-only: the
|
|
610
|
+
* universe was rebuilt and name scans must not be reused;
|
|
611
|
+
* 'reindex' a file is gone, or one kept out by the size limit is no
|
|
612
|
+
* longer over it: the set of indexed files may differ, which
|
|
613
|
+
* only a build can settle.
|
|
614
|
+
*/
|
|
615
|
+
function refreshTextOnly(index, { maxFileSize } = {}) {
|
|
616
|
+
const current = index._sourceUniverse;
|
|
617
|
+
if (!current) return 'unchanged';
|
|
618
|
+
let changed = false;
|
|
619
|
+
// Every candidate, not only the ones currently searched: a file the
|
|
620
|
+
// budget left out, or one that could not be read, changes the
|
|
621
|
+
// assignment for the others when it shrinks or becomes readable.
|
|
622
|
+
for (const entry of current.candidates || []) {
|
|
623
|
+
const absolutePath = path.join(index.root, entry.relativePath);
|
|
624
|
+
const stat = readableStat(absolutePath);
|
|
625
|
+
if (!stat.readable) {
|
|
626
|
+
// Gone: the set of discovered files may differ. Still unreadable
|
|
627
|
+
// as before: nothing moved.
|
|
628
|
+
if (!fs.existsSync(absolutePath)) return 'reindex';
|
|
629
|
+
if (entry.readable) changed = true;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (entry.readable && stat.size === entry.size && stat.mtimeMs === entry.mtimeMs) continue;
|
|
633
|
+
if (entry.reason === 'max-file-size' && maxFileSize != null && stat.size <= maxFileSize) {
|
|
634
|
+
return 'reindex';
|
|
635
|
+
}
|
|
636
|
+
changed = true;
|
|
637
|
+
}
|
|
638
|
+
if (!changed) return 'unchanged';
|
|
639
|
+
resetUniverse(index);
|
|
640
|
+
return 'refreshed';
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** "Ruby, bundled/minified": what kind of unanalyzed source matched. */
|
|
644
|
+
function matchLabels(bucket) {
|
|
645
|
+
const labels = Object.keys((bucket && bucket.languages) || {});
|
|
646
|
+
for (const reason of Object.keys((bucket && bucket.reasons) || {})) {
|
|
647
|
+
if (reason === 'unsupported-language') continue;
|
|
648
|
+
labels.push(REASON_LABELS[reason] || reason);
|
|
649
|
+
}
|
|
650
|
+
return labels.join(', ');
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** "(Ruby: 2, bundled/minified: 1)" with line counts. */
|
|
654
|
+
function matchLabelCounts(bucket) {
|
|
655
|
+
const parts = Object.entries((bucket && bucket.languages) || {})
|
|
656
|
+
.map(([language, count]) => `${language}: ${count}`);
|
|
657
|
+
for (const [reason, count] of Object.entries((bucket && bucket.reasons) || {})) {
|
|
658
|
+
if (reason === 'unsupported-language') continue;
|
|
659
|
+
parts.push(`${REASON_LABELS[reason] || reason}: ${count}`);
|
|
660
|
+
}
|
|
661
|
+
return parts.join(', ');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** What to do about a match in unanalyzed source. */
|
|
665
|
+
function matchAdvice(bucket) {
|
|
666
|
+
const bundled = bucket && bucket.reasons && bucket.reasons.bundled > 0;
|
|
667
|
+
return 'Use grep/ripgrep plus a language-native analyzer.' +
|
|
668
|
+
(bundled ? ' --include-bundled indexes JavaScript bundles.' : '');
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function reasonList(reasons) {
|
|
672
|
+
return Object.entries(reasons || {}).map(([reason, count]) => `${reason} ${count}`).join(', ');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** One sentence for an answer that found nothing while part of the project
|
|
676
|
+
* could not be searched at all; '' when everything readable was scanned. */
|
|
677
|
+
function unsearchedNote(index) {
|
|
678
|
+
const opaque = opaqueSummary(index);
|
|
679
|
+
if (opaque.count === 0) return '';
|
|
680
|
+
return `${opaque.count} path(s) could not be searched (${reasonList(opaque.reasons)}); ` +
|
|
681
|
+
'this is not a repository-wide zero. See repo --sections=health.';
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function hasTextOnly(index) {
|
|
685
|
+
return universe(index).textOnly.length > 0;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
module.exports = {
|
|
689
|
+
DEFAULT_TEXT_SCAN_BUDGET,
|
|
690
|
+
matchLabels,
|
|
691
|
+
matchLabelCounts,
|
|
692
|
+
matchAdvice,
|
|
693
|
+
reasonList,
|
|
694
|
+
unsearchedNote,
|
|
695
|
+
hasTextOnly,
|
|
696
|
+
REASON_LABELS,
|
|
697
|
+
universe,
|
|
698
|
+
resetUniverse,
|
|
699
|
+
scanChunksForName,
|
|
700
|
+
scanChunksForNames,
|
|
701
|
+
scanTextOnly,
|
|
702
|
+
scanTextOnlyNames,
|
|
703
|
+
countNameLines,
|
|
704
|
+
opaqueSummary,
|
|
705
|
+
scopeSummary,
|
|
706
|
+
refreshTextOnly,
|
|
707
|
+
};
|