@cosmicdrift/kumiko-guards 0.294.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.294.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.294.0",
30
+ "@cosmicdrift/kumiko-repo-manifest": "0.295.0",
31
31
  "ts-morph": "^28.0.0"
32
32
  },
33
33
  "publishConfig": {
@@ -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
 
@@ -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 files = scopeHits
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 = [