@shivaedev/quality 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +151 -508
  3. package/biome/declarations.json +0 -45
  4. package/biome/plugins/constant-names.grit +1 -1
  5. package/biome/plugins/effect-fn-functions.grit +1 -1
  6. package/biome/preset.json +16 -9
  7. package/dist/baseline/compare.d.ts.map +1 -1
  8. package/dist/baseline/format.d.ts.map +1 -1
  9. package/dist/baseline/legacy.d.ts.map +1 -1
  10. package/dist/baseline/rewrite.d.ts.map +1 -1
  11. package/dist/baseline/update.d.ts.map +1 -1
  12. package/dist/baseline/update.js.map +1 -1
  13. package/dist/biome/nativeEffectCall.d.ts +4 -0
  14. package/dist/biome/nativeEffectCall.d.ts.map +1 -0
  15. package/dist/biome/nativeEffectCall.js +34 -0
  16. package/dist/biome/nativeEffectCall.js.map +1 -0
  17. package/dist/biome/recognizeDiagnostics.d.ts +3 -0
  18. package/dist/biome/recognizeDiagnostics.d.ts.map +1 -0
  19. package/dist/biome/recognizeDiagnostics.js +58 -0
  20. package/dist/biome/recognizeDiagnostics.js.map +1 -0
  21. package/dist/biome/report.d.ts +10 -0
  22. package/dist/biome/report.d.ts.map +1 -1
  23. package/dist/biome/report.js +23 -5
  24. package/dist/biome/report.js.map +1 -1
  25. package/dist/biome/run.js.map +1 -1
  26. package/dist/biome/syntax.d.ts +12 -0
  27. package/dist/biome/syntax.d.ts.map +1 -0
  28. package/dist/biome/syntax.js +29 -0
  29. package/dist/biome/syntax.js.map +1 -0
  30. package/dist/cli/args.d.ts +10 -1
  31. package/dist/cli/args.d.ts.map +1 -1
  32. package/dist/cli/args.js +25 -3
  33. package/dist/cli/args.js.map +1 -1
  34. package/dist/cli/baseline.d.ts +1 -1
  35. package/dist/cli/baseline.d.ts.map +1 -1
  36. package/dist/cli/baseline.js.map +1 -1
  37. package/dist/cli/fix.d.ts +6 -6
  38. package/dist/cli/fix.d.ts.map +1 -1
  39. package/dist/cli/fix.js +1 -1
  40. package/dist/cli/fix.js.map +1 -1
  41. package/dist/cli/lint.d.ts.map +1 -1
  42. package/dist/cli/migrate.d.ts.map +1 -1
  43. package/dist/cli/run-main.d.ts +1 -1
  44. package/dist/cli/run-main.d.ts.map +1 -1
  45. package/dist/cli/run-main.js.map +1 -1
  46. package/dist/cli/shrink.d.ts.map +1 -1
  47. package/dist/cli.js +14 -0
  48. package/dist/cli.js.map +1 -1
  49. package/dist/config/decode.d.ts +3 -3
  50. package/dist/config/decode.d.ts.map +1 -1
  51. package/dist/config/decode.js +5 -2
  52. package/dist/config/decode.js.map +1 -1
  53. package/dist/config/load.d.ts.map +1 -1
  54. package/dist/config/load.js +11 -6
  55. package/dist/config/load.js.map +1 -1
  56. package/dist/config/resolve.d.ts.map +1 -1
  57. package/dist/config/resolve.js +8 -12
  58. package/dist/config/resolve.js.map +1 -1
  59. package/dist/config.d.ts +10 -11
  60. package/dist/config.d.ts.map +1 -1
  61. package/dist/config.js.map +1 -1
  62. package/dist/decoded.d.ts +4 -4
  63. package/dist/decoded.d.ts.map +1 -1
  64. package/dist/decoded.js.map +1 -1
  65. package/dist/engine/baseline-file.d.ts.map +1 -1
  66. package/dist/engine/evaluate.d.ts.map +1 -1
  67. package/dist/engine/run-rules.d.ts.map +1 -1
  68. package/dist/engine/session.d.ts.map +1 -1
  69. package/dist/engine/session.js.map +1 -1
  70. package/dist/engine/violation.d.ts +1 -1
  71. package/dist/engine/violation.d.ts.map +1 -1
  72. package/dist/engine/violation.js.map +1 -1
  73. package/dist/exceptions/registry.d.ts.map +1 -1
  74. package/dist/git/base.d.ts.map +1 -1
  75. package/dist/git/command.d.ts.map +1 -1
  76. package/dist/git/history.d.ts.map +1 -1
  77. package/dist/hooks/command.d.ts +13 -0
  78. package/dist/hooks/command.d.ts.map +1 -0
  79. package/dist/hooks/command.js +20 -0
  80. package/dist/hooks/command.js.map +1 -0
  81. package/dist/hooks/install.d.ts +11 -0
  82. package/dist/hooks/install.d.ts.map +1 -0
  83. package/dist/hooks/install.js +52 -0
  84. package/dist/hooks/install.js.map +1 -0
  85. package/dist/hooks/location.d.ts +9 -0
  86. package/dist/hooks/location.d.ts.map +1 -0
  87. package/dist/hooks/location.js +22 -0
  88. package/dist/hooks/location.js.map +1 -0
  89. package/dist/hooks/preCommit.d.ts +5 -0
  90. package/dist/hooks/preCommit.d.ts.map +1 -0
  91. package/dist/hooks/preCommit.js +50 -0
  92. package/dist/hooks/preCommit.js.map +1 -0
  93. package/dist/hooks/shim.d.ts +4 -0
  94. package/dist/hooks/shim.d.ts.map +1 -0
  95. package/dist/hooks/shim.js +26 -0
  96. package/dist/hooks/shim.js.map +1 -0
  97. package/dist/hooks/uninstall.d.ts +4 -0
  98. package/dist/hooks/uninstall.d.ts.map +1 -0
  99. package/dist/hooks/uninstall.js +19 -0
  100. package/dist/hooks/uninstall.js.map +1 -0
  101. package/dist/imports/browserGlobals.d.ts +3 -0
  102. package/dist/imports/browserGlobals.d.ts.map +1 -0
  103. package/dist/imports/browserGlobals.js +125 -0
  104. package/dist/imports/browserGlobals.js.map +1 -0
  105. package/dist/imports/browserNames.d.ts +2 -0
  106. package/dist/imports/browserNames.d.ts.map +1 -0
  107. package/dist/imports/browserNames.js +78 -0
  108. package/dist/imports/browserNames.js.map +1 -0
  109. package/dist/imports/fences/check.d.ts +4 -0
  110. package/dist/imports/fences/check.d.ts.map +1 -0
  111. package/dist/imports/fences/check.js +10 -0
  112. package/dist/imports/fences/check.js.map +1 -0
  113. package/dist/imports/fences/declarations.d.ts +5 -0
  114. package/dist/imports/fences/declarations.d.ts.map +1 -0
  115. package/dist/imports/fences/declarations.js +29 -0
  116. package/dist/imports/fences/declarations.js.map +1 -0
  117. package/dist/imports/fences/discover.d.ts +5 -0
  118. package/dist/imports/fences/discover.d.ts.map +1 -0
  119. package/dist/imports/fences/discover.js +41 -0
  120. package/dist/imports/fences/discover.js.map +1 -0
  121. package/dist/imports/fences/match.js.map +1 -1
  122. package/dist/imports/graph.js +2 -2
  123. package/dist/imports/graph.js.map +1 -1
  124. package/dist/imports/projects.js.map +1 -1
  125. package/dist/imports/runtime.d.ts +5 -0
  126. package/dist/imports/runtime.d.ts.map +1 -0
  127. package/dist/imports/runtime.js +32 -0
  128. package/dist/imports/runtime.js.map +1 -0
  129. package/dist/imports/workspace.d.ts +1 -1
  130. package/dist/imports/workspace.d.ts.map +1 -1
  131. package/dist/imports/workspace.js +5 -1
  132. package/dist/imports/workspace.js.map +1 -1
  133. package/dist/inventory/collect.d.ts.map +1 -1
  134. package/dist/inventory/collect.js +8 -1
  135. package/dist/inventory/collect.js.map +1 -1
  136. package/dist/inventory/filesystem.d.ts.map +1 -1
  137. package/dist/inventory/ignore-scope.d.ts.map +1 -1
  138. package/dist/inventory/trackedPaths.d.ts +3 -0
  139. package/dist/inventory/trackedPaths.d.ts.map +1 -0
  140. package/dist/inventory/trackedPaths.js +26 -0
  141. package/dist/inventory/trackedPaths.js.map +1 -0
  142. package/dist/inventory/walk.d.ts +4 -4
  143. package/dist/inventory/walk.d.ts.map +1 -1
  144. package/dist/inventory/walk.js +32 -12
  145. package/dist/inventory/walk.js.map +1 -1
  146. package/dist/naming/testName.d.ts.map +1 -1
  147. package/dist/report/plural.d.ts.map +1 -1
  148. package/dist/report/render.d.ts.map +1 -1
  149. package/dist/report/stale.d.ts.map +1 -1
  150. package/dist/rules/built-in.d.ts +2 -3
  151. package/dist/rules/built-in.d.ts.map +1 -1
  152. package/dist/rules/built-in.js +6 -0
  153. package/dist/rules/built-in.js.map +1 -1
  154. package/dist/rules/comments/kinds.d.ts.map +1 -1
  155. package/dist/rules/comments/max-per-file.js +1 -1
  156. package/dist/rules/comments/max-per-file.js.map +1 -1
  157. package/dist/rules/comments/pattern-rule.d.ts.map +1 -1
  158. package/dist/rules/comments/scan.d.ts.map +1 -1
  159. package/dist/rules/files/folderNames.js +1 -1
  160. package/dist/rules/files/folderNames.js.map +1 -1
  161. package/dist/rules/files/otherNames.d.ts.map +1 -1
  162. package/dist/rules/files/otherNames.js +1 -10
  163. package/dist/rules/files/otherNames.js.map +1 -1
  164. package/dist/rules/imports/bundleInputs.d.ts +7 -0
  165. package/dist/rules/imports/bundleInputs.d.ts.map +1 -0
  166. package/dist/rules/imports/bundleInputs.js +77 -0
  167. package/dist/rules/imports/bundleInputs.js.map +1 -0
  168. package/dist/rules/imports/fences.d.ts +1 -2
  169. package/dist/rules/imports/fences.d.ts.map +1 -1
  170. package/dist/rules/imports/fences.js +4 -14
  171. package/dist/rules/imports/fences.js.map +1 -1
  172. package/dist/rules/imports/manifest.d.ts +2 -0
  173. package/dist/rules/imports/manifest.d.ts.map +1 -0
  174. package/dist/rules/imports/manifest.js +98 -0
  175. package/dist/rules/imports/manifest.js.map +1 -0
  176. package/dist/rules/imports/noTestCode.d.ts +2 -0
  177. package/dist/rules/imports/noTestCode.d.ts.map +1 -0
  178. package/dist/rules/imports/noTestCode.js +17 -0
  179. package/dist/rules/imports/noTestCode.js.map +1 -0
  180. package/dist/rules/imports/runtime.d.ts +2 -0
  181. package/dist/rules/imports/runtime.d.ts.map +1 -0
  182. package/dist/rules/imports/runtime.js +41 -0
  183. package/dist/rules/imports/runtime.js.map +1 -0
  184. package/dist/rules/max-lines.d.ts +0 -1
  185. package/dist/rules/max-lines.d.ts.map +1 -1
  186. package/dist/rules/max-lines.js +3 -10
  187. package/dist/rules/max-lines.js.map +1 -1
  188. package/dist/rules/suppressions/biome/configs.d.ts.map +1 -1
  189. package/dist/rules/suppressions/biome/declarations.d.ts.map +1 -1
  190. package/dist/rules/suppressions/biome/json.d.ts.map +1 -1
  191. package/dist/rules/suppressions/biome/resolve.d.ts.map +1 -1
  192. package/dist/rules/suppressions/biome/weakenings.d.ts.map +1 -1
  193. package/dist/rules/suppressions/biome-overrides.d.ts.map +1 -1
  194. package/dist/rules/suppressions/biome-overrides.js +21 -4
  195. package/dist/rules/suppressions/biome-overrides.js.map +1 -1
  196. package/dist/rules/suppressions/biomeRecommended.js +2 -2
  197. package/dist/rules/suppressions/biomeRecommended.js.map +1 -1
  198. package/dist/rules/suppressions/directives.d.ts.map +1 -1
  199. package/dist/rules/suppressions/no-inline.js +1 -1
  200. package/dist/rules/suppressions/no-inline.js.map +1 -1
  201. package/dist/rules/suppressions/stylesheet-comments.d.ts.map +1 -1
  202. package/dist/rules/syntax.d.ts.map +1 -1
  203. package/dist/standard-schema.d.ts +1 -1
  204. package/dist/standard-schema.d.ts.map +1 -1
  205. package/dist/standard-schema.js +10 -2
  206. package/dist/standard-schema.js.map +1 -1
  207. package/dist/vitest.d.ts +1 -3
  208. package/dist/vitest.d.ts.map +1 -1
  209. package/dist/vitest.js +11 -6
  210. package/dist/vitest.js.map +1 -1
  211. package/docs/debt-and-hooks.md +90 -0
  212. package/docs/modules.md +253 -0
  213. package/docs/naming.md +126 -0
  214. package/docs/north-star.md +61 -0
  215. package/docs/roadmap.md +37 -0
  216. package/docs/rules.md +308 -0
  217. package/docs/tooling.md +120 -0
  218. package/package.json +8 -6
  219. package/src/biome/nativeEffectCall.ts +45 -0
  220. package/src/biome/recognizeDiagnostics.ts +67 -0
  221. package/src/biome/report.ts +24 -6
  222. package/src/biome/syntax.ts +36 -0
  223. package/src/cli/args.ts +29 -3
  224. package/src/cli/baseline.ts +3 -3
  225. package/src/cli/fix.ts +13 -13
  226. package/src/cli/run-main.ts +1 -1
  227. package/src/cli.ts +14 -0
  228. package/src/config/decode.ts +7 -2
  229. package/src/config/load.ts +15 -6
  230. package/src/config/resolve.ts +10 -15
  231. package/src/config.ts +8 -9
  232. package/src/decoded.ts +3 -3
  233. package/src/engine/session.ts +3 -3
  234. package/src/engine/violation.ts +2 -2
  235. package/src/hooks/command.ts +34 -0
  236. package/src/hooks/install.ts +69 -0
  237. package/src/hooks/location.ts +28 -0
  238. package/src/hooks/preCommit.ts +68 -0
  239. package/src/hooks/shim.ts +28 -0
  240. package/src/hooks/uninstall.ts +19 -0
  241. package/src/imports/browserGlobals.ts +142 -0
  242. package/src/imports/browserNames.ts +80 -0
  243. package/src/imports/fences/check.ts +12 -0
  244. package/src/imports/fences/declarations.ts +38 -0
  245. package/src/imports/fences/discover.ts +37 -0
  246. package/src/imports/graph.ts +2 -2
  247. package/src/imports/projects.ts +2 -2
  248. package/src/imports/runtime.ts +37 -0
  249. package/src/imports/workspace.ts +7 -2
  250. package/src/inventory/collect.ts +8 -1
  251. package/src/inventory/trackedPaths.ts +28 -0
  252. package/src/inventory/walk.ts +65 -36
  253. package/src/rules/built-in.ts +6 -0
  254. package/src/rules/comments/max-per-file.ts +1 -1
  255. package/src/rules/files/folderNames.ts +1 -1
  256. package/src/rules/files/otherNames.ts +1 -10
  257. package/src/rules/imports/bundleInputs.ts +94 -0
  258. package/src/rules/imports/fences.ts +4 -16
  259. package/src/rules/imports/manifest.ts +116 -0
  260. package/src/rules/imports/noTestCode.ts +18 -0
  261. package/src/rules/imports/runtime.ts +43 -0
  262. package/src/rules/max-lines.ts +4 -10
  263. package/src/rules/suppressions/biome-overrides.ts +31 -4
  264. package/src/rules/suppressions/biomeRecommended.ts +2 -2
  265. package/src/rules/suppressions/no-inline.ts +1 -1
  266. package/src/standard-schema.ts +11 -3
  267. package/src/vitest.ts +11 -6
@@ -1,11 +1,14 @@
1
+ import { relative, sep } from "node:path";
1
2
  import { Schema } from "effect";
3
+ import { ignoredTrackedPaths } from "#inventory/trackedPaths.ts";
4
+ import { recognizeDiagnostics } from "./recognizeDiagnostics.ts";
2
5
  import { type BiomeRun, runBiome } from "./run.ts";
3
6
 
4
- const Position = Schema.Struct({ line: Schema.Int });
7
+ const Position = Schema.Struct({ column: Schema.optionalKey(Schema.Int), line: Schema.Int });
5
8
 
6
9
  const Diagnostic = Schema.Struct({
7
10
  category: Schema.optionalKey(Schema.String),
8
- location: Schema.Struct({ path: Schema.optionalKey(Schema.String), start: Schema.optionalKey(Position) }),
11
+ location: Schema.Struct({ end: Schema.optionalKey(Position), path: Schema.optionalKey(Schema.String), start: Schema.optionalKey(Position) }),
9
12
  message: Schema.String,
10
13
  severity: Schema.String,
11
14
  });
@@ -21,11 +24,12 @@ export type Report = typeof Report.Type;
21
24
 
22
25
  const decodeReport = Schema.decodeUnknownOption(Schema.fromJsonString(Report));
23
26
 
24
- const failure = (args: readonly string[], run: BiomeRun): Error =>
25
- new Error(`biome ${args.join(" ")} exited ${run.code} without a report:\n${[run.stdout, run.stderr].join("\n").trim()}`);
27
+ function failure(args: readonly string[], run: BiomeRun): Error {
28
+ return new Error(`biome ${args.join(" ")} exited ${run.code} without a report:\n${[run.stdout, run.stderr].join("\n").trim()}`);
29
+ }
26
30
 
27
- export async function biomeReport(root: string, args: readonly string[]): Promise<Report> {
28
- const command = [...args, "--reporter=json", "--max-diagnostics=none", "."];
31
+ async function reportFor(root: string, args: readonly string[], paths: readonly string[]): Promise<Report> {
32
+ const command = [...args, "--reporter=json", "--max-diagnostics=none", ...paths];
29
33
  const run = await runBiome(root, command);
30
34
  const report = decodeReport(run.stdout.trim());
31
35
  if (report._tag === "None") {
@@ -33,3 +37,17 @@ export async function biomeReport(root: string, args: readonly string[]): Promis
33
37
  }
34
38
  return report.value;
35
39
  }
40
+
41
+ export async function biomeReport(root: string, args: readonly string[]): Promise<Report> {
42
+ const [report, tracked] = await Promise.all([reportFor(root, args, ["."]), ignoredTrackedPaths(root)]);
43
+ const paths = tracked
44
+ .map((path) => relative(root, path))
45
+ .filter((path) => !path.split(sep).some((part) => part === ".git" || part === "node_modules"));
46
+ const extra = paths.length === 0 ? undefined : await reportFor(root, [...args, "--vcs-enabled=false"], paths);
47
+ const diagnostics = [...report.diagnostics, ...(extra?.diagnostics ?? [])];
48
+ const unique = [...new Map(diagnostics.map((diagnostic) => [JSON.stringify(diagnostic), diagnostic])).values()];
49
+ return {
50
+ diagnostics: recognizeDiagnostics(root, unique),
51
+ summary: { changed: report.summary.changed + (extra?.summary.changed ?? 0) },
52
+ };
53
+ }
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+ import ts from "typescript";
3
+
4
+ export interface Syntax {
5
+ readonly checker: ts.TypeChecker;
6
+ readonly source: ts.SourceFile;
7
+ }
8
+
9
+ export function syntaxOf(path: string): Syntax {
10
+ const source = ts.createSourceFile(path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true);
11
+ const options: ts.CompilerOptions = { allowJs: true, noLib: true, noResolve: true };
12
+ const host = ts.createCompilerHost(options);
13
+ host.getSourceFile = (file) => (file === path ? source : undefined);
14
+ const program = ts.createProgram([path], options, host);
15
+ return { checker: program.getTypeChecker(), source };
16
+ }
17
+
18
+ export function offsetOf(source: ts.SourceFile, position: { readonly line: number; readonly column?: number }): number | undefined {
19
+ const start = source.getLineStarts()[position.line - 1];
20
+ if (start === undefined || position.column === undefined || position.column < 1) {
21
+ return undefined;
22
+ }
23
+ const line = source.text.slice(start, source.getLineStarts()[position.line] ?? source.text.length);
24
+ const characters = [...line];
25
+ return position.column <= characters.length + 1 ? start + characters.slice(0, position.column - 1).join("").length : undefined;
26
+ }
27
+
28
+ export function nodeAt(source: ts.SourceFile, start: number, end: number): ts.Node | undefined {
29
+ function visit(node: ts.Node): ts.Node | undefined {
30
+ if (node.getStart(source) > start || node.end < end) {
31
+ return undefined;
32
+ }
33
+ return ts.forEachChild(node, visit) ?? (node.getStart(source) === start && node.end === end ? node : undefined);
34
+ }
35
+ return visit(source);
36
+ }
package/src/cli/args.ts CHANGED
@@ -9,6 +9,9 @@ export type Command =
9
9
  | { readonly _tag: "BaselinePrune"; readonly config: string | undefined; readonly against: string | undefined }
10
10
  | { readonly _tag: "BaselineTighten"; readonly config: string | undefined; readonly staged: boolean }
11
11
  | { readonly _tag: "BaselineMigrate"; readonly config: string | undefined; readonly from: string | undefined }
12
+ | { readonly _tag: "HooksInstall"; readonly config: string | undefined; readonly force: boolean }
13
+ | { readonly _tag: "HooksUninstall" }
14
+ | { readonly _tag: "HooksPreCommit"; readonly config: string | undefined }
12
15
  | { readonly _tag: "Help" };
13
16
 
14
17
  export type Parsed = { readonly _tag: "Parsed"; readonly command: Command } | { readonly _tag: "Usage"; readonly problem: string };
@@ -20,6 +23,9 @@ export const USAGE = `Usage:
20
23
  quality baseline prune [--config <file>] [--against <ref>]
21
24
  quality baseline tighten [--config <file>] [--staged]
22
25
  quality baseline migrate [--config <file>] [--from <file>]
26
+ quality hooks install [--config <file>] [--force]
27
+ quality hooks uninstall
28
+ quality hooks pre-commit [--config <file>]
23
29
 
24
30
  lint Run every rule. Exits 1 on an error-level violation, a file over its baseline, a stale registry entry
25
31
  or a baseline entry for a rule that is off or unknown.
@@ -28,14 +34,19 @@ baseline write Record current error-level violations. Creates the baseline, o
28
34
  baseline prune Drop fixed debt, lower entries to what is left and carry entries to files git saw move. Never adds or raises an entry.
29
35
  baseline tighten Prune only the entries of files changed since HEAD, or with --staged, in the index.
30
36
  baseline migrate Move a baseline from the earlier JSON format (--from, quality/baseline.json by default) to the configured file.
37
+ hooks install Install the git pre-commit hook, shared by every worktree, that runs hooks pre-commit. Keeps a hook that is not quality's
38
+ unless --force replaces it.
39
+ hooks uninstall Remove quality's pre-commit hook. Keeps a hook that is not quality's.
40
+ hooks pre-commit What the hook runs: lint, then package.json scripts typecheck and quality:commit when present.
31
41
 
32
- --config <file> Config file; its directory is the repository root. Defaults to ./quality.config.ts.
42
+ --config <file> Config file; its directory is the repository root. Without this option, uses ./quality.config.ts when present, otherwise defaults.
33
43
  --against <ref> The branch the work merges into, whose merge base prune follows moves from. Defaults to origin/HEAD, then origin/main, then origin/master.
34
44
  Exit codes: 0 passed, 1 failed the gate, 2 could not run.`;
35
45
 
36
46
  const OPTIONS = {
37
47
  against: { type: "string" },
38
48
  config: { type: "string" },
49
+ force: { type: "boolean" },
39
50
  from: { type: "string" },
40
51
  help: { short: "h", type: "boolean" },
41
52
  rule: { multiple: true, type: "string" },
@@ -48,13 +59,18 @@ const usage = (problem: string): Parsed => ({ _tag: "Usage", problem });
48
59
  const ADOPT_REMOVED =
49
60
  "`quality adopt` was removed in 0.7.0. To record a rule's existing findings in the baseline, run `quality baseline write --rule <id>`; to apply the fixes Biome can make first, run `quality fix`.";
50
61
 
62
+ const SUBCOMMANDS: Readonly<Record<string, string>> = {
63
+ baseline: "baseline takes write, prune, tighten or migrate.",
64
+ hooks: "hooks takes install, uninstall or pre-commit.",
65
+ };
66
+
51
67
  const parsed = (command: Command): Parsed => ({ _tag: "Parsed", command });
52
68
 
53
69
  type Values = ReturnType<typeof parseArgs<{ options: typeof OPTIONS; allowPositionals: true }>>["values"];
54
70
 
55
71
  type Option = Exclude<keyof Values, "config" | "help">;
56
72
 
57
- const COMMAND_OPTIONS: readonly Option[] = ["against", "from", "rule", "staged", "warnings"];
73
+ const COMMAND_OPTIONS: readonly Option[] = ["against", "force", "from", "rule", "staged", "warnings"];
58
74
 
59
75
  const ACCEPTS: Readonly<Record<string, readonly Option[]>> = {
60
76
  "baseline migrate": ["from"],
@@ -62,6 +78,9 @@ const ACCEPTS: Readonly<Record<string, readonly Option[]>> = {
62
78
  "baseline tighten": ["staged"],
63
79
  "baseline write": ["rule"],
64
80
  fix: [],
81
+ "hooks install": ["force"],
82
+ "hooks pre-commit": [],
83
+ "hooks uninstall": [],
65
84
  lint: ["warnings"],
66
85
  };
67
86
 
@@ -91,6 +110,12 @@ const commandFor = (name: string, values: Values): Parsed => {
91
110
  return ref(values.against, { _tag: "BaselinePrune", against: values.against, config });
92
111
  case "baseline tighten":
93
112
  return parsed({ _tag: "BaselineTighten", config, staged: values.staged === true });
113
+ case "hooks install":
114
+ return parsed({ _tag: "HooksInstall", config, force: values.force === true });
115
+ case "hooks uninstall":
116
+ return parsed({ _tag: "HooksUninstall" });
117
+ case "hooks pre-commit":
118
+ return parsed({ _tag: "HooksPreCommit", config });
94
119
  default:
95
120
  return parsed({ _tag: "BaselineMigrate", config, from: values.from });
96
121
  }
@@ -114,7 +139,8 @@ export const parseCommand = (args: readonly string[]): Parsed => {
114
139
  return usage(ADOPT_REMOVED);
115
140
  }
116
141
  if (ACCEPTS[name] === undefined) {
117
- return usage(command === "baseline" && rest.length < 2 ? "baseline takes write, prune, tighten or migrate." : `unknown command: ${name}`);
142
+ const subcommands = SUBCOMMANDS[command];
143
+ return usage(subcommands !== undefined && rest.length < 2 ? subcommands : `unknown command: ${name}`);
118
144
  }
119
145
  const problem = misplaced(name, values);
120
146
  return problem === undefined ? commandFor(name, values) : usage(problem);
@@ -46,11 +46,11 @@ const changes = (pruned: Pruned): string => {
46
46
  return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
47
47
  };
48
48
 
49
- export const shrinkBaseline = <Requirements>(
49
+ export const shrinkBaseline = <TRequirements>(
50
50
  cwd: string,
51
51
  config: string | undefined,
52
- scopeOf: (session: Session) => Effect.Effect<Scope, SetupFailure, Requirements>,
53
- ): Effect.Effect<void, SetupFailure, FileSystem.FileSystem | Requirements> =>
52
+ scopeOf: (session: Session) => Effect.Effect<Scope, SetupFailure, TRequirements>,
53
+ ): Effect.Effect<void, SetupFailure, FileSystem.FileSystem | TRequirements> =>
54
54
  Effect.gen(function* () {
55
55
  const session = yield* openSession(cwd, config);
56
56
  if (session.baseline.raw === undefined) {
package/src/cli/fix.ts CHANGED
@@ -33,21 +33,21 @@ export function snapshotOf(root: string): Effect.Effect<ReadonlyMap<string, stri
33
33
  }).pipe(Effect.mapError((failure) => new SetupFailure({ message: failure.message })));
34
34
  }
35
35
 
36
- export interface Pass<Failure, Requirements> {
36
+ export interface Pass<TFailure, TRequirements> {
37
37
  readonly label: string;
38
- readonly run: Effect.Effect<number, Failure, Requirements>;
38
+ readonly run: Effect.Effect<number, TFailure, TRequirements>;
39
39
  }
40
40
 
41
- export interface Settling<Failure, Requirements> {
42
- readonly passes: ReadonlyArray<Pass<Failure, Requirements>>;
43
- readonly snapshot: Effect.Effect<ReadonlyMap<string, string>, Failure, Requirements>;
41
+ export interface Settling<TFailure, TRequirements> {
42
+ readonly passes: readonly Pass<TFailure, TRequirements>[];
43
+ readonly snapshot: Effect.Effect<ReadonlyMap<string, string>, TFailure, TRequirements>;
44
44
  }
45
45
 
46
46
  function changedBy(root: string, args: readonly string[]): Effect.Effect<number, SetupFailure> {
47
47
  return Effect.map(biome(root, args), (report) => report.summary.changed);
48
48
  }
49
49
 
50
- function biomePasses(root: string): ReadonlyArray<Pass<SetupFailure, never>> {
50
+ function biomePasses(root: string): readonly Pass<SetupFailure, never>[] {
51
51
  return [
52
52
  {
53
53
  label: "with fixes",
@@ -57,11 +57,11 @@ function biomePasses(root: string): ReadonlyArray<Pass<SetupFailure, never>> {
57
57
  ];
58
58
  }
59
59
 
60
- function round<Failure, Requirements>(
61
- passes: ReadonlyArray<Pass<Failure, Requirements>>,
60
+ function round<TFailure, TRequirements>(
61
+ passes: readonly Pass<TFailure, TRequirements>[],
62
62
  index: number,
63
- afterEachPass: Effect.Effect<void, Failure, Requirements>,
64
- ): Effect.Effect<number, Failure, Requirements> {
63
+ afterEachPass: Effect.Effect<void, TFailure, TRequirements>,
64
+ ): Effect.Effect<number, TFailure, TRequirements> {
65
65
  return Effect.gen(function* () {
66
66
  let total = 0;
67
67
  const parts: string[] = [];
@@ -91,14 +91,14 @@ function unsettled(changed: ReadonlySet<string>): SetupFailure {
91
91
  "Run `biome check --write --unsafe --verbose` to name them.",
92
92
  ];
93
93
  return new SetupFailure({
94
- message: [...named, "Two fixes probably undo each other there. Fix the code by hand, or turn off one of the rules."].join("\n"),
94
+ message: [...named, "Two fixes probably undo each other there. Repair the conflicting code by hand so both checks pass."].join("\n"),
95
95
  });
96
96
  }
97
97
 
98
- export function settle<Failure, Requirements>({
98
+ export function settle<TFailure, TRequirements>({
99
99
  passes,
100
100
  snapshot,
101
- }: Settling<Failure, Requirements>): Effect.Effect<void, Failure | SetupFailure, Requirements> {
101
+ }: Settling<TFailure, TRequirements>): Effect.Effect<void, TFailure | SetupFailure, TRequirements> {
102
102
  return Effect.gen(function* () {
103
103
  for (let index = 1; index < MAX_FIX_ROUNDS; index += 1) {
104
104
  if ((yield* round(passes, index, Effect.void)) === 0) {
@@ -47,7 +47,7 @@ export function quietOnClosedPipe(stream: EventEmitter): void {
47
47
 
48
48
  const services = NodeChildProcessSpawner.layer.pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)));
49
49
 
50
- export const runMain = <Value, Error>(program: Effect.Effect<Value, Error, FileSystem.FileSystem | Git>): void => {
50
+ export const runMain = <TValue, TError>(program: Effect.Effect<TValue, TError, FileSystem.FileSystem | Git>): void => {
51
51
  quietOnClosedPipe(process.stdout);
52
52
  quietOnClosedPipe(process.stderr);
53
53
  const explained = Effect.tapCause(program, (cause) => (Cause.hasInterruptsOnly(cause) ? Effect.void : explain(cause)));
package/src/cli.ts CHANGED
@@ -8,6 +8,9 @@ import { lint } from "#cli/lint.ts";
8
8
  import { migrateBaseline } from "#cli/migrate.ts";
9
9
  import { runMain } from "#cli/run-main.ts";
10
10
  import { pruneBaseline, tightenBaseline } from "#cli/shrink.ts";
11
+ import { installHook } from "#hooks/install.ts";
12
+ import { preCommitHook } from "#hooks/preCommit.ts";
13
+ import { uninstallHook } from "#hooks/uninstall.ts";
11
14
  import { SetupFailure } from "./failure.ts";
12
15
 
13
16
  const parsed = parseCommand(process.argv.slice(2));
@@ -32,6 +35,17 @@ const program = Effect.gen(function* () {
32
35
  return yield* tightenBaseline(process.cwd(), command.config, command.staged);
33
36
  case "BaselineMigrate":
34
37
  return yield* migrateBaseline(process.cwd(), command.config, command.from);
38
+ case "HooksInstall":
39
+ return yield* installHook({
40
+ config: command.config,
41
+ cwd: process.cwd(),
42
+ force: command.force,
43
+ launch: { options: process.execArgv, script: process.argv[1] ?? "" },
44
+ });
45
+ case "HooksUninstall":
46
+ return yield* uninstallHook(process.cwd());
47
+ case "HooksPreCommit":
48
+ return yield* preCommitHook(process.cwd(), command.config);
35
49
  }
36
50
  });
37
51
 
@@ -12,7 +12,7 @@ const isRule = (value: unknown): value is Rule =>
12
12
  && "configure" in value
13
13
  && typeof value.configure === "function";
14
14
 
15
- const Level = Schema.Literals(["error", "warn", "off"]);
15
+ const Level = Schema.Literal("error");
16
16
 
17
17
  const Setting = Schema.Union([Level, Schema.Struct({ level: Schema.optionalKey(Level), options: Schema.optionalKey(Schema.Unknown) })]);
18
18
 
@@ -22,12 +22,17 @@ const AdoptRemoved = Schema.Never.annotate({
22
22
  message: "removed in quality 0.7.0. Delete the key, and record a rule's existing findings with `quality baseline write --rule <id>`.",
23
23
  });
24
24
 
25
+ const PreCommitRemoved = Schema.Never.annotate({
26
+ message:
27
+ "removed. Put repository checks in the package.json quality:commit script; the hook also runs typecheck when present. Use `quality baseline tighten --staged` explicitly to lower staged debt.",
28
+ });
29
+
25
30
  const ConfigInput = Schema.Struct({
26
31
  adopt: Schema.optionalKey(AdoptRemoved),
27
32
  baseline: Schema.optionalKey(Schema.String),
28
- exclude: Schema.optionalKey(Schema.Array(Schema.String)),
29
33
  extensions: Schema.optionalKey(Schema.Array(Schema.String)),
30
34
  local: Schema.optionalKey(Schema.Array(LocalRule)),
35
+ preCommit: Schema.optionalKey(PreCommitRemoved),
31
36
  registry: Schema.optionalKey(Schema.String),
32
37
  rules: Schema.optionalKey(Schema.Record(Schema.String, Setting)),
33
38
  sources: Schema.optionalKey(Schema.Array(Schema.String)),
@@ -1,4 +1,4 @@
1
- import { dirname, resolve } from "node:path";
1
+ import { basename, dirname, resolve } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { Effect, FileSystem } from "effect";
4
4
  import { validOrFail } from "#decoded.ts";
@@ -34,7 +34,7 @@ const importDefault = (file: string): Effect.Effect<unknown, SetupFailure> =>
34
34
  const resolved = (root: string, file: string, config: ConfigInput, rules: ResolvedRules): ResolvedConfig => ({
35
35
  ...rules,
36
36
  baseline: config.baseline ?? "quality/baseline.jsonl",
37
- exclude: config.exclude ?? [],
37
+ exclude: [],
38
38
  extensions: config.extensions ?? DEFAULT_EXTENSIONS,
39
39
  file,
40
40
  registry: config.registry ?? "quality/registry.json",
@@ -46,11 +46,20 @@ export const loadConfig = (cwd: string, path: string | undefined): Effect.Effect
46
46
  Effect.gen(function* () {
47
47
  const fs = yield* FileSystem.FileSystem;
48
48
  const file = resolve(cwd, path ?? CONFIG_FILE);
49
- const present = yield* Effect.orElseSucceed(fs.exists(file), () => false);
50
- if (!present) {
51
- return yield* new SetupFailure({ message: `no config at ${file}. Create ${CONFIG_FILE} exporting defineConfig({ ... }), or pass --config.` });
49
+ const entries = yield* fs
50
+ .readDirectory(dirname(file))
51
+ .pipe(
52
+ Effect.catchTag("PlatformError", (error) =>
53
+ error.reason._tag === "NotFound"
54
+ ? Effect.succeed<string[]>([])
55
+ : Effect.fail(new SetupFailure({ message: `cannot inspect ${file}: ${error.message}` })),
56
+ ),
57
+ );
58
+ const present = entries.includes(basename(file));
59
+ if (!present && path !== undefined) {
60
+ return yield* new SetupFailure({ message: `no config at ${file}. Check the --config path.` });
52
61
  }
53
- const loaded = yield* importDefault(file);
62
+ const loaded = present ? yield* importDefault(file) : {};
54
63
  const config = yield* validOrFail(file, yield* Effect.promise(() => decodeConfig(loaded)));
55
64
  const rules = yield* validOrFail(file, yield* resolveRules(config));
56
65
  return resolved(dirname(file), file, config, rules);
@@ -1,5 +1,4 @@
1
1
  import { Effect } from "effect";
2
- import type { Level } from "#config.ts";
3
2
  import type { Decoded } from "#decoded.ts";
4
3
  import type { ActiveRule } from "#engine/run-rules.ts";
5
4
  import type { RuleIndex } from "#engine/violation.ts";
@@ -15,8 +14,6 @@ type Setting = NonNullable<ConfigInput["rules"]>[string];
15
14
 
16
15
  const messageOf = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
17
16
 
18
- const levelOf = (setting: Setting | undefined): Level => (typeof setting === "string" ? setting : (setting?.level ?? "error"));
19
-
20
17
  const optionsOf = (setting: Setting | undefined): unknown => (typeof setting === "object" ? setting.options : undefined);
21
18
 
22
19
  const duplicateIssues = (rules: readonly Rule[]): readonly string[] =>
@@ -24,23 +21,21 @@ const duplicateIssues = (rules: readonly Rule[]): readonly string[] =>
24
21
  rules.findIndex((other) => other.id === rule.id) === index ? [] : [`local: rule id "${rule.id}" is already defined`],
25
22
  );
26
23
 
27
- const activate = (rule: Rule, setting: Setting | undefined): Effect.Effect<Decoded<ActiveRule | undefined>> => {
28
- const level = levelOf(setting);
29
- if (level === "off") {
30
- return Effect.succeed({ _tag: "Valid", value: undefined });
31
- }
32
- return Effect.tryPromise({ catch: messageOf, try: () => rule.configure(optionsOf(setting)) }).pipe(
24
+ const activate = (rule: Rule, setting: Setting | undefined): Effect.Effect<Decoded<ActiveRule>> =>
25
+ Effect.tryPromise({ catch: messageOf, try: () => rule.configure(optionsOf(setting)) }).pipe(
33
26
  Effect.map(
34
- (configured): Decoded<ActiveRule | undefined> =>
27
+ (configured): Decoded<ActiveRule> =>
35
28
  configured._tag === "Invalid"
36
29
  ? { _tag: "Invalid", issues: configured.issues.map((issue) => `rules.${rule.id}.options: ${issue}`) }
37
- : { _tag: "Valid", value: { check: configured.check, description: rule.description, family: rule.family === true, id: rule.id, level } },
30
+ : {
31
+ _tag: "Valid",
32
+ value: { check: configured.check, description: rule.description, family: rule.family === true, id: rule.id, level: "error" },
33
+ },
38
34
  ),
39
35
  Effect.catch((failure: string) =>
40
- Effect.succeed<Decoded<ActiveRule | undefined>>({ _tag: "Invalid", issues: [`rules.${rule.id}.options: validation threw: ${failure}`] }),
36
+ Effect.succeed<Decoded<ActiveRule>>({ _tag: "Invalid", issues: [`rules.${rule.id}.options: validation threw: ${failure}`] }),
41
37
  ),
42
38
  );
43
- };
44
39
 
45
40
  export const resolveRules = (config: ConfigInput): Effect.Effect<Decoded<ResolvedRules>> =>
46
41
  Effect.gen(function* () {
@@ -58,8 +53,8 @@ export const resolveRules = (config: ConfigInput): Effect.Effect<Decoded<Resolve
58
53
  if (issues.length > 0) {
59
54
  return { _tag: "Invalid", issues };
60
55
  }
61
- const active = activated.flatMap((result) => (result._tag === "Valid" && result.value !== undefined ? [result.value] : []));
62
- const levels = new Map(rules.map((rule) => [rule.id, levelOf(settings[rule.id])]));
56
+ const active = activated.flatMap((result) => (result._tag === "Valid" ? [result.value] : []));
57
+ const levels = new Map(rules.map((rule) => [rule.id, "error" as const]));
63
58
  const unregistrable = new Set(rules.filter((rule) => rule.registrable === false).map((rule) => rule.id));
64
59
  return { _tag: "Valid", value: { active, families, levels, unregistrable } };
65
60
  });
package/src/config.ts CHANGED
@@ -3,24 +3,23 @@ import type { Rule } from "./rule.ts";
3
3
 
4
4
  export type Level = "error" | "warn" | "off";
5
5
 
6
- export type RuleSetting<Input> = Level | { readonly level?: Level; readonly options?: Input };
6
+ export type RuleSetting<TInput> = "error" | { readonly level?: "error"; readonly options?: TInput };
7
7
 
8
- type InputOf<Candidate> = Candidate extends Rule<string, infer Input> ? Input : never;
8
+ type InputOf<TCandidate> = TCandidate extends Rule<string, infer TInput> ? TInput : never;
9
9
 
10
- export type RuleSettings<Rules extends readonly Rule[]> = {
11
- readonly [Candidate in Rules[number] as Candidate["id"]]?: RuleSetting<InputOf<Candidate>>;
10
+ export type RuleSettings<TRules extends readonly Rule[]> = {
11
+ readonly [TCandidate in TRules[number] as TCandidate["id"]]?: RuleSetting<InputOf<TCandidate>>;
12
12
  };
13
13
 
14
14
  export type BuiltInRules = typeof builtInRules;
15
15
 
16
- export interface QualityConfig<Local extends readonly Rule[] = readonly Rule[]> {
16
+ export interface QualityConfig<TLocal extends readonly Rule[] = readonly Rule[]> {
17
17
  readonly baseline?: string;
18
- readonly exclude?: readonly string[];
19
18
  readonly extensions?: readonly string[];
20
- readonly local?: Local;
19
+ readonly local?: TLocal;
21
20
  readonly registry?: string;
22
- readonly rules?: RuleSettings<readonly [...BuiltInRules, ...Local]>;
21
+ readonly rules?: RuleSettings<readonly [...BuiltInRules, ...TLocal]>;
23
22
  readonly sources?: readonly string[];
24
23
  }
25
24
 
26
- export const defineConfig = <const Local extends readonly Rule[] = readonly []>(config: QualityConfig<Local>): QualityConfig<Local> => config;
25
+ export const defineConfig = <const TLocal extends readonly Rule[] = readonly []>(config: QualityConfig<TLocal>): QualityConfig<TLocal> => config;
package/src/decoded.ts CHANGED
@@ -2,14 +2,14 @@ import { Effect } from "effect";
2
2
  import { SetupFailure } from "./failure.ts";
3
3
  import { describeIssue, type StandardSchemaV1 } from "./standard-schema.ts";
4
4
 
5
- export type Decoded<Value> = { readonly _tag: "Valid"; readonly value: Value } | { readonly _tag: "Invalid"; readonly issues: readonly string[] };
5
+ export type Decoded<TValue> = { readonly _tag: "Valid"; readonly value: TValue } | { readonly _tag: "Invalid"; readonly issues: readonly string[] };
6
6
 
7
- export const decodeWith = async <Output>(schema: StandardSchemaV1<unknown, Output>, value: unknown): Promise<Decoded<Output>> => {
7
+ export const decodeWith = async <TOutput>(schema: StandardSchemaV1<unknown, TOutput>, value: unknown): Promise<Decoded<TOutput>> => {
8
8
  const result = await schema["~standard"].validate(value);
9
9
  return result.issues === undefined ? { _tag: "Valid", value: result.value } : { _tag: "Invalid", issues: result.issues.map(describeIssue) };
10
10
  };
11
11
 
12
- export const validOrFail = <Value>(file: string, decoded: Decoded<Value>): Effect.Effect<Value, SetupFailure> =>
12
+ export const validOrFail = <TValue>(file: string, decoded: Decoded<TValue>): Effect.Effect<TValue, SetupFailure> =>
13
13
  decoded._tag === "Valid"
14
14
  ? Effect.succeed(decoded.value)
15
15
  : Effect.fail(new SetupFailure({ message: `${file} is invalid:\n${decoded.issues.map((issue) => ` - ${issue}`).join("\n")}` }));
@@ -18,9 +18,9 @@ export interface Session {
18
18
  readonly violations: readonly Violation[];
19
19
  }
20
20
 
21
- const setup = <Value, Requirements>(
22
- effect: Effect.Effect<Value, FilesystemFailure, Requirements>,
23
- ): Effect.Effect<Value, SetupFailure, Requirements> => Effect.mapError(effect, (failure) => new SetupFailure({ message: failure.message }));
21
+ const setup = <TValue, TRequirements>(
22
+ effect: Effect.Effect<TValue, FilesystemFailure, TRequirements>,
23
+ ): Effect.Effect<TValue, SetupFailure, TRequirements> => Effect.mapError(effect, (failure) => new SetupFailure({ message: failure.message }));
24
24
 
25
25
  export const scan = (config: ResolvedConfig): Effect.Effect<Pick<Session, "inventory" | "violations">, SetupFailure, FileSystem.FileSystem> =>
26
26
  Effect.gen(function* () {
@@ -13,8 +13,8 @@ export const keyOf = (rule: string, file: string): string => `${rule}\u0000${fil
13
13
  export const byLocation = (left: Violation, right: Violation): number =>
14
14
  left.rule.localeCompare(right.rule) || left.file.localeCompare(right.file) || (left.line ?? 0) - (right.line ?? 0);
15
15
 
16
- export const groupBy = <Item>(items: readonly Item[], key: (item: Item) => string): ReadonlyMap<string, readonly Item[]> => {
17
- const groups = new Map<string, Item[]>();
16
+ export const groupBy = <TItem>(items: readonly TItem[], key: (item: TItem) => string): ReadonlyMap<string, readonly TItem[]> => {
17
+ const groups = new Map<string, TItem[]>();
18
18
  for (const item of items) {
19
19
  const group = groups.get(key(item));
20
20
  if (group === undefined) {
@@ -0,0 +1,34 @@
1
+ import { isAbsolute, relative, sep } from "node:path";
2
+
3
+ export interface Launch {
4
+ readonly options: readonly string[];
5
+ readonly script: string;
6
+ }
7
+
8
+ export interface HookCommand {
9
+ readonly args: readonly string[];
10
+ readonly launcher: string;
11
+ readonly runner: readonly string[];
12
+ }
13
+
14
+ const BIN = "node_modules/.bin/quality";
15
+ const SAFE = /^[\w@%+=:,./-]+$/u;
16
+
17
+ // A quality run from a file in the repository, such as its own source, is run the same way by the hook; an installed one through its bin.
18
+ export function hookCommand(root: string, launch: Launch, config: string | undefined): HookCommand {
19
+ const script = relative(root, launch.script);
20
+ const installed = script.startsWith("..") || isAbsolute(script) || script.split(sep).includes("node_modules");
21
+ return {
22
+ args: ["hooks", "pre-commit", ...(config === undefined ? [] : ["--config", config])],
23
+ launcher: installed ? BIN : script,
24
+ runner: installed ? [] : ["node", ...launch.options],
25
+ };
26
+ }
27
+
28
+ export function commandWords(command: HookCommand): readonly string[] {
29
+ return [...command.runner, command.launcher, ...command.args];
30
+ }
31
+
32
+ export function shellWords(words: readonly string[]): string {
33
+ return words.map((word) => (SAFE.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`)).join(" ");
34
+ }
@@ -0,0 +1,69 @@
1
+ import { basename, dirname, join, relative, resolve } from "node:path";
2
+ import { Console, Effect, FileSystem } from "effect";
3
+ import { CONFIG_FILE } from "#config/file.ts";
4
+ import { loadConfig } from "#config/load.ts";
5
+ import { SetupFailure } from "#failure.ts";
6
+ import { readOptionalText } from "#inventory/filesystem.ts";
7
+ import { commandWords, hookCommand, type Launch, shellWords } from "./command.ts";
8
+ import { type HookLocation, hookLocation } from "./location.ts";
9
+ import { hookShim, isHookShim } from "./shim.ts";
10
+
11
+ export interface HookInstall {
12
+ readonly config: string | undefined;
13
+ readonly cwd: string;
14
+ readonly force: boolean;
15
+ readonly launch: Launch;
16
+ }
17
+
18
+ function failed(error: { readonly message: string }): SetupFailure {
19
+ return new SetupFailure({ message: error.message });
20
+ }
21
+
22
+ const realPath = Effect.fnUntraced(function* (path: string) {
23
+ const fs = yield* FileSystem.FileSystem;
24
+ return yield* Effect.mapError(fs.realPath(path), failed);
25
+ });
26
+
27
+ const write = Effect.fnUntraced(function* (file: string, text: string) {
28
+ const fs = yield* FileSystem.FileSystem;
29
+ yield* fs.makeDirectory(dirname(file), { recursive: true }).pipe(
30
+ Effect.andThen(fs.writeFileString(file, text)),
31
+ Effect.andThen(fs.chmod(file, 0o755)),
32
+ Effect.mapError((error) => new SetupFailure({ message: `cannot write ${file}: ${error.message}` })),
33
+ );
34
+ });
35
+
36
+ function elsewhere(location: HookLocation): Effect.Effect<void> {
37
+ return location.hooksPath === undefined || resolve(location.toplevel, location.hooksPath) === dirname(location.file)
38
+ ? Effect.void
39
+ : Console.error(
40
+ `note: core.hooksPath is ${location.hooksPath}, so git runs the pre-commit hook there; ${location.file} runs only when that hook calls it.`,
41
+ );
42
+ }
43
+
44
+ export const installHook = Effect.fn("Hooks.installHook")(function* (install: HookInstall) {
45
+ const config = yield* loadConfig(install.cwd, install.config);
46
+ const location = yield* hookLocation(config.root);
47
+ const root = yield* realPath(config.root);
48
+ const script = join(yield* realPath(dirname(install.launch.script)), basename(install.launch.script));
49
+ const name = basename(config.file);
50
+ const command = hookCommand(root, { options: install.launch.options, script }, name === CONFIG_FILE ? undefined : name);
51
+ const directory = relative(location.toplevel, root);
52
+ const shim = hookShim(directory, command);
53
+ const words = shellWords(commandWords(command));
54
+ const existing = yield* Effect.mapError(readOptionalText(location.file), failed);
55
+ const foreign = existing !== undefined && !isHookShim(existing);
56
+ if (foreign && !install.force) {
57
+ const call = `${directory === "" ? "" : `cd ${shellWords([directory])} && `}${words}`;
58
+ yield* Console.error(
59
+ `quality: kept ${location.file}: it is not quality's pre-commit hook, so quality does not check commits.\nhelp: call \`${call}\` from that hook, or replace it with \`quality hooks install --force\`.`,
60
+ );
61
+ } else if (existing === shim) {
62
+ yield* Console.log(`quality: the pre-commit hook at ${location.file} is up to date.`);
63
+ } else {
64
+ yield* write(location.file, shim);
65
+ const done = foreign ? `replaced ${location.file}, which was not quality's hook` : `installed the pre-commit hook at ${location.file}`;
66
+ yield* Console.log(`quality: ${done}; each commit runs \`${words}\`.`);
67
+ }
68
+ yield* elsewhere(location);
69
+ });
@@ -0,0 +1,28 @@
1
+ import { join } from "node:path";
2
+ import { Effect } from "effect";
3
+ import { SetupFailure } from "#failure.ts";
4
+ import { git } from "#git/command.ts";
5
+
6
+ export interface HookLocation {
7
+ readonly file: string;
8
+ readonly hooksPath: string | undefined;
9
+ readonly toplevel: string;
10
+ }
11
+
12
+ // Hooks live in the common git directory, which every worktree of the repository shares.
13
+ export const hookLocation = Effect.fn("Hooks.hookLocation")(function* (cwd: string) {
14
+ const paths = yield* git(cwd, ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir"]);
15
+ const [toplevel, common] = paths.stdout.trim().split("\n");
16
+ if (paths.code !== 0 || toplevel === undefined || common === undefined) {
17
+ return yield* new SetupFailure({
18
+ message: `${cwd} is not in a git work tree, so there is no hook to manage.\nhelp: run quality from inside the repository, or run \`git init\` first.`,
19
+ });
20
+ }
21
+ const hooksPath = yield* git(cwd, ["config", "--type=path", "--get", "core.hooksPath"]);
22
+ const location: HookLocation = {
23
+ file: join(common, "hooks", "pre-commit"),
24
+ hooksPath: hooksPath.code === 0 ? hooksPath.stdout.trim() : undefined,
25
+ toplevel,
26
+ };
27
+ return location;
28
+ });