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,1066 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const MAX_SCAN_FILES = 80;
|
|
5
|
+
export const CODE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
|
6
|
+
export const DEFAULT_STORY_DIRS = [
|
|
7
|
+
path.join('docs', 'user_stories', 'active'),
|
|
8
|
+
path.join('docs', 'user_stories'),
|
|
9
|
+
path.join('docs', 'management', 'stories', 'active'),
|
|
10
|
+
path.join('docs', 'management', 'stories'),
|
|
11
|
+
path.join('docs', 'stories')
|
|
12
|
+
];
|
|
13
|
+
export const STORY_DIR_PREFIXES = DEFAULT_STORY_DIRS.map((dir) => dir.split(path.sep).join('/'));
|
|
14
|
+
const REQUIREMENT_SOURCE_DIRS = [
|
|
15
|
+
{ kind: 'spec', dir: path.join('docs', 'specs') },
|
|
16
|
+
{ kind: 'spec', dir: path.join('docs', 'features', 'specifications') },
|
|
17
|
+
{ kind: 'architecture', dir: path.join('docs', 'architecture') },
|
|
18
|
+
{ kind: 'architecture', dir: path.join('docs', 'management', 'architecture') },
|
|
19
|
+
{ kind: 'policy', dir: path.join('docs', 'management', 'policies') },
|
|
20
|
+
{ kind: 'policy', dir: path.join('docs', 'frames') },
|
|
21
|
+
{ kind: 'policy', dir: path.join('docs', '00-glossary') }
|
|
22
|
+
];
|
|
23
|
+
export const INVARIANT_PATTERNS = [
|
|
24
|
+
/\bmust\b/i,
|
|
25
|
+
/\bshall\b/i,
|
|
26
|
+
/\bnever\b/i,
|
|
27
|
+
/\bkeep\b/i,
|
|
28
|
+
/\buntil\b/i,
|
|
29
|
+
/必ず/,
|
|
30
|
+
/維持/,
|
|
31
|
+
/保持/,
|
|
32
|
+
/禁止/,
|
|
33
|
+
/してはいけない/,
|
|
34
|
+
/変えない/,
|
|
35
|
+
/一致/,
|
|
36
|
+
/同じ/,
|
|
37
|
+
/期間終了/,
|
|
38
|
+
/認可/,
|
|
39
|
+
/署名/,
|
|
40
|
+
/重複/,
|
|
41
|
+
/正規化/,
|
|
42
|
+
/1件/,
|
|
43
|
+
/一意/,
|
|
44
|
+
/分離/,
|
|
45
|
+
/境界/,
|
|
46
|
+
/責務/,
|
|
47
|
+
/扱う/,
|
|
48
|
+
/premium|プレミアム/i,
|
|
49
|
+
/subscription|サブスクリプション/i
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
export const DOMAIN_KEYWORDS = [
|
|
53
|
+
'auth',
|
|
54
|
+
'認証',
|
|
55
|
+
'認可',
|
|
56
|
+
'session',
|
|
57
|
+
'user',
|
|
58
|
+
'identity',
|
|
59
|
+
'billing',
|
|
60
|
+
'stripe',
|
|
61
|
+
'subscription',
|
|
62
|
+
'premium',
|
|
63
|
+
'webhook',
|
|
64
|
+
'署名',
|
|
65
|
+
'onboarding',
|
|
66
|
+
'profile'
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const GENERIC_CONDITION_TOKENS = new Set([
|
|
70
|
+
'true',
|
|
71
|
+
'false',
|
|
72
|
+
'null',
|
|
73
|
+
'undefined',
|
|
74
|
+
'return',
|
|
75
|
+
'status',
|
|
76
|
+
'state',
|
|
77
|
+
'error',
|
|
78
|
+
'result',
|
|
79
|
+
'value',
|
|
80
|
+
'data',
|
|
81
|
+
'body',
|
|
82
|
+
'function'
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const INHERITED_BEHAVIOR_PATTERNS = [
|
|
86
|
+
/\binherited\b/i,
|
|
87
|
+
/\bexisting\b/i,
|
|
88
|
+
/\bunchanged\b/i,
|
|
89
|
+
/\bremain(?:s|ed)?\b/i,
|
|
90
|
+
/\bcontinue(?:s|d)?\b/i,
|
|
91
|
+
/\bas before\b/i,
|
|
92
|
+
/\bdo not change\b/i,
|
|
93
|
+
/\bnot changed\b/i,
|
|
94
|
+
/既存/,
|
|
95
|
+
/維持/,
|
|
96
|
+
/変更しない/,
|
|
97
|
+
/従来/,
|
|
98
|
+
/そのまま/
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
export async function buildRequirementConsistency(repoRoot, options = {}) {
|
|
102
|
+
const root = path.resolve(repoRoot);
|
|
103
|
+
const codeFiles = await resolveCodeFiles(root, options);
|
|
104
|
+
const storySource = await resolveStorySource(root, options);
|
|
105
|
+
const requirementSources = await collectRequirementSources(root, {
|
|
106
|
+
story: options.story,
|
|
107
|
+
storySource,
|
|
108
|
+
codeFiles
|
|
109
|
+
});
|
|
110
|
+
const policyRefs = requirementSources.filter((source) => source.kind === 'policy');
|
|
111
|
+
const invariants = options.inferredSpec
|
|
112
|
+
? extractInvariantsFromInferredSpec(options.inferredSpec, storySource)
|
|
113
|
+
: extractInvariants(storySource, requirementSources);
|
|
114
|
+
const codeScenarios = await collectCodeScenarios(root, codeFiles);
|
|
115
|
+
const scenarioGaps = buildScenarioGaps({
|
|
116
|
+
invariants,
|
|
117
|
+
codeScenarios,
|
|
118
|
+
storySource,
|
|
119
|
+
requirementSources,
|
|
120
|
+
inferredSpec: options.inferredSpec
|
|
121
|
+
});
|
|
122
|
+
const contradictions = buildContradictions({ invariants, codeScenarios, storySource });
|
|
123
|
+
const status = contradictions.length > 0
|
|
124
|
+
? 'contradicted'
|
|
125
|
+
: scenarioGaps.length > 0
|
|
126
|
+
? 'needs_review'
|
|
127
|
+
: invariants.length > 0
|
|
128
|
+
? 'pass'
|
|
129
|
+
: 'not_applicable';
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
schema_version: '0.1.0',
|
|
133
|
+
status,
|
|
134
|
+
story_source: {
|
|
135
|
+
path: storySource?.path ?? null,
|
|
136
|
+
title: storySource?.title ?? null
|
|
137
|
+
},
|
|
138
|
+
summary: {
|
|
139
|
+
invariant_count: invariants.length,
|
|
140
|
+
scenario_gap_count: scenarioGaps.length,
|
|
141
|
+
contradiction_count: contradictions.length,
|
|
142
|
+
scanned_code_files: codeScenarios.length,
|
|
143
|
+
requirement_source_count: requirementSources.length,
|
|
144
|
+
spec_ref_count: requirementSources.filter((source) => source.kind === 'spec').length,
|
|
145
|
+
architecture_ref_count: requirementSources.filter((source) => source.kind === 'architecture').length,
|
|
146
|
+
policy_ref_count: policyRefs.length
|
|
147
|
+
},
|
|
148
|
+
invariants,
|
|
149
|
+
scenario_gaps: scenarioGaps,
|
|
150
|
+
contradictions,
|
|
151
|
+
requirement_sources: requirementSources.map(toRequirementSourceRef),
|
|
152
|
+
policy_refs: policyRefs.map(toRequirementSourceRef),
|
|
153
|
+
code_scenarios: codeScenarios
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function renderRequirementConsistencyReport(requirement) {
|
|
158
|
+
if (!requirement) return '# Requirement Consistency\n\n- 未生成\n';
|
|
159
|
+
return `# Requirement Consistency
|
|
160
|
+
|
|
161
|
+
| 項目 | 内容 |
|
|
162
|
+
|------|------|
|
|
163
|
+
| Status | ${requirement.status} |
|
|
164
|
+
| Invariants | ${requirement.summary?.invariant_count ?? 0} |
|
|
165
|
+
| Scenario Gaps | ${requirement.summary?.scenario_gap_count ?? 0} |
|
|
166
|
+
| Contradictions | ${requirement.summary?.contradiction_count ?? 0} |
|
|
167
|
+
| Scanned Code Files | ${requirement.summary?.scanned_code_files ?? 0} |
|
|
168
|
+
| Requirement Sources | ${requirement.summary?.requirement_source_count ?? 0} |
|
|
169
|
+
| Spec Refs | ${requirement.summary?.spec_ref_count ?? 0} |
|
|
170
|
+
| Architecture Refs | ${requirement.summary?.architecture_ref_count ?? 0} |
|
|
171
|
+
| Policy Refs | ${requirement.summary?.policy_ref_count ?? 0} |
|
|
172
|
+
|
|
173
|
+
## Invariants
|
|
174
|
+
|
|
175
|
+
${formatItems(requirement.invariants, (item) => `- ${item.id}: ${item.text} (${formatSourceRef(item.source)})`)}
|
|
176
|
+
|
|
177
|
+
## Scenario Gaps
|
|
178
|
+
|
|
179
|
+
${formatItems(requirement.scenario_gaps, (item) => `- ${item.id}: ${item.title} - ${item.detail}`)}
|
|
180
|
+
|
|
181
|
+
## Potential Contradictions
|
|
182
|
+
|
|
183
|
+
${formatItems(requirement.contradictions, (item) => `- ${item.id}: ${item.title} - ${item.detail}`)}
|
|
184
|
+
|
|
185
|
+
## Requirement Sources
|
|
186
|
+
|
|
187
|
+
${formatItems(requirement.requirement_sources, (item) => `- ${item.kind}: ${item.path}: ${item.title ?? '-'}`)}
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function renderRequirementGateSummary(requirement) {
|
|
192
|
+
if (!requirement) return '- Requirement Gate: not_generated';
|
|
193
|
+
const detail = [
|
|
194
|
+
`${requirement.summary?.invariant_count ?? 0} invariants`,
|
|
195
|
+
`${requirement.summary?.scenario_gap_count ?? 0} scenario gaps`,
|
|
196
|
+
`${requirement.summary?.contradiction_count ?? 0} contradictions`
|
|
197
|
+
].join(', ');
|
|
198
|
+
return `- Requirement Gate: ${requirement.status} - ${detail}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function resolveStoryDirs(repoRoot) {
|
|
202
|
+
try {
|
|
203
|
+
const { getWorkspaceDir } = await import('./workspace.js');
|
|
204
|
+
const configPath = path.join(getWorkspaceDir(repoRoot), 'config.json');
|
|
205
|
+
const raw = await readFile(configPath, 'utf8');
|
|
206
|
+
const config = JSON.parse(raw);
|
|
207
|
+
const override = config?.doc_paths?.stories;
|
|
208
|
+
if (Array.isArray(override) && override.length > 0) {
|
|
209
|
+
return override.map((entry) => String(entry));
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error.code !== 'ENOENT') {
|
|
213
|
+
// fall through to defaults
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return [...DEFAULT_STORY_DIRS];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function findStorySource(repoRoot, story) {
|
|
220
|
+
const storyId = story?.story_id ?? null;
|
|
221
|
+
const storyDirs = await resolveStoryDirs(repoRoot);
|
|
222
|
+
const candidates = [];
|
|
223
|
+
for (const dir of storyDirs) {
|
|
224
|
+
const files = await listFiles(path.join(repoRoot, dir));
|
|
225
|
+
candidates.push(...files.filter((file) => /\.(md|mdx)$/i.test(file)));
|
|
226
|
+
}
|
|
227
|
+
if (candidates.length === 0) {
|
|
228
|
+
return {
|
|
229
|
+
path: null,
|
|
230
|
+
title: story?.title ?? null,
|
|
231
|
+
content: '',
|
|
232
|
+
acceptance_criteria: [],
|
|
233
|
+
background: null,
|
|
234
|
+
policy: null
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (storyId) {
|
|
238
|
+
const byFrontmatter = await findCandidateByFrontmatter(repoRoot, candidates, storyId);
|
|
239
|
+
if (byFrontmatter) return parseStoryLikeDocument(repoRoot, byFrontmatter, 'story');
|
|
240
|
+
const bySubstring = candidates.find((file) => normalizePath(file).includes(storyId));
|
|
241
|
+
if (bySubstring) return parseStoryLikeDocument(repoRoot, bySubstring, 'story');
|
|
242
|
+
return {
|
|
243
|
+
path: null,
|
|
244
|
+
title: story?.title ?? null,
|
|
245
|
+
content: '',
|
|
246
|
+
acceptance_criteria: [],
|
|
247
|
+
background: null,
|
|
248
|
+
policy: null
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return parseStoryLikeDocument(repoRoot, candidates[0], 'story');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function findCandidateByFrontmatter(repoRoot, candidates, storyId) {
|
|
255
|
+
for (const file of candidates) {
|
|
256
|
+
let content;
|
|
257
|
+
try {
|
|
258
|
+
content = await readFile(file, 'utf8');
|
|
259
|
+
} catch {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const frontmatter = parseFrontmatter(content);
|
|
263
|
+
const candidateIds = [
|
|
264
|
+
frontmatter.story_id,
|
|
265
|
+
frontmatter.vibepro_story_id,
|
|
266
|
+
frontmatter.story_ref,
|
|
267
|
+
frontmatter.story,
|
|
268
|
+
frontmatter.requirement_id
|
|
269
|
+
].filter(Boolean).map((value) => String(value));
|
|
270
|
+
if (candidateIds.includes(storyId)) return file;
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function resolveStorySource(repoRoot, options) {
|
|
276
|
+
if (options.storySource?.path) {
|
|
277
|
+
try {
|
|
278
|
+
const parsed = await parseStoryLikeDocument(repoRoot, options.storySource.path, 'story');
|
|
279
|
+
return {
|
|
280
|
+
...parsed,
|
|
281
|
+
...Object.fromEntries(Object.entries(options.storySource).filter(([, value]) => value !== null && value !== undefined)),
|
|
282
|
+
content: parsed.content
|
|
283
|
+
};
|
|
284
|
+
} catch {
|
|
285
|
+
return { ...options.storySource, kind: 'story', content: options.storySource.content ?? '' };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return options.storySource
|
|
289
|
+
? { ...options.storySource, kind: 'story', content: options.storySource.content ?? '' }
|
|
290
|
+
: findStorySource(repoRoot, options.story);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function collectRequirementSources(repoRoot, { story, storySource, codeFiles }) {
|
|
294
|
+
const docs = [];
|
|
295
|
+
for (const sourceDir of REQUIREMENT_SOURCE_DIRS) {
|
|
296
|
+
const files = await listFiles(path.join(repoRoot, sourceDir.dir));
|
|
297
|
+
docs.push(...files
|
|
298
|
+
.filter((file) => /\.(md|mdx)$/i.test(file))
|
|
299
|
+
.map((file) => ({ file, kind: sourceDir.kind })));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const linkedPaths = new Set(extractLinkedDocPaths(storySource?.content ?? ''));
|
|
303
|
+
const storyId = story?.story_id ?? storySource?.story_id ?? null;
|
|
304
|
+
const sourceText = [
|
|
305
|
+
storyId,
|
|
306
|
+
storySource?.title,
|
|
307
|
+
storySource?.background,
|
|
308
|
+
storySource?.policy,
|
|
309
|
+
...(storySource?.acceptance_criteria ?? []),
|
|
310
|
+
...codeFiles
|
|
311
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
312
|
+
const sourceKeywords = DOMAIN_KEYWORDS.filter((keyword) => sourceText.includes(keyword.toLowerCase()));
|
|
313
|
+
const refs = [];
|
|
314
|
+
const seen = new Set();
|
|
315
|
+
for (const { file, kind } of docs.slice(0, 240)) {
|
|
316
|
+
const parsed = await parseStoryLikeDocument(repoRoot, file, kind);
|
|
317
|
+
if (seen.has(parsed.path) || parsed.path === storySource?.path) continue;
|
|
318
|
+
const linked = linkedPaths.has(parsed.path) || linkedPaths.has(`./${parsed.path}`);
|
|
319
|
+
const storyMatched = storyId && parsed.frontmatter?.story_id === storyId;
|
|
320
|
+
const refMatched = storyId && [
|
|
321
|
+
parsed.frontmatter?.story_ref,
|
|
322
|
+
parsed.frontmatter?.story,
|
|
323
|
+
parsed.frontmatter?.requirement_id
|
|
324
|
+
].filter(Boolean).some((value) => String(value) === storyId);
|
|
325
|
+
const haystack = [parsed.path, parsed.title, parsed.content.slice(0, 1600)].filter(Boolean).join(' ').toLowerCase();
|
|
326
|
+
const invariantHints = extractInvariantTexts(parsed).slice(0, 6);
|
|
327
|
+
const keywordHits = sourceKeywords.filter((keyword) => haystack.includes(keyword.toLowerCase()));
|
|
328
|
+
const keywordMatched = sourceKeywords.length > 0
|
|
329
|
+
&& keywordHits.length >= 2
|
|
330
|
+
&& invariantHints.length > 0;
|
|
331
|
+
if (!linked && !storyMatched && !refMatched && !keywordMatched) continue;
|
|
332
|
+
seen.add(parsed.path);
|
|
333
|
+
refs.push({
|
|
334
|
+
...parsed,
|
|
335
|
+
linked_from_story: linked,
|
|
336
|
+
matched_by_story_id: Boolean(storyMatched || refMatched),
|
|
337
|
+
invariant_hints: invariantHints
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return refs.sort(compareRequirementSources).slice(0, 20);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function toRequirementSourceRef(source) {
|
|
344
|
+
return {
|
|
345
|
+
kind: source.kind ?? inferSourceKind(source.path),
|
|
346
|
+
path: source.path ?? null,
|
|
347
|
+
title: source.title ?? null,
|
|
348
|
+
linked_from_story: source.linked_from_story === true,
|
|
349
|
+
matched_by_story_id: source.matched_by_story_id === true,
|
|
350
|
+
invariant_count: source.invariant_hints?.length ?? 0
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function formatSourceRef(source) {
|
|
355
|
+
if (!source) return 'source:unknown';
|
|
356
|
+
const kind = source.kind ?? inferSourceKind(source.path);
|
|
357
|
+
return `${kind}:${source.path ?? '-'}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function inferSourceKind(filePath) {
|
|
361
|
+
const normalized = normalizePath(filePath ?? '');
|
|
362
|
+
if (
|
|
363
|
+
normalized.includes('/stories/')
|
|
364
|
+
|| normalized.includes('/user_stories/')
|
|
365
|
+
|| normalized.startsWith('user_stories/')
|
|
366
|
+
|| normalized.startsWith('docs/management/stories/')
|
|
367
|
+
) {
|
|
368
|
+
return 'story';
|
|
369
|
+
}
|
|
370
|
+
if (normalized.startsWith('docs/specs/') || normalized.startsWith('docs/features/specifications/')) return 'spec';
|
|
371
|
+
if (
|
|
372
|
+
normalized.startsWith('docs/architecture/')
|
|
373
|
+
|| normalized.startsWith('docs/management/architecture/')
|
|
374
|
+
|| /^docs\/.+\/ADR-[^/]+\.md$/i.test(normalized)
|
|
375
|
+
) {
|
|
376
|
+
return 'architecture';
|
|
377
|
+
}
|
|
378
|
+
if (
|
|
379
|
+
normalized.startsWith('docs/management/policies/')
|
|
380
|
+
|| normalized.startsWith('docs/frames/')
|
|
381
|
+
|| normalized.startsWith('docs/00-glossary/')
|
|
382
|
+
) {
|
|
383
|
+
return 'policy';
|
|
384
|
+
}
|
|
385
|
+
return 'requirement';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function isStoryDocPath(filePath) {
|
|
389
|
+
const normalized = normalizePath(filePath ?? '');
|
|
390
|
+
if (!normalized) return false;
|
|
391
|
+
for (const prefix of STORY_DIR_PREFIXES) {
|
|
392
|
+
if (normalized.startsWith(`${prefix}/`)) return true;
|
|
393
|
+
}
|
|
394
|
+
return /^docs\/.+\/stories\//.test(normalized);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function extractLinkedDocPaths(content) {
|
|
398
|
+
const refs = new Set();
|
|
399
|
+
const source = String(content ?? '');
|
|
400
|
+
const patterns = [
|
|
401
|
+
/\[[^\]]+\]\(([^)\s]+\.mdx?)(?:#[^)]+)?\)/gi,
|
|
402
|
+
/\b(?:path|doc|file|adr|specification|architecture)\s*:\s*['"]?([^'"\n]+\.mdx?)(?:#[^\s'"]*)?['"]?/gi,
|
|
403
|
+
/\b(docs\/[^\s)'"]+\.mdx?)(?:#[^\s)'"]*)?/gi
|
|
404
|
+
];
|
|
405
|
+
for (const pattern of patterns) {
|
|
406
|
+
for (const match of source.matchAll(pattern)) {
|
|
407
|
+
const cleaned = cleanupLinkedPath(match[1]);
|
|
408
|
+
if (cleaned) refs.add(cleaned);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return [...refs];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function cleanupLinkedPath(value) {
|
|
415
|
+
const cleaned = normalizePath(String(value ?? '')
|
|
416
|
+
.trim()
|
|
417
|
+
.replace(/^['"]|['"]$/g, '')
|
|
418
|
+
.replace(/[),.。]$/g, '')
|
|
419
|
+
.split('#')[0]
|
|
420
|
+
.split('?')[0]);
|
|
421
|
+
if (!cleaned || /^n\/?a$/i.test(cleaned) || cleaned === '-') return null;
|
|
422
|
+
return cleaned;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function compareRequirementSources(a, b) {
|
|
426
|
+
if (a.linked_from_story !== b.linked_from_story) return a.linked_from_story ? -1 : 1;
|
|
427
|
+
const kindOrder = { spec: 0, architecture: 1, policy: 2, requirement: 3 };
|
|
428
|
+
const kindDelta = (kindOrder[a.kind] ?? 9) - (kindOrder[b.kind] ?? 9);
|
|
429
|
+
if (kindDelta !== 0) return kindDelta;
|
|
430
|
+
return String(a.path ?? '').localeCompare(String(b.path ?? ''));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function extractInvariantsFromInferredSpec(spec, storySource) {
|
|
434
|
+
if (!spec || !Array.isArray(spec.clauses)) return [];
|
|
435
|
+
return spec.clauses
|
|
436
|
+
.filter((clause) => clause && typeof clause.statement === 'string')
|
|
437
|
+
.map((clause) => ({
|
|
438
|
+
id: clause.id,
|
|
439
|
+
text: clause.statement.slice(0, 240),
|
|
440
|
+
source: {
|
|
441
|
+
kind: 'inferred_spec',
|
|
442
|
+
path: storySource?.path ?? null,
|
|
443
|
+
clause_type: clause.type ?? 'invariant'
|
|
444
|
+
}
|
|
445
|
+
}))
|
|
446
|
+
.slice(0, 32);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function extractInvariants(storySource, requirementSources) {
|
|
450
|
+
const storyInvariants = extractInvariantTexts(storySource).map((text, index) => ({
|
|
451
|
+
id: `REQ-INV-${String(index + 1).padStart(3, '0')}`,
|
|
452
|
+
text,
|
|
453
|
+
source: { kind: 'story', path: storySource?.path ?? null }
|
|
454
|
+
}));
|
|
455
|
+
const sourceInvariants = requirementSources
|
|
456
|
+
.flatMap((source) => (source.invariant_hints ?? []).map((text) => ({
|
|
457
|
+
text,
|
|
458
|
+
source: { kind: source.kind, path: source.path }
|
|
459
|
+
})))
|
|
460
|
+
.filter((item) => !storyInvariants.some((invariant) => normalizeText(invariant.text) === normalizeText(item.text)))
|
|
461
|
+
.map((item, index) => ({
|
|
462
|
+
id: `REQ-SRC-${String(index + 1).padStart(3, '0')}`,
|
|
463
|
+
...item
|
|
464
|
+
}));
|
|
465
|
+
return [...storyInvariants, ...sourceInvariants].slice(0, 24);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function extractInvariantTexts(doc) {
|
|
469
|
+
const sourceKind = doc?.kind ?? inferSourceKind(doc?.path);
|
|
470
|
+
const content = [
|
|
471
|
+
doc?.policy,
|
|
472
|
+
...(doc?.acceptance_criteria ?? []),
|
|
473
|
+
...(sourceKind === 'architecture' && doc?.content ? extractDecisionLines(doc.content) : []),
|
|
474
|
+
...(doc?.content ? extractImportantLines(doc.content) : [])
|
|
475
|
+
].filter(Boolean);
|
|
476
|
+
const values = [];
|
|
477
|
+
for (const text of content) {
|
|
478
|
+
for (const sentence of splitSentences(text)) {
|
|
479
|
+
const clean = cleanupLine(sentence);
|
|
480
|
+
if (!clean || clean.length < 8) continue;
|
|
481
|
+
if (isDiagnosticNarrative(clean)) continue;
|
|
482
|
+
if (INVARIANT_PATTERNS.some((pattern) => pattern.test(clean))) {
|
|
483
|
+
values.push(clean);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return [...new Set(values.map((item) => item.slice(0, 240)))].slice(0, 12);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function extractDecisionLines(content) {
|
|
491
|
+
return [
|
|
492
|
+
extractRawSection(content, ['Decision', '判断', '決定']),
|
|
493
|
+
extractRawSection(content, ['Consequences', '影響', '結果', '制約'])
|
|
494
|
+
]
|
|
495
|
+
.filter(Boolean)
|
|
496
|
+
.flatMap((section) => section.split('\n'))
|
|
497
|
+
.map((line) => line.trim())
|
|
498
|
+
.filter((line) => /^[-*]\s+/.test(line))
|
|
499
|
+
.map((line) => line.replace(/^[-*]\s+/, ''))
|
|
500
|
+
.slice(0, 24);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function isDiagnosticNarrative(text) {
|
|
504
|
+
return /^--/.test(text)
|
|
505
|
+
|| /npm\s+(?:run\s+)?(?:test|type-?check)|vibepro|graphify|diagnostic|diagnose|hotspot|refactor|runtime file|責務混在|診断|候補|スコア|出現|差分/.test(String(text).toLowerCase());
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function extractImportantLines(content) {
|
|
509
|
+
return stripFrontmatter(content)
|
|
510
|
+
.split('\n')
|
|
511
|
+
.map((line) => line.trim())
|
|
512
|
+
.filter((line) => /^[-*]\s+/.test(line))
|
|
513
|
+
.map((line) => line.replace(/^[-*]\s+/, ''))
|
|
514
|
+
.slice(0, 80);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export async function resolveCodeFiles(repoRoot, options) {
|
|
518
|
+
const files = options.files?.length > 0
|
|
519
|
+
? options.files
|
|
520
|
+
: options.fileGroups
|
|
521
|
+
? [...(options.fileGroups.source?.files ?? [])]
|
|
522
|
+
: await resolveInferredSpecCodeFiles(repoRoot, options.inferredSpec);
|
|
523
|
+
const effectiveFiles = files.length > 0 ? files : await listLikelyRuntimeFiles(repoRoot);
|
|
524
|
+
return [...new Set(effectiveFiles.map(normalizePath))]
|
|
525
|
+
.filter((file) => CODE_EXTENSIONS.has(path.extname(file)))
|
|
526
|
+
.filter((file) => !file.includes('/node_modules/') && !file.startsWith('.vibepro/'))
|
|
527
|
+
.slice(0, MAX_SCAN_FILES);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function resolveInferredSpecCodeFiles(repoRoot, spec) {
|
|
531
|
+
const patterns = extractInferredSpecFilePatterns(spec);
|
|
532
|
+
if (patterns.length === 0) return [];
|
|
533
|
+
const exactFiles = patterns.filter((file) => !file.includes('*'));
|
|
534
|
+
const globPatterns = patterns.filter((file) => file.includes('*'));
|
|
535
|
+
if (globPatterns.length === 0) return exactFiles;
|
|
536
|
+
const repoFiles = (await listFiles(repoRoot))
|
|
537
|
+
.filter((file) => CODE_EXTENSIONS.has(path.extname(file)))
|
|
538
|
+
.map((file) => normalizePath(path.relative(repoRoot, file)));
|
|
539
|
+
return [
|
|
540
|
+
...exactFiles,
|
|
541
|
+
...repoFiles.filter((file) => globPatterns.some((pattern) => pathPatternMatches(pattern, file)))
|
|
542
|
+
];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function extractInferredSpecFilePatterns(spec) {
|
|
546
|
+
if (!spec || !Array.isArray(spec.clauses)) return [];
|
|
547
|
+
return [...new Set(spec.clauses.flatMap((clause) => [
|
|
548
|
+
...(Array.isArray(clause?.origin?.code_refs) ? clause.origin.code_refs.map((ref) => ref?.file) : []),
|
|
549
|
+
...(Array.isArray(clause?.verifiable_by?.code_pattern) ? clause.verifiable_by.code_pattern.map((pattern) => pattern?.file_glob) : [])
|
|
550
|
+
])
|
|
551
|
+
.filter(Boolean)
|
|
552
|
+
.map(normalizePath))];
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function listLikelyRuntimeFiles(repoRoot) {
|
|
556
|
+
const roots = [
|
|
557
|
+
path.join(repoRoot, 'src', 'app', 'api'),
|
|
558
|
+
path.join(repoRoot, 'src', 'lib', 'services'),
|
|
559
|
+
path.join(repoRoot, 'src', 'lib', 'actions')
|
|
560
|
+
];
|
|
561
|
+
const files = [];
|
|
562
|
+
for (const root of roots) {
|
|
563
|
+
files.push(...await listFiles(root));
|
|
564
|
+
}
|
|
565
|
+
return files
|
|
566
|
+
.filter((file) => CODE_EXTENSIONS.has(path.extname(file)))
|
|
567
|
+
.map((file) => normalizePath(path.relative(repoRoot, file)))
|
|
568
|
+
.slice(0, MAX_SCAN_FILES);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export async function collectCodeScenarios(repoRoot, files) {
|
|
572
|
+
const scenarios = [];
|
|
573
|
+
for (const file of files) {
|
|
574
|
+
const absolute = path.join(repoRoot, file);
|
|
575
|
+
let content = '';
|
|
576
|
+
try {
|
|
577
|
+
content = await readFile(absolute, 'utf8');
|
|
578
|
+
} catch {
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const code = stripBlockComments(content);
|
|
582
|
+
scenarios.push({
|
|
583
|
+
file,
|
|
584
|
+
branches: extractBranches(code),
|
|
585
|
+
state_transitions: extractStateTransitions(code),
|
|
586
|
+
external_effects: extractExternalEffects(code),
|
|
587
|
+
response_messages: extractResponseMessages(code),
|
|
588
|
+
domain_keywords: DOMAIN_KEYWORDS.filter((keyword) => code.toLowerCase().includes(keyword.toLowerCase()))
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
return scenarios.filter((scenario) => (
|
|
592
|
+
scenario.branches.length > 0
|
|
593
|
+
|| scenario.state_transitions.length > 0
|
|
594
|
+
|| scenario.external_effects.length > 0
|
|
595
|
+
|| scenario.response_messages.length > 0
|
|
596
|
+
));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function extractBranches(content) {
|
|
600
|
+
const branches = [];
|
|
601
|
+
for (const match of content.matchAll(/\bif\s*\(([^)]{1,180})\)/g)) {
|
|
602
|
+
branches.push({ kind: 'if', condition: cleanupLine(match[1]) });
|
|
603
|
+
}
|
|
604
|
+
for (const match of content.matchAll(/\bcase\s+([^:]{1,120}):/g)) {
|
|
605
|
+
branches.push({ kind: 'case', condition: cleanupLine(match[1]) });
|
|
606
|
+
}
|
|
607
|
+
return branches.slice(0, 30);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function extractStateTransitions(content) {
|
|
611
|
+
const transitions = [];
|
|
612
|
+
const patterns = [
|
|
613
|
+
{ key: 'userType', pattern: /\bUserType\s*:\s*([0-9]+)/g },
|
|
614
|
+
{ key: 'userType', pattern: /\buserType\s*:\s*([0-9]+)/g },
|
|
615
|
+
{ key: 'cancelAtPeriodEnd', pattern: /\bcancel(?:_|A)tPeriodEnd\s*:\s*(true|false)/gi },
|
|
616
|
+
{ key: 'subscriptionCancelAtPeriodEnd', pattern: /\bSubscriptionCancelAtPeriodEnd\s*:\s*(true|false)/g },
|
|
617
|
+
{ key: 'status', pattern: /\bstatus\s*:\s*['"]([^'"]+)['"]/g }
|
|
618
|
+
];
|
|
619
|
+
for (const { key, pattern } of patterns) {
|
|
620
|
+
for (const match of content.matchAll(pattern)) {
|
|
621
|
+
transitions.push({ key, value: match[1] });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return transitions.slice(0, 30);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function extractExternalEffects(content) {
|
|
628
|
+
const effects = [];
|
|
629
|
+
const patterns = [
|
|
630
|
+
{ type: 'db_update', pattern: /\bprisma\.[A-Za-z0-9_$.]+\.update(?:Many)?\s*\(/g },
|
|
631
|
+
{ type: 'db_delete', pattern: /\bprisma\.[A-Za-z0-9_$.]+\.delete(?:Many)?\s*\(/g },
|
|
632
|
+
{ type: 'stripe_subscription_update', pattern: /\bstripe\.subscriptions\.update\s*\(/g },
|
|
633
|
+
{ type: 'webhook_signature', pattern: /\bconstructEvent|verify(?:Webhook|Signature)?\b/g },
|
|
634
|
+
{ type: 'notification', pattern: /\b(send|notify|notification|email|resend)\b/gi }
|
|
635
|
+
];
|
|
636
|
+
for (const { type, pattern } of patterns) {
|
|
637
|
+
for (const match of content.matchAll(pattern)) {
|
|
638
|
+
effects.push({ type, evidence: match[0] });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return effects.slice(0, 30);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function extractResponseMessages(content) {
|
|
645
|
+
return [...content.matchAll(/message\s*:\s*['"`]([^'"`]{4,120})['"`]/g)]
|
|
646
|
+
.map((match) => match[1])
|
|
647
|
+
.slice(0, 20);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function buildScenarioGaps({ invariants, codeScenarios, storySource, requirementSources, inferredSpec = null }) {
|
|
651
|
+
if (invariants.length === 0) return [];
|
|
652
|
+
const gaps = [];
|
|
653
|
+
const inferredSpecContext = buildInferredSpecContext(inferredSpec);
|
|
654
|
+
const scopeContext = buildRequirementScopeContext({ storySource, requirementSources, inferredSpecContext });
|
|
655
|
+
const acceptanceText = [
|
|
656
|
+
...(storySource?.acceptance_criteria ?? []),
|
|
657
|
+
storySource?.policy,
|
|
658
|
+
...requirementSources.flatMap((source) => [
|
|
659
|
+
...(source.acceptance_criteria ?? []),
|
|
660
|
+
source.policy
|
|
661
|
+
]),
|
|
662
|
+
...inferredSpecContext.texts
|
|
663
|
+
].filter(Boolean).join('\n').toLowerCase();
|
|
664
|
+
for (const scenario of codeScenarios) {
|
|
665
|
+
for (const branch of scenario.branches) {
|
|
666
|
+
const condition = branch.condition.toLowerCase();
|
|
667
|
+
if (!isDomainBranch(condition)) continue;
|
|
668
|
+
if (isImplementationGuardBranch(branch.condition)) continue;
|
|
669
|
+
if (acceptanceText && acceptanceText.includes(condition.slice(0, 24))) continue;
|
|
670
|
+
if (isBranchCoveredByRequirementScope({ branch, scopeContext })) continue;
|
|
671
|
+
if (isBranchCoveredByInferredSpec({
|
|
672
|
+
branch,
|
|
673
|
+
scenario,
|
|
674
|
+
invariants,
|
|
675
|
+
inferredSpecContext
|
|
676
|
+
})) continue;
|
|
677
|
+
gaps.push({
|
|
678
|
+
id: `REQ-GAP-${String(gaps.length + 1).padStart(3, '0')}`,
|
|
679
|
+
title: 'Requirement Sourcesに明示されていない重要分岐がある',
|
|
680
|
+
detail: `${scenario.file} の \`${branch.condition}\` 分岐が、Story/Spec/Architecture/Policyの受け入れ基準または方針で明示されているか確認が必要。`,
|
|
681
|
+
file: scenario.file,
|
|
682
|
+
evidence: branch,
|
|
683
|
+
related_invariants: relatedInvariantIds(invariants, branch.condition)
|
|
684
|
+
});
|
|
685
|
+
if (gaps.length >= 12) return gaps;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return gaps;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function buildRequirementScopeContext({ storySource, requirementSources, inferredSpecContext }) {
|
|
692
|
+
const texts = [
|
|
693
|
+
storySource?.background,
|
|
694
|
+
storySource?.policy,
|
|
695
|
+
storySource?.content,
|
|
696
|
+
...(storySource?.acceptance_criteria ?? []),
|
|
697
|
+
...requirementSources.flatMap((source) => [
|
|
698
|
+
source.background,
|
|
699
|
+
source.policy,
|
|
700
|
+
source.content,
|
|
701
|
+
...(source.acceptance_criteria ?? [])
|
|
702
|
+
]),
|
|
703
|
+
...inferredSpecContext.texts
|
|
704
|
+
].filter(Boolean);
|
|
705
|
+
return {
|
|
706
|
+
texts: texts.map((text) => ({
|
|
707
|
+
raw: String(text),
|
|
708
|
+
normalized: normalizeComparableText(text),
|
|
709
|
+
inherited_behavior: INHERITED_BEHAVIOR_PATTERNS.some((pattern) => pattern.test(String(text)))
|
|
710
|
+
}))
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function isBranchCoveredByRequirementScope({ branch, scopeContext }) {
|
|
715
|
+
const conditionTokens = meaningfulConditionTokens(branch.condition);
|
|
716
|
+
if (conditionTokens.length === 0) return false;
|
|
717
|
+
return scopeContext.texts.some((entry) => (
|
|
718
|
+
entry.inherited_behavior && tokensCoveredByText(conditionTokens, entry.normalized)
|
|
719
|
+
));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function buildInferredSpecContext(spec) {
|
|
723
|
+
if (!spec || !Array.isArray(spec.clauses)) {
|
|
724
|
+
return { clauses: [], texts: [] };
|
|
725
|
+
}
|
|
726
|
+
const clauses = spec.clauses
|
|
727
|
+
.filter((clause) => clause && typeof clause.statement === 'string')
|
|
728
|
+
.map((clause) => {
|
|
729
|
+
const codeRefs = Array.isArray(clause.origin?.code_refs) ? clause.origin.code_refs : [];
|
|
730
|
+
const codePatterns = Array.isArray(clause.verifiable_by?.code_pattern) ? clause.verifiable_by.code_pattern : [];
|
|
731
|
+
const files = [
|
|
732
|
+
...codeRefs.map((ref) => ref?.file),
|
|
733
|
+
...codePatterns.map((pattern) => pattern?.file_glob)
|
|
734
|
+
].filter(Boolean).map((file) => normalizePath(file));
|
|
735
|
+
const fragments = [
|
|
736
|
+
...codeRefs.map((ref) => ref?.anchor),
|
|
737
|
+
...codePatterns.map((pattern) => pattern?.must_contain)
|
|
738
|
+
].filter(Boolean).map((value) => String(value));
|
|
739
|
+
const text = [
|
|
740
|
+
clause.id,
|
|
741
|
+
clause.type,
|
|
742
|
+
clause.statement,
|
|
743
|
+
...files,
|
|
744
|
+
...fragments
|
|
745
|
+
].filter(Boolean).join('\n');
|
|
746
|
+
return {
|
|
747
|
+
id: clause.id,
|
|
748
|
+
type: clause.type ?? 'invariant',
|
|
749
|
+
statement: clause.statement,
|
|
750
|
+
files,
|
|
751
|
+
fragments,
|
|
752
|
+
text,
|
|
753
|
+
normalized_text: normalizeComparableText(text)
|
|
754
|
+
};
|
|
755
|
+
});
|
|
756
|
+
return {
|
|
757
|
+
clauses,
|
|
758
|
+
texts: clauses.map((clause) => clause.text)
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function isBranchCoveredByInferredSpec({ branch, scenario, invariants, inferredSpecContext }) {
|
|
763
|
+
if (!inferredSpecContext?.clauses?.length) return false;
|
|
764
|
+
const inferredInvariants = invariants.filter((invariant) => invariant.source?.kind === 'inferred_spec');
|
|
765
|
+
const relatedIds = new Set(relatedInvariantIds(inferredInvariants, branch.condition));
|
|
766
|
+
const condition = normalizeComparableCode(branch.condition);
|
|
767
|
+
const conditionTokens = meaningfulConditionTokens(branch.condition);
|
|
768
|
+
|
|
769
|
+
for (const clause of inferredSpecContext.clauses) {
|
|
770
|
+
const appliesToFile = clauseAppliesToScenario(clause, scenario.file);
|
|
771
|
+
if (appliesToFile && clause.fragments.some((fragment) => codeFragmentCoversCondition(fragment, condition))) {
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
if (tokensCoveredByText(conditionTokens, clause.normalized_text)) {
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
if (appliesToFile && relatedIds.has(clause.id) && conditionTokens.some((token) => textIncludesToken(clause.normalized_text, token))) {
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function clauseAppliesToScenario(clause, scenarioFile) {
|
|
785
|
+
if (!clause.files.length) return true;
|
|
786
|
+
return clause.files.some((filePattern) => pathPatternMatches(filePattern, scenarioFile));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function pathPatternMatches(pattern, filePath) {
|
|
790
|
+
const normalizedPattern = normalizePath(pattern);
|
|
791
|
+
const normalizedFile = normalizePath(filePath);
|
|
792
|
+
if (!normalizedPattern) return false;
|
|
793
|
+
if (normalizedPattern === normalizedFile) return true;
|
|
794
|
+
if (!normalizedPattern.includes('*')) return normalizedFile.endsWith(normalizedPattern);
|
|
795
|
+
const regex = new RegExp(`^${escapeRegExp(normalizedPattern)
|
|
796
|
+
.replace(/\\\*\\\*/g, '.*')
|
|
797
|
+
.replace(/\\\*/g, '[^/]*')}$`);
|
|
798
|
+
return regex.test(normalizedFile);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function codeFragmentCoversCondition(fragment, normalizedCondition) {
|
|
802
|
+
const normalizedFragment = normalizeComparableCode(fragment);
|
|
803
|
+
if (!normalizedFragment || !normalizedCondition) return false;
|
|
804
|
+
return normalizedCondition.includes(normalizedFragment) || normalizedFragment.includes(normalizedCondition);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function tokensCoveredByText(tokens, normalizedText) {
|
|
808
|
+
if (tokens.length === 0 || !normalizedText) return false;
|
|
809
|
+
const matches = tokens.filter((token) => textIncludesToken(normalizedText, token));
|
|
810
|
+
if (matches.some((token) => token.length >= 8)) return true;
|
|
811
|
+
return matches.length >= Math.min(2, tokens.length);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function textIncludesToken(normalizedText, token) {
|
|
815
|
+
if (normalizedText.includes(token)) return true;
|
|
816
|
+
return tokenVariants(token).some((variant) => variant.length >= 5 && normalizedText.includes(variant));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function tokenVariants(token) {
|
|
820
|
+
const variants = new Set();
|
|
821
|
+
variants.add(token);
|
|
822
|
+
for (const suffix of ['ing', 'ed', 'ion', 'ions', 'ive', 'ives', 'ed']) {
|
|
823
|
+
if (token.length > suffix.length + 4 && token.endsWith(suffix)) {
|
|
824
|
+
variants.add(token.slice(0, -suffix.length));
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (token.endsWith('ation') && token.length > 9) variants.add(token.slice(0, -5));
|
|
828
|
+
if (token.endsWith('ating') && token.length > 9) variants.add(token.slice(0, -3));
|
|
829
|
+
if (token.endsWith('archive') && token.length > 9) variants.add(token.slice(0, -1));
|
|
830
|
+
return [...variants];
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function meaningfulConditionTokens(value) {
|
|
834
|
+
return normalizeComparableText(value)
|
|
835
|
+
.split(' ')
|
|
836
|
+
.filter((token) => token.length >= 4 || DOMAIN_KEYWORDS.includes(token))
|
|
837
|
+
.filter((token) => !GENERIC_CONDITION_TOKENS.has(token))
|
|
838
|
+
.slice(0, 8);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function normalizeComparableCode(value) {
|
|
842
|
+
return String(value ?? '')
|
|
843
|
+
.toLowerCase()
|
|
844
|
+
.replace(/[?!]/g, '')
|
|
845
|
+
.replace(/\s+/g, '')
|
|
846
|
+
.replace(/['"`]/g, '');
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function normalizeComparableText(value) {
|
|
850
|
+
return String(value ?? '')
|
|
851
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
852
|
+
.toLowerCase()
|
|
853
|
+
.replace(/[^a-z0-9_]+/gi, ' ')
|
|
854
|
+
.replace(/\s+/g, ' ')
|
|
855
|
+
.trim();
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function isImplementationGuardBranch(condition) {
|
|
859
|
+
const normalized = String(condition ?? '').trim();
|
|
860
|
+
const compact = normalized.replace(/\s+/g, ' ');
|
|
861
|
+
const lower = compact.toLowerCase();
|
|
862
|
+
return /^typeof\s+[\w$.[\]?()]+(?:\?\.[\w$.[\]?()]+)*\s*(?:!==|===)\s*['"]function['"]$/.test(lower)
|
|
863
|
+
|| /^(?:req|request)\.body\.[\w$.[\]?]+\s*!==\s*undefined$/.test(lower)
|
|
864
|
+
|| /^this\.[\w$.[\]?]+\.has\([^)]{1,80}\)?$/.test(lower)
|
|
865
|
+
|| /^typeof\s+this\.[\w$.[\]?]+\s*(?:!==|===)\s*['"]function['"]$/.test(lower)
|
|
866
|
+
|| /^([a-z_$][\w$]*)\.id\s*!==\s*\1id$/.test(lower);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function buildContradictions({ invariants, codeScenarios }) {
|
|
870
|
+
const contradictions = [];
|
|
871
|
+
const premiumUntilEndInvariants = invariants.filter(isPremiumUntilEndInvariant);
|
|
872
|
+
if (premiumUntilEndInvariants.length > 0) {
|
|
873
|
+
for (const scenario of codeScenarios) {
|
|
874
|
+
const userTypes = new Set(
|
|
875
|
+
scenario.state_transitions
|
|
876
|
+
.filter((item) => item.key === 'userType')
|
|
877
|
+
.map((item) => String(item.value))
|
|
878
|
+
);
|
|
879
|
+
if (userTypes.has('1') && userTypes.has('2')) {
|
|
880
|
+
contradictions.push({
|
|
881
|
+
id: `REQ-CON-${String(contradictions.length + 1).padStart(3, '0')}`,
|
|
882
|
+
title: 'premium維持要件と状態遷移が分岐している可能性',
|
|
883
|
+
detail: `${scenario.file} は同じ変更範囲で userType=1 と userType=2 の両方を返す/更新する。期間終了までpremium維持する要件と矛盾しないか確認が必要。`,
|
|
884
|
+
file: scenario.file,
|
|
885
|
+
related_invariants: premiumUntilEndInvariants.map((invariant) => invariant.id).slice(0, 5)
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return contradictions.slice(0, 8);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function isPremiumUntilEndInvariant(invariant) {
|
|
894
|
+
const text = normalizeText(invariant?.text ?? '');
|
|
895
|
+
if (/shape|レスポンスshape|response\s*shape/.test(text)) return false;
|
|
896
|
+
return /premium|プレミアム/.test(text)
|
|
897
|
+
&& /維持|keep|期間終了|current_period_end|until/.test(text);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function isDomainBranch(condition) {
|
|
901
|
+
const normalized = String(condition ?? '').trim().toLowerCase();
|
|
902
|
+
if (!normalized) return false;
|
|
903
|
+
if (isGenericImplementationGuard(normalized)) return false;
|
|
904
|
+
return /user|auth|session|subscription|premium|stripe|webhook|signature|customer|cancel/i.test(normalized);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function isGenericImplementationGuard(condition) {
|
|
908
|
+
return /^error\s+instanceof\s+error\b/.test(condition)
|
|
909
|
+
|| /^!?found$/.test(condition)
|
|
910
|
+
|| /^!?session(?:id)?(?:\s*\|\|\s*![a-z0-9_.$]+)?$/.test(condition)
|
|
911
|
+
|| /^!?isinsecureheaderauthallowed\(/.test(condition)
|
|
912
|
+
|| /^session\.[a-z0-9_?.]+$/.test(condition)
|
|
913
|
+
|| /^sessions\[[^\]]+\]\.[a-z0-9_?.]+\s*!==/.test(condition)
|
|
914
|
+
|| /^!?normalizedsessionid$/.test(condition)
|
|
915
|
+
|| /^changesnotpushed\s*>/.test(condition)
|
|
916
|
+
|| /^result\.notfound\s*\|\|\s*!result\.success$/.test(condition)
|
|
917
|
+
|| /^message\.includes\(/.test(condition)
|
|
918
|
+
|| /^value\s*===\s*['"][a-z0-9_:-]+['"]$/.test(condition);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function relatedInvariantIds(invariants, text) {
|
|
922
|
+
const haystack = text.toLowerCase();
|
|
923
|
+
return invariants
|
|
924
|
+
.filter((invariant) => DOMAIN_KEYWORDS.some((keyword) => (
|
|
925
|
+
haystack.includes(keyword.toLowerCase()) && invariant.text.toLowerCase().includes(keyword.toLowerCase())
|
|
926
|
+
)))
|
|
927
|
+
.map((invariant) => invariant.id)
|
|
928
|
+
.slice(0, 5);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
export async function parseStoryLikeDocument(repoRoot, absoluteOrRelativeFile, kind = null) {
|
|
932
|
+
const absolute = path.isAbsolute(absoluteOrRelativeFile)
|
|
933
|
+
? absoluteOrRelativeFile
|
|
934
|
+
: path.join(repoRoot, absoluteOrRelativeFile);
|
|
935
|
+
const content = await readFile(absolute, 'utf8');
|
|
936
|
+
const relative = normalizePath(path.relative(repoRoot, absolute));
|
|
937
|
+
const frontmatter = parseFrontmatter(content);
|
|
938
|
+
return {
|
|
939
|
+
kind: kind ?? inferSourceKind(relative),
|
|
940
|
+
path: relative,
|
|
941
|
+
frontmatter,
|
|
942
|
+
story_id: frontmatter.story_id ?? null,
|
|
943
|
+
title: findMarkdownTitle(content),
|
|
944
|
+
content,
|
|
945
|
+
background: extractSectionText(content, ['背景', '現状', '課題']),
|
|
946
|
+
policy: extractSectionText(content, ['方針', '実装方針', '実装戦略', 'ポリシー']),
|
|
947
|
+
acceptance_criteria: extractAcceptanceCriteria(content)
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function parseFrontmatter(content) {
|
|
952
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
953
|
+
if (!match) return {};
|
|
954
|
+
const result = {};
|
|
955
|
+
for (const line of match[1].split('\n')) {
|
|
956
|
+
const item = line.match(/^\s*([A-Za-z0-9_-]+):\s*(.+?)\s*$/);
|
|
957
|
+
if (!item) continue;
|
|
958
|
+
result[item[1]] = item[2].replace(/^['"]|['"]$/g, '');
|
|
959
|
+
}
|
|
960
|
+
return result;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function findMarkdownTitle(content) {
|
|
964
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
965
|
+
return match?.[1]?.trim() ?? null;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function extractSectionText(content, headings) {
|
|
969
|
+
for (const heading of headings) {
|
|
970
|
+
const escaped = escapeRegExp(heading);
|
|
971
|
+
const match = content.match(new RegExp(`^##+\\s+.*${escaped}.*\\n([\\s\\S]*?)(?=^##+\\s+|(?![\\s\\S]))`, 'm'));
|
|
972
|
+
if (!match) continue;
|
|
973
|
+
const paragraph = match[1]
|
|
974
|
+
.split('\n')
|
|
975
|
+
.map((line) => line.trim())
|
|
976
|
+
.filter((line) => line && !line.startsWith('|') && !line.startsWith('---'))
|
|
977
|
+
.join(' ')
|
|
978
|
+
.replace(/\s+/g, ' ')
|
|
979
|
+
.slice(0, 1200);
|
|
980
|
+
if (paragraph) return paragraph;
|
|
981
|
+
}
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function extractAcceptanceCriteria(content) {
|
|
986
|
+
const section = extractRawSection(content, ['受け入れ基準', '完了定義', 'Acceptance Criteria']);
|
|
987
|
+
const source = section ?? content;
|
|
988
|
+
return source
|
|
989
|
+
.split('\n')
|
|
990
|
+
.map((line) => line.trim())
|
|
991
|
+
.filter((line) => /^-\s+(?:\[[ xX]\]\s+)?/.test(line))
|
|
992
|
+
.map((line) => line.replace(/^-\s+(?:\[[ xX]\]\s+)?/, '').trim())
|
|
993
|
+
.filter(Boolean)
|
|
994
|
+
.slice(0, 16);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function extractRawSection(content, headings) {
|
|
998
|
+
for (const heading of headings) {
|
|
999
|
+
const escaped = escapeRegExp(heading);
|
|
1000
|
+
const match = content.match(new RegExp(`^##+\\s+.*${escaped}.*\\n([\\s\\S]*?)(?=^##+\\s+|(?![\\s\\S]))`, 'm'));
|
|
1001
|
+
if (match) return match[1];
|
|
1002
|
+
}
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function listFiles(root) {
|
|
1007
|
+
const result = [];
|
|
1008
|
+
async function walk(dir) {
|
|
1009
|
+
let entries = [];
|
|
1010
|
+
try {
|
|
1011
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1012
|
+
} catch {
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
for (const entry of entries) {
|
|
1016
|
+
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.vibepro') continue;
|
|
1017
|
+
const fullPath = path.join(dir, entry.name);
|
|
1018
|
+
if (entry.isDirectory()) await walk(fullPath);
|
|
1019
|
+
else result.push(fullPath);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
await walk(root);
|
|
1023
|
+
return result;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function splitSentences(text) {
|
|
1027
|
+
return String(text)
|
|
1028
|
+
.split(/(?<=[。.!?])\s+|\n+/)
|
|
1029
|
+
.flatMap((line) => line.split(/(?<=。)/))
|
|
1030
|
+
.map((line) => line.trim())
|
|
1031
|
+
.filter(Boolean);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function cleanupLine(value) {
|
|
1035
|
+
return String(value)
|
|
1036
|
+
.replace(/^[-*]\s+/, '')
|
|
1037
|
+
.replace(/^\[[ xX]\]\s+/, '')
|
|
1038
|
+
.replace(/`/g, '')
|
|
1039
|
+
.replace(/\s+/g, ' ')
|
|
1040
|
+
.trim();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function normalizeText(value) {
|
|
1044
|
+
return cleanupLine(value).toLowerCase();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function normalizePath(value) {
|
|
1048
|
+
return String(value).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function stripBlockComments(content) {
|
|
1052
|
+
return content.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function stripFrontmatter(content) {
|
|
1056
|
+
return String(content).replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function formatItems(items, formatter) {
|
|
1060
|
+
if (!Array.isArray(items) || items.length === 0) return '- なし';
|
|
1061
|
+
return items.map(formatter).join('\n');
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function escapeRegExp(value) {
|
|
1065
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1066
|
+
}
|