@avi2dg/checks 0.10.0 → 0.12.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/README.md +426 -89
- package/dist/feature-rules.js +242 -0
- package/package.json +25 -18
- package/presets/effect.language-service.json +8 -0
- package/presets/effect.oxlint.json +11 -0
- package/quality.schema.json +339 -0
- package/scripts/ci-wiring.ts +16 -43
- package/scripts/commit-identity.ts +4 -33
- package/scripts/feature-owners.ts +142 -0
- package/scripts/gates.ts +17 -15
- package/scripts/git.ts +70 -14
- package/scripts/lint.ts +7 -17
- package/scripts/quality-file.ts +298 -0
- package/scripts/quality.ts +180 -0
- package/scripts/size-budget.ts +158 -0
package/scripts/gates.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const EVERY_REPOSITORY = "every repository";
|
|
|
14
14
|
|
|
15
15
|
export type KitGate = Program & {
|
|
16
16
|
readonly reads: "tree" | "range";
|
|
17
|
+
readonly args?: readonly string[];
|
|
17
18
|
readonly appliesTo: typeof EVERY_REPOSITORY | TrackedContent;
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -23,8 +24,12 @@ export const TEST_ENTRY_POINT: Program = { bin: "checks-test", script: "test.ts"
|
|
|
23
24
|
|
|
24
25
|
export const DEFAULT_BRANCH = "main";
|
|
25
26
|
|
|
27
|
+
export const QUALITY_FILE = "quality.json";
|
|
28
|
+
|
|
26
29
|
const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
|
|
27
30
|
|
|
31
|
+
const QUALITY_DECLARATION: TrackedContent = { pathspecs: [QUALITY_FILE], content: `a ${QUALITY_FILE}` };
|
|
32
|
+
|
|
28
33
|
export const KIT_GATES = [
|
|
29
34
|
{ bin: "checks-lint-coverage", script: "lint-coverage.sh", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
30
35
|
{ bin: "checks-test-layout", script: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
@@ -32,6 +37,9 @@ export const KIT_GATES = [
|
|
|
32
37
|
{ bin: "checks-comment-gate", script: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
33
38
|
{ bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
34
39
|
{ bin: "checks-ci-wiring", script: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
|
|
40
|
+
{ bin: "checks-quality", script: "quality.ts", reads: "tree", args: ["--check"], appliesTo: QUALITY_DECLARATION },
|
|
41
|
+
{ bin: "checks-size-budget", script: "size-budget.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
42
|
+
{ bin: "checks-feature-owners", script: "feature-owners.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
35
43
|
] as const satisfies readonly KitGate[];
|
|
36
44
|
|
|
37
45
|
const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
|
|
@@ -39,22 +47,16 @@ const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSI
|
|
|
39
47
|
// A selection without ci-wiring would go unchecked under checks-lint, so a gate every repository runs
|
|
40
48
|
// is refused where checks-lint decodes the selection, not by ci-wiring.
|
|
41
49
|
export const LintGates = Schema.Array(Schema.Literals(KIT_GATES.map((gate) => gate.bin))).check(
|
|
42
|
-
Schema.makeFilter(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
export const LintWiring = Schema.Struct({
|
|
51
|
-
ciWiring: Schema.optionalKey(
|
|
52
|
-
Schema.Struct({
|
|
53
|
-
defaultBranch: Schema.optionalKey(Schema.NonEmptyString),
|
|
54
|
-
lintGates: Schema.optionalKey(LintGates),
|
|
55
|
-
}),
|
|
50
|
+
Schema.makeFilter(
|
|
51
|
+
(selected) => {
|
|
52
|
+
const missing = UNCONDITIONAL.filter((bin) => !selected.includes(bin));
|
|
53
|
+
if (missing.length === 0) return true;
|
|
54
|
+
const verb = missing.length === 1 ? "applies" : "apply";
|
|
55
|
+
return `checks-lint must run ${missing.join(", ")}, which ${verb} to ${EVERY_REPOSITORY}`;
|
|
56
|
+
},
|
|
57
|
+
{ toJsonSchema: () => ({ allOf: UNCONDITIONAL.map((bin) => ({ contains: { const: bin } })) }) },
|
|
56
58
|
),
|
|
57
|
-
|
|
59
|
+
);
|
|
58
60
|
|
|
59
61
|
export function selectedGates(lintGates: typeof LintGates.Type | undefined): readonly KitGate[] {
|
|
60
62
|
return lintGates === undefined ? KIT_GATES : KIT_GATES.filter((gate) => lintGates.includes(gate.bin));
|
package/scripts/git.ts
CHANGED
|
@@ -8,24 +8,76 @@ export class GitFailure extends Schema.TaggedError<GitFailure>()("GitFailure", {
|
|
|
8
8
|
const text = <E>(bytes: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
|
|
9
9
|
bytes.pipe(Stream.decodeText(), Stream.mkString);
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
export type Feed = {
|
|
12
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
13
|
+
readonly input?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Both pipes drain while the program runs: one left unread fills its buffer and stalls it.
|
|
17
|
+
export const collect = Effect.fn("collect")(
|
|
18
|
+
function* (program: string, args: readonly string[], cwd?: string, { env, input }: Feed = {}) {
|
|
14
19
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const handle = yield* spawner
|
|
18
|
-
.spawn(ChildProcess.make("git", args, { cwd }))
|
|
19
|
-
.pipe(Effect.mapError((cause) => failed(cause.message)));
|
|
20
|
+
const stdin = input === undefined ? undefined : Stream.make(new TextEncoder().encode(input));
|
|
21
|
+
const handle = yield* spawner.spawn(ChildProcess.make(program, args, { cwd, env, extendEnv: true, stdin }));
|
|
20
22
|
const [stdout, stderr, exitCode] = yield* Effect.all([text(handle.stdout), text(handle.stderr), handle.exitCode], {
|
|
21
23
|
concurrency: "unbounded",
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
return stdout;
|
|
24
|
+
});
|
|
25
|
+
return { stdout, stderr, exitCode };
|
|
25
26
|
},
|
|
26
27
|
Effect.scoped,
|
|
27
28
|
);
|
|
28
29
|
|
|
30
|
+
export const git = Effect.fn("git")(function* (args: readonly string[], cwd?: string, feed?: Feed) {
|
|
31
|
+
const failed = (reason: string): GitFailure => new GitFailure({ message: `git ${args.join(" ")}: ${reason.trim()}` });
|
|
32
|
+
const { stdout, stderr, exitCode } = yield* collect("git", args, cwd, feed).pipe(
|
|
33
|
+
Effect.mapError((cause) => failed(cause.message)),
|
|
34
|
+
);
|
|
35
|
+
if (exitCode !== ChildProcessSpawner.ExitCode(0)) return yield* failed(stderr);
|
|
36
|
+
return stdout;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export type Change =
|
|
40
|
+
| { readonly kind: "written" | "deleted"; readonly path: string }
|
|
41
|
+
| { readonly kind: "renamed"; readonly from: string; readonly path: string; readonly edited: boolean };
|
|
42
|
+
|
|
43
|
+
const UNCHANGED_RENAME = "R100";
|
|
44
|
+
|
|
45
|
+
function parseNameStatus(output: string): readonly Change[] {
|
|
46
|
+
const fields = output.split("\0");
|
|
47
|
+
const changes: Change[] = [];
|
|
48
|
+
let index = 0;
|
|
49
|
+
while (index < fields.length - 1) {
|
|
50
|
+
const status = fields[index] ?? "";
|
|
51
|
+
if (status.startsWith("R")) {
|
|
52
|
+
changes.push({ kind: "renamed", from: fields[index + 1] ?? "", path: fields[index + 2] ?? "", edited: status !== UNCHANGED_RENAME });
|
|
53
|
+
index += 3;
|
|
54
|
+
} else if (status.startsWith("C")) {
|
|
55
|
+
changes.push({ kind: "written", path: fields[index + 2] ?? "" });
|
|
56
|
+
index += 3;
|
|
57
|
+
} else {
|
|
58
|
+
changes.push({ kind: status === "D" ? "deleted" : "written", path: fields[index + 1] ?? "" });
|
|
59
|
+
index += 2;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return changes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const changedPaths = Effect.fn("changedPaths")(function* (
|
|
66
|
+
base: string,
|
|
67
|
+
head: string,
|
|
68
|
+
pathspecs: readonly string[],
|
|
69
|
+
cwd?: string,
|
|
70
|
+
) {
|
|
71
|
+
return parseNameStatus(yield* git(["diff", "--name-status", "-z", "-M", base, head, "--", ...pathspecs], cwd));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const emptyTree = (cwd?: string) => git(["hash-object", "-t", "tree", "/dev/null"], cwd).pipe(Effect.map((sha) => sha.trim()));
|
|
75
|
+
|
|
76
|
+
export const pathsAt = Effect.fn("pathsAt")(function* (rev: string, pathspecs: readonly string[], cwd?: string) {
|
|
77
|
+
const listed = yield* git(["diff", "--name-only", "-z", "--no-renames", yield* emptyTree(cwd), rev, "--", ...pathspecs], cwd);
|
|
78
|
+
return listed.split("\0").filter((path) => path !== "");
|
|
79
|
+
});
|
|
80
|
+
|
|
29
81
|
function namesAParent(commitObject: string): boolean {
|
|
30
82
|
const [headers = ""] = commitObject.split("\n\n", 1);
|
|
31
83
|
return headers.split("\n").some((header) => header.startsWith("parent "));
|
|
@@ -34,8 +86,12 @@ function namesAParent(commitObject: string): boolean {
|
|
|
34
86
|
// A shallow clone's boundary commit reads as parentless to rev-parse and log, and judged against
|
|
35
87
|
// the empty tree it would carry the whole repository; only the commit object still names its parents.
|
|
36
88
|
export const parentOrEmptyTree = Effect.fn("parentOrEmptyTree")(function* (rev: string, cwd?: string) {
|
|
37
|
-
if (!namesAParent(yield* git(["cat-file", "commit", rev], cwd)))
|
|
38
|
-
return (yield* git(["hash-object", "-t", "tree", "/dev/null"], cwd)).trim();
|
|
39
|
-
}
|
|
89
|
+
if (!namesAParent(yield* git(["cat-file", "commit", rev], cwd))) return yield* emptyTree(cwd);
|
|
40
90
|
return (yield* git(["rev-parse", "--verify", `${rev}^`], cwd)).trim();
|
|
41
91
|
});
|
|
92
|
+
|
|
93
|
+
// From the base branch's tip, a range would charge the head with what the base branch changed after it branched off.
|
|
94
|
+
export const rangeEnds = Effect.fn("rangeEnds")(function* (first: string, second: string | undefined, cwd?: string) {
|
|
95
|
+
if (second === undefined) return { base: yield* parentOrEmptyTree(first, cwd), head: first };
|
|
96
|
+
return { base: (yield* git(["merge-base", first, second], cwd)).trim(), head: second };
|
|
97
|
+
});
|
package/scripts/lint.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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,
|
|
4
|
+
import { DEFAULT_BRANCH, selectedGates, type KitGate } from "./gates.ts";
|
|
5
5
|
import { git } from "./git.ts";
|
|
6
6
|
import { runMain, Usage } from "./main.ts";
|
|
7
|
+
import { readQuality } from "./quality-file.ts";
|
|
7
8
|
|
|
8
9
|
type Range = {
|
|
9
10
|
readonly refs: readonly [tip: string] | readonly [base: string, head: string];
|
|
@@ -20,10 +21,6 @@ class GatesUndecided extends Schema.TaggedError<GatesUndecided>()("GatesUndecide
|
|
|
20
21
|
message: Schema.String,
|
|
21
22
|
}) {}
|
|
22
23
|
|
|
23
|
-
class WiringUnreadable extends Schema.TaggedError<WiringUnreadable>()("WiringUnreadable", {
|
|
24
|
-
message: Schema.String,
|
|
25
|
-
}) {}
|
|
26
|
-
|
|
27
24
|
const NAME = "checks-lint";
|
|
28
25
|
const USAGE = "usage: lint.ts [<base-ref> <head-ref>]";
|
|
29
26
|
const PULL_REQUEST_EVENT = "pull_request";
|
|
@@ -56,16 +53,9 @@ const pullRequestEnds = Effect.fn("pullRequestEnds")(function* (eventPath: strin
|
|
|
56
53
|
};
|
|
57
54
|
});
|
|
58
55
|
|
|
59
|
-
const decodeWiring = Schema.decodeUnknownEffect(Schema.fromJsonString(LintWiring));
|
|
60
|
-
|
|
61
56
|
const readWiring = Effect.gen(function* () {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
const { ciWiring } = yield* (yield* FileSystem.FileSystem).readFileString(manifest).pipe(
|
|
65
|
-
Effect.flatMap(decodeWiring),
|
|
66
|
-
Effect.mapError((cause) => new WiringUnreadable({ message: `cannot read ciWiring from ${manifest}: ${cause.message}` })),
|
|
67
|
-
);
|
|
68
|
-
return { defaultBranch: ciWiring?.defaultBranch ?? DEFAULT_BRANCH, lintGates: ciWiring?.lintGates };
|
|
57
|
+
const { source, quality } = yield* readQuality((yield* git(["rev-parse", "--show-toplevel"])).trim());
|
|
58
|
+
return { source, defaultBranch: quality.defaultBranch ?? DEFAULT_BRANCH, lintGates: quality.gates?.lint };
|
|
69
59
|
});
|
|
70
60
|
|
|
71
61
|
const originEnds = (defaultBranch: string) =>
|
|
@@ -142,7 +132,7 @@ const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
|
|
|
142
132
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
143
133
|
const script = (yield* Path.Path).join(import.meta.dir, gate.script);
|
|
144
134
|
const program = gate.script.endsWith(".sh") ? "sh" : process.execPath;
|
|
145
|
-
const args = gate.reads === "range" ? [script, ...range.refs] : [script];
|
|
135
|
+
const args = gate.reads === "range" ? [script, ...range.refs] : [script, ...(gate.args ?? [])];
|
|
146
136
|
const exitCode = yield* spawner
|
|
147
137
|
.exitCode(ChildProcess.make(program, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit" }))
|
|
148
138
|
.pipe(
|
|
@@ -154,12 +144,12 @@ const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
|
|
|
154
144
|
});
|
|
155
145
|
|
|
156
146
|
const lint = Effect.gen(function* () {
|
|
157
|
-
const { defaultBranch, lintGates } = yield* readWiring;
|
|
147
|
+
const { source, defaultBranch, lintGates } = yield* readWiring;
|
|
158
148
|
const range = yield* resolveRange(process.argv.slice(2), defaultBranch);
|
|
159
149
|
yield* Console.log(`${NAME}: ${describe(range)} from ${range.source}`);
|
|
160
150
|
const gates = selectedGates(lintGates);
|
|
161
151
|
if (lintGates !== undefined) {
|
|
162
|
-
yield* Console.log(`${NAME}:
|
|
152
|
+
yield* Console.log(`${NAME}: ${source} selects ${gates.map((gate) => gate.bin).join(", ")}`);
|
|
163
153
|
}
|
|
164
154
|
|
|
165
155
|
const verdicts = yield* Effect.forEach(gates, (gate) => runGate(gate, range));
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { Console, Effect, FileSystem, JsonSchema, Path, Schema } from "effect";
|
|
2
|
+
import { LintGates, QUALITY_FILE } from "./gates.ts";
|
|
3
|
+
|
|
4
|
+
const SEGMENT = String.raw`(?!\.\.?(?:/|$))(?:\*\*|(?:[\w.@+-]|\*(?!\*))+)`;
|
|
5
|
+
|
|
6
|
+
const FILE = String.raw`(?:[\w.@+-]|\*(?!\*))*\.\w+`;
|
|
7
|
+
|
|
8
|
+
// oxlint matches a glob without a slash against a file's name at any depth, where tsc, the
|
|
9
|
+
// language service and git match it at the root alone, so a glob names a directory first.
|
|
10
|
+
// The language service drops an include ending in ** and oxlint reads a bare name as a file,
|
|
11
|
+
// so a glob ends in a file name with an extension.
|
|
12
|
+
const PathGlob = Schema.String.check(
|
|
13
|
+
Schema.isPattern(new RegExp(`^${SEGMENT}(?:/${SEGMENT})*/${FILE}$`), {
|
|
14
|
+
expected:
|
|
15
|
+
"a glob from the repository root such as src/**/*.ts: a directory first, * within a segment, ** as a whole one, a file name with an extension last",
|
|
16
|
+
}),
|
|
17
|
+
).annotate({
|
|
18
|
+
identifier: "PathGlob",
|
|
19
|
+
description:
|
|
20
|
+
"A glob from the repository root that oxlint, the Effect language service and git read alike: a directory first, * within a segment, ** as a whole one, a file name with an extension last, and no braces, ?, [ or leading ./",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
|
|
24
|
+
|
|
25
|
+
const DirectoryPath = Schema.String.check(
|
|
26
|
+
Schema.isPattern(new RegExp(`^${LITERAL_SEGMENT}(?:/${LITERAL_SEGMENT})*$`), {
|
|
27
|
+
expected: "a directory from the repository root such as src/billing, with no glob and no trailing slash",
|
|
28
|
+
}),
|
|
29
|
+
).annotate({ identifier: "DirectoryPath" });
|
|
30
|
+
|
|
31
|
+
const FilePath = Schema.String.check(
|
|
32
|
+
Schema.isPattern(new RegExp(`^(?:${LITERAL_SEGMENT}/)*[\\w.@+-]*\\.\\w+$`), {
|
|
33
|
+
expected: "a file from the repository root such as src/billing/index.ts, with no glob",
|
|
34
|
+
}),
|
|
35
|
+
).annotate({ identifier: "FilePath" });
|
|
36
|
+
|
|
37
|
+
export const PROOF_DIRECTORY = "tests/e2e/";
|
|
38
|
+
|
|
39
|
+
const ProofPath = Schema.String.check(
|
|
40
|
+
Schema.isPattern(new RegExp(`^${PROOF_DIRECTORY}(?:${LITERAL_SEGMENT}/)*[\\w.@+-]+\\.test\\.tsx?$`), {
|
|
41
|
+
expected: `a test file under ${PROOF_DIRECTORY} such as ${PROOF_DIRECTORY}billing.test.ts`,
|
|
42
|
+
}),
|
|
43
|
+
).annotate({ identifier: "ProofPath" });
|
|
44
|
+
|
|
45
|
+
const Command = Schema.NonEmptyString.annotate({ identifier: "Command" });
|
|
46
|
+
|
|
47
|
+
const RuleName = Schema.String.check(
|
|
48
|
+
Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a Rule name in kebab case" }),
|
|
49
|
+
).annotate({ identifier: "RuleName" });
|
|
50
|
+
|
|
51
|
+
export const Identity = Schema.Struct({ name: Schema.NonEmptyString, email: Schema.NonEmptyString }).annotate({
|
|
52
|
+
identifier: "Identity",
|
|
53
|
+
});
|
|
54
|
+
export type Identity = typeof Identity.Type;
|
|
55
|
+
|
|
56
|
+
const CommitIdentity = Schema.Struct({
|
|
57
|
+
authors: Schema.NonEmptyArray(Identity).annotate({
|
|
58
|
+
description: "The identities allowed to author and commit, in place of the kit's default owner",
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const Gates = Schema.Struct({
|
|
63
|
+
ci: Schema.optionalKey(
|
|
64
|
+
Schema.NonEmptyArray(Command).annotate({
|
|
65
|
+
description: "The commands CI runs on every pull request to the default branch, each one plain command",
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
scheduled: Schema.optionalKey(
|
|
69
|
+
Schema.Array(Command).annotate({ description: "The commands a cron-scheduled workflow runs" }),
|
|
70
|
+
),
|
|
71
|
+
lint: Schema.optionalKey(LintGates),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const EffectSources = Schema.Struct({
|
|
75
|
+
paths: Schema.NonEmptyArray(PathGlob).annotate({
|
|
76
|
+
description: "Where source is written in Effect, held to the Effect rules of oxlint and the language service",
|
|
77
|
+
}),
|
|
78
|
+
exempt: Schema.optionalKey(
|
|
79
|
+
Schema.Array(PathGlob).annotate({ description: "Files under paths the Effect rules pass over" }),
|
|
80
|
+
),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const Sources = Schema.Struct({
|
|
84
|
+
production: Schema.optionalKey(
|
|
85
|
+
Schema.Array(PathGlob).annotate({ description: "The source the repository ships, as against tests and tooling" }),
|
|
86
|
+
),
|
|
87
|
+
effect: Schema.optionalKey(EffectSources),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const LineBudget = Schema.Int.check(Schema.isGreaterThan(0));
|
|
91
|
+
|
|
92
|
+
const Size = Schema.Struct({
|
|
93
|
+
fileLines: LineBudget.annotate({ description: "The most lines a file may hold, blank and comment lines counted" }),
|
|
94
|
+
functionLines: LineBudget.annotate({
|
|
95
|
+
description: "The most lines a function may span, blank and comment lines counted",
|
|
96
|
+
}),
|
|
97
|
+
applies: Schema.Literals(["changed", "all"]).annotate({
|
|
98
|
+
description:
|
|
99
|
+
"Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory",
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const Feature = Schema.Struct({
|
|
104
|
+
name: Schema.String.check(
|
|
105
|
+
Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a feature name in kebab case" }),
|
|
106
|
+
).annotate({ description: "The owner the dependency rule and the change signal name" }),
|
|
107
|
+
root: DirectoryPath.annotate({ description: "The directory the feature owns" }),
|
|
108
|
+
entries: Schema.NonEmptyArray(FilePath).annotate({
|
|
109
|
+
description: "The files under root that code outside it imports the feature through",
|
|
110
|
+
}),
|
|
111
|
+
allowFrom: Schema.optionalKey(
|
|
112
|
+
Schema.Array(PathGlob).annotate({
|
|
113
|
+
description: "Files outside root that may import past its entries, such as a CLI or a harness; tests/ always may",
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
116
|
+
proof: ProofPath.annotate({ description: "The end-to-end test that imports one of entries" }),
|
|
117
|
+
}).check(
|
|
118
|
+
Schema.makeFilter(({ root, entries }) => {
|
|
119
|
+
const outside = entries.filter((entry) => !entry.startsWith(`${root}/`));
|
|
120
|
+
return outside.length === 0 || `lists ${outside.join(", ")} among its entries, outside its root ${root}`;
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
export type Feature = typeof Feature.Type;
|
|
124
|
+
|
|
125
|
+
function nests(outer: string, inner: string): boolean {
|
|
126
|
+
return outer === inner || inner.startsWith(`${outer}/`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const Features = Schema.Array(Feature).check(
|
|
130
|
+
Schema.makeFilter((features) => {
|
|
131
|
+
const names = features.map((feature) => feature.name);
|
|
132
|
+
const repeated = names.filter((name, index) => names.indexOf(name) !== index);
|
|
133
|
+
if (repeated.length > 0) return `names ${[...new Set(repeated)].join(", ")} more than once`;
|
|
134
|
+
for (const outer of features) {
|
|
135
|
+
const inner = features.find((other) => other !== outer && nests(outer.root, other.root));
|
|
136
|
+
if (inner !== undefined) return `gives ${inner.root} to both ${outer.name} and ${inner.name}`;
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const AgentRules = Schema.Struct({
|
|
143
|
+
on: Schema.optionalKey(Schema.Array(RuleName).annotate({ description: "Catalogued Rules switched on here" })),
|
|
144
|
+
off: Schema.optionalKey(Schema.Array(RuleName).annotate({ description: "Catalogued Rules switched off here" })),
|
|
145
|
+
}).check(
|
|
146
|
+
Schema.makeFilter(({ on = [], off = [] }) => {
|
|
147
|
+
const both = on.filter((rule) => off.includes(rule));
|
|
148
|
+
return both.length === 0 || `switches ${both.join(", ")} both on and off`;
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
export const Quality = Schema.Struct({
|
|
153
|
+
$schema: Schema.optionalKey(Schema.String),
|
|
154
|
+
defaultBranch: Schema.optionalKey(
|
|
155
|
+
Schema.NonEmptyString.annotate({ description: "The branch pull requests merge into; main when absent" }),
|
|
156
|
+
),
|
|
157
|
+
gates: Schema.optionalKey(Gates),
|
|
158
|
+
commitIdentity: Schema.optionalKey(CommitIdentity),
|
|
159
|
+
sources: Schema.optionalKey(Sources),
|
|
160
|
+
size: Schema.optionalKey(
|
|
161
|
+
Size.annotate({ description: "The line budget oxlint holds production files to, read by checks-size-budget" }),
|
|
162
|
+
),
|
|
163
|
+
features: Schema.optionalKey(
|
|
164
|
+
Features.annotate({
|
|
165
|
+
description: "The feature owners dependency-cruiser holds to their entries and checks-feature-owners maps a change to",
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
168
|
+
changeSignal: Schema.optionalKey(
|
|
169
|
+
Schema.Literal("advisory").annotate({
|
|
170
|
+
description: "Report which feature owners a change touches, without failing on it",
|
|
171
|
+
}),
|
|
172
|
+
),
|
|
173
|
+
agentRules: Schema.optionalKey(AgentRules),
|
|
174
|
+
})
|
|
175
|
+
.annotate({
|
|
176
|
+
title: QUALITY_FILE,
|
|
177
|
+
description: "What a repository has opted into from @avi2dg/checks, read by its bins and agent Rule selection",
|
|
178
|
+
})
|
|
179
|
+
.check(
|
|
180
|
+
Schema.makeFilter(
|
|
181
|
+
({ size, sources }) =>
|
|
182
|
+
size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds nothing without sources.production",
|
|
183
|
+
{
|
|
184
|
+
toJsonSchema: () => ({
|
|
185
|
+
if: { required: ["size"] },
|
|
186
|
+
then: { required: ["sources"], properties: { sources: { required: ["production"], properties: { production: { minItems: 1 } } } } },
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
),
|
|
190
|
+
Schema.makeFilter(
|
|
191
|
+
({ changeSignal, features = [] }) =>
|
|
192
|
+
changeSignal === undefined || features.length > 0 || "declares changeSignal, which maps a change to no owner without features",
|
|
193
|
+
{
|
|
194
|
+
toJsonSchema: () => ({
|
|
195
|
+
if: { required: ["changeSignal"] },
|
|
196
|
+
then: { required: ["features"], properties: { features: { minItems: 1 } } },
|
|
197
|
+
}),
|
|
198
|
+
},
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
export type Quality = typeof Quality.Type;
|
|
202
|
+
|
|
203
|
+
const LegacyManifest = Schema.Struct({
|
|
204
|
+
ciWiring: Schema.optionalKey(
|
|
205
|
+
Schema.Struct({
|
|
206
|
+
gates: Schema.optionalKey(Schema.NonEmptyArray(Command)),
|
|
207
|
+
scheduled: Schema.optionalKey(Schema.Array(Command)),
|
|
208
|
+
lintGates: Schema.optionalKey(LintGates),
|
|
209
|
+
defaultBranch: Schema.optionalKey(Schema.NonEmptyString),
|
|
210
|
+
}),
|
|
211
|
+
),
|
|
212
|
+
commitIdentity: Schema.optionalKey(CommitIdentity),
|
|
213
|
+
});
|
|
214
|
+
type LegacyManifest = typeof LegacyManifest.Type;
|
|
215
|
+
|
|
216
|
+
const LEGACY_KEYS = ["ciWiring", "commitIdentity"] as const;
|
|
217
|
+
|
|
218
|
+
export type LegacyDeclaration = {
|
|
219
|
+
readonly keys: readonly (typeof LEGACY_KEYS)[number][];
|
|
220
|
+
readonly quality: Quality;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export class QualityUnreadable extends Schema.TaggedError<QualityUnreadable>()("QualityUnreadable", {
|
|
224
|
+
message: Schema.String,
|
|
225
|
+
}) {}
|
|
226
|
+
|
|
227
|
+
const MANIFEST = "package.json";
|
|
228
|
+
|
|
229
|
+
export type Declared = {
|
|
230
|
+
readonly source: typeof QUALITY_FILE | typeof MANIFEST;
|
|
231
|
+
readonly quality: Quality;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const decodeQualityJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Quality), { onExcessProperty: "error" });
|
|
235
|
+
const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(LegacyManifest));
|
|
236
|
+
|
|
237
|
+
export const decodeQuality = (text: string, source: string): Effect.Effect<Quality, QualityUnreadable> =>
|
|
238
|
+
decodeQualityJson(text).pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })));
|
|
239
|
+
|
|
240
|
+
function fromLegacy({ ciWiring, commitIdentity }: LegacyManifest): Quality {
|
|
241
|
+
const gates = {
|
|
242
|
+
...(ciWiring?.gates === undefined ? {} : { ci: ciWiring.gates }),
|
|
243
|
+
...(ciWiring?.scheduled === undefined ? {} : { scheduled: ciWiring.scheduled }),
|
|
244
|
+
...(ciWiring?.lintGates === undefined ? {} : { lint: ciWiring.lintGates }),
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
...(ciWiring?.defaultBranch === undefined ? {} : { defaultBranch: ciWiring.defaultBranch }),
|
|
248
|
+
...(Object.keys(gates).length === 0 ? {} : { gates }),
|
|
249
|
+
...(commitIdentity === undefined ? {} : { commitIdentity }),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const decodeManifest = (text: string, source: string): Effect.Effect<LegacyDeclaration, QualityUnreadable> =>
|
|
254
|
+
decodeManifestJson(text).pipe(
|
|
255
|
+
Effect.map((manifest) => ({
|
|
256
|
+
keys: LEGACY_KEYS.filter((key) => manifest[key] !== undefined),
|
|
257
|
+
quality: fromLegacy(manifest),
|
|
258
|
+
})),
|
|
259
|
+
Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const UNDECLARED: LegacyDeclaration = { keys: [], quality: {} };
|
|
263
|
+
|
|
264
|
+
export const readQuality = Effect.fn("readQuality")(function* (root: string) {
|
|
265
|
+
const fs = yield* FileSystem.FileSystem;
|
|
266
|
+
const path = yield* Path.Path;
|
|
267
|
+
const read = (file: string) =>
|
|
268
|
+
fs
|
|
269
|
+
.readFileString(path.join(root, file))
|
|
270
|
+
.pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `cannot read ${file}: ${cause.message}` })));
|
|
271
|
+
|
|
272
|
+
const legacy = (yield* fs.exists(path.join(root, MANIFEST)))
|
|
273
|
+
? yield* decodeManifest(yield* read(MANIFEST), MANIFEST)
|
|
274
|
+
: UNDECLARED;
|
|
275
|
+
const keys = legacy.keys.join(" and ");
|
|
276
|
+
if (yield* fs.exists(path.join(root, QUALITY_FILE))) {
|
|
277
|
+
if (legacy.keys.length > 0) {
|
|
278
|
+
return yield* new QualityUnreadable({
|
|
279
|
+
message: `${MANIFEST} still sets ${keys}, which ${QUALITY_FILE} replaces; move what it holds there`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return { source: QUALITY_FILE, quality: yield* decodeQuality(yield* read(QUALITY_FILE), QUALITY_FILE) } satisfies Declared;
|
|
283
|
+
}
|
|
284
|
+
if (legacy.keys.length > 0) {
|
|
285
|
+
const them = legacy.keys.length === 1 ? "it" : "them";
|
|
286
|
+
yield* Console.error(`${MANIFEST} sets ${keys}, which a later minor release stops reading; move ${them} into ${QUALITY_FILE}`);
|
|
287
|
+
}
|
|
288
|
+
return { source: MANIFEST, quality: legacy.quality } satisfies Declared;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
export function renderJson(value: unknown): string {
|
|
292
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function qualityJsonSchema(): JsonSchema.JsonSchema {
|
|
296
|
+
const { schema, definitions } = Schema.toJsonSchemaDocument(Quality, { onExcessProperty: "error" });
|
|
297
|
+
return { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, ...schema, $defs: definitions };
|
|
298
|
+
}
|