@avi2dg/checks 0.56.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/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[] }>(
@@ -1,104 +0,0 @@
1
- ---
2
- kind: reference
3
- audience: consumers
4
- ---
5
- # checks-exports
6
-
7
- `checks-exports` is the gate that refuses an exported value or type nothing imports.
8
-
9
- ## What it checks
10
-
11
- It runs Knip with the repository's own configuration and names each exported value and type no file imports.
12
- It reads only the exports and types issue types, so an unreferenced file or an unused dependency never fails it.
13
- It asks Knip for those issue types itself, so a configuration that narrows `include` or excludes them still has its exports judged.
14
- A configuration that turns the exports or types rules off silences those symbols, so keep both rules on.
15
- It holds each reported symbol against `exports-baseline.json`, and fails on any symbol the baseline does not hold.
16
- It fails on any baseline entry the range adds unless Knip reports that same symbol unused at the base, so the baseline records debt that already existed and never debt the range creates.
17
- It fails on any baseline entry Knip no longer reports, so the baseline only shrinks.
18
- It fails when the repository tracks no TypeScript source, since an empty scan would pass without judging anything.
19
- It fails when the repository holds no Knip configuration, since Knip's default entries cannot tell a dead export from a public one.
20
-
21
- ## What it reads
22
-
23
- It runs Knip over the working tree, so an uncommitted file is judged like a committed one.
24
- It reads the repository's Knip configuration, which names the entries.
25
- It reads `exports-baseline.json` in the repository root, which holds each accepted unused export as a file, kind and name triple.
26
- It reads `exports-baseline.json` again at the base of the range, where a commit without the file counts as empty.
27
- When the range adds baseline entries, it runs Knip again on a temporary checkout of the base and removes the checkout when it ends.
28
- That checkout links the repository's `node_modules`, so a configuration importing the kit's builder loads there too.
29
- The repository writes `knip.config.ts` with the kit's builder, which requires `entry`, the files nothing imports:
30
-
31
- ```ts
32
- import { defineConfig } from "@avi2dg/checks/knip";
33
-
34
- export default defineConfig({ entry: ["src/index.ts"] });
35
- ```
36
-
37
- The builder sets `include` to unreferenced files alone, and adds `tests/**/*.test.ts`, `dependency-cruiser.config.ts` and `oxlint.config.ts` to the entries.
38
- The gate resolves the Knip binary from the installed kit, so a consumer installs nothing beyond the kit.
39
-
40
- ## Arguments
41
-
42
- ```sh
43
- checks-exports <base-ref> <head-ref>
44
- checks-exports <ref>
45
- checks-exports --write
46
- ```
47
-
48
- With two arguments the base is where the head branched off, at their merge-base.
49
- When the checked-out commit is a merge of the head ref and one other commit, as a pull request's default checkout is, the base is that other commit instead.
50
- So under `checks-lint` a pull request's merge checkout is judged against the base branch tip it merges, and a symbol that branch already baselined does not count as added.
51
- With one the base is that commit's parent, or the empty tree for a repository's first commit, where nothing counts as already unused.
52
- With `--write` it records every unused export and type Knip reports into `exports-baseline.json`, the way `oxlint --suppress-all` seeds `oxlint-suppressions.json`.
53
-
54
- ## Seeding a baseline
55
-
56
- A repository adopting the gate runs `checks-exports --write` on a branch that changes no source and commits the file it writes.
57
- The gate passes that range, since Knip reports every seeded symbol unused at the base too.
58
- A seed written after the range adds a dead export, or after it removes the last import of one, fails on that symbol.
59
-
60
- ## Exit codes
61
-
62
- | Code | When |
63
- | --- | --- |
64
- | 0 | no reported symbol is unlisted, no baseline entry is added or stale, or `--write` recorded the baseline |
65
- | 1 | a reported symbol is unlisted, a baseline entry is added or stale or the repository holds no Knip configuration |
66
- | 2 | a ref does not resolve, the repository tracks no TypeScript source, Knip cannot run, or a report or baseline does not parse |
67
-
68
- ## Sample output
69
-
70
- ```
71
- exports: 2 unused export(s) not in exports-baseline.json:
72
- src/used.ts: unusedExport (export)
73
- src/used.ts: UnusedOptions (type)
74
- ```
75
-
76
- A baseline entry the range adds fails when Knip does not report its symbol unused at the base:
77
-
78
- ```
79
- exports: 1 exports-baseline.json export(s) the range adds that its base did not leave unused, remove the export instead:
80
- src/used.ts: unusedExport (export)
81
- ```
82
-
83
- A passing run with a held baseline counts what it holds:
84
-
85
- ```
86
- exports: 2 unused export(s) in exports-baseline.json, and no new ones
87
- ```
88
-
89
- A passing run with an empty baseline says so:
90
-
91
- ```
92
- exports: no unused exports or types
93
- ```
94
-
95
- ## When it runs
96
-
97
- `checks-lint` runs it over each pull request's range when the repository tracks a `.ts` or `.tsx` file.
98
- Such a repository names its entries in a Knip configuration.
99
-
100
- ## Related topics
101
-
102
- - [checks-lint](checks-lint.md)
103
- - [checks-suppressions-ratchet](checks-suppressions-ratchet.md)
104
- - [checks-unused](checks-unused.md)
@@ -1,64 +0,0 @@
1
- ---
2
- kind: reference
3
- audience: consumers
4
- ---
5
- # checks-imports
6
-
7
- `checks-imports` is the gate that holds a repository's imports to the dependency rules, with the kit's defaults when the repository writes no config.
8
-
9
- ## What it checks
10
-
11
- It runs dependency-cruiser over every tracked `.ts`, `.tsx`, `.mts` and `.cts` file and names each violation with its rule, the importing module and the module it reaches.
12
- It fails on a violation whose rule has `error` severity, and lists a `warn` or `info` violation without failing.
13
- The rules are those [The dependency rules](../configs/dependency-rules.md) lists, plus each rule the repository's config adds.
14
-
15
- In a repository whose `package.json` lists `astro` under `dependencies` or `devDependencies`, the kit's defaults leave `no-orphans` out.
16
- The cruise never reads an `.astro` file, so a `.ts` module only a page imports would read as an orphan.
17
- [checks-unused](checks-unused.md) already names each file no entry reaches there, and it reads `.astro` imports.
18
- A repository that wants the rule back lists its own `no-orphans` under `forbidden` in `dependency-cruiser.config.ts`.
19
-
20
- ## What it reads
21
-
22
- It reads the working tree from the repository root, and leaves out a tracked file the working tree no longer holds.
23
- It cruises against the first config it finds there:
24
-
25
- 1. `dependency-cruiser.config.ts`, which calls `defineConfig` from `@avi2dg/checks/dependency-cruiser`.
26
- 1. A config under one of dependency-cruiser's own names, such as `.dependency-cruiser.cjs`.
27
- 1. The kit's defaults, which `defineConfig()` returns with no argument.
28
-
29
- It runs dependency-cruiser from the installed kit under Bun, which loads a TypeScript config wherever it sits.
30
-
31
- ## Arguments
32
-
33
- It takes none.
34
-
35
- ## Exit codes
36
-
37
- | Code | When |
38
- | --- | --- |
39
- | 0 | no import breaks an `error` rule |
40
- | 1 | an import breaks an `error` rule |
41
- | 2 | the repository tracks no TypeScript, or dependency-cruiser cannot load the config or run |
42
-
43
- ## Sample output
44
-
45
- ```
46
- imports: 2 violation(s) in 36 module(s) cruised against dependency-cruiser.config.ts
47
- error not-to-dev-dep: src/index.ts → node_modules/effect/dist/index.js
48
- error no-orphans: src/lonely.ts
49
- ```
50
-
51
- A passing run counts the modules it cruised:
52
-
53
- ```
54
- imports: 225 module(s) cruised against dependency-cruiser.config.ts, no violation
55
- ```
56
-
57
- ## When it runs
58
-
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
-
61
- ## Related topics
62
-
63
- - [The dependency rules](../configs/dependency-rules.md)
64
- - [checks-lint](checks-lint.md)