@avi2dg/checks 0.54.1 → 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,22 @@
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
+
13
+ ## 0.55.0
14
+
15
+ Released 2026-10-27.
16
+
17
+ ### Features
18
+
19
+ - **dependencies:** defer the advisory scan to the scheduled run [#10](https://github.com/avi2d/checks/pull/10)
20
+
5
21
  ## 0.54.1
6
22
 
7
23
  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.
@@ -14,6 +14,7 @@ It ignores aliases, because two live advisories can list each other as aliases.
14
14
  An advisory published against a package the base already held shows at both ends, so it fails no range, and `--all` reports it instead.
15
15
  Each failure names the package, its version, the advisory id, its severity and its summary.
16
16
  A range that leaves `bun.lock` unchanged runs no scan.
17
+ A run `checks-lint` starts defers the scan to the scheduled `--all` run instead, so the desk and the pull request neither fetch the database nor scan.
17
18
  It also holds `advisory-acks.json` to the rules in [The acknowledgement file](#the-acknowledgement-file) on every run.
18
19
 
19
20
  ## What it reads
@@ -62,6 +63,7 @@ An entry whose `until` falls more than 30 days after that moment fails the run a
62
63
  Once that moment passes `until`, the entry fails every run until it is deleted or renewed with a later `until`, whether or not the range touches `bun.lock`.
63
64
  Delete it once the package is upgraded.
64
65
  When a scan runs, an entry that matches no advisory at the head fails, so the file holds only live entries.
66
+ A deferred run still holds each entry to its day, while an entry that matches nothing waits for the scheduled run.
65
67
  A file that does not decode as that list exits 2.
66
68
 
67
69
  ## Arguments
@@ -80,9 +82,9 @@ With `--all` it fails on every advisory in `bun.lock` at `HEAD` that no entry co
80
82
 
81
83
  | Code | When |
82
84
  | --- | --- |
83
- | 0 | the range adds no advisory to `bun.lock`, and every acknowledgement holds |
84
- | 1 | the range adds an advisory no acknowledgement covers, or an acknowledgement does not hold |
85
- | 2 | a ref does not resolve, `advisory-acks.json` does not decode, or no verified scanner or usable database is at hand |
85
+ | 0 | the range adds no advisory on a direct scan or an unchanged range and every acknowledgement holds, or a deferred run holds every acknowledgement without a scan |
86
+ | 1 | a direct scan adds an advisory no acknowledgement covers, or an acknowledgement does not hold on any run |
87
+ | 2 | a ref does not resolve, `advisory-acks.json` does not decode, or a direct scan has no verified scanner or usable database at hand |
86
88
 
87
89
  ## Sample output
88
90
 
@@ -97,7 +99,8 @@ advisories: 1 acknowledgement(s) in advisory-acks.json do not hold:
97
99
  ## When it runs
98
100
 
99
101
  `checks-lint` runs it over each pull request's range in a repository that tracks `bun.lock`, as [checks-lint](checks-lint.md) says.
100
- A range that leaves `bun.lock` unchanged runs in under a second, and one that changes it scans for about 10 seconds on a warm cache.
102
+ That run defers the scan to the scheduled run, so it fetches no database and reports the deferral instead of a verdict.
103
+ A range that leaves `bun.lock` unchanged runs in under a second, and a direct run over a range that changes `bun.lock` scans for about 10 seconds on a warm cache.
101
104
 
102
105
  ## Running it on a schedule
103
106
 
@@ -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,14 +4,20 @@ 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`.
20
+ `checks-lint` defers its scan to the scheduled run, so the desk and the pull request fetch no database.
15
21
  `checks-frontend-syntax` begins running when the repository tracks `frontend-syntax.json` at its root.
16
22
  `checks-copy` begins running when the repository tracks `copy.json` at its root.
17
23
  `checks-css-size` begins running when the repository tracks a `*.css` file.
@@ -30,16 +36,19 @@ Tree gates read the working tree rather than the range.
30
36
 
31
37
  ```sh
32
38
  checks-lint
33
- checks-lint <base-ref> <head-ref>
39
+ checks-lint [--full | --desk]
40
+ checks-lint [--full | --desk] <base-ref> <head-ref>
34
41
  ```
35
42
 
36
43
  With two arguments, the base and head override range discovery.
44
+ `--full` forces the unscoped run.
45
+ `--desk` runs the fast tree gates.
37
46
 
38
47
  ## Exit codes
39
48
 
40
49
  | Code | Result |
41
50
  | --- | --- |
42
- | 0 | Every applicable gate passed. |
51
+ | 0 | Every gate that ran passed. |
43
52
  | 1 | At least one gate found a violation. |
44
53
  | 2 | The range could not resolve or no failed gate could decide. |
45
54
 
@@ -59,6 +68,7 @@ checks-lint: 1 of 19 gate(s) failed: checks-comment-gate
59
68
  A repository runs it from `bun run lint` in a pull request workflow that fetches the whole git history.
60
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.
61
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.
62
72
 
63
73
  ## Related topics
64
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.54.1",
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/gates.ts CHANGED
@@ -8,6 +8,9 @@ export type TrackedContent = {
8
8
  readonly content: string;
9
9
  };
10
10
 
11
+ // checks-lint sets this when it spawns the advisories gate, so the desk and pull request runs leave the database fetch and the scan to the scheduled --all run.
12
+ export const ADVISORIES_DEFER_SCAN = "CHECKS_ADVISORIES_DEFER_SCAN";
13
+
11
14
  export const EVERY_REPOSITORY = "every repository";
12
15
 
13
16
  export const VECTORS = ["complexity", "quality", "testing", "docs", "delivery", "dependencies"] as const;
@@ -21,6 +24,7 @@ export type KitGate = {
21
24
  readonly reads: "tree" | "range";
22
25
  readonly alsoReads?: string;
23
26
  readonly args?: readonly string[];
27
+ readonly env?: Readonly<Record<string, string>>;
24
28
  readonly appliesTo: typeof EVERY_REPOSITORY | TrackedContent;
25
29
  };
26
30
 
@@ -63,5 +67,12 @@ export const KIT_GATES = [
63
67
  { bin: "checks-exports", vector: "complexity", file: "exports.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
64
68
  { bin: "checks-quarantine-clock", vector: "testing", file: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
65
69
  { bin: "checks-imports", vector: "dependencies", file: "imports.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
66
- { bin: "checks-advisories", vector: "dependencies", file: "advisories.ts", reads: "range", appliesTo: BUN_LOCKFILE },
70
+ {
71
+ bin: "checks-advisories",
72
+ vector: "dependencies",
73
+ file: "advisories.ts",
74
+ reads: "range",
75
+ env: { [ADVISORIES_DEFER_SCAN]: "1" },
76
+ appliesTo: BUN_LOCKFILE,
77
+ },
67
78
  ] as const satisfies readonly KitGate[];
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
  }
@@ -154,8 +164,9 @@ const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
154
164
  const script = (yield* Path.Path).join(import.meta.dir, "..", gate.vector, gate.file);
155
165
  const program = gate.file.endsWith(".sh") ? "sh" : process.execPath;
156
166
  const args = gate.reads === "range" ? [script, ...range.refs] : [script, ...(gate.args ?? [])];
167
+ const extra = gate.env === undefined ? {} : { env: gate.env, extendEnv: true };
157
168
  const exitCode = yield* spawner
158
- .exitCode(ChildProcess.make(program, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit" }))
169
+ .exitCode(ChildProcess.make(program, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit", ...extra }))
159
170
  .pipe(
160
171
  Effect.catch((cause) =>
161
172
  Console.error(`${NAME}: cannot run ${gate.bin}: ${cause.message}`).pipe(Effect.as(ChildProcessSpawner.ExitCode(2))),
@@ -164,15 +175,41 @@ const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
164
175
  return { gate, outcome: outcomeOf(exitCode) };
165
176
  });
166
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
+
167
190
  const lint = Effect.gen(function* () {
191
+ const split = splitMode(process.argv.slice(2));
192
+ if (split === undefined) return yield* new Usage({ message: USAGE });
168
193
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
169
- const range = yield* resolveRange(process.argv.slice(2));
194
+ const range = yield* resolveRange(split.rest);
170
195
  yield* Console.log(`${NAME}: ${describe(range)} from ${range.source}`);
171
- const gates = yield* Effect.filter(KIT_GATES, (gate) =>
196
+ const applicable = yield* Effect.filter(KIT_GATES, (gate) =>
172
197
  gate.appliesTo === EVERY_REPOSITORY
173
198
  ? Effect.succeed(true)
174
199
  : git(["ls-files", "--", ...gate.appliesTo.pathspecs], root).pipe(Effect.map((files) => files.trim() !== "")),
175
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}`);
176
213
  const verdicts = yield* Effect.forEach(gates, (gate) => runGate(gate, range));
177
214
  const failed = verdicts.filter((verdict) => verdict.outcome !== "passed");
178
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
+
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Clock, Config, Console, Effect, FileSystem, type Layer, Option, Path, Schema } from "effect";
3
+ import { ADVISORIES_DEFER_SCAN } from "../core/gates.ts";
3
4
  import { changedPaths, commitOf, git, pathsAt, rangeFromArgs, writtenAt } from "../core/git.ts";
4
5
  import type { BunServices } from "@effect/platform-bun";
5
6
  import { runMain, Usage } from "../core/main.ts";
@@ -80,10 +81,17 @@ const findingsOf = Effect.fn("findingsOf")(function* (lockfiles: Lockfiles) {
80
81
  return { base: findingsIn(scanned, at("base")), head: findingsIn(scanned, at("head")) };
81
82
  }, Effect.scoped);
82
83
 
83
- const outcomeOf = Effect.fn("outcomeOf")(function* (request: Request, root: string, clock: AcknowledgementClock) {
84
+ const defersScan = Effect.fn("defersScan")(function* () {
85
+ const value = yield* Config.option(Config.String(ADVISORIES_DEFER_SCAN));
86
+ return Option.isSome(value) && value.value.trim() !== "";
87
+ });
88
+
89
+ const outcomeOf = Effect.fn("outcomeOf")(function* (request: Request, root: string, clock: AcknowledgementClock, deferred: boolean) {
84
90
  if (request.kind === "range" && (yield* changedPaths(request.base, request.head, [LOCKFILE], root)).length === 0) {
85
91
  return { kind: "unchanged", problems: clock.problems } satisfies Outcome;
86
92
  }
93
+ // The lint path leaves the verdict to the scheduled run, so a changed lockfile reports the deferral instead of scanning.
94
+ if (deferred && request.kind === "range") return { kind: "deferred", problems: clock.problems } satisfies Outcome;
87
95
  const head = yield* fileAt(request.head, LOCKFILE, root);
88
96
  const base = request.kind === "range" ? yield* fileAt(request.base, LOCKFILE, root) : Option.none<string>();
89
97
  const findings = Option.isNone(head) ? { base: [], head: [] } : yield* findingsOf({ base, head });
@@ -101,7 +109,7 @@ const advisories = Effect.gen(function* () {
101
109
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
102
110
  const dates = { head: (yield* writtenAt(request.head, root)) * 1000, now: yield* Clock.currentTimeMillis };
103
111
  const clock = clockOf(yield* acknowledgementsAt(request.head, root), { kind: request.kind }, dates);
104
- const outcome = yield* outcomeOf(request, root, clock);
112
+ const outcome = yield* outcomeOf(request, root, clock, yield* defersScan());
105
113
  const text = report(outcome);
106
114
  yield* Console.log(text);
107
115
  if (request.kind === "head") yield* toStepSummary(text);
@@ -151,10 +151,11 @@ export function judge(base: readonly Finding[], head: readonly Finding[], clock:
151
151
 
152
152
  export type Outcome =
153
153
  | { readonly kind: "unchanged"; readonly problems: readonly AcknowledgementProblem[] }
154
+ | { readonly kind: "deferred"; readonly problems: readonly AcknowledgementProblem[] }
154
155
  | { readonly kind: "scanned"; readonly scope: Scope; readonly judged: Judged };
155
156
 
156
157
  export function passes(outcome: Outcome): boolean {
157
- if (outcome.kind === "unchanged") return outcome.problems.length === 0;
158
+ if (outcome.kind === "unchanged" || outcome.kind === "deferred") return outcome.problems.length === 0;
158
159
  return outcome.judged.failing.length === 0 && outcome.judged.problems.length === 0;
159
160
  }
160
161
 
@@ -194,6 +195,12 @@ export function report(outcome: Outcome): string {
194
195
  if (outcome.kind === "unchanged") {
195
196
  return [`advisories: ${LOCKFILE} is unchanged in the range, so nothing was scanned`, ...problemLines(outcome.problems)].join("\n");
196
197
  }
198
+ if (outcome.kind === "deferred") {
199
+ return [
200
+ `advisories: ${LOCKFILE} changed in the range, so nothing was scanned; the scheduled --all run owns the verdict`,
201
+ ...problemLines(outcome.problems),
202
+ ].join("\n");
203
+ }
197
204
  return [...scannedLines(outcome.scope, outcome.judged), ...problemLines(outcome.judged.problems)].join("\n");
198
205
  }
199
206