@cosmicdrift/kumiko-guards 0.339.0 → 0.340.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/README.md CHANGED
@@ -15,6 +15,25 @@ repo's root. `--explain` prints the resolved repo and per-guard scan scope
15
15
  without running any guard; `--write-security-baseline` freezes current
16
16
  findings into the baseline file.
17
17
 
18
+ ## Raw-SQL markers
19
+
20
+ `guard-raw-sql` blocks `.unsafe()` / `asRawClient()` outside `db/queries/*`,
21
+ `bun-db/query.ts` and testing code. A `// kumiko-lint-ignore raw-sql <reason>`
22
+ marker on the call's line or the line above only suppresses while its
23
+ (file, reason) pair is frozen in `.kumiko-raw-sql-baseline.json` at the repo
24
+ root. A new or reworded marker without a baseline entry fails the check, so
25
+ every exception shows up as a baseline diff in review. Without a baseline file
26
+ the guard only warns, so consumers do not break on bump. Prefer a typed
27
+ `bun-db` helper; freeze a deliberate exception with:
28
+
29
+ ```
30
+ bunx kumiko-guards checks --write-baseline --guard=guard-raw-sql
31
+ ```
32
+
33
+ Rewording the reason is a new entry: re-run the command and review the diff.
34
+ A removed marker leaves a stale entry that does not fail; the same command
35
+ drops it.
36
+
18
37
  ## `kumiko-pre-push`
19
38
 
20
39
  A POSIX-sh pre-push hook, wired by copying the package's `hooks/pre-push`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.339.0",
3
+ "version": "0.340.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,7 +28,7 @@
28
28
  "kumiko-pre-push": "./src/pre-push.sh"
29
29
  },
30
30
  "dependencies": {
31
- "@cosmicdrift/kumiko-repo-manifest": "0.339.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.340.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
@@ -428,6 +428,8 @@ export type RepoCheck = {
428
428
  readonly name: string;
429
429
  readonly hint?: string;
430
430
  run(roots: readonly RepoRoot[]): RepoCheckOutcome | Promise<RepoCheckOutcome>;
431
+ /** Freezes the check's ratchet baseline(s); reachable via `checks --write-baseline --guard=<name>`. */
432
+ writeBaseline?(roots: readonly RepoRoot[]): void | Promise<void>;
431
433
  };
432
434
 
433
435
  const VACUOUS_MESSAGE =
@@ -6,7 +6,9 @@
6
6
  * Escape hatch for a justified raw-SQL call:
7
7
  * // kumiko-lint-ignore raw-sql <reason>
8
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.
9
+ * reason text after it does NOT suppress the finding. guard-raw-sql additionally
10
+ * requires each (file, reason) to be frozen in `.kumiko-raw-sql-baseline.json`;
11
+ * the hit carries `markerReason`/`markerLine` for that comparison.
10
12
  *
11
13
  * I/O: Bun.Glob + Bun.file; directoryExists uses node:fs (same as roots.ts).
12
14
  */
@@ -30,6 +32,10 @@ export type SqlInventoryHit = {
30
32
  readonly allowed: boolean;
31
33
  /** True when a `kumiko-lint-ignore raw-sql <reason>` marker suppresses this hit. */
32
34
  readonly markerSuppressed: boolean;
35
+ /** Trimmed reason text of the suppressing marker; set iff markerSuppressed. */
36
+ readonly markerReason?: string;
37
+ /** 1-based line of the suppressing marker; set iff markerSuppressed. */
38
+ readonly markerLine?: number;
33
39
  readonly snippet: string;
34
40
  };
35
41
 
@@ -62,7 +68,6 @@ export const RAW_SQL_ALLOWLIST: ReadonlyArray<RegExp> = [
62
68
  /(^|\/)(packages\/[^/]+\/)?src\/bun-db\/query\.ts$/,
63
69
  /(^|\/)(packages\/[^/]+\/)?src\/testing\//,
64
70
  // ponytail: explicit enumeration — a blanket regex would auto-allow any new .unsafe()
65
- /\/bundled-features\/src\/billing-foundation\/db\/queries\/subscription-projection\.ts$/,
66
71
  /\/bundled-features\/src\/config\/db\/queries\/resolver\.ts$/,
67
72
  /\/bundled-features\/src\/custom-fields\/db\/queries\/field-access\.ts$/,
68
73
  /\/bundled-features\/src\/custom-fields\/db\/queries\/projection\.ts$/,
@@ -73,7 +78,6 @@ export const RAW_SQL_ALLOWLIST: ReadonlyArray<RegExp> = [
73
78
  /\/bundled-features\/src\/form-draft\/db\/queries\/cleanup\.ts$/,
74
79
  /\/bundled-features\/src\/form-draft\/db\/queries\/draft-count\.ts$/,
75
80
  /\/bundled-features\/src\/form-draft\/db\/queries\/owned-file-refs\.ts$/,
76
- /\/bundled-features\/src\/inbound-mail-foundation\/db\/queries\/inbound-projections\.ts$/,
77
81
  /\/bundled-features\/src\/secrets\/db\/queries\/read\.ts$/,
78
82
  /\/bundled-features\/src\/sessions\/db\/queries\/cleanup\.ts$/,
79
83
  /\/bundled-features\/src\/user\/db\/queries\/stream-tenant-backfill\.ts$/,
@@ -88,7 +92,7 @@ export const RAW_SQL_ALLOWLIST: ReadonlyArray<RegExp> = [
88
92
  /** Escape-hatch tag. Must be followed by whitespace + a non-empty reason to suppress. */
89
93
  const MARKER_TAG = "kumiko-lint-ignore raw-sql";
90
94
  // 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/;
95
+ const MARKER_WITH_REASON_RE = /(^|\s)\/\/\s*kumiko-lint-ignore raw-sql\s+(\S.*)$/;
92
96
 
93
97
  const SKIP_PATH_PARTS = ["/node_modules/", "/dist/", "/.kumiko/"] as const;
94
98
 
@@ -185,13 +189,18 @@ function scanFileText(relPath: string, text: string, hits: SqlInventoryHit[]): v
185
189
  // Documented hatch: marker on the call line or the line directly above.
186
190
  // Each marker line suppresses at most one subsequent hit (consumed), so a
187
191
  // second `.unsafe()` under the same comment is not silently covered.
188
- let suppressed = false;
189
- if (MARKER_WITH_REASON_RE.test(line)) {
190
- suppressed = true;
191
- } else if (i > 0 && MARKER_WITH_REASON_RE.test(lines[i - 1] ?? "")) {
192
+ let markerReason: string | undefined;
193
+ let markerLine: number | undefined;
194
+ const sameLineReason = MARKER_WITH_REASON_RE.exec(line)?.[2];
195
+ if (sameLineReason !== undefined) {
196
+ markerReason = sameLineReason.trim();
197
+ markerLine = i + 1;
198
+ } else if (i > 0) {
199
+ const aboveReason = MARKER_WITH_REASON_RE.exec(lines[i - 1] ?? "")?.[2];
192
200
  const markerIdx = i - 1;
193
- if (!consumedMarkers.has(markerIdx)) {
194
- suppressed = true;
201
+ if (aboveReason !== undefined && !consumedMarkers.has(markerIdx)) {
202
+ markerReason = aboveReason.trim();
203
+ markerLine = i;
195
204
  consumedMarkers.add(markerIdx);
196
205
  }
197
206
  }
@@ -211,7 +220,10 @@ function scanFileText(relPath: string, text: string, hits: SqlInventoryHit[]): v
211
220
  line: i + 1,
212
221
  kind,
213
222
  allowed: isRawSqlAllowed(relPath),
214
- markerSuppressed: suppressed,
223
+ markerSuppressed: markerReason !== undefined,
224
+ ...(markerReason !== undefined && markerLine !== undefined
225
+ ? { markerReason, markerLine }
226
+ : {}),
215
227
  snippet: stripMarker(trimmed).slice(0, 120),
216
228
  });
217
229
  }
package/src/changes.json CHANGED
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.340.0",
4
+ "type": "improvement",
5
+ "title": "Raw-sql markers must be listed in a baseline",
6
+ "detail": "`guard-raw-sql` counts the markers that suppress a blocking hit per file and reason and compares them with `.kumiko-raw-sql-baseline.json` in the repo root. A marker without a baseline entry, or with a changed reason, fails the check. Without a baseline file the guard only warns. `kumiko-guards checks --write-baseline --guard=guard-raw-sql` writes the file.",
7
+ "migration": "Consumer: `--write-baseline` einmal laufen lassen (`bunx kumiko-guards checks --write-baseline --guard=guard-raw-sql`) und die erzeugte `.kumiko-raw-sql-baseline.json` committen."
8
+ },
2
9
  {
3
10
  "version": "0.339.0",
4
11
  "type": "fix",
package/src/cli.ts CHANGED
@@ -29,6 +29,9 @@ function printHelp(): void {
29
29
  for (const sub of SUBCOMMANDS) {
30
30
  const flags = SUBCOMMAND_FLAGS[sub];
31
31
  console.log(` ${sub}${flags.length > 0 ? ` [${flags.join("|")}]` : ""}`);
32
+ if (flags.includes("--write-baseline")) {
33
+ console.log(` --write-baseline needs --guard=<name> (freezes one guard's baseline)`);
34
+ }
32
35
  }
33
36
  }
34
37
 
@@ -9,10 +9,23 @@
9
9
  * should otherwise route SQL through `db/queries/*` or typed
10
10
  * `bun-db/query` helpers.
11
11
  *
12
- * Plan: kumiko-platform/docs/plans/architecture/intern/sql-queries-consolidation.md
12
+ * A marker only suppresses while its (file, reason) pair is frozen in
13
+ * `.kumiko-raw-sql-baseline.json` at the repo root, so every new or reworded
14
+ * marker shows up as a baseline diff in review. Without a baseline file the
15
+ * guard only warns (consumers do not break on bump). Freeze with:
16
+ * bun packages/guards/src/guard-raw-sql.ts --write-baseline
17
+ * (or `kumiko-guards checks --write-baseline --guard=guard-raw-sql`)
13
18
  */
14
19
 
15
- import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
20
+ import { existsSync } from "node:fs";
21
+ import path from "node:path";
22
+ import {
23
+ baselineRatchet,
24
+ type GuardViolation,
25
+ type RepoCheck,
26
+ reportResults,
27
+ runRepoChecks,
28
+ } from "./_lib/guard-kit";
16
29
  import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
17
30
  import {
18
31
  BLOCKING_SQL_KINDS,
@@ -29,22 +42,54 @@ export type RawSqlFinding = {
29
42
  readonly snippet: string;
30
43
  };
31
44
 
45
+ const BASELINE_FILE = ".kumiko-raw-sql-baseline.json";
46
+
47
+ type RootMarkers = {
48
+ readonly root: RepoRoot;
49
+ /** `<file>::<reason>` → distinct marker lines (1-based, ascending). */
50
+ readonly linesByKey: ReadonlyMap<string, readonly number[]>;
51
+ };
52
+
53
+ function ratchetFor(root: RepoRoot) {
54
+ return baselineRatchet({
55
+ file: path.join(root.absPath, BASELINE_FILE),
56
+ formatVersion: 1,
57
+ unit: "raw-sql marker(s)",
58
+ });
59
+ }
60
+
61
+ function countsOf(markers: RootMarkers): Record<string, number> {
62
+ return Object.fromEntries([...markers.linesByKey].map(([key, lines]) => [key, lines.length]));
63
+ }
64
+
32
65
  async function scanAllRepos(roots: readonly RepoRoot[]): Promise<{
33
66
  readonly findings: RawSqlFinding[];
67
+ readonly markers: RootMarkers[];
34
68
  readonly scannedFiles: number;
35
69
  }> {
36
70
  const findings: RawSqlFinding[] = [];
71
+ const markers: RootMarkers[] = [];
37
72
  let scannedFiles = 0;
38
73
 
39
74
  for (const root of roots) {
40
75
  const report = await scanRepo(root.absPath, sqlScanDirsFor(root));
41
76
  scannedFiles += report.scannedFiles;
77
+ const markerLines = new Map<string, Set<number>>();
42
78
 
43
79
  for (const hit of report.hits) {
44
80
  if (hit.allowed) continue;
45
- if (hit.markerSuppressed) continue;
46
81
  if (hit.file.includes("/__tests__/")) continue;
47
82
  if (!(BLOCKING_SQL_KINDS as readonly string[]).includes(hit.kind)) continue;
83
+ if (hit.markerSuppressed) {
84
+ // One line like `asRawClient(db).unsafe(` yields two hits for one marker.
85
+ if (hit.markerReason !== undefined && hit.markerLine !== undefined) {
86
+ const key = `${hit.file}::${hit.markerReason}`;
87
+ const lines = markerLines.get(key) ?? new Set<number>();
88
+ lines.add(hit.markerLine);
89
+ markerLines.set(key, lines);
90
+ }
91
+ continue;
92
+ }
48
93
  findings.push({
49
94
  repo: root.name,
50
95
  file: hit.file,
@@ -53,9 +98,16 @@ async function scanAllRepos(roots: readonly RepoRoot[]): Promise<{
53
98
  snippet: hit.snippet,
54
99
  });
55
100
  }
101
+
102
+ markers.push({
103
+ root,
104
+ linesByKey: new Map(
105
+ [...markerLines].map(([key, lines]) => [key, [...lines].sort((x, y) => x - y)]),
106
+ ),
107
+ });
56
108
  }
57
109
 
58
- return { findings, scannedFiles };
110
+ return { findings, markers, scannedFiles };
59
111
  }
60
112
 
61
113
  export async function collectRawSqlFindings(
@@ -64,11 +116,19 @@ export async function collectRawSqlFindings(
64
116
  return (await scanAllRepos(roots)).findings;
65
117
  }
66
118
 
119
+ const MARKER_REMEDIATION =
120
+ "New or reworded raw-sql marker. Use a typed bun-db helper (selectMany, selectInnerJoin, " +
121
+ "insertOnConflictDoNothing, upsertOnConflict, aggregateWhere, where jsonText …) if one fits; " +
122
+ "otherwise freeze the exception with `kumiko-guards checks --write-baseline --guard=guard-raw-sql` " +
123
+ "so the baseline diff shows up in review.";
124
+
67
125
  export const check: RepoCheck = {
68
126
  name: "guard-raw-sql",
69
127
  hint:
70
128
  "Rule: runtime SQL only in db/queries/*, bun-db/query.ts, testing/*, or with " +
71
- "// kumiko-lint-ignore raw-sql <reason> on the line or the line above.",
129
+ "// kumiko-lint-ignore raw-sql <reason> on the line or the line above — and that " +
130
+ "(file, reason) must be frozen in .kumiko-raw-sql-baseline.json " +
131
+ "(`kumiko-guards checks --write-baseline --guard=guard-raw-sql`).",
72
132
  async run(roots) {
73
133
  // Only the deliberate exclusion is skipped; a repo whose declared source roots
74
134
  // are all missing stays in the scan so 0 files surfaces as vacuous.
@@ -76,20 +136,41 @@ export const check: RepoCheck = {
76
136
  if (applicableRoots.length === 0) {
77
137
  return { violations: [], matchedFiles: 0, notApplicable: true };
78
138
  }
79
- const { findings, scannedFiles } = await scanAllRepos(applicableRoots);
139
+ const { findings, markers, scannedFiles } = await scanAllRepos(applicableRoots);
140
+ // A root without markers and without a baseline has nothing to freeze, so it
141
+ // skips the ratchet's "no baseline found" warning instead of nagging every consumer.
142
+ const markerViolations: GuardViolation[] = markers
143
+ .filter((m) => m.linesByKey.size > 0 || existsSync(path.join(m.root.absPath, BASELINE_FILE)))
144
+ .flatMap((m) =>
145
+ ratchetFor(m.root).check(countsOf(m), MARKER_REMEDIATION, {
146
+ resolveLine: (key) => m.linesByKey.get(key)?.[0] ?? 1,
147
+ }),
148
+ );
80
149
  return {
81
- violations: findings.map((f) => ({
82
- file: f.file,
83
- line: f.line,
84
- message: `[${f.kind}] ${f.snippet}`,
85
- })),
150
+ violations: [
151
+ ...findings.map((f) => ({
152
+ file: f.file,
153
+ line: f.line,
154
+ message: `[${f.kind}] ${f.snippet}`,
155
+ })),
156
+ ...markerViolations,
157
+ ],
86
158
  matchedFiles: scannedFiles,
87
159
  notApplicable: false,
88
160
  };
89
161
  },
162
+ async writeBaseline(roots) {
163
+ const applicableRoots = roots.filter((r) => !isSqlScanExcluded(r));
164
+ const { markers } = await scanAllRepos(applicableRoots);
165
+ for (const m of markers) ratchetFor(m.root).write(countsOf(m));
166
+ },
90
167
  };
91
168
 
92
169
  if (import.meta.main) {
170
+ if (process.argv.slice(2).includes("--write-baseline")) {
171
+ await check.writeBaseline?.(resolveRepoRoots());
172
+ process.exit(0);
173
+ }
93
174
  const failed = reportResults(await runRepoChecks([check]));
94
175
  process.exit(failed > 0 ? 1 : 0);
95
176
  }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { cliFlagsError, printGuardKitBanner, reportResults, runRepoChecks } from "./_lib/guard-kit";
3
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
3
4
  import { check as realProviderIsolation } from "./check-real-provider-isolation";
4
5
  // Standalone-`main()` guards ported as RepoCheck — run in-process, no
5
6
  // per-guard subprocess/project.
@@ -30,22 +31,55 @@ export const REPO_CHECKS = [
30
31
  singleRuntimeInstance,
31
32
  ];
32
33
 
33
- // No flags today — the array stays so an unknown flag still fails loud
34
- // instead of silently doing nothing, and so a future flag has one place to land.
35
- export const REPO_CHECK_FLAGS: readonly string[] = [];
34
+ export const REPO_CHECK_FLAGS: readonly string[] = ["--write-baseline"];
35
+
36
+ const GUARD_NAME_PREFIX = "--guard=";
36
37
 
37
38
  // Shared by the direct `bun run-repo-checks.ts` invocation below and by the
38
39
  // `checks` subcommand in cli.ts.
39
- export async function runRepoChecksCli(argv: readonly string[]): Promise<number> {
40
- const flagsError = cliFlagsError("checks", argv, REPO_CHECK_FLAGS);
40
+ export async function runRepoChecksCli(
41
+ argv: readonly string[],
42
+ roots?: readonly RepoRoot[],
43
+ ): Promise<number> {
44
+ const guardNameArg = argv.find((arg) => arg.startsWith(GUARD_NAME_PREFIX));
45
+ const flags = guardNameArg === undefined ? argv : argv.filter((arg) => arg !== guardNameArg);
46
+ const flagsError = cliFlagsError("checks", flags, REPO_CHECK_FLAGS);
41
47
  if (flagsError !== undefined) {
42
48
  console.error(flagsError);
43
49
  return 1;
44
50
  }
51
+ const writeBaseline = flags.includes("--write-baseline");
52
+ if (guardNameArg !== undefined && !writeBaseline) {
53
+ console.error("--guard=<name> is only valid with --write-baseline");
54
+ return 1;
55
+ }
56
+ if (writeBaseline) {
57
+ // Baselines freeze deliberately, one check at a time — never a refreeze-everything.
58
+ if (guardNameArg === undefined) {
59
+ console.error(
60
+ "--write-baseline needs --guard=<name>. Freeze a baseline only deliberately, one check at a time.",
61
+ );
62
+ return 1;
63
+ }
64
+ const checkName = guardNameArg.slice(GUARD_NAME_PREFIX.length);
65
+ const target = REPO_CHECKS.find((c) => c.name === checkName);
66
+ if (target === undefined) {
67
+ console.error(
68
+ `Unknown check "${checkName}". Known check names: ${REPO_CHECKS.map((c) => c.name).join(", ")}`,
69
+ );
70
+ return 1;
71
+ }
72
+ if (target.writeBaseline === undefined) {
73
+ console.error(`Check "${checkName}" has no ratchet baseline to write.`);
74
+ return 1;
75
+ }
76
+ await target.writeBaseline(roots ?? resolveRepoRoots());
77
+ return 0;
78
+ }
45
79
  // No shared ts-morph Project here — RepoCheck.run() does its own file
46
80
  // walk per check, so the banner omits the "Project: N files" line.
47
81
  printGuardKitBanner(REPO_CHECKS.length);
48
- return reportResults(await runRepoChecks(REPO_CHECKS));
82
+ return reportResults(await runRepoChecks(REPO_CHECKS, roots));
49
83
  }
50
84
 
51
85
  if (import.meta.main) {