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,2813 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { profileArchitecture } from './architecture-profiler.js';
|
|
5
|
+
import { getPreset, resolvePresetId } from './presets.js';
|
|
6
|
+
import { generateStoryCandidates } from './story-candidate-generator.js';
|
|
7
|
+
import { getWorkspaceDir } from './workspace.js';
|
|
8
|
+
|
|
9
|
+
const IGNORED_DIRS = new Set([
|
|
10
|
+
'.git',
|
|
11
|
+
'.next',
|
|
12
|
+
'.turbo',
|
|
13
|
+
'.vibepro',
|
|
14
|
+
'.venv',
|
|
15
|
+
'coverage',
|
|
16
|
+
'dist',
|
|
17
|
+
'graphify-out',
|
|
18
|
+
'node_modules',
|
|
19
|
+
'venv'
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const CODE_SURFACE_SIGNATURES = [
|
|
23
|
+
{
|
|
24
|
+
id: 'story-product-auth-account-access',
|
|
25
|
+
title: '認証とアカウント利用開始を成立させる',
|
|
26
|
+
category: 'product',
|
|
27
|
+
patterns: [
|
|
28
|
+
/^src\/app\/\(auth\)\//,
|
|
29
|
+
/^src\/app\/auth\//,
|
|
30
|
+
/^src\/app\/login\//,
|
|
31
|
+
/^src\/app\/api\/auth\//,
|
|
32
|
+
/^src\/app\/api\/sso-logout\//,
|
|
33
|
+
/^src\/components\/auth\//,
|
|
34
|
+
/^src\/lib\/auth/,
|
|
35
|
+
/^src\/lib\/services\/user\//,
|
|
36
|
+
/^src\/lib\/actions\/user_actions\.ts$/
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'story-product-profile-personalization',
|
|
41
|
+
title: 'プロフィール情報で体験を個人化する',
|
|
42
|
+
category: 'product',
|
|
43
|
+
patterns: [
|
|
44
|
+
/^src\/app\/\(app\)\/profile\//,
|
|
45
|
+
/^src\/app\/profile\//,
|
|
46
|
+
/^src\/components\/profile\//,
|
|
47
|
+
/^src\/lib\/services\/profile\//,
|
|
48
|
+
/^src\/lib\/actions\/profile_action\.ts$/,
|
|
49
|
+
/^src\/lib\/constants\/profile-errors\.ts$/
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'story-product-content-cms',
|
|
54
|
+
title: '記事とCMS運用を整理する',
|
|
55
|
+
category: 'product',
|
|
56
|
+
patterns: [
|
|
57
|
+
/^src\/app\/\(public\)\/articles\//,
|
|
58
|
+
/^src\/app\/articles\//,
|
|
59
|
+
/^src\/app\/api\/articles\//,
|
|
60
|
+
/^src\/app\/admin\/content\//,
|
|
61
|
+
/^src\/lib\/article/,
|
|
62
|
+
/^src\/lib\/article-utils\.ts$/
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'story-product-premium-billing',
|
|
67
|
+
title: 'プレミアム課金導線を安定化する',
|
|
68
|
+
category: 'product',
|
|
69
|
+
patterns: [
|
|
70
|
+
/^src\/app\/\(public\)\/premium\//,
|
|
71
|
+
/^src\/app\/api\/stripe\//,
|
|
72
|
+
/^src\/app\/api\/webhook\/stripe\/route\.ts$/,
|
|
73
|
+
/^src\/components\/ui\/button\/ButtonCheckout\.tsx$/,
|
|
74
|
+
/^src\/components\/ui\/modal\/PremiumRequiredModal\.tsx$/,
|
|
75
|
+
/^src\/lib\/constants\/stripe\.ts$/,
|
|
76
|
+
/^src\/lib\/services\/stripe\//
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'story-product-notification',
|
|
81
|
+
title: '通知体験を安定化する',
|
|
82
|
+
category: 'product',
|
|
83
|
+
patterns: [
|
|
84
|
+
/^src\/app\/\(app\)\/notification\//,
|
|
85
|
+
/^src\/app\/notification\//,
|
|
86
|
+
/^src\/components\/notification\//,
|
|
87
|
+
/^src\/components\/ui\/UpdateNotification\.tsx$/,
|
|
88
|
+
/^src\/lib\/services\/notification\//
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'story-product-public-discovery-seo',
|
|
93
|
+
title: '公開検索とSEO導線で新規流入を受け止める',
|
|
94
|
+
category: 'product',
|
|
95
|
+
patterns: [
|
|
96
|
+
/^src\/app\/\(public\)\/articles\//,
|
|
97
|
+
/^src\/app\/\(public\)\/sitemap/,
|
|
98
|
+
/^src\/app\/robots\.ts$/,
|
|
99
|
+
/^src\/app\/sitemap\.ts$/,
|
|
100
|
+
/^src\/lib\/services\/analytics\//,
|
|
101
|
+
/^src\/components\/common\/StructuredData\.tsx$/
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'story-product-waiting-list-contact',
|
|
106
|
+
title: '問い合わせと待機リストで利用意向を受け取る',
|
|
107
|
+
category: 'product',
|
|
108
|
+
patterns: [
|
|
109
|
+
/^src\/app\/\(public\)\/contact\//,
|
|
110
|
+
/^src\/app\/\(public\)\/waiting-list\//,
|
|
111
|
+
/^src\/lib\/constants\/contact\.ts$/
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'story-product-qr-offline-access',
|
|
116
|
+
title: 'QRとオフライン状態でも利用接点を維持する',
|
|
117
|
+
category: 'product',
|
|
118
|
+
patterns: [
|
|
119
|
+
/^src\/app\/\(app\)\/_components\/QRCodeScanner\.tsx$/,
|
|
120
|
+
/^src\/app\/\(public\)\/offline\//,
|
|
121
|
+
/^src\/components\/common\/ModernServiceWorkerManager\.tsx$/,
|
|
122
|
+
/^src\/components\/ui\/UpdateNotification\.tsx$/
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'story-product-app-navigation-shell',
|
|
127
|
+
title: 'アプリの起点とナビゲーションを成立させる',
|
|
128
|
+
category: 'product',
|
|
129
|
+
patterns: [
|
|
130
|
+
/^src\/app\/\(app\)\/home\//,
|
|
131
|
+
/^src\/components\/layout\//
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'story-ops-observability-health',
|
|
136
|
+
title: '稼働状態と運用確認を見える化する',
|
|
137
|
+
category: 'ops',
|
|
138
|
+
patterns: [
|
|
139
|
+
/^src\/app\/api\/health\/route\.ts$/,
|
|
140
|
+
/^src\/app\/api\/heartbeat\/route\.ts$/,
|
|
141
|
+
/^src\/app\/api\/vercel\/route\.ts$/,
|
|
142
|
+
/^src\/app\/log_viewer\//,
|
|
143
|
+
/^src\/components\/common\/ConsoleLogger\.tsx$/
|
|
144
|
+
]
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'story-product-legal-trust-pages',
|
|
148
|
+
title: '公開ページで利用前の信頼と規約確認を支える',
|
|
149
|
+
category: 'product',
|
|
150
|
+
patterns: [
|
|
151
|
+
/^src\/app\/\(public\)\/privacy/,
|
|
152
|
+
/^src\/app\/\(public\)\/terms\//,
|
|
153
|
+
/^src\/app\/\(public\)\/tos\//,
|
|
154
|
+
/^src\/app\/\(public\)\/tokusho\//,
|
|
155
|
+
/^src\/app\/\(public\)\/guidelines\//
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
export async function generateStoryCatalog(repoRoot, options = {}) {
|
|
161
|
+
const root = path.resolve(repoRoot);
|
|
162
|
+
const files = await collectRepoFiles(root);
|
|
163
|
+
const fileSet = new Set(files.map((file) => file.relativePath));
|
|
164
|
+
const evidenceResult = await readEvidence(root, options.manifest, options.fromRunId);
|
|
165
|
+
const evidence = evidenceResult.evidence;
|
|
166
|
+
const architectureProfile = evidence?.architecture_profile ?? await profileArchitecture(root);
|
|
167
|
+
const repoProfile = detectStoryRepoProfile(fileSet, architectureProfile, files);
|
|
168
|
+
const explicitPresetId = options.preset ?? options.config?.story_catalog?.preset ?? null;
|
|
169
|
+
const activePreset = getPreset(resolvePresetId(options.config, options.preset));
|
|
170
|
+
const presetResolution = {
|
|
171
|
+
mode: explicitPresetId ? 'explicit' : 'auto',
|
|
172
|
+
requested: explicitPresetId,
|
|
173
|
+
selected: activePreset.id,
|
|
174
|
+
repo_profile: repoProfile.id,
|
|
175
|
+
reason: explicitPresetId
|
|
176
|
+
? 'Preset was explicitly selected by CLI or repo config.'
|
|
177
|
+
: 'Preset was selected by VibePro default with repo profile applicability gates.'
|
|
178
|
+
};
|
|
179
|
+
const currentStory = findCurrentStory(options.config);
|
|
180
|
+
const defaults = buildDefaultStoryFields(currentStory, activePreset, {
|
|
181
|
+
repoProfile,
|
|
182
|
+
presetExplicit: Boolean(explicitPresetId)
|
|
183
|
+
});
|
|
184
|
+
const graph = await readGraph(root);
|
|
185
|
+
const graphSummary = summarizeGraph(graph);
|
|
186
|
+
const documentSignals = await collectDocumentSignals(root, files, activePreset);
|
|
187
|
+
const productSurfaceResult = deriveProductSurfaceStories(fileSet, defaults, documentSignals, activePreset, {
|
|
188
|
+
repoProfile,
|
|
189
|
+
presetExplicit: Boolean(explicitPresetId)
|
|
190
|
+
});
|
|
191
|
+
const codeSurfaceResult = deriveCodeSurfaceStories(fileSet, defaults, documentSignals, activePreset, {
|
|
192
|
+
repoProfile,
|
|
193
|
+
presetExplicit: Boolean(explicitPresetId)
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const derivedStories = attachLinkedDocumentSignals([
|
|
197
|
+
...productSurfaceResult.stories,
|
|
198
|
+
...codeSurfaceResult.stories,
|
|
199
|
+
...deriveArchitectureStories(architectureProfile, evidence, defaults, documentSignals),
|
|
200
|
+
...deriveDocumentationStories(fileSet, documentSignals, defaults)
|
|
201
|
+
], documentSignals);
|
|
202
|
+
const stories = dedupeStories([
|
|
203
|
+
...derivedStories,
|
|
204
|
+
...deriveConfiguredStories(options.config, documentSignals, defaults)
|
|
205
|
+
]);
|
|
206
|
+
const coverage = buildGraphStoryCoverage(graph, stories, activePreset);
|
|
207
|
+
const openQuestions = collectOpenQuestions(stories);
|
|
208
|
+
const storyCandidates = generateStoryCandidates(coverage);
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
schema_version: '0.1.0',
|
|
212
|
+
generated_at: new Date().toISOString(),
|
|
213
|
+
source: {
|
|
214
|
+
tool: 'vibepro',
|
|
215
|
+
repo: '.',
|
|
216
|
+
run_id: evidence?.run_id ?? null,
|
|
217
|
+
evidence: evidence ? evidencePathForRun(options.manifest, evidence.run_id) : null,
|
|
218
|
+
preset: activePreset.id,
|
|
219
|
+
preset_resolution: presetResolution,
|
|
220
|
+
repo_profile: repoProfile,
|
|
221
|
+
graphify: graphSummary,
|
|
222
|
+
warnings: [...evidenceResult.warnings, ...mergeDomainConfirmationWarnings([
|
|
223
|
+
...productSurfaceResult.warnings,
|
|
224
|
+
...codeSurfaceResult.warnings
|
|
225
|
+
])]
|
|
226
|
+
},
|
|
227
|
+
story_count: stories.length,
|
|
228
|
+
coverage,
|
|
229
|
+
open_questions: openQuestions,
|
|
230
|
+
stories,
|
|
231
|
+
story_candidates: storyCandidates
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function renderStoryCatalogMap(catalog) {
|
|
236
|
+
const stories = Array.isArray(catalog?.stories) ? catalog.stories : [];
|
|
237
|
+
const portfolio = renderStoryPortfolio(stories);
|
|
238
|
+
const storyCards = stories.map((story) => renderStoryCard(story)).join('\n\n');
|
|
239
|
+
|
|
240
|
+
return `# Story Map
|
|
241
|
+
|
|
242
|
+
## サマリー
|
|
243
|
+
|
|
244
|
+
${renderExecutiveSummary(catalog, stories)}
|
|
245
|
+
|
|
246
|
+
## まず確認すること
|
|
247
|
+
|
|
248
|
+
${renderReviewQueue(catalog, stories)}
|
|
249
|
+
|
|
250
|
+
## Story構造
|
|
251
|
+
|
|
252
|
+
${portfolio}
|
|
253
|
+
|
|
254
|
+
## Storyカード
|
|
255
|
+
|
|
256
|
+
${storyCards || '-'}
|
|
257
|
+
|
|
258
|
+
## Story候補(uncovered cluster)
|
|
259
|
+
|
|
260
|
+
${renderStoryCandidatesAppendix(catalog.story_candidates ?? [])}
|
|
261
|
+
|
|
262
|
+
## 付録: Graph Coverage
|
|
263
|
+
|
|
264
|
+
${renderCoverageAppendix(catalog.coverage)}
|
|
265
|
+
|
|
266
|
+
## 付録: 不明点
|
|
267
|
+
|
|
268
|
+
${renderOpenQuestionsAppendix(catalog.open_questions ?? [])}
|
|
269
|
+
`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderStoryCandidatesAppendix(candidates) {
|
|
273
|
+
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
274
|
+
return 'uncovered cluster は検出されませんでした。';
|
|
275
|
+
}
|
|
276
|
+
return candidates.map((candidate) => {
|
|
277
|
+
const evidence = (candidate.evidence ?? []).map((line) => ` - ${line}`).join('\n') || ' - -';
|
|
278
|
+
const questions = (candidate.open_questions ?? []).map((line) => ` - ${line}`).join('\n') || ' - -';
|
|
279
|
+
const title = candidate.suggested_story_titles?.[0] ?? candidate.candidate_id;
|
|
280
|
+
return `### ${title}
|
|
281
|
+
|
|
282
|
+
- 候補ID: \`${candidate.candidate_id}\`
|
|
283
|
+
- Role: ${candidate.role}
|
|
284
|
+
- 共通パス: ${candidate.common_path}
|
|
285
|
+
- ファイル数: ${candidate.file_count}
|
|
286
|
+
- 確度: ${candidate.confidence}
|
|
287
|
+
- 主な根拠:
|
|
288
|
+
${evidence}
|
|
289
|
+
- 未決事項:
|
|
290
|
+
${questions}`;
|
|
291
|
+
}).join('\n\n');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function deriveArchitectureStories(profile, evidence, defaults, documentSignals) {
|
|
295
|
+
const stories = [];
|
|
296
|
+
const views = profile?.views ?? {};
|
|
297
|
+
const findings = Array.isArray(evidence?.findings) ? evidence.findings : [];
|
|
298
|
+
const actionCandidates = Array.isArray(evidence?.action_candidates) ? evidence.action_candidates : [];
|
|
299
|
+
const architectureDocs = documentSignals.architecture ?? [];
|
|
300
|
+
const architecturePaths = docPaths(documentSignals, 'architecture');
|
|
301
|
+
|
|
302
|
+
if (profile?.has_api_routes || views.runtime?.entrypoints?.length > 0) {
|
|
303
|
+
stories.push(buildDerivedStory({
|
|
304
|
+
id: 'story-architecture-api-surface',
|
|
305
|
+
title: 'API公開面と実行境界を整理する',
|
|
306
|
+
category: 'architecture',
|
|
307
|
+
sourceType: 'architecture_profile',
|
|
308
|
+
paths: architecturePaths.slice(0, 5),
|
|
309
|
+
evidence: [
|
|
310
|
+
`${views.runtime?.entrypoints?.length ?? 0} entrypoints`,
|
|
311
|
+
...(views.runtime?.server_boundaries ?? []),
|
|
312
|
+
...architecturePaths.slice(0, 2)
|
|
313
|
+
],
|
|
314
|
+
storyDefinition: storyDefinitionFor('story-architecture-api-surface', selectDocs(documentSignals, 'architecture')),
|
|
315
|
+
relatedFindings: findings
|
|
316
|
+
.filter((finding) => String(finding.id).startsWith('VP-API-'))
|
|
317
|
+
.map((finding) => finding.id),
|
|
318
|
+
docs: selectDocs(documentSignals, 'architecture'),
|
|
319
|
+
defaults
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (profile?.has_database || views.data?.stores?.length > 0 || views.data?.access_patterns?.length > 0) {
|
|
324
|
+
stories.push(buildDerivedStory({
|
|
325
|
+
id: 'story-architecture-data-access',
|
|
326
|
+
title: 'データアクセスと永続化境界を整理する',
|
|
327
|
+
category: 'architecture',
|
|
328
|
+
sourceType: 'architecture_profile',
|
|
329
|
+
paths: architecturePaths.slice(0, 5),
|
|
330
|
+
evidence: [...(views.data?.stores ?? []), ...(views.data?.access_patterns ?? []), ...architecturePaths.slice(0, 2)],
|
|
331
|
+
storyDefinition: storyDefinitionFor('story-architecture-data-access', selectDocs(documentSignals, 'architecture')),
|
|
332
|
+
docs: selectDocs(documentSignals, 'architecture'),
|
|
333
|
+
defaults
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (profile?.has_auth || views.security?.auth_boundaries?.length > 0) {
|
|
338
|
+
stories.push(buildDerivedStory({
|
|
339
|
+
id: 'story-security-auth-boundary',
|
|
340
|
+
title: '認証とユーザー境界を固める',
|
|
341
|
+
category: 'security',
|
|
342
|
+
sourceType: 'architecture_profile',
|
|
343
|
+
evidence: profile.auth ?? views.security?.auth_mechanisms ?? [],
|
|
344
|
+
storyDefinition: storyDefinitionFor('story-security-auth-boundary'),
|
|
345
|
+
defaults
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (findings.some((finding) => ['VP-API-002', 'VP-API-003', 'VP-STATIC-002', 'VP-STATIC-003'].includes(finding.id))) {
|
|
350
|
+
stories.push(buildDerivedStory({
|
|
351
|
+
id: 'story-security-api-trust-boundary',
|
|
352
|
+
title: 'APIと外部連携の信頼境界を固める',
|
|
353
|
+
category: 'security',
|
|
354
|
+
sourceType: 'diagnosis',
|
|
355
|
+
evidence: actionCandidates.map((candidate) => candidate.title).filter(Boolean),
|
|
356
|
+
storyDefinition: storyDefinitionFor('story-security-api-trust-boundary'),
|
|
357
|
+
relatedFindings: findings
|
|
358
|
+
.filter((finding) => ['VP-API-002', 'VP-API-003', 'VP-STATIC-002', 'VP-STATIC-003'].includes(finding.id))
|
|
359
|
+
.map((finding) => finding.id),
|
|
360
|
+
diagnosisBased: true,
|
|
361
|
+
defaults
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (views.deployment?.targets?.length > 0 || profile?.deployment?.length > 0) {
|
|
366
|
+
stories.push(buildDerivedStory({
|
|
367
|
+
id: 'story-ops-deployment-runtime',
|
|
368
|
+
title: 'デプロイと実行基盤を運用可能にする',
|
|
369
|
+
category: 'ops',
|
|
370
|
+
sourceType: 'architecture_profile',
|
|
371
|
+
evidence: views.deployment?.targets ?? profile.deployment ?? [],
|
|
372
|
+
storyDefinition: storyDefinitionFor('story-ops-deployment-runtime'),
|
|
373
|
+
defaults
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (views.quality?.test_tools?.length > 0 || views.quality?.ci?.length > 0) {
|
|
378
|
+
stories.push(buildDerivedStory({
|
|
379
|
+
id: 'story-quality-test-ci-readiness',
|
|
380
|
+
title: 'テストとCIを開発ゲートとして整える',
|
|
381
|
+
category: 'quality',
|
|
382
|
+
sourceType: 'architecture_profile',
|
|
383
|
+
evidence: [...(views.quality?.test_tools ?? []), ...(views.quality?.ci ?? []).slice(0, 3)],
|
|
384
|
+
storyDefinition: storyDefinitionFor('story-quality-test-ci-readiness'),
|
|
385
|
+
defaults
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return stories;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function deriveProductSurfaceStories(fileSet, defaults, documentSignals, preset, context = {}) {
|
|
393
|
+
const signals = preset?.productSurfaceSignals ?? [];
|
|
394
|
+
if (signals.length === 0) return { stories: [], warnings: [] };
|
|
395
|
+
const files = [...fileSet];
|
|
396
|
+
const stories = [];
|
|
397
|
+
const suppressed = [];
|
|
398
|
+
const hasDocs = (key) => key && (documentSignals[key] ?? []).length > 0;
|
|
399
|
+
|
|
400
|
+
for (const signal of signals) {
|
|
401
|
+
const codePaths = signal.codePattern
|
|
402
|
+
? files.filter((file) => signal.codePattern.test(file)).sort().slice(0, 8)
|
|
403
|
+
: [];
|
|
404
|
+
const codeMatch = codePaths.length > 0;
|
|
405
|
+
const docMatch = hasDocs(signal.docKey);
|
|
406
|
+
if (!codeMatch && !docMatch) continue;
|
|
407
|
+
const applicability = evaluateProductSurfaceApplicability({ signal, codePaths, docMatch, preset, context });
|
|
408
|
+
if (!applicability.allowed) {
|
|
409
|
+
suppressed.push({
|
|
410
|
+
story_id: signal.id,
|
|
411
|
+
reason: applicability.reason,
|
|
412
|
+
evidence_paths: codePaths,
|
|
413
|
+
required_profile: applicability.required_profile
|
|
414
|
+
});
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const docs = signal.docKey ? selectDocs(documentSignals, signal.docKey) : [];
|
|
418
|
+
const effectiveCodePaths = isProductSurfaceCodeEvidenceApplicable(context) ? codePaths : [];
|
|
419
|
+
const paths = uniqueList([...(signal.docKey ? docPaths(documentSignals, signal.docKey) : []), ...effectiveCodePaths]);
|
|
420
|
+
if (paths.length === 0) continue;
|
|
421
|
+
stories.push(buildDerivedStory({
|
|
422
|
+
id: signal.id,
|
|
423
|
+
title: signal.title,
|
|
424
|
+
category: signal.category ?? 'product',
|
|
425
|
+
sourceType: signal.sourceType ?? 'story_cluster',
|
|
426
|
+
paths,
|
|
427
|
+
evidence: [...(signal.evidenceTokens ?? []), ...paths.slice(0, 3)],
|
|
428
|
+
storyDefinition: storyDefinitionFor(signal.id, docs, preset),
|
|
429
|
+
docs,
|
|
430
|
+
defaults
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
stories,
|
|
436
|
+
warnings: suppressed.length === 0 ? [] : [{
|
|
437
|
+
severity: 'warning',
|
|
438
|
+
code: 'needs_domain_confirmation',
|
|
439
|
+
message: 'story derive suppressed template product stories because repo profile does not provide matching Web/SaaS evidence. Use --preset or story_catalog.preset to opt in explicitly.',
|
|
440
|
+
repo_profile: context.repoProfile?.id ?? 'unknown',
|
|
441
|
+
preset: preset?.id ?? null,
|
|
442
|
+
suppressed_story_ids: suppressed.map((item) => item.story_id),
|
|
443
|
+
suppressed
|
|
444
|
+
}]
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function evaluateProductSurfaceApplicability({ signal, codePaths, docMatch, preset, context }) {
|
|
449
|
+
if (context.presetExplicit) return { allowed: true, reason: 'explicit_preset' };
|
|
450
|
+
if (docMatch) return { allowed: true, reason: 'document_signal' };
|
|
451
|
+
if (preset?.id !== 'next-app') return { allowed: true, reason: 'non_next_app_preset' };
|
|
452
|
+
const repoProfile = context.repoProfile;
|
|
453
|
+
if (repoProfile?.product_surface_applicable === true) return { allowed: true, reason: 'repo_profile' };
|
|
454
|
+
return {
|
|
455
|
+
allowed: false,
|
|
456
|
+
reason: 'repo_profile_not_web_product',
|
|
457
|
+
required_profile: ['next-app', 'web'],
|
|
458
|
+
evidence_paths: codePaths
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function isProductSurfaceCodeEvidenceApplicable(context = {}) {
|
|
463
|
+
if (context.presetExplicit) return true;
|
|
464
|
+
return context.repoProfile?.product_surface_applicable === true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function deriveCodeSurfaceStories(fileSet, defaults, documentSignals, preset, context = {}) {
|
|
468
|
+
const files = [...fileSet];
|
|
469
|
+
const signatures = preset.codeSurfaceSignatures ?? CODE_SURFACE_SIGNATURES;
|
|
470
|
+
const suppressed = [];
|
|
471
|
+
const stories = signatures
|
|
472
|
+
.map((signature) => {
|
|
473
|
+
const codePaths = files
|
|
474
|
+
.filter((file) => signature.patterns.some((pattern) => pattern.test(file)))
|
|
475
|
+
.sort((a, b) => rankStoryCodePath(signature.id, a) - rankStoryCodePath(signature.id, b) || a.localeCompare(b))
|
|
476
|
+
.slice(0, 8);
|
|
477
|
+
const docs = selectDocsForStory(documentSignals, signature.id);
|
|
478
|
+
const codePathsAllowed = isCodeSurfaceStoryApplicable(signature, context);
|
|
479
|
+
const effectiveCodePaths = codePathsAllowed ? codePaths : [];
|
|
480
|
+
if (codePaths.length === 0 && docs.length === 0) return null;
|
|
481
|
+
if (!codePathsAllowed && codePaths.length > 0 && docs.length === 0) {
|
|
482
|
+
suppressed.push({
|
|
483
|
+
story_id: signature.id,
|
|
484
|
+
reason: 'repo_profile_not_web_product',
|
|
485
|
+
evidence_paths: codePaths,
|
|
486
|
+
required_profile: ['next-app', 'web']
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
if (effectiveCodePaths.length === 0 && docs.length === 0) return null;
|
|
490
|
+
const paths = uniqueList([...docs.map((doc) => doc.path), ...effectiveCodePaths]);
|
|
491
|
+
return buildDerivedStory({
|
|
492
|
+
id: signature.id,
|
|
493
|
+
title: signature.title,
|
|
494
|
+
category: signature.category,
|
|
495
|
+
sourceType: 'code_surface',
|
|
496
|
+
paths,
|
|
497
|
+
evidence: paths,
|
|
498
|
+
storyDefinition: codeStoryDefinitionFor(signature.id, effectiveCodePaths, docs, preset),
|
|
499
|
+
docs,
|
|
500
|
+
codeDerived: true,
|
|
501
|
+
defaults
|
|
502
|
+
});
|
|
503
|
+
})
|
|
504
|
+
.filter(Boolean);
|
|
505
|
+
return {
|
|
506
|
+
stories,
|
|
507
|
+
warnings: suppressed.length === 0 ? [] : [buildDomainConfirmationWarning({ suppressed, preset, repoProfile: context.repoProfile })]
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function isCodeSurfaceStoryApplicable(signature, context = {}) {
|
|
512
|
+
if (signature.category !== 'product') return true;
|
|
513
|
+
if (context.presetExplicit) return true;
|
|
514
|
+
return context.repoProfile?.product_surface_applicable === true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function buildDomainConfirmationWarning({ suppressed, preset, repoProfile }) {
|
|
518
|
+
return {
|
|
519
|
+
severity: 'warning',
|
|
520
|
+
code: 'needs_domain_confirmation',
|
|
521
|
+
message: 'story derive suppressed template product stories because repo profile does not provide matching Web/SaaS evidence. Use --preset or story_catalog.preset to opt in explicitly.',
|
|
522
|
+
repo_profile: repoProfile?.id ?? 'unknown',
|
|
523
|
+
preset: preset?.id ?? null,
|
|
524
|
+
suppressed_story_ids: uniqueList(suppressed.map((item) => item.story_id)),
|
|
525
|
+
suppressed
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function mergeDomainConfirmationWarnings(warnings) {
|
|
530
|
+
const domainWarnings = warnings.filter((warning) => warning.code === 'needs_domain_confirmation');
|
|
531
|
+
const otherWarnings = warnings.filter((warning) => warning.code !== 'needs_domain_confirmation');
|
|
532
|
+
if (domainWarnings.length <= 1) return warnings;
|
|
533
|
+
const suppressedByStory = new Map();
|
|
534
|
+
for (const warning of domainWarnings) {
|
|
535
|
+
for (const item of warning.suppressed ?? []) {
|
|
536
|
+
if (!suppressedByStory.has(item.story_id)) suppressedByStory.set(item.story_id, item);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return [
|
|
540
|
+
...otherWarnings,
|
|
541
|
+
buildDomainConfirmationWarning({
|
|
542
|
+
suppressed: [...suppressedByStory.values()],
|
|
543
|
+
preset: { id: domainWarnings[0].preset },
|
|
544
|
+
repoProfile: { id: domainWarnings[0].repo_profile }
|
|
545
|
+
})
|
|
546
|
+
];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function deriveDocumentationStories(fileSet, documentSignals, defaults) {
|
|
550
|
+
const hasDocs = [...fileSet].some((file) => file.startsWith('docs/'));
|
|
551
|
+
if (!hasDocs) return [];
|
|
552
|
+
return [buildDerivedStory({
|
|
553
|
+
id: 'story-docs-story-ssot-recovery',
|
|
554
|
+
title: '要求とStoryの正本を復元する',
|
|
555
|
+
category: 'docs',
|
|
556
|
+
sourceType: 'repo_surface',
|
|
557
|
+
paths: [
|
|
558
|
+
...docPaths(documentSignals, 'requirements').slice(0, 3),
|
|
559
|
+
...docPaths(documentSignals, 'userStories').slice(0, 3),
|
|
560
|
+
...docPaths(documentSignals, 'features').slice(0, 3)
|
|
561
|
+
],
|
|
562
|
+
evidence: [
|
|
563
|
+
`${documentSignals.requirements?.length ?? 0} requirement docs`,
|
|
564
|
+
`${documentSignals.userStories?.length ?? 0} user story docs`,
|
|
565
|
+
`${documentSignals.features?.length ?? 0} feature docs`
|
|
566
|
+
],
|
|
567
|
+
storyDefinition: storyDefinitionFor('story-docs-story-ssot-recovery', [
|
|
568
|
+
...selectDocs(documentSignals, 'requirements'),
|
|
569
|
+
...selectDocs(documentSignals, 'userStories'),
|
|
570
|
+
...selectDocs(documentSignals, 'features')
|
|
571
|
+
], defaults.preset),
|
|
572
|
+
docs: [
|
|
573
|
+
...selectDocs(documentSignals, 'requirements'),
|
|
574
|
+
...selectDocs(documentSignals, 'userStories'),
|
|
575
|
+
...selectDocs(documentSignals, 'features')
|
|
576
|
+
],
|
|
577
|
+
defaults
|
|
578
|
+
})];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function deriveConfiguredStories(config, documentSignals, defaults) {
|
|
582
|
+
const stories = Array.isArray(config?.brainbase?.stories) ? config.brainbase.stories : [];
|
|
583
|
+
return stories
|
|
584
|
+
.filter((story) => story?.story_id && !['archived', 'アーカイブ'].includes(story.status))
|
|
585
|
+
.filter((story) => !isLikelyObsoleteConfiguredStory(story))
|
|
586
|
+
.map((story) => buildConfiguredStory(story, documentSignals, defaults));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function buildConfiguredStory(story, documentSignals, defaults) {
|
|
590
|
+
const docs = documentSignals?._byStoryId?.[story.story_id] ?? [];
|
|
591
|
+
const category = story.category ?? inferConfiguredStoryCategory(story);
|
|
592
|
+
const derivedStory = buildDerivedStory({
|
|
593
|
+
id: story.story_id,
|
|
594
|
+
title: story.title ?? story.story_id,
|
|
595
|
+
category,
|
|
596
|
+
sourceType: 'config_story',
|
|
597
|
+
paths: docs.map((doc) => doc.path),
|
|
598
|
+
evidence: docs.map((doc) => doc.path),
|
|
599
|
+
docs,
|
|
600
|
+
storyDefinition: docs.find((doc) => doc.story_definition)?.story_definition ?? null,
|
|
601
|
+
defaults
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
derivedStory.ssot = story.ssot ?? derivedStory.ssot;
|
|
605
|
+
derivedStory.status = story.status ?? derivedStory.status;
|
|
606
|
+
derivedStory.horizon = story.horizon ?? derivedStory.horizon;
|
|
607
|
+
derivedStory.view = story.view ?? derivedStory.view;
|
|
608
|
+
derivedStory.period = story.period ?? derivedStory.period;
|
|
609
|
+
derivedStory.started_at = story.started_at ?? derivedStory.started_at;
|
|
610
|
+
derivedStory.due_at = story.due_at ?? derivedStory.due_at;
|
|
611
|
+
if (story.period) {
|
|
612
|
+
derivedStory.derived.open_questions = (derivedStory.derived.open_questions ?? [])
|
|
613
|
+
.filter((item) => item.field !== 'period');
|
|
614
|
+
const meaning = derivedStory.derived.meaning ?? {};
|
|
615
|
+
meaning.evidence_by_type = {
|
|
616
|
+
...(meaning.evidence_by_type ?? {}),
|
|
617
|
+
missing_evidence: (meaning.evidence_by_type?.missing_evidence ?? [])
|
|
618
|
+
.filter((item) => item.field !== 'period')
|
|
619
|
+
};
|
|
620
|
+
meaning.counter_evidence = (meaning.counter_evidence ?? [])
|
|
621
|
+
.filter((item) => !/period|実行期|計画期間/i.test(item));
|
|
622
|
+
}
|
|
623
|
+
derivedStory.derived.config_story = {
|
|
624
|
+
story_id: story.story_id,
|
|
625
|
+
ssot: story.ssot ?? null,
|
|
626
|
+
status: story.status ?? null
|
|
627
|
+
};
|
|
628
|
+
return derivedStory;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function inferConfiguredStoryCategory(story) {
|
|
632
|
+
if (story.view === 'business') return 'product';
|
|
633
|
+
if (story.view === 'dev') return 'ops';
|
|
634
|
+
if (/arch|architecture|設計|adr/i.test(story.story_id) || /設計|アーキテクチャ|ADR/i.test(story.title ?? '')) return 'architecture';
|
|
635
|
+
return 'product';
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function isLikelyObsoleteConfiguredStory(story) {
|
|
639
|
+
if (!/^story-(product|architecture)-/.test(story.story_id)) return false;
|
|
640
|
+
return /(仕様|要件|REQ-\d+|US-\d+|アーキテクチャ|設計|ガイド|ロードマップ|システムドキュメント|現在の実装|セットアップチェックリスト|インターフェース|テクノロジースタック|シーケンス図|sequence diagram|関係図|バージョン情報|フロー|構造)/i.test(story.title ?? '');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function buildDerivedStory({
|
|
644
|
+
id,
|
|
645
|
+
title,
|
|
646
|
+
category,
|
|
647
|
+
sourceType,
|
|
648
|
+
paths = [],
|
|
649
|
+
evidence = [],
|
|
650
|
+
relatedFindings = [],
|
|
651
|
+
docs = [],
|
|
652
|
+
storyDefinition = null,
|
|
653
|
+
diagnosisBased = false,
|
|
654
|
+
codeDerived = false,
|
|
655
|
+
defaults
|
|
656
|
+
}) {
|
|
657
|
+
const preset = defaults.preset;
|
|
658
|
+
const planning = inferPlanning({ category, docs, defaults, diagnosisBased, codeDerived });
|
|
659
|
+
const normalizedDefinition = normalizeStoryDefinition(storyDefinition, docs);
|
|
660
|
+
const businessContext = summarizeBusinessContext(docs, codeDerived);
|
|
661
|
+
const storyContract = buildStoryContract({
|
|
662
|
+
id,
|
|
663
|
+
title,
|
|
664
|
+
category,
|
|
665
|
+
sourceType,
|
|
666
|
+
paths,
|
|
667
|
+
evidence,
|
|
668
|
+
docs,
|
|
669
|
+
definition: normalizedDefinition,
|
|
670
|
+
businessContext,
|
|
671
|
+
planning,
|
|
672
|
+
diagnosisBased,
|
|
673
|
+
codeDerived,
|
|
674
|
+
defaults
|
|
675
|
+
});
|
|
676
|
+
const openQuestions = [
|
|
677
|
+
...planning.open_questions,
|
|
678
|
+
...storyContract.open_questions
|
|
679
|
+
];
|
|
680
|
+
const meaning = buildStoryMeaning({
|
|
681
|
+
id,
|
|
682
|
+
category,
|
|
683
|
+
sourceType,
|
|
684
|
+
paths,
|
|
685
|
+
evidence,
|
|
686
|
+
docs,
|
|
687
|
+
relatedFindings,
|
|
688
|
+
definition: normalizedDefinition,
|
|
689
|
+
planning,
|
|
690
|
+
businessContext,
|
|
691
|
+
openQuestions,
|
|
692
|
+
diagnosisBased,
|
|
693
|
+
codeDerived,
|
|
694
|
+
preset
|
|
695
|
+
});
|
|
696
|
+
return {
|
|
697
|
+
story_id: id,
|
|
698
|
+
title,
|
|
699
|
+
ssot: 'local',
|
|
700
|
+
status: 'active',
|
|
701
|
+
horizon: planning.horizon,
|
|
702
|
+
view: planning.view,
|
|
703
|
+
period: planning.period,
|
|
704
|
+
started_at: planning.started_at,
|
|
705
|
+
due_at: planning.due_at,
|
|
706
|
+
category,
|
|
707
|
+
source: {
|
|
708
|
+
type: sourceType,
|
|
709
|
+
paths
|
|
710
|
+
},
|
|
711
|
+
derived: {
|
|
712
|
+
evidence: evidence.filter(Boolean),
|
|
713
|
+
related_findings: relatedFindings,
|
|
714
|
+
confidence: paths.length > 0 || evidence.length > 0 ? 'medium' : 'low',
|
|
715
|
+
story_definition: normalizedDefinition,
|
|
716
|
+
story_contract: storyContract,
|
|
717
|
+
meaning,
|
|
718
|
+
predictions: planning.predictions,
|
|
719
|
+
business_context: businessContext,
|
|
720
|
+
open_questions: openQuestions
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function buildStoryContract({
|
|
726
|
+
id,
|
|
727
|
+
title,
|
|
728
|
+
category,
|
|
729
|
+
sourceType,
|
|
730
|
+
paths = [],
|
|
731
|
+
evidence = [],
|
|
732
|
+
docs = [],
|
|
733
|
+
definition,
|
|
734
|
+
businessContext,
|
|
735
|
+
planning,
|
|
736
|
+
diagnosisBased,
|
|
737
|
+
codeDerived,
|
|
738
|
+
defaults
|
|
739
|
+
}) {
|
|
740
|
+
const preset = defaults.preset;
|
|
741
|
+
const storyType = inferStoryContractType({ id, title, category, sourceType, diagnosisBased, codeDerived });
|
|
742
|
+
const codePaths = paths.filter((item) => isCodePath(item, preset));
|
|
743
|
+
const docPathsList = docs.map((doc) => doc.path);
|
|
744
|
+
const sourceRole = evaluateStorySourceRoleIntegrity({
|
|
745
|
+
id,
|
|
746
|
+
category,
|
|
747
|
+
sourceType,
|
|
748
|
+
paths,
|
|
749
|
+
docs,
|
|
750
|
+
codePaths,
|
|
751
|
+
defaults
|
|
752
|
+
});
|
|
753
|
+
const intentStatus = hasStoryIntent(definition) ? 'passed' : 'needs_clarification';
|
|
754
|
+
const boundaryStatus = codePaths.length > 0 || docPathsList.length > 0
|
|
755
|
+
? 'passed'
|
|
756
|
+
: evidence.length > 0 || sourceType === 'architecture_profile' || sourceType === 'config_story' || diagnosisBased
|
|
757
|
+
? 'inferred'
|
|
758
|
+
: 'needs_clarification';
|
|
759
|
+
const acceptanceStatus = (definition.acceptance_focus ?? []).length > 0 ? 'passed' : 'needs_clarification';
|
|
760
|
+
const verification = inferStoryContractVerification({ storyType, category, definition, codePaths, docs });
|
|
761
|
+
const checks = [
|
|
762
|
+
buildStoryContractCheck('story_type_fit', storyType === 'story_contract_review' ? 'inferred' : 'passed', `Story typeを ${storyType} と推定した。`, { story_type: storyType }),
|
|
763
|
+
buildStoryContractCheck('source_role_integrity', sourceRole.status, sourceRole.reason, sourceRole.evidence),
|
|
764
|
+
buildStoryContractCheck('business_intent', intentStatus, intentStatus === 'passed'
|
|
765
|
+
? 'who/problem/outcome が実装判断の枠組みとして利用できる。'
|
|
766
|
+
: 'who/problem/outcome が十分に分離されていない。', summarizeStoryIntentEvidence(definition)),
|
|
767
|
+
buildStoryContractCheck('developer_boundary', boundaryStatus, boundaryStatus === 'needs_clarification'
|
|
768
|
+
? 'コード、Spec、Architecture、診断、文書のいずれからも開発境界を置けない。'
|
|
769
|
+
: inferDeveloperBoundaryReason({ sourceType, codePaths, docs, evidence }), {
|
|
770
|
+
code_paths: codePaths.slice(0, 8),
|
|
771
|
+
docs: docPathsList.slice(0, 8),
|
|
772
|
+
inferred_evidence: evidence.filter((item) => typeof item === 'string').slice(0, 8)
|
|
773
|
+
}),
|
|
774
|
+
buildStoryContractCheck('acceptance_examples', acceptanceStatus, acceptanceStatus === 'passed'
|
|
775
|
+
? '受け入れ観点が利用できる。'
|
|
776
|
+
: '受け入れ例が不足している。', {
|
|
777
|
+
acceptance_focus: (definition.acceptance_focus ?? []).slice(0, 8)
|
|
778
|
+
}),
|
|
779
|
+
buildStoryContractCheck('verification_strategy', verification.status, verification.reason, {
|
|
780
|
+
approach: verification.approach,
|
|
781
|
+
required_evidence: verification.required_evidence
|
|
782
|
+
})
|
|
783
|
+
];
|
|
784
|
+
const openQuestions = checks
|
|
785
|
+
.filter((check) => check.status === 'needs_clarification')
|
|
786
|
+
.map((check) => storyContractQuestionForCheck(check, { id, title, category, storyType, repoProfile: defaults.repoProfile }));
|
|
787
|
+
const status = checks.some((check) => check.status === 'needs_clarification') ? 'needs_clarification' : 'ready';
|
|
788
|
+
return {
|
|
789
|
+
schema_version: '0.1.0',
|
|
790
|
+
story_type: storyType,
|
|
791
|
+
status,
|
|
792
|
+
checks,
|
|
793
|
+
open_questions: dedupeStoryContractQuestions(openQuestions),
|
|
794
|
+
developer_boundary_hypothesis: inferDeveloperBoundaryHypothesis({ codePaths, docs, evidence, sourceType }),
|
|
795
|
+
risk_surface_hypothesis: inferStoryContractRiskSurface({
|
|
796
|
+
category,
|
|
797
|
+
storyType,
|
|
798
|
+
businessContext,
|
|
799
|
+
planning,
|
|
800
|
+
sourceRole,
|
|
801
|
+
codePaths
|
|
802
|
+
}),
|
|
803
|
+
verification_strategy: {
|
|
804
|
+
status: verification.status,
|
|
805
|
+
approach: verification.approach,
|
|
806
|
+
required_evidence: verification.required_evidence
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function buildStoryContractCheck(id, status, reason, evidence = {}) {
|
|
812
|
+
return {
|
|
813
|
+
id,
|
|
814
|
+
status,
|
|
815
|
+
reason,
|
|
816
|
+
evidence
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function inferStoryContractType({ id, title, category, sourceType, diagnosisBased, codeDerived }) {
|
|
821
|
+
const text = [id, title, category, sourceType].join(' ').toLowerCase();
|
|
822
|
+
if (/regression|回帰|再発/.test(text)) return 'regression_fix';
|
|
823
|
+
if (/bug|fix|failure|error|不具合|障害|失敗|修正/.test(text) || diagnosisBased) return 'bug_fix';
|
|
824
|
+
if (/refactor|cleanup|整理|リファクタ/.test(text)) return 'refactor';
|
|
825
|
+
if (category === 'architecture') return 'architecture_decision';
|
|
826
|
+
if (category === 'docs') return 'docs_policy_change';
|
|
827
|
+
if (category === 'security' || category === 'quality') return 'quality_hardening';
|
|
828
|
+
if (category === 'ops') return 'operational_change';
|
|
829
|
+
if (category === 'product' && codeDerived) return 'enhancement';
|
|
830
|
+
if (category === 'product') return 'new_capability';
|
|
831
|
+
return 'story_contract_review';
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function evaluateStorySourceRoleIntegrity({ id, category, sourceType, paths, docs, codePaths, defaults }) {
|
|
835
|
+
const repoProfile = defaults.repoProfile;
|
|
836
|
+
const docOnly = docs.length > 0 && codePaths.length === 0;
|
|
837
|
+
const productTemplate = category === 'product' && id.startsWith('story-product-');
|
|
838
|
+
const productSurfaceApplicable = repoProfile?.product_surface_applicable === true;
|
|
839
|
+
const presetExplicit = defaults.presetExplicit === true;
|
|
840
|
+
const explicitProductEvidence = hasExplicitProductStoryEvidence(id, docs);
|
|
841
|
+
if (productTemplate && docOnly && !productSurfaceApplicable && !presetExplicit && !explicitProductEvidence) {
|
|
842
|
+
return {
|
|
843
|
+
status: 'needs_clarification',
|
|
844
|
+
reason: 'product surfaceではないrepoのdocument-only根拠は、ユーザー向けproduct storyではなく内部ツール仕様を指している可能性がある。',
|
|
845
|
+
evidence: {
|
|
846
|
+
repo_profile: repoProfile?.id ?? 'unknown',
|
|
847
|
+
product_surface_applicable: productSurfaceApplicable,
|
|
848
|
+
preset_explicit: presetExplicit,
|
|
849
|
+
source_type: sourceType,
|
|
850
|
+
paths: paths.slice(0, 8),
|
|
851
|
+
doc_story_ids: uniqueList(docs.map((doc) => doc.story_id)).slice(0, 8)
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
if (productTemplate && docOnly && !productSurfaceApplicable) {
|
|
856
|
+
return {
|
|
857
|
+
status: 'inferred',
|
|
858
|
+
reason: 'product surfaceではないrepoだが、文書の役割が明示されているためStory仮説として保持する。',
|
|
859
|
+
evidence: {
|
|
860
|
+
repo_profile: repoProfile?.id ?? 'unknown',
|
|
861
|
+
product_surface_applicable: productSurfaceApplicable,
|
|
862
|
+
preset_explicit: presetExplicit,
|
|
863
|
+
explicit_product_evidence: explicitProductEvidence,
|
|
864
|
+
paths: paths.slice(0, 8)
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
return {
|
|
869
|
+
status: 'passed',
|
|
870
|
+
reason: 'repo profile、明示preset、またはコード根拠とsource roleが整合している。',
|
|
871
|
+
evidence: {
|
|
872
|
+
repo_profile: repoProfile?.id ?? 'unknown',
|
|
873
|
+
product_surface_applicable: productSurfaceApplicable,
|
|
874
|
+
preset_explicit: presetExplicit,
|
|
875
|
+
code_paths: codePaths.slice(0, 8),
|
|
876
|
+
docs: docs.map((doc) => doc.path).slice(0, 8)
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function hasExplicitProductStoryEvidence(storyId, docs) {
|
|
882
|
+
return docs.some((doc) => {
|
|
883
|
+
if (doc.story_id === storyId) return true;
|
|
884
|
+
if (typeof doc.story_id === 'string' && doc.story_id.startsWith('story-product-')) return true;
|
|
885
|
+
if (doc.path.startsWith('docs/user_stories/')) return true;
|
|
886
|
+
if (doc.path.startsWith('docs/features/')) return true;
|
|
887
|
+
if (doc.path.startsWith('docs/requirements/')) return true;
|
|
888
|
+
return false;
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function hasStoryIntent(definition) {
|
|
893
|
+
return [definition.who, definition.problem, definition.outcome]
|
|
894
|
+
.filter((value) => typeof value === 'string' && value.trim().length > 0)
|
|
895
|
+
.length >= 2;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function summarizeStoryIntentEvidence(definition) {
|
|
899
|
+
return {
|
|
900
|
+
has_who: Boolean(definition.who),
|
|
901
|
+
has_problem: Boolean(definition.problem),
|
|
902
|
+
has_want: Boolean(definition.want),
|
|
903
|
+
has_outcome: Boolean(definition.outcome),
|
|
904
|
+
has_business_value: Boolean(definition.business_value)
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function inferDeveloperBoundaryReason({ sourceType, codePaths, docs, evidence }) {
|
|
909
|
+
if (codePaths.length > 0) return 'コードパスから実装境界を置ける。';
|
|
910
|
+
if (docs.some((doc) => doc.path.startsWith('docs/specs/'))) return 'Spec文書から実装境界を置ける。';
|
|
911
|
+
if (docs.some((doc) => doc.path.startsWith('docs/architecture/'))) return 'Architecture文書から実装境界を置ける。';
|
|
912
|
+
if (docs.length > 0) return '関連文書から暫定的な境界を置ける。';
|
|
913
|
+
if (evidence.length > 0) return 'Architecture profileまたは診断根拠から暫定的な境界を置ける。';
|
|
914
|
+
return `${sourceType} から暫定的な境界を置く。`;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function inferDeveloperBoundaryHypothesis({ codePaths, docs, evidence, sourceType }) {
|
|
918
|
+
if (codePaths.length > 0) {
|
|
919
|
+
return {
|
|
920
|
+
status: 'code_backed',
|
|
921
|
+
summary: `実装境界は ${codePaths.slice(0, 3).join(', ')} から開始する。`,
|
|
922
|
+
evidence_paths: codePaths.slice(0, 8)
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
const specOrArchitecture = docs
|
|
926
|
+
.filter((doc) => doc.path.startsWith('docs/specs/') || doc.path.startsWith('docs/architecture/'))
|
|
927
|
+
.map((doc) => doc.path);
|
|
928
|
+
if (specOrArchitecture.length > 0) {
|
|
929
|
+
return {
|
|
930
|
+
status: 'document_backed',
|
|
931
|
+
summary: `境界は ${specOrArchitecture.slice(0, 3).join(', ')} から推定する。`,
|
|
932
|
+
evidence_paths: specOrArchitecture.slice(0, 8)
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
if (docs.length > 0) {
|
|
936
|
+
return {
|
|
937
|
+
status: 'story_or_feature_doc_backed',
|
|
938
|
+
summary: `境界は ${docs.slice(0, 3).map((doc) => doc.path).join(', ')} から暫定推定する。`,
|
|
939
|
+
evidence_paths: docs.map((doc) => doc.path).slice(0, 8)
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
return {
|
|
943
|
+
status: evidence.length > 0 ? 'inferred' : 'unknown',
|
|
944
|
+
summary: evidence.length > 0
|
|
945
|
+
? `${sourceType} の根拠から境界を推定する。`
|
|
946
|
+
: '開発境界はまだ置けない。',
|
|
947
|
+
evidence_paths: evidence.filter((item) => typeof item === 'string').slice(0, 8)
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function inferStoryContractRiskSurface({ category, storyType, businessContext, planning, sourceRole, codePaths }) {
|
|
952
|
+
if (sourceRole.status === 'needs_clarification') {
|
|
953
|
+
return {
|
|
954
|
+
level: 'high',
|
|
955
|
+
summary: 'source roleの不一致により、内部ツール文書を誤ったproduct実装タスクへ変換する可能性がある。',
|
|
956
|
+
drivers: ['source_role_integrity']
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
if (category === 'security') {
|
|
960
|
+
return {
|
|
961
|
+
level: 'high',
|
|
962
|
+
summary: 'Security境界Storyは前提が誤ると本番露出リスクにつながる。',
|
|
963
|
+
drivers: ['security_boundary']
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
if (storyType === 'bug_fix' || storyType === 'regression_fix') {
|
|
967
|
+
return {
|
|
968
|
+
level: 'medium',
|
|
969
|
+
summary: '修正Storyには明確な失敗モードと回帰証跡が必要である。',
|
|
970
|
+
drivers: [storyType]
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
const businessGap = (planning.open_questions ?? []).some((item) => item.field === 'business_metric' || item.field === 'business_context');
|
|
974
|
+
if (category === 'product' && businessGap) {
|
|
975
|
+
return {
|
|
976
|
+
level: 'medium',
|
|
977
|
+
summary: 'product価値はあり得るが、成功指標またはビジネス文脈が明示されていない。',
|
|
978
|
+
drivers: ['business_context', 'business_metric'].filter((field) => (planning.open_questions ?? []).some((item) => item.field === field))
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
if (codePaths.length > 5) {
|
|
982
|
+
return {
|
|
983
|
+
level: 'medium',
|
|
984
|
+
summary: '実装境界が複数ファイルに広がっており、分割可能性の確認が必要である。',
|
|
985
|
+
drivers: ['code_scope']
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
return {
|
|
989
|
+
level: businessContext.signals?.length > 0 ? 'low' : 'medium',
|
|
990
|
+
summary: businessContext.signals?.length > 0
|
|
991
|
+
? 'business signalとsource roleは計画に使える程度に整合している。'
|
|
992
|
+
: 'business signalが薄いため、明示的な仮説として扱う。',
|
|
993
|
+
drivers: businessContext.signals ?? []
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function inferStoryContractVerification({ storyType, category, definition, codePaths, docs }) {
|
|
998
|
+
const acceptance = definition.acceptance_focus ?? [];
|
|
999
|
+
if (acceptance.length === 0) {
|
|
1000
|
+
return {
|
|
1001
|
+
status: 'needs_clarification',
|
|
1002
|
+
reason: '受け入れ例がないため検証方法を選べない。',
|
|
1003
|
+
approach: '実装前に受け入れ例を定義する。',
|
|
1004
|
+
required_evidence: ['acceptance_examples']
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
if (storyType === 'docs_policy_change') {
|
|
1008
|
+
return {
|
|
1009
|
+
status: 'inferred',
|
|
1010
|
+
reason: 'Documentation Storyはsource linkとreview evidenceで検証できる。',
|
|
1011
|
+
approach: 'Story/Spec/Architectureリンクを確認し、story map/planを再生成する。',
|
|
1012
|
+
required_evidence: ['story-map.md', 'story-plan.json']
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
if (storyType === 'architecture_decision') {
|
|
1016
|
+
return {
|
|
1017
|
+
status: 'inferred',
|
|
1018
|
+
reason: 'Architecture StoryはUI実行よりもgraph/context reviewが重要である。',
|
|
1019
|
+
approach: 'graph/story planを実行し、影響境界をADRまたはArchitecture文書と照合する。',
|
|
1020
|
+
required_evidence: ['graphify', 'architecture_doc_or_adr']
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
if (storyType === 'bug_fix' || storyType === 'regression_fix') {
|
|
1024
|
+
return {
|
|
1025
|
+
status: 'inferred',
|
|
1026
|
+
reason: '修正Storyには回帰観点の検証経路が必要である。',
|
|
1027
|
+
approach: '最小の回帰テストまたは再現確認と、影響ファイルの重点inspectionを行う。',
|
|
1028
|
+
required_evidence: ['regression_test_or_manual_repro', ...codePaths.slice(0, 3)]
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
const hasSpec = docs.some((doc) => doc.path.startsWith('docs/specs/'));
|
|
1032
|
+
return {
|
|
1033
|
+
status: 'inferred',
|
|
1034
|
+
reason: hasSpec
|
|
1035
|
+
? 'Specに紐づく受け入れ観点から検証を組み立てられる。'
|
|
1036
|
+
: '受け入れ観点はあるため、task planning時に検証方法を選べる。',
|
|
1037
|
+
approach: category === 'product'
|
|
1038
|
+
? 'PR前に受け入れ観点をunit/integration/E2Eまたは明示的な手動証跡へ対応づける。'
|
|
1039
|
+
: 'PR前に受け入れ観点をCLI/test/inspection証跡へ対応づける。',
|
|
1040
|
+
required_evidence: hasSpec ? ['spec_acceptance_trace'] : ['acceptance_trace']
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function storyContractQuestionForCheck(check, context) {
|
|
1045
|
+
const field = {
|
|
1046
|
+
story_type_fit: 'story_contract_story_type',
|
|
1047
|
+
source_role_integrity: 'story_contract_source_role',
|
|
1048
|
+
business_intent: 'story_contract_business_intent',
|
|
1049
|
+
developer_boundary: 'story_contract_developer_boundary',
|
|
1050
|
+
acceptance_examples: 'story_contract_acceptance_examples',
|
|
1051
|
+
verification_strategy: 'story_contract_verification_strategy'
|
|
1052
|
+
}[check.id] ?? `story_contract_${check.id}`;
|
|
1053
|
+
const question = check.id === 'source_role_integrity'
|
|
1054
|
+
? `このStoryの根拠は本当に ${context.storyType} として実装すべき要求か。repo profile:${context.repoProfile?.id ?? 'unknown'} で、内部ツール文書の語彙一致ではないことを確認する。`
|
|
1055
|
+
: `${context.title} のStory Contract check '${check.id}' が未解決: ${check.reason}`;
|
|
1056
|
+
return {
|
|
1057
|
+
field,
|
|
1058
|
+
question
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function dedupeStoryContractQuestions(questions) {
|
|
1063
|
+
const seen = new Set();
|
|
1064
|
+
const result = [];
|
|
1065
|
+
for (const question of questions) {
|
|
1066
|
+
const key = `${question.field}:${question.question}`;
|
|
1067
|
+
if (seen.has(key)) continue;
|
|
1068
|
+
seen.add(key);
|
|
1069
|
+
result.push(question);
|
|
1070
|
+
}
|
|
1071
|
+
return result;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function codeStoryDefinitionFor(storyId, paths, docs = [], preset = null) {
|
|
1075
|
+
const sourceSynthesis = [
|
|
1076
|
+
...synthesizeSources(docs),
|
|
1077
|
+
...synthesizeCodeSources(paths)
|
|
1078
|
+
];
|
|
1079
|
+
const presetDefinition = preset?.storyDefinitions?.[storyId];
|
|
1080
|
+
if (presetDefinition) {
|
|
1081
|
+
return applyStoryDocDefinition({ ...presetDefinition, source_synthesis: sourceSynthesis }, docs, sourceSynthesis);
|
|
1082
|
+
}
|
|
1083
|
+
const definitions = {
|
|
1084
|
+
'story-product-auth-account-access': {
|
|
1085
|
+
who: 'サービスを継続利用したいユーザー',
|
|
1086
|
+
problem: '認証、アカウント切替、退会、OAuth連携が不安定だと、個人化や有料機能の前提が崩れる。',
|
|
1087
|
+
want: '安全にログインし、必要に応じてアカウント操作ができ、利用開始後の状態が保たれてほしい。',
|
|
1088
|
+
outcome: 'ユーザーが安心してアカウントを作成し、継続利用できる。',
|
|
1089
|
+
business_value: '継続利用、個人化、有料機能の土台。認証完了率や離脱率は未確認。',
|
|
1090
|
+
acceptance_focus: ['主要OAuthログインが動く', 'セッション同期とユーザー情報更新が一貫する', 'エラー、退会、アカウント切替の扱いが明確である'],
|
|
1091
|
+
source_synthesis: sourceSynthesis
|
|
1092
|
+
},
|
|
1093
|
+
'story-product-profile-personalization': {
|
|
1094
|
+
who: '自分向けの設定や体験を保ちたいユーザー',
|
|
1095
|
+
problem: 'プロフィールや設定が体験に反映されないと、表示、提案、通知が汎用的になり、自分向けの価値が弱くなる。',
|
|
1096
|
+
want: 'プロフィール、設定、利用目的が体験全体に反映されてほしい。',
|
|
1097
|
+
outcome: '主要画面や通知の文脈がユーザーごとに近づく。',
|
|
1098
|
+
business_value: '個人化による活性化と再訪向上が期待できる。具体KPIは未確認。',
|
|
1099
|
+
acceptance_focus: ['プロフィール編集が保存される', '保存情報が関連画面で使われる', '未入力やエラー時の体験が決まる'],
|
|
1100
|
+
source_synthesis: sourceSynthesis
|
|
1101
|
+
},
|
|
1102
|
+
'story-product-content-cms': {
|
|
1103
|
+
who: '公開コンテンツを運用したい担当者と、検索流入から来るユーザー',
|
|
1104
|
+
problem: 'コンテンツ運用と主要導線がつながっていないと、SEO流入を獲得しても登録、問い合わせ、購入などの行動へ接続できない。',
|
|
1105
|
+
want: '非エンジニアでも記事、特集、CTAを作成し、公開ページからプロダクト利用へ誘導したい。',
|
|
1106
|
+
outcome: '記事コンテンツが主要導線へ接続され、運用者が継続的に改善できる。',
|
|
1107
|
+
business_value: 'SEO流入、コンバージョン、コンテンツ運用効率の改善が期待できる。優先KPIは未確定。',
|
|
1108
|
+
acceptance_focus: ['記事内に主要CTAを配置できる', '非エンジニアがCMSで作成、編集、公開できる', 'SEOに必要なメタ情報と構造を持つ'],
|
|
1109
|
+
source_synthesis: sourceSynthesis
|
|
1110
|
+
},
|
|
1111
|
+
'story-product-public-discovery-seo': {
|
|
1112
|
+
who: '検索エンジンや公開ページから初めて訪れるユーザー',
|
|
1113
|
+
problem: '公開検索、記事、サイトマップ、構造化データが弱いと、アプリの価値がログイン前に伝わらず新規流入も取りこぼす。',
|
|
1114
|
+
want: '検索結果や公開ページから価値を理解し、自然に登録、問い合わせ、購入などの導線へ進みたい。',
|
|
1115
|
+
outcome: '未ログインユーザーがサービスの価値を理解し、次の行動へ進める。',
|
|
1116
|
+
business_value: 'SEO流入と新規獲得の土台。流入数、登録率、主要導線到達率は未確認。',
|
|
1117
|
+
acceptance_focus: ['公開ページが成立する', 'sitemapとrobotsが意図通り出る', '構造化データと記事導線が壊れない'],
|
|
1118
|
+
source_synthesis: sourceSynthesis
|
|
1119
|
+
},
|
|
1120
|
+
'story-product-waiting-list-contact': {
|
|
1121
|
+
who: '問い合わせや利用希望を伝えたいユーザーまたは事業者',
|
|
1122
|
+
problem: '問い合わせや待機リストの受け皿が弱いと、利用意向や連絡機会を失う。',
|
|
1123
|
+
want: '公開ページから迷わず問い合わせ、待機リスト登録、連絡ができてほしい。',
|
|
1124
|
+
outcome: 'プロダクト外部からの関心を運営が拾える。',
|
|
1125
|
+
business_value: 'リード獲得と顧客接点の維持。登録後の運用フローは未確認。',
|
|
1126
|
+
acceptance_focus: ['フォーム入力と送信が成立する', '送信後の状態が明確である', '運営側の受け取り先が定義される'],
|
|
1127
|
+
source_synthesis: sourceSynthesis
|
|
1128
|
+
},
|
|
1129
|
+
'story-product-qr-offline-access': {
|
|
1130
|
+
who: 'QRや通信状態が不安定な場面でもサービスにアクセスしたいユーザー',
|
|
1131
|
+
problem: 'QR読み取りやオフライン時の案内が弱いと、外部接点や再訪のタイミングで利用が途切れる。',
|
|
1132
|
+
want: 'QRから必要な情報へ進み、通信が不安定でも状態が分かる案内を受けたい。',
|
|
1133
|
+
outcome: 'オンライン外の接点でもサービス利用を継続しやすくなる。',
|
|
1134
|
+
business_value: '外部接点、再訪、PWA的利用の下支え。利用場面とKPIは未確認。',
|
|
1135
|
+
acceptance_focus: ['QR読み取りの成功・失敗状態がある', 'オフライン時の導線がある', '更新通知がユーザー操作を妨げない'],
|
|
1136
|
+
source_synthesis: sourceSynthesis
|
|
1137
|
+
},
|
|
1138
|
+
'story-product-app-navigation-shell': {
|
|
1139
|
+
who: 'サービスの主要機能を行き来するユーザー',
|
|
1140
|
+
problem: 'ホーム、ナビゲーション、共通レイアウトがStoryに紐づかないと、ユーザーがどこから主要機能へ進むのかを引き継げない。',
|
|
1141
|
+
want: 'アプリの起点と主要導線が一貫し、画面間を迷わず移動できてほしい。',
|
|
1142
|
+
outcome: 'ユーザーがログイン後に目的の機能へ自然に進める。',
|
|
1143
|
+
business_value: '初回活性化、再訪、主要機能への到達率を支える。具体KPIは未確認。',
|
|
1144
|
+
acceptance_focus: ['ホームから主要機能へ到達できる', '下部ナビとレイアウトが画面状態に応じて破綻しない', 'ログイン状態やプラン状態と導線が矛盾しない'],
|
|
1145
|
+
source_synthesis: sourceSynthesis
|
|
1146
|
+
},
|
|
1147
|
+
'story-ops-observability-health': {
|
|
1148
|
+
who: '本番稼働を確認する運用者と開発チーム',
|
|
1149
|
+
problem: 'health、heartbeat、ログ確認の入口があっても運用Storyがないと、障害時に何を見るべきか分からない。',
|
|
1150
|
+
want: '稼働確認とログ確認の入口を運用手順に接続したい。',
|
|
1151
|
+
outcome: '本番状態の確認と初動判断がしやすくなる。',
|
|
1152
|
+
business_value: '障害検知と復旧時間短縮につながる。監視基準は未確認。',
|
|
1153
|
+
acceptance_focus: ['health/heartbeatの意味が定義される', 'ログ確認権限が整理される', '異常時の次アクションが残る'],
|
|
1154
|
+
source_synthesis: sourceSynthesis
|
|
1155
|
+
},
|
|
1156
|
+
'story-product-legal-trust-pages': {
|
|
1157
|
+
who: '利用前にサービスの条件や安全性を確認したいユーザー',
|
|
1158
|
+
problem: '規約、プライバシー、特商法、ガイドラインが整っていないと、公開サービスとしての信頼や法務確認が弱くなる。',
|
|
1159
|
+
want: '利用前に必要な条件、個人情報の扱い、禁止事項を確認したい。',
|
|
1160
|
+
outcome: 'ユーザーと運営の前提が明確になり、公開利用に耐えやすくなる。',
|
|
1161
|
+
business_value: '公開サービスとしての信頼とリスク低減。法務レビュー状態は未確認。',
|
|
1162
|
+
acceptance_focus: ['各公開ページが到達可能である', '内容の正本と更新責任が決まる', '主要導線から確認できる'],
|
|
1163
|
+
source_synthesis: sourceSynthesis
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
return applyStoryDocDefinition(definitions[storyId] ?? storyDefinitionFor('unknown', docs, preset), docs, sourceSynthesis);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function storyDefinitionFor(storyId, docs = [], preset = null) {
|
|
1170
|
+
const sourceSynthesis = synthesizeSources(docs);
|
|
1171
|
+
const presetDefinition = preset?.storyDefinitions?.[storyId];
|
|
1172
|
+
if (presetDefinition) {
|
|
1173
|
+
return applyStoryDocDefinition({ ...presetDefinition, source_synthesis: sourceSynthesis }, docs, sourceSynthesis);
|
|
1174
|
+
}
|
|
1175
|
+
const definitions = {
|
|
1176
|
+
'story-product-auth-account-access': {
|
|
1177
|
+
who: 'サービスを継続利用したいユーザー',
|
|
1178
|
+
problem: '認証、アカウント切替、退会、OAuth連携が不安定だと、個人化や有料機能の前提が崩れる。',
|
|
1179
|
+
want: '安全にログインし、必要に応じてアカウント操作ができ、利用開始後の状態が保たれてほしい。',
|
|
1180
|
+
outcome: 'ユーザーが安心してアカウントを作成し、継続利用できる。',
|
|
1181
|
+
business_value: '継続利用、個人化、有料機能の土台。認証完了率や離脱率は未確認。',
|
|
1182
|
+
acceptance_focus: [
|
|
1183
|
+
'主要ログイン導線が動く',
|
|
1184
|
+
'セッション同期とユーザー情報更新が一貫する',
|
|
1185
|
+
'エラー、退会、アカウント切替の扱いが明確である'
|
|
1186
|
+
],
|
|
1187
|
+
source_synthesis: sourceSynthesis
|
|
1188
|
+
},
|
|
1189
|
+
'story-product-profile-personalization': {
|
|
1190
|
+
who: '自分向けの設定や体験を保ちたいユーザー',
|
|
1191
|
+
problem: 'プロフィールや設定が体験に反映されないと、表示、提案、通知が汎用的になり、自分向けの価値が弱くなる。',
|
|
1192
|
+
want: 'プロフィール、設定、利用目的が体験全体に反映されてほしい。',
|
|
1193
|
+
outcome: '主要画面や通知の文脈がユーザーごとに近づく。',
|
|
1194
|
+
business_value: '個人化による活性化と再訪向上が期待できる。具体KPIは未確認。',
|
|
1195
|
+
acceptance_focus: [
|
|
1196
|
+
'プロフィール編集が保存される',
|
|
1197
|
+
'保存情報が関連画面で使われる',
|
|
1198
|
+
'未入力やエラー時の体験が決まる'
|
|
1199
|
+
],
|
|
1200
|
+
source_synthesis: sourceSynthesis
|
|
1201
|
+
},
|
|
1202
|
+
'story-product-premium-billing': {
|
|
1203
|
+
who: 'プレミアム機能を利用したいユーザーと運営者',
|
|
1204
|
+
problem: '課金、プラン変更、webhook反映が不安定だと、使えるはずの機能が使えない、または使えてはいけない機能が使える状態になる。',
|
|
1205
|
+
want: 'Stripeを通じて加入、解約、ダウングレードが正しく反映され、機能権限と請求状態が一致してほしい。',
|
|
1206
|
+
outcome: '有料機能の提供状態と請求状態が同期し、ユーザーと運営の双方が安心して運用できる。',
|
|
1207
|
+
business_value: 'プレミアム収益の土台。MRR、解約率、決済失敗率は別途定義が必要。',
|
|
1208
|
+
acceptance_focus: [
|
|
1209
|
+
'Checkout完了後にプレミアム権限が付与される',
|
|
1210
|
+
'webhookで加入、解約、ダウングレードが反映される',
|
|
1211
|
+
'権限と請求状態の不整合が検知できる',
|
|
1212
|
+
'決済失敗時のユーザー体験が定義される'
|
|
1213
|
+
],
|
|
1214
|
+
source_synthesis: sourceSynthesis
|
|
1215
|
+
},
|
|
1216
|
+
'story-product-content-cms': {
|
|
1217
|
+
who: '公開コンテンツを運用したい担当者と、検索流入から来るユーザー',
|
|
1218
|
+
problem: 'コンテンツ運用と主要導線がつながっていないと、SEO流入を獲得しても登録、問い合わせ、購入などの行動へ接続できない。',
|
|
1219
|
+
want: '非エンジニアでも記事、特集、CTAを作成し、公開ページからプロダクト利用へ誘導したい。',
|
|
1220
|
+
outcome: '記事コンテンツが主要導線へ接続され、運用者が継続的に改善できる。',
|
|
1221
|
+
business_value: 'SEO流入、コンバージョン、コンテンツ運用効率の改善が期待できる。優先KPIは未確定。',
|
|
1222
|
+
acceptance_focus: [
|
|
1223
|
+
'記事内に主要CTAを配置できる',
|
|
1224
|
+
'非エンジニアがCMSで作成、編集、公開できる',
|
|
1225
|
+
'SEOに必要なメタ情報と構造を持つ',
|
|
1226
|
+
'記事から主要導線へ遷移できる'
|
|
1227
|
+
],
|
|
1228
|
+
source_synthesis: sourceSynthesis
|
|
1229
|
+
},
|
|
1230
|
+
'story-product-onboarding': {
|
|
1231
|
+
who: '初めてサービスを使うユーザー',
|
|
1232
|
+
problem: '利用目的や初期設定が分からないままだと、初回体験で自分向けの価値を感じにくい。',
|
|
1233
|
+
want: '初回利用時に必要な情報を短く入力し、次に使うべき機能へ進めてほしい。',
|
|
1234
|
+
outcome: 'ユーザーが最初から価値のある導線へ近づけ、継続利用の理由が生まれる。',
|
|
1235
|
+
business_value: '初回活性化と個人化精度の改善が期待できる。オンボーディング完了率と初回主要導線到達率は確認が必要。',
|
|
1236
|
+
acceptance_focus: [
|
|
1237
|
+
'目的や初期設定など必要最小限の情報を取得する',
|
|
1238
|
+
'取得した情報が主要導線に反映される',
|
|
1239
|
+
'途中離脱しても再開できる',
|
|
1240
|
+
'入力負荷が高すぎない'
|
|
1241
|
+
],
|
|
1242
|
+
source_synthesis: sourceSynthesis
|
|
1243
|
+
},
|
|
1244
|
+
'story-product-notification': {
|
|
1245
|
+
who: '重要な更新を逃したくないユーザー',
|
|
1246
|
+
problem: '重要な更新、問い合わせ、ステータス変化が分散すると、ユーザーが必要なタイミングで戻ってこられない。',
|
|
1247
|
+
want: '必要な通知をアプリ、メール、Pushなど適切な経路で受け取り、設定も管理したい。',
|
|
1248
|
+
outcome: '重要な更新が届き、ユーザーが利用を再開しやすくなる。',
|
|
1249
|
+
business_value: '再訪、継続利用、主要導線への復帰が期待できる。通知許諾率と再訪率は確認が必要。',
|
|
1250
|
+
acceptance_focus: [
|
|
1251
|
+
'通知対象イベントが整理されている',
|
|
1252
|
+
'ユーザーが通知設定を管理できる',
|
|
1253
|
+
'未読、既読、再通知の扱いが定義されている',
|
|
1254
|
+
'過剰通知を避ける制御がある'
|
|
1255
|
+
],
|
|
1256
|
+
source_synthesis: sourceSynthesis
|
|
1257
|
+
},
|
|
1258
|
+
'story-architecture-api-surface': {
|
|
1259
|
+
who: '開発チームとレビュアー',
|
|
1260
|
+
problem: 'API公開面と実行境界が曖昧だと、どのrouteが外部入力を受け、どこで保護されるべきか判断しづらい。',
|
|
1261
|
+
want: 'entrypoint、server boundary、公開APIを把握し、実装とレビューの単位を揃えたい。',
|
|
1262
|
+
outcome: 'API変更の影響範囲と保護責務が追跡できる。',
|
|
1263
|
+
business_value: '機能追加速度を落とさず、本番事故やレビュー漏れを減らす。',
|
|
1264
|
+
acceptance_focus: ['公開API一覧が把握できる', 'routeごとの責務と保護境界が説明できる', '診断Findingと修正タスクがつながる'],
|
|
1265
|
+
source_synthesis: sourceSynthesis
|
|
1266
|
+
},
|
|
1267
|
+
'story-architecture-data-access': {
|
|
1268
|
+
who: '開発チーム',
|
|
1269
|
+
problem: 'データモデルとアクセス経路が散らばると、機能追加時に整合性や権限の見落としが起きやすい。',
|
|
1270
|
+
want: '永続化境界、主要モデル、アクセスパターンをStory単位で見える化したい。',
|
|
1271
|
+
outcome: 'データ変更の影響範囲を判断しやすくなる。',
|
|
1272
|
+
business_value: '機能追加時の手戻りとデータ不整合リスクを減らす。',
|
|
1273
|
+
acceptance_focus: ['主要storeとaccess patternが整理される', '重要モデルの利用箇所が追跡できる', '権限とデータアクセスの接点が見える'],
|
|
1274
|
+
source_synthesis: sourceSynthesis
|
|
1275
|
+
},
|
|
1276
|
+
'story-security-auth-boundary': {
|
|
1277
|
+
who: 'ユーザー情報や有料機能を扱う開発チーム',
|
|
1278
|
+
problem: '認証境界が曖昧だと、本人だけが見られるべき情報や有料機能の保護が崩れる。',
|
|
1279
|
+
want: '認証方式、保護対象、例外を明確にしたい。',
|
|
1280
|
+
outcome: 'ユーザー境界を前提に実装とレビューができる。',
|
|
1281
|
+
business_value: '信頼性と課金機能の正当性を守る。',
|
|
1282
|
+
acceptance_focus: ['保護対象routeが識別される', '認証なしで触れるrouteの理由が説明できる', '権限チェックの根拠が残る'],
|
|
1283
|
+
source_synthesis: sourceSynthesis
|
|
1284
|
+
},
|
|
1285
|
+
'story-security-api-trust-boundary': {
|
|
1286
|
+
who: '外部連携や管理APIを扱う開発チーム',
|
|
1287
|
+
problem: 'webhook、debug、test、admin系APIの信頼境界が弱いと、本番で不正実行や情報漏えいが起きうる。',
|
|
1288
|
+
want: 'APIごとの信頼境界、認証、実行可否を診断結果から修正可能な形にしたい。',
|
|
1289
|
+
outcome: '本番に出してよいAPIと修正が必要なAPIを区別できる。',
|
|
1290
|
+
business_value: '外部連携を使う機能の安全性を上げ、公開前のリスクを減らす。',
|
|
1291
|
+
acceptance_focus: ['未保護APIの分類がある', 'webhook検証の有無が分かる', 'debug/test routeの公開可否が判断できる'],
|
|
1292
|
+
source_synthesis: sourceSynthesis
|
|
1293
|
+
},
|
|
1294
|
+
'story-ops-deployment-runtime': {
|
|
1295
|
+
who: '運用担当と開発チーム',
|
|
1296
|
+
problem: 'デプロイ先、環境変数、実行プロセスが不明確だと、機能は作れても安定運用に移れない。',
|
|
1297
|
+
want: '実行基盤とデプロイ条件をStoryとして管理したい。',
|
|
1298
|
+
outcome: 'リリース前に運用上の不足を確認できる。',
|
|
1299
|
+
business_value: '本番反映の失敗と復旧コストを減らす。',
|
|
1300
|
+
acceptance_focus: ['デプロイ対象が分かる', '環境変数とsecretの扱いが整理される', '運用時の確認手順が残る'],
|
|
1301
|
+
source_synthesis: sourceSynthesis
|
|
1302
|
+
},
|
|
1303
|
+
'story-quality-test-ci-readiness': {
|
|
1304
|
+
who: '開発チームとレビュアー',
|
|
1305
|
+
problem: 'テストとCIがStoryの受け入れ基準に接続していないと、PRで何を担保したか説明できない。',
|
|
1306
|
+
want: 'Unit、Integration、E2EのGateをStoryの受け入れ基準から追跡したい。',
|
|
1307
|
+
outcome: '実装完了の判断がテスト証跡と結びつく。',
|
|
1308
|
+
business_value: 'レビュー品質を上げ、回帰バグを減らす。',
|
|
1309
|
+
acceptance_focus: ['受け入れ基準とテストGateが対応する', 'Playwrightが必要な箇所を判定できる', 'CIで見るべきコマンドが分かる'],
|
|
1310
|
+
source_synthesis: sourceSynthesis
|
|
1311
|
+
},
|
|
1312
|
+
'story-docs-story-ssot-recovery': {
|
|
1313
|
+
who: 'プロダクト判断を引き継ぐメンバー',
|
|
1314
|
+
problem: '仕様書、要求、User Storyが分散し、Storyの正本構造が見えないと、次に何を作るべきか判断できない。',
|
|
1315
|
+
want: '仕様書をそのままStoryにせず、Story、根拠文書、不明点を分けて管理したい。',
|
|
1316
|
+
outcome: 'Story Mapから全体の開発意図と不足情報が読める。',
|
|
1317
|
+
business_value: '引き継ぎ、優先順位付け、NocoDB同期の品質を上げる。',
|
|
1318
|
+
acceptance_focus: ['Storyと仕様書が1対1になっていない', '複数文書がStoryの根拠として紐づく', 'periodやKPIの不明点が明示される'],
|
|
1319
|
+
source_synthesis: sourceSynthesis
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
const fallback = {
|
|
1323
|
+
who: '関係者',
|
|
1324
|
+
problem: '対象Storyの課題が文書から十分に特定できていない。',
|
|
1325
|
+
want: '根拠文書を読み直して、利用者、課題、成果を分けて定義したい。',
|
|
1326
|
+
outcome: '実装対象が仕様書名ではなく価値単位で判断できる。',
|
|
1327
|
+
business_value: '価値と検証観点の不明確さを減らす。',
|
|
1328
|
+
acceptance_focus: ['利用者が明確である', '成果が明確である', '根拠文書と不明点が分かれている'],
|
|
1329
|
+
source_synthesis: sourceSynthesis
|
|
1330
|
+
};
|
|
1331
|
+
return applyStoryDocDefinition(definitions[storyId] ?? fallback, docs, sourceSynthesis);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function normalizeStoryDefinition(definition, docs) {
|
|
1335
|
+
const fallback = storyDefinitionFor('unknown', docs);
|
|
1336
|
+
const normalized = definition ?? fallback;
|
|
1337
|
+
return {
|
|
1338
|
+
who: normalized.who ?? fallback.who,
|
|
1339
|
+
problem: normalized.problem ?? fallback.problem,
|
|
1340
|
+
want: normalized.want ?? fallback.want,
|
|
1341
|
+
outcome: normalized.outcome ?? fallback.outcome,
|
|
1342
|
+
business_value: normalized.business_value ?? fallback.business_value,
|
|
1343
|
+
acceptance_focus: Array.isArray(normalized.acceptance_focus) ? normalized.acceptance_focus : fallback.acceptance_focus,
|
|
1344
|
+
source_synthesis: Array.isArray(normalized.source_synthesis) ? normalized.source_synthesis : synthesizeSources(docs)
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function applyStoryDocDefinition(baseDefinition, docs, sourceSynthesis) {
|
|
1349
|
+
const storyDocDefinition = docs
|
|
1350
|
+
.map((doc) => doc.story_definition)
|
|
1351
|
+
.find((definition) => definition && Object.values(definition).some((value) => Array.isArray(value) ? value.length > 0 : Boolean(value)));
|
|
1352
|
+
if (!storyDocDefinition) return baseDefinition;
|
|
1353
|
+
return {
|
|
1354
|
+
...baseDefinition,
|
|
1355
|
+
...Object.fromEntries(Object.entries(storyDocDefinition)
|
|
1356
|
+
.filter(([, value]) => !Array.isArray(value) && Boolean(value))),
|
|
1357
|
+
acceptance_focus: storyDocDefinition.acceptance_focus?.length > 0
|
|
1358
|
+
? storyDocDefinition.acceptance_focus
|
|
1359
|
+
: baseDefinition.acceptance_focus,
|
|
1360
|
+
source_synthesis: sourceSynthesis
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function buildStoryMeaning({
|
|
1365
|
+
id,
|
|
1366
|
+
category,
|
|
1367
|
+
sourceType,
|
|
1368
|
+
paths,
|
|
1369
|
+
evidence,
|
|
1370
|
+
docs,
|
|
1371
|
+
relatedFindings,
|
|
1372
|
+
definition,
|
|
1373
|
+
planning,
|
|
1374
|
+
businessContext,
|
|
1375
|
+
openQuestions,
|
|
1376
|
+
diagnosisBased,
|
|
1377
|
+
codeDerived,
|
|
1378
|
+
preset
|
|
1379
|
+
}) {
|
|
1380
|
+
const confidence = inferMeaningConfidence({ docs, paths, businessContext, openQuestions, diagnosisBased });
|
|
1381
|
+
const workflow = workflowPositionFor(id, preset);
|
|
1382
|
+
const codePaths = paths.filter((item) => isCodePath(item, preset));
|
|
1383
|
+
return {
|
|
1384
|
+
value_hypothesis: `${definition.outcome} ${definition.business_value}`,
|
|
1385
|
+
user_actor: {
|
|
1386
|
+
value: definition.who,
|
|
1387
|
+
confidence: docs.length > 0 ? 'high' : codeDerived ? 'medium' : 'low',
|
|
1388
|
+
evidence: meaningEvidencePaths({ docs, paths, relatedFindings }).slice(0, 5)
|
|
1389
|
+
},
|
|
1390
|
+
business_goal: {
|
|
1391
|
+
value: definition.business_value,
|
|
1392
|
+
confidence: businessContext.signals.length > 0 ? 'high' : category === 'product' ? 'low' : 'medium',
|
|
1393
|
+
evidence: businessContext.evidence_paths,
|
|
1394
|
+
missing: openQuestions.find((item) => item.field === 'business_metric')?.question ?? null
|
|
1395
|
+
},
|
|
1396
|
+
code_scope: {
|
|
1397
|
+
value: summarizeCodeScope(paths, sourceType, preset),
|
|
1398
|
+
confidence: codePaths.length > 0 ? 'high' : sourceType === 'architecture_profile' ? 'medium' : 'low',
|
|
1399
|
+
evidence: codePaths.slice(0, 8)
|
|
1400
|
+
},
|
|
1401
|
+
workflow_position: workflow,
|
|
1402
|
+
evidence_by_type: {
|
|
1403
|
+
docs_evidence: docs.map((doc) => doc.path),
|
|
1404
|
+
code_evidence: paths.filter((item) => isCodePath(item, preset)),
|
|
1405
|
+
diagnosis_evidence: relatedFindings,
|
|
1406
|
+
inferred_evidence: evidence.filter((item) => typeof item === 'string' && !isCodePath(item, preset) && !item.startsWith('docs/')).slice(0, 8),
|
|
1407
|
+
missing_evidence: openQuestions.map((item) => ({ field: item.field, question: item.question }))
|
|
1408
|
+
},
|
|
1409
|
+
counter_evidence: buildCounterEvidence({ docs, paths, openQuestions, planning, codeDerived }),
|
|
1410
|
+
confidence
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function inferMeaningConfidence({ docs, paths, businessContext, openQuestions, diagnosisBased }) {
|
|
1415
|
+
const hasMissingSpec = openQuestions.some((item) => item.field === 'missing_spec');
|
|
1416
|
+
const hasBusinessGap = openQuestions.some((item) => item.field === 'business_context' || item.field === 'business_metric');
|
|
1417
|
+
if (diagnosisBased) return 'medium';
|
|
1418
|
+
if (docs.length > 0 && businessContext.signals.length > 0 && !hasBusinessGap) return 'high';
|
|
1419
|
+
if (docs.length > 0 || paths.length > 0) return hasMissingSpec || hasBusinessGap ? 'medium' : 'high';
|
|
1420
|
+
return 'low';
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function meaningEvidencePaths({ docs, paths, relatedFindings }) {
|
|
1424
|
+
return [
|
|
1425
|
+
...docs.map((doc) => doc.path),
|
|
1426
|
+
...paths,
|
|
1427
|
+
...relatedFindings.map((id) => `finding:${id}`)
|
|
1428
|
+
].filter(Boolean);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function summarizeCodeScope(paths, sourceType, preset) {
|
|
1432
|
+
if (paths.length === 0) return sourceType === 'architecture_profile' ? 'architecture profileから推定' : '直接のコード根拠なし';
|
|
1433
|
+
const roles = [...new Set(paths.filter((item) => isCodePath(item, preset)).map(inferCodeRole))];
|
|
1434
|
+
return roles.length > 0 ? roles.join('、') : '直接のコード根拠なし';
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function buildCounterEvidence({ docs, paths, openQuestions, planning, codeDerived }) {
|
|
1438
|
+
const items = [];
|
|
1439
|
+
if (codeDerived && docs.length === 0) {
|
|
1440
|
+
items.push('仕様書、要求、既存Storyではなくコードからの逆算である。');
|
|
1441
|
+
}
|
|
1442
|
+
if (paths.length === 0) {
|
|
1443
|
+
items.push('Storyに直接紐づくコード根拠がまだ少ない。');
|
|
1444
|
+
}
|
|
1445
|
+
if (openQuestions.some((item) => item.field === 'business_metric')) {
|
|
1446
|
+
items.push('KPIまたは効果測定指標が未確認である。');
|
|
1447
|
+
}
|
|
1448
|
+
if (!planning.period) {
|
|
1449
|
+
items.push('NocoDB Periodとして確定できる実行期が未確認である。');
|
|
1450
|
+
}
|
|
1451
|
+
return items;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function workflowPositionFor(storyId, preset = null) {
|
|
1455
|
+
if (preset?.workflowPositions?.[storyId]) return preset.workflowPositions[storyId];
|
|
1456
|
+
const positions = {
|
|
1457
|
+
'story-product-app-navigation-shell': {
|
|
1458
|
+
stage: 'entry',
|
|
1459
|
+
before: [],
|
|
1460
|
+
after: ['story-product-auth-account-access', 'story-product-onboarding', 'story-product-profile-personalization'],
|
|
1461
|
+
confidence: 'medium',
|
|
1462
|
+
rationale: 'ホームと共通ナビはログイン後の主要機能への入口になるため'
|
|
1463
|
+
},
|
|
1464
|
+
'story-product-public-discovery-seo': {
|
|
1465
|
+
stage: 'acquisition',
|
|
1466
|
+
before: [],
|
|
1467
|
+
after: ['story-product-content-cms', 'story-product-auth-account-access'],
|
|
1468
|
+
confidence: 'medium',
|
|
1469
|
+
rationale: '公開検索、記事、SEOは未ログイン流入からアプリ利用へ接続するため'
|
|
1470
|
+
},
|
|
1471
|
+
'story-product-content-cms': {
|
|
1472
|
+
stage: 'acquisition',
|
|
1473
|
+
before: ['story-product-public-discovery-seo'],
|
|
1474
|
+
after: ['story-product-auth-account-access'],
|
|
1475
|
+
confidence: 'medium',
|
|
1476
|
+
rationale: '記事とCMSは公開流入を主要な利用開始導線へ送るため'
|
|
1477
|
+
},
|
|
1478
|
+
'story-product-auth-account-access': {
|
|
1479
|
+
stage: 'activation',
|
|
1480
|
+
before: ['story-product-public-discovery-seo', 'story-product-app-navigation-shell'],
|
|
1481
|
+
after: ['story-product-onboarding', 'story-product-premium-billing', 'story-product-profile-personalization'],
|
|
1482
|
+
confidence: 'medium',
|
|
1483
|
+
rationale: '認証は個人化、課金、継続利用の前提になるため'
|
|
1484
|
+
},
|
|
1485
|
+
'story-product-onboarding': {
|
|
1486
|
+
stage: 'activation',
|
|
1487
|
+
before: ['story-product-auth-account-access'],
|
|
1488
|
+
after: ['story-product-profile-personalization'],
|
|
1489
|
+
confidence: 'medium',
|
|
1490
|
+
rationale: '初回入力はプロフィールと主要導線の材料になるため'
|
|
1491
|
+
},
|
|
1492
|
+
'story-product-profile-personalization': {
|
|
1493
|
+
stage: 'personalization',
|
|
1494
|
+
before: ['story-product-auth-account-access', 'story-product-onboarding'],
|
|
1495
|
+
after: ['story-product-notification'],
|
|
1496
|
+
confidence: 'medium',
|
|
1497
|
+
rationale: '保存された設定は表示や通知の文脈になるため'
|
|
1498
|
+
},
|
|
1499
|
+
'story-product-premium-billing': {
|
|
1500
|
+
stage: 'monetization',
|
|
1501
|
+
before: ['story-product-auth-account-access'],
|
|
1502
|
+
after: ['story-product-notification'],
|
|
1503
|
+
confidence: 'medium',
|
|
1504
|
+
rationale: '課金状態は有料機能の利用可否を決めるため'
|
|
1505
|
+
},
|
|
1506
|
+
'story-product-notification': {
|
|
1507
|
+
stage: 'retention',
|
|
1508
|
+
before: ['story-product-profile-personalization', 'story-product-premium-billing'],
|
|
1509
|
+
after: [],
|
|
1510
|
+
confidence: 'medium',
|
|
1511
|
+
rationale: '通知は重要な更新確認や再訪を促すため'
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
return positions[storyId] ?? {
|
|
1515
|
+
stage: defaultWorkflowStage(storyId),
|
|
1516
|
+
before: [],
|
|
1517
|
+
after: [],
|
|
1518
|
+
confidence: 'low',
|
|
1519
|
+
rationale: 'Story間の前後関係はコードと文書だけでは十分に確定できないため'
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function defaultWorkflowStage(storyId) {
|
|
1524
|
+
if (storyId.startsWith('story-architecture-')) return 'architecture';
|
|
1525
|
+
if (storyId.startsWith('story-security-')) return 'risk_control';
|
|
1526
|
+
if (storyId.startsWith('story-ops-')) return 'operations';
|
|
1527
|
+
if (storyId.startsWith('story-quality-')) return 'quality_gate';
|
|
1528
|
+
if (storyId.startsWith('story-docs-')) return 'knowledge_recovery';
|
|
1529
|
+
return 'unknown';
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function synthesizeSources(docs) {
|
|
1533
|
+
if (!Array.isArray(docs) || docs.length === 0) return [];
|
|
1534
|
+
return docs.slice(0, 6).map((doc) => ({
|
|
1535
|
+
path: doc.path,
|
|
1536
|
+
role: inferDocumentRole(doc),
|
|
1537
|
+
title: doc.title
|
|
1538
|
+
}));
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function inferDocumentRole(doc) {
|
|
1542
|
+
if (doc.path.startsWith('docs/management/stories/')) return 'Story正本';
|
|
1543
|
+
if (doc.path.startsWith('docs/user_stories/')) return 'ユーザー課題と受け入れ観点';
|
|
1544
|
+
if (doc.path.startsWith('docs/requirements/')) return '機能要求と制約';
|
|
1545
|
+
if (doc.path.startsWith('docs/features/')) return '機能構想とビジネス背景';
|
|
1546
|
+
if (doc.path.startsWith('docs/architecture/')) return '設計判断と構造';
|
|
1547
|
+
return '補助根拠';
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function synthesizeCodeSources(paths) {
|
|
1551
|
+
return [...paths]
|
|
1552
|
+
.sort((a, b) => rankCodePath(a) - rankCodePath(b) || a.localeCompare(b))
|
|
1553
|
+
.slice(0, 8)
|
|
1554
|
+
.map((filePath) => ({
|
|
1555
|
+
path: filePath,
|
|
1556
|
+
role: inferCodeRole(filePath),
|
|
1557
|
+
title: humanizeFileName(filePath)
|
|
1558
|
+
}));
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function rankStoryCodePath(storyId, filePath) {
|
|
1562
|
+
const storySpecificRank = {
|
|
1563
|
+
'story-product-auth-account-access': [
|
|
1564
|
+
[/^src\/lib\/auth\//, -10],
|
|
1565
|
+
[/^src\/app\/api\/auth\//, -9],
|
|
1566
|
+
[/sign-in|nextauth|session|userSync|providers/, -8],
|
|
1567
|
+
[/account-deleted|error\/page|layout\.tsx$/, 20]
|
|
1568
|
+
],
|
|
1569
|
+
'story-product-profile-personalization': [
|
|
1570
|
+
[/profileService|profile_action|profile\/page|ProfileHeader|ProfileInfo/, -10]
|
|
1571
|
+
],
|
|
1572
|
+
'story-product-content-cms': [
|
|
1573
|
+
[/article|cms|sanity|content/, -10]
|
|
1574
|
+
],
|
|
1575
|
+
'story-product-premium-billing': [
|
|
1576
|
+
[/stripe|checkout|premium|subscription|billing/, -10]
|
|
1577
|
+
],
|
|
1578
|
+
'story-product-notification': [
|
|
1579
|
+
[/notification|push|email/, -10]
|
|
1580
|
+
],
|
|
1581
|
+
'story-product-onboarding': [
|
|
1582
|
+
[/onboarding|profile-step|preferences-step/, -10]
|
|
1583
|
+
],
|
|
1584
|
+
'story-product-public-discovery-seo': [
|
|
1585
|
+
[/articles\/\[slug\]\/page|sitemap|robots|StructuredData|analytics/, -10],
|
|
1586
|
+
[/landing/, -6]
|
|
1587
|
+
]
|
|
1588
|
+
};
|
|
1589
|
+
const adjustment = (storySpecificRank[storyId] ?? [])
|
|
1590
|
+
.filter(([pattern]) => pattern.test(filePath))
|
|
1591
|
+
.reduce((min, [, value]) => Math.min(min, value), 0);
|
|
1592
|
+
return rankCodePath(filePath) + adjustment;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function rankCodePath(filePath) {
|
|
1596
|
+
if (/\.(test|spec)\.[jt]sx?$/.test(filePath)) return 200;
|
|
1597
|
+
if (/\/types\/|\.types\.|\/index\.[jt]s$|\/constants\//.test(filePath)) return 80;
|
|
1598
|
+
if (/\/(error|loading|not-found|layout)\.[jt]sx?$/.test(filePath)) return 70;
|
|
1599
|
+
if (/^src\/lib\/services\//.test(filePath)) return 0;
|
|
1600
|
+
if (/^src\/lib\/auth/.test(filePath)) return 2;
|
|
1601
|
+
if (/^src\/lib\/actions\//.test(filePath)) return 4;
|
|
1602
|
+
if (/^src\/app\/api\/.+\/route\.[jt]s$/.test(filePath)) return 6;
|
|
1603
|
+
if (/^src\/app\/.+\/page\.[jt]sx?$/.test(filePath)) return 8;
|
|
1604
|
+
if (/^src\/app\/.+\/client\.[jt]sx?$/.test(filePath)) return 10;
|
|
1605
|
+
if (/^src\/app\/.+\/_components\//.test(filePath)) return 12;
|
|
1606
|
+
if (/^src\/components\//.test(filePath)) return 14;
|
|
1607
|
+
if (/^src\/lib\/crawlers\//.test(filePath)) return 16;
|
|
1608
|
+
if (/^src\/lib\/api\//.test(filePath)) return 18;
|
|
1609
|
+
if (/^src\/lib\//.test(filePath)) return 20;
|
|
1610
|
+
return 50;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function inferCodeRole(filePath) {
|
|
1614
|
+
if (filePath.startsWith('src/app/') && filePath.includes('/api/')) return 'API route';
|
|
1615
|
+
if (filePath.startsWith('src/app/')) return '画面・ルーティング';
|
|
1616
|
+
if (filePath.startsWith('src/components/')) return 'UIコンポーネント';
|
|
1617
|
+
if (filePath.startsWith('src/lib/actions/')) return 'ユーザー操作・サーバーアクション';
|
|
1618
|
+
if (filePath.startsWith('src/lib/crawlers/')) return 'データ収集処理';
|
|
1619
|
+
if (filePath.startsWith('src/lib/auth')) return '認証・セッション処理';
|
|
1620
|
+
if (filePath.startsWith('src/lib/')) return 'ドメインロジック';
|
|
1621
|
+
return 'コード根拠';
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
async function collectRepoFiles(repoRoot) {
|
|
1625
|
+
const pending = [''];
|
|
1626
|
+
const files = [];
|
|
1627
|
+
while (pending.length > 0) {
|
|
1628
|
+
const currentRelative = pending.pop();
|
|
1629
|
+
const dir = path.join(repoRoot, currentRelative);
|
|
1630
|
+
let entries;
|
|
1631
|
+
try {
|
|
1632
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1633
|
+
} catch (error) {
|
|
1634
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') continue;
|
|
1635
|
+
throw error;
|
|
1636
|
+
}
|
|
1637
|
+
for (const entry of entries) {
|
|
1638
|
+
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
|
|
1639
|
+
const relativePath = path.posix.join(currentRelative.split(path.sep).join('/'), entry.name);
|
|
1640
|
+
const fullPath = path.join(repoRoot, relativePath);
|
|
1641
|
+
if (entry.isDirectory()) {
|
|
1642
|
+
pending.push(relativePath);
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
if (!entry.isFile()) continue;
|
|
1646
|
+
const fileStat = await stat(fullPath);
|
|
1647
|
+
files.push({ relativePath, size: fileStat.size });
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
return files;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
async function collectDocumentSignals(repoRoot, files, preset) {
|
|
1654
|
+
const groups = preset?.documentSignalGroups ?? [];
|
|
1655
|
+
const signals = {};
|
|
1656
|
+
const byStoryId = {};
|
|
1657
|
+
const docFiles = files
|
|
1658
|
+
.map((file) => file.relativePath)
|
|
1659
|
+
.filter((file) => file.endsWith('.md') && file.startsWith('docs/') && !/dummy|template/i.test(file));
|
|
1660
|
+
for (const filePath of docFiles) {
|
|
1661
|
+
const doc = await analyzeDocument(repoRoot, filePath);
|
|
1662
|
+
if (doc.story_id) {
|
|
1663
|
+
byStoryId[doc.story_id] = [...(byStoryId[doc.story_id] ?? []), doc];
|
|
1664
|
+
}
|
|
1665
|
+
for (const group of groups) {
|
|
1666
|
+
if (!group.pattern.test(filePath)) continue;
|
|
1667
|
+
signals[group.key] = [...(signals[group.key] ?? []), doc];
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
const normalized = Object.fromEntries(Object.entries(signals).map(([key, values]) => [key, dedupeDocs(values)]));
|
|
1671
|
+
normalized._byStoryId = Object.fromEntries(Object.entries(byStoryId).map(([storyId, values]) => [storyId, dedupeDocs(values)]));
|
|
1672
|
+
return normalized;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
async function analyzeDocument(repoRoot, relativePath) {
|
|
1676
|
+
const content = await readFile(path.join(repoRoot, relativePath), 'utf8');
|
|
1677
|
+
const frontMatter = parseFrontMatter(content);
|
|
1678
|
+
const title = frontMatter.title ?? extractMarkdownTitle(content) ?? humanizeFileName(relativePath);
|
|
1679
|
+
return {
|
|
1680
|
+
path: relativePath,
|
|
1681
|
+
story_id: frontMatter.story_id ?? null,
|
|
1682
|
+
title,
|
|
1683
|
+
status: frontMatter.status ?? null,
|
|
1684
|
+
view: frontMatter.view ?? null,
|
|
1685
|
+
horizon: frontMatter.horizon ?? null,
|
|
1686
|
+
period: frontMatter.period ?? null,
|
|
1687
|
+
has_period: Object.prototype.hasOwnProperty.call(frontMatter, 'period'),
|
|
1688
|
+
priority: frontMatter.priority ?? null,
|
|
1689
|
+
points: frontMatter.points ?? null,
|
|
1690
|
+
created_at: frontMatter.created_at ?? null,
|
|
1691
|
+
updated_at: frontMatter.updated_at ?? null,
|
|
1692
|
+
story_definition: relativePath.startsWith('docs/management/stories/')
|
|
1693
|
+
? parseStoryDocumentDefinition(content)
|
|
1694
|
+
: null,
|
|
1695
|
+
business_signals: extractBusinessSignals(content),
|
|
1696
|
+
timeline_signals: extractTimelineSignals(content),
|
|
1697
|
+
has_acceptance_criteria: /受け入れ基準|Acceptance Criteria/i.test(content),
|
|
1698
|
+
has_kpi: /KPI|成功指標|期待効果|効果|コンバージョン|SEO|収益|売上/.test(content)
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function inferPlanning({ category, docs, defaults, diagnosisBased, codeDerived }) {
|
|
1703
|
+
const businessContext = summarizeBusinessContext(docs, codeDerived);
|
|
1704
|
+
const storyDocPlanning = findStoryDocPlanning(docs);
|
|
1705
|
+
const viewPrediction = inferView(category, businessContext, storyDocPlanning);
|
|
1706
|
+
const horizonPrediction = inferHorizon(category, diagnosisBased, storyDocPlanning);
|
|
1707
|
+
const periodPrediction = inferPeriod({ horizon: horizonPrediction.value, docs, defaults, diagnosisBased, storyDocPlanning });
|
|
1708
|
+
const openQuestions = [];
|
|
1709
|
+
|
|
1710
|
+
if (!periodPrediction.value) {
|
|
1711
|
+
openQuestions.push({
|
|
1712
|
+
field: 'period',
|
|
1713
|
+
question: codeDerived
|
|
1714
|
+
? `NocoDB Period に置く実行期がコードから確定できない。候補は ${periodPrediction.candidate ?? '-'}。`
|
|
1715
|
+
: `NocoDB Period に置く実行期が仕様書から確定できない。候補は ${periodPrediction.candidate ?? '-'}。`
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
if (codeDerived && docs.length === 0) {
|
|
1719
|
+
openQuestions.push({
|
|
1720
|
+
field: 'missing_spec',
|
|
1721
|
+
question: 'コード上は機能面が確認できるが、対応するStory、要求、仕様書が見つからない。'
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
if (category === 'product' && businessContext.signals.length === 0) {
|
|
1725
|
+
openQuestions.push({
|
|
1726
|
+
field: 'business_context',
|
|
1727
|
+
question: codeDerived
|
|
1728
|
+
? 'biz視点の成功指標、顧客価値、優先順位がコードからは十分に読めない。'
|
|
1729
|
+
: 'biz視点の成功指標、顧客価値、優先順位が仕様書本文から十分に読めない。'
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
if (category === 'product' && !docs.some((doc) => doc.has_kpi)) {
|
|
1733
|
+
openQuestions.push({
|
|
1734
|
+
field: 'business_metric',
|
|
1735
|
+
question: codeDerived
|
|
1736
|
+
? 'KPIまたは効果測定指標がコードからは確定できない。'
|
|
1737
|
+
: 'KPIまたは効果測定指標が仕様書から確定できない。'
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
return {
|
|
1742
|
+
view: viewPrediction.value,
|
|
1743
|
+
horizon: horizonPrediction.value,
|
|
1744
|
+
period: periodPrediction.value,
|
|
1745
|
+
started_at: periodPrediction.value ? defaults.started_at : null,
|
|
1746
|
+
due_at: null,
|
|
1747
|
+
predictions: {
|
|
1748
|
+
view: viewPrediction,
|
|
1749
|
+
horizon: horizonPrediction,
|
|
1750
|
+
period: periodPrediction
|
|
1751
|
+
},
|
|
1752
|
+
open_questions: openQuestions
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function inferView(category, businessContext, storyDocPlanning = {}) {
|
|
1757
|
+
if (storyDocPlanning.view) {
|
|
1758
|
+
return {
|
|
1759
|
+
value: storyDocPlanning.view,
|
|
1760
|
+
confidence: 'high',
|
|
1761
|
+
rationale: 'Story正本frontmatterのviewを優先するため'
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
if (category === 'product') {
|
|
1765
|
+
return {
|
|
1766
|
+
value: 'business',
|
|
1767
|
+
confidence: businessContext.signals.length > 0 ? 'high' : 'medium',
|
|
1768
|
+
rationale: businessContext.signals.length > 0
|
|
1769
|
+
? `仕様書から ${businessContext.signals.slice(0, 3).join(', ')} が読めるため`
|
|
1770
|
+
: 'ユーザー体験Storyだがbiz効果は未確定のため'
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
return {
|
|
1774
|
+
value: 'dev',
|
|
1775
|
+
confidence: 'high',
|
|
1776
|
+
rationale: `${category} は開発・運用品質の管理ビューで扱うため`
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function inferHorizon(category, diagnosisBased, storyDocPlanning = {}) {
|
|
1781
|
+
if (storyDocPlanning.horizon) {
|
|
1782
|
+
return {
|
|
1783
|
+
value: storyDocPlanning.horizon,
|
|
1784
|
+
confidence: 'high',
|
|
1785
|
+
rationale: 'Story正本frontmatterのhorizonを優先するため'
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
if (diagnosisBased) {
|
|
1789
|
+
return { value: 'sprint', confidence: 'medium', rationale: '診断Finding由来で短期修正候補のため' };
|
|
1790
|
+
}
|
|
1791
|
+
if (category === 'product') {
|
|
1792
|
+
return { value: 'quarter', confidence: 'medium', rationale: '仕様書本文は機能価値の塊で、週次タスクではなく四半期テーマに近いため' };
|
|
1793
|
+
}
|
|
1794
|
+
return { value: 'month', confidence: 'medium', rationale: '開発基盤・設計整理のStoryとして月次管理が妥当なため' };
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function inferPeriod({ horizon, docs, defaults, diagnosisBased, storyDocPlanning = {} }) {
|
|
1798
|
+
if (storyDocPlanning.hasPeriod && storyDocPlanning.period) {
|
|
1799
|
+
return {
|
|
1800
|
+
value: storyDocPlanning.period,
|
|
1801
|
+
candidate: storyDocPlanning.period,
|
|
1802
|
+
confidence: 'high',
|
|
1803
|
+
rationale: 'Story正本frontmatterのperiodを優先するため'
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
if (storyDocPlanning.hasPeriod && !storyDocPlanning.period) {
|
|
1807
|
+
return {
|
|
1808
|
+
value: null,
|
|
1809
|
+
candidate: null,
|
|
1810
|
+
confidence: 'unknown',
|
|
1811
|
+
rationale: 'Story正本frontmatterでperiodが未定義のため'
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
if (diagnosisBased && defaults.period) {
|
|
1815
|
+
return {
|
|
1816
|
+
value: defaults.period,
|
|
1817
|
+
candidate: defaults.period,
|
|
1818
|
+
confidence: 'medium',
|
|
1819
|
+
rationale: '診断runに紐づく現在のStory periodを引き継ぐため'
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
const explicitPeriod = findExplicitManagementPeriod(docs);
|
|
1823
|
+
if (explicitPeriod) {
|
|
1824
|
+
return {
|
|
1825
|
+
value: explicitPeriod,
|
|
1826
|
+
candidate: explicitPeriod,
|
|
1827
|
+
confidence: 'high',
|
|
1828
|
+
rationale: '仕様書本文に管理期間らしき記述があるため'
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
return {
|
|
1832
|
+
value: null,
|
|
1833
|
+
candidate: horizon === 'quarter' ? currentQuarter() : horizon === 'month' ? currentMonth() : defaults.period,
|
|
1834
|
+
confidence: 'unknown',
|
|
1835
|
+
rationale: '作成日・実装完了日は読めるが、NocoDB Periodとして確定できる計画期間は仕様書から読めないため'
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function summarizeBusinessContext(docs, codeDerived = false) {
|
|
1840
|
+
const signals = [...new Set(docs.flatMap((doc) => doc.business_signals ?? []))];
|
|
1841
|
+
return {
|
|
1842
|
+
signals,
|
|
1843
|
+
source: codeDerived ? 'code_surface' : 'documents',
|
|
1844
|
+
evidence_paths: docs
|
|
1845
|
+
.filter((doc) => (doc.business_signals ?? []).length > 0)
|
|
1846
|
+
.map((doc) => doc.path)
|
|
1847
|
+
.slice(0, 5)
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function extractBusinessSignals(content) {
|
|
1852
|
+
const signals = [];
|
|
1853
|
+
const checks = [
|
|
1854
|
+
[/SEO|オーガニック検索|検索流入/, 'SEO流入'],
|
|
1855
|
+
[/コンバージョン|CVR/i, 'コンバージョン'],
|
|
1856
|
+
[/収益|月額|課金|サブスクリプション|プレミアム|売上/, '収益化'],
|
|
1857
|
+
[/効率|効率化|意思決定|選択できる|視覚的に把握/, 'ユーザー効率'],
|
|
1858
|
+
[/パーソナライズ|嗜好|マッチング|個人化/, '個人化'],
|
|
1859
|
+
[/通知|再訪|既読|メール|Push|プッシュ/, '継続利用'],
|
|
1860
|
+
[/非エンジニア|運用効率|コンテンツ作成/, '運用効率']
|
|
1861
|
+
];
|
|
1862
|
+
for (const [pattern, label] of checks) {
|
|
1863
|
+
if (pattern.test(content)) signals.push(label);
|
|
1864
|
+
}
|
|
1865
|
+
return signals;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function parseStoryDocumentDefinition(content) {
|
|
1869
|
+
const body = stripFrontMatter(content);
|
|
1870
|
+
const sections = extractMarkdownSections(body);
|
|
1871
|
+
const labeled = extractLabeledStoryFields(body);
|
|
1872
|
+
const userStory = extractUserStoryFields(body);
|
|
1873
|
+
const sectionValue = (...keys) => firstText(keys.map((key) => sections[key]));
|
|
1874
|
+
const acceptanceSection = sectionValue('acceptance', 'completion');
|
|
1875
|
+
return {
|
|
1876
|
+
who: labeled.who ?? userStory.who ?? sectionValue('who'),
|
|
1877
|
+
problem: labeled.problem ?? sectionValue('problem', 'background'),
|
|
1878
|
+
want: labeled.want ?? userStory.want ?? sectionValue('want'),
|
|
1879
|
+
outcome: labeled.outcome ?? userStory.outcome ?? sectionValue('outcome'),
|
|
1880
|
+
business_value: labeled.business_value ?? userStory.business_value ?? sectionValue('business_value'),
|
|
1881
|
+
acceptance_focus: labeled.acceptance_focus?.length > 0
|
|
1882
|
+
? labeled.acceptance_focus
|
|
1883
|
+
: extractBulletItems(acceptanceSection)
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function extractUserStoryFields(content) {
|
|
1888
|
+
const asA = matchStoryLine(content, /(?:\*\*)?As a(?:\*\*)?\s+(.+)/i);
|
|
1889
|
+
const want = matchStoryLine(content, /(?:\*\*)?I want to(?:\*\*)?\s+(.+)/i);
|
|
1890
|
+
const soThat = matchStoryLine(content, /(?:\*\*)?So that(?:\*\*)?\s+(.+)/i);
|
|
1891
|
+
return {
|
|
1892
|
+
who: asA,
|
|
1893
|
+
want,
|
|
1894
|
+
outcome: soThat,
|
|
1895
|
+
business_value: soThat
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
function matchStoryLine(content, pattern) {
|
|
1900
|
+
for (const line of content.split(/\r?\n/)) {
|
|
1901
|
+
const normalized = line
|
|
1902
|
+
.replace(/^\s*[-*]\s*/, '')
|
|
1903
|
+
.replace(/\s{2,}$/, '')
|
|
1904
|
+
.trim();
|
|
1905
|
+
const match = normalized.match(pattern);
|
|
1906
|
+
if (!match) continue;
|
|
1907
|
+
return cleanMarkdownInline(match[1]);
|
|
1908
|
+
}
|
|
1909
|
+
return null;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
function cleanMarkdownInline(value) {
|
|
1913
|
+
return value
|
|
1914
|
+
.replace(/\*\*/g, '')
|
|
1915
|
+
.replace(/`/g, '')
|
|
1916
|
+
.trim();
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function stripFrontMatter(content) {
|
|
1920
|
+
return content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function extractMarkdownSections(content) {
|
|
1924
|
+
const sections = {};
|
|
1925
|
+
let currentKey = null;
|
|
1926
|
+
let currentLines = [];
|
|
1927
|
+
const flush = () => {
|
|
1928
|
+
if (!currentKey) return;
|
|
1929
|
+
sections[currentKey] = [...(sections[currentKey] ? [sections[currentKey]] : []), currentLines.join('\n').trim()]
|
|
1930
|
+
.filter(Boolean)
|
|
1931
|
+
.join('\n\n');
|
|
1932
|
+
};
|
|
1933
|
+
for (const line of content.split(/\r?\n/)) {
|
|
1934
|
+
const heading = line.match(/^#{2,4}\s+(.+?)\s*$/);
|
|
1935
|
+
if (heading) {
|
|
1936
|
+
flush();
|
|
1937
|
+
currentKey = normalizeStoryHeading(heading[1]);
|
|
1938
|
+
currentLines = [];
|
|
1939
|
+
continue;
|
|
1940
|
+
}
|
|
1941
|
+
if (currentKey) currentLines.push(line);
|
|
1942
|
+
}
|
|
1943
|
+
flush();
|
|
1944
|
+
return sections;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function normalizeStoryHeading(heading) {
|
|
1948
|
+
const normalized = heading.trim().toLowerCase();
|
|
1949
|
+
if (/誰のため|だれのため|対象ユーザー|利用者|who|actor|user/.test(normalized)) return 'who';
|
|
1950
|
+
if (/課題|問題|現状|背景|problem|pain|background/.test(normalized)) return 'problem';
|
|
1951
|
+
if (/望む変化|やりたいこと|したいこと|want|need/.test(normalized)) return 'want';
|
|
1952
|
+
if (/成果|成功状態|outcome|goal/.test(normalized)) return 'outcome';
|
|
1953
|
+
if (/事業価値|価値|効果|kpi|business/.test(normalized)) return 'business_value';
|
|
1954
|
+
if (/受け入れ基準|受入基準|acceptance/.test(normalized)) return 'acceptance';
|
|
1955
|
+
if (/完了条件|completion|done/.test(normalized)) return 'completion';
|
|
1956
|
+
return null;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function extractLabeledStoryFields(content) {
|
|
1960
|
+
const result = {};
|
|
1961
|
+
for (const line of content.split(/\r?\n/)) {
|
|
1962
|
+
const match = line.match(/^\s*(?:[-*]\s*)?(who|problem|want|outcome|business[_ ]value|acceptance|誰のため|課題|望む変化|成果|事業価値|受け入れ基準)\s*[::]\s*(.+)$/i);
|
|
1963
|
+
if (!match) continue;
|
|
1964
|
+
const key = normalizeStoryHeading(match[1]);
|
|
1965
|
+
if (!key) continue;
|
|
1966
|
+
if (key === 'acceptance') {
|
|
1967
|
+
result.acceptance_focus = [...(result.acceptance_focus ?? []), match[2].trim()];
|
|
1968
|
+
continue;
|
|
1969
|
+
}
|
|
1970
|
+
result[key] = match[2].trim();
|
|
1971
|
+
}
|
|
1972
|
+
return result;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
function firstText(items) {
|
|
1976
|
+
const value = items.find((item) => typeof item === 'string' && item.trim().length > 0);
|
|
1977
|
+
if (!value) return null;
|
|
1978
|
+
return value
|
|
1979
|
+
.split(/\n{2,}/)
|
|
1980
|
+
.map((block) => block.trim())
|
|
1981
|
+
.find((block) => block && !block.startsWith('- ') && !block.startsWith('* '))
|
|
1982
|
+
?? value.trim();
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
function extractBulletItems(text) {
|
|
1986
|
+
if (!text) return [];
|
|
1987
|
+
return text
|
|
1988
|
+
.split(/\r?\n/)
|
|
1989
|
+
.map((line) => line.match(/^\s*[-*]\s+(.+)$/)?.[1]?.trim())
|
|
1990
|
+
.filter(Boolean);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
function extractTimelineSignals(content) {
|
|
1994
|
+
const signals = [];
|
|
1995
|
+
const dateMatches = content.match(/\d{4}[/-]\d{1,2}[/-]\d{1,2}|\d{4}年\d{1,2}月\d{1,2}日/g) ?? [];
|
|
1996
|
+
const durationMatches = content.match(/\d+(?:\.\d+)?\s*(?:日|週間|ヶ月|か月|月)/g) ?? [];
|
|
1997
|
+
signals.push(...dateMatches.slice(0, 5), ...durationMatches.slice(0, 5));
|
|
1998
|
+
return [...new Set(signals)];
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function findExplicitManagementPeriod(docs) {
|
|
2002
|
+
const text = docs.flatMap((doc) => doc.timeline_signals ?? []).join(' ');
|
|
2003
|
+
const quarter = text.match(/\d{4}Q[1-4]/);
|
|
2004
|
+
if (quarter) return quarter[0];
|
|
2005
|
+
const month = text.match(/\d{4}-\d{2}(?!-\d{2})/);
|
|
2006
|
+
if (month) return month[0];
|
|
2007
|
+
return null;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
function findStoryDocPlanning(docs) {
|
|
2011
|
+
const storyDoc = docs.find((doc) => doc.path?.startsWith('docs/management/stories/'));
|
|
2012
|
+
if (!storyDoc) return {};
|
|
2013
|
+
return {
|
|
2014
|
+
view: storyDoc.view,
|
|
2015
|
+
horizon: storyDoc.horizon,
|
|
2016
|
+
period: storyDoc.period,
|
|
2017
|
+
hasPeriod: storyDoc.has_period
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
function collectOpenQuestions(stories) {
|
|
2022
|
+
return stories.flatMap((story) => (story.derived?.open_questions ?? []).map((item) => ({
|
|
2023
|
+
story_id: story.story_id,
|
|
2024
|
+
field: item.field,
|
|
2025
|
+
question: item.question
|
|
2026
|
+
})));
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
function parseFrontMatter(content) {
|
|
2030
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
2031
|
+
if (!match) return {};
|
|
2032
|
+
return Object.fromEntries(match[1].split(/\r?\n/)
|
|
2033
|
+
.map((line) => line.match(/^([A-Za-z0-9_-]+):\s*(.+)$/))
|
|
2034
|
+
.filter(Boolean)
|
|
2035
|
+
.map((matchLine) => [matchLine[1], normalizeFrontMatterValue(matchLine[2])]));
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
function normalizeFrontMatterValue(value) {
|
|
2039
|
+
const trimmed = value.trim();
|
|
2040
|
+
if (trimmed === 'null') return null;
|
|
2041
|
+
if (trimmed === 'true') return true;
|
|
2042
|
+
if (trimmed === 'false') return false;
|
|
2043
|
+
if (/^\d+$/.test(trimmed)) return Number(trimmed);
|
|
2044
|
+
return trimmed.replace(/^["']|["']$/g, '');
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function dedupeDocs(docs) {
|
|
2048
|
+
const seen = new Set();
|
|
2049
|
+
const result = [];
|
|
2050
|
+
for (const doc of docs) {
|
|
2051
|
+
if (seen.has(doc.path)) continue;
|
|
2052
|
+
seen.add(doc.path);
|
|
2053
|
+
result.push(doc);
|
|
2054
|
+
}
|
|
2055
|
+
return result.sort((a, b) => documentRank(a.path) - documentRank(b.path) || a.path.localeCompare(b.path));
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
function documentRank(filePath) {
|
|
2059
|
+
if (filePath.startsWith('docs/user_stories/')) return 0;
|
|
2060
|
+
if (filePath.startsWith('docs/management/stories/')) return 0;
|
|
2061
|
+
if (filePath.startsWith('docs/requirements/')) return 1;
|
|
2062
|
+
if (filePath.startsWith('docs/features/')) return 2;
|
|
2063
|
+
if (filePath.startsWith('docs/architecture/')) return 4;
|
|
2064
|
+
if (filePath.startsWith('docs/cms/')) return 5;
|
|
2065
|
+
if (filePath.startsWith('docs/dev/')) return 6;
|
|
2066
|
+
return 9;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
function selectDocs(documentSignals, key) {
|
|
2070
|
+
return documentSignals[key] ?? [];
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
function selectDocsForStory(documentSignals, storyId) {
|
|
2074
|
+
return documentSignals._byStoryId?.[storyId] ?? [];
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function attachLinkedDocumentSignals(stories, documentSignals) {
|
|
2078
|
+
return stories.map((story) => {
|
|
2079
|
+
const docs = selectDocsForStory(documentSignals, story.story_id);
|
|
2080
|
+
if (docs.length === 0) return story;
|
|
2081
|
+
const docPathsForStory = docs.map((doc) => doc.path);
|
|
2082
|
+
const docsEvidence = uniqueList([
|
|
2083
|
+
...(story.derived?.meaning?.evidence_by_type?.docs_evidence ?? []),
|
|
2084
|
+
...docPathsForStory
|
|
2085
|
+
]);
|
|
2086
|
+
const sourcePaths = uniqueList([
|
|
2087
|
+
...(story.source?.paths ?? []),
|
|
2088
|
+
...docPathsForStory
|
|
2089
|
+
]);
|
|
2090
|
+
const openQuestions = linkedDocsSatisfySpec(docs)
|
|
2091
|
+
? (story.derived?.open_questions ?? []).filter((item) => item.field !== 'missing_spec')
|
|
2092
|
+
: story.derived?.open_questions ?? [];
|
|
2093
|
+
const meaning = story.derived?.meaning ?? {};
|
|
2094
|
+
const evidenceByType = {
|
|
2095
|
+
...(meaning.evidence_by_type ?? {}),
|
|
2096
|
+
docs_evidence: docsEvidence,
|
|
2097
|
+
missing_evidence: (meaning.evidence_by_type?.missing_evidence ?? [])
|
|
2098
|
+
.filter((item) => !(linkedDocsSatisfySpec(docs) && item.field === 'missing_spec'))
|
|
2099
|
+
};
|
|
2100
|
+
return {
|
|
2101
|
+
...story,
|
|
2102
|
+
source: {
|
|
2103
|
+
...(story.source ?? {}),
|
|
2104
|
+
paths: sourcePaths
|
|
2105
|
+
},
|
|
2106
|
+
derived: {
|
|
2107
|
+
...(story.derived ?? {}),
|
|
2108
|
+
open_questions: openQuestions,
|
|
2109
|
+
story_definition: appendLinkedDocsToStoryDefinition(story.derived?.story_definition, docs),
|
|
2110
|
+
meaning: {
|
|
2111
|
+
...meaning,
|
|
2112
|
+
evidence_by_type: evidenceByType
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
};
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function linkedDocsSatisfySpec(docs) {
|
|
2120
|
+
return docs.some((doc) => /docs\/(specs|requirements|features|user_stories)\//.test(doc.path));
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function appendLinkedDocsToStoryDefinition(definition = {}, docs = []) {
|
|
2124
|
+
return {
|
|
2125
|
+
...definition,
|
|
2126
|
+
source_synthesis: [
|
|
2127
|
+
...(definition?.source_synthesis ?? []),
|
|
2128
|
+
...docs.map((doc) => ({
|
|
2129
|
+
path: doc.path,
|
|
2130
|
+
role: classifyDocumentRole(doc),
|
|
2131
|
+
title: doc.title
|
|
2132
|
+
}))
|
|
2133
|
+
].filter((item, index, items) => items.findIndex((candidate) => candidate.path === item.path) === index)
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function classifyDocumentRole(doc) {
|
|
2138
|
+
if (doc.path.startsWith('docs/specs/')) return 'Spec正本';
|
|
2139
|
+
if (doc.path.startsWith('docs/architecture/')) return 'Architecture正本';
|
|
2140
|
+
if (doc.path.startsWith('docs/requirements/')) return 'Requirement正本';
|
|
2141
|
+
if (doc.path.startsWith('docs/user_stories/')) return 'User Story正本';
|
|
2142
|
+
if (doc.path.startsWith('docs/management/stories/')) return 'Story正本';
|
|
2143
|
+
return 'linked document';
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
function docPaths(documentSignals, key) {
|
|
2147
|
+
return selectDocs(documentSignals, key).map((doc) => doc.path);
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function uniqueList(items) {
|
|
2151
|
+
return [...new Set(items.filter(Boolean))];
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
async function readEvidence(repoRoot, manifest, fromRunId) {
|
|
2155
|
+
const warnings = [];
|
|
2156
|
+
const runs = Array.isArray(manifest?.runs) ? manifest.runs : [];
|
|
2157
|
+
const targetRun = fromRunId
|
|
2158
|
+
? runs.find((run) => run.run_id === fromRunId)
|
|
2159
|
+
: runs.find((run) => run.run_id === manifest?.latest_run) ?? runs[0];
|
|
2160
|
+
if (!targetRun?.artifacts?.evidence) return { evidence: null, warnings };
|
|
2161
|
+
try {
|
|
2162
|
+
return {
|
|
2163
|
+
evidence: JSON.parse(await readFile(path.resolve(repoRoot, targetRun.artifacts.evidence), 'utf8')),
|
|
2164
|
+
warnings
|
|
2165
|
+
};
|
|
2166
|
+
} catch (error) {
|
|
2167
|
+
if (error.code !== 'ENOENT') throw error;
|
|
2168
|
+
warnings.push({
|
|
2169
|
+
code: 'missing_evidence',
|
|
2170
|
+
run_id: targetRun.run_id,
|
|
2171
|
+
path: targetRun.artifacts.evidence,
|
|
2172
|
+
message: `manifestが参照する診断evidenceが見つからないため、診断runなしでStory Mapを生成した: ${targetRun.artifacts.evidence}`
|
|
2173
|
+
});
|
|
2174
|
+
return { evidence: null, warnings };
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
async function readGraph(repoRoot) {
|
|
2179
|
+
try {
|
|
2180
|
+
return JSON.parse(await readFile(path.join(getWorkspaceDir(repoRoot), 'graphify', 'graph.json'), 'utf8'));
|
|
2181
|
+
} catch (error) {
|
|
2182
|
+
if (error.code === 'ENOENT') return null;
|
|
2183
|
+
throw error;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
function summarizeGraph(graph) {
|
|
2188
|
+
const edges = Array.isArray(graph?.edges) ? graph.edges : Array.isArray(graph?.links) ? graph.links : [];
|
|
2189
|
+
return {
|
|
2190
|
+
node_count: Array.isArray(graph?.nodes) ? graph.nodes.length : 0,
|
|
2191
|
+
edge_count: edges.length
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function buildGraphStoryCoverage(graph, stories, preset) {
|
|
2196
|
+
if (!Array.isArray(graph?.nodes)) {
|
|
2197
|
+
return {
|
|
2198
|
+
model: 'graphify-story-coverage-v1',
|
|
2199
|
+
status: 'unavailable',
|
|
2200
|
+
reason: 'graphify graph.json が見つからないためCoverage Gateを実行できない。',
|
|
2201
|
+
totals: {
|
|
2202
|
+
graph_story_relevant_files: 0,
|
|
2203
|
+
covered_files: 0,
|
|
2204
|
+
uncovered_files: 0,
|
|
2205
|
+
coverage_ratio: null
|
|
2206
|
+
},
|
|
2207
|
+
by_role: [],
|
|
2208
|
+
uncovered: []
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
const graphFiles = summarizeGraphFiles(graph.nodes);
|
|
2213
|
+
const relevantFiles = graphFiles.filter((item) => isStoryRelevantGraphFile(item.path, preset));
|
|
2214
|
+
const coverageMatchers = buildStoryCoverageMatchers(stories, preset);
|
|
2215
|
+
const uncovered = relevantFiles
|
|
2216
|
+
.filter((item) => !isCoveredByStory(item.path, coverageMatchers))
|
|
2217
|
+
.map((item) => ({
|
|
2218
|
+
path: item.path,
|
|
2219
|
+
role: classifyStoryRelevantFile(item.path, preset),
|
|
2220
|
+
node_count: item.node_count,
|
|
2221
|
+
reason: 'graphify上は主要な画面/API/ドメインコードだが、Story根拠に紐づいていない。'
|
|
2222
|
+
}))
|
|
2223
|
+
.sort((a, b) => b.node_count - a.node_count || a.path.localeCompare(b.path));
|
|
2224
|
+
const coveredCount = relevantFiles.length - uncovered.length;
|
|
2225
|
+
const byRole = summarizeCoverageByRole(relevantFiles, uncovered, preset);
|
|
2226
|
+
|
|
2227
|
+
return {
|
|
2228
|
+
model: 'graphify-story-coverage-v1',
|
|
2229
|
+
status: uncovered.length > 0 ? 'warn' : 'pass',
|
|
2230
|
+
totals: {
|
|
2231
|
+
graph_story_relevant_files: relevantFiles.length,
|
|
2232
|
+
covered_files: coveredCount,
|
|
2233
|
+
uncovered_files: uncovered.length,
|
|
2234
|
+
coverage_ratio: relevantFiles.length === 0 ? null : Number((coveredCount / relevantFiles.length).toFixed(4))
|
|
2235
|
+
},
|
|
2236
|
+
by_role: byRole,
|
|
2237
|
+
uncovered
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
function summarizeGraphFiles(nodes) {
|
|
2242
|
+
const counts = new Map();
|
|
2243
|
+
for (const node of nodes) {
|
|
2244
|
+
const sourceFile = normalizeGraphSourceFile(node.source_file);
|
|
2245
|
+
if (!sourceFile) continue;
|
|
2246
|
+
counts.set(sourceFile, (counts.get(sourceFile) ?? 0) + 1);
|
|
2247
|
+
}
|
|
2248
|
+
return [...counts.entries()].map(([pathName, nodeCount]) => ({
|
|
2249
|
+
path: pathName,
|
|
2250
|
+
node_count: nodeCount
|
|
2251
|
+
}));
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function buildStoryCoverageMatchers(stories, preset) {
|
|
2255
|
+
const paths = new Set();
|
|
2256
|
+
const patterns = [];
|
|
2257
|
+
for (const story of stories) {
|
|
2258
|
+
for (const pathName of story.source?.paths ?? []) {
|
|
2259
|
+
if (isCodePath(pathName, preset)) paths.add(normalizeGraphSourceFile(pathName));
|
|
2260
|
+
}
|
|
2261
|
+
for (const item of story.derived?.story_definition?.source_synthesis ?? []) {
|
|
2262
|
+
if (isCodePath(item.path, preset)) paths.add(normalizeGraphSourceFile(item.path));
|
|
2263
|
+
}
|
|
2264
|
+
patterns.push(...(preset?.coveragePatterns?.[story.story_id] ?? []));
|
|
2265
|
+
}
|
|
2266
|
+
return { paths, patterns };
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
function isCoveredByStory(pathName, coverageMatchers) {
|
|
2270
|
+
if (coverageMatchers.paths.has(pathName)) return true;
|
|
2271
|
+
if ([...coverageMatchers.paths].some((coveredPath) => pathName.startsWith(`${coveredPath}/`) || coveredPath.startsWith(`${pathName}/`))) return true;
|
|
2272
|
+
return coverageMatchers.patterns.some((pattern) => pattern.test(pathName));
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
function summarizeCoverageByRole(relevantFiles, uncovered, preset) {
|
|
2276
|
+
const uncoveredByPath = new Set(uncovered.map((item) => item.path));
|
|
2277
|
+
const groups = groupBy(relevantFiles, (item) => classifyStoryRelevantFile(item.path, preset));
|
|
2278
|
+
return Object.entries(groups)
|
|
2279
|
+
.map(([role, items]) => {
|
|
2280
|
+
const uncoveredCount = items.filter((item) => uncoveredByPath.has(item.path)).length;
|
|
2281
|
+
return {
|
|
2282
|
+
role,
|
|
2283
|
+
total: items.length,
|
|
2284
|
+
covered: items.length - uncoveredCount,
|
|
2285
|
+
uncovered: uncoveredCount
|
|
2286
|
+
};
|
|
2287
|
+
})
|
|
2288
|
+
.sort((a, b) => b.uncovered - a.uncovered || a.role.localeCompare(b.role));
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
function isStoryRelevantGraphFile(filePath, preset) {
|
|
2292
|
+
if (!isCodePath(filePath, preset)) return false;
|
|
2293
|
+
if (/\.(test|spec)\.[jt]sx?$/.test(filePath)) return false;
|
|
2294
|
+
if (/\/(__tests__|test|tests)\//.test(filePath)) return false;
|
|
2295
|
+
if (/\/(ui|magicui)\//.test(filePath)) return false;
|
|
2296
|
+
if (/\/fonts\//.test(filePath)) return false;
|
|
2297
|
+
if (/\.types\.[jt]s$/.test(filePath)) return false;
|
|
2298
|
+
if (/(^|\/)(index|types|styles|constants)\.[jt]sx?$/.test(filePath)) return false;
|
|
2299
|
+
return preset.storyRelevantPatterns.some((pattern) => pattern.test(filePath));
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
function classifyStoryRelevantFile(filePath, preset) {
|
|
2303
|
+
return preset.classifyRole(filePath);
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
function isCodePath(filePath, preset) {
|
|
2307
|
+
return preset.isCodePath(filePath);
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
function normalizeGraphSourceFile(filePath) {
|
|
2311
|
+
if (typeof filePath !== 'string' || filePath.length === 0) return null;
|
|
2312
|
+
return filePath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
function findCurrentStory(config) {
|
|
2316
|
+
const stories = Array.isArray(config?.brainbase?.stories) ? config.brainbase.stories : [];
|
|
2317
|
+
return stories.find((story) => story.story_id === config?.brainbase?.current_story_id)
|
|
2318
|
+
?? stories.find((story) => !['archived', 'アーカイブ'].includes(story.status))
|
|
2319
|
+
?? null;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
function buildDefaultStoryFields(currentStory, preset, context = {}) {
|
|
2323
|
+
const today = new Date();
|
|
2324
|
+
return {
|
|
2325
|
+
view: currentStory?.view ?? 'dev',
|
|
2326
|
+
period: currentStory?.period ?? formatIsoWeek(today),
|
|
2327
|
+
started_at: currentStory?.started_at ?? formatLocalDate(today),
|
|
2328
|
+
due_at: null,
|
|
2329
|
+
preset,
|
|
2330
|
+
repoProfile: context.repoProfile ?? null,
|
|
2331
|
+
presetExplicit: context.presetExplicit === true
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function dedupeStories(stories) {
|
|
2336
|
+
const seen = new Set();
|
|
2337
|
+
const result = [];
|
|
2338
|
+
for (const story of stories) {
|
|
2339
|
+
if (seen.has(story.story_id)) continue;
|
|
2340
|
+
seen.add(story.story_id);
|
|
2341
|
+
result.push(story);
|
|
2342
|
+
}
|
|
2343
|
+
return result.sort((a, b) => {
|
|
2344
|
+
const categoryOrder = ['product', 'architecture', 'security', 'ops', 'quality', 'docs'];
|
|
2345
|
+
return categoryOrder.indexOf(a.category) - categoryOrder.indexOf(b.category) || a.story_id.localeCompare(b.story_id);
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
function formatIsoWeek(date) {
|
|
2350
|
+
const target = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
2351
|
+
const day = target.getUTCDay() || 7;
|
|
2352
|
+
target.setUTCDate(target.getUTCDate() + 4 - day);
|
|
2353
|
+
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
|
|
2354
|
+
const week = Math.ceil((((target - yearStart) / 86400000) + 1) / 7);
|
|
2355
|
+
return `${target.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
function formatLocalDate(date) {
|
|
2359
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
function currentMonth(date = new Date()) {
|
|
2363
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
function currentQuarter(date = new Date()) {
|
|
2367
|
+
return `${date.getFullYear()}Q${Math.floor(date.getMonth() / 3) + 1}`;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
function detectStoryRepoProfile(fileSet, architectureProfile = {}, files = []) {
|
|
2371
|
+
const paths = [...fileSet];
|
|
2372
|
+
const languageCounts = countFileLanguages(files);
|
|
2373
|
+
const evidence = [];
|
|
2374
|
+
const has = (pattern) => paths.some((file) => pattern.test(file));
|
|
2375
|
+
const addEvidence = (label, pattern) => {
|
|
2376
|
+
const matches = paths.filter((file) => pattern.test(file)).slice(0, 8);
|
|
2377
|
+
if (matches.length > 0) evidence.push({ label, paths: matches });
|
|
2378
|
+
return matches.length > 0;
|
|
2379
|
+
};
|
|
2380
|
+
|
|
2381
|
+
const hasNextEvidence = architectureProfile.rendering === 'nextjs'
|
|
2382
|
+
|| addEvidence('next_app_router', /^(src\/)?app\/.+\/(page|route)\.[jt]sx?$/)
|
|
2383
|
+
|| addEvidence('next_config', /^next\.config\.[cm]?[jt]s$/);
|
|
2384
|
+
if (hasNextEvidence) {
|
|
2385
|
+
return buildRepoProfile({
|
|
2386
|
+
id: 'next-app',
|
|
2387
|
+
confidence: 'high',
|
|
2388
|
+
productSurfaceApplicable: true,
|
|
2389
|
+
evidence,
|
|
2390
|
+
languageCounts,
|
|
2391
|
+
architectureProfile
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
const pythonFiles = languageCounts.python ?? 0;
|
|
2396
|
+
const jsTsFiles = (languageCounts.javascript ?? 0) + (languageCounts.typescript ?? 0);
|
|
2397
|
+
const hasPythonCliEvidence = pythonFiles > 0
|
|
2398
|
+
&& (pythonFiles >= Math.max(3, jsTsFiles * 2) || has(/^scripts\/.+\.py$/) || has(/^src\/.+\.py$/) || has(/^pyproject\.toml$/));
|
|
2399
|
+
if (hasPythonCliEvidence) {
|
|
2400
|
+
addEvidence('python_source', /^(src|scripts|pkg)\/.+\.py$|^pyproject\.toml$/);
|
|
2401
|
+
return buildRepoProfile({
|
|
2402
|
+
id: has(/^scripts\/.+\.py$/) ? 'data-pipeline' : 'python-cli',
|
|
2403
|
+
confidence: 'medium',
|
|
2404
|
+
productSurfaceApplicable: false,
|
|
2405
|
+
evidence,
|
|
2406
|
+
languageCounts,
|
|
2407
|
+
architectureProfile
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
const hasWebEvidence = architectureProfile.app_type === 'web_app'
|
|
2412
|
+
|| architectureProfile.app_type === 'static_site'
|
|
2413
|
+
|| addEvidence('web_component', /^(src\/)?components\/.+\.[jt]sx$/)
|
|
2414
|
+
|| addEvidence('web_entry', /^(src\/)?(main|App)\.[jt]sx?$|^index\.html$/);
|
|
2415
|
+
if (hasWebEvidence) {
|
|
2416
|
+
return buildRepoProfile({
|
|
2417
|
+
id: 'web',
|
|
2418
|
+
confidence: architectureProfile.app_type === 'web_app' ? 'high' : 'medium',
|
|
2419
|
+
productSurfaceApplicable: true,
|
|
2420
|
+
evidence,
|
|
2421
|
+
languageCounts,
|
|
2422
|
+
architectureProfile
|
|
2423
|
+
});
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
const hasApiEvidence = architectureProfile.has_api_routes === true
|
|
2427
|
+
|| addEvidence('api_route', /^(src\/)?(server|api|routes)\//);
|
|
2428
|
+
if (hasApiEvidence) {
|
|
2429
|
+
return buildRepoProfile({
|
|
2430
|
+
id: 'api-service',
|
|
2431
|
+
confidence: 'medium',
|
|
2432
|
+
productSurfaceApplicable: false,
|
|
2433
|
+
evidence,
|
|
2434
|
+
languageCounts,
|
|
2435
|
+
architectureProfile
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
const hasLibraryEvidence = has(/^src\/.+\.(js|ts|py|go|rs)$/) || has(/^lib\/.+\.(js|ts|py|go|rs)$/);
|
|
2440
|
+
if (hasLibraryEvidence) {
|
|
2441
|
+
addEvidence('library_source', /^(src|lib)\/.+\.(js|ts|py|go|rs)$/);
|
|
2442
|
+
return buildRepoProfile({
|
|
2443
|
+
id: 'library',
|
|
2444
|
+
confidence: 'low',
|
|
2445
|
+
productSurfaceApplicable: false,
|
|
2446
|
+
evidence,
|
|
2447
|
+
languageCounts,
|
|
2448
|
+
architectureProfile
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
return buildRepoProfile({
|
|
2453
|
+
id: 'unknown',
|
|
2454
|
+
confidence: 'low',
|
|
2455
|
+
productSurfaceApplicable: false,
|
|
2456
|
+
evidence,
|
|
2457
|
+
languageCounts,
|
|
2458
|
+
architectureProfile
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
function countFileLanguages(files) {
|
|
2463
|
+
const counts = {};
|
|
2464
|
+
for (const file of files) {
|
|
2465
|
+
const ext = path.extname(file.relativePath).toLowerCase();
|
|
2466
|
+
const language = {
|
|
2467
|
+
'.ts': 'typescript',
|
|
2468
|
+
'.tsx': 'typescript',
|
|
2469
|
+
'.js': 'javascript',
|
|
2470
|
+
'.jsx': 'javascript',
|
|
2471
|
+
'.mjs': 'javascript',
|
|
2472
|
+
'.py': 'python',
|
|
2473
|
+
'.go': 'go',
|
|
2474
|
+
'.rs': 'rust',
|
|
2475
|
+
'.rb': 'ruby',
|
|
2476
|
+
'.php': 'php'
|
|
2477
|
+
}[ext];
|
|
2478
|
+
if (!language) continue;
|
|
2479
|
+
counts[language] = (counts[language] ?? 0) + 1;
|
|
2480
|
+
}
|
|
2481
|
+
return counts;
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
function buildRepoProfile({ id, confidence, productSurfaceApplicable, evidence, languageCounts, architectureProfile }) {
|
|
2485
|
+
return {
|
|
2486
|
+
id,
|
|
2487
|
+
confidence,
|
|
2488
|
+
product_surface_applicable: productSurfaceApplicable,
|
|
2489
|
+
app_type: architectureProfile.app_type ?? 'unknown',
|
|
2490
|
+
rendering: architectureProfile.rendering ?? null,
|
|
2491
|
+
frameworks: architectureProfile.frameworks ?? [],
|
|
2492
|
+
languages: architectureProfile.languages ?? Object.keys(languageCounts).sort(),
|
|
2493
|
+
language_counts: languageCounts,
|
|
2494
|
+
evidence
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
function evidencePathForRun(manifest, runId) {
|
|
2499
|
+
const run = (manifest?.runs ?? []).find((item) => item.run_id === runId);
|
|
2500
|
+
return run?.artifacts?.evidence ?? null;
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
function groupBy(items, getKey) {
|
|
2504
|
+
return items.reduce((groups, item) => {
|
|
2505
|
+
const key = getKey(item);
|
|
2506
|
+
groups[key] = [...(groups[key] ?? []), item];
|
|
2507
|
+
return groups;
|
|
2508
|
+
}, {});
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
function renderExecutiveSummary(catalog, stories) {
|
|
2512
|
+
const viewCounts = countBy(stories, (story) => story.view ?? 'unknown');
|
|
2513
|
+
const categoryCounts = countBy(stories, (story) => story.category ?? 'unknown');
|
|
2514
|
+
const sourceCounts = countBy(stories, (story) => story.source?.type ?? 'unknown');
|
|
2515
|
+
const contractCounts = countBy(stories, (story) => story.derived?.story_contract?.status ?? 'unknown');
|
|
2516
|
+
const questionCounts = countBy(catalog.open_questions ?? [], (item) => item.field ?? 'unknown');
|
|
2517
|
+
const coverage = catalog.coverage;
|
|
2518
|
+
const warnings = catalog.source?.warnings ?? [];
|
|
2519
|
+
|
|
2520
|
+
return [
|
|
2521
|
+
`- 生成日時: ${catalog.generated_at ?? '-'}`,
|
|
2522
|
+
`- 診断run: ${catalog.source?.run_id ?? '-'}`,
|
|
2523
|
+
`- Repo profile: ${catalog.source?.repo_profile?.id ?? 'unknown'} (${catalog.source?.repo_profile?.confidence ?? 'unknown'})`,
|
|
2524
|
+
`- Preset: ${catalog.source?.preset ?? '-'} / ${catalog.source?.preset_resolution?.mode ?? 'unknown'}`,
|
|
2525
|
+
`- 警告: ${warnings.length > 0 ? warnings.map((warning) => warning.code).join(', ') : '-'}`,
|
|
2526
|
+
`- Story数: ${stories.length}`,
|
|
2527
|
+
`- View: ${formatCounts(viewCounts)}`,
|
|
2528
|
+
`- Category: ${formatCounts(categoryCounts)}`,
|
|
2529
|
+
`- Source: ${formatCounts(sourceCounts)}`,
|
|
2530
|
+
`- Story Contract: ${formatCounts(contractCounts) || '-'}`,
|
|
2531
|
+
`- Graph: nodes ${catalog.source?.graphify?.node_count ?? 0}, edges ${catalog.source?.graphify?.edge_count ?? 0}`,
|
|
2532
|
+
`- Coverage Gate: ${coverage?.status ?? 'unavailable'} (${formatCoverageRatio(coverage?.totals?.coverage_ratio)})`,
|
|
2533
|
+
`- 主な不明点: ${formatCounts(questionCounts) || '-'}`
|
|
2534
|
+
].join('\n');
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
function renderReviewQueue(catalog, stories) {
|
|
2538
|
+
const items = [];
|
|
2539
|
+
const coverage = catalog.coverage;
|
|
2540
|
+
if (coverage?.status === 'warn') {
|
|
2541
|
+
items.push(`- Graph Coverage が warn。未カバー ${coverage.totals?.uncovered_files ?? 0} / ${coverage.totals?.graph_story_relevant_files ?? 0} files。まず未カバー上位を既存Storyへ吸収するか、新Storyにするか判断する。`);
|
|
2542
|
+
} else if (coverage?.status === 'pass') {
|
|
2543
|
+
items.push('- Graph Coverage は pass。コード面の主要ファイルはStory根拠に紐づいている。');
|
|
2544
|
+
} else {
|
|
2545
|
+
items.push('- Graph Coverage は unavailable。graphify成果物を取り込んでから再生成する。');
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
const missingSpecStories = stories.filter((story) => hasOpenQuestion(story, 'missing_spec'));
|
|
2549
|
+
if (missingSpecStories.length > 0) {
|
|
2550
|
+
items.push(`- 仕様/Storyがないコード由来Storyが ${missingSpecStories.length} 件ある。優先確認: ${missingSpecStories.slice(0, 5).map((story) => story.story_id).join(', ')}`);
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
const periodUnknownCount = (catalog.open_questions ?? []).filter((item) => item.field === 'period').length;
|
|
2554
|
+
if (periodUnknownCount > 0) {
|
|
2555
|
+
items.push(`- Period未確定が ${periodUnknownCount} 件ある。NocoDB同期前に実行期を確定するか、未定として扱う方針を決める。`);
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
const contractUnknown = stories.filter((story) => story.derived?.story_contract?.status === 'needs_clarification');
|
|
2559
|
+
if (contractUnknown.length > 0) {
|
|
2560
|
+
items.push(`- Story Contract未解決が ${contractUnknown.length} 件ある。優先確認: ${contractUnknown.slice(0, 5).map((story) => story.story_id).join(', ')}`);
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
const topUncovered = (coverage?.uncovered ?? []).slice(0, 8);
|
|
2564
|
+
if (topUncovered.length > 0) {
|
|
2565
|
+
items.push('- Coverage未カバー上位:');
|
|
2566
|
+
items.push(...topUncovered.map((item) => ` - ${item.path} (${item.role}, nodes:${item.node_count})`));
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
return items.join('\n');
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
function renderStoryPortfolio(stories) {
|
|
2573
|
+
const groups = groupBy(stories, (story) => story.category ?? 'unknown');
|
|
2574
|
+
return Object.entries(groups)
|
|
2575
|
+
.sort(([a], [b]) => {
|
|
2576
|
+
const order = ['product', 'architecture', 'security', 'ops', 'quality', 'docs'];
|
|
2577
|
+
return order.indexOf(a) - order.indexOf(b) || a.localeCompare(b);
|
|
2578
|
+
})
|
|
2579
|
+
.map(([category, items]) => {
|
|
2580
|
+
const rows = items.map((story) => {
|
|
2581
|
+
const flags = storyFlags(story);
|
|
2582
|
+
const source = story.source?.type ?? '-';
|
|
2583
|
+
const period = story.period ?? '-';
|
|
2584
|
+
return `- \`${story.story_id}\` ${story.title} — ${story.view ?? '-'} / ${story.horizon ?? '-'} / period:${period} / source:${source}${flags ? ` / ${flags}` : ''}`;
|
|
2585
|
+
}).join('\n');
|
|
2586
|
+
return `### ${category} (${items.length})\n\n${rows}`;
|
|
2587
|
+
})
|
|
2588
|
+
.join('\n\n');
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
function renderStoryCard(story) {
|
|
2592
|
+
const definition = story.derived?.story_definition ?? {};
|
|
2593
|
+
const meaning = story.derived?.meaning ?? {};
|
|
2594
|
+
const storyContract = story.derived?.story_contract ?? {};
|
|
2595
|
+
const evidence = sourceSynthesisLines(definition.source_synthesis ?? [], 4);
|
|
2596
|
+
const questions = story.derived?.open_questions ?? [];
|
|
2597
|
+
const importantQuestions = questions
|
|
2598
|
+
.filter((item) => item.field !== 'period')
|
|
2599
|
+
.slice(0, 4);
|
|
2600
|
+
const periodQuestion = questions.find((item) => item.field === 'period');
|
|
2601
|
+
const questionLines = importantQuestions.length > 0
|
|
2602
|
+
? importantQuestions.map((item) => ` - ${item.field}: ${item.question}`).join('\n')
|
|
2603
|
+
: ' - -';
|
|
2604
|
+
const acceptance = Array.isArray(definition.acceptance_focus) && definition.acceptance_focus.length > 0
|
|
2605
|
+
? definition.acceptance_focus.slice(0, 4).map((item) => ` - ${item}`).join('\n')
|
|
2606
|
+
: ' - -';
|
|
2607
|
+
const meaningLines = renderMeaningLines(meaning);
|
|
2608
|
+
const contractLines = renderStoryContractLines(storyContract);
|
|
2609
|
+
|
|
2610
|
+
return `### ${story.title}
|
|
2611
|
+
|
|
2612
|
+
- Story ID: \`${story.story_id}\`
|
|
2613
|
+
- 管理: view:${story.view ?? '-'} / category:${story.category ?? '-'} / horizon:${story.horizon ?? '-'} / period:${story.period ?? '-'}
|
|
2614
|
+
- 根拠: ${story.source?.type ?? '-'}${story.source?.paths?.length ? ` (${story.source.paths.length} paths)` : ''}
|
|
2615
|
+
- Story Contract:
|
|
2616
|
+
${contractLines}
|
|
2617
|
+
- 誰のため: ${definition.who ?? '-'}
|
|
2618
|
+
- 課題: ${definition.problem ?? '-'}
|
|
2619
|
+
- 望む変化: ${definition.want ?? '-'}
|
|
2620
|
+
- 成果: ${definition.outcome ?? '-'}
|
|
2621
|
+
- 事業価値: ${definition.business_value ?? '-'}
|
|
2622
|
+
- 意味づけ:
|
|
2623
|
+
${meaningLines}
|
|
2624
|
+
- 受け入れ観点:
|
|
2625
|
+
${acceptance}
|
|
2626
|
+
- 主要根拠:
|
|
2627
|
+
${evidence}
|
|
2628
|
+
- 未決事項:
|
|
2629
|
+
${questionLines}
|
|
2630
|
+
${periodQuestion ? `- Period: ${periodQuestion.question}` : '- Period: -'}`;
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
function renderStoryContractLines(storyContract) {
|
|
2634
|
+
if (!storyContract || Object.keys(storyContract).length === 0) return ' - -';
|
|
2635
|
+
const unresolved = (storyContract.checks ?? [])
|
|
2636
|
+
.filter((check) => check.status === 'needs_clarification')
|
|
2637
|
+
.map((check) => check.id);
|
|
2638
|
+
return [
|
|
2639
|
+
` - status:${storyContract.status ?? '-'} / type:${storyContract.story_type ?? '-'}`,
|
|
2640
|
+
` - boundary:${storyContract.developer_boundary_hypothesis?.status ?? '-'} / risk:${storyContract.risk_surface_hypothesis?.level ?? '-'}`,
|
|
2641
|
+
` - verification:${storyContract.verification_strategy?.approach ?? '-'}`,
|
|
2642
|
+
` - unresolved:${unresolved.length > 0 ? unresolved.join(', ') : '-'}`
|
|
2643
|
+
].join('\n');
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
function renderMeaningLines(meaning) {
|
|
2647
|
+
if (!meaning || Object.keys(meaning).length === 0) return ' - -';
|
|
2648
|
+
const workflow = meaning.workflow_position ?? {};
|
|
2649
|
+
const counterEvidence = Array.isArray(meaning.counter_evidence) && meaning.counter_evidence.length > 0
|
|
2650
|
+
? meaning.counter_evidence.slice(0, 2).join(' / ')
|
|
2651
|
+
: '-';
|
|
2652
|
+
return [
|
|
2653
|
+
` - 価値仮説: ${meaning.value_hypothesis ?? '-'}`,
|
|
2654
|
+
` - 信頼度: actor:${meaning.user_actor?.confidence ?? '-'} / biz:${meaning.business_goal?.confidence ?? '-'} / code:${meaning.code_scope?.confidence ?? '-'} / overall:${meaning.confidence ?? '-'}`,
|
|
2655
|
+
` - 位置づけ: ${workflow.stage ?? '-'} / before:${formatStoryRefs(workflow.before)} / after:${formatStoryRefs(workflow.after)}`,
|
|
2656
|
+
` - 反証・不足: ${counterEvidence}`
|
|
2657
|
+
].join('\n');
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
function renderCoverageAppendix(coverage) {
|
|
2661
|
+
const roleRows = (coverage?.by_role ?? [])
|
|
2662
|
+
.map((item) => `| ${item.role} | ${item.total} | ${item.covered} | ${item.uncovered} |`)
|
|
2663
|
+
.join('\n');
|
|
2664
|
+
const uncoveredRows = (coverage?.uncovered ?? [])
|
|
2665
|
+
.slice(0, 50)
|
|
2666
|
+
.map((item) => `| ${item.path} | ${item.role} | ${item.node_count} |`)
|
|
2667
|
+
.join('\n');
|
|
2668
|
+
|
|
2669
|
+
return `| 項目 | 内容 |
|
|
2670
|
+
|------|------|
|
|
2671
|
+
| Status | ${coverage?.status ?? 'unavailable'} |
|
|
2672
|
+
| 対象ファイル | ${coverage?.totals?.graph_story_relevant_files ?? 0} |
|
|
2673
|
+
| Covered | ${coverage?.totals?.covered_files ?? 0} |
|
|
2674
|
+
| Uncovered | ${coverage?.totals?.uncovered_files ?? 0} |
|
|
2675
|
+
| Coverage | ${formatCoverageRatio(coverage?.totals?.coverage_ratio)} |
|
|
2676
|
+
|
|
2677
|
+
### Role別
|
|
2678
|
+
|
|
2679
|
+
| Role | Total | Covered | Uncovered |
|
|
2680
|
+
|------|-------|---------|-----------|
|
|
2681
|
+
${roleRows || '| - | 0 | 0 | 0 |'}
|
|
2682
|
+
|
|
2683
|
+
### 未カバー上位
|
|
2684
|
+
|
|
2685
|
+
| Path | Role | Nodes |
|
|
2686
|
+
|------|------|-------|
|
|
2687
|
+
${uncoveredRows || '| - | - | 0 |'}`;
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
function renderOpenQuestionsAppendix(openQuestions) {
|
|
2691
|
+
if (openQuestions.length === 0) return '-';
|
|
2692
|
+
const groups = groupBy(openQuestions, (item) => item.field ?? 'unknown');
|
|
2693
|
+
return Object.entries(groups)
|
|
2694
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
2695
|
+
.map(([field, items]) => {
|
|
2696
|
+
const rows = items
|
|
2697
|
+
.map((item) => `- \`${item.story_id}\`: ${item.question}`)
|
|
2698
|
+
.join('\n');
|
|
2699
|
+
return `### ${field} (${items.length})\n\n${rows}`;
|
|
2700
|
+
})
|
|
2701
|
+
.join('\n\n');
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
function countBy(items, getKey) {
|
|
2705
|
+
return Object.entries(groupBy(items, getKey))
|
|
2706
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
2707
|
+
.map(([key, values]) => ({ key, count: values.length }));
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
function formatCounts(counts) {
|
|
2711
|
+
return counts.map((item) => `${item.key}:${item.count}`).join(', ');
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
function storyFlags(story) {
|
|
2715
|
+
const fields = (story.derived?.open_questions ?? []).map((item) => item.field);
|
|
2716
|
+
const flags = [];
|
|
2717
|
+
if (fields.includes('missing_spec')) flags.push('missing_spec');
|
|
2718
|
+
if (fields.includes('business_metric')) flags.push('metric_unknown');
|
|
2719
|
+
if (fields.includes('period')) flags.push('period_unknown');
|
|
2720
|
+
if (story.derived?.story_contract?.status === 'needs_clarification') flags.push('contract_needs_clarification');
|
|
2721
|
+
return flags.join(', ');
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
function hasOpenQuestion(story, field) {
|
|
2725
|
+
return (story.derived?.open_questions ?? []).some((item) => item.field === field);
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
function sourceSynthesisLines(items, limit) {
|
|
2729
|
+
if (!Array.isArray(items) || items.length === 0) return ' - -';
|
|
2730
|
+
return items.slice(0, limit).map((item) => ` - ${item.path}: ${item.role}${item.title ? ` (${item.title})` : ''}`).join('\n');
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
function formatStoryRefs(items) {
|
|
2734
|
+
return Array.isArray(items) && items.length > 0 ? items.join(', ') : '-';
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
function formatSource(story) {
|
|
2738
|
+
const source = story.source ?? {};
|
|
2739
|
+
const paths = source.paths?.length ? `:${source.paths.slice(0, 2).join('<br>')}` : '';
|
|
2740
|
+
return `${source.type ?? '-'}${paths}`;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
function formatPrediction(story) {
|
|
2744
|
+
const predictions = story.derived?.predictions ?? {};
|
|
2745
|
+
return [
|
|
2746
|
+
formatPredictionItem('view', predictions.view),
|
|
2747
|
+
formatPredictionItem('horizon', predictions.horizon),
|
|
2748
|
+
formatPredictionItem('period', predictions.period)
|
|
2749
|
+
].filter(Boolean).join('<br>') || '-';
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
function formatPredictionItem(label, prediction) {
|
|
2753
|
+
if (!prediction) return null;
|
|
2754
|
+
const value = prediction.value ?? prediction.candidate ?? '-';
|
|
2755
|
+
return `${label}:${value}(${prediction.confidence ?? 'unknown'})`;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
function formatOpenQuestions(story) {
|
|
2759
|
+
const questions = story.derived?.open_questions ?? [];
|
|
2760
|
+
if (questions.length === 0) return '-';
|
|
2761
|
+
return questions.map((item) => `${item.field}: ${item.question}`).join('<br>');
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
function formatCoverageRatio(value) {
|
|
2765
|
+
if (typeof value !== 'number') return '-';
|
|
2766
|
+
return `${Math.round(value * 1000) / 10}%`;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
function renderStoryDetail(story) {
|
|
2770
|
+
const definition = story.derived?.story_definition ?? {};
|
|
2771
|
+
const meaning = story.derived?.meaning ?? {};
|
|
2772
|
+
const acceptance = Array.isArray(definition.acceptance_focus) && definition.acceptance_focus.length > 0
|
|
2773
|
+
? definition.acceptance_focus.map((item) => ` - ${item}`).join('\n')
|
|
2774
|
+
: ' - -';
|
|
2775
|
+
const sources = Array.isArray(definition.source_synthesis) && definition.source_synthesis.length > 0
|
|
2776
|
+
? definition.source_synthesis.map((item) => ` - ${item.path}: ${item.role}${item.title ? ` (${item.title})` : ''}`).join('\n')
|
|
2777
|
+
: ' - -';
|
|
2778
|
+
const openQuestions = (story.derived?.open_questions ?? []).length > 0
|
|
2779
|
+
? story.derived.open_questions.map((item) => ` - ${item.field}: ${item.question}`).join('\n')
|
|
2780
|
+
: ' - -';
|
|
2781
|
+
|
|
2782
|
+
return `### ${story.title} (${story.story_id})
|
|
2783
|
+
|
|
2784
|
+
- View: ${story.view ?? '-'}
|
|
2785
|
+
- Category: ${story.category ?? '-'}
|
|
2786
|
+
- Horizon: ${story.horizon ?? '-'}
|
|
2787
|
+
- Period: ${story.period ?? '-'}
|
|
2788
|
+
- Who: ${definition.who ?? '-'}
|
|
2789
|
+
- Problem: ${definition.problem ?? '-'}
|
|
2790
|
+
- Want: ${definition.want ?? '-'}
|
|
2791
|
+
- Outcome: ${definition.outcome ?? '-'}
|
|
2792
|
+
- Business value: ${definition.business_value ?? '-'}
|
|
2793
|
+
- Meaning:
|
|
2794
|
+
${renderMeaningLines(meaning)}
|
|
2795
|
+
- Acceptance focus:
|
|
2796
|
+
${acceptance}
|
|
2797
|
+
- Evidence synthesis:
|
|
2798
|
+
${sources}
|
|
2799
|
+
- Open questions:
|
|
2800
|
+
${openQuestions}`;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
function extractMarkdownTitle(content) {
|
|
2804
|
+
const line = content.split(/\r?\n/).find((item) => item.startsWith('# '));
|
|
2805
|
+
return line ? line.replace(/^#\s+/, '').trim() : null;
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
function humanizeFileName(filePath) {
|
|
2809
|
+
return path.basename(filePath, path.extname(filePath))
|
|
2810
|
+
.replace(/[-_]+/g, ' ')
|
|
2811
|
+
.replace(/\s+/g, ' ')
|
|
2812
|
+
.trim();
|
|
2813
|
+
}
|