@shivaedev/quality 0.26.0 → 0.27.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 (79) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +10 -3
  3. package/dist/biome/run.d.ts.map +1 -1
  4. package/dist/biome/run.js +15 -2
  5. package/dist/biome/run.js.map +1 -1
  6. package/dist/cli/args.d.ts +3 -1
  7. package/dist/cli/args.d.ts.map +1 -1
  8. package/dist/cli/args.js +3 -0
  9. package/dist/cli/args.js.map +1 -1
  10. package/dist/cli/messages.d.ts +1 -1
  11. package/dist/cli/messages.d.ts.map +1 -1
  12. package/dist/cli/messages.js +3 -1
  13. package/dist/cli/messages.js.map +1 -1
  14. package/dist/cli/program.d.ts.map +1 -1
  15. package/dist/cli/program.js +3 -0
  16. package/dist/cli/program.js.map +1 -1
  17. package/dist/domEnvironment.config.d.ts +4 -0
  18. package/dist/domEnvironment.config.d.ts.map +1 -0
  19. package/dist/domEnvironment.config.js +17 -0
  20. package/dist/domEnvironment.config.js.map +1 -0
  21. package/dist/hooks/install.d.ts.map +1 -1
  22. package/dist/hooks/install.js +9 -8
  23. package/dist/hooks/install.js.map +1 -1
  24. package/dist/hooks/shim.d.ts.map +1 -1
  25. package/dist/hooks/shim.js +11 -1
  26. package/dist/hooks/shim.js.map +1 -1
  27. package/dist/hooks/status.d.ts +6 -0
  28. package/dist/hooks/status.d.ts.map +1 -0
  29. package/dist/hooks/status.js +45 -0
  30. package/dist/hooks/status.js.map +1 -0
  31. package/dist/hooks/uninstall.d.ts.map +1 -1
  32. package/dist/hooks/uninstall.js +6 -0
  33. package/dist/hooks/uninstall.js.map +1 -1
  34. package/dist/inventory/collect.d.ts.map +1 -1
  35. package/dist/inventory/collect.js +3 -0
  36. package/dist/inventory/collect.js.map +1 -1
  37. package/dist/inventory/shadcnAlias.d.ts +2 -0
  38. package/dist/inventory/shadcnAlias.d.ts.map +1 -0
  39. package/dist/inventory/shadcnAlias.js +73 -0
  40. package/dist/inventory/shadcnAlias.js.map +1 -0
  41. package/dist/inventory/shadcnFolders.d.ts +4 -0
  42. package/dist/inventory/shadcnFolders.d.ts.map +1 -0
  43. package/dist/inventory/shadcnFolders.js +25 -0
  44. package/dist/inventory/shadcnFolders.js.map +1 -0
  45. package/dist/naming/migrationFile.d.ts +4 -0
  46. package/dist/naming/migrationFile.d.ts.map +1 -0
  47. package/dist/naming/migrationFile.js +8 -0
  48. package/dist/naming/migrationFile.js.map +1 -0
  49. package/dist/rules/files/folderNames.d.ts.map +1 -1
  50. package/dist/rules/files/folderNames.js +1 -2
  51. package/dist/rules/files/folderNames.js.map +1 -1
  52. package/dist/rules/files/namedAfterExport.d.ts.map +1 -1
  53. package/dist/rules/files/namedAfterExport.js +4 -1
  54. package/dist/rules/files/namedAfterExport.js.map +1 -1
  55. package/dist/rules/files/otherNames.js +1 -1
  56. package/dist/rules/files/otherNames.js.map +1 -1
  57. package/dist/testProjects.d.ts +4 -1
  58. package/dist/testProjects.d.ts.map +1 -1
  59. package/dist/testProjects.js +23 -3
  60. package/dist/testProjects.js.map +1 -1
  61. package/docs/debt-and-hooks.md +4 -2
  62. package/package.json +6 -2
  63. package/src/biome/run.ts +16 -2
  64. package/src/cli/args.ts +4 -0
  65. package/src/cli/messages.ts +3 -1
  66. package/src/cli/program.ts +3 -0
  67. package/src/domEnvironment.config.ts +13 -0
  68. package/src/hooks/install.ts +11 -12
  69. package/src/hooks/shim.ts +11 -1
  70. package/src/hooks/status.ts +50 -0
  71. package/src/hooks/uninstall.ts +6 -0
  72. package/src/inventory/collect.ts +3 -0
  73. package/src/inventory/shadcnAlias.ts +88 -0
  74. package/src/inventory/shadcnFolders.ts +30 -0
  75. package/src/naming/migrationFile.ts +9 -0
  76. package/src/rules/files/folderNames.ts +1 -3
  77. package/src/rules/files/namedAfterExport.ts +4 -1
  78. package/src/rules/files/otherNames.ts +1 -1
  79. package/src/testProjects.ts +27 -3
@@ -0,0 +1,50 @@
1
+ import { constants } from "node:fs";
2
+ import { access, realpath } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { Console, Effect } from "effect";
5
+ import { GateFailed, SetupFailure } from "#failure.ts";
6
+ import { git, gitOrFail } from "#git/command.ts";
7
+ import { readOptionalText } from "#inventory/filesystem.ts";
8
+ import { type HookLocation, hookLocation } from "./location.ts";
9
+ import { isHookShim } from "./shim.ts";
10
+
11
+ function executable(file: string) {
12
+ return Effect.promise(async () => {
13
+ try {
14
+ await access(file, constants.X_OK);
15
+ return await realpath(file);
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ });
20
+ }
21
+
22
+ export const globalHookStatus = Effect.fn("Hooks.globalHookStatus")(function* (location: HookLocation) {
23
+ const global = yield* git(location.toplevel, ["config", "--global", "--includes", "--type=path", "--get", "core.hooksPath"]);
24
+ if (global.code !== 0 && global.code !== 1) {
25
+ return yield* new SetupFailure({ message: `cannot read global core.hooksPath: ${global.stderr.trim()}` });
26
+ }
27
+ const path = global.stdout.trim();
28
+ const hook = resolve(location.toplevel, path, "pre-commit");
29
+ const target = path === "" ? undefined : yield* executable(hook);
30
+ const own = yield* executable(location.file);
31
+ yield* Console.log(
32
+ target !== undefined && target !== own
33
+ ? `quality: chaining global pre-commit hook at ${hook}.`
34
+ : "quality: no executable global pre-commit hook to chain.",
35
+ );
36
+ });
37
+
38
+ export const hookStatus = Effect.fn("Hooks.hookStatus")(function* (cwd: string) {
39
+ const location = yield* hookLocation(cwd);
40
+ const path = (yield* gitOrFail(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "hooks/pre-commit"])).trim();
41
+ const text = yield* readOptionalText(path).pipe(Effect.mapError((error) => new SetupFailure({ message: error.message })));
42
+ if (text === undefined || !isHookShim(text) || (yield* executable(path)) === undefined) {
43
+ yield* Console.error(
44
+ `quality: the effective hooks path ${dirname(path)} does not run Quality's pre-commit hook.\nhelp: run \`quality hooks install\` to check commits.`,
45
+ );
46
+ return yield* new GateFailed();
47
+ }
48
+ yield* Console.log(`quality: git runs Quality's pre-commit hook at ${path}.`);
49
+ yield* globalHookStatus(location);
50
+ });
@@ -1,5 +1,7 @@
1
+ import { dirname } from "node:path";
1
2
  import { Console, Effect, FileSystem } from "effect";
2
3
  import { SetupFailure } from "#failure.ts";
4
+ import { git, gitOrFail } from "#git/command.ts";
3
5
  import { readOptionalText } from "#inventory/filesystem.ts";
4
6
  import { hookLocation } from "./location.ts";
5
7
  import { isHookShim } from "./shim.ts";
@@ -15,5 +17,9 @@ export const uninstallHook = Effect.fn("Hooks.uninstallHook")(function* (cwd: st
15
17
  return yield* Console.error(`quality: kept ${file}: it is not quality's pre-commit hook.`);
16
18
  }
17
19
  yield* Effect.mapError(fs.remove(file), (error) => new SetupFailure({ message: `cannot remove ${file}: ${error.message}` }));
20
+ const local = yield* git(cwd, ["config", "--local", "--get", "core.hooksPath"]);
21
+ if (local.stdout.trim() === dirname(file)) {
22
+ yield* gitOrFail(cwd, ["config", "--local", "--unset-all", "core.hooksPath"]);
23
+ }
18
24
  yield* Console.log(`quality: removed the pre-commit hook at ${file}.`);
19
25
  });
@@ -4,6 +4,7 @@ import ignore from "ignore";
4
4
  import type { SourceFile } from "#rule.ts";
5
5
  import { FilesystemFailure, orWhenAbsent, readRequiredText } from "./filesystem.ts";
6
6
  import { posix } from "./ignoreScope.ts";
7
+ import { shadcnFolders } from "./shadcnFolders.ts";
7
8
  import { scopeAbove, walk } from "./walk.ts";
8
9
 
9
10
  export interface InventoryScope {
@@ -49,10 +50,12 @@ export const collectInventory = Effect.fn("Inventory.collectInventory")(function
49
50
  scope: InventoryScope,
50
51
  ): Effect.fn.Return<Inventory, FilesystemFailure, FileSystem.FileSystem> {
51
52
  const excluded = ignore().add([...scope.exclude]);
53
+ const toolOwned = yield* shadcnFolders(root);
52
54
  const zones = yield* Effect.forEach(scope.sources, (zone) => zoneFiles(root, zone), { concurrency: READ_CONCURRENCY });
53
55
  const relativePaths = zones.flat().map((absolute) => posix(relative(root, absolute)));
54
56
  const files = [...new Set(relativePaths)]
55
57
  .filter((path) => !excluded.ignores(path))
58
+ .filter((path) => !toolOwned.some((folder) => path.startsWith(`${folder}/`)))
56
59
  .sort((left, right) => {
57
60
  if (left === right) {
58
61
  return 0;
@@ -0,0 +1,88 @@
1
+ import { dirname, isAbsolute, join, resolve } from "node:path";
2
+ import ts from "typescript";
3
+
4
+ const PROBE = "__quality_shadcn__.tsx";
5
+
6
+ function capture(pattern: string, alias: string): string | undefined {
7
+ const star = pattern.indexOf("*");
8
+ if (star === -1) {
9
+ return pattern === alias ? "" : undefined;
10
+ }
11
+ const prefix = pattern.slice(0, star);
12
+ const suffix = pattern.slice(star + 1);
13
+ return alias.startsWith(prefix) && alias.endsWith(suffix)
14
+ ? alias.slice(prefix.length, suffix.length === 0 ? undefined : -suffix.length)
15
+ : undefined;
16
+ }
17
+
18
+ function pathTarget(alias: string, options: ts.CompilerOptions, directory: string): string | undefined {
19
+ const pathsBase = Reflect.get(options, "pathsBasePath");
20
+ const base = options.baseUrl ?? (typeof pathsBase === "string" ? pathsBase : directory);
21
+ const patterns = Object.entries(options.paths ?? {}).toSorted(([left], [right]) => right.indexOf("*") - left.indexOf("*"));
22
+ const exact = options.paths?.[alias]?.[0];
23
+ if (exact !== undefined) {
24
+ return resolve(base, exact);
25
+ }
26
+ for (const [pattern, targets] of patterns) {
27
+ const captured = capture(pattern, alias);
28
+ const target = targets[0];
29
+ if (captured !== undefined && target !== undefined) {
30
+ return resolve(base, target.replace("*", captured));
31
+ }
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ export function shadcnAlias(directory: string, alias: string): string {
37
+ if (alias.startsWith(".") || isAbsolute(alias)) {
38
+ return resolve(directory, alias);
39
+ }
40
+ const config = ts.findConfigFile(directory, ts.sys.fileExists);
41
+ const parsed =
42
+ config === undefined
43
+ ? undefined
44
+ : ts.getParsedCommandLineOfConfigFile(
45
+ config,
46
+ {},
47
+ {
48
+ ...ts.sys,
49
+ onUnRecoverableConfigFileDiagnostic: () => undefined,
50
+ },
51
+ );
52
+ if (parsed?.errors.length) {
53
+ throw new Error(
54
+ ts.formatDiagnostics(parsed.errors, {
55
+ getCanonicalFileName: (file) => file,
56
+ getCurrentDirectory: () => directory,
57
+ getNewLine: () => "\n",
58
+ }),
59
+ );
60
+ }
61
+ const options = parsed?.options ?? {};
62
+ const target = pathTarget(alias, options, config === undefined ? directory : dirname(config));
63
+ if (target !== undefined) {
64
+ return target;
65
+ }
66
+ if (!alias.startsWith("#")) {
67
+ throw new Error(`Cannot resolve shadcn UI alias "${alias}" from ${directory}. Check tsconfig paths.`);
68
+ }
69
+ // A synthetic child lets TypeScript resolve directory aliases through package imports without requiring a stock component to exist.
70
+ const resolved = ts.resolveModuleName(
71
+ `${alias}/${PROBE}`,
72
+ join(directory, "components.tsx"),
73
+ {
74
+ ...options,
75
+ customConditions: ["source", ...(options.customConditions ?? [])],
76
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
77
+ },
78
+ {
79
+ ...ts.sys,
80
+ directoryExists: () => true,
81
+ fileExists: (file) => file.endsWith(`/${PROBE}`) || ts.sys.fileExists(file),
82
+ },
83
+ ).resolvedModule;
84
+ if (resolved === undefined) {
85
+ throw new Error(`Cannot resolve shadcn UI alias "${alias}" from ${directory}. Check tsconfig paths or package imports.`);
86
+ }
87
+ return dirname(resolved.resolvedFileName);
88
+ }
@@ -0,0 +1,30 @@
1
+ import { basename, dirname, relative } from "node:path";
2
+ import { Effect, type FileSystem, Schema } from "effect";
3
+ import { FilesystemFailure, readRequiredText } from "./filesystem.ts";
4
+ import { posix } from "./ignoreScope.ts";
5
+ import { shadcnAlias } from "./shadcnAlias.ts";
6
+ import { walk } from "./walk.ts";
7
+
8
+ const Components = Schema.fromJsonString(Schema.Struct({ aliases: Schema.Struct({ ui: Schema.NonEmptyString }) }));
9
+
10
+ export const shadcnFolders = Effect.fn("Inventory.shadcnFolders")(function* (
11
+ root: string,
12
+ ): Effect.fn.Return<readonly string[], FilesystemFailure, FileSystem.FileSystem> {
13
+ const paths = (yield* walk(root)).filter((path) => basename(path) === "components.json");
14
+ return yield* Effect.forEach(paths, (path) =>
15
+ Effect.gen(function* () {
16
+ const text = yield* readRequiredText(path);
17
+ return yield* Effect.try({
18
+ catch: (cause) => new FilesystemFailure({ message: `cannot resolve ${path}: ${String(cause)}`, path }),
19
+ try: () => {
20
+ const { aliases } = Schema.decodeUnknownSync(Components)(text);
21
+ const folder = posix(relative(root, shadcnAlias(dirname(path), aliases.ui)));
22
+ if (folder === "" || folder === ".." || folder.startsWith("../")) {
23
+ throw new Error("the shadcn UI folder must be inside the repository and cannot be its root");
24
+ }
25
+ return folder;
26
+ },
27
+ });
28
+ }),
29
+ );
30
+ });
@@ -0,0 +1,9 @@
1
+ import { posix } from "node:path";
2
+
3
+ export const MIGRATIONS_DIRECTORY = "src/server/migrations";
4
+ export const MIGRATION_FILE_PATTERN = /^[0-9]{4}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*\.ts$/u;
5
+
6
+ export function isMigrationFile(path: string): boolean {
7
+ const directory = posix.dirname(path);
8
+ return (directory === MIGRATIONS_DIRECTORY || directory.endsWith(`/${MIGRATIONS_DIRECTORY}`)) && MIGRATION_FILE_PATTERN.test(posix.basename(path));
9
+ }
@@ -5,15 +5,13 @@ import { packageLayout } from "#naming/packageLayout.ts";
5
5
  import { KEBAB, PASCAL } from "#naming/words.ts";
6
6
  import { defineRule, type Finding } from "#rule.ts";
7
7
 
8
- const DEFAULT_TOOL_OWNED: readonly string[] = ["migrations/"];
9
-
10
8
  const SNAKE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/u;
11
9
 
12
10
  const CODE = /\.[cm]?[jt]sx?$/u;
13
11
 
14
12
  const FolderNamesOptions = Schema.Struct({
15
13
  content: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
16
- toolOwned: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(DEFAULT_TOOL_OWNED))),
14
+ toolOwned: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
17
15
  });
18
16
 
19
17
  function foldersIn(files: readonly string[]): readonly string[] {
@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect";
3
3
  import ignore from "ignore";
4
4
  import { exportFits } from "#naming/exportFits.ts";
5
5
  import { mainExport } from "#naming/mainExport.ts";
6
+ import { isMigrationFile } from "#naming/migrationFile.ts";
6
7
  import { ownExports } from "#naming/ownExports.ts";
7
8
  import { type PackageLayout, packageLayout } from "#naming/packageLayout.ts";
8
9
  import { CAMEL, KEBAB, sameWord, wordsOf } from "#naming/words.ts";
@@ -86,7 +87,9 @@ export const namedAfterExport = defineRule({
86
87
  check: async ({ files, options, readText, sources }) => {
87
88
  const layout = await packageLayout({ files, readText });
88
89
  const toolOwned = ignore().add([...options.toolOwned]);
89
- return sources.filter((file) => !(TEST.test(file.path) || toolOwned.ignores(file.path))).flatMap((file) => findingsOf(file, layout));
90
+ return sources
91
+ .filter((file) => !(TEST.test(file.path) || isMigrationFile(file.path) || toolOwned.ignores(file.path)))
92
+ .flatMap((file) => findingsOf(file, layout));
90
93
  },
91
94
  description:
92
95
  "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.",
@@ -5,7 +5,7 @@ import { CAMEL, KEBAB } from "#naming/words.ts";
5
5
  import { defineRule, type Finding } from "#rule.ts";
6
6
  import { stylesheetImporters } from "./stylesheetImporters.ts";
7
7
 
8
- const DEFAULT_TOOL_OWNED: readonly string[] = [".*", "package.json", "tsconfig*.json", "biome.json", "biome.jsonc", "*.config.*", "migrations/"];
8
+ const DEFAULT_TOOL_OWNED: readonly string[] = [".*", "package.json", "tsconfig*.json", "biome.json", "biome.jsonc", "*.config.*"];
9
9
 
10
10
  const CONVENTIONAL: ReadonlySet<string> = new Set(["AGENTS", "CHANGELOG", "CLAUDE", "CONTRIBUTING", "LICENSE", "README", "SECURITY", "SKILL"]);
11
11
 
@@ -1,5 +1,6 @@
1
+ import { fileURLToPath } from "node:url";
1
2
  import { Config, ConfigProvider, Effect } from "effect";
2
- import { defaultExclude, type TestUserConfig } from "vitest/config";
3
+ import { coverageConfigDefaults, defaultExclude, type TestUserConfig } from "vitest/config";
3
4
  import type { Environment } from "#naming/testName.ts";
4
5
 
5
6
  const EXTENSION = "?(c|m)[jt]s?(x)";
@@ -14,13 +15,36 @@ function excluding(...patterns: readonly string[]): string[] {
14
15
  return [...defaultExclude, named("typecheck"), TYPECHECK_ALONE, ...patterns];
15
16
  }
16
17
 
17
- export function testProjects(): TestUserConfig {
18
+ export interface TestProjectsOptions {
19
+ readonly coverage?: boolean | NonNullable<TestUserConfig["coverage"]>;
20
+ }
21
+
22
+ export function testProjects({ coverage = false }: TestProjectsOptions = {}): TestUserConfig {
18
23
  const ci = Effect.runSync(Config.String("CI").pipe(Config.withDefault("")).parse(ConfigProvider.fromEnv()));
19
24
  return {
25
+ ...(coverage === false
26
+ ? {}
27
+ : {
28
+ coverage: {
29
+ enabled: true,
30
+ exclude: [...coverageConfigDefaults.exclude, "**/*.d.ts", "**/*.test.*", "**/*.spec.*", "**/test-support/**"],
31
+ include: ["src/**/*.{ts,tsx}"],
32
+ provider: "v8",
33
+ ...(coverage === true ? {} : coverage),
34
+ },
35
+ }),
20
36
  project: ["unit", "dom"],
21
37
  projects: [
22
38
  { extends: true, test: { environment: "node", exclude: excluding(named("dom"), named("slow")), include: [named()], name: "unit" } },
23
- { extends: true, test: { environment: "happy-dom", exclude: excluding(named("slow")), include: [named("dom")], name: "dom" } },
39
+ {
40
+ extends: true,
41
+ test: {
42
+ environment: fileURLToPath(import.meta.resolve("#domEnvironment.config.ts")),
43
+ exclude: excluding(named("slow")),
44
+ include: [named("dom")],
45
+ name: "dom",
46
+ },
47
+ },
24
48
  { extends: true, test: { environment: "node", exclude: excluding(), include: [named("slow")], name: "slow" } },
25
49
  ],
26
50
  silent: ci ? false : "passed-only",