isthmus-cli 0.5.0 → 0.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.
- package/README.ko.md +49 -28
- package/README.md +55 -31
- package/Skills/isthmus/SKILL.md +93 -4
- package/dist/cli/command-support.d.ts +1 -1
- package/dist/cli/command-support.js +2 -2
- package/dist/cli/command-support.js.map +1 -1
- package/dist/cli/impact-command.d.ts +5 -0
- package/dist/cli/impact-command.js +111 -0
- package/dist/cli/impact-command.js.map +1 -0
- package/dist/cli/main.js +15 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/preflight-command.d.ts +5 -0
- package/dist/cli/preflight-command.js +83 -0
- package/dist/cli/preflight-command.js.map +1 -0
- package/dist/cli/runtime-command.d.ts +5 -0
- package/dist/cli/runtime-command.js +42 -0
- package/dist/cli/runtime-command.js.map +1 -0
- package/dist/cli/runtime-json-reader.d.ts +12 -0
- package/dist/cli/runtime-json-reader.js +37 -0
- package/dist/cli/runtime-json-reader.js.map +1 -0
- package/dist/exchange/impact-selection.d.ts +13 -0
- package/dist/exchange/impact-selection.js +35 -0
- package/dist/exchange/impact-selection.js.map +1 -0
- package/dist/exchange/kartograph-impact.d.ts +4 -0
- package/dist/exchange/kartograph-impact.js +169 -0
- package/dist/exchange/kartograph-impact.js.map +1 -0
- package/dist/exchange/messages.d.ts +51 -0
- package/dist/exchange/messages.js +126 -0
- package/dist/exchange/messages.js.map +1 -0
- package/dist/exchange/parse.d.ts +8 -0
- package/dist/exchange/parse.js +12 -8
- package/dist/exchange/parse.js.map +1 -1
- package/dist/exchange/preflight-context.d.ts +71 -0
- package/dist/exchange/preflight-context.js +317 -0
- package/dist/exchange/preflight-context.js.map +1 -0
- package/dist/exchange/producer-impact.d.ts +17 -0
- package/dist/exchange/producer-impact.js +226 -0
- package/dist/exchange/producer-impact.js.map +1 -0
- package/dist/exchange/runtime.d.ts +69 -0
- package/dist/exchange/runtime.js +160 -0
- package/dist/exchange/runtime.js.map +1 -0
- package/dist/join/message-address.d.ts +7 -0
- package/dist/join/message-address.js +37 -0
- package/dist/join/message-address.js.map +1 -0
- package/dist/join/messages.d.ts +23 -0
- package/dist/join/messages.js +80 -0
- package/dist/join/messages.js.map +1 -0
- package/dist/report/diff.js +4 -3
- package/dist/report/diff.js.map +1 -1
- package/dist/report/impact.d.ts +62 -0
- package/dist/report/impact.js +168 -0
- package/dist/report/impact.js.map +1 -0
- package/dist/report/preflight-runtime.d.ts +42 -0
- package/dist/report/preflight-runtime.js +193 -0
- package/dist/report/preflight-runtime.js.map +1 -0
- package/dist/report/preflight-view.d.ts +146 -0
- package/dist/report/preflight-view.js +189 -0
- package/dist/report/preflight-view.js.map +1 -0
- package/dist/report/preflight.d.ts +104 -0
- package/dist/report/preflight.js +296 -0
- package/dist/report/preflight.js.map +1 -0
- package/dist/report/runtime-impact.d.ts +38 -0
- package/dist/report/runtime-impact.js +94 -0
- package/dist/report/runtime-impact.js.map +1 -0
- package/dist/report/runtime.d.ts +54 -0
- package/dist/report/runtime.js +137 -0
- package/dist/report/runtime.js.map +1 -0
- package/dist/report/sorted-json.d.ts +1 -1
- package/dist/report/sorted-json.js +2 -2
- package/dist/report/sorted-json.js.map +1 -1
- package/docs/BRIDGE-MESSAGES.md +102 -0
- package/docs/GRAPH-EXCHANGE.md +255 -0
- package/docs/IMPACT.md +96 -0
- package/docs/PREFLIGHT.md +295 -0
- package/docs/RUNTIME.md +172 -0
- package/docs/TOOLCHAIN.md +101 -0
- package/package.json +11 -2
- package/scripts/build-preflight-toolchain.mjs +186 -0
- package/scripts/capture-preflight.mjs +392 -0
- package/scripts/run-child.mjs +16 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { parseImpactSelection } from '../dist/exchange/impact-selection.js';
|
|
8
|
+
import { isProjectRelativePath, isSafeNonEmptyString, parseBridgeFactsDocument } from '../dist/exchange/parse.js';
|
|
9
|
+
import { parsePreflightContext } from '../dist/exchange/preflight-context.js';
|
|
10
|
+
import { parseMessageBridgeDocument } from '../dist/exchange/messages.js';
|
|
11
|
+
import { adaptCartographImpact, adaptDartographImpact } from '../dist/exchange/producer-impact.js';
|
|
12
|
+
import { adaptKartographImpact } from '../dist/exchange/kartograph-impact.js';
|
|
13
|
+
import { createPreflightReport, hasPreflightBlockers } from '../dist/report/preflight.js';
|
|
14
|
+
import { encodeSortedJson } from '../dist/report/sorted-json.js';
|
|
15
|
+
import { writeTextAtomically } from '../dist/cli/atomic-write.js';
|
|
16
|
+
import { runChild } from './run-child.mjs';
|
|
17
|
+
|
|
18
|
+
/** workflow 실패는 자식 출력·입력 경로를 오류 본문에 싣지 않는다. */
|
|
19
|
+
class CaptureError extends Error {}
|
|
20
|
+
|
|
21
|
+
/** 명시한 소스·설정·도구 입력이 같은 동안 수집한 producer 결과만 캐시한다. */
|
|
22
|
+
export async function capturePreflight(config, { execute = runChild } = {}) {
|
|
23
|
+
const started = performance.now();
|
|
24
|
+
const project = await realpath(config.project);
|
|
25
|
+
validateConfig(config, project);
|
|
26
|
+
const natives = [['swift', 'cartograph'], ['kotlin', 'kartograph']].filter(([, name]) => config[name] !== undefined);
|
|
27
|
+
const producerNames = ['dartograph', ...natives.map(([, name]) => name)];
|
|
28
|
+
const output = resolve(project, config.output);
|
|
29
|
+
const cachePath = resolve(project, config.cache);
|
|
30
|
+
const selection = {};
|
|
31
|
+
for (const platform of Object.keys(config.selection ?? {})) {
|
|
32
|
+
if (platform !== 'dart' && !natives.some(([language]) => language === platform)) {
|
|
33
|
+
throw new CaptureError('Selection requires a configured producer for its platform.');
|
|
34
|
+
}
|
|
35
|
+
selection[platform] = parseImpactSelection({ format: 'isthmus-changes', version: 1, ...config.selection[platform] });
|
|
36
|
+
}
|
|
37
|
+
const timings = [];
|
|
38
|
+
async function run(command, args, step, accepted = [0]) {
|
|
39
|
+
const start = performance.now();
|
|
40
|
+
const result = await execute(command[0], [...command.slice(1), ...args], {
|
|
41
|
+
cwd: project, timeout: 300_000, maxBuffer: 64 * 1024 * 1024,
|
|
42
|
+
env: { ...process.env, CI: 'true', DART_SUPPRESS_ANALYTICS: 'true', FLUTTER_SUPPRESS_ANALYTICS: 'true' },
|
|
43
|
+
});
|
|
44
|
+
timings.push({ step, milliseconds: Math.round(performance.now() - start) });
|
|
45
|
+
if (result.error || result.signal || !accepted.includes(result.status)) throw new CaptureError(`Preflight producer step failed: ${step}.`);
|
|
46
|
+
return result.stdout;
|
|
47
|
+
}
|
|
48
|
+
async function json(command, args, step, accepted) {
|
|
49
|
+
const text = await run(command, args, step, accepted);
|
|
50
|
+
try { return JSON.parse(text); }
|
|
51
|
+
catch { throw new CaptureError(`Preflight producer did not return JSON: ${step}.`); }
|
|
52
|
+
}
|
|
53
|
+
const limitations = [];
|
|
54
|
+
let selectionBase = null;
|
|
55
|
+
if (config.since !== undefined) {
|
|
56
|
+
selectionBase = (await run(['git'], ['rev-parse', '--verify', '--end-of-options', `${config.since}^{commit}`], 'git-base')).trim();
|
|
57
|
+
if (!/^[a-f0-9]{40,64}$/.test(selectionBase)) throw new CaptureError('Git did not resolve a commit for the selection.');
|
|
58
|
+
const diff = await run(['git'], ['diff', '--name-status', '-z', '--find-renames', selectionBase, '--'], 'git-changes');
|
|
59
|
+
const untracked = await run(['git'], ['ls-files', '--others', '--exclude-standard', '-z'], 'git-untracked');
|
|
60
|
+
const paths = gitChangePaths(diff);
|
|
61
|
+
for (const path of nulFields(untracked)) if (/\.(dart|swift|m|mm|kt|java)$/.test(path)) paths.add(path);
|
|
62
|
+
const files = { dart: [], swift: [], kotlin: [] };
|
|
63
|
+
let outsideModel = 0;
|
|
64
|
+
for (const path of paths) {
|
|
65
|
+
if (!isProjectRelativePath(path)) throw new CaptureError('Git returned an unsupported source path.');
|
|
66
|
+
if (path.endsWith('.dart')) files.dart.push(path);
|
|
67
|
+
else if (/\.(swift|m|mm)$/.test(path)) files.swift.push(path);
|
|
68
|
+
else if (/\.(kt|java)$/.test(path)) files.kotlin.push(path);
|
|
69
|
+
else outsideModel++;
|
|
70
|
+
}
|
|
71
|
+
for (const platform of ['dart', 'swift', 'kotlin']) if (files[platform].length) {
|
|
72
|
+
if (platform !== 'dart' && !natives.some(([language]) => language === platform)) {
|
|
73
|
+
limitations.push(`unconfigured-platform-changes: ${files[platform].length} ${platform} source change(s) require separate review`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
selection[platform] = parseImpactSelection({ format: 'isthmus-changes', version: 1, files: files[platform], symbols: [] });
|
|
77
|
+
}
|
|
78
|
+
if (outsideModel) limitations.push(`unmodeled-changes: ${outsideModel} tracked configuration/resource change(s) require separate review`);
|
|
79
|
+
}
|
|
80
|
+
const tools = {};
|
|
81
|
+
for (const name of producerNames) {
|
|
82
|
+
const value = (await run(config[name], ['--version'], `${name}-version`)).trim();
|
|
83
|
+
const version = value.replace(new RegExp(`^${name} `), '');
|
|
84
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) throw new CaptureError('Unsupported producer version response.');
|
|
85
|
+
tools[name] = { name, version };
|
|
86
|
+
}
|
|
87
|
+
const messageCommands = config.messages === undefined ? undefined : Object.fromEntries(producerNames
|
|
88
|
+
.map((name) => [name, config.messages === true ? config[name] : config.messages[name] ?? config[name]]));
|
|
89
|
+
if (messageCommands) for (const name of producerNames) {
|
|
90
|
+
if (JSON.stringify(messageCommands[name]) === JSON.stringify(config[name])) continue;
|
|
91
|
+
const value = (await run(messageCommands[name], ['--version'], `${name}-messages-version`)).trim();
|
|
92
|
+
const version = value.replace(new RegExp(`^${name} `), '');
|
|
93
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) throw new CaptureError('Unsupported message producer version response.');
|
|
94
|
+
tools[`${name}Messages`] = { name, version };
|
|
95
|
+
}
|
|
96
|
+
const keyConfig = { project, inputs: config.inputs, toolInputs: config.toolInputs, prepare: config.prepare,
|
|
97
|
+
dartograph: config.dartograph, cartograph: config.cartograph ?? null, kartograph: config.kartograph ?? null,
|
|
98
|
+
// 입력 선택의 한계만 지문에 넣는다. 수집 결과로 추가되는 한계는 입력 변경이 아니다.
|
|
99
|
+
kartographSnapshot: config.kartographSnapshot ?? null, selection, selectionBase, limitations: [...limitations],
|
|
100
|
+
indexStore: config.indexStore ?? null, messageCommands: messageCommands ?? null, tools,
|
|
101
|
+
host: { node: process.version, platform: process.platform, arch: process.arch },
|
|
102
|
+
toolchainEnvironment: digest(Object.fromEntries(['PATH', 'SDKROOT', 'DEVELOPER_DIR', 'FLUTTER_ROOT', 'DART_SDK', 'SWIFT_EXEC',
|
|
103
|
+
'JAVA_HOME', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'GRADLE_USER_HOME']
|
|
104
|
+
.map((name) => [name, process.env[name] ?? null]))) };
|
|
105
|
+
async function fingerprint() {
|
|
106
|
+
const entries = [];
|
|
107
|
+
let bytes = 0;
|
|
108
|
+
async function visit(path, label) {
|
|
109
|
+
if (entries.length >= 100_000) throw new CaptureError('Fingerprint inputs exceed the capture budget.');
|
|
110
|
+
let stat;
|
|
111
|
+
try { stat = await lstat(path); }
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code === 'ENOENT') { entries.push([label, 'missing']); return; }
|
|
114
|
+
throw new CaptureError('Unable to read a declared fingerprint input.');
|
|
115
|
+
}
|
|
116
|
+
if (stat.isSymbolicLink()) throw new CaptureError('Fingerprint inputs must not contain symbolic links.');
|
|
117
|
+
if (stat.isDirectory()) {
|
|
118
|
+
entries.push([label, 'directory']);
|
|
119
|
+
for (const name of (await readdir(path)).sort()) await visit(join(path, name), `${label}/${name}`);
|
|
120
|
+
} else if (stat.isFile()) {
|
|
121
|
+
bytes += stat.size;
|
|
122
|
+
if (bytes > 512 * 1024 * 1024 || entries.length >= 100_000) throw new CaptureError('Fingerprint inputs exceed the capture budget.');
|
|
123
|
+
const hash = createHash('sha256');
|
|
124
|
+
for await (const data of createReadStream(path)) hash.update(data);
|
|
125
|
+
entries.push([label, hash.digest('hex')]);
|
|
126
|
+
} else throw new CaptureError('Fingerprint input is not a regular file or directory.');
|
|
127
|
+
}
|
|
128
|
+
for (const path of [...config.inputs].sort()) await visit(resolve(project, path), `source:${path}`);
|
|
129
|
+
if (config.kartographSnapshot !== undefined) {
|
|
130
|
+
await visit(resolve(project, config.kartographSnapshot), 'kartograph:snapshot');
|
|
131
|
+
}
|
|
132
|
+
for (const path of [...config.toolInputs].sort()) await visit(resolve(project, path), `tool:${path}`);
|
|
133
|
+
// 같은 버전 문자열의 개발 빌드도 adapter·수집 정책이 바뀌면 다시 수집한다.
|
|
134
|
+
await visit(fileURLToPath(new URL('../dist', import.meta.url)), 'isthmus:dist');
|
|
135
|
+
await visit(fileURLToPath(import.meta.url), 'isthmus:capture');
|
|
136
|
+
await visit(fileURLToPath(new URL('./run-child.mjs', import.meta.url)), 'isthmus:runner');
|
|
137
|
+
return { key: digest({ config: keyConfig, entries }), entries };
|
|
138
|
+
}
|
|
139
|
+
let state = await fingerprint();
|
|
140
|
+
let cached;
|
|
141
|
+
try { cached = JSON.parse(await readFile(cachePath, 'utf8')); }
|
|
142
|
+
catch (error) { if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw new CaptureError('Unable to read preflight cache.'); }
|
|
143
|
+
if (cached?.format === 'isthmus-preflight-cache' && cached.version === 1 && cached.key === state.key &&
|
|
144
|
+
cached.contextHash === digest(cached.context) && cached.evidence?.revision === cached.context.revision &&
|
|
145
|
+
cached.evidenceHash === digest(cached.evidence)) {
|
|
146
|
+
try {
|
|
147
|
+
const context = parsePreflightContext(cached.context);
|
|
148
|
+
if (context.project === project && context.revision === `sha256:${state.key}`) {
|
|
149
|
+
const confirmed = await fingerprint();
|
|
150
|
+
if (confirmed.key !== state.key) throw new CaptureError('Declared inputs changed during capture.');
|
|
151
|
+
await publish(`${output}.sources.json`, cached.evidence);
|
|
152
|
+
await publish(output, context);
|
|
153
|
+
return { cached: true, fingerprintScope: 'declared-inputs', context, report: createPreflightReport(context),
|
|
154
|
+
timings, milliseconds: Math.round(performance.now() - started) };
|
|
155
|
+
}
|
|
156
|
+
} catch (error) { if (error instanceof CaptureError) throw error; }
|
|
157
|
+
}
|
|
158
|
+
for (const [index, command] of config.prepare.entries()) await run(command, [], `prepare-${index + 1}`);
|
|
159
|
+
state = await fingerprint();
|
|
160
|
+
const scratch = await mkdtemp(join(tmpdir(), 'isthmus-preflight-input-'));
|
|
161
|
+
try {
|
|
162
|
+
const nativeArgs = ['--project', project, ...(config.indexStore === undefined ? [] : ['--index-store', resolve(project, config.indexStore)])];
|
|
163
|
+
const kotlinArgs = ['--project', project, ...(config.kartographSnapshot === undefined ? []
|
|
164
|
+
: ['--graph-file', resolve(project, config.kartographSnapshot)])];
|
|
165
|
+
const bridges = [
|
|
166
|
+
parseBridgeFactsDocument(await json(config.dartograph, ['bridges', '--format', 'json', '--project', project, project], 'dart-bridges')),
|
|
167
|
+
];
|
|
168
|
+
const messages = messageCommands === undefined ? undefined : [
|
|
169
|
+
parseMessageBridgeDocument(await json(messageCommands.dartograph, ['bridges', '--messages', '--format', 'json', '--project', project, project], 'dart-messages')),
|
|
170
|
+
];
|
|
171
|
+
for (const [platform, name] of natives) {
|
|
172
|
+
const args = platform === 'swift' ? nativeArgs : kotlinArgs;
|
|
173
|
+
bridges.push(parseBridgeFactsDocument(await json(config[name],
|
|
174
|
+
['bridges', '--target', 'flutter', '--format', 'json', ...args], `${platform}-bridges`)));
|
|
175
|
+
if (messages) messages.push(parseMessageBridgeDocument(await json(messageCommands[name],
|
|
176
|
+
['bridges', '--messages', '--target', 'flutter', '--format', 'json', ...args], `${platform}-messages`)));
|
|
177
|
+
}
|
|
178
|
+
const analyses = [];
|
|
179
|
+
const artifacts = {};
|
|
180
|
+
const metadata = (platform, requested, trigger) => ({ id: `${platform}-${analyses.length}`, project, requested,
|
|
181
|
+
tool: tools[platform === 'dart' ? 'dartograph' : platform === 'swift' ? 'cartograph' : 'kartograph'],
|
|
182
|
+
...(trigger === undefined ? {} : { trigger }) });
|
|
183
|
+
async function dartImpact(requested, trigger) {
|
|
184
|
+
if (analyses.length >= 256) throw new CaptureError('Preflight analysis count exceeds its budget.');
|
|
185
|
+
let flags;
|
|
186
|
+
if (requested.files.length > 0) {
|
|
187
|
+
const path = join(scratch, 'changed.json');
|
|
188
|
+
await writeFile(path, JSON.stringify(requested.files), { mode: 0o600 });
|
|
189
|
+
flags = ['--changed', path];
|
|
190
|
+
} else flags = ['--symbol', requested.symbols[0]];
|
|
191
|
+
const raw = await json(config.dartograph, ['impact', ...flags, '--format', 'json', '--limit', '50000', project], 'dart-impact');
|
|
192
|
+
const meta = metadata('dart', requested, trigger);
|
|
193
|
+
artifacts[meta.id] = raw;
|
|
194
|
+
analyses.push(adaptDartographImpact(raw, meta));
|
|
195
|
+
}
|
|
196
|
+
if (selection.swift) {
|
|
197
|
+
// Cartograph는 파일·심볼 선택을 한 번에 혼용하지 않으므로 각각 요청한다.
|
|
198
|
+
const selections = [
|
|
199
|
+
...(selection.swift.files.length ? [{ files: selection.swift.files, symbols: [] }] : []),
|
|
200
|
+
...(selection.swift.symbols.length ? [{ files: [], symbols: selection.swift.symbols }] : []),
|
|
201
|
+
];
|
|
202
|
+
for (const requested of selections) {
|
|
203
|
+
const raw = await json(config.cartograph, ['impact', ...nativeArgs, '--format', 'json', '--limit', '10000',
|
|
204
|
+
...requested.files.flatMap((path) => ['--file', resolve(project, path)]),
|
|
205
|
+
...(requested.symbols.length ? ['--', ...requested.symbols] : [])], 'swift-impact');
|
|
206
|
+
const meta = metadata('swift', requested);
|
|
207
|
+
artifacts[meta.id] = raw;
|
|
208
|
+
analyses.push(adaptCartographImpact(raw, meta));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (selection.dart) {
|
|
212
|
+
if (selection.dart.files.length > 0) await dartImpact({ files: selection.dart.files, symbols: [] });
|
|
213
|
+
for (const symbol of selection.dart.symbols) await dartImpact({ files: [], symbols: [symbol] });
|
|
214
|
+
}
|
|
215
|
+
if (selection.kotlin) {
|
|
216
|
+
const requested = selection.kotlin;
|
|
217
|
+
const raw = await json(config.kartograph, ['impact', '--graph-file', resolve(project, config.kartographSnapshot),
|
|
218
|
+
'--limit', '10000', '--depth', '128', ...requested.files.flatMap((path) => ['--file', path]),
|
|
219
|
+
...requested.symbols.flatMap((symbol) => ['--symbol', symbol])], 'kotlin-impact', [0, 64]);
|
|
220
|
+
const meta = metadata('kotlin', requested);
|
|
221
|
+
artifacts[meta.id] = raw;
|
|
222
|
+
analyses.push(adaptKartographImpact(raw, meta));
|
|
223
|
+
}
|
|
224
|
+
const dartFacts = [...bridges[0].facts, ...(messages?.[0].facts ?? [])];
|
|
225
|
+
const names = [...new Set(dartFacts.flatMap((fact) => fact.symbol ? [fact.symbol.qualifiedName] : []))].sort();
|
|
226
|
+
const subjects = new Map();
|
|
227
|
+
const conflictingBindings = new Set();
|
|
228
|
+
const candidateRequests = new Map();
|
|
229
|
+
let candidateWork = 0;
|
|
230
|
+
const subjectKey = (requested, path) => JSON.stringify([requested, path]);
|
|
231
|
+
async function querySubjects(requests, label) {
|
|
232
|
+
const path = join(scratch, 'queries.json');
|
|
233
|
+
await writeFile(path, JSON.stringify(requests), { mode: 0o600 });
|
|
234
|
+
const document = await json(config.dartograph, ['query', '--batch', path, '--depth', '1', '--limit', '1', project], 'dart-bindings', [0, 64]);
|
|
235
|
+
if (document.format !== 'symbol-query-batch' || document.version !== 1 || !Array.isArray(document.results) ||
|
|
236
|
+
document.results.length !== requests.length || document.results.some((row, index) => row?.requested !== requests[index])) {
|
|
237
|
+
throw new CaptureError('Dart caller query responses do not match the requests.');
|
|
238
|
+
}
|
|
239
|
+
artifacts[label] = document;
|
|
240
|
+
return document.results;
|
|
241
|
+
}
|
|
242
|
+
function rememberSubject(requested, result, exact = false) {
|
|
243
|
+
if (result.status !== 'found') return;
|
|
244
|
+
const subject = result.result?.subject;
|
|
245
|
+
const location = subject?.location;
|
|
246
|
+
const source = typeof location?.path === 'string' && location.path.startsWith('project:') ? location.path.slice(8) : undefined;
|
|
247
|
+
if (!isSafeNonEmptyString(subject?.usr) || !isSafeNonEmptyString(subject?.qualifiedName) ||
|
|
248
|
+
!isProjectRelativePath(source) || !Number.isSafeInteger(location.line) || location.line < 1 ||
|
|
249
|
+
!Number.isSafeInteger(location.column) || location.column < 1) return;
|
|
250
|
+
if (exact && subject.usr !== result.requested) throw new CaptureError('Dart caller candidate identity changed during resolution.');
|
|
251
|
+
const key = subjectKey(requested, source);
|
|
252
|
+
if (conflictingBindings.has(key)) return;
|
|
253
|
+
const symbol = { id: subject.usr, qualifiedName: subject.qualifiedName,
|
|
254
|
+
location: { path: source, line: location.line, column: location.column } };
|
|
255
|
+
if (subjects.has(key) && encodeSortedJson(subjects.get(key)) !== encodeSortedJson(symbol)) {
|
|
256
|
+
subjects.delete(key); conflictingBindings.add(key); return;
|
|
257
|
+
}
|
|
258
|
+
subjects.set(key, symbol);
|
|
259
|
+
}
|
|
260
|
+
for (let offset = 0; offset < names.length; offset += 1000) {
|
|
261
|
+
for (const result of await querySubjects(names.slice(offset, offset + 1000), `bindings-${offset}`)) {
|
|
262
|
+
rememberSubject(result.requested, result);
|
|
263
|
+
if (result.status !== 'ambiguous' || !Array.isArray(result.candidates)) continue;
|
|
264
|
+
for (const candidate of result.candidates) {
|
|
265
|
+
if (++candidateWork > 100_000) throw new CaptureError('Dart caller candidates exceed the capture budget.');
|
|
266
|
+
if (!isSafeNonEmptyString(candidate?.usr)) throw new CaptureError('Invalid Dart caller candidate identity.');
|
|
267
|
+
let requests = candidateRequests.get(candidate.usr);
|
|
268
|
+
if (requests === undefined) {
|
|
269
|
+
if (candidateRequests.size >= 50_000) throw new CaptureError('Dart caller candidates exceed the capture budget.');
|
|
270
|
+
requests = new Set(); candidateRequests.set(candidate.usr, requests);
|
|
271
|
+
}
|
|
272
|
+
requests.add(result.requested);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// 이름이 같은 main 등의 경로를 추측하지 않고 실제 ID를 재조회해 fact 파일과 대조한다.
|
|
277
|
+
const candidateIds = [...candidateRequests.keys()].sort();
|
|
278
|
+
let unresolvedCandidates = 0;
|
|
279
|
+
for (let offset = 0; offset < candidateIds.length; offset += 1000) {
|
|
280
|
+
for (const result of await querySubjects(candidateIds.slice(offset, offset + 1000), `bindings-candidates-${offset}`)) {
|
|
281
|
+
if (result.status !== 'found') unresolvedCandidates++;
|
|
282
|
+
for (const requested of candidateRequests.get(result.requested)) rememberSubject(requested, result, true);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (unresolvedCandidates > 0) limitations.push(`unresolved-dart-candidates: ${unresolvedCandidates} producer candidate identity(s) could not be resolved on requery`);
|
|
286
|
+
const bindings = dartFacts.flatMap((fact) => {
|
|
287
|
+
const symbol = subjects.get(subjectKey(fact.symbol?.qualifiedName, fact.location.path));
|
|
288
|
+
return symbol?.location.path === fact.location.path
|
|
289
|
+
? [{ platform: 'dart', location: fact.location, requested: fact.symbol.qualifiedName, symbol }] : [];
|
|
290
|
+
});
|
|
291
|
+
const makeContext = () => parsePreflightContext({ format: 'isthmus-preflight-context', version: 1, project,
|
|
292
|
+
revision: `sha256:${state.key}`, selection, bridges, ...(messages === undefined ? {} : { messages }), bindings, analyses, limitations });
|
|
293
|
+
const initial = createPreflightReport(makeContext());
|
|
294
|
+
const reached = new Set([...initial.roots, ...initial.affected.map(({ subject }) => subject)]
|
|
295
|
+
.filter((subject) => subject.kind === 'symbol' && subject.platform === 'dart').map(({ symbol }) => symbol.id));
|
|
296
|
+
const supplied = new Set(analyses.filter(({ platform }) => platform === 'dart').flatMap(({ roots }) => roots.map(({ id }) => id)));
|
|
297
|
+
for (const id of [...new Set(bindings.map(({ symbol }) => symbol.id))].sort()) {
|
|
298
|
+
if (reached.has(id) && !supplied.has(id)) await dartImpact({ files: [], symbols: [id] }, id);
|
|
299
|
+
}
|
|
300
|
+
const context = makeContext();
|
|
301
|
+
const report = createPreflightReport(context);
|
|
302
|
+
const confirmed = await fingerprint();
|
|
303
|
+
if (confirmed.key !== state.key) throw new CaptureError('Declared inputs changed during capture.');
|
|
304
|
+
const evidence = { format: 'isthmus-capture-evidence', version: 1, revision: context.revision,
|
|
305
|
+
fingerprintScope: 'declared-inputs', inputs: state.entries, tools, artifacts, timings };
|
|
306
|
+
await publish(`${output}.sources.json`, evidence);
|
|
307
|
+
await publish(cachePath, { format: 'isthmus-preflight-cache', version: 1, key: state.key, context, contextHash: digest(context),
|
|
308
|
+
evidence, evidenceHash: digest(evidence) });
|
|
309
|
+
await publish(output, context);
|
|
310
|
+
return { cached: false, fingerprintScope: 'declared-inputs', context, report, timings, milliseconds: Math.round(performance.now() - started) };
|
|
311
|
+
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function digest(value) { return createHash('sha256').update(encodeSortedJson(value, true)).digest('hex'); }
|
|
315
|
+
|
|
316
|
+
function nulFields(text) {
|
|
317
|
+
if (text === '') return [];
|
|
318
|
+
if (!text.endsWith('\0')) throw new CaptureError('Git returned an incomplete path list.');
|
|
319
|
+
return text.slice(0, -1).split('\0');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** rename·copy는 두 경로를 모두 보존하며 이름에 공백이 있어도 재분할하지 않는다. */
|
|
323
|
+
function gitChangePaths(text) {
|
|
324
|
+
const fields = nulFields(text);
|
|
325
|
+
const paths = new Set();
|
|
326
|
+
for (let index = 0; index < fields.length;) {
|
|
327
|
+
const status = fields[index++];
|
|
328
|
+
if (!/^(?:[AMDUTX]|[RC]\d{1,3})$/.test(status)) throw new CaptureError('Git returned an unsupported change status.');
|
|
329
|
+
const count = /^[RC]/.test(status) ? 2 : 1;
|
|
330
|
+
for (let cursor = 0; cursor < count; cursor++) {
|
|
331
|
+
if (index >= fields.length) throw new CaptureError('Git returned an incomplete change.');
|
|
332
|
+
paths.add(fields[index++]);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return paths;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function publish(path, value) {
|
|
339
|
+
await mkdir(dirname(path), { recursive: true });
|
|
340
|
+
await writeTextAtomically(path, encodeSortedJson(value, true));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** 입력 범위와 실행할 명령은 사용자가 작성한 workflow 설정에서 명시한다. */
|
|
344
|
+
function validateConfig(config, project) {
|
|
345
|
+
const command = (value) => Array.isArray(value) && value.length > 0 && value.every(isSafeNonEmptyString);
|
|
346
|
+
if (!command(config.dartograph) || (config.cartograph === undefined && config.kartograph === undefined) ||
|
|
347
|
+
(config.cartograph !== undefined && !command(config.cartograph)) ||
|
|
348
|
+
(config.kartograph !== undefined && !command(config.kartograph)) || !Array.isArray(config.prepare) ||
|
|
349
|
+
config.prepare.length === 0 || !config.prepare.every(command)) throw new CaptureError('Configure producer commands and a native index preparation command.');
|
|
350
|
+
if (config.messages !== undefined && config.messages !== true && (config.messages === null || typeof config.messages !== 'object' ||
|
|
351
|
+
Array.isArray(config.messages) || Object.entries(config.messages).some(([name, value]) =>
|
|
352
|
+
!['dartograph', 'cartograph', 'kartograph'].includes(name) || config[name] === undefined || !command(value)))) {
|
|
353
|
+
throw new CaptureError('Invalid message producer configuration.');
|
|
354
|
+
}
|
|
355
|
+
if (config.kartograph !== undefined && !isSafeNonEmptyString(config.kartographSnapshot)) {
|
|
356
|
+
throw new CaptureError('Configure a Kartograph snapshot produced by the preparation command.');
|
|
357
|
+
}
|
|
358
|
+
if (config.kartograph === undefined && config.kartographSnapshot !== undefined) throw new CaptureError('A Kartograph snapshot requires its producer.');
|
|
359
|
+
if (!Array.isArray(config.inputs) || config.inputs.length === 0 || !config.inputs.every(isProjectRelativePath) ||
|
|
360
|
+
!Array.isArray(config.toolInputs) || config.toolInputs.length === 0 || !config.toolInputs.every(isSafeNonEmptyString)) {
|
|
361
|
+
throw new CaptureError('Declare source/config inputs and producer implementation files for fingerprinting.');
|
|
362
|
+
}
|
|
363
|
+
const explicit = config.selection !== undefined;
|
|
364
|
+
const since = config.since !== undefined;
|
|
365
|
+
if (explicit === since || (explicit && (!config.selection || typeof config.selection !== 'object' || Array.isArray(config.selection))) ||
|
|
366
|
+
(since && !isSafeNonEmptyString(config.since)) || !isSafeNonEmptyString(config.output) || !isSafeNonEmptyString(config.cache)) {
|
|
367
|
+
throw new CaptureError('Configure exactly one selection or since revision, plus output and cache paths.');
|
|
368
|
+
}
|
|
369
|
+
for (const output of [config.output, `${config.output}.sources.json`, config.cache]) {
|
|
370
|
+
const target = resolve(project, output);
|
|
371
|
+
for (const input of [...config.inputs, ...config.toolInputs,
|
|
372
|
+
...(config.kartographSnapshot === undefined ? [] : [config.kartographSnapshot])]) {
|
|
373
|
+
const part = relative(resolve(project, input), target);
|
|
374
|
+
if (part === '' || (!part.startsWith('..') && !isAbsolute(part))) throw new CaptureError('Keep output and cache outside fingerprint input trees.');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (resolve(project, config.output) === resolve(project, config.cache)) throw new CaptureError('Output and cache paths must differ.');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
381
|
+
try {
|
|
382
|
+
if (process.argv.length !== 3) throw new CaptureError('Usage: node scripts/capture-preflight.mjs <capture.json>');
|
|
383
|
+
const config = JSON.parse(await readFile(process.argv[2], 'utf8'));
|
|
384
|
+
const result = await capturePreflight(config);
|
|
385
|
+
process.stdout.write(encodeSortedJson({ cached: result.cached, revision: result.context.revision,
|
|
386
|
+
fingerprintScope: result.fingerprintScope, summary: result.report.summary, timings: result.timings, milliseconds: result.milliseconds }, true));
|
|
387
|
+
process.exitCode = hasPreflightBlockers(result.report) ? 1 : 0;
|
|
388
|
+
} catch (error) {
|
|
389
|
+
process.stderr.write(`${error instanceof CaptureError ? error.message : 'Preflight capture failed; check configuration and producer compatibility.'}\n`);
|
|
390
|
+
process.exitCode = 2;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const defaultTimeout = 60_000;
|
|
4
|
+
const defaultMaxBuffer = 16 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
/** 검증 자식 프로세스를 제한시간과 충분한 출력 버퍼 안에서 실행한다. */
|
|
7
|
+
export function runChild(command, arguments_, options = {}) {
|
|
8
|
+
return spawnSync(command, arguments_, {
|
|
9
|
+
cwd: options.cwd,
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
env: options.env,
|
|
12
|
+
timeout: options.timeout ?? defaultTimeout,
|
|
13
|
+
maxBuffer: options.maxBuffer ?? defaultMaxBuffer,
|
|
14
|
+
stdio: options.stdio,
|
|
15
|
+
});
|
|
16
|
+
}
|