@synap-core/workspace-templates 0.9.1 → 0.9.3

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/validate.ts CHANGED
@@ -14,8 +14,8 @@
14
14
  * • `scripts/check-converters.mjs` — injects the real converters, exits non-zero.
15
15
  * • CP / pod / CLI (from dist) — import `validateTemplate` + `SYSTEM_PROFILES`.
16
16
  *
17
- * ZERO RUNTIME IMPORTS BY DESIGN. `build.mjs` loads this file directly under
18
- * Node's type-stripping BEFORE `tsup` builds `dist/`. A static
17
+ * NO RUNTIME IMPORT THAT FAILS AT STRIP-TIME. `build.mjs` loads this file
18
+ * directly under Node's type-stripping BEFORE `tsup` builds `dist/`. A static
19
19
  * `import { toWorkspaceDefinition } from "./define.js"` would fail to resolve at
20
20
  * that moment (define → templates.js does not exist pre-build, and Node's
21
21
  * type-stripper does not remap a `.js` specifier to its `.ts` source). So the
@@ -23,6 +23,14 @@
23
23
  * YAML/bento rules run everywhere; the converter-parity rules run wherever the
24
24
  * caller can supply the compiled converters (check-converters.mjs, from dist).
25
25
  * `import type` lines are fully erased by the type-stripper, so they are safe.
26
+ *
27
+ * THE ONE RUNTIME IMPORT: `./validate-flow.ts` (the pure automation-flow
28
+ * validator). It is imported with the `.ts` extension SPECIFICALLY so it resolves
29
+ * under Node's type-stripper at build.mjs time (a `.js` specifier would NOT — see
30
+ * above), and it has NO generated dependency (unlike `./define.js` → templates)
31
+ * so it is always present. It is DB-free (only `import type` internally), so it
32
+ * needs no injection: the flow-node contract check runs for every caller
33
+ * (build / check-converters / CLI / CP) automatically.
26
34
  */
27
35
 
28
36
  import type {
@@ -31,6 +39,9 @@ import type {
31
39
  TemplateProfile,
32
40
  } from "./types.js";
33
41
 
42
+ // eslint-disable-next-line import/extensions -- .ts extension is intentional; see header.
43
+ import { validateFlowDefinition } from "./validate-flow.ts";
44
+
34
45
  // ── Result shape ─────────────────────────────────────────────────────────────
35
46
 
36
47
  export interface ValidationError {
@@ -271,12 +282,86 @@ export function validateTemplate(
271
282
  else {
272
283
  for (const f of REQUIRED_WORKSPACE_FIELDS) {
273
284
  if (!tpl.workspace[f])
274
- add("workspace-field-required", `workspace.${f}`, `workspace.${f} is required`);
285
+ add(
286
+ "workspace-field-required", `workspace.${f}`, `workspace.${f} is required`
287
+ );
275
288
  }
276
289
  }
277
290
  if (!tpl.profiles || tpl.profiles.length === 0) {
278
291
  add("profiles-required", "profiles", "At least one profile is required");
279
292
  }
293
+ if (
294
+ tpl.app &&
295
+ tpl.layout &&
296
+ Object.prototype.hasOwnProperty.call(tpl.layout, "primarySurface")
297
+ ) {
298
+ add(
299
+ "primary-surface-authoring-conflict",
300
+ "layout.primarySurface",
301
+ "Declare either top-level app or layout.primarySurface, not both"
302
+ );
303
+ }
304
+ if (tpl.app?.kind === "native" && !tpl.app.appId) {
305
+ add(
306
+ "app-native-id-required",
307
+ "app.appId",
308
+ "A native workspace app requires appId"
309
+ );
310
+ }
311
+ if (tpl.app?.kind === "url" && (!tpl.app.appId || !tpl.app.url)) {
312
+ add(
313
+ "app-url-identity-required",
314
+ "app",
315
+ "A hosted workspace app requires both appId and url"
316
+ );
317
+ }
318
+ if (tpl.app?.kind === "stored" && !tpl.app.cellKey) {
319
+ add(
320
+ "app-cell-key-required",
321
+ "app.cellKey",
322
+ "A stored workspace app requires cellKey"
323
+ );
324
+ }
325
+ const authoredPrimary = tpl.layout?.primarySurface;
326
+ if (authoredPrimary?.kind === "view" && !("viewId" in authoredPrimary)) {
327
+ const viewName = authoredPrimary.viewName;
328
+ const viewSlug = authoredPrimary.viewSlug;
329
+ if (!viewName && !viewSlug) {
330
+ add(
331
+ "primary-view-reference-required",
332
+ "layout.primarySurface",
333
+ "A view primary surface requires viewName or viewSlug"
334
+ );
335
+ } else {
336
+ const declaredViews = [
337
+ ...(tpl.views ?? []).map((view) => ({
338
+ name: view.name,
339
+ slug: view.slug,
340
+ })),
341
+ ...(tpl.bento?.viewName
342
+ ? [{ name: tpl.bento.viewName, slug: undefined }]
343
+ : []),
344
+ ];
345
+ const matches = declaredViews.filter(
346
+ (view) =>
347
+ (!viewName || view.name === viewName) &&
348
+ (!viewSlug || view.slug === viewSlug)
349
+ );
350
+ if (matches.length === 0) {
351
+ add(
352
+ "primary-view-reference-unknown",
353
+ "layout.primarySurface",
354
+ `Primary surface references an unknown view (${viewName ?? viewSlug})`
355
+ );
356
+ } else if (matches.length > 1) {
357
+ add(
358
+ "primary-view-reference-ambiguous",
359
+ "layout.primarySurface",
360
+ `Primary surface view reference matches ${matches.length} authored views; add a unique viewSlug`
361
+ );
362
+ }
363
+ }
364
+ }
280
365
 
281
366
  // ── Referential integrity: build the known-profile predicate ──────────────
282
367
  const baseProfileSlugs = collectBaseProfileSlugs(tpl, resolveDepFrom(opts));
@@ -371,11 +456,37 @@ export function validateTemplate(
371
456
  // SILENTLY at runtime.
372
457
  for (const a of tpl.automations || []) {
373
458
  const refs: Array<{ where: string; slug: string }> = [];
374
- collectProfileSlugRefs(a.triggerConfig, `automation "${a.name}".triggerConfig`, refs);
459
+ collectProfileSlugRefs(
460
+ a.triggerConfig, `automation "${a.name}".triggerConfig`,
461
+ refs
462
+ );
375
463
  collectProfileSlugRefs(a.flow, `automation "${a.name}".flow`, refs);
376
464
  for (const r of refs)
377
465
  if (!known(r.slug))
378
- add("automation-ref-unknown", r.where, `${r.where} references unknown profile "${r.slug}"`);
466
+ add(
467
+ "automation-ref-unknown", r.where, `${r.where} references unknown profile "${r.slug}"`
468
+ );
469
+
470
+ // Node-contract validation. Previously validate.ts checked automations for
471
+ // profileSlug refs ONLY, so a bogus node type / dangling edge / cycle / typo'd
472
+ // channelType / verb-less capability node passed the template gate green and
473
+ // then SILENTLY never fired at runtime (validateFlowDefinition only ran
474
+ // server-side at install). Run the SAME contract validator here, at author
475
+ // time. Catalog-existence checks are resolver-gated and skipped (no catalog at
476
+ // author time); the structural + per-node contract checks always run.
477
+ const flow = a.flow ?? (a as { flowDefinition?: unknown }).flowDefinition;
478
+ if (flow != null) {
479
+ const { errors: flowErrors
480
+ } = validateFlowDefinition(flow);
481
+ for (const fe of flowErrors) {
482
+ const loc = fe.nodeId
483
+ ? `automation "${a.name}".flow.node "${fe.nodeId}"`
484
+ : fe.edgeId
485
+ ? `automation "${a.name}".flow.edge "${fe.edgeId}"`
486
+ : `automation "${a.name}".flow`;
487
+ add(`automation-flow-${fe.code}`, loc, `${loc}: ${fe.message}`);
488
+ }
489
+ }
379
490
  }
380
491
  for (const pb of tpl.playbooks || []) {
381
492
  const ps = pb.subjectProfile?.profileSlug;
@@ -426,7 +537,9 @@ export function validateTemplate(
426
537
  // its base's workspace. The additive reconcile OVERWRITES workspace-level
427
538
  // settings, so an overlay MUST set workspace.subtype to its compose-base's slug
428
539
  // and MUST OMIT workspace.visibility — else it clobbers the base's identity.
429
- const composeDeps = (tpl.dependencies || []).filter((d) => d.relation === "compose");
540
+ const composeDeps = (tpl.dependencies || []).filter(
541
+ (d) => d.relation === "compose"
542
+ );
430
543
  for (const dep of composeDeps) {
431
544
  if (tpl.workspace?.visibility !== undefined) {
432
545
  add(
@@ -448,7 +561,9 @@ export function validateTemplate(
448
561
  const blocks: TemplateBentoBlock[] = tpl.bento?.blocks ?? [];
449
562
  const authoredWidgets = blocks.filter((b) => b.kind === "widget");
450
563
  const authoredViews = blocks.filter((b) => b.kind === "view");
451
- const unknownKind = blocks.filter((b) => b.kind !== "widget" && b.kind !== "view");
564
+ const unknownKind = blocks.filter(
565
+ (b) => b.kind !== "widget" && b.kind !== "view"
566
+ );
452
567
  if (unknownKind.length) {
453
568
  add(
454
569
  "bento-unknown-kind",
@@ -485,8 +600,14 @@ export function validateTemplate(
485
600
  const expectW = authoredWidgets.length - badWidgets.length;
486
601
  const expectV = authoredViews.length - badViews.length;
487
602
 
488
- const doors: Array<[string, Array<{ widgetType?: unknown }>, Array<{ viewName?: unknown; kind?: unknown }>]> = [
489
- ["tRPC createFromDefinition", definition.bentoLayout ?? [], definition.bentoViewBlocks ?? []],
603
+ const doors: Array<
604
+ [
605
+ string, Array<{ widgetType?: unknown }>, Array<{ viewName?: unknown; kind?: unknown }>,
606
+ ]
607
+ > = [
608
+ [
609
+ "tRPC createFromDefinition", definition.bentoLayout ?? [], definition.bentoViewBlocks ?? [],
610
+ ],
490
611
  ["CP packages/apply", pkg.bentoLayout ?? [], pkg.bentoViewBlocks ?? []],
491
612
  ];
492
613
  for (const [door, layout, viewBlocks] of doors) {
@@ -497,7 +618,9 @@ export function validateTemplate(
497
618
  `[${door}]: authored ${expectW} widget/${expectV} view → got ${layout.length} bentoLayout/${viewBlocks.length} bentoViewBlocks`
498
619
  );
499
620
  }
500
- const layoutBad = layout.filter((b) => typeof b.widgetType !== "string");
621
+ const layoutBad = layout.filter(
622
+ (b) => typeof b.widgetType !== "string"
623
+ );
501
624
  const viewsBad = viewBlocks.filter(
502
625
  (b) => typeof b.viewName !== "string" || b.kind !== "view"
503
626
  );