@shivaedev/quality 0.2.0 → 0.3.1

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.
Files changed (258) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +207 -35
  3. package/dist/baseline/compare.d.ts +3 -8
  4. package/dist/baseline/compare.d.ts.map +1 -1
  5. package/dist/baseline/compare.js +13 -23
  6. package/dist/baseline/compare.js.map +1 -1
  7. package/dist/baseline/format.d.ts +7 -5
  8. package/dist/baseline/format.d.ts.map +1 -1
  9. package/dist/baseline/format.js +31 -23
  10. package/dist/baseline/format.js.map +1 -1
  11. package/dist/baseline/guard.d.ts +23 -0
  12. package/dist/baseline/guard.d.ts.map +1 -0
  13. package/dist/baseline/guard.js +31 -0
  14. package/dist/baseline/guard.js.map +1 -0
  15. package/dist/baseline/legacy.d.ts +11 -0
  16. package/dist/baseline/legacy.d.ts.map +1 -0
  17. package/dist/baseline/legacy.js +33 -0
  18. package/dist/baseline/legacy.js.map +1 -0
  19. package/dist/baseline/rewrite.d.ts +3 -0
  20. package/dist/baseline/rewrite.d.ts.map +1 -0
  21. package/dist/baseline/rewrite.js +19 -0
  22. package/dist/baseline/rewrite.js.map +1 -0
  23. package/dist/baseline/update.d.ts +7 -6
  24. package/dist/baseline/update.d.ts.map +1 -1
  25. package/dist/baseline/update.js +28 -28
  26. package/dist/baseline/update.js.map +1 -1
  27. package/dist/cli/args.d.ts +14 -1
  28. package/dist/cli/args.d.ts.map +1 -1
  29. package/dist/cli/args.js +54 -29
  30. package/dist/cli/args.js.map +1 -1
  31. package/dist/cli/baseline.d.ts +3 -1
  32. package/dist/cli/baseline.d.ts.map +1 -1
  33. package/dist/cli/baseline.js +19 -10
  34. package/dist/cli/baseline.js.map +1 -1
  35. package/dist/cli/check.d.ts +5 -0
  36. package/dist/cli/check.d.ts.map +1 -0
  37. package/dist/cli/check.js +39 -0
  38. package/dist/cli/check.js.map +1 -0
  39. package/dist/cli/lint.js +1 -1
  40. package/dist/cli/lint.js.map +1 -1
  41. package/dist/cli/migrate.d.ts +4 -0
  42. package/dist/cli/migrate.d.ts.map +1 -0
  43. package/dist/cli/migrate.js +31 -0
  44. package/dist/cli/migrate.js.map +1 -0
  45. package/dist/cli/run-main.d.ts +2 -1
  46. package/dist/cli/run-main.d.ts.map +1 -1
  47. package/dist/cli/run-main.js +5 -2
  48. package/dist/cli/run-main.js.map +1 -1
  49. package/dist/cli/shrink.d.ts +6 -0
  50. package/dist/cli/shrink.d.ts.map +1 -0
  51. package/dist/cli/shrink.js +8 -0
  52. package/dist/cli/shrink.js.map +1 -0
  53. package/dist/cli.js +11 -2
  54. package/dist/cli.js.map +1 -1
  55. package/dist/config/decode.d.ts +1 -0
  56. package/dist/config/decode.d.ts.map +1 -1
  57. package/dist/config/decode.js +1 -0
  58. package/dist/config/decode.js.map +1 -1
  59. package/dist/config/file.d.ts +2 -0
  60. package/dist/config/file.d.ts.map +1 -0
  61. package/dist/config/file.js +2 -0
  62. package/dist/config/file.js.map +1 -0
  63. package/dist/config/load.d.ts +1 -1
  64. package/dist/config/load.d.ts.map +1 -1
  65. package/dist/config/load.js +3 -2
  66. package/dist/config/load.js.map +1 -1
  67. package/dist/config/resolve.d.ts +1 -0
  68. package/dist/config/resolve.d.ts.map +1 -1
  69. package/dist/config/resolve.js +6 -4
  70. package/dist/config/resolve.js.map +1 -1
  71. package/dist/config.d.ts +2 -7
  72. package/dist/config.d.ts.map +1 -1
  73. package/dist/config.js.map +1 -1
  74. package/dist/engine/baseline-file.d.ts +11 -0
  75. package/dist/engine/baseline-file.d.ts.map +1 -0
  76. package/dist/engine/baseline-file.js +18 -0
  77. package/dist/engine/baseline-file.js.map +1 -0
  78. package/dist/engine/evaluate.d.ts +2 -2
  79. package/dist/engine/evaluate.d.ts.map +1 -1
  80. package/dist/engine/evaluate.js +3 -3
  81. package/dist/engine/evaluate.js.map +1 -1
  82. package/dist/engine/session.d.ts +3 -5
  83. package/dist/engine/session.d.ts.map +1 -1
  84. package/dist/engine/session.js +12 -10
  85. package/dist/engine/session.js.map +1 -1
  86. package/dist/exceptions/registry.d.ts +1 -1
  87. package/dist/exceptions/registry.d.ts.map +1 -1
  88. package/dist/exceptions/registry.js +8 -3
  89. package/dist/exceptions/registry.js.map +1 -1
  90. package/dist/failure.d.ts +0 -2
  91. package/dist/failure.d.ts.map +1 -1
  92. package/dist/failure.js +0 -2
  93. package/dist/failure.js.map +1 -1
  94. package/dist/git/base.d.ts +9 -0
  95. package/dist/git/base.d.ts.map +1 -0
  96. package/dist/git/base.js +59 -0
  97. package/dist/git/base.js.map +1 -0
  98. package/dist/git/command.d.ts +12 -0
  99. package/dist/git/command.d.ts.map +1 -0
  100. package/dist/git/command.js +13 -0
  101. package/dist/git/command.js.map +1 -0
  102. package/dist/git/history.d.ts +10 -0
  103. package/dist/git/history.d.ts.map +1 -0
  104. package/dist/git/history.js +28 -0
  105. package/dist/git/history.js.map +1 -0
  106. package/dist/inventory/ignore-scope.d.ts.map +1 -1
  107. package/dist/inventory/ignore-scope.js +0 -1
  108. package/dist/inventory/ignore-scope.js.map +1 -1
  109. package/dist/inventory/walk.d.ts +0 -1
  110. package/dist/inventory/walk.d.ts.map +1 -1
  111. package/dist/inventory/walk.js +1 -1
  112. package/dist/inventory/walk.js.map +1 -1
  113. package/dist/report/guard.d.ts +9 -0
  114. package/dist/report/guard.d.ts.map +1 -0
  115. package/dist/report/guard.js +34 -0
  116. package/dist/report/guard.js.map +1 -0
  117. package/dist/report/render.js +1 -2
  118. package/dist/report/render.js.map +1 -1
  119. package/dist/report/stale.d.ts.map +1 -1
  120. package/dist/report/stale.js +4 -3
  121. package/dist/report/stale.js.map +1 -1
  122. package/dist/rule.d.ts +4 -7
  123. package/dist/rule.d.ts.map +1 -1
  124. package/dist/rule.js +1 -1
  125. package/dist/rule.js.map +1 -1
  126. package/dist/rules/built-in.d.ts +17 -0
  127. package/dist/rules/built-in.d.ts.map +1 -1
  128. package/dist/rules/built-in.js +21 -1
  129. package/dist/rules/built-in.js.map +1 -1
  130. package/dist/rules/comments/kinds.d.ts +9 -0
  131. package/dist/rules/comments/kinds.d.ts.map +1 -0
  132. package/dist/rules/comments/kinds.js +34 -0
  133. package/dist/rules/comments/kinds.js.map +1 -0
  134. package/dist/rules/comments/max-per-file.d.ts +5 -0
  135. package/dist/rules/comments/max-per-file.d.ts.map +1 -0
  136. package/dist/rules/comments/max-per-file.js +39 -0
  137. package/dist/rules/comments/max-per-file.js.map +1 -0
  138. package/dist/rules/comments/no-banner.d.ts +2 -0
  139. package/dist/rules/comments/no-banner.d.ts.map +1 -0
  140. package/dist/rules/comments/no-banner.js +9 -0
  141. package/dist/rules/comments/no-banner.js.map +1 -0
  142. package/dist/rules/comments/no-jsdoc.d.ts +4 -0
  143. package/dist/rules/comments/no-jsdoc.d.ts.map +1 -0
  144. package/dist/rules/comments/no-jsdoc.js +14 -0
  145. package/dist/rules/comments/no-jsdoc.js.map +1 -0
  146. package/dist/rules/comments/no-line-reference.d.ts +2 -0
  147. package/dist/rules/comments/no-line-reference.d.ts.map +1 -0
  148. package/dist/rules/comments/no-line-reference.js +9 -0
  149. package/dist/rules/comments/no-line-reference.js.map +1 -0
  150. package/dist/rules/comments/no-pr-reference.d.ts +2 -0
  151. package/dist/rules/comments/no-pr-reference.d.ts.map +1 -0
  152. package/dist/rules/comments/no-pr-reference.js +13 -0
  153. package/dist/rules/comments/no-pr-reference.js.map +1 -0
  154. package/dist/rules/comments/no-todo.d.ts +2 -0
  155. package/dist/rules/comments/no-todo.d.ts.map +1 -0
  156. package/dist/rules/comments/no-todo.js +8 -0
  157. package/dist/rules/comments/no-todo.js.map +1 -0
  158. package/dist/rules/comments/pattern-rule.d.ts +10 -0
  159. package/dist/rules/comments/pattern-rule.d.ts.map +1 -0
  160. package/dist/rules/comments/pattern-rule.js +16 -0
  161. package/dist/rules/comments/pattern-rule.js.map +1 -0
  162. package/dist/rules/comments/scan.d.ts +13 -0
  163. package/dist/rules/comments/scan.d.ts.map +1 -0
  164. package/dist/rules/comments/scan.js +62 -0
  165. package/dist/rules/comments/scan.js.map +1 -0
  166. package/dist/rules/max-lines.d.ts.map +1 -1
  167. package/dist/rules/max-lines.js +8 -1
  168. package/dist/rules/max-lines.js.map +1 -1
  169. package/dist/rules/suppressions/biome/configs.d.ts +14 -0
  170. package/dist/rules/suppressions/biome/configs.d.ts.map +1 -0
  171. package/dist/rules/suppressions/biome/configs.js +39 -0
  172. package/dist/rules/suppressions/biome/configs.js.map +1 -0
  173. package/dist/rules/suppressions/biome/json.d.ts +27 -0
  174. package/dist/rules/suppressions/biome/json.d.ts.map +1 -0
  175. package/dist/rules/suppressions/biome/json.js +37 -0
  176. package/dist/rules/suppressions/biome/json.js.map +1 -0
  177. package/dist/rules/suppressions/biome/weakenings.d.ts +9 -0
  178. package/dist/rules/suppressions/biome/weakenings.d.ts.map +1 -0
  179. package/dist/rules/suppressions/biome/weakenings.js +68 -0
  180. package/dist/rules/suppressions/biome/weakenings.js.map +1 -0
  181. package/dist/rules/suppressions/biome-overrides.d.ts +8 -0
  182. package/dist/rules/suppressions/biome-overrides.d.ts.map +1 -0
  183. package/dist/rules/suppressions/biome-overrides.js +47 -0
  184. package/dist/rules/suppressions/biome-overrides.js.map +1 -0
  185. package/dist/rules/suppressions/directives.d.ts +4 -0
  186. package/dist/rules/suppressions/directives.d.ts.map +1 -0
  187. package/dist/rules/suppressions/directives.js +19 -0
  188. package/dist/rules/suppressions/directives.js.map +1 -0
  189. package/dist/rules/suppressions/no-double-cast.d.ts +2 -0
  190. package/dist/rules/suppressions/no-double-cast.d.ts.map +1 -0
  191. package/dist/rules/suppressions/no-double-cast.js +38 -0
  192. package/dist/rules/suppressions/no-double-cast.js.map +1 -0
  193. package/dist/rules/suppressions/no-inline.d.ts +8 -0
  194. package/dist/rules/suppressions/no-inline.d.ts.map +1 -0
  195. package/dist/rules/suppressions/no-inline.js +48 -0
  196. package/dist/rules/suppressions/no-inline.js.map +1 -0
  197. package/dist/rules/suppressions/stylesheet-comments.d.ts +4 -0
  198. package/dist/rules/suppressions/stylesheet-comments.d.ts.map +1 -0
  199. package/dist/rules/suppressions/stylesheet-comments.js +20 -0
  200. package/dist/rules/suppressions/stylesheet-comments.js.map +1 -0
  201. package/dist/rules/syntax.d.ts +4 -0
  202. package/dist/rules/syntax.d.ts.map +1 -0
  203. package/dist/rules/syntax.js +12 -0
  204. package/dist/rules/syntax.js.map +1 -0
  205. package/package.json +4 -5
  206. package/src/baseline/compare.ts +15 -39
  207. package/src/baseline/format.ts +35 -32
  208. package/src/baseline/guard.ts +47 -0
  209. package/src/baseline/legacy.ts +46 -0
  210. package/src/baseline/rewrite.ts +24 -0
  211. package/src/baseline/update.ts +47 -30
  212. package/src/cli/args.ts +64 -30
  213. package/src/cli/baseline.ts +27 -13
  214. package/src/cli/check.ts +51 -0
  215. package/src/cli/lint.ts +1 -1
  216. package/src/cli/migrate.ts +36 -0
  217. package/src/cli/run-main.ts +8 -3
  218. package/src/cli/shrink.ts +31 -0
  219. package/src/cli.ts +11 -2
  220. package/src/config/decode.ts +1 -0
  221. package/src/config/file.ts +1 -0
  222. package/src/config/load.ts +4 -3
  223. package/src/config/resolve.ts +7 -4
  224. package/src/config.ts +3 -7
  225. package/src/engine/baseline-file.ts +27 -0
  226. package/src/engine/evaluate.ts +4 -2
  227. package/src/engine/session.ts +14 -12
  228. package/src/exceptions/registry.ts +9 -2
  229. package/src/failure.ts +0 -2
  230. package/src/git/base.ts +89 -0
  231. package/src/git/command.ts +31 -0
  232. package/src/git/history.ts +41 -0
  233. package/src/inventory/ignore-scope.ts +0 -1
  234. package/src/inventory/walk.ts +1 -1
  235. package/src/report/guard.ts +46 -0
  236. package/src/report/render.ts +2 -2
  237. package/src/report/stale.ts +11 -3
  238. package/src/rule.ts +5 -8
  239. package/src/rules/built-in.ts +21 -1
  240. package/src/rules/comments/kinds.ts +45 -0
  241. package/src/rules/comments/max-per-file.ts +46 -0
  242. package/src/rules/comments/no-banner.ts +10 -0
  243. package/src/rules/comments/no-jsdoc.ts +18 -0
  244. package/src/rules/comments/no-line-reference.ts +11 -0
  245. package/src/rules/comments/no-pr-reference.ts +13 -0
  246. package/src/rules/comments/no-todo.ts +8 -0
  247. package/src/rules/comments/pattern-rule.ts +28 -0
  248. package/src/rules/comments/scan.ts +80 -0
  249. package/src/rules/max-lines.ts +8 -1
  250. package/src/rules/suppressions/biome/configs.ts +50 -0
  251. package/src/rules/suppressions/biome/json.ts +55 -0
  252. package/src/rules/suppressions/biome/weakenings.ts +102 -0
  253. package/src/rules/suppressions/biome-overrides.ts +61 -0
  254. package/src/rules/suppressions/directives.ts +25 -0
  255. package/src/rules/suppressions/no-double-cast.ts +47 -0
  256. package/src/rules/suppressions/no-inline.ts +63 -0
  257. package/src/rules/suppressions/stylesheet-comments.ts +27 -0
  258. package/src/rules/syntax.ts +14 -0
@@ -0,0 +1,46 @@
1
+ import type { GuardProblem, GuardResult } from "../baseline/guard.ts";
2
+ import type { Base } from "../git/base.ts";
3
+ import { plural } from "./plural.ts";
4
+
5
+ export interface GuardContext {
6
+ readonly baseline: string;
7
+ readonly config: string;
8
+ readonly base: Base;
9
+ }
10
+
11
+ const problemLine = (problem: GuardProblem, context: GuardContext): string => {
12
+ switch (problem._tag) {
13
+ case "Added":
14
+ return `${problem.entry.rule} ${problem.entry.file} is new, at ${problem.entry.count}.`;
15
+ case "Raised":
16
+ return `${problem.entry.rule} ${problem.entry.file} rose to ${problem.entry.count} from ${problem.base.count}.`;
17
+ case "Unadopted":
18
+ return `${problem.rule} is newly baselined in ${plural(problem.entries, "file")} without being named under \`adopt\` in ${context.config}.`;
19
+ case "NothingAdopted":
20
+ return `\`adopt\` in ${context.config} names ${problem.rule}, which has nothing baselined. Remove it.`;
21
+ }
22
+ };
23
+
24
+ const against = (base: Base): string => `${base.ref} (merge base ${base.commit.slice(0, 12)})`;
25
+
26
+ const held = (result: GuardResult): string =>
27
+ [
28
+ ...(result.moved > 0 ? [`${plural(result.moved, "entry", "entries")} followed moved files`] : []),
29
+ ...(result.adopted.length > 0 ? [`adopted ${result.adopted.join(", ")}`] : []),
30
+ ]
31
+ .map((part) => `; ${part}`)
32
+ .join("");
33
+
34
+ export const renderGuard = (result: GuardResult, context: GuardContext): string => {
35
+ if (result.problems.length === 0) {
36
+ return `quality: ${context.baseline} holds against ${against(context.base)}${held(result)}.`;
37
+ }
38
+ return [
39
+ [
40
+ `error ${context.baseline} grew against ${against(context.base)}`,
41
+ ` The baseline only shrinks. Fix new violations instead of baselining them; a rule enters the baseline for the first time only while \`adopt\` names it.`,
42
+ ...result.problems.map((problem) => ` ${problemLine(problem, context)}`),
43
+ ].join("\n"),
44
+ `quality: baseline check failed with ${plural(result.problems.length, "problem")}.`,
45
+ ].join("\n\n");
46
+ };
@@ -1,4 +1,4 @@
1
- import { describeEntry, describeTally, type Regression } from "../baseline/compare.ts";
1
+ import type { Regression } from "../baseline/compare.ts";
2
2
  import { type Outcome, passes } from "../engine/evaluate.ts";
3
3
  import { groupBy, keyOf, type Violation } from "../engine/violation.ts";
4
4
  import { plural } from "./plural.ts";
@@ -19,7 +19,7 @@ const LISTED_FILES = 5;
19
19
  const locate = (violation: Violation): string => (violation.line === undefined ? violation.file : `${violation.file}:${violation.line}`);
20
20
 
21
21
  const regressionNote = (regression: Regression): string =>
22
- ` ${regression.entry.file} is over its baseline: ${describeTally(regression.tally)} against ${describeEntry(regression.entry)} baselined.`;
22
+ ` ${regression.entry.file} is over its baseline: ${regression.count} against ${regression.entry.count} baselined.`;
23
23
 
24
24
  const heading = (label: string, rule: string, violations: ReadonlyArray<Violation>, context: ReportContext): ReadonlyArray<string> => {
25
25
  const description = context.descriptions.get(rule);
@@ -8,8 +8,8 @@ interface StaleFiles {
8
8
  readonly baseline: string;
9
9
  }
10
10
 
11
- const section = (file: string, guidance: string, lines: ReadonlyArray<string>): ReadonlyArray<string> =>
12
- lines.length === 0 ? [] : [[`error ${file}: ${plural(lines.length, "stale entry", "stale entries")}`, ` ${guidance}`, ...lines].join("\n")];
11
+ const section = (label: string, file: string, guidance: string, lines: ReadonlyArray<string>): ReadonlyArray<string> =>
12
+ lines.length === 0 ? [] : [[`${label} ${file}: ${plural(lines.length, "stale entry", "stale entries")}`, ` ${guidance}`, ...lines].join("\n")];
13
13
 
14
14
  const baselineLine = ({ entry, problem }: StaleBaselineEntry): string => ` ${entry.rule} ${entry.file} ${problem}.`;
15
15
 
@@ -18,11 +18,19 @@ const registryLine = ({ entry, problem }: StaleRegistryEntry): string =>
18
18
 
19
19
  export const staleSections = (outcome: Outcome, files: StaleFiles): ReadonlyArray<string> => [
20
20
  ...section(
21
+ "error",
21
22
  files.baseline,
22
- "The baseline only shrinks. Run `quality baseline prune` to drop fixed debt; prune never adds or raises an entry.",
23
+ "An entry for a rule that is off or unknown covers nothing. Run `quality baseline prune` to drop it.",
23
24
  outcome.staleBaseline.map(baselineLine),
24
25
  ),
25
26
  ...section(
27
+ "note",
28
+ files.baseline,
29
+ "These pass, and `quality baseline tighten` lowers them when their files change. `quality baseline prune` lowers them all.",
30
+ outcome.looseBaseline.map(baselineLine),
31
+ ),
32
+ ...section(
33
+ "error",
26
34
  files.registry,
27
35
  "Every registered exception must still apply. Remove the entries whose exception is gone.",
28
36
  outcome.staleRegistry.map(registryLine),
package/src/rule.ts CHANGED
@@ -4,10 +4,9 @@ export interface Finding {
4
4
  readonly file: string;
5
5
  readonly line?: number | undefined;
6
6
  readonly message: string;
7
- /** Tells apart exceptions of one rule in one file, such as which Biome rule an override turns off. */
8
7
  readonly subject?: string | undefined;
9
- /** A size where larger is worse, such as a file's line count. A baselined file may not grow past it. */
10
- readonly measure?: number | undefined;
8
+ readonly count?: number | undefined;
9
+ readonly threshold?: number | undefined;
11
10
  }
12
11
 
13
12
  export type Findings = ReadonlyArray<Finding>;
@@ -36,6 +35,7 @@ export type Configured =
36
35
  export interface Rule<Id extends string = string, Input = unknown> {
37
36
  readonly id: Id;
38
37
  readonly description: string;
38
+ readonly registrable?: boolean;
39
39
  // Method syntax keeps rules with different options in one list; configure validates its input at runtime.
40
40
  configure(options: Input | undefined): Promise<Configured>;
41
41
  }
@@ -43,6 +43,7 @@ export interface Rule<Id extends string = string, Input = unknown> {
43
43
  interface RuleDefinition<Id extends string, Options> {
44
44
  readonly id: Id;
45
45
  readonly description: string;
46
+ readonly registrable?: false;
46
47
  readonly check: (context: RuleContext<Options>) => Findings | PromiseLike<Findings>;
47
48
  }
48
49
 
@@ -73,10 +74,6 @@ const configureWith =
73
74
  return { _tag: "Ready", check: async (inputs) => check({ ...inputs, options: value }) };
74
75
  };
75
76
 
76
- /**
77
- * Defines a rule. Options are an object validated by a Standard Schema; a rule the config gives no options validates `{}`,
78
- * so give each option a default or make it optional.
79
- */
80
77
  export function defineRule<const Id extends string, Input, Options>(definition: RuleDefinitionWithOptions<Id, Input, Options>): Rule<Id, Input>;
81
78
  export function defineRule<const Id extends string>(definition: RuleDefinitionWithoutOptions<Id>): Rule<Id, undefined>;
82
79
  export function defineRule<const Id extends string, Input, Options>(
@@ -86,5 +83,5 @@ export function defineRule<const Id extends string, Input, Options>(
86
83
  definition.options === undefined
87
84
  ? configureWith(noOptions, definition.check, undefined)
88
85
  : configureWith(definition.options, definition.check, {});
89
- return { configure, description: definition.description, id: definition.id };
86
+ return { configure, description: definition.description, id: definition.id, registrable: definition.registrable ?? true };
90
87
  }
@@ -1,3 +1,23 @@
1
+ import { maxPerFile } from "./comments/max-per-file.ts";
2
+ import { noBanner } from "./comments/no-banner.ts";
3
+ import { noJsdoc } from "./comments/no-jsdoc.ts";
4
+ import { noLineReference } from "./comments/no-line-reference.ts";
5
+ import { noPrReference } from "./comments/no-pr-reference.ts";
6
+ import { noTodo } from "./comments/no-todo.ts";
1
7
  import { maxLines } from "./max-lines.ts";
8
+ import { biomeOverrides } from "./suppressions/biome-overrides.ts";
9
+ import { noDoubleCast } from "./suppressions/no-double-cast.ts";
10
+ import { noInline } from "./suppressions/no-inline.ts";
2
11
 
3
- export const builtInRules = [maxLines] as const;
12
+ export const builtInRules = [
13
+ maxLines,
14
+ noJsdoc,
15
+ noLineReference,
16
+ noPrReference,
17
+ noBanner,
18
+ noTodo,
19
+ maxPerFile,
20
+ noInline,
21
+ noDoubleCast,
22
+ biomeOverrides,
23
+ ] as const;
@@ -0,0 +1,45 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { SUPPRESSIONS } from "../suppressions/directives.ts";
3
+ import type { SourceComment } from "./scan.ts";
4
+
5
+ export const DEFAULT_PRAGMAS: ReadonlyArray<string> = [
6
+ "@vitest-environment",
7
+ "@vitest-environment-options",
8
+ "@jest-environment",
9
+ "@jsx",
10
+ "@jsxFrag",
11
+ "@jsxImportSource",
12
+ "@jsxRuntime",
13
+ ];
14
+
15
+ export const Pragmas = Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(DEFAULT_PRAGMAS)));
16
+
17
+ const DIRECTIVES: ReadonlyArray<RegExp> = [
18
+ ...SUPPRESSIONS,
19
+ /^@ts-/,
20
+ /^biome-ignore/,
21
+ /^(?:eslint|oxlint|stylelint)-(?:disable|enable)/,
22
+ /^[#@]__(?:PURE|NO_SIDE_EFFECTS)__/,
23
+ /^(?:c8|v8|istanbul) ignore/,
24
+ ];
25
+
26
+ const TRIPLE_SLASH = /^\/\/\/\s*</;
27
+
28
+ const contentOf = (comment: SourceComment): ReadonlyArray<string> => comment.body.filter((line) => line !== "");
29
+
30
+ const startsWithTag = (line: string, tags: ReadonlyArray<string>): boolean =>
31
+ tags.some((tag) => line === tag || line.startsWith(`${tag} `) || line.startsWith(`${tag}\t`));
32
+
33
+ export const textOf = (comment: SourceComment): string => comment.body.join("\n");
34
+
35
+ export const isJsdoc = (comment: SourceComment): boolean => comment.kind === "block" && comment.text.startsWith("/**") && comment.text !== "/**/";
36
+
37
+ export const isPragma = (comment: SourceComment, pragmas: ReadonlyArray<string>): boolean => {
38
+ const content = contentOf(comment);
39
+ return content.length > 0 && content.every((line) => startsWithTag(line, pragmas));
40
+ };
41
+
42
+ export const isDirective = (comment: SourceComment): boolean => {
43
+ const first = contentOf(comment)[0] ?? "";
44
+ return TRIPLE_SLASH.test(comment.text) || DIRECTIVES.some((directive) => directive.test(first));
45
+ };
@@ -0,0 +1,46 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { plural } from "../../report/plural.ts";
3
+ import { defineRule, type Finding, type SourceFile } from "../../rule.ts";
4
+ import { isDirective, isPragma, Pragmas } from "./kinds.ts";
5
+ import { commentsOf, type SourceComment } from "./scan.ts";
6
+
7
+ const MaxPerFileOptions = Schema.Struct({
8
+ max: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.withDecodingDefaultKey(Effect.succeed(2))),
9
+ allow: Pragmas,
10
+ });
11
+
12
+ const continues = (previous: SourceComment | undefined, comment: SourceComment): boolean =>
13
+ previous !== undefined &&
14
+ previous.kind === "line" &&
15
+ comment.kind === "line" &&
16
+ previous.ownLine &&
17
+ comment.ownLine &&
18
+ comment.line === previous.endLine + 1;
19
+
20
+ const commentBlocks = (comments: ReadonlyArray<SourceComment>): ReadonlyArray<SourceComment> =>
21
+ comments.filter((comment, index) => !continues(comments[index - 1], comment));
22
+
23
+ const overCap = (file: SourceFile, max: number, allow: ReadonlyArray<string>): ReadonlyArray<Finding> => {
24
+ const counted = commentsOf(file).filter((comment) => !isDirective(comment) && !isPragma(comment, allow));
25
+ const blocks = commentBlocks(counted);
26
+ const first = blocks[max];
27
+ return first === undefined
28
+ ? []
29
+ : [
30
+ {
31
+ file: file.path,
32
+ line: first.line,
33
+ count: blocks.length - max,
34
+ message: `${plural(blocks.length, "comment", "comments")} against a limit of ${max}.`,
35
+ threshold: max,
36
+ },
37
+ ];
38
+ };
39
+
40
+ export const maxPerFile = defineRule({
41
+ id: "comments/max-per-file",
42
+ description:
43
+ "A file that needs more than a few comments does too much, or its comments narrate the code. Delete the comments that restate the code, then split the file.",
44
+ options: Schema.toStandardSchemaV1(MaxPerFileOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
45
+ check: ({ options, sources }) => sources.flatMap((file) => overCap(file, options.max, options.allow)),
46
+ });
@@ -0,0 +1,10 @@
1
+ import { definePatternRule } from "./pattern-rule.ts";
2
+
3
+ const DIVIDER = "[-=*#~_+/\\\\─━═┄┈]{3,}";
4
+
5
+ export const noBanner = definePatternRule({
6
+ id: "comments/no-banner",
7
+ description: "Banners, dividers and regions mark a file that does several jobs. Split the file along those sections instead.",
8
+ patterns: [new RegExp(`^${DIVIDER}|${DIVIDER}$`, "m"), /^#(?:end)?region\b/m],
9
+ message: (match) => `Banner or divider: "${match}".`,
10
+ });
@@ -0,0 +1,18 @@
1
+ import { Schema } from "effect";
2
+ import { defineRule } from "../../rule.ts";
3
+ import { isDirective, isJsdoc, isPragma, Pragmas } from "./kinds.ts";
4
+ import { commentsOf } from "./scan.ts";
5
+
6
+ const NoJsdocOptions = Schema.Struct({ allow: Pragmas });
7
+
8
+ export const noJsdoc = defineRule({
9
+ id: "comments/no-jsdoc",
10
+ description: "Names and types already say what a JSDoc block repeats. Delete it, and keep a reason the code cannot show as a short // comment.",
11
+ options: Schema.toStandardSchemaV1(NoJsdocOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
12
+ check: ({ options, sources }) =>
13
+ sources.flatMap((file) =>
14
+ commentsOf(file)
15
+ .filter((comment) => isJsdoc(comment) && !isPragma(comment, options.allow) && !isDirective(comment))
16
+ .map((comment) => ({ file: file.path, line: comment.line, message: "JSDoc block." })),
17
+ ),
18
+ });
@@ -0,0 +1,11 @@
1
+ import { definePatternRule } from "./pattern-rule.ts";
2
+
3
+ const SOURCE_EXTENSION =
4
+ "(?:[cm]?[jt]sx?|json5?|mdx?|css|scss|less|html?|vue|svelte|astro|ya?ml|toml|sql|prisma|graphql|gql|go|rs|py|rb|java|kt|swift|sh)";
5
+
6
+ export const noLineReference = definePatternRule({
7
+ id: "comments/no-line-reference",
8
+ description: "A line number goes stale with the next edit. Name the function, type, test or setting instead.",
9
+ patterns: [new RegExp(`[\\w./@-]*\\.${SOURCE_EXTENSION}(?::\\d+|#L\\d+)`, "i"), /(?<![\w-])lines?\s+\d+/i],
10
+ message: (match) => `Refers to a line: "${match}".`,
11
+ });
@@ -0,0 +1,13 @@
1
+ import { definePatternRule } from "./pattern-rule.ts";
2
+
3
+ export const noPrReference = definePatternRule({
4
+ id: "comments/no-pr-reference",
5
+ description: "History belongs in version control, not in the code. State the reason itself, so the comment holds without the link.",
6
+ patterns: [
7
+ /\b(?:PR|MR|pull request|merge request|issue|ticket)s?\s*#?\d+\b/i,
8
+ /\/(?:pull|pulls|issues|merge_requests)\/\d+/,
9
+ /\bGH-\d+\b/,
10
+ /(?<![\w&/#-])#\d+\b/,
11
+ ],
12
+ message: (match) => `Refers to a pull request or issue: "${match}".`,
13
+ });
@@ -0,0 +1,8 @@
1
+ import { definePatternRule } from "./pattern-rule.ts";
2
+
3
+ export const noTodo = definePatternRule({
4
+ id: "comments/no-todo",
5
+ description: "A TODO in the code is never done. Do the work now, or track it where the repository tracks work.",
6
+ patterns: [/\b(?:TODO|FIXME|XXX)\b/, /@todo\b/i],
7
+ message: (match) => `Marks unfinished work: "${match}".`,
8
+ });
@@ -0,0 +1,28 @@
1
+ import { defineRule, type Finding, type Rule } from "../../rule.ts";
2
+ import { isDirective, textOf } from "./kinds.ts";
3
+ import { commentsOf, type SourceComment } from "./scan.ts";
4
+
5
+ interface PatternRule<Id extends string> {
6
+ readonly id: Id;
7
+ readonly description: string;
8
+ readonly patterns: ReadonlyArray<RegExp>;
9
+ readonly message: (match: string) => string;
10
+ }
11
+
12
+ const firstMatch = (comment: SourceComment, patterns: ReadonlyArray<RegExp>): string | undefined => {
13
+ const text = textOf(comment);
14
+ return patterns.map((pattern) => pattern.exec(text)?.[0]).find((match) => match !== undefined);
15
+ };
16
+
17
+ export const definePatternRule = <const Id extends string>({ description, id, message, patterns }: PatternRule<Id>): Rule<Id, undefined> =>
18
+ defineRule({
19
+ id,
20
+ description,
21
+ check: ({ sources }) =>
22
+ sources.flatMap((file) =>
23
+ commentsOf(file).flatMap((comment): ReadonlyArray<Finding> => {
24
+ const match = isDirective(comment) ? undefined : firstMatch(comment, patterns);
25
+ return match === undefined ? [] : [{ file: file.path, line: comment.line, message: message(match) }];
26
+ }),
27
+ ),
28
+ });
@@ -0,0 +1,80 @@
1
+ import ts from "typescript";
2
+ import type { SourceFile } from "../../rule.ts";
3
+ import { parse } from "../syntax.ts";
4
+
5
+ export interface SourceComment {
6
+ readonly kind: "block" | "line";
7
+ readonly text: string;
8
+ readonly body: ReadonlyArray<string>;
9
+ readonly line: number;
10
+ readonly endLine: number;
11
+ readonly ownLine: boolean;
12
+ }
13
+
14
+ const DECLARATION = /\.d\.[cm]?ts$/;
15
+
16
+ const commentRanges = (source: ts.SourceFile): ReadonlyArray<ts.CommentRange> => {
17
+ const ranges = new Map<number, ts.CommentRange>();
18
+ const jsxText: Array<ts.Node> = [];
19
+ const add = (found: ReadonlyArray<ts.CommentRange> | undefined) => {
20
+ for (const range of found ?? []) {
21
+ ranges.set(range.pos, range);
22
+ }
23
+ };
24
+ const visit = (node: ts.Node) => {
25
+ if (node.kind === ts.SyntaxKind.JsxText) {
26
+ jsxText.push(node);
27
+ }
28
+ add(ts.getLeadingCommentRanges(source.text, node.pos));
29
+ add(ts.getTrailingCommentRanges(source.text, node.end));
30
+ for (const child of node.getChildren(source)) {
31
+ visit(child);
32
+ }
33
+ };
34
+ visit(source);
35
+ return [...ranges.values()]
36
+ .filter((range) => !jsxText.some((text) => range.pos >= text.pos && range.pos < text.end))
37
+ .sort((left, right) => left.pos - right.pos);
38
+ };
39
+
40
+ export const bodyOf = (kind: SourceComment["kind"], text: string): ReadonlyArray<string> =>
41
+ kind === "line"
42
+ ? [text.slice(2).trim()]
43
+ : text
44
+ .slice(2, -2)
45
+ .split("\n")
46
+ .map((line) => line.trim().replace(/^\*(?=\s|$)\s*/, ""));
47
+
48
+ const toComment = (source: ts.SourceFile, range: ts.CommentRange): SourceComment => {
49
+ const kind = range.kind === ts.SyntaxKind.SingleLineCommentTrivia ? "line" : "block";
50
+ const text = source.text.slice(range.pos, range.end);
51
+ const start = source.getLineAndCharacterOfPosition(range.pos);
52
+ const lineStart = source.getPositionOfLineAndCharacter(start.line, 0);
53
+ return {
54
+ body: bodyOf(kind, text),
55
+ endLine: source.getLineAndCharacterOfPosition(range.end).line + 1,
56
+ kind,
57
+ line: start.line + 1,
58
+ ownLine: source.text.slice(lineStart, range.pos).trim() === "",
59
+ text,
60
+ };
61
+ };
62
+
63
+ const scanned = new WeakMap<SourceFile, ReadonlyArray<SourceComment>>();
64
+
65
+ const scan = (file: SourceFile): ReadonlyArray<SourceComment> => {
66
+ const source = parse(file);
67
+ return source === undefined ? [] : commentRanges(source).map((range) => toComment(source, range));
68
+ };
69
+
70
+ export const scanComments = (file: SourceFile): ReadonlyArray<SourceComment> => {
71
+ const cached = scanned.get(file);
72
+ if (cached !== undefined) {
73
+ return cached;
74
+ }
75
+ const comments = scan(file);
76
+ scanned.set(file, comments);
77
+ return comments;
78
+ };
79
+
80
+ export const commentsOf = (file: SourceFile): ReadonlyArray<SourceComment> => (DECLARATION.test(file.path) ? [] : scanComments(file));
@@ -16,7 +16,14 @@ const MaxLinesOptions = Schema.Struct({
16
16
  const oversized = (file: SourceFile, limit: number): ReadonlyArray<Finding> =>
17
17
  file.lines.length <= limit
18
18
  ? []
19
- : [{ file: file.path, measure: file.lines.length, message: `${file.lines.length} lines exceeds the ${limit}-line limit.` }];
19
+ : [
20
+ {
21
+ count: file.lines.length - limit,
22
+ file: file.path,
23
+ message: `${file.lines.length} lines exceeds the ${limit}-line limit.`,
24
+ threshold: limit,
25
+ },
26
+ ];
20
27
 
21
28
  export const maxLines = defineRule({
22
29
  id: "structure/max-lines",
@@ -0,0 +1,50 @@
1
+ import { posix } from "node:path";
2
+ import type { RuleInputs } from "../../../rule.ts";
3
+ import { itemsOf, type Json, member, parseJsonc, textOf } from "./json.ts";
4
+
5
+ export type Config =
6
+ | { readonly _tag: "Config"; readonly path: string; readonly base: string; readonly json: Json }
7
+ | { readonly _tag: "Unreadable"; readonly path: string; readonly reason: string };
8
+
9
+ const ROOT_CONFIGS: ReadonlyArray<string> = ["biome.json", "biome.jsonc"];
10
+
11
+ const NESTED = /(?:^|\/)biome\.jsonc?$/;
12
+
13
+ const directoryOf = (path: string): string => (posix.dirname(path) === "." ? "" : posix.dirname(path));
14
+
15
+ const localExtends = (path: string, json: Json): ReadonlyArray<string> => {
16
+ const node = member(json, "extends");
17
+ const entries = node?._tag === "Array" ? itemsOf(node).flatMap((item) => textOf(item) ?? []) : [textOf(node) ?? ""];
18
+ return entries.filter((entry) => entry.startsWith(".")).map((entry) => posix.join(posix.dirname(path), entry));
19
+ };
20
+
21
+ const load = async (readText: RuleInputs["readText"], path: string, base: string, seen: Set<string>): Promise<ReadonlyArray<Config>> => {
22
+ if (seen.has(path)) {
23
+ return [];
24
+ }
25
+ seen.add(path);
26
+ const text = await readText(path);
27
+ if (text === undefined) {
28
+ return [{ _tag: "Unreadable", path, reason: "the file does not exist" }];
29
+ }
30
+ const parsed = parseJsonc(path, text);
31
+ if (parsed._tag === "Unreadable") {
32
+ return [{ _tag: "Unreadable", path, reason: parsed.reason }];
33
+ }
34
+ const extended = await Promise.all(localExtends(path, parsed.json).map((target) => load(readText, target, base, seen)));
35
+ return [{ _tag: "Config", base, json: parsed.json, path }, ...extended.flat()];
36
+ };
37
+
38
+ const rootConfig = async (readText: RuleInputs["readText"]): Promise<string | undefined> => {
39
+ const present = await Promise.all(ROOT_CONFIGS.map(async (path) => ((await readText(path)) === undefined ? [] : [path])));
40
+ return present.flat()[0];
41
+ };
42
+
43
+ export const biomeConfigs = async ({ files, readText }: RuleInputs): Promise<ReadonlyArray<Config>> => {
44
+ const seen = new Set<string>();
45
+ const root = await rootConfig(readText);
46
+ const nested = files.filter((path) => NESTED.test(path) && path !== root);
47
+ const roots = root === undefined ? [] : await load(readText, root, "", seen);
48
+ const below = await Promise.all(nested.map((path) => load(readText, path, directoryOf(path), seen)));
49
+ return [...roots, ...below.flat()];
50
+ };
@@ -0,0 +1,55 @@
1
+ import ts from "typescript";
2
+
3
+ export type Json =
4
+ | { readonly _tag: "Object"; readonly line: number; readonly entries: ReadonlyMap<string, Json> }
5
+ | { readonly _tag: "Array"; readonly line: number; readonly items: ReadonlyArray<Json> }
6
+ | { readonly _tag: "Value"; readonly line: number; readonly value: string | boolean | undefined };
7
+
8
+ export type Parsed = { readonly _tag: "Parsed"; readonly json: Json } | { readonly _tag: "Unreadable"; readonly reason: string };
9
+
10
+ const LITERALS: ReadonlyMap<ts.SyntaxKind, boolean> = new Map([
11
+ [ts.SyntaxKind.TrueKeyword, true],
12
+ [ts.SyntaxKind.FalseKeyword, false],
13
+ ]);
14
+
15
+ const scalarOf = (node: ts.Expression): string | boolean | undefined => (ts.isStringLiteral(node) ? node.text : LITERALS.get(node.kind));
16
+
17
+ const entryOf = (source: ts.JsonSourceFile, property: ts.ObjectLiteralElementLike): ReadonlyArray<readonly [string, Json]> =>
18
+ ts.isPropertyAssignment(property) && (ts.isStringLiteral(property.name) || ts.isIdentifier(property.name))
19
+ ? [[property.name.text, toJson(source, property.initializer)]]
20
+ : [];
21
+
22
+ const toJson = (source: ts.JsonSourceFile, node: ts.Expression): Json => {
23
+ const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
24
+ if (ts.isObjectLiteralExpression(node)) {
25
+ return { _tag: "Object", entries: new Map(node.properties.flatMap((property) => entryOf(source, property))), line };
26
+ }
27
+ if (ts.isArrayLiteralExpression(node)) {
28
+ return { _tag: "Array", items: node.elements.map((element) => toJson(source, element)), line };
29
+ }
30
+ return { _tag: "Value", line, value: scalarOf(node) };
31
+ };
32
+
33
+ export const parseJsonc = (path: string, text: string): Parsed => {
34
+ const error = ts.parseConfigFileTextToJson(path, text).error;
35
+ const source = ts.parseJsonText(path, text);
36
+ const root = source.statements[0]?.expression;
37
+ if (error !== undefined || root === undefined) {
38
+ return {
39
+ _tag: "Unreadable",
40
+ reason: error === undefined ? "it is empty" : ts.flattenDiagnosticMessageText(error.messageText, " ").replace(/\.$/, ""),
41
+ };
42
+ }
43
+ return { _tag: "Parsed", json: toJson(source, root) };
44
+ };
45
+
46
+ export const member = (node: Json | undefined, key: string): Json | undefined => (node?._tag === "Object" ? node.entries.get(key) : undefined);
47
+
48
+ export const entriesOf = (node: Json | undefined): ReadonlyArray<readonly [string, Json]> => (node?._tag === "Object" ? [...node.entries] : []);
49
+
50
+ export const itemsOf = (node: Json | undefined): ReadonlyArray<Json> => (node?._tag === "Array" ? node.items : []);
51
+
52
+ export const textOf = (node: Json | undefined): string | undefined =>
53
+ node?._tag === "Value" && typeof node.value === "string" ? node.value : undefined;
54
+
55
+ export const isFalse = (node: Json | undefined): boolean => node?._tag === "Value" && node.value === false;