@shivaedev/quality 0.21.1 → 0.22.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +24 -8
  3. package/dist/biome/exceptionConfig.d.ts +7 -0
  4. package/dist/biome/exceptionConfig.d.ts.map +1 -0
  5. package/dist/biome/exceptionConfig.js +54 -0
  6. package/dist/biome/exceptionConfig.js.map +1 -0
  7. package/dist/biome/report.d.ts +2 -1
  8. package/dist/biome/report.d.ts.map +1 -1
  9. package/dist/biome/report.js +5 -5
  10. package/dist/biome/report.js.map +1 -1
  11. package/dist/biome/run.d.ts +2 -1
  12. package/dist/biome/run.d.ts.map +1 -1
  13. package/dist/biome/run.js +5 -4
  14. package/dist/biome/run.js.map +1 -1
  15. package/dist/cli/args.d.ts +5 -1
  16. package/dist/cli/args.d.ts.map +1 -1
  17. package/dist/cli/args.js +6 -27
  18. package/dist/cli/args.js.map +1 -1
  19. package/dist/cli/fix.d.ts.map +1 -1
  20. package/dist/cli/fix.js +9 -9
  21. package/dist/cli/fix.js.map +1 -1
  22. package/dist/cli/lint.d.ts.map +1 -1
  23. package/dist/cli/lint.js +2 -1
  24. package/dist/cli/lint.js.map +1 -1
  25. package/dist/cli/messages.d.ts +3 -0
  26. package/dist/cli/messages.d.ts.map +1 -0
  27. package/dist/cli/messages.js +30 -0
  28. package/dist/cli/messages.js.map +1 -0
  29. package/dist/cli/migrateExceptions.d.ts +4 -0
  30. package/dist/cli/migrateExceptions.d.ts.map +1 -0
  31. package/dist/cli/migrateExceptions.js +78 -0
  32. package/dist/cli/migrateExceptions.js.map +1 -0
  33. package/dist/cli.js +3 -0
  34. package/dist/cli.js.map +1 -1
  35. package/dist/config/decode.d.ts +6 -0
  36. package/dist/config/decode.d.ts.map +1 -1
  37. package/dist/config/decode.js +2 -0
  38. package/dist/config/decode.js.map +1 -1
  39. package/dist/config/load.d.ts +4 -0
  40. package/dist/config/load.d.ts.map +1 -1
  41. package/dist/config/load.js +10 -1
  42. package/dist/config/load.js.map +1 -1
  43. package/dist/config/resolve.d.ts.map +1 -1
  44. package/dist/config/resolve.js +11 -1
  45. package/dist/config/resolve.js.map +1 -1
  46. package/dist/config.d.ts +2 -0
  47. package/dist/config.d.ts.map +1 -1
  48. package/dist/config.js.map +1 -1
  49. package/dist/engine/run-rules.d.ts +1 -0
  50. package/dist/engine/run-rules.d.ts.map +1 -1
  51. package/dist/engine/run-rules.js +19 -4
  52. package/dist/engine/run-rules.js.map +1 -1
  53. package/dist/engine/session.d.ts.map +1 -1
  54. package/dist/engine/session.js +11 -3
  55. package/dist/engine/session.js.map +1 -1
  56. package/dist/exceptions/biomeFindings.d.ts +4 -0
  57. package/dist/exceptions/biomeFindings.d.ts.map +1 -0
  58. package/dist/exceptions/biomeFindings.js +14 -0
  59. package/dist/exceptions/biomeFindings.js.map +1 -0
  60. package/dist/exceptions/declaration.d.ts +11 -0
  61. package/dist/exceptions/declaration.d.ts.map +1 -0
  62. package/dist/exceptions/declaration.js +24 -0
  63. package/dist/exceptions/declaration.js.map +1 -0
  64. package/dist/exceptions/migrateBiome.d.ts +3 -0
  65. package/dist/exceptions/migrateBiome.d.ts.map +1 -0
  66. package/dist/exceptions/migrateBiome.js +62 -0
  67. package/dist/exceptions/migrateBiome.js.map +1 -0
  68. package/dist/exceptions/migrateConfig.d.ts +3 -0
  69. package/dist/exceptions/migrateConfig.d.ts.map +1 -0
  70. package/dist/exceptions/migrateConfig.js +57 -0
  71. package/dist/exceptions/migrateConfig.js.map +1 -0
  72. package/dist/exceptions/registryEntries.d.ts +4 -0
  73. package/dist/exceptions/registryEntries.d.ts.map +1 -0
  74. package/dist/exceptions/registryEntries.js +6 -0
  75. package/dist/exceptions/registryEntries.js.map +1 -0
  76. package/dist/rule.d.ts +3 -0
  77. package/dist/rule.d.ts.map +1 -1
  78. package/dist/rule.js.map +1 -1
  79. package/dist/rules/biome.js +2 -2
  80. package/dist/rules/biome.js.map +1 -1
  81. package/dist/rules/suppressions/biome/ConsumerDeclaration.d.ts +7 -0
  82. package/dist/rules/suppressions/biome/ConsumerDeclaration.d.ts.map +1 -0
  83. package/dist/rules/suppressions/biome/ConsumerDeclaration.js +15 -0
  84. package/dist/rules/suppressions/biome/ConsumerDeclaration.js.map +1 -0
  85. package/dist/rules/suppressions/biome-overrides.d.ts.map +1 -1
  86. package/dist/rules/suppressions/biome-overrides.js +7 -16
  87. package/dist/rules/suppressions/biome-overrides.js.map +1 -1
  88. package/docs/debt-and-hooks.md +31 -11
  89. package/docs/modules.md +2 -0
  90. package/docs/north-star.md +4 -4
  91. package/docs/roadmap.md +1 -1
  92. package/docs/rules.md +12 -33
  93. package/docs/tooling.md +2 -2
  94. package/package.json +1 -1
  95. package/src/biome/exceptionConfig.ts +70 -0
  96. package/src/biome/report.ts +11 -5
  97. package/src/biome/run.ts +17 -10
  98. package/src/cli/args.ts +7 -29
  99. package/src/cli/fix.ts +14 -9
  100. package/src/cli/lint.ts +3 -1
  101. package/src/cli/messages.ts +31 -0
  102. package/src/cli/migrateExceptions.ts +87 -0
  103. package/src/cli.ts +3 -0
  104. package/src/config/decode.ts +2 -0
  105. package/src/config/load.ts +15 -1
  106. package/src/config/resolve.ts +12 -1
  107. package/src/config.ts +2 -0
  108. package/src/engine/run-rules.ts +21 -4
  109. package/src/engine/session.ts +11 -3
  110. package/src/exceptions/biomeFindings.ts +20 -0
  111. package/src/exceptions/declaration.ts +38 -0
  112. package/src/exceptions/migrateBiome.ts +78 -0
  113. package/src/exceptions/migrateConfig.ts +64 -0
  114. package/src/exceptions/registryEntries.ts +10 -0
  115. package/src/rule.ts +3 -0
  116. package/src/rules/biome.ts +2 -2
  117. package/src/rules/suppressions/biome/ConsumerDeclaration.ts +24 -0
  118. package/src/rules/suppressions/biome-overrides.ts +7 -26
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { access, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { Schema } from "effect";
5
+ import ts from "typescript";
6
+ import type { ExceptionDeclaration } from "#exceptions/declaration.ts";
7
+
8
+ function nativeOverride(files: readonly string[], rule: string): unknown {
9
+ const [, tool, group = "", name = ""] = rule.split("/");
10
+ const key = tool === "lint" ? "linter" : "assist";
11
+ const settings = tool === "lint" ? "rules" : "actions";
12
+ return { includes: files, [key]: { [settings]: { [group]: { [name]: "off" } } } };
13
+ }
14
+
15
+ export function exceptionConfig(declarations: readonly ExceptionDeclaration[]): {
16
+ readonly includes: readonly string[];
17
+ readonly overrides: readonly unknown[];
18
+ } {
19
+ const includes = declarations.filter((entry) => entry.rules.includes("biome/files/includes")).flatMap((entry) => entry.files);
20
+ return {
21
+ includes: includes.length === 0 ? [] : ["**", ...includes.filter((pattern) => pattern !== "**")],
22
+ overrides: declarations.flatMap(({ files, rules }) =>
23
+ rules.filter((rule) => /^biome\/(?:lint|assist)\//u.test(rule)).map((rule) => nativeOverride(files, rule)),
24
+ ),
25
+ };
26
+ }
27
+
28
+ export async function withExceptionConfig<TValue>(
29
+ root: string,
30
+ declarations: readonly ExceptionDeclaration[],
31
+ run: (args: readonly string[]) => Promise<TValue>,
32
+ ): Promise<TValue> {
33
+ const { includes, overrides } = exceptionConfig(declarations);
34
+ if (includes.length === 0 && overrides.length === 0) {
35
+ return run([]);
36
+ }
37
+ const name = `.quality-biome-${randomUUID()}.json`;
38
+ const path = join(root, name);
39
+ const config = await access(join(root, "biome.json")).then(
40
+ () => "biome.json",
41
+ () => "biome.jsonc",
42
+ );
43
+ const text = await readFile(join(root, config), "utf8");
44
+ const parsed = ts.parseConfigFileTextToJson(config, text);
45
+ if (parsed.error !== undefined) {
46
+ throw new Error(`Cannot load ${config}: ${ts.flattenDiagnosticMessageText(parsed.error.messageText, " ")}`);
47
+ }
48
+ const own = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Unknown))(parsed.config);
49
+ const files = Schema.is(Schema.Record(Schema.String, Schema.Unknown))(own.files) ? own.files : {};
50
+ if (
51
+ (own.files !== undefined && !Schema.is(Schema.Record(Schema.String, Schema.Unknown))(own.files))
52
+ || (files.includes !== undefined && !Array.isArray(files.includes))
53
+ || (own.overrides !== undefined && !Array.isArray(own.overrides))
54
+ ) {
55
+ throw new Error(`Cannot merge exceptions into invalid ${config}: files, includes or overrides has the wrong shape.`);
56
+ }
57
+ const previousIncludes = Array.isArray(files.includes) ? files.includes : ["**"];
58
+ const previousOverrides = Array.isArray(own.overrides) ? own.overrides : [];
59
+ const generated = {
60
+ ...own,
61
+ files: { ...files, includes: [...previousIncludes, ...includes.filter((pattern) => pattern !== "**"), `!${name}`] },
62
+ overrides: [...previousOverrides, ...overrides],
63
+ };
64
+ try {
65
+ await writeFile(path, JSON.stringify(generated), { flag: "wx" });
66
+ return await run([`--config-path=${path}`]);
67
+ } finally {
68
+ await rm(path, { force: true });
69
+ }
70
+ }
@@ -1,5 +1,6 @@
1
1
  import { relative, sep } from "node:path";
2
2
  import { Schema } from "effect";
3
+ import type { ExceptionDeclaration } from "#exceptions/declaration.ts";
3
4
  import { ignoredTrackedPaths } from "#inventory/trackedPaths.ts";
4
5
  import { recognizeDiagnostics } from "./recognizeDiagnostics.ts";
5
6
  import { type BiomeRun, runBiome } from "./run.ts";
@@ -28,9 +29,14 @@ function failure(args: readonly string[], run: BiomeRun): Error {
28
29
  return new Error(`biome ${args.join(" ")} exited ${run.code} without a report:\n${[run.stdout, run.stderr].join("\n").trim()}`);
29
30
  }
30
31
 
31
- async function reportFor(root: string, args: readonly string[], paths: readonly string[]): Promise<Report> {
32
+ async function reportFor(
33
+ root: string,
34
+ args: readonly string[],
35
+ paths: readonly string[],
36
+ exceptions: readonly ExceptionDeclaration[],
37
+ ): Promise<Report> {
32
38
  const command = [...args, "--reporter=json", "--max-diagnostics=none", ...paths];
33
- const run = await runBiome(root, command);
39
+ const run = await runBiome(root, command, exceptions);
34
40
  const report = decodeReport(run.stdout.trim());
35
41
  if (report._tag === "None") {
36
42
  throw failure(command, run);
@@ -38,12 +44,12 @@ async function reportFor(root: string, args: readonly string[], paths: readonly
38
44
  return report.value;
39
45
  }
40
46
 
41
- export async function biomeReport(root: string, args: readonly string[]): Promise<Report> {
42
- const [report, tracked] = await Promise.all([reportFor(root, args, ["."]), ignoredTrackedPaths(root)]);
47
+ export async function biomeReport(root: string, args: readonly string[], exceptions: readonly ExceptionDeclaration[] = []): Promise<Report> {
48
+ const [report, tracked] = await Promise.all([reportFor(root, args, ["."], exceptions), ignoredTrackedPaths(root)]);
43
49
  const paths = tracked
44
50
  .map((path) => relative(root, path))
45
51
  .filter((path) => !path.split(sep).some((part) => part === ".git" || part === "node_modules"));
46
- const extra = paths.length === 0 ? undefined : await reportFor(root, [...args, "--vcs-enabled=false"], paths);
52
+ const extra = paths.length === 0 ? undefined : await reportFor(root, [...args, "--vcs-enabled=false"], paths, exceptions);
47
53
  const diagnostics = [...report.diagnostics, ...(extra?.diagnostics ?? [])];
48
54
  const unique = [...new Map(diagnostics.map((diagnostic) => [JSON.stringify(diagnostic), diagnostic])).values()];
49
55
  return {
package/src/biome/run.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createRequire } from "node:module";
3
3
  import process from "node:process";
4
+ import type { ExceptionDeclaration } from "#exceptions/declaration.ts";
5
+ import { withExceptionConfig } from "./exceptionConfig.ts";
4
6
 
5
7
  export interface BiomeRun {
6
8
  readonly code: number;
@@ -10,14 +12,19 @@ export interface BiomeRun {
10
12
 
11
13
  const BIN = createRequire(import.meta.url).resolve("@biomejs/biome/bin/biome");
12
14
 
13
- export function runBiome(root: string, args: readonly string[]): Promise<BiomeRun> {
14
- return new Promise((resolve, reject) => {
15
- execFile(process.execPath, [BIN, ...args, "--colors=off"], { cwd: root, maxBuffer: 2 ** 30 }, (error, stdout, stderr) => {
16
- if (error !== null && typeof error.code !== "number") {
17
- reject(error);
18
- return;
19
- }
20
- resolve({ code: error === null ? 0 : Number(error.code), stderr, stdout });
21
- });
22
- });
15
+ export function runBiome(root: string, args: readonly string[], exceptions: readonly ExceptionDeclaration[] = []): Promise<BiomeRun> {
16
+ return withExceptionConfig(
17
+ root,
18
+ exceptions,
19
+ (configArgs) =>
20
+ new Promise((resolve, reject) => {
21
+ execFile(process.execPath, [BIN, ...args, ...configArgs, "--colors=off"], { cwd: root, maxBuffer: 2 ** 30 }, (error, stdout, stderr) => {
22
+ if (error !== null && typeof error.code !== "number") {
23
+ reject(error);
24
+ return;
25
+ }
26
+ resolve({ code: error === null ? 0 : Number(error.code), stderr, stdout });
27
+ });
28
+ }),
29
+ );
23
30
  }
package/src/cli/args.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { Result } from "effect";
3
3
  import type { WarningDetail } from "#report/render.ts";
4
+ import { ADOPT_REMOVED, USAGE as COMMAND_USAGE } from "./messages.ts";
4
5
 
5
6
  export type Command =
6
7
  | { readonly _tag: "Lint"; readonly config: string | undefined; readonly warnings: WarningDetail }
8
+ | { readonly _tag: "ExceptionsMigrate"; readonly config: string | undefined; readonly from: string | undefined }
7
9
  | { readonly _tag: "Fix"; readonly config: string | undefined }
8
10
  | { readonly _tag: "BaselineWrite"; readonly config: string | undefined; readonly rules: readonly string[] }
9
11
  | { readonly _tag: "BaselinePrune"; readonly config: string | undefined; readonly against: string | undefined }
@@ -16,32 +18,7 @@ export type Command =
16
18
 
17
19
  export type Parsed = { readonly _tag: "Parsed"; readonly command: Command } | { readonly _tag: "Usage"; readonly problem: string };
18
20
 
19
- export const USAGE = `Usage:
20
- quality lint [--config <file>] [--warnings summary|all]
21
- quality fix [--config <file>]
22
- quality baseline write [--config <file>] [--rule <id>]...
23
- quality baseline prune [--config <file>] [--against <ref>]
24
- quality baseline tighten [--config <file>] [--staged]
25
- quality baseline migrate [--config <file>] [--from <file>]
26
- quality hooks install [--config <file>] [--force]
27
- quality hooks uninstall
28
- quality hooks pre-commit [--config <file>]
29
-
30
- lint Run every rule. Exits 1 on an error-level violation, a file over its baseline, a stale registry entry
31
- or a baseline entry for a rule that is off or unknown.
32
- fix Sort every package.json, apply Biome's lint fixes, unsafe ones included, its assist actions and formatting, then format again.
33
- baseline write Record current error-level violations. Creates the baseline, or records the named rules again, replacing their entries.
34
- baseline prune Drop fixed debt, lower entries to what is left and carry entries to files git saw move. Never adds or raises an entry.
35
- baseline tighten Prune only the entries of files changed since HEAD, or with --staged, in the index.
36
- baseline migrate Move a baseline from the earlier JSON format (--from, quality/baseline.json by default) to the configured file.
37
- hooks install Install the git pre-commit hook, shared by every worktree, that runs hooks pre-commit. Keeps a hook that is not quality's
38
- unless --force replaces it.
39
- hooks uninstall Remove quality's pre-commit hook. Keeps a hook that is not quality's.
40
- hooks pre-commit What the hook runs: lint, then package.json scripts typecheck and quality:commit when present.
41
-
42
- --config <file> Config file; its directory is the repository root. Without this option, uses ./quality.config.ts when present, otherwise defaults.
43
- --against <ref> The branch the work merges into, whose merge base prune follows moves from. Defaults to origin/HEAD, then origin/main, then origin/master.
44
- Exit codes: 0 passed, 1 failed the gate, 2 could not run.`;
21
+ export const USAGE = COMMAND_USAGE;
45
22
 
46
23
  const OPTIONS = {
47
24
  against: { type: "string" },
@@ -56,11 +33,9 @@ const OPTIONS = {
56
33
 
57
34
  const usage = (problem: string): Parsed => ({ _tag: "Usage", problem });
58
35
 
59
- const ADOPT_REMOVED =
60
- "`quality adopt` was removed in 0.7.0. To record a rule's existing findings in the baseline, run `quality baseline write --rule <id>`; to apply the fixes Biome can make first, run `quality fix`.";
61
-
62
36
  const SUBCOMMANDS: Readonly<Record<string, string>> = {
63
37
  baseline: "baseline takes write, prune, tighten or migrate.",
38
+ exceptions: "exceptions takes migrate.",
64
39
  hooks: "hooks takes install, uninstall or pre-commit.",
65
40
  };
66
41
 
@@ -77,6 +52,7 @@ const ACCEPTS: Readonly<Record<string, readonly Option[]>> = {
77
52
  "baseline prune": ["against"],
78
53
  "baseline tighten": ["staged"],
79
54
  "baseline write": ["rule"],
55
+ "exceptions migrate": ["from"],
80
56
  fix: [],
81
57
  "hooks install": ["force"],
82
58
  "hooks pre-commit": [],
@@ -102,6 +78,8 @@ const commandFor = (name: string, values: Values): Parsed => {
102
78
  ? parsed({ _tag: "Lint", config, warnings })
103
79
  : usage(`--warnings takes summary or all, not "${warnings}".`);
104
80
  }
81
+ case "exceptions migrate":
82
+ return parsed({ _tag: "ExceptionsMigrate", config, from: values.from });
105
83
  case "fix":
106
84
  return parsed({ _tag: "Fix", config });
107
85
  case "baseline write":
package/src/cli/fix.ts CHANGED
@@ -2,6 +2,7 @@ import { relative } from "node:path";
2
2
  import { Console, Effect, FileSystem } from "effect";
3
3
  import { biomeReport, type Report } from "#biome/report.ts";
4
4
  import { loadConfig } from "#config/load.ts";
5
+ import type { ExceptionDeclaration } from "#exceptions/declaration.ts";
5
6
  import { SetupFailure } from "#failure.ts";
6
7
  import { posix } from "#inventory/ignore-scope.ts";
7
8
  import { walk } from "#inventory/walk.ts";
@@ -10,10 +11,10 @@ import { plural } from "#report/plural.ts";
10
11
 
11
12
  export const MAX_FIX_ROUNDS = 5;
12
13
 
13
- function biome(root: string, args: readonly string[]): Effect.Effect<Report, SetupFailure> {
14
+ function biome(root: string, args: readonly string[], exceptions: readonly ExceptionDeclaration[]): Effect.Effect<Report, SetupFailure> {
14
15
  return Effect.tryPromise({
15
16
  catch: (cause) => new SetupFailure({ message: cause instanceof Error ? cause.message : String(cause) }),
16
- try: () => biomeReport(root, args),
17
+ try: () => biomeReport(root, args, exceptions),
17
18
  });
18
19
  }
19
20
 
@@ -43,17 +44,21 @@ export interface Settling<TFailure, TRequirements> {
43
44
  readonly snapshot: Effect.Effect<ReadonlyMap<string, string>, TFailure, TRequirements>;
44
45
  }
45
46
 
46
- function changedBy(root: string, args: readonly string[]): Effect.Effect<number, SetupFailure> {
47
- return Effect.map(biome(root, args), (report) => report.summary.changed);
47
+ function changedBy(root: string, args: readonly string[], exceptions: readonly ExceptionDeclaration[]): Effect.Effect<number, SetupFailure> {
48
+ return Effect.map(biome(root, args, exceptions), (report) => report.summary.changed);
48
49
  }
49
50
 
50
- function biomePasses(root: string): readonly Pass<SetupFailure, never>[] {
51
+ function biomePasses(root: string, exceptions: readonly ExceptionDeclaration[]): readonly Pass<SetupFailure, never>[] {
51
52
  return [
52
53
  {
53
54
  label: "with fixes",
54
- run: changedBy(root, ["check", "--write", "--unsafe", "--skip=assist/source/useSortedKeys", "--skip=lint/suspicious/noDuplicateObjectKeys"]),
55
+ run: changedBy(
56
+ root,
57
+ ["check", "--write", "--unsafe", "--skip=assist/source/useSortedKeys", "--skip=lint/suspicious/noDuplicateObjectKeys"],
58
+ exceptions,
59
+ ),
55
60
  },
56
- { label: "with the format pass", run: changedBy(root, ["format", "--write"]) },
61
+ { label: "with the format pass", run: changedBy(root, ["format", "--write"], exceptions) },
57
62
  ];
58
63
  }
59
64
 
@@ -122,9 +127,9 @@ export function settle<TFailure, TRequirements>({
122
127
 
123
128
  export function fix(cwd: string, config: string | undefined): Effect.Effect<void, SetupFailure, FileSystem.FileSystem> {
124
129
  return Effect.gen(function* () {
125
- const { root } = yield* loadConfig(cwd, config);
130
+ const { root, exceptions } = yield* loadConfig(cwd, config);
126
131
  const sorted = yield* Effect.mapError(sortManifests(root), (failure) => new SetupFailure({ message: failure.message }));
127
132
  yield* Console.log(`quality: sort-package-json rewrote ${plural(sorted, "manifest")}.`);
128
- yield* settle({ passes: biomePasses(root), snapshot: snapshotOf(root) });
133
+ yield* settle({ passes: biomePasses(root, exceptions), snapshot: snapshotOf(root) });
129
134
  });
130
135
  }
package/src/cli/lint.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { relative } from "node:path";
1
2
  import { Console, Effect, type FileSystem } from "effect";
2
3
  import { evaluate, passes } from "#engine/evaluate.ts";
3
4
  import { openSession } from "#engine/session.ts";
@@ -17,7 +18,8 @@ export const lint = (
17
18
  baseline: session.config.baseline,
18
19
  checked: session.inventory.sources.length,
19
20
  descriptions: new Map(session.config.active.map((rule) => [rule.id, rule.description])),
20
- registry: session.config.registry,
21
+ registry:
22
+ session.config.exceptions.length === 0 ? session.config.registry : `${relative(session.config.root, session.config.file)} exceptions`,
21
23
  warnings,
22
24
  }),
23
25
  );
@@ -0,0 +1,31 @@
1
+ export const USAGE = `Usage:
2
+ quality lint [--config <file>] [--warnings summary|all]
3
+ quality fix [--config <file>]
4
+ quality exceptions migrate [--config <file>] [--from <registry>]
5
+ quality baseline write [--config <file>] [--rule <id>]...
6
+ quality baseline prune [--config <file>] [--against <ref>]
7
+ quality baseline tighten [--config <file>] [--staged]
8
+ quality baseline migrate [--config <file>] [--from <file>]
9
+ quality hooks install [--config <file>] [--force]
10
+ quality hooks uninstall
11
+ quality hooks pre-commit [--config <file>]
12
+
13
+ lint Run every rule. Exits 1 on an error-level violation, a file over its baseline, a stale registry entry
14
+ or a baseline entry for a rule that is off or unknown.
15
+ fix Sort every package.json, apply Biome's lint fixes, unsafe ones included, its assist actions and formatting, then format again.
16
+ exceptions migrate Move legacy registry entries and Biome declarations into quality.config.ts exceptions and remove matching root overrides.
17
+ baseline write Record current error-level violations. Creates the baseline, or records the named rules again, replacing their entries.
18
+ baseline prune Drop fixed debt, lower entries to what is left and carry entries to files git saw move. Never adds or raises an entry.
19
+ baseline tighten Prune only the entries of files changed since HEAD, or with --staged, in the index.
20
+ baseline migrate Move a baseline from the earlier JSON format (--from, quality/baseline.json by default) to the configured file.
21
+ hooks install Install the git pre-commit hook, shared by every worktree, that runs hooks pre-commit. Keeps a hook that is not quality's
22
+ unless --force replaces it.
23
+ hooks uninstall Remove quality's pre-commit hook. Keeps a hook that is not quality's.
24
+ hooks pre-commit What the hook runs: lint, then package.json scripts typecheck and quality:commit when present.
25
+
26
+ --config <file> Config file; its directory is the repository root. Without this option, uses ./quality.config.ts when present, otherwise defaults.
27
+ --against <ref> The branch the work merges into, whose merge base prune follows moves from. Defaults to origin/HEAD, then origin/main, then origin/master.
28
+ Exit codes: 0 passed, 1 failed the gate, 2 could not run.`;
29
+
30
+ export const ADOPT_REMOVED =
31
+ "`quality adopt` was removed in 0.7.0. To record a rule's existing findings in the baseline, run `quality baseline write --rule <id>`; to apply the fixes Biome can make first, run `quality fix`.";
@@ -0,0 +1,87 @@
1
+ import { join } from "node:path";
2
+ import { Console, Effect, FileSystem } from "effect";
3
+ import { loadConfig } from "#config/load.ts";
4
+ import { validOrFail } from "#decoded.ts";
5
+ import { readInput } from "#engine/baseline-file.ts";
6
+ import { decodeExceptions, type ExceptionDeclaration } from "#exceptions/declaration.ts";
7
+ import { migrateBiome } from "#exceptions/migrateBiome.ts";
8
+ import { migrateConfig } from "#exceptions/migrateConfig.ts";
9
+ import { decodeRegistry } from "#exceptions/registry.ts";
10
+ import { SetupFailure } from "#failure.ts";
11
+ import { writeText } from "#inventory/filesystem.ts";
12
+
13
+ function merge(entries: readonly ExceptionDeclaration[]): readonly ExceptionDeclaration[] {
14
+ const merged: ExceptionDeclaration[] = [];
15
+ for (const entry of entries) {
16
+ const index = merged.findIndex(
17
+ (other) =>
18
+ other.reason === entry.reason
19
+ && other.subject === entry.subject
20
+ && other.files.length === entry.files.length
21
+ && other.files.every((file) => entry.files.includes(file)),
22
+ );
23
+ const previous = merged[index];
24
+ if (previous === undefined) {
25
+ merged.push(entry);
26
+ } else {
27
+ merged[index] = {
28
+ ...previous,
29
+ rules: [previous.rules[0], ...new Set([...previous.rules.slice(1), ...entry.rules.filter((rule) => rule !== previous.rules[0])])],
30
+ };
31
+ }
32
+ }
33
+ return merged;
34
+ }
35
+
36
+ export const migrateExceptions = Effect.fnUntraced(function* (
37
+ cwd: string,
38
+ configPath: string | undefined,
39
+ from: string | undefined,
40
+ ): Effect.fn.Return<void, SetupFailure, FileSystem.FileSystem> {
41
+ const config = yield* loadConfig(cwd, configPath);
42
+ const registryPath = from ?? config.registry;
43
+ const raw = yield* readInput(config.root, registryPath);
44
+ if (from !== undefined && raw === undefined) {
45
+ return yield* new SetupFailure({ message: `${registryPath} does not exist. Pass an existing registry file with --from.` });
46
+ }
47
+ const registry = yield* validOrFail(registryPath, yield* Effect.promise(() => decodeRegistry(raw)));
48
+ const entries = merge([
49
+ ...config.exceptions,
50
+ ...registry.map(
51
+ ({ file, reason, rule, subject }): ExceptionDeclaration => ({
52
+ files: [file],
53
+ reason,
54
+ rules: [rule],
55
+ ...(subject === undefined ? {} : { subject }),
56
+ }),
57
+ ),
58
+ ...config.biomeDeclarations.map(({ includes, reason, rule }): ExceptionDeclaration => ({ files: includes, reason, rules: [`biome/${rule}`] })),
59
+ ]);
60
+ yield* validOrFail(config.file, yield* Effect.promise(() => decodeExceptions(entries)));
61
+ const text = yield* readInput("", config.file);
62
+ const next = yield* Effect.try({
63
+ catch: (cause) => new SetupFailure({ message: String(cause) }),
64
+ try: () => migrateConfig(text, config.file, entries, config.biomeDeclarations.length > 0),
65
+ });
66
+ const updates: Array<{ readonly path: string; readonly text: string }> = [{ path: config.file, text: next }];
67
+ if (config.biomeDeclarations.length > 0) {
68
+ for (const file of ["biome.json", "biome.jsonc"]) {
69
+ const biome = yield* readInput(config.root, file);
70
+ if (biome !== undefined) {
71
+ const updated = yield* Effect.try({
72
+ catch: (cause) => new SetupFailure({ message: String(cause) }),
73
+ try: () => migrateBiome(biome, file, config.biomeDeclarations),
74
+ });
75
+ updates.push({ path: join(config.root, file), text: updated });
76
+ }
77
+ }
78
+ }
79
+ for (const update of updates) {
80
+ yield* writeText(update.path, update.text).pipe(Effect.mapError((failure) => new SetupFailure({ message: failure.message })));
81
+ }
82
+ if (raw !== undefined) {
83
+ const fs = yield* FileSystem.FileSystem;
84
+ yield* fs.remove(join(config.root, registryPath)).pipe(Effect.mapError((failure) => new SetupFailure({ message: failure.message })));
85
+ }
86
+ yield* Console.log(`quality: moved exceptions to ${config.file}. Run quality fix and review the changes; remove unused declaration imports.`);
87
+ });
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@ import { writeBaseline } from "#cli/baseline.ts";
6
6
  import { fix } from "#cli/fix.ts";
7
7
  import { lint } from "#cli/lint.ts";
8
8
  import { migrateBaseline } from "#cli/migrate.ts";
9
+ import { migrateExceptions } from "#cli/migrateExceptions.ts";
9
10
  import { runMain } from "#cli/run-main.ts";
10
11
  import { pruneBaseline, tightenBaseline } from "#cli/shrink.ts";
11
12
  import { installHook } from "#hooks/install.ts";
@@ -33,6 +34,8 @@ const program = Effect.gen(function* () {
33
34
  return yield* pruneBaseline(process.cwd(), command.config, command.against);
34
35
  case "BaselineTighten":
35
36
  return yield* tightenBaseline(process.cwd(), command.config, command.staged);
37
+ case "ExceptionsMigrate":
38
+ return yield* migrateExceptions(process.cwd(), command.config, command.from);
36
39
  case "BaselineMigrate":
37
40
  return yield* migrateBaseline(process.cwd(), command.config, command.from);
38
41
  case "HooksInstall":
@@ -1,5 +1,6 @@
1
1
  import { Schema } from "effect";
2
2
  import { type Decoded, decodeWith } from "#decoded.ts";
3
+ import { ExceptionDeclaration } from "#exceptions/declaration.ts";
3
4
  import type { Rule } from "#rule.ts";
4
5
 
5
6
  const isRule = (value: unknown): value is Rule =>
@@ -30,6 +31,7 @@ const PreCommitRemoved = Schema.Never.annotate({
30
31
  const ConfigInput = Schema.Struct({
31
32
  adopt: Schema.optionalKey(AdoptRemoved),
32
33
  baseline: Schema.optionalKey(Schema.String),
34
+ exceptions: Schema.optionalKey(Schema.Array(ExceptionDeclaration)),
33
35
  extensions: Schema.optionalKey(Schema.Array(Schema.String)),
34
36
  local: Schema.optionalKey(Schema.Array(LocalRule)),
35
37
  preCommit: Schema.optionalKey(PreCommitRemoved),
@@ -1,9 +1,12 @@
1
1
  import { basename, dirname, resolve } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
- import { Effect, FileSystem } from "effect";
3
+ import { Effect, FileSystem, Schema } from "effect";
4
4
  import { validOrFail } from "#decoded.ts";
5
+ import type { ExceptionDeclaration } from "#exceptions/declaration.ts";
5
6
  import { SetupFailure } from "#failure.ts";
6
7
  import type { InventoryScope } from "#inventory/collect.ts";
8
+ import { ConsumerDeclaration } from "#rules/suppressions/biome/ConsumerDeclaration.ts";
9
+ import type { Declaration } from "#rules/suppressions/biome/declarations.ts";
7
10
  import { type ConfigInput, decodeConfig } from "./decode.ts";
8
11
  import { CONFIG_FILE } from "./file.ts";
9
12
  import { type ResolvedRules, resolveRules } from "./resolve.ts";
@@ -12,6 +15,8 @@ const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs"
12
15
 
13
16
  export interface ResolvedConfig extends ResolvedRules, InventoryScope {
14
17
  readonly baseline: string;
18
+ readonly biomeDeclarations: readonly Declaration[];
19
+ readonly exceptions: readonly ExceptionDeclaration[];
15
20
  readonly file: string;
16
21
  readonly registry: string;
17
22
  readonly root: string;
@@ -31,9 +36,18 @@ const importDefault = (file: string): Effect.Effect<unknown, SetupFailure> =>
31
36
  : Effect.fail(new SetupFailure({ message: `${file} has no default export. Export defineConfig({ ... }) as the default.` })),
32
37
  );
33
38
 
39
+ const LegacyOptions = Schema.Struct({ declared: Schema.Array(ConsumerDeclaration) });
40
+ function biomeDeclarations(config: ConfigInput): readonly Declaration[] {
41
+ const setting = config.rules?.["suppressions/biome-overrides"];
42
+ const options = typeof setting === "object" ? setting.options : undefined;
43
+ return Schema.is(LegacyOptions)(options) ? options.declared : [];
44
+ }
45
+
34
46
  const resolved = (root: string, file: string, config: ConfigInput, rules: ResolvedRules): ResolvedConfig => ({
35
47
  ...rules,
36
48
  baseline: config.baseline ?? "quality/baseline.jsonl",
49
+ biomeDeclarations: biomeDeclarations(config),
50
+ exceptions: config.exceptions ?? [],
37
51
  exclude: [],
38
52
  extensions: config.extensions ?? DEFAULT_EXTENSIONS,
39
53
  file,
@@ -21,6 +21,17 @@ const duplicateIssues = (rules: readonly Rule[]): readonly string[] =>
21
21
  rules.findIndex((other) => other.id === rule.id) === index ? [] : [`local: rule id "${rule.id}" is already defined`],
22
22
  );
23
23
 
24
+ function activeRule(rule: Rule, check: ActiveRule["check"]): ActiveRule {
25
+ return {
26
+ check,
27
+ description: rule.description,
28
+ family: rule.family === true,
29
+ id: rule.id,
30
+ level: "error",
31
+ registrable: rule.registrable !== false,
32
+ };
33
+ }
34
+
24
35
  const activate = (rule: Rule, setting: Setting | undefined): Effect.Effect<Decoded<ActiveRule>> =>
25
36
  Effect.tryPromise({ catch: messageOf, try: () => rule.configure(optionsOf(setting)) }).pipe(
26
37
  Effect.map(
@@ -29,7 +40,7 @@ const activate = (rule: Rule, setting: Setting | undefined): Effect.Effect<Decod
29
40
  ? { _tag: "Invalid", issues: configured.issues.map((issue) => `rules.${rule.id}.options: ${issue}`) }
30
41
  : {
31
42
  _tag: "Valid",
32
- value: { check: configured.check, description: rule.description, family: rule.family === true, id: rule.id, level: "error" },
43
+ value: activeRule(rule, configured.check),
33
44
  },
34
45
  ),
35
46
  Effect.catch((failure: string) =>
package/src/config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ExceptionDeclaration } from "#exceptions/declaration.ts";
1
2
  import type { builtInRules } from "#rules/built-in.ts";
2
3
  import type { Rule } from "./rule.ts";
3
4
 
@@ -15,6 +16,7 @@ export type BuiltInRules = typeof builtInRules;
15
16
 
16
17
  export interface QualityConfig<TLocal extends readonly Rule[] = readonly Rule[]> {
17
18
  readonly baseline?: string;
19
+ readonly exceptions?: readonly ExceptionDeclaration[];
18
20
  readonly extensions?: readonly string[];
19
21
  readonly local?: TLocal;
20
22
  readonly registry?: string;
@@ -11,6 +11,7 @@ export interface ActiveRule {
11
11
  readonly family: boolean;
12
12
  readonly id: string;
13
13
  readonly level: ActiveLevel;
14
+ readonly registrable?: boolean;
14
15
  }
15
16
 
16
17
  const messageOf = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
@@ -18,24 +19,40 @@ const messageOf = (cause: unknown): string => (cause instanceof Error ? cause.me
18
19
  // Registry and baseline entries match on the path, so every finding names its file the same way.
19
20
  const repositoryPath = (root: string, file: string): string => posix(isAbsolute(file) ? relative(root, file) : file).replace(/^(\.\/)+/u, "");
20
21
 
21
- const violationOf = (rule: ActiveRule, root: string, finding: Finding): Violation => ({
22
+ function exceptionHint(rule: ActiveRule, finding: Finding, file: string, config: string): string {
23
+ const id = rule.family && finding.subject !== undefined ? `${rule.id}/${finding.subject}` : rule.id;
24
+ const biome = /^biome\/(?:lint|assist)\/[a-zA-Z]+\/[a-zA-Z]+$/u.test(id);
25
+ if (!biome && rule.registrable === false) {
26
+ return "";
27
+ }
28
+ const shape = JSON.stringify({
29
+ files: [file],
30
+ reason: "Explain why this exception is permanent.",
31
+ rules: [id],
32
+ ...(!rule.family && finding.subject !== undefined ? { subject: finding.subject } : {}),
33
+ });
34
+ return ` For a permanent exception, add to exceptions in ${config}: ${shape}.`;
35
+ }
36
+
37
+ const violationOf = (rule: ActiveRule, root: string, finding: Finding, exceptionFile: string): Violation => ({
22
38
  ...finding,
23
39
  file: repositoryPath(root, finding.file),
24
40
  level: rule.level,
41
+ message: finding.message + exceptionHint(rule, finding, repositoryPath(root, finding.file), exceptionFile),
25
42
  rule: rule.family && finding.subject !== undefined ? `${rule.id}/${finding.subject}` : rule.id,
26
43
  });
27
44
 
28
- const runRule = (rule: ActiveRule, inputs: RuleInputs): Effect.Effect<readonly Violation[], SetupFailure> =>
45
+ const runRule = (rule: ActiveRule, inputs: RuleInputs, exceptionFile: string): Effect.Effect<readonly Violation[], SetupFailure> =>
29
46
  Effect.map(
30
47
  Effect.tryPromise({
31
48
  catch: (cause) => new SetupFailure({ message: `rule ${rule.id} failed: ${messageOf(cause)}` }),
32
49
  try: () => rule.check(inputs),
33
50
  }),
34
- (findings) => findings.map((finding) => violationOf(rule, inputs.root, finding)),
51
+ (findings) => findings.map((finding) => violationOf(rule, inputs.root, finding, exceptionFile)),
35
52
  );
36
53
 
37
54
  export const runRules = (rules: readonly ActiveRule[], inputs: RuleInputs): Effect.Effect<readonly Violation[], SetupFailure> =>
38
55
  Effect.map(
39
- Effect.forEach(rules, (rule) => runRule(rule, inputs), { concurrency: "unbounded" }),
56
+ Effect.forEach(rules, (rule) => runRule(rule, inputs, inputs.configFile ?? "quality.config.ts"), { concurrency: "unbounded" }),
40
57
  (results) => results.flat(),
41
58
  );
@@ -1,8 +1,9 @@
1
- import { resolve } from "node:path";
1
+ import { relative, resolve } from "node:path";
2
2
  import { Effect, type FileSystem } from "effect";
3
3
  import { loadConfig, type ResolvedConfig } from "#config/load.ts";
4
4
  import { validOrFail } from "#decoded.ts";
5
5
  import { decodeRegistry, type RegistryEntry } from "#exceptions/registry.ts";
6
+ import { registryEntries } from "#exceptions/registryEntries.ts";
6
7
  import { SetupFailure } from "#failure.ts";
7
8
  import { collectInventory, type Inventory } from "#inventory/collect.ts";
8
9
  import { type FilesystemFailure, readOptionalText } from "#inventory/filesystem.ts";
@@ -27,7 +28,14 @@ export const scan = (config: ResolvedConfig): Effect.Effect<Pick<Session, "inven
27
28
  const inventory = yield* setup(collectInventory(config.root, config));
28
29
  const services = yield* Effect.context<FileSystem.FileSystem>();
29
30
  const readText = (path: string): Promise<string | undefined> => Effect.runPromiseWith(services)(readOptionalText(resolve(config.root, path)));
30
- const violations = yield* runRules(config.active, { files: inventory.files, readText, root: config.root, sources: inventory.sources });
31
+ const violations = yield* runRules(config.active, {
32
+ configFile: relative(config.root, config.file),
33
+ exceptions: config.exceptions,
34
+ files: inventory.files,
35
+ readText,
36
+ root: config.root,
37
+ sources: inventory.sources,
38
+ });
31
39
  return { inventory, violations };
32
40
  });
33
41
 
@@ -38,5 +46,5 @@ export const openSession = (cwd: string, configPath: string | undefined): Effect
38
46
  const registry = yield* validOrFail(config.registry, yield* Effect.promise(() => decodeRegistry(registryRaw)));
39
47
  const baseline = yield* readBaseline(config);
40
48
  const { inventory, violations } = yield* scan(config);
41
- return { baseline, config, inventory, registry, violations };
49
+ return { baseline, config, inventory, registry: [...registry, ...registryEntries(config.exceptions)], violations };
42
50
  });
@@ -0,0 +1,20 @@
1
+ import type { Finding, RuleInputs } from "#rule.ts";
2
+
3
+ export async function missingBiomeFiles(inputs: RuleInputs): Promise<readonly Finding[]> {
4
+ const paths = (inputs.exceptions ?? [])
5
+ .filter((entry) => entry.rules.some((rule) => rule.startsWith("biome/") && rule !== "biome/files/includes"))
6
+ .flatMap((entry) => entry.files);
7
+ const checked = await Promise.all(
8
+ [...new Set(paths)].map(async (file) =>
9
+ (await inputs.readText(file)) === undefined
10
+ ? [{ file, message: `This Biome exception names a missing file. Remove it from exceptions in ${inputs.configFile ?? "quality.config.ts"}.` }]
11
+ : [],
12
+ ),
13
+ );
14
+ return checked.flat();
15
+ }
16
+
17
+ export function biomeExceptionHint(files: readonly string[], rule: string, config = "quality.config.ts"): string {
18
+ const entry = { files, reason: "Explain why this exception is permanent.", rules: [`biome/${rule}`] };
19
+ return ` Move this setting into exceptions in ${config}: ${JSON.stringify(entry)}, and remove the setting from the Biome config.`;
20
+ }