@avi2dg/checks 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -1
- package/bunfig.toml +1 -1
- package/dependency-cruiser.config.js +2 -0
- package/dist/feature-rules.js +22 -5
- package/docs/configs/commit-messages.md +2 -18
- package/docs/configs/quality-file.md +3 -2
- package/docs/gates/checks-lint.md +2 -1
- package/docs/gates/checks-mutation-compare.md +20 -11
- package/docs/gates/checks-quality.md +23 -8
- package/docs/gates/checks-quarantine-clock.md +54 -0
- package/docs/gates/checks-test-layout.md +9 -3
- package/docs/gates/checks-vendor.md +97 -0
- package/package.json +11 -2
- package/quality.schema.json +56 -4
- package/scripts/backtest.ts +57 -41
- package/scripts/ci-wiring.ts +2 -63
- package/scripts/comment-gate.ts +3 -4
- package/scripts/comment-matchers.ts +54 -57
- package/scripts/commit-identity.ts +3 -4
- package/scripts/doc-outline.ts +34 -16
- package/scripts/doc-rules.ts +34 -21
- package/scripts/docs.ts +3 -4
- package/scripts/feature-owners.ts +5 -8
- package/scripts/gates.ts +1 -0
- package/scripts/git.ts +41 -26
- package/scripts/lint.ts +75 -35
- package/scripts/mutation-compare.ts +179 -70
- package/scripts/prose-matchers.ts +93 -83
- package/scripts/quality-file.ts +30 -3
- package/scripts/quality.ts +157 -10
- package/scripts/quarantine-clock.ts +153 -0
- package/scripts/range-gate.ts +9 -0
- package/scripts/repetition.ts +40 -19
- package/scripts/shell-command.ts +74 -0
- package/scripts/size-budget.ts +51 -28
- package/scripts/suppressions-ratchet.ts +22 -28
- package/scripts/test-layout.ts +65 -52
- package/scripts/test-report.ts +18 -13
- package/scripts/vendor.ts +337 -0
|
@@ -97,59 +97,70 @@ function targetOf(destination: string): string {
|
|
|
97
97
|
return trimmed.split(/\s/, 1)[0] ?? "";
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
type Masking = { readonly raw: string; readonly out: string[]; readonly code: string[]; readonly links: string[]; inComment: boolean };
|
|
101
|
+
|
|
102
|
+
function hide(masking: Masking, from: number, to: number, fill: string): void {
|
|
103
|
+
for (let at = from; at < to; at += 1) masking.out[at] = fill;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A step returns an index past `at`, or undefined when its token does not start there.
|
|
107
|
+
function commentStep(masking: Masking, at: number): number | undefined {
|
|
108
|
+
const { raw } = masking;
|
|
109
|
+
if (!masking.inComment) {
|
|
110
|
+
if (!raw.startsWith("<!--", at)) return undefined;
|
|
111
|
+
hide(masking, at, at + 4, BLANK);
|
|
112
|
+
masking.inComment = true;
|
|
113
|
+
return at + 4;
|
|
114
|
+
}
|
|
115
|
+
const close = raw.indexOf("-->", at);
|
|
116
|
+
const end = close < 0 ? raw.length : close + 3;
|
|
117
|
+
hide(masking, at, end, BLANK);
|
|
118
|
+
masking.inComment = close < 0;
|
|
119
|
+
return end;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function codeSpanStep(masking: Masking, at: number): number | undefined {
|
|
123
|
+
const { raw } = masking;
|
|
124
|
+
if (raw.charAt(at) !== "`") return undefined;
|
|
125
|
+
const run = runOf(raw, at, "`");
|
|
126
|
+
const close = closingRun(raw, at + run, run);
|
|
127
|
+
if (close < 0) return at + run;
|
|
128
|
+
masking.code.push(raw.slice(at + run, close).trim());
|
|
129
|
+
hide(masking, at, close + run, HIDDEN);
|
|
130
|
+
return close + run;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function linkStep(masking: Masking, at: number): number | undefined {
|
|
134
|
+
const { raw } = masking;
|
|
135
|
+
const end = raw.startsWith("](", at) ? destinationEnd(raw, at + 1) : -1;
|
|
136
|
+
if (end < 0) return undefined;
|
|
137
|
+
masking.links.push(targetOf(raw.slice(at + 2, end)));
|
|
138
|
+
hide(masking, at + 1, end + 1, HIDDEN);
|
|
139
|
+
return end + 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hiddenStep(masking: Masking, at: number): number | undefined {
|
|
143
|
+
const hidden = hiddenAt(masking.raw, at, masking.raw.slice(at));
|
|
144
|
+
if (hidden === undefined) return undefined;
|
|
145
|
+
hide(masking, at, at + hidden.length, hidden.fill);
|
|
146
|
+
return at + hidden.length;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const MASK_STEPS = [commentStep, codeSpanStep, linkStep, hiddenStep];
|
|
150
|
+
|
|
151
|
+
function maskStep(masking: Masking, at: number): number {
|
|
152
|
+
for (const step of MASK_STEPS) {
|
|
153
|
+
const next = step(masking, at);
|
|
154
|
+
if (next !== undefined) return next;
|
|
155
|
+
}
|
|
156
|
+
return at + 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
100
159
|
function mask(raw: string, startsInComment: boolean): Masked {
|
|
101
|
-
const
|
|
102
|
-
const code: string[] = [];
|
|
103
|
-
const links: string[] = [];
|
|
104
|
-
const hide = (from: number, to: number, fill: string): void => {
|
|
105
|
-
for (let at = from; at < to; at += 1) out[at] = fill;
|
|
106
|
-
};
|
|
107
|
-
let inComment = startsInComment;
|
|
160
|
+
const masking: Masking = { raw, out: raw.split(""), code: [], links: [], inComment: startsInComment };
|
|
108
161
|
let at = 0;
|
|
109
|
-
while (at < raw.length)
|
|
110
|
-
|
|
111
|
-
const close = raw.indexOf("-->", at);
|
|
112
|
-
const end = close < 0 ? raw.length : close + 3;
|
|
113
|
-
hide(at, end, BLANK);
|
|
114
|
-
inComment = close < 0;
|
|
115
|
-
at = end;
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
const rest = raw.slice(at);
|
|
119
|
-
if (rest.startsWith("<!--")) {
|
|
120
|
-
hide(at, at + 4, BLANK);
|
|
121
|
-
inComment = true;
|
|
122
|
-
at += 4;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (rest.startsWith("`")) {
|
|
126
|
-
const run = runOf(raw, at, "`");
|
|
127
|
-
const close = closingRun(raw, at + run, run);
|
|
128
|
-
if (close >= 0) {
|
|
129
|
-
code.push(raw.slice(at + run, close).trim());
|
|
130
|
-
hide(at, close + run, HIDDEN);
|
|
131
|
-
at = close + run;
|
|
132
|
-
} else at += run;
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (rest.startsWith("](")) {
|
|
136
|
-
const end = destinationEnd(raw, at + 1);
|
|
137
|
-
if (end >= 0) {
|
|
138
|
-
links.push(targetOf(raw.slice(at + 2, end)));
|
|
139
|
-
hide(at + 1, end + 1, HIDDEN);
|
|
140
|
-
at = end + 1;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
const hidden = hiddenAt(raw, at, rest);
|
|
145
|
-
if (hidden !== undefined) {
|
|
146
|
-
hide(at, at + hidden.length, hidden.fill);
|
|
147
|
-
at += hidden.length;
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
at += 1;
|
|
151
|
-
}
|
|
152
|
-
return { prose: out.join(""), code, links, inComment };
|
|
162
|
+
while (at < raw.length) at = maskStep(masking, at);
|
|
163
|
+
return { prose: masking.out.join(""), code: masking.code, links: masking.links, inComment: masking.inComment };
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
const FENCE = /^\s*(`{3,}|~{3,})(.*)$/;
|
|
@@ -191,42 +202,41 @@ function settext(lines: MarkdownLine[]): MarkdownLine[] {
|
|
|
191
202
|
});
|
|
192
203
|
}
|
|
193
204
|
|
|
205
|
+
function frontMatterLength(raws: readonly string[]): number {
|
|
206
|
+
if (raws[0] !== "---") return 0;
|
|
207
|
+
const close = raws.findIndex((raw, index) => index > 0 && (raw === "---" || raw === "..."));
|
|
208
|
+
return close < 0 ? raws.length : close + 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
type Scanning = { fence: string | undefined; inComment: boolean; inHtmlBlock: boolean };
|
|
212
|
+
|
|
213
|
+
function scanLine(line: number, raw: string, scanning: Scanning): MarkdownLine {
|
|
214
|
+
if (scanning.fence !== undefined) {
|
|
215
|
+
if (fenceCloses(raw, scanning.fence)) scanning.fence = undefined;
|
|
216
|
+
return unread(line, raw, "code");
|
|
217
|
+
}
|
|
218
|
+
const opener = scanning.inComment ? undefined : fenceOpener(raw);
|
|
219
|
+
if (opener !== undefined) {
|
|
220
|
+
scanning.fence = opener;
|
|
221
|
+
return unread(line, raw, "code");
|
|
222
|
+
}
|
|
223
|
+
const definition = scanning.inComment ? null : DEFINITION.exec(raw);
|
|
224
|
+
if (definition !== null) return { ...unread(line, raw, "definition"), links: [targetOf(definition[1] ?? "")] };
|
|
225
|
+
const masked = mask(raw, scanning.inComment);
|
|
226
|
+
scanning.inComment = masked.inComment;
|
|
227
|
+
const kind = kindOf(raw, masked.prose);
|
|
228
|
+
// An HTML block runs to the next blank line, whatever its later lines open with.
|
|
229
|
+
scanning.inHtmlBlock = kind === "html" || (scanning.inHtmlBlock && kind !== "blank");
|
|
230
|
+
return { line, kind: scanning.inHtmlBlock ? "html" : kind, raw, prose: masked.prose, code: masked.code, links: masked.links };
|
|
231
|
+
}
|
|
232
|
+
|
|
194
233
|
export function scanMarkdown(text: string): readonly MarkdownLine[] {
|
|
195
234
|
const raws = text.split("\n").map((raw) => raw.replace(/\r$/, ""));
|
|
235
|
+
const frontMatter = frontMatterLength(raws);
|
|
236
|
+
const scanning: Scanning = { fence: undefined, inComment: false, inHtmlBlock: false };
|
|
196
237
|
const lines: MarkdownLine[] = [];
|
|
197
|
-
let fence: string | undefined;
|
|
198
|
-
let inComment = false;
|
|
199
|
-
let inHtmlBlock = false;
|
|
200
|
-
let frontMatter = raws[0] === "---";
|
|
201
238
|
for (const [index, raw] of raws.entries()) {
|
|
202
|
-
|
|
203
|
-
if (frontMatter) {
|
|
204
|
-
frontMatter = index === 0 || (raw !== "---" && raw !== "...");
|
|
205
|
-
lines.push(unread(line, raw, "front-matter"));
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (fence !== undefined) {
|
|
209
|
-
if (fenceCloses(raw, fence)) fence = undefined;
|
|
210
|
-
lines.push(unread(line, raw, "code"));
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
const opener = inComment ? undefined : fenceOpener(raw);
|
|
214
|
-
if (opener !== undefined) {
|
|
215
|
-
fence = opener;
|
|
216
|
-
lines.push(unread(line, raw, "code"));
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
const definition = inComment ? null : DEFINITION.exec(raw);
|
|
220
|
-
if (definition !== null) {
|
|
221
|
-
lines.push({ ...unread(line, raw, "definition"), links: [targetOf(definition[1] ?? "")] });
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
const masked = mask(raw, inComment);
|
|
225
|
-
inComment = masked.inComment;
|
|
226
|
-
const kind = kindOf(raw, masked.prose);
|
|
227
|
-
// An HTML block runs to the next blank line, whatever its later lines open with.
|
|
228
|
-
inHtmlBlock = kind === "html" || (inHtmlBlock && kind !== "blank");
|
|
229
|
-
lines.push({ line, kind: inHtmlBlock ? "html" : kind, raw, prose: masked.prose, code: masked.code, links: masked.links });
|
|
239
|
+
lines.push(index < frontMatter ? unread(index + 1, raw, "front-matter") : scanLine(index + 1, raw, scanning));
|
|
230
240
|
}
|
|
231
241
|
return settext(lines);
|
|
232
242
|
}
|
package/scripts/quality-file.ts
CHANGED
|
@@ -91,10 +91,33 @@ const EffectSources = Schema.Struct({
|
|
|
91
91
|
),
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
const Library = Schema.Struct({
|
|
95
|
+
name: Schema.String.check(
|
|
96
|
+
Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a library name in kebab case" }),
|
|
97
|
+
).annotate({ description: "The link checks-vendor manages under repos/" }),
|
|
98
|
+
package: Schema.NonEmptyString.annotate({ description: "The npm package whose installed version picks the tag" }),
|
|
99
|
+
repository: Schema.NonEmptyString.annotate({ description: "The git remote checks-vendor clones the tag from" }),
|
|
100
|
+
tag: Schema.String.check(Schema.isPattern(/\{version\}/, { expected: "a tag template holding {version}" })).annotate({
|
|
101
|
+
description: "The tag template, with {version} for the installed version",
|
|
102
|
+
}),
|
|
103
|
+
path: Schema.optionalKey(
|
|
104
|
+
FilePath.annotate({ description: "The manifest inside the clone holding the version; package.json when absent" }),
|
|
105
|
+
),
|
|
106
|
+
}).annotate({ identifier: "Library" });
|
|
107
|
+
export type Library = typeof Library.Type;
|
|
108
|
+
|
|
94
109
|
const Sources = Schema.Struct({
|
|
95
110
|
production: Schema.optionalKey(
|
|
96
111
|
Schema.Array(PathGlob).annotate({ description: "The source the repository ships, as against tests and tooling" }),
|
|
97
112
|
),
|
|
113
|
+
libraries: Schema.optionalKey(
|
|
114
|
+
Schema.Array(Library).check(
|
|
115
|
+
Schema.makeFilter((libraries) => {
|
|
116
|
+
const repeated = duplicates(libraries.map((library) => library.name));
|
|
117
|
+
return repeated === undefined || `names ${repeated} more than once`;
|
|
118
|
+
}),
|
|
119
|
+
).annotate({ description: "The libraries checks-vendor pins to a shared read-only clone" }),
|
|
120
|
+
),
|
|
98
121
|
effect: Schema.optionalKey(EffectSources),
|
|
99
122
|
});
|
|
100
123
|
|
|
@@ -124,11 +147,15 @@ function nests(outer: string, inner: string): boolean {
|
|
|
124
147
|
return outer === inner || inner.startsWith(`${outer}/`);
|
|
125
148
|
}
|
|
126
149
|
|
|
150
|
+
function duplicates(names: readonly string[]): string | undefined {
|
|
151
|
+
const repeated = names.filter((name, index) => names.indexOf(name) !== index);
|
|
152
|
+
return repeated.length === 0 ? undefined : [...new Set(repeated)].join(", ");
|
|
153
|
+
}
|
|
154
|
+
|
|
127
155
|
const Features = Schema.Array(Feature).check(
|
|
128
156
|
Schema.makeFilter((features) => {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
if (repeated.length > 0) return `names ${[...new Set(repeated)].join(", ")} more than once`;
|
|
157
|
+
const repeated = duplicates(features.map((feature) => feature.name));
|
|
158
|
+
if (repeated !== undefined) return `names ${repeated} more than once`;
|
|
132
159
|
for (const outer of features) {
|
|
133
160
|
const inner = features.find((other) => other !== outer && nests(outer.root, other.root));
|
|
134
161
|
if (inner !== undefined) return `gives ${inner.root} to both ${outer.name} and ${inner.name}`;
|
package/scripts/quality.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
3
|
import kitOxlint from "../oxlintrc.json" with { type: "json" };
|
|
4
|
+
import kitManifest from "../package.json" with { type: "json" };
|
|
4
5
|
import effectLanguageService from "../presets/effect.language-service.json" with { type: "json" };
|
|
5
6
|
import effectOxlint from "../presets/effect.oxlint.json" with { type: "json" };
|
|
6
7
|
import { git } from "./git.ts";
|
|
8
|
+
import { DEFAULT_BRANCH } from "./gates.ts";
|
|
7
9
|
import { runMain, Usage } from "./main.ts";
|
|
8
|
-
import { readQuality, renderJson, type Quality } from "./quality-file.ts";
|
|
10
|
+
import { QualityUnreadable, readQuality, renderJson, type Quality } from "./quality-file.ts";
|
|
11
|
+
import { invokes, plainCommand } from "./shell-command.ts";
|
|
9
12
|
|
|
10
13
|
// oxlint resolves an override's files, and the language service an override's include, against the
|
|
11
14
|
// directory of the config that holds it, so a fragment anywhere but the root matches nothing there.
|
|
12
15
|
export const OXLINT_FRAGMENT = "oxlintrc.quality.json";
|
|
13
16
|
export const TSCONFIG_FRAGMENT = "tsconfig.quality.json";
|
|
17
|
+
export const SUITE_WORKFLOW = ".github/workflows/ci.yml";
|
|
18
|
+
export const COMMITLINT_WORKFLOW = ".github/workflows/commitlint.yml";
|
|
14
19
|
|
|
15
20
|
const NAME = "checks-quality";
|
|
16
21
|
const USAGE = `usage: ${NAME} --check | generate`;
|
|
@@ -25,6 +30,16 @@ export type Fragment = {
|
|
|
25
30
|
readonly content: unknown;
|
|
26
31
|
};
|
|
27
32
|
|
|
33
|
+
export type GeneratedWorkflow = {
|
|
34
|
+
readonly file: typeof SUITE_WORKFLOW | typeof COMMITLINT_WORKFLOW;
|
|
35
|
+
readonly content: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type WorkflowRecipe = {
|
|
39
|
+
readonly commitlintConfig: string;
|
|
40
|
+
readonly bunVersionFile: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
28
43
|
class NativeConfigUnreadable extends Schema.TaggedError<NativeConfigUnreadable>()("NativeConfigUnreadable", {
|
|
29
44
|
message: Schema.String,
|
|
30
45
|
}) {}
|
|
@@ -51,14 +66,86 @@ function tsconfigFragment({ paths, exempt = [] }: EffectSources): unknown {
|
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
const FRAGMENTS = [
|
|
54
|
-
{
|
|
55
|
-
|
|
69
|
+
{
|
|
70
|
+
file: OXLINT_FRAGMENT,
|
|
71
|
+
extendedBy: ".oxlintrc.json",
|
|
72
|
+
reader: "oxlint",
|
|
73
|
+
kitConfig: "./node_modules/@avi2dg/checks/oxlintrc.json",
|
|
74
|
+
kitRepositoryConfig: "./oxlintrc.json",
|
|
75
|
+
kitConfigReason: "so the kit's oxlint rules are not loaded",
|
|
76
|
+
build: oxlintFragment,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
file: TSCONFIG_FRAGMENT,
|
|
80
|
+
extendedBy: "tsconfig.json",
|
|
81
|
+
reader: "the language service",
|
|
82
|
+
kitConfig: "@avi2dg/checks/tsconfig.effect.json",
|
|
83
|
+
kitRepositoryConfig: "./tsconfig.effect.json",
|
|
84
|
+
kitConfigReason: "the one accepted spelling of the kit's Effect config",
|
|
85
|
+
build: tsconfigFragment,
|
|
86
|
+
},
|
|
56
87
|
] as const;
|
|
57
88
|
|
|
58
89
|
export function fragmentsFor(quality: Quality): readonly Fragment[] {
|
|
59
90
|
const effect = quality.sources?.effect;
|
|
60
91
|
if (effect === undefined) return [];
|
|
61
|
-
return FRAGMENTS.map(({
|
|
92
|
+
return FRAGMENTS.map(({ file, extendedBy, reader, build }) => ({ file, extendedBy, reader, content: build(effect) }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const OWN_COMMITLINT_CONFIG = "./commitlint.config.js";
|
|
96
|
+
const KIT_COMMITLINT_CONFIG = "./node_modules/@avi2dg/checks/commitlint.config.js";
|
|
97
|
+
|
|
98
|
+
function titleLint(commitlintConfig: string): string {
|
|
99
|
+
return `./node_modules/.bin/commitlint --config ${commitlintConfig} --edit "$RUNNER_TEMP/pr-title"`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function runsInTitleLint(gate: string, commitlintConfig: string): boolean {
|
|
103
|
+
const words = plainCommand(gate);
|
|
104
|
+
return words !== undefined && invokes(plainCommand(titleLint(commitlintConfig)), words);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function runScalar(command: string): string {
|
|
108
|
+
return /(^[\s#&*!|>@`'"%{[-])|:\s|\s#|:$|[\n\\]/.test(command) ? JSON.stringify(command) : command;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function commitlintWorkflow(commitlintConfig: string): string {
|
|
112
|
+
return `on:
|
|
113
|
+
pull_request:
|
|
114
|
+
types: [opened, edited, synchronize, reopened]
|
|
115
|
+
jobs:
|
|
116
|
+
commitlint:
|
|
117
|
+
runs-on: ubuntu-latest
|
|
118
|
+
steps:
|
|
119
|
+
- uses: actions/checkout@v5
|
|
120
|
+
- uses: oven-sh/setup-bun@v2
|
|
121
|
+
- run: bun install --frozen-lockfile
|
|
122
|
+
# Only the title is linted: it is what a squash merge lands, with GitHub appending " (#N)" to it.
|
|
123
|
+
- run: printf '%s' "$PR_TITLE (#0000)" > "$RUNNER_TEMP/pr-title"
|
|
124
|
+
env:
|
|
125
|
+
PR_TITLE: \${{ github.event.pull_request.title }}
|
|
126
|
+
- run: ${titleLint(commitlintConfig)}
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function suiteWorkflow(defaultBranch: string, gates: readonly string[], bunVersionFile: boolean): string {
|
|
131
|
+
const setup = bunVersionFile
|
|
132
|
+
? " - uses: oven-sh/setup-bun@v2\n with:\n bun-version-file: .bun-version\n"
|
|
133
|
+
: " - uses: oven-sh/setup-bun@v2\n";
|
|
134
|
+
const steps = gates.map((gate) => ` - run: ${runScalar(gate)}\n`).join("");
|
|
135
|
+
return `name: ci\non:\n push:\n branches: [${defaultBranch}]\n pull_request:\n types: [opened, edited, synchronize, reopened]\njobs:\n checks:\n runs-on: ubuntu-latest\n steps:\n # The head, not GitHub's merge ref, so the tree the gates read is the commit the range ends at.\n # The whole history, since the range starts where the head branched from the base branch.\n - uses: actions/checkout@v5\n with:\n ref: \${{ github.event.pull_request.head.sha || github.sha }}\n fetch-depth: 0\n${setup} - run: bun install --frozen-lockfile\n${steps}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function workflowsFor(quality: Quality, recipe: WorkflowRecipe): readonly GeneratedWorkflow[] {
|
|
139
|
+
const commitlint: GeneratedWorkflow = {
|
|
140
|
+
file: COMMITLINT_WORKFLOW,
|
|
141
|
+
content: commitlintWorkflow(recipe.commitlintConfig),
|
|
142
|
+
};
|
|
143
|
+
if (quality.gates?.ci === undefined) return [commitlint];
|
|
144
|
+
const suite = quality.gates.ci.filter((gate) => !runsInTitleLint(gate, recipe.commitlintConfig));
|
|
145
|
+
return [
|
|
146
|
+
{ file: SUITE_WORKFLOW, content: suiteWorkflow(quality.defaultBranch ?? DEFAULT_BRANCH, suite, recipe.bunVersionFile) },
|
|
147
|
+
commitlint,
|
|
148
|
+
];
|
|
62
149
|
}
|
|
63
150
|
|
|
64
151
|
const NativeConfig = Schema.Struct({
|
|
@@ -94,11 +181,24 @@ const sameJson = (text: string, content: unknown): Effect.Effect<boolean> =>
|
|
|
94
181
|
Effect.orElseSucceed(() => false),
|
|
95
182
|
);
|
|
96
183
|
|
|
184
|
+
const decodePackageName = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ name: Schema.optionalKey(Schema.String) })));
|
|
185
|
+
|
|
186
|
+
const isKit = Effect.fn("isKit")(function* (root: string) {
|
|
187
|
+
const fs = yield* FileSystem.FileSystem;
|
|
188
|
+
const manifest = (yield* Path.Path).join(root, "package.json");
|
|
189
|
+
if (!(yield* fs.exists(manifest))) return false;
|
|
190
|
+
const { name } = yield* decodePackageName(yield* fs.readFileString(manifest)).pipe(
|
|
191
|
+
Effect.mapError((cause) => new QualityUnreadable({ message: `package.json: ${cause.message}` })),
|
|
192
|
+
);
|
|
193
|
+
return name === kitManifest.name;
|
|
194
|
+
});
|
|
195
|
+
|
|
97
196
|
const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string, source: string, expected: readonly Fragment[]) {
|
|
98
197
|
const fs = yield* FileSystem.FileSystem;
|
|
99
198
|
const path = yield* Path.Path;
|
|
100
199
|
const problems: string[] = [];
|
|
101
|
-
|
|
200
|
+
const kitRepository = yield* isKit(root);
|
|
201
|
+
for (const { file, extendedBy, reader, kitConfig, kitRepositoryConfig, kitConfigReason } of FRAGMENTS) {
|
|
102
202
|
const target = path.join(root, file);
|
|
103
203
|
const fragment = expected.find((candidate) => candidate.file === file);
|
|
104
204
|
const present = yield* fs.exists(target);
|
|
@@ -111,8 +211,13 @@ const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string,
|
|
|
111
211
|
} else if (!(yield* sameJson(yield* fs.readFileString(target), fragment.content))) {
|
|
112
212
|
problems.push(`${file} is stale against ${source} and the kit's presets; run ${GENERATE}`);
|
|
113
213
|
}
|
|
114
|
-
|
|
115
|
-
|
|
214
|
+
const configured = yield* extendsOf(root, extendedBy);
|
|
215
|
+
if (!configured.includes(file)) {
|
|
216
|
+
problems.push(`${extendedBy} does not extend ./${file}, so ${reader} never reads it`);
|
|
217
|
+
}
|
|
218
|
+
const requiredKitConfig = kitRepository ? kitRepositoryConfig : kitConfig;
|
|
219
|
+
if (!configured.includes(path.normalize(requiredKitConfig))) {
|
|
220
|
+
problems.push(`${extendedBy} does not extend ${requiredKitConfig}, ${kitConfigReason}`);
|
|
116
221
|
}
|
|
117
222
|
}
|
|
118
223
|
return problems;
|
|
@@ -135,18 +240,54 @@ const unmatchedPaths = Effect.fn("unmatchedPaths")(function* (root: string, qual
|
|
|
135
240
|
return problems;
|
|
136
241
|
});
|
|
137
242
|
|
|
243
|
+
const recipeOf = Effect.fn("recipeOf")(function* (root: string) {
|
|
244
|
+
const fs = yield* FileSystem.FileSystem;
|
|
245
|
+
const path = yield* Path.Path;
|
|
246
|
+
return {
|
|
247
|
+
// The kit never installs itself, so its own tree lints with its root config.
|
|
248
|
+
commitlintConfig: (yield* isKit(root)) ? OWN_COMMITLINT_CONFIG : KIT_COMMITLINT_CONFIG,
|
|
249
|
+
bunVersionFile: yield* fs.exists(path.join(root, ".bun-version")),
|
|
250
|
+
} satisfies WorkflowRecipe;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const workflowProblems = Effect.fn("workflowProblems")(function* (
|
|
254
|
+
root: string,
|
|
255
|
+
source: string,
|
|
256
|
+
expected: readonly GeneratedWorkflow[],
|
|
257
|
+
) {
|
|
258
|
+
const fs = yield* FileSystem.FileSystem;
|
|
259
|
+
const path = yield* Path.Path;
|
|
260
|
+
const problems: string[] = [];
|
|
261
|
+
for (const { file, content } of expected) {
|
|
262
|
+
const target = path.join(root, file);
|
|
263
|
+
if (!(yield* fs.exists(target))) {
|
|
264
|
+
problems.push(`${file} is missing; run ${GENERATE}`);
|
|
265
|
+
} else if ((yield* fs.readFileString(target)) !== content) {
|
|
266
|
+
problems.push(`${file} is stale against ${source} and the kit recipe; run ${GENERATE}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return problems;
|
|
270
|
+
});
|
|
271
|
+
|
|
138
272
|
const check = Effect.fn("check")(function* (root: string) {
|
|
139
273
|
const { source, quality } = yield* readQuality(root);
|
|
140
274
|
const expected = fragmentsFor(quality);
|
|
141
|
-
const
|
|
275
|
+
const workflows = workflowsFor(quality, yield* recipeOf(root));
|
|
276
|
+
const problems = [
|
|
277
|
+
...(yield* fragmentProblems(root, source, expected)),
|
|
278
|
+
...(yield* workflowProblems(root, source, workflows)),
|
|
279
|
+
...(yield* unmatchedPaths(root, quality)),
|
|
280
|
+
];
|
|
142
281
|
if (problems.length > 0) {
|
|
143
282
|
yield* Console.error([`${NAME}: ${problems.length} problem(s) with what ${source} declares:`, ...problems.map((line) => ` ${line}`)].join("\n"));
|
|
144
283
|
return false;
|
|
145
284
|
}
|
|
285
|
+
const held = [...expected.map((fragment) => fragment.file), ...workflows.map((workflow) => workflow.file)];
|
|
286
|
+
const verb = held.length === 1 ? "holds" : "hold";
|
|
146
287
|
yield* Console.log(
|
|
147
288
|
expected.length === 0
|
|
148
|
-
? `${NAME}: no sources.effect is declared, so
|
|
149
|
-
: `${NAME}: ${
|
|
289
|
+
? `${NAME}: no sources.effect is declared, so no fragment is generated; ${held.join(" and ")} ${verb} the kit recipe`
|
|
290
|
+
: `${NAME}: ${held.join(" and ")} ${verb} what ${source} declares`,
|
|
150
291
|
);
|
|
151
292
|
return true;
|
|
152
293
|
});
|
|
@@ -167,6 +308,12 @@ const generate = Effect.fn("generate")(function* (root: string) {
|
|
|
167
308
|
yield* Console.log(`${NAME}: removed ${file}`);
|
|
168
309
|
}
|
|
169
310
|
}
|
|
311
|
+
const workflows = workflowsFor(quality, yield* recipeOf(root));
|
|
312
|
+
yield* fs.makeDirectory(path.join(root, ".github", "workflows"), { recursive: true });
|
|
313
|
+
for (const { file, content } of workflows) {
|
|
314
|
+
yield* fs.writeFileString(path.join(root, file), content);
|
|
315
|
+
yield* Console.log(`${NAME}: wrote ${file}`);
|
|
316
|
+
}
|
|
170
317
|
return yield* check(root);
|
|
171
318
|
});
|
|
172
319
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, Effect, Schema } from "effect";
|
|
3
|
+
import { commitOf, git, isShallowBoundary, refArgs } from "./git.ts";
|
|
4
|
+
import { runMain } from "./main.ts";
|
|
5
|
+
import { TEST_FILE } from "./test-layout.ts";
|
|
6
|
+
|
|
7
|
+
export const QUARANTINE = "tests/quarantine/";
|
|
8
|
+
export const QUARANTINE_DAYS = 30;
|
|
9
|
+
const DAY_SECONDS = 86400;
|
|
10
|
+
|
|
11
|
+
export class QuarantineError extends Schema.TaggedError<QuarantineError>()("QuarantineError", {
|
|
12
|
+
message: Schema.String,
|
|
13
|
+
}) {}
|
|
14
|
+
|
|
15
|
+
const USAGE = "usage: quarantine-clock.ts <ref> | <base-ref> <head-ref>";
|
|
16
|
+
|
|
17
|
+
export type Entry =
|
|
18
|
+
| { readonly kind: "entered"; readonly sha: string; readonly at: number; readonly day: string }
|
|
19
|
+
| { readonly kind: "truncated" };
|
|
20
|
+
|
|
21
|
+
export type Overdue = {
|
|
22
|
+
readonly file: string;
|
|
23
|
+
readonly day: string;
|
|
24
|
+
readonly days: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ClockResult = {
|
|
28
|
+
readonly checked: number;
|
|
29
|
+
readonly overdue: readonly Overdue[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const HEADER = /^commit ([0-9a-f]{40}|[0-9a-f]{64}) (\d+) (\S+)$/;
|
|
33
|
+
const TWO_PATHS = /^[RC]/;
|
|
34
|
+
|
|
35
|
+
type Status = readonly [status: string, from: string, to: string];
|
|
36
|
+
|
|
37
|
+
type CommitBlock = {
|
|
38
|
+
readonly sha: string;
|
|
39
|
+
readonly at: number;
|
|
40
|
+
readonly day: string;
|
|
41
|
+
readonly statuses: readonly Status[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function parseBlocks(output: string): readonly CommitBlock[] {
|
|
45
|
+
const blocks: { readonly sha: string; readonly at: number; readonly day: string; readonly statuses: Status[] }[] = [];
|
|
46
|
+
const fields = output.split("\0");
|
|
47
|
+
let index = 0;
|
|
48
|
+
while (index < fields.length) {
|
|
49
|
+
const field = (fields[index] ?? "").replace(/^\n/, "");
|
|
50
|
+
index += 1;
|
|
51
|
+
const header = HEADER.exec(field);
|
|
52
|
+
if (header !== null) {
|
|
53
|
+
blocks.push({ sha: header[1] ?? "", at: Number(header[2] ?? "0"), day: (header[3] ?? "").slice(0, "YYYY-MM-DD".length), statuses: [] });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (field === "") continue;
|
|
57
|
+
const paths = TWO_PATHS.test(field) ? 2 : 1;
|
|
58
|
+
blocks.at(-1)?.statuses.push([field, fields[index] ?? "", fields[index + paths - 1] ?? ""]);
|
|
59
|
+
index += paths;
|
|
60
|
+
}
|
|
61
|
+
return blocks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function entryIn({ sha, at, day, statuses }: CommitBlock, path: string): Entry | undefined {
|
|
65
|
+
const entered = statuses.some(
|
|
66
|
+
([status, from, to]) =>
|
|
67
|
+
to === path && (status === "A" || status.startsWith("C") || (status.startsWith("R") && !from.startsWith(QUARANTINE))),
|
|
68
|
+
);
|
|
69
|
+
return entered ? { kind: "entered", sha, at, day } : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function followedPath({ statuses }: CommitBlock, path: string): string {
|
|
73
|
+
const rename = statuses.find(([status, , to]) => status.startsWith("R") && to === path);
|
|
74
|
+
return rename?.[1] ?? path;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function findEntry(output: string, file: string): Entry {
|
|
78
|
+
let current = file;
|
|
79
|
+
for (const block of parseBlocks(output)) {
|
|
80
|
+
const entry = entryIn(block, current);
|
|
81
|
+
if (entry !== undefined) return entry;
|
|
82
|
+
current = followedPath(block, current);
|
|
83
|
+
}
|
|
84
|
+
return { kind: "truncated" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function overdueOf(
|
|
88
|
+
entries: readonly { readonly file: string; readonly at: number; readonly day: string }[],
|
|
89
|
+
headAt: number,
|
|
90
|
+
): readonly Overdue[] {
|
|
91
|
+
const limit = QUARANTINE_DAYS * DAY_SECONDS;
|
|
92
|
+
return entries
|
|
93
|
+
.flatMap((entry) =>
|
|
94
|
+
headAt - entry.at > limit
|
|
95
|
+
? [{ file: entry.file, day: entry.day, days: Math.floor((headAt - entry.at) / DAY_SECONDS) }]
|
|
96
|
+
: [],
|
|
97
|
+
)
|
|
98
|
+
.toSorted((a, b) => (a.file < b.file ? -1 : 1));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function report({ checked, overdue }: ClockResult): string {
|
|
102
|
+
if (overdue.length === 0) {
|
|
103
|
+
return `quarantine-clock: no test in ${QUARANTINE} is past ${QUARANTINE_DAYS} days (${checked} checked)`;
|
|
104
|
+
}
|
|
105
|
+
const verb = overdue.length === 1 ? "is" : "are";
|
|
106
|
+
return [
|
|
107
|
+
`quarantine-clock: ${overdue.length} test(s) in ${QUARANTINE} ${verb} past ${QUARANTINE_DAYS} days; fix each and move it back, or delete it:`,
|
|
108
|
+
...overdue.map((file) => ` ${file.file} entered quarantine on ${file.day} (${file.days} days ago)`),
|
|
109
|
+
].join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const filesAt = Effect.fn("filesAt")(function* (root: string, head: string) {
|
|
113
|
+
const listed = yield* git(["ls-tree", "-r", "-z", "--name-only", head, "--", QUARANTINE], root);
|
|
114
|
+
return listed.split("\0").filter((file) => TEST_FILE.test(file));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const entryAt = Effect.fn("entryAt")(function* (root: string, head: string, file: string) {
|
|
118
|
+
const output = yield* git(["log", "--follow", "--root", "--name-status", "-z", "--format=commit %H %at %aI", head, "--", file], root);
|
|
119
|
+
const entry = findEntry(output, file);
|
|
120
|
+
if (entry.kind === "truncated" || (yield* isShallowBoundary(entry.sha, root))) {
|
|
121
|
+
return yield* new QuarantineError({
|
|
122
|
+
message: `cannot see ${file} entering ${QUARANTINE}; fetch the whole history, since a shallow clone ends before it`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return { file, at: entry.at, day: entry.day };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const headAt = Effect.fn("headAt")(function* (root: string, head: string) {
|
|
129
|
+
const shown = yield* git(["show", "-s", "--format=%at %ct", head], root);
|
|
130
|
+
const dates = shown.trim().split(" ").map(Number);
|
|
131
|
+
if (dates.length !== 2 || !dates.every(Number.isInteger)) {
|
|
132
|
+
return yield* new QuarantineError({ message: `cannot read when ${head} was written` });
|
|
133
|
+
}
|
|
134
|
+
return Math.max(...dates);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export const runHead = Effect.fn("runHead")(function* (root: string, head: string) {
|
|
138
|
+
const files = yield* filesAt(root, head);
|
|
139
|
+
const entries = yield* Effect.forEach(files, (file) => entryAt(root, head, file));
|
|
140
|
+
return { checked: files.length, overdue: overdueOf(entries, yield* headAt(root, head)) } satisfies ClockResult;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const clock = Effect.gen(function* () {
|
|
144
|
+
const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
|
|
145
|
+
if (second !== undefined) yield* commitOf(first);
|
|
146
|
+
const head = yield* commitOf(second ?? first);
|
|
147
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
148
|
+
const result = yield* runHead(root, head);
|
|
149
|
+
yield* Console.log(report(result));
|
|
150
|
+
return result.overdue.length === 0;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (import.meta.main) runMain("quarantine-clock", clock);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { git, refArgs } from "./git.ts";
|
|
3
|
+
import { readQuality } from "./quality-file.ts";
|
|
4
|
+
|
|
5
|
+
export const rangeGateInputs = Effect.fn("rangeGateInputs")(function* (usage: string) {
|
|
6
|
+
const refs = yield* refArgs(process.argv.slice(2), usage);
|
|
7
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
8
|
+
return { refs, root, ...(yield* readQuality(root)) };
|
|
9
|
+
});
|