rcf-lite 0.16.0 → 0.18.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 (94) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/blueprints/application-api-rest/README.md +5 -1
  3. package/blueprints/application-api-rest/blueprint.json +1 -1
  4. package/blueprints/application-api-rest/contributions/requirements/application-api-rest-req-006.json +4 -4
  5. package/blueprints/application-api-rest/contributions/tacs/tac-306-application-api-rest-operability.json +9 -8
  6. package/blueprints/application-api-rest/contributions/user-stories/application-api-rest-us-2103.json +3 -3
  7. package/blueprints/application-api-rest/contributions/user-stories/application-api-rest-us-2108.json +27 -27
  8. package/blueprints/application-api-rest/docs/topics.md +2 -2
  9. package/blueprints/application-api-rest/guide/application-api-rest.md +5 -1
  10. package/blueprints/application-spa/assets/tc-templates/e2e.md +85 -0
  11. package/blueprints/application-spa/blueprint.json +24 -2
  12. package/blueprints/application-spa/contributions/user-stories/application-spa-us-1134.json +24 -0
  13. package/blueprints/application-spa/contributions/user-stories/application-spa-us-1135.json +24 -0
  14. package/blueprints/application-spa/docs/topics.md +2 -2
  15. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/pull-request-checks.yml +69 -0
  16. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/notes.md +18 -0
  17. package/blueprints/delivery-ci-workflows/blueprint.json +301 -60
  18. package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6124.json +28 -0
  19. package/blueprints/delivery-ci-workflows/docs/topics.md +2 -2
  20. package/blueprints/deploy-cloudflare-workers/docs/topics.md +2 -2
  21. package/blueprints/email-smtp-resend/docs/topics.md +2 -2
  22. package/blueprints/observability-essentials/README.md +6 -2
  23. package/blueprints/observability-essentials/blueprint.json +133 -33
  24. package/blueprints/observability-essentials/contributions/adrs/adr-801-observability-essentials-health-probes.json +4 -4
  25. package/blueprints/observability-essentials/contributions/adrs/adr-802-observability-essentials-readiness-semantics.json +4 -4
  26. package/blueprints/observability-essentials/contributions/requirements/observability-essentials-req-001.json +4 -4
  27. package/blueprints/observability-essentials/contributions/requirements/observability-essentials-req-002.json +4 -4
  28. package/blueprints/observability-essentials/contributions/tacs/tac-801-observability-essentials-liveness-probe.json +10 -9
  29. package/blueprints/observability-essentials/contributions/tacs/tac-802-observability-essentials-readiness-probe.json +17 -11
  30. package/blueprints/observability-essentials/contributions/user-stories/observability-essentials-us-7101.json +12 -3
  31. package/blueprints/observability-essentials/contributions/user-stories/observability-essentials-us-7102.json +12 -3
  32. package/blueprints/observability-essentials/docs/topics.md +15 -8
  33. package/blueprints/observability-essentials/guide/observability-essentials.md +9 -3
  34. package/blueprints/observability-probe-endpoints/README.md +5 -1
  35. package/blueprints/observability-probe-endpoints/blueprint.json +116 -24
  36. package/blueprints/observability-probe-endpoints/contributions/adrs/adr-1503-observability-probe-endpoints-kubernetes-default.json +5 -5
  37. package/blueprints/observability-probe-endpoints/contributions/user-stories/observability-probe-endpoints-us-14102.json +11 -2
  38. package/blueprints/observability-probe-endpoints/contributions/user-stories/observability-probe-endpoints-us-14107.json +11 -2
  39. package/blueprints/observability-probe-endpoints/docs/topics.md +6 -6
  40. package/blueprints/observability-probe-endpoints/guide/observability-probe-endpoints.md +10 -0
  41. package/blueprints/persistence-data-d1/docs/topics.md +2 -2
  42. package/blueprints/persistence-data-sqlite/docs/topics.md +2 -2
  43. package/blueprints/security-auth-clerk/docs/topics.md +2 -2
  44. package/blueprints/security-auth-keycloak/docs/topics.md +2 -2
  45. package/blueprints/security-auth-magic-link/docs/topics.md +2 -2
  46. package/blueprints/security-auth-oauth2/docs/topics.md +2 -2
  47. package/blueprints/security-secrets-management/docs/topics.md +2 -2
  48. package/fixtures/canary-manifest.json +9 -9
  49. package/package.json +13 -1
  50. package/rcf/code-nodes/cn-070.json +12 -0
  51. package/rcf/code-nodes/cn-071.json +12 -0
  52. package/rcf/code-nodes/cn-072.json +12 -0
  53. package/rcf/code-nodes/cn-073.json +12 -0
  54. package/rcf/code-nodes/cn-074.json +19 -0
  55. package/rcf/code-nodes/cn-075.json +15 -0
  56. package/rcf/code-nodes/cn-076.json +14 -0
  57. package/rcf/fbs/fbs-020.json +18 -0
  58. package/rcf/fbs/fbs-021.json +18 -0
  59. package/rcf/fbs/fbs-022.json +18 -0
  60. package/rcf/fbs/fbs-023.json +18 -0
  61. package/rcf/fbs/fbs-024.json +24 -0
  62. package/rcf/fbs/fbs-025.json +25 -0
  63. package/rcf/fbs/fbs-026.json +27 -0
  64. package/rcf/fbs/fbs-027.json +25 -0
  65. package/rcf/prd.json +3 -2
  66. package/rcf/requirements/req-011.json +22 -0
  67. package/rcf/requirements/req-012.json +22 -0
  68. package/rcf/test-suites/ts-030.json +66 -0
  69. package/rcf/test-suites/ts-031.json +59 -0
  70. package/rcf/test-suites/ts-032.json +50 -0
  71. package/rcf/test-suites/ts-033.json +83 -0
  72. package/rcf/test-suites/ts-034.json +32 -0
  73. package/rcf/test-suites/ts-035.json +23 -0
  74. package/rcf/test-suites/ts-036.json +65 -0
  75. package/rcf/test-suites/ts-037.json +55 -0
  76. package/rcf/user-stories/us-1101.json +51 -0
  77. package/rcf/user-stories/us-1102.json +60 -0
  78. package/rcf/user-stories/us-1103.json +51 -0
  79. package/rcf/user-stories/us-1104.json +60 -0
  80. package/rcf/user-stories/us-1201.json +34 -0
  81. package/rcf/user-stories/us-1202.json +25 -0
  82. package/rcf/user-stories/us-1203.json +43 -0
  83. package/rcf/user-stories/us-1204.json +25 -0
  84. package/releases/releases.yaml +21 -1
  85. package/src/blueprint/index.js +1 -0
  86. package/src/blueprint/remove-resolution.js +104 -0
  87. package/src/blueprint/supersede.js +2 -2
  88. package/src/cli/blueprint.js +41 -0
  89. package/src/cli/doctor.js +257 -5
  90. package/src/cli/init.js +166 -0
  91. package/src/setup/playwright-checks.js +426 -0
  92. package/src/verify/cli/run.js +28 -0
  93. package/src/verify/engine/index.js +24 -4
  94. package/src/verify/engine/launcher.js +41 -10
package/src/cli/init.js CHANGED
@@ -31,6 +31,11 @@ import {
31
31
  writeAgentInstructions,
32
32
  writeMcpConfig,
33
33
  } from '../setup/agent-setup.js';
34
+ import {
35
+ findProjectPlaywrightKey,
36
+ probeClaudeCodeMcp,
37
+ } from '../setup/playwright-checks.js';
38
+ import { PLAYWRIGHT_MCP_VERSION } from '../verify/engine/launcher.js';
34
39
  import { writeKnowledgeSeed } from '../setup/knowledge-seed.js';
35
40
  import { writeIdentityTemplate } from '../setup/identity-seed.js';
36
41
  import {
@@ -46,6 +51,7 @@ const OPTION_SPEC = {
46
51
  'project-name': { type: 'string' },
47
52
  'non-interactive': { type: 'boolean' },
48
53
  'no-agent-setup': { type: 'boolean' },
54
+ 'no-playwright-mcp': { type: 'boolean' },
49
55
  quiet: { type: 'boolean' },
50
56
  help: { type: 'boolean' },
51
57
  };
@@ -70,6 +76,11 @@ Options:
70
76
  not on a TTY or when piped)
71
77
  --no-agent-setup Scaffold the tree only; print the manual
72
78
  harness-wiring instructions instead
79
+ --no-playwright-mcp Skip the Playwright MCP entry step. The probe
80
+ still runs so the print-out remains honest, but
81
+ init writes no Playwright entry and touches no
82
+ existing one. Use when a user-scope Playwright
83
+ entry is declared in a harness init cannot probe.
73
84
  --quiet Suppress non-error stdout
74
85
  --help Print this help
75
86
  `;
@@ -172,6 +183,26 @@ export async function main(argv, deps = {}) {
172
183
  return 2;
173
184
  }
174
185
 
186
+ // Step 1a: Playwright MCP entry (spec 2026-09-03, section 4). Writes a
187
+ // distinctly named 'playwright-rcf' entry ONLY when init can prove no
188
+ // Playwright entry exists at any scope the harness can report. Detection
189
+ // is by command tail (spec 4.1). Cross-scope probe of the Claude Code
190
+ // harness via `claude mcp list` (spec 4.2). Where the harness cannot be
191
+ // probed non-interactively, init falls back to the distinctly-named entry
192
+ // with a notice (spec 4.3). --no-playwright-mcp suppresses the write
193
+ // entirely; the probe still runs so the print-out remains honest.
194
+ const playwrightPass = await runPlaywrightMcpPass({
195
+ projectRoot: cwd,
196
+ stdout,
197
+ quiet: Boolean(flags.quiet),
198
+ optOut: Boolean(flags['no-playwright-mcp']),
199
+ probeClaudeCodeMcp: deps.probeClaudeCodeMcp ?? probeClaudeCodeMcp,
200
+ });
201
+ if (playwrightPass && 'kind' in playwrightPass && 'message' in playwrightPass) {
202
+ stderr.write(`[error] ${playwrightPass.kind} ${playwrightPass.message}\n`);
203
+ return 2;
204
+ }
205
+
175
206
  // Step 2: agent-instructions managed block inside rcf markers (idempotent).
176
207
  // Source is the 0.6.0 canonical asset, not the harness-template fenced
177
208
  // fragment (which is now regenerated from the same canonical text at
@@ -297,3 +328,138 @@ function countLocal(haystack, needle) {
297
328
  i = at + needle.length;
298
329
  }
299
330
  }
331
+
332
+ /**
333
+ * Init's Playwright MCP pass (spec 2026-09-03, section 4). Decision tree:
334
+ *
335
+ * 1. If --no-playwright-mcp: probe still runs so the print-out is honest,
336
+ * but no .mcp.json entry is written or touched.
337
+ * 2. If the project-scope .mcp.json carries a Playwright signature under
338
+ * any key (spec 4.1): print the 'left alone' line and return.
339
+ * 3. Probe the Claude Code harness (spec 4.2):
340
+ * - 'found': print the 'already registered at <scope> scope' line and
341
+ * return (no shadowing).
342
+ * - 'none': write the distinctly-named 'playwright-rcf' entry.
343
+ * - 'inconclusive' (claude absent, non-zero, or unparseable): write the
344
+ * distinctly-named 'playwright-rcf' entry with the 'could not probe'
345
+ * notice.
346
+ *
347
+ * A distinct project-scope entry keeps init's "never write outside the
348
+ * project root" discipline; a coexisting user-scope entry the operator has
349
+ * that init could not see is not shadowed by naming convention.
350
+ *
351
+ * @param {object} args
352
+ * @param {string} args.projectRoot
353
+ * @param {NodeJS.WritableStream} args.stdout
354
+ * @param {boolean} args.quiet
355
+ * @param {boolean} args.optOut
356
+ * @param {import('../setup/playwright-checks.js').probeClaudeCodeMcp} args.probeClaudeCodeMcp
357
+ * @returns {Promise<{ action: 'left-alone-project'|'left-alone-harness'|'written'|'skipped-opt-out', name?: string, scope?: string } | import('../core/errors/index.js').RcfError>}
358
+ */
359
+ async function runPlaywrightMcpPass({ projectRoot, stdout, quiet, optOut, probeClaudeCodeMcp: probeImpl }) {
360
+ const mcpPath = join(projectRoot, '.mcp.json');
361
+ let mcpJson = null;
362
+ try {
363
+ const raw = await readFile(mcpPath, 'utf8');
364
+ try {
365
+ mcpJson = JSON.parse(raw);
366
+ } catch {
367
+ // writeMcpConfig already refused unparseable with a distinct error and
368
+ // returned before us; if we somehow reach here with a corrupt file,
369
+ // stay out and let doctor surface it. Return silently.
370
+ return { action: 'skipped-opt-out' };
371
+ }
372
+ } catch (err) {
373
+ if (err.code !== 'ENOENT') throw err;
374
+ // .mcp.json does not exist yet (rcf mcp write above would have created
375
+ // it; if that step ran we should not hit ENOENT). Treat as no project
376
+ // entry and continue to the probe.
377
+ }
378
+
379
+ const projectKey = mcpJson ? findProjectPlaywrightKey(mcpJson) : null;
380
+ // Gate finding 8 (2026-09-03): when --no-playwright-mcp is set, print the
381
+ // opt-out notice on EVERY terminal branch of this pass, in addition to the
382
+ // honest probe line. The probe still runs (spec 4.4) so the print-out is
383
+ // always honest about what init could see; the notice makes the reason a
384
+ // write did not happen unambiguous on the branches that would otherwise
385
+ // have written.
386
+ const optOutLine = 'Playwright MCP: --no-playwright-mcp set; nothing written.\n';
387
+
388
+ if (projectKey) {
389
+ if (!quiet) {
390
+ stdout.write(
391
+ `Playwright MCP: already registered in .mcp.json under key '${projectKey}' at project scope, left alone.\n`,
392
+ );
393
+ if (optOut) stdout.write(optOutLine);
394
+ }
395
+ return { action: 'left-alone-project', name: projectKey };
396
+ }
397
+
398
+ // Probe the harness even when --no-playwright-mcp so the print-out remains
399
+ // honest about what init can see.
400
+ const probeResult = await probeImpl();
401
+ if (probeResult.kind === 'found') {
402
+ if (!quiet) {
403
+ stdout.write(
404
+ `Playwright MCP: already registered under key '${probeResult.name}' at ${probeResult.scope} scope in Claude Code, left alone.\n`,
405
+ );
406
+ if (optOut) stdout.write(optOutLine);
407
+ }
408
+ return { action: 'left-alone-harness', name: probeResult.name, scope: probeResult.scope };
409
+ }
410
+
411
+ if (optOut) {
412
+ if (!quiet) {
413
+ // The honest probe line first, then the opt-out notice.
414
+ if (probeResult.kind === 'inconclusive') {
415
+ stdout.write(
416
+ `Playwright MCP: could not probe user-scope entries (${probeResult.reason}).\n`,
417
+ );
418
+ } else {
419
+ stdout.write(
420
+ 'Playwright MCP: no ambient Playwright entry found at any scope this init could probe.\n',
421
+ );
422
+ }
423
+ stdout.write(optOutLine);
424
+ }
425
+ return { action: 'skipped-opt-out' };
426
+ }
427
+
428
+ // Compose the distinctly-named entry. Distinct from 'playwright' (the
429
+ // common name a user-scope entry uses) and from any name Claude Code
430
+ // writes by default. If init could not see a user-scope entry that
431
+ // actually exists, both coexist and no shadowing has occurred; verify
432
+ // provisions its OWN MCP config anyway.
433
+ const distinctEntry = {
434
+ type: 'stdio',
435
+ command: 'npx',
436
+ args: ['-y', `@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}`],
437
+ env: {},
438
+ };
439
+ const nextBody = mcpJson ?? {};
440
+ const servers = (nextBody.mcpServers && typeof nextBody.mcpServers === 'object' && !Array.isArray(nextBody.mcpServers))
441
+ ? nextBody.mcpServers
442
+ : {};
443
+ const nextConfig = {
444
+ ...nextBody,
445
+ mcpServers: {
446
+ ...servers,
447
+ 'playwright-rcf': distinctEntry,
448
+ },
449
+ };
450
+ await writeFile(mcpPath, `${JSON.stringify(nextConfig, null, 2)}\n`, 'utf8');
451
+
452
+ if (!quiet) {
453
+ if (probeResult.kind === 'inconclusive') {
454
+ stdout.write(
455
+ `Playwright MCP: could not probe user-scope entries (${probeResult.reason}). Wrote 'playwright-rcf' at project scope; remove with \`rcf init --no-playwright-mcp\` or delete the entry by hand.\n`,
456
+ );
457
+ } else {
458
+ stdout.write(
459
+ "Playwright MCP: wrote 'playwright-rcf' at project scope (no ambient Playwright entry found at any scope this init could probe). Remove with `rcf init --no-playwright-mcp` or delete the entry by hand.\n",
460
+ );
461
+ }
462
+ }
463
+ return { action: 'written' };
464
+ }
465
+
@@ -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)) {