ucn 4.2.3 → 5.0.2

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 (72) hide show
  1. package/.claude/skills/ucn/SKILL.md +89 -77
  2. package/.claude/skills/ucn/references/commands.md +62 -68
  3. package/.claude/skills/ucn/references/trust-contract.md +31 -6
  4. package/README.md +438 -305
  5. package/assets/demo.svg +31 -0
  6. package/cli/index.js +430 -1385
  7. package/core/account.js +144 -34
  8. package/core/analysis.js +182 -72
  9. package/core/ast-analysis.js +279 -0
  10. package/core/bridge.js +205 -24
  11. package/core/brief.js +27 -58
  12. package/core/build-worker.js +21 -140
  13. package/core/cache.js +513 -11
  14. package/core/callers.js +4920 -456
  15. package/core/check.js +13 -4
  16. package/core/command-contracts.js +402 -0
  17. package/core/compilation-database.js +276 -0
  18. package/core/confidence.js +4 -1
  19. package/core/deadcode.js +397 -19
  20. package/core/discovery.js +359 -46
  21. package/core/entrypoints.js +195 -41
  22. package/core/execute.js +887 -81
  23. package/core/graph-build.js +162 -7
  24. package/core/graph.js +53 -77
  25. package/core/imports.js +65 -6
  26. package/core/index-ir.js +138 -0
  27. package/core/ir.js +195 -0
  28. package/core/output/analysis.js +212 -22
  29. package/core/output/brief.js +23 -0
  30. package/core/output/check.js +4 -0
  31. package/core/output/doctor.js +37 -6
  32. package/core/output/endpoints.js +5 -2
  33. package/core/output/extraction.js +24 -12
  34. package/core/output/find.js +141 -36
  35. package/core/output/graph.js +11 -5
  36. package/core/output/public.js +462 -0
  37. package/core/output/refactoring.js +42 -10
  38. package/core/output/reporting.js +97 -20
  39. package/core/output/search.js +24 -16
  40. package/core/output/shared.js +22 -1
  41. package/core/output/tracing.js +30 -15
  42. package/core/output-budget.js +295 -0
  43. package/core/output.js +1 -0
  44. package/core/parallel-build.js +44 -11
  45. package/core/parser.js +3 -3
  46. package/core/project.js +384 -187
  47. package/core/public-command.js +47 -0
  48. package/core/registry.js +247 -117
  49. package/core/reporting.js +312 -290
  50. package/core/search.js +317 -185
  51. package/core/semantic-provider.js +110 -0
  52. package/core/stacktrace.js +25 -0
  53. package/core/tracing.js +101 -51
  54. package/core/trust-matrix.js +19 -40
  55. package/core/verify.js +534 -37
  56. package/languages/adapter.js +218 -0
  57. package/languages/c-family.js +2791 -0
  58. package/languages/c.js +3 -0
  59. package/languages/cpp.js +3 -0
  60. package/languages/csharp.js +1402 -0
  61. package/languages/go.js +60 -21
  62. package/languages/html.js +2 -2
  63. package/languages/index.js +85 -7
  64. package/languages/java.js +396 -13
  65. package/languages/javascript.js +199 -19
  66. package/languages/python.js +964 -22
  67. package/languages/rust.js +1317 -152
  68. package/languages/utils.js +40 -3
  69. package/mcp/server.js +254 -636
  70. package/package.json +39 -22
  71. package/eslint.config.js +0 -43
  72. package/jsconfig.json +0 -10
package/core/account.js CHANGED
@@ -16,11 +16,18 @@
16
16
  * strings, and scanner-skipped tokens such as JS builtins —
17
17
  * deliberately named "unclassified", not "comment")
18
18
  * unparsed - line in a file that failed to parse (still readable text)
19
+ * unsupported - line in a source file whose language UCN cannot parse
20
+ * (index.unsupportedFiles — e.g. .rb in a mixed repo).
21
+ * Text-scanned only, never analyzed; sites are listed so the
22
+ * answer is a superset of grep, and the CONTRACT sentence
23
+ * degrades whenever this bucket is non-zero. Without it a
24
+ * mixed-language repo got "partition complete" over a ground
25
+ * set that silently excluded whole languages.
19
26
  * unaccounted - residual; 0 when the arithmetic is conserved
20
27
  *
21
28
  * Conservation invariant:
22
29
  * groundTotal === confirmed + unverified + nonCall.total + excluded.total
23
- * + unparsed.lines + unaccounted
30
+ * + unparsed.lines + unsupported.lines + unaccounted
24
31
  *
25
32
  * Engine finds that grep would MISS (alias-resolved call sites whose line does
26
33
  * not word-boundary-match the name) are reported in `beyondText` — additive
@@ -43,7 +50,12 @@
43
50
 
44
51
  const fs = require('fs');
45
52
  const path = require('path');
46
- const { escapeRegExp } = require('./shared');
53
+ const { escapeRegExp, codeUnitCompare } = require('./shared');
54
+
55
+ // Unsupported-language site listings are capped so a Rails-sized repo cannot
56
+ // flood the account object; the counts always cover the full set.
57
+ const UNSUPPORTED_SITE_CAP = 50;
58
+ const UNSUPPORTED_SITE_TEXT_MAX = 160;
47
59
 
48
60
  /**
49
61
  * Compute the text-occurrence ground set for a symbol name.
@@ -51,10 +63,14 @@ const { escapeRegExp } = require('./shared');
51
63
  * @param {object} index - ProjectIndex
52
64
  * @param {string} name - Symbol name (matched with \b word boundaries)
53
65
  * @returns {{
54
- * total: number, // matching lines incl. unparsed files
55
- * fileCount: number, // files (indexed + unparsed) with >= 1 matching line
66
+ * total: number, // matching lines incl. unparsed + unsupported files
67
+ * fileCount: number, // files (indexed + unparsed + unsupported) with >= 1 matching line
56
68
  * perFile: Map<string, number[]>, // absPath -> sorted 1-indexed line numbers (indexed files only)
57
69
  * unparsed: { fileCount: number, lines: number, files: string[] }, // relative paths
70
+ * unsupported: { fileCount: number, lines: number, files: string[],
71
+ * languages: Object<string, number>,
72
+ * sites: Array<{file: string, line: number, text: string}>,
73
+ * sitesTruncated: boolean },
58
74
  * unreadableFiles: string[] // relative paths; OUTSIDE the arithmetic
59
75
  * }}
60
76
  */
@@ -88,43 +104,126 @@ function computeGroundSet(index, name) {
88
104
 
89
105
  // Failed-to-parse files are still text: their matching lines are part of
90
106
  // the ground set, classified as `unparsed` (loud degradation, not silence).
91
- const unparsed = { fileCount: 0, lines: 0, files: [] };
92
- const unreadableFiles = [];
93
- if (index.failedFiles && index.failedFiles.size > 0) {
94
- for (const failedPath of index.failedFiles) {
95
- if (index.files.has(failedPath)) continue; // indexed despite earlier failure
96
- let content;
97
- try {
98
- content = fs.readFileSync(failedPath, 'utf-8');
99
- } catch (e) {
100
- unreadableFiles.push(path.relative(index.root, failedPath));
101
- continue;
102
- }
103
- if (!content.includes(name)) continue;
104
- const lines = content.split('\n');
105
- let matched = 0;
106
- for (let i = 0; i < lines.length; i++) {
107
- if (wordRe.test(lines[i])) matched++;
108
- }
109
- if (matched > 0) {
110
- unparsed.fileCount++;
111
- unparsed.lines += matched;
112
- unparsed.files.push(path.relative(index.root, failedPath));
113
- }
114
- }
115
- unparsed.files.sort();
116
- }
107
+ const { unparsed, unreadableFiles } = scanFailedFiles(index, name);
108
+
109
+ // Unsupported-language source files (discovery classified them as source,
110
+ // the parser registry cannot read them) are still text the contract must
111
+ // cover: a literal `processPayment(order)` in a .rb file is exactly what
112
+ // grep would show. Their matching lines join the ground set as
113
+ // `unsupported`, with sites listed so the caller answer strictly
114
+ // dominates grep instead of silently under-reporting it.
115
+ const unsupported = scanUnsupportedFiles(index, name, { unreadableFiles });
116
+ const skippedSources = Array.isArray(index.discoveryIssues)
117
+ ? index.discoveryIssues.map(issue => ({ ...issue })) : [];
117
118
  unreadableFiles.sort();
118
119
 
119
120
  return {
120
- total: total + unparsed.lines,
121
- fileCount: fileCount + unparsed.fileCount,
121
+ total: total + unparsed.lines + unsupported.lines,
122
+ fileCount: fileCount + unparsed.fileCount + unsupported.fileCount,
122
123
  perFile,
123
124
  unparsed,
125
+ unsupported,
124
126
  unreadableFiles,
127
+ skippedSources,
125
128
  };
126
129
  }
127
130
 
131
+ /** Scan only files the parser/index could not ingest. */
132
+ function scanFailedFiles(index, name) {
133
+ const unparsed = { fileCount: 0, lines: 0, files: [] };
134
+ const unreadableFiles = [];
135
+ if (!index.failedFiles || index.failedFiles.size === 0) {
136
+ return { unparsed, unreadableFiles };
137
+ }
138
+ const wordRe = new RegExp('\\b' + escapeRegExp(name) + '\\b');
139
+ for (const failedPath of index.failedFiles) {
140
+ if (index.files.has(failedPath)) continue;
141
+ let content;
142
+ try {
143
+ content = fs.readFileSync(failedPath, 'utf-8');
144
+ } catch (_) {
145
+ unreadableFiles.push(path.relative(index.root, failedPath));
146
+ continue;
147
+ }
148
+ if (!content.includes(name)) continue;
149
+ let matched = 0;
150
+ for (const line of content.split('\n')) {
151
+ if (wordRe.test(line)) matched++;
152
+ }
153
+ if (matched > 0) {
154
+ unparsed.fileCount++;
155
+ unparsed.lines += matched;
156
+ unparsed.files.push(path.relative(index.root, failedPath));
157
+ }
158
+ }
159
+ unparsed.files.sort(codeUnitCompare);
160
+ unreadableFiles.sort(codeUnitCompare);
161
+ return { unparsed, unreadableFiles };
162
+ }
163
+
164
+ /**
165
+ * Word-boundary-scan index.unsupportedFiles for `name`. Returns the
166
+ * `unsupported` account bucket ({ fileCount, lines, files, languages, sites,
167
+ * sitesTruncated }). Shared by computeGroundSet and by commands whose result
168
+ * is not account-shaped (tests, usages) but still must not stay silent when
169
+ * a mixed-language repo has occurrences UCN cannot analyze.
170
+ *
171
+ * @param {object} index - ProjectIndex
172
+ * @param {string} name - Symbol name (matched with \b word boundaries)
173
+ * @param {object} [opts]
174
+ * @param {string[]} [opts.unreadableFiles] - collector for read failures
175
+ */
176
+ function scanUnsupportedFiles(index, name, opts = {}) {
177
+ const unsupported = {
178
+ fileCount: 0, lines: 0, files: [], languages: {}, sites: [],
179
+ sitesTruncated: false,
180
+ };
181
+ if (!Array.isArray(index.unsupportedFiles) || index.unsupportedFiles.length === 0) {
182
+ return unsupported;
183
+ }
184
+ const wordRe = new RegExp('\\b' + escapeRegExp(name) + '\\b');
185
+ const languageCounts = new Map();
186
+ for (const skipped of index.unsupportedFiles) {
187
+ const absPath = path.join(index.root, skipped.relativePath);
188
+ let content;
189
+ try {
190
+ content = index._readFile(absPath);
191
+ } catch (e) {
192
+ if (opts.unreadableFiles) opts.unreadableFiles.push(skipped.relativePath);
193
+ continue;
194
+ }
195
+ if (!content.includes(name)) continue;
196
+ const lines = content.split('\n');
197
+ let matched = 0;
198
+ for (let i = 0; i < lines.length; i++) {
199
+ if (!wordRe.test(lines[i])) continue;
200
+ matched++;
201
+ if (unsupported.sites.length < UNSUPPORTED_SITE_CAP) {
202
+ unsupported.sites.push({
203
+ file: skipped.relativePath,
204
+ line: i + 1,
205
+ text: lines[i].trim().slice(0, UNSUPPORTED_SITE_TEXT_MAX),
206
+ });
207
+ } else {
208
+ unsupported.sitesTruncated = true;
209
+ }
210
+ }
211
+ if (matched > 0) {
212
+ unsupported.fileCount++;
213
+ unsupported.lines += matched;
214
+ unsupported.files.push(skipped.relativePath);
215
+ const lang = skipped.language || skipped.extension || 'unknown';
216
+ languageCounts.set(lang, (languageCounts.get(lang) || 0) + matched);
217
+ }
218
+ }
219
+ unsupported.files.sort(codeUnitCompare);
220
+ unsupported.sites.sort((a, b) => codeUnitCompare(a.file, b.file) || a.line - b.line);
221
+ for (const lang of [...languageCounts.keys()].sort(codeUnitCompare)) {
222
+ unsupported.languages[lang] = languageCounts.get(lang);
223
+ }
224
+ return unsupported;
225
+ }
226
+
128
227
  /**
129
228
  * Classify ground lines not claimed by engine results.
130
229
  *
@@ -313,12 +412,19 @@ function buildAccount(index, name, parts) {
313
412
  // visible entries instead of silent gaps.
314
413
  unverified += callNotResolved.length;
315
414
 
316
- const accountedTotal = confirmed + unverified + nonCall.total + excludedTotal + groundSet.unparsed.lines;
415
+ const unsupported = groundSet.unsupported ||
416
+ { fileCount: 0, lines: 0, files: [], languages: {}, sites: [], sitesTruncated: false };
417
+ const accountedTotal = confirmed + unverified + nonCall.total + excludedTotal +
418
+ groundSet.unparsed.lines + unsupported.lines;
317
419
  const unaccounted = groundSet.total - accountedTotal;
318
420
 
421
+ // Occurrences in unsupported-language files break the whole-text claim:
422
+ // UCN cannot adjudicate them, so "partition complete" would be false.
319
423
  const textComplete = unaccounted === 0 &&
320
424
  groundSet.unparsed.fileCount === 0 &&
321
- groundSet.unreadableFiles.length === 0;
425
+ unsupported.lines === 0 &&
426
+ groundSet.unreadableFiles.length === 0 &&
427
+ (!groundSet.skippedSources || groundSet.skippedSources.length === 0);
322
428
  const observedTextZero = textComplete && confirmed === 0 && unverified === 0 &&
323
429
  beyondText.count === 0;
324
430
 
@@ -331,7 +437,9 @@ function buildAccount(index, name, parts) {
331
437
  nonCall,
332
438
  excluded: { total: excludedTotal, byReason: excludedByReason },
333
439
  unparsed: groundSet.unparsed,
440
+ unsupported,
334
441
  unreadableFiles: groundSet.unreadableFiles,
442
+ skippedSources: groundSet.skippedSources || [],
335
443
  beyondText,
336
444
  unaccounted,
337
445
  conserved: unaccounted === 0,
@@ -371,4 +479,6 @@ module.exports = {
371
479
  computeGroundSet,
372
480
  classifyGroundLines,
373
481
  buildAccount,
482
+ scanUnsupportedFiles,
483
+ scanFailedFiles,
374
484
  };