@percy/cli-command 1.32.0-beta.5 → 1.32.0-beta.7

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,157 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import url from 'url';
4
+
5
+ // Template resolution mirrors core/utils.js's secretPatterns.yml lookup:
6
+ // resolves relative to this file's URL so it works under src/ (dev) and
7
+ // dist/ (installed) without bundler help. The .html file is copied alongside
8
+ // by babel's copyFiles when cli-command is built.
9
+ const TEMPLATE_PATH = path.resolve(url.fileURLToPath(import.meta.url), '../graphTraceTemplate.html');
10
+
11
+ // Maps a (raw kind, changed) pair to the kind value the template expects:
12
+ // 'package' | 'component' | 'story' | 'is_relevant'. `changed: true` wins
13
+ // over the underlying kind so any node touched in the diff renders purple.
14
+ function templateKindOf(v) {
15
+ if (v.changed) return 'is_relevant';
16
+ switch (v.kind) {
17
+ case 'dependency':
18
+ return 'package';
19
+ case 'component':
20
+ return 'component';
21
+ case 'story':
22
+ return 'story';
23
+ default:
24
+ return 'component';
25
+ }
26
+ }
27
+
28
+ // Sort order within a column: packages left, components middle, stories right.
29
+ // `is_relevant` shares rank with components so a changed node doesn't jump
30
+ // out of its own group — it just recolors.
31
+ const KIND_RANK = {
32
+ package: 0,
33
+ component: 1,
34
+ is_relevant: 1,
35
+ story: 2
36
+ };
37
+
38
+ // Layout algorithm (ported from the original Ruby renderer):
39
+ // 1. col = longest-path depth reaching the vertex (read from the
40
+ // transitive-closure triples the API sends), with dependencies pinned
41
+ // to col 0.
42
+ // 2. Propagate over edges so col[target] > col[source]. Bounded loop
43
+ // guards against degenerate inputs.
44
+ // 3. Stories pushed past the rightmost non-story column.
45
+ // 4. Within each column, sort by (kind-rank, name) and assign row.
46
+ function computeLayout(rawVertices, edges, transitiveClosure) {
47
+ const n = rawVertices.length;
48
+ const vertices = rawVertices.map((v, i) => ({
49
+ index: i,
50
+ name: v.file_path,
51
+ kind: v.kind,
52
+ changed: !!v.changed,
53
+ row: 0,
54
+ col: 0
55
+ }));
56
+
57
+ // 1. Seed col from incoming transitive-closure lengths.
58
+ const incomingMax = new Array(n).fill(0);
59
+ for (const triple of transitiveClosure) {
60
+ const [u, v, val] = triple;
61
+ if (u === v || val <= 0) continue;
62
+ if (v < 0 || v >= n) continue;
63
+ if (val > incomingMax[v]) incomingMax[v] = val;
64
+ }
65
+ for (let i = 0; i < n; i++) {
66
+ vertices[i].col = vertices[i].kind === 'dependency' ? 0 : incomingMax[i] + 1;
67
+ }
68
+
69
+ // 2. Propagate edge constraint. n+2 iterations is enough for any DAG
70
+ // and bounds the work on accidentally-cyclic input.
71
+ const iterations = n + 2;
72
+ for (let iter = 0; iter < iterations; iter++) {
73
+ let changed = false;
74
+ for (const [s, t] of edges) {
75
+ if (s < 0 || s >= n || t < 0 || t >= n) continue;
76
+ if (vertices[s].col < vertices[t].col) continue;
77
+ vertices[t].col = vertices[s].col + 1;
78
+ changed = true;
79
+ }
80
+ if (!changed) break;
81
+ }
82
+
83
+ // 3. Stories rightmost. Two passes: max across non-stories first, then
84
+ // push every story past that boundary. Folding into one loop would let
85
+ // stories visited before the last non-story keep a stale max.
86
+ let furthestNonStory = 0;
87
+ for (const v of vertices) {
88
+ if (v.kind === 'story') continue;
89
+ if (v.col > furthestNonStory) furthestNonStory = v.col;
90
+ }
91
+ for (const v of vertices) {
92
+ if (v.kind !== 'story') continue;
93
+ if (v.col < furthestNonStory + 1) v.col = furthestNonStory + 1;
94
+ }
95
+
96
+ // 4. Group by column, sort by (kind-rank, name), assign row.
97
+ const groups = new Map();
98
+ for (const v of vertices) {
99
+ let list = groups.get(v.col);
100
+ if (!list) groups.set(v.col, list = []);
101
+ list.push(v);
102
+ }
103
+ const rankOf = v => {
104
+ const r = KIND_RANK[templateKindOf(v)];
105
+ /* istanbul ignore next: templateKindOf always returns a kind present in
106
+ KIND_RANK, so the `=== undefined` fallback is defensive */
107
+ return r === undefined ? 99 : r;
108
+ };
109
+ for (const list of groups.values()) {
110
+ list.sort((a, b) => {
111
+ const ra = rankOf(a);
112
+ const rb = rankOf(b);
113
+ if (ra !== rb) return ra - rb;
114
+ // Byte-wise compare on name to match Ruby's String#<=> behaviour.
115
+ if (a.name < b.name) return -1;
116
+ if (a.name > b.name) return 1;
117
+ return 0;
118
+ });
119
+ list.forEach((v, row) => {
120
+ v.row = row;
121
+ });
122
+ }
123
+
124
+ // 5. Final shape the template consumes: drop `changed`, fold it into kind.
125
+ return vertices.map(v => ({
126
+ index: v.index,
127
+ name: v.name,
128
+ row: v.row,
129
+ col: v.col,
130
+ kind: templateKindOf(v)
131
+ }));
132
+ }
133
+
134
+ // Escapes characters that have meaning inside a <script> block so user-derived
135
+ // strings (e.g. a vertex file_path) can't break out of the surrounding tag.
136
+ // `</` covers `</script>`; `<!--`/`-->` cover HTML comment confusion; U+2028
137
+ // and U+2029 are valid JSON but illegal in JS string literals pre-ES2019 and
138
+ // have historically been XSS sinks.
139
+ const LS = String.fromCharCode(0x2028);
140
+ const PS = String.fromCharCode(0x2029);
141
+ function safeJson(obj) {
142
+ return JSON.stringify(obj).replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--').replace(/--(!?)>/g, '--$1\\>').split(LS).join('\\u2028').split(PS).join('\\u2029');
143
+ }
144
+
145
+ // Populates the trace template with the three JSON payloads the page needs.
146
+ // Input shape matches the API's graph data: `vertices` carries `kind`,
147
+ // `file_path`, `changed`; `edges` and `transitive_closure_matrix_sparse`
148
+ // are arrays of integer tuples.
149
+ export function renderGraphTraceHtml({
150
+ vertices,
151
+ edges,
152
+ transitiveClosureMatrixSparse
153
+ }) {
154
+ const laidOutVertices = computeLayout(vertices || [], edges || [], transitiveClosureMatrixSparse || []);
155
+ const template = fs.readFileSync(TEMPLATE_PATH, 'utf8');
156
+ return template.replace('__VERTICES_JSON__', safeJson(laidOutVertices)).replace('__EDGES_JSON__', safeJson(edges || [])).replace('__TRANSITIVE_CLOSURE_JSON__', safeJson(transitiveClosureMatrixSparse || []));
157
+ }
@@ -0,0 +1,349 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Smartsnap 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
+ // ---------------------------------------------------------------------------
119
+ // Payload populated by the CLI before this template is written to disk.
120
+ // Tokens are replaced by graphTrace.js via plain string substitution.
121
+ // vertices: [{ index, name, row, col, kind }, ...]
122
+ // edges: [[srcIndex, tgtIndex], ...]
123
+ // transitive_closure_matrix_sparse: [[srcIndex, tgtIndex, length], ...]
124
+ // `kind` is one of: 'package' | 'component' | 'story' | 'is_relevant'.
125
+ // ---------------------------------------------------------------------------
126
+ const vertices = __VERTICES_JSON__;
127
+ const edges = __EDGES_JSON__;
128
+ const transitive_closure_matrix_sparse = __TRANSITIVE_CLOSURE_JSON__;
129
+ const n = vertices.length;
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Width measurement: each node grows to fit its file path. We need the
133
+ // rendered width (DOM-dependent) to lay out columns without overlap.
134
+ // ---------------------------------------------------------------------------
135
+ function measureWidth(text) {
136
+ const el = document.createElement('div');
137
+ el.className = 'measure-node';
138
+ el.textContent = text;
139
+ document.body.appendChild(el);
140
+ const w = el.offsetWidth;
141
+ el.remove();
142
+ return w;
143
+ }
144
+
145
+ // Drawflow adds port circles on each side (~25px) + border; pad generously.
146
+ const PORT_PAD = 60;
147
+ const nodeWidth = vertices.map(v => measureWidth(v.name) + PORT_PAD);
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Column X positions (cumulative by max width per column).
151
+ // ---------------------------------------------------------------------------
152
+ const PAD_X = 60, PAD_Y = 60, COL_GAP = 140, ROW_HEIGHT = 70;
153
+
154
+ const colMaxWidth = {};
155
+ for (let i = 0; i < n; i++) {
156
+ const c = vertices[i].col;
157
+ if (!colMaxWidth[c] || nodeWidth[i] > colMaxWidth[c]) colMaxWidth[c] = nodeWidth[i];
158
+ }
159
+ const sortedCols = Object.keys(colMaxWidth).map(Number).sort((a, b) => a - b);
160
+ const colX = {};
161
+ let cursor = PAD_X;
162
+ for (const c of sortedCols) {
163
+ colX[c] = cursor;
164
+ cursor += colMaxWidth[c] + COL_GAP;
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Boot Drawflow.
169
+ // ---------------------------------------------------------------------------
170
+ const container = document.getElementById('drawflow');
171
+ const editor = new Drawflow(container);
172
+ editor.reroute = false;
173
+ editor.start();
174
+
175
+ function escapeHtml(s) {
176
+ return String(s)
177
+ .replace(/&/g, '&amp;')
178
+ .replace(/</g, '&lt;')
179
+ .replace(/>/g, '&gt;')
180
+ .replace(/"/g, '&quot;')
181
+ .replace(/'/g, '&#39;');
182
+ }
183
+
184
+ // CSS classes per kind. `is_relevant` becomes `is-relevant` so it matches
185
+ // the stylesheet rule.
186
+ function cssClassFor(kind) {
187
+ if (kind === 'is_relevant') return 'is-relevant';
188
+ return kind;
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Add nodes. row/col come from the server; pixel positions are computed
193
+ // here using the measured widths so columns never overlap.
194
+ // ---------------------------------------------------------------------------
195
+ const nodeIds = new Array(n);
196
+ for (let i = 0; i < n; i++) {
197
+ const v = vertices[i];
198
+ const id = editor.addNode(
199
+ 'node',
200
+ 1, 1,
201
+ colX[v.col],
202
+ PAD_Y + v.row * ROW_HEIGHT,
203
+ cssClassFor(v.kind),
204
+ { index: v.index, kind: v.kind, name: v.name },
205
+ escapeHtml(v.name),
206
+ );
207
+ nodeIds[i] = id;
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Connections.
212
+ // ---------------------------------------------------------------------------
213
+ for (const e of edges) {
214
+ editor.addConnection(nodeIds[e[0]], nodeIds[e[1]], 'output_1', 'input_1');
215
+ }
216
+
217
+ editor.editor_mode = 'fixed';
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Arrowheads on connection paths.
221
+ // ---------------------------------------------------------------------------
222
+ const SVG_NS = 'http://www.w3.org/2000/svg';
223
+ function addArrowhead(svg) {
224
+ if (svg.querySelector('marker')) return;
225
+ let defs = svg.querySelector('defs');
226
+ if (!defs) {
227
+ defs = document.createElementNS(SVG_NS, 'defs');
228
+ svg.insertBefore(defs, svg.firstChild);
229
+ }
230
+ const marker = document.createElementNS(SVG_NS, 'marker');
231
+ marker.setAttribute('id', 'arrowhead');
232
+ marker.setAttribute('viewBox', '0 0 10 10');
233
+ marker.setAttribute('refX', '9');
234
+ marker.setAttribute('refY', '5');
235
+ marker.setAttribute('markerWidth', '5');
236
+ marker.setAttribute('markerHeight', '5');
237
+ marker.setAttribute('orient', 'auto');
238
+ const tip = document.createElementNS(SVG_NS, 'path');
239
+ tip.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
240
+ tip.setAttribute('fill', '#4ea9ff');
241
+ marker.appendChild(tip);
242
+ defs.appendChild(marker);
243
+ const path = svg.querySelector('path.main-path') || svg.querySelector('path');
244
+ if (path) path.setAttribute('marker-end', 'url(#arrowhead)');
245
+ }
246
+ container.querySelectorAll('svg.connection').forEach(addArrowhead);
247
+
248
+ new MutationObserver((muts) => {
249
+ for (const m of muts) {
250
+ for (const node of m.addedNodes) {
251
+ if (node.nodeType !== 1) continue;
252
+ if (node.matches && node.matches('svg.connection')) addArrowhead(node);
253
+ if (node.querySelectorAll) {
254
+ node.querySelectorAll('svg.connection').forEach(addArrowhead);
255
+ }
256
+ }
257
+ }
258
+ }).observe(container, { subtree: true, childList: true });
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Click-to-select with bidirectional reachability highlight.
262
+ //
263
+ // Reachability comes straight from transitive_closure_matrix_sparse: for a click
264
+ // on vertex `start`, anything appearing as the target of a triple
265
+ // (start, j, _) is reachable from `start`, and anything appearing as the
266
+ // source of a triple (i, start, _) can reach `start`. No adjacency list —
267
+ // we just iterate the triples each time. With small click counts and a
268
+ // sparse matrix this is fast enough and avoids carrying redundant graph
269
+ // state in the page.
270
+ // ---------------------------------------------------------------------------
271
+ const idxByDfId = new Map();
272
+ for (let i = 0; i < n; i++) {
273
+ if (nodeIds[i] !== undefined) idxByDfId.set(nodeIds[i], i);
274
+ }
275
+
276
+ function connectedTo(start) {
277
+ const seen = new Set([start]);
278
+ for (const transitive_edge of transitive_closure_matrix_sparse) {
279
+ if (transitive_edge[0] === start) seen.add(transitive_edge[1]);
280
+ else if (transitive_edge[1] === start) seen.add(transitive_edge[0]);
281
+ }
282
+ return seen;
283
+ }
284
+
285
+ function parseEndpointIds(svg) {
286
+ let outId = null, inId = null;
287
+ for (const cls of svg.classList) {
288
+ if (cls.startsWith('node_out_node-')) outId = parseInt(cls.slice('node_out_node-'.length), 10);
289
+ else if (cls.startsWith('node_in_node-')) inId = parseInt(cls.slice('node_in_node-'.length), 10);
290
+ }
291
+ return { outId, inId };
292
+ }
293
+
294
+ let currentSelected = null;
295
+
296
+ function clearSelection() {
297
+ container.querySelectorAll('.drawflow-node.is-dim, .drawflow-node.is-selected')
298
+ .forEach(el => el.classList.remove('is-dim', 'is-selected'));
299
+ container.querySelectorAll('svg.connection.is-dim')
300
+ .forEach(el => el.classList.remove('is-dim'));
301
+ }
302
+
303
+ function applySelection(startIdx) {
304
+ const connected = connectedTo(startIdx);
305
+
306
+ for (let i = 0; i < n; i++) {
307
+ const el = document.getElementById('node-' + nodeIds[i]);
308
+ if (!el) continue;
309
+ el.classList.toggle('is-dim', !connected.has(i));
310
+ el.classList.toggle('is-selected', i === startIdx);
311
+ }
312
+
313
+ container.querySelectorAll('svg.connection').forEach((svg) => {
314
+ const { outId, inId } = parseEndpointIds(svg);
315
+ const s = idxByDfId.get(outId);
316
+ const t = idxByDfId.get(inId);
317
+ const keep = (s !== undefined && t !== undefined
318
+ && connected.has(s) && connected.has(t));
319
+ svg.classList.toggle('is-dim', !keep);
320
+ });
321
+ }
322
+
323
+ container.addEventListener('click', (evt) => {
324
+ const nodeEl = evt.target.closest('.drawflow-node');
325
+ if (!nodeEl) return;
326
+ const m = nodeEl.id.match(/^node-(\d+)$/);
327
+ if (!m) return;
328
+ const dfId = parseInt(m[1], 10);
329
+ const idx = idxByDfId.get(dfId);
330
+ if (idx === undefined) return;
331
+
332
+ if (currentSelected === idx) {
333
+ currentSelected = null;
334
+ clearSelection();
335
+ } else {
336
+ currentSelected = idx;
337
+ applySelection(idx);
338
+ }
339
+ });
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Toolbar.
343
+ // ---------------------------------------------------------------------------
344
+ document.getElementById('zoom-in').onclick = () => editor.zoom_in();
345
+ document.getElementById('zoom-out').onclick = () => editor.zoom_out();
346
+ document.getElementById('zoom-reset').onclick = () => { editor.zoom_reset(); currentSelected = null; clearSelection(); };
347
+ </script>
348
+ </body>
349
+ </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 { applySmartSnap, SmartSnapBailError } from './smartsnap.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,184 @@
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
+ const require = createRequire(import.meta.url);
10
+ let _snykModule;
11
+ /* istanbul ignore next: snyk-backed path — the parser requires Node >=18 while
12
+ CI runs the suite on Node 14, so these lines can't execute there; they're
13
+ exercised by the describeSnyk tests on Node >=18 */
14
+ function loadSnyk() {
15
+ if (_snykModule) return _snykModule;
16
+ try {
17
+ _snykModule = require('snyk-nodejs-lockfile-parser');
18
+ return _snykModule;
19
+ } catch (e) {
20
+ // Surface the underlying require failure so callers can distinguish
21
+ // "not installed" (engine mismatch / optional skip) from genuine parse
22
+ // failures that happen later inside buildDepTree.
23
+ const err = new Error(`snyk-nodejs-lockfile-parser is not available (requires Node >=18, or the optional install was skipped): ${e.message}`);
24
+ err.code = 'SNYK_LOCKFILE_PARSER_UNAVAILABLE';
25
+ throw err;
26
+ }
27
+ }
28
+
29
+ // Map the on-disk filename to snyk's LockfileType enum key. We can't resolve
30
+ // the enum value at module-eval time because the snyk import is deferred, so
31
+ // we look up the value inside diffLockfileDeps after loadSnyk() runs.
32
+ const TYPE_KEY_BY_FILENAME = {
33
+ 'package-lock.json': 'npm',
34
+ 'yarn.lock': 'yarn',
35
+ 'pnpm-lock.yaml': 'pnpm'
36
+ };
37
+
38
+ // Walk snyk's PkgTree (a recursive `{ name, version, dependencies: { ... } }`
39
+ // structure) into a flat `Map<name, version>` of every resolved package.
40
+ // We dedupe by name on first sight — multiple versions of the same package
41
+ // in the tree get collapsed to the first one encountered, which is fine for
42
+ // the diff because we only care about whether *something* about the package
43
+ // changed between old and new.
44
+ /* istanbul ignore next: snyk-backed path — only reachable on Node >=18 (see loadSnyk) */
45
+ function flattenPkgTree(tree) {
46
+ const out = new Map();
47
+ const walk = node => {
48
+ if (!(node !== null && node !== void 0 && node.dependencies)) return;
49
+ for (const [name, child] of Object.entries(node.dependencies)) {
50
+ if (!out.has(name)) out.set(name, child.version);
51
+ walk(child);
52
+ }
53
+ };
54
+ walk(tree);
55
+ return out;
56
+ }
57
+
58
+ // Pull `{ name -> rangeString }` from a raw package.json's `dependencies`
59
+ // and `peerDependencies` blocks. devDeps and optionalDeps are intentionally
60
+ // excluded — only runtime-relevant top-level packages count. Returns {} on
61
+ // parse failure so the diff falls through to lockfile-only signal.
62
+ /* istanbul ignore next: snyk-backed path — only reachable on Node >=18 (see loadSnyk) */
63
+ function topLevelDeps(packageJsonContents) {
64
+ try {
65
+ const pkg = JSON.parse(packageJsonContents);
66
+ return {
67
+ ...(pkg.dependencies || {}),
68
+ ...(pkg.peerDependencies || {})
69
+ };
70
+ } catch {
71
+ return {};
72
+ }
73
+ }
74
+
75
+ // Given the project's package.json plus the old and new lockfile contents,
76
+ // return the set of package names that were added, removed, or version-bumped.
77
+ // Results are restricted to packages declared at the top level (dependencies
78
+ // or peerDependencies) of either old or new package.json — transitives ride
79
+ // along on direct-dep bumps and the BE module graph already resolves them
80
+ // from stats `imports[]`, so surfacing them here would just be noise.
81
+ //
82
+ // Two complementary diffs run, both gated by top-level names:
83
+ // 1. Resolved-version diff over the snyk PkgTree — catches lockfile entries
84
+ // whose version actually changed.
85
+ // 2. Range-string diff over package.json — catches the case where a user
86
+ // bumped `^5.8.3` to `^5.18.0` but the lockfile already resolved to
87
+ // 5.18.0 under the old range, so the resolved tree looks identical.
88
+ export async function diffLockfileDeps({
89
+ packageJson,
90
+ oldPackageJson,
91
+ oldLockfile,
92
+ newLockfile,
93
+ lockfileType
94
+ }) {
95
+ const log = logger('storybook:smartsnap:lockfile');
96
+
97
+ // Validate the filename maps to a supported lockfile type BEFORE loading the
98
+ // optional snyk parser. The parser requires Node >=18, so on older runtimes
99
+ // (or when the optional install was skipped) an unsupported type must still
100
+ // fail with this clear error rather than the parser-unavailable error from
101
+ // loadSnyk(). This filename check needs no parser to run.
102
+ const typeKey = TYPE_KEY_BY_FILENAME[lockfileType];
103
+ /* istanbul ignore else: on the Node-14 CI only the unsupported-type path is
104
+ exercised; the supported-type branch runs in describeSnyk on Node >=18 */
105
+ if (!typeKey) {
106
+ throw new Error(`Unsupported lockfile type: ${lockfileType}`);
107
+ }
108
+
109
+ /* istanbul ignore next: supported-type path — only reachable on Node >=18 (see loadSnyk) */
110
+ return resolveAffectedDeps({
111
+ packageJson,
112
+ oldPackageJson,
113
+ oldLockfile,
114
+ newLockfile,
115
+ typeKey,
116
+ log
117
+ });
118
+ }
119
+
120
+ // The snyk-backed resolution, split into its own function so a single
121
+ // coverage-ignore covers the whole path: the parser requires Node >=18 while CI
122
+ // runs the suite on Node 14, so none of this executes there. The describeSnyk
123
+ // tests exercise it on Node >=18. `typeKey` is already validated against
124
+ // TYPE_KEY_BY_FILENAME by the caller, so it always resolves to a LockfileType.
125
+ /* istanbul ignore next: snyk-backed path — only reachable on Node >=18 (see loadSnyk) */
126
+ async function resolveAffectedDeps({
127
+ packageJson,
128
+ oldPackageJson,
129
+ oldLockfile,
130
+ newLockfile,
131
+ typeKey,
132
+ log
133
+ }) {
134
+ const {
135
+ buildDepTree,
136
+ LockfileType
137
+ } = loadSnyk();
138
+ const type = LockfileType[typeKey];
139
+
140
+ // The two buildDepTree calls are kept sequential (not Promise.all'd) so that
141
+ // when one of them throws the log message identifies *which* side failed —
142
+ // old vs. new lockfile parsing behave differently when the user's lockfile
143
+ // is in an unexpected state.
144
+ let oldTree;
145
+ try {
146
+ log.debug('buildDepTree: parsing OLD lockfile...');
147
+ oldTree = await buildDepTree(oldPackageJson, oldLockfile, true, type);
148
+ log.debug('buildDepTree: OLD lockfile parsed successfully');
149
+ } catch (e) {
150
+ log.warn(`buildDepTree: OLD lockfile failed to parse: ${e.message}`);
151
+ throw e;
152
+ }
153
+ let newTree;
154
+ try {
155
+ log.debug('buildDepTree: parsing NEW lockfile...');
156
+ newTree = await buildDepTree(packageJson, newLockfile, true, type);
157
+ log.debug('buildDepTree: NEW lockfile parsed successfully');
158
+ } catch (e) {
159
+ log.warn(`buildDepTree: NEW lockfile failed to parse: ${e.message}`);
160
+ throw e;
161
+ }
162
+ const oldPkgs = flattenPkgTree(oldTree);
163
+ const newPkgs = flattenPkgTree(newTree);
164
+ const oldTopLevel = topLevelDeps(oldPackageJson);
165
+ const newTopLevel = topLevelDeps(packageJson);
166
+ const topLevelNames = new Set([...Object.keys(oldTopLevel), ...Object.keys(newTopLevel)]);
167
+ const affected = new Set();
168
+ for (const [name, version] of newPkgs) {
169
+ if (!topLevelNames.has(name)) continue;
170
+ if (!oldPkgs.has(name) || oldPkgs.get(name) !== version) affected.add(name);
171
+ }
172
+ for (const [name] of oldPkgs) {
173
+ if (!topLevelNames.has(name)) continue;
174
+ if (!newPkgs.has(name)) affected.add(name);
175
+ }
176
+
177
+ // Range-string diff is inherently top-level since it iterates package.json
178
+ // directly. `!==` between the strings (or undefined) covers added, removed,
179
+ // and changed in a single comparison.
180
+ for (const name of topLevelNames) {
181
+ if (oldTopLevel[name] !== newTopLevel[name]) affected.add(name);
182
+ }
183
+ return [...affected];
184
+ }
@@ -0,0 +1,632 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { spawnSync } from 'child_process';
4
+ import { createRequire } from 'module';
5
+ import globToRegExp from 'glob-to-regexp';
6
+ import logger from '@percy/logger';
7
+ import { diffLockfileDeps } from './lockfileDiff.js';
8
+ import { renderGraphTraceHtml } from './graphTrace.js';
9
+
10
+ // stream-json is CommonJS — load via createRequire to avoid named-import interop issues.
11
+ const require = createRequire(import.meta.url);
12
+ const {
13
+ parser
14
+ } = require('stream-json');
15
+ const {
16
+ pick
17
+ } = require('stream-json/filters/Pick');
18
+ const {
19
+ streamArray
20
+ } = require('stream-json/streamers/StreamArray');
21
+ const {
22
+ streamValues
23
+ } = require('stream-json/streamers/StreamValues');
24
+
25
+ // Webpack/Vite stats prefix loader-resolved virtual modules with a NUL byte
26
+ // (e.g. "\u0000/path/to/file?commonjs-es-import"). Strip it so the path lines
27
+ // up with the absolute paths in our file index. Built via String.fromCharCode
28
+ // to avoid embedding a control char in source / tripping no-control-regex.
29
+ const NULL_CHAR = String.fromCharCode(0);
30
+ // split/join instead of String.prototype.replaceAll: replaceAll is Node 15+
31
+ // and cli-command supports Node >=14.
32
+ /* istanbul ignore next: the non-string branch is defensive — resolveAndIndex
33
+ only ever passes stats string values, and a non-string would throw at the
34
+ path.isAbsolute call below anyway */
35
+ const stripNull = s => typeof s === 'string' ? s.split(NULL_CHAR).join('') : s;
36
+
37
+ // Status poll cadence: 12 attempts × 5s = 1 minute total.
38
+ const POLL_INTERVAL_MS = 5000;
39
+ const POLL_ATTEMPTS = 12;
40
+ const GLOB_CHARS = /[*?]/;
41
+ const MAX_PATTERN_LENGTH = 500;
42
+ function patternToRegex(pattern) {
43
+ /* istanbul ignore next: callers (matchesPattern) only ever pass a string, so
44
+ the typeof guard is defensive; the length guard is exercised at runtime by
45
+ the over-long-glob test via the matchesPattern catch below */
46
+ if (typeof pattern !== 'string' || pattern.length > MAX_PATTERN_LENGTH) {
47
+ throw new Error('Invalid pattern: must be a string with max length of 500 characters');
48
+ }
49
+ return globToRegExp(pattern, {
50
+ extended: true,
51
+ globstar: true
52
+ });
53
+ }
54
+ function matchesPattern(str, pattern) {
55
+ if (GLOB_CHARS.test(pattern)) {
56
+ try {
57
+ return patternToRegex(pattern).test(str);
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+ return str === pattern;
63
+ }
64
+
65
+ // Thrown from any pipeline step that wants to fall back to the full snapshot
66
+ // set. The caller in snapshots.js downgrades these to log.info — they're
67
+ // expected, user-visible bail conditions, not crashes.
68
+ export class SmartSnapBailError extends Error {
69
+ constructor(message) {
70
+ super(message);
71
+ this.name = 'SmartSnapBailError';
72
+ }
73
+ }
74
+
75
+ // Any git failure is treated as a recoverable bail (full snapshot fallback),
76
+ // per this module's contract. A common trigger is CI shallow clones where the
77
+ // predicted base commit isn't fetched locally, so `git diff <sha> HEAD` fails —
78
+ // we must downgrade that to a full snapshot, not crash the build. Callers that
79
+ // want a more specific bail message (e.g. lockfile lookup) catch and re-throw.
80
+ function git(args) {
81
+ let res;
82
+ try {
83
+ res = spawnSync('git', args, {
84
+ encoding: 'utf8'
85
+ });
86
+ } catch (e) {
87
+ /* istanbul ignore next: spawnSync only throws on an exec-level failure (e.g.
88
+ git binary missing / ENOMEM), which the test environment can't induce —
89
+ the non-zero-exit bail below is the path real git failures take */
90
+ throw new SmartSnapBailError(`SmartSnap: git ${args.join(' ')} failed to spawn: ${e.message}; running full snapshot set`);
91
+ }
92
+ if (res.status !== 0) {
93
+ /* istanbul ignore next: the stderr||stdout||exit-code fallbacks in the
94
+ message are defensive — git failures in tests always carry stderr */
95
+ throw new SmartSnapBailError(`SmartSnap: git ${args.join(' ')} failed: ${res.stderr || res.stdout || `exit ${res.status}`}; running full snapshot set`);
96
+ }
97
+ return res.stdout;
98
+ }
99
+
100
+ // baseRef flows into `git` argv from either user config (`baseline`) or the
101
+ // API. Reject anything that could be parsed as an option (leading `-`) or
102
+ // contains chars outside the safe ref alphabet — git also accepts `--` as an
103
+ // end-of-options separator in `git diff`, but not before the rev in
104
+ // `git show <rev>:<path>`, so validation is the only universal guard.
105
+ function assertSafeRef(ref) {
106
+ if (typeof ref !== 'string' || !/^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(ref)) {
107
+ throw new SmartSnapBailError(`SmartSnap: unsafe baseline ref "${ref}"; running full snapshot set`);
108
+ }
109
+ }
110
+ function gitDiffNames(ref) {
111
+ assertSafeRef(ref);
112
+ return git(['diff', '--name-only', ref, 'HEAD', '--']).split('\n').filter(Boolean);
113
+ }
114
+ function gitProjectRoot() {
115
+ return git(['rev-parse', '--show-toplevel']).trim();
116
+ }
117
+
118
+ // Paths under these directories are dependencies / framework wiring rather
119
+ // than first-party source, so we don't track them in the file index.
120
+ const EXCLUDED_DIRS = new Set(['node_modules', '.storybook']);
121
+ const isExcluded = relPath => relPath.split(/[/\\]/).some(seg => EXCLUDED_DIRS.has(seg));
122
+
123
+ // Resolve+index used for `id` and `resolvedFrom` only. Converts absolute paths
124
+ // to projectRoot-relative form, then either returns the existing index for that
125
+ // path or assigns the next one (= current map size). Paths inside node_modules
126
+ // or .storybook are returned as the relative string and *not* indexed; modules
127
+ // whose id falls into that bucket are dropped downstream by the
128
+ // "id is string → drop" contract in transformModule.
129
+ function resolveAndIndex(value, fileIndex, projectRoot) {
130
+ const clean = stripNull(value);
131
+ if (!path.isAbsolute(clean)) return clean;
132
+ const rel = path.relative(projectRoot, clean);
133
+ if (isExcluded(rel)) return rel;
134
+ let idx = fileIndex.get(rel);
135
+ if (idx === undefined) {
136
+ idx = fileIndex.size;
137
+ fileIndex.set(rel, idx);
138
+ }
139
+ return idx;
140
+ }
141
+
142
+ // Transform a stats `modules[]` entry into the indexed shape the SmartSnap graph expects.
143
+ // Returns null when the module's id is a string — those entries are dropped (per BE contract).
144
+ // Each `imports[i]` / `passThroughExports[i]` carries `{ type, source }` from the
145
+ // bundler-plugin: for `type === 'src'` we translate the absolute file path to its
146
+ // project-file index when possible; for `type === 'module'` the source is already
147
+ // the bare package name, so we leave it alone.
148
+ function transformModule(m, fileIndex, projectRoot) {
149
+ const out = {};
150
+ if (m.id != null) out.id = resolveAndIndex(m.id, fileIndex, projectRoot);
151
+ if (typeof out.id === 'string') return null;
152
+ const mapEntry = e => {
153
+ const copy = {
154
+ ...e
155
+ };
156
+ if (copy.type === 'src' && typeof copy.source === 'string') {
157
+ copy.source = resolveAndIndex(copy.source, fileIndex, projectRoot);
158
+ }
159
+ return copy;
160
+ };
161
+ if (Array.isArray(m.imports)) out.imports = m.imports.map(mapEntry);
162
+ if (Array.isArray(m.passThroughExports)) out.passThroughExports = m.passThroughExports.map(mapEntry);
163
+ if (Array.isArray(m.nonPassThroughExports)) out.nonPassThroughExports = m.nonPassThroughExports;
164
+ return out;
165
+ }
166
+ function streamModules(filePath, onModule) {
167
+ return new Promise((resolve, reject) => {
168
+ fs.createReadStream(filePath).pipe(parser()).pipe(pick({
169
+ filter: 'modules'
170
+ })).pipe(streamArray()).on('data', ({
171
+ value
172
+ }) => onModule(value)).on('end', resolve).on('error', reject);
173
+ });
174
+ }
175
+ function readTopLevelKey(filePath, key) {
176
+ return new Promise((resolve, reject) => {
177
+ let value;
178
+ fs.createReadStream(filePath).pipe(parser()).pipe(pick({
179
+ filter: key
180
+ })).pipe(streamValues()).on('data', ({
181
+ value: v
182
+ }) => {
183
+ value = v;
184
+ }).on('end', () => resolve(value)).on('error', reject);
185
+ });
186
+ }
187
+ async function readStats(statsFile, projectRoot) {
188
+ const fileIndex = new Map();
189
+ const modules = [];
190
+ await streamModules(statsFile, m => {
191
+ const t = transformModule(m, fileIndex, projectRoot);
192
+ if (t) modules.push(t);
193
+ });
194
+
195
+ // Emit `files` ordered by encounter-time index so files[N] corresponds to
196
+ // every module/source ref that was assigned index N during streaming.
197
+ const files = [...fileIndex.entries()].sort((a, b) => a[1] - b[1]).map(([p]) => p);
198
+
199
+ // The bundler-plugin-smartsnap emits a unique buildId per storybook build
200
+ // so concurrent runs against the same project don't share Redis state.
201
+ const buildId = await readTopLevelKey(statsFile, 'buildId');
202
+ return {
203
+ files,
204
+ modules,
205
+ buildId
206
+ };
207
+ }
208
+
209
+ // Polls `job_status?sync=true&type=smartsnap_graph&id=<buildId>` — the sync
210
+ // response blocks server-side until the job moves off `in_progress`, but the
211
+ // API enforces a shorter timeout than the job can take, so we retry up to
212
+ // POLL_ATTEMPTS times. On `done` the response also carries the graph payload
213
+ // (affected stories + vertices/edges/transitive closure for trace
214
+ // rendering), so the caller reads it directly without a second fetch.
215
+ //
216
+ // Response shape is the unwrapped `{ status, data }` — the smartsnap_graph
217
+ // status response no longer keys the result by buildId.
218
+ async function pollGraphStatus(percy, buildId, log) {
219
+ for (let i = 0; i < POLL_ATTEMPTS; i++) {
220
+ const res = await percy.client.getStatus('smartsnap_graph', [buildId]);
221
+ const status = res === null || res === void 0 ? void 0 : res.status;
222
+ log.debug(`SmartSnap: graph status (attempt ${i + 1}) = ${status}`);
223
+ if (status === 'done' || status === 'failure') return {
224
+ status,
225
+ data: res === null || res === void 0 ? void 0 : res.data
226
+ };
227
+ if (i < POLL_ATTEMPTS - 1) await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
228
+ }
229
+ return {
230
+ status: null
231
+ };
232
+ }
233
+
234
+ // First component: resolve + validate the statsFile path, stream-read it, and
235
+ // assert it carries a usable buildId. Returns { files, modules, buildId }; any
236
+ // problem throws a SmartSnapBailError so the caller falls back to a full set.
237
+ export async function validateAndReadStats(buildDir, statsFile, projectRoot, log) {
238
+ // Treat statsFile as a flat filename anchored inside the build directory.
239
+ // path.basename() strips any traversal segments so the resolved path can't
240
+ // escape buildDir even if the config is hostile.
241
+ const statsName = path.basename(statsFile || 'enriched-stats.json');
242
+ if (!/^[\w.-]+\.json$/i.test(statsName)) {
243
+ throw new SmartSnapBailError(`SmartSnap: invalid statsFile "${statsName}" — must be a .json filename; running full snapshot set`);
244
+ }
245
+ // statsName is path.basename'd and regex-validated above; buildDir is operator-supplied config (reviewed, approved by security)
246
+ const resolvedStatsPath = path.join(path.resolve(buildDir), statsName); // nosemgrep
247
+ let statsStat;
248
+ try {
249
+ statsStat = fs.statSync(resolvedStatsPath);
250
+ } catch {
251
+ throw new SmartSnapBailError(`SmartSnap: stats file "${statsName}" not found in build directory ${buildDir}; running full snapshot set`);
252
+ }
253
+ if (!statsStat.isFile()) {
254
+ throw new SmartSnapBailError(`SmartSnap: stats file "${statsName}" in ${buildDir} is not a regular file; running full snapshot set`);
255
+ }
256
+ log.debug(`SmartSnap: parsing stats file ${resolvedStatsPath}`);
257
+ const {
258
+ files,
259
+ modules,
260
+ buildId
261
+ } = await readStats(resolvedStatsPath, projectRoot);
262
+ if (typeof buildId !== 'string' || !buildId) {
263
+ throw new SmartSnapBailError(`SmartSnap: stats file at ${resolvedStatsPath} is missing a top-level "buildId" — running full snapshot set`);
264
+ }
265
+ return {
266
+ files,
267
+ modules,
268
+ buildId
269
+ };
270
+ }
271
+
272
+ // Resolve the diff base ref + the list of changed files. With an explicit
273
+ // `baseline` we diff against it directly and skip the API base-build lookup
274
+ // entirely (its snapshot map is only consulted on the no-baseline path), so
275
+ // `baselineSnapshots` comes back null in that case.
276
+ export async function getBaselineAndAffectedNodes(percy, baseline, log) {
277
+ let baseRef;
278
+ let baselineSnapshots;
279
+ if (baseline) {
280
+ log.debug(`SmartSnap: diffing against explicit baseline "${baseline}"`);
281
+ baseRef = baseline;
282
+ baselineSnapshots = null;
283
+ } else {
284
+ // New API shape: `{ base_build_commit_sha, snapshots: { <name>: <review_state> } }`.
285
+ // The single base-build commit replaces the previous per-snapshot commit map —
286
+ // baseline prediction now happens server-side via `Percy::BaseBuildService`,
287
+ // so we just diff against whatever commit it picked. The set of snapshot
288
+ // names is no longer sent; the API resolves baselines from the project +
289
+ // git/PR context alone.
290
+ const baseLookup = await percy.client.getSmartsnapSnapshotNameToCommit();
291
+ log.debug(`SmartSnap: base lookup ${JSON.stringify(baseLookup)}`);
292
+ if (!(baseLookup !== null && baseLookup !== void 0 && baseLookup.base_build_commit_sha)) {
293
+ throw new SmartSnapBailError('SmartSnap: API could not predict a base build commit and no explicit baseline was set; running full snapshot set');
294
+ }
295
+ log.debug(`SmartSnap: diffing against predicted base build commit "${baseLookup.base_build_commit_sha}"`);
296
+ baseRef = baseLookup.base_build_commit_sha;
297
+ baselineSnapshots = baseLookup.snapshots || {};
298
+ }
299
+ assertSafeRef(baseRef);
300
+ const affectedNodes = gitDiffNames(baseRef);
301
+ return {
302
+ baseRef,
303
+ affectedNodes,
304
+ baselineSnapshots
305
+ };
306
+ }
307
+
308
+ // A change to anything under `.storybook/` (preview config, addons, manager
309
+ // wiring) can affect every story's render, so the dep graph isn't enough.
310
+ export function assertNoDotStorybookChange(affectedNodes) {
311
+ const dotStorybookHit = affectedNodes.find(p => p.split(/[/\\]/).includes('.storybook'));
312
+ if (dotStorybookHit) {
313
+ throw new SmartSnapBailError(`SmartSnap: change to "${dotStorybookHit}" inside .storybook affects all stories; running full snapshot set`);
314
+ }
315
+ }
316
+
317
+ // Bail to a full snapshot set if any changed file matches a user-supplied
318
+ // bailOnChanges glob.
319
+ export function assertNoBailOnChanges(affectedNodes, bailOnChanges) {
320
+ if (bailOnChanges !== null && bailOnChanges !== void 0 && bailOnChanges.length) {
321
+ const bailed = affectedNodes.find(p => bailOnChanges.some(g => matchesPattern(p, g)));
322
+ if (bailed) {
323
+ throw new SmartSnapBailError(`SmartSnap: change to "${bailed}" matched bailOnChanges; running full snapshot set`);
324
+ }
325
+ }
326
+ }
327
+
328
+ // Drop changed files that match a user-supplied `untraced` glob so they don't
329
+ // drive snapshot selection. Returns the filtered list (unchanged when no
330
+ // untraced patterns are configured).
331
+ export function enforceUntraced(affectedNodes, untraced) {
332
+ if (untraced !== null && untraced !== void 0 && untraced.length) {
333
+ return affectedNodes.filter(p => !untraced.some(g => matchesPattern(p, g)));
334
+ }
335
+ return affectedNodes;
336
+ }
337
+
338
+ // Manifest/lockfile changes can shift the dependency tree, so resolve the
339
+ // diff at the package level via snyk-nodejs-lockfile-parser and return the
340
+ // changed package names to feed back into the graph. Returns [] when there's
341
+ // nothing to resolve; short-circuits below fall back to a full snapshot
342
+ // (via SmartSnapBailError) whenever we can't reason about the change.
343
+ export async function getAffectedPackages(affectedNodes, baseRef, projectRoot, log) {
344
+ const MANIFEST_PATHS = new Set(['package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']);
345
+ const manifestHits = affectedNodes.filter(p => MANIFEST_PATHS.has(path.basename(p)));
346
+ if (manifestHits.length === 0) return [];
347
+
348
+ // Locate the changed manifest's directory from affectedNodes (NOT the git
349
+ // root) — monorepos keep package.json + lockfile inside a workspace dir.
350
+ // If two changes land in different dirs, we'd need per-workspace resolution
351
+ // we don't try to do yet, so bail.
352
+ const uniqueDirs = [...new Set(manifestHits.map(p => path.dirname(p)))];
353
+ if (uniqueDirs.length > 1) {
354
+ throw new SmartSnapBailError(`SmartSnap: manifest changes span multiple directories (${uniqueDirs.join(', ')}); running full snapshot set`);
355
+ }
356
+ const manifestDir = uniqueDirs[0]; // repo-relative; '.' for root
357
+ // manifestDir is derived from git-tracked paths under projectRoot (reviewed, approved by security)
358
+ const absManifestDir = path.resolve(projectRoot, manifestDir); // nosemgrep
359
+
360
+ // Pick the lockfile that lives next to the changed manifest. If two
361
+ // coexist (e.g. a stray package-lock.json next to yarn.lock) we can't
362
+ // pick a canonical source, so bail.
363
+ const LOCKFILE_NAMES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
364
+ // n is from the hardcoded LOCKFILE_NAMES allowlist (reviewed, approved by security)
365
+ const presentLockfiles = LOCKFILE_NAMES.filter(n => fs.existsSync(path.join(absManifestDir, n))); // nosemgrep
366
+ if (presentLockfiles.length === 0) {
367
+ throw new SmartSnapBailError(`SmartSnap: manifest changed in "${manifestDir}" but no lockfile present there; running full snapshot set`);
368
+ }
369
+ if (presentLockfiles.length > 1) {
370
+ throw new SmartSnapBailError(`SmartSnap: multiple lockfiles in "${manifestDir}" (${presentLockfiles.join(', ')}); cannot pick canonical; running full snapshot set`);
371
+ }
372
+ const lockfileName = presentLockfiles[0];
373
+ // git always uses forward slashes for its <rev>:<path> spec; build it by
374
+ // hand instead of path.join (which would use backslashes on Windows).
375
+ const lockfileRepoPath = manifestDir === '.' ? lockfileName : `${manifestDir}/${lockfileName}`;
376
+
377
+ // Resolve the lockfile at the base commit. If it wasn't tracked there
378
+ // (first SmartSnap run, lockfile renamed, etc.) we can't diff, so bail.
379
+ let oldLockfile;
380
+ try {
381
+ oldLockfile = git(['show', `${baseRef}:${lockfileRepoPath}`]);
382
+ } catch {
383
+ throw new SmartSnapBailError(`SmartSnap: lockfile "${lockfileRepoPath}" not present at base ref ${baseRef}; running full snapshot set`);
384
+ }
385
+
386
+ // lockfileName is one of the hardcoded LOCKFILE_NAMES allowlist (reviewed, approved by security)
387
+ const newLockfile = fs.readFileSync(path.join(absManifestDir, lockfileName), 'utf8'); // nosemgrep
388
+
389
+ // Byte-identical lockfile means only package.json's non-dep fields changed —
390
+ // nothing shifted in the dependency tree, so there are no affected packages.
391
+ if (oldLockfile === newLockfile) return [];
392
+
393
+ // joined with the literal 'package.json' under the resolved absManifestDir (reviewed, approved by security)
394
+ const packageJson = fs.readFileSync(path.join(absManifestDir, 'package.json'), 'utf8'); // nosemgrep
395
+ const packageJsonRepoPath = manifestDir === '.' ? 'package.json' : `${manifestDir}/package.json`;
396
+ const oldPackageJson = git(['show', `${baseRef}:${packageJsonRepoPath}`]);
397
+ try {
398
+ const packageAffected = await diffLockfileDeps({
399
+ packageJson,
400
+ oldLockfile,
401
+ newLockfile,
402
+ lockfileType: lockfileName,
403
+ oldPackageJson
404
+ });
405
+ // The two statements below run only on Node >=18 where the snyk parser
406
+ // loads; the CI matrix runs the suite on Node 14, where the await above
407
+ // throws SNYK_LOCKFILE_PARSER_UNAVAILABLE and we take the catch instead.
408
+ /* istanbul ignore next */
409
+ log.debug(`SmartSnap: lockfile diff produced ${packageAffected.length} affected packages: ${packageAffected.join(', ')}`);
410
+ /* istanbul ignore next */
411
+ return packageAffected;
412
+ } catch (e) {
413
+ // snyk-nodejs-lockfile-parser is an optionalDependency (requires Node >=18).
414
+ // When it's missing we can't reason about the manifest change, so we
415
+ // conservatively bail to a full snapshot rather than under-snapshotting.
416
+ /* istanbul ignore else: a non-snyk diff error can only surface on Node >=18 */
417
+ if (e.code === 'SNYK_LOCKFILE_PARSER_UNAVAILABLE') {
418
+ throw new SmartSnapBailError(`SmartSnap: ${e.message}; running full snapshot set`);
419
+ }
420
+ /* istanbul ignore next: non-snyk diff errors only surface on Node >=18 */
421
+ throw e;
422
+ }
423
+ }
424
+
425
+ // Project each snapshot's importPath into the project-root frame and return
426
+ // the unique set. Logs how many snapshots carried an importPath and warns
427
+ // (with a sample) when none do, which usually means broken story extraction.
428
+ export function extractStorybookPaths(snapshots, normalizeImportPath, log) {
429
+ const storybookPaths = [...new Set(snapshots.map(s => normalizeImportPath(s.importPath)).filter(Boolean))];
430
+ const snapshotsWithImportPath = snapshots.filter(s => s.importPath).length;
431
+ log.debug(`SmartSnap: ${snapshotsWithImportPath}/${snapshots.length} snapshots have importPath; ${storybookPaths.length} unique storybookPaths`);
432
+ if (storybookPaths.length === 0) {
433
+ var _snapshots$, _snapshots$2, _snapshots$3;
434
+ log.warn(`SmartSnap: no snapshots have importPath set — check Storybook story extraction. Sample snapshot: ${JSON.stringify({
435
+ id: (_snapshots$ = snapshots[0]) === null || _snapshots$ === void 0 ? void 0 : _snapshots$.id,
436
+ name: (_snapshots$2 = snapshots[0]) === null || _snapshots$2 === void 0 ? void 0 : _snapshots$2.name,
437
+ importPath: (_snapshots$3 = snapshots[0]) === null || _snapshots$3 === void 0 ? void 0 : _snapshots$3.importPath,
438
+ keys: snapshots[0] ? Object.keys(snapshots[0]) : []
439
+ })}`);
440
+ } else {
441
+ log.debug(`SmartSnap: storybookPaths sample: ${storybookPaths.slice(0, 3).join(', ')}`);
442
+ }
443
+ return storybookPaths;
444
+ }
445
+
446
+ // Kick off the graph generation job and poll it to completion, returning the
447
+ // graph payload. The sync status response carries the graph payload directly
448
+ // on completion, so there's no second fetch — the returned `data` is the same
449
+ // response body that used to come from `getSmartsnapGraphData`. Bails to a
450
+ // full snapshot set if the job doesn't reach `done`.
451
+ export async function runGraphGeneration(percy, buildId, payload, log) {
452
+ const {
453
+ files,
454
+ modules,
455
+ storybookPaths,
456
+ affectedNodes
457
+ } = payload;
458
+ log.debug(`SmartSnap: starting graph generation job ${JSON.stringify({
459
+ buildId,
460
+ files,
461
+ modules,
462
+ storybookPaths,
463
+ affectedNodes
464
+ })}`);
465
+ await percy.client.generateSmartsnapGraph(buildId, {
466
+ files,
467
+ modules,
468
+ storybookPaths,
469
+ affectedNodes
470
+ });
471
+ const {
472
+ status,
473
+ data
474
+ } = await pollGraphStatus(percy, buildId, log);
475
+ if (status !== 'done') {
476
+ throw new SmartSnapBailError(`SmartSnap: graph generation did not complete (status: ${status ?? 'timed out'}); running full snapshot set`);
477
+ }
478
+ log.debug(`SmartSnap: affected stories result ${JSON.stringify(data === null || data === void 0 ? void 0 : data.affected_stories)}`);
479
+ return data;
480
+ }
481
+
482
+ // Trace rendering moved client-side: the API now returns the raw graph
483
+ // (vertices, edges, transitive-closure triples) and we populate the bundled
484
+ // HTML template here. Only runs when `trace` is enabled and the payload is
485
+ // complete — anything missing means the BE couldn't produce a graph, so we
486
+ // skip silently rather than write a broken page.
487
+ export function maybeWriteTrace(trace, data, log) {
488
+ 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) {
489
+ const tracePath = path.resolve(process.cwd(), 'trace.html');
490
+ try {
491
+ const html = renderGraphTraceHtml({
492
+ vertices: data.vertices,
493
+ edges: data.edges,
494
+ transitiveClosureMatrixSparse: data.transitive_closure_matrix_sparse
495
+ });
496
+ fs.writeFileSync(tracePath, html);
497
+ log.info(`SmartSnap: trace written to ${tracePath}`);
498
+ } catch (e) {
499
+ log.warn(`SmartSnap: failed to write trace.html: ${e.message}`);
500
+ }
501
+ }
502
+ }
503
+
504
+ // Filter the snapshots down to those the affected-graph reports plus any that
505
+ // need a forced re-snapshot, log the summary, and return the kept list.
506
+ //
507
+ // Snapshots whose baseline review_state is `failed` or `rejected` have no
508
+ // usable baseline image to diff against, and snapshots that don't appear in
509
+ // the base build at all are brand-new (no baseline exists yet). In both cases
510
+ // SmartSnap can't legitimately skip them — re-snapshot unconditionally
511
+ // regardless of what the affected-graph reports. `baselineSnapshots` is null
512
+ // when an explicit baseline is set, but `needsBaselineRefresh` short-circuits
513
+ // on `baseline` before reading it.
514
+ export function selectAffectedSnapshots(snapshots, data, baseline, baselineSnapshots, normalizeImportPath, log) {
515
+ const affected = new Set((data === null || data === void 0 ? void 0 : data.affected_stories) || []);
516
+ const FORCE_RESNAPSHOT_STATES = new Set(['failed', 'rejected']);
517
+ const needsBaselineRefresh = name => {
518
+ if (baseline) return false;
519
+ const state = baselineSnapshots[name];
520
+ return state === undefined || FORCE_RESNAPSHOT_STATES.has(state);
521
+ };
522
+
523
+ // Use the same normalization on lookup so a snapshot's `./src/...` matches
524
+ // an affected-stories `src/...` from the API.
525
+ let forced = 0;
526
+ let affectedKept = 0;
527
+ const filtered = snapshots.filter(s => {
528
+ if (needsBaselineRefresh(s.name)) {
529
+ forced += 1;
530
+ return true;
531
+ }
532
+ const p = normalizeImportPath(s.importPath);
533
+ if (p && affected.has(p)) {
534
+ affectedKept += 1;
535
+ return true;
536
+ }
537
+ return false;
538
+ });
539
+ log.info(`SmartSnap: ${filtered.length} of ${snapshots.length} snapshots kept (${affectedKept} via affected-graph, ${forced} via missing/failed/rejected baseline)`);
540
+ return filtered;
541
+ }
542
+
543
+ // Given the mapped snapshots and storybook.smartSnap config, returns the subset of snapshots
544
+ // that the SmartSnap graph reports as affected. On any recoverable failure, returns the input
545
+ // list unchanged so the build runs as a full snapshot pass.
546
+ export async function applySmartSnap(percy, snapshots, smartSnapConfig, buildDir) {
547
+ const log = logger('storybook:smartsnap');
548
+ const {
549
+ baseline,
550
+ untraced,
551
+ trace,
552
+ bailOnChanges,
553
+ statsFile
554
+ } = smartSnapConfig || {};
555
+ if (!buildDir) {
556
+ throw new SmartSnapBailError('SmartSnap requires the Storybook build directory (e.g. `percy storybook ./storybook-static`); URL and `start` modes are not supported. Running full snapshot set');
557
+ }
558
+ const projectRoot = gitProjectRoot();
559
+ const {
560
+ files,
561
+ modules,
562
+ buildId
563
+ } = await validateAndReadStats(buildDir, statsFile, projectRoot, log);
564
+ let {
565
+ baseRef,
566
+ affectedNodes,
567
+ baselineSnapshots
568
+ } = await getBaselineAndAffectedNodes(percy, baseline, log);
569
+ assertNoDotStorybookChange(affectedNodes);
570
+ assertNoBailOnChanges(affectedNodes, bailOnChanges);
571
+ affectedNodes = enforceUntraced(affectedNodes, untraced);
572
+ const packageAffectedNodes = await getAffectedPackages(affectedNodes, baseRef, projectRoot, log);
573
+
574
+ // With no traced files and no package-level changes there's nothing for the
575
+ // graph to reason about — bail to a full snapshot set rather than send an
576
+ // empty diff.
577
+ if (!affectedNodes.length && !packageAffectedNodes.length) {
578
+ throw new SmartSnapBailError('SmartSnap: no affected files or packages detected after filtering; running full snapshot set');
579
+ }
580
+
581
+ // Storybook's `entries[id].importPath` (and v6 `parameters.fileName`)
582
+ // is resolved relative to the directory percy was invoked from — for a
583
+ // monorepo storybook that's typically the package dir (e.g.
584
+ // `frontend/packages/design-stack`), not the git root. Example:
585
+ // `./modules/AgentCard/AgentCard.stories.tsx`.
586
+ //
587
+ // `affectedNodes` from `git diff --name-only` and the `files` array
588
+ // built by the stats compactor are both project-root-relative
589
+ // (e.g. `packages/design-stack/modules/AgentCard/AgentCard.stories.tsx`).
590
+ // Project them into the same frame so the BE matches importPath against
591
+ // affectedNodes without a cross-frame translation.
592
+ const dotPosix = './';
593
+ const dotPlatform = `.${path.sep}`;
594
+ const invocationDir = process.cwd();
595
+ const normalizeImportPath = p => {
596
+ if (typeof p !== 'string' || !p) return p;
597
+ let rel = p;
598
+ /* istanbul ignore next: on POSIX CI dotPlatform === dotPosix ('./'), so the
599
+ dotPosix else-if (a Windows-only '.\\' case) is unreachable there; ignore
600
+ the whole prefix-strip chain rather than a single dead else-if branch */
601
+ if (rel.startsWith(dotPlatform)) rel = rel.slice(dotPlatform.length);else if (rel.startsWith(dotPosix)) rel = rel.slice(dotPosix.length);
602
+ // If the importPath happens to be absolute (older Storybook configs),
603
+ // path.resolve treats it as the target directly; otherwise it's joined
604
+ // against `invocationDir`. Then re-base against the git project root.
605
+ // rel comes from build stats file paths, re-based against projectRoot on the next line (reviewed, approved by security)
606
+ const abs = path.resolve(invocationDir, rel); // nosemgrep
607
+ const projRel = path.relative(projectRoot, abs);
608
+ // path.relative('','') → '' and `path.relative` produces backslashes
609
+ // on Windows; the stats `files` array uses the same — leave platform
610
+ // sep alone so the two stay byte-identical for the BE match.
611
+ /* istanbul ignore next: the `|| rel` fallback only triggers when an importPath
612
+ resolves exactly to projectRoot (projRel === ''), an edge that's unstable to
613
+ reproduce across OS symlink/tmpdir differences */
614
+ return projRel || rel;
615
+ };
616
+ const storybookPaths = extractStorybookPaths(snapshots, normalizeImportPath, log);
617
+
618
+ /* istanbul ignore next: packageAffectedNodes is only non-empty on Node >=18
619
+ (the snyk lockfile diff); on the Node-14 CI getAffectedPackages either
620
+ returns [] or bails before yielding packages, so this never runs there */
621
+ if (packageAffectedNodes.length) {
622
+ affectedNodes = [...affectedNodes, ...packageAffectedNodes];
623
+ }
624
+ const data = await runGraphGeneration(percy, buildId, {
625
+ files,
626
+ modules,
627
+ storybookPaths,
628
+ affectedNodes
629
+ }, log);
630
+ maybeWriteTrace(trace, data, log);
631
+ return selectAffectedSnapshots(snapshots, data, baseline, baselineSnapshots, normalizeImportPath, log);
632
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/cli-command",
3
- "version": "1.32.0-beta.5",
3
+ "version": "1.32.0-beta.7",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,6 +27,7 @@
27
27
  ".": "./dist/index.js",
28
28
  "./flags": "./dist/flags.js",
29
29
  "./utils": "./dist/utils.js",
30
+ "./smartsnap": "./dist/smartsnap.js",
30
31
  "./test/helpers": "./test/helpers.js"
31
32
  },
32
33
  "scripts": {
@@ -36,9 +37,14 @@
36
37
  "test:coverage": "yarn test --coverage"
37
38
  },
38
39
  "dependencies": {
39
- "@percy/config": "1.32.0-beta.5",
40
- "@percy/core": "1.32.0-beta.5",
41
- "@percy/logger": "1.32.0-beta.5"
40
+ "@percy/config": "1.32.0-beta.7",
41
+ "@percy/core": "1.32.0-beta.7",
42
+ "@percy/logger": "1.32.0-beta.7",
43
+ "glob-to-regexp": "^0.4.1",
44
+ "stream-json": "^1.8.0"
42
45
  },
43
- "gitHead": "d93415f2cc83533c9c037579e440c6e2a5119920"
46
+ "optionalDependencies": {
47
+ "snyk-nodejs-lockfile-parser": "2.7.1"
48
+ },
49
+ "gitHead": "9f25a93344237c69c9b0d104de0208857d4c8981"
44
50
  }