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
package/core/output-budget.js
CHANGED
|
@@ -3,22 +3,24 @@
|
|
|
3
3
|
const {
|
|
4
4
|
BROAD_COMMANDS: BROAD_CANONICAL,
|
|
5
5
|
FLAG_APPLICABILITY,
|
|
6
|
+
paramDefault,
|
|
7
|
+
paramRange,
|
|
6
8
|
resolveCommand,
|
|
7
|
-
toMcpName,
|
|
8
9
|
} = require('./registry');
|
|
10
|
+
const { LINES_COMMANDS, budgetLineListing } = require('./output/lines');
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
...[...BROAD_CANONICAL].map(toMcpName),
|
|
16
|
-
]);
|
|
12
|
+
// The output budget is the parameter table's `maxChars`: its ceiling, its
|
|
13
|
+
// default, and the smaller default of the broad sweep commands.
|
|
14
|
+
const DEFAULT_OUTPUT_CHARS = paramDefault('maxChars');
|
|
15
|
+
const BROAD_OUTPUT_CHARS = paramDefault('maxChars', [...BROAD_CANONICAL][0]);
|
|
16
|
+
const MAX_OUTPUT_CHARS = paramRange('maxChars').max;
|
|
17
17
|
|
|
18
|
-
const CONTRACT_LINE_RE = /^\s*(?:(?:Summary|ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT|VALUE ACCOUNT|TYPE ACCOUNT|Note)
|
|
18
|
+
const CONTRACT_LINE_RE = /^\s*(?:(?:Summary|ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT|VALUE ACCOUNT|TYPE ACCOUNT|Note):|TRUST: (?:BLOCKED|REVIEW_REQUIRED|READY_FOR_TOOLCHAIN)\b|\d+ test-file usage\(s\) hidden\b|Found \d+ (?:definitions|fuzzy matches)\b)/;
|
|
19
19
|
// Trust lines: the accounting and contract lines that qualify a caller
|
|
20
20
|
// answer. A truncated answer carries every one of them or none of its body.
|
|
21
|
-
|
|
21
|
+
// The pre-commit check's verdict line is one of them (`repo` prints a
|
|
22
|
+
// TRUST level of its own, which is orientation, not a verdict).
|
|
23
|
+
const TRUST_LINE_RE = /^(?:(?:ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT|VALUE ACCOUNT|TYPE ACCOUNT):|TRUST: (?:BLOCKED|REVIEW_REQUIRED|READY_FOR_TOOLCHAIN)\b)/;
|
|
22
24
|
const MAX_PRESERVED_CONTRACT_LINES = 24;
|
|
23
25
|
const MAX_PRESERVED_CONTRACT_CHARS = 8000;
|
|
24
26
|
|
|
@@ -139,7 +141,9 @@ function compactNarrowingHint(command, surface, params = {}) {
|
|
|
139
141
|
// (`CALLERS — CONFIRMED (12, ...)`, `CALLERS — RUNTIME DISPATCH (332 sites`,
|
|
140
142
|
// `UNVERIFIED CALL SITES (627) —`, `CALLEES (3):`, `BY FILE:`).
|
|
141
143
|
const SECTION_HEADER_RE = /^(?:[A-Z][A-Z-]*(?: [A-Z][A-Z-]*)*(?: — [A-Z][A-Z -]*)? \(\d[^)]*\)|[A-Z][A-Z ]+:$)/;
|
|
142
|
-
|
|
144
|
+
// `check` groups its items by verdict (`BLOCKED (2) — ...`): a long list of
|
|
145
|
+
// breaks must not push what is ready, or what to review, out of the answer.
|
|
146
|
+
const SECTION_AWARE_COMMANDS = new Set(['show', 'impact', 'check']);
|
|
143
147
|
|
|
144
148
|
/**
|
|
145
149
|
* Cut a relationship answer to `budget` chars keeping a representative head
|
|
@@ -152,7 +156,7 @@ const SECTION_AWARE_COMMANDS = new Set(['show', 'impact']);
|
|
|
152
156
|
* no sections or its fixed parts alone do not fit (caller falls back to a
|
|
153
157
|
* head cut).
|
|
154
158
|
*/
|
|
155
|
-
function sectionAwareCut(text, budget) {
|
|
159
|
+
function sectionAwareCut(text, budget, lineCost = line => line.length) {
|
|
156
160
|
const lines = text.split('\n');
|
|
157
161
|
const blocks = []; // { kind: 'fixed', line } | { kind: 'section', header, body }
|
|
158
162
|
let current = null;
|
|
@@ -177,7 +181,7 @@ function sectionAwareCut(text, budget) {
|
|
|
177
181
|
const gapLine = ' ...';
|
|
178
182
|
let used = 0;
|
|
179
183
|
for (const block of blocks) {
|
|
180
|
-
used += (block.kind === 'fixed' ? block.line
|
|
184
|
+
used += lineCost(block.kind === 'fixed' ? block.line : block.header) + 1;
|
|
181
185
|
}
|
|
182
186
|
// Reserve the worst-case `+N more` line and one gap marker per section.
|
|
183
187
|
for (const section of sections) {
|
|
@@ -212,7 +216,7 @@ function sectionAwareCut(text, budget) {
|
|
|
212
216
|
const state = orders.get(section);
|
|
213
217
|
if (state.next >= state.order.length) continue;
|
|
214
218
|
const index = state.order[state.next];
|
|
215
|
-
const cost = section.body[index]
|
|
219
|
+
const cost = lineCost(section.body[index]) + 1;
|
|
216
220
|
if (used + cost > budget) { state.next = state.order.length; continue; }
|
|
217
221
|
used += cost;
|
|
218
222
|
state.taken.add(index);
|
|
@@ -249,13 +253,31 @@ function applyOutputBudget(text, {
|
|
|
249
253
|
surface = 'cli',
|
|
250
254
|
params = {},
|
|
251
255
|
trailingChars = 0,
|
|
256
|
+
peerText,
|
|
252
257
|
} = {}) {
|
|
253
|
-
const defaultLimit =
|
|
254
|
-
|
|
255
|
-
: DEFAULT_OUTPUT_CHARS;
|
|
258
|
+
const defaultLimit = paramDefault('maxChars',
|
|
259
|
+
resolveCommand(command, 'mcp') || resolveCommand(command, 'cli') || command);
|
|
256
260
|
const requested = maxChars || (all ? MAX_OUTPUT_CHARS : defaultLimit);
|
|
257
261
|
const hardLimit = Math.min(requested, MAX_OUTPUT_CHARS);
|
|
258
|
-
|
|
262
|
+
// Native CLI/MCP guidance has different lengths. Both presentations
|
|
263
|
+
// select the same semantic lines using the larger cost at each source
|
|
264
|
+
// line, including room for the CLI's terminal newline. Rendered text
|
|
265
|
+
// keeps its own native guidance and actual full-character count.
|
|
266
|
+
const paired = typeof peerText === 'string';
|
|
267
|
+
const costs = new Map();
|
|
268
|
+
if (paired) {
|
|
269
|
+
const own = String(text || '').split('\n'), peer = peerText.split('\n');
|
|
270
|
+
for (let i = 0; i < own.length; i++) {
|
|
271
|
+
const cost = Math.max(own[i].length, (peer[i] || '').length);
|
|
272
|
+
costs.set(own[i], Math.max(costs.get(own[i]) || 0, cost));
|
|
273
|
+
costs.set(own[i].trim(), Math.max(costs.get(own[i].trim()) || 0,
|
|
274
|
+
Math.max(own[i].trim().length, (peer[i] || '').trim().length)));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const lineCost = line => costs.get(line) ?? line.length;
|
|
278
|
+
const textCost = value => value.split('\n').reduce((sum, line) => sum + lineCost(line) + 1, -1);
|
|
279
|
+
const terminalChars = paired ? Math.max(1, trailingChars) : trailingChars;
|
|
280
|
+
const limit = Math.max(0, hardLimit - terminalChars);
|
|
259
281
|
if (!text) {
|
|
260
282
|
return {
|
|
261
283
|
text: '(no output)'.slice(0, limit),
|
|
@@ -267,7 +289,7 @@ function applyOutputBudget(text, {
|
|
|
267
289
|
};
|
|
268
290
|
}
|
|
269
291
|
|
|
270
|
-
if (text
|
|
292
|
+
if (textCost(text) <= limit) {
|
|
271
293
|
return {
|
|
272
294
|
text,
|
|
273
295
|
truncated: false,
|
|
@@ -280,25 +302,53 @@ function applyOutputBudget(text, {
|
|
|
280
302
|
|
|
281
303
|
const canonical = resolveCommand(command, surface === 'mcp' ? 'mcp' : 'cli') ||
|
|
282
304
|
command;
|
|
305
|
+
// A line listing is cut between records: the rows that fit, the
|
|
306
|
+
// accounting, and one line counting the rows left out.
|
|
307
|
+
if (params.lines && LINES_COMMANDS.has(canonical)) {
|
|
308
|
+
const listing = budgetLineListing(text, limit, surface, { allowNoRows: true });
|
|
309
|
+
if (listing) {
|
|
310
|
+
return {
|
|
311
|
+
text: listing.text,
|
|
312
|
+
truncated: true,
|
|
313
|
+
fullChars: text.length,
|
|
314
|
+
requestedLimit: hardLimit,
|
|
315
|
+
contractMetadata: [],
|
|
316
|
+
contractMetadataComplete: listing.contractMetadataComplete,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
283
320
|
const supportsAll = FLAG_APPLICABILITY[canonical]?.includes('all') &&
|
|
284
321
|
!(canonical === 'deps' && params.cycles);
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
?
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
322
|
+
const noticesFor = (target, fullChars) => {
|
|
323
|
+
const allHint = supportsAll
|
|
324
|
+
? (target === 'mcp'
|
|
325
|
+
? 'Use all=true or max_chars=<n> (100K maximum).'
|
|
326
|
+
: 'Use --all or --max-chars=N (100K maximum).')
|
|
327
|
+
: (target === 'mcp'
|
|
328
|
+
? 'Use max_chars=<n> (100K maximum).'
|
|
329
|
+
: 'Use --max-chars=N (100K maximum).');
|
|
330
|
+
const raiseHint = target === 'mcp' ? 'max_chars=<n>' : '--max-chars=N';
|
|
331
|
+
const compactScope = compactNarrowingHint(command, target, params);
|
|
332
|
+
const compactGuidance = compactScope.includes(raiseHint)
|
|
333
|
+
? `Raise ${raiseHint}.`
|
|
334
|
+
: `Narrow with ${compactScope || raiseHint}; raise ${raiseHint}.`;
|
|
335
|
+
return {
|
|
336
|
+
longNotice: `... OUTPUT TRUNCATED: ${fullChars} chars total; hard limit ${hardLimit}. ` +
|
|
337
|
+
`${narrowingHint(command, target, params)} ${allHint}`,
|
|
338
|
+
compactNotice: `... OUTPUT TRUNCATED (${fullChars}→${hardLimit}). ${compactGuidance}`,
|
|
339
|
+
shortNotice: `... OUTPUT TRUNCATED. Raise ${raiseHint}.`,
|
|
340
|
+
raiseHint,
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
const ownNotices = noticesFor(surface, text.length);
|
|
344
|
+
if (paired) {
|
|
345
|
+
const peerNotices = noticesFor(surface === 'mcp' ? 'cli' : 'mcp', peerText.length);
|
|
346
|
+
for (const name of ['longNotice', 'compactNotice', 'shortNotice']) {
|
|
347
|
+
costs.set(ownNotices[name], Math.max(ownNotices[name].length, peerNotices[name].length));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const { longNotice, compactNotice, shortNotice, raiseHint } = ownNotices;
|
|
292
351
|
const compactBudget = limit < 500;
|
|
293
|
-
const raiseHint = surface === 'mcp' ? 'max_chars=<n>' : '--max-chars=N';
|
|
294
|
-
const compactScope = compactNarrowingHint(command, surface, params);
|
|
295
|
-
const compactGuidance = compactScope.includes(raiseHint)
|
|
296
|
-
? `Raise ${raiseHint}.`
|
|
297
|
-
: `Narrow with ${compactScope || raiseHint}; raise ${raiseHint}.`;
|
|
298
|
-
const longNotice = `... OUTPUT TRUNCATED: ${text.length} chars total; hard limit ${hardLimit}. ` +
|
|
299
|
-
`${narrowingHint(command, surface, params)} ${allHint}`;
|
|
300
|
-
const compactNotice = `... OUTPUT TRUNCATED (${text.length}→${hardLimit}). ${compactGuidance}`;
|
|
301
|
-
const shortNotice = `... OUTPUT TRUNCATED. Raise ${raiseHint}.`;
|
|
302
352
|
|
|
303
353
|
// Trust lines (ACCOUNT/CONTRACT/WARNING/...) qualify the answer: room for
|
|
304
354
|
// every one of them is reserved before any body text. Optional
|
|
@@ -307,7 +357,7 @@ function applyOutputBudget(text, {
|
|
|
307
357
|
const trust = candidates.filter(candidate => candidate.priority === 0);
|
|
308
358
|
const optional = candidates.filter(candidate => candidate.priority > 0)
|
|
309
359
|
.sort((a, b) => a.priority - b.priority || a.sourceIndex - b.sourceIndex);
|
|
310
|
-
const trustChars = trust.reduce((sum, candidate) => sum + candidate.line
|
|
360
|
+
const trustChars = trust.reduce((sum, candidate) => sum + lineCost(candidate.line) + 1, 0);
|
|
311
361
|
const result = (rendered, appended, complete) => ({
|
|
312
362
|
text: rendered,
|
|
313
363
|
truncated: true,
|
|
@@ -321,6 +371,16 @@ function applyOutputBudget(text, {
|
|
|
321
371
|
// number of complete source lines it holds.
|
|
322
372
|
const headCut = (budget) => {
|
|
323
373
|
if (budget <= 0) return { body: '', keptLines: 0 };
|
|
374
|
+
if (paired) {
|
|
375
|
+
const kept = [];
|
|
376
|
+
let used = -1;
|
|
377
|
+
for (const line of text.split('\n')) {
|
|
378
|
+
if (used + lineCost(line) + 1 > budget) break;
|
|
379
|
+
kept.push(line);
|
|
380
|
+
used += lineCost(line) + 1;
|
|
381
|
+
}
|
|
382
|
+
return { body: kept.join('\n'), keptLines: kept.length };
|
|
383
|
+
}
|
|
324
384
|
const prefix = text.slice(0, budget);
|
|
325
385
|
const lastNewline = prefix.lastIndexOf('\n');
|
|
326
386
|
const body = lastNewline > budget * 0.8 ? prefix.slice(0, lastNewline) : prefix;
|
|
@@ -336,18 +396,18 @@ function applyOutputBudget(text, {
|
|
|
336
396
|
// to `optionalShare` chars (priority order) before the body; the body
|
|
337
397
|
// takes the rest. Returns null when the notice and the trust lines alone
|
|
338
398
|
// exceed the limit.
|
|
339
|
-
const costOf = list => list.reduce((sum, candidate) => sum + candidate.line
|
|
399
|
+
const costOf = list => list.reduce((sum, candidate) => sum + lineCost(candidate.line) + 1, 0);
|
|
340
400
|
const layout = (notice, heading, separator, optionalShare) => {
|
|
341
|
-
const reserveFor = metadataChars => notice
|
|
401
|
+
const reserveFor = metadataChars => lineCost(notice) + (metadataChars > 0 ? heading.length + metadataChars : 0);
|
|
342
402
|
if (reserveFor(trustChars) > limit) return null;
|
|
343
403
|
const reservedOptional = [];
|
|
344
404
|
let optionalChars = 0;
|
|
345
405
|
const optionalRoom = Math.min(optionalShare, limit - reserveFor(trustChars) - (trustChars > 0 ? 0 : heading.length));
|
|
346
406
|
for (const candidate of optional) {
|
|
347
407
|
if (reservedOptional.length >= MAX_PRESERVED_CONTRACT_LINES) break;
|
|
348
|
-
if (optionalChars + candidate.line
|
|
408
|
+
if (optionalChars + lineCost(candidate.line) + 1 > optionalRoom) continue;
|
|
349
409
|
reservedOptional.push(candidate);
|
|
350
|
-
optionalChars += candidate.line
|
|
410
|
+
optionalChars += lineCost(candidate.line) + 1;
|
|
351
411
|
}
|
|
352
412
|
const reserved = [...trust, ...reservedOptional];
|
|
353
413
|
const pendingOf = cutState => reserved.filter(candidate => candidate.sourceIndex >= cutState.keptLines);
|
|
@@ -361,12 +421,12 @@ function applyOutputBudget(text, {
|
|
|
361
421
|
}
|
|
362
422
|
const metadata = pendingOf(cut);
|
|
363
423
|
let rendered = cut.body ? `${cut.body}${separator}${notice}` : notice;
|
|
364
|
-
let used = rendered
|
|
424
|
+
let used = textCost(rendered) + (metadata.length > 0 ? heading.length + costOf(metadata) : 0);
|
|
365
425
|
let omitted = 0;
|
|
366
426
|
const taken = new Set(reserved);
|
|
367
427
|
for (const candidate of optional) {
|
|
368
428
|
if (taken.has(candidate) || candidate.sourceIndex < cut.keptLines) continue;
|
|
369
|
-
const cost = candidate.line
|
|
429
|
+
const cost = lineCost(candidate.line) + 1 + (metadata.length === 0 ? heading.length : 0);
|
|
370
430
|
if (metadata.length >= trust.length + MAX_PRESERVED_CONTRACT_LINES || used + cost > limit) {
|
|
371
431
|
omitted++;
|
|
372
432
|
continue;
|
|
@@ -392,28 +452,35 @@ function applyOutputBudget(text, {
|
|
|
392
452
|
// so with the budget that would carry them.
|
|
393
453
|
const withheld = () => {
|
|
394
454
|
const flag = surface === 'mcp' ? 'max_chars' : '--max-chars';
|
|
395
|
-
const noticeFor = (shown, needed
|
|
396
|
-
|
|
455
|
+
const noticeFor = (shown, needed, target = surface, fullChars = text.length) =>
|
|
456
|
+
`... OUTPUT TRUNCATED (${fullChars}→${hardLimit}): answer withheld; ` +
|
|
457
|
+
`${shown} of ${trust.length} trust line(s) shown. Raise ${target === 'mcp' ? 'max_chars' : '--max-chars'} to at least ${needed}.`;
|
|
397
458
|
// Smallest budget whose shortest layout carries every trust line.
|
|
398
|
-
const needed =
|
|
459
|
+
const needed = terminalChars + lineCost(shortNotice) + trustChars;
|
|
399
460
|
let shown = 0;
|
|
400
|
-
|
|
461
|
+
const noticeCost = text => paired ? Math.max(text.length,
|
|
462
|
+
text.replace(surface === 'mcp' ? 'max_chars' : '--max-chars', surface === 'mcp' ? '--max-chars' : 'max_chars').length) : text.length;
|
|
463
|
+
// Native full sizes can have different digit widths. Pair the whole
|
|
464
|
+
// notice before choosing trust lines, just as for ordinary notices.
|
|
465
|
+
const fullNoticeCost = shown => Math.max(noticeFor(shown, needed).length,
|
|
466
|
+
paired ? noticeFor(shown, needed, surface === 'mcp' ? 'cli' : 'mcp', peerText.length).length : 0);
|
|
467
|
+
let used = fullNoticeCost(trust.length);
|
|
401
468
|
const kept = [];
|
|
402
469
|
for (const candidate of trust) {
|
|
403
|
-
if (used + candidate.line
|
|
470
|
+
if (used + lineCost(candidate.line) + 1 > limit) break;
|
|
404
471
|
kept.push(candidate.line);
|
|
405
|
-
used += candidate.line
|
|
472
|
+
used += lineCost(candidate.line) + 1;
|
|
406
473
|
shown++;
|
|
407
474
|
}
|
|
408
475
|
const notice = noticeFor(shown, needed);
|
|
409
|
-
if (
|
|
476
|
+
if (fullNoticeCost(shown) > limit) {
|
|
410
477
|
// Shorter statements of the same fact; a budget number is never
|
|
411
478
|
// cut mid-digit.
|
|
412
479
|
const shorter = [
|
|
413
480
|
`TRUNCATED: answer withheld; raise ${flag} to at least ${needed}.`,
|
|
414
481
|
`Answer withheld; ${flag}>=${needed}`,
|
|
415
482
|
`withheld; >=${needed}`,
|
|
416
|
-
].find(text => text
|
|
483
|
+
].find(text => noticeCost(text) <= limit);
|
|
417
484
|
return result(shorter || '', [], false);
|
|
418
485
|
}
|
|
419
486
|
return result([notice, ...kept].join('\n'), kept, false);
|
|
@@ -448,26 +515,26 @@ function applyOutputBudget(text, {
|
|
|
448
515
|
// Sections keep every non-indented line (trust lines included) in
|
|
449
516
|
// place; trust lines inside a cut section body are appended, with
|
|
450
517
|
// their room taken from the sections.
|
|
451
|
-
let sectionBudget = limit - longNotice
|
|
518
|
+
let sectionBudget = limit - lineCost(longNotice) - 2;
|
|
452
519
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
453
|
-
const sectioned = sectionAwareCut(text, sectionBudget);
|
|
520
|
+
const sectioned = sectionAwareCut(text, sectionBudget, lineCost);
|
|
454
521
|
if (sectioned === null) break;
|
|
455
522
|
const visible = new Set(sectioned.split('\n').map(line => line.trim()));
|
|
456
523
|
const missing = trust.filter(candidate => !visible.has(candidate.line));
|
|
457
|
-
const missingChars = missing.reduce((sum, candidate) => sum + candidate.line
|
|
524
|
+
const missingChars = missing.reduce((sum, candidate) => sum + lineCost(candidate.line) + 1, 0);
|
|
458
525
|
let rendered = `${sectioned}\n\n${longNotice}`;
|
|
459
|
-
if (rendered
|
|
526
|
+
if (textCost(rendered) + missingChars > limit) {
|
|
460
527
|
sectionBudget -= missingChars;
|
|
461
528
|
continue;
|
|
462
529
|
}
|
|
463
530
|
const appended = missing.map(candidate => candidate.line);
|
|
464
531
|
let omitted = 0;
|
|
465
|
-
let used = rendered
|
|
532
|
+
let used = textCost(rendered) + missingChars;
|
|
466
533
|
for (const candidate of optional) {
|
|
467
534
|
if (visible.has(candidate.line)) continue;
|
|
468
|
-
if (used + candidate.line
|
|
535
|
+
if (used + lineCost(candidate.line) + 1 > limit) { omitted++; continue; }
|
|
469
536
|
appended.push(candidate.line);
|
|
470
|
-
used += candidate.line
|
|
537
|
+
used += lineCost(candidate.line) + 1;
|
|
471
538
|
}
|
|
472
539
|
if (appended.length > 0) rendered += '\n' + appended.join('\n');
|
|
473
540
|
return result(rendered, appended, omitted === 0);
|
package/core/parallel-build.js
CHANGED
|
@@ -220,7 +220,7 @@ function parallelBuild(index, files, options = {}) {
|
|
|
220
220
|
{
|
|
221
221
|
for (const result of collected) {
|
|
222
222
|
if (result.error) {
|
|
223
|
-
index.
|
|
223
|
+
index._noteFailedFile(result.filePath);
|
|
224
224
|
if (!options.quiet) {
|
|
225
225
|
console.error(` Warning: Could not index ${result.filePath}: ${result.error}`);
|
|
226
226
|
}
|
|
@@ -236,7 +236,7 @@ function parallelBuild(index, files, options = {}) {
|
|
|
236
236
|
existing.size = result.sizeUpdate;
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
|
-
index.
|
|
239
|
+
index._clearFailedFile(result.filePath);
|
|
240
240
|
continue;
|
|
241
241
|
}
|
|
242
242
|
|
|
@@ -267,7 +267,7 @@ function parallelBuild(index, files, options = {}) {
|
|
|
267
267
|
index.callsCacheDirty = true;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
index.
|
|
270
|
+
index._clearFailedFile(result.filePath);
|
|
271
271
|
changed++;
|
|
272
272
|
}
|
|
273
273
|
}
|
package/core/project.js
CHANGED
|
@@ -9,11 +9,9 @@ const fs = require('fs');
|
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const crypto = require('crypto');
|
|
12
|
+
const { paramDefault } = require('./registry');
|
|
12
13
|
const { UcnError } = require('./errors');
|
|
13
|
-
const {
|
|
14
|
-
expandGlob, findProjectRoot, detectProjectPattern, isTestFile,
|
|
15
|
-
parseGitignore, gitTrackedPaths, gitListing, DEFAULT_IGNORES, compareNames, classifyUnsupportedSourceFile,
|
|
16
|
-
} = require('./discovery');
|
|
14
|
+
const { expandGlob, findProjectRoot, detectProjectPattern, isTestFile, parseGitignore, gitTrackedPaths, gitListing, compareNames, classifyUnsupportedSourceFile, projectDiscoveryOptions } = require('./discovery');
|
|
17
15
|
const { cleanHtmlScriptTags } = require('./parser');
|
|
18
16
|
const { detectLanguage, getParser, getLanguageAdapter, safeParse, langTraits, PARSE_OPTIONS } = require('../languages');
|
|
19
17
|
const { validateFileIR } = require('./ir');
|
|
@@ -102,8 +100,11 @@ class ProjectIndex {
|
|
|
102
100
|
* Create a new ProjectIndex
|
|
103
101
|
* @param {string} rootDir - Project root directory
|
|
104
102
|
*/
|
|
105
|
-
constructor(rootDir) {
|
|
106
|
-
|
|
103
|
+
constructor(rootDir, options = {}) {
|
|
104
|
+
// `exactRoot`: the directory IS the project root. A tree exported
|
|
105
|
+
// from the git index has no `.git` to stop the walk toward a marker,
|
|
106
|
+
// and must not adopt a directory above itself.
|
|
107
|
+
this.root = options.exactRoot ? path.resolve(rootDir) : findProjectRoot(rootDir);
|
|
107
108
|
this.files = new Map(); // path -> FileEntry
|
|
108
109
|
this.symbols = new Map(); // name -> SymbolEntry[]
|
|
109
110
|
this.importGraph = new Map(); // file -> [imported files]
|
|
@@ -128,6 +129,7 @@ class ProjectIndex {
|
|
|
128
129
|
this.macroExpansionDirty = false; // persisted C/C++ macro-expansion records (core/macro-expansion.js)
|
|
129
130
|
this._macroExpansionPersisted = null;
|
|
130
131
|
this.failedFiles = new Set(); // files that failed to index (e.g. large minified bundles)
|
|
132
|
+
this._failedFileStats = new Map(); // failed file -> { size, mtimeMs } AT the failure (see _noteFailedFile)
|
|
131
133
|
this.unsupportedFiles = []; // common source files skipped by the parser registry
|
|
132
134
|
this.discoveryIssues = []; // explicit reasons the index may be partial
|
|
133
135
|
this._opContentCache = null; // per-operation file content cache (Map<filePath, string>)
|
|
@@ -151,9 +153,15 @@ class ProjectIndex {
|
|
|
151
153
|
this._usageResultCache = new Map();
|
|
152
154
|
this._usageResultCacheWeight = 0;
|
|
153
155
|
this.usageCacheDirty = false;
|
|
156
|
+
// HOT rankings already computed for this index state, by request
|
|
157
|
+
// (core/hot-summary.js); stored beside the index when dirty.
|
|
158
|
+
this._hotSummaries = null;
|
|
159
|
+
this._hotSummarySavedGeneration = null;
|
|
160
|
+
this.hotSummaryDirty = false;
|
|
154
161
|
// Exact text-ground sets are likewise immutable for one built index.
|
|
155
162
|
// The cache is cleared at every build and bounded in account.js.
|
|
156
163
|
this._groundSetCache = new Map();
|
|
164
|
+
this._sourceUniverse = null; // non-indexed source classification (core/source-universe.js)
|
|
157
165
|
this._groundSetCacheLines = 0;
|
|
158
166
|
// Bounded cross-operation memo for immutable name-level export
|
|
159
167
|
// ownership. Agent workflows ask show/impact/tests about related
|
|
@@ -545,6 +553,29 @@ class ProjectIndex {
|
|
|
545
553
|
}
|
|
546
554
|
}
|
|
547
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Record a file the parser could not ingest, with what it looked like
|
|
558
|
+
* when it failed. A failed file is in no index, so its size and mtime
|
|
559
|
+
* are the only way to tell that it changed and deserves another try;
|
|
560
|
+
* they must be taken here, at the failure, never at the first later
|
|
561
|
+
* check (by then a repaired file already looks "unchanged").
|
|
562
|
+
*/
|
|
563
|
+
_noteFailedFile(filePath) {
|
|
564
|
+
this.failedFiles.add(filePath);
|
|
565
|
+
if (!this._failedFileStats) this._failedFileStats = new Map();
|
|
566
|
+
try {
|
|
567
|
+
const stat = fs.statSync(filePath);
|
|
568
|
+
this._failedFileStats.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs });
|
|
569
|
+
} catch (_) {
|
|
570
|
+
this._failedFileStats.delete(filePath);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
_clearFailedFile(filePath) {
|
|
575
|
+
this.failedFiles.delete(filePath);
|
|
576
|
+
if (this._failedFileStats) this._failedFileStats.delete(filePath);
|
|
577
|
+
}
|
|
578
|
+
|
|
548
579
|
/**
|
|
549
580
|
* Load .ucn.json config if present (data-only, no code execution)
|
|
550
581
|
*/
|
|
@@ -574,6 +605,7 @@ class ProjectIndex {
|
|
|
574
605
|
// a text-universe answer across that boundary. Hash-keyed usage
|
|
575
606
|
// results remain safe and useful for unchanged files.
|
|
576
607
|
this._groundSetCache = new Map();
|
|
608
|
+
this._sourceUniverse = null; // non-indexed source classification (core/source-universe.js)
|
|
577
609
|
this._groundSetCacheLines = 0;
|
|
578
610
|
this._nameBindingReachCache = new Map();
|
|
579
611
|
this._returnTypeFlowCache = new Map();
|
|
@@ -630,17 +662,20 @@ class ProjectIndex {
|
|
|
630
662
|
pattern = detectProjectPattern(this.root);
|
|
631
663
|
}
|
|
632
664
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
665
|
+
// Shared with the staleness walk (cache.js): both must read the
|
|
666
|
+
// project's configuration the same way.
|
|
667
|
+
const globOpts = projectDiscoveryOptions(this.root, this.config, {
|
|
668
|
+
maxFiles: options.maxFiles,
|
|
669
|
+
maxDepth: options.maxDepth,
|
|
670
|
+
maxFileSize: options.maxFileSize,
|
|
638
671
|
followSymlinks: options.followSymlinks,
|
|
639
672
|
includeBundled: this.includeBundled,
|
|
640
|
-
};
|
|
673
|
+
});
|
|
641
674
|
|
|
642
675
|
// Merge .gitignore and .ucn.json exclude into file discovery
|
|
643
|
-
|
|
676
|
+
// A snapshot exported from the git index is not a repository:
|
|
677
|
+
// its tracked inventory and ignore files come with it.
|
|
678
|
+
const listing = options.gitListing !== undefined ? options.gitListing : gitListing(this.root);
|
|
644
679
|
const gitignoreRules = parseGitignore(this.root, listing);
|
|
645
680
|
globOpts.gitignoreRules = gitignoreRules;
|
|
646
681
|
globOpts.trackedPaths = gitTrackedPaths(this.root, listing);
|
|
@@ -654,13 +689,6 @@ class ProjectIndex {
|
|
|
654
689
|
detail: `${listing.failure.detail}; tracked files that match .gitignore rules could not be identified and are not indexed`,
|
|
655
690
|
});
|
|
656
691
|
}
|
|
657
|
-
const configExclude = Array.isArray(this.config.exclude)
|
|
658
|
-
? this.config.exclude
|
|
659
|
-
: (this.config.exclude ? [String(this.config.exclude)] : []);
|
|
660
|
-
if (configExclude.length > 0) {
|
|
661
|
-
globOpts.ignores = [...DEFAULT_IGNORES, ...configExclude];
|
|
662
|
-
}
|
|
663
|
-
globOpts.disclosureIgnores = configExclude;
|
|
664
692
|
globOpts.onDiscoveryIssue = recordDiscoveryIssue;
|
|
665
693
|
|
|
666
694
|
if (implicitProjectDiscovery) {
|
|
@@ -683,7 +711,7 @@ class ProjectIndex {
|
|
|
683
711
|
}
|
|
684
712
|
|
|
685
713
|
// Track if files were truncated by maxFiles limit
|
|
686
|
-
const maxFiles = options.maxFiles || this.config.maxFiles ||
|
|
714
|
+
const maxFiles = options.maxFiles || this.config.maxFiles || paramDefault('maxFiles');
|
|
687
715
|
const maxFileIssues = this.discoveryIssues.filter(issue => issue.reason === 'max-files');
|
|
688
716
|
if (!Array.isArray(pattern) && maxFileIssues.length > 0) {
|
|
689
717
|
this.truncated = { indexed: files.length, maxFiles, skipped: maxFileIssues.length };
|
|
@@ -727,6 +755,7 @@ class ProjectIndex {
|
|
|
727
755
|
this._callNameCache = null;
|
|
728
756
|
this._callsGenerationKey = null;
|
|
729
757
|
this._completenessCache = null;
|
|
758
|
+
require('./hot-summary').reset(this);
|
|
730
759
|
this._attrTypeCache = null;
|
|
731
760
|
this._computedDispatchBlindspots = null;
|
|
732
761
|
this._reflectionPatternIndex = null;
|
|
@@ -743,6 +772,7 @@ class ProjectIndex {
|
|
|
743
772
|
let indexed = 0;
|
|
744
773
|
let changed = 0;
|
|
745
774
|
if (!this.failedFiles) this.failedFiles = new Set();
|
|
775
|
+
if (!this._failedFileStats) this._failedFileStats = new Map();
|
|
746
776
|
|
|
747
777
|
// Try parallel build for large projects
|
|
748
778
|
const workersSetting = options.workers;
|
|
@@ -862,9 +892,9 @@ class ProjectIndex {
|
|
|
862
892
|
try {
|
|
863
893
|
if (this.indexFile(file)) changed++;
|
|
864
894
|
indexed++;
|
|
865
|
-
this.
|
|
895
|
+
this._clearFailedFile(file); // Succeeded now, remove from failed
|
|
866
896
|
} catch (e) {
|
|
867
|
-
this.
|
|
897
|
+
this._noteFailedFile(file); // Track files that fail to index
|
|
868
898
|
if (!quiet) {
|
|
869
899
|
console.error(` Warning: Could not index ${file}: ${e.message}`);
|
|
870
900
|
}
|
|
@@ -1031,6 +1061,7 @@ class ProjectIndex {
|
|
|
1031
1061
|
// These query caches depend on project symbols and include closure.
|
|
1032
1062
|
// A changed file can alter either even before a full rebuild reaches
|
|
1033
1063
|
// its graph phase, so no prior call-identity verdict may survive.
|
|
1064
|
+
require('./hot-summary').reset(this);
|
|
1034
1065
|
this._implicitConversionOwners = null;
|
|
1035
1066
|
this._constructionAliases = null;
|
|
1036
1067
|
this._cppScope = null;
|
|
@@ -1126,6 +1157,52 @@ class ProjectIndex {
|
|
|
1126
1157
|
return true;
|
|
1127
1158
|
}
|
|
1128
1159
|
|
|
1160
|
+
/**
|
|
1161
|
+
* Where a file of this index lives in the git repository it describes.
|
|
1162
|
+
* The same path, except for a snapshot exported from the git index
|
|
1163
|
+
* (core/snapshot.js), whose files stand in for the repository's.
|
|
1164
|
+
*/
|
|
1165
|
+
repoPathOf(filePath) {
|
|
1166
|
+
if (!this.repoRoot || !filePath) return filePath;
|
|
1167
|
+
const relative = path.relative(this.root, filePath);
|
|
1168
|
+
return relative.startsWith('..') ? filePath : path.join(this.repoRoot, relative);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* The file entry another revision of a file would have: its symbols,
|
|
1173
|
+
* exports and bindings as the index records them, built from `content`
|
|
1174
|
+
* and attached to nothing. The index is not changed. Used to read a
|
|
1175
|
+
* base revision (a diff's old side) with the same eyes as the current
|
|
1176
|
+
* one. Returns null for a file no language claims or a parse the
|
|
1177
|
+
* adapter rejects.
|
|
1178
|
+
*/
|
|
1179
|
+
fileEntryForContent(filePath, content) {
|
|
1180
|
+
const language = detectLanguage(filePath, this.root);
|
|
1181
|
+
if (!language || content == null) return null;
|
|
1182
|
+
try {
|
|
1183
|
+
const adapter = getLanguageAdapter(language);
|
|
1184
|
+
const parser = getParser(language);
|
|
1185
|
+
const externalMacros = this._macroDictionary
|
|
1186
|
+
? require('./external-macros').contextFor(this._macroDictionary, language) : null;
|
|
1187
|
+
const ir = adapter.analyze(content, parser, filePath,
|
|
1188
|
+
externalMacros ? { externalMacros } : undefined);
|
|
1189
|
+
if (validateFileIR(ir).length > 0) return null;
|
|
1190
|
+
const fileEntry = createFileEntryFromIR({
|
|
1191
|
+
ir,
|
|
1192
|
+
filePath,
|
|
1193
|
+
relativePath: path.relative(this.root, filePath),
|
|
1194
|
+
hash: null,
|
|
1195
|
+
mtime: 0,
|
|
1196
|
+
size: Buffer.byteLength(content),
|
|
1197
|
+
lineCount: content.length === 0 ? 0 : content.split('\n').length,
|
|
1198
|
+
});
|
|
1199
|
+
populateFileEntryFromIR(fileEntry, ir, null);
|
|
1200
|
+
return fileEntry;
|
|
1201
|
+
} catch (_) {
|
|
1202
|
+
return null;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1129
1206
|
/**
|
|
1130
1207
|
* Remove a file's symbols from the global map
|
|
1131
1208
|
*/
|
|
@@ -1133,6 +1210,7 @@ class ProjectIndex {
|
|
|
1133
1210
|
const existing = this.files.get(filePath);
|
|
1134
1211
|
if (!existing) return;
|
|
1135
1212
|
|
|
1213
|
+
require('./hot-summary').reset(this);
|
|
1136
1214
|
this._implicitConversionOwners = null;
|
|
1137
1215
|
this._constructionAliases = null;
|
|
1138
1216
|
this._cppScope = null;
|
|
@@ -3063,6 +3141,11 @@ class ProjectIndex {
|
|
|
3063
3141
|
return indexCache.saveUsageCache(this, cachePath);
|
|
3064
3142
|
}
|
|
3065
3143
|
|
|
3144
|
+
/** Persist the HOT rankings computed for the saved index state. */
|
|
3145
|
+
saveHotSummaries(cachePath = undefined) {
|
|
3146
|
+
return require('./hot-summary').save(this, cachePath);
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3066
3149
|
/** Return this project's default per-user cache file path. */
|
|
3067
3150
|
getCachePath() { return indexCache.getProjectCachePath(this.root); }
|
|
3068
3151
|
|
package/core/provenance-type.js
CHANGED
|
@@ -185,7 +185,11 @@ function constructionDenotes(index, resolver, query, definition, seen = new Set(
|
|
|
185
185
|
const visited = new Set(seen).add(key);
|
|
186
186
|
const traits = lazyLanguagesModule().langTraits(entry.language);
|
|
187
187
|
if (query.localShadow && !localConstructionBinding(index, query, definition, traits, entry.language)) return false;
|
|
188
|
-
|
|
188
|
+
// A decorated class may be replaced by its decorator: no proof that its
|
|
189
|
+
// constructor runs. Which class the name denotes (`identity`) is the
|
|
190
|
+
// declaration all the same, as the language's own typing reads it.
|
|
191
|
+
if (!query.identity && traits.constructorInheritance?.order === 'single' && definition.decorators?.length) return false;
|
|
192
|
+
const identity = query.identity ? { identity: true } : null;
|
|
189
193
|
if (traits.functionalTypeConstruction) {
|
|
190
194
|
const classes = cppConstructionClasses(index, query, resolver);
|
|
191
195
|
return classes.length > 0 && classes.every(def =>
|
|
@@ -228,7 +232,7 @@ function constructionDenotes(index, resolver, query, definition, seen = new Set(
|
|
|
228
232
|
const bindings = (entry.importBindings || []).filter(b => (b.alias || b.name) === qualifier);
|
|
229
233
|
if (bindings.length !== 1) return false;
|
|
230
234
|
const target = moduleFile(`${bindings[0].module}.${bindings[0].name}`) || moduleFile(bindings[0].module);
|
|
231
|
-
return !!target && constructionDenotes(index, resolver, { file: target, line: 1, name }, definition, visited);
|
|
235
|
+
return !!target && constructionDenotes(index, resolver, { file: target, line: 1, name, ...identity }, definition, visited);
|
|
232
236
|
}
|
|
233
237
|
const aliases = (entry.moduleValueAliases || []).filter(alias => alias.name === name);
|
|
234
238
|
if (aliases.length) {
|
|
@@ -238,11 +242,11 @@ function constructionDenotes(index, resolver, query, definition, seen = new Set(
|
|
|
238
242
|
if (alternative.target) {
|
|
239
243
|
const parts = alternative.target.split('.'), targetName = parts.pop();
|
|
240
244
|
return constructionDenotes(index, resolver, { file, line: alias.line || line,
|
|
241
|
-
name: targetName, qualifier: parts.join('.') || null }, definition, visited);
|
|
245
|
+
name: targetName, qualifier: parts.join('.') || null, ...identity }, definition, visited);
|
|
242
246
|
}
|
|
243
247
|
const target = moduleFile(alternative.module);
|
|
244
248
|
return !!target && !!alternative.imported && constructionDenotes(index, resolver,
|
|
245
|
-
{ file: target, line: 1, name: alternative.imported }, definition, visited);
|
|
249
|
+
{ file: target, line: 1, name: alternative.imported, ...identity }, definition, visited);
|
|
246
250
|
});
|
|
247
251
|
});
|
|
248
252
|
}
|
|
@@ -252,13 +256,16 @@ function constructionDenotes(index, resolver, query, definition, seen = new Set(
|
|
|
252
256
|
!def.lexicalScopeStartLine && !['class', 'struct', 'interface', 'type'].includes(def.type));
|
|
253
257
|
return !shadows.length && !(entry.moduleAssignedNames || []).includes(name);
|
|
254
258
|
}
|
|
259
|
+
// A name the module assigns, or a function writes through its module
|
|
260
|
+
// binding, does not hold its import's value at every use.
|
|
261
|
+
if ((entry.moduleAssignedNames || []).includes(name)) return false;
|
|
255
262
|
const links = [...(entry.importBindings || []).filter(b => (b.alias || b.name) === name &&
|
|
256
263
|
b.name !== '*' && b.kind !== 'namespace'), ...(entry.exportDetails || []).filter(e =>
|
|
257
264
|
e.type === 're-export' && (e.alias || e.name) === name && e.source)];
|
|
258
265
|
if (links.length !== 1) return false;
|
|
259
266
|
const target = moduleFile(links[0].module || links[0].source);
|
|
260
267
|
return !!target && constructionDenotes(index, resolver, { file: target, line: 1,
|
|
261
|
-
name: links[0].name }, definition, visited);
|
|
268
|
+
name: links[0].name, ...identity }, definition, visited);
|
|
262
269
|
}
|
|
263
270
|
|
|
264
271
|
// A construction through a method's own receiver (fix #434): the class
|