@synap-core/workspace-templates 0.9.2 → 0.9.4

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.
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { validateTemplate } from "./validate.js";
3
+ import type { WorkspaceYaml } from "./types.js";
4
+
5
+ function templateWithOnboarding(onboarding: unknown): WorkspaceYaml {
6
+ return {
7
+ meta: {
8
+ slug: "onboarding-validation",
9
+ name: "Onboarding validation",
10
+ description: "Validation fixture",
11
+ icon: "circle",
12
+ color: "blue",
13
+ },
14
+ workspace: {
15
+ name: "Onboarding validation",
16
+ description: "Validation fixture",
17
+ },
18
+ profiles: [
19
+ {
20
+ slug: "record",
21
+ displayName: "Record",
22
+ properties: [],
23
+ },
24
+ ],
25
+ onboarding,
26
+ } as WorkspaceYaml;
27
+ }
28
+
29
+ describe("validateTemplate onboarding contract", () => {
30
+ it("accepts a complete domain recipe", () => {
31
+ const result = validateTemplate(
32
+ templateWithOnboarding({
33
+ goal: "Create the first useful record.",
34
+ framing: "Act as a domain expert.",
35
+ expertise: {
36
+ starters: ["Begin with the live work."],
37
+ blindSpots: ["Do not collect records without an owner."],
38
+ bar: "Every record has a clear next step.",
39
+ },
40
+ collect: [
41
+ {
42
+ profileSlug: "record",
43
+ what: "The records currently in motion.",
44
+ cardinality: "few",
45
+ keyFields: ["title"],
46
+ },
47
+ ],
48
+ openingQuestions: ["What is currently in motion?"],
49
+ doneWhen: "At least one useful record exists.",
50
+ }),
51
+ {}
52
+ );
53
+
54
+ expect(result, JSON.stringify(result.errors)).toEqual({
55
+ ok: true,
56
+ errors: [],
57
+ });
58
+ });
59
+
60
+ it("rejects empty expertise prose and collection instructions", () => {
61
+ const result = validateTemplate(
62
+ templateWithOnboarding({
63
+ goal: "Set up this workspace.",
64
+ framing: "Act as a domain expert.",
65
+ expertise: {
66
+ starters: [""],
67
+ blindSpots: [" "],
68
+ bar: "",
69
+ },
70
+ collect: [{ profileSlug: "record", what: "", keyFields: [""] }],
71
+ openingQuestions: [""],
72
+ doneWhen: "",
73
+ }),
74
+ {}
75
+ );
76
+
77
+ expect(result.ok).toBe(false);
78
+ expect(result.errors.map((error) => error.rule)).toEqual(
79
+ expect.arrayContaining([
80
+ "onboarding-expertise-invalid",
81
+ "onboarding-collect-what-empty",
82
+ "onboarding-collect-key-fields-invalid",
83
+ "onboarding-opening-questions-invalid",
84
+ "onboarding-done-when-invalid",
85
+ ])
86
+ );
87
+ });
88
+ });
@@ -79,3 +79,124 @@ describe("validateTemplate — clean template", () => {
79
79
  expect(result, JSON.stringify(result.errors)).toEqual({ ok: true, errors: [] });
80
80
  });
81
81
  });
82
+
83
+ // Regression: before wiring `validateFlowDefinition` into validateTemplate, a
84
+ // template automation with a broken FLOW (bogus node type / dangling edge / cycle
85
+ // / typo'd channelType) passed the template gate green and then silently never
86
+ // fired on a live pod — the flow contract only ran server-side at install.
87
+ describe("validateTemplate — automation flow node contract", () => {
88
+ const withAutomation = (flow: unknown): WorkspaceYaml =>
89
+ ({
90
+ meta: {
91
+ slug: "flowy",
92
+ name: "Flowy",
93
+ description: "d",
94
+ icon: "x",
95
+ color: "#000000",
96
+ },
97
+ workspace: { name: "Flowy", description: "d" },
98
+ profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
99
+ automations: [
100
+ { name: "on-create", triggerType: "event", triggerConfig: {}, flow },
101
+ ],
102
+ }) as unknown as WorkspaceYaml;
103
+
104
+ it("FAILS on an unknown node type (was green before)", () => {
105
+ const result = validateTemplate(
106
+ withAutomation({
107
+ nodes: [
108
+ { id: "t1", type: "trigger", data: {} },
109
+ { id: "x1", type: "not_a_real_node", data: {} },
110
+ ],
111
+ edges: [{ id: "e1", source: "t1", target: "x1" }],
112
+ }),
113
+ {}
114
+ );
115
+ expect(result.ok).toBe(false);
116
+ const err = result.errors.find(
117
+ (e) => e.rule === "automation-flow-unknown_node_type"
118
+ );
119
+ expect(err, JSON.stringify(result.errors)).toBeDefined();
120
+ expect(err!.message).toContain("not_a_real_node");
121
+ });
122
+
123
+ it("FAILS on a dangling edge (target references no node)", () => {
124
+ const result = validateTemplate(
125
+ withAutomation({
126
+ nodes: [{ id: "t1", type: "trigger", data: {} }],
127
+ edges: [{ id: "e1", source: "t1", target: "ghost" }],
128
+ }),
129
+ {}
130
+ );
131
+ expect(result.ok).toBe(false);
132
+ const err = result.errors.find(
133
+ (e) => e.rule === "automation-flow-edge_bad_target"
134
+ );
135
+ expect(err, JSON.stringify(result.errors)).toBeDefined();
136
+ });
137
+
138
+ it("FAILS on a capability node with no verbId", () => {
139
+ const result = validateTemplate(
140
+ withAutomation({
141
+ nodes: [
142
+ { id: "t1", type: "trigger", data: {} },
143
+ { id: "c1", type: "capability", data: {} },
144
+ ],
145
+ edges: [{ id: "e1", source: "t1", target: "c1" }],
146
+ }),
147
+ {}
148
+ );
149
+ expect(result.ok).toBe(false);
150
+ expect(
151
+ result.errors.find(
152
+ (e) => e.rule === "automation-flow-capability_missing_verbId"
153
+ ),
154
+ JSON.stringify(result.errors)
155
+ ).toBeDefined();
156
+ });
157
+
158
+ it("FAILS on a cycle", () => {
159
+ const result = validateTemplate(
160
+ withAutomation({
161
+ nodes: [
162
+ { id: "a", type: "transform", data: { expression: "{{x}}" } },
163
+ { id: "b", type: "transform", data: { expression: "{{y}}" } },
164
+ ],
165
+ edges: [
166
+ { id: "e1", source: "a", target: "b" },
167
+ { id: "e2", source: "b", target: "a" },
168
+ ],
169
+ }),
170
+ {}
171
+ );
172
+ expect(result.ok).toBe(false);
173
+ expect(
174
+ result.errors.find((e) => e.rule === "automation-flow-flow_has_cycle"),
175
+ JSON.stringify(result.errors)
176
+ ).toBeDefined();
177
+ });
178
+
179
+ it("PASSES a well-formed flow (trigger → notification output)", () => {
180
+ const result = validateTemplate(
181
+ withAutomation({
182
+ nodes: [
183
+ { id: "t1", type: "trigger", data: { triggerType: "event" } },
184
+ {
185
+ id: "o1",
186
+ type: "output",
187
+ data: {
188
+ outputType: "notification",
189
+ config: { title: "Hi", body: "{{trigger.payload.data.title}}" },
190
+ },
191
+ },
192
+ ],
193
+ edges: [{ id: "e1", source: "t1", target: "o1" }],
194
+ }),
195
+ {}
196
+ );
197
+ expect(result, JSON.stringify(result.errors)).toEqual({
198
+ ok: true,
199
+ errors: [],
200
+ });
201
+ });
202
+ });
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 {
@@ -263,7 +274,8 @@ export function validateTemplate(
263
274
  if (!tpl.meta) add("meta-required", "meta", "Missing required field: meta");
264
275
  else {
265
276
  for (const f of REQUIRED_META_FIELDS) {
266
- if (!tpl.meta[f]) add("meta-field-required", `meta.${f}`, `meta.${f} is required`);
277
+ if (!tpl.meta[f])
278
+ add("meta-field-required", `meta.${f}`, `meta.${f} is required`);
267
279
  }
268
280
  }
269
281
  if (!tpl.workspace)
@@ -271,12 +283,88 @@ export function validateTemplate(
271
283
  else {
272
284
  for (const f of REQUIRED_WORKSPACE_FIELDS) {
273
285
  if (!tpl.workspace[f])
274
- add("workspace-field-required", `workspace.${f}`, `workspace.${f} is required`);
286
+ add(
287
+ "workspace-field-required",
288
+ `workspace.${f}`,
289
+ `workspace.${f} is required`
290
+ );
275
291
  }
276
292
  }
277
293
  if (!tpl.profiles || tpl.profiles.length === 0) {
278
294
  add("profiles-required", "profiles", "At least one profile is required");
279
295
  }
296
+ if (
297
+ tpl.app &&
298
+ tpl.layout &&
299
+ Object.prototype.hasOwnProperty.call(tpl.layout, "primarySurface")
300
+ ) {
301
+ add(
302
+ "primary-surface-authoring-conflict",
303
+ "layout.primarySurface",
304
+ "Declare either top-level app or layout.primarySurface, not both"
305
+ );
306
+ }
307
+ if (tpl.app?.kind === "native" && !tpl.app.appId) {
308
+ add(
309
+ "app-native-id-required",
310
+ "app.appId",
311
+ "A native workspace app requires appId"
312
+ );
313
+ }
314
+ if (tpl.app?.kind === "url" && (!tpl.app.appId || !tpl.app.url)) {
315
+ add(
316
+ "app-url-identity-required",
317
+ "app",
318
+ "A hosted workspace app requires both appId and url"
319
+ );
320
+ }
321
+ if (tpl.app?.kind === "stored" && !tpl.app.cellKey) {
322
+ add(
323
+ "app-cell-key-required",
324
+ "app.cellKey",
325
+ "A stored workspace app requires cellKey"
326
+ );
327
+ }
328
+ const authoredPrimary = tpl.layout?.primarySurface;
329
+ if (authoredPrimary?.kind === "view" && !("viewId" in authoredPrimary)) {
330
+ const viewName = authoredPrimary.viewName;
331
+ const viewSlug = authoredPrimary.viewSlug;
332
+ if (!viewName && !viewSlug) {
333
+ add(
334
+ "primary-view-reference-required",
335
+ "layout.primarySurface",
336
+ "A view primary surface requires viewName or viewSlug"
337
+ );
338
+ } else {
339
+ const declaredViews = [
340
+ ...(tpl.views ?? []).map((view) => ({
341
+ name: view.name,
342
+ slug: view.slug,
343
+ })),
344
+ ...(tpl.bento?.viewName
345
+ ? [{ name: tpl.bento.viewName, slug: undefined }]
346
+ : []),
347
+ ];
348
+ const matches = declaredViews.filter(
349
+ (view) =>
350
+ (!viewName || view.name === viewName) &&
351
+ (!viewSlug || view.slug === viewSlug)
352
+ );
353
+ if (matches.length === 0) {
354
+ add(
355
+ "primary-view-reference-unknown",
356
+ "layout.primarySurface",
357
+ `Primary surface references an unknown view (${viewName ?? viewSlug})`
358
+ );
359
+ } else if (matches.length > 1) {
360
+ add(
361
+ "primary-view-reference-ambiguous",
362
+ "layout.primarySurface",
363
+ `Primary surface view reference matches ${matches.length} authored views; add a unique viewSlug`
364
+ );
365
+ }
366
+ }
367
+ }
280
368
 
281
369
  // ── Referential integrity: build the known-profile predicate ──────────────
282
370
  const baseProfileSlugs = collectBaseProfileSlugs(tpl, resolveDepFrom(opts));
@@ -349,7 +437,110 @@ export function validateTemplate(
349
437
  }
350
438
  }
351
439
 
352
- for (const c of tpl.onboarding?.collect || []) {
440
+ const onboarding = tpl.onboarding;
441
+ if (onboarding) {
442
+ if (typeof onboarding.goal !== "string" || onboarding.goal.trim() === "") {
443
+ add(
444
+ "onboarding-goal-empty",
445
+ "onboarding.goal",
446
+ "onboarding.goal must be a non-empty string"
447
+ );
448
+ }
449
+ if (
450
+ typeof onboarding.framing !== "string" ||
451
+ onboarding.framing.trim() === ""
452
+ ) {
453
+ add(
454
+ "onboarding-framing-empty",
455
+ "onboarding.framing",
456
+ "onboarding.framing must be a non-empty string"
457
+ );
458
+ }
459
+ if (!Array.isArray(onboarding.collect) || onboarding.collect.length === 0) {
460
+ add(
461
+ "onboarding-collect-empty",
462
+ "onboarding.collect",
463
+ "onboarding.collect must contain at least one structured target"
464
+ );
465
+ }
466
+
467
+ const expertise = onboarding.expertise;
468
+ for (const [field, values] of [
469
+ ["starters", expertise?.starters],
470
+ ["blindSpots", expertise?.blindSpots],
471
+ ] as const) {
472
+ if (
473
+ values !== undefined &&
474
+ (!Array.isArray(values) ||
475
+ values.some(
476
+ (value) => typeof value !== "string" || value.trim() === ""
477
+ ))
478
+ ) {
479
+ add(
480
+ "onboarding-expertise-invalid",
481
+ `onboarding.expertise.${field}`,
482
+ `onboarding.expertise.${field} must contain only non-empty strings`
483
+ );
484
+ }
485
+ }
486
+ if (
487
+ expertise?.bar !== undefined &&
488
+ (typeof expertise.bar !== "string" || expertise.bar.trim() === "")
489
+ ) {
490
+ add(
491
+ "onboarding-expertise-invalid",
492
+ "onboarding.expertise.bar",
493
+ "onboarding.expertise.bar must be a non-empty string"
494
+ );
495
+ }
496
+
497
+ if (
498
+ onboarding.openingQuestions !== undefined &&
499
+ (!Array.isArray(onboarding.openingQuestions) ||
500
+ onboarding.openingQuestions.some(
501
+ (question) => typeof question !== "string" || question.trim() === ""
502
+ ))
503
+ ) {
504
+ add(
505
+ "onboarding-opening-questions-invalid",
506
+ "onboarding.openingQuestions",
507
+ "onboarding.openingQuestions must contain only non-empty strings"
508
+ );
509
+ }
510
+ if (
511
+ onboarding.doneWhen !== undefined &&
512
+ (typeof onboarding.doneWhen !== "string" ||
513
+ onboarding.doneWhen.trim() === "")
514
+ ) {
515
+ add(
516
+ "onboarding-done-when-invalid",
517
+ "onboarding.doneWhen",
518
+ "onboarding.doneWhen must be a non-empty string"
519
+ );
520
+ }
521
+ }
522
+
523
+ for (const c of onboarding?.collect || []) {
524
+ if (typeof c.what !== "string" || c.what.trim() === "") {
525
+ add(
526
+ "onboarding-collect-what-empty",
527
+ `onboarding.collect.${c.profileSlug}.what`,
528
+ `onboarding.collect target "${c.profileSlug}" must describe what to capture`
529
+ );
530
+ }
531
+ if (
532
+ c.keyFields !== undefined &&
533
+ (!Array.isArray(c.keyFields) ||
534
+ c.keyFields.some(
535
+ (field) => typeof field !== "string" || field.trim() === ""
536
+ ))
537
+ ) {
538
+ add(
539
+ "onboarding-collect-key-fields-invalid",
540
+ `onboarding.collect.${c.profileSlug}.keyFields`,
541
+ `onboarding.collect target "${c.profileSlug}" keyFields must contain only non-empty strings`
542
+ );
543
+ }
353
544
  if (!known(c.profileSlug))
354
545
  add(
355
546
  "onboarding-ref-unknown",
@@ -371,11 +562,39 @@ export function validateTemplate(
371
562
  // SILENTLY at runtime.
372
563
  for (const a of tpl.automations || []) {
373
564
  const refs: Array<{ where: string; slug: string }> = [];
374
- collectProfileSlugRefs(a.triggerConfig, `automation "${a.name}".triggerConfig`, refs);
565
+ collectProfileSlugRefs(
566
+ a.triggerConfig,
567
+ `automation "${a.name}".triggerConfig`,
568
+ refs
569
+ );
375
570
  collectProfileSlugRefs(a.flow, `automation "${a.name}".flow`, refs);
376
571
  for (const r of refs)
377
572
  if (!known(r.slug))
378
- add("automation-ref-unknown", r.where, `${r.where} references unknown profile "${r.slug}"`);
573
+ add(
574
+ "automation-ref-unknown",
575
+ r.where,
576
+ `${r.where} references unknown profile "${r.slug}"`
577
+ );
578
+
579
+ // Node-contract validation. Previously validate.ts checked automations for
580
+ // profileSlug refs ONLY, so a bogus node type / dangling edge / cycle / typo'd
581
+ // channelType / verb-less capability node passed the template gate green and
582
+ // then SILENTLY never fired at runtime (validateFlowDefinition only ran
583
+ // server-side at install). Run the SAME contract validator here, at author
584
+ // time. Catalog-existence checks are resolver-gated and skipped (no catalog at
585
+ // author time); the structural + per-node contract checks always run.
586
+ const flow = a.flow ?? (a as { flowDefinition?: unknown }).flowDefinition;
587
+ if (flow != null) {
588
+ const { errors: flowErrors } = validateFlowDefinition(flow);
589
+ for (const fe of flowErrors) {
590
+ const loc = fe.nodeId
591
+ ? `automation "${a.name}".flow.node "${fe.nodeId}"`
592
+ : fe.edgeId
593
+ ? `automation "${a.name}".flow.edge "${fe.edgeId}"`
594
+ : `automation "${a.name}".flow`;
595
+ add(`automation-flow-${fe.code}`, loc, `${loc}: ${fe.message}`);
596
+ }
597
+ }
379
598
  }
380
599
  for (const pb of tpl.playbooks || []) {
381
600
  const ps = pb.subjectProfile?.profileSlug;
@@ -426,7 +645,9 @@ export function validateTemplate(
426
645
  // its base's workspace. The additive reconcile OVERWRITES workspace-level
427
646
  // settings, so an overlay MUST set workspace.subtype to its compose-base's slug
428
647
  // and MUST OMIT workspace.visibility — else it clobbers the base's identity.
429
- const composeDeps = (tpl.dependencies || []).filter((d) => d.relation === "compose");
648
+ const composeDeps = (tpl.dependencies || []).filter(
649
+ (d) => d.relation === "compose"
650
+ );
430
651
  for (const dep of composeDeps) {
431
652
  if (tpl.workspace?.visibility !== undefined) {
432
653
  add(
@@ -448,7 +669,9 @@ export function validateTemplate(
448
669
  const blocks: TemplateBentoBlock[] = tpl.bento?.blocks ?? [];
449
670
  const authoredWidgets = blocks.filter((b) => b.kind === "widget");
450
671
  const authoredViews = blocks.filter((b) => b.kind === "view");
451
- const unknownKind = blocks.filter((b) => b.kind !== "widget" && b.kind !== "view");
672
+ const unknownKind = blocks.filter(
673
+ (b) => b.kind !== "widget" && b.kind !== "view"
674
+ );
452
675
  if (unknownKind.length) {
453
676
  add(
454
677
  "bento-unknown-kind",
@@ -485,8 +708,18 @@ export function validateTemplate(
485
708
  const expectW = authoredWidgets.length - badWidgets.length;
486
709
  const expectV = authoredViews.length - badViews.length;
487
710
 
488
- const doors: Array<[string, Array<{ widgetType?: unknown }>, Array<{ viewName?: unknown; kind?: unknown }>]> = [
489
- ["tRPC createFromDefinition", definition.bentoLayout ?? [], definition.bentoViewBlocks ?? []],
711
+ const doors: Array<
712
+ [
713
+ string,
714
+ Array<{ widgetType?: unknown }>,
715
+ Array<{ viewName?: unknown; kind?: unknown }>,
716
+ ]
717
+ > = [
718
+ [
719
+ "tRPC createFromDefinition",
720
+ definition.bentoLayout ?? [],
721
+ definition.bentoViewBlocks ?? [],
722
+ ],
490
723
  ["CP packages/apply", pkg.bentoLayout ?? [], pkg.bentoViewBlocks ?? []],
491
724
  ];
492
725
  for (const [door, layout, viewBlocks] of doors) {
@@ -497,7 +730,9 @@ export function validateTemplate(
497
730
  `[${door}]: authored ${expectW} widget/${expectV} view → got ${layout.length} bentoLayout/${viewBlocks.length} bentoViewBlocks`
498
731
  );
499
732
  }
500
- const layoutBad = layout.filter((b) => typeof b.widgetType !== "string");
733
+ const layoutBad = layout.filter(
734
+ (b) => typeof b.widgetType !== "string"
735
+ );
501
736
  const viewsBad = viewBlocks.filter(
502
737
  (b) => typeof b.viewName !== "string" || b.kind !== "view"
503
738
  );