@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
|
@@ -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
|
+
});
|
package/scripts/test.ts
CHANGED
|
@@ -3,20 +3,10 @@ import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
|
3
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
4
4
|
import { TEST_ENTRY_POINT } from "./gates.ts";
|
|
5
5
|
import { runMain, Usage } from "./main.ts";
|
|
6
|
+
import { readSkipDeclarations, type Environment, type SkipDeclaration, type TestTier } from "./test-skips.ts";
|
|
6
7
|
import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export type Environment = typeof Environment.Type;
|
|
11
|
-
|
|
12
|
-
const SkipDeclaration = Schema.Struct({
|
|
13
|
-
file: Schema.NonEmptyString,
|
|
14
|
-
test: Schema.NonEmptyString,
|
|
15
|
-
reason: Schema.NonEmptyString,
|
|
16
|
-
when: Schema.optionalKey(Environment),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export type SkipDeclaration = typeof SkipDeclaration.Type;
|
|
9
|
+
export type { Environment, SkipDeclaration } from "./test-skips.ts";
|
|
20
10
|
|
|
21
11
|
export type Verdict = {
|
|
22
12
|
readonly environment: Environment;
|
|
@@ -31,15 +21,23 @@ export class SkipGateError extends Schema.TaggedError<SkipGateError>()("SkipGate
|
|
|
31
21
|
}) {}
|
|
32
22
|
|
|
33
23
|
const NAME = TEST_ENTRY_POINT.bin;
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
);
|
|
24
|
+
const USAGE = `usage: ${NAME} takes no arguments except --tier=live or --tier=pixel`;
|
|
25
|
+
const TIERS = new Map<string, TestTier>([
|
|
26
|
+
["--tier=live", "live"],
|
|
27
|
+
["--tier=pixel", "pixel"],
|
|
28
|
+
]);
|
|
40
29
|
|
|
41
30
|
function matches(declaration: SkipDeclaration, result: TestResult): boolean {
|
|
42
|
-
|
|
31
|
+
const { name } = declaration;
|
|
32
|
+
return (
|
|
33
|
+
declaration.file === result.file &&
|
|
34
|
+
declaration.line === result.line &&
|
|
35
|
+
(result.name === name || result.name.endsWith(`${NAME_SEPARATOR}${name}`))
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function skippedOutcome(result: TestResult): boolean {
|
|
40
|
+
return result.outcome === "skipped" || result.outcome === "todo";
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
function byPlace(a: TestResult, b: TestResult): number {
|
|
@@ -51,7 +49,7 @@ export function judgeSkips(
|
|
|
51
49
|
declarations: readonly SkipDeclaration[],
|
|
52
50
|
environment: Environment,
|
|
53
51
|
): Verdict {
|
|
54
|
-
const skipped = results.filter(
|
|
52
|
+
const skipped = results.filter(skippedOutcome);
|
|
55
53
|
const applying = declarations.filter((declaration) => (declaration.when ?? environment) === environment);
|
|
56
54
|
return {
|
|
57
55
|
environment,
|
|
@@ -71,7 +69,7 @@ export function passes(verdict: Verdict): boolean {
|
|
|
71
69
|
}
|
|
72
70
|
|
|
73
71
|
function staleLine(declaration: SkipDeclaration): string {
|
|
74
|
-
return ` ${declaration.file}
|
|
72
|
+
return ` ${declaration.file}:${declaration.line}: no test skipped with this declaration; reason: ${declaration.reason}`;
|
|
75
73
|
}
|
|
76
74
|
|
|
77
75
|
function verdictLines(verdict: Verdict): readonly string[] {
|
|
@@ -80,15 +78,18 @@ function verdictLines(verdict: Verdict): readonly string[] {
|
|
|
80
78
|
if (passes(verdict)) {
|
|
81
79
|
const other = environment === "ci" ? "local" : "ci";
|
|
82
80
|
const aside = unjudged === 0 ? "" : `; ${unjudged} declaration(s) for ${other} not judged in ${run}`;
|
|
83
|
-
return [skipped === 0 ? `${NAME}: no test skipped${aside}` : `${NAME}: ${skipped} skipped test(s), each declared
|
|
81
|
+
return [skipped === 0 ? `${NAME}: no test skipped${aside}` : `${NAME}: ${skipped} skipped test(s), each declared at its test site${aside}`];
|
|
84
82
|
}
|
|
85
83
|
const stale = refusedStale(verdict);
|
|
86
84
|
const counted = environment === "ci" ? ` and ${stale.length} declaration(s) matching no skipped test` : "";
|
|
87
85
|
return [
|
|
88
86
|
`${NAME}: ${undeclared.length} skipped test(s) undeclared${counted} in ${run}:`,
|
|
89
87
|
...undeclared.map((result) => {
|
|
90
|
-
const kind =
|
|
91
|
-
|
|
88
|
+
const [kind, usage] =
|
|
89
|
+
result.outcome === "todo"
|
|
90
|
+
? ["a todo", "test.todo(skipReason(reason, name))"]
|
|
91
|
+
: ["skipped", "test.skipIf(condition)(skipReason(reason, name), fn)"];
|
|
92
|
+
return ` ${result.file}:${result.line} ${result.name}: ${kind} with no reason at its test site; use ${usage}`;
|
|
92
93
|
}),
|
|
93
94
|
...stale.map(staleLine),
|
|
94
95
|
];
|
|
@@ -106,37 +107,31 @@ export function report(verdict: Verdict): string {
|
|
|
106
107
|
return [...verdictLines(verdict), ...warning].join("\n");
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
const readDeclarations = Effect.fn("readDeclarations")(function* (root: string) {
|
|
110
|
-
const manifest = (yield* Path.Path).join(root, "package.json");
|
|
111
|
-
const { testSkips } = yield* (yield* FileSystem.FileSystem).readFileString(manifest).pipe(
|
|
112
|
-
Effect.flatMap(decodeManifest),
|
|
113
|
-
Effect.mapError((cause) => new SkipGateError({ message: `cannot read ${DECLARATIONS}: ${cause.message}` })),
|
|
114
|
-
);
|
|
115
|
-
return testSkips ?? [];
|
|
116
|
-
});
|
|
117
|
-
|
|
118
110
|
const environmentOf = Config.Boolean("CI").pipe(
|
|
119
111
|
Config.withDefault(false),
|
|
120
112
|
Config.map((ci): Environment => (ci ? "ci" : "local")),
|
|
121
113
|
Effect.mapError((cause) => new SkipGateError({ message: `cannot tell a ci run from a local one: ${cause.message}` })),
|
|
122
114
|
);
|
|
123
115
|
|
|
124
|
-
const runSuite = Effect.fn("runSuite")(function* (outfile: string) {
|
|
116
|
+
const runSuite = Effect.fn("runSuite")(function* (outfile: string, tier: TestTier | undefined) {
|
|
125
117
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
126
|
-
const
|
|
118
|
+
const tierArgs = tier === undefined ? [] : ["--path-ignore-patterns", "", `./tests/${tier}`];
|
|
119
|
+
const args = ["test", "--randomize", ...tierArgs, ...reporterArgs(outfile)];
|
|
127
120
|
return yield* spawner.exitCode(
|
|
128
121
|
ChildProcess.make(process.execPath, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit" }),
|
|
129
122
|
);
|
|
130
123
|
});
|
|
131
124
|
|
|
132
125
|
const testEntry = Effect.gen(function* () {
|
|
133
|
-
|
|
126
|
+
const args = process.argv.slice(2);
|
|
127
|
+
const tier = args.length === 0 ? undefined : args.length === 1 ? TIERS.get(args[0] ?? "") : undefined;
|
|
128
|
+
if (args.length > 0 && tier === undefined) return yield* new Usage({ message: USAGE });
|
|
134
129
|
const fs = yield* FileSystem.FileSystem;
|
|
135
|
-
const declarations = yield*
|
|
130
|
+
const declarations = yield* readSkipDeclarations(process.cwd(), tier);
|
|
136
131
|
const environment = yield* environmentOf;
|
|
137
132
|
|
|
138
133
|
const outfile = (yield* Path.Path).join(yield* fs.makeTempDirectoryScoped({ prefix: "checks-test-" }), "junit.xml");
|
|
139
|
-
const suitePassed = (yield* runSuite(outfile)) === ChildProcessSpawner.ExitCode(0);
|
|
134
|
+
const suitePassed = (yield* runSuite(outfile, tier)) === ChildProcessSpawner.ExitCode(0);
|
|
140
135
|
if (!(yield* fs.exists(outfile))) {
|
|
141
136
|
if (!suitePassed) {
|
|
142
137
|
yield* Console.error(`${NAME}: bun test failed before it wrote a report, so no skip was judged`);
|
package/scripts/vendor.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
|
-
import { QUALITY_FILE } from "./gates.ts";
|
|
4
3
|
import { git } from "./git.ts";
|
|
5
4
|
import { runMain, Usage } from "./main.ts";
|
|
6
|
-
import { readQuality, type Library } from "./quality-file.ts";
|
|
7
5
|
|
|
8
6
|
const NAME = "checks-vendor";
|
|
9
|
-
const USAGE = `usage: ${NAME}
|
|
7
|
+
const USAGE = `usage: ${NAME} [--library <name> --package <package> --repository <remote> --tag <template> [--path <manifest>]]...`;
|
|
10
8
|
const CACHE_HOME = ".cache/avi2dg-checks";
|
|
11
9
|
const RECORD_SUFFIX = ".commit";
|
|
12
10
|
const LINKS = "repos";
|
|
@@ -23,6 +21,55 @@ export class Unreachable extends Schema.TaggedError<Unreachable>()("Unreachable"
|
|
|
23
21
|
message: Schema.String,
|
|
24
22
|
}) {}
|
|
25
23
|
|
|
24
|
+
const Library = Schema.Struct({
|
|
25
|
+
name: Schema.String.check(Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)),
|
|
26
|
+
package: Schema.NonEmptyString,
|
|
27
|
+
repository: Schema.NonEmptyString,
|
|
28
|
+
tag: Schema.String.check(Schema.isPattern(/\{version\}/)),
|
|
29
|
+
path: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^(?:[\w.@+-]+\/)*[\w.@+-]+\.\w+$/))),
|
|
30
|
+
});
|
|
31
|
+
type Library = typeof Library.Type;
|
|
32
|
+
|
|
33
|
+
const Libraries = Schema.Array(Library).check(
|
|
34
|
+
Schema.makeFilter((libraries) => {
|
|
35
|
+
const names = libraries.map(({ name }) => name);
|
|
36
|
+
const repeated = names.find((name, index) => names.indexOf(name) !== index);
|
|
37
|
+
return repeated === undefined || `--library ${repeated} appears more than once`;
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const OPENER = "--library";
|
|
42
|
+
const FIELDS = new Map<string, keyof Library>([
|
|
43
|
+
["--package", "package"],
|
|
44
|
+
["--repository", "repository"],
|
|
45
|
+
["--tag", "tag"],
|
|
46
|
+
["--path", "path"],
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
function misuse(message: string): Usage {
|
|
50
|
+
return new Usage({ message: `${message}\n${USAGE}` });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const librariesFrom = Effect.fn("librariesFrom")(function* (args: readonly string[]) {
|
|
54
|
+
const groups: Record<string, string>[] = [];
|
|
55
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
56
|
+
const flag = args[index] ?? "";
|
|
57
|
+
const value = args[index + 1];
|
|
58
|
+
if (value === undefined || value.startsWith("--")) return yield* misuse(`${flag} takes a value`);
|
|
59
|
+
if (flag === OPENER) {
|
|
60
|
+
groups.push({ name: value });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const field = FIELDS.get(flag);
|
|
64
|
+
const current = groups.at(-1);
|
|
65
|
+
if (field === undefined) return yield* misuse(`${flag} is not an argument`);
|
|
66
|
+
if (current === undefined) return yield* misuse(`${flag} comes before any ${OPENER}`);
|
|
67
|
+
if (field in current) return yield* misuse(`${flag} appears twice for ${OPENER} ${current["name"]}`);
|
|
68
|
+
current[field] = value;
|
|
69
|
+
}
|
|
70
|
+
return yield* Schema.decodeUnknownEffect(Libraries)(groups).pipe(Effect.mapError((cause) => misuse(cause.message)));
|
|
71
|
+
});
|
|
72
|
+
|
|
26
73
|
export function tagFor(template: string, version: string): string {
|
|
27
74
|
return template.replaceAll(VERSION_TOKEN, version);
|
|
28
75
|
}
|
|
@@ -296,8 +343,11 @@ const cacheRoot = Effect.fn("cacheRoot")(function* () {
|
|
|
296
343
|
});
|
|
297
344
|
|
|
298
345
|
const main = Effect.gen(function* () {
|
|
299
|
-
const
|
|
300
|
-
if (
|
|
346
|
+
const libraries = yield* librariesFrom(process.argv.slice(2));
|
|
347
|
+
if (libraries.length === 0) {
|
|
348
|
+
yield* Console.error(`${NAME}: no ${OPENER} argument names a library, so nothing is pinned`);
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
301
351
|
const toplevel = yield* git(["rev-parse", "--show-toplevel"]).pipe(
|
|
302
352
|
Effect.map((output) => Option.some(output.trim())),
|
|
303
353
|
Effect.catchTag("GitFailure", (failure) =>
|
|
@@ -308,12 +358,6 @@ const main = Effect.gen(function* () {
|
|
|
308
358
|
);
|
|
309
359
|
if (Option.isNone(toplevel)) return true;
|
|
310
360
|
const root = toplevel.value;
|
|
311
|
-
const { quality } = yield* readQuality(root);
|
|
312
|
-
const libraries = quality.sources?.libraries ?? [];
|
|
313
|
-
if (libraries.length === 0) {
|
|
314
|
-
yield* Console.error(`${NAME}: ${QUALITY_FILE} declares no libraries, so nothing is pinned`);
|
|
315
|
-
return true;
|
|
316
|
-
}
|
|
317
361
|
const cache = yield* cacheRoot();
|
|
318
362
|
let passed = true;
|
|
319
363
|
for (const library of libraries) {
|