@avi2dg/checks 0.10.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,197 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "$schema": {
6
+ "type": "string"
7
+ },
8
+ "defaultBranch": {
9
+ "type": "string",
10
+ "minLength": 1,
11
+ "description": "The branch pull requests merge into; main when absent"
12
+ },
13
+ "gates": {
14
+ "type": "object",
15
+ "properties": {
16
+ "ci": {
17
+ "type": "array",
18
+ "prefixItems": [
19
+ {
20
+ "$ref": "#/$defs/Command"
21
+ }
22
+ ],
23
+ "minItems": 1,
24
+ "items": {
25
+ "$ref": "#/$defs/Command"
26
+ },
27
+ "description": "The commands CI runs on every pull request to the default branch, each one plain command"
28
+ },
29
+ "scheduled": {
30
+ "type": "array",
31
+ "items": {
32
+ "$ref": "#/$defs/Command"
33
+ },
34
+ "description": "The commands a cron-scheduled workflow runs"
35
+ },
36
+ "lint": {
37
+ "type": "array",
38
+ "items": {
39
+ "type": "string",
40
+ "enum": [
41
+ "checks-lint-coverage",
42
+ "checks-test-layout",
43
+ "checks-commit-identity",
44
+ "checks-comment-gate",
45
+ "checks-suppressions-ratchet",
46
+ "checks-ci-wiring",
47
+ "checks-quality"
48
+ ]
49
+ },
50
+ "allOf": [
51
+ {
52
+ "contains": {
53
+ "const": "checks-commit-identity"
54
+ }
55
+ },
56
+ {
57
+ "contains": {
58
+ "const": "checks-comment-gate"
59
+ }
60
+ },
61
+ {
62
+ "contains": {
63
+ "const": "checks-suppressions-ratchet"
64
+ }
65
+ },
66
+ {
67
+ "contains": {
68
+ "const": "checks-ci-wiring"
69
+ }
70
+ }
71
+ ]
72
+ }
73
+ },
74
+ "additionalProperties": false
75
+ },
76
+ "commitIdentity": {
77
+ "type": "object",
78
+ "properties": {
79
+ "authors": {
80
+ "type": "array",
81
+ "prefixItems": [
82
+ {
83
+ "$ref": "#/$defs/Identity"
84
+ }
85
+ ],
86
+ "minItems": 1,
87
+ "items": {
88
+ "$ref": "#/$defs/Identity"
89
+ },
90
+ "description": "The identities allowed to author and commit, in place of the kit's default owner"
91
+ }
92
+ },
93
+ "required": [
94
+ "authors"
95
+ ],
96
+ "additionalProperties": false
97
+ },
98
+ "sources": {
99
+ "type": "object",
100
+ "properties": {
101
+ "production": {
102
+ "type": "array",
103
+ "items": {
104
+ "$ref": "#/$defs/PathGlob"
105
+ },
106
+ "description": "The source the repository ships, as against tests and tooling"
107
+ },
108
+ "effect": {
109
+ "type": "object",
110
+ "properties": {
111
+ "paths": {
112
+ "type": "array",
113
+ "prefixItems": [
114
+ {
115
+ "$ref": "#/$defs/PathGlob"
116
+ }
117
+ ],
118
+ "minItems": 1,
119
+ "items": {
120
+ "$ref": "#/$defs/PathGlob"
121
+ },
122
+ "description": "Where source is written in Effect, held to the Effect rules of oxlint and the language service"
123
+ },
124
+ "exempt": {
125
+ "type": "array",
126
+ "items": {
127
+ "$ref": "#/$defs/PathGlob"
128
+ },
129
+ "description": "Files under paths the Effect rules pass over"
130
+ }
131
+ },
132
+ "required": [
133
+ "paths"
134
+ ],
135
+ "additionalProperties": false
136
+ }
137
+ },
138
+ "additionalProperties": false
139
+ },
140
+ "agentRules": {
141
+ "type": "object",
142
+ "properties": {
143
+ "on": {
144
+ "type": "array",
145
+ "items": {
146
+ "$ref": "#/$defs/RuleName"
147
+ },
148
+ "description": "Catalogued Rules switched on here"
149
+ },
150
+ "off": {
151
+ "type": "array",
152
+ "items": {
153
+ "$ref": "#/$defs/RuleName"
154
+ },
155
+ "description": "Catalogued Rules switched off here"
156
+ }
157
+ },
158
+ "additionalProperties": false
159
+ }
160
+ },
161
+ "additionalProperties": false,
162
+ "title": "quality.json",
163
+ "description": "What a repository has opted into from @avi2dg/checks, read by its bins and agent Rule selection",
164
+ "$defs": {
165
+ "Command": {
166
+ "type": "string",
167
+ "minLength": 1
168
+ },
169
+ "Identity": {
170
+ "type": "object",
171
+ "properties": {
172
+ "name": {
173
+ "type": "string",
174
+ "minLength": 1
175
+ },
176
+ "email": {
177
+ "type": "string",
178
+ "minLength": 1
179
+ }
180
+ },
181
+ "required": [
182
+ "name",
183
+ "email"
184
+ ],
185
+ "additionalProperties": false
186
+ },
187
+ "PathGlob": {
188
+ "type": "string",
189
+ "pattern": "^(?!\\.\\.?(?:\\/|$))(?:\\*\\*|(?:[\\w.@+-]|\\*(?!\\*))+)(?:\\/(?!\\.\\.?(?:\\/|$))(?:\\*\\*|(?:[\\w.@+-]|\\*(?!\\*))+))*\\/(?:[\\w.@+-]|\\*(?!\\*))*\\.\\w+$",
190
+ "description": "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 ./"
191
+ },
192
+ "RuleName": {
193
+ "type": "string",
194
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
195
+ }
196
+ }
197
+ }
@@ -2,15 +2,8 @@
2
2
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { git } from "./git.ts";
4
4
  import { runMain } from "./main.ts";
5
- import {
6
- DEFAULT_BRANCH,
7
- ENTRY_POINT,
8
- EVERY_REPOSITORY,
9
- KIT_GATES,
10
- LintWiring,
11
- selectedGates,
12
- type KitGate,
13
- } from "./gates.ts";
5
+ import { DEFAULT_BRANCH, ENTRY_POINT, EVERY_REPOSITORY, KIT_GATES, QUALITY_FILE, selectedGates, type KitGate } from "./gates.ts";
6
+ import { readQuality, type Quality } from "./quality-file.ts";
14
7
 
15
8
  export type Command = readonly string[];
16
9
 
@@ -303,8 +296,6 @@ function gapLines(gaps: readonly Gap[]): readonly string[] {
303
296
  return lines;
304
297
  }
305
298
 
306
- const decodeWiring = Schema.decodeUnknownEffect(LintWiring);
307
-
308
299
  export function formatReport(declaration: Declaration, gaps: readonly Gap[]): string {
309
300
  const target = `pull requests to ${declaration.defaultBranch}`;
310
301
  if (gaps.length === 0) return `ci-wiring: ${declaration.gates.length} gate(s) run on ${target}`;
@@ -318,13 +309,13 @@ export function formatScheduledReport(declaration: Declaration, gaps: readonly G
318
309
  }
319
310
 
320
311
  const plainCommands = Effect.fnUntraced(function* (
321
- listed: readonly unknown[],
312
+ listed: readonly string[],
322
313
  subject: string,
323
314
  ): Effect.fn.Return<readonly Gate[], WiringError> {
324
315
  const gates: Gate[] = [];
325
316
  for (const command of listed) {
326
- const words = typeof command === "string" ? plainCommand(command) : undefined;
327
- if (typeof command !== "string" || words === undefined) {
317
+ const words = plainCommand(command);
318
+ if (words === undefined) {
328
319
  return yield* new WiringError({ message: `${subject} ${JSON.stringify(command)} is not one plain command` });
329
320
  }
330
321
  gates.push({ command, words });
@@ -333,28 +324,17 @@ const plainCommands = Effect.fnUntraced(function* (
333
324
  });
334
325
 
335
326
  export const parseDeclaration = Effect.fnUntraced(function* (
336
- manifest: unknown,
327
+ { defaultBranch, gates: declared }: Quality,
337
328
  source: string,
338
329
  ): Effect.fn.Return<Declaration, WiringError> {
339
- const configured = isRecord(manifest) && isRecord(manifest["ciWiring"]) ? manifest["ciWiring"] : {};
340
- const listed: unknown = configured["gates"];
341
- if (!Array.isArray(listed) || listed.length === 0) {
342
- return yield* new WiringError({ message: `${source} sets no ciWiring.gates, a non-empty array of commands` });
343
- }
344
- const gates = yield* plainCommands(listed, `${source} ciWiring gate`);
345
- const scheduledListed = configured["scheduled"] ?? [];
346
- if (!Array.isArray(scheduledListed)) {
347
- return yield* new WiringError({ message: `${source} ciWiring.scheduled is not an array of commands` });
330
+ if (declared?.ci === undefined) {
331
+ return yield* new WiringError({ message: `${QUALITY_FILE} declares no gates.ci, a non-empty array of commands` });
348
332
  }
349
- const scheduled = yield* plainCommands(scheduledListed, `${source} ciWiring scheduled command`);
350
- const { ciWiring } = yield* decodeWiring(manifest).pipe(
351
- Effect.mapError((cause) => new WiringError({ message: `${source}: ${cause.message}` })),
352
- );
353
333
  return {
354
- gates,
355
- scheduled,
356
- defaultBranch: ciWiring?.defaultBranch ?? DEFAULT_BRANCH,
357
- lintGates: selectedGates(ciWiring?.lintGates),
334
+ gates: yield* plainCommands(declared.ci, `${source} gate`),
335
+ scheduled: yield* plainCommands(declared.scheduled ?? [], `${source} scheduled command`),
336
+ defaultBranch: defaultBranch ?? DEFAULT_BRANCH,
337
+ lintGates: selectedGates(declared.lint),
358
338
  };
359
339
  });
360
340
 
@@ -383,10 +363,10 @@ export function formatOmissions(lintGates: readonly KitGate[], omissions: readon
383
363
  if (omitted.length === 0) return undefined;
384
364
  if (omissions.length === 0) {
385
365
  const bins = omitted.map((gate) => gate.bin).join(", ");
386
- return `ci-wiring: ciWiring.lintGates leaves out ${bins}, none of which this repository's contents make applicable`;
366
+ return `ci-wiring: ${QUALITY_FILE} gates.lint leaves out ${bins}, none of which this repository's contents make applicable`;
387
367
  }
388
368
  return [
389
- `ci-wiring: ciWiring.lintGates leaves out ${omissions.length} gate(s) this repository's contents make applicable:`,
369
+ `ci-wiring: ${QUALITY_FILE} gates.lint leaves out ${omissions.length} gate(s) this repository's contents make applicable:`,
390
370
  ...omissions.map(({ gate, content, files }) => ` ${gate}: the repository tracks ${content} (${sample(files)})`),
391
371
  ].join("\n");
392
372
  }
@@ -397,16 +377,9 @@ export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflo
397
377
  catch: (error) => new WiringError({ message: `cannot parse ${path}: ${String(error)}` }),
398
378
  });
399
379
 
400
- const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
401
-
402
380
  export const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
403
- const fs = yield* FileSystem.FileSystem;
404
- const path = (yield* Path.Path).join(root, "package.json");
405
- const manifest = yield* fs.readFileString(path).pipe(
406
- Effect.flatMap(parseJson),
407
- Effect.mapError((cause) => new WiringError({ message: `cannot read ${path} as JSON: ${cause.message}` })),
408
- );
409
- return yield* parseDeclaration(manifest, path);
381
+ const { source, quality } = yield* readQuality(root);
382
+ return yield* parseDeclaration(quality, source);
410
383
  });
411
384
 
412
385
  export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string) {
@@ -1,10 +1,8 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
2
+ import { Console, Effect, Schema } from "effect";
3
3
  import { git } from "./git.ts";
4
4
  import { runMain, Usage } from "./main.ts";
5
-
6
- const Identity = Schema.Struct({ name: Schema.String, email: Schema.String });
7
- type Identity = typeof Identity.Type;
5
+ import { readQuality, type Identity } from "./quality-file.ts";
8
6
 
9
7
  type Commit = {
10
8
  readonly sha: string;
@@ -32,16 +30,6 @@ const RECORD = "\u001e";
32
30
 
33
31
  const USAGE = "usage: commit-identity.ts <ref> | <base-ref> <head-ref>";
34
32
 
35
- const Manifest = Schema.Struct({
36
- commitIdentity: Schema.optional(Schema.Struct({ authors: Schema.NonEmptyArray(Identity) })),
37
- });
38
- const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
39
- const decodeManifest = Schema.decodeUnknownEffect(Manifest);
40
-
41
- class InvalidAllowlist extends Schema.TaggedError<InvalidAllowlist>()("InvalidAllowlist", {
42
- message: Schema.String,
43
- }) {}
44
-
45
33
  class UnreadableLog extends Schema.TaggedError<UnreadableLog>()("UnreadableLog", {
46
34
  message: Schema.String,
47
35
  }) {}
@@ -51,25 +39,8 @@ function render(identity: Identity): string {
51
39
  }
52
40
 
53
41
  const allowedAuthors = Effect.gen(function* () {
54
- const fs = yield* FileSystem.FileSystem;
55
- const path = yield* Path.Path;
56
- const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
57
- const manifestPath = path.join(root, "package.json");
58
- if (!(yield* fs.exists(manifestPath))) return DEFAULT_AUTHORS;
59
-
60
- const parsed = yield* fs.readFileString(manifestPath).pipe(
61
- Effect.flatMap(parseJson),
62
- Effect.mapError((cause) => new InvalidAllowlist({ message: `cannot read ${manifestPath} as JSON: ${cause.message}` })),
63
- );
64
- const manifest = yield* decodeManifest(parsed).pipe(
65
- Effect.mapError(
66
- () =>
67
- new InvalidAllowlist({
68
- message: `${manifestPath} sets commitIdentity without a non-empty authors array of {name, email}`,
69
- }),
70
- ),
71
- );
72
- return manifest.commitIdentity?.authors ?? DEFAULT_AUTHORS;
42
+ const { quality } = yield* readQuality((yield* git(["rev-parse", "--show-toplevel"])).trim());
43
+ return quality.commitIdentity?.authors ?? DEFAULT_AUTHORS;
73
44
  });
74
45
 
75
46
  const readCommits = Effect.fn("readCommits")(function* (revisions: readonly string[]) {
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,7 @@ 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 },
35
41
  ] as const satisfies readonly KitGate[];
36
42
 
37
43
  const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
@@ -39,22 +45,16 @@ const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSI
39
45
  // A selection without ci-wiring would go unchecked under checks-lint, so a gate every repository runs
40
46
  // is refused where checks-lint decodes the selection, not by ci-wiring.
41
47
  export const LintGates = Schema.Array(Schema.Literals(KIT_GATES.map((gate) => gate.bin))).check(
42
- Schema.makeFilter((selected) => {
43
- const missing = UNCONDITIONAL.filter((bin) => !selected.includes(bin));
44
- if (missing.length === 0) return true;
45
- const verb = missing.length === 1 ? "applies" : "apply";
46
- return `checks-lint must run ${missing.join(", ")}, which ${verb} to ${EVERY_REPOSITORY}`;
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
- }),
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 } })) }) },
56
56
  ),
57
- });
57
+ );
58
58
 
59
59
  export function selectedGates(lintGates: typeof LintGates.Type | undefined): readonly KitGate[] {
60
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
+ }