@avi2dg/checks 0.20.0 → 0.22.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +65 -39
  2. package/CONTRIBUTING.md +11 -8
  3. package/README.md +15 -24
  4. package/bunfig.toml +1 -1
  5. package/docs/configs/commit-messages.md +5 -1
  6. package/docs/configs/dependency-rules.md +5 -2
  7. package/docs/configs/effect-rules.md +32 -33
  8. package/docs/configs/native-settings.md +74 -0
  9. package/docs/configs/typescript-rules.md +4 -0
  10. package/docs/design.md +24 -25
  11. package/docs/gates/checks-backtest.md +4 -0
  12. package/docs/gates/checks-ci-wiring.md +30 -93
  13. package/docs/gates/checks-comment-gate.md +4 -0
  14. package/docs/gates/checks-commit-identity.md +23 -27
  15. package/docs/gates/checks-docs.md +23 -21
  16. package/docs/gates/checks-flake.md +4 -10
  17. package/docs/gates/checks-lint-coverage.md +5 -1
  18. package/docs/gates/checks-lint.md +22 -104
  19. package/docs/gates/checks-mutation-compare.md +4 -0
  20. package/docs/gates/checks-quarantine-clock.md +4 -0
  21. package/docs/gates/checks-repetition.md +26 -41
  22. package/docs/gates/checks-subsumed-tests.md +4 -0
  23. package/docs/gates/checks-suppressions-ratchet.md +4 -0
  24. package/docs/gates/checks-test-layout.md +16 -8
  25. package/docs/gates/checks-test.md +68 -36
  26. package/docs/gates/checks-vendor.md +20 -13
  27. package/package.json +8 -21
  28. package/scripts/ci-wiring.ts +28 -97
  29. package/scripts/commit-identity.ts +32 -4
  30. package/scripts/doc-rules.ts +26 -11
  31. package/scripts/doc-templates.ts +2 -1
  32. package/scripts/docs.ts +4 -7
  33. package/scripts/gates.ts +0 -29
  34. package/scripts/git.ts +24 -1
  35. package/scripts/lint.ts +15 -34
  36. package/scripts/range-gate.ts +1 -2
  37. package/scripts/repetition.ts +40 -40
  38. package/scripts/shell-command.ts +7 -1
  39. package/scripts/swc.ts +46 -0
  40. package/scripts/test-layout.ts +40 -56
  41. package/scripts/test-skips.ts +180 -0
  42. package/scripts/test.ts +33 -38
  43. package/scripts/vendor.ts +55 -11
  44. package/dist/feature-rules.js +0 -349
  45. package/docs/configs/quality-file.md +0 -101
  46. package/docs/gates/checks-feature-owners.md +0 -113
  47. package/docs/gates/checks-quality.md +0 -109
  48. package/docs/gates/checks-size-budget.md +0 -107
  49. package/quality.schema.json +0 -496
  50. package/scripts/feature-owners.ts +0 -139
  51. package/scripts/quality-file.ts +0 -347
  52. package/scripts/quality.ts +0 -342
  53. package/scripts/size-budget.ts +0 -285
  54. package/scripts/size-rules.ts +0 -126
@@ -1,347 +0,0 @@
1
- import { Console, Effect, FileSystem, JsonSchema, Path, Schema } from "effect";
2
- import { LintGates, QUALITY_FILE } from "./gates.ts";
3
- import { Size } from "./size-rules.ts";
4
-
5
- const SEGMENT = String.raw`(?!\.\.?(?:/|$))(?:\*\*|(?:[\w.@+-]|\*(?!\*))+)`;
6
-
7
- const FILE = String.raw`(?:[\w.@+-]|\*(?!\*))*\.\w+`;
8
-
9
- // oxlint matches a glob without a slash against a file's name at any depth, where tsc, the
10
- // language service and git match it at the root alone, so a glob names a directory first.
11
- // The language service drops an include ending in ** and oxlint reads a bare name as a file,
12
- // so a glob ends in a file name with an extension.
13
- const PathGlob = Schema.String.check(
14
- Schema.isPattern(new RegExp(`^${SEGMENT}(?:/${SEGMENT})*/${FILE}$`), {
15
- expected:
16
- "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",
17
- }),
18
- ).annotate({
19
- identifier: "PathGlob",
20
- description:
21
- "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 ./",
22
- });
23
-
24
- // Only checks-docs reads a doc glob, matching it from the root, so a file at the root names itself.
25
- const DocGlob = Schema.String.check(
26
- Schema.isPattern(new RegExp(`^(?:${SEGMENT}/)*${FILE}$`), {
27
- expected: "a glob from the repository root such as README.md or docs/**/*.md: * within a segment, ** as a whole one, a file name with an extension last",
28
- }),
29
- ).annotate({
30
- identifier: "DocGlob",
31
- description: "A glob from the repository root that checks-docs reads: * within a segment, ** as a whole one, a file name with an extension last",
32
- });
33
-
34
- const LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
35
-
36
- const DirectoryPath = Schema.String.check(
37
- Schema.isPattern(new RegExp(`^${LITERAL_SEGMENT}(?:/${LITERAL_SEGMENT})*$`), {
38
- expected: "a directory from the repository root such as src/billing, with no glob and no trailing slash",
39
- }),
40
- ).annotate({ identifier: "DirectoryPath" });
41
-
42
- const FilePath = Schema.String.check(
43
- Schema.isPattern(new RegExp(`^(?:${LITERAL_SEGMENT}/)*[\\w.@+-]*\\.\\w+$`), {
44
- expected: "a file from the repository root such as src/billing/index.ts, with no glob",
45
- }),
46
- ).annotate({ identifier: "FilePath" });
47
-
48
- export const PROOF_DIRECTORY = "tests/e2e/";
49
-
50
- const ProofPath = Schema.String.check(
51
- Schema.isPattern(new RegExp(`^${PROOF_DIRECTORY}(?:${LITERAL_SEGMENT}/)*[\\w.@+-]+\\.test\\.tsx?$`), {
52
- expected: `a test file under ${PROOF_DIRECTORY} such as ${PROOF_DIRECTORY}billing.test.ts`,
53
- }),
54
- ).annotate({ identifier: "ProofPath" });
55
-
56
- const Command = Schema.NonEmptyString.annotate({ identifier: "Command" });
57
-
58
- const RuleName = Schema.String.check(
59
- Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a Rule name in kebab case" }),
60
- ).annotate({ identifier: "RuleName" });
61
-
62
- export const Identity = Schema.Struct({ name: Schema.NonEmptyString, email: Schema.NonEmptyString }).annotate({
63
- identifier: "Identity",
64
- });
65
- export type Identity = typeof Identity.Type;
66
-
67
- const CommitIdentity = Schema.Struct({
68
- authors: Schema.NonEmptyArray(Identity).annotate({
69
- description: "The identities allowed to author and commit, in place of the kit's default owner",
70
- }),
71
- });
72
-
73
- const Gates = Schema.Struct({
74
- ci: Schema.optionalKey(
75
- Schema.NonEmptyArray(Command).annotate({
76
- description: "The commands CI runs on every pull request to the default branch, each one plain command",
77
- }),
78
- ),
79
- scheduled: Schema.optionalKey(
80
- Schema.Array(Command).annotate({ description: "The commands a cron-scheduled workflow runs" }),
81
- ),
82
- lint: Schema.optionalKey(LintGates),
83
- });
84
-
85
- const EffectSources = Schema.Struct({
86
- paths: Schema.NonEmptyArray(PathGlob).annotate({
87
- description: "Where source is written in Effect, held to the Effect rules of oxlint and the language service",
88
- }),
89
- exempt: Schema.optionalKey(
90
- Schema.Array(PathGlob).annotate({ description: "Files under paths the Effect rules pass over" }),
91
- ),
92
- });
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
-
109
- const Sources = Schema.Struct({
110
- production: Schema.optionalKey(
111
- Schema.Array(PathGlob).annotate({ description: "The source the repository ships, as against tests and tooling" }),
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
- ),
121
- effect: Schema.optionalKey(EffectSources),
122
- });
123
-
124
- const Feature = Schema.Struct({
125
- name: Schema.String.check(
126
- Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a feature name in kebab case" }),
127
- ).annotate({ description: "The owner the dependency rule and the change signal name" }),
128
- root: DirectoryPath.annotate({ description: "The directory the feature owns" }),
129
- entries: Schema.NonEmptyArray(FilePath).annotate({
130
- description: "The files under root that code outside it imports the feature through",
131
- }),
132
- allowFrom: Schema.optionalKey(
133
- Schema.Array(PathGlob).annotate({
134
- description: "Files outside root that may import past its entries, such as a CLI or a harness; tests/ always may",
135
- }),
136
- ),
137
- proof: ProofPath.annotate({ description: "The end-to-end test that imports one of entries" }),
138
- }).check(
139
- Schema.makeFilter(({ root, entries }) => {
140
- const outside = entries.filter((entry) => !entry.startsWith(`${root}/`));
141
- return outside.length === 0 || `lists ${outside.join(", ")} among its entries, outside its root ${root}`;
142
- }),
143
- );
144
- export type Feature = typeof Feature.Type;
145
-
146
- function nests(outer: string, inner: string): boolean {
147
- return outer === inner || inner.startsWith(`${outer}/`);
148
- }
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
-
155
- const Features = Schema.Array(Feature).check(
156
- Schema.makeFilter((features) => {
157
- const repeated = duplicates(features.map((feature) => feature.name));
158
- if (repeated !== undefined) return `names ${repeated} more than once`;
159
- for (const outer of features) {
160
- const inner = features.find((other) => other !== outer && nests(outer.root, other.root));
161
- if (inner !== undefined) return `gives ${inner.root} to both ${outer.name} and ${inner.name}`;
162
- }
163
- return true;
164
- }),
165
- );
166
-
167
- const AgentRules = Schema.Struct({
168
- on: Schema.optionalKey(Schema.Array(RuleName).annotate({ description: "Catalogued Rules switched on here" })),
169
- off: Schema.optionalKey(Schema.Array(RuleName).annotate({ description: "Catalogued Rules switched off here" })),
170
- }).check(
171
- Schema.makeFilter(({ on = [], off = [] }) => {
172
- const both = on.filter((rule) => off.includes(rule));
173
- return both.length === 0 || `switches ${both.join(", ")} both on and off`;
174
- }),
175
- );
176
-
177
- export const MODES = ["tutorial", "how-to", "reference", "explanation"] as const;
178
- export type Mode = (typeof MODES)[number];
179
-
180
- const pagesIn = (mode: string) =>
181
- Schema.optionalKey(Schema.Array(PathGlob).annotate({ description: `The pages written as ${mode}` }));
182
-
183
- const Docs = Schema.Struct({
184
- pages: Schema.optionalKey(
185
- Schema.Struct({
186
- tutorial: pagesIn("a tutorial, which teaches by building one thing"),
187
- "how-to": pagesIn("a how-to, which walks one task"),
188
- reference: pagesIn("reference, which describes a thing to be looked up"),
189
- explanation: pagesIn("an explanation, which says why"),
190
- } satisfies Record<Mode, unknown>).annotate({
191
- description: "The Diátaxis mode of each page, whose template checks-docs holds the page to; a page under docs/ needs one",
192
- }),
193
- ),
194
- forConsumers: Schema.optionalKey(
195
- Schema.Array(DocGlob).annotate({
196
- description: "The living docs that speak to a repository installing this one, whose bun run commands checks-docs does not hold to this package.json",
197
- }),
198
- ),
199
- });
200
- export type Docs = typeof Docs.Type;
201
-
202
- export const Quality = Schema.Struct({
203
- $schema: Schema.optionalKey(Schema.String),
204
- defaultBranch: Schema.optionalKey(
205
- Schema.NonEmptyString.annotate({ description: "The branch pull requests merge into; main when absent" }),
206
- ),
207
- gates: Schema.optionalKey(Gates),
208
- commitIdentity: Schema.optionalKey(CommitIdentity),
209
- sources: Schema.optionalKey(Sources),
210
- size: Schema.optionalKey(Size),
211
- features: Schema.optionalKey(
212
- Features.annotate({
213
- description: "The feature owners dependency-cruiser holds to their entries and checks-feature-owners maps a change to",
214
- }),
215
- ),
216
- changeSignal: Schema.optionalKey(
217
- Schema.Literal("advisory").annotate({
218
- description: "Report which feature owners a change touches, without failing on it",
219
- }),
220
- ),
221
- agentRules: Schema.optionalKey(AgentRules),
222
- docs: Schema.optionalKey(Docs.annotate({ description: "What checks-docs reads to map a doc file to its template" })),
223
- })
224
- .annotate({
225
- title: QUALITY_FILE,
226
- description: "What a repository has opted into from @avi2dg/checks, read by its bins and agent Rule selection",
227
- })
228
- .check(
229
- Schema.makeFilter(
230
- ({ size, sources }) =>
231
- size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds no production file without sources.production",
232
- {
233
- toJsonSchema: () => ({
234
- if: { required: ["size"] },
235
- then: { required: ["sources"], properties: { sources: { required: ["production"], properties: { production: { minItems: 1 } } } } },
236
- }),
237
- },
238
- ),
239
- Schema.makeFilter(
240
- ({ changeSignal, features = [] }) =>
241
- changeSignal === undefined || features.length > 0 || "declares changeSignal, which maps a change to no owner without features",
242
- {
243
- toJsonSchema: () => ({
244
- if: { required: ["changeSignal"] },
245
- then: { required: ["features"], properties: { features: { minItems: 1 } } },
246
- }),
247
- },
248
- ),
249
- );
250
- export type Quality = typeof Quality.Type;
251
-
252
- export const LegacyManifest = Schema.Struct({
253
- ciWiring: Schema.optionalKey(
254
- Schema.Struct({
255
- gates: Schema.optionalKey(Schema.NonEmptyArray(Command)),
256
- scheduled: Schema.optionalKey(Schema.Array(Command)),
257
- lintGates: Schema.optionalKey(LintGates),
258
- defaultBranch: Schema.optionalKey(Schema.NonEmptyString),
259
- }),
260
- ),
261
- commitIdentity: Schema.optionalKey(CommitIdentity),
262
- });
263
- type LegacyManifest = typeof LegacyManifest.Type;
264
-
265
- const LEGACY_KEYS = ["ciWiring", "commitIdentity"] as const;
266
-
267
- export type LegacyDeclaration = {
268
- readonly keys: readonly (typeof LEGACY_KEYS)[number][];
269
- readonly quality: Quality;
270
- };
271
-
272
- export class QualityUnreadable extends Schema.TaggedError<QualityUnreadable>()("QualityUnreadable", {
273
- message: Schema.String,
274
- }) {}
275
-
276
- const MANIFEST = "package.json";
277
-
278
- export type Declared = {
279
- readonly source: typeof QUALITY_FILE | typeof MANIFEST;
280
- readonly quality: Quality;
281
- };
282
-
283
- const decodeQualityJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Quality), { onExcessProperty: "error" });
284
- const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(LegacyManifest));
285
-
286
- export const decodeQuality = (text: string, source: string): Effect.Effect<Quality, QualityUnreadable> =>
287
- decodeQualityJson(text).pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })));
288
-
289
- function fromLegacy({ ciWiring, commitIdentity }: LegacyManifest): Quality {
290
- const gates = {
291
- ...(ciWiring?.gates === undefined ? {} : { ci: ciWiring.gates }),
292
- ...(ciWiring?.scheduled === undefined ? {} : { scheduled: ciWiring.scheduled }),
293
- ...(ciWiring?.lintGates === undefined ? {} : { lint: ciWiring.lintGates }),
294
- };
295
- return {
296
- ...(ciWiring?.defaultBranch === undefined ? {} : { defaultBranch: ciWiring.defaultBranch }),
297
- ...(Object.keys(gates).length === 0 ? {} : { gates }),
298
- ...(commitIdentity === undefined ? {} : { commitIdentity }),
299
- };
300
- }
301
-
302
- export const decodeManifest = (text: string, source: string): Effect.Effect<LegacyDeclaration, QualityUnreadable> =>
303
- decodeManifestJson(text).pipe(
304
- Effect.map((manifest) => ({
305
- keys: LEGACY_KEYS.filter((key) => manifest[key] !== undefined),
306
- quality: fromLegacy(manifest),
307
- })),
308
- Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })),
309
- );
310
-
311
- const UNDECLARED: LegacyDeclaration = { keys: [], quality: {} };
312
-
313
- export const readQuality = Effect.fn("readQuality")(function* (root: string) {
314
- const fs = yield* FileSystem.FileSystem;
315
- const path = yield* Path.Path;
316
- const read = (file: string) =>
317
- fs
318
- .readFileString(path.join(root, file))
319
- .pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `cannot read ${file}: ${cause.message}` })));
320
-
321
- const legacy = (yield* fs.exists(path.join(root, MANIFEST)))
322
- ? yield* decodeManifest(yield* read(MANIFEST), MANIFEST)
323
- : UNDECLARED;
324
- const keys = legacy.keys.join(" and ");
325
- if (yield* fs.exists(path.join(root, QUALITY_FILE))) {
326
- if (legacy.keys.length > 0) {
327
- return yield* new QualityUnreadable({
328
- message: `${MANIFEST} still sets ${keys}, which ${QUALITY_FILE} replaces; move what it holds there`,
329
- });
330
- }
331
- return { source: QUALITY_FILE, quality: yield* decodeQuality(yield* read(QUALITY_FILE), QUALITY_FILE) } satisfies Declared;
332
- }
333
- if (legacy.keys.length > 0) {
334
- const them = legacy.keys.length === 1 ? "it" : "them";
335
- yield* Console.error(`${MANIFEST} sets ${keys}, which a later minor release stops reading; move ${them} into ${QUALITY_FILE}`);
336
- }
337
- return { source: MANIFEST, quality: legacy.quality } satisfies Declared;
338
- });
339
-
340
- export function renderJson(value: unknown): string {
341
- return `${JSON.stringify(value, null, 2)}\n`;
342
- }
343
-
344
- export function qualityJsonSchema(): JsonSchema.JsonSchema {
345
- const { schema, definitions } = Schema.toJsonSchemaDocument(Quality, { onExcessProperty: "error" });
346
- return { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, ...schema, $defs: definitions };
347
- }
@@ -1,342 +0,0 @@
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 kitManifest from "../package.json" with { type: "json" };
5
- import effectLanguageService from "../presets/effect.language-service.json" with { type: "json" };
6
- import effectOxlint from "../presets/effect.oxlint.json" with { type: "json" };
7
- import { git } from "./git.ts";
8
- import { DEFAULT_BRANCH } from "./gates.ts";
9
- import { runMain, Usage } from "./main.ts";
10
- import { QualityUnreadable, readQuality, renderJson, type Quality } from "./quality-file.ts";
11
- import { invokes, plainCommand } from "./shell-command.ts";
12
-
13
- // oxlint resolves an override's files, and the language service an override's include, against the
14
- // directory of the config that holds it, so a fragment anywhere but the root matches nothing there.
15
- export const OXLINT_FRAGMENT = "oxlintrc.quality.json";
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";
19
-
20
- const NAME = "checks-quality";
21
- const USAGE = `usage: ${NAME} --check | generate`;
22
- const GENERATE = `${NAME} generate`;
23
-
24
- type EffectSources = NonNullable<NonNullable<Quality["sources"]>["effect"]>;
25
-
26
- export type Fragment = {
27
- readonly file: typeof OXLINT_FRAGMENT | typeof TSCONFIG_FRAGMENT;
28
- readonly extendedBy: string;
29
- readonly reader: string;
30
- readonly content: unknown;
31
- };
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
- readonly nodeVersionFile: boolean;
42
- };
43
-
44
- class NativeConfigUnreadable extends Schema.TaggedError<NativeConfigUnreadable>()("NativeConfigUnreadable", {
45
- message: Schema.String,
46
- }) {}
47
-
48
- function oxlintFragment({ paths, exempt = [] }: EffectSources): unknown {
49
- const override = {
50
- files: paths,
51
- ...(exempt.length === 0 ? {} : { excludeFiles: exempt }),
52
- // An override that leaves out any of the kit's plugins turns on the category rules of the ones it adds.
53
- plugins: [...kitOxlint.plugins, ...effectOxlint.plugins.filter((plugin) => !kitOxlint.plugins.includes(plugin))],
54
- rules: effectOxlint.rules,
55
- };
56
- return {
57
- $schema: "./node_modules/oxlint/configuration_schema.json",
58
- // A config without plugins brings oxlint's default plugins into the one extending it.
59
- plugins: kitOxlint.plugins,
60
- overrides: [override],
61
- };
62
- }
63
-
64
- function tsconfigFragment({ paths, exempt = [] }: EffectSources): unknown {
65
- const override = { include: paths, ...(exempt.length === 0 ? {} : { exclude: exempt }), options: effectLanguageService };
66
- return { compilerOptions: { plugins: [{ name: "@effect/language-service", overrides: [override] }] } };
67
- }
68
-
69
- const FRAGMENTS = [
70
- {
71
- file: OXLINT_FRAGMENT,
72
- extendedBy: ".oxlintrc.json",
73
- reader: "oxlint",
74
- kitConfig: "./node_modules/@avi2dg/checks/oxlintrc.json",
75
- kitRepositoryConfig: "./oxlintrc.json",
76
- kitConfigReason: "so the kit's oxlint rules are not loaded",
77
- build: oxlintFragment,
78
- },
79
- {
80
- file: TSCONFIG_FRAGMENT,
81
- extendedBy: "tsconfig.json",
82
- reader: "the language service",
83
- kitConfig: "@avi2dg/checks/tsconfig.effect.json",
84
- kitRepositoryConfig: "./tsconfig.effect.json",
85
- kitConfigReason: "the one accepted spelling of the kit's Effect config",
86
- build: tsconfigFragment,
87
- },
88
- ] as const;
89
-
90
- export function fragmentsFor(quality: Quality): readonly Fragment[] {
91
- const effect = quality.sources?.effect;
92
- if (effect === undefined) return [];
93
- return FRAGMENTS.map(({ file, extendedBy, reader, build }) => ({ file, extendedBy, reader, content: build(effect) }));
94
- }
95
-
96
- const OWN_COMMITLINT_CONFIG = "./commitlint.config.js";
97
- const KIT_COMMITLINT_CONFIG = "./node_modules/@avi2dg/checks/commitlint.config.js";
98
-
99
- function titleLint(commitlintConfig: string): string {
100
- return `./node_modules/.bin/commitlint --config ${commitlintConfig} --edit "$RUNNER_TEMP/pr-title"`;
101
- }
102
-
103
- function runsInTitleLint(gate: string, commitlintConfig: string): boolean {
104
- const words = plainCommand(gate);
105
- return words !== undefined && invokes(plainCommand(titleLint(commitlintConfig)), words);
106
- }
107
-
108
- function runScalar(command: string): string {
109
- return /(^[\s#&*!|>@`'"%{[-])|:\s|\s#|:$|[\n\\]/.test(command) ? JSON.stringify(command) : command;
110
- }
111
-
112
- export function commitlintWorkflow(commitlintConfig: string): string {
113
- return `permissions:
114
- contents: read
115
- on:
116
- pull_request:
117
- types: [opened, edited, synchronize, reopened]
118
- jobs:
119
- commitlint:
120
- runs-on: ubuntu-latest
121
- steps:
122
- - uses: actions/checkout@v5
123
- - uses: oven-sh/setup-bun@v2
124
- - run: bun install --frozen-lockfile
125
- # Only the title is linted: it is what a squash merge lands, with GitHub appending " (#N)" to it.
126
- - run: printf '%s' "$PR_TITLE (#0000)" > "$RUNNER_TEMP/pr-title"
127
- env:
128
- PR_TITLE: \${{ github.event.pull_request.title }}
129
- # A title starting with git's comment character lints as empty without this: commentChar moves off '#'.
130
- - run: ${titleLint(commitlintConfig)}
131
- env:
132
- GIT_CONFIG_COUNT: "1"
133
- GIT_CONFIG_KEY_0: core.commentChar
134
- GIT_CONFIG_VALUE_0: "\\x01"
135
- `;
136
- }
137
-
138
- export function suiteWorkflow(defaultBranch: string, gates: readonly string[], bunVersionFile: boolean, nodeVersionFile: boolean): string {
139
- const node = nodeVersionFile
140
- ? " - uses: actions/setup-node@v5\n with:\n node-version-file: .node-version\n"
141
- : "";
142
- const setup = bunVersionFile
143
- ? " - uses: oven-sh/setup-bun@v2\n with:\n bun-version-file: .bun-version\n"
144
- : " - uses: oven-sh/setup-bun@v2\n";
145
- const steps = gates.map((gate) => ` - run: ${runScalar(gate)}\n`).join("");
146
- 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${node}${setup} - run: bun install --frozen-lockfile\n${steps}`;
147
- }
148
-
149
- export function workflowsFor(quality: Quality, recipe: WorkflowRecipe): readonly GeneratedWorkflow[] {
150
- const commitlint: GeneratedWorkflow = {
151
- file: COMMITLINT_WORKFLOW,
152
- content: commitlintWorkflow(recipe.commitlintConfig),
153
- };
154
- if (quality.gates?.ci === undefined) return [commitlint];
155
- const suite = quality.gates.ci.filter((gate) => !runsInTitleLint(gate, recipe.commitlintConfig));
156
- return [
157
- {
158
- file: SUITE_WORKFLOW,
159
- content: suiteWorkflow(quality.defaultBranch ?? DEFAULT_BRANCH, suite, recipe.bunVersionFile, recipe.nodeVersionFile),
160
- },
161
- commitlint,
162
- ];
163
- }
164
-
165
- const NativeConfig = Schema.Struct({
166
- extends: Schema.optionalKey(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
167
- });
168
- const decodeNativeConfig = Schema.decodeUnknownEffect(NativeConfig);
169
-
170
- const parseJsonc = (text: string, file: string) =>
171
- Effect.try({
172
- try: (): unknown => Bun.JSONC.parse(text),
173
- catch: (error) => new NativeConfigUnreadable({ message: `cannot parse ${file}: ${String(error)}` }),
174
- });
175
-
176
- const extendsOf = Effect.fn("extendsOf")(function* (root: string, file: string) {
177
- const fs = yield* FileSystem.FileSystem;
178
- const path = yield* Path.Path;
179
- const target = path.join(root, file);
180
- if (!(yield* fs.exists(target))) return [];
181
- const config = yield* fs.readFileString(target).pipe(
182
- Effect.flatMap((text) => parseJsonc(text, file)),
183
- Effect.flatMap(decodeNativeConfig),
184
- Effect.mapError((cause) => new NativeConfigUnreadable({ message: `cannot read extends from ${file}: ${cause.message}` })),
185
- );
186
- const listed = config.extends ?? [];
187
- return (typeof listed === "string" ? [listed] : listed).map((entry) => path.normalize(entry));
188
- });
189
-
190
- const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
191
-
192
- const sameJson = (text: string, content: unknown): Effect.Effect<boolean> =>
193
- decodeJson(text).pipe(
194
- Effect.map((parsed) => Bun.deepEquals(parsed, content, true)),
195
- Effect.orElseSucceed(() => false),
196
- );
197
-
198
- const decodePackageName = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ name: Schema.optionalKey(Schema.String) })));
199
-
200
- const isKit = Effect.fn("isKit")(function* (root: string) {
201
- const fs = yield* FileSystem.FileSystem;
202
- const manifest = (yield* Path.Path).join(root, "package.json");
203
- if (!(yield* fs.exists(manifest))) return false;
204
- const { name } = yield* decodePackageName(yield* fs.readFileString(manifest)).pipe(
205
- Effect.mapError((cause) => new QualityUnreadable({ message: `package.json: ${cause.message}` })),
206
- );
207
- return name === kitManifest.name;
208
- });
209
-
210
- const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string, source: string, expected: readonly Fragment[]) {
211
- const fs = yield* FileSystem.FileSystem;
212
- const path = yield* Path.Path;
213
- const problems: string[] = [];
214
- const kitRepository = yield* isKit(root);
215
- for (const { file, extendedBy, reader, kitConfig, kitRepositoryConfig, kitConfigReason } of FRAGMENTS) {
216
- const target = path.join(root, file);
217
- const fragment = expected.find((candidate) => candidate.file === file);
218
- const present = yield* fs.exists(target);
219
- if (fragment === undefined) {
220
- if (present) problems.push(`${file} is left over, since no sources.effect is declared; run ${GENERATE}`);
221
- continue;
222
- }
223
- if (!present) {
224
- problems.push(`${file} is missing; run ${GENERATE}`);
225
- } else if (!(yield* sameJson(yield* fs.readFileString(target), fragment.content))) {
226
- problems.push(`${file} is stale against ${source} and the kit's presets; run ${GENERATE}`);
227
- }
228
- const configured = yield* extendsOf(root, extendedBy);
229
- if (!configured.includes(file)) {
230
- problems.push(`${extendedBy} does not extend ./${file}, so ${reader} never reads it`);
231
- }
232
- const requiredKitConfig = kitRepository ? kitRepositoryConfig : kitConfig;
233
- if (!configured.includes(path.normalize(requiredKitConfig))) {
234
- problems.push(`${extendedBy} does not extend ${requiredKitConfig}, ${kitConfigReason}`);
235
- }
236
- }
237
- return problems;
238
- });
239
-
240
- const unmatchedPaths = Effect.fn("unmatchedPaths")(function* (root: string, quality: Quality) {
241
- const declared = [
242
- ...(quality.sources?.production ?? []).map((glob) => ({
243
- glob,
244
- key: "sources.production",
245
- holds: "no source to checks-size-budget or checks-repetition",
246
- })),
247
- ...(quality.sources?.effect?.paths ?? []).map((glob) => ({ glob, key: "sources.effect.paths", holds: "nothing to the Effect rules" })),
248
- ];
249
- const problems: string[] = [];
250
- for (const { glob, key, holds } of declared) {
251
- const matched = yield* git(["ls-files", "--cached", "--others", "--exclude-standard", "--", `:(glob)${glob}`], root);
252
- if (matched.trim() === "") problems.push(`${key} ${glob} matches no file, so it holds ${holds}`);
253
- }
254
- return problems;
255
- });
256
-
257
- const recipeOf = Effect.fn("recipeOf")(function* (root: string) {
258
- const fs = yield* FileSystem.FileSystem;
259
- const path = yield* Path.Path;
260
- return {
261
- // The kit never installs itself, so its own tree lints with its root config.
262
- commitlintConfig: (yield* isKit(root)) ? OWN_COMMITLINT_CONFIG : KIT_COMMITLINT_CONFIG,
263
- bunVersionFile: yield* fs.exists(path.join(root, ".bun-version")),
264
- nodeVersionFile: yield* fs.exists(path.join(root, ".node-version")),
265
- } satisfies WorkflowRecipe;
266
- });
267
-
268
- const workflowProblems = Effect.fn("workflowProblems")(function* (
269
- root: string,
270
- source: string,
271
- expected: readonly GeneratedWorkflow[],
272
- ) {
273
- const fs = yield* FileSystem.FileSystem;
274
- const path = yield* Path.Path;
275
- const problems: string[] = [];
276
- for (const { file, content } of expected) {
277
- const target = path.join(root, file);
278
- if (!(yield* fs.exists(target))) {
279
- problems.push(`${file} is missing; run ${GENERATE}`);
280
- } else if ((yield* fs.readFileString(target)) !== content) {
281
- problems.push(`${file} is stale against ${source} and the kit recipe; run ${GENERATE}`);
282
- }
283
- }
284
- return problems;
285
- });
286
-
287
- const check = Effect.fn("check")(function* (root: string) {
288
- const { source, quality } = yield* readQuality(root);
289
- const expected = fragmentsFor(quality);
290
- const workflows = workflowsFor(quality, yield* recipeOf(root));
291
- const problems = [
292
- ...(yield* fragmentProblems(root, source, expected)),
293
- ...(yield* workflowProblems(root, source, workflows)),
294
- ...(yield* unmatchedPaths(root, quality)),
295
- ];
296
- if (problems.length > 0) {
297
- yield* Console.error([`${NAME}: ${problems.length} problem(s) with what ${source} declares:`, ...problems.map((line) => ` ${line}`)].join("\n"));
298
- return false;
299
- }
300
- const held = [...expected.map((fragment) => fragment.file), ...workflows.map((workflow) => workflow.file)];
301
- const verb = held.length === 1 ? "holds" : "hold";
302
- yield* Console.log(
303
- expected.length === 0
304
- ? `${NAME}: no sources.effect is declared, so no fragment is generated; ${held.join(" and ")} ${verb} the kit recipe`
305
- : `${NAME}: ${held.join(" and ")} ${verb} what ${source} declares`,
306
- );
307
- return true;
308
- });
309
-
310
- const generate = Effect.fn("generate")(function* (root: string) {
311
- const fs = yield* FileSystem.FileSystem;
312
- const path = yield* Path.Path;
313
- const { quality } = yield* readQuality(root);
314
- const expected = fragmentsFor(quality);
315
- for (const { file } of FRAGMENTS) {
316
- const target = path.join(root, file);
317
- const fragment = expected.find((candidate) => candidate.file === file);
318
- if (fragment !== undefined) {
319
- yield* fs.writeFileString(target, renderJson(fragment.content));
320
- yield* Console.log(`${NAME}: wrote ${file}`);
321
- } else if (yield* fs.exists(target)) {
322
- yield* fs.remove(target);
323
- yield* Console.log(`${NAME}: removed ${file}`);
324
- }
325
- }
326
- const workflows = workflowsFor(quality, yield* recipeOf(root));
327
- yield* fs.makeDirectory(path.join(root, ".github", "workflows"), { recursive: true });
328
- for (const { file, content } of workflows) {
329
- yield* fs.writeFileString(path.join(root, file), content);
330
- yield* Console.log(`${NAME}: wrote ${file}`);
331
- }
332
- return yield* check(root);
333
- });
334
-
335
- const main = Effect.gen(function* () {
336
- const [mode, ...extra] = process.argv.slice(2);
337
- if (extra.length > 0 || (mode !== "--check" && mode !== "generate")) return yield* new Usage({ message: USAGE });
338
- const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
339
- return mode === "generate" ? yield* generate(root) : yield* check(root);
340
- });
341
-
342
- if (import.meta.main) runMain(NAME, main);