@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.
- package/dist-lib/FrozenRoutePreview-efexOSqR.js +9416 -0
- package/dist-lib/pygmalion.js +9498 -6873
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/node/component-branches.mjs +108 -0
- package/node/dev-view.vite.mjs +45 -1
- package/node/preview-artifact-plugin.mjs +20 -1
- package/node/preview-artifact-store.mjs +483 -29
- package/node/route-dependency-digest.mjs +151 -0
- package/node/route-preview-artifact-v3.mjs +10 -5
- package/node/source-graph.mjs +258 -0
- package/node/storyboard-capture-runtime.mjs +25 -2
- package/node/storyboard-environment.mjs +97 -10
- package/node/vite.mjs +11 -0
- package/package.json +2 -1
- package/types.d.ts +495 -0
- package/vite.d.ts +31 -0
- package/dist-lib/FrozenRoutePreview-B6xc8Ami.js +0 -8059
|
@@ -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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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;
|
package/node/source-graph.mjs
CHANGED
|
@@ -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;
|