vibepro 0.1.0-alpha.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 (89) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +9 -0
  3. package/README.ja.md +448 -0
  4. package/README.md +520 -0
  5. package/agent-instructions/codex/AGENTS.vibepro.md +45 -0
  6. package/bin/vibepro.js +9 -0
  7. package/docs/assets/vibepro-header.png +0 -0
  8. package/package.json +51 -0
  9. package/skills/vibepro-diagnosis-packages/SKILL.md +133 -0
  10. package/skills/vibepro-human-review/SKILL.md +73 -0
  11. package/skills/vibepro-story-refactor/SKILL.md +89 -0
  12. package/skills/vibepro-workflow/SKILL.md +139 -0
  13. package/src/agent-harness-map.js +230 -0
  14. package/src/agent-harness-scanner.js +337 -0
  15. package/src/agent-review.js +2180 -0
  16. package/src/api-boundary-scanner.js +452 -0
  17. package/src/architecture-profiler.js +423 -0
  18. package/src/authorization-scoring.js +149 -0
  19. package/src/brainbase-importer.js +534 -0
  20. package/src/change-risk-classifier.js +195 -0
  21. package/src/check-packs.js +605 -0
  22. package/src/checkpoint-manager.js +233 -0
  23. package/src/cli.js +2213 -0
  24. package/src/code-quality-scanner.js +310 -0
  25. package/src/codex-manager.js +143 -0
  26. package/src/component-style-scanner.js +336 -0
  27. package/src/coverage-report.js +99 -0
  28. package/src/database-access-scanner.js +163 -0
  29. package/src/decision-records.js +315 -0
  30. package/src/design-modernize.js +1435 -0
  31. package/src/design-system.js +1732 -0
  32. package/src/diagnostic-engine.js +1945 -0
  33. package/src/diagram-requirement-resolver.js +194 -0
  34. package/src/doctor.js +677 -0
  35. package/src/environment-graph.js +424 -0
  36. package/src/execution-state.js +849 -0
  37. package/src/explore-evidence.js +425 -0
  38. package/src/flow-design-scanner.js +896 -0
  39. package/src/flow-verifier.js +887 -0
  40. package/src/gesture-interaction-scanner.js +330 -0
  41. package/src/graph-context.js +263 -0
  42. package/src/graphify-adapter.js +189 -0
  43. package/src/html-report.js +1035 -0
  44. package/src/journey-map.js +1299 -0
  45. package/src/language.js +48 -0
  46. package/src/lazy-pattern-detector.js +182 -0
  47. package/src/local-dev-scanner.js +135 -0
  48. package/src/managed-worktree-gate.js +187 -0
  49. package/src/managed-worktree.js +766 -0
  50. package/src/merge-manager.js +501 -0
  51. package/src/network-contract-scanner.js +442 -0
  52. package/src/nocodb-story-sync.js +386 -0
  53. package/src/oss-readiness-scanner.js +417 -0
  54. package/src/performance-evidence.js +756 -0
  55. package/src/performance-measurer.js +591 -0
  56. package/src/pr-manager.js +8220 -0
  57. package/src/presets.js +682 -0
  58. package/src/public-discovery-scanner.js +519 -0
  59. package/src/refactoring-delta-reporter.js +367 -0
  60. package/src/refactoring-opportunity-generator.js +797 -0
  61. package/src/regression-risk-scanner.js +146 -0
  62. package/src/repo-status.js +266 -0
  63. package/src/report-fingerprint.js +188 -0
  64. package/src/report-pr-body-prompt-template.md +108 -0
  65. package/src/report-pr-body-schema.json +95 -0
  66. package/src/report-store.js +135 -0
  67. package/src/report-validator.js +192 -0
  68. package/src/requirement-consistency.js +1066 -0
  69. package/src/runtime-info.js +134 -0
  70. package/src/self-dogfood-scanner.js +476 -0
  71. package/src/session-learning.js +164 -0
  72. package/src/skills-manager.js +157 -0
  73. package/src/spec-drift.js +378 -0
  74. package/src/spec-fingerprint.js +445 -0
  75. package/src/spec-prompt-template.md +155 -0
  76. package/src/spec-schema.json +219 -0
  77. package/src/spec-store.js +258 -0
  78. package/src/spec-validator.js +459 -0
  79. package/src/static-site-scanner.js +316 -0
  80. package/src/story-candidate-generator.js +85 -0
  81. package/src/story-catalog-generator.js +2813 -0
  82. package/src/story-html.js +156 -0
  83. package/src/story-manager.js +2144 -0
  84. package/src/story-task-generator.js +522 -0
  85. package/src/task-manager.js +1029 -0
  86. package/src/terminal-link-scanner.js +238 -0
  87. package/src/usage-report.js +417 -0
  88. package/src/verification-evidence.js +284 -0
  89. package/src/workspace.js +126 -0
@@ -0,0 +1,887 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+
7
+ import { getWorkspaceDir, initWorkspace, readManifest, toWorkspaceRelative, writeManifest } from './workspace.js';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ export async function runFlowVerification(repoRoot, options = {}) {
12
+ if (!options.baseUrl) throw new Error('verify flow requires --base-url <url>');
13
+ await initWorkspace(repoRoot);
14
+ const root = path.resolve(repoRoot);
15
+ const runId = options.runId ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '');
16
+ const runDir = path.join(getWorkspaceDir(root), 'verification', runId);
17
+ const screenshotDir = path.join(runDir, 'screenshots');
18
+ await mkdir(screenshotDir, { recursive: true });
19
+
20
+ const config = await readConfig(root);
21
+ const manifest = await readManifest(root);
22
+ const story = resolveStory(config, options.storyId);
23
+ const probes = resolveFlowProbes({ config, manifest, story, journeyId: options.journeyId });
24
+ const connection = resolveConnectionOptions(options, options.env ?? process.env);
25
+ const playwright = await detectPlaywright(root);
26
+ const startedAt = new Date().toISOString();
27
+ const gitContext = await collectFlowGitContext(root);
28
+ const warnings = normalizeWarnings([options.managedWorktreeWarning]);
29
+
30
+ let commandResult = null;
31
+ let generatedSpecPath = null;
32
+ let generatedConfigPath = null;
33
+ const probeResults = probes.map((probe) => buildPendingProbeResult(probe, options));
34
+ const runnableProbes = probeResults.filter((probe) => probe.status === 'pending');
35
+ let status = 'pass';
36
+ let reason = null;
37
+
38
+ if (!playwright.detected) {
39
+ status = 'needs_setup';
40
+ reason = playwright.reason;
41
+ for (const probe of probeResults) {
42
+ if (probe.status === 'pending') {
43
+ probe.status = 'needs_setup';
44
+ probe.reason = playwright.reason;
45
+ }
46
+ }
47
+ } else if (runnableProbes.length > 0) {
48
+ generatedSpecPath = path.join(runDir, 'flow-verification.spec.js');
49
+ generatedConfigPath = path.join(runDir, 'playwright.config.mjs');
50
+ await writeFile(generatedSpecPath, renderPlaywrightSpec({
51
+ probes: runnableProbes,
52
+ baseUrl: connection.baseUrl,
53
+ screenshotDir
54
+ }));
55
+ await writeFile(generatedConfigPath, renderPlaywrightConfig());
56
+ commandResult = await runPlaywright(root, {
57
+ specPath: generatedSpecPath,
58
+ configPath: generatedConfigPath,
59
+ baseUrl: connection.baseUrl,
60
+ httpAuth: connection.httpAuth,
61
+ headed: options.headed === true,
62
+ env: options.env
63
+ });
64
+ const setupIssue = commandResult.exit_code === 0 ? null : detectPlaywrightSetupIssue(commandResult);
65
+ const runtimeContractFailures = commandResult.runtime_contract_failures ?? [];
66
+ const commandStatus = commandResult.exit_code === 0 && runtimeContractFailures.length === 0 ? 'pass' : setupIssue ? 'needs_setup' : 'fail';
67
+ for (const probe of runnableProbes) {
68
+ probe.status = commandStatus;
69
+ probe.exit_code = commandResult.exit_code;
70
+ probe.artifacts.log = 'playwright-output.log';
71
+ if (setupIssue) probe.reason = setupIssue.reason;
72
+ }
73
+ status = commandStatus;
74
+ if (setupIssue) reason = setupIssue.reason;
75
+ else if (commandStatus === 'fail') reason = `Playwright exited with code ${commandResult.exit_code}`;
76
+ if (setupIssue) commandResult.setup = setupIssue;
77
+ } else if (probeResults.length === 0) {
78
+ status = 'needs_evidence';
79
+ reason = 'No runtime probes were configured for Flow Verification.';
80
+ } else if (probeResults.some((probe) => probe.status === 'skipped')) {
81
+ status = 'skipped';
82
+ reason = 'No runnable probes after mutation guard filtering.';
83
+ }
84
+
85
+ const verification = {
86
+ schema_version: '0.1.0',
87
+ run_id: runId,
88
+ story_id: story?.story_id ?? null,
89
+ created_at: startedAt,
90
+ status,
91
+ reason,
92
+ base_url: connection.baseUrl,
93
+ http_auth: connection.httpAuth?.summary ?? { enabled: false },
94
+ playwright,
95
+ setup: buildSetupGuidance({ playwright, commandResult }),
96
+ options: {
97
+ journey_id: options.journeyId ?? null,
98
+ allow_mutation: options.allowMutation === true,
99
+ headed: options.headed === true,
100
+ basic_auth_env: options.basicAuthEnv ?? null,
101
+ basic_auth_inline: Boolean(options.basicAuth)
102
+ },
103
+ summary: summarizeProbeResults(probeResults),
104
+ probes: probeResults,
105
+ command: commandResult,
106
+ runtime_contract_failures: commandResult?.runtime_contract_failures ?? [],
107
+ warnings,
108
+ git_context: gitContext,
109
+ generated_spec: generatedSpecPath ? toWorkspaceRelative(root, generatedSpecPath) : null,
110
+ generated_config: generatedConfigPath ? toWorkspaceRelative(root, generatedConfigPath) : null
111
+ };
112
+
113
+ const jsonPath = path.join(runDir, 'flow-verification.json');
114
+ const markdownPath = path.join(runDir, 'flow-verification.md');
115
+ const logPath = path.join(runDir, 'playwright-output.log');
116
+ await writeFile(jsonPath, `${JSON.stringify(verification, null, 2)}\n`);
117
+ await writeFile(markdownPath, renderFlowVerificationReport(verification));
118
+ if (!commandResult) await writeFile(logPath, `${reason ?? 'Playwright was not executed.'}\n`);
119
+
120
+ manifest.latest_flow_verification_run = runId;
121
+ manifest.flow_verification_runs = [
122
+ {
123
+ run_id: runId,
124
+ story_id: verification.story_id,
125
+ created_at: verification.created_at,
126
+ status: verification.status,
127
+ base_url: verification.base_url,
128
+ git_context: gitContext,
129
+ warnings,
130
+ artifacts: {
131
+ flow_verification_json: toWorkspaceRelative(root, jsonPath),
132
+ flow_verification_report: toWorkspaceRelative(root, markdownPath),
133
+ playwright_log: toWorkspaceRelative(root, logPath),
134
+ generated_spec: verification.generated_spec,
135
+ generated_config: verification.generated_config
136
+ },
137
+ summary: verification.summary
138
+ },
139
+ ...(manifest.flow_verification_runs ?? []).filter((run) => run.run_id !== runId)
140
+ ];
141
+ await writeManifest(root, manifest);
142
+
143
+ return {
144
+ runDir,
145
+ artifacts: {
146
+ json: toWorkspaceRelative(root, jsonPath),
147
+ markdown: toWorkspaceRelative(root, markdownPath),
148
+ log: toWorkspaceRelative(root, logPath),
149
+ spec: verification.generated_spec,
150
+ config: verification.generated_config
151
+ },
152
+ verification
153
+ };
154
+ }
155
+
156
+ function normalizeWarnings(warnings) {
157
+ return warnings.filter((warning) => warning && typeof warning === 'object');
158
+ }
159
+
160
+ async function collectFlowGitContext(repoRoot) {
161
+ const [headSha, currentBranch, statusOutput] = await Promise.all([
162
+ gitOptional(repoRoot, ['rev-parse', 'HEAD']),
163
+ gitOptional(repoRoot, ['branch', '--show-current']),
164
+ gitStatus(repoRoot)
165
+ ]);
166
+ const dirtyDiff = await collectDirtyDiff(repoRoot);
167
+ return {
168
+ head_sha: headSha || null,
169
+ current_branch: currentBranch || null,
170
+ dirty: statusOutput.length > 0,
171
+ status_fingerprint_hash: hashFingerprint(fingerprintStatus(statusOutput, dirtyDiff)),
172
+ recorded_at: new Date().toISOString()
173
+ };
174
+ }
175
+
176
+ async function gitStatus(repoRoot) {
177
+ try {
178
+ const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-uall'], { cwd: repoRoot, encoding: 'utf8' });
179
+ return stdout.trimEnd();
180
+ } catch {
181
+ return '';
182
+ }
183
+ }
184
+
185
+ async function collectDirtyDiff(repoRoot) {
186
+ const [unstaged, staged, untracked] = await Promise.all([
187
+ gitOptional(repoRoot, ['diff', '--binary']),
188
+ gitOptional(repoRoot, ['diff', '--cached', '--binary']),
189
+ collectUntrackedFileFingerprint(repoRoot)
190
+ ]);
191
+ return [staged, unstaged, untracked].filter(Boolean).join('\n');
192
+ }
193
+
194
+ async function collectUntrackedFileFingerprint(repoRoot) {
195
+ const output = await gitOptional(repoRoot, ['ls-files', '--others', '--exclude-standard']);
196
+ const files = output.split('\n').filter(Boolean).sort().slice(0, 200);
197
+ const chunks = [];
198
+ for (const file of files) {
199
+ try {
200
+ const content = await readFile(path.join(repoRoot, file), 'utf8');
201
+ chunks.push(`untracked:${file}\n${content}`);
202
+ } catch {
203
+ chunks.push(`untracked:${file}\n<unreadable>`);
204
+ }
205
+ }
206
+ return chunks.join('\n');
207
+ }
208
+
209
+ function fingerprintStatus(statusOutput, dirtyDiff = '') {
210
+ return [
211
+ 'git-status --porcelain -uall',
212
+ String(statusOutput ?? '').trimEnd(),
213
+ 'git-diff --binary',
214
+ String(dirtyDiff ?? '').trimEnd()
215
+ ].join('\n');
216
+ }
217
+
218
+ function hashFingerprint(value) {
219
+ return createHash('sha256').update(String(value ?? '')).digest('hex');
220
+ }
221
+
222
+ async function gitOptional(repoRoot, args) {
223
+ try {
224
+ const { stdout } = await execFileAsync('git', args, { cwd: repoRoot, encoding: 'utf8' });
225
+ return stdout.trim();
226
+ } catch {
227
+ return '';
228
+ }
229
+ }
230
+
231
+ export function renderFlowVerificationSummary(result) {
232
+ const setupLines = result.verification.setup?.next_commands?.length > 0
233
+ ? [
234
+ '',
235
+ '## Setup',
236
+ ...result.verification.setup.next_commands.map((command) => `- ${command}`)
237
+ ]
238
+ : [];
239
+ const lines = [
240
+ '# VibePro Flow Verification',
241
+ '',
242
+ `Run ID: ${result.verification.run_id}`,
243
+ `Status: ${result.verification.status}`,
244
+ `Report: ${result.artifacts.markdown}`,
245
+ `HTTP Auth: ${result.verification.http_auth?.enabled ? `enabled (${result.verification.http_auth.source})` : 'disabled'}`,
246
+ '',
247
+ '## Summary',
248
+ `- pass: ${result.verification.summary.pass}`,
249
+ `- fail: ${result.verification.summary.fail}`,
250
+ `- skipped: ${result.verification.summary.skipped}`,
251
+ `- needs_setup: ${result.verification.summary.needs_setup}`,
252
+ `- runtime_contract_failures: ${result.verification.runtime_contract_failures?.length ?? 0}`,
253
+ ...setupLines
254
+ ];
255
+ return `${lines.join('\n')}\n`;
256
+ }
257
+
258
+ async function readConfig(root) {
259
+ try {
260
+ return JSON.parse(await readFile(path.join(getWorkspaceDir(root), 'config.json'), 'utf8'));
261
+ } catch {
262
+ return {};
263
+ }
264
+ }
265
+
266
+ function resolveStory(config, storyId = null) {
267
+ const stories = config.brainbase?.stories ?? [];
268
+ const id = storyId ?? config.brainbase?.current_story_id ?? null;
269
+ return stories.find((story) => story.story_id === id) ?? stories[0] ?? null;
270
+ }
271
+
272
+ function resolveFlowProbes({ config, manifest, story, journeyId }) {
273
+ const configured = config.flow_design?.runtime_probes;
274
+ const evidenceProbes = latestFlowDesignProbes(config, manifest);
275
+ const defaults = defaultProbesForProfile(config.flow_design?.profile, story);
276
+ const probes = Array.isArray(configured) && configured.length > 0
277
+ ? configured
278
+ : evidenceProbes.length > 0
279
+ ? evidenceProbes
280
+ : defaults;
281
+ return probes
282
+ .filter((probe) => !journeyId || probe.id === journeyId)
283
+ .map(normalizeProbe)
284
+ .filter(Boolean);
285
+ }
286
+
287
+ function latestFlowDesignProbes(config, manifest) {
288
+ const latestRunId = config.brainbase?.current_story_id
289
+ ? manifest.latest_run_by_story?.[config.brainbase.current_story_id]
290
+ : manifest.latest_run;
291
+ const run = (manifest.runs ?? []).find((item) => item.run_id === latestRunId) ?? null;
292
+ const probes = run?.flow_design?.runtime_probe_plan?.commands ?? [];
293
+ return Array.isArray(probes) ? probes.filter((probe) => probe.path && Array.isArray(probe.steps)) : [];
294
+ }
295
+
296
+ function defaultProbesForProfile(profile, story) {
297
+ return [];
298
+ }
299
+
300
+ function normalizeProbe(probe) {
301
+ if (!probe?.id || !probe.path || !Array.isArray(probe.steps)) return null;
302
+ return {
303
+ id: String(probe.id),
304
+ title: probe.title ?? probe.intent ?? probe.id,
305
+ path: probe.path,
306
+ mutates: probe.mutates === true,
307
+ steps: probe.steps
308
+ };
309
+ }
310
+
311
+ function buildPendingProbeResult(probe, options) {
312
+ const screenshotPaths = probe.steps
313
+ .filter((step) => step.action === 'screenshot')
314
+ .map((step) => `screenshots/${safeFileName(step.name ?? probe.id)}.png`);
315
+ if (probe.mutates && options.allowMutation !== true) {
316
+ return {
317
+ ...probe,
318
+ status: 'skipped',
319
+ reason: 'mutates=true requires --allow-mutation',
320
+ artifacts: { screenshot_paths: screenshotPaths }
321
+ };
322
+ }
323
+ return {
324
+ ...probe,
325
+ status: 'pending',
326
+ reason: null,
327
+ artifacts: { screenshot_paths: screenshotPaths }
328
+ };
329
+ }
330
+
331
+ async function detectPlaywright(root) {
332
+ const packageJson = await readPackageJson(root);
333
+ if (!packageJson) {
334
+ return {
335
+ detected: false,
336
+ command: 'npx playwright test',
337
+ reason: 'Playwright setup is not detectable because package.json is missing.'
338
+ };
339
+ }
340
+ const deps = {
341
+ ...(packageJson.dependencies ?? {}),
342
+ ...(packageJson.devDependencies ?? {})
343
+ };
344
+ const scripts = packageJson.scripts ?? {};
345
+ const hasDependency = Boolean(deps['@playwright/test'] || deps.playwright);
346
+ const script = Object.entries(scripts).find(([, command]) => /\bplaywright\b/.test(String(command)));
347
+ if (hasDependency || script) {
348
+ return {
349
+ detected: true,
350
+ command: script ? `npm run ${script[0]}` : 'npx playwright test',
351
+ reason: hasDependency ? 'Playwright dependency detected.' : `Playwright script detected: ${script[0]}`
352
+ };
353
+ }
354
+ return {
355
+ detected: false,
356
+ command: 'npx playwright test',
357
+ reason: 'Playwright dependency or script was not found.'
358
+ };
359
+ }
360
+
361
+ async function readPackageJson(root) {
362
+ try {
363
+ return JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
364
+ } catch (error) {
365
+ if (error.code === 'ENOENT') return null;
366
+ throw error;
367
+ }
368
+ }
369
+
370
+ async function runPlaywright(root, { specPath, configPath, baseUrl, httpAuth, headed, env }) {
371
+ const logPath = path.join(path.dirname(specPath), 'playwright-output.log');
372
+ const args = ['playwright', 'test', '--config', configPath];
373
+ if (headed) args.push('--headed');
374
+ const command = `npx ${args.map(toPosix).join(' ')}`;
375
+ const secrets = [
376
+ httpAuth?.credentials?.username,
377
+ httpAuth?.credentials?.password,
378
+ ...(env ? Object.values(env) : [])
379
+ ].filter((value) => typeof value === 'string' && value.length > 0);
380
+ try {
381
+ const result = await execFileAsync('npx', args, {
382
+ cwd: root,
383
+ env: {
384
+ ...process.env,
385
+ ...(env ?? {}),
386
+ VIBEPRO_BASE_URL: baseUrl,
387
+ ...(httpAuth?.credentials
388
+ ? {
389
+ VIBEPRO_BASIC_AUTH_USER: httpAuth.credentials.username,
390
+ VIBEPRO_BASIC_AUTH_PASSWORD: httpAuth.credentials.password
391
+ }
392
+ : {})
393
+ },
394
+ encoding: 'utf8',
395
+ maxBuffer: 20 * 1024 * 1024
396
+ });
397
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
398
+ const redactedOutput = redactSecrets(output, secrets);
399
+ await writeFile(logPath, redactedOutput);
400
+ return {
401
+ command,
402
+ status: 'pass',
403
+ exit_code: 0,
404
+ stdout: truncate(redactSecrets(result.stdout, secrets)),
405
+ stderr: truncate(redactSecrets(result.stderr, secrets)),
406
+ runtime_contract_failures: extractRuntimeContractFailures(redactedOutput)
407
+ };
408
+ } catch (error) {
409
+ const output = `${error.stdout ?? ''}${error.stderr ?? ''}${error.message ?? ''}`;
410
+ const redactedOutput = redactSecrets(output, secrets);
411
+ await writeFile(logPath, redactedOutput);
412
+ return {
413
+ command,
414
+ status: 'fail',
415
+ exit_code: error.code ?? 1,
416
+ stdout: truncate(redactSecrets(error.stdout, secrets)),
417
+ stderr: truncate(redactSecrets(error.stderr ?? error.message, secrets)),
418
+ runtime_contract_failures: extractRuntimeContractFailures(redactedOutput)
419
+ };
420
+ }
421
+ }
422
+
423
+ function redactSecrets(value, secrets = []) {
424
+ let output = String(value ?? '');
425
+ for (const secret of secrets) {
426
+ if (!secret) continue;
427
+ output = output.split(secret).join('[REDACTED]');
428
+ }
429
+ return output;
430
+ }
431
+
432
+ function resolveConnectionOptions(options, env) {
433
+ const parsed = parseBaseUrl(options.baseUrl);
434
+ const inlineAuth = options.basicAuth ? parseBasicAuthValue(options.basicAuth, '--basic-auth') : null;
435
+ const envAuth = options.basicAuthEnv ? parseBasicAuthValue(env?.[options.basicAuthEnv], `env:${options.basicAuthEnv}`) : null;
436
+ const urlAuth = parsed.credentials
437
+ ? {
438
+ source: 'base-url',
439
+ credentials: parsed.credentials,
440
+ summary: buildHttpAuthSummary('base-url', parsed.credentials.username)
441
+ }
442
+ : null;
443
+ const httpAuth = inlineAuth ?? envAuth ?? urlAuth ?? null;
444
+ return {
445
+ baseUrl: parsed.sanitizedUrl,
446
+ httpAuth
447
+ };
448
+ }
449
+
450
+ function parseBaseUrl(value) {
451
+ try {
452
+ const url = new URL(value);
453
+ const hasCredentials = Boolean(url.username || url.password);
454
+ const credentials = hasCredentials
455
+ ? {
456
+ username: decodeURIComponent(url.username),
457
+ password: decodeURIComponent(url.password)
458
+ }
459
+ : null;
460
+ if (hasCredentials) {
461
+ url.username = '';
462
+ url.password = '';
463
+ }
464
+ return {
465
+ sanitizedUrl: url.toString().replace(/\/$/, ''),
466
+ credentials
467
+ };
468
+ } catch {
469
+ return {
470
+ sanitizedUrl: value,
471
+ credentials: null
472
+ };
473
+ }
474
+ }
475
+
476
+ function parseBasicAuthValue(value, source) {
477
+ if (!value) throw new Error(`${source} must be set to <username>:<password>`);
478
+ const separator = String(value).indexOf(':');
479
+ if (separator <= 0) throw new Error(`${source} must be formatted as <username>:<password>`);
480
+ const credentials = {
481
+ username: String(value).slice(0, separator),
482
+ password: String(value).slice(separator + 1)
483
+ };
484
+ if (!credentials.password) throw new Error(`${source} must include a non-empty password`);
485
+ return {
486
+ source,
487
+ credentials,
488
+ summary: buildHttpAuthSummary(source, credentials.username)
489
+ };
490
+ }
491
+
492
+ function buildHttpAuthSummary(source, username) {
493
+ return {
494
+ enabled: true,
495
+ source,
496
+ username_redacted: Boolean(username),
497
+ password_redacted: true
498
+ };
499
+ }
500
+
501
+ function detectPlaywrightSetupIssue(commandResult) {
502
+ const output = [commandResult?.stdout, commandResult?.stderr].filter(Boolean).join('\n');
503
+ if (/Executable (doesn't|does not) exist|Looks like Playwright was just installed|npx playwright install/i.test(output)) {
504
+ return {
505
+ kind: 'playwright_browser_missing',
506
+ reason: 'Playwright browser binaries are missing. Run `npx playwright install chromium` in the target repository.',
507
+ next_commands: ['npx playwright install chromium']
508
+ };
509
+ }
510
+ return null;
511
+ }
512
+
513
+ function buildSetupGuidance({ playwright, commandResult }) {
514
+ if (!playwright.detected) {
515
+ return {
516
+ kind: 'playwright_dependency_missing',
517
+ reason: playwright.reason,
518
+ next_commands: [
519
+ 'npm install -D @playwright/test',
520
+ 'npx playwright install chromium'
521
+ ]
522
+ };
523
+ }
524
+ return commandResult?.setup ?? null;
525
+ }
526
+
527
+ function renderPlaywrightSpec({ probes, screenshotDir }) {
528
+ return `import { test, expect } from '@playwright/test';
529
+
530
+ const BASE_URL = process.env.VIBEPRO_BASE_URL;
531
+ const BASIC_AUTH_USER = process.env.VIBEPRO_BASIC_AUTH_USER;
532
+ const BASIC_AUTH_PASSWORD = process.env.VIBEPRO_BASIC_AUTH_PASSWORD;
533
+
534
+ if (BASIC_AUTH_USER && BASIC_AUTH_PASSWORD) {
535
+ test.use({
536
+ httpCredentials: {
537
+ username: BASIC_AUTH_USER,
538
+ password: BASIC_AUTH_PASSWORD
539
+ }
540
+ });
541
+ }
542
+
543
+ function installRuntimeContractWatch(page) {
544
+ const events = [];
545
+ page.on('response', async (response) => {
546
+ const url = response.url();
547
+ if (!url.includes('/api/')) return;
548
+ const status = response.status();
549
+ const contentType = response.headers()['content-type'] || '';
550
+ if (status >= 400) {
551
+ events.push({ kind: 'api_response_error', url, status, contentType });
552
+ return;
553
+ }
554
+ if (/text\\/html/i.test(contentType)) {
555
+ events.push({ kind: 'api_html_response', url, status, contentType });
556
+ }
557
+ });
558
+ page.on('requestfailed', (request) => {
559
+ if (request.url().includes('/api/')) {
560
+ events.push({ kind: 'api_request_failed', url: request.url(), failure: request.failure()?.errorText || null });
561
+ }
562
+ });
563
+ page.on('console', (message) => {
564
+ if (message.type() === 'error') events.push({ kind: 'console_error', text: message.text() });
565
+ });
566
+ page.on('pageerror', (error) => {
567
+ events.push({ kind: 'page_error', text: error.message });
568
+ });
569
+ return events;
570
+ }
571
+
572
+ async function assertNoRuntimeContractFailures(page, events) {
573
+ await page.waitForTimeout(250);
574
+ const bodyText = await page.locator('body').innerText().catch(() => '');
575
+ const visibleErrorPatterns = [
576
+ '情報を取得できませんでした',
577
+ '読み込みに失敗しました',
578
+ 'Failed to fetch',
579
+ "Unexpected token '<'",
580
+ 'Server Action'
581
+ ];
582
+ for (const pattern of visibleErrorPatterns) {
583
+ if (bodyText.includes(pattern)) events.push({ kind: 'visible_error_text', text: pattern });
584
+ }
585
+ const relevant = events.filter((event) => {
586
+ const text = [event.text, event.failure, event.url].filter(Boolean).join(' ');
587
+ return event.kind.startsWith('api_')
588
+ || /Failed to fetch|Unexpected token '<'|Server Action .*not found|情報を取得できませんでした|読み込みに失敗しました|NEXT_RUNTIME|Unhandled/i.test(text);
589
+ });
590
+ expect(relevant, 'VibePro runtime contract failure: ' + JSON.stringify(relevant, null, 2)).toEqual([]);
591
+ }
592
+
593
+ ${probes.map((probe) => `test(${JSON.stringify(probe.title)}, async ({ page }) => {
594
+ const runtimeContractEvents = installRuntimeContractWatch(page);
595
+ await page.goto(new URL(${JSON.stringify(probe.path)}, BASE_URL).toString());
596
+ const vibeproProbeState = { urls: { initial: page.url() }, scrollLeft: {} };
597
+ ${probe.steps.map((step, index) => renderStep(step, probe, screenshotDir, index)).filter(Boolean).join('\n')}
598
+ await assertNoRuntimeContractFailures(page, runtimeContractEvents);
599
+ });`).join('\n\n')}
600
+ `;
601
+ }
602
+
603
+ function renderPlaywrightConfig() {
604
+ return `export default {
605
+ testDir: '.',
606
+ testMatch: /flow-verification\\.spec\\.js$/,
607
+ timeout: 30000,
608
+ use: {
609
+ trace: 'retain-on-failure'
610
+ }
611
+ };
612
+ `;
613
+ }
614
+
615
+ function renderStep(step, probe, screenshotDir, index = 0) {
616
+ if (step.action === 'expectVisible') {
617
+ return ` await expect(page.getByText(${JSON.stringify(step.text)}, { exact: false }).first()).toBeVisible();`;
618
+ }
619
+ if (step.action === 'expectNotVisible') {
620
+ return ` await expect(page.getByText(${JSON.stringify(step.text)}, { exact: false }).first()).toBeHidden();`;
621
+ }
622
+ if (step.action === 'click') {
623
+ return ` await page.getByText(${JSON.stringify(step.text)}, { exact: false }).first().click();`;
624
+ }
625
+ if (step.action === 'physicalClick') {
626
+ return renderPhysicalClickStep(step);
627
+ }
628
+ if (step.action === 'expectElementFromPoint') {
629
+ return renderElementFromPointStep(step);
630
+ }
631
+ if (step.action === 'captureUrl') {
632
+ const key = safeStateKey(step.name ?? step.key ?? 'checkpoint');
633
+ return ` vibeproProbeState.urls[${JSON.stringify(key)}] = page.url();`;
634
+ }
635
+ if (step.action === 'expectUrlUnchanged') {
636
+ const key = safeStateKey(step.name ?? step.key ?? 'initial');
637
+ return [
638
+ ` const expectedUrl${index} = vibeproProbeState.urls[${JSON.stringify(key)}] ?? vibeproProbeState.urls.initial;`,
639
+ ` expect(page.url(), ${JSON.stringify(`Expected URL to remain unchanged from ${key}`)}).toBe(expectedUrl${index});`
640
+ ].join('\n');
641
+ }
642
+ if (step.action === 'drag' || step.action === 'touchDrag') {
643
+ return renderGestureDragStep(step, index);
644
+ }
645
+ if (step.action === 'expectScrollLeftChanged') {
646
+ const key = safeStateKey(step.name ?? step.key ?? step.selector ?? `gesture-${index}`);
647
+ return [
648
+ ` const scrollState${index} = vibeproProbeState.scrollLeft[${JSON.stringify(key)}];`,
649
+ ` expect(scrollState${index}, ${JSON.stringify(`Expected recorded scrollLeft state for ${key}`)}).toBeTruthy();`,
650
+ ` expect(scrollState${index}.after, ${JSON.stringify(`Expected scrollLeft to change for ${key}`)}).not.toBe(scrollState${index}.before);`
651
+ ].join('\n');
652
+ }
653
+ if (step.action === 'fill') {
654
+ if (step.selector) return ` await page.locator(${JSON.stringify(step.selector)}).fill(${JSON.stringify(step.value ?? '')});`;
655
+ return ` await page.getByLabel(${JSON.stringify(step.label ?? step.text)}, { exact: false }).fill(${JSON.stringify(step.value ?? '')});`;
656
+ }
657
+ if (step.action === 'fillFromText') {
658
+ const matcher = step.textRegex ?? step.regex ?? null;
659
+ if (!matcher) return null;
660
+ const group = Number.isInteger(step.group) ? step.group : 1;
661
+ const capture = [
662
+ ' const bodyText = await page.locator(\'body\').innerText();',
663
+ ` const textMatch = bodyText.match(new RegExp(${JSON.stringify(matcher)}));`,
664
+ ` expect(textMatch, ${JSON.stringify(`Expected page text to match ${matcher}`)}).not.toBeNull();`,
665
+ ` const capturedValue = textMatch[${group}];`,
666
+ ` expect(capturedValue, ${JSON.stringify(`Expected capture group ${group} from ${matcher}`)}).toBeTruthy();`
667
+ ].join('\n');
668
+ const fill = step.selector
669
+ ? ` await page.locator(${JSON.stringify(step.selector)}).fill(capturedValue);`
670
+ : ` await page.getByLabel(${JSON.stringify(step.label ?? step.text)}, { exact: ${step.exact === true ? 'true' : 'false'} }).fill(capturedValue);`;
671
+ return `${capture}\n${fill}`;
672
+ }
673
+ if (step.action === 'screenshot') {
674
+ const fileName = `${safeFileName(step.name ?? probe.id)}.png`;
675
+ return ` await page.screenshot({ path: ${JSON.stringify(path.join(screenshotDir, fileName))}, fullPage: true });`;
676
+ }
677
+ return null;
678
+ }
679
+
680
+ function renderElementFromPointStep(step) {
681
+ const locatorExpression = step.selector
682
+ ? `page.locator(${JSON.stringify(step.selector)}).first()`
683
+ : `page.getByText(${JSON.stringify(step.text ?? step.label)}, { exact: ${step.exact === true ? 'true' : 'false'} }).first()`;
684
+ const targetLabel = step.selector ?? step.text ?? step.label ?? 'elementFromPoint target';
685
+ return [
686
+ ` const hitTarget = ${locatorExpression};`,
687
+ ' await expect(hitTarget).toBeVisible();',
688
+ ' await hitTarget.scrollIntoViewIfNeeded();',
689
+ ' const hit = await hitTarget.evaluate((element) => {',
690
+ ' const rect = element.getBoundingClientRect();',
691
+ ' const x = rect.left + rect.width / 2;',
692
+ ' const y = rect.top + rect.height / 2;',
693
+ ' const target = document.elementFromPoint(x, y);',
694
+ ' return {',
695
+ ' isSelf: target === element,',
696
+ ' isInside: Boolean(target && element.contains(target)),',
697
+ ' tagName: target?.tagName ?? null,',
698
+ ' className: String(target?.className ?? \'\'),',
699
+ ' html: target?.outerHTML?.slice(0, 240) ?? null',
700
+ ' };',
701
+ ' });',
702
+ ` expect(hit.isSelf || hit.isInside, \`Hit target for ${escapeTemplate(targetLabel)} is intercepted by \${hit.tagName}.\${hit.className}: \${hit.html}\`).toBe(true);`
703
+ ].join('\n');
704
+ }
705
+
706
+ function renderGestureDragStep(step, index) {
707
+ const selector = step.selector ?? step.text ?? step.label;
708
+ if (!selector) return null;
709
+ const locatorExpression = step.selector
710
+ ? `page.locator(${JSON.stringify(step.selector)}).first()`
711
+ : `page.getByText(${JSON.stringify(step.text ?? step.label)}, { exact: ${step.exact === true ? 'true' : 'false'} }).first()`;
712
+ const key = safeStateKey(step.name ?? step.key ?? step.selector ?? `gesture-${index}`);
713
+ const deltaX = Number.isFinite(step.deltaX) ? step.deltaX : -160;
714
+ const deltaY = Number.isFinite(step.deltaY) ? step.deltaY : 0;
715
+ const steps = Number.isInteger(step.steps) && step.steps > 0 ? step.steps : 8;
716
+ const expectsScroll = step.expectScrollLeftChanged === true || step.expectScrollChange === true;
717
+ const expectsActiveChange = step.expectActiveChanged === true || step.expectActiveCardChanged === true;
718
+ const lines = [
719
+ ` const gestureTarget${index} = ${locatorExpression};`,
720
+ ` await expect(gestureTarget${index}).toBeVisible();`,
721
+ ` await gestureTarget${index}.scrollIntoViewIfNeeded();`,
722
+ ` const gestureScrollBefore${index} = await gestureTarget${index}.evaluate((element) => element.scrollLeft);`
723
+ ];
724
+ if (step.activeSelector) {
725
+ lines.push(` const gestureActiveBefore${index} = await page.locator(${JSON.stringify(step.activeSelector)}).first().evaluate((element) => ({ text: element.textContent, className: String(element.className), ariaSelected: element.getAttribute('aria-selected') })).catch(() => null);`);
726
+ }
727
+ lines.push(
728
+ ` const gestureBox${index} = await gestureTarget${index}.boundingBox();`,
729
+ ` expect(gestureBox${index}, ${JSON.stringify(`Expected a bounding box for ${selector}`)}).not.toBeNull();`,
730
+ ` await page.mouse.move(gestureBox${index}.x + gestureBox${index}.width / 2, gestureBox${index}.y + gestureBox${index}.height / 2);`,
731
+ ' await page.mouse.down();',
732
+ ` await page.mouse.move(gestureBox${index}.x + gestureBox${index}.width / 2 + ${deltaX}, gestureBox${index}.y + gestureBox${index}.height / 2 + ${deltaY}, { steps: ${steps} });`,
733
+ ' await page.mouse.up();',
734
+ ' await page.waitForTimeout(100);',
735
+ ` const gestureScrollAfter${index} = await gestureTarget${index}.evaluate((element) => element.scrollLeft);`,
736
+ ` vibeproProbeState.scrollLeft[${JSON.stringify(key)}] = { before: gestureScrollBefore${index}, after: gestureScrollAfter${index} };`
737
+ );
738
+ if (step.activeSelector) {
739
+ lines.push(` const gestureActiveAfter${index} = await page.locator(${JSON.stringify(step.activeSelector)}).first().evaluate((element) => ({ text: element.textContent, className: String(element.className), ariaSelected: element.getAttribute('aria-selected') })).catch(() => null);`);
740
+ }
741
+ if (expectsScroll) {
742
+ lines.push(` expect(gestureScrollAfter${index}, ${JSON.stringify(`Expected scrollLeft to change for ${selector}`)}).not.toBe(gestureScrollBefore${index});`);
743
+ }
744
+ if (expectsActiveChange && step.activeSelector) {
745
+ lines.push(` expect(JSON.stringify(gestureActiveAfter${index}), ${JSON.stringify(`Expected active item state to change for ${step.activeSelector}`)}).not.toBe(JSON.stringify(gestureActiveBefore${index}));`);
746
+ }
747
+ if (step.expectUrlUnchanged === true) {
748
+ lines.push(` expect(page.url(), ${JSON.stringify(`Expected drag not to navigate for ${selector}`)}).toBe(vibeproProbeState.urls.initial);`);
749
+ }
750
+ return lines.join('\n');
751
+ }
752
+
753
+ function renderPhysicalClickStep(step) {
754
+ const locatorExpression = step.selector
755
+ ? `page.locator(${JSON.stringify(step.selector)}).first()`
756
+ : `page.getByText(${JSON.stringify(step.text ?? step.label)}, { exact: ${step.exact === true ? 'true' : 'false'} }).first()`;
757
+ const targetLabel = step.selector ?? step.text ?? step.label ?? 'physicalClick target';
758
+ const assertSelfTarget = step.targetPolicy !== 'closest';
759
+ const hitAssertion = assertSelfTarget
760
+ ? ` expect(hit.isSelf, \`Physical click target for ${escapeTemplate(targetLabel)} is intercepted by \${hit.tagName}.\${hit.className}: \${hit.html}\`).toBe(true);`
761
+ : ` expect(hit.isSelf || hit.isInside, \`Physical click target for ${escapeTemplate(targetLabel)} is outside the locator: \${hit.tagName}.\${hit.className}: \${hit.html}\`).toBe(true);`;
762
+ return [
763
+ ` const physicalTarget = ${locatorExpression};`,
764
+ ' await expect(physicalTarget).toBeVisible();',
765
+ ' await physicalTarget.scrollIntoViewIfNeeded();',
766
+ ' const hit = await physicalTarget.evaluate((element) => {',
767
+ ' const rect = element.getBoundingClientRect();',
768
+ ' const x = rect.left + rect.width / 2;',
769
+ ' const y = rect.top + rect.height / 2;',
770
+ ' const target = document.elementFromPoint(x, y);',
771
+ ' return {',
772
+ ' isSelf: target === element,',
773
+ ' isInside: Boolean(target && element.contains(target)),',
774
+ ' tagName: target?.tagName ?? null,',
775
+ ' className: String(target?.className ?? \'\'),',
776
+ ' html: target?.outerHTML?.slice(0, 240) ?? null',
777
+ ' };',
778
+ ' });',
779
+ hitAssertion,
780
+ ' const box = await physicalTarget.boundingBox();',
781
+ ` expect(box, ${JSON.stringify(`Expected a bounding box for ${targetLabel}`)}).not.toBeNull();`,
782
+ ' await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);'
783
+ ].join('\n');
784
+ }
785
+
786
+ function safeStateKey(value) {
787
+ return String(value ?? 'default').replace(/[^a-zA-Z0-9_.:-]+/g, '-').slice(0, 80) || 'default';
788
+ }
789
+
790
+ function renderFlowVerificationReport(verification) {
791
+ return `# Flow Verification
792
+
793
+ | 項目 | 内容 |
794
+ |------|------|
795
+ | Run ID | ${verification.run_id} |
796
+ | Story ID | ${verification.story_id ?? '-'} |
797
+ | Status | ${verification.status} |
798
+ | Base URL | ${verification.base_url} |
799
+ | HTTP Auth | ${verification.http_auth?.enabled ? `enabled (${verification.http_auth.source}, credentials redacted)` : 'disabled'} |
800
+ | Reason | ${verification.reason ?? '-'} |
801
+
802
+ ## Summary
803
+
804
+ - pass: ${verification.summary.pass}
805
+ - fail: ${verification.summary.fail}
806
+ - skipped: ${verification.summary.skipped}
807
+ - needs_setup: ${verification.summary.needs_setup}
808
+ - runtime_contract_failures: ${verification.runtime_contract_failures?.length ?? 0}
809
+
810
+ ## Probes
811
+
812
+ ${verification.probes.length === 0 ? '- なし' : verification.probes.map((probe) => `- ${probe.id}: ${probe.status} ${probe.reason ? `(${probe.reason})` : ''}`).join('\n')}
813
+
814
+ ## Setup
815
+
816
+ ${verification.setup?.next_commands?.length > 0 ? verification.setup.next_commands.map((command) => `- \`${command}\``).join('\n') : '- なし'}
817
+
818
+ ## Runtime Contract Failures
819
+
820
+ ${verification.runtime_contract_failures?.length > 0 ? verification.runtime_contract_failures.map((item) => `- ${item.kind}: ${item.detail}`).join('\n') : '- なし'}
821
+
822
+ ## Warnings
823
+
824
+ ${verification.warnings?.length > 0 ? verification.warnings.map((warning) => `- ${warning.id}: ${warning.reason ?? warning.status ?? 'warning'}`).join('\n') : '- なし'}
825
+ `;
826
+ }
827
+
828
+ function summarizeProbeResults(probes) {
829
+ return {
830
+ total: probes.length,
831
+ pass: probes.filter((probe) => probe.status === 'pass').length,
832
+ fail: probes.filter((probe) => probe.status === 'fail').length,
833
+ skipped: probes.filter((probe) => probe.status === 'skipped').length,
834
+ needs_setup: probes.filter((probe) => probe.status === 'needs_setup').length
835
+ };
836
+ }
837
+
838
+ function extractRuntimeContractFailures(output) {
839
+ const text = String(output ?? '');
840
+ const failures = [];
841
+ const runtimeFailure = /VibePro runtime contract failure:\s*([\s\S]*?)(?:\n\s*at |\n\s*Error:|$)/m.exec(text);
842
+ if (runtimeFailure) {
843
+ failures.push({
844
+ kind: 'runtime_contract_failure',
845
+ detail: truncate(runtimeFailure[1].trim(), 2000)
846
+ });
847
+ }
848
+ for (const pattern of [
849
+ /\/api\/[^\s"'`]+[\s\S]{0,120}\b(404|500|502|503)\b/g,
850
+ /Unexpected token '<'/g,
851
+ /Failed to fetch/g,
852
+ /Server Action [^\n]+ was not found/g,
853
+ /情報を取得できませんでした/g,
854
+ /読み込みに失敗しました/g
855
+ ]) {
856
+ for (const match of text.matchAll(pattern)) {
857
+ failures.push({
858
+ kind: 'runtime_contract_signal',
859
+ detail: truncate(match[0], 500)
860
+ });
861
+ }
862
+ }
863
+ return failures.slice(0, 20);
864
+ }
865
+
866
+ function safeFileName(value) {
867
+ return String(value ?? 'screenshot')
868
+ .toLowerCase()
869
+ .replace(/[^a-z0-9_-]+/g, '-')
870
+ .replace(/^-+|-+$/g, '') || 'screenshot';
871
+ }
872
+
873
+ function escapeTemplate(value) {
874
+ return String(value ?? '')
875
+ .replace(/\\/g, '\\\\')
876
+ .replace(/`/g, '\\`')
877
+ .replace(/\$\{/g, '\\${');
878
+ }
879
+
880
+ function toPosix(value) {
881
+ return String(value).split(path.sep).join('/');
882
+ }
883
+
884
+ function truncate(value, maxLength = 6000) {
885
+ const text = String(value ?? '');
886
+ return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
887
+ }