@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
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @fileoverview File wrapper for scene compilation
3
+ */
4
+
5
+ import { readFile } from 'node:fs/promises';
6
+ import { compileSceneFromText } from './compile_scene.js';
7
+
8
+ /**
9
+ * @param {string} path
10
+ * @returns {Promise<import('../manifest/scene_manifest.js').SceneManifest>}
11
+ */
12
+ export async function compileSceneFile(path) {
13
+ const sourceText = await readFile(path, 'utf-8');
14
+ return compileSceneFromText(sourceText);
15
+ }
package/src/index.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @fileoverview Scenes library API: compileFromPath / compileFromString return SceneInstance (portal handles + derived functions).
3
+ */
4
+
5
+ import { compileFromPath as compileFromPathImpl, compileFromString as compileFromStringImpl } from './api/SceneCompiler.js';
6
+ import { createRuntime } from './api/SceneRuntime.js';
7
+ import { createSceneInstance } from './api/SceneInstance.js';
8
+
9
+ // Export attestation modules
10
+ export { loadAttestations, saveAttestations } from './attestations/attestations.js';
11
+ export { computeProjectionStatus } from './attestations/compute_status.js';
12
+
13
+ /**
14
+ * Compile a scene from a file path. Returns a SceneInstance (Proxy).
15
+ * @param {string} path
16
+ * @returns {Promise<Proxy>}
17
+ */
18
+ async function compileFromPath(path) {
19
+ const manifest = await compileFromPathImpl(path);
20
+ const runtime = createRuntime(manifest);
21
+ return createSceneInstance(manifest, runtime);
22
+ }
23
+
24
+ /**
25
+ * Compile a scene from source text. Returns a SceneInstance (Proxy).
26
+ * @param {string} source
27
+ * @returns {Proxy}
28
+ */
29
+ function compileFromString(source) {
30
+ const manifest = compileFromStringImpl(source);
31
+ const runtime = createRuntime(manifest);
32
+ return createSceneInstance(manifest, runtime);
33
+ }
34
+
35
+ const Scenes = {
36
+ compileFromPath,
37
+ compileFromString,
38
+ };
39
+
40
+ export default Scenes;
41
+ export { compileFromPath, compileFromString };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Scene manifest types for canonical scene compilation (TypeScript declarations).
3
+ * Matches JSDoc in scene_manifest.js.
4
+ */
5
+
6
+ export type PrimitiveKind =
7
+ | 'string'
8
+ | 'integer'
9
+ | 'boolean'
10
+ | 'float'
11
+ | 'date'
12
+ | 'route';
13
+
14
+ /** Uniform kind: primitive, object (named fields), or array. */
15
+ export type Kind =
16
+ | { primitive: PrimitiveKind }
17
+ | { object: Record<string, KindRef> }
18
+ | { array: KindRef };
19
+
20
+ /** Reference: full Kind or actor by name. */
21
+ export type KindRef = Kind | { actor: string };
22
+
23
+ /** Portal definition: declares boundary shape (read-only). */
24
+ export interface PortalDef {
25
+ kind: KindRef;
26
+ }
27
+
28
+ /** Reference to a prior step (by id), a portal (by name), or another derived block (by name). */
29
+ export type DerivedStepRef = { stepId: string } | { portal: string } | { derived: string };
30
+
31
+ /** Path expression: stored as string, e.g. "ContainerRows[].item.tools[].name". */
32
+ export type PathExpr = string;
33
+
34
+ export type DerivedStep =
35
+ | {
36
+ id: string;
37
+ name?: string;
38
+ type: 'values';
39
+ pathExpr?: PathExpr;
40
+ items?: Array<{ ref: string; expand?: boolean }>;
41
+ }
42
+ | { id: string; name?: string; type: 'count'; from: DerivedStepRef }
43
+ | { id: string; name?: string; type: 'unique'; from: DerivedStepRef }
44
+ | {
45
+ id: string;
46
+ name?: string;
47
+ type: 'sort';
48
+ from: DerivedStepRef;
49
+ order: 'ascending' | 'descending';
50
+ }
51
+ | {
52
+ id: string;
53
+ name?: string;
54
+ type: 'join';
55
+ from: DerivedStepRef;
56
+ separator: string;
57
+ }
58
+ | {
59
+ id: string;
60
+ type: 'each';
61
+ witness: string;
62
+ source: { type: 'pathExpr'; value: string };
63
+ shape: { type: 'object'; fields: Record<string, EachShapeField> };
64
+ };
65
+
66
+ /** Field in an each step's shape: witness path, one-resolver, or many-resolver. */
67
+ export type EachShapeField =
68
+ | { type: 'witnessPath'; witness: string; path: string[] }
69
+ | {
70
+ type: 'one';
71
+ from: { type: 'pathExpr'; value: string };
72
+ by: string;
73
+ matches: { witness: string; path: string[] };
74
+ }
75
+ | {
76
+ type: 'many';
77
+ from: { type: 'pathExpr'; value: string };
78
+ by: string;
79
+ matches: { witness: string; path: string[] };
80
+ expects?: 'any' | 'some';
81
+ };
82
+
83
+ export interface DerivedBlock {
84
+ steps: DerivedStep[];
85
+ kind?: KindRef;
86
+ }
87
+
88
+ export interface ActorDef {
89
+ kind: Kind;
90
+ identity: string[];
91
+ }
92
+
93
+ export interface SceneManifest {
94
+ sceneName: string;
95
+ actors: Record<string, ActorDef>;
96
+ portals: Record<string, PortalDef>;
97
+ derived: Record<string, DerivedBlock>;
98
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @fileoverview Scene manifest types for canonical scene compilation
3
+ */
4
+
5
+ /**
6
+ * @typedef {'string' | 'integer' | 'boolean' | 'float' | 'date' | 'route'} PrimitiveKind
7
+ */
8
+
9
+ /**
10
+ * Uniform kind: primitive, object (named fields), or array.
11
+ * @typedef {{ primitive: PrimitiveKind } | { object: Record<string, KindRef> } | { array: KindRef }} Kind
12
+ */
13
+
14
+ /**
15
+ * Reference: full Kind or actor by name.
16
+ * @typedef {Kind | { actor: string }} KindRef
17
+ */
18
+
19
+ /**
20
+ * Portal definition: declares boundary shape (read-only).
21
+ * @typedef {{ kind: KindRef }} PortalDef
22
+ */
23
+
24
+ /**
25
+ * Reference to a prior step (by id), a portal (by name), or another derived block (by name).
26
+ * @typedef {{ stepId: string } | { portal: string } | { derived: string }} DerivedStepRef
27
+ */
28
+
29
+ /**
30
+ * Path expression: stored as string.
31
+ * @typedef {string} PathExpr
32
+ */
33
+
34
+ /**
35
+ * @typedef {{ id: string, name?: string, type: 'values', pathExpr?: PathExpr, items?: Array<{ ref: string, expand?: boolean }> }} DerivedStepValues
36
+ * @typedef {{ id: string, name?: string, type: 'count', from: DerivedStepRef }} DerivedStepCount
37
+ * @typedef {{ id: string, name?: string, type: 'unique', from: DerivedStepRef }} DerivedStepUnique
38
+ * @typedef {{ id: string, name?: string, type: 'sort', from: DerivedStepRef, order: 'ascending' | 'descending' }} DerivedStepSort
39
+ * @typedef {{ id: string, name?: string, type: 'join', from: DerivedStepRef, separator: string }} DerivedStepJoin
40
+ * @typedef {{ id: string, type: 'each', witness: string, source: { type: 'pathExpr', value: string }, shape: { type: 'object', fields: Record<string, EachShapeField> } }} DerivedStepEach
41
+ * @typedef {DerivedStepValues | DerivedStepCount | DerivedStepUnique | DerivedStepSort | DerivedStepJoin | DerivedStepEach} DerivedStep
42
+ */
43
+
44
+ /**
45
+ * @typedef {{ type: 'witnessPath', witness: string, path: string[] } | { type: 'one', from: { type: 'pathExpr', value: string }, by: string, matches: { witness: string, path: string[] } } | { type: 'many', from: { type: 'pathExpr', value: string }, by: string, matches: { witness: string, path: string[] }, expects?: 'any' | 'some' }} EachShapeField
46
+ */
47
+
48
+ /**
49
+ * @typedef {Object} DerivedBlock
50
+ * @property {DerivedStep[]} steps
51
+ * @property {KindRef} [kind]
52
+ */
53
+
54
+ /**
55
+ * Location definition: declares a projection target.
56
+ * @typedef {Object} LocationDef
57
+ * @property {string} kind
58
+ */
59
+
60
+ /**
61
+ * Projection expectation: declares where an actor is expected to be projected.
62
+ * @typedef {Object} ProjectionExpectation
63
+ * @property {string} location
64
+ * @property {'table' | 'schema' | 'dataset'} artifactType
65
+ * @property {string} name
66
+ */
67
+
68
+ /**
69
+ * @typedef {Object} ActorDef
70
+ * @property {Kind} kind
71
+ * @property {string[]} identity
72
+ * @property {ProjectionExpectation[]} [expectations]
73
+ */
74
+
75
+ /**
76
+ * @typedef {Object} SceneManifest
77
+ * @property {string} sceneName
78
+ * @property {Record<string, ActorDef>} actors
79
+ * @property {Record<string, PortalDef>} portals
80
+ * @property {Record<string, DerivedBlock>} derived
81
+ * @property {Record<string, LocationDef>} [locations]
82
+ */
83
+
84
+ export {};
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @fileoverview Tests for Scenes library API: compileFromPath, compileFromString, SceneInstance (portal handles + derived functions).
3
+ */
4
+
5
+ import { test } from 'node:test';
6
+ import { strictEqual, throws } from 'node:assert/strict';
7
+ import { join, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import Scenes from '../src/index.js';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+
14
+ const MINIMAL_PLAYERS_PROSE = `
15
+ scene TestScene {
16
+ actor Player {
17
+ kind {
18
+ id { integer }
19
+ name { string }
20
+ }
21
+ identity { id }
22
+ }
23
+
24
+ portal Players {
25
+ kind { [ Player ] }
26
+ }
27
+
28
+ derived PlayerCount {
29
+ count { Players }
30
+ }
31
+
32
+ derived PlayerNames {
33
+ values { Players[].name }
34
+ unique { values }
35
+ sort { from { unique } order { ascending } }
36
+ join { from { sort } with { ', ' } }
37
+ }
38
+ }
39
+ `;
40
+
41
+ test('tutorial-style: compileFromString, set portal, call derived', () => {
42
+ const scene = Scenes.compileFromString(MINIMAL_PLAYERS_PROSE);
43
+ scene.Players.set([
44
+ { id: 1, name: 'John' },
45
+ { id: 2, name: 'Jane' },
46
+ ]);
47
+ strictEqual(scene.PlayerCount(), 2);
48
+ strictEqual(scene.PlayerNames(), 'Jane, John'); // sorted ascending
49
+ });
50
+
51
+ test('portal set/get: get returns last set value', () => {
52
+ const scene = Scenes.compileFromString(MINIMAL_PLAYERS_PROSE);
53
+ const data = [{ id: 1, name: 'A' }];
54
+ scene.Players.set(data);
55
+ strictEqual(scene.Players.get(), data);
56
+ });
57
+
58
+ test('portal clear: get returns undefined after clear', () => {
59
+ const scene = Scenes.compileFromString(MINIMAL_PLAYERS_PROSE);
60
+ scene.Players.set([{ id: 1, name: 'X' }]);
61
+ scene.Players.clear();
62
+ strictEqual(scene.Players.get(), undefined);
63
+ });
64
+
65
+ test('derived evaluation: PlayerCount returns number', () => {
66
+ const scene = Scenes.compileFromString(MINIMAL_PLAYERS_PROSE);
67
+ scene.Players.set([
68
+ { id: 1, name: 'a' },
69
+ { id: 2, name: 'b' },
70
+ ]);
71
+ strictEqual(scene.PlayerCount(), 2);
72
+ });
73
+
74
+ test('unknown property throws helpful message with known portals and derived', () => {
75
+ const scene = Scenes.compileFromString(MINIMAL_PLAYERS_PROSE);
76
+ throws(
77
+ () => {
78
+ scene.Playerss;
79
+ },
80
+ (err) => {
81
+ strictEqual(err instanceof Error, true);
82
+ strictEqual(err.message.includes('Unknown scene member "Playerss"'), true);
83
+ strictEqual(err.message.includes('Known portals:'), true);
84
+ strictEqual(err.message.includes('Players'), true);
85
+ strictEqual(err.message.includes('Known derived values:'), true);
86
+ strictEqual(err.message.includes('PlayerCount'), true);
87
+ strictEqual(err.message.includes('PlayerNames'), true);
88
+ return true;
89
+ }
90
+ );
91
+ });
92
+
93
+ test('derived names return callable functions', () => {
94
+ const scene = Scenes.compileFromString(MINIMAL_PLAYERS_PROSE);
95
+ strictEqual(typeof scene.PlayerCount, 'function');
96
+ strictEqual(typeof scene.PlayerNames, 'function');
97
+ scene.Players.set([]);
98
+ strictEqual(scene.PlayerCount(), 0);
99
+ });
100
+
101
+ test('compileFromPath: load fixture and evaluate', async () => {
102
+ const path = join(__dirname, 'fixtures', 'players.scene.prose');
103
+ const scene = await Scenes.compileFromPath(path);
104
+ scene.Players.set([
105
+ { id: 1, name: 'Alice' },
106
+ { id: 2, name: 'Bob' },
107
+ ]);
108
+ strictEqual(scene.PlayerCount(), 2);
109
+ strictEqual(scene.PlayerNames(), 'Alice, Bob');
110
+ });
@@ -0,0 +1,311 @@
1
+ /**
2
+ * @fileoverview Tests for scene compilation (actor, portal, derived only)
3
+ */
4
+
5
+ import { test } from 'node:test';
6
+ import { deepStrictEqual, strictEqual, throws } from 'node:assert/strict';
7
+ import { readFile } from 'node:fs/promises';
8
+ import { join, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { compileSceneFromText } from '../src/compiler/compile_scene.js';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+
15
+ test('compiles canonical actor scene', async () => {
16
+ const file = join(__dirname, '..', 'src', 'canonical', 'actor.scene.prose');
17
+ const sourceText = await readFile(file, 'utf-8');
18
+ const manifest = compileSceneFromText(sourceText);
19
+
20
+ const jsonFile = join(
21
+ __dirname,
22
+ '..',
23
+ 'src',
24
+ 'canonical',
25
+ 'actor.scene.json',
26
+ );
27
+ const expectedText = await readFile(jsonFile, 'utf-8');
28
+ const expected = JSON.parse(expectedText);
29
+
30
+ deepStrictEqual(JSON.parse(JSON.stringify(manifest)), expected);
31
+ });
32
+
33
+ test('block comments do not affect manifest output', async () => {
34
+ const file = join(__dirname, '..', 'src', 'canonical', 'actor.scene.prose');
35
+ const baseSource = await readFile(file, 'utf-8');
36
+ const withComments = baseSource
37
+ .replace('scene LeftPanel {', 'scene LeftPanel { {* scene def *}')
38
+ .replace('actor Player {', 'actor Player { {* player actor *}');
39
+ const manifest = compileSceneFromText(withComments);
40
+
41
+ const jsonFile = join(
42
+ __dirname,
43
+ '..',
44
+ 'src',
45
+ 'canonical',
46
+ 'actor.scene.json',
47
+ );
48
+ const expectedText = await readFile(jsonFile, 'utf-8');
49
+ const expected = JSON.parse(expectedText);
50
+
51
+ deepStrictEqual(JSON.parse(JSON.stringify(manifest)), expected);
52
+ });
53
+
54
+ test('compiles canonical actors scene', async () => {
55
+ const file = join(__dirname, '..', 'src', 'canonical', 'actors.scene.prose');
56
+ const sourceText = await readFile(file, 'utf-8');
57
+ const manifest = compileSceneFromText(sourceText);
58
+
59
+ const jsonFile = join(
60
+ __dirname,
61
+ '..',
62
+ 'src',
63
+ 'canonical',
64
+ 'actors.scene.json',
65
+ );
66
+ const expectedText = await readFile(jsonFile, 'utf-8');
67
+ const expected = JSON.parse(expectedText);
68
+
69
+ deepStrictEqual(JSON.parse(JSON.stringify(manifest)), expected);
70
+ });
71
+
72
+ test('compiles canonical tools scene', async () => {
73
+ const file = join(__dirname, '..', 'src', 'canonical', 'tools.scene.prose');
74
+ const sourceText = await readFile(file, 'utf-8');
75
+ const manifest = compileSceneFromText(sourceText);
76
+
77
+ const jsonFile = join(
78
+ __dirname,
79
+ '..',
80
+ 'src',
81
+ 'canonical',
82
+ 'tools.scene.json',
83
+ );
84
+ const expectedText = await readFile(jsonFile, 'utf-8');
85
+ const expected = JSON.parse(expectedText);
86
+
87
+ deepStrictEqual(JSON.parse(JSON.stringify(manifest)), expected);
88
+ });
89
+
90
+ test('tools scene: portals have array kind', async () => {
91
+ const file = join(__dirname, '..', 'src', 'canonical', 'tools.scene.prose');
92
+ const sourceText = await readFile(file, 'utf-8');
93
+ const manifest = compileSceneFromText(sourceText);
94
+
95
+ strictEqual(!!manifest.portals?.ContainerRows, true);
96
+ deepStrictEqual(manifest.portals.ContainerRows.kind, {
97
+ array: { actor: 'ContainerRow' },
98
+ });
99
+ strictEqual(!!manifest.portals?.AnotherToolPortal, true);
100
+ deepStrictEqual(manifest.portals.AnotherToolPortal.kind, {
101
+ array: { actor: 'Something' },
102
+ });
103
+ });
104
+
105
+ test('tools scene: derived RowCount has count from portal', async () => {
106
+ const file = join(__dirname, '..', 'src', 'canonical', 'tools.scene.prose');
107
+ const sourceText = await readFile(file, 'utf-8');
108
+ const manifest = compileSceneFromText(sourceText);
109
+
110
+ const rowCount = manifest.derived?.RowCount;
111
+ strictEqual(!!rowCount, true);
112
+ const steps = rowCount?.steps ?? [];
113
+ strictEqual(steps.length, 1);
114
+ strictEqual(steps[0]?.type, 'count');
115
+ deepStrictEqual(steps[0]?.from, { portal: 'ContainerRows' });
116
+ });
117
+
118
+ test('tools scene: derived ToolNames has values, unique, sort in order', async () => {
119
+ const file = join(__dirname, '..', 'src', 'canonical', 'tools.scene.prose');
120
+ const sourceText = await readFile(file, 'utf-8');
121
+ const manifest = compileSceneFromText(sourceText);
122
+
123
+ const toolNames = manifest.derived?.ToolNames;
124
+ strictEqual(!!toolNames, true);
125
+ const steps = toolNames?.steps ?? [];
126
+ strictEqual(steps.length, 3);
127
+ strictEqual(steps[0]?.type, 'values');
128
+ strictEqual(steps[1]?.type, 'unique');
129
+ strictEqual(steps[2]?.type, 'sort');
130
+ strictEqual(steps[0]?.id, 's1');
131
+ strictEqual(steps[1]?.id, 's2');
132
+ strictEqual(steps[2]?.id, 's3');
133
+ deepStrictEqual(steps[1]?.from, { stepId: 's1' });
134
+ deepStrictEqual(steps[2]?.from, { stepId: 's2' });
135
+ strictEqual(steps[2]?.order, 'ascending');
136
+ });
137
+
138
+ test('tools scene: derived ToolCount has count from derived ToolNames', async () => {
139
+ const file = join(__dirname, '..', 'src', 'canonical', 'tools.scene.prose');
140
+ const sourceText = await readFile(file, 'utf-8');
141
+ const manifest = compileSceneFromText(sourceText);
142
+
143
+ const toolCount = manifest.derived?.ToolCount;
144
+ strictEqual(!!toolCount, true);
145
+ const steps = toolCount?.steps ?? [];
146
+ strictEqual(steps.length, 1);
147
+ strictEqual(steps[0]?.type, 'count');
148
+ deepStrictEqual(steps[0]?.from, { derived: 'ToolNames' });
149
+ });
150
+
151
+ test('tools scene: derived ToolString1 has join step with separator', async () => {
152
+ const file = join(__dirname, '..', 'src', 'canonical', 'tools.scene.prose');
153
+ const sourceText = await readFile(file, 'utf-8');
154
+ const manifest = compileSceneFromText(sourceText);
155
+
156
+ const toolString = manifest.derived?.ToolString1;
157
+ strictEqual(!!toolString, true);
158
+ const joinStep = toolString?.steps?.find((s) => s.type === 'join');
159
+ strictEqual(!!joinStep, true);
160
+ strictEqual(joinStep?.separator, ', ');
161
+ deepStrictEqual(joinStep?.from, { derived: 'ToolNames' });
162
+ });
163
+
164
+ test('grouped actors: produces same manifest as singular', () => {
165
+ const grouped = `
166
+ scene G {
167
+ actors {
168
+ A {
169
+ kind { id { integer } }
170
+ identity { id }
171
+ }
172
+ }
173
+ }
174
+ `;
175
+ const singular = `
176
+ scene G {
177
+ actor A {
178
+ kind { id { integer } }
179
+ identity { id }
180
+ }
181
+ }
182
+ `;
183
+ const manifestGrouped = compileSceneFromText(grouped);
184
+ const manifestSingular = compileSceneFromText(singular);
185
+ deepStrictEqual(manifestGrouped.actors?.A, manifestSingular.actors?.A);
186
+ strictEqual(manifestGrouped.sceneName, 'G');
187
+ strictEqual(Object.keys(manifestGrouped.actors ?? {}).length, 1);
188
+ deepStrictEqual(manifestGrouped.portals, {});
189
+ deepStrictEqual(manifestGrouped.derived, {});
190
+ });
191
+
192
+ test('grouped portals: produces same manifest as singular', () => {
193
+ const grouped = `
194
+ scene G {
195
+ actor R { kind { id { integer } } identity { id } }
196
+ portals {
197
+ P { kind { [ R ] } }
198
+ }
199
+ }
200
+ `;
201
+ const singular = `
202
+ scene G {
203
+ actor R { kind { id { integer } } identity { id } }
204
+ portal P { kind { [ R ] } }
205
+ }
206
+ `;
207
+ const manifestGrouped = compileSceneFromText(grouped);
208
+ const manifestSingular = compileSceneFromText(singular);
209
+ deepStrictEqual(manifestGrouped.portals?.P, manifestSingular.portals?.P);
210
+ strictEqual(Object.keys(manifestGrouped.portals ?? {}).length, 1);
211
+ });
212
+
213
+ test('grouped derived: singular derived C is in manifest', () => {
214
+ const source = `
215
+ scene G {
216
+ actor R { kind { id { integer } } identity { id } }
217
+ portal X { kind { [ R ] } }
218
+ derived C { count { X } }
219
+ }
220
+ `;
221
+ const manifest = compileSceneFromText(source);
222
+ strictEqual(!!manifest.derived?.C, true);
223
+ strictEqual(manifest.derived?.C?.steps?.length, 1);
224
+ strictEqual(manifest.derived?.C?.steps?.[0]?.type, 'count');
225
+ deepStrictEqual(manifest.derived?.C?.steps?.[0]?.from, { portal: 'X' });
226
+ });
227
+
228
+ test('mixed singular and grouped: actors and portals and derived appear in manifest', () => {
229
+ const source = `
230
+ scene M {
231
+ actor X { kind { id { integer } } identity { id } }
232
+ actors {
233
+ Y { kind { id { integer } } identity { id } }
234
+ }
235
+ portal P1 { kind { [ X ] } }
236
+ portals {
237
+ P2 { kind { [ Y ] } }
238
+ }
239
+ derived D1 { count { P1 } }
240
+ derived D2 { count { P2 } }
241
+ }
242
+ `;
243
+ const manifest = compileSceneFromText(source);
244
+ strictEqual(manifest.sceneName, 'M');
245
+ strictEqual(!!manifest.actors?.X, true);
246
+ strictEqual(!!manifest.actors?.Y, true);
247
+ strictEqual(!!manifest.portals?.P1, true);
248
+ strictEqual(!!manifest.portals?.P2, true);
249
+ strictEqual(!!manifest.derived?.D1, true);
250
+ strictEqual(!!manifest.derived?.D2, true);
251
+ });
252
+
253
+ test('skills scene: derived PlayerSkills has each step with expected shape', async () => {
254
+ const file = join(__dirname, 'fixtures', 'skills.scene.prose');
255
+ const sourceText = await readFile(file, 'utf-8');
256
+ const manifest = compileSceneFromText(sourceText);
257
+
258
+ const playerSkills = manifest.derived?.PlayerSkills;
259
+ strictEqual(!!playerSkills, true);
260
+ strictEqual(playerSkills?.kind?.array?.actor, 'PlayerSkill');
261
+ const steps = playerSkills?.steps ?? [];
262
+ strictEqual(steps.length, 1);
263
+ strictEqual(steps[0]?.type, 'each');
264
+ strictEqual(steps[0]?.witness, 'row');
265
+ deepStrictEqual(steps[0]?.source, { type: 'pathExpr', value: 'RawPlayerSkills[]' });
266
+ const fields = steps[0]?.shape?.fields ?? {};
267
+ strictEqual(fields.player?.type, 'one');
268
+ strictEqual(fields.skill?.type, 'one');
269
+ strictEqual(fields.experience?.type, 'witnessPath');
270
+ deepStrictEqual(fields.player?.matches, { witness: 'row', path: ['playerId'] });
271
+ deepStrictEqual(fields.skill?.matches, { witness: 'row', path: ['skillId'] });
272
+ deepStrictEqual(fields.experience?.path, ['experience']);
273
+ });
274
+
275
+ test('skills-many scene: parses many block with from, matches, by', async () => {
276
+ const file = join(__dirname, 'fixtures', 'skills-many.scene.prose');
277
+ const sourceText = await readFile(file, 'utf-8');
278
+ const manifest = compileSceneFromText(sourceText);
279
+
280
+ const block = manifest.derived?.PlayerSkillMatches;
281
+ strictEqual(!!block, true);
282
+ const steps = block?.steps ?? [];
283
+ strictEqual(steps.length, 1);
284
+ const fields = steps[0]?.shape?.fields ?? {};
285
+ strictEqual(fields.skillMatches?.type, 'many');
286
+ deepStrictEqual(fields.skillMatches?.from, { type: 'pathExpr', value: 'Skills[]' });
287
+ strictEqual(fields.skillMatches?.by, 'id');
288
+ deepStrictEqual(fields.skillMatches?.matches, { witness: 'row', path: ['skillId'] });
289
+ strictEqual(fields.skillMatches?.expects, undefined);
290
+ });
291
+
292
+ test('skills-many-some scene: parses many with expects some', async () => {
293
+ const file = join(__dirname, 'fixtures', 'skills-many-some.scene.prose');
294
+ const sourceText = await readFile(file, 'utf-8');
295
+ const manifest = compileSceneFromText(sourceText);
296
+
297
+ const block = manifest.derived?.PlayerSkillMatchesSome;
298
+ strictEqual(!!block, true);
299
+ const fields = block?.steps?.[0]?.shape?.fields ?? {};
300
+ strictEqual(fields.skillMatches?.type, 'many');
301
+ strictEqual(fields.skillMatches?.expects, 'some');
302
+ });
303
+
304
+ test('empty scene returns empty manifest shape', () => {
305
+ const source = 'not a scene';
306
+ const manifest = compileSceneFromText(source);
307
+ strictEqual(manifest.sceneName, '');
308
+ deepStrictEqual(manifest.actors, {});
309
+ deepStrictEqual(manifest.portals, {});
310
+ deepStrictEqual(manifest.derived, {});
311
+ });