ucn 5.5.3 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -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 +474 -0
  6. package/README.md +69 -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 +273 -107
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +14 -0
  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/command-contracts.js +7 -6
  19. package/core/constructed-class.js +1195 -0
  20. package/core/contract-membership.js +142 -0
  21. package/core/deadcode.js +362 -84
  22. package/core/deletion.js +401 -0
  23. package/core/discovery.js +104 -9
  24. package/core/entrypoints.js +12 -6
  25. package/core/evidence-bands.js +68 -0
  26. package/core/evidence-bands.json +44 -0
  27. package/core/execute.js +172 -161
  28. package/core/hot-summary.js +241 -0
  29. package/core/index-ir.js +3 -1
  30. package/core/ir.js +5 -1
  31. package/core/member-signatures.js +789 -0
  32. package/core/output/analysis-ext.js +20 -10
  33. package/core/output/analysis.js +98 -60
  34. package/core/output/check.js +98 -37
  35. package/core/output/doctor.js +34 -1
  36. package/core/output/find.js +2 -1
  37. package/core/output/lines.js +63 -3
  38. package/core/output/public.js +29 -11
  39. package/core/output/refactoring.js +32 -0
  40. package/core/output/reporting.js +15 -2
  41. package/core/output/search.js +11 -2
  42. package/core/output/tracing.js +35 -7
  43. package/core/output-budget.js +124 -57
  44. package/core/parallel-build.js +3 -3
  45. package/core/project.js +106 -23
  46. package/core/provenance-type.js +12 -5
  47. package/core/public-command.js +35 -9
  48. package/core/python-modules.js +6 -0
  49. package/core/reflection.js +151 -23
  50. package/core/registry.js +623 -72
  51. package/core/reporting.js +69 -34
  52. package/core/review-items.js +75 -0
  53. package/core/search.js +89 -128
  54. package/core/shared.js +0 -45
  55. package/core/snapshot.js +350 -0
  56. package/core/source-universe.js +707 -0
  57. package/core/test-units.js +3624 -0
  58. package/core/tracing.js +150 -96
  59. package/core/verify.js +27 -11
  60. package/languages/adapter.js +8 -3
  61. package/languages/c-family.js +12 -0
  62. package/languages/csharp.js +634 -0
  63. package/languages/go.js +120 -0
  64. package/languages/index.js +422 -4
  65. package/languages/java.js +712 -0
  66. package/languages/javascript.js +1460 -2
  67. package/languages/lexical-scope.js +68 -0
  68. package/languages/python.js +1672 -1
  69. package/languages/rust.js +91 -0
  70. package/mcp/server.js +44 -110
  71. package/package.json +10 -5
package/cli/index.js CHANGED
@@ -30,13 +30,22 @@ const {
30
30
  formatSurfaceMessage,
31
31
  getCliFlagsForCommand,
32
32
  getCliAcceptedFlags,
33
+ getCliValueFlags,
34
+ generateCliParamReference,
35
+ commandMode,
36
+ paramDefault,
37
+ paramFact,
38
+ paramRange,
39
+ paramValues,
40
+ GLOBAL_PARAMS,
33
41
  } = require('../core/registry');
34
- const { buildPublicParams, isPublicCommand } = require('../core/public-command');
42
+ const { FlagValidationError, parseFlags, parseProcessFlags, validateFlags, processInteger } = require('./flags');
43
+ const { buildPublicParams, isPublicCommand, noOpNotes } = require('../core/public-command');
35
44
  const { execute } = require('../core/execute');
36
45
  const { describeError } = require('../core/errors');
37
- const { applyOutputBudget, MAX_OUTPUT_CHARS } = require('../core/output-budget');
46
+ const { applyOutputBudget } = require('../core/output-budget');
38
47
  const { clearAllCaches } = require('../core/cache');
39
- const { commentLines } = require('../core/output/lines');
48
+ const { commentLines, budgetLineListing } = require('../core/output/lines');
40
49
 
41
50
  // A downstream consumer such as head may finish before our write drains.
42
51
  // Handle the pipe closure without an unhandled Node error or bypassing cache
@@ -53,13 +62,6 @@ let activeCanonicalCommand = null;
53
62
  // Thrown instead of process.exit(1) so finally blocks can run (cache save).
54
63
  class CommandError extends Error { constructor() { super(); } }
55
64
 
56
- // Thrown by validateNumericFlags when a numeric flag has a bad value.
57
- // The CLI top-level catches this, prints the message, and exits 2 (JSON: 1). Interactive
58
- // mode catches it inside its REPL try/catch and continues the session.
59
- class FlagValidationError extends Error {
60
- constructor(msg) { super(msg); this.name = 'FlagValidationError'; }
61
- }
62
-
63
65
  function unknownCommandMessage(command, { interactive = false } = {}) {
64
66
  const migration = v4MigrationHint(command, 'cli');
65
67
  if (migration) return `Unknown command: ${command}. ${migration}`;
@@ -77,11 +79,9 @@ function resultExitCode(command, result) {
77
79
  if (result && result.ok === false) return 2;
78
80
  if (command !== 'check' || !result) return 0;
79
81
  if (result.trust) {
80
- // Target-less pre-commit check distinguishes advisory review from a
81
- // blocking validation failure. Export/reference review remains
82
- // machine-readable in trust.status but does not make every ordinary
83
- // body edit fail CI; incomplete accounts and signature drift do.
84
- return result.trust.status === 'BLOCKED' ? 1 : 0;
82
+ // Target-less pre-commit check: a definite break fails, something
83
+ // to look at does not, unless the caller asked for --strict.
84
+ return result.trust.passes === false ? 1 : 0;
85
85
  }
86
86
  // Symbol-mode check (verify): a mismatch or unresolved call-site tier is
87
87
  // review-required and must fail a CI gate.
@@ -89,113 +89,11 @@ function resultExitCode(command, result) {
89
89
  Number(result.unverifiedCount || 0) > 0) ? 1 : 0;
90
90
  }
91
91
 
92
- /**
93
- * Validate that a raw flag value is a positive integer. Returns the parsed
94
- * number when valid, or throws FlagValidationError. Callers pass `null`/`undefined`
95
- * raw values through unchanged (no flag → no validation).
96
- *
97
- * @param {string|null|undefined} raw - The raw string captured from the CLI/interactive token.
98
- * @param {string} flagName - The CLI flag name including dashes (e.g. "--top") for error messages.
99
- * @param {object} [opts]
100
- * @param {boolean} [opts.allowZero=false] - Whether 0 is a valid value (e.g. depth=0 may be meaningful).
101
- * @param {number} [opts.cap=10000000] - Maximum accepted value (rejects 1e100 etc).
102
- * @returns {number|undefined} The validated integer, or undefined when raw is null/undefined.
103
- */
104
- function validatePositiveInt(raw, flagName, { allowZero = false, cap = 10000000 } = {}) {
105
- if (raw == null) return undefined;
106
- const label = allowZero ? 'non-negative integer' : 'positive integer';
107
- const trimmed = String(raw).trim();
108
- if (trimmed === '') {
109
- throw new FlagValidationError(`Invalid ${flagName} value: must be a ${label} (got "${raw}")`);
110
- }
111
- // CLI integer flags use canonical base-10 spelling. Number()/parseInt()
112
- // accept exponent, decimal and hexadecimal forms (`1e1`, `2.5`, `0x10`),
113
- // which is especially dangerous for source-line identity because a
114
- // malformed pin can accidentally select a real definition.
115
- if (!/^\d+$/.test(trimmed)) {
116
- throw new FlagValidationError(`Invalid ${flagName} value: must be a ${label} (got "${raw}")`);
117
- }
118
- const n = Number(trimmed);
119
- if (!isFinite(n) || isNaN(n)) {
120
- throw new FlagValidationError(`Invalid ${flagName} value: must be a ${label} (got "${raw}")`);
121
- }
122
- if (!Number.isInteger(n)) {
123
- throw new FlagValidationError(`Invalid ${flagName} value: must be a ${label} (got ${n})`);
124
- }
125
- if (allowZero) {
126
- if (n < 0) {
127
- throw new FlagValidationError(`Invalid ${flagName} value: must be a ${label} (got ${n})`);
128
- }
129
- } else if (n <= 0) {
130
- throw new FlagValidationError(`Invalid ${flagName} value: must be a ${label} (got ${n})`);
131
- }
132
- if (n > cap) {
133
- throw new FlagValidationError(`Invalid ${flagName} value: ${n} exceeds maximum (${cap})`);
134
- }
135
- return n;
136
- }
137
-
138
- /**
139
- * Validate all numeric flags on a parsed flags object. Looks at the *Raw
140
- * companion strings preserved by parseFlags so we catch user-supplied bad
141
- * values regardless of whether the parsed numeric form happened to be falsy.
142
- * Mutates `flags` to hold the validated numeric values.
143
- *
144
- * Throws FlagValidationError on the first invalid flag.
145
- */
146
- function validateNumericFlags(flags) {
147
- // --top: positive integer, no zero. Used by show/find/repo/etc.
148
- if (flags.topRaw != null) {
149
- flags.top = validatePositiveInt(flags.topRaw, '--top');
150
- }
151
- // --limit: positive integer, no zero. Reject "0 = no limit" silent coercion.
152
- if (flags.limitRaw != null) {
153
- flags.limit = validatePositiveInt(flags.limitRaw, '--limit');
154
- }
155
- // --max-files: positive integer, no zero.
156
- if (flags.maxFilesRaw != null) {
157
- flags.maxFiles = validatePositiveInt(flags.maxFilesRaw, '--max-files');
158
- }
159
- if (flags.lineRaw != null) {
160
- flags.line = validatePositiveInt(flags.lineRaw, '--line');
161
- }
162
- // --max-lines: positive integer, no zero. Used by source.
163
- if (flags.maxLinesRaw != null) {
164
- flags.maxLines = validatePositiveInt(flags.maxLinesRaw, '--max-lines');
165
- }
166
- // --depth: non-negative integer (0 is meaningful: "this symbol only").
167
- if (flags.depthRaw != null) {
168
- flags.depth = validatePositiveInt(flags.depthRaw, '--depth', { allowZero: true });
169
- }
170
- // --context: non-negative integer (0 = no surrounding lines).
171
- if (flags.contextRaw != null) {
172
- flags.context = validatePositiveInt(flags.contextRaw, '--context', { allowZero: true });
173
- }
174
- // --workers: non-negative integer (0 disables parallel build).
175
- if (flags.workersRaw != null) {
176
- flags.workers = validatePositiveInt(flags.workersRaw, '--workers', { allowZero: true });
177
- }
178
- if (flags.maxCharsRaw != null) {
179
- flags.maxChars = validatePositiveInt(flags.maxCharsRaw, '--max-chars', {
180
- cap: MAX_OUTPUT_CHARS,
181
- });
182
- }
183
- // --min-confidence: number in [0,1] (ordinal evidence weight). Anything
184
- // else used to coerce to 0 silently — "abc" behaved like no filter.
185
- if (flags.minConfidenceRaw != null) {
186
- const value = Number(flags.minConfidenceRaw);
187
- if (flags.minConfidenceRaw === '' || !Number.isFinite(value) ||
188
- value < 0 || value > 1) {
189
- throw new FlagValidationError(
190
- `Invalid --min-confidence value: "${flags.minConfidenceRaw}" — must be a number between 0 and 1.`);
191
- }
192
- flags.minConfidence = value;
193
- }
194
- }
195
-
196
92
  /** Emit the one CLI error contract, including failures before main dispatch. */
197
93
  function emitCliError(msg, command = activeCanonicalCommand, { internal = false } = {}) {
198
- const wantsJson = process.argv.includes('--json');
94
+ const argv = process.argv.slice(2);
95
+ const end = argv.indexOf('--');
96
+ const wantsJson = parseProcessFlags(end === -1 ? argv : argv.slice(0, end)).json;
199
97
  const error = typeof msg === 'string' ? msg : String(msg);
200
98
  if (wantsJson) {
201
99
  const canonical = command && isPublicCommand(command)
@@ -254,9 +152,17 @@ function emitCaughtError(e) {
254
152
  // ============================================================================
255
153
 
256
154
  const rawArgs = process.argv.slice(2);
155
+ const processArgs = rawArgs.includes('--') ? rawArgs.slice(0, rawArgs.indexOf('--')) : rawArgs;
156
+ const processFlags = parseProcessFlags(processArgs);
157
+ try {
158
+ validateFlags(processFlags);
159
+ } catch (e) {
160
+ emitCliError(e.message);
161
+ process.exit(processFlags.json ? 1 : 2);
162
+ }
257
163
 
258
164
  // MCP server mode — launch server and skip CLI
259
- if (rawArgs.includes('--mcp')) {
165
+ if (processFlags.mcp) {
260
166
  require('../mcp/server.js');
261
167
  } else {
262
168
  // Support -- to separate flags from positional arguments
@@ -264,156 +170,28 @@ const doubleDashIdx = rawArgs.indexOf('--');
264
170
  const args = doubleDashIdx === -1 ? rawArgs : rawArgs.slice(0, doubleDashIdx);
265
171
  const argsAfterDoubleDash = doubleDashIdx === -1 ? [] : rawArgs.slice(doubleDashIdx + 1);
266
172
 
267
- // Parse flags
268
- /**
269
- * Parse flags from an array of tokens. Supports both --flag=value and --flag value forms.
270
- * Shared between global CLI mode and interactive mode.
271
- */
272
- function parseFlags(tokens) {
273
- function getValueFlag(flagName) {
274
- const eqForm = tokens.find(a => a.startsWith(flagName + '='));
275
- if (eqForm) return eqForm.split('=').slice(1).join('=');
276
- const idx = tokens.indexOf(flagName);
277
- if (idx !== -1 && idx + 1 < tokens.length && !tokens[idx + 1].startsWith('-')) {
278
- return tokens[idx + 1];
279
- }
280
- return null;
281
- }
282
- function parseExclude() {
283
- const result = [];
284
- for (const a of tokens) {
285
- if (a.startsWith('--exclude=') || a.startsWith('--not=')) {
286
- result.push(...a.split('=').slice(1).join('=').split(','));
287
- }
288
- }
289
- for (const flag of ['--exclude', '--not']) {
290
- for (let i = 0; i < tokens.length; i++) {
291
- if (tokens[i] === flag && i + 1 < tokens.length && !tokens[i + 1].startsWith('-')) {
292
- result.push(...tokens[i + 1].split(','));
293
- }
294
- }
295
- }
296
- return result;
297
- }
298
- return {
299
- file: getValueFlag('--file'),
300
- exclude: parseExclude(),
301
- in: getValueFlag('--in'),
302
- includeTests: tokens.includes('--include-tests') ? true : undefined,
303
- excludeTests: tokens.includes('--exclude-tests') ? true : undefined,
304
- includeExported: tokens.includes('--include-exported') || undefined,
305
- includeDecorated: tokens.includes('--include-decorated') || undefined,
306
- expandUnverified: tokens.includes('--expand-unverified') || undefined,
307
- includeMethods: tokens.some(a => a === '--include-methods=false' || a === '--no-include-methods') ? false : tokens.some(a => a === '--include-methods' || (a.startsWith('--include-methods=') && a !== '--include-methods=false')) ? true : undefined,
308
- detailed: tokens.includes('--detailed') || undefined,
309
- topLevel: tokens.includes('--top-level') || undefined,
310
- all: tokens.includes('--all') || undefined,
311
- exact: tokens.includes('--exact') || undefined,
312
- callsOnly: tokens.includes('--calls-only') || undefined,
313
- codeOnly: tokens.includes('--code-only') || undefined,
314
- caseSensitive: tokens.includes('--case-sensitive') || undefined,
315
- withTypes: tokens.includes('--with-types') || undefined,
316
- withSource: tokens.includes('--with-source') || undefined,
317
- cycles: tokens.includes('--cycles') || undefined,
318
- depth: getValueFlag('--depth'),
319
- depthRaw: getValueFlag('--depth'),
320
- // `top` is the parsed numeric value (NaN/0 default → falsy). `topRaw`
321
- // preserves the original string so downstream validators can produce
322
- // helpful errors for "abc"/"-1"/"0" instead of silently defaulting.
323
- top: parseInt(getValueFlag('--top') || '0'),
324
- topRaw: getValueFlag('--top'),
325
- context: parseInt(getValueFlag('--context') || '0'),
326
- contextRaw: getValueFlag('--context'),
327
- direction: getValueFlag('--direction'),
328
- to: getValueFlag('--to'),
329
- sections: getValueFlag('--sections'),
330
- range: getValueFlag('--range'),
331
- addParam: getValueFlag('--add-param'),
332
- removeParam: getValueFlag('--remove-param'),
333
- renameTo: getValueFlag('--rename-to'),
334
- defaultValue: getValueFlag('--default-value') ?? getValueFlag('--default'),
335
- base: getValueFlag('--base'),
336
- staged: tokens.includes('--staged') || undefined,
337
- deep: tokens.includes('--deep') || undefined,
338
- compact: tokens.includes('--no-compact')
339
- ? false
340
- : (tokens.includes('--compact') ? true : undefined),
341
- maxLines: getValueFlag('--max-lines') || null,
342
- maxLinesRaw: getValueFlag('--max-lines'),
343
- regex: tokens.includes('--regex')
344
- ? true
345
- : (tokens.includes('--no-regex') ? false : undefined),
346
- functions: tokens.includes('--functions') || undefined,
347
- hot: tokens.includes('--hot') || undefined,
348
- diverse: tokens.includes('--diverse') || undefined,
349
- raw: tokens.includes('--raw') || undefined,
350
- lines: tokens.includes('--lines') || undefined,
351
- git: tokens.includes('--git') || undefined,
352
- className: getValueFlag('--class-name'),
353
- // Explicit line pin (fix #249: our own disambiguation notes advertise
354
- // line= but no surface accepted it).
355
- line: parseInt(getValueFlag('--line') || '0', 10) || undefined,
356
- lineRaw: getValueFlag('--line'),
357
- limit: parseInt(getValueFlag('--limit') || '0') || undefined,
358
- limitRaw: getValueFlag('--limit'),
359
- maxFiles: parseInt(getValueFlag('--max-files') || '0') || undefined,
360
- maxFilesRaw: getValueFlag('--max-files'),
361
- maxChars: parseInt(getValueFlag('--max-chars') || '0') || undefined,
362
- maxCharsRaw: getValueFlag('--max-chars'),
363
- // Structural search flags
364
- type: getValueFlag('--type'),
365
- param: getValueFlag('--param'),
366
- receiver: getValueFlag('--receiver'),
367
- returns: getValueFlag('--returns'),
368
- decorator: getValueFlag('--decorator'),
369
- exported: tokens.includes('--exported') || undefined,
370
- unused: tokens.includes('--unused') || undefined,
371
- showConfidence: (tokens.includes('--hide-confidence') || tokens.includes('--no-confidence')) ? false
372
- : tokens.includes('--show-confidence') ? true : undefined,
373
- minConfidence: 0,
374
- minConfidenceRaw: getValueFlag('--min-confidence'),
375
- unreachableOnly: tokens.includes('--unreachable-only') || undefined,
376
- framework: getValueFlag('--framework'),
377
- // endpoints command flags
378
- bridge: tokens.includes('--bridge') || undefined,
379
- serverOnly: tokens.includes('--server-only') || undefined,
380
- clientOnly: tokens.includes('--client-only') || undefined,
381
- unmatched: tokens.includes('--unmatched') || undefined,
382
- method: getValueFlag('--method'),
383
- prefix: getValueFlag('--prefix'),
384
- hideUncertain: tokens.includes('--hide-uncertain') || tokens.includes('--no-uncertain') || undefined,
385
- stack: getValueFlag('--stack'),
386
- workersRaw: getValueFlag('--workers'),
387
- includeBundled: tokens.includes('--include-bundled') || undefined,
388
- workers: (() => {
389
- const v = getValueFlag('--workers');
390
- if (v === null) return undefined;
391
- const n = parseInt(v, 10);
392
- return isNaN(n) ? undefined : n;
393
- })(),
394
- };
395
- }
396
-
397
173
  // Parse shared flags from CLI args, then add global-only flags
174
+ // Parameters come from the registry's table (cli/flags.js); the flags of the
175
+ // CLI process itself are read here.
398
176
  const flags = parseFlags(args);
399
- flags.json = args.includes('--json');
400
- flags.quiet = !args.includes('--verbose') && !args.includes('--no-quiet');
401
- flags.cache = !args.includes('--no-cache') && !flags.includeBundled;
402
- flags.clearCache = args.includes('--clear-cache');
403
- flags.interactive = args.includes('--interactive') || args.includes('-i');
404
- flags.followSymlinks = !args.includes('--no-follow-symlinks');
177
+ flags.json = processFlags.json ?? false;
178
+ flags.quiet = processFlags.quiet ?? true;
179
+ flags.cache = (processFlags.cache ?? true) && !flags.includeBundled;
180
+ flags.clearCache = processFlags.clearCache ?? false;
181
+ flags.interactive = processFlags.interactive ?? false;
182
+ flags.followSymlinks = flags.followSymlinks ?? paramDefault('followSymlinks');
405
183
 
406
184
  // Known flags for validation
407
185
  const knownFlags = getCliAcceptedFlags();
408
186
 
409
187
  // Handle help flag
410
- if (args.includes('--help') || args.includes('-h')) {
188
+ if (processFlags.help) {
411
189
  printUsage();
412
190
  process.exit(0);
413
191
  }
414
192
 
415
193
  // Handle version flag — read from package.json (single source of truth, shared with MCP serverInfo)
416
- if (args.includes('--version') || args.includes('-v')) {
194
+ if (processFlags.version) {
417
195
  console.log(require('../package.json').version);
418
196
  process.exit(0);
419
197
  }
@@ -433,11 +211,11 @@ if (unknownFlags.length > 0) {
433
211
  process.exit(flags.json ? 1 : 2);
434
212
  }
435
213
 
436
- // Validate numeric flag values up front so bad input fails before we build
437
- // any indexes. Applies to --top, --limit, --max-files, --max-lines, --depth,
438
- // --context, --workers. Throws FlagValidationError with a helpful message.
214
+ // Refuse a flag value the parameter table does not take before any index is
215
+ // built: a number outside its bounds, a flag written without its value.
439
216
  try {
440
- validateNumericFlags(flags);
217
+ validateFlags(flags);
218
+ flags.workers = processInteger(args, '--workers');
441
219
  } catch (e) {
442
220
  if (e instanceof FlagValidationError) {
443
221
  emitCliError(e.message);
@@ -447,20 +225,12 @@ try {
447
225
  }
448
226
 
449
227
  // Value flags that consume the next token (space form: --flag value)
450
- const VALUE_FLAGS = new Set([
451
- '--file', '--depth', '--top', '--context', '--direction', '--to', '--sections', '--range',
452
- '--add-param', '--remove-param', '--rename-to', '--default', '--default-value',
453
- '--base', '--exclude', '--not', '--in', '--max-lines', '--class-name', '--line',
454
- '--type', '--param', '--receiver', '--returns', '--decorator',
455
- '--limit', '--max-files', '--max-chars', '--min-confidence', '--stack', '--framework',
456
- '--workers', '--method', '--prefix'
457
- ]);
228
+ const VALUE_FLAGS = getCliValueFlags();
458
229
 
459
230
  // Remove flags from args, then add args after -- (which are all positional)
460
231
  const positionalArgs = [
461
232
  ...args.filter((a, idx) =>
462
- !a.startsWith('--') &&
463
- a !== '-i' &&
233
+ !a.startsWith('-') &&
464
234
  !(idx > 0 && VALUE_FLAGS.has(args[idx - 1]) && !args[idx - 1].includes('='))
465
235
  ),
466
236
  ...argsAfterDoubleDash
@@ -485,18 +255,37 @@ function requireArg(arg, usage) {
485
255
  }
486
256
  }
487
257
 
258
+ /** Exact raw source and mandatory note bytes, shared by budgets and emitters. */
259
+ function rawCliOutput(text, note, interactive = false) {
260
+ const body = String(text);
261
+ return {
262
+ source: body + (interactive || !body.endsWith('\n') ? '\n' : ''),
263
+ note: note ? commentLines(formatSurfaceMessage(note, 'cli')).join('\n') + '\n' : '',
264
+ };
265
+ }
266
+
488
267
  function formatCliText(command, result, params, execution, displayFlags) {
489
268
  const text = output.formatPublicText(command, result, params, {
490
269
  ...execution,
491
270
  surface: 'cli',
492
271
  });
493
- // --lines / --raw are pipe surfaces: records are compact, a truncated
494
- // function body is worse than a long one, and an empty answer must stay
495
- // empty (grep prints nothing and exits 1). An explicit --max-chars acts
496
- // as a fail-before-output guard, never a lossy source/record truncation.
272
+ // --lines / --raw are pipe surfaces with no default budget, and an empty
273
+ // answer must stay empty (grep prints nothing and exits 1). Under an
274
+ // explicit --max-chars a line listing prints the rows that fit and one
275
+ // line counting the rest, as `| head` would; a record is never cut. Raw
276
+ // source is never cut either, since a truncated function body is worse
277
+ // than a long one: there the budget is a fail-before-output guard.
497
278
  if (params?.lines || params?.raw) {
498
- if (displayFlags?.maxChars && text.length > displayFlags.maxChars) {
499
- fail(`Output exceeds --max-chars=${displayFlags.maxChars}; shell output cannot be truncated. Narrow the query, use --limit/--max-lines, or omit --max-chars.`);
279
+ const rawOutput = params.raw ? rawCliOutput(text, execution?.note, displayFlags?.interactive) : null;
280
+ const emittedChars = params.lines
281
+ ? (text.split('\n').filter(Boolean).join('\n').length + (text ? 1 : 0))
282
+ : rawOutput.source.length + rawOutput.note.length;
283
+ if (displayFlags?.maxChars && emittedChars > displayFlags.maxChars) {
284
+ const listing = params.lines ? budgetLineListing(text, displayFlags.maxChars - 1, 'cli') : null;
285
+ if (listing) return listing.text;
286
+ fail(params.lines
287
+ ? `Output exceeds --max-chars=${displayFlags.maxChars} and not one row fits beside the accounting lines. Raise --max-chars or omit it.`
288
+ : `Output exceeds --max-chars=${displayFlags.maxChars}; shell output cannot be truncated. Narrow the query, use --limit/--max-lines, or omit --max-chars.`);
500
289
  }
501
290
  return text;
502
291
  }
@@ -507,6 +296,7 @@ function formatCliText(command, result, params, execution, displayFlags) {
507
296
  surface: 'cli',
508
297
  params,
509
298
  trailingChars: 1, // emitCliText appends the terminal newline.
299
+ peerText: output.formatPublicText(command, result, params, { ...execution, surface: 'mcp' }),
510
300
  }).text;
511
301
  }
512
302
 
@@ -530,11 +320,11 @@ function emitCliText(text, params, json, note) {
530
320
  return;
531
321
  }
532
322
  if (!json && params?.raw) {
533
- const body = String(text);
534
- process.stdout.write(body.endsWith('\n') ? body : body + '\n');
323
+ const rawOutput = rawCliOutput(text, note);
324
+ process.stdout.write(rawOutput.source);
535
325
  // Code lines are never reinterpreted (a Python comment starts with
536
326
  // "# " too), so the note travels on its own channel.
537
- if (note) process.stderr.write(commentLines(formatSurfaceMessage(note, 'cli')).join('\n') + '\n');
327
+ if (rawOutput.note) process.stderr.write(rawOutput.note);
538
328
  return;
539
329
  }
540
330
  console.log(text);
@@ -638,20 +428,14 @@ function main() {
638
428
  * Tiered-output contract notes shared by one-shot and interactive mode.
639
429
  */
640
430
  function printTieredNoOpNotes(canonical, flags, print) {
641
- if (!['show', 'impact', 'trace', 'tests', 'check'].includes(canonical)) return;
642
- if (['impact', 'trace', 'tests', 'check'].includes(canonical) && flags.includeMethods) {
643
- print(`Note: --include-methods has no effect on '${toCliName(canonical)}' — method calls are always tiered by receiver evidence.`);
644
- }
645
- if (canonical === 'show' && flags.includeMethods) {
646
- print(`Note: --include-methods on '${toCliName(canonical)}' affects only method-callee display for standalone-function targets — caller tiers are always evidence-based, and method targets analyze method calls by default.`);
647
- }
431
+ for (const note of noOpNotes(canonical, flags)) print(`Note: ${formatSurfaceMessage(note, 'cli')}`);
648
432
  }
649
433
 
434
+ // Keys of a parsed flag object that no command's applicability decides: the
435
+ // CLI process flags and the parameters every command accepts.
650
436
  const GLOBAL_FLAG_KEYS = new Set([
651
- 'json', 'quiet', 'cache', 'clearCache', 'followSymlinks', 'includeBundled', 'maxFiles',
652
- 'verbose', 'interactive', '_fileFromFileMode', 'topRaw',
653
- 'limitRaw', 'maxFilesRaw', 'maxLinesRaw', 'depthRaw', 'contextRaw',
654
- 'workers', 'workersRaw', 'lineRaw', 'maxChars', 'maxCharsRaw', 'minConfidenceRaw',
437
+ 'json', 'quiet', 'cache', 'clearCache', 'verbose', 'interactive', 'workers',
438
+ '_fileFromFileMode', ...GLOBAL_PARAMS,
655
439
  ]);
656
440
 
657
441
  /** Apply one command/flag policy across project, file, glob, and REPL modes. */
@@ -715,7 +499,7 @@ function runFileCommand(filePath, command, arg) {
715
499
  tests: 'tests <name>', plan: 'plan <name>',
716
500
  };
717
501
  // Structural search doesn't require term
718
- const isStructural = flags.type || flags.param || flags.receiver || flags.returns || flags.decorator || flags.exported || flags.unused;
502
+ const isStructural = commandMode('search', flags) === 'structural';
719
503
  const hasFlagTarget = canonical === 'source' && flags.range;
720
504
  if (needsArg[canonical] && !(canonical === 'search' && isStructural) && !hasFlagTarget) {
721
505
  requireArg(arg, `Usage: ucn <file> ${needsArg[canonical]}`);
@@ -854,6 +638,10 @@ function runProjectCommand(rootDir, command, arg) {
854
638
  if (flags.cache && index.usageCacheDirty) {
855
639
  try { index.saveUsageCache(); } catch (e) { /* best-effort */ }
856
640
  }
641
+ // A HOT ranking computed by this command, for the next one.
642
+ if (flags.cache && index.hotSummaryDirty) {
643
+ try { index.saveHotSummaries(); } catch (e) { /* best-effort */ }
644
+ }
857
645
  }
858
646
  }
859
647
 
@@ -884,6 +672,7 @@ function runGlobCommand(pattern, command, arg) {
884
672
  index.discoveryIssues.push(...discoveryIssues.map(issue => ({
885
673
  ...issue, relativePath: path.relative(index.root, issue.path), path: undefined,
886
674
  })));
675
+ index._sourceUniverse = null; // classify with the glob walk's facts included
887
676
 
888
677
  if (!isPublicCommand(canonical)) {
889
678
  fail(unknownCommandMessage(command));
@@ -916,6 +705,13 @@ function printUsage() {
916
705
  const flags = getCliFlagsForCommand(command);
917
706
  return ` ${command.padEnd(14)} ${flags.join(' ')}`;
918
707
  }).join('\n');
708
+ // Value lists, defaults and bounds are the registry's parameter table.
709
+ const values = (param, command, separator = '|') => paramValues(param, command).join(separator);
710
+ const budget = {
711
+ max: paramRange('maxChars').max,
712
+ targeted: paramDefault('maxChars', 'show'),
713
+ broad: paramDefault('maxChars', 'repo'),
714
+ };
919
715
  console.log(`UCN - Universal Code Navigator
920
716
 
921
717
  Supported: JavaScript/TypeScript, Python, Go, Rust, Java, C, C++, C#, HTML
@@ -927,12 +723,12 @@ Usage:
927
723
 
928
724
  Commands:
929
725
  repo Repository overview
930
- --sections=summary,files,stats,health Select repository sections
726
+ --sections=${values('sections', 'repo', ',')} Select repository sections
931
727
  --deep Include deep readiness evidence
932
728
  show <symbol> Symbol summary and relationships
933
- --sections=summary,callers,callees,source,dependencies,tests,types,example,related
729
+ --sections=${values('sections', 'show', ',')}
934
730
  find <name> Definitions; --type=type, --with-source
935
- text: top 5 in detail + a count of the rest; --json: up to 500;
731
+ text: top ${paramFact('limit', 'findDetail')} in detail + a count of the rest; --json: up to ${paramDefault('limit', 'find')};
936
732
  --limit=N raises both
937
733
  usages <name> Calls, imports, definitions, references (test files
938
734
  included; --exclude-tests hides them with a count)
@@ -940,11 +736,11 @@ Commands:
940
736
  literal text by default; --regex enables regular-expression syntax
941
737
  source <symbol|file:range> Exact function, class, or line extraction
942
738
  trace <symbol> Call graph
943
- --direction=callees|callers Downstream or upstream (default: callees)
944
- --to=entrypoints Follow callers toward entry points
739
+ --direction=${values('direction', 'trace')} Downstream or upstream (default: ${paramDefault('direction', 'trace')})
740
+ --to=${values('to', 'trace')} Follow callers toward entry points
945
741
  impact [symbol] Symbol impact; without symbol, Git-diff impact
946
742
  tests <symbol> Direct tests; --depth=N adds transitive impact
947
- deps <file> File graph; --direction=imports|importers|both
743
+ deps <file> File graph; --direction=${values('direction', 'deps')}
948
744
  --detailed Include import declarations
949
745
  deps --cycles Report circular dependencies (no file target)
950
746
  api [file] Project or file public API (exact files include tests;
@@ -961,22 +757,27 @@ Common flags:
961
757
  --file=PATH --exclude=a,b --in=PATH --depth=N --top=N --limit=N
962
758
  --all --compact --no-compact --json --include-tests --class-name=X --line=N
963
759
  --range=N-M (source <file> --range=N-M, or --file=PATH)
964
- --base=REF --staged --no-cache --clear-cache [--all] --max-files=N --workers=N
965
- --max-chars=N (text characters, not UTF-8 bytes; default 10K targeted / 3K broad, ceiling 100K)
760
+ --base=REF --staged --strict --no-cache --clear-cache [--all] --max-files=N --workers=N
761
+ --max-chars=N (text characters, not UTF-8 bytes; default ${budget.targeted} targeted / ${budget.broad} broad, ceiling ${budget.max})
966
762
  JSON is uncapped; meta.maxChars is "not-applied", even with an explicit budget.
967
763
  --lines find/usages/search/show/impact: grep -n shape, one path:line:text
968
764
  record per output line; usages combines occurrences on each source line
969
765
  (tags after a tab: # unverified: <reason>, # import,
970
766
  # callee); accounting and notes go to stderr as "# " lines; exit 1
971
767
  when nothing matched; exit 2 on errors. No default result cap.
972
- show defaults to callers; --sections=callers,callees selects bands.
768
+ Under an explicit --max-chars the rows that fit are printed whole
769
+ and one "# truncated: X of Y rows omitted" line counts the rest.
770
+ show defaults to ${paramDefault('sections', 'show', 'lines').join(',')}; --sections=${paramValues('sections', 'show', 'lines').join(',')} selects bands.
973
771
  --raw source: full code, no header or gutter (including large classes).
974
- Shell modes fail before output if an explicit --max-chars is exceeded.
772
+ Never cut: an explicit --max-chars it exceeds fails before output.
975
773
  Cache: per-user by default; set UCN_CACHE_DIR to override the cache root.
976
774
 
977
775
  Accepted flags by command:
978
776
  ${perCommandFlags}
979
777
 
778
+ Values, bounds and defaults:
779
+ ${generateCliParamReference()}
780
+
980
781
  Global/build/output flags:
981
782
  --help -h --version -v --mcp --json --verbose --no-quiet --quiet
982
783
  --interactive -i --no-cache --clear-cache --no-follow-symlinks --include-bundled
@@ -1073,12 +874,12 @@ function runInteractive(rootDir) {
1073
874
  if (input === 'help') {
1074
875
  console.log(`
1075
876
  Commands:
1076
- repo Repository overview (--sections=files,stats,health)
877
+ repo Repository overview (--sections=${paramValues('sections', 'repo').join(',')})
1077
878
  show <name> Symbol summary + relationships (--sections=...)
1078
- find <name> Find definitions (--type=type, --with-source; top 5 shown, --limit=N)
879
+ find <name> Find definitions (--type=type, --with-source; top ${paramFact('limit', 'findDetail')} shown, --limit=N)
1079
880
  usages <name> All usages grouped by type
1080
881
  source <target> Extract a symbol or file:line-range
1081
- trace <name> Call tree (--direction=callees|callers, --to=entrypoints)
882
+ trace <name> Call tree (--direction=${paramValues('direction', 'trace').join('|')}, --to=${paramValues('to', 'trace').join('|')})
1082
883
  impact [name] Symbol impact, or Git diff impact without a name
1083
884
  tests <name> Direct tests; --depth=N includes transitive impact
1084
885
  deps <file> File graph (--direction=, --depth=, --detailed, --cycles)
@@ -1088,7 +889,7 @@ Commands:
1088
889
  entrypoints Detect runtime, framework, task, and test entry points
1089
890
  endpoints List server/client HTTP endpoints (--bridge to match)
1090
891
  check [name] Symbol signature check, or pre-commit check without a name
1091
- plan <name> Preview refactoring (--add-param=, --remove-param=, --rename-to=, --default-value=)
892
+ plan <name> Preview refactoring (--add-param=, --remove-param=, --rename-to=, --default-value=, --delete)
1092
893
  stacktrace <text> Parse a stack trace
1093
894
  api Show public symbols
1094
895
  audit-async Find likely missing-await calls (JS/TS/Python/C#)
@@ -1113,13 +914,13 @@ Flags can be added per-command: show myFunc --sections=source,callers
1113
914
  const tokens = input.split(/\s+/);
1114
915
  const command = tokens[0];
1115
916
  // Flags that take a space-separated value (--flag value)
1116
- const valueFlagNames = new Set(['--file', '--in', '--base', '--add-param', '--remove-param', '--rename-to', '--default', '--depth', '--top', '--context', '--max-lines', '--direction', '--to', '--sections', '--range', '--exclude', '--not', '--stack', '--type', '--param', '--receiver', '--returns', '--decorator', '--limit', '--max-files', '--max-chars', '--min-confidence', '--class-name', '--line', '--framework', '--method', '--prefix']);
917
+ const valueFlagNames = VALUE_FLAGS;
1117
918
  const flagTokens = [];
1118
919
  const argTokens = [];
1119
920
  const skipNext = new Set();
1120
921
  for (let i = 1; i < tokens.length; i++) {
1121
922
  if (skipNext.has(i)) { continue; }
1122
- if (tokens[i].startsWith('--')) {
923
+ if (tokens[i].startsWith('-')) {
1123
924
  flagTokens.push(tokens[i]);
1124
925
  // If it's a value-flag without = and next token exists and isn't a flag, consume it too
1125
926
  if (valueFlagNames.has(tokens[i]) && !tokens[i].includes('=') && i + 1 < tokens.length && !tokens[i + 1].startsWith('--')) {
@@ -1135,24 +936,28 @@ Flags can be added per-command: show myFunc --sections=source,callers
1135
936
  // Unknown flags error instead of folding their value into the symbol
1136
937
  // name. Same vocabulary as one-shot mode.
1137
938
  const unknown = flagTokens.filter(t =>
1138
- t.startsWith('--') && !knownFlags.has(t.split('=')[0]));
939
+ t.startsWith('-') && !knownFlags.has(t.split('=')[0]));
1139
940
  if (unknown.length > 0) {
1140
941
  console.log(`Unknown flag(s): ${unknown.join(', ')}`);
1141
942
  rl.prompt();
1142
943
  return;
1143
944
  }
1144
945
  const iflags = parseFlags(flagTokens);
1145
- // parseFlags never extracts --json (it is a global-argv flag), so
1146
- // check the raw tokens.
1147
- if (flagTokens.includes('--json')) {
1148
- console.log('Note: interactive mode prints text output — run `ucn . <command> --json` one-shot for JSON.');
1149
- }
946
+ const iprocess = parseProcessFlags(flagTokens);
1150
947
 
1151
948
  try {
1152
- // Validate numeric flags (--top, --limit, etc) — same rules as
1153
- // global CLI mode. MED-2/MED-3/MED-5: bad values are rejected with
1154
- // a helpful message instead of being silently coerced.
1155
- validateNumericFlags(iflags);
949
+ // The same value rules as one-shot mode: a bad value is refused
950
+ // with a message, never silently coerced.
951
+ validateFlags(iflags);
952
+ validateFlags(iprocess);
953
+ if (iprocess.json) {
954
+ console.log('Note: interactive mode prints text output — run `ucn . <command> --json` one-shot for JSON.');
955
+ }
956
+ const startupOnly = Object.keys(iprocess).filter(key => key !== 'json');
957
+ if (startupOnly.length > 0 || flagTokens.some(token => token === '--workers' || token.startsWith('--workers='))) {
958
+ processInteger(flagTokens, '--workers');
959
+ console.log('Note: CLI process controls apply when starting UCN; they do not change an active interactive session.');
960
+ }
1156
961
  // The REPL is an edit/query loop, not a frozen snapshot. Keep the
1157
962
  // symbol table and call cache on the same generation by checking
1158
963
  // source staleness before every command. getCachedCalls already
@@ -1184,7 +989,7 @@ Flags can be added per-command: show myFunc --sections=source,callers
1184
989
  } catch (e) {
1185
990
  if (e instanceof FlagValidationError) {
1186
991
  console.log(e.message);
1187
- } else {
992
+ } else if (!(e instanceof CommandError)) {
1188
993
  const described = describeError(e);
1189
994
  console.error(described.internalError ? described.error : `Error: ${described.error}`);
1190
995
  }
@@ -1212,13 +1017,21 @@ function executeInteractiveCommand(index, command, arg, iflags = {}) {
1212
1017
  : formatSurfaceMessage(publicExecution.error, 'cli'));
1213
1018
  return;
1214
1019
  }
1215
- console.log(formatCliText(
1020
+ const text = formatCliText(
1216
1021
  command,
1217
1022
  publicExecution.result,
1218
1023
  publicParams,
1219
1024
  publicExecution,
1220
- iflags,
1221
- ));
1025
+ { ...iflags, interactive: true },
1026
+ );
1027
+ if (publicParams.raw) {
1028
+ // Keep the REPL's existing source/newline and prompt layout; the
1029
+ // mandatory disclosure follows it in the same text stream.
1030
+ const rawOutput = rawCliOutput(text, publicExecution.note, true);
1031
+ process.stdout.write(rawOutput.source + rawOutput.note);
1032
+ } else {
1033
+ console.log(text);
1034
+ }
1222
1035
  }
1223
1036
 
1224
1037
  // ============================================================================