@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,389 @@
1
+ // Narrative validation: anchor resolution, coverage warnings, and the
2
+ // section-required-when-data-present rule.
3
+
4
+ import { loadYaml, resolveAnalysisTree } from "../helpers.js";
5
+ import { SemanticError } from "./semantic.js";
6
+
7
+ const HREF_RE = /\[[^\]]*\]\(([^)\s]+)\)/g;
8
+ const PARENT_PATH_FORM_RE = /^(?:\.\.\/)+#/;
9
+
10
+ const CATEGORIES = new Set([
11
+ "inputs",
12
+ "outputs",
13
+ "decisions",
14
+ "findings",
15
+ "prior_insights",
16
+ "analyses",
17
+ ]);
18
+
19
+ const ANALYSES_PREFIX_FORM_RE = new RegExp(
20
+ `^analyses\\.[^.]+\\.(?:${[...CATEGORIES].sort().join("|")})(?:\\.|$)`,
21
+ );
22
+
23
+ const COVERAGE_LABELS: Record<string, string> = {
24
+ decisions: "Decision",
25
+ findings: "Finding",
26
+ outputs: "Output",
27
+ analyses: "Sub-analysis",
28
+ };
29
+
30
+ const NARRATIVE_SECTIONS = ["summary", "findings", "methods", "inputs", "outputs"] as const;
31
+
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
+ export class NarrativeWarning {
43
+ constructor(
44
+ public readonly code: string,
45
+ public readonly message: string,
46
+ public readonly path?: string,
47
+ ) {}
48
+ toString(): string {
49
+ return this.path ? `[${this.code}] ${this.path}: ${this.message}` : `[${this.code}] ${this.message}`;
50
+ }
51
+ }
52
+
53
+ interface ParsedAnchor {
54
+ raw: string;
55
+ upLevels: number;
56
+ subPath: string[];
57
+ category: string;
58
+ elementId: string;
59
+ optionId: string | null;
60
+ }
61
+
62
+ function parseAnchor(raw: string): ParsedAnchor | null {
63
+ let up = 0;
64
+ let remaining = raw;
65
+ while (remaining.startsWith("../")) {
66
+ up += 1;
67
+ remaining = remaining.slice(3);
68
+ }
69
+ if (!remaining) return null;
70
+ const segments = remaining.split(".");
71
+ if (segments.some((s) => !s)) return null;
72
+ let catIdx = -1;
73
+ for (let i = 0; i < segments.length; i++) {
74
+ if (CATEGORIES.has(segments[i]!)) {
75
+ catIdx = i;
76
+ break;
77
+ }
78
+ }
79
+ if (catIdx === -1) return null;
80
+ const subPath = segments.slice(0, catIdx);
81
+ const category = segments[catIdx]!;
82
+ const tail = segments.slice(catIdx + 1);
83
+ if (tail.length === 0) return null;
84
+ const elementId = tail[0]!;
85
+ let optionId: string | null = null;
86
+ if (category === "decisions") {
87
+ if (tail.length === 3 && tail[1] === "options") optionId = tail[2]!;
88
+ else if (tail.length > 1) return null;
89
+ } else if (tail.length > 1) {
90
+ return null;
91
+ }
92
+ return { raw, upLevels: up, subPath, category, elementId, optionId };
93
+ }
94
+
95
+ function getNodeAt(root: Dict, path: string[]): Dict | null {
96
+ let current: Dict = root;
97
+ for (const seg of path) {
98
+ const analyses = asDict(current.analyses) ?? {};
99
+ if (!(seg in analyses)) return null;
100
+ const next = asDict(analyses[seg]);
101
+ if (!next) return null;
102
+ current = next;
103
+ }
104
+ return current;
105
+ }
106
+
107
+ 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
+ }
116
+ if (category === "decisions") {
117
+ const decisions = asDict(node.decisions) ?? {};
118
+ if (!(elementId in decisions)) return false;
119
+ if (optionId === null) return true;
120
+ const options = asDict((decisions[elementId] as Dict).options) ?? {};
121
+ return optionId in options;
122
+ }
123
+ if (category === "findings") return elementId in (asDict(node.findings) ?? {});
124
+ if (category === "prior_insights") return elementId in (asDict(node.prior_insights) ?? {});
125
+ if (category === "analyses") return elementId in (asDict(node.analyses) ?? {});
126
+ return false;
127
+ }
128
+
129
+ function resolveAnchor(
130
+ anchor: ParsedAnchor,
131
+ hostingPath: string[],
132
+ root: Dict,
133
+ ): { targetPath: string[]; category: string; elementId: string; optionId: string | null } | null {
134
+ if (anchor.upLevels > hostingPath.length) return null;
135
+ const base = hostingPath.slice(0, hostingPath.length - anchor.upLevels);
136
+ const targetPath = [...base, ...anchor.subPath];
137
+ const targetNode = getNodeAt(root, targetPath);
138
+ if (!targetNode) return null;
139
+ if (!lookupElement(targetNode, anchor.category, anchor.elementId, anchor.optionId)) return null;
140
+ return { targetPath, category: anchor.category, elementId: anchor.elementId, optionId: anchor.optionId };
141
+ }
142
+
143
+ function* iterSections(narrative: unknown): Generator<[string, string]> {
144
+ const dict = asDict(narrative);
145
+ if (!dict) return;
146
+ for (const section of NARRATIVE_SECTIONS) {
147
+ const content = dict[section];
148
+ if (typeof content === "string" && content) yield [section, content];
149
+ }
150
+ }
151
+
152
+ function* extractSectionHrefs(narrative: unknown): Generator<[string, string]> {
153
+ for (const [section, content] of iterSections(narrative)) {
154
+ HREF_RE.lastIndex = 0;
155
+ let match: RegExpExecArray | null;
156
+ while ((match = HREF_RE.exec(content)) !== null) {
157
+ yield [section, match[1]!];
158
+ }
159
+ }
160
+ }
161
+
162
+ function nodePathStr(path: string[]): string {
163
+ return path.map((seg) => `analyses.${seg}`).join(".");
164
+ }
165
+
166
+ function narrativeReportPath(base: string, section: string): string {
167
+ return base ? `${base}.narrative.${section}` : `narrative.${section}`;
168
+ }
169
+
170
+ export function validateNarrativeAnchors(
171
+ data: Dict,
172
+ options: { basePath?: string } = {},
173
+ ): SemanticError[] {
174
+ let working = data;
175
+ if (options.basePath) working = resolveAnalysisTree(data, options.basePath);
176
+ const errors: SemanticError[] = [];
177
+ walkAnchors(working, [], working, errors);
178
+ return errors;
179
+ }
180
+
181
+ function walkAnchors(node: Dict, path: string[], root: Dict, errors: SemanticError[]): void {
182
+ const narrative = node.narrative;
183
+ if (narrative) {
184
+ const base = nodePathStr(path);
185
+ for (const [section, href] of extractSectionHrefs(narrative)) {
186
+ const narrPath = narrativeReportPath(base, section);
187
+ if (PARENT_PATH_FORM_RE.test(href)) {
188
+ errors.push(
189
+ new SemanticError(
190
+ "INVALID_NARRATIVE_ANCHOR",
191
+ `Anchor '${href}' uses non-canonical parent escape; move '../' inside the fragment ` +
192
+ `(e.g. '#../target' instead of '../#target')`,
193
+ narrPath,
194
+ ),
195
+ );
196
+ continue;
197
+ }
198
+ if (!href.startsWith("#")) continue;
199
+ const raw = href.slice(1);
200
+ if (!raw.includes(".")) continue;
201
+ const parsed = parseAnchor(raw);
202
+ if (!parsed) {
203
+ let stripped = raw;
204
+ while (stripped.startsWith("../")) stripped = stripped.slice(3);
205
+ if (ANALYSES_PREFIX_FORM_RE.test(stripped)) {
206
+ errors.push(
207
+ new SemanticError(
208
+ "INVALID_NARRATIVE_ANCHOR",
209
+ `Anchor '${href}' starts with 'analyses.<sub>' but drilling below the sub-analysis ` +
210
+ `node uses the tree-path form: write '#<sub>.<category>.<id>' (or ` +
211
+ `'#../<sib>.<category>.<id>' from a sibling) instead.`,
212
+ narrPath,
213
+ ),
214
+ );
215
+ continue;
216
+ }
217
+ errors.push(
218
+ new SemanticError(
219
+ "INVALID_NARRATIVE_ANCHOR",
220
+ `Anchor '#${raw}' does not match the narrative anchor grammar`,
221
+ narrPath,
222
+ ),
223
+ );
224
+ continue;
225
+ }
226
+ if (resolveAnchor(parsed, path, root) === null) {
227
+ errors.push(
228
+ new SemanticError(
229
+ "BROKEN_NARRATIVE_ANCHOR",
230
+ `Anchor '#${raw}' does not resolve to a declared element`,
231
+ narrPath,
232
+ ),
233
+ );
234
+ }
235
+ }
236
+ }
237
+ const subAnalyses = asDict(node.analyses) ?? {};
238
+ for (const [subId, raw] of Object.entries(subAnalyses)) {
239
+ const sub = asDict(raw);
240
+ if (sub) walkAnchors(sub, [...path, subId], root, errors);
241
+ }
242
+ }
243
+
244
+ export function checkNarrativeCoverage(
245
+ data: Dict,
246
+ options: { basePath?: string } = {},
247
+ ): NarrativeWarning[] {
248
+ let working = data;
249
+ if (options.basePath) working = resolveAnalysisTree(data, options.basePath);
250
+ const mentioned = new Set<string>();
251
+ collectMentioned(working, [], working, mentioned);
252
+ const warnings: NarrativeWarning[] = [];
253
+ walkCoverage(working, [], mentioned, warnings);
254
+ return warnings;
255
+ }
256
+
257
+ function mentionKey(path: string[], category: string, elementId: string): string {
258
+ return `${path.join("/")}|${category}|${elementId}`;
259
+ }
260
+
261
+ function collectMentioned(node: Dict, path: string[], root: Dict, mentioned: Set<string>): void {
262
+ const narrative = node.narrative;
263
+ if (narrative) {
264
+ for (const [, href] of extractSectionHrefs(narrative)) {
265
+ if (!href.startsWith("#")) continue;
266
+ const raw = href.slice(1);
267
+ if (!raw.includes(".")) continue;
268
+ const parsed = parseAnchor(raw);
269
+ if (!parsed) continue;
270
+ const resolved = resolveAnchor(parsed, path, root);
271
+ if (!resolved) continue;
272
+ mentioned.add(mentionKey(resolved.targetPath, resolved.category, resolved.elementId));
273
+ // Each ancestor sub-analysis along the resolved target counts as mentioned.
274
+ for (let i = 0; i < resolved.targetPath.length; i++) {
275
+ mentioned.add(mentionKey(resolved.targetPath.slice(0, i), "analyses", resolved.targetPath[i]!));
276
+ }
277
+ }
278
+ }
279
+ const subAnalyses = asDict(node.analyses) ?? {};
280
+ for (const [subId, raw] of Object.entries(subAnalyses)) {
281
+ const sub = asDict(raw);
282
+ if (sub) collectMentioned(sub, [...path, subId], root, mentioned);
283
+ }
284
+ }
285
+
286
+ function* iterCoverageIds(node: Dict, category: string): Generator<string> {
287
+ if (category === "decisions") {
288
+ const decisions = asDict(node.decisions) ?? {};
289
+ for (const [did, decision] of Object.entries(decisions)) {
290
+ if (decision && (decision as Dict).from) continue;
291
+ yield did;
292
+ }
293
+ } else if (category === "outputs") {
294
+ for (const out of asArray(node.outputs) as Dict[]) {
295
+ const oid = out?.id as string | undefined;
296
+ if (oid) yield oid;
297
+ }
298
+ } else {
299
+ yield* Object.keys(asDict(node[category]) ?? {});
300
+ }
301
+ }
302
+
303
+ function walkCoverage(
304
+ node: Dict,
305
+ path: string[],
306
+ mentioned: Set<string>,
307
+ warnings: NarrativeWarning[],
308
+ ): void {
309
+ const base = nodePathStr(path);
310
+ for (const [category, label] of Object.entries(COVERAGE_LABELS)) {
311
+ for (const eid of iterCoverageIds(node, category)) {
312
+ if (mentioned.has(mentionKey(path, category, eid))) continue;
313
+ const elementPath = base ? `${base}.${category}.${eid}` : `${category}.${eid}`;
314
+ warnings.push(
315
+ new NarrativeWarning(
316
+ "NARRATIVE_UNMENTIONED",
317
+ `${label} '${eid}' is not mentioned in any narrative`,
318
+ elementPath,
319
+ ),
320
+ );
321
+ }
322
+ }
323
+ const subAnalyses = asDict(node.analyses) ?? {};
324
+ for (const [subId, raw] of Object.entries(subAnalyses)) {
325
+ const sub = asDict(raw);
326
+ if (sub) walkCoverage(sub, [...path, subId], mentioned, warnings);
327
+ }
328
+ }
329
+
330
+ const DATA_TRIGGERED_SECTIONS: Record<string, string[]> = {
331
+ findings: ["findings"],
332
+ methods: ["decisions", "analyses"],
333
+ inputs: ["inputs"],
334
+ outputs: ["outputs"],
335
+ };
336
+
337
+ export function validateNarrativeSections(
338
+ data: Dict,
339
+ options: { basePath?: string } = {},
340
+ ): SemanticError[] {
341
+ let working = data;
342
+ if (options.basePath) working = resolveAnalysisTree(data, options.basePath);
343
+ const errors: SemanticError[] = [];
344
+ walkSectionRequirements(working, [], errors);
345
+ return errors;
346
+ }
347
+
348
+ function walkSectionRequirements(node: Dict, path: string[], errors: SemanticError[]): void {
349
+ const narrative = asDict(node.narrative) ?? {};
350
+ const base = nodePathStr(path);
351
+ for (const [section, triggers] of Object.entries(DATA_TRIGGERED_SECTIONS)) {
352
+ const present = triggers.filter((k) => {
353
+ const v = node[k];
354
+ if (Array.isArray(v)) return v.length > 0;
355
+ if (v && typeof v === "object") return Object.keys(v).length > 0;
356
+ return Boolean(v);
357
+ });
358
+ if (present.length === 0) continue;
359
+ const content = narrative[section];
360
+ if (typeof content === "string" && content.trim()) continue;
361
+ const triggersStr = present.map((k) => `'${k}'`).join(" and ");
362
+ errors.push(
363
+ new SemanticError(
364
+ "NARRATIVE_SECTION_REQUIRED",
365
+ `Narrative section '${section}' is required because ${triggersStr} has entries`,
366
+ narrativeReportPath(base, section),
367
+ ),
368
+ );
369
+ }
370
+ const subAnalyses = asDict(node.analyses) ?? {};
371
+ for (const [subId, raw] of Object.entries(subAnalyses)) {
372
+ const sub = asDict(raw);
373
+ if (sub) walkSectionRequirements(sub, [...path, subId], errors);
374
+ }
375
+ }
376
+
377
+ import { dirname } from "node:path";
378
+
379
+ export function validateNarrativeAnchorsFile(filePath: string): SemanticError[] {
380
+ return validateNarrativeAnchors(loadYaml(filePath), { basePath: dirname(filePath) });
381
+ }
382
+
383
+ export function checkNarrativeCoverageFile(filePath: string): NarrativeWarning[] {
384
+ return checkNarrativeCoverage(loadYaml(filePath), { basePath: dirname(filePath) });
385
+ }
386
+
387
+ export function validateNarrativeSectionsFile(filePath: string): SemanticError[] {
388
+ return validateNarrativeSections(loadYaml(filePath), { basePath: dirname(filePath) });
389
+ }
@@ -0,0 +1,132 @@
1
+ // Structural (JSON Schema) validation. The schema itself is fetched on
2
+ // demand from astra-spec.org via `loadAstraSchema` and cached in memory
3
+ // + on disk; consumers can also pass a pre-loaded schema directly.
4
+
5
+ import Ajv2019 from "ajv/dist/2019.js";
6
+ import type { ErrorObject, ValidateFunction } from "ajv";
7
+ import addFormats from "ajv-formats";
8
+
9
+ import {
10
+ type JsonSchema,
11
+ type SchemaLoadOptions,
12
+ astraSchemaUrl,
13
+ loadAstraSchema,
14
+ } from "../schema/index.js";
15
+ import {
16
+ deepClone,
17
+ injectAnalysisIdsInPlace,
18
+ injectUniverseIdsInPlace,
19
+ loadYaml,
20
+ } from "../helpers.js";
21
+
22
+ export interface ValidateOptions extends SchemaLoadOptions {
23
+ /** Pre-loaded schema. Wins over any loader options. */
24
+ schema?: JsonSchema;
25
+ }
26
+
27
+ interface CompiledValidators {
28
+ analysis: ValidateFunction;
29
+ universe: ValidateFunction;
30
+ }
31
+
32
+ const _compiledCache = new WeakMap<JsonSchema, CompiledValidators>();
33
+
34
+ function compileFor(schema: JsonSchema): CompiledValidators {
35
+ const cached = _compiledCache.get(schema);
36
+ if (cached) return cached;
37
+
38
+ // The published spec uses JSON Schema draft 2019-09.
39
+ const ajv = new Ajv2019({ allErrors: true, strict: false, allowUnionTypes: true });
40
+ addFormats(ajv);
41
+
42
+ const analysis = ajv.compile(schema);
43
+
44
+ // Wrap to validate against `#/$defs/Universe`. Don't spread the root —
45
+ // its top-level Analysis keywords would still apply alongside `$ref`.
46
+ const universeWrapper: Record<string, unknown> = {
47
+ $schema: schema.$schema,
48
+ $defs: schema.$defs,
49
+ $ref: "#/$defs/Universe",
50
+ };
51
+ const universe = ajv.compile(universeWrapper);
52
+
53
+ const compiled = { analysis, universe };
54
+ _compiledCache.set(schema, compiled);
55
+ return compiled;
56
+ }
57
+
58
+ async function resolveSchema(opts: ValidateOptions): Promise<JsonSchema> {
59
+ if (opts.schema) return opts.schema;
60
+ return loadAstraSchema(opts);
61
+ }
62
+
63
+ function formatAjvError(err: ErrorObject): string {
64
+ const path = err.instancePath
65
+ ? err.instancePath.replace(/^\//, "").split("/").join(".")
66
+ : "(root)";
67
+ let msg = err.message ?? "validation error";
68
+ if (err.keyword === "required" && (err.params as { missingProperty?: string }).missingProperty) {
69
+ const missing = (err.params as { missingProperty: string }).missingProperty;
70
+ msg = `missing required property '${missing}'`;
71
+ }
72
+ return path === "(root)" ? `(root): ${msg}` : `${path}: ${msg}`;
73
+ }
74
+
75
+ /** Validate an Analysis dict against the JSON Schema. Returns error
76
+ * strings (empty when valid). The schema is fetched from astra-spec.org
77
+ * on first use unless `opts.schema` is provided. */
78
+ export async function validateAnalysisData(
79
+ data: Record<string, unknown>,
80
+ opts: ValidateOptions = {},
81
+ ): Promise<string[]> {
82
+ const schema = await resolveSchema(opts);
83
+ const { analysis } = compileFor(schema);
84
+ const prepared = deepClone(data);
85
+ if (prepared.id === undefined) prepared.id = "root";
86
+ injectAnalysisIdsInPlace(prepared);
87
+ if (analysis(prepared)) return [];
88
+ return (analysis.errors ?? []).map(formatAjvError);
89
+ }
90
+
91
+ export async function validateUniverseData(
92
+ data: Record<string, unknown>,
93
+ opts: ValidateOptions = {},
94
+ ): Promise<string[]> {
95
+ const schema = await resolveSchema(opts);
96
+ const { universe } = compileFor(schema);
97
+ const prepared = deepClone(data);
98
+ injectUniverseIdsInPlace(prepared);
99
+ if (universe(prepared)) return [];
100
+ return (universe.errors ?? []).map(formatAjvError);
101
+ }
102
+
103
+ export async function validateAnalysisFile(
104
+ filePath: string,
105
+ opts: ValidateOptions = {},
106
+ ): Promise<string[]> {
107
+ return validateAnalysisData(loadYaml(filePath), opts);
108
+ }
109
+
110
+ export async function validateUniverseFile(
111
+ filePath: string,
112
+ opts: ValidateOptions = {},
113
+ ): Promise<string[]> {
114
+ return validateUniverseData(loadYaml(filePath), opts);
115
+ }
116
+
117
+ export async function isValidAnalysis(
118
+ filePath: string,
119
+ opts: ValidateOptions = {},
120
+ ): Promise<boolean> {
121
+ return (await validateAnalysisFile(filePath, opts)).length === 0;
122
+ }
123
+
124
+ export async function isValidUniverse(
125
+ filePath: string,
126
+ opts: ValidateOptions = {},
127
+ ): Promise<boolean> {
128
+ return (await validateUniverseFile(filePath, opts)).length === 0;
129
+ }
130
+
131
+ // Re-export for convenience so callers don't need to import from two paths.
132
+ export { astraSchemaUrl, loadAstraSchema } from "../schema/index.js";