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,350 @@
1
+ /**
2
+ * core/snapshot.js - Which tree does a diff's NEW side live in?
3
+ *
4
+ * A diff against the worktree is answered by the worktree's index. A staged
5
+ * diff (`--staged`) describes the git index, and the worktree may hold
6
+ * something else: a staged break with an unstaged repair must still be a
7
+ * break, a staged repair with an unstaged break must not be one.
8
+ *
9
+ * The staged snapshot is the git index and nothing else. Source files,
10
+ * `.gitignore` rules, `.ucn.json` and every manifest come from it. A file
11
+ * that exists only in the worktree, tracked or not, ignored or not, is not
12
+ * part of it and is not applied.
13
+ *
14
+ * The live index answers for the staged snapshot only when the worktree
15
+ * provably holds the same thing:
16
+ * (a) no tracked path has an unstaged modification or deletion, and none
17
+ * is marked assume-unchanged or skip-worktree (for those git reports
18
+ * no difference whatever the worktree holds),
19
+ * (b) no untracked path exists that git does not ignore,
20
+ * (c) no ignored file exists that analysis reads as metadata
21
+ * (ANALYSIS_METADATA: the names every reader of a non-source file
22
+ * declares here), and the live index holds and searched no file git
23
+ * does not track.
24
+ * When any of the three is false or cannot be established, the snapshot is
25
+ * built: the git index is exported to a temporary directory and indexed
26
+ * there, with the staged file list as the tracked inventory (a tracked file
27
+ * that matches a .gitignore rule stays in). Every staged file is exported,
28
+ * skip-worktree paths included, and a checked-out submodule at the commit
29
+ * the index names. That index is never saved.
30
+ *
31
+ * When the export cannot be built the answer is that the check could not
32
+ * run (`staged-snapshot-unavailable`), never an answer about the worktree.
33
+ */
34
+
35
+ 'use strict';
36
+
37
+ const fs = require('fs');
38
+ const os = require('os');
39
+ const path = require('path');
40
+ const { execFileSync } = require('child_process');
41
+ const { UcnError } = require('./errors');
42
+
43
+ // Files that are not source and that analysis reads: configuration, ignore
44
+ // rules, package and build manifests. A reader of a new one adds it here;
45
+ // test/check-staged.test.js fails when a file name of this kind appears in
46
+ // core/ or languages/ without being listed.
47
+ const ANALYSIS_METADATA = Object.freeze([
48
+ '.ucn.json', '.gitignore',
49
+ 'package.json', 'tsconfig.json', 'jsconfig.json',
50
+ 'go.mod', 'go.work',
51
+ 'Cargo.toml',
52
+ 'pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'pytest.ini', 'tox.ini',
53
+ 'pom.xml', 'build.gradle', 'build.gradle.kts',
54
+ 'compile_commands.json', 'CMakeLists.txt', 'Makefile',
55
+ 'composer.json', 'Gemfile', 'Podfile', 'Cartfile', 'mix.exs', 'rebar.config',
56
+ 'Directory.Build.props', 'Directory.Build.targets',
57
+ // Configuration of the test runners (core/test-units.js).
58
+ 'jest.config.js', 'jest.config.cjs', 'jest.config.mjs', 'jest.config.ts', 'jest.config.cts', 'jest.config.mts',
59
+ 'jest.config.json',
60
+ 'vitest.config.js', 'vitest.config.cjs', 'vitest.config.mjs', 'vitest.config.ts', 'vitest.config.cts',
61
+ 'vitest.config.mts', 'vite.config.js', 'vite.config.cjs', 'vite.config.mjs', 'vite.config.ts',
62
+ 'vite.config.cts', 'vite.config.mts', 'vitest.workspace.js', 'vitest.workspace.mjs', 'vitest.workspace.ts',
63
+ 'vitest.workspace.mts', 'vitest.workspace.json', 'vitest.projects.js', 'vitest.projects.ts',
64
+ 'vitest.projects.json',
65
+ '.mocharc', '.mocharc.js', '.mocharc.cjs', '.mocharc.mjs', '.mocharc.json', '.mocharc.jsonc', '.mocharc.yaml',
66
+ '.mocharc.yml',
67
+ 'conftest.py',
68
+ ]);
69
+ // Project files named after the project.
70
+ const ANALYSIS_METADATA_SUFFIXES = Object.freeze(['.csproj', '.sln']);
71
+ const METADATA_NAMES = new Set(ANALYSIS_METADATA);
72
+
73
+ function isAnalysisMetadata(relativePath) {
74
+ const base = String(relativePath).split('/').pop();
75
+ return METADATA_NAMES.has(base) || ANALYSIS_METADATA_SUFFIXES.some(suffix => base.endsWith(suffix));
76
+ }
77
+
78
+ function git(cwd, args, env = null) {
79
+ return execFileSync('git', args, {
80
+ cwd, encoding: 'utf8', maxBuffer: 1024 * 1024 * 1024,
81
+ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
82
+ ...(env && { env: { ...process.env, ...env } }),
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Tracked paths under `root` that git has been told not to compare with
88
+ * the worktree: assume-unchanged (`h`) and skip-worktree (`S`, `s`). For
89
+ * these `git diff` reports nothing whatever the file holds, or whether it
90
+ * exists, so a quiet diff says nothing about them.
91
+ * @returns {{path: string, skipWorktree: boolean}[]}
92
+ */
93
+ function flaggedPaths(root) {
94
+ const flagged = [];
95
+ for (const record of git(root, ['ls-files', '-v', '-z', '--', '.']).split('\0')) {
96
+ if (record.length < 3 || record[0] === 'H') continue;
97
+ flagged.push({ path: record.slice(2), skipWorktree: record[0] === 'S' || record[0] === 's' });
98
+ }
99
+ return flagged;
100
+ }
101
+
102
+ /**
103
+ * Does the worktree under `root` hold what the git index holds, as far as
104
+ * analysis can tell? { same: true } or { same: false, why }. With the live
105
+ * `index`, every file it holds or searched must be one git tracks: a file
106
+ * git ignores by a rule the engine does not read (a global or
107
+ * repository-local exclude) is in the live index and not in the git index.
108
+ */
109
+ function worktreeMatchesIndex(root, index = null) {
110
+ try {
111
+ // A path git was told not to compare is one a quiet diff cannot
112
+ // vouch for: the worktree may hold anything there, or nothing.
113
+ const flagged = flaggedPaths(root);
114
+ if (flagged.length > 0) {
115
+ return { same: false, why: `${flagged[0].path} is marked assume-unchanged or skip-worktree: git does not compare it` };
116
+ }
117
+ try {
118
+ // Submodules are compared whatever the configuration says to
119
+ // ignore: the snapshot exports them at the staged commit.
120
+ git(root, ['diff', '--quiet', '--ignore-submodules=none', '--', '.']);
121
+ } catch (error) {
122
+ if (error.status === 1) return { same: false, why: 'tracked files have unstaged changes' };
123
+ throw error;
124
+ }
125
+ const untracked = git(root, ['ls-files', '--others', '--exclude-standard', '-z', '--', '.']);
126
+ if (untracked.length > 0) return { same: false, why: 'untracked files exist' };
127
+ // Ignored files, with wholly ignored directories listed as one
128
+ // entry: analysis does not walk into those either.
129
+ const ignored = git(root, ['ls-files', '--others', '--ignored', '--exclude-standard',
130
+ '--directory', '-z', '--', '.']).split('\0').filter(Boolean);
131
+ const metadata = ignored.find(entry => !entry.endsWith('/') && isAnalysisMetadata(entry));
132
+ if (metadata) return { same: false, why: `${metadata} is not in the git index` };
133
+ if (index) {
134
+ const tracked = new Set(git(root, ['ls-files', '--cached', '-z', '--', '.']).split('\0').filter(Boolean));
135
+ const relative = file => path.relative(index.root, file).split(path.sep).join('/');
136
+ for (const file of index.files.keys()) {
137
+ if (!tracked.has(relative(file))) return { same: false, why: `${relative(file)} is indexed and not in the git index` };
138
+ }
139
+ // Source the engine searches as text or could not read
140
+ // (core/source-universe.js): part of every answer too.
141
+ const { universe } = require('./source-universe');
142
+ const scope = universe(index);
143
+ for (const entry of [...(scope.textOnly || []), ...(scope.opaque || [])]) {
144
+ if (!entry.relativePath || entry.kind === 'directory') continue;
145
+ if (!tracked.has(entry.relativePath)) {
146
+ return { same: false, why: `${entry.relativePath} is searched and not in the git index` };
147
+ }
148
+ }
149
+ }
150
+ return { same: true };
151
+ } catch (_) {
152
+ return { same: false, why: 'the worktree could not be compared with the git index' };
153
+ }
154
+ }
155
+
156
+ function unavailable(detail, cause, reason = null) {
157
+ const error = new UcnError(`staged-snapshot-unavailable: ${detail}`, cause ? { cause } : undefined);
158
+ error.code = 'staged-snapshot-unavailable';
159
+ // 'not-a-repository': there is no git index to describe at all.
160
+ if (reason) error.reason = reason;
161
+ return error;
162
+ }
163
+
164
+ /**
165
+ * Write every file of the git index under `exportRoot`, whatever the
166
+ * worktree holds: a path marked skip-worktree is staged like any other.
167
+ * Git older than the option that says so gets the same through a private
168
+ * copy of the index with the marks cleared; the repository's index is
169
+ * never written.
170
+ */
171
+ function exportIndex(gitRoot, exportRoot) {
172
+ const prefix = `--prefix=${exportRoot}${path.sep}`;
173
+ try {
174
+ git(gitRoot, ['checkout-index', '--all', '--force', '--ignore-skip-worktree-bits', prefix]);
175
+ return;
176
+ } catch (error) {
177
+ if (!/unknown option|usage:/i.test(String(error.stderr || error.message))) throw error;
178
+ }
179
+ // The index git itself would read: a hook runs with its own.
180
+ const indexFile = path.resolve(gitRoot, process.env.GIT_INDEX_FILE ||
181
+ git(gitRoot, ['rev-parse', '--git-path', 'index']).trim());
182
+ const privateIndex = path.join(exportRoot, '..', `${path.basename(exportRoot)}.index`);
183
+ try {
184
+ fs.copyFileSync(indexFile, privateIndex);
185
+ const env = { GIT_INDEX_FILE: privateIndex };
186
+ const skipped = git(gitRoot, ['ls-files', '-v', '-z'], env).split('\0')
187
+ .filter(record => record[0] === 'S' || record[0] === 's').map(record => record.slice(2));
188
+ if (skipped.length > 0) {
189
+ execFileSync('git', ['update-index', '--no-skip-worktree', '-z', '--stdin'], {
190
+ cwd: gitRoot, input: skipped.join('\0') + '\0', env: { ...process.env, ...env },
191
+ stdio: ['pipe', 'pipe', 'pipe'],
192
+ });
193
+ }
194
+ git(gitRoot, ['checkout-index', '--all', '--force', prefix], env);
195
+ } finally {
196
+ fs.rmSync(privateIndex, { force: true });
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Submodules of the staged tree: each is a commit the index names, not
202
+ * files. One that is checked out here is exported at THAT commit (what
203
+ * the commit being made will build against), and so are the submodules
204
+ * that commit names, to any depth; one that is not checked out is absent
205
+ * from the worktree's analysis too, and is named in a note.
206
+ * @returns {{files: string[], notes: string[]}} files relative to the project
207
+ */
208
+ function exportSubmodules(projectRoot, snapshotRoot) {
209
+ const files = [];
210
+ const notes = [];
211
+ let archives = 0;
212
+ // One submodule at the commit its parent names, then the submodules
213
+ // that commit names in turn.
214
+ const exportAt = (worktree, commit, relative) => {
215
+ let checkedOut;
216
+ try { checkedOut = fs.existsSync(path.join(worktree, '.git')); } catch (_) { checkedOut = false; }
217
+ if (!checkedOut) {
218
+ notes.push(`submodule ${relative} is not checked out: its code is not part of the staged analysis`);
219
+ return;
220
+ }
221
+ const target = path.join(snapshotRoot, relative);
222
+ const archive = path.join(snapshotRoot, '..', `${path.basename(snapshotRoot)}.${archives++}.tar`);
223
+ const nested = [];
224
+ try {
225
+ fs.mkdirSync(target, { recursive: true });
226
+ git(worktree, ['archive', '--format=tar', '-o', archive, commit]);
227
+ execFileSync('tar', ['-xf', archive, '-C', target], { stdio: ['ignore', 'pipe', 'pipe'] });
228
+ for (const record of git(worktree, ['ls-tree', '-r', '-z', commit]).split('\0')) {
229
+ const entry = /^(\d+) \w+ ([0-9a-f]+)\t(.+)$/s.exec(record);
230
+ if (!entry) continue;
231
+ if (entry[1] === '160000') nested.push([entry[3], entry[2]]);
232
+ else files.push(`${relative}/${entry[3]}`);
233
+ }
234
+ } catch (error) {
235
+ throw unavailable(`submodule ${relative} could not be exported at ${commit.slice(0, 10)}`, error);
236
+ } finally {
237
+ fs.rmSync(archive, { force: true });
238
+ }
239
+ for (const [inner, innerCommit] of nested) exportAt(path.join(worktree, inner), innerCommit, `${relative}/${inner}`);
240
+ };
241
+ for (const record of git(projectRoot, ['ls-files', '-s', '-z', '--', '.']).split('\0')) {
242
+ const match = /^160000 ([0-9a-f]+) \d+\t(.+)$/s.exec(record);
243
+ if (match) exportAt(path.join(projectRoot, match[2]), match[1], match[2]);
244
+ }
245
+ return { files, notes };
246
+ }
247
+
248
+ /**
249
+ * The index that represents the NEW side of a diff.
250
+ *
251
+ * @param {object} index - the live ProjectIndex of the worktree
252
+ * @param {object} options - { staged, force: build the snapshot even
253
+ * when the worktree matches (tests compare the two) }
254
+ * @returns {{index, repoRoot, ephemeral, notes: string[], dispose: Function}}
255
+ * `repoRoot` is where git commands run; `index.root` is where the
256
+ * snapshot's files are. They differ only for an ephemeral snapshot.
257
+ * @throws UcnError (code `staged-snapshot-unavailable`) when a staged
258
+ * snapshot is needed and cannot be built.
259
+ */
260
+ function resolveTarget(index, options = {}) {
261
+ const live = { index, repoRoot: index.root, ephemeral: false, notes: [], dispose() {} };
262
+ if (!options.staged) return live;
263
+ if (!options.force && worktreeMatchesIndex(index.root, index).same) return live;
264
+
265
+ let gitRoot;
266
+ let listed;
267
+ try {
268
+ gitRoot = git(index.root, ['rev-parse', '--show-toplevel']).trim();
269
+ } catch (error) {
270
+ throw unavailable('not a git repository', error, 'not-a-repository');
271
+ }
272
+ try {
273
+ // Staged paths under the project, relative to it.
274
+ listed = git(index.root, ['ls-files', '--cached', '-z', '--', '.']).split('\0').filter(Boolean);
275
+ } catch (error) {
276
+ throw unavailable('git could not list the staged files', error);
277
+ }
278
+ let exportRoot = null;
279
+ try {
280
+ exportRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-staged-')));
281
+ exportIndex(gitRoot, exportRoot);
282
+ const prefix = path.relative(fs.realpathSync(gitRoot), fs.realpathSync(index.root));
283
+ const snapshotRoot = prefix ? path.join(exportRoot, prefix) : exportRoot;
284
+ if (!fs.existsSync(snapshotRoot)) fs.mkdirSync(snapshotRoot, { recursive: true });
285
+ const submodules = exportSubmodules(index.root, snapshotRoot);
286
+ const files = new Set([...listed, ...submodules.files]);
287
+ const directories = new Set();
288
+ for (const file of files) {
289
+ for (let slash = file.lastIndexOf('/'); slash > 0; slash = file.lastIndexOf('/', slash - 1)) {
290
+ const parent = file.slice(0, slash);
291
+ if (directories.has(parent)) break;
292
+ directories.add(parent);
293
+ }
294
+ }
295
+ const gitignores = [...files]
296
+ .filter(file => file === '.gitignore' || file.endsWith('/.gitignore'))
297
+ .map(file => path.join(snapshotRoot, file));
298
+ const { ProjectIndex } = require('./project');
299
+ const snapshot = new ProjectIndex(snapshotRoot, { exactRoot: true });
300
+ // Git commands about this tree run in the repository it came from.
301
+ snapshot.repoRoot = index.root;
302
+ snapshot.build(null, { quiet: true, gitListing: { tracked: { files, directories }, gitignores } });
303
+ const notes = [...submodules.notes];
304
+ if (!files.has('.ucn.json') && fs.existsSync(path.join(index.root, '.ucn.json'))) {
305
+ notes.push('the .ucn.json in the worktree is not in the git index: the staged check does not apply it');
306
+ }
307
+ const root = exportRoot;
308
+ return {
309
+ index: snapshot,
310
+ repoRoot: index.root,
311
+ ephemeral: true,
312
+ notes,
313
+ dispose() { fs.rmSync(root, { recursive: true, force: true }); },
314
+ };
315
+ } catch (error) {
316
+ if (exportRoot) fs.rmSync(exportRoot, { recursive: true, force: true });
317
+ if (error && error.code === 'staged-snapshot-unavailable') throw error;
318
+ // What git said, not the command line (which names a temporary
319
+ // directory nobody can act on).
320
+ const said = String((error && error.stderr) || '').split('\n').map(line => line.trim()).find(Boolean) ||
321
+ String(error && error.message ? error.message : error).split('\n')[0];
322
+ throw unavailable(exportRoot ? said.split(exportRoot).join('<export>') : said, error);
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Rewrite, in place, every string under `value` that is a path inside
328
+ * `fromRoot` to the same path inside `toRoot`: a result computed on an
329
+ * exported snapshot names files where the repository has them.
330
+ */
331
+ function rebasePaths(value, fromRoot, toRoot, seen = new Set()) {
332
+ if (!value || typeof value !== 'object' || seen.has(value)) return value;
333
+ seen.add(value);
334
+ const prefix = fromRoot + path.sep;
335
+ for (const key of Object.keys(value)) {
336
+ const item = value[key];
337
+ if (typeof item === 'string') {
338
+ if (item === fromRoot) value[key] = toRoot;
339
+ else if (item.startsWith(prefix)) value[key] = toRoot + path.sep + item.slice(prefix.length);
340
+ } else if (item && typeof item === 'object' && !(item instanceof Map) && !(item instanceof Set)) {
341
+ rebasePaths(item, fromRoot, toRoot, seen);
342
+ }
343
+ }
344
+ return value;
345
+ }
346
+
347
+ module.exports = {
348
+ resolveTarget, rebasePaths, worktreeMatchesIndex, flaggedPaths, isAnalysisMetadata,
349
+ ANALYSIS_METADATA, ANALYSIS_METADATA_SUFFIXES,
350
+ };