@cosmicdrift/kumiko-guards 0.310.0 → 0.311.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.310.0",
3
+ "version": "0.311.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.310.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.311.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
package/src/changes.json CHANGED
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.311.0",
4
+ "type": "breaking",
5
+ "title": "test-timeouts is now always-enforcing, plus two new guards (test-template-drift, Real-Provider-Isolation)",
6
+ "detail": "test-timeouts drops its transitional baseline/ratchet — any sleep loop,\ntest.setTimeout/test.slow, or waitForTimeout finding fails the guard run\ndirectly, with no `.kumiko-test-timeouts-baseline.json` grandfathering\npre-existing violations.\nNew AST guard test-template-drift flags apps reimplementing the\nkumiko-testing e2e/screenshot template locally: a Playwright config that\nbuilds its own defineConfig(...) instead of calling defineAppE2eConfig(),\na literal deviceScaleFactor override anywhere (test.use, a project's use,\nor the config's own use block), a literal viewport override in a\nplaywright*.config.ts's own use or a project's use (a spec's own\ntest.use({ viewport }) is not flagged — it's the documented way to assert a\nlayout that only exists below the template's 1920px default; a\n`...devices[\"…\"]` spread stays allowed everywhere), or a direct\npage.screenshot(...) call instead of captureScreenshot.\nNew repo check Real-Provider-Isolation flags a CI workflow that references\nKUMIKO_REAL_PROVIDERS or invokes test:real/e2e:real, a package.json script\nother than test:real/e2e:real that leaks the real-provider env or a\n*.real. spec/test reference, and one of the template's own non-real bunfig\nfiles (bunfig.toml, bunfig.integration.toml, bunfig.dom.toml) missing the\n**/*.real.test.ts exclusion.",
7
+ "migration": "Fix or mark each test-timeouts finding: replace hand-rolled poll loops with\nthe shared `waitFor` from @cosmicdrift/kumiko-framework/testing, or add\n`// @timeout-exception: #<issue> <technical reason>` on the loop's own line\nor the line above for a genuine non-condition wait. Delete any committed\n`.kumiko-test-timeouts-baseline.json`.\nFor test-template-drift: switch a hand-rolled Playwright/screenshot config\nto defineAppE2eConfig(), drop config-level viewport/deviceScaleFactor\noverrides (the template already sets DESKTOP_VIEWPORT and renders\nscreenshot runs at SCREENSHOT_DEVICE_SCALE_FACTOR), and replace direct\npage.screenshot(...) calls with the template's captureScreenshot. Specs\nasserting a layout that only exists below 1920px set their own viewport via\ntest.use({ viewport }) — no exception marker needed. A deliberate exception\nfor anything else the guard flags (e.g. a handbook screenshot helper that\nneeds its own viewport-growth pass) is\n`// @template-drift-exception: #<issue> <technical reason>` on the line\nabove.\nFor Real-Provider-Isolation: remove any KUMIKO_REAL_PROVIDERS/test:real/\ne2e:real reference from CI workflows, move real-provider env/spec\nreferences out of scripts other than test:real/e2e:real, and regenerate\nbunfig.toml/bunfig.integration.toml/bunfig.dom.toml via\n`kumiko-testing bunfig` instead of hand-editing pathIgnorePatterns."
8
+ },
2
9
  {
3
10
  "version": "0.308.0",
4
11
  "type": "improvement",
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Real-Provider-Isolation-Guard (stack-agnostisch, #3118).
4
+ *
5
+ * Real-provider specs (`*.real.test.ts`, `*.real.spec.ts`) hit live third-party
6
+ * services and must only ever run manually, opt-in, via `bun run test:real` /
7
+ * `bun run e2e:real` — never inside a default `bun test`/`playwright test`
8
+ * glob and never inside CI (an accidental `KUMIKO_REAL_PROVIDERS=1` in a
9
+ * workflow spends real quota/credentials on every push).
10
+ *
11
+ * Flags:
12
+ * (a) any CI workflow (`.github/workflows/*.{yml,yaml}`) that references
13
+ * KUMIKO_REAL_PROVIDERS, `test:real`, or `e2e:real` — CI must never run
14
+ * real-provider tests, full stop, so any mention is a violation.
15
+ * (b) one of the template's own non-`real` bunfig files (`bunfig.toml`,
16
+ * `bunfig.integration.toml`, `bunfig.dom.toml` — see BUNFIG_FILES in
17
+ * packages/testing/src/bunfig.ts) whose pathIgnorePatterns doesn't
18
+ * exclude `**\/*.real.test.ts` — the default-glob discovery trap. A
19
+ * repo-specific bunfig outside that set (e.g. a coverage-ratchet
20
+ * config invoked with an explicit path filter, never a bare
21
+ * `bun test`) is not the template's concern and is left alone.
22
+ * (c) a package.json script other than `test:real`/`e2e:real` that sets
23
+ * KUMIKO_REAL_PROVIDERS or references a `.real.` spec/test file.
24
+ *
25
+ * `bunfig.real.toml` itself, and the `test:real`/`e2e:real` scripts, are the
26
+ * accepted, designated home for all three signals — see
27
+ * packages/testing/src/scaffold.ts and packages/testing/src/bunfig.ts, which
28
+ * generate them. Apps that use kumiko-testing's scaffold never trip this;
29
+ * it catches a hand-rolled or drifted bunfig/CI setup.
30
+ */
31
+ import { readFileSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { Glob } from "bun";
34
+ import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
35
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
36
+ import { scanLinesForPredicate, type TextLineFinding } from "./_lib/scan-lines";
37
+
38
+ const REAL_PROVIDERS_ENV = "KUMIKO_REAL_PROVIDERS";
39
+ const REAL_SCRIPT_NAMES: ReadonlySet<string> = new Set(["test:real", "e2e:real"]);
40
+ const REAL_TEST_IGNORE_ENTRY = "**/*.real.test.ts";
41
+
42
+ const CI_WORKFLOW_GLOB = ".github/workflows/*.{yml,yaml}";
43
+ // Only the template's own generated bunfig files (BUNFIG_FILES in
44
+ // packages/testing/src/bunfig.ts) minus the real variant itself — a
45
+ // hand-rolled bunfig outside this set (coverage ratchet, CI-only tweaks)
46
+ // isn't what the template owns and is never invoked as a bare `bun test`.
47
+ const TEMPLATE_OWNED_NON_REAL_BUNFIGS: readonly string[] = [
48
+ "bunfig.toml",
49
+ "bunfig.integration.toml",
50
+ "bunfig.dom.toml",
51
+ ];
52
+
53
+ function ciWorkflowViolation(line: string): boolean {
54
+ return (
55
+ line.includes(REAL_PROVIDERS_ENV) || /\btest:real\b/.test(line) || /\be2e:real\b/.test(line)
56
+ );
57
+ }
58
+
59
+ function scanCiWorkflows(root: RepoRoot, findings: TextLineFinding[]): number {
60
+ let scanned = 0;
61
+ for (const rel of new Glob(CI_WORKFLOW_GLOB).scanSync({ cwd: root.absPath })) {
62
+ scanned++;
63
+ scanLinesForPredicate(join(root.absPath, rel), rel, ciWorkflowViolation, findings);
64
+ }
65
+ return scanned;
66
+ }
67
+
68
+ // package.json scripts: a real-provider signal in any script OTHER than the
69
+ // two designated ones is a leak (e.g. a "test" or "ci" script that pulls in
70
+ // the real env or filters for a .real. spec by hand).
71
+ function scriptLeaksRealProvider(name: string, command: string): string | undefined {
72
+ if (REAL_SCRIPT_NAMES.has(name)) return undefined;
73
+ if (command.includes(REAL_PROVIDERS_ENV)) {
74
+ return `script "${name}" sets ${REAL_PROVIDERS_ENV} outside test:real/e2e:real: ${command}`;
75
+ }
76
+ if (/\.real\.(test|spec)\.ts/.test(command)) {
77
+ return `script "${name}" references a *.real. spec/test outside test:real/e2e:real: ${command}`;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ function scanPackageJsonScripts(root: RepoRoot, findings: TextLineFinding[]): number {
83
+ const path = join(root.absPath, "package.json");
84
+ let parsed: { scripts?: Record<string, string> };
85
+ try {
86
+ parsed = JSON.parse(readFileSync(path, "utf8")) as { scripts?: Record<string, string> };
87
+ } catch {
88
+ return 0;
89
+ }
90
+ const scripts = parsed.scripts ?? {};
91
+ let scanned = 0;
92
+ for (const [name, command] of Object.entries(scripts)) {
93
+ scanned++;
94
+ const message = scriptLeaksRealProvider(name, command);
95
+ if (message !== undefined) {
96
+ findings.push({ file: "package.json", line: 1, text: message });
97
+ }
98
+ }
99
+ return scanned;
100
+ }
101
+
102
+ // The generated bunfig template (packages/testing/src/bunfig.ts) is what app
103
+ // and library repos consume via `kumiko-testing bunfig` — the framework repo
104
+ // hand-maintains its own root bunfig*.toml files and doesn't run this
105
+ // template on itself, so it's excluded here to avoid flagging a convention
106
+ // it was never meant to follow.
107
+ function bunfigMissingRealExclusion(root: RepoRoot, findings: TextLineFinding[]): number {
108
+ if (root.kind === "framework") return 0;
109
+ let scanned = 0;
110
+ for (const rel of TEMPLATE_OWNED_NON_REAL_BUNFIGS) {
111
+ const abs = join(root.absPath, rel);
112
+ let content: string;
113
+ try {
114
+ content = readFileSync(abs, "utf8");
115
+ } catch {
116
+ continue;
117
+ }
118
+ scanned++;
119
+ if (!content.includes(REAL_TEST_IGNORE_ENTRY)) {
120
+ findings.push({
121
+ file: rel,
122
+ line: 1,
123
+ text: `[test] pathIgnorePatterns is missing "${REAL_TEST_IGNORE_ENTRY}" — a default \`bun test\` would pick up real-provider specs`,
124
+ });
125
+ }
126
+ }
127
+ return scanned;
128
+ }
129
+
130
+ export const check: RepoCheck = {
131
+ name: "Real-Provider-Isolation Guard",
132
+ hint:
133
+ `Real-provider tests run only via \`bun run test:real\`/\`bun run e2e:real\`, ` +
134
+ `never in CI and never in a default test glob. Regenerate bunfig via ` +
135
+ `\`kumiko-testing bunfig\` instead of hand-editing pathIgnorePatterns.`,
136
+ async run(roots) {
137
+ if (roots.length === 0) {
138
+ return { violations: [], matchedFiles: 0, notApplicable: true };
139
+ }
140
+ const findings: TextLineFinding[] = [];
141
+ let matchedFiles = 0;
142
+ for (const root of roots) {
143
+ matchedFiles += scanCiWorkflows(root, findings);
144
+ matchedFiles += scanPackageJsonScripts(root, findings);
145
+ matchedFiles += bunfigMissingRealExclusion(root, findings);
146
+ }
147
+ return {
148
+ violations: findings.map((f) => ({ file: f.file, line: f.line, message: f.text })),
149
+ matchedFiles,
150
+ notApplicable: false,
151
+ };
152
+ },
153
+ };
154
+
155
+ if (import.meta.main) {
156
+ const failed = reportResults(await runRepoChecks([check], resolveRepoRoots()));
157
+ process.exit(failed > 0 ? 1 : 0);
158
+ }
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: apps must not locally reimplement the e2e/screenshot template that
4
+ * `@cosmicdrift/kumiko-testing` already owns (#3118). Flags, in Playwright
5
+ * configs and e2e/screenshot specs/helpers:
6
+ * (a) a `playwright*.config.ts` that imports `defineConfig` from
7
+ * `@playwright/test` directly instead of `defineAppE2eConfig` from
8
+ * `@cosmicdrift/kumiko-testing/e2e` — a hand-rolled config drifts from
9
+ * the template's timeouts/retries/workers/real-provider wiring.
10
+ * (b) a literal `deviceScaleFactor: <n>` property anywhere (`test.use(...)`,
11
+ * a project's `use`, or a config's own `use`), or a literal
12
+ * `viewport: {…}` property in a `playwright*.config.ts`'s own `use` or
13
+ * a project's `use` — instead of the template's `DESKTOP_VIEWPORT` /
14
+ * `SCREENSHOT_DEVICE_SCALE_FACTOR`. A spec's own `test.use({ viewport })`
15
+ * is not flagged — the 0.310.0 changeset documents it as the way to
16
+ * assert a layout that only exists below the template's 1920px
17
+ * default. A `...devices["…"]` spread is not a property assignment and
18
+ * is never flagged — device-matrix projects (phone/tablet passes via
19
+ * `runMatrix`) stay allowed.
20
+ * (c) a direct `page.screenshot(...)` call — the template's
21
+ * `captureScreenshot` (fit: viewport/fullPage/content) is the one path
22
+ * apps should call.
23
+ *
24
+ * `packages/testing/**` (the template's own implementation) and
25
+ * `samples/recipes/**` are never scanned.
26
+ *
27
+ * Opt-out on the finding's own line or the line above:
28
+ * `// @template-drift-exception: #<issue> <technical reason>`.
29
+ *
30
+ * Usage (framework dev checkout):
31
+ * bun guards/guard-test-template-drift.ts
32
+ * Consumer (via the published `kumiko-guards` CLI):
33
+ * kumiko-guards guards --guard=test-template-drift
34
+ */
35
+ import { Node, type SourceFile, SyntaxKind } from "ts-morph";
36
+ import {
37
+ type AstGuard,
38
+ type GuardOutcome,
39
+ type GuardViolation,
40
+ isLocalFinding,
41
+ relFromRepoRoot,
42
+ runStandalone,
43
+ type ScanSpec,
44
+ } from "./_lib/guard-kit";
45
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
46
+
47
+ const SCAN: ScanSpec = {
48
+ scope: "tests",
49
+ extensions: ["ts", "tsx"],
50
+ kinds: ["framework", "app", "library"],
51
+ frameworkWithin: ["samples/apps/*/playwright*.config.ts", "samples/apps/*/e2e/**/*.{ts,tsx}"],
52
+ // frameworkWithin only narrows what's already collected here — the
53
+ // samples/apps globs must be listed in extraGlobs too, or nothing under
54
+ // samples/apps ever reaches the framework-kind scan in the first place.
55
+ extraGlobs: [
56
+ "playwright*.config.ts",
57
+ "e2e/**/*.{ts,tsx}",
58
+ "packages/*/e2e/**/*.{ts,tsx}",
59
+ "packages/*/src/e2e/**/*.{ts,tsx}",
60
+ "samples/apps/*/playwright*.config.ts",
61
+ "samples/apps/*/e2e/**/*.{ts,tsx}",
62
+ ],
63
+ };
64
+
65
+ const TESTING_DIR = "/packages/testing/";
66
+ const RECIPES_DIR = "/samples/recipes/";
67
+ const EXCEPTION_TAG = "@template-drift-exception";
68
+ const COMPLETE_EXCEPTION = /@template-drift-exception:\s*#\d+\s+\S/;
69
+
70
+ const CONFIG_FILENAME = /(?:^|\/)playwright[^/]*\.config\.tsx?$/;
71
+ const DEFINE_APP_E2E_CONFIG = "defineAppE2eConfig";
72
+ const DEFINE_APP_E2E_CONFIG_MODULE = "@cosmicdrift/kumiko-testing/e2e";
73
+ const RAW_DEFINE_CONFIG_MODULE = "@playwright/test";
74
+
75
+ const TEMPLATE_OWNED_KEYS: ReadonlySet<string> = new Set(["viewport", "deviceScaleFactor"]);
76
+
77
+ export interface Finding {
78
+ file: string;
79
+ line: number;
80
+ message: string;
81
+ }
82
+
83
+ function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
84
+ return relFromRepoRoot(sf.getFilePath(), roots);
85
+ }
86
+
87
+ function exceptionNote(node: Node): "allowed" | "incomplete" | "none" {
88
+ const lines = node.getSourceFile().getFullText().split("\n");
89
+ const line = node.getStartLineNumber();
90
+ const nearby = [lines[line - 1] ?? "", lines[line - 2] ?? ""];
91
+ if (nearby.some((text) => COMPLETE_EXCEPTION.test(text))) return "allowed";
92
+ return nearby.some((text) => text.includes(EXCEPTION_TAG)) ? "incomplete" : "none";
93
+ }
94
+
95
+ function withExceptionNote(node: Node, message: string): string | undefined {
96
+ const note = exceptionNote(node);
97
+ if (note === "allowed") return undefined;
98
+ return note === "incomplete"
99
+ ? `${message} [incomplete ${EXCEPTION_TAG} marker: needs "#<issue> <technical reason>"]`
100
+ : message;
101
+ }
102
+
103
+ function importsFrom(sf: SourceFile, moduleSuffix: string): boolean {
104
+ return sf
105
+ .getImportDeclarations()
106
+ .some((imp) => imp.getModuleSpecifierValue().endsWith(moduleSuffix));
107
+ }
108
+
109
+ function callsDefineAppE2eConfig(sf: SourceFile): boolean {
110
+ return sf
111
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
112
+ .some((call) => call.getExpression().getText() === DEFINE_APP_E2E_CONFIG);
113
+ }
114
+
115
+ // A raw `defineConfig` import next to a missing `defineAppE2eConfig` call is
116
+ // the hand-rolled-config signal — a file that never imports Playwright's
117
+ // `defineConfig` at all (e.g. a fixture/helper next to the config) isn't one.
118
+ function ownConfigFinding(sf: SourceFile): Finding | undefined {
119
+ if (!CONFIG_FILENAME.test(sf.getFilePath())) return undefined;
120
+ if (!importsFrom(sf, RAW_DEFINE_CONFIG_MODULE)) return undefined;
121
+ if (importsFrom(sf, DEFINE_APP_E2E_CONFIG_MODULE) && callsDefineAppE2eConfig(sf))
122
+ return undefined;
123
+ const message = withExceptionNote(
124
+ sf,
125
+ `Playwright config builds its own defineConfig(...) instead of defineAppE2eConfig() — timeouts, retries, workers and the real-provider spec split drift from the template.`,
126
+ );
127
+ return message === undefined ? undefined : { file: "", line: 1, message };
128
+ }
129
+
130
+ // A `viewport`/`deviceScaleFactor` key is only a Playwright config/test.use
131
+ // override — the drift this guard cares about — when the object literal that
132
+ // holds it is a `test.use(...)` argument or the value of a `use:` property
133
+ // (a config's own use block, or a project's use block). The same key names
134
+ // are also legitimate on unrelated app-owned option objects (e.g. runMatrix's
135
+ // `Scenario.viewport`, a GIF-recorder's per-clip viewport), which must not be
136
+ // flagged.
137
+ function isPlaywrightUseObject(node: Node): boolean {
138
+ const parent = node.getParent();
139
+ if (parent === undefined) return false;
140
+ if (Node.isCallExpression(parent)) {
141
+ const callee = parent.getExpression().getText();
142
+ return callee === "test.use" || callee.endsWith(".use");
143
+ }
144
+ if (Node.isPropertyAssignment(parent)) return parent.getName() === "use";
145
+ return false;
146
+ }
147
+
148
+ function templateOwnedKeyFindings(sf: SourceFile): Finding[] {
149
+ const findings: Finding[] = [];
150
+ const isConfigFile = CONFIG_FILENAME.test(sf.getFilePath());
151
+ for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
152
+ const name = prop.getName();
153
+ if (!TEMPLATE_OWNED_KEYS.has(name)) continue;
154
+ // viewport is only drift in the config's own use/project use — a spec's
155
+ // own test.use({ viewport }) is the documented way (0.310.0 changeset) to
156
+ // assert a layout that only exists below the template's 1920px default.
157
+ // deviceScaleFactor has no such legitimate per-spec override, so it stays
158
+ // flagged everywhere.
159
+ if (name === "viewport" && !isConfigFile) continue;
160
+ const objectLiteral = prop.getParent();
161
+ if (!Node.isObjectLiteralExpression(objectLiteral)) continue;
162
+ if (!isPlaywrightUseObject(objectLiteral)) continue;
163
+ const message = withExceptionNote(
164
+ prop,
165
+ `Literal "${name}" — use the template's DESKTOP_VIEWPORT/SCREENSHOT_DEVICE_SCALE_FACTOR (defineAppE2eConfig) instead of overriding it locally.`,
166
+ );
167
+ if (message !== undefined)
168
+ findings.push({ file: "", line: prop.getStartLineNumber(), message });
169
+ }
170
+ return findings;
171
+ }
172
+
173
+ function isPageScreenshotCall(call: Node): boolean {
174
+ if (!Node.isCallExpression(call)) return false;
175
+ const callee = call.getExpression();
176
+ if (!Node.isPropertyAccessExpression(callee)) return false;
177
+ if (callee.getName() !== "screenshot") return false;
178
+ return /(?:^|\.)page$/i.test(callee.getExpression().getText());
179
+ }
180
+
181
+ function pageScreenshotFindings(sf: SourceFile): Finding[] {
182
+ const findings: Finding[] = [];
183
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
184
+ if (!isPageScreenshotCall(call)) continue;
185
+ const message = withExceptionNote(
186
+ call,
187
+ `Direct page.screenshot(...) call — use the template's captureScreenshot (fit: viewport/fullPage/content) instead of a local reimplementation.`,
188
+ );
189
+ if (message !== undefined)
190
+ findings.push({ file: "", line: call.getStartLineNumber(), message });
191
+ }
192
+ return findings;
193
+ }
194
+
195
+ export function scanTemplateDrift(
196
+ sf: SourceFile,
197
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
198
+ ): Finding[] {
199
+ const path = sf.getFilePath();
200
+ if (path.includes(TESTING_DIR) || path.includes(RECIPES_DIR)) return [];
201
+ const file = relFile(sf, roots);
202
+ const findings: Finding[] = [];
203
+ const own = ownConfigFinding(sf);
204
+ if (own !== undefined) findings.push({ ...own, file });
205
+ for (const f of [...templateOwnedKeyFindings(sf), ...pageScreenshotFindings(sf)]) {
206
+ findings.push({ ...f, file });
207
+ }
208
+ return findings.sort((a, b) => a.line - b.line);
209
+ }
210
+
211
+ const REMEDIATION = `Mark a deliberate exception with \`// ${EXCEPTION_TAG}: #<issue> <technical reason>\`.`;
212
+
213
+ const HINT =
214
+ "Apps consume the e2e/screenshot template through defineAppE2eConfig, not a hand-rolled Playwright config or a local viewport/DPR/screenshot reimplementation. See https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/testing-standard.md.";
215
+
216
+ function analyse(files: readonly SourceFile[], roots: readonly RepoRoot[]): GuardOutcome {
217
+ const findings: Finding[] = [];
218
+ for (const sf of files) {
219
+ for (const finding of scanTemplateDrift(sf, roots)) {
220
+ findings.push(finding);
221
+ console.warn(
222
+ ` [test-template-drift WARN] ${finding.file}:${finding.line} ${finding.message}`,
223
+ );
224
+ }
225
+ }
226
+ const violations: GuardViolation[] = findings
227
+ .filter(isLocalFinding)
228
+ .map((f) => ({ file: f.file, line: f.line, message: `${f.message} ${REMEDIATION}` }));
229
+ return { violations };
230
+ }
231
+
232
+ export const guard: AstGuard = {
233
+ name: "test-template-drift",
234
+ scan: SCAN,
235
+ hint: HINT,
236
+ run: (files, roots = resolveRepoRoots()) => analyse(files, roots),
237
+ };
238
+
239
+ if (import.meta.main) runStandalone(guard);
@@ -16,25 +16,19 @@
16
16
  * without an issue number or a reason does not count and is called out in
17
17
  * the message.
18
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).
19
+ * `samples/recipes/**` is not touched. No baseline, no ratchet: any finding
20
+ * fails. #3118's transition period (a `.kumiko-test-timeouts-baseline.json`
21
+ * grandfathering pre-existing sleep loops) is over — every consumer must fix
22
+ * or mark the cause, not accumulate a pinned count.
23
23
  *
24
24
  * Usage (framework dev checkout):
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
25
+ * bun guards/guard-test-timeouts.ts
28
26
  * Consumer (via the published `kumiko-guards` CLI):
29
- * kumiko-guards guards --write-baseline --guard=test-timeouts
27
+ * kumiko-guards guards --guard=test-timeouts
30
28
  */
31
- import * as path from "node:path";
32
29
  import { type CallExpression, Node, type SourceFile, SyntaxKind } from "ts-morph";
33
30
  import {
34
31
  type AstGuard,
35
- baselineRatchet,
36
- buildSharedProject,
37
- filesForGuard,
38
32
  type GuardOutcome,
39
33
  type GuardViolation,
40
34
  isLocalFinding,
@@ -44,8 +38,6 @@ import {
44
38
  } from "./_lib/guard-kit";
45
39
  import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
46
40
 
47
- const ROOT = process.cwd();
48
-
49
41
  // Playwright dirs sit outside every manifest's sourceRoots/testGlobs, and
50
42
  // scenario helpers next to the specs (not just *.spec.ts) carry the timeouts too.
51
43
  const SCAN: ScanSpec = {
@@ -181,30 +173,12 @@ export function scanTimeouts(
181
173
  return findings.sort((a, b) => a.line - b.line);
182
174
  }
183
175
 
184
- const BASELINE_FILE = ".kumiko-test-timeouts-baseline.json";
185
- const timeoutsBaseline = baselineRatchet({
186
- file: path.join(ROOT, BASELINE_FILE),
187
- formatVersion: 1,
188
- unit: "test timeout workaround(s)",
189
- });
190
-
191
176
  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>\`.`;
192
177
 
193
178
  const HINT =
194
- "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 https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/test-failures.md. After a deliberate change: `kumiko-guards guards --write-baseline --guard=test-timeouts`.";
179
+ "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 https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/test-failures.md.";
195
180
 
196
- export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
197
- const counts: Record<string, number> = {};
198
- for (const finding of findings.filter(isLocalFinding)) {
199
- counts[finding.file] = (counts[finding.file] ?? 0) + 1;
200
- }
201
- return counts;
202
- }
203
-
204
- function scan(
205
- files: readonly SourceFile[],
206
- roots: readonly RepoRoot[] = resolveRepoRoots(),
207
- ): Finding[] {
181
+ function analyse(files: readonly SourceFile[], roots: readonly RepoRoot[]): GuardOutcome {
208
182
  const findings: Finding[] = [];
209
183
  for (const sf of files) {
210
184
  for (const finding of scanTimeouts(sf, roots)) {
@@ -212,29 +186,9 @@ function scan(
212
186
  console.warn(` [test-timeouts WARN] ${finding.file}:${finding.line} ${finding.message}`);
213
187
  }
214
188
  }
215
- return findings;
216
- }
217
-
218
- function analyse(
219
- files: readonly SourceFile[],
220
- roots: readonly RepoRoot[],
221
- compareBaseline: boolean,
222
- ): GuardOutcome {
223
- const findings = scan(files, roots);
224
- if (!compareBaseline) {
225
- console.log(" Baseline comparison skipped (--no-baseline).");
226
- return { violations: [] };
227
- }
228
- const local = findings.filter(isLocalFinding);
229
- const violations: GuardViolation[] = timeoutsBaseline.check(
230
- baselineCounts(findings),
231
- REMEDIATION,
232
- {
233
- formatDriftRemediation:
234
- "Run `kumiko-guards guards --write-baseline --guard=test-timeouts` once.",
235
- resolveLine: (file) => local.find((f) => f.file === file)?.line ?? 1,
236
- },
237
- );
189
+ const violations: GuardViolation[] = findings
190
+ .filter(isLocalFinding)
191
+ .map((f) => ({ file: f.file, line: f.line, message: `${f.message} ${REMEDIATION}` }));
238
192
  return { violations };
239
193
  }
240
194
 
@@ -242,24 +196,7 @@ export const guard: AstGuard = {
242
196
  name: "test-timeouts",
243
197
  scan: SCAN,
244
198
  hint: HINT,
245
- run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
246
- writeBaseline: (files) => timeoutsBaseline.write(baselineCounts(scan(files))),
199
+ run: (files, roots = resolveRepoRoots()) => analyse(files, roots),
247
200
  };
248
201
 
249
- // Flags are read only here, never in run() — the shared runner drives every
250
- // guard with the same argv, and a --write-baseline meant for another guard
251
- // must not silently refreeze this one.
252
- if (import.meta.main) {
253
- const args = process.argv.slice(2);
254
- if (args.includes("--write-baseline")) {
255
- const project = buildSharedProject([guard]);
256
- guard.writeBaseline?.(filesForGuard(project, guard));
257
- process.exit(0);
258
- }
259
- if (args.includes("--no-baseline")) {
260
- const project = buildSharedProject([guard]);
261
- analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
262
- process.exit(0);
263
- }
264
- runStandalone(guard);
265
- }
202
+ if (import.meta.main) runStandalone(guard);
package/src/run-guards.ts CHANGED
@@ -49,6 +49,7 @@ import { guard as sectionFieldsRaw } from "./guard-section-fields-raw";
49
49
  import { guard as silentSkip } from "./guard-silent-skip";
50
50
  import { guard as tableDdl } from "./guard-table-ddl";
51
51
  import { guard as tenantEscalation } from "./guard-tenant-escalation";
52
+ import { guard as testTemplateDrift } from "./guard-test-template-drift";
52
53
  import { guard as testTimeouts } from "./guard-test-timeouts";
53
54
  import { guard as textFieldStance } from "./guard-text-field-stance";
54
55
  import { guard as unsafeJsonParse } from "./guard-unsafe-json-parse";
@@ -90,6 +91,7 @@ export const GUARDS = [
90
91
  appFeatureStructure,
91
92
  libTestCoverage,
92
93
  testTimeouts,
94
+ testTemplateDrift,
93
95
  ];
94
96
 
95
97
  export const GUARD_FLAGS = [
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { cliFlagsError, printGuardKitBanner, reportResults, runRepoChecks } from "./_lib/guard-kit";
3
+ import { check as realProviderIsolation } from "./check-real-provider-isolation";
3
4
  // Standalone-`main()` guards ported as RepoCheck — run in-process, no
4
5
  // per-guard subprocess/project.
5
6
  import { check as runtimeIsolation } from "./check-runtime-isolation";
@@ -24,6 +25,7 @@ export const REPO_CHECKS = [
24
25
  testStackDrift,
25
26
  runtimeIsolation,
26
27
  upgradeState,
28
+ realProviderIsolation,
27
29
  ];
28
30
 
29
31
  // No flags today — the array stays so an unknown flag still fails loud