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.
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.ja.md +448 -0
- package/README.md +520 -0
- package/agent-instructions/codex/AGENTS.vibepro.md +45 -0
- package/bin/vibepro.js +9 -0
- package/docs/assets/vibepro-header.png +0 -0
- package/package.json +51 -0
- package/skills/vibepro-diagnosis-packages/SKILL.md +133 -0
- package/skills/vibepro-human-review/SKILL.md +73 -0
- package/skills/vibepro-story-refactor/SKILL.md +89 -0
- package/skills/vibepro-workflow/SKILL.md +139 -0
- package/src/agent-harness-map.js +230 -0
- package/src/agent-harness-scanner.js +337 -0
- package/src/agent-review.js +2180 -0
- package/src/api-boundary-scanner.js +452 -0
- package/src/architecture-profiler.js +423 -0
- package/src/authorization-scoring.js +149 -0
- package/src/brainbase-importer.js +534 -0
- package/src/change-risk-classifier.js +195 -0
- package/src/check-packs.js +605 -0
- package/src/checkpoint-manager.js +233 -0
- package/src/cli.js +2213 -0
- package/src/code-quality-scanner.js +310 -0
- package/src/codex-manager.js +143 -0
- package/src/component-style-scanner.js +336 -0
- package/src/coverage-report.js +99 -0
- package/src/database-access-scanner.js +163 -0
- package/src/decision-records.js +315 -0
- package/src/design-modernize.js +1435 -0
- package/src/design-system.js +1732 -0
- package/src/diagnostic-engine.js +1945 -0
- package/src/diagram-requirement-resolver.js +194 -0
- package/src/doctor.js +677 -0
- package/src/environment-graph.js +424 -0
- package/src/execution-state.js +849 -0
- package/src/explore-evidence.js +425 -0
- package/src/flow-design-scanner.js +896 -0
- package/src/flow-verifier.js +887 -0
- package/src/gesture-interaction-scanner.js +330 -0
- package/src/graph-context.js +263 -0
- package/src/graphify-adapter.js +189 -0
- package/src/html-report.js +1035 -0
- package/src/journey-map.js +1299 -0
- package/src/language.js +48 -0
- package/src/lazy-pattern-detector.js +182 -0
- package/src/local-dev-scanner.js +135 -0
- package/src/managed-worktree-gate.js +187 -0
- package/src/managed-worktree.js +766 -0
- package/src/merge-manager.js +501 -0
- package/src/network-contract-scanner.js +442 -0
- package/src/nocodb-story-sync.js +386 -0
- package/src/oss-readiness-scanner.js +417 -0
- package/src/performance-evidence.js +756 -0
- package/src/performance-measurer.js +591 -0
- package/src/pr-manager.js +8220 -0
- package/src/presets.js +682 -0
- package/src/public-discovery-scanner.js +519 -0
- package/src/refactoring-delta-reporter.js +367 -0
- package/src/refactoring-opportunity-generator.js +797 -0
- package/src/regression-risk-scanner.js +146 -0
- package/src/repo-status.js +266 -0
- package/src/report-fingerprint.js +188 -0
- package/src/report-pr-body-prompt-template.md +108 -0
- package/src/report-pr-body-schema.json +95 -0
- package/src/report-store.js +135 -0
- package/src/report-validator.js +192 -0
- package/src/requirement-consistency.js +1066 -0
- package/src/runtime-info.js +134 -0
- package/src/self-dogfood-scanner.js +476 -0
- package/src/session-learning.js +164 -0
- package/src/skills-manager.js +157 -0
- package/src/spec-drift.js +378 -0
- package/src/spec-fingerprint.js +445 -0
- package/src/spec-prompt-template.md +155 -0
- package/src/spec-schema.json +219 -0
- package/src/spec-store.js +258 -0
- package/src/spec-validator.js +459 -0
- package/src/static-site-scanner.js +316 -0
- package/src/story-candidate-generator.js +85 -0
- package/src/story-catalog-generator.js +2813 -0
- package/src/story-html.js +156 -0
- package/src/story-manager.js +2144 -0
- package/src/story-task-generator.js +522 -0
- package/src/task-manager.js +1029 -0
- package/src/terminal-link-scanner.js +238 -0
- package/src/usage-report.js +417 -0
- package/src/verification-evidence.js +284 -0
- 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
|
+
}
|