|
| 1 | +// Helpers for parcel-reporter-turbosnap-stats. See ./StatsReporter.ts for the |
| 2 | +// plugin entry; this file holds the pure functions exported for unit testing. |
| 3 | + |
| 4 | +import type {Asset, BundleGraph, FileSystem} from '@parcel/types'; |
| 5 | +import path from 'path'; |
| 6 | + |
| 7 | +// TurboSnap may still report 0% reuse for reasons outside this reporter's control: |
| 8 | +// 1. Lockfile-only diff with no node_modules in stats — we DO include node_modules, |
| 9 | +// but filter @parcel/runtime-* and react/jsx-runtime (mirrors builder-vite). If |
| 10 | +// a react upgrade fails to propagate, this filter is the suspect. |
| 11 | +// 2. Changes under .storybook/ or .chromatic/ — chromatic-cli treats these as |
| 12 | +// Storybook-config changes and bails to full snapshot. By design. |
| 13 | +// 3. Changes under any configured staticDir — same bail. |
| 14 | +// See chromatic-cli node-src/lib/turbosnap/getDependentStoryFiles.ts lines 250-269. |
| 15 | + |
| 16 | +export interface Reason { |
| 17 | + moduleName: string; |
| 18 | +} |
| 19 | +export interface Module { |
| 20 | + id: string; |
| 21 | + name: string; |
| 22 | + reasons: Reason[]; |
| 23 | +} |
| 24 | + |
| 25 | +export function stripQueryParams(id: string): string { |
| 26 | + const idx = id.indexOf('?'); |
| 27 | + return idx === -1 ? id : id.slice(0, idx); |
| 28 | +} |
| 29 | + |
| 30 | +export function normalize(filePath: string, projectRoot: string): string { |
| 31 | + const stripped = stripQueryParams(filePath); |
| 32 | + // Convert backslashes to forward slashes regardless of platform — |
| 33 | + // path.sep is '/' on Mac/Linux so .split(path.sep) wouldn't catch literal |
| 34 | + // backslashes inside an input string. Universal replace avoids the gap. |
| 35 | + const rel = path.relative(projectRoot, stripped).replace(/\\/g, '/'); |
| 36 | + return './' + rel; |
| 37 | +} |
| 38 | + |
| 39 | +// Filter Parcel runtime chunks (path may be bare "@parcel/runtime-*" or |
| 40 | +// normalized "./node_modules/@parcel/runtime-*"). Also filter the React JSX |
| 41 | +// runtime — mirrors builder-vite's filter; means React-version bumps won't |
| 42 | +// propagate via stats, but avoids every JSX file having identical noisy reasons. |
| 43 | +const FILTER_PATTERNS: RegExp[] = [/@parcel\/runtime-/, /\/react\/jsx-runtime\.js$/]; |
| 44 | + |
| 45 | +export function isUserCode(name: string): boolean { |
| 46 | + for (const re of FILTER_PATTERNS) { |
| 47 | + if (re.test(name)) return false; |
| 48 | + } |
| 49 | + return true; |
| 50 | +} |
| 51 | + |
| 52 | +const STORY_VIRTUAL_RE = /\/storybook-builder-parcel\/generated-entries\/stories\.js$/; |
| 53 | +const CANONICAL_CSF_GLOB = './storybook-stories.js'; |
| 54 | + |
| 55 | +export function rewriteStoryVirtuals(statsMap: Map<string, Module>): void { |
| 56 | + for (const [oldName, entry] of [...statsMap]) { |
| 57 | + if (!STORY_VIRTUAL_RE.test(oldName)) continue; |
| 58 | + statsMap.delete(oldName); |
| 59 | + entry.id = CANONICAL_CSF_GLOB; |
| 60 | + entry.name = CANONICAL_CSF_GLOB; |
| 61 | + const existing = statsMap.get(CANONICAL_CSF_GLOB); |
| 62 | + if (existing) { |
| 63 | + for (const r of entry.reasons) { |
| 64 | + if (existing.reasons.every(x => x.moduleName !== r.moduleName)) { |
| 65 | + existing.reasons.push(r); |
| 66 | + } |
| 67 | + } |
| 68 | + } else { |
| 69 | + statsMap.set(CANONICAL_CSF_GLOB, entry); |
| 70 | + } |
| 71 | + } |
| 72 | + for (const entry of statsMap.values()) { |
| 73 | + for (const reason of entry.reasons) { |
| 74 | + if (STORY_VIRTUAL_RE.test(reason.moduleName)) { |
| 75 | + reason.moduleName = CANONICAL_CSF_GLOB; |
| 76 | + } |
| 77 | + } |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +export function buildStatsMap( |
| 82 | + bundleGraph: BundleGraph<any>, |
| 83 | + projectRoot: string |
| 84 | +): Map<string, Module> { |
| 85 | + const statsMap = new Map<string, Module>(); |
| 86 | + const ensure = (name: string): Module => { |
| 87 | + let entry = statsMap.get(name); |
| 88 | + if (!entry) { |
| 89 | + entry = {id: name, name, reasons: []}; |
| 90 | + statsMap.set(name, entry); |
| 91 | + } |
| 92 | + return entry; |
| 93 | + }; |
| 94 | + const seen = new Set<string>(); |
| 95 | + |
| 96 | + for (const bundle of bundleGraph.getBundles()) { |
| 97 | + bundle.traverseAssets((asset: Asset) => { |
| 98 | + if (seen.has(asset.id)) return; |
| 99 | + seen.add(asset.id); |
| 100 | + |
| 101 | + const assetName = normalize(asset.filePath, projectRoot); |
| 102 | + if (!isUserCode(assetName)) return; |
| 103 | + ensure(assetName); |
| 104 | + |
| 105 | + for (const dep of bundleGraph.getDependencies(asset)) { |
| 106 | + // resolveAsyncDependency unwraps Parcel's @parcel/runtime-js code-splitting |
| 107 | + // wrappers for `() => import('...')` deps so the edge points at the real |
| 108 | + // target asset (e.g. ./Foo.stories.tsx) instead of the runtime chunk. |
| 109 | + // Returns null for sync deps; fall back to getResolvedAsset there. |
| 110 | + const asyncResult = bundleGraph.resolveAsyncDependency(dep, bundle); |
| 111 | + let target: Asset | null | undefined; |
| 112 | + if (asyncResult) { |
| 113 | + target = |
| 114 | + asyncResult.type === 'asset' |
| 115 | + ? asyncResult.value |
| 116 | + : bundleGraph.getAssetById(asyncResult.value.entryAssetId); |
| 117 | + } else { |
| 118 | + target = bundleGraph.getResolvedAsset(dep, bundle); |
| 119 | + } |
| 120 | + if (!target) continue; |
| 121 | + const depName = normalize(target.filePath, projectRoot); |
| 122 | + if (!isUserCode(depName)) continue; |
| 123 | + // Skip self-edges. Parcel sometimes emits multiple Asset objects for the |
| 124 | + // same source file (e.g., a transformer's sibling output, HMR runtime |
| 125 | + // injection), giving them distinct asset.id values but identical filePath. |
| 126 | + // Without this guard those collapse into "TagGroup.tsx is a reason for |
| 127 | + // TagGroup.tsx" entries — harmless (chromatic-cli filters them at |
| 128 | + // getDependentStoryFiles.ts:169) but noisy in the emitted JSON. |
| 129 | + if (depName === assetName) continue; |
| 130 | + const entry = ensure(depName); |
| 131 | + if (entry.reasons.every(r => r.moduleName !== assetName)) { |
| 132 | + entry.reasons.push({moduleName: assetName}); |
| 133 | + } |
| 134 | + } |
| 135 | + }); |
| 136 | + } |
| 137 | + return statsMap; |
| 138 | +} |
| 139 | + |
| 140 | +const CSF_GLOB_ENTRY = './parcel-csf-glob.js'; |
| 141 | + |
| 142 | +// chromatic-cli's getDependentStoryFiles expects this three-level chain: |
| 143 | +// |
| 144 | +// ./storybook-stories.js ← (CSF entry, imported by preview-main.js) |
| 145 | +// ↓ imports |
| 146 | +// ./parcel-csf-glob.js ← reasons=[storybook-stories.js] → identified as the CSF glob |
| 147 | +// ↓ imports |
| 148 | +// ./Foo.stories.tsx ← reasons=[parcel-csf-glob.js] → added to affectedModuleIds |
| 149 | +// |
| 150 | +// We discover story files structurally: after buildStatsMap (with resolveAsyncDependency) |
| 151 | +// and rewriteStoryVirtuals, every story file has './storybook-stories.js' as a reason. |
| 152 | +// We rewrite that reason to point at the synthetic ./parcel-csf-glob.js instead. |
| 153 | +// |
| 154 | +// Pointing story files directly at './storybook-stories.js' would make THEM the |
| 155 | +// CSF globs (per getDependentStoryFiles.ts:175-181), causing traceName to bail |
| 156 | +// at the story file (line 286) and source files (not story files) to end up |
| 157 | +// in affectedModuleIds — which chromatic then can't match to storyIndex entries. |
| 158 | +export function addStoryEntries(statsMap: Map<string, Module>, logger?: Logger): number { |
| 159 | + let tagged = 0; |
| 160 | + for (const entry of statsMap.values()) { |
| 161 | + if (entry.name === CSF_GLOB_ENTRY) continue; |
| 162 | + let rewritten = false; |
| 163 | + for (const reason of entry.reasons) { |
| 164 | + if (reason.moduleName === CANONICAL_CSF_GLOB) { |
| 165 | + reason.moduleName = CSF_GLOB_ENTRY; |
| 166 | + rewritten = true; |
| 167 | + } |
| 168 | + } |
| 169 | + if (rewritten) tagged++; |
| 170 | + } |
| 171 | + if (tagged > 0 && !statsMap.has(CSF_GLOB_ENTRY)) { |
| 172 | + statsMap.set(CSF_GLOB_ENTRY, { |
| 173 | + id: CSF_GLOB_ENTRY, |
| 174 | + name: CSF_GLOB_ENTRY, |
| 175 | + reasons: [{moduleName: CANONICAL_CSF_GLOB}] |
| 176 | + }); |
| 177 | + } |
| 178 | + logger?.info({ |
| 179 | + message: `parcel-reporter-turbosnap-stats: tagged ${tagged} story file(s) via synthetic CSF glob` |
| 180 | + }); |
| 181 | + return tagged; |
| 182 | +} |
| 183 | + |
| 184 | +interface Logger { |
| 185 | + info: (m: {message: string}) => void; |
| 186 | +} |
| 187 | + |
| 188 | +export async function writeStats( |
| 189 | + distDir: string, |
| 190 | + statsMap: Map<string, Module>, |
| 191 | + outputFS: FileSystem, |
| 192 | + logger: Logger |
| 193 | +): Promise<void> { |
| 194 | + // Sort modules by name so the emitted JSON is byte-stable across Parcel |
| 195 | + // versions even if bundle.traverseAssets order shifts. chromatic-cli doesn't |
| 196 | + // care about order; this only helps reproducibility for caching/diff use cases. |
| 197 | + const modules = [...statsMap.values()].sort((a, b) => a.name.localeCompare(b.name)); |
| 198 | + const stats = {modules}; |
| 199 | + |
| 200 | + if (stats.modules.length === 0) { |
| 201 | + throw new Error( |
| 202 | + 'parcel-reporter-turbosnap-stats: empty modules array — nothing was traversed.' |
| 203 | + ); |
| 204 | + } |
| 205 | + const hasCsfGlob = stats.modules.some(m => |
| 206 | + m.reasons.some(r => r.moduleName === CANONICAL_CSF_GLOB) |
| 207 | + ); |
| 208 | + if (!hasCsfGlob) { |
| 209 | + throw new Error( |
| 210 | + 'parcel-reporter-turbosnap-stats: no module references ./storybook-stories.js as a reason. ' + |
| 211 | + 'chromatic-cli will hard-error with "Did not find any CSF globs in preview-stats.json". ' + |
| 212 | + 'Check that parcel-resolver-storybook generated a stories.js virtual and STORY_VIRTUAL_RE matches its filePath.' |
| 213 | + ); |
| 214 | + } |
| 215 | + |
| 216 | + await outputFS.writeFile(path.join(distDir, 'preview-stats.json'), JSON.stringify(stats), null); |
| 217 | + logger.info({ |
| 218 | + message: `parcel-reporter-turbosnap-stats: wrote preview-stats.json (${stats.modules.length} modules) to ${distDir}` |
| 219 | + }); |
| 220 | +} |
0 commit comments