@avi2dg/checks 0.54.0 → 0.55.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.55.0
6
+
7
+ Released 2026-10-27.
8
+
9
+ ### Features
10
+
11
+ - **dependencies:** defer the advisory scan to the scheduled run [#10](https://github.com/avi2d/checks/pull/10)
12
+
13
+ ## 0.54.1
14
+
15
+ Released 2026-10-27.
16
+
17
+ ### Fixes
18
+
19
+ - resolve the lint gate tools from the repository's own install [#9](https://github.com/avi2d/checks/pull/9)
20
+
5
21
  ## 0.54.0
6
22
 
7
23
  Released 2026-10-27.
@@ -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
 
@@ -21,7 +21,7 @@ A `tsconfig.json` that does not extend `@avi2dg/checks/tsconfig.effect.json`, or
21
21
  It reads the working tree: the `*.ts`, `*.tsx` and `*.astro` files `git ls-files` lists, and the files `oxlint --debug=files` walks.
22
22
  It walks without naming a path, since an explicit path bypasses the ignore files whose skips it looks for.
23
23
  It reads the program from `tsc --listFilesOnly -p tsconfig.json`, and passes over the program when the repository tracks no `.ts` or `.tsx` file or the root holds no `tsconfig.json`.
24
- oxlint and tsc must be on `PATH`, as they are under a package script.
24
+ The gate runs oxlint and tsc from the repository's own `node_modules/.bin`, so they need no `PATH` entry.
25
25
 
26
26
  ## Arguments
27
27
 
@@ -33,7 +33,7 @@ It takes none.
33
33
  | --- | --- |
34
34
  | 0 | the repository tracks no `.ts`, `.tsx` or `.astro` file, or oxlint walks each one and the program holds both ts-reset rules, the repository tracks no `.ts` or `.tsx` file or the root holds no `tsconfig.json` |
35
35
  | 1 | oxlint skips a tracked file, or the program drops a ts-reset rule |
36
- | 2 | oxlint cannot walk the tree, or tsc cannot list the program after oxlint walks every tracked file, as when either is not on `PATH` or a config does not parse |
36
+ | 2 | oxlint cannot walk the tree, or tsc cannot list the program after oxlint walks every tracked file, as when the repository's install lacks either tool or a config does not parse |
37
37
 
38
38
  ## Sample output
39
39
 
@@ -12,6 +12,7 @@ It runs the gates under [What runs](../bins.md#what-runs), each in its own proce
12
12
  Gates requiring tracked TypeScript files begin running when the repository tracks TypeScript.
13
13
  `checks-lint-coverage` and `checks-unused` also begin running when the repository tracks an `.astro` file.
14
14
  `checks-advisories` begins running when the repository tracks `bun.lock`.
15
+ `checks-lint` defers its scan to the scheduled run, so the desk and the pull request fetch no database.
15
16
  `checks-frontend-syntax` begins running when the repository tracks `frontend-syntax.json` at its root.
16
17
  `checks-copy` begins running when the repository tracks `copy.json` at its root.
17
18
  `checks-css-size` begins running when the repository tracks a `*.css` file.
@@ -21,7 +21,7 @@ Two uploads that overlap can still leave `latest` on the older version, since th
21
21
  That hold ends early in two cases.
22
22
  One is when the 45 minute wait times out.
23
23
  The other is when polling fails past the retry bound or the dispatch of the release workflow fails after GitHub accepted it.
24
- In those cases an unfinished publisher can overlap the next release, and if latest lands on the older version the fix is npm dist-tag add <package>@<newer version> latest.
24
+ In those cases an unfinished publisher can overlap the next release, and if `latest` lands on the older version, `npm dist-tag add <package>@<newer version> latest` repairs it.
25
25
 
26
26
  ## What it reads
27
27
 
@@ -140,7 +140,7 @@ The concurrency group holds from reading the branch until the release workflow i
140
140
  That hold ends early in two cases.
141
141
  One is when the 45 minute wait times out.
142
142
  The other is when polling fails past the retry bound or the dispatch of the release workflow fails after GitHub accepted it.
143
- In those cases an unfinished publisher can overlap the next release, and if latest lands on the older version the fix is npm dist-tag add <package>@<newer version> latest.
143
+ In those cases an unfinished publisher can overlap the next release, and if `latest` lands on the older version, `npm dist-tag add <package>@<newer version> latest` repairs it.
144
144
  [checks-release-publish](checks-release-publish.md) stays the guard for a release workflow dispatched or rerun by hand.
145
145
  A run that waits behind it checks out the branch's tip when it starts, so it releases on top of what landed.
146
146
  The job waits on the checks it dispatched and on the release workflow, so a hosted runner bills the minutes those workflows take.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -68,6 +68,15 @@ export function repeatedLines(clones: readonly Clone[]): ReadonlyMap<string, num
68
68
  return new Map([...lines].map(([file, covered]) => [file, covered.size]));
69
69
  }
70
70
 
71
+ const jscpdBinary = Effect.fn("jscpdBinary")(function* () {
72
+ const path = yield* Path.Path;
73
+ const main = yield* Effect.try({
74
+ try: () => new URL("./run-jscpd.js", import.meta.resolve("jscpd/package.json")),
75
+ catch: () => new JscpdUnreadable({ message: "cannot resolve jscpd, a peer dependency of the kit" }),
76
+ });
77
+ return yield* path.fromFileUrl(main);
78
+ });
79
+
71
80
  // jscpd reads files from disk, and the working tree need not hold the revision: a merge checkout or an uncommitted edit.
72
81
  // Both ends scan under the head's config, so a changed file set never reads as a change in repetition.
73
82
  // jscpd refuses a missing path, so a path in `emptied` the revision lacks is created empty.
@@ -87,7 +96,7 @@ const scan = Effect.fn("scan")(
87
96
  const flags = ["--silent", "--no-tips", "--no-gitignore", "--absolute", "--reporters", "json", "--output", output];
88
97
  const thresholds = ["--min-tokens", `${MIN_TOKENS}`, "--min-lines", `${MIN_LINES}`];
89
98
 
90
- const run = yield* collect("jscpd", [...flags, ...thresholds], tree).pipe(
99
+ const run = yield* collect(process.execPath, [yield* jscpdBinary(), ...flags, ...thresholds], tree).pipe(
91
100
  Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot run jscpd: ${cause.message}` })),
92
101
  );
93
102
  const reportPath = path.join(output, REPORT);
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
@@ -154,8 +154,9 @@ const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
154
154
  const script = (yield* Path.Path).join(import.meta.dir, "..", gate.vector, gate.file);
155
155
  const program = gate.file.endsWith(".sh") ? "sh" : process.execPath;
156
156
  const args = gate.reads === "range" ? [script, ...range.refs] : [script, ...(gate.args ?? [])];
157
+ const extra = gate.env === undefined ? {} : { env: gate.env, extendEnv: true };
157
158
  const exitCode = yield* spawner
158
- .exitCode(ChildProcess.make(program, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit" }))
159
+ .exitCode(ChildProcess.make(program, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit", ...extra }))
159
160
  .pipe(
160
161
  Effect.catch((cause) =>
161
162
  Console.error(`${NAME}: cannot run ${gate.bin}: ${cause.message}`).pipe(Effect.as(ChildProcessSpawner.ExitCode(2))),
@@ -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
 
@@ -12,8 +12,12 @@ if ! [ -s "$tmp/expected" ]; then
12
12
  exit 0
13
13
  fi
14
14
 
15
+ root="$(git rev-parse --show-toplevel)"
16
+ oxlint="$root/node_modules/.bin/oxlint"
17
+ tsc="$root/node_modules/.bin/tsc"
18
+
15
19
  # Explicit paths bypass ignore files, so only an unscoped walk proves coverage.
16
- if ! oxlint --debug=files > "$tmp/walk" 2> "$tmp/walk-error"; then
20
+ if ! "$oxlint" --debug=files > "$tmp/walk" 2> "$tmp/walk-error"; then
17
21
  echo "lint-coverage: oxlint could not walk the tree:"
18
22
  cat "$tmp/walk" "$tmp/walk-error"
19
23
  exit 2
@@ -44,7 +48,7 @@ if ! [ -f tsconfig.json ]; then
44
48
  exit "$status"
45
49
  fi
46
50
 
47
- if ! tsc --listFilesOnly -p tsconfig.json > "$tmp/program" 2> "$tmp/program-error"; then
51
+ if ! "$tsc" --listFilesOnly -p tsconfig.json > "$tmp/program" 2> "$tmp/program-error"; then
48
52
  echo "lint-coverage: tsc could not list the program tsconfig.json builds:"
49
53
  # tsc lists each file of the program as an absolute path beside its errors.
50
54
  grep -v '^/' "$tmp/program" || true