@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.
- package/README.md +376 -92
- package/package.json +23 -14
- package/presets/effect.language-service.json +8 -0
- package/presets/effect.oxlint.json +11 -0
- package/quality.schema.json +197 -0
- package/scripts/ci-wiring.ts +74 -58
- package/scripts/commit-identity.ts +4 -33
- package/scripts/flake.ts +153 -0
- package/scripts/gates.ts +17 -15
- package/scripts/lint.ts +7 -17
- package/scripts/quality-file.ts +188 -0
- package/scripts/quality.ts +172 -0
- package/scripts/test-layout.ts +4 -4
- package/scripts/test-report.ts +115 -0
- package/scripts/test.ts +153 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Deterministic checks shared across the captain's TypeScript repos",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
"scripts/gates.ts",
|
|
22
22
|
"scripts/lint.ts",
|
|
23
23
|
"scripts/test-layout.ts",
|
|
24
|
+
"scripts/test.ts",
|
|
25
|
+
"scripts/test-report.ts",
|
|
26
|
+
"scripts/flake.ts",
|
|
24
27
|
"scripts/commit-identity.ts",
|
|
25
28
|
"scripts/mutation-compare.ts",
|
|
26
29
|
"scripts/ci-wiring.ts",
|
|
@@ -29,6 +32,11 @@
|
|
|
29
32
|
"scripts/comment-gate.ts",
|
|
30
33
|
"scripts/suppressions-ratchet.ts",
|
|
31
34
|
"scripts/backtest.ts",
|
|
35
|
+
"scripts/quality-file.ts",
|
|
36
|
+
"scripts/quality.ts",
|
|
37
|
+
"presets/effect.oxlint.json",
|
|
38
|
+
"presets/effect.language-service.json",
|
|
39
|
+
"quality.schema.json",
|
|
32
40
|
"oxlintrc.json",
|
|
33
41
|
"stryker.preset.js",
|
|
34
42
|
"tsconfig.effect.json",
|
|
@@ -41,6 +49,8 @@
|
|
|
41
49
|
"./scripts/lint-coverage.sh": "./scripts/lint-coverage.sh",
|
|
42
50
|
"./scripts/lint.ts": "./scripts/lint.ts",
|
|
43
51
|
"./scripts/test-layout.ts": "./scripts/test-layout.ts",
|
|
52
|
+
"./scripts/test.ts": "./scripts/test.ts",
|
|
53
|
+
"./scripts/flake.ts": "./scripts/flake.ts",
|
|
44
54
|
"./scripts/commit-identity.ts": "./scripts/commit-identity.ts",
|
|
45
55
|
"./scripts/mutation-compare.ts": "./scripts/mutation-compare.ts",
|
|
46
56
|
"./scripts/ci-wiring.ts": "./scripts/ci-wiring.ts",
|
|
@@ -49,6 +59,11 @@
|
|
|
49
59
|
"./scripts/comment-gate.ts": "./scripts/comment-gate.ts",
|
|
50
60
|
"./scripts/suppressions-ratchet.ts": "./scripts/suppressions-ratchet.ts",
|
|
51
61
|
"./scripts/backtest.ts": "./scripts/backtest.ts",
|
|
62
|
+
"./scripts/quality-file.ts": "./scripts/quality-file.ts",
|
|
63
|
+
"./scripts/quality.ts": "./scripts/quality.ts",
|
|
64
|
+
"./presets/effect.oxlint.json": "./presets/effect.oxlint.json",
|
|
65
|
+
"./presets/effect.language-service.json": "./presets/effect.language-service.json",
|
|
66
|
+
"./quality.schema.json": "./quality.schema.json",
|
|
52
67
|
"./oxlintrc.json": "./oxlintrc.json",
|
|
53
68
|
"./stryker.preset.js": "./stryker.preset.js",
|
|
54
69
|
"./tsconfig.effect.json": "./tsconfig.effect.json",
|
|
@@ -58,28 +73,21 @@
|
|
|
58
73
|
"checks-lint": "scripts/lint.ts",
|
|
59
74
|
"checks-lint-coverage": "scripts/lint-coverage.sh",
|
|
60
75
|
"checks-test-layout": "scripts/test-layout.ts",
|
|
76
|
+
"checks-test": "scripts/test.ts",
|
|
77
|
+
"checks-flake": "scripts/flake.ts",
|
|
61
78
|
"checks-commit-identity": "scripts/commit-identity.ts",
|
|
62
79
|
"checks-mutation-compare": "scripts/mutation-compare.ts",
|
|
63
80
|
"checks-ci-wiring": "scripts/ci-wiring.ts",
|
|
64
81
|
"checks-comment-gate": "scripts/comment-gate.ts",
|
|
65
82
|
"checks-suppressions-ratchet": "scripts/suppressions-ratchet.ts",
|
|
66
|
-
"checks-backtest": "scripts/backtest.ts"
|
|
83
|
+
"checks-backtest": "scripts/backtest.ts",
|
|
84
|
+
"checks-quality": "scripts/quality.ts"
|
|
67
85
|
},
|
|
68
86
|
"scripts": {
|
|
69
|
-
"build": "bun build effect-channel/index.ts --outdir dist --target node --format esm",
|
|
87
|
+
"build": "bun build effect-channel/index.ts --outdir dist --target node --format esm && bun scripts/quality-schema.ts",
|
|
70
88
|
"lint": "oxlint --type-aware && bun scripts/lint.ts && depcruise --config .dependency-cruiser.cjs .",
|
|
71
89
|
"typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
|
|
72
|
-
"test": "bun test
|
|
73
|
-
},
|
|
74
|
-
"ciWiring": {
|
|
75
|
-
"gates": [
|
|
76
|
-
"bun run build",
|
|
77
|
-
"git diff --exit-code dist/",
|
|
78
|
-
"bun run lint",
|
|
79
|
-
"bun run typecheck",
|
|
80
|
-
"bun run test",
|
|
81
|
-
"./node_modules/.bin/commitlint"
|
|
82
|
-
]
|
|
90
|
+
"test": "bun scripts/test.ts"
|
|
83
91
|
},
|
|
84
92
|
"peerDependencies": {
|
|
85
93
|
"@swc/core": "1.16.2",
|
|
@@ -94,6 +102,7 @@
|
|
|
94
102
|
"@oxlint/plugins": "1.83.0",
|
|
95
103
|
"@swc/core": "1.16.2",
|
|
96
104
|
"@types/bun": "^1.3.0",
|
|
105
|
+
"ajv": "8.20.0",
|
|
97
106
|
"dependency-cruiser": "18.4.0",
|
|
98
107
|
"effect": "4.0.0-rc.115",
|
|
99
108
|
"oxlint": "1.83.0",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"plugins": ["node", "promise", "unicorn"],
|
|
3
|
+
"rules": {
|
|
4
|
+
"node/no-sync": "error",
|
|
5
|
+
"oxc/no-async-await": "error",
|
|
6
|
+
"promise/avoid-new": "error",
|
|
7
|
+
"unicorn/no-process-exit": "error",
|
|
8
|
+
"effect-channel/no-throw": "error",
|
|
9
|
+
"effect-channel/no-try-catch": "error"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -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
|
+
}
|
package/scripts/ci-wiring.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -21,6 +14,7 @@ export type Gate = {
|
|
|
21
14
|
|
|
22
15
|
export type Declaration = {
|
|
23
16
|
readonly gates: readonly Gate[];
|
|
17
|
+
readonly scheduled: readonly Gate[];
|
|
24
18
|
readonly defaultBranch: string;
|
|
25
19
|
readonly lintGates: readonly KitGate[];
|
|
26
20
|
};
|
|
@@ -53,6 +47,8 @@ type RunStep = {
|
|
|
53
47
|
readonly script: string;
|
|
54
48
|
};
|
|
55
49
|
|
|
50
|
+
type Trigger = (workflow: Workflow) => string | undefined;
|
|
51
|
+
|
|
56
52
|
export class WiringError extends Schema.TaggedError<WiringError>()("WiringError", {
|
|
57
53
|
message: Schema.String,
|
|
58
54
|
}) {}
|
|
@@ -161,7 +157,7 @@ function selects(patterns: readonly string[], branch: string): boolean {
|
|
|
161
157
|
return selected;
|
|
162
158
|
}
|
|
163
159
|
|
|
164
|
-
|
|
160
|
+
const pullRequestTrigger = (branch: string): Trigger => (workflow) => {
|
|
165
161
|
const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
|
|
166
162
|
const events = isRecord(on) ? Object.keys(on) : (names(on) ?? []);
|
|
167
163
|
if (!events.includes("pull_request")) return `${workflow.path} does not trigger on pull_request`;
|
|
@@ -183,7 +179,14 @@ function triggerBlocker(workflow: Workflow, branch: string): string | undefined
|
|
|
183
179
|
const missing = types === undefined ? [] : GATING_TYPES.filter((type) => !types.includes(type));
|
|
184
180
|
if (missing.length > 0) return `${workflow.path} limits pull_request to types without ${missing.join(", ")}`;
|
|
185
181
|
return undefined;
|
|
186
|
-
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const scheduleTrigger: Trigger = (workflow) => {
|
|
185
|
+
const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
|
|
186
|
+
const schedule = isRecord(on) ? on["schedule"] : undefined;
|
|
187
|
+
const crons = Array.isArray(schedule) ? schedule.filter((entry) => isRecord(entry) && typeof entry["cron"] === "string") : [];
|
|
188
|
+
return crons.length > 0 ? undefined : `${workflow.path} does not trigger on a schedule`;
|
|
189
|
+
};
|
|
187
190
|
|
|
188
191
|
// GitHub prefixes any other if: with success(), so only a status function overrides a needed job's skip.
|
|
189
192
|
function skippedBy(jobs: Readonly<Record<string, unknown>>, id: string, seen: readonly string[]): string | undefined {
|
|
@@ -202,7 +205,7 @@ function localCall(uses: unknown): string | undefined {
|
|
|
202
205
|
return typeof uses === "string" && uses.startsWith("./") ? uses.slice(2) : undefined;
|
|
203
206
|
}
|
|
204
207
|
|
|
205
|
-
function runSteps(workflows: readonly Workflow[],
|
|
208
|
+
function runSteps(workflows: readonly Workflow[], trigger: Trigger): readonly RunStep[] {
|
|
206
209
|
const documents = new Map(workflows.map((workflow) => [workflow.path, workflow.document]));
|
|
207
210
|
const steps: RunStep[] = [];
|
|
208
211
|
|
|
@@ -240,7 +243,7 @@ function runSteps(workflows: readonly Workflow[], branch: string): readonly RunS
|
|
|
240
243
|
};
|
|
241
244
|
|
|
242
245
|
for (const workflow of workflows) {
|
|
243
|
-
visit(workflow.document, workflow.path,
|
|
246
|
+
visit(workflow.document, workflow.path, trigger(workflow), [workflow.path]);
|
|
244
247
|
}
|
|
245
248
|
return steps;
|
|
246
249
|
}
|
|
@@ -250,10 +253,9 @@ function entryPointCommand(gate: Command, lintGates: readonly KitGate[]): Comman
|
|
|
250
253
|
return index === -1 ? undefined : [...gate.slice(0, index), ENTRY_POINT.bin];
|
|
251
254
|
}
|
|
252
255
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const entryPoint = entryPointCommand(gate.words, declaration.lintGates);
|
|
256
|
+
function gapsAmong(gates: readonly Gate[], steps: readonly RunStep[], lintGates: readonly KitGate[]): readonly Gap[] {
|
|
257
|
+
return gates.flatMap((gate) => {
|
|
258
|
+
const entryPoint = entryPointCommand(gate.words, lintGates);
|
|
257
259
|
const commands = [
|
|
258
260
|
{ command: gate.command, words: gate.words },
|
|
259
261
|
...(entryPoint === undefined ? [] : [{ command: entryPoint.join(" "), words: entryPoint }]),
|
|
@@ -274,10 +276,16 @@ export function findGaps(declaration: Declaration, workflows: readonly Workflow[
|
|
|
274
276
|
});
|
|
275
277
|
}
|
|
276
278
|
|
|
277
|
-
export function
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
279
|
+
export function findGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
|
|
280
|
+
return gapsAmong(declaration.gates, runSteps(workflows, pullRequestTrigger(declaration.defaultBranch)), declaration.lintGates);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function findScheduledGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
|
|
284
|
+
return gapsAmong(declaration.scheduled, runSteps(workflows, scheduleTrigger), declaration.lintGates);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function gapLines(gaps: readonly Gap[]): readonly string[] {
|
|
288
|
+
const lines: string[] = [];
|
|
281
289
|
for (const gap of gaps) {
|
|
282
290
|
lines.push(` ${gap.gate}`);
|
|
283
291
|
if (gap.blocked.length === 0) {
|
|
@@ -285,38 +293,48 @@ export function formatReport(declaration: Declaration, gaps: readonly Gap[]): st
|
|
|
285
293
|
}
|
|
286
294
|
for (const { location, blocker } of gap.blocked) lines.push(` ${location}: ${blocker}`);
|
|
287
295
|
}
|
|
288
|
-
return lines
|
|
296
|
+
return lines;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function formatReport(declaration: Declaration, gaps: readonly Gap[]): string {
|
|
300
|
+
const target = `pull requests to ${declaration.defaultBranch}`;
|
|
301
|
+
if (gaps.length === 0) return `ci-wiring: ${declaration.gates.length} gate(s) run on ${target}`;
|
|
302
|
+
return [`ci-wiring: ${gaps.length} of ${declaration.gates.length} gate(s) do not run on ${target}:`, ...gapLines(gaps)].join("\n");
|
|
289
303
|
}
|
|
290
304
|
|
|
291
|
-
|
|
305
|
+
export function formatScheduledReport(declaration: Declaration, gaps: readonly Gap[]): string {
|
|
306
|
+
const total = declaration.scheduled.length;
|
|
307
|
+
if (gaps.length === 0) return `ci-wiring: ${total} scheduled command(s) run on a schedule`;
|
|
308
|
+
return [`ci-wiring: ${gaps.length} of ${total} scheduled command(s) do not run on a schedule:`, ...gapLines(gaps)].join("\n");
|
|
309
|
+
}
|
|
292
310
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
): Effect.fn.Return<
|
|
297
|
-
const configured = isRecord(manifest) && isRecord(manifest["ciWiring"]) ? manifest["ciWiring"] : {};
|
|
298
|
-
const listed: unknown = configured["gates"];
|
|
299
|
-
if (!Array.isArray(listed) || listed.length === 0) {
|
|
300
|
-
return yield* new WiringError({ message: `${source} sets no ciWiring.gates, a non-empty array of commands` });
|
|
301
|
-
}
|
|
302
|
-
const commands: readonly unknown[] = listed;
|
|
311
|
+
const plainCommands = Effect.fnUntraced(function* (
|
|
312
|
+
listed: readonly string[],
|
|
313
|
+
subject: string,
|
|
314
|
+
): Effect.fn.Return<readonly Gate[], WiringError> {
|
|
303
315
|
const gates: Gate[] = [];
|
|
304
|
-
for (const command of
|
|
305
|
-
const words =
|
|
306
|
-
if (
|
|
307
|
-
return yield* new WiringError({
|
|
308
|
-
message: `${source} ciWiring gate ${JSON.stringify(command)} is not one plain command`,
|
|
309
|
-
});
|
|
316
|
+
for (const command of listed) {
|
|
317
|
+
const words = plainCommand(command);
|
|
318
|
+
if (words === undefined) {
|
|
319
|
+
return yield* new WiringError({ message: `${subject} ${JSON.stringify(command)} is not one plain command` });
|
|
310
320
|
}
|
|
311
321
|
gates.push({ command, words });
|
|
312
322
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
323
|
+
return gates;
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
export const parseDeclaration = Effect.fnUntraced(function* (
|
|
327
|
+
{ defaultBranch, gates: declared }: Quality,
|
|
328
|
+
source: string,
|
|
329
|
+
): Effect.fn.Return<Declaration, WiringError> {
|
|
330
|
+
if (declared?.ci === undefined) {
|
|
331
|
+
return yield* new WiringError({ message: `${QUALITY_FILE} declares no gates.ci, a non-empty array of commands` });
|
|
332
|
+
}
|
|
316
333
|
return {
|
|
317
|
-
gates,
|
|
318
|
-
|
|
319
|
-
|
|
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),
|
|
320
338
|
};
|
|
321
339
|
});
|
|
322
340
|
|
|
@@ -345,10 +363,10 @@ export function formatOmissions(lintGates: readonly KitGate[], omissions: readon
|
|
|
345
363
|
if (omitted.length === 0) return undefined;
|
|
346
364
|
if (omissions.length === 0) {
|
|
347
365
|
const bins = omitted.map((gate) => gate.bin).join(", ");
|
|
348
|
-
return `ci-wiring:
|
|
366
|
+
return `ci-wiring: ${QUALITY_FILE} gates.lint leaves out ${bins}, none of which this repository's contents make applicable`;
|
|
349
367
|
}
|
|
350
368
|
return [
|
|
351
|
-
`ci-wiring:
|
|
369
|
+
`ci-wiring: ${QUALITY_FILE} gates.lint leaves out ${omissions.length} gate(s) this repository's contents make applicable:`,
|
|
352
370
|
...omissions.map(({ gate, content, files }) => ` ${gate}: the repository tracks ${content} (${sample(files)})`),
|
|
353
371
|
].join("\n");
|
|
354
372
|
}
|
|
@@ -359,16 +377,9 @@ export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflo
|
|
|
359
377
|
catch: (error) => new WiringError({ message: `cannot parse ${path}: ${String(error)}` }),
|
|
360
378
|
});
|
|
361
379
|
|
|
362
|
-
const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
|
|
363
|
-
|
|
364
380
|
export const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
const manifest = yield* fs.readFileString(path).pipe(
|
|
368
|
-
Effect.flatMap(parseJson),
|
|
369
|
-
Effect.mapError((cause) => new WiringError({ message: `cannot read ${path} as JSON: ${cause.message}` })),
|
|
370
|
-
);
|
|
371
|
-
return yield* parseDeclaration(manifest, path);
|
|
381
|
+
const { source, quality } = yield* readQuality(root);
|
|
382
|
+
return yield* parseDeclaration(quality, source);
|
|
372
383
|
});
|
|
373
384
|
|
|
374
385
|
export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string) {
|
|
@@ -389,7 +400,8 @@ export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string)
|
|
|
389
400
|
const wiring = Effect.gen(function* () {
|
|
390
401
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
391
402
|
const declaration = yield* readDeclaration(root);
|
|
392
|
-
const
|
|
403
|
+
const workflows = yield* readWorkflows(root);
|
|
404
|
+
const gaps = findGaps(declaration, workflows);
|
|
393
405
|
const omissions = yield* findOmissions(root, declaration.lintGates);
|
|
394
406
|
const gapReport = formatReport(declaration, gaps);
|
|
395
407
|
yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
|
|
@@ -397,7 +409,11 @@ const wiring = Effect.gen(function* () {
|
|
|
397
409
|
if (omissionReport !== undefined) {
|
|
398
410
|
yield* omissions.length > 0 ? Console.error(omissionReport) : Console.log(omissionReport);
|
|
399
411
|
}
|
|
400
|
-
return gaps.length === 0 && omissions.length === 0;
|
|
412
|
+
if (declaration.scheduled.length === 0) return gaps.length === 0 && omissions.length === 0;
|
|
413
|
+
const scheduledGaps = findScheduledGaps(declaration, workflows);
|
|
414
|
+
const scheduledReport = formatScheduledReport(declaration, scheduledGaps);
|
|
415
|
+
yield* scheduledGaps.length > 0 ? Console.error(scheduledReport) : Console.log(scheduledReport);
|
|
416
|
+
return gaps.length === 0 && omissions.length === 0 && scheduledGaps.length === 0;
|
|
401
417
|
});
|
|
402
418
|
|
|
403
419
|
if (import.meta.main) runMain("ci-wiring", wiring);
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { Console, 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
|
|
55
|
-
|
|
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[]) {
|