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,756 @@
1
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { normalizeActiveStories } from './story-manager.js';
5
+ import { getWorkspaceDir, initWorkspace, readManifest, toWorkspaceRelative, writeManifest } from './workspace.js';
6
+
7
+ const SCHEMA_VERSION = '0.1.0';
8
+ const COMPLETED_STATUS = 'completed';
9
+ const INCOMPLETE_STATUSES = new Set([
10
+ 'blocked',
11
+ 'needs_review',
12
+ 'timeout',
13
+ 'auth_required',
14
+ 'resource_unavailable',
15
+ 'unknown'
16
+ ]);
17
+ const EVIDENCE_SOURCE_TYPES = new Set([
18
+ 'server_log',
19
+ 'browser_e2e',
20
+ 'api_log',
21
+ 'client_marker',
22
+ 'manual_observation'
23
+ ]);
24
+ const USER_PERCEIVED_SOURCES = new Set(['browser_e2e', 'client_marker', 'manual_observation']);
25
+
26
+ export async function definePerformanceMetric(repoRoot, options = {}) {
27
+ await initWorkspace(repoRoot);
28
+ const root = path.resolve(repoRoot);
29
+ const storyId = requiredOption(options.storyId, '--id <story-id>');
30
+ const metricId = requiredOption(options.metricId, '--metric-id <id>');
31
+ const configPath = path.join(getWorkspaceDir(root), 'config.json');
32
+ const config = JSON.parse(await readFile(configPath, 'utf8'));
33
+ const rawStories = Array.isArray(config.brainbase?.stories) ? config.brainbase.stories : [];
34
+ const stories = rawStories.length > 0 ? rawStories : normalizeActiveStories(config.brainbase?.stories);
35
+ const story = stories.find((item) => item.story_id === storyId && item.status !== 'archived');
36
+ if (!story) throw new Error(`Story not found: ${storyId}`);
37
+
38
+ const metric = normalizeMetricDefinition({
39
+ metricId,
40
+ userStory: options.userStory ?? story.title ?? storyId,
41
+ startCondition: requiredOption(options.startCondition, '--start-condition <text>'),
42
+ completionCondition: requiredOption(options.completionCondition, '--completion-condition <text>'),
43
+ intermediateMarkers: normalizeList(options.intermediateMarkers),
44
+ timeoutMs: normalizeTimeout(options.timeoutMs),
45
+ failureClassifications: normalizeFailureClassifications(options.failureClassifications),
46
+ evidenceSources: normalizeEvidenceSourceDefinitions(options.evidenceSources),
47
+ comparisonPolicy: normalizeComparisonPolicy(options.comparisonPolicy),
48
+ readinessKind: options.readinessKind
49
+ });
50
+
51
+ const nextStories = stories.map((item) => {
52
+ if (item.story_id !== storyId) return item;
53
+ const existing = Array.isArray(item.performanceMetrics) ? item.performanceMetrics : [];
54
+ return {
55
+ ...item,
56
+ performanceMetrics: [
57
+ metric,
58
+ ...existing.filter((candidate) => candidate.metricId !== metric.metricId)
59
+ ]
60
+ };
61
+ });
62
+ config.brainbase = {
63
+ ...(config.brainbase ?? {}),
64
+ stories: nextStories,
65
+ current_story_id: config.brainbase?.current_story_id ?? storyId
66
+ };
67
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
68
+
69
+ return {
70
+ story_id: storyId,
71
+ metric,
72
+ artifacts: {
73
+ config: toWorkspaceRelative(root, configPath)
74
+ }
75
+ };
76
+ }
77
+
78
+ export async function recordPerformanceRun(repoRoot, options = {}) {
79
+ await initWorkspace(repoRoot);
80
+ const root = path.resolve(repoRoot);
81
+ const storyId = requiredOption(options.storyId, '--id <story-id>');
82
+ const metricId = requiredOption(options.metricId, '--metric-id <id>');
83
+ const metric = await readPerformanceMetric(root, storyId, metricId);
84
+ const runId = options.runId ?? createRunId();
85
+ const runDir = path.join(getPerformanceRunDir(root, storyId));
86
+ await mkdir(runDir, { recursive: true });
87
+
88
+ const status = normalizeStatus(options.status ?? COMPLETED_STATUS);
89
+ const durationMs = normalizeDuration(options.durationMs, {
90
+ startedAt: options.startedAt,
91
+ completedAt: options.completedAt,
92
+ status
93
+ });
94
+ const completionCondition = options.completionCondition ?? metric.completionCondition.description;
95
+ const markers = normalizeObservedMarkers(options.markers);
96
+ const evidenceSources = normalizeEvidenceSources(options.evidenceSources);
97
+ const run = {
98
+ schema_version: SCHEMA_VERSION,
99
+ story_id: storyId,
100
+ metric_id: metricId,
101
+ run_id: runId,
102
+ label: options.label ?? metric.comparisonPolicy.afterLabel,
103
+ recorded_at: new Date().toISOString(),
104
+ status,
105
+ status_classification: status === COMPLETED_STATUS ? null : status,
106
+ user_story: metric.userStory,
107
+ metric_definition: metric,
108
+ measurement_definition: {
109
+ start_condition: metric.startCondition,
110
+ completion_condition: {
111
+ ...metric.completionCondition,
112
+ description: completionCondition,
113
+ matches_metric_definition: completionCondition === metric.completionCondition.description
114
+ },
115
+ intermediate_markers: metric.intermediateMarkers,
116
+ timeout_ms: metric.timeoutMs
117
+ },
118
+ observation: {
119
+ started_at: options.startedAt ?? null,
120
+ completed_at: options.completedAt ?? null,
121
+ duration_ms: durationMs,
122
+ intermediate_markers: markers,
123
+ evidence_sources: evidenceSources,
124
+ notes: options.notes ?? null
125
+ },
126
+ comparison_key: {
127
+ metric_id: metricId,
128
+ completion_condition: completionCondition
129
+ },
130
+ quality: evaluateRunQuality(metric, { status, durationMs, completionCondition, markers, evidenceSources })
131
+ };
132
+
133
+ const jsonPath = path.join(runDir, `${safeFileName(runId)}.json`);
134
+ await writeFile(jsonPath, `${JSON.stringify(run, null, 2)}\n`);
135
+ const summary = await summarizeStoryPerformanceEvidence(root, storyId);
136
+ await updatePerformanceManifest(root, storyId, run, jsonPath, summary);
137
+ return {
138
+ run,
139
+ summary,
140
+ artifacts: {
141
+ json: toWorkspaceRelative(root, jsonPath)
142
+ }
143
+ };
144
+ }
145
+
146
+ export async function compareStoryPerformance(repoRoot, options = {}) {
147
+ const root = path.resolve(repoRoot);
148
+ const storyId = requiredOption(options.storyId, '--id <story-id>');
149
+ const summary = await summarizeStoryPerformanceEvidence(root, storyId, {
150
+ metricId: options.metricId,
151
+ beforeLabel: options.beforeLabel,
152
+ afterLabel: options.afterLabel
153
+ });
154
+ return {
155
+ comparison: summary,
156
+ markdown: renderPerformanceEvidenceSummary(summary)
157
+ };
158
+ }
159
+
160
+ export async function summarizeStoryPerformanceEvidence(repoRoot, storyId, options = {}) {
161
+ const root = path.resolve(repoRoot);
162
+ const definitions = await readPerformanceMetrics(root, storyId);
163
+ const allRuns = await readPerformanceRuns(root, storyId);
164
+ const targetDefinitions = options.metricId
165
+ ? definitions.metrics.filter((metric) => metric.metricId === options.metricId)
166
+ : definitions.metrics;
167
+ const metrics = targetDefinitions.map((metric) => summarizeMetric(metric, allRuns.runs, {
168
+ beforeLabel: options.beforeLabel,
169
+ afterLabel: options.afterLabel
170
+ }));
171
+ const orphanMetricIds = [...new Set(allRuns.runs
172
+ .map((run) => run.metric_id)
173
+ .filter((metricId) => metricId && !definitions.metrics.some((metric) => metric.metricId === metricId)))];
174
+ return {
175
+ schema_version: SCHEMA_VERSION,
176
+ story_id: storyId,
177
+ generated_at: new Date().toISOString(),
178
+ metric_count: metrics.length,
179
+ run_count: allRuns.runs.length,
180
+ comparable_count: metrics.filter((metric) => metric.comparison.status === 'comparable').length,
181
+ not_comparable_count: metrics.filter((metric) => metric.comparison.status !== 'comparable').length,
182
+ metrics,
183
+ orphan_metric_ids: orphanMetricIds,
184
+ load_errors: [
185
+ ...definitions.errors,
186
+ ...allRuns.errors
187
+ ]
188
+ };
189
+ }
190
+
191
+ export function renderPerformanceDefineSummary(result) {
192
+ return [
193
+ '# VibePro Performance Metric',
194
+ '',
195
+ `Story: ${result.story_id}`,
196
+ `Metric: ${result.metric.metricId}`,
197
+ `Readiness: ${result.metric.readinessKind}`,
198
+ `Start: ${result.metric.startCondition.description}`,
199
+ `Complete: ${result.metric.completionCondition.description}`,
200
+ `Completion kind: ${result.metric.completionCondition.kind}`,
201
+ `Evidence sources: ${result.metric.evidenceSources.map((source) => source.type).join(', ') || '-'}`,
202
+ ''
203
+ ].join('\n');
204
+ }
205
+
206
+ export function renderPerformanceRecordSummary(result) {
207
+ const run = result.run;
208
+ return [
209
+ '# VibePro Performance Run',
210
+ '',
211
+ `Story: ${run.story_id}`,
212
+ `Metric: ${run.metric_id}`,
213
+ `Run: ${run.run_id}`,
214
+ `Label: ${run.label}`,
215
+ `Status: ${run.status}`,
216
+ `Duration: ${formatMs(run.observation.duration_ms)}`,
217
+ `Artifact: ${result.artifacts.json}`,
218
+ '',
219
+ '## Quality',
220
+ ...(run.quality.issues.length === 0
221
+ ? ['- No schema-level issues.']
222
+ : run.quality.issues.map((issue) => `- ${issue}`)),
223
+ ''
224
+ ].join('\n');
225
+ }
226
+
227
+ export function renderPerformanceEvidenceSummary(summary) {
228
+ const lines = [
229
+ '# VibePro Performance Evidence',
230
+ '',
231
+ `Story: ${summary.story_id}`,
232
+ `Metrics: ${summary.metric_count}`,
233
+ `Runs: ${summary.run_count}`,
234
+ `Comparable: ${summary.comparable_count}`,
235
+ `Not comparable: ${summary.not_comparable_count}`,
236
+ ''
237
+ ];
238
+ for (const metric of summary.metrics) {
239
+ lines.push(`## ${metric.metric_id}`);
240
+ lines.push(`- user story: ${metric.user_story}`);
241
+ lines.push(`- readiness: ${metric.readiness_kind}`);
242
+ lines.push(`- start: ${metric.start_condition.description}`);
243
+ lines.push(`- complete: ${metric.completion_condition.description}`);
244
+ lines.push(`- completion kind: ${metric.completion_condition.kind}`);
245
+ lines.push(`- comparison: ${metric.comparison.status}`);
246
+ if (metric.comparison.status !== 'comparable') {
247
+ lines.push(`- improvement: unknown`);
248
+ for (const reason of metric.comparison.not_comparable_reasons) {
249
+ lines.push(`- not comparable: ${reason}`);
250
+ }
251
+ } else {
252
+ lines.push(`- p50: ${formatMs(metric.comparison.delta.p50_ms)} (${formatPercent(metric.comparison.delta.p50_change_ratio)})`);
253
+ lines.push(`- p90: ${formatMs(metric.comparison.delta.p90_ms)} (${formatPercent(metric.comparison.delta.p90_change_ratio)})`);
254
+ lines.push(`- max: ${formatMs(metric.comparison.delta.max_ms)} (${formatPercent(metric.comparison.delta.max_change_ratio)})`);
255
+ }
256
+ lines.push(`- before samples: ${metric.before.sample_count}, incomplete: ${metric.before.incomplete_count} (${formatPercent(metric.before.incomplete_rate)})`);
257
+ lines.push(`- after samples: ${metric.after.sample_count}, incomplete: ${metric.after.incomplete_count} (${formatPercent(metric.after.incomplete_rate)})`);
258
+ for (const missing of metric.missing_evidence) {
259
+ lines.push(`- missing ${missing.label}: ${missing.items.join(', ')}`);
260
+ }
261
+ lines.push('');
262
+ }
263
+ if (summary.metrics.length === 0) {
264
+ lines.push('- No performanceMetrics are defined for this story.', '');
265
+ }
266
+ if (summary.load_errors.length > 0) {
267
+ lines.push('## Load Errors');
268
+ for (const error of summary.load_errors) lines.push(`- ${error.file}: ${error.error}`);
269
+ lines.push('');
270
+ }
271
+ return `${lines.join('\n')}`;
272
+ }
273
+
274
+ export function renderPerformancePrSection(summary) {
275
+ if (!summary || summary.metric_count === 0) {
276
+ return `## Performance Evidence
277
+ - status: not_configured
278
+ - reason: このStoryには performanceMetrics が定義されていません`;
279
+ }
280
+ const rows = summary.metrics.map((metric) => {
281
+ if (metric.comparison.status !== 'comparable') {
282
+ return `| ${metric.metric_id} | ${metric.readiness_kind} | ${metric.completion_condition.kind} | 改善率不明 | ${metric.comparison.not_comparable_reasons.join('; ') || '-'} |`;
283
+ }
284
+ return `| ${metric.metric_id} | ${metric.readiness_kind} | ${metric.completion_condition.kind} | p50 ${formatMs(metric.comparison.delta.p50_ms)}, p90 ${formatMs(metric.comparison.delta.p90_ms)}, max ${formatMs(metric.comparison.delta.max_ms)} | before ${metric.before.sample_count} / after ${metric.after.sample_count} |`;
285
+ });
286
+ const missing = summary.metrics.flatMap((metric) => metric.missing_evidence
287
+ .map((item) => `- ${metric.metric_id}: missing ${item.label}: ${item.items.join(', ')}`));
288
+ return `## Performance Evidence
289
+ | Metric | Readiness | Complete kind | Comparison | Evidence |
290
+ | ------ | --------- | ------------- | ---------- | -------- |
291
+ ${rows.join('\n') || '| - | - | - | - | - |'}
292
+
293
+ ${missing.length > 0 ? missing.join('\n') : '- missing evidence: none'}`;
294
+ }
295
+
296
+ async function readPerformanceMetric(repoRoot, storyId, metricId) {
297
+ const definitions = await readPerformanceMetrics(repoRoot, storyId);
298
+ const metric = definitions.metrics.find((item) => item.metricId === metricId);
299
+ if (!metric) throw new Error(`Performance metric not found for ${storyId}: ${metricId}`);
300
+ return metric;
301
+ }
302
+
303
+ async function readPerformanceMetrics(repoRoot, storyId) {
304
+ const configPath = path.join(getWorkspaceDir(repoRoot), 'config.json');
305
+ try {
306
+ const config = JSON.parse(await readFile(configPath, 'utf8'));
307
+ const rawStories = Array.isArray(config.brainbase?.stories) ? config.brainbase.stories : [];
308
+ const stories = rawStories.length > 0 ? rawStories : normalizeActiveStories(config.brainbase?.stories);
309
+ const story = stories.find((item) => item.story_id === storyId && item.status !== 'archived');
310
+ const metrics = (story?.performanceMetrics ?? []).map((metric) => normalizeMetricDefinition(metric));
311
+ return { metrics, errors: [] };
312
+ } catch (error) {
313
+ if (error.code === 'ENOENT') return { metrics: [], errors: [] };
314
+ return { metrics: [], errors: [{ file: toWorkspaceRelative(repoRoot, configPath), error: error.message }] };
315
+ }
316
+ }
317
+
318
+ async function readPerformanceRuns(repoRoot, storyId) {
319
+ const runDir = getPerformanceRunDir(repoRoot, storyId);
320
+ let files;
321
+ try {
322
+ files = (await readdir(runDir)).filter((file) => file.endsWith('.json')).sort();
323
+ } catch (error) {
324
+ if (error.code === 'ENOENT') return { runs: [], errors: [] };
325
+ throw error;
326
+ }
327
+ const runs = [];
328
+ const errors = [];
329
+ for (const file of files) {
330
+ const filePath = path.join(runDir, file);
331
+ try {
332
+ const run = JSON.parse(await readFile(filePath, 'utf8'));
333
+ runs.push({ ...run, artifact: toWorkspaceRelative(repoRoot, filePath) });
334
+ } catch (error) {
335
+ errors.push({ file: toWorkspaceRelative(repoRoot, filePath), error: error.message });
336
+ }
337
+ }
338
+ return { runs, errors };
339
+ }
340
+
341
+ function summarizeMetric(metric, allRuns, options = {}) {
342
+ const beforeLabel = options.beforeLabel ?? metric.comparisonPolicy.beforeLabel;
343
+ const afterLabel = options.afterLabel ?? metric.comparisonPolicy.afterLabel;
344
+ const metricRuns = allRuns.filter((run) => run.metric_id === metric.metricId);
345
+ const beforeRuns = metricRuns.filter((run) => run.label === beforeLabel);
346
+ const afterRuns = metricRuns.filter((run) => run.label === afterLabel);
347
+ const before = summarizeRunGroup(metric, beforeRuns);
348
+ const after = summarizeRunGroup(metric, afterRuns);
349
+ const comparison = compareRunGroups(metric, before, after, { beforeLabel, afterLabel, beforeRuns, afterRuns });
350
+ return {
351
+ metric_id: metric.metricId,
352
+ user_story: metric.userStory,
353
+ readiness_kind: metric.readinessKind,
354
+ start_condition: metric.startCondition,
355
+ completion_condition: metric.completionCondition,
356
+ intermediate_markers: metric.intermediateMarkers,
357
+ timeout_ms: metric.timeoutMs,
358
+ evidence_sources: metric.evidenceSources,
359
+ comparison_policy: {
360
+ ...metric.comparisonPolicy,
361
+ beforeLabel,
362
+ afterLabel
363
+ },
364
+ before,
365
+ after,
366
+ comparison,
367
+ missing_evidence: collectMissingEvidence(metric, { beforeRuns, afterRuns })
368
+ };
369
+ }
370
+
371
+ function summarizeRunGroup(metric, runs) {
372
+ const completionCondition = metric.completionCondition.description;
373
+ const completed = runs.filter((run) => isCompletedComparableRun(run, completionCondition));
374
+ const durations = completed.map((run) => run.observation?.duration_ms).filter((value) => Number.isFinite(value));
375
+ const incompleteRuns = runs.filter((run) => run.status !== COMPLETED_STATUS);
376
+ return {
377
+ label: runs[0]?.label ?? null,
378
+ run_count: runs.length,
379
+ sample_count: durations.length,
380
+ incomplete_count: incompleteRuns.length,
381
+ incomplete_rate: runs.length > 0 ? roundRatio(incompleteRuns.length / runs.length) : null,
382
+ p50_ms: percentile(durations, 0.5),
383
+ p90_ms: percentile(durations, 0.9),
384
+ max_ms: durations.length > 0 ? Math.max(...durations) : null,
385
+ status_classifications: countBy(incompleteRuns.map((run) => run.status_classification ?? run.status ?? 'unknown')),
386
+ artifacts: runs.map((run) => run.artifact).filter(Boolean)
387
+ };
388
+ }
389
+
390
+ function compareRunGroups(metric, before, after, context) {
391
+ const reasons = [];
392
+ if (before.run_count === 0) reasons.push(`missing baseline label "${context.beforeLabel}"`);
393
+ if (after.run_count === 0) reasons.push(`missing after label "${context.afterLabel}"`);
394
+ if (before.sample_count === 0 && before.run_count > 0) reasons.push('baseline has no completed duration samples');
395
+ if (after.sample_count === 0 && after.run_count > 0) reasons.push('after has no completed duration samples');
396
+ const mismatches = [...context.beforeRuns, ...context.afterRuns]
397
+ .filter((run) => run.comparison_key?.completion_condition !== metric.completionCondition.description);
398
+ if (mismatches.length > 0) reasons.push('completionCondition mismatch exists in recorded runs');
399
+ if (metric.readinessKind === 'user_perceived') {
400
+ if (!hasUserPerceivedEvidence(context.beforeRuns)) reasons.push('baseline user-perceived evidence is missing; server logs alone cannot prove user experience');
401
+ if (!hasUserPerceivedEvidence(context.afterRuns)) reasons.push('after user-perceived evidence is missing; server logs alone cannot prove user experience');
402
+ }
403
+ const status = reasons.length === 0 ? 'comparable' : 'not_comparable';
404
+ return {
405
+ status,
406
+ not_comparable_reasons: reasons,
407
+ delta: status === 'comparable' ? {
408
+ p50_ms: nullableDelta(after.p50_ms, before.p50_ms),
409
+ p90_ms: nullableDelta(after.p90_ms, before.p90_ms),
410
+ max_ms: nullableDelta(after.max_ms, before.max_ms),
411
+ p50_change_ratio: nullableRatioDelta(after.p50_ms, before.p50_ms),
412
+ p90_change_ratio: nullableRatioDelta(after.p90_ms, before.p90_ms),
413
+ max_change_ratio: nullableRatioDelta(after.max_ms, before.max_ms)
414
+ } : {
415
+ p50_ms: null,
416
+ p90_ms: null,
417
+ max_ms: null,
418
+ p50_change_ratio: null,
419
+ p90_change_ratio: null,
420
+ max_change_ratio: null
421
+ }
422
+ };
423
+ }
424
+
425
+ function collectMissingEvidence(metric, { beforeRuns, afterRuns }) {
426
+ return [
427
+ missingForLabel(metric, 'before', beforeRuns),
428
+ missingForLabel(metric, 'after', afterRuns)
429
+ ].filter((item) => item.items.length > 0);
430
+ }
431
+
432
+ function missingForLabel(metric, label, runs) {
433
+ const completedRuns = runs.filter((run) => run.status === COMPLETED_STATUS);
434
+ const markerIds = new Set(completedRuns.flatMap((run) => (run.observation?.intermediate_markers ?? []).map((marker) => marker.markerId)));
435
+ const sourceTypes = new Set(completedRuns.flatMap((run) => (run.observation?.evidence_sources ?? []).map((source) => source.type)));
436
+ const missingMarkers = metric.intermediateMarkers
437
+ .map((marker) => marker.markerId)
438
+ .filter((markerId) => !markerIds.has(markerId));
439
+ const missingSources = metric.evidenceSources
440
+ .map((source) => source.type)
441
+ .filter((type) => !sourceTypes.has(type));
442
+ return {
443
+ label,
444
+ items: [
445
+ ...missingMarkers.map((markerId) => `marker:${markerId}`),
446
+ ...missingSources.map((type) => `source:${type}`)
447
+ ]
448
+ };
449
+ }
450
+
451
+ function evaluateRunQuality(metric, { status, durationMs, completionCondition, markers, evidenceSources }) {
452
+ const issues = [];
453
+ if (status === COMPLETED_STATUS && !Number.isFinite(durationMs)) {
454
+ issues.push('completed run requires duration_ms or started_at/completed_at');
455
+ }
456
+ if (completionCondition !== metric.completionCondition.description) {
457
+ issues.push('completionCondition differs from metric definition; this run will not be used for before/after comparison');
458
+ }
459
+ const markerIds = new Set(markers.map((marker) => marker.markerId));
460
+ for (const marker of metric.intermediateMarkers) {
461
+ if (!markerIds.has(marker.markerId)) issues.push(`missing intermediate marker: ${marker.markerId}`);
462
+ }
463
+ const sourceTypes = new Set(evidenceSources.map((source) => source.type));
464
+ for (const source of metric.evidenceSources) {
465
+ if (!sourceTypes.has(source.type)) issues.push(`missing evidence source: ${source.type}`);
466
+ }
467
+ if (metric.readinessKind === 'user_perceived' && !evidenceSources.some((source) => USER_PERCEIVED_SOURCES.has(source.type))) {
468
+ issues.push('user_perceived metric requires browser_e2e, client_marker, or manual_observation evidence');
469
+ }
470
+ return {
471
+ status: issues.length === 0 ? 'ok' : 'needs_review',
472
+ issues
473
+ };
474
+ }
475
+
476
+ function normalizeMetricDefinition(raw) {
477
+ const startDescription = typeof raw.startCondition === 'string'
478
+ ? raw.startCondition
479
+ : raw.startCondition?.description;
480
+ const completionDescription = typeof raw.completionCondition === 'string'
481
+ ? raw.completionCondition
482
+ : raw.completionCondition?.description;
483
+ const readinessKind = normalizeReadinessKind(raw.readinessKind ?? raw.readiness_kind ?? inferReadinessKind(raw.metricId, raw.evidenceSources));
484
+ return {
485
+ schema_version: SCHEMA_VERSION,
486
+ metricId: requiredOption(raw.metricId ?? raw.metric_id, 'metricId'),
487
+ userStory: requiredOption(raw.userStory ?? raw.user_story, 'userStory'),
488
+ readinessKind,
489
+ startCondition: {
490
+ description: requiredOption(startDescription, 'startCondition'),
491
+ kind: classifyStartCondition(startDescription)
492
+ },
493
+ completionCondition: {
494
+ description: requiredOption(completionDescription, 'completionCondition'),
495
+ kind: raw.completionCondition?.kind ?? classifyCompletionCondition(completionDescription)
496
+ },
497
+ intermediateMarkers: normalizeMarkers(raw.intermediateMarkers ?? raw.intermediate_markers),
498
+ timeoutMs: normalizeTimeout(raw.timeoutMs ?? raw.timeout_ms),
499
+ failureClassifications: normalizeFailureClassifications(raw.failureClassifications ?? raw.failure_classifications),
500
+ evidenceSources: normalizeEvidenceSourceDefinitions(raw.evidenceSources ?? raw.evidence_sources),
501
+ comparisonPolicy: normalizeComparisonPolicy(raw.comparisonPolicy ?? raw.comparison_policy)
502
+ };
503
+ }
504
+
505
+ function normalizeMarkers(markers) {
506
+ return normalizeList(markers).map((marker) => {
507
+ if (typeof marker === 'object' && marker) {
508
+ return {
509
+ markerId: requiredOption(marker.markerId ?? marker.marker_id ?? marker.id, 'markerId'),
510
+ description: marker.description ?? marker.markerId ?? marker.id
511
+ };
512
+ }
513
+ const markerId = String(marker).trim();
514
+ return { markerId, description: markerId };
515
+ }).filter((marker) => marker.markerId);
516
+ }
517
+
518
+ function normalizeObservedMarkers(markers) {
519
+ return normalizeList(markers).map((marker) => {
520
+ if (typeof marker === 'object' && marker) return marker;
521
+ const text = String(marker);
522
+ const separator = text.lastIndexOf('=');
523
+ const rawId = separator === -1 ? text : text.slice(0, separator);
524
+ const rawValue = separator === -1 ? '' : text.slice(separator + 1);
525
+ const elapsedMs = rawValue === '' ? null : Number(rawValue);
526
+ return {
527
+ markerId: rawId.trim(),
528
+ elapsed_ms: Number.isFinite(elapsedMs) ? elapsedMs : null
529
+ };
530
+ }).filter((marker) => marker.markerId);
531
+ }
532
+
533
+ function normalizeEvidenceSourceDefinitions(sources) {
534
+ return normalizeList(sources).map((source) => {
535
+ if (typeof source === 'object' && source) {
536
+ return {
537
+ type: normalizeEvidenceSourceType(source.type),
538
+ description: source.description ?? source.type
539
+ };
540
+ }
541
+ const type = normalizeEvidenceSourceType(source);
542
+ return { type, description: type };
543
+ });
544
+ }
545
+
546
+ function normalizeEvidenceSources(sources) {
547
+ return normalizeList(sources).map((source) => {
548
+ if (typeof source === 'object' && source) {
549
+ return {
550
+ type: normalizeEvidenceSourceType(source.type),
551
+ ref: source.ref ?? source.path ?? null,
552
+ summary: source.summary ?? null
553
+ };
554
+ }
555
+ const [rawType, rawRef = '', rawSummary = ''] = String(source).split(':');
556
+ return {
557
+ type: normalizeEvidenceSourceType(rawType),
558
+ ref: rawRef || null,
559
+ summary: rawSummary || null
560
+ };
561
+ });
562
+ }
563
+
564
+ function normalizeEvidenceSourceType(value) {
565
+ const type = String(value ?? '').trim();
566
+ if (!EVIDENCE_SOURCE_TYPES.has(type)) {
567
+ throw new Error(`Unsupported evidence source type: ${type}. Use one of ${[...EVIDENCE_SOURCE_TYPES].join(', ')}`);
568
+ }
569
+ return type;
570
+ }
571
+
572
+ function normalizeComparisonPolicy(policy) {
573
+ if (typeof policy === 'string' && policy.trim().startsWith('{')) {
574
+ return normalizeComparisonPolicy(JSON.parse(policy));
575
+ }
576
+ if (typeof policy === 'string' && policy.trim()) {
577
+ return {
578
+ mode: policy,
579
+ beforeLabel: 'before',
580
+ afterLabel: 'after',
581
+ statistic: 'p50_p90_max',
582
+ compareOnlySameCompletionCondition: true
583
+ };
584
+ }
585
+ return {
586
+ mode: policy?.mode ?? 'before_after',
587
+ beforeLabel: policy?.beforeLabel ?? policy?.before_label ?? 'before',
588
+ afterLabel: policy?.afterLabel ?? policy?.after_label ?? 'after',
589
+ statistic: policy?.statistic ?? 'p50_p90_max',
590
+ compareOnlySameCompletionCondition: policy?.compareOnlySameCompletionCondition ?? policy?.compare_only_same_completion_condition ?? true
591
+ };
592
+ }
593
+
594
+ function normalizeFailureClassifications(values) {
595
+ const classifications = normalizeList(values);
596
+ const required = ['blocked', 'needs_review', 'timeout', 'auth_required', 'resource_unavailable', 'unknown'];
597
+ return [...new Set([...classifications, ...required])];
598
+ }
599
+
600
+ function normalizeStatus(status) {
601
+ const normalized = String(status).trim();
602
+ if (normalized === 'pass') return COMPLETED_STATUS;
603
+ if (normalized === COMPLETED_STATUS || INCOMPLETE_STATUSES.has(normalized)) return normalized;
604
+ throw new Error(`Unsupported performance run status: ${status}`);
605
+ }
606
+
607
+ function normalizeReadinessKind(value) {
608
+ const kind = String(value ?? 'user_perceived').trim();
609
+ if (['server_side', 'user_perceived', 'external_dependency', 'system_internal'].includes(kind)) return kind;
610
+ throw new Error(`Unsupported readiness kind: ${kind}`);
611
+ }
612
+
613
+ function inferReadinessKind(metricId, evidenceSources) {
614
+ const id = String(metricId ?? '').toLowerCase();
615
+ if (/server|internal/.test(id)) return 'server_side';
616
+ const sources = normalizeList(evidenceSources).map((source) => typeof source === 'object' ? source.type : source);
617
+ if (sources.length > 0 && sources.every((source) => source === 'server_log' || source === 'api_log')) return 'server_side';
618
+ return 'user_perceived';
619
+ }
620
+
621
+ function normalizeDuration(value, { startedAt, completedAt, status }) {
622
+ if (value !== null && value !== undefined) {
623
+ const duration = Number(value);
624
+ if (!Number.isFinite(duration) || duration < 0) throw new Error('--duration-ms must be a non-negative number');
625
+ return Math.round(duration);
626
+ }
627
+ if (startedAt && completedAt) {
628
+ const duration = new Date(completedAt).getTime() - new Date(startedAt).getTime();
629
+ if (Number.isFinite(duration) && duration >= 0) return duration;
630
+ }
631
+ return status === COMPLETED_STATUS ? null : null;
632
+ }
633
+
634
+ function normalizeTimeout(value) {
635
+ const timeout = Number(value ?? 30000);
636
+ if (!Number.isFinite(timeout) || timeout < 1) throw new Error('timeoutMs must be a positive number');
637
+ return Math.floor(timeout);
638
+ }
639
+
640
+ function normalizeList(value) {
641
+ if (value === null || value === undefined) return [];
642
+ if (Array.isArray(value)) return value.flatMap((item) => normalizeList(item));
643
+ if (typeof value === 'string') {
644
+ return value.split(',').map((item) => item.trim()).filter(Boolean);
645
+ }
646
+ return [value];
647
+ }
648
+
649
+ function classifyStartCondition(description) {
650
+ const text = String(description ?? '').toLowerCase();
651
+ if (/click|tap|keypress|input|操作/.test(text)) return 'user_action';
652
+ if (/request|api|handleupgrade|websocket|server/.test(text)) return 'server_event';
653
+ if (/marker|client/.test(text)) return 'client_marker';
654
+ return 'custom';
655
+ }
656
+
657
+ function classifyCompletionCondition(description) {
658
+ const text = String(description ?? '').toLowerCase();
659
+ if (/snapshot/.test(text)) return 'snapshot_visible';
660
+ if (/dom|visible|render|表示|host/.test(text)) return 'dom_visible';
661
+ if (/api|response|request.*complete|完了/.test(text)) return 'api_completed';
662
+ if (/inputready|interactive|clickable|操作可能|ready=true|owner/.test(text)) return 'interactive_ready';
663
+ if (/tmux|running=true|wsstate|server|backend/.test(text)) return 'server_ready';
664
+ return 'custom';
665
+ }
666
+
667
+ function isCompletedComparableRun(run, completionCondition) {
668
+ return run.status === COMPLETED_STATUS
669
+ && Number.isFinite(run.observation?.duration_ms)
670
+ && run.comparison_key?.completion_condition === completionCondition;
671
+ }
672
+
673
+ function hasUserPerceivedEvidence(runs) {
674
+ return runs.some((run) => (run.observation?.evidence_sources ?? [])
675
+ .some((source) => USER_PERCEIVED_SOURCES.has(source.type)));
676
+ }
677
+
678
+ function percentile(values, ratio) {
679
+ if (!Array.isArray(values) || values.length === 0) return null;
680
+ const sorted = [...values].sort((a, b) => a - b);
681
+ const index = Math.ceil(sorted.length * ratio) - 1;
682
+ return sorted[Math.min(Math.max(index, 0), sorted.length - 1)];
683
+ }
684
+
685
+ function nullableDelta(after, before) {
686
+ if (!Number.isFinite(after) || !Number.isFinite(before)) return null;
687
+ return after - before;
688
+ }
689
+
690
+ function nullableRatioDelta(after, before) {
691
+ if (!Number.isFinite(after) || !Number.isFinite(before) || before === 0) return null;
692
+ return roundRatio((after - before) / before);
693
+ }
694
+
695
+ function roundRatio(value) {
696
+ return Math.round(value * 10000) / 10000;
697
+ }
698
+
699
+ function countBy(values) {
700
+ return values.reduce((acc, value) => {
701
+ acc[value] = (acc[value] ?? 0) + 1;
702
+ return acc;
703
+ }, {});
704
+ }
705
+
706
+ function formatMs(value) {
707
+ if (value === null || value === undefined) return '-';
708
+ const sign = value < 0 ? '-' : '';
709
+ const absolute = Math.abs(value);
710
+ if (absolute >= 1000) return `${sign}${(absolute / 1000).toFixed(2)}s`;
711
+ return `${sign}${absolute}ms`;
712
+ }
713
+
714
+ function formatPercent(value) {
715
+ if (value === null || value === undefined) return '-';
716
+ return `${Math.round(value * 1000) / 10}%`;
717
+ }
718
+
719
+ function requiredOption(value, name) {
720
+ if (value === null || value === undefined || String(value).trim() === '') {
721
+ throw new Error(`${name} is required`);
722
+ }
723
+ return value;
724
+ }
725
+
726
+ function getPerformanceRunDir(repoRoot, storyId) {
727
+ return path.join(getWorkspaceDir(repoRoot), 'pr', storyId, 'performance-runs');
728
+ }
729
+
730
+ function safeFileName(value) {
731
+ return String(value).replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '') || createRunId();
732
+ }
733
+
734
+ function createRunId() {
735
+ return `${new Date().toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '')}-${process.pid}`;
736
+ }
737
+
738
+ async function updatePerformanceManifest(repoRoot, storyId, run, jsonPath, summary) {
739
+ const manifest = await readManifest(repoRoot);
740
+ manifest.performance_evidence = {
741
+ ...(manifest.performance_evidence ?? {}),
742
+ [storyId]: {
743
+ latest_run: run.run_id,
744
+ latest_metric_id: run.metric_id,
745
+ latest_run_artifact: toWorkspaceRelative(repoRoot, jsonPath),
746
+ latest_summary: {
747
+ generated_at: summary.generated_at,
748
+ metric_count: summary.metric_count,
749
+ run_count: summary.run_count,
750
+ comparable_count: summary.comparable_count,
751
+ not_comparable_count: summary.not_comparable_count
752
+ }
753
+ }
754
+ };
755
+ await writeManifest(repoRoot, manifest);
756
+ }