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