@shivaedev/quality 0.21.0 → 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 (125) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +24 -8
  3. package/biome/declarations.json +1 -1
  4. package/biome/preset.json +1 -1
  5. package/dist/biome/exceptionConfig.d.ts +7 -0
  6. package/dist/biome/exceptionConfig.d.ts.map +1 -0
  7. package/dist/biome/exceptionConfig.js +54 -0
  8. package/dist/biome/exceptionConfig.js.map +1 -0
  9. package/dist/biome/report.d.ts +2 -1
  10. package/dist/biome/report.d.ts.map +1 -1
  11. package/dist/biome/report.js +5 -5
  12. package/dist/biome/report.js.map +1 -1
  13. package/dist/biome/run.d.ts +2 -1
  14. package/dist/biome/run.d.ts.map +1 -1
  15. package/dist/biome/run.js +5 -4
  16. package/dist/biome/run.js.map +1 -1
  17. package/dist/cli/args.d.ts +5 -1
  18. package/dist/cli/args.d.ts.map +1 -1
  19. package/dist/cli/args.js +6 -27
  20. package/dist/cli/args.js.map +1 -1
  21. package/dist/cli/fix.d.ts.map +1 -1
  22. package/dist/cli/fix.js +9 -9
  23. package/dist/cli/fix.js.map +1 -1
  24. package/dist/cli/lint.d.ts.map +1 -1
  25. package/dist/cli/lint.js +2 -1
  26. package/dist/cli/lint.js.map +1 -1
  27. package/dist/cli/messages.d.ts +3 -0
  28. package/dist/cli/messages.d.ts.map +1 -0
  29. package/dist/cli/messages.js +30 -0
  30. package/dist/cli/messages.js.map +1 -0
  31. package/dist/cli/migrateExceptions.d.ts +4 -0
  32. package/dist/cli/migrateExceptions.d.ts.map +1 -0
  33. package/dist/cli/migrateExceptions.js +78 -0
  34. package/dist/cli/migrateExceptions.js.map +1 -0
  35. package/dist/cli.js +3 -0
  36. package/dist/cli.js.map +1 -1
  37. package/dist/config/decode.d.ts +6 -0
  38. package/dist/config/decode.d.ts.map +1 -1
  39. package/dist/config/decode.js +2 -0
  40. package/dist/config/decode.js.map +1 -1
  41. package/dist/config/load.d.ts +4 -0
  42. package/dist/config/load.d.ts.map +1 -1
  43. package/dist/config/load.js +10 -1
  44. package/dist/config/load.js.map +1 -1
  45. package/dist/config/resolve.d.ts.map +1 -1
  46. package/dist/config/resolve.js +11 -1
  47. package/dist/config/resolve.js.map +1 -1
  48. package/dist/config.d.ts +2 -0
  49. package/dist/config.d.ts.map +1 -1
  50. package/dist/config.js.map +1 -1
  51. package/dist/engine/run-rules.d.ts +1 -0
  52. package/dist/engine/run-rules.d.ts.map +1 -1
  53. package/dist/engine/run-rules.js +19 -4
  54. package/dist/engine/run-rules.js.map +1 -1
  55. package/dist/engine/session.d.ts.map +1 -1
  56. package/dist/engine/session.js +11 -3
  57. package/dist/engine/session.js.map +1 -1
  58. package/dist/exceptions/biomeFindings.d.ts +4 -0
  59. package/dist/exceptions/biomeFindings.d.ts.map +1 -0
  60. package/dist/exceptions/biomeFindings.js +14 -0
  61. package/dist/exceptions/biomeFindings.js.map +1 -0
  62. package/dist/exceptions/declaration.d.ts +11 -0
  63. package/dist/exceptions/declaration.d.ts.map +1 -0
  64. package/dist/exceptions/declaration.js +24 -0
  65. package/dist/exceptions/declaration.js.map +1 -0
  66. package/dist/exceptions/migrateBiome.d.ts +3 -0
  67. package/dist/exceptions/migrateBiome.d.ts.map +1 -0
  68. package/dist/exceptions/migrateBiome.js +62 -0
  69. package/dist/exceptions/migrateBiome.js.map +1 -0
  70. package/dist/exceptions/migrateConfig.d.ts +3 -0
  71. package/dist/exceptions/migrateConfig.d.ts.map +1 -0
  72. package/dist/exceptions/migrateConfig.js +57 -0
  73. package/dist/exceptions/migrateConfig.js.map +1 -0
  74. package/dist/exceptions/registryEntries.d.ts +4 -0
  75. package/dist/exceptions/registryEntries.d.ts.map +1 -0
  76. package/dist/exceptions/registryEntries.js +6 -0
  77. package/dist/exceptions/registryEntries.js.map +1 -0
  78. package/dist/rule.d.ts +3 -0
  79. package/dist/rule.d.ts.map +1 -1
  80. package/dist/rule.js.map +1 -1
  81. package/dist/rules/biome.js +2 -2
  82. package/dist/rules/biome.js.map +1 -1
  83. package/dist/rules/files/namedAfterExport.d.ts.map +1 -1
  84. package/dist/rules/files/namedAfterExport.js +9 -1
  85. package/dist/rules/files/namedAfterExport.js.map +1 -1
  86. package/dist/rules/suppressions/biome/ConsumerDeclaration.d.ts +7 -0
  87. package/dist/rules/suppressions/biome/ConsumerDeclaration.d.ts.map +1 -0
  88. package/dist/rules/suppressions/biome/ConsumerDeclaration.js +15 -0
  89. package/dist/rules/suppressions/biome/ConsumerDeclaration.js.map +1 -0
  90. package/dist/rules/suppressions/biome-overrides.d.ts.map +1 -1
  91. package/dist/rules/suppressions/biome-overrides.js +7 -16
  92. package/dist/rules/suppressions/biome-overrides.js.map +1 -1
  93. package/docs/debt-and-hooks.md +31 -11
  94. package/docs/modules.md +2 -0
  95. package/docs/naming.md +1 -1
  96. package/docs/north-star.md +4 -4
  97. package/docs/roadmap.md +1 -1
  98. package/docs/rules.md +12 -33
  99. package/docs/tooling.md +2 -2
  100. package/package.json +1 -1
  101. package/src/biome/exceptionConfig.ts +70 -0
  102. package/src/biome/report.ts +11 -5
  103. package/src/biome/run.ts +17 -10
  104. package/src/cli/args.ts +7 -29
  105. package/src/cli/fix.ts +14 -9
  106. package/src/cli/lint.ts +3 -1
  107. package/src/cli/messages.ts +31 -0
  108. package/src/cli/migrateExceptions.ts +87 -0
  109. package/src/cli.ts +3 -0
  110. package/src/config/decode.ts +2 -0
  111. package/src/config/load.ts +15 -1
  112. package/src/config/resolve.ts +12 -1
  113. package/src/config.ts +2 -0
  114. package/src/engine/run-rules.ts +21 -4
  115. package/src/engine/session.ts +11 -3
  116. package/src/exceptions/biomeFindings.ts +20 -0
  117. package/src/exceptions/declaration.ts +38 -0
  118. package/src/exceptions/migrateBiome.ts +78 -0
  119. package/src/exceptions/migrateConfig.ts +64 -0
  120. package/src/exceptions/registryEntries.ts +10 -0
  121. package/src/rule.ts +3 -0
  122. package/src/rules/biome.ts +2 -2
  123. package/src/rules/files/namedAfterExport.ts +9 -1
  124. package/src/rules/suppressions/biome/ConsumerDeclaration.ts +24 -0
  125. package/src/rules/suppressions/biome-overrides.ts +7 -26
@@ -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
+ }
@@ -0,0 +1,38 @@
1
+ import { Schema } from "effect";
2
+ import { type Decoded, decodeWith } from "#decoded.ts";
3
+ import { ConsumerDeclaration } from "#rules/suppressions/biome/ConsumerDeclaration.ts";
4
+
5
+ const FILE = /^(?!\.\/)(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[^*!?[\]{}\\]+$/u;
6
+
7
+ export const ExceptionDeclaration = Schema.Struct({
8
+ files: Schema.NonEmptyArray(Schema.NonEmptyString),
9
+ reason: Schema.String.check(Schema.isPattern(/\S/u, { expected: "a reason that says why the exception is permanent" })),
10
+ rules: Schema.NonEmptyArray(Schema.NonEmptyString),
11
+ subject: Schema.optionalKey(Schema.NonEmptyString),
12
+ }).check(
13
+ Schema.makeFilter(
14
+ ({ files, reason, rules, subject }) =>
15
+ rules.every((rule) => {
16
+ if (rule.startsWith("biome/")) {
17
+ return (
18
+ subject === undefined
19
+ && (rule === "biome/files/includes" || files.every((file) => FILE.test(file)))
20
+ && Schema.is(ConsumerDeclaration)({ includes: files, reason, rule: rule.slice(6) })
21
+ );
22
+ }
23
+ return files.every((file) => FILE.test(file));
24
+ }),
25
+ {
26
+ message:
27
+ "Exceptions name explicit repository files and individual rules. Biome rules use biome/lint/<group>/<rule> or biome/assist/<group>/<action>, without a subject. Only biome/files/includes accepts ** and named directory exclusions.",
28
+ },
29
+ ),
30
+ );
31
+
32
+ export type ExceptionDeclaration = typeof ExceptionDeclaration.Type;
33
+
34
+ const standard = Schema.toStandardSchemaV1(Schema.Array(ExceptionDeclaration), { parseOptions: { errors: "all", onExcessProperty: "error" } });
35
+
36
+ export function decodeExceptions(value: unknown): Promise<Decoded<readonly ExceptionDeclaration[]>> {
37
+ return decodeWith(standard, value);
38
+ }
@@ -0,0 +1,78 @@
1
+ import ts from "typescript";
2
+ import type { Declaration } from "#rules/suppressions/biome/declarations.ts";
3
+
4
+ function nameOf(property: ts.ObjectLiteralElementLike): string | undefined {
5
+ return ts.isPropertyAssignment(property) && ts.isStringLiteral(property.name) ? property.name.text : undefined;
6
+ }
7
+
8
+ function strip(node: ts.Expression, paths: readonly (readonly string[])[]): ts.Expression {
9
+ if (!ts.isObjectLiteralExpression(node)) {
10
+ return node;
11
+ }
12
+ const properties = node.properties.flatMap((property) => {
13
+ const name = nameOf(property);
14
+ const matching = paths.filter((path) => path[0] === name);
15
+ if (!ts.isPropertyAssignment(property) || matching.length === 0) {
16
+ return [property];
17
+ }
18
+ if (matching.some((path) => path.length === 1)) {
19
+ return [];
20
+ }
21
+ const value = strip(
22
+ property.initializer,
23
+ matching.map((path) => path.slice(1)),
24
+ );
25
+ return ts.isObjectLiteralExpression(value) && value.properties.length === 0
26
+ ? []
27
+ : [ts.factory.updatePropertyAssignment(property, property.name, value)];
28
+ });
29
+ return ts.factory.updateObjectLiteralExpression(node, properties);
30
+ }
31
+
32
+ function settingPath(rule: string): readonly string[] {
33
+ const [tool = "", group = "", name = ""] = rule.split("/");
34
+ return tool === "files" ? ["files", "includes"] : [tool === "lint" ? "linter" : "assist", tool === "lint" ? "rules" : "actions", group, name];
35
+ }
36
+
37
+ export function migrateBiome(text: string, file: string, declarations: readonly Declaration[]): string {
38
+ const source = ts.parseJsonText(file, text);
39
+ const root = source.statements[0]?.expression;
40
+ if (ts.parseConfigFileTextToJson(file, text).error !== undefined || root === undefined || !ts.isObjectLiteralExpression(root)) {
41
+ throw new Error(`Cannot migrate invalid Biome config ${file}. Repair it, then run quality exceptions migrate again.`);
42
+ }
43
+ const globals = declarations.filter((entry) => entry.rule === "files/includes");
44
+ const stripped = strip(
45
+ root,
46
+ globals.map((entry) => settingPath(entry.rule)),
47
+ );
48
+ if (!ts.isObjectLiteralExpression(stripped)) {
49
+ throw new Error(`Cannot migrate ${file}.`);
50
+ }
51
+ const properties = stripped.properties.flatMap((property) => {
52
+ if (nameOf(property) !== "overrides" || !ts.isPropertyAssignment(property) || !ts.isArrayLiteralExpression(property.initializer)) {
53
+ return [property];
54
+ }
55
+ const overrides = property.initializer.elements.flatMap((override) => {
56
+ if (!ts.isObjectLiteralExpression(override)) {
57
+ return [override];
58
+ }
59
+ const includes = override.properties.find((item) => nameOf(item) === "includes");
60
+ const paths =
61
+ includes !== undefined && ts.isPropertyAssignment(includes) && ts.isArrayLiteralExpression(includes.initializer)
62
+ ? includes.initializer.elements.filter(ts.isStringLiteral).map((item) => item.text)
63
+ : ["**"];
64
+ const matching = declarations.filter(
65
+ (entry) => entry.rule !== "files/includes" && entry.includes.length === paths.length && entry.includes.every((path) => paths.includes(path)),
66
+ );
67
+ const updated = strip(
68
+ override,
69
+ matching.map((entry) => settingPath(entry.rule)),
70
+ );
71
+ return ts.isObjectLiteralExpression(updated) && updated.properties.every((item) => nameOf(item) === "includes") ? [] : [updated];
72
+ });
73
+ return overrides.length === 0
74
+ ? []
75
+ : [ts.factory.updatePropertyAssignment(property, property.name, ts.factory.updateArrayLiteralExpression(property.initializer, overrides))];
76
+ });
77
+ return `${ts.createPrinter().printNode(ts.EmitHint.Expression, ts.factory.updateObjectLiteralExpression(root, properties), source)}\n`;
78
+ }
@@ -0,0 +1,64 @@
1
+ import ts from "typescript";
2
+ import type { ExceptionDeclaration } from "./declaration.ts";
3
+
4
+ function objectOf(expression: ts.Expression): ts.ObjectLiteralExpression | undefined {
5
+ if (ts.isObjectLiteralExpression(expression)) {
6
+ return expression;
7
+ }
8
+ if (ts.isCallExpression(expression) && expression.arguments[0] !== undefined) {
9
+ return objectOf(expression.arguments[0]);
10
+ }
11
+ return ts.isSatisfiesExpression(expression) || ts.isAsExpression(expression) || ts.isParenthesizedExpression(expression)
12
+ ? objectOf(expression.expression)
13
+ : undefined;
14
+ }
15
+
16
+ function nameOf(property: ts.ObjectLiteralElementLike): string | undefined {
17
+ const name = property.name;
18
+ if (name === undefined) {
19
+ return undefined;
20
+ }
21
+ if (ts.isComputedPropertyName(name)) {
22
+ if (ts.isStringLiteral(name.expression) || ts.isNoSubstitutionTemplateLiteral(name.expression) || ts.isNumericLiteral(name.expression)) {
23
+ return name.expression.text;
24
+ }
25
+ throw new Error("Cannot migrate a computed property automatically. Use literal property keys, then run quality exceptions migrate again.");
26
+ }
27
+ return ts.isStringLiteral(name) || ts.isIdentifier(name) || ts.isNumericLiteral(name) ? name.text : undefined;
28
+ }
29
+
30
+ export function migrateConfig(text: string | undefined, file: string, exceptions: readonly ExceptionDeclaration[], hasDeclarations: boolean): string {
31
+ if (text === undefined) {
32
+ return `export default { exceptions: ${JSON.stringify(exceptions, null, "\t")} };\n`;
33
+ }
34
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
35
+ const exported = source.statements.find(ts.isExportAssignment);
36
+ const object = exported === undefined ? undefined : objectOf(exported.expression);
37
+ if (object === undefined || object.properties.some(ts.isSpreadAssignment)) {
38
+ throw new Error(
39
+ `Cannot migrate ${file} automatically. Export an object or defineConfig({ ... }) without spreads, then run quality exceptions migrate again.`,
40
+ );
41
+ }
42
+ const properties = object.properties.flatMap((property) => {
43
+ const name = nameOf(property);
44
+ if (name === "registry" || name === "exceptions") {
45
+ return [];
46
+ }
47
+ if (name !== "rules" || !hasDeclarations) {
48
+ return [{ name: name ?? "", text: property.getFullText(source).trim() }];
49
+ }
50
+ if (
51
+ !(ts.isPropertyAssignment(property) && ts.isObjectLiteralExpression(property.initializer))
52
+ || property.initializer.properties.some(ts.isSpreadAssignment)
53
+ ) {
54
+ throw new Error(`Inline the rules object in ${file}, then run quality exceptions migrate again.`);
55
+ }
56
+ const rules = property.initializer.properties.filter((rule) => nameOf(rule) !== "suppressions/biome-overrides");
57
+ return rules.length === 0 ? [] : [{ name: "rules", text: `rules: {\n${rules.map((rule) => rule.getFullText(source).trim()).join(",\n")}\n}` }];
58
+ });
59
+ const next = properties.map((property) => property.text);
60
+ const index = properties.findIndex((property) => property.name > "exceptions");
61
+ next.splice(index < 0 ? next.length : index, 0, `exceptions: ${JSON.stringify(exceptions, null, "\t")}`);
62
+ const replacement = `{\n${next.join(",\n")}\n}`;
63
+ return text.slice(0, object.getStart(source)) + replacement + text.slice(object.end);
64
+ }
@@ -0,0 +1,10 @@
1
+ import type { ExceptionDeclaration } from "./declaration.ts";
2
+ import type { RegistryEntry } from "./registry.ts";
3
+
4
+ export function registryEntries(declarations: readonly ExceptionDeclaration[]): readonly RegistryEntry[] {
5
+ return declarations.flatMap(({ files, reason, rules, subject }) =>
6
+ rules
7
+ .filter((rule) => !rule.startsWith("biome/"))
8
+ .flatMap((rule) => files.map((file) => ({ file, reason, rule, ...(subject === undefined ? {} : { subject }) }))),
9
+ );
10
+ }
package/src/rule.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Bivariant } from "@shivaedev/types/bivariant.ts";
2
+ import type { ExceptionDeclaration } from "#exceptions/declaration.ts";
2
3
  import { describeIssue, type StandardSchemaV1 } from "./standard-schema.ts";
3
4
 
4
5
  export interface Finding {
@@ -19,6 +20,8 @@ export interface SourceFile {
19
20
  }
20
21
 
21
22
  export interface RuleInputs {
23
+ readonly configFile?: string;
24
+ readonly exceptions?: readonly ExceptionDeclaration[];
22
25
  readonly files: readonly string[];
23
26
  readonly readText: (path: string) => Promise<string | undefined>;
24
27
  readonly root: string;
@@ -43,8 +43,8 @@ async function presetFinding(readText: RuleInputs["readText"]): Promise<readonly
43
43
  }
44
44
 
45
45
  const bridge = defineRule({
46
- check: async ({ readText, root }) => {
47
- const [preset, report] = await Promise.all([presetFinding(readText), biomeReport(root, ["check"])]);
46
+ check: async ({ exceptions, readText, root }) => {
47
+ const [preset, report] = await Promise.all([presetFinding(readText), biomeReport(root, ["check"], exceptions)]);
48
48
  return [...preset, ...report.diagnostics.filter((diagnostic) => FAILING.has(diagnostic.severity)).map(findingOf)];
49
49
  },
50
50
  description:
@@ -9,7 +9,15 @@ import { CAMEL, KEBAB, sameWord, wordsOf } from "#naming/words.ts";
9
9
  import { defineRule, type Finding, type SourceFile } from "#rule.ts";
10
10
  import { parse } from "#rules/syntax.ts";
11
11
 
12
- const DEFAULT_TOOL_OWNED: readonly string[] = ["*.config.*", "*.d.ts", "*.d.mts", "*.d.cts"];
12
+ const DEFAULT_TOOL_OWNED: readonly string[] = [
13
+ "*.config.*",
14
+ "*.d.ts",
15
+ "*.d.mts",
16
+ "*.d.cts",
17
+ "**/.railway/railway.ts",
18
+ "**/.railway/railway.py",
19
+ "**/.railway/railway.go",
20
+ ];
13
21
 
14
22
  const TEST = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
15
23
 
@@ -0,0 +1,24 @@
1
+ import { Schema } from "effect";
2
+ import { Declaration } from "./declarations.ts";
3
+
4
+ const GLOB = /[*!?[\]{}]/u;
5
+ const DIRECTORY = /^\.?[^./\\*!?[\]{}]+$/u;
6
+ const INDIVIDUAL = /^(?:lint|assist)\/(?!domains\/)[a-zA-Z]+\/[a-zA-Z]+$/u;
7
+
8
+ function namedExclusion(pattern: string): boolean {
9
+ const segments = pattern.slice(pattern.startsWith("!**/") ? 4 : 1).split("/");
10
+ return pattern.startsWith("!") && DIRECTORY.test(segments.at(-1) ?? "") && segments.every((segment) => segment === "*" || DIRECTORY.test(segment));
11
+ }
12
+
13
+ export const ConsumerDeclaration = Declaration.check(
14
+ Schema.makeFilter(
15
+ ({ includes, rule }) =>
16
+ rule === "files/includes"
17
+ ? includes.includes("**") && includes.length > 1 && includes.every((pattern) => pattern === "**" || namedExclusion(pattern))
18
+ : INDIVIDUAL.test(rule) && includes.every((path) => !GLOB.test(path) && /[^/]+\.[^/]+$/u.test(path)),
19
+ {
20
+ message:
21
+ "Declare one lint rule or assist action for explicit files, or files/includes with ** and named directory exclusions. Whole checks, groups and blanket scopes cannot be weakened.",
22
+ },
23
+ ),
24
+ );
@@ -1,31 +1,11 @@
1
1
  import { Effect, Schema } from "effect";
2
+ import { biomeExceptionHint, missingBiomeFiles } from "#exceptions/biomeFindings.ts";
2
3
  import { defineRule, type Finding } from "#rule.ts";
4
+ import { ConsumerDeclaration } from "#rules/suppressions/biome/ConsumerDeclaration.ts";
3
5
  import { biomeConfigs, type Chain } from "#rules/suppressions/biome/configs.ts";
4
- import { Declaration, matches, type Scoped, scoped, scopeText } from "#rules/suppressions/biome/declarations.ts";
6
+ import { matches, type Scoped, scoped, scopeText } from "#rules/suppressions/biome/declarations.ts";
5
7
  import { type Weakening, weakeningsOf } from "#rules/suppressions/biome/weakenings.ts";
6
8
 
7
- const GLOB = /[*!?[\]{}]/u;
8
- const DIRECTORY = /^\.?[^./\\*!?[\]{}]+$/u;
9
- const INDIVIDUAL = /^(?:lint|assist)\/(?!domains\/)[a-zA-Z]+\/[a-zA-Z]+$/u;
10
-
11
- function namedExclusion(pattern: string): boolean {
12
- const segments = pattern.slice(pattern.startsWith("!**/") ? 4 : 1).split("/");
13
- return pattern.startsWith("!") && DIRECTORY.test(segments.at(-1) ?? "") && segments.every((segment) => segment === "*" || DIRECTORY.test(segment));
14
- }
15
-
16
- const ConsumerDeclaration = Declaration.check(
17
- Schema.makeFilter(
18
- ({ includes, rule }) =>
19
- rule === "files/includes"
20
- ? includes.includes("**") && includes.length > 1 && includes.every((pattern) => pattern === "**" || namedExclusion(pattern))
21
- : INDIVIDUAL.test(rule) && includes.every((path) => !GLOB.test(path) && /[^/]+\.[^/]+$/u.test(path)),
22
- {
23
- message:
24
- "Declare one lint rule or assist action for explicit files, or files/includes with ** and named directory exclusions. Whole checks, groups and blanket scopes cannot be weakened.",
25
- },
26
- ),
27
- );
28
-
29
9
  const BiomeOverridesOptions = Schema.Struct({
30
10
  declared: Schema.Array(ConsumerDeclaration).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
31
11
  });
@@ -41,12 +21,12 @@ const shippedBy = ({ base, layers }: Chain): readonly Shipped[] =>
41
21
  : preset.declared.map(({ includes, rule }) => ({ includes: includes.map((glob) => scoped(base, glob)), rule, specifier: preset.specifier })),
42
22
  );
43
23
 
44
- const undeclared = ({ includes, layer, line, rule }: Weakening): Finding =>
24
+ const undeclared = ({ includes, layer, line, rule }: Weakening, configFile: string): Finding =>
45
25
  layer.preset === undefined
46
26
  ? {
47
27
  file: layer.path,
48
28
  line,
49
- message: `Weakens "${rule}" for ${scopeText(includes)} without a declaration. Repair the code and remove the weakening setting.`,
29
+ message: `Weakens "${rule}" for ${scopeText(includes)} without a declaration. Repair the code and remove the weakening setting.${Schema.is(ConsumerDeclaration)({ includes, reason: "Permanent exception.", rule }) ? biomeExceptionHint(includes, rule, configFile) : ""}`,
50
30
  subject: rule,
51
31
  }
52
32
  : {
@@ -85,7 +65,8 @@ export const biomeOverrides = defineRule({
85
65
  const covered = (weakening: Weakening): boolean => [...declared, ...shipped].some((declaration) => matches(declaration, weakening));
86
66
  return [
87
67
  ...problems,
88
- ...weakenings.filter((weakening) => !covered(weakening)).map(undeclared),
68
+ ...(await missingBiomeFiles(inputs)),
69
+ ...weakenings.filter((weakening) => !covered(weakening)).map((weakening) => undeclared(weakening, inputs.configFile ?? "quality.config.ts")),
89
70
  ...declared.flatMap(staleOrRepeated(root, weakenings, shipped)),
90
71
  ];
91
72
  },