@danypops/papyrus 0.41.0 → 0.42.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.
@@ -0,0 +1,273 @@
1
+ import {
2
+ SEED_RELATIONS,
3
+ SKILL_MAX_BLUEPRINTS,
4
+ SKILL_MAX_ENUM_VALUES,
5
+ SKILL_MAX_INPUTS,
6
+ SKILL_MAX_LINKS,
7
+ } from "../constants.ts";
8
+
9
+ export type BlueprintArgumentValue = string | number | boolean;
10
+ export type BlueprintInputType = "string" | "number" | "boolean";
11
+
12
+ export interface BlueprintInputDefinition {
13
+ type: BlueprintInputType;
14
+ required?: boolean;
15
+ default?: BlueprintArgumentValue;
16
+ enum?: BlueprintArgumentValue[];
17
+ }
18
+
19
+ export interface DocBlueprint {
20
+ ref: string;
21
+ title: string;
22
+ body?: string;
23
+ subtype?: string;
24
+ labels?: string[];
25
+ extra?: Record<string, unknown>;
26
+ }
27
+
28
+ export interface RuleBlueprint {
29
+ ref: string;
30
+ title: string;
31
+ body?: string;
32
+ condition?: string;
33
+ action?: string;
34
+ severity?: "block" | "warn" | "info";
35
+ labels?: string[];
36
+ extra?: Record<string, unknown>;
37
+ }
38
+
39
+ export interface TaskBlueprint {
40
+ ref: string;
41
+ title: string;
42
+ body?: string;
43
+ dependsOn?: string[];
44
+ parent?: string;
45
+ labels?: string[];
46
+ extra?: Record<string, unknown>;
47
+ }
48
+
49
+ /**
50
+ * A pipeline step that nests another run inside this one -- the Jenkins "trigger downstream
51
+ * job and wait" / Ansible "include_tasks" primitive. The target named by `targetId` can be
52
+ * either a workflow-definition Playbook or an ordinary steps/trigger-shaped Playbook
53
+ * (workflow-execution.ts resolves which, by the target artifact's own subtype); existence and
54
+ * eligibility are both checked at execution time, not here, since this validator has no store
55
+ * access. `dependsOn`/`parent` place this step in the SAME dependency graph as ordinary task
56
+ * blueprints -- a task can depend on a call ref (meaning: depend on every task the nested run
57
+ * creates), and a call's own `parent` contains the nested run's root tasks under an outer task.
58
+ */
59
+ export interface CallBlueprint {
60
+ ref: string;
61
+ title: string;
62
+ targetId: string;
63
+ arguments?: Record<string, unknown>;
64
+ dependsOn?: string[];
65
+ parent?: string;
66
+ }
67
+
68
+ export interface Blueprints {
69
+ docs: DocBlueprint[];
70
+ rules: RuleBlueprint[];
71
+ tasks: TaskBlueprint[];
72
+ skills: CallBlueprint[];
73
+ }
74
+
75
+ export interface BlueprintLink {
76
+ from: string;
77
+ relation: string;
78
+ to: string;
79
+ }
80
+
81
+ export interface BlueprintDefinition {
82
+ version: 1;
83
+ inputs: Record<string, BlueprintInputDefinition>;
84
+ blueprints: Blueprints;
85
+ links: BlueprintLink[];
86
+ }
87
+
88
+ const NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
89
+ const PLACEHOLDER_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}/g;
90
+ /** Exported so any other caller declaring typed inputs against this same shape (e.g. Playbook arguments) validates and rejects exactly the same way, instead of re-deriving its own type-checking logic. */
91
+ export const BLUEPRINT_INPUT_TYPES = new Set<BlueprintInputType>(["string", "number", "boolean"]);
92
+ const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
93
+ const RELATIONS = new Set<string>(SEED_RELATIONS);
94
+
95
+ function record(value: unknown, label: string): Record<string, unknown> {
96
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`);
97
+ return value as Record<string, unknown>;
98
+ }
99
+
100
+ function array(value: unknown, label: string): unknown[] {
101
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
102
+ return value;
103
+ }
104
+
105
+ function string(value: unknown, label: string): string {
106
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`);
107
+ return value;
108
+ }
109
+
110
+ /** Exported for reuse by any other typed-argument declaration against this same value shape (e.g. Playbook arguments), rather than re-deriving this exact check elsewhere. */
111
+ export function validateArgumentValue(name: string, type: BlueprintInputType, value: unknown): BlueprintArgumentValue {
112
+ if (typeof value !== type || (type === "number" && !Number.isFinite(value))) {
113
+ throw new Error(`argument "${name}" must be a ${type}`);
114
+ }
115
+ return value as BlueprintArgumentValue;
116
+ }
117
+
118
+ function validateInputs(value: unknown): Record<string, BlueprintInputDefinition> {
119
+ const source = record(value ?? {}, "inputs");
120
+ const entries = Object.entries(source);
121
+ if (entries.length > SKILL_MAX_INPUTS) throw new Error(`inputs exceed ${SKILL_MAX_INPUTS}`);
122
+ const result: Record<string, BlueprintInputDefinition> = {};
123
+ for (const [name, raw] of entries) {
124
+ if (RESERVED_KEYS.has(name)) throw new Error(`reserved input name "${name}"`);
125
+ if (!NAME_PATTERN.test(name)) throw new Error(`invalid input name "${name}"`);
126
+ const input = record(raw, `input "${name}"`);
127
+ if (!BLUEPRINT_INPUT_TYPES.has(input["type"] as BlueprintInputType)) throw new Error(`input "${name}" has unsupported type`);
128
+ const type = input["type"] as BlueprintInputType;
129
+ if (input["required"] !== undefined && typeof input["required"] !== "boolean") {
130
+ throw new Error(`input "${name}" required must be boolean`);
131
+ }
132
+ const normalized: BlueprintInputDefinition = { type };
133
+ if (input["required"] !== undefined) normalized.required = input["required"] as boolean;
134
+ if (input["default"] !== undefined) normalized.default = validateArgumentValue(name, type, input["default"]);
135
+ if (input["enum"] !== undefined) {
136
+ const values = array(input["enum"], `input "${name}" enum`);
137
+ if (values.length === 0 || values.length > SKILL_MAX_ENUM_VALUES) throw new Error(`input "${name}" enum must contain 1-${SKILL_MAX_ENUM_VALUES} values`);
138
+ normalized.enum = values.map((entry) => validateArgumentValue(name, type, entry));
139
+ if (normalized.default !== undefined && !normalized.enum.includes(normalized.default)) {
140
+ throw new Error(`input "${name}" default must be one of its enum values`);
141
+ }
142
+ }
143
+ result[name] = normalized;
144
+ }
145
+ return result;
146
+ }
147
+
148
+ function validateBlueprint<T extends { ref: string; title: string }>(value: unknown, kind: string): T {
149
+ const source = record(value, `${kind} blueprint`);
150
+ const ref = string(source["ref"], `${kind} blueprint ref`);
151
+ if (!NAME_PATTERN.test(ref)) throw new Error(`invalid blueprint ref "${ref}"`);
152
+ const title = string(source["title"], `${kind} blueprint title`);
153
+ return { ...source, ref, title } as T;
154
+ }
155
+
156
+ function placeholders(value: unknown, result: Set<string> = new Set()): Set<string> {
157
+ if (typeof value === "string") {
158
+ for (const match of value.matchAll(PLACEHOLDER_PATTERN)) result.add(match[1]!);
159
+ } else if (Array.isArray(value)) {
160
+ for (const entry of value) placeholders(entry, result);
161
+ } else if (typeof value === "object" && value !== null) {
162
+ for (const entry of Object.values(value)) placeholders(entry, result);
163
+ }
164
+ return result;
165
+ }
166
+
167
+ /** Steps sharing one dependency graph: ordinary tasks and call pipeline steps alike. */
168
+ interface DependentStep {
169
+ ref: string;
170
+ dependsOn?: string[];
171
+ }
172
+
173
+ function assertAcyclic(steps: DependentStep[]): void {
174
+ const byRef = new Map(steps.map((step) => [step.ref, step]));
175
+ const visiting = new Set<string>();
176
+ const visited = new Set<string>();
177
+ const visit = (ref: string): void => {
178
+ if (visiting.has(ref)) throw new Error(`step dependency cycle includes "${ref}"`);
179
+ if (visited.has(ref)) return;
180
+ visiting.add(ref);
181
+ for (const dependency of byRef.get(ref)?.dependsOn ?? []) visit(dependency);
182
+ visiting.delete(ref);
183
+ visited.add(ref);
184
+ };
185
+ for (const step of steps) visit(step.ref);
186
+ }
187
+
188
+ function validateCallBlueprint(value: unknown): CallBlueprint {
189
+ const source = record(value, "call blueprint");
190
+ const ref = string(source["ref"], "call blueprint ref");
191
+ if (!NAME_PATTERN.test(ref)) throw new Error(`invalid blueprint ref "${ref}"`);
192
+ const title = string(source["title"], "call blueprint title");
193
+ const targetId = string(source["targetId"] ?? source["skillId"], "call blueprint targetId");
194
+ return { ...source, ref, title, targetId } as CallBlueprint;
195
+ }
196
+
197
+ export function validateBlueprintDefinition(value: unknown): BlueprintDefinition {
198
+ const source = record(value, "blueprint definition");
199
+ if (source["version"] !== 1) throw new Error("blueprint definition version must be 1");
200
+ const inputs = validateInputs(source["inputs"]);
201
+ const rawBlueprints = record(source["blueprints"], "blueprints");
202
+ const docs = array(rawBlueprints["docs"] ?? [], "doc blueprints").map((entry) => validateBlueprint<DocBlueprint>(entry, "doc"));
203
+ const rules = array(rawBlueprints["rules"] ?? [], "rule blueprints").map((entry) => validateBlueprint<RuleBlueprint>(entry, "rule"));
204
+ const tasks = array(rawBlueprints["tasks"] ?? [], "task blueprints").map((entry) => validateBlueprint<TaskBlueprint>(entry, "task"));
205
+ const calls = array(rawBlueprints["skills"] ?? [], "call blueprints").map(validateCallBlueprint);
206
+ const all = [...docs, ...rules, ...tasks, ...calls];
207
+ if (all.length === 0 || all.length > SKILL_MAX_BLUEPRINTS) throw new Error(`blueprints must contain 1-${SKILL_MAX_BLUEPRINTS} artifacts`);
208
+ const refs = new Set<string>();
209
+ for (const blueprint of all) {
210
+ if (refs.has(blueprint.ref)) throw new Error(`duplicate blueprint ref "${blueprint.ref}"`);
211
+ refs.add(blueprint.ref);
212
+ }
213
+ // Tasks and call pipeline steps share one dependency graph: a task may depend on a call ref
214
+ // (meaning: depend on every task that nested run creates), and vice versa.
215
+ const stepRefs = new Set<string>([...tasks.map((task) => task.ref), ...calls.map((call) => call.ref)]);
216
+ for (const task of tasks) {
217
+ if (task.dependsOn !== undefined && !Array.isArray(task.dependsOn)) throw new Error(`task "${task.ref}" dependsOn must be an array`);
218
+ for (const dependency of task.dependsOn ?? []) {
219
+ if (!stepRefs.has(dependency)) throw new Error(`unknown task dependency ref "${dependency}"`);
220
+ }
221
+ // parent stays task-only: containment under a call step's exploded task SET has no
222
+ // single natural parent, so parent must name an actual task blueprint.
223
+ if (task.parent !== undefined && !tasks.some((candidate) => candidate.ref === task.parent)) {
224
+ throw new Error(`unknown task parent ref "${task.parent}"`);
225
+ }
226
+ }
227
+ for (const call of calls) {
228
+ if (call.dependsOn !== undefined && !Array.isArray(call.dependsOn)) throw new Error(`call "${call.ref}" dependsOn must be an array`);
229
+ for (const dependency of call.dependsOn ?? []) {
230
+ if (!stepRefs.has(dependency)) throw new Error(`unknown call dependency ref "${dependency}"`);
231
+ }
232
+ if (call.parent !== undefined && !tasks.some((candidate) => candidate.ref === call.parent)) {
233
+ throw new Error(`unknown call parent ref "${call.parent}"`);
234
+ }
235
+ }
236
+ assertAcyclic([...tasks, ...calls]);
237
+ for (const name of placeholders(all)) {
238
+ if (!Object.hasOwn(inputs, name)) throw new Error(`unknown input placeholder "${name}"`);
239
+ }
240
+ const links = array(source["links"] ?? [], "links").map((entry) => {
241
+ const link = record(entry, "link");
242
+ const from = string(link["from"], "link from");
243
+ const relation = string(link["relation"], "link relation");
244
+ const to = string(link["to"], "link to");
245
+ if (!refs.has(from)) throw new Error(`unknown blueprint ref "${from}"`);
246
+ if (!refs.has(to)) throw new Error(`unknown blueprint ref "${to}"`);
247
+ if (!RELATIONS.has(relation)) throw new Error(`unknown link relation "${relation}"`);
248
+ return { from, relation, to };
249
+ });
250
+ if (links.length > SKILL_MAX_LINKS) throw new Error(`links exceed ${SKILL_MAX_LINKS}`);
251
+ return { version: 1, inputs, blueprints: { docs, rules, tasks, skills: calls }, links };
252
+ }
253
+
254
+ export function resolveBlueprintArguments(definition: BlueprintDefinition, value: unknown): Record<string, BlueprintArgumentValue> {
255
+ const source = record(value ?? {}, "arguments");
256
+ for (const name of Object.keys(source)) {
257
+ if (!Object.hasOwn(definition.inputs, name)) throw new Error(`unknown argument "${name}"`);
258
+ }
259
+ const result: Record<string, BlueprintArgumentValue> = {};
260
+ for (const [name, input] of Object.entries(definition.inputs)) {
261
+ const raw = source[name] ?? input.default;
262
+ if (raw === undefined) {
263
+ if (input.required) throw new Error(`missing required argument "${name}"`);
264
+ continue;
265
+ }
266
+ const normalized = validateArgumentValue(name, input.type, raw);
267
+ if (input.enum && !input.enum.includes(normalized)) {
268
+ throw new Error(`argument "${name}" must be one of: ${input.enum.join(", ")}`);
269
+ }
270
+ result[name] = normalized;
271
+ }
272
+ return result;
273
+ }