@percy/cli-command 1.32.6 → 1.32.7-beta.2

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,109 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import url from 'url';
4
+ const TEMPLATE_PATH = path.resolve(url.fileURLToPath(import.meta.url), '../graphTraceTemplate.html');
5
+ function templateKindOf(v) {
6
+ if (v.changed) return 'is_relevant';
7
+ switch (v.kind) {
8
+ case 'dependency':
9
+ return 'package';
10
+ case 'component':
11
+ return 'component';
12
+ case 'story':
13
+ return 'story';
14
+ default:
15
+ return 'component';
16
+ }
17
+ }
18
+ const KIND_RANK = {
19
+ package: 0,
20
+ component: 1,
21
+ is_relevant: 1,
22
+ story: 2
23
+ };
24
+ function computeLayout(rawVertices, edges, transitiveClosure) {
25
+ const n = rawVertices.length;
26
+ const vertices = rawVertices.map((v, i) => ({
27
+ index: i,
28
+ name: v.file_path,
29
+ kind: v.kind,
30
+ changed: !!v.changed,
31
+ row: 0,
32
+ col: 0
33
+ }));
34
+ const incomingMax = new Array(n).fill(0);
35
+ for (const triple of transitiveClosure) {
36
+ const [u, v, val] = triple;
37
+ if (u === v || val <= 0) continue;
38
+ if (v < 0 || v >= n) continue;
39
+ if (val > incomingMax[v]) incomingMax[v] = val;
40
+ }
41
+ for (let i = 0; i < n; i++) {
42
+ vertices[i].col = vertices[i].kind === 'dependency' ? 0 : incomingMax[i] + 1;
43
+ }
44
+ const iterations = n + 2;
45
+ for (let iter = 0; iter < iterations; iter++) {
46
+ let changed = false;
47
+ for (const [s, t] of edges) {
48
+ if (s < 0 || s >= n || t < 0 || t >= n) continue;
49
+ if (vertices[s].col < vertices[t].col) continue;
50
+ vertices[t].col = vertices[s].col + 1;
51
+ changed = true;
52
+ }
53
+ if (!changed) break;
54
+ }
55
+ let furthestNonStory = 0;
56
+ for (const v of vertices) {
57
+ if (v.kind === 'story') continue;
58
+ if (v.col > furthestNonStory) furthestNonStory = v.col;
59
+ }
60
+ for (const v of vertices) {
61
+ if (v.kind !== 'story') continue;
62
+ if (v.col < furthestNonStory + 1) v.col = furthestNonStory + 1;
63
+ }
64
+ const groups = new Map();
65
+ for (const v of vertices) {
66
+ let list = groups.get(v.col);
67
+ if (!list) groups.set(v.col, list = []);
68
+ list.push(v);
69
+ }
70
+ const rankOf = v => {
71
+ const r = KIND_RANK[templateKindOf(v)];
72
+ /* istanbul ignore next */
73
+ return r === undefined ? 99 : r;
74
+ };
75
+ for (const list of groups.values()) {
76
+ list.sort((a, b) => {
77
+ const ra = rankOf(a);
78
+ const rb = rankOf(b);
79
+ if (ra !== rb) return ra - rb;
80
+ if (a.name < b.name) return -1;
81
+ if (a.name > b.name) return 1;
82
+ return 0;
83
+ });
84
+ list.forEach((v, row) => {
85
+ v.row = row;
86
+ });
87
+ }
88
+ return vertices.map(v => ({
89
+ index: v.index,
90
+ name: v.name,
91
+ row: v.row,
92
+ col: v.col,
93
+ kind: templateKindOf(v)
94
+ }));
95
+ }
96
+ const LS = String.fromCharCode(0x2028);
97
+ const PS = String.fromCharCode(0x2029);
98
+ function safeJson(obj) {
99
+ return JSON.stringify(obj).replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--').replace(/--(!?)>/g, '--$1\\>').split(LS).join('\\u2028').split(PS).join('\\u2029');
100
+ }
101
+ export function renderGraphTraceHtml({
102
+ vertices,
103
+ edges,
104
+ transitiveClosureMatrixSparse
105
+ }) {
106
+ const laidOutVertices = computeLayout(vertices || [], edges || [], transitiveClosureMatrixSparse || []);
107
+ const template = fs.readFileSync(TEMPLATE_PATH, 'utf8');
108
+ return template.replace('__VERTICES_JSON__', safeJson(laidOutVertices)).replace('__EDGES_JSON__', safeJson(edges || [])).replace('__TRANSITIVE_CLOSURE_JSON__', safeJson(transitiveClosureMatrixSparse || []));
109
+ }
@@ -0,0 +1,304 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>IntelliStory Graph</title>
6
+ <link
7
+ rel="stylesheet"
8
+ href="https://cdn.jsdelivr.net/npm/drawflow@0.0.60/dist/drawflow.min.css"
9
+ integrity="sha384-IFh+Q6zh+LRcTjqVmAKetdGY59dT485vtvWT5DAKQy8iv5+fYWHXisHP7mFKcFqV"
10
+ crossorigin="anonymous">
11
+ <script
12
+ src="https://cdn.jsdelivr.net/npm/drawflow@0.0.60/dist/drawflow.min.js"
13
+ integrity="sha384-JsnM4f1X8b5e9VbQCj6Bt6840HQNDYVKohzbXJnqOetMno8i6cmKNLVkyK3snqSw"
14
+ crossorigin="anonymous"></script>
15
+ <style>
16
+ html, body {
17
+ margin: 0; padding: 0;
18
+ font-family: sans-serif; background: #fafafa;
19
+ width: 100%; height: 100%; overflow: hidden;
20
+ }
21
+ #drawflow {
22
+ position: fixed;
23
+ top: 0; left: 0; right: 0; bottom: 0;
24
+ background: #fafafa;
25
+ }
26
+ #legend {
27
+ position: fixed; top: 8px; right: 8px;
28
+ background: rgba(255,255,255,0.95);
29
+ padding: 8px 12px; border: 1px solid #999;
30
+ font-size: 12px; z-index: 100;
31
+ }
32
+ #legend span {
33
+ display: inline-block; width: 12px; height: 12px;
34
+ vertical-align: middle; margin-right: 4px; border: 1px solid #333;
35
+ }
36
+ #controls {
37
+ position: fixed; top: 8px; left: 8px;
38
+ background: rgba(255,255,255,0.95);
39
+ border: 1px solid #999; z-index: 100;
40
+ display: flex; gap: 2px;
41
+ }
42
+ #controls button {
43
+ border: 0; background: transparent;
44
+ padding: 6px 10px; cursor: pointer;
45
+ font-size: 14px; font-family: inherit;
46
+ }
47
+ #controls button:hover { background: #eee; }
48
+
49
+ .drawflow .drawflow-node {
50
+ width: max-content;
51
+ min-width: 0; max-width: none;
52
+ height: auto; min-height: 0;
53
+ padding: 8px 14px;
54
+ white-space: nowrap;
55
+ font-size: 12px;
56
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
57
+ background: #fff;
58
+ border: 2px solid #333;
59
+ border-radius: 4px;
60
+ cursor: pointer;
61
+ transition: opacity 150ms;
62
+ }
63
+ .drawflow .drawflow-node .drawflow_content_node {
64
+ width: max-content;
65
+ white-space: nowrap;
66
+ }
67
+ .drawflow .drawflow-node.package { background: #d4e5f7; border-color: #2a6db5; }
68
+ .drawflow .drawflow-node.component { background: #daf0c1; border-color: #4a8e1f; }
69
+ .drawflow .drawflow-node.story { background: #fce4b6; border-color: #b07310; }
70
+ .drawflow .drawflow-node.is-relevant { background: #d9b3ff; border-color: #6f30b0; }
71
+
72
+ .drawflow .drawflow-node .input,
73
+ .drawflow .drawflow-node .output {
74
+ width: 10px !important;
75
+ height: 10px !important;
76
+ border-width: 1px !important;
77
+ }
78
+
79
+ .drawflow .connection,
80
+ .drawflow .connection *,
81
+ .drawflow svg.connection,
82
+ .drawflow svg.connection * { pointer-events: none !important; }
83
+
84
+ .drawflow .drawflow-node.is-dim { opacity: 0.25; }
85
+ .drawflow .connection.is-dim { visibility: hidden; }
86
+
87
+ .drawflow .drawflow-node.is-selected {
88
+ box-shadow: 0 0 0 3px #2a6db5, 0 2px 8px rgba(0,0,0,0.2);
89
+ }
90
+
91
+ .measure-node {
92
+ position: absolute;
93
+ top: -9999px; left: -9999px;
94
+ opacity: 0;
95
+ pointer-events: none;
96
+ white-space: nowrap;
97
+ padding: 8px 14px;
98
+ font-size: 12px;
99
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
100
+ border: 2px solid transparent;
101
+ }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <div id="controls">
106
+ <button id="zoom-in" title="Zoom in">+</button>
107
+ <button id="zoom-out" title="Zoom out">-</button>
108
+ <button id="zoom-reset" title="Reset">reset</button>
109
+ </div>
110
+ <div id="legend">
111
+ <div><span style="background:#d4e5f7"></span>Package Dependency</div>
112
+ <div><span style="background:#daf0c1"></span>Component</div>
113
+ <div><span style="background:#fce4b6"></span>Story</div>
114
+ <div><span style="background:#d9b3ff"></span>Changed file</div>
115
+ </div>
116
+ <div id="drawflow"></div>
117
+ <script type="module">
118
+ const vertices = __VERTICES_JSON__;
119
+ const edges = __EDGES_JSON__;
120
+ const transitive_closure_matrix_sparse = __TRANSITIVE_CLOSURE_JSON__;
121
+ const n = vertices.length;
122
+
123
+ function measureWidth(text) {
124
+ const el = document.createElement('div');
125
+ el.className = 'measure-node';
126
+ el.textContent = text;
127
+ document.body.appendChild(el);
128
+ const w = el.offsetWidth;
129
+ el.remove();
130
+ return w;
131
+ }
132
+
133
+ const PORT_PAD = 60;
134
+ const nodeWidth = vertices.map(v => measureWidth(v.name) + PORT_PAD);
135
+
136
+ const PAD_X = 60, PAD_Y = 60, COL_GAP = 140, ROW_HEIGHT = 70;
137
+
138
+ const colMaxWidth = {};
139
+ for (let i = 0; i < n; i++) {
140
+ const c = vertices[i].col;
141
+ if (!colMaxWidth[c] || nodeWidth[i] > colMaxWidth[c]) colMaxWidth[c] = nodeWidth[i];
142
+ }
143
+ const sortedCols = Object.keys(colMaxWidth).map(Number).sort((a, b) => a - b);
144
+ const colX = {};
145
+ let cursor = PAD_X;
146
+ for (const c of sortedCols) {
147
+ colX[c] = cursor;
148
+ cursor += colMaxWidth[c] + COL_GAP;
149
+ }
150
+
151
+ const container = document.getElementById('drawflow');
152
+ const editor = new Drawflow(container);
153
+ editor.reroute = false;
154
+ editor.start();
155
+
156
+ function escapeHtml(s) {
157
+ return String(s)
158
+ .replace(/&/g, '&amp;')
159
+ .replace(/</g, '&lt;')
160
+ .replace(/>/g, '&gt;')
161
+ .replace(/"/g, '&quot;')
162
+ .replace(/'/g, '&#39;');
163
+ }
164
+
165
+ function cssClassFor(kind) {
166
+ if (kind === 'is_relevant') return 'is-relevant';
167
+ return kind;
168
+ }
169
+
170
+ const nodeIds = new Array(n);
171
+ for (let i = 0; i < n; i++) {
172
+ const v = vertices[i];
173
+ const id = editor.addNode(
174
+ 'node',
175
+ 1, 1,
176
+ colX[v.col],
177
+ PAD_Y + v.row * ROW_HEIGHT,
178
+ cssClassFor(v.kind),
179
+ { index: v.index, kind: v.kind, name: v.name },
180
+ escapeHtml(v.name),
181
+ );
182
+ nodeIds[i] = id;
183
+ }
184
+
185
+ for (const e of edges) {
186
+ editor.addConnection(nodeIds[e[0]], nodeIds[e[1]], 'output_1', 'input_1');
187
+ }
188
+
189
+ editor.editor_mode = 'fixed';
190
+
191
+ const SVG_NS = 'http://www.w3.org/2000/svg';
192
+ function addArrowhead(svg) {
193
+ if (svg.querySelector('marker')) return;
194
+ let defs = svg.querySelector('defs');
195
+ if (!defs) {
196
+ defs = document.createElementNS(SVG_NS, 'defs');
197
+ svg.insertBefore(defs, svg.firstChild);
198
+ }
199
+ const marker = document.createElementNS(SVG_NS, 'marker');
200
+ marker.setAttribute('id', 'arrowhead');
201
+ marker.setAttribute('viewBox', '0 0 10 10');
202
+ marker.setAttribute('refX', '9');
203
+ marker.setAttribute('refY', '5');
204
+ marker.setAttribute('markerWidth', '5');
205
+ marker.setAttribute('markerHeight', '5');
206
+ marker.setAttribute('orient', 'auto');
207
+ const tip = document.createElementNS(SVG_NS, 'path');
208
+ tip.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
209
+ tip.setAttribute('fill', '#4ea9ff');
210
+ marker.appendChild(tip);
211
+ defs.appendChild(marker);
212
+ const path = svg.querySelector('path.main-path') || svg.querySelector('path');
213
+ if (path) path.setAttribute('marker-end', 'url(#arrowhead)');
214
+ }
215
+ container.querySelectorAll('svg.connection').forEach(addArrowhead);
216
+
217
+ new MutationObserver((muts) => {
218
+ for (const m of muts) {
219
+ for (const node of m.addedNodes) {
220
+ if (node.nodeType !== 1) continue;
221
+ if (node.matches && node.matches('svg.connection')) addArrowhead(node);
222
+ if (node.querySelectorAll) {
223
+ node.querySelectorAll('svg.connection').forEach(addArrowhead);
224
+ }
225
+ }
226
+ }
227
+ }).observe(container, { subtree: true, childList: true });
228
+
229
+ const idxByDfId = new Map();
230
+ for (let i = 0; i < n; i++) {
231
+ if (nodeIds[i] !== undefined) idxByDfId.set(nodeIds[i], i);
232
+ }
233
+
234
+ function connectedTo(start) {
235
+ const seen = new Set([start]);
236
+ for (const transitive_edge of transitive_closure_matrix_sparse) {
237
+ if (transitive_edge[0] === start) seen.add(transitive_edge[1]);
238
+ else if (transitive_edge[1] === start) seen.add(transitive_edge[0]);
239
+ }
240
+ return seen;
241
+ }
242
+
243
+ function parseEndpointIds(svg) {
244
+ let outId = null, inId = null;
245
+ for (const cls of svg.classList) {
246
+ if (cls.startsWith('node_out_node-')) outId = parseInt(cls.slice('node_out_node-'.length), 10);
247
+ else if (cls.startsWith('node_in_node-')) inId = parseInt(cls.slice('node_in_node-'.length), 10);
248
+ }
249
+ return { outId, inId };
250
+ }
251
+
252
+ let currentSelected = null;
253
+
254
+ function clearSelection() {
255
+ container.querySelectorAll('.drawflow-node.is-dim, .drawflow-node.is-selected')
256
+ .forEach(el => el.classList.remove('is-dim', 'is-selected'));
257
+ container.querySelectorAll('svg.connection.is-dim')
258
+ .forEach(el => el.classList.remove('is-dim'));
259
+ }
260
+
261
+ function applySelection(startIdx) {
262
+ const connected = connectedTo(startIdx);
263
+
264
+ for (let i = 0; i < n; i++) {
265
+ const el = document.getElementById('node-' + nodeIds[i]);
266
+ if (!el) continue;
267
+ el.classList.toggle('is-dim', !connected.has(i));
268
+ el.classList.toggle('is-selected', i === startIdx);
269
+ }
270
+
271
+ container.querySelectorAll('svg.connection').forEach((svg) => {
272
+ const { outId, inId } = parseEndpointIds(svg);
273
+ const s = idxByDfId.get(outId);
274
+ const t = idxByDfId.get(inId);
275
+ const keep = (s !== undefined && t !== undefined
276
+ && connected.has(s) && connected.has(t));
277
+ svg.classList.toggle('is-dim', !keep);
278
+ });
279
+ }
280
+
281
+ container.addEventListener('click', (evt) => {
282
+ const nodeEl = evt.target.closest('.drawflow-node');
283
+ if (!nodeEl) return;
284
+ const m = nodeEl.id.match(/^node-(\d+)$/);
285
+ if (!m) return;
286
+ const dfId = parseInt(m[1], 10);
287
+ const idx = idxByDfId.get(dfId);
288
+ if (idx === undefined) return;
289
+
290
+ if (currentSelected === idx) {
291
+ currentSelected = null;
292
+ clearSelection();
293
+ } else {
294
+ currentSelected = idx;
295
+ applySelection(idx);
296
+ }
297
+ });
298
+
299
+ document.getElementById('zoom-in').onclick = () => editor.zoom_in();
300
+ document.getElementById('zoom-out').onclick = () => editor.zoom_out();
301
+ document.getElementById('zoom-reset').onclick = () => { editor.zoom_reset(); currentSelected = null; clearSelection(); };
302
+ </script>
303
+ </body>
304
+ </html>
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { default, command, _resetShutdownForTest } from './command.js';
2
2
  export { legacyCommand, legacyFlags as flags } from './legacy.js';
3
+ export { applyIntelliStory, writeIntelliStoryTrace, IntelliStoryBailError } from './intelliStory.js';
3
4
  // export common packages to avoid dependency resolution issues
4
5
  export { default as PercyConfig } from '@percy/config';
5
6
  export { default as logger } from '@percy/logger';
@@ -0,0 +1,756 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { spawnSync } from 'child_process';
4
+ import globToRegExp from 'glob-to-regexp';
5
+ import logger from '@percy/logger';
6
+ import { diffLockfileDeps } from './lockfileDiff.js';
7
+ import { renderGraphTraceHtml } from './graphTrace.js';
8
+ const NULL_CHAR = String.fromCharCode(0);
9
+ /* istanbul ignore next */
10
+ const stripNull = s => typeof s === 'string' ? s.split(NULL_CHAR).join('') : s;
11
+ const POLL_INTERVAL_MS = 5000;
12
+ const POLL_ATTEMPTS = 12;
13
+ const GLOB_CHARS = /[*?{}[\]]/;
14
+ const MAX_PATTERN_LENGTH = 500;
15
+
16
+ // Share of stats modules that may have unresolved ids before the dependency
17
+ // graph is treated as too incomplete to filter on.
18
+ const MAX_DROPPED_MODULE_RATIO = 0.1;
19
+ const regexCache = new Map();
20
+ function patternToRegex(pattern) {
21
+ /* istanbul ignore next */
22
+ if (typeof pattern !== 'string' || pattern.length > MAX_PATTERN_LENGTH) {
23
+ throw new Error('Invalid pattern: must be a string with max length of 500 characters');
24
+ }
25
+ let regex = regexCache.get(pattern);
26
+ if (!regex) {
27
+ regex = globToRegExp(pattern, {
28
+ extended: true,
29
+ globstar: true
30
+ });
31
+ regexCache.set(pattern, regex);
32
+ }
33
+ return regex;
34
+ }
35
+
36
+ // Callers must have cleared `pattern` through `patternError` first -- a glob that
37
+ // fails to compile is reported there (bail for `bailOnChanges`, warn-and-skip for
38
+ // `untraced`) rather than being swallowed into a silent non-match here.
39
+ function matchesPattern(str, pattern) {
40
+ if (GLOB_CHARS.test(pattern)) return patternToRegex(pattern).test(str);
41
+ return str === pattern;
42
+ }
43
+
44
+ // Returns the compile error for a glob, or null when it is usable. Patterns are
45
+ // validated up front rather than relying on `matchesPattern` swallowing the
46
+ // throw, so a typo is reported even when nothing happens to be compared to it.
47
+ function patternError(pattern) {
48
+ if (!GLOB_CHARS.test(pattern)) return null;
49
+ try {
50
+ patternToRegex(pattern);
51
+ return null;
52
+ } catch (e) {
53
+ return e;
54
+ }
55
+ }
56
+ const ROOT_DIR_TOKEN = '<rootDir>/';
57
+
58
+ // `git diff --name-only` always emits repo-root-relative paths, whatever the
59
+ // cwd. User patterns are interpreted relative to the invocation directory --
60
+ // the same basis as the `importPath` normalization in `applyIntelliStory`, and
61
+ // the basis a user editing `.percy.yml` beside their config files expects -- so
62
+ // they have to be rebased onto the repo root before they can match. A
63
+ // `<rootDir>/` prefix opts out and anchors the pattern to the repo root.
64
+ //
65
+ // `rebased` is null when the pattern resolves outside the repo, which no diff
66
+ // path can ever match; callers report that rather than matching nothing.
67
+ export function resolvePattern(raw, projectRoot, invocationDir) {
68
+ const toPosix = p => p.split(path.sep).join('/');
69
+ if (raw.startsWith(ROOT_DIR_TOKEN)) {
70
+ return {
71
+ raw,
72
+ rebased: toPosix(raw.slice(ROOT_DIR_TOKEN.length)),
73
+ anchored: true
74
+ };
75
+ }
76
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(invocationDir, raw);
77
+ const rel = path.relative(projectRoot, abs);
78
+ if (!rel || rel.startsWith('..')) return {
79
+ raw,
80
+ rebased: null,
81
+ anchored: false
82
+ };
83
+ return {
84
+ raw,
85
+ rebased: toPosix(rel),
86
+ anchored: false
87
+ };
88
+ }
89
+
90
+ // Whether `raw` would have matched something under the repo-root reading that
91
+ // the (correct) invocation-relative reading missed. Used to report the one
92
+ // mistake this design can still produce -- a pattern written repo-relative --
93
+ // instead of letting it silently match nothing.
94
+ function rootReadingHit(affectedNodes, {
95
+ raw,
96
+ rebased,
97
+ anchored
98
+ }) {
99
+ if (anchored || raw === rebased || patternError(raw)) return null;
100
+ if (affectedNodes.some(p => matchesPattern(p, rebased))) return null;
101
+ return affectedNodes.find(p => matchesPattern(p, raw)) ?? null;
102
+ }
103
+ function describeInvocation(projectRoot, invocationDir) {
104
+ return path.relative(projectRoot, invocationDir).split(path.sep).join('/') || '.';
105
+ }
106
+
107
+ // How to spell a repo-root-relative pattern so it means the same thing under
108
+ // the invocation-relative contract. Used only in the "you probably meant the
109
+ // repo root" advice, so it falls back to the `<rootDir>/` form when the target
110
+ // is not reachable from the invocation directory without escaping it.
111
+ function suggestRewrite(raw, projectRoot, invocationDir) {
112
+ const rel = path.relative(invocationDir, path.resolve(projectRoot, raw)).split(path.sep).join('/');
113
+ const anchored = `"${ROOT_DIR_TOKEN}${raw}"`;
114
+ if (!rel || rel.startsWith('..')) return anchored;
115
+ return `"${rel}" (relative to the invocation directory) or ${anchored}`;
116
+ }
117
+ export class IntelliStoryBailError extends Error {
118
+ constructor(message) {
119
+ super(message);
120
+ this.name = 'IntelliStoryBailError';
121
+ }
122
+ }
123
+ function git(args) {
124
+ let res;
125
+ try {
126
+ res = spawnSync('git', args, {
127
+ encoding: 'utf8'
128
+ });
129
+ } catch (e) {
130
+ /* istanbul ignore next */
131
+ throw new IntelliStoryBailError(`IntelliStory: git ${args.join(' ')} failed to spawn: ${e.message}; running full snapshot set`);
132
+ }
133
+ if (res.status !== 0) {
134
+ /* istanbul ignore next */
135
+ throw new IntelliStoryBailError(`IntelliStory: git ${args.join(' ')} failed: ${res.stderr || res.stdout || `exit ${res.status}`}; running full snapshot set`);
136
+ }
137
+ return res.stdout;
138
+ }
139
+ function assertSafeRef(ref) {
140
+ if (typeof ref !== 'string' || !/^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(ref)) {
141
+ throw new IntelliStoryBailError(`IntelliStory: unsafe baseline ref "${ref}"; running full snapshot set`);
142
+ }
143
+ }
144
+ function gitDiffNames(ref) {
145
+ assertSafeRef(ref);
146
+ return git(['-c', 'core.quotepath=false', 'diff', '--name-only', ref, 'HEAD', '--']).split('\n').filter(Boolean);
147
+ }
148
+ function gitProjectRoot() {
149
+ return git(['rev-parse', '--show-toplevel']).trim();
150
+ }
151
+ export function getAffectedFileLocations(baseRef, files) {
152
+ assertSafeRef(baseRef);
153
+ const diff = git(['-c', 'core.quotepath=false', 'diff', '--unified=0', '--no-color', '--no-renames', baseRef, 'HEAD', '--']);
154
+ const toPosix = p => p.split(path.sep).join('/');
155
+ const indexByPath = new Map(files.map((f, i) => [toPosix(f), i]));
156
+ const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
157
+ const locations = {};
158
+ let currentIdx;
159
+ for (const line of diff.split('\n')) {
160
+ if (line.startsWith('+++ ')) {
161
+ const p = line.slice(4);
162
+ if (p === '/dev/null') {
163
+ currentIdx = undefined;
164
+ continue;
165
+ }
166
+ /* istanbul ignore next */
167
+ const rel = p.startsWith('b/') ? p.slice(2) : p;
168
+ currentIdx = indexByPath.get(rel);
169
+ continue;
170
+ }
171
+ if (currentIdx === undefined) continue;
172
+ const m = HUNK.exec(line);
173
+ if (!m) continue;
174
+ const start = parseInt(m[1], 10);
175
+ const count = m[2] === undefined ? 1 : parseInt(m[2], 10);
176
+ if (count === 0) continue;
177
+ if (!locations[currentIdx]) locations[currentIdx] = [];
178
+ locations[currentIdx].push([start, start + count - 1]);
179
+ }
180
+ return locations;
181
+ }
182
+ export const DEFAULT_CONFIG_DIR = '.storybook';
183
+
184
+ // Storybook's config directory is configurable (`-c`), so the literal
185
+ // `.storybook` cannot be the only thing recognised. `configDirs` always carries
186
+ // the default alongside any configured directory: recognising one directory too
187
+ // many only costs snapshots, while missing the real one loses comparisons.
188
+ //
189
+ // A single-segment name matches at any depth — a `.storybook` anywhere is a
190
+ // Storybook config directory, including in a sibling package — while a
191
+ // multi-segment path (`config/storybook`) is matched as a path prefix so it
192
+ // cannot also match `config/storybook-old`.
193
+ function isInsideDirs(relPath, dirs) {
194
+ const segs = relPath.split(/[/\\]/).filter(Boolean);
195
+ return dirs.some(dir => {
196
+ const d = dir.split(/[/\\]/).filter(Boolean);
197
+ if (!d.length) return false;
198
+ if (d.length === 1) return segs.includes(d[0]);
199
+ return d.length <= segs.length && d.every((s, i) => segs[i] === s);
200
+ });
201
+ }
202
+ const isExcluded = (relPath, configDirs) => isInsideDirs(relPath, ['node_modules', ...configDirs]);
203
+ function resolveAndIndex(value, fileIndex, projectRoot, configDirs) {
204
+ const clean = stripNull(value);
205
+ if (!path.isAbsolute(clean)) return clean;
206
+ const rel = path.relative(projectRoot, clean);
207
+ if (isExcluded(rel, configDirs)) return rel;
208
+ let idx = fileIndex.get(rel);
209
+ if (idx === undefined) {
210
+ idx = fileIndex.size;
211
+ fileIndex.set(rel, idx);
212
+ }
213
+ return idx;
214
+ }
215
+
216
+ // Returns `{ module }` when the module made it into the graph, or `{ dropped }`
217
+ // with the reason it did not. The two reasons are not equivalent: `excluded` is
218
+ // this module deliberately ignoring node_modules and the Storybook config
219
+ // directory, while `unresolved` is a synthetic or virtual id we could not place
220
+ // on disk — a hole in the graph, and the only one worth counting.
221
+ function transformModule(m, fileIndex, projectRoot, configDirs) {
222
+ const out = {};
223
+ if (m.id != null) out.id = resolveAndIndex(m.id, fileIndex, projectRoot, configDirs);
224
+ if (typeof out.id === 'string') {
225
+ return {
226
+ dropped: path.isAbsolute(stripNull(m.id)) ? 'excluded' : 'unresolved'
227
+ };
228
+ }
229
+ const mapEntry = e => {
230
+ const copy = {
231
+ ...e
232
+ };
233
+ if (copy.type === 'src' && typeof copy.source === 'string') {
234
+ copy.source = resolveAndIndex(copy.source, fileIndex, projectRoot, configDirs);
235
+ }
236
+ if (Array.isArray(copy.loc)) {
237
+ copy.loc = copy.loc.map(l => [l.start, l.end]);
238
+ }
239
+ return copy;
240
+ };
241
+ if (Array.isArray(m.imports)) out.imports = m.imports.map(mapEntry);
242
+ if (Array.isArray(m.passThroughExports)) out.passThroughExports = m.passThroughExports.map(mapEntry);
243
+ if (Array.isArray(m.nonPassThroughExports)) out.nonPassThroughExports = m.nonPassThroughExports;
244
+ return {
245
+ module: out
246
+ };
247
+ }
248
+ function readStats(statsFile, projectRoot, log, configDirs) {
249
+ const fileIndex = new Map();
250
+ const modules = [];
251
+ let stats;
252
+ try {
253
+ stats = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
254
+ } catch (e) {
255
+ throw new IntelliStoryBailError(`IntelliStory: failed to parse stats file ${statsFile}: ${e.message}; running full snapshot set`);
256
+ }
257
+ /* istanbul ignore next */
258
+ const rawModules = stats.modules || [];
259
+ let unresolved = 0;
260
+ let excluded = 0;
261
+ for (const m of rawModules) {
262
+ const {
263
+ module,
264
+ dropped
265
+ } = transformModule(m, fileIndex, projectRoot, configDirs);
266
+ if (module) modules.push(module);else if (dropped === 'unresolved') unresolved++;else excluded++;
267
+ }
268
+ if (excluded) log === null || log === void 0 || log.debug(`IntelliStory: skipped ${excluded} module(s) in node_modules or the Storybook config directory`);
269
+
270
+ // An unresolved module is a missing edge, and a missing edge is exactly what
271
+ // makes the API judge a story unaffected. A few synthetic ids are normal for
272
+ // some builders, so this warns rather than bails -- but past the threshold the
273
+ // graph is degraded enough that filtering on it is not trustworthy. Only
274
+ // resolvable candidates count towards the ratio: deliberately excluded modules
275
+ // are not a gap, and including them would bail on healthy builds whose stats
276
+ // happen to carry a lot of node_modules.
277
+ if (unresolved) {
278
+ // at least `unresolved` modules were candidates, so this cannot divide by zero
279
+ const candidates = rawModules.length - excluded;
280
+ const ratio = unresolved / candidates;
281
+ if (ratio > MAX_DROPPED_MODULE_RATIO) {
282
+ throw new IntelliStoryBailError(`IntelliStory: ${unresolved} of ${candidates} stats modules have unresolved (non-absolute) ids (${Math.round(ratio * 100)}%, limit ${Math.round(MAX_DROPPED_MODULE_RATIO * 100)}%); the dependency graph would be incomplete; running full snapshot set`);
283
+ }
284
+ log === null || log === void 0 || log.warn(`IntelliStory: dropped ${unresolved} of ${candidates} module(s) with unresolved (non-absolute) ids; the dependency graph may be missing edges for them`);
285
+ }
286
+ const files = [...fileIndex.entries()].sort((a, b) => a[1] - b[1]).map(([p]) => p);
287
+ return {
288
+ files,
289
+ modules,
290
+ buildId: stats.buildId
291
+ };
292
+ }
293
+
294
+ // Every other failure mode in this module — git, fs, config, lockfile, glob,
295
+ // degraded graph — is converted to IntelliStoryBailError so the caller can
296
+ // degrade to a full snapshot run. API and network failures are no different: a
297
+ // 403 means the organization is not entitled to IntelliStory, and a 5xx /
298
+ // timeout / connection reset is transient. Neither should hard-fail the
299
+ // storybook command, which is what a bare Error does once @percy/storybook
300
+ // catches IntelliStoryBailError specifically.
301
+ function isNotAllowed(e) {
302
+ var _e$response;
303
+ return (e === null || e === void 0 || (_e$response = e.response) === null || _e$response === void 0 ? void 0 : _e$response.statusCode) === 403;
304
+ }
305
+ function apiBailError(e, action) {
306
+ if (isNotAllowed(e)) {
307
+ return new IntelliStoryBailError(`IntelliStory: not enabled for this organization (${action} was not allowed); running full snapshot set`);
308
+ }
309
+ return new IntelliStoryBailError(`IntelliStory: ${action} failed: ${e === null || e === void 0 ? void 0 : e.message}; running full snapshot set`);
310
+ }
311
+ async function pollGraphStatus(percy, buildId, log) {
312
+ for (let i = 0; i < POLL_ATTEMPTS; i++) {
313
+ var _res, _res2;
314
+ let res;
315
+ try {
316
+ res = await percy.client.getStatus('intelli_story_graph', [buildId]);
317
+ } catch (e) {
318
+ // A 403 will not resolve by polling again, so stop immediately rather
319
+ // than burning the full POLL_ATTEMPTS window on it. Anything else is
320
+ // treated as "not done yet" and retried; if it never clears, the caller's
321
+ // timed-out bail covers it.
322
+ if (isNotAllowed(e)) throw apiBailError(e, 'graph status poll');
323
+ log.debug(`IntelliStory: graph status (attempt ${i + 1}) errored: ${e === null || e === void 0 ? void 0 : e.message}`);
324
+ if (i < POLL_ATTEMPTS - 1) await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
325
+ continue;
326
+ }
327
+ const status = (_res = res) === null || _res === void 0 ? void 0 : _res.status;
328
+ log.debug(`IntelliStory: graph status (attempt ${i + 1}) = ${status}`);
329
+ if (status === 'done' || status === 'failed') return {
330
+ status,
331
+ data: (_res2 = res) === null || _res2 === void 0 ? void 0 : _res2.data
332
+ };
333
+ if (i < POLL_ATTEMPTS - 1) await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
334
+ }
335
+ return {
336
+ status: null
337
+ };
338
+ }
339
+ export async function validateAndReadStats(buildDir, statsFile, projectRoot, log, configDirs = [DEFAULT_CONFIG_DIR]) {
340
+ const statsName = path.basename(statsFile || 'enriched-stats.json');
341
+ if (!/^[\w.-]+\.json$/i.test(statsName)) {
342
+ throw new IntelliStoryBailError(`IntelliStory: invalid statsFile "${statsName}" — must be a .json filename; running full snapshot set`);
343
+ }
344
+ const resolvedStatsPath = path.join(path.resolve(buildDir), statsName);
345
+ let statsStat;
346
+ try {
347
+ statsStat = fs.statSync(resolvedStatsPath);
348
+ } catch {
349
+ throw new IntelliStoryBailError(`IntelliStory: stats file "${statsName}" not found in build directory ${buildDir}; running full snapshot set`);
350
+ }
351
+ if (!statsStat.isFile()) {
352
+ throw new IntelliStoryBailError(`IntelliStory: stats file "${statsName}" in ${buildDir} is not a regular file; running full snapshot set`);
353
+ }
354
+ log.debug(`IntelliStory: parsing stats file ${resolvedStatsPath}`);
355
+ // The graph is keyed by the Percy build id, not the stats-file `buildId`, so a
356
+ // missing `buildId` here is not fatal — it is logged only so a support ticket
357
+ // can be tied back to the Storybook build that produced this stats file.
358
+ const {
359
+ files,
360
+ modules,
361
+ buildId
362
+ } = await readStats(resolvedStatsPath, projectRoot, log, configDirs);
363
+ log.debug(`IntelliStory: stats file buildId = ${buildId}`);
364
+ return {
365
+ files,
366
+ modules
367
+ };
368
+ }
369
+ export async function getBaselineAndAffectedNodes(percy, baseline, log) {
370
+ var _baseLookup;
371
+ let baseRef;
372
+
373
+ // Always look up the base build: its `browsers_changed_from_base` flag forces
374
+ // a full snapshot run regardless of whether an explicit baseline was configured.
375
+ let baseLookup;
376
+ try {
377
+ var _percy$build;
378
+ baseLookup = await percy.client.getIntelliStorySnapshotNameToCommit((_percy$build = percy.build) === null || _percy$build === void 0 ? void 0 : _percy$build.id);
379
+ } catch (e) {
380
+ throw apiBailError(e, 'base build lookup');
381
+ }
382
+ log.debug(`IntelliStory: base lookup ${JSON.stringify(baseLookup)}`);
383
+ if ((_baseLookup = baseLookup) !== null && _baseLookup !== void 0 && _baseLookup.browsers_changed_from_base) {
384
+ throw new IntelliStoryBailError('IntelliStory: This build has to take all snapshots by fallback because this build corresponds to a browser upgrade');
385
+ }
386
+ if (baseline) {
387
+ log.debug(`IntelliStory: diffing against explicit baseline "${baseline}"`);
388
+ baseRef = baseline;
389
+ } else {
390
+ var _baseLookup2;
391
+ if (!((_baseLookup2 = baseLookup) !== null && _baseLookup2 !== void 0 && _baseLookup2.base_build_commit_sha)) {
392
+ throw new IntelliStoryBailError('IntelliStory: API could not predict a base build commit and no explicit baseline was set; running full snapshot set');
393
+ }
394
+ log.debug(`IntelliStory: diffing against predicted base build commit "${baseLookup.base_build_commit_sha}"`);
395
+ baseRef = baseLookup.base_build_commit_sha;
396
+ }
397
+ assertSafeRef(baseRef);
398
+ const affectedNodes = gitDiffNames(baseRef);
399
+ return {
400
+ baseRef,
401
+ affectedNodes
402
+ };
403
+ }
404
+ export function assertNoDotStorybookChange(affectedNodes, configDirs = [DEFAULT_CONFIG_DIR]) {
405
+ const hit = affectedNodes.find(p => isInsideDirs(p, configDirs));
406
+ if (hit) {
407
+ throw new IntelliStoryBailError(`IntelliStory: change to "${hit}" inside the Storybook config directory affects all stories; running full snapshot set`);
408
+ }
409
+ }
410
+
411
+ // The config directory the user gave us, on the same repo-root basis as
412
+ // everything else. Interpreted relative to the invocation directory, matching
413
+ // `bailOnChanges`/`untraced`; the default is kept alongside it so a project that
414
+ // has both a custom directory and a stray `.storybook` is covered either way.
415
+ export function resolveConfigDirs(configDir, projectRoot, invocationDir, log) {
416
+ const dirs = new Set([DEFAULT_CONFIG_DIR]);
417
+ if (!configDir || configDir === DEFAULT_CONFIG_DIR) return [...dirs];
418
+ const {
419
+ rebased
420
+ } = resolvePattern(configDir, projectRoot, invocationDir);
421
+ if (rebased == null) {
422
+ log === null || log === void 0 || log.warn(`IntelliStory: configDir "${configDir}" resolves outside the project root; falling back to "${DEFAULT_CONFIG_DIR}"`);
423
+ return [...dirs];
424
+ }
425
+
426
+ // Unlike a glob, a config directory has to exist -- so a directory written on
427
+ // the wrong basis is detectable here rather than silently matching nothing
428
+ // later. When only the repo-root reading exists, recognise it as well:
429
+ // recognising one directory too many costs snapshots, missing the real one
430
+ // loses comparisons.
431
+ if (!fs.existsSync(path.resolve(projectRoot, rebased))) {
432
+ const asRootRelative = configDir.split(path.sep).join('/');
433
+ if (!path.isAbsolute(configDir) && fs.existsSync(path.resolve(projectRoot, configDir))) {
434
+ log === null || log === void 0 || log.warn(`IntelliStory: configDir "${configDir}" does not exist relative to the invocation directory ("${describeInvocation(projectRoot, invocationDir)}") but does relative to the project root; recognising both. Write it relative to the invocation directory to make this explicit.`);
435
+ dirs.add(asRootRelative);
436
+ } else {
437
+ log === null || log === void 0 || log.warn(`IntelliStory: configDir "${configDir}" resolves to "${rebased}", which does not exist; it is interpreted relative to the invocation directory, not the project root`);
438
+ }
439
+ }
440
+ dirs.add(rebased);
441
+ return [...dirs];
442
+ }
443
+
444
+ // `bailOnChanges` is the user's escape hatch for changes the graph cannot
445
+ // reason about, so every failure mode here resolves towards bailing: a pattern
446
+ // that cannot be evaluated is a disabled safety valve, and a pattern that only
447
+ // matches under the repo-root reading still bails (loudly) rather than being
448
+ // treated as a non-match.
449
+ export function assertNoBailOnChanges(affectedNodes, bailOnChanges, {
450
+ projectRoot = process.cwd(),
451
+ invocationDir = projectRoot,
452
+ log
453
+ } = {}) {
454
+ if (!(bailOnChanges !== null && bailOnChanges !== void 0 && bailOnChanges.length)) return;
455
+ for (const raw of bailOnChanges) {
456
+ const resolved = resolvePattern(raw, projectRoot, invocationDir);
457
+ if (resolved.rebased == null) {
458
+ throw new IntelliStoryBailError(`IntelliStory: bailOnChanges pattern "${raw}" resolves outside the project root and can never match a changed file; running full snapshot set`);
459
+ }
460
+ const err = patternError(resolved.rebased);
461
+ if (err) {
462
+ throw new IntelliStoryBailError(`IntelliStory: bailOnChanges pattern "${raw}" is not a valid glob (${err.message}); running full snapshot set`);
463
+ }
464
+ let bailed = affectedNodes.find(p => matchesPattern(p, resolved.rebased));
465
+ if (!bailed) {
466
+ const rootHit = rootReadingHit(affectedNodes, resolved);
467
+ if (!rootHit) continue;
468
+ log === null || log === void 0 || log.warn(`IntelliStory: bailOnChanges pattern "${raw}" matched "${rootHit}" only when read relative to the project root. Patterns are relative to the invocation directory ("${describeInvocation(projectRoot, invocationDir)}"); bailing anyway, but write it as ${suggestRewrite(raw, projectRoot, invocationDir)} to make this explicit.`);
469
+ bailed = rootHit;
470
+ }
471
+ throw new IntelliStoryBailError(`IntelliStory: change to "${bailed}" matched bailOnChanges; running full snapshot set`);
472
+ }
473
+ }
474
+
475
+ // `untraced` drops files from the affected set, so honouring an ambiguous
476
+ // reading here would remove snapshots -- the opposite direction of harm from
477
+ // `bailOnChanges`. Only the invocation-relative reading is applied; a pattern
478
+ // that looks repo-relative is reported and left unapplied, since keeping a file
479
+ // traced never loses a comparison.
480
+ export function enforceUntraced(affectedNodes, untraced, {
481
+ projectRoot = process.cwd(),
482
+ invocationDir = projectRoot,
483
+ log
484
+ } = {}) {
485
+ if (!(untraced !== null && untraced !== void 0 && untraced.length)) return affectedNodes;
486
+ const resolved = [];
487
+ for (const raw of untraced) {
488
+ const r = resolvePattern(raw, projectRoot, invocationDir);
489
+ if (r.rebased == null) {
490
+ log === null || log === void 0 || log.warn(`IntelliStory: ignoring untraced pattern "${raw}" — it resolves outside the project root and can never match a changed file`);
491
+ continue;
492
+ }
493
+ const err = patternError(r.rebased);
494
+ if (err) {
495
+ log === null || log === void 0 || log.warn(`IntelliStory: ignoring untraced pattern "${raw}" — not a valid glob (${err.message})`);
496
+ continue;
497
+ }
498
+ const rootHit = rootReadingHit(affectedNodes, r);
499
+ if (rootHit) {
500
+ log === null || log === void 0 || log.warn(`IntelliStory: untraced pattern "${raw}" matches "${rootHit}" only when read relative to the project root. Patterns are relative to the invocation directory ("${describeInvocation(projectRoot, invocationDir)}") — not applied; write it as ${suggestRewrite(raw, projectRoot, invocationDir)}.`);
501
+ continue;
502
+ }
503
+ resolved.push(r);
504
+ }
505
+ return affectedNodes.filter(p => !resolved.some(({
506
+ rebased
507
+ }) => matchesPattern(p, rebased)));
508
+ }
509
+ export async function getAffectedPackages(affectedNodes, baseRef, projectRoot, log) {
510
+ assertSafeRef(baseRef);
511
+ const MANIFEST_PATHS = new Set(['package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']);
512
+ const manifestHits = affectedNodes.filter(p => MANIFEST_PATHS.has(path.basename(p)));
513
+ if (manifestHits.length === 0) return [];
514
+ const uniqueDirs = [...new Set(manifestHits.map(p => path.dirname(p)))];
515
+ if (uniqueDirs.length > 1) {
516
+ throw new IntelliStoryBailError(`IntelliStory: manifest changes span multiple directories (${uniqueDirs.join(', ')}); running full snapshot set`);
517
+ }
518
+ const manifestDir = uniqueDirs[0];
519
+ const absManifestDir = path.resolve(projectRoot, manifestDir);
520
+ const LOCKFILE_NAMES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
521
+ const presentLockfiles = LOCKFILE_NAMES.filter(n => fs.existsSync(path.join(absManifestDir, n)));
522
+ if (presentLockfiles.length === 0) {
523
+ throw new IntelliStoryBailError(`IntelliStory: manifest changed in "${manifestDir}" but no lockfile present there; running full snapshot set`);
524
+ }
525
+ if (presentLockfiles.length > 1) {
526
+ throw new IntelliStoryBailError(`IntelliStory: multiple lockfiles in "${manifestDir}" (${presentLockfiles.join(', ')}); cannot pick canonical; running full snapshot set`);
527
+ }
528
+ const lockfileName = presentLockfiles[0];
529
+ const lockfileRepoPath = manifestDir === '.' ? lockfileName : `${manifestDir}/${lockfileName}`;
530
+ let oldLockfile;
531
+ try {
532
+ oldLockfile = git(['show', `${baseRef}:${lockfileRepoPath}`]);
533
+ } catch {
534
+ throw new IntelliStoryBailError(`IntelliStory: lockfile "${lockfileRepoPath}" not present at base ref ${baseRef}; running full snapshot set`);
535
+ }
536
+ let newLockfile;
537
+ try {
538
+ newLockfile = fs.readFileSync(path.join(absManifestDir, lockfileName), 'utf8');
539
+ } catch {
540
+ throw new IntelliStoryBailError(`IntelliStory: failed to read lockfile "${lockfileName}" in "${manifestDir}"; running full snapshot set`);
541
+ }
542
+ if (oldLockfile === newLockfile) return [];
543
+ let packageJson;
544
+ try {
545
+ packageJson = fs.readFileSync(path.join(absManifestDir, 'package.json'), 'utf8');
546
+ } catch {
547
+ throw new IntelliStoryBailError(`IntelliStory: failed to read "package.json" in "${manifestDir}"; running full snapshot set`);
548
+ }
549
+ const packageJsonRepoPath = manifestDir === '.' ? 'package.json' : `${manifestDir}/package.json`;
550
+ const oldPackageJson = git(['show', `${baseRef}:${packageJsonRepoPath}`]);
551
+ try {
552
+ const packageAffected = await diffLockfileDeps({
553
+ packageJson,
554
+ oldLockfile,
555
+ newLockfile,
556
+ lockfileType: lockfileName,
557
+ oldPackageJson
558
+ });
559
+ /* istanbul ignore next */
560
+ log.debug(`IntelliStory: lockfile diff produced ${packageAffected.length} affected packages: ${packageAffected.join(', ')}`);
561
+ /* istanbul ignore next */
562
+ return packageAffected;
563
+ } catch (e) {
564
+ /* istanbul ignore else */
565
+ if (e.code === 'SNYK_LOCKFILE_PARSER_UNAVAILABLE') {
566
+ throw new IntelliStoryBailError(`IntelliStory: ${e.message}; running full snapshot set`);
567
+ }
568
+ /* istanbul ignore next */
569
+ throw e;
570
+ }
571
+ }
572
+ export function extractStorybookPaths(snapshots, normalizeImportPath, log) {
573
+ const storybookPaths = [...new Set(snapshots.map(s => normalizeImportPath(s.importPath)).filter(Boolean))];
574
+ const snapshotsWithImportPath = snapshots.filter(s => s.importPath).length;
575
+ log.debug(`IntelliStory: ${snapshotsWithImportPath}/${snapshots.length} snapshots have importPath; ${storybookPaths.length} unique storybookPaths`);
576
+ if (storybookPaths.length === 0) {
577
+ var _snapshots$, _snapshots$2, _snapshots$3;
578
+ log.warn(`IntelliStory: no snapshots have importPath set — check Storybook story extraction. Sample snapshot: ${JSON.stringify({
579
+ id: (_snapshots$ = snapshots[0]) === null || _snapshots$ === void 0 ? void 0 : _snapshots$.id,
580
+ name: (_snapshots$2 = snapshots[0]) === null || _snapshots$2 === void 0 ? void 0 : _snapshots$2.name,
581
+ importPath: (_snapshots$3 = snapshots[0]) === null || _snapshots$3 === void 0 ? void 0 : _snapshots$3.importPath,
582
+ keys: snapshots[0] ? Object.keys(snapshots[0]) : []
583
+ })}`);
584
+ } else {
585
+ log.debug(`IntelliStory: storybookPaths sample: ${storybookPaths.slice(0, 3).join(', ')}`);
586
+ }
587
+ return storybookPaths;
588
+ }
589
+ export async function runGraphGeneration(percy, buildId, payload, log) {
590
+ const {
591
+ files,
592
+ modules,
593
+ storybookPaths,
594
+ affectedNodes,
595
+ affectedFileLocations
596
+ } = payload;
597
+ log.debug(`IntelliStory: starting graph generation job ${JSON.stringify({
598
+ buildId,
599
+ files,
600
+ modules,
601
+ storybookPaths,
602
+ affectedNodes,
603
+ affectedFileLocations
604
+ })}`);
605
+ try {
606
+ await percy.client.generateIntelliStoryGraph(buildId, {
607
+ files,
608
+ modules,
609
+ storybookPaths,
610
+ affectedNodes,
611
+ affectedFileLocations
612
+ });
613
+ } catch (e) {
614
+ throw apiBailError(e, 'graph generation request');
615
+ }
616
+ const {
617
+ status
618
+ } = await pollGraphStatus(percy, buildId, log);
619
+ if (status !== 'done') {
620
+ throw new IntelliStoryBailError(`IntelliStory: graph generation did not complete (status: ${status ?? 'timed out'}); running full snapshot set`);
621
+ }
622
+ }
623
+ export function maybeWriteTrace(trace, data, log) {
624
+ if (trace && data !== null && data !== void 0 && data.vertices && data !== null && data !== void 0 && data.edges && data !== null && data !== void 0 && data.transitive_closure_matrix_sparse) {
625
+ const tracePath = path.resolve(process.cwd(), 'trace.html');
626
+ try {
627
+ const html = renderGraphTraceHtml({
628
+ vertices: data.vertices,
629
+ edges: data.edges,
630
+ transitiveClosureMatrixSparse: data.transitive_closure_matrix_sparse
631
+ });
632
+ fs.writeFileSync(tracePath, html);
633
+ log.info(`IntelliStory: trace written to ${tracePath}`);
634
+ } catch (e) {
635
+ log.warn(`IntelliStory: failed to write trace.html: ${e.message}`);
636
+ }
637
+ }
638
+ }
639
+ export async function applyIntelliStory(percy, snapshots, intelliStoryConfig, buildDir) {
640
+ var _percy$build2;
641
+ const log = logger('storybook:intelliStory');
642
+ const {
643
+ baseline,
644
+ untraced,
645
+ bailOnChanges,
646
+ statsFile,
647
+ configDir
648
+ } = intelliStoryConfig || {};
649
+ if (!buildDir) {
650
+ throw new IntelliStoryBailError('IntelliStory requires the Storybook build directory (e.g. `percy storybook ./storybook-static`); URL and `start` modes are not supported. Running full snapshot set');
651
+ }
652
+
653
+ // The graph is keyed by the real Percy build id. The build is created up
654
+ // front for IntelliStory runs (see @percy/storybook); if it is not present
655
+ // (e.g. a dry run, or build creation failed) there is nothing to key on.
656
+ const buildId = (_percy$build2 = percy.build) === null || _percy$build2 === void 0 ? void 0 : _percy$build2.id;
657
+ if (!buildId) {
658
+ throw new IntelliStoryBailError('IntelliStory: Percy build was not created (dry run or build creation failed); running full snapshot set');
659
+ }
660
+ const projectRoot = gitProjectRoot();
661
+ const invocationDir = process.cwd();
662
+ const patternOpts = {
663
+ projectRoot,
664
+ invocationDir,
665
+ log
666
+ };
667
+ log.debug(`IntelliStory: project root ${projectRoot}, invoked from "${describeInvocation(projectRoot, invocationDir)}" — user patterns and configDir are relative to the invocation directory`);
668
+ const configDirs = resolveConfigDirs(configDir, projectRoot, invocationDir, log);
669
+ log.debug(`IntelliStory: Storybook config directories: ${configDirs.map(d => `"${d}"`).join(', ')}`);
670
+ const {
671
+ files,
672
+ modules
673
+ } = await validateAndReadStats(buildDir, statsFile, projectRoot, log, configDirs);
674
+ let {
675
+ baseRef,
676
+ affectedNodes
677
+ } = await getBaselineAndAffectedNodes(percy, baseline, log);
678
+ assertNoDotStorybookChange(affectedNodes, configDirs);
679
+ assertNoBailOnChanges(affectedNodes, bailOnChanges, patternOpts);
680
+ affectedNodes = enforceUntraced(affectedNodes, untraced, patternOpts);
681
+ const packageAffectedNodes = await getAffectedPackages(affectedNodes, baseRef, projectRoot, log);
682
+ if (!affectedNodes.length && !packageAffectedNodes.length) {
683
+ throw new IntelliStoryBailError('IntelliStory: no affected files or packages detected after filtering; running full snapshot set');
684
+ }
685
+ const dotPosix = './';
686
+ const dotPlatform = `.${path.sep}`;
687
+ const normalizeImportPath = p => {
688
+ if (typeof p !== 'string' || !p) return p;
689
+ let rel = p;
690
+ /* istanbul ignore next */
691
+ if (rel.startsWith(dotPlatform)) rel = rel.slice(dotPlatform.length);else if (rel.startsWith(dotPosix)) rel = rel.slice(dotPosix.length);
692
+ const abs = path.resolve(invocationDir, rel);
693
+ const projRel = path.relative(projectRoot, abs);
694
+ /* istanbul ignore next */
695
+ return projRel || rel;
696
+ };
697
+ const storybookPaths = extractStorybookPaths(snapshots, normalizeImportPath, log);
698
+
699
+ /* istanbul ignore next */
700
+ if (packageAffectedNodes.length) {
701
+ affectedNodes = [...affectedNodes, ...packageAffectedNodes];
702
+ }
703
+ const affectedFileLocations = getAffectedFileLocations(baseRef, files);
704
+
705
+ // Enqueue the affected-story graph against the Percy build. Snapshot
706
+ // selection now happens server-side (when snapshots are posted), so we no
707
+ // longer read affected_stories back here or write the trace — we only kick
708
+ // off generation and surface a failure by bailing to the full set.
709
+ await runGraphGeneration(percy, buildId, {
710
+ files,
711
+ modules,
712
+ storybookPaths,
713
+ affectedNodes,
714
+ affectedFileLocations
715
+ }, log);
716
+
717
+ // Tag every snapshot with `intelliStory` and its normalized `storybookPath`
718
+ // so the API can perform affected-story selection when each is posted. The
719
+ // API is the sole arbiter of what can be skipped — including whether a
720
+ // snapshot has a usable baseline to carry forward.
721
+ //
722
+ // The one exception is a story with no resolvable source path: selection is
723
+ // keyed by that path, so the API rejects `intelli-story` without it (400).
724
+ // Leave those untagged and they are simply captured as normal.
725
+ return snapshots.map(s => {
726
+ const storybookPath = normalizeImportPath(s.importPath);
727
+ return {
728
+ ...s,
729
+ intelliStory: !!storybookPath,
730
+ storybookPath
731
+ };
732
+ });
733
+ }
734
+
735
+ // Called after the build has been finalized. At that point the graph job's
736
+ // data (vertices/edges/transitive closure) is available from job status, so we
737
+ // fetch it once more and write the trace when `trace` is enabled.
738
+ export async function writeIntelliStoryTrace(percy, intelliStoryConfig, log = logger('storybook:intelliStory')) {
739
+ var _percy$build3;
740
+ const {
741
+ trace
742
+ } = intelliStoryConfig || {};
743
+ if (!trace) return;
744
+ const buildId = (_percy$build3 = percy.build) === null || _percy$build3 === void 0 ? void 0 : _percy$build3.id;
745
+ if (!buildId) return;
746
+ log.debug(`IntelliStory: fetching finalized graph data for build ${buildId} to write trace`);
747
+ const {
748
+ status,
749
+ data
750
+ } = await pollGraphStatus(percy, buildId, log);
751
+ if (status !== 'done') {
752
+ log.debug(`IntelliStory: graph status "${status ?? 'timed out'}" after finalize; skipping trace`);
753
+ return;
754
+ }
755
+ maybeWriteTrace(trace, data, log);
756
+ }
@@ -0,0 +1,138 @@
1
+ import { createRequire } from 'module';
2
+ import logger from '@percy/logger';
3
+
4
+ // snyk-nodejs-lockfile-parser is a CommonJS optionalDependency. It requires
5
+ // Node >=18 while the CLI supports Node >=14, so we defer the require to call
6
+ // time — that way importing this module never throws on older Node versions
7
+ // (or when the optional install was skipped for any other reason). Cached on
8
+ // first successful load so the require only resolves once per process.
9
+ // Bound to a non-`require` name on purpose: when this ESM file is transpiled to
10
+ // CommonJS for the packaged binary, naming it `require` collides with two Babel
11
+ // transforms at once — preset-env renames the local `require` to `_require`, and
12
+ // transform-import-meta expands `import.meta.url` into a `require('url')` call
13
+ // that gets renamed to `_require` too, producing `_require(...)` inside its own
14
+ // initializer (TypeError: _require is not a function). See PER intelliStory binary.
15
+ const cjsRequire = createRequire(import.meta.url);
16
+ let _snykModule;
17
+ /* istanbul ignore next: snyk-backed path — the parser requires Node >=18 while
18
+ CI runs the suite on Node 14, so these lines can't execute there; they're
19
+ exercised by the describeSnyk tests on Node >=18 */
20
+ function loadSnyk() {
21
+ if (_snykModule) return _snykModule;
22
+ try {
23
+ _snykModule = cjsRequire('snyk-nodejs-lockfile-parser');
24
+ return _snykModule;
25
+ } catch (e) {
26
+ const err = new Error(`snyk-nodejs-lockfile-parser is not available (requires Node >=18, or the optional install was skipped): ${e.message}`);
27
+ err.code = 'SNYK_LOCKFILE_PARSER_UNAVAILABLE';
28
+ throw err;
29
+ }
30
+ }
31
+ const TYPE_KEY_BY_FILENAME = {
32
+ 'package-lock.json': 'npm',
33
+ 'yarn.lock': 'yarn',
34
+ 'pnpm-lock.yaml': 'pnpm'
35
+ };
36
+
37
+ /* istanbul ignore next */
38
+ function flattenPkgTree(tree) {
39
+ const out = new Map();
40
+ const walk = node => {
41
+ if (!(node !== null && node !== void 0 && node.dependencies)) return;
42
+ for (const [name, child] of Object.entries(node.dependencies)) {
43
+ if (!out.has(name)) out.set(name, child.version);
44
+ walk(child);
45
+ }
46
+ };
47
+ walk(tree);
48
+ return out;
49
+ }
50
+
51
+ /* istanbul ignore next */
52
+ function topLevelDeps(packageJsonContents) {
53
+ try {
54
+ const pkg = JSON.parse(packageJsonContents);
55
+ return {
56
+ ...(pkg.dependencies || {}),
57
+ ...(pkg.peerDependencies || {})
58
+ };
59
+ } catch {
60
+ return {};
61
+ }
62
+ }
63
+ export async function diffLockfileDeps({
64
+ packageJson,
65
+ oldPackageJson,
66
+ oldLockfile,
67
+ newLockfile,
68
+ lockfileType
69
+ }) {
70
+ const log = logger('storybook:intelliStory:lockfile');
71
+ const typeKey = TYPE_KEY_BY_FILENAME[lockfileType];
72
+ /* istanbul ignore else */
73
+ if (!typeKey) {
74
+ throw new Error(`Unsupported lockfile type: ${lockfileType}`);
75
+ }
76
+
77
+ /* istanbul ignore next */
78
+ return resolveAffectedDeps({
79
+ packageJson,
80
+ oldPackageJson,
81
+ oldLockfile,
82
+ newLockfile,
83
+ typeKey,
84
+ log
85
+ });
86
+ }
87
+
88
+ /* istanbul ignore next */
89
+ async function resolveAffectedDeps({
90
+ packageJson,
91
+ oldPackageJson,
92
+ oldLockfile,
93
+ newLockfile,
94
+ typeKey,
95
+ log
96
+ }) {
97
+ const {
98
+ buildDepTree,
99
+ LockfileType
100
+ } = loadSnyk();
101
+ const type = LockfileType[typeKey];
102
+ let oldTree;
103
+ try {
104
+ log.debug('buildDepTree: parsing OLD lockfile...');
105
+ oldTree = await buildDepTree(oldPackageJson, oldLockfile, true, type);
106
+ log.debug('buildDepTree: OLD lockfile parsed successfully');
107
+ } catch (e) {
108
+ log.warn(`buildDepTree: OLD lockfile failed to parse: ${e.message}`);
109
+ throw e;
110
+ }
111
+ let newTree;
112
+ try {
113
+ log.debug('buildDepTree: parsing NEW lockfile...');
114
+ newTree = await buildDepTree(packageJson, newLockfile, true, type);
115
+ log.debug('buildDepTree: NEW lockfile parsed successfully');
116
+ } catch (e) {
117
+ log.warn(`buildDepTree: NEW lockfile failed to parse: ${e.message}`);
118
+ throw e;
119
+ }
120
+ const oldPkgs = flattenPkgTree(oldTree);
121
+ const newPkgs = flattenPkgTree(newTree);
122
+ const oldTopLevel = topLevelDeps(oldPackageJson);
123
+ const newTopLevel = topLevelDeps(packageJson);
124
+ const topLevelNames = new Set([...Object.keys(oldTopLevel), ...Object.keys(newTopLevel)]);
125
+ const affected = new Set();
126
+ for (const [name, version] of newPkgs) {
127
+ if (!topLevelNames.has(name)) continue;
128
+ if (!oldPkgs.has(name) || oldPkgs.get(name) !== version) affected.add(name);
129
+ }
130
+ for (const [name] of oldPkgs) {
131
+ if (!topLevelNames.has(name)) continue;
132
+ if (!newPkgs.has(name)) affected.add(name);
133
+ }
134
+ for (const name of topLevelNames) {
135
+ if (oldTopLevel[name] !== newTopLevel[name]) affected.add(name);
136
+ }
137
+ return [...affected];
138
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/cli-command",
3
- "version": "1.32.6",
3
+ "version": "1.32.7-beta.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "publishConfig": {
11
11
  "access": "public",
12
- "tag": "latest"
12
+ "tag": "beta"
13
13
  },
14
14
  "files": [
15
15
  "dist",
@@ -27,6 +27,7 @@
27
27
  ".": "./dist/index.js",
28
28
  "./flags": "./dist/flags.js",
29
29
  "./utils": "./dist/utils.js",
30
+ "./intelliStory": "./dist/intelliStory.js",
30
31
  "./test/helpers": "./test/helpers.js"
31
32
  },
32
33
  "scripts": {
@@ -36,9 +37,13 @@
36
37
  "test:coverage": "yarn test --coverage"
37
38
  },
38
39
  "dependencies": {
39
- "@percy/config": "1.32.6",
40
- "@percy/core": "1.32.6",
41
- "@percy/logger": "1.32.6"
40
+ "@percy/config": "1.32.7-beta.2",
41
+ "@percy/core": "1.32.7-beta.2",
42
+ "@percy/logger": "1.32.7-beta.2",
43
+ "glob-to-regexp": "^0.4.1"
42
44
  },
43
- "gitHead": "e7dd935acf71273fd045ccceda6d1a37db8e4600"
45
+ "optionalDependencies": {
46
+ "snyk-nodejs-lockfile-parser": "2.7.1"
47
+ },
48
+ "gitHead": "be6a40b9d99ef190e4f6123491241c27c8308161"
44
49
  }