@avi2dg/checks 0.21.0 → 0.23.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +66 -40
  2. package/CONTRIBUTING.md +14 -10
  3. package/README.md +15 -24
  4. package/bunfig.toml +1 -1
  5. package/dist/effect-channel/index.js +122 -0
  6. package/dist/{index.js → readability/index.js} +8 -120
  7. package/docs/configs/commit-messages.md +5 -1
  8. package/docs/configs/dependency-rules.md +5 -2
  9. package/docs/configs/effect-rules.md +32 -33
  10. package/docs/configs/native-settings.md +74 -0
  11. package/docs/configs/typescript-rules.md +4 -0
  12. package/docs/design.md +26 -27
  13. package/docs/gates/checks-backtest.md +4 -0
  14. package/docs/gates/checks-ci-wiring.md +30 -93
  15. package/docs/gates/checks-comment-gate.md +4 -0
  16. package/docs/gates/checks-commit-identity.md +23 -27
  17. package/docs/gates/checks-docs.md +23 -21
  18. package/docs/gates/checks-flake.md +4 -10
  19. package/docs/gates/checks-lint-coverage.md +5 -1
  20. package/docs/gates/checks-lint.md +22 -104
  21. package/docs/gates/checks-mutation-compare.md +4 -0
  22. package/docs/gates/checks-quarantine-clock.md +4 -0
  23. package/docs/gates/checks-repetition.md +27 -41
  24. package/docs/gates/checks-subsumed-tests.md +4 -0
  25. package/docs/gates/checks-suppressions-ratchet.md +4 -0
  26. package/docs/gates/checks-test-layout.md +16 -8
  27. package/docs/gates/checks-test.md +68 -36
  28. package/docs/gates/checks-vendor.md +20 -13
  29. package/oxlintrc.json +1 -1
  30. package/package.json +10 -21
  31. package/scripts/ci-wiring.ts +28 -97
  32. package/scripts/commit-identity.ts +32 -4
  33. package/scripts/doc-rules.ts +26 -11
  34. package/scripts/doc-templates.ts +2 -1
  35. package/scripts/docs.ts +4 -7
  36. package/scripts/gates.ts +0 -29
  37. package/scripts/git.ts +24 -1
  38. package/scripts/lint.ts +15 -34
  39. package/scripts/range-gate.ts +1 -2
  40. package/scripts/repetition.ts +51 -40
  41. package/scripts/shell-command.ts +7 -1
  42. package/scripts/swc.ts +46 -0
  43. package/scripts/test-layout.ts +40 -56
  44. package/scripts/test-skips.ts +180 -0
  45. package/scripts/test.ts +33 -38
  46. package/scripts/vendor.ts +55 -11
  47. package/dist/feature-rules.js +0 -354
  48. package/docs/configs/quality-file.md +0 -103
  49. package/docs/gates/checks-feature-owners.md +0 -113
  50. package/docs/gates/checks-quality.md +0 -111
  51. package/docs/gates/checks-size-budget.md +0 -107
  52. package/quality.schema.json +0 -514
  53. package/scripts/feature-owners.ts +0 -139
  54. package/scripts/quality-file.ts +0 -353
  55. package/scripts/quality.ts +0 -363
  56. package/scripts/size-budget.ts +0 -285
  57. package/scripts/size-rules.ts +0 -126
@@ -2,7 +2,6 @@
2
2
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { changedPaths, checkoutFiles, collect, git, pathsAt, rangeFromArgs } from "./git.ts";
4
4
  import { runMain } from "./main.ts";
5
- import { readQuality } from "./quality-file.ts";
6
5
 
7
6
  export type Fragment = {
8
7
  readonly file: string;
@@ -34,19 +33,29 @@ const USAGE = "usage: repetition.ts <ref> | <base-ref> <head-ref>";
34
33
  const MIN_TOKENS = 50;
35
34
  const MIN_LINES = 5;
36
35
  const MEASURE = `at ${MIN_TOKENS} tokens and ${MIN_LINES} lines`;
37
- const DECLARATIONS = ":(exclude,glob)**/*.d.ts";
38
- const TYPESCRIPT = [":(glob)**/*.ts", ":(glob)**/*.tsx", DECLARATIONS];
36
+ const CONFIG = ".jscpd.json";
39
37
  const JSCPD_FINISHED = 0;
40
38
  const REPORT = "jscpd-report.json";
41
39
 
42
40
  const Location = Schema.Struct({ name: Schema.String, start: Schema.Int, end: Schema.Int });
43
41
 
44
42
  const decodeReport = Schema.decodeUnknownEffect(
45
- Schema.fromJsonString(Schema.Struct({ duplicates: Schema.Array(Schema.Struct({ firstFile: Location, secondFile: Location })) })),
43
+ Schema.fromJsonString(
44
+ Schema.Struct({
45
+ duplicates: Schema.Array(Schema.Struct({ firstFile: Location, secondFile: Location })),
46
+ statistics: Schema.Struct({ total: Schema.Struct({ sources: Schema.Int }) }),
47
+ }),
48
+ ),
46
49
  );
47
50
 
48
- function fragmentOf({ name, start, end }: typeof Location.Type): Fragment {
49
- return { file: name, start, end };
51
+ const decodeConfigPaths = Schema.decodeUnknownEffect(
52
+ Schema.fromJsonString(Schema.Struct({ path: Schema.optional(Schema.Array(Schema.String)) })),
53
+ );
54
+
55
+ type Scan = { readonly sources: number; readonly clones: readonly Clone[] };
56
+
57
+ function fragmentOf(file: (name: string) => string, { name, start, end }: typeof Location.Type): Fragment {
58
+ return { file: file(name), start, end };
50
59
  }
51
60
 
52
61
  export function repeatedLines(clones: readonly Clone[]): ReadonlyMap<string, number> {
@@ -60,28 +69,40 @@ export function repeatedLines(clones: readonly Clone[]): ReadonlyMap<string, num
60
69
  }
61
70
 
62
71
  // jscpd reads files from disk, and the working tree need not hold the revision: a merge checkout or an uncommitted edit.
72
+ // Both ends scan under the head's config, so a changed file set never reads as a change in repetition.
73
+ // jscpd refuses a missing path, so a path in `emptied` the revision lacks is created empty.
63
74
  const scan = Effect.fn("scan")(
64
- function* (root: string, rev: string, files: readonly string[]) {
65
- if (files.length === 0) return [];
75
+ function* (root: string, rev: string, config: string, emptied: readonly string[]) {
76
+ const files = yield* pathsAt(rev, [], root);
77
+ if (files.length === 0) return { sources: 0, clones: [] } satisfies Scan;
66
78
  const fs = yield* FileSystem.FileSystem;
67
79
  const path = yield* Path.Path;
68
80
  const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "checks-repetition-" });
69
81
  const tree = yield* checkoutFiles(rev, files, scratch, root);
82
+ yield* fs.writeFileString(path.join(tree, CONFIG), config);
83
+ for (const absent of emptied.map((entry) => path.join(tree, entry))) {
84
+ if (!(yield* fs.exists(absent))) yield* fs.makeDirectory(absent, { recursive: true });
85
+ }
70
86
  const output = path.join(scratch, "report");
71
- const flags = ["--silent", "--no-tips", "--no-gitignore", "--reporters", "json", "--output", output];
87
+ const flags = ["--silent", "--no-tips", "--no-gitignore", "--absolute", "--reporters", "json", "--output", output];
72
88
  const thresholds = ["--min-tokens", `${MIN_TOKENS}`, "--min-lines", `${MIN_LINES}`];
73
89
 
74
- const run = yield* collect("jscpd", [...flags, ...thresholds, "."], tree).pipe(
90
+ const run = yield* collect("jscpd", [...flags, ...thresholds], tree).pipe(
75
91
  Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot run jscpd: ${cause.message}` })),
76
92
  );
77
- if (run.exitCode !== JSCPD_FINISHED) {
93
+ const reportPath = path.join(output, REPORT);
94
+ // A threshold, exitCode or failOnEmpty in the repository's config fails jscpd after the report is written.
95
+ if (run.exitCode !== JSCPD_FINISHED && !(yield* fs.exists(reportPath))) {
78
96
  return yield* new JscpdUnreadable({ message: `jscpd exited ${run.exitCode}: ${run.stderr.trim() || run.stdout.trim()}` });
79
97
  }
80
- const { duplicates } = yield* fs.readFileString(path.join(output, REPORT)).pipe(
98
+ const { duplicates, statistics } = yield* fs.readFileString(reportPath).pipe(
81
99
  Effect.flatMap(decodeReport),
82
100
  Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot read jscpd's report: ${cause.message}` })),
83
101
  );
84
- return duplicates.map(({ firstFile, secondFile }): Clone => [fragmentOf(firstFile), fragmentOf(secondFile)]);
102
+ const scanned = yield* fs.realPath(tree);
103
+ const file = (name: string): string => path.relative(scanned, name);
104
+ const clones = duplicates.map(({ firstFile, secondFile }): Clone => [fragmentOf(file, firstFile), fragmentOf(file, secondFile)]);
105
+ return { sources: statistics.total.sources, clones } satisfies Scan;
85
106
  },
86
107
  Effect.scoped,
87
108
  );
@@ -113,33 +134,25 @@ export function risesOf(
113
134
  .toSorted((a, b) => a.file.localeCompare(b.file));
114
135
  }
115
136
 
116
- // Tallies every held file that did not rise, plus every file outside the held set, as advisory.
117
- export function heldOf(
118
- measured: number,
119
- rises: readonly Rise[],
120
- after: ReadonlyMap<string, number>,
121
- othersRepeated: ReadonlyMap<string, number>,
122
- ): Held {
137
+ // Tallies every repeating file that did not rise as advisory.
138
+ export function heldOf(measured: number, rises: readonly Rise[], after: ReadonlyMap<string, number>): Held {
123
139
  const risen = new Set(rises.map((rise) => rise.file));
124
- const advisory = [...after].filter(([file]) => !risen.has(file)).concat([...othersRepeated]);
140
+ const advisory = [...after].filter(([file]) => !risen.has(file));
125
141
  return { measured, rises, advisory: new Map(advisory.toSorted(([a], [b]) => a.localeCompare(b))) };
126
142
  }
127
143
 
128
- const runHold = Effect.fn("runHold")(function* (root: string, production: readonly string[], base: string, head: string) {
129
- const pathspecs = [...production.map((glob) => `:(glob)${glob}`), DECLARATIONS];
130
- const held = yield* pathsAt(head, pathspecs, root);
131
- const holds = new Set(held);
132
- const others = (yield* pathsAt(head, TYPESCRIPT, root)).filter((file) => !holds.has(file));
144
+ const runHold = Effect.fn("runHold")(function* (root: string, config: string, base: string, head: string) {
133
145
  const formerPath = new Map(
134
- (yield* changedPaths(base, head, pathspecs, root)).flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from]] : [])),
146
+ (yield* changedPaths(base, head, [], root)).flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from]] : [])),
135
147
  );
136
-
137
- const before = repeatedLines(yield* scan(root, base, yield* pathsAt(base, pathspecs, root)));
138
- const clones = yield* scan(root, head, held);
148
+ const { path: configPaths = [] } = yield* decodeConfigPaths(config).pipe(
149
+ Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot read ${CONFIG}: ${cause.message}` })),
150
+ );
151
+ const before = repeatedLines((yield* scan(root, base, config, configPaths)).clones);
152
+ const { sources, clones } = yield* scan(root, head, config, []);
139
153
  const after = repeatedLines(clones);
140
- const rises = risesOf(held, before, after, formerPath, clones);
141
- const othersRepeated = repeatedLines(yield* scan(root, head, others));
142
- return heldOf(held.length, rises, after, othersRepeated) satisfies Held;
154
+ const rises = risesOf([...after.keys()], before, after, formerPath, clones);
155
+ return heldOf(sources, rises, after) satisfies Held;
143
156
  });
144
157
 
145
158
  export function describe({ file, before, after, clones }: Rise): readonly string[] {
@@ -152,8 +165,8 @@ export function describe({ file, before, after, clones }: Rise): readonly string
152
165
  export function report({ measured, rises, advisory }: Held): string {
153
166
  const verdict =
154
167
  rises.length === 0
155
- ? [`${NAME}: ${measured} production file(s) repeat no more lines than where the range starts, ${MEASURE}`]
156
- : [`${NAME}: ${rises.length} production file(s) repeat more lines than where the range starts, ${MEASURE}:`, ...rises.flatMap(describe)];
168
+ ? [`${NAME}: ${measured} file(s) ${CONFIG} holds repeat no more lines than where the range starts, ${MEASURE}`]
169
+ : [`${NAME}: ${rises.length} file(s) ${CONFIG} holds repeat more lines than where the range starts, ${MEASURE}:`, ...rises.flatMap(describe)];
157
170
  const notice =
158
171
  advisory.size === 0
159
172
  ? []
@@ -167,13 +180,11 @@ export function report({ measured, rises, advisory }: Held): string {
167
180
  const hold = Effect.gen(function* () {
168
181
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
169
182
  const { base, head } = yield* rangeFromArgs(process.argv.slice(2), USAGE, root);
170
- const { source, quality } = yield* readQuality(root);
171
- const production = quality.sources?.production ?? [];
172
- if (production.length === 0) {
173
- yield* Console.log(`${NAME}: ${source} declares no sources.production`);
183
+ if ((yield* pathsAt(head, [`:(literal)${CONFIG}`], root)).length === 0) {
184
+ yield* Console.log(`${NAME}: the head holds no ${CONFIG}, so no file is measured`);
174
185
  return true;
175
186
  }
176
- const held = yield* runHold(root, production, base, head);
187
+ const held = yield* runHold(root, yield* git(["show", `${head}:${CONFIG}`], root), base, head);
177
188
 
178
189
  yield* Console.log(report(held));
179
190
  return held.rises.length === 0;
@@ -64,8 +64,14 @@ export function plainCommand(script: string): Command | undefined {
64
64
  return words.length === 0 ? undefined : words;
65
65
  }
66
66
 
67
+ // bun run resolves any other word, even one holding a slash, to a package.json script of that name first.
68
+ const FILE_PATH = /^\.{0,2}\//;
69
+
67
70
  export function invokes(command: Command | undefined, gate: Command): boolean {
68
- return command !== undefined && gate.length <= command.length && gate.every((word, index) => command[index] === word);
71
+ if (command === undefined) return false;
72
+ const begins = (words: Command) => gate.length <= words.length && gate.every((word, index) => words[index] === word);
73
+ const bunRunsFile = command[0] === "bun" && command[1] === "run" && FILE_PATH.test(gate[0] ?? "");
74
+ return begins(command) || (bunRunsFile && begins(command.slice(2)));
69
75
  }
70
76
 
71
77
  export function mentions(script: string, gate: Command): boolean {
package/scripts/swc.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { parse } from "@swc/core";
2
+ import { Effect, Schema } from "effect";
3
+
4
+ export class TypeScriptParseError extends Schema.TaggedError<TypeScriptParseError>()("TypeScriptParseError", {
5
+ message: Schema.String,
6
+ }) {}
7
+
8
+ export const parseTypeScript = Effect.fn("parseTypeScript")(function* (file: string, source: string) {
9
+ return yield* Effect.tryPromise({
10
+ try: () => parse(source, { syntax: "typescript", tsx: file.endsWith(".tsx"), target: "esnext" }),
11
+ catch: (cause) => new TypeScriptParseError({ message: `cannot parse ${file}: ${String(cause)}` }),
12
+ });
13
+ });
14
+
15
+ export function isRecord(value: unknown): value is Record<string, unknown> {
16
+ return typeof value === "object" && value !== null;
17
+ }
18
+
19
+ export function identifierName(node: unknown): string | undefined {
20
+ if (!isRecord(node) || node["type"] !== "Identifier") return undefined;
21
+ const value = node["value"];
22
+ return typeof value === "string" ? value : undefined;
23
+ }
24
+
25
+ export function stringValue(node: unknown): string | undefined {
26
+ if (!isRecord(node) || node["type"] !== "StringLiteral") return undefined;
27
+ const value = node["value"];
28
+ return typeof value === "string" ? value : undefined;
29
+ }
30
+
31
+ export function spanStart(node: Record<string, unknown>): number {
32
+ const span = node["span"];
33
+ if (!isRecord(span)) return 0;
34
+ const start = span["start"];
35
+ return typeof start === "number" ? start : 0;
36
+ }
37
+
38
+ export function lineOf(source: string, start: number): number {
39
+ const bytes = Buffer.from(source, "utf8");
40
+ const offset = Math.max(0, Math.min(bytes.length, start - 1));
41
+ let line = 1;
42
+ for (let index = 0; index < offset; index += 1) {
43
+ if (bytes[index] === 0x0a) line += 1;
44
+ }
45
+ return line;
46
+ }
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env bun
2
- import { parse } from "@swc/core";
3
2
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
4
3
  import { git } from "./git.ts";
5
4
  import { runMain } from "./main.ts";
6
- import { ENTRY_POINT, QUALITY_FILE, TEST_ENTRY_POINT } from "./gates.ts";
7
- import { readQuality } from "./quality-file.ts";
5
+ import { ENTRY_POINT, TEST_ENTRY_POINT } from "./gates.ts";
6
+ import { identifierName, isRecord, lineOf, parseTypeScript, spanStart, stringValue } from "./swc.ts";
8
7
 
9
8
  export type Violation = {
10
9
  readonly file: string;
@@ -42,12 +41,15 @@ const BANNED_GLOBAL_CALLS: readonly string[] = ["fetch"];
42
41
 
43
42
  const IGNORES_KEY = "pathIgnorePatterns";
44
43
  const QUARANTINE = "**/tests/quarantine/**";
44
+ const LIVE_TESTS = "**/tests/live/**";
45
+ const PIXEL_TESTS = "**/tests/pixel/**";
45
46
  const VENDORED = "repos/**";
46
- const PRESET_IGNORES: readonly string[] = [QUARANTINE, VENDORED];
47
-
48
- function acceptedIgnores(vendors: boolean): readonly (readonly string[])[] {
49
- return vendors ? [PRESET_IGNORES] : [PRESET_IGNORES, [QUARANTINE]];
50
- }
47
+ const TEST_TIERS = ["live", "pixel"] as const;
48
+ const OUT_OF_PROCESS: readonly string[] = [E2E, ...TEST_TIERS.map((tier) => `${TESTS}${tier}/`)];
49
+ const ACCEPTED_IGNORES: readonly (readonly string[])[] = [
50
+ [QUARANTINE, LIVE_TESTS, PIXEL_TESTS, VENDORED],
51
+ [QUARANTINE, LIVE_TESTS, PIXEL_TESTS],
52
+ ];
51
53
  export const LAYOUT_CHECK_MARK = "scripts/test-layout.ts";
52
54
  export const LAYOUT_CHECK_BIN = "checks-test-layout";
53
55
  const OWN_ENTRY_POINT = `scripts/${ENTRY_POINT.script}`;
@@ -57,10 +59,6 @@ export class LayoutError extends Schema.TaggedError<LayoutError>()("LayoutError"
57
59
  message: Schema.String,
58
60
  }) {}
59
61
 
60
- function isRecord(value: unknown): value is Record<string, unknown> {
61
- return typeof value === "object" && value !== null;
62
- }
63
-
64
62
  function runsLayoutCheck(lint: string): boolean {
65
63
  if (lint.includes(LAYOUT_CHECK_MARK) || lint.includes(LAYOUT_CHECK_BIN)) return true;
66
64
  return lint.split(/[\s|&;()]+/).some((word) => word === ENTRY_POINT.bin || word === OWN_ENTRY_POINT);
@@ -104,40 +102,11 @@ export function placementViolations(files: readonly string[]): readonly Violatio
104
102
  return violations;
105
103
  }
106
104
 
107
- function lineOf(source: string, start: number): number {
108
- const bytes = Buffer.from(source, "utf8");
109
- const offset = Math.max(0, Math.min(bytes.length, start - 1));
110
- let line = 1;
111
- for (let index = 0; index < offset; index += 1) {
112
- if (bytes[index] === 0x0a) line += 1;
113
- }
114
- return line;
115
- }
116
-
117
105
  function bannedModule(specifier: string): string | undefined {
118
106
  const bare = specifier.startsWith("node:") ? specifier.slice("node:".length) : specifier;
119
107
  return BANNED_MODULES.includes(bare) ? bare : undefined;
120
108
  }
121
109
 
122
- function identifierName(node: unknown): string | undefined {
123
- if (!isRecord(node) || node["type"] !== "Identifier") return undefined;
124
- const value = node["value"];
125
- return typeof value === "string" ? value : undefined;
126
- }
127
-
128
- function stringValue(node: unknown): string | undefined {
129
- if (!isRecord(node) || node["type"] !== "StringLiteral") return undefined;
130
- const value = node["value"];
131
- return typeof value === "string" ? value : undefined;
132
- }
133
-
134
- function spanStart(node: Record<string, unknown>): number {
135
- const span = node["span"];
136
- if (!isRecord(span)) return 0;
137
- const start = span["start"];
138
- return typeof start === "number" ? start : 0;
139
- }
140
-
141
110
  function importedNames(node: Record<string, unknown>): readonly string[] {
142
111
  const specifiers = node["specifiers"];
143
112
  if (!Array.isArray(specifiers)) return [];
@@ -197,10 +166,9 @@ function outOfProcessUse(node: Record<string, unknown>): string | undefined {
197
166
  }
198
167
 
199
168
  export const isolationViolations = Effect.fn("isolationViolations")(function* (file: string, source: string) {
200
- const module = yield* Effect.tryPromise({
201
- try: () => parse(source, { syntax: "typescript", tsx: file.endsWith(".tsx"), target: "esnext" }),
202
- catch: (error) => new LayoutError({ message: `cannot parse ${file}: ${String(error)}` }),
203
- });
169
+ const module = yield* parseTypeScript(file, source).pipe(
170
+ Effect.mapError((error) => new LayoutError({ message: error.message })),
171
+ );
204
172
  const violations: Violation[] = [];
205
173
  const seen = new Set<unknown>();
206
174
 
@@ -228,7 +196,22 @@ export const isolationViolations = Effect.fn("isolationViolations")(function* (f
228
196
  return violations;
229
197
  });
230
198
 
231
- export function scriptViolations(manifest: unknown): readonly Violation[] {
199
+ function tierScriptViolation(scripts: unknown, files: readonly string[], tier: (typeof TEST_TIERS)[number]): Violation | undefined {
200
+ const file = "package.json";
201
+ const key = `test:${tier}`;
202
+ const found = isRecord(scripts) ? scripts[key] : undefined;
203
+ const expected = `${TEST_ENTRY_POINT.bin} --tier=${tier}`;
204
+ const hasTests = files.some((candidate) => candidate.startsWith(`tests/${tier}/`) && TEST_FILE.test(candidate));
205
+ if (!hasTests && found === undefined) return undefined;
206
+ if (found === expected) return undefined;
207
+ return {
208
+ file,
209
+ line: undefined,
210
+ message: `${key} must be "${expected}"${hasTests ? " when its test tier has files" : " when declared"}, found ${JSON.stringify(found ?? null)}`,
211
+ };
212
+ }
213
+
214
+ export function scriptViolations(manifest: unknown, files: readonly string[] = []): readonly Violation[] {
232
215
  const file = "package.json";
233
216
  const scripts = isRecord(manifest) ? manifest["scripts"] : undefined;
234
217
  const test = isRecord(scripts) ? scripts["test"] : undefined;
@@ -248,10 +231,14 @@ export function scriptViolations(manifest: unknown): readonly Violation[] {
248
231
  message: `scripts.lint must run the layout check: add "${ENTRY_POINT.bin}"`,
249
232
  });
250
233
  }
234
+ for (const tier of TEST_TIERS) {
235
+ const violation = tierScriptViolation(scripts, files, tier);
236
+ if (violation !== undefined) violations.push(violation);
237
+ }
251
238
  return violations;
252
239
  }
253
240
 
254
- export function bunfigViolations(consumer: unknown, preset: unknown, vendors: boolean): readonly Violation[] {
241
+ export function bunfigViolations(consumer: unknown, preset: unknown): readonly Violation[] {
255
242
  const file = "bunfig.toml";
256
243
  const copy = "bun has no bunfig extends, so copy node_modules/@avi2dg/checks/bunfig.toml";
257
244
  if (consumer === undefined) {
@@ -272,10 +259,9 @@ export function bunfigViolations(consumer: unknown, preset: unknown, vendors: bo
272
259
  for (const [key, value] of Object.entries(presetTest)) {
273
260
  if (key !== IGNORES_KEY && !Bun.deepEquals(found(key), value)) violations.push(drifted(key, JSON.stringify(value), copy));
274
261
  }
275
- const accepted = acceptedIgnores(vendors);
276
- if (!accepted.some((ignores) => Bun.deepEquals(found(IGNORES_KEY), ignores))) {
277
- const wanted = accepted.map((ignores) => JSON.stringify(ignores)).join(" or ");
278
- violations.push(drifted(IGNORES_KEY, wanted, `the check pins it, requiring ${VENDORED} where ${QUALITY_FILE} declares sources.libraries`));
262
+ if (!ACCEPTED_IGNORES.some((ignores) => Bun.deepEquals(found(IGNORES_KEY), ignores))) {
263
+ const wanted = ACCEPTED_IGNORES.map((ignores) => JSON.stringify(ignores)).join(" or ");
264
+ violations.push(drifted(IGNORES_KEY, wanted, `the check pins it, and a repository that links libraries under repos/ keeps ${VENDORED}`));
279
265
  }
280
266
  return violations;
281
267
  }
@@ -318,7 +304,7 @@ export const run = Effect.fn("run")(function* (root: string, presetPath: string)
318
304
  (file) =>
319
305
  file.startsWith(TESTS) &&
320
306
  TYPESCRIPT.test(file) &&
321
- !file.startsWith(E2E) &&
307
+ !startsWithAny(file, OUT_OF_PROCESS) &&
322
308
  !file.startsWith(DATA_DIR),
323
309
  );
324
310
  for (const file of inProcess) {
@@ -326,13 +312,11 @@ export const run = Effect.fn("run")(function* (root: string, presetPath: string)
326
312
  violations.push(...(yield* isolationViolations(file, source)));
327
313
  }
328
314
 
329
- violations.push(...scriptViolations(yield* readManifest(path.join(root, "package.json"))));
315
+ violations.push(...scriptViolations(yield* readManifest(path.join(root, "package.json")), files));
330
316
 
331
317
  const bunfig = path.join(root, "bunfig.toml");
332
318
  const consumerBunfig = (yield* fs.exists(bunfig)) ? yield* parsedToml(bunfig) : undefined;
333
- const { quality } = yield* readQuality(root);
334
- const vendors = (quality.sources?.libraries ?? []).length > 0;
335
- violations.push(...bunfigViolations(consumerBunfig, yield* parsedToml(presetPath), vendors));
319
+ violations.push(...bunfigViolations(consumerBunfig, yield* parsedToml(presetPath)));
336
320
 
337
321
  return { files: files.length, violations };
338
322
  });
@@ -0,0 +1,180 @@
1
+ import { Effect, FileSystem, Path, Schema } from "effect";
2
+ import { identifierName, isRecord, lineOf, parseTypeScript, spanStart, stringValue } from "./swc.ts";
3
+
4
+ export type Environment = "ci" | "local";
5
+ export type TestTier = "live" | "pixel";
6
+ export type SkipDeclaration = {
7
+ readonly file: string;
8
+ readonly line: number;
9
+ readonly name: string;
10
+ readonly reason: string;
11
+ readonly when?: Environment;
12
+ };
13
+
14
+ export class SkipDeclarationError extends Schema.TaggedError<SkipDeclarationError>()("SkipDeclarationError", {
15
+ message: Schema.String,
16
+ }) {}
17
+
18
+ const TEST_FILES = ["tests/**/*.test.ts", "tests/**/*.test.tsx"] as const;
19
+ const SKIP_HELPER_MODULE = "test-skips.ts";
20
+ const whenSchema = Schema.Literals(["ci", "local"]);
21
+
22
+ export function skipReason(_reason: string, label: string, _when?: Environment): string {
23
+ return label;
24
+ }
25
+
26
+ function expressionOf(argument: unknown): unknown {
27
+ return isRecord(argument) ? argument["expression"] : undefined;
28
+ }
29
+
30
+ function argumentsOf(node: Record<string, unknown>): readonly unknown[] {
31
+ const args = node["arguments"];
32
+ return Array.isArray(args) ? args : [];
33
+ }
34
+
35
+ function skipReasonAliases(statement: unknown): readonly string[] {
36
+ if (!isRecord(statement) || statement["type"] !== "ImportDeclaration") return [];
37
+ const source = stringValue(statement["source"]);
38
+ const specifiers = statement["specifiers"];
39
+ if (source === undefined || !source.endsWith(SKIP_HELPER_MODULE) || !Array.isArray(specifiers)) return [];
40
+ return specifiers.flatMap((specifier) => {
41
+ if (!isRecord(specifier) || specifier["type"] !== "ImportSpecifier") return [];
42
+ const imported = identifierName(specifier["imported"]) ?? identifierName(specifier["local"]);
43
+ const local = identifierName(specifier["local"]);
44
+ return imported === "skipReason" && local !== undefined ? [local] : [];
45
+ });
46
+ }
47
+
48
+ function importedSkipReasonNames(module: unknown): ReadonlySet<string> {
49
+ if (!isRecord(module) || !Array.isArray(module["body"])) return new Set();
50
+ return new Set(module["body"].flatMap(skipReasonAliases));
51
+ }
52
+
53
+ type Member = { readonly object: string | undefined; readonly property: string | undefined };
54
+
55
+ function memberOf(node: unknown): Member | undefined {
56
+ if (!isRecord(node) || node["type"] !== "MemberExpression") return undefined;
57
+ return { object: identifierName(node["object"]), property: identifierName(node["property"]) };
58
+ }
59
+
60
+ type SkipReasonScan =
61
+ | { readonly kind: "none" }
62
+ | { readonly kind: "invalid"; readonly message: string }
63
+ | { readonly kind: "valid"; readonly name: string; readonly reason: string; readonly when?: Environment };
64
+
65
+ function skipReasonCall(node: unknown, aliases: ReadonlySet<string>): SkipReasonScan {
66
+ if (!isRecord(node) || node["type"] !== "CallExpression") return { kind: "none" };
67
+ if (!aliases.has(identifierName(node["callee"]) ?? "")) return { kind: "none" };
68
+ const [reasonArgument, labelArgument, whenArgument] = argumentsOf(node);
69
+ const reason = stringValue(expressionOf(reasonArgument));
70
+ const label = stringValue(expressionOf(labelArgument));
71
+ if (reason === undefined || reason.trim() === "" || label === undefined || label.trim() === "") {
72
+ return { kind: "invalid", message: "skipReason needs a non-empty literal reason and a literal test name" };
73
+ }
74
+ const when = whenArgument === undefined ? undefined : stringValue(expressionOf(whenArgument));
75
+ if (whenArgument !== undefined && when === undefined) {
76
+ return { kind: "invalid", message: "skipReason's when value must be \"ci\" or \"local\"" };
77
+ }
78
+ if (when !== undefined && !Schema.is(whenSchema)(when)) {
79
+ return {
80
+ kind: "invalid",
81
+ message: `skipReason's when value must be "ci" or "local", found ${JSON.stringify(when)}`,
82
+ };
83
+ }
84
+ return { kind: "valid", name: label, reason, ...(when === undefined ? {} : { when }) };
85
+ }
86
+
87
+ type RegisteredSkip = { readonly onDescribe: boolean; readonly first: unknown };
88
+
89
+ function registeredSkip(node: Record<string, unknown>): RegisteredSkip | undefined {
90
+ if (node["type"] !== "CallExpression") return undefined;
91
+ const callee = node["callee"];
92
+ const curried = isRecord(callee) && callee["type"] === "CallExpression";
93
+ const member = memberOf(curried ? callee["callee"] : callee);
94
+ const methods = curried ? ["skipIf", "if"] : ["skip", "todo"];
95
+ if (member === undefined || !methods.includes(member.property ?? "")) return undefined;
96
+ return { onDescribe: member.object === "describe", first: expressionOf(argumentsOf(node)[0]) };
97
+ }
98
+
99
+ function registeredSkips(module: unknown): readonly RegisteredSkip[] {
100
+ const found: RegisteredSkip[] = [];
101
+ const visit = (node: unknown): void => {
102
+ if (Array.isArray(node)) {
103
+ for (const child of node) visit(child);
104
+ return;
105
+ }
106
+ if (!isRecord(node)) return;
107
+ const registered = registeredSkip(node);
108
+ if (registered !== undefined) found.push(registered);
109
+ for (const value of Object.values(node)) visit(value);
110
+ };
111
+ visit(module);
112
+ return found;
113
+ }
114
+
115
+ type DeclarationsRead =
116
+ | { readonly kind: "valid"; readonly declarations: readonly SkipDeclaration[] }
117
+ | { readonly kind: "invalid"; readonly message: string };
118
+
119
+ function declarationsIn(source: string, file: string, module: unknown): DeclarationsRead {
120
+ const aliases = importedSkipReasonNames(module);
121
+ if (aliases.size === 0) return { kind: "valid", declarations: [] };
122
+ const declarations: SkipDeclaration[] = [];
123
+ for (const { onDescribe, first } of registeredSkips(module)) {
124
+ if (!isRecord(first)) continue;
125
+ const metadata = skipReasonCall(first, aliases);
126
+ if (metadata.kind === "none") continue;
127
+ const line = lineOf(source, spanStart(first));
128
+ if (onDescribe) {
129
+ return {
130
+ kind: "invalid",
131
+ message: `line ${line}: skipReason cannot declare a describe; declare each test inside it with its own skipReason`,
132
+ };
133
+ }
134
+ if (metadata.kind === "invalid") return metadata;
135
+ const { name, reason, when } = metadata;
136
+ declarations.push({ file, line, name, reason, ...(when === undefined ? {} : { when }) });
137
+ }
138
+ return { kind: "valid", declarations };
139
+ }
140
+
141
+ function selectedTierFile(file: string, tier: TestTier | undefined): boolean {
142
+ if (file.startsWith("tests/quarantine/")) return false;
143
+ if (tier === undefined) return !file.startsWith("tests/live/") && !file.startsWith("tests/pixel/");
144
+ return file.startsWith(`tests/${tier}/`);
145
+ }
146
+
147
+ export const readSkipDeclarations = Effect.fn("readSkipDeclarations")(function* (
148
+ root: string,
149
+ tier: TestTier | undefined,
150
+ ) {
151
+ const fs = yield* FileSystem.FileSystem;
152
+ const path = yield* Path.Path;
153
+ const files = yield* Effect.tryPromise({
154
+ try: () =>
155
+ Promise.all(
156
+ TEST_FILES.map((pattern) =>
157
+ Array.fromAsync(new Bun.Glob(pattern).scan({ cwd: root, onlyFiles: true })),
158
+ ),
159
+ ).then((groups) => groups.flat().filter((file) => selectedTierFile(file, tier)).sort()),
160
+ catch: (cause) => new SkipDeclarationError({ message: `cannot find test files: ${String(cause)}` }),
161
+ });
162
+ const declarations: SkipDeclaration[] = [];
163
+ for (const file of files) {
164
+ const source = yield* fs.readFileString(path.join(root, file)).pipe(
165
+ Effect.mapError((cause) => new SkipDeclarationError({ message: `cannot read ${file}: ${cause.message}` })),
166
+ );
167
+ const module = yield* parseTypeScript(file, source).pipe(
168
+ Effect.mapError((error) => new SkipDeclarationError({ message: error.message })),
169
+ );
170
+ const parsed = yield* Effect.try({
171
+ try: () => declarationsIn(source, file, module),
172
+ catch: (cause) => new SkipDeclarationError({ message: `cannot read skip declarations in ${file}: ${String(cause)}` }),
173
+ });
174
+ if (parsed.kind === "invalid") {
175
+ return yield* new SkipDeclarationError({ message: `${file}: ${parsed.message}` });
176
+ }
177
+ declarations.push(...parsed.declarations);
178
+ }
179
+ return declarations;
180
+ });