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,336 @@
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 COMPONENT_PATTERNS = [
17
+ { kind: 'button', pattern: /\b(button|btn|primary-button|secondary-button|action-button|terminal-action)\b/i },
18
+ { kind: 'tab', pattern: /\b(tab|tabs|segmented|toggle-group)\b/i },
19
+ { kind: 'card', pattern: /\b(card|panel|tile|item-card)\b/i },
20
+ { kind: 'list_item', pattern: /\b(row|list-item|task-item|session-item|item-row)\b/i },
21
+ { kind: 'filter', pattern: /\b(filter|search|query|sort|select)\b/i },
22
+ { kind: 'badge', pattern: /\b(badge|pill|chip|tag|status)\b/i },
23
+ { kind: 'input', pattern: /\b(input|textarea|select|field)\b/i },
24
+ { kind: 'modal', pattern: /\b(modal|dialog|popover|drawer|sheet)\b/i },
25
+ { kind: 'sidebar', pattern: /\b(sidebar|nav|navigation|activity-bar)\b/i }
26
+ ];
27
+ const LEGACY_STYLE_TOKENS = [
28
+ { token: '#0f172a', kind: 'tailwind_slate_background' },
29
+ { token: '#1e293b', kind: 'tailwind_slate_surface' },
30
+ { token: '#334155', kind: 'tailwind_slate_border' },
31
+ { token: '#475569', kind: 'tailwind_slate_muted' },
32
+ { token: '#64748b', kind: 'tailwind_slate_muted' },
33
+ { token: '#ef4444', kind: 'default_red_accent' },
34
+ { token: 'rgb(239, 68, 68)', kind: 'default_red_accent' },
35
+ { token: 'rgba(239, 68, 68', kind: 'default_red_accent' },
36
+ { token: 'border-radius: 16px', kind: 'large_rounded_card' },
37
+ { token: 'border-radius: 20px', kind: 'large_rounded_card' },
38
+ { token: 'border-radius: 24px', kind: 'large_rounded_card' },
39
+ { token: 'box-shadow: 0 20px', kind: 'heavy_drop_shadow' },
40
+ { token: 'box-shadow: 0 24px', kind: 'heavy_drop_shadow' },
41
+ { token: 'box-shadow: 0 32px', kind: 'heavy_drop_shadow' }
42
+ ];
43
+ const DESIGN_SYSTEM_MARKERS = [
44
+ '--bb-',
45
+ '--vibepro-component',
46
+ 'data-component',
47
+ 'component-style',
48
+ 'design-token'
49
+ ];
50
+ const INTERACTIVE_SELECTOR_PATTERN = /\b(button|btn|action|icon|menu-toggle|tab|link|trigger|start|edit|delete|play|close|submit|toggle)\b|\[role=["']?button["']?\]|button\b/i;
51
+ const INTERACTIVE_STATE_PATTERN = /:(hover|focus|focus-visible|active)\b/i;
52
+ const MOVING_TRANSFORM_PATTERN = /transform\s*:\s*(?!none\b)[^;]*(translate|scale|rotate|matrix)/i;
53
+ const TRANSITION_ALL_PATTERN = /transition\s*:\s*all\b/i;
54
+
55
+ export async function scanComponentStyle(repoRoot) {
56
+ const root = path.resolve(repoRoot);
57
+ const files = await collectFiles(root);
58
+ const result = {
59
+ schema_version: '0.1.0',
60
+ scanned_files: files.length,
61
+ component_inventory: [],
62
+ component_kinds: [],
63
+ legacy_style_hits: [],
64
+ interaction_reliability_hits: [],
65
+ design_system_markers: [],
66
+ risk_summary: {
67
+ legacy_style_hits: { block: 0, review: 0, info: 0 },
68
+ interaction_reliability_hits: { block: 0, review: 0, info: 0 }
69
+ },
70
+ coverage: {
71
+ observed_component_kinds: [],
72
+ missing_component_kinds: [],
73
+ replacement_observable: false
74
+ }
75
+ };
76
+
77
+ for (const file of files) {
78
+ const content = await readFile(file.absolutePath, 'utf8');
79
+ const lines = content.split(/\r?\n/);
80
+ for (const [index, line] of lines.entries()) {
81
+ collectComponentInventory(result.component_inventory, file.relativePath, index + 1, line);
82
+ collectLegacyStyleHits(result.legacy_style_hits, file.relativePath, index + 1, line);
83
+ collectDesignSystemMarkers(result.design_system_markers, file.relativePath, index + 1, line);
84
+ }
85
+ if (path.extname(file.relativePath).toLowerCase() === '.css') {
86
+ collectInteractionReliabilityHits(result.interaction_reliability_hits, file.relativePath, content);
87
+ }
88
+ }
89
+
90
+ result.component_kinds = [...new Set(result.component_inventory.map((item) => item.kind))].sort();
91
+ result.coverage.observed_component_kinds = result.component_kinds;
92
+ result.coverage.replacement_observable = result.design_system_markers.length > 0;
93
+ result.coverage.missing_component_kinds = inferMissingComponentKinds(result.component_kinds);
94
+ result.risk_summary.legacy_style_hits = summarizeGateEffects(result.legacy_style_hits);
95
+ result.risk_summary.interaction_reliability_hits = summarizeGateEffects(result.interaction_reliability_hits);
96
+ return result;
97
+ }
98
+
99
+ async function collectFiles(root, current = root) {
100
+ const entries = await readdir(current, { withFileTypes: true });
101
+ const files = [];
102
+
103
+ for (const entry of entries) {
104
+ if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
105
+ const absolutePath = path.join(current, entry.name);
106
+ const relativePath = path.relative(root, absolutePath).split(path.sep).join('/');
107
+
108
+ if (entry.isDirectory()) {
109
+ files.push(...await collectFiles(root, absolutePath));
110
+ continue;
111
+ }
112
+
113
+ if (!entry.isFile()) continue;
114
+ if (!shouldScanFile(relativePath)) continue;
115
+ const fileStat = await stat(absolutePath);
116
+ if (fileStat.size > 1024 * 1024) continue;
117
+ files.push({ absolutePath, relativePath });
118
+ }
119
+
120
+ return files;
121
+ }
122
+
123
+ function shouldScanFile(relativePath) {
124
+ const ext = path.extname(relativePath).toLowerCase();
125
+ if (!TEXT_EXTENSIONS.has(ext)) return false;
126
+ if (!UI_ROOT_PATTERN.test(relativePath) && relativePath.includes('/')) return false;
127
+ return !relativePath.endsWith('.test.js')
128
+ && !relativePath.endsWith('.test.jsx')
129
+ && !relativePath.endsWith('.test.ts')
130
+ && !relativePath.endsWith('.test.tsx');
131
+ }
132
+
133
+ function collectComponentInventory(inventory, file, lineNumber, line) {
134
+ const excerpt = line.trim();
135
+ if (!excerpt) return;
136
+ for (const { kind, pattern } of COMPONENT_PATTERNS) {
137
+ if (!pattern.test(line)) continue;
138
+ inventory.push({
139
+ file,
140
+ line: lineNumber,
141
+ kind,
142
+ excerpt: excerpt.slice(0, 160)
143
+ });
144
+ return;
145
+ }
146
+ }
147
+
148
+ function collectLegacyStyleHits(hits, file, lineNumber, line) {
149
+ const normalized = line.toLowerCase();
150
+ for (const { token, kind } of LEGACY_STYLE_TOKENS) {
151
+ if (!normalized.includes(token.toLowerCase())) continue;
152
+ hits.push({
153
+ file,
154
+ line: lineNumber,
155
+ kind,
156
+ token,
157
+ excerpt: line.trim().slice(0, 160),
158
+ confidence: classifyLegacyConfidence(file, line),
159
+ gate_effect: 'review'
160
+ });
161
+ return;
162
+ }
163
+ }
164
+
165
+ function collectDesignSystemMarkers(markers, file, lineNumber, line) {
166
+ const marker = DESIGN_SYSTEM_MARKERS.find((candidate) => line.includes(candidate));
167
+ if (!marker) return;
168
+ markers.push({
169
+ file,
170
+ line: lineNumber,
171
+ marker,
172
+ excerpt: line.trim().slice(0, 160)
173
+ });
174
+ }
175
+
176
+ function collectInteractionReliabilityHits(hits, file, content) {
177
+ const rulePattern = /([^{}]+)\{([^{}]*)\}/gm;
178
+ const smallTargetCandidates = new Map();
179
+ const interactiveSvgChildren = new Map();
180
+ const svgPointerEventsNone = new Set();
181
+ let match;
182
+ while ((match = rulePattern.exec(content)) !== null) {
183
+ const selector = match[1].trim().replace(/\s+/g, ' ');
184
+ const body = match[2];
185
+ const lineNumber = lineNumberAt(content, match.index);
186
+ collectInteractiveSvgChildRule({
187
+ interactiveSvgChildren,
188
+ svgPointerEventsNone,
189
+ selector,
190
+ body,
191
+ file,
192
+ lineNumber
193
+ });
194
+ if (!INTERACTIVE_SELECTOR_PATTERN.test(selector)) continue;
195
+
196
+ const excerpt = `${selector} { ${body.trim().replace(/\s+/g, ' ').slice(0, 140)} }`;
197
+
198
+ if (INTERACTIVE_STATE_PATTERN.test(selector) && MOVING_TRANSFORM_PATTERN.test(body)) {
199
+ hits.push({
200
+ file,
201
+ line: lineNumber,
202
+ kind: 'interactive_target_moves_on_state',
203
+ selector,
204
+ excerpt,
205
+ confidence: 'high',
206
+ gate_effect: 'review',
207
+ recommendation: 'クリック可能要素のhover/focus/activeではhit targetを移動せず、色・border・shadowで状態を表現する。'
208
+ });
209
+ }
210
+
211
+ if (TRANSITION_ALL_PATTERN.test(body)) {
212
+ hits.push({
213
+ file,
214
+ line: lineNumber,
215
+ kind: 'transition_all_on_interactive_target',
216
+ selector,
217
+ excerpt,
218
+ confidence: 'medium',
219
+ gate_effect: 'review',
220
+ recommendation: 'transition: all はhit targetやlayoutの意図しない遷移を含むため、background-color/border-color/color/box-shadowなどに限定する。'
221
+ });
222
+ }
223
+
224
+ const targetSize = extractStaticTargetSize(body);
225
+ if (targetSize && !isInteractiveChildGraphicSelector(selector)) {
226
+ smallTargetCandidates.set(selector, {
227
+ file,
228
+ line: lineNumber,
229
+ kind: 'small_interactive_target',
230
+ selector,
231
+ width_px: targetSize.width,
232
+ height_px: targetSize.height,
233
+ excerpt,
234
+ confidence: 'medium',
235
+ gate_effect: 'review',
236
+ recommendation: '高頻度操作のclick targetは少なくとも28px程度を確保し、端クリックでも取りこぼさないようにする。'
237
+ });
238
+ }
239
+ }
240
+
241
+ for (const candidate of smallTargetCandidates.values()) {
242
+ if (candidate.width_px < 28 && candidate.height_px < 28) {
243
+ hits.push(candidate);
244
+ }
245
+ }
246
+
247
+ for (const [baseSelector, candidate] of interactiveSvgChildren.entries()) {
248
+ if (svgPointerEventsNone.has(baseSelector)) continue;
249
+ hits.push(candidate);
250
+ }
251
+ }
252
+
253
+ function collectInteractiveSvgChildRule({
254
+ interactiveSvgChildren,
255
+ svgPointerEventsNone,
256
+ selector,
257
+ body,
258
+ file,
259
+ lineNumber
260
+ }) {
261
+ const hasPointerEventsNone = /pointer-events\s*:\s*none\b/i.test(body);
262
+ for (const rawPart of selector.split(',')) {
263
+ const part = rawPart.trim().replace(/\s+/g, ' ');
264
+ const baseSelector = extractInteractiveSvgBaseSelector(part);
265
+ if (!baseSelector) continue;
266
+ if (hasPointerEventsNone) {
267
+ svgPointerEventsNone.add(baseSelector);
268
+ continue;
269
+ }
270
+ if (interactiveSvgChildren.has(baseSelector)) continue;
271
+ interactiveSvgChildren.set(baseSelector, {
272
+ file,
273
+ line: lineNumber,
274
+ kind: 'icon_child_captures_click_target',
275
+ selector: part,
276
+ base_selector: baseSelector,
277
+ excerpt: `${selector} { ${body.trim().replace(/\s+/g, ' ').slice(0, 140)} }`,
278
+ confidence: 'high',
279
+ gate_effect: 'review',
280
+ recommendation: 'アイコンボタン配下のsvg/svg *はpointer-events:noneにし、実座標クリックのtargetをbutton本体へ固定する。Playwrightではlocator.clickだけでなくelementFromPoint(center)とpage.mouse.clickで検証する。'
281
+ });
282
+ }
283
+ }
284
+
285
+ function extractInteractiveSvgBaseSelector(selector) {
286
+ const match = selector.match(/^(.+?)(?:\s*>\s*|\s+)svg(?:\s+\*)?$/i);
287
+ if (!match) return null;
288
+ const baseSelector = match[1].trim();
289
+ if (!baseSelector || !INTERACTIVE_SELECTOR_PATTERN.test(baseSelector)) return null;
290
+ return baseSelector;
291
+ }
292
+
293
+ function isInteractiveChildGraphicSelector(selector) {
294
+ return selector
295
+ .split(',')
296
+ .every((part) => /(?:^|[\s>+~])(?:i|svg|path|use)\b/i.test(part.trim()));
297
+ }
298
+
299
+ function lineNumberAt(content, index) {
300
+ return content.slice(0, index).split(/\r?\n/).length;
301
+ }
302
+
303
+ function extractStaticTargetSize(body) {
304
+ const width = extractPxValue(body, 'width');
305
+ const height = extractPxValue(body, 'height');
306
+ if (width == null || height == null) return null;
307
+ return { width, height };
308
+ }
309
+
310
+ function extractPxValue(body, property) {
311
+ const match = body.match(new RegExp(`(?:^|[;\\s])${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px\\b`, 'i'));
312
+ return match ? Number.parseFloat(match[1]) : null;
313
+ }
314
+
315
+ function classifyLegacyConfidence(file, line) {
316
+ if (/\/\*|\/\/|example|sample/i.test(line) || file.includes('/fixtures/')) return 'low';
317
+ if (path.extname(file).toLowerCase() === '.css') return 'high';
318
+ return 'medium';
319
+ }
320
+
321
+ function inferMissingComponentKinds(componentKinds) {
322
+ const observed = new Set(componentKinds);
323
+ const requiredWhenUiExists = ['button', 'card', 'input'];
324
+ if (observed.size === 0) return [];
325
+ return requiredWhenUiExists.filter((kind) => !observed.has(kind));
326
+ }
327
+
328
+ function summarizeGateEffects(hits) {
329
+ const summary = { block: 0, review: 0, info: 0 };
330
+ for (const hit of hits) {
331
+ if (hit.gate_effect === 'block') summary.block += 1;
332
+ else if (hit.gate_effect === 'review') summary.review += 1;
333
+ else summary.info += 1;
334
+ }
335
+ return summary;
336
+ }
@@ -0,0 +1,99 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { normalizeGraphPath } from './graph-context.js';
5
+
6
+ // Coverage ingestion for regression-risk scoring.
7
+ //
8
+ // Reads a coverage report produced by the project's own tooling (c8 / istanbul /
9
+ // nyc) and returns a Map of repo-relative file path -> line-coverage fraction
10
+ // (0..1). We never run coverage ourselves: coverage commands vary per project
11
+ // and belong to the project's test setup, not to a static scanner. If no report
12
+ // is present we return null so callers degrade to fan-in-only scoring.
13
+
14
+ // Standard locations, most specific (already has percentages) first.
15
+ const COVERAGE_CANDIDATES = [
16
+ 'coverage/coverage-summary.json',
17
+ 'coverage/coverage-final.json',
18
+ 'coverage/lcov.info'
19
+ ];
20
+
21
+ export async function loadCoverage(repoRoot, options = {}) {
22
+ const root = path.resolve(repoRoot);
23
+ const candidates = options.file ? [options.file] : COVERAGE_CANDIDATES;
24
+
25
+ for (const candidate of candidates) {
26
+ const absolute = path.resolve(root, candidate);
27
+ let raw;
28
+ try {
29
+ raw = await readFile(absolute, 'utf8');
30
+ } catch (error) {
31
+ if (error.code === 'ENOENT') continue;
32
+ throw error;
33
+ }
34
+ const parsed = candidate.endsWith('.info') ? parseLcov(raw) : parseIstanbulJson(raw);
35
+ if (parsed && parsed.size > 0) {
36
+ return { coverage: normalizeCoveragePaths(parsed, root), source: candidate };
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ // lcov.info -> Map<rawPath, fraction>. Uses line counts (LF/LH).
43
+ export function parseLcov(text) {
44
+ const coverage = new Map();
45
+ let file = null;
46
+ let found = 0;
47
+ let hit = 0;
48
+ for (const line of String(text).split(/\r?\n/)) {
49
+ if (line.startsWith('SF:')) {
50
+ file = line.slice(3).trim();
51
+ found = 0;
52
+ hit = 0;
53
+ } else if (line.startsWith('LF:')) {
54
+ found = Number(line.slice(3).trim()) || 0;
55
+ } else if (line.startsWith('LH:')) {
56
+ hit = Number(line.slice(3).trim()) || 0;
57
+ } else if (line.startsWith('end_of_record') && file) {
58
+ coverage.set(file, found > 0 ? hit / found : 1);
59
+ file = null;
60
+ }
61
+ }
62
+ return coverage;
63
+ }
64
+
65
+ // istanbul coverage-summary.json or coverage-final.json -> Map<rawPath, fraction>.
66
+ export function parseIstanbulJson(text) {
67
+ const data = JSON.parse(text);
68
+ const coverage = new Map();
69
+ for (const [key, value] of Object.entries(data)) {
70
+ if (key === 'total' || !value || typeof value !== 'object') continue;
71
+ // coverage-summary.json shape: { lines: { total, covered, pct } }
72
+ if (value.lines && typeof value.lines === 'object') {
73
+ const { total, covered, pct } = value.lines;
74
+ if (typeof covered === 'number' && typeof total === 'number') {
75
+ coverage.set(key, total > 0 ? covered / total : 1);
76
+ } else if (typeof pct === 'number') {
77
+ coverage.set(key, pct / 100);
78
+ }
79
+ continue;
80
+ }
81
+ // coverage-final.json shape: { statementMap, s: { id: hitCount } }
82
+ if (value.s && typeof value.s === 'object') {
83
+ const counts = Object.values(value.s);
84
+ const total = counts.length;
85
+ const covered = counts.filter((count) => count > 0).length;
86
+ coverage.set(key, total > 0 ? covered / total : 1);
87
+ }
88
+ }
89
+ return coverage;
90
+ }
91
+
92
+ function normalizeCoveragePaths(rawCoverage, root) {
93
+ const normalized = new Map();
94
+ for (const [rawPath, fraction] of rawCoverage) {
95
+ const relative = path.isAbsolute(rawPath) ? path.relative(root, rawPath) : rawPath;
96
+ normalized.set(normalizeGraphPath(relative), fraction);
97
+ }
98
+ return normalized;
99
+ }
@@ -0,0 +1,163 @@
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
+ 'node_modules',
11
+ 'graphify-out'
12
+ ]);
13
+ const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.ts', '.tsx']);
14
+ const GATE_EFFECTS = ['block', 'review', 'info'];
15
+
16
+ export async function scanDatabaseAccess(repoRoot) {
17
+ const root = path.resolve(repoRoot);
18
+ const files = await collectFiles(root);
19
+ const result = {
20
+ scanned_files: files.length,
21
+ unbounded_find_many: []
22
+ };
23
+
24
+ for (const file of files) {
25
+ const content = await readFile(file.absolutePath, 'utf8');
26
+ collectUnboundedFindMany(result.unbounded_find_many, file.relativePath, content);
27
+ }
28
+
29
+ result.risk_summary = {
30
+ unbounded_find_many: summarizeGateEffects(result.unbounded_find_many)
31
+ };
32
+ return result;
33
+ }
34
+
35
+ async function collectFiles(root, current = root) {
36
+ const entries = await readdir(current, { withFileTypes: true });
37
+ const files = [];
38
+
39
+ for (const entry of entries) {
40
+ if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
41
+ const absolutePath = path.join(current, entry.name);
42
+ const relativePath = path.relative(root, absolutePath).split(path.sep).join('/');
43
+
44
+ if (entry.isDirectory()) {
45
+ files.push(...await collectFiles(root, absolutePath));
46
+ continue;
47
+ }
48
+
49
+ if (!entry.isFile()) continue;
50
+ if (!SOURCE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue;
51
+ const fileStat = await stat(absolutePath);
52
+ if (fileStat.size > 1024 * 1024) continue;
53
+ files.push({ absolutePath, relativePath });
54
+ }
55
+
56
+ return files;
57
+ }
58
+
59
+ function collectUnboundedFindMany(hits, file, content) {
60
+ const code = stripComments(content);
61
+ const pattern = /\b(?:prisma|db|client|prismaAny)\.[A-Za-z_$][\w$]*\.findMany\s*\(/g;
62
+ let match = pattern.exec(code);
63
+ while (match) {
64
+ const call = extractCall(code, match.index);
65
+ if (call && !hasResultBound(call.text)) {
66
+ hits.push({
67
+ file,
68
+ line: lineNumberAt(code, match.index),
69
+ kind: 'prisma_find_many_without_bound',
70
+ excerpt: firstLine(call.text).slice(0, 160),
71
+ ...classifyDatabaseRisk(file)
72
+ });
73
+ }
74
+ pattern.lastIndex = call ? call.end : match.index + match[0].length;
75
+ match = pattern.exec(code);
76
+ }
77
+ }
78
+
79
+ function hasResultBound(callText) {
80
+ return /\b(take|skip|cursor|distinct)\s*:/.test(callText)
81
+ || /\b(limit|pageSize|maxResults|maxCount)\b/.test(callText);
82
+ }
83
+
84
+ function classifyDatabaseRisk(file) {
85
+ const sourceKind = classifySourceKind(file);
86
+ if (sourceKind !== 'runtime_code') {
87
+ return { source_kind: sourceKind, confidence: 'low', gate_effect: 'info' };
88
+ }
89
+ if (file.startsWith('src/app/') || file.startsWith('src/lib/services/')) {
90
+ return { source_kind: sourceKind, confidence: 'medium', gate_effect: 'review' };
91
+ }
92
+ return { source_kind: sourceKind, confidence: 'low', gate_effect: 'info' };
93
+ }
94
+
95
+ function classifySourceKind(file) {
96
+ const normalized = file.toLowerCase();
97
+ if (normalized.startsWith('scripts/') || normalized.includes('/crawlers/')) return 'batch_or_tooling';
98
+ if (/(^|\/)(__tests__|tests?|spec|fixtures?)(\/|$)/.test(normalized)
99
+ || /\.(test|spec)\.(js|jsx|ts|tsx)$/.test(normalized)) {
100
+ return 'test';
101
+ }
102
+ if (normalized.startsWith('docs/') || normalized.endsWith('.md')) return 'docs';
103
+ return 'runtime_code';
104
+ }
105
+
106
+ function extractCall(content, startIndex) {
107
+ const openIndex = content.indexOf('(', startIndex);
108
+ if (openIndex === -1) return null;
109
+ let depth = 0;
110
+ let quote = null;
111
+ let escaped = false;
112
+
113
+ for (let index = openIndex; index < content.length; index += 1) {
114
+ const char = content[index];
115
+ if (quote) {
116
+ if (escaped) {
117
+ escaped = false;
118
+ } else if (char === '\\') {
119
+ escaped = true;
120
+ } else if (char === quote) {
121
+ quote = null;
122
+ }
123
+ continue;
124
+ }
125
+ if (char === '"' || char === "'" || char === '`') {
126
+ quote = char;
127
+ continue;
128
+ }
129
+ if (char === '(') depth += 1;
130
+ if (char === ')') {
131
+ depth -= 1;
132
+ if (depth === 0) {
133
+ return {
134
+ text: content.slice(startIndex, index + 1),
135
+ end: index + 1
136
+ };
137
+ }
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ function stripComments(content) {
144
+ return content
145
+ .replace(/\/\*[\s\S]*?\*\//g, '')
146
+ .replace(/(^|[^:])\/\/.*$/gm, '$1');
147
+ }
148
+
149
+ function lineNumberAt(content, index) {
150
+ return content.slice(0, index).split(/\r?\n/).length;
151
+ }
152
+
153
+ function firstLine(text) {
154
+ return text.trim().split(/\r?\n/)[0].trim();
155
+ }
156
+
157
+ function summarizeGateEffects(hits) {
158
+ const summary = Object.fromEntries(GATE_EFFECTS.map((effect) => [effect, 0]));
159
+ for (const hit of hits) {
160
+ if (summary[hit.gate_effect] !== undefined) summary[hit.gate_effect] += 1;
161
+ }
162
+ return summary;
163
+ }