@shivaedev/quality 0.17.0 → 0.18.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.
- package/CHANGELOG.md +11 -0
- package/README.md +39 -3
- package/dist/baseline/compare.d.ts.map +1 -1
- package/dist/baseline/format.d.ts.map +1 -1
- package/dist/baseline/legacy.d.ts.map +1 -1
- package/dist/baseline/rewrite.d.ts.map +1 -1
- package/dist/baseline/update.d.ts.map +1 -1
- package/dist/baseline/update.js.map +1 -1
- package/dist/biome/run.js.map +1 -1
- package/dist/cli/args.d.ts +10 -1
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +24 -2
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/baseline.d.ts.map +1 -1
- package/dist/cli/lint.d.ts.map +1 -1
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/run-main.d.ts.map +1 -1
- package/dist/cli/shrink.d.ts.map +1 -1
- package/dist/cli.js +14 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/decode.d.ts +4 -0
- package/dist/config/decode.d.ts.map +1 -1
- package/dist/config/decode.js +1 -0
- package/dist/config/decode.js.map +1 -1
- package/dist/config/load.d.ts +2 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +1 -0
- package/dist/config/load.js.map +1 -1
- package/dist/config/resolve.d.ts.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/decoded.d.ts.map +1 -1
- package/dist/engine/baseline-file.d.ts.map +1 -1
- package/dist/engine/evaluate.d.ts.map +1 -1
- package/dist/engine/run-rules.d.ts.map +1 -1
- package/dist/engine/session.d.ts.map +1 -1
- package/dist/engine/violation.d.ts.map +1 -1
- package/dist/exceptions/registry.d.ts.map +1 -1
- package/dist/git/base.d.ts.map +1 -1
- package/dist/git/command.d.ts.map +1 -1
- package/dist/git/history.d.ts.map +1 -1
- package/dist/hooks/command.d.ts +13 -0
- package/dist/hooks/command.d.ts.map +1 -0
- package/dist/hooks/command.js +20 -0
- package/dist/hooks/command.js.map +1 -0
- package/dist/hooks/install.d.ts +11 -0
- package/dist/hooks/install.d.ts.map +1 -0
- package/dist/hooks/install.js +52 -0
- package/dist/hooks/install.js.map +1 -0
- package/dist/hooks/location.d.ts +9 -0
- package/dist/hooks/location.d.ts.map +1 -0
- package/dist/hooks/location.js +22 -0
- package/dist/hooks/location.js.map +1 -0
- package/dist/hooks/preCommit.d.ts +5 -0
- package/dist/hooks/preCommit.d.ts.map +1 -0
- package/dist/hooks/preCommit.js +60 -0
- package/dist/hooks/preCommit.js.map +1 -0
- package/dist/hooks/shim.d.ts +4 -0
- package/dist/hooks/shim.d.ts.map +1 -0
- package/dist/hooks/shim.js +26 -0
- package/dist/hooks/shim.js.map +1 -0
- package/dist/hooks/uninstall.d.ts +4 -0
- package/dist/hooks/uninstall.d.ts.map +1 -0
- package/dist/hooks/uninstall.js +19 -0
- package/dist/hooks/uninstall.js.map +1 -0
- package/dist/imports/fences/match.js.map +1 -1
- package/dist/inventory/collect.d.ts.map +1 -1
- package/dist/inventory/filesystem.d.ts.map +1 -1
- package/dist/inventory/ignore-scope.d.ts.map +1 -1
- package/dist/inventory/walk.d.ts.map +1 -1
- package/dist/inventory/walk.js.map +1 -1
- package/dist/naming/testName.d.ts.map +1 -1
- package/dist/report/plural.d.ts.map +1 -1
- package/dist/report/render.d.ts.map +1 -1
- package/dist/report/stale.d.ts.map +1 -1
- package/dist/rules/comments/kinds.d.ts.map +1 -1
- package/dist/rules/comments/pattern-rule.d.ts.map +1 -1
- package/dist/rules/comments/scan.d.ts.map +1 -1
- package/dist/rules/suppressions/biome/configs.d.ts.map +1 -1
- package/dist/rules/suppressions/biome/declarations.d.ts.map +1 -1
- package/dist/rules/suppressions/biome/json.d.ts.map +1 -1
- package/dist/rules/suppressions/biome/resolve.d.ts.map +1 -1
- package/dist/rules/suppressions/biome/weakenings.d.ts.map +1 -1
- package/dist/rules/suppressions/directives.d.ts.map +1 -1
- package/dist/rules/suppressions/stylesheet-comments.d.ts.map +1 -1
- package/dist/rules/syntax.d.ts.map +1 -1
- package/dist/standard-schema.d.ts.map +1 -1
- package/dist/vitest.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/args.ts +28 -2
- package/src/cli.ts +14 -0
- package/src/config/decode.ts +1 -0
- package/src/config/load.ts +3 -0
- package/src/config.ts +6 -0
- package/src/hooks/command.ts +34 -0
- package/src/hooks/install.ts +69 -0
- package/src/hooks/location.ts +28 -0
- package/src/hooks/preCommit.ts +77 -0
- package/src/hooks/shim.ts +28 -0
- package/src/hooks/uninstall.ts +19 -0
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,6 +34,10 @@ 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: baseline tighten for staged files when preCommit.tighten is set, lint, then each preCommit.run command.
|
|
31
41
|
|
|
32
42
|
--config <file> Config file; its directory is the repository root. Defaults to ./quality.config.ts.
|
|
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.
|
|
@@ -36,6 +46,7 @@ Exit codes: 0 passed, 1 failed the gate, 2 could not run.`;
|
|
|
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
|
-
|
|
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);
|
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
|
|
package/src/config/decode.ts
CHANGED
|
@@ -28,6 +28,7 @@ const ConfigInput = Schema.Struct({
|
|
|
28
28
|
exclude: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
29
29
|
extensions: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
30
30
|
local: Schema.optionalKey(Schema.Array(LocalRule)),
|
|
31
|
+
preCommit: Schema.optionalKey(Schema.Struct({ run: Schema.optionalKey(Schema.Array(Schema.String)), tighten: Schema.optionalKey(Schema.Boolean) })),
|
|
31
32
|
registry: Schema.optionalKey(Schema.String),
|
|
32
33
|
rules: Schema.optionalKey(Schema.Record(Schema.String, Setting)),
|
|
33
34
|
sources: Schema.optionalKey(Schema.Array(Schema.String)),
|
package/src/config/load.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dirname, resolve } from "node:path";
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
3
|
import { Effect, FileSystem } from "effect";
|
|
4
|
+
import type { PreCommit } from "#config.ts";
|
|
4
5
|
import { validOrFail } from "#decoded.ts";
|
|
5
6
|
import { SetupFailure } from "#failure.ts";
|
|
6
7
|
import type { InventoryScope } from "#inventory/collect.ts";
|
|
@@ -13,6 +14,7 @@ const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs"
|
|
|
13
14
|
export interface ResolvedConfig extends ResolvedRules, InventoryScope {
|
|
14
15
|
readonly baseline: string;
|
|
15
16
|
readonly file: string;
|
|
17
|
+
readonly preCommit: Required<PreCommit>;
|
|
16
18
|
readonly registry: string;
|
|
17
19
|
readonly root: string;
|
|
18
20
|
}
|
|
@@ -37,6 +39,7 @@ const resolved = (root: string, file: string, config: ConfigInput, rules: Resolv
|
|
|
37
39
|
exclude: config.exclude ?? [],
|
|
38
40
|
extensions: config.extensions ?? DEFAULT_EXTENSIONS,
|
|
39
41
|
file,
|
|
42
|
+
preCommit: { run: config.preCommit?.run ?? [], tighten: config.preCommit?.tighten ?? false },
|
|
40
43
|
registry: config.registry ?? "quality/registry.json",
|
|
41
44
|
root,
|
|
42
45
|
sources: config.sources ?? ["."],
|
package/src/config.ts
CHANGED
|
@@ -13,11 +13,17 @@ export type RuleSettings<Rules extends readonly Rule[]> = {
|
|
|
13
13
|
|
|
14
14
|
export type BuiltInRules = typeof builtInRules;
|
|
15
15
|
|
|
16
|
+
export interface PreCommit {
|
|
17
|
+
readonly run?: readonly string[];
|
|
18
|
+
readonly tighten?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export interface QualityConfig<Local extends readonly Rule[] = readonly Rule[]> {
|
|
17
22
|
readonly baseline?: string;
|
|
18
23
|
readonly exclude?: readonly string[];
|
|
19
24
|
readonly extensions?: readonly string[];
|
|
20
25
|
readonly local?: Local;
|
|
26
|
+
readonly preCommit?: PreCommit;
|
|
21
27
|
readonly registry?: string;
|
|
22
28
|
readonly rules?: RuleSettings<readonly [...BuiltInRules, ...Local]>;
|
|
23
29
|
readonly sources?: readonly string[];
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Console, Effect, FileSystem } from "effect";
|
|
3
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
4
|
+
import type { Scope } from "#baseline/update.ts";
|
|
5
|
+
import { shrinkBaseline } from "#cli/baseline.ts";
|
|
6
|
+
import { lint } from "#cli/lint.ts";
|
|
7
|
+
import { loadConfig, type ResolvedConfig } from "#config/load.ts";
|
|
8
|
+
import { GateFailed, SetupFailure } from "#failure.ts";
|
|
9
|
+
import { gitOrFail } from "#git/command.ts";
|
|
10
|
+
import { changesSince } from "#git/history.ts";
|
|
11
|
+
|
|
12
|
+
const COMMIT_AGAIN =
|
|
13
|
+
"help: fix what the output above reports, then commit again; `quality hooks pre-commit` repeats these checks without committing.";
|
|
14
|
+
|
|
15
|
+
// A partly staged file is counted as it is on disk, which is not what the commit holds, so its entries wait for a later commit.
|
|
16
|
+
const stagedScope = Effect.fnUntraced(function* (config: ResolvedConfig) {
|
|
17
|
+
const unstaged = (yield* changesSince(config.root, [])).files;
|
|
18
|
+
if (unstaged.has(config.baseline)) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const staged = yield* changesSince(config.root, ["--cached"]);
|
|
22
|
+
const held = new Set([...unstaged, ...[...staged.moves].filter(([, to]) => unstaged.has(to)).map(([from]) => from)]);
|
|
23
|
+
const scope: Scope = {
|
|
24
|
+
files: new Set([...staged.files].filter((file) => !held.has(file))),
|
|
25
|
+
moves: new Map([...staged.moves].filter(([from]) => !held.has(from))),
|
|
26
|
+
};
|
|
27
|
+
return scope;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const tighten = Effect.fnUntraced(function* (cwd: string, path: string | undefined, config: ResolvedConfig) {
|
|
31
|
+
const scope = yield* stagedScope(config);
|
|
32
|
+
if (scope === undefined) {
|
|
33
|
+
return yield* Console.error(
|
|
34
|
+
`quality: left ${config.baseline} as it is, because it has unstaged changes.\nhelp: stage or discard them to tighten it.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
yield* shrinkBaseline(cwd, path, () => Effect.succeed(scope));
|
|
38
|
+
const fs = yield* FileSystem.FileSystem;
|
|
39
|
+
if (yield* Effect.orElseSucceed(fs.exists(join(config.root, config.baseline)), () => false)) {
|
|
40
|
+
yield* gitOrFail(config.root, ["add", "--", config.baseline]);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const shell = Effect.fnUntraced(
|
|
45
|
+
function* (root: string, command: string) {
|
|
46
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
47
|
+
const child = yield* spawner.spawn(
|
|
48
|
+
ChildProcess.make("sh", ["-c", command], { cwd: root, stderr: "inherit", stdin: "inherit", stdout: "inherit" }),
|
|
49
|
+
);
|
|
50
|
+
return Number(yield* child.exitCode);
|
|
51
|
+
},
|
|
52
|
+
Effect.scoped,
|
|
53
|
+
Effect.mapError((error) => new SetupFailure({ message: `cannot run a pre-commit command: ${error.message}` })),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export const preCommitHook = Effect.fn("Hooks.preCommitHook")(function* (cwd: string, path: string | undefined) {
|
|
57
|
+
const config = yield* loadConfig(cwd, path);
|
|
58
|
+
if (config.preCommit.tighten) {
|
|
59
|
+
yield* tighten(cwd, path, config);
|
|
60
|
+
}
|
|
61
|
+
const linted = yield* lint(cwd, path, "summary").pipe(
|
|
62
|
+
Effect.as(true),
|
|
63
|
+
Effect.catchTag("GateFailed", () => Effect.succeed(false)),
|
|
64
|
+
);
|
|
65
|
+
const failed = linted ? [] : ["quality lint"];
|
|
66
|
+
for (const command of config.preCommit.run) {
|
|
67
|
+
yield* Console.log(`quality: running \`${command}\``);
|
|
68
|
+
const code = yield* shell(config.root, command);
|
|
69
|
+
if (code !== 0) {
|
|
70
|
+
failed.push(`\`${command}\` (exit code ${code})`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (failed.length > 0) {
|
|
74
|
+
yield* Console.error(`quality: pre-commit failed: ${failed.join(", ")}.\n${COMMIT_AGAIN}`);
|
|
75
|
+
return yield* new GateFailed();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type HookCommand, shellWords } from "./command.ts";
|
|
2
|
+
|
|
3
|
+
const OWNER = "# Installed by @shivaedev/quality: `quality hooks install` writes it, `quality hooks uninstall` removes it.";
|
|
4
|
+
|
|
5
|
+
export function isHookShim(text: string): boolean {
|
|
6
|
+
return text.split("\n").includes(OWNER);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// The hook moves to the checked-out worktree first, so each worktree commits under its own config and its own quality.
|
|
10
|
+
// Git sets GIT_DIR for a hook in a linked worktree, which makes git take the current folder as the worktree root unless GIT_WORK_TREE names it.
|
|
11
|
+
export function hookShim(directory: string, command: HookCommand): string {
|
|
12
|
+
const folder = directory === "" ? "" : `/${shellWords([directory])}`;
|
|
13
|
+
const exec = [shellWords(command.runner), '"$quality"', shellWords(command.args)].filter((part) => part !== "");
|
|
14
|
+
return [
|
|
15
|
+
"#!/bin/sh",
|
|
16
|
+
OWNER,
|
|
17
|
+
'top="$(git rev-parse --show-toplevel)" || exit 2',
|
|
18
|
+
'if [ -n "$GIT_DIR" ] && [ -z "$GIT_WORK_TREE" ]; then export GIT_WORK_TREE="$top"; fi',
|
|
19
|
+
`cd "$top"${folder} || exit 2`,
|
|
20
|
+
`quality=${shellWords([command.launcher])}`,
|
|
21
|
+
'if [ ! -e "$quality" ]; then',
|
|
22
|
+
'\tprintf \'quality: cannot check this commit: %s/%s does not exist.\\nhelp: install the dependencies of this worktree, then commit again.\\n\' "$PWD" "$quality" >&2',
|
|
23
|
+
"\texit 2",
|
|
24
|
+
"fi",
|
|
25
|
+
`exec ${exec.join(" ")}`,
|
|
26
|
+
"",
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Console, Effect, FileSystem } from "effect";
|
|
2
|
+
import { SetupFailure } from "#failure.ts";
|
|
3
|
+
import { readOptionalText } from "#inventory/filesystem.ts";
|
|
4
|
+
import { hookLocation } from "./location.ts";
|
|
5
|
+
import { isHookShim } from "./shim.ts";
|
|
6
|
+
|
|
7
|
+
export const uninstallHook = Effect.fn("Hooks.uninstallHook")(function* (cwd: string) {
|
|
8
|
+
const fs = yield* FileSystem.FileSystem;
|
|
9
|
+
const { file } = yield* hookLocation(cwd);
|
|
10
|
+
const existing = yield* Effect.mapError(readOptionalText(file), (failure) => new SetupFailure({ message: failure.message }));
|
|
11
|
+
if (existing === undefined) {
|
|
12
|
+
return yield* Console.log(`quality: there is no pre-commit hook at ${file}.`);
|
|
13
|
+
}
|
|
14
|
+
if (!isHookShim(existing)) {
|
|
15
|
+
return yield* Console.error(`quality: kept ${file}: it is not quality's pre-commit hook.`);
|
|
16
|
+
}
|
|
17
|
+
yield* Effect.mapError(fs.remove(file), (error) => new SetupFailure({ message: `cannot remove ${file}: ${error.message}` }));
|
|
18
|
+
yield* Console.log(`quality: removed the pre-commit hook at ${file}.`);
|
|
19
|
+
});
|