@synap-core/workspace-templates 0.7.2 → 0.7.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.
- package/dist/index.d.ts +207 -8
- package/dist/index.js +2122 -1427
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-fleet.yaml +666 -108
- package/src/blockchain-ecosystem.yaml +1 -0
- package/src/brand-library.yaml +127 -12
- package/src/builder.yaml +116 -12
- package/src/business-developer.yaml +102 -18
- package/src/catalog.ts +13 -3
- package/src/communication.yaml +23 -453
- package/src/composition-graph.test.ts +26 -0
- package/src/content-os.yaml +103 -10
- package/src/content-studio.yaml +1 -0
- package/src/crm.yaml +12 -0
- package/src/define.ts +8 -8
- package/src/dev-dashboard.yaml +43 -9
- package/src/domains.test.ts +117 -0
- package/src/domains.ts +102 -0
- package/src/ecosystem.yaml +21 -4
- package/src/enterprise-os.yaml +5 -0
- package/src/finance.yaml +46 -5
- package/src/foundation.yaml +13 -1
- package/src/grants.yaml +1 -0
- package/src/hr.yaml +61 -8
- package/src/index.ts +29 -0
- package/src/internal-runbook.yaml +36 -5
- package/src/legal.yaml +97 -12
- package/src/life-os.yaml +188 -27
- package/src/marketing.yaml +15 -2
- package/src/meetings-knowledge.yaml +7 -1
- package/src/operations.yaml +351 -0
- package/src/outreach-comms.yaml +6 -1
- package/src/project-management.yaml +840 -111
- package/src/research-base.yaml +516 -0
- package/src/research-decision.yaml +158 -0
- package/src/shared-role-ssot.test.ts +268 -0
- package/src/social.yaml +105 -37
- package/src/templates.ts +4573 -4302
- package/src/the-arch.yaml +11 -0
- package/src/types.ts +9 -0
- package/src/validate.test.ts +81 -0
- package/src/validate.ts +561 -0
- package/src/personal.yaml +0 -177
package/src/the-arch.yaml
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
meta:
|
|
23
23
|
slug: the-arch
|
|
24
|
+
domain: company
|
|
24
25
|
name: The Arch
|
|
25
26
|
description: The complete private company brain — the whole Enterprise OS plus the grant pipeline, the web3 ecosystem overlay, and the dev dashboard.
|
|
26
27
|
icon: landmark
|
|
@@ -108,6 +109,16 @@ views:
|
|
|
108
109
|
config:
|
|
109
110
|
groupBy: stage
|
|
110
111
|
|
|
112
|
+
# The Arch now OWNS the per-client comms digest capability. `client-comms-report`
|
|
113
|
+
# is the one capability no constituent template installs today — the portable,
|
|
114
|
+
# message-only client digest that any pod can run. The Arch, as the full company
|
|
115
|
+
# brain, is where it belongs: it rides in alongside the operations-native durable
|
|
116
|
+
# briefing so the founder has BOTH the entity-first record and the lightweight
|
|
117
|
+
# recap. (discord-bot / agency-skills / stellar-grant-client already ride in via
|
|
118
|
+
# the `grants` constituent's own integrations, so they are not re-declared here.)
|
|
119
|
+
integrations:
|
|
120
|
+
- templateKey: client-comms-report
|
|
121
|
+
|
|
111
122
|
onboarding:
|
|
112
123
|
goal: >
|
|
113
124
|
Name the cross-workspace initiatives that span the operating system and its
|
package/src/types.ts
CHANGED
|
@@ -524,6 +524,15 @@ export interface TemplateMeta {
|
|
|
524
524
|
color: string;
|
|
525
525
|
tags?: string[];
|
|
526
526
|
isPublic?: boolean;
|
|
527
|
+
/**
|
|
528
|
+
* Human-facing subject area used to SECTION the marketplace (CLI `market
|
|
529
|
+
* list`, browser New Space + catalog). Authored here so grouping is a
|
|
530
|
+
* property of the template, read by every surface — never re-derived from a
|
|
531
|
+
* hardcoded slug→group map in a consumer. NOT `category` (that is the
|
|
532
|
+
* PACKAGE TYPE — `workspace`/`capability`/…). Optional and open: an unknown
|
|
533
|
+
* or missing value is grouped, never dropped — see `./domains.ts`.
|
|
534
|
+
*/
|
|
535
|
+
domain?: string;
|
|
527
536
|
}
|
|
528
537
|
|
|
529
538
|
export interface WorkspaceYaml {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests proving the extracted `validateTemplate` actually fires.
|
|
3
|
+
*
|
|
4
|
+
* The whole point of moving the rule logic out of build.mjs / check-converters.mjs
|
|
5
|
+
* into `src/validate.ts` is that the CLI, the CP, and the pod can call it. These
|
|
6
|
+
* tests exercise it directly (not through the build) with a deliberately-bad
|
|
7
|
+
* template and a clean one, so a regression that silently stops enforcing a rule
|
|
8
|
+
* is caught here rather than on a live pod.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { validateTemplate } from "./validate.js";
|
|
13
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
14
|
+
|
|
15
|
+
describe("validateTemplate — fires on a bad template", () => {
|
|
16
|
+
const bad = {
|
|
17
|
+
// meta.slug MISSING — required field.
|
|
18
|
+
meta: { name: "Bad", description: "d", icon: "x", color: "#000000" },
|
|
19
|
+
workspace: { name: "Bad", description: "d" },
|
|
20
|
+
profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
|
|
21
|
+
automations: [
|
|
22
|
+
{
|
|
23
|
+
name: "notify",
|
|
24
|
+
triggerType: "event",
|
|
25
|
+
// profileSlug at an author-chosen depth points at a profile that
|
|
26
|
+
// exists nowhere — own, system, or a dependency base.
|
|
27
|
+
triggerConfig: { config: { profileSlug: "does-not-exist" } },
|
|
28
|
+
flow: { nodes: [], edges: [] },
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
} as unknown as WorkspaceYaml;
|
|
32
|
+
|
|
33
|
+
const result = validateTemplate(bad, {});
|
|
34
|
+
|
|
35
|
+
it("returns ok:false with structured errors", () => {
|
|
36
|
+
expect(result.ok).toBe(false);
|
|
37
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
38
|
+
for (const e of result.errors) {
|
|
39
|
+
expect(typeof e.rule).toBe("string");
|
|
40
|
+
expect(typeof e.path).toBe("string");
|
|
41
|
+
expect(typeof e.message).toBe("string");
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("flags the missing required meta field", () => {
|
|
46
|
+
const missingSlug = result.errors.find(
|
|
47
|
+
(e) => e.rule === "meta-field-required" && e.path === "meta.slug"
|
|
48
|
+
);
|
|
49
|
+
expect(missingSlug, JSON.stringify(result.errors)).toBeDefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("flags the unknown profileSlug buried in an automation", () => {
|
|
53
|
+
const dangling = result.errors.find(
|
|
54
|
+
(e) => e.rule === "automation-ref-unknown" && e.message.includes("does-not-exist")
|
|
55
|
+
);
|
|
56
|
+
expect(dangling, JSON.stringify(result.errors)).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("validateTemplate — clean template", () => {
|
|
61
|
+
it("returns ok:true with no errors", () => {
|
|
62
|
+
const good = {
|
|
63
|
+
meta: {
|
|
64
|
+
slug: "good",
|
|
65
|
+
name: "Good",
|
|
66
|
+
description: "d",
|
|
67
|
+
icon: "x",
|
|
68
|
+
color: "#000000",
|
|
69
|
+
},
|
|
70
|
+
workspace: { name: "Good", description: "d" },
|
|
71
|
+
profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
|
|
72
|
+
// references a SYSTEM profile (task) — resolves without declaring it.
|
|
73
|
+
views: [
|
|
74
|
+
{ name: "Tasks", type: "table", scopeProfileSlug: "task" },
|
|
75
|
+
],
|
|
76
|
+
} as unknown as WorkspaceYaml;
|
|
77
|
+
|
|
78
|
+
const result = validateTemplate(good, {});
|
|
79
|
+
expect(result, JSON.stringify(result.errors)).toEqual({ ok: true, errors: [] });
|
|
80
|
+
});
|
|
81
|
+
});
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE importable template validator.
|
|
3
|
+
* ======================================
|
|
4
|
+
*
|
|
5
|
+
* Historically the only real per-template guards lived in build-only scripts
|
|
6
|
+
* OUTSIDE `src/` — `build.mjs`'s `validate()` and `scripts/check-converters.mjs`
|
|
7
|
+
* — so no other repo could call them. The CP publish path, the pod install/apply
|
|
8
|
+
* path, and the CLI all re-implemented (or skipped) these checks. This module
|
|
9
|
+
* moves the per-template rule logic into `src/`, as a PURE function that
|
|
10
|
+
* RETURNS structured results (never `process.exit`), so all four callers share
|
|
11
|
+
* ONE validator:
|
|
12
|
+
*
|
|
13
|
+
* • `build.mjs` — loops the corpus, exits non-zero on errors.
|
|
14
|
+
* • `scripts/check-converters.mjs` — injects the real converters, exits non-zero.
|
|
15
|
+
* • CP / pod / CLI (from dist) — import `validateTemplate` + `SYSTEM_PROFILES`.
|
|
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
|
|
19
|
+
* `import { toWorkspaceDefinition } from "./define.js"` would fail to resolve at
|
|
20
|
+
* that moment (define → templates.js does not exist pre-build, and Node's
|
|
21
|
+
* type-stripper does not remap a `.js` specifier to its `.ts` source). So the
|
|
22
|
+
* converters are INJECTED via `opts.converters` rather than imported: the pure
|
|
23
|
+
* YAML/bento rules run everywhere; the converter-parity rules run wherever the
|
|
24
|
+
* caller can supply the compiled converters (check-converters.mjs, from dist).
|
|
25
|
+
* `import type` lines are fully erased by the type-stripper, so they are safe.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type {
|
|
29
|
+
WorkspaceYaml,
|
|
30
|
+
TemplateBentoBlock,
|
|
31
|
+
TemplateProfile,
|
|
32
|
+
} from "./types.js";
|
|
33
|
+
|
|
34
|
+
// ── Result shape ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export interface ValidationError {
|
|
37
|
+
/** Stable machine code for the rule that fired (e.g. "profile-ref-unknown"). */
|
|
38
|
+
rule: string;
|
|
39
|
+
/** Dotted location inside the template, for error messages. */
|
|
40
|
+
path: string;
|
|
41
|
+
/** Human-readable description of the violation. */
|
|
42
|
+
message: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ValidateResult {
|
|
46
|
+
ok: boolean;
|
|
47
|
+
errors: ValidationError[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Structural view of the two converters, INJECTED (not imported) — see the file
|
|
52
|
+
* header. `check-converters.mjs` passes the real `toWorkspaceDefinition` /
|
|
53
|
+
* `toPackageDefinition` from `dist/`; their concrete types are structurally
|
|
54
|
+
* compatible with these minimal shapes.
|
|
55
|
+
*/
|
|
56
|
+
export interface TemplateConverters {
|
|
57
|
+
toWorkspaceDefinition: (tpl: WorkspaceYaml) => {
|
|
58
|
+
definition: {
|
|
59
|
+
bentoLayout?: Array<{ widgetType?: unknown }>;
|
|
60
|
+
bentoViewBlocks?: Array<{ viewName?: unknown; kind?: unknown }>;
|
|
61
|
+
bentoViewName?: string;
|
|
62
|
+
capabilities?: unknown[];
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
toPackageDefinition: (tpl: WorkspaceYaml) => {
|
|
66
|
+
bentoLayout?: Array<{ widgetType?: unknown }>;
|
|
67
|
+
bentoViewBlocks?: Array<{ viewName?: unknown; kind?: unknown }>;
|
|
68
|
+
bentoViewName?: string;
|
|
69
|
+
capabilities?: unknown[];
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ValidateOptions {
|
|
74
|
+
/**
|
|
75
|
+
* The whole corpus (parsed template data objects). Used to resolve a
|
|
76
|
+
* `compose`/`require` base's profile vocabulary. Keyed by `meta.slug`. Ignored
|
|
77
|
+
* when `resolveDep` is given.
|
|
78
|
+
*/
|
|
79
|
+
corpus?: WorkspaceYaml[];
|
|
80
|
+
/** Direct dependency resolver — supersedes `corpus` when provided. */
|
|
81
|
+
resolveDep?: (slug: string) => WorkspaceYaml | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Compiled converters. When present, the converter-parity rules run (bento
|
|
84
|
+
* union split, wire-field shape, bentoViewName + capabilities parity across
|
|
85
|
+
* both install doors). When absent, only the pure YAML/bento rules run.
|
|
86
|
+
*/
|
|
87
|
+
converters?: TemplateConverters;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Static rule data ─────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
// Types we validate against — kept in sync with types.ts.
|
|
93
|
+
export const REQUIRED_META_FIELDS = [
|
|
94
|
+
"slug",
|
|
95
|
+
"name",
|
|
96
|
+
"description",
|
|
97
|
+
"icon",
|
|
98
|
+
"color",
|
|
99
|
+
] as const;
|
|
100
|
+
export const REQUIRED_WORKSPACE_FIELDS = ["name", "description"] as const;
|
|
101
|
+
|
|
102
|
+
// Mirrors the backend's view-repository rule: every "structured" view (any type
|
|
103
|
+
// except whiteboard/mindmap/bento) MUST declare a scope, else workspace creation
|
|
104
|
+
// throws "scopeProfileIds is required for structured views".
|
|
105
|
+
const SCOPE_EXEMPT = new Set(["whiteboard", "mindmap", "bento"]);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* System profiles a template may reference without defining. This MUST mirror
|
|
109
|
+
* the profiles seeded by synap-backend `ensure-system-profiles.ts` (the
|
|
110
|
+
* `profiles` array) — that seeder is the ground truth for what exists on every
|
|
111
|
+
* pod. A slug present here but NOT seeded lets a dangling ref validate green (the
|
|
112
|
+
* old bug: `project`/`document` were listed here after both were removed from the
|
|
113
|
+
* seeder — `project` became a first-class table in migration 0151, `document`
|
|
114
|
+
* never existed as a profile). A slug seeded but MISSING here forces every
|
|
115
|
+
* template to re-declare it, which is the exact pressure that spawns vocabulary
|
|
116
|
+
* forks. Keep the two lists in lockstep.
|
|
117
|
+
*
|
|
118
|
+
* SINGLE SOURCE OF TRUTH: this set lived in build.mjs; it now lives HERE and
|
|
119
|
+
* build.mjs imports it, so there is one list to keep in lockstep with the seeder.
|
|
120
|
+
*/
|
|
121
|
+
export const SYSTEM_PROFILES = new Set([
|
|
122
|
+
// Core capture hierarchy
|
|
123
|
+
"note",
|
|
124
|
+
"task",
|
|
125
|
+
"event",
|
|
126
|
+
"bookmark",
|
|
127
|
+
"website",
|
|
128
|
+
"article",
|
|
129
|
+
// People / orgs / CRM
|
|
130
|
+
"person",
|
|
131
|
+
"contact",
|
|
132
|
+
"company",
|
|
133
|
+
"deal",
|
|
134
|
+
// Files + programmatic kinds
|
|
135
|
+
"file",
|
|
136
|
+
"anchor",
|
|
137
|
+
"signal_item",
|
|
138
|
+
// Knowledge-work flow
|
|
139
|
+
"decision",
|
|
140
|
+
"question",
|
|
141
|
+
"research",
|
|
142
|
+
"knowledge",
|
|
143
|
+
"user_observation",
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
// ── Dependency-vocabulary closure (compose vs require semantics) ─────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Only `scope: shared` is a pod-wide row visible outside its origin workspace.
|
|
150
|
+
*/
|
|
151
|
+
function isPodWideProfile(p: TemplateProfile): boolean {
|
|
152
|
+
return String(p.scope || "").toLowerCase() === "shared";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Profile slugs a template may reference by virtue of its dependencies.
|
|
157
|
+
*
|
|
158
|
+
* `compose` and `require` are NOT the same for what a consumer may reference:
|
|
159
|
+
* • `compose` MERGES the overlay into the base's ONE workspace, so the consumer
|
|
160
|
+
* legitimately references ALL of the base's profiles (workspace-scoped ones
|
|
161
|
+
* included) — that is the point of composing.
|
|
162
|
+
* • `require` creates a SEPARATE workspace (installed first). Only the base's
|
|
163
|
+
* POD-WIDE profiles (`scope: shared`; system profiles are handled separately)
|
|
164
|
+
* cross that boundary and are visible to the consumer. The base's
|
|
165
|
+
* `scope: WORKSPACE` profiles live in the base's OWN workspace and resolve
|
|
166
|
+
* against NOTHING when referenced across a `require` — that is the class of
|
|
167
|
+
* bug the social→content-os fork hit (a view/link/prop pointing at the base's
|
|
168
|
+
* workspace-scoped profile provisions dangling and throws at runtime).
|
|
169
|
+
*
|
|
170
|
+
* Resolved TRANSITIVELY (a base's own bases count too, each by its own relation).
|
|
171
|
+
* Without this the validator is per-file and a real overlay can only reference
|
|
172
|
+
* system profiles + its own (blocks e.g. communication composing on brand-library).
|
|
173
|
+
*
|
|
174
|
+
* Moved verbatim out of build.mjs (semantics unchanged); it now resolves bases
|
|
175
|
+
* through the injected `resolveDep` rather than a hand-built `dataBySlug` map, so
|
|
176
|
+
* the same closure serves every caller.
|
|
177
|
+
*/
|
|
178
|
+
export function collectBaseProfileSlugs(
|
|
179
|
+
data: WorkspaceYaml,
|
|
180
|
+
resolveDep: (slug: string) => WorkspaceYaml | undefined,
|
|
181
|
+
seen = new Set<string>()
|
|
182
|
+
): Set<string> {
|
|
183
|
+
const out = new Set<string>();
|
|
184
|
+
for (const dep of data.dependencies || []) {
|
|
185
|
+
if (dep.relation !== "compose" && dep.relation !== "require") continue;
|
|
186
|
+
if (seen.has(dep.slug)) continue;
|
|
187
|
+
seen.add(dep.slug);
|
|
188
|
+
const base = resolveDep(dep.slug);
|
|
189
|
+
if (!base) continue; // unresolved dep is caught by the corpus check, not here
|
|
190
|
+
// compose → all of the base's profiles; require → only its pod-wide ones.
|
|
191
|
+
const podWideOnly = dep.relation === "require";
|
|
192
|
+
for (const p of base.profiles || []) {
|
|
193
|
+
if (podWideOnly && !isPodWideProfile(p)) continue;
|
|
194
|
+
out.add(p.slug);
|
|
195
|
+
}
|
|
196
|
+
for (const s of collectBaseProfileSlugs(base, resolveDep, seen)) out.add(s);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Recursively collect every `profileSlug` string value under `node`, tagged with
|
|
203
|
+
* a dotted path for error messages. Used for `automations` (whose flow-node /
|
|
204
|
+
* trigger-config profile references live at author-chosen depths — e.g. an
|
|
205
|
+
* `entity_create` node keeps its target at `data.config.profileSlug`), so the
|
|
206
|
+
* validator covers ALL of them instead of one hard-coded field. Interpolated
|
|
207
|
+
* values (`{{...}}`) are runtime refs, not literal slugs — skipped.
|
|
208
|
+
*/
|
|
209
|
+
function collectProfileSlugRefs(
|
|
210
|
+
node: unknown,
|
|
211
|
+
path: string,
|
|
212
|
+
out: Array<{ where: string; slug: string }>
|
|
213
|
+
): void {
|
|
214
|
+
if (node == null || typeof node !== "object") return;
|
|
215
|
+
if (Array.isArray(node)) {
|
|
216
|
+
node.forEach((v, i) => collectProfileSlugRefs(v, `${path}[${i}]`, out));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
|
|
220
|
+
if (k === "profileSlug" && typeof v === "string" && !v.includes("{{")) {
|
|
221
|
+
out.push({ where: `${path}.profileSlug`, slug: v });
|
|
222
|
+
} else {
|
|
223
|
+
collectProfileSlugRefs(v, `${path}.${k}`, out);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function resolveDepFrom(
|
|
229
|
+
opts: ValidateOptions
|
|
230
|
+
): (slug: string) => WorkspaceYaml | undefined {
|
|
231
|
+
if (opts.resolveDep) return opts.resolveDep;
|
|
232
|
+
if (opts.corpus) {
|
|
233
|
+
const bySlug = new Map<string, WorkspaceYaml>();
|
|
234
|
+
for (const t of opts.corpus) {
|
|
235
|
+
const s = t.meta?.slug;
|
|
236
|
+
if (s) bySlug.set(s, t);
|
|
237
|
+
}
|
|
238
|
+
return (slug: string) => bySlug.get(slug);
|
|
239
|
+
}
|
|
240
|
+
return () => undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── The validator ────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Validate ONE template. Pure: returns `{ ok, errors }`, never exits. Covers the
|
|
247
|
+
* per-template rules formerly split across build.mjs `validate()` (required
|
|
248
|
+
* fields, ≥1 profile, structured-view-needs-scope, profileSlug referential
|
|
249
|
+
* integrity against `own ∪ SYSTEM_PROFILES ∪ dependency-vocab`, overlay safety,
|
|
250
|
+
* entityLink `type`) and check-converters.mjs (bento union split, widgetType /
|
|
251
|
+
* viewName present, bentoViewName + capabilities parity — the last group only
|
|
252
|
+
* when `opts.converters` is supplied).
|
|
253
|
+
*/
|
|
254
|
+
export function validateTemplate(
|
|
255
|
+
tpl: WorkspaceYaml,
|
|
256
|
+
opts: ValidateOptions = {}
|
|
257
|
+
): ValidateResult {
|
|
258
|
+
const errors: ValidationError[] = [];
|
|
259
|
+
const add = (rule: string, path: string, message: string) =>
|
|
260
|
+
errors.push({ rule, path, message });
|
|
261
|
+
|
|
262
|
+
// ── Required fields ────────────────────────────────────────────────────────
|
|
263
|
+
if (!tpl.meta) add("meta-required", "meta", "Missing required field: meta");
|
|
264
|
+
else {
|
|
265
|
+
for (const f of REQUIRED_META_FIELDS) {
|
|
266
|
+
if (!tpl.meta[f]) add("meta-field-required", `meta.${f}`, `meta.${f} is required`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (!tpl.workspace)
|
|
270
|
+
add("workspace-required", "workspace", "Missing required field: workspace");
|
|
271
|
+
else {
|
|
272
|
+
for (const f of REQUIRED_WORKSPACE_FIELDS) {
|
|
273
|
+
if (!tpl.workspace[f])
|
|
274
|
+
add("workspace-field-required", `workspace.${f}`, `workspace.${f} is required`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!tpl.profiles || tpl.profiles.length === 0) {
|
|
278
|
+
add("profiles-required", "profiles", "At least one profile is required");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Referential integrity: build the known-profile predicate ──────────────
|
|
282
|
+
const baseProfileSlugs = collectBaseProfileSlugs(tpl, resolveDepFrom(opts));
|
|
283
|
+
const slugs = new Set((tpl.profiles || []).map((p) => p.slug));
|
|
284
|
+
const known = (s: string) =>
|
|
285
|
+
slugs.has(s) || SYSTEM_PROFILES.has(s) || baseProfileSlugs.has(s);
|
|
286
|
+
|
|
287
|
+
// Views: structured views need a scope; scoped profiles must resolve.
|
|
288
|
+
for (const v of tpl.views || []) {
|
|
289
|
+
const type = String(v.type || "").toLowerCase();
|
|
290
|
+
const scoped =
|
|
291
|
+
v.scopeProfileSlug || (v.scopeProfileSlugs && v.scopeProfileSlugs.length);
|
|
292
|
+
if (!SCOPE_EXEMPT.has(type) && !scoped) {
|
|
293
|
+
add(
|
|
294
|
+
"view-missing-scope",
|
|
295
|
+
`views."${v.name}"`,
|
|
296
|
+
`view "${v.name}" (${type}) needs scopeProfileSlug(s) — structured views require a scope`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (v.scopeProfileSlug && !known(v.scopeProfileSlug))
|
|
300
|
+
add(
|
|
301
|
+
"view-scope-unknown",
|
|
302
|
+
`views."${v.name}".scopeProfileSlug`,
|
|
303
|
+
`view "${v.name}" scopes unknown profile "${v.scopeProfileSlug}"`
|
|
304
|
+
);
|
|
305
|
+
for (const s of v.scopeProfileSlugs || [])
|
|
306
|
+
if (!known(s))
|
|
307
|
+
add(
|
|
308
|
+
"view-scope-unknown",
|
|
309
|
+
`views."${v.name}".scopeProfileSlugs`,
|
|
310
|
+
`view "${v.name}" scopes unknown profile "${s}"`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Property target refs.
|
|
315
|
+
for (const p of tpl.profiles || []) {
|
|
316
|
+
for (const pr of p.properties || []) {
|
|
317
|
+
if (pr.targetProfileSlug && !known(pr.targetProfileSlug)) {
|
|
318
|
+
add(
|
|
319
|
+
"property-target-unknown",
|
|
320
|
+
`profiles.${p.slug}.${pr.slug}.targetProfileSlug`,
|
|
321
|
+
`property ${p.slug}.${pr.slug} targets unknown profile "${pr.targetProfileSlug}"`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Entity links: endpoints resolve + predicate `type` present.
|
|
328
|
+
for (const el of tpl.entityLinks || []) {
|
|
329
|
+
if (!known(el.sourceProfileSlug))
|
|
330
|
+
add(
|
|
331
|
+
"entitylink-source-unknown",
|
|
332
|
+
"entityLinks",
|
|
333
|
+
`entityLink source "${el.sourceProfileSlug}" is unknown`
|
|
334
|
+
);
|
|
335
|
+
if (!known(el.targetProfileSlug))
|
|
336
|
+
add(
|
|
337
|
+
"entitylink-target-unknown",
|
|
338
|
+
"entityLinks",
|
|
339
|
+
`entityLink target "${el.targetProfileSlug}" is unknown`
|
|
340
|
+
);
|
|
341
|
+
// The predicate field is `type` — a link with no `type` provisions a
|
|
342
|
+
// malformed relation at runtime; catch it here.
|
|
343
|
+
if (typeof el.type !== "string" || !el.type) {
|
|
344
|
+
add(
|
|
345
|
+
"entitylink-missing-type",
|
|
346
|
+
"entityLinks",
|
|
347
|
+
`entityLink ${el.sourceProfileSlug}→${el.targetProfileSlug} is missing its \`type\` (the predicate)`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (const c of tpl.onboarding?.collect || []) {
|
|
353
|
+
if (!known(c.profileSlug))
|
|
354
|
+
add(
|
|
355
|
+
"onboarding-ref-unknown",
|
|
356
|
+
"onboarding.collect",
|
|
357
|
+
`onboarding.collect references unknown profile "${c.profileSlug}"`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
for (const s of tpl.seedEntities || []) {
|
|
361
|
+
if (!known(s.profileSlug))
|
|
362
|
+
add(
|
|
363
|
+
"seedentity-ref-unknown",
|
|
364
|
+
"seedEntities",
|
|
365
|
+
`seedEntity references unknown profile "${s.profileSlug}"`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// profileSlug referential integrity, part 2 — every OTHER site that names a
|
|
370
|
+
// profile by slug, validated against the same closure. A bogus slug here fails
|
|
371
|
+
// SILENTLY at runtime.
|
|
372
|
+
for (const a of tpl.automations || []) {
|
|
373
|
+
const refs: Array<{ where: string; slug: string }> = [];
|
|
374
|
+
collectProfileSlugRefs(a.triggerConfig, `automation "${a.name}".triggerConfig`, refs);
|
|
375
|
+
collectProfileSlugRefs(a.flow, `automation "${a.name}".flow`, refs);
|
|
376
|
+
for (const r of refs)
|
|
377
|
+
if (!known(r.slug))
|
|
378
|
+
add("automation-ref-unknown", r.where, `${r.where} references unknown profile "${r.slug}"`);
|
|
379
|
+
}
|
|
380
|
+
for (const pb of tpl.playbooks || []) {
|
|
381
|
+
const ps = pb.subjectProfile?.profileSlug;
|
|
382
|
+
if (ps && !known(ps))
|
|
383
|
+
add(
|
|
384
|
+
"playbook-ref-unknown",
|
|
385
|
+
`playbooks."${pb.name}".subjectProfile`,
|
|
386
|
+
`playbook "${pb.name}".subjectProfile references unknown profile "${ps}"`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
for (const ap of tpl.actionPlacements || []) {
|
|
390
|
+
if (ap.profileSlug && !known(ap.profileSlug)) {
|
|
391
|
+
add(
|
|
392
|
+
"actionplacement-ref-unknown",
|
|
393
|
+
"actionPlacements",
|
|
394
|
+
`actionPlacement "${ap.label ?? ap.ref}" references unknown profile "${ap.profileSlug}"`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (const si of tpl.layout?.sidebarItems || []) {
|
|
399
|
+
if (si.profileSlug && !known(si.profileSlug)) {
|
|
400
|
+
add(
|
|
401
|
+
"sidebar-ref-unknown",
|
|
402
|
+
"layout.sidebarItems",
|
|
403
|
+
`layout.sidebarItems ("${si.label ?? si.profileSlug}") references unknown profile "${si.profileSlug}"`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
for (const dt of tpl.displayTemplates || []) {
|
|
408
|
+
if (dt.entityType && !known(dt.entityType)) {
|
|
409
|
+
add(
|
|
410
|
+
"displaytemplate-ref-unknown",
|
|
411
|
+
`displayTemplates."${dt.name}".entityType`,
|
|
412
|
+
`displayTemplate "${dt.name}" targets unknown profile "${dt.entityType}" (entityType)`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
for (const key of Object.keys(tpl.profileEntityBentoTemplates || {})) {
|
|
417
|
+
if (!known(key))
|
|
418
|
+
add(
|
|
419
|
+
"bentotemplate-ref-unknown",
|
|
420
|
+
"profileEntityBentoTemplates",
|
|
421
|
+
`profileEntityBentoTemplates has an entry for unknown profile "${key}"`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── Overlay-safety: an overlay (compose relation) is layered additively onto
|
|
426
|
+
// its base's workspace. The additive reconcile OVERWRITES workspace-level
|
|
427
|
+
// settings, so an overlay MUST set workspace.subtype to its compose-base's slug
|
|
428
|
+
// and MUST OMIT workspace.visibility — else it clobbers the base's identity.
|
|
429
|
+
const composeDeps = (tpl.dependencies || []).filter((d) => d.relation === "compose");
|
|
430
|
+
for (const dep of composeDeps) {
|
|
431
|
+
if (tpl.workspace?.visibility !== undefined) {
|
|
432
|
+
add(
|
|
433
|
+
"overlay-visibility",
|
|
434
|
+
"workspace.visibility",
|
|
435
|
+
`overlay (compose on "${dep.slug}") must OMIT workspace.visibility — it overwrites the base's on additive reconcile`
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (tpl.workspace?.subtype !== dep.slug) {
|
|
439
|
+
add(
|
|
440
|
+
"overlay-subtype",
|
|
441
|
+
"workspace.subtype",
|
|
442
|
+
`overlay (compose on "${dep.slug}") must set workspace.subtype to "${dep.slug}" (found "${tpl.workspace?.subtype}")`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── Bento union: pure checks (no converters needed) ───────────────────────
|
|
448
|
+
const blocks: TemplateBentoBlock[] = tpl.bento?.blocks ?? [];
|
|
449
|
+
const authoredWidgets = blocks.filter((b) => b.kind === "widget");
|
|
450
|
+
const authoredViews = blocks.filter((b) => b.kind === "view");
|
|
451
|
+
const unknownKind = blocks.filter((b) => b.kind !== "widget" && b.kind !== "view");
|
|
452
|
+
if (unknownKind.length) {
|
|
453
|
+
add(
|
|
454
|
+
"bento-unknown-kind",
|
|
455
|
+
"bento.blocks",
|
|
456
|
+
`${unknownKind.length} bento block(s) with unknown kind: ${unknownKind
|
|
457
|
+
.map((b) => String(b.kind))
|
|
458
|
+
.join(", ")}`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
const badWidgets = authoredWidgets.filter((b) => !b.widgetType);
|
|
462
|
+
const badViews = authoredViews.filter((b) => !b.viewName);
|
|
463
|
+
if (badWidgets.length) {
|
|
464
|
+
add(
|
|
465
|
+
"bento-widget-missing-type",
|
|
466
|
+
"bento.blocks",
|
|
467
|
+
`${badWidgets.length} widget block(s) missing widgetType`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
if (badViews.length) {
|
|
471
|
+
add(
|
|
472
|
+
"bento-view-missing-name",
|
|
473
|
+
"bento.blocks",
|
|
474
|
+
`${badViews.length} view block(s) missing viewName`
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── Converter parity: only when the compiled converters are injected ──────
|
|
479
|
+
if (opts.converters) {
|
|
480
|
+
const { toWorkspaceDefinition, toPackageDefinition } = opts.converters;
|
|
481
|
+
const { definition } = toWorkspaceDefinition(tpl);
|
|
482
|
+
const pkg = toPackageDefinition(tpl);
|
|
483
|
+
|
|
484
|
+
if (blocks.length > 0) {
|
|
485
|
+
const expectW = authoredWidgets.length - badWidgets.length;
|
|
486
|
+
const expectV = authoredViews.length - badViews.length;
|
|
487
|
+
|
|
488
|
+
const doors: Array<[string, Array<{ widgetType?: unknown }>, Array<{ viewName?: unknown; kind?: unknown }>]> = [
|
|
489
|
+
["tRPC createFromDefinition", definition.bentoLayout ?? [], definition.bentoViewBlocks ?? []],
|
|
490
|
+
["CP packages/apply", pkg.bentoLayout ?? [], pkg.bentoViewBlocks ?? []],
|
|
491
|
+
];
|
|
492
|
+
for (const [door, layout, viewBlocks] of doors) {
|
|
493
|
+
if (layout.length !== expectW || viewBlocks.length !== expectV) {
|
|
494
|
+
add(
|
|
495
|
+
"bento-split-count",
|
|
496
|
+
"bento.blocks",
|
|
497
|
+
`[${door}]: authored ${expectW} widget/${expectV} view → got ${layout.length} bentoLayout/${viewBlocks.length} bentoViewBlocks`
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const layoutBad = layout.filter((b) => typeof b.widgetType !== "string");
|
|
501
|
+
const viewsBad = viewBlocks.filter(
|
|
502
|
+
(b) => typeof b.viewName !== "string" || b.kind !== "view"
|
|
503
|
+
);
|
|
504
|
+
if (layoutBad.length) {
|
|
505
|
+
add(
|
|
506
|
+
"bento-layout-shape",
|
|
507
|
+
"bento.blocks",
|
|
508
|
+
`[${door}]: ${layoutBad.length} bentoLayout block(s) without a string widgetType (mis-routed view block?)`
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
if (viewsBad.length) {
|
|
512
|
+
add(
|
|
513
|
+
"bento-viewblocks-shape",
|
|
514
|
+
"bento.blocks",
|
|
515
|
+
`[${door}]: ${viewsBad.length} bentoViewBlocks entry(s) without kind:'view' + string viewName (mis-routed widget block?)`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// bentoViewName parity — both converters MUST carry the authored bento.viewName.
|
|
522
|
+
const authoredViewName = tpl.bento?.viewName;
|
|
523
|
+
if (authoredViewName) {
|
|
524
|
+
if (definition.bentoViewName !== authoredViewName) {
|
|
525
|
+
add(
|
|
526
|
+
"bento-viewname-parity",
|
|
527
|
+
"bento.viewName",
|
|
528
|
+
`[tRPC]: authored bento.viewName "${authoredViewName}" → got "${definition.bentoViewName}"`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
if (pkg.bentoViewName !== authoredViewName) {
|
|
532
|
+
add(
|
|
533
|
+
"bento-viewname-parity",
|
|
534
|
+
"bento.viewName",
|
|
535
|
+
`[CP]: authored bento.viewName "${authoredViewName}" → got "${pkg.bentoViewName}"`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Capabilities parity — both doors emit integrations → capabilities.
|
|
541
|
+
const authored = tpl.integrations ?? [];
|
|
542
|
+
const wsCaps = definition.capabilities ?? [];
|
|
543
|
+
const pkgCaps = pkg.capabilities ?? [];
|
|
544
|
+
if (wsCaps.length !== authored.length) {
|
|
545
|
+
add(
|
|
546
|
+
"capabilities-count",
|
|
547
|
+
"integrations",
|
|
548
|
+
`[tRPC]: authored ${authored.length} integration(s) → got ${wsCaps.length} capabilities`
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
if (JSON.stringify(wsCaps) !== JSON.stringify(pkgCaps)) {
|
|
552
|
+
add(
|
|
553
|
+
"capabilities-parity",
|
|
554
|
+
"integrations",
|
|
555
|
+
`capability parity mismatch — tRPC door and CP door emit different capabilities`
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return { ok: errors.length === 0, errors };
|
|
561
|
+
}
|