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
@@ -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) {
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
  }),