@shivaedev/quality 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +103 -9
- package/biome/plugins/constant-names.grit +17 -0
- package/biome/plugins/effect-fn-spans.grit +13 -3
- package/biome/plugins/handler-names.grit +10 -0
- package/biome/plugins/schema-names.grit +13 -0
- package/biome/plugins/schema-struct-keys.grit +7 -0
- package/biome/plugins/service-layers.grit +13 -0
- package/biome/plugins/type-re-exports.grit +8 -0
- package/biome/preset.json +48 -1
- package/dist/imports/graph.d.ts.map +1 -1
- package/dist/imports/graph.js +9 -6
- package/dist/imports/graph.js.map +1 -1
- package/dist/imports/resolve.d.ts +1 -0
- package/dist/imports/resolve.d.ts.map +1 -1
- package/dist/imports/resolve.js +14 -0
- package/dist/imports/resolve.js.map +1 -1
- package/dist/naming/exportFits.d.ts +2 -0
- package/dist/naming/exportFits.d.ts.map +1 -0
- package/dist/naming/exportFits.js +20 -0
- package/dist/naming/exportFits.js.map +1 -0
- package/dist/naming/mainExport.d.ts +3 -0
- package/dist/naming/mainExport.d.ts.map +1 -0
- package/dist/naming/mainExport.js +10 -0
- package/dist/naming/mainExport.js.map +1 -0
- package/dist/naming/ownExports.d.ts +8 -0
- package/dist/naming/ownExports.d.ts.map +1 -0
- package/dist/naming/ownExports.js +78 -0
- package/dist/naming/ownExports.js.map +1 -0
- package/dist/naming/packageLayout.d.ts +8 -0
- package/dist/naming/packageLayout.d.ts.map +1 -0
- package/dist/naming/packageLayout.js +57 -0
- package/dist/naming/packageLayout.js.map +1 -0
- package/dist/naming/testName.d.ts +11 -0
- package/dist/naming/testName.d.ts.map +1 -0
- package/dist/naming/testName.js +13 -0
- package/dist/naming/testName.js.map +1 -0
- package/dist/naming/words.d.ts +8 -0
- package/dist/naming/words.d.ts.map +1 -0
- package/dist/naming/words.js +21 -0
- package/dist/naming/words.js.map +1 -0
- package/dist/rules/built-in.d.ts +13 -1
- package/dist/rules/built-in.d.ts.map +1 -1
- package/dist/rules/built-in.js +10 -0
- package/dist/rules/built-in.js.map +1 -1
- package/dist/rules/files/folderNames.d.ts +5 -0
- package/dist/rules/files/folderNames.d.ts.map +1 -0
- package/dist/rules/files/folderNames.js +74 -0
- package/dist/rules/files/folderNames.js.map +1 -0
- package/dist/rules/files/namedAfterExport.d.ts +4 -0
- package/dist/rules/files/namedAfterExport.d.ts.map +1 -0
- package/dist/rules/files/namedAfterExport.js +76 -0
- package/dist/rules/files/namedAfterExport.js.map +1 -0
- package/dist/rules/files/otherNames.d.ts +5 -0
- package/dist/rules/files/otherNames.d.ts.map +1 -0
- package/dist/rules/files/otherNames.js +99 -0
- package/dist/rules/files/otherNames.js.map +1 -0
- package/dist/rules/test-names/colocated.d.ts +4 -0
- package/dist/rules/test-names/colocated.d.ts.map +1 -0
- package/dist/rules/test-names/colocated.js +31 -0
- package/dist/rules/test-names/colocated.js.map +1 -0
- package/dist/rules/test-names/follow.d.ts +4 -0
- package/dist/rules/test-names/follow.d.ts.map +1 -0
- package/dist/rules/test-names/follow.js +66 -0
- package/dist/rules/test-names/follow.js.map +1 -0
- package/dist/rules/test-names/usesDom.d.ts +3 -0
- package/dist/rules/test-names/usesDom.d.ts.map +1 -0
- package/dist/rules/test-names/usesDom.js +18 -0
- package/dist/rules/test-names/usesDom.js.map +1 -0
- package/dist/vitest.d.ts +3 -1
- package/dist/vitest.d.ts.map +1 -1
- package/dist/vitest.js +11 -11
- package/dist/vitest.js.map +1 -1
- package/package.json +1 -1
- package/src/imports/graph.ts +11 -7
- package/src/imports/resolve.ts +18 -0
- package/src/naming/exportFits.ts +21 -0
- package/src/naming/mainExport.ts +11 -0
- package/src/naming/ownExports.ts +95 -0
- package/src/naming/packageLayout.ts +75 -0
- package/src/naming/testName.ts +25 -0
- package/src/naming/words.ts +28 -0
- package/src/rules/built-in.ts +10 -0
- package/src/rules/files/folderNames.ts +92 -0
- package/src/rules/files/namedAfterExport.ts +87 -0
- package/src/rules/files/otherNames.ts +118 -0
- package/src/rules/test-names/colocated.ts +34 -0
- package/src/rules/test-names/follow.ts +78 -0
- package/src/rules/test-names/usesDom.ts +22 -0
- package/src/vitest.ts +14 -11
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
import ignore, { type Ignore } from "ignore";
|
|
4
|
+
import { packageLayout } from "#naming/packageLayout.ts";
|
|
5
|
+
import { KEBAB, PASCAL } from "#naming/words.ts";
|
|
6
|
+
import { defineRule, type Finding } from "#rule.ts";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TOOL_OWNED: readonly string[] = ["generated/", "migrations/"];
|
|
9
|
+
|
|
10
|
+
const SNAKE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/u;
|
|
11
|
+
|
|
12
|
+
const CODE = /\.[cm]?[jt]sx?$/u;
|
|
13
|
+
|
|
14
|
+
const FolderNamesOptions = Schema.Struct({
|
|
15
|
+
content: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
|
|
16
|
+
toolOwned: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(DEFAULT_TOOL_OWNED))),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function foldersIn(files: readonly string[]): readonly string[] {
|
|
20
|
+
const folders = new Set<string>();
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
for (let folder = posix.dirname(file); folder !== "."; folder = posix.dirname(folder)) {
|
|
23
|
+
folders.add(folder);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [...folders].sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stemsIn(files: readonly string[]): ReadonlySet<string> {
|
|
30
|
+
return new Set(files.filter((file) => CODE.test(file)).map((file) => `${posix.dirname(file)}/${posix.basename(file).replace(CODE, "")}`));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isModule(folder: string, stems: ReadonlySet<string>): boolean {
|
|
34
|
+
const name = posix.basename(folder);
|
|
35
|
+
return stems.has(`${folder}/${name}`) || stems.has(`${posix.dirname(folder)}/${name}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function packageMessage(name: string, packageName: string): string | undefined {
|
|
39
|
+
const expected = packageName.replace(/^@[^/]+\//u, "");
|
|
40
|
+
return name === expected ? undefined : `The folder of the package ${packageName} is named "${expected}", in kebab-case after the package name.`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function groupMessage(name: string): string | undefined {
|
|
44
|
+
if (PASCAL.test(name)) {
|
|
45
|
+
return `"${name}" is PascalCase but holds no ${name}.ts or ${name}.tsx. A PascalCase folder is a module named after its main file: add that file, or name the folder in kebab-case.`;
|
|
46
|
+
}
|
|
47
|
+
return KEBAB.test(name.replace(/^\./u, ""))
|
|
48
|
+
? undefined
|
|
49
|
+
: `"${name}" groups files, so it is named in kebab-case, such as test-support/. A folder named after a file beside or inside it is a module and takes that file's name.`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Context {
|
|
53
|
+
readonly content: Ignore;
|
|
54
|
+
readonly names: ReadonlyMap<string, string>;
|
|
55
|
+
readonly stems: ReadonlySet<string>;
|
|
56
|
+
readonly toolOwned: Ignore;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function messageFor(folder: string, context: Context): string | undefined {
|
|
60
|
+
const name = posix.basename(folder);
|
|
61
|
+
if (context.toolOwned.ignores(`${folder}/`) || isModule(folder, context.stems)) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const packageName = context.names.get(folder);
|
|
65
|
+
if (packageName !== undefined) {
|
|
66
|
+
return packageMessage(name, packageName);
|
|
67
|
+
}
|
|
68
|
+
if (context.content.ignores(`${folder}/`)) {
|
|
69
|
+
return SNAKE.test(name) ? undefined : `"${name}" holds content, so it is named in snake_case, such as forest_path/.`;
|
|
70
|
+
}
|
|
71
|
+
return groupMessage(name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const folderNames = defineRule({
|
|
75
|
+
check: async ({ files, options, readText }) => {
|
|
76
|
+
const layout = await packageLayout({ files, readText });
|
|
77
|
+
const context: Context = {
|
|
78
|
+
content: ignore().add([...options.content]),
|
|
79
|
+
names: layout.names,
|
|
80
|
+
stems: stemsIn(files),
|
|
81
|
+
toolOwned: ignore().add([...options.toolOwned]),
|
|
82
|
+
};
|
|
83
|
+
return foldersIn(files).flatMap((folder): readonly Finding[] => {
|
|
84
|
+
const message = messageFor(folder, context);
|
|
85
|
+
return message === undefined ? [] : [{ file: folder, message }];
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
description:
|
|
89
|
+
"A package folder is named after its package, a module folder after its main file, a content folder in snake_case and every other folder in kebab-case.",
|
|
90
|
+
id: "files/folder-names",
|
|
91
|
+
options: Schema.toStandardSchemaV1(FolderNamesOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
|
|
92
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
import ignore from "ignore";
|
|
4
|
+
import { exportFits } from "#naming/exportFits.ts";
|
|
5
|
+
import { mainExport } from "#naming/mainExport.ts";
|
|
6
|
+
import { ownExports } from "#naming/ownExports.ts";
|
|
7
|
+
import { type PackageLayout, packageLayout } from "#naming/packageLayout.ts";
|
|
8
|
+
import { CAMEL, KEBAB, sameWord, wordsOf } from "#naming/words.ts";
|
|
9
|
+
import { defineRule, type Finding, type SourceFile } from "#rule.ts";
|
|
10
|
+
import { parse } from "#rules/syntax.ts";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TOOL_OWNED: readonly string[] = ["*.config.*", "*.d.ts", "*.d.mts", "*.d.cts"];
|
|
13
|
+
|
|
14
|
+
const TEST = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
|
|
15
|
+
|
|
16
|
+
const SHEBANG = "#!";
|
|
17
|
+
|
|
18
|
+
const SHAPES =
|
|
19
|
+
"The file name and the names of its folders spell the export, in any order, and the file's first letter follows the export's case: listItems in items/ is items/list.ts, ItemPanel is ItemPanel.tsx or item/Panel.tsx.";
|
|
20
|
+
|
|
21
|
+
const NamedAfterExportOptions = Schema.Struct({
|
|
22
|
+
toolOwned: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(DEFAULT_TOOL_OWNED))),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function stemOf(path: string): string {
|
|
26
|
+
return posix.basename(path).replace(/\.[^.]+$/u, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function repeated(stem: string, folders: readonly string[]): string | undefined {
|
|
30
|
+
if (folders.at(-1) === stem) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const words = wordsOf(stem);
|
|
34
|
+
return folders.find((folder) => wordsOf(folder).some((word) => words.some((own) => sameWord(own, word))));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nameFinding(file: SourceFile, stem: string, folders: readonly string[]): string | undefined {
|
|
38
|
+
const source = parse(file);
|
|
39
|
+
const exports = source === undefined ? [] : ownExports(source);
|
|
40
|
+
const [only] = exports;
|
|
41
|
+
if (only === undefined) {
|
|
42
|
+
return CAMEL.test(stem)
|
|
43
|
+
? undefined
|
|
44
|
+
: `"${stem}" exports nothing, so it is named in camelCase, such as setupTests.ts. A file that a package.json script or bin runs is kebab-case.`;
|
|
45
|
+
}
|
|
46
|
+
if (exports.length === 1 && only.constant) {
|
|
47
|
+
return `"${stem}" exports only the constant ${only.name}. Constants live in a topic file with related constants, such as limits.ts holding MAX_ITEMS and MAX_DEPTH.`;
|
|
48
|
+
}
|
|
49
|
+
if (exports.some((entry) => exportFits(entry.name, stem, folders))) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const main = mainExport(exports);
|
|
53
|
+
if (main !== undefined) {
|
|
54
|
+
return `"${stem}" does not name its export ${main.name}. ${SHAPES} Rename the file or the export.`;
|
|
55
|
+
}
|
|
56
|
+
return CAMEL.test(stem)
|
|
57
|
+
? undefined
|
|
58
|
+
: `"${stem}" exports several things, so it is a topic file named in camelCase, such as limits.ts. Or split it so each file has one main export. ${SHAPES}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function findingsOf(file: SourceFile, layout: PackageLayout): readonly Finding[] {
|
|
62
|
+
const stem = stemOf(file.path);
|
|
63
|
+
const folders = layout.foldersOf(file.path);
|
|
64
|
+
if (layout.scripts.has(file.path) || file.text.startsWith(SHEBANG)) {
|
|
65
|
+
return KEBAB.test(stem)
|
|
66
|
+
? []
|
|
67
|
+
: [{ file: file.path, message: `"${stem}" is run as a script, so it is named in kebab-case, such as build-docs.ts.` }];
|
|
68
|
+
}
|
|
69
|
+
const folder = repeated(stem, folders);
|
|
70
|
+
const messages = [
|
|
71
|
+
nameFinding(file, stem, folders),
|
|
72
|
+
folder === undefined ? undefined : `"${stem}" repeats its folder "${folder}". The folder is the prefix: items/listItems.ts is items/list.ts.`,
|
|
73
|
+
];
|
|
74
|
+
return messages.flatMap((message) => (message === undefined ? [] : [{ file: file.path, message }]));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const namedAfterExport = defineRule({
|
|
78
|
+
check: async ({ files, options, readText, sources }) => {
|
|
79
|
+
const layout = await packageLayout({ files, readText });
|
|
80
|
+
const toolOwned = ignore().add([...options.toolOwned]);
|
|
81
|
+
return sources.filter((file) => !(TEST.test(file.path) || toolOwned.ignores(file.path))).flatMap((file) => findingsOf(file, layout));
|
|
82
|
+
},
|
|
83
|
+
description:
|
|
84
|
+
"A file is named after its main export, with its folders as the prefix; a file of several exports is a camelCase topic file, and a script is kebab-case. Rename the file or the export; nothing renames them for you.",
|
|
85
|
+
id: "files/named-after-export",
|
|
86
|
+
options: Schema.toStandardSchemaV1(NamedAfterExportOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
|
|
87
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
import ignore, { type Ignore } from "ignore";
|
|
4
|
+
import { CAMEL, KEBAB } from "#naming/words.ts";
|
|
5
|
+
import { defineRule, type Finding, type SourceFile } from "#rule.ts";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TOOL_OWNED: readonly string[] = [
|
|
8
|
+
".*",
|
|
9
|
+
"package.json",
|
|
10
|
+
"tsconfig*.json",
|
|
11
|
+
"biome.json",
|
|
12
|
+
"biome.jsonc",
|
|
13
|
+
"*.config.*",
|
|
14
|
+
"migrations/",
|
|
15
|
+
"generated/",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const CONVENTIONAL: ReadonlySet<string> = new Set(["AGENTS", "CHANGELOG", "CLAUDE", "CONTRIBUTING", "LICENSE", "README", "SECURITY", "SKILL"]);
|
|
19
|
+
|
|
20
|
+
const KEBAB_KINDS: ReadonlySet<string> = new Set([
|
|
21
|
+
"avif",
|
|
22
|
+
"gif",
|
|
23
|
+
"grit",
|
|
24
|
+
"ico",
|
|
25
|
+
"jpeg",
|
|
26
|
+
"jpg",
|
|
27
|
+
"json",
|
|
28
|
+
"jsonc",
|
|
29
|
+
"md",
|
|
30
|
+
"mdx",
|
|
31
|
+
"otf",
|
|
32
|
+
"png",
|
|
33
|
+
"svg",
|
|
34
|
+
"ttf",
|
|
35
|
+
"webp",
|
|
36
|
+
"woff",
|
|
37
|
+
"woff2",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const STYLESHEETS: ReadonlySet<string> = new Set(["css", "scss"]);
|
|
41
|
+
|
|
42
|
+
const DOTTED_KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/u;
|
|
43
|
+
|
|
44
|
+
const SNAKE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/u;
|
|
45
|
+
|
|
46
|
+
const STYLESHEET_IMPORT = /["'](?<stylesheet>\.{1,2}\/[^"'\n]+\.s?css)["']/gu;
|
|
47
|
+
|
|
48
|
+
const CODE = /\.[cm]?[jt]sx?$/u;
|
|
49
|
+
|
|
50
|
+
const OtherNamesOptions = Schema.Struct({
|
|
51
|
+
content: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
|
|
52
|
+
toolOwned: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(DEFAULT_TOOL_OWNED))),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function importers(sources: readonly SourceFile[]): ReadonlyMap<string, readonly string[]> {
|
|
56
|
+
const found = new Map<string, string[]>();
|
|
57
|
+
for (const source of sources) {
|
|
58
|
+
for (const match of source.text.matchAll(STYLESHEET_IMPORT)) {
|
|
59
|
+
const target = posix.normalize(posix.join(posix.dirname(source.path), match.groups?.stylesheet ?? ""));
|
|
60
|
+
found.set(target, [...new Set([...(found.get(target) ?? []), source.path])]);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stylesheetMessage(path: string, stem: string, importedBy: readonly string[]): string | undefined {
|
|
67
|
+
const [only] = importedBy;
|
|
68
|
+
if (importedBy.length === 1 && only !== undefined) {
|
|
69
|
+
const owner = posix.basename(only).replace(CODE, "");
|
|
70
|
+
return stem === owner ? undefined : `"${stem}" is styling for ${posix.basename(only)} alone, so it is named ${owner}${posix.extname(path)}.`;
|
|
71
|
+
}
|
|
72
|
+
return KEBAB.test(stem) ? undefined : `"${stem}" is a shared stylesheet, so it is named in kebab-case, such as form-controls.css.`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Context {
|
|
76
|
+
readonly content: Ignore;
|
|
77
|
+
readonly stylesheets: ReadonlyMap<string, readonly string[]>;
|
|
78
|
+
readonly toolOwned: Ignore;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function messageFor(path: string, context: Context): string | undefined {
|
|
82
|
+
const name = posix.basename(path);
|
|
83
|
+
const kind = posix.extname(name).slice(1);
|
|
84
|
+
const stem = name.slice(0, name.length - kind.length - 1);
|
|
85
|
+
if (kind === "" || context.toolOwned.ignores(path) || CONVENTIONAL.has(stem)) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
if (context.content.ignores(path)) {
|
|
89
|
+
return SNAKE.test(stem) ? undefined : `"${name}" is content, so it is named in snake_case, such as forest_path.json.`;
|
|
90
|
+
}
|
|
91
|
+
if (STYLESHEETS.has(kind)) {
|
|
92
|
+
return stylesheetMessage(path, stem, context.stylesheets.get(path) ?? []);
|
|
93
|
+
}
|
|
94
|
+
if (kind === "prisma") {
|
|
95
|
+
return CAMEL.test(stem) ? undefined : `"${name}" is a Prisma schema, so it is named in camelCase, such as schema.prisma.`;
|
|
96
|
+
}
|
|
97
|
+
return KEBAB_KINDS.has(kind) && !DOTTED_KEBAB.test(stem)
|
|
98
|
+
? `"${name}" is named in kebab-case, such as release-notes.md. Only conventional names such as README.md and CHANGELOG.md are upper case.`
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const otherNames = defineRule({
|
|
103
|
+
check: ({ files, options, sources }) => {
|
|
104
|
+
const context: Context = {
|
|
105
|
+
content: ignore().add([...options.content]),
|
|
106
|
+
stylesheets: importers(sources),
|
|
107
|
+
toolOwned: ignore().add([...options.toolOwned]),
|
|
108
|
+
};
|
|
109
|
+
return files.flatMap((path): readonly Finding[] => {
|
|
110
|
+
const message = messageFor(path, context);
|
|
111
|
+
return message === undefined ? [] : [{ file: path, message }];
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
description:
|
|
115
|
+
"Documents, data, assets and GritQL files are kebab-case, a stylesheet that one component imports is named after it, a Prisma schema is camelCase and content is snake_case.",
|
|
116
|
+
id: "files/other-names",
|
|
117
|
+
options: Schema.toStandardSchemaV1(OtherNamesOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
|
|
118
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import ignore from "ignore";
|
|
3
|
+
import { testName } from "#naming/testName.ts";
|
|
4
|
+
import { defineRule, type Finding } from "#rule.ts";
|
|
5
|
+
|
|
6
|
+
const MIRROR_FOLDERS: ReadonlySet<string> = new Set(["__tests__", "spec", "test", "tests"]);
|
|
7
|
+
|
|
8
|
+
const TestsColocatedOptions = Schema.Struct({
|
|
9
|
+
suites: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function findingOf(path: string): readonly Finding[] {
|
|
13
|
+
const name = testName(path);
|
|
14
|
+
const mirror = name?.folder.split("/").find((folder) => MIRROR_FOLDERS.has(folder));
|
|
15
|
+
if (name === undefined || mirror === undefined) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
file: path,
|
|
21
|
+
message: `Sits in the test folder "${mirror}/". A test sits beside the file it covers, as ${name.stem}.test${name.extension} beside ${name.stem}.ts, or as a <behaviour>.spec${name.extension} in the folder it covers. A folder of tests that cover several packages is a suite in the "suites" option.`,
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const testsColocated = defineRule({
|
|
27
|
+
check: ({ files, options }) => {
|
|
28
|
+
const suites = ignore().add([...options.suites]);
|
|
29
|
+
return files.filter((path) => !suites.ignores(path)).flatMap(findingOf);
|
|
30
|
+
},
|
|
31
|
+
description: "A test sits beside the code it covers, not in a test, tests, __tests__ or spec folder, unless that folder is a declared suite.",
|
|
32
|
+
id: "tests/colocated",
|
|
33
|
+
options: Schema.toStandardSchemaV1(TestsColocatedOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
|
|
34
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
import ignore from "ignore";
|
|
4
|
+
import { ENVIRONMENTS, type TestName, testName } from "#naming/testName.ts";
|
|
5
|
+
import { CAMEL } from "#naming/words.ts";
|
|
6
|
+
import { defineRule, type Finding, type SourceFile } from "#rule.ts";
|
|
7
|
+
import { usesDom } from "#rules/test-names/usesDom.ts";
|
|
8
|
+
|
|
9
|
+
const ENVIRONMENT_NAMES: ReadonlySet<string> = new Set(ENVIRONMENTS);
|
|
10
|
+
|
|
11
|
+
const FIXTURE_SUFFIXES: ReadonlySet<string> = new Set(["integration", "postgres"]);
|
|
12
|
+
|
|
13
|
+
const CODE = /\.[cm]?[jt]sx?$/u;
|
|
14
|
+
|
|
15
|
+
const GRAMMAR =
|
|
16
|
+
"A test file is <file>[.<environment>].test.ts beside its file or <behaviour>[.<environment>].spec.ts in the folder it covers, with at most one environment of dom, slow and typecheck.";
|
|
17
|
+
|
|
18
|
+
const TestsFollowOptions = Schema.Struct({
|
|
19
|
+
suites: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function siblingsIn(files: readonly string[]): ReadonlySet<string> {
|
|
23
|
+
return new Set(files.filter((file) => CODE.test(file) && testName(file) === undefined).map((file) => file.replace(CODE, "")));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function suffixMessage(file: string, name: TestName): string | undefined {
|
|
27
|
+
const [first, ...rest] = name.modifiers;
|
|
28
|
+
if (first === undefined || (rest.length === 0 && ENVIRONMENT_NAMES.has(first))) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const fixture = name.modifiers.find((modifier) => FIXTURE_SUFFIXES.has(modifier));
|
|
32
|
+
if (fixture !== undefined) {
|
|
33
|
+
return `".${fixture}" is not a test environment. A test that needs a database or a running app takes it from the app's test fixture and is named like any other test. ${GRAMMAR}`;
|
|
34
|
+
}
|
|
35
|
+
return `"${file}" has the suffixes ".${name.modifiers.join(".")}". ${GRAMMAR} An aspect of a module gets its own file in the module's folder, or a .spec.`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function placeMessage(file: string, name: TestName, siblings: ReadonlySet<string>): string | undefined {
|
|
39
|
+
const covered = siblings.has(`${name.folder}/${name.stem}`);
|
|
40
|
+
if (name.kind === "test") {
|
|
41
|
+
return covered
|
|
42
|
+
? undefined
|
|
43
|
+
: `"${file}" follows no file: ${name.stem}.test${name.extension} sits beside ${name.stem}.ts in the same folder. A test that covers the folder as a whole is a <behaviour>.spec${name.extension}, such as checkoutFlow.spec${name.extension}.`;
|
|
44
|
+
}
|
|
45
|
+
if (covered) {
|
|
46
|
+
return `"${file}" shares its name with ${name.stem}.ts. A .spec names a behaviour of its folder, such as checkoutFlow.spec${name.extension}; a test of ${name.stem}.ts is ${name.stem}.test${name.extension}.`;
|
|
47
|
+
}
|
|
48
|
+
return CAMEL.test(name.stem) ? undefined : `"${file}" names its behaviour in camelCase, such as checkoutFlow.spec${name.extension}.`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function environmentMessage(file: string, name: TestName, source: SourceFile): string | undefined {
|
|
52
|
+
if (name.modifiers.includes("dom") || name.modifiers.includes("typecheck") || !usesDom(source)) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return `"${file}" uses the DOM, so it is ${name.stem}.dom.${name.kind}${name.extension} and runs in the dom project.`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findingsOf(source: SourceFile, siblings: ReadonlySet<string>): readonly Finding[] {
|
|
59
|
+
const name = testName(source.path);
|
|
60
|
+
if (name === undefined) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const file = posix.basename(source.path);
|
|
64
|
+
const messages = [suffixMessage(file, name), placeMessage(file, name, siblings), environmentMessage(file, name, source)];
|
|
65
|
+
return messages.flatMap((message) => (message === undefined ? [] : [{ file: source.path, message }]));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const testsFollow = defineRule({
|
|
69
|
+
check: ({ files, options, sources }) => {
|
|
70
|
+
const suites = ignore().add([...options.suites]);
|
|
71
|
+
const siblings = siblingsIn(files);
|
|
72
|
+
return sources.filter((source) => !suites.ignores(source.path)).flatMap((source) => findingsOf(source, siblings));
|
|
73
|
+
},
|
|
74
|
+
description:
|
|
75
|
+
"A .test file follows the file beside it, a .spec names a behaviour of its folder, the only suffix is one environment of dom, slow and typecheck, and a test that uses the DOM is a .dom test.",
|
|
76
|
+
id: "tests/follow",
|
|
77
|
+
options: Schema.toStandardSchemaV1(TestsFollowOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
|
|
78
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { SourceFile } from "#rule.ts";
|
|
3
|
+
import { parse } from "#rules/syntax.ts";
|
|
4
|
+
|
|
5
|
+
const DOM_GLOBALS: ReadonlySet<string> = new Set(["document", "window"]);
|
|
6
|
+
|
|
7
|
+
const TESTING_LIBRARY = "@testing-library/";
|
|
8
|
+
|
|
9
|
+
function touchesDom(node: ts.Node): boolean {
|
|
10
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
11
|
+
return node.moduleSpecifier.text.startsWith(TESTING_LIBRARY);
|
|
12
|
+
}
|
|
13
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && DOM_GLOBALS.has(node.expression.text)) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
return ts.forEachChild(node, touchesDom) ?? false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function usesDom(file: SourceFile): boolean {
|
|
20
|
+
const source = parse(file);
|
|
21
|
+
return source === undefined ? false : touchesDom(source);
|
|
22
|
+
}
|
package/src/vitest.ts
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
import { defaultExclude, type TestUserConfig } from "vitest/config";
|
|
2
|
+
import type { Environment } from "#naming/testName.ts";
|
|
2
3
|
|
|
3
|
-
const
|
|
4
|
-
const DOM = "**/*.dom.test.?(c|m)[jt]s?(x)";
|
|
5
|
-
const SLOW = "**/*.slow.test.?(c|m)[jt]s?(x)";
|
|
6
|
-
const TYPECHECK = "**/*.typecheck.test.?(c|m)[jt]s?(x)";
|
|
7
|
-
const TYPECHECK_ALONE = "**/typecheck.test.?(c|m)[jt]s?(x)";
|
|
4
|
+
const EXTENSION = "?(c|m)[jt]s?(x)";
|
|
8
5
|
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
const TYPECHECK_ALONE = `**/typecheck.test.${EXTENSION}`;
|
|
7
|
+
|
|
8
|
+
function named(environment?: Environment): string {
|
|
9
|
+
return environment === undefined ? `**/*.@(test|spec).${EXTENSION}` : `**/*.${environment}.@(test|spec).${EXTENSION}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function excluding(exclude: readonly string[], ...patterns: readonly string[]): string[] {
|
|
13
|
+
return [...defaultExclude, named("typecheck"), TYPECHECK_ALONE, ...exclude, ...patterns];
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
export function testProjects(): TestUserConfig {
|
|
16
|
+
export function testProjects({ exclude = [] }: { readonly exclude?: readonly string[] } = {}): TestUserConfig {
|
|
14
17
|
return {
|
|
15
18
|
project: ["unit", "dom"],
|
|
16
19
|
projects: [
|
|
17
|
-
{ extends: true, test: { environment: "node", exclude: excluding(
|
|
18
|
-
{ extends: true, test: { environment: "happy-dom", exclude: excluding(
|
|
19
|
-
{ extends: true, test: { environment: "node", exclude: excluding(), include: [
|
|
20
|
+
{ extends: true, test: { environment: "node", exclude: excluding(exclude, named("dom"), named("slow")), include: [named()], name: "unit" } },
|
|
21
|
+
{ extends: true, test: { environment: "happy-dom", exclude: excluding(exclude, named("slow")), include: [named("dom")], name: "dom" } },
|
|
22
|
+
{ extends: true, test: { environment: "node", exclude: excluding(exclude), include: [named("slow")], name: "slow" } },
|
|
20
23
|
],
|
|
21
24
|
};
|
|
22
25
|
}
|