@pygmalionjs/pygmalion 0.2.7 → 0.2.9

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.
@@ -0,0 +1,1507 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import ts from 'typescript';
4
+ import { buildSourceGraph } from './source-graph.mjs';
5
+ import {
6
+ canonicalizeStoryboardExecutions,
7
+ PYGMALION_STORYBOARD_CANONICAL_CONTROL,
8
+ } from './storyboard-canonical.mjs';
9
+
10
+ export const PYGMALION_STORYBOARD_ENVIRONMENT_CONTROL =
11
+ '/__pygmalion-storyboard/environment';
12
+ export const PYGMALION_STORYBOARD_ENVIRONMENT_QUERY =
13
+ '__pygmalion_environment';
14
+
15
+ const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx']);
16
+ const IGNORED_DIRECTORIES = new Set([
17
+ '.git',
18
+ '.next',
19
+ '.turbo',
20
+ 'build',
21
+ 'coverage',
22
+ 'dist',
23
+ 'dist-lib',
24
+ 'node_modules',
25
+ 'public',
26
+ ]);
27
+ const MAX_SOURCE_BYTES = 1024 * 1024;
28
+ const MAX_ENVIRONMENT_BYTES = 16 * 1024;
29
+ const MAX_EXECUTION_RESULTS_BYTES = 64 * 1024 * 1024;
30
+
31
+ function normalizedRelativePath(root, file) {
32
+ return path.relative(root, file).split(path.sep).join('/');
33
+ }
34
+
35
+ async function collectSourceFiles(root, options = {}) {
36
+ const files = [];
37
+ const maxFiles = Math.max(1, Math.min(20_000, options.maxFiles ?? 5_000));
38
+
39
+ async function visit(directory) {
40
+ if (files.length >= maxFiles) return;
41
+ let entries;
42
+ try {
43
+ entries = await fs.readdir(directory, { withFileTypes: true });
44
+ } catch {
45
+ return;
46
+ }
47
+ entries.sort((a, b) => a.name.localeCompare(b.name));
48
+ for (const entry of entries) {
49
+ if (files.length >= maxFiles) return;
50
+ if (entry.isSymbolicLink()) continue;
51
+ const absolute = path.join(directory, entry.name);
52
+ if (entry.isDirectory()) {
53
+ if (!IGNORED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) {
54
+ await visit(absolute);
55
+ }
56
+ continue;
57
+ }
58
+ if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
59
+ files.push(absolute);
60
+ }
61
+ }
62
+ }
63
+
64
+ await visit(root);
65
+ return files;
66
+ }
67
+
68
+ function propertyPath(node) {
69
+ if (ts.isIdentifier(node)) return node.text;
70
+ if (ts.isPropertyAccessExpression(node)) {
71
+ const owner = propertyPath(node.expression);
72
+ return owner ? `${owner}.${node.name.text}` : node.name.text;
73
+ }
74
+ return '';
75
+ }
76
+
77
+ function literalText(node, constants = new Map()) {
78
+ if (!node) return null;
79
+ if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
80
+ return node.text;
81
+ }
82
+ if (ts.isIdentifier(node)) return constants.get(node.text) ?? null;
83
+ if (
84
+ ts.isAsExpression(node) ||
85
+ ts.isTypeAssertionExpression(node) ||
86
+ ts.isParenthesizedExpression(node)
87
+ ) {
88
+ return literalText(node.expression, constants);
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function objectStringProperty(node, name, constants) {
94
+ if (!node || !ts.isObjectLiteralExpression(node)) return null;
95
+ for (const property of node.properties) {
96
+ if (!ts.isPropertyAssignment(property)) continue;
97
+ const propertyName = literalText(property.name, constants) ??
98
+ (ts.isIdentifier(property.name) ? property.name.text : null);
99
+ if (propertyName === name) return literalText(property.initializer, constants);
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function callPath(node) {
105
+ return ts.isCallExpression(node) ? propertyPath(node.expression) : '';
106
+ }
107
+
108
+ function storageCall(node, constants) {
109
+ if (!ts.isCallExpression(node)) return null;
110
+ const called = callPath(node);
111
+ const match = called.match(
112
+ /^(?:(?:window|globalThis)\.)?(localStorage|sessionStorage)\.(getItem|setItem|removeItem)$/,
113
+ );
114
+ if (!match) return null;
115
+ return {
116
+ storage: match[1],
117
+ operation: match[2],
118
+ key: literalText(node.arguments[0], constants),
119
+ value: literalText(node.arguments[1], constants),
120
+ };
121
+ }
122
+
123
+ function storageCallShape(node) {
124
+ if (!ts.isCallExpression(node)) return null;
125
+ const called = callPath(node);
126
+ const match = called.match(
127
+ /^(?:(?:window|globalThis)\.)?(localStorage|sessionStorage)\.(getItem|setItem|removeItem)$/,
128
+ );
129
+ if (!match) return null;
130
+ return {
131
+ node,
132
+ storage: match[1],
133
+ operation: match[2],
134
+ keyNode: node.arguments[0],
135
+ valueNode: node.arguments[1],
136
+ };
137
+ }
138
+
139
+ function sourcePosition(sourceFile, node) {
140
+ const location = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
141
+ return { line: location.line + 1, column: location.character + 1 };
142
+ }
143
+
144
+ function evidenceId(sourcePath, kind, capability, line, column) {
145
+ return `${sourcePath}:${line}:${column}:${kind}:${capability}`;
146
+ }
147
+
148
+ function addEvidence(result, sourceFile, sourcePath, node, kind, capability, detail = {}) {
149
+ const { line, column } = sourcePosition(sourceFile, node);
150
+ const id = evidenceId(sourcePath, kind, capability, line, column);
151
+ if (result.evidenceById.has(id)) return id;
152
+ result.evidenceById.set(id, {
153
+ id,
154
+ kind,
155
+ capability,
156
+ sourcePath,
157
+ line,
158
+ column,
159
+ ...detail,
160
+ });
161
+ return id;
162
+ }
163
+
164
+ function dimensionId(kind, name) {
165
+ return `${kind}:${name}`;
166
+ }
167
+
168
+ function ensureDimension(result, kind, name, values, evidenceIdValue) {
169
+ const id = dimensionId(kind, name);
170
+ let dimension = result.dimensions.get(id);
171
+ if (!dimension) {
172
+ dimension = {
173
+ id,
174
+ kind,
175
+ name,
176
+ values: new Set(),
177
+ evidenceIds: new Set(),
178
+ };
179
+ result.dimensions.set(id, dimension);
180
+ }
181
+ values.forEach((value) => dimension.values.add(value));
182
+ dimension.evidenceIds.add(evidenceIdValue);
183
+ return dimension;
184
+ }
185
+
186
+ function addUnresolved(result, sourceFile, sourcePath, node, kind, reason) {
187
+ const { line, column } = sourcePosition(sourceFile, node);
188
+ result.unresolved.push({
189
+ id: `${sourcePath}:${line}:${column}:${kind}`,
190
+ kind,
191
+ reason,
192
+ sourcePath,
193
+ line,
194
+ column,
195
+ });
196
+ }
197
+
198
+ function jsxTagName(node) {
199
+ if (ts.isIdentifier(node)) return node.text;
200
+ if (ts.isPropertyAccessExpression(node)) return propertyPath(node);
201
+ return '';
202
+ }
203
+
204
+ function jsxStringAttribute(attributes, name, constants) {
205
+ for (const property of attributes.properties) {
206
+ if (!ts.isJsxAttribute(property) || property.name.text !== name) continue;
207
+ if (!property.initializer) return '';
208
+ if (ts.isStringLiteral(property.initializer)) return property.initializer.text;
209
+ if (ts.isJsxExpression(property.initializer)) {
210
+ return literalText(property.initializer.expression, constants);
211
+ }
212
+ }
213
+ return null;
214
+ }
215
+
216
+ function addRoute(
217
+ result,
218
+ sourceFile,
219
+ sourcePath,
220
+ node,
221
+ route,
222
+ rootSpecifiers = [],
223
+ hasExplicitRoot = false,
224
+ ) {
225
+ if (!route || !route.startsWith('/')) return;
226
+ const { line, column } = sourcePosition(sourceFile, node);
227
+ let entry = result.routes.get(route);
228
+ if (!entry) {
229
+ entry = {
230
+ id: `route:${route}`,
231
+ path: route,
232
+ sourcePaths: new Set(),
233
+ rootSpecifiers: new Set(),
234
+ hasExplicitRoot: false,
235
+ declarations: [],
236
+ };
237
+ result.routes.set(route, entry);
238
+ }
239
+ entry.sourcePaths.add(sourcePath);
240
+ rootSpecifiers.forEach((specifier) => entry.rootSpecifiers.add(specifier));
241
+ entry.hasExplicitRoot ||= hasExplicitRoot;
242
+ entry.declarations.push({ sourcePath, line, column });
243
+ }
244
+
245
+ function addTransition(result, sourceFile, sourcePath, node, target, kind) {
246
+ if (!target || !target.startsWith('/')) return;
247
+ const { line, column } = sourcePosition(sourceFile, node);
248
+ result.transitions.push({
249
+ id: `${sourcePath}:${line}:${column}:${kind}:${target}`,
250
+ sourcePath,
251
+ target,
252
+ kind,
253
+ line,
254
+ column,
255
+ });
256
+ }
257
+
258
+ function exportedStringConstants(source, sourcePath) {
259
+ const scriptKind = sourcePath.endsWith('.tsx')
260
+ ? ts.ScriptKind.TSX
261
+ : sourcePath.endsWith('.jsx')
262
+ ? ts.ScriptKind.JSX
263
+ : sourcePath.endsWith('.ts')
264
+ ? ts.ScriptKind.TS
265
+ : ts.ScriptKind.JS;
266
+ const sourceFile = ts.createSourceFile(
267
+ sourcePath,
268
+ source,
269
+ ts.ScriptTarget.Latest,
270
+ true,
271
+ scriptKind,
272
+ );
273
+ const result = new Map();
274
+ for (const statement of sourceFile.statements) {
275
+ if (!ts.isVariableStatement(statement)) continue;
276
+ const exported = statement.modifiers?.some(
277
+ (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
278
+ );
279
+ if (!exported) continue;
280
+ for (const declaration of statement.declarationList.declarations) {
281
+ if (!ts.isIdentifier(declaration.name)) continue;
282
+ const value = literalText(declaration.initializer);
283
+ if (value != null) result.set(declaration.name.text, value);
284
+ }
285
+ }
286
+ return result;
287
+ }
288
+
289
+ function scanSource(source, sourcePath, result, context) {
290
+ const scriptKind = sourcePath.endsWith('.tsx')
291
+ ? ts.ScriptKind.TSX
292
+ : sourcePath.endsWith('.jsx')
293
+ ? ts.ScriptKind.JSX
294
+ : sourcePath.endsWith('.ts')
295
+ ? ts.ScriptKind.TS
296
+ : ts.ScriptKind.JS;
297
+ const sourceFile = ts.createSourceFile(
298
+ sourcePath,
299
+ source,
300
+ ts.ScriptTarget.Latest,
301
+ true,
302
+ scriptKind,
303
+ );
304
+ const constants = new Map();
305
+ const moduleBindings = new Map();
306
+ const importedNames = new Map();
307
+ const localDeclarations = new Map();
308
+
309
+ function collectConstants(node) {
310
+ if (
311
+ ts.isVariableDeclaration(node) &&
312
+ ts.isIdentifier(node.name) &&
313
+ node.initializer
314
+ ) {
315
+ const value = literalText(node.initializer, constants);
316
+ if (value != null) constants.set(node.name.text, value);
317
+ }
318
+ ts.forEachChild(node, collectConstants);
319
+ }
320
+
321
+ collectConstants(sourceFile);
322
+
323
+ function collectBindings(node) {
324
+ if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
325
+ const specifier = node.moduleSpecifier.text;
326
+ const clause = node.importClause;
327
+ if (clause?.name) {
328
+ moduleBindings.set(clause.name.text, specifier);
329
+ importedNames.set(clause.name.text, 'default');
330
+ }
331
+ if (clause?.namedBindings) {
332
+ if (ts.isNamespaceImport(clause.namedBindings)) {
333
+ moduleBindings.set(clause.namedBindings.name.text, specifier);
334
+ importedNames.set(clause.namedBindings.name.text, '*');
335
+ } else {
336
+ for (const element of clause.namedBindings.elements) {
337
+ moduleBindings.set(element.name.text, specifier);
338
+ importedNames.set(
339
+ element.name.text,
340
+ element.propertyName?.text ?? element.name.text,
341
+ );
342
+ }
343
+ }
344
+ }
345
+ }
346
+ if (ts.isFunctionDeclaration(node) && node.name) {
347
+ localDeclarations.set(node.name.text, node);
348
+ } else if (ts.isClassDeclaration(node) && node.name) {
349
+ localDeclarations.set(node.name.text, node);
350
+ } else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
351
+ localDeclarations.set(node.name.text, node);
352
+ if (node.initializer) {
353
+ let dynamicSpecifier = null;
354
+ function findDynamicImport(current) {
355
+ if (
356
+ ts.isCallExpression(current) &&
357
+ current.expression.kind === ts.SyntaxKind.ImportKeyword
358
+ ) {
359
+ dynamicSpecifier ??= literalText(current.arguments[0], constants);
360
+ }
361
+ ts.forEachChild(current, findDynamicImport);
362
+ }
363
+ findDynamicImport(node.initializer);
364
+ if (dynamicSpecifier) moduleBindings.set(node.name.text, dynamicSpecifier);
365
+ }
366
+ }
367
+ ts.forEachChild(node, collectBindings);
368
+ }
369
+
370
+ collectBindings(sourceFile);
371
+ for (const [localName, specifier] of moduleBindings) {
372
+ const importedName = importedNames.get(localName);
373
+ if (!importedName || importedName === 'default' || importedName === '*') {
374
+ continue;
375
+ }
376
+ const target = resolveSourceSpecifier(
377
+ sourcePath,
378
+ specifier,
379
+ context.sourceFiles,
380
+ );
381
+ const value = target
382
+ ? context.exportedConstants.get(target)?.get(importedName)
383
+ : null;
384
+ if (value != null) constants.set(localName, value);
385
+ }
386
+ collectConstants(sourceFile);
387
+
388
+ function rootSpecifiers(node) {
389
+ const specifiers = new Set();
390
+ const visitedDeclarations = new Set();
391
+ function visitReference(current) {
392
+ if (ts.isIdentifier(current)) {
393
+ const moduleSpecifier = moduleBindings.get(current.text);
394
+ if (moduleSpecifier) specifiers.add(moduleSpecifier);
395
+ const declaration = localDeclarations.get(current.text);
396
+ if (declaration && !visitedDeclarations.has(declaration)) {
397
+ visitedDeclarations.add(declaration);
398
+ ts.forEachChild(declaration, visitReference);
399
+ }
400
+ }
401
+ if (
402
+ ts.isCallExpression(current) &&
403
+ current.expression.kind === ts.SyntaxKind.ImportKeyword
404
+ ) {
405
+ const dynamicSpecifier = literalText(current.arguments[0], constants);
406
+ if (dynamicSpecifier) specifiers.add(dynamicSpecifier);
407
+ }
408
+ ts.forEachChild(current, visitReference);
409
+ }
410
+ if (node) visitReference(node);
411
+ return [...specifiers].sort();
412
+ }
413
+
414
+ function normalizedRoutePath(node, rawRoute) {
415
+ if (!rawRoute) return rawRoute;
416
+ if (rawRoute.startsWith('/')) return rawRoute;
417
+ let current = node.parent;
418
+ while (current) {
419
+ if (ts.isObjectLiteralExpression(current)) {
420
+ for (const property of current.properties) {
421
+ if (!ts.isPropertyAssignment(property)) continue;
422
+ const name = literalText(property.name, constants) ??
423
+ (ts.isIdentifier(property.name) ? property.name.text : null);
424
+ if (name !== 'path') continue;
425
+ const parentRoute = literalText(property.initializer, constants);
426
+ if (!parentRoute) continue;
427
+ const prefix = parentRoute.startsWith('/')
428
+ ? parentRoute
429
+ : `/${parentRoute}`;
430
+ return `${prefix.replace(/\/+$/, '')}/${rawRoute.replace(/^\/+/, '')}`;
431
+ }
432
+ }
433
+ current = current.parent;
434
+ }
435
+ return `/${rawRoute.replace(/^\/+/, '')}`;
436
+ }
437
+ const storageWrappers = new Map();
438
+ const wrappedStorageNodes = new Set();
439
+
440
+ function collectStorageWrappers(node) {
441
+ if (
442
+ ts.isFunctionDeclaration(node) &&
443
+ node.name &&
444
+ node.body
445
+ ) {
446
+ const parameters = node.parameters.map((parameter) =>
447
+ ts.isIdentifier(parameter.name) ? parameter.name.text : null,
448
+ );
449
+ const templates = [];
450
+
451
+ function visitWrapper(current) {
452
+ const shape = storageCallShape(current);
453
+ if (shape && ts.isIdentifier(shape.keyNode)) {
454
+ const parameterIndex = parameters.indexOf(shape.keyNode.text);
455
+ if (parameterIndex >= 0) {
456
+ const values = new Set();
457
+ const written = literalText(shape.valueNode, constants);
458
+ if (written != null) values.add(written);
459
+ const parent = current.parent;
460
+ if (ts.isBinaryExpression(parent)) {
461
+ const compared =
462
+ parent.left === current
463
+ ? literalText(parent.right, constants)
464
+ : parent.right === current
465
+ ? literalText(parent.left, constants)
466
+ : null;
467
+ if (compared != null) values.add(compared);
468
+ }
469
+ templates.push({
470
+ node: current,
471
+ storage: shape.storage,
472
+ operation: shape.operation,
473
+ parameterIndex,
474
+ values,
475
+ });
476
+ }
477
+ }
478
+ ts.forEachChild(current, visitWrapper);
479
+ }
480
+
481
+ visitWrapper(node.body);
482
+ if (templates.length > 0) storageWrappers.set(node.name.text, templates);
483
+ }
484
+ ts.forEachChild(node, collectStorageWrappers);
485
+ }
486
+
487
+ collectStorageWrappers(sourceFile);
488
+ const wrapperCallStats = new Map(
489
+ [...storageWrappers.keys()].map((name) => [
490
+ name,
491
+ { total: 0, resolved: 0 },
492
+ ]),
493
+ );
494
+
495
+ function collectWrapperCalls(node) {
496
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
497
+ const templates = storageWrappers.get(node.expression.text);
498
+ const stats = wrapperCallStats.get(node.expression.text);
499
+ if (templates && stats) {
500
+ stats.total += 1;
501
+ const resolved = templates.every(
502
+ (template) =>
503
+ literalText(node.arguments[template.parameterIndex], constants) != null,
504
+ );
505
+ if (resolved) stats.resolved += 1;
506
+ }
507
+ }
508
+ ts.forEachChild(node, collectWrapperCalls);
509
+ }
510
+
511
+ collectWrapperCalls(sourceFile);
512
+ for (const [name, templates] of storageWrappers) {
513
+ const stats = wrapperCallStats.get(name);
514
+ if (stats && stats.total > 0 && stats.total === stats.resolved) {
515
+ templates.forEach((template) => wrappedStorageNodes.add(template.node));
516
+ }
517
+ }
518
+
519
+ function visit(node) {
520
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
521
+ const tag = jsxTagName(node.tagName);
522
+ if (tag === 'Route' || tag.endsWith('.Route')) {
523
+ const element = node.attributes.properties.find(
524
+ (property) =>
525
+ ts.isJsxAttribute(property) &&
526
+ property.name.text === 'element',
527
+ );
528
+ addRoute(
529
+ result,
530
+ sourceFile,
531
+ sourcePath,
532
+ node,
533
+ normalizedRoutePath(
534
+ node,
535
+ jsxStringAttribute(node.attributes, 'path', constants),
536
+ ),
537
+ rootSpecifiers(
538
+ element && ts.isJsxAttribute(element)
539
+ ? element.initializer
540
+ : undefined,
541
+ ),
542
+ Boolean(element),
543
+ );
544
+ }
545
+ if (
546
+ tag === 'Link' ||
547
+ tag === 'NavLink' ||
548
+ tag === 'Navigate' ||
549
+ tag.endsWith('.Link') ||
550
+ tag.endsWith('.NavLink') ||
551
+ tag.endsWith('.Navigate')
552
+ ) {
553
+ addTransition(
554
+ result,
555
+ sourceFile,
556
+ sourcePath,
557
+ node,
558
+ jsxStringAttribute(node.attributes, 'to', constants),
559
+ 'jsx-navigation',
560
+ );
561
+ }
562
+ }
563
+
564
+ if (ts.isObjectLiteralExpression(node)) {
565
+ let route = null;
566
+ let routeLike = false;
567
+ let routeRoot = null;
568
+ for (const property of node.properties) {
569
+ if (!ts.isPropertyAssignment(property)) continue;
570
+ const name = literalText(property.name, constants) ??
571
+ (ts.isIdentifier(property.name) ? property.name.text : null);
572
+ if (name === 'path') route = literalText(property.initializer, constants);
573
+ if (
574
+ name === 'element' ||
575
+ name === 'Component' ||
576
+ name === 'children' ||
577
+ name === 'loader' ||
578
+ name === 'lazy'
579
+ ) {
580
+ routeLike = true;
581
+ if (
582
+ name === 'element' ||
583
+ name === 'Component' ||
584
+ name === 'lazy'
585
+ ) {
586
+ routeRoot = property.initializer;
587
+ }
588
+ }
589
+ }
590
+ if (routeLike) {
591
+ const normalizedRoute = normalizedRoutePath(node, route);
592
+ addRoute(
593
+ result,
594
+ sourceFile,
595
+ sourcePath,
596
+ node,
597
+ normalizedRoute,
598
+ rootSpecifiers(routeRoot),
599
+ Boolean(routeRoot),
600
+ );
601
+ if (!normalizedRoute) {
602
+ const pathProperty = node.properties.find((property) => {
603
+ if (!ts.isPropertyAssignment(property)) return false;
604
+ const name = literalText(property.name, constants) ??
605
+ (ts.isIdentifier(property.name) ? property.name.text : null);
606
+ return name === 'path';
607
+ });
608
+ if (pathProperty) {
609
+ addUnresolved(
610
+ result,
611
+ sourceFile,
612
+ sourcePath,
613
+ pathProperty,
614
+ 'route',
615
+ 'Route path is computed at runtime.',
616
+ );
617
+ }
618
+ }
619
+ }
620
+ }
621
+
622
+ if (ts.isCallExpression(node)) {
623
+ const called = callPath(node);
624
+ if (
625
+ called === 'navigate' ||
626
+ called.endsWith('.navigate') ||
627
+ called === 'router.push' ||
628
+ called === 'router.replace'
629
+ ) {
630
+ addTransition(
631
+ result,
632
+ sourceFile,
633
+ sourcePath,
634
+ node,
635
+ literalText(node.arguments[0], constants),
636
+ 'imperative-navigation',
637
+ );
638
+ }
639
+ if (
640
+ called === 'navigator.permissions.query' ||
641
+ called === 'window.navigator.permissions.query'
642
+ ) {
643
+ const permission = objectStringProperty(
644
+ node.arguments[0],
645
+ 'name',
646
+ constants,
647
+ );
648
+ if (permission) {
649
+ const id = addEvidence(
650
+ result,
651
+ sourceFile,
652
+ sourcePath,
653
+ node,
654
+ 'permission',
655
+ permission,
656
+ );
657
+ ensureDimension(
658
+ result,
659
+ 'permission',
660
+ permission,
661
+ ['granted', 'denied', 'prompt'],
662
+ id,
663
+ );
664
+ } else {
665
+ addUnresolved(
666
+ result,
667
+ sourceFile,
668
+ sourcePath,
669
+ node,
670
+ 'permission',
671
+ 'Permission name is computed at runtime.',
672
+ );
673
+ }
674
+ }
675
+
676
+ const mediaMatch = called.match(
677
+ /^(?:window\.)?navigator\.mediaDevices\.(enumerateDevices|getUserMedia)$/,
678
+ );
679
+ if (mediaMatch) {
680
+ const operation = mediaMatch[1];
681
+ const id = addEvidence(
682
+ result,
683
+ sourceFile,
684
+ sourcePath,
685
+ node,
686
+ 'media-devices',
687
+ operation,
688
+ );
689
+ ensureDimension(
690
+ result,
691
+ 'media-devices',
692
+ operation,
693
+ operation === 'enumerateDevices'
694
+ ? ['available', 'empty']
695
+ : ['passthrough', 'not-allowed', 'not-found', 'not-readable'],
696
+ id,
697
+ );
698
+ }
699
+
700
+ const storage = storageCall(node, constants);
701
+ if (storage && (storage.key || !wrappedStorageNodes.has(node))) {
702
+ const capability = `${storage.storage}:${storage.key ?? '<dynamic>'}`;
703
+ const id = addEvidence(
704
+ result,
705
+ sourceFile,
706
+ sourcePath,
707
+ node,
708
+ 'storage',
709
+ capability,
710
+ { operation: storage.operation },
711
+ );
712
+ if (storage.key) {
713
+ const values = ['missing'];
714
+ if (storage.operation === 'setItem' && storage.value != null) {
715
+ values.push(storage.value);
716
+ }
717
+ ensureDimension(result, 'storage', capability, values, id);
718
+ } else if (!wrappedStorageNodes.has(node)) {
719
+ addUnresolved(
720
+ result,
721
+ sourceFile,
722
+ sourcePath,
723
+ node,
724
+ 'storage',
725
+ 'Storage key is computed at runtime.',
726
+ );
727
+ }
728
+ }
729
+
730
+ const wrapper = ts.isIdentifier(node.expression)
731
+ ? storageWrappers.get(node.expression.text)
732
+ : null;
733
+ if (wrapper) {
734
+ for (const template of wrapper) {
735
+ const key = literalText(
736
+ node.arguments[template.parameterIndex],
737
+ constants,
738
+ );
739
+ if (!key) {
740
+ addUnresolved(
741
+ result,
742
+ sourceFile,
743
+ sourcePath,
744
+ node,
745
+ 'storage',
746
+ 'A local storage wrapper is called with a computed key.',
747
+ );
748
+ continue;
749
+ }
750
+ const capability = `${template.storage}:${key}`;
751
+ const id = addEvidence(
752
+ result,
753
+ sourceFile,
754
+ sourcePath,
755
+ node,
756
+ 'storage',
757
+ capability,
758
+ { operation: template.operation },
759
+ );
760
+ ensureDimension(
761
+ result,
762
+ 'storage',
763
+ capability,
764
+ ['missing', ...template.values],
765
+ id,
766
+ );
767
+ }
768
+ }
769
+ }
770
+
771
+ if (ts.isBinaryExpression(node)) {
772
+ const leftStorage = storageCall(node.left, constants);
773
+ const rightStorage = storageCall(node.right, constants);
774
+ const storage = leftStorage ?? rightStorage;
775
+ const compared = leftStorage
776
+ ? literalText(node.right, constants)
777
+ : literalText(node.left, constants);
778
+ if (storage?.key && compared != null) {
779
+ const capability = `${storage.storage}:${storage.key}`;
780
+ const id = addEvidence(
781
+ result,
782
+ sourceFile,
783
+ sourcePath,
784
+ node,
785
+ 'storage',
786
+ capability,
787
+ { operation: 'compare' },
788
+ );
789
+ ensureDimension(result, 'storage', capability, ['missing', compared], id);
790
+ }
791
+ }
792
+
793
+ if (ts.isStringLiteralLike(node)) {
794
+ const errorNames = new Map([
795
+ ['NotAllowedError', 'not-allowed'],
796
+ ['NotFoundError', 'not-found'],
797
+ ['NotReadableError', 'not-readable'],
798
+ ['OverconstrainedError', 'overconstrained'],
799
+ ]);
800
+ const failure = errorNames.get(node.text);
801
+ if (failure) {
802
+ const id = addEvidence(
803
+ result,
804
+ sourceFile,
805
+ sourcePath,
806
+ node,
807
+ 'media-error',
808
+ failure,
809
+ );
810
+ ensureDimension(
811
+ result,
812
+ 'media-devices',
813
+ 'getUserMedia',
814
+ [failure],
815
+ id,
816
+ );
817
+ }
818
+ }
819
+
820
+ ts.forEachChild(node, visit);
821
+ }
822
+
823
+ visit(sourceFile);
824
+ }
825
+
826
+ function environmentForDimension(dimension, value) {
827
+ if (dimension.kind === 'permission') {
828
+ return { permissions: { [dimension.name]: value } };
829
+ }
830
+ if (dimension.kind === 'storage') {
831
+ const separator = dimension.name.indexOf(':');
832
+ const storageName = dimension.name.slice(0, separator);
833
+ const key = dimension.name.slice(separator + 1);
834
+ return {
835
+ [storageName]: {
836
+ [key]: value === 'missing' ? null : value,
837
+ },
838
+ };
839
+ }
840
+ if (dimension.kind === 'media-devices' && dimension.name === 'enumerateDevices') {
841
+ return {
842
+ mediaDevices: {
843
+ devices:
844
+ value === 'empty'
845
+ ? []
846
+ : [
847
+ {
848
+ kind: 'audioinput',
849
+ deviceId: 'pygmalion-audio-input',
850
+ groupId: 'pygmalion-device-group',
851
+ label: 'Pygmalion audio input',
852
+ },
853
+ ],
854
+ },
855
+ };
856
+ }
857
+ if (dimension.kind === 'media-devices' && dimension.name === 'getUserMedia') {
858
+ return { mediaDevices: { getUserMedia: value } };
859
+ }
860
+ return {};
861
+ }
862
+
863
+ function mergeEnvironment(base, override) {
864
+ const merged = structuredClone(base);
865
+ for (const [key, value] of Object.entries(override)) {
866
+ if (
867
+ value &&
868
+ typeof value === 'object' &&
869
+ !Array.isArray(value) &&
870
+ merged[key] &&
871
+ typeof merged[key] === 'object' &&
872
+ !Array.isArray(merged[key])
873
+ ) {
874
+ merged[key] = { ...merged[key], ...structuredClone(value) };
875
+ } else {
876
+ merged[key] = structuredClone(value);
877
+ }
878
+ }
879
+ return merged;
880
+ }
881
+
882
+ function baselineEnvironment(dimensions) {
883
+ const environment = {};
884
+ const permissions = dimensions.filter(
885
+ (dimension) => dimension.kind === 'permission',
886
+ );
887
+ if (permissions.length > 0) {
888
+ environment.permissions = Object.fromEntries(
889
+ permissions.map((dimension) => [dimension.name, 'prompt']),
890
+ );
891
+ }
892
+ if (
893
+ dimensions.some(
894
+ (dimension) =>
895
+ dimension.kind === 'media-devices' &&
896
+ dimension.name === 'enumerateDevices',
897
+ )
898
+ ) {
899
+ environment.mediaDevices = { devices: [] };
900
+ }
901
+ return environment;
902
+ }
903
+
904
+ function finalizeScan(result, root) {
905
+ const dimensions = [...result.dimensions.values()]
906
+ .map((dimension) => ({
907
+ ...dimension,
908
+ values: [...dimension.values].sort(),
909
+ evidenceIds: [...dimension.evidenceIds].sort(),
910
+ }))
911
+ .sort((a, b) => a.id.localeCompare(b.id));
912
+ const baseline = baselineEnvironment(dimensions);
913
+ const capabilityDimensions = dimensions.filter(
914
+ (dimension) =>
915
+ dimension.kind === 'permission' ||
916
+ dimension.kind === 'media-devices',
917
+ );
918
+ const capabilityEvidence = [
919
+ ...new Set(
920
+ capabilityDimensions.flatMap((dimension) => dimension.evidenceIds),
921
+ ),
922
+ ].sort();
923
+ const grantedPermissions = Object.fromEntries(
924
+ dimensions
925
+ .filter((dimension) => dimension.kind === 'permission')
926
+ .map((dimension) => [dimension.name, 'granted']),
927
+ );
928
+ const deniedPermissions = Object.fromEntries(
929
+ dimensions
930
+ .filter((dimension) => dimension.kind === 'permission')
931
+ .map((dimension) => [dimension.name, 'denied']),
932
+ );
933
+ const scenarios = [
934
+ {
935
+ id: 'environment:baseline',
936
+ label: 'Browser baseline',
937
+ environment: baseline,
938
+ evidenceIds: [],
939
+ },
940
+ ...dimensions.flatMap((dimension) =>
941
+ dimension.values.map((value) => ({
942
+ id: `${dimension.id}:${value}`,
943
+ label: `${dimension.name} = ${value}`,
944
+ environment: mergeEnvironment(
945
+ baseline,
946
+ environmentForDimension(dimension, value),
947
+ ),
948
+ evidenceIds: dimension.evidenceIds,
949
+ })),
950
+ ),
951
+ ...(capabilityDimensions.length > 1
952
+ ? [
953
+ {
954
+ id: 'environment:browser-capabilities:available',
955
+ label: 'Browser capabilities available',
956
+ environment: mergeEnvironment(baseline, {
957
+ ...(Object.keys(grantedPermissions).length > 0
958
+ ? { permissions: grantedPermissions }
959
+ : {}),
960
+ mediaDevices: {
961
+ devices: [
962
+ {
963
+ kind: 'audioinput',
964
+ deviceId: 'pygmalion-audio-input',
965
+ groupId: 'pygmalion-device-group',
966
+ label: 'Pygmalion audio input',
967
+ },
968
+ ],
969
+ getUserMedia: 'passthrough',
970
+ },
971
+ }),
972
+ evidenceIds: capabilityEvidence,
973
+ },
974
+ {
975
+ id: 'environment:browser-capabilities:unavailable',
976
+ label: 'Browser capabilities unavailable',
977
+ environment: mergeEnvironment(baseline, {
978
+ ...(Object.keys(deniedPermissions).length > 0
979
+ ? { permissions: deniedPermissions }
980
+ : {}),
981
+ mediaDevices: {
982
+ devices: [],
983
+ getUserMedia: 'not-found',
984
+ },
985
+ }),
986
+ evidenceIds: capabilityEvidence,
987
+ },
988
+ ]
989
+ : []),
990
+ ];
991
+ return {
992
+ version: 1,
993
+ sourceRoot: root,
994
+ dimensions,
995
+ scenarios,
996
+ evidence: [...result.evidenceById.values()].sort((a, b) =>
997
+ a.id.localeCompare(b.id),
998
+ ),
999
+ routes: [...result.routes.values()]
1000
+ .map((route) => ({
1001
+ ...route,
1002
+ sourcePaths: [...route.sourcePaths].sort(),
1003
+ rootSpecifiers: [...route.rootSpecifiers].sort(),
1004
+ declarations: route.declarations.sort((a, b) =>
1005
+ `${a.sourcePath}:${a.line}:${a.column}`.localeCompare(
1006
+ `${b.sourcePath}:${b.line}:${b.column}`,
1007
+ ),
1008
+ ),
1009
+ }))
1010
+ .sort((a, b) => a.path.localeCompare(b.path)),
1011
+ transitions: result.transitions.sort((a, b) => a.id.localeCompare(b.id)),
1012
+ unresolved: result.unresolved.sort((a, b) => a.id.localeCompare(b.id)),
1013
+ };
1014
+ }
1015
+
1016
+ function stripSourceDirectory(file, sourceDirectory) {
1017
+ if (sourceDirectory === '.') return file;
1018
+ const prefix = `${sourceDirectory}/`;
1019
+ return file.startsWith(prefix) ? file.slice(prefix.length) : file;
1020
+ }
1021
+
1022
+ function resolveSourceSpecifier(importer, specifier, files) {
1023
+ let base;
1024
+ if (specifier.startsWith('@/')) {
1025
+ base = specifier.slice(2);
1026
+ } else if (specifier.startsWith('./') || specifier.startsWith('../')) {
1027
+ base = path.posix.normalize(
1028
+ path.posix.join(path.posix.dirname(importer), specifier),
1029
+ );
1030
+ } else {
1031
+ return null;
1032
+ }
1033
+ const candidates = [
1034
+ base,
1035
+ ...['.ts', '.tsx', '.js', '.jsx'].map((extension) => `${base}${extension}`),
1036
+ ...['.ts', '.tsx', '.js', '.jsx'].map((extension) =>
1037
+ path.posix.join(base, `index${extension}`),
1038
+ ),
1039
+ ];
1040
+ return candidates.find((candidate) => files.has(candidate)) ?? null;
1041
+ }
1042
+
1043
+ function associateRouteCandidates(manifest, graph) {
1044
+ const dependencies = {};
1045
+ for (const [file, values] of Object.entries(graph.dependencies)) {
1046
+ dependencies[stripSourceDirectory(file, graph.sourceDirectory)] = values.map(
1047
+ (value) => stripSourceDirectory(value, graph.sourceDirectory),
1048
+ );
1049
+ }
1050
+
1051
+ const dependencyClosure = (roots) => {
1052
+ const visited = new Set(roots);
1053
+ const queue = [...visited];
1054
+ for (let index = 0; index < queue.length; index += 1) {
1055
+ for (const dependency of dependencies[queue[index]] ?? []) {
1056
+ if (visited.has(dependency)) continue;
1057
+ visited.add(dependency);
1058
+ queue.push(dependency);
1059
+ }
1060
+ }
1061
+ return visited;
1062
+ };
1063
+ const sourceFiles = new Set(Object.keys(dependencies));
1064
+
1065
+ const scenarioByEvidence = new Map();
1066
+ for (const scenario of manifest.scenarios) {
1067
+ for (const evidenceId of scenario.evidenceIds) {
1068
+ let ids = scenarioByEvidence.get(evidenceId);
1069
+ if (!ids) {
1070
+ ids = new Set();
1071
+ scenarioByEvidence.set(evidenceId, ids);
1072
+ }
1073
+ ids.add(scenario.id);
1074
+ }
1075
+ }
1076
+
1077
+ const candidates = [];
1078
+ const edges = [];
1079
+ const routes = manifest.routes.map((route) => {
1080
+ const resolvedRoots = route.sourcePaths.flatMap((sourcePath) =>
1081
+ route.rootSpecifiers
1082
+ .map((specifier) =>
1083
+ resolveSourceSpecifier(sourcePath, specifier, sourceFiles),
1084
+ )
1085
+ .filter(Boolean),
1086
+ );
1087
+ const reachable = dependencyClosure(
1088
+ resolvedRoots.length > 0
1089
+ ? resolvedRoots
1090
+ : route.hasExplicitRoot
1091
+ ? []
1092
+ : route.sourcePaths,
1093
+ );
1094
+ const evidenceIds = manifest.evidence
1095
+ .filter((evidence) => reachable.has(evidence.sourcePath))
1096
+ .map((evidence) => evidence.id);
1097
+ const scenarioIds = new Set(['environment:baseline']);
1098
+ for (const evidenceId of evidenceIds) {
1099
+ for (const scenarioId of scenarioByEvidence.get(evidenceId) ?? []) {
1100
+ scenarioIds.add(scenarioId);
1101
+ }
1102
+ }
1103
+ for (const scenarioId of scenarioIds) {
1104
+ const scenario = manifest.scenarios.find((item) => item.id === scenarioId);
1105
+ if (!scenario) continue;
1106
+ candidates.push({
1107
+ id: `${route.id}:${scenario.id}`,
1108
+ route: route.path,
1109
+ scenarioId: scenario.id,
1110
+ environment: scenario.environment,
1111
+ evidenceIds: scenario.evidenceIds.filter((id) => evidenceIds.includes(id)),
1112
+ });
1113
+ }
1114
+ for (const transition of manifest.transitions) {
1115
+ if (!reachable.has(transition.sourcePath)) continue;
1116
+ edges.push({
1117
+ id: `${route.id}:${transition.id}`,
1118
+ from: route.path,
1119
+ to: transition.target,
1120
+ kind: transition.kind,
1121
+ evidence: {
1122
+ sourcePath: transition.sourcePath,
1123
+ line: transition.line,
1124
+ column: transition.column,
1125
+ },
1126
+ });
1127
+ }
1128
+ return {
1129
+ ...route,
1130
+ resolvedRoots: [...new Set(resolvedRoots)].sort(),
1131
+ evidenceIds,
1132
+ scenarioIds: [...scenarioIds].sort(),
1133
+ };
1134
+ });
1135
+
1136
+ const knownRoutes = new Set(routes.map((route) => route.path));
1137
+ const uniqueEdges = [
1138
+ ...new Map(
1139
+ edges.map((edge) => [
1140
+ `${edge.from}:${edge.to}:${edge.kind}:${edge.evidence.sourcePath}:${edge.evidence.line}`,
1141
+ { ...edge, unresolvedTarget: !knownRoutes.has(edge.to) },
1142
+ ]),
1143
+ ).values(),
1144
+ ].sort((a, b) => a.id.localeCompare(b.id));
1145
+
1146
+ return {
1147
+ ...manifest,
1148
+ routes,
1149
+ candidates: candidates.sort((a, b) => a.id.localeCompare(b.id)),
1150
+ edges: uniqueEdges,
1151
+ coverage: {
1152
+ routes: routes.length,
1153
+ candidates: candidates.length,
1154
+ environmentDimensions: manifest.dimensions.length,
1155
+ unresolved: manifest.unresolved.length,
1156
+ unresolvedTargets: uniqueEdges.filter((edge) => edge.unresolvedTarget).length,
1157
+ },
1158
+ };
1159
+ }
1160
+
1161
+ /**
1162
+ * Finds browser-environment branches from source without importing or executing
1163
+ * the consuming application.
1164
+ */
1165
+ export async function scanStoryboardEnvironment(root, options = {}) {
1166
+ const sourceRoot = path.resolve(root, options.sourceDirectory ?? '.');
1167
+ const files = await collectSourceFiles(sourceRoot, options);
1168
+ const result = {
1169
+ dimensions: new Map(),
1170
+ evidenceById: new Map(),
1171
+ routes: new Map(),
1172
+ transitions: [],
1173
+ unresolved: [],
1174
+ };
1175
+ const sources = [];
1176
+
1177
+ for (const file of files) {
1178
+ let stat;
1179
+ try {
1180
+ stat = await fs.stat(file);
1181
+ } catch {
1182
+ continue;
1183
+ }
1184
+ if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) continue;
1185
+ let source;
1186
+ try {
1187
+ source = await fs.readFile(file, 'utf8');
1188
+ } catch {
1189
+ continue;
1190
+ }
1191
+ sources.push({
1192
+ source,
1193
+ sourcePath: normalizedRelativePath(sourceRoot, file),
1194
+ });
1195
+ }
1196
+
1197
+ const sourceFiles = new Set(sources.map((entry) => entry.sourcePath));
1198
+ const exportedConstants = new Map(
1199
+ sources.map((entry) => [
1200
+ entry.sourcePath,
1201
+ exportedStringConstants(entry.source, entry.sourcePath),
1202
+ ]),
1203
+ );
1204
+ for (const entry of sources) {
1205
+ scanSource(entry.source, entry.sourcePath, result, {
1206
+ sourceFiles,
1207
+ exportedConstants,
1208
+ });
1209
+ }
1210
+
1211
+ const manifest = finalizeScan(result, sourceRoot);
1212
+ const graph = await buildSourceGraph(path.resolve(root), {
1213
+ sourceDirectory: options.sourceDirectory ?? '.',
1214
+ });
1215
+ return associateRouteCandidates(manifest, graph);
1216
+ }
1217
+
1218
+ export function storyboardEnvironmentBootstrapSource() {
1219
+ const queryName = JSON.stringify(PYGMALION_STORYBOARD_ENVIRONMENT_QUERY);
1220
+ const maxBytes = MAX_ENVIRONMENT_BYTES;
1221
+ return `;(() => {
1222
+ const queryName = ${queryName};
1223
+ const encoded = new URL(location.href).searchParams.get(queryName);
1224
+ if (!encoded || encoded.length > ${maxBytes}) return;
1225
+ const decode = (value) => {
1226
+ const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
1227
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
1228
+ const binary = atob(padded);
1229
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
1230
+ return JSON.parse(new TextDecoder().decode(bytes));
1231
+ };
1232
+ const plainRecord = (value) =>
1233
+ value && typeof value === 'object' && !Array.isArray(value) ? value : null;
1234
+ const environment = plainRecord(decode(encoded));
1235
+ if (!environment) return;
1236
+ const storageOverlays = new Map();
1237
+ const addStorageOverlay = (storage, entries) => {
1238
+ const record = plainRecord(entries);
1239
+ if (!storage || !record) return;
1240
+ const overlay = {};
1241
+ for (const [key, value] of Object.entries(record)) {
1242
+ if (
1243
+ typeof key === 'string' &&
1244
+ key.length <= 512 &&
1245
+ (value === null || typeof value === 'string')
1246
+ ) {
1247
+ overlay[key] = value;
1248
+ }
1249
+ }
1250
+ storageOverlays.set(storage, overlay);
1251
+ };
1252
+ addStorageOverlay(globalThis.localStorage, environment.localStorage);
1253
+ addStorageOverlay(globalThis.sessionStorage, environment.sessionStorage);
1254
+ if (storageOverlays.size > 0 && globalThis.Storage?.prototype) {
1255
+ const storagePrototype = globalThis.Storage.prototype;
1256
+ const originalGetItem = storagePrototype.getItem;
1257
+ const originalSetItem = storagePrototype.setItem;
1258
+ const originalRemoveItem = storagePrototype.removeItem;
1259
+ const originalClear = storagePrototype.clear;
1260
+ Object.defineProperties(storagePrototype, {
1261
+ getItem: {
1262
+ configurable: true,
1263
+ value(key) {
1264
+ const normalizedKey = String(key);
1265
+ const overlay = storageOverlays.get(this);
1266
+ return overlay && Object.hasOwn(overlay, normalizedKey)
1267
+ ? overlay[normalizedKey]
1268
+ : originalGetItem.call(this, normalizedKey);
1269
+ },
1270
+ },
1271
+ setItem: {
1272
+ configurable: true,
1273
+ value(key, value) {
1274
+ const normalizedKey = String(key);
1275
+ const overlay = storageOverlays.get(this);
1276
+ if (overlay && Object.hasOwn(overlay, normalizedKey)) {
1277
+ overlay[normalizedKey] = String(value);
1278
+ return;
1279
+ }
1280
+ return originalSetItem.call(this, normalizedKey, String(value));
1281
+ },
1282
+ },
1283
+ removeItem: {
1284
+ configurable: true,
1285
+ value(key) {
1286
+ const normalizedKey = String(key);
1287
+ const overlay = storageOverlays.get(this);
1288
+ if (overlay && Object.hasOwn(overlay, normalizedKey)) {
1289
+ overlay[normalizedKey] = null;
1290
+ return;
1291
+ }
1292
+ return originalRemoveItem.call(this, normalizedKey);
1293
+ },
1294
+ },
1295
+ clear: {
1296
+ configurable: true,
1297
+ value() {
1298
+ const overlay = storageOverlays.get(this);
1299
+ if (overlay) {
1300
+ for (const key of Object.keys(overlay)) overlay[key] = null;
1301
+ }
1302
+ return originalClear.call(this);
1303
+ },
1304
+ },
1305
+ });
1306
+ }
1307
+
1308
+ const permissions = plainRecord(environment.permissions);
1309
+ if (permissions && navigator.permissions?.query) {
1310
+ const originalQuery = navigator.permissions.query.bind(navigator.permissions);
1311
+ const createStatus = (state) => {
1312
+ const listeners = new Set();
1313
+ return {
1314
+ state,
1315
+ onchange: null,
1316
+ addEventListener(type, listener) {
1317
+ if (type === 'change') listeners.add(listener);
1318
+ },
1319
+ removeEventListener(type, listener) {
1320
+ if (type === 'change') listeners.delete(listener);
1321
+ },
1322
+ dispatchEvent(event) {
1323
+ if (event?.type !== 'change') return true;
1324
+ for (const listener of listeners) listener.call(this, event);
1325
+ if (typeof this.onchange === 'function') this.onchange.call(this, event);
1326
+ return !event?.defaultPrevented;
1327
+ },
1328
+ };
1329
+ };
1330
+ Object.defineProperty(navigator.permissions, 'query', {
1331
+ configurable: true,
1332
+ value(descriptor) {
1333
+ const state = descriptor && permissions[descriptor.name];
1334
+ return state === 'granted' || state === 'denied' || state === 'prompt'
1335
+ ? Promise.resolve(createStatus(state))
1336
+ : originalQuery(descriptor);
1337
+ },
1338
+ });
1339
+ }
1340
+
1341
+ const media = plainRecord(environment.mediaDevices);
1342
+ if (media && navigator.mediaDevices) {
1343
+ if (Array.isArray(media.devices)) {
1344
+ const devices = media.devices
1345
+ .filter((device) => plainRecord(device))
1346
+ .map((device) => ({
1347
+ kind: device.kind,
1348
+ deviceId: String(device.deviceId ?? ''),
1349
+ groupId: String(device.groupId ?? ''),
1350
+ label: String(device.label ?? ''),
1351
+ toJSON() {
1352
+ return {
1353
+ kind: this.kind,
1354
+ deviceId: this.deviceId,
1355
+ groupId: this.groupId,
1356
+ label: this.label,
1357
+ };
1358
+ },
1359
+ }));
1360
+ Object.defineProperty(navigator.mediaDevices, 'enumerateDevices', {
1361
+ configurable: true,
1362
+ value: async () => devices.map((device) => ({ ...device })),
1363
+ });
1364
+ }
1365
+ const failureNames = {
1366
+ 'not-allowed': 'NotAllowedError',
1367
+ 'not-found': 'NotFoundError',
1368
+ 'not-readable': 'NotReadableError',
1369
+ overconstrained: 'OverconstrainedError',
1370
+ };
1371
+ const failureName = failureNames[media.getUserMedia];
1372
+ if (failureName) {
1373
+ Object.defineProperty(navigator.mediaDevices, 'getUserMedia', {
1374
+ configurable: true,
1375
+ value: async () => {
1376
+ throw new DOMException('Pygmalion storyboard environment', failureName);
1377
+ },
1378
+ });
1379
+ }
1380
+ }
1381
+
1382
+ Object.defineProperty(globalThis, '__PYGMALION_STORYBOARD_ENVIRONMENT__', {
1383
+ configurable: true,
1384
+ value: environment,
1385
+ });
1386
+ const clean = new URL(location.href);
1387
+ clean.searchParams.delete(queryName);
1388
+ history.replaceState(history.state, '', clean.href);
1389
+ })();`;
1390
+ }
1391
+
1392
+ export function pygmalionStoryboardRuntimePlugin() {
1393
+ return {
1394
+ name: 'pygmalion-storyboard-runtime',
1395
+ enforce: 'pre',
1396
+ transformIndexHtml: {
1397
+ order: 'pre',
1398
+ handler() {
1399
+ return [
1400
+ {
1401
+ tag: 'script',
1402
+ attrs: { 'data-pygmalion-storyboard-runtime': '' },
1403
+ children: storyboardEnvironmentBootstrapSource(),
1404
+ injectTo: 'head-prepend',
1405
+ },
1406
+ ];
1407
+ },
1408
+ },
1409
+ };
1410
+ }
1411
+
1412
+ async function readExecutionResults(request) {
1413
+ const chunks = [];
1414
+ let size = 0;
1415
+ for await (const chunk of request) {
1416
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1417
+ size += buffer.length;
1418
+ if (size > MAX_EXECUTION_RESULTS_BYTES) {
1419
+ throw new RangeError('Storyboard execution results exceed the size limit.');
1420
+ }
1421
+ chunks.push(buffer);
1422
+ }
1423
+ const raw = Buffer.concat(chunks).toString('utf8');
1424
+ const parsed = JSON.parse(raw || '{}');
1425
+ if (
1426
+ !parsed ||
1427
+ typeof parsed !== 'object' ||
1428
+ !Array.isArray(parsed.executions)
1429
+ ) {
1430
+ throw new TypeError('Storyboard execution results require an executions array.');
1431
+ }
1432
+ return parsed.executions;
1433
+ }
1434
+
1435
+ function sendJson(response, statusCode, body) {
1436
+ response.statusCode = statusCode;
1437
+ response.setHeader('content-type', 'application/json; charset=utf-8');
1438
+ response.setHeader('cache-control', 'no-store');
1439
+ response.end(JSON.stringify(body));
1440
+ }
1441
+
1442
+ export function pygmalionStoryboardPlugin(options) {
1443
+ const root = path.resolve(options.root);
1444
+ const sourceDirectory = options.sourceDirectory ?? '.';
1445
+ const endpoint =
1446
+ options.endpoint ?? PYGMALION_STORYBOARD_ENVIRONMENT_CONTROL;
1447
+ const canonicalEndpoint =
1448
+ options.canonicalEndpoint ?? PYGMALION_STORYBOARD_CANONICAL_CONTROL;
1449
+ let cached = null;
1450
+
1451
+ return {
1452
+ ...pygmalionStoryboardRuntimePlugin(),
1453
+ name: 'pygmalion-storyboard',
1454
+ configureServer(server) {
1455
+ cached = scanStoryboardEnvironment(root, { sourceDirectory });
1456
+ const invalidate = (file) => {
1457
+ if (file.startsWith(path.resolve(root, sourceDirectory))) cached = null;
1458
+ };
1459
+ server.watcher?.on?.('add', invalidate);
1460
+ server.watcher?.on?.('change', invalidate);
1461
+ server.watcher?.on?.('unlink', invalidate);
1462
+ server.middlewares.use(endpoint, async (request, response, next) => {
1463
+ if (request.method !== 'GET') {
1464
+ next();
1465
+ return;
1466
+ }
1467
+ try {
1468
+ cached ??= scanStoryboardEnvironment(root, { sourceDirectory });
1469
+ const manifest = await cached;
1470
+ sendJson(response, 200, manifest);
1471
+ } catch (error) {
1472
+ cached = null;
1473
+ sendJson(response, 500, {
1474
+ error: error instanceof Error ? error.message : String(error),
1475
+ });
1476
+ }
1477
+ });
1478
+ server.middlewares.use(
1479
+ canonicalEndpoint,
1480
+ async (request, response, next) => {
1481
+ if (request.method !== 'POST') {
1482
+ next();
1483
+ return;
1484
+ }
1485
+ try {
1486
+ const executions = await readExecutionResults(request);
1487
+ cached ??= scanStoryboardEnvironment(root, { sourceDirectory });
1488
+ const manifest = await cached;
1489
+ sendJson(
1490
+ response,
1491
+ 200,
1492
+ canonicalizeStoryboardExecutions(manifest.candidates, executions),
1493
+ );
1494
+ } catch (error) {
1495
+ sendJson(
1496
+ response,
1497
+ error instanceof RangeError ? 413 : 400,
1498
+ {
1499
+ error: error instanceof Error ? error.message : String(error),
1500
+ },
1501
+ );
1502
+ }
1503
+ }
1504
+ );
1505
+ },
1506
+ };
1507
+ }