@avi2dg/checks 0.16.0 → 0.18.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,74 @@
1
+ export type Command = readonly string[];
2
+
3
+ type Span = {
4
+ readonly text: string;
5
+ readonly end: number;
6
+ };
7
+
8
+ const VARIABLE = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/;
9
+ const UNPLAIN = new Set(["|", "&", ";", "<", ">", "(", ")", "`", "\\", "#", "\n"]);
10
+
11
+ function variable(line: string, from: number): Span | undefined {
12
+ const match = VARIABLE.exec(line.slice(from));
13
+ return match === null ? undefined : { text: match[0], end: from + match[0].length };
14
+ }
15
+
16
+ function singleQuoted(line: string, open: number): Span | undefined {
17
+ const close = line.indexOf("'", open + 1);
18
+ return close === -1 ? undefined : { text: line.slice(open + 1, close), end: close + 1 };
19
+ }
20
+
21
+ function doubleQuoted(line: string, open: number): Span | undefined {
22
+ let text = "";
23
+ let index = open + 1;
24
+ while (line.charAt(index) !== '"') {
25
+ const inner = line.charAt(index);
26
+ if (inner === "" || inner === "\\" || inner === "`") return undefined;
27
+ const part = inner === "$" ? variable(line, index) : { text: inner, end: index + 1 };
28
+ if (part === undefined) return undefined;
29
+ text += part.text;
30
+ index = part.end;
31
+ }
32
+ return { text, end: index + 1 };
33
+ }
34
+
35
+ function wordPart(line: string, index: number): Span | undefined {
36
+ const char = line.charAt(index);
37
+ if (char === "'") return singleQuoted(line, index);
38
+ if (char === '"') return doubleQuoted(line, index);
39
+ if (char === "$") return variable(line, index);
40
+ return UNPLAIN.has(char) ? undefined : { text: char, end: index + 1 };
41
+ }
42
+
43
+ // A script counts only when it is one line of plain words: any shell control, redirection or
44
+ // substitution can run the gate without its failure failing the step.
45
+ export function plainCommand(script: string): Command | undefined {
46
+ const line = script.trim();
47
+ const words: string[] = [];
48
+ let word: string | undefined;
49
+ let index = 0;
50
+ while (index < line.length) {
51
+ const char = line.charAt(index);
52
+ if (char === " " || char === "\t") {
53
+ if (word !== undefined) words.push(word);
54
+ word = undefined;
55
+ index += 1;
56
+ } else {
57
+ const part = wordPart(line, index);
58
+ if (part === undefined) return undefined;
59
+ word = (word ?? "") + part.text;
60
+ index = part.end;
61
+ }
62
+ }
63
+ if (word !== undefined) words.push(word);
64
+ return words.length === 0 ? undefined : words;
65
+ }
66
+
67
+ export function invokes(command: Command | undefined, gate: Command): boolean {
68
+ return command !== undefined && gate.length <= command.length && gate.every((word, index) => command[index] === word);
69
+ }
70
+
71
+ export function mentions(script: string, gate: Command): boolean {
72
+ const tokens = script.split(/[\s|&;<>()`]+/);
73
+ return tokens.some((_, start) => invokes(tokens.slice(start), gate));
74
+ }
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { changedPaths, collect, git, pathsAt, rangeEnds, type Change } from "./git.ts";
4
- import { runMain, Usage } from "./main.ts";
5
- import { readQuality, renderJson } from "./quality-file.ts";
4
+ import { runMain } from "./main.ts";
5
+ import { renderJson } from "./quality-file.ts";
6
+ import { rangeGateInputs } from "./range-gate.ts";
6
7
  import {
7
8
  budgetOf,
8
9
  budgetsOf,
10
+ diagnosticCode,
11
+ qualifiedName,
9
12
  SIZE_DEFAULTS,
10
13
  SIZE_RULES,
11
14
  TESTS_DIRECTORY,
@@ -68,16 +71,17 @@ const decodeReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Str
68
71
 
69
72
  function rulesOf(budget: Budget): Record<string, unknown> {
70
73
  return Object.fromEntries(
71
- SIZE_RULES.map(({ key, rule, options }) => {
72
- const max = budget[key];
73
- return [rule, max === undefined ? "off" : ["error", { max, ...options }]];
74
+ SIZE_RULES.map((entry) => {
75
+ const max = budget[entry.key];
76
+ return [qualifiedName(entry), max === undefined ? "off" : ["error", { max, ...entry.options }]];
74
77
  }),
75
78
  );
76
79
  }
77
80
 
78
- function sizeConfig({ production, tests }: Budgets): unknown {
81
+ function sizeConfig({ production, tests }: Budgets, plugin: string): unknown {
79
82
  return {
80
83
  plugins: [],
84
+ jsPlugins: [plugin],
81
85
  categories: { correctness: "off" },
82
86
  rules: rulesOf(production),
83
87
  overrides: [{ files: [`${TESTS_DIRECTORY}/**`], rules: rulesOf(tests) }],
@@ -116,7 +120,7 @@ const materializeBase = Effect.fn("materializeBase")(function* (root: string, ba
116
120
 
117
121
  const siteOf = (budgets: Budgets) =>
118
122
  Effect.fn("siteOf")(function* ({ code, message, filename, labels }: typeof Diagnostic.Type) {
119
- const rule = SIZE_RULES.find((candidate) => code === `eslint(${candidate.rule})`);
123
+ const rule = SIZE_RULES.find((candidate) => code === diagnosticCode(candidate));
120
124
  if (rule === undefined) return [];
121
125
  const measured = rule.measured.exec(message)?.[1];
122
126
  const max = budgetOf(budgets, filename)[rule.key];
@@ -135,11 +139,11 @@ const siteOf = (budgets: Budgets) =>
135
139
  ];
136
140
  });
137
141
 
138
- const measure = Effect.fn("measure")(function* (tree: string, budgets: Budgets) {
142
+ const measure = Effect.fn("measure")(function* (tree: string, budgets: Budgets, plugin: string) {
139
143
  const fs = yield* FileSystem.FileSystem;
140
144
  if (!(yield* fs.exists(tree))) return [];
141
145
  // oxlint reads an override's glob from the directory of the config that holds it, so the config sits in the tree.
142
- yield* fs.writeFileString((yield* Path.Path).join(tree, CONFIG), renderJson(sizeConfig(budgets)));
146
+ yield* fs.writeFileString((yield* Path.Path).join(tree, CONFIG), renderJson(sizeConfig(budgets, plugin)));
143
147
 
144
148
  const { stdout, stderr, exitCode } = yield* collect("oxlint", ["-c", CONFIG, "-f", "json", "."], tree).pipe(
145
149
  Effect.mapError((cause) => new OxlintUnreadable({ message: `cannot run oxlint: ${cause.message}` })),
@@ -190,7 +194,8 @@ const runBudget = Effect.fn("runBudget")(
190
194
 
191
195
  const headTree = path.join(scratch, "head");
192
196
  if (held.length + others.length > 0) yield* materializeHead(root, head, [...holds, ...others], headTree);
193
- const sites = yield* measure(headTree, budgets);
197
+ const plugin = path.join(import.meta.dir, "..", "dist", "index.js");
198
+ const sites = yield* measure(headTree, budgets, plugin);
194
199
  const heldSites = sites.filter((site) => holds.has(site.file));
195
200
  if (applies !== "ratchet") {
196
201
  return { applies, held: held.length, overruns: heldSites, advisory: sites.filter((site) => !holds.has(site.file)) } satisfies Verdict;
@@ -198,7 +203,7 @@ const runBudget = Effect.fn("runBudget")(
198
203
 
199
204
  const baseTree = path.join(scratch, "base");
200
205
  if (held.length > 0) yield* materializeBase(root, base, held, baseTree);
201
- const growths = growthsOf(heldSites, yield* measure(baseTree, budgets));
206
+ const growths = growthsOf(heldSites, yield* measure(baseTree, budgets, plugin));
202
207
  const failing = new Set(growths.flatMap((growth) => growth.sites));
203
208
  return { applies, held: held.length, growths, advisory: sites.filter((site) => !failing.has(site)) } satisfies Verdict;
204
209
  },
@@ -239,16 +244,12 @@ function passes(verdict: Verdict): boolean {
239
244
  }
240
245
 
241
246
  const budget = Effect.gen(function* () {
242
- const [first, second, ...extra] = process.argv.slice(2);
243
- if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
244
-
245
- const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
246
- const { source, quality } = yield* readQuality(root);
247
+ const { refs, root, source, quality } = yield* rangeGateInputs(USAGE);
247
248
  if (quality.size === undefined) {
248
249
  yield* Console.log(`${NAME}: ${source} declares no size budget`);
249
250
  return true;
250
251
  }
251
- const { base, head } = yield* rangeEnds(first, second, root);
252
+ const { base, head } = yield* rangeEnds(refs.first, refs.second, root);
252
253
  const verdict = yield* runBudget(root, quality.size, quality.sources?.production ?? [], base, head);
253
254
 
254
255
  yield* Console.log(report(verdict));
@@ -30,10 +30,11 @@ const STATEMENTS = {
30
30
 
31
31
  const COMPLEXITY = {
32
32
  key: "complexity",
33
- rule: "complexity",
34
- options: { variant: "modified" },
35
- measured: /has a complexity of (\d+)/,
36
- limits: "The highest cyclomatic complexity a function may reach, a switch counted once",
33
+ rule: "cognitive-complexity",
34
+ plugin: "effect-channel",
35
+ options: {},
36
+ measured: /has a cognitive complexity of (\d+)/,
37
+ limits: "The highest cognitive complexity a function may reach, a switch counted once",
37
38
  } as const;
38
39
 
39
40
  const DEPTH = {
@@ -49,6 +50,14 @@ export const SIZE_RULES = [FILE_LINES, FUNCTION_LINES, STATEMENTS, COMPLEXITY, D
49
50
  export type SizeRule = (typeof SIZE_RULES)[number];
50
51
  export type LimitKey = SizeRule["key"];
51
52
 
53
+ export function qualifiedName(rule: SizeRule): string {
54
+ return "plugin" in rule ? `${rule.plugin}/${rule.rule}` : rule.rule;
55
+ }
56
+
57
+ export function diagnosticCode(rule: SizeRule): string {
58
+ return "plugin" in rule ? `${rule.plugin}(${rule.rule})` : `eslint(${rule.rule})`;
59
+ }
60
+
52
61
  // An absent limit turns its rule off.
53
62
  export type Budget = { readonly [K in LimitKey]?: number };
54
63
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Schema } from "effect";
3
- import { git, parentOrEmptyTree } from "./git.ts";
4
- import { runMain, Usage } from "./main.ts";
3
+ import { git, parentOrEmptyTree, refArgs } from "./git.ts";
4
+ import { runMain } from "./main.ts";
5
5
 
6
6
  export const SUPPRESSIONS = "oxlint-suppressions.json";
7
7
 
@@ -49,28 +49,27 @@ export const parseSuppressions = Effect.fn("parseSuppressions")(function* (
49
49
  return suppressions;
50
50
  });
51
51
 
52
+ type Count = {
53
+ readonly file: string;
54
+ readonly rule: string;
55
+ readonly count: number;
56
+ };
57
+
58
+ function countsOf(suppressions: Suppressions): readonly Count[] {
59
+ return [...suppressions].flatMap(([file, rules]) => [...rules].map(([rule, count]) => ({ file, rule, count })));
60
+ }
61
+
62
+ function riseOf({ file, rule, count }: Count, before: number | undefined): readonly Rise[] {
63
+ if (before === undefined) return count > 0 ? [{ kind: "appeared", file, rule, head: count }] : [];
64
+ return count > before ? [{ kind: "rose", file, rule, base: before, head: count }] : [];
65
+ }
66
+
52
67
  export function compareSuppressions(base: Suppressions, head: Suppressions): Ratchet {
53
- const rises: Rise[] = [];
54
- let counted = 0;
55
- for (const [file, rules] of head) {
56
- for (const [rule, count] of rules) {
57
- counted += 1;
58
- const before = base.get(file)?.get(rule);
59
- if (before === undefined) {
60
- if (count > 0) rises.push({ kind: "appeared", file, rule, head: count });
61
- } else if (count > before) {
62
- rises.push({ kind: "rose", file, rule, base: before, head: count });
63
- }
64
- }
65
- }
66
- let lowered = 0;
67
- for (const [file, rules] of base) {
68
- for (const [rule, count] of rules) {
69
- if ((head.get(file)?.get(rule) ?? 0) < count) lowered += 1;
70
- }
71
- }
68
+ const counts = countsOf(head);
69
+ const rises = counts.flatMap((counted) => riseOf(counted, base.get(counted.file)?.get(counted.rule)));
70
+ const lowered = countsOf(base).filter(({ file, rule, count }) => (head.get(file)?.get(rule) ?? 0) < count).length;
72
71
  rises.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
73
- return { counted, lowered, rises };
72
+ return { counted: counts.length, lowered, rises };
74
73
  }
75
74
 
76
75
  function describeRise(rise: Rise): string {
@@ -118,8 +117,7 @@ export const runTip = Effect.fn("runTip")(function* (tip: string) {
118
117
  });
119
118
 
120
119
  const ratchet = Effect.gen(function* () {
121
- const [first, second, ...extra] = process.argv.slice(2);
122
- if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
120
+ const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
123
121
 
124
122
  const result = second !== undefined ? yield* runRange(first, second) : yield* runTip(first);
125
123
 
@@ -147,45 +147,47 @@ function importedNames(node: Record<string, unknown>): readonly string[] {
147
147
  return names;
148
148
  }
149
149
 
150
- function outOfProcessUse(node: Record<string, unknown>): string | undefined {
151
- const type = node["type"];
150
+ type UseCheck = (node: Record<string, unknown>) => string | undefined;
151
+
152
+ function moduleUse(node: Record<string, unknown>): string | undefined {
153
+ const source = stringValue(node["source"]);
154
+ if (source === undefined) return undefined;
155
+ const banned = bannedModule(source);
156
+ if (banned !== undefined) return `imports node:${banned}`;
157
+ if (source !== "bun") return undefined;
158
+ const name = importedNames(node).find((candidate) => BANNED_BUN_NAMES.includes(candidate));
159
+ return name === undefined ? undefined : `imports ${name} from bun`;
160
+ }
152
161
 
153
- if (type === "ImportDeclaration" || type === "ExportNamedDeclaration" || type === "ExportAllDeclaration") {
154
- const source = stringValue(node["source"]);
155
- if (source === undefined) return undefined;
156
- const banned = bannedModule(source);
157
- if (banned !== undefined) return `imports node:${banned}`;
158
- if (source === "bun") {
159
- const name = importedNames(node).find((candidate) => BANNED_BUN_NAMES.includes(candidate));
160
- if (name !== undefined) return `imports ${name} from bun`;
161
- }
162
- return undefined;
163
- }
162
+ function bunMemberUse(node: Record<string, unknown>): string | undefined {
163
+ if (identifierName(node["object"]) !== "Bun") return undefined;
164
+ const property = identifierName(node["property"]);
165
+ return property !== undefined && BANNED_BUN_NAMES.includes(property) ? `uses Bun.${property}` : undefined;
166
+ }
164
167
 
165
- if (type === "MemberExpression") {
166
- if (identifierName(node["object"]) !== "Bun") return undefined;
167
- const property = identifierName(node["property"]);
168
- if (property !== undefined && BANNED_BUN_NAMES.includes(property)) return `uses Bun.${property}`;
169
- return undefined;
170
- }
168
+ function callUse(node: Record<string, unknown>): string | undefined {
169
+ const callee = node["callee"];
170
+ const calleeName = identifierName(callee);
171
+ if (calleeName !== undefined && BANNED_GLOBAL_CALLS.includes(calleeName)) return `calls ${calleeName}`;
172
+ const argument = Array.isArray(node["arguments"]) ? node["arguments"][0] : undefined;
173
+ const specifier = isRecord(argument) ? stringValue(argument["expression"]) : undefined;
174
+ const banned = specifier === undefined ? undefined : bannedModule(specifier);
175
+ if (banned === undefined) return undefined;
176
+ if (isRecord(callee) && callee["type"] === "Import") return `imports node:${banned}`;
177
+ return calleeName === "require" ? `requires node:${banned}` : undefined;
178
+ }
171
179
 
172
- if (type === "CallExpression") {
173
- const callee = node["callee"];
174
- const calleeName = identifierName(callee);
175
- if (calleeName !== undefined && BANNED_GLOBAL_CALLS.includes(calleeName)) {
176
- return `calls ${calleeName}`;
177
- }
178
- const argument = Array.isArray(node["arguments"]) ? node["arguments"][0] : undefined;
179
- const specifier = isRecord(argument) ? stringValue(argument["expression"]) : undefined;
180
- if (specifier === undefined) return undefined;
181
- const banned = bannedModule(specifier);
182
- if (banned === undefined) return undefined;
183
- if (isRecord(callee) && callee["type"] === "Import") return `imports node:${banned}`;
184
- if (calleeName === "require") return `requires node:${banned}`;
185
- return undefined;
186
- }
180
+ const USE_CHECKS = new Map<string, UseCheck>([
181
+ ["ImportDeclaration", moduleUse],
182
+ ["ExportNamedDeclaration", moduleUse],
183
+ ["ExportAllDeclaration", moduleUse],
184
+ ["MemberExpression", bunMemberUse],
185
+ ["CallExpression", callUse],
186
+ ]);
187
187
 
188
- return undefined;
188
+ function outOfProcessUse(node: Record<string, unknown>): string | undefined {
189
+ const type = node["type"];
190
+ return typeof type === "string" ? USE_CHECKS.get(type)?.(node) : undefined;
189
191
  }
190
192
 
191
193
  export const isolationViolations = Effect.fn("isolationViolations")(function* (file: string, source: string) {
@@ -52,26 +52,31 @@ function attributesOf(source: string): Readonly<Record<string, string>> {
52
52
  return attributes;
53
53
  }
54
54
 
55
+ const malformed = (reason: string) => new ReportError({ message: `the junit report is malformed: ${reason}` });
56
+
57
+ const takeTag = Effect.fnUntraced(function* (open: Element[], match: RegExpExecArray): Effect.fn.Return<void, ReportError> {
58
+ const [, closing, tag, attributes, selfClosing] = match;
59
+ if (tag === undefined) return;
60
+ const parent = open.at(-1);
61
+ if (parent === undefined) return yield* malformed(`<${tag}> after the root closed`);
62
+ if (closing === "/") {
63
+ if (parent.tag !== tag) return yield* malformed(`</${tag}> closes <${parent.tag}>`);
64
+ open.pop();
65
+ return;
66
+ }
67
+ const element: Element = { tag, attributes: attributesOf(attributes ?? ""), children: [] };
68
+ parent.children.push(element);
69
+ if (selfClosing !== "/") open.push(element);
70
+ });
71
+
55
72
  const parseElements = Effect.fnUntraced(function* (xml: string): Effect.fn.Return<Element, ReportError> {
56
- const malformed = (reason: string) => new ReportError({ message: `the junit report is malformed: ${reason}` });
57
73
  const document: Element = { tag: "", attributes: {}, children: [] };
58
74
  const open: Element[] = [document];
59
75
  let consumed = 0;
60
76
  for (const match of xml.matchAll(MARKUP)) {
61
77
  if (xml.slice(consumed, match.index).includes("<")) return yield* malformed(`stray < before offset ${match.index}`);
62
78
  consumed = match.index + match[0].length;
63
- const [, closing, tag, attributes, selfClosing] = match;
64
- if (tag === undefined) continue;
65
- const parent = open.at(-1);
66
- if (parent === undefined) return yield* malformed(`<${tag}> after the root closed`);
67
- if (closing === "/") {
68
- if (parent.tag !== tag) return yield* malformed(`</${tag}> closes <${parent.tag}>`);
69
- open.pop();
70
- continue;
71
- }
72
- const element: Element = { tag, attributes: attributesOf(attributes ?? ""), children: [] };
73
- parent.children.push(element);
74
- if (selfClosing !== "/") open.push(element);
79
+ yield* takeTag(open, match);
75
80
  }
76
81
  if (xml.slice(consumed).includes("<")) return yield* malformed("stray < after the last element");
77
82
  if (open.length !== 1) return yield* malformed(`<${open.at(-1)?.tag}> never closes`);