@withtruing/companion 0.0.0-stage → 0.13.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 +21 -0
- package/README.md +329 -3
- package/dist/api-key-caller.js +388 -0
- package/dist/claude-binary.js +122 -0
- package/dist/claude-cli-caller.js +344 -0
- package/dist/cli-arguments.js +11 -0
- package/dist/cli.js +1486 -0
- package/dist/codex-cli-caller.js +536 -0
- package/dist/config.js +119 -0
- package/dist/design-context.js +54 -0
- package/dist/git-range.js +291 -0
- package/dist/http-client.js +214 -0
- package/dist/import-resolver.js +644 -0
- package/dist/investigation-reader.js +1303 -0
- package/dist/local-scan.js +243 -0
- package/dist/managed-repo.js +408 -0
- package/dist/observed-sources.js +85 -0
- package/dist/reader-result-contract.js +467 -0
- package/dist/sandbox.js +215 -0
- package/dist/schema-entities.js +755 -0
- package/dist/service.js +710 -0
- package/dist/shipment.js +115 -0
- package/dist/strict-schema.js +187 -0
- package/dist/surface-mapper.js +357 -0
- package/dist/text-model.js +121 -0
- package/dist/types.js +11 -0
- package/dist/usage-limit.js +130 -0
- package/package.json +50 -6
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
import spawn from 'cross-spawn';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
const MAX_FILE_BYTES = 1_048_576;
|
|
4
|
+
const MAX_SCHEMA_ENTITIES = 1_000;
|
|
5
|
+
const MAX_MODULES = 1_000;
|
|
6
|
+
const MAX_NAME_LENGTH = 200;
|
|
7
|
+
const GIT_TIMEOUT_MS = 60_000;
|
|
8
|
+
const HUB_MODULE_THRESHOLD = 4;
|
|
9
|
+
const safePath = (path) => typeof path === 'string'
|
|
10
|
+
&& path.length > 0
|
|
11
|
+
&& !path.startsWith('/')
|
|
12
|
+
&& !path.includes('\\')
|
|
13
|
+
&& !/^[A-Za-z]:/.test(path)
|
|
14
|
+
&& path.split('/').every((segment) => segment.length > 0 && segment !== '.' && segment !== '..');
|
|
15
|
+
export function validSchemaProbe(value) {
|
|
16
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
17
|
+
return false;
|
|
18
|
+
const probe = value;
|
|
19
|
+
if (Object.keys(probe).sort().join('\0') !== ['migrationGlobs', 'modules', 'schemaPaths'].sort().join('\0')
|
|
20
|
+
|| !Array.isArray(probe.schemaPaths) || !Array.isArray(probe.migrationGlobs) || !Array.isArray(probe.modules)
|
|
21
|
+
|| probe.schemaPaths.length > 3_000 || probe.migrationGlobs.length > 100 || probe.modules.length > MAX_MODULES
|
|
22
|
+
|| probe.schemaPaths.some((path) => !safePath(path) || !String(path).endsWith('.prisma'))
|
|
23
|
+
|| probe.migrationGlobs.some((glob) => typeof glob !== 'string' || !glob || glob.length > 500))
|
|
24
|
+
return false;
|
|
25
|
+
const moduleIds = new Set();
|
|
26
|
+
for (const entry of probe.modules) {
|
|
27
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
28
|
+
return false;
|
|
29
|
+
const module = entry;
|
|
30
|
+
if (Object.keys(module).sort().join('\0') !== ['moduleId', 'paths'].sort().join('\0')
|
|
31
|
+
|| typeof module.moduleId !== 'string' || !module.moduleId || module.moduleId.length > MAX_NAME_LENGTH
|
|
32
|
+
|| moduleIds.has(module.moduleId) || !Array.isArray(module.paths) || module.paths.length > 3_000
|
|
33
|
+
|| module.paths.some((path) => !safePath(path)))
|
|
34
|
+
return false;
|
|
35
|
+
moduleIds.add(module.moduleId);
|
|
36
|
+
}
|
|
37
|
+
return new Set(probe.schemaPaths).size === probe.schemaPaths.length;
|
|
38
|
+
}
|
|
39
|
+
function runGit(repoPath, args, outputLimit = MAX_FILE_BYTES + 1) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const child = spawn('git', ['--no-replace-objects', ...args], {
|
|
42
|
+
cwd: repoPath,
|
|
43
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
44
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
45
|
+
windowsHide: true,
|
|
46
|
+
});
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let bytes = 0;
|
|
49
|
+
let settled = false;
|
|
50
|
+
const finish = (error, output) => {
|
|
51
|
+
if (settled)
|
|
52
|
+
return;
|
|
53
|
+
settled = true;
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
if (error)
|
|
56
|
+
reject(error);
|
|
57
|
+
else
|
|
58
|
+
resolve(output);
|
|
59
|
+
};
|
|
60
|
+
child.stdout?.on('data', (chunk) => {
|
|
61
|
+
bytes += chunk.length;
|
|
62
|
+
if (bytes > outputLimit) {
|
|
63
|
+
child.kill('SIGKILL');
|
|
64
|
+
finish(new Error('git output exceeded limit'));
|
|
65
|
+
}
|
|
66
|
+
else
|
|
67
|
+
chunks.push(chunk);
|
|
68
|
+
});
|
|
69
|
+
child.stderr?.on('data', () => undefined);
|
|
70
|
+
child.on('error', (error) => finish(error));
|
|
71
|
+
child.on('close', (code, signal) => {
|
|
72
|
+
if (signal || code === null)
|
|
73
|
+
finish(new Error('git command failed'));
|
|
74
|
+
else
|
|
75
|
+
finish(undefined, { stdout: Buffer.concat(chunks), exitCode: code });
|
|
76
|
+
});
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
child.kill('SIGKILL');
|
|
79
|
+
finish(new Error('git command timed out'));
|
|
80
|
+
}, GIT_TIMEOUT_MS);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async function gitText(repoPath, revision, path) {
|
|
84
|
+
const output = await runGit(repoPath, ['show', `${revision}:${path}`]);
|
|
85
|
+
if (output.exitCode !== 0)
|
|
86
|
+
return null;
|
|
87
|
+
if (output.stdout.includes(0))
|
|
88
|
+
return null;
|
|
89
|
+
try {
|
|
90
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(output.stdout);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function gitPathExists(repoPath, revision, path) {
|
|
97
|
+
return (await runGit(repoPath, ['cat-file', '-e', `${revision}:${path}`], 100)).exitCode === 0;
|
|
98
|
+
}
|
|
99
|
+
function skipTrivia(source, start) {
|
|
100
|
+
let index = start;
|
|
101
|
+
while (index < source.length) {
|
|
102
|
+
if (/\s/.test(source[index]))
|
|
103
|
+
index += 1;
|
|
104
|
+
else if (source.startsWith('//', index)) {
|
|
105
|
+
const end = source.indexOf('\n', index + 2);
|
|
106
|
+
index = end === -1 ? source.length : end + 1;
|
|
107
|
+
}
|
|
108
|
+
else if (source.startsWith('/*', index)) {
|
|
109
|
+
const end = source.indexOf('*/', index + 2);
|
|
110
|
+
if (end === -1)
|
|
111
|
+
return -1;
|
|
112
|
+
index = end + 2;
|
|
113
|
+
}
|
|
114
|
+
else
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
return index;
|
|
118
|
+
}
|
|
119
|
+
function findBlockEnd(source, open) {
|
|
120
|
+
let depth = 0;
|
|
121
|
+
let quote = null;
|
|
122
|
+
for (let index = open; index < source.length; index += 1) {
|
|
123
|
+
const char = source[index];
|
|
124
|
+
if (quote) {
|
|
125
|
+
if (char === '\\')
|
|
126
|
+
index += 1;
|
|
127
|
+
else if (char === quote)
|
|
128
|
+
quote = null;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (char === '"' || char === "'")
|
|
132
|
+
quote = char;
|
|
133
|
+
else if (source.startsWith('//', index)) {
|
|
134
|
+
const end = source.indexOf('\n', index + 2);
|
|
135
|
+
if (end === -1)
|
|
136
|
+
return -1;
|
|
137
|
+
index = end;
|
|
138
|
+
}
|
|
139
|
+
else if (source.startsWith('/*', index)) {
|
|
140
|
+
const end = source.indexOf('*/', index + 2);
|
|
141
|
+
if (end === -1)
|
|
142
|
+
return -1;
|
|
143
|
+
index = end + 1;
|
|
144
|
+
}
|
|
145
|
+
else if (char === '{')
|
|
146
|
+
depth += 1;
|
|
147
|
+
else if (char === '}' && --depth === 0)
|
|
148
|
+
return index;
|
|
149
|
+
}
|
|
150
|
+
return -1;
|
|
151
|
+
}
|
|
152
|
+
function canonicalPrisma(text) {
|
|
153
|
+
let output = '';
|
|
154
|
+
let quote = null;
|
|
155
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
156
|
+
const char = text[index];
|
|
157
|
+
if (quote) {
|
|
158
|
+
output += char;
|
|
159
|
+
if (char === '\\' && index + 1 < text.length)
|
|
160
|
+
output += text[++index];
|
|
161
|
+
else if (char === quote)
|
|
162
|
+
quote = null;
|
|
163
|
+
}
|
|
164
|
+
else if (char === '"' || char === "'") {
|
|
165
|
+
quote = char;
|
|
166
|
+
output += char;
|
|
167
|
+
}
|
|
168
|
+
else if (text.startsWith('//', index)) {
|
|
169
|
+
const end = text.indexOf('\n', index + 2);
|
|
170
|
+
if (end === -1)
|
|
171
|
+
break;
|
|
172
|
+
index = end;
|
|
173
|
+
}
|
|
174
|
+
else if (text.startsWith('/*', index)) {
|
|
175
|
+
const end = text.indexOf('*/', index + 2);
|
|
176
|
+
if (end === -1)
|
|
177
|
+
return null;
|
|
178
|
+
index = end + 1;
|
|
179
|
+
}
|
|
180
|
+
else if (!/\s/.test(char))
|
|
181
|
+
output += char;
|
|
182
|
+
}
|
|
183
|
+
return quote ? null : output;
|
|
184
|
+
}
|
|
185
|
+
function modelMappedName(text) {
|
|
186
|
+
const occurrences = [...text.matchAll(/@@map\b/g)];
|
|
187
|
+
if (occurrences.length === 0)
|
|
188
|
+
return null;
|
|
189
|
+
if (occurrences.length !== 1)
|
|
190
|
+
return undefined;
|
|
191
|
+
const match = /@@map\s*\(\s*("(?:\\.|[^"\\])*")\s*\)/.exec(text);
|
|
192
|
+
if (!match)
|
|
193
|
+
return undefined;
|
|
194
|
+
try {
|
|
195
|
+
const value = JSON.parse(match[1]);
|
|
196
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
export function parsePrismaSchema(source) {
|
|
203
|
+
const blocks = new Map();
|
|
204
|
+
const blockKinds = new Set(['model', 'enum', 'view', 'type', 'datasource', 'generator']);
|
|
205
|
+
let index = 0;
|
|
206
|
+
while (index < source.length) {
|
|
207
|
+
index = skipTrivia(source, index);
|
|
208
|
+
if (index < 0)
|
|
209
|
+
return null;
|
|
210
|
+
if (index >= source.length)
|
|
211
|
+
break;
|
|
212
|
+
const word = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(index));
|
|
213
|
+
if (!word) {
|
|
214
|
+
if (source[index] === '{' || source[index] === '}')
|
|
215
|
+
return null;
|
|
216
|
+
index += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const kind = word[0];
|
|
220
|
+
index += word[0].length;
|
|
221
|
+
if (!blockKinds.has(kind))
|
|
222
|
+
continue;
|
|
223
|
+
index = skipTrivia(source, index);
|
|
224
|
+
if (index < 0)
|
|
225
|
+
return null;
|
|
226
|
+
const nameMatch = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(index));
|
|
227
|
+
if (!nameMatch)
|
|
228
|
+
return null;
|
|
229
|
+
const name = nameMatch[0];
|
|
230
|
+
index += name.length;
|
|
231
|
+
index = skipTrivia(source, index);
|
|
232
|
+
if (index < 0 || source[index] !== '{')
|
|
233
|
+
return null;
|
|
234
|
+
const end = findBlockEnd(source, index);
|
|
235
|
+
if (end < 0)
|
|
236
|
+
return null;
|
|
237
|
+
const text = source.slice(index, end + 1);
|
|
238
|
+
const canonical = canonicalPrisma(text);
|
|
239
|
+
if (canonical === null)
|
|
240
|
+
return null;
|
|
241
|
+
const mappedName = kind === 'model' ? modelMappedName(canonical) : null;
|
|
242
|
+
if (mappedName === undefined)
|
|
243
|
+
return null;
|
|
244
|
+
const key = `${kind}:${name}`;
|
|
245
|
+
if (blocks.has(key))
|
|
246
|
+
return null;
|
|
247
|
+
blocks.set(key, { kind, name, text, canonical, mappedName });
|
|
248
|
+
index = end + 1;
|
|
249
|
+
}
|
|
250
|
+
return { blocks };
|
|
251
|
+
}
|
|
252
|
+
function modelStatements(text) {
|
|
253
|
+
const open = text.indexOf('{');
|
|
254
|
+
const close = text.lastIndexOf('}');
|
|
255
|
+
if (open < 0 || close <= open)
|
|
256
|
+
return null;
|
|
257
|
+
const statements = [];
|
|
258
|
+
let current = '';
|
|
259
|
+
let quote = null;
|
|
260
|
+
let depth = 0;
|
|
261
|
+
for (let index = open + 1; index < close; index += 1) {
|
|
262
|
+
const char = text[index];
|
|
263
|
+
if (quote) {
|
|
264
|
+
current += char;
|
|
265
|
+
if (char === '\\' && index + 1 < close)
|
|
266
|
+
current += text[++index];
|
|
267
|
+
else if (char === quote)
|
|
268
|
+
quote = null;
|
|
269
|
+
}
|
|
270
|
+
else if (char === '"' || char === "'") {
|
|
271
|
+
quote = char;
|
|
272
|
+
current += char;
|
|
273
|
+
}
|
|
274
|
+
else if (text.startsWith('//', index)) {
|
|
275
|
+
const end = text.indexOf('\n', index + 2);
|
|
276
|
+
index = end === -1 || end >= close ? close - 1 : end - 1;
|
|
277
|
+
}
|
|
278
|
+
else if (text.startsWith('/*', index)) {
|
|
279
|
+
const end = text.indexOf('*/', index + 2);
|
|
280
|
+
if (end === -1 || end >= close)
|
|
281
|
+
return null;
|
|
282
|
+
index = end + 1;
|
|
283
|
+
}
|
|
284
|
+
else if (char === '(' || char === '[') {
|
|
285
|
+
depth += 1;
|
|
286
|
+
current += char;
|
|
287
|
+
}
|
|
288
|
+
else if (char === ')' || char === ']') {
|
|
289
|
+
if (depth === 0)
|
|
290
|
+
return null;
|
|
291
|
+
depth -= 1;
|
|
292
|
+
current += char;
|
|
293
|
+
}
|
|
294
|
+
else if ((char === '\n' || char === '\r') && depth === 0) {
|
|
295
|
+
if (current.trim())
|
|
296
|
+
statements.push(current.trim());
|
|
297
|
+
current = '';
|
|
298
|
+
}
|
|
299
|
+
else
|
|
300
|
+
current += char;
|
|
301
|
+
}
|
|
302
|
+
if (quote || depth !== 0)
|
|
303
|
+
return null;
|
|
304
|
+
if (current.trim())
|
|
305
|
+
statements.push(current.trim());
|
|
306
|
+
return statements;
|
|
307
|
+
}
|
|
308
|
+
function canonicalAttributes(source, prefix) {
|
|
309
|
+
const attributes = [];
|
|
310
|
+
let index = 0;
|
|
311
|
+
while (index < source.length) {
|
|
312
|
+
while (/\s/.test(source[index] ?? ''))
|
|
313
|
+
index += 1;
|
|
314
|
+
if (!source.startsWith(prefix, index) || (prefix === '@' && source[index + 1] === '@'))
|
|
315
|
+
return null;
|
|
316
|
+
const start = index;
|
|
317
|
+
index += prefix.length;
|
|
318
|
+
const name = /^[A-Za-z_][A-Za-z0-9_.]*/.exec(source.slice(index));
|
|
319
|
+
if (!name)
|
|
320
|
+
return null;
|
|
321
|
+
index += name[0].length;
|
|
322
|
+
while (/\s/.test(source[index] ?? ''))
|
|
323
|
+
index += 1;
|
|
324
|
+
if (source[index] === '(') {
|
|
325
|
+
let depth = 0;
|
|
326
|
+
let quote = null;
|
|
327
|
+
for (; index < source.length; index += 1) {
|
|
328
|
+
const char = source[index];
|
|
329
|
+
if (quote) {
|
|
330
|
+
if (char === '\\')
|
|
331
|
+
index += 1;
|
|
332
|
+
else if (char === quote)
|
|
333
|
+
quote = null;
|
|
334
|
+
}
|
|
335
|
+
else if (char === '"' || char === "'")
|
|
336
|
+
quote = char;
|
|
337
|
+
else if (char === '(')
|
|
338
|
+
depth += 1;
|
|
339
|
+
else if (char === ')' && --depth === 0) {
|
|
340
|
+
index += 1;
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (depth !== 0 || quote)
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
const canonical = canonicalPrisma(source.slice(start, index));
|
|
348
|
+
if (canonical === null)
|
|
349
|
+
return null;
|
|
350
|
+
attributes.push(canonical);
|
|
351
|
+
}
|
|
352
|
+
return attributes;
|
|
353
|
+
}
|
|
354
|
+
function comparableModel(block, modelNames) {
|
|
355
|
+
const statements = modelStatements(block.text);
|
|
356
|
+
if (!statements)
|
|
357
|
+
return null;
|
|
358
|
+
const fields = [];
|
|
359
|
+
const blockAttributes = [];
|
|
360
|
+
for (const statement of statements) {
|
|
361
|
+
if (statement.startsWith('@@')) {
|
|
362
|
+
const attributes = canonicalAttributes(statement, '@@');
|
|
363
|
+
if (!attributes)
|
|
364
|
+
return null;
|
|
365
|
+
blockAttributes.push(...attributes);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const field = /^([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_]*)(\[\]|\?)?\s*(.*)$/s.exec(statement);
|
|
369
|
+
if (!field)
|
|
370
|
+
return null;
|
|
371
|
+
const [, name, type, modifier = '', rest] = field;
|
|
372
|
+
const attributes = canonicalAttributes(rest, '@');
|
|
373
|
+
if (!attributes)
|
|
374
|
+
return null;
|
|
375
|
+
if (modelNames.has(type))
|
|
376
|
+
continue;
|
|
377
|
+
fields.push(`${name}:${type}${modifier}:${attributes.sort().join(',')}`);
|
|
378
|
+
}
|
|
379
|
+
return `${fields.join('|')}#${blockAttributes.sort().join(',')}`;
|
|
380
|
+
}
|
|
381
|
+
function modelsUsingEnums(schemas, enumNames) {
|
|
382
|
+
const models = [];
|
|
383
|
+
for (const schema of schemas) {
|
|
384
|
+
for (const block of schema.blocks.values()) {
|
|
385
|
+
if (block.kind !== 'model')
|
|
386
|
+
continue;
|
|
387
|
+
const statements = modelStatements(block.text);
|
|
388
|
+
if (!statements)
|
|
389
|
+
return null;
|
|
390
|
+
for (const statement of statements) {
|
|
391
|
+
if (statement.startsWith('@@'))
|
|
392
|
+
continue;
|
|
393
|
+
const field = /^[A-Za-z_][A-Za-z0-9_]*\s+([A-Za-z_][A-Za-z0-9_]*)(?:\[\]|\?)?(?:\s|$)/s.exec(statement);
|
|
394
|
+
if (!field)
|
|
395
|
+
continue;
|
|
396
|
+
if (enumNames.has(field[1])) {
|
|
397
|
+
models.push(block);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return models;
|
|
404
|
+
}
|
|
405
|
+
function changedBlocks(before, after) {
|
|
406
|
+
for (const kind of ['datasource', 'generator']) {
|
|
407
|
+
const keys = new Set([...before.blocks.keys(), ...after.blocks.keys()].filter((key) => key.startsWith(`${kind}:`)));
|
|
408
|
+
for (const key of keys)
|
|
409
|
+
if (before.blocks.get(key)?.canonical !== after.blocks.get(key)?.canonical)
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
const changed = [];
|
|
413
|
+
const modelNames = new Set([...before.blocks.values(), ...after.blocks.values()]
|
|
414
|
+
.filter((block) => block.kind === 'model').map((block) => block.name));
|
|
415
|
+
const keys = new Set([...before.blocks.keys(), ...after.blocks.keys()]);
|
|
416
|
+
for (const key of keys) {
|
|
417
|
+
const prior = before.blocks.get(key);
|
|
418
|
+
const next = after.blocks.get(key);
|
|
419
|
+
if (prior?.kind === 'datasource' || prior?.kind === 'generator'
|
|
420
|
+
|| next?.kind === 'datasource' || next?.kind === 'generator')
|
|
421
|
+
continue;
|
|
422
|
+
if (!prior || !next) {
|
|
423
|
+
changed.push(next ?? prior);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (prior.kind === 'model' && next.kind === 'model') {
|
|
427
|
+
const priorComparable = comparableModel(prior, modelNames);
|
|
428
|
+
const nextComparable = comparableModel(next, modelNames);
|
|
429
|
+
if (priorComparable === null || nextComparable === null) {
|
|
430
|
+
if (prior.canonical !== next.canonical)
|
|
431
|
+
changed.push(next);
|
|
432
|
+
}
|
|
433
|
+
else if (priorComparable !== nextComparable)
|
|
434
|
+
changed.push(next);
|
|
435
|
+
}
|
|
436
|
+
else if (prior.canonical !== next.canonical)
|
|
437
|
+
changed.push(next);
|
|
438
|
+
}
|
|
439
|
+
return changed;
|
|
440
|
+
}
|
|
441
|
+
function migrationPath(path, globs) {
|
|
442
|
+
return globs.some((glob) => glob === '**/migrations/**/*.sql'
|
|
443
|
+
? /(^|\/)migrations\/.*\.sql$/i.test(path)
|
|
444
|
+
: glob === '*.sql' ? path.endsWith('.sql') : false);
|
|
445
|
+
}
|
|
446
|
+
function splitSql(source) {
|
|
447
|
+
if (/\$[A-Za-z0-9_]*\$/.test(source))
|
|
448
|
+
return null;
|
|
449
|
+
const statements = [];
|
|
450
|
+
let current = '';
|
|
451
|
+
let quote = null;
|
|
452
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
453
|
+
const char = source[index];
|
|
454
|
+
if (quote) {
|
|
455
|
+
current += char;
|
|
456
|
+
if (char === quote) {
|
|
457
|
+
if (source[index + 1] === quote)
|
|
458
|
+
current += source[++index];
|
|
459
|
+
else
|
|
460
|
+
quote = null;
|
|
461
|
+
}
|
|
462
|
+
else if (char === '\\' && quote === "'" && index + 1 < source.length)
|
|
463
|
+
current += source[++index];
|
|
464
|
+
}
|
|
465
|
+
else if (char === "'" || char === '"' || char === '`') {
|
|
466
|
+
quote = char;
|
|
467
|
+
current += char;
|
|
468
|
+
}
|
|
469
|
+
else if (source.startsWith('--', index)) {
|
|
470
|
+
const end = source.indexOf('\n', index + 2);
|
|
471
|
+
index = end === -1 ? source.length : end;
|
|
472
|
+
current += ' ';
|
|
473
|
+
}
|
|
474
|
+
else if (source.startsWith('/*', index)) {
|
|
475
|
+
const end = source.indexOf('*/', index + 2);
|
|
476
|
+
if (end === -1)
|
|
477
|
+
return null;
|
|
478
|
+
index = end + 1;
|
|
479
|
+
current += ' ';
|
|
480
|
+
}
|
|
481
|
+
else if (char === ';') {
|
|
482
|
+
if (current.trim())
|
|
483
|
+
statements.push(current.trim());
|
|
484
|
+
current = '';
|
|
485
|
+
}
|
|
486
|
+
else
|
|
487
|
+
current += char;
|
|
488
|
+
}
|
|
489
|
+
if (quote)
|
|
490
|
+
return null;
|
|
491
|
+
if (current.trim())
|
|
492
|
+
statements.push(current.trim());
|
|
493
|
+
return statements;
|
|
494
|
+
}
|
|
495
|
+
const IDENTIFIER = '(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[A-Za-z_][A-Za-z0-9_$]*)';
|
|
496
|
+
const QUALIFIED = `(${IDENTIFIER}(?:\\s*\\.\\s*${IDENTIFIER})*)`;
|
|
497
|
+
function identifierName(value) {
|
|
498
|
+
const last = value.split('.').at(-1).trim();
|
|
499
|
+
if (last.startsWith('"'))
|
|
500
|
+
return last.slice(1, -1).replaceAll('""', '"');
|
|
501
|
+
if (last.startsWith('`'))
|
|
502
|
+
return last.slice(1, -1).replaceAll('``', '`');
|
|
503
|
+
return last;
|
|
504
|
+
}
|
|
505
|
+
function sqlTargets(source) {
|
|
506
|
+
const statements = splitSql(source);
|
|
507
|
+
if (!statements)
|
|
508
|
+
return null;
|
|
509
|
+
const targets = [];
|
|
510
|
+
const patterns = [
|
|
511
|
+
{ kind: 'table', pattern: new RegExp(`^CREATE\\s+TABLE(?:\\s+IF\\s+NOT\\s+EXISTS)?\\s+${QUALIFIED}`, 'i') },
|
|
512
|
+
{ kind: 'table', pattern: new RegExp(`^ALTER\\s+TABLE(?:\\s+ONLY)?\\s+${QUALIFIED}`, 'i') },
|
|
513
|
+
{ kind: 'table', pattern: new RegExp(`^DROP\\s+TABLE(?:\\s+IF\\s+EXISTS)?\\s+${QUALIFIED}`, 'i') },
|
|
514
|
+
{ kind: 'table', pattern: new RegExp(`^CREATE\\s+(?:UNIQUE\\s+)?INDEX(?:\\s+CONCURRENTLY)?(?:\\s+IF\\s+NOT\\s+EXISTS)?\\s+${IDENTIFIER}\\s+ON\\s+(?:ONLY\\s+)?${QUALIFIED}`, 'i') },
|
|
515
|
+
{ kind: 'enum', pattern: new RegExp(`^ALTER\\s+TYPE\\s+${QUALIFIED}`, 'i') },
|
|
516
|
+
{ kind: 'enum', pattern: new RegExp(`^CREATE\\s+TYPE\\s+${QUALIFIED}`, 'i') },
|
|
517
|
+
];
|
|
518
|
+
for (const statement of statements) {
|
|
519
|
+
let matched = false;
|
|
520
|
+
for (const { kind, pattern } of patterns) {
|
|
521
|
+
const match = pattern.exec(statement);
|
|
522
|
+
if (!match)
|
|
523
|
+
continue;
|
|
524
|
+
targets.push({ kind, name: identifierName(match[1]) });
|
|
525
|
+
matched = true;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
if (!matched && !/^(?:BEGIN|COMMIT|ROLLBACK|DROP\s+INDEX\b|SET\s+|UPDATE\s+|INSERT\s+INTO\s+|DELETE\s+FROM\s+)/i.test(statement))
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
return targets;
|
|
532
|
+
}
|
|
533
|
+
const lowerCamel = (name) => name ? `${name[0].toLowerCase()}${name.slice(1)}` : name;
|
|
534
|
+
function termsFor(block) {
|
|
535
|
+
return {
|
|
536
|
+
name: block.name,
|
|
537
|
+
kind: block.kind,
|
|
538
|
+
accessor: block.kind === 'model' || block.kind === 'view' ? lowerCamel(block.name) : null,
|
|
539
|
+
mappedName: block.mappedName,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
function mappedEntity(target, schemas) {
|
|
543
|
+
const kinds = target.kind === 'table' ? new Set(['model', 'view']) : new Set(['enum']);
|
|
544
|
+
const candidates = schemas.flatMap((schema) => [...schema.blocks.values()]).filter((block) => kinds.has(block.kind) && (block.mappedName === target.name || block.name === target.name));
|
|
545
|
+
if (candidates.length === 1)
|
|
546
|
+
return candidates[0];
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
function referenceFacts(path, text) {
|
|
550
|
+
const facts = {
|
|
551
|
+
calledAccessors: new Set(),
|
|
552
|
+
importedNames: new Set(),
|
|
553
|
+
prismaNames: new Set(),
|
|
554
|
+
enumNames: new Set(),
|
|
555
|
+
strings: [],
|
|
556
|
+
};
|
|
557
|
+
if (path.toLowerCase().endsWith('.sql'))
|
|
558
|
+
facts.strings.push(text);
|
|
559
|
+
const kind = path.endsWith('.tsx') ? ts.ScriptKind.TSX
|
|
560
|
+
: path.endsWith('.jsx') ? ts.ScriptKind.JSX
|
|
561
|
+
: path.endsWith('.js') || path.endsWith('.mjs') || path.endsWith('.cjs') ? ts.ScriptKind.JS
|
|
562
|
+
: ts.ScriptKind.TS;
|
|
563
|
+
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, kind);
|
|
564
|
+
const visit = (node) => {
|
|
565
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)
|
|
566
|
+
&& (node.moduleSpecifier.text === '@prisma/client' || node.moduleSpecifier.text.includes('.prisma/client'))
|
|
567
|
+
&& node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
|
|
568
|
+
for (const element of node.importClause.namedBindings.elements) {
|
|
569
|
+
facts.importedNames.add((element.propertyName ?? element.name).text);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
|
|
573
|
+
&& ts.isPropertyAccessExpression(node.expression.expression)) {
|
|
574
|
+
facts.calledAccessors.add(node.expression.expression.name.text);
|
|
575
|
+
}
|
|
576
|
+
if (ts.isQualifiedName(node) && ts.isIdentifier(node.left)) {
|
|
577
|
+
if (node.left.text === 'Prisma')
|
|
578
|
+
facts.prismaNames.add(node.right.text);
|
|
579
|
+
else if (node.left.text === '$Enums')
|
|
580
|
+
facts.enumNames.add(node.right.text);
|
|
581
|
+
}
|
|
582
|
+
else if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
|
|
583
|
+
if (node.expression.text === 'Prisma')
|
|
584
|
+
facts.prismaNames.add(node.name.text);
|
|
585
|
+
else if (node.expression.text === '$Enums')
|
|
586
|
+
facts.enumNames.add(node.name.text);
|
|
587
|
+
else
|
|
588
|
+
facts.enumNames.add(node.expression.text);
|
|
589
|
+
}
|
|
590
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
|
|
591
|
+
|| ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) {
|
|
592
|
+
facts.strings.push(node.text);
|
|
593
|
+
}
|
|
594
|
+
ts.forEachChild(node, visit);
|
|
595
|
+
};
|
|
596
|
+
visit(source);
|
|
597
|
+
return facts;
|
|
598
|
+
}
|
|
599
|
+
function generatedTypeReference(names, model) {
|
|
600
|
+
const suffix = /^(?:Where|Select|Omit|Include|GetPayload|DefaultArgs|FieldRefs|Scalar|Nullable|ListRelation|Relation|Create|Update|Unchecked|OrderBy|Find|Aggregate|GroupBy|Count|Min|Max|Avg|Sum|Delegate|Payload|Args|Result)/;
|
|
601
|
+
for (const name of names) {
|
|
602
|
+
if (name.startsWith(model) && suffix.test(name.slice(model.length)))
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
function referencesEntity(facts, entity) {
|
|
608
|
+
if (facts.importedNames.has(entity.name))
|
|
609
|
+
return true;
|
|
610
|
+
if (entity.kind === 'enum') {
|
|
611
|
+
return facts.enumNames.has(entity.name);
|
|
612
|
+
}
|
|
613
|
+
if (entity.accessor && facts.calledAccessors.has(entity.accessor))
|
|
614
|
+
return true;
|
|
615
|
+
if (generatedTypeReference(facts.prismaNames, entity.name))
|
|
616
|
+
return true;
|
|
617
|
+
return entity.mappedName !== null
|
|
618
|
+
&& facts.strings.some((value) => value.includes(`"${entity.mappedName}"`));
|
|
619
|
+
}
|
|
620
|
+
async function headFile(repoPath, head, path) {
|
|
621
|
+
const size = await runGit(repoPath, ['cat-file', '-s', `${head}:${path}`], 100);
|
|
622
|
+
if (size.exitCode !== 0)
|
|
623
|
+
return null;
|
|
624
|
+
const parsed = Number(size.stdout.toString('ascii').trim());
|
|
625
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > MAX_FILE_BYTES)
|
|
626
|
+
return null;
|
|
627
|
+
return gitText(repoPath, head, path);
|
|
628
|
+
}
|
|
629
|
+
export async function detectSchemaImpact(input) {
|
|
630
|
+
if (!validSchemaProbe(input.probe))
|
|
631
|
+
return { entities: null, modulesReferencing: [] };
|
|
632
|
+
const changedSchemas = input.changedPaths.filter((path) => path.endsWith('.prisma'));
|
|
633
|
+
const changedMigrations = input.changedPaths.filter((path) => migrationPath(path, input.probe.migrationGlobs));
|
|
634
|
+
if (changedSchemas.length === 0 && changedMigrations.length === 0) {
|
|
635
|
+
return { entities: [], modulesReferencing: [] };
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
const entityByName = new Map();
|
|
639
|
+
const headSchemas = [];
|
|
640
|
+
const baseSchemas = [];
|
|
641
|
+
for (const path of new Set([...input.probe.schemaPaths, ...changedSchemas])) {
|
|
642
|
+
const text = await gitText(input.repoPath, input.head, path);
|
|
643
|
+
if (text === null) {
|
|
644
|
+
if (await gitPathExists(input.repoPath, input.head, path)) {
|
|
645
|
+
return { entities: null, modulesReferencing: [] };
|
|
646
|
+
}
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const parsed = parsePrismaSchema(text);
|
|
650
|
+
if (!parsed)
|
|
651
|
+
return { entities: null, modulesReferencing: [] };
|
|
652
|
+
headSchemas.push(parsed);
|
|
653
|
+
}
|
|
654
|
+
for (const path of changedSchemas) {
|
|
655
|
+
const [beforeText, afterText] = await Promise.all([
|
|
656
|
+
gitText(input.repoPath, input.base, path),
|
|
657
|
+
gitText(input.repoPath, input.head, path),
|
|
658
|
+
]);
|
|
659
|
+
const [beforeExists, afterExists] = await Promise.all([
|
|
660
|
+
beforeText === null ? gitPathExists(input.repoPath, input.base, path) : false,
|
|
661
|
+
afterText === null ? gitPathExists(input.repoPath, input.head, path) : false,
|
|
662
|
+
]);
|
|
663
|
+
if (beforeExists || afterExists)
|
|
664
|
+
return { entities: null, modulesReferencing: [] };
|
|
665
|
+
const before = beforeText === null ? { blocks: new Map() } : parsePrismaSchema(beforeText);
|
|
666
|
+
const after = afterText === null ? { blocks: new Map() } : parsePrismaSchema(afterText);
|
|
667
|
+
if (!before || !after)
|
|
668
|
+
return { entities: null, modulesReferencing: [] };
|
|
669
|
+
baseSchemas.push(before);
|
|
670
|
+
const changed = changedBlocks(before, after);
|
|
671
|
+
if (!changed)
|
|
672
|
+
return { entities: null, modulesReferencing: [] };
|
|
673
|
+
for (const block of changed) {
|
|
674
|
+
const terms = termsFor(block);
|
|
675
|
+
entityByName.set(terms.name, terms);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
for (const path of changedMigrations) {
|
|
679
|
+
const sql = await gitText(input.repoPath, input.head, path);
|
|
680
|
+
if (sql === null) {
|
|
681
|
+
if (await gitPathExists(input.repoPath, input.head, path)) {
|
|
682
|
+
return { entities: null, modulesReferencing: [] };
|
|
683
|
+
}
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const targets = sqlTargets(sql);
|
|
687
|
+
if (!targets)
|
|
688
|
+
return { entities: null, modulesReferencing: [] };
|
|
689
|
+
for (const target of targets) {
|
|
690
|
+
const block = mappedEntity(target, headSchemas);
|
|
691
|
+
if (!block)
|
|
692
|
+
return { entities: null, modulesReferencing: [] };
|
|
693
|
+
const terms = termsFor(block);
|
|
694
|
+
entityByName.set(terms.name, terms);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const changedEnums = new Set([...entityByName.values()]
|
|
698
|
+
.filter((entity) => entity.kind === 'enum').map((entity) => entity.name));
|
|
699
|
+
if (changedEnums.size > 0) {
|
|
700
|
+
const headModels = modelsUsingEnums(headSchemas, changedEnums);
|
|
701
|
+
const baseModels = modelsUsingEnums(baseSchemas, changedEnums);
|
|
702
|
+
if (!headModels || !baseModels)
|
|
703
|
+
return { entities: null, modulesReferencing: [] };
|
|
704
|
+
for (const block of [...headModels, ...baseModels]) {
|
|
705
|
+
if (!entityByName.has(block.name))
|
|
706
|
+
entityByName.set(block.name, termsFor(block));
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const entities = [...entityByName.keys()].sort((left, right) => left.localeCompare(right));
|
|
710
|
+
if (entities.length > MAX_SCHEMA_ENTITIES || entities.some((name) => !name || name.length > MAX_NAME_LENGTH)) {
|
|
711
|
+
return { entities: null, modulesReferencing: [] };
|
|
712
|
+
}
|
|
713
|
+
if (entities.length === 0)
|
|
714
|
+
return { entities, modulesReferencing: [] };
|
|
715
|
+
const excluded = (path) => path.endsWith('.prisma') || migrationPath(path, input.probe.migrationGlobs);
|
|
716
|
+
const pathModuleCounts = new Map();
|
|
717
|
+
for (const module of input.probe.modules) {
|
|
718
|
+
for (const path of new Set(module.paths))
|
|
719
|
+
pathModuleCounts.set(path, (pathModuleCounts.get(path) ?? 0) + 1);
|
|
720
|
+
}
|
|
721
|
+
const referenceEntities = [...entityByName.values()];
|
|
722
|
+
const fileCache = new Map();
|
|
723
|
+
const readHeadFile = (path) => {
|
|
724
|
+
let pending = fileCache.get(path);
|
|
725
|
+
if (!pending) {
|
|
726
|
+
pending = headFile(input.repoPath, input.head, path)
|
|
727
|
+
.then((text) => text === null ? null : referenceFacts(path, text));
|
|
728
|
+
fileCache.set(path, pending);
|
|
729
|
+
}
|
|
730
|
+
return pending;
|
|
731
|
+
};
|
|
732
|
+
const modulesReferencing = [];
|
|
733
|
+
for (const module of input.probe.modules) {
|
|
734
|
+
let referenced = false;
|
|
735
|
+
for (const path of module.paths) {
|
|
736
|
+
if (excluded(path) || (pathModuleCounts.get(path) ?? 0) > HUB_MODULE_THRESHOLD)
|
|
737
|
+
continue;
|
|
738
|
+
const facts = await readHeadFile(path);
|
|
739
|
+
if (facts === null)
|
|
740
|
+
continue;
|
|
741
|
+
if (referenceEntities.some((entity) => referencesEntity(facts, entity))) {
|
|
742
|
+
referenced = true;
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (referenced)
|
|
747
|
+
modulesReferencing.push(module.moduleId);
|
|
748
|
+
}
|
|
749
|
+
modulesReferencing.sort((left, right) => left.localeCompare(right));
|
|
750
|
+
return { entities, modulesReferencing };
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
return { entities: null, modulesReferencing: [] };
|
|
754
|
+
}
|
|
755
|
+
}
|