@cosmicdrift/kumiko-guards 0.388.0 → 0.390.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 +3 -2
- package/package.json +3 -3
- package/src/_lib/sql-inventory.ts +1 -1
- package/src/_lib/test-es-raw-writes.ts +88 -0
- package/src/changes.json +62 -0
- package/src/check-as-casts.ts +2 -2
- package/src/check-complexity.ts +5 -4
- package/src/guard-admin-api.ts +1 -1
- package/src/guard-error-reasons.ts +17 -4
- package/src/guard-event-store-writes.ts +2 -2
- package/src/guard-import-extensions.ts +1 -1
- package/src/guard-internal-subpath-imports.ts +279 -0
- package/src/guard-no-date-api.ts +2 -2
- package/src/guard-no-direct-fs.ts +8 -2
- package/src/guard-restricted-symbols.ts +4 -2
- package/src/guard-rls-session-settings.ts +206 -0
- package/src/guard-schema-cli-features.ts +142 -0
- package/src/guard-silent-skip.ts +1 -1
- package/src/guard-test-es-fixtures.ts +83 -0
- package/src/guard-test-es-row-writes.ts +153 -0
- package/src/guard-unsafe-array-param.ts +110 -0
- package/src/pre-push.sh +15 -3
- package/src/run-guards.ts +8 -0
- package/src/run-repo-checks.ts +4 -0
|
@@ -92,7 +92,7 @@ const ALLOWLIST: readonly AllowEntry[] = [
|
|
|
92
92
|
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/migrations\/kumiko-drift\.ts$/ },
|
|
93
93
|
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/schema-cli(\.ts|\/[^/]+\.ts)$/ },
|
|
94
94
|
// same CLI-tooling pattern as schema-cli.ts — repo-tree/node_modules walking for the upgrade command, not app-level file storage
|
|
95
|
-
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/upgrade-cli
|
|
95
|
+
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/upgrade-cli(-internals)?\.ts$/ },
|
|
96
96
|
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/es-ops\/runner\.ts$/ },
|
|
97
97
|
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/db\/migrate-generator\.ts$/ },
|
|
98
98
|
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/db\/migrate-runner\.ts$/ },
|
|
@@ -103,7 +103,7 @@ const ALLOWLIST: readonly AllowEntry[] = [
|
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
105
|
repo: "kumiko-framework",
|
|
106
|
-
pattern: /^packages\/
|
|
106
|
+
pattern: /^packages\/feature-ast\/src\/codemod\/pipeline-codemod\.ts$/,
|
|
107
107
|
},
|
|
108
108
|
|
|
109
109
|
// server-runtime: prod bundle build + static asset delivery from the
|
|
@@ -115,6 +115,12 @@ const ALLOWLIST: readonly AllowEntry[] = [
|
|
|
115
115
|
repo: "kumiko-framework",
|
|
116
116
|
pattern: /^packages\/server-runtime\/src\/run-prod-app-static-files\.ts$/,
|
|
117
117
|
},
|
|
118
|
+
// Package lookup for the build and the dev server: existence check for
|
|
119
|
+
// node_modules/<pkg>/package.json above a directory, no request input as a path.
|
|
120
|
+
{
|
|
121
|
+
repo: "kumiko-framework",
|
|
122
|
+
pattern: /^packages\/server-runtime\/src\/resolve-installed-package\.ts$/,
|
|
123
|
+
},
|
|
118
124
|
|
|
119
125
|
// create-kumiko-app: Scaffolding-CLI.
|
|
120
126
|
{ repo: "kumiko-framework", pattern: /^packages\/create-kumiko-app\/src\/manifest\.ts$/ },
|
|
@@ -37,7 +37,7 @@ const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
|
|
|
37
37
|
const RESTRICTED_SYMBOLS = new Set([
|
|
38
38
|
"getUnscopedAggregateStreamMaxVersion",
|
|
39
39
|
"getUnscopedAggregateStreamTenant",
|
|
40
|
-
// Appends any event type without the feature-ownership check of ctx.
|
|
40
|
+
// Appends any event type without the feature-ownership check of ctx.events.append and without
|
|
41
41
|
// an audited unsafeRaw escape hatch, so new callers need an explicit allowlist entry.
|
|
42
42
|
"appendEventInTenantDb",
|
|
43
43
|
"getStreamVersionInTenantDb",
|
|
@@ -52,6 +52,8 @@ const ALLOWLIST = [
|
|
|
52
52
|
/^packages\/framework\/src\/event-store\/index\.ts$/,
|
|
53
53
|
/^packages\/framework\/src\/event-store\/tenant-db-append\.ts$/,
|
|
54
54
|
/^packages\/bundled-features\/src\/cap-counter\/cap-reservation\.ts$/,
|
|
55
|
+
// Catch-up of a missing terminal extract event in the caller tenant's own stream; JobContext has no events.unsafeAppend.
|
|
56
|
+
/^packages\/bundled-features\/src\/document-ingest-foundation\/append-missing-extract-event\.ts$/,
|
|
55
57
|
/^packages\/bundled-features\/src\/user-data-rights-defaults\/hooks\/delivery-attempt\.userdata-hook\.ts$/,
|
|
56
58
|
/^packages\/framework\/src\/db\/tenant-db\.ts$/,
|
|
57
59
|
/^packages\/framework\/src\/db\/index\.ts$/,
|
|
@@ -128,7 +130,7 @@ function findRestrictedSymbolReferences(sf: SourceFile): Violation[] {
|
|
|
128
130
|
export const guard: AstGuard = {
|
|
129
131
|
name: "Restricted-Symbols Guard",
|
|
130
132
|
scan: SCAN,
|
|
131
|
-
hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} is an existence oracle for foreign tenants — only seed-/system-internal code may reference them. appendEventInTenantDb/getStreamVersionInTenantDb bypass the feature-ownership check of ctx.
|
|
133
|
+
hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} is an existence oracle for foreign tenants — only seed-/system-internal code may reference them. appendEventInTenantDb/getStreamVersionInTenantDb bypass the feature-ownership check of ctx.events.append — only framework-internal coordination code may use them. createTenantDataDestroyDb grants direct writes on event-fed tables and belongs to the tenant-destroy stage only. withExecutorManagedWritesForTests is the test-seed seam behind seedRow/deleteRows and must not reach runtime code. New caller needed? Extend the allowlist in guard-restricted-symbols.ts, with a reason. Known gap: `export * from "...event-store"` is not detected (named imports, re-exports, and namespace property access are covered).',
|
|
132
134
|
run(files, roots = resolveRepoRoots()) {
|
|
133
135
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
134
136
|
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: row-level-security settings are transaction-local and written in one place.
|
|
4
|
+
*
|
|
5
|
+
* The scope lives in the `kumiko.*` settings that `packages/framework/src/db/queries/rls-scope.ts`
|
|
6
|
+
* sets per statement transaction. A session-level setting survives on a pooled connection and leaks
|
|
7
|
+
* one request's scope into the next, so in every source and test string:
|
|
8
|
+
*
|
|
9
|
+
* 1. `SET kumiko.…` / `SET SESSION kumiko.…` is flagged (use `SET LOCAL`).
|
|
10
|
+
* 2. Every `set_config` passes the literal `true` as is_local.
|
|
11
|
+
* 3. Outside rls-scope.ts, non-test code writes no `kumiko.*` setting at all, and a `set_config`
|
|
12
|
+
* with a dynamic setting name is flagged because it cannot be proven to leave `kumiko.*` alone.
|
|
13
|
+
*
|
|
14
|
+
* `.sql` files (consumer migrations) are checked statement by statement under the same rules.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* bun guards/guard-rls-session-settings.ts
|
|
18
|
+
*
|
|
19
|
+
* Exit 1 on violations, 0 when clean.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { Glob } from "bun";
|
|
24
|
+
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
25
|
+
import {
|
|
26
|
+
ALL_REPO_KINDS,
|
|
27
|
+
type AstGuard,
|
|
28
|
+
isAllowlisted,
|
|
29
|
+
relFromRepoRoot,
|
|
30
|
+
runStandalone,
|
|
31
|
+
type ScanSpec,
|
|
32
|
+
} from "./_lib/guard-kit";
|
|
33
|
+
import { resolveRepoRoots } from "./_lib/roots";
|
|
34
|
+
import { TEST_FILE_RE } from "./_lib/test-file";
|
|
35
|
+
|
|
36
|
+
const SCAN: ScanSpec = {
|
|
37
|
+
kinds: ALL_REPO_KINDS,
|
|
38
|
+
scope: "source+tests",
|
|
39
|
+
extensions: ["ts", "tsx"],
|
|
40
|
+
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const SCOPE_WRITER = [/^packages\/framework\/src\/db\/queries\/rls-scope\.ts$/];
|
|
44
|
+
|
|
45
|
+
// The assignment tail separates SQL from prose such as "Set kumiko.deploy.infraRef to an infra tag".
|
|
46
|
+
const ASSIGNMENT = String.raw`"?kumiko\.[\w.]+"?\s*(?:=|TO\s+['"$\d])`;
|
|
47
|
+
const SESSION_SET_RE = new RegExp(String.raw`\bSET\s+(?:SESSION\s+)?${ASSIGNMENT}`, "i");
|
|
48
|
+
const LOCAL_SET_RE = new RegExp(String.raw`\bSET\s+LOCAL\s+${ASSIGNMENT}`, "i");
|
|
49
|
+
const SET_CONFIG_CALL_RE = /\bset_config\s*\(/gi;
|
|
50
|
+
|
|
51
|
+
function isTestFile(rel: string): boolean {
|
|
52
|
+
return rel.split("/").includes("__tests__") || TEST_FILE_RE.test(rel);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const QUOTES = "'\"`";
|
|
56
|
+
const OPENERS = "([{";
|
|
57
|
+
const CLOSERS = ")]}";
|
|
58
|
+
|
|
59
|
+
// Top-level comma-separated arguments of the call whose "(" ends right before `from`.
|
|
60
|
+
function callArguments(text: string, from: number): string[] {
|
|
61
|
+
const args: string[] = [];
|
|
62
|
+
let depth = 0;
|
|
63
|
+
let quote: string | undefined;
|
|
64
|
+
let current = "";
|
|
65
|
+
for (let i = from; i < text.length; i++) {
|
|
66
|
+
const c = text.charAt(i);
|
|
67
|
+
if (quote !== undefined) {
|
|
68
|
+
current += c;
|
|
69
|
+
if (c === quote) quote = undefined;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (QUOTES.includes(c)) quote = c;
|
|
73
|
+
else if (OPENERS.includes(c)) depth++;
|
|
74
|
+
else if (CLOSERS.includes(c)) {
|
|
75
|
+
if (depth === 0) break;
|
|
76
|
+
depth--;
|
|
77
|
+
} else if (c === "," && depth === 0) {
|
|
78
|
+
args.push(current.trim());
|
|
79
|
+
current = "";
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
current += c;
|
|
83
|
+
}
|
|
84
|
+
args.push(current.trim());
|
|
85
|
+
return args;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function setConfigCalls(text: string): string[][] {
|
|
89
|
+
return [...text.matchAll(SET_CONFIG_CALL_RE)].map((m) =>
|
|
90
|
+
callArguments(text, m.index + m[0].length).map((arg) => arg.toLowerCase()),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isKumikoLiteral(arg: string | undefined): boolean {
|
|
95
|
+
return arg !== undefined && /^['"`]kumiko\./.test(arg);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isStringLiteralArgument(arg: string | undefined): boolean {
|
|
99
|
+
return arg !== undefined && /^['"`]/.test(arg);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function setConfigMessages(text: string, isScopeWriter: boolean, isTest: boolean): string[] {
|
|
103
|
+
const calls = setConfigCalls(text);
|
|
104
|
+
const messages: string[] = [];
|
|
105
|
+
if (calls.some((args) => args[2] !== "true")) {
|
|
106
|
+
messages.push(
|
|
107
|
+
"set_config must pass the literal true as is_local, anything else can be session-level",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (isScopeWriter || isTest) return messages;
|
|
111
|
+
if (calls.some((args) => isKumikoLiteral(args[0]))) {
|
|
112
|
+
messages.push("only rls-scope.ts may write a kumiko.* setting");
|
|
113
|
+
}
|
|
114
|
+
if (calls.some((args) => !isStringLiteralArgument(args[0]))) {
|
|
115
|
+
messages.push(
|
|
116
|
+
"set_config with a dynamic setting name cannot be proven to leave kumiko.* alone",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return messages;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function violationsInText(text: string, rel: string): string[] {
|
|
123
|
+
const isScopeWriter = isAllowlisted(rel, SCOPE_WRITER);
|
|
124
|
+
const isTest = isTestFile(rel);
|
|
125
|
+
const messages: string[] = [];
|
|
126
|
+
if (SESSION_SET_RE.test(text)) {
|
|
127
|
+
messages.push("SET / SET SESSION on a kumiko.* setting is session-level; use SET LOCAL");
|
|
128
|
+
}
|
|
129
|
+
if (!isScopeWriter && !isTest && LOCAL_SET_RE.test(text)) {
|
|
130
|
+
messages.push("only rls-scope.ts may write a kumiko.* setting");
|
|
131
|
+
}
|
|
132
|
+
return [...messages, ...setConfigMessages(text, isScopeWriter, isTest)];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isStringLike(node: Node): boolean {
|
|
136
|
+
return (
|
|
137
|
+
Node.isStringLiteral(node) ||
|
|
138
|
+
Node.isNoSubstitutionTemplateLiteral(node) ||
|
|
139
|
+
Node.isTemplateExpression(node)
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function findSessionSettings(sf: SourceFile, rel: string) {
|
|
144
|
+
const found: Array<{ line: number; message: string }> = [];
|
|
145
|
+
for (const node of sf.getDescendants()) {
|
|
146
|
+
if (!isStringLike(node)) continue;
|
|
147
|
+
// A template's own spans are visited as separate nodes; its full text is checked once here.
|
|
148
|
+
if (node.getParent()?.getKind() === SyntaxKind.TemplateSpan) continue;
|
|
149
|
+
for (const message of violationsInText(node.getText(), rel)) {
|
|
150
|
+
found.push({ line: node.getStartLineNumber(), message: `[rls-session-setting] ${message}` });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return found;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const SQL_IGNORED_DIRS = /(^|\/)(node_modules|\.wt|\.git|dist)(\/|$)/;
|
|
157
|
+
|
|
158
|
+
// Statements split on ";" with their start line; "--" comment lines are blanked so prose in them never matches.
|
|
159
|
+
export function findSqlSessionSettings(sql: string, rel: string) {
|
|
160
|
+
const found: Array<{ line: number; message: string }> = [];
|
|
161
|
+
const code = sql.replace(/^\s*--.*$/gm, "");
|
|
162
|
+
let line = 1;
|
|
163
|
+
for (const statement of code.split(";")) {
|
|
164
|
+
const startLine =
|
|
165
|
+
line + countNewlines(statement.slice(0, statement.length - statement.trimStart().length));
|
|
166
|
+
for (const message of violationsInText(statement, rel)) {
|
|
167
|
+
found.push({ line: startLine, message: `[rls-session-setting] ${message}` });
|
|
168
|
+
}
|
|
169
|
+
line += countNewlines(statement);
|
|
170
|
+
}
|
|
171
|
+
return found;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function countNewlines(text: string): number {
|
|
175
|
+
return text.split("\n").length - 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function sqlFilesOf(rootDir: string): string[] {
|
|
179
|
+
// skip: in-memory roots of guard tests have no directory
|
|
180
|
+
if (!existsSync(rootDir)) return [];
|
|
181
|
+
return [...new Glob("**/*.sql").scanSync({ cwd: rootDir })].filter(
|
|
182
|
+
(file) => !SQL_IGNORED_DIRS.test(file),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const guard: AstGuard = {
|
|
187
|
+
name: "RLS-Session-Settings Guard",
|
|
188
|
+
scan: SCAN,
|
|
189
|
+
hint: "Row-level-security settings must be transaction-local: use SET LOCAL or pass true as the third set_config argument. Only packages/framework/src/db/queries/rls-scope.ts writes the kumiko.* scope; tests may use SET LOCAL.",
|
|
190
|
+
run(files, roots = resolveRepoRoots()) {
|
|
191
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
192
|
+
for (const sf of files) {
|
|
193
|
+
const rel = relFromRepoRoot(sf.getFilePath(), roots);
|
|
194
|
+
for (const f of findSessionSettings(sf, rel)) violations.push({ file: rel, ...f });
|
|
195
|
+
}
|
|
196
|
+
for (const root of roots) {
|
|
197
|
+
for (const file of sqlFilesOf(root.absPath)) {
|
|
198
|
+
const sql = readFileSync(`${root.absPath}/${file}`, "utf8");
|
|
199
|
+
for (const f of findSqlSessionSettings(sql, file)) violations.push({ file, ...f });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { violations };
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: a consumer's `runSchemaCli(...)` call must pass `features: FEATURES` imported from `kumiko/schema`.
|
|
4
|
+
*
|
|
5
|
+
* `allAppFeatures(config)` is raw (no bundled foundation: config, user, tenant, auth-email-password).
|
|
6
|
+
* Only the managed `kumiko/schema.ts` composes it. With a raw or hand-built list the projection rebuild
|
|
7
|
+
* after the migrations fails at deploy with "Feature X requires feature Y". Repos without
|
|
8
|
+
* `kumiko/schema.ts` and the framework repo are not applicable. `composeEnvSchema` inputs are not checked.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join, relative } from "node:path";
|
|
13
|
+
import { Node, Project, type SourceFile, SyntaxKind } from "ts-morph";
|
|
14
|
+
import {
|
|
15
|
+
type GuardViolation,
|
|
16
|
+
type RepoCheck,
|
|
17
|
+
reportResults,
|
|
18
|
+
runRepoChecks,
|
|
19
|
+
} from "./_lib/guard-kit";
|
|
20
|
+
import type { RepoRoot } from "./_lib/roots";
|
|
21
|
+
import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
|
|
22
|
+
|
|
23
|
+
const SCAN: ScanSpec = { scope: "source", extensions: ["ts"], kinds: ["app", "library"] };
|
|
24
|
+
|
|
25
|
+
const SCHEMA_MODULE_REL = "kumiko/schema.ts";
|
|
26
|
+
const SCHEMA_MODULE_SPECIFIER = /(^|\/)kumiko\/schema(\.ts)?$/;
|
|
27
|
+
const SCHEMA_CLI_CALL = "runSchemaCli";
|
|
28
|
+
const OPTIONS_ARG_INDEX = 3;
|
|
29
|
+
const FEATURES_EXPORT = "FEATURES";
|
|
30
|
+
const FOUND_SNIPPET_MAX = 60;
|
|
31
|
+
|
|
32
|
+
const FIX_HINT =
|
|
33
|
+
'Fix: `import { FEATURES } from "../kumiko/schema";` and pass `features: FEATURES`. ' +
|
|
34
|
+
"allAppFeatures() is raw and a locally composed list drifts from kumiko/schema, which fails the rebuild at deploy.";
|
|
35
|
+
|
|
36
|
+
export type SchemaCliFinding = {
|
|
37
|
+
readonly line: number;
|
|
38
|
+
readonly message: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function importOf(sf: SourceFile, localName: string): { name: string; module: string } | undefined {
|
|
42
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
43
|
+
for (const spec of imp.getNamedImports()) {
|
|
44
|
+
if ((spec.getAliasNode()?.getText() ?? spec.getName()) === localName) {
|
|
45
|
+
return { name: spec.getName(), module: imp.getModuleSpecifierValue() };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isSchemaFeaturesImport(sf: SourceFile, localName: string): boolean {
|
|
53
|
+
const imported = importOf(sf, localName);
|
|
54
|
+
return (
|
|
55
|
+
imported !== undefined &&
|
|
56
|
+
imported.name === FEATURES_EXPORT &&
|
|
57
|
+
SCHEMA_MODULE_SPECIFIER.test(imported.module)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function snippet(node: Node): string {
|
|
62
|
+
const flat = node.getText().replace(/\s+/g, " ");
|
|
63
|
+
return flat.length > FOUND_SNIPPET_MAX ? `${flat.slice(0, FOUND_SNIPPET_MAX)}...` : flat;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function featuresValueOf(options: Node): Node | undefined | "no-literal" {
|
|
67
|
+
if (!Node.isObjectLiteralExpression(options)) return "no-literal";
|
|
68
|
+
const property = options.getProperty("features");
|
|
69
|
+
if (property === undefined) return undefined;
|
|
70
|
+
if (Node.isPropertyAssignment(property)) return property.getInitializer();
|
|
71
|
+
if (Node.isShorthandPropertyAssignment(property)) return property.getNameNode();
|
|
72
|
+
return "no-literal";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isRunSchemaCliCall(sf: SourceFile, callee: Node): boolean {
|
|
76
|
+
if (!Node.isIdentifier(callee)) return false;
|
|
77
|
+
const localName = callee.getText();
|
|
78
|
+
return (importOf(sf, localName)?.name ?? localName) === SCHEMA_CLI_CALL;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function problemOf(sf: SourceFile, optionsArg: Node | undefined): string | undefined {
|
|
82
|
+
if (optionsArg === undefined) {
|
|
83
|
+
return `${SCHEMA_CLI_CALL} has no options argument, so the projection rebuild is skipped`;
|
|
84
|
+
}
|
|
85
|
+
const value = featuresValueOf(optionsArg);
|
|
86
|
+
if (value === "no-literal") {
|
|
87
|
+
return `${SCHEMA_CLI_CALL} options are not an object literal with a \`features\` property`;
|
|
88
|
+
}
|
|
89
|
+
if (value === undefined) return `${SCHEMA_CLI_CALL} options have no \`features\` property`;
|
|
90
|
+
if (Node.isIdentifier(value) && isSchemaFeaturesImport(sf, value.getText())) return undefined;
|
|
91
|
+
return `${SCHEMA_CLI_CALL} gets \`features: ${snippet(value)}\`, not ${FEATURES_EXPORT} from kumiko/schema`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function findSchemaCliFeatureProblems(sf: SourceFile): SchemaCliFinding[] {
|
|
95
|
+
const findings: SchemaCliFinding[] = [];
|
|
96
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
97
|
+
if (!isRunSchemaCliCall(sf, call.getExpression())) continue;
|
|
98
|
+
const problem = problemOf(sf, call.getArguments()[OPTIONS_ARG_INDEX]);
|
|
99
|
+
if (problem !== undefined) {
|
|
100
|
+
findings.push({ line: call.getStartLineNumber(), message: `${problem}. ${FIX_HINT}` });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return findings;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hasSchemaModule(root: RepoRoot): boolean {
|
|
107
|
+
return existsSync(join(root.absPath, SCHEMA_MODULE_REL));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const check: RepoCheck = {
|
|
111
|
+
name: "Schema CLI Features Guard",
|
|
112
|
+
hint:
|
|
113
|
+
"Pass `features: FEATURES` imported from kumiko/schema to runSchemaCli; allAppFeatures() is raw " +
|
|
114
|
+
"and breaks the projection rebuild at deploy.",
|
|
115
|
+
run(roots) {
|
|
116
|
+
const applicable = roots.filter((root) => root.kind !== "framework" && hasSchemaModule(root));
|
|
117
|
+
if (applicable.length === 0) return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
118
|
+
|
|
119
|
+
// Manifest excludes must not hide bin/kumiko.ts: kumiko-studio and phronexsis exclude it from other guards.
|
|
120
|
+
const paths = scanFiles(SCAN, applicable, { security: true });
|
|
121
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true, useInMemoryFileSystem: true });
|
|
122
|
+
const violations: GuardViolation[] = [];
|
|
123
|
+
for (const absPath of paths) {
|
|
124
|
+
const text = readFileSync(absPath, "utf-8");
|
|
125
|
+
if (!text.includes(SCHEMA_CLI_CALL)) continue;
|
|
126
|
+
const sf = project.createSourceFile(absPath, text, { overwrite: true });
|
|
127
|
+
for (const finding of findSchemaCliFeatureProblems(sf)) {
|
|
128
|
+
violations.push({
|
|
129
|
+
file: relative(process.cwd(), absPath),
|
|
130
|
+
line: finding.line,
|
|
131
|
+
message: finding.message,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { violations, matchedFiles: paths.length, notApplicable: false };
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (import.meta.main) {
|
|
140
|
+
const failed = reportResults(await runRepoChecks([check]));
|
|
141
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
142
|
+
}
|
package/src/guard-silent-skip.ts
CHANGED
|
@@ -118,7 +118,7 @@ function classifyEnclosing(_ret: ReturnStatement, fn: Node): SkipSite["enclosing
|
|
|
118
118
|
* Policy: a bare `return;` needs one of the following markers right before it
|
|
119
119
|
* — otherwise it is flagged.
|
|
120
120
|
*
|
|
121
|
-
* (a) Log call: ctx.log
|
|
121
|
+
* (a) Log call: ctx.obs.log.debug(...), logger.debug(...), context.log?.info(...)
|
|
122
122
|
* (b) Skip comment: // skip: <reason>
|
|
123
123
|
* (c) Throw escape: preceded by a throw (the return is dead code, should go)
|
|
124
124
|
*
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: tests seed event-sourced state through the event store, not through
|
|
4
|
+
* hand-written SQL. Flags, in test files, string/template literals with
|
|
5
|
+
* (a) INSERT INTO / UPDATE against kumiko_events (use append / runSeedWritesAt)
|
|
6
|
+
* (b) CREATE TABLE ... read_tenant_memberships (use unsafeCreateEntityTable with
|
|
7
|
+
* tenantMembershipEntity and seedRow, so the schema cannot drift from the entity)
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* bun packages/guards/src/guard-test-es-fixtures.ts
|
|
11
|
+
*/
|
|
12
|
+
import type { SourceFile } from "ts-morph";
|
|
13
|
+
import { SyntaxKind } from "ts-morph";
|
|
14
|
+
import {
|
|
15
|
+
type AstGuard,
|
|
16
|
+
type GuardViolation,
|
|
17
|
+
relFromRepoRoot,
|
|
18
|
+
runStandalone,
|
|
19
|
+
type ScanSpec,
|
|
20
|
+
} from "./_lib/guard-kit";
|
|
21
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
22
|
+
|
|
23
|
+
const SCAN: ScanSpec = { scope: "tests", extensions: ["ts"] };
|
|
24
|
+
|
|
25
|
+
// Bulk seeding of thousands of events through append() would take minutes; these
|
|
26
|
+
// perf tests measure the read path on a finished stream, not the seed phase.
|
|
27
|
+
// The concurrent-commit ordering tests hold a transaction open and need explicit event
|
|
28
|
+
// ids/versions, which append() does not expose.
|
|
29
|
+
// The guards' own tests carry the forbidden SQL as fixture strings.
|
|
30
|
+
const ALLOWLIST: readonly RegExp[] = [
|
|
31
|
+
/^packages\/framework\/src\/event-store\/__tests__\/perf\.integration\.test\.ts$/,
|
|
32
|
+
/^packages\/framework\/src\/pipeline\/__tests__\/perf-rebuild\.integration\.test\.ts$/,
|
|
33
|
+
/^packages\/framework\/src\/pipeline\/__tests__\/event-dispatcher-commit-order\.integration\.test\.ts$/,
|
|
34
|
+
/^packages\/framework\/src\/pipeline\/__tests__\/event-dispatcher-start-from\.integration\.test\.ts$/,
|
|
35
|
+
/^packages\/framework\/src\/pipeline\/__tests__\/msp-rebuild\.integration\.test\.ts$/,
|
|
36
|
+
/^packages\/framework\/src\/pipeline\/__tests__\/projection-rebuild\.integration\.test\.ts$/,
|
|
37
|
+
/^packages\/guards\/src\/__tests__\//,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const EVENT_WRITE_RE = /\b(INSERT\s+INTO|UPDATE)\s+["`]?kumiko_events\b/i;
|
|
41
|
+
const MEMBERSHIP_DDL_RE = /\bCREATE\s+TABLE\b[^;]*?\bread_tenant_memberships\b/i;
|
|
42
|
+
|
|
43
|
+
const LITERAL_KINDS = [
|
|
44
|
+
SyntaxKind.StringLiteral,
|
|
45
|
+
SyntaxKind.NoSubstitutionTemplateLiteral,
|
|
46
|
+
SyntaxKind.TemplateExpression,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function violationMessage(text: string): string | undefined {
|
|
50
|
+
const write = EVENT_WRITE_RE.exec(text);
|
|
51
|
+
if (write)
|
|
52
|
+
return `raw ${write[0].replace(/\s+/g, " ")} in a test: seed via append() or runSeedWritesAt()`;
|
|
53
|
+
if (MEMBERSHIP_DDL_RE.test(text)) {
|
|
54
|
+
return "hand-written read_tenant_memberships DDL in a test: use unsafeCreateEntityTable(db, tenantMembershipEntity)";
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function collect(sourceFile: SourceFile, roots: readonly RepoRoot[]): GuardViolation[] {
|
|
60
|
+
const file = relFromRepoRoot(sourceFile.getFilePath(), roots);
|
|
61
|
+
if (ALLOWLIST.some((re) => re.test(file))) return [];
|
|
62
|
+
const violations: GuardViolation[] = [];
|
|
63
|
+
for (const kind of LITERAL_KINDS) {
|
|
64
|
+
for (const node of sourceFile.getDescendantsOfKind(kind)) {
|
|
65
|
+
const message = violationMessage(node.getText());
|
|
66
|
+
if (message) violations.push({ file, line: node.getStartLineNumber(), message });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return violations;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const guard: AstGuard = {
|
|
73
|
+
name: "Test-ES-Fixtures Guard",
|
|
74
|
+
scan: SCAN,
|
|
75
|
+
hint:
|
|
76
|
+
"Seed events with append() (runSeedWritesAt for backdating) and membership rows with " +
|
|
77
|
+
"seedRow on a table made by unsafeCreateEntityTable; bulk perf seeds belong in the guard allowlist with a Why.",
|
|
78
|
+
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
79
|
+
return { violations: files.flatMap((sf) => collect(sf, roots)) };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: framework tests do not write event-sourced rows through the seed bypass.
|
|
4
|
+
* Flags calls to updateRows/deleteRows imported from kumiko-testing/helpers, seedRow on
|
|
5
|
+
* an entity table the file builds itself, and raw DELETE/UPDATE SQL on read_ tables,
|
|
6
|
+
* kumiko_events or such an entity table: they change the projection only, so the event
|
|
7
|
+
* log drifts from it and a rebuild undoes the write.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* bun packages/guards/src/guard-test-es-row-writes.ts
|
|
11
|
+
*/
|
|
12
|
+
import type { SourceFile } from "ts-morph";
|
|
13
|
+
import { Node, SyntaxKind } from "ts-morph";
|
|
14
|
+
import {
|
|
15
|
+
type AstGuard,
|
|
16
|
+
type GuardViolation,
|
|
17
|
+
relFromRepoRoot,
|
|
18
|
+
runStandalone,
|
|
19
|
+
type ScanSpec,
|
|
20
|
+
} from "./_lib/guard-kit";
|
|
21
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
22
|
+
import { findRawSqlWrites, findSeedRowOnEntityTables } from "./_lib/test-es-raw-writes";
|
|
23
|
+
|
|
24
|
+
// Framework repo only: the consumer repos still call updateRows/deleteRows in their
|
|
25
|
+
// tests, so rolling this out there needs a migration of those call sites first.
|
|
26
|
+
const SCAN: ScanSpec = { scope: "tests", extensions: ["ts"], kinds: ["framework"] };
|
|
27
|
+
|
|
28
|
+
const ALLOWLIST: readonly RegExp[] = [
|
|
29
|
+
// Home of the helpers.
|
|
30
|
+
/^packages\/testing\/src\//,
|
|
31
|
+
// The guard's own fixtures.
|
|
32
|
+
/^packages\/guards\/src\/__tests__\//,
|
|
33
|
+
// Simulates event-less legacy rows for the backfill.
|
|
34
|
+
/^packages\/bundled-features\/src\/user-data-rights\/__tests__\/read-users-rebuild-survives-lifecycle\.integration\.test\.ts$/,
|
|
35
|
+
// Stands in for the handover move, which itself writes directly.
|
|
36
|
+
/^packages\/bundled-features\/src\/files-tenant-data\/__tests__\/hooks\.integration\.test\.ts$/,
|
|
37
|
+
// Corrupts ciphertext on the projection to exercise the skip path.
|
|
38
|
+
/^packages\/bundled-features\/src\/inbound-mail-foundation\/__tests__\/watch-supervisor\.integration\.test\.ts$/,
|
|
39
|
+
// Primitive test of the TenantDb where-merge.
|
|
40
|
+
/^packages\/framework\/src\/db\/__tests__\/tenant-db-where-merge\.integration\.test\.ts$/,
|
|
41
|
+
// The entity cache is invalidated on the helper path, so only a projection-only write leaves it stale.
|
|
42
|
+
/^packages\/framework\/src\/__tests__\/full-stack\.integration\.test\.ts$/,
|
|
43
|
+
// Corrupt, drift or poison a projection row on purpose to exercise the read, repair or skip path.
|
|
44
|
+
/^packages\/bundled-features\/src\/auth-mfa\/__tests__\/reencrypt-job\.integration\.test\.ts$/,
|
|
45
|
+
/^packages\/bundled-features\/src\/config\/__tests__\/config\.integration\.test\.ts$/,
|
|
46
|
+
/^packages\/bundled-features\/src\/custom-fields\/__tests__\/custom-fields\.integration\.test\.ts$/,
|
|
47
|
+
/^packages\/bundled-features\/src\/custom-fields\/__tests__\/field-access\.integration\.test\.ts$/,
|
|
48
|
+
/^packages\/bundled-features\/src\/inbound-mail-foundation\/__tests__\/inbound-mail-foundation\.integration\.test\.ts$/,
|
|
49
|
+
/^packages\/framework\/src\/db\/__tests__\/event-store-executor\.integration\.test\.ts$/,
|
|
50
|
+
/^packages\/framework\/src\/db\/__tests__\/event-store-executor-write-verbs\.integration\.test\.ts$/,
|
|
51
|
+
/^packages\/framework\/src\/event-store\/__tests__\/backfill-pii\.integration\.test\.ts$/,
|
|
52
|
+
/^packages\/framework\/src\/pipeline\/__tests__\/search-consumer-named-events\.integration\.test\.ts$/,
|
|
53
|
+
/^packages\/framework\/src\/search\/__tests__\/search-pii-derived-index\.integration\.test\.ts$/,
|
|
54
|
+
// Seed an event-less legacy row that the code under test must adopt or upcast.
|
|
55
|
+
/^packages\/framework\/src\/__tests__\/reference-data\.integration\.test\.ts$/,
|
|
56
|
+
/^packages\/framework\/src\/pipeline\/__tests__\/load-aggregate-query\.integration\.test\.ts$/,
|
|
57
|
+
// Backdate timestamps that no command can set, to drive retention and cleanup crons.
|
|
58
|
+
/^packages\/bundled-features\/src\/custom-fields\/__tests__\/retention\.integration\.test\.ts$/,
|
|
59
|
+
/^packages\/bundled-features\/src\/form-draft\/__tests__\/cleanup\.integration\.test\.ts$/,
|
|
60
|
+
/^packages\/bundled-features\/src\/form-draft\/__tests__\/feature-optional-config\.integration\.test\.ts$/,
|
|
61
|
+
/^packages\/server-runtime\/src\/__tests__\/bundled\/tier-engine-resolver\.integration\.test\.ts$/,
|
|
62
|
+
/^samples\/recipes\/public-share-token\/src\/__tests__\/feature\.integration\.test\.ts$/,
|
|
63
|
+
// App user-data delete hooks and probes that stand in for app code deleting its own rows.
|
|
64
|
+
/^packages\/bundled-features\/src\/custom-fields\/__tests__\/user-data-rights\.integration\.test\.ts$/,
|
|
65
|
+
/^packages\/bundled-features\/src\/crypto-shredding\/__tests__\/forget-subject-record\.integration\.test\.ts$/,
|
|
66
|
+
/^packages\/bundled-features\/src\/user-data-rights\/__tests__\/forget-cleanup-search-purge-ordering\.integration\.test\.ts$/,
|
|
67
|
+
// Rebuild scenarios wipe the projection while the events stay.
|
|
68
|
+
/^packages\/bundled-features\/src\/jobs\/__tests__\/projection-rebuild-job\.integration\.test\.ts$/,
|
|
69
|
+
/^packages\/framework\/src\/pipeline\/__tests__\/projection-rebuild\.integration\.test\.ts$/,
|
|
70
|
+
// Deletes rows between pages or batches to simulate concurrent removal for the pager and reindex.
|
|
71
|
+
/^packages\/framework\/src\/db\/__tests__\/event-store-executor-list\.integration\.test\.ts$/,
|
|
72
|
+
/^packages\/framework\/src\/search\/__tests__\/reindex-entity\.integration\.test\.ts$/,
|
|
73
|
+
// Plants projection rows without events because detecting them is the behavior under test.
|
|
74
|
+
/^packages\/framework\/src\/db\/__tests__\/assert-no-unreachable-live-rows\.integration\.test\.ts$/,
|
|
75
|
+
// Primitive db tests of the write lock, inserts, codecs and migrations below the executor.
|
|
76
|
+
/^packages\/framework\/src\/db\/__tests__\/executor-managed-write-lock\.integration\.test\.ts$/,
|
|
77
|
+
/^packages\/framework\/src\/db\/__tests__\/multi-row-insert\.integration\.test\.ts$/,
|
|
78
|
+
/^packages\/framework\/src\/db\/__tests__\/number-field-fractional\.integration\.test\.ts$/,
|
|
79
|
+
/^packages\/framework\/src\/db\/__tests__\/query\/write-brand\.test\.ts$/,
|
|
80
|
+
/^packages\/framework\/src\/db\/__tests__\/schema-migration\.integration\.test\.ts$/,
|
|
81
|
+
/^packages\/framework\/src\/db\/__tests__\/source-shadow-create\.integration\.test\.ts$/,
|
|
82
|
+
// A hook writes a marker row through its TenantDb, which the event-store helpers do not take.
|
|
83
|
+
/^packages\/bundled-features\/src\/feature-toggles\/__tests__\/feature-toggles\.integration\.test\.ts$/,
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const BYPASS_NAMES: ReadonlySet<string> = new Set(["updateRows", "deleteRows"]);
|
|
87
|
+
const SEED_ROW_NAMES: ReadonlySet<string> = new Set(["seedRow"]);
|
|
88
|
+
const TESTING_HELPERS_RE =
|
|
89
|
+
/^@cosmicdrift\/kumiko-testing\/helpers$|(^|\/)testing\/src\/helpers(\/|$)/;
|
|
90
|
+
const MESSAGE =
|
|
91
|
+
"updateRows/deleteRows bypass the event store: use the real command, seedUser, " +
|
|
92
|
+
"updateRowViaEventStore/deleteRowViaEventStore, or updateMany/deleteMany on a Writable table";
|
|
93
|
+
|
|
94
|
+
type HelperBindings = {
|
|
95
|
+
readonly wanted: ReadonlySet<string>;
|
|
96
|
+
readonly names: Set<string>;
|
|
97
|
+
readonly namespaces: Set<string>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function helperBindings(sourceFile: SourceFile, wanted: ReadonlySet<string>): HelperBindings {
|
|
101
|
+
const names = new Set<string>();
|
|
102
|
+
const namespaces = new Set<string>();
|
|
103
|
+
for (const decl of sourceFile.getImportDeclarations()) {
|
|
104
|
+
if (!TESTING_HELPERS_RE.test(decl.getModuleSpecifierValue())) continue;
|
|
105
|
+
for (const spec of decl.getNamedImports()) {
|
|
106
|
+
if (wanted.has(spec.getName())) names.add(spec.getAliasNode()?.getText() ?? spec.getName());
|
|
107
|
+
}
|
|
108
|
+
const namespace = decl.getNamespaceImport();
|
|
109
|
+
if (namespace) namespaces.add(namespace.getText());
|
|
110
|
+
}
|
|
111
|
+
return { wanted, names, namespaces };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isHelperCallee(callee: Node, bindings: HelperBindings): boolean {
|
|
115
|
+
if (Node.isIdentifier(callee)) return bindings.names.has(callee.getText());
|
|
116
|
+
if (!Node.isPropertyAccessExpression(callee)) return false;
|
|
117
|
+
const target = callee.getExpression();
|
|
118
|
+
return (
|
|
119
|
+
bindings.wanted.has(callee.getName()) &&
|
|
120
|
+
Node.isIdentifier(target) &&
|
|
121
|
+
bindings.namespaces.has(target.getText())
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function collect(sourceFile: SourceFile, roots: readonly RepoRoot[]): GuardViolation[] {
|
|
126
|
+
const file = relFromRepoRoot(sourceFile.getFilePath(), roots);
|
|
127
|
+
if (ALLOWLIST.some((re) => re.test(file))) return [];
|
|
128
|
+
const bypass = helperBindings(sourceFile, BYPASS_NAMES);
|
|
129
|
+
const seedRow = helperBindings(sourceFile, SEED_ROW_NAMES);
|
|
130
|
+
const bypassCalls = sourceFile
|
|
131
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
132
|
+
.filter((call) => isHelperCallee(call.getExpression(), bypass))
|
|
133
|
+
.map((call) => ({ line: call.getStartLineNumber(), message: MESSAGE }));
|
|
134
|
+
return [
|
|
135
|
+
...bypassCalls,
|
|
136
|
+
...findSeedRowOnEntityTables(sourceFile, (callee) => isHelperCallee(callee, seedRow)),
|
|
137
|
+
...findRawSqlWrites(sourceFile),
|
|
138
|
+
].map((finding) => ({ file, ...finding }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const guard: AstGuard = {
|
|
142
|
+
name: "Test-ES-Row-Writes Guard",
|
|
143
|
+
scan: SCAN,
|
|
144
|
+
hint:
|
|
145
|
+
"Change event-sourced rows through the real command, seedUser({...}), seedRowViaEventStore or " +
|
|
146
|
+
"updateRowViaEventStore/deleteRowViaEventStore, reset them with resetTestTables plus eventsTable; " +
|
|
147
|
+
"use updateMany/deleteMany for non-event-sourced tables.",
|
|
148
|
+
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
149
|
+
return { violations: files.flatMap((sf) => collect(sf, roots)) };
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (import.meta.main) runStandalone(guard);
|