vibepro 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +9 -0
  3. package/README.ja.md +448 -0
  4. package/README.md +520 -0
  5. package/agent-instructions/codex/AGENTS.vibepro.md +45 -0
  6. package/bin/vibepro.js +9 -0
  7. package/docs/assets/vibepro-header.png +0 -0
  8. package/package.json +51 -0
  9. package/skills/vibepro-diagnosis-packages/SKILL.md +133 -0
  10. package/skills/vibepro-human-review/SKILL.md +73 -0
  11. package/skills/vibepro-story-refactor/SKILL.md +89 -0
  12. package/skills/vibepro-workflow/SKILL.md +139 -0
  13. package/src/agent-harness-map.js +230 -0
  14. package/src/agent-harness-scanner.js +337 -0
  15. package/src/agent-review.js +2180 -0
  16. package/src/api-boundary-scanner.js +452 -0
  17. package/src/architecture-profiler.js +423 -0
  18. package/src/authorization-scoring.js +149 -0
  19. package/src/brainbase-importer.js +534 -0
  20. package/src/change-risk-classifier.js +195 -0
  21. package/src/check-packs.js +605 -0
  22. package/src/checkpoint-manager.js +233 -0
  23. package/src/cli.js +2213 -0
  24. package/src/code-quality-scanner.js +310 -0
  25. package/src/codex-manager.js +143 -0
  26. package/src/component-style-scanner.js +336 -0
  27. package/src/coverage-report.js +99 -0
  28. package/src/database-access-scanner.js +163 -0
  29. package/src/decision-records.js +315 -0
  30. package/src/design-modernize.js +1435 -0
  31. package/src/design-system.js +1732 -0
  32. package/src/diagnostic-engine.js +1945 -0
  33. package/src/diagram-requirement-resolver.js +194 -0
  34. package/src/doctor.js +677 -0
  35. package/src/environment-graph.js +424 -0
  36. package/src/execution-state.js +849 -0
  37. package/src/explore-evidence.js +425 -0
  38. package/src/flow-design-scanner.js +896 -0
  39. package/src/flow-verifier.js +887 -0
  40. package/src/gesture-interaction-scanner.js +330 -0
  41. package/src/graph-context.js +263 -0
  42. package/src/graphify-adapter.js +189 -0
  43. package/src/html-report.js +1035 -0
  44. package/src/journey-map.js +1299 -0
  45. package/src/language.js +48 -0
  46. package/src/lazy-pattern-detector.js +182 -0
  47. package/src/local-dev-scanner.js +135 -0
  48. package/src/managed-worktree-gate.js +187 -0
  49. package/src/managed-worktree.js +766 -0
  50. package/src/merge-manager.js +501 -0
  51. package/src/network-contract-scanner.js +442 -0
  52. package/src/nocodb-story-sync.js +386 -0
  53. package/src/oss-readiness-scanner.js +417 -0
  54. package/src/performance-evidence.js +756 -0
  55. package/src/performance-measurer.js +591 -0
  56. package/src/pr-manager.js +8220 -0
  57. package/src/presets.js +682 -0
  58. package/src/public-discovery-scanner.js +519 -0
  59. package/src/refactoring-delta-reporter.js +367 -0
  60. package/src/refactoring-opportunity-generator.js +797 -0
  61. package/src/regression-risk-scanner.js +146 -0
  62. package/src/repo-status.js +266 -0
  63. package/src/report-fingerprint.js +188 -0
  64. package/src/report-pr-body-prompt-template.md +108 -0
  65. package/src/report-pr-body-schema.json +95 -0
  66. package/src/report-store.js +135 -0
  67. package/src/report-validator.js +192 -0
  68. package/src/requirement-consistency.js +1066 -0
  69. package/src/runtime-info.js +134 -0
  70. package/src/self-dogfood-scanner.js +476 -0
  71. package/src/session-learning.js +164 -0
  72. package/src/skills-manager.js +157 -0
  73. package/src/spec-drift.js +378 -0
  74. package/src/spec-fingerprint.js +445 -0
  75. package/src/spec-prompt-template.md +155 -0
  76. package/src/spec-schema.json +219 -0
  77. package/src/spec-store.js +258 -0
  78. package/src/spec-validator.js +459 -0
  79. package/src/static-site-scanner.js +316 -0
  80. package/src/story-candidate-generator.js +85 -0
  81. package/src/story-catalog-generator.js +2813 -0
  82. package/src/story-html.js +156 -0
  83. package/src/story-manager.js +2144 -0
  84. package/src/story-task-generator.js +522 -0
  85. package/src/task-manager.js +1029 -0
  86. package/src/terminal-link-scanner.js +238 -0
  87. package/src/usage-report.js +417 -0
  88. package/src/verification-evidence.js +284 -0
  89. package/src/workspace.js +126 -0
@@ -0,0 +1,896 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const UI_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
5
+ const DEFAULT_UI_ROOTS = [
6
+ 'app',
7
+ 'src/app',
8
+ 'pages',
9
+ 'src/pages',
10
+ 'components',
11
+ 'src/components',
12
+ 'public',
13
+ 'styles',
14
+ 'src/styles'
15
+ ];
16
+ const IGNORED_DIRS = new Set([
17
+ '.git',
18
+ '.next',
19
+ '.turbo',
20
+ '.vibepro',
21
+ 'coverage',
22
+ 'dist',
23
+ 'node_modules',
24
+ 'graphify-out'
25
+ ]);
26
+ const JSX_ARROW_TOKEN = '=$';
27
+ const NATIVE_INTERACTIVE_TAGS = new Set(['button', 'a', 'summary', 'details']);
28
+ const KNOWN_INTERACTION_CONTRACT_COMPONENTS = new Set([
29
+ 'DialogTrigger',
30
+ 'DialogClose',
31
+ 'AlertDialogTrigger',
32
+ 'AlertDialogCancel',
33
+ 'AlertDialogAction',
34
+ 'AccordionTrigger',
35
+ 'SheetTrigger',
36
+ 'SheetClose',
37
+ 'PopoverTrigger',
38
+ 'PopoverClose',
39
+ 'DropdownMenuTrigger',
40
+ 'DropdownMenuItem',
41
+ 'SelectTrigger',
42
+ 'TabsTrigger',
43
+ 'CollapsibleTrigger'
44
+ ]);
45
+ const KNOWN_INTERACTION_NAMESPACES = new Set([
46
+ 'Dialog',
47
+ 'AlertDialog',
48
+ 'Accordion',
49
+ 'Sheet',
50
+ 'Popover',
51
+ 'DropdownMenu',
52
+ 'Select',
53
+ 'Tabs',
54
+ 'Collapsible'
55
+ ]);
56
+ const KNOWN_INTERACTION_LOCALS = new Set([
57
+ 'Trigger',
58
+ 'Close',
59
+ 'Cancel',
60
+ 'Action',
61
+ 'Item'
62
+ ]);
63
+ const JSX_EVENT_PROPS = [
64
+ 'onClick',
65
+ 'onSubmit',
66
+ 'onChange',
67
+ 'onKeyDown',
68
+ 'onMouseDown',
69
+ 'onPointerDown',
70
+ 'onTouchStart'
71
+ ];
72
+
73
+ export async function scanFlowDesign(repoRoot, options = {}) {
74
+ const root = path.resolve(repoRoot);
75
+ const config = options.config ?? {};
76
+ const flowConfig = config.flow_design ?? {};
77
+ const profile = flowConfig.profile ?? inferProfile(options.story);
78
+ const story = options.story ?? {};
79
+ const uiFiles = await collectUiFiles(root, flowConfig);
80
+ const valueContract = buildValueContract({ profile, flowConfig });
81
+ const result = {
82
+ schema_version: '0.1.0',
83
+ status: 'pass',
84
+ profile,
85
+ story: {
86
+ story_id: story.story_id ?? null,
87
+ title: story.title ?? null
88
+ },
89
+ summary: {
90
+ scanned_ui_files: uiFiles.length,
91
+ contract_count: (flowConfig.contracts ?? []).length,
92
+ interaction_count: 0,
93
+ silent_noop_count: 0,
94
+ ambiguous_primary_action_count: 0,
95
+ selection_side_effect_count: 0,
96
+ question_dead_end_count: 0,
97
+ dead_ui_state_count: 0,
98
+ interactive_contract_count: 0,
99
+ value_alignment_count: 0
100
+ },
101
+ contracts: flowConfig.contracts ?? [],
102
+ interactions: [],
103
+ silent_noop_hits: [],
104
+ ambiguous_primary_action_hits: [],
105
+ selection_side_effect_hits: [],
106
+ question_dead_end_hits: [],
107
+ dead_ui_state_hits: [],
108
+ interactive_contract_hits: [],
109
+ value_alignment_hits: [],
110
+ runtime_probe_plan: buildRuntimeProbePlan({ profile, story, flowConfig })
111
+ };
112
+
113
+ if (uiFiles.length === 0 && isUiStory(story, flowConfig)) {
114
+ result.value_alignment_hits.push({
115
+ id: 'FLOW-NO-UI-CODE',
116
+ kind: 'ui_story_without_code_scan',
117
+ severity: 'Critical',
118
+ gate_effect: 'block',
119
+ file: null,
120
+ line: null,
121
+ detail: 'UI Storyとして扱うべきStoryだが、flow-designが走査できるUIコードが0件だった。',
122
+ recommendation: 'flow_design.code_rootsで対象UI実装のパスを指定するか、対象repoでVibeProを実行する。'
123
+ });
124
+ }
125
+
126
+ for (const file of uiFiles) {
127
+ const content = await readFile(file.absolutePath, 'utf8');
128
+ collectInteractions(result.interactions, file.relativePath, content);
129
+ collectSilentNoops(result.silent_noop_hits, file.relativePath, content);
130
+ collectAmbiguousPrimaryActions(result.ambiguous_primary_action_hits, file.relativePath, content);
131
+ collectSelectionSideEffects(result.selection_side_effect_hits, file.relativePath, content);
132
+ collectQuestionDeadEnds(result.question_dead_end_hits, file.relativePath, content);
133
+ collectDeadUiStates(result.dead_ui_state_hits, file.relativePath, content);
134
+ collectInteractiveContractHits(result.interactive_contract_hits, file.relativePath, content);
135
+ collectValueAlignmentHits(result.value_alignment_hits, file.relativePath, content, valueContract);
136
+ }
137
+
138
+ result.summary.interaction_count = result.interactions.length;
139
+ result.summary.silent_noop_count = result.silent_noop_hits.length;
140
+ result.summary.ambiguous_primary_action_count = result.ambiguous_primary_action_hits.length;
141
+ result.summary.selection_side_effect_count = result.selection_side_effect_hits.length;
142
+ result.summary.question_dead_end_count = result.question_dead_end_hits.length;
143
+ result.summary.dead_ui_state_count = result.dead_ui_state_hits.length;
144
+ result.summary.interactive_contract_count = result.interactive_contract_hits.length;
145
+ result.summary.value_alignment_count = result.value_alignment_hits.length;
146
+ result.status = resolveStatus(result);
147
+ return result;
148
+ }
149
+
150
+ export function renderFlowDesignReport({ runId, flowDesign }) {
151
+ if (!flowDesign) {
152
+ return `# Flow Design Check
153
+
154
+ | 項目 | 内容 |
155
+ |------|------|
156
+ | Run ID | ${runId} |
157
+ | 状態 | flow-design-check は未生成 |
158
+ `;
159
+ }
160
+ return `# Flow Design Check
161
+
162
+ | 項目 | 内容 |
163
+ |------|------|
164
+ | Run ID | ${runId} |
165
+ | Status | ${flowDesign.status} |
166
+ | Profile | ${flowDesign.profile ?? '-'} |
167
+ | UI走査ファイル | ${flowDesign.summary?.scanned_ui_files ?? 0}件 |
168
+ | Interaction | ${flowDesign.summary?.interaction_count ?? 0}件 |
169
+ | Silent noop | ${flowDesign.summary?.silent_noop_count ?? 0}件 |
170
+ | Selection side effect | ${flowDesign.summary?.selection_side_effect_count ?? 0}件 |
171
+ | Question dead end | ${flowDesign.summary?.question_dead_end_count ?? 0}件 |
172
+ | Dead UI state | ${flowDesign.summary?.dead_ui_state_count ?? 0}件 |
173
+ | Interactive contract | ${flowDesign.summary?.interactive_contract_count ?? 0}件 |
174
+ | Value alignment | ${flowDesign.summary?.value_alignment_count ?? 0}件 |
175
+
176
+ ## Silent noop
177
+
178
+ ${formatHits(flowDesign.silent_noop_hits)}
179
+
180
+ ## Selection side effect
181
+
182
+ ${formatHits(flowDesign.selection_side_effect_hits)}
183
+
184
+ ## Question dead end
185
+
186
+ ${formatHits(flowDesign.question_dead_end_hits)}
187
+
188
+ ## Dead UI state
189
+
190
+ ${formatHits(flowDesign.dead_ui_state_hits)}
191
+
192
+ ## Interactive contract
193
+
194
+ ${formatHits(flowDesign.interactive_contract_hits)}
195
+
196
+ ## Value alignment
197
+
198
+ ${formatHits(flowDesign.value_alignment_hits)}
199
+
200
+ ## Runtime probe plan
201
+
202
+ ${(flowDesign.runtime_probe_plan?.commands ?? []).length === 0 ? '- なし' : flowDesign.runtime_probe_plan.commands.map((item) => `- ${item.id}: ${item.intent}`).join('\n')}
203
+ `;
204
+ }
205
+
206
+ function formatHits(hits = []) {
207
+ if (!Array.isArray(hits) || hits.length === 0) return '- なし';
208
+ return hits.map((hit) => `- ${hit.file ?? '-'}:${hit.line ?? '-'} ${hit.kind} severity=${hit.severity ?? '-'} gate_effect=${hit.gate_effect ?? '-'} ${hit.detail ?? hit.excerpt ?? ''}`.trim()).join('\n');
209
+ }
210
+
211
+ async function collectUiFiles(root, flowConfig) {
212
+ const roots = flowConfig.code_roots?.length > 0 ? flowConfig.code_roots : DEFAULT_UI_ROOTS;
213
+ const files = [];
214
+ for (const candidate of roots) {
215
+ const absoluteRoot = path.isAbsolute(candidate) ? candidate : path.join(root, candidate);
216
+ files.push(...await listUiFiles(root, absoluteRoot));
217
+ }
218
+ return uniqueBy(files, (file) => file.absolutePath)
219
+ .filter((file) => !isApiRoute(file.relativePath))
220
+ .slice(0, 160);
221
+ }
222
+
223
+ async function listUiFiles(repoRoot, current) {
224
+ let entries = [];
225
+ try {
226
+ entries = await readdir(current, { withFileTypes: true });
227
+ } catch (error) {
228
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return [];
229
+ throw error;
230
+ }
231
+ const files = [];
232
+ for (const entry of entries) {
233
+ if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
234
+ const absolutePath = path.join(current, entry.name);
235
+ if (entry.isDirectory()) {
236
+ files.push(...await listUiFiles(repoRoot, absolutePath));
237
+ continue;
238
+ }
239
+ if (!entry.isFile()) continue;
240
+ if (!UI_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue;
241
+ const fileStat = await stat(absolutePath);
242
+ if (fileStat.size > 1024 * 1024) continue;
243
+ files.push({
244
+ absolutePath,
245
+ relativePath: path.relative(repoRoot, absolutePath).split(path.sep).join('/')
246
+ });
247
+ }
248
+ return files;
249
+ }
250
+
251
+ function collectInteractions(interactions, file, content) {
252
+ for (const eventProp of JSX_EVENT_PROPS) {
253
+ const pattern = new RegExp(`\\b${eventProp}\\s*=\\s*\\{([^}\\n]+)\\}`, 'g');
254
+ for (const match of content.matchAll(pattern)) {
255
+ interactions.push({
256
+ file,
257
+ line: lineNumberAt(content, match.index),
258
+ event: eventProp,
259
+ handler: cleanup(match[1]),
260
+ excerpt: cleanup(match[0]).slice(0, 180)
261
+ });
262
+ }
263
+ }
264
+ }
265
+
266
+ function collectSilentNoops(hits, file, content) {
267
+ const code = stripComments(content);
268
+ const functions = extractFunctions(code);
269
+ const eventPaths = collectEventPathFunctions(code, functions);
270
+ for (const fn of functions) {
271
+ const eventPathEntries = eventPaths.get(fn.name);
272
+ if (!eventPathEntries || eventPathEntries.length === 0) continue;
273
+ for (const match of fn.body.matchAll(/\bif\s*\(([^)]{1,180})\)\s*return\s*(?:;|(?=[\r\n}]))/g)) {
274
+ const bodyOffset = code.indexOf(fn.body, fn.start);
275
+ const absoluteIndex = bodyOffset + match.index;
276
+ const start = Math.max(0, absoluteIndex - 360);
277
+ const end = Math.min(code.length, absoluteIndex + 360);
278
+ const localContext = code.slice(start, end);
279
+ const mitigations = eventPathEntries.map((entry) => classifyNoopMitigation({
280
+ mitigationContext: entry.mitigation_context,
281
+ localContext,
282
+ condition: match[1]
283
+ }));
284
+ const mitigation = mitigations.length > 0 && mitigations.every(Boolean)
285
+ ? [...new Set(mitigations)].join(', ')
286
+ : null;
287
+ hits.push({
288
+ kind: 'silent_noop_return',
289
+ severity: mitigation ? 'Low' : 'Medium',
290
+ gate_effect: mitigation ? 'info' : 'review',
291
+ file,
292
+ line: lineNumberAt(code, absoluteIndex),
293
+ handler: fn.name,
294
+ event_path: eventPathEntries.map((entry) => entry.label).join(', '),
295
+ condition: cleanup(match[1]),
296
+ mitigation,
297
+ excerpt: cleanup(match[0]),
298
+ detail: mitigation
299
+ ? `\`${cleanup(match[1])}\` で早期returnするが、${mitigation} が見えるため補足情報として扱う。`
300
+ : `\`${cleanup(match[1])}\` で早期returnするが、同じ操作経路にエラー表示・disabled・誘導が見えない。`
301
+ });
302
+ }
303
+ }
304
+ }
305
+
306
+ function collectAmbiguousPrimaryActions(hits, file, content) {
307
+ const labelMatch = content.match(/\?\s*([^?:]{2,60})\s*:\s*([^}\n]{2,80})/);
308
+ if (!labelMatch) return;
309
+ const labels = [labelMatch[1], labelMatch[2]].map(cleanup).join(' / ');
310
+ if (!/(検索|登録|保存|遷移|詳細へ|進む)/.test(labels)) return;
311
+ if (!/(search|lookup|save|register|router\.push|navigate)/i.test(content)) return;
312
+ hits.push({
313
+ kind: 'state_dependent_primary_action',
314
+ severity: 'Medium',
315
+ gate_effect: 'review',
316
+ file,
317
+ line: lineNumberAt(content, labelMatch.index ?? 0),
318
+ excerpt: labels,
319
+ detail: '同じ主ボタンが状態によって検索・登録・保存・遷移など異なる意味を持つ可能性がある。'
320
+ });
321
+ }
322
+
323
+ function collectSelectionSideEffects(hits, file, content) {
324
+ for (const fn of extractFunctions(content)) {
325
+ if (!/^select|choose|pick|handleSelect/i.test(fn.name)) continue;
326
+ if (/router\.push|navigate\s*\(|location\.href/.test(fn.body)) {
327
+ hits.push(buildFunctionHit('selection_triggers_navigation', file, content, fn, '候補選択handlerが画面遷移を含む。'));
328
+ }
329
+ if (/fetch\s*\([^)]*method\s*:\s*['"`]POST|await\s+\w*save|createCase|register/i.test(fn.body)) {
330
+ hits.push(buildFunctionHit('selection_triggers_persistence', file, content, fn, '候補選択handlerが保存・作成系副作用を含む。'));
331
+ }
332
+ }
333
+ }
334
+
335
+ function collectQuestionDeadEnds(hits, file, content) {
336
+ const questionKeys = [...content.matchAll(/question\.key\s*===\s*['"`]([^'"`]+)['"`]/g)];
337
+ for (const match of questionKeys) {
338
+ const block = content.slice(match.index, Math.min(content.length, match.index + 720));
339
+ if (!/fetch\s*\(|set[A-Z]/.test(block)) continue;
340
+ if (/router\.push|scrollIntoView|focus\s*\(|set.*Open|set.*Expanded|document\.getElementById|location\.hash/.test(block)) continue;
341
+ hits.push({
342
+ kind: 'question_answer_does_not_open_next_ui',
343
+ question_key: match[1],
344
+ severity: 'High',
345
+ gate_effect: 'review',
346
+ file,
347
+ line: lineNumberAt(content, match.index),
348
+ excerpt: cleanup(block).slice(0, 180),
349
+ detail: `質問 \`${match[1]}\` の回答後に、次の入力UIを開く/移動する導線が見えない。`
350
+ });
351
+ }
352
+ }
353
+
354
+ function collectDeadUiStates(hits, file, content) {
355
+ for (const match of content.matchAll(/\bset([A-Z][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*;?/g)) {
356
+ const state = `${match[1].charAt(0).toLowerCase()}${match[1].slice(1)}`;
357
+ const after = content.slice(match.index, Math.min(content.length, match.index + 360));
358
+ if (!/(router\.push|navigate\s*\(|location\.href|await\s+\w*save|await\s+saveCase)/.test(after)) continue;
359
+ if (!new RegExp(`\\b${escapeRegExp(state)}\\b`).test(content.slice(match.index + match[0].length))) continue;
360
+ hits.push({
361
+ kind: 'state_set_before_immediate_navigation_or_save',
362
+ state,
363
+ severity: 'Medium',
364
+ gate_effect: 'review',
365
+ file,
366
+ line: lineNumberAt(content, match.index),
367
+ excerpt: cleanup(after).slice(0, 180),
368
+ detail: `\`${state}\` を表示用stateとして更新した直後に保存/遷移しており、到達不能UIの可能性がある。`
369
+ });
370
+ }
371
+ }
372
+
373
+ function collectInteractiveContractHits(hits, file, content) {
374
+ if (isTestOrMockFile(file)) return;
375
+ const source = stripComments(content);
376
+ const code = normalizeJsxForTagScan(source);
377
+ const functions = extractFunctions(source);
378
+ const functionLookup = new Map(functions.map((fn) => [fn.name, fn]));
379
+ const parentLinkRanges = collectParentLinkRanges(code);
380
+ const contractWrapperRanges = collectContractWrapperRanges(code);
381
+ const elementPattern = /<([A-Za-z][A-Za-z0-9_.]*)\b([^<>]*?)(?:\/>|>)/g;
382
+ for (const match of code.matchAll(elementPattern)) {
383
+ const tag = match[1];
384
+ const attrs = match[2] ?? '';
385
+ if (isClosingOrNonInteractiveTag(tag, attrs)) continue;
386
+ const after = code.slice(match.index + match[0].length, Math.min(code.length, match.index + match[0].length + 240));
387
+ const label = extractElementLabel(after);
388
+ if (!looksInteractive(tag, attrs, label)) continue;
389
+ if (hasExplicitUnavailableState(attrs, label, after)) continue;
390
+ if (isInsideParentLink(match.index, parentLinkRanges)) continue;
391
+ if (isInsideContractWrapper(match.index, contractWrapperRanges)) continue;
392
+
393
+ const directContract = classifyDirectInteractiveContract(tag, attrs);
394
+ if (directContract) continue;
395
+
396
+ const handler = extractHandlerName(attrs);
397
+ if (handler) {
398
+ const fn = functionLookup.get(handler);
399
+ if (!fn) continue;
400
+ if (handlerBodyHasUserVisibleContract(fn.body)) continue;
401
+ hits.push({
402
+ kind: 'interactive_handler_without_user_visible_effect',
403
+ severity: 'High',
404
+ gate_effect: 'review',
405
+ file,
406
+ line: lineNumberAt(code, match.index),
407
+ element: tag,
408
+ handler,
409
+ label,
410
+ excerpt: cleanup(match[0]).slice(0, 180),
411
+ detail: `クリック可能に見える \`${label || tag}\` のhandler \`${handler}\` に、保存・表示変化・遷移・scroll/focus・準備中表示のいずれも静的に確認できない。`
412
+ });
413
+ continue;
414
+ }
415
+
416
+ hits.push({
417
+ kind: 'interactive_element_without_contract',
418
+ severity: 'High',
419
+ gate_effect: 'review',
420
+ file,
421
+ line: lineNumberAt(code, match.index),
422
+ element: tag,
423
+ label,
424
+ excerpt: cleanup(match[0]).slice(0, 180),
425
+ detail: `クリック可能に見える \`${label || tag}\` が、onClick/href/submit/disabled/準備中表示などの操作契約を持っていない。`
426
+ });
427
+ }
428
+ }
429
+
430
+ function collectValueAlignmentHits(hits, file, content, valueContract) {
431
+ for (const label of valueContract.forbidden_labels ?? []) {
432
+ for (const match of content.matchAll(new RegExp(escapeRegExp(label), 'g'))) {
433
+ hits.push({
434
+ kind: 'forbidden_label',
435
+ label,
436
+ severity: 'High',
437
+ gate_effect: 'review',
438
+ file,
439
+ line: lineNumberAt(content, match.index),
440
+ excerpt: label,
441
+ detail: `価値観contractで禁止されたラベル \`${label}\` が表示面に残っている。`
442
+ });
443
+ }
444
+ }
445
+ if (isNewRegistrationFile(file)) {
446
+ for (const label of valueContract.forbidden_new_registration_labels ?? []) {
447
+ for (const match of content.matchAll(new RegExp(escapeRegExp(label), 'g'))) {
448
+ hits.push({
449
+ kind: 'forbidden_new_registration_label',
450
+ label,
451
+ severity: 'High',
452
+ gate_effect: 'review',
453
+ file,
454
+ line: lineNumberAt(content, match.index),
455
+ excerpt: label,
456
+ detail: `新規登録画面では扱わない導線 \`${label}\` が残っている。`
457
+ });
458
+ }
459
+ }
460
+ }
461
+ }
462
+
463
+ function extractFunctions(content) {
464
+ const functions = [];
465
+ const seen = new Set();
466
+ const pattern = /\b(?:(?:const|let|var)\s+([A-Za-z0-9_]+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>|(?:async\s+)?function\s+([A-Za-z0-9_]+)\s*\([^)]*\))\s*\{/g;
467
+ for (const match of content.matchAll(pattern)) {
468
+ const bodyStart = content.indexOf('{', match.index);
469
+ const bodyEnd = findMatchingBrace(content, bodyStart);
470
+ if (bodyEnd < 0) continue;
471
+ const name = match[1] ?? match[2];
472
+ seen.add(name);
473
+ functions.push({
474
+ name,
475
+ start: match.index,
476
+ body: content.slice(bodyStart, bodyEnd + 1)
477
+ });
478
+ }
479
+ const expressionArrowPattern = /\b(?:const|let|var)\s+([A-Za-z0-9_]+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*([^;\n]+);/g;
480
+ for (const match of content.matchAll(expressionArrowPattern)) {
481
+ const name = match[1];
482
+ if (seen.has(name)) continue;
483
+ functions.push({
484
+ name,
485
+ start: match.index,
486
+ body: `{ ${match[2]}; }`
487
+ });
488
+ }
489
+ return functions;
490
+ }
491
+
492
+ function findMatchingBrace(content, start) {
493
+ let depth = 0;
494
+ for (let index = start; index < content.length; index += 1) {
495
+ if (content[index] === '{') depth += 1;
496
+ if (content[index] === '}') depth -= 1;
497
+ if (depth === 0) return index;
498
+ }
499
+ return -1;
500
+ }
501
+
502
+ function buildFunctionHit(kind, file, content, fn, detail) {
503
+ return {
504
+ kind,
505
+ severity: 'High',
506
+ gate_effect: 'review',
507
+ file,
508
+ line: lineNumberAt(content, fn.start),
509
+ handler: fn.name,
510
+ excerpt: cleanup(fn.body).slice(0, 180),
511
+ detail
512
+ };
513
+ }
514
+
515
+ function collectEventPathFunctions(code, functions) {
516
+ const lookup = new Map(functions.map((fn) => [fn.name, fn]));
517
+ const paths = new Map();
518
+ for (const eventProp of JSX_EVENT_PROPS) {
519
+ const pattern = new RegExp(`\\b${eventProp}\\s*=\\s*\\{([^}\\n]+)\\}`, 'g');
520
+ for (const match of code.matchAll(pattern)) {
521
+ const mitigationContext = collectEventMitigationContext(code, match.index);
522
+ for (const handler of extractEventExpressionHandlers(match[1])) {
523
+ if (!lookup.has(handler)) continue;
524
+ addEventPath(paths, handler, {
525
+ label: `${eventProp}:${handler}`,
526
+ mitigation_context: mitigationContext
527
+ });
528
+ }
529
+ }
530
+ }
531
+ for (const [handler, pathLabel] of [...paths.entries()]) {
532
+ const fn = lookup.get(handler);
533
+ if (!fn) continue;
534
+ for (const called of collectDirectFunctionCalls(fn.body)) {
535
+ if (!lookup.has(called)) continue;
536
+ if (isLikelyPureValueHelper(called)) continue;
537
+ for (const entry of pathLabel) {
538
+ addEventPath(paths, called, {
539
+ label: `${entry.label}->${called}`,
540
+ mitigation_context: entry.mitigation_context
541
+ });
542
+ }
543
+ }
544
+ }
545
+ return paths;
546
+ }
547
+
548
+ function addEventPath(paths, handler, entry) {
549
+ const existing = paths.get(handler) ?? [];
550
+ existing.push(entry);
551
+ paths.set(handler, existing);
552
+ }
553
+
554
+ function collectEventMitigationContext(code, eventIndex) {
555
+ const tagStart = code.lastIndexOf('<', eventIndex);
556
+ if (tagStart < 0) return code.slice(Math.max(0, eventIndex - 120), Math.min(code.length, eventIndex + 120));
557
+ const openEnd = findJsxOpeningTagEnd(code, tagStart);
558
+ if (openEnd < 0) return code.slice(tagStart, Math.min(code.length, eventIndex + 120));
559
+ const openTag = code.slice(tagStart, openEnd + 1);
560
+ const tagMatch = openTag.match(/^<([A-Za-z][A-Za-z0-9_.]*)\b/);
561
+ const tag = tagMatch?.[1];
562
+ if (tag && !openTag.endsWith('/>')) {
563
+ const closeIndex = findClosingTagIndex(code, tag, openEnd + 1);
564
+ if (closeIndex > openEnd) {
565
+ return code.slice(tagStart, Math.min(code.length, closeIndex + tag.length + 3));
566
+ }
567
+ }
568
+ return openTag;
569
+ }
570
+
571
+ function findJsxOpeningTagEnd(code, tagStart) {
572
+ let braceDepth = 0;
573
+ let quote = null;
574
+ for (let index = tagStart; index < code.length; index += 1) {
575
+ const char = code[index];
576
+ if (quote) {
577
+ if (char === quote && code[index - 1] !== '\\') quote = null;
578
+ continue;
579
+ }
580
+ if (char === '"' || char === "'" || char === '`') {
581
+ quote = char;
582
+ continue;
583
+ }
584
+ if (char === '{') {
585
+ braceDepth += 1;
586
+ continue;
587
+ }
588
+ if (char === '}') {
589
+ braceDepth = Math.max(0, braceDepth - 1);
590
+ continue;
591
+ }
592
+ if (char === '>' && braceDepth === 0) return index;
593
+ }
594
+ return -1;
595
+ }
596
+
597
+ function extractEventExpressionHandlers(expression) {
598
+ const trimmed = cleanup(expression);
599
+ const direct = trimmed.match(/^([A-Za-z_$][\w$]*)$/);
600
+ if (direct) return [direct[1]];
601
+ const handlers = new Set();
602
+ for (const called of collectDirectFunctionCalls(trimmed)) {
603
+ handlers.add(called);
604
+ }
605
+ return [...handlers];
606
+ }
607
+
608
+ function collectDirectFunctionCalls(body) {
609
+ const calls = new Set();
610
+ for (const match of body.matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) {
611
+ const name = match[1];
612
+ if ([
613
+ 'if',
614
+ 'for',
615
+ 'while',
616
+ 'switch',
617
+ 'return',
618
+ 'await',
619
+ 'setTimeout',
620
+ 'setInterval',
621
+ 'console',
622
+ 'fetch'
623
+ ].includes(name)) continue;
624
+ calls.add(name);
625
+ }
626
+ return calls;
627
+ }
628
+
629
+ function isLikelyPureValueHelper(name) {
630
+ return /^(createId|format[A-Z]|getLatest[A-Z]|summarize[A-Z]|flatten[A-Z])/.test(name)
631
+ || /(Label|Formatter|Selector|Summary)$/.test(name);
632
+ }
633
+
634
+ function classifyNoopMitigation({ mitigationContext, localContext, condition }) {
635
+ if (/setError|throw\s+new|focus\s*\(|scrollIntoView|aria-invalid/.test(localContext)) {
636
+ return 'error/focus guidance';
637
+ }
638
+ const conditionText = cleanup(condition);
639
+ const conditionBranches = conditionText.split(/\|\|/).map((branch) => collectConditionTokens(branch));
640
+ const context = `${localContext}\n${mitigationContext ?? ''}`;
641
+ const disabledExpressions = [...String(mitigationContext ?? '').matchAll(/\bdisabled\s*=\s*\{([^}]{1,240})\}/g)].map((match) => match[1]);
642
+ const hasLoadingAffordance = /aria-busy|<(?:Spinner|Loader)\b|(?:Spinner|Loader)\b|['"`][^'"`\n]*(?:Loading|loading|読み込み中|処理中)[^'"`\n]*['"`]/.test(context);
643
+ const branchMitigations = conditionBranches.map((tokens) => {
644
+ const disabled = disabledExpressions.some((expression) => (
645
+ tokens.some((token) => new RegExp(`\\b${escapeRegExp(token)}\\b`).test(expression))
646
+ ));
647
+ if (disabled) return 'disabled';
648
+ if (tokens.some((token) => /loading|pending|submitting|isLoading|isPending|isSubmitting/i.test(token)) && hasLoadingAffordance) {
649
+ return 'loading';
650
+ }
651
+ return null;
652
+ });
653
+ if (branchMitigations.length > 0 && branchMitigations.every(Boolean)) {
654
+ return branchMitigations.includes('disabled') ? 'disabled UI mitigation' : 'loading UI mitigation';
655
+ }
656
+ return null;
657
+ }
658
+
659
+ function collectConditionTokens(condition) {
660
+ return [...condition.matchAll(/[A-Za-z_$][\w$]*/g)]
661
+ .map((match) => match[0])
662
+ .filter((token) => !['if', 'return', 'true', 'false', 'null', 'undefined'].includes(token));
663
+ }
664
+
665
+ function buildValueContract({ profile, flowConfig }) {
666
+ const configured = flowConfig.value_contract ?? {};
667
+ return {
668
+ forbidden_labels: [...new Set(configured.forbidden_labels ?? [])],
669
+ required_labels: [...new Set(configured.required_labels ?? [])],
670
+ forbidden_new_registration_labels: [
671
+ ...new Set(configured.forbidden_new_registration_labels ?? [])
672
+ ]
673
+ };
674
+ }
675
+
676
+ function buildRuntimeProbePlan({ profile, story, flowConfig }) {
677
+ if (Array.isArray(flowConfig?.runtime_probes) && flowConfig.runtime_probes.length > 0) {
678
+ return {
679
+ status: 'available',
680
+ commands: flowConfig.runtime_probes.map((probe) => ({
681
+ id: probe.id,
682
+ intent: probe.intent ?? probe.title ?? probe.id,
683
+ path: probe.path ?? null,
684
+ mutates: probe.mutates === true,
685
+ steps: Array.isArray(probe.steps) ? probe.steps : []
686
+ })),
687
+ story_id: story?.story_id ?? null
688
+ };
689
+ }
690
+ return {
691
+ status: 'available',
692
+ commands: [],
693
+ story_id: story?.story_id ?? null
694
+ };
695
+ }
696
+
697
+ function resolveStatus(result) {
698
+ const allHits = [
699
+ ...(result.silent_noop_hits ?? []),
700
+ ...(result.ambiguous_primary_action_hits ?? []),
701
+ ...(result.selection_side_effect_hits ?? []),
702
+ ...(result.question_dead_end_hits ?? []),
703
+ ...(result.dead_ui_state_hits ?? []),
704
+ ...(result.interactive_contract_hits ?? []),
705
+ ...(result.value_alignment_hits ?? [])
706
+ ];
707
+ const gateHits = allHits.filter((hit) => hit.gate_effect !== 'info');
708
+ if (gateHits.some((hit) => hit.severity === 'Critical')) return 'block';
709
+ if (gateHits.length > 0) return 'needs_review';
710
+ return 'pass';
711
+ }
712
+
713
+ function isClosingOrNonInteractiveTag(tag, attrs) {
714
+ if (!tag || tag.startsWith('/')) return true;
715
+ if (/^(summary|details)$/i.test(tag)) return true;
716
+ if (/^[a-z]/.test(tag) && !NATIVE_INTERACTIVE_TAGS.has(tag.toLowerCase())) {
717
+ return !hasExplicitInteractiveSignal(attrs);
718
+ }
719
+ if (/^(Fragment|React\.Fragment|form|input|select|textarea|option|img|svg|path|p|h[1-6]|ul|ol|li|section|main|header|footer)$/i.test(tag)) {
720
+ return !hasExplicitInteractiveSignal(attrs);
721
+ }
722
+ if (/^label$/i.test(tag)) {
723
+ return !hasExplicitInteractiveSignal(attrs) && !/\bhtmlFor\s*=|\bfor\s*=/.test(attrs);
724
+ }
725
+ return false;
726
+ }
727
+
728
+ function looksInteractive(tag, attrs, label) {
729
+ if (/^(button|a)$/i.test(tag)) return true;
730
+ if (hasExplicitInteractiveSignal(attrs)) return true;
731
+ if (/[A-Z][A-Za-z0-9_.]*(Button|Link|Action|Trigger|Tab|MenuItem)$/.test(tag)) return true;
732
+ if (isKnownInteractionContractComponent(tag)) return true;
733
+ return false;
734
+ }
735
+
736
+ function classifyDirectInteractiveContract(tag, attrs) {
737
+ if (/\bdisabled(?:\s*=\s*(?:\{?true\}?|["']true["']))?/.test(attrs)) return 'disabled';
738
+ if (/\baria-disabled\s*=\s*(?:\{?true\}?|["']true["'])/.test(attrs)) return 'disabled';
739
+ if (/\bhref\s*=|\bto\s*=/.test(attrs)) return 'navigation';
740
+ if (/^label$/i.test(tag) && /\bhtmlFor\s*=|\bfor\s*=/.test(attrs)) return 'label_for_control';
741
+ if (isKnownInteractionContractComponent(tag)) return 'component_wrapper_contract';
742
+ if (isCustomInteractiveComponentWithOwnContract(tag)) return 'custom_component_contract';
743
+ if (/\bformAction\s*=/.test(attrs)) return 'submit';
744
+ if (/^button$/i.test(tag) && /\btype\s*=\s*["']submit["']/.test(attrs)) return 'submit';
745
+ if (/\bonClick\s*=\s*\{\s*(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?:=>|=\$)/.test(attrs)) return 'inline-handler';
746
+ if (/\bonMouseDown\s*=|\bonKeyDown\s*=|\bonSubmit\s*=|\bonChange\s*=/.test(attrs)) return 'handler';
747
+ return null;
748
+ }
749
+
750
+ function extractHandlerName(attrs) {
751
+ const match = attrs.match(/\bonClick\s*=\s*\{\s*([A-Za-z_$][\w$]*)\s*\}/);
752
+ return match?.[1] ?? null;
753
+ }
754
+
755
+ function handlerBodyHasUserVisibleContract(body) {
756
+ const normalized = cleanup(body);
757
+ if (!normalized || /^\{\s*\}$/.test(normalized)) return false;
758
+ if (/\b(fetch|axios|mutate|save|create|update|delete|submit|post|put|patch)\b/i.test(body)) return true;
759
+ if (/\b(router\.push|router\.replace|navigate\s*\(|location\.href|window\.open|history\.pushState)\b/.test(body)) return true;
760
+ if (/\bset[A-Z][A-Za-z0-9_]*\s*\(/.test(body)) return true;
761
+ if (/\b(scrollIntoView|focus\s*\(|document\.getElementById|location\.hash)\b/.test(body)) return true;
762
+ if (/(準備中|未実装|近日|coming soon|not implemented|disabled|toast|setError|alert\s*\()/i.test(body)) return true;
763
+ if (/^\{\s*(?:event\.)?preventDefault\s*\(\s*\)\s*;?\s*\}$/.test(normalized)) return false;
764
+ if (/^\{\s*(?:console\.(log|warn|error)\s*\([^)]*\)\s*;?\s*)+\}$/.test(normalized)) return false;
765
+ return !/(TODO|noop|no-op|placeholder|仮置き)/i.test(body);
766
+ }
767
+
768
+ function hasExplicitUnavailableState(attrs, label, after) {
769
+ const text = `${attrs} ${label ?? ''} ${after.match(/^([^<]{0,120})/)?.[1] ?? ''}`;
770
+ return /(disabled|aria-disabled|準備中|未実装|近日公開|coming soon|not implemented|placeholder|工事中)/i.test(text);
771
+ }
772
+
773
+ function extractElementLabel(after) {
774
+ const text = (after.match(/^([^<]{0,160})/)?.[1] ?? '')
775
+ .replace(/\{[^}]*\}/g, ' ');
776
+ return cleanup(text).slice(0, 80);
777
+ }
778
+
779
+ function normalizeJsxForTagScan(content) {
780
+ return content.replace(/=>/g, JSX_ARROW_TOKEN);
781
+ }
782
+
783
+ function hasExplicitInteractiveSignal(attrs) {
784
+ return /\brole\s*=\s*["']button["']/.test(attrs)
785
+ || /\bonClick\s*=|\bonMouseDown\s*=|\bonKeyDown\s*=/.test(attrs)
786
+ || /\b(className|class)\s*=\s*["'`{][^"'`}]*\b(button|btn|action|clickable|link|tab|trigger|menu|detail|summary)\b/i.test(attrs);
787
+ }
788
+
789
+ function collectParentLinkRanges(code) {
790
+ const ranges = [];
791
+ const pattern = /<Link\b([^<>]*?)>/g;
792
+ for (const match of code.matchAll(pattern)) {
793
+ const attrs = match[1] ?? '';
794
+ if (!/\bhref\s*=|\bto\s*=/.test(attrs)) continue;
795
+ const closeIndex = code.indexOf('</Link>', match.index + match[0].length);
796
+ if (closeIndex < 0) continue;
797
+ ranges.push({ start: match.index, end: closeIndex + '</Link>'.length });
798
+ }
799
+ return ranges;
800
+ }
801
+
802
+ function isInsideParentLink(index, ranges) {
803
+ return ranges.some((range) => index > range.start && index < range.end);
804
+ }
805
+
806
+ function collectContractWrapperRanges(code) {
807
+ const ranges = [];
808
+ const pattern = /<([A-Za-z][A-Za-z0-9_.]*)\b([^<>]*?)>/g;
809
+ for (const match of code.matchAll(pattern)) {
810
+ const tag = match[1];
811
+ if (!isKnownInteractionContractComponent(tag)) continue;
812
+ const closeIndex = findClosingTagIndex(code, tag, match.index + match[0].length);
813
+ if (closeIndex < 0) continue;
814
+ ranges.push({ start: match.index, end: closeIndex + tag.length + 3 });
815
+ }
816
+ return ranges;
817
+ }
818
+
819
+ function findClosingTagIndex(code, tag, fromIndex) {
820
+ const closeTag = `</${tag}>`;
821
+ return code.indexOf(closeTag, fromIndex);
822
+ }
823
+
824
+ function isInsideContractWrapper(index, ranges) {
825
+ return ranges.some((range) => index > range.start && index < range.end);
826
+ }
827
+
828
+ function isKnownInteractionContractComponent(tag) {
829
+ const normalized = tag.replace(/^.*\./, '');
830
+ if (KNOWN_INTERACTION_CONTRACT_COMPONENTS.has(tag) || KNOWN_INTERACTION_CONTRACT_COMPONENTS.has(normalized)) {
831
+ return true;
832
+ }
833
+ const [namespace, local] = tag.split('.');
834
+ return Boolean(namespace && local && KNOWN_INTERACTION_NAMESPACES.has(namespace) && KNOWN_INTERACTION_LOCALS.has(local));
835
+ }
836
+
837
+ function isCustomInteractiveComponentWithOwnContract(tag) {
838
+ if (!/^[A-Z]/.test(tag)) return false;
839
+ if (/^(Button|Link|Action|Trigger|Tab|MenuItem)$/.test(tag)) return false;
840
+ if (/^(button|a)$/i.test(tag)) return false;
841
+ return /(Button|Link|Action|Trigger|MenuItem)$/.test(tag);
842
+ }
843
+
844
+ function isUiStory(story, flowConfig) {
845
+ if (flowConfig.enabled === true) return true;
846
+ const text = [story?.story_id, story?.title, story?.view].filter(Boolean).join(' ');
847
+ return /UI|画面|導線|登録|質問|フォーム|dashboard|frontend|user/i.test(text);
848
+ }
849
+
850
+ function inferProfile(story) {
851
+ return 'generic';
852
+ }
853
+
854
+ function isApiRoute(file) {
855
+ return /(?:^|\/)api\/.+\/route\.(js|jsx|ts|tsx)$/.test(file) || /(?:^|\/)pages\/api\//.test(file);
856
+ }
857
+
858
+ function isNewRegistrationFile(file) {
859
+ return /(?:^|\/)(new|register|registration)(?:\/|\.|$)/i.test(file);
860
+ }
861
+
862
+ function isTestOrMockFile(file) {
863
+ return /(?:^|\/)__mocks__(?:\/|$)/.test(file)
864
+ || /\.(test|spec)\.(js|jsx|ts|tsx)$/.test(file)
865
+ || /(?:^|\/)(test|tests|__tests__)(?:\/|$)/.test(file);
866
+ }
867
+
868
+ function stripComments(content) {
869
+ return content
870
+ .replace(/\/\*[\s\S]*?\*\//g, '')
871
+ .replace(/(^|[^:])\/\/.*$/gm, '$1');
872
+ }
873
+
874
+ function lineNumberAt(content, index) {
875
+ return content.slice(0, index).split(/\r?\n/).length;
876
+ }
877
+
878
+ function cleanup(value) {
879
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
880
+ }
881
+
882
+ function uniqueBy(items, keyFn) {
883
+ const seen = new Set();
884
+ const values = [];
885
+ for (const item of items) {
886
+ const key = keyFn(item);
887
+ if (seen.has(key)) continue;
888
+ seen.add(key);
889
+ values.push(item);
890
+ }
891
+ return values;
892
+ }
893
+
894
+ function escapeRegExp(value) {
895
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
896
+ }