ucn 5.5.3 → 5.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.claude/skills/ucn/SKILL.md +37 -12
  2. package/.claude/skills/ucn/references/commands.md +18 -14
  3. package/.claude/skills/ucn/references/resolution-rules.md +4 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +4 -4
  5. package/CHANGELOG.md +485 -0
  6. package/README.md +66 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +137 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +274 -108
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +14 -0
  13. package/core/cache.js +103 -16
  14. package/core/call-fit.js +130 -0
  15. package/core/callers.js +353 -71
  16. package/core/capture.js +363 -0
  17. package/core/change-kinds.js +1107 -0
  18. package/core/check.js +1150 -139
  19. package/core/command-contracts.js +8 -7
  20. package/core/constructed-class.js +1195 -0
  21. package/core/contract-membership.js +142 -0
  22. package/core/deadcode.js +455 -106
  23. package/core/deletion.js +421 -0
  24. package/core/discovery.js +104 -9
  25. package/core/entrypoint-toml.js +110 -0
  26. package/core/entrypoints.js +12 -6
  27. package/core/evidence-bands.js +68 -0
  28. package/core/evidence-bands.json +44 -0
  29. package/core/execute.js +172 -161
  30. package/core/hot-summary.js +241 -0
  31. package/core/index-ir.js +5 -3
  32. package/core/ir.js +6 -2
  33. package/core/member-signatures.js +789 -0
  34. package/core/outgoing-check.js +116 -0
  35. package/core/output/analysis-ext.js +20 -10
  36. package/core/output/analysis.js +98 -60
  37. package/core/output/check.js +101 -37
  38. package/core/output/doctor.js +34 -1
  39. package/core/output/find.js +2 -1
  40. package/core/output/lines.js +63 -3
  41. package/core/output/public.js +29 -11
  42. package/core/output/refactoring.js +32 -0
  43. package/core/output/reporting.js +19 -2
  44. package/core/output/search.js +14 -2
  45. package/core/output/tracing.js +36 -8
  46. package/core/output-budget.js +146 -61
  47. package/core/parallel-build.js +3 -3
  48. package/core/project.js +106 -23
  49. package/core/provenance-type.js +12 -5
  50. package/core/public-command.js +35 -9
  51. package/core/python-binding-identity.js +707 -0
  52. package/core/python-modules.js +6 -0
  53. package/core/reflection.js +248 -31
  54. package/core/registry.js +623 -72
  55. package/core/reporting.js +69 -34
  56. package/core/review-items.js +75 -0
  57. package/core/runtime-entrypoints.js +159 -0
  58. package/core/search.js +148 -164
  59. package/core/shared.js +0 -45
  60. package/core/snapshot.js +350 -0
  61. package/core/source-universe.js +707 -0
  62. package/core/test-units.js +3698 -0
  63. package/core/tracing.js +270 -658
  64. package/core/verify.js +63 -22
  65. package/languages/adapter.js +8 -3
  66. package/languages/c-family.js +12 -0
  67. package/languages/csharp.js +634 -0
  68. package/languages/go.js +120 -0
  69. package/languages/index.js +466 -4
  70. package/languages/java.js +712 -0
  71. package/languages/javascript.js +1498 -6
  72. package/languages/lexical-scope.js +68 -0
  73. package/languages/python.js +1708 -3
  74. package/languages/rust.js +209 -104
  75. package/languages/static-js.js +88 -0
  76. package/mcp/server.js +44 -110
  77. package/package.json +11 -5
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
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { CANONICAL_COMMANDS } = require('./registry');
3
+ const { CANONICAL_COMMANDS, GLOBAL_PARAMS } = require('./registry');
4
4
 
5
5
  const NAME_COMMANDS = new Set(['show', 'find', 'usages', 'trace', 'tests', 'plan']);
6
6
 
@@ -34,14 +34,16 @@ function sourceRangeTarget(params) {
34
34
  /** Build normalized execute() params from the shared CLI-style positional arg. */
35
35
  function buildPublicParams(command, arg, params = {}) {
36
36
  const clean = { ...params };
37
- if (clean.top === 0 && params.topRaw == null) delete clean.top;
38
- if (clean.maxLines == null) delete clean.maxLines;
39
- // Surface-only values never belong in execute params.
40
- for (const key of ['json', 'quiet', 'cache', 'clearCache', 'followSymlinks', 'includeBundled', 'interactive',
41
- 'topRaw', 'limitRaw', 'maxFilesRaw', 'maxLinesRaw', 'maxChars', 'maxCharsRaw',
42
- 'depthRaw', 'contextRaw', 'workersRaw',
43
- '_fileFromFileMode']) delete clean[key];
37
+ // Process flags and the build/transport parameters every command accepts
38
+ // never belong in execute params: the surface applies them itself.
39
+ for (const key of ['json', 'quiet', 'cache', 'clearCache', 'interactive', 'workers', '_fileFromFileMode',
40
+ ...GLOBAL_PARAMS]) delete clean[key];
44
41
 
42
+ return withoutUnset(subjectParams(command, arg, clean));
43
+ }
44
+
45
+ /** The params with the positional argument placed as the command's subject. */
46
+ function subjectParams(command, arg, clean) {
45
47
  if (NAME_COMMANDS.has(command)) return { ...clean, name: arg || clean.name };
46
48
  switch (command) {
47
49
  case 'search': return { ...clean, term: arg || clean.term };
@@ -58,8 +60,32 @@ function buildPublicParams(command, arg, params = {}) {
58
60
  }
59
61
  }
60
62
 
63
+ /** A parameter that was not given has no key: the same object MCP arguments make. */
64
+ function withoutUnset(params) {
65
+ for (const key of Object.keys(params)) {
66
+ if (params[key] === undefined) delete params[key];
67
+ }
68
+ return params;
69
+ }
70
+
71
+ /**
72
+ * What to say about a parameter a command accepts and that changes nothing,
73
+ * or less than its name suggests, there. One text for every surface, in CLI
74
+ * spelling: the surface renders it with formatSurfaceMessage.
75
+ */
76
+ function noOpNotes(command, params = {}) {
77
+ if (!params.includeMethods) return [];
78
+ if (['impact', 'trace', 'tests', 'check'].includes(command)) {
79
+ return [`--include-methods has no effect on '${command}' — method calls are always tiered by receiver evidence.`];
80
+ }
81
+ if (command === 'show') {
82
+ return [`--include-methods on '${command}' affects only method-callee display for standalone-function targets — caller tiers are always evidence-based, and method targets analyze method calls by default.`];
83
+ }
84
+ return [];
85
+ }
86
+
61
87
  function isPublicCommand(command) {
62
88
  return CANONICAL_COMMANDS.includes(command);
63
89
  }
64
90
 
65
- module.exports = { buildPublicParams, isPublicCommand, parseSourceTarget, sourceRangeTarget };
91
+ module.exports = { buildPublicParams, isPublicCommand, noOpNotes, parseSourceTarget, sourceRangeTarget };