ucn 5.5.2 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -12
  2. package/.claude/skills/ucn/references/commands.md +21 -17
  3. package/.claude/skills/ucn/references/resolution-rules.md +4 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +4 -4
  5. package/CHANGELOG.md +474 -0
  6. package/README.md +71 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +138 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +273 -107
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +29 -65
  13. package/core/cache.js +101 -16
  14. package/core/callers.js +352 -70
  15. package/core/capture.js +331 -0
  16. package/core/change-kinds.js +888 -0
  17. package/core/check.js +1117 -139
  18. package/core/client-receivers.js +189 -0
  19. package/core/command-contracts.js +7 -6
  20. package/core/constructed-class.js +1195 -0
  21. package/core/contract-membership.js +142 -0
  22. package/core/deadcode.js +388 -85
  23. package/core/deletion.js +401 -0
  24. package/core/discovery.js +104 -9
  25. package/core/entrypoints.js +12 -6
  26. package/core/evidence-bands.js +68 -0
  27. package/core/evidence-bands.json +44 -0
  28. package/core/execute.js +172 -161
  29. package/core/hot-summary.js +241 -0
  30. package/core/index-ir.js +3 -1
  31. package/core/inprocess-clients.js +41 -15
  32. package/core/ir.js +5 -1
  33. package/core/member-signatures.js +789 -0
  34. package/core/output/analysis-ext.js +20 -10
  35. package/core/output/analysis.js +98 -60
  36. package/core/output/check.js +98 -37
  37. package/core/output/doctor.js +34 -1
  38. package/core/output/find.js +2 -1
  39. package/core/output/lines.js +63 -3
  40. package/core/output/public.js +30 -11
  41. package/core/output/refactoring.js +32 -0
  42. package/core/output/reporting.js +21 -2
  43. package/core/output/search.js +11 -2
  44. package/core/output/tracing.js +35 -7
  45. package/core/output-budget.js +124 -57
  46. package/core/parallel-build.js +3 -3
  47. package/core/project.js +106 -23
  48. package/core/provenance-type.js +12 -5
  49. package/core/public-command.js +35 -9
  50. package/core/python-modules.js +6 -0
  51. package/core/reflection.js +166 -23
  52. package/core/registry.js +623 -72
  53. package/core/reporting.js +69 -34
  54. package/core/request-helpers.js +63 -7
  55. package/core/review-items.js +75 -0
  56. package/core/search.js +89 -128
  57. package/core/shared.js +0 -45
  58. package/core/snapshot.js +350 -0
  59. package/core/source-universe.js +707 -0
  60. package/core/test-units.js +3624 -0
  61. package/core/tracing.js +150 -96
  62. package/core/verify.js +27 -11
  63. package/languages/adapter.js +8 -3
  64. package/languages/c-family.js +12 -0
  65. package/languages/csharp.js +634 -0
  66. package/languages/go.js +120 -0
  67. package/languages/index.js +422 -4
  68. package/languages/java.js +712 -0
  69. package/languages/javascript.js +1460 -2
  70. package/languages/lexical-scope.js +68 -0
  71. package/languages/python.js +1672 -1
  72. package/languages/rust.js +91 -0
  73. package/mcp/server.js +44 -110
  74. package/package.json +11 -6
@@ -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
- const DEFAULT_OUTPUT_CHARS = 10000;
11
- const BROAD_OUTPUT_CHARS = 3000;
12
- const MAX_OUTPUT_CHARS = 100000;
13
- const BROAD_COMMANDS = new Set([
14
- ...BROAD_CANONICAL,
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):|\d+ test-file usage\(s\) hidden\b|Found \d+ (?:definitions|fuzzy matches)\b)/;
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
- const TRUST_LINE_RE = /^(?:ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT|VALUE ACCOUNT|TYPE ACCOUNT):/;
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
- const SECTION_AWARE_COMMANDS = new Set(['show', 'impact']);
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.length : block.header.length) + 1;
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].length + 1;
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 = BROAD_COMMANDS.has(command)
254
- ? BROAD_OUTPUT_CHARS
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
- const limit = Math.max(0, hardLimit - trailingChars);
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.length <= limit) {
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 allHint = supportsAll
286
- ? (surface === 'mcp'
287
- ? 'Use all=true or max_chars=<n> (100K maximum).'
288
- : 'Use --all or --max-chars=N (100K maximum).')
289
- : (surface === 'mcp'
290
- ? 'Use max_chars=<n> (100K maximum).'
291
- : 'Use --max-chars=N (100K maximum).');
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.length + 1, 0);
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.length + 1, 0);
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.length + (metadataChars > 0 ? heading.length + metadataChars : 0);
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.length + 1 > optionalRoom) continue;
408
+ if (optionalChars + lineCost(candidate.line) + 1 > optionalRoom) continue;
349
409
  reservedOptional.push(candidate);
350
- optionalChars += candidate.line.length + 1;
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.length + (metadata.length > 0 ? heading.length + costOf(metadata) : 0);
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.length + 1 + (metadata.length === 0 ? heading.length : 0);
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) => `... OUTPUT TRUNCATED (${text.length}→${hardLimit}): answer withheld; ` +
396
- `${shown} of ${trust.length} trust line(s) shown. Raise ${flag} to at least ${needed}.`;
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 = trailingChars + shortNotice.length + trustChars;
459
+ const needed = terminalChars + lineCost(shortNotice) + trustChars;
399
460
  let shown = 0;
400
- let used = noticeFor(trust.length, needed).length;
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.length + 1 > limit) break;
470
+ if (used + lineCost(candidate.line) + 1 > limit) break;
404
471
  kept.push(candidate.line);
405
- used += candidate.line.length + 1;
472
+ used += lineCost(candidate.line) + 1;
406
473
  shown++;
407
474
  }
408
475
  const notice = noticeFor(shown, needed);
409
- if (notice.length > limit) {
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.length <= limit);
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.length - 2;
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.length + 1, 0);
524
+ const missingChars = missing.reduce((sum, candidate) => sum + lineCost(candidate.line) + 1, 0);
458
525
  let rendered = `${sectioned}\n\n${longNotice}`;
459
- if (rendered.length + missingChars > limit) {
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.length + missingChars;
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.length + 1 > limit) { omitted++; continue; }
535
+ if (used + lineCost(candidate.line) + 1 > limit) { omitted++; continue; }
469
536
  appended.push(candidate.line);
470
- used += candidate.line.length + 1;
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);
@@ -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.failedFiles.add(result.filePath);
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.failedFiles.delete(result.filePath);
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.failedFiles.delete(result.filePath);
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
- this.root = findProjectRoot(rootDir);
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
- const globOpts = {
634
- root: this.root,
635
- maxFiles: options.maxFiles || this.config.maxFiles || 50000,
636
- maxDepth: options.maxDepth ?? this.config.maxDepth,
637
- maxFileSize: options.maxFileSize ?? this.config.maxFileSize,
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
- const listing = gitListing(this.root);
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 || 50000;
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.failedFiles.delete(file); // Succeeded now, remove from failed
895
+ this._clearFailedFile(file); // Succeeded now, remove from failed
866
896
  } catch (e) {
867
- this.failedFiles.add(file); // Track files that fail to index
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
 
@@ -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
- if (traits.constructorInheritance?.order === 'single' && definition.decorators?.length) return false;
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