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,424 @@
|
|
|
1
|
+
import { readFile, mkdir, writeFile, access } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { getWorkspaceDir } from './workspace.js';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
const SCHEMA_VERSION = '0.1.0';
|
|
10
|
+
|
|
11
|
+
// L0 dependency signals -> typed Resource/Component facts.
|
|
12
|
+
// type one of: database | cache | queue | storage | auth | external_api | frontend | backend
|
|
13
|
+
const DEP_SIGNALS = [
|
|
14
|
+
{ match: ['next'], kind: 'component', type: 'frontend', label: 'Next.js app', provider: null },
|
|
15
|
+
{ match: ['nuxt'], kind: 'component', type: 'frontend', label: 'Nuxt app', provider: null },
|
|
16
|
+
{ match: ['@remix-run/react', '@remix-run/node'], kind: 'component', type: 'frontend', label: 'Remix app', provider: null },
|
|
17
|
+
{ match: ['react', 'vue', 'svelte', '@angular/core'], kind: 'component', type: 'frontend', label: 'Web frontend', provider: null },
|
|
18
|
+
{ match: ['express', 'fastify', '@nestjs/core', 'koa', 'hono'], kind: 'component', type: 'backend', label: 'API server', provider: null },
|
|
19
|
+
{ match: ['@prisma/client', 'prisma', 'pg', 'postgres'], kind: 'resource', type: 'database', label: 'PostgreSQL', provider: null, engine: 'postgres' },
|
|
20
|
+
{ match: ['mysql', 'mysql2'], kind: 'resource', type: 'database', label: 'MySQL', provider: null, engine: 'mysql' },
|
|
21
|
+
{ match: ['mongoose', 'mongodb'], kind: 'resource', type: 'database', label: 'MongoDB', provider: null, engine: 'mongodb' },
|
|
22
|
+
{ match: ['@planetscale/database'], kind: 'resource', type: 'database', label: 'PlanetScale', provider: 'planetscale', engine: 'mysql' },
|
|
23
|
+
{ match: ['@supabase/supabase-js'], kind: 'resource', type: 'database', label: 'Supabase', provider: 'supabase', engine: 'postgres' },
|
|
24
|
+
{ match: ['ioredis', 'redis', '@upstash/redis'], kind: 'resource', type: 'cache', label: 'Redis', provider: null, engine: 'redis' },
|
|
25
|
+
{ match: ['bullmq', 'bull'], kind: 'resource', type: 'queue', label: 'Job queue (Redis)', provider: null, engine: 'redis' },
|
|
26
|
+
{ match: ['@aws-sdk/client-sqs'], kind: 'resource', type: 'queue', label: 'AWS SQS', provider: 'aws', engine: 'sqs' },
|
|
27
|
+
{ match: ['@aws-sdk/client-s3'], kind: 'resource', type: 'storage', label: 'AWS S3', provider: 'aws', engine: 's3' },
|
|
28
|
+
{ match: ['next-auth', '@auth/core'], kind: 'resource', type: 'auth', label: 'Auth.js', provider: null },
|
|
29
|
+
{ match: ['@clerk/nextjs', '@clerk/clerk-sdk-node'], kind: 'resource', type: 'auth', label: 'Clerk', provider: 'clerk' },
|
|
30
|
+
{ match: ['stripe', '@stripe/stripe-js'], kind: 'resource', type: 'external_api', label: 'Stripe', provider: 'stripe' },
|
|
31
|
+
{ match: ['openai'], kind: 'resource', type: 'external_api', label: 'OpenAI', provider: 'openai' },
|
|
32
|
+
{ match: ['@anthropic-ai/sdk'], kind: 'resource', type: 'external_api', label: 'Anthropic', provider: 'anthropic' }
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// L0 env-key signals -> typed Resource facts.
|
|
36
|
+
const ENV_SIGNALS = [
|
|
37
|
+
{ match: /^(DATABASE_URL|POSTGRES(QL)?_URL|PG.*URL)$/i, type: 'database', label: 'Database', engine: 'postgres' },
|
|
38
|
+
{ match: /^(MYSQL_URL|MYSQL_DATABASE_URL)$/i, type: 'database', label: 'MySQL', engine: 'mysql' },
|
|
39
|
+
{ match: /^(MONGO(DB)?_URL|MONGO_URI)$/i, type: 'database', label: 'MongoDB', engine: 'mongodb' },
|
|
40
|
+
{ match: /^(REDIS_URL|UPSTASH_REDIS.*)$/i, type: 'cache', label: 'Redis', engine: 'redis' },
|
|
41
|
+
{ match: /^(NEXT_PUBLIC_)?SUPABASE_URL$/i, type: 'database', label: 'Supabase', provider: 'supabase', engine: 'postgres' },
|
|
42
|
+
{ match: /^STRIPE_(SECRET|PUBLISHABLE|API)?_?KEY$/i, type: 'external_api', label: 'Stripe', provider: 'stripe' },
|
|
43
|
+
{ match: /^OPENAI_API_KEY$/i, type: 'external_api', label: 'OpenAI', provider: 'openai' },
|
|
44
|
+
{ match: /^ANTHROPIC_API_KEY$/i, type: 'external_api', label: 'Anthropic', provider: 'anthropic' },
|
|
45
|
+
{ match: /^(CLERK_|NEXT_PUBLIC_CLERK_)/i, type: 'auth', label: 'Clerk', provider: 'clerk' },
|
|
46
|
+
{ match: /^(NEXTAUTH_|AUTH_)/i, type: 'auth', label: 'Auth.js' },
|
|
47
|
+
{ match: /^AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY|REGION|S3_BUCKET)$/i, type: 'storage', label: 'AWS', provider: 'aws' }
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Connection-string host -> provider attribution.
|
|
51
|
+
const HOST_PROVIDERS = [
|
|
52
|
+
{ match: /\.neon\.tech$/i, provider: 'neon' },
|
|
53
|
+
{ match: /\.supabase\.(co|com)$/i, provider: 'supabase' },
|
|
54
|
+
{ match: /\.upstash\.io$/i, provider: 'upstash' },
|
|
55
|
+
{ match: /(\.psdb\.cloud|\.planetscale\.)/i, provider: 'planetscale' },
|
|
56
|
+
{ match: /\.rds\.amazonaws\.com$/i, provider: 'aws_rds' },
|
|
57
|
+
{ match: /\.mongodb\.net$/i, provider: 'mongodb_atlas' },
|
|
58
|
+
{ match: /\.render\.com$/i, provider: 'render' },
|
|
59
|
+
{ match: /\.railway\.app$/i, provider: 'railway' }
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// Identity discriminator: infra resources are keyed by engine (postgres/redis/...)
|
|
63
|
+
// so a dependency signal and a connection-string signal for the same datastore
|
|
64
|
+
// merge even when only one knows the hosting provider. External/auth services are
|
|
65
|
+
// keyed by provider so e.g. Stripe and OpenAI stay distinct.
|
|
66
|
+
const ENGINE_KEYED = new Set(['database', 'cache', 'queue', 'storage']);
|
|
67
|
+
function nodeKey(kind, type, provider, engine) {
|
|
68
|
+
if (kind === 'component') return `component:${type}`;
|
|
69
|
+
const disc = ENGINE_KEYED.has(type) ? (engine ?? provider ?? 'unknown') : (provider ?? engine ?? type);
|
|
70
|
+
return `resource:${type}:${disc}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rankConfidence(c) {
|
|
74
|
+
return { ambiguous: 0, inferred: 1, confirmed: 2 }[c] ?? 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A named managed provider (neon, upstash, vercel, fly, ...) is more useful than
|
|
78
|
+
// the `self_hosted`/`local` placeholder a compose image implies, which in turn
|
|
79
|
+
// beats null. When two sources disagree, keep the most specific provider so a
|
|
80
|
+
// connection-string host (e.g. neon) is not overwritten by a compose container.
|
|
81
|
+
const PLACEHOLDER_PROVIDERS = new Set(['self_hosted', 'local']);
|
|
82
|
+
function providerRank(p) {
|
|
83
|
+
if (!p) return 0;
|
|
84
|
+
if (PLACEHOLDER_PROVIDERS.has(p)) return 1;
|
|
85
|
+
return 2;
|
|
86
|
+
}
|
|
87
|
+
function preferProvider(a, b) {
|
|
88
|
+
return providerRank(b) > providerRank(a) ? b : (a ?? b ?? null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function mergeNode(map, node, source) {
|
|
92
|
+
const key = nodeKey(node.kind, node.type, node.provider, node.engine);
|
|
93
|
+
const existing = map.get(key);
|
|
94
|
+
if (!existing) {
|
|
95
|
+
map.set(key, { ...node, id: key, sources: [source] });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Corroboration: independent source pointing at the same service.
|
|
99
|
+
if (!existing.sources.includes(source)) existing.sources.push(source);
|
|
100
|
+
existing.provider = preferProvider(existing.provider, node.provider);
|
|
101
|
+
existing.engine = existing.engine ?? node.engine;
|
|
102
|
+
existing.environment = existing.environment ?? node.environment;
|
|
103
|
+
// Confidence = strongest of the two signals (a confirmed deploy-config/IaC
|
|
104
|
+
// fact upgrades an inferred/ambiguous L0 node)...
|
|
105
|
+
let target = rankConfidence(node.confidence) > rankConfidence(existing.confidence)
|
|
106
|
+
? node.confidence
|
|
107
|
+
: existing.confidence;
|
|
108
|
+
// ...with an L0 corroboration bump: two independent weak signals -> inferred.
|
|
109
|
+
if (existing.sources.length >= 2 && rankConfidence(target) < rankConfidence('inferred')) {
|
|
110
|
+
target = 'inferred';
|
|
111
|
+
}
|
|
112
|
+
existing.confidence = target;
|
|
113
|
+
// A confirmed (deploy-config/IaC) fact provides the authoritative label.
|
|
114
|
+
if (rankConfidence(node.confidence) >= rankConfidence('confirmed') && node.label) existing.label = node.label;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Pure L0 graph builder. No filesystem access.
|
|
119
|
+
* @param {object} input
|
|
120
|
+
* @param {string[]} input.deps - dependency names (deps + devDeps)
|
|
121
|
+
* @param {Array<{key:string, host:(string|null)}>} input.envEntries
|
|
122
|
+
*/
|
|
123
|
+
export function buildEnvironmentGraph({ deps = [], envEntries = [], deployTargets = [] } = {}) {
|
|
124
|
+
const nodes = new Map();
|
|
125
|
+
const gaps = [];
|
|
126
|
+
|
|
127
|
+
for (const dep of deps) {
|
|
128
|
+
const sig = DEP_SIGNALS.find((s) => s.match.includes(dep));
|
|
129
|
+
if (!sig) continue;
|
|
130
|
+
mergeNode(nodes, {
|
|
131
|
+
kind: sig.kind,
|
|
132
|
+
type: sig.type,
|
|
133
|
+
label: sig.label,
|
|
134
|
+
provider: sig.provider ?? null,
|
|
135
|
+
engine: sig.engine ?? null,
|
|
136
|
+
// A recognized dependency is a strong-ish signal: inferred.
|
|
137
|
+
confidence: 'inferred'
|
|
138
|
+
}, `package.json:${dep}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const { key, host } of envEntries) {
|
|
142
|
+
const sig = ENV_SIGNALS.find((s) => s.match.test(key));
|
|
143
|
+
const hostProvider = host ? HOST_PROVIDERS.find((h) => h.match.test(host))?.provider ?? null : null;
|
|
144
|
+
if (!sig) {
|
|
145
|
+
// Unrecognized *_URL / *_KEY -> ambiguous gap, not a confident node.
|
|
146
|
+
if (/_(URL|URI|KEY|TOKEN|SECRET|DSN)$/i.test(key)) {
|
|
147
|
+
gaps.push({ kind: 'unclassified_env', key, note: `environment key "${key}" suggests an external dependency but could not be typed` });
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
mergeNode(nodes, {
|
|
152
|
+
kind: 'resource',
|
|
153
|
+
type: sig.type,
|
|
154
|
+
label: sig.label,
|
|
155
|
+
provider: sig.provider ?? hostProvider ?? null,
|
|
156
|
+
engine: sig.engine ?? null,
|
|
157
|
+
// A single env key is weaker than a dependency: ambiguous, upgraded by corroboration.
|
|
158
|
+
confidence: 'ambiguous'
|
|
159
|
+
}, host ? `.env:${key}@${host}` : `.env:${key}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// L1: platform deploy configs / compose / IaC -> confirmed facts that upgrade
|
|
163
|
+
// confidence and attribute provider + environment.
|
|
164
|
+
for (const t of deployTargets) {
|
|
165
|
+
mergeNode(nodes, {
|
|
166
|
+
kind: t.kind,
|
|
167
|
+
type: t.type,
|
|
168
|
+
label: t.label,
|
|
169
|
+
provider: t.provider ?? null,
|
|
170
|
+
engine: t.engine ?? null,
|
|
171
|
+
environment: t.environment ?? null,
|
|
172
|
+
confidence: t.confidence ?? 'confirmed'
|
|
173
|
+
}, t.source);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const nodeList = [...nodes.values()];
|
|
177
|
+
|
|
178
|
+
// Ensure at least one Component exists so resources have an owner edge.
|
|
179
|
+
let appComponent = nodeList.find((n) => n.kind === 'component');
|
|
180
|
+
if (!appComponent && nodeList.length > 0) {
|
|
181
|
+
appComponent = {
|
|
182
|
+
id: 'component:application:unknown',
|
|
183
|
+
kind: 'component',
|
|
184
|
+
type: 'application',
|
|
185
|
+
label: 'Application (unclassified)',
|
|
186
|
+
provider: null,
|
|
187
|
+
engine: null,
|
|
188
|
+
confidence: 'ambiguous',
|
|
189
|
+
sources: ['inferred:has-resources-without-explicit-component']
|
|
190
|
+
};
|
|
191
|
+
nodeList.push(appComponent);
|
|
192
|
+
gaps.push({ kind: 'unidentified_component', note: 'resources detected but no frontend/backend framework identified' });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const RELATION = {
|
|
196
|
+
database: 'reads_writes', cache: 'reads_writes', storage: 'reads_writes',
|
|
197
|
+
queue: 'publishes_to', auth: 'authenticates_with', external_api: 'consumes_api'
|
|
198
|
+
};
|
|
199
|
+
const edges = appComponent
|
|
200
|
+
? nodeList
|
|
201
|
+
.filter((n) => n.kind === 'resource')
|
|
202
|
+
.map((r) => ({
|
|
203
|
+
from: appComponent.id,
|
|
204
|
+
to: r.id,
|
|
205
|
+
relation: RELATION[r.type] ?? 'depends_on',
|
|
206
|
+
confidence: rankConfidence(r.confidence) <= rankConfidence(appComponent.confidence) ? r.confidence : appComponent.confidence,
|
|
207
|
+
sources: r.sources
|
|
208
|
+
}))
|
|
209
|
+
: [];
|
|
210
|
+
|
|
211
|
+
if (!nodeList.some((n) => n.kind === 'resource')) {
|
|
212
|
+
gaps.push({ kind: 'no_resources', note: 'no managed runtime resources derived from L0 signals' });
|
|
213
|
+
}
|
|
214
|
+
if (deployTargets.length === 0) {
|
|
215
|
+
gaps.push({ kind: 'deploy_target_unknown', note: 'no L1/L2 deploy config (vercel/fly/Dockerfile/compose/IaC) detected; provider/environment unverified' });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const byConfidence = nodeList.reduce((acc, n) => {
|
|
219
|
+
acc[n.confidence] = (acc[n.confidence] ?? 0) + 1; return acc;
|
|
220
|
+
}, {});
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
schema_version: SCHEMA_VERSION,
|
|
224
|
+
derivation_level: deployTargets.length > 0 ? 'L1' : 'L0',
|
|
225
|
+
nodes: nodeList,
|
|
226
|
+
edges,
|
|
227
|
+
coverage: {
|
|
228
|
+
node_count: nodeList.length,
|
|
229
|
+
edge_count: edges.length,
|
|
230
|
+
by_confidence: byConfidence,
|
|
231
|
+
gaps,
|
|
232
|
+
complete: false,
|
|
233
|
+
note: 'L0 derivation (dependencies + env). Absence of IaC is expected; gaps are honest, not failures.'
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function parseEnv(text) {
|
|
239
|
+
const entries = [];
|
|
240
|
+
for (const raw of text.split('\n')) {
|
|
241
|
+
const line = raw.trim();
|
|
242
|
+
if (!line || line.startsWith('#')) continue;
|
|
243
|
+
const eq = line.indexOf('=');
|
|
244
|
+
if (eq === -1) continue;
|
|
245
|
+
const key = line.slice(0, eq).replace(/^export\s+/, '').trim();
|
|
246
|
+
let value = line.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
|
|
247
|
+
let host = null;
|
|
248
|
+
if (value.includes('://')) {
|
|
249
|
+
try { host = new URL(value).hostname || null; } catch { host = null; }
|
|
250
|
+
}
|
|
251
|
+
// Never retain the raw value (may be a secret); keep key + host only.
|
|
252
|
+
if (key) entries.push({ key, host });
|
|
253
|
+
}
|
|
254
|
+
return entries;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function readIfExists(file) {
|
|
258
|
+
try {
|
|
259
|
+
await access(file);
|
|
260
|
+
return await readFile(file, 'utf8');
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Derive the L0 Environment Graph from repo artifacts, bind it to the current
|
|
268
|
+
* git head, and write it under .vibepro/environment/graph.json. No network,
|
|
269
|
+
* no provisioning; reads files + `git rev-parse HEAD` only.
|
|
270
|
+
*/
|
|
271
|
+
// L1 compose image -> typed Resource.
|
|
272
|
+
const COMPOSE_IMAGE_RESOURCES = [
|
|
273
|
+
{ match: /postgres|postgis/i, type: 'database', engine: 'postgres', label: 'PostgreSQL (compose)' },
|
|
274
|
+
{ match: /mysql|mariadb/i, type: 'database', engine: 'mysql', label: 'MySQL (compose)' },
|
|
275
|
+
{ match: /mongo/i, type: 'database', engine: 'mongodb', label: 'MongoDB (compose)' },
|
|
276
|
+
{ match: /redis|valkey/i, type: 'cache', engine: 'redis', label: 'Redis (compose)' },
|
|
277
|
+
{ match: /rabbitmq/i, type: 'queue', engine: 'amqp', label: 'RabbitMQ (compose)' },
|
|
278
|
+
{ match: /(minio|localstack)/i, type: 'storage', engine: 's3', label: 'Object storage (compose)' }
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
export function parseFlyToml(text, source = 'fly.toml') {
|
|
282
|
+
const app = (text.match(/^\s*app\s*=\s*["']?([\w.-]+)/m) || [])[1] || null;
|
|
283
|
+
const region = (text.match(/primary_region\s*=\s*["']?([\w-]+)/m) || [])[1] || null;
|
|
284
|
+
return [{
|
|
285
|
+
kind: 'component', type: 'backend', label: app ? `Fly app: ${app}` : 'Fly backend',
|
|
286
|
+
provider: 'fly', environment: region ? `production:${region}` : 'production',
|
|
287
|
+
confidence: 'confirmed', source
|
|
288
|
+
}];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function parseDockerfile(_text, source = 'Dockerfile') {
|
|
292
|
+
return [{
|
|
293
|
+
kind: 'component', type: 'backend', label: 'Containerized service',
|
|
294
|
+
provider: null, environment: null, confidence: 'confirmed', source
|
|
295
|
+
}];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function parseCompose(text, source = 'docker-compose.yml') {
|
|
299
|
+
const facts = [];
|
|
300
|
+
for (const line of text.split('\n')) {
|
|
301
|
+
const m = line.match(/^\s*image:\s*["']?([^\s"']+)/);
|
|
302
|
+
if (!m) continue;
|
|
303
|
+
const r = COMPOSE_IMAGE_RESOURCES.find((x) => x.match.test(m[1]));
|
|
304
|
+
if (r) {
|
|
305
|
+
facts.push({
|
|
306
|
+
kind: 'resource', type: r.type, label: r.label, engine: r.engine,
|
|
307
|
+
provider: 'self_hosted', environment: 'local', confidence: 'confirmed',
|
|
308
|
+
source: `${source}:${m[1]}`
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return facts;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function collectDeployTargets(repoRoot) {
|
|
316
|
+
const targets = [];
|
|
317
|
+
const read = (f) => readIfExists(path.join(repoRoot, f));
|
|
318
|
+
|
|
319
|
+
if ((await read('vercel.json')) !== null) {
|
|
320
|
+
targets.push({ kind: 'component', type: 'frontend', label: 'Vercel frontend', provider: 'vercel', environment: 'production', confidence: 'confirmed', source: 'vercel.json' });
|
|
321
|
+
}
|
|
322
|
+
if ((await read('netlify.toml')) !== null) {
|
|
323
|
+
targets.push({ kind: 'component', type: 'frontend', label: 'Netlify frontend', provider: 'netlify', environment: 'production', confidence: 'confirmed', source: 'netlify.toml' });
|
|
324
|
+
}
|
|
325
|
+
if ((await read('render.yaml')) !== null) {
|
|
326
|
+
targets.push({ kind: 'component', type: 'backend', label: 'Render service', provider: 'render', environment: 'production', confidence: 'confirmed', source: 'render.yaml' });
|
|
327
|
+
}
|
|
328
|
+
const fly = await read('fly.toml');
|
|
329
|
+
if (fly !== null) targets.push(...parseFlyToml(fly));
|
|
330
|
+
const docker = await read('Dockerfile');
|
|
331
|
+
if (docker !== null) targets.push(...parseDockerfile(docker));
|
|
332
|
+
for (const name of ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']) {
|
|
333
|
+
const c = await read(name);
|
|
334
|
+
if (c !== null) targets.push(...parseCompose(c, name));
|
|
335
|
+
}
|
|
336
|
+
return targets;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function deriveEnvironmentGraph(repoRoot, options = {}) {
|
|
340
|
+
const pkgRaw = await readIfExists(path.join(repoRoot, 'package.json'));
|
|
341
|
+
let deps = [];
|
|
342
|
+
if (pkgRaw) {
|
|
343
|
+
try {
|
|
344
|
+
const pkg = JSON.parse(pkgRaw);
|
|
345
|
+
deps = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})];
|
|
346
|
+
} catch { /* malformed package.json -> no deps */ }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const envEntries = [];
|
|
350
|
+
for (const name of ['.env', '.env.example', '.env.local', '.env.sample']) {
|
|
351
|
+
const text = await readIfExists(path.join(repoRoot, name));
|
|
352
|
+
if (text) for (const e of parseEnv(text)) envEntries.push(e);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const deployTargets = await collectDeployTargets(repoRoot);
|
|
356
|
+
|
|
357
|
+
const graph = buildEnvironmentGraph({ deps, envEntries, deployTargets });
|
|
358
|
+
|
|
359
|
+
let headSha = null;
|
|
360
|
+
try {
|
|
361
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot });
|
|
362
|
+
headSha = stdout.trim() || null;
|
|
363
|
+
} catch { headSha = null; }
|
|
364
|
+
|
|
365
|
+
const artifact = {
|
|
366
|
+
...graph,
|
|
367
|
+
generated_for_sha: headSha,
|
|
368
|
+
sources_scanned: {
|
|
369
|
+
package_json: Boolean(pkgRaw),
|
|
370
|
+
env_files: envEntries.length > 0,
|
|
371
|
+
deploy_config: deployTargets.length > 0
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
if (options.write !== false) {
|
|
376
|
+
const dir = path.join(getWorkspaceDir(repoRoot), 'environment');
|
|
377
|
+
await mkdir(dir, { recursive: true });
|
|
378
|
+
await writeFile(path.join(dir, 'graph.json'), `${JSON.stringify(artifact, null, 2)}\n`);
|
|
379
|
+
artifact.artifact_path = path.join('.vibepro', 'environment', 'graph.json');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return artifact;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Read a previously-derived Environment Graph artifact, if present.
|
|
387
|
+
* Returns null when absent or unreadable (never throws on missing).
|
|
388
|
+
*/
|
|
389
|
+
export async function readEnvironmentGraphIfExists(repoRoot) {
|
|
390
|
+
const file = path.join(getWorkspaceDir(repoRoot), 'environment', 'graph.json');
|
|
391
|
+
try {
|
|
392
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Deploy targets are the components that get deployed to a hosting provider
|
|
400
|
+
* (a component with a known provider/environment, or any confirmed deploy
|
|
401
|
+
* fact). These are what a deploy-verification gate must have evidence for.
|
|
402
|
+
* Returns [] for null/empty graphs.
|
|
403
|
+
*/
|
|
404
|
+
export function deployTargetsFromGraph(graph) {
|
|
405
|
+
if (!graph || !Array.isArray(graph.nodes)) return [];
|
|
406
|
+
return graph.nodes.filter((n) => (
|
|
407
|
+
n.kind === 'component' && (Boolean(n.provider) || Boolean(n.environment) || n.confidence === 'confirmed')
|
|
408
|
+
));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function renderEnvironmentGraphSummary(graph) {
|
|
412
|
+
const lines = [
|
|
413
|
+
`Environment Graph (${graph.derivation_level}) — bound to ${graph.generated_for_sha ?? 'no-git'}`,
|
|
414
|
+
`Nodes: ${graph.coverage.node_count} (by confidence: ${JSON.stringify(graph.coverage.by_confidence)}), Edges: ${graph.coverage.edge_count}`
|
|
415
|
+
];
|
|
416
|
+
for (const n of graph.nodes) {
|
|
417
|
+
lines.push(` - [${n.kind}/${n.type}] ${n.label}${n.provider ? ` (${n.provider})` : ''} — ${n.confidence} {${n.sources.join(', ')}}`);
|
|
418
|
+
}
|
|
419
|
+
if (graph.coverage.gaps.length) {
|
|
420
|
+
lines.push('Coverage gaps:');
|
|
421
|
+
for (const g of graph.coverage.gaps) lines.push(` ! ${g.kind}: ${g.note ?? g.key ?? ''}`);
|
|
422
|
+
}
|
|
423
|
+
return `${lines.join('\n')}\n`;
|
|
424
|
+
}
|