rcf-lite 0.16.0 → 0.17.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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/blueprints/application-spa/assets/tc-templates/e2e.md +85 -0
  3. package/blueprints/application-spa/blueprint.json +24 -2
  4. package/blueprints/application-spa/contributions/user-stories/application-spa-us-1134.json +24 -0
  5. package/blueprints/application-spa/contributions/user-stories/application-spa-us-1135.json +24 -0
  6. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/pull-request-checks.yml +69 -0
  7. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/notes.md +18 -0
  8. package/blueprints/delivery-ci-workflows/blueprint.json +301 -60
  9. package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6124.json +28 -0
  10. package/fixtures/canary-manifest.json +9 -9
  11. package/package.json +13 -1
  12. package/rcf/code-nodes/cn-070.json +12 -0
  13. package/rcf/code-nodes/cn-071.json +12 -0
  14. package/rcf/code-nodes/cn-072.json +12 -0
  15. package/rcf/code-nodes/cn-073.json +12 -0
  16. package/rcf/fbs/fbs-020.json +18 -0
  17. package/rcf/fbs/fbs-021.json +18 -0
  18. package/rcf/fbs/fbs-022.json +18 -0
  19. package/rcf/fbs/fbs-023.json +18 -0
  20. package/rcf/prd.json +3 -2
  21. package/rcf/requirements/req-011.json +22 -0
  22. package/rcf/test-suites/ts-030.json +66 -0
  23. package/rcf/test-suites/ts-031.json +59 -0
  24. package/rcf/test-suites/ts-032.json +50 -0
  25. package/rcf/test-suites/ts-033.json +83 -0
  26. package/rcf/user-stories/us-1101.json +51 -0
  27. package/rcf/user-stories/us-1102.json +60 -0
  28. package/rcf/user-stories/us-1103.json +51 -0
  29. package/rcf/user-stories/us-1104.json +60 -0
  30. package/releases/releases.yaml +11 -1
  31. package/src/blueprint/supersede.js +2 -2
  32. package/src/cli/doctor.js +182 -5
  33. package/src/cli/init.js +166 -0
  34. package/src/setup/playwright-checks.js +426 -0
  35. package/src/verify/cli/run.js +28 -0
  36. package/src/verify/engine/index.js +24 -4
  37. package/src/verify/engine/launcher.js +41 -10
@@ -0,0 +1,426 @@
1
+ // Doctor's browser-facing project checks (spec 2026-09-03, section 3) plus
2
+ // init-time Playwright signature detection and the Claude Code cross-scope
3
+ // probe (spec sections 4.1-4.3). All probes are injectable via a `deps`
4
+ // argument so the unit suite runs with none of these tools installed.
5
+ //
6
+ // A project is browser-facing exactly when its rcf/manifest.json carries at
7
+ // least one blueprint in manifest.blueprints[] whose loaded blueprint.json
8
+ // declares `browserSurface`. Detection is a manifest fact, not string grammar:
9
+ // doctor loads the manifest, walks manifest.blueprints[].source, reads each
10
+ // blueprint.json, and evaluates the flag. Blueprints not shipping the field
11
+ // are treated as not-browser-facing, and no per-slug allowlist exists.
12
+ //
13
+ // The 15-second cap on the reachability probe is a diagnostic ceiling; on a
14
+ // warm cache the probe returns in well under a second, and the ceiling only
15
+ // matters where npx would be doing a fresh network fetch it should not be
16
+ // doing on a diagnostic run.
17
+
18
+ import { existsSync } from 'node:fs';
19
+ import { readFile, readdir, stat } from 'node:fs/promises';
20
+ import { createRequire } from 'node:module';
21
+ import { homedir } from 'node:os';
22
+ import { basename, isAbsolute, join, resolve as pathResolve } from 'node:path';
23
+ import { spawn } from 'node:child_process';
24
+
25
+ import { PLAYWRIGHT_MCP_VERSION } from '../verify/engine/launcher.js';
26
+
27
+ /**
28
+ * The three fix lines emitted per check, verbatim from the spec's fix-line
29
+ * table. Tests assert the strings; changing them requires a spec amendment.
30
+ */
31
+ export const FIX_LINES = Object.freeze({
32
+ 'playwright-present':
33
+ 'Install the peer dependency: npm i -D playwright@^1.50.0 (or the pnpm/yarn equivalent for your project).',
34
+ 'browser-present':
35
+ 'Install a browser: npx @playwright/mcp install-browser chromium (Playwright-managed) or install system Google Chrome (used by @playwright/mcp by default).',
36
+ 'playwright-mcp-reachable':
37
+ `Install @playwright/mcp: npm i -D @playwright/mcp@${PLAYWRIGHT_MCP_VERSION} (the pinned version rcf verify runs).`,
38
+ 'playwright-mcp-redundant':
39
+ "project-scope .mcp.json carries a Playwright MCP entry that is also declared at user scope. The project entry shadows the user entry. Remove the project entry with `rcf init --no-playwright-mcp` (which re-runs init without writing it), or delete the entry from .mcp.json by hand.",
40
+ });
41
+
42
+ /**
43
+ * The single skip line for non-browser-facing projects. Not suppressed by
44
+ * --quiet (spec 3.4).
45
+ */
46
+ export const SKIP_LINE_NON_BROWSER_FACING =
47
+ 'rcf doctor: skipping playwright-present, browser-present, playwright-mcp-reachable (no applied blueprint declares a browser surface).';
48
+
49
+ /**
50
+ * Is the manifest a browser-facing project per spec 3.1?
51
+ *
52
+ * @param {string} projectRoot
53
+ * @param {object} [deps]
54
+ * @param {(source: string) => Promise<object|null>} [deps.readBlueprintManifest] - test seam
55
+ * @returns {Promise<{ browserFacing: boolean, sources: string[] }>}
56
+ */
57
+ export async function loadBrowserFacingSources(projectRoot, deps = {}) {
58
+ const readManifest = deps.readBlueprintManifest ?? defaultReadBlueprintManifest;
59
+ const rootManifestPath = join(projectRoot, 'rcf', 'manifest.json');
60
+ let manifest = null;
61
+ try {
62
+ manifest = JSON.parse(await readFile(rootManifestPath, 'utf8'));
63
+ } catch (err) {
64
+ if (err.code === 'ENOENT') return { browserFacing: false, sources: [] };
65
+ throw err;
66
+ }
67
+ const applied = Array.isArray(manifest?.blueprints) ? manifest.blueprints : [];
68
+ const sources = [];
69
+ for (const record of applied) {
70
+ if (!record || typeof record.source !== 'string') continue;
71
+ const resolved = isAbsolute(record.source)
72
+ ? record.source
73
+ : pathResolve(projectRoot, record.source);
74
+ let bp;
75
+ try {
76
+ bp = await readManifest(resolved);
77
+ } catch {
78
+ bp = null;
79
+ }
80
+ if (bp && bp.browserSurface && bp.browserSurface.declared === true) {
81
+ sources.push(resolved);
82
+ }
83
+ }
84
+ return { browserFacing: sources.length > 0, sources };
85
+ }
86
+
87
+ async function defaultReadBlueprintManifest(source) {
88
+ const metaPath = join(source, 'blueprint.json');
89
+ try {
90
+ return JSON.parse(await readFile(metaPath, 'utf8'));
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Check that `playwright` is resolvable from the project root.
98
+ *
99
+ * @param {string} projectRoot
100
+ * @returns {{ ok: boolean, resolvedFrom: (string|null) }}
101
+ */
102
+ export function checkPlaywrightPresent(projectRoot) {
103
+ const req = createRequire(join(projectRoot, '__rcf-require-anchor__'));
104
+ try {
105
+ const resolvedFrom = req.resolve('playwright');
106
+ return { ok: true, resolvedFrom };
107
+ } catch {
108
+ return { ok: false, resolvedFrom: null };
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Check that some Chromium is available to @playwright/mcp: system Chrome on
114
+ * PATH first, then a Playwright-managed cache directory, then the tool's own
115
+ * first-party status probe.
116
+ *
117
+ * @param {object} [deps]
118
+ * @param {(name: string) => boolean} [deps.pathHas]
119
+ * @param {(path: string) => boolean} [deps.dirExists]
120
+ * @param {() => Promise<{ ok: boolean, source: string }>} [deps.probeMcpBrowserStatus]
121
+ * @param {() => string} [deps.homedir]
122
+ * @returns {Promise<{ ok: boolean, source: (string|null) }>}
123
+ */
124
+ export async function checkBrowserPresent(deps = {}) {
125
+ const pathHas = deps.pathHas ?? defaultPathHas;
126
+ const dirExists = deps.dirExists ?? existsSync;
127
+ const home = deps.homedir ?? homedir;
128
+ for (const bin of ['google-chrome-stable', 'chromium', 'chrome', 'Google Chrome']) {
129
+ if (pathHas(bin)) return { ok: true, source: `path:${bin}` };
130
+ }
131
+ const playwrightCache = join(home(), '.cache', 'ms-playwright');
132
+ if (dirExists(playwrightCache)) {
133
+ try {
134
+ const entries = await readdir(playwrightCache);
135
+ if (entries.some((e) => /^chromium/i.test(e))) {
136
+ return { ok: true, source: `cache:${playwrightCache}` };
137
+ }
138
+ } catch {
139
+ // fall through to first-party probe
140
+ }
141
+ }
142
+ const probe = deps.probeMcpBrowserStatus ?? defaultProbeMcpBrowserStatus;
143
+ try {
144
+ const res = await probe();
145
+ if (res && res.ok) return { ok: true, source: `mcp:${res.source ?? 'ok'}` };
146
+ } catch {
147
+ // treat any probe failure as no browser
148
+ }
149
+ return { ok: false, source: null };
150
+ }
151
+
152
+ function defaultPathHas(binName) {
153
+ const path = process.env.PATH ?? '';
154
+ const sep = process.platform === 'win32' ? ';' : ':';
155
+ for (const dir of path.split(sep)) {
156
+ if (!dir) continue;
157
+ try {
158
+ const p = join(dir, binName);
159
+ if (existsSync(p)) return true;
160
+ } catch {
161
+ /* ignore */
162
+ }
163
+ }
164
+ return false;
165
+ }
166
+
167
+ async function defaultProbeMcpBrowserStatus() {
168
+ return runNpxProbe(['--no-install', '@playwright/mcp', 'browser-status', '--json'], 15000)
169
+ .then((r) => (r.exitCode === 0 ? { ok: true, source: 'browser-status' } : { ok: false }))
170
+ .catch(() => ({ ok: false }));
171
+ }
172
+
173
+ /**
174
+ * Check that `npx --no-install @playwright/mcp --help` runs and exits 0 in
175
+ * under 15 seconds. `--no-install` so the check does not silently pull the
176
+ * package during a diagnostic run.
177
+ *
178
+ * @param {object} [deps]
179
+ * @param {(args: string[], timeoutMs: number) => Promise<{ exitCode: number, timedOut: boolean }>} [deps.runNpx]
180
+ * @param {number} [deps.timeoutMs]
181
+ * @returns {Promise<{ ok: boolean, exitCode: (number|null), timedOut: boolean }>}
182
+ */
183
+ export async function checkPlaywrightMcpReachable(deps = {}) {
184
+ const runNpx = deps.runNpx ?? runNpxProbe;
185
+ const timeoutMs = deps.timeoutMs ?? 15000;
186
+ try {
187
+ const res = await runNpx(['--no-install', '@playwright/mcp', '--help'], timeoutMs);
188
+ return {
189
+ ok: res.exitCode === 0,
190
+ exitCode: res.exitCode ?? null,
191
+ timedOut: Boolean(res.timedOut),
192
+ };
193
+ } catch (err) {
194
+ return { ok: false, exitCode: null, timedOut: false, error: err.message };
195
+ }
196
+ }
197
+
198
+ function runNpxProbe(args, timeoutMs) {
199
+ return new Promise((resolvePromise) => {
200
+ let child;
201
+ try {
202
+ child = spawn('npx', args, { stdio: 'ignore' });
203
+ } catch (err) {
204
+ resolvePromise({ exitCode: null, timedOut: false, error: err.message });
205
+ return;
206
+ }
207
+ let done = false;
208
+ const timer = setTimeout(() => {
209
+ done = true;
210
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
211
+ resolvePromise({ exitCode: null, timedOut: true });
212
+ }, timeoutMs);
213
+ child.on('close', (code) => {
214
+ if (done) return;
215
+ clearTimeout(timer);
216
+ done = true;
217
+ resolvePromise({ exitCode: code ?? null, timedOut: false });
218
+ });
219
+ child.on('error', (err) => {
220
+ if (done) return;
221
+ clearTimeout(timer);
222
+ done = true;
223
+ resolvePromise({ exitCode: null, timedOut: false, error: err.message });
224
+ });
225
+ });
226
+ }
227
+
228
+ /* ================================================================== */
229
+ /* Playwright signature detection (spec 4.1) + probe (spec 4.2/4.3). */
230
+ /* ================================================================== */
231
+
232
+ /** Regex naming a @playwright/mcp args token (any pinning suffix accepted). */
233
+ const PLAYWRIGHT_MCP_TOKEN = /^@playwright\/mcp(?:@|$)/;
234
+
235
+ /**
236
+ * True iff a parsed mcpServers[<name>] entry has a Playwright signature by
237
+ * command tail: any `args` string matching /^@playwright\/mcp(@|$)/, or a
238
+ * `command` path whose basename is `mcp` and lives under a @playwright/mcp
239
+ * package directory.
240
+ *
241
+ * @param {unknown} entry
242
+ * @returns {boolean}
243
+ */
244
+ export function hasPlaywrightSignature(entry) {
245
+ if (!entry || typeof entry !== 'object') return false;
246
+ const args = Array.isArray(entry.args) ? entry.args : [];
247
+ if (args.some((a) => typeof a === 'string' && PLAYWRIGHT_MCP_TOKEN.test(a))) return true;
248
+ if (typeof entry.command === 'string') {
249
+ const cmd = entry.command;
250
+ if (basename(cmd) === 'mcp' && /@playwright\/mcp/.test(cmd)) return true;
251
+ }
252
+ return false;
253
+ }
254
+
255
+ /**
256
+ * Look up the first project-scope entry key carrying a Playwright signature
257
+ * in a parsed .mcp.json body. Returns null when no entry matches.
258
+ *
259
+ * @param {object|null|undefined} mcpJson - the parsed .mcp.json body
260
+ * @returns {string|null}
261
+ */
262
+ export function findProjectPlaywrightKey(mcpJson) {
263
+ const servers = mcpJson?.mcpServers;
264
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return null;
265
+ for (const [key, entry] of Object.entries(servers)) {
266
+ if (hasPlaywrightSignature(entry)) return key;
267
+ }
268
+ return null;
269
+ }
270
+
271
+ /**
272
+ * Probe the Claude Code harness for a Playwright entry at any scope by
273
+ * shelling out to `claude mcp list` and parsing its text output. `list`
274
+ * itself does not name the scope column (as of Claude Code 1.x); when the
275
+ * list surfaces a Playwright entry, follow up with `claude mcp get <name>`
276
+ * to read the scope. Result shapes:
277
+ *
278
+ * - { kind: 'found', name, scope } - list matched signature and get named scope
279
+ * - { kind: 'none' } - claude ran cleanly and reported no Playwright entry
280
+ * - { kind: 'inconclusive', reason } - probe could not prove either way
281
+ *
282
+ * @param {object} [deps]
283
+ * @param {(cmd: string, args: string[], timeoutMs: number) => Promise<{ exitCode: number, stdout: string, timedOut: boolean, error?: string }>} [deps.runProbe]
284
+ * @param {number} [deps.timeoutMs]
285
+ * @returns {Promise<{ kind: 'found', name: string, scope: string }
286
+ * | { kind: 'none' } | { kind: 'inconclusive', reason: string }>}
287
+ */
288
+ export async function probeClaudeCodeMcp(deps = {}) {
289
+ const run = deps.runProbe ?? defaultRunProbeText;
290
+ const timeoutMs = deps.timeoutMs ?? 5000;
291
+ let res;
292
+ try {
293
+ res = await run('claude', ['mcp', 'list'], timeoutMs);
294
+ } catch (err) {
295
+ return { kind: 'inconclusive', reason: `claude probe threw: ${err.message}` };
296
+ }
297
+ if (res.timedOut) return { kind: 'inconclusive', reason: 'claude mcp list timed out' };
298
+ if (res.error) return { kind: 'inconclusive', reason: `claude probe error: ${res.error}` };
299
+ if (res.exitCode !== 0) return { kind: 'inconclusive', reason: `claude mcp list exited ${res.exitCode}` };
300
+ const parsed = parseClaudeMcpListOutput(res.stdout ?? '');
301
+ if (parsed.kind === 'unparseable') {
302
+ return { kind: 'inconclusive', reason: 'claude mcp list output did not parse' };
303
+ }
304
+ if (parsed.kind === 'none') return { kind: 'none' };
305
+ // parsed.kind === 'found': list has a Playwright line but the shipping
306
+ // `claude mcp list` format does not carry an explicit scope column. Ask
307
+ // for the per-server detail so we can name the scope on the print-out.
308
+ let scope = parsed.scope;
309
+ if (!scope || scope === 'unknown') {
310
+ try {
311
+ const detail = await run('claude', ['mcp', 'get', parsed.name], timeoutMs);
312
+ if (detail.exitCode === 0 && typeof detail.stdout === 'string') {
313
+ scope = parseClaudeMcpGetScope(detail.stdout) ?? 'unknown';
314
+ } else {
315
+ scope = 'unknown';
316
+ }
317
+ } catch {
318
+ scope = 'unknown';
319
+ }
320
+ }
321
+ return { kind: 'found', name: parsed.name, scope };
322
+ }
323
+
324
+ /**
325
+ * Read the Scope line out of `claude mcp get <name>` output. Format observed
326
+ * on Claude Code 1.x: ` Scope: User config (available in all your projects)`
327
+ * for user, ` Scope: Local config` / ` Scope: Project config` otherwise.
328
+ * Returns 'user' | 'project' | 'local', or null when the format is not
329
+ * recognised (the caller then reports scope as 'unknown').
330
+ *
331
+ * @param {string} text
332
+ * @returns {'user'|'project'|'local'|null}
333
+ */
334
+ export function parseClaudeMcpGetScope(text) {
335
+ if (typeof text !== 'string') return null;
336
+ const m = text.match(/Scope:\s*(User|Project|Local)/i);
337
+ if (!m) return null;
338
+ return m[1].toLowerCase();
339
+ }
340
+
341
+ /**
342
+ * Parse `claude mcp list` text output for a Playwright signature. Claude
343
+ * Code 1.x prints each server on its own line as
344
+ * `<name>: <command line> - <status>`; there is no scope column on the
345
+ * list output (per-server scope is available via `claude mcp get <name>`),
346
+ * so this parser returns the name and leaves the scope as 'unknown' for
347
+ * the caller to resolve if it wants one.
348
+ *
349
+ * Accepted line shapes:
350
+ * - `<name>: <command line> - <status>` (Claude Code 1.x)
351
+ * - `<name> <scope> <command line>` (older columnar form, kept for
352
+ * tolerance)
353
+ *
354
+ * @param {string} text
355
+ * @returns {{ kind: 'found', name: string, scope: string } | { kind: 'none' } | { kind: 'unparseable' }}
356
+ */
357
+ export function parseClaudeMcpListOutput(text) {
358
+ if (typeof text !== 'string') return { kind: 'unparseable' };
359
+ const lines = text.split('\n');
360
+ let sawAnyEntry = false;
361
+ for (const raw of lines) {
362
+ const line = raw.trim();
363
+ if (line.length === 0) continue;
364
+ if (/^(No |No MCP servers|Usage:|Error:|Checking |name\b)/i.test(line)) continue;
365
+ // Recognise a non-Playwright entry so we can distinguish "parseable but
366
+ // no Playwright" from "unparseable output".
367
+ if (/^[A-Za-z0-9_.\/@-]+:\s+/.test(line) || /\b(user|project|local)\b/.test(line)) {
368
+ sawAnyEntry = true;
369
+ }
370
+ if (!/@playwright\/mcp/.test(line)) continue;
371
+
372
+ // Extract the name. Both accepted shapes start with the name; the
373
+ // 1.x shape ends the name with a colon, the older shape is space-
374
+ // separated.
375
+ let name;
376
+ const colonForm = line.match(/^([A-Za-z0-9_.\/@-]+):\s+/);
377
+ if (colonForm) {
378
+ name = colonForm[1];
379
+ } else {
380
+ name = line.split(/\s+/)[0];
381
+ }
382
+ if (!name) continue;
383
+ // Older columnar shape may name the scope inline; keep that when present.
384
+ const scopeMatch = line.match(/\b(user|project|local)\b/);
385
+ const scope = scopeMatch ? scopeMatch[1] : 'unknown';
386
+ return { kind: 'found', name, scope };
387
+ }
388
+ if (sawAnyEntry) return { kind: 'none' };
389
+ if (lines.every((l) => l.trim().length === 0)) return { kind: 'none' };
390
+ return { kind: 'none' };
391
+ }
392
+
393
+ async function defaultRunProbeText(cmd, args, timeoutMs) {
394
+ return new Promise((resolvePromise) => {
395
+ let child;
396
+ try {
397
+ child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
398
+ } catch (err) {
399
+ resolvePromise({ exitCode: null, stdout: '', timedOut: false, error: err.message });
400
+ return;
401
+ }
402
+ let stdout = '';
403
+ child.stdout?.on('data', (d) => { stdout += d.toString(); });
404
+ let done = false;
405
+ const timer = setTimeout(() => {
406
+ done = true;
407
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
408
+ resolvePromise({ exitCode: null, stdout, timedOut: true });
409
+ }, timeoutMs);
410
+ child.on('close', (code) => {
411
+ if (done) return;
412
+ clearTimeout(timer);
413
+ done = true;
414
+ resolvePromise({ exitCode: code ?? null, stdout, timedOut: false });
415
+ });
416
+ child.on('error', (err) => {
417
+ if (done) return;
418
+ clearTimeout(timer);
419
+ done = true;
420
+ resolvePromise({ exitCode: null, stdout, timedOut: false, error: err.message });
421
+ });
422
+ });
423
+ }
424
+
425
+ // Silence unused import warnings for values consumed only via names.
426
+ void stat;
@@ -12,6 +12,7 @@ import { parseArgs } from 'node:util';
12
12
  import { formatError, isRcfError } from '#core/errors';
13
13
 
14
14
  import { runVerification } from '../engine/index.js';
15
+ import { isSemverString, PLAYWRIGHT_MCP_VERSION } from '../engine/launcher.js';
15
16
  import { serialiseReport } from '../report/index.js';
16
17
  import { gateTripped, FINDING_SEVERITIES } from '../verdict/index.js';
17
18
 
@@ -26,6 +27,7 @@ const OPTION_SPEC = {
26
27
  'severity-gate': { type: 'string' },
27
28
  'provision-mode': { type: 'string' },
28
29
  persona: { type: 'string' },
30
+ 'playwright-mcp-version': { type: 'string' },
29
31
  help: { type: 'boolean' },
30
32
  };
31
33
 
@@ -51,6 +53,12 @@ Optional:
51
53
  PASS | COSMETIC | DEGRADED | BROKEN
52
54
  --provision-mode <m> run | skip (default: run)
53
55
  --persona <name> Adversarial persona flavour (default: generic-sceptic)
56
+ --playwright-mcp-version <semver>
57
+ Override the pinned Playwright MCP version for
58
+ one run (emergency use only; a loud stderr notice
59
+ fires and the report records the overridden pin
60
+ as runStats.playwrightMcpVersion). Must be an
61
+ exact semver X.Y.Z.
54
62
  --help Print this help
55
63
 
56
64
  Exit codes:
@@ -119,6 +127,25 @@ export async function main(argv, deps = {}) {
119
127
  return 2;
120
128
  }
121
129
 
130
+ // --playwright-mcp-version override (spec 2026-09-03, section 1.4). Semver
131
+ // only; anything else refuses exit 2 with the spec-named message. When set,
132
+ // the effective pin fires a loud stderr override notice on preflight; when
133
+ // unset, preflight prints the pinned default.
134
+ const overrideRaw = flags['playwright-mcp-version'];
135
+ if (overrideRaw !== undefined && !isSemverString(overrideRaw)) {
136
+ // Spec sections 1.4 and 6 name the message without a prefix. The
137
+ // module's other refusals prefix with `[error] usage`, but this one
138
+ // is the spec-verbatim line and stands on its own.
139
+ stderr.write(`--playwright-mcp-version expects a semver string, got '${overrideRaw}'\n`);
140
+ return 2;
141
+ }
142
+ if (overrideRaw !== undefined) {
143
+ // Loud override notice on stderr (spec 1.4: not silenceable with quiet).
144
+ stderr.write(`Playwright MCP: OVERRIDE @playwright/mcp@${overrideRaw} (pinned default: ${PLAYWRIGHT_MCP_VERSION})\n`);
145
+ } else {
146
+ stderr.write(`Playwright MCP: pinned to @playwright/mcp@${PLAYWRIGHT_MCP_VERSION}\n`);
147
+ }
148
+
122
149
  const result = await runVerification({
123
150
  repo: flags.repo,
124
151
  chainRef: flags.chain,
@@ -129,6 +156,7 @@ export async function main(argv, deps = {}) {
129
156
  provisionMode,
130
157
  persona: flags.persona,
131
158
  severityGate: gate,
159
+ playwrightMcpVersion: overrideRaw,
132
160
  }, deps);
133
161
 
134
162
  if (isRcfError(result)) {
@@ -18,7 +18,7 @@ import {
18
18
  import { readChain as defaultReadChain } from '../chain/index.js';
19
19
  import { runProvisioning, cleanup as defaultCleanup } from '../provision/index.js';
20
20
  import { composeBrief } from './brief.js';
21
- import { resolveLauncher } from './launcher.js';
21
+ import { PLAYWRIGHT_MCP_VERSION, playwrightMcpConfig, resolveLauncher } from './launcher.js';
22
22
  import { aggregateVerdict, derivePerAcVerdicts, validateFinding } from '../verdict/index.js';
23
23
  import { buildReport } from '../report/index.js';
24
24
 
@@ -127,11 +127,23 @@ export async function runVerification(opts = {}, deps = {}) {
127
127
  if (cleanupResult.cleanupBlocked?.length) provisioning.cleanupBlocked = cleanupResult.cleanupBlocked;
128
128
  };
129
129
 
130
+ // Playwright MCP effective pin (spec 2026-09-03, section 1). The override
131
+ // overrides the constant for one run; unset means the pinned default. The
132
+ // effective pin lands on the report as runStats.playwrightMcpVersion so a
133
+ // report re-render tells the operator what browser tooling the pass ran
134
+ // against, whether default or overridden (spec Q1).
135
+ const playwrightMcpVersion = opts.playwrightMcpVersion ?? PLAYWRIGHT_MCP_VERSION;
136
+ const playwrightMcpOverridden = opts.playwrightMcpVersion !== undefined
137
+ && opts.playwrightMcpVersion !== null;
138
+ const mcpConfigForPin = playwrightMcpOverridden
139
+ ? playwrightMcpConfig(playwrightMcpVersion)
140
+ : undefined;
141
+
130
142
  // 6. Launch the isolated verifier agent (§7.3 isolation env, §9 fresh session).
131
143
  let launchResult;
132
144
  try {
133
145
  const launchAgent = await resolveLauncher(deps);
134
- launchResult = await launchAgent({ brief, url, profile });
146
+ launchResult = await launchAgent({ brief, url, profile, mcpConfig: mcpConfigForPin });
135
147
  } catch (err) {
136
148
  // A verifier agent that could not run — or whose output could not be
137
149
  // ingested — is NEVER a fabricated PASS (§9). But the report is still
@@ -147,6 +159,7 @@ export async function runVerification(opts = {}, deps = {}) {
147
159
  findings: [], blockedAcs, provisioning,
148
160
  launchFailure: { message: err.message, rawOutputPath: err.rawOutputPath ?? null },
149
161
  perAcVerdicts,
162
+ runStats: { playwrightMcpVersion },
150
163
  });
151
164
  return { report };
152
165
  }
@@ -162,14 +175,21 @@ export async function runVerification(opts = {}, deps = {}) {
162
175
  // 9. Aggregate the verdict — split, never averaged (§5.1).
163
176
  const verdict = aggregateVerdict({ findings, blockedAcs, notDeployed: false });
164
177
 
165
- // 10. Build the ingestible report (§5.3).
178
+ // 10. Build the ingestible report (§5.3). The effective Playwright MCP pin
179
+ // (default or overridden) always lands on the report as
180
+ // runStats.playwrightMcpVersion so a report re-render tells the operator
181
+ // which browser tooling this pass ran against (spec 2026-09-03, Q1).
182
+ const runStatsForReport = {
183
+ ...(launchResult?.runStats ?? {}),
184
+ playwrightMcpVersion,
185
+ };
166
186
  const report = buildReport({
167
187
  profile, url, parityEnv, reachability, chainRef: chain.chainRef, repo: opts.repo,
168
188
  persona: opts.persona, startedAt, finishedAt: now(),
169
189
  verifierIsolation: isolationProvenance(),
170
190
  verdict, verdictAuthority,
171
191
  findings, blockedAcs, provisioning,
172
- runStats: launchResult?.runStats ?? null,
192
+ runStats: runStatsForReport,
173
193
  perAcVerdicts,
174
194
  });
175
195
 
@@ -17,6 +17,46 @@ import { isolationEnv, isolationProvenance } from '#core/isolation';
17
17
  /** Env var naming a module (exporting `launchAgent`) to use instead of the default spawn launcher. The integration + manual-e2e seam. */
18
18
  export const LAUNCHER_ENV = 'RCF_VERIFY_LAUNCHER';
19
19
 
20
+ /**
21
+ * The pinned Playwright MCP version verify runs against. Verify is the ship
22
+ * gate; @latest re-resolves per verify run and any newly published
23
+ * Playwright MCP behaviour would become a silent runtime change on the next
24
+ * pass. The pin closes that reproducibility hole (spec 2026-09-03, section
25
+ * 1). A bump is a deliberate rcf-lite change with its own commit, CHANGELOG
26
+ * entry, and, where behaviour is affected, a re-run of the verify test set;
27
+ * pre-1.0, a Playwright MCP major bump ships as a rcf-lite minor bump per
28
+ * the pre-1.0 minor-is-breaking convention, a patch bump otherwise.
29
+ * `rcf verify run --playwright-mcp-version <semver>` overrides for one run
30
+ * only (emergency use), printed on stderr and recorded on the report.
31
+ */
32
+ export const PLAYWRIGHT_MCP_VERSION = '0.0.80';
33
+
34
+ /** True iff the value is a semver X.Y.Z (numeric segments; no pre-release). Verify's override
35
+ * accepts only exact semver so a typo cannot silently install a range. */
36
+ export function isSemverString(value) {
37
+ return typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value);
38
+ }
39
+
40
+ /**
41
+ * Compose the Playwright MCP config with the given pin. Exported so the CLI
42
+ * override can build the same shape without duplicating the DEFAULT_MCP_CONFIG
43
+ * literal.
44
+ * @param {string} version - the semver pin
45
+ * @returns {object}
46
+ */
47
+ export function playwrightMcpConfig(version) {
48
+ return {
49
+ mcpServers: {
50
+ playwright: {
51
+ type: 'stdio',
52
+ command: 'npx',
53
+ args: ['-y', `@playwright/mcp@${version}`],
54
+ env: {},
55
+ },
56
+ },
57
+ };
58
+ }
59
+
20
60
  /**
21
61
  * The minimal, SCOPED tool surface the verifier agent needs to drive a live
22
62
  * app and collect runtime evidence (spec §9 method). Deliberately narrow —
@@ -40,16 +80,7 @@ export const DEFAULT_ALLOWED_TOOLS = Object.freeze([
40
80
  * context (§9). Server name `playwright` -> tool prefix `mcp__playwright__*`.
41
81
  * `npx` is resolved off PATH for portability (no machine-specific absolute).
42
82
  */
43
- export const DEFAULT_MCP_CONFIG = Object.freeze({
44
- mcpServers: {
45
- playwright: {
46
- type: 'stdio',
47
- command: 'npx',
48
- args: ['-y', '@playwright/mcp@latest'],
49
- env: {},
50
- },
51
- },
52
- });
83
+ export const DEFAULT_MCP_CONFIG = Object.freeze(playwrightMcpConfig(PLAYWRIGHT_MCP_VERSION));
53
84
 
54
85
  /**
55
86
  * Build the child-process launch configuration for the verifier agent. Pure