@astra-spec/sdk 0.0.1

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.
Files changed (43) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +117 -0
  3. package/dist/helpers.d.ts +26 -0
  4. package/dist/helpers.d.ts.map +1 -0
  5. package/dist/helpers.js +165 -0
  6. package/dist/helpers.js.map +1 -0
  7. package/dist/index.d.ts +5 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +12 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/schema/index.d.ts +35 -0
  12. package/dist/schema/index.d.ts.map +1 -0
  13. package/dist/schema/index.js +85 -0
  14. package/dist/schema/index.js.map +1 -0
  15. package/dist/types.d.ts +122 -0
  16. package/dist/types.d.ts.map +1 -0
  17. package/dist/types.js +5 -0
  18. package/dist/types.js.map +1 -0
  19. package/dist/validation/index.d.ts +4 -0
  20. package/dist/validation/index.d.ts.map +1 -0
  21. package/dist/validation/index.js +4 -0
  22. package/dist/validation/index.js.map +1 -0
  23. package/dist/validation/narrative.d.ts +23 -0
  24. package/dist/validation/narrative.d.ts.map +1 -0
  25. package/dist/validation/narrative.js +337 -0
  26. package/dist/validation/narrative.js.map +1 -0
  27. package/dist/validation/schema.d.ts +16 -0
  28. package/dist/validation/schema.d.ts.map +1 -0
  29. package/dist/validation/schema.js +82 -0
  30. package/dist/validation/schema.js.map +1 -0
  31. package/dist/validation/semantic.d.ts +18 -0
  32. package/dist/validation/semantic.d.ts.map +1 -0
  33. package/dist/validation/semantic.js +751 -0
  34. package/dist/validation/semantic.js.map +1 -0
  35. package/package.json +60 -0
  36. package/src/helpers.ts +171 -0
  37. package/src/index.ts +69 -0
  38. package/src/schema/index.ts +113 -0
  39. package/src/types.ts +139 -0
  40. package/src/validation/index.ts +26 -0
  41. package/src/validation/narrative.ts +389 -0
  42. package/src/validation/schema.ts +132 -0
  43. package/src/validation/semantic.ts +1129 -0
@@ -0,0 +1,751 @@
1
+ // Semantic validation: cross-references, constraint resolution, and
2
+ // `from:`-path direction rules. Operates on parsed dict-like data
3
+ // (matching the Python validator) so it doesn't depend on type narrowing.
4
+ import { collectNodeDecisions, getInputIds, getOutputIds, isConditionMet, loadYaml, resolveAnalysisTree, } from "../helpers.js";
5
+ export class SemanticError {
6
+ code;
7
+ message;
8
+ path;
9
+ constructor(code, message, path) {
10
+ this.code = code;
11
+ this.message = message;
12
+ this.path = path;
13
+ }
14
+ toString() {
15
+ return this.path ? `[${this.code}] ${this.path}: ${this.message}` : `[${this.code}] ${this.message}`;
16
+ }
17
+ }
18
+ const ID_PATTERN = /^[a-z][a-z0-9_]*$/;
19
+ function asDict(v) {
20
+ return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
21
+ }
22
+ function asArray(v) {
23
+ return Array.isArray(v) ? v : [];
24
+ }
25
+ /** Parse a `../scope.id` style path. Returns null on malformed input. */
26
+ function parseFromPath(ref) {
27
+ let up = 0;
28
+ let rest = ref;
29
+ while (rest.startsWith("../")) {
30
+ up += 1;
31
+ rest = rest.slice(3);
32
+ }
33
+ if (!rest || rest.startsWith(".") || rest.endsWith("."))
34
+ return null;
35
+ const segments = rest.split(".");
36
+ for (const seg of segments)
37
+ if (!ID_PATTERN.test(seg))
38
+ return null;
39
+ return { up, segments };
40
+ }
41
+ function checkPathExclusivity(data, errors, pathPrefix = "") {
42
+ const subAnalyses = asDict(data.analyses) ?? {};
43
+ for (const [subId, raw] of Object.entries(subAnalyses)) {
44
+ const sub = asDict(raw);
45
+ if (!sub)
46
+ continue;
47
+ const fullPath = pathPrefix ? `${pathPrefix}.analyses.${subId}` : `analyses.${subId}`;
48
+ if (sub.path) {
49
+ const extra = Object.keys(sub).filter((k) => k !== "path").sort();
50
+ if (extra.length) {
51
+ errors.push(new SemanticError("PATH_FIELD_CONFLICT", `Sub-analysis '${subId}' has 'path:' alongside fields ${JSON.stringify(extra)}; ` +
52
+ `content must come from the referenced file. Move these fields into the sub's astra.yaml.`, fullPath));
53
+ }
54
+ }
55
+ else {
56
+ checkPathExclusivity(sub, errors, fullPath);
57
+ }
58
+ }
59
+ }
60
+ /** Validate an Analysis dict semantically. Returns the list of errors. */
61
+ export function validateAnalysis(data, options = {}) {
62
+ const errors = [];
63
+ let working = data;
64
+ // Run before any external `path:` resolution merges over content fields.
65
+ checkPathExclusivity(working, errors);
66
+ if (options.basePath) {
67
+ working = resolveAnalysisTree(working, options.basePath);
68
+ }
69
+ for (const field of ["version", "name", "inputs", "outputs"]) {
70
+ if (working[field] == null) {
71
+ errors.push(new SemanticError("MISSING_ROOT_FIELD", `Root analysis is missing required field '${field}'`, field));
72
+ }
73
+ }
74
+ const inputs = asArray(working.inputs);
75
+ const outputs = asArray(working.outputs);
76
+ const priorInsights = asDict(working.prior_insights) ?? {};
77
+ const inputIds = new Set();
78
+ for (const inp of inputs) {
79
+ const id = inp?.id;
80
+ if (id && inputIds.has(id)) {
81
+ errors.push(new SemanticError("DUPLICATE_INPUT", `Duplicate input ID: ${id}`, `inputs.${id}`));
82
+ }
83
+ if (id)
84
+ inputIds.add(id);
85
+ }
86
+ const outputIds = new Set();
87
+ for (const out of outputs) {
88
+ const id = out?.id;
89
+ if (id && outputIds.has(id)) {
90
+ errors.push(new SemanticError("DUPLICATE_OUTPUT", `Duplicate output ID: ${id}`, `outputs.${id}`));
91
+ }
92
+ if (id)
93
+ outputIds.add(id);
94
+ }
95
+ const rootDecisions = collectNodeDecisions(working);
96
+ errors.push(..._validateDecisions(rootDecisions, priorInsights, ""));
97
+ errors.push(..._validateInsightArtifacts(asDict(working.prior_insights) ?? {}, outputIds, "", "prior_insights"));
98
+ errors.push(..._validateInsightArtifacts(asDict(working.findings) ?? {}, outputIds, "", "findings"));
99
+ const subAnalyses = asDict(working.analyses) ?? {};
100
+ const subOutputIds = new Set();
101
+ for (const [analysisId, raw] of Object.entries(subAnalyses)) {
102
+ const sub = asDict(raw);
103
+ if (!sub)
104
+ continue;
105
+ for (const out of asArray(sub.outputs)) {
106
+ const oid = out?.id;
107
+ if (oid)
108
+ subOutputIds.add(`${analysisId}.${oid}`);
109
+ }
110
+ }
111
+ errors.push(..._validateOutputsFrom(outputs, working, ""));
112
+ errors.push(..._validateOutputDependencies(outputs, {
113
+ analysisInputIds: inputIds,
114
+ decisionsInScope: rootDecisions,
115
+ pathPrefix: "",
116
+ extraValidIds: subOutputIds,
117
+ }));
118
+ errors.push(..._validateOutputWhen(outputs, rootDecisions, ""));
119
+ for (const [analysisId, raw] of Object.entries(subAnalyses)) {
120
+ const sub = asDict(raw);
121
+ if (!sub)
122
+ continue;
123
+ errors.push(..._validateAnalysisNode(analysisId, sub, priorInsights, [working], "analyses"));
124
+ }
125
+ return errors;
126
+ }
127
+ function _validateAnalysisNode(nodeId, node, priorInsights, ancestorChain, pathPrefix) {
128
+ const errors = [];
129
+ const nodePath = `${pathPrefix}.${nodeId}`;
130
+ // Path-only stub (not yet resolved): skip deep checks.
131
+ if (node.path && !node.inputs && !node.outputs)
132
+ return errors;
133
+ for (const field of ["inputs", "outputs"]) {
134
+ if (!node[field]) {
135
+ errors.push(new SemanticError("MISSING_SUB_FIELD", `Sub-analysis '${nodeId}' is missing required field: ${field}`, nodePath));
136
+ }
137
+ }
138
+ // Decision `from:` checks.
139
+ const allDecisions = (asDict(node.decisions) ?? {});
140
+ for (const [decisionId, decision] of Object.entries(allDecisions)) {
141
+ const ref = decision?.from;
142
+ if (ref) {
143
+ errors.push(..._validateDecisionFrom(ref, ancestorChain, `${nodePath}.decisions.${decisionId}`));
144
+ }
145
+ }
146
+ // Inputs.
147
+ const nodeInputs = asArray(node.inputs);
148
+ const nodeInputIds = new Set();
149
+ for (const inp of nodeInputs) {
150
+ const id = inp?.id;
151
+ if (id)
152
+ nodeInputIds.add(id);
153
+ const ref = inp?.from;
154
+ if (ref)
155
+ errors.push(..._validateInputFrom(ref, ancestorChain, nodeId, nodePath));
156
+ }
157
+ // Output IDs unique.
158
+ const nodeOutputIds = new Set();
159
+ for (const out of asArray(node.outputs)) {
160
+ const id = out?.id;
161
+ if (id && nodeOutputIds.has(id)) {
162
+ errors.push(new SemanticError("DUPLICATE_OUTPUT", `Duplicate output ID in analysis node: ${id}`, `${nodePath}.outputs.${id}`));
163
+ }
164
+ if (id)
165
+ nodeOutputIds.add(id);
166
+ }
167
+ const nodeOutputs = asArray(node.outputs);
168
+ errors.push(..._validateOutputsFrom(nodeOutputs, node, nodePath));
169
+ const nodeDecisions = collectNodeDecisions(node);
170
+ // Build the constraint scope: locally-defined decisions plus any `from:`
171
+ // alias resolved one ancestor up (matches the Python `constraint_scope`).
172
+ const constraintScope = { ...nodeDecisions };
173
+ for (const [decisionId, decision] of Object.entries(allDecisions)) {
174
+ const ref = decision?.from;
175
+ if (!ref)
176
+ continue;
177
+ const parsed = parseFromPath(ref);
178
+ if (!parsed || parsed.up <= 0 || parsed.segments.length !== 1)
179
+ continue;
180
+ const targetScope = _resolveAncestorScope(ancestorChain, parsed.up);
181
+ if (!targetScope)
182
+ continue;
183
+ const targetDecisions = (asDict(targetScope.decisions) ?? {});
184
+ const seg = parsed.segments[0];
185
+ if (seg in targetDecisions) {
186
+ const tgt = targetDecisions[seg];
187
+ if (tgt)
188
+ constraintScope[decisionId] = tgt;
189
+ }
190
+ }
191
+ errors.push(..._validateDecisions(nodeDecisions, priorInsights, nodePath, constraintScope));
192
+ errors.push(..._validateInsightArtifacts(asDict(node.prior_insights) ?? {}, nodeOutputIds, nodePath, "prior_insights"));
193
+ errors.push(..._validateInsightArtifacts(asDict(node.findings) ?? {}, nodeOutputIds, nodePath, "findings"));
194
+ const subAnalyses = asDict(node.analyses) ?? {};
195
+ const subOutputIds = new Set();
196
+ for (const [subId, raw] of Object.entries(subAnalyses)) {
197
+ const sub = asDict(raw);
198
+ if (!sub)
199
+ continue;
200
+ for (const out of asArray(sub.outputs)) {
201
+ const oid = out?.id;
202
+ if (oid)
203
+ subOutputIds.add(`${subId}.${oid}`);
204
+ }
205
+ }
206
+ errors.push(..._validateOutputDependencies(nodeOutputs, {
207
+ analysisInputIds: nodeInputIds,
208
+ decisionsInScope: constraintScope,
209
+ pathPrefix: nodePath,
210
+ extraValidIds: subOutputIds,
211
+ }));
212
+ errors.push(..._validateOutputWhen(nodeOutputs, constraintScope, nodePath));
213
+ for (const [subId, raw] of Object.entries(subAnalyses)) {
214
+ const sub = asDict(raw);
215
+ if (!sub)
216
+ continue;
217
+ errors.push(..._validateAnalysisNode(subId, sub, priorInsights, [...ancestorChain, node], `${nodePath}.analyses`));
218
+ }
219
+ return errors;
220
+ }
221
+ function _validateOutputsFrom(outputs, currentScope, pathPrefix) {
222
+ const errors = [];
223
+ const prefix = pathPrefix ? `${pathPrefix}.outputs` : "outputs";
224
+ for (const out of outputs) {
225
+ const ref = out?.from;
226
+ const id = out?.id;
227
+ if (!ref || !id)
228
+ continue;
229
+ errors.push(..._validateOutputFrom(ref, currentScope, `${prefix}.${id}`));
230
+ }
231
+ return errors;
232
+ }
233
+ function _validateInsightArtifacts(insights, outputIds, pathPrefix, section) {
234
+ const errors = [];
235
+ if (!insights || Object.keys(insights).length === 0)
236
+ return errors;
237
+ const prefix = pathPrefix ? `${pathPrefix}.${section}` : section;
238
+ for (const [insightId, raw] of Object.entries(insights)) {
239
+ const insight = asDict(raw);
240
+ if (!insight)
241
+ continue;
242
+ const insightPath = `${prefix}.${insightId}`;
243
+ const evidenceList = asArray(insight.evidence);
244
+ evidenceList.forEach((ev, i) => {
245
+ const artifactRef = ev?.artifact;
246
+ if (artifactRef !== undefined && !outputIds.has(artifactRef)) {
247
+ errors.push(new SemanticError("INVALID_ARTIFACT_REF", `Evidence artifact '${artifactRef}' not found in declared outputs`, `${insightPath}.evidence[${i}].artifact`));
248
+ }
249
+ });
250
+ }
251
+ return errors;
252
+ }
253
+ function _validateDecisions(decisions, priorInsights, pathPrefix, constraintScope) {
254
+ const errors = [];
255
+ const scope = constraintScope ?? decisions;
256
+ const decisionsPrefix = pathPrefix ? `${pathPrefix}.decisions` : "decisions";
257
+ for (const [decisionId, decision] of Object.entries(decisions)) {
258
+ const decisionPath = `${decisionsPrefix}.${decisionId}`;
259
+ const options = (asDict(decision.options) ?? {});
260
+ const defaultOpt = decision.default;
261
+ if (defaultOpt != null && !(defaultOpt in options)) {
262
+ errors.push(new SemanticError("INVALID_DEFAULT", `Default option '${defaultOpt}' not found in options`, decisionPath));
263
+ }
264
+ const when = decision.when;
265
+ if (when) {
266
+ const conds = typeof when === "string" ? [when] : when;
267
+ for (const cond of conds) {
268
+ const ref = cond.startsWith("~") ? cond.slice(1) : cond;
269
+ const parts = ref.split(".");
270
+ if (parts.length !== 2) {
271
+ errors.push(new SemanticError("INVALID_WHEN_REF", `Decision 'when' condition '${cond}' has invalid format`, decisionPath));
272
+ continue;
273
+ }
274
+ const [whenDecisionId, whenOptionId] = parts;
275
+ if (!(whenDecisionId in decisions) && !(whenDecisionId in scope)) {
276
+ errors.push(new SemanticError("INVALID_WHEN_REF", `'when' references non-existent decision '${whenDecisionId}'`, decisionPath));
277
+ }
278
+ else {
279
+ const refDecision = decisions[whenDecisionId] ?? scope[whenDecisionId];
280
+ const refOptions = refDecision ? (asDict(refDecision.options) ?? {}) : {};
281
+ if (refDecision && !(whenOptionId in refOptions)) {
282
+ errors.push(new SemanticError("INVALID_WHEN_REF", `'when' references non-existent option '${whenOptionId}' in decision '${whenDecisionId}'`, decisionPath));
283
+ }
284
+ }
285
+ if (whenDecisionId === decisionId) {
286
+ errors.push(new SemanticError("INVALID_WHEN_REF", "'when' cannot reference own decision", decisionPath));
287
+ }
288
+ }
289
+ }
290
+ for (const [optionId, optionRaw] of Object.entries(options)) {
291
+ const option = optionRaw;
292
+ const optionPath = `${decisionPath}.options.${optionId}`;
293
+ const insightRefs = asArray(option.insights);
294
+ insightRefs.forEach((insightRef, i) => {
295
+ if (!(insightRef in priorInsights)) {
296
+ errors.push(new SemanticError("INVALID_INSIGHT_REF", `Option insight '${insightRef}' not found in prior_insights`, `${optionPath}.insights[${i}]`));
297
+ }
298
+ });
299
+ for (const ref of asArray(option.incompatible_with)) {
300
+ errors.push(..._validateConstraintRef(ref, scope, optionPath));
301
+ }
302
+ for (const ref of asArray(option.requires)) {
303
+ errors.push(..._validateConstraintRef(ref, scope, optionPath));
304
+ }
305
+ const isExcluded = option.excluded === true;
306
+ const excludedReason = option.excluded_reason;
307
+ if (isExcluded && !excludedReason) {
308
+ errors.push(new SemanticError("MISSING_EXCLUDED_REASON", `Excluded option '${optionId}' must have an 'excluded_reason'`, optionPath));
309
+ }
310
+ if (excludedReason && !isExcluded) {
311
+ errors.push(new SemanticError("ORPHAN_EXCLUDED_REASON", `Option '${optionId}' has 'excluded_reason' but is not marked excluded`, optionPath));
312
+ }
313
+ }
314
+ if (defaultOpt != null && defaultOpt in options) {
315
+ const defaultOption = options[defaultOpt];
316
+ if (defaultOption.excluded === true) {
317
+ errors.push(new SemanticError("EXCLUDED_DEFAULT", `Default option '${defaultOpt}' is marked as excluded`, decisionPath));
318
+ }
319
+ }
320
+ }
321
+ return errors;
322
+ }
323
+ function _validateOutputWhen(outputs, decisions, pathPrefix) {
324
+ const errors = [];
325
+ const outputsPrefix = pathPrefix ? `${pathPrefix}.outputs` : "outputs";
326
+ for (const out of outputs) {
327
+ const id = out?.id;
328
+ if (!id)
329
+ continue;
330
+ const when = out.when;
331
+ if (!when)
332
+ continue;
333
+ const conds = typeof when === "string" ? [when] : when;
334
+ const outputPath = `${outputsPrefix}.${id}`;
335
+ for (const cond of conds) {
336
+ const ref = cond.startsWith("~") ? cond.slice(1) : cond;
337
+ const parts = ref.split(".");
338
+ if (parts.length !== 2) {
339
+ errors.push(new SemanticError("INVALID_WHEN_REF", `Output 'when' condition '${cond}' has invalid format`, outputPath));
340
+ continue;
341
+ }
342
+ const [decisionId, optionId] = parts;
343
+ if (!(decisionId in decisions)) {
344
+ errors.push(new SemanticError("INVALID_WHEN_REF", `Output 'when' references non-existent decision '${decisionId}'`, outputPath));
345
+ }
346
+ else {
347
+ const refDecision = decisions[decisionId];
348
+ const refOptions = (asDict(refDecision.options) ?? {});
349
+ if (!(optionId in refOptions)) {
350
+ errors.push(new SemanticError("INVALID_WHEN_REF", `Output 'when' references non-existent option '${optionId}' in decision '${decisionId}'`, outputPath));
351
+ }
352
+ }
353
+ }
354
+ }
355
+ return errors;
356
+ }
357
+ function _validateOutputDependencies(outputs, args) {
358
+ const errors = [];
359
+ const { analysisInputIds, decisionsInScope, pathPrefix, extraValidIds } = args;
360
+ const outputsPrefix = pathPrefix ? `${pathPrefix}.outputs` : "outputs";
361
+ const outputIds = new Set();
362
+ for (const out of outputs)
363
+ if (out?.id)
364
+ outputIds.add(out.id);
365
+ const siblingOrExtra = new Set([...outputIds, ...(extraValidIds ?? new Set())]);
366
+ const validInputIds = new Set([...analysisInputIds, ...siblingOrExtra]);
367
+ const depGraph = {};
368
+ for (const out of outputs) {
369
+ const id = out?.id;
370
+ if (!id)
371
+ continue;
372
+ const outPath = `${outputsPrefix}.${id}`;
373
+ if (out.from) {
374
+ depGraph[id] = [];
375
+ continue;
376
+ }
377
+ const declaredInputs = asArray(out.inputs);
378
+ depGraph[id] = declaredInputs.filter((i) => siblingOrExtra.has(i));
379
+ for (const inpId of declaredInputs) {
380
+ if (!validInputIds.has(inpId)) {
381
+ errors.push(new SemanticError("INVALID_OUTPUT_INPUT", `Output input '${inpId}' is not a declared analysis input or sibling output`, `${outPath}.inputs`));
382
+ }
383
+ }
384
+ const declaredDecisions = asArray(out.decisions);
385
+ for (const decId of declaredDecisions) {
386
+ if (!(decId in decisionsInScope)) {
387
+ errors.push(new SemanticError("INVALID_OUTPUT_DECISION", `Output decision '${decId}' is not a decision in scope`, `${outPath}.decisions`));
388
+ }
389
+ }
390
+ const recipe = asDict(out.recipe);
391
+ const command = recipe?.command;
392
+ if (typeof command === "string" && command) {
393
+ errors.push(..._validateCommandTemplate(command, new Set(declaredInputs), new Set(declaredDecisions), `${outPath}.recipe.command`));
394
+ }
395
+ }
396
+ const cycle = _detectOutputCycle(depGraph);
397
+ if (cycle) {
398
+ errors.push(new SemanticError("OUTPUT_CYCLE", `Dependency cycle detected: ${cycle.join(" -> ")}`, outputsPrefix));
399
+ }
400
+ return errors;
401
+ }
402
+ /** Iterate Python `string.Formatter.parse()`-style fields out of a
403
+ * format string. Yields `{ field, formatSpec, conversion }` for each
404
+ * placeholder; `{{` and `}}` are emitted as literal braces (skipped). */
405
+ function* iterTemplateFields(command) {
406
+ let i = 0;
407
+ while (i < command.length) {
408
+ const ch = command[i];
409
+ if (ch === "{") {
410
+ if (command[i + 1] === "{") {
411
+ // literal {
412
+ i += 2;
413
+ continue;
414
+ }
415
+ // find matching } (no nested braces inside placeholders for our purposes)
416
+ const end = command.indexOf("}", i + 1);
417
+ if (end < 0) {
418
+ throw new Error("Single '{' encountered in format string");
419
+ }
420
+ const body = command.slice(i + 1, end);
421
+ // Conversion: !s, !r, !a — single char after `!`.
422
+ let field = body;
423
+ let conversion = null;
424
+ let formatSpec = "";
425
+ const colon = field.indexOf(":");
426
+ if (colon >= 0) {
427
+ formatSpec = field.slice(colon + 1);
428
+ field = field.slice(0, colon);
429
+ }
430
+ const bang = field.indexOf("!");
431
+ if (bang >= 0) {
432
+ conversion = field.slice(bang + 1);
433
+ field = field.slice(0, bang);
434
+ }
435
+ yield { field, formatSpec, conversion };
436
+ i = end + 1;
437
+ }
438
+ else if (ch === "}") {
439
+ if (command[i + 1] === "}") {
440
+ i += 2;
441
+ continue;
442
+ }
443
+ throw new Error("Single '}' encountered in format string");
444
+ }
445
+ else {
446
+ i++;
447
+ }
448
+ }
449
+ }
450
+ function _validateCommandTemplate(command, declaredInputs, declaredDecisions, path) {
451
+ const errors = [];
452
+ let fields;
453
+ try {
454
+ fields = [...iterTemplateFields(command)];
455
+ }
456
+ catch (e) {
457
+ return [new SemanticError("INVALID_COMMAND_TEMPLATE", e.message, path)];
458
+ }
459
+ const declared = {
460
+ inputs: declaredInputs,
461
+ decisions: declaredDecisions,
462
+ };
463
+ for (const { field, formatSpec, conversion } of fields) {
464
+ if (field == null)
465
+ continue;
466
+ if (field === "" || formatSpec || conversion) {
467
+ errors.push(new SemanticError("INVALID_COMMAND_TEMPLATE", `Invalid command placeholder '{${field}}'`, path));
468
+ continue;
469
+ }
470
+ if (field === "output" || field === "inputs")
471
+ continue;
472
+ const dot = field.indexOf(".");
473
+ if (dot >= 0) {
474
+ const head = field.slice(0, dot);
475
+ const tail = field.slice(dot + 1);
476
+ if (!tail.includes(".") && head in declared) {
477
+ if (!declared[head].has(tail)) {
478
+ const singular = head === "inputs" ? "input" : "decision";
479
+ errors.push(new SemanticError("UNDECLARED_TEMPLATE_REF", `Command placeholder '{${field}}' references undeclared ${singular} '${tail}' (add it to Output.${head})`, path));
480
+ }
481
+ continue;
482
+ }
483
+ }
484
+ errors.push(new SemanticError("INVALID_COMMAND_TEMPLATE", `Unknown command placeholder '{${field}}' (use {inputs}, {inputs.<id>}, {decisions.<id>}, or {output})`, path));
485
+ }
486
+ return errors;
487
+ }
488
+ function _detectOutputCycle(depGraph) {
489
+ const White = 0;
490
+ const Gray = 1;
491
+ const Black = 2;
492
+ const color = {};
493
+ for (const id of Object.keys(depGraph))
494
+ color[id] = White;
495
+ const path = [];
496
+ function dfs(node) {
497
+ color[node] = Gray;
498
+ path.push(node);
499
+ for (const dep of depGraph[node] ?? []) {
500
+ if (!(dep in color))
501
+ continue;
502
+ if (color[dep] === Gray) {
503
+ const start = path.indexOf(dep);
504
+ return [...path.slice(start), dep];
505
+ }
506
+ if (color[dep] === White) {
507
+ const result = dfs(dep);
508
+ if (result)
509
+ return result;
510
+ }
511
+ }
512
+ path.pop();
513
+ color[node] = Black;
514
+ return null;
515
+ }
516
+ for (const id of Object.keys(depGraph)) {
517
+ if (color[id] === White) {
518
+ const result = dfs(id);
519
+ if (result)
520
+ return result;
521
+ }
522
+ }
523
+ return null;
524
+ }
525
+ function _resolveAncestorScope(chain, up) {
526
+ if (up <= 0 || up > chain.length)
527
+ return null;
528
+ return chain[chain.length - up] ?? null;
529
+ }
530
+ function _validateDecisionFrom(ref, ancestorChain, decisionPath) {
531
+ const mkErr = (m) => [new SemanticError("INVALID_DECISION_FROM", m, decisionPath)];
532
+ const parsed = parseFromPath(ref);
533
+ if (!parsed)
534
+ return mkErr(`Decision.from '${ref}' has invalid path syntax`);
535
+ if (parsed.up === 0)
536
+ return mkErr(`Decision.from '${ref}' must start with '../' to reference an ancestor decision`);
537
+ if (parsed.segments.length !== 1)
538
+ return mkErr(`Decision.from '${ref}' must reference a single decision id ` +
539
+ `(no descent into sibling/child scopes allowed; lift the decision to a common ancestor instead)`);
540
+ const target = _resolveAncestorScope(ancestorChain, parsed.up);
541
+ if (!target)
542
+ return mkErr(`Decision.from '${ref}' escapes ${parsed.up} level(s) but only ${ancestorChain.length} ancestor scope(s) available`);
543
+ const targetDecisions = (asDict(target.decisions) ?? {});
544
+ if (!(parsed.segments[0] in targetDecisions))
545
+ return mkErr(`Decision.from '${ref}' points to non-existent ancestor decision '${parsed.segments[0]}'`);
546
+ return [];
547
+ }
548
+ function _validateInputFrom(ref, ancestorChain, currentNodeId, nodePath) {
549
+ const mkErr = (m) => [new SemanticError("INVALID_FROM", m, nodePath)];
550
+ const parsed = parseFromPath(ref);
551
+ if (!parsed)
552
+ return mkErr(`Input.from '${ref}' has invalid path syntax`);
553
+ if (parsed.up === 0)
554
+ return mkErr(`Input.from '${ref}' must start with '../' to escape upward ` +
555
+ `(downward references aren't allowed on Inputs; consume sub outputs via Output re-export)`);
556
+ const target = _resolveAncestorScope(ancestorChain, parsed.up);
557
+ if (!target)
558
+ return mkErr(`Input.from '${ref}' escapes ${parsed.up} level(s) but only ${ancestorChain.length} ancestor scope(s) available`);
559
+ if (parsed.segments.length === 1) {
560
+ if (!getInputIds(target).has(parsed.segments[0]))
561
+ return mkErr(`Input.from '${ref}' points to non-existent ancestor input '${parsed.segments[0]}'`);
562
+ return [];
563
+ }
564
+ let current = target;
565
+ const heads = parsed.segments.slice(0, -1);
566
+ for (let i = 0; i < heads.length; i++) {
567
+ const seg = heads[i];
568
+ const subAnalyses = asDict(current.analyses) ?? {};
569
+ if (!(seg in subAnalyses))
570
+ return mkErr(`Input.from '${ref}': sub-analysis '${seg}' not found at depth ${i} in target scope`);
571
+ if (parsed.up === 1 && i === 0 && seg === currentNodeId)
572
+ return mkErr(`Input.from '${ref}' cannot reference own outputs`);
573
+ current = subAnalyses[seg];
574
+ }
575
+ const tail = parsed.segments[parsed.segments.length - 1];
576
+ if (!getOutputIds(current).has(tail))
577
+ return mkErr(`Input.from '${ref}': output '${tail}' not found in target sub-analysis`);
578
+ return [];
579
+ }
580
+ function _validateOutputFrom(ref, currentScope, outputPath) {
581
+ const mkErr = (m) => [
582
+ new SemanticError("INVALID_OUTPUT_FROM", m, outputPath),
583
+ ];
584
+ const parsed = parseFromPath(ref);
585
+ if (!parsed)
586
+ return mkErr(`Output.from '${ref}' has invalid path syntax`);
587
+ if (parsed.up !== 0)
588
+ return mkErr(`Output.from '${ref}' must descend into a sub-analysis ` +
589
+ `(upward references aren't allowed; outputs flow up via per-layer re-export)`);
590
+ if (parsed.segments.length < 2)
591
+ return mkErr(`Output.from '${ref}' must take the form 'child.out_id' ` +
592
+ `(at least one descent step into a named sub-analysis)`);
593
+ let current = currentScope;
594
+ const heads = parsed.segments.slice(0, -1);
595
+ for (let i = 0; i < heads.length; i++) {
596
+ const seg = heads[i];
597
+ const subAnalyses = asDict(current.analyses) ?? {};
598
+ if (!(seg in subAnalyses))
599
+ return mkErr(`Output.from '${ref}': sub-analysis '${seg}' not found at depth ${i}`);
600
+ current = subAnalyses[seg];
601
+ }
602
+ const tail = parsed.segments[parsed.segments.length - 1];
603
+ if (!getOutputIds(current).has(tail))
604
+ return mkErr(`Output.from '${ref}': output '${tail}' not found in target sub-analysis`);
605
+ return [];
606
+ }
607
+ function _validateConstraintRef(ref, decisions, optionPath) {
608
+ const parts = ref.split(".");
609
+ if (parts.length !== 2) {
610
+ return [
611
+ new SemanticError("INVALID_CONSTRAINT_FORMAT", `Constraint '${ref}' should be in 'decision.option' format`, optionPath),
612
+ ];
613
+ }
614
+ const [decisionId, optionId] = parts;
615
+ if (!(decisionId in decisions)) {
616
+ return [
617
+ new SemanticError("INVALID_CONSTRAINT_REF", `Constraint ref '${ref}' points to non-existent decision '${decisionId}'`, optionPath),
618
+ ];
619
+ }
620
+ const options = (asDict(decisions[decisionId].options) ?? {});
621
+ if (!(optionId in options)) {
622
+ return [
623
+ new SemanticError("INVALID_CONSTRAINT_REF", `Constraint ref '${ref}' points to non-existent option '${optionId}'`, optionPath),
624
+ ];
625
+ }
626
+ return [];
627
+ }
628
+ // ---------------------------------------------------------------------------
629
+ // Universe validation
630
+ // ---------------------------------------------------------------------------
631
+ export function validateUniverse(universeData, analysisData) {
632
+ return _validateUniverseNode(universeData, analysisData, "", []);
633
+ }
634
+ function _validateUniverseNode(universeNode, analysisNode, pathPrefix, ancestorUniverseChain) {
635
+ const errors = [];
636
+ const analysisDecisions = collectNodeDecisions(analysisNode);
637
+ const allAnalysisDecisions = (asDict(analysisNode.decisions) ?? {});
638
+ const universeDecisions = (asDict(universeNode.decisions) ?? {});
639
+ const decisionsPath = pathPrefix ? `${pathPrefix}.decisions` : "decisions";
640
+ const fromDecisionIds = new Set();
641
+ for (const [id, decision] of Object.entries(allAnalysisDecisions)) {
642
+ if (decision && decision.from)
643
+ fromDecisionIds.add(id);
644
+ }
645
+ for (const [decisionId, optionId] of Object.entries(universeDecisions)) {
646
+ if (fromDecisionIds.has(decisionId)) {
647
+ errors.push(new SemanticError("FROM_DECISION_IN_UNIVERSE", `Universe should not set decision '${decisionId}' (it uses 'from' to reference a parent decision)`, `${decisionsPath}.${decisionId}`));
648
+ continue;
649
+ }
650
+ if (!(decisionId in analysisDecisions)) {
651
+ errors.push(new SemanticError("UNKNOWN_DECISION", `Universe references unknown decision '${decisionId}'`, `${decisionsPath}.${decisionId}`));
652
+ continue;
653
+ }
654
+ const decision = analysisDecisions[decisionId];
655
+ const options = (asDict(decision.options) ?? {});
656
+ if (!(optionId in options)) {
657
+ errors.push(new SemanticError("UNKNOWN_OPTION", `Universe selects unknown option '${optionId}' for decision '${decisionId}'`, `${decisionsPath}.${decisionId}`));
658
+ }
659
+ else if (options[optionId].excluded === true) {
660
+ errors.push(new SemanticError("EXCLUDED_OPTION_SELECTED", `Universe selects excluded option '${optionId}' for decision '${decisionId}'`, `${decisionsPath}.${decisionId}`));
661
+ }
662
+ }
663
+ // Merge universe selections from this and all ancestor levels for `when:` evaluation.
664
+ const allUniverseDecisions = {};
665
+ for (const ancestor of ancestorUniverseChain)
666
+ Object.assign(allUniverseDecisions, ancestor);
667
+ Object.assign(allUniverseDecisions, universeDecisions);
668
+ for (const decisionId of Object.keys(analysisDecisions)) {
669
+ if (fromDecisionIds.has(decisionId))
670
+ continue;
671
+ const decision = analysisDecisions[decisionId];
672
+ const when = decision.when;
673
+ if (when) {
674
+ if (!isConditionMet(when, allUniverseDecisions)) {
675
+ if (decisionId in universeDecisions) {
676
+ errors.push(new SemanticError("INACTIVE_DECISION", `Universe specifies decision '${decisionId}' but its condition '${JSON.stringify(when)}' is not met`, `${decisionsPath}.${decisionId}`));
677
+ }
678
+ continue;
679
+ }
680
+ }
681
+ if (!(decisionId in universeDecisions)) {
682
+ errors.push(new SemanticError("MISSING_DECISION", `Universe missing decision '${decisionId}'`, `${decisionsPath}.${decisionId}`));
683
+ }
684
+ }
685
+ // Build effective selections (resolve `from:` from the matching ancestor universe).
686
+ const effective = { ...universeDecisions };
687
+ for (const decisionId of fromDecisionIds) {
688
+ const ref = allAnalysisDecisions[decisionId].from ?? "";
689
+ const parsed = parseFromPath(ref);
690
+ if (!parsed || parsed.up <= 0 || parsed.segments.length !== 1)
691
+ continue;
692
+ if (parsed.up > ancestorUniverseChain.length)
693
+ continue;
694
+ const targetUniverse = ancestorUniverseChain[ancestorUniverseChain.length - parsed.up];
695
+ const tgt = parsed.segments[0];
696
+ if (tgt in targetUniverse)
697
+ effective[decisionId] = targetUniverse[tgt];
698
+ }
699
+ errors.push(..._validateNodeUniverseConstraints(effective, analysisDecisions, decisionsPath));
700
+ const analysisSub = (asDict(analysisNode.analyses) ?? {});
701
+ const universeSub = (asDict(universeNode.analyses) ?? {});
702
+ const analysesPrefix = pathPrefix ? `${pathPrefix}.analyses` : "analyses";
703
+ for (const analysisId of Object.keys(universeSub)) {
704
+ if (!(analysisId in analysisSub)) {
705
+ errors.push(new SemanticError("UNKNOWN_ANALYSIS", `Universe references unknown analysis: ${analysisId}`, `${analysesPrefix}.${analysisId}`));
706
+ }
707
+ }
708
+ for (const [analysisId, sub] of Object.entries(analysisSub)) {
709
+ const subUniverse = (universeSub[analysisId] ?? {});
710
+ errors.push(..._validateUniverseNode(subUniverse, sub, `${analysesPrefix}.${analysisId}`, [...ancestorUniverseChain, universeDecisions]));
711
+ }
712
+ return errors;
713
+ }
714
+ function _validateNodeUniverseConstraints(universeDecisions, analysisDecisions, pathPrefix) {
715
+ const errors = [];
716
+ for (const [decisionId, optionId] of Object.entries(universeDecisions)) {
717
+ const decision = analysisDecisions[decisionId];
718
+ if (!decision)
719
+ continue;
720
+ const options = (asDict(decision.options) ?? {});
721
+ const option = options[optionId];
722
+ if (!option)
723
+ continue;
724
+ const path = `${pathPrefix}.${decisionId}`;
725
+ for (const ref of asArray(option.incompatible_with)) {
726
+ const parts = ref.split(".");
727
+ if (parts.length === 2 && universeDecisions[parts[0]] === parts[1]) {
728
+ errors.push(new SemanticError("INCOMPATIBLE_OPTIONS", `Option '${decisionId}.${optionId}' is incompatible with '${ref}'`, path));
729
+ }
730
+ }
731
+ for (const ref of asArray(option.requires)) {
732
+ const parts = ref.split(".");
733
+ if (parts.length === 2 && universeDecisions[parts[0]] !== parts[1]) {
734
+ const actual = universeDecisions[parts[0]] ?? "(not set)";
735
+ errors.push(new SemanticError("MISSING_REQUIRED_OPTION", `Option '${decisionId}.${optionId}' requires '${ref}' but got '${actual}'`, path));
736
+ }
737
+ }
738
+ }
739
+ return errors;
740
+ }
741
+ // ---------------------------------------------------------------------------
742
+ // File-level wrappers
743
+ // ---------------------------------------------------------------------------
744
+ import { dirname } from "node:path";
745
+ export function validateAnalysisFile(filePath) {
746
+ return validateAnalysis(loadYaml(filePath), { basePath: dirname(filePath) });
747
+ }
748
+ export function validateUniverseFile(universePath, analysisPath) {
749
+ return validateUniverse(loadYaml(universePath), loadYaml(analysisPath));
750
+ }
751
+ //# sourceMappingURL=semantic.js.map