yarramate 0.16.0 → 0.18.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.md +2 -1
- package/dist/adapters/graphify-cli.js +0 -0
- package/dist/adapters/likec4-cli.js +0 -0
- package/dist/adapters/mcp-cli.js +0 -0
- package/dist/adapters/visual/client.d.ts +74 -0
- package/dist/adapters/visual/client.js +448 -0
- package/dist/adapters/visual/likec4-compiler.d.ts +55 -0
- package/dist/adapters/visual/likec4-compiler.js +460 -0
- package/dist/adapters/visual/protocol-contract.d.ts +246 -0
- package/dist/adapters/visual/protocol-contract.js +19 -0
- package/dist/adapters/visual/protocol.d.ts +13 -0
- package/dist/adapters/visual/protocol.js +258 -0
- package/dist/adapters/visual/session-server.d.ts +130 -0
- package/dist/adapters/visual/session-server.js +1406 -0
- package/dist/adapters/visual/session-store.d.ts +126 -0
- package/dist/adapters/visual/session-store.js +755 -0
- package/dist/adapters/visual/wire.d.ts +93 -0
- package/dist/adapters/visual/wire.js +1 -0
- package/dist/adapters/visual-cli.d.ts +42 -0
- package/dist/adapters/visual-cli.js +274 -0
- package/dist/attestation-staleness.js +8 -1
- package/dist/cli.js +0 -0
- package/dist/compiler.d.ts +12 -0
- package/dist/compiler.js +46 -2
- package/dist/reconciliation.d.ts +17 -1
- package/dist/reconciliation.js +48 -1
- package/dist/rtm.d.ts +1 -0
- package/dist/rtm.js +9 -7
- package/dist/visual-app/assets/index-CGvC8e3R.css +1 -0
- package/dist/visual-app/assets/index-CXuW1tOj.js +209 -0
- package/dist/visual-app/index.html +18 -0
- package/docs/CONSUMING-YARRAMATE.md +49 -0
- package/package.json +25 -5
- package/schema/yarramate-ask-result.schema.json +4 -0
- package/schema/yarramate-document.schema.json +5 -0
- package/schema/yarramate-operations.schema.json +7 -1
- package/schema/yarramate-reconciliation-report.schema.json +36 -1
- package/schema/yarramate-visual-diagnostic-result.schema.json +67 -0
- package/schema/yarramate-visual-event.schema.json +255 -0
- package/schema/yarramate-visual-handoff.schema.json +120 -0
- package/schema/yarramate-visual-model.schema.json +111 -0
- package/schema/yarramate-visual-response.schema.json +230 -0
- package/schema/yarramate-visual-session-descriptor.schema.json +45 -0
- package/schema/yarramate-visual-session-request.schema.json +110 -0
- package/schema/yarramate-visual-session-started.schema.json +121 -0
- package/schema/yarramate-visual-status.schema.json +123 -0
- package/skills/yarramate-architecture/SKILL.md +28 -1
- package/skills/yarramate-architecture/references/native-authoring.md +15 -3
- package/skills/yarramate-architecture/references/visual-conversations.md +263 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { open, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { promoteCompiledModel, } from './session-store.js';
|
|
5
|
+
/**
|
|
6
|
+
* Document name reported by diagnostics that no staged source file owns — a
|
|
7
|
+
* compiler that could not be executed, ran past its budget, or answered with
|
|
8
|
+
* something other than the report its command promised.
|
|
9
|
+
*/
|
|
10
|
+
export const VISUAL_COMPILER_DOCUMENT = 'likec4-compiler';
|
|
11
|
+
/**
|
|
12
|
+
* Name of the exported document inside the candidate root. The leading dot
|
|
13
|
+
* cannot collide with a staged model file, because model paths reject
|
|
14
|
+
* dot-prefixed segments.
|
|
15
|
+
*/
|
|
16
|
+
export const VISUAL_COMPILER_EXPORT_FILE = '.likec4-export.json';
|
|
17
|
+
export const VISUAL_COMPILER_LIMITS = {
|
|
18
|
+
timeoutMs: 30_000,
|
|
19
|
+
outputBytes: 1024 * 1024,
|
|
20
|
+
exportBytes: 16 * 1024 * 1024,
|
|
21
|
+
/** Grace between the abort's SIGTERM and an unconditional SIGKILL. */
|
|
22
|
+
killGraceMs: 2_000,
|
|
23
|
+
};
|
|
24
|
+
const MODEL_SOURCE_EXTENSIONS = ['.c4', '.likec4'];
|
|
25
|
+
/**
|
|
26
|
+
* Reads a trusted compiler document's own fields. Anything that is not a plain
|
|
27
|
+
* object simply has no fields, so the caller's field checks report the fault.
|
|
28
|
+
*/
|
|
29
|
+
const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
const plainFields = (value) => isPlainObject(value) ? value : {};
|
|
31
|
+
const pointerSegment = (segment) => segment.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
32
|
+
const compilerDiagnostic = (code, message, path = VISUAL_COMPILER_DOCUMENT, pointer = '/files', line = 1, column = 1) => ({
|
|
33
|
+
severity: 'error',
|
|
34
|
+
code,
|
|
35
|
+
message,
|
|
36
|
+
path,
|
|
37
|
+
pointer,
|
|
38
|
+
line,
|
|
39
|
+
column,
|
|
40
|
+
});
|
|
41
|
+
/** Keeps a compiler's own prose from entering the diagnostic stream unbounded. */
|
|
42
|
+
const summarise = (text) => {
|
|
43
|
+
const line = text
|
|
44
|
+
.split('\n')
|
|
45
|
+
.map((candidate) => candidate.trim())
|
|
46
|
+
.find((candidate) => candidate.length > 0);
|
|
47
|
+
if (line === undefined)
|
|
48
|
+
return 'no diagnostic output';
|
|
49
|
+
return line.length > 500 ? `${line.slice(0, 500)}…` : line;
|
|
50
|
+
};
|
|
51
|
+
const runCompiler = (command, args, cwd, budget) => new Promise((settle) => {
|
|
52
|
+
// An abort that already landed raises no further event, so a signal
|
|
53
|
+
// cancelled before this call — including in the window between one stage
|
|
54
|
+
// settling and the next registering its listener — must be read directly.
|
|
55
|
+
if (budget.signal?.aborted === true) {
|
|
56
|
+
settle({ status: 'cancelled', code: null, failure: '', stdout: '', stderr: '' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
let timedOut = false;
|
|
61
|
+
const deadline = setTimeout(() => {
|
|
62
|
+
timedOut = true;
|
|
63
|
+
controller.abort();
|
|
64
|
+
}, budget.timeoutMs);
|
|
65
|
+
const cancel = () => controller.abort();
|
|
66
|
+
budget.signal?.addEventListener('abort', cancel, { once: true });
|
|
67
|
+
const child = spawn(command.command, [...args], {
|
|
68
|
+
shell: false,
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
71
|
+
cwd,
|
|
72
|
+
});
|
|
73
|
+
// Aborting the spawn only sends SIGTERM; a child that declines to honour it
|
|
74
|
+
// must still not outlive its budget.
|
|
75
|
+
let grace;
|
|
76
|
+
controller.signal.addEventListener('abort', () => {
|
|
77
|
+
grace = setTimeout(() => child.kill('SIGKILL'), VISUAL_COMPILER_LIMITS.killGraceMs);
|
|
78
|
+
grace.unref();
|
|
79
|
+
}, { once: true });
|
|
80
|
+
const stdout = [];
|
|
81
|
+
const stderr = [];
|
|
82
|
+
let bytes = 0;
|
|
83
|
+
let overflowed = false;
|
|
84
|
+
const bound = (stream, sink) => {
|
|
85
|
+
stream?.on('data', (chunk) => {
|
|
86
|
+
bytes += chunk.byteLength;
|
|
87
|
+
if (bytes > budget.outputBytes) {
|
|
88
|
+
overflowed = true;
|
|
89
|
+
child.kill('SIGKILL');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
sink.push(chunk);
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
bound(child.stdout, stdout);
|
|
96
|
+
bound(child.stderr, stderr);
|
|
97
|
+
let failure = '';
|
|
98
|
+
let settled = false;
|
|
99
|
+
const finish = (status, code) => {
|
|
100
|
+
if (settled)
|
|
101
|
+
return;
|
|
102
|
+
settled = true;
|
|
103
|
+
clearTimeout(deadline);
|
|
104
|
+
if (grace !== undefined)
|
|
105
|
+
clearTimeout(grace);
|
|
106
|
+
budget.signal?.removeEventListener('abort', cancel);
|
|
107
|
+
settle({
|
|
108
|
+
status,
|
|
109
|
+
code,
|
|
110
|
+
failure,
|
|
111
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
112
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
child.on('error', (error) => {
|
|
116
|
+
failure = error.message;
|
|
117
|
+
// A child that never started owns no stdio to close, so a spawn failure
|
|
118
|
+
// settles here rather than waiting for a 'close' that may not arrive.
|
|
119
|
+
if (child.pid === undefined)
|
|
120
|
+
finish('failed', null);
|
|
121
|
+
});
|
|
122
|
+
child.on('close', (code) => {
|
|
123
|
+
if (overflowed)
|
|
124
|
+
finish('overflow', code);
|
|
125
|
+
else if (timedOut)
|
|
126
|
+
finish('timeout', code);
|
|
127
|
+
else if (controller.signal.aborted)
|
|
128
|
+
finish('cancelled', code);
|
|
129
|
+
else if (failure !== '')
|
|
130
|
+
finish('failed', code);
|
|
131
|
+
else
|
|
132
|
+
finish('exited', code);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
const processDiagnostics = (stage, outcome, budget) => {
|
|
136
|
+
switch (outcome.status) {
|
|
137
|
+
case 'timeout':
|
|
138
|
+
return [
|
|
139
|
+
compilerDiagnostic('YMVS204', `LikeC4 ${stage} exceeded its ${budget.timeoutMs}ms budget`),
|
|
140
|
+
];
|
|
141
|
+
case 'cancelled':
|
|
142
|
+
return [
|
|
143
|
+
compilerDiagnostic('YMVS204', `LikeC4 ${stage} was cancelled before it finished`),
|
|
144
|
+
];
|
|
145
|
+
case 'overflow':
|
|
146
|
+
return [
|
|
147
|
+
compilerDiagnostic('YMVS205', `LikeC4 ${stage} exceeded its ${budget.outputBytes} byte output budget`),
|
|
148
|
+
];
|
|
149
|
+
case 'failed':
|
|
150
|
+
return [
|
|
151
|
+
compilerDiagnostic('YMVS203', `LikeC4 ${stage} could not be executed: ${summarise(outcome.failure)}`),
|
|
152
|
+
];
|
|
153
|
+
default:
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const exitDiagnostic = (stage, outcome) => compilerDiagnostic('YMVS203', `LikeC4 ${stage} exited with status ${outcome.code}: ${summarise(outcome.stderr)}`);
|
|
158
|
+
/**
|
|
159
|
+
* Project the staged configuration declares, if it declares one.
|
|
160
|
+
*/
|
|
161
|
+
const declaredProject = (model) => {
|
|
162
|
+
const config = model.files['likec4.config.json'];
|
|
163
|
+
if (config === undefined)
|
|
164
|
+
return undefined;
|
|
165
|
+
try {
|
|
166
|
+
const name = plainFields(JSON.parse(config)).name;
|
|
167
|
+
return typeof name === 'string' && name.length > 0 ? name : undefined;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// An unreadable configuration is the CLI's to report, not this adapter's.
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* The one exported project this rendering comes from.
|
|
176
|
+
*
|
|
177
|
+
* `likec4 export json` writes a bare project document for exactly one project
|
|
178
|
+
* and a bare array otherwise, and a workspace that stages a configuration
|
|
179
|
+
* resolves the named project beside an implicit default over the same sources.
|
|
180
|
+
* The named project is asked for on the command line; this is the reader that
|
|
181
|
+
* still resolves whatever arrives: one project, else the only project defining
|
|
182
|
+
* the requested view, else the one the configuration named. Anything still
|
|
183
|
+
* ambiguous is refused rather than picked from.
|
|
184
|
+
*/
|
|
185
|
+
const projectDocument = (exported, initialView, declared) => {
|
|
186
|
+
const documents = Array.isArray(exported) ? exported : [exported];
|
|
187
|
+
const projects = documents.filter((document) => isPlainObject(plainFields(document).views));
|
|
188
|
+
const only = projects[0];
|
|
189
|
+
if (projects.length === 1 && only !== undefined) {
|
|
190
|
+
return { ok: true, document: only };
|
|
191
|
+
}
|
|
192
|
+
const defining = projects.filter((document) => Object.hasOwn(plainFields(plainFields(document).views), initialView));
|
|
193
|
+
const named = declared === undefined
|
|
194
|
+
? []
|
|
195
|
+
: projects.filter((document) => plainFields(document).projectId === declared);
|
|
196
|
+
const resolved = declared === undefined ? defining : named;
|
|
197
|
+
const match = resolved[0];
|
|
198
|
+
return resolved.length === 1 && match !== undefined
|
|
199
|
+
? { ok: true, document: match }
|
|
200
|
+
: { ok: false, projects: projects.length, matches: defining.length };
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* Candidate-relative POSIX path for a source location the compiler reported,
|
|
204
|
+
* or `undefined` when the location is not a file staged under this candidate.
|
|
205
|
+
*/
|
|
206
|
+
const stagedSource = (raw, candidateDir) => {
|
|
207
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
208
|
+
return undefined;
|
|
209
|
+
const within = relative(candidateDir, resolve(candidateDir, raw));
|
|
210
|
+
if (within === '' || within.startsWith('..') || isAbsolute(within)) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
return within.split(sep).join('/');
|
|
214
|
+
};
|
|
215
|
+
const reportedLocation = (entry) => {
|
|
216
|
+
// LikeC4 reports LSP ranges, which count lines and characters from zero.
|
|
217
|
+
const start = plainFields(plainFields(entry.range).start);
|
|
218
|
+
const oneBased = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0
|
|
219
|
+
? value + 1
|
|
220
|
+
: 1;
|
|
221
|
+
return { line: oneBased(start.line), column: oneBased(start.character) };
|
|
222
|
+
};
|
|
223
|
+
const validationDiagnostics = (entries, candidateDir) => entries.flatMap((entry) => {
|
|
224
|
+
const fields = plainFields(entry);
|
|
225
|
+
// LikeC4 reports warnings and hints beside errors and still exits clean;
|
|
226
|
+
// only an error may block a candidate.
|
|
227
|
+
const severity = fields.severity;
|
|
228
|
+
if (severity !== undefined && severity !== 1 && severity !== 'error') {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
const message = typeof fields.message === 'string' && fields.message.length > 0
|
|
232
|
+
? fields.message
|
|
233
|
+
: 'LikeC4 reported an unlabelled validation error';
|
|
234
|
+
const source = stagedSource(fields.sourceFsPath, candidateDir);
|
|
235
|
+
if (source === undefined)
|
|
236
|
+
return [compilerDiagnostic('YMVS201', message)];
|
|
237
|
+
const { line, column } = reportedLocation(fields);
|
|
238
|
+
return [
|
|
239
|
+
compilerDiagnostic('YMVS201', message, source, `/files/${pointerSegment(source)}`, line, column),
|
|
240
|
+
];
|
|
241
|
+
});
|
|
242
|
+
const validate = async (command, model, candidateDir, budget) => {
|
|
243
|
+
const sources = Object.keys(model.files)
|
|
244
|
+
.filter((file) => MODEL_SOURCE_EXTENSIONS.some((allowed) => allowed === extname(file)))
|
|
245
|
+
.sort();
|
|
246
|
+
const outcome = await runCompiler(command, [
|
|
247
|
+
...command.args,
|
|
248
|
+
'validate',
|
|
249
|
+
'--json',
|
|
250
|
+
'--no-layout',
|
|
251
|
+
...sources.flatMap((file) => ['--file', file]),
|
|
252
|
+
candidateDir,
|
|
253
|
+
], candidateDir, budget);
|
|
254
|
+
const bounded = processDiagnostics('validate', outcome, budget);
|
|
255
|
+
if (bounded.length > 0)
|
|
256
|
+
return bounded;
|
|
257
|
+
// No report at all is a process failure; a report that is not the promised
|
|
258
|
+
// JSON is a broken compiler contract.
|
|
259
|
+
if (outcome.stdout.trim() === '') {
|
|
260
|
+
return outcome.code === 0 ? [] : [exitDiagnostic('validate', outcome)];
|
|
261
|
+
}
|
|
262
|
+
let report;
|
|
263
|
+
try {
|
|
264
|
+
report = JSON.parse(outcome.stdout);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return [
|
|
268
|
+
compilerDiagnostic('YMVS206', `LikeC4 validate did not emit a JSON report: ${summarise(outcome.stdout)}`),
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
const entries = plainFields(report).diagnostics;
|
|
272
|
+
if (entries !== undefined && !Array.isArray(entries)) {
|
|
273
|
+
return [
|
|
274
|
+
compilerDiagnostic('YMVS206', 'LikeC4 validate reported diagnostics that are not a list'),
|
|
275
|
+
];
|
|
276
|
+
}
|
|
277
|
+
const reported = validationDiagnostics(entries ?? [], candidateDir);
|
|
278
|
+
if (reported.length > 0)
|
|
279
|
+
return reported;
|
|
280
|
+
// A compiler that fails without naming a cause must still fail the candidate.
|
|
281
|
+
return outcome.code === 0 ? [] : [exitDiagnostic('validate', outcome)];
|
|
282
|
+
};
|
|
283
|
+
const exportViews = async (command, model, candidateDir, exportPath, budget) => {
|
|
284
|
+
const declared = declaredProject(model);
|
|
285
|
+
const outcome = await runCompiler(command, [
|
|
286
|
+
...command.args,
|
|
287
|
+
'export',
|
|
288
|
+
'json',
|
|
289
|
+
'--pretty',
|
|
290
|
+
// Without a project the CLI writes every project it resolved, and a
|
|
291
|
+
// staged configuration always resolves at least two.
|
|
292
|
+
...(declared === undefined ? [] : ['--project', declared]),
|
|
293
|
+
'-o',
|
|
294
|
+
exportPath,
|
|
295
|
+
candidateDir,
|
|
296
|
+
], candidateDir, budget);
|
|
297
|
+
const bounded = processDiagnostics('export', outcome, budget);
|
|
298
|
+
if (bounded.length > 0)
|
|
299
|
+
return { diagnostics: bounded };
|
|
300
|
+
if (outcome.code !== 0) {
|
|
301
|
+
return { diagnostics: [exitDiagnostic('export', outcome)] };
|
|
302
|
+
}
|
|
303
|
+
let raw;
|
|
304
|
+
try {
|
|
305
|
+
// The compiler is a separate process, so it creates the export with its
|
|
306
|
+
// own umask and typically leaves it group- and world-readable. The
|
|
307
|
+
// document is narrowed through the descriptor it is read from, so no
|
|
308
|
+
// widely readable rendering exists at any point between the export and
|
|
309
|
+
// the promotion, and no path is resolved twice in between.
|
|
310
|
+
const handle = await open(exportPath, 'r');
|
|
311
|
+
try {
|
|
312
|
+
await handle.chmod(0o600);
|
|
313
|
+
raw = await handle.readFile();
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
await handle.close();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return {
|
|
321
|
+
diagnostics: [
|
|
322
|
+
compilerDiagnostic('YMVS207', `LikeC4 export did not write "${VISUAL_COMPILER_EXPORT_FILE}"`),
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (raw.byteLength > budget.exportBytes) {
|
|
327
|
+
return {
|
|
328
|
+
diagnostics: [
|
|
329
|
+
compilerDiagnostic('YMVS205', `LikeC4 export exceeded its ${budget.exportBytes} byte output budget`),
|
|
330
|
+
],
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
let exported;
|
|
334
|
+
try {
|
|
335
|
+
exported = JSON.parse(raw.toString('utf8'));
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return {
|
|
339
|
+
diagnostics: [
|
|
340
|
+
compilerDiagnostic('YMVS207', 'LikeC4 export document is not JSON'),
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const sole = projectDocument(exported, model.initialView, declared);
|
|
345
|
+
if (!sole.ok) {
|
|
346
|
+
return {
|
|
347
|
+
diagnostics: [
|
|
348
|
+
compilerDiagnostic(sole.projects === 0 ? 'YMVS207' : 'YMVS210', sole.projects === 0
|
|
349
|
+
? 'LikeC4 export document does not carry a view collection'
|
|
350
|
+
: `LikeC4 export carries ${sole.projects} projects and ${sole.matches} of them define "${model.initialView}", so no single rendering can be promoted`),
|
|
351
|
+
],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// The promoted artefact is always one project document, because that is what
|
|
355
|
+
// the browser's model factory reads. Truncating in place keeps the 0600 the
|
|
356
|
+
// read above imposed; a create-time mode would not apply to a file that the
|
|
357
|
+
// compiler already wrote.
|
|
358
|
+
if (sole.document !== exported) {
|
|
359
|
+
try {
|
|
360
|
+
await writeFile(exportPath, JSON.stringify(sole.document, null, 2));
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return {
|
|
364
|
+
diagnostics: [
|
|
365
|
+
compilerDiagnostic('YMVS207', `LikeC4 export "${VISUAL_COMPILER_EXPORT_FILE}" could not be narrowed`),
|
|
366
|
+
],
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const views = plainFields(sole.document).views;
|
|
371
|
+
if (!isPlainObject(views)) {
|
|
372
|
+
return {
|
|
373
|
+
diagnostics: [
|
|
374
|
+
compilerDiagnostic('YMVS207', 'LikeC4 export document does not carry a view collection'),
|
|
375
|
+
],
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
const ids = Object.keys(views).sort();
|
|
379
|
+
if (ids.length === 0) {
|
|
380
|
+
return {
|
|
381
|
+
diagnostics: [
|
|
382
|
+
compilerDiagnostic('YMVS208', 'LikeC4 export contains no views'),
|
|
383
|
+
],
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (!ids.includes(model.initialView)) {
|
|
387
|
+
return {
|
|
388
|
+
diagnostics: [
|
|
389
|
+
compilerDiagnostic('YMVS209', `LikeC4 export does not contain the requested initial view "${model.initialView}"`),
|
|
390
|
+
],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return { views: ids, diagnostics: [] };
|
|
394
|
+
};
|
|
395
|
+
/**
|
|
396
|
+
* Validates and exports one candidate model with the trusted LikeC4 command,
|
|
397
|
+
* promoting it only once a complete export naming the requested initial view
|
|
398
|
+
* has been parsed. Every process, parse, and diagnostic failure becomes a
|
|
399
|
+
* `VisualDiagnostic` and leaves the last good rendering in place.
|
|
400
|
+
*/
|
|
401
|
+
export const compileVisualModel = async (options) => {
|
|
402
|
+
const { command, model, paths } = options;
|
|
403
|
+
// The command vector is trusted preflight input, so a relative executable is
|
|
404
|
+
// a caller defect: resolving it against PATH or the cwd is exactly the
|
|
405
|
+
// ambiguity a session must not carry into a spawned process.
|
|
406
|
+
if (!isAbsolute(command.command)) {
|
|
407
|
+
return {
|
|
408
|
+
ok: false,
|
|
409
|
+
diagnostics: [
|
|
410
|
+
compilerDiagnostic('YMVS202', `LikeC4 compiler command "${command.command}" must be an absolute executable path`),
|
|
411
|
+
],
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
// Staging a candidate for a request the caller already withdrew would leave
|
|
415
|
+
// a directory behind for work that can never be promoted.
|
|
416
|
+
if (options.signal?.aborted === true) {
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
diagnostics: [
|
|
420
|
+
compilerDiagnostic('YMVS204', 'LikeC4 compilation was cancelled before it started'),
|
|
421
|
+
],
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
const budget = {
|
|
425
|
+
timeoutMs: options.timeoutMs ?? VISUAL_COMPILER_LIMITS.timeoutMs,
|
|
426
|
+
outputBytes: options.maxOutputBytes ?? VISUAL_COMPILER_LIMITS.outputBytes,
|
|
427
|
+
exportBytes: options.maxExportBytes ?? VISUAL_COMPILER_LIMITS.exportBytes,
|
|
428
|
+
signal: options.signal,
|
|
429
|
+
};
|
|
430
|
+
let views;
|
|
431
|
+
let exportPath = '';
|
|
432
|
+
const promotion = await promoteCompiledModel(paths, {
|
|
433
|
+
model,
|
|
434
|
+
now: options.now ?? (() => new Date()),
|
|
435
|
+
compile: async (candidateDir) => {
|
|
436
|
+
const invalid = await validate(command, model, candidateDir, budget);
|
|
437
|
+
if (invalid.length > 0)
|
|
438
|
+
return invalid;
|
|
439
|
+
exportPath = join(candidateDir, VISUAL_COMPILER_EXPORT_FILE);
|
|
440
|
+
const exported = await exportViews(command, model, candidateDir, exportPath, budget);
|
|
441
|
+
views = exported.views;
|
|
442
|
+
return exported.diagnostics;
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
if (!promotion.promoted || views === undefined) {
|
|
446
|
+
return { ok: false, diagnostics: promotion.diagnostics };
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
ok: true,
|
|
450
|
+
diagnostics: [],
|
|
451
|
+
compiled: {
|
|
452
|
+
candidate: promotion.candidate,
|
|
453
|
+
candidateDir: promotion.candidateDir,
|
|
454
|
+
authority: model.authority,
|
|
455
|
+
initialView: model.initialView,
|
|
456
|
+
views,
|
|
457
|
+
exportPath,
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
};
|