launchprep 0.0.1 → 0.2.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.
Potentially problematic release.
This version of launchprep might be problematic. Click here for more details.
- package/README.md +76 -12
- package/bin/launchprep.mjs +38 -0
- package/net/client.mjs +41 -0
- package/net/commands.mjs +166 -0
- package/net/consent.mjs +57 -0
- package/net/credentials.mjs +33 -0
- package/package.json +27 -12
- package/scripts/verify-readonly.mjs +80 -0
- package/src/brand.mjs +15 -0
- package/src/checks-ai.mjs +255 -0
- package/src/checks-auth.mjs +267 -0
- package/src/checks-authz.mjs +179 -0
- package/src/checks-batch2.mjs +385 -0
- package/src/checks-batch3.mjs +327 -0
- package/src/checks-batch4.mjs +529 -0
- package/src/checks-deploy.mjs +292 -0
- package/src/checks-frameworks.mjs +337 -0
- package/src/checks-injection.mjs +322 -0
- package/src/checks.mjs +221 -0
- package/src/detect.mjs +304 -0
- package/src/digest.mjs +169 -0
- package/src/fs-scan.mjs +211 -0
- package/src/gate.mjs +103 -0
- package/src/index.mjs +71 -0
- package/src/report.mjs +101 -0
- package/src/rules.json +3661 -0
- package/src/workspace.mjs +0 -0
- package/bin/cli.js +0 -11
package/src/detect.mjs
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Profile detection. Every fact carries evidence and a confidence level.
|
|
2
|
+
// LOW confidence never produces a `fail` — the gate turns it into a question.
|
|
3
|
+
import { readPackageJson, allDeps, stripComments, isWorkspaceRoot } from './fs-scan.mjs';
|
|
4
|
+
|
|
5
|
+
const fact = (value, confidence, evidence = []) => ({ value, confidence, evidence });
|
|
6
|
+
const dep = (deps, ...names) => names.find(n => deps[n] !== undefined);
|
|
7
|
+
|
|
8
|
+
// ---------- stack ----------
|
|
9
|
+
function detectFramework(repo, deps) {
|
|
10
|
+
// ---- python ----
|
|
11
|
+
if (repo.exists('manage.py') || repo.has(/(^|\/)manage\.py$/))
|
|
12
|
+
return fact('django', 'high', ['manage.py']);
|
|
13
|
+
if (repo.grep(/^\s*(DJANGO_SETTINGS_MODULE|INSTALLED_APPS)\s*=/m, /\.py$/).length ||
|
|
14
|
+
repo.grep(/^\s*[Dd]jango[><=~]/m, /requirements.*\.txt$|Pipfile$|pyproject\.toml$/).length)
|
|
15
|
+
return fact('django', 'high', ['django settings']);
|
|
16
|
+
if (repo.grep(/from fastapi|FastAPI\s*\(/, /\.py$/).length)
|
|
17
|
+
return fact('fastapi', 'high', ['FastAPI import']);
|
|
18
|
+
if (repo.grep(/from flask import|Flask\s*\(__name__/, /\.py$/).length)
|
|
19
|
+
return fact('flask', 'high', ['Flask import']);
|
|
20
|
+
|
|
21
|
+
// ---- ruby ----
|
|
22
|
+
const gemfile = repo.read('Gemfile') || repo.find(/(^|\/)Gemfile$/)[0]?.text || '';
|
|
23
|
+
if (/gem\s+['"]rails['"]/.test(gemfile) || repo.exists('config/application.rb') ||
|
|
24
|
+
repo.has(/config\/application\.rb$/))
|
|
25
|
+
return fact('rails', 'high', ['rails in Gemfile']);
|
|
26
|
+
if (/gem\s+['"]sinatra['"]/.test(gemfile)) return fact('sinatra', 'high', ['sinatra gem']);
|
|
27
|
+
|
|
28
|
+
// ---- php ----
|
|
29
|
+
const composer = repo.read('composer.json') || '';
|
|
30
|
+
if (repo.exists('artisan') || /laravel\/framework/.test(composer))
|
|
31
|
+
return fact('laravel', 'high', ['artisan / laravel framework']);
|
|
32
|
+
if (/symfony\//.test(composer)) return fact('symfony', 'high', ['symfony packages']);
|
|
33
|
+
|
|
34
|
+
// ---- javascript ----
|
|
35
|
+
const byDep = [
|
|
36
|
+
['next','next'], ['remix','@remix-run/react'], ['nuxt','nuxt'],
|
|
37
|
+
['sveltekit','@sveltejs/kit'], ['nest','@nestjs/core'],
|
|
38
|
+
['express','express'], ['fastify','fastify'], ['react-native','react-native'],
|
|
39
|
+
];
|
|
40
|
+
for (const [name, d] of byDep) if (deps[d]) return fact(name, 'high', [`dependency ${d}`]);
|
|
41
|
+
|
|
42
|
+
// ---- mobile ----
|
|
43
|
+
if (repo.exists('pubspec.yaml')) return fact('flutter', 'high', ['pubspec.yaml']);
|
|
44
|
+
if (repo.has(/Package\.swift$|\.xcodeproj/)) return fact('swiftui', 'high', ['xcode project']);
|
|
45
|
+
return fact('other', 'low', []);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `root` is the whole repo when profiling one package of a monorepo: the app
|
|
49
|
+
// package imports the db package, so its schema lives somewhere else entirely.
|
|
50
|
+
function detectDatabase(repo, deps, root) {
|
|
51
|
+
if (deps['@supabase/supabase-js'] || repo.exists('supabase/config.toml') ||
|
|
52
|
+
root?.exists('supabase/config.toml'))
|
|
53
|
+
return fact('supabase', 'high', ['@supabase/supabase-js']);
|
|
54
|
+
|
|
55
|
+
const prisma = repo.find(/schema\.prisma$/)[0] || root?.find(/schema\.prisma$/)[0];
|
|
56
|
+
if (prisma?.text) {
|
|
57
|
+
const m = prisma.text.match(/provider\s*=\s*"(\w+)"/);
|
|
58
|
+
if (m) return fact(m[1] === 'postgresql' ? 'postgres' : m[1], 'high', ['prisma schema provider']);
|
|
59
|
+
}
|
|
60
|
+
// ORM present but schema elsewhere - still tells us a database exists
|
|
61
|
+
if (deps['@prisma/client'] || deps['prisma'])
|
|
62
|
+
return fact('postgres', 'low', ['prisma client, schema not in this package']);
|
|
63
|
+
if (deps['drizzle-orm']) {
|
|
64
|
+
const d = (root || repo).grep(/pgTable\s*\(/) .length ? 'postgres'
|
|
65
|
+
: (root || repo).grep(/mysqlTable\s*\(/).length ? 'mysql'
|
|
66
|
+
: (root || repo).grep(/sqliteTable\s*\(/).length ? 'sqlite' : 'postgres';
|
|
67
|
+
return fact(d, 'high', ['drizzle schema']);
|
|
68
|
+
}
|
|
69
|
+
if (deps['mongoose']) return fact('mongodb','high',['mongoose']);
|
|
70
|
+
if (deps['typeorm'] || deps['sequelize']) return fact('postgres','low',['orm present']);
|
|
71
|
+
if (deps['sqlalchemy'] || deps['SQLAlchemy'] || deps['psycopg2'] || deps['psycopg2-binary'])
|
|
72
|
+
return fact('postgres','high',['python postgres driver']);
|
|
73
|
+
|
|
74
|
+
// django settings.py -> DATABASES ENGINE
|
|
75
|
+
const dj = repo.grep(/ENGINE['"]?\s*:\s*['"]django\.db\.backends\.(\w+)/, /\.py$/)[0]
|
|
76
|
+
|| root?.grep(/ENGINE['"]?\s*:\s*['"]django\.db\.backends\.(\w+)/, /\.py$/)[0];
|
|
77
|
+
if (dj) {
|
|
78
|
+
const m = dj.text.match(/django\.db\.backends\.(\w+)/);
|
|
79
|
+
const map = { postgresql: 'postgres', postgresql_psycopg2: 'postgres', mysql: 'mysql', sqlite3: 'sqlite', oracle: 'other' };
|
|
80
|
+
if (m) return fact(map[m[1]] || m[1], 'high', ['django DATABASES engine']);
|
|
81
|
+
}
|
|
82
|
+
// rails config/database.yml -> adapter
|
|
83
|
+
const ry = repo.read('config/database.yml') || root?.read('config/database.yml');
|
|
84
|
+
if (ry) {
|
|
85
|
+
const m = ry.match(/adapter:\s*(\w+)/);
|
|
86
|
+
const map = { postgresql: 'postgres', postgres: 'postgres', mysql2: 'mysql', sqlite3: 'sqlite' };
|
|
87
|
+
if (m) return fact(map[m[1]] || m[1], 'high', ['rails database.yml adapter']);
|
|
88
|
+
}
|
|
89
|
+
// requirements.txt / Gemfile drivers
|
|
90
|
+
if (repo.grep(/^\s*psycopg2|^\s*asyncpg/m, /requirements.*\.txt$|Pipfile$/).length)
|
|
91
|
+
return fact('postgres','high',['python postgres driver']);
|
|
92
|
+
const gem = repo.read('Gemfile') || '';
|
|
93
|
+
if (/gem\s+['"]pg['"]/.test(gem)) return fact('postgres','high',['pg gem']);
|
|
94
|
+
if (/gem\s+['"]mysql2['"]/.test(gem)) return fact('mysql','high',['mysql2 gem']);
|
|
95
|
+
if (dep(deps,'pg','postgres','@neondatabase/serverless')) return fact('postgres','high',['pg driver']);
|
|
96
|
+
if (dep(deps,'mysql2','mysql')) return fact('mysql','high',['mysql driver']);
|
|
97
|
+
if (dep(deps,'mongodb','mongoose')) return fact('mongodb','high',['mongo driver']);
|
|
98
|
+
if (dep(deps,'better-sqlite3','sqlite3')) return fact('sqlite','high',['sqlite driver']);
|
|
99
|
+
if (dep(deps,'firebase','firebase-admin')) return fact('firebase','high',['firebase sdk']);
|
|
100
|
+
return fact('none','low',[]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function detectAuth(repo, deps) {
|
|
104
|
+
if (dep(deps,'@clerk/nextjs','@clerk/clerk-react')) return fact('clerk','high',['clerk sdk']);
|
|
105
|
+
if (dep(deps,'@auth0/nextjs-auth0')) return fact('auth0','high',['auth0 sdk']);
|
|
106
|
+
if (dep(deps,'next-auth','@auth/core')) return fact('nextauth','high',['next-auth']);
|
|
107
|
+
if (deps['@supabase/supabase-js'] && repo.grep(/auth\.(signIn|signUp|getUser|getSession)/).length)
|
|
108
|
+
return fact('supabase-auth','high',['supabase auth calls']);
|
|
109
|
+
const gemf = repo.read('Gemfile') || '';
|
|
110
|
+
if (/gem\s+['"]devise['"]/.test(gemf)) return fact('devise','high',['devise gem']);
|
|
111
|
+
if (/has_secure_password/.test((repo.grep(/has_secure_password/, /\.rb$/)[0]||{}).text || ''))
|
|
112
|
+
return fact('custom','high',['has_secure_password']);
|
|
113
|
+
if (repo.grep(/django\.contrib\.auth/, /\.py$/).length)
|
|
114
|
+
return fact('django-auth','high',['django.contrib.auth']);
|
|
115
|
+
if (dep(deps,'bcrypt','bcryptjs','argon2') ||
|
|
116
|
+
repo.grep(/(?:from|require\()\s*['"](?:bcrypt|bcryptjs|argon2)/).length)
|
|
117
|
+
return fact('custom','high',['password hashing import']);
|
|
118
|
+
return fact('none','low',[]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function detectHost(repo, deps) {
|
|
122
|
+
if (repo.exists('vercel.json') || deps['@vercel/analytics']) return fact('vercel','high',['vercel.json']);
|
|
123
|
+
if (repo.exists('netlify.toml')) return fact('netlify','high',['netlify.toml']);
|
|
124
|
+
if (repo.exists('wrangler.toml')) return fact('cloudflare','high',['wrangler.toml']);
|
|
125
|
+
if (repo.exists('fly.toml')) return fact('fly','high',['fly.toml']);
|
|
126
|
+
if (repo.exists('railway.json')) return fact('railway','high',['railway.json']);
|
|
127
|
+
if (repo.exists('render.yaml')) return fact('render','high',['render.yaml']);
|
|
128
|
+
if (repo.has(/^Dockerfile$|docker-compose\.ya?ml$/)) return fact('vps','low',['Dockerfile — host inferred']);
|
|
129
|
+
return fact(null,'low',[]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------- capability facts ----------
|
|
133
|
+
const LLM_DEPS = {
|
|
134
|
+
'@anthropic-ai/sdk':'anthropic', 'openai':'openai', 'groq-sdk':'groq',
|
|
135
|
+
'@google/generative-ai':'google', '@mistralai/mistralai':'mistral', 'ollama':'other',
|
|
136
|
+
'ai':'other', '@ai-sdk/anthropic':'anthropic', '@ai-sdk/openai':'openai',
|
|
137
|
+
};
|
|
138
|
+
function detectLlm(repo, deps) {
|
|
139
|
+
const providers = [...new Set(Object.entries(LLM_DEPS).filter(([d]) => deps[d]).map(([,p]) => p))];
|
|
140
|
+
const hits = repo.grep(/api\.(openai|anthropic)\.com|generativelanguage\.googleapis|api\.groq\.com/);
|
|
141
|
+
if (!providers.length && !hits.length) return { calls_llm: fact(false,'high',[]), providers: fact([], 'high', []) };
|
|
142
|
+
const ev = providers.length ? [`sdk: ${providers.join(', ')}`] : [`direct call in ${hits[0].path}`];
|
|
143
|
+
return { calls_llm: fact(true,'high',ev), providers: fact(providers,'high',ev) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function detectTenancy(repo, hasAccounts) {
|
|
147
|
+
const RE = /\b(tenant_id|org_id|organization_id|workspace_id|team_id|account_id)\b/;
|
|
148
|
+
const schema = repo.grep(RE, /\.(sql|prisma)$/);
|
|
149
|
+
const code = repo.grep(RE);
|
|
150
|
+
if (schema.length >= 2 || (schema.length && code.length >= 3))
|
|
151
|
+
return fact('multi-tenant-shared-db','high',[`tenant key in ${schema.length} schema file(s)`]);
|
|
152
|
+
if (schema.length || code.length >= 3)
|
|
153
|
+
return fact('multi-tenant-shared-db','low',['tenant key present but sparse']);
|
|
154
|
+
if (!hasAccounts) return fact('none','high',['no accounts']);
|
|
155
|
+
return fact('single-user','low',['accounts present, no tenant key found']);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const PII = /\b(email|phone|first_?name|last_?name|address|date_?of_?birth|dob|postal|zip_?code)\b/i;
|
|
159
|
+
const FIN = /\b(card_?number|iban|invoice|amount_cents|stripe_customer|tax_id|vat)\b/i;
|
|
160
|
+
const HEALTH = /\b(diagnosis|patient|medical_record|prescription|icd_?10)\b/i;
|
|
161
|
+
const CHILD = /\b(parental_consent|guardian|is_minor|age_gate)\b/i;
|
|
162
|
+
// only count matches inside actual schema declarations, with comments removed
|
|
163
|
+
function schemaHits(repo, re) {
|
|
164
|
+
const files = repo.files.filter(f => f.text && /\.(sql|prisma|ts|py|rb)$/.test(f.path));
|
|
165
|
+
const hits = [];
|
|
166
|
+
for (const f of files) {
|
|
167
|
+
const body = stripComments(f.text);
|
|
168
|
+
const looksLikeSchema =
|
|
169
|
+
/pgTable\s*\(|mysqlTable\s*\(|sqliteTable\s*\(|CREATE TABLE|^model\s+\w+\s*\{|class\s+\w+\(.*Model\)/mi.test(body)
|
|
170
|
+
|| /(^|\/)(schema|models?|migrations?|entities)(\/|\.)/i.test(f.path);
|
|
171
|
+
if (!looksLikeSchema) continue;
|
|
172
|
+
const m = body.match(new RegExp(re.source, 'gi'));
|
|
173
|
+
if (m) hits.push({ path: f.path, terms: [...new Set(m.map(x => x.toLowerCase()))] });
|
|
174
|
+
}
|
|
175
|
+
return hits;
|
|
176
|
+
}
|
|
177
|
+
function detectSensitivity(repo) {
|
|
178
|
+
const terms = (hits) => [...new Set(hits.flatMap(h => h.terms))];
|
|
179
|
+
// high-consequence classifications require corroboration: 2+ distinct terms
|
|
180
|
+
const health = schemaHits(repo, HEALTH), child = schemaHits(repo, CHILD);
|
|
181
|
+
if (terms(health).length >= 2) return fact('health','high',[`clinical fields: ${terms(health).join(', ')}`]);
|
|
182
|
+
if (terms(child).length >= 2) return fact('children','high',[`minor/guardian fields: ${terms(child).join(', ')}`]);
|
|
183
|
+
const fin = schemaHits(repo, FIN), pii = schemaHits(repo, PII);
|
|
184
|
+
if (terms(fin).length >= 1) return fact('financial','high',[`financial fields: ${terms(fin).slice(0,4).join(', ')}`]);
|
|
185
|
+
if (terms(pii).length >= 2) return fact('pii','high',[`personal fields: ${terms(pii).slice(0,4).join(', ')}`]);
|
|
186
|
+
if (terms(pii).length === 1) return fact('pii','low',[`single personal field: ${terms(pii)[0]}`]);
|
|
187
|
+
return fact('none','low',[]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function detectUploads(repo, deps) {
|
|
191
|
+
if (dep(deps,'multer','formidable','busboy','uploadthing','@aws-sdk/client-s3','@uploadcare/upload-client'))
|
|
192
|
+
return fact(true,'high',['upload library']);
|
|
193
|
+
if (repo.grep(/storage\.from\([^)]*\)\.upload|multipart\/form-data|new FormData\(\)/).length)
|
|
194
|
+
return fact(true,'high',['upload call site']);
|
|
195
|
+
return fact(false,'low',[]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function detectAccounts(repo, deps, auth) {
|
|
199
|
+
if (auth.value && auth.value !== 'none') return fact(true,'high',[`auth: ${auth.value}`]);
|
|
200
|
+
if (repo.grep(/(?:CREATE TABLE\s+(?:IF NOT EXISTS\s+)?["`]?(?:public\.)?(?:users|accounts)|^model\s+(?:User|Account)\b)/mi,
|
|
201
|
+
/\.(sql|prisma)$/).length) return fact(true,'high',['users table in schema']);
|
|
202
|
+
if (repo.has(/(login|signin|sign-in|register|signup)/i)) return fact(true,'low',['auth route naming']);
|
|
203
|
+
return fact(false,'low',[]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function looksLikeLibrary(repo, framework, deps) {
|
|
207
|
+
// an app has an entrypoint users reach; a library only has exports
|
|
208
|
+
const pkg = readPackageJson(repo);
|
|
209
|
+
const hasAppRoutes =
|
|
210
|
+
repo.has(/^(src\/)?(app|pages|routes|views)\//) ||
|
|
211
|
+
repo.has(/^(src\/)?(server|api)\//) ||
|
|
212
|
+
repo.grep(/createServer\(|app\.listen\(|export const (GET|POST|PUT|DELETE)\b/).length > 0;
|
|
213
|
+
const isFrameworkApp = ['next','remix','nuxt','sveltekit','express','fastify','nest','django','fastapi','rails','flutter','swiftui','react-native'].includes(framework.value);
|
|
214
|
+
const exportsOnly = !!(pkg && (pkg.main || pkg.module || pkg.exports || pkg.types) && !pkg.bin);
|
|
215
|
+
if (isFrameworkApp || hasAppRoutes) return false;
|
|
216
|
+
if (exportsOnly) return true;
|
|
217
|
+
// config-only packages (eslint/prettier/tsconfig/tailwind presets)
|
|
218
|
+
if (pkg && Object.keys(deps).length <= 3 && repo.files.length < 30) return true;
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function detectSurface(repo, framework, deps) {
|
|
223
|
+
const f = framework.value;
|
|
224
|
+
if (looksLikeLibrary(repo, framework, deps))
|
|
225
|
+
return fact('library','high',['exports only, no app entrypoint']);
|
|
226
|
+
if (['swiftui'].includes(f)) return fact('mobile-ios','high',['xcode project']);
|
|
227
|
+
if (['flutter','react-native'].includes(f)) return fact('mobile-android','low',['cross-platform mobile']);
|
|
228
|
+
if (['django','rails','laravel','symfony','flask'].includes(f))
|
|
229
|
+
return fact('web-app','high',[`${f} application`]);
|
|
230
|
+
if (['express','fastify','nest','fastapi','sinatra'].includes(f)) return fact('api-only','low',['server framework, no UI framework']);
|
|
231
|
+
if (['next','remix','nuxt','sveltekit'].includes(f)) {
|
|
232
|
+
const dynamic = repo.has(/\/(api|actions)\//) || repo.grep(/'use server'/).length;
|
|
233
|
+
return fact(dynamic ? 'web-app' : 'web-site','low',[dynamic?'server routes present':'no server routes found']);
|
|
234
|
+
}
|
|
235
|
+
const pkg = readPackageJson(repo);
|
|
236
|
+
if (pkg?.bin) return fact('cli','high',['package.json bin']);
|
|
237
|
+
return fact('web-app','low',[]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function detectStage(repo, host) {
|
|
241
|
+
const signals = [];
|
|
242
|
+
if (host.value) signals.push(`host config (${host.value})`);
|
|
243
|
+
if (repo.has(/^\.github\/workflows\//)) signals.push('CI workflows');
|
|
244
|
+
if (repo.exists('.env.production') || repo.exists('.env.prod')) signals.push('production env file');
|
|
245
|
+
if (signals.length >= 2) return fact('production','low',signals);
|
|
246
|
+
if (signals.length === 1) return fact('pre-launch','low',signals);
|
|
247
|
+
return fact('prototype','low',['no deploy signals']);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------- entry ----------
|
|
251
|
+
export function detectProfile(repo, opts = {}) {
|
|
252
|
+
const root = opts.root || null;
|
|
253
|
+
const pkg = readPackageJson(repo);
|
|
254
|
+
const deps = allDeps(repo); // monorepo-aware: unions every workspace manifest
|
|
255
|
+
|
|
256
|
+
const framework = detectFramework(repo, deps);
|
|
257
|
+
const database = detectDatabase(repo, deps, root);
|
|
258
|
+
const auth = detectAuth(repo, deps);
|
|
259
|
+
const host = detectHost(repo, deps);
|
|
260
|
+
const llm = detectLlm(repo, deps);
|
|
261
|
+
const accounts = detectAccounts(repo, deps, auth);
|
|
262
|
+
|
|
263
|
+
const f = {
|
|
264
|
+
surface: detectSurface(repo, framework, deps),
|
|
265
|
+
has_accounts: accounts,
|
|
266
|
+
calls_llm: llm.calls_llm,
|
|
267
|
+
llm_providers: llm.providers,
|
|
268
|
+
is_public: host.value ? fact(true,'low',['deployed to a public host']) : fact(false,'low',[]),
|
|
269
|
+
data_sensitivity: (() => { const l = detectSensitivity(repo);
|
|
270
|
+
return (l.value === 'none' && root) ? detectSensitivity(root) : l; })(),
|
|
271
|
+
has_file_uploads: detectUploads(repo, deps),
|
|
272
|
+
tenancy: (() => { const l = detectTenancy(repo, accounts.value);
|
|
273
|
+
return (['none','single-user'].includes(l.value) && root)
|
|
274
|
+
? detectTenancy(root, accounts.value) : l; })(),
|
|
275
|
+
stage: detectStage(repo, host),
|
|
276
|
+
// cheap extras that gate real rules
|
|
277
|
+
has_migrations: repo.has(/migrations?\//) ? fact(true,'high',['migrations dir']) : fact(false,'low',[]),
|
|
278
|
+
has_ci: repo.has(/^\.github\/workflows\//) ? fact(true,'high',['github workflows']) : fact(false,'high',[]),
|
|
279
|
+
sends_email: dep(deps,'resend','@sendgrid/mail','nodemailer','postmark','@aws-sdk/client-ses')
|
|
280
|
+
? fact(true,'high',['email sdk']) : fact(false,'low',[]),
|
|
281
|
+
handles_payments: dep(deps,'stripe','@stripe/stripe-js') ? fact('stripe','high',['stripe sdk'])
|
|
282
|
+
: dep(deps,'@paddle/paddle-js') ? fact('paddle','high',['paddle sdk'])
|
|
283
|
+
: fact('none','low',[]),
|
|
284
|
+
has_admin_panel: repo.has(/\/admin\//i) ? fact(true,'low',['admin route']) : fact(false,'low',[]),
|
|
285
|
+
stack: {
|
|
286
|
+
framework: framework.value, database: database.value,
|
|
287
|
+
auth: auth.value, host: host.value,
|
|
288
|
+
},
|
|
289
|
+
_stack_evidence: { framework, database, auth, host },
|
|
290
|
+
};
|
|
291
|
+
return f;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// flatten to the shape gate.py expects
|
|
295
|
+
export function toGateProfile(f) {
|
|
296
|
+
const v = (x) => (x && typeof x === 'object' && 'value' in x) ? x.value : x;
|
|
297
|
+
const out = {};
|
|
298
|
+
for (const [k, val] of Object.entries(f)) {
|
|
299
|
+
if (k.startsWith('_') || k === 'stack') continue;
|
|
300
|
+
out[k] = v(val);
|
|
301
|
+
}
|
|
302
|
+
out.stack = f.stack;
|
|
303
|
+
return out;
|
|
304
|
+
}
|
package/src/digest.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Build the block of code the model reads.
|
|
2
|
+
//
|
|
3
|
+
// This is the single most expensive decision in the product. Every token here
|
|
4
|
+
// is written to cache once and then read by every rule-group call, so a digest
|
|
5
|
+
// twice as big costs roughly twice as much for the whole scan. It is also the
|
|
6
|
+
// only defence against someone sending a 2M-token payload to burn our money.
|
|
7
|
+
//
|
|
8
|
+
// The job is not "send the repo". It is "send the parts a question about
|
|
9
|
+
// authorization, tenancy, deletion or spend could possibly be answered from".
|
|
10
|
+
|
|
11
|
+
// MEASURED, not guessed. 3.5 chars/token is the figure for prose; source code
|
|
12
|
+
// tokenizes far worse - identifiers, punctuation and indentation all split.
|
|
13
|
+
// Checked against messages.countTokens on real digests at three sizes:
|
|
14
|
+
// estimated 5,981 -> actual 9,321 (1.56x)
|
|
15
|
+
// estimated 49,991 -> actual 77,175 (1.54x)
|
|
16
|
+
// estimated 149,995 -> actual 226,891 (1.51x)
|
|
17
|
+
// At 3.5 a "150K cap" admitted 227K tokens, so the cap capped nothing and every
|
|
18
|
+
// cost estimate was about half the real number.
|
|
19
|
+
const CHARS_PER_TOKEN = 2.3;
|
|
20
|
+
export const estimateTokens = (s) => Math.ceil(s.length / CHARS_PER_TOKEN);
|
|
21
|
+
|
|
22
|
+
// Higher scores are read first. A schema is 200 lines that answer half the
|
|
23
|
+
// tenancy questions; a React component is 400 lines that answer none.
|
|
24
|
+
const RANK = [
|
|
25
|
+
[/\.(prisma|sql)$/, 100, 'schema'],
|
|
26
|
+
[/(^|\/)(middleware|auth|authz|authorization|session|permissions?|policy|policies|guard|rbac)\.(ts|js|mjs|py|rb|go)$/, 95, 'auth'],
|
|
27
|
+
[/(^|\/)(middleware|auth|guards?|policies|permissions?)\//, 90, 'auth'],
|
|
28
|
+
[/(^|\/)api\/.*\/route\.(ts|js)$/, 85, 'route'],
|
|
29
|
+
[/(^|\/)(pages\/api|app\/api)\//, 85, 'route'],
|
|
30
|
+
[/(^|\/)(routes?|controllers?|handlers?|endpoints?|resolvers?)\//, 80, 'route'],
|
|
31
|
+
[/(^|\/)(actions?|server)\//, 75, 'server'],
|
|
32
|
+
[/\.(server)\.(ts|js)$/, 75, 'server'],
|
|
33
|
+
[/(^|\/)(services?|repositor(y|ies)|queries|db|database|models?|dal)\//, 70, 'data'],
|
|
34
|
+
[/(^|\/)(jobs?|workers?|queues?|tasks?|cron)\//, 60, 'async'],
|
|
35
|
+
[/(^|\/)(lib|utils?|helpers?)\//, 45, 'lib'],
|
|
36
|
+
[/(next|nuxt|vite|astro|svelte)\.config\.(ts|js|mjs)$/, 55, 'config'],
|
|
37
|
+
[/(^|\/)(docker-compose.*\.ya?ml|Dockerfile|.*\.tf)$/, 50, 'infra'],
|
|
38
|
+
[/\.github\/workflows\/.*\.ya?ml$/, 40, 'ci'],
|
|
39
|
+
[/(^|\/)package\.json$/, 35, 'manifest'],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Never worth a token.
|
|
43
|
+
const NEVER = [
|
|
44
|
+
/(^|\/)(test|tests|__tests__|spec|e2e|cypress|fixtures?|mocks?|__mocks__)\//,
|
|
45
|
+
/\.(test|spec)\.[a-z]+$/,
|
|
46
|
+
/(^|\/)(node_modules|dist|build|out|coverage|\.next|vendor)\//,
|
|
47
|
+
/-lock\.(json|yaml)$|\.lock$/,
|
|
48
|
+
/\.(css|scss|sass|less|svg|png|jpe?g|gif|webp|ico|woff2?|ttf|eot|map|md|txt)$/,
|
|
49
|
+
/(^|\/)(migrations?)\/.*\/(down|rollback)\.sql$/,
|
|
50
|
+
/\.min\.(js|css)$/,
|
|
51
|
+
/(^|\/)(i18n|locales?|translations?)\//,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// The scanner's whole idea is profile -> gate -> check: work out what the app IS
|
|
55
|
+
// before deciding what to ask. The digest ignored that and used one fixed
|
|
56
|
+
// ranking for a static blog and an AI agent platform alike. On tuura that meant
|
|
57
|
+
// lib/agent/ scored 45 - the same as lib/date-helpers/ - so the agent code lost
|
|
58
|
+
// its place to formatting utilities and every AI rule came back "I cannot see
|
|
59
|
+
// the code". Reproducing, in the one new file, the exact mistake the product
|
|
60
|
+
// exists to prevent.
|
|
61
|
+
//
|
|
62
|
+
// Each entry: if the profile fact is true, files matching the pattern get this
|
|
63
|
+
// score instead. Only what the app actually is gets promoted.
|
|
64
|
+
const PROFILE_BOOSTS = [
|
|
65
|
+
['calls_llm', /(^|\/)(ai|llm|agents?|prompts?|completions?|chat|inference|models?)\//i, 92],
|
|
66
|
+
['calls_llm', /(^|\/)[a-z0-9-]*(ai|llm|agent|prompt|completion|anthropic|openai)[a-z0-9-]*\.(ts|tsx|js|mjs|py|rb)$/i, 88],
|
|
67
|
+
['has_file_uploads', /(^|\/)(uploads?|attachments?|files?|storage|media)\//i, 88],
|
|
68
|
+
['has_file_uploads', /(^|\/)[a-z0-9-]*(upload|attachment|multer|s3|r2|bucket)[a-z0-9-]*\.(ts|tsx|js|mjs|py|rb)$/i, 86],
|
|
69
|
+
['has_accounts', /(^|\/)[a-z0-9-]*(auth|session|token|login|password|jwt)[a-z0-9-]*\.(ts|tsx|js|mjs|py|rb)$/i, 93],
|
|
70
|
+
['handles_payments', /(^|\/)[a-z0-9-]*(payment|billing|stripe|checkout|invoice|subscription)[a-z0-9-]*\.(ts|tsx|js|mjs|py|rb)$/i, 88],
|
|
71
|
+
['is_multi_tenant', /(^|\/)[a-z0-9-]*(tenant|org|organization|workspace)[a-z0-9-]*\.(ts|tsx|js|mjs|py|rb)$/i, 88],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
function score(path, ctx) {
|
|
75
|
+
if (NEVER.some(re => re.test(path))) return null;
|
|
76
|
+
// Prisma's migrations are GENERATED from schema.prisma - a historical log of
|
|
77
|
+
// every table ever created. On cal.com they are 603 files and would eat the
|
|
78
|
+
// entire budget, leaving 2 route files, which is where the authorization
|
|
79
|
+
// bugs actually are. The schema is the current state; the log is not.
|
|
80
|
+
if (ctx.hasPrisma && /(^|\/)migrations?\//.test(path) && /\.sql$/.test(path)) return null;
|
|
81
|
+
|
|
82
|
+
// what this app IS beats where the file happens to sit
|
|
83
|
+
let boosted = null;
|
|
84
|
+
for (const [fact, re, n] of PROFILE_BOOSTS) {
|
|
85
|
+
if (!ctx.profile?.[fact] || !re.test(path)) continue;
|
|
86
|
+
if (!boosted || n > boosted.n) boosted = { n, kind: fact === 'calls_llm' ? 'ai' : fact.replace(/^(has_|is_|handles_)/, '') };
|
|
87
|
+
}
|
|
88
|
+
if (boosted) return boosted;
|
|
89
|
+
|
|
90
|
+
for (const [re, n, kind] of RANK) if (re.test(path)) return { n, kind };
|
|
91
|
+
// unranked server-ish source still beats nothing, but only just
|
|
92
|
+
if (/\.(ts|tsx|js|jsx|mjs|py|rb|go|php|java|kt|swift)$/.test(path)) return { n: 20, kind: 'other' };
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A single enormous generated file can eat the whole budget alone.
|
|
97
|
+
const MAX_FILE_CHARS = 60_000;
|
|
98
|
+
|
|
99
|
+
export function buildDigest(repo, { maxTokens = 150_000, profile = {} } = {}) {
|
|
100
|
+
const ctx = { profile, hasPrisma: repo.files.some(f => /\.prisma$/.test(f.path)) };
|
|
101
|
+
const ranked = [];
|
|
102
|
+
for (const f of repo.files) {
|
|
103
|
+
if (!f.text) continue;
|
|
104
|
+
const s = score(f.path, ctx);
|
|
105
|
+
if (!s) continue;
|
|
106
|
+
ranked.push({ path: f.path, text: f.text, ...s });
|
|
107
|
+
}
|
|
108
|
+
// highest value first; within a rank, smaller files first so a 60k monster
|
|
109
|
+
// never displaces six schemas that would all have fitted
|
|
110
|
+
ranked.sort((a, b) => b.n - a.n || a.text.length - b.text.length);
|
|
111
|
+
|
|
112
|
+
// No single kind may crowd out the others. Without this one category with
|
|
113
|
+
// hundreds of files takes everything and the scan answers questions nobody
|
|
114
|
+
// asked while missing the code the rules are actually about.
|
|
115
|
+
// These must sum to roughly 1.0. The first pass RESERVES each kind its share;
|
|
116
|
+
// the second spends whatever is left in rank order. If the shares sum to more
|
|
117
|
+
// than the budget the first pass runs out early and the lowest-ranked kinds
|
|
118
|
+
// never get their reservation - which is how promoting the AI files knocked
|
|
119
|
+
// routes from 23 to 8, and routes are where authorization bugs live.
|
|
120
|
+
const SHARE = {
|
|
121
|
+
schema: 0.10, auth: 0.08, accounts: 0.12, ai: 0.18, route: 0.20,
|
|
122
|
+
data: 0.12, server: 0.08, file_uploads: 0.06, payments: 0.03,
|
|
123
|
+
multi_tenant: 0.03,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const parts = [];
|
|
127
|
+
const included = [];
|
|
128
|
+
const omitted = [];
|
|
129
|
+
const spentByKind = {};
|
|
130
|
+
let tokens = 0;
|
|
131
|
+
|
|
132
|
+
const take = (f, respectShare) => {
|
|
133
|
+
let body = f.text;
|
|
134
|
+
let truncated = false;
|
|
135
|
+
if (body.length > MAX_FILE_CHARS) { body = body.slice(0, MAX_FILE_CHARS); truncated = true; }
|
|
136
|
+
const block = `\n──── ${f.path}${truncated ? ' [truncated]' : ''}\n${body}\n`;
|
|
137
|
+
const cost = estimateTokens(block);
|
|
138
|
+
if (tokens + cost > maxTokens) return false;
|
|
139
|
+
if (respectShare && SHARE[f.kind] != null) {
|
|
140
|
+
const cap = maxTokens * SHARE[f.kind];
|
|
141
|
+
if ((spentByKind[f.kind] || 0) + cost > cap) return false;
|
|
142
|
+
}
|
|
143
|
+
parts.push(block);
|
|
144
|
+
included.push({ path: f.path, kind: f.kind, tokens: cost });
|
|
145
|
+
spentByKind[f.kind] = (spentByKind[f.kind] || 0) + cost;
|
|
146
|
+
tokens += cost;
|
|
147
|
+
return true;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const leftovers = [];
|
|
151
|
+
for (const f of ranked) if (!take(f, true)) leftovers.push(f);
|
|
152
|
+
// budget left after every kind hit its ceiling - spend it in rank order
|
|
153
|
+
for (const f of leftovers) if (!take(f, false)) omitted.push(f.path);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
text: parts.join(''),
|
|
157
|
+
tokens,
|
|
158
|
+
included,
|
|
159
|
+
omitted,
|
|
160
|
+
// what the model must be told it cannot see, or it will reason as though
|
|
161
|
+
// the absence of an ownership check is proof there isn't one
|
|
162
|
+
coverage: {
|
|
163
|
+
filesRead: included.length,
|
|
164
|
+
filesSkipped: omitted.length,
|
|
165
|
+
byKind: included.reduce((a, f) => (a[f.kind] = (a[f.kind] || 0) + 1, a), {}),
|
|
166
|
+
tokensByKind: spentByKind,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|