@shivaedev/quality 0.18.0 → 0.19.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 (203) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +143 -536
  3. package/biome/declarations.json +0 -45
  4. package/biome/plugins/constant-names.grit +1 -1
  5. package/biome/plugins/effect-fn-functions.grit +1 -1
  6. package/biome/preset.json +16 -9
  7. package/dist/biome/nativeEffectCall.d.ts +4 -0
  8. package/dist/biome/nativeEffectCall.d.ts.map +1 -0
  9. package/dist/biome/nativeEffectCall.js +34 -0
  10. package/dist/biome/nativeEffectCall.js.map +1 -0
  11. package/dist/biome/recognizeDiagnostics.d.ts +3 -0
  12. package/dist/biome/recognizeDiagnostics.d.ts.map +1 -0
  13. package/dist/biome/recognizeDiagnostics.js +58 -0
  14. package/dist/biome/recognizeDiagnostics.js.map +1 -0
  15. package/dist/biome/report.d.ts +10 -0
  16. package/dist/biome/report.d.ts.map +1 -1
  17. package/dist/biome/report.js +23 -5
  18. package/dist/biome/report.js.map +1 -1
  19. package/dist/biome/syntax.d.ts +12 -0
  20. package/dist/biome/syntax.d.ts.map +1 -0
  21. package/dist/biome/syntax.js +29 -0
  22. package/dist/biome/syntax.js.map +1 -0
  23. package/dist/cli/args.d.ts +1 -1
  24. package/dist/cli/args.d.ts.map +1 -1
  25. package/dist/cli/args.js +2 -2
  26. package/dist/cli/baseline.d.ts +1 -1
  27. package/dist/cli/baseline.d.ts.map +1 -1
  28. package/dist/cli/baseline.js.map +1 -1
  29. package/dist/cli/fix.d.ts +6 -6
  30. package/dist/cli/fix.d.ts.map +1 -1
  31. package/dist/cli/fix.js +1 -1
  32. package/dist/cli/fix.js.map +1 -1
  33. package/dist/cli/run-main.d.ts +1 -1
  34. package/dist/cli/run-main.d.ts.map +1 -1
  35. package/dist/cli/run-main.js.map +1 -1
  36. package/dist/config/decode.d.ts +3 -7
  37. package/dist/config/decode.d.ts.map +1 -1
  38. package/dist/config/decode.js +5 -3
  39. package/dist/config/decode.js.map +1 -1
  40. package/dist/config/load.d.ts +0 -2
  41. package/dist/config/load.d.ts.map +1 -1
  42. package/dist/config/load.js +11 -7
  43. package/dist/config/load.js.map +1 -1
  44. package/dist/config/resolve.d.ts.map +1 -1
  45. package/dist/config/resolve.js +8 -12
  46. package/dist/config/resolve.js.map +1 -1
  47. package/dist/config.d.ts +10 -16
  48. package/dist/config.d.ts.map +1 -1
  49. package/dist/config.js.map +1 -1
  50. package/dist/decoded.d.ts +4 -4
  51. package/dist/decoded.d.ts.map +1 -1
  52. package/dist/decoded.js.map +1 -1
  53. package/dist/engine/session.js.map +1 -1
  54. package/dist/engine/violation.d.ts +1 -1
  55. package/dist/engine/violation.d.ts.map +1 -1
  56. package/dist/engine/violation.js.map +1 -1
  57. package/dist/hooks/preCommit.d.ts +2 -2
  58. package/dist/hooks/preCommit.d.ts.map +1 -1
  59. package/dist/hooks/preCommit.js +24 -34
  60. package/dist/hooks/preCommit.js.map +1 -1
  61. package/dist/imports/browserGlobals.d.ts +3 -0
  62. package/dist/imports/browserGlobals.d.ts.map +1 -0
  63. package/dist/imports/browserGlobals.js +125 -0
  64. package/dist/imports/browserGlobals.js.map +1 -0
  65. package/dist/imports/browserNames.d.ts +2 -0
  66. package/dist/imports/browserNames.d.ts.map +1 -0
  67. package/dist/imports/browserNames.js +78 -0
  68. package/dist/imports/browserNames.js.map +1 -0
  69. package/dist/imports/fences/check.d.ts +4 -0
  70. package/dist/imports/fences/check.d.ts.map +1 -0
  71. package/dist/imports/fences/check.js +10 -0
  72. package/dist/imports/fences/check.js.map +1 -0
  73. package/dist/imports/fences/declarations.d.ts +5 -0
  74. package/dist/imports/fences/declarations.d.ts.map +1 -0
  75. package/dist/imports/fences/declarations.js +29 -0
  76. package/dist/imports/fences/declarations.js.map +1 -0
  77. package/dist/imports/fences/discover.d.ts +5 -0
  78. package/dist/imports/fences/discover.d.ts.map +1 -0
  79. package/dist/imports/fences/discover.js +41 -0
  80. package/dist/imports/fences/discover.js.map +1 -0
  81. package/dist/imports/graph.js +2 -2
  82. package/dist/imports/graph.js.map +1 -1
  83. package/dist/imports/projects.js.map +1 -1
  84. package/dist/imports/runtime.d.ts +5 -0
  85. package/dist/imports/runtime.d.ts.map +1 -0
  86. package/dist/imports/runtime.js +32 -0
  87. package/dist/imports/runtime.js.map +1 -0
  88. package/dist/imports/workspace.d.ts +1 -1
  89. package/dist/imports/workspace.d.ts.map +1 -1
  90. package/dist/imports/workspace.js +5 -1
  91. package/dist/imports/workspace.js.map +1 -1
  92. package/dist/inventory/collect.d.ts.map +1 -1
  93. package/dist/inventory/collect.js +8 -1
  94. package/dist/inventory/collect.js.map +1 -1
  95. package/dist/inventory/trackedPaths.d.ts +3 -0
  96. package/dist/inventory/trackedPaths.d.ts.map +1 -0
  97. package/dist/inventory/trackedPaths.js +26 -0
  98. package/dist/inventory/trackedPaths.js.map +1 -0
  99. package/dist/inventory/walk.d.ts +4 -4
  100. package/dist/inventory/walk.d.ts.map +1 -1
  101. package/dist/inventory/walk.js +32 -12
  102. package/dist/inventory/walk.js.map +1 -1
  103. package/dist/rules/built-in.d.ts +2 -3
  104. package/dist/rules/built-in.d.ts.map +1 -1
  105. package/dist/rules/built-in.js +6 -0
  106. package/dist/rules/built-in.js.map +1 -1
  107. package/dist/rules/comments/max-per-file.js +1 -1
  108. package/dist/rules/comments/max-per-file.js.map +1 -1
  109. package/dist/rules/files/folderNames.js +1 -1
  110. package/dist/rules/files/folderNames.js.map +1 -1
  111. package/dist/rules/files/otherNames.d.ts.map +1 -1
  112. package/dist/rules/files/otherNames.js +1 -10
  113. package/dist/rules/files/otherNames.js.map +1 -1
  114. package/dist/rules/imports/bundleInputs.d.ts +7 -0
  115. package/dist/rules/imports/bundleInputs.d.ts.map +1 -0
  116. package/dist/rules/imports/bundleInputs.js +77 -0
  117. package/dist/rules/imports/bundleInputs.js.map +1 -0
  118. package/dist/rules/imports/fences.d.ts +1 -2
  119. package/dist/rules/imports/fences.d.ts.map +1 -1
  120. package/dist/rules/imports/fences.js +4 -14
  121. package/dist/rules/imports/fences.js.map +1 -1
  122. package/dist/rules/imports/manifest.d.ts +2 -0
  123. package/dist/rules/imports/manifest.d.ts.map +1 -0
  124. package/dist/rules/imports/manifest.js +98 -0
  125. package/dist/rules/imports/manifest.js.map +1 -0
  126. package/dist/rules/imports/noTestCode.d.ts +2 -0
  127. package/dist/rules/imports/noTestCode.d.ts.map +1 -0
  128. package/dist/rules/imports/noTestCode.js +17 -0
  129. package/dist/rules/imports/noTestCode.js.map +1 -0
  130. package/dist/rules/imports/runtime.d.ts +2 -0
  131. package/dist/rules/imports/runtime.d.ts.map +1 -0
  132. package/dist/rules/imports/runtime.js +41 -0
  133. package/dist/rules/imports/runtime.js.map +1 -0
  134. package/dist/rules/max-lines.d.ts +0 -1
  135. package/dist/rules/max-lines.d.ts.map +1 -1
  136. package/dist/rules/max-lines.js +3 -10
  137. package/dist/rules/max-lines.js.map +1 -1
  138. package/dist/rules/suppressions/biome-overrides.d.ts.map +1 -1
  139. package/dist/rules/suppressions/biome-overrides.js +21 -4
  140. package/dist/rules/suppressions/biome-overrides.js.map +1 -1
  141. package/dist/rules/suppressions/biomeRecommended.js +2 -2
  142. package/dist/rules/suppressions/biomeRecommended.js.map +1 -1
  143. package/dist/rules/suppressions/no-inline.js +1 -1
  144. package/dist/rules/suppressions/no-inline.js.map +1 -1
  145. package/dist/standard-schema.d.ts +1 -1
  146. package/dist/standard-schema.d.ts.map +1 -1
  147. package/dist/standard-schema.js +10 -2
  148. package/dist/standard-schema.js.map +1 -1
  149. package/dist/vitest.d.ts +1 -3
  150. package/dist/vitest.d.ts.map +1 -1
  151. package/dist/vitest.js +11 -6
  152. package/dist/vitest.js.map +1 -1
  153. package/docs/debt-and-hooks.md +90 -0
  154. package/docs/modules.md +253 -0
  155. package/docs/naming.md +126 -0
  156. package/docs/north-star.md +61 -0
  157. package/docs/roadmap.md +37 -0
  158. package/docs/rules.md +308 -0
  159. package/docs/tooling.md +120 -0
  160. package/package.json +7 -5
  161. package/src/biome/nativeEffectCall.ts +45 -0
  162. package/src/biome/recognizeDiagnostics.ts +67 -0
  163. package/src/biome/report.ts +24 -6
  164. package/src/biome/syntax.ts +36 -0
  165. package/src/cli/args.ts +2 -2
  166. package/src/cli/baseline.ts +3 -3
  167. package/src/cli/fix.ts +13 -13
  168. package/src/cli/run-main.ts +1 -1
  169. package/src/config/decode.ts +7 -3
  170. package/src/config/load.ts +15 -9
  171. package/src/config/resolve.ts +10 -15
  172. package/src/config.ts +8 -15
  173. package/src/decoded.ts +3 -3
  174. package/src/engine/session.ts +3 -3
  175. package/src/engine/violation.ts +2 -2
  176. package/src/hooks/preCommit.ts +30 -39
  177. package/src/imports/browserGlobals.ts +142 -0
  178. package/src/imports/browserNames.ts +80 -0
  179. package/src/imports/fences/check.ts +12 -0
  180. package/src/imports/fences/declarations.ts +38 -0
  181. package/src/imports/fences/discover.ts +37 -0
  182. package/src/imports/graph.ts +2 -2
  183. package/src/imports/projects.ts +2 -2
  184. package/src/imports/runtime.ts +37 -0
  185. package/src/imports/workspace.ts +7 -2
  186. package/src/inventory/collect.ts +8 -1
  187. package/src/inventory/trackedPaths.ts +28 -0
  188. package/src/inventory/walk.ts +65 -36
  189. package/src/rules/built-in.ts +6 -0
  190. package/src/rules/comments/max-per-file.ts +1 -1
  191. package/src/rules/files/folderNames.ts +1 -1
  192. package/src/rules/files/otherNames.ts +1 -10
  193. package/src/rules/imports/bundleInputs.ts +94 -0
  194. package/src/rules/imports/fences.ts +4 -16
  195. package/src/rules/imports/manifest.ts +116 -0
  196. package/src/rules/imports/noTestCode.ts +18 -0
  197. package/src/rules/imports/runtime.ts +43 -0
  198. package/src/rules/max-lines.ts +4 -10
  199. package/src/rules/suppressions/biome-overrides.ts +31 -4
  200. package/src/rules/suppressions/biomeRecommended.ts +2 -2
  201. package/src/rules/suppressions/no-inline.ts +1 -1
  202. package/src/standard-schema.ts +11 -3
  203. package/src/vitest.ts +11 -6
@@ -0,0 +1,94 @@
1
+ import { posix } from "node:path";
2
+ import { outgoing } from "#imports/fences/reach.ts";
3
+ import type { ImportGraph } from "#imports/graph.ts";
4
+ import { packageOf, type WorkspacePackage } from "#imports/workspace.ts";
5
+
6
+ function blocked(exports: unknown, subpath: string): boolean {
7
+ if (typeof exports !== "object" || exports === null || Array.isArray(exports)) {
8
+ return false;
9
+ }
10
+ const entries = Object.entries(exports);
11
+ const exact = entries.find(([key]) => key === subpath);
12
+ if (exact !== undefined) {
13
+ return exact[1] === null;
14
+ }
15
+ const matching = entries.filter(([key]) => {
16
+ const [prefix = "", suffix, ...rest] = key.split("*");
17
+ return (
18
+ suffix !== undefined
19
+ && rest.length === 0
20
+ && subpath.length > prefix.length + suffix.length
21
+ && subpath.startsWith(prefix)
22
+ && subpath.endsWith(suffix)
23
+ );
24
+ });
25
+ const best = matching.toSorted(([left], [right]) => right.indexOf("*") - left.indexOf("*") || right.length - left.length)[0];
26
+ return best?.[1] === null;
27
+ }
28
+
29
+ function targets(value: unknown): string[] {
30
+ if (typeof value === "string") {
31
+ return [value];
32
+ }
33
+ return typeof value === "object" && value !== null ? Object.values(value).flatMap(targets) : [];
34
+ }
35
+
36
+ function exported(exports: unknown, local: string): boolean {
37
+ if (typeof exports !== "object" || exports === null) {
38
+ return false;
39
+ }
40
+ const paths = [
41
+ `./${local}`,
42
+ `./${local.replace(/^src\//u, "dist/").replace(/\.(?<mode>[cm]?)tsx?$/u, ".$<mode>js")}`,
43
+ `./${local.replace(/^src\//u, "dist/").replace(/\.(?<mode>[cm]?)tsx?$/u, ".d.$<mode>ts")}`,
44
+ ];
45
+ return Object.entries(exports).some(([key, value]) =>
46
+ targets(value).some((target) =>
47
+ paths.some((path) => {
48
+ const [prefix = "", suffix] = target.split("*");
49
+ if (suffix === undefined) {
50
+ return path === target && !blocked(exports, key);
51
+ }
52
+ if (!(path.startsWith(prefix) && path.endsWith(suffix)) || path.length <= prefix.length + suffix.length) {
53
+ return false;
54
+ }
55
+ const star = path.slice(prefix.length, path.length - suffix.length);
56
+ return !blocked(exports, key.replace("*", star));
57
+ }),
58
+ ),
59
+ );
60
+ }
61
+
62
+ function executable(manifest: object, local: string): boolean {
63
+ const binTargets = targets(Reflect.get(manifest, "bin"));
64
+ const built = local.replace(/^src\//u, "dist/").replace(/\.(?<mode>[cm]?)tsx?$/u, ".$<mode>js");
65
+ return binTargets.some((target) => typeof target === "string" && [local, built].includes(posix.normalize(target)));
66
+ }
67
+
68
+ export function bundleInputs(graph: ImportGraph, owner: WorkspacePackage, manifest: object, developmentFile: (path: string) => boolean) {
69
+ const modules = graph.modules.filter((path) => (packageOf(graph.packages, path) ?? (owner.directory === "." ? owner : undefined)) === owner);
70
+ const privateFiles = new Set(
71
+ modules.filter((path) => {
72
+ const local = posix.relative(owner.directory, path);
73
+ return (
74
+ local.startsWith("src/")
75
+ && blocked(Reflect.get(manifest, "exports"), `./${local.slice(4)}`)
76
+ && !exported(Reflect.get(manifest, "exports"), local)
77
+ );
78
+ }),
79
+ );
80
+ const queue = modules.filter(
81
+ (path) => executable(manifest, posix.relative(owner.directory, path)) || !(developmentFile(path) || privateFiles.has(path)),
82
+ );
83
+ const runtime = new Set(queue);
84
+ const edges = outgoing(graph.edges);
85
+ for (const path of queue) {
86
+ for (const edge of edges.get(path) ?? []) {
87
+ if (edge.to.kind === "file" && !runtime.has(edge.to.path)) {
88
+ runtime.add(edge.to.path);
89
+ queue.push(edge.to.path);
90
+ }
91
+ }
92
+ }
93
+ return { privateFiles, runtime };
94
+ }
@@ -1,26 +1,14 @@
1
1
  import { Effect, Schema } from "effect";
2
- import type { Fence } from "#imports/fences/model.ts";
3
- import { compilePolicy } from "#imports/fences/policy.ts";
4
- import { importGraph } from "#imports/graph.ts";
2
+ import { checkFences } from "#imports/fences/check.ts";
3
+ import { Declarations } from "#imports/fences/declarations.ts";
5
4
  import { defineRule } from "#rule.ts";
6
5
 
7
- function isFence(value: unknown): value is Fence {
8
- return typeof value === "object" && value !== null && "_tag" in value && value._tag === "Fence";
9
- }
10
-
11
6
  const FencesOptions = Schema.Struct({
12
- fences: Schema.Array(Schema.declare(isFence, { expected: "a fence made with fence()" })).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
7
+ fences: Declarations.pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
13
8
  });
14
9
 
15
10
  export const importFences = defineRule({
16
- check: async ({ options, ...inputs }) => {
17
- if (options.fences.length === 0) {
18
- return [];
19
- }
20
- const graph = await importGraph(inputs);
21
- const policy = compilePolicy(options.fences, { files: inputs.files, packages: graph.packages });
22
- return policy.flatMap((compiled) => compiled.evaluate(graph));
23
- },
11
+ check: ({ options, ...inputs }) => checkFences(inputs, options.fences),
24
12
  description:
25
13
  "Imports of shipped code respect the fences the repository declares; a test file ships nowhere, so no fence holds it. Move the code to the side of the fence its imports belong to.",
26
14
  id: "imports/fences",
@@ -0,0 +1,116 @@
1
+ import { posix } from "node:path";
2
+ import { type ImportGraph, importGraph } from "#imports/graph.ts";
3
+ import { builtinName } from "#imports/resolve.ts";
4
+ import { packageOf, type WorkspacePackage } from "#imports/workspace.ts";
5
+ import { isTestCode } from "#naming/testName.ts";
6
+ import { defineRule, type RuleInputs } from "#rule.ts";
7
+ import { bundleInputs } from "./bundleInputs.ts";
8
+
9
+ const QUERY = /\?.*$/u;
10
+
11
+ function packageName(specifier: string): string {
12
+ const bare = specifier.replace(QUERY, "");
13
+ return (
14
+ builtinName(bare)
15
+ ?? bare
16
+ .split("/")
17
+ .slice(0, bare.startsWith("@") ? 2 : 1)
18
+ .join("/")
19
+ );
20
+ }
21
+
22
+ interface ManifestDependencies {
23
+ readonly development: ReadonlySet<string>;
24
+ readonly privateFiles: ReadonlySet<string>;
25
+ readonly runtime: ReadonlySet<string>;
26
+ readonly shipped: ReadonlySet<string>;
27
+ }
28
+
29
+ function names(manifest: object, field: string): readonly string[] {
30
+ const value: unknown = Reflect.get(manifest, field);
31
+ if (value === undefined) {
32
+ return [];
33
+ }
34
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
35
+ throw new Error(`${field} must be an object`);
36
+ }
37
+ return Object.keys(value);
38
+ }
39
+
40
+ async function manifestDependencies(inputs: RuleInputs, owner: WorkspacePackage, graph: ImportGraph): Promise<ManifestDependencies> {
41
+ const path = posix.join(owner.directory, "package.json");
42
+ try {
43
+ const text = await inputs.readText(path);
44
+ if (text === undefined) {
45
+ throw new Error("the manifest is missing");
46
+ }
47
+ const manifest: unknown = JSON.parse(text);
48
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
49
+ throw new Error("the manifest must be an object");
50
+ }
51
+ return {
52
+ ...bundleInputs(graph, owner, manifest, (candidate) => developmentFile(owner, candidate)),
53
+ development: new Set(names(manifest, "devDependencies")),
54
+ shipped: new Set(["dependencies", "peerDependencies", "optionalDependencies"].flatMap((field) => names(manifest, field))),
55
+ };
56
+ } catch (cause) {
57
+ throw new Error(`cannot read dependency policy at ${path}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
58
+ }
59
+ }
60
+
61
+ const TOOL_FOLDERS = new Set(["script", "scripts", "tooling"]);
62
+
63
+ const CONFIG_FILE = /(?:^|\.)config\.[cm]?[jt]sx?$/u;
64
+
65
+ function developmentFile(owner: WorkspacePackage, path: string): boolean {
66
+ if (isTestCode(path)) {
67
+ return true;
68
+ }
69
+ const local = posix.relative(owner.directory, path);
70
+ const [folder = ""] = local.split("/");
71
+ return TOOL_FOLDERS.has(folder) || (!local.includes("/") && CONFIG_FILE.test(local));
72
+ }
73
+
74
+ function developmentImport(policy: ManifestDependencies | undefined, owner: WorkspacePackage, path: string, runtime: ReadonlySet<string>) {
75
+ if (runtime.has(path)) {
76
+ return false;
77
+ }
78
+ return developmentFile(owner, path) || policy?.privateFiles.has(path);
79
+ }
80
+
81
+ export const importsManifest = defineRule({
82
+ check: async (inputs) => {
83
+ const graph = await importGraph(inputs);
84
+ const root: WorkspacePackage | undefined = (await inputs.readText("package.json")) === undefined ? undefined : { directory: ".", name: "." };
85
+ const owners = [...graph.packages, ...(root === undefined ? [] : [root])];
86
+ const policies = new Map(await Promise.all(owners.map(async (owner) => [owner, await manifestDependencies(inputs, owner, graph)] as const)));
87
+ const runtime = new Set([...policies.values()].flatMap((policy) => [...policy.runtime]));
88
+ const named = new Map(graph.packages.map((owner) => [owner.name, owner]));
89
+ const requests = [...graph.edges, ...graph.unresolved.filter((request) => request.missing === undefined)];
90
+ return requests.flatMap((request) => {
91
+ const owner = packageOf(graph.packages, request.from) ?? root;
92
+ const target =
93
+ ("to" in request && request.to.kind === "file" ? packageOf(graph.packages, request.to.path) : undefined)
94
+ ?? named.get(packageName(request.specifier));
95
+ if (owner === undefined || target === undefined || owner === target) {
96
+ return [];
97
+ }
98
+ const policy = policies.get(owner);
99
+ const development = developmentImport(policy, owner, request.from, runtime);
100
+ if (policy?.shipped.has(target.name) || (development && policy?.development.has(target.name))) {
101
+ return [];
102
+ }
103
+ return [
104
+ {
105
+ file: request.from,
106
+ line: request.line,
107
+ message: `Imports workspace package "${target.name}" without declaring it in ${posix.join(owner.directory, "package.json")}. Add it to dependencies, peerDependencies or optionalDependencies, or remove the import.${development ? " Tooling, test code and export-blocked bundle inputs may also use devDependencies." : " devDependencies are only available to tests, test support, tooling and export-blocked bundle inputs unreachable from runtime code."}`,
108
+ subject: target.name,
109
+ },
110
+ ];
111
+ });
112
+ },
113
+ description:
114
+ "Workspace imports follow the importing package's manifest; devDependencies are available only outside runtime code, including export-blocked bundle inputs.",
115
+ id: "imports/manifest",
116
+ });
@@ -0,0 +1,18 @@
1
+ import { importGraph } from "#imports/graph.ts";
2
+ import { isTestCode } from "#naming/testName.ts";
3
+ import { defineRule } from "#rule.ts";
4
+
5
+ export const noTestCode = defineRule({
6
+ check: async (inputs) =>
7
+ (await importGraph(inputs)).edges
8
+ .filter((edge) => !isTestCode(edge.from) && edge.to.kind === "file" && isTestCode(edge.to.path))
9
+ .map((edge) => ({
10
+ file: edge.from,
11
+ line: edge.line,
12
+ message: `Imports test code through "${edge.specifier}". Move the shared code into a production module and import it from both production and tests.`,
13
+ subject: edge.specifier,
14
+ })),
15
+ description: "Production modules import production code. Move shared code out of test files and test-support/ into a production module.",
16
+ id: "imports/no-test-code",
17
+ registrable: false,
18
+ });
@@ -0,0 +1,43 @@
1
+ import { browserGlobals } from "#imports/browserGlobals.ts";
2
+ import { importGraph } from "#imports/graph.ts";
3
+ import { builtinName } from "#imports/resolve.ts";
4
+ import { type Runtime, runtimeOf } from "#imports/runtime.ts";
5
+ import { isTestCode } from "#naming/testName.ts";
6
+ import { defineRule } from "#rule.ts";
7
+
8
+ function forbiddenImport(from: Runtime, to: Runtime | undefined, builtin: boolean): boolean {
9
+ if (from === "shared") {
10
+ return to === "client" || to === "server" || builtin;
11
+ }
12
+ return from === "client" && (to === "server" || builtin);
13
+ }
14
+
15
+ export const importsRuntime = defineRule({
16
+ check: async (inputs) => {
17
+ const graph = await importGraph(inputs);
18
+ const runtime = runtimeOf(inputs, graph.packages);
19
+ const imports = graph.edges.flatMap((edge) => {
20
+ const from = runtime(edge.from);
21
+ if (from === undefined || isTestCode(edge.from)) {
22
+ return [];
23
+ }
24
+ const to = edge.to.kind === "file" ? runtime(edge.to.path) : undefined;
25
+ const builtin = edge.to.kind === "external" && builtinName(edge.to.specifier) !== undefined;
26
+ if (!forbiddenImport(from, to, builtin)) {
27
+ return [];
28
+ }
29
+ return [
30
+ {
31
+ file: edge.from,
32
+ line: edge.line,
33
+ message: `${from === "client" ? "Client" : "Shared"} code imports ${builtin ? "Node built-in" : `${to} code`} through "${edge.specifier}". Move runtime-dependent code into src/client/ or src/server/ and keep shared code independent of both runtimes.`,
34
+ subject: edge.specifier,
35
+ },
36
+ ];
37
+ });
38
+ return [...imports, ...inputs.sources.filter((file) => !isTestCode(file.path) && runtime(file.path) === "shared").flatMap(browserGlobals)];
39
+ },
40
+ description:
41
+ "In packages with src/client/ or src/server/, folder names define runtime boundaries. Shared code runs in either runtime without browser globals or Node built-ins.",
42
+ id: "imports/runtime",
43
+ });
@@ -1,5 +1,4 @@
1
1
  import { Effect, Schema } from "effect";
2
- import ignore from "ignore";
3
2
  import { isTestCode } from "#naming/testName.ts";
4
3
  import { defineRule, type Finding, type SourceFile } from "#rule.ts";
5
4
 
@@ -8,9 +7,8 @@ const DECLARATION = /\.d\.[cm]?ts$/u;
8
7
  const Limit = Schema.Int.check(Schema.isGreaterThan(0));
9
8
 
10
9
  const MaxLinesOptions = Schema.Struct({
11
- source: Limit.pipe(Schema.withDecodingDefaultKey(Effect.succeed(150))),
12
- test: Limit.pipe(Schema.withDecodingDefaultKey(Effect.succeed(300))),
13
- testFiles: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
10
+ source: Limit.check(Schema.isLessThanOrEqualTo(150)).pipe(Schema.withDecodingDefaultKey(Effect.succeed(150))),
11
+ test: Limit.check(Schema.isLessThanOrEqualTo(300)).pipe(Schema.withDecodingDefaultKey(Effect.succeed(300))),
14
12
  });
15
13
 
16
14
  const oversized = (file: SourceFile, limit: number): readonly Finding[] =>
@@ -26,12 +24,8 @@ const oversized = (file: SourceFile, limit: number): readonly Finding[] =>
26
24
  ];
27
25
 
28
26
  export const maxLines = defineRule({
29
- check: ({ options, sources }) => {
30
- const suites = ignore().add([...options.testFiles]);
31
- return sources
32
- .filter((file) => !DECLARATION.test(file.path))
33
- .flatMap((file) => oversized(file, isTestCode(file.path) || suites.ignores(file.path) ? options.test : options.source));
34
- },
27
+ check: ({ options, sources }) =>
28
+ sources.filter((file) => !DECLARATION.test(file.path)).flatMap((file) => oversized(file, isTestCode(file.path) ? options.test : options.source)),
35
29
  description: "Keep each module to one job. Split a long file along its responsibilities; never golf it under the limit.",
36
30
  id: "structure/max-lines",
37
31
  options: Schema.toStandardSchemaV1(MaxLinesOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
@@ -4,8 +4,30 @@ import { biomeConfigs, type Chain } from "#rules/suppressions/biome/configs.ts";
4
4
  import { Declaration, matches, type Scoped, scoped, scopeText } from "#rules/suppressions/biome/declarations.ts";
5
5
  import { type Weakening, weakeningsOf } from "#rules/suppressions/biome/weakenings.ts";
6
6
 
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
+
7
29
  const BiomeOverridesOptions = Schema.Struct({
8
- declared: Schema.Array(Declaration).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
30
+ declared: Schema.Array(ConsumerDeclaration).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
9
31
  });
10
32
 
11
33
  interface Shipped extends Scoped {
@@ -21,11 +43,16 @@ const shippedBy = ({ base, layers }: Chain): readonly Shipped[] =>
21
43
 
22
44
  const undeclared = ({ includes, layer, line, rule }: Weakening): Finding =>
23
45
  layer.preset === undefined
24
- ? { file: layer.path, line, message: `Weakens "${rule}" for ${scopeText(includes)} without a declaration.`, subject: rule }
46
+ ? {
47
+ file: layer.path,
48
+ line,
49
+ message: `Weakens "${rule}" for ${scopeText(includes)} without a declaration. Repair the code and remove the weakening setting.`,
50
+ subject: rule,
51
+ }
25
52
  : {
26
53
  file: layer.preset.file,
27
54
  line: layer.preset.line,
28
- message: `Extends "${layer.preset.specifier}", which weakens "${rule}" for ${scopeText(includes)} without a declaration.`,
55
+ message: `Extends "${layer.preset.specifier}", which weakens "${rule}" for ${scopeText(includes)} without a declaration. Repair the code and restore the check in the preset.`,
29
56
  subject: rule,
30
57
  };
31
58
 
@@ -63,7 +90,7 @@ export const biomeOverrides = defineRule({
63
90
  ];
64
91
  },
65
92
  description:
66
- "A Biome setting that turns a check off or down, or keeps files out of it, is an exception for every file it covers. Fix the code and remove the setting, or declare its rule, includes and reason under this rule's declared option.",
93
+ "A Biome setting that turns a check off or down, or keeps files out of it, hides findings for every file it covers. Repair the code and remove the weakening setting.",
67
94
  id: "suppressions/biome-overrides",
68
95
  options: Schema.toStandardSchemaV1(BiomeOverridesOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
69
96
  registrable: false,
@@ -22,7 +22,7 @@ function settingOf(rules: Json | undefined, rule: string): Setting {
22
22
  return { level: textOf(node) ?? textOf(member(node, "level")), line: node?.line };
23
23
  }
24
24
 
25
- const FIX = `Set it to "error" in the preset, or declare why not in its ${PRESET_DECLARATIONS}.`;
25
+ const FIX = 'Set it to "error" in the preset and repair the code it reports.';
26
26
 
27
27
  function looseRule(file: string, rule: string, { level, line }: Setting): Finding {
28
28
  const state = level === undefined ? "leaves at Biome's default level" : `sets to "${level}"`;
@@ -65,7 +65,7 @@ export function biomeRecommendedFrom(recommended: () => Promise<readonly string[
65
65
  return defineRule({
66
66
  check: ({ readText }) => check(readText, recommended),
67
67
  description:
68
- "Every rule the installed Biome recommends is at error in the shared preset, or the preset declares why not, so a Biome upgrade that adds or changes a recommended rule cannot slip in at Biome's default level.",
68
+ "Every recommended Biome rule without a shipped internal exception runs at error in the shared preset. Set missing rules to error in the preset and repair the code they report.",
69
69
  id: "suppressions/biome-recommended",
70
70
  registrable: false,
71
71
  });
@@ -33,7 +33,7 @@ export const noInline = defineRule({
33
33
  (site) => !allowed(site),
34
34
  ),
35
35
  description:
36
- "A suppression silences a check at one site instead of fixing the cause. Fix the code; where a lint rule truly cannot apply, turn it off for that scope in the Biome config and declare it under suppressions/biome-overrides. A type test asserts a compile error with @ts-expect-error in a *.typecheck.test.ts or *.typecheck.spec.ts file.",
36
+ "A suppression silences a check at one site instead of fixing the cause. Repair the code that fails the check and remove the suppression. A type test asserts a compile error with @ts-expect-error in a *.typecheck.test.ts or *.typecheck.spec.ts file.",
37
37
  id: "suppressions/no-inline",
38
38
  registrable: false,
39
39
  });
@@ -16,7 +16,15 @@ export interface StandardIssue {
16
16
  readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }> | undefined;
17
17
  }
18
18
 
19
- const segment = (part: PropertyKey | { readonly key: PropertyKey }): string => String(typeof part === "object" ? part.key : part);
19
+ // Biome's noBaseToString does not narrow the ternary in segment, so the key is stringified behind a PropertyKey parameter.
20
+ function keyText(key: PropertyKey): string {
21
+ return key.toString();
22
+ }
23
+
24
+ function segment(part: PropertyKey | { readonly key: PropertyKey }): string {
25
+ return keyText(typeof part === "object" ? part.key : part);
26
+ }
20
27
 
21
- export const describeIssue = (issue: StandardIssue): string =>
22
- issue.path === undefined || issue.path.length === 0 ? issue.message : `${issue.path.map(segment).join(".")}: ${issue.message}`;
28
+ export function describeIssue(issue: StandardIssue): string {
29
+ return issue.path === undefined || issue.path.length === 0 ? issue.message : `${issue.path.map(segment).join(".")}: ${issue.message}`;
30
+ }
package/src/vitest.ts CHANGED
@@ -11,18 +11,23 @@ function named(environment?: Environment): string {
11
11
  return environment === undefined ? `**/*.@(test|spec).${EXTENSION}` : `**/*.${environment}.@(test|spec).${EXTENSION}`;
12
12
  }
13
13
 
14
- function excluding(exclude: readonly string[], ...patterns: readonly string[]): string[] {
15
- return [...defaultExclude, named("typecheck"), TYPECHECK_ALONE, ...exclude, ...patterns];
14
+ function excluding(...patterns: readonly string[]): string[] {
15
+ return [...defaultExclude, named("typecheck"), TYPECHECK_ALONE, ...patterns];
16
16
  }
17
17
 
18
- export function testProjects({ exclude = [] }: { readonly exclude?: readonly string[] } = {}): TestUserConfig {
18
+ export function testProjects(...options: readonly never[]): TestUserConfig {
19
+ if (options.length > 0) {
20
+ throw new Error("testProjects takes no options. Remove suite exclusions; every test belongs in its named project.");
21
+ }
19
22
  return {
20
23
  project: ["unit", "dom"],
21
24
  projects: [
22
- { extends: true, test: { environment: "node", exclude: excluding(exclude, named("dom"), named("slow")), include: [named()], name: "unit" } },
23
- { extends: true, test: { environment: "happy-dom", exclude: excluding(exclude, named("slow")), include: [named("dom")], name: "dom" } },
24
- { extends: true, test: { environment: "node", exclude: excluding(exclude), include: [named("slow")], name: "slow" } },
25
+ { extends: true, test: { environment: "node", exclude: excluding(named("dom"), named("slow")), include: [named()], name: "unit" } },
26
+ { extends: true, test: { environment: "happy-dom", exclude: excluding(named("slow")), include: [named("dom")], name: "dom" } },
27
+ { extends: true, test: { environment: "node", exclude: excluding(), include: [named("slow")], name: "slow" } },
25
28
  ],
29
+ // Tests that start real servers, browsers or CLIs need seconds on a busy CI runner, and Vitest's 5s default fails them for load, not behavior.
30
+ testTimeout: 30_000,
26
31
  };
27
32
  }
28
33