@avi2dg/checks 0.21.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.
- package/CHANGELOG.md +58 -40
- package/CONTRIBUTING.md +11 -8
- package/README.md +15 -24
- package/bunfig.toml +1 -1
- package/docs/configs/commit-messages.md +5 -1
- package/docs/configs/dependency-rules.md +5 -2
- package/docs/configs/effect-rules.md +32 -33
- package/docs/configs/native-settings.md +74 -0
- package/docs/configs/typescript-rules.md +4 -0
- package/docs/design.md +24 -25
- package/docs/gates/checks-backtest.md +4 -0
- package/docs/gates/checks-ci-wiring.md +30 -93
- package/docs/gates/checks-comment-gate.md +4 -0
- package/docs/gates/checks-commit-identity.md +23 -27
- package/docs/gates/checks-docs.md +23 -21
- package/docs/gates/checks-flake.md +4 -10
- package/docs/gates/checks-lint-coverage.md +5 -1
- package/docs/gates/checks-lint.md +22 -104
- package/docs/gates/checks-mutation-compare.md +4 -0
- package/docs/gates/checks-quarantine-clock.md +4 -0
- package/docs/gates/checks-repetition.md +26 -41
- package/docs/gates/checks-subsumed-tests.md +4 -0
- package/docs/gates/checks-suppressions-ratchet.md +4 -0
- package/docs/gates/checks-test-layout.md +16 -8
- package/docs/gates/checks-test.md +68 -36
- package/docs/gates/checks-vendor.md +20 -13
- package/package.json +8 -21
- package/scripts/ci-wiring.ts +28 -97
- package/scripts/commit-identity.ts +32 -4
- package/scripts/doc-rules.ts +26 -11
- package/scripts/doc-templates.ts +2 -1
- package/scripts/docs.ts +4 -7
- package/scripts/gates.ts +0 -29
- package/scripts/git.ts +24 -1
- package/scripts/lint.ts +15 -34
- package/scripts/range-gate.ts +1 -2
- package/scripts/repetition.ts +40 -40
- package/scripts/shell-command.ts +7 -1
- package/scripts/swc.ts +46 -0
- package/scripts/test-layout.ts +40 -56
- package/scripts/test-skips.ts +180 -0
- package/scripts/test.ts +33 -38
- package/scripts/vendor.ts +55 -11
- package/dist/feature-rules.js +0 -354
- package/docs/configs/quality-file.md +0 -103
- package/docs/gates/checks-feature-owners.md +0 -113
- package/docs/gates/checks-quality.md +0 -111
- package/docs/gates/checks-size-budget.md +0 -107
- package/quality.schema.json +0 -514
- package/scripts/feature-owners.ts +0 -139
- package/scripts/quality-file.ts +0 -353
- package/scripts/quality.ts +0 -363
- package/scripts/size-budget.ts +0 -285
- package/scripts/size-rules.ts +0 -126
package/dist/feature-rules.js
DELETED
|
@@ -1,354 +0,0 @@
|
|
|
1
|
-
// scripts/feature-rules.ts
|
|
2
|
-
import { Schema as Schema4 } from "effect";
|
|
3
|
-
|
|
4
|
-
// scripts/quality-file.ts
|
|
5
|
-
import { Console, Effect, FileSystem, JsonSchema, Path, Schema as Schema3 } from "effect";
|
|
6
|
-
|
|
7
|
-
// scripts/gates.ts
|
|
8
|
-
import { Schema } from "effect";
|
|
9
|
-
var EVERY_REPOSITORY = "every repository";
|
|
10
|
-
var QUALITY_FILE = "quality.json";
|
|
11
|
-
var TYPESCRIPT_SOURCE = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
|
|
12
|
-
var QUALITY_DECLARATION = { pathspecs: [QUALITY_FILE], content: `a ${QUALITY_FILE}` };
|
|
13
|
-
var KIT_GATES = [
|
|
14
|
-
{ bin: "checks-lint-coverage", script: "lint-coverage.sh", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
15
|
-
{ bin: "checks-test-layout", script: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
16
|
-
{ bin: "checks-commit-identity", script: "commit-identity.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
17
|
-
{ bin: "checks-comment-gate", script: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
18
|
-
{ bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
19
|
-
{ bin: "checks-ci-wiring", script: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
|
|
20
|
-
{ bin: "checks-docs", script: "docs.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
21
|
-
{ bin: "checks-quality", script: "quality.ts", reads: "tree", args: ["--check"], appliesTo: QUALITY_DECLARATION },
|
|
22
|
-
{ bin: "checks-size-budget", script: "size-budget.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
23
|
-
{ bin: "checks-repetition", script: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
24
|
-
{ bin: "checks-feature-owners", script: "feature-owners.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
25
|
-
{ bin: "checks-quarantine-clock", script: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY }
|
|
26
|
-
];
|
|
27
|
-
var UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
|
|
28
|
-
var LintGates = Schema.Array(Schema.Literals(KIT_GATES.map((gate) => gate.bin))).check(Schema.makeFilter((selected) => {
|
|
29
|
-
const missing = UNCONDITIONAL.filter((bin) => !selected.includes(bin));
|
|
30
|
-
if (missing.length === 0)
|
|
31
|
-
return true;
|
|
32
|
-
const verb = missing.length === 1 ? "applies" : "apply";
|
|
33
|
-
return `checks-lint must run ${missing.join(", ")}, which ${verb} to ${EVERY_REPOSITORY}`;
|
|
34
|
-
}, { toJsonSchema: () => ({ allOf: UNCONDITIONAL.map((bin) => ({ contains: { const: bin } })) }) }));
|
|
35
|
-
|
|
36
|
-
// scripts/size-rules.ts
|
|
37
|
-
import { Schema as Schema2 } from "effect";
|
|
38
|
-
var TESTS_DIRECTORY = "tests";
|
|
39
|
-
var COUNTED = { skipBlankLines: false, skipComments: false };
|
|
40
|
-
var FILE_LINES = {
|
|
41
|
-
key: "fileLines",
|
|
42
|
-
rule: "max-lines",
|
|
43
|
-
options: COUNTED,
|
|
44
|
-
measured: /has too many lines \((\d+)\)/,
|
|
45
|
-
limits: "The most lines a file may hold, blank and comment lines counted"
|
|
46
|
-
};
|
|
47
|
-
var FUNCTION_LINES = {
|
|
48
|
-
key: "functionLines",
|
|
49
|
-
rule: "max-lines-per-function",
|
|
50
|
-
options: COUNTED,
|
|
51
|
-
measured: /has too many lines \((\d+)\)/,
|
|
52
|
-
limits: "The most lines a function may span, blank and comment lines counted"
|
|
53
|
-
};
|
|
54
|
-
var STATEMENTS = {
|
|
55
|
-
key: "statements",
|
|
56
|
-
rule: "max-statements",
|
|
57
|
-
options: {},
|
|
58
|
-
measured: /has too many statements \((\d+)\)/,
|
|
59
|
-
limits: "The most statements a function may hold"
|
|
60
|
-
};
|
|
61
|
-
var COMPLEXITY = {
|
|
62
|
-
key: "complexity",
|
|
63
|
-
rule: "cognitive-complexity",
|
|
64
|
-
plugin: "effect-channel",
|
|
65
|
-
options: {},
|
|
66
|
-
measured: /has a cognitive complexity of (\d+)/,
|
|
67
|
-
limits: "The highest cognitive complexity a function may reach, a switch counted once"
|
|
68
|
-
};
|
|
69
|
-
var DEPTH = {
|
|
70
|
-
key: "depth",
|
|
71
|
-
rule: "max-depth",
|
|
72
|
-
options: {},
|
|
73
|
-
measured: /nested too deeply \((\d+)\)/,
|
|
74
|
-
limits: "The deepest a block may nest inside a function"
|
|
75
|
-
};
|
|
76
|
-
var APPLIES = ["ratchet", "all"];
|
|
77
|
-
var SIZE_DEFAULTS = {
|
|
78
|
-
applies: "ratchet",
|
|
79
|
-
production: { fileLines: 400, functionLines: 100, statements: 30, complexity: 15, depth: 4 },
|
|
80
|
-
tests: { fileLines: 600, statements: 50, complexity: 15, depth: 4 }
|
|
81
|
-
};
|
|
82
|
-
var Limit = Schema2.Int.check(Schema2.isGreaterThan(0));
|
|
83
|
-
function limit({ limits }, fallback) {
|
|
84
|
-
return Schema2.optionalKey(Limit.annotate({ description: `${limits}; ${fallback} when absent` }));
|
|
85
|
-
}
|
|
86
|
-
var ProductionBudget = Schema2.Struct({
|
|
87
|
-
fileLines: limit(FILE_LINES, SIZE_DEFAULTS.production.fileLines),
|
|
88
|
-
functionLines: limit(FUNCTION_LINES, SIZE_DEFAULTS.production.functionLines),
|
|
89
|
-
statements: limit(STATEMENTS, SIZE_DEFAULTS.production.statements),
|
|
90
|
-
complexity: limit(COMPLEXITY, SIZE_DEFAULTS.production.complexity),
|
|
91
|
-
depth: limit(DEPTH, SIZE_DEFAULTS.production.depth)
|
|
92
|
-
});
|
|
93
|
-
var TestBudget = Schema2.Struct({
|
|
94
|
-
fileLines: limit(FILE_LINES, SIZE_DEFAULTS.tests.fileLines),
|
|
95
|
-
statements: limit(STATEMENTS, SIZE_DEFAULTS.tests.statements),
|
|
96
|
-
complexity: limit(COMPLEXITY, SIZE_DEFAULTS.tests.complexity),
|
|
97
|
-
depth: limit(DEPTH, SIZE_DEFAULTS.tests.depth)
|
|
98
|
-
});
|
|
99
|
-
var Size = Schema2.Struct({
|
|
100
|
-
applies: Schema2.optionalKey(Schema2.Literals(APPLIES).annotate({
|
|
101
|
-
description: `Which production and test files the budget holds: ratchet, the ones a range adds or changes, to no more overrun per rule than at the range's base; all, every one. ${SIZE_DEFAULTS.applies} when absent. The rest are listed as advisory`,
|
|
102
|
-
message: "Expected ratchet or all, and ratchet replaces changed"
|
|
103
|
-
})),
|
|
104
|
-
production: Schema2.optionalKey(ProductionBudget.annotate({
|
|
105
|
-
description: `The budget of the files under sources.production, and of the files listed as advisory outside ${TESTS_DIRECTORY}/`
|
|
106
|
-
})),
|
|
107
|
-
tests: Schema2.optionalKey(TestBudget.annotate({ description: `The budget of the files under ${TESTS_DIRECTORY}/, which sets no limit on a function's lines` }))
|
|
108
|
-
}).annotate({
|
|
109
|
-
description: "The size budget oxlint holds production and test files to, read by checks-size-budget",
|
|
110
|
-
messageUnexpectedKey: "Expected only applies, production and tests, since each limit is set inside production or tests"
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// scripts/quality-file.ts
|
|
114
|
-
var SEGMENT = String.raw`(?!\.\.?(?:/|$))(?:\*\*|(?:[\w.@+-]|\*(?!\*))+)`;
|
|
115
|
-
var FILE = String.raw`(?:[\w.@+-]|\*(?!\*))*\.\w+`;
|
|
116
|
-
var PathGlob = Schema3.String.check(Schema3.isPattern(new RegExp(`^${SEGMENT}(?:/${SEGMENT})*/${FILE}$`), {
|
|
117
|
-
expected: "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"
|
|
118
|
-
})).annotate({
|
|
119
|
-
identifier: "PathGlob",
|
|
120
|
-
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 ./"
|
|
121
|
-
});
|
|
122
|
-
var DocGlob = Schema3.String.check(Schema3.isPattern(new RegExp(`^(?:${SEGMENT}/)*${FILE}$`), {
|
|
123
|
-
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"
|
|
124
|
-
})).annotate({
|
|
125
|
-
identifier: "DocGlob",
|
|
126
|
-
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"
|
|
127
|
-
});
|
|
128
|
-
var LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
|
|
129
|
-
var DirectoryPath = Schema3.String.check(Schema3.isPattern(new RegExp(`^${LITERAL_SEGMENT}(?:/${LITERAL_SEGMENT})*$`), {
|
|
130
|
-
expected: "a directory from the repository root such as src/billing, with no glob and no trailing slash"
|
|
131
|
-
})).annotate({ identifier: "DirectoryPath" });
|
|
132
|
-
var FilePath = Schema3.String.check(Schema3.isPattern(new RegExp(`^(?:${LITERAL_SEGMENT}/)*[\\w.@+-]*\\.\\w+$`), {
|
|
133
|
-
expected: "a file from the repository root such as src/billing/index.ts, with no glob"
|
|
134
|
-
})).annotate({ identifier: "FilePath" });
|
|
135
|
-
var PROOF_DIRECTORY = "tests/e2e/";
|
|
136
|
-
var ProofPath = Schema3.String.check(Schema3.isPattern(new RegExp(`^${PROOF_DIRECTORY}(?:${LITERAL_SEGMENT}/)*[\\w.@+-]+\\.test\\.tsx?$`), {
|
|
137
|
-
expected: `a test file under ${PROOF_DIRECTORY} such as ${PROOF_DIRECTORY}billing.test.ts`
|
|
138
|
-
})).annotate({ identifier: "ProofPath" });
|
|
139
|
-
var Command = Schema3.NonEmptyString.annotate({ identifier: "Command" });
|
|
140
|
-
var RuleName = Schema3.String.check(Schema3.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a Rule name in kebab case" })).annotate({ identifier: "RuleName" });
|
|
141
|
-
var Identity = Schema3.Struct({ name: Schema3.NonEmptyString, email: Schema3.NonEmptyString }).annotate({
|
|
142
|
-
identifier: "Identity"
|
|
143
|
-
});
|
|
144
|
-
var CommitIdentity = Schema3.Struct({
|
|
145
|
-
authors: Schema3.NonEmptyArray(Identity).annotate({
|
|
146
|
-
description: "The identities allowed to author and commit, in place of the kit's default owner"
|
|
147
|
-
})
|
|
148
|
-
});
|
|
149
|
-
var Gates = Schema3.Struct({
|
|
150
|
-
ci: Schema3.optionalKey(Schema3.NonEmptyArray(Command).annotate({
|
|
151
|
-
description: "The commands CI runs on every pull request to the default branch, each one plain command"
|
|
152
|
-
})),
|
|
153
|
-
scheduled: Schema3.optionalKey(Schema3.Array(Command).annotate({ description: "The commands a cron-scheduled workflow runs" })),
|
|
154
|
-
lint: Schema3.optionalKey(LintGates)
|
|
155
|
-
});
|
|
156
|
-
var RunsOn = Schema3.NonEmptyArray(Schema3.NonEmptyString).annotate({
|
|
157
|
-
identifier: "RunsOn",
|
|
158
|
-
description: "The runner labels every job the kit generates runs on; ubuntu-latest when absent"
|
|
159
|
-
});
|
|
160
|
-
var EffectSources = Schema3.Struct({
|
|
161
|
-
paths: Schema3.NonEmptyArray(PathGlob).annotate({
|
|
162
|
-
description: "Where source is written in Effect, held to the Effect rules of oxlint and the language service"
|
|
163
|
-
}),
|
|
164
|
-
exempt: Schema3.optionalKey(Schema3.Array(PathGlob).annotate({ description: "Files under paths the Effect rules pass over" }))
|
|
165
|
-
});
|
|
166
|
-
var Library = Schema3.Struct({
|
|
167
|
-
name: Schema3.String.check(Schema3.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a library name in kebab case" })).annotate({ description: "The link checks-vendor manages under repos/" }),
|
|
168
|
-
package: Schema3.NonEmptyString.annotate({ description: "The npm package whose installed version picks the tag" }),
|
|
169
|
-
repository: Schema3.NonEmptyString.annotate({ description: "The git remote checks-vendor clones the tag from" }),
|
|
170
|
-
tag: Schema3.String.check(Schema3.isPattern(/\{version\}/, { expected: "a tag template holding {version}" })).annotate({
|
|
171
|
-
description: "The tag template, with {version} for the installed version"
|
|
172
|
-
}),
|
|
173
|
-
path: Schema3.optionalKey(FilePath.annotate({ description: "The manifest inside the clone holding the version; package.json when absent" }))
|
|
174
|
-
}).annotate({ identifier: "Library" });
|
|
175
|
-
var Sources = Schema3.Struct({
|
|
176
|
-
production: Schema3.optionalKey(Schema3.Array(PathGlob).annotate({ description: "The source the repository ships, as against tests and tooling" })),
|
|
177
|
-
libraries: Schema3.optionalKey(Schema3.Array(Library).check(Schema3.makeFilter((libraries) => {
|
|
178
|
-
const repeated = duplicates(libraries.map((library) => library.name));
|
|
179
|
-
return repeated === undefined || `names ${repeated} more than once`;
|
|
180
|
-
})).annotate({ description: "The libraries checks-vendor pins to a shared read-only clone" })),
|
|
181
|
-
effect: Schema3.optionalKey(EffectSources)
|
|
182
|
-
});
|
|
183
|
-
var Feature = Schema3.Struct({
|
|
184
|
-
name: Schema3.String.check(Schema3.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a feature name in kebab case" })).annotate({ description: "The owner the dependency rule and the change signal name" }),
|
|
185
|
-
root: DirectoryPath.annotate({ description: "The directory the feature owns" }),
|
|
186
|
-
entries: Schema3.NonEmptyArray(FilePath).annotate({
|
|
187
|
-
description: "The files under root that code outside it imports the feature through"
|
|
188
|
-
}),
|
|
189
|
-
allowFrom: Schema3.optionalKey(Schema3.Array(PathGlob).annotate({
|
|
190
|
-
description: "Files outside root that may import past its entries, such as a CLI or a harness; tests/ always may"
|
|
191
|
-
})),
|
|
192
|
-
proof: ProofPath.annotate({ description: "The end-to-end test that imports one of entries" })
|
|
193
|
-
}).check(Schema3.makeFilter(({ root, entries }) => {
|
|
194
|
-
const outside = entries.filter((entry) => !entry.startsWith(`${root}/`));
|
|
195
|
-
return outside.length === 0 || `lists ${outside.join(", ")} among its entries, outside its root ${root}`;
|
|
196
|
-
}));
|
|
197
|
-
function nests(outer, inner) {
|
|
198
|
-
return outer === inner || inner.startsWith(`${outer}/`);
|
|
199
|
-
}
|
|
200
|
-
function duplicates(names) {
|
|
201
|
-
const repeated = names.filter((name, index) => names.indexOf(name) !== index);
|
|
202
|
-
return repeated.length === 0 ? undefined : [...new Set(repeated)].join(", ");
|
|
203
|
-
}
|
|
204
|
-
var Features = Schema3.Array(Feature).check(Schema3.makeFilter((features) => {
|
|
205
|
-
const repeated = duplicates(features.map((feature) => feature.name));
|
|
206
|
-
if (repeated !== undefined)
|
|
207
|
-
return `names ${repeated} more than once`;
|
|
208
|
-
for (const outer of features) {
|
|
209
|
-
const inner = features.find((other) => other !== outer && nests(outer.root, other.root));
|
|
210
|
-
if (inner !== undefined)
|
|
211
|
-
return `gives ${inner.root} to both ${outer.name} and ${inner.name}`;
|
|
212
|
-
}
|
|
213
|
-
return true;
|
|
214
|
-
}));
|
|
215
|
-
var AgentRules = Schema3.Struct({
|
|
216
|
-
on: Schema3.optionalKey(Schema3.Array(RuleName).annotate({ description: "Catalogued Rules switched on here" })),
|
|
217
|
-
off: Schema3.optionalKey(Schema3.Array(RuleName).annotate({ description: "Catalogued Rules switched off here" }))
|
|
218
|
-
}).check(Schema3.makeFilter(({ on = [], off = [] }) => {
|
|
219
|
-
const both = on.filter((rule) => off.includes(rule));
|
|
220
|
-
return both.length === 0 || `switches ${both.join(", ")} both on and off`;
|
|
221
|
-
}));
|
|
222
|
-
var pagesIn = (mode) => Schema3.optionalKey(Schema3.Array(PathGlob).annotate({ description: `The pages written as ${mode}` }));
|
|
223
|
-
var Docs = Schema3.Struct({
|
|
224
|
-
pages: Schema3.optionalKey(Schema3.Struct({
|
|
225
|
-
tutorial: pagesIn("a tutorial, which teaches by building one thing"),
|
|
226
|
-
"how-to": pagesIn("a how-to, which walks one task"),
|
|
227
|
-
reference: pagesIn("reference, which describes a thing to be looked up"),
|
|
228
|
-
explanation: pagesIn("an explanation, which says why")
|
|
229
|
-
}).annotate({
|
|
230
|
-
description: "The Diátaxis mode of each page, whose template checks-docs holds the page to; a page under docs/ needs one"
|
|
231
|
-
})),
|
|
232
|
-
forConsumers: Schema3.optionalKey(Schema3.Array(DocGlob).annotate({
|
|
233
|
-
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"
|
|
234
|
-
}))
|
|
235
|
-
});
|
|
236
|
-
var Quality = Schema3.Struct({
|
|
237
|
-
$schema: Schema3.optionalKey(Schema3.String),
|
|
238
|
-
defaultBranch: Schema3.optionalKey(Schema3.NonEmptyString.annotate({ description: "The branch pull requests merge into; main when absent" })),
|
|
239
|
-
gates: Schema3.optionalKey(Gates),
|
|
240
|
-
runsOn: Schema3.optionalKey(RunsOn),
|
|
241
|
-
commitIdentity: Schema3.optionalKey(CommitIdentity),
|
|
242
|
-
sources: Schema3.optionalKey(Sources),
|
|
243
|
-
size: Schema3.optionalKey(Size),
|
|
244
|
-
features: Schema3.optionalKey(Features.annotate({
|
|
245
|
-
description: "The feature owners dependency-cruiser holds to their entries and checks-feature-owners maps a change to"
|
|
246
|
-
})),
|
|
247
|
-
changeSignal: Schema3.optionalKey(Schema3.Literal("advisory").annotate({
|
|
248
|
-
description: "Report which feature owners a change touches, without failing on it"
|
|
249
|
-
})),
|
|
250
|
-
agentRules: Schema3.optionalKey(AgentRules),
|
|
251
|
-
docs: Schema3.optionalKey(Docs.annotate({ description: "What checks-docs reads to map a doc file to its template" }))
|
|
252
|
-
}).annotate({
|
|
253
|
-
title: QUALITY_FILE,
|
|
254
|
-
description: "What a repository has opted into from @avi2dg/checks, read by its bins and agent Rule selection"
|
|
255
|
-
}).check(Schema3.makeFilter(({ size, sources }) => size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds no production file without sources.production", {
|
|
256
|
-
toJsonSchema: () => ({
|
|
257
|
-
if: { required: ["size"] },
|
|
258
|
-
then: { required: ["sources"], properties: { sources: { required: ["production"], properties: { production: { minItems: 1 } } } } }
|
|
259
|
-
})
|
|
260
|
-
}), Schema3.makeFilter(({ changeSignal, features = [] }) => changeSignal === undefined || features.length > 0 || "declares changeSignal, which maps a change to no owner without features", {
|
|
261
|
-
toJsonSchema: () => ({
|
|
262
|
-
if: { required: ["changeSignal"] },
|
|
263
|
-
then: { required: ["features"], properties: { features: { minItems: 1 } } }
|
|
264
|
-
})
|
|
265
|
-
}));
|
|
266
|
-
var LegacyManifest = Schema3.Struct({
|
|
267
|
-
ciWiring: Schema3.optionalKey(Schema3.Struct({
|
|
268
|
-
gates: Schema3.optionalKey(Schema3.NonEmptyArray(Command)),
|
|
269
|
-
scheduled: Schema3.optionalKey(Schema3.Array(Command)),
|
|
270
|
-
lintGates: Schema3.optionalKey(LintGates),
|
|
271
|
-
defaultBranch: Schema3.optionalKey(Schema3.NonEmptyString)
|
|
272
|
-
})),
|
|
273
|
-
commitIdentity: Schema3.optionalKey(CommitIdentity)
|
|
274
|
-
});
|
|
275
|
-
var LEGACY_KEYS = ["ciWiring", "commitIdentity"];
|
|
276
|
-
|
|
277
|
-
class QualityUnreadable extends Schema3.TaggedError()("QualityUnreadable", {
|
|
278
|
-
message: Schema3.String
|
|
279
|
-
}) {
|
|
280
|
-
}
|
|
281
|
-
var MANIFEST = "package.json";
|
|
282
|
-
var decodeQualityJson = Schema3.decodeUnknownEffect(Schema3.fromJsonString(Quality), { onExcessProperty: "error" });
|
|
283
|
-
var decodeManifestJson = Schema3.decodeUnknownEffect(Schema3.fromJsonString(LegacyManifest));
|
|
284
|
-
var decodeQuality = (text, source) => decodeQualityJson(text).pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })));
|
|
285
|
-
function fromLegacy({ ciWiring, commitIdentity }) {
|
|
286
|
-
const gates = {
|
|
287
|
-
...ciWiring?.gates === undefined ? {} : { ci: ciWiring.gates },
|
|
288
|
-
...ciWiring?.scheduled === undefined ? {} : { scheduled: ciWiring.scheduled },
|
|
289
|
-
...ciWiring?.lintGates === undefined ? {} : { lint: ciWiring.lintGates }
|
|
290
|
-
};
|
|
291
|
-
return {
|
|
292
|
-
...ciWiring?.defaultBranch === undefined ? {} : { defaultBranch: ciWiring.defaultBranch },
|
|
293
|
-
...Object.keys(gates).length === 0 ? {} : { gates },
|
|
294
|
-
...commitIdentity === undefined ? {} : { commitIdentity }
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
var decodeManifest = (text, source) => decodeManifestJson(text).pipe(Effect.map((manifest) => ({
|
|
298
|
-
keys: LEGACY_KEYS.filter((key) => manifest[key] !== undefined),
|
|
299
|
-
quality: fromLegacy(manifest)
|
|
300
|
-
})), Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })));
|
|
301
|
-
var UNDECLARED = { keys: [], quality: {} };
|
|
302
|
-
var readQuality = Effect.fn("readQuality")(function* (root) {
|
|
303
|
-
const fs = yield* FileSystem.FileSystem;
|
|
304
|
-
const path = yield* Path.Path;
|
|
305
|
-
const read = (file) => fs.readFileString(path.join(root, file)).pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `cannot read ${file}: ${cause.message}` })));
|
|
306
|
-
const legacy = (yield* fs.exists(path.join(root, MANIFEST))) ? yield* decodeManifest(yield* read(MANIFEST), MANIFEST) : UNDECLARED;
|
|
307
|
-
const keys = legacy.keys.join(" and ");
|
|
308
|
-
if (yield* fs.exists(path.join(root, QUALITY_FILE))) {
|
|
309
|
-
if (legacy.keys.length > 0) {
|
|
310
|
-
return yield* new QualityUnreadable({
|
|
311
|
-
message: `${MANIFEST} still sets ${keys}, which ${QUALITY_FILE} replaces; move what it holds there`
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
return { source: QUALITY_FILE, quality: yield* decodeQuality(yield* read(QUALITY_FILE), QUALITY_FILE) };
|
|
315
|
-
}
|
|
316
|
-
if (legacy.keys.length > 0) {
|
|
317
|
-
const them = legacy.keys.length === 1 ? "it" : "them";
|
|
318
|
-
yield* Console.error(`${MANIFEST} sets ${keys}, which a later minor release stops reading; move ${them} into ${QUALITY_FILE}`);
|
|
319
|
-
}
|
|
320
|
-
return { source: MANIFEST, quality: legacy.quality };
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
// scripts/feature-rules.ts
|
|
324
|
-
var TESTS = "^tests/";
|
|
325
|
-
function escaped(literal) {
|
|
326
|
-
return literal.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
327
|
-
}
|
|
328
|
-
function globPattern(glob) {
|
|
329
|
-
const segments = glob.split("/");
|
|
330
|
-
const body = segments.map((segment, index) => {
|
|
331
|
-
if (segment === "**")
|
|
332
|
-
return "(?:[^/]+/)*";
|
|
333
|
-
const pattern = segment.split("*").map(escaped).join("[^/]*");
|
|
334
|
-
return index === segments.length - 1 ? pattern : `${pattern}/`;
|
|
335
|
-
});
|
|
336
|
-
return `^${body.join("")}$`;
|
|
337
|
-
}
|
|
338
|
-
function rulesFor(features) {
|
|
339
|
-
return features.map(({ name, root, entries, allowFrom = [] }) => ({
|
|
340
|
-
name: `feature-${name}-entries`,
|
|
341
|
-
severity: "error",
|
|
342
|
-
comment: `Outside ${root}/, ${name} is imported through ${entries.join(", ")}. Import one of those, or list the importer in the feature's allowFrom in quality.json.`,
|
|
343
|
-
from: { pathNot: [`^${escaped(root)}/`, TESTS, ...allowFrom.map(globPattern)] },
|
|
344
|
-
to: { path: `^${escaped(root)}/`, pathNot: entries.map((entry) => `^${escaped(entry)}$`) }
|
|
345
|
-
}));
|
|
346
|
-
}
|
|
347
|
-
var decode = Schema4.decodeUnknownSync(Quality);
|
|
348
|
-
function featureRules(quality) {
|
|
349
|
-
return rulesFor(decode(quality, { onExcessProperty: "error" }).features ?? []);
|
|
350
|
-
}
|
|
351
|
-
export {
|
|
352
|
-
globPattern,
|
|
353
|
-
featureRules
|
|
354
|
-
};
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
# The quality file
|
|
2
|
-
|
|
3
|
-
`quality.json` at the repository root says what the repository has opted into, and a reader looks it up to learn what each key holds and which bin reads it.
|
|
4
|
-
|
|
5
|
-
## Keys
|
|
6
|
-
|
|
7
|
-
The kit's bins find the file at the git root and read it there:
|
|
8
|
-
|
|
9
|
-
```json
|
|
10
|
-
{
|
|
11
|
-
"$schema": "./node_modules/@avi2dg/checks/quality.schema.json",
|
|
12
|
-
"defaultBranch": "main",
|
|
13
|
-
"gates": {
|
|
14
|
-
"ci": ["bun run lint", "bun run typecheck", "bun run test"],
|
|
15
|
-
"scheduled": ["bunx checks-flake --runs 10 --report flake-report.json"]
|
|
16
|
-
},
|
|
17
|
-
"runsOn": ["self-hosted", "Linux", "X64", "winbox"],
|
|
18
|
-
"commitIdentity": { "authors": [{ "name": "avi2d", "email": "avi2dg@gmail.com" }] },
|
|
19
|
-
"sources": {
|
|
20
|
-
"production": ["src/**/*.ts"],
|
|
21
|
-
"effect": { "paths": ["src/**/*.ts"], "exempt": ["src/host/*.ts"] }
|
|
22
|
-
},
|
|
23
|
-
"size": { "applies": "ratchet", "tests": { "fileLines": 800 } },
|
|
24
|
-
"features": [
|
|
25
|
-
{
|
|
26
|
-
"name": "billing",
|
|
27
|
-
"root": "src/billing",
|
|
28
|
-
"entries": ["src/billing/index.ts"],
|
|
29
|
-
"allowFrom": ["src/main.ts"],
|
|
30
|
-
"proof": "tests/e2e/billing.test.ts"
|
|
31
|
-
}
|
|
32
|
-
],
|
|
33
|
-
"changeSignal": "advisory",
|
|
34
|
-
"agentRules": { "on": [], "off": [] },
|
|
35
|
-
"docs": { "pages": { "reference": ["docs/gates/*.md"], "explanation": ["docs/design.md"] } }
|
|
36
|
-
}
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
<!-- generated quality-keys: bun run build writes it from Quality in scripts/quality-file.ts and scripts/doc-blocks.ts -->
|
|
40
|
-
|
|
41
|
-
| Key | Read by | Holds |
|
|
42
|
-
| --- | --- | --- |
|
|
43
|
-
| `defaultBranch` | `checks-lint`, `checks-ci-wiring`, `checks-quality` | the branch pull requests merge into, `main` when absent |
|
|
44
|
-
| `gates.ci` | `checks-ci-wiring`, `checks-quality` | the commands CI runs on every pull request, as [checks-ci-wiring](../gates/checks-ci-wiring.md) says |
|
|
45
|
-
| `gates.scheduled` | `checks-ci-wiring` | the commands a schedule runs |
|
|
46
|
-
| `gates.lint` | `checks-lint`, `checks-ci-wiring` | the gates `checks-lint` runs when not all apply, as [Gate selection](../gates/checks-lint.md#gate-selection) says |
|
|
47
|
-
| `runsOn` | `checks-quality` | the runner labels every job the ci and commitlint workflows run on, `ubuntu-latest` when absent |
|
|
48
|
-
| `commitIdentity.authors` | `checks-commit-identity` | the identities allowed to author and commit, as [checks-commit-identity](../gates/checks-commit-identity.md) says |
|
|
49
|
-
| `sources.production` | `checks-size-budget`, `checks-repetition`, `checks-quality` | the source the repository ships, as [checks-size-budget](../gates/checks-size-budget.md) and [checks-repetition](../gates/checks-repetition.md) say |
|
|
50
|
-
| `sources.effect` | `checks-quality` | the paths held to the Effect rules, and the files under them that are not, as [The Effect rules](effect-rules.md) says |
|
|
51
|
-
| `sources.libraries` | `checks-vendor`, `checks-test-layout` | the libraries pinned to a shared read-only clone, as [checks-vendor](../gates/checks-vendor.md) says |
|
|
52
|
-
| `size` | `checks-size-budget` | the size budget of production and test files, and how a change is held to it, as [checks-size-budget](../gates/checks-size-budget.md) says |
|
|
53
|
-
| `features` | `featureRules`, `checks-feature-owners` | each feature's root, entries, exempt importers and proof, as [checks-feature-owners](../gates/checks-feature-owners.md) says |
|
|
54
|
-
| `changeSignal` | `checks-feature-owners` | `advisory` to list the feature owners a change touches |
|
|
55
|
-
| `agentRules.on` | agent Rule selection, not the kit | catalogued Rules switched on for this repository |
|
|
56
|
-
| `agentRules.off` | agent Rule selection, not the kit | catalogued Rules switched off for this repository |
|
|
57
|
-
| `docs.pages` | `checks-docs` | the Diátaxis mode of each page, by glob, as [checks-docs](../gates/checks-docs.md) says |
|
|
58
|
-
| `docs.forConsumers` | `checks-docs` | the living docs that speak to a repository installing this one, by glob, whose `bun run` commands name that repository's scripts |
|
|
59
|
-
|
|
60
|
-
<!-- end generated quality-keys -->
|
|
61
|
-
|
|
62
|
-
Every key is optional.
|
|
63
|
-
|
|
64
|
-
## Schema
|
|
65
|
-
|
|
66
|
-
The bins decode the file with one Effect `Schema`, and the package ships `quality.schema.json` emitted from that schema.
|
|
67
|
-
The `$schema` line therefore gives an editor the verdict the bins reach, save what no JSON Schema can express across two values, which the bins refuse:
|
|
68
|
-
|
|
69
|
-
- a Rule switched both on and off
|
|
70
|
-
- a feature entry outside its root
|
|
71
|
-
- two features with one name or sharing a root
|
|
72
|
-
|
|
73
|
-
A key the schema does not name is refused, not ignored, so a misspelt `sources` cannot switch the Effect rules off unnoticed.
|
|
74
|
-
|
|
75
|
-
## Globs
|
|
76
|
-
|
|
77
|
-
A glob in `sources` starts at the repository root, names a directory first, uses `*` only within a segment and `**` only as a whole one, and ends in a file name with an extension.
|
|
78
|
-
Those are the globs oxlint, the language service and git all read alike.
|
|
79
|
-
oxlint matches `*.ts` at any depth where the other two match it at the root alone, so the schema refuses it, and `**/*.ts` means every depth to all three.
|
|
80
|
-
The language service matches nothing for `src/**` and oxlint nothing for `src/lib`, so the schema refuses both, and `src/**/*.ts` and `src/lib/*.ts` say it to all three.
|
|
81
|
-
|
|
82
|
-
## Keys moved from package.json
|
|
83
|
-
|
|
84
|
-
A repository with no `quality.json` still has `ciWiring` and `commitIdentity` read from `package.json`, with a notice on each read.
|
|
85
|
-
One with both files exits 2 until `package.json` drops them.
|
|
86
|
-
The keys map one for one:
|
|
87
|
-
|
|
88
|
-
<!-- generated legacy-keys: bun run build writes it from LegacyManifest in scripts/quality-file.ts, QUALITY_FILE in scripts/gates.ts and scripts/doc-blocks.ts -->
|
|
89
|
-
|
|
90
|
-
| `package.json` | `quality.json` |
|
|
91
|
-
| --- | --- |
|
|
92
|
-
| `ciWiring.gates` | `gates.ci` |
|
|
93
|
-
| `ciWiring.scheduled` | `gates.scheduled` |
|
|
94
|
-
| `ciWiring.lintGates` | `gates.lint` |
|
|
95
|
-
| `ciWiring.defaultBranch` | `defaultBranch` |
|
|
96
|
-
| `commitIdentity` | `commitIdentity` |
|
|
97
|
-
|
|
98
|
-
<!-- end generated legacy-keys -->
|
|
99
|
-
|
|
100
|
-
## Related topics
|
|
101
|
-
|
|
102
|
-
- [checks-quality](../gates/checks-quality.md)
|
|
103
|
-
- [Why it is shaped this way](../design.md)
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
# checks-feature-owners
|
|
2
|
-
|
|
3
|
-
`checks-feature-owners` is the gate that holds each declared feature to a runnable proof and lists the feature owners a change touches, and a reader looks it up to declare a feature.
|
|
4
|
-
|
|
5
|
-
## What it checks
|
|
6
|
-
|
|
7
|
-
A repository opts a feature in by declaring, in `quality.json`, the directory it owns, the files code outside it imports it through, the files that may reach past those, and the end-to-end test that proves it runs:
|
|
8
|
-
|
|
9
|
-
```json
|
|
10
|
-
"features": [
|
|
11
|
-
{
|
|
12
|
-
"name": "billing",
|
|
13
|
-
"root": "src/billing",
|
|
14
|
-
"entries": ["src/billing/index.ts"],
|
|
15
|
-
"allowFrom": ["src/main.ts", "src/cli/*.ts"],
|
|
16
|
-
"proof": "tests/e2e/billing.test.ts"
|
|
17
|
-
}
|
|
18
|
-
],
|
|
19
|
-
"changeSignal": "advisory"
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
`root` is a directory and `entries` are files under it, both without globs.
|
|
23
|
-
`allowFrom` holds globs of the same shape as `sources`.
|
|
24
|
-
`proof` is a `.test.ts` or `.test.tsx` file under `tests/e2e/`.
|
|
25
|
-
Nothing moves, since a root is wherever the feature already lives.
|
|
26
|
-
`quality.json` refuses an entry outside its root, a name used twice, and two features sharing a root or one root inside another, so a file has at most one owner.
|
|
27
|
-
|
|
28
|
-
The gate fails when a feature's proof cannot prove it:
|
|
29
|
-
|
|
30
|
-
- the proof or an entry is not in the head commit
|
|
31
|
-
- the proof does not parse
|
|
32
|
-
- the proof imports none of the feature's entries
|
|
33
|
-
|
|
34
|
-
An import counts when it is a runtime `import`, `export ... from` or `export * from` of a relative path that names an entry.
|
|
35
|
-
It names the entry by its own name, by the `.js`, `.jsx`, `.mjs` or `.cjs` spelling of it, or without an extension, the way a directory `index` is imported.
|
|
36
|
-
`.js` names a `.tsx` entry as well as a `.ts` one.
|
|
37
|
-
`import type` does not count, and neither does a path alias.
|
|
38
|
-
The proof runs in `bun run test` like any end-to-end test, which is what shows it passes.
|
|
39
|
-
|
|
40
|
-
With `changeSignal` set to `advisory` it also lists each owner the range touches, with the paths it touched under the owner's root or at its proof, and still passes.
|
|
41
|
-
Whether a change that spans owners is one coherent slice is for a reviewer to judge.
|
|
42
|
-
A rename counts at both of its paths.
|
|
43
|
-
|
|
44
|
-
## What it reads
|
|
45
|
-
|
|
46
|
-
It reads `features` and `changeSignal` from `quality.json`, each proof and entry from the head commit, and the paths the range touches.
|
|
47
|
-
|
|
48
|
-
## Arguments
|
|
49
|
-
|
|
50
|
-
```sh
|
|
51
|
-
checks-feature-owners <base-ref> <head-ref>
|
|
52
|
-
checks-feature-owners <ref>
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
With two arguments the range starts where the head branched from the base, at their merge-base.
|
|
56
|
-
With one it is that commit against its parent, or against the empty tree for a repository's first commit.
|
|
57
|
-
|
|
58
|
-
## Exit codes
|
|
59
|
-
|
|
60
|
-
| Code | When |
|
|
61
|
-
| --- | --- |
|
|
62
|
-
| 0 | every feature's proof imports one of its entries |
|
|
63
|
-
| 1 | a feature's proof cannot prove it |
|
|
64
|
-
| 2 | `quality.json` does not decode, which is where a proof outside `tests/e2e/` is refused, or a ref does not resolve |
|
|
65
|
-
|
|
66
|
-
## Sample output
|
|
67
|
-
|
|
68
|
-
```
|
|
69
|
-
feature-owners: 1 problem(s) with the features' runnable proofs:
|
|
70
|
-
billing: proof tests/e2e/billing.test.ts imports none of its entries, src/billing/index.ts
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
With `changeSignal` set to `advisory`:
|
|
74
|
-
|
|
75
|
-
```
|
|
76
|
-
feature-owners: advisory, the range touches 2 feature owner(s); a reviewer judges whether they make one slice:
|
|
77
|
-
billing: src/billing/charge.ts, src/billing/tax.ts
|
|
78
|
-
invoices: src/invoices/tax.ts, tests/e2e/invoices.test.ts
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
## Opting out
|
|
82
|
-
|
|
83
|
-
A repository that declares no feature passes.
|
|
84
|
-
`quality.json` refuses a `changeSignal` without features, which would map a change to no owner.
|
|
85
|
-
A repository that tracks no `.ts` or `.tsx` file leaves it out of `gates.lint`, as [Gate selection](checks-lint.md#gate-selection) says.
|
|
86
|
-
|
|
87
|
-
## Import boundary
|
|
88
|
-
|
|
89
|
-
`dist/feature-rules.js` compiles `features` into one dependency-cruiser rule per feature, which `.dependency-cruiser.cjs` spreads beside its own:
|
|
90
|
-
|
|
91
|
-
```js
|
|
92
|
-
const { featureRules } = require("@avi2dg/checks/dist/feature-rules.js");
|
|
93
|
-
|
|
94
|
-
module.exports = {
|
|
95
|
-
extends: "./node_modules/@avi2dg/checks/dependency-cruiser.config.js",
|
|
96
|
-
forbidden: [...featureRules(require("./quality.json"))],
|
|
97
|
-
};
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
A module outside a feature's root that imports a file inside it must import one of the feature's `entries`.
|
|
101
|
-
Modules under `tests/` and the files `allowFrom` matches, such as a CLI or a harness, may import any file in it:
|
|
102
|
-
|
|
103
|
-
```
|
|
104
|
-
error feature-billing-entries: src/report.ts → src/billing/charge.ts
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
`featureRules` decodes its argument with the schema the bins use and throws the schema's refusal when it does not decode, which stops the cruise.
|
|
108
|
-
It is an ES module, as `effect` is, so a `.cjs` config loads it through `require`, which needs node 20.19, 22.12 or later.
|
|
109
|
-
|
|
110
|
-
## Related topics
|
|
111
|
-
|
|
112
|
-
- [The dependency rules](../configs/dependency-rules.md)
|
|
113
|
-
- [The quality file](../configs/quality-file.md)
|