checkly 8.19.0 → 8.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/ai-context/context.d.ts +0 -6
  2. package/dist/ai-context/context.js +0 -9
  3. package/dist/ai-context/context.js.map +1 -1
  4. package/dist/ai-context/public-skills/checkly/SKILL.md +2 -2
  5. package/dist/ai-context/skills-command/references/configure-playwright-checks.md +1 -0
  6. package/dist/ai-context/skills-command/references/configure.md +1 -4
  7. package/dist/ai-context/skills-command/references/initialize.md +1 -1
  8. package/dist/ai-context/skills-command/references/investigate-test-sessions.md +1 -1
  9. package/dist/ai-context/skills-command/references/manage-plan.md +1 -1
  10. package/dist/ai-context/skills-command/references/manage.md +3 -3
  11. package/dist/commands/account/plan.d.ts +13 -0
  12. package/dist/commands/account/plan.js +3 -2
  13. package/dist/commands/account/plan.js.map +1 -1
  14. package/dist/commands/debug/parse-project.js +3 -1
  15. package/dist/commands/debug/parse-project.js.map +1 -1
  16. package/dist/commands/deploy.js +3 -1
  17. package/dist/commands/deploy.js.map +1 -1
  18. package/dist/commands/pw-test.js +3 -1
  19. package/dist/commands/pw-test.js.map +1 -1
  20. package/dist/commands/test.js +3 -1
  21. package/dist/commands/test.js.map +1 -1
  22. package/dist/formatters/account-plan.d.ts +2 -2
  23. package/dist/formatters/account-plan.js +6 -6
  24. package/dist/formatters/account-plan.js.map +1 -1
  25. package/dist/rest/account-members.d.ts +3 -3
  26. package/dist/rest/accounts.d.ts +2 -2
  27. package/dist/rest/api.d.ts +1 -1
  28. package/dist/rest/batch-analytics.d.ts +1 -1
  29. package/dist/rest/cancel.d.ts +6 -2
  30. package/dist/rest/check-groups.d.ts +1 -1
  31. package/dist/rest/check-results.d.ts +2 -2
  32. package/dist/rest/check-statuses.d.ts +2 -2
  33. package/dist/rest/checkly-storage.d.ts +5 -3
  34. package/dist/rest/checks.d.ts +3 -3
  35. package/dist/rest/entitlements.d.ts +1 -1
  36. package/dist/rest/environment-variables.d.ts +14 -5
  37. package/dist/rest/error-groups.d.ts +2 -2
  38. package/dist/rest/heartbeat-checks.d.ts +1 -1
  39. package/dist/rest/locations.d.ts +1 -1
  40. package/dist/rest/private-locations.d.ts +1 -1
  41. package/dist/rest/projects.d.ts +11 -11
  42. package/dist/rest/rca.d.ts +3 -3
  43. package/dist/rest/test-session-error-groups.d.ts +1 -1
  44. package/dist/rest/test-sessions.d.ts +9 -9
  45. package/dist/rest/users.d.ts +1 -1
  46. package/dist/services/check-parser/bundler.d.ts +2 -1
  47. package/dist/services/check-parser/bundler.js +112 -17
  48. package/dist/services/check-parser/bundler.js.map +1 -1
  49. package/dist/services/check-parser/cache-hash.d.ts +29 -1
  50. package/dist/services/check-parser/cache-hash.js +30 -1
  51. package/dist/services/check-parser/cache-hash.js.map +1 -1
  52. package/dist/services/check-parser/parser.d.ts +18 -0
  53. package/dist/services/check-parser/parser.js +9 -2
  54. package/dist/services/check-parser/parser.js.map +1 -1
  55. package/dist/services/check-parser/playwright-config-expander.d.ts +10 -1
  56. package/dist/services/check-parser/playwright-config-expander.js +87 -8
  57. package/dist/services/check-parser/playwright-config-expander.js.map +1 -1
  58. package/dist/services/checkly-config-loader.d.ts +28 -0
  59. package/dist/services/checkly-config-loader.js +10 -0
  60. package/dist/services/checkly-config-loader.js.map +1 -1
  61. package/dist/services/playwright-config.d.ts +14 -0
  62. package/dist/services/playwright-config.js +62 -9
  63. package/dist/services/playwright-config.js.map +1 -1
  64. package/dist/services/playwright-project-bundler.js +42 -5
  65. package/dist/services/playwright-project-bundler.js.map +1 -1
  66. package/dist/services/symlink-resolver.d.ts +56 -0
  67. package/dist/services/symlink-resolver.js +860 -0
  68. package/dist/services/symlink-resolver.js.map +1 -0
  69. package/oclif.manifest.json +447 -447
  70. package/package.json +14 -14
  71. package/dist/ai-context/skills-command/references/configure-agentic-checks.md +0 -65
@@ -0,0 +1,860 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import Debug from 'debug';
4
+ import { glob } from 'glob';
5
+ import { minimatch } from 'minimatch';
6
+ import { pathToPosix } from './util.js';
7
+ const debug = Debug('checkly:cli:services:symlink-resolver');
8
+ const NODE_MODULES = 'node_modules';
9
+ const PNPM_STORE = '.pnpm';
10
+ const BIN_DIR = '.bin';
11
+ /**
12
+ * pnpm records the state of a node_modules directory in these files. They must
13
+ * never end up in a code bundle: on the runner the store directory differs from
14
+ * the one they name, and pnpm reacts by purging node_modules entirely — without
15
+ * prompting when CI is set.
16
+ */
17
+ const PACKAGE_MANAGER_STATE_FILES = new Set([
18
+ '.modules.yaml',
19
+ '.pnpm-workspace-state.json',
20
+ '.pnpm-workspace-state-v1.json',
21
+ ]);
22
+ /**
23
+ * Turns the paths matched by the include globs into archive entries, resolving
24
+ * symlinks so that the resulting archive is both extractable and usable.
25
+ *
26
+ * Two problems make this necessary.
27
+ *
28
+ * Extractability: glob's `nodir` option filters on lstat, so a symlink pointing
29
+ * at a directory is reported as if it were a regular file, while glob separately
30
+ * walks *through* that same symlink and reports the files beneath it. Archiving
31
+ * both yields a path that is simultaneously a symlink and a directory, which tar
32
+ * refuses to extract. Package managers that link packages out of a shared store
33
+ * (pnpm) make that the ordinary shape of node_modules.
34
+ *
35
+ * Usability: simply dereferencing the symlink does not work either. Under pnpm a
36
+ * package's dependencies are siblings of it inside the store, not children, so a
37
+ * flattened copy of node_modules/<pkg> cannot resolve anything it depends on.
38
+ *
39
+ * The archive therefore keeps symlinks as symlinks and brings their targets
40
+ * along, which reproduces the layout the package manager built. The invariant
41
+ * that makes it extractable: an entry is either a symlink, which by construction
42
+ * never has children, or a regular file at a symlink-free archive path.
43
+ */
44
+ export async function resolveBundleFiles(options) {
45
+ const resolver = new SymlinkResolver(options);
46
+ return await resolver.resolve(options.matchedPaths, options.referencedPaths ?? [], options.workspaceMembers ?? []);
47
+ }
48
+ class SymlinkResolver {
49
+ /**
50
+ * Candidate archive roots. A path may be expressed either lexically (as the
51
+ * include globs produced it) or canonically (as realpath produced it), and on
52
+ * macOS those differ whenever the project sits under a symlinked prefix such
53
+ * as /tmp. Both spellings must map to the same archive path.
54
+ */
55
+ #roots = [];
56
+ #ignoreCwd;
57
+ #ignorePatterns;
58
+ /** Archive entries, keyed by archive path. */
59
+ #entries = new Map();
60
+ /** Paths already classified. */
61
+ #classified = new Set();
62
+ /** Paths the include globs matched outright, as opposed to ones we followed to. */
63
+ #directPaths = new Set();
64
+ /** Real directories already expanded. */
65
+ #expanded = new Set();
66
+ /**
67
+ * Store directories whose dependency links have been collected. Without this,
68
+ * a dependency cycle — which pnpm stores have whenever two packages depend on
69
+ * each other, as they routinely do — would recurse until the process dies, and
70
+ * even an acyclic graph would be walked once per distinct path through it.
71
+ */
72
+ #closed = new Set();
73
+ /** Workspace member package names, keyed by canonical member directory. */
74
+ #memberNames = new Map();
75
+ /** Out-of-root directories already copied, and where each one landed. */
76
+ #copiedTrees = new Map();
77
+ #lstatCache = new Map();
78
+ #warned = new Set();
79
+ constructor(options) {
80
+ this.#ignoreCwd = options.ignoreCwd;
81
+ this.#ignorePatterns = options.ignorePatterns;
82
+ this.#roots = [options.bundleRoot];
83
+ }
84
+ async resolve(matchedPaths, referencedPaths, workspaceMembers) {
85
+ const [bundleRoot] = this.#roots;
86
+ // The canonical root is what real paths are measured against; the lexical
87
+ // root is what the include globs produced. Keep both, or a project reached
88
+ // through a symlink would treat every real path as being outside the root.
89
+ try {
90
+ const realRoot = await fs.realpath(bundleRoot);
91
+ if (realRoot !== bundleRoot) {
92
+ this.#roots.push(realRoot);
93
+ }
94
+ }
95
+ catch {
96
+ // Root does not exist; nothing can be inside it anyway.
97
+ }
98
+ // Member paths are stored canonically: the workspace model records them as
99
+ // given (sometimes lexical), while the link targets they are compared with
100
+ // arrive here as realpaths.
101
+ for (const member of workspaceMembers) {
102
+ this.#memberNames.set(await this.#realpath(member.path) ?? member.path, member.name);
103
+ }
104
+ // Which paths the include globs matched is a property of the path, not of
105
+ // when it happens to be reached: expansion can arrive at a directly-matched
106
+ // file first, and it must not then be judged by rules the glob already
107
+ // applied to it.
108
+ for (const matchedPath of matchedPaths) {
109
+ this.#directPaths.add(matchedPath);
110
+ }
111
+ for (const matchedPath of matchedPaths) {
112
+ await this.#classify(matchedPath);
113
+ }
114
+ for (const referencedPath of referencedPaths) {
115
+ await this.#carryReferencedLinks(referencedPath);
116
+ }
117
+ this.#pruneSymlinks();
118
+ return Array.from(this.#entries.values());
119
+ }
120
+ /**
121
+ * Emits a symlink entry for every link a referenced path traverses, so the
122
+ * path resolves in the extracted archive exactly as spelled. Content is not
123
+ * this method's concern — whoever referenced the path also discovers and
124
+ * bundles what it points at (at real paths). Only the links travel here.
125
+ *
126
+ * The walk mirrors #classify's first-symlink rule: find the first symlinked
127
+ * component, emit it, jump into the target's real namespace, and continue —
128
+ * so every emitted link sits at a symlink-free archive path of its own and
129
+ * the extractability invariant holds by construction.
130
+ */
131
+ async #carryReferencedLinks(referencedPath) {
132
+ // Emissions are buffered until the whole walk succeeds. When a later hop
133
+ // leaves the bundle root, discovery has fallen back to bundling the content
134
+ // at the spelled path — real directories — and an already-emitted earlier
135
+ // link would then sit above those very directories, guaranteeing its own
136
+ // removal (and a spurious warning) at the bundler.
137
+ const chain = [];
138
+ let current = referencedPath;
139
+ for (;;) {
140
+ const symlink = await this.#firstSymlinkComponent(current);
141
+ if (symlink === undefined) {
142
+ break;
143
+ }
144
+ const archivePath = this.#archivePathOf(symlink);
145
+ if (archivePath === undefined || archivePath === '') {
146
+ // The "link" is the bundle root itself — the whole project is reached
147
+ // through a symlink, which the two-root reconciliation already absorbs.
148
+ // The root is not an entry; emitting one at the empty name would abort
149
+ // the archive.
150
+ return;
151
+ }
152
+ const target = await this.#realpath(symlink);
153
+ if (target === undefined) {
154
+ // Broken; the reference cannot resolve locally either.
155
+ this.#skipDanglingSymlink(symlink);
156
+ return;
157
+ }
158
+ if (this.#archivePathOf(target) === undefined) {
159
+ // The reference's content is outside the bundle root: discovery bundles
160
+ // it at the spelled path (or errors), so the spelled tree extracts as
161
+ // ordinary directories and no link entry is wanted anywhere along the
162
+ // spelling.
163
+ return;
164
+ }
165
+ chain.push([symlink, target]);
166
+ if (current === symlink) {
167
+ break;
168
+ }
169
+ current = path.join(target, path.relative(symlink, current));
170
+ }
171
+ for (const [symlink, target] of chain) {
172
+ this.#emitSymlink(symlink, target);
173
+ // Marked separately: the same link may already be in the archive because
174
+ // an include pattern matched it, and being referenced is a property of
175
+ // the link, not of which pass got to it first.
176
+ this.#markLinkReferenced(symlink);
177
+ }
178
+ }
179
+ /**
180
+ * Enforces, over the finished set of entries, the two things a symlink entry
181
+ * must satisfy. Doing it here rather than at each emit is what makes it hold
182
+ * regardless of the order paths happened to be classified in.
183
+ *
184
+ * A symlink must have nothing beneath it: one path cannot be both a symlink
185
+ * and a directory, and tar refuses to extract an archive claiming otherwise —
186
+ * the whole reason this resolver exists. Where entries did land beneath a link,
187
+ * the link is what goes: the entries are real content and extract as ordinary
188
+ * files, whereas the link would take the archive down with it.
189
+ *
190
+ * A symlink must also point at something the archive contains, or it extracts
191
+ * into a link to nothing and the check fails at run time. Dropping one link can
192
+ * empty out the directory another points at, so this repeats until it settles.
193
+ */
194
+ #pruneSymlinks() {
195
+ for (;;) {
196
+ // Every path the extracted archive will contain: each entry, and every
197
+ // directory tar has to create on the way to it. Symlink entries count —
198
+ // tar materializes their parent directories exactly as it does a file's —
199
+ // so a link onto a directory holding nothing but other links still
200
+ // resolves.
201
+ const occupied = new Set();
202
+ for (const archivePath of this.#entries.keys()) {
203
+ occupied.add(archivePath);
204
+ for (let parent = path.posix.dirname(archivePath); parent !== '.' && parent !== '/' && parent !== '' && !occupied.has(parent); parent = path.posix.dirname(parent)) {
205
+ occupied.add(parent);
206
+ }
207
+ }
208
+ let pruned = false;
209
+ for (const [archivePath, file] of Array.from(this.#entries)) {
210
+ if (file.symlinkTarget === undefined) {
211
+ continue;
212
+ }
213
+ const hasChildren = Array.from(this.#entries.keys())
214
+ .some(other => other.startsWith(`${archivePath}/`));
215
+ const target = resolveArchivePath(archivePath, file.symlinkTarget);
216
+ // A link onto the archive root always resolves; the root is not an
217
+ // entry. A link carried for a referenced path resolves too: its target
218
+ // content is bundled by the parser, which this resolver cannot see.
219
+ const resolves = target === ''
220
+ || occupied.has(target)
221
+ || file.referencedLink === true;
222
+ if (hasChildren || !resolves) {
223
+ debug(`Dropping symlink ${archivePath}: ${hasChildren ? 'has children' : 'target is not bundled'}`);
224
+ this.#entries.delete(archivePath);
225
+ pruned = true;
226
+ }
227
+ }
228
+ if (!pruned) {
229
+ return;
230
+ }
231
+ }
232
+ }
233
+ /**
234
+ * Decides how a single matched path is represented in the archive. Everything
235
+ * hinges on the *first* symlinked component of the path: it alone determines
236
+ * the mode, which is what guarantees that entries never end up beneath a
237
+ * symlink entry.
238
+ *
239
+ */
240
+ async #classify(matchedPath) {
241
+ if (this.#classified.has(matchedPath)) {
242
+ return;
243
+ }
244
+ this.#classified.add(matchedPath);
245
+ if (isPackageManagerStateFile(matchedPath)) {
246
+ // Reachable when an include pattern names the file outright, since a
247
+ // literal dot segment matches even though wildcards do not.
248
+ debug(`Refusing to bundle package manager state file ${matchedPath}`);
249
+ return;
250
+ }
251
+ // The include glob already applied the ignore patterns to what it matched,
252
+ // in its own cwd namespace. Re-deciding those here would reinterpret the
253
+ // user's patterns in a different namespace and could drop files the glob
254
+ // deliberately kept. Content this resolver reached by itself, on the other
255
+ // hand, the glob never saw — and it is the only content that needs checking.
256
+ if (!this.#directPaths.has(matchedPath) && this.#isIgnored(matchedPath)) {
257
+ return;
258
+ }
259
+ const archivePath = this.#archivePathOf(matchedPath);
260
+ if (archivePath === undefined) {
261
+ // Include patterns may be absolute, and a Playwright config may live
262
+ // outside the workspace root, so a matched path is not guaranteed to sit
263
+ // under it. Such a path has no archive path relative to the root, and
264
+ // therefore nothing a symlink could point at. Leave the archive path unset
265
+ // and let the bundler name it exactly as it did before.
266
+ this.#emit(matchedPath, {
267
+ filePath: matchedPath,
268
+ physical: true,
269
+ });
270
+ return;
271
+ }
272
+ const symlink = await this.#firstSymlinkComponent(matchedPath);
273
+ if (symlink === undefined) {
274
+ this.#emitFile(matchedPath, archivePath);
275
+ return;
276
+ }
277
+ await this.#handleSymlink(symlink, matchedPath);
278
+ }
279
+ /**
280
+ * Walks the path from the archive root downwards and returns the first
281
+ * component that is a symlink. Components above the root are never examined —
282
+ * a symlinked root is simply the root.
283
+ */
284
+ async #firstSymlinkComponent(target) {
285
+ const root = this.#rootOf(target);
286
+ if (root === undefined) {
287
+ return undefined;
288
+ }
289
+ const relative = path.relative(root, target);
290
+ let current = root;
291
+ for (const segment of relative.split(path.sep)) {
292
+ current = path.join(current, segment);
293
+ const stats = await this.#lstat(current);
294
+ if (stats?.isSymbolicLink()) {
295
+ return current;
296
+ }
297
+ }
298
+ return undefined;
299
+ }
300
+ async #handleSymlink(symlink, matchedPath) {
301
+ const target = await this.#realpath(symlink);
302
+ if (target === undefined) {
303
+ this.#skipDanglingSymlink(symlink);
304
+ return;
305
+ }
306
+ const targetArchivePath = this.#archivePathOf(target);
307
+ if (targetArchivePath === undefined) {
308
+ const symlinkArchivePath = this.#archivePathOf(symlink);
309
+ if (symlinkArchivePath !== undefined && this.#isIgnoredArchivePathOrContents(symlinkArchivePath)) {
310
+ // The escape hatch: the user excluded the link itself.
311
+ this.#warnOnce(symlink, `${symlink} is excluded from the bundle by the ignore patterns. Skipping the symlink.`);
312
+ return;
313
+ }
314
+ // For node_modules shapes the user matched outright — a package link, or
315
+ // a node_modules directory itself linked elsewhere (a cache volume, a
316
+ // relocated virtual store) — the old behaviour of silently flattening the
317
+ // target's contents produced bundles that only half-worked: a pnpm
318
+ // package's dependencies are its store siblings, which never came along.
319
+ // Fail loudly instead. The error is reserved for what the include
320
+ // patterns named directly: a link this resolver reached on its own (a
321
+ // store sibling pointing out of the project, say) must not turn a
322
+ // previously-bundling project into a hard failure.
323
+ const isNodeModulesShape = isInsideNodeModules(symlink) || path.basename(symlink) === NODE_MODULES;
324
+ if (isNodeModulesShape && this.#directPaths.has(matchedPath)) {
325
+ throw new Error(`${symlink} points at ${target}, which is outside the project's bundle root `
326
+ + `(${this.#roots[0]}). Files outside it cannot be included in the code bundle. The `
327
+ + `bundle root is your workspace root, or the nearest package.json directory when the `
328
+ + `project is not part of a workspace — if the target belongs to your monorepo, make `
329
+ + `sure the package containing your Checkly config is listed in the workspace `
330
+ + `configuration. Otherwise, move the target inside the project, exclude the symlink `
331
+ + `via ignoreDirectoriesMatch, or narrow your include patterns.`);
332
+ }
333
+ // A plain file or asset-directory link has no such failure mode: copying
334
+ // the bytes to the spelled path produces a complete, working bundle, as
335
+ // it always has. For node_modules shapes reached indirectly the copy is
336
+ // the best available fallback — say what it cannot deliver.
337
+ this.#warnOnce(symlink, isNodeModulesShape
338
+ ? `${symlink} is linked from outside the project. Its contents will be bundled, but its `
339
+ + `dependencies cannot be, so it may fail to resolve them when the check runs.`
340
+ : `${symlink} points outside the project. Bundling its contents instead of the symlink.`);
341
+ await this.#copyOutOfRootLink(matchedPath);
342
+ return;
343
+ }
344
+ if (this.#isIgnored(target)) {
345
+ // The ignore patterns exclude what this link points at, so its target will
346
+ // not be in the archive. Keeping the link would extract to a link pointing
347
+ // at nothing, which fails at run time rather than here.
348
+ this.#warnOnce(symlink, `${symlink} points at ${target}, which is excluded from the bundle. Skipping the symlink.`);
349
+ return;
350
+ }
351
+ this.#emitSymlink(symlink, target);
352
+ const stats = await this.#statThroughLink(symlink);
353
+ if (stats === undefined) {
354
+ return;
355
+ }
356
+ if (!stats.isDirectory()) {
357
+ // A symlink to a file cannot have children, so it is safe to keep as a
358
+ // link. Its target still has to be in the archive for it to resolve.
359
+ this.#emitFile(target, targetArchivePath);
360
+ return;
361
+ }
362
+ const isPackageLink = isInsideNodeModules(symlink);
363
+ // A workspace member reached through a package link is handled selectively,
364
+ // the way the CLI treats every other workspace dependency: the link travels,
365
+ // the member's manifest travels, whatever the include patterns matched
366
+ // through the link travels — and the rest of the member's content is the
367
+ // import parser's business, not a wholesale directory copy.
368
+ //
369
+ // Three qualifiers, each load-bearing:
370
+ // - The store-shape check comes first: a pnpm store can live inside a member
371
+ // directory, and store packages need the expansion and sibling-closure
372
+ // treatment no matter where the store sits.
373
+ // - The target must be the member directory itself. A link into a member's
374
+ // subdirectory (`link:./packages/x/dist`) names content the parser will
375
+ // never bundle, so it keeps expansion.
376
+ // - The link's node_modules name must equal the member's package name. The
377
+ // parser resolves workspace dependencies by import specifier, so an
378
+ // aliased dependency (`"ui": "file:../ui"` for a package named @scope/ui)
379
+ // is invisible to it — selective treatment would ship an empty package.
380
+ // The member branch is reserved for links the include patterns matched
381
+ // (directly, or by matching files through them): those express user intent
382
+ // the parser complements. A member link this resolver reached on its own —
383
+ // a pnpm store package depending on a workspace member — has no parser
384
+ // coverage at all (the parser never reads store-internal code), so it keeps
385
+ // whole-target expansion below.
386
+ if (isPackageLink
387
+ && !this.#isPnpmStoreLocation(target)
388
+ && this.#memberNames.has(target)
389
+ && this.#directPaths.has(matchedPath)) {
390
+ if (this.#linkName(symlink) === this.#memberNames.get(target)) {
391
+ // The emitted manifest is also what keeps the link alive: it occupies
392
+ // the target, so the prune pass sees the link as resolvable. If the
393
+ // manifest cannot be emitted, the prune drops the link rather than
394
+ // shipping it dangling.
395
+ await this.#emitMemberPackageJson(target);
396
+ if (matchedPath === symlink) {
397
+ // The include pattern named this link outright, but a workspace
398
+ // member travels selectively — a silent narrowing worth surfacing,
399
+ // since include exists for assets the import parser cannot see.
400
+ this.#warnOnce(`${symlink}\0member`, `${symlink} resolves to the workspace package at ${target}. Only its manifest and `
401
+ + `files reached through imports or matching include patterns are bundled. To bundle `
402
+ + `other files from it, add include patterns for its own path.`);
403
+ }
404
+ if (matchedPath !== symlink) {
405
+ await this.#classify(path.join(target, path.relative(symlink, matchedPath)));
406
+ }
407
+ return;
408
+ }
409
+ }
410
+ // Expanding the target subtree is what puts the package's own files in the
411
+ // archive. Do it when the pattern matched the link itself, and for package
412
+ // links whatever the pattern's shape — `node_modules/pkg/**/*` matches only
413
+ // files *beneath* the link, and those files alone are not enough to run.
414
+ //
415
+ // Do NOT expand unconditionally: for a plain directory symlink that would
416
+ // drag in the whole target whenever a pattern merely matched something
417
+ // inside it, so `assets/**/*.png` with a symlinked `assets` would bundle the
418
+ // entire directory rather than the images.
419
+ //
420
+ // A link that points at one of its own ancestors is never expanded. pnpm
421
+ // creates one for a package that depends on itself (`file:.`), giving
422
+ // node_modules/<name> -> .., and expanding that would walk the whole project
423
+ // and bundle every file the include patterns deliberately left out.
424
+ const pointsAtAncestor = isInside(target, symlink);
425
+ if ((matchedPath === symlink || isPackageLink) && !pointsAtAncestor) {
426
+ await this.#expand(target);
427
+ }
428
+ if (isPackageLink && !pointsAtAncestor) {
429
+ await this.#addDependencyClosure(target);
430
+ }
431
+ if (matchedPath !== symlink) {
432
+ // Re-express the matched path in the real namespace and classify it there.
433
+ // Its remaining components may contain symlinks of their own; each ends up
434
+ // at its own real path, never nested under this link.
435
+ await this.#classify(path.join(target, path.relative(symlink, matchedPath)));
436
+ }
437
+ // Whether this link survives — whether its target contributed anything, and
438
+ // whether anything landed beneath the link itself — is only knowable once
439
+ // every path has been classified. #pruneSymlinks decides that at the end.
440
+ }
441
+ /**
442
+ * The name the link resolves as at run time — its path under the enclosing
443
+ * node_modules directory. When this equals the target package's declared
444
+ * name, the import parser can resolve the package; that equality is the
445
+ * member branch's precondition.
446
+ */
447
+ #linkName(symlink) {
448
+ const nodeModules = enclosingNodeModules(symlink);
449
+ if (nodeModules === undefined) {
450
+ return undefined;
451
+ }
452
+ return pathToPosix(path.relative(nodeModules, symlink));
453
+ }
454
+ /**
455
+ * Copies an out-of-root link's content to the archive at the spelled path,
456
+ * where it extracts as ordinary files. Nested directory links recurse
457
+ * (anything they point at is out of root as well); the ancestor set cuts
458
+ * cycles.
459
+ */
460
+ async #copyOutOfRootLink(matchedPath) {
461
+ const archivePath = this.#archivePathOf(matchedPath);
462
+ if (archivePath === undefined) {
463
+ return;
464
+ }
465
+ const stats = await this.#statThroughLink(matchedPath);
466
+ if (stats === undefined) {
467
+ // Dangling somewhere along the way; nothing to copy.
468
+ return;
469
+ }
470
+ if (!stats.isDirectory()) {
471
+ // The bytes are readable straight through the link at the matched path,
472
+ // which is exactly where they belong in the archive.
473
+ this.#emitFile(matchedPath, archivePath);
474
+ return;
475
+ }
476
+ // The matched path itself may be a nested directory link inside the
477
+ // out-of-root tree (glob reports such links as files); its contents belong
478
+ // at its archive path just like the top link's do.
479
+ await this.#copyTree(matchedPath, archivePath, new Set());
480
+ }
481
+ async #copyTree(directory, archiveDirectory, ancestors) {
482
+ const real = await this.#realpath(directory);
483
+ if (real === undefined || ancestors.has(real)) {
484
+ return;
485
+ }
486
+ // A directory reachable by more than one route is copied once; every later
487
+ // route becomes a link to the first copy. Re-copying per route would take
488
+ // time exponential in the depth of a link fan-out. If files also arrive
489
+ // beneath a later route, that link gains children and the prune pass drops
490
+ // it in favour of them.
491
+ const copied = this.#copiedTrees.get(real);
492
+ if (copied !== undefined) {
493
+ if (copied !== archiveDirectory) {
494
+ this.#emitSymlinkEntry(directory, archiveDirectory, copied);
495
+ }
496
+ return;
497
+ }
498
+ this.#copiedTrees.set(real, archiveDirectory);
499
+ const visited = new Set(ancestors).add(real);
500
+ for (const entry of await this.#enumerate(real)) {
501
+ const archivePath = path.posix.join(archiveDirectory, pathToPosix(path.relative(real, entry)));
502
+ if (this.#isIgnoredArchivePath(archivePath)) {
503
+ continue;
504
+ }
505
+ const stats = await this.#lstat(entry);
506
+ if (!stats?.isSymbolicLink()) {
507
+ this.#emitFile(entry, archivePath);
508
+ continue;
509
+ }
510
+ const linkStats = await this.#statThroughLink(entry);
511
+ if (linkStats === undefined) {
512
+ // Dangling. A link to nothing is worth nothing on the runner.
513
+ continue;
514
+ }
515
+ if (linkStats.isDirectory()) {
516
+ await this.#copyTree(entry, archivePath, visited);
517
+ continue;
518
+ }
519
+ this.#emitFile(entry, archivePath);
520
+ }
521
+ }
522
+ /**
523
+ * The member's manifest carries load-bearing metadata (`type`, `exports`) and
524
+ * may exist in the archive only as the parser's faux placeholder; the real
525
+ * one wins by the registry's prefer-physical rule.
526
+ */
527
+ async #emitMemberPackageJson(member) {
528
+ const packageJson = path.join(member, 'package.json');
529
+ // Stat through any link: a manifest that is itself a symlink still reads as
530
+ // a file when the archive is built.
531
+ const stats = await this.#statThroughLink(packageJson);
532
+ if (stats === undefined || !stats.isFile()) {
533
+ return false;
534
+ }
535
+ const archivePath = this.#archivePathOf(packageJson);
536
+ if (archivePath === undefined || this.#isIgnoredArchivePath(archivePath)) {
537
+ return false;
538
+ }
539
+ this.#emitFile(packageJson, archivePath);
540
+ return true;
541
+ }
542
+ /**
543
+ * Marks a link whose target content arrives through the parser rather than
544
+ * through this resolver — the prune pass must not treat its target as absent,
545
+ * and the bundler should warn if a conflict ever forces the link out.
546
+ */
547
+ #markLinkReferenced(symlink) {
548
+ const archivePath = this.#archivePathOf(symlink);
549
+ const existing = archivePath !== undefined ? this.#entries.get(archivePath) : undefined;
550
+ if (existing !== undefined && existing.symlinkTarget !== undefined) {
551
+ existing.referencedLink = true;
552
+ }
553
+ }
554
+ /** Whether a real directory sits inside a pnpm store (`.pnpm/<pkg>@<v>/node_modules/...`). */
555
+ #isPnpmStoreLocation(target) {
556
+ return pnpmStoreNodeModules(target) !== undefined;
557
+ }
558
+ /**
559
+ * Bundles a real directory that a symlink points at, and everything reachable
560
+ * from it. Deduplicated by real path, which is what makes cyclic and diamond
561
+ * link graphs terminate.
562
+ */
563
+ async #expand(directory) {
564
+ if (this.#expanded.has(directory)) {
565
+ return;
566
+ }
567
+ this.#expanded.add(directory);
568
+ debug(`Expanding symlink target ${directory}`);
569
+ for (const entry of await this.#enumerate(directory)) {
570
+ await this.#classify(entry);
571
+ }
572
+ }
573
+ /**
574
+ * Adds the dependency links that live alongside a package inside a pnpm store,
575
+ * which is the only way a bundled package can resolve what it depends on.
576
+ *
577
+ * pnpm links node_modules/debug to .pnpm/debug@4.3.4/node_modules/debug, and
578
+ * puts debug's own dependency ms next to that directory, at
579
+ * .pnpm/debug@4.3.4/node_modules/ms — a *sibling* of the link's target rather
580
+ * than something inside it. Expanding the target alone therefore produces a
581
+ * package whose dependencies are all missing.
582
+ *
583
+ * Restricted to the pnpm store on purpose. The same rule applied to a package
584
+ * inside an ordinary node_modules directory would enumerate that entire
585
+ * directory, so a single linked package could pull in everything installed.
586
+ *
587
+ * Known gap: pnpm also hoists packages into node_modules/.pnpm/node_modules,
588
+ * which Node reaches from inside the store. A package that requires something
589
+ * it does not declare resolves through there locally, and is not collected
590
+ * here, so it would still fail on the runner. Collecting that directory means
591
+ * collecting every installed package, which is far too much to pay for a case
592
+ * pnpm's own strictness makes rare.
593
+ */
594
+ async #addDependencyClosure(packageDirectory) {
595
+ // A pnpm store package's node_modules directory looks like
596
+ // <...>/.pnpm/<package>@<version>/node_modules. Anything else — including a
597
+ // node_modules directory bundled *inside* a package — must not trigger this.
598
+ const nodeModules = pnpmStoreNodeModules(packageDirectory);
599
+ if (nodeModules === undefined) {
600
+ return;
601
+ }
602
+ if (this.#closed.has(nodeModules)) {
603
+ return;
604
+ }
605
+ this.#closed.add(nodeModules);
606
+ debug(`Adding dependency closure from ${nodeModules}`);
607
+ for (const entry of await this.#readdir(nodeModules)) {
608
+ if (PACKAGE_MANAGER_STATE_FILES.has(entry.name)) {
609
+ continue;
610
+ }
611
+ const entryPath = path.join(nodeModules, entry.name);
612
+ if (entry.isSymbolicLink()) {
613
+ await this.#classify(entryPath);
614
+ continue;
615
+ }
616
+ if (!entry.isDirectory()) {
617
+ continue;
618
+ }
619
+ if (entry.name === BIN_DIR) {
620
+ // Executables the package's own scripts rely on. They are ordinary
621
+ // files that locate themselves at run time, so copying them works.
622
+ for (const bin of await this.#readdir(entryPath)) {
623
+ await this.#classify(path.join(entryPath, bin.name));
624
+ }
625
+ continue;
626
+ }
627
+ if (entry.name.startsWith('@')) {
628
+ // A scoped dependency is a symlink one level inside a real scope
629
+ // directory, so the scope directory itself has to be opened.
630
+ for (const scoped of await this.#readdir(entryPath)) {
631
+ if (scoped.isSymbolicLink()) {
632
+ await this.#classify(path.join(entryPath, scoped.name));
633
+ }
634
+ }
635
+ continue;
636
+ }
637
+ // A real directory here is the package itself, which #expand covers.
638
+ }
639
+ }
640
+ /**
641
+ * A broken symlink is not bundled. Whatever it points at does not exist here
642
+ * and so cannot travel with it, leaving a link to nothing on the runner. (Were
643
+ * it kept, #pruneSymlinks would drop it anyway, its target having no entry.)
644
+ */
645
+ #skipDanglingSymlink(symlink) {
646
+ this.#warnOnce(symlink, `${symlink} is a broken symlink. Skipping it.`);
647
+ }
648
+ #emitSymlink(symlink, target) {
649
+ const archivePath = this.#archivePathOf(symlink);
650
+ const targetArchivePath = this.#archivePathOf(target);
651
+ if (archivePath === undefined || targetArchivePath === undefined) {
652
+ return;
653
+ }
654
+ this.#emitSymlinkEntry(symlink, archivePath, targetArchivePath);
655
+ }
656
+ #emitSymlinkEntry(symlink, archivePath, targetArchivePath) {
657
+ // The link target is computed between archive paths, not filesystem paths,
658
+ // so it stays valid wherever the archive is extracted. Both are anchored to
659
+ // '/' first: path.posix.relative() resolves bare relative paths against the
660
+ // process's working directory, which has nothing to do with the archive.
661
+ //
662
+ // A link to its own parent directory relativizes to the empty string, which
663
+ // symlink(2) rejects, so name the directory instead.
664
+ const relativeTarget = path.posix.relative(path.posix.dirname(`/${archivePath}`), `/${targetArchivePath}`);
665
+ const symlinkTarget = relativeTarget === '' ? '.' : relativeTarget;
666
+ this.#emit(archivePath, {
667
+ filePath: symlink,
668
+ physical: true,
669
+ archivePath,
670
+ symlinkTarget,
671
+ });
672
+ }
673
+ #emitFile(filePath, archivePath) {
674
+ this.#emit(archivePath, {
675
+ filePath,
676
+ physical: true,
677
+ archivePath,
678
+ });
679
+ }
680
+ #emit(key, file) {
681
+ if (this.#entries.has(key)) {
682
+ return;
683
+ }
684
+ this.#entries.set(key, file);
685
+ }
686
+ /**
687
+ * Lists the files in a real directory. Rooted at a real path on purpose: glob
688
+ * yields nothing at all when its cwd is a symlink.
689
+ *
690
+ * Dotfiles stay out, matching the include globs. That is not cosmetic — it is
691
+ * what keeps the pnpm store, its state files and stray .env files from being
692
+ * swept into the archive when a node_modules directory is enumerated.
693
+ */
694
+ async #enumerate(directory) {
695
+ return await glob('**/*', {
696
+ cwd: directory,
697
+ nodir: true,
698
+ absolute: true,
699
+ dot: false,
700
+ });
701
+ }
702
+ /**
703
+ * Ignore patterns are matched against the path a file would occupy in the
704
+ * archive, not against its path relative to the include glob's cwd.
705
+ *
706
+ * The cwd is the Playwright config directory, which in a monorepo sits below
707
+ * the workspace root while the pnpm store sits at it — so a store path
708
+ * relativized against the cwd starts with `..`, and minimatch's `**` cannot
709
+ * swallow a `..` segment (with or without `dot`). Matching in that namespace
710
+ * would silently ignore nothing at all for exactly the content this resolver
711
+ * pulls in.
712
+ *
713
+ * The trade-off is that a pattern anchored to the cwd rather than the root —
714
+ * `fixtures/...` rather than a globstar-prefixed one — does not apply to
715
+ * expanded content. A pattern reaching outside the cwd has to be root-relative
716
+ * to mean anything, and the CLI's own examples are all globstar-prefixed.
717
+ */
718
+ #isIgnored(file) {
719
+ const archivePath = this.#archivePathOf(file);
720
+ if (archivePath === undefined) {
721
+ // Outside the archive root, so there is no root-relative name to match.
722
+ // Callers that know where the file will land in the archive should use
723
+ // #isIgnoredArchivePath instead.
724
+ const relative = pathToPosix(path.relative(this.#ignoreCwd, file));
725
+ return this.#ignorePatterns.some(pattern => minimatch(relative, pattern, { dot: true }));
726
+ }
727
+ return this.#isIgnoredArchivePath(archivePath);
728
+ }
729
+ /**
730
+ * Whether the ignore patterns exclude an archive path either directly or in
731
+ * its entirety via a directory-shaped pattern. The distinction matters for
732
+ * deciding whether a *link* counts as excluded: the pattern shape the CLI's
733
+ * own docs teach (a globstar prefix, then the directory name, then a trailing
734
+ * globstar) matches everything under the directory but not the bare directory
735
+ * entry itself — a trailing globstar requires at least one segment. Probing
736
+ * with a synthetic child answers "did the user exclude this subtree" for both
737
+ * spellings.
738
+ */
739
+ #isIgnoredArchivePathOrContents(archivePath) {
740
+ return this.#isIgnoredArchivePath(archivePath)
741
+ || this.#isIgnoredArchivePath(path.posix.join(archivePath, 'x'));
742
+ }
743
+ #isIgnoredArchivePath(archivePath) {
744
+ return this.#ignorePatterns.some(pattern => minimatch(archivePath, pattern, { dot: true }));
745
+ }
746
+ #rootOf(target) {
747
+ return this.#roots.find(root => isInside(root, target));
748
+ }
749
+ #archivePathOf(target) {
750
+ const root = this.#rootOf(target);
751
+ if (root === undefined) {
752
+ return undefined;
753
+ }
754
+ const relative = pathToPosix(path.relative(root, target));
755
+ // The root itself normalizes to '.', which is not a path anything is
756
+ // archived at. Spell it as the empty string so it reads as "the root".
757
+ return relative === '.' ? '' : relative;
758
+ }
759
+ async #lstat(target) {
760
+ const cached = this.#lstatCache.get(target);
761
+ if (cached !== undefined || this.#lstatCache.has(target)) {
762
+ return cached;
763
+ }
764
+ let stats;
765
+ try {
766
+ stats = await fs.lstat(target);
767
+ }
768
+ catch {
769
+ stats = undefined;
770
+ }
771
+ this.#lstatCache.set(target, stats);
772
+ return stats;
773
+ }
774
+ /** Stats the target of a link. Undefined when the link is broken. */
775
+ async #statThroughLink(target) {
776
+ try {
777
+ return await fs.stat(target);
778
+ }
779
+ catch {
780
+ return undefined;
781
+ }
782
+ }
783
+ /** Undefined when the path is a broken or cyclic symlink. */
784
+ async #realpath(target) {
785
+ try {
786
+ return await fs.realpath(target);
787
+ }
788
+ catch {
789
+ return undefined;
790
+ }
791
+ }
792
+ async #readdir(directory) {
793
+ try {
794
+ return await fs.readdir(directory, { withFileTypes: true });
795
+ }
796
+ catch {
797
+ return [];
798
+ }
799
+ }
800
+ #warnOnce(key, message) {
801
+ if (this.#warned.has(key)) {
802
+ return;
803
+ }
804
+ this.#warned.add(key);
805
+ debug(message);
806
+ process.stderr.write(`Warning: ${message}\n`);
807
+ }
808
+ }
809
+ function isInside(root, target) {
810
+ return target === root || target.startsWith(root + path.sep);
811
+ }
812
+ /** Where a symlink entry's target lands, as an archive path. */
813
+ function resolveArchivePath(archivePath, symlinkTarget) {
814
+ const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(`/${archivePath}`), symlinkTarget));
815
+ // Anchored at '/' so the join cannot escape into the process's working
816
+ // directory; strip the anchor back off to get an archive path again.
817
+ return resolved.replace(/^\/+/, '');
818
+ }
819
+ /**
820
+ * pnpm's record of how a node_modules directory was built. Bundling one is worse
821
+ * than useless: the runner's store directory is not the one it names, and pnpm
822
+ * responds by purging node_modules — without asking, when CI is set.
823
+ */
824
+ function isPackageManagerStateFile(target) {
825
+ return PACKAGE_MANAGER_STATE_FILES.has(path.basename(target))
826
+ && path.basename(path.dirname(target)) === NODE_MODULES;
827
+ }
828
+ /** Whether a path is a package inside a node_modules directory, scope included. */
829
+ function isInsideNodeModules(target) {
830
+ return enclosingNodeModules(target) !== undefined;
831
+ }
832
+ /**
833
+ * The pnpm store node_modules directory enclosing a package directory
834
+ * (`<...>/.pnpm/<pkg>@<v>/node_modules`), or undefined when the package does
835
+ * not sit in a store.
836
+ */
837
+ function pnpmStoreNodeModules(packageDirectory) {
838
+ const nodeModules = enclosingNodeModules(packageDirectory);
839
+ if (nodeModules === undefined) {
840
+ return undefined;
841
+ }
842
+ return path.basename(path.dirname(path.dirname(nodeModules))) === PNPM_STORE ? nodeModules : undefined;
843
+ }
844
+ /**
845
+ * The node_modules directory a package directory belongs to, looking through a
846
+ * scope directory when there is one: node_modules/@types/node lives two levels
847
+ * below its node_modules, not one.
848
+ */
849
+ function enclosingNodeModules(packageDirectory) {
850
+ const parent = path.dirname(packageDirectory);
851
+ if (path.basename(parent) === NODE_MODULES) {
852
+ return parent;
853
+ }
854
+ const grandParent = path.dirname(parent);
855
+ if (path.basename(parent).startsWith('@') && path.basename(grandParent) === NODE_MODULES) {
856
+ return grandParent;
857
+ }
858
+ return undefined;
859
+ }
860
+ //# sourceMappingURL=symlink-resolver.js.map