@syntopica/db-quality 0.1.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 +114 -0
- package/assets/db-quality.yml +26 -0
- package/assets/drizzle-eslint.config.mjs +22 -0
- package/assets/prisma-lint.json +5 -0
- package/bin/codeality-db.mjs +2 -0
- package/dist/cli.js +1636 -0
- package/dist/cli.js.map +1 -0
- package/package.json +85 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1636 @@
|
|
|
1
|
+
// src/cli.ts
|
|
2
|
+
import { argv, cwd, exit, stderr, stdout } from "process";
|
|
3
|
+
|
|
4
|
+
// src/audit/isSupabaseHost.ts
|
|
5
|
+
var isSupabaseHost = (dbUrl) => {
|
|
6
|
+
try {
|
|
7
|
+
const { hostname } = new URL(dbUrl);
|
|
8
|
+
return hostname.endsWith(".supabase.co") || hostname.endsWith(".pooler.supabase.com");
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// src/config/ConfigError.ts
|
|
15
|
+
var ConfigError = class extends Error {
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// src/audit/auditPlan.ts
|
|
19
|
+
var auditPlan = (config, target) => {
|
|
20
|
+
const supabase = "linked" in target || isSupabaseHost(target.dbUrl);
|
|
21
|
+
const soda = config.audit.soda !== void 0 && "dbUrl" in target;
|
|
22
|
+
const skipped = [
|
|
23
|
+
...supabase ? [] : ["supabase: skipped, --db-url is not a Supabase project host"],
|
|
24
|
+
...config.audit.soda !== void 0 && !soda ? [
|
|
25
|
+
"soda: skipped, a linked target carries no database password; pass --db-url"
|
|
26
|
+
] : []
|
|
27
|
+
];
|
|
28
|
+
if (!supabase && !soda) {
|
|
29
|
+
throw new ConfigError(
|
|
30
|
+
"nothing to audit: not a Supabase host and audit.soda is not configured"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return { supabase, soda, skipped };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// src/audit/resolveAuditTarget.ts
|
|
37
|
+
import { existsSync } from "fs";
|
|
38
|
+
import { join } from "path";
|
|
39
|
+
var resolveAuditTarget = (root, values) => {
|
|
40
|
+
if (values["db-url"]) return { dbUrl: values["db-url"] };
|
|
41
|
+
if (values.linked) {
|
|
42
|
+
if (!existsSync(join(root, "supabase/.temp/project-ref"))) {
|
|
43
|
+
throw new ConfigError(
|
|
44
|
+
'project is not linked; run "supabase link" or pass --db-url'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return { linked: true };
|
|
48
|
+
}
|
|
49
|
+
throw new ConfigError("audit needs --linked or --db-url");
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// src/adapters/soda/runSoda.ts
|
|
53
|
+
import {
|
|
54
|
+
existsSync as existsSync2,
|
|
55
|
+
mkdtempSync,
|
|
56
|
+
readFileSync,
|
|
57
|
+
rmSync,
|
|
58
|
+
writeFileSync
|
|
59
|
+
} from "fs";
|
|
60
|
+
import { tmpdir } from "os";
|
|
61
|
+
import { join as join2 } from "path";
|
|
62
|
+
|
|
63
|
+
// src/adapters/soda/dataSourceType.ts
|
|
64
|
+
var dataSourceType = (url) => {
|
|
65
|
+
const scheme = url.split(":")[0] ?? "";
|
|
66
|
+
if (scheme === "postgres" || scheme === "postgresql") return "postgres";
|
|
67
|
+
if (scheme === "mysql") return "mysql";
|
|
68
|
+
throw new ConfigError(
|
|
69
|
+
`Soda supports postgres and mysql URLs, not "${scheme}"`
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// src/config/isDisabled.ts
|
|
74
|
+
var isDisabled = (code, disabled) => disabled.includes(code);
|
|
75
|
+
|
|
76
|
+
// src/model/fingerprintFinding.ts
|
|
77
|
+
import { createHash } from "crypto";
|
|
78
|
+
var fingerprintFinding = (finding, context) => createHash("sha256").update(["1", finding.code, finding.path, context].join("|")).digest("hex").slice(0, 16);
|
|
79
|
+
|
|
80
|
+
// src/adapters/soda/parseSodaResults.ts
|
|
81
|
+
var parseSodaResults = (json, sodaDir, disabled) => {
|
|
82
|
+
const { checks } = JSON.parse(json);
|
|
83
|
+
return checks.flatMap((check) => {
|
|
84
|
+
if (check.outcome !== "fail" && check.outcome !== "warn") return [];
|
|
85
|
+
const code = `BDB700/${check.name}`;
|
|
86
|
+
if (isDisabled(code, disabled)) return [];
|
|
87
|
+
const partial = {
|
|
88
|
+
code,
|
|
89
|
+
severity: check.outcome === "fail" ? "error" : "warn",
|
|
90
|
+
path: `${sodaDir}/checks.yml`,
|
|
91
|
+
line: 0,
|
|
92
|
+
message: `check ${check.outcome === "fail" ? "failed" : "warned"}: ${check.name}`,
|
|
93
|
+
subject: [check.table, check.column].filter(Boolean).join(".")
|
|
94
|
+
};
|
|
95
|
+
return [
|
|
96
|
+
{ ...partial, fingerprint: fingerprintFinding(partial, check.name) }
|
|
97
|
+
];
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// src/adapters/soda/renderSodaConfiguration.ts
|
|
102
|
+
var renderSodaConfiguration = (name, url) => {
|
|
103
|
+
const type = dataSourceType(url);
|
|
104
|
+
const parsed = new URL(url);
|
|
105
|
+
const lines = [
|
|
106
|
+
`data_source ${name}:`,
|
|
107
|
+
` type: ${type}`,
|
|
108
|
+
` host: ${parsed.hostname}`,
|
|
109
|
+
` port: ${parsed.port || (type === "postgres" ? "5432" : "3306")}`,
|
|
110
|
+
` username: ${decodeURIComponent(parsed.username)}`,
|
|
111
|
+
` password: ${JSON.stringify(decodeURIComponent(parsed.password))}`,
|
|
112
|
+
` database: ${parsed.pathname.slice(1)}`,
|
|
113
|
+
...type === "postgres" ? [" schema: public"] : []
|
|
114
|
+
];
|
|
115
|
+
return `${lines.join("\n")}
|
|
116
|
+
`;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// src/tools/ToolMissingError.ts
|
|
120
|
+
var ToolMissingError = class extends Error {
|
|
121
|
+
constructor(tool, hint) {
|
|
122
|
+
super(`${tool} is not installed or not on PATH; ${hint}`);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// src/adapters/soda/runSoda.ts
|
|
127
|
+
var runSoda = (runner, root, soda, disabled) => {
|
|
128
|
+
const scratch = mkdtempSync(join2(tmpdir(), "codeality-db-soda-"));
|
|
129
|
+
try {
|
|
130
|
+
const configuration = join2(scratch, "configuration.yml");
|
|
131
|
+
const results = join2(scratch, "results.json");
|
|
132
|
+
writeFileSync(configuration, renderSodaConfiguration("codeality", soda.url));
|
|
133
|
+
const result = runner(
|
|
134
|
+
"uvx",
|
|
135
|
+
[
|
|
136
|
+
"--with",
|
|
137
|
+
"setuptools",
|
|
138
|
+
"--from",
|
|
139
|
+
`soda-core-${dataSourceType(soda.url)}`,
|
|
140
|
+
"soda",
|
|
141
|
+
"scan",
|
|
142
|
+
"-d",
|
|
143
|
+
"codeality",
|
|
144
|
+
"-c",
|
|
145
|
+
configuration,
|
|
146
|
+
"-srf",
|
|
147
|
+
results,
|
|
148
|
+
`${soda.dir}/checks.yml`
|
|
149
|
+
],
|
|
150
|
+
{ cwd: root, env: { SETUPTOOLS_USE_DISTUTILS: "local" } }
|
|
151
|
+
);
|
|
152
|
+
if (result.missing) throw new ToolMissingError("uvx", "install uv");
|
|
153
|
+
if (!existsSync2(results)) {
|
|
154
|
+
const tail = result.stderr.trim().slice(-500);
|
|
155
|
+
throw new Error(
|
|
156
|
+
`soda scan produced no results (exit ${String(result.status)}): ${tail}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return parseSodaResults(readFileSync(results, "utf8"), soda.dir, disabled);
|
|
160
|
+
} finally {
|
|
161
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// src/adapters/supabase/ADVISOR_SEVERITY.ts
|
|
166
|
+
var ADVISOR_SEVERITY = {
|
|
167
|
+
ERROR: "error",
|
|
168
|
+
WARN: "warn",
|
|
169
|
+
INFO: "info"
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// src/adapters/supabase/parseAdvisorReport.ts
|
|
173
|
+
var parseAdvisorReport = (stdout2, disabled) => {
|
|
174
|
+
const { results } = JSON.parse(stdout2);
|
|
175
|
+
return results.flatMap((entry) => {
|
|
176
|
+
const code = `BDB500/${entry.name}`;
|
|
177
|
+
if (isDisabled(code, disabled)) return [];
|
|
178
|
+
const subject = entry.metadata?.schema && entry.metadata.name ? `${entry.metadata.schema}.${entry.metadata.name}` : "";
|
|
179
|
+
const partial = {
|
|
180
|
+
code,
|
|
181
|
+
severity: ADVISOR_SEVERITY[entry.level],
|
|
182
|
+
path: "supabase",
|
|
183
|
+
line: 0,
|
|
184
|
+
message: entry.detail.replaceAll("\\`", "`"),
|
|
185
|
+
subject
|
|
186
|
+
};
|
|
187
|
+
return [
|
|
188
|
+
{ ...partial, fingerprint: fingerprintFinding(partial, entry.detail) }
|
|
189
|
+
];
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// src/adapters/supabase/parseCliError.ts
|
|
194
|
+
var parseCliError = (text) => {
|
|
195
|
+
const start = text.indexOf('{"_tag":"Error"');
|
|
196
|
+
if (start === -1) return void 0;
|
|
197
|
+
for (let end = text.indexOf("}", start); end !== -1; end = text.indexOf("}", end + 1)) {
|
|
198
|
+
try {
|
|
199
|
+
const { error } = JSON.parse(text.slice(start, end + 1));
|
|
200
|
+
return { code: error.code, message: error.message };
|
|
201
|
+
} catch {
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { code: "unknown", message: text.slice(start, start + 200) };
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// src/adapters/supabase/supabaseJson.ts
|
|
208
|
+
var supabaseJson = (runner, root, args, label) => {
|
|
209
|
+
const result = runner("supabase", args, { cwd: root });
|
|
210
|
+
if (result.missing)
|
|
211
|
+
throw new ToolMissingError("supabase", "install the Supabase CLI");
|
|
212
|
+
const failure = parseCliError(`${result.stdout}
|
|
213
|
+
${result.stderr}`);
|
|
214
|
+
if (failure || result.status !== 0) {
|
|
215
|
+
const code = failure?.code ?? String(result.status);
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${label} failed: ${code}: ${failure?.message ?? result.stderr.trim()}`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return result.stdout.slice(result.stdout.indexOf("{"));
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// src/audit/targetArgs.ts
|
|
224
|
+
var targetArgs = (target) => "dbUrl" in target ? ["--db-url", target.dbUrl] : ["--linked"];
|
|
225
|
+
|
|
226
|
+
// src/adapters/supabase/runAdvisors.ts
|
|
227
|
+
var runAdvisors = (runner, root, target, disabled) => parseAdvisorReport(
|
|
228
|
+
supabaseJson(
|
|
229
|
+
runner,
|
|
230
|
+
root,
|
|
231
|
+
[
|
|
232
|
+
"db",
|
|
233
|
+
"advisors",
|
|
234
|
+
"--type",
|
|
235
|
+
"all",
|
|
236
|
+
"--output-format",
|
|
237
|
+
"json",
|
|
238
|
+
...targetArgs(target)
|
|
239
|
+
],
|
|
240
|
+
"supabase advisors"
|
|
241
|
+
),
|
|
242
|
+
disabled
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// src/adapters/supabase/parseBloat.ts
|
|
246
|
+
var parseBloat = (stdout2, threshold, disabled) => {
|
|
247
|
+
if (isDisabled("BDB602", disabled)) return [];
|
|
248
|
+
const { rows } = JSON.parse(stdout2);
|
|
249
|
+
return rows.filter((row) => Number(row.bloat) > threshold).map((row) => {
|
|
250
|
+
const partial = {
|
|
251
|
+
code: "BDB602",
|
|
252
|
+
severity: "warn",
|
|
253
|
+
path: "supabase",
|
|
254
|
+
line: 0,
|
|
255
|
+
message: `bloat factor ${row.bloat}, ${row.waste} wasted`,
|
|
256
|
+
subject: row.name
|
|
257
|
+
};
|
|
258
|
+
return { ...partial, fingerprint: fingerprintFinding(partial, row.name) };
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// src/adapters/supabase/parseIndexStats.ts
|
|
263
|
+
var parseIndexStats = (stdout2, disabled) => {
|
|
264
|
+
if (isDisabled("BDB601", disabled)) return [];
|
|
265
|
+
const { rows } = JSON.parse(stdout2);
|
|
266
|
+
return rows.filter(
|
|
267
|
+
(row) => (row.unused || row.index_scans === "0") && !row.name.endsWith("_pkey")
|
|
268
|
+
).map((row) => {
|
|
269
|
+
const partial = {
|
|
270
|
+
code: "BDB601",
|
|
271
|
+
severity: "info",
|
|
272
|
+
path: "supabase",
|
|
273
|
+
line: 0,
|
|
274
|
+
message: `index has never been scanned (${row.size})`,
|
|
275
|
+
subject: row.name
|
|
276
|
+
};
|
|
277
|
+
return { ...partial, fingerprint: fingerprintFinding(partial, row.name) };
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// src/adapters/supabase/runInspect.ts
|
|
282
|
+
var runInspect = (runner, root, target, config) => {
|
|
283
|
+
const query = (report) => supabaseJson(
|
|
284
|
+
runner,
|
|
285
|
+
root,
|
|
286
|
+
[
|
|
287
|
+
"inspect",
|
|
288
|
+
"db",
|
|
289
|
+
report,
|
|
290
|
+
"--output-format",
|
|
291
|
+
"json",
|
|
292
|
+
...targetArgs(target)
|
|
293
|
+
],
|
|
294
|
+
`supabase inspect ${report}`
|
|
295
|
+
);
|
|
296
|
+
return [
|
|
297
|
+
...parseIndexStats(query("index-stats"), config.disable),
|
|
298
|
+
...parseBloat(query("bloat"), config.audit.bloatThreshold, config.disable)
|
|
299
|
+
];
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// src/model/compareFindings.ts
|
|
303
|
+
var compareFindings = (a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.code.localeCompare(b.code);
|
|
304
|
+
|
|
305
|
+
// src/audit/runAudit.ts
|
|
306
|
+
var runAudit = ({
|
|
307
|
+
root,
|
|
308
|
+
config,
|
|
309
|
+
runner,
|
|
310
|
+
target
|
|
311
|
+
}) => {
|
|
312
|
+
const plan = auditPlan(config, target);
|
|
313
|
+
const findings = [];
|
|
314
|
+
if (plan.soda && "dbUrl" in target && config.audit.soda !== void 0) {
|
|
315
|
+
const soda = { dir: config.audit.soda, url: target.dbUrl };
|
|
316
|
+
findings.push(...runSoda(runner, root, soda, config.disable));
|
|
317
|
+
}
|
|
318
|
+
if (plan.supabase) {
|
|
319
|
+
findings.push(
|
|
320
|
+
...runAdvisors(runner, root, target, config.disable),
|
|
321
|
+
...runInspect(runner, root, target, config)
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return findings.sort(compareFindings);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// src/check/renderFindings.ts
|
|
328
|
+
var renderFindings = (findings) => [
|
|
329
|
+
...findings.map(
|
|
330
|
+
(f) => `${f.path}:${String(f.line)}: ${f.code} ${f.message}${f.subject ? ` (${f.subject})` : ""}`
|
|
331
|
+
),
|
|
332
|
+
`${String(findings.length)} findings`
|
|
333
|
+
].join("\n");
|
|
334
|
+
|
|
335
|
+
// src/check/renderFindingsJson.ts
|
|
336
|
+
var renderFindingsJson = (findings) => JSON.stringify({ schemaVersion: 1, findings }, null, 2);
|
|
337
|
+
|
|
338
|
+
// src/commands/parseCommandArgs.ts
|
|
339
|
+
import { parseArgs } from "util";
|
|
340
|
+
var parseCommandArgs = (argv2, options) => {
|
|
341
|
+
try {
|
|
342
|
+
const parsed = parseArgs({
|
|
343
|
+
args: argv2,
|
|
344
|
+
options,
|
|
345
|
+
allowPositionals: true,
|
|
346
|
+
strict: true
|
|
347
|
+
});
|
|
348
|
+
return { values: parsed.values, positionals: parsed.positionals };
|
|
349
|
+
} catch (error) {
|
|
350
|
+
throw new ConfigError(error.message);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// src/model/ExitCode.ts
|
|
355
|
+
var ExitCode = {
|
|
356
|
+
OK: 0,
|
|
357
|
+
FINDINGS: 1,
|
|
358
|
+
CONFIGURATION: 2,
|
|
359
|
+
INFRASTRUCTURE: 3
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// src/commands/reportCommandError.ts
|
|
363
|
+
var reportCommandError = (error, stderr2) => {
|
|
364
|
+
const configuration = error instanceof ConfigError;
|
|
365
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
366
|
+
stderr2(`${configuration ? "configuration error" : "error"}: ${message}
|
|
367
|
+
`);
|
|
368
|
+
return configuration ? ExitCode.CONFIGURATION : ExitCode.INFRASTRUCTURE;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// src/config/readConfig.ts
|
|
372
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
373
|
+
import { join as join3 } from "path";
|
|
374
|
+
|
|
375
|
+
// src/config/CONFIG_FILENAME.ts
|
|
376
|
+
var CONFIG_FILENAME = "codeality-db.json";
|
|
377
|
+
|
|
378
|
+
// src/config/expectConfig.ts
|
|
379
|
+
var expectConfig = (condition, message) => {
|
|
380
|
+
if (!condition) throw new ConfigError(message);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// src/config/auditSectionFrom.ts
|
|
384
|
+
var auditSectionFrom = (audit) => {
|
|
385
|
+
const inGate = audit?.["inGate"] ?? true;
|
|
386
|
+
const bloatThreshold = audit?.["bloatThreshold"] ?? 5;
|
|
387
|
+
const soda = audit?.["soda"];
|
|
388
|
+
expectConfig(typeof inGate === "boolean", "audit.inGate must be a boolean");
|
|
389
|
+
expectConfig(
|
|
390
|
+
typeof bloatThreshold === "number",
|
|
391
|
+
"audit.bloatThreshold must be a number"
|
|
392
|
+
);
|
|
393
|
+
expectConfig(
|
|
394
|
+
soda === void 0 || typeof soda === "string",
|
|
395
|
+
"audit.soda must be a string"
|
|
396
|
+
);
|
|
397
|
+
return {
|
|
398
|
+
inGate,
|
|
399
|
+
bloatThreshold,
|
|
400
|
+
...soda === void 0 ? {} : { soda }
|
|
401
|
+
};
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// src/config/CONFIG_KEYS.ts
|
|
405
|
+
var CONFIG_KEYS = /* @__PURE__ */ new Set([
|
|
406
|
+
"schemaVersion",
|
|
407
|
+
"supabase",
|
|
408
|
+
"prisma",
|
|
409
|
+
"drizzle",
|
|
410
|
+
"sqlite",
|
|
411
|
+
"audit",
|
|
412
|
+
"disable"
|
|
413
|
+
]);
|
|
414
|
+
|
|
415
|
+
// src/config/configSection.ts
|
|
416
|
+
var configSection = (raw, name) => {
|
|
417
|
+
const value = raw[name];
|
|
418
|
+
if (value === void 0) return void 0;
|
|
419
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
420
|
+
throw new ConfigError(`${name} must be an object`);
|
|
421
|
+
}
|
|
422
|
+
return value;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// src/config/isStringList.ts
|
|
426
|
+
var isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
427
|
+
|
|
428
|
+
// src/config/drizzleSectionFrom.ts
|
|
429
|
+
var drizzleSectionFrom = (drizzle) => {
|
|
430
|
+
const roots = drizzle["roots"];
|
|
431
|
+
const objectNames = drizzle["objectNames"] ?? ["db", "tx"];
|
|
432
|
+
if (!isStringList(roots))
|
|
433
|
+
throw new ConfigError("drizzle.roots must be a list of strings");
|
|
434
|
+
if (!isStringList(objectNames)) {
|
|
435
|
+
throw new ConfigError("drizzle.objectNames must be a list of strings");
|
|
436
|
+
}
|
|
437
|
+
return { roots, objectNames };
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// src/config/stackSectionsFrom.ts
|
|
441
|
+
var stackSectionsFrom = (raw) => {
|
|
442
|
+
const stacks = {};
|
|
443
|
+
const supabase = configSection(raw, "supabase");
|
|
444
|
+
if (supabase) {
|
|
445
|
+
const migrations = supabase["migrations"];
|
|
446
|
+
if (typeof migrations !== "string")
|
|
447
|
+
throw new ConfigError("supabase.migrations must be a string");
|
|
448
|
+
stacks.supabase = { migrations };
|
|
449
|
+
}
|
|
450
|
+
const prisma = configSection(raw, "prisma");
|
|
451
|
+
if (prisma) {
|
|
452
|
+
const schema = prisma["schema"];
|
|
453
|
+
if (typeof schema !== "string")
|
|
454
|
+
throw new ConfigError("prisma.schema must be a string");
|
|
455
|
+
stacks.prisma = { schema };
|
|
456
|
+
}
|
|
457
|
+
const drizzle = configSection(raw, "drizzle");
|
|
458
|
+
if (drizzle) stacks.drizzle = drizzleSectionFrom(drizzle);
|
|
459
|
+
const sqlite = configSection(raw, "sqlite");
|
|
460
|
+
if (sqlite) {
|
|
461
|
+
const files = sqlite["files"];
|
|
462
|
+
if (!isStringList(files))
|
|
463
|
+
throw new ConfigError("sqlite.files must be a list of strings");
|
|
464
|
+
stacks.sqlite = { files };
|
|
465
|
+
}
|
|
466
|
+
return stacks;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// src/config/configFromDocument.ts
|
|
470
|
+
var configFromDocument = (document) => {
|
|
471
|
+
if (typeof document !== "object" || document === null || Array.isArray(document)) {
|
|
472
|
+
throw new ConfigError("configuration must be an object");
|
|
473
|
+
}
|
|
474
|
+
const raw = document;
|
|
475
|
+
for (const key of Object.keys(raw)) {
|
|
476
|
+
if (!CONFIG_KEYS.has(key)) throw new ConfigError(`unknown key "${key}"`);
|
|
477
|
+
}
|
|
478
|
+
if (raw["schemaVersion"] !== 1)
|
|
479
|
+
throw new ConfigError("schemaVersion must be 1");
|
|
480
|
+
const disable = raw["disable"] ?? [];
|
|
481
|
+
if (!isStringList(disable))
|
|
482
|
+
throw new ConfigError("disable must be a list of strings");
|
|
483
|
+
return {
|
|
484
|
+
schemaVersion: 1,
|
|
485
|
+
...stackSectionsFrom(raw),
|
|
486
|
+
audit: auditSectionFrom(configSection(raw, "audit")),
|
|
487
|
+
disable
|
|
488
|
+
};
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// src/config/readConfig.ts
|
|
492
|
+
var readConfig = (root) => {
|
|
493
|
+
const path = join3(root, CONFIG_FILENAME);
|
|
494
|
+
if (!existsSync3(path)) {
|
|
495
|
+
throw new ConfigError(
|
|
496
|
+
`${CONFIG_FILENAME} not found in ${root}; run "codeality-db init"`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
let document;
|
|
500
|
+
try {
|
|
501
|
+
document = JSON.parse(readFileSync2(path, "utf8"));
|
|
502
|
+
} catch (error) {
|
|
503
|
+
throw new ConfigError(
|
|
504
|
+
`${CONFIG_FILENAME} is not valid JSON: ${error.message}`
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
return configFromDocument(document);
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// src/commands/auditCommand.ts
|
|
511
|
+
var auditCommand = (argv2, io) => {
|
|
512
|
+
try {
|
|
513
|
+
const { values } = parseCommandArgs(argv2, {
|
|
514
|
+
json: { type: "boolean" },
|
|
515
|
+
linked: { type: "boolean" },
|
|
516
|
+
"db-url": { type: "string" }
|
|
517
|
+
});
|
|
518
|
+
const config = readConfig(io.root);
|
|
519
|
+
const target = resolveAuditTarget(io.root, {
|
|
520
|
+
linked: values["linked"] === true,
|
|
521
|
+
"db-url": values["db-url"]
|
|
522
|
+
});
|
|
523
|
+
for (const notice of auditPlan(config, target).skipped)
|
|
524
|
+
io.stderr(`${notice}
|
|
525
|
+
`);
|
|
526
|
+
const findings = runAudit({
|
|
527
|
+
root: io.root,
|
|
528
|
+
config,
|
|
529
|
+
runner: io.runner,
|
|
530
|
+
target
|
|
531
|
+
});
|
|
532
|
+
io.stdout(
|
|
533
|
+
`${values["json"] === true ? renderFindingsJson(findings) : renderFindings(findings)}
|
|
534
|
+
`
|
|
535
|
+
);
|
|
536
|
+
return findings.length > 0 ? ExitCode.FINDINGS : ExitCode.OK;
|
|
537
|
+
} catch (error) {
|
|
538
|
+
return reportCommandError(error, io.stderr);
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// src/commands/baselineCommand.ts
|
|
543
|
+
import { existsSync as existsSync5 } from "fs";
|
|
544
|
+
import { join as join7 } from "path";
|
|
545
|
+
|
|
546
|
+
// src/baseline/BASELINE_FILENAME.ts
|
|
547
|
+
var BASELINE_FILENAME = ".codeality-db-baseline.json";
|
|
548
|
+
|
|
549
|
+
// src/baseline/BASELINE_USAGE.ts
|
|
550
|
+
var BASELINE_USAGE = "usage: codeality-db baseline create|update|check [--check-stale]";
|
|
551
|
+
|
|
552
|
+
// src/baseline/baselineActionFrom.ts
|
|
553
|
+
var baselineActionFrom = (positionals) => {
|
|
554
|
+
const action = positionals[0];
|
|
555
|
+
if (action === "create" || action === "update" || action === "check")
|
|
556
|
+
return action;
|
|
557
|
+
throw new ConfigError(BASELINE_USAGE);
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// src/baseline/classifyFindings.ts
|
|
561
|
+
var classifyFindings = (findings, baseline) => {
|
|
562
|
+
const entries = new Set(baseline.entries);
|
|
563
|
+
const current = new Set(findings.map((finding) => finding.fingerprint));
|
|
564
|
+
return {
|
|
565
|
+
new: findings.filter((finding) => !entries.has(finding.fingerprint)),
|
|
566
|
+
known: findings.filter((finding) => entries.has(finding.fingerprint)),
|
|
567
|
+
resolved: baseline.entries.filter((entry) => !current.has(entry)).sort()
|
|
568
|
+
};
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
// src/baseline/readBaseline.ts
|
|
572
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
573
|
+
import { join as join4 } from "path";
|
|
574
|
+
var readBaseline = (root) => {
|
|
575
|
+
const path = join4(root, BASELINE_FILENAME);
|
|
576
|
+
if (!existsSync4(path)) {
|
|
577
|
+
throw new ConfigError(
|
|
578
|
+
`${BASELINE_FILENAME} not found; run "codeality-db baseline create"`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const document = JSON.parse(
|
|
582
|
+
readFileSync3(path, "utf8")
|
|
583
|
+
);
|
|
584
|
+
if (document.schemaVersion !== 1 || !Array.isArray(document.entries)) {
|
|
585
|
+
throw new ConfigError(`${BASELINE_FILENAME} is not a version 1 baseline`);
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
schemaVersion: 1,
|
|
589
|
+
toolVersion: document.toolVersion ?? "",
|
|
590
|
+
entries: [...document.entries].sort()
|
|
591
|
+
};
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
// src/baseline/renderClassified.ts
|
|
595
|
+
var renderClassified = (classified) => {
|
|
596
|
+
const counts = `${String(classified.new.length)} new, ${String(classified.known.length)} known, ${String(classified.resolved.length)} resolved`;
|
|
597
|
+
return classified.new.length > 0 ? `${counts}
|
|
598
|
+
${renderFindings(classified.new)}` : counts;
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// src/baseline/writeBaseline.ts
|
|
602
|
+
import { renameSync, writeFileSync as writeFileSync2 } from "fs";
|
|
603
|
+
import { join as join5 } from "path";
|
|
604
|
+
var writeBaseline = (root, baseline) => {
|
|
605
|
+
const path = join5(root, BASELINE_FILENAME);
|
|
606
|
+
const document = {
|
|
607
|
+
schemaVersion: 1,
|
|
608
|
+
toolVersion: baseline.toolVersion,
|
|
609
|
+
entries: [...baseline.entries].sort()
|
|
610
|
+
};
|
|
611
|
+
writeFileSync2(`${path}.tmp`, `${JSON.stringify(document, null, 2)}
|
|
612
|
+
`);
|
|
613
|
+
renameSync(`${path}.tmp`, path);
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
// src/adapters/drizzle/parseEslintReport.ts
|
|
617
|
+
import { relative } from "path";
|
|
618
|
+
var parseEslintReport = (stdout2, root, disabled) => {
|
|
619
|
+
if (!stdout2.trim()) return [];
|
|
620
|
+
let files;
|
|
621
|
+
try {
|
|
622
|
+
files = JSON.parse(stdout2);
|
|
623
|
+
} catch {
|
|
624
|
+
throw new Error(`eslint produced no JSON report: ${stdout2.slice(0, 200)}`);
|
|
625
|
+
}
|
|
626
|
+
return files.flatMap(
|
|
627
|
+
(file) => file.messages.flatMap((message) => {
|
|
628
|
+
if (!message.ruleId?.startsWith("drizzle/")) return [];
|
|
629
|
+
const rule = message.ruleId.slice("drizzle/".length);
|
|
630
|
+
const code = `BDB300/${rule}`;
|
|
631
|
+
if (isDisabled(code, disabled)) return [];
|
|
632
|
+
const partial = {
|
|
633
|
+
code,
|
|
634
|
+
severity: "error",
|
|
635
|
+
path: relative(root, file.filePath).replaceAll("\\", "/"),
|
|
636
|
+
line: message.line,
|
|
637
|
+
message: message.message,
|
|
638
|
+
subject: rule
|
|
639
|
+
};
|
|
640
|
+
return [
|
|
641
|
+
{
|
|
642
|
+
...partial,
|
|
643
|
+
fingerprint: fingerprintFinding(partial, message.message)
|
|
644
|
+
}
|
|
645
|
+
];
|
|
646
|
+
})
|
|
647
|
+
);
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
// src/assetPath.ts
|
|
651
|
+
import { fileURLToPath } from "url";
|
|
652
|
+
var assetPath = (name) => fileURLToPath(new URL(`../assets/${name}`, import.meta.url));
|
|
653
|
+
|
|
654
|
+
// src/adapters/drizzle/runDrizzleLint.ts
|
|
655
|
+
var runDrizzleLint = (runner, root, drizzle, disabled) => {
|
|
656
|
+
const result = runner(
|
|
657
|
+
"eslint",
|
|
658
|
+
[
|
|
659
|
+
"--no-config-lookup",
|
|
660
|
+
"-c",
|
|
661
|
+
assetPath("drizzle-eslint.config.mjs"),
|
|
662
|
+
"-f",
|
|
663
|
+
"json",
|
|
664
|
+
"--pass-on-unpruned-suppressions",
|
|
665
|
+
...drizzle.roots
|
|
666
|
+
],
|
|
667
|
+
{
|
|
668
|
+
cwd: root,
|
|
669
|
+
env: { CODEALITY_DB_DRIZZLE_OBJECTS: drizzle.objectNames.join(",") }
|
|
670
|
+
}
|
|
671
|
+
);
|
|
672
|
+
if (result.missing) {
|
|
673
|
+
throw new ToolMissingError(
|
|
674
|
+
"eslint",
|
|
675
|
+
"add eslint, eslint-plugin-drizzle and typescript-eslint as devDependencies"
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
if (result.status !== 0 && result.status !== 1) {
|
|
679
|
+
throw new Error(
|
|
680
|
+
`eslint exited ${String(result.status)}: ${result.stderr.trim()}`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
return parseEslintReport(result.stdout, root, disabled);
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
// src/adapters/prisma/parsePrismaLintReport.ts
|
|
687
|
+
var parsePrismaLintReport = (stderr2, disabled) => {
|
|
688
|
+
if (!stderr2.trim()) return [];
|
|
689
|
+
let violations;
|
|
690
|
+
try {
|
|
691
|
+
violations = JSON.parse(stderr2).violations;
|
|
692
|
+
} catch {
|
|
693
|
+
throw new Error(
|
|
694
|
+
`prisma-lint produced no JSON report: ${stderr2.slice(0, 200)}`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
return violations.flatMap((violation) => {
|
|
698
|
+
const code = `BDB200/${violation.ruleName}`;
|
|
699
|
+
if (isDisabled(code, disabled)) return [];
|
|
700
|
+
const partial = {
|
|
701
|
+
code,
|
|
702
|
+
severity: "warn",
|
|
703
|
+
path: violation.fileName,
|
|
704
|
+
line: violation.location.startLine,
|
|
705
|
+
message: violation.message,
|
|
706
|
+
subject: /Field "([^"]+)"/.exec(violation.message)?.[1] ?? ""
|
|
707
|
+
};
|
|
708
|
+
return [
|
|
709
|
+
{
|
|
710
|
+
...partial,
|
|
711
|
+
fingerprint: fingerprintFinding(partial, violation.message)
|
|
712
|
+
}
|
|
713
|
+
];
|
|
714
|
+
});
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// src/adapters/prisma/runPrismaLint.ts
|
|
718
|
+
var runPrismaLint = (runner, root, schemaPath, disabled) => {
|
|
719
|
+
const result = runner(
|
|
720
|
+
"prisma-lint",
|
|
721
|
+
["-c", assetPath("prisma-lint.json"), "-o", "json", schemaPath],
|
|
722
|
+
{ cwd: root }
|
|
723
|
+
);
|
|
724
|
+
if (result.missing) {
|
|
725
|
+
throw new ToolMissingError(
|
|
726
|
+
"prisma-lint",
|
|
727
|
+
"add prisma-lint as a devDependency"
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
if (result.status !== 0 && result.status !== 1) {
|
|
731
|
+
throw new Error(
|
|
732
|
+
`prisma-lint exited ${String(result.status)}: ${result.stderr.trim()}`
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
return parsePrismaLintReport(result.stderr, disabled);
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
// src/adapters/sqlite/sqliteFinding.ts
|
|
739
|
+
var sqliteFinding = (input, disabled) => {
|
|
740
|
+
if (isDisabled(input.code, disabled)) return [];
|
|
741
|
+
const partial = {
|
|
742
|
+
code: input.code,
|
|
743
|
+
severity: input.severity,
|
|
744
|
+
path: input.file,
|
|
745
|
+
line: 0,
|
|
746
|
+
message: input.message,
|
|
747
|
+
subject: input.subject
|
|
748
|
+
};
|
|
749
|
+
return [
|
|
750
|
+
{ ...partial, fingerprint: fingerprintFinding(partial, input.context) }
|
|
751
|
+
];
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
// src/adapters/sqlite/sqliteQuery.ts
|
|
755
|
+
var sqliteQuery = (runner, root, file, sql) => {
|
|
756
|
+
const result = runner("sqlite3", ["-json", "-readonly", file, sql], {
|
|
757
|
+
cwd: root
|
|
758
|
+
});
|
|
759
|
+
if (result.missing) {
|
|
760
|
+
throw new ToolMissingError(
|
|
761
|
+
"sqlite3",
|
|
762
|
+
"install the sqlite3 command line shell"
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
if (result.status !== 0)
|
|
766
|
+
throw new Error(`sqlite3 failed on ${file}: ${result.stderr.trim()}`);
|
|
767
|
+
return result.stdout;
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
// src/adapters/sqlite/sqliteRows.ts
|
|
771
|
+
var sqliteRows = (runner, root, file, sql) => {
|
|
772
|
+
const out = sqliteQuery(runner, root, file, sql).trim();
|
|
773
|
+
return out ? JSON.parse(out) : [];
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
// src/adapters/sqlite/foreignKeyFindings.ts
|
|
777
|
+
var foreignKeyFindings = (runner, root, file, disabled) => sqliteRows(
|
|
778
|
+
runner,
|
|
779
|
+
root,
|
|
780
|
+
file,
|
|
781
|
+
"PRAGMA foreign_key_check"
|
|
782
|
+
).flatMap(
|
|
783
|
+
(row) => sqliteFinding(
|
|
784
|
+
{
|
|
785
|
+
code: "BDB402",
|
|
786
|
+
severity: "error",
|
|
787
|
+
file,
|
|
788
|
+
subject: row.table,
|
|
789
|
+
message: `row ${String(row.rowid)} references missing ${row.parent}`,
|
|
790
|
+
context: `${row.table}:${String(row.rowid)}:${row.parent}`
|
|
791
|
+
},
|
|
792
|
+
disabled
|
|
793
|
+
)
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
// src/adapters/sqlite/integrityFindings.ts
|
|
797
|
+
var integrityFindings = (runner, root, file, disabled) => {
|
|
798
|
+
const rows = sqliteRows(
|
|
799
|
+
runner,
|
|
800
|
+
root,
|
|
801
|
+
file,
|
|
802
|
+
"PRAGMA integrity_check"
|
|
803
|
+
);
|
|
804
|
+
const first = rows[0]?.integrity_check ?? "no result";
|
|
805
|
+
if (rows.length === 1 && first === "ok") return [];
|
|
806
|
+
return sqliteFinding(
|
|
807
|
+
{
|
|
808
|
+
code: "BDB401",
|
|
809
|
+
severity: "error",
|
|
810
|
+
file,
|
|
811
|
+
subject: "",
|
|
812
|
+
message: `integrity_check: ${first}`,
|
|
813
|
+
context: first
|
|
814
|
+
},
|
|
815
|
+
disabled
|
|
816
|
+
);
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
// src/adapters/sqlite/hasPrimaryKey.ts
|
|
820
|
+
var hasPrimaryKey = (createSql) => {
|
|
821
|
+
const text = createSql.toLowerCase();
|
|
822
|
+
return text.startsWith("create virtual table") || text.includes("primary key");
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
// src/adapters/sqlite/primaryKeyFindings.ts
|
|
826
|
+
var primaryKeyFindings = (runner, root, file, disabled) => sqliteRows(
|
|
827
|
+
runner,
|
|
828
|
+
root,
|
|
829
|
+
file,
|
|
830
|
+
"select name, sql from sqlite_master where type = 'table' and name not like 'sqlite_%'"
|
|
831
|
+
).flatMap(
|
|
832
|
+
(row) => hasPrimaryKey(row.sql) ? [] : sqliteFinding(
|
|
833
|
+
{
|
|
834
|
+
code: "BDB403",
|
|
835
|
+
severity: "warn",
|
|
836
|
+
file,
|
|
837
|
+
subject: row.name,
|
|
838
|
+
message: "table has no primary key",
|
|
839
|
+
context: row.name
|
|
840
|
+
},
|
|
841
|
+
disabled
|
|
842
|
+
)
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
// src/adapters/sqlite/runSqliteChecks.ts
|
|
846
|
+
var runSqliteChecks = (runner, root, files, disabled) => files.flatMap((file) => [
|
|
847
|
+
...integrityFindings(runner, root, file, disabled),
|
|
848
|
+
...foreignKeyFindings(runner, root, file, disabled),
|
|
849
|
+
...primaryKeyFindings(runner, root, file, disabled)
|
|
850
|
+
]);
|
|
851
|
+
|
|
852
|
+
// src/model/normalizeSqlText.ts
|
|
853
|
+
var normalizeSqlText = (text) => text.toLowerCase().replaceAll(/\s+/g, " ").trim();
|
|
854
|
+
|
|
855
|
+
// src/sql/statementAtLine.ts
|
|
856
|
+
var statementAtLine = (file, line) => [...file.statements].reverse().find((statement) => statement.line <= line);
|
|
857
|
+
|
|
858
|
+
// src/adapters/squawk/parseSquawkReport.ts
|
|
859
|
+
var parseSquawkReport = (stdout2, set, disabled) => {
|
|
860
|
+
if (!stdout2.trim()) return [];
|
|
861
|
+
let entries;
|
|
862
|
+
try {
|
|
863
|
+
entries = JSON.parse(stdout2);
|
|
864
|
+
} catch {
|
|
865
|
+
throw new Error(`squawk produced no JSON report: ${stdout2.slice(0, 200)}`);
|
|
866
|
+
}
|
|
867
|
+
return entries.flatMap((entry) => {
|
|
868
|
+
const code = `BDB100/${entry.rule_name}`;
|
|
869
|
+
if (isDisabled(code, disabled)) return [];
|
|
870
|
+
const file = set.find((candidate) => candidate.path === entry.file);
|
|
871
|
+
const statement = file ? statementAtLine(file, entry.line) : void 0;
|
|
872
|
+
const partial = {
|
|
873
|
+
code,
|
|
874
|
+
severity: entry.level === "Error" ? "error" : "warn",
|
|
875
|
+
path: entry.file,
|
|
876
|
+
line: entry.line,
|
|
877
|
+
message: entry.message,
|
|
878
|
+
subject: entry.rule_name
|
|
879
|
+
};
|
|
880
|
+
const context = statement ? normalizeSqlText(statement.text) : entry.message;
|
|
881
|
+
return [{ ...partial, fingerprint: fingerprintFinding(partial, context) }];
|
|
882
|
+
});
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// src/adapters/squawk/SUPABASE_SQUAWK_EXCLUDES.ts
|
|
886
|
+
var SUPABASE_SQUAWK_EXCLUDES = [
|
|
887
|
+
"prefer-robust-stmts",
|
|
888
|
+
"require-lock-timeout",
|
|
889
|
+
"require-statement-timeout",
|
|
890
|
+
"require-concurrent-index-creation"
|
|
891
|
+
];
|
|
892
|
+
|
|
893
|
+
// src/adapters/squawk/runSquawk.ts
|
|
894
|
+
var runSquawk = (runner, root, set, disabled) => {
|
|
895
|
+
const result = runner(
|
|
896
|
+
"squawk",
|
|
897
|
+
[
|
|
898
|
+
"--reporter",
|
|
899
|
+
"json",
|
|
900
|
+
"--exclude",
|
|
901
|
+
SUPABASE_SQUAWK_EXCLUDES.join(","),
|
|
902
|
+
...set.map((f) => f.path)
|
|
903
|
+
],
|
|
904
|
+
{ cwd: root }
|
|
905
|
+
);
|
|
906
|
+
if (result.missing)
|
|
907
|
+
throw new ToolMissingError("squawk", "add squawk-cli as a devDependency");
|
|
908
|
+
if (result.status !== 0 && result.status !== 1) {
|
|
909
|
+
throw new Error(
|
|
910
|
+
`squawk exited ${String(result.status)}: ${result.stderr.trim()}`
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
return parseSquawkReport(result.stdout, set, disabled);
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
// src/rules/makeSqlFinding.ts
|
|
917
|
+
var makeSqlFinding = (rule, { file, statement }, subject, message) => {
|
|
918
|
+
const partial = {
|
|
919
|
+
code: rule.code,
|
|
920
|
+
severity: rule.severity,
|
|
921
|
+
path: file.path,
|
|
922
|
+
line: statement.line,
|
|
923
|
+
message,
|
|
924
|
+
subject
|
|
925
|
+
};
|
|
926
|
+
return {
|
|
927
|
+
...partial,
|
|
928
|
+
fingerprint: fingerprintFinding(partial, normalizeSqlText(statement.text))
|
|
929
|
+
};
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
// src/sql/qualifiedName.ts
|
|
933
|
+
var qualifiedName = (raw) => {
|
|
934
|
+
const parts = raw.trim().split(".").map((part) => part.replaceAll('"', "").toLowerCase());
|
|
935
|
+
return parts.length === 1 ? `public.${parts[0] ?? ""}` : parts.join(".");
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
// src/rules/policyTable.ts
|
|
939
|
+
var policyTable = (statement) => {
|
|
940
|
+
const match = /^create policy .+? on ((?:"[^"]+"|[\w$]+)(?:\.(?:"[^"]+"|[\w$]+))?)/.exec(
|
|
941
|
+
normalizeSqlText(statement.text)
|
|
942
|
+
);
|
|
943
|
+
return match?.[1] === void 0 ? void 0 : qualifiedName(match[1]);
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
// src/rules/authUidNotWrapped.ts
|
|
947
|
+
var authUidNotWrapped = {
|
|
948
|
+
code: "BDB004",
|
|
949
|
+
name: "auth-uid-not-wrapped",
|
|
950
|
+
severity: "warn",
|
|
951
|
+
run: (set) => set.flatMap(
|
|
952
|
+
(file) => file.statements.filter((statement) => {
|
|
953
|
+
const text = normalizeSqlText(statement.text);
|
|
954
|
+
return text.startsWith("create policy") && /(?<!\(select )auth\.(?:uid|jwt)\(\)/.test(text);
|
|
955
|
+
}).map(
|
|
956
|
+
(statement) => makeSqlFinding(
|
|
957
|
+
authUidNotWrapped,
|
|
958
|
+
{ file, statement },
|
|
959
|
+
policyTable(statement) ?? "",
|
|
960
|
+
"auth.uid() or auth.jwt() is re-evaluated per row; wrap it as (select auth.uid()) so Postgres caches it as an initplan"
|
|
961
|
+
)
|
|
962
|
+
)
|
|
963
|
+
)
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
// src/rules/definerWithoutSearchPath.ts
|
|
967
|
+
var definerWithoutSearchPath = {
|
|
968
|
+
code: "BDB005",
|
|
969
|
+
name: "definer-without-search-path",
|
|
970
|
+
severity: "warn",
|
|
971
|
+
run: (set) => set.flatMap(
|
|
972
|
+
(file) => file.statements.flatMap((statement) => {
|
|
973
|
+
const text = normalizeSqlText(statement.text);
|
|
974
|
+
const head = /^create (?:or replace )?(?:function|procedure) ((?:"[^"]+"|[\w$]+)(?:\.(?:"[^"]+"|[\w$]+))?)\s*\(/.exec(
|
|
975
|
+
text
|
|
976
|
+
);
|
|
977
|
+
if (!head?.[1] || !text.includes(" security definer") || text.includes(" set search_path")) {
|
|
978
|
+
return [];
|
|
979
|
+
}
|
|
980
|
+
return [
|
|
981
|
+
makeSqlFinding(
|
|
982
|
+
definerWithoutSearchPath,
|
|
983
|
+
{ file, statement },
|
|
984
|
+
qualifiedName(head[1]),
|
|
985
|
+
'SECURITY DEFINER function without "set search_path": a caller can shadow the objects it touches'
|
|
986
|
+
)
|
|
987
|
+
];
|
|
988
|
+
})
|
|
989
|
+
)
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
// src/rules/permissivePolicy.ts
|
|
993
|
+
var permissivePolicy = {
|
|
994
|
+
code: "BDB001",
|
|
995
|
+
name: "permissive-policy",
|
|
996
|
+
severity: "warn",
|
|
997
|
+
run: (set) => set.flatMap(
|
|
998
|
+
(file) => file.statements.filter((statement) => {
|
|
999
|
+
const text = normalizeSqlText(statement.text);
|
|
1000
|
+
return text.startsWith("create policy") && /(?:using|with check) \( ?true ?\)/.test(text);
|
|
1001
|
+
}).map(
|
|
1002
|
+
(statement) => makeSqlFinding(
|
|
1003
|
+
permissivePolicy,
|
|
1004
|
+
{ file, statement },
|
|
1005
|
+
policyTable(statement) ?? "",
|
|
1006
|
+
"policy is permissive: using (true) or with check (true) lets every row through for the granted role"
|
|
1007
|
+
)
|
|
1008
|
+
)
|
|
1009
|
+
)
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
// src/rules/policyTables.ts
|
|
1013
|
+
var policyTables = (set) => {
|
|
1014
|
+
const tables = /* @__PURE__ */ new Set();
|
|
1015
|
+
for (const file of set)
|
|
1016
|
+
for (const statement of file.statements) {
|
|
1017
|
+
const table = policyTable(statement);
|
|
1018
|
+
if (table) tables.add(table);
|
|
1019
|
+
}
|
|
1020
|
+
return tables;
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
// src/rules/matchedTables.ts
|
|
1024
|
+
var matchedTables = (set, pattern) => {
|
|
1025
|
+
const tables = /* @__PURE__ */ new Map();
|
|
1026
|
+
for (const file of set)
|
|
1027
|
+
for (const statement of file.statements) {
|
|
1028
|
+
const match = pattern.exec(normalizeSqlText(statement.text));
|
|
1029
|
+
if (!match?.[1]) continue;
|
|
1030
|
+
const name = qualifiedName(match[1]);
|
|
1031
|
+
if (!tables.has(name)) tables.set(name, { file, statement });
|
|
1032
|
+
}
|
|
1033
|
+
return tables;
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
// src/rules/rlsEnabledTables.ts
|
|
1037
|
+
var rlsEnabledTables = (set) => matchedTables(
|
|
1038
|
+
set,
|
|
1039
|
+
/^alter table (?:if exists )?(?:only )?((?:"[^"]+"|[\w$]+)(?:\.(?:"[^"]+"|[\w$]+))?) enable row level security/
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
// src/rules/rlsEnabledNoPolicy.ts
|
|
1043
|
+
var rlsEnabledNoPolicy = {
|
|
1044
|
+
code: "BDB002",
|
|
1045
|
+
name: "rls-enabled-no-policy",
|
|
1046
|
+
severity: "info",
|
|
1047
|
+
run: (set) => {
|
|
1048
|
+
const withPolicy = policyTables(set);
|
|
1049
|
+
return [...rlsEnabledTables(set)].filter(([table]) => !withPolicy.has(table)).map(
|
|
1050
|
+
([table, ref]) => makeSqlFinding(
|
|
1051
|
+
rlsEnabledNoPolicy,
|
|
1052
|
+
ref,
|
|
1053
|
+
table,
|
|
1054
|
+
"RLS is enabled but no migration defines a policy: only the service role can reach this table"
|
|
1055
|
+
)
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
// src/rules/createdTables.ts
|
|
1061
|
+
var createdTables = (set) => new Map(
|
|
1062
|
+
[
|
|
1063
|
+
...matchedTables(
|
|
1064
|
+
set,
|
|
1065
|
+
/^create (?:unlogged |temp(?:orary)? )?table (?:if not exists )?((?:"[^"]+"|[\w$]+)(?:\.(?:"[^"]+"|[\w$]+))?)/
|
|
1066
|
+
)
|
|
1067
|
+
].filter(([name]) => name.startsWith("public."))
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
// src/rules/tableWithoutRls.ts
|
|
1071
|
+
var tableWithoutRls = {
|
|
1072
|
+
code: "BDB003",
|
|
1073
|
+
name: "table-without-rls",
|
|
1074
|
+
severity: "warn",
|
|
1075
|
+
run: (set) => {
|
|
1076
|
+
const enabled = rlsEnabledTables(set);
|
|
1077
|
+
return [...createdTables(set)].filter(([table]) => !enabled.has(table)).map(
|
|
1078
|
+
([table, ref]) => makeSqlFinding(
|
|
1079
|
+
tableWithoutRls,
|
|
1080
|
+
ref,
|
|
1081
|
+
table,
|
|
1082
|
+
"table in public never enables row level security: it is readable through the Data API by anyone holding the anon key"
|
|
1083
|
+
)
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
// src/rules/SQL_RULES.ts
|
|
1089
|
+
var SQL_RULES = [
|
|
1090
|
+
permissivePolicy,
|
|
1091
|
+
rlsEnabledNoPolicy,
|
|
1092
|
+
tableWithoutRls,
|
|
1093
|
+
authUidNotWrapped,
|
|
1094
|
+
definerWithoutSearchPath
|
|
1095
|
+
];
|
|
1096
|
+
|
|
1097
|
+
// src/rules/runSqlRules.ts
|
|
1098
|
+
var runSqlRules = (set, disabled) => SQL_RULES.filter((rule) => !isDisabled(rule.code, disabled)).flatMap((rule) => rule.run(set)).sort(compareFindings);
|
|
1099
|
+
|
|
1100
|
+
// src/sql/readMigrationSet.ts
|
|
1101
|
+
import { readdirSync, readFileSync as readFileSync4 } from "fs";
|
|
1102
|
+
import { join as join6 } from "path";
|
|
1103
|
+
|
|
1104
|
+
// src/sql/quotedSpanEnd.ts
|
|
1105
|
+
var quotedSpanEnd = (sql, index) => {
|
|
1106
|
+
const dollar = /^\$[a-z_]*\$/i.exec(sql.slice(index, index + 64));
|
|
1107
|
+
if (dollar) {
|
|
1108
|
+
const end2 = sql.indexOf(dollar[0], index + dollar[0].length);
|
|
1109
|
+
return end2 === -1 ? sql.length : end2 + dollar[0].length;
|
|
1110
|
+
}
|
|
1111
|
+
const quote = sql[index];
|
|
1112
|
+
if (quote !== "'" && quote !== '"') return void 0;
|
|
1113
|
+
const end = sql.indexOf(quote, index + 1);
|
|
1114
|
+
return end === -1 ? sql.length : end + 1;
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
// src/sql/stripSqlComments.ts
|
|
1118
|
+
var stripSqlComments = (sql) => {
|
|
1119
|
+
let out = "";
|
|
1120
|
+
let index = 0;
|
|
1121
|
+
while (index < sql.length) {
|
|
1122
|
+
const quoted = quotedSpanEnd(sql, index);
|
|
1123
|
+
if (quoted !== void 0) {
|
|
1124
|
+
out += sql.slice(index, quoted);
|
|
1125
|
+
index = quoted;
|
|
1126
|
+
} else if (sql.startsWith("--", index)) {
|
|
1127
|
+
const end = sql.indexOf("\n", index);
|
|
1128
|
+
index = end === -1 ? sql.length : end;
|
|
1129
|
+
} else if (sql.startsWith("/*", index)) {
|
|
1130
|
+
const end = sql.indexOf("*/", index + 2);
|
|
1131
|
+
const stop = end === -1 ? sql.length : end + 2;
|
|
1132
|
+
out += sql.slice(index, stop).replaceAll(/[^\n]/g, "");
|
|
1133
|
+
index = stop;
|
|
1134
|
+
} else {
|
|
1135
|
+
out += sql.charAt(index);
|
|
1136
|
+
index += 1;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return out;
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
// src/sql/splitSqlStatements.ts
|
|
1143
|
+
var splitSqlStatements = (sql) => {
|
|
1144
|
+
const text = stripSqlComments(sql);
|
|
1145
|
+
const statements = [];
|
|
1146
|
+
let start = 0;
|
|
1147
|
+
const flush = (end) => {
|
|
1148
|
+
const raw = text.slice(start, end);
|
|
1149
|
+
const leading = raw.length - raw.trimStart().length;
|
|
1150
|
+
const body = raw.trim();
|
|
1151
|
+
if (body) {
|
|
1152
|
+
const line = 1 + (text.slice(0, start + leading).match(/\n/g)?.length ?? 0);
|
|
1153
|
+
statements.push({ text: body, line });
|
|
1154
|
+
}
|
|
1155
|
+
start = end + 1;
|
|
1156
|
+
};
|
|
1157
|
+
let index = 0;
|
|
1158
|
+
while (index < text.length) {
|
|
1159
|
+
const quoted = quotedSpanEnd(text, index);
|
|
1160
|
+
if (quoted !== void 0) index = quoted;
|
|
1161
|
+
else {
|
|
1162
|
+
if (text[index] === ";") flush(index);
|
|
1163
|
+
index += 1;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
flush(text.length);
|
|
1167
|
+
return statements;
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
// src/sql/readMigrationSet.ts
|
|
1171
|
+
var readMigrationSet = (root, migrationsDir) => readdirSync(join6(root, migrationsDir)).filter((name) => name.endsWith(".sql")).sort().map((name) => ({
|
|
1172
|
+
path: `${migrationsDir.replaceAll("\\", "/")}/${name}`,
|
|
1173
|
+
statements: splitSqlStatements(
|
|
1174
|
+
readFileSync4(join6(root, migrationsDir, name), "utf8")
|
|
1175
|
+
)
|
|
1176
|
+
}));
|
|
1177
|
+
|
|
1178
|
+
// src/check/runCheck.ts
|
|
1179
|
+
var runCheck = ({ root, config, runner }) => {
|
|
1180
|
+
const findings = [];
|
|
1181
|
+
if (config.supabase) {
|
|
1182
|
+
const set = readMigrationSet(root, config.supabase.migrations);
|
|
1183
|
+
findings.push(
|
|
1184
|
+
...runSqlRules(set, config.disable),
|
|
1185
|
+
...runSquawk(runner, root, set, config.disable)
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
if (config.prisma) {
|
|
1189
|
+
findings.push(
|
|
1190
|
+
...runPrismaLint(runner, root, config.prisma.schema, config.disable)
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
if (config.drizzle)
|
|
1194
|
+
findings.push(
|
|
1195
|
+
...runDrizzleLint(runner, root, config.drizzle, config.disable)
|
|
1196
|
+
);
|
|
1197
|
+
if (config.sqlite) {
|
|
1198
|
+
findings.push(
|
|
1199
|
+
...runSqliteChecks(runner, root, config.sqlite.files, config.disable)
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
return findings.sort(compareFindings);
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
// src/packageVersion.ts
|
|
1206
|
+
import { createRequire } from "module";
|
|
1207
|
+
var PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
1208
|
+
|
|
1209
|
+
// src/commands/baselineCommand.ts
|
|
1210
|
+
var baselineCommand = (argv2, io) => {
|
|
1211
|
+
try {
|
|
1212
|
+
const { values, positionals } = parseCommandArgs(argv2, {
|
|
1213
|
+
"check-stale": { type: "boolean" }
|
|
1214
|
+
});
|
|
1215
|
+
const action = baselineActionFrom(positionals);
|
|
1216
|
+
if (action === "create" && existsSync5(join7(io.root, BASELINE_FILENAME))) {
|
|
1217
|
+
throw new ConfigError(
|
|
1218
|
+
`${BASELINE_FILENAME} exists; use "baseline update" to rewrite it`
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
const findings = runCheck({
|
|
1222
|
+
root: io.root,
|
|
1223
|
+
config: readConfig(io.root),
|
|
1224
|
+
runner: io.runner
|
|
1225
|
+
});
|
|
1226
|
+
if (action !== "check") {
|
|
1227
|
+
const entries = findings.map((f) => f.fingerprint);
|
|
1228
|
+
writeBaseline(io.root, {
|
|
1229
|
+
schemaVersion: 1,
|
|
1230
|
+
toolVersion: PACKAGE_VERSION,
|
|
1231
|
+
entries
|
|
1232
|
+
});
|
|
1233
|
+
io.stdout(
|
|
1234
|
+
`recorded ${String(findings.length)} findings in ${BASELINE_FILENAME}
|
|
1235
|
+
`
|
|
1236
|
+
);
|
|
1237
|
+
return ExitCode.OK;
|
|
1238
|
+
}
|
|
1239
|
+
const classified = classifyFindings(findings, readBaseline(io.root));
|
|
1240
|
+
io.stdout(`${renderClassified(classified)}
|
|
1241
|
+
`);
|
|
1242
|
+
const stale = values["check-stale"] === true && classified.resolved.length > 0;
|
|
1243
|
+
return classified.new.length > 0 || stale ? ExitCode.FINDINGS : ExitCode.OK;
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
return reportCommandError(error, io.stderr);
|
|
1246
|
+
}
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
// src/commands/checkCommand.ts
|
|
1250
|
+
var checkCommand = (argv2, io) => {
|
|
1251
|
+
try {
|
|
1252
|
+
const { values } = parseCommandArgs(argv2, { json: { type: "boolean" } });
|
|
1253
|
+
const findings = runCheck({
|
|
1254
|
+
root: io.root,
|
|
1255
|
+
config: readConfig(io.root),
|
|
1256
|
+
runner: io.runner
|
|
1257
|
+
});
|
|
1258
|
+
io.stdout(
|
|
1259
|
+
`${values["json"] === true ? renderFindingsJson(findings) : renderFindings(findings)}
|
|
1260
|
+
`
|
|
1261
|
+
);
|
|
1262
|
+
return findings.length > 0 ? ExitCode.FINDINGS : ExitCode.OK;
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
return reportCommandError(error, io.stderr);
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
// src/gate/gateExitCode.ts
|
|
1269
|
+
var gateExitCode = (results) => {
|
|
1270
|
+
if (results.some((result) => result.status === "failed-to-run"))
|
|
1271
|
+
return ExitCode.INFRASTRUCTURE;
|
|
1272
|
+
if (results.some((result) => result.status === "findings"))
|
|
1273
|
+
return ExitCode.FINDINGS;
|
|
1274
|
+
return ExitCode.OK;
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
// src/gate/gateStages.ts
|
|
1278
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1279
|
+
import { join as join8 } from "path";
|
|
1280
|
+
var gateStages = (context) => {
|
|
1281
|
+
const hasBaseline = existsSync6(join8(context.root, BASELINE_FILENAME));
|
|
1282
|
+
const linked = existsSync6(join8(context.root, "supabase/.temp/project-ref"));
|
|
1283
|
+
return [
|
|
1284
|
+
hasBaseline ? {
|
|
1285
|
+
name: "baseline-check",
|
|
1286
|
+
run: () => classifyFindings(runCheck(context), readBaseline(context.root)).new
|
|
1287
|
+
} : { name: "check", run: () => runCheck(context) },
|
|
1288
|
+
{
|
|
1289
|
+
name: "audit",
|
|
1290
|
+
run: () => context.config.audit.inGate && linked ? runAudit({ ...context, target: { linked: true } }) : "not-applicable"
|
|
1291
|
+
}
|
|
1292
|
+
];
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
// src/gate/renderGateReport.ts
|
|
1296
|
+
var renderGateReport = (results) => results.flatMap((result) => [
|
|
1297
|
+
`${result.status.padStart(22)} ${result.name.padEnd(14)}${result.durationSeconds.toFixed(2).padStart(8)}s`,
|
|
1298
|
+
...result.status === "passed" || result.status === "skipped-not-applicable" || !result.detail ? [] : result.detail.split("\n").map((line) => ` ${line}`)
|
|
1299
|
+
]).join("\n");
|
|
1300
|
+
|
|
1301
|
+
// src/gate/runStage.ts
|
|
1302
|
+
import { performance } from "perf_hooks";
|
|
1303
|
+
var runStage = (stage) => {
|
|
1304
|
+
const started = performance.now();
|
|
1305
|
+
const done = (status, detail) => ({
|
|
1306
|
+
name: stage.name,
|
|
1307
|
+
status,
|
|
1308
|
+
durationSeconds: (performance.now() - started) / 1e3,
|
|
1309
|
+
detail
|
|
1310
|
+
});
|
|
1311
|
+
try {
|
|
1312
|
+
const outcome = stage.run();
|
|
1313
|
+
if (outcome === "not-applicable") return done("skipped-not-applicable", "");
|
|
1314
|
+
return outcome.length === 0 ? done("passed", "") : done("findings", renderFindings(outcome));
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
return done(
|
|
1317
|
+
"failed-to-run",
|
|
1318
|
+
error instanceof Error ? error.message : String(error)
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
// src/gate/runGate.ts
|
|
1324
|
+
var runGate = (stages) => stages.map(runStage);
|
|
1325
|
+
|
|
1326
|
+
// src/commands/gateCommand.ts
|
|
1327
|
+
var gateCommand = (argv2, io) => {
|
|
1328
|
+
try {
|
|
1329
|
+
const { values } = parseCommandArgs(argv2, { json: { type: "boolean" } });
|
|
1330
|
+
const results = runGate(
|
|
1331
|
+
gateStages({
|
|
1332
|
+
root: io.root,
|
|
1333
|
+
config: readConfig(io.root),
|
|
1334
|
+
runner: io.runner
|
|
1335
|
+
})
|
|
1336
|
+
);
|
|
1337
|
+
const report = values["json"] === true ? JSON.stringify({ schemaVersion: 1, stages: results }, null, 2) : renderGateReport(results);
|
|
1338
|
+
io.stdout(`${report}
|
|
1339
|
+
`);
|
|
1340
|
+
return gateExitCode(results);
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
return reportCommandError(error, io.stderr);
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
// src/init/applyInit.ts
|
|
1347
|
+
import { mkdirSync, writeFileSync as writeFileSync3 } from "fs";
|
|
1348
|
+
import { dirname, join as join9 } from "path";
|
|
1349
|
+
var applyInit = (root, plan) => {
|
|
1350
|
+
for (const file of plan) {
|
|
1351
|
+
const writes = file.disposition === "create" || file.disposition === "merge";
|
|
1352
|
+
if (!writes || file.content === void 0) continue;
|
|
1353
|
+
mkdirSync(dirname(join9(root, file.path)), { recursive: true });
|
|
1354
|
+
writeFileSync3(join9(root, file.path), file.content);
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
// src/init/planConfigFile.ts
|
|
1359
|
+
import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
|
|
1360
|
+
import { join as join12 } from "path";
|
|
1361
|
+
|
|
1362
|
+
// src/config/detectStacks.ts
|
|
1363
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1364
|
+
import { join as join11 } from "path";
|
|
1365
|
+
|
|
1366
|
+
// src/config/findSqliteFiles.ts
|
|
1367
|
+
import { readdirSync as readdirSync2 } from "fs";
|
|
1368
|
+
import { join as join10, relative as relative2 } from "path";
|
|
1369
|
+
var findSqliteFiles = (root) => {
|
|
1370
|
+
const skipped = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage"]);
|
|
1371
|
+
const found = [];
|
|
1372
|
+
const walk = (directory, depth) => {
|
|
1373
|
+
for (const entry of readdirSync2(directory, { withFileTypes: true })) {
|
|
1374
|
+
if (skipped.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
1375
|
+
const path = join10(directory, entry.name);
|
|
1376
|
+
if (entry.isDirectory() && depth < 2) walk(path, depth + 1);
|
|
1377
|
+
else if (entry.isFile() && /\.(?:db|sqlite|sqlite3)$/.test(entry.name)) {
|
|
1378
|
+
found.push(relative2(root, path).replaceAll("\\", "/"));
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
walk(root, 0);
|
|
1383
|
+
return found.sort();
|
|
1384
|
+
};
|
|
1385
|
+
|
|
1386
|
+
// src/config/isDirectory.ts
|
|
1387
|
+
import { existsSync as existsSync7, statSync } from "fs";
|
|
1388
|
+
var isDirectory = (path) => existsSync7(path) && statSync(path).isDirectory();
|
|
1389
|
+
|
|
1390
|
+
// src/config/detectStacks.ts
|
|
1391
|
+
var detectStacks = (root) => {
|
|
1392
|
+
const sqliteFiles = findSqliteFiles(root);
|
|
1393
|
+
const hasDrizzle = ["ts", "js", "mjs"].some(
|
|
1394
|
+
(ext) => existsSync8(join11(root, `drizzle.config.${ext}`))
|
|
1395
|
+
);
|
|
1396
|
+
const drizzleRoots = ["src", "server", "app", "lib", "db"].filter(
|
|
1397
|
+
(name) => isDirectory(join11(root, name))
|
|
1398
|
+
);
|
|
1399
|
+
return {
|
|
1400
|
+
...isDirectory(join11(root, "supabase/migrations")) ? { supabase: { migrations: "supabase/migrations" } } : {},
|
|
1401
|
+
...existsSync8(join11(root, "prisma/schema.prisma")) ? { prisma: { schema: "prisma/schema.prisma" } } : {},
|
|
1402
|
+
...hasDrizzle ? { drizzle: { roots: drizzleRoots, objectNames: ["db", "tx"] } } : {},
|
|
1403
|
+
...sqliteFiles.length > 0 ? { sqlite: { files: sqliteFiles } } : {}
|
|
1404
|
+
};
|
|
1405
|
+
};
|
|
1406
|
+
|
|
1407
|
+
// src/init/planConfigFile.ts
|
|
1408
|
+
var planConfigFile = (root, force) => {
|
|
1409
|
+
const content = `${JSON.stringify({ schemaVersion: 1, ...detectStacks(root) }, null, 2)}
|
|
1410
|
+
`;
|
|
1411
|
+
const path = join12(root, CONFIG_FILENAME);
|
|
1412
|
+
if (!existsSync9(path)) {
|
|
1413
|
+
return {
|
|
1414
|
+
path: CONFIG_FILENAME,
|
|
1415
|
+
disposition: "create",
|
|
1416
|
+
detail: "from the detected stacks",
|
|
1417
|
+
content
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
try {
|
|
1421
|
+
configFromDocument(JSON.parse(readFileSync5(path, "utf8")));
|
|
1422
|
+
return { path: CONFIG_FILENAME, disposition: "unchanged", detail: "valid" };
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
return force ? {
|
|
1425
|
+
path: CONFIG_FILENAME,
|
|
1426
|
+
disposition: "merge",
|
|
1427
|
+
detail: "replaced (--force)",
|
|
1428
|
+
content
|
|
1429
|
+
} : {
|
|
1430
|
+
path: CONFIG_FILENAME,
|
|
1431
|
+
disposition: "conflict",
|
|
1432
|
+
detail: error.message
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
// src/init/planPackageScript.ts
|
|
1438
|
+
import { existsSync as existsSync10, readFileSync as readFileSync6 } from "fs";
|
|
1439
|
+
import { join as join13 } from "path";
|
|
1440
|
+
|
|
1441
|
+
// src/init/PACKAGE_MANIFEST.ts
|
|
1442
|
+
var PACKAGE_MANIFEST = "package.json";
|
|
1443
|
+
|
|
1444
|
+
// src/init/planPackageScript.ts
|
|
1445
|
+
var planPackageScript = (root, force) => {
|
|
1446
|
+
const path = join13(root, PACKAGE_MANIFEST);
|
|
1447
|
+
if (!existsSync10(path)) {
|
|
1448
|
+
return {
|
|
1449
|
+
path: PACKAGE_MANIFEST,
|
|
1450
|
+
disposition: "unchanged",
|
|
1451
|
+
detail: "no package.json"
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
const manifest = JSON.parse(readFileSync6(path, "utf8"));
|
|
1455
|
+
const current = manifest.scripts?.["db:gate"];
|
|
1456
|
+
if (current === "codeality-db gate") {
|
|
1457
|
+
return {
|
|
1458
|
+
path: PACKAGE_MANIFEST,
|
|
1459
|
+
disposition: "unchanged",
|
|
1460
|
+
detail: "db:gate present"
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
if (current !== void 0 && !force) {
|
|
1464
|
+
return {
|
|
1465
|
+
path: PACKAGE_MANIFEST,
|
|
1466
|
+
disposition: "conflict",
|
|
1467
|
+
detail: `db:gate is "${current}"`
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
const scripts = { ...manifest.scripts, "db:gate": "codeality-db gate" };
|
|
1471
|
+
const content = `${JSON.stringify({ ...manifest, scripts }, null, 2)}
|
|
1472
|
+
`;
|
|
1473
|
+
return {
|
|
1474
|
+
path: PACKAGE_MANIFEST,
|
|
1475
|
+
disposition: "merge",
|
|
1476
|
+
detail: "add scripts.db:gate",
|
|
1477
|
+
content
|
|
1478
|
+
};
|
|
1479
|
+
};
|
|
1480
|
+
|
|
1481
|
+
// src/init/planWorkflow.ts
|
|
1482
|
+
import { existsSync as existsSync11, readFileSync as readFileSync7 } from "fs";
|
|
1483
|
+
import { join as join14 } from "path";
|
|
1484
|
+
var planWorkflow = (root, force) => {
|
|
1485
|
+
const relative3 = ".github/workflows/db-quality.yml";
|
|
1486
|
+
const content = readFileSync7(assetPath("db-quality.yml"), "utf8");
|
|
1487
|
+
const path = join14(root, relative3);
|
|
1488
|
+
if (!existsSync11(path)) {
|
|
1489
|
+
const detail = "runs codeality-db gate on push and pull request";
|
|
1490
|
+
return { path: relative3, disposition: "create", detail, content };
|
|
1491
|
+
}
|
|
1492
|
+
if (readFileSync7(path, "utf8") === content) {
|
|
1493
|
+
return { path: relative3, disposition: "unchanged", detail: "identical" };
|
|
1494
|
+
}
|
|
1495
|
+
return force ? {
|
|
1496
|
+
path: relative3,
|
|
1497
|
+
disposition: "merge",
|
|
1498
|
+
detail: "replaced (--force)",
|
|
1499
|
+
content
|
|
1500
|
+
} : {
|
|
1501
|
+
path: relative3,
|
|
1502
|
+
disposition: "conflict",
|
|
1503
|
+
detail: "differs from the shipped workflow"
|
|
1504
|
+
};
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
// src/init/planInit.ts
|
|
1508
|
+
var planInit = (root, force) => [
|
|
1509
|
+
planConfigFile(root, force),
|
|
1510
|
+
planPackageScript(root, force),
|
|
1511
|
+
planWorkflow(root, force)
|
|
1512
|
+
];
|
|
1513
|
+
|
|
1514
|
+
// src/init/renderInitPlan.ts
|
|
1515
|
+
var renderInitPlan = (plan) => plan.map(
|
|
1516
|
+
(file) => `${file.disposition.padEnd(9)} ${file.path} ${file.detail}`
|
|
1517
|
+
).join("\n");
|
|
1518
|
+
|
|
1519
|
+
// src/commands/initCommand.ts
|
|
1520
|
+
var initCommand = (argv2, io) => {
|
|
1521
|
+
try {
|
|
1522
|
+
const { values } = parseCommandArgs(argv2, {
|
|
1523
|
+
check: { type: "boolean" },
|
|
1524
|
+
apply: { type: "boolean" },
|
|
1525
|
+
force: { type: "boolean" }
|
|
1526
|
+
});
|
|
1527
|
+
const force = values["force"] === true;
|
|
1528
|
+
const plan = planInit(io.root, force);
|
|
1529
|
+
io.stdout(`${renderInitPlan(plan)}
|
|
1530
|
+
`);
|
|
1531
|
+
if (values["check"] === true) {
|
|
1532
|
+
return plan.every((file) => file.disposition === "unchanged") ? ExitCode.OK : ExitCode.FINDINGS;
|
|
1533
|
+
}
|
|
1534
|
+
if (values["apply"] !== true && !force) return ExitCode.OK;
|
|
1535
|
+
if (plan.some((file) => file.disposition === "conflict")) {
|
|
1536
|
+
throw new ConfigError("conflicts remain; resolve them or pass --force");
|
|
1537
|
+
}
|
|
1538
|
+
applyInit(io.root, plan);
|
|
1539
|
+
return ExitCode.OK;
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
return reportCommandError(error, io.stderr);
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
|
|
1545
|
+
// src/cli/COMMANDS.ts
|
|
1546
|
+
var COMMANDS = {
|
|
1547
|
+
init: initCommand,
|
|
1548
|
+
check: checkCommand,
|
|
1549
|
+
audit: auditCommand,
|
|
1550
|
+
gate: gateCommand,
|
|
1551
|
+
baseline: baselineCommand
|
|
1552
|
+
};
|
|
1553
|
+
|
|
1554
|
+
// src/cli/USAGE.ts
|
|
1555
|
+
var USAGE = `usage: codeality-db [--project <dir>] <command> [options]
|
|
1556
|
+
|
|
1557
|
+
init [--check|--apply|--force] detect stacks, write codeality-db.json, the db:gate script and the CI workflow
|
|
1558
|
+
check [--json] static findings from the repository alone
|
|
1559
|
+
audit --linked|--db-url <url> [--json] findings from the live database
|
|
1560
|
+
gate [--json] check (or baseline check) and the linked audit, one exit code
|
|
1561
|
+
baseline create|update|check [--check-stale] record, refresh or enforce the debt the project carries
|
|
1562
|
+
|
|
1563
|
+
exit codes: 0 passed, 1 findings, 2 invalid usage or configuration, 3 required tool missing or unusable
|
|
1564
|
+
`;
|
|
1565
|
+
|
|
1566
|
+
// src/cli/runCli.ts
|
|
1567
|
+
var runCli = (argv2, io) => {
|
|
1568
|
+
const args = [...argv2];
|
|
1569
|
+
if (args.includes("--version")) {
|
|
1570
|
+
io.stdout(`codeality-db ${PACKAGE_VERSION}
|
|
1571
|
+
`);
|
|
1572
|
+
return ExitCode.OK;
|
|
1573
|
+
}
|
|
1574
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
1575
|
+
io.stdout(USAGE);
|
|
1576
|
+
return ExitCode.OK;
|
|
1577
|
+
}
|
|
1578
|
+
let root = io.root;
|
|
1579
|
+
if (args[0] === "--project") {
|
|
1580
|
+
root = args[1] ?? "";
|
|
1581
|
+
args.splice(0, 2);
|
|
1582
|
+
}
|
|
1583
|
+
const [command, ...rest] = args;
|
|
1584
|
+
const handler = command === void 0 ? void 0 : COMMANDS[command];
|
|
1585
|
+
if (!handler) {
|
|
1586
|
+
io.stderr(USAGE);
|
|
1587
|
+
return ExitCode.CONFIGURATION;
|
|
1588
|
+
}
|
|
1589
|
+
return handler(rest, { ...io, root });
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1592
|
+
// src/tools/spawnRunner.ts
|
|
1593
|
+
import { spawnSync } from "child_process";
|
|
1594
|
+
import { delimiter, join as join15 } from "path";
|
|
1595
|
+
import { env as processEnv } from "process";
|
|
1596
|
+
|
|
1597
|
+
// src/tools/packageBinPath.ts
|
|
1598
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1599
|
+
var packageBinPath = () => fileURLToPath2(new URL("../node_modules/.bin", import.meta.url));
|
|
1600
|
+
|
|
1601
|
+
// src/tools/spawnRunner.ts
|
|
1602
|
+
var spawnRunner = (command, args, options) => {
|
|
1603
|
+
const path = [
|
|
1604
|
+
join15(options.cwd, "node_modules/.bin"),
|
|
1605
|
+
packageBinPath(),
|
|
1606
|
+
processEnv["PATH"] ?? ""
|
|
1607
|
+
].join(delimiter);
|
|
1608
|
+
const child = spawnSync(command, args, {
|
|
1609
|
+
cwd: options.cwd,
|
|
1610
|
+
encoding: "utf8",
|
|
1611
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1612
|
+
env: { ...processEnv, ...options.env, PATH: path }
|
|
1613
|
+
});
|
|
1614
|
+
const output = child;
|
|
1615
|
+
return {
|
|
1616
|
+
status: child.status ?? -1,
|
|
1617
|
+
stdout: output.stdout ?? "",
|
|
1618
|
+
stderr: output.stderr ?? "",
|
|
1619
|
+
missing: child.error?.code === "ENOENT"
|
|
1620
|
+
};
|
|
1621
|
+
};
|
|
1622
|
+
|
|
1623
|
+
// src/cli.ts
|
|
1624
|
+
exit(
|
|
1625
|
+
runCli(argv.slice(2), {
|
|
1626
|
+
root: cwd(),
|
|
1627
|
+
runner: spawnRunner,
|
|
1628
|
+
stdout: (text) => {
|
|
1629
|
+
stdout.write(text);
|
|
1630
|
+
},
|
|
1631
|
+
stderr: (text) => {
|
|
1632
|
+
stderr.write(text);
|
|
1633
|
+
}
|
|
1634
|
+
})
|
|
1635
|
+
);
|
|
1636
|
+
//# sourceMappingURL=cli.js.map
|