@cosmicdrift/kumiko-guards 0.339.0 → 0.341.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.341.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.341.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
@@ -56,7 +56,7 @@ export type AstGuard = {
56
56
  /** `roots` are the same roots `runGuards` scanned `files` with — a guard classifying paths against repo roots (`relFromRepoRoot`) must use these, not re-derive its own single-repo `resolveRepoRoots()`, or a multi-root caller's roots never reach it. */
57
57
  run(files: readonly SourceFile[], roots?: readonly RepoRoot[]): GuardOutcome;
58
58
  /** Ratchet guards only: freeze the current findings into that guard's own baseline file. */
59
- writeBaseline?(files: readonly SourceFile[]): void;
59
+ writeBaseline?(files: readonly SourceFile[], roots?: readonly RepoRoot[]): void;
60
60
  };
61
61
 
62
62
  export function isSecurityGuard(guard: Pick<AstGuard, "security">): boolean {
@@ -195,6 +195,8 @@ export function baselineRatchet(args: {
195
195
  readonly file: string;
196
196
  readonly formatVersion: number;
197
197
  readonly unit: string;
198
+ /** A missing baseline file counts as an empty baseline (every current entry is a regression) instead of a warning. For guards whose suppression must not hold without a frozen file. */
199
+ readonly failClosed?: boolean;
198
200
  }): {
199
201
  write(current: Readonly<Record<string, number>>): void;
200
202
  check(
@@ -229,6 +231,13 @@ export function baselineRatchet(args: {
229
231
  readonly resolveLine?: (file: string) => number;
230
232
  },
231
233
  ): GuardViolation[] => {
234
+ if (!existsSync(args.file) && args.failClosed === true) {
235
+ return compareToBaseline(current, {}).regressions.map((regression) => ({
236
+ file: regression.file,
237
+ line: opts?.resolveLine?.(regression.file) ?? 1,
238
+ message: `${args.unit} without a baseline file (${args.file}): ${regression.current} not frozen. ${remediation}`,
239
+ }));
240
+ }
232
241
  if (!existsSync(args.file)) {
233
242
  console.log(
234
243
  ` No baseline found (${args.file}). Freeze it first with \`--write-baseline\` — warning until then, no fail.`,
@@ -428,6 +437,8 @@ export type RepoCheck = {
428
437
  readonly name: string;
429
438
  readonly hint?: string;
430
439
  run(roots: readonly RepoRoot[]): RepoCheckOutcome | Promise<RepoCheckOutcome>;
440
+ /** Freezes the check's ratchet baseline(s); reachable via `checks --write-baseline --guard=<name>`. */
441
+ writeBaseline?(roots: readonly RepoRoot[]): void | Promise<void>;
431
442
  };
432
443
 
433
444
  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,18 @@
1
1
  [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "breaking",
5
+ "title": "Repo-local direct-fs exceptions with a frozen reason, bundled read-only assets",
6
+ "detail": "`guard-no-direct-fs` has a repo-local exception: `// kumiko-lint-ignore direct-fs <reason>` on the `node:fs` import (or the line above) suppresses the finding only while the `<file>::<reason>` pair is frozen in `.kumiko-direct-fs-baseline.json` at the repo root. It fails closed: without a baseline file, with an unreadable file, with a changed reason, or with more markers than frozen, the finding stays. A bare tag without a reason does not count. Freeze with `kumiko-guards guards --write-baseline --guard=\"No-Direct-Fs Guard\"`. The guard hint now points to the marker, `FileStorageProvider` and `readBundledAsset`. `AstGuard.writeBaseline` receives the scanned roots as an optional second argument, and `baselineRatchet` takes an opt-in `failClosed` that treats a missing baseline file as empty.\n**Migration (breaking-ish):** the framework allowlist no longer contains `src/marketing/render-landing.ts` and `src/marketing/rebuild-pages-job.ts` for every repo. Apps that have these files (today money-horse, phronexsis, show-pony, publicstatus) put `// kumiko-lint-ignore direct-fs <reason>` above the `node:fs` import in each file and run `kumiko-guards guards --write-baseline --guard=\"No-Direct-Fs Guard\"` once in the app repo. Commit the generated `.kumiko-direct-fs-baseline.json`.\n`@cosmicdrift/kumiko-server-runtime` can ship read-only files from an app's own build. Declare them in `package.json` under `kumiko.assets` (`[{ \"name\": \"inter-bold.ttf\", \"source\": \"packages/site-kit/fonts/inter-bold.ttf\" }]`, `source` relative to the app's `package.json`). `buildProdBundle` copies them to `dist/kumiko-bundled-assets/`, which the static file server does not serve, and fails the build on an invalid name, a duplicate, a source outside the package or a missing file. `readBundledAsset(name)` and `resolveBundledAsset(name)` read the dist copy in prod and the declared source in dev, so apps need no `node:fs` for it.",
7
+ "migration": "The allowlist no longer frees src/marketing/render-landing.ts and src/marketing/rebuild-pages-job.ts in every repo. Put // kumiko-lint-ignore direct-fs <reason> above the node:fs import in each such file, run kumiko-guards guards --write-baseline --guard=\"No-Direct-Fs Guard\" once and commit .kumiko-direct-fs-baseline.json."
8
+ },
9
+ {
10
+ "version": "0.340.0",
11
+ "type": "improvement",
12
+ "title": "Raw-sql markers must be listed in a baseline",
13
+ "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.",
14
+ "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."
15
+ },
2
16
  {
3
17
  "version": "0.339.0",
4
18
  "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
 
@@ -19,17 +19,29 @@
19
19
  * one repo cannot silently free node:fs in another under the same relative
20
20
  * path.
21
21
  *
22
+ * Repo-local exception for a justified import (tooling, build-time reads):
23
+ * // kumiko-lint-ignore direct-fs <reason>
24
+ * on the import line or the line directly above. A bare tag without a reason
25
+ * does not count. The marker suppresses only while its `<file>::<reason>` pair
26
+ * is frozen in `.kumiko-direct-fs-baseline.json` at the repo root — fail-closed:
27
+ * no file, an unreadable file, a changed reason or more markers than frozen
28
+ * all make the guard fail. Freeze with:
29
+ * kumiko-guards guards --write-baseline --guard="No-Direct-Fs Guard"
30
+ *
22
31
  * Usage:
23
32
  * bun guards/guard-no-direct-fs.ts
24
33
  *
25
34
  * Exit 1 on violations in non-allowlisted files, 0 when clean.
26
35
  */
27
36
 
28
- import { relative as pathRelative } from "node:path";
37
+ import { existsSync } from "node:fs";
38
+ import { join, relative as pathRelative } from "node:path";
29
39
  import { Node, type SourceFile, SyntaxKind } from "ts-morph";
30
40
  import {
31
41
  type AstGuard,
42
+ baselineRatchet,
32
43
  findRepoRootFor,
44
+ type GuardViolation,
33
45
  relFromRepoRoot,
34
46
  runStandalone,
35
47
  type ScanSpec,
@@ -86,8 +98,10 @@ const ALLOWLIST: readonly AllowEntry[] = [
86
98
  },
87
99
 
88
100
  // server-runtime: prod bundle build + static asset delivery from the
89
- // build output (no user-controlled path).
101
+ // build output (no user-controlled path). bundled-assets.ts reads declared
102
+ // read-only assets, name-pattern + containment checked.
90
103
  { repo: "kumiko-framework", pattern: /^packages\/server-runtime\/src\/build-prod-bundle\.ts$/ },
104
+ { repo: "kumiko-framework", pattern: /^packages\/server-runtime\/src\/bundled-assets\.ts$/ },
91
105
  {
92
106
  repo: "kumiko-framework",
93
107
  pattern: /^packages\/server-runtime\/src\/run-prod-app-static-files\.ts$/,
@@ -110,12 +124,6 @@ const ALLOWLIST: readonly AllowEntry[] = [
110
124
  // walk from cwd, no user input as a path.
111
125
  { repo: "kumiko-enterprise", pattern: /^packages\/ai-foundation\/src\/prompt\/corpus\.ts$/ },
112
126
 
113
- // App repos: marketing landing-page renderer, iterates over a fixed
114
- // language enum (LANGS/SUPPORTED_LANGS) — no user input in the path.
115
- // repo:"*" — same relative path is intentional across flat-layout apps.
116
- { repo: "*", pattern: /(^|\/)src\/marketing\/render-landing\.ts$/ },
117
- { repo: "*", pattern: /(^|\/)src\/marketing\/rebuild-pages-job\.ts$/ },
118
-
119
127
  // kumiko-enterprise publish pipeline: materialize.ts is the guarded
120
128
  // place (writeMaterializedTree — resolve()+startsWith(root+sep) check,
121
129
  // same pattern as local-provider.ts). build.ts/feature.ts/validate.ts
@@ -153,6 +161,100 @@ interface Violation {
153
161
  moduleSpecifier: string;
154
162
  }
155
163
 
164
+ const BASELINE_FILE = ".kumiko-direct-fs-baseline.json";
165
+ // Must sit in a // comment, so a string literal describing the marker does not suppress.
166
+ const MARKER_WITH_REASON_RE = /(^|\s)\/\/\s*kumiko-lint-ignore direct-fs\s+(\S.*)$/;
167
+
168
+ const MARKER_REMEDIATION =
169
+ "New, reworded or unfrozen direct-fs marker. Prefer FileStorageProvider or readBundledAsset; " +
170
+ 'otherwise freeze the exception with `kumiko-guards guards --write-baseline --guard="No-Direct-Fs Guard"` ' +
171
+ "so the baseline diff shows up in review.";
172
+
173
+ type RootMarkers = {
174
+ readonly root: RepoRoot;
175
+ /** `<file>::<reason>` -> distinct marker lines (1-based). */
176
+ readonly linesByKey: Map<string, Set<number>>;
177
+ };
178
+
179
+ function ratchetFor(root: RepoRoot) {
180
+ return baselineRatchet({
181
+ file: join(root.absPath, BASELINE_FILE),
182
+ formatVersion: 1,
183
+ unit: "direct-fs marker(s)",
184
+ failClosed: true,
185
+ });
186
+ }
187
+
188
+ function markerFor(
189
+ lines: readonly string[],
190
+ importLine: number,
191
+ ): { readonly reason: string; readonly line: number } | undefined {
192
+ const sameLine = MARKER_WITH_REASON_RE.exec(lines[importLine - 1] ?? "")?.[2];
193
+ if (sameLine !== undefined) return { reason: sameLine.trim(), line: importLine };
194
+ const above = MARKER_WITH_REASON_RE.exec(lines[importLine - 2] ?? "")?.[2];
195
+ return above === undefined ? undefined : { reason: above.trim(), line: importLine - 1 };
196
+ }
197
+
198
+ function countsOf(markers: RootMarkers): Record<string, number> {
199
+ return Object.fromEntries([...markers.linesByKey].map(([key, lines]) => [key, lines.size]));
200
+ }
201
+
202
+ function collect(
203
+ files: readonly SourceFile[],
204
+ roots: readonly RepoRoot[],
205
+ ): { readonly unsuppressed: GuardViolation[]; readonly markers: Map<string, RootMarkers> } {
206
+ const unsuppressed: GuardViolation[] = [];
207
+ const markers = new Map<string, RootMarkers>();
208
+
209
+ for (const sf of files) {
210
+ const file = sf.getFilePath();
211
+ const rel = relFromRepoRoot(file, roots);
212
+ if (EXCLUDE.test(rel)) continue;
213
+ const root = findRepoRootFor(file, roots);
214
+ if (isRepoAllowlisted(root?.name, rel)) continue;
215
+ const lines = sf.getFullText().split("\n");
216
+ for (const v of findDirectFsImports(sf)) {
217
+ const marker = root === undefined ? undefined : markerFor(lines, v.line);
218
+ if (root !== undefined && marker !== undefined) {
219
+ const entry = markers.get(root.absPath) ?? { root, linesByKey: new Map() };
220
+ const key = `${rel}::${marker.reason}`;
221
+ const set = entry.linesByKey.get(key) ?? new Set<number>();
222
+ set.add(marker.line);
223
+ entry.linesByKey.set(key, set);
224
+ markers.set(root.absPath, entry);
225
+ continue;
226
+ }
227
+ unsuppressed.push({
228
+ // cwd-relative, not repo-relative `rel` — the security baseline
229
+ // needs an unambiguous path to resolve back to (repo, relPath).
230
+ file: pathRelative(process.cwd(), file),
231
+ line: v.line,
232
+ message: `[${v.moduleSpecifier}] direct fs import outside allowlist`,
233
+ });
234
+ }
235
+ }
236
+ return { unsuppressed, markers };
237
+ }
238
+
239
+ function markerViolations(markers: ReadonlyMap<string, RootMarkers>): GuardViolation[] {
240
+ return [...markers.values()].flatMap((m) =>
241
+ ratchetFor(m.root)
242
+ .check(countsOf(m), MARKER_REMEDIATION, {
243
+ resolveLine: (key) => [...(m.linesByKey.get(key) ?? [])].sort((a, b) => a - b)[0] ?? 1,
244
+ })
245
+ .map((v) => {
246
+ const [relFile] = v.file.split("::");
247
+ const isMarkerKey = v.file.includes("::") && relFile !== undefined;
248
+ return {
249
+ ...v,
250
+ file: isMarkerKey ? pathRelative(process.cwd(), join(m.root.absPath, relFile)) : v.file,
251
+ // A frozen security baseline must never excuse an unfrozen marker.
252
+ neverFrozen: true,
253
+ };
254
+ }),
255
+ );
256
+ }
257
+
156
258
  function findDynamicFsRequires(sf: SourceFile): Violation[] {
157
259
  const violations: Violation[] = [];
158
260
 
@@ -208,28 +310,20 @@ export const guard: AstGuard = {
208
310
  name: "No-Direct-Fs Guard",
209
311
  scan: SCAN,
210
312
  security: true,
211
- hint: "Direct node:fs import outside the allowlist — use FileStorageProvider (packages/framework/src/files/) instead of wiring fs yourself. The path-traversal guard (resolveContainedPath) only exists there. Legitimate new tooling caller? Extend the allowlist in guard-no-direct-fs.ts, with a reason + repo scope.",
313
+ hint: 'Direct node:fs import outside the allowlist — use FileStorageProvider (packages/framework/src/files/) instead of wiring fs yourself; the path-traversal guard (resolveContainedPath) only exists there. Read-only files your own build ships: declare them under package.json `kumiko.assets` and use readBundledAsset(name) from @cosmicdrift/kumiko-server-runtime. Legitimate tooling exception: mark the import with `// kumiko-lint-ignore direct-fs <reason>` and freeze it with `kumiko-guards guards --write-baseline --guard="No-Direct-Fs Guard"` (.kumiko-direct-fs-baseline.json, reviewed as a diff).',
212
314
  run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
213
- const violations: Array<{ file: string; line: number; message: string }> = [];
214
-
215
- for (const sf of files) {
216
- const file = sf.getFilePath();
217
- const rel = relFromRepoRoot(file, roots);
218
- if (EXCLUDE.test(rel)) continue;
219
- const root = findRepoRootFor(file, roots);
220
- if (isRepoAllowlisted(root?.name, rel)) continue;
221
- for (const v of findDirectFsImports(sf)) {
222
- violations.push({
223
- // cwd-relative, not repo-relative `rel` — the security baseline
224
- // needs an unambiguous path to resolve back to (repo, relPath).
225
- file: pathRelative(process.cwd(), file),
226
- line: v.line,
227
- message: `[${v.moduleSpecifier}] direct fs import outside allowlist`,
228
- });
315
+ const { unsuppressed, markers } = collect(files, roots);
316
+ return { violations: [...unsuppressed, ...markerViolations(markers)] };
317
+ },
318
+ writeBaseline(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
319
+ const { markers } = collect(files, roots);
320
+ for (const m of markers.values()) ratchetFor(m.root).write(countsOf(m));
321
+ // A repo whose last marker is gone keeps a stale baseline unless it is shrunk too.
322
+ for (const root of roots) {
323
+ if (!markers.has(root.absPath) && existsSync(join(root.absPath, BASELINE_FILE))) {
324
+ ratchetFor(root).write({});
229
325
  }
230
326
  }
231
-
232
- return { violations };
233
327
  },
234
328
  };
235
329
 
@@ -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
  }
package/src/run-guards.ts CHANGED
@@ -160,7 +160,10 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
160
160
  return 1;
161
161
  }
162
162
  const project = buildSharedProject([target], deps.writeBaselineRoots);
163
- target.writeBaseline(filesForGuard(project, target, deps.writeBaselineRoots));
163
+ target.writeBaseline(
164
+ filesForGuard(project, target, deps.writeBaselineRoots),
165
+ deps.writeBaselineRoots,
166
+ );
164
167
  return 0;
165
168
  }
166
169
  const strictSecurityBaseline = flags.includes("--strict-security-baseline");
@@ -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) {