@pygmalionjs/pygmalion 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,7 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
1
5
  function normalizedRoutePath(value) {
2
6
  if (typeof value !== 'string' || !value.trim()) return null;
3
7
  try {
@@ -60,3 +64,150 @@ export function resolvePreviewRouteDependencyDigest(route, entries) {
60
64
  );
61
65
  return candidates[0]?.digest;
62
66
  }
67
+
68
+ // Observed dependencies — the source files a captured frame actually rendered.
69
+ //
70
+ // Freshness used to be decided by the source revision, so every commit retired
71
+ // every frame even when a frame's own content could not have changed. A route
72
+ // digest is not a usable replacement in a single-page application: nearly every
73
+ // screen lives on one or two routes, so a route's dependency closure is the
74
+ // whole application and any edit invalidates everything.
75
+ //
76
+ // A frozen snapshot stamps the file each element came from, so the set of files
77
+ // a screen really rendered is recoverable from the capture itself. That set is
78
+ // finer than a route and cannot drift from the truth, because it is an
79
+ // observation rather than a declaration.
80
+
81
+ /** Attributes the DOM importer stamps a source file onto. */
82
+ const SOURCE_ATTRIBUTES = ['data-pygmalion-source', 'data-pygmalion-own-source'];
83
+
84
+ /**
85
+ * Minimum stamped elements before an observed set is trusted.
86
+ *
87
+ * A frame whose stamps are missing (a host that never wired sourceComponents,
88
+ * or a capture that failed early) would otherwise read as "depends on nothing"
89
+ * and never be invalidated again. Too few stamps therefore means unknown, and
90
+ * unknown must fall back to the coarse key rather than claim precision.
91
+ */
92
+ export const OBSERVED_DEPENDENCY_MIN_STAMPS = 8;
93
+
94
+ /** Files whose content decides a screen without ever being stamped. */
95
+ const DEFAULT_ALWAYS_INCLUDED = Object.freeze([]);
96
+
97
+ function normalizedSourcePath(value) {
98
+ if (typeof value !== 'string') return null;
99
+ // A stamp is "<file>|<hash>|<tag>"; only the file participates in identity.
100
+ const file = value.split('|', 1)[0].trim();
101
+ if (!file || file.startsWith('/') || file.includes('..')) return null;
102
+ return file.split(path.sep).join('/');
103
+ }
104
+
105
+ /**
106
+ * Source files stamped into a serialized snapshot.
107
+ *
108
+ * Parsing the stored HTML rather than collecting in the page keeps the answer
109
+ * true to what was actually stored: a serializer that prunes or rewrites nodes
110
+ * cannot make the recorded set disagree with the artifact it describes. It also
111
+ * means one function serves every producer, since all of them publish HTML.
112
+ */
113
+ export function extractObservedSourceFiles(snapshot) {
114
+ // A stored snapshot is `{ document, head, body }`, but callers also hold the
115
+ // raw body string; accept both so one function serves every producer.
116
+ const html =
117
+ typeof snapshot === 'string'
118
+ ? snapshot
119
+ : snapshot && typeof snapshot === 'object'
120
+ ? [snapshot.document, snapshot.head, snapshot.body]
121
+ .filter((part) => typeof part === 'string')
122
+ .join('\n')
123
+ : '';
124
+ if (!html) {
125
+ return { files: [], stamps: 0 };
126
+ }
127
+ const files = new Set();
128
+ let stamps = 0;
129
+ for (const attribute of SOURCE_ATTRIBUTES) {
130
+ const pattern = new RegExp(`${attribute}="([^"]*)"`, 'g');
131
+ for (const match of html.matchAll(pattern)) {
132
+ stamps += 1;
133
+ const file = normalizedSourcePath(match[1]);
134
+ if (file) files.add(file);
135
+ }
136
+ }
137
+ return { files: [...files].sort(), stamps };
138
+ }
139
+
140
+ async function fileContentHash(root, file) {
141
+ try {
142
+ const resolved = path.resolve(root, file);
143
+ if (!resolved.startsWith(path.resolve(root) + path.sep)) return null;
144
+ const source = await fs.readFile(resolved);
145
+ return createHash('sha256').update(source).digest('hex').slice(0, 32);
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Records the observed set as `[path, contentHash]` pairs.
153
+ *
154
+ * Pairs rather than one digest: freshness is then a comparison against the
155
+ * files as they are now, so recording the set never changes the key the entry
156
+ * was written under. Storing a digest in the key instead would retire the very
157
+ * entry that just produced it.
158
+ */
159
+ export async function recordObservedDependencies({
160
+ snapshot,
161
+ sourceRoot,
162
+ alwaysInclude = DEFAULT_ALWAYS_INCLUDED,
163
+ minStamps = OBSERVED_DEPENDENCY_MIN_STAMPS,
164
+ }) {
165
+ if (typeof sourceRoot !== 'string' || !sourceRoot) return null;
166
+ const { files, stamps } = extractObservedSourceFiles(snapshot);
167
+ if (stamps < minStamps || files.length === 0) return null;
168
+ const wanted = [
169
+ ...new Set([
170
+ ...files,
171
+ ...alwaysInclude
172
+ .map((file) => normalizedSourcePath(file))
173
+ .filter((file) => file != null),
174
+ ]),
175
+ ].sort();
176
+ const recorded = [];
177
+ for (const file of wanted) {
178
+ const hash = await fileContentHash(sourceRoot, file);
179
+ // A stamped file we cannot read leaves the set unverifiable, and an
180
+ // unverifiable set must not be treated as fresh later.
181
+ if (hash == null) return null;
182
+ recorded.push([file, hash]);
183
+ }
184
+ return recorded;
185
+ }
186
+
187
+ /** True when every recorded file still hashes to what it did at capture time. */
188
+ export async function observedDependenciesUnchanged({ recorded, sourceRoot }) {
189
+ if (!Array.isArray(recorded) || recorded.length === 0) return false;
190
+ if (typeof sourceRoot !== 'string' || !sourceRoot) return false;
191
+ for (const pair of recorded) {
192
+ if (!Array.isArray(pair) || pair.length !== 2) return false;
193
+ const [file, hash] = pair;
194
+ if (typeof file !== 'string' || typeof hash !== 'string') return false;
195
+ const current = await fileContentHash(sourceRoot, file);
196
+ if (current !== hash) return false;
197
+ }
198
+ return true;
199
+ }
200
+
201
+ /** Normalizes a persisted set, dropping anything malformed. */
202
+ export function normalizeObservedDependencies(value) {
203
+ if (!Array.isArray(value) || value.length === 0) return null;
204
+ const pairs = [];
205
+ for (const pair of value) {
206
+ if (!Array.isArray(pair) || pair.length !== 2) return null;
207
+ const file = normalizedSourcePath(pair[0]);
208
+ const hash = typeof pair[1] === 'string' ? pair[1].trim() : '';
209
+ if (!file || !hash) return null;
210
+ pairs.push([file, hash]);
211
+ }
212
+ return pairs.sort((left, right) => left[0].localeCompare(right[0]));
213
+ }
@@ -652,11 +652,16 @@ export function selectRoutePreviewArtifactFrames(bundle, wanted, options = {}) {
652
652
  }
653
653
  const fingerprint = isPlainRecord(request) ? request.fingerprint : undefined;
654
654
  const frameSourceRevision = frame.sourceRevision ?? bundle.sourceRevision;
655
- if (
656
- (options.sourceRevision != null &&
657
- frameSourceRevision !== options.sourceRevision) ||
658
- (fingerprint != null && frame.fingerprint !== fingerprint)
659
- ) {
655
+ // A fingerprint is content identity, so it decides on its own and the
656
+ // revision stays provenance. Requiring both retired every frame on every
657
+ // commit, even when the fingerprint proved the frame could not differ.
658
+ // Without a fingerprint the revision is the only identity available.
659
+ const staleForRequest =
660
+ fingerprint != null
661
+ ? frame.fingerprint !== fingerprint
662
+ : options.sourceRevision != null &&
663
+ frameSourceRevision !== options.sourceRevision;
664
+ if (staleForRequest) {
660
665
  stale.push(id);
661
666
  if (options.includeStale === true) frames[id] = frame;
662
667
  continue;
@@ -106,6 +106,95 @@ function moduleSpecifiers(source, componentFile) {
106
106
  return [...result].sort();
107
107
  }
108
108
 
109
+ /**
110
+ * Symbol-level import and re-export facts for one file.
111
+ *
112
+ * The dependency graph answers "which files reference this module", which a
113
+ * barrel turns into "the whole app": every atom is imported by
114
+ * `components/ui/index.ts`, and everything imports that. Usage has to be
115
+ * asked about the SYMBOL to mean anything, so this records what each import
116
+ * actually named and what each barrel passes through.
117
+ */
118
+ function moduleSymbols(source, componentFile) {
119
+ const sourceFile = ts.createSourceFile(
120
+ componentFile,
121
+ source,
122
+ ts.ScriptTarget.Latest,
123
+ true,
124
+ scriptKind(componentFile),
125
+ );
126
+ const imports = [];
127
+ const reExports = [];
128
+ const exports = new Set();
129
+
130
+ const visit = (node) => {
131
+ if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
132
+ const clause = node.importClause;
133
+ const names = new Set();
134
+ let namespace = false;
135
+ if (clause?.name) names.add('default');
136
+ const bindings = clause?.namedBindings;
137
+ if (bindings && ts.isNamespaceImport(bindings)) namespace = true;
138
+ if (bindings && ts.isNamedImports(bindings)) {
139
+ for (const element of bindings.elements) {
140
+ if (element.isTypeOnly) continue;
141
+ names.add((element.propertyName ?? element.name).text);
142
+ }
143
+ }
144
+ // A type-only import renders nothing, so it is not usage.
145
+ if (!clause?.isTypeOnly) {
146
+ imports.push({
147
+ specifier: node.moduleSpecifier.text,
148
+ names: [...names],
149
+ namespace,
150
+ });
151
+ }
152
+ } else if (ts.isExportDeclaration(node)) {
153
+ if (node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
154
+ if (node.isTypeOnly) return;
155
+ const clause = node.exportClause;
156
+ if (!clause) {
157
+ reExports.push({ specifier: node.moduleSpecifier.text, star: true, names: [] });
158
+ } else if (ts.isNamespaceExport(clause)) {
159
+ reExports.push({ specifier: node.moduleSpecifier.text, star: true, names: [] });
160
+ exports.add(clause.name.text);
161
+ } else {
162
+ const names = [];
163
+ for (const element of clause.elements) {
164
+ if (element.isTypeOnly) continue;
165
+ // The name importers of this barrel will write.
166
+ names.push(element.name.text);
167
+ exports.add(element.name.text);
168
+ }
169
+ reExports.push({ specifier: node.moduleSpecifier.text, star: false, names });
170
+ }
171
+ } else if (node.exportClause && ts.isNamedExports(node.exportClause)) {
172
+ for (const element of node.exportClause.elements) exports.add(element.name.text);
173
+ }
174
+ } else if (
175
+ (ts.isFunctionDeclaration(node) ||
176
+ ts.isClassDeclaration(node) ||
177
+ ts.isInterfaceDeclaration(node) ||
178
+ ts.isTypeAliasDeclaration(node) ||
179
+ ts.isEnumDeclaration(node)) &&
180
+ node.name &&
181
+ node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
182
+ ) {
183
+ exports.add(node.name.text);
184
+ } else if (
185
+ ts.isVariableStatement(node) &&
186
+ node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
187
+ ) {
188
+ for (const declaration of node.declarationList.declarations) {
189
+ if (ts.isIdentifier(declaration.name)) exports.add(declaration.name.text);
190
+ }
191
+ }
192
+ ts.forEachChild(node, visit);
193
+ };
194
+ visit(sourceFile);
195
+ return { imports, reExports, exports: [...exports].sort() };
196
+ }
197
+
109
198
  function resolveModule(importerFile, specifier, sourceDirectory, files) {
110
199
  if (
111
200
  typeof specifier !== 'string' ||
@@ -189,6 +278,175 @@ export async function buildSourceGraph(root, { sourceDirectory = 'src' } = {}) {
189
278
  };
190
279
  }
191
280
 
281
+ const MAX_BARREL_DEPTH = 6;
282
+ const MAX_USAGE_FILES = 2_000;
283
+
284
+ /**
285
+ * Scans the project once for symbol-level usage.
286
+ *
287
+ * Shares the shape of buildSourceGraph so the same result can answer both
288
+ * "what breaks if this changes" (transitive, over files) and "who uses this"
289
+ * (direct, over symbols).
290
+ */
291
+ export async function buildSourceUsage(root, { sourceDirectory = 'src' } = {}) {
292
+ const location = await sourceLocation(root, sourceDirectory);
293
+ const files = await scanSourceFiles(location.rootPath, location.sourcePath);
294
+ const fileSet = new Set(files);
295
+ const symbols = {};
296
+ const importersByModule = new Map();
297
+ const reExportersByModule = new Map();
298
+ const reverseSets = new Map(files.map((file) => [file, new Set()]));
299
+
300
+ const parsed = [];
301
+ for (let offset = 0; offset < files.length; offset += READ_BATCH_SIZE) {
302
+ parsed.push(
303
+ ...(await Promise.all(
304
+ files.slice(offset, offset + READ_BATCH_SIZE).map(async (componentFile) => {
305
+ const source = await fs.readFile(path.resolve(location.rootPath, componentFile), 'utf8');
306
+ return [componentFile, moduleSymbols(source, componentFile)];
307
+ }),
308
+ )),
309
+ );
310
+ }
311
+
312
+ const index = (map, key, value) => {
313
+ const bucket = map.get(key);
314
+ if (bucket) bucket.push(value);
315
+ else map.set(key, [value]);
316
+ };
317
+
318
+ for (const [componentFile, facts] of parsed) {
319
+ const imports = [];
320
+ const reExports = [];
321
+ for (const entry of facts.imports) {
322
+ const module = resolveModule(
323
+ componentFile,
324
+ entry.specifier,
325
+ location.sourceDirectory,
326
+ fileSet,
327
+ );
328
+ if (!module || module === componentFile) continue;
329
+ const record = { file: componentFile, module, names: entry.names, namespace: entry.namespace };
330
+ imports.push(record);
331
+ index(importersByModule, module, record);
332
+ reverseSets.get(module).add(componentFile);
333
+ }
334
+ for (const entry of facts.reExports) {
335
+ const module = resolveModule(
336
+ componentFile,
337
+ entry.specifier,
338
+ location.sourceDirectory,
339
+ fileSet,
340
+ );
341
+ if (!module || module === componentFile) continue;
342
+ const record = { file: componentFile, module, star: entry.star, names: entry.names };
343
+ reExports.push(record);
344
+ index(reExportersByModule, module, record);
345
+ reverseSets.get(module).add(componentFile);
346
+ }
347
+ symbols[componentFile] = { imports, reExports, exports: facts.exports };
348
+ }
349
+
350
+ const reverseDependencies = {};
351
+ for (const componentFile of files) {
352
+ reverseDependencies[componentFile] = [...reverseSets.get(componentFile)].sort();
353
+ }
354
+
355
+ return {
356
+ sourceDirectory: location.sourceDirectory,
357
+ files,
358
+ symbols,
359
+ reverseDependencies,
360
+ importersByModule,
361
+ reExportersByModule,
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Files that use a module's symbols, with barrels made transparent.
367
+ *
368
+ * A file that only re-exports is not a user — it is a pass-through, so the
369
+ * search continues to whoever imported the name from it. An importer counts
370
+ * when it names one of the symbols that reached that barrel from the target,
371
+ * or takes the namespace.
372
+ */
373
+ export function directSourceUsers(usage, targetFile, { maxDepth = MAX_BARREL_DEPTH } = {}) {
374
+ if (!usage?.symbols || !usage.symbols[targetFile]) {
375
+ return { users: [], barrels: [] };
376
+ }
377
+ const users = new Set();
378
+ const barrels = new Set();
379
+ const seen = new Set();
380
+ // 'star' means every symbol of the target is in play, which is what the
381
+ // target itself exposes to a direct importer.
382
+ const queue = [{ module: targetFile, names: 'star', depth: 0 }];
383
+
384
+ while (queue.length > 0) {
385
+ const { module, names, depth } = queue.shift();
386
+ const key = `${module}::${names === 'star' ? '*' : [...names].sort().join(',')}`;
387
+ if (seen.has(key)) continue;
388
+ seen.add(key);
389
+
390
+ for (const record of usage.importersByModule.get(module) ?? []) {
391
+ if (users.size >= MAX_USAGE_FILES) break;
392
+ const matches =
393
+ record.namespace ||
394
+ names === 'star' ||
395
+ record.names.some((name) => names.has(name));
396
+ if (matches) users.add(record.file);
397
+ }
398
+
399
+ if (depth >= maxDepth) continue;
400
+ for (const record of usage.reExportersByModule.get(module) ?? []) {
401
+ let exposed;
402
+ if (record.star) {
403
+ // `export *` forwards the target's own names — not everything the
404
+ // barrel has. Treating it as "everything" made a file that imported
405
+ // one atom look like a user of every atom beside it.
406
+ const moduleExports = usage.symbols[module]?.exports ?? [];
407
+ if (moduleExports.length === 0) {
408
+ // Nothing parsed to narrow by (a nested star chain); keep reach
409
+ // rather than silently dropping users.
410
+ exposed = names;
411
+ } else if (names === 'star') {
412
+ exposed = new Set(moduleExports);
413
+ } else {
414
+ exposed = new Set([...names].filter((name) => moduleExports.includes(name)));
415
+ }
416
+ } else {
417
+ exposed =
418
+ names === 'star'
419
+ ? new Set(record.names)
420
+ : new Set(record.names.filter((name) => names.has(name)));
421
+ }
422
+ if (exposed !== 'star' && exposed.size === 0) continue;
423
+ barrels.add(record.file);
424
+ queue.push({ module: record.file, names: exposed, depth: depth + 1 });
425
+ }
426
+ }
427
+
428
+ // A pure pass-through is not a user of what it forwards.
429
+ for (const barrel of barrels) users.delete(barrel);
430
+ return { users: [...users].sort(), barrels: [...barrels].sort() };
431
+ }
432
+
433
+ /** Direct users plus the transitive file count, per requested file. */
434
+ export async function findSourceUsage(root, componentFiles, { sourceDirectory = 'src' } = {}) {
435
+ const usage = await buildSourceUsage(root, { sourceDirectory });
436
+ const requested = validateComponentFiles(componentFiles, usage.sourceDirectory);
437
+ const files = new Set(usage.files);
438
+ const result = {};
439
+ for (const componentFile of requested) {
440
+ if (!files.has(componentFile)) continue;
441
+ const { users, barrels } = directSourceUsers(usage, componentFile);
442
+ const transitive = collectAffectedSourceFiles(usage, [componentFile]).filter(
443
+ (file) => file !== componentFile,
444
+ );
445
+ result[componentFile] = { direct: users, barrels, transitive: transitive.length };
446
+ }
447
+ return result;
448
+ }
449
+
192
450
  function validateComponentFilePath(value, sourceDirectory) {
193
451
  if (
194
452
  typeof value !== 'string' ||
@@ -38,6 +38,15 @@ export const STORYBOARD_CAPTURE_STATUSES = Object.freeze({
38
38
  captureError: 'capture-error',
39
39
  });
40
40
 
41
+ /**
42
+ * Device pixels captured per CSS pixel. Screenshots are the zoomable canvas
43
+ * surface, so they are taken above 1:1 — a 1x bitmap reads as a blurry
44
+ * screenshot on high-density displays the moment the camera zooms in.
45
+ * Mirrored in the capture recipe identity (`previewBootstrap.ts`) so changing
46
+ * it retires every previously captured artifact.
47
+ */
48
+ export const STORYBOARD_CAPTURE_SCREENSHOT_SCALE = 2;
49
+
41
50
  export class StoryboardCaptureStageError extends Error {
42
51
  constructor(stage, error, details = {}) {
43
52
  const message = error instanceof Error ? error.message : String(error);
@@ -1120,6 +1129,14 @@ function captureDiagnostic(error, viewport) {
1120
1129
  };
1121
1130
  }
1122
1131
 
1132
+ /** The context option wins so a caller's override keeps honest metadata. */
1133
+ function resolveScreenshotScale(contextOptions = {}) {
1134
+ const scale = contextOptions.deviceScaleFactor;
1135
+ return Number.isFinite(scale) && scale > 0
1136
+ ? scale
1137
+ : STORYBOARD_CAPTURE_SCREENSHOT_SCALE;
1138
+ }
1139
+
1123
1140
  async function collectStableEvidence(
1124
1141
  page,
1125
1142
  viewport,
@@ -1129,6 +1146,7 @@ async function collectStableEvidence(
1129
1146
  previewBaseToken,
1130
1147
  sourceComponents = null,
1131
1148
  screenshotOptions,
1149
+ screenshotScale = STORYBOARD_CAPTURE_SCREENSHOT_SCALE,
1132
1150
  stability = {},
1133
1151
  },
1134
1152
  ) {
@@ -1172,15 +1190,18 @@ async function collectStableEvidence(
1172
1190
  }
1173
1191
  }
1174
1192
  try {
1193
+ // Screenshot metadata records the bitmap's pixel dimensions; the frame's
1194
+ // CSS-pixel geometry stays on the capture record's `viewport`.
1175
1195
  evidence.screenshot = {
1176
1196
  mediaType: 'image/png',
1177
- width: viewport.width,
1178
- height: viewport.height,
1197
+ width: viewport.width * screenshotScale,
1198
+ height: viewport.height * screenshotScale,
1179
1199
  bytes: await page.screenshot({
1180
1200
  type: 'png',
1181
1201
  fullPage: false,
1182
1202
  animations: 'disabled',
1183
1203
  caret: 'hide',
1204
+ scale: 'device',
1184
1205
  ...screenshotOptions,
1185
1206
  }),
1186
1207
  };
@@ -1291,6 +1312,7 @@ export async function captureStoryboardCase({
1291
1312
  context = await atCaptureStage('setup', () =>
1292
1313
  browser.newContext({
1293
1314
  viewport,
1315
+ deviceScaleFactor: resolveScreenshotScale(contextOptions),
1294
1316
  colorScheme: 'light',
1295
1317
  reducedMotion: 'reduce',
1296
1318
  locale: 'en-US',
@@ -1396,6 +1418,7 @@ export async function captureStoryboardCase({
1396
1418
  previewBaseToken,
1397
1419
  sourceComponents,
1398
1420
  screenshotOptions,
1421
+ screenshotScale: resolveScreenshotScale(contextOptions),
1399
1422
  stability,
1400
1423
  });
1401
1424
  evidence = collected.evidence;