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,330 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const IGNORED_DIRS = new Set([
|
|
5
|
+
'.git',
|
|
6
|
+
'.next',
|
|
7
|
+
'.turbo',
|
|
8
|
+
'.vibepro',
|
|
9
|
+
'coverage',
|
|
10
|
+
'dist',
|
|
11
|
+
'node_modules',
|
|
12
|
+
'graphify-out'
|
|
13
|
+
]);
|
|
14
|
+
const TEXT_EXTENSIONS = new Set(['.css', '.htm', '.html', '.js', '.jsx', '.mjs', '.ts', '.tsx']);
|
|
15
|
+
const UI_ROOT_PATTERN = /^(app|components|pages|public|src|styles)\//;
|
|
16
|
+
const GESTURE_SURFACE_PATTERN = /\b(carousel|slider|swiper|snap|scroll-x|horizontal|map|mapbox|google-map|leaflet|drag|draggable|swipe|touch)\b/i;
|
|
17
|
+
const MAP_SURFACE_PATTERN = /\b(map|mapbox|google-map|leaflet|marker|pin|overlay)\b/i;
|
|
18
|
+
const CAROUSEL_SURFACE_PATTERN = /\b(carousel|slider|swiper|slide|snap|scroll-x|horizontal)\b/i;
|
|
19
|
+
const TOUCH_TARGET_PATTERN = /\b(card|marker|pin|carousel|slide|item|thumb|button|btn)\b/i;
|
|
20
|
+
const TOUCH_ACTION_PATTERN = /touch-action\s*:\s*([^;]+)/i;
|
|
21
|
+
const POSITIONED_OVERLAY_PATTERN = /position\s*:\s*(absolute|fixed|sticky)\b/i;
|
|
22
|
+
const POINTER_EVENTS_NONE_PATTERN = /pointer-events\s*:\s*none\b/i;
|
|
23
|
+
const OVERFLOW_X_PATTERN = /overflow-x\s*:\s*(auto|scroll)\b/i;
|
|
24
|
+
const SCROLL_SNAP_PATTERN = /scroll-snap-type\s*:/i;
|
|
25
|
+
const MARKER_CODE_PATTERN = /\b(AdvancedMarkerElement|google\.maps\.Marker|MarkerF|MapMarker|<Marker\b|<AdvancedMarker\b|mapbox|leaflet|L\.marker)\b/;
|
|
26
|
+
const DRAG_HANDLER_PATTERN = /\b(onPointerDown|onPointerMove|onPointerUp|onTouchStart|onTouchMove|onTouchEnd|onMouseDown|onMouseMove|onMouseUp|onDragStart|draggable)\b/;
|
|
27
|
+
const CLICK_NAV_PATTERN = /\b(onClick|router\.push|navigate\s*\(|window\.location|href=)\b/;
|
|
28
|
+
const DRAG_STATE_PATTERN = /\b(isDragging|dragging|dragStart|touchStart|pointerStart|swipeStart|isSwiping|hasDragged|dragDistance|touchMoved)\b/g;
|
|
29
|
+
const CLICK_SUPPRESSION_PATTERN = /\b(preventClick|ignoreClick|suppressClick|cancelClick|hasDragged|touchMoved)\b|Math\.abs\s*\(|event\.preventDefault\s*\(|e\.preventDefault\s*\(/i;
|
|
30
|
+
const DRAG_THRESHOLD_PATTERN = /\b(threshold|delta|dragDistance|swipeDistance|minDrag|movementX|movementY|clientX|clientY|pageX|pageY|Math\.abs)\b/i;
|
|
31
|
+
const MARKER_LAYERING_PATTERN = /\b(collisionBehavior|zIndex|z-index|aria-label|selected|active|contrast|outline|box-shadow)\b/i;
|
|
32
|
+
|
|
33
|
+
export async function scanGestureInteraction(repoRoot) {
|
|
34
|
+
const root = path.resolve(repoRoot);
|
|
35
|
+
const files = await collectFiles(root);
|
|
36
|
+
const result = {
|
|
37
|
+
schema_version: '0.1.0',
|
|
38
|
+
status: 'pass',
|
|
39
|
+
scanned_files: files.length,
|
|
40
|
+
touch_action_hits: [],
|
|
41
|
+
overlay_pointer_hits: [],
|
|
42
|
+
drag_tap_hits: [],
|
|
43
|
+
carousel_hits: [],
|
|
44
|
+
map_marker_hits: [],
|
|
45
|
+
risk_summary: {
|
|
46
|
+
touch_action_hits: { block: 0, review: 0, info: 0 },
|
|
47
|
+
overlay_pointer_hits: { block: 0, review: 0, info: 0 },
|
|
48
|
+
drag_tap_hits: { block: 0, review: 0, info: 0 },
|
|
49
|
+
carousel_hits: { block: 0, review: 0, info: 0 },
|
|
50
|
+
map_marker_hits: { block: 0, review: 0, info: 0 }
|
|
51
|
+
},
|
|
52
|
+
summary: {
|
|
53
|
+
total_hits: 0,
|
|
54
|
+
scanned_files: files.length
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
const content = await readFile(file.absolutePath, 'utf8');
|
|
60
|
+
const ext = path.extname(file.relativePath).toLowerCase();
|
|
61
|
+
if (['.css', '.html', '.htm'].includes(ext)) {
|
|
62
|
+
collectCssGestureHits(result, file.relativePath, content);
|
|
63
|
+
}
|
|
64
|
+
if (['.js', '.jsx', '.mjs', '.ts', '.tsx', '.html', '.htm'].includes(ext)) {
|
|
65
|
+
collectCodeGestureHits(result, file.relativePath, content);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const key of Object.keys(result.risk_summary)) {
|
|
70
|
+
result.risk_summary[key] = summarizeGateEffects(result[key]);
|
|
71
|
+
}
|
|
72
|
+
result.summary.total_hits = [
|
|
73
|
+
result.touch_action_hits,
|
|
74
|
+
result.overlay_pointer_hits,
|
|
75
|
+
result.drag_tap_hits,
|
|
76
|
+
result.carousel_hits,
|
|
77
|
+
result.map_marker_hits
|
|
78
|
+
].reduce((total, hits) => total + hits.length, 0);
|
|
79
|
+
result.status = result.summary.total_hits > 0 ? 'needs_review' : 'pass';
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function collectCssGestureHits(result, file, content) {
|
|
84
|
+
const rulePattern = /([^{}]+)\{([^{}]*)\}/gm;
|
|
85
|
+
let match;
|
|
86
|
+
while ((match = rulePattern.exec(content)) !== null) {
|
|
87
|
+
const selector = cleanup(match[1]);
|
|
88
|
+
const body = match[2];
|
|
89
|
+
const selectorAndBody = `${selector} ${body}`;
|
|
90
|
+
const line = lineNumberAt(content, match.index);
|
|
91
|
+
const excerpt = `${selector} { ${cleanup(body).slice(0, 160)} }`;
|
|
92
|
+
const touchAction = TOUCH_ACTION_PATTERN.exec(body)?.[1]?.trim();
|
|
93
|
+
|
|
94
|
+
if (touchAction && GESTURE_SURFACE_PATTERN.test(selectorAndBody) && isAmbiguousTouchAction(touchAction)) {
|
|
95
|
+
result.touch_action_hits.push({
|
|
96
|
+
file,
|
|
97
|
+
line,
|
|
98
|
+
kind: 'ambiguous_touch_action_on_gesture_surface',
|
|
99
|
+
selector,
|
|
100
|
+
touch_action: touchAction,
|
|
101
|
+
excerpt,
|
|
102
|
+
confidence: /pan-x.*pan-y|pan-y.*pan-x/.test(touchAction) ? 'high' : 'medium',
|
|
103
|
+
gate_effect: 'review',
|
|
104
|
+
recommendation: 'carousel、map、drag surfaceでは縦横panやpinch-zoomを同時に許可する前に、スワイプ、地図移動、ページスクロールの優先順位をStory/E2Eで明示する。'
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (MAP_SURFACE_PATTERN.test(selectorAndBody)
|
|
109
|
+
&& POSITIONED_OVERLAY_PATTERN.test(body)
|
|
110
|
+
&& !POINTER_EVENTS_NONE_PATTERN.test(body)
|
|
111
|
+
&& /z-index\s*:|inset\s*:|top\s*:|bottom\s*:|left\s*:|right\s*:/.test(body)) {
|
|
112
|
+
result.overlay_pointer_hits.push({
|
|
113
|
+
file,
|
|
114
|
+
line,
|
|
115
|
+
kind: 'map_overlay_may_capture_touch',
|
|
116
|
+
selector,
|
|
117
|
+
excerpt,
|
|
118
|
+
confidence: 'medium',
|
|
119
|
+
gate_effect: 'review',
|
|
120
|
+
recommendation: 'map上のoverlayは操作を奪う必要がある領域だけpointer-events:autoにし、装飾や表示だけのlayerはpointer-events:noneにする。'
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (CAROUSEL_SURFACE_PATTERN.test(selectorAndBody) && OVERFLOW_X_PATTERN.test(body) && !SCROLL_SNAP_PATTERN.test(body)) {
|
|
125
|
+
result.carousel_hits.push({
|
|
126
|
+
file,
|
|
127
|
+
line,
|
|
128
|
+
kind: 'carousel_missing_scroll_snap_contract',
|
|
129
|
+
selector,
|
|
130
|
+
excerpt,
|
|
131
|
+
confidence: 'medium',
|
|
132
|
+
gate_effect: 'review',
|
|
133
|
+
recommendation: 'carouselはscroll-snap、active item更新、drag threshold、または代替の明示制御を持つことを確認する。'
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const size = extractStaticSize(body);
|
|
138
|
+
if (size && TOUCH_TARGET_PATTERN.test(selector) && (size.width < 44 || size.height < 44)) {
|
|
139
|
+
result.carousel_hits.push({
|
|
140
|
+
file,
|
|
141
|
+
line,
|
|
142
|
+
kind: 'small_gesture_hit_area',
|
|
143
|
+
selector,
|
|
144
|
+
width_px: size.width,
|
|
145
|
+
height_px: size.height,
|
|
146
|
+
excerpt,
|
|
147
|
+
confidence: 'medium',
|
|
148
|
+
gate_effect: 'review',
|
|
149
|
+
recommendation: 'mobile touch targetやmap markerの実hit areaは44px程度を基準に、visual sizeだけでなくpaddingや透明hit areaで補強する。'
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function collectCodeGestureHits(result, file, content) {
|
|
156
|
+
const code = stripComments(content);
|
|
157
|
+
if (DRAG_HANDLER_PATTERN.test(code) && CLICK_NAV_PATTERN.test(code)) {
|
|
158
|
+
const states = [...new Set([...code.matchAll(DRAG_STATE_PATTERN)].map((match) => match[1]))];
|
|
159
|
+
if (states.length > 0 && !CLICK_SUPPRESSION_PATTERN.test(code)) {
|
|
160
|
+
result.drag_tap_hits.push({
|
|
161
|
+
file,
|
|
162
|
+
line: lineNumberAt(content, code.indexOf(states[0])),
|
|
163
|
+
kind: 'drag_state_not_connected_to_click_suppression',
|
|
164
|
+
state_candidates: states.slice(0, 5),
|
|
165
|
+
excerpt: excerptAround(content, states[0]),
|
|
166
|
+
confidence: 'medium',
|
|
167
|
+
gate_effect: 'review',
|
|
168
|
+
recommendation: 'drag/swipe stateを取得している場合は、移動量が閾値を超えたclickやnavigationを抑止し、Playwrightでdrag後にURLが変わらないことを確認する。'
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (CAROUSEL_SURFACE_PATTERN.test(code)
|
|
174
|
+
&& DRAG_HANDLER_PATTERN.test(code)
|
|
175
|
+
&& !DRAG_THRESHOLD_PATTERN.test(code)) {
|
|
176
|
+
result.carousel_hits.push({
|
|
177
|
+
file,
|
|
178
|
+
line: firstPatternLine(content, DRAG_HANDLER_PATTERN),
|
|
179
|
+
kind: 'carousel_drag_threshold_not_detected',
|
|
180
|
+
excerpt: excerptAround(content, 'onPointer'),
|
|
181
|
+
confidence: 'medium',
|
|
182
|
+
gate_effect: 'review',
|
|
183
|
+
recommendation: 'carouselのdrag/tap判定は開始座標、移動量、閾値、active card更新、scrollLeft変化を明示する。'
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (MARKER_CODE_PATTERN.test(code) && !MARKER_LAYERING_PATTERN.test(code)) {
|
|
188
|
+
result.map_marker_hits.push({
|
|
189
|
+
file,
|
|
190
|
+
line: firstPatternLine(content, MARKER_CODE_PATTERN),
|
|
191
|
+
kind: 'map_marker_layering_contract_missing',
|
|
192
|
+
excerpt: excerptAround(content, 'Marker'),
|
|
193
|
+
confidence: 'medium',
|
|
194
|
+
gate_effect: 'review',
|
|
195
|
+
recommendation: 'map markerはcollisionBehavior、zIndex、選択状態、contrast、hit areaの少なくとも一部を明示し、重なり時の操作優先度を確認する。'
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function collectFiles(root, current = root) {
|
|
201
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
202
|
+
const files = [];
|
|
203
|
+
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
|
|
206
|
+
const absolutePath = path.join(current, entry.name);
|
|
207
|
+
const relativePath = path.relative(root, absolutePath).split(path.sep).join('/');
|
|
208
|
+
|
|
209
|
+
if (entry.isDirectory()) {
|
|
210
|
+
files.push(...await collectFiles(root, absolutePath));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!entry.isFile()) continue;
|
|
215
|
+
if (!shouldScanFile(relativePath)) continue;
|
|
216
|
+
const fileStat = await stat(absolutePath);
|
|
217
|
+
if (fileStat.size > 1024 * 1024) continue;
|
|
218
|
+
files.push({ absolutePath, relativePath });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return files;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function shouldScanFile(relativePath) {
|
|
225
|
+
const ext = path.extname(relativePath).toLowerCase();
|
|
226
|
+
if (!TEXT_EXTENSIONS.has(ext)) return false;
|
|
227
|
+
if (!UI_ROOT_PATTERN.test(relativePath) && relativePath.includes('/')) return false;
|
|
228
|
+
return !relativePath.endsWith('.test.js')
|
|
229
|
+
&& !relativePath.endsWith('.test.jsx')
|
|
230
|
+
&& !relativePath.endsWith('.test.ts')
|
|
231
|
+
&& !relativePath.endsWith('.test.tsx');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isAmbiguousTouchAction(value) {
|
|
235
|
+
const normalized = value.toLowerCase();
|
|
236
|
+
if (normalized === 'auto' || normalized === 'manipulation') return true;
|
|
237
|
+
if (normalized.includes('pinch-zoom') && (normalized.includes('pan-x') || normalized.includes('pan-y'))) return true;
|
|
238
|
+
return normalized.includes('pan-x') && normalized.includes('pan-y');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function extractStaticSize(body) {
|
|
242
|
+
const width = extractPxValue(body, 'width') ?? extractPxValue(body, 'min-width');
|
|
243
|
+
const height = extractPxValue(body, 'height') ?? extractPxValue(body, 'min-height');
|
|
244
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
|
245
|
+
return { width, height };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function extractPxValue(body, property) {
|
|
249
|
+
const pattern = new RegExp(`${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px`, 'i');
|
|
250
|
+
const match = pattern.exec(body);
|
|
251
|
+
if (!match) return null;
|
|
252
|
+
return Number(match[1]);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function stripComments(content) {
|
|
256
|
+
return content
|
|
257
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
258
|
+
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function firstPatternLine(content, pattern) {
|
|
262
|
+
const match = pattern.exec(content);
|
|
263
|
+
return lineNumberAt(content, match?.index ?? 0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function excerptAround(content, token) {
|
|
267
|
+
const index = token ? content.indexOf(token) : -1;
|
|
268
|
+
const start = Math.max(0, index < 0 ? 0 : index - 80);
|
|
269
|
+
return cleanup(content.slice(start, start + 180));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function lineNumberAt(content, index) {
|
|
273
|
+
return content.slice(0, index).split(/\r?\n/).length;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function cleanup(value) {
|
|
277
|
+
return String(value).trim().replace(/\s+/g, ' ');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function summarizeGateEffects(hits) {
|
|
281
|
+
const summary = { block: 0, review: 0, info: 0 };
|
|
282
|
+
for (const hit of hits) {
|
|
283
|
+
const effect = ['block', 'review', 'info'].includes(hit.gate_effect) ? hit.gate_effect : 'info';
|
|
284
|
+
summary[effect] += 1;
|
|
285
|
+
}
|
|
286
|
+
return summary;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function renderGestureInteractionReport({ runId, gestureInteraction }) {
|
|
290
|
+
if (!gestureInteraction) {
|
|
291
|
+
return `# ジェスチャー操作診断結果
|
|
292
|
+
|
|
293
|
+
| 項目 | 内容 |
|
|
294
|
+
|------|------|
|
|
295
|
+
| Run ID | ${runId} |
|
|
296
|
+
| 状態 | gesture-interaction は適用されていない |
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
299
|
+
const groups = [
|
|
300
|
+
['touch_action_hits', 'touch-action候補'],
|
|
301
|
+
['overlay_pointer_hits', 'overlay pointer候補'],
|
|
302
|
+
['drag_tap_hits', 'drag/tap候補'],
|
|
303
|
+
['carousel_hits', 'carousel/hit area候補'],
|
|
304
|
+
['map_marker_hits', 'map marker候補']
|
|
305
|
+
];
|
|
306
|
+
return `# ジェスチャー操作診断結果
|
|
307
|
+
|
|
308
|
+
| 項目 | 内容 |
|
|
309
|
+
|------|------|
|
|
310
|
+
| Run ID | ${runId} |
|
|
311
|
+
| Status | ${gestureInteraction.status} |
|
|
312
|
+
| 走査ファイル | ${gestureInteraction.scanned_files}件 |
|
|
313
|
+
| 検出候補 | ${gestureInteraction.summary?.total_hits ?? 0}件 |
|
|
314
|
+
${groups.map(([key, label]) => `| ${label} | ${formatRiskCount(gestureInteraction[key] ?? [], gestureInteraction.risk_summary?.[key])} |`).join('\n')}
|
|
315
|
+
|
|
316
|
+
${groups.map(([key, label]) => `## ${label}
|
|
317
|
+
|
|
318
|
+
${renderHits(gestureInteraction[key] ?? [])}`).join('\n\n')}
|
|
319
|
+
`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function renderHits(hits) {
|
|
323
|
+
if (hits.length === 0) return '- なし';
|
|
324
|
+
return hits.map((hit) => `- ${hit.file}:${hit.line} ${hit.kind} confidence=${hit.confidence ?? '-'} gate_effect=${hit.gate_effect ?? '-'} ${hit.selector ? `selector=${hit.selector} ` : ''}\`${hit.excerpt ?? ''}\``).join('\n');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function formatRiskCount(hits = [], summary = null) {
|
|
328
|
+
const gateSummary = summary ?? summarizeGateEffects(hits);
|
|
329
|
+
return `${hits.length}件 (block=${gateSummary.block ?? 0}, review=${gateSummary.review ?? 0}, info=${gateSummary.info ?? 0})`;
|
|
330
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
export function normalizeGraphEdges(graph) {
|
|
2
|
+
if (Array.isArray(graph?.edges)) return { edges: graph.edges, sourceKey: 'edges' };
|
|
3
|
+
if (Array.isArray(graph?.links)) return { edges: graph.links, sourceKey: 'links' };
|
|
4
|
+
return { edges: [], sourceKey: null };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function buildGraphIndex({ nodes = [], edges = [] } = {}) {
|
|
8
|
+
const nodesById = new Map();
|
|
9
|
+
const nodesBySourceFile = new Map();
|
|
10
|
+
const degreeByNodeId = new Map();
|
|
11
|
+
const edgesByNodeId = new Map();
|
|
12
|
+
|
|
13
|
+
for (const node of nodes) {
|
|
14
|
+
if (!node || typeof node !== 'object' || typeof node.id !== 'string') continue;
|
|
15
|
+
nodesById.set(node.id, node);
|
|
16
|
+
const sourceFile = extractGraphNodeSourceFile(node);
|
|
17
|
+
if (sourceFile) {
|
|
18
|
+
const key = normalizeGraphPath(sourceFile);
|
|
19
|
+
if (!nodesBySourceFile.has(key)) nodesBySourceFile.set(key, []);
|
|
20
|
+
nodesBySourceFile.get(key).push(node);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const edge of edges) {
|
|
25
|
+
const source = getEdgeEndpoint(edge, 'source');
|
|
26
|
+
const target = getEdgeEndpoint(edge, 'target');
|
|
27
|
+
if (!source || !target) continue;
|
|
28
|
+
degreeByNodeId.set(source, (degreeByNodeId.get(source) ?? 0) + 1);
|
|
29
|
+
degreeByNodeId.set(target, (degreeByNodeId.get(target) ?? 0) + 1);
|
|
30
|
+
if (!edgesByNodeId.has(source)) edgesByNodeId.set(source, []);
|
|
31
|
+
if (!edgesByNodeId.has(target)) edgesByNodeId.set(target, []);
|
|
32
|
+
edgesByNodeId.get(source).push(edge);
|
|
33
|
+
edgesByNodeId.get(target).push(edge);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
nodesById,
|
|
38
|
+
nodesBySourceFile,
|
|
39
|
+
degreeByNodeId,
|
|
40
|
+
edgesByNodeId,
|
|
41
|
+
edgeCount: edges.length
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildGraphContextForRoutes(routes, graphIndex) {
|
|
46
|
+
const context = buildGraphContextForFiles(routes.map((route) => route.file), graphIndex);
|
|
47
|
+
return {
|
|
48
|
+
...context,
|
|
49
|
+
matched_route_count: context.matched_file_count,
|
|
50
|
+
affected_communities: context.affected_communities.map((community) => ({
|
|
51
|
+
...community,
|
|
52
|
+
route_count: community.file_count ?? community.route_count ?? 0
|
|
53
|
+
}))
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildGraphContextForFiles(files, graphIndex) {
|
|
58
|
+
const normalizedFiles = uniqueNormalizedGraphFiles(files);
|
|
59
|
+
const empty = {
|
|
60
|
+
...emptyGraphContext(),
|
|
61
|
+
target_file_count: normalizedFiles.length,
|
|
62
|
+
matched_file_count: 0,
|
|
63
|
+
matched_files: [],
|
|
64
|
+
unmatched_files: normalizedFiles,
|
|
65
|
+
related_files: [],
|
|
66
|
+
community_span: 0,
|
|
67
|
+
cross_community: false
|
|
68
|
+
};
|
|
69
|
+
if (!graphIndex || normalizedFiles.length === 0) return empty;
|
|
70
|
+
|
|
71
|
+
const targetFiles = new Set(normalizedFiles);
|
|
72
|
+
const matchedFiles = new Set();
|
|
73
|
+
const matchedNodesById = new Map();
|
|
74
|
+
const relatedEdges = new Set();
|
|
75
|
+
|
|
76
|
+
for (const file of normalizedFiles) {
|
|
77
|
+
const matchedNodes = graphIndex.nodesBySourceFile.get(file) ?? [];
|
|
78
|
+
if (matchedNodes.length === 0) continue;
|
|
79
|
+
matchedFiles.add(file);
|
|
80
|
+
for (const node of matchedNodes) {
|
|
81
|
+
matchedNodesById.set(node.id, node);
|
|
82
|
+
for (const edge of graphIndex.edgesByNodeId.get(node.id) ?? []) {
|
|
83
|
+
relatedEdges.add(edge);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (matchedNodesById.size === 0) return empty;
|
|
89
|
+
|
|
90
|
+
const touchedNodeIds = new Set(matchedNodesById.keys());
|
|
91
|
+
for (const edge of relatedEdges) {
|
|
92
|
+
const source = getEdgeEndpoint(edge, 'source');
|
|
93
|
+
const target = getEdgeEndpoint(edge, 'target');
|
|
94
|
+
if (source) touchedNodeIds.add(source);
|
|
95
|
+
if (target) touchedNodeIds.add(target);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const affectedCommunities = buildAffectedCommunities({
|
|
99
|
+
matchedNodes: [...matchedNodesById.values()],
|
|
100
|
+
matchedRouteFiles: new Set(),
|
|
101
|
+
matchedFiles,
|
|
102
|
+
relatedEdges: [...relatedEdges],
|
|
103
|
+
graphIndex
|
|
104
|
+
});
|
|
105
|
+
const affectedCommunityIds = new Set(affectedCommunities.map((community) => community.id));
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
matched_route_count: 0,
|
|
109
|
+
target_file_count: normalizedFiles.length,
|
|
110
|
+
matched_file_count: matchedFiles.size,
|
|
111
|
+
matched_files: [...matchedFiles].sort(),
|
|
112
|
+
unmatched_files: normalizedFiles.filter((file) => !matchedFiles.has(file)),
|
|
113
|
+
matched_node_count: matchedNodesById.size,
|
|
114
|
+
affected_communities: affectedCommunities,
|
|
115
|
+
hub_nodes: buildHubNodes({
|
|
116
|
+
touchedNodeIds,
|
|
117
|
+
matchedNodeIds: new Set(matchedNodesById.keys()),
|
|
118
|
+
affectedCommunityIds,
|
|
119
|
+
graphIndex
|
|
120
|
+
}),
|
|
121
|
+
related_files: buildRelatedGraphFiles({ touchedNodeIds, targetFiles, graphIndex }),
|
|
122
|
+
related_edge_count: relatedEdges.size,
|
|
123
|
+
impact_score: calculateImpactScore(relatedEdges.size, graphIndex.edgeCount),
|
|
124
|
+
community_span: affectedCommunities.length,
|
|
125
|
+
cross_community: affectedCommunities.length > 1
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function emptyGraphContext() {
|
|
130
|
+
return {
|
|
131
|
+
matched_route_count: 0,
|
|
132
|
+
target_file_count: 0,
|
|
133
|
+
matched_file_count: 0,
|
|
134
|
+
matched_files: [],
|
|
135
|
+
unmatched_files: [],
|
|
136
|
+
matched_node_count: 0,
|
|
137
|
+
affected_communities: [],
|
|
138
|
+
hub_nodes: [],
|
|
139
|
+
related_files: [],
|
|
140
|
+
related_edge_count: 0,
|
|
141
|
+
impact_score: 0,
|
|
142
|
+
community_span: 0,
|
|
143
|
+
cross_community: false
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function normalizeGraphPath(filePath) {
|
|
148
|
+
return String(filePath).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function uniqueNormalizedGraphFiles(files) {
|
|
152
|
+
return [...new Set((files ?? [])
|
|
153
|
+
.map((file) => normalizeGraphPath(file ?? ''))
|
|
154
|
+
.filter(Boolean))]
|
|
155
|
+
.sort();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildAffectedCommunities({ matchedNodes, matchedRouteFiles, matchedFiles = matchedRouteFiles, relatedEdges, graphIndex }) {
|
|
159
|
+
const communities = new Map();
|
|
160
|
+
for (const node of matchedNodes) {
|
|
161
|
+
const id = node.community ?? 'unknown';
|
|
162
|
+
if (!communities.has(id)) {
|
|
163
|
+
communities.set(id, {
|
|
164
|
+
id,
|
|
165
|
+
routeFiles: new Set(),
|
|
166
|
+
files: new Set(),
|
|
167
|
+
nodeIds: new Set(),
|
|
168
|
+
edgeCount: 0
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const item = communities.get(id);
|
|
172
|
+
const sourceFile = extractGraphNodeSourceFile(node);
|
|
173
|
+
const normalizedSourceFile = sourceFile ? normalizeGraphPath(sourceFile) : null;
|
|
174
|
+
if (normalizedSourceFile && matchedRouteFiles.has(normalizedSourceFile)) {
|
|
175
|
+
item.routeFiles.add(normalizedSourceFile);
|
|
176
|
+
}
|
|
177
|
+
if (normalizedSourceFile && matchedFiles.has(normalizedSourceFile)) {
|
|
178
|
+
item.files.add(normalizedSourceFile);
|
|
179
|
+
}
|
|
180
|
+
item.nodeIds.add(node.id);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const edge of relatedEdges) {
|
|
184
|
+
const communityIds = new Set([
|
|
185
|
+
graphIndex.nodesById.get(getEdgeEndpoint(edge, 'source'))?.community ?? null,
|
|
186
|
+
graphIndex.nodesById.get(getEdgeEndpoint(edge, 'target'))?.community ?? null
|
|
187
|
+
].filter((id) => id !== null));
|
|
188
|
+
for (const id of communityIds) {
|
|
189
|
+
if (communities.has(id)) communities.get(id).edgeCount += 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return [...communities.values()]
|
|
194
|
+
.map((item) => ({
|
|
195
|
+
id: item.id,
|
|
196
|
+
route_count: item.routeFiles.size,
|
|
197
|
+
file_count: item.files.size,
|
|
198
|
+
node_count: item.nodeIds.size,
|
|
199
|
+
edge_count: item.edgeCount
|
|
200
|
+
}))
|
|
201
|
+
.sort((a, b) => b.route_count - a.route_count || b.file_count - a.file_count || b.node_count - a.node_count || String(a.id).localeCompare(String(b.id)));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function buildHubNodes({ touchedNodeIds, matchedNodeIds, affectedCommunityIds, graphIndex }) {
|
|
205
|
+
return [...touchedNodeIds]
|
|
206
|
+
.map((id) => graphIndex.nodesById.get(id))
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.filter((node) => isRelevantHubNode({ node, matchedNodeIds, affectedCommunityIds }))
|
|
209
|
+
.map((node) => ({
|
|
210
|
+
id: node.id,
|
|
211
|
+
label: node.label ?? node.name ?? node.id,
|
|
212
|
+
source_file: extractGraphNodeSourceFile(node) ?? null,
|
|
213
|
+
community: node.community ?? null,
|
|
214
|
+
degree: graphIndex.degreeByNodeId.get(node.id) ?? 0
|
|
215
|
+
}))
|
|
216
|
+
.sort((a, b) => b.degree - a.degree || a.id.localeCompare(b.id))
|
|
217
|
+
.slice(0, 5);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isRelevantHubNode({ node, matchedNodeIds, affectedCommunityIds }) {
|
|
221
|
+
if (matchedNodeIds.has(node.id)) return true;
|
|
222
|
+
if (!affectedCommunityIds.has(node.community ?? 'unknown')) return false;
|
|
223
|
+
const sourceFile = extractGraphNodeSourceFile(node);
|
|
224
|
+
if (!sourceFile) return false;
|
|
225
|
+
return normalizeGraphPath(sourceFile).startsWith('src/');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function buildRelatedGraphFiles({ touchedNodeIds, targetFiles, graphIndex }) {
|
|
229
|
+
const related = new Map();
|
|
230
|
+
for (const nodeId of touchedNodeIds) {
|
|
231
|
+
const node = graphIndex.nodesById.get(nodeId);
|
|
232
|
+
const sourceFile = node ? extractGraphNodeSourceFile(node) : null;
|
|
233
|
+
const file = sourceFile ? normalizeGraphPath(sourceFile) : null;
|
|
234
|
+
if (!file || targetFiles.has(file)) continue;
|
|
235
|
+
const degree = graphIndex.degreeByNodeId.get(nodeId) ?? 0;
|
|
236
|
+
related.set(file, Math.max(related.get(file) ?? 0, degree));
|
|
237
|
+
}
|
|
238
|
+
return [...related.entries()]
|
|
239
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
240
|
+
.slice(0, 8)
|
|
241
|
+
.map(([file]) => file);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function calculateImpactScore(relatedEdgeCount, totalEdgeCount) {
|
|
245
|
+
if (!totalEdgeCount) return 0;
|
|
246
|
+
return Number(Math.min(1, relatedEdgeCount / totalEdgeCount).toFixed(4));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function extractGraphNodeSourceFile(node) {
|
|
250
|
+
return node.source_file
|
|
251
|
+
?? node.sourceFile
|
|
252
|
+
?? node.file
|
|
253
|
+
?? node.path
|
|
254
|
+
?? node.payload?.source_file
|
|
255
|
+
?? node.payload?.sourceFile
|
|
256
|
+
?? null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function getEdgeEndpoint(edge, endpoint) {
|
|
260
|
+
if (!edge || typeof edge !== 'object') return null;
|
|
261
|
+
if (endpoint === 'source') return edge.source ?? edge.from ?? edge._src ?? edge.source_id ?? edge.sourceId ?? null;
|
|
262
|
+
return edge.target ?? edge.to ?? edge._dst ?? edge._tgt ?? edge.target_id ?? edge.targetId ?? null;
|
|
263
|
+
}
|