@cosmicdrift/kumiko-guards 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.
Files changed (47) hide show
  1. package/LICENSE +57 -0
  2. package/README.md +16 -0
  3. package/package.json +40 -0
  4. package/src/_lib/baseline-compare.ts +56 -0
  5. package/src/_lib/generic-reason.ts +39 -0
  6. package/src/_lib/guard-kit.ts +534 -0
  7. package/src/_lib/handler-name-forms.ts +29 -0
  8. package/src/_lib/ignore-tag.ts +24 -0
  9. package/src/_lib/primitives-access.ts +19 -0
  10. package/src/_lib/roots.ts +304 -0
  11. package/src/_lib/scan-lines.ts +25 -0
  12. package/src/_lib/scan-scope.ts +152 -0
  13. package/src/_lib/security-baseline-cli.ts +54 -0
  14. package/src/_lib/security-baseline.ts +325 -0
  15. package/src/_lib/sql-inventory.ts +267 -0
  16. package/src/guard-access-denied-test.ts +135 -0
  17. package/src/guard-admin-api.ts +134 -0
  18. package/src/guard-cross-feature-imports.ts +244 -0
  19. package/src/guard-direct-entity-writes.ts +387 -0
  20. package/src/guard-direct-fetch.ts +154 -0
  21. package/src/guard-escape-hatch-declared.ts +520 -0
  22. package/src/guard-fake-tests.ts +137 -0
  23. package/src/guard-html-escape.ts +345 -0
  24. package/src/guard-no-custom-primitives.ts +196 -0
  25. package/src/guard-no-date-api.ts +186 -0
  26. package/src/guard-no-direct-fs.ts +232 -0
  27. package/src/guard-no-direct-process-env.ts +126 -0
  28. package/src/guard-no-inline-styles.ts +58 -0
  29. package/src/guard-no-logic-in-views.ts +147 -0
  30. package/src/guard-no-raw-hooks.ts +76 -0
  31. package/src/guard-open-to-all-reason.ts +112 -0
  32. package/src/guard-pre-es-patterns.ts +199 -0
  33. package/src/guard-primitives-discipline.ts +330 -0
  34. package/src/guard-raw-classname.ts +111 -0
  35. package/src/guard-raw-interactive-elements.ts +154 -0
  36. package/src/guard-raw-sql.ts +89 -0
  37. package/src/guard-renderer-boundaries.ts +157 -0
  38. package/src/guard-restricted-symbols.ts +138 -0
  39. package/src/guard-silent-skip.ts +186 -0
  40. package/src/guard-tailwind-scan-surface.ts +588 -0
  41. package/src/guard-tenant-escalation.ts +312 -0
  42. package/src/guard-thin-wrappers.ts +422 -0
  43. package/src/guard-unsafe-json-parse.ts +86 -0
  44. package/src/index.ts +29 -0
  45. package/src/run-guards.ts +78 -0
  46. package/src/run-repo-checks.ts +22 -0
  47. package/src/run-ui-guards.ts +25 -0
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Fail-closed per-repo baseline for `security: true` guards: missing or
3
+ * unparseable baseline file means zero tolerance — every finding blocks.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { compareToBaseline, findRepoRootFor } from "./baseline-compare";
8
+ import type { GuardViolation } from "./guard-kit";
9
+
10
+ export const SECURITY_BASELINE_FORMAT = 1;
11
+ export const SECURITY_BASELINE_FILE = ".kumiko-security-baseline.json";
12
+
13
+ export type SecurityBaseline = {
14
+ readonly format: number;
15
+ readonly repo: string;
16
+ readonly generated: string;
17
+ readonly total: number;
18
+ /** Guard names "fertig migriert" for this repo: no baseline tolerance, every finding blocks. */
19
+ readonly hardFail?: readonly string[];
20
+ /** guardName -> repo-relative path -> frozen finding count. */
21
+ readonly findings: Readonly<Record<string, Readonly<Record<string, number>>>>;
22
+ };
23
+
24
+ export type SecurityBaselineLoad =
25
+ | {
26
+ readonly kind: "ok";
27
+ readonly findings: SecurityBaseline["findings"];
28
+ readonly hardFail: readonly string[];
29
+ }
30
+ | { readonly kind: "invalid"; readonly file: string; readonly reason: string };
31
+
32
+ // Compared only against the baseline file's `repo` field — a scoped npm
33
+ // package name (`@x/y`) is no longer a path segment, so it needs no
34
+ // path-traversal-safe validation, just a sanity check on shape.
35
+ const REPO_NAME_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
36
+
37
+ export function securityBaselinePath(repoDir: string): string {
38
+ return join(repoDir, SECURITY_BASELINE_FILE);
39
+ }
40
+
41
+ function isNonNegativeInteger(value: unknown): value is number {
42
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
43
+ }
44
+
45
+ function isStringArrayNoDuplicates(value: unknown): value is string[] {
46
+ if (!Array.isArray(value)) return false;
47
+ if (!value.every((entry): entry is string => typeof entry === "string")) return false;
48
+ return new Set(value).size === value.length;
49
+ }
50
+
51
+ function isFindingsShape(value: unknown): value is SecurityBaseline["findings"] {
52
+ if (typeof value !== "object" || value === null) return false;
53
+ return Object.values(value).every((perFile) => {
54
+ if (typeof perFile !== "object" || perFile === null) return false;
55
+ return Object.values(perFile).every(isNonNegativeInteger);
56
+ });
57
+ }
58
+
59
+ export function parseSecurityBaseline(raw: unknown): SecurityBaseline | undefined {
60
+ if (typeof raw !== "object" || raw === null) return undefined;
61
+ if (!("format" in raw) || raw.format !== SECURITY_BASELINE_FORMAT) return undefined;
62
+ if (!("repo" in raw) || typeof raw.repo !== "string") return undefined;
63
+ if (!("generated" in raw) || typeof raw.generated !== "string") return undefined;
64
+ if (!("total" in raw) || typeof raw.total !== "number") return undefined;
65
+ if (!("findings" in raw) || !isFindingsShape(raw.findings)) return undefined;
66
+
67
+ let hardFail: readonly string[] | undefined;
68
+ if ("hardFail" in raw && raw.hardFail !== undefined) {
69
+ if (!isStringArrayNoDuplicates(raw.hardFail)) return undefined;
70
+ for (const guardName of raw.hardFail) {
71
+ const perFile = raw.findings[guardName];
72
+ // {} is allowed (guard has no current findings); any entry means it isn't done migrating yet.
73
+ if (perFile && Object.keys(perFile).length > 0) return undefined;
74
+ }
75
+ hardFail = raw.hardFail;
76
+ }
77
+
78
+ return {
79
+ format: raw.format,
80
+ repo: raw.repo,
81
+ generated: raw.generated,
82
+ total: raw.total,
83
+ hardFail,
84
+ findings: raw.findings,
85
+ };
86
+ }
87
+
88
+ export function loadSecurityBaseline(repo: string, repoDir: string): SecurityBaselineLoad {
89
+ if (!REPO_NAME_RE.test(repo)) {
90
+ throw new Error(`loadSecurityBaseline: invalid repo name "${repo}"`);
91
+ }
92
+ const file = securityBaselinePath(repoDir);
93
+ if (!existsSync(file)) return { kind: "ok", findings: {}, hardFail: [] };
94
+ let raw: unknown;
95
+ try {
96
+ raw = JSON.parse(readFileSync(file, "utf-8"));
97
+ } catch (e) {
98
+ return {
99
+ kind: "invalid",
100
+ file,
101
+ reason: `kaputtes JSON: ${e instanceof Error ? e.message : String(e)}`,
102
+ };
103
+ }
104
+ const parsed = parseSecurityBaseline(raw);
105
+ if (!parsed) {
106
+ return { kind: "invalid", file, reason: "unerwartetes Baseline-Format (format/repo/findings)" };
107
+ }
108
+ if (parsed.repo !== repo) {
109
+ return {
110
+ kind: "invalid",
111
+ file,
112
+ reason: `repo-Feld "${parsed.repo}" passt nicht zum Dateinamen "${repo}"`,
113
+ };
114
+ }
115
+ return { kind: "ok", findings: parsed.findings, hardFail: parsed.hardFail ?? [] };
116
+ }
117
+
118
+ export function locateFinding(
119
+ file: string,
120
+ roots: readonly { readonly name: string; readonly absPath: string }[],
121
+ cwd: string,
122
+ ): { readonly repo: string; readonly relPath: string } | undefined {
123
+ const abs = isAbsolute(file) ? file : resolve(cwd, file);
124
+ // Pseudo files like "<scan>" must stay unlocatable — existsSync guards
125
+ // against resolve() placing them under cwd.
126
+ if (!existsSync(abs)) return undefined;
127
+ const root = findRepoRootFor(abs, roots);
128
+ if (!root) return undefined;
129
+ return { repo: root.name, relPath: relative(root.absPath, abs).split(sep).join("/") };
130
+ }
131
+
132
+ export function applySecurityBaseline(args: {
133
+ readonly guardName: string;
134
+ readonly violations: readonly GuardViolation[];
135
+ readonly roots: readonly { readonly name: string; readonly absPath: string }[];
136
+ readonly cwd: string;
137
+ readonly load: (repo: string) => SecurityBaselineLoad;
138
+ /** Also fail on baseline headroom (current < baseline) across every root, not just repos with current findings. */
139
+ readonly strict?: boolean;
140
+ }): { readonly blocking: GuardViolation[]; readonly frozen: number; readonly reduced: number } {
141
+ const { guardName, violations, roots, cwd, load, strict } = args;
142
+
143
+ type Located = {
144
+ readonly relPath: string;
145
+ readonly index: number;
146
+ readonly violation: GuardViolation;
147
+ };
148
+ const byRepo = new Map<string, Map<string, Located[]>>();
149
+ // (sortKey, violation) so the final blocking list preserves the original
150
+ // violation order even after synthetic baseline-file entries are spliced in.
151
+ const blockingEntries: Array<[number, GuardViolation]> = [];
152
+
153
+ violations.forEach((violation, index) => {
154
+ if (violation.neverFrozen) {
155
+ blockingEntries.push([index, violation]);
156
+ return;
157
+ }
158
+ const located = locateFinding(violation.file, roots, cwd);
159
+ if (!located) {
160
+ blockingEntries.push([index, violation]);
161
+ return;
162
+ }
163
+ let perRepo = byRepo.get(located.repo);
164
+ if (!perRepo) {
165
+ perRepo = new Map();
166
+ byRepo.set(located.repo, perRepo);
167
+ }
168
+ const bucket = perRepo.get(located.relPath) ?? [];
169
+ bucket.push({ relPath: located.relPath, index, violation });
170
+ perRepo.set(located.relPath, bucket);
171
+ });
172
+
173
+ const loadCache = new Map<string, SecurityBaselineLoad>();
174
+ const loadOnce = (repo: string): SecurityBaselineLoad => {
175
+ const cached = loadCache.get(repo);
176
+ if (cached) return cached;
177
+ const result = load(repo);
178
+ loadCache.set(repo, result);
179
+ return result;
180
+ };
181
+
182
+ let frozen = 0;
183
+ let reduced = 0;
184
+
185
+ for (const [repo, byPath] of byRepo) {
186
+ const baseline = loadOnce(repo);
187
+ const allEntries = [...byPath.values()].flat();
188
+ if (baseline.kind === "invalid") {
189
+ const minIndex = Math.min(...allEntries.map((e) => e.index));
190
+ blockingEntries.push([
191
+ minIndex - 0.5,
192
+ {
193
+ file: baseline.file,
194
+ line: 1,
195
+ message: `Security-Baseline unlesbar oder ungültig: ${baseline.reason}. Datei reparieren; ein kaputter Baseline-Stand darf keine Funde freigeben.`,
196
+ },
197
+ ]);
198
+ for (const e of allEntries) blockingEntries.push([e.index, e.violation]);
199
+ continue;
200
+ }
201
+ if (baseline.hardFail.includes(guardName)) {
202
+ for (const e of allEntries) {
203
+ blockingEntries.push([
204
+ e.index,
205
+ {
206
+ ...e.violation,
207
+ message: `${e.violation.message} (Security-Baseline ${repo}: ${guardName} ist fertig migriert — keine Baseline-Toleranz)`,
208
+ },
209
+ ]);
210
+ }
211
+ continue;
212
+ }
213
+ const current: Record<string, number> = {};
214
+ for (const [relPath, entries] of byPath) current[relPath] = entries.length;
215
+ const baselineForGuard = baseline.findings[guardName] ?? {};
216
+ const { regressions, reduced: repoReduced } = compareToBaseline(current, baselineForGuard);
217
+ reduced += repoReduced;
218
+ const regressionByPath = new Map(regressions.map((r) => [r.file, r]));
219
+ for (const [relPath, entries] of byPath) {
220
+ const regression = regressionByPath.get(relPath);
221
+ if (regression) {
222
+ for (const e of entries) {
223
+ blockingEntries.push([
224
+ e.index,
225
+ {
226
+ ...e.violation,
227
+ message: `${e.violation.message} (Security-Baseline ${repo}: erlaubt=${regression.baseline}, aktuell=${regression.current})`,
228
+ },
229
+ ]);
230
+ }
231
+ } else {
232
+ frozen += entries.length;
233
+ }
234
+ }
235
+ }
236
+
237
+ if (strict === true) {
238
+ let strictIndex = 0;
239
+ for (const root of roots) {
240
+ const baseline = loadOnce(root.name);
241
+ const byPath = byRepo.get(root.name);
242
+ if (baseline.kind === "invalid") {
243
+ // Repos with current findings already got this entry in the loop above.
244
+ if (byPath) continue;
245
+ blockingEntries.push([
246
+ violations.length + strictIndex++,
247
+ {
248
+ file: baseline.file,
249
+ line: 1,
250
+ message: `Security-Baseline unlesbar oder ungültig: ${baseline.reason}. Datei reparieren; ein kaputter Baseline-Stand darf keine Funde freigeben.`,
251
+ },
252
+ ]);
253
+ continue;
254
+ }
255
+ const current: Record<string, number> = {};
256
+ if (byPath) {
257
+ for (const [relPath, entries] of byPath) current[relPath] = entries.length;
258
+ }
259
+ const baselineForGuard = baseline.findings[guardName] ?? {};
260
+ const { reductions } = compareToBaseline(current, baselineForGuard);
261
+ if (!byPath) {
262
+ reduced += reductions.reduce((sum, r) => sum + (r.baseline - r.current), 0);
263
+ }
264
+ for (const r of reductions) {
265
+ blockingEntries.push([
266
+ violations.length + strictIndex++,
267
+ {
268
+ file: join(root.absPath, r.file),
269
+ line: 1,
270
+ message: `Security-Baseline veraltet: ${root.name}/${r.file} erlaubt ${r.baseline}, gefunden ${r.current} — \`--write-security-baseline\` ausführen und committen, sonst deckt der Headroom neue Funde.`,
271
+ },
272
+ ]);
273
+ }
274
+ }
275
+ }
276
+
277
+ blockingEntries.sort((a, b) => a[0] - b[0]);
278
+ return { blocking: blockingEntries.map(([, v]) => v), frozen, reduced };
279
+ }
280
+
281
+ export function buildSecurityBaseline(
282
+ repo: string,
283
+ guardViolations: ReadonlyArray<{
284
+ readonly guardName: string;
285
+ readonly violations: readonly GuardViolation[];
286
+ }>,
287
+ roots: readonly { readonly name: string; readonly absPath: string }[],
288
+ cwd: string,
289
+ hardFail: readonly string[] = [],
290
+ ): SecurityBaseline {
291
+ const findings: Record<string, Record<string, number>> = {};
292
+ let total = 0;
293
+ for (const { guardName, violations } of guardViolations) {
294
+ // hardFail guards are "fertig migriert" — nothing of theirs freezes into headroom.
295
+ if (hardFail.includes(guardName)) continue;
296
+ const perFile: Record<string, number> = {};
297
+ for (const v of violations) {
298
+ if (v.neverFrozen) continue;
299
+ const located = locateFinding(v.file, roots, cwd);
300
+ if (!located || located.repo !== repo) continue;
301
+ perFile[located.relPath] = (perFile[located.relPath] ?? 0) + 1;
302
+ total++;
303
+ }
304
+ if (Object.keys(perFile).length === 0) continue;
305
+ findings[guardName] = Object.fromEntries(
306
+ Object.entries(perFile).sort(([a], [b]) => a.localeCompare(b)),
307
+ );
308
+ }
309
+ const sortedHardFail = [...hardFail].sort();
310
+ return {
311
+ format: SECURITY_BASELINE_FORMAT,
312
+ repo,
313
+ generated: new Date().toISOString().slice(0, 10),
314
+ total,
315
+ ...(sortedHardFail.length > 0 ? { hardFail: sortedHardFail } : {}),
316
+ findings: Object.fromEntries(Object.entries(findings).sort(([a], [b]) => a.localeCompare(b))),
317
+ };
318
+ }
319
+
320
+ export function writeSecurityBaseline(baseline: SecurityBaseline, repoDir: string): string {
321
+ mkdirSync(repoDir, { recursive: true });
322
+ const path = securityBaselinePath(repoDir);
323
+ writeFileSync(path, `${JSON.stringify(baseline, null, 2)}\n`);
324
+ return path;
325
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Raw-SQL inventory — shared allowlist + scanner for `guard-raw-sql`.
3
+ * Scans TypeScript sources for escape-hatch patterns (`.unsafe()`,
4
+ * `asRawClient()`, `DELETE FROM`, `.execute()`).
5
+ *
6
+ * Escape hatch for a justified raw-SQL call:
7
+ * // kumiko-lint-ignore raw-sql <reason>
8
+ * on the call's own line or the line directly above. A bare tag with no
9
+ * reason text after it does NOT suppress the finding.
10
+ *
11
+ * I/O: Bun.Glob + Bun.file; directoryExists uses node:fs (same as roots.ts).
12
+ */
13
+ import { existsSync, statSync } from "node:fs";
14
+ import type { RepoRoot } from "./roots";
15
+
16
+ /** POSIX path join without Node path module. */
17
+ function joinPath(base: string, ...segments: string[]): string {
18
+ return [base, ...segments]
19
+ .join("/")
20
+ .replace(/\/+/g, "/")
21
+ .replace(/\/\.\//g, "/");
22
+ }
23
+
24
+ export type SqlInventoryKind = "unsafe" | "asRawClient" | "delete_from" | "execute";
25
+
26
+ export type SqlInventoryHit = {
27
+ readonly file: string;
28
+ readonly line: number;
29
+ readonly kind: SqlInventoryKind;
30
+ readonly allowed: boolean;
31
+ /** True when a `kumiko-lint-ignore raw-sql <reason>` marker suppresses this hit. */
32
+ readonly markerSuppressed: boolean;
33
+ readonly snippet: string;
34
+ };
35
+
36
+ export type SqlInventoryReport = {
37
+ readonly scannedAt: string;
38
+ readonly root: string;
39
+ readonly hits: readonly SqlInventoryHit[];
40
+ /** Total files scanned across this repo's scan dirs — feeds the vacuity check. */
41
+ readonly scannedFiles: number;
42
+ readonly summary: {
43
+ readonly total: number;
44
+ readonly disallowed: number;
45
+ readonly byKind: Readonly<Record<SqlInventoryKind, number>>;
46
+ readonly byBucket: {
47
+ readonly allowed: number;
48
+ readonly tests: number;
49
+ readonly marker: number;
50
+ readonly disallowed: number;
51
+ };
52
+ };
53
+ };
54
+
55
+ /** Paths where `.unsafe()` / `asRawClient()` are permitted (Phase 5 guard). */
56
+ export const RAW_SQL_ALLOWLIST: ReadonlyArray<RegExp> = [
57
+ // Layout-agnostic: framework packages/... AND flat app src/... AND enterprise packages/<pkg>/...
58
+ /(^|\/)(packages\/[^/]+\/)?src\/db\/queries\//,
59
+ /(^|\/)(packages\/[^/]+\/)?src\/db\/migrate-runner\.ts$/,
60
+ /(^|\/)(packages\/[^/]+\/)?src\/db\/schema-inspection\.ts$/,
61
+ /(^|\/)(packages\/[^/]+\/)?src\/db\/render-ddl\.ts$/,
62
+ /(^|\/)(packages\/[^/]+\/)?src\/bun-db\/query\.ts$/,
63
+ /(^|\/)(packages\/[^/]+\/)?src\/testing\//,
64
+ // ponytail: explicit enumeration — a blanket regex would auto-allow any new .unsafe()
65
+ /\/bundled-features\/src\/billing-foundation\/db\/queries\/subscription-projection\.ts$/,
66
+ /\/bundled-features\/src\/config\/db\/queries\/resolver\.ts$/,
67
+ /\/bundled-features\/src\/custom-fields\/db\/queries\/field-access\.ts$/,
68
+ /\/bundled-features\/src\/custom-fields\/db\/queries\/projection\.ts$/,
69
+ /\/bundled-features\/src\/custom-fields\/db\/queries\/quota\.ts$/,
70
+ /\/bundled-features\/src\/custom-fields\/db\/queries\/retention\.ts$/,
71
+ /\/bundled-features\/src\/custom-fields\/db\/queries\/user-data-rights\.ts$/,
72
+ /\/bundled-features\/src\/delivery\/db\/queries\/preferences\.ts$/,
73
+ /\/bundled-features\/src\/form-draft\/db\/queries\/cleanup\.ts$/,
74
+ /\/bundled-features\/src\/form-draft\/db\/queries\/draft-count\.ts$/,
75
+ /\/bundled-features\/src\/form-draft\/db\/queries\/owned-file-refs\.ts$/,
76
+ /\/bundled-features\/src\/inbound-mail-foundation\/db\/queries\/inbound-projections\.ts$/,
77
+ /\/bundled-features\/src\/secrets\/db\/queries\/read\.ts$/,
78
+ /\/bundled-features\/src\/sessions\/db\/queries\/cleanup\.ts$/,
79
+ /\/bundled-features\/src\/user\/db\/queries\/stream-tenant-backfill\.ts$/,
80
+ /\/packages\/framework\/src\/engine\/steps\/unsafe-projection-/,
81
+ /(^|\/)samples\/(apps|recipes)\/[^/]+\/src\/db\/queries\//,
82
+ /\/bin\/commands\//,
83
+ /\/scripts\/codemod-/,
84
+ /\/__tests__\//,
85
+ /\/bin\/_lib\//,
86
+ ];
87
+
88
+ /** Escape-hatch tag. Must be followed by whitespace + a non-empty reason to suppress. */
89
+ const MARKER_TAG = "kumiko-lint-ignore raw-sql";
90
+ // Marker must appear in a // comment — string literals describing the hatch must not suppress.
91
+ const MARKER_WITH_REASON_RE = /(^|\s)\/\/\s*kumiko-lint-ignore raw-sql\s+\S/;
92
+
93
+ const SKIP_PATH_PARTS = ["/node_modules/", "/dist/", "/.kumiko/"] as const;
94
+
95
+ const PATTERNS: ReadonlyArray<{
96
+ readonly kind: SqlInventoryKind;
97
+ readonly re: RegExp;
98
+ }> = [
99
+ { kind: "unsafe", re: /\.unsafe\s*[<(]/ },
100
+ { kind: "asRawClient", re: /asRawClient\s*\(/ },
101
+ { kind: "delete_from", re: /DELETE\s+FROM/i },
102
+ { kind: "execute", re: /\.execute\s*\(/ },
103
+ ];
104
+
105
+ /** Only these kinds fail CI; delete_from/execute stay inventory-only (infra#610). */
106
+ export const BLOCKING_SQL_KINDS: ReadonlyArray<SqlInventoryKind> = ["unsafe", "asRawClient"];
107
+
108
+ const TS_GLOB = new Bun.Glob("**/*.{ts,tsx}");
109
+
110
+ export type SqlScanLayout = "multi-package" | "flat" | "none";
111
+
112
+ // kumiko-platform deliberately returns "none" (0 files) — its docs-samples tree needs its own allowlist review before this guard scans it (follow-up issue).
113
+ export function sqlScanLayoutFor(root: Pick<RepoRoot, "name" | "kind">): SqlScanLayout {
114
+ if (root.name === "kumiko-platform") return "none";
115
+ if (root.kind === "framework" || root.kind === "library") return "multi-package";
116
+ return "flat";
117
+ }
118
+
119
+ /** Scan dirs per repo layout — multi-package keeps the packages/samples/scripts/bin layout, flat is a bare src/ + bin/. */
120
+ function scanDirsFor(layout: SqlScanLayout): readonly string[] {
121
+ if (layout === "multi-package") return ["packages", "samples", "scripts", "bin"];
122
+ if (layout === "none") return [];
123
+ return ["src", "bin"];
124
+ }
125
+
126
+ function normalizePathForMatch(filePath: string): string {
127
+ return filePath.startsWith("/") ? filePath : `/${filePath}`;
128
+ }
129
+
130
+ export function isRawSqlAllowed(filePath: string): boolean {
131
+ const normalized = normalizePathForMatch(filePath);
132
+ return RAW_SQL_ALLOWLIST.some((re) => re.test(normalized));
133
+ }
134
+
135
+ function isTestPath(filePath: string): boolean {
136
+ return /\/__tests__\//.test(normalizePathForMatch(filePath));
137
+ }
138
+
139
+ function bucketFor(hit: SqlInventoryHit): "allowed" | "tests" | "marker" | "disallowed" {
140
+ if (isTestPath(hit.file)) return "tests";
141
+ if (hit.allowed) return "allowed";
142
+ if (hit.markerSuppressed) return "marker";
143
+ return "disallowed";
144
+ }
145
+
146
+ function shouldSkipRelativePath(rel: string): boolean {
147
+ return SKIP_PATH_PARTS.some((part) => rel.includes(part));
148
+ }
149
+
150
+ function directoryExists(path: string): boolean {
151
+ try {
152
+ return existsSync(path) && statSync(path).isDirectory();
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ async function collectTsFiles(repoRoot: string, layout: SqlScanLayout): Promise<string[]> {
159
+ const out: string[] = [];
160
+ for (const sub of scanDirsFor(layout)) {
161
+ const cwd = joinPath(repoRoot, sub);
162
+ if (!directoryExists(cwd)) continue;
163
+ for await (const rel of TS_GLOB.scan({ cwd, onlyFiles: true })) {
164
+ const normalized = rel.replace(/\0/g, "");
165
+ if (!normalized || shouldSkipRelativePath(normalized)) continue;
166
+ out.push(joinPath(sub, normalized));
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ /** Strip a trailing/leading ignore-marker comment out of a reported snippet. */
173
+ function stripMarker(trimmed: string): string {
174
+ const idx = trimmed.indexOf(MARKER_TAG);
175
+ if (idx < 0) return trimmed;
176
+ return trimmed.slice(0, idx).trimEnd();
177
+ }
178
+
179
+ function scanFileText(relPath: string, text: string, hits: SqlInventoryHit[]): void {
180
+ const lines = text.split("\n");
181
+ const consumedMarkers = new Set<number>();
182
+ for (let i = 0; i < lines.length; i++) {
183
+ const line = lines[i] ?? "";
184
+ const trimmed = line.trim();
185
+
186
+ // Lookback happens independently of the comment-only-line skip below —
187
+ // a marker placed on its own comment line (the common case: the line
188
+ // ABOVE the offending call) must still be seen even though that line
189
+ // itself never reaches the pattern loop.
190
+ //
191
+ // Documented hatch: marker on the call line or the line directly above.
192
+ // Each marker line suppresses at most one subsequent hit (consumed), so a
193
+ // second `.unsafe()` under the same comment is not silently covered.
194
+ let suppressed = false;
195
+ if (MARKER_WITH_REASON_RE.test(line)) {
196
+ suppressed = true;
197
+ } else if (i > 0 && MARKER_WITH_REASON_RE.test(lines[i - 1] ?? "")) {
198
+ const markerIdx = i - 1;
199
+ if (!consumedMarkers.has(markerIdx)) {
200
+ suppressed = true;
201
+ consumedMarkers.add(markerIdx);
202
+ }
203
+ }
204
+
205
+ if (
206
+ trimmed.startsWith("//") ||
207
+ trimmed.startsWith("*") ||
208
+ trimmed.startsWith("/**") ||
209
+ trimmed.startsWith("/*")
210
+ ) {
211
+ continue;
212
+ }
213
+ for (const { kind, re } of PATTERNS) {
214
+ if (!re.test(line)) continue;
215
+ hits.push({
216
+ file: relPath,
217
+ line: i + 1,
218
+ kind,
219
+ allowed: isRawSqlAllowed(relPath),
220
+ markerSuppressed: suppressed,
221
+ snippet: stripMarker(trimmed).slice(0, 120),
222
+ });
223
+ }
224
+ }
225
+ }
226
+
227
+ export async function scanRepo(
228
+ repoRoot: string,
229
+ layout: SqlScanLayout,
230
+ ): Promise<SqlInventoryReport> {
231
+ const relFiles = await collectTsFiles(repoRoot, layout);
232
+ const hits: SqlInventoryHit[] = [];
233
+
234
+ for (const rel of relFiles) {
235
+ const abs = joinPath(repoRoot, rel);
236
+ const text = await Bun.file(abs).text();
237
+ scanFileText(rel, text, hits);
238
+ }
239
+
240
+ const byKind: Record<SqlInventoryKind, number> = {
241
+ unsafe: 0,
242
+ asRawClient: 0,
243
+ delete_from: 0,
244
+ execute: 0,
245
+ };
246
+ let disallowed = 0;
247
+ const byBucket = { allowed: 0, tests: 0, marker: 0, disallowed: 0 };
248
+ for (const h of hits) {
249
+ byKind[h.kind]++;
250
+ const b = bucketFor(h);
251
+ byBucket[b]++;
252
+ if (b === "disallowed") disallowed++;
253
+ }
254
+
255
+ return {
256
+ scannedAt: new Date().toISOString(),
257
+ root: repoRoot,
258
+ hits,
259
+ scannedFiles: relFiles.length,
260
+ summary: {
261
+ total: hits.length,
262
+ disallowed,
263
+ byKind,
264
+ byBucket,
265
+ },
266
+ };
267
+ }