@cosmicdrift/kumiko-guards 0.294.1 → 0.295.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 +1 -0
- package/src/_lib/scan-scope.ts +13 -1
- package/src/guard-test-timeouts.ts +249 -0
- package/src/run-guards.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.295.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>",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"kumiko-guards": "./src/cli.ts"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@cosmicdrift/kumiko-repo-manifest": "0.
|
|
30
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.295.0",
|
|
31
31
|
"ts-morph": "^28.0.0"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -415,6 +415,7 @@ function scanSpecSummary(spec: ScanSpec): string {
|
|
|
415
415
|
if (spec.kinds) parts.push(`kinds=${spec.kinds.join(",")}`);
|
|
416
416
|
if (spec.scope === "source" && spec.within) parts.push(`within=${spec.within.join(",")}`);
|
|
417
417
|
if (spec.frameworkWithin) parts.push(`frameworkWithin=${spec.frameworkWithin.join(",")}`);
|
|
418
|
+
if (spec.extraGlobs) parts.push(`extraGlobs=${spec.extraGlobs.join(",")}`);
|
|
418
419
|
return parts.join(" ");
|
|
419
420
|
}
|
|
420
421
|
|
package/src/_lib/scan-scope.ts
CHANGED
|
@@ -14,6 +14,8 @@ type ScanSpecBase = {
|
|
|
14
14
|
readonly kinds?: readonly RepoKind[];
|
|
15
15
|
/** Repo-relative globs; in a kind "framework" root only files matching one of them are kept (replaces `within` there). */
|
|
16
16
|
readonly frameworkWithin?: readonly string[];
|
|
17
|
+
/** Repo-relative globs of files added on top of the scope's own hits (e.g. Playwright `e2e/` dirs outside sourceRoots/testGlobs); manifest excludes and the extension filter still apply. */
|
|
18
|
+
readonly extraGlobs?: readonly string[];
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export type ScanSpec =
|
|
@@ -109,6 +111,14 @@ function testHits(root: RepoRoot): Hit[] {
|
|
|
109
111
|
return [...byRepoRel.values()];
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
function extraHits(root: RepoRoot, globs: readonly string[] | undefined): Hit[] {
|
|
115
|
+
const byRepoRel = new Map<string, Hit>();
|
|
116
|
+
for (const glob of globs ?? []) {
|
|
117
|
+
for (const repoRel of scanGlob(root.absPath, glob)) byRepoRel.set(repoRel, { repoRel });
|
|
118
|
+
}
|
|
119
|
+
return [...byRepoRel.values()];
|
|
120
|
+
}
|
|
121
|
+
|
|
112
122
|
function afterExcludes(hits: readonly Hit[], excludes: readonly string[] | undefined): Hit[] {
|
|
113
123
|
if (!excludes || excludes.length === 0) return [...hits];
|
|
114
124
|
return hits.filter((hit) => !matchesAny(hit.repoRel, excludes));
|
|
@@ -135,7 +145,9 @@ function scanRoot(spec: ScanSpec, root: RepoRoot): RootScan {
|
|
|
135
145
|
spec.scope === "source"
|
|
136
146
|
? sourceSurfaceHits
|
|
137
147
|
: afterExcludes(testHits(root), root.manifest.excludes);
|
|
138
|
-
const
|
|
148
|
+
const seen = new Set(scopeHits.map((hit) => hit.repoRel));
|
|
149
|
+
const additionalHits = afterExcludes(extraHits(root, spec.extraGlobs), root.manifest.excludes);
|
|
150
|
+
const files = [...scopeHits, ...additionalHits.filter((hit) => !seen.has(hit.repoRel))]
|
|
139
151
|
.filter((hit) => keepForSpec(hit, root, spec))
|
|
140
152
|
.map((hit) => join(root.absPath, hit.repoRel))
|
|
141
153
|
.sort();
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: tests wait for a condition, not for time. Flags, in test files and
|
|
4
|
+
* Playwright specs/helpers:
|
|
5
|
+
* (a) `test.setTimeout(...)`, `test.slow(...)` — raising a timeout hides the
|
|
6
|
+
* cause of a slow or flaky test
|
|
7
|
+
* (b) `<page>.waitForTimeout(...)` — a fixed sleep instead of a condition
|
|
8
|
+
* (c) sleep loops — a `while`/`do`/`for`/`for…of`/`for…in` whose body calls
|
|
9
|
+
* `sleep(...)`/`Bun.sleep(...)` or awaits a `new Promise` that only
|
|
10
|
+
* runs `setTimeout` (hand-rolled polling; use `waitFor`/`expect.poll`)
|
|
11
|
+
* A single `sleep()`/`setTimeout` outside a loop, and a sleeping loop that
|
|
12
|
+
* `yield`s (slow-producer generator), are work simulation and never flagged.
|
|
13
|
+
*
|
|
14
|
+
* Opt-out on the finding's own line or the line above (loop statement for
|
|
15
|
+
* (c)): `// @timeout-exception: #<issue> <technical reason>`. A marker
|
|
16
|
+
* without an issue number or a reason does not count and is called out in
|
|
17
|
+
* the message.
|
|
18
|
+
*
|
|
19
|
+
* `samples/recipes/**` is not touched. `.kumiko-test-timeouts-baseline.json`
|
|
20
|
+
* in the repo root pins the frozen count per file (transition ratchet); over
|
|
21
|
+
* baseline fails, at or under passes. Without the file the guard is
|
|
22
|
+
* warning-only (bootstrap: `--write-baseline` once).
|
|
23
|
+
*
|
|
24
|
+
* Usage:
|
|
25
|
+
* bun guards/guard-test-timeouts.ts # compare to baseline
|
|
26
|
+
* bun guards/guard-test-timeouts.ts --write-baseline # freeze current
|
|
27
|
+
* bun guards/guard-test-timeouts.ts --no-baseline # skip comparison
|
|
28
|
+
*/
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { type CallExpression, Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
31
|
+
import {
|
|
32
|
+
type AstGuard,
|
|
33
|
+
baselineRatchet,
|
|
34
|
+
buildSharedProject,
|
|
35
|
+
filesForGuard,
|
|
36
|
+
type GuardOutcome,
|
|
37
|
+
type GuardViolation,
|
|
38
|
+
isLocalFinding,
|
|
39
|
+
runStandalone,
|
|
40
|
+
type ScanSpec,
|
|
41
|
+
} from "./_lib/guard-kit";
|
|
42
|
+
|
|
43
|
+
const ROOT = process.cwd();
|
|
44
|
+
|
|
45
|
+
// Playwright dirs sit outside every manifest's sourceRoots/testGlobs, and
|
|
46
|
+
// scenario helpers next to the specs (not just *.spec.ts) carry the timeouts too.
|
|
47
|
+
const SCAN: ScanSpec = {
|
|
48
|
+
scope: "tests",
|
|
49
|
+
extensions: ["ts", "tsx"],
|
|
50
|
+
extraGlobs: [
|
|
51
|
+
"e2e/**/*.{ts,tsx}",
|
|
52
|
+
"packages/*/e2e/**/*.{ts,tsx}",
|
|
53
|
+
"packages/*/src/e2e/**/*.{ts,tsx}",
|
|
54
|
+
"samples/e2e/**/*.{ts,tsx}",
|
|
55
|
+
"samples/apps/*/e2e/**/*.{ts,tsx}",
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const EXCEPTION_TAG = "@timeout-exception";
|
|
60
|
+
const COMPLETE_EXCEPTION = /@timeout-exception:\s*#\d+\s+\S/;
|
|
61
|
+
const RECIPES_DIR = "/samples/recipes/";
|
|
62
|
+
|
|
63
|
+
const LOOP_LABELS: ReadonlyMap<SyntaxKind, string> = new Map([
|
|
64
|
+
[SyntaxKind.WhileStatement, "while"],
|
|
65
|
+
[SyntaxKind.DoStatement, "do…while"],
|
|
66
|
+
[SyntaxKind.ForStatement, "for"],
|
|
67
|
+
[SyntaxKind.ForOfStatement, "for…of"],
|
|
68
|
+
[SyntaxKind.ForInStatement, "for…in"],
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const SLEEP_CALLEES: ReadonlySet<string> = new Set(["sleep", "Bun.sleep"]);
|
|
72
|
+
const TIMEOUT_RAISING_CALLEES: ReadonlySet<string> = new Set(["test.setTimeout", "test.slow"]);
|
|
73
|
+
const SET_TIMEOUT_CALLEE = /(^|\.)setTimeout$/;
|
|
74
|
+
|
|
75
|
+
export interface Finding {
|
|
76
|
+
file: string;
|
|
77
|
+
line: number;
|
|
78
|
+
message: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function relFile(sf: SourceFile): string {
|
|
82
|
+
return path.relative(ROOT, sf.getFilePath());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isLoop(node: Node): boolean {
|
|
86
|
+
return LOOP_LABELS.has(node.getKind());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function callsSetTimeout(node: Node): boolean {
|
|
90
|
+
return node
|
|
91
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
92
|
+
.some((call) => SET_TIMEOUT_CALLEE.test(call.getExpression().getText()));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isSleepStatement(node: Node): boolean {
|
|
96
|
+
if (Node.isCallExpression(node)) return SLEEP_CALLEES.has(node.getExpression().getText());
|
|
97
|
+
if (Node.isNewExpression(node) && node.getExpression().getText() === "Promise") {
|
|
98
|
+
const executor = node.getArguments()[0];
|
|
99
|
+
return executor !== undefined && callsSetTimeout(executor);
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Nested loops are their own finding — skipping them keeps one sleep from
|
|
105
|
+
// being counted once per enclosing loop.
|
|
106
|
+
function bodySleeps(loop: Node): boolean {
|
|
107
|
+
let found = false;
|
|
108
|
+
loop.forEachDescendant((node, traversal) => {
|
|
109
|
+
if (isLoop(node)) {
|
|
110
|
+
traversal.skip();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (isSleepStatement(node)) {
|
|
114
|
+
found = true;
|
|
115
|
+
traversal.stop();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return found;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A sleeping loop that yields is a slow-producer simulation, not polling.
|
|
122
|
+
function yieldsInBody(loop: Node): boolean {
|
|
123
|
+
return loop.getDescendantsOfKind(SyntaxKind.YieldExpression).length > 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function exceptionNote(node: Node): "allowed" | "incomplete" | "none" {
|
|
127
|
+
const lines = node.getSourceFile().getFullText().split("\n");
|
|
128
|
+
const line = node.getStartLineNumber();
|
|
129
|
+
const nearby = [lines[line - 1] ?? "", lines[line - 2] ?? ""];
|
|
130
|
+
if (nearby.some((text) => COMPLETE_EXCEPTION.test(text))) return "allowed";
|
|
131
|
+
return nearby.some((text) => text.includes(EXCEPTION_TAG)) ? "incomplete" : "none";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function raisedTimeoutReason(call: CallExpression): string | undefined {
|
|
135
|
+
const callee = call.getExpression();
|
|
136
|
+
const calleeText = callee.getText();
|
|
137
|
+
if (TIMEOUT_RAISING_CALLEES.has(calleeText)) {
|
|
138
|
+
return `${calleeText}(…) raises the timeout instead of fixing the cause`;
|
|
139
|
+
}
|
|
140
|
+
if (Node.isPropertyAccessExpression(callee) && callee.getName() === "waitForTimeout") {
|
|
141
|
+
return `${calleeText}(…) waits for time, not for a condition`;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function withExceptionNote(node: Node, message: string): string | undefined {
|
|
147
|
+
const note = exceptionNote(node);
|
|
148
|
+
if (note === "allowed") return undefined;
|
|
149
|
+
return note === "incomplete"
|
|
150
|
+
? `${message} [incomplete ${EXCEPTION_TAG} marker: needs "#<issue> <technical reason>"]`
|
|
151
|
+
: message;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function scanTimeouts(sf: SourceFile): Finding[] {
|
|
155
|
+
if (sf.getFilePath().includes(RECIPES_DIR)) return [];
|
|
156
|
+
const file = relFile(sf);
|
|
157
|
+
const findings: Finding[] = [];
|
|
158
|
+
const add = (node: Node, message: string): void => {
|
|
159
|
+
const reported = withExceptionNote(node, message);
|
|
160
|
+
if (reported === undefined) return;
|
|
161
|
+
findings.push({ file, line: node.getStartLineNumber(), message: reported });
|
|
162
|
+
};
|
|
163
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
164
|
+
const reason = raisedTimeoutReason(call);
|
|
165
|
+
if (reason !== undefined) add(call, reason);
|
|
166
|
+
}
|
|
167
|
+
for (const [kind, label] of LOOP_LABELS) {
|
|
168
|
+
for (const loop of sf.getDescendantsOfKind(kind)) {
|
|
169
|
+
if (bodySleeps(loop) && !yieldsInBody(loop)) {
|
|
170
|
+
add(loop, `${label} loop polls with sleep — wait for a condition, not for time`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return findings.sort((a, b) => a.line - b.line);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const BASELINE_FILE = ".kumiko-test-timeouts-baseline.json";
|
|
178
|
+
const timeoutsBaseline = baselineRatchet({
|
|
179
|
+
file: path.join(ROOT, BASELINE_FILE),
|
|
180
|
+
formatVersion: 1,
|
|
181
|
+
unit: "test timeout workaround(s)",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const REMEDIATION = `Wait for a condition (\`waitFor\`, \`expect.poll\`) instead of raising timeouts or sleeping, or mark the line with \`// ${EXCEPTION_TAG}: #<issue> <technical reason>\`.`;
|
|
185
|
+
|
|
186
|
+
const HINT =
|
|
187
|
+
"Diagnose in this order: (1) reproduce a single run (`--repeat-each`, single file), (2) check shared state and missing isolation (one tenant per flow), (3) wait for a condition, not for time (`waitFor`, `expect.poll`), (4) check the seed, (5) only then change the central template via an issue — never per app. See docs/guides/test-failures.md. After a deliberate change: `bun guards/guard-test-timeouts.ts --write-baseline`.";
|
|
188
|
+
|
|
189
|
+
export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
|
|
190
|
+
const counts: Record<string, number> = {};
|
|
191
|
+
for (const finding of findings.filter(isLocalFinding)) {
|
|
192
|
+
counts[finding.file] = (counts[finding.file] ?? 0) + 1;
|
|
193
|
+
}
|
|
194
|
+
return counts;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function scan(files: readonly SourceFile[]): Finding[] {
|
|
198
|
+
const findings: Finding[] = [];
|
|
199
|
+
for (const sf of files) {
|
|
200
|
+
for (const finding of scanTimeouts(sf)) {
|
|
201
|
+
findings.push(finding);
|
|
202
|
+
console.warn(` [test-timeouts WARN] ${finding.file}:${finding.line} ${finding.message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return findings;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
|
|
209
|
+
const findings = scan(files);
|
|
210
|
+
if (!compareBaseline) {
|
|
211
|
+
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
212
|
+
return { violations: [] };
|
|
213
|
+
}
|
|
214
|
+
const local = findings.filter(isLocalFinding);
|
|
215
|
+
const violations: GuardViolation[] = timeoutsBaseline.check(
|
|
216
|
+
baselineCounts(findings),
|
|
217
|
+
REMEDIATION,
|
|
218
|
+
{
|
|
219
|
+
formatDriftRemediation: "Run `bun guards/guard-test-timeouts.ts --write-baseline` once.",
|
|
220
|
+
resolveLine: (file) => local.find((f) => f.file === file)?.line ?? 1,
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
return { violations };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export const guard: AstGuard = {
|
|
227
|
+
name: "test-timeouts",
|
|
228
|
+
scan: SCAN,
|
|
229
|
+
hint: HINT,
|
|
230
|
+
run: (files) => analyse(files, true),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Flags are read only here, never in run() — the shared runner drives every
|
|
234
|
+
// guard with the same argv, and a --write-baseline meant for another guard
|
|
235
|
+
// must not silently refreeze this one.
|
|
236
|
+
if (import.meta.main) {
|
|
237
|
+
const args = process.argv.slice(2);
|
|
238
|
+
if (args.includes("--write-baseline")) {
|
|
239
|
+
const project = buildSharedProject([guard]);
|
|
240
|
+
timeoutsBaseline.write(baselineCounts(scan(filesForGuard(project, guard))));
|
|
241
|
+
process.exit(0);
|
|
242
|
+
}
|
|
243
|
+
if (args.includes("--no-baseline")) {
|
|
244
|
+
const project = buildSharedProject([guard]);
|
|
245
|
+
analyse(filesForGuard(project, guard), false);
|
|
246
|
+
process.exit(0);
|
|
247
|
+
}
|
|
248
|
+
runStandalone(guard);
|
|
249
|
+
}
|
package/src/run-guards.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { guard as sectionFieldsRaw } from "./guard-section-fields-raw";
|
|
|
48
48
|
import { guard as silentSkip } from "./guard-silent-skip";
|
|
49
49
|
import { guard as tableDdl } from "./guard-table-ddl";
|
|
50
50
|
import { guard as tenantEscalation } from "./guard-tenant-escalation";
|
|
51
|
+
import { guard as testTimeouts } from "./guard-test-timeouts";
|
|
51
52
|
import { guard as textFieldStance } from "./guard-text-field-stance";
|
|
52
53
|
import { guard as unsafeJsonParse } from "./guard-unsafe-json-parse";
|
|
53
54
|
import { guard as writeHandlerQns } from "./guard-write-handler-qns";
|
|
@@ -87,6 +88,7 @@ export const GUARDS = [
|
|
|
87
88
|
tableDdl,
|
|
88
89
|
appFeatureStructure,
|
|
89
90
|
libTestCoverage,
|
|
91
|
+
testTimeouts,
|
|
90
92
|
];
|
|
91
93
|
|
|
92
94
|
export const GUARD_FLAGS = [
|