@avi2dg/checks 0.55.0 → 0.56.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/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.56.0
6
+
7
+ Released 2026-10-27.
8
+
9
+ ### Features
10
+
11
+ - **core:** scope lint gates by changed paths and add desk run [#11](https://github.com/avi2d/checks/pull/11)
12
+
5
13
  ## 0.55.0
6
14
 
7
15
  Released 2026-10-27.
package/docs/bins.md CHANGED
@@ -10,6 +10,7 @@ The kit ships one bin per check, each named `checks-` and called from a package
10
10
 
11
11
  `checks-lint` runs each of these gates that applies to the repository, and names every one that fails.
12
12
  A gate reads either the working tree or the range `checks-lint` resolves.
13
+ It scopes tree gates to the change unless `--full` forces the full run, and `--desk` runs the fast tree gates.
13
14
  The table groups the gates by vector, the part of a repository each one judges.
14
15
 
15
16
  <!-- generated gates: bun run build writes it from KIT_GATES in src/core/gates.ts and scripts/doc-blocks.ts -->
@@ -61,7 +61,7 @@ A rule under `forbidden` that takes a kit rule's name replaces that rule whole,
61
61
 
62
62
  ## Running it
63
63
 
64
- `checks-lint` runs [checks-imports](../gates/checks-imports.md), which cruises every tracked TypeScript file against `dependency-cruiser.config.ts`, or against the kit's defaults when the repository holds no config.
64
+ `checks-lint` runs [checks-imports](../gates/checks-imports.md) in the full run, which cruises every tracked TypeScript file against `dependency-cruiser.config.ts`, or against the kit's defaults when the repository holds no config.
65
65
 
66
66
  ## Related topics
67
67
 
package/docs/design.md CHANGED
@@ -123,10 +123,11 @@ So the three packages move together at one exact version.
123
123
  `checks-lint` runs each gate in a child process rather than importing it.
124
124
  A gate then behaves the same alone or through `checks-lint`, and `lint-coverage.sh` can stay a shell script.
125
125
  The gates run one at a time and pass their output straight through, so each report reads whole and in the order of the gate table.
126
- `checks-lint` picks the gates that apply from the tracked files, and a repository cannot select gates.
127
- A TypeScript gate runs as soon as the repository tracks TypeScript source, and `checks-lint-coverage` and `checks-unused` also run on tracked Astro source.
126
+ `checks-lint` picks the gates that apply from the tracked files for the full run, and a repository cannot select gates.
127
+ A scoped run selects a subset by changed paths, and the desk run selects the fast tree gates.
128
+ A TypeScript gate applies as soon as the repository tracks TypeScript source, and `checks-lint-coverage` and `checks-unused` also apply on tracked Astro source.
128
129
 
129
- dependency-cruiser runs as the `checks-imports` gate rather than as a script each repository adds to `lint`, so no repository can leave it out.
130
+ dependency-cruiser runs as the `checks-imports` gate rather than as a script each repository adds to `lint`, so the full run keeps it.
130
131
  A repository with no config of its own is cruised against the kit's defaults, since every rule has one.
131
132
  The gate cruises the tracked TypeScript, so a build output or a linked library outside git never reaches the cruise.
132
133
  It runs dependency-cruiser under Bun, which loads a TypeScript config wherever it sits, the kit's defaults under `node_modules` among them.
@@ -131,7 +131,7 @@ ci-wiring: 1 reference(s) name a missing workflow or job:
131
131
 
132
132
  ## When it runs
133
133
 
134
- `checks-lint` runs it in every repository.
134
+ `checks-lint` runs it in the full run in every repository.
135
135
 
136
136
  ## Related topics
137
137
 
@@ -107,7 +107,7 @@ copy: no violation in 2 file(s) from 2 declared input(s)
107
107
 
108
108
  ## When it runs
109
109
 
110
- `checks-lint` runs it when the repository tracks `copy.json` at its root.
110
+ `checks-lint` runs it in the full run when the repository tracks `copy.json` at its root.
111
111
  A repository without that file never runs it and needs no html-validate.
112
112
 
113
113
  ## Related topics
@@ -44,7 +44,7 @@ css-size: 1 violation(s)
44
44
 
45
45
  ## When it runs
46
46
 
47
- `checks-lint` runs it when the repository tracks a `*.css` file.
47
+ `checks-lint` runs it in the full run when the repository tracks a `*.css` file.
48
48
 
49
49
  ## Related topics
50
50
 
@@ -55,7 +55,7 @@ effect-scope: the @effect/language-service entry in tsconfig.json differs from t
55
55
  ## When it runs
56
56
 
57
57
  A repository runs `checks-effect-scope` from its `build` script, so CI's `git diff --exit-code` after the build fails on a stale `tsconfig.json` too.
58
- `checks-lint` runs it with `--check` when the repository tracks `oxlint.config.ts`.
58
+ `checks-lint` runs it with `--check` in the full run when the repository tracks `oxlint.config.ts`.
59
59
 
60
60
  ## Related topics
61
61
 
@@ -177,7 +177,7 @@ frontend-syntax: no violation in 2 file(s) from 2 declared input(s)
177
177
 
178
178
  ## When it runs
179
179
 
180
- `checks-lint` runs it when the repository tracks `frontend-syntax.json` at its root.
180
+ `checks-lint` runs it in the full run when the repository tracks `frontend-syntax.json` at its root.
181
181
  A repository without that file never runs it and needs neither `stylelint.config.ts` nor the peers.
182
182
 
183
183
  ## Related topics
@@ -56,7 +56,7 @@ imports: 225 module(s) cruised against dependency-cruiser.config.ts, no violatio
56
56
 
57
57
  ## When it runs
58
58
 
59
- `checks-lint` runs it when the repository tracks a `.ts` or `.tsx` file, so a repository with no config of its own still has its imports judged.
59
+ `checks-lint` runs it in the full run when the repository tracks a `.ts` or `.tsx` file, so a repository with no config of its own still has its imports judged, and never in the desk run.
60
60
 
61
61
  ## Related topics
62
62
 
@@ -53,7 +53,7 @@ lint-coverage: the program tsconfig.json builds holds the ts-reset rules is-arra
53
53
 
54
54
  ## When it runs
55
55
 
56
- `checks-lint` runs it when the repository tracks a `.ts`, `.tsx` or `.astro` file.
56
+ `checks-lint` runs it in the full run when the repository tracks a `.ts`, `.tsx` or `.astro` file.
57
57
 
58
58
  ## Related topics
59
59
 
@@ -4,11 +4,16 @@ audience: consumers
4
4
  ---
5
5
  # checks-lint
6
6
 
7
- `checks-lint` runs every applicable kit lint gate over one range and reports each failure.
7
+ `checks-lint` runs every applicable kit lint gate in the selected set over one range and reports each failure.
8
8
 
9
9
  ## What it checks
10
10
 
11
11
  It runs the gates under [What runs](../bins.md#what-runs), each in its own process.
12
+ It scopes the run to the gates the change reaches, and the full run stays the fallback.
13
+ Applicability below selects the full run, and a scoped or desk run selects a subset.
14
+ A Markdown change runs the range gates plus `checks-copy`.
15
+ A TypeScript source change runs every applicable gate.
16
+ A test file classifies as TypeScript, so its change runs every applicable gate too.
12
17
  Gates requiring tracked TypeScript files begin running when the repository tracks TypeScript.
13
18
  `checks-lint-coverage` and `checks-unused` also begin running when the repository tracks an `.astro` file.
14
19
  `checks-advisories` begins running when the repository tracks `bun.lock`.
@@ -31,16 +36,19 @@ Tree gates read the working tree rather than the range.
31
36
 
32
37
  ```sh
33
38
  checks-lint
34
- checks-lint <base-ref> <head-ref>
39
+ checks-lint [--full | --desk]
40
+ checks-lint [--full | --desk] <base-ref> <head-ref>
35
41
  ```
36
42
 
37
43
  With two arguments, the base and head override range discovery.
44
+ `--full` forces the unscoped run.
45
+ `--desk` runs the fast tree gates.
38
46
 
39
47
  ## Exit codes
40
48
 
41
49
  | Code | Result |
42
50
  | --- | --- |
43
- | 0 | Every applicable gate passed. |
51
+ | 0 | Every gate that ran passed. |
44
52
  | 1 | At least one gate found a violation. |
45
53
  | 2 | The range could not resolve or no failed gate could decide. |
46
54
 
@@ -60,6 +68,7 @@ checks-lint: 1 of 19 gate(s) failed: checks-comment-gate
60
68
  A repository runs it from `bun run lint` in a pull request workflow that fetches the whole git history.
61
69
  It leaves out the TypeScript gates while the repository tracks no TypeScript file, except that `checks-lint-coverage` and `checks-unused` run when it tracks an `.astro` file.
62
70
  It never runs `checks-browser`, which a product runs after it builds its site.
71
+ A pull request run scopes the gates to the change.
63
72
 
64
73
  ## Related topics
65
74
 
@@ -51,7 +51,7 @@ no-plain-js: 2 violation(s)
51
51
 
52
52
  ## When it runs
53
53
 
54
- `checks-lint` runs it when the repository tracks TypeScript.
54
+ `checks-lint` runs it in the full run when the repository tracks TypeScript.
55
55
 
56
56
  ## Related topics
57
57
 
@@ -87,7 +87,7 @@ test-layout: 4 violation(s)
87
87
 
88
88
  ## When it runs
89
89
 
90
- `checks-lint` runs it when the repository tracks TypeScript.
90
+ `checks-lint` runs it in the full run when the repository tracks TypeScript.
91
91
  A repository without TypeScript needs neither the `checks-test` script nor `bunfig.toml`.
92
92
 
93
93
  ## Related topics
@@ -57,7 +57,7 @@ unused: no unreferenced files among 110 tracked .ts/.tsx/.astro file(s)
57
57
 
58
58
  ## When it runs
59
59
 
60
- `checks-lint` runs it when the repository tracks a `.ts`, `.tsx` or `.astro` file.
60
+ `checks-lint` runs it in the full run when the repository tracks a `.ts`, `.tsx` or `.astro` file, and never in the desk run.
61
61
  Such a repository names its entries in a Knip configuration.
62
62
 
63
63
  ## Related topics
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -34,6 +34,7 @@
34
34
  "src/core/gates.ts",
35
35
  "src/core/declared-inputs.ts",
36
36
  "src/core/lint.ts",
37
+ "src/core/scope.ts",
37
38
  "src/delivery/changelog.ts",
38
39
  "src/delivery/changelog-write.ts",
39
40
  "src/delivery/release-notes.ts",
@@ -205,6 +206,7 @@
205
206
  },
206
207
  "scripts": {
207
208
  "prepare": "bun src/dependencies/vendor.ts --library effect --package effect --repository https://github.com/Effect-TS/effect.git --tag 'effect@{version}' --path packages/effect/package.json",
209
+ "desk": "bun run test:fast && bun src/core/lint.ts --desk",
208
210
  "build": "bun build src/quality/effect-channel/index.ts --outdir dist/effect-channel --target node --format esm && bun build src/complexity/readability/index.ts --outdir dist/readability --target node --format esm && bun build src/quality/data-shape/index.ts --outdir dist/data-shape --target node --format esm && bun build src/testing/mutation-guard-plugin.ts src/testing/mutation-refusal.ts src/testing/unit-test-order.ts --outdir dist/mutation --target node --format esm --packages external && bun build src/quality/presets/oxlint.ts src/quality/presets/knip.ts src/quality/presets/dependency-cruiser.ts src/quality/presets/stylelint.ts --outdir dist/presets --target node --format esm --packages external && tsc -p tsconfig.presets.json && bun scripts/configs-write.ts && bun src/quality/effect-scope.ts && bun scripts/doc-templates-write.ts && bun src/delivery/changelog-write.ts && bun scripts/doc-blocks-write.ts",
209
211
  "lint": "oxlint && bun src/core/lint.ts",
210
212
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
package/src/core/lint.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
3
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
  import { EVERY_REPOSITORY, KIT_GATES, type KitGate } from "./gates.ts";
5
- import { defaultBranch, git } from "./git.ts";
5
+ import { changedPaths, defaultBranch, git } from "./git.ts";
6
6
  import { runMain, Usage } from "./main.ts";
7
+ import { FAST_TREE_GATES, selectScope } from "./scope.ts";
7
8
 
8
9
  type Range = {
9
10
  readonly refs: readonly [tip: string] | readonly [base: string, head: string];
@@ -21,7 +22,7 @@ class GatesUndecided extends Schema.TaggedError<GatesUndecided>()("GatesUndecide
21
22
  }) {}
22
23
 
23
24
  const NAME = "checks-lint";
24
- const USAGE = "usage: lint.ts [<base-ref> <head-ref>]";
25
+ const USAGE = "usage: lint.ts [--full | --desk] [<base-ref> <head-ref>]";
25
26
  const PULL_REQUEST_EVENT = "pull_request";
26
27
  const SHALLOW_HINT = "a CI checkout needs actions/checkout fetch-depth: 0";
27
28
 
@@ -140,6 +141,15 @@ const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[
140
141
  return { refs: rangeOf(base, head), source: ends.source } satisfies Range;
141
142
  });
142
143
 
144
+ export type LintMode = "full" | "desk" | "scoped";
145
+
146
+ export function splitMode(args: readonly string[]): { readonly mode: LintMode; readonly rest: readonly string[] } | undefined {
147
+ const flags = args.filter((arg) => arg.startsWith("--"));
148
+ const rest = args.filter((arg) => !arg.startsWith("--"));
149
+ if (flags.length > 1 || (flags.length === 1 && flags[0] !== "--full" && flags[0] !== "--desk")) return undefined;
150
+ return { mode: flags.length === 0 ? "scoped" : flags[0] === "--full" ? "full" : "desk", rest };
151
+ }
152
+
143
153
  export function describe({ refs }: Range): string {
144
154
  return refs.length === 1 ? `tip ${refs[0]}` : `range ${refs[0]}..${refs[1]}`;
145
155
  }
@@ -165,15 +175,41 @@ const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
165
175
  return { gate, outcome: outcomeOf(exitCode) };
166
176
  });
167
177
 
178
+ const scopedGates = Effect.fn("scopedGates")(function* (range: Range, root: string) {
179
+ if (range.refs.length === 1) return { bins: undefined, detail: "the full run, since a lone tip scopes nothing" };
180
+ const [base, head] = range.refs;
181
+ const changes = yield* changedPaths(base, head, [], root);
182
+ const committed = changes.flatMap((change) => (change.kind === "renamed" ? [change.from, change.path] : [change.path]));
183
+ const working = (yield* git(["diff", "--no-renames", "--name-only", "-z", "HEAD", "--"], root)).split("\0").filter(Boolean);
184
+ const untracked = (yield* git(["ls-files", "--others", "--exclude-standard", "-z"], root)).split("\0").filter(Boolean);
185
+ const scope = selectScope([...committed, ...working, ...untracked]);
186
+ if (scope.full) return { bins: undefined, detail: "the full run, since the change reaches past a scoped shape" };
187
+ return { bins: scope.gates, detail: "" };
188
+ });
189
+
168
190
  const lint = Effect.gen(function* () {
191
+ const split = splitMode(process.argv.slice(2));
192
+ if (split === undefined) return yield* new Usage({ message: USAGE });
169
193
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
170
- const range = yield* resolveRange(process.argv.slice(2));
194
+ const range = yield* resolveRange(split.rest);
171
195
  yield* Console.log(`${NAME}: ${describe(range)} from ${range.source}`);
172
- const gates = yield* Effect.filter(KIT_GATES, (gate) =>
196
+ const applicable = yield* Effect.filter(KIT_GATES, (gate) =>
173
197
  gate.appliesTo === EVERY_REPOSITORY
174
198
  ? Effect.succeed(true)
175
199
  : git(["ls-files", "--", ...gate.appliesTo.pathspecs], root).pipe(Effect.map((files) => files.trim() !== "")),
176
200
  );
201
+ const scoped = split.mode === "desk"
202
+ ? {
203
+ bins: applicable.filter((gate) => FAST_TREE_GATES.includes(gate.bin)).map((gate) => gate.bin),
204
+ detail: "the desk run",
205
+ }
206
+ : split.mode === "full"
207
+ ? { bins: applicable.map((gate) => gate.bin), detail: "the full run (--full)" }
208
+ : yield* scopedGates(range, root);
209
+ const bins = scoped.bins;
210
+ const gates = bins === undefined ? applicable : applicable.filter((gate) => bins.includes(gate.bin));
211
+ const detail = split.mode === "scoped" && bins !== undefined ? `${gates.length} gate(s) for this change` : scoped.detail;
212
+ yield* Console.log(`${NAME}: ${detail}`);
177
213
  const verdicts = yield* Effect.forEach(gates, (gate) => runGate(gate, range));
178
214
  const failed = verdicts.filter((verdict) => verdict.outcome !== "passed");
179
215
  if (failed.length === 0) {
@@ -0,0 +1,54 @@
1
+ import { KIT_GATES } from "./gates.ts";
2
+
3
+ export type Scope = {
4
+ readonly full: boolean;
5
+ readonly gates: readonly string[];
6
+ };
7
+
8
+ export type ChangeShape = "docs" | "copy" | "typescript" | "workflow" | "lockfile" | "unknown";
9
+
10
+ const TREE_GATES = KIT_GATES.filter((gate) => gate.reads === "tree").map((gate) => gate.bin);
11
+
12
+ // checks-unused and checks-imports each take over a second, so the desk run skips them.
13
+ const SLOW_TREE_GATES: readonly string[] = ["checks-unused", "checks-imports"];
14
+
15
+ export const FAST_TREE_GATES: readonly string[] = TREE_GATES.filter((bin) => !SLOW_TREE_GATES.includes(bin));
16
+
17
+ const CI_CONFIGS = new Set([
18
+ "package.json",
19
+ "bunfig.toml",
20
+ "commitlint.config.ts",
21
+ "oxlint.config.ts",
22
+ "knip.config.ts",
23
+ "knip-base.json",
24
+ "dependency-cruiser.config.js",
25
+ "dependency-cruiser.config.ts",
26
+ ]);
27
+ const basenameOf = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
28
+
29
+ export function classifyPath(path: string): ChangeShape {
30
+ const basename = basenameOf(path);
31
+ if (basename === "bun.lock") return "lockfile";
32
+ if (path.startsWith(".github/") || CI_CONFIGS.has(basename) || /^tsconfig(\..*)?\.json$/.test(basename)) {
33
+ return "workflow";
34
+ }
35
+ if (path === "copy.json") return "copy";
36
+ if (/\.(?:ts|tsx|mts|cts|astro)$/.test(path)) return "typescript";
37
+ if (path.endsWith(".md")) return "docs";
38
+ return "unknown";
39
+ }
40
+
41
+ const fullScope = (): Scope => ({ full: true, gates: KIT_GATES.map((gate) => gate.bin) });
42
+
43
+ export function selectScope(paths: readonly string[]): Scope {
44
+ if (paths.length === 0) return fullScope();
45
+ const shapes = new Set(paths.map(classifyPath));
46
+ if (shapes.has("unknown") || shapes.has("workflow") || shapes.has("lockfile")) return fullScope();
47
+ const copyContent = shapes.has("copy") || shapes.has("docs");
48
+ const tree = shapes.has("typescript") ? TREE_GATES : copyContent ? ["checks-copy"] : [];
49
+ return {
50
+ full: false,
51
+ gates: KIT_GATES.filter((gate) => gate.reads === "range" || tree.includes(gate.bin)).map((gate) => gate.bin),
52
+ };
53
+ }
54
+