ucn 5.5.2 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -12
  2. package/.claude/skills/ucn/references/commands.md +21 -17
  3. package/.claude/skills/ucn/references/resolution-rules.md +4 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +4 -4
  5. package/CHANGELOG.md +474 -0
  6. package/README.md +71 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +138 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +273 -107
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +29 -65
  13. package/core/cache.js +101 -16
  14. package/core/callers.js +352 -70
  15. package/core/capture.js +331 -0
  16. package/core/change-kinds.js +888 -0
  17. package/core/check.js +1117 -139
  18. package/core/client-receivers.js +189 -0
  19. package/core/command-contracts.js +7 -6
  20. package/core/constructed-class.js +1195 -0
  21. package/core/contract-membership.js +142 -0
  22. package/core/deadcode.js +388 -85
  23. package/core/deletion.js +401 -0
  24. package/core/discovery.js +104 -9
  25. package/core/entrypoints.js +12 -6
  26. package/core/evidence-bands.js +68 -0
  27. package/core/evidence-bands.json +44 -0
  28. package/core/execute.js +172 -161
  29. package/core/hot-summary.js +241 -0
  30. package/core/index-ir.js +3 -1
  31. package/core/inprocess-clients.js +41 -15
  32. package/core/ir.js +5 -1
  33. package/core/member-signatures.js +789 -0
  34. package/core/output/analysis-ext.js +20 -10
  35. package/core/output/analysis.js +98 -60
  36. package/core/output/check.js +98 -37
  37. package/core/output/doctor.js +34 -1
  38. package/core/output/find.js +2 -1
  39. package/core/output/lines.js +63 -3
  40. package/core/output/public.js +30 -11
  41. package/core/output/refactoring.js +32 -0
  42. package/core/output/reporting.js +21 -2
  43. package/core/output/search.js +11 -2
  44. package/core/output/tracing.js +35 -7
  45. package/core/output-budget.js +124 -57
  46. package/core/parallel-build.js +3 -3
  47. package/core/project.js +106 -23
  48. package/core/provenance-type.js +12 -5
  49. package/core/public-command.js +35 -9
  50. package/core/python-modules.js +6 -0
  51. package/core/reflection.js +166 -23
  52. package/core/registry.js +623 -72
  53. package/core/reporting.js +69 -34
  54. package/core/request-helpers.js +63 -7
  55. package/core/review-items.js +75 -0
  56. package/core/search.js +89 -128
  57. package/core/shared.js +0 -45
  58. package/core/snapshot.js +350 -0
  59. package/core/source-universe.js +707 -0
  60. package/core/test-units.js +3624 -0
  61. package/core/tracing.js +150 -96
  62. package/core/verify.js +27 -11
  63. package/languages/adapter.js +8 -3
  64. package/languages/c-family.js +12 -0
  65. package/languages/csharp.js +634 -0
  66. package/languages/go.js +120 -0
  67. package/languages/index.js +422 -4
  68. package/languages/java.js +712 -0
  69. package/languages/javascript.js +1460 -2
  70. package/languages/lexical-scope.js +68 -0
  71. package/languages/python.js +1672 -1
  72. package/languages/rust.js +91 -0
  73. package/mcp/server.js +44 -110
  74. package/package.json +11 -6
@@ -0,0 +1,241 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * core/hot-summary.js - The HOT ranking of `repo`, kept per index state.
5
+ *
6
+ * Ranking the most-called functions runs an exact, pinned caller query on
7
+ * every definition able to enter the list (core/reporting.js getStats). On a
8
+ * 3,400-file project that is about 1,300 queries and ten seconds, and the
9
+ * answer is the same until a file changes: it is a function of the index and
10
+ * of the options that shape the ranking, nothing else. So it is kept:
11
+ *
12
+ * - in memory, on the index, until the index is rebuilt;
13
+ * - on disk beside the index (hot-summary.json), tagged with the index
14
+ * generation it was computed on. A loaded index reads it back only when
15
+ * its own generation is the tagged one. Every save of the index starts a
16
+ * new generation and rewrites the file under it; a rebuild drops the
17
+ * summaries first, so a changed file always ranks afresh.
18
+ *
19
+ * A summary answers only the request it was computed for: the key is every
20
+ * option that reaches the ranking (list length, production-only counting,
21
+ * both budgets, scope). No summary is derived from another, so a kept answer
22
+ * is the answer a fresh ranking gives, byte for byte. Fresh and kept answers
23
+ * leave this module through the same serialization.
24
+ *
25
+ * Two more conditions keep that true. A ranking is kept only when every
26
+ * indexed file still is what the index recorded once it has been computed:
27
+ * call records are refreshed from a file that changes under a running
28
+ * command, and such a ranking describes neither the index nor the edit. And
29
+ * a stored ranking is read only by the engine build that computed it: unlike
30
+ * a parsed fact it is a verdict of the caller engine, so an edited engine
31
+ * file in a development tree ends it.
32
+ */
33
+
34
+ const crypto = require('crypto');
35
+ const fs = require('fs');
36
+ const path = require('path');
37
+ const { version: UCN_VERSION } = require('../package.json');
38
+
39
+ const SUMMARY_FILE = 'hot-summary.json';
40
+ const SUMMARY_FORMAT = 1;
41
+ // Requests differ by scope and list length; a session asks a handful.
42
+ const MAX_SUMMARIES = 8;
43
+ // Listed functions kept on disk across all summaries. A list longer than
44
+ // this (`--hot --top=10000`) is ranked again instead of being stored.
45
+ const MAX_STORED_ITEMS = 2000;
46
+
47
+ /** The request a summary answers: every option the ranking reads. */
48
+ function keyOf({ top, productionCallsOnly, maxRefine, workBudget, file, in: inScope, exclude }) {
49
+ return JSON.stringify([
50
+ top,
51
+ !!productionCallsOnly,
52
+ Number.isFinite(maxRefine) ? maxRefine : null,
53
+ Number.isFinite(workBudget) ? workBudget : null,
54
+ file || null,
55
+ inScope || null,
56
+ Array.isArray(exclude) && exclude.length > 0 ? exclude : null,
57
+ ]);
58
+ }
59
+
60
+ let engineStampMemo = null;
61
+ /**
62
+ * The engine build: the package version and the size and modification time
63
+ * of every engine source file. Constant for an installed package.
64
+ */
65
+ function engineStamp() {
66
+ if (engineStampMemo) return engineStampMemo;
67
+ const hash = crypto.createHash('md5').update(UCN_VERSION);
68
+ const root = path.join(__dirname, '..');
69
+ for (const folder of ['core', 'core/output', 'languages']) {
70
+ let names = [];
71
+ try {
72
+ names = fs.readdirSync(path.join(root, folder)).filter(name => name.endsWith('.js')).sort();
73
+ } catch (_) { /* a folder this build does not have */ }
74
+ for (const name of names) {
75
+ let state = '?';
76
+ try {
77
+ const stat = fs.statSync(path.join(root, folder, name));
78
+ state = `${stat.size}:${stat.mtimeMs}`;
79
+ } catch (_) { /* unreadable: its own state */ }
80
+ hash.update(`\0${folder}/${name}:${state}`);
81
+ }
82
+ }
83
+ return (engineStampMemo = hash.digest('hex'));
84
+ }
85
+
86
+ /**
87
+ * Whether every indexed file still is what the index recorded: same size
88
+ * and modification time, or the same content when only the time moved (the
89
+ * rule the staleness check applies).
90
+ */
91
+ function sourceMatchesIndex(index) {
92
+ for (const [filePath, entry] of index.files) {
93
+ try {
94
+ const stat = fs.statSync(filePath);
95
+ if (entry.size !== undefined && stat.size !== entry.size) return false;
96
+ if (entry.mtime && stat.mtimeMs === entry.mtime) continue;
97
+ const content = fs.readFileSync(filePath, 'utf-8');
98
+ if (crypto.createHash('md5').update(content).digest('hex') !== entry.hash) return false;
99
+ } catch (_) { return false; }
100
+ }
101
+ return true;
102
+ }
103
+
104
+ function cacheDirOf(index, cachePath) {
105
+ if (cachePath) return path.dirname(cachePath);
106
+ if (index._cacheDir) return index._cacheDir;
107
+ return require('./cache').getProjectCacheDir(index.root);
108
+ }
109
+
110
+ /** A summary as every reader receives it: plain data, its own copy. */
111
+ function copyOf(summary) {
112
+ return JSON.parse(JSON.stringify(summary));
113
+ }
114
+
115
+ function validSummary(summary) {
116
+ return !!summary && typeof summary === 'object' && Number.isInteger(summary.top) &&
117
+ Array.isArray(summary.items) && summary.items.every(item =>
118
+ item && typeof item === 'object' && typeof item.name === 'string' &&
119
+ Number.isInteger(item.callCount));
120
+ }
121
+
122
+ /**
123
+ * Read the stored summaries of the generation in memory, once per
124
+ * generation. An index that was rebuilt, or never saved, has none.
125
+ */
126
+ function ensure(index) {
127
+ const generation = index._callsGenerationKey || null;
128
+ if (index._hotSummariesGeneration === generation && index._hotSummaries) return;
129
+ // Summaries computed since the last save stay: the state they describe
130
+ // is the one in memory.
131
+ const kept = index._hotSummaries || new Map();
132
+ index._hotSummaries = kept;
133
+ index._hotSummariesGeneration = generation;
134
+ if (!generation || index.isProvenanceFactIndex) return;
135
+ let stored;
136
+ try {
137
+ stored = JSON.parse(fs.readFileSync(path.join(cacheDirOf(index), SUMMARY_FILE), 'utf8'));
138
+ } catch (_) { return; }
139
+ if (!stored || stored.format !== SUMMARY_FORMAT || stored.engine !== engineStamp() ||
140
+ stored.generation !== generation || !Array.isArray(stored.summaries)) return;
141
+ for (const row of stored.summaries) {
142
+ if (!Array.isArray(row) || row.length !== 2 || typeof row[0] !== 'string' ||
143
+ !validSummary(row[1])) return;
144
+ }
145
+ for (const [key, summary] of stored.summaries) {
146
+ if (!kept.has(key)) kept.set(key, summary);
147
+ }
148
+ }
149
+
150
+ /** The kept answer to this request, or undefined. */
151
+ function get(index, key) {
152
+ ensure(index);
153
+ const summary = index._hotSummaries.get(key);
154
+ if (summary === undefined) return undefined;
155
+ // A caller may edit source between the surface's freshness check and
156
+ // this query, or use a live index directly. Native caller queries refresh
157
+ // those records; a kept verdict must not bypass that existing path.
158
+ if (!sourceMatchesIndex(index)) {
159
+ reset(index);
160
+ return undefined;
161
+ }
162
+ // Most recently asked last: the bound drops the oldest.
163
+ index._hotSummaries.delete(key);
164
+ index._hotSummaries.set(key, summary);
165
+ return copyOf(summary);
166
+ }
167
+
168
+ /** Keep a freshly ranked answer; returns the copy the caller hands out. */
169
+ function put(index, key, summary) {
170
+ const stored = copyOf(summary);
171
+ // Ranked while a file was being edited: answered, not kept.
172
+ if (!sourceMatchesIndex(index)) return stored;
173
+ ensure(index);
174
+ index._hotSummaries.delete(key);
175
+ index._hotSummaries.set(key, stored);
176
+ while (index._hotSummaries.size > MAX_SUMMARIES) {
177
+ index._hotSummaries.delete(index._hotSummaries.keys().next().value);
178
+ }
179
+ index.hotSummaryDirty = true;
180
+ return copyOf(stored);
181
+ }
182
+
183
+ /** Drop every summary: the index is being changed. */
184
+ function reset(index) {
185
+ index._hotSummaries = new Map();
186
+ // The generation in memory no longer describes the index, so its stored
187
+ // summaries are not read back either.
188
+ index._hotSummariesGeneration = index._callsGenerationKey || null;
189
+ // A direct indexFile/removeFileSymbols mutation leaves the calls-shard
190
+ // generation in place. It no longer identifies the state ranked here.
191
+ index._hotSummarySavedGeneration = null;
192
+ index.hotSummaryDirty = false;
193
+ }
194
+
195
+ /** Another saved state was loaded: its stored summaries are read on demand. */
196
+ function forget(index) {
197
+ index._hotSummaries = null;
198
+ index._hotSummariesGeneration = undefined;
199
+ index._hotSummarySavedGeneration = index._callsGenerationKey || null;
200
+ index.hotSummaryDirty = false;
201
+ }
202
+
203
+ /** A completed index save establishes which state a stored answer may name. */
204
+ function adoptSavedGeneration(index) {
205
+ const generation = index._callsGenerationKey || null;
206
+ index._hotSummarySavedGeneration = generation;
207
+ index._hotSummariesGeneration = generation;
208
+ }
209
+
210
+ /**
211
+ * Write the summaries under the generation in memory. An index with no
212
+ * generation has no saved state they could be read back against.
213
+ */
214
+ function save(index, cachePath) {
215
+ const generation = index._callsGenerationKey || null;
216
+ if (!generation || index._hotSummarySavedGeneration !== generation || index.isProvenanceFactIndex) return null;
217
+ // The file is replaced, so what it holds for this generation comes first.
218
+ ensure(index);
219
+ const target = path.join(cacheDirOf(index, cachePath), SUMMARY_FILE);
220
+ const summaries = [];
221
+ let items = 0;
222
+ // Newest first, within the stored-item bound.
223
+ for (const [key, summary] of [...index._hotSummaries].reverse()) {
224
+ if (items + summary.items.length > MAX_STORED_ITEMS) continue;
225
+ items += summary.items.length;
226
+ summaries.unshift([key, summary]);
227
+ }
228
+ index.hotSummaryDirty = false;
229
+ if (summaries.length === 0) {
230
+ try { fs.rmSync(target, { force: true }); } catch (_) { /* nothing stored */ }
231
+ return null;
232
+ }
233
+ fs.mkdirSync(path.dirname(target), { recursive: true });
234
+ fs.writeFileSync(target + '.tmp', JSON.stringify({
235
+ format: SUMMARY_FORMAT, engine: engineStamp(), generation, summaries,
236
+ }));
237
+ fs.renameSync(target + '.tmp', target);
238
+ return target;
239
+ }
240
+
241
+ module.exports = { SUMMARY_FILE, keyOf, engineStamp, ensure, get, put, reset, forget, adoptSavedGeneration, save };
package/core/index-ir.js CHANGED
@@ -146,6 +146,8 @@ function createFileEntryFromIR({
146
146
  ...(ir.reflectionSites?.length > 0 && {
147
147
  reflectionSites: ir.reflectionSites,
148
148
  }),
149
+ ...(ir.reflectionUses > 0 && { reflectionUses: ir.reflectionUses }),
150
+ ...(ir.evalUses > 0 && { evalUses: ir.evalUses }),
149
151
  ...(Array.isArray(ir.computedDispatchSites) && {
150
152
  computedDispatchSites: ir.computedDispatchSites,
151
153
  }),
@@ -201,7 +203,7 @@ const OPTIONAL_SYMBOL_FIELDS = Object.freeze([
201
203
  'annotationType', 'valueShape', 'friendOf', 'templateParams', 'delegateParams',
202
204
  'typedefName', 'forwardDeclaration', 'anonymousNamespace', 'specialization',
203
205
  'memberConst', 'refQualifier', 'fieldPointers', 'memberPaths', 'instanceAttribute',
204
- 'ambientAugmentation',
206
+ 'ambientAugmentation', 'requiredMember', 'protocolClass',
205
207
  ]);
206
208
 
207
209
  function materializeSymbol(fileEntry, item) {
@@ -195,9 +195,23 @@ class PythonClientResolver {
195
195
 
196
196
  /** The fixture named `name` a test in `file` requests: same module first,
197
197
  * then conftest.py files from the test's directory up to the root. */
198
- fixtureDef(file, name) {
199
- const key = `${file}\0${name}`;
198
+ fixtureDef(file, name, requester = null) {
199
+ let owner = requester?.parent;
200
+ while (owner && owner.type !== 'class_definition') owner = owner.parent;
201
+ const key = `${file}\0${name}\0${owner?.startIndex ?? ''}`;
200
202
  if (this.fixtureMemo.has(key)) return this.fixtureMemo.get(key);
203
+ this.fixtureMemo.set(key, null);
204
+ if (owner) {
205
+ const matches = namedChildren(field(owner, 'body')).map(stmt =>
206
+ stmt.type === 'decorated_definition' ? field(stmt, 'definition') : stmt)
207
+ .filter(fn => fn?.type === 'function_definition' && field(fn, 'name')?.text === name);
208
+ if (matches.length) {
209
+ const found = matches.length === 1 && this.isFixture(file, matches[0])
210
+ ? { file, fnNode: matches[0] } : null;
211
+ this.fixtureMemo.set(key, found);
212
+ return found;
213
+ }
214
+ }
201
215
  let found = this.moduleFixture(file, name);
202
216
  if (!found) {
203
217
  for (let dir = path.dirname(file); dir.startsWith(this.index.root); dir = path.dirname(dir)) {
@@ -213,7 +227,8 @@ class PythonClientResolver {
213
227
  return found;
214
228
  }
215
229
 
216
- moduleFixture(file, name) {
230
+ moduleFixture(file, name, depth = 0) {
231
+ if (depth > MAX_HOPS) return null;
217
232
  const root = this.sess.root(file);
218
233
  if (!root) return null;
219
234
  const hits = [];
@@ -222,7 +237,12 @@ class PythonClientResolver {
222
237
  if (fn?.type === 'function_definition' && field(fn, 'name')?.text === name &&
223
238
  this.isFixture(file, fn)) hits.push(fn);
224
239
  }
225
- return hits.length === 1 ? { file, fnNode: hits[0] } : null;
240
+ if (hits.length) return hits.length === 1 ? { file, fnNode: hits[0] } : null;
241
+ const entry = this.sess.entry(file);
242
+ const bindings = (entry?.importBindings || []).filter(b => (b.alias || b.name) === name && !b.deferred);
243
+ if (bindings.length !== 1 || bindings[0].kind !== 'from') return null;
244
+ const relative = entry.moduleResolved?.[bindings[0].module];
245
+ return relative ? this.moduleFixture(path.join(this.index.root, relative), bindings[0].name, depth + 1) : null;
226
246
  }
227
247
 
228
248
  /** The expression a fixture provides: its single yielded/returned value,
@@ -253,11 +273,14 @@ class PythonClientResolver {
253
273
  if (!expr || expr.type !== 'identifier' || hops > MAX_HOPS) return { file, expr };
254
274
  if (fnNode) {
255
275
  const values = boundValues(field(fnNode, 'body'), expr.text, this.bindingMemo);
256
- if (values === null) return null;
276
+ if (values === null) {
277
+ const decl = routeGraph.findDecl(this.sess, file, expr.text, expr);
278
+ return decl?.kind === 'function' ? { file, expr: decl.valueNode } : null;
279
+ }
257
280
  if (values.length === 1) return this.followBinding(file, fnNode, unwrap(values[0].node), hops + 1);
258
281
  if (values.length > 1) return null;
259
282
  if (isParameterOf(fnNode, expr.text)) {
260
- const fixture = this.fixtureDef(file, expr.text);
283
+ const fixture = this.fixtureDef(file, expr.text, fnNode);
261
284
  return fixture ? this.fixtureValue(fixture.file, fixture.fnNode, hops + 1) : null;
262
285
  }
263
286
  }
@@ -319,7 +342,7 @@ class PythonClientResolver {
319
342
  if (expr.type !== 'identifier') return null;
320
343
  const fnNode = enclosingFunctionNode(expr);
321
344
  if (fnNode && isParameterOf(fnNode, expr.text)) {
322
- const fixture = this.fixtureDef(file, expr.text);
345
+ const fixture = this.fixtureDef(file, expr.text, fnNode);
323
346
  if (!fixture) return null;
324
347
  const value = this.fixtureValue(fixture.file, fixture.fnNode, hops + 1);
325
348
  if (!value?.expr) return null;
@@ -345,13 +368,8 @@ class PythonClientResolver {
345
368
  return null;
346
369
  }
347
370
 
348
- /**
349
- * { appKey } for a request `receiver.verb(path)` at `line` of `file` made
350
- * through an in-process client, { inProcess: true } when the client is
351
- * built from an app that did not resolve, else null (not a client built
352
- * from an app).
353
- */
354
- resolve(file, line, receiver, name) {
371
+ /** Values bound to a request receiver, without assuming they are clients. */
372
+ clientValues(file, line, receiver, name) {
355
373
  const root = this.sess.root(file);
356
374
  if (!root) return null;
357
375
  const sites = requestCallNodes(root, line, receiver, name);
@@ -365,7 +383,7 @@ class PythonClientResolver {
365
383
  if (values.length > 0) {
366
384
  clients = this.bindingInEffect(values, receiverNode).map(node => ({ file, expr: unwrap(node) }));
367
385
  } else if (isParameterOf(fnNode, receiver)) {
368
- const fixture = this.fixtureDef(file, receiver);
386
+ const fixture = this.fixtureDef(file, receiver, fnNode);
369
387
  const value = fixture ? this.fixtureValue(fixture.file, fixture.fnNode, 1) : null;
370
388
  clients = value ? [value] : [];
371
389
  }
@@ -376,6 +394,14 @@ class PythonClientResolver {
376
394
  clients = this.bindingInEffect(values, receiverNode).map(node => ({ file, expr: unwrap(node) }));
377
395
  }
378
396
  }
397
+ const values = clients?.map(c => this.followBinding(c.file,
398
+ enclosingFunctionNode(c.expr), c.expr, 0));
399
+ return values?.every(Boolean) ? values : null;
400
+ }
401
+
402
+ /** The served app key, an unresolved in-process app, or null. */
403
+ resolve(file, line, receiver, name) {
404
+ const clients = this.clientValues(file, line, receiver, name);
379
405
  if (!clients || clients.length === 0 || clients.some(c => c.expr?.type !== 'call')) return null;
380
406
  const keys = clients.map(c => this.clientAppOnce(c.file, c.expr));
381
407
  if (keys.every(key => key && key === keys[0])) {
package/core/ir.js CHANGED
@@ -84,7 +84,7 @@ function normalizeSymbol(symbol, family, language, kind, owner = null) {
84
84
  'annotationType', 'valueShape', 'friendOf', 'templateParams', 'delegateParams',
85
85
  'typedefName', 'forwardDeclaration', 'anonymousNamespace', 'specialization',
86
86
  'memberConst', 'refQualifier', 'fieldPointers', 'memberPaths', 'instanceAttribute',
87
- 'ambientAugmentation',
87
+ 'ambientAugmentation', 'requiredMember', 'protocolClass',
88
88
  ];
89
89
  for (const field of passthrough) {
90
90
  if (symbol[field] !== undefined && symbol[field] !== null) {
@@ -261,6 +261,10 @@ function createFileIR({
261
261
  ...(parsed.reflectionSites?.length > 0 && {
262
262
  reflectionSites: parsed.reflectionSites.map(item => ({ ...item })),
263
263
  }),
264
+ // Uses of the reflection and code-execution vocabulary, counted in
265
+ // the tree (core/reflection.js).
266
+ ...(parsed.reflectionUses > 0 && { reflectionUses: parsed.reflectionUses }),
267
+ ...(parsed.evalUses > 0 && { evalUses: parsed.evalUses }),
264
268
  ...(Array.isArray(parsed.computedDispatchSites) && {
265
269
  computedDispatchSites: parsed.computedDispatchSites.map(item => ({ ...item })),
266
270
  }),