@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.
Files changed (90) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +103 -9
  3. package/biome/plugins/constant-names.grit +17 -0
  4. package/biome/plugins/effect-fn-spans.grit +13 -3
  5. package/biome/plugins/handler-names.grit +10 -0
  6. package/biome/plugins/schema-names.grit +13 -0
  7. package/biome/plugins/schema-struct-keys.grit +7 -0
  8. package/biome/plugins/service-layers.grit +13 -0
  9. package/biome/plugins/type-re-exports.grit +8 -0
  10. package/biome/preset.json +48 -1
  11. package/dist/imports/graph.d.ts.map +1 -1
  12. package/dist/imports/graph.js +9 -6
  13. package/dist/imports/graph.js.map +1 -1
  14. package/dist/imports/resolve.d.ts +1 -0
  15. package/dist/imports/resolve.d.ts.map +1 -1
  16. package/dist/imports/resolve.js +14 -0
  17. package/dist/imports/resolve.js.map +1 -1
  18. package/dist/naming/exportFits.d.ts +2 -0
  19. package/dist/naming/exportFits.d.ts.map +1 -0
  20. package/dist/naming/exportFits.js +20 -0
  21. package/dist/naming/exportFits.js.map +1 -0
  22. package/dist/naming/mainExport.d.ts +3 -0
  23. package/dist/naming/mainExport.d.ts.map +1 -0
  24. package/dist/naming/mainExport.js +10 -0
  25. package/dist/naming/mainExport.js.map +1 -0
  26. package/dist/naming/ownExports.d.ts +8 -0
  27. package/dist/naming/ownExports.d.ts.map +1 -0
  28. package/dist/naming/ownExports.js +78 -0
  29. package/dist/naming/ownExports.js.map +1 -0
  30. package/dist/naming/packageLayout.d.ts +8 -0
  31. package/dist/naming/packageLayout.d.ts.map +1 -0
  32. package/dist/naming/packageLayout.js +57 -0
  33. package/dist/naming/packageLayout.js.map +1 -0
  34. package/dist/naming/testName.d.ts +11 -0
  35. package/dist/naming/testName.d.ts.map +1 -0
  36. package/dist/naming/testName.js +13 -0
  37. package/dist/naming/testName.js.map +1 -0
  38. package/dist/naming/words.d.ts +8 -0
  39. package/dist/naming/words.d.ts.map +1 -0
  40. package/dist/naming/words.js +21 -0
  41. package/dist/naming/words.js.map +1 -0
  42. package/dist/rules/built-in.d.ts +13 -1
  43. package/dist/rules/built-in.d.ts.map +1 -1
  44. package/dist/rules/built-in.js +10 -0
  45. package/dist/rules/built-in.js.map +1 -1
  46. package/dist/rules/files/folderNames.d.ts +5 -0
  47. package/dist/rules/files/folderNames.d.ts.map +1 -0
  48. package/dist/rules/files/folderNames.js +74 -0
  49. package/dist/rules/files/folderNames.js.map +1 -0
  50. package/dist/rules/files/namedAfterExport.d.ts +4 -0
  51. package/dist/rules/files/namedAfterExport.d.ts.map +1 -0
  52. package/dist/rules/files/namedAfterExport.js +76 -0
  53. package/dist/rules/files/namedAfterExport.js.map +1 -0
  54. package/dist/rules/files/otherNames.d.ts +5 -0
  55. package/dist/rules/files/otherNames.d.ts.map +1 -0
  56. package/dist/rules/files/otherNames.js +99 -0
  57. package/dist/rules/files/otherNames.js.map +1 -0
  58. package/dist/rules/test-names/colocated.d.ts +4 -0
  59. package/dist/rules/test-names/colocated.d.ts.map +1 -0
  60. package/dist/rules/test-names/colocated.js +31 -0
  61. package/dist/rules/test-names/colocated.js.map +1 -0
  62. package/dist/rules/test-names/follow.d.ts +4 -0
  63. package/dist/rules/test-names/follow.d.ts.map +1 -0
  64. package/dist/rules/test-names/follow.js +66 -0
  65. package/dist/rules/test-names/follow.js.map +1 -0
  66. package/dist/rules/test-names/usesDom.d.ts +3 -0
  67. package/dist/rules/test-names/usesDom.d.ts.map +1 -0
  68. package/dist/rules/test-names/usesDom.js +18 -0
  69. package/dist/rules/test-names/usesDom.js.map +1 -0
  70. package/dist/vitest.d.ts +3 -1
  71. package/dist/vitest.d.ts.map +1 -1
  72. package/dist/vitest.js +11 -11
  73. package/dist/vitest.js.map +1 -1
  74. package/package.json +1 -1
  75. package/src/imports/graph.ts +11 -7
  76. package/src/imports/resolve.ts +18 -0
  77. package/src/naming/exportFits.ts +21 -0
  78. package/src/naming/mainExport.ts +11 -0
  79. package/src/naming/ownExports.ts +95 -0
  80. package/src/naming/packageLayout.ts +75 -0
  81. package/src/naming/testName.ts +25 -0
  82. package/src/naming/words.ts +28 -0
  83. package/src/rules/built-in.ts +10 -0
  84. package/src/rules/files/folderNames.ts +92 -0
  85. package/src/rules/files/namedAfterExport.ts +87 -0
  86. package/src/rules/files/otherNames.ts +118 -0
  87. package/src/rules/test-names/colocated.ts +34 -0
  88. package/src/rules/test-names/follow.ts +78 -0
  89. package/src/rules/test-names/usesDom.ts +22 -0
  90. package/src/vitest.ts +14 -11
@@ -0,0 +1,4 @@
1
+ export declare const testsColocated: import("#rule.ts").Rule<"tests/colocated", {
2
+ readonly suites?: readonly string[];
3
+ }>;
4
+ //# sourceMappingURL=colocated.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"colocated.d.ts","sourceRoot":"","sources":["../../../src/rules/test-names/colocated.ts"],"names":[],"mappings":"AAyBA,eAAO,MAAM,cAAc;;EAQzB,CAAC"}
@@ -0,0 +1,31 @@
1
+ import { Effect, Schema } from "effect";
2
+ import ignore from "ignore";
3
+ import { testName } from "#naming/testName.ts";
4
+ import { defineRule } from "#rule.ts";
5
+ const MIRROR_FOLDERS = new Set(["__tests__", "spec", "test", "tests"]);
6
+ const TestsColocatedOptions = Schema.Struct({
7
+ suites: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
8
+ });
9
+ function findingOf(path) {
10
+ const name = testName(path);
11
+ const mirror = name?.folder.split("/").find((folder) => MIRROR_FOLDERS.has(folder));
12
+ if (name === undefined || mirror === undefined) {
13
+ return [];
14
+ }
15
+ return [
16
+ {
17
+ file: path,
18
+ 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.`,
19
+ },
20
+ ];
21
+ }
22
+ export const testsColocated = defineRule({
23
+ check: ({ files, options }) => {
24
+ const suites = ignore().add([...options.suites]);
25
+ return files.filter((path) => !suites.ignores(path)).flatMap(findingOf);
26
+ },
27
+ 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.",
28
+ id: "tests/colocated",
29
+ options: Schema.toStandardSchemaV1(TestsColocatedOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
30
+ });
31
+ //# sourceMappingURL=colocated.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"colocated.js","sourceRoot":"","sources":["../../../src/rules/test-names/colocated.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAgB,MAAM,UAAU,CAAC;AAEpD,MAAM,cAAc,GAAwB,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAE5F,MAAM,qBAAqB,GAAG,MAAM,CAAC,MAAM,CAAC;IAC3C,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC;CAC3F,CAAC,CAAC;AAEH,SAAS,SAAS,CAAC,IAAY;IAC9B,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC5B,MAAM,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;IACpF,IAAI,IAAI,KAAK,SAAS,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QAChD,OAAO,EAAE,CAAC;IACX,CAAC;IACD,OAAO;QACN;YACC,IAAI,EAAE,IAAI;YACV,OAAO,EAAE,4BAA4B,MAAM,iDAAiD,IAAI,CAAC,IAAI,QAAQ,IAAI,CAAC,SAAS,WAAW,IAAI,CAAC,IAAI,gCAAgC,IAAI,CAAC,SAAS,4GAA4G;SACzS;KACD,CAAC;AACH,CAAC;AAED,MAAM,CAAC,MAAM,cAAc,GAAG,UAAU,CAAC;IACxC,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE;QAC7B,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;QACjD,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IACzE,CAAC;IACD,WAAW,EAAE,gIAAgI;IAC7I,EAAE,EAAE,iBAAiB;IACrB,OAAO,EAAE,MAAM,CAAC,kBAAkB,CAAC,qBAAqB,EAAE,EAAE,YAAY,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,EAAE,OAAO,EAAE,EAAE,CAAC;CACzH,CAAC,CAAC"}
@@ -0,0 +1,4 @@
1
+ export declare const testsFollow: import("#rule.ts").Rule<"tests/follow", {
2
+ readonly suites?: readonly string[];
3
+ }>;
4
+ //# sourceMappingURL=follow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"follow.d.ts","sourceRoot":"","sources":["../../../src/rules/test-names/follow.ts"],"names":[],"mappings":"AAmEA,eAAO,MAAM,WAAW;;EAUtB,CAAC"}
@@ -0,0 +1,66 @@
1
+ import { posix } from "node:path";
2
+ import { Effect, Schema } from "effect";
3
+ import ignore from "ignore";
4
+ import { ENVIRONMENTS, testName } from "#naming/testName.ts";
5
+ import { CAMEL } from "#naming/words.ts";
6
+ import { defineRule } from "#rule.ts";
7
+ import { usesDom } from "#rules/test-names/usesDom.ts";
8
+ const ENVIRONMENT_NAMES = new Set(ENVIRONMENTS);
9
+ const FIXTURE_SUFFIXES = new Set(["integration", "postgres"]);
10
+ const CODE = /\.[cm]?[jt]sx?$/u;
11
+ const GRAMMAR = "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.";
12
+ const TestsFollowOptions = Schema.Struct({
13
+ suites: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
14
+ });
15
+ function siblingsIn(files) {
16
+ return new Set(files.filter((file) => CODE.test(file) && testName(file) === undefined).map((file) => file.replace(CODE, "")));
17
+ }
18
+ function suffixMessage(file, name) {
19
+ const [first, ...rest] = name.modifiers;
20
+ if (first === undefined || (rest.length === 0 && ENVIRONMENT_NAMES.has(first))) {
21
+ return undefined;
22
+ }
23
+ const fixture = name.modifiers.find((modifier) => FIXTURE_SUFFIXES.has(modifier));
24
+ if (fixture !== undefined) {
25
+ 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}`;
26
+ }
27
+ 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.`;
28
+ }
29
+ function placeMessage(file, name, siblings) {
30
+ const covered = siblings.has(`${name.folder}/${name.stem}`);
31
+ if (name.kind === "test") {
32
+ return covered
33
+ ? undefined
34
+ : `"${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}.`;
35
+ }
36
+ if (covered) {
37
+ 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}.`;
38
+ }
39
+ return CAMEL.test(name.stem) ? undefined : `"${file}" names its behaviour in camelCase, such as checkoutFlow.spec${name.extension}.`;
40
+ }
41
+ function environmentMessage(file, name, source) {
42
+ if (name.modifiers.includes("dom") || name.modifiers.includes("typecheck") || !usesDom(source)) {
43
+ return undefined;
44
+ }
45
+ return `"${file}" uses the DOM, so it is ${name.stem}.dom.${name.kind}${name.extension} and runs in the dom project.`;
46
+ }
47
+ function findingsOf(source, siblings) {
48
+ const name = testName(source.path);
49
+ if (name === undefined) {
50
+ return [];
51
+ }
52
+ const file = posix.basename(source.path);
53
+ const messages = [suffixMessage(file, name), placeMessage(file, name, siblings), environmentMessage(file, name, source)];
54
+ return messages.flatMap((message) => (message === undefined ? [] : [{ file: source.path, message }]));
55
+ }
56
+ export const testsFollow = defineRule({
57
+ check: ({ files, options, sources }) => {
58
+ const suites = ignore().add([...options.suites]);
59
+ const siblings = siblingsIn(files);
60
+ return sources.filter((source) => !suites.ignores(source.path)).flatMap((source) => findingsOf(source, siblings));
61
+ },
62
+ description: "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.",
63
+ id: "tests/follow",
64
+ options: Schema.toStandardSchemaV1(TestsFollowOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
65
+ });
66
+ //# sourceMappingURL=follow.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"follow.js","sourceRoot":"","sources":["../../../src/rules/test-names/follow.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import type { SourceFile } from "#rule.ts";
2
+ export declare function usesDom(file: SourceFile): boolean;
3
+ //# sourceMappingURL=usesDom.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usesDom.d.ts","sourceRoot":"","sources":["../../../src/rules/test-names/usesDom.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAiB3C,wBAAgB,OAAO,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAGjD"}
@@ -0,0 +1,18 @@
1
+ import ts from "typescript";
2
+ import { parse } from "#rules/syntax.ts";
3
+ const DOM_GLOBALS = new Set(["document", "window"]);
4
+ const TESTING_LIBRARY = "@testing-library/";
5
+ function touchesDom(node) {
6
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
7
+ return node.moduleSpecifier.text.startsWith(TESTING_LIBRARY);
8
+ }
9
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && DOM_GLOBALS.has(node.expression.text)) {
10
+ return true;
11
+ }
12
+ return ts.forEachChild(node, touchesDom) ?? false;
13
+ }
14
+ export function usesDom(file) {
15
+ const source = parse(file);
16
+ return source === undefined ? false : touchesDom(source);
17
+ }
18
+ //# sourceMappingURL=usesDom.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usesDom.js","sourceRoot":"","sources":["../../../src/rules/test-names/usesDom.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAC;AAE5B,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAEzC,MAAM,WAAW,GAAwB,IAAI,GAAG,CAAC,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC,CAAC;AAEzE,MAAM,eAAe,GAAG,mBAAmB,CAAC;AAE5C,SAAS,UAAU,CAAC,IAAa;IAChC,IAAI,EAAE,CAAC,mBAAmB,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,eAAe,CAAC,EAAE,CAAC;QAC9E,OAAO,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,UAAU,CAAC,eAAe,CAAC,CAAC;IAC9D,CAAC;IACD,IAAI,EAAE,CAAC,0BAA0B,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACtH,OAAO,IAAI,CAAC;IACb,CAAC;IACD,OAAO,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,UAAU,CAAC,IAAI,KAAK,CAAC;AACnD,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,IAAgB;IACvC,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC;IAC3B,OAAO,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;AAC1D,CAAC"}
package/dist/vitest.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  import { type TestUserConfig } from "vitest/config";
2
- export declare function testProjects(): TestUserConfig;
2
+ export declare function testProjects({ exclude }?: {
3
+ readonly exclude?: readonly string[];
4
+ }): TestUserConfig;
3
5
  //# sourceMappingURL=vitest.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"vitest.d.ts","sourceRoot":"","sources":["../src/vitest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAYpE,wBAAgB,YAAY,IAAI,cAAc,CAS7C"}
1
+ {"version":3,"file":"vitest.d.ts","sourceRoot":"","sources":["../src/vitest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAepE,wBAAgB,YAAY,CAAC,EAAE,OAAY,EAAE,GAAE;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAAO,GAAG,cAAc,CAS5G"}
package/dist/vitest.js CHANGED
@@ -1,19 +1,19 @@
1
1
  import { defaultExclude } from "vitest/config";
2
- const TEST = "**/*.test.?(c|m)[jt]s?(x)";
3
- const DOM = "**/*.dom.test.?(c|m)[jt]s?(x)";
4
- const SLOW = "**/*.slow.test.?(c|m)[jt]s?(x)";
5
- const TYPECHECK = "**/*.typecheck.test.?(c|m)[jt]s?(x)";
6
- const TYPECHECK_ALONE = "**/typecheck.test.?(c|m)[jt]s?(x)";
7
- function excluding(...patterns) {
8
- return [...defaultExclude, TYPECHECK, TYPECHECK_ALONE, ...patterns];
2
+ const EXTENSION = "?(c|m)[jt]s?(x)";
3
+ const TYPECHECK_ALONE = `**/typecheck.test.${EXTENSION}`;
4
+ function named(environment) {
5
+ return environment === undefined ? `**/*.@(test|spec).${EXTENSION}` : `**/*.${environment}.@(test|spec).${EXTENSION}`;
9
6
  }
10
- export function testProjects() {
7
+ function excluding(exclude, ...patterns) {
8
+ return [...defaultExclude, named("typecheck"), TYPECHECK_ALONE, ...exclude, ...patterns];
9
+ }
10
+ export function testProjects({ exclude = [] } = {}) {
11
11
  return {
12
12
  project: ["unit", "dom"],
13
13
  projects: [
14
- { extends: true, test: { environment: "node", exclude: excluding(DOM, SLOW), include: [TEST], name: "unit" } },
15
- { extends: true, test: { environment: "happy-dom", exclude: excluding(SLOW), include: [DOM], name: "dom" } },
16
- { extends: true, test: { environment: "node", exclude: excluding(), include: [SLOW], name: "slow" } },
14
+ { extends: true, test: { environment: "node", exclude: excluding(exclude, named("dom"), named("slow")), include: [named()], name: "unit" } },
15
+ { extends: true, test: { environment: "happy-dom", exclude: excluding(exclude, named("slow")), include: [named("dom")], name: "dom" } },
16
+ { extends: true, test: { environment: "node", exclude: excluding(exclude), include: [named("slow")], name: "slow" } },
17
17
  ],
18
18
  };
19
19
  }
@@ -1 +1 @@
1
- {"version":3,"file":"vitest.js","sourceRoot":"","sources":["../src/vitest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAuB,MAAM,eAAe,CAAC;AAEpE,MAAM,IAAI,GAAG,2BAA2B,CAAC;AACzC,MAAM,GAAG,GAAG,+BAA+B,CAAC;AAC5C,MAAM,IAAI,GAAG,gCAAgC,CAAC;AAC9C,MAAM,SAAS,GAAG,qCAAqC,CAAC;AACxD,MAAM,eAAe,GAAG,mCAAmC,CAAC;AAE5D,SAAS,SAAS,CAAC,GAAG,QAA2B;IAChD,OAAO,CAAC,GAAG,cAAc,EAAE,SAAS,EAAE,eAAe,EAAE,GAAG,QAAQ,CAAC,CAAC;AACrE,CAAC;AAED,MAAM,UAAU,YAAY;IAC3B,OAAO;QACN,OAAO,EAAE,CAAC,MAAM,EAAE,KAAK,CAAC;QACxB,QAAQ,EAAE;YACT,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE;YAC9G,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,OAAO,EAAE,SAAS,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE;YAC5G,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,EAAE,OAAO,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE;SACrG;KACD,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"vitest.js","sourceRoot":"","sources":["../src/vitest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAuB,MAAM,eAAe,CAAC;AAGpE,MAAM,SAAS,GAAG,iBAAiB,CAAC;AAEpC,MAAM,eAAe,GAAG,qBAAqB,SAAS,EAAE,CAAC;AAEzD,SAAS,KAAK,CAAC,WAAyB;IACvC,OAAO,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,qBAAqB,SAAS,EAAE,CAAC,CAAC,CAAC,QAAQ,WAAW,iBAAiB,SAAS,EAAE,CAAC;AACvH,CAAC;AAED,SAAS,SAAS,CAAC,OAA0B,EAAE,GAAG,QAA2B;IAC5E,OAAO,CAAC,GAAG,cAAc,EAAE,KAAK,CAAC,WAAW,CAAC,EAAE,eAAe,EAAE,GAAG,OAAO,EAAE,GAAG,QAAQ,CAAC,CAAC;AAC1F,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,EAAE,OAAO,GAAG,EAAE,KAA+C,EAAE;IAC3F,OAAO;QACN,OAAO,EAAE,CAAC,MAAM,EAAE,KAAK,CAAC;QACxB,QAAQ,EAAE;YACT,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,CAAC,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE;YAC5I,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,OAAO,EAAE,SAAS,CAAC,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE;YACvI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE;SACrH;KACD,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/quality",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
4
4
  "description": "Repository quality gate with typed rules, a baseline of existing debt and a registry of reasoned exceptions",
5
5
  "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/quality#readme",
6
6
  "bugs": {
@@ -4,8 +4,8 @@ import type { RuleInputs, SourceFile } from "#rule.ts";
4
4
  import { parse } from "#rules/syntax.ts";
5
5
  import { ambientModules } from "./ambient.ts";
6
6
  import { type ImportKind, type ImportRequest, importsOf } from "./extract.ts";
7
- import { projectsFor } from "./projects.ts";
8
- import { type Endpoint, isDeclarationFile, resolveImport } from "./resolve.ts";
7
+ import { type Project, projectsFor } from "./projects.ts";
8
+ import { type Endpoint, isDeclarationFile, presumedTarget, resolveImport } from "./resolve.ts";
9
9
  import { type WorkspacePackage, workspacePackages } from "./workspace.ts";
10
10
 
11
11
  export interface ImportEdge {
@@ -31,11 +31,15 @@ const QUERY = /\?.*$/u;
31
31
  const NO_MODULES =
32
32
  "the import graph covers no modules: the sources hold no TypeScript or JavaScript module. Point `sources` at the code, or turn the imports rules off.";
33
33
 
34
- // A relative import of a missing file still names a path, so fences see it before its generator writes it.
35
- function missingTarget(from: string, request: ImportRequest): string | undefined {
34
+ function relativeTarget(from: string, request: ImportRequest): string | undefined {
36
35
  const relative = request.kind === "path-reference" || request.specifier.startsWith("./") || request.specifier.startsWith("../");
37
- const target = posix.join(posix.dirname(from), request.specifier.replace(QUERY, ""));
38
- return relative && !target.startsWith("../") ? target : undefined;
36
+ return relative ? posix.join(posix.dirname(from), request.specifier.replace(QUERY, "")) : undefined;
37
+ }
38
+
39
+ // A relative import or a package import of a missing file still names a path, so fences see it before its generator writes it.
40
+ function missingTarget(root: string, from: string, request: ImportRequest, project: Project): string | undefined {
41
+ const target = presumedTarget(root, request, join(root, from), project) ?? relativeTarget(from, request);
42
+ return target === undefined || target.startsWith("../") ? undefined : target;
39
43
  }
40
44
 
41
45
  function walk(inputs: RuleInputs, root: string): Pick<ImportGraph, "edges" | "modules" | "unresolved"> {
@@ -52,7 +56,7 @@ function walk(inputs: RuleInputs, root: string): Pick<ImportGraph, "edges" | "mo
52
56
  for (const request of importsOf(syntax, isDeclarationFile(file.path))) {
53
57
  const to = resolveImport(root, ambient, request, path, project);
54
58
  if (to === undefined) {
55
- const missing = missingTarget(file.path, request);
59
+ const missing = missingTarget(root, file.path, request, project);
56
60
  unresolved.push({ ...request, from: file.path, missing });
57
61
  edges.push(...(missing === undefined ? [] : [{ ...request, from: file.path, to: { kind: "file" as const, path: missing } }]));
58
62
  } else {
@@ -21,6 +21,16 @@ const DECLARATION = /\.d\.[cm]?ts$/u;
21
21
 
22
22
  const QUERY = /\?.*$/u;
23
23
 
24
+ const PACKAGE_IMPORT = "#";
25
+
26
+ const PACKAGE_JSON = "package.json";
27
+
28
+ const presumingHost: ts.ModuleResolutionHost = {
29
+ ...ts.sys,
30
+ directoryExists: () => true,
31
+ fileExists: (path) => !path.endsWith(`/${PACKAGE_JSON}`) || ts.sys.fileExists(path),
32
+ };
33
+
24
34
  export function builtinName(specifier: string): string | undefined {
25
35
  if (!isBuiltin(specifier)) {
26
36
  return undefined;
@@ -90,6 +100,14 @@ function resolvedPath(request: ImportRequest, from: string, project: Project): s
90
100
  return path === undefined ? undefined : fitting(path, request.type);
91
101
  }
92
102
 
103
+ export function presumedTarget(root: string, request: ImportRequest, from: string, project: Project): string | undefined {
104
+ if (request.kind !== "import" || !request.specifier.startsWith(PACKAGE_IMPORT)) {
105
+ return undefined;
106
+ }
107
+ const path = ts.resolveModuleName(request.specifier.replace(QUERY, ""), from, project.options, presumingHost).resolvedModule?.resolvedFileName;
108
+ return path === undefined ? undefined : posix(relative(root, path));
109
+ }
110
+
93
111
  function endpointAt(root: string, specifier: string, path: string): Endpoint {
94
112
  const real = posix(realpathSync(path));
95
113
  const installed = real.lastIndexOf(NODE_MODULES);
@@ -0,0 +1,21 @@
1
+ import { CAMEL, isUpper, PASCAL, sameWord, wordsOf } from "#naming/words.ts";
2
+
3
+ function restIsFolders(rest: readonly string[], folders: readonly string[]): boolean {
4
+ const folderWords = folders.flatMap(wordsOf);
5
+ return rest.every((word) => folderWords.some((folder) => sameWord(word, folder)));
6
+ }
7
+
8
+ export function exportFits(name: string, stem: string, folders: readonly string[]): boolean {
9
+ if (!(CAMEL.test(stem) || PASCAL.test(stem)) || isUpper(stem) !== isUpper(name)) {
10
+ return false;
11
+ }
12
+ const named = wordsOf(name);
13
+ const filed = wordsOf(stem);
14
+ for (let start = 0; start + filed.length <= named.length; start += 1) {
15
+ const matches = filed.every((word, offset) => word === named[start + offset]);
16
+ if (matches && restIsFolders([...named.slice(0, start), ...named.slice(start + filed.length)], folders)) {
17
+ return true;
18
+ }
19
+ }
20
+ return false;
21
+ }
@@ -0,0 +1,11 @@
1
+ import type { OwnExport } from "#naming/ownExports.ts";
2
+
3
+ export function mainExport(exports: readonly OwnExport[]): OwnExport | undefined {
4
+ const values = exports.filter((entry) => entry.kind === "value");
5
+ const types = exports.filter((entry) => entry.kind === "type");
6
+ const [value] = values;
7
+ if (values.length === 1 && value !== undefined && types.every((type) => type.name.startsWith(value.name))) {
8
+ return value;
9
+ }
10
+ return values.length === 0 && types.length === 1 ? types[0] : undefined;
11
+ }
@@ -0,0 +1,95 @@
1
+ import ts from "typescript";
2
+
3
+ export interface OwnExport {
4
+ readonly constant: boolean;
5
+ readonly kind: "type" | "value";
6
+ readonly name: string;
7
+ }
8
+
9
+ const CONSTANT_CASE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/u;
10
+
11
+ const PRIMITIVE_LITERALS: ReadonlySet<ts.SyntaxKind> = new Set([
12
+ ts.SyntaxKind.BigIntLiteral,
13
+ ts.SyntaxKind.FalseKeyword,
14
+ ts.SyntaxKind.NoSubstitutionTemplateLiteral,
15
+ ts.SyntaxKind.NumericLiteral,
16
+ ts.SyntaxKind.StringLiteral,
17
+ ts.SyntaxKind.TrueKeyword,
18
+ ]);
19
+
20
+ function isPrimitiveLiteral(node: ts.Expression | undefined): boolean {
21
+ if (node === undefined) {
22
+ return false;
23
+ }
24
+ if (ts.isPrefixUnaryExpression(node)) {
25
+ return isPrimitiveLiteral(node.operand);
26
+ }
27
+ return PRIMITIVE_LITERALS.has(node.kind);
28
+ }
29
+
30
+ function isExported(statement: ts.Statement): boolean {
31
+ const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
32
+ const exported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
33
+ const isDefault = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
34
+ return exported && !isDefault;
35
+ }
36
+
37
+ function variables(statement: ts.VariableStatement): readonly OwnExport[] {
38
+ const constant = (statement.declarationList.flags & ts.NodeFlags.Const) !== 0;
39
+ return statement.declarationList.declarations.flatMap((declaration) =>
40
+ ts.isIdentifier(declaration.name)
41
+ ? [
42
+ {
43
+ constant: constant && (CONSTANT_CASE.test(declaration.name.text) || isPrimitiveLiteral(declaration.initializer)),
44
+ kind: "value" as const,
45
+ name: declaration.name.text,
46
+ },
47
+ ]
48
+ : [],
49
+ );
50
+ }
51
+
52
+ function declared(statement: ts.Statement): readonly OwnExport[] {
53
+ if (ts.isVariableStatement(statement)) {
54
+ return variables(statement);
55
+ }
56
+ if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
57
+ return [{ constant: false, kind: "type", name: statement.name.text }];
58
+ }
59
+ const named =
60
+ ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement)
61
+ ? statement.name
62
+ : undefined;
63
+ return named !== undefined && ts.isIdentifier(named) ? [{ constant: false, kind: "value", name: named.text }] : [];
64
+ }
65
+
66
+ function locals(source: ts.SourceFile): ReadonlyMap<string, OwnExport> {
67
+ const all = source.statements.flatMap(declared);
68
+ return new Map(all.map((local) => [local.name, local]));
69
+ }
70
+
71
+ function listed(statement: ts.ExportDeclaration, known: ReadonlyMap<string, OwnExport>): readonly OwnExport[] {
72
+ const clause = statement.exportClause;
73
+ if (statement.moduleSpecifier !== undefined || clause === undefined || !ts.isNamedExports(clause)) {
74
+ return [];
75
+ }
76
+ return clause.elements.flatMap((element) => {
77
+ const name = element.name.text;
78
+ const local = known.get((element.propertyName ?? element.name).text);
79
+ if (name === "default" || local === undefined) {
80
+ return [];
81
+ }
82
+ const typeOnly = statement.isTypeOnly || element.isTypeOnly;
83
+ return [{ constant: local.constant, kind: typeOnly ? ("type" as const) : local.kind, name }];
84
+ });
85
+ }
86
+
87
+ export function ownExports(source: ts.SourceFile): readonly OwnExport[] {
88
+ const known = locals(source);
89
+ return source.statements.flatMap((statement) => {
90
+ if (ts.isExportDeclaration(statement)) {
91
+ return listed(statement, known);
92
+ }
93
+ return isExported(statement) ? declared(statement) : [];
94
+ });
95
+ }
@@ -0,0 +1,75 @@
1
+ import { posix } from "node:path";
2
+ import type { RuleInputs } from "#rule.ts";
3
+
4
+ export interface PackageLayout {
5
+ readonly foldersOf: (path: string) => readonly string[];
6
+ readonly names: ReadonlyMap<string, string>;
7
+ readonly scripts: ReadonlySet<string>;
8
+ }
9
+
10
+ const MANIFEST = "package.json";
11
+
12
+ const SOURCE_FOLDER = "src";
13
+
14
+ const RUNNABLE = /\.[cm]?[jt]sx?$/u;
15
+
16
+ const BUILT = /^dist\/(?<entry>.+)\.[cm]?js$/u;
17
+
18
+ function parsed(text: string | undefined): unknown {
19
+ if (text === undefined) {
20
+ return undefined;
21
+ }
22
+ try {
23
+ return JSON.parse(text);
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ function field(value: unknown, key: string): unknown {
30
+ return typeof value === "object" && value !== null && key in value ? Reflect.get(value, key) : undefined;
31
+ }
32
+
33
+ function texts(value: unknown): readonly string[] {
34
+ if (typeof value === "string") {
35
+ return [value];
36
+ }
37
+ return typeof value === "object" && value !== null ? Object.values(value).filter((entry): entry is string => typeof entry === "string") : [];
38
+ }
39
+
40
+ function sourcesOf(target: string): readonly string[] {
41
+ const built = BUILT.exec(target)?.groups?.entry;
42
+ return built === undefined ? [target] : [target, `src/${built}.ts`, `src/${built}.tsx`];
43
+ }
44
+
45
+ function packageOf(path: string, roots: readonly string[]): string {
46
+ return roots.find((root) => root === "" || path.startsWith(`${root}/`)) ?? "";
47
+ }
48
+
49
+ function runBy(directory: string, manifest: unknown): readonly string[] {
50
+ const commands = texts(field(manifest, "scripts")).flatMap((command) => command.split(/\s+/u).filter((token) => RUNNABLE.test(token)));
51
+ const bins = texts(field(manifest, "bin")).flatMap(sourcesOf);
52
+ return [...commands, ...bins].map((target) => posix.join(directory, target));
53
+ }
54
+
55
+ export async function packageLayout({ files, readText }: Pick<RuleInputs, "files" | "readText">): Promise<PackageLayout> {
56
+ const directories = ["", ...files.filter((path) => posix.basename(path) === MANIFEST).map((path) => posix.dirname(path))];
57
+ const manifests = await Promise.all(
58
+ [...new Set(directories)].map(async (directory) => ({
59
+ directory: directory === "." ? "" : directory,
60
+ manifest: parsed(await readText(directory === "" ? MANIFEST : `${directory}/${MANIFEST}`)),
61
+ })),
62
+ );
63
+ const present = manifests.filter((entry) => entry.manifest !== undefined);
64
+ const roots = present.map((entry) => entry.directory).toSorted((left, right) => right.length - left.length);
65
+ const names = new Map(present.flatMap(({ directory, manifest }) => texts(field(manifest, "name")).map((name) => [directory, name] as const)));
66
+ return {
67
+ foldersOf: (path) => {
68
+ const root = packageOf(path, roots);
69
+ const folders = posix.dirname(root === "" ? path : path.slice(root.length + 1)).split("/");
70
+ return folders.filter((folder, index) => folder !== "." && !(index === 0 && folder === SOURCE_FOLDER));
71
+ },
72
+ names,
73
+ scripts: new Set(present.flatMap(({ directory, manifest }) => runBy(directory, manifest))),
74
+ };
75
+ }
@@ -0,0 +1,25 @@
1
+ import { posix } from "node:path";
2
+
3
+ export const ENVIRONMENTS = ["dom", "slow", "typecheck"] as const;
4
+
5
+ export type Environment = (typeof ENVIRONMENTS)[number];
6
+
7
+ export interface TestName {
8
+ readonly extension: string;
9
+ readonly folder: string;
10
+ readonly kind: "spec" | "test";
11
+ readonly modifiers: readonly string[];
12
+ readonly stem: string;
13
+ }
14
+
15
+ const TEST_FILE = /^(?<base>.+)\.(?<kind>test|spec)(?<extension>\.[cm]?[jt]sx?)$/u;
16
+
17
+ export function testName(path: string): TestName | undefined {
18
+ const groups = TEST_FILE.exec(posix.basename(path))?.groups;
19
+ const kind = groups?.kind;
20
+ if (groups?.base === undefined || groups.extension === undefined || (kind !== "test" && kind !== "spec")) {
21
+ return undefined;
22
+ }
23
+ const [stem = "", ...modifiers] = groups.base.split(".");
24
+ return { extension: groups.extension, folder: posix.dirname(path), kind, modifiers, stem };
25
+ }
@@ -0,0 +1,28 @@
1
+ const WORD = /[A-Z]?[a-z0-9]+|[A-Z]+(?![a-z])/gu;
2
+
3
+ export const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
4
+
5
+ export const CAMEL = /^[a-z][a-zA-Z0-9]*$/u;
6
+
7
+ export const PASCAL = /^[A-Z][a-zA-Z0-9]*$/u;
8
+
9
+ const UPPER_START = /^[A-Z]/u;
10
+
11
+ export function wordsOf(name: string): readonly string[] {
12
+ return [...name.matchAll(WORD)].map((match) => match[0].toLowerCase());
13
+ }
14
+
15
+ export function singular(word: string): string {
16
+ if (word.endsWith("ies") && word.length > 3) {
17
+ return `${word.slice(0, -3)}y`;
18
+ }
19
+ return word.endsWith("s") && !word.endsWith("ss") && word.length > 1 ? word.slice(0, -1) : word;
20
+ }
21
+
22
+ export function sameWord(left: string, right: string): boolean {
23
+ return singular(left) === singular(right);
24
+ }
25
+
26
+ export function isUpper(text: string): boolean {
27
+ return UPPER_START.test(text);
28
+ }
@@ -5,6 +5,9 @@ import { noJsdoc } from "#rules/comments/no-jsdoc.ts";
5
5
  import { noLineReference } from "#rules/comments/no-line-reference.ts";
6
6
  import { noPrReference } from "#rules/comments/no-pr-reference.ts";
7
7
  import { noTodo } from "#rules/comments/no-todo.ts";
8
+ import { folderNames } from "#rules/files/folderNames.ts";
9
+ import { namedAfterExport } from "#rules/files/namedAfterExport.ts";
10
+ import { otherNames } from "#rules/files/otherNames.ts";
8
11
  import { importsAliased } from "#rules/imports/aliased.ts";
9
12
  import { importCycles } from "#rules/imports/cycles.ts";
10
13
  import { importFences } from "#rules/imports/fences.ts";
@@ -12,6 +15,8 @@ import { importsResolvable } from "#rules/imports/resolvable.ts";
12
15
  import { biomeOverrides } from "#rules/suppressions/biome-overrides.ts";
13
16
  import { noDoubleCast } from "#rules/suppressions/no-double-cast.ts";
14
17
  import { noInline } from "#rules/suppressions/no-inline.ts";
18
+ import { testsColocated } from "#rules/test-names/colocated.ts";
19
+ import { testsFollow } from "#rules/test-names/follow.ts";
15
20
  import { biome } from "./biome.ts";
16
21
  import { manifestsSorted } from "./manifests-sorted.ts";
17
22
  import { maxLines } from "./max-lines.ts";
@@ -34,4 +39,9 @@ export const builtInRules = [
34
39
  importsResolvable,
35
40
  importsAliased,
36
41
  manifestsSorted,
42
+ namedAfterExport,
43
+ folderNames,
44
+ otherNames,
45
+ testsFollow,
46
+ testsColocated,
37
47
  ] as const;