@cosmicdrift/kumiko-framework 0.215.5 → 0.215.7
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/package.json +3 -3
- package/src/engine/feature-ast/__tests__/parse.test.ts +222 -5
- package/src/engine/feature-ast/__tests__/patch.test.ts +58 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +76 -1
- package/src/engine/feature-ast/extractors/ai-steps.ts +366 -0
- package/src/engine/feature-ast/extractors/index.ts +5 -0
- package/src/engine/feature-ast/parse.ts +135 -0
- package/src/engine/feature-ast/patch.ts +99 -16
- package/src/engine/feature-ast/patterns.ts +51 -0
- package/src/engine/feature-ast/render.ts +71 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +39 -0
- package/src/engine/pattern-library/library.ts +6 -0
- package/src/engine/pattern-library/mixed-schemas.ts +103 -1
- package/src/stack/__tests__/request-helper.integration.test.ts +126 -0
- package/src/stack/__tests__/request-helper.test.ts +159 -0
- package/src/testing/__tests__/multipart-helper.test.ts +49 -0
- package/src/db/__tests__/sql-inventory.test.ts +0 -81
- package/src/db/sql-inventory.ts +0 -232
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import type { JwtHelper } from "../../api/jwt";
|
|
4
|
+
import type { SessionUser } from "../../engine/types";
|
|
5
|
+
import { createRequestHelper } from "../request-helper";
|
|
6
|
+
|
|
7
|
+
const user: SessionUser = {
|
|
8
|
+
id: "u1",
|
|
9
|
+
tenantId: "t1",
|
|
10
|
+
roles: ["member"],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function jwtStub() {
|
|
14
|
+
const signed: unknown[] = [];
|
|
15
|
+
const jwt = {
|
|
16
|
+
sign: async (payload: unknown) => {
|
|
17
|
+
signed.push(payload);
|
|
18
|
+
return `j.${Buffer.from(JSON.stringify(payload)).toString("base64url")}`;
|
|
19
|
+
},
|
|
20
|
+
} as unknown as JwtHelper;
|
|
21
|
+
return { jwt, signed };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function appRecording(
|
|
25
|
+
handler: (c: {
|
|
26
|
+
req: { json: () => Promise<unknown>; header: (n: string) => string | undefined };
|
|
27
|
+
}) => Response | Promise<Response>,
|
|
28
|
+
) {
|
|
29
|
+
const calls: { path: string; auth?: string; extraHeaders: Record<string, string | undefined> }[] =
|
|
30
|
+
[];
|
|
31
|
+
const app = new Hono();
|
|
32
|
+
for (const path of ["/api/write", "/api/query", "/api/command", "/api/batch"]) {
|
|
33
|
+
app.post(path, async (c) => {
|
|
34
|
+
calls.push({
|
|
35
|
+
path,
|
|
36
|
+
auth: c.req.header("authorization"),
|
|
37
|
+
extraHeaders: { "x-trace": c.req.header("x-trace") },
|
|
38
|
+
});
|
|
39
|
+
return handler({ req: { json: () => c.req.json(), header: (n) => c.req.header(n) } });
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return { app, calls };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("createRequestHelper", () => {
|
|
46
|
+
test("writeOk posts JSON to /api/write and resolves the success data", async () => {
|
|
47
|
+
const { app, calls } = appRecording(() =>
|
|
48
|
+
Response.json({ isSuccess: true, data: { written: true } }),
|
|
49
|
+
);
|
|
50
|
+
const { jwt } = jwtStub();
|
|
51
|
+
const http = createRequestHelper(app, jwt);
|
|
52
|
+
|
|
53
|
+
const data = await http.writeOk("todo.create", { title: "x" }, user);
|
|
54
|
+
expect(data).toEqual({ written: true });
|
|
55
|
+
expect(calls).toHaveLength(1);
|
|
56
|
+
expect(calls[0]!.path).toBe("/api/write");
|
|
57
|
+
expect(calls[0]!.auth).toMatch(/^Bearer j\./);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("sessionCreator is consulted once per user and its sid reaches the jwt", async () => {
|
|
61
|
+
let creations = 0;
|
|
62
|
+
const { app } = appRecording(() => Response.json({ isSuccess: true, data: {} }));
|
|
63
|
+
const { jwt, signed } = jwtStub();
|
|
64
|
+
const http = createRequestHelper(app, jwt, {
|
|
65
|
+
sessionCreator: async () => {
|
|
66
|
+
creations += 1;
|
|
67
|
+
return "sid-42";
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await http.writeOk("a.b", {}, user);
|
|
72
|
+
await http.queryOk("c.d", {}, user);
|
|
73
|
+
expect(creations).toBe(1);
|
|
74
|
+
expect(signed).toHaveLength(2);
|
|
75
|
+
expect(signed[0]).toMatchObject({ id: "u1", tenantId: "t1", sid: "sid-42" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("user-provided sid skips the sessionCreator entirely", async () => {
|
|
79
|
+
const { app } = appRecording(() => Response.json({ isSuccess: true, data: {} }));
|
|
80
|
+
const { jwt, signed } = jwtStub();
|
|
81
|
+
let creations = 0;
|
|
82
|
+
const http = createRequestHelper(app, jwt, {
|
|
83
|
+
sessionCreator: async () => {
|
|
84
|
+
creations += 1;
|
|
85
|
+
throw new Error("must not be called");
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
await http.writeOk("a.b", {}, { ...user, sid: "pre-set" });
|
|
89
|
+
expect(creations).toBe(0);
|
|
90
|
+
expect(signed[0]).toMatchObject({ sid: "pre-set" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("writeOk surfaces the formatted failure including cause details", async () => {
|
|
94
|
+
const { app } = appRecording(() =>
|
|
95
|
+
Response.json(
|
|
96
|
+
{
|
|
97
|
+
error: {
|
|
98
|
+
code: "internal_error",
|
|
99
|
+
details: { causeName: "DbError", causeMessage: "connection lost" },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{ status: 500 },
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
const http = createRequestHelper(app, jwtStub().jwt);
|
|
106
|
+
expect(http.writeOk("todo.create", {}, user)).rejects.toThrow(
|
|
107
|
+
'Expected write "todo.create" to succeed but got error: internal_error (DbError: connection lost)',
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("queryErr returns the wire error plus http status, but throws on success", async () => {
|
|
112
|
+
const failing = appRecording(() =>
|
|
113
|
+
Response.json({ error: { code: "validation_failed", message: "nope" } }, { status: 400 }),
|
|
114
|
+
);
|
|
115
|
+
const http = createRequestHelper(failing.app, jwtStub().jwt);
|
|
116
|
+
const err = await http.queryErr("q.type", {}, user);
|
|
117
|
+
expect(err.code).toBe("validation_failed");
|
|
118
|
+
expect(err.httpStatus).toBe(400);
|
|
119
|
+
|
|
120
|
+
const succeeding = appRecording(() => Response.json({ isSuccess: true, data: {} }));
|
|
121
|
+
const http2 = createRequestHelper(succeeding.app, jwtStub().jwt);
|
|
122
|
+
expect(http2.queryErr("q.type", {}, user)).rejects.toThrow(
|
|
123
|
+
'Expected query "q.type" to fail but it succeeded',
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("batch posts the commands array unchanged to /api/batch", async () => {
|
|
128
|
+
const bodies: unknown[] = [];
|
|
129
|
+
const app = new Hono();
|
|
130
|
+
app.post("/api/batch", async (c) => {
|
|
131
|
+
bodies.push(await c.req.json());
|
|
132
|
+
return Response.json({ isSuccess: true, data: [] });
|
|
133
|
+
});
|
|
134
|
+
const http = createRequestHelper(app, jwtStub().jwt);
|
|
135
|
+
await http.batch(
|
|
136
|
+
[
|
|
137
|
+
{ type: "a", payload: { x: 1 } },
|
|
138
|
+
{ type: "b", payload: {} },
|
|
139
|
+
],
|
|
140
|
+
user,
|
|
141
|
+
"req-9",
|
|
142
|
+
);
|
|
143
|
+
expect(bodies[0]).toEqual({
|
|
144
|
+
commands: [
|
|
145
|
+
{ type: "a", payload: { x: 1 } },
|
|
146
|
+
{ type: "b", payload: {} },
|
|
147
|
+
],
|
|
148
|
+
requestId: "req-9",
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("extra headers are forwarded alongside the auth header", async () => {
|
|
153
|
+
const { app, calls } = appRecording(() => Response.json({ isSuccess: true, data: {} }));
|
|
154
|
+
const http = createRequestHelper(app, jwtStub().jwt);
|
|
155
|
+
await http.writeWithHeaders("a.b", {}, user, { "x-trace": "tr-1" });
|
|
156
|
+
expect(calls[0]!.extraHeaders["x-trace"]).toBe("tr-1");
|
|
157
|
+
expect(calls[0]!.auth).toMatch(/^Bearer /);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildMultipartBody } from "../multipart-helper";
|
|
3
|
+
|
|
4
|
+
describe("buildMultipartBody", () => {
|
|
5
|
+
test("serializes text fields so a Response can parse them back", async () => {
|
|
6
|
+
const fd = new FormData();
|
|
7
|
+
fd.set("title", "hello world");
|
|
8
|
+
fd.set("count", "42");
|
|
9
|
+
|
|
10
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
11
|
+
expect(contentType).toMatch(/^multipart\/form-data; boundary=kumikoBnd/i);
|
|
12
|
+
|
|
13
|
+
const parsed = await new Response(body, {
|
|
14
|
+
headers: { "content-type": contentType },
|
|
15
|
+
}).formData();
|
|
16
|
+
expect(parsed.get("title")).toBe("hello world");
|
|
17
|
+
expect(parsed.get("count")).toBe("42");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("carries file name and bytes through", async () => {
|
|
21
|
+
const fd = new FormData();
|
|
22
|
+
fd.set("doc", new File(["PDF-bytes"], "report.pdf", { type: "application/pdf" }));
|
|
23
|
+
|
|
24
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
25
|
+
const text = typeof body === "string" ? body : await new Response(body).text();
|
|
26
|
+
|
|
27
|
+
expect(contentType).toMatch(/^multipart\/form-data; boundary=kumikoBnd/i);
|
|
28
|
+
expect(text).toContain('filename="report.pdf"');
|
|
29
|
+
expect(text).toContain("PDF-bytes");
|
|
30
|
+
expect(text.endsWith(`--${contentType.split("boundary=")[1]}--\r\n`)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("mixed fields and files round-trip together", async () => {
|
|
34
|
+
const fd = new FormData();
|
|
35
|
+
fd.set("note", "attached");
|
|
36
|
+
fd.set("file", new File(["abc"], "a.txt"));
|
|
37
|
+
|
|
38
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
39
|
+
const parsed = await new Response(body, {
|
|
40
|
+
headers: { "content-type": contentType },
|
|
41
|
+
}).formData();
|
|
42
|
+
expect(parsed.get("note")).toBe("attached");
|
|
43
|
+
// Structural check: happy-dom and bun can expose different File realms,
|
|
44
|
+
// so instanceof would fail depending on file order in the same process.
|
|
45
|
+
const file = parsed.get("file") as { name?: string; size?: number } | null;
|
|
46
|
+
expect(file?.name).toBe("a.txt");
|
|
47
|
+
expect(file?.size).toBe(3);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
formatReport,
|
|
4
|
-
isRawSqlAllowed,
|
|
5
|
-
joinPath,
|
|
6
|
-
scanRepo,
|
|
7
|
-
toBaselineJson,
|
|
8
|
-
} from "../sql-inventory";
|
|
9
|
-
|
|
10
|
-
const cleanups: string[] = [];
|
|
11
|
-
|
|
12
|
-
afterEach(async () => {
|
|
13
|
-
for (const dir of cleanups) {
|
|
14
|
-
await Bun.spawn(["rm", "-rf", dir]).exited;
|
|
15
|
-
}
|
|
16
|
-
cleanups.length = 0;
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
async function tempRepo(files: Record<string, string>): Promise<string> {
|
|
20
|
-
const root = joinPath(import.meta.dir, `.tmp-sql-inv-${crypto.randomUUID()}`);
|
|
21
|
-
cleanups.push(root);
|
|
22
|
-
await Promise.all(
|
|
23
|
-
Object.entries(files).map(([rel, content]) => Bun.write(joinPath(root, rel), content)),
|
|
24
|
-
);
|
|
25
|
-
return root;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe("sql-inventory", () => {
|
|
29
|
-
test("isRawSqlAllowed permits db/queries and bun-db/query", () => {
|
|
30
|
-
expect(isRawSqlAllowed("/repo/packages/framework/src/db/queries/event-store.ts")).toBe(true);
|
|
31
|
-
expect(isRawSqlAllowed("/repo/packages/framework/src/bun-db/query.ts")).toBe(true);
|
|
32
|
-
expect(
|
|
33
|
-
isRawSqlAllowed("/repo/packages/bundled-features/src/sessions/db/queries/cleanup.ts"),
|
|
34
|
-
).toBe(true);
|
|
35
|
-
expect(isRawSqlAllowed("/repo/samples/apps/marketing-demo/src/db/queries/seed-counts.ts")).toBe(
|
|
36
|
-
true,
|
|
37
|
-
);
|
|
38
|
-
expect(isRawSqlAllowed("/repo/bin/commands/schema.ts")).toBe(true);
|
|
39
|
-
expect(isRawSqlAllowed("/repo/scripts/codemod-bun-db-swap.ts")).toBe(true);
|
|
40
|
-
expect(
|
|
41
|
-
isRawSqlAllowed("/repo/packages/bundled-features/src/sessions/handlers/cleanup.job.ts"),
|
|
42
|
-
).toBe(false);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("scanRepo classifies production vs test hits", async () => {
|
|
46
|
-
const root = await tempRepo({
|
|
47
|
-
"packages/framework/src/db/queries/demo.ts": `export async function x(db: unknown) {
|
|
48
|
-
return asRawClient(db).unsafe("SELECT 1");
|
|
49
|
-
}`,
|
|
50
|
-
"packages/framework/src/handlers/bad.ts": `export async function y(db: unknown) {
|
|
51
|
-
return asRawClient(db).unsafe("DELETE FROM read_users");
|
|
52
|
-
}`,
|
|
53
|
-
"packages/framework/src/__tests__/ok.integration.ts": `await asRawClient(db).unsafe("DELETE FROM read_users");`,
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const report = await scanRepo(root);
|
|
57
|
-
expect(report.summary.byBucket.allowed).toBeGreaterThanOrEqual(1);
|
|
58
|
-
expect(report.summary.byBucket.tests).toBeGreaterThanOrEqual(1);
|
|
59
|
-
expect(report.summary.byBucket.disallowed).toBeGreaterThanOrEqual(1);
|
|
60
|
-
expect(formatReport(report)).toContain("sql inventory");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("toBaselineJson normalizes machine-specific root + scannedAt, keeps the rest", async () => {
|
|
64
|
-
const root = await tempRepo({
|
|
65
|
-
"packages/framework/src/handlers/bad.ts": `export async function y(db: unknown) {
|
|
66
|
-
return asRawClient(db).unsafe("DELETE FROM read_users");
|
|
67
|
-
}`,
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
const report = await scanRepo(root);
|
|
71
|
-
expect(report.root).toBe(root);
|
|
72
|
-
expect(report.scannedAt).not.toBe("");
|
|
73
|
-
|
|
74
|
-
const parsed = JSON.parse(toBaselineJson(report));
|
|
75
|
-
expect(parsed.root).toBe(".");
|
|
76
|
-
expect(parsed.scannedAt).toBe("");
|
|
77
|
-
expect(parsed.root).not.toContain(root);
|
|
78
|
-
expect(parsed.summary.disallowed).toBe(report.summary.disallowed);
|
|
79
|
-
expect(parsed.hits).toHaveLength(report.hits.length);
|
|
80
|
-
});
|
|
81
|
-
});
|
package/src/db/sql-inventory.ts
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Raw-SQL inventory — shared allowlist for `kumiko sql-inventory` and
|
|
3
|
-
* `guard-raw-sql` (Phase 5). Scans TypeScript sources for escape-hatch patterns.
|
|
4
|
-
*
|
|
5
|
-
* Bun-only I/O: Bun.Glob + Bun.file (no node:fs, no node:path).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/** POSIX path join without Node path module. */
|
|
9
|
-
export function joinPath(base: string, ...segments: string[]): string {
|
|
10
|
-
return [base, ...segments]
|
|
11
|
-
.join("/")
|
|
12
|
-
.replace(/\/+/g, "/")
|
|
13
|
-
.replace(/\/\.\//g, "/");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type SqlInventoryKind = "unsafe" | "asRawClient" | "delete_from" | "execute";
|
|
17
|
-
|
|
18
|
-
export type SqlInventoryHit = {
|
|
19
|
-
readonly file: string;
|
|
20
|
-
readonly line: number;
|
|
21
|
-
readonly kind: SqlInventoryKind;
|
|
22
|
-
readonly allowed: boolean;
|
|
23
|
-
readonly snippet: string;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export type SqlInventoryReport = {
|
|
27
|
-
readonly scannedAt: string;
|
|
28
|
-
readonly root: string;
|
|
29
|
-
readonly hits: readonly SqlInventoryHit[];
|
|
30
|
-
readonly summary: {
|
|
31
|
-
readonly total: number;
|
|
32
|
-
readonly disallowed: number;
|
|
33
|
-
readonly byKind: Readonly<Record<SqlInventoryKind, number>>;
|
|
34
|
-
readonly byBucket: {
|
|
35
|
-
readonly allowed: number;
|
|
36
|
-
readonly tests: number;
|
|
37
|
-
readonly disallowed: number;
|
|
38
|
-
};
|
|
39
|
-
};
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
/** Paths where `.unsafe()` / `asRawClient()` are permitted (Phase 5 guard). */
|
|
43
|
-
export const RAW_SQL_ALLOWLIST: ReadonlyArray<RegExp> = [
|
|
44
|
-
/\/packages\/framework\/src\/db\/queries\//,
|
|
45
|
-
/\/packages\/framework\/src\/db\/migrate-runner\.ts$/,
|
|
46
|
-
/\/packages\/framework\/src\/db\/schema-inspection\.ts$/,
|
|
47
|
-
/\/packages\/framework\/src\/db\/render-ddl\.ts$/,
|
|
48
|
-
/\/packages\/framework\/src\/db\/sql-inventory\.ts$/,
|
|
49
|
-
/\/packages\/framework\/src\/bun-db\/query\.ts$/,
|
|
50
|
-
/\/packages\/framework\/src\/testing\//,
|
|
51
|
-
// ponytail: explicit enumeration — blanket regex auto-allowed any new .unsafe()
|
|
52
|
-
/\/bundled-features\/src\/billing-foundation\/db\/queries\/subscription-projection\.ts$/,
|
|
53
|
-
/\/bundled-features\/src\/config\/db\/queries\/resolver\.ts$/,
|
|
54
|
-
/\/bundled-features\/src\/custom-fields\/db\/queries\/field-access\.ts$/,
|
|
55
|
-
/\/bundled-features\/src\/custom-fields\/db\/queries\/projection\.ts$/,
|
|
56
|
-
/\/bundled-features\/src\/custom-fields\/db\/queries\/quota\.ts$/,
|
|
57
|
-
/\/bundled-features\/src\/custom-fields\/db\/queries\/retention\.ts$/,
|
|
58
|
-
/\/bundled-features\/src\/custom-fields\/db\/queries\/user-data-rights\.ts$/,
|
|
59
|
-
/\/bundled-features\/src\/delivery\/db\/queries\/preferences\.ts$/,
|
|
60
|
-
/\/bundled-features\/src\/form-draft\/db\/queries\/cleanup\.ts$/,
|
|
61
|
-
/\/bundled-features\/src\/form-draft\/db\/queries\/draft-count\.ts$/,
|
|
62
|
-
/\/bundled-features\/src\/form-draft\/db\/queries\/owned-file-refs\.ts$/,
|
|
63
|
-
/\/bundled-features\/src\/inbound-mail-foundation\/db\/queries\/inbound-projections\.ts$/,
|
|
64
|
-
/\/bundled-features\/src\/secrets\/db\/queries\/read\.ts$/,
|
|
65
|
-
/\/bundled-features\/src\/sessions\/db\/queries\/cleanup\.ts$/,
|
|
66
|
-
/\/bundled-features\/src\/user\/db\/queries\/stream-tenant-backfill\.ts$/,
|
|
67
|
-
/\/packages\/framework\/src\/engine\/steps\/unsafe-projection-/,
|
|
68
|
-
/\/samples\/(apps|recipes)\/[^/]+\/src\/db\/queries\//,
|
|
69
|
-
/\/bin\/commands\//,
|
|
70
|
-
/\/scripts\/codemod-/,
|
|
71
|
-
/\/__tests__\//,
|
|
72
|
-
/\/scripts\/sql-inventory\.ts$/,
|
|
73
|
-
/\/bin\/_lib\//,
|
|
74
|
-
];
|
|
75
|
-
|
|
76
|
-
const SCAN_DIRS = ["packages", "samples", "scripts", "bin"] as const;
|
|
77
|
-
|
|
78
|
-
const SKIP_PATH_PARTS = ["/node_modules/", "/dist/", "/.kumiko/"] as const;
|
|
79
|
-
|
|
80
|
-
const PATTERNS: ReadonlyArray<{ readonly kind: SqlInventoryKind; readonly re: RegExp }> = [
|
|
81
|
-
{ kind: "unsafe", re: /\.unsafe\s*\(/ },
|
|
82
|
-
{ kind: "asRawClient", re: /asRawClient\s*\(/ },
|
|
83
|
-
{ kind: "delete_from", re: /DELETE\s+FROM/i },
|
|
84
|
-
{ kind: "execute", re: /\.execute\s*\(/ },
|
|
85
|
-
];
|
|
86
|
-
|
|
87
|
-
const TS_GLOB = new Bun.Glob("**/*.{ts,tsx}");
|
|
88
|
-
|
|
89
|
-
function normalizePathForMatch(filePath: string): string {
|
|
90
|
-
return filePath.startsWith("/") ? filePath : `/${filePath}`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function isRawSqlAllowed(filePath: string): boolean {
|
|
94
|
-
const normalized = normalizePathForMatch(filePath);
|
|
95
|
-
return RAW_SQL_ALLOWLIST.some((re) => re.test(normalized));
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function isTestPath(filePath: string): boolean {
|
|
99
|
-
return /\/__tests__\//.test(normalizePathForMatch(filePath));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function bucketFor(hit: SqlInventoryHit): "allowed" | "tests" | "disallowed" {
|
|
103
|
-
if (isTestPath(hit.file)) return "tests";
|
|
104
|
-
if (hit.allowed) return "allowed";
|
|
105
|
-
return "disallowed";
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function shouldSkipRelativePath(rel: string): boolean {
|
|
109
|
-
return SKIP_PATH_PARTS.some((part) => rel.includes(part));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function directoryExists(path: string): boolean {
|
|
113
|
-
return Bun.spawnSync(["test", "-d", path]).exitCode === 0;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function collectTsFiles(repoRoot: string): Promise<string[]> {
|
|
117
|
-
const out: string[] = [];
|
|
118
|
-
for (const sub of SCAN_DIRS) {
|
|
119
|
-
const cwd = joinPath(repoRoot, sub);
|
|
120
|
-
if (!directoryExists(cwd)) continue;
|
|
121
|
-
for await (const rel of TS_GLOB.scan({ cwd, onlyFiles: true })) {
|
|
122
|
-
const normalized = rel.replace(/\0/g, "");
|
|
123
|
-
if (!normalized || shouldSkipRelativePath(normalized)) continue;
|
|
124
|
-
out.push(joinPath(sub, normalized));
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return out;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function scanFileText(relPath: string, text: string, hits: SqlInventoryHit[]): void {
|
|
131
|
-
const lines = text.split("\n");
|
|
132
|
-
for (let i = 0; i < lines.length; i++) {
|
|
133
|
-
const line = lines[i] ?? "";
|
|
134
|
-
const trimmed = line.trim();
|
|
135
|
-
if (
|
|
136
|
-
trimmed.startsWith("//") ||
|
|
137
|
-
trimmed.startsWith("*") ||
|
|
138
|
-
trimmed.startsWith("/**") ||
|
|
139
|
-
trimmed.startsWith("/*")
|
|
140
|
-
) {
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
for (const { kind, re } of PATTERNS) {
|
|
144
|
-
if (!re.test(line)) continue;
|
|
145
|
-
hits.push({
|
|
146
|
-
file: relPath,
|
|
147
|
-
line: i + 1,
|
|
148
|
-
kind,
|
|
149
|
-
allowed: isRawSqlAllowed(relPath),
|
|
150
|
-
snippet: trimmed.slice(0, 120),
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export async function scanRepo(repoRoot: string): Promise<SqlInventoryReport> {
|
|
157
|
-
const relFiles = await collectTsFiles(repoRoot);
|
|
158
|
-
const hits: SqlInventoryHit[] = [];
|
|
159
|
-
|
|
160
|
-
for (const rel of relFiles) {
|
|
161
|
-
const abs = joinPath(repoRoot, rel);
|
|
162
|
-
const text = await Bun.file(abs).text();
|
|
163
|
-
scanFileText(rel, text, hits);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const byKind: Record<SqlInventoryKind, number> = {
|
|
167
|
-
unsafe: 0,
|
|
168
|
-
asRawClient: 0,
|
|
169
|
-
delete_from: 0,
|
|
170
|
-
execute: 0,
|
|
171
|
-
};
|
|
172
|
-
let disallowed = 0;
|
|
173
|
-
const byBucket = { allowed: 0, tests: 0, disallowed: 0 };
|
|
174
|
-
for (const h of hits) {
|
|
175
|
-
byKind[h.kind]++;
|
|
176
|
-
const b = bucketFor(h);
|
|
177
|
-
byBucket[b]++;
|
|
178
|
-
if (b === "disallowed") disallowed++;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return {
|
|
182
|
-
scannedAt: new Date().toISOString(),
|
|
183
|
-
root: repoRoot,
|
|
184
|
-
hits,
|
|
185
|
-
summary: {
|
|
186
|
-
total: hits.length,
|
|
187
|
-
disallowed,
|
|
188
|
-
byKind,
|
|
189
|
-
byBucket,
|
|
190
|
-
},
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Serialize for the checked-in baseline. `root` (absolute scan path) and
|
|
195
|
-
// `scannedAt` (run timestamp) are machine-/run-specific noise that churned the
|
|
196
|
-
// baseline on every regen; --compare-baseline reads only summary.disallowed.
|
|
197
|
-
// Pin them to stable placeholders so the committed file is reproducible.
|
|
198
|
-
export function toBaselineJson(report: SqlInventoryReport): string {
|
|
199
|
-
const stable: SqlInventoryReport = { ...report, root: ".", scannedAt: "" };
|
|
200
|
-
return `${JSON.stringify(stable, null, 2)}\n`;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export function formatReport(report: SqlInventoryReport): string {
|
|
204
|
-
const lines: string[] = [
|
|
205
|
-
"--- sql inventory ---",
|
|
206
|
-
` scanned: ${report.scannedAt}`,
|
|
207
|
-
` root: ${report.root}`,
|
|
208
|
-
` total: ${report.summary.total}`,
|
|
209
|
-
` allowed: ${report.summary.byBucket.allowed}`,
|
|
210
|
-
` tests: ${report.summary.byBucket.tests}`,
|
|
211
|
-
` disallowed:${report.summary.disallowed}`,
|
|
212
|
-
` unsafe: ${report.summary.byKind.unsafe}`,
|
|
213
|
-
` asRawClient:${report.summary.byKind.asRawClient}`,
|
|
214
|
-
` DELETE FROM strings: ${report.summary.byKind.delete_from}`,
|
|
215
|
-
` .execute: ${report.summary.byKind.execute}`,
|
|
216
|
-
"---",
|
|
217
|
-
];
|
|
218
|
-
|
|
219
|
-
const bad = report.hits.filter((h) => bucketFor(h) === "disallowed");
|
|
220
|
-
if (bad.length === 0) {
|
|
221
|
-
lines.push(" (no disallowed production hits)");
|
|
222
|
-
} else {
|
|
223
|
-
lines.push(" disallowed (production):");
|
|
224
|
-
for (const h of bad.slice(0, 40)) {
|
|
225
|
-
lines.push(` ${h.kind.padEnd(12)} ${h.file}:${h.line} ${h.snippet}`);
|
|
226
|
-
}
|
|
227
|
-
if (bad.length > 40) {
|
|
228
|
-
lines.push(` … +${bad.length - 40} more`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return lines.join("\n");
|
|
232
|
-
}
|