@avi2dg/checks 0.55.0 → 0.56.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.
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env bun
2
+ import { BunServices } from "@effect/platform-bun";
3
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
4
+ import { LINTED_SOURCE, TYPESCRIPT_SOURCE, type TrackedContent } from "../core/gates.ts";
5
+ import { checkoutFiles, collect, commitOf, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
6
+ import { PASSED, runExitMain, UNDECIDED, Usage, VIOLATED } from "../core/main.ts";
7
+ import { knipReport } from "./knip.ts";
8
+
9
+ const NAME = "graphwalk";
10
+ const UNUSED = "unused";
11
+ const EXPORTS = "exports";
12
+ const IMPORTS = "imports";
13
+ const WRITE = "--write";
14
+ const USAGE = `usage: graphwalk.ts [<ref> | <base-ref> <head-ref> | ${WRITE}]`;
15
+
16
+ const INCLUDED = ["--include", "files,exports,types"];
17
+ const JUDGED = /[.]tsx?$|[.]astro$/;
18
+
19
+ export const BASELINE_FILE = "exports-baseline.json";
20
+
21
+ export const PROJECT_CONFIG = "dependency-cruiser.config.ts";
22
+
23
+ export const OWN_CONFIGS = [
24
+ ".dependency-cruiser.json",
25
+ ".dependency-cruiser.js",
26
+ ".dependency-cruiser.cjs",
27
+ ".dependency-cruiser.mjs",
28
+ ".dependency-cruiser.ts",
29
+ ".dependency-cruiser.cts",
30
+ ".dependency-cruiser.mts",
31
+ ] as const;
32
+
33
+ export const CRUISED = ["*.ts", "*.tsx", "*.mts", "*.cts"] as const;
34
+
35
+ const KIT_DEFAULTS = "kit-defaults.ts";
36
+
37
+ export type Status = "passed" | "violated" | "undecided";
38
+
39
+ class GraphwalkError extends Schema.TaggedError<GraphwalkError>()("GraphwalkError", {
40
+ message: Schema.String,
41
+ }) {}
42
+
43
+ const UnusedSymbolSchema = Schema.Struct({ file: Schema.String, kind: Schema.Literals(["export", "type"]), name: Schema.String });
44
+
45
+ export type UnusedSymbol = typeof UnusedSymbolSchema.Type;
46
+
47
+ export type Baseline = readonly UnusedSymbol[];
48
+
49
+ export type Drift = { readonly unlisted: readonly UnusedSymbol[]; readonly added: readonly UnusedSymbol[]; readonly stale: readonly UnusedSymbol[] };
50
+
51
+ const Violation = Schema.Struct({ from: Schema.String, to: Schema.String, rule: Schema.Struct({ name: Schema.String, severity: Schema.String }) });
52
+
53
+ export type Violation = typeof Violation.Type;
54
+
55
+ const decodeKnipFiles = Schema.decodeUnknownEffect(
56
+ Schema.fromJsonString(Schema.Struct({ issues: Schema.Array(Schema.Struct({ files: Schema.optional(Schema.Array(Schema.Struct({ name: Schema.String }))) })) })),
57
+ );
58
+
59
+ const decodeKnipSymbols = Schema.decodeUnknownEffect(
60
+ Schema.fromJsonString(Schema.Struct({ issues: Schema.Array(Schema.Struct({ file: Schema.String, exports: Schema.optional(Schema.Array(Schema.Struct({ name: Schema.String }))), types: Schema.optional(Schema.Array(Schema.Struct({ name: Schema.String }))) })) })),
61
+ );
62
+
63
+ const decodeBaseline = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(UnusedSymbolSchema)));
64
+
65
+ const decodeCruise = Schema.decodeUnknownEffect(
66
+ Schema.fromJsonString(Schema.Struct({ summary: Schema.Struct({ violations: Schema.Array(Violation), error: Schema.Int, totalCruised: Schema.Int }) })),
67
+ );
68
+
69
+ const byPosition = (a: UnusedSymbol, b: UnusedSymbol): number => a.file.localeCompare(b.file) || a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name);
70
+
71
+ const keyOf = ({ file, kind, name }: UnusedSymbol): string => `${kind}\0${file}\0${name}`;
72
+
73
+ function outside(symbols: readonly UnusedSymbol[], others: readonly UnusedSymbol[]): readonly UnusedSymbol[] {
74
+ const known = new Set(others.map(keyOf));
75
+ return symbols.filter((symbol) => !known.has(keyOf(symbol))).toSorted(byPosition);
76
+ }
77
+
78
+ const describe = (symbol: UnusedSymbol): string => ` ${symbol.file}: ${symbol.name} (${symbol.kind})`;
79
+
80
+ export const filesOf = Effect.fn("filesOf")(function* (stdout: string) {
81
+ const { issues } = yield* decodeKnipFiles(stdout).pipe(
82
+ Effect.mapError((cause) => new GraphwalkError({ message: `knip's JSON report does not decode: ${cause.message}` })),
83
+ );
84
+ return issues
85
+ .flatMap(({ files = [] }) => files.map(({ name }) => name))
86
+ .filter((file) => JUDGED.test(file))
87
+ .toSorted();
88
+ });
89
+
90
+ export function unusedReport({ tracked, files }: { readonly tracked: number; readonly files: readonly string[] }): string {
91
+ if (files.length === 0) return `${UNUSED}: no unreferenced files among ${tracked} tracked .ts/.tsx/.astro file(s)`;
92
+ return [`${UNUSED}: ${files.length} unreferenced file(s):`, ...files.map((file) => ` ${file}`)].join("\n");
93
+ }
94
+
95
+ export const symbolsOf = Effect.fn("symbolsOf")(function* (stdout: string) {
96
+ const { issues } = yield* decodeKnipSymbols(stdout).pipe(
97
+ Effect.mapError((cause) => new GraphwalkError({ message: `knip's JSON report does not decode: ${cause.message}` })),
98
+ );
99
+ return issues
100
+ .flatMap(({ file, exports = [], types = [] }): readonly UnusedSymbol[] => [
101
+ ...exports.map(({ name }): UnusedSymbol => ({ file, kind: "export", name })),
102
+ ...types.map(({ name }): UnusedSymbol => ({ file, kind: "type", name })),
103
+ ])
104
+ .toSorted(byPosition);
105
+ });
106
+
107
+ export const baselineOf = Effect.fn("baselineOf")(function* (text: string, where: string) {
108
+ return yield* decodeBaseline(text).pipe(
109
+ Effect.mapError((cause) => new GraphwalkError({ message: `${where} does not decode: ${cause.message}` })),
110
+ );
111
+ });
112
+
113
+ export function driftOf({
114
+ reported,
115
+ base,
116
+ head,
117
+ unusedAtBase,
118
+ }: {
119
+ readonly reported: Baseline;
120
+ readonly base: Baseline;
121
+ readonly head: Baseline;
122
+ readonly unusedAtBase: Baseline;
123
+ }): Drift {
124
+ return {
125
+ unlisted: outside(reported, head),
126
+ added: outside(head, [...base, ...unusedAtBase]),
127
+ stale: outside(head, reported),
128
+ };
129
+ }
130
+
131
+ export function exportsReport(head: Baseline, { unlisted, added, stale }: Drift): string {
132
+ const sections = [
133
+ { symbols: unlisted, what: `unused export(s) not in ${BASELINE_FILE}:` },
134
+ { symbols: added, what: `${BASELINE_FILE} export(s) the range adds that its base did not leave unused, remove the export instead:` },
135
+ { symbols: stale, what: `${BASELINE_FILE} export(s) no longer reported, remove them:` },
136
+ ].filter(({ symbols }) => symbols.length > 0);
137
+ if (sections.length > 0) {
138
+ return sections.flatMap(({ symbols, what }) => [`${EXPORTS}: ${symbols.length} ${what}`, ...symbols.map(describe)]).join("\n");
139
+ }
140
+ return head.length === 0
141
+ ? `${EXPORTS}: no unused exports or types`
142
+ : `${EXPORTS}: ${head.length} unused export(s) in ${BASELINE_FILE}, and no new ones`;
143
+ }
144
+
145
+ export function describeViolation({ from, to, rule }: Violation): string {
146
+ return from === to ? ` ${rule.severity} ${rule.name}: ${from}` : ` ${rule.severity} ${rule.name}: ${from} → ${to}`;
147
+ }
148
+
149
+ const unconfiguredLine = (section: string): string =>
150
+ `${section}: no knip configuration names entry files, so add a knip.config.ts calling defineConfig from @avi2dg/checks/knip`;
151
+
152
+ const trackedIn = Effect.fn("trackedIn")(function* (root: string, source: TrackedContent) {
153
+ return (yield* git(["ls-files", "--", ...source.pathspecs], root))
154
+ .split("\n")
155
+ .map((line) => line.trim())
156
+ .filter((line) => line !== "");
157
+ });
158
+
159
+ const baselinePath = Effect.fn("baselinePath")(function* (root: string) {
160
+ return (yield* Path.Path).join(root, BASELINE_FILE);
161
+ });
162
+
163
+ const baselineInTree = Effect.fn("baselineInTree")(function* (root: string) {
164
+ const fs = yield* FileSystem.FileSystem;
165
+ const at = yield* baselinePath(root);
166
+ if (!(yield* fs.exists(at))) return [] satisfies Baseline;
167
+ return yield* fs.readFileString(at).pipe(Effect.flatMap((text) => baselineOf(text, BASELINE_FILE)));
168
+ });
169
+
170
+ const baselineAt = Effect.fn("baselineAt")(function* (treeish: string, root: string) {
171
+ const blob = (yield* git(["ls-tree", "--object-only", treeish, "--", BASELINE_FILE], root)).trim();
172
+ if (blob === "") return [] satisfies Baseline;
173
+ return yield* baselineOf(yield* git(["cat-file", "blob", blob], root), `${BASELINE_FILE} at ${treeish}`);
174
+ });
175
+
176
+ // Knip loads the configuration's imports, such as the kit's knip builder, from a node_modules the checkout lacks.
177
+ const unusedAt = Effect.fn("unusedAt")(
178
+ function* (rev: string, root: string) {
179
+ const files = yield* pathsAt(rev, [], root);
180
+ if (files.length === 0) return [] satisfies Baseline;
181
+ const fs = yield* FileSystem.FileSystem;
182
+ const path = yield* Path.Path;
183
+ const tree = yield* checkoutFiles(rev, files, yield* fs.makeTempDirectoryScoped({ prefix: "checks-graphwalk-" }), root);
184
+ const modules = path.join(root, "node_modules");
185
+ if (yield* fs.exists(modules)) yield* fs.symlink(modules, path.join(tree, "node_modules"));
186
+ const reported = yield* knipReport(tree, INCLUDED).pipe(
187
+ Effect.mapError((cause) => new GraphwalkError({ message: `at ${rev}: ${cause.message}` })),
188
+ );
189
+ return reported.kind === "unconfigured" ? [] : yield* symbolsOf(reported.stdout);
190
+ },
191
+ Effect.scoped,
192
+ );
193
+
194
+ // Knip scans the checked-out tree, and a pull request's merge checkout holds the base branch tip beside the range head.
195
+ const judgedBase = Effect.fn("judgedBase")(function* (first: string, second: string | undefined, root: string) {
196
+ const { base } = yield* rangeEnds(first, second, root);
197
+ if (second === undefined) return base;
198
+ const head = yield* commitOf(second, root);
199
+ const parents = (yield* git(["rev-parse", "HEAD^@"], root)).split("\n").filter((line) => line !== "");
200
+ const [other, ...rest] = parents.filter((parent) => parent !== head);
201
+ return parents.includes(head) && other !== undefined && rest.length === 0 ? other : base;
202
+ });
203
+
204
+ const configOf = Effect.fn("configOf")(function* (root: string) {
205
+ const fs = yield* FileSystem.FileSystem;
206
+ const path = yield* Path.Path;
207
+ for (const name of [PROJECT_CONFIG, ...OWN_CONFIGS]) {
208
+ if (yield* fs.exists(path.join(root, name))) return { file: name, shown: name };
209
+ }
210
+ return { file: path.join(import.meta.dir, "..", "dependencies", KIT_DEFAULTS), shown: "the kit's defaults" };
211
+ });
212
+
213
+ const depcruise = Effect.fn("depcruise")(function* () {
214
+ const path = yield* Path.Path;
215
+ const main = yield* Effect.try({
216
+ try: () => new URL("../../bin/dependency-cruiser.mjs", import.meta.resolve("dependency-cruiser")),
217
+ catch: () => new GraphwalkError({ message: "cannot resolve dependency-cruiser, a peer dependency of the kit" }),
218
+ });
219
+ return yield* path.fromFileUrl(main);
220
+ });
221
+
222
+ const trackedInTree = Effect.fn("trackedInTree")(function* (root: string) {
223
+ const fs = yield* FileSystem.FileSystem;
224
+ const path = yield* Path.Path;
225
+ const listed = yield* git(["ls-files", "-z", "--", ...CRUISED], root);
226
+ return yield* Effect.filter(listed.split("\0").filter(Boolean), (file) => fs.exists(path.join(root, file)), { concurrency: "unbounded" });
227
+ });
228
+
229
+ const runSection = Effect.fn("runSection")(function* (
230
+ section: string,
231
+ body: Effect.Effect<Status, { readonly message: string }, BunServices.BunServices>,
232
+ ) {
233
+ return yield* body.pipe(
234
+ Effect.catch((cause) => Console.error(`${section}: ${cause.message}`).pipe(Effect.as<Status>("undecided"))),
235
+ );
236
+ });
237
+
238
+ const unusedSection = (tracked: number, stdout: string) =>
239
+ runSection(
240
+ UNUSED,
241
+ Effect.gen(function* () {
242
+ const files = yield* filesOf(stdout);
243
+ yield* Console.log(unusedReport({ tracked, files }));
244
+ return files.length === 0 ? "passed" : "violated";
245
+ }),
246
+ );
247
+
248
+ const exportsSection = (root: string, stdout: string, first: string, second: string | undefined) =>
249
+ runSection(
250
+ EXPORTS,
251
+ Effect.gen(function* () {
252
+ const head = yield* baselineInTree(root);
253
+ const reported = yield* symbolsOf(stdout);
254
+ const baseRev = yield* judgedBase(first, second, root);
255
+ const base = yield* baselineAt(baseRev, root);
256
+ const unusedAtBase = outside(head, base).length === 0 ? [] : yield* unusedAt(baseRev, root);
257
+ const drift = driftOf({ reported, base, head, unusedAtBase });
258
+ yield* Console.log(exportsReport(head, drift));
259
+ return drift.unlisted.length === 0 && drift.added.length === 0 && drift.stale.length === 0 ? "passed" : "violated";
260
+ }),
261
+ );
262
+
263
+ const importsSection = (root: string) =>
264
+ runSection(
265
+ IMPORTS,
266
+ Effect.gen(function* () {
267
+ const files = yield* trackedInTree(root);
268
+ if (files.length === 0) return yield* new GraphwalkError({ message: "no tracked TypeScript to cruise" });
269
+ const config = yield* configOf(root);
270
+ const run = yield* collect(process.execPath, [yield* depcruise(), "--config", config.file, "--output-type", "json", ...files], root).pipe(
271
+ Effect.mapError((cause) => new GraphwalkError({ message: `cannot run dependency-cruiser: ${cause.message}` })),
272
+ );
273
+ const { summary } = yield* decodeCruise(run.stdout).pipe(
274
+ Effect.mapError(
275
+ () => new GraphwalkError({ message: `dependency-cruiser exits ${run.exitCode} with no report: ${(run.stdout + run.stderr).trim()}` }),
276
+ ),
277
+ );
278
+ const cruised = `${summary.totalCruised} module(s) cruised against ${config.shown}`;
279
+ if (summary.violations.length === 0) {
280
+ yield* Console.log(`${IMPORTS}: ${cruised}, no violation`);
281
+ return "passed";
282
+ }
283
+ yield* Console.error(
284
+ [`${IMPORTS}: ${summary.violations.length} violation(s) in ${cruised}`, ...summary.violations.map(describeViolation)].join("\n"),
285
+ );
286
+ return summary.error === 0 ? "passed" : "violated";
287
+ }),
288
+ );
289
+
290
+ function exitOf(statuses: readonly Status[]): number {
291
+ if (statuses.includes("violated")) return VIOLATED;
292
+ if (statuses.includes("undecided")) return UNDECIDED;
293
+ return PASSED;
294
+ }
295
+
296
+ const seed = Effect.fn("seed")(function* (root: string, tracked: number) {
297
+ if (tracked === 0) return yield* new GraphwalkError({ message: "no tracked TypeScript source to scan" });
298
+ const reported = yield* knipReport(root, INCLUDED).pipe(Effect.mapError((cause) => new GraphwalkError({ message: cause.message })));
299
+ if (reported.kind === "unconfigured") {
300
+ yield* Console.log(unconfiguredLine(EXPORTS));
301
+ return VIOLATED;
302
+ }
303
+ const symbols = yield* symbolsOf(reported.stdout);
304
+ yield* (yield* FileSystem.FileSystem).writeFileString(yield* baselinePath(root), `${JSON.stringify(symbols, null, 2)}\n`);
305
+ yield* Console.log(`${EXPORTS}: wrote ${symbols.length} unused export(s) to ${BASELINE_FILE}`);
306
+ return PASSED;
307
+ });
308
+
309
+ const lintOnlyGate = Effect.fn("lintOnlyGate")(function* (root: string, args: readonly string[]) {
310
+ if ((yield* trackedInTree(root)).length === 0) return yield* new GraphwalkError({ message: "no tracked TypeScript or Astro source to scan" });
311
+ if (args.length > 0) yield* refArgs(args, USAGE);
312
+ return exitOf([yield* importsSection(root)]);
313
+ });
314
+
315
+ const knipFailedGate = Effect.fn("knipFailedGate")(function* (root: string, message: string, hasTypescript: boolean) {
316
+ yield* Console.error(`${UNUSED}: ${message}`);
317
+ if (!hasTypescript) {
318
+ if ((yield* trackedInTree(root)).length === 0) return UNDECIDED;
319
+ return exitOf(["undecided", yield* importsSection(root)]);
320
+ }
321
+ yield* Console.error(`${EXPORTS}: ${message}`);
322
+ return exitOf(["undecided", "undecided", yield* importsSection(root)]);
323
+ });
324
+
325
+ const unconfiguredGate = Effect.fn("unconfiguredGate")(function* (root: string, hasTypescript: boolean) {
326
+ yield* Console.log(unconfiguredLine(UNUSED));
327
+ yield* Console.log(unconfiguredLine(EXPORTS));
328
+ const cruised = hasTypescript || (yield* trackedInTree(root)).length > 0 ? yield* importsSection(root) : "passed";
329
+ return exitOf(["violated", "violated", cruised]);
330
+ });
331
+
332
+ const judgedGate = Effect.fn("judgedGate")(function* (root: string, linted: number, typescript: number, stdout: string, first: string, second: string | undefined) {
333
+ const unlisted = yield* unusedSection(linted, stdout);
334
+ if (typescript === 0) {
335
+ if ((yield* trackedInTree(root)).length === 0) return exitOf([unlisted]);
336
+ return exitOf([unlisted, yield* importsSection(root)]);
337
+ }
338
+ const drifted = yield* exportsSection(root, stdout, first, second);
339
+ return exitOf([unlisted, drifted, yield* importsSection(root)]);
340
+ });
341
+
342
+ const gate = Effect.gen(function* () {
343
+ const args = process.argv.slice(2);
344
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
345
+ if (args.length === 1 && args[0] === WRITE) {
346
+ const tracked = yield* trackedIn(root, TYPESCRIPT_SOURCE);
347
+ return yield* seed(root, tracked.length).pipe(
348
+ Effect.catch((cause: { readonly message: string }) => Console.error(`${EXPORTS}: ${cause.message}`).pipe(Effect.as(UNDECIDED))),
349
+ );
350
+ }
351
+ const linted = yield* trackedIn(root, LINTED_SOURCE);
352
+ if (linted.length === 0) return yield* lintOnlyGate(root, args);
353
+ const { first, second } = args.length === 0 ? { first: "HEAD", second: undefined } : yield* refArgs(args, USAGE);
354
+ const typescript = yield* trackedIn(root, TYPESCRIPT_SOURCE);
355
+ const knipOutcome = yield* knipReport(root, INCLUDED).pipe(
356
+ Effect.mapError((cause) => new GraphwalkError({ message: cause.message })),
357
+ Effect.map((reported) => ({ failed: false as const, reported })),
358
+ Effect.catch((cause: { readonly message: string }) => Effect.succeed({ failed: true as const, message: cause.message })),
359
+ );
360
+ if (knipOutcome.failed) return yield* knipFailedGate(root, knipOutcome.message, typescript.length > 0);
361
+ const reported = knipOutcome.reported;
362
+ if (reported.kind === "unconfigured") return yield* unconfiguredGate(root, typescript.length > 0);
363
+ return yield* judgedGate(root, linted.length, typescript.length, reported.stdout, first, second);
364
+ }).pipe(
365
+ Effect.catch((cause: { readonly message: string }) =>
366
+ (cause instanceof Usage ? Console.error(`${NAME}: ${cause.message}`) : Console.error(`${UNUSED}: ${cause.message}`)).pipe(
367
+ Effect.as(UNDECIDED),
368
+ ),
369
+ ),
370
+ );
371
+
372
+ if (import.meta.main) {
373
+ runExitMain(gate);
374
+ }
@@ -1,6 +1,5 @@
1
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
2
- import type { TrackedContent } from "../core/gates.ts";
3
- import { collect, git } from "../core/git.ts";
1
+ import { Effect, FileSystem, Path, Schema } from "effect";
2
+ import { collect } from "../core/git.ts";
4
3
 
5
4
  class KnipError extends Schema.TaggedError<KnipError>()("KnipError", {
6
5
  message: Schema.String,
@@ -53,17 +52,3 @@ export const knipReport = Effect.fn("knipReport")(function* (root: string, args:
53
52
  }
54
53
  return { kind: "reported", stdout: run.stdout } as const;
55
54
  });
56
-
57
- export const scanTree = Effect.fn("scanTree")(function* (name: string, args: readonly string[], source: TrackedContent) {
58
- const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
59
- const tracked = (yield* git(["ls-files", "--", ...source.pathspecs], root))
60
- .split("\n")
61
- .map((line) => line.trim())
62
- .filter((line) => line !== "");
63
- if (tracked.length === 0) return yield* new KnipError({ message: `no tracked ${source.content} to scan` });
64
- const reported = yield* knipReport(root, args);
65
- if (reported.kind === "unconfigured") {
66
- yield* Console.log(`${name}: no knip configuration names entry files, so add a knip.config.ts calling defineConfig from @avi2dg/checks/knip`);
67
- }
68
- return { root, tracked: tracked.length, reported };
69
- });
package/src/core/gates.ts CHANGED
@@ -63,10 +63,8 @@ export const KIT_GATES = [
63
63
  { bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", alsoReads: "every agent file at the head commit", appliesTo: EVERY_REPOSITORY },
64
64
  { bin: "checks-copy", vector: "docs", file: "copy.ts", reads: "tree", appliesTo: COPY_DECLARATION },
65
65
  { bin: "checks-repetition", vector: "complexity", file: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
66
- { bin: "checks-unused", vector: "complexity", file: "unused.ts", reads: "tree", appliesTo: LINTED_SOURCE },
67
- { bin: "checks-exports", vector: "complexity", file: "exports.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
66
+ { bin: "checks-graphwalk", vector: "complexity", file: "graphwalk.ts", reads: "range", appliesTo: LINTED_SOURCE },
68
67
  { bin: "checks-quarantine-clock", vector: "testing", file: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
69
- { bin: "checks-imports", vector: "dependencies", file: "imports.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
70
68
  {
71
69
  bin: "checks-advisories",
72
70
  vector: "dependencies",
@@ -74,5 +72,5 @@ export const KIT_GATES = [
74
72
  reads: "range",
75
73
  env: { [ADVISORIES_DEFER_SCAN]: "1" },
76
74
  appliesTo: BUN_LOCKFILE,
77
- },
75
+ }
78
76
  ] 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
  }
@@ -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) {
package/src/core/main.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { BunRuntime, BunServices } from "@effect/platform-bun";
2
2
  import { Cause, Console, Effect, Exit, Schema } from "effect";
3
3
 
4
- const PASSED = 0;
5
- const VIOLATED = 1;
6
- const UNDECIDED = 2;
4
+ export const PASSED = 0;
5
+ export const VIOLATED = 1;
6
+ export const UNDECIDED = 2;
7
7
  const INTERRUPTED = 130;
8
8
 
9
9
  export class Usage extends Schema.TaggedError<Usage>()("Usage", {
@@ -19,12 +19,16 @@ export function runMain<E extends { readonly message: string }>(
19
19
  name: string,
20
20
  passes: Effect.Effect<boolean, E, BunServices.BunServices>,
21
21
  ): void {
22
- const program = passes.pipe(
23
- Effect.map((passed) => (passed ? PASSED : VIOLATED)),
24
- Effect.catch((failure) => Console.error(`${name}: ${failure.message}`).pipe(Effect.as(UNDECIDED))),
25
- Effect.provide(BunServices.layer),
22
+ runExitMain(
23
+ passes.pipe(
24
+ Effect.map((passed) => (passed ? PASSED : VIOLATED)),
25
+ Effect.catch((failure) => Console.error(`${name}: ${failure.message}`).pipe(Effect.as(UNDECIDED))),
26
+ ),
26
27
  );
27
- BunRuntime.runMain(program, { teardown: (exit, onExit) => onExit(statusOf(exit)) });
28
+ }
29
+
30
+ export function runExitMain(exits: Effect.Effect<number, never, BunServices.BunServices>): void {
31
+ BunRuntime.runMain(exits.pipe(Effect.provide(BunServices.layer)), { teardown: (exit, onExit) => onExit(statusOf(exit)) });
28
32
  }
29
33
 
30
34
  export function runTreeGate<Outcome extends { readonly violations: readonly unknown[] }>(
@@ -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
+