@avi2dg/checks 0.15.0 → 0.16.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 +9 -0
- package/README.md +3 -1
- package/dist/feature-rules.js +138 -71
- package/docs/configs/quality-file.md +3 -3
- package/docs/design.md +7 -0
- package/docs/gates/checks-lint.md +4 -3
- package/docs/gates/checks-quality.md +1 -1
- package/docs/gates/checks-repetition.md +72 -0
- package/docs/gates/checks-size-budget.md +57 -14
- package/package.json +7 -1
- package/quality.schema.json +63 -18
- package/scripts/gates.ts +1 -0
- package/scripts/git.ts +19 -1
- package/scripts/quality-file.ts +3 -17
- package/scripts/quality.ts +2 -2
- package/scripts/repetition.ts +161 -0
- package/scripts/size-budget.ts +187 -87
- package/scripts/size-rules.ts +117 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.16.0
|
|
6
|
+
|
|
7
|
+
Released 2026-09-25.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **scripts:** add checks-repetition to hold new repetition in production code (#46)
|
|
12
|
+
- **scripts:** add the recommended size limits, a tests budget and an overrun ratchet (#45)
|
|
13
|
+
|
|
5
14
|
## 0.15.0
|
|
6
15
|
|
|
7
16
|
Released 2026-09-25.
|
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ A repository declares what it opts into once, in `quality.json`, and each check
|
|
|
16
16
|
- `@swc/core` 1.16.2
|
|
17
17
|
- `dependency-cruiser` 18.4.0
|
|
18
18
|
- `effect` 4.0.0-rc.115
|
|
19
|
+
- `jscpd` 5.3.2
|
|
19
20
|
- `oxlint` 1.83.0
|
|
20
21
|
- `oxlint-tsgolint` 7.0.2002
|
|
21
22
|
|
|
@@ -30,7 +31,7 @@ To consume the kit from a repository:
|
|
|
30
31
|
<!-- generated install: bun run build writes it from package.json and scripts/doc-blocks.ts -->
|
|
31
32
|
|
|
32
33
|
```sh
|
|
33
|
-
bun add -d @avi2dg/checks @effect/tsgo@0.45.0 @swc/core@1.16.2 dependency-cruiser@18.4.0 effect@4.0.0-rc.115 oxlint@1.83.0 oxlint-tsgolint@7.0.2002 typescript@7.0.2
|
|
34
|
+
bun add -d @avi2dg/checks @effect/tsgo@0.45.0 @swc/core@1.16.2 dependency-cruiser@18.4.0 effect@4.0.0-rc.115 jscpd@5.3.2 oxlint@1.83.0 oxlint-tsgolint@7.0.2002 typescript@7.0.2
|
|
34
35
|
```
|
|
35
36
|
|
|
36
37
|
<!-- end generated install -->
|
|
@@ -120,6 +121,7 @@ A repository leaves out a gate that does not apply to it through `gates.lint`, a
|
|
|
120
121
|
| [`checks-docs`](docs/gates/checks-docs.md) | the range | every repository |
|
|
121
122
|
| [`checks-quality`](docs/gates/checks-quality.md) | the working tree | a repository tracking `quality.json` |
|
|
122
123
|
| [`checks-size-budget`](docs/gates/checks-size-budget.md) | the range | a repository tracking `*.ts` or `*.tsx` |
|
|
124
|
+
| [`checks-repetition`](docs/gates/checks-repetition.md) | the range | a repository tracking `*.ts` or `*.tsx` |
|
|
123
125
|
| [`checks-feature-owners`](docs/gates/checks-feature-owners.md) | the range | a repository tracking `*.ts` or `*.tsx` |
|
|
124
126
|
|
|
125
127
|
<!-- end generated gates -->
|
package/dist/feature-rules.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// scripts/feature-rules.ts
|
|
2
|
-
import { Schema as
|
|
2
|
+
import { Schema as Schema4 } from "effect";
|
|
3
3
|
|
|
4
4
|
// scripts/quality-file.ts
|
|
5
|
-
import { Console, Effect, FileSystem, JsonSchema, Path, Schema as
|
|
5
|
+
import { Console, Effect, FileSystem, JsonSchema, Path, Schema as Schema3 } from "effect";
|
|
6
6
|
|
|
7
7
|
// scripts/gates.ts
|
|
8
8
|
import { Schema } from "effect";
|
|
@@ -20,6 +20,7 @@ var KIT_GATES = [
|
|
|
20
20
|
{ bin: "checks-docs", script: "docs.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
21
21
|
{ bin: "checks-quality", script: "quality.ts", reads: "tree", args: ["--check"], appliesTo: QUALITY_DECLARATION },
|
|
22
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 },
|
|
23
24
|
{ bin: "checks-feature-owners", script: "feature-owners.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE }
|
|
24
25
|
];
|
|
25
26
|
var UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
|
|
@@ -31,87 +32,153 @@ var LintGates = Schema.Array(Schema.Literals(KIT_GATES.map((gate) => gate.bin)))
|
|
|
31
32
|
return `checks-lint must run ${missing.join(", ")}, which ${verb} to ${EVERY_REPOSITORY}`;
|
|
32
33
|
}, { toJsonSchema: () => ({ allOf: UNCONDITIONAL.map((bin) => ({ contains: { const: bin } })) }) }));
|
|
33
34
|
|
|
35
|
+
// scripts/size-rules.ts
|
|
36
|
+
import { Schema as Schema2 } from "effect";
|
|
37
|
+
var TESTS_DIRECTORY = "tests";
|
|
38
|
+
var COUNTED = { skipBlankLines: false, skipComments: false };
|
|
39
|
+
var FILE_LINES = {
|
|
40
|
+
key: "fileLines",
|
|
41
|
+
rule: "max-lines",
|
|
42
|
+
options: COUNTED,
|
|
43
|
+
measured: /has too many lines \((\d+)\)/,
|
|
44
|
+
limits: "The most lines a file may hold, blank and comment lines counted"
|
|
45
|
+
};
|
|
46
|
+
var FUNCTION_LINES = {
|
|
47
|
+
key: "functionLines",
|
|
48
|
+
rule: "max-lines-per-function",
|
|
49
|
+
options: COUNTED,
|
|
50
|
+
measured: /has too many lines \((\d+)\)/,
|
|
51
|
+
limits: "The most lines a function may span, blank and comment lines counted"
|
|
52
|
+
};
|
|
53
|
+
var STATEMENTS = {
|
|
54
|
+
key: "statements",
|
|
55
|
+
rule: "max-statements",
|
|
56
|
+
options: {},
|
|
57
|
+
measured: /has too many statements \((\d+)\)/,
|
|
58
|
+
limits: "The most statements a function may hold"
|
|
59
|
+
};
|
|
60
|
+
var COMPLEXITY = {
|
|
61
|
+
key: "complexity",
|
|
62
|
+
rule: "complexity",
|
|
63
|
+
options: { variant: "modified" },
|
|
64
|
+
measured: /has a complexity of (\d+)/,
|
|
65
|
+
limits: "The highest cyclomatic complexity a function may reach, a switch counted once"
|
|
66
|
+
};
|
|
67
|
+
var DEPTH = {
|
|
68
|
+
key: "depth",
|
|
69
|
+
rule: "max-depth",
|
|
70
|
+
options: {},
|
|
71
|
+
measured: /nested too deeply \((\d+)\)/,
|
|
72
|
+
limits: "The deepest a block may nest inside a function"
|
|
73
|
+
};
|
|
74
|
+
var APPLIES = ["ratchet", "all"];
|
|
75
|
+
var SIZE_DEFAULTS = {
|
|
76
|
+
applies: "ratchet",
|
|
77
|
+
production: { fileLines: 400, functionLines: 100, statements: 30, complexity: 15, depth: 4 },
|
|
78
|
+
tests: { fileLines: 600, statements: 50, complexity: 15, depth: 4 }
|
|
79
|
+
};
|
|
80
|
+
var Limit = Schema2.Int.check(Schema2.isGreaterThan(0));
|
|
81
|
+
function limit({ limits }, fallback) {
|
|
82
|
+
return Schema2.optionalKey(Limit.annotate({ description: `${limits}; ${fallback} when absent` }));
|
|
83
|
+
}
|
|
84
|
+
var ProductionBudget = Schema2.Struct({
|
|
85
|
+
fileLines: limit(FILE_LINES, SIZE_DEFAULTS.production.fileLines),
|
|
86
|
+
functionLines: limit(FUNCTION_LINES, SIZE_DEFAULTS.production.functionLines),
|
|
87
|
+
statements: limit(STATEMENTS, SIZE_DEFAULTS.production.statements),
|
|
88
|
+
complexity: limit(COMPLEXITY, SIZE_DEFAULTS.production.complexity),
|
|
89
|
+
depth: limit(DEPTH, SIZE_DEFAULTS.production.depth)
|
|
90
|
+
});
|
|
91
|
+
var TestBudget = Schema2.Struct({
|
|
92
|
+
fileLines: limit(FILE_LINES, SIZE_DEFAULTS.tests.fileLines),
|
|
93
|
+
statements: limit(STATEMENTS, SIZE_DEFAULTS.tests.statements),
|
|
94
|
+
complexity: limit(COMPLEXITY, SIZE_DEFAULTS.tests.complexity),
|
|
95
|
+
depth: limit(DEPTH, SIZE_DEFAULTS.tests.depth)
|
|
96
|
+
});
|
|
97
|
+
var Size = Schema2.Struct({
|
|
98
|
+
applies: Schema2.optionalKey(Schema2.Literals(APPLIES).annotate({
|
|
99
|
+
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`,
|
|
100
|
+
message: "Expected ratchet or all, and ratchet replaces changed"
|
|
101
|
+
})),
|
|
102
|
+
production: Schema2.optionalKey(ProductionBudget.annotate({
|
|
103
|
+
description: `The budget of the files under sources.production, and of the files listed as advisory outside ${TESTS_DIRECTORY}/`
|
|
104
|
+
})),
|
|
105
|
+
tests: Schema2.optionalKey(TestBudget.annotate({ description: `The budget of the files under ${TESTS_DIRECTORY}/, which sets no limit on a function's lines` }))
|
|
106
|
+
}).annotate({
|
|
107
|
+
description: "The size budget oxlint holds production and test files to, read by checks-size-budget",
|
|
108
|
+
messageUnexpectedKey: "Expected only applies, production and tests, since each limit is set inside production or tests"
|
|
109
|
+
});
|
|
110
|
+
|
|
34
111
|
// scripts/quality-file.ts
|
|
35
112
|
var SEGMENT = String.raw`(?!\.\.?(?:/|$))(?:\*\*|(?:[\w.@+-]|\*(?!\*))+)`;
|
|
36
113
|
var FILE = String.raw`(?:[\w.@+-]|\*(?!\*))*\.\w+`;
|
|
37
|
-
var PathGlob =
|
|
114
|
+
var PathGlob = Schema3.String.check(Schema3.isPattern(new RegExp(`^${SEGMENT}(?:/${SEGMENT})*/${FILE}$`), {
|
|
38
115
|
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"
|
|
39
116
|
})).annotate({
|
|
40
117
|
identifier: "PathGlob",
|
|
41
118
|
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 ./"
|
|
42
119
|
});
|
|
43
|
-
var DocGlob =
|
|
120
|
+
var DocGlob = Schema3.String.check(Schema3.isPattern(new RegExp(`^(?:${SEGMENT}/)*${FILE}$`), {
|
|
44
121
|
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"
|
|
45
122
|
})).annotate({
|
|
46
123
|
identifier: "DocGlob",
|
|
47
124
|
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"
|
|
48
125
|
});
|
|
49
126
|
var LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
|
|
50
|
-
var DirectoryPath =
|
|
127
|
+
var DirectoryPath = Schema3.String.check(Schema3.isPattern(new RegExp(`^${LITERAL_SEGMENT}(?:/${LITERAL_SEGMENT})*$`), {
|
|
51
128
|
expected: "a directory from the repository root such as src/billing, with no glob and no trailing slash"
|
|
52
129
|
})).annotate({ identifier: "DirectoryPath" });
|
|
53
|
-
var FilePath =
|
|
130
|
+
var FilePath = Schema3.String.check(Schema3.isPattern(new RegExp(`^(?:${LITERAL_SEGMENT}/)*[\\w.@+-]*\\.\\w+$`), {
|
|
54
131
|
expected: "a file from the repository root such as src/billing/index.ts, with no glob"
|
|
55
132
|
})).annotate({ identifier: "FilePath" });
|
|
56
133
|
var PROOF_DIRECTORY = "tests/e2e/";
|
|
57
|
-
var ProofPath =
|
|
134
|
+
var ProofPath = Schema3.String.check(Schema3.isPattern(new RegExp(`^${PROOF_DIRECTORY}(?:${LITERAL_SEGMENT}/)*[\\w.@+-]+\\.test\\.tsx?$`), {
|
|
58
135
|
expected: `a test file under ${PROOF_DIRECTORY} such as ${PROOF_DIRECTORY}billing.test.ts`
|
|
59
136
|
})).annotate({ identifier: "ProofPath" });
|
|
60
|
-
var Command =
|
|
61
|
-
var RuleName =
|
|
62
|
-
var Identity =
|
|
137
|
+
var Command = Schema3.NonEmptyString.annotate({ identifier: "Command" });
|
|
138
|
+
var RuleName = Schema3.String.check(Schema3.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a Rule name in kebab case" })).annotate({ identifier: "RuleName" });
|
|
139
|
+
var Identity = Schema3.Struct({ name: Schema3.NonEmptyString, email: Schema3.NonEmptyString }).annotate({
|
|
63
140
|
identifier: "Identity"
|
|
64
141
|
});
|
|
65
|
-
var CommitIdentity =
|
|
66
|
-
authors:
|
|
142
|
+
var CommitIdentity = Schema3.Struct({
|
|
143
|
+
authors: Schema3.NonEmptyArray(Identity).annotate({
|
|
67
144
|
description: "The identities allowed to author and commit, in place of the kit's default owner"
|
|
68
145
|
})
|
|
69
146
|
});
|
|
70
|
-
var Gates =
|
|
71
|
-
ci:
|
|
147
|
+
var Gates = Schema3.Struct({
|
|
148
|
+
ci: Schema3.optionalKey(Schema3.NonEmptyArray(Command).annotate({
|
|
72
149
|
description: "The commands CI runs on every pull request to the default branch, each one plain command"
|
|
73
150
|
})),
|
|
74
|
-
scheduled:
|
|
75
|
-
lint:
|
|
151
|
+
scheduled: Schema3.optionalKey(Schema3.Array(Command).annotate({ description: "The commands a cron-scheduled workflow runs" })),
|
|
152
|
+
lint: Schema3.optionalKey(LintGates)
|
|
76
153
|
});
|
|
77
|
-
var EffectSources =
|
|
78
|
-
paths:
|
|
154
|
+
var EffectSources = Schema3.Struct({
|
|
155
|
+
paths: Schema3.NonEmptyArray(PathGlob).annotate({
|
|
79
156
|
description: "Where source is written in Effect, held to the Effect rules of oxlint and the language service"
|
|
80
157
|
}),
|
|
81
|
-
exempt:
|
|
158
|
+
exempt: Schema3.optionalKey(Schema3.Array(PathGlob).annotate({ description: "Files under paths the Effect rules pass over" }))
|
|
82
159
|
});
|
|
83
|
-
var Sources =
|
|
84
|
-
production:
|
|
85
|
-
effect:
|
|
86
|
-
});
|
|
87
|
-
var LineBudget = Schema2.Int.check(Schema2.isGreaterThan(0));
|
|
88
|
-
var Size = Schema2.Struct({
|
|
89
|
-
fileLines: LineBudget.annotate({ description: "The most lines a file may hold, blank and comment lines counted" }),
|
|
90
|
-
functionLines: LineBudget.annotate({
|
|
91
|
-
description: "The most lines a function may span, blank and comment lines counted"
|
|
92
|
-
}),
|
|
93
|
-
applies: Schema2.Literals(["changed", "all"]).annotate({
|
|
94
|
-
description: "Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory"
|
|
95
|
-
})
|
|
160
|
+
var Sources = Schema3.Struct({
|
|
161
|
+
production: Schema3.optionalKey(Schema3.Array(PathGlob).annotate({ description: "The source the repository ships, as against tests and tooling" })),
|
|
162
|
+
effect: Schema3.optionalKey(EffectSources)
|
|
96
163
|
});
|
|
97
|
-
var Feature =
|
|
98
|
-
name:
|
|
164
|
+
var Feature = Schema3.Struct({
|
|
165
|
+
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" }),
|
|
99
166
|
root: DirectoryPath.annotate({ description: "The directory the feature owns" }),
|
|
100
|
-
entries:
|
|
167
|
+
entries: Schema3.NonEmptyArray(FilePath).annotate({
|
|
101
168
|
description: "The files under root that code outside it imports the feature through"
|
|
102
169
|
}),
|
|
103
|
-
allowFrom:
|
|
170
|
+
allowFrom: Schema3.optionalKey(Schema3.Array(PathGlob).annotate({
|
|
104
171
|
description: "Files outside root that may import past its entries, such as a CLI or a harness; tests/ always may"
|
|
105
172
|
})),
|
|
106
173
|
proof: ProofPath.annotate({ description: "The end-to-end test that imports one of entries" })
|
|
107
|
-
}).check(
|
|
174
|
+
}).check(Schema3.makeFilter(({ root, entries }) => {
|
|
108
175
|
const outside = entries.filter((entry) => !entry.startsWith(`${root}/`));
|
|
109
176
|
return outside.length === 0 || `lists ${outside.join(", ")} among its entries, outside its root ${root}`;
|
|
110
177
|
}));
|
|
111
178
|
function nests(outer, inner) {
|
|
112
179
|
return outer === inner || inner.startsWith(`${outer}/`);
|
|
113
180
|
}
|
|
114
|
-
var Features =
|
|
181
|
+
var Features = Schema3.Array(Feature).check(Schema3.makeFilter((features) => {
|
|
115
182
|
const names = features.map((feature) => feature.name);
|
|
116
183
|
const repeated = names.filter((name, index) => names.indexOf(name) !== index);
|
|
117
184
|
if (repeated.length > 0)
|
|
@@ -123,16 +190,16 @@ var Features = Schema2.Array(Feature).check(Schema2.makeFilter((features) => {
|
|
|
123
190
|
}
|
|
124
191
|
return true;
|
|
125
192
|
}));
|
|
126
|
-
var AgentRules =
|
|
127
|
-
on:
|
|
128
|
-
off:
|
|
129
|
-
}).check(
|
|
193
|
+
var AgentRules = Schema3.Struct({
|
|
194
|
+
on: Schema3.optionalKey(Schema3.Array(RuleName).annotate({ description: "Catalogued Rules switched on here" })),
|
|
195
|
+
off: Schema3.optionalKey(Schema3.Array(RuleName).annotate({ description: "Catalogued Rules switched off here" }))
|
|
196
|
+
}).check(Schema3.makeFilter(({ on = [], off = [] }) => {
|
|
130
197
|
const both = on.filter((rule) => off.includes(rule));
|
|
131
198
|
return both.length === 0 || `switches ${both.join(", ")} both on and off`;
|
|
132
199
|
}));
|
|
133
|
-
var pagesIn = (mode) =>
|
|
134
|
-
var Docs =
|
|
135
|
-
pages:
|
|
200
|
+
var pagesIn = (mode) => Schema3.optionalKey(Schema3.Array(PathGlob).annotate({ description: `The pages written as ${mode}` }));
|
|
201
|
+
var Docs = Schema3.Struct({
|
|
202
|
+
pages: Schema3.optionalKey(Schema3.Struct({
|
|
136
203
|
tutorial: pagesIn("a tutorial, which teaches by building one thing"),
|
|
137
204
|
"how-to": pagesIn("a how-to, which walks one task"),
|
|
138
205
|
reference: pagesIn("reference, which describes a thing to be looked up"),
|
|
@@ -140,57 +207,57 @@ var Docs = Schema2.Struct({
|
|
|
140
207
|
}).annotate({
|
|
141
208
|
description: "The Diátaxis mode of each page, whose template checks-docs holds the page to; a page under docs/ needs one"
|
|
142
209
|
})),
|
|
143
|
-
forConsumers:
|
|
210
|
+
forConsumers: Schema3.optionalKey(Schema3.Array(DocGlob).annotate({
|
|
144
211
|
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"
|
|
145
212
|
}))
|
|
146
213
|
});
|
|
147
|
-
var Quality =
|
|
148
|
-
$schema:
|
|
149
|
-
defaultBranch:
|
|
150
|
-
gates:
|
|
151
|
-
commitIdentity:
|
|
152
|
-
sources:
|
|
153
|
-
size:
|
|
154
|
-
features:
|
|
214
|
+
var Quality = Schema3.Struct({
|
|
215
|
+
$schema: Schema3.optionalKey(Schema3.String),
|
|
216
|
+
defaultBranch: Schema3.optionalKey(Schema3.NonEmptyString.annotate({ description: "The branch pull requests merge into; main when absent" })),
|
|
217
|
+
gates: Schema3.optionalKey(Gates),
|
|
218
|
+
commitIdentity: Schema3.optionalKey(CommitIdentity),
|
|
219
|
+
sources: Schema3.optionalKey(Sources),
|
|
220
|
+
size: Schema3.optionalKey(Size),
|
|
221
|
+
features: Schema3.optionalKey(Features.annotate({
|
|
155
222
|
description: "The feature owners dependency-cruiser holds to their entries and checks-feature-owners maps a change to"
|
|
156
223
|
})),
|
|
157
|
-
changeSignal:
|
|
224
|
+
changeSignal: Schema3.optionalKey(Schema3.Literal("advisory").annotate({
|
|
158
225
|
description: "Report which feature owners a change touches, without failing on it"
|
|
159
226
|
})),
|
|
160
|
-
agentRules:
|
|
161
|
-
docs:
|
|
227
|
+
agentRules: Schema3.optionalKey(AgentRules),
|
|
228
|
+
docs: Schema3.optionalKey(Docs.annotate({ description: "What checks-docs reads to map a doc file to its template" }))
|
|
162
229
|
}).annotate({
|
|
163
230
|
title: QUALITY_FILE,
|
|
164
231
|
description: "What a repository has opted into from @avi2dg/checks, read by its bins and agent Rule selection"
|
|
165
|
-
}).check(
|
|
232
|
+
}).check(Schema3.makeFilter(({ size, sources }) => size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds no production file without sources.production", {
|
|
166
233
|
toJsonSchema: () => ({
|
|
167
234
|
if: { required: ["size"] },
|
|
168
235
|
then: { required: ["sources"], properties: { sources: { required: ["production"], properties: { production: { minItems: 1 } } } } }
|
|
169
236
|
})
|
|
170
|
-
}),
|
|
237
|
+
}), Schema3.makeFilter(({ changeSignal, features = [] }) => changeSignal === undefined || features.length > 0 || "declares changeSignal, which maps a change to no owner without features", {
|
|
171
238
|
toJsonSchema: () => ({
|
|
172
239
|
if: { required: ["changeSignal"] },
|
|
173
240
|
then: { required: ["features"], properties: { features: { minItems: 1 } } }
|
|
174
241
|
})
|
|
175
242
|
}));
|
|
176
|
-
var LegacyManifest =
|
|
177
|
-
ciWiring:
|
|
178
|
-
gates:
|
|
179
|
-
scheduled:
|
|
180
|
-
lintGates:
|
|
181
|
-
defaultBranch:
|
|
243
|
+
var LegacyManifest = Schema3.Struct({
|
|
244
|
+
ciWiring: Schema3.optionalKey(Schema3.Struct({
|
|
245
|
+
gates: Schema3.optionalKey(Schema3.NonEmptyArray(Command)),
|
|
246
|
+
scheduled: Schema3.optionalKey(Schema3.Array(Command)),
|
|
247
|
+
lintGates: Schema3.optionalKey(LintGates),
|
|
248
|
+
defaultBranch: Schema3.optionalKey(Schema3.NonEmptyString)
|
|
182
249
|
})),
|
|
183
|
-
commitIdentity:
|
|
250
|
+
commitIdentity: Schema3.optionalKey(CommitIdentity)
|
|
184
251
|
});
|
|
185
252
|
var LEGACY_KEYS = ["ciWiring", "commitIdentity"];
|
|
186
253
|
|
|
187
|
-
class QualityUnreadable extends
|
|
188
|
-
message:
|
|
254
|
+
class QualityUnreadable extends Schema3.TaggedError()("QualityUnreadable", {
|
|
255
|
+
message: Schema3.String
|
|
189
256
|
}) {
|
|
190
257
|
}
|
|
191
258
|
var MANIFEST = "package.json";
|
|
192
|
-
var decodeQualityJson =
|
|
193
|
-
var decodeManifestJson =
|
|
259
|
+
var decodeQualityJson = Schema3.decodeUnknownEffect(Schema3.fromJsonString(Quality), { onExcessProperty: "error" });
|
|
260
|
+
var decodeManifestJson = Schema3.decodeUnknownEffect(Schema3.fromJsonString(LegacyManifest));
|
|
194
261
|
var decodeQuality = (text, source) => decodeQualityJson(text).pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })));
|
|
195
262
|
function fromLegacy({ ciWiring, commitIdentity }) {
|
|
196
263
|
const gates = {
|
|
@@ -254,7 +321,7 @@ function rulesFor(features) {
|
|
|
254
321
|
to: { path: `^${escaped(root)}/`, pathNot: entries.map((entry) => `^${escaped(entry)}$`) }
|
|
255
322
|
}));
|
|
256
323
|
}
|
|
257
|
-
var decode =
|
|
324
|
+
var decode = Schema4.decodeUnknownSync(Quality);
|
|
258
325
|
function featureRules(quality) {
|
|
259
326
|
return rulesFor(decode(quality, { onExcessProperty: "error" }).features ?? []);
|
|
260
327
|
}
|
|
@@ -19,7 +19,7 @@ The kit's bins find the file at the git root and read it there:
|
|
|
19
19
|
"production": ["src/**/*.ts"],
|
|
20
20
|
"effect": { "paths": ["src/**/*.ts"], "exempt": ["src/host/*.ts"] }
|
|
21
21
|
},
|
|
22
|
-
"size": { "
|
|
22
|
+
"size": { "applies": "ratchet", "tests": { "fileLines": 800 } },
|
|
23
23
|
"features": [
|
|
24
24
|
{
|
|
25
25
|
"name": "billing",
|
|
@@ -44,9 +44,9 @@ The kit's bins find the file at the git root and read it there:
|
|
|
44
44
|
| `gates.scheduled` | `checks-ci-wiring` | the commands a schedule runs |
|
|
45
45
|
| `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 |
|
|
46
46
|
| `commitIdentity.authors` | `checks-commit-identity` | the identities allowed to author and commit, as [checks-commit-identity](../gates/checks-commit-identity.md) says |
|
|
47
|
-
| `sources.production` | `checks-size-budget`, `checks-quality` | the source the repository ships, as [checks-size-budget](../gates/checks-size-budget.md)
|
|
47
|
+
| `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 |
|
|
48
48
|
| `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 |
|
|
49
|
-
| `size` | `checks-size-budget` | the
|
|
49
|
+
| `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 |
|
|
50
50
|
| `features` | `featureRules`, `checks-feature-owners` | each feature's root, entries, exempt importers and proof, as [checks-feature-owners](../gates/checks-feature-owners.md) says |
|
|
51
51
|
| `changeSignal` | `checks-feature-owners` | `advisory` to list the feature owners a change touches |
|
|
52
52
|
| `agentRules.on` | agent Rule selection, not the kit | catalogued Rules switched on for this repository |
|
package/docs/design.md
CHANGED
|
@@ -14,6 +14,13 @@ Each entry below is a choice in the kit's shape and the constraint that forced i
|
|
|
14
14
|
- `featureRules` ships compiled as `dist/feature-rules.js` for the same reason, with `effect` left out of the bundle so it resolves the consumer's own copy.
|
|
15
15
|
dependency-cruiser uses a config's export as it is and never awaits it, so the declaration decodes synchronously, and `quality.json` exempts that one file from the Effect rules.
|
|
16
16
|
- `checks-size-budget` writes the head commit's files to a temporary directory and runs oxlint there, with a configuration that sets no plugin and turns every category off, so the consumer's own `.oxlintrc.json`, its ignore files and its other rules never reach the count.
|
|
17
|
+
- `checks-size-budget` ratchets against the base of the range rather than a committed baseline such as `oxlint-suppressions.json`.
|
|
18
|
+
A suppression file stores a count of sites per file and rule, and `max-lines` reports a file once however long it grows, so the count stays at one while the file doubles.
|
|
19
|
+
The gate sums how far each site runs over its limit instead, which grows with the file.
|
|
20
|
+
The base commit already holds that sum, so nothing is generated, committed or pruned.
|
|
21
|
+
- `checks-repetition` writes the production files of both ends of the range to temporary directories and runs jscpd in each, so the consumer's `.jscpd.json` and ignore files never reach the count.
|
|
22
|
+
It compares each file's count of repeated lines rather than using jscpd's `--baseline-from-ref`.
|
|
23
|
+
That flag reports a repeated block as new once its text changes, so a change that shortens a grandfathered block would fail.
|
|
17
24
|
- `dist/` is committed.
|
|
18
25
|
No `prepack` or `prepublishOnly` builds it, so a publish ships whatever bundle the publishing worktree holds.
|
|
19
26
|
Rebuild it after pulling with `bun run build`.
|
|
@@ -64,7 +64,7 @@ It prints the range, the declared selection if there is one, each gate's own rep
|
|
|
64
64
|
```
|
|
65
65
|
checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
|
|
66
66
|
...
|
|
67
|
-
checks-lint: 3 of
|
|
67
|
+
checks-lint: 3 of 11 gate(s) failed: checks-commit-identity, checks-comment-gate, checks-suppressions-ratchet
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
## Opting out
|
|
@@ -74,7 +74,7 @@ A repository that runs its gates without `checks-lint` calls each gate's bin in
|
|
|
74
74
|
|
|
75
75
|
## Gate selection
|
|
76
76
|
|
|
77
|
-
A repository with no TypeScript source gives `checks-lint-coverage`, `checks-test-layout`, `checks-size-budget` and `checks-feature-owners` nothing to check, and test-layout would still refuse its missing `bun test` script and `bunfig.toml`.
|
|
77
|
+
A repository with no TypeScript source gives `checks-lint-coverage`, `checks-test-layout`, `checks-size-budget`, `checks-repetition` and `checks-feature-owners` nothing to check, and test-layout would still refuse its missing `bun test` script and `bunfig.toml`.
|
|
78
78
|
It declares the gates `checks-lint` runs as `gates.lint`:
|
|
79
79
|
|
|
80
80
|
```json
|
|
@@ -100,10 +100,11 @@ Both `checks-lint` and `checks-ci-wiring` exit 2 on a `gates.lint` that names an
|
|
|
100
100
|
`checks-ci-wiring` exits 1 when the selection leaves out a gate the repository's tracked files make applicable, and names the gate and the files:
|
|
101
101
|
|
|
102
102
|
```
|
|
103
|
-
ci-wiring: quality.json gates.lint leaves out
|
|
103
|
+
ci-wiring: quality.json gates.lint leaves out 5 gate(s) this repository's contents make applicable:
|
|
104
104
|
checks-lint-coverage: the repository tracks TypeScript source (src/widget.ts)
|
|
105
105
|
checks-test-layout: the repository tracks TypeScript source (src/widget.ts)
|
|
106
106
|
checks-size-budget: the repository tracks TypeScript source (src/widget.ts)
|
|
107
|
+
checks-repetition: the repository tracks TypeScript source (src/widget.ts)
|
|
107
108
|
checks-feature-owners: the repository tracks TypeScript source (src/widget.ts)
|
|
108
109
|
```
|
|
109
110
|
|
|
@@ -34,7 +34,7 @@ A rule only this repository needs stays in its own `.oxlintrc.json`, whose overr
|
|
|
34
34
|
- A fragment is missing, or differs from what `generate` would write from `quality.json` and the installed kit's presets.
|
|
35
35
|
- A fragment is left over once `quality.json` stops declaring `sources.effect`.
|
|
36
36
|
- `.oxlintrc.json` or `tsconfig.json` does not list its fragment in `extends`, so the tool never reads it.
|
|
37
|
-
- A `sources.effect.paths`
|
|
37
|
+
- A `sources.effect.paths` or `sources.production` glob matches no tracked or untracked file, so it holds nothing.
|
|
38
38
|
|
|
39
39
|
Two details of the fragments are easy to get wrong, so the kit's tests pin both:
|
|
40
40
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# checks-repetition
|
|
2
|
+
|
|
3
|
+
`checks-repetition` is the gate that fails a change adding repeated lines to production code, and a reader looks it up when a production file repeats more lines than it did where the range starts.
|
|
4
|
+
|
|
5
|
+
## What it checks
|
|
6
|
+
|
|
7
|
+
It runs jscpd over the files `quality.json` declares as production:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
"sources": { "production": ["src/**/*.ts"] }
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
jscpd finds each block of at least 50 tokens and 5 lines that appears twice, within one file or across two.
|
|
14
|
+
A repeated line is a line of a file inside such a block.
|
|
15
|
+
The gate counts the repeated lines of each production file at the head and where the range starts, and fails when a file counts more at the head.
|
|
16
|
+
A renamed file is compared with its count under the old path.
|
|
17
|
+
A block counts only when both copies are in production files, so a test that copies production code changes no count.
|
|
18
|
+
Each file whose count rose is listed with the blocks it repeats and where the other copy is.
|
|
19
|
+
|
|
20
|
+
Every other file that repeats lines is listed as advisory and never fails the gate.
|
|
21
|
+
The advisory list names the production files whose count did not rise, and every other tracked `.ts` or `.tsx` file that repeats a block within itself or from another such file.
|
|
22
|
+
`.d.ts` files are not measured.
|
|
23
|
+
Nothing is committed as a baseline, because each run measures where the range starts as well as the head.
|
|
24
|
+
|
|
25
|
+
## What it reads
|
|
26
|
+
|
|
27
|
+
It reads each file from the commits at the two ends of the range rather than the working tree, so an uncommitted edit neither fails nor passes a range, and a pull request's merge checkout measures what the pull request holds.
|
|
28
|
+
It reads `sources.production` from `quality.json`.
|
|
29
|
+
jscpd must be on `PATH`, as it is under a package script.
|
|
30
|
+
|
|
31
|
+
## Arguments
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
checks-repetition <base-ref> <head-ref>
|
|
35
|
+
checks-repetition <ref>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
With two arguments the range starts where the head branched from the base, at their merge-base.
|
|
39
|
+
With one it is that commit against its parent, or against the empty tree for a repository's first commit.
|
|
40
|
+
|
|
41
|
+
## Exit codes
|
|
42
|
+
|
|
43
|
+
| Code | When |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| 0 | no production file repeats more lines than where the range starts |
|
|
46
|
+
| 1 | a production file repeats more lines than where the range starts |
|
|
47
|
+
| 2 | `quality.json` does not decode, a ref does not resolve, or jscpd cannot run |
|
|
48
|
+
|
|
49
|
+
## Sample output
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
repetition: 1 production file(s) repeat more lines than where the range starts, at 50 tokens and 5 lines:
|
|
53
|
+
src/billing/refund.ts: 11 repeated line(s), up from 0
|
|
54
|
+
src/billing/refund.ts:1-11 repeats src/billing/legacy.ts:1-11
|
|
55
|
+
repetition: advisory, 4 file(s) repeat lines the hold does not fail:
|
|
56
|
+
src/billing/ledger.ts: 11 repeated line(s)
|
|
57
|
+
src/billing/legacy.ts: 21 repeated line(s)
|
|
58
|
+
tests/one.test.ts: 11 repeated line(s)
|
|
59
|
+
tests/two.test.ts: 11 repeated line(s)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Opting out
|
|
63
|
+
|
|
64
|
+
A repository that declares no `sources.production` passes.
|
|
65
|
+
`checks-quality` refuses a `sources.production` glob that matches no file.
|
|
66
|
+
A repository that tracks no `.ts` or `.tsx` file leaves it out of `gates.lint`, as [Gate selection](checks-lint.md#gate-selection) says.
|
|
67
|
+
|
|
68
|
+
## Related topics
|
|
69
|
+
|
|
70
|
+
- [The quality file](../configs/quality-file.md)
|
|
71
|
+
- [checks-size-budget](checks-size-budget.md)
|
|
72
|
+
- [checks-lint](checks-lint.md)
|
|
@@ -1,26 +1,66 @@
|
|
|
1
1
|
# checks-size-budget
|
|
2
2
|
|
|
3
|
-
`checks-size-budget` is the gate that holds production files to the
|
|
3
|
+
`checks-size-budget` is the gate that holds production and test files to the size budget `quality.json` declares, and a reader looks it up when a file or a function runs over it.
|
|
4
4
|
|
|
5
5
|
## What it checks
|
|
6
6
|
|
|
7
|
-
It holds production files to the
|
|
7
|
+
It holds production and test files to the size budget, and lists every other file over it without failing:
|
|
8
8
|
|
|
9
9
|
```json
|
|
10
10
|
"sources": { "production": ["src/**/*.ts"] },
|
|
11
|
-
"size": { "
|
|
11
|
+
"size": { "applies": "ratchet" }
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
A production file is one under `sources.production`, and a test file is a tracked `.ts` or `.tsx` file under `tests/`.
|
|
15
|
+
A test file keeps to the tests budget, and every other file keeps to the production budget.
|
|
16
|
+
It runs oxlint with a configuration of five rules and nothing else, one for each limit.
|
|
17
|
+
The kit sets each limit:
|
|
18
|
+
|
|
19
|
+
<!-- generated size-limits: bun run build writes it from SIZE_RULES and SIZE_DEFAULTS in scripts/size-rules.ts and scripts/doc-blocks.ts -->
|
|
20
|
+
|
|
21
|
+
| Key | Limits | oxlint rule | Production | Tests |
|
|
22
|
+
| --- | --- | --- | --- | --- |
|
|
23
|
+
| `fileLines` | The most lines a file may hold, blank and comment lines counted | `max-lines` | 400 | 600 |
|
|
24
|
+
| `functionLines` | The most lines a function may span, blank and comment lines counted | `max-lines-per-function` | 100 | none |
|
|
25
|
+
| `statements` | The most statements a function may hold | `max-statements` | 30 | 50 |
|
|
26
|
+
| `complexity` | The highest cyclomatic complexity a function may reach, a switch counted once | `complexity` | 15 | 15 |
|
|
27
|
+
| `depth` | The deepest a block may nest inside a function | `max-depth` | 4 | 4 |
|
|
28
|
+
|
|
29
|
+
<!-- end generated size-limits -->
|
|
30
|
+
|
|
31
|
+
`size.production` and `size.tests` state only a limit that differs from the kit's:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
"size": {
|
|
35
|
+
"applies": "ratchet",
|
|
36
|
+
"production": { "complexity": 12 },
|
|
37
|
+
"tests": { "fileLines": 800 }
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`applies` decides which files the gate holds and what fails them:
|
|
42
|
+
|
|
43
|
+
- `ratchet` holds each production and test file the range adds or changes, a rename that edits the file included.
|
|
44
|
+
For each file and each rule, it sums how far every site runs over its limit, and fails when that sum is higher at the head than at the base of the range.
|
|
45
|
+
A file the range adds starts from zero, so it has to keep within the budget.
|
|
46
|
+
A file already over the budget passes while its overrun does not grow, and an edit that shrinks the overrun passes.
|
|
47
|
+
An edited rename is compared with the file it was renamed from.
|
|
48
|
+
Nothing is committed as a baseline, because the base of the range holds it.
|
|
49
|
+
- `all` holds every production and test file, and fails on any overrun in them.
|
|
50
|
+
|
|
51
|
+
`applies` is `ratchet` when `size` leaves it out.
|
|
17
52
|
A file the range deletes or only renames is not held.
|
|
18
|
-
Every other tracked `.ts` or `.tsx` file over the budget,
|
|
53
|
+
Every other tracked `.ts` or `.tsx` file over the budget, tooling and unchanged files alike, is listed as advisory and never fails the gate.
|
|
54
|
+
Under `ratchet` the advisory list also names each site in a held file whose overrun did not grow.
|
|
19
55
|
`.d.ts` files are not measured.
|
|
20
56
|
|
|
57
|
+
`quality.json` refuses `applies: "changed"`, which `ratchet` replaces.
|
|
58
|
+
It refuses a limit set directly under `size`, since each limit goes in `size.production` or `size.tests`.
|
|
59
|
+
|
|
21
60
|
## What it reads
|
|
22
61
|
|
|
23
62
|
It reads each file from the head commit rather than the working tree, so an uncommitted edit neither fails nor passes a range, and a pull request's merge checkout measures what the pull request holds.
|
|
63
|
+
Under `ratchet` it also reads each held file from the base of the range, under the path the file has at the head, so a file keeps the same budget at both ends.
|
|
24
64
|
It reads `sources.production` and `size` from `quality.json`.
|
|
25
65
|
oxlint must be on `PATH`, as it is under a package script.
|
|
26
66
|
|
|
@@ -38,25 +78,28 @@ With one it is that commit against its parent, or against the empty tree for a r
|
|
|
38
78
|
|
|
39
79
|
| Code | When |
|
|
40
80
|
| --- | --- |
|
|
41
|
-
| 0 | every file it holds keeps within the budget |
|
|
42
|
-
| 1 | a file it holds runs over the budget |
|
|
81
|
+
| 0 | every file it holds keeps within the budget, or under `ratchet` no file's overrun grows |
|
|
82
|
+
| 1 | a file it holds runs over the budget, or under `ratchet` a file's overrun grows |
|
|
43
83
|
| 2 | `quality.json` does not decode, a ref does not resolve, or oxlint cannot run |
|
|
44
84
|
|
|
45
85
|
## Sample output
|
|
46
86
|
|
|
47
87
|
```
|
|
48
|
-
size-budget:
|
|
49
|
-
src/billing/
|
|
88
|
+
size-budget: 2 overrun(s) grew past the base in the production and test files the range adds or changes:
|
|
89
|
+
src/billing/invoice.ts: max-lines over by 31 in total, up from 19
|
|
90
|
+
src/billing/invoice.ts: File has too many lines (431). Maximum allowed is 400.
|
|
91
|
+
src/billing/ledger.ts: complexity over by 3 in total, up from 0
|
|
92
|
+
src/billing/ledger.ts:12: function `settle` has a complexity of 18. Maximum allowed is 15.
|
|
50
93
|
size-budget: advisory, 1 overrun(s) where the budget does not hold yet:
|
|
51
|
-
tests/e2e/billing.test.ts: File has too many lines (
|
|
94
|
+
tests/e2e/billing.test.ts: File has too many lines (612). Maximum allowed is 600.
|
|
52
95
|
```
|
|
53
96
|
|
|
54
97
|
## Opting out
|
|
55
98
|
|
|
56
99
|
A repository that declares no `size` passes.
|
|
57
|
-
`quality.json` refuses a `size` without `sources.production`,
|
|
100
|
+
`quality.json` refuses a `size` without `sources.production`, and `checks-quality` refuses a `sources.production` glob that matches no file.
|
|
58
101
|
A repository that tracks no `.ts` or `.tsx` file leaves it out of `gates.lint`, as [Gate selection](checks-lint.md#gate-selection) says.
|
|
59
|
-
Moving `applies` from `
|
|
102
|
+
Moving `applies` from `ratchet` to `all` tightens the budget to every production and test file, once the advisory list names none.
|
|
60
103
|
|
|
61
104
|
## Related topics
|
|
62
105
|
|