@shivaedev/quality 0.31.1 → 0.32.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 (93) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +20 -5
  3. package/dist/baseline/installDriver.d.ts +5 -0
  4. package/dist/baseline/installDriver.d.ts.map +1 -0
  5. package/dist/baseline/installDriver.js +11 -0
  6. package/dist/baseline/installDriver.js.map +1 -0
  7. package/dist/baseline/merge.d.ts +4 -0
  8. package/dist/baseline/merge.d.ts.map +1 -0
  9. package/dist/baseline/merge.js +28 -0
  10. package/dist/baseline/merge.js.map +1 -0
  11. package/dist/baseline/rewrite.d.ts.map +1 -1
  12. package/dist/baseline/rewrite.js +0 -1
  13. package/dist/baseline/rewrite.js.map +1 -1
  14. package/dist/baseline/shards.d.ts +13 -0
  15. package/dist/baseline/shards.d.ts.map +1 -0
  16. package/dist/baseline/shards.js +48 -0
  17. package/dist/baseline/shards.js.map +1 -0
  18. package/dist/cli/args.d.ts +8 -1
  19. package/dist/cli/args.d.ts.map +1 -1
  20. package/dist/cli/args.js +9 -0
  21. package/dist/cli/args.js.map +1 -1
  22. package/dist/cli/baseline.d.ts.map +1 -1
  23. package/dist/cli/baseline.js +6 -7
  24. package/dist/cli/baseline.js.map +1 -1
  25. package/dist/cli/messages.d.ts +1 -1
  26. package/dist/cli/messages.d.ts.map +1 -1
  27. package/dist/cli/messages.js +6 -2
  28. package/dist/cli/messages.js.map +1 -1
  29. package/dist/cli/migrate.d.ts.map +1 -1
  30. package/dist/cli/migrate.js +10 -6
  31. package/dist/cli/migrate.js.map +1 -1
  32. package/dist/cli/program.d.ts.map +1 -1
  33. package/dist/cli/program.js +6 -0
  34. package/dist/cli/program.js.map +1 -1
  35. package/dist/config/load.js +1 -1
  36. package/dist/engine/baselineFile.d.ts +2 -1
  37. package/dist/engine/baselineFile.d.ts.map +1 -1
  38. package/dist/engine/baselineFile.js +23 -6
  39. package/dist/engine/baselineFile.js.map +1 -1
  40. package/dist/hooks/forwarding.d.ts +7 -0
  41. package/dist/hooks/forwarding.d.ts.map +1 -0
  42. package/dist/hooks/forwarding.js +94 -0
  43. package/dist/hooks/forwarding.js.map +1 -0
  44. package/dist/hooks/install.d.ts.map +1 -1
  45. package/dist/hooks/install.js +2 -1
  46. package/dist/hooks/install.js.map +1 -1
  47. package/dist/hooks/shim.d.ts +1 -0
  48. package/dist/hooks/shim.d.ts.map +1 -1
  49. package/dist/hooks/shim.js +3 -1
  50. package/dist/hooks/shim.js.map +1 -1
  51. package/dist/hooks/status.d.ts +1 -1
  52. package/dist/hooks/status.d.ts.map +1 -1
  53. package/dist/hooks/status.js +8 -6
  54. package/dist/hooks/status.js.map +1 -1
  55. package/dist/hooks/uninstall.d.ts.map +1 -1
  56. package/dist/hooks/uninstall.js +3 -1
  57. package/dist/hooks/uninstall.js.map +1 -1
  58. package/dist/imports/packageExports.d.ts +4 -0
  59. package/dist/imports/packageExports.d.ts.map +1 -0
  60. package/dist/imports/packageExports.js +48 -0
  61. package/dist/imports/packageExports.js.map +1 -0
  62. package/dist/imports/publicModules.d.ts +4 -0
  63. package/dist/imports/publicModules.d.ts.map +1 -0
  64. package/dist/imports/publicModules.js +21 -0
  65. package/dist/imports/publicModules.js.map +1 -0
  66. package/dist/rules/imports/bundleInputs.d.ts.map +1 -1
  67. package/dist/rules/imports/bundleInputs.js +4 -50
  68. package/dist/rules/imports/bundleInputs.js.map +1 -1
  69. package/dist/rules/stories/supportImports.d.ts.map +1 -1
  70. package/dist/rules/stories/supportImports.js +9 -3
  71. package/dist/rules/stories/supportImports.js.map +1 -1
  72. package/docs/debt-and-hooks.md +9 -6
  73. package/package.json +3 -3
  74. package/src/baseline/installDriver.ts +12 -0
  75. package/src/baseline/merge.ts +34 -0
  76. package/src/baseline/rewrite.ts +0 -1
  77. package/src/baseline/shards.ts +60 -0
  78. package/src/cli/args.ts +11 -0
  79. package/src/cli/baseline.ts +6 -10
  80. package/src/cli/messages.ts +6 -2
  81. package/src/cli/migrate.ts +10 -6
  82. package/src/cli/program.ts +6 -0
  83. package/src/config/load.ts +1 -1
  84. package/src/engine/baselineFile.ts +24 -7
  85. package/src/hooks/forwarding.ts +99 -0
  86. package/src/hooks/install.ts +3 -1
  87. package/src/hooks/shim.ts +3 -1
  88. package/src/hooks/status.ts +10 -6
  89. package/src/hooks/uninstall.ts +3 -1
  90. package/src/imports/packageExports.ts +55 -0
  91. package/src/imports/publicModules.ts +27 -0
  92. package/src/rules/imports/bundleInputs.ts +4 -59
  93. package/src/rules/stories/supportImports.ts +10 -3
package/src/hooks/shim.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type HookCommand, shellWords } from "./command.ts";
2
2
 
3
- const OWNER = "# Installed by @shivaedev/quality: `quality hooks install` writes it, `quality hooks uninstall` removes it.";
3
+ export const OWNER = "# Installed by @shivaedev/quality: `quality hooks install` writes it, `quality hooks uninstall` removes it.";
4
4
 
5
5
  export function isHookShim(text: string): boolean {
6
6
  return text.split("\n").includes(OWNER);
@@ -14,6 +14,8 @@ export function hookShim(directory: string, command: HookCommand): string {
14
14
  return [
15
15
  "#!/bin/sh",
16
16
  OWNER,
17
+ 'if [ -n "$QUALITY_PRE_COMMIT_ACTIVE" ]; then exit 0; fi',
18
+ "export QUALITY_PRE_COMMIT_ACTIVE=1",
17
19
  'top="$(git rev-parse --show-toplevel)" || exit 2',
18
20
  'if [ -n "$GIT_DIR" ] && [ -z "$GIT_WORK_TREE" ]; then export GIT_WORK_TREE="$top"; fi',
19
21
  'cd "$top" || exit 2',
@@ -3,8 +3,9 @@ import { access, realpath } from "node:fs/promises";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { Console, Effect } from "effect";
5
5
  import { GateFailed, SetupFailure } from "#failure.ts";
6
- import { git, gitOrFail } from "#git/command.ts";
6
+ import { gitOrFail } from "#git/command.ts";
7
7
  import { readOptionalText } from "#inventory/filesystem.ts";
8
+ import { forwardingPath, globalHooksPath } from "./forwarding.ts";
8
9
  import { type HookLocation, hookLocation } from "./location.ts";
9
10
  import { isHookShim } from "./shim.ts";
10
11
 
@@ -20,11 +21,7 @@ function executable(file: string) {
20
21
  }
21
22
 
22
23
  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();
24
+ const path = yield* globalHooksPath(location.toplevel);
28
25
  const hook = resolve(location.toplevel, path, "pre-commit");
29
26
  const target = path === "" ? undefined : yield* executable(hook);
30
27
  const own = yield* executable(location.file);
@@ -45,6 +42,13 @@ export const hookStatus = Effect.fn("Hooks.hookStatus")(function* (cwd: string)
45
42
  );
46
43
  return yield* new GateFailed();
47
44
  }
45
+ const global = yield* globalHooksPath(cwd);
46
+ if (global !== "" && dirname(path) !== resolve(location.toplevel, global) && dirname(path) !== forwardingPath(location.file)) {
47
+ yield* Console.error(
48
+ "quality: the repository-local core.hooksPath hides global hooks other than pre-commit.\nhelp: run `quality hooks install` to keep all global hooks running.",
49
+ );
50
+ return yield* new GateFailed();
51
+ }
48
52
  yield* Console.log(`quality: git runs Quality's pre-commit hook at ${path}.`);
49
53
  yield* globalHookStatus(location);
50
54
  });
@@ -3,6 +3,7 @@ import { Console, Effect, FileSystem } from "effect";
3
3
  import { SetupFailure } from "#failure.ts";
4
4
  import { git, gitOrFail } from "#git/command.ts";
5
5
  import { readOptionalText } from "#inventory/filesystem.ts";
6
+ import { forwardingPath, removeForwarding } from "./forwarding.ts";
6
7
  import { hookLocation } from "./location.ts";
7
8
  import { isHookShim } from "./shim.ts";
8
9
 
@@ -18,7 +19,8 @@ export const uninstallHook = Effect.fn("Hooks.uninstallHook")(function* (cwd: st
18
19
  }
19
20
  yield* Effect.mapError(fs.remove(file), (error) => new SetupFailure({ message: `cannot remove ${file}: ${error.message}` }));
20
21
  const local = yield* git(cwd, ["config", "--local", "--get", "core.hooksPath"]);
21
- if (local.stdout.trim() === dirname(file)) {
22
+ yield* removeForwarding(file).pipe(Effect.mapError((error) => new SetupFailure({ message: error.message })));
23
+ if ([dirname(file), forwardingPath(file)].includes(local.stdout.trim())) {
22
24
  yield* gitOrFail(cwd, ["config", "--local", "--unset-all", "core.hooksPath"]);
23
25
  }
24
26
  yield* Console.log(`quality: removed the pre-commit hook at ${file}.`);
@@ -0,0 +1,55 @@
1
+ export function blockedExport(exports: unknown, subpath: string): boolean {
2
+ if (typeof exports !== "object" || exports === null || Array.isArray(exports)) {
3
+ return false;
4
+ }
5
+ const entries = Object.entries(exports);
6
+ const exact = entries.find(([key]) => key === subpath);
7
+ if (exact !== undefined) {
8
+ return exact[1] === null;
9
+ }
10
+ const matching = entries.filter(([key]) => {
11
+ const [prefix = "", suffix, ...rest] = key.split("*");
12
+ return (
13
+ suffix !== undefined
14
+ && rest.length === 0
15
+ && subpath.length > prefix.length + suffix.length
16
+ && subpath.startsWith(prefix)
17
+ && subpath.endsWith(suffix)
18
+ );
19
+ });
20
+ const best = matching.toSorted(([left], [right]) => right.indexOf("*") - left.indexOf("*") || right.length - left.length)[0];
21
+ return best?.[1] === null;
22
+ }
23
+
24
+ export function exportTargets(value: unknown): string[] {
25
+ if (typeof value === "string") {
26
+ return [value];
27
+ }
28
+ return typeof value === "object" && value !== null ? Object.values(value).flatMap(exportTargets) : [];
29
+ }
30
+
31
+ export function exportedModule(exports: unknown, local: string): boolean {
32
+ if (typeof exports !== "object" || exports === null) {
33
+ return false;
34
+ }
35
+ const paths = [
36
+ `./${local}`,
37
+ `./${local.replace(/^src\//u, "dist/").replace(/\.(?<mode>[cm]?)tsx?$/u, ".$<mode>js")}`,
38
+ `./${local.replace(/^src\//u, "dist/").replace(/\.(?<mode>[cm]?)tsx?$/u, ".d.$<mode>ts")}`,
39
+ ];
40
+ return Object.entries(exports).some(([key, value]) =>
41
+ exportTargets(value).some((target) =>
42
+ paths.some((path) => {
43
+ const [prefix = "", suffix] = target.split("*");
44
+ if (suffix === undefined) {
45
+ return path === target && !blockedExport(exports, key);
46
+ }
47
+ if (!(path.startsWith(prefix) && path.endsWith(suffix)) || path.length <= prefix.length + suffix.length) {
48
+ return false;
49
+ }
50
+ const star = path.slice(prefix.length, path.length - suffix.length);
51
+ return !blockedExport(exports, key.replace("*", star));
52
+ }),
53
+ ),
54
+ );
55
+ }
@@ -0,0 +1,27 @@
1
+ import { posix } from "node:path";
2
+ import type { RuleInputs } from "#rule.ts";
3
+ import type { ImportGraph } from "./graph.ts";
4
+ import { exportedModule } from "./packageExports.ts";
5
+ import { packageOf } from "./workspace.ts";
6
+
7
+ export async function publicModules(inputs: RuleInputs, graph: ImportGraph): Promise<ReadonlySet<string>> {
8
+ const exports = new Map(
9
+ await Promise.all(
10
+ graph.packages.map(async (owner) => {
11
+ const manifest: { readonly exports?: unknown } = JSON.parse((await inputs.readText(posix.join(owner.directory, "package.json"))) ?? "");
12
+ return [owner, manifest.exports] as const;
13
+ }),
14
+ ),
15
+ );
16
+ const modules = new Set<string>();
17
+ for (const edge of graph.edges) {
18
+ if (edge.to.kind !== "file") {
19
+ continue;
20
+ }
21
+ const owner = packageOf(graph.packages, edge.to.path);
22
+ if (owner !== undefined && exportedModule(exports.get(owner), posix.relative(owner.directory, edge.to.path))) {
23
+ modules.add(edge.to.path);
24
+ }
25
+ }
26
+ return modules;
27
+ }
@@ -1,66 +1,11 @@
1
1
  import { posix } from "node:path";
2
2
  import { outgoing } from "#imports/fences/reach.ts";
3
3
  import type { ImportGraph } from "#imports/graph.ts";
4
+ import { blockedExport, exportedModule, exportTargets } from "#imports/packageExports.ts";
4
5
  import { packageOf, type WorkspacePackage } from "#imports/workspace.ts";
5
6
 
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
7
  function executable(manifest: object, local: string): boolean {
63
- const binTargets = targets(Reflect.get(manifest, "bin"));
8
+ const binTargets = exportTargets(Reflect.get(manifest, "bin"));
64
9
  const built = local.replace(/^src\//u, "dist/").replace(/\.(?<mode>[cm]?)tsx?$/u, ".$<mode>js");
65
10
  return binTargets.some((target) => typeof target === "string" && [local, built].includes(posix.normalize(target)));
66
11
  }
@@ -72,8 +17,8 @@ export function bundleInputs(graph: ImportGraph, owner: WorkspacePackage, manife
72
17
  const local = posix.relative(owner.directory, path);
73
18
  return (
74
19
  local.startsWith("src/")
75
- && blocked(Reflect.get(manifest, "exports"), `./${local.slice(4)}`)
76
- && !exported(Reflect.get(manifest, "exports"), local)
20
+ && blockedExport(Reflect.get(manifest, "exports"), `./${local.slice(4)}`)
21
+ && !exportedModule(Reflect.get(manifest, "exports"), local)
77
22
  );
78
23
  }),
79
24
  );
@@ -1,5 +1,6 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import { importGraph } from "#imports/graph.ts";
3
+ import { publicModules } from "#imports/publicModules.ts";
3
4
  import { isTestSupport } from "#naming/testName.ts";
4
5
  import { defineRule } from "#rule.ts";
5
6
  import { matchesAny } from "./matchesAny.ts";
@@ -14,18 +15,24 @@ export const supportImports = defineRule({
14
15
  return [];
15
16
  }
16
17
  const graph = await importGraph(inputs);
18
+ const exported = await publicModules(inputs, graph);
17
19
  return graph.edges
18
20
  .filter(
19
- (edge) => isTestSupport(edge.from) && edge.to.kind === "file" && !isTestSupport(edge.to.path) && !matchesAny(edge.to.path, options.modules),
21
+ (edge) =>
22
+ isTestSupport(edge.from)
23
+ && edge.to.kind === "file"
24
+ && !isTestSupport(edge.to.path)
25
+ && !exported.has(edge.to.path)
26
+ && !matchesAny(edge.to.path, options.modules),
20
27
  )
21
28
  .map((edge) => ({
22
29
  file: edge.from,
23
30
  line: edge.line,
24
- message: `Test-support imports "${edge.specifier}" outside its allowed public surface. Drive the app through its host, schemas or queue definitions; list those public module paths in tests/support-imports.options.modules in the repository's quality config.`,
31
+ message: `Test-support imports "${edge.specifier}" outside its allowed public surface. Drive the app through its host, schemas or queue definitions, or a workspace package's exports; list additional internal module paths in tests/support-imports.options.modules in the repository's quality config.`,
25
32
  subject: edge.specifier,
26
33
  }));
27
34
  },
28
- description: "Test-support reaches repository modules only through the public surface named by the repository's module allowlist.",
35
+ description: "Test-support reaches repository modules through workspace package exports and the repository's additional module allowlist.",
29
36
  id: "tests/support-imports",
30
37
  options: Schema.toStandardSchemaV1(SupportImportsOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
31
38
  });