ucn 5.5.3 → 5.6.1
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.
- package/.claude/skills/ucn/SKILL.md +37 -12
- package/.claude/skills/ucn/references/commands.md +18 -14
- package/.claude/skills/ucn/references/resolution-rules.md +4 -0
- package/.claude/skills/ucn/references/trust-contract.md +4 -4
- package/CHANGELOG.md +485 -0
- package/README.md +66 -29
- package/cli/flags.js +184 -0
- package/cli/index.js +137 -324
- package/core/account.js +44 -87
- package/core/analysis.js +274 -108
- package/core/ast-analysis.js +2 -0
- package/core/bridge.js +14 -0
- package/core/cache.js +103 -16
- package/core/call-fit.js +130 -0
- package/core/callers.js +353 -71
- package/core/capture.js +363 -0
- package/core/change-kinds.js +1107 -0
- package/core/check.js +1150 -139
- package/core/command-contracts.js +8 -7
- package/core/constructed-class.js +1195 -0
- package/core/contract-membership.js +142 -0
- package/core/deadcode.js +455 -106
- package/core/deletion.js +421 -0
- package/core/discovery.js +104 -9
- package/core/entrypoint-toml.js +110 -0
- package/core/entrypoints.js +12 -6
- package/core/evidence-bands.js +68 -0
- package/core/evidence-bands.json +44 -0
- package/core/execute.js +172 -161
- package/core/hot-summary.js +241 -0
- package/core/index-ir.js +5 -3
- package/core/ir.js +6 -2
- package/core/member-signatures.js +789 -0
- package/core/outgoing-check.js +116 -0
- package/core/output/analysis-ext.js +20 -10
- package/core/output/analysis.js +98 -60
- package/core/output/check.js +101 -37
- package/core/output/doctor.js +34 -1
- package/core/output/find.js +2 -1
- package/core/output/lines.js +63 -3
- package/core/output/public.js +29 -11
- package/core/output/refactoring.js +32 -0
- package/core/output/reporting.js +19 -2
- package/core/output/search.js +14 -2
- package/core/output/tracing.js +36 -8
- package/core/output-budget.js +146 -61
- package/core/parallel-build.js +3 -3
- package/core/project.js +106 -23
- package/core/provenance-type.js +12 -5
- package/core/public-command.js +35 -9
- package/core/python-binding-identity.js +707 -0
- package/core/python-modules.js +6 -0
- package/core/reflection.js +248 -31
- package/core/registry.js +623 -72
- package/core/reporting.js +69 -34
- package/core/review-items.js +75 -0
- package/core/runtime-entrypoints.js +159 -0
- package/core/search.js +148 -164
- package/core/shared.js +0 -45
- package/core/snapshot.js +350 -0
- package/core/source-universe.js +707 -0
- package/core/test-units.js +3698 -0
- package/core/tracing.js +270 -658
- package/core/verify.js +63 -22
- package/languages/adapter.js +8 -3
- package/languages/c-family.js +12 -0
- package/languages/csharp.js +634 -0
- package/languages/go.js +120 -0
- package/languages/index.js +466 -4
- package/languages/java.js +712 -0
- package/languages/javascript.js +1498 -6
- package/languages/lexical-scope.js +68 -0
- package/languages/python.js +1708 -3
- package/languages/rust.js +209 -104
- package/languages/static-js.js +88 -0
- package/mcp/server.js +44 -110
- package/package.json +11 -5
package/core/snapshot.js
ADDED
|
@@ -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
|
+
};
|