@astra-spec/sdk 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,14 @@
1
- // Narrative validation: anchor resolution, coverage warnings, and the
2
- // section-required-when-data-present rule.
1
+ import { dirname } from "node:path";
3
2
 
4
- import { loadYaml, resolveAnalysisTree } from "../helpers.js";
3
+ import {
4
+ type Dict,
5
+ asArray,
6
+ asDict,
7
+ getInputIds,
8
+ getOutputIds,
9
+ loadYaml,
10
+ resolveAnalysisTree,
11
+ } from "../helpers.js";
5
12
  import { SemanticError } from "./semantic.js";
6
13
 
7
14
  const HREF_RE = /\[[^\]]*\]\(([^)\s]+)\)/g;
@@ -29,16 +36,6 @@ const COVERAGE_LABELS: Record<string, string> = {
29
36
 
30
37
  const NARRATIVE_SECTIONS = ["summary", "findings", "methods", "inputs", "outputs"] as const;
31
38
 
32
- type Dict = Record<string, unknown>;
33
-
34
- function asDict(v: unknown): Dict | undefined {
35
- return v && typeof v === "object" && !Array.isArray(v) ? (v as Dict) : undefined;
36
- }
37
-
38
- function asArray(v: unknown): unknown[] {
39
- return Array.isArray(v) ? v : [];
40
- }
41
-
42
39
  export class NarrativeWarning {
43
40
  constructor(
44
41
  public readonly code: string,
@@ -105,14 +102,8 @@ function getNodeAt(root: Dict, path: string[]): Dict | null {
105
102
  }
106
103
 
107
104
  function lookupElement(node: Dict, category: string, elementId: string, optionId: string | null): boolean {
108
- if (category === "inputs") {
109
- const ids = (asArray(node.inputs) as Dict[]).map((i) => i?.id as string | undefined).filter(Boolean);
110
- return ids.includes(elementId);
111
- }
112
- if (category === "outputs") {
113
- const ids = (asArray(node.outputs) as Dict[]).map((o) => o?.id as string | undefined).filter(Boolean);
114
- return ids.includes(elementId);
115
- }
105
+ if (category === "inputs") return getInputIds(node).has(elementId);
106
+ if (category === "outputs") return getOutputIds(node).has(elementId);
116
107
  if (category === "decisions") {
117
108
  const decisions = asDict(node.decisions) ?? {};
118
109
  if (!(elementId in decisions)) return false;
@@ -291,7 +282,7 @@ function* iterCoverageIds(node: Dict, category: string): Generator<string> {
291
282
  yield did;
292
283
  }
293
284
  } else if (category === "outputs") {
294
- for (const out of asArray(node.outputs) as Dict[]) {
285
+ for (const out of asArray<Dict>(node.outputs)) {
295
286
  const oid = out?.id as string | undefined;
296
287
  if (oid) yield oid;
297
288
  }
@@ -374,8 +365,6 @@ function walkSectionRequirements(node: Dict, path: string[], errors: SemanticErr
374
365
  }
375
366
  }
376
367
 
377
- import { dirname } from "node:path";
378
-
379
368
  export function validateNarrativeAnchorsFile(filePath: string): SemanticError[] {
380
369
  return validateNarrativeAnchors(loadYaml(filePath), { basePath: dirname(filePath) });
381
370
  }
@@ -13,7 +13,6 @@ import {
13
13
  loadAstraSchema,
14
14
  } from "../schema/index.js";
15
15
  import {
16
- deepClone,
17
16
  injectAnalysisIdsInPlace,
18
17
  injectUniverseIdsInPlace,
19
18
  loadYaml,
@@ -81,7 +80,7 @@ export async function validateAnalysisData(
81
80
  ): Promise<string[]> {
82
81
  const schema = await resolveSchema(opts);
83
82
  const { analysis } = compileFor(schema);
84
- const prepared = deepClone(data);
83
+ const prepared = structuredClone(data);
85
84
  if (prepared.id === undefined) prepared.id = "root";
86
85
  injectAnalysisIdsInPlace(prepared);
87
86
  if (analysis(prepared)) return [];
@@ -94,7 +93,7 @@ export async function validateUniverseData(
94
93
  ): Promise<string[]> {
95
94
  const schema = await resolveSchema(opts);
96
95
  const { universe } = compileFor(schema);
97
- const prepared = deepClone(data);
96
+ const prepared = structuredClone(data);
98
97
  injectUniverseIdsInPlace(prepared);
99
98
  if (universe(prepared)) return [];
100
99
  return (universe.errors ?? []).map(formatAjvError);
@@ -1,8 +1,9 @@
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.
1
+ import { dirname } from "node:path";
4
2
 
5
3
  import {
4
+ type Dict,
5
+ asArray,
6
+ asDict,
6
7
  collectNodeDecisions,
7
8
  getInputIds,
8
9
  getOutputIds,
@@ -26,16 +27,6 @@ export class SemanticError {
26
27
 
27
28
  const ID_PATTERN = /^[a-z][a-z0-9_]*$/;
28
29
 
29
- type Dict = Record<string, unknown>;
30
-
31
- function asDict(v: unknown): Dict | undefined {
32
- return v && typeof v === "object" && !Array.isArray(v) ? (v as Dict) : undefined;
33
- }
34
-
35
- function asArray(v: unknown): unknown[] {
36
- return Array.isArray(v) ? v : [];
37
- }
38
-
39
30
  /** Parse a `../scope.id` style path. Returns null on malformed input. */
40
31
  function parseFromPath(ref: string): { up: number; segments: string[] } | null {
41
32
  let up = 0;
@@ -101,8 +92,8 @@ export function validateAnalysis(
101
92
  }
102
93
  }
103
94
 
104
- const inputs = asArray(working.inputs) as Dict[];
105
- const outputs = asArray(working.outputs) as Dict[];
95
+ const inputs = asArray<Dict>(working.inputs);
96
+ const outputs = asArray<Dict>(working.outputs);
106
97
  const priorInsights = asDict(working.prior_insights) ?? {};
107
98
 
108
99
  const inputIds = new Set<string>();
@@ -138,7 +129,7 @@ export function validateAnalysis(
138
129
  for (const [analysisId, raw] of Object.entries(subAnalyses)) {
139
130
  const sub = asDict(raw);
140
131
  if (!sub) continue;
141
- for (const out of asArray(sub.outputs) as Dict[]) {
132
+ for (const out of asArray<Dict>(sub.outputs)) {
142
133
  const oid = out?.id as string | undefined;
143
134
  if (oid) subOutputIds.add(`${analysisId}.${oid}`);
144
135
  }
@@ -178,7 +169,6 @@ function _validateAnalysisNode(
178
169
  const errors: SemanticError[] = [];
179
170
  const nodePath = `${pathPrefix}.${nodeId}`;
180
171
 
181
- // Path-only stub (not yet resolved): skip deep checks.
182
172
  if (node.path && !node.inputs && !node.outputs) return errors;
183
173
 
184
174
  for (const field of ["inputs", "outputs"]) {
@@ -193,7 +183,6 @@ function _validateAnalysisNode(
193
183
  }
194
184
  }
195
185
 
196
- // Decision `from:` checks.
197
186
  const allDecisions = (asDict(node.decisions) ?? {}) as Record<string, Dict>;
198
187
  for (const [decisionId, decision] of Object.entries(allDecisions)) {
199
188
  const ref = decision?.from as string | undefined;
@@ -204,8 +193,7 @@ function _validateAnalysisNode(
204
193
  }
205
194
  }
206
195
 
207
- // Inputs.
208
- const nodeInputs = asArray(node.inputs) as Dict[];
196
+ const nodeInputs = asArray<Dict>(node.inputs);
209
197
  const nodeInputIds = new Set<string>();
210
198
  for (const inp of nodeInputs) {
211
199
  const id = inp?.id as string | undefined;
@@ -214,9 +202,8 @@ function _validateAnalysisNode(
214
202
  if (ref) errors.push(..._validateInputFrom(ref, ancestorChain, nodeId, nodePath));
215
203
  }
216
204
 
217
- // Output IDs unique.
218
205
  const nodeOutputIds = new Set<string>();
219
- for (const out of asArray(node.outputs) as Dict[]) {
206
+ for (const out of asArray<Dict>(node.outputs)) {
220
207
  const id = out?.id as string | undefined;
221
208
  if (id && nodeOutputIds.has(id)) {
222
209
  errors.push(
@@ -230,13 +217,13 @@ function _validateAnalysisNode(
230
217
  if (id) nodeOutputIds.add(id);
231
218
  }
232
219
 
233
- const nodeOutputs = asArray(node.outputs) as Dict[];
220
+ const nodeOutputs = asArray<Dict>(node.outputs);
234
221
  errors.push(..._validateOutputsFrom(nodeOutputs, node, nodePath));
235
222
 
236
223
  const nodeDecisions = collectNodeDecisions(node) as Record<string, Dict>;
237
224
 
238
225
  // Build the constraint scope: locally-defined decisions plus any `from:`
239
- // alias resolved one ancestor up (matches the Python `constraint_scope`).
226
+ // alias resolved one ancestor up (matches the Python constraint_scope).
240
227
  const constraintScope: Record<string, Dict> = { ...nodeDecisions };
241
228
  for (const [decisionId, decision] of Object.entries(allDecisions)) {
242
229
  const ref = decision?.from as string | undefined;
@@ -272,7 +259,7 @@ function _validateAnalysisNode(
272
259
  for (const [subId, raw] of Object.entries(subAnalyses)) {
273
260
  const sub = asDict(raw);
274
261
  if (!sub) continue;
275
- for (const out of asArray(sub.outputs) as Dict[]) {
262
+ for (const out of asArray<Dict>(sub.outputs)) {
276
263
  const oid = out?.id as string | undefined;
277
264
  if (oid) subOutputIds.add(`${subId}.${oid}`);
278
265
  }
@@ -334,7 +321,7 @@ function _validateInsightArtifacts(
334
321
  const insight = asDict(raw);
335
322
  if (!insight) continue;
336
323
  const insightPath = `${prefix}.${insightId}`;
337
- const evidenceList = asArray(insight.evidence) as Dict[];
324
+ const evidenceList = asArray<Dict>(insight.evidence);
338
325
  evidenceList.forEach((ev, i) => {
339
326
  const artifactRef = ev?.artifact as string | undefined;
340
327
  if (artifactRef !== undefined && !outputIds.has(artifactRef)) {
@@ -376,57 +363,20 @@ function _validateDecisions(
376
363
  );
377
364
  }
378
365
 
379
- const when = decision.when as string | string[] | undefined;
380
- if (when) {
381
- const conds = typeof when === "string" ? [when] : when;
382
- for (const cond of conds) {
383
- const ref = cond.startsWith("~") ? cond.slice(1) : cond;
384
- const parts = ref.split(".");
385
- if (parts.length !== 2) {
386
- errors.push(
387
- new SemanticError(
388
- "INVALID_WHEN_REF",
389
- `Decision 'when' condition '${cond}' has invalid format`,
390
- decisionPath,
391
- ),
392
- );
393
- continue;
394
- }
395
- const [whenDecisionId, whenOptionId] = parts as [string, string];
396
- if (!(whenDecisionId in decisions) && !(whenDecisionId in scope)) {
397
- errors.push(
398
- new SemanticError(
399
- "INVALID_WHEN_REF",
400
- `'when' references non-existent decision '${whenDecisionId}'`,
401
- decisionPath,
402
- ),
403
- );
404
- } else {
405
- const refDecision = decisions[whenDecisionId] ?? scope[whenDecisionId];
406
- const refOptions = refDecision ? (asDict(refDecision.options) ?? {}) : {};
407
- if (refDecision && !(whenOptionId in refOptions)) {
408
- errors.push(
409
- new SemanticError(
410
- "INVALID_WHEN_REF",
411
- `'when' references non-existent option '${whenOptionId}' in decision '${whenDecisionId}'`,
412
- decisionPath,
413
- ),
414
- );
415
- }
416
- }
417
- if (whenDecisionId === decisionId) {
418
- errors.push(
419
- new SemanticError("INVALID_WHEN_REF", "'when' cannot reference own decision", decisionPath),
420
- );
421
- }
422
- }
423
- }
366
+ errors.push(
367
+ ..._validateWhenRefs(decision.when, {
368
+ decisions: { ...scope, ...decisions },
369
+ path: decisionPath,
370
+ ownerKind: "Decision",
371
+ forbidSelfRef: decisionId,
372
+ }),
373
+ );
424
374
 
425
375
  for (const [optionId, optionRaw] of Object.entries(options)) {
426
376
  const option = optionRaw;
427
377
  const optionPath = `${decisionPath}.options.${optionId}`;
428
378
 
429
- const insightRefs = asArray(option.insights) as string[];
379
+ const insightRefs = asArray<string>(option.insights);
430
380
  insightRefs.forEach((insightRef, i) => {
431
381
  if (!(insightRef in priorInsights)) {
432
382
  errors.push(
@@ -439,10 +389,10 @@ function _validateDecisions(
439
389
  }
440
390
  });
441
391
 
442
- for (const ref of asArray(option.incompatible_with) as string[]) {
392
+ for (const ref of asArray<string>(option.incompatible_with)) {
443
393
  errors.push(..._validateConstraintRef(ref, scope, optionPath));
444
394
  }
445
- for (const ref of asArray(option.requires) as string[]) {
395
+ for (const ref of asArray<string>(option.requires)) {
446
396
  errors.push(..._validateConstraintRef(ref, scope, optionPath));
447
397
  }
448
398
 
@@ -492,52 +442,78 @@ function _validateOutputWhen(
492
442
  ): SemanticError[] {
493
443
  const errors: SemanticError[] = [];
494
444
  const outputsPrefix = pathPrefix ? `${pathPrefix}.outputs` : "outputs";
495
-
496
445
  for (const out of outputs) {
497
446
  const id = out?.id as string | undefined;
498
447
  if (!id) continue;
499
- const when = out.when as string | string[] | undefined;
500
- if (!when) continue;
501
- const conds = typeof when === "string" ? [when] : when;
502
- const outputPath = `${outputsPrefix}.${id}`;
503
- for (const cond of conds) {
504
- const ref = cond.startsWith("~") ? cond.slice(1) : cond;
505
- const parts = ref.split(".");
506
- if (parts.length !== 2) {
507
- errors.push(
508
- new SemanticError(
509
- "INVALID_WHEN_REF",
510
- `Output 'when' condition '${cond}' has invalid format`,
511
- outputPath,
512
- ),
513
- );
514
- continue;
515
- }
516
- const [decisionId, optionId] = parts as [string, string];
517
- if (!(decisionId in decisions)) {
448
+ errors.push(
449
+ ..._validateWhenRefs(out.when, {
450
+ decisions,
451
+ path: `${outputsPrefix}.${id}`,
452
+ ownerKind: "Output",
453
+ }),
454
+ );
455
+ }
456
+ return errors;
457
+ }
458
+
459
+ interface WhenRefContext {
460
+ decisions: Record<string, Dict>;
461
+ path: string;
462
+ ownerKind: "Decision" | "Output";
463
+ /** Decision ID that may not appear in its own `when` clause. */
464
+ forbidSelfRef?: string;
465
+ }
466
+
467
+ function _validateWhenRefs(
468
+ when: unknown,
469
+ ctx: WhenRefContext,
470
+ ): SemanticError[] {
471
+ if (when == null) return [];
472
+ const conds = typeof when === "string" ? [when] : Array.isArray(when) ? (when as string[]) : [];
473
+ const errors: SemanticError[] = [];
474
+ for (const cond of conds) {
475
+ const ref = cond.startsWith("~") ? cond.slice(1) : cond;
476
+ const parts = ref.split(".");
477
+ if (parts.length !== 2) {
478
+ errors.push(
479
+ new SemanticError(
480
+ "INVALID_WHEN_REF",
481
+ `${ctx.ownerKind} 'when' condition '${cond}' has invalid format`,
482
+ ctx.path,
483
+ ),
484
+ );
485
+ continue;
486
+ }
487
+ const [decisionId, optionId] = parts as [string, string];
488
+ const referenced = ctx.decisions[decisionId];
489
+ if (!referenced) {
490
+ const subject = ctx.ownerKind === "Output" ? `${ctx.ownerKind} 'when'` : "'when'";
491
+ errors.push(
492
+ new SemanticError(
493
+ "INVALID_WHEN_REF",
494
+ `${subject} references non-existent decision '${decisionId}'`,
495
+ ctx.path,
496
+ ),
497
+ );
498
+ } else {
499
+ const refOptions = asDict(referenced.options) ?? {};
500
+ if (!(optionId in refOptions)) {
501
+ const subject = ctx.ownerKind === "Output" ? `${ctx.ownerKind} 'when'` : "'when'";
518
502
  errors.push(
519
503
  new SemanticError(
520
504
  "INVALID_WHEN_REF",
521
- `Output 'when' references non-existent decision '${decisionId}'`,
522
- outputPath,
505
+ `${subject} references non-existent option '${optionId}' in decision '${decisionId}'`,
506
+ ctx.path,
523
507
  ),
524
508
  );
525
- } else {
526
- const refDecision = decisions[decisionId]!;
527
- const refOptions = (asDict(refDecision.options) ?? {}) as Record<string, Dict>;
528
- if (!(optionId in refOptions)) {
529
- errors.push(
530
- new SemanticError(
531
- "INVALID_WHEN_REF",
532
- `Output 'when' references non-existent option '${optionId}' in decision '${decisionId}'`,
533
- outputPath,
534
- ),
535
- );
536
- }
537
509
  }
538
510
  }
511
+ if (ctx.forbidSelfRef && decisionId === ctx.forbidSelfRef) {
512
+ errors.push(
513
+ new SemanticError("INVALID_WHEN_REF", "'when' cannot reference own decision", ctx.path),
514
+ );
515
+ }
539
516
  }
540
-
541
517
  return errors;
542
518
  }
543
519
 
@@ -573,7 +549,7 @@ function _validateOutputDependencies(
573
549
  continue;
574
550
  }
575
551
 
576
- const declaredInputs = asArray(out.inputs) as string[];
552
+ const declaredInputs = asArray<string>(out.inputs);
577
553
  depGraph[id] = declaredInputs.filter((i) => siblingOrExtra.has(i));
578
554
 
579
555
  for (const inpId of declaredInputs) {
@@ -588,7 +564,7 @@ function _validateOutputDependencies(
588
564
  }
589
565
  }
590
566
 
591
- const declaredDecisions = asArray(out.decisions) as string[];
567
+ const declaredDecisions = asArray<string>(out.decisions);
592
568
  for (const decId of declaredDecisions) {
593
569
  if (!(decId in decisionsInScope)) {
594
570
  errors.push(
@@ -918,10 +894,6 @@ function _validateConstraintRef(
918
894
  return [];
919
895
  }
920
896
 
921
- // ---------------------------------------------------------------------------
922
- // Universe validation
923
- // ---------------------------------------------------------------------------
924
-
925
897
  export function validateUniverse(
926
898
  universeData: Dict,
927
899
  analysisData: Dict,
@@ -1082,7 +1054,7 @@ function _validateNodeUniverseConstraints(
1082
1054
  if (!option) continue;
1083
1055
  const path = `${pathPrefix}.${decisionId}`;
1084
1056
 
1085
- for (const ref of asArray(option.incompatible_with) as string[]) {
1057
+ for (const ref of asArray<string>(option.incompatible_with)) {
1086
1058
  const parts = ref.split(".");
1087
1059
  if (parts.length === 2 && universeDecisions[parts[0]!] === parts[1]!) {
1088
1060
  errors.push(
@@ -1094,7 +1066,7 @@ function _validateNodeUniverseConstraints(
1094
1066
  );
1095
1067
  }
1096
1068
  }
1097
- for (const ref of asArray(option.requires) as string[]) {
1069
+ for (const ref of asArray<string>(option.requires)) {
1098
1070
  const parts = ref.split(".");
1099
1071
  if (parts.length === 2 && universeDecisions[parts[0]!] !== parts[1]!) {
1100
1072
  const actual = universeDecisions[parts[0]!] ?? "(not set)";
@@ -1111,17 +1083,11 @@ function _validateNodeUniverseConstraints(
1111
1083
  return errors;
1112
1084
  }
1113
1085
 
1114
- // ---------------------------------------------------------------------------
1115
- // File-level wrappers
1116
- // ---------------------------------------------------------------------------
1117
-
1118
- import { dirname } from "node:path";
1119
-
1120
- export function validateAnalysisFile(filePath: string): SemanticError[] {
1086
+ export function semanticValidateAnalysisFile(filePath: string): SemanticError[] {
1121
1087
  return validateAnalysis(loadYaml(filePath), { basePath: dirname(filePath) });
1122
1088
  }
1123
1089
 
1124
- export function validateUniverseFile(
1090
+ export function semanticValidateUniverseFile(
1125
1091
  universePath: string,
1126
1092
  analysisPath: string,
1127
1093
  ): SemanticError[] {