@sprig-and-prose/sprig-scenes 0.1.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 (35) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/biome.json +23 -0
  3. package/docs/projection-expectations.md +188 -0
  4. package/example/players.example.js +21 -0
  5. package/examples/expectations-demo.js +146 -0
  6. package/package.json +36 -0
  7. package/scripts/build-canonical.js +29 -0
  8. package/src/api/PortalHandle.js +22 -0
  9. package/src/api/SceneCompiler.js +24 -0
  10. package/src/api/SceneInstance.js +50 -0
  11. package/src/api/SceneRuntime.js +14 -0
  12. package/src/api/formatUnknownMemberError.js +18 -0
  13. package/src/attestations/attestations.js +65 -0
  14. package/src/attestations/compute_status.js +101 -0
  15. package/src/canonical/actor.scene.json +20 -0
  16. package/src/canonical/actor.scene.prose +8 -0
  17. package/src/canonical/actors.scene.json +74 -0
  18. package/src/canonical/actors.scene.prose +39 -0
  19. package/src/canonical/tools.scene.json +198 -0
  20. package/src/canonical/tools.scene.prose +92 -0
  21. package/src/compiler/compile_scene.js +1885 -0
  22. package/src/compiler/compile_scene_file.js +15 -0
  23. package/src/index.js +41 -0
  24. package/src/manifest/scene_manifest.d.ts +98 -0
  25. package/src/manifest/scene_manifest.js +84 -0
  26. package/test/api.test.js +110 -0
  27. package/test/compile_scene.test.js +311 -0
  28. package/test/expectations.test.js +508 -0
  29. package/test/fixtures/expectations-basic.attestations.json +33 -0
  30. package/test/fixtures/expectations-basic.scene.prose +79 -0
  31. package/test/fixtures/players.scene.prose +24 -0
  32. package/test/fixtures/skills-many-some.scene.prose +53 -0
  33. package/test/fixtures/skills-many.scene.prose +71 -0
  34. package/test/fixtures/skills.scene.prose +85 -0
  35. package/tsconfig.json +14 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @sprig-and-prose/sprig-scenes
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 9557987: Add dataset expectations, sprig-edge initial commit, and scene tutorial scaffolder
package/biome.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3
+ "organizeImports": {
4
+ "enabled": true
5
+ },
6
+ "linter": {
7
+ "enabled": true,
8
+ "rules": {
9
+ "recommended": true
10
+ }
11
+ },
12
+ "formatter": {
13
+ "enabled": true,
14
+ "indentStyle": "space",
15
+ "indentWidth": 2
16
+ },
17
+ "javascript": {
18
+ "formatter": {
19
+ "quoteStyle": "single",
20
+ "semicolons": "always"
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,188 @@
1
+ # Projection Expectations
2
+
3
+ A metadata layer for declaring and tracking where actors are expected to be projected to external systems.
4
+
5
+ ## Overview
6
+
7
+ Projection Expectations provide a **TDD-style approach for system boundaries**. During planning, you declare where actors should be projected (e.g., to Postgres tables, Snowflake warehouses, or OpenAPI schemas). Later, attestations verify whether those projections actually exist.
8
+
9
+ This is metadata-only. No live database connections or verification logic is implemented in v0.
10
+
11
+ ## Syntax
12
+
13
+ ### 1. Declare Locations (Top-Level)
14
+
15
+ ```sprig
16
+ locations {
17
+ TeamPostgres {
18
+ kind { 'postgres' }
19
+ }
20
+
21
+ Analytics {
22
+ kind { 'snowflake' }
23
+ }
24
+
25
+ PublicAPI {
26
+ kind { 'openapi' }
27
+ }
28
+ }
29
+ ```
30
+
31
+ **Location kinds are arbitrary strings:**
32
+ - Use any string literal that describes your datastore
33
+ - Examples: `'postgres'`, `'snowflake'`, `'openapi'`, `'fhir.r4'`, `'legacy-mainframe'`
34
+ - No hardcoded validation - sprig is in the truth business, not the integration catalog business
35
+
36
+ ### 2. Declare Expectations (Actor-Level)
37
+
38
+ ```sprig
39
+ actor PlayerSkill {
40
+ kind {
41
+ player { Player }
42
+ skill { Skill }
43
+ experience { integer }
44
+ }
45
+
46
+ identity { player skill }
47
+
48
+ expects {
49
+ project {
50
+ to { TeamPostgres }
51
+ as { table { player_skills } }
52
+ }
53
+
54
+ project {
55
+ to { PublicAPI }
56
+ as { schema { PlayerSkill } }
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ **Supported artifact types:**
63
+ - `table { name }` - Database table
64
+ - `schema { name }` - API schema
65
+
66
+ ## Attestations
67
+
68
+ Attestations are stored in `.sprig/SCENENAME.attestations.json`:
69
+
70
+ ```json
71
+ {
72
+ "version": 1,
73
+ "actors": {
74
+ "PlayerSkill": [
75
+ {
76
+ "location": "TeamPostgres",
77
+ "artifactType": "table",
78
+ "name": "player_skills",
79
+ "status": "present",
80
+ "observedAt": "2026-02-13T10:00:00Z",
81
+ "shapeHash": "sha256:abc123"
82
+ }
83
+ ]
84
+ }
85
+ }
86
+ ```
87
+
88
+ ## Status Computation
89
+
90
+ Use `computeProjectionStatus()` to compare expectations with attestations:
91
+
92
+ ```javascript
93
+ import { compileSceneFromText } from '@sprig-and-prose/sprig-scenes';
94
+ import { loadAttestations, computeProjectionStatus } from '@sprig-and-prose/sprig-scenes';
95
+
96
+ const manifest = compileSceneFromText(sceneSource);
97
+ const attestations = await loadAttestations('.sprig/MyScene.attestations.json');
98
+
99
+ const status = computeProjectionStatus(manifest, attestations);
100
+
101
+ // Result:
102
+ // {
103
+ // actors: {
104
+ // PlayerSkill: [
105
+ // {
106
+ // location: "TeamPostgres",
107
+ // artifactType: "table",
108
+ // name: "player_skills",
109
+ // status: "attested", // or "expected" or "failed"
110
+ // expectation: { ... },
111
+ // attestation: { ... } // present if attested/failed
112
+ // }
113
+ // ]
114
+ // }
115
+ // }
116
+ ```
117
+
118
+ ### Status Values
119
+
120
+ - **`expected`** (grey) - Projection declared but not yet attested
121
+ - **`attested`** (green) - Attestation exists with status "present"
122
+ - **`failed`** (red) - Attestation exists but status is not "present"
123
+ - **`drifted`** (orange) - Reserved for future shape comparison (not implemented)
124
+
125
+ ## API
126
+
127
+ ### Compilation
128
+
129
+ ```javascript
130
+ import { compileSceneFromText } from '@sprig-and-prose/sprig-scenes/compiler';
131
+
132
+ const manifest = compileSceneFromText(sourceCode);
133
+ // manifest.locations → { LocationName: { kind: string } }
134
+ // manifest.actors.ActorName.expectations → ProjectionExpectation[]
135
+ ```
136
+
137
+ ### Attestations
138
+
139
+ ```javascript
140
+ import { loadAttestations, saveAttestations } from '@sprig-and-prose/sprig-scenes/attestations';
141
+
142
+ // Load (returns empty structure if file doesn't exist)
143
+ const attestations = await loadAttestations('path/to/file.json');
144
+
145
+ // Save
146
+ await saveAttestations('path/to/file.json', attestations);
147
+ ```
148
+
149
+ ### Status Computation
150
+
151
+ ```javascript
152
+ import { computeProjectionStatus } from '@sprig-and-prose/sprig-scenes/attestations/compute-status';
153
+
154
+ const status = computeProjectionStatus(manifest, attestations);
155
+ ```
156
+
157
+ ## Validation
158
+
159
+ The compiler validates:
160
+ - Location names are unique
161
+ - Location kinds are string literals (any string is valid)
162
+ - Artifact types are valid (`table`, `schema`)
163
+ - Expectation locations reference declared locations
164
+
165
+ Invalid expectations throw errors during compilation.
166
+
167
+ ## Example
168
+
169
+ See `examples/expectations-demo.js` for a complete working example.
170
+
171
+ ## Future Work (Not Implemented in v0)
172
+
173
+ - Live database verification
174
+ - Shape diffing and migration detection (status: "drifted")
175
+ - Automated attestation generation
176
+ - Additional artifact types (views, indices, etc.)
177
+
178
+ Note: Location kinds are intentionally unrestricted - sprig stays in the truth business, not the integration catalog business.
179
+
180
+ ## Philosophy
181
+
182
+ **Expectations are declarations, not implementations.**
183
+
184
+ They specify *what should exist* without prescribing *how to create it*. This separation allows:
185
+ - Early planning without infrastructure
186
+ - UI visualization of projection status
187
+ - Future integration with verification tools
188
+ - Clear boundaries between truth layer and projection layer
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Tutorial-style example: compile scene, set portal, call derived.
3
+ * Run from repo root: node --import tsx packages/scenes/example/players.example.js
4
+ * Or from packages/scenes: node --import tsx example/players.example.js
5
+ */
6
+ import { join, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import Scenes from '../src/index.js';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const scenePath = join(__dirname, '..', 'test', 'fixtures', 'players.scene.prose');
12
+
13
+ const scene = await Scenes.compileFromPath(scenePath);
14
+
15
+ scene.Players.set([
16
+ { id: 1, name: 'John' },
17
+ { id: 2, name: 'Jane' },
18
+ ]);
19
+
20
+ console.log(scene.PlayerCount()); // 2
21
+ console.log(scene.PlayerNames()); // 'Jane, John' (sorted ascending)
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @fileoverview Demo: How to use projection expectations
3
+ */
4
+
5
+ import { compileSceneFromText } from '../src/compiler/compile_scene.js';
6
+ import { computeProjectionStatus } from '../src/attestations/compute_status.js';
7
+
8
+ // Example scene with locations and expectations
9
+ const sceneSource = `
10
+ scene DemoScene {
11
+ locations {
12
+ ProductionDB {
13
+ kind { 'postgres' }
14
+ }
15
+
16
+ Analytics {
17
+ kind { 'snowflake' }
18
+ }
19
+
20
+ PublicAPI {
21
+ kind { 'openapi' }
22
+ }
23
+ }
24
+
25
+ actors {
26
+ Player {
27
+ kind {
28
+ id { integer }
29
+ name { string }
30
+ email { string }
31
+ }
32
+
33
+ identity { id }
34
+
35
+ expects {
36
+ project {
37
+ to { ProductionDB }
38
+ as { table { players } }
39
+ }
40
+
41
+ project {
42
+ to { Analytics }
43
+ as { table { dim_players } }
44
+ }
45
+
46
+ project {
47
+ to { PublicAPI }
48
+ as { schema { Player } }
49
+ }
50
+ }
51
+ }
52
+
53
+ Game {
54
+ kind {
55
+ id { integer }
56
+ title { string }
57
+ }
58
+
59
+ identity { id }
60
+
61
+ expects {
62
+ project {
63
+ to { ProductionDB }
64
+ as { table { games } }
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ portals {
71
+ Players {
72
+ kind { [ Player ] }
73
+ }
74
+ }
75
+ }
76
+ `;
77
+
78
+ // Compile the scene
79
+ console.log('📝 Compiling scene...\n');
80
+ const manifest = compileSceneFromText(sceneSource);
81
+
82
+ console.log('✅ Scene compiled successfully!');
83
+ console.log(` Scene name: ${manifest.sceneName}`);
84
+ console.log(` Locations: ${Object.keys(manifest.locations).join(', ')}`);
85
+ console.log(` Actors: ${Object.keys(manifest.actors).join(', ')}\n`);
86
+
87
+ // Show expectations
88
+ console.log('🎯 Projection Expectations:\n');
89
+ for (const [actorName, actorDef] of Object.entries(manifest.actors)) {
90
+ if (actorDef.expectations) {
91
+ console.log(` ${actorName}:`);
92
+ for (const exp of actorDef.expectations) {
93
+ console.log(` → ${exp.location} / ${exp.artifactType} / ${exp.name}`);
94
+ }
95
+ }
96
+ }
97
+
98
+ // Simulate attestations (some present, some missing)
99
+ const attestations = {
100
+ version: 1,
101
+ actors: {
102
+ Player: [
103
+ {
104
+ location: 'ProductionDB',
105
+ artifactType: 'table',
106
+ name: 'players',
107
+ status: 'present',
108
+ observedAt: '2026-02-13T12:00:00Z',
109
+ shapeHash: 'sha256:abc123',
110
+ },
111
+ {
112
+ location: 'PublicAPI',
113
+ artifactType: 'schema',
114
+ name: 'Player',
115
+ status: 'present',
116
+ observedAt: '2026-02-13T12:00:00Z',
117
+ shapeHash: 'sha256:def456',
118
+ },
119
+ ],
120
+ // Note: Analytics dim_players is missing (expected but not verified)
121
+ // Note: Game is completely missing (all expected but not verified)
122
+ },
123
+ };
124
+
125
+ // Compute status
126
+ console.log('\n📊 Computing projection status...\n');
127
+ const status = computeProjectionStatus(manifest, attestations);
128
+
129
+ // Display results
130
+ console.log('Status Summary:\n');
131
+ for (const [actorName, statuses] of Object.entries(status.actors)) {
132
+ console.log(` ${actorName}:`);
133
+ for (const s of statuses) {
134
+ const icon =
135
+ s.status === 'attested' ? '🟢' : s.status === 'expected' ? '⚪' : '🔴';
136
+ console.log(
137
+ ` ${icon} ${s.status.toUpperCase().padEnd(10)} → ${s.location} / ${s.artifactType} / ${s.name}`,
138
+ );
139
+ }
140
+ }
141
+
142
+ console.log('\n✨ Legend:');
143
+ console.log(' ⚪ EXPECTED - Projection declared but not yet attested');
144
+ console.log(' 🟢 ATTESTED - Projection exists and is attested');
145
+ console.log(' 🟠 DRIFTED - Shape has changed (not yet implemented)');
146
+ console.log(' 🔴 FAILED - Attestation exists but status is not "present"');
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@sprig-and-prose/sprig-scenes",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Scene compiler and canonical outputs for sprig",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./compiler": "./src/compiler/compile_scene_file.js",
10
+ "./compiler/compile_scene": "./src/compiler/compile_scene.js",
11
+ "./manifest": "./src/manifest/scene_manifest.js",
12
+ "./attestations": "./src/attestations/attestations.js",
13
+ "./attestations/compute-status": "./src/attestations/compute_status.js",
14
+ "./canonical/layout": "./src/canonical/layout.scene.json",
15
+ "./canonical/tools": "./src/canonical/tools.scene.json"
16
+ },
17
+ "scripts": {
18
+ "format": "biome format . --write",
19
+ "lint": "biome lint .",
20
+ "typecheck": "tsc -p tsconfig.json",
21
+ "test": "node --import tsx --test",
22
+ "build:canonical": "node scripts/build-canonical.js"
23
+ },
24
+ "keywords": [],
25
+ "author": "",
26
+ "license": "ISC",
27
+ "dependencies": {
28
+ "@sprig-and-prose/prose-parser": "*",
29
+ "@sprig-and-prose/sprig-scene-engine": "*"
30
+ },
31
+ "devDependencies": {
32
+ "@biomejs/biome": "^1.9.4",
33
+ "tsx": "^4.19.2",
34
+ "typescript": "^5.7.2"
35
+ }
36
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @fileoverview Build canonical scene manifest JSON
3
+ */
4
+
5
+ import { readdir, writeFile } from 'node:fs/promises';
6
+ import { join, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { compileSceneFile } from '../src/compiler/compile_scene_file.js';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+
13
+ const canonicalDir = join(__dirname, '..', 'src', 'canonical');
14
+
15
+ const entries = await readdir(canonicalDir);
16
+ const canonicalFiles = entries
17
+ .filter((entry) => entry.endsWith('.scene.prose'))
18
+ .sort();
19
+
20
+ for (const entry of canonicalFiles) {
21
+ const canonicalSource = join(canonicalDir, entry);
22
+ const outputFile = join(
23
+ canonicalDir,
24
+ entry.replace(/\.scene\.prose$/u, '.scene.json'),
25
+ );
26
+ const manifest = await compileSceneFile(canonicalSource);
27
+ const json = `${JSON.stringify(manifest, null, 2)}\n`;
28
+ await writeFile(outputFile, json, 'utf-8');
29
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Portal handle: set/get/clear for one portal, synced to runtime.
3
+ * @param {string} name - Portal name
4
+ * @param {Record<string, unknown>} portalValues - Mirror of portal data (for get())
5
+ * @param {{ setPortalData(name: string, value: unknown): void }} runtime - Engine runtime
6
+ * @returns {{ set(value: unknown): void; get(): unknown; clear(): void }}
7
+ */
8
+ export function createPortalHandle(name, portalValues, runtime) {
9
+ return {
10
+ set(value) {
11
+ portalValues[name] = value;
12
+ runtime.setPortalData(name, value);
13
+ },
14
+ get() {
15
+ return portalValues[name];
16
+ },
17
+ clear() {
18
+ delete portalValues[name];
19
+ runtime.setPortalData(name, undefined);
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Wraps the scene compiler: compileFromPath (async) and compileFromString (sync).
3
+ */
4
+
5
+ import { compileSceneFile } from '../compiler/compile_scene_file.js';
6
+ import { compileSceneFromText } from '../compiler/compile_scene.js';
7
+
8
+ /**
9
+ * Compile a scene from a file path.
10
+ * @param {string} path
11
+ * @returns {Promise<import('../manifest/scene_manifest.js').SceneManifest>}
12
+ */
13
+ export async function compileFromPath(path) {
14
+ return compileSceneFile(path);
15
+ }
16
+
17
+ /**
18
+ * Compile a scene from source text.
19
+ * @param {string} source
20
+ * @returns {import('../manifest/scene_manifest.js').SceneManifest}
21
+ */
22
+ export function compileFromString(source) {
23
+ return compileSceneFromText(source);
24
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * SceneInstance: Proxy that returns PortalHandle for portal names, callable for derived names,
3
+ * and throws formatUnknownMemberError for unknown names.
4
+ */
5
+
6
+ import { createPortalHandle } from './PortalHandle.js';
7
+ import { formatUnknownMemberError } from './formatUnknownMemberError.js';
8
+
9
+ /**
10
+ * Create a SceneInstance (Proxy) for the given manifest and runtime.
11
+ * @param {import('../manifest/scene_manifest.js').SceneManifest} manifest
12
+ * @param {{ setPortalData(name: string, value: unknown): void; getDerived(blockName: string): unknown }} runtime
13
+ * @returns {Proxy}
14
+ */
15
+ export function createSceneInstance(manifest, runtime) {
16
+ const portalValues = /** @type {Record<string, unknown>} */ ({});
17
+
18
+ const portalHandles = /** @type {Record<string, { set(value: unknown): void; get(): unknown; clear(): void }} */ ({});
19
+ function getPortalHandle(name) {
20
+ if (!portalHandles[name]) {
21
+ portalHandles[name] = createPortalHandle(name, portalValues, runtime);
22
+ }
23
+ return portalHandles[name];
24
+ }
25
+
26
+ const target = {};
27
+ return new Proxy(target, {
28
+ get(_obj, prop) {
29
+ const name = typeof prop === 'string' ? prop : String(prop);
30
+ // Allow then/catch/finally so the Proxy is not treated as thenable (e.g. by test runners).
31
+ if (name === 'then' || name === 'catch' || name === 'finally') {
32
+ return undefined;
33
+ }
34
+ if (Object.prototype.hasOwnProperty.call(manifest.portals, name)) {
35
+ return getPortalHandle(name);
36
+ }
37
+ if (Object.prototype.hasOwnProperty.call(manifest.derived, name)) {
38
+ return () => runtime.getDerived(name);
39
+ }
40
+ throw formatUnknownMemberError(name, manifest);
41
+ },
42
+ has(_obj, prop) {
43
+ const name = typeof prop === 'string' ? prop : String(prop);
44
+ return (
45
+ Object.prototype.hasOwnProperty.call(manifest.portals, name) ||
46
+ Object.prototype.hasOwnProperty.call(manifest.derived, name)
47
+ );
48
+ },
49
+ });
50
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Wraps the scene-engine runtime: given manifest, returns engine's createSceneRuntime(manifest).
3
+ */
4
+
5
+ import { createSceneRuntime } from '@sprig-and-prose/sprig-scene-engine';
6
+
7
+ /**
8
+ * Create a runtime for the given manifest.
9
+ * @param {import('../manifest/scene_manifest.js').SceneManifest} manifest
10
+ * @returns {import('@sprig-and-prose/sprig-scene-engine').SceneRuntime}
11
+ */
12
+ export function createRuntime(manifest) {
13
+ return createSceneRuntime(manifest);
14
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Build a helpful error for unknown scene member access.
3
+ * @param {string} memberName
4
+ * @param {{ portals: Record<string, unknown>; derived: Record<string, unknown> }} manifest
5
+ * @returns {Error}
6
+ */
7
+ export function formatUnknownMemberError(memberName, manifest) {
8
+ const portalNames = Object.keys(manifest.portals ?? {}).sort();
9
+ const derivedNames = Object.keys(manifest.derived ?? {}).sort();
10
+ const portalList = portalNames.length ? portalNames.join(', ') : '(none)';
11
+ const derivedList = derivedNames.length ? derivedNames.join(', ') : '(none)';
12
+ const message = [
13
+ `Unknown scene member "${memberName}".`,
14
+ `Known portals: ${portalList}.`,
15
+ `Known derived values: ${derivedList}.`,
16
+ ].join('\n');
17
+ return new Error(message);
18
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @fileoverview Attestation file management for projection expectations
3
+ */
4
+
5
+ import { readFile, writeFile } from 'node:fs/promises';
6
+
7
+ /**
8
+ * Single attestation for an actor projection.
9
+ * @typedef {Object} Attestation
10
+ * @property {string} location - Location name
11
+ * @property {'table' | 'schema'} artifactType - Type of artifact
12
+ * @property {string} name - Artifact name
13
+ * @property {'present' | 'absent'} status - Attestation status
14
+ * @property {string} observedAt - ISO 8601 timestamp
15
+ * @property {string} [shapeHash] - Optional shape hash for verification
16
+ */
17
+
18
+ /**
19
+ * Attestation file structure.
20
+ * @typedef {Object} AttestationFile
21
+ * @property {1} version - File format version
22
+ * @property {Record<string, Attestation[]>} actors - Attestations by actor name
23
+ */
24
+
25
+ /**
26
+ * Load attestations from a JSON file.
27
+ * @param {string} filePath - Path to attestation file
28
+ * @returns {Promise<AttestationFile>}
29
+ */
30
+ export async function loadAttestations(filePath) {
31
+ try {
32
+ const text = await readFile(filePath, 'utf-8');
33
+ const data = JSON.parse(text);
34
+
35
+ if (!data.version || data.version !== 1) {
36
+ throw new Error(`Invalid attestation file version. Expected 1, got ${data.version}`);
37
+ }
38
+
39
+ if (!data.actors || typeof data.actors !== 'object') {
40
+ throw new Error('Attestation file must have an actors object');
41
+ }
42
+
43
+ return data;
44
+ } catch (err) {
45
+ if (err.code === 'ENOENT') {
46
+ // File doesn't exist, return empty attestations
47
+ return {
48
+ version: 1,
49
+ actors: {},
50
+ };
51
+ }
52
+ throw err;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Save attestations to a JSON file.
58
+ * @param {string} filePath - Path to attestation file
59
+ * @param {AttestationFile} data - Attestation data to save
60
+ * @returns {Promise<void>}
61
+ */
62
+ export async function saveAttestations(filePath, data) {
63
+ const json = JSON.stringify(data, null, 2);
64
+ await writeFile(filePath, json, 'utf-8');
65
+ }