@avi2dg/checks 0.20.0 → 0.22.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 (54) hide show
  1. package/CHANGELOG.md +65 -39
  2. package/CONTRIBUTING.md +11 -8
  3. package/README.md +15 -24
  4. package/bunfig.toml +1 -1
  5. package/docs/configs/commit-messages.md +5 -1
  6. package/docs/configs/dependency-rules.md +5 -2
  7. package/docs/configs/effect-rules.md +32 -33
  8. package/docs/configs/native-settings.md +74 -0
  9. package/docs/configs/typescript-rules.md +4 -0
  10. package/docs/design.md +24 -25
  11. package/docs/gates/checks-backtest.md +4 -0
  12. package/docs/gates/checks-ci-wiring.md +30 -93
  13. package/docs/gates/checks-comment-gate.md +4 -0
  14. package/docs/gates/checks-commit-identity.md +23 -27
  15. package/docs/gates/checks-docs.md +23 -21
  16. package/docs/gates/checks-flake.md +4 -10
  17. package/docs/gates/checks-lint-coverage.md +5 -1
  18. package/docs/gates/checks-lint.md +22 -104
  19. package/docs/gates/checks-mutation-compare.md +4 -0
  20. package/docs/gates/checks-quarantine-clock.md +4 -0
  21. package/docs/gates/checks-repetition.md +26 -41
  22. package/docs/gates/checks-subsumed-tests.md +4 -0
  23. package/docs/gates/checks-suppressions-ratchet.md +4 -0
  24. package/docs/gates/checks-test-layout.md +16 -8
  25. package/docs/gates/checks-test.md +68 -36
  26. package/docs/gates/checks-vendor.md +20 -13
  27. package/package.json +8 -21
  28. package/scripts/ci-wiring.ts +28 -97
  29. package/scripts/commit-identity.ts +32 -4
  30. package/scripts/doc-rules.ts +26 -11
  31. package/scripts/doc-templates.ts +2 -1
  32. package/scripts/docs.ts +4 -7
  33. package/scripts/gates.ts +0 -29
  34. package/scripts/git.ts +24 -1
  35. package/scripts/lint.ts +15 -34
  36. package/scripts/range-gate.ts +1 -2
  37. package/scripts/repetition.ts +40 -40
  38. package/scripts/shell-command.ts +7 -1
  39. package/scripts/swc.ts +46 -0
  40. package/scripts/test-layout.ts +40 -56
  41. package/scripts/test-skips.ts +180 -0
  42. package/scripts/test.ts +33 -38
  43. package/scripts/vendor.ts +55 -11
  44. package/dist/feature-rules.js +0 -349
  45. package/docs/configs/quality-file.md +0 -101
  46. package/docs/gates/checks-feature-owners.md +0 -113
  47. package/docs/gates/checks-quality.md +0 -109
  48. package/docs/gates/checks-size-budget.md +0 -107
  49. package/quality.schema.json +0 -496
  50. package/scripts/feature-owners.ts +0 -139
  51. package/scripts/quality-file.ts +0 -347
  52. package/scripts/quality.ts +0 -342
  53. package/scripts/size-budget.ts +0 -285
  54. package/scripts/size-rules.ts +0 -126
package/scripts/lint.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
3
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
- import { DEFAULT_BRANCH, selectedGates, type KitGate } from "./gates.ts";
5
- import { git } from "./git.ts";
4
+ import { EVERY_REPOSITORY, KIT_GATES, type KitGate } from "./gates.ts";
5
+ import { defaultBranch, git } from "./git.ts";
6
6
  import { runMain, Usage } from "./main.ts";
7
- import { readQuality } from "./quality-file.ts";
8
7
 
9
8
  type Range = {
10
9
  readonly refs: readonly [tip: string] | readonly [base: string, head: string];
@@ -56,17 +55,6 @@ const pullRequestEnds = Effect.fn("pullRequestEnds")(function* (eventPath: strin
56
55
  return pullRequestEndsOf(pullRequest);
57
56
  });
58
57
 
59
- const readWiring = Effect.gen(function* () {
60
- const { source, quality } = yield* readQuality((yield* git(["rev-parse", "--show-toplevel"])).trim());
61
- return { source, defaultBranch: quality.defaultBranch ?? DEFAULT_BRANCH, lintGates: quality.gates?.lint };
62
- });
63
-
64
- // Decides the base ref `git symbolic-ref` already resolved, or the repository's declared
65
- // default branch when that ref is absent, such as a checkout with no remote HEAD symlink.
66
- export function originRefOf(symbolicRef: string | undefined, defaultBranch: string): string {
67
- return symbolicRef ?? `origin/${defaultBranch}`;
68
- }
69
-
70
58
  // A clone holding any remote-tracking ref but not the default branch is a shallow CI checkout,
71
59
  // where judging HEAD alone would pass every commit before it unchecked. No origin ref means no remote-tracking ref at all.
72
60
  export function localEndsOf(originRef: string | undefined): { base: string; head: string; source: string } {
@@ -74,18 +62,11 @@ export function localEndsOf(originRef: string | undefined): { base: string; head
74
62
  return { base: originRef, head: "HEAD", source: `HEAD against ${originRef}` };
75
63
  }
76
64
 
77
- const originRef = (defaultBranch: string) =>
78
- git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]).pipe(
79
- Effect.map((ref) => ref.trim()),
80
- Effect.catchTag("GitFailure", () => Effect.succeed(undefined)),
81
- Effect.map((symbolicRef) => originRefOf(symbolicRef, defaultBranch)),
82
- );
83
-
84
- const localEnds = Effect.fn("localEnds")(function* (defaultBranch: string) {
65
+ const localEnds = Effect.gen(function* () {
85
66
  const remoteTracking = yield* git(["for-each-ref", "--count=1", "refs/remotes/"]).pipe(
86
67
  Effect.mapError((cause) => new RangeUnresolved({ message: cause.message })),
87
68
  );
88
- return localEndsOf(remoteTracking.trim() === "" ? undefined : yield* originRef(defaultBranch));
69
+ return localEndsOf(remoteTracking.trim() === "" ? undefined : `origin/${yield* defaultBranch()}`);
89
70
  });
90
71
 
91
72
  // Decides which source of range ends the arguments and the pull request env vars select,
@@ -107,7 +88,7 @@ export function selectEnds(args: readonly string[], eventName: string | undefine
107
88
  return { kind: "local" };
108
89
  }
109
90
 
110
- const endsOf = Effect.fn("endsOf")(function* (args: readonly string[], defaultBranch: string) {
91
+ const endsOf = Effect.fn("endsOf")(function* (args: readonly string[]) {
111
92
  const event = yield* Config.all({
112
93
  name: Config.option(Config.String("GITHUB_EVENT_NAME")),
113
94
  path: Config.option(Config.String("GITHUB_EVENT_PATH")),
@@ -127,7 +108,7 @@ const endsOf = Effect.fn("endsOf")(function* (args: readonly string[], defaultBr
127
108
  case "pull-request-unresolved":
128
109
  return yield* new RangeUnresolved({ message: `${PULL_REQUEST_EVENT} sets no GITHUB_EVENT_PATH` });
129
110
  case "local":
130
- return yield* localEnds(defaultBranch);
111
+ return yield* localEnds;
131
112
  }
132
113
  });
133
114
 
@@ -147,8 +128,8 @@ export function rangeOf(base: string, head: string): Range["refs"] {
147
128
 
148
129
  // From the base branch's tip, every range gate would charge the head with the commits the base
149
130
  // branch gained after the head branched off.
150
- const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[], defaultBranch: string) {
151
- const ends = yield* endsOf(args, defaultBranch);
131
+ const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[]) {
132
+ const ends = yield* endsOf(args);
152
133
  const head = yield* commitOf(ends.head);
153
134
  const base = yield* git(["merge-base", yield* commitOf(ends.base), head]).pipe(
154
135
  Effect.map((sha) => sha.trim()),
@@ -184,14 +165,14 @@ const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
184
165
  });
185
166
 
186
167
  const lint = Effect.gen(function* () {
187
- const { source, defaultBranch, lintGates } = yield* readWiring;
188
- const range = yield* resolveRange(process.argv.slice(2), defaultBranch);
168
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
169
+ const range = yield* resolveRange(process.argv.slice(2));
189
170
  yield* Console.log(`${NAME}: ${describe(range)} from ${range.source}`);
190
- const gates = selectedGates(lintGates);
191
- if (lintGates !== undefined) {
192
- yield* Console.log(`${NAME}: ${source} selects ${gates.map((gate) => gate.bin).join(", ")}`);
193
- }
194
-
171
+ const gates = yield* Effect.filter(KIT_GATES, (gate) =>
172
+ gate.appliesTo === EVERY_REPOSITORY
173
+ ? Effect.succeed(true)
174
+ : git(["ls-files", "--", ...gate.appliesTo.pathspecs], root).pipe(Effect.map((files) => files.trim() !== "")),
175
+ );
195
176
  const verdicts = yield* Effect.forEach(gates, (gate) => runGate(gate, range));
196
177
  const failed = verdicts.filter((verdict) => verdict.outcome !== "passed");
197
178
  if (failed.length === 0) {
@@ -1,9 +1,8 @@
1
1
  import { Effect } from "effect";
2
2
  import { git, refArgs } from "./git.ts";
3
- import { readQuality } from "./quality-file.ts";
4
3
 
5
4
  export const rangeGateInputs = Effect.fn("rangeGateInputs")(function* (usage: string) {
6
5
  const refs = yield* refArgs(process.argv.slice(2), usage);
7
6
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
8
- return { refs, root, ...(yield* readQuality(root)) };
7
+ return { refs, root };
9
8
  });
@@ -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,25 @@ 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
+ type Scan = { readonly sources: number; readonly clones: readonly Clone[] };
52
+
53
+ function fragmentOf(file: (name: string) => string, { name, start, end }: typeof Location.Type): Fragment {
54
+ return { file: file(name), start, end };
50
55
  }
51
56
 
52
57
  export function repeatedLines(clones: readonly Clone[]): ReadonlyMap<string, number> {
@@ -60,28 +65,36 @@ export function repeatedLines(clones: readonly Clone[]): ReadonlyMap<string, num
60
65
  }
61
66
 
62
67
  // jscpd reads files from disk, and the working tree need not hold the revision: a merge checkout or an uncommitted edit.
68
+ // Both ends scan under the head's config, so a changed file set never reads as a change in repetition.
63
69
  const scan = Effect.fn("scan")(
64
- function* (root: string, rev: string, files: readonly string[]) {
65
- if (files.length === 0) return [];
70
+ function* (root: string, rev: string, config: string) {
71
+ const files = yield* pathsAt(rev, [], root);
72
+ if (files.length === 0) return { sources: 0, clones: [] } satisfies Scan;
66
73
  const fs = yield* FileSystem.FileSystem;
67
74
  const path = yield* Path.Path;
68
75
  const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "checks-repetition-" });
69
76
  const tree = yield* checkoutFiles(rev, files, scratch, root);
77
+ yield* fs.writeFileString(path.join(tree, CONFIG), config);
70
78
  const output = path.join(scratch, "report");
71
- const flags = ["--silent", "--no-tips", "--no-gitignore", "--reporters", "json", "--output", output];
79
+ const flags = ["--silent", "--no-tips", "--no-gitignore", "--absolute", "--reporters", "json", "--output", output];
72
80
  const thresholds = ["--min-tokens", `${MIN_TOKENS}`, "--min-lines", `${MIN_LINES}`];
73
81
 
74
- const run = yield* collect("jscpd", [...flags, ...thresholds, "."], tree).pipe(
82
+ const run = yield* collect("jscpd", [...flags, ...thresholds], tree).pipe(
75
83
  Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot run jscpd: ${cause.message}` })),
76
84
  );
77
- if (run.exitCode !== JSCPD_FINISHED) {
85
+ const reportPath = path.join(output, REPORT);
86
+ // A threshold, exitCode or failOnEmpty in the repository's config fails jscpd after the report is written.
87
+ if (run.exitCode !== JSCPD_FINISHED && !(yield* fs.exists(reportPath))) {
78
88
  return yield* new JscpdUnreadable({ message: `jscpd exited ${run.exitCode}: ${run.stderr.trim() || run.stdout.trim()}` });
79
89
  }
80
- const { duplicates } = yield* fs.readFileString(path.join(output, REPORT)).pipe(
90
+ const { duplicates, statistics } = yield* fs.readFileString(reportPath).pipe(
81
91
  Effect.flatMap(decodeReport),
82
92
  Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot read jscpd's report: ${cause.message}` })),
83
93
  );
84
- return duplicates.map(({ firstFile, secondFile }): Clone => [fragmentOf(firstFile), fragmentOf(secondFile)]);
94
+ const scanned = yield* fs.realPath(tree);
95
+ const file = (name: string): string => path.relative(scanned, name);
96
+ const clones = duplicates.map(({ firstFile, secondFile }): Clone => [fragmentOf(file, firstFile), fragmentOf(file, secondFile)]);
97
+ return { sources: statistics.total.sources, clones } satisfies Scan;
85
98
  },
86
99
  Effect.scoped,
87
100
  );
@@ -113,33 +126,22 @@ export function risesOf(
113
126
  .toSorted((a, b) => a.file.localeCompare(b.file));
114
127
  }
115
128
 
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 {
129
+ // Tallies every repeating file that did not rise as advisory.
130
+ export function heldOf(measured: number, rises: readonly Rise[], after: ReadonlyMap<string, number>): Held {
123
131
  const risen = new Set(rises.map((rise) => rise.file));
124
- const advisory = [...after].filter(([file]) => !risen.has(file)).concat([...othersRepeated]);
132
+ const advisory = [...after].filter(([file]) => !risen.has(file));
125
133
  return { measured, rises, advisory: new Map(advisory.toSorted(([a], [b]) => a.localeCompare(b))) };
126
134
  }
127
135
 
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));
136
+ const runHold = Effect.fn("runHold")(function* (root: string, config: string, base: string, head: string) {
133
137
  const formerPath = new Map(
134
- (yield* changedPaths(base, head, pathspecs, root)).flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from]] : [])),
138
+ (yield* changedPaths(base, head, [], root)).flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from]] : [])),
135
139
  );
136
-
137
- const before = repeatedLines(yield* scan(root, base, yield* pathsAt(base, pathspecs, root)));
138
- const clones = yield* scan(root, head, held);
140
+ const before = repeatedLines((yield* scan(root, base, config)).clones);
141
+ const { sources, clones } = yield* scan(root, head, config);
139
142
  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;
143
+ const rises = risesOf([...after.keys()], before, after, formerPath, clones);
144
+ return heldOf(sources, rises, after) satisfies Held;
143
145
  });
144
146
 
145
147
  export function describe({ file, before, after, clones }: Rise): readonly string[] {
@@ -152,8 +154,8 @@ export function describe({ file, before, after, clones }: Rise): readonly string
152
154
  export function report({ measured, rises, advisory }: Held): string {
153
155
  const verdict =
154
156
  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)];
157
+ ? [`${NAME}: ${measured} file(s) ${CONFIG} holds repeat no more lines than where the range starts, ${MEASURE}`]
158
+ : [`${NAME}: ${rises.length} file(s) ${CONFIG} holds repeat more lines than where the range starts, ${MEASURE}:`, ...rises.flatMap(describe)];
157
159
  const notice =
158
160
  advisory.size === 0
159
161
  ? []
@@ -167,13 +169,11 @@ export function report({ measured, rises, advisory }: Held): string {
167
169
  const hold = Effect.gen(function* () {
168
170
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
169
171
  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`);
172
+ if ((yield* pathsAt(head, [`:(literal)${CONFIG}`], root)).length === 0) {
173
+ yield* Console.log(`${NAME}: the head holds no ${CONFIG}, so no file is measured`);
174
174
  return true;
175
175
  }
176
- const held = yield* runHold(root, production, base, head);
176
+ const held = yield* runHold(root, yield* git(["show", `${head}:${CONFIG}`], root), base, head);
177
177
 
178
178
  yield* Console.log(report(held));
179
179
  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
  });