@avi2dg/checks 0.57.0 → 0.57.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.57.1
6
+
7
+ Released 2026-10-29.
8
+
9
+ ### Fixes
10
+
11
+ - **complexity:** time out graphwalk and its dependency cruiser child [#18](https://github.com/avi2d/checks/pull/18)
12
+
5
13
  ## 0.57.0
6
14
 
7
15
  Released 2026-10-27.
@@ -1,21 +1,3 @@
1
- import configConventional from "@commitlint/config-conventional";
2
-
3
- const houseTypes: ReadonlyArray<string> = [
4
- "theme",
5
- "renamer",
6
- "shell",
7
- "pi",
8
- "home",
9
- "audit",
10
- "comments",
11
- "unslop",
12
- ];
13
-
14
- const conventionalEnum = configConventional.rules["type-enum"];
15
-
16
1
  export default {
17
2
  extends: ["@commitlint/config-conventional"],
18
- rules: {
19
- "type-enum": [conventionalEnum[0], conventionalEnum[1], [...conventionalEnum[2], ...houseTypes]],
20
- },
21
3
  };
@@ -8,7 +8,7 @@ The shared commitlint config holds each pull request title to the conventional c
8
8
 
9
9
  ## Config
10
10
 
11
- Commits follow `@commitlint/config-conventional` plus the house prefixes `commitlint.config.ts` lists, shared from `node_modules/@avi2dg/checks/commitlint.config.ts`.
11
+ Commits follow `@commitlint/config-conventional`, shared from `node_modules/@avi2dg/checks/commitlint.config.ts`.
12
12
  It arrives with the kit, since `@commitlint/cli` and `@commitlint/config-conventional` are dependencies, not peers.
13
13
 
14
14
  ## Workflow
@@ -28,8 +28,8 @@ import { defineConfig } from "@avi2dg/checks/oxlint";
28
28
  export default defineConfig({
29
29
  effect: {
30
30
  excludeFiles: [
31
- // Launchd runs the renamer, and it cannot import Effect v4.
32
- "home/.config/renamer/**",
31
+ // The host loads this entry outside the Effect runtime.
32
+ "src/host/**",
33
33
  ],
34
34
  },
35
35
  });
@@ -9,7 +9,7 @@ audience: consumers
9
9
  ## What it checks
10
10
 
11
11
  A change is docs-only when every file it touches is a `.md` file at the top level or under `docs/`, and it touches at least one file.
12
- Markdown anywhere else, such as a test fixture under `tests/` or a template under `dist/`, keeps the full run.
12
+ Markdown anywhere else, such as a test fixture under `tests/` or a template under `dist/`, keeps the typecheck and unit test steps running.
13
13
  A rename counts both sides.
14
14
  An empty change and a change with no range keep the full run.
15
15
 
@@ -41,10 +41,11 @@ docs-only=true
41
41
 
42
42
  ## When it runs
43
43
 
44
- Each of the `static`, `unit` and `e2e` jobs of the `ci` workflow appends the verdict to `GITHUB_OUTPUT` in a filter step.
44
+ The `static` and `unit` jobs of the `ci` workflow append the verdict to `GITHUB_OUTPUT` in a filter step.
45
45
  Their typecheck and test steps skip when the verdict reads `true`.
46
+ The `e2e` job does not read this verdict: `scripts/e2e-skip.ts` skips it for a change of only Markdown and the root `copy.json`, a wider set than this bin's.
46
47
  The build, its `git diff --exit-code` and `bun run lint` still run, so `checks-docs` and the generated blocks hold on a docs-only change.
47
- Every job still runs and reports, so every required check stays green on a docs-only change.
48
+ The `checks` job still runs and reports, so its required check stays green on a docs-only change.
48
49
 
49
50
  ## Related topics
50
51
 
@@ -55,6 +55,14 @@ It cruises against the first config it finds there:
55
55
  It runs dependency-cruiser from the installed kit under Bun, which loads a TypeScript config wherever it sits.
56
56
  It leaves out a tracked file the working tree no longer holds.
57
57
 
58
+ ## Time limits
59
+
60
+ A run that lasts past 300 seconds stops, prints `graphwalk: timed out after 300s; set CHECKS_GRAPHWALK_TIMEOUT_SECONDS to allow longer` and exits 2.
61
+ A dependency-cruiser run that lasts past 120 seconds stops, and the imports section prints `imports: dependency-cruiser timed out after 120s; set CHECKS_DEPCRUISE_TIMEOUT_SECONDS to allow longer` and cannot decide.
62
+ `CHECKS_GRAPHWALK_TIMEOUT_SECONDS` and `CHECKS_DEPCRUISE_TIMEOUT_SECONDS` each take a whole number of seconds above 0, and any other value leaves the run or the section undecided.
63
+ When a limit passes, the gate sends SIGTERM to the process group of each process it started.
64
+ It sends SIGKILL 2 seconds later to any process still in that group, so nothing it started outlives the gate.
65
+
58
66
  ## Arguments
59
67
 
60
68
  ```sh
@@ -81,9 +89,10 @@ A seed written after the range adds a dead export, or after it removes the last
81
89
  | --- | --- |
82
90
  | 0 | no file is unreferenced, no symbol is unlisted, added or stale, and no import breaks an `error` rule, or `--write` recorded the baseline |
83
91
  | 1 | a file is unreferenced, a symbol is unlisted, added or stale, an import breaks an `error` rule, or the repository holds no Knip configuration |
84
- | 2 | a ref does not resolve, a scanner cannot run, or a Knip report, a cruise report or the baseline does not parse |
92
+ | 2 | a ref does not resolve, a scanner cannot run, the run or dependency-cruiser passes its time limit, or a Knip report, a cruise report or the baseline does not parse |
85
93
 
86
94
  The gate exits 1 when any section reports a violation, and exits 2 only when no section reports one and at least one section cannot decide.
95
+ When the run passes its time limit the gate exits 2 even when a section already reported a violation.
87
96
 
88
97
  It also exits 2 when the repository tracks no `.ts`, `.tsx`, `.astro`, `.mts` or `.cts` file, or, with `--write`, when it tracks no TypeScript file.
89
98
  A repository that tracks `.astro` files but no `.ts`, `.tsx`, `.mts` or `.cts` file prints only the unreferenced-files section.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.57.0",
3
+ "version": "0.57.1",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { BunServices } from "@effect/platform-bun";
3
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { Config, Console, Duration, Effect, FileSystem, Path, Schema } from "effect";
4
4
  import { LINTED_SOURCE, TYPESCRIPT_SOURCE, type TrackedContent } from "../core/gates.ts";
5
5
  import { checkoutFiles, collect, commitOf, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
6
6
  import { PASSED, runExitMain, UNDECIDED, Usage, VIOLATED } from "../core/main.ts";
@@ -34,6 +34,10 @@ export const CRUISED = ["*.ts", "*.tsx", "*.mts", "*.cts"] as const;
34
34
 
35
35
  const KIT_DEFAULTS = "kit-defaults.ts";
36
36
 
37
+ type TimeLimit = { readonly variable: string; readonly seconds: number };
38
+ const RUN_LIMIT = { variable: "CHECKS_GRAPHWALK_TIMEOUT_SECONDS", seconds: 300 } satisfies TimeLimit;
39
+ const CRUISE_LIMIT = { variable: "CHECKS_DEPCRUISE_TIMEOUT_SECONDS", seconds: 120 } satisfies TimeLimit;
40
+
37
41
  export type Status = "passed" | "violated" | "undecided";
38
42
 
39
43
  class GraphwalkError extends Schema.TaggedError<GraphwalkError>()("GraphwalkError", {
@@ -75,6 +79,15 @@ function outside(symbols: readonly UnusedSymbol[], others: readonly UnusedSymbol
75
79
  return symbols.filter((symbol) => !known.has(keyOf(symbol))).toSorted(byPosition);
76
80
  }
77
81
 
82
+ const withinLimit = Effect.fn("withinLimit")(function* <A, E, R>(prefix: string, { variable, seconds: fallback }: TimeLimit, body: Effect.Effect<A, E, R>) {
83
+ const seconds = yield* Config.schema(Schema.Int.check(Schema.isGreaterThan(0)), variable).pipe(
84
+ Config.withDefault(fallback),
85
+ Effect.mapError(() => new GraphwalkError({ message: `${variable} must be a whole number of seconds above 0` })),
86
+ );
87
+ const message = `${prefix}timed out after ${seconds}s; set ${variable} to allow longer`;
88
+ return yield* body.pipe(Effect.timeoutOrElse({ duration: Duration.seconds(seconds), orElse: () => Effect.fail(new GraphwalkError({ message })) }));
89
+ });
90
+
78
91
  const describe = (symbol: UnusedSymbol): string => ` ${symbol.file}: ${symbol.name} (${symbol.kind})`;
79
92
 
80
93
  export const filesOf = Effect.fn("filesOf")(function* (stdout: string) {
@@ -267,9 +280,10 @@ const importsSection = (root: string) =>
267
280
  const files = yield* trackedInTree(root);
268
281
  if (files.length === 0) return yield* new GraphwalkError({ message: "no tracked TypeScript to cruise" });
269
282
  const config = yield* configOf(root);
270
- const run = yield* collect(process.execPath, [yield* depcruise(), "--config", config.file, "--output-type", "json", ...files], root).pipe(
283
+ const cruise = collect(process.execPath, [yield* depcruise(), "--config", config.file, "--output-type", "json", ...files], root).pipe(
271
284
  Effect.mapError((cause) => new GraphwalkError({ message: `cannot run dependency-cruiser: ${cause.message}` })),
272
285
  );
286
+ const run = yield* withinLimit("dependency-cruiser ", CRUISE_LIMIT, cruise);
273
287
  const { summary } = yield* decodeCruise(run.stdout).pipe(
274
288
  Effect.mapError(
275
289
  () => new GraphwalkError({ message: `dependency-cruiser exits ${run.exitCode} with no report: ${(run.stdout + run.stderr).trim()}` }),
@@ -369,6 +383,10 @@ const gate = Effect.gen(function* () {
369
383
  ),
370
384
  );
371
385
 
386
+ const timedGate = withinLimit("", RUN_LIMIT, gate).pipe(
387
+ Effect.catch((cause) => Console.error(`${NAME}: ${cause.message}`).pipe(Effect.as(UNDECIDED))),
388
+ );
389
+
372
390
  if (import.meta.main) {
373
- runExitMain(gate);
391
+ runExitMain(timedGate);
374
392
  }
package/src/core/git.ts CHANGED
@@ -15,12 +15,15 @@ export type Feed = {
15
15
  readonly input?: string;
16
16
  };
17
17
 
18
+ // Without a SIGKILL deadline, an interrupted child that ignores SIGTERM holds its scope open forever.
19
+ const KILL_GRACE = "2 seconds";
20
+
18
21
  // Both pipes drain while the program runs: one left unread fills its buffer and stalls it.
19
22
  export const collect = Effect.fn("collect")(
20
23
  function* (program: string, args: readonly string[], cwd?: string, { env, input }: Feed = {}) {
21
24
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
22
25
  const stdin = input === undefined ? undefined : Stream.make(new TextEncoder().encode(input));
23
- const handle = yield* spawner.spawn(ChildProcess.make(program, args, { cwd, env, extendEnv: true, stdin }));
26
+ const handle = yield* spawner.spawn(ChildProcess.make(program, args, { cwd, env, extendEnv: true, stdin, forceKillAfter: KILL_GRACE }));
24
27
  const [stdout, stderr, exitCode] = yield* Effect.all([text(handle.stdout), text(handle.stderr), handle.exitCode], {
25
28
  concurrency: "unbounded",
26
29
  });
@@ -101,7 +101,7 @@ const land = Effect.fn("land")(function* (root: string, base: string, head: stri
101
101
  Effect.gen(function* () {
102
102
  const now = listed.split("\t")[0];
103
103
  if (now === release) return "landed";
104
- if (now === head) return yield* Effect.fail(failure);
104
+ if (now === head) return yield* failure;
105
105
  yield* git(["fetch", "--quiet", "--no-tags", "origin", `refs/heads/${base}`], root);
106
106
  const ancestor = yield* git(["merge-base", "--is-ancestor", release, "FETCH_HEAD"], root).pipe(
107
107
  Effect.as(true),