@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.
@@ -0,0 +1,380 @@
1
+ export type ProseFinding = { readonly line: number; readonly message: string };
2
+
3
+ export type LineKind = "prose" | "heading" | "table" | "html" | "definition" | "break" | "blank" | "code" | "front-matter";
4
+
5
+ export type MarkdownLine = {
6
+ readonly line: number;
7
+ readonly kind: LineKind;
8
+ readonly raw: string;
9
+ readonly prose: string;
10
+ readonly code: readonly string[];
11
+ readonly links: readonly string[];
12
+ };
13
+
14
+ export type Reader = "people" | "agents";
15
+
16
+ export type ProseRule = {
17
+ readonly readers: readonly Reader[];
18
+ readonly refuses: string;
19
+ readonly example: string;
20
+ readonly instead: string;
21
+ };
22
+
23
+ type MatchedRule = ProseRule & { readonly find: RegExp };
24
+
25
+ export const ADR_DIRECTORY = "docs/adr/";
26
+ export const DOCS_DIRECTORY = "docs/";
27
+ export const LIVING_NAMES: readonly string[] = ["README.md", "CONTRIBUTING.md"];
28
+ export const AGENT_NAMES: readonly string[] = ["AGENTS.md", "CLAUDE.md"];
29
+ export const HISTORY_NAMES: readonly string[] = ["CHANGELOG.md"];
30
+ export const DATED_RECORD_EXAMPLES: readonly string[] = ["0001-", "2026-05-08-"];
31
+ const DATED_RECORD = /^\d{4}-/;
32
+
33
+ export function isLivingDoc(repositoryPath: string): boolean {
34
+ const name = repositoryPath.slice(repositoryPath.lastIndexOf("/") + 1);
35
+ if (HISTORY_NAMES.includes(name) || AGENT_NAMES.includes(name) || DATED_RECORD.test(name) || repositoryPath.startsWith(ADR_DIRECTORY)) return false;
36
+ return LIVING_NAMES.includes(name) || (repositoryPath.startsWith(DOCS_DIRECTORY) && name.endsWith(".md"));
37
+ }
38
+
39
+ export function readerOf(repositoryPath: string): Reader | undefined {
40
+ if (isLivingDoc(repositoryPath)) return "people";
41
+ return AGENT_NAMES.includes(repositoryPath.slice(repositoryPath.lastIndexOf("/") + 1)) ? "agents" : undefined;
42
+ }
43
+
44
+ // Masking keeps each line's length, so a column in the masked prose is the same column in the raw line.
45
+ const HIDDEN = "\0";
46
+ const BLANK = " ";
47
+
48
+ type Masked = { readonly prose: string; readonly code: string[]; readonly links: string[]; readonly inComment: boolean };
49
+
50
+ const AUTOLINK = /^<(?:[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>]*|[^\s@<>]+@[^\s<>]+)>/;
51
+ const HTML_TAG = /^<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*)?\/?>/;
52
+ const BARE_URL = /^https?:\/\/[^\s<>]*[^\s<>.,:;!?'")\]]/;
53
+ const ENTITY = /^&(?:#\d+|#x[0-9a-f]+|[a-z][a-z0-9]*);/i;
54
+ const WORD = /\w/;
55
+
56
+ function hiddenAt(raw: string, at: number, rest: string): { readonly length: number; readonly fill: string } | undefined {
57
+ const char = raw.charAt(at);
58
+ const url = char === "h" && !WORD.test(raw.charAt(at - 1)) ? BARE_URL.exec(rest) : null;
59
+ const visible = url ?? (char === "&" ? ENTITY.exec(rest) : null) ?? (char === "<" ? AUTOLINK.exec(rest) : null);
60
+ if (visible !== null) return { length: visible[0].length, fill: HIDDEN };
61
+ const tag = char === "<" ? HTML_TAG.exec(rest) : null;
62
+ return tag === null ? undefined : { length: tag[0].length, fill: BLANK };
63
+ }
64
+
65
+ function runOf(raw: string, at: number, char: string): number {
66
+ let end = at;
67
+ while (raw[end] === char) end += 1;
68
+ return end - at;
69
+ }
70
+
71
+ function closingRun(raw: string, from: number, length: number): number {
72
+ for (let at = raw.indexOf("`", from); at >= 0; at = raw.indexOf("`", at + 1)) {
73
+ const run = runOf(raw, at, "`");
74
+ if (run === length) return at;
75
+ at += run - 1;
76
+ }
77
+ return -1;
78
+ }
79
+
80
+ function destinationEnd(raw: string, open: number): number {
81
+ let depth = 0;
82
+ for (let at = open + 1; at < raw.length; at += 1) {
83
+ const char = raw[at];
84
+ if (char === "\\") at += 1;
85
+ else if (char === "(") depth += 1;
86
+ else if (char === ")") {
87
+ if (depth === 0) return at;
88
+ depth -= 1;
89
+ }
90
+ }
91
+ return -1;
92
+ }
93
+
94
+ function targetOf(destination: string): string {
95
+ const trimmed = destination.trim();
96
+ if (trimmed.startsWith("<")) return trimmed.slice(1, Math.max(trimmed.indexOf(">"), 1));
97
+ return trimmed.split(/\s/, 1)[0] ?? "";
98
+ }
99
+
100
+ function mask(raw: string, startsInComment: boolean): Masked {
101
+ const out = raw.split("");
102
+ const code: string[] = [];
103
+ const links: string[] = [];
104
+ const hide = (from: number, to: number, fill: string): void => {
105
+ for (let at = from; at < to; at += 1) out[at] = fill;
106
+ };
107
+ let inComment = startsInComment;
108
+ let at = 0;
109
+ while (at < raw.length) {
110
+ if (inComment) {
111
+ const close = raw.indexOf("-->", at);
112
+ const end = close < 0 ? raw.length : close + 3;
113
+ hide(at, end, BLANK);
114
+ inComment = close < 0;
115
+ at = end;
116
+ continue;
117
+ }
118
+ const rest = raw.slice(at);
119
+ if (rest.startsWith("<!--")) {
120
+ hide(at, at + 4, BLANK);
121
+ inComment = true;
122
+ at += 4;
123
+ continue;
124
+ }
125
+ if (rest.startsWith("`")) {
126
+ const run = runOf(raw, at, "`");
127
+ const close = closingRun(raw, at + run, run);
128
+ if (close >= 0) {
129
+ code.push(raw.slice(at + run, close).trim());
130
+ hide(at, close + run, HIDDEN);
131
+ at = close + run;
132
+ } else at += run;
133
+ continue;
134
+ }
135
+ if (rest.startsWith("](")) {
136
+ const end = destinationEnd(raw, at + 1);
137
+ if (end >= 0) {
138
+ links.push(targetOf(raw.slice(at + 2, end)));
139
+ hide(at + 1, end + 1, HIDDEN);
140
+ at = end + 1;
141
+ continue;
142
+ }
143
+ }
144
+ const hidden = hiddenAt(raw, at, rest);
145
+ if (hidden !== undefined) {
146
+ hide(at, at + hidden.length, hidden.fill);
147
+ at += hidden.length;
148
+ continue;
149
+ }
150
+ at += 1;
151
+ }
152
+ return { prose: out.join(""), code, links, inComment };
153
+ }
154
+
155
+ const FENCE = /^\s*(`{3,}|~{3,})(.*)$/;
156
+ const DEFINITION = /^\s{0,3}\[[^\]]+\]:\s*(\S+)/;
157
+ const HEADING = /^\s{0,3}#{1,6}(?:\s|$)/;
158
+ const BREAK = /^\s{0,3}(?:[-*_](?:\s*[-*_]){2,}|=+|-+)\s*$/;
159
+ const UNDERLINE = /^\s{0,3}(?:=+|-+)\s*$/;
160
+ const HTML_BLOCK = /^\s{0,3}<[A-Za-z/!?]/;
161
+
162
+ function fenceOpener(raw: string): string | undefined {
163
+ const match = FENCE.exec(raw);
164
+ if (match === null) return undefined;
165
+ const [, fence = "", info = ""] = match;
166
+ return fence.startsWith("`") && info.includes("`") ? undefined : fence;
167
+ }
168
+
169
+ function fenceCloses(raw: string, opener: string): boolean {
170
+ const closer = FENCE.exec(raw);
171
+ return closer !== null && closer[1]?.[0] === opener[0] && (closer[1]?.length ?? 0) >= opener.length && closer[2]?.trim() === "";
172
+ }
173
+
174
+ function kindOf(raw: string, prose: string): LineKind {
175
+ if (raw.trim() === "") return "blank";
176
+ if (HEADING.test(raw)) return "heading";
177
+ if (BREAK.test(raw)) return "break";
178
+ if (raw.trimStart().startsWith("|")) return "table";
179
+ if (HTML_BLOCK.test(raw) && !AUTOLINK.test(raw.trimStart())) return "html";
180
+ return prose.trim() === "" ? "blank" : "prose";
181
+ }
182
+
183
+ function unread(line: number, raw: string, kind: LineKind): MarkdownLine {
184
+ return { line, kind, raw, prose: BLANK.repeat(raw.length), code: [], links: [] };
185
+ }
186
+
187
+ function settext(lines: MarkdownLine[]): MarkdownLine[] {
188
+ return lines.map((line, index) => {
189
+ const next = lines[index + 1];
190
+ return line.kind === "prose" && next?.kind === "break" && UNDERLINE.test(next.raw) ? { ...line, kind: "heading" } : line;
191
+ });
192
+ }
193
+
194
+ export function scanMarkdown(text: string): readonly MarkdownLine[] {
195
+ const raws = text.split("\n").map((raw) => raw.replace(/\r$/, ""));
196
+ const lines: MarkdownLine[] = [];
197
+ let fence: string | undefined;
198
+ let inComment = false;
199
+ let inHtmlBlock = false;
200
+ let frontMatter = raws[0] === "---";
201
+ for (const [index, raw] of raws.entries()) {
202
+ const line = index + 1;
203
+ if (frontMatter) {
204
+ frontMatter = index === 0 || (raw !== "---" && raw !== "...");
205
+ lines.push(unread(line, raw, "front-matter"));
206
+ continue;
207
+ }
208
+ if (fence !== undefined) {
209
+ if (fenceCloses(raw, fence)) fence = undefined;
210
+ lines.push(unread(line, raw, "code"));
211
+ continue;
212
+ }
213
+ const opener = inComment ? undefined : fenceOpener(raw);
214
+ if (opener !== undefined) {
215
+ fence = opener;
216
+ lines.push(unread(line, raw, "code"));
217
+ continue;
218
+ }
219
+ const definition = inComment ? null : DEFINITION.exec(raw);
220
+ if (definition !== null) {
221
+ lines.push({ ...unread(line, raw, "definition"), links: [targetOf(definition[1] ?? "")] });
222
+ continue;
223
+ }
224
+ const masked = mask(raw, inComment);
225
+ inComment = masked.inComment;
226
+ const kind = kindOf(raw, masked.prose);
227
+ // An HTML block runs to the next blank line, whatever its later lines open with.
228
+ inHtmlBlock = kind === "html" || (inHtmlBlock && kind !== "blank");
229
+ lines.push({ line, kind: inHtmlBlock ? "html" : kind, raw, prose: masked.prose, code: masked.code, links: masked.links });
230
+ }
231
+ return settext(lines);
232
+ }
233
+
234
+ const LEADING_MARKERS = /^(?:\s*>)*\s*(?:#{1,6}\s+|(?:[-*+]|\d{1,9}[.)])\s+(?:\[[ xX]\]\s+)?)?/;
235
+
236
+ function bodyOf({ prose }: MarkdownLine): string {
237
+ const markers = LEADING_MARKERS.exec(prose)?.[0] ?? "";
238
+ return BLANK.repeat(markers.length) + prose.slice(markers.length);
239
+ }
240
+
241
+ const SEPARATOR_INSTEAD = "End the sentence, or use a comma";
242
+
243
+ const PROMISES: readonly (readonly [shows: string, pattern: string])[] = [
244
+ ["until #11", String.raw`until \[?(?:[\w.-]+\/[\w.-]+)?#\d+`],
245
+ ["is planned", "(?:is|are) planned"],
246
+ ["will soon", "will soon"],
247
+ ["coming soon", "coming soon"],
248
+ ["in a future release", "in a future (?:release|version)"],
249
+ ];
250
+
251
+ const code = (text: string): string => `\`${text}\``;
252
+ const EVERY_READER: readonly Reader[] = ["people", "agents"];
253
+ const PEOPLE: readonly Reader[] = ["people"];
254
+
255
+ const RULES: readonly MatchedRule[] = [
256
+ { readers: EVERY_READER, refuses: "an em dash", example: code("—"), instead: SEPARATOR_INSTEAD, find: /—/g },
257
+ { readers: EVERY_READER, refuses: "an en dash", example: code("–"), instead: SEPARATOR_INSTEAD, find: /–/g },
258
+ {
259
+ readers: EVERY_READER,
260
+ refuses: "a parenthesis other than the plural `(s)`",
261
+ example: code("("),
262
+ instead: "Make the aside its own sentence, or set it off with commas",
263
+ find: /\((?!s\))/g,
264
+ },
265
+ {
266
+ readers: EVERY_READER,
267
+ refuses: "a hyphen used as a dash",
268
+ example: `${code("a - b")} or ${code("a -- b")}`,
269
+ instead: SEPARATOR_INSTEAD,
270
+ find: /(?<=[^\s|]) -{1,3} (?=[^\s|])/g,
271
+ },
272
+ { readers: EVERY_READER, refuses: "a semicolon", example: code(";"), instead: "Use two sentences", find: /;/g },
273
+ {
274
+ readers: PEOPLE,
275
+ refuses: "a promise about the future",
276
+ example: PROMISES.map(([shows]) => code(shows)).join(", "),
277
+ instead: "Say what is true now",
278
+ find: new RegExp(PROMISES.map(([, pattern]) => String.raw`\b${pattern}\b`).join("|"), "gi"),
279
+ },
280
+ {
281
+ readers: PEOPLE,
282
+ refuses: "a sentence that opens by talking about the page",
283
+ example: code("This page explains"),
284
+ instead: "Talk directly about the subject",
285
+ find: /(?<=^\s*|[.!?:]\s+)(?:this|the following|in this) (?:page|topic|section|document|doc|guide|tutorial|article|readme|file|chapter)\b(?: \w+)?/gi,
286
+ },
287
+ ];
288
+
289
+ const SECOND_SENTENCE: ProseRule = {
290
+ readers: PEOPLE,
291
+ refuses: "a second sentence on one line",
292
+ example: code("It builds. It ships."),
293
+ instead: "Start it on its own line",
294
+ };
295
+
296
+ const RUN_ON: ProseRule = {
297
+ readers: PEOPLE,
298
+ refuses: "a sentence that runs across lines",
299
+ example: `${code("It builds")} with ${code("and ships.")} on the next line`,
300
+ instead: "Join the sentence onto one line",
301
+ };
302
+
303
+ export const PROSE_RULES: readonly ProseRule[] = [
304
+ ...RULES.map(({ readers, refuses, example, instead }) => ({ readers, refuses, example, instead })),
305
+ SECOND_SENTENCE,
306
+ RUN_ON,
307
+ ];
308
+
309
+ const JUDGED_KINDS: ReadonlySet<LineKind> = new Set(["prose", "heading", "table", "html"]);
310
+
311
+ function ruleFindings(line: MarkdownLine, reader: Reader): ProseFinding[] {
312
+ const body = bodyOf(line);
313
+ return RULES.filter(({ readers }) => readers.includes(reader)).flatMap(({ refuses, instead, find }) =>
314
+ [...body.matchAll(find)].map(([match]) => ({ line: line.line, message: `carries \`${match.trim()}\`, ${refuses}. ${instead}` })),
315
+ );
316
+ }
317
+
318
+ const SENTENCE_BREAK = /(?<=[^\s.!?])[.!?]["'”’)\]*_]*\s+(?=[A-Z"“*_[\0])/g;
319
+ const ABBREVIATION = /\b(?:e\.g|i\.e|etc|vs|cf|approx|Mr|Mrs|Ms|Dr|St|No|Fig)$/i;
320
+ const SENTENCE_END = /[.!?:]["'”’)\]*_]*\s*$/;
321
+ const LIST_ITEM = /^(?:\s*>)*\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
322
+ const QUOTE_DEPTH = /^(?:\s*>)*/;
323
+
324
+ // A bold label that opens a line, as in **Status.**, heads the sentence after it rather than being one.
325
+ const RUN_IN_LABEL = /^\s*(\*\*|__)(?:(?!\1).)+?[.!?:]\1\s/;
326
+
327
+ function secondSentence(line: MarkdownLine): ProseFinding | undefined {
328
+ const body = bodyOf(line);
329
+ const label = RUN_IN_LABEL.exec(body)?.[0].length ?? 0;
330
+ for (const match of body.matchAll(SENTENCE_BREAK)) {
331
+ if (match.index < label || ABBREVIATION.test(body.slice(0, match.index))) continue;
332
+ const opening = line.raw.slice(match.index + match[0].length).split(/\s+/, 3).join(" ").replace(/[.!?,:]+$/, "");
333
+ return { line: line.line, message: `carries ${SECOND_SENTENCE.refuses}, which opens with \`${opening}\`. ${SECOND_SENTENCE.instead}` };
334
+ }
335
+ return undefined;
336
+ }
337
+
338
+ const OPENS_LOWERCASE = /^\s*[a-z]/;
339
+
340
+ // A sentence may end inside the code or link that closes its line, so only a lowercase next line proves it runs on.
341
+ function endsMidSentence(line: MarkdownLine, next: MarkdownLine): boolean {
342
+ const body = bodyOf(line).trimEnd();
343
+ if (SENTENCE_END.test(body) || line.raw.endsWith(" ") || line.raw.endsWith("\\")) return false;
344
+ return !body.endsWith(HIDDEN) || OPENS_LOWERCASE.test(bodyOf(next));
345
+ }
346
+
347
+ function continues(earlier: MarkdownLine | undefined, later: MarkdownLine | undefined): boolean {
348
+ if (earlier?.kind !== "prose" || later?.kind !== "prose" || LIST_ITEM.test(later.raw)) return false;
349
+ return QUOTE_DEPTH.exec(earlier.raw)?.[0].split(">").length === QUOTE_DEPTH.exec(later.raw)?.[0].split(">").length;
350
+ }
351
+
352
+ function spansLines(lines: readonly MarkdownLine[], index: number): ProseFinding | undefined {
353
+ const line = lines[index];
354
+ if (line === undefined) return undefined;
355
+ const next = lines[index + 1];
356
+ const previous = lines[index - 1];
357
+ const forward = next !== undefined && continues(line, next) && endsMidSentence(line, next);
358
+ const back = previous !== undefined && continues(previous, line) && endsMidSentence(previous, line);
359
+ if (!forward && !back) return undefined;
360
+ return { line: line.line, message: `carries ${RUN_ON.refuses}. ${RUN_ON.instead}` };
361
+ }
362
+
363
+ export function proseFindings(text: string, reader: Reader, within?: ReadonlySet<number>): readonly ProseFinding[] {
364
+ const lines = scanMarkdown(text);
365
+ return lines.flatMap((line, index) => {
366
+ if ((within !== undefined && !within.has(line.line)) || !JUDGED_KINDS.has(line.kind)) return [];
367
+ const prose = line.kind === "prose";
368
+ const sentences = [
369
+ prose && SECOND_SENTENCE.readers.includes(reader) ? secondSentence(line) : undefined,
370
+ prose && RUN_ON.readers.includes(reader) ? spansLines(lines, index) : undefined,
371
+ ];
372
+ return [...ruleFindings(line, reader), ...sentences.filter((finding) => finding !== undefined)];
373
+ });
374
+ }
375
+
376
+ export function proseRefused(repositoryPath: string, text: string, within?: ReadonlySet<number>): string[] {
377
+ const reader = readerOf(repositoryPath);
378
+ if (reader === undefined) return [];
379
+ return proseFindings(text, reader, within).map(({ line, message }) => `${repositoryPath}:${line} ${message}`);
380
+ }
@@ -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
 
@@ -20,6 +21,16 @@ const PathGlob = Schema.String.check(
20
21
  "A glob from the repository root that oxlint, the Effect language service and git read alike: a directory first, * within a segment, ** as a whole one, a file name with an extension last, and no braces, ?, [ or leading ./",
21
22
  });
22
23
 
24
+ // Only checks-docs reads a doc glob, matching it from the root, so a file at the root names itself.
25
+ const DocGlob = Schema.String.check(
26
+ Schema.isPattern(new RegExp(`^(?:${SEGMENT}/)*${FILE}$`), {
27
+ expected: "a glob from the repository root such as README.md or docs/**/*.md: * within a segment, ** as a whole one, a file name with an extension last",
28
+ }),
29
+ ).annotate({
30
+ identifier: "DocGlob",
31
+ description: "A glob from the repository root that checks-docs reads: * within a segment, ** as a whole one, a file name with an extension last",
32
+ });
33
+
23
34
  const LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
24
35
 
25
36
  const DirectoryPath = Schema.String.check(
@@ -87,19 +98,6 @@ const Sources = Schema.Struct({
87
98
  effect: Schema.optionalKey(EffectSources),
88
99
  });
89
100
 
90
- const LineBudget = Schema.Int.check(Schema.isGreaterThan(0));
91
-
92
- const Size = Schema.Struct({
93
- fileLines: LineBudget.annotate({ description: "The most lines a file may hold, blank and comment lines counted" }),
94
- functionLines: LineBudget.annotate({
95
- description: "The most lines a function may span, blank and comment lines counted",
96
- }),
97
- applies: Schema.Literals(["changed", "all"]).annotate({
98
- description:
99
- "Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory",
100
- }),
101
- });
102
-
103
101
  const Feature = Schema.Struct({
104
102
  name: Schema.String.check(
105
103
  Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a feature name in kebab case" }),
@@ -166,6 +164,11 @@ const Docs = Schema.Struct({
166
164
  description: "The Diátaxis mode of each page, whose template checks-docs holds the page to; a page under docs/ needs one",
167
165
  }),
168
166
  ),
167
+ forConsumers: Schema.optionalKey(
168
+ Schema.Array(DocGlob).annotate({
169
+ 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",
170
+ }),
171
+ ),
169
172
  });
170
173
  export type Docs = typeof Docs.Type;
171
174
 
@@ -177,9 +180,7 @@ export const Quality = Schema.Struct({
177
180
  gates: Schema.optionalKey(Gates),
178
181
  commitIdentity: Schema.optionalKey(CommitIdentity),
179
182
  sources: Schema.optionalKey(Sources),
180
- size: Schema.optionalKey(
181
- Size.annotate({ description: "The line budget oxlint holds production files to, read by checks-size-budget" }),
182
- ),
183
+ size: Schema.optionalKey(Size),
183
184
  features: Schema.optionalKey(
184
185
  Features.annotate({
185
186
  description: "The feature owners dependency-cruiser holds to their entries and checks-feature-owners maps a change to",
@@ -200,7 +201,7 @@ export const Quality = Schema.Struct({
200
201
  .check(
201
202
  Schema.makeFilter(
202
203
  ({ size, sources }) =>
203
- 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",
204
205
  {
205
206
  toJsonSchema: () => ({
206
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);