@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Deterministic checks shared across the captain's TypeScript repos",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,6 +41,8 @@
41
41
  "scripts/quality-file.ts",
42
42
  "scripts/quality.ts",
43
43
  "scripts/size-budget.ts",
44
+ "scripts/size-rules.ts",
45
+ "scripts/repetition.ts",
44
46
  "scripts/feature-owners.ts",
45
47
  "scripts/docs.ts",
46
48
  "scripts/doc-outline.ts",
@@ -79,6 +81,7 @@
79
81
  "./scripts/quality-file.ts": "./scripts/quality-file.ts",
80
82
  "./scripts/quality.ts": "./scripts/quality.ts",
81
83
  "./scripts/size-budget.ts": "./scripts/size-budget.ts",
84
+ "./scripts/repetition.ts": "./scripts/repetition.ts",
82
85
  "./scripts/feature-owners.ts": "./scripts/feature-owners.ts",
83
86
  "./scripts/docs.ts": "./scripts/docs.ts",
84
87
  "./templates/readme.md": "./templates/readme.md",
@@ -113,6 +116,7 @@
113
116
  "checks-backtest": "scripts/backtest.ts",
114
117
  "checks-quality": "scripts/quality.ts",
115
118
  "checks-size-budget": "scripts/size-budget.ts",
119
+ "checks-repetition": "scripts/repetition.ts",
116
120
  "checks-feature-owners": "scripts/feature-owners.ts",
117
121
  "checks-docs": "scripts/docs.ts"
118
122
  },
@@ -127,6 +131,7 @@
127
131
  "dependency-cruiser": "18.4.0",
128
132
  "@effect/tsgo": "0.45.0",
129
133
  "effect": "4.0.0-rc.115",
134
+ "jscpd": "5.3.2",
130
135
  "oxlint": "1.83.0",
131
136
  "oxlint-tsgolint": "7.0.2002"
132
137
  },
@@ -138,6 +143,7 @@
138
143
  "ajv": "8.20.0",
139
144
  "dependency-cruiser": "18.4.0",
140
145
  "effect": "4.0.0-rc.115",
146
+ "jscpd": "5.3.2",
141
147
  "oxlint": "1.83.0",
142
148
  "oxlint-tsgolint": "7.0.2002",
143
149
  "typescript": "7.0.2"
@@ -47,6 +47,7 @@
47
47
  "checks-docs",
48
48
  "checks-quality",
49
49
  "checks-size-budget",
50
+ "checks-repetition",
50
51
  "checks-feature-owners"
51
52
  ]
52
53
  },
@@ -148,32 +149,76 @@
148
149
  "size": {
149
150
  "type": "object",
150
151
  "properties": {
151
- "fileLines": {
152
- "type": "integer",
153
- "exclusiveMinimum": 0,
154
- "description": "The most lines a file may hold, blank and comment lines counted"
155
- },
156
- "functionLines": {
157
- "type": "integer",
158
- "exclusiveMinimum": 0,
159
- "description": "The most lines a function may span, blank and comment lines counted"
160
- },
161
152
  "applies": {
162
153
  "type": "string",
163
154
  "enum": [
164
- "changed",
155
+ "ratchet",
165
156
  "all"
166
157
  ],
167
- "description": "Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory"
158
+ "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. ratchet when absent. The rest are listed as advisory"
159
+ },
160
+ "production": {
161
+ "type": "object",
162
+ "properties": {
163
+ "fileLines": {
164
+ "type": "integer",
165
+ "exclusiveMinimum": 0,
166
+ "description": "The most lines a file may hold, blank and comment lines counted; 400 when absent"
167
+ },
168
+ "functionLines": {
169
+ "type": "integer",
170
+ "exclusiveMinimum": 0,
171
+ "description": "The most lines a function may span, blank and comment lines counted; 100 when absent"
172
+ },
173
+ "statements": {
174
+ "type": "integer",
175
+ "exclusiveMinimum": 0,
176
+ "description": "The most statements a function may hold; 30 when absent"
177
+ },
178
+ "complexity": {
179
+ "type": "integer",
180
+ "exclusiveMinimum": 0,
181
+ "description": "The highest cyclomatic complexity a function may reach, a switch counted once; 15 when absent"
182
+ },
183
+ "depth": {
184
+ "type": "integer",
185
+ "exclusiveMinimum": 0,
186
+ "description": "The deepest a block may nest inside a function; 4 when absent"
187
+ }
188
+ },
189
+ "additionalProperties": false,
190
+ "description": "The budget of the files under sources.production, and of the files listed as advisory outside tests/"
191
+ },
192
+ "tests": {
193
+ "type": "object",
194
+ "properties": {
195
+ "fileLines": {
196
+ "type": "integer",
197
+ "exclusiveMinimum": 0,
198
+ "description": "The most lines a file may hold, blank and comment lines counted; 600 when absent"
199
+ },
200
+ "statements": {
201
+ "type": "integer",
202
+ "exclusiveMinimum": 0,
203
+ "description": "The most statements a function may hold; 50 when absent"
204
+ },
205
+ "complexity": {
206
+ "type": "integer",
207
+ "exclusiveMinimum": 0,
208
+ "description": "The highest cyclomatic complexity a function may reach, a switch counted once; 15 when absent"
209
+ },
210
+ "depth": {
211
+ "type": "integer",
212
+ "exclusiveMinimum": 0,
213
+ "description": "The deepest a block may nest inside a function; 4 when absent"
214
+ }
215
+ },
216
+ "additionalProperties": false,
217
+ "description": "The budget of the files under tests/, which sets no limit on a function's lines"
168
218
  }
169
219
  },
170
- "required": [
171
- "fileLines",
172
- "functionLines",
173
- "applies"
174
- ],
175
220
  "additionalProperties": false,
176
- "description": "The line budget oxlint holds production files to, read by checks-size-budget"
221
+ "description": "The size budget oxlint holds production and test files to, read by checks-size-budget"
177
222
  },
178
223
  "features": {
179
224
  "type": "array",
package/scripts/gates.ts CHANGED
@@ -40,6 +40,7 @@ export const KIT_GATES = [
40
40
  { bin: "checks-docs", script: "docs.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
41
41
  { bin: "checks-quality", script: "quality.ts", reads: "tree", args: ["--check"], appliesTo: QUALITY_DECLARATION },
42
42
  { bin: "checks-size-budget", script: "size-budget.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
43
+ { bin: "checks-repetition", script: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
43
44
  { bin: "checks-feature-owners", script: "feature-owners.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
44
45
  ] as const satisfies readonly KitGate[];
45
46
 
package/scripts/git.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { Effect, Schema, Stream } from "effect";
1
+ import { Effect, Path, Schema, Stream } from "effect";
2
2
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
+ import { Usage } from "./main.ts";
3
4
 
4
5
  export class GitFailure extends Schema.TaggedError<GitFailure>()("GitFailure", {
5
6
  message: Schema.String,
@@ -159,3 +160,20 @@ export const rangeEnds = Effect.fn("rangeEnds")(function* (first: string, second
159
160
  if (second === undefined) return { base: yield* parentOrEmptyTree(first, cwd), head: first };
160
161
  return { base: (yield* git(["merge-base", first, second], cwd)).trim(), head: second };
161
162
  });
163
+
164
+ export const rangeFromArgs = Effect.fn("rangeFromArgs")(function* (args: readonly string[], usage: string, cwd?: string) {
165
+ const [first, second, ...extra] = args;
166
+ if (first === undefined || extra.length > 0) return yield* new Usage({ message: usage });
167
+ return yield* rangeEnds(first, second, cwd);
168
+ });
169
+
170
+ // A scratch index leaves the repository's own index and working tree untouched.
171
+ export const checkoutFiles = Effect.fn("checkoutFiles")(function* (rev: string, files: readonly string[], scratch: string, cwd?: string) {
172
+ const path = yield* Path.Path;
173
+ const env = { GIT_INDEX_FILE: path.join(scratch, "index") };
174
+ const tree = path.join(scratch, "tree");
175
+ yield* git(["read-tree", rev], cwd, { env });
176
+ const listed = files.map((file) => `${file}\0`).join("");
177
+ yield* git(["checkout-index", "-z", "--stdin", `--prefix=${tree}${path.sep}`], cwd, { env, input: listed });
178
+ return tree;
179
+ });
@@ -1,5 +1,6 @@
1
1
  import { Console, Effect, FileSystem, JsonSchema, Path, Schema } from "effect";
2
2
  import { LintGates, QUALITY_FILE } from "./gates.ts";
3
+ import { Size } from "./size-rules.ts";
3
4
 
4
5
  const SEGMENT = String.raw`(?!\.\.?(?:/|$))(?:\*\*|(?:[\w.@+-]|\*(?!\*))+)`;
5
6
 
@@ -97,19 +98,6 @@ const Sources = Schema.Struct({
97
98
  effect: Schema.optionalKey(EffectSources),
98
99
  });
99
100
 
100
- const LineBudget = Schema.Int.check(Schema.isGreaterThan(0));
101
-
102
- const Size = Schema.Struct({
103
- fileLines: LineBudget.annotate({ description: "The most lines a file may hold, blank and comment lines counted" }),
104
- functionLines: LineBudget.annotate({
105
- description: "The most lines a function may span, blank and comment lines counted",
106
- }),
107
- applies: Schema.Literals(["changed", "all"]).annotate({
108
- description:
109
- "Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory",
110
- }),
111
- });
112
-
113
101
  const Feature = Schema.Struct({
114
102
  name: Schema.String.check(
115
103
  Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a feature name in kebab case" }),
@@ -192,9 +180,7 @@ export const Quality = Schema.Struct({
192
180
  gates: Schema.optionalKey(Gates),
193
181
  commitIdentity: Schema.optionalKey(CommitIdentity),
194
182
  sources: Schema.optionalKey(Sources),
195
- size: Schema.optionalKey(
196
- Size.annotate({ description: "The line budget oxlint holds production files to, read by checks-size-budget" }),
197
- ),
183
+ size: Schema.optionalKey(Size),
198
184
  features: Schema.optionalKey(
199
185
  Features.annotate({
200
186
  description: "The feature owners dependency-cruiser holds to their entries and checks-feature-owners maps a change to",
@@ -215,7 +201,7 @@ export const Quality = Schema.Struct({
215
201
  .check(
216
202
  Schema.makeFilter(
217
203
  ({ size, sources }) =>
218
- size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds nothing without sources.production",
204
+ size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds no production file without sources.production",
219
205
  {
220
206
  toJsonSchema: () => ({
221
207
  if: { required: ["size"] },
@@ -120,10 +120,10 @@ const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string,
120
120
 
121
121
  const unmatchedPaths = Effect.fn("unmatchedPaths")(function* (root: string, quality: Quality) {
122
122
  const declared = [
123
- ...(quality.size === undefined ? [] : (quality.sources?.production ?? [])).map((glob) => ({
123
+ ...(quality.sources?.production ?? []).map((glob) => ({
124
124
  glob,
125
125
  key: "sources.production",
126
- holds: "no source to the size budget",
126
+ holds: "no source to checks-size-budget or checks-repetition",
127
127
  })),
128
128
  ...(quality.sources?.effect?.paths ?? []).map((glob) => ({ glob, key: "sources.effect.paths", holds: "nothing to the Effect rules" })),
129
129
  ];
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { changedPaths, checkoutFiles, collect, git, pathsAt, rangeFromArgs } from "./git.ts";
4
+ import { runMain } from "./main.ts";
5
+ import { readQuality } from "./quality-file.ts";
6
+
7
+ type Fragment = {
8
+ readonly file: string;
9
+ readonly start: number;
10
+ readonly end: number;
11
+ };
12
+
13
+ type Clone = readonly [Fragment, Fragment];
14
+
15
+ type Rise = {
16
+ readonly file: string;
17
+ readonly before: number;
18
+ readonly after: number;
19
+ readonly clones: readonly Clone[];
20
+ };
21
+
22
+ type Held = {
23
+ readonly measured: number;
24
+ readonly rises: readonly Rise[];
25
+ readonly advisory: ReadonlyMap<string, number>;
26
+ };
27
+
28
+ class JscpdUnreadable extends Schema.TaggedError<JscpdUnreadable>()("JscpdUnreadable", {
29
+ message: Schema.String,
30
+ }) {}
31
+
32
+ const NAME = "repetition";
33
+ const USAGE = "usage: repetition.ts <ref> | <base-ref> <head-ref>";
34
+ const MIN_TOKENS = 50;
35
+ const MIN_LINES = 5;
36
+ const MEASURE = `at ${MIN_TOKENS} tokens and ${MIN_LINES} lines`;
37
+ const DECLARATIONS = ":(exclude,glob)**/*.d.ts";
38
+ const TYPESCRIPT = [":(glob)**/*.ts", ":(glob)**/*.tsx", DECLARATIONS];
39
+ const JSCPD_FINISHED = 0;
40
+ const REPORT = "jscpd-report.json";
41
+
42
+ const Location = Schema.Struct({ name: Schema.String, start: Schema.Int, end: Schema.Int });
43
+
44
+ const decodeReport = Schema.decodeUnknownEffect(
45
+ Schema.fromJsonString(Schema.Struct({ duplicates: Schema.Array(Schema.Struct({ firstFile: Location, secondFile: Location })) })),
46
+ );
47
+
48
+ function fragmentOf({ name, start, end }: typeof Location.Type): Fragment {
49
+ return { file: name, start, end };
50
+ }
51
+
52
+ export function repeatedLines(clones: readonly Clone[]): ReadonlyMap<string, number> {
53
+ const lines = new Map<string, Set<number>>();
54
+ for (const { file, start, end } of clones.flat()) {
55
+ const covered = lines.get(file) ?? new Set<number>();
56
+ for (let line = start; line <= end; line += 1) covered.add(line);
57
+ lines.set(file, covered);
58
+ }
59
+ return new Map([...lines].map(([file, covered]) => [file, covered.size]));
60
+ }
61
+
62
+ // jscpd reads files from disk, and the working tree need not hold the revision: a merge checkout or an uncommitted edit.
63
+ const scan = Effect.fn("scan")(
64
+ function* (root: string, rev: string, files: readonly string[]) {
65
+ if (files.length === 0) return [];
66
+ const fs = yield* FileSystem.FileSystem;
67
+ const path = yield* Path.Path;
68
+ const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "checks-repetition-" });
69
+ const tree = yield* checkoutFiles(rev, files, scratch, root);
70
+ const output = path.join(scratch, "report");
71
+ const flags = ["--silent", "--no-tips", "--no-gitignore", "--reporters", "json", "--output", output];
72
+ const thresholds = ["--min-tokens", `${MIN_TOKENS}`, "--min-lines", `${MIN_LINES}`];
73
+
74
+ const run = yield* collect("jscpd", [...flags, ...thresholds, "."], tree).pipe(
75
+ Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot run jscpd: ${cause.message}` })),
76
+ );
77
+ if (run.exitCode !== JSCPD_FINISHED) {
78
+ return yield* new JscpdUnreadable({ message: `jscpd exited ${run.exitCode}: ${run.stderr.trim() || run.stdout.trim()}` });
79
+ }
80
+ const { duplicates } = yield* fs.readFileString(path.join(output, REPORT)).pipe(
81
+ Effect.flatMap(decodeReport),
82
+ Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot read jscpd's report: ${cause.message}` })),
83
+ );
84
+ return duplicates.map(({ firstFile, secondFile }): Clone => [fragmentOf(firstFile), fragmentOf(secondFile)]);
85
+ },
86
+ Effect.scoped,
87
+ );
88
+
89
+ function clonesOf(file: string, clones: readonly Clone[]): readonly Clone[] {
90
+ return clones
91
+ .flatMap(([first, second]): Clone[] => {
92
+ if (first.file === file) return [[first, second]];
93
+ return second.file === file ? [[second, first]] : [];
94
+ })
95
+ .toSorted(([a], [b]) => a.start - b.start);
96
+ }
97
+
98
+ const runHold = Effect.fn("runHold")(function* (root: string, production: readonly string[], base: string, head: string) {
99
+ const pathspecs = [...production.map((glob) => `:(glob)${glob}`), DECLARATIONS];
100
+ const held = yield* pathsAt(head, pathspecs, root);
101
+ const holds = new Set(held);
102
+ const others = (yield* pathsAt(head, TYPESCRIPT, root)).filter((file) => !holds.has(file));
103
+ const formerPath = new Map(
104
+ (yield* changedPaths(base, head, pathspecs, root)).flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from]] : [])),
105
+ );
106
+
107
+ const before = repeatedLines(yield* scan(root, base, yield* pathsAt(base, pathspecs, root)));
108
+ const clones = yield* scan(root, head, held);
109
+ const after = repeatedLines(clones);
110
+ const rises = held.flatMap((file): Rise[] => {
111
+ const was = before.get(formerPath.get(file) ?? file) ?? 0;
112
+ const is = after.get(file) ?? 0;
113
+ return is > was ? [{ file, before: was, after: is, clones: clonesOf(file, clones) }] : [];
114
+ });
115
+ const risen = new Set(rises.map((rise) => rise.file));
116
+ const advisory = [...after].filter(([file]) => !risen.has(file)).concat([...repeatedLines(yield* scan(root, head, others))]);
117
+ return {
118
+ measured: held.length,
119
+ rises: rises.toSorted((a, b) => a.file.localeCompare(b.file)),
120
+ advisory: new Map(advisory.toSorted(([a], [b]) => a.localeCompare(b))),
121
+ } satisfies Held;
122
+ });
123
+
124
+ function describe({ file, before, after, clones }: Rise): readonly string[] {
125
+ return [
126
+ ` ${file}: ${after} repeated line(s), up from ${before}`,
127
+ ...clones.map(([own, other]) => ` ${own.file}:${own.start}-${own.end} repeats ${other.file}:${other.start}-${other.end}`),
128
+ ];
129
+ }
130
+
131
+ function report({ measured, rises, advisory }: Held): string {
132
+ const verdict =
133
+ rises.length === 0
134
+ ? [`${NAME}: ${measured} production file(s) repeat no more lines than where the range starts, ${MEASURE}`]
135
+ : [`${NAME}: ${rises.length} production file(s) repeat more lines than where the range starts, ${MEASURE}:`, ...rises.flatMap(describe)];
136
+ const notice =
137
+ advisory.size === 0
138
+ ? []
139
+ : [
140
+ `${NAME}: advisory, ${advisory.size} file(s) repeat lines the hold does not fail:`,
141
+ ...[...advisory].map(([file, lines]) => ` ${file}: ${lines} repeated line(s)`),
142
+ ];
143
+ return [...verdict, ...notice].join("\n");
144
+ }
145
+
146
+ const hold = Effect.gen(function* () {
147
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
148
+ const { base, head } = yield* rangeFromArgs(process.argv.slice(2), USAGE, root);
149
+ const { source, quality } = yield* readQuality(root);
150
+ const production = quality.sources?.production ?? [];
151
+ if (production.length === 0) {
152
+ yield* Console.log(`${NAME}: ${source} declares no sources.production`);
153
+ return true;
154
+ }
155
+ const held = yield* runHold(root, production, base, head);
156
+
157
+ yield* Console.log(report(held));
158
+ return held.rises.length === 0;
159
+ });
160
+
161
+ if (import.meta.main) runMain(NAME, hold);