@avi2dg/checks 0.9.0 → 0.11.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.
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bun
2
+ import { Config, Console, Effect, FileSystem, Option, Path, Random, Schema } from "effect";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
+ import { runMain, Usage } from "./main.ts";
5
+ import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
6
+
7
+ export type Run = {
8
+ readonly seed: number;
9
+ readonly passed: boolean;
10
+ readonly tests: number;
11
+ readonly failed: readonly TestResult[];
12
+ };
13
+
14
+ export type Failure = {
15
+ readonly file: string;
16
+ readonly test: string;
17
+ readonly line: number;
18
+ readonly seeds: readonly number[];
19
+ };
20
+
21
+ export type RunRecord = Omit<Run, "failed"> & { readonly failed: readonly string[] };
22
+
23
+ export type FlakeRecord = {
24
+ readonly runs: readonly RunRecord[];
25
+ readonly failures: readonly Failure[];
26
+ readonly outsideTests: readonly number[];
27
+ };
28
+
29
+ type Plan = { readonly seeds: readonly number[] } | { readonly runs: number };
30
+
31
+ const NAME = "checks-flake";
32
+ const USAGE = `usage: ${NAME} [--runs <count> | --seed <seed>...] [--report <file>]`;
33
+ const DEFAULT_RUNS = 10;
34
+ const MAX_SEED = 2 ** 32 - 1;
35
+
36
+ const wholeBetween = (minimum: number, maximum: number) =>
37
+ Schema.decodeUnknownEffect(Schema.NumberFromString.check(Schema.isInt(), Schema.isBetween({ minimum, maximum })));
38
+ const decodeRuns = wholeBetween(1, 1000);
39
+ const decodeSeed = wholeBetween(0, MAX_SEED);
40
+
41
+ const parseArgs = Effect.fnUntraced(function* (args: readonly string[]) {
42
+ const usage = (reason?: string) => new Usage({ message: reason === undefined ? USAGE : `${reason}; ${USAGE}` });
43
+ let runs: string | undefined;
44
+ let report: string | undefined;
45
+ const seeds: string[] = [];
46
+ for (let index = 0; index < args.length; index += 2) {
47
+ const [flag, value] = [args[index], args[index + 1]];
48
+ if (value === undefined) return yield* usage();
49
+ if (flag === "--runs" && runs === undefined) runs = value;
50
+ else if (flag === "--seed") seeds.push(value);
51
+ else if (flag === "--report" && report === undefined) report = value;
52
+ else return yield* usage();
53
+ }
54
+ if (runs !== undefined && seeds.length > 0) return yield* usage();
55
+ const invalid = (flag: string) => (cause: { readonly message: string }) => usage(`${flag}: ${cause.message}`);
56
+ const plan: Plan =
57
+ seeds.length > 0
58
+ ? { seeds: yield* Effect.forEach(seeds, (seed) => decodeSeed(seed).pipe(Effect.mapError(invalid("--seed")))) }
59
+ : { runs: runs === undefined ? DEFAULT_RUNS : yield* decodeRuns(runs).pipe(Effect.mapError(invalid("--runs"))) };
60
+ return { plan, report };
61
+ });
62
+
63
+ const seedsOf = (plan: Plan) =>
64
+ "seeds" in plan ? Effect.succeed(plan.seeds) : Effect.replicateEffect(Random.nextIntBetween(0, MAX_SEED), plan.runs);
65
+
66
+ const runOnce = Effect.fn("runOnce")(function* (seed: number, outfile: string) {
67
+ const fs = yield* FileSystem.FileSystem;
68
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
69
+ const args = ["test", "--randomize", `--seed=${seed}`, ...reporterArgs(outfile)];
70
+ const exitCode = yield* spawner.exitCode(
71
+ ChildProcess.make(process.execPath, args, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }),
72
+ );
73
+ const passed = exitCode === ChildProcessSpawner.ExitCode(0);
74
+ if (!(yield* fs.exists(outfile))) {
75
+ if (passed) return yield* new ReportError({ message: `bun test passed with seed ${seed} but wrote no report` });
76
+ return { seed, passed, tests: 0, failed: [] } satisfies Run;
77
+ }
78
+ const results = yield* parseReport(yield* fs.readFileString(outfile));
79
+ return { seed, passed, tests: results.length, failed: results.filter((result) => result.outcome === "failed") } satisfies Run;
80
+ });
81
+
82
+ function outcomeOf(run: Run): string {
83
+ if (run.passed) return "passed";
84
+ return run.failed.length === 0 ? "failed outside any test" : `failed, ${run.failed.length} test(s) failing`;
85
+ }
86
+
87
+ function nameOf(result: TestResult): string {
88
+ return `${result.file}${NAME_SEPARATOR}${result.name}`;
89
+ }
90
+
91
+ export function recordOf(runs: readonly Run[]): FlakeRecord {
92
+ const failures = new Map<string, { file: string; test: string; line: number; seeds: number[] }>();
93
+ for (const run of runs) {
94
+ const failing = new Map(run.failed.map((result) => [nameOf(result), result]));
95
+ for (const [name, result] of failing) {
96
+ const failure = failures.get(name) ?? { file: result.file, test: result.name, line: result.line, seeds: [] };
97
+ failure.seeds.push(run.seed);
98
+ failures.set(name, failure);
99
+ }
100
+ }
101
+ return {
102
+ runs: runs.map(({ seed, passed, tests, failed }) => ({ seed, passed, tests, failed: failed.map(nameOf) })),
103
+ failures: [...failures.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line),
104
+ outsideTests: runs.filter((run) => !run.passed && run.failed.length === 0).map((run) => run.seed),
105
+ };
106
+ }
107
+
108
+ function cell(text: string): string {
109
+ return text.replaceAll("|", "\\|");
110
+ }
111
+
112
+ export function summary(record: FlakeRecord): string {
113
+ const total = record.runs.length;
114
+ const failedRuns = record.runs.filter((run) => !run.passed).length;
115
+ if (failedRuns === 0) return `${NAME}: ${total} run(s) passed, with seeds ${record.runs.map((run) => run.seed).join(", ")}`;
116
+ const lines = [`${NAME}: ${failedRuns} of ${total} run(s) failed, ${record.failures.length} test(s) failing in them`];
117
+ if (record.failures.length > 0) {
118
+ lines.push("", "| Test | Failed | Seeds |", "| --- | --- | --- |");
119
+ for (const failure of record.failures) {
120
+ const place = cell(`${failure.file}:${failure.line} ${failure.test}`);
121
+ lines.push(`| ${place} | ${failure.seeds.length} of ${total} runs | ${failure.seeds.join(", ")} |`);
122
+ }
123
+ }
124
+ if (record.outsideTests.length > 0) {
125
+ lines.push("", `${record.outsideTests.length} run(s) failed outside any test, with seeds ${record.outsideTests.join(", ")}`);
126
+ }
127
+ lines.push("", "Reproduce a failing run with bun test --randomize --seed=<seed>.");
128
+ return lines.join("\n");
129
+ }
130
+
131
+ const flake = Effect.gen(function* () {
132
+ const fs = yield* FileSystem.FileSystem;
133
+ const path = yield* Path.Path;
134
+ const { plan, report } = yield* parseArgs(process.argv.slice(2));
135
+ const seeds = yield* seedsOf(plan);
136
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-flake-" });
137
+
138
+ const runs = yield* Effect.forEach(seeds, (seed, index) =>
139
+ runOnce(seed, path.join(dir, `${index}.xml`)).pipe(
140
+ Effect.tap((run) => Console.log(`${NAME}: run ${index + 1} of ${seeds.length}, seed ${seed}: ${outcomeOf(run)}`)),
141
+ ),
142
+ );
143
+ const record = recordOf(runs);
144
+ const text = summary(record);
145
+ yield* Console.log(text);
146
+
147
+ if (report !== undefined) yield* fs.writeFileString(report, `${JSON.stringify(record, null, 2)}\n`);
148
+ const stepSummary = yield* Config.option(Config.String("GITHUB_STEP_SUMMARY"));
149
+ if (Option.isSome(stepSummary)) yield* fs.writeFileString(stepSummary.value, `${text}\n`, { flag: "a" });
150
+ return runs.every((run) => run.passed);
151
+ }).pipe(Effect.scoped);
152
+
153
+ if (import.meta.main) runMain(NAME, flake);
package/scripts/gates.ts CHANGED
@@ -14,15 +14,22 @@ 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
 
20
21
  export const ENTRY_POINT: Program = { bin: "checks-lint", script: "lint.ts" };
21
22
 
23
+ export const TEST_ENTRY_POINT: Program = { bin: "checks-test", script: "test.ts" };
24
+
22
25
  export const DEFAULT_BRANCH = "main";
23
26
 
27
+ export const QUALITY_FILE = "quality.json";
28
+
24
29
  const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
25
30
 
31
+ const QUALITY_DECLARATION: TrackedContent = { pathspecs: [QUALITY_FILE], content: `a ${QUALITY_FILE}` };
32
+
26
33
  export const KIT_GATES = [
27
34
  { bin: "checks-lint-coverage", script: "lint-coverage.sh", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
28
35
  { bin: "checks-test-layout", script: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
@@ -30,6 +37,7 @@ export const KIT_GATES = [
30
37
  { bin: "checks-comment-gate", script: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
31
38
  { bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
32
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 },
33
41
  ] as const satisfies readonly KitGate[];
34
42
 
35
43
  const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
@@ -37,22 +45,16 @@ const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSI
37
45
  // A selection without ci-wiring would go unchecked under checks-lint, so a gate every repository runs
38
46
  // is refused where checks-lint decodes the selection, not by ci-wiring.
39
47
  export const LintGates = Schema.Array(Schema.Literals(KIT_GATES.map((gate) => gate.bin))).check(
40
- Schema.makeFilter((selected) => {
41
- const missing = UNCONDITIONAL.filter((bin) => !selected.includes(bin));
42
- if (missing.length === 0) return true;
43
- const verb = missing.length === 1 ? "applies" : "apply";
44
- return `checks-lint must run ${missing.join(", ")}, which ${verb} to ${EVERY_REPOSITORY}`;
45
- }),
46
- );
47
-
48
- export const LintWiring = Schema.Struct({
49
- ciWiring: Schema.optionalKey(
50
- Schema.Struct({
51
- defaultBranch: Schema.optionalKey(Schema.NonEmptyString),
52
- lintGates: Schema.optionalKey(LintGates),
53
- }),
48
+ Schema.makeFilter(
49
+ (selected) => {
50
+ const missing = UNCONDITIONAL.filter((bin) => !selected.includes(bin));
51
+ if (missing.length === 0) return true;
52
+ const verb = missing.length === 1 ? "applies" : "apply";
53
+ return `checks-lint must run ${missing.join(", ")}, which ${verb} to ${EVERY_REPOSITORY}`;
54
+ },
55
+ { toJsonSchema: () => ({ allOf: UNCONDITIONAL.map((bin) => ({ contains: { const: bin } })) }) },
54
56
  ),
55
- });
57
+ );
56
58
 
57
59
  export function selectedGates(lintGates: typeof LintGates.Type | undefined): readonly KitGate[] {
58
60
  return lintGates === undefined ? KIT_GATES : KIT_GATES.filter((gate) => lintGates.includes(gate.bin));
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, LintWiring, selectedGates, type KitGate } from "./gates.ts";
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 root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
63
- const manifest = (yield* Path.Path).join(root, "package.json");
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}: ciWiring.lintGates selects ${gates.map((gate) => gate.bin).join(", ")}`);
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,188 @@
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 Command = Schema.NonEmptyString.annotate({ identifier: "Command" });
24
+
25
+ const RuleName = Schema.String.check(
26
+ Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a Rule name in kebab case" }),
27
+ ).annotate({ identifier: "RuleName" });
28
+
29
+ export const Identity = Schema.Struct({ name: Schema.NonEmptyString, email: Schema.NonEmptyString }).annotate({
30
+ identifier: "Identity",
31
+ });
32
+ export type Identity = typeof Identity.Type;
33
+
34
+ const CommitIdentity = Schema.Struct({
35
+ authors: Schema.NonEmptyArray(Identity).annotate({
36
+ description: "The identities allowed to author and commit, in place of the kit's default owner",
37
+ }),
38
+ });
39
+
40
+ const Gates = Schema.Struct({
41
+ ci: Schema.optionalKey(
42
+ Schema.NonEmptyArray(Command).annotate({
43
+ description: "The commands CI runs on every pull request to the default branch, each one plain command",
44
+ }),
45
+ ),
46
+ scheduled: Schema.optionalKey(
47
+ Schema.Array(Command).annotate({ description: "The commands a cron-scheduled workflow runs" }),
48
+ ),
49
+ lint: Schema.optionalKey(LintGates),
50
+ });
51
+
52
+ const EffectSources = Schema.Struct({
53
+ paths: Schema.NonEmptyArray(PathGlob).annotate({
54
+ description: "Where source is written in Effect, held to the Effect rules of oxlint and the language service",
55
+ }),
56
+ exempt: Schema.optionalKey(
57
+ Schema.Array(PathGlob).annotate({ description: "Files under paths the Effect rules pass over" }),
58
+ ),
59
+ });
60
+
61
+ const Sources = Schema.Struct({
62
+ production: Schema.optionalKey(
63
+ Schema.Array(PathGlob).annotate({ description: "The source the repository ships, as against tests and tooling" }),
64
+ ),
65
+ effect: Schema.optionalKey(EffectSources),
66
+ });
67
+
68
+ const AgentRules = Schema.Struct({
69
+ on: Schema.optionalKey(Schema.Array(RuleName).annotate({ description: "Catalogued Rules switched on here" })),
70
+ off: Schema.optionalKey(Schema.Array(RuleName).annotate({ description: "Catalogued Rules switched off here" })),
71
+ }).check(
72
+ Schema.makeFilter(({ on = [], off = [] }) => {
73
+ const both = on.filter((rule) => off.includes(rule));
74
+ return both.length === 0 || `switches ${both.join(", ")} both on and off`;
75
+ }),
76
+ );
77
+
78
+ export const Quality = Schema.Struct({
79
+ $schema: Schema.optionalKey(Schema.String),
80
+ defaultBranch: Schema.optionalKey(
81
+ Schema.NonEmptyString.annotate({ description: "The branch pull requests merge into; main when absent" }),
82
+ ),
83
+ gates: Schema.optionalKey(Gates),
84
+ commitIdentity: Schema.optionalKey(CommitIdentity),
85
+ sources: Schema.optionalKey(Sources),
86
+ agentRules: Schema.optionalKey(AgentRules),
87
+ }).annotate({
88
+ title: QUALITY_FILE,
89
+ description: "What a repository has opted into from @avi2dg/checks, read by its bins and agent Rule selection",
90
+ });
91
+ export type Quality = typeof Quality.Type;
92
+
93
+ const LegacyManifest = Schema.Struct({
94
+ ciWiring: Schema.optionalKey(
95
+ Schema.Struct({
96
+ gates: Schema.optionalKey(Schema.NonEmptyArray(Command)),
97
+ scheduled: Schema.optionalKey(Schema.Array(Command)),
98
+ lintGates: Schema.optionalKey(LintGates),
99
+ defaultBranch: Schema.optionalKey(Schema.NonEmptyString),
100
+ }),
101
+ ),
102
+ commitIdentity: Schema.optionalKey(CommitIdentity),
103
+ });
104
+ type LegacyManifest = typeof LegacyManifest.Type;
105
+
106
+ const LEGACY_KEYS = ["ciWiring", "commitIdentity"] as const;
107
+
108
+ export type LegacyDeclaration = {
109
+ readonly keys: readonly (typeof LEGACY_KEYS)[number][];
110
+ readonly quality: Quality;
111
+ };
112
+
113
+ export class QualityUnreadable extends Schema.TaggedError<QualityUnreadable>()("QualityUnreadable", {
114
+ message: Schema.String,
115
+ }) {}
116
+
117
+ const MANIFEST = "package.json";
118
+
119
+ export type Declared = {
120
+ readonly source: typeof QUALITY_FILE | typeof MANIFEST;
121
+ readonly quality: Quality;
122
+ };
123
+
124
+ const decodeQualityJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Quality), { onExcessProperty: "error" });
125
+ const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(LegacyManifest));
126
+
127
+ export const decodeQuality = (text: string, source: string): Effect.Effect<Quality, QualityUnreadable> =>
128
+ decodeQualityJson(text).pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })));
129
+
130
+ function fromLegacy({ ciWiring, commitIdentity }: LegacyManifest): Quality {
131
+ const gates = {
132
+ ...(ciWiring?.gates === undefined ? {} : { ci: ciWiring.gates }),
133
+ ...(ciWiring?.scheduled === undefined ? {} : { scheduled: ciWiring.scheduled }),
134
+ ...(ciWiring?.lintGates === undefined ? {} : { lint: ciWiring.lintGates }),
135
+ };
136
+ return {
137
+ ...(ciWiring?.defaultBranch === undefined ? {} : { defaultBranch: ciWiring.defaultBranch }),
138
+ ...(Object.keys(gates).length === 0 ? {} : { gates }),
139
+ ...(commitIdentity === undefined ? {} : { commitIdentity }),
140
+ };
141
+ }
142
+
143
+ export const decodeManifest = (text: string, source: string): Effect.Effect<LegacyDeclaration, QualityUnreadable> =>
144
+ decodeManifestJson(text).pipe(
145
+ Effect.map((manifest) => ({
146
+ keys: LEGACY_KEYS.filter((key) => manifest[key] !== undefined),
147
+ quality: fromLegacy(manifest),
148
+ })),
149
+ Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })),
150
+ );
151
+
152
+ const UNDECLARED: LegacyDeclaration = { keys: [], quality: {} };
153
+
154
+ export const readQuality = Effect.fn("readQuality")(function* (root: string) {
155
+ const fs = yield* FileSystem.FileSystem;
156
+ const path = yield* Path.Path;
157
+ const read = (file: string) =>
158
+ fs
159
+ .readFileString(path.join(root, file))
160
+ .pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `cannot read ${file}: ${cause.message}` })));
161
+
162
+ const legacy = (yield* fs.exists(path.join(root, MANIFEST)))
163
+ ? yield* decodeManifest(yield* read(MANIFEST), MANIFEST)
164
+ : UNDECLARED;
165
+ const keys = legacy.keys.join(" and ");
166
+ if (yield* fs.exists(path.join(root, QUALITY_FILE))) {
167
+ if (legacy.keys.length > 0) {
168
+ return yield* new QualityUnreadable({
169
+ message: `${MANIFEST} still sets ${keys}, which ${QUALITY_FILE} replaces; move what it holds there`,
170
+ });
171
+ }
172
+ return { source: QUALITY_FILE, quality: yield* decodeQuality(yield* read(QUALITY_FILE), QUALITY_FILE) } satisfies Declared;
173
+ }
174
+ if (legacy.keys.length > 0) {
175
+ const them = legacy.keys.length === 1 ? "it" : "them";
176
+ yield* Console.error(`${MANIFEST} sets ${keys}, which a later minor release stops reading; move ${them} into ${QUALITY_FILE}`);
177
+ }
178
+ return { source: MANIFEST, quality: legacy.quality } satisfies Declared;
179
+ });
180
+
181
+ export function renderJson(value: unknown): string {
182
+ return `${JSON.stringify(value, null, 2)}\n`;
183
+ }
184
+
185
+ export function qualityJsonSchema(): JsonSchema.JsonSchema {
186
+ const { schema, definitions } = Schema.toJsonSchemaDocument(Quality, { onExcessProperty: "error" });
187
+ return { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, ...schema, $defs: definitions };
188
+ }
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import kitOxlint from "../oxlintrc.json" with { type: "json" };
4
+ import effectLanguageService from "../presets/effect.language-service.json" with { type: "json" };
5
+ import effectOxlint from "../presets/effect.oxlint.json" with { type: "json" };
6
+ import { git } from "./git.ts";
7
+ import { runMain, Usage } from "./main.ts";
8
+ import { readQuality, renderJson, type Quality } from "./quality-file.ts";
9
+
10
+ // oxlint resolves an override's files, and the language service an override's include, against the
11
+ // directory of the config that holds it, so a fragment anywhere but the root matches nothing there.
12
+ export const OXLINT_FRAGMENT = "oxlintrc.quality.json";
13
+ export const TSCONFIG_FRAGMENT = "tsconfig.quality.json";
14
+
15
+ const NAME = "checks-quality";
16
+ const USAGE = `usage: ${NAME} --check | generate`;
17
+ const GENERATE = `${NAME} generate`;
18
+
19
+ type EffectSources = NonNullable<NonNullable<Quality["sources"]>["effect"]>;
20
+
21
+ export type Fragment = {
22
+ readonly file: typeof OXLINT_FRAGMENT | typeof TSCONFIG_FRAGMENT;
23
+ readonly extendedBy: string;
24
+ readonly reader: string;
25
+ readonly content: unknown;
26
+ };
27
+
28
+ class NativeConfigUnreadable extends Schema.TaggedError<NativeConfigUnreadable>()("NativeConfigUnreadable", {
29
+ message: Schema.String,
30
+ }) {}
31
+
32
+ function oxlintFragment({ paths, exempt = [] }: EffectSources): unknown {
33
+ const override = {
34
+ files: paths,
35
+ ...(exempt.length === 0 ? {} : { excludeFiles: exempt }),
36
+ // An override that leaves out any of the kit's plugins turns on the category rules of the ones it adds.
37
+ plugins: [...kitOxlint.plugins, ...effectOxlint.plugins.filter((plugin) => !kitOxlint.plugins.includes(plugin))],
38
+ rules: effectOxlint.rules,
39
+ };
40
+ return {
41
+ $schema: "./node_modules/oxlint/configuration_schema.json",
42
+ // A config without plugins brings oxlint's default plugins into the one extending it.
43
+ plugins: kitOxlint.plugins,
44
+ overrides: [override],
45
+ };
46
+ }
47
+
48
+ function tsconfigFragment({ paths, exempt = [] }: EffectSources): unknown {
49
+ const override = { include: paths, ...(exempt.length === 0 ? {} : { exclude: exempt }), options: effectLanguageService };
50
+ return { compilerOptions: { plugins: [{ name: "@effect/language-service", overrides: [override] }] } };
51
+ }
52
+
53
+ const FRAGMENTS = [
54
+ { file: OXLINT_FRAGMENT, extendedBy: ".oxlintrc.json", reader: "oxlint", build: oxlintFragment },
55
+ { file: TSCONFIG_FRAGMENT, extendedBy: "tsconfig.json", reader: "the language service", build: tsconfigFragment },
56
+ ] as const;
57
+
58
+ export function fragmentsFor(quality: Quality): readonly Fragment[] {
59
+ const effect = quality.sources?.effect;
60
+ if (effect === undefined) return [];
61
+ return FRAGMENTS.map(({ build, ...fragment }) => ({ ...fragment, content: build(effect) }));
62
+ }
63
+
64
+ const NativeConfig = Schema.Struct({
65
+ extends: Schema.optionalKey(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
66
+ });
67
+ const decodeNativeConfig = Schema.decodeUnknownEffect(NativeConfig);
68
+
69
+ const parseJsonc = (text: string, file: string) =>
70
+ Effect.try({
71
+ try: (): unknown => Bun.JSONC.parse(text),
72
+ catch: (error) => new NativeConfigUnreadable({ message: `cannot parse ${file}: ${String(error)}` }),
73
+ });
74
+
75
+ const extendsOf = Effect.fn("extendsOf")(function* (root: string, file: string) {
76
+ const fs = yield* FileSystem.FileSystem;
77
+ const path = yield* Path.Path;
78
+ const target = path.join(root, file);
79
+ if (!(yield* fs.exists(target))) return [];
80
+ const config = yield* fs.readFileString(target).pipe(
81
+ Effect.flatMap((text) => parseJsonc(text, file)),
82
+ Effect.flatMap(decodeNativeConfig),
83
+ Effect.mapError((cause) => new NativeConfigUnreadable({ message: `cannot read extends from ${file}: ${cause.message}` })),
84
+ );
85
+ const listed = config.extends ?? [];
86
+ return (typeof listed === "string" ? [listed] : listed).map((entry) => path.normalize(entry));
87
+ });
88
+
89
+ const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
90
+
91
+ const sameJson = (text: string, content: unknown): Effect.Effect<boolean> =>
92
+ decodeJson(text).pipe(
93
+ Effect.map((parsed) => Bun.deepEquals(parsed, content, true)),
94
+ Effect.orElseSucceed(() => false),
95
+ );
96
+
97
+ const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string, source: string, expected: readonly Fragment[]) {
98
+ const fs = yield* FileSystem.FileSystem;
99
+ const path = yield* Path.Path;
100
+ const problems: string[] = [];
101
+ for (const { file } of FRAGMENTS) {
102
+ const target = path.join(root, file);
103
+ const fragment = expected.find((candidate) => candidate.file === file);
104
+ const present = yield* fs.exists(target);
105
+ if (fragment === undefined) {
106
+ if (present) problems.push(`${file} is left over, since no sources.effect is declared; run ${GENERATE}`);
107
+ continue;
108
+ }
109
+ if (!present) {
110
+ problems.push(`${file} is missing; run ${GENERATE}`);
111
+ } else if (!(yield* sameJson(yield* fs.readFileString(target), fragment.content))) {
112
+ problems.push(`${file} is stale against ${source} and the kit's presets; run ${GENERATE}`);
113
+ }
114
+ if (!(yield* extendsOf(root, fragment.extendedBy)).includes(file)) {
115
+ problems.push(`${fragment.extendedBy} does not extend ./${file}, so ${fragment.reader} never reads it`);
116
+ }
117
+ }
118
+ return problems;
119
+ });
120
+
121
+ const unmatchedPaths = Effect.fn("unmatchedPaths")(function* (root: string, quality: Quality) {
122
+ const problems: string[] = [];
123
+ for (const glob of quality.sources?.effect?.paths ?? []) {
124
+ const matched = yield* git(["ls-files", "--cached", "--others", "--exclude-standard", "--", `:(glob)${glob}`], root);
125
+ if (matched.trim() === "") problems.push(`sources.effect.paths ${glob} matches no file, so it holds nothing to the Effect rules`);
126
+ }
127
+ return problems;
128
+ });
129
+
130
+ const check = Effect.fn("check")(function* (root: string) {
131
+ const { source, quality } = yield* readQuality(root);
132
+ const expected = fragmentsFor(quality);
133
+ const problems = [...(yield* fragmentProblems(root, source, expected)), ...(yield* unmatchedPaths(root, quality))];
134
+ if (problems.length > 0) {
135
+ yield* Console.error([`${NAME}: ${problems.length} problem(s) with what ${source} declares:`, ...problems.map((line) => ` ${line}`)].join("\n"));
136
+ return false;
137
+ }
138
+ yield* Console.log(
139
+ expected.length === 0
140
+ ? `${NAME}: no sources.effect is declared, so nothing is generated`
141
+ : `${NAME}: ${expected.map((fragment) => fragment.file).join(" and ")} hold what ${source} declares`,
142
+ );
143
+ return true;
144
+ });
145
+
146
+ const generate = Effect.fn("generate")(function* (root: string) {
147
+ const fs = yield* FileSystem.FileSystem;
148
+ const path = yield* Path.Path;
149
+ const { quality } = yield* readQuality(root);
150
+ const expected = fragmentsFor(quality);
151
+ for (const { file } of FRAGMENTS) {
152
+ const target = path.join(root, file);
153
+ const fragment = expected.find((candidate) => candidate.file === file);
154
+ if (fragment !== undefined) {
155
+ yield* fs.writeFileString(target, renderJson(fragment.content));
156
+ yield* Console.log(`${NAME}: wrote ${file}`);
157
+ } else if (yield* fs.exists(target)) {
158
+ yield* fs.remove(target);
159
+ yield* Console.log(`${NAME}: removed ${file}`);
160
+ }
161
+ }
162
+ return yield* check(root);
163
+ });
164
+
165
+ const main = Effect.gen(function* () {
166
+ const [mode, ...extra] = process.argv.slice(2);
167
+ if (extra.length > 0 || (mode !== "--check" && mode !== "generate")) return yield* new Usage({ message: USAGE });
168
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
169
+ return mode === "generate" ? yield* generate(root) : yield* check(root);
170
+ });
171
+
172
+ if (import.meta.main) runMain(NAME, main);