@cosmicdrift/kumiko-guards 0.338.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 +19 -0
- package/package.json +2 -2
- package/src/_lib/guard-kit.ts +49 -3
- package/src/_lib/sql-inventory.ts +23 -11
- package/src/changes.json +14 -0
- package/src/cli.ts +3 -0
- package/src/guard-no-framed-extension-sections.ts +10 -3
- package/src/guard-raw-sql.ts +92 -11
- package/src/run-repo-checks.ts +40 -6
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.
|
|
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.
|
|
31
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.340.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
* kumiko.json manifest instead of hardcoded repo-kind globs.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
-
import { relative as pathRelative, resolve } from "node:path";
|
|
15
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { relative as pathRelative, resolve, sep } from "node:path";
|
|
17
17
|
import { Project, type SourceFile } from "ts-morph";
|
|
18
18
|
import { compareToBaseline, findRepoRootFor } from "./baseline-compare";
|
|
19
19
|
import {
|
|
20
20
|
explainRepoRoots,
|
|
21
|
+
findLocalRepo,
|
|
21
22
|
frameworkTsConfigPath,
|
|
22
23
|
type RepoRoot,
|
|
23
24
|
type RootResolution,
|
|
@@ -62,6 +63,49 @@ export function isSecurityGuard(guard: Pick<AstGuard, "security">): boolean {
|
|
|
62
63
|
return guard.security === true;
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
// Followers call the predicate per import hop; findLocalRepo re-reads
|
|
67
|
+
// package.json and the manifest each time.
|
|
68
|
+
const localRepoRootByCwd = new Map<string, string | undefined>();
|
|
69
|
+
|
|
70
|
+
function localRepoRoot(): string | undefined {
|
|
71
|
+
const cwd = process.cwd();
|
|
72
|
+
if (!localRepoRootByCwd.has(cwd)) localRepoRootByCwd.set(cwd, findLocalRepo(cwd)?.absPath);
|
|
73
|
+
return localRepoRootByCwd.get(cwd);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function realOrResolved(path: string): string {
|
|
77
|
+
try {
|
|
78
|
+
return realpathSync(path);
|
|
79
|
+
} catch {
|
|
80
|
+
// In-memory test files and paths not on disk: compare as spelled.
|
|
81
|
+
return resolve(path);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* True for a file outside the local repo's root — a neighbour repo of the
|
|
87
|
+
* parent workspace reached through a workspace symlink or a `../..` import. Such
|
|
88
|
+
* a file is as external as a package under node_modules: a guard may not scan it
|
|
89
|
+
* or follow imports into it, or a `.wt/<repo>` worktree reports findings that a
|
|
90
|
+
* fresh CI checkout (no parent workspace) never sees. The root comes from
|
|
91
|
+
* `findLocalRepo`, not a path heuristic; without a local repo nothing is
|
|
92
|
+
* decidable and nothing counts as outside.
|
|
93
|
+
*/
|
|
94
|
+
export function isOutsideRepoRoot(
|
|
95
|
+
filePath: string,
|
|
96
|
+
repoRoot: string | undefined = localRepoRoot(),
|
|
97
|
+
): boolean {
|
|
98
|
+
if (repoRoot === undefined) return false;
|
|
99
|
+
const root = realOrResolved(repoRoot);
|
|
100
|
+
const file = realOrResolved(filePath);
|
|
101
|
+
return file !== root && !file.startsWith(root + sep);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Import followers stop at these: a package from node_modules or a file outside the repo root. */
|
|
105
|
+
export function isExternalSourcePath(filePath: string): boolean {
|
|
106
|
+
return filePath.includes("/node_modules/") || isOutsideRepoRoot(filePath);
|
|
107
|
+
}
|
|
108
|
+
|
|
65
109
|
export function buildSharedProject(
|
|
66
110
|
guards: readonly AstGuard[],
|
|
67
111
|
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
@@ -88,7 +132,7 @@ export function buildSharedProject(
|
|
|
88
132
|
}
|
|
89
133
|
}),
|
|
90
134
|
),
|
|
91
|
-
];
|
|
135
|
+
].filter((path) => roots.some((root) => !isOutsideRepoRoot(path, root.absPath)));
|
|
92
136
|
// Exact paths, never re-glob: filenames like `[id].tsx` are literal here.
|
|
93
137
|
for (const path of union) project.addSourceFileAtPath(path);
|
|
94
138
|
return project;
|
|
@@ -384,6 +428,8 @@ export type RepoCheck = {
|
|
|
384
428
|
readonly name: string;
|
|
385
429
|
readonly hint?: string;
|
|
386
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>;
|
|
387
433
|
};
|
|
388
434
|
|
|
389
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
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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:
|
|
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.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
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.339.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "Guards ignore neighbour repos of the parent workspace",
|
|
13
|
+
"detail": "The root comes from `findLocalRepo`, not a path heuristic. `isOutsideRepoRoot(filePath, repoRoot?)` compares real paths, so symlinks into a neighbour repo count as outside. `buildSharedProject` drops such files and `no-framed-extension-sections` stops following imports into them.",
|
|
14
|
+
"migration": "keine"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"version": "0.337.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
|
|
|
@@ -41,7 +41,13 @@ import {
|
|
|
41
41
|
SyntaxKind,
|
|
42
42
|
VariableDeclarationKind,
|
|
43
43
|
} from "ts-morph";
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
type AstGuard,
|
|
46
|
+
type GuardViolation,
|
|
47
|
+
isExternalSourcePath,
|
|
48
|
+
runStandalone,
|
|
49
|
+
type ScanSpec,
|
|
50
|
+
} from "./_lib/guard-kit";
|
|
45
51
|
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
46
52
|
|
|
47
53
|
const ROOT = process.cwd();
|
|
@@ -58,11 +64,12 @@ const MAX_HOPS = 4;
|
|
|
58
64
|
const FRAMED_TAG = /^(Card|SectionCard|CollapsibleSection)$/;
|
|
59
65
|
|
|
60
66
|
// A vendored package's shipped .tsx source is still resolvable through
|
|
61
|
-
// node_modules
|
|
67
|
+
// node_modules, and in a parent workspace a workspace link resolves into a
|
|
68
|
+
// neighbour repo — treat both the same as a truly unresolvable specifier
|
|
62
69
|
// ("package component, checked inside the package itself"), not as a hop
|
|
63
70
|
// into the App-Repo's own extension code.
|
|
64
71
|
function isExternalSourceFile(sf: SourceFile): boolean {
|
|
65
|
-
return sf.getFilePath()
|
|
72
|
+
return isExternalSourcePath(sf.getFilePath());
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
function stringConstantValue(node: Node): string | undefined {
|
package/src/guard-raw-sql.ts
CHANGED
|
@@ -9,10 +9,23 @@
|
|
|
9
9
|
* should otherwise route SQL through `db/queries/*` or typed
|
|
10
10
|
* `bun-db/query` helpers.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
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 {
|
|
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:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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-repo-checks.ts
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
40
|
-
|
|
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) {
|