@cosmicdrift/kumiko-guards 0.354.1 → 0.355.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/package.json +2 -2
- package/src/_lib/guard-kit.ts +13 -5
- package/src/_lib/roots.ts +8 -0
- package/src/_lib/scan-scope.ts +40 -8
- package/src/changes.json +12 -0
- package/src/guard-direct-entity-writes.ts +83 -35
- package/src/guard-i18n-locale-mount.ts +114 -47
- package/src/guard-no-direct-fs.ts +10 -2
- package/src/guard-tenant-escalation.ts +6 -46
- package/src/guard-test-timeouts.ts +61 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.355.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.355.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
type RootResolution,
|
|
25
25
|
resolveRepoRoots,
|
|
26
26
|
} from "./roots";
|
|
27
|
-
import { type RootScan, type ScanSpec, scanFiles, scanRoots } from "./scan-scope";
|
|
27
|
+
import { type RootScan, type ScanOptions, type ScanSpec, scanFiles, scanRoots } from "./scan-scope";
|
|
28
28
|
import {
|
|
29
29
|
applySecurityBaseline,
|
|
30
30
|
loadSecurityBaseline,
|
|
@@ -63,6 +63,10 @@ export function isSecurityGuard(guard: Pick<AstGuard, "security">): boolean {
|
|
|
63
63
|
return guard.security === true;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function scanOptionsFor(guard: Pick<AstGuard, "security">): ScanOptions {
|
|
67
|
+
return { security: isSecurityGuard(guard) };
|
|
68
|
+
}
|
|
69
|
+
|
|
66
70
|
// Followers call the predicate per import hop; findLocalRepo re-reads
|
|
67
71
|
// package.json and the manifest each time.
|
|
68
72
|
const localRepoRootByCwd = new Map<string, string | undefined>();
|
|
@@ -126,7 +130,7 @@ export function buildSharedProject(
|
|
|
126
130
|
...new Set(
|
|
127
131
|
guards.flatMap((guard) => {
|
|
128
132
|
try {
|
|
129
|
-
return scanFiles(guard.scan, roots);
|
|
133
|
+
return scanFiles(guard.scan, roots, scanOptionsFor(guard));
|
|
130
134
|
} catch {
|
|
131
135
|
return [];
|
|
132
136
|
}
|
|
@@ -143,7 +147,7 @@ export function filesForGuard(
|
|
|
143
147
|
guard: AstGuard,
|
|
144
148
|
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
145
149
|
): SourceFile[] {
|
|
146
|
-
return scanFiles(guard.scan, roots).map(
|
|
150
|
+
return scanFiles(guard.scan, roots, scanOptionsFor(guard)).map(
|
|
147
151
|
(path) => project.getSourceFile(path) ?? project.addSourceFileAtPath(path),
|
|
148
152
|
);
|
|
149
153
|
}
|
|
@@ -366,7 +370,9 @@ export function runGuards(
|
|
|
366
370
|
): RunResult[] {
|
|
367
371
|
const results: RunResult[] = [];
|
|
368
372
|
const scan =
|
|
369
|
-
deps.scan ??
|
|
373
|
+
deps.scan ??
|
|
374
|
+
((guard: AstGuard, roots: readonly RepoRoot[]) =>
|
|
375
|
+
scanRoots(guard.scan, roots, scanOptionsFor(guard)));
|
|
370
376
|
const roots = deps.roots ?? resolveRepoRoots();
|
|
371
377
|
|
|
372
378
|
for (const guard of guards) {
|
|
@@ -505,7 +511,9 @@ export function explainGuards(
|
|
|
505
511
|
deps: ExplainGuardsDeps = {},
|
|
506
512
|
): string[] {
|
|
507
513
|
const scan =
|
|
508
|
-
deps.scan ??
|
|
514
|
+
deps.scan ??
|
|
515
|
+
((guard: AstGuard, roots: readonly RepoRoot[]) =>
|
|
516
|
+
scanRoots(guard.scan, roots, scanOptionsFor(guard)));
|
|
509
517
|
const { roots } = deps.resolution ?? explainRepoRoots();
|
|
510
518
|
const lines = [
|
|
511
519
|
`Repo: ${roots[0]?.root.absPath ?? "— none found (no package.json with kumiko.json/src layout above cwd)"}`,
|
package/src/_lib/roots.ts
CHANGED
|
@@ -24,6 +24,9 @@ export type { RepoKind } from "@cosmicdrift/kumiko-repo-manifest";
|
|
|
24
24
|
|
|
25
25
|
import type { RepoKind } from "@cosmicdrift/kumiko-repo-manifest";
|
|
26
26
|
|
|
27
|
+
// Worktrees (`.wt/kumiko-framework-*`) keep this package name, so it is stable across checkouts.
|
|
28
|
+
const FRAMEWORK_PACKAGE_NAME = "kumiko-framework";
|
|
29
|
+
|
|
27
30
|
export type RepoRoot = {
|
|
28
31
|
/** Repo identifier — the package.json `name`. */
|
|
29
32
|
readonly name: string;
|
|
@@ -150,6 +153,11 @@ function repoAt(dir: string): RepoRoot | undefined {
|
|
|
150
153
|
const name = pkg.name;
|
|
151
154
|
if (typeof name !== "string") return undefined;
|
|
152
155
|
const loaded = manifestRootAt(dir);
|
|
156
|
+
if (loaded?.manifest.kind === "framework" && name !== FRAMEWORK_PACKAGE_NAME) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`${join(dir, "kumiko.json")}: kind "framework" is reserved for the ${FRAMEWORK_PACKAGE_NAME} repo (package name is "${name}"); it narrows scans via frameworkWithin and must not be self-declared`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
153
161
|
return loaded ? rootFrom(name, dir, loaded) : undefined;
|
|
154
162
|
}
|
|
155
163
|
|
package/src/_lib/scan-scope.ts
CHANGED
|
@@ -124,6 +124,27 @@ function extraHits(root: RepoRoot, globs: readonly string[] | undefined): Hit[]
|
|
|
124
124
|
return [...byRepoRel.values()];
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Security guards honour only these: a repo manifest must not be able to switch a security scan off for its own files.
|
|
128
|
+
export const SECURITY_GUARD_HONORED_EXCLUDES: readonly string[] = [
|
|
129
|
+
"**/node_modules/**",
|
|
130
|
+
"**/dist/**",
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
export type ScanOptions = {
|
|
134
|
+
/** Security guards ignore manifest excludes beyond SECURITY_GUARD_HONORED_EXCLUDES. */
|
|
135
|
+
readonly security?: boolean;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
function effectiveExcludes(
|
|
139
|
+
root: RepoRoot,
|
|
140
|
+
options: ScanOptions | undefined,
|
|
141
|
+
): readonly string[] | undefined {
|
|
142
|
+
if (options?.security !== true) return root.manifest.excludes;
|
|
143
|
+
return (root.manifest.excludes ?? []).filter((pattern) =>
|
|
144
|
+
SECURITY_GUARD_HONORED_EXCLUDES.includes(pattern),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
127
148
|
function afterExcludes(hits: readonly Hit[], excludes: readonly string[] | undefined): Hit[] {
|
|
128
149
|
if (!excludes || excludes.length === 0) return [...hits];
|
|
129
150
|
return hits.filter((hit) => !matchesAny(hit.repoRel, excludes));
|
|
@@ -144,9 +165,10 @@ function keepForSpec(hit: Hit, root: RepoRoot, spec: ScanSpec): boolean {
|
|
|
144
165
|
return true;
|
|
145
166
|
}
|
|
146
167
|
|
|
147
|
-
function scanRoot(spec: ScanSpec, root: RepoRoot): RootScan {
|
|
148
|
-
const
|
|
149
|
-
const
|
|
168
|
+
function scanRoot(spec: ScanSpec, root: RepoRoot, options?: ScanOptions): RootScan {
|
|
169
|
+
const excludes = effectiveExcludes(root, options);
|
|
170
|
+
const sourceSurfaceHits = afterExcludes(sourceHits(root), excludes);
|
|
171
|
+
const testSurfaceHits = () => afterExcludes(testHits(root), excludes);
|
|
150
172
|
const scopeHits =
|
|
151
173
|
spec.scope === "source"
|
|
152
174
|
? sourceSurfaceHits
|
|
@@ -159,7 +181,7 @@ function scanRoot(spec: ScanSpec, root: RepoRoot): RootScan {
|
|
|
159
181
|
seen.add(hit.repoRel);
|
|
160
182
|
return true;
|
|
161
183
|
});
|
|
162
|
-
const additionalHits = afterExcludes(extraHits(root, spec.extraGlobs),
|
|
184
|
+
const additionalHits = afterExcludes(extraHits(root, spec.extraGlobs), excludes);
|
|
163
185
|
const files = [...uniqueScopeHits, ...additionalHits.filter((hit) => !seen.has(hit.repoRel))]
|
|
164
186
|
.filter((hit) => keepForSpec(hit, root, spec))
|
|
165
187
|
.map((hit) => join(root.absPath, hit.repoRel))
|
|
@@ -172,10 +194,20 @@ export function keepsRootKind(spec: ScanSpec, root: RepoRoot): boolean {
|
|
|
172
194
|
return root.kind !== "tooling";
|
|
173
195
|
}
|
|
174
196
|
|
|
175
|
-
export function scanRoots(
|
|
176
|
-
|
|
197
|
+
export function scanRoots(
|
|
198
|
+
spec: ScanSpec,
|
|
199
|
+
roots: readonly RepoRoot[],
|
|
200
|
+
options?: ScanOptions,
|
|
201
|
+
): RootScan[] {
|
|
202
|
+
return roots
|
|
203
|
+
.filter((root) => keepsRootKind(spec, root))
|
|
204
|
+
.map((root) => scanRoot(spec, root, options));
|
|
177
205
|
}
|
|
178
206
|
|
|
179
|
-
export function scanFiles(
|
|
180
|
-
|
|
207
|
+
export function scanFiles(
|
|
208
|
+
spec: ScanSpec,
|
|
209
|
+
roots: readonly RepoRoot[],
|
|
210
|
+
options?: ScanOptions,
|
|
211
|
+
): string[] {
|
|
212
|
+
return [...new Set(scanRoots(spec, roots, options).flatMap((rootScan) => rootScan.files))].sort();
|
|
181
213
|
}
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.355.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Security guards ignore kumiko.json excludes beyond node_modules and dist; kind framework is reserved for kumiko-framework",
|
|
6
|
+
"migration": "Security guards (No-Direct-Fs, Direct-Entity-Writes, Direct-Fetch, Tenant-Escalation, Admin-API, Access-Denied-Test, Open-To-All-Reason, Escape-Hatch-Declared) now scan files matched by your kumiko.json excludes; fix the findings or add a precise guard allowlist entry. A kumiko.json with kind framework outside the kumiko-framework package now fails root resolution; use library or app. Direct-Entity-Writes now sees table arguments behind casts (`table as T`, parentheses, non-null), so a cast no longer hides a direct write. i18n-Locale-Mount handles conditional spreads, alias depth and circular constants."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.355.0",
|
|
10
|
+
"type": "breaking",
|
|
11
|
+
"title": "test-timeouts guard flags timeout arguments on test(), it() and describe()",
|
|
12
|
+
"migration": "Remove the timeout argument from test/it/describe calls (test(name, fn, 30_000), test(name, fn, { timeout }), also via .skip/.only/.if()/.each()) and fix the cause: poll with waitFor/expect.poll, shrink the data set, share expensive setup in beforeAll. setDefaultTimeout and describe.configure through renamed or namespace bun:test imports are flagged too. A justified remainder gets '// @timeout-exception: #<issue> <reason>' on the line above the call."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.354.0",
|
|
4
16
|
"type": "improvement",
|
|
@@ -44,13 +44,7 @@
|
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
46
|
import * as path from "node:path";
|
|
47
|
-
import {
|
|
48
|
-
type CallExpression,
|
|
49
|
-
type Identifier,
|
|
50
|
-
type Node,
|
|
51
|
-
type SourceFile,
|
|
52
|
-
SyntaxKind,
|
|
53
|
-
} from "ts-morph";
|
|
47
|
+
import { type CallExpression, type Identifier, Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
54
48
|
import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
55
49
|
|
|
56
50
|
const ROOT = process.cwd();
|
|
@@ -94,6 +88,61 @@ const FN_WRITE_HELPERS = new Map<string, "insert" | "update" | "delete">([
|
|
|
94
88
|
["deleteManyBatched", "delete"],
|
|
95
89
|
]);
|
|
96
90
|
|
|
91
|
+
// Writes that are deliberately outside the event-store executor. Match on file + enclosing function + table so the exception cannot widen silently.
|
|
92
|
+
type DirectWriteException = {
|
|
93
|
+
readonly file: RegExp;
|
|
94
|
+
readonly functionName: string;
|
|
95
|
+
readonly table: string;
|
|
96
|
+
readonly reason: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const DIRECT_WRITE_EXCEPTIONS: readonly DirectWriteException[] = [
|
|
100
|
+
{
|
|
101
|
+
file: /(^|\/)packages\/bundled-features\/src\/template-resolver\/seeding\.ts$/,
|
|
102
|
+
functionName: "resolveExistingForEventStoreSeed",
|
|
103
|
+
table: "templateResourcesTable",
|
|
104
|
+
reason: "Orphan projection row without events, no event-store verb possible",
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
// First named container: `function f`, `const f = () =>`, or `run: () =>` in an object literal.
|
|
109
|
+
function enclosingFunctionName(node: Node): string | undefined {
|
|
110
|
+
for (const ancestor of node.getAncestors()) {
|
|
111
|
+
if (Node.isFunctionDeclaration(ancestor)) return ancestor.getName();
|
|
112
|
+
if (Node.isVariableDeclaration(ancestor) || Node.isPropertyAssignment(ancestor)) {
|
|
113
|
+
return ancestor.getName();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isDirectWriteException(call: CallExpression, table: string): boolean {
|
|
120
|
+
const functionName = enclosingFunctionName(call);
|
|
121
|
+
if (functionName === undefined) return false;
|
|
122
|
+
const file = call.getSourceFile().getFilePath();
|
|
123
|
+
return DIRECT_WRITE_EXCEPTIONS.some(
|
|
124
|
+
(exception) =>
|
|
125
|
+
exception.file.test(file) &&
|
|
126
|
+
exception.functionName === functionName &&
|
|
127
|
+
exception.table === table,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// `table as EntityTableMeta`, `(table)`, `table!`, `<T>table`, `table satisfies T` all name the same table.
|
|
132
|
+
export function unwrapTableExpression(node: Node): Node {
|
|
133
|
+
let current = node;
|
|
134
|
+
while (
|
|
135
|
+
Node.isAsExpression(current) ||
|
|
136
|
+
Node.isSatisfiesExpression(current) ||
|
|
137
|
+
Node.isParenthesizedExpression(current) ||
|
|
138
|
+
Node.isNonNullExpression(current) ||
|
|
139
|
+
Node.isTypeAssertion(current)
|
|
140
|
+
) {
|
|
141
|
+
current = current.getExpression();
|
|
142
|
+
}
|
|
143
|
+
return current;
|
|
144
|
+
}
|
|
145
|
+
|
|
97
146
|
// Identity of a table declaration: absolute file path + identifier name.
|
|
98
147
|
// Text-only names collide across samples (currencies-global.invoiceTable vs.
|
|
99
148
|
// beammycar.invoiceTable would look the same); resolving to the underlying
|
|
@@ -118,9 +167,9 @@ export function collectEsTables(files: readonly SourceFile[]): Set<TableId> {
|
|
|
118
167
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
119
168
|
const expr = call.getExpression();
|
|
120
169
|
if (expr.getText() !== "createEventStoreExecutor") continue;
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
170
|
+
const firstArg = call.getArguments()[0];
|
|
171
|
+
const id = firstArg && unwrapTableExpression(firstArg).asKind(SyntaxKind.Identifier);
|
|
172
|
+
if (!id) continue;
|
|
124
173
|
|
|
125
174
|
// Repo convention: projection-tables follow `<name>Table`. Secondary
|
|
126
175
|
// filter that catches factory-of-factory pass-throughs even if
|
|
@@ -149,9 +198,10 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
|
|
|
149
198
|
const args = call.getArguments();
|
|
150
199
|
if (!args[0] || args[0].getKind() !== SyntaxKind.StringLiteral) continue;
|
|
151
200
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
201
|
+
const entityIdentifier =
|
|
202
|
+
args[1] && unwrapTableExpression(args[1]).asKind(SyntaxKind.Identifier);
|
|
203
|
+
if (entityIdentifier) {
|
|
204
|
+
const did = declIdOf(entityIdentifier);
|
|
155
205
|
if (did) rebuildableEntities.add(did);
|
|
156
206
|
}
|
|
157
207
|
|
|
@@ -162,8 +212,9 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
|
|
|
162
212
|
.getProperty("table");
|
|
163
213
|
if (tableProp?.getKind() === SyntaxKind.PropertyAssignment) {
|
|
164
214
|
const init = tableProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
|
|
165
|
-
|
|
166
|
-
|
|
215
|
+
const tableIdentifier = init && unwrapTableExpression(init).asKind(SyntaxKind.Identifier);
|
|
216
|
+
if (tableIdentifier) {
|
|
217
|
+
const did = declIdOf(tableIdentifier);
|
|
167
218
|
if (did) tables.add(did);
|
|
168
219
|
}
|
|
169
220
|
}
|
|
@@ -177,8 +228,10 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
|
|
|
177
228
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
178
229
|
if (call.getExpression().getText() !== "buildEntityTable") continue;
|
|
179
230
|
const entityArg = call.getArguments()[1];
|
|
180
|
-
|
|
181
|
-
|
|
231
|
+
const entityIdentifier =
|
|
232
|
+
entityArg && unwrapTableExpression(entityArg).asKind(SyntaxKind.Identifier);
|
|
233
|
+
if (!entityIdentifier) continue;
|
|
234
|
+
const entityDid = declIdOf(entityIdentifier);
|
|
182
235
|
if (!entityDid || !rebuildableEntities.has(entityDid)) continue;
|
|
183
236
|
|
|
184
237
|
const varDecl = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
|
|
@@ -253,9 +306,9 @@ function isInsideProjectionApply(callNode: Node): boolean {
|
|
|
253
306
|
|
|
254
307
|
// Extracts (table identifier, db/receiver expression, op) from a write
|
|
255
308
|
// call — both forms: the Drizzle method `<recv>.insert(table)` and the
|
|
256
|
-
// bun-db function `updateMany(db, table, ...)`.
|
|
257
|
-
//
|
|
258
|
-
// a false block).
|
|
309
|
+
// bun-db function `updateMany(db, table, ...)`. Casts around the table arg are
|
|
310
|
+
// unwrapped; undefined for non-writes or when the table arg is still no plain
|
|
311
|
+
// identifier (computed tables are not resolvable → skip, never a false block).
|
|
259
312
|
function resolveWrite(
|
|
260
313
|
call: CallExpression,
|
|
261
314
|
): { tableArg: Identifier; receiver: Node; op: "insert" | "update" | "delete" } | undefined {
|
|
@@ -266,23 +319,17 @@ function resolveWrite(
|
|
|
266
319
|
const pa = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
|
|
267
320
|
const m = pa.getName();
|
|
268
321
|
if (m !== "insert" && m !== "update" && m !== "delete") return undefined;
|
|
269
|
-
|
|
270
|
-
return
|
|
271
|
-
|
|
272
|
-
receiver: pa.getExpression(),
|
|
273
|
-
op: m,
|
|
274
|
-
};
|
|
322
|
+
const tableArg = args[0] && unwrapTableExpression(args[0]).asKind(SyntaxKind.Identifier);
|
|
323
|
+
if (!tableArg) return undefined;
|
|
324
|
+
return { tableArg, receiver: pa.getExpression(), op: m };
|
|
275
325
|
}
|
|
276
326
|
|
|
277
327
|
if (expr.getKind() === SyntaxKind.Identifier) {
|
|
278
328
|
const op = FN_WRITE_HELPERS.get(expr.getText());
|
|
279
329
|
if (!op) return undefined;
|
|
280
|
-
|
|
281
|
-
return
|
|
282
|
-
|
|
283
|
-
receiver: args[0],
|
|
284
|
-
op,
|
|
285
|
-
};
|
|
330
|
+
const tableArg = args[1] && unwrapTableExpression(args[1]).asKind(SyntaxKind.Identifier);
|
|
331
|
+
if (!args[0] || !tableArg) return undefined;
|
|
332
|
+
return { tableArg, receiver: args[0], op };
|
|
286
333
|
}
|
|
287
334
|
|
|
288
335
|
return undefined;
|
|
@@ -355,9 +402,6 @@ export function scanDirectWrites(
|
|
|
355
402
|
const w = resolveWrite(call);
|
|
356
403
|
if (!w) continue;
|
|
357
404
|
|
|
358
|
-
const did = declIdOf(w.tableArg);
|
|
359
|
-
if (!did || !esTables.has(did)) continue;
|
|
360
|
-
|
|
361
405
|
// Walk the receiver chain down to its leftmost identifier. For `db.insert`
|
|
362
406
|
// that's `db`; for `ctx.db.raw` (function-form arg0) that's `ctx`; for
|
|
363
407
|
// `tx` that's `tx`. Checked against the TX-receiver-Set.
|
|
@@ -367,6 +411,10 @@ export function scanDirectWrites(
|
|
|
367
411
|
}
|
|
368
412
|
const receiverName = receiver.getText();
|
|
369
413
|
|
|
414
|
+
const did = declIdOf(w.tableArg);
|
|
415
|
+
if (!did || !esTables.has(did)) continue;
|
|
416
|
+
if (isDirectWriteException(call, w.tableArg.getText())) continue;
|
|
417
|
+
|
|
370
418
|
if (TX_RECEIVER_NAMES.has(receiverName)) {
|
|
371
419
|
// A tx receiver is only legitimate when the write actually sits
|
|
372
420
|
// inside a projection-apply callback. A sub-tx pattern like
|
|
@@ -99,7 +99,14 @@ function localeDeClientRef(node: Node): boolean {
|
|
|
99
99
|
// through the specifier's own name to get there.
|
|
100
100
|
function resolveDeclaration(identifier: Identifier, depth = 0): Node | undefined {
|
|
101
101
|
if (depth > 3) return undefined;
|
|
102
|
-
|
|
102
|
+
// With a contextual type (e.g. a declared options type) the first definition
|
|
103
|
+
// can be the property signature, not the binding — pick the binding.
|
|
104
|
+
const def = identifier
|
|
105
|
+
.getDefinitionNodes()
|
|
106
|
+
.find(
|
|
107
|
+
(node) =>
|
|
108
|
+
node.isKind(SyntaxKind.VariableDeclaration) || node.isKind(SyntaxKind.ImportSpecifier),
|
|
109
|
+
);
|
|
103
110
|
if (def === undefined) return undefined;
|
|
104
111
|
if (def.isKind(SyntaxKind.VariableDeclaration)) return def;
|
|
105
112
|
if (def.isKind(SyntaxKind.ImportSpecifier)) {
|
|
@@ -113,9 +120,14 @@ function resolveDeclaration(identifier: Identifier, depth = 0): Node | undefined
|
|
|
113
120
|
|
|
114
121
|
// `as const` and `as const satisfies T[]` (both in active use, e.g.
|
|
115
122
|
// kumiko-studio's run-config.ts) wrap the array literal in an AsExpression
|
|
116
|
-
// and/or SatisfiesExpression
|
|
123
|
+
// and/or SatisfiesExpression, and `(X as T)` adds a ParenthesizedExpression —
|
|
124
|
+
// unwrap all of them so callers see the literal underneath.
|
|
117
125
|
function unwrapAsExpression(node: Node): Node {
|
|
118
|
-
if (
|
|
126
|
+
if (
|
|
127
|
+
node.isKind(SyntaxKind.AsExpression) ||
|
|
128
|
+
node.isKind(SyntaxKind.SatisfiesExpression) ||
|
|
129
|
+
node.isKind(SyntaxKind.ParenthesizedExpression)
|
|
130
|
+
) {
|
|
119
131
|
return unwrapAsExpression(node.getExpression());
|
|
120
132
|
}
|
|
121
133
|
return node;
|
|
@@ -137,37 +149,44 @@ function resolveSameRepoInitializer(identifier: Identifier): Node | undefined {
|
|
|
137
149
|
return init === undefined ? undefined : unwrapAsExpression(init);
|
|
138
150
|
}
|
|
139
151
|
|
|
152
|
+
// Bounds every alias/spread chain the guard follows; circular constants
|
|
153
|
+
// (`const a = [...b]; const b = [...a]`) parse fine and must end in a verdict,
|
|
154
|
+
// not a stack overflow.
|
|
155
|
+
const MAX_ALIAS_DEPTH = 5;
|
|
156
|
+
|
|
140
157
|
// Resolves an array-shaped value node down to its elements, whether it's a
|
|
141
158
|
// literal right there or an identifier pointing at a same-repo constant
|
|
142
159
|
// (`const CONST = [...]`, `... as const` included).
|
|
143
|
-
function arrayElementsOf(value: Node | undefined): Node[] | undefined {
|
|
144
|
-
if (value === undefined) return undefined;
|
|
160
|
+
function arrayElementsOf(value: Node | undefined, depth = 0): Node[] | undefined {
|
|
161
|
+
if (value === undefined || depth > MAX_ALIAS_DEPTH) return undefined;
|
|
145
162
|
if (value.isKind(SyntaxKind.ArrayLiteralExpression)) return value.getElements();
|
|
146
163
|
if (value.isKind(SyntaxKind.Identifier)) {
|
|
147
|
-
return arrayElementsOf(resolveSameRepoInitializer(value));
|
|
164
|
+
return arrayElementsOf(resolveSameRepoInitializer(value), depth + 1);
|
|
148
165
|
}
|
|
149
166
|
return undefined;
|
|
150
167
|
}
|
|
151
168
|
|
|
152
|
-
|
|
169
|
+
// Past the depth bound the answer is "no German found" (fail-closed).
|
|
170
|
+
function elementRegistersGerman(el: Node, depth = 0): boolean {
|
|
171
|
+
if (depth > MAX_ALIAS_DEPTH) return false;
|
|
153
172
|
if (el.isKind(SyntaxKind.SpreadElement)) {
|
|
154
173
|
const expr = el.getExpression();
|
|
155
174
|
if (expr.isKind(SyntaxKind.Identifier)) {
|
|
156
175
|
const init = resolveSameRepoInitializer(expr);
|
|
157
|
-
if (init !== undefined) return elementRegistersGerman(init);
|
|
176
|
+
if (init !== undefined) return elementRegistersGerman(init, depth + 1);
|
|
158
177
|
// Unresolvable or cross-repo identifier spread — fail-open rather than risk a false alarm.
|
|
159
178
|
// Safe here: clientFeatures is already known to exist as an array, this only concerns
|
|
160
179
|
// one of its elements — other elements are still checked.
|
|
161
180
|
return true;
|
|
162
181
|
}
|
|
163
|
-
return elementRegistersGerman(expr);
|
|
182
|
+
return elementRegistersGerman(expr, depth + 1);
|
|
164
183
|
}
|
|
165
184
|
if (el.isKind(SyntaxKind.ArrayLiteralExpression)) {
|
|
166
|
-
return el.getElements().some(elementRegistersGerman);
|
|
185
|
+
return el.getElements().some((element) => elementRegistersGerman(element, depth + 1));
|
|
167
186
|
}
|
|
168
187
|
// localeDeClient().translations (kumiko-studio auth-mount shape)
|
|
169
188
|
if (el.isKind(SyntaxKind.PropertyAccessExpression)) {
|
|
170
|
-
return localeDeClientRef(el) || elementRegistersGerman(el.getExpression());
|
|
189
|
+
return localeDeClientRef(el) || elementRegistersGerman(el.getExpression(), depth + 1);
|
|
171
190
|
}
|
|
172
191
|
if (el.isKind(SyntaxKind.CallExpression)) {
|
|
173
192
|
return localeDeClientRef(el.getExpression());
|
|
@@ -198,42 +217,90 @@ function clientFeaturesElements(prop: Node | undefined): Node[] | undefined {
|
|
|
198
217
|
return arrayElementsOf(value);
|
|
199
218
|
}
|
|
200
219
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
220
|
+
type ClientFeaturesLookup =
|
|
221
|
+
| { readonly found: false }
|
|
222
|
+
| { readonly found: true; readonly registersGerman: boolean };
|
|
223
|
+
|
|
224
|
+
const CLIENT_FEATURES_ABSENT: ClientFeaturesLookup = { found: false };
|
|
225
|
+
// An options source the guard cannot see into could hold (or override) clientFeatures —
|
|
226
|
+
// unlike a single array element, treating it as "German registered" would blind the guard.
|
|
227
|
+
const CLIENT_FEATURES_UNRESOLVABLE: ClientFeaturesLookup = { found: true, registersGerman: false };
|
|
228
|
+
|
|
229
|
+
// `cond && {…}` and `cond ? {…} : {…}` spread one of their object branches (or nothing).
|
|
230
|
+
function conditionalBranches(node: Node): Node[] | undefined {
|
|
231
|
+
if (node.isKind(SyntaxKind.ConditionalExpression))
|
|
232
|
+
return [node.getWhenTrue(), node.getWhenFalse()];
|
|
233
|
+
if (
|
|
234
|
+
node.isKind(SyntaxKind.BinaryExpression) &&
|
|
235
|
+
node.getOperatorToken().getKind() === SyntaxKind.AmpersandAmpersandToken
|
|
236
|
+
) {
|
|
237
|
+
return [node.getRight()];
|
|
218
238
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Spreading these contributes no properties.
|
|
243
|
+
function isNothingLiteral(node: Node): boolean {
|
|
244
|
+
return (
|
|
245
|
+
node.isKind(SyntaxKind.NullKeyword) ||
|
|
246
|
+
node.isKind(SyntaxKind.FalseKeyword) ||
|
|
247
|
+
(node.isKind(SyntaxKind.Identifier) && node.getText() === "undefined")
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Finds the clientFeatures property that wins at runtime in the mount call's
|
|
252
|
+
// options — a literal right there, or an identifier pointing at a same-repo
|
|
253
|
+
// constant (offlot's `APP_OPTIONS` shape). Properties are walked last to
|
|
254
|
+
// first, mirroring object-spread override semantics: the last property or
|
|
255
|
+
// spread that sets `clientFeatures` decides. Resolvable spreads without
|
|
256
|
+
// `clientFeatures` are skipped; unresolvable/cross-repo ones are fail-closed.
|
|
257
|
+
function lookupClientFeatures(value: Node | undefined, depth = 0): ClientFeaturesLookup {
|
|
258
|
+
if (value === undefined) return CLIENT_FEATURES_ABSENT;
|
|
259
|
+
if (depth > MAX_ALIAS_DEPTH) return CLIENT_FEATURES_UNRESOLVABLE;
|
|
260
|
+
const unwrapped = unwrapAsExpression(value);
|
|
261
|
+
if (unwrapped.isKind(SyntaxKind.Identifier)) {
|
|
262
|
+
const init = resolveSameRepoInitializer(unwrapped);
|
|
263
|
+
return init === undefined
|
|
264
|
+
? CLIENT_FEATURES_UNRESOLVABLE
|
|
265
|
+
: lookupClientFeatures(init, depth + 1);
|
|
266
|
+
}
|
|
267
|
+
const branches = conditionalBranches(unwrapped);
|
|
268
|
+
if (branches !== undefined) {
|
|
269
|
+
const found = branches
|
|
270
|
+
.map((branch) => lookupClientFeatures(branch, depth + 1))
|
|
271
|
+
.filter((lookup) => lookup.found);
|
|
272
|
+
if (found.length === 0) return CLIENT_FEATURES_ABSENT;
|
|
273
|
+
// Which branch runs is unknown statically: German must be registered in every branch that sets clientFeatures.
|
|
274
|
+
return {
|
|
275
|
+
found: true,
|
|
276
|
+
registersGerman: found.every((lookup) => lookup.found && lookup.registersGerman),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
if (isNothingLiteral(unwrapped)) return CLIENT_FEATURES_ABSENT;
|
|
280
|
+
if (!unwrapped.isKind(SyntaxKind.ObjectLiteralExpression)) return CLIENT_FEATURES_UNRESOLVABLE;
|
|
281
|
+
for (const prop of [...unwrapped.getProperties()].reverse()) {
|
|
282
|
+
if (
|
|
283
|
+
(prop.isKind(SyntaxKind.PropertyAssignment) ||
|
|
284
|
+
prop.isKind(SyntaxKind.ShorthandPropertyAssignment)) &&
|
|
285
|
+
prop.getNameNode().getText() === "clientFeatures"
|
|
286
|
+
) {
|
|
287
|
+
return {
|
|
288
|
+
found: true,
|
|
289
|
+
registersGerman:
|
|
290
|
+
clientFeaturesElements(prop)?.some((el) => elementRegistersGerman(el)) ?? false,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (prop.isKind(SyntaxKind.SpreadAssignment)) {
|
|
294
|
+
const spread = lookupClientFeatures(prop.getExpression(), depth + 1);
|
|
295
|
+
if (spread.found) return spread;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return CLIENT_FEATURES_ABSENT;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function objectRegistersGermanClientFeatures(value: Node | undefined): boolean {
|
|
302
|
+
const lookup = lookupClientFeatures(value);
|
|
303
|
+
return lookup.found && lookup.registersGerman;
|
|
237
304
|
}
|
|
238
305
|
|
|
239
306
|
function clientMountViolations(sf: SourceFile): GuardViolation[] {
|
|
@@ -258,7 +325,7 @@ function clientMountViolations(sf: SourceFile): GuardViolation[] {
|
|
|
258
325
|
const init = attr.getInitializer();
|
|
259
326
|
const expr = init?.isKind(SyntaxKind.JsxExpression) ? init.getExpression() : undefined;
|
|
260
327
|
const elements = arrayElementsOf(expr);
|
|
261
|
-
const registered = elements?.some(elementRegistersGerman) ?? false;
|
|
328
|
+
const registered = elements?.some((el) => elementRegistersGerman(el)) ?? false;
|
|
262
329
|
if (!registered) {
|
|
263
330
|
violations.push({
|
|
264
331
|
file: sf.getFilePath(),
|
|
@@ -64,8 +64,12 @@ const EXCLUDE =
|
|
|
64
64
|
|
|
65
65
|
const FS_MODULES = new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]);
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// Closed union (not `string | "*"`, which collapses to `string`): a typo in an
|
|
68
|
+
// allowlist repo name must fail to compile instead of silently never matching.
|
|
69
|
+
export type AllowlistRepoName = "kumiko-framework" | "kumiko-enterprise";
|
|
70
|
+
|
|
71
|
+
export type AllowEntry = {
|
|
72
|
+
readonly repo: AllowlistRepoName | "*";
|
|
69
73
|
readonly pattern: RegExp;
|
|
70
74
|
};
|
|
71
75
|
|
|
@@ -77,6 +81,10 @@ const ALLOWLIST: readonly AllowEntry[] = [
|
|
|
77
81
|
// machine, no request input as a path.
|
|
78
82
|
{ repo: "kumiko-framework", pattern: /^packages\/dev-server\/src\// },
|
|
79
83
|
|
|
84
|
+
// Guard runner + repo-manifest loader: read the scanned repo tree at dev/CI time, no request input as path.
|
|
85
|
+
{ repo: "kumiko-framework", pattern: /^packages\/guards\/src\// },
|
|
86
|
+
{ repo: "kumiko-framework", pattern: /^packages\/repo-manifest\/src\// },
|
|
87
|
+
|
|
80
88
|
// CLI (@cosmicdrift/kumiko-cli): local dev/CI tooling reading the repo tree, no request input as path.
|
|
81
89
|
{ repo: "kumiko-framework", pattern: /^packages\/cli\/src\// },
|
|
82
90
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Guard: tenant / privilege-escalation safety.
|
|
3
|
+
* Guard: tenant / privilege-escalation safety. Three complementary checks.
|
|
4
4
|
*
|
|
5
5
|
* A) **Role-input write handlers need an escalation test.** Any
|
|
6
6
|
* `defineWriteHandler` whose Zod schema takes a `role`/`roles` field lets
|
|
@@ -12,13 +12,8 @@
|
|
|
12
12
|
* because a feature's escalation test legitimately lives in another
|
|
13
13
|
* feature's __tests__ (e.g. user:update is covered by auth's multi-roles).
|
|
14
14
|
*
|
|
15
|
-
* B) **tenantIdOverride handlers must use crossTenantOverrideDenied.** A
|
|
16
|
-
* payload `tenantIdOverride` on a TenantAdmin-reachable handler is the
|
|
17
|
-
* cross-tenant escape hatch; the SystemAdmin gate must go through the
|
|
18
|
-
* shared framework helper, not an inline `roles.includes("SystemAdmin")`
|
|
19
|
-
* check that the next handler forgets.
|
|
20
15
|
*
|
|
21
|
-
*
|
|
16
|
+
* B) **Membership-derived JWT mints must strip reserved roles.** Command-time
|
|
22
17
|
* validation rejects reserved roles from a membership, but a projection
|
|
23
18
|
* rebuild replays stored membership events through the apply path, not the
|
|
24
19
|
* handler — so a forbidden role can be resurrected into the projection. Any
|
|
@@ -27,7 +22,7 @@
|
|
|
27
22
|
* `invitationRole`) MUST call `stripForbiddenMembershipRoles`, the
|
|
28
23
|
* read-time backstop.
|
|
29
24
|
*
|
|
30
|
-
*
|
|
25
|
+
* C) **TenantAdmin-reachable writes on a global user row need a membership
|
|
31
26
|
* check.** A `ctx.db.raw` read deliberately bypasses the auto-tenant-
|
|
32
27
|
* filter because User status is global. Combined with `access.admin`
|
|
33
28
|
* (which includes the tenant-scoped TenantAdmin) and a `userId` payload,
|
|
@@ -36,7 +31,7 @@
|
|
|
36
31
|
* isSystemAdminActor + tenantMembershipsTable shape lift-restriction.
|
|
37
32
|
* write.ts uses. restrict-account.write.ts shipped without it.
|
|
38
33
|
*
|
|
39
|
-
* All
|
|
34
|
+
* All three are tripwires: false-negatives (a weak name match) are tolerated, a
|
|
40
35
|
* false-positive on the clean repo is not. Detection stays conservative.
|
|
41
36
|
*
|
|
42
37
|
* Usage:
|
|
@@ -128,32 +123,6 @@ export function findUntestedRoleHandlers(files: readonly SourceFile[]): RoleHand
|
|
|
128
123
|
});
|
|
129
124
|
}
|
|
130
125
|
|
|
131
|
-
type OverrideHandler = { file: string; line: number };
|
|
132
|
-
|
|
133
|
-
export function findOverrideHandlersMissingHelper(files: readonly SourceFile[]): OverrideHandler[] {
|
|
134
|
-
const out: OverrideHandler[] = [];
|
|
135
|
-
for (const sf of files) {
|
|
136
|
-
if (TEST_FILE_RE.test(sf.getFilePath())) continue;
|
|
137
|
-
for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
138
|
-
if (pa.getName() !== "tenantIdOverride") continue;
|
|
139
|
-
if (pa.getInitializer()?.getText().startsWith("z.") !== true) continue;
|
|
140
|
-
// Scope the check to the enclosing defineWriteHandler(...) call, not
|
|
141
|
-
// the whole file — a file with two override handlers where only one
|
|
142
|
-
// calls crossTenantOverrideDenied must still flag the other.
|
|
143
|
-
const handlerCall = pa.getFirstAncestor(
|
|
144
|
-
(a) =>
|
|
145
|
-
a.getKind() === SyntaxKind.CallExpression &&
|
|
146
|
-
a.asKindOrThrow(SyntaxKind.CallExpression).getExpression().getText() ===
|
|
147
|
-
"defineWriteHandler",
|
|
148
|
-
);
|
|
149
|
-
const scopeText = handlerCall?.getText() ?? sf.getFullText();
|
|
150
|
-
if (scopeText.includes("crossTenantOverrideDenied")) continue;
|
|
151
|
-
out.push({ file: sf.getFilePath(), line: pa.getStartLineNumber() });
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return out;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
126
|
// A session built for a JWT — either the auth-session result shape or a typed
|
|
158
127
|
// SessionUser literal.
|
|
159
128
|
const SESSION_MINT = /kind:\s*"auth-session"|:\s*SessionUser\s*=\s*\{/;
|
|
@@ -232,7 +201,7 @@ export function findGlobalUserWritesMissingMembershipCheck(
|
|
|
232
201
|
// `name` is only used for the violation message, not the security
|
|
233
202
|
// check itself — a factory-built handler with a non-literal name
|
|
234
203
|
// expression (`name: enable ? "enable" : "disable"`) must not skip
|
|
235
|
-
// Check
|
|
204
|
+
// Check C just because literalStringOf() can't resolve it.
|
|
236
205
|
const name = literalStringOf(obj.getProperty("name"));
|
|
237
206
|
const schema = obj.getProperty("schema");
|
|
238
207
|
const accessProp = obj.getProperty("access");
|
|
@@ -267,7 +236,7 @@ export const guard: AstGuard = {
|
|
|
267
236
|
name: "Tenant-Escalation Guard",
|
|
268
237
|
scan: SCAN,
|
|
269
238
|
security: true,
|
|
270
|
-
hint: "Role-input handlers need an escalation test (assert a reserved role is rejected);
|
|
239
|
+
hint: "Role-input handlers need an escalation test (assert a reserved role is rejected); membership-derived JWT mints must call stripForbiddenMembershipRoles; TenantAdmin-reachable ctx.db.raw user writes must check the target's membership.",
|
|
271
240
|
run(files) {
|
|
272
241
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
273
242
|
|
|
@@ -279,15 +248,6 @@ export const guard: AstGuard = {
|
|
|
279
248
|
});
|
|
280
249
|
}
|
|
281
250
|
|
|
282
|
-
for (const o of findOverrideHandlersMissingHelper(files)) {
|
|
283
|
-
violations.push({
|
|
284
|
-
file: path.relative(ROOT, o.file),
|
|
285
|
-
line: o.line,
|
|
286
|
-
message:
|
|
287
|
-
"handler exposes tenantIdOverride but never calls crossTenantOverrideDenied — route the SystemAdmin gate through the shared helper.",
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
|
|
291
251
|
for (const m of findMembershipMintsMissingStrip(files)) {
|
|
292
252
|
violations.push({
|
|
293
253
|
file: path.relative(ROOT, m.file),
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* Guard: tests wait for a condition, not for time. Flags, in test files and
|
|
4
4
|
* Playwright specs/helpers:
|
|
5
5
|
* (a) `test.setTimeout(...)`, `test.slow(...)`, `setDefaultTimeout(...)`,
|
|
6
|
-
* `*.describe.configure({ timeout })
|
|
7
|
-
*
|
|
6
|
+
* `*.describe.configure({ timeout })`, and a timeout argument on
|
|
7
|
+
* `test`/`it`/`describe` calls (`test(name, fn, 30_000)`,
|
|
8
|
+
* `test(name, fn, { timeout })`, also via `.skip/.only/.if()/.each()()`
|
|
9
|
+
* and renamed/namespace `bun:test` imports) — raising a timeout hides
|
|
10
|
+
* the cause of a slow or flaky test
|
|
8
11
|
* (b) `<page>.waitForTimeout(...)` — a fixed sleep instead of a condition
|
|
9
12
|
* (c) sleep loops — a `while`/`do`/`for`/`for…of`/`for…in` whose body calls
|
|
10
13
|
* `sleep(...)`/`Bun.sleep(...)` or awaits a `new Promise` that only
|
|
@@ -70,6 +73,9 @@ const TIMEOUT_RAISING_CALLEES: ReadonlySet<string> = new Set(["test.setTimeout",
|
|
|
70
73
|
const SET_TIMEOUT_CALLEE = /(^|\.)setTimeout$/;
|
|
71
74
|
const SET_DEFAULT_TIMEOUT_CALLEE = /(^|\.)setDefaultTimeout$/;
|
|
72
75
|
const DESCRIBE_CONFIGURE_CALLEE = /(^|\.)describe\.configure$/;
|
|
76
|
+
const TEST_FRAMEWORK_MODULES: ReadonlySet<string> = new Set(["bun:test", "@playwright/test"]);
|
|
77
|
+
const TEST_DECLARATION_CALLEE =
|
|
78
|
+
/^(test|it|describe)(\.(skip|only|todo|if|each|failing|concurrent|serial|skipIf|runIf|todoIf))*$/;
|
|
73
79
|
|
|
74
80
|
export interface Finding {
|
|
75
81
|
file: string;
|
|
@@ -143,9 +149,58 @@ function hasTimeoutProperty(node: Node | undefined): boolean {
|
|
|
143
149
|
);
|
|
144
150
|
}
|
|
145
151
|
|
|
146
|
-
|
|
152
|
+
// Maps local names imported from the test framework to their exported name;
|
|
153
|
+
// "" marks a namespace import (`import * as bt`), whose member access is stripped.
|
|
154
|
+
function testFrameworkAliases(sf: SourceFile): ReadonlyMap<string, string> {
|
|
155
|
+
const aliases = new Map<string, string>();
|
|
156
|
+
for (const decl of sf.getImportDeclarations()) {
|
|
157
|
+
if (!TEST_FRAMEWORK_MODULES.has(decl.getModuleSpecifierValue())) continue;
|
|
158
|
+
const namespaceImport = decl.getNamespaceImport();
|
|
159
|
+
if (namespaceImport !== undefined) aliases.set(namespaceImport.getText(), "");
|
|
160
|
+
for (const named of decl.getNamedImports()) {
|
|
161
|
+
const alias = named.getAliasNode();
|
|
162
|
+
if (alias !== undefined) aliases.set(alias.getText(), named.getName());
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return aliases;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function canonicalCalleeText(text: string, aliases: ReadonlyMap<string, string>): string {
|
|
169
|
+
const [head = "", ...rest] = text.split(".");
|
|
170
|
+
const imported = aliases.get(head);
|
|
171
|
+
if (imported === undefined) return text;
|
|
172
|
+
return (imported === "" ? rest : [imported, ...rest]).join(".");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// `test.each(rows)(…)` and `test.if(cond)(…)` call the result of a call.
|
|
176
|
+
function declarationBaseCallee(callee: Node): Node {
|
|
177
|
+
return Node.isCallExpression(callee) ? declarationBaseCallee(callee.getExpression()) : callee;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isTimeoutArgument(arg: Node): boolean {
|
|
181
|
+
if (Node.isObjectLiteralExpression(arg)) return hasTimeoutProperty(arg);
|
|
182
|
+
return !Node.isArrowFunction(arg) && !Node.isFunctionExpression(arg);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function declarationTimeoutReason(
|
|
186
|
+
call: CallExpression,
|
|
187
|
+
aliases: ReadonlyMap<string, string>,
|
|
188
|
+
): string | undefined {
|
|
189
|
+
const base = canonicalCalleeText(declarationBaseCallee(call.getExpression()).getText(), aliases);
|
|
190
|
+
if (!TEST_DECLARATION_CALLEE.test(base)) return undefined;
|
|
191
|
+
const [, , ...extra] = call.getArguments();
|
|
192
|
+
if (!extra.some(isTimeoutArgument)) return undefined;
|
|
193
|
+
return `${base}(…, timeout) raises the timeout instead of fixing the cause`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function raisedTimeoutReason(
|
|
197
|
+
call: CallExpression,
|
|
198
|
+
aliases: ReadonlyMap<string, string>,
|
|
199
|
+
): string | undefined {
|
|
147
200
|
const callee = call.getExpression();
|
|
148
|
-
const calleeText = callee.getText();
|
|
201
|
+
const calleeText = canonicalCalleeText(callee.getText(), aliases);
|
|
202
|
+
const declarationReason = declarationTimeoutReason(call, aliases);
|
|
203
|
+
if (declarationReason !== undefined) return declarationReason;
|
|
149
204
|
if (TIMEOUT_RAISING_CALLEES.has(calleeText) || SET_DEFAULT_TIMEOUT_CALLEE.test(calleeText)) {
|
|
150
205
|
return `${calleeText}(…) raises the timeout instead of fixing the cause`;
|
|
151
206
|
}
|
|
@@ -178,8 +233,9 @@ export function scanTimeouts(
|
|
|
178
233
|
if (reported === undefined) return;
|
|
179
234
|
findings.push({ file, line: node.getStartLineNumber(), message: reported });
|
|
180
235
|
};
|
|
236
|
+
const aliases = testFrameworkAliases(sf);
|
|
181
237
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
182
|
-
const reason = raisedTimeoutReason(call);
|
|
238
|
+
const reason = raisedTimeoutReason(call, aliases);
|
|
183
239
|
if (reason !== undefined) add(call, reason);
|
|
184
240
|
}
|
|
185
241
|
for (const [kind, label] of LOOP_LABELS) {
|