@cosmicdrift/kumiko-guards 0.370.0 → 0.371.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/hooks/pre-push CHANGED
@@ -11,8 +11,9 @@ if [ -z "$REPO_ROOT" ]; then
11
11
  exit 1
12
12
  fi
13
13
 
14
- # Walk up to / so a worktree without its own node_modules resolves via the
15
- # parent install; `[ -x ]` also skips a broken (uninstalled) symlink.
14
+ # Walk up so a worktree without its own node_modules resolves via the parent
15
+ # install, but not past an enclosing repository: a bin above that boundary
16
+ # belongs to another project. `[ -x ]` also skips a broken (uninstalled) symlink.
16
17
  BIN=""
17
18
  DIR="$REPO_ROOT"
18
19
  while true; do
@@ -24,6 +25,9 @@ while true; do
24
25
  if [ "$DIR" = "/" ]; then
25
26
  break
26
27
  fi
28
+ if [ "$DIR" != "$REPO_ROOT" ] && [ -e "$DIR/.git" ]; then
29
+ break
30
+ fi
27
31
  DIR="$(dirname "$DIR")"
28
32
  done
29
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.370.0",
3
+ "version": "0.371.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.370.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.371.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
package/src/_lib/roots.ts CHANGED
@@ -183,10 +183,25 @@ function isDerivedAppFallback(root: RepoRoot): boolean {
183
183
  return root.manifestSource === "derived" && root.kind === "app";
184
184
  }
185
185
 
186
+ /** The repo of the nearest `kumiko.json` at or above `start`, which is the app root in a git tree that holds several. */
187
+ function nearestManifestRepo(start: string): RepoRoot | undefined {
188
+ for (let curr = start; ; curr = dirname(curr)) {
189
+ if (existsSync(join(curr, REPO_MANIFEST_FILE))) {
190
+ const repo = repoAt(curr);
191
+ if (repo) return repo;
192
+ }
193
+ if (curr === dirname(curr)) return undefined;
194
+ }
195
+ }
196
+
186
197
  /**
187
198
  * The repository `cwd` belongs to.
188
199
  *
189
- * Git first: `rev-parse --show-toplevel` is the checkout root from any
200
+ * A `kumiko.json` file wins over everything: the nearest one above `cwd` is the
201
+ * repo root even when the git checkout root lies further up (an app inside a
202
+ * larger checkout).
203
+ *
204
+ * Otherwise git first: `rev-parse --show-toplevel` is the checkout root from any
190
205
  * subdirectory, package or worktree, independent of folder names. The
191
206
  * package.json walk remains for non-git trees; there a derived-manifest "app"
192
207
  * candidate (no kumiko.json, just a bare `src/` layout) is only a fallback, so
@@ -195,6 +210,8 @@ function isDerivedAppFallback(root: RepoRoot): boolean {
195
210
  */
196
211
  export function findLocalRepo(cwd: string = process.cwd()): RepoRoot | undefined {
197
212
  const start = resolve(cwd);
213
+ const fromManifest = nearestManifestRepo(start);
214
+ if (fromManifest) return fromManifest;
198
215
  const toplevel = gitToplevelOf(start);
199
216
  const fromGit = toplevel !== undefined ? repoAt(toplevel) : undefined;
200
217
  if (fromGit) return fromGit;
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.371.0",
4
+ "type": "improvement",
5
+ "title": "test-timeouts guard flags hook timeouts and scans recipes",
6
+ "detail": "The test-timeouts guard now also fails on a timeout argument to beforeAll, beforeEach, afterAll and afterEach, as a number or as a { timeout } object, including renamed and namespace imports from bun:test. The samples/recipes exemption is gone, recipe tests and their e2e dirs are scanned like every other test. The kumiko.json of the framework repo scans tests under scripts and bin as well. There is no baseline."
7
+ },
8
+ {
9
+ "version": "0.371.0",
10
+ "type": "improvement",
11
+ "title": "import-extensions guard skips imports of build output",
12
+ "detail": "A relative specifier with a dist/ path segment, such as a published bin importing ../dist/cli.js, is no longer reported. The .js there is the only file that exists."
13
+ },
14
+ {
15
+ "version": "0.371.0",
16
+ "type": "improvement",
17
+ "title": "Pre-push shim stops at the repo boundary, nearest kumiko.json wins, process.env guard covers bin",
18
+ "detail": "The pre-push shim searches upward only through ancestors up to the git root and fails with a message when nothing is found. findLocalRepo prefers the nearest kumiko.json over the git root. guard-no-direct-process-env scans bin/**/*.ts including bin/ops."
19
+ },
2
20
  {
3
21
  "version": "0.370.0",
4
22
  "type": "improvement",
@@ -24,6 +24,9 @@ const SCAN: ScanSpec = {
24
24
 
25
25
  const EXCLUDE = /(\.d\.ts$|\/fixtures\/)/;
26
26
  const RELATIVE_JS_SPECIFIER = /^\.{1,2}\/.*\.jsx?$/;
27
+ // A published bin that imports its own build output (`../dist/cli.js`) has no
28
+ // `.ts` sibling to name; the `.js` there is the only file that exists.
29
+ const BUILD_OUTPUT_SEGMENT = /(^|\/)dist\//;
27
30
 
28
31
  interface Finding {
29
32
  readonly line: number;
@@ -33,7 +36,8 @@ interface Finding {
33
36
  function relativeJsSpecifier(node: Node | undefined): string | undefined {
34
37
  const literal = node?.asKind(SyntaxKind.StringLiteral);
35
38
  const value = literal?.getLiteralValue();
36
- return value !== undefined && RELATIVE_JS_SPECIFIER.test(value) ? value : undefined;
39
+ if (value === undefined || !RELATIVE_JS_SPECIFIER.test(value)) return undefined;
40
+ return BUILD_OUTPUT_SEGMENT.test(value) ? undefined : value;
37
41
  }
38
42
 
39
43
  function findJsSpecifiers(sf: SourceFile): Finding[] {
@@ -5,7 +5,7 @@
5
5
  * Apps with composed env-schemas (`bin/env.ts`) must read runtime config from
6
6
  * the validated `env` export — not scattered `process.env.X` in prod paths.
7
7
  *
8
- * Scans bin/main.ts, other bin ts files (excl. allowlist), and src (excl. tests)
8
+ * Scans bin/main.ts, other bin ts files incl. subfolders such as bin/ops/ (excl. allowlist), and src (excl. tests)
9
9
  * in every repo that ships `bin/env.ts`. Allowed raw process.env:
10
10
  * - bin/env.ts — sole parseEnv input
11
11
  * - bin/server.ts — dev entrypoint (runDevApp)
@@ -21,9 +21,9 @@ import { scanLinesForPredicate } from "./_lib/scan-lines";
21
21
  // .tsx deliberately excluded — client screens have no runtime process.env
22
22
  // access (bundler boundary), only .ts server/bin code reads process.env
23
23
  // directly. Add .tsx here if that ever stops being true.
24
- const SCAN_PATTERNS: ReadonlyArray<string> = ["bin/*.ts", "src/**/*.ts"];
24
+ const SCAN_PATTERNS: ReadonlyArray<string> = ["bin/**/*.ts", "src/**/*.ts"];
25
25
 
26
- /** bin/*.ts paths that may read process.env directly (see header). */
26
+ /** bin ts paths that may read process.env directly (see header). */
27
27
  export const ALLOWED_BIN_FILES: ReadonlySet<string> = new Set([
28
28
  "bin/env.ts",
29
29
  "bin/server.ts",
@@ -7,7 +7,9 @@
7
7
  * `test`/`it`/`describe` calls (`test(name, fn, 30_000)`,
8
8
  * `test(name, fn, { timeout })`, also via `.skip/.only/.if()/.each()()`
9
9
  * and renamed/namespace `bun:test` imports) — raising a timeout hides
10
- * the cause of a slow or flaky test
10
+ * the cause of a slow or flaky test. The same holds for a timeout
11
+ * argument on hooks (`beforeAll/beforeEach/afterAll/afterEach(fn, 60_000)`
12
+ * or `(fn, { timeout })`).
11
13
  * (b) `<page>.waitForTimeout(...)` — a fixed sleep instead of a condition
12
14
  * (c) sleep loops — a `while`/`do`/`for`/`for…of`/`for…in` whose body calls
13
15
  * `sleep(...)`/`Bun.sleep(...)` or awaits a `new Promise` that only
@@ -20,7 +22,7 @@
20
22
  * without an issue number or a reason does not count and is called out in
21
23
  * the message.
22
24
  *
23
- * `samples/recipes/**` is not touched. No baseline, no ratchet: any finding
25
+ * No baseline, no ratchet: any finding
24
26
  * fails. #3118's transition period (a `.kumiko-test-timeouts-baseline.json`
25
27
  * grandfathering pre-existing sleep loops) is over — every consumer must fix
26
28
  * or mark the cause, not accumulate a pinned count.
@@ -52,13 +54,13 @@ const SCAN: ScanSpec = {
52
54
  "packages/*/e2e/**/*.{ts,tsx}",
53
55
  "packages/*/src/e2e/**/*.{ts,tsx}",
54
56
  "samples/e2e/**/*.{ts,tsx}",
57
+ "samples/recipes/*/e2e/**/*.{ts,tsx}",
55
58
  "samples/apps/*/e2e/**/*.{ts,tsx}",
56
59
  ],
57
60
  };
58
61
 
59
62
  const EXCEPTION_TAG = "@timeout-exception";
60
63
  const COMPLETE_EXCEPTION = /@timeout-exception:\s*#\d+\s+\S/;
61
- const RECIPES_DIR = "/samples/recipes/";
62
64
 
63
65
  const LOOP_LABELS: ReadonlyMap<SyntaxKind, string> = new Map([
64
66
  [SyntaxKind.WhileStatement, "while"],
@@ -77,6 +79,8 @@ const TEST_FRAMEWORK_MODULES: ReadonlySet<string> = new Set(["bun:test", "@playw
77
79
  const TEST_DECLARATION_CALLEE =
78
80
  /^(test|it|describe)(\.(skip|only|todo|if|each|failing|concurrent|serial|skipIf|runIf|todoIf))*$/;
79
81
 
82
+ const HOOK_DECLARATION_CALLEE = /^(beforeAll|beforeEach|afterAll|afterEach)$/;
83
+
80
84
  export interface Finding {
81
85
  file: string;
82
86
  line: number;
@@ -191,6 +195,11 @@ function declarationTimeoutReason(
191
195
  aliases: ReadonlyMap<string, string>,
192
196
  ): string | undefined {
193
197
  const base = canonicalCalleeText(declarationBaseCallee(call.getExpression()).getText(), aliases);
198
+ if (HOOK_DECLARATION_CALLEE.test(base)) {
199
+ const [, ...hookExtra] = call.getArguments();
200
+ if (!hookExtra.some(isTimeoutArgument)) return undefined;
201
+ return `${base}(…, timeout) raises the timeout instead of fixing the cause`;
202
+ }
194
203
  if (!TEST_DECLARATION_CALLEE.test(base)) return undefined;
195
204
  const [, , ...extra] = call.getArguments();
196
205
  if (!extra.some(isTimeoutArgument)) return undefined;
@@ -229,7 +238,6 @@ export function scanTimeouts(
229
238
  sf: SourceFile,
230
239
  roots: readonly RepoRoot[] = resolveRepoRoots(),
231
240
  ): Finding[] {
232
- if (sf.getFilePath().includes(RECIPES_DIR)) return [];
233
241
  const file = relFile(sf, roots);
234
242
  const findings: Finding[] = [];
235
243
  const add = (node: Node, message: string): void => {