@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.
- package/CHANGELOG.md +65 -39
- package/CONTRIBUTING.md +11 -8
- package/README.md +15 -24
- package/bunfig.toml +1 -1
- package/docs/configs/commit-messages.md +5 -1
- package/docs/configs/dependency-rules.md +5 -2
- package/docs/configs/effect-rules.md +32 -33
- package/docs/configs/native-settings.md +74 -0
- package/docs/configs/typescript-rules.md +4 -0
- package/docs/design.md +24 -25
- package/docs/gates/checks-backtest.md +4 -0
- package/docs/gates/checks-ci-wiring.md +30 -93
- package/docs/gates/checks-comment-gate.md +4 -0
- package/docs/gates/checks-commit-identity.md +23 -27
- package/docs/gates/checks-docs.md +23 -21
- package/docs/gates/checks-flake.md +4 -10
- package/docs/gates/checks-lint-coverage.md +5 -1
- package/docs/gates/checks-lint.md +22 -104
- package/docs/gates/checks-mutation-compare.md +4 -0
- package/docs/gates/checks-quarantine-clock.md +4 -0
- package/docs/gates/checks-repetition.md +26 -41
- package/docs/gates/checks-subsumed-tests.md +4 -0
- package/docs/gates/checks-suppressions-ratchet.md +4 -0
- package/docs/gates/checks-test-layout.md +16 -8
- package/docs/gates/checks-test.md +68 -36
- package/docs/gates/checks-vendor.md +20 -13
- package/package.json +8 -21
- package/scripts/ci-wiring.ts +28 -97
- package/scripts/commit-identity.ts +32 -4
- package/scripts/doc-rules.ts +26 -11
- package/scripts/doc-templates.ts +2 -1
- package/scripts/docs.ts +4 -7
- package/scripts/gates.ts +0 -29
- package/scripts/git.ts +24 -1
- package/scripts/lint.ts +15 -34
- package/scripts/range-gate.ts +1 -2
- package/scripts/repetition.ts +40 -40
- package/scripts/shell-command.ts +7 -1
- package/scripts/swc.ts +46 -0
- package/scripts/test-layout.ts +40 -56
- package/scripts/test-skips.ts +180 -0
- package/scripts/test.ts +33 -38
- package/scripts/vendor.ts +55 -11
- package/dist/feature-rules.js +0 -349
- package/docs/configs/quality-file.md +0 -101
- package/docs/gates/checks-feature-owners.md +0 -113
- package/docs/gates/checks-quality.md +0 -109
- package/docs/gates/checks-size-budget.md +0 -107
- package/quality.schema.json +0 -496
- package/scripts/feature-owners.ts +0 -139
- package/scripts/quality-file.ts +0 -347
- package/scripts/quality.ts +0 -342
- package/scripts/size-budget.ts +0 -285
- 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 {
|
|
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
|
|
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*
|
|
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[]
|
|
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
|
|
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[]
|
|
151
|
-
const ends = yield* endsOf(args
|
|
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
|
|
188
|
-
const range = yield* resolveRange(process.argv.slice(2)
|
|
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 =
|
|
191
|
-
|
|
192
|
-
|
|
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) {
|
package/scripts/range-gate.ts
CHANGED
|
@@ -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
|
|
7
|
+
return { refs, root };
|
|
9
8
|
});
|
package/scripts/repetition.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
49
|
-
|
|
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,
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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))
|
|
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,
|
|
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,
|
|
138
|
+
(yield* changedPaths(base, head, [], root)).flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from]] : [])),
|
|
135
139
|
);
|
|
136
|
-
|
|
137
|
-
const
|
|
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(
|
|
141
|
-
|
|
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}
|
|
156
|
-
: [`${NAME}: ${rises.length}
|
|
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
|
-
|
|
171
|
-
|
|
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,
|
|
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;
|
package/scripts/shell-command.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/scripts/test-layout.ts
CHANGED
|
@@ -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,
|
|
7
|
-
import {
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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*
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|