@qaguardian/playwright-score 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,114 @@ parsing accuracy, not grading policy — can still move a suite's score
10
10
  without itself being a new model version; 2.1.0 below is exactly that
11
11
  case, and says so explicitly.
12
12
 
13
+ ## 2.1.1 — 2026-09-26
14
+
15
+ Scoring model stays v4. Reviewer follow-ups from the 2.1.0 release review
16
+ (QAG-230) — none of these blocked that release. They close symlink-escape
17
+ gaps a crafted repo could otherwise use, quiet a false warning, and fix a
18
+ false-positive drop the first round of these same fixes introduced. None
19
+ of that changes what an ordinary suite with no symlinked spec files
20
+ scores; the realpath dedupe fix below is the one exception — it can
21
+ change the file/test count for a suite that has a symlinked spec file
22
+ matching discovery alongside its own real target, and says so.
23
+
24
+ ### Fixed
25
+ - **A match escaping the *repository* through a symlink is now dropped —
26
+ scoped to the repo root, not the narrower directory the caller pointed
27
+ at.** `testDirEscapesRepo` (added in 2.1.0) only clamps a config's
28
+ `testDir` itself before it becomes a glob root — it couldn't see a
29
+ symlinked directory the glob then walked *through* on the way to an
30
+ individual match. A `testMatch` entry that names a symlinked directory
31
+ by a literal path segment (not a `**` wildcard) is still followed even
32
+ with glob's `follow: false` (that option only stops `**` from
33
+ *expanding into* a symlinked directory, not a pattern from naming one
34
+ explicitly), so a crafted repo (`tests/evil -> /`, `testMatch:
35
+ ['evil/**/*.ts']`) could make discovery walk arbitrary parts of the
36
+ filesystem while every match still lexically looked like it lived
37
+ under the scanned directory. The same gap let an individually
38
+ symlinked spec file sitting directly in the repo (no `testMatch`
39
+ trickery needed) resolve to, and get scored from, a file outside it.
40
+ `src/index.ts`'s match loop realpath's every candidate match and drops
41
+ it unless it's still contained within the realpath of the **repository
42
+ root** (`safeRealpath(findRepoBoundary(abs))`) — not, as this package
43
+ first shipped it, the narrower directory the caller actually scanned.
44
+ That first version silently dropped a real, common monorepo shape too:
45
+ a spec reachable through an in-repo symlink that resolves to a sibling
46
+ directory *outside* the caller's scanned subpath (one package's specs
47
+ shared with another via a symlink) is legitimate and is kept; only a
48
+ match whose realpath lands outside the repository itself is dropped.
49
+ The same repo-root containment now also applies to a bare glob-pattern
50
+ input (e.g. `tests/*.spec.ts` passed directly to `scorePaths`, not a
51
+ directory it walks itself) — the first version of this fix only
52
+ covered the directory-scan branch, so switching an otherwise-identical
53
+ call from a directory argument to an equivalent glob argument silently
54
+ bypassed the guard entirely.
55
+ - **The walk itself is now pruned at a symlink that escapes the repo
56
+ root, not just filtered after the fact.** The realpath containment
57
+ check above still has to pay to walk all the way through a symlinked
58
+ directory before dropping every match inside it one by one — for a
59
+ large or slow target (`tests/evil -> /`) that's a real cost, not just
60
+ a theoretical one (measured: ~6.4s to fully walk a large symlinked
61
+ directory before this fix). `RepoBoundaryIgnore` (`src/index.ts`) wraps
62
+ glob's own `ignore` matching with a `childrenIgnored` check that stops
63
+ the walk from ever descending into a symlinked directory whose
64
+ realpath lands outside the repo root, only paying the realpath cost at
65
+ an actual symlink (a plain nested directory can't walk outside a root
66
+ its own parent is already inside, so it's never checked).
67
+ - **An in-repo symlinked spec and its own real target are no longer both
68
+ scored, when both independently match discovery under the same scan.**
69
+ Pre-existing, not new to the fixes above: `expandPaths` deduped a
70
+ literal path only against `explicit` inputs, never against another
71
+ `expanded` match that happens to resolve to the same realpath. A
72
+ symlink sitting alongside its target inside the same scanned directory
73
+ (or reachable via two different scan roots in one `scorePaths` call)
74
+ was counted, and scored, twice. Now deduped by realpath, keeping the
75
+ first lexical path in sorted order. **This can change file/test counts
76
+ for a repo that has such a link** — said plainly here rather than
77
+ folded into "doesn't change what a correctly-shaped suite scores",
78
+ since a suite with this exact shape is exactly what changes.
79
+ - **False "testDir resolves outside the repository" warning on a
80
+ nonexistent `testDir` under a symlinked repo path.** `safeRealpath`
81
+ only resolves the portion of a path that exists on disk, so a
82
+ nonexistent `testDir` kept its literal, unresolved form while
83
+ `repoRoot` (which does exist) got fully resolved — on a host where the
84
+ repo itself sits under a symlinked prefix (macOS: `/tmp` ->
85
+ `/private/tmp`), that asymmetry alone made the two sides disagree and
86
+ fired a false escape warning for an ordinary missing `testDir`.
87
+ `testDirEscapesRepo` now checks existence first and returns "does not
88
+ escape" immediately for a `testDir` that isn't on disk — consistent
89
+ with `resolveConfigScopedRoot`'s own existing "nothing to score there"
90
+ handling for the same case.
91
+ - **Escape warning now prints cwd-relative paths**, matching every other
92
+ `configWarning` in this package, instead of the runner's own absolute
93
+ filesystem paths.
94
+ - **A raw glob-pattern input pointing at a different repo than `cwd` no
95
+ longer hard-fails with 0 matches.** The repo-root containment fix above
96
+ used `cwd`'s own repo boundary for a bare glob pattern regardless of
97
+ where the pattern actually pointed — an absolute pattern into a sibling
98
+ repo (`['/abs/repoB/e2e/*.spec.ts']`), or an equivalent `../`-relative
99
+ one, had every match rejected as an "escape" of `cwd`'s repo, where the
100
+ same target passed as a directory already scored fine. `src/index.ts`'s
101
+ glob-input branch now derives its containment boundary from *the
102
+ pattern's own* literal (non-magic) leading-directory prefix, resolved
103
+ against `cwd`, not from `cwd` itself — a literal prefix that lands
104
+ inside another repo entirely is scored against that repo's own
105
+ boundary, while a symlink an in-bounds prefix still reaches that escapes
106
+ *that* repo is still dropped.
107
+ - **An explicit target and a directory scan that reaches it again through
108
+ a symlink are no longer scored twice.** The realpath dedupe added above
109
+ only ever populated its map from `expanded` matches; an `explicit` file
110
+ and a same-realpath `expanded` symlink are two different literal paths,
111
+ so the separate "explicit wins" step (an exact literal-path delete)
112
+ never caught that pairing. The dedupe map is now seeded with
113
+ `explicit`'s own realpaths first, so a same-realpath `expanded` match is
114
+ recognized as a duplicate instead of surviving as a second, independent
115
+ file.
116
+
117
+ Corpus rerun (100 public repos, directory inputs, run on the round-2 build;
118
+ the round-3 fixes only touch glob-pattern inputs): 96/100 repos score
119
+ identically, 4 moved upstream, 0 changed.
120
+
13
121
  ## 2.1.0 — 2026-09-24
14
122
 
15
123
  Scoring model stays v4 — nothing here changes a rule's weight or
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { globSync } from 'glob';
3
+ import { globSync, hasMagic, Ignore } from 'glob';
4
4
  import { runEslint } from './eslint-runner.js';
5
5
  import { commonAncestorDir } from './fs-util.js';
6
6
  import { collectLocallyImportedFiles } from './import-graph.js';
7
7
  import { analyzeSource, attributeFindingScopes, countLocators, findLocalAssertionHelperNames, getTestSpans, looksLikeNonPlaywrightTest, } from './metrics.js';
8
8
  import { detectExtendedTestAliases } from './test-aliases.js';
9
- import { findPlaywrightConfig, findRepoBoundary, importsPlaywrightTestTransitively, parsePlaywrightConfig, resolveConfigScopedRoot, testDirEscapesRepo, } from './playwright-config.js';
9
+ import { findPlaywrightConfig, findRepoBoundary, importsPlaywrightTestTransitively, isAncestorOrSame, parsePlaywrightConfig, resolveConfigScopedRoot, safeRealpath, testDirEscapesRepo, } from './playwright-config.js';
10
10
  import { DEFAULT_THRESHOLDS } from './profiles.js';
11
11
  import { computeScore } from './score-engine.js';
12
12
  export { computeScore, MODEL, locatorsScore, gradeFromScore, ratioDimensionScore, } from './score-engine.js';
@@ -57,6 +57,86 @@ const DEFAULT_IGNORE_GLOBS = [
57
57
  '**/playwright-report/**',
58
58
  '**/test-results/**',
59
59
  ];
60
+ /**
61
+ * Wraps glob's own default `Ignore` (the string-pattern matcher behind
62
+ * DEFAULT_IGNORE_GLOBS/testIgnore) so this package can *also* prune the
63
+ * walk at a symlinked directory whose realpath escapes `realRoot`,
64
+ * without giving up the string-pattern ignores glob's `ignore` option
65
+ * only accepts one or the other of (a plain string[] XOR an IgnoreLike
66
+ * object — see glob's own types).
67
+ *
68
+ * Only `childrenIgnored` does the extra realpath work, and only for an
69
+ * actual symlink: a plain (non-symlinked) directory can't walk outside a
70
+ * root its own parent is already inside, so resolving its realpath would
71
+ * just be wasted syscalls on every directory in a large tree. This is
72
+ * what stops discovery from following a `testDir -> /` (or any other
73
+ * huge, real) symlinked directory arbitrarily far (QAG-230) — the
74
+ * per-match realpath containment check in expandPaths below still
75
+ * catches an escaping *match*, but only after the walk already paid to
76
+ * get there; this stops the walk itself from ever descending.
77
+ */
78
+ class RepoBoundaryIgnore {
79
+ realRoot;
80
+ inner;
81
+ constructor(patterns, realRoot) {
82
+ this.realRoot = realRoot;
83
+ this.inner = new Ignore(patterns, {});
84
+ }
85
+ ignored(p) {
86
+ return this.inner.ignored(p);
87
+ }
88
+ // Forwarded, not required by any globSync call this package makes today
89
+ // (includeChildMatches defaults to true, which never checks for this) —
90
+ // present anyway so relying on that default doesn't quietly become a
91
+ // hard requirement if it's ever overridden.
92
+ add(pattern) {
93
+ this.inner.add(pattern);
94
+ }
95
+ childrenIgnored(p) {
96
+ if (this.inner.childrenIgnored(p))
97
+ return true;
98
+ if (!p.isSymbolicLink())
99
+ return false;
100
+ return !isAncestorOrSame(this.realRoot, safeRealpath(p.fullpath()));
101
+ }
102
+ }
103
+ /**
104
+ * The literal (non-magic) leading-directory prefix of a raw glob pattern
105
+ * string, resolved against `cwd` — e.g. `/abs/repoB/e2e/*.spec.ts` ->
106
+ * `/abs/repoB/e2e`; `../repoB/e2e/*.spec.ts` -> `<cwd>/../repoB/e2e`
107
+ * (collapsed by path.resolve). Used to find *that pattern's own* repo
108
+ * boundary (QAG-230 round 3): a bare glob-pattern input isn't necessarily
109
+ * anywhere near `cwd`'s own repo — it can be an absolute path, or a
110
+ * relative one that climbs out via `../` — into a completely different
111
+ * repo, and computing containment from cwd's repo root regardless made
112
+ * every match there look like an escape (hard-failing a perfectly legit
113
+ * `['/abs/repoB/e2e/*.spec.ts']` down to 0 files, where the equivalent
114
+ * directory input `../repoB/e2e` already scored fine).
115
+ *
116
+ * Segments are checked with glob's own `hasMagic` — with `magicalBraces`
117
+ * on, since an unexpanded `{a,b}` segment isn't a real, literal directory
118
+ * name any more than a `*` is — so this only ever returns a directory
119
+ * that's guaranteed to be a plain path segment, never a piece the glob
120
+ * engine itself still has to interpret.
121
+ */
122
+ function literalPrefixDir(pattern, cwd) {
123
+ const segments = pattern.split('/');
124
+ const prefix = [];
125
+ let sawMagic = false;
126
+ for (const segment of segments) {
127
+ if (hasMagic(segment, { magicalBraces: true })) {
128
+ sawMagic = true;
129
+ break;
130
+ }
131
+ prefix.push(segment);
132
+ }
133
+ // No magic anywhere in the pattern: it's a literal path (only reachable
134
+ // here because it didn't exist on disk — an existing path is handled by
135
+ // the file/directory branches above) — its directory is the parent of
136
+ // the last (literal) segment, not the whole joined string.
137
+ const dirSegments = sawMagic ? prefix : prefix.slice(0, -1);
138
+ return path.resolve(cwd, dirSegments.join('/') || '.');
139
+ }
60
140
  /**
61
141
  * `explicit`: paths the caller named directly (a literal existing file) —
62
142
  * always scored, regardless of whether they look like a Playwright spec.
@@ -84,6 +164,19 @@ function expandPaths(inputs, cwd) {
84
164
  // needs this wider boundary, not the narrower common ancestor of
85
165
  // only the matched spec files.
86
166
  scanRootDirs.add(abs);
167
+ // Repo boundary for this scan's per-match realpath containment check
168
+ // and glob-walk pruning below (see the containment check itself, and
169
+ // RepoBoundaryIgnore, for why this must be the *repo root*, not the
170
+ // narrower `abs`): a scan pointed at one monorepo package's specs
171
+ // must still keep an in-repo symlink that resolves to a sibling
172
+ // directory outside that subpath, while still rejecting anything
173
+ // that resolves outside the repo entirely (QAG-230). Starts with the
174
+ // same conservative fallback-to-`abs` findRepoBoundary uses
175
+ // elsewhere in this file; replaced below with the wider boundary a
176
+ // successfully parsed config already computes for testDirEscapesRepo,
177
+ // so both checks share one repo boundary instead of computing it
178
+ // twice.
179
+ let repoRootForContainment = findRepoBoundary(abs, abs);
87
180
  // A playwright.config.* at or above this directory, when it can be
88
181
  // read statically, is authoritative about what this target itself
89
182
  // considers a spec — see playwright-config.ts. Falls back to the
@@ -120,13 +213,14 @@ function expandPaths(inputs, cwd) {
120
213
  // same of it), else `abs` itself. Never the filesystem root or
121
214
  // 20 levels up — see findRepoBoundary's doc comment.
122
215
  const repoRoot = findRepoBoundary(abs, path.dirname(configFile));
216
+ repoRootForContainment = repoRoot;
123
217
  if (testDirEscapesRepo(parsedConfig.testDirAbs, repoRoot)) {
124
218
  // The config's testDir resolves outside the repo boundary
125
219
  // (directly, or via a symlink — see testDirEscapesRepo). Its
126
220
  // testMatch/testIgnore were written for that escaped testDir,
127
221
  // so applying them to the clamped repoRoot instead would be a
128
222
  // silent mismatch; fall back to filename-based discovery.
129
- configWarnings.push(`Found ${path.relative(cwd, configFile) || configFile} but its testDir (${parsedConfig.testDirAbs}) resolves outside the repository (${repoRoot}) — falling back to filename-based spec discovery for ${path.relative(cwd, abs) || abs}.`);
223
+ configWarnings.push(`Found ${path.relative(cwd, configFile) || configFile} but its testDir (${path.relative(cwd, parsedConfig.testDirAbs) || parsedConfig.testDirAbs}) resolves outside the repository (${path.relative(cwd, repoRoot) || repoRoot}) — falling back to filename-based spec discovery for ${path.relative(cwd, abs) || abs}.`);
130
224
  }
131
225
  else {
132
226
  const scopedRoot = resolveConfigScopedRoot(abs, parsedConfig.testDirAbs, repoRoot);
@@ -143,12 +237,21 @@ function expandPaths(inputs, cwd) {
143
237
  }
144
238
  }
145
239
  }
240
+ const realRepoRoot = safeRealpath(repoRootForContainment);
146
241
  for (const g of specGlobs) {
147
242
  for (const f of globSync(g, {
148
243
  cwd: globCwd,
149
244
  absolute: true,
150
245
  nodir: true,
151
- ignore: ignoreGlobs,
246
+ // RepoBoundaryIgnore keeps ignoreGlobs' own string-pattern
247
+ // matching (node_modules/, testIgnore, ...) and *additionally*
248
+ // stops the walk from ever descending into a symlinked directory
249
+ // whose realpath escapes realRepoRoot — see its own doc comment.
250
+ // Pruning during the walk (not just filtering matches after the
251
+ // fact, below) is what keeps a `testDir/evil -> /` (or any
252
+ // other huge, real symlinked target) from making discovery
253
+ // actually walk arbitrary parts of the filesystem (QAG-230).
254
+ ignore: new RepoBoundaryIgnore(ignoreGlobs, realRepoRoot),
152
255
  // glob's own default for `follow` is already false (don't
153
256
  // descend into a symlinked directory while expanding `**`), but
154
257
  // this is untrusted-repo territory (see testDirEscapesRepo
@@ -157,6 +260,31 @@ function expandPaths(inputs, cwd) {
157
260
  follow: false,
158
261
  })) {
159
262
  const resolved = path.resolve(f);
263
+ // testDirEscapesRepo (above) only clamps testDir itself before
264
+ // it becomes a glob root — it can't see what the glob then
265
+ // actually walks *through* on the way to a match. A testMatch
266
+ // entry that names a symlinked directory by a literal path
267
+ // segment (not a `**` wildcard) still gets followed even with
268
+ // `follow: false` on the globSync call above, since that option
269
+ // only stops `**` from *expanding into* a symlinked directory —
270
+ // it doesn't stop a pattern from naming one explicitly (e.g.
271
+ // `tests/evil -> /` with `testMatch: ['evil/**/*.ts']` walks
272
+ // `/`, and every match still lexically looks like it's under
273
+ // `abs`). The same gap lets an individually symlinked spec file
274
+ // sitting directly in the repo (no testMatch trickery needed)
275
+ // resolve to a file outside it. A lexical containment check
276
+ // can't catch either case — both matches still "look" like they
277
+ // live under `abs` right up until the symlink is resolved — so
278
+ // this compares realpaths on both sides. Checked against the
279
+ // *repo root* (realRepoRoot), not `abs` itself: a scan pointed
280
+ // at one monorepo package's specs must still keep a match that
281
+ // resolves, in-repo, to a sibling directory outside that
282
+ // subpath — only a true escape of the repo itself is dropped
283
+ // here (QAG-230; the config-scoped re-narrowing to `abs` right
284
+ // below handles the "don't silently widen past what the caller
285
+ // asked for" half of this separately).
286
+ if (!isAncestorOrSame(realRepoRoot, safeRealpath(resolved)))
287
+ continue;
160
288
  // resolveConfigScopedRoot always globs from testDir (Playwright's
161
289
  // own testMatch/testIgnore semantics — see its doc comment), which
162
290
  // can be wider than the directory the caller actually pointed
@@ -191,16 +319,65 @@ function expandPaths(inputs, cwd) {
191
319
  }
192
320
  continue;
193
321
  }
194
- // treat as glob
322
+ // treat as glob — a raw pattern the caller passed directly (e.g.
323
+ // `tests/*.spec.ts`), resolved relative to `cwd` rather than a
324
+ // directory this function itself walked. Same realpath containment
325
+ // and walk-pruning as the directory branch above applies here too
326
+ // (QAG-230): a bare glob input is just as capable of naming a
327
+ // symlinked path segment explicitly, and skipping this check here
328
+ // would have made switching from a directory argument to an
329
+ // equivalent glob argument silently bypass it.
330
+ //
331
+ // The boundary itself comes from *this pattern's own* literal prefix
332
+ // directory (literalPrefixDir), not from `cwd` (QAG-230 round 3) — see
333
+ // that function's doc comment for why cwd's repo root is the wrong
334
+ // boundary for a pattern that points somewhere else entirely.
335
+ const prefixDir = literalPrefixDir(input, cwd);
336
+ const repoRootForPattern = safeRealpath(findRepoBoundary(prefixDir, prefixDir));
195
337
  for (const f of globSync(input, {
196
338
  cwd,
197
339
  absolute: true,
198
340
  nodir: true,
199
- ignore: DEFAULT_IGNORE_GLOBS,
341
+ ignore: new RepoBoundaryIgnore(DEFAULT_IGNORE_GLOBS, repoRootForPattern),
342
+ follow: false,
200
343
  })) {
201
- expanded.add(path.resolve(f));
344
+ const resolved = path.resolve(f);
345
+ if (!isAncestorOrSame(repoRootForPattern, safeRealpath(resolved)))
346
+ continue;
347
+ expanded.add(resolved);
202
348
  }
203
349
  }
350
+ // De-dupe by realpath: an in-repo symlinked spec and its real target can
351
+ // both independently match discovery (the symlink itself sitting under
352
+ // one scanned root, its target sitting somewhere else under the same
353
+ // repo) and would otherwise be scored twice, as if they were two
354
+ // different files. Keeps the first lexical path per realpath, in sorted
355
+ // order, so which one "wins" is deterministic rather than depending on
356
+ // Set insertion order. Pre-existing gap, not new to the escape fixes
357
+ // above — those only ever drop a true escape, never a legitimate
358
+ // in-repo duplicate (see CHANGELOG: this can change file counts for a
359
+ // repo that happens to have such a link).
360
+ const firstPathByRealpath = new Map();
361
+ // Seeded with `explicit`'s own realpaths first: an explicit target and a
362
+ // directory scan containing a symlink to that same file are two
363
+ // different literal paths for the same file, so the plain "explicit
364
+ // wins" delete below (which only matches identical literal paths) can't
365
+ // catch that pairing — without this seeding, the symlinked match survives
366
+ // dedup (its realpath hadn't been seen yet) and gets scored a second
367
+ // time alongside the explicit path.
368
+ for (const f of [...explicit].sort()) {
369
+ const real = safeRealpath(f);
370
+ if (!firstPathByRealpath.has(real))
371
+ firstPathByRealpath.set(real, f);
372
+ }
373
+ for (const f of [...expanded].sort()) {
374
+ const real = safeRealpath(f);
375
+ if (!firstPathByRealpath.has(real))
376
+ firstPathByRealpath.set(real, f);
377
+ }
378
+ expanded.clear();
379
+ for (const f of firstPathByRealpath.values())
380
+ expanded.add(f);
204
381
  // A file can be reached both explicitly and via expansion (e.g. an
205
382
  // explicit path plus an overlapping glob); explicit intent wins.
206
383
  for (const f of explicit)
@@ -57,6 +57,19 @@ export interface ParsedPlaywrightConfig {
57
57
  * partial or guessed reading.
58
58
  */
59
59
  export declare function parsePlaywrightConfig(configFile: string): ParsedPlaywrightConfig | undefined;
60
+ /** Exported for index.ts's own realpath containment check on matched spec
61
+ * files (see expandPaths) — a config-scoped `testDir`/`testMatch` can be
62
+ * clamped to the repo boundary here, but the glob it drives can still walk
63
+ * through a symlinked directory *inside* that boundary whose real target
64
+ * is outside it (see testDirEscapesRepo's doc comment for why a lexical
65
+ * check alone isn't enough); index.ts needs the same primitive to catch
66
+ * that per-match, not just per-testDir. */
67
+ export declare function isAncestorOrSame(ancestor: string, descendant: string): boolean;
68
+ /** Resolves symlinks; falls back to the input path unchanged when it
69
+ * doesn't exist on disk (matches the existing "testDir doesn't exist"
70
+ * behavior elsewhere in this file rather than throwing). Exported for the
71
+ * same reason as isAncestorOrSame above. */
72
+ export declare function safeRealpath(p: string): string;
60
73
  /**
61
74
  * True when `testDirAbs`, resolved through any symlinks, is NOT inside
62
75
  * (or equal to) `repoRoot`, also resolved through symlinks. A scored
@@ -67,6 +80,19 @@ export declare function parsePlaywrightConfig(configFile: string): ParsedPlaywri
67
80
  * this public tool glob outside its own boundary. Both sides are
68
81
  * realpath'd (never just testDirAbs) because `repoRoot` itself could in
69
82
  * principle be reached through a symlinked path.
83
+ *
84
+ * A nonexistent `testDirAbs` (nothing to score there — resolveConfigScopedRoot's
85
+ * own existence check downstream already handles that) short-circuits to
86
+ * "doesn't escape" *before* either side is realpath'd: `safeRealpath` only
87
+ * resolves symlinks up to the point where a path stops existing on disk,
88
+ * so a testDir that doesn't exist keeps its literal (unresolved) form
89
+ * while `repoRoot` — which does exist — gets fully resolved. On a host
90
+ * where the repo itself sits under a symlinked prefix (macOS: `/tmp` ->
91
+ * `/private/tmp`), that asymmetry alone made the two sides disagree and
92
+ * fired a false "escapes the repository" warning for a perfectly ordinary
93
+ * missing testDir (QAG-230). Checked with a plain `fs.existsSync`, not
94
+ * `safeRealpath`'s own try/catch, so this stays a pure existence check
95
+ * with no symlink resolution of its own to get wrong.
70
96
  */
71
97
  export declare function testDirEscapesRepo(testDirAbs: string, repoRoot: string): boolean;
72
98
  /**
@@ -276,14 +276,22 @@ export function parsePlaywrightConfig(configFile) {
276
276
  testIgnore: sawTestIgnoreKey ? testIgnore : undefined,
277
277
  };
278
278
  }
279
- function isAncestorOrSame(ancestor, descendant) {
279
+ /** Exported for index.ts's own realpath containment check on matched spec
280
+ * files (see expandPaths) — a config-scoped `testDir`/`testMatch` can be
281
+ * clamped to the repo boundary here, but the glob it drives can still walk
282
+ * through a symlinked directory *inside* that boundary whose real target
283
+ * is outside it (see testDirEscapesRepo's doc comment for why a lexical
284
+ * check alone isn't enough); index.ts needs the same primitive to catch
285
+ * that per-match, not just per-testDir. */
286
+ export function isAncestorOrSame(ancestor, descendant) {
280
287
  const rel = path.relative(ancestor, descendant);
281
288
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
282
289
  }
283
290
  /** Resolves symlinks; falls back to the input path unchanged when it
284
291
  * doesn't exist on disk (matches the existing "testDir doesn't exist"
285
- * behavior elsewhere in this file rather than throwing). */
286
- function safeRealpath(p) {
292
+ * behavior elsewhere in this file rather than throwing). Exported for the
293
+ * same reason as isAncestorOrSame above. */
294
+ export function safeRealpath(p) {
287
295
  try {
288
296
  return fs.realpathSync(p);
289
297
  }
@@ -301,8 +309,23 @@ function safeRealpath(p) {
301
309
  * this public tool glob outside its own boundary. Both sides are
302
310
  * realpath'd (never just testDirAbs) because `repoRoot` itself could in
303
311
  * principle be reached through a symlinked path.
312
+ *
313
+ * A nonexistent `testDirAbs` (nothing to score there — resolveConfigScopedRoot's
314
+ * own existence check downstream already handles that) short-circuits to
315
+ * "doesn't escape" *before* either side is realpath'd: `safeRealpath` only
316
+ * resolves symlinks up to the point where a path stops existing on disk,
317
+ * so a testDir that doesn't exist keeps its literal (unresolved) form
318
+ * while `repoRoot` — which does exist — gets fully resolved. On a host
319
+ * where the repo itself sits under a symlinked prefix (macOS: `/tmp` ->
320
+ * `/private/tmp`), that asymmetry alone made the two sides disagree and
321
+ * fired a false "escapes the repository" warning for a perfectly ordinary
322
+ * missing testDir (QAG-230). Checked with a plain `fs.existsSync`, not
323
+ * `safeRealpath`'s own try/catch, so this stays a pure existence check
324
+ * with no symlink resolution of its own to get wrong.
304
325
  */
305
326
  export function testDirEscapesRepo(testDirAbs, repoRoot) {
327
+ if (!fs.existsSync(testDirAbs))
328
+ return false;
306
329
  const realRepoRoot = safeRealpath(repoRoot);
307
330
  const realTestDirAbs = safeRealpath(testDirAbs);
308
331
  return !isAncestorOrSame(realRepoRoot, realTestDirAbs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qaguardian/playwright-score",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "Deterministic, AI-free Playwright spec quality score — the weighted share of your tests that are clean.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",