@vmz/test 0.0.1 → 0.0.2

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/src/compile.ts ADDED
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Compile-mode host for `vmz test` / `@vmz/test` (T1+).
3
+ * Builds the project and checks graph/plan/view assertions against dist artifacts.
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+ const repoRootGuess = path.resolve(packageRoot, '../../..');
14
+
15
+ export type CreateWorkspaceFn = (opts: { root: string; outDir: string }) => {
16
+ build: (clean: boolean) => { diagnostics?: Array<{ severity?: string; level?: string }> };
17
+ dispose: () => void;
18
+ };
19
+
20
+ export type BuildOptions = {
21
+ createWorkspace?: CreateWorkspaceFn;
22
+ repoRoot?: string;
23
+ };
24
+
25
+ export type BuildResult =
26
+ | { ok: true; outDir: string; diagnostics: unknown[] }
27
+ | { ok: false; outDir: string; diagnostics: unknown[]; error: string };
28
+
29
+ /** Build project for compile/logic evidence. Prefers vmz-tools; optional N-API inject. */
30
+ export function buildForCompile(project: string, outDir?: string, options: BuildOptions = {}): BuildResult {
31
+ const dist = outDir || fs.mkdtempSync(path.join(os.tmpdir(), 'vmz-test-compile-'));
32
+ if (!fs.existsSync(dist)) fs.mkdirSync(dist, { recursive: true });
33
+
34
+ const preferNapi = process.env.VMZ_TEST_BUILD === 'napi';
35
+ const repoRoot = options.repoRoot || repoRootGuess;
36
+
37
+ if (!preferNapi) {
38
+ const cargoToml = path.join(repoRoot, 'Cargo.toml');
39
+ if (fs.existsSync(cargoToml)) {
40
+ const cargo = spawnSync(
41
+ 'cargo',
42
+ ['run', '--quiet', '--manifest-path', cargoToml, '-p', 'vmz-tools', '--', 'build', project, '--out-dir', dist],
43
+ { cwd: repoRoot, encoding: 'utf8' },
44
+ );
45
+ if (cargo.status === 0) {
46
+ return { ok: true, outDir: dist, diagnostics: [] };
47
+ }
48
+ }
49
+ }
50
+
51
+ if (options.createWorkspace) {
52
+ try {
53
+ const ws = options.createWorkspace({ root: project, outDir: dist });
54
+ try {
55
+ const report = ws.build(false);
56
+ const diags = report.diagnostics ?? [];
57
+ const errors = diags.filter((d) => d && (d.severity === 'error' || d.level === 'error'));
58
+ if (errors.length) {
59
+ return {
60
+ ok: false,
61
+ outDir: dist,
62
+ diagnostics: diags,
63
+ error: 'workspace build reported errors',
64
+ };
65
+ }
66
+ return { ok: true, outDir: dist, diagnostics: diags };
67
+ } finally {
68
+ ws.dispose();
69
+ }
70
+ } catch (e) {
71
+ return {
72
+ ok: false,
73
+ outDir: dist,
74
+ diagnostics: [],
75
+ error: `build failed: ${e instanceof Error ? e.message : String(e)}`,
76
+ };
77
+ }
78
+ }
79
+
80
+ return {
81
+ ok: false,
82
+ outDir: dist,
83
+ diagnostics: [],
84
+ error: 'build failed: vmz-tools unavailable and no createWorkspace provided',
85
+ };
86
+ }
87
+
88
+ export function resolveChunkArtifacts(dist: string, chunkId: string) {
89
+ const rel = chunkId.replace(/\\/g, '/');
90
+ const programPath = path.join(dist, `${rel}.program.json`);
91
+ const clientPath = path.join(dist, `${rel}.client.js`);
92
+ return {
93
+ programPath: fs.existsSync(programPath) ? programPath : null,
94
+ clientPath: fs.existsSync(clientPath) ? clientPath : null,
95
+ };
96
+ }
97
+
98
+ type Diag = { severity: string; kind?: string; message: string; expect?: unknown };
99
+
100
+ function collectViewKinds(nodes: unknown[] | undefined, kinds: Set<string>, flags: { each: boolean }) {
101
+ for (const raw of nodes ?? []) {
102
+ if (!raw || typeof raw !== 'object') continue;
103
+ const n = raw as Record<string, unknown>;
104
+ if (typeof n.kind === 'string') kinds.add(n.kind);
105
+ if (n.each) flags.each = true;
106
+ if (Array.isArray(n.children)) collectViewKinds(n.children as unknown[], kinds, flags);
107
+ if (Array.isArray(n.branches)) {
108
+ for (const b of n.branches as Array<Record<string, unknown>>) {
109
+ if (b?.body) collectViewKinds([b.body], kinds, flags);
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ export type CompileResult = {
116
+ status: 'passed' | 'failed' | 'error';
117
+ diagnostics: Diag[];
118
+ planId: string | null;
119
+ programId: string | null;
120
+ };
121
+
122
+ export function runCompileManifest(manifest: Record<string, unknown>, ctx: { outDir: string }): CompileResult {
123
+ const diagnostics: Diag[] = [];
124
+ const program = manifest.program && typeof manifest.program === 'object' ? (manifest.program as Record<string, unknown>) : {};
125
+ const chunkId = String(program.chunkId || '');
126
+ const programId = chunkId || null;
127
+
128
+ if (!chunkId) {
129
+ return {
130
+ status: 'error',
131
+ diagnostics: [{ severity: 'error', message: 'program.chunkId missing' }],
132
+ planId: null,
133
+ programId: null,
134
+ };
135
+ }
136
+
137
+ const arts = resolveChunkArtifacts(ctx.outDir, chunkId);
138
+ if (!arts.programPath) {
139
+ return {
140
+ status: 'failed',
141
+ diagnostics: [
142
+ {
143
+ severity: 'error',
144
+ message: `missing ${chunkId}.program.json under ${ctx.outDir}`,
145
+ },
146
+ ],
147
+ planId: null,
148
+ programId,
149
+ };
150
+ }
151
+
152
+ let prog: Record<string, unknown>;
153
+ try {
154
+ prog = JSON.parse(fs.readFileSync(arts.programPath, 'utf8'));
155
+ } catch (e) {
156
+ return {
157
+ status: 'error',
158
+ diagnostics: [
159
+ {
160
+ severity: 'error',
161
+ message: `parse program.json: ${e instanceof Error ? e.message : String(e)}`,
162
+ },
163
+ ],
164
+ planId: null,
165
+ programId,
166
+ };
167
+ }
168
+
169
+ const units = (prog.units as Array<Record<string, unknown>>) || [];
170
+ const unit =
171
+ units.find((u) => u && (u.name === program.unitName || u.chunkId === chunkId || String(u.chunk_id || '') === chunkId)) || units[0];
172
+
173
+ if (!unit) {
174
+ diagnostics.push({ severity: 'error', message: 'program.json has no units' });
175
+ return { status: 'failed', diagnostics, planId: null, programId };
176
+ }
177
+
178
+ const plan = (unit.plan as Record<string, unknown> | null) || null;
179
+ const planId = plan?.schema ? String(plan.schema) : null;
180
+ const clientJs = arts.clientPath ? fs.readFileSync(arts.clientPath, 'utf8') : '';
181
+ const view = (unit.view as Record<string, unknown> | null) || null;
182
+
183
+ const assertions = Array.isArray(manifest.assertions) ? manifest.assertions : [];
184
+ for (const raw of assertions) {
185
+ const a = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
186
+ const kind = String(a.kind || '');
187
+ const expect = a.expect && typeof a.expect === 'object' ? (a.expect as Record<string, unknown>) : {};
188
+ const fail = (message: string) => {
189
+ diagnostics.push({ severity: 'error', kind, message, expect });
190
+ };
191
+
192
+ if (kind === 'plan') {
193
+ if (expect.schema != null) {
194
+ if (!plan || plan.schema !== expect.schema) {
195
+ fail(`plan.schema want ${expect.schema}, got ${plan?.schema}`);
196
+ }
197
+ }
198
+ if (expect.status != null) {
199
+ if (!plan || plan.status !== expect.status) {
200
+ fail(`plan.status want ${expect.status}, got ${plan?.status}`);
201
+ }
202
+ }
203
+ if (expect.nonEmpty === true) {
204
+ if (!plan || !Array.isArray(plan.nodes) || plan.nodes.length === 0) {
205
+ fail('plan.nodes empty');
206
+ }
207
+ if (!plan || !Array.isArray(plan.root_ids) || plan.root_ids.length === 0) {
208
+ fail('plan.root_ids empty');
209
+ }
210
+ }
211
+ if (Array.isArray(expect.kinds)) {
212
+ const have = new Set(
213
+ (
214
+ (plan?.nodes as Array<{
215
+ kind?: string;
216
+ }>) || []
217
+ ).map((n) => String(n.kind || '')),
218
+ );
219
+ for (const k of expect.kinds) {
220
+ if (!have.has(String(k))) fail(`plan.nodes missing kind ${k}: ${[...have]}`);
221
+ }
222
+ }
223
+ if (expect.anyStructuralKind === true) {
224
+ const have = new Set(
225
+ (
226
+ (plan?.nodes as Array<{
227
+ kind?: string;
228
+ }>) || []
229
+ ).map((n) => String(n.kind || '')),
230
+ );
231
+ if (!have.has('element') && !have.has('interp') && !have.has('text')) {
232
+ fail(`plan missing structural kinds: ${[...have]}`);
233
+ }
234
+ }
235
+ if (expect.nodeIdsInClient === true || typeof expect.nodeIdsInClient === 'number') {
236
+ if (!arts.clientPath) {
237
+ fail(`missing ${chunkId}.client.js`);
238
+ } else {
239
+ const nodes = (plan?.nodes as Array<{ id?: number | string }>) || [];
240
+ const n = typeof expect.nodeIdsInClient === 'number' ? expect.nodeIdsInClient : Math.min(3, nodes.length);
241
+ for (const node of nodes.slice(0, n)) {
242
+ const id = node.id;
243
+ if (id == null || !clientJs.includes(`id:${id}`)) {
244
+ fail(`__vmzPlan missing node id ${id}`);
245
+ }
246
+ }
247
+ }
248
+ }
249
+ continue;
250
+ }
251
+
252
+ if (kind === 'view') {
253
+ if (expect.status != null) {
254
+ if (!view || view.status !== expect.status) {
255
+ fail(`view.status want ${expect.status}, got ${view?.status}`);
256
+ }
257
+ }
258
+ if (expect.nonEmptyRoots === true) {
259
+ if (!view || !Array.isArray(view.roots) || view.roots.length === 0) {
260
+ fail('view.roots empty');
261
+ }
262
+ }
263
+ const kinds = new Set<string>();
264
+ const flags = { each: false };
265
+ collectViewKinds(view?.roots as unknown[] | undefined, kinds, flags);
266
+ if (Array.isArray(expect.kinds)) {
267
+ for (const k of expect.kinds) {
268
+ if (!kinds.has(String(k))) fail(`view missing kind=${k}: ${[...kinds]}`);
269
+ }
270
+ }
271
+ if (expect.hasEach === true && !flags.each) {
272
+ fail('view missing each on element');
273
+ }
274
+ continue;
275
+ }
276
+
277
+ if (kind === 'graph') {
278
+ const unitGraph = (unit.graph as Record<string, unknown> | null) || null;
279
+ const needsClient =
280
+ expect.direct ||
281
+ expect.create ||
282
+ expect.plan ||
283
+ expect.serialize ||
284
+ expect.noRender ||
285
+ expect.noRenderFallback ||
286
+ expect.includes ||
287
+ expect.includesAll ||
288
+ expect.includesAny;
289
+ if (needsClient && !arts.clientPath) {
290
+ fail(`missing ${chunkId}.client.js`);
291
+ continue;
292
+ }
293
+ if (arts.clientPath) {
294
+ if (expect.direct === true && !clientJs.includes('__vmzDirect = true')) {
295
+ fail('missing __vmzDirect = true');
296
+ }
297
+ if (expect.create === true && !clientJs.includes('__vmzCreate')) {
298
+ fail('missing __vmzCreate');
299
+ }
300
+ if (expect.plan === true && !clientJs.includes('__vmzPlan')) {
301
+ fail('missing __vmzPlan');
302
+ }
303
+ if (expect.serialize === true && !clientJs.includes('__vmzSerialize')) {
304
+ fail('missing __vmzSerialize');
305
+ }
306
+ if (expect.noRender === true || expect.noRenderFallback === true) {
307
+ if (clientJs.includes('prototype.render')) {
308
+ fail('production client must not emit prototype.render (Gate 3)');
309
+ }
310
+ }
311
+ if (typeof expect.includes === 'string' && !clientJs.includes(expect.includes)) {
312
+ fail(`client.js missing substring ${JSON.stringify(expect.includes)}`);
313
+ }
314
+ if (Array.isArray(expect.includesAll)) {
315
+ for (const s of expect.includesAll) {
316
+ if (!clientJs.includes(String(s))) {
317
+ fail(`client.js missing substring ${JSON.stringify(s)}`);
318
+ }
319
+ }
320
+ }
321
+ if (Array.isArray(expect.includesAny)) {
322
+ const ok = expect.includesAny.some((s) => clientJs.includes(String(s)));
323
+ if (!ok) fail(`client.js missing any of ${JSON.stringify(expect.includesAny)}`);
324
+ }
325
+ }
326
+ if (expect.ownsUnitToRegion === true) {
327
+ const edges = (unitGraph?.edges as Array<Record<string, unknown>>) || [];
328
+ const owns = edges.filter((e) => e.kind === 'owns');
329
+ const hit = owns.some((e) => String(e.from).startsWith('unit:') && String(e.to).startsWith('region:'));
330
+ if (!hit) fail(`missing owns unit→region edges: ${JSON.stringify(owns)}`);
331
+ }
332
+ if (expect.disposesMin != null) {
333
+ const edges = (unitGraph?.edges as Array<Record<string, unknown>>) || [];
334
+ const n = edges.filter((e) => e.kind === 'disposes').length;
335
+ if (n < Number(expect.disposesMin)) {
336
+ fail(`disposes edges want >= ${expect.disposesMin}, got ${n}`);
337
+ }
338
+ }
339
+ if (typeof expect.unknownsVia === 'string') {
340
+ const unknowns = (unitGraph?.unknowns as Array<Record<string, unknown>>) || [];
341
+ const via = String(expect.unknownsVia);
342
+ const hits = unknowns.filter((u) => u.via === via);
343
+ if (!hits.length) fail(`graph.unknowns missing via ${via}`);
344
+ if (expect.unknownReason != null) {
345
+ if (!hits.some((u) => u.reason === expect.unknownReason)) {
346
+ fail(`unknown reason want ${expect.unknownReason}: ${JSON.stringify(hits)}`);
347
+ }
348
+ }
349
+ if (expect.unknownReasonNot === true || expect.unknownReasonNot === 'ir_unknown') {
350
+ if (!hits.every((u) => u.reason && u.reason !== 'ir_unknown')) {
351
+ fail(`opaque reasons must be specific: ${JSON.stringify(hits)}`);
352
+ }
353
+ }
354
+ }
355
+ continue;
356
+ }
357
+
358
+ if (kind === 'analysis') {
359
+ const unitGraph = (unit.graph as Record<string, unknown> | null) || null;
360
+ const analysis = (unitGraph?.analysis as Record<string, unknown> | null) || null;
361
+ if (!analysis || typeof analysis.exact !== 'number') {
362
+ fail(`missing graph.analysis: ${JSON.stringify(unitGraph)}`);
363
+ continue;
364
+ }
365
+ if (expect.exactMin != null && Number(analysis.exact) < Number(expect.exactMin)) {
366
+ fail(`analysis.exact want >= ${expect.exactMin}, got ${analysis.exact}`);
367
+ }
368
+ if (expect.widenedMin != null && Number(analysis.widened || 0) < Number(expect.widenedMin)) {
369
+ fail(`analysis.widened want >= ${expect.widenedMin}, got ${analysis.widened}`);
370
+ }
371
+ if (expect.unknownMin != null && Number(analysis.unknown || 0) < Number(expect.unknownMin)) {
372
+ fail(`analysis.unknown want >= ${expect.unknownMin}, got ${analysis.unknown}`);
373
+ }
374
+ if (expect.callEdgesMin != null && Number(analysis.call_edges || 0) < Number(expect.callEdgesMin)) {
375
+ fail(`analysis.call_edges want >= ${expect.callEdgesMin}, got ${analysis.call_edges}`);
376
+ }
377
+ if (expect.widenedOrUnknownMin != null) {
378
+ const sum = Number(analysis.widened || 0) + Number(analysis.unknown || 0);
379
+ if (sum < Number(expect.widenedOrUnknownMin)) {
380
+ fail(`widened+unknown want >= ${expect.widenedOrUnknownMin}, got ${sum}`);
381
+ }
382
+ }
383
+ continue;
384
+ }
385
+
386
+ if (kind === 'reactive') {
387
+ const reactive = (unit.reactive as Record<string, unknown> | null) || null;
388
+ const effects = (reactive?.effects as Array<Record<string, unknown>>) || [];
389
+ const name = expect.effect != null ? String(expect.effect) : '';
390
+ const effect = name ? effects.find((e) => e.name === name) : null;
391
+ if (name && !effect) {
392
+ fail(`missing reactive effect ${name}`);
393
+ continue;
394
+ }
395
+ if (effect && expect.writesInclude != null) {
396
+ const needle = String(expect.writesInclude);
397
+ const raw = JSON.stringify(effect);
398
+ if (!raw.includes(needle)) fail(`effect ${name} must write ${needle}: ${raw}`);
399
+ }
400
+ if (effect && expect.opaqueCallee === true && !effect.opaque_callee) {
401
+ fail(`effect ${name} must be opaque: ${JSON.stringify(effect)}`);
402
+ }
403
+ if (effect && Array.isArray(expect.starReasons)) {
404
+ const reasons = (effect.star_reasons as Array<Record<string, unknown>>) || [];
405
+ for (const want of expect.starReasons as Array<Record<string, unknown>>) {
406
+ const hit = reasons.some(
407
+ (r) => (want.field == null || r.field === want.field) && (want.reason == null || r.reason === want.reason),
408
+ );
409
+ if (!hit) {
410
+ fail(`star_reasons missing ${JSON.stringify(want)}: ${JSON.stringify(reasons)}`);
411
+ }
412
+ }
413
+ }
414
+ continue;
415
+ }
416
+
417
+ if (kind === 'lifetime') {
418
+ const life = (unit.lifetime as Record<string, unknown> | null) || null;
419
+ if (expect.status != null) {
420
+ if (!life || life.status !== expect.status) {
421
+ fail(`lifetime.status want ${expect.status}, got ${life?.status}`);
422
+ }
423
+ }
424
+ if (expect.nonEmptyRegions === true) {
425
+ if (!life || !Array.isArray(life.regions) || life.regions.length === 0) {
426
+ fail('lifetime.regions empty');
427
+ }
428
+ }
429
+ if (Array.isArray(expect.regionKinds)) {
430
+ const have = new Set(
431
+ (
432
+ (life?.regions as Array<{
433
+ kind?: string;
434
+ }>) || []
435
+ ).map((r) => String(r.kind || '')),
436
+ );
437
+ for (const k of expect.regionKinds) {
438
+ if (!have.has(String(k))) fail(`lifetime missing region kind=${k}: ${[...have]}`);
439
+ }
440
+ }
441
+ if (expect.hasDisposeRegion === true) {
442
+ const nodes = ((plan?.nodes as Array<Record<string, unknown>>) || []).filter((n) => n.kind === 'dispose_region');
443
+ if (!nodes.length) fail('plan missing dispose_region nodes');
444
+ for (const d of nodes) {
445
+ if (d.region == null) fail(`dispose_region missing region: ${JSON.stringify(d)}`);
446
+ }
447
+ }
448
+ if (expect.disposeTag != null) {
449
+ const nodes = ((plan?.nodes as Array<Record<string, unknown>>) || []).filter((n) => n.kind === 'dispose_region');
450
+ if (!nodes.some((n) => n.tag === expect.disposeTag)) {
451
+ fail(`dispose_region missing tag=${expect.disposeTag}: ${JSON.stringify(nodes)}`);
452
+ }
453
+ }
454
+ continue;
455
+ }
456
+
457
+ if (kind === 'deployment') {
458
+ const deployment = (unit.deployment as Record<string, unknown> | null) || null;
459
+ const entries =
460
+ (deployment?.resume_entries as Array<Record<string, unknown>>) ||
461
+ (deployment?.resumeEntries as Array<Record<string, unknown>>) ||
462
+ [];
463
+ if (expect.resumeComponent != null) {
464
+ const name = String(expect.resumeComponent);
465
+ const hit = entries.find((e) => (e.component || e.Component) === name);
466
+ if (!hit) {
467
+ fail(`resume entry missing ${name}: ${JSON.stringify(entries)}`);
468
+ } else if (expect.strategy != null && (hit.strategy || '') !== expect.strategy) {
469
+ fail(`resume strategy want ${expect.strategy}, got ${hit.strategy}`);
470
+ }
471
+ }
472
+ if (expect.deploymentFileSchema != null) {
473
+ const depPath = path.join(ctx.outDir, 'vmz-deployment.json');
474
+ if (!fs.existsSync(depPath)) {
475
+ fail('missing vmz-deployment.json');
476
+ } else {
477
+ const deploy = JSON.parse(fs.readFileSync(depPath, 'utf8'));
478
+ if (deploy.schema !== expect.deploymentFileSchema) {
479
+ fail(`deployment schema want ${expect.deploymentFileSchema}, got ${deploy.schema}`);
480
+ }
481
+ if (expect.deploymentResumeComponent != null) {
482
+ const name = String(expect.deploymentResumeComponent);
483
+ const unitsDep = (deploy.units as Array<Record<string, unknown>>) || [];
484
+ const chunk = unitsDep.find((u) => String(u.chunkId || '').includes(String(expect.deploymentChunkIncludes || chunkId)));
485
+ const resumes = (chunk?.resumeEntries as Array<Record<string, unknown>>) || [];
486
+ if (!resumes.some((e) => e.component === name)) {
487
+ fail(`deployment resumeEntries missing ${name}: ${JSON.stringify(resumes)}`);
488
+ }
489
+ }
490
+ }
491
+ }
492
+ continue;
493
+ }
494
+
495
+ if (kind === 'diagnostic') {
496
+ continue;
497
+ }
498
+
499
+ fail(`unknown assertion kind ${JSON.stringify(kind)}`);
500
+ }
501
+
502
+ const failed = diagnostics.some((d) => d.severity === 'error');
503
+ return {
504
+ status: failed ? 'failed' : 'passed',
505
+ diagnostics,
506
+ planId,
507
+ programId,
508
+ };
509
+ }