@qaguardian/playwright-score 0.1.0 → 0.1.9

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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @qaguardian/playwright-score
2
2
 
3
+ [![CI](https://github.com/qa-guardian/playwright-score/actions/workflows/ci.yml/badge.svg)](https://github.com/qa-guardian/playwright-score/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/@qaguardian/playwright-score.svg)](https://www.npmjs.com/package/@qaguardian/playwright-score)
5
+ [![license](https://img.shields.io/npm/l/@qaguardian/playwright-score.svg)](./LICENSE)
6
+
3
7
  **Deterministic, AI-free quality score for Playwright specs** (`sqs-v1`).
4
8
 
5
9
  **Website:** [qaguardian.com/open-source/playwright-score](https://qaguardian.com/open-source/playwright-score)
@@ -18,15 +22,18 @@ See [METHODOLOGY.md](./METHODOLOGY.md) for the frozen formula, or the full produ
18
22
 
19
23
  ```bash
20
24
  npm install -D @qaguardian/playwright-score
21
- # or run from this package
22
- npm install && npm run build
23
25
  ```
24
26
 
27
+ Package: [@qaguardian/playwright-score](https://www.npmjs.com/package/@qaguardian/playwright-score) on npm.
28
+
25
29
  ## CLI
26
30
 
27
31
  ```bash
28
- npx @qaguardian/playwright-score ./tests --profile standard --threshold 80
29
- npx @qaguardian/playwright-score ./flow.spec.ts --profile guardian --format json --out report.json
32
+ # One-shot (scoped package — use -p so the playwright-score binary is resolved)
33
+ npx -p @qaguardian/playwright-score playwright-score ./tests --profile standard --threshold 80
34
+
35
+ # After local install
36
+ npx playwright-score ./flow.spec.ts --profile guardian --format json --out report.json
30
37
  ```
31
38
 
32
39
  | Flag | Description |
@@ -35,8 +42,9 @@ npx @qaguardian/playwright-score ./flow.spec.ts --profile guardian --format json
35
42
  | `--threshold <n>` | Pass bar 0–100 |
36
43
  | `--format text\|json\|markdown\|sarif` | Output format |
37
44
  | `--out <file>` | Write report to file |
45
+ | `--version` | Print version |
38
46
 
39
- **Exit codes:** `0` pass · `1` below threshold · `2` tool error
47
+ **Exit codes:** `0` pass · `1` below threshold **or no files matched** · `2` tool error
40
48
 
41
49
  ## Library
42
50
 
@@ -52,11 +60,39 @@ const result = await scorePaths({
52
60
  console.log(result.score, result.grade, result.pass, result.findings);
53
61
  ```
54
62
 
55
- ## CI example
63
+ ## GitHub Action
64
+
65
+ Drop this into a workflow to gate PRs on the score, post a job summary, and
66
+ keep a sticky PR comment with the full findings up to date:
67
+
68
+ ```yaml
69
+ - uses: qa-guardian/playwright-score@v1
70
+ with:
71
+ paths: tests e2e
72
+ profile: standard # or guardian
73
+ # threshold: 80 # defaults to the profile's default
74
+ mode: gate # gate: fail CI below threshold · warn: report only
75
+ ```
76
+
77
+ | Input | Description | Default |
78
+ |---|---|---|
79
+ | `paths` | Space-separated paths/globs to score | `tests` |
80
+ | `profile` | `standard` \| `guardian` | `standard` |
81
+ | `threshold` | Pass bar 0–100 | profile default (80 / 75) |
82
+ | `mode` | `gate` (fail CI below threshold) \| `warn` (report only) | `gate` |
83
+ | `comment` | Post/update a sticky PR comment | `true` |
84
+ | `version` | `@qaguardian/playwright-score` version to run | `latest` |
85
+ | `github-token` | Token for the PR comment | `${{ github.token }}` |
86
+
87
+ Outputs: `score`, `grade`, `pass` — usable by downstream steps (e.g. a
88
+ custom badge, a Slack notification on regression, etc).
89
+
90
+ Prefer a raw CLI call, or a non-GitHub CI system? See the [CLI](#cli)
91
+ section above — same score, same exit codes:
56
92
 
57
93
  ```yaml
58
94
  - name: Playwright Spec Score
59
- run: npx playwright-score ./tests --profile standard --threshold 80 --format text
95
+ run: npx -p @qaguardian/playwright-score playwright-score ./tests --profile standard --threshold 80 --format text
60
96
  ```
61
97
 
62
98
  ## QA Guardian integration
package/dist/cli.js CHANGED
@@ -10,28 +10,44 @@ import { formatText } from './formatters/text.js';
10
10
  import { formatMarkdown } from './formatters/markdown.js';
11
11
  import { formatSarif } from './formatters/sarif.js';
12
12
  function printHelp() {
13
- console.log(`playwright-score — deterministic Playwright spec quality score (sqs-v1)
13
+ console.log(`@qaguardian/playwright-score — deterministic Playwright spec quality score (sqs-v1)
14
14
 
15
15
  Usage:
16
- playwright-score <paths...> [options]
16
+ npx -p @qaguardian/playwright-score playwright-score <paths...> [options]
17
+ # after npm install -D @qaguardian/playwright-score:
18
+ npx playwright-score <paths...> [options]
17
19
 
18
20
  Options:
19
21
  --profile <standard|guardian> Scoring profile (default: standard)
20
22
  --threshold <n> Pass threshold 0-100 (default: 80 standard / 75 guardian)
21
23
  --format <text|json|markdown|sarif> Output format (default: text)
22
24
  --out <file> Write report to file
25
+ --version Print version
23
26
  --help Show help
24
27
 
25
28
  Exit codes:
26
29
  0 score >= threshold
27
- 1 score < threshold
30
+ 1 score < threshold (or no files matched)
28
31
  2 tool error
29
32
 
30
- See METHODOLOGY.md for sqs-v1 formula.
33
+ Website: https://qaguardian.com/open-source/playwright-score
34
+ Methodology: METHODOLOGY.md (sqs-v1)
31
35
  `);
32
36
  }
33
37
  async function main() {
34
38
  const args = process.argv.slice(2);
39
+ if (args.includes('--version') || args.includes('-v')) {
40
+ try {
41
+ const { createRequire } = await import('node:module');
42
+ const require = createRequire(import.meta.url);
43
+ const pkg = require('../package.json');
44
+ console.log(pkg.version ?? '0.0.0');
45
+ }
46
+ catch {
47
+ console.log('0.1.8');
48
+ }
49
+ process.exit(0);
50
+ }
35
51
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
36
52
  printHelp();
37
53
  process.exit(args.length === 0 ? 2 : 0);
@@ -68,6 +84,9 @@ async function main() {
68
84
  else if (a === '--out') {
69
85
  out = args[++i];
70
86
  }
87
+ else if (a === '--version' || a === '-v') {
88
+ continue;
89
+ }
71
90
  else if (a.startsWith('-')) {
72
91
  console.error(`Unknown option: ${a}`);
73
92
  process.exit(2);
@@ -7,4 +7,6 @@ export declare function runEslint(options: {
7
7
  files: string[];
8
8
  profile: ProfileName;
9
9
  cwd?: string;
10
+ /** See findLocalAssertionHelperNames in metrics.ts. */
11
+ assertFunctionNames?: string[];
10
12
  }): Promise<Finding[]>;
@@ -1,9 +1,11 @@
1
1
  import path from 'node:path';
2
2
  import { ESLint } from 'eslint';
3
+ import { commonAncestorDir } from './fs-util.js';
3
4
  import playwright from 'eslint-plugin-playwright';
4
5
  import { guardianPlugin, guardianRuleConfigs, } from './rules/guardian-plugin.js';
6
+ import { basePlugin } from './rules/base-plugin.js';
5
7
  import { mapRule } from './profiles.js';
6
- function buildConfig(profile) {
8
+ function buildConfig(profile, assertFunctionNames) {
7
9
  const playwrightRecommended = playwright.configs?.['flat/recommended'] ??
8
10
  playwright.configs?.recommended;
9
11
  const base = [
@@ -16,21 +18,79 @@ function buildConfig(profile) {
16
18
  },
17
19
  plugins: {
18
20
  playwright,
21
+ pwscore: basePlugin,
19
22
  ...(profile === 'guardian' ? { guardian: guardianPlugin } : {}),
20
23
  },
21
24
  rules: {
22
- // Ensure key hygiene rules even if recommended set differs by version
25
+ // Ensure key hygiene rules even if recommended set differs by
26
+ // version. A few (no-wait-for-timeout, no-force-option,
27
+ // no-wait-for-selector, expect-expect) are intentionally stricter
28
+ // than eslint-plugin-playwright's own "recommended" severity —
29
+ // these are patterns Playwright's own docs call out as anti-
30
+ // patterns (see playwright.dev/docs/best-practices), so sqs-v1
31
+ // treats them as errors rather than warnings. no-networkidle and
32
+ // no-raw-locators aren't part of upstream "recommended" at all but
33
+ // are enabled here for the same reason.
23
34
  'playwright/no-wait-for-timeout': 'error',
24
35
  'playwright/no-networkidle': 'error',
25
36
  'playwright/no-force-option': 'error',
26
37
  'playwright/no-wait-for-selector': 'error',
27
38
  'playwright/no-wait-for-navigation': 'error',
28
39
  'playwright/missing-playwright-await': 'error',
29
- 'playwright/expect-expect': 'error',
40
+ // expect-expect can only see expect(...) calls written directly in
41
+ // the test body — it has no way to trace an assertion made inside
42
+ // a helper function the test calls. assertFunctionNames is
43
+ // populated per-run (see index.ts's findLocalAssertionHelperNames)
44
+ // with every same-file helper whose own body actually contains an
45
+ // expect(...)-shaped call, so this is name-agnostic for the common
46
+ // case regardless of what the helper is called. assertFunctionPatterns
47
+ // is a secondary, name-based fallback for helpers imported from
48
+ // another file (which we can't inspect the body of) — patterns are
49
+ // camelCase/exact-anchored so e.g. "checkoutFlow" doesn't collide
50
+ // with "check*"; the suffix pattern catches any name embedding a
51
+ // real Playwright matcher name (toBeVisible, toHaveText, ...).
52
+ // Verified against several real production files using several
53
+ // different naming conventions for the exact same delegation shape
54
+ // (audit, expectRealButtonToBeVisible, alertToBeVisible,
55
+ // checkFlashMessageVisibility) — all previously false-positived as
56
+ // "no assertions" at error severity.
57
+ 'playwright/expect-expect': [
58
+ 'error',
59
+ {
60
+ assertFunctionNames,
61
+ assertFunctionPatterns: [
62
+ '^(assert|verify|validate|audit|expect)([A-Z]|$)',
63
+ '^checkA11y$',
64
+ '^checkAccessibility$',
65
+ '(ToBe|ToHave|ToContain|ToMatch|ToEqual)[A-Z]',
66
+ ],
67
+ },
68
+ ],
30
69
  'playwright/no-focused-test': 'error',
31
- 'playwright/no-skipped-test': 'warn',
70
+ // Upstream flags every test.skip(...) form identically, including
71
+ // Playwright's own documented conditional runtime skip
72
+ // (test.skip(condition, reason)) — a false positive on entirely
73
+ // idiomatic cross-browser test code, verified against real-world
74
+ // suites. pwscore/no-skipped-test-declaration (below) replaces it
75
+ // with the same severity for the actual anti-pattern (an
76
+ // always-skipped test declaration) only.
77
+ 'playwright/no-skipped-test': 'off',
78
+ 'pwscore/no-skipped-test-declaration': 'warn',
32
79
  'playwright/no-raw-locators': 'warn',
33
- 'playwright/prefer-web-first-assertions': 'warn',
80
+ // Both were mapped in profiles.ts (report-only, so they don't
81
+ // double-penalize the ratio-based locators dimension) but never
82
+ // actually enabled anywhere — completely inert. prefer-locator
83
+ // specifically catches Playwright's older direct-action API
84
+ // (page.click('#foo') instead of a locator or native locator),
85
+ // arguably the single most common raw-selector anti-pattern in
86
+ // naive/AI-generated code — verified against a realistic sample
87
+ // that scored a perfect 100 on the locators dimension despite
88
+ // using nothing but page.click(selector)/page.type(selector, ...)
89
+ // throughout, before this fix.
90
+ 'playwright/prefer-locator': 'warn',
91
+ 'playwright/prefer-native-locators': 'warn',
92
+ // Matches eslint-plugin-playwright's own recommended severity.
93
+ 'playwright/prefer-web-first-assertions': 'error',
34
94
  ...(profile === 'guardian' ? { ...guardianRuleConfigs } : {}),
35
95
  },
36
96
  },
@@ -67,8 +127,18 @@ function toSeverity(eslintSeverity, override) {
67
127
  * Does not load host-repo eslint.config.js.
68
128
  */
69
129
  export async function runEslint(options) {
70
- const cwd = options.cwd ?? process.cwd();
71
- const overrideConfig = buildConfig(options.profile);
130
+ // ESLint 9 flat config resolves a `basePath` from `cwd` and silently
131
+ // drops (as an untracked, ruleId-less message) any file that isn't
132
+ // underneath it — no error, no findings, just a quietly clean score.
133
+ // The caller's cwd (e.g. a runner process's install dir) has no reason
134
+ // to contain the spec files being scored, so it can never be trusted
135
+ // as the ESLint basePath. Derive a basePath that is guaranteed to
136
+ // contain every file instead; fall back to the caller's cwd only when
137
+ // there are no files (nothing to lint anyway).
138
+ const cwd = options.files.length > 0
139
+ ? commonAncestorDir(options.files)
140
+ : (options.cwd ?? process.cwd());
141
+ const overrideConfig = buildConfig(options.profile, options.assertFunctionNames ?? []);
72
142
  // Try to attach typescript-eslint parser for .ts files
73
143
  try {
74
144
  const tsParser = await import('@typescript-eslint/parser');
@@ -99,8 +169,39 @@ export async function runEslint(options) {
99
169
  const results = await eslint.lintFiles(options.files);
100
170
  const findings = [];
101
171
  for (const result of results) {
102
- const file = path.resolve(result.filePath);
172
+ const absFile = path.resolve(result.filePath);
173
+ const file = path.relative(cwd, absFile) || path.basename(absFile);
103
174
  for (const msg of result.messages) {
175
+ // A `// eslint-disable-next-line some/rule-we-dont-bundle` comment is
176
+ // extremely common in real-world code (any host project that lints
177
+ // with plugins we don't embed, e.g. react-hooks, import, sonarjs, or
178
+ // even @typescript-eslint's own rules — we only bundle its parser,
179
+ // not its rule set). ESLint reports these with a fixed message
180
+ // regardless of reportUnusedDisableDirectives config, since it can't
181
+ // even validate a rule id it has no definition for. This is a
182
+ // diagnostic about *our* rule coverage, not the spec's quality —
183
+ // surfacing it as a finding (even report-only) is pure confusion,
184
+ // not signal.
185
+ if (/^Definition for rule '.*' was not found\.$/.test(msg.message)) {
186
+ continue;
187
+ }
188
+ if (msg.fatal) {
189
+ // A file that doesn't parse can't be meaningfully scored at all —
190
+ // surface it explicitly rather than silently dropping it (it has
191
+ // no ruleId) and letting an unparseable file default to a clean
192
+ // 100. scorePaths hard-fails the whole result when this rule id
193
+ // is present.
194
+ findings.push({
195
+ rule: 'playwright-score/parse-error',
196
+ severity: 'error',
197
+ message: msg.message,
198
+ file,
199
+ line: msg.line,
200
+ column: msg.column,
201
+ dimension: 'structure',
202
+ });
203
+ continue;
204
+ }
104
205
  if (!msg.ruleId)
105
206
  continue;
106
207
  const mapping = mapRule(msg.ruleId);
@@ -22,5 +22,15 @@ export function formatMarkdown(result) {
22
22
  lines.push(`- **${f.severity}** \`${f.rule}\` \`${f.file}${loc}\` — ${f.message}`);
23
23
  }
24
24
  }
25
+ if (result.skippedFiles?.length) {
26
+ lines.push('');
27
+ lines.push(`<details><summary>Skipped — looks like a non-Playwright test (Jest/Vitest/RTL) (${result.skippedFiles.length})</summary>`);
28
+ lines.push('');
29
+ for (const f of result.skippedFiles) {
30
+ lines.push(`- \`${f}\``);
31
+ }
32
+ lines.push('');
33
+ lines.push('</details>');
34
+ }
25
35
  return lines.join('\n');
26
36
  }
@@ -17,7 +17,7 @@ export function formatSarif(result) {
17
17
  tool: {
18
18
  driver: {
19
19
  name: 'playwright-score',
20
- informationUri: 'https://github.com/qaguardian/playwright-score',
20
+ informationUri: 'https://github.com/qa-guardian/playwright-score',
21
21
  version: result.scoreVersion,
22
22
  rules: [...rulesMap.values()],
23
23
  },
@@ -24,6 +24,16 @@ export function formatText(result) {
24
24
  lines.push(` … and ${result.findings.length - 50} more`);
25
25
  }
26
26
  }
27
+ if (result.skippedFiles?.length) {
28
+ lines.push('');
29
+ lines.push(`Skipped (looks like a non-Playwright test — Jest/Vitest/RTL — ${result.skippedFiles.length}):`);
30
+ for (const f of result.skippedFiles.slice(0, 20)) {
31
+ lines.push(` ${f}`);
32
+ }
33
+ if (result.skippedFiles.length > 20) {
34
+ lines.push(` … and ${result.skippedFiles.length - 20} more`);
35
+ }
36
+ }
27
37
  return lines.join('\n');
28
38
  }
29
39
  function pathBasename(p) {
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Deepest directory that contains every given absolute file path.
3
+ * Used as the ESLint flat-config basePath so linting never silently
4
+ * excludes files that fall outside an unrelated caller cwd.
5
+ */
6
+ export declare function commonAncestorDir(absoluteFiles: string[]): string;
@@ -0,0 +1,22 @@
1
+ import path from 'node:path';
2
+ /**
3
+ * Deepest directory that contains every given absolute file path.
4
+ * Used as the ESLint flat-config basePath so linting never silently
5
+ * excludes files that fall outside an unrelated caller cwd.
6
+ */
7
+ export function commonAncestorDir(absoluteFiles) {
8
+ const dirs = absoluteFiles.map((f) => path.dirname(path.resolve(f)));
9
+ if (dirs.length === 0)
10
+ return process.cwd();
11
+ let common = dirs[0].split(path.sep);
12
+ for (const dir of dirs.slice(1)) {
13
+ const parts = dir.split(path.sep);
14
+ let i = 0;
15
+ while (i < common.length && i < parts.length && common[i] === parts[i]) {
16
+ i++;
17
+ }
18
+ common = common.slice(0, i);
19
+ }
20
+ const joined = common.join(path.sep);
21
+ return joined || path.sep;
22
+ }
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { globSync } from 'glob';
4
4
  import { runEslint } from './eslint-runner.js';
5
- import { analyzeSource } from './metrics.js';
5
+ import { commonAncestorDir } from './fs-util.js';
6
+ import { analyzeSource, findLocalAssertionHelperNames, looksLikeNonPlaywrightTest, } from './metrics.js';
6
7
  import { DEFAULT_THRESHOLDS } from './profiles.js';
7
8
  import { computeScore } from './score-engine.js';
8
9
  export { computeScore, SQS_V1, locatorsScore, gradeFromScore } from './score-engine.js';
@@ -13,28 +14,103 @@ export { formatText } from './formatters/text.js';
13
14
  export { formatMarkdown } from './formatters/markdown.js';
14
15
  export { formatSarif } from './formatters/sarif.js';
15
16
  const SPEC_GLOBS = ['**/*.{spec,test}.{ts,tsx,js,jsx}', '**/*.spec.ts', '**/*.test.ts'];
17
+ // Directory/glob expansion must never sweep in vendored or generated code —
18
+ // verified as a real bug: scanning a project root with a plain node_modules
19
+ // dependency that ships its own *.spec.ts files silently included them
20
+ // alongside real specs. Every other JS tool in this space (ESLint,
21
+ // Prettier, Jest) default-excludes these directories for the same reason;
22
+ // an explicit file path always bypasses this (see `explicit` in
23
+ // expandPaths), so this only affects auto-discovery.
24
+ const DEFAULT_IGNORE_GLOBS = [
25
+ '**/node_modules/**',
26
+ '**/.git/**',
27
+ '**/dist/**',
28
+ '**/build/**',
29
+ '**/out/**',
30
+ '**/coverage/**',
31
+ '**/.next/**',
32
+ '**/.nuxt/**',
33
+ '**/.turbo/**',
34
+ '**/.cache/**',
35
+ '**/.svelte-kit/**',
36
+ '**/playwright-report/**',
37
+ '**/test-results/**',
38
+ ];
39
+ /**
40
+ * `explicit`: paths the caller named directly (a literal existing file) —
41
+ * always scored, regardless of whether they look like a Playwright spec.
42
+ * `expanded`: everything else (matched via a directory scan or a glob
43
+ * pattern) — subject to the looksLikePlaywrightSpec filter in scorePaths,
44
+ * since a broad `**\/*.test.ts`-style match can just as easily sweep in
45
+ * unrelated Jest/Vitest/RTL unit tests sitting in the same repo.
46
+ */
16
47
  function expandPaths(inputs, cwd) {
17
- const files = new Set();
48
+ const explicit = new Set();
49
+ const expanded = new Set();
18
50
  for (const input of inputs) {
19
51
  const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input);
20
52
  if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
21
- files.add(abs);
53
+ explicit.add(abs);
22
54
  continue;
23
55
  }
24
56
  if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
25
57
  for (const g of SPEC_GLOBS) {
26
- for (const f of globSync(g, { cwd: abs, absolute: true, nodir: true })) {
27
- files.add(path.resolve(f));
58
+ for (const f of globSync(g, {
59
+ cwd: abs,
60
+ absolute: true,
61
+ nodir: true,
62
+ ignore: DEFAULT_IGNORE_GLOBS,
63
+ })) {
64
+ expanded.add(path.resolve(f));
28
65
  }
29
66
  }
30
67
  continue;
31
68
  }
32
69
  // treat as glob
33
- for (const f of globSync(input, { cwd, absolute: true, nodir: true })) {
34
- files.add(path.resolve(f));
70
+ for (const f of globSync(input, {
71
+ cwd,
72
+ absolute: true,
73
+ nodir: true,
74
+ ignore: DEFAULT_IGNORE_GLOBS,
75
+ })) {
76
+ expanded.add(path.resolve(f));
35
77
  }
36
78
  }
37
- return [...files].sort();
79
+ // A file can be reached both explicitly and via expansion (e.g. an
80
+ // explicit path plus an overlapping glob); explicit intent wins.
81
+ for (const f of explicit)
82
+ expanded.delete(f);
83
+ return { explicit: [...explicit].sort(), expanded: [...expanded].sort() };
84
+ }
85
+ function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
86
+ return {
87
+ scoreVersion: 'sqs-v1',
88
+ profile,
89
+ score: 0,
90
+ grade: 'F',
91
+ pass: false,
92
+ threshold,
93
+ summary: {
94
+ files: 0,
95
+ tests: 0,
96
+ sloc: 0,
97
+ findings: findings.length,
98
+ errors: findings.filter((f) => f.severity === 'error').length,
99
+ warnings: findings.filter((f) => f.severity === 'warning').length,
100
+ nativeLocators: 0,
101
+ rawLocators: 0,
102
+ ...extra,
103
+ },
104
+ dimensions: {
105
+ playwrightHygiene: 0,
106
+ assertions: 0,
107
+ locators: 0,
108
+ structure: 0,
109
+ ...(profile === 'guardian' ? { guardianConventions: 0 } : {}),
110
+ },
111
+ findings,
112
+ ...(skippedFiles && skippedFiles.length > 0 ? { skippedFiles } : {}),
113
+ };
38
114
  }
39
115
  /**
40
116
  * Score Playwright spec files. Deterministic and AI-free (sqs-v1).
@@ -43,36 +119,78 @@ export async function scorePaths(options) {
43
119
  const cwd = options.cwd ?? process.cwd();
44
120
  const profile = options.profile ?? 'standard';
45
121
  const threshold = options.threshold ?? DEFAULT_THRESHOLDS[profile] ?? 80;
46
- const files = expandPaths(options.paths, cwd);
122
+ const { explicit, expanded } = expandPaths(options.paths, cwd);
123
+ if (explicit.length === 0 && expanded.length === 0) {
124
+ // Hard-fail: empty match must never look like a healthy suite (was ~99 PASS).
125
+ return hardFail(profile, threshold, [
126
+ {
127
+ rule: 'playwright-score/no-files',
128
+ severity: 'error',
129
+ message: `No Playwright spec files matched: ${options.paths.join(', ') || '(no paths)'}`,
130
+ file: cwd,
131
+ dimension: 'structure',
132
+ },
133
+ ]);
134
+ }
135
+ // Directory/glob matches are excluded only on positive evidence of a
136
+ // non-Playwright test framework — see looksLikeNonPlaywrightTest. Paths
137
+ // the caller named explicitly are always scored regardless.
138
+ const skippedFiles = [];
139
+ const acceptedExpanded = [];
140
+ const skipBase = commonAncestorDir([...explicit, ...expanded]);
141
+ for (const f of expanded) {
142
+ const source = fs.readFileSync(f, 'utf8');
143
+ if (looksLikeNonPlaywrightTest(source)) {
144
+ skippedFiles.push(path.relative(skipBase, f) || path.basename(f));
145
+ }
146
+ else {
147
+ acceptedExpanded.push(f);
148
+ }
149
+ }
150
+ const files = [...explicit, ...acceptedExpanded].sort();
47
151
  if (files.length === 0) {
48
- return computeScore({
49
- profile,
50
- threshold,
51
- findings: [
52
- {
53
- rule: 'playwright-score/no-files',
54
- severity: 'error',
55
- message: 'No Playwright spec files matched the given paths',
56
- file: cwd,
57
- dimension: 'structure',
58
- },
59
- ],
60
- sloc: 1,
61
- files: 0,
62
- tests: 0,
63
- nativeLocators: 0,
64
- rawLocators: 0,
65
- });
152
+ return hardFail(profile, threshold, [
153
+ {
154
+ rule: 'playwright-score/no-files',
155
+ severity: 'error',
156
+ message: `${expanded.length} file(s) matched but all look like non-Playwright tests (Jest/Vitest/RTL) — nothing to score.`,
157
+ file: cwd,
158
+ dimension: 'structure',
159
+ },
160
+ ], {}, skippedFiles);
66
161
  }
67
- const eslintFindings = await runEslint({ files, profile, cwd });
162
+ // Relative to the files' own common ancestor (not the caller's cwd, which
163
+ // may be unrelated) so findings stay portable across machines/CI and
164
+ // formatters/sarif.ts can emit repo-relative artifactLocation URIs.
165
+ const filesBase = commonAncestorDir(files);
166
+ // Read every file once, up front, so (a) we can feed ESLint's
167
+ // expect-expect a per-run list of local assertion-helper names before
168
+ // linting (see findLocalAssertionHelperNames) and (b) the metrics loop
169
+ // below doesn't re-read the same files ESLint already read internally.
170
+ const sources = new Map();
171
+ const assertFunctionNames = new Set();
172
+ for (const file of files) {
173
+ const source = fs.readFileSync(file, 'utf8');
174
+ sources.set(file, source);
175
+ for (const name of findLocalAssertionHelperNames(source, file)) {
176
+ assertFunctionNames.add(name);
177
+ }
178
+ }
179
+ const eslintFindings = await runEslint({
180
+ files,
181
+ profile,
182
+ cwd,
183
+ assertFunctionNames: [...assertFunctionNames],
184
+ });
68
185
  let totalSloc = 0;
69
186
  let totalTests = 0;
70
187
  let native = 0;
71
188
  let raw = 0;
72
189
  const metricFindings = [];
73
190
  for (const file of files) {
74
- const source = fs.readFileSync(file, 'utf8');
75
- const m = analyzeSource(source, file);
191
+ const source = sources.get(file) ?? '';
192
+ const relFile = path.relative(filesBase, file) || path.basename(file);
193
+ const m = analyzeSource(source, relFile);
76
194
  totalSloc += m.sloc;
77
195
  totalTests += m.tests;
78
196
  native += m.locators.native;
@@ -82,7 +200,21 @@ export async function scorePaths(options) {
82
200
  }
83
201
  // Dedupe empty-test style: if guardian require-expect already fired, still ok
84
202
  const findings = [...eslintFindings, ...metricFindings];
85
- return computeScore({
203
+ const parseErrors = findings.filter((f) => f.rule === 'playwright-score/parse-error');
204
+ if (parseErrors.length > 0) {
205
+ // A file that isn't valid JS/TS can't be executed, let alone scored on
206
+ // its merits. Diluting this into the normal per-dimension penalty math
207
+ // (a handful of exp-decay points on an otherwise large, clean batch)
208
+ // would let a broken file hide inside a passing score. Hard-fail
209
+ // instead, same as the no-matching-files case.
210
+ return hardFail(profile, threshold, findings, {
211
+ files: files.length,
212
+ sloc: Math.max(totalSloc, 1),
213
+ nativeLocators: native,
214
+ rawLocators: raw,
215
+ }, skippedFiles);
216
+ }
217
+ const result = computeScore({
86
218
  profile,
87
219
  threshold,
88
220
  findings,
@@ -92,4 +224,5 @@ export async function scorePaths(options) {
92
224
  nativeLocators: native,
93
225
  rawLocators: raw,
94
226
  });
227
+ return skippedFiles.length > 0 ? { ...result, skippedFiles } : result;
95
228
  }