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
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ BROAD_COMMANDS: BROAD_CANONICAL,
5
+ FLAG_APPLICABILITY,
6
+ resolveCommand,
7
+ toMcpName,
8
+ } = require('./registry');
9
+
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
+ ]);
17
+
18
+ const CONTRACT_LINE_RE = /^\s*(?:(?:Summary|ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT):|\d+ test-file usage\(s\) hidden\b|(?:Note:\s*)?Found \d+ (?:definitions|fuzzy matches)\b)/;
19
+ const MAX_PRESERVED_CONTRACT_LINES = 24;
20
+ const MAX_PRESERVED_CONTRACT_CHARS = 8000;
21
+
22
+ function preservedContractMetadata(fullText, visibleText, options = {}) {
23
+ const visible = new Set(visibleText.split('\n').map(line => line.trim()));
24
+ const candidates = [];
25
+ const selected = [];
26
+ let selectedChars = 0;
27
+ let omitted = 0;
28
+ const maxLines = options.maxLines ?? MAX_PRESERVED_CONTRACT_LINES;
29
+ const maxChars = options.maxChars ?? MAX_PRESERVED_CONTRACT_CHARS;
30
+
31
+ for (const [sourceIndex, rawLine] of fullText.split('\n').entries()) {
32
+ // Execution notes may concatenate several independent disclosures on
33
+ // one physical line. Preserve the actionable test-scope contract as
34
+ // its own sentence so a later parse-failure note cannot make the
35
+ // whole metadata item too large for a small transport budget.
36
+ const firstSentenceEnd = /^\s*\d+ test-file usage\(s\) hidden\b/.test(rawLine)
37
+ ? rawLine.indexOf('. ')
38
+ : -1;
39
+ const contractLine = firstSentenceEnd >= 0
40
+ ? rawLine.slice(0, firstSentenceEnd + 1)
41
+ : rawLine;
42
+ if (!CONTRACT_LINE_RE.test(contractLine)) continue;
43
+ const line = contractLine.trim();
44
+ if (!line || visible.has(line)) continue;
45
+ const priority = /^(?:ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT):/.test(line)
46
+ ? 0
47
+ : /^\d+ test-file usage\(s\) hidden\b/.test(line) ? 1 : 2;
48
+ candidates.push({ line, priority, sourceIndex });
49
+ }
50
+
51
+ for (const { line } of candidates.sort((a, b) =>
52
+ a.priority - b.priority || a.sourceIndex - b.sourceIndex)) {
53
+ if (selected.length >= maxLines ||
54
+ selectedChars + line.length + 1 > maxChars) {
55
+ omitted++;
56
+ continue;
57
+ }
58
+ selected.push(line);
59
+ selectedChars += line.length + 1;
60
+ }
61
+
62
+ return { lines: selected, omitted, complete: omitted === 0 };
63
+ }
64
+
65
+ function narrowingHint(command, surface, params = {}) {
66
+ const cli = {
67
+ repo: 'Use --sections, --in, or --exclude to narrow the view.',
68
+ entrypoints: 'Use --framework or --exclude to narrow the result.',
69
+ endpoints: 'Use --prefix, --method, --server-only, or --client-only.',
70
+ impact: 'Use --file or --limit=N to narrow the result.',
71
+ tests: 'Use --file or --exclude to narrow the result.',
72
+ deadcode: 'Use --file, --in, or --exclude to narrow the result.',
73
+ usages: 'Use --file or --in to narrow the result.',
74
+ deps: 'Use --depth=1 or --direction=imports|importers.',
75
+ api: 'Use --limit=N to narrow the result.',
76
+ };
77
+ const mcp = {
78
+ repo: 'Use sections=, in=, or exclude= to narrow the view.',
79
+ entrypoints: 'Use framework= or exclude= to narrow the result.',
80
+ endpoints: 'Use prefix=, method=, server_only=true, or client_only=true.',
81
+ impact: 'Use file= or limit=<n> to narrow the result.',
82
+ tests: 'Use file= or exclude= to narrow the result.',
83
+ deadcode: 'Use file=, in=, or exclude= to narrow the result.',
84
+ usages: 'Use file= or in= to narrow the result.',
85
+ deps: 'Use depth=1 or direction=imports|importers.',
86
+ api: 'Use limit=<n> to narrow the result.',
87
+ };
88
+ const canonical = String(command).replace(/_([a-z])/g, (_, c) => c.toUpperCase());
89
+ if (canonical === 'deps' && params.cycles) {
90
+ return surface === 'mcp'
91
+ ? 'Use max_chars=<n> to raise the output budget for cycle results.'
92
+ : 'Use --max-chars=N to raise the output budget for cycle results.';
93
+ }
94
+ if (surface === 'mcp' && (mcp[command] || mcp[canonical])) {
95
+ return mcp[command] || mcp[canonical];
96
+ }
97
+ if (surface !== 'mcp' && (cli[canonical] || cli[command])) {
98
+ return cli[canonical] || cli[command];
99
+ }
100
+ const applicable = new Set(FLAG_APPLICABILITY[canonical] || []);
101
+ const candidates = ['file', 'in', 'exclude'].filter(flag => applicable.has(flag));
102
+ if (candidates.length > 0) {
103
+ return surface === 'mcp'
104
+ ? `Use ${candidates.map(flag => `${flag}=`).join(', ')} to narrow scope.`
105
+ : `Use ${candidates.map(flag => `--${flag}`).join(', ')} to narrow scope.`;
106
+ }
107
+ return surface === 'mcp'
108
+ ? 'Use max_chars=<n> to raise the explicit output budget.'
109
+ : 'Use --max-chars=N to raise the explicit output budget.';
110
+ }
111
+
112
+ function compactNarrowingHint(command, surface, params = {}) {
113
+ const full = narrowingHint(command, surface, params);
114
+ if (surface !== 'mcp') {
115
+ const flags = full.match(/--[a-z-]+(?:=N)?/g) || [];
116
+ return [...new Set(flags)].join('/');
117
+ }
118
+ const flags = full.match(/\b(?:sections|in|exclude|framework|prefix|method|server_only|client_only|file|limit|depth|direction|max_chars)(?:=<n>|=true|=imports\|importers|=1|=)?/g) || [];
119
+ return [...new Set(flags)].join('/');
120
+ }
121
+
122
+ /**
123
+ * Apply the same bounded-output contract to CLI and MCP text. JSON is not
124
+ * passed here: structured consumers receive the complete stable envelope.
125
+ */
126
+ function applyOutputBudget(text, {
127
+ command,
128
+ maxChars,
129
+ all = false,
130
+ surface = 'cli',
131
+ params = {},
132
+ } = {}) {
133
+ if (!text) {
134
+ return {
135
+ text: '(no output)',
136
+ truncated: false,
137
+ fullChars: 0,
138
+ requestedLimit: maxChars || null,
139
+ contractMetadata: [],
140
+ contractMetadataComplete: true,
141
+ };
142
+ }
143
+
144
+ const defaultLimit = BROAD_COMMANDS.has(command)
145
+ ? BROAD_OUTPUT_CHARS
146
+ : DEFAULT_OUTPUT_CHARS;
147
+ const requested = maxChars || (all ? MAX_OUTPUT_CHARS : defaultLimit);
148
+ const limit = Math.min(requested, MAX_OUTPUT_CHARS);
149
+ if (text.length <= limit) {
150
+ return {
151
+ text,
152
+ truncated: false,
153
+ fullChars: text.length,
154
+ requestedLimit: limit,
155
+ contractMetadata: [],
156
+ contractMetadataComplete: true,
157
+ };
158
+ }
159
+
160
+ const canonical = resolveCommand(command, surface === 'mcp' ? 'mcp' : 'cli') ||
161
+ command;
162
+ const supportsAll = FLAG_APPLICABILITY[canonical]?.includes('all') &&
163
+ !(canonical === 'deps' && params.cycles);
164
+ const allHint = supportsAll
165
+ ? (surface === 'mcp'
166
+ ? 'Use all=true or max_chars=<n> (100K maximum).'
167
+ : 'Use --all or --max-chars=N (100K maximum).')
168
+ : (surface === 'mcp'
169
+ ? 'Use max_chars=<n> (100K maximum).'
170
+ : 'Use --max-chars=N (100K maximum).');
171
+ const compactBudget = limit < 500;
172
+ const raiseHint = surface === 'mcp' ? 'max_chars=<n>' : '--max-chars=N';
173
+ const compactScope = compactNarrowingHint(command, surface, params);
174
+ const compactGuidance = compactScope.includes(raiseHint)
175
+ ? `Raise ${raiseHint}.`
176
+ : `Narrow with ${compactScope || raiseHint}; raise ${raiseHint}.`;
177
+ let notice = compactBudget
178
+ ? `... OUTPUT TRUNCATED (${text.length}→${limit}). ${compactGuidance}`
179
+ : `... OUTPUT TRUNCATED: ${text.length} chars total; hard limit ${limit}. ` +
180
+ `${narrowingHint(command, surface, params)} ${allHint}`;
181
+ if (compactBudget && notice.length > limit) {
182
+ const emergency = supportsAll
183
+ ? (surface === 'mcp'
184
+ ? 'all=true/max_chars=<n>'
185
+ : '--all/--max-chars=N')
186
+ : raiseHint;
187
+ notice = emergency.slice(0, limit);
188
+ }
189
+
190
+ // At very small explicit budgets, the verbose preservation heading alone
191
+ // can consume most of the transport. Trust/account lines take precedence
192
+ // over body detail and are appended directly after a compact notice.
193
+ if (compactBudget) {
194
+ const metadataCapacity = Math.max(0, limit - notice.length - 1);
195
+ const candidate = preservedContractMetadata(text, '', {
196
+ maxChars: metadataCapacity,
197
+ });
198
+ const candidateText = candidate.lines.join('\n');
199
+ const separatorChars = candidateText ? 2 : 1;
200
+ const bodyBudget = Math.max(0,
201
+ limit - notice.length - candidateText.length - separatorChars);
202
+ const prefix = text.slice(0, bodyBudget);
203
+ const lastNewline = prefix.lastIndexOf('\n');
204
+ const cleanCut = lastNewline > bodyBudget * 0.8
205
+ ? prefix.slice(0, lastNewline)
206
+ : prefix;
207
+ const remainingForMetadata = Math.max(0,
208
+ limit - cleanCut.length - notice.length -
209
+ (cleanCut ? 1 : 0) - 1);
210
+ const contractMetadata = preservedContractMetadata(text, cleanCut, {
211
+ // preservedContractMetadata accounts a trailing newline per item;
212
+ // the renderer joins the final item without one.
213
+ maxChars: remainingForMetadata + 1,
214
+ });
215
+ const pieces = [];
216
+ if (cleanCut) pieces.push(cleanCut);
217
+ pieces.push(notice);
218
+ if (contractMetadata.lines.length > 0) {
219
+ pieces.push(contractMetadata.lines.join('\n'));
220
+ }
221
+ return {
222
+ text: pieces.join('\n').slice(0, limit),
223
+ truncated: true,
224
+ fullChars: text.length,
225
+ requestedLimit: limit,
226
+ contractMetadata: contractMetadata.lines,
227
+ contractMetadataComplete: contractMetadata.complete,
228
+ };
229
+ }
230
+
231
+ // max_chars is a transport ceiling, not merely a body target. Reserve a
232
+ // bounded share for omitted trust/account lines, then spend the remainder
233
+ // on the original body and truncation notice. Tiny limits may be too small
234
+ // for any metadata; the returned completeness bit records that fact.
235
+ const metadataBudget = Math.min(
236
+ MAX_PRESERVED_CONTRACT_CHARS,
237
+ Math.max(0, Math.floor(limit * 0.50)),
238
+ );
239
+ const candidateMetadata = preservedContractMetadata(text, '', {
240
+ maxChars: metadataBudget,
241
+ });
242
+ const provisionalMetadata = candidateMetadata.lines.length > 0
243
+ ? '\n\nPRESERVED CONTRACT METADATA (from omitted output):\n' +
244
+ candidateMetadata.lines.join('\n')
245
+ : '';
246
+ const bodyBudget = Math.max(0,
247
+ limit - notice.length - provisionalMetadata.length - 2);
248
+ const truncated = text.substring(0, bodyBudget);
249
+ const lastNewline = truncated.lastIndexOf('\n');
250
+ const cleanCut = lastNewline > bodyBudget * 0.8
251
+ ? truncated.substring(0, lastNewline)
252
+ : truncated;
253
+ const metadataHeading = '\n\nPRESERVED CONTRACT METADATA (from omitted output):';
254
+ const remainingForMetadata = Math.max(0,
255
+ limit - cleanCut.length - notice.length - (cleanCut ? 2 : 0) -
256
+ metadataHeading.length);
257
+ const contractMetadata = preservedContractMetadata(text, cleanCut, {
258
+ maxChars: Math.min(metadataBudget, remainingForMetadata),
259
+ });
260
+ let rendered = `${cleanCut}${cleanCut ? '\n\n' : ''}${notice}`;
261
+
262
+ if (contractMetadata.lines.length > 0) {
263
+ const heading = metadataHeading;
264
+ if (rendered.length + heading.length <= limit) rendered += heading;
265
+ for (const line of contractMetadata.lines) {
266
+ if (rendered.length + line.length + 1 > limit) break;
267
+ rendered += '\n' + line;
268
+ }
269
+ }
270
+ if (contractMetadata.omitted > 0) {
271
+ const warning = `\nWARNING: ${contractMetadata.omitted} additional contract line(s) ` +
272
+ 'could not fit the preservation budget; narrow scope before acting.';
273
+ if (rendered.length + warning.length <= limit) rendered += warning;
274
+ }
275
+
276
+ // Defensive final ceiling for very small limits and future notice edits.
277
+ rendered = rendered.slice(0, limit);
278
+
279
+ return {
280
+ text: rendered,
281
+ truncated: true,
282
+ fullChars: text.length,
283
+ requestedLimit: limit,
284
+ contractMetadata: contractMetadata.lines,
285
+ contractMetadataComplete: contractMetadata.complete,
286
+ };
287
+ }
288
+
289
+ module.exports = {
290
+ DEFAULT_OUTPUT_CHARS,
291
+ BROAD_OUTPUT_CHARS,
292
+ MAX_OUTPUT_CHARS,
293
+ applyOutputBudget,
294
+ preservedContractMetadata,
295
+ };
package/core/output.js CHANGED
@@ -23,4 +23,5 @@ module.exports = {
23
23
  ...require('./output/doctor'),
24
24
  ...require('./output/check'),
25
25
  ...require('./output/endpoints'),
26
+ ...require('./output/public'),
26
27
  };
@@ -9,9 +9,32 @@
9
9
  */
10
10
 
11
11
  const os = require('os');
12
+ const fs = require('fs');
12
13
  const path = require('path');
13
14
  const { Worker, MessageChannel, receiveMessageOnPort } = require('worker_threads');
14
15
 
16
+ function partitionFiles(index, files, workerCount) {
17
+ const chunks = Array.from({ length: workerCount }, () => ({
18
+ files: [], bytes: 0,
19
+ }));
20
+ const weightedFiles = files.map((file, order) => {
21
+ let bytes = index.files.get(file)?.size;
22
+ if (!Number.isFinite(bytes)) {
23
+ try { bytes = fs.statSync(file).size; } catch (_) { bytes = 0; }
24
+ }
25
+ return { file, bytes, order };
26
+ }).sort((a, b) => b.bytes - a.bytes || a.order - b.order);
27
+ for (const weighted of weightedFiles) {
28
+ let target = chunks[0];
29
+ for (let i = 1; i < chunks.length; i++) {
30
+ if (chunks[i].bytes < target.bytes) target = chunks[i];
31
+ }
32
+ target.files.push(weighted.file);
33
+ target.bytes += weighted.bytes;
34
+ }
35
+ return chunks;
36
+ }
37
+
15
38
  /**
16
39
  * Build index in parallel using worker threads.
17
40
  *
@@ -28,23 +51,33 @@ function parallelBuild(index, files, options = {}) {
28
51
  : os.cpus().length;
29
52
  const autoWorkers = Math.max(availableCpus - 1, 1);
30
53
  const maxWorkers = (options.workerCount > 0) ? options.workerCount : autoWorkers;
54
+ const workerCap = options.maxWorkers > 0 ? options.maxWorkers : 8;
55
+ const minFilesPerWorker = options.minFilesPerWorker > 0
56
+ ? options.minFilesPerWorker : 100;
31
57
  const workerCount = Math.min(
32
58
  maxWorkers,
33
- 8,
34
- Math.ceil(files.length / 100) // at least 100 files per worker
59
+ workerCap,
60
+ Math.ceil(files.length / minFilesPerWorker)
35
61
  );
36
62
 
37
- if (workerCount < 2) return false;
63
+ if (workerCount < 2) {
64
+ index.lastBuildWorkerCount = 1;
65
+ return false;
66
+ }
67
+ index.lastBuildWorkerCount = workerCount;
38
68
 
39
69
  if (!options.quiet) {
40
70
  console.error(`Parallel build: ${workerCount} workers for ${files.length} files`);
41
71
  }
42
72
 
43
- // Partition files round-robin for balanced work distribution
44
- const chunks = Array.from({ length: workerCount }, () => []);
45
- for (let i = 0; i < files.length; i++) {
46
- chunks[i % workerCount].push(files[i]);
47
- }
73
+ // Parsing cost is driven much more by source size than file count. A
74
+ // round-robin split can put several giant generated/template headers in
75
+ // one worker while peers finish tiny files, making the whole build wait
76
+ // on one straggler and retaining multiple large native ASTs together.
77
+ // Longest-processing-time scheduling by byte size is deterministic and
78
+ // gives a substantially tighter upper bound on both wall time and peak
79
+ // memory. Canonical index ordering after the merge remains unchanged.
80
+ const chunks = partitionFiles(index, files, workerCount);
48
81
 
49
82
  // Synchronization: one Int32 per worker in SharedArrayBuffer
50
83
  const sab = new SharedArrayBuffer(4 * workerCount);
@@ -59,7 +92,7 @@ function parallelBuild(index, files, options = {}) {
59
92
 
60
93
  // Build per-worker hash subset (each worker only needs hashes for its chunk)
61
94
  const workerHashes = Object.create(null);
62
- for (const fp of chunks[i]) {
95
+ for (const fp of chunks[i].files) {
63
96
  const entry = index.files.get(fp);
64
97
  if (entry) {
65
98
  workerHashes[fp] = { mtime: entry.mtime, size: entry.size, hash: entry.hash };
@@ -68,7 +101,7 @@ function parallelBuild(index, files, options = {}) {
68
101
 
69
102
  const worker = new Worker(path.join(__dirname, 'build-worker.js'), {
70
103
  workerData: {
71
- files: chunks[i],
104
+ files: chunks[i].files,
72
105
  rootDir: index.root,
73
106
  existingHashes: workerHashes,
74
107
  signal: sab,
@@ -165,4 +198,4 @@ function parallelBuild(index, files, options = {}) {
165
198
  return changed;
166
199
  }
167
200
 
168
- module.exports = { parallelBuild };
201
+ module.exports = { parallelBuild, partitionFiles };
package/core/parser.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  const fs = require('fs');
9
- const { detectLanguage, getParser, getLanguageModule, isSupported } = require('../languages');
9
+ const { detectLanguage, getParser, getLanguageAdapter, isSupported } = require('../languages');
10
10
 
11
11
  /**
12
12
  * @typedef {Object} FunctionDef
@@ -80,9 +80,9 @@ function parse(code, language) {
80
80
  }
81
81
 
82
82
  const parser = getParser(language);
83
- const langModule = getLanguageModule(language);
83
+ const adapter = getLanguageAdapter(language);
84
84
 
85
- return langModule.parse(code, parser);
85
+ return adapter.parse(code, parser);
86
86
  }
87
87
 
88
88
  /**