@avi2dg/checks 0.14.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 CHANGED
@@ -2,6 +2,23 @@
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
+
14
+ ## 0.15.0
15
+
16
+ Released 2026-09-25.
17
+
18
+ ### Features
19
+
20
+ - **scripts:** hold living docs to prose rules and resolvable references in checks-docs (#42)
21
+
5
22
  ## 0.14.0
6
23
 
7
24
  Released 2026-09-25.
package/CONTRIBUTING.md CHANGED
@@ -81,7 +81,7 @@ To place a change:
81
81
 
82
82
  This repository holds itself to the kit, with two exceptions of its own.
83
83
  Its `.dependency-cruiser.cjs` redeclares `no-orphans` with the plugin entry added to its `pathNot`.
84
- Its `.oxlintrc.json` lifts `effect-channel/no-throw` from `scripts/comment-matchers.ts`, the one file under its Effect path that a host loads without `node_modules`.
84
+ Its `.oxlintrc.json` lifts `effect-channel/no-throw` from `scripts/comment-matchers.ts`, whose synchronous `refused()` a host loads without `node_modules`.
85
85
 
86
86
  ## Related topics
87
87
 
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 -->
@@ -1,8 +1,8 @@
1
1
  // scripts/feature-rules.ts
2
- import { Schema as Schema3 } from "effect";
2
+ import { Schema as Schema4 } from "effect";
3
3
 
4
4
  // scripts/quality-file.ts
5
- import { Console, Effect, FileSystem, JsonSchema, Path, Schema as Schema2 } from "effect";
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,81 +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 = Schema2.String.check(Schema2.isPattern(new RegExp(`^${SEGMENT}(?:/${SEGMENT})*/${FILE}$`), {
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
  });
120
+ var DocGlob = Schema3.String.check(Schema3.isPattern(new RegExp(`^(?:${SEGMENT}/)*${FILE}$`), {
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"
122
+ })).annotate({
123
+ identifier: "DocGlob",
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"
125
+ });
43
126
  var LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
44
- var DirectoryPath = Schema2.String.check(Schema2.isPattern(new RegExp(`^${LITERAL_SEGMENT}(?:/${LITERAL_SEGMENT})*$`), {
127
+ var DirectoryPath = Schema3.String.check(Schema3.isPattern(new RegExp(`^${LITERAL_SEGMENT}(?:/${LITERAL_SEGMENT})*$`), {
45
128
  expected: "a directory from the repository root such as src/billing, with no glob and no trailing slash"
46
129
  })).annotate({ identifier: "DirectoryPath" });
47
- var FilePath = Schema2.String.check(Schema2.isPattern(new RegExp(`^(?:${LITERAL_SEGMENT}/)*[\\w.@+-]*\\.\\w+$`), {
130
+ var FilePath = Schema3.String.check(Schema3.isPattern(new RegExp(`^(?:${LITERAL_SEGMENT}/)*[\\w.@+-]*\\.\\w+$`), {
48
131
  expected: "a file from the repository root such as src/billing/index.ts, with no glob"
49
132
  })).annotate({ identifier: "FilePath" });
50
133
  var PROOF_DIRECTORY = "tests/e2e/";
51
- var ProofPath = Schema2.String.check(Schema2.isPattern(new RegExp(`^${PROOF_DIRECTORY}(?:${LITERAL_SEGMENT}/)*[\\w.@+-]+\\.test\\.tsx?$`), {
134
+ var ProofPath = Schema3.String.check(Schema3.isPattern(new RegExp(`^${PROOF_DIRECTORY}(?:${LITERAL_SEGMENT}/)*[\\w.@+-]+\\.test\\.tsx?$`), {
52
135
  expected: `a test file under ${PROOF_DIRECTORY} such as ${PROOF_DIRECTORY}billing.test.ts`
53
136
  })).annotate({ identifier: "ProofPath" });
54
- var Command = Schema2.NonEmptyString.annotate({ identifier: "Command" });
55
- var RuleName = Schema2.String.check(Schema2.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a Rule name in kebab case" })).annotate({ identifier: "RuleName" });
56
- var Identity = Schema2.Struct({ name: Schema2.NonEmptyString, email: Schema2.NonEmptyString }).annotate({
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({
57
140
  identifier: "Identity"
58
141
  });
59
- var CommitIdentity = Schema2.Struct({
60
- authors: Schema2.NonEmptyArray(Identity).annotate({
142
+ var CommitIdentity = Schema3.Struct({
143
+ authors: Schema3.NonEmptyArray(Identity).annotate({
61
144
  description: "The identities allowed to author and commit, in place of the kit's default owner"
62
145
  })
63
146
  });
64
- var Gates = Schema2.Struct({
65
- ci: Schema2.optionalKey(Schema2.NonEmptyArray(Command).annotate({
147
+ var Gates = Schema3.Struct({
148
+ ci: Schema3.optionalKey(Schema3.NonEmptyArray(Command).annotate({
66
149
  description: "The commands CI runs on every pull request to the default branch, each one plain command"
67
150
  })),
68
- scheduled: Schema2.optionalKey(Schema2.Array(Command).annotate({ description: "The commands a cron-scheduled workflow runs" })),
69
- lint: Schema2.optionalKey(LintGates)
151
+ scheduled: Schema3.optionalKey(Schema3.Array(Command).annotate({ description: "The commands a cron-scheduled workflow runs" })),
152
+ lint: Schema3.optionalKey(LintGates)
70
153
  });
71
- var EffectSources = Schema2.Struct({
72
- paths: Schema2.NonEmptyArray(PathGlob).annotate({
154
+ var EffectSources = Schema3.Struct({
155
+ paths: Schema3.NonEmptyArray(PathGlob).annotate({
73
156
  description: "Where source is written in Effect, held to the Effect rules of oxlint and the language service"
74
157
  }),
75
- exempt: Schema2.optionalKey(Schema2.Array(PathGlob).annotate({ description: "Files under paths the Effect rules pass over" }))
158
+ exempt: Schema3.optionalKey(Schema3.Array(PathGlob).annotate({ description: "Files under paths the Effect rules pass over" }))
76
159
  });
77
- var Sources = Schema2.Struct({
78
- production: Schema2.optionalKey(Schema2.Array(PathGlob).annotate({ description: "The source the repository ships, as against tests and tooling" })),
79
- effect: Schema2.optionalKey(EffectSources)
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)
80
163
  });
81
- var LineBudget = Schema2.Int.check(Schema2.isGreaterThan(0));
82
- var Size = Schema2.Struct({
83
- fileLines: LineBudget.annotate({ description: "The most lines a file may hold, blank and comment lines counted" }),
84
- functionLines: LineBudget.annotate({
85
- description: "The most lines a function may span, blank and comment lines counted"
86
- }),
87
- applies: Schema2.Literals(["changed", "all"]).annotate({
88
- description: "Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory"
89
- })
90
- });
91
- var Feature = Schema2.Struct({
92
- name: Schema2.String.check(Schema2.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" }),
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" }),
93
166
  root: DirectoryPath.annotate({ description: "The directory the feature owns" }),
94
- entries: Schema2.NonEmptyArray(FilePath).annotate({
167
+ entries: Schema3.NonEmptyArray(FilePath).annotate({
95
168
  description: "The files under root that code outside it imports the feature through"
96
169
  }),
97
- allowFrom: Schema2.optionalKey(Schema2.Array(PathGlob).annotate({
170
+ allowFrom: Schema3.optionalKey(Schema3.Array(PathGlob).annotate({
98
171
  description: "Files outside root that may import past its entries, such as a CLI or a harness; tests/ always may"
99
172
  })),
100
173
  proof: ProofPath.annotate({ description: "The end-to-end test that imports one of entries" })
101
- }).check(Schema2.makeFilter(({ root, entries }) => {
174
+ }).check(Schema3.makeFilter(({ root, entries }) => {
102
175
  const outside = entries.filter((entry) => !entry.startsWith(`${root}/`));
103
176
  return outside.length === 0 || `lists ${outside.join(", ")} among its entries, outside its root ${root}`;
104
177
  }));
105
178
  function nests(outer, inner) {
106
179
  return outer === inner || inner.startsWith(`${outer}/`);
107
180
  }
108
- var Features = Schema2.Array(Feature).check(Schema2.makeFilter((features) => {
181
+ var Features = Schema3.Array(Feature).check(Schema3.makeFilter((features) => {
109
182
  const names = features.map((feature) => feature.name);
110
183
  const repeated = names.filter((name, index) => names.indexOf(name) !== index);
111
184
  if (repeated.length > 0)
@@ -117,71 +190,74 @@ var Features = Schema2.Array(Feature).check(Schema2.makeFilter((features) => {
117
190
  }
118
191
  return true;
119
192
  }));
120
- var AgentRules = Schema2.Struct({
121
- on: Schema2.optionalKey(Schema2.Array(RuleName).annotate({ description: "Catalogued Rules switched on here" })),
122
- off: Schema2.optionalKey(Schema2.Array(RuleName).annotate({ description: "Catalogued Rules switched off here" }))
123
- }).check(Schema2.makeFilter(({ on = [], off = [] }) => {
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 = [] }) => {
124
197
  const both = on.filter((rule) => off.includes(rule));
125
198
  return both.length === 0 || `switches ${both.join(", ")} both on and off`;
126
199
  }));
127
- var pagesIn = (mode) => Schema2.optionalKey(Schema2.Array(PathGlob).annotate({ description: `The pages written as ${mode}` }));
128
- var Docs = Schema2.Struct({
129
- pages: Schema2.optionalKey(Schema2.Struct({
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({
130
203
  tutorial: pagesIn("a tutorial, which teaches by building one thing"),
131
204
  "how-to": pagesIn("a how-to, which walks one task"),
132
205
  reference: pagesIn("reference, which describes a thing to be looked up"),
133
206
  explanation: pagesIn("an explanation, which says why")
134
207
  }).annotate({
135
208
  description: "The Diátaxis mode of each page, whose template checks-docs holds the page to; a page under docs/ needs one"
209
+ })),
210
+ forConsumers: Schema3.optionalKey(Schema3.Array(DocGlob).annotate({
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"
136
212
  }))
137
213
  });
138
- var Quality = Schema2.Struct({
139
- $schema: Schema2.optionalKey(Schema2.String),
140
- defaultBranch: Schema2.optionalKey(Schema2.NonEmptyString.annotate({ description: "The branch pull requests merge into; main when absent" })),
141
- gates: Schema2.optionalKey(Gates),
142
- commitIdentity: Schema2.optionalKey(CommitIdentity),
143
- sources: Schema2.optionalKey(Sources),
144
- size: Schema2.optionalKey(Size.annotate({ description: "The line budget oxlint holds production files to, read by checks-size-budget" })),
145
- features: Schema2.optionalKey(Features.annotate({
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({
146
222
  description: "The feature owners dependency-cruiser holds to their entries and checks-feature-owners maps a change to"
147
223
  })),
148
- changeSignal: Schema2.optionalKey(Schema2.Literal("advisory").annotate({
224
+ changeSignal: Schema3.optionalKey(Schema3.Literal("advisory").annotate({
149
225
  description: "Report which feature owners a change touches, without failing on it"
150
226
  })),
151
- agentRules: Schema2.optionalKey(AgentRules),
152
- docs: Schema2.optionalKey(Docs.annotate({ description: "What checks-docs reads to map a doc file to its template" }))
227
+ agentRules: Schema3.optionalKey(AgentRules),
228
+ docs: Schema3.optionalKey(Docs.annotate({ description: "What checks-docs reads to map a doc file to its template" }))
153
229
  }).annotate({
154
230
  title: QUALITY_FILE,
155
231
  description: "What a repository has opted into from @avi2dg/checks, read by its bins and agent Rule selection"
156
- }).check(Schema2.makeFilter(({ size, sources }) => size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds nothing without sources.production", {
232
+ }).check(Schema3.makeFilter(({ size, sources }) => size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds no production file without sources.production", {
157
233
  toJsonSchema: () => ({
158
234
  if: { required: ["size"] },
159
235
  then: { required: ["sources"], properties: { sources: { required: ["production"], properties: { production: { minItems: 1 } } } } }
160
236
  })
161
- }), Schema2.makeFilter(({ changeSignal, features = [] }) => changeSignal === undefined || features.length > 0 || "declares changeSignal, which maps a change to no owner without features", {
237
+ }), Schema3.makeFilter(({ changeSignal, features = [] }) => changeSignal === undefined || features.length > 0 || "declares changeSignal, which maps a change to no owner without features", {
162
238
  toJsonSchema: () => ({
163
239
  if: { required: ["changeSignal"] },
164
240
  then: { required: ["features"], properties: { features: { minItems: 1 } } }
165
241
  })
166
242
  }));
167
- var LegacyManifest = Schema2.Struct({
168
- ciWiring: Schema2.optionalKey(Schema2.Struct({
169
- gates: Schema2.optionalKey(Schema2.NonEmptyArray(Command)),
170
- scheduled: Schema2.optionalKey(Schema2.Array(Command)),
171
- lintGates: Schema2.optionalKey(LintGates),
172
- defaultBranch: Schema2.optionalKey(Schema2.NonEmptyString)
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)
173
249
  })),
174
- commitIdentity: Schema2.optionalKey(CommitIdentity)
250
+ commitIdentity: Schema3.optionalKey(CommitIdentity)
175
251
  });
176
252
  var LEGACY_KEYS = ["ciWiring", "commitIdentity"];
177
253
 
178
- class QualityUnreadable extends Schema2.TaggedError()("QualityUnreadable", {
179
- message: Schema2.String
254
+ class QualityUnreadable extends Schema3.TaggedError()("QualityUnreadable", {
255
+ message: Schema3.String
180
256
  }) {
181
257
  }
182
258
  var MANIFEST = "package.json";
183
- var decodeQualityJson = Schema2.decodeUnknownEffect(Schema2.fromJsonString(Quality), { onExcessProperty: "error" });
184
- var decodeManifestJson = Schema2.decodeUnknownEffect(Schema2.fromJsonString(LegacyManifest));
259
+ var decodeQualityJson = Schema3.decodeUnknownEffect(Schema3.fromJsonString(Quality), { onExcessProperty: "error" });
260
+ var decodeManifestJson = Schema3.decodeUnknownEffect(Schema3.fromJsonString(LegacyManifest));
185
261
  var decodeQuality = (text, source) => decodeQualityJson(text).pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })));
186
262
  function fromLegacy({ ciWiring, commitIdentity }) {
187
263
  const gates = {
@@ -245,7 +321,7 @@ function rulesFor(features) {
245
321
  to: { path: `^${escaped(root)}/`, pathNot: entries.map((entry) => `^${escaped(entry)}$`) }
246
322
  }));
247
323
  }
248
- var decode = Schema3.decodeUnknownSync(Quality);
324
+ var decode = Schema4.decodeUnknownSync(Quality);
249
325
  function featureRules(quality) {
250
326
  return rulesFor(decode(quality, { onExcessProperty: "error" }).features ?? []);
251
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": { "fileLines": 400, "functionLines": 100, "applies": "changed" },
22
+ "size": { "applies": "ratchet", "tests": { "fileLines": 800 } },
23
23
  "features": [
24
24
  {
25
25
  "name": "billing",
@@ -44,14 +44,15 @@ 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) says |
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 line budget, and which production files it holds |
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 |
53
53
  | `agentRules.off` | agent Rule selection, not the kit | catalogued Rules switched off for this repository |
54
54
  | `docs.pages` | `checks-docs` | the Diátaxis mode of each page, by glob, as [checks-docs](../gates/checks-docs.md) says |
55
+ | `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 |
55
56
 
56
57
  <!-- end generated quality-keys -->
57
58
 
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`.
@@ -25,7 +32,8 @@ Each entry below is a choice in the kit's shape and the constraint that forced i
25
32
  A bump not newer than the release before it is a revert: it cancels every release above the version it returns to, and a tag only keeps a reverted release that was already published.
26
33
  The committed changelog is the record of what was released: a version older than the newest it lists and absent from it was never published, and its commits go into the next release.
27
34
  A section keeps the date it was written with, since the squash merge that lands the release commit may fall on another day.
28
- Entries come from commit subjects, the squash-merged pull request titles commitlint holds to the conventional format; the bodies are the branch's own messages, which nothing lints.
35
+ Entries come from commit subjects, the squash-merged pull request titles commitlint holds to the conventional format.
36
+ The bodies are the branch's own messages, which nothing lints.
29
37
  The release path needs no `contents: write`: the changelog arrives in the release commit's pull request, not from a workflow that pushes.
30
38
  - `quality.json` is JSON, not TOML or a TypeScript module: a bun bin, a hook running without `node_modules`, a `.cjs` or `.mjs` config and `jq` all parse it with nothing installed, and nobody runs a repository's own code to learn its policy.
31
39
  It holds declarations only.
@@ -71,6 +79,22 @@ Each entry below is a choice in the kit's shape and the constraint that forced i
71
79
  A repository adopts the templates as its files change, and an untouched file is listed as advisory rather than failing a change that never read it.
72
80
  - A task heading is verb first, and review holds it there rather than the check.
73
81
  No word list tells `Test layout` from `Test the layout`, and a check that passes the noun is worse than none.
82
+ - The prose rules judge only the lines a change adds or edits, the way `checks-comment-gate` judges comments.
83
+ Text nobody touched never turns a change red, a record keeps the words it was written in, and a repository needs no cleanup pass before the gate runs.
84
+ - A living doc takes one sentence per line, so a changed line is a changed sentence.
85
+ Under a hard wrap a one-word edit reflows a paragraph, and the gate would then demand fixes to sentences the edit never touched.
86
+ - An agent file such as `AGENTS.md` takes the separator rules and no other prose rule.
87
+ One sentence per line serves the people who review a doc's diffs, and an agent file keeps each entry to one line however many sentences it holds.
88
+ - `scripts/prose-matchers.ts` imports nothing, so the gate and a write-time hook run one matcher and refuse in the same words.
89
+ A hook bundle ships without `node_modules`, so a matcher that needed Vale or a package could not refuse at write time.
90
+ - Readability grades and words such as easy stay out of the prose rules.
91
+ A score cannot fail a change without failing correct prose, and a suggestion nobody runs an editor for is never seen.
92
+ - A path, link or command on a line the range leaves alone fails when the range broke it, as by deleting the file it names.
93
+ A reference goes stale when the code it names moves far more often than when its own line is edited, so a gate on edited lines alone would miss the usual break.
94
+ - A path under a top directory the repository lacks names a file in another repository, such as a consumer's, and no program tells that from a typo.
95
+ A directory the range deletes still counts as this repository's, so a path under it reads as stale rather than foreign.
96
+ - The command check passes over the docs a repository declares under `docs.forConsumers`.
97
+ This kit's README and reference pages speak to a consuming repository, whose scripts are not this one's, and no program tells an example for a consumer from an instruction for a contributor.
74
98
 
75
99
  ## Related topics
76
100
 
@@ -1,10 +1,12 @@
1
1
  # checks-docs
2
2
 
3
- `checks-docs` is the gate that holds each doc file a change touches to the template for its kind, and a reader looks it up to learn which template a file answers to.
3
+ `checks-docs` is the gate that holds each doc file a change touches to the template for its kind, each line a change adds to a living doc or an agent file to the prose rules, and each path, link and command a living doc names to what the repository holds, and a reader looks it up to learn what a doc file answers to.
4
4
 
5
5
  ## What it checks
6
6
 
7
7
  It holds each doc file a change touches to the template for its kind, and lists every other doc file that does not conform yet without failing.
8
+ It holds each line a change adds or edits in a living doc or an agent file to the prose rules, as [The prose rules](#the-prose-rules) says.
9
+ It fails when a living doc names a path, link or command that does not resolve, and the range added it or broke it, as [Paths, links and commands](#paths-links-and-commands) says.
8
10
  The package ships one template per kind under `templates/`, and a repository starts a new doc file by copying one:
9
11
 
10
12
  ```sh
@@ -51,12 +53,81 @@ A template decides a file's structure, and the template file itself is the refer
51
53
  - A changelog lists its releases newest first, each opening with a `Released YYYY-MM-DD.` line.
52
54
  - A how-to or tutorial page numbers its steps.
53
55
  - `CLAUDE.md` is its template word for word.
54
- It is a fixed agent pointer rather than a people doc, so the separator ban does not apply to it: people docs take no em dash, en dash, parenthesis or hyphen used as a dash, and no semicolon, and `checks-docs` does not check separators.
56
+ It is a fixed agent pointer rather than a living doc, so it takes only the prose rules for agent files.
57
+
58
+ ## The prose rules
59
+
60
+ A line a change adds or edits in a living doc or an agent file is held to the prose rules, and a line the change leaves alone is not, so a repository needs no cleanup pass before it runs them.
61
+
62
+ <!-- generated living-docs: bun run build writes it from scripts/prose-matchers.ts and scripts/doc-blocks.ts -->
63
+
64
+ A living doc is one of these:
65
+
66
+ - a `README.md` or `CONTRIBUTING.md` in any directory
67
+ - a Markdown page under `docs/`
68
+
69
+ An agent file is a `AGENTS.md` or `CLAUDE.md` in any directory, and takes only the rules the table below marks for agent files.
70
+
71
+ These are records, and take no prose rule:
72
+
73
+ - a file in `docs/adr/`
74
+ - a file whose name opens with four digits, as in `0001-` or `2026-05-08-`
75
+ - a `CHANGELOG.md`
76
+
77
+ <!-- end generated living-docs -->
78
+
79
+ <!-- generated prose-rules: bun run build writes it from PROSE_RULES in scripts/prose-matchers.ts and scripts/doc-blocks.ts -->
80
+
81
+ | Refused | For example | Write instead | In agent files |
82
+ | --- | --- | --- | --- |
83
+ | an em dash | `—` | End the sentence, or use a comma | yes |
84
+ | an en dash | `–` | End the sentence, or use a comma | yes |
85
+ | a parenthesis other than the plural `(s)` | `(` | Make the aside its own sentence, or set it off with commas | yes |
86
+ | a hyphen used as a dash | `a - b` or `a -- b` | End the sentence, or use a comma | yes |
87
+ | a semicolon | `;` | Use two sentences | yes |
88
+ | a promise about the future | `until #11`, `is planned`, `will soon`, `coming soon`, `in a future release` | Say what is true now | no |
89
+ | a sentence that opens by talking about the page | `This page explains` | Talk directly about the subject | no |
90
+ | a second sentence on one line | `It builds. It ships.` | Start it on its own line | no |
91
+ | a sentence that runs across lines | `It builds` with `and ships.` on the next line | Join the sentence onto one line | no |
92
+
93
+ <!-- end generated prose-rules -->
94
+
95
+ A line holds one sentence, so a changed line is a changed sentence.
96
+ A bold label that opens a line, as in `**Status.**`, heads the sentence after it rather than counting as one.
97
+ Fenced code, inline code, link destinations, URLs, HTML comments and front matter are not prose, so no rule reads them.
98
+ Readability scores and word choice, such as easy, are not checked.
99
+
100
+ `scripts/prose-matchers.ts` holds the rules and a synchronous `proseRefused()`, and imports nothing.
101
+ A host such as a hook bundle can therefore copy it alone into a directory with no `node_modules` and import it as `@avi2dg/checks/scripts/prose-matchers.ts`, to refuse the same lines at write time.
102
+
103
+ ## Paths, links and commands
104
+
105
+ Each reference a living doc names has to resolve at the head commit:
106
+
107
+ - A path in inline code that ends in a file extension, such as `scripts/lint.ts`, names a file from the root or from the doc's directory.
108
+ - A relative Markdown link names a file or a directory, and its anchor names a heading in the file it links, as GitHub derives the anchor, or an explicit `id`.
109
+ - A `bun run` command in code names a script in the nearest `package.json`, a bin in `node_modules/.bin`, or a file that exists.
110
+
111
+ A reference that does not resolve fails when it sits on a line the range adds or edits.
112
+ It fails on any other line when the range broke it, as by deleting the file it names or renaming the heading it links, and is listed as advisory when it was broken before the range.
113
+ A path under a top directory the repository lacks at both ends of the range names another repository's file, such as a consumer's, and is passed over.
114
+ So is a path git ignores, since a clean checkout lacks a generated file by design.
115
+
116
+ A doc that speaks to a repository installing this one is declared under `docs.forConsumers` in `quality.json`, and its commands are not held to this repository's `package.json`:
117
+
118
+ ```json
119
+ "docs": {
120
+ "forConsumers": ["README.md", "docs/gates/*.md"]
121
+ }
122
+ ```
55
123
 
56
124
  ## What it reads
57
125
 
58
126
  It reads each Markdown file from the head commit, and `docs.pages` from `quality.json`.
59
127
  A file the range adds, changes or renames is held to its template, and a file it deletes is not.
128
+ It reads the lines the range adds or edits from the diff, with renames detected, so a renamed doc is judged only on the lines the rename changed.
129
+ It reads the files tracked at both ends of the range, the `scripts` of each `package.json` a living doc sits under, and `docs.forConsumers` from `quality.json`.
130
+ From the working tree it reads the ignore files git reads, and `node_modules/.bin`.
60
131
 
61
132
  ## Arguments
62
133
 
@@ -72,24 +143,30 @@ With one it is that commit against its parent, or against the empty tree for a r
72
143
 
73
144
  | Code | When |
74
145
  | --- | --- |
75
- | 0 | every doc file the range touches holds to its template |
76
- | 1 | a doc file the range touches does not |
77
- | 2 | `quality.json` does not decode, or a ref does not resolve |
146
+ | 0 | every doc file the range touches holds to its template, every line it adds to a living doc or an agent file holds to the prose rules, and it adds or breaks no reference that does not resolve |
147
+ | 1 | a doc file the range touches does not hold to its template, a line the range adds to a living doc or an agent file breaks a prose rule, or the range adds or breaks a reference that does not resolve |
148
+ | 2 | `quality.json` or a `package.json` does not decode, or a ref does not resolve |
78
149
 
79
150
  ## Sample output
80
151
 
81
152
  ```
82
- docs: 2 violation(s) in the doc files the range touches:
153
+ docs: 4 violation(s):
83
154
  README.md:1: lacks `## Where things are`
155
+ README.md:12: carries `;`, a semicolon. Use two sentences
156
+ README.md:20: names `scripts/bild.ts`, which is not in the repository
84
157
  docs/parts.md: is a page under docs/ with no mode; declare it under docs.pages in quality.json as tutorial, how-to, reference, explanation
85
158
  docs: advisory, 1 doc file(s) the range leaves alone do not hold to their templates yet:
86
159
  docs/adr/0001-quality-gates.md: 5 violation(s)
160
+ docs: advisory, 1 path(s), link(s) or command(s) the living docs name were broken before the range:
161
+ docs/parts.md:9: links to `suppliers.md#prices`, and `docs/suppliers.md` has no heading with that anchor
87
162
  ```
88
163
 
89
164
  ## Opting out
90
165
 
91
166
  It applies to every repository, so no selection leaves it out.
92
167
  A file the range leaves alone is only listed as advisory, so a repository adopts the templates as its files change.
168
+ A line the range leaves alone takes no prose rule, so a repository adopts the prose rules as its lines change.
169
+ A doc listed under `docs.forConsumers` holds no `bun run` command to this repository's `package.json`.
93
170
  `checks-lint` runs it over each pull request's range, as [checks-lint](checks-lint.md) says.
94
171
 
95
172
  ## Related topics
@@ -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 10 gate(s) failed: checks-commit-identity, checks-comment-gate, checks-suppressions-ratchet
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 4 gate(s) this repository's contents make applicable:
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` glob, or a `sources.production` glob while `size` is declared, matches no tracked or untracked file, so it holds nothing.
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