@zgeoff/chex 0.0.0 → 0.0.2

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 (53) hide show
  1. package/README.md +96 -3
  2. package/lefthook.yml +123 -0
  3. package/package.json +31 -3
  4. package/src/build-init-package.ts +57 -0
  5. package/src/build-init-plan.ts +96 -0
  6. package/src/build-lefthook-extends.ts +117 -0
  7. package/src/check-names.ts +18 -0
  8. package/src/cli.ts +49 -0
  9. package/src/collect-command-names.ts +297 -0
  10. package/src/collect-expression-references.ts +17 -0
  11. package/src/dependency-gates/collect-stale-age-gate-excludes.ts +83 -0
  12. package/src/dependency-gates/collect-unneeded-audit-ignores.ts +41 -0
  13. package/src/dependency-gates/collect-unneeded-overrides.ts +70 -0
  14. package/src/dependency-gates/first-party-scopes.ts +1 -0
  15. package/src/dependency-gates/is-record.ts +4 -0
  16. package/src/dependency-gates/parse-audit-advisories.ts +33 -0
  17. package/src/dependency-gates/parse-audit-ignore-ids.ts +15 -0
  18. package/src/dependency-gates/parse-fixable-packages.ts +29 -0
  19. package/src/dependency-gates/parse-lockfile-dependents.ts +77 -0
  20. package/src/dependency-gates/parse-root-manifest.ts +68 -0
  21. package/src/dependency-gates/read-dependency-gate-findings.ts +91 -0
  22. package/src/dependency-gates/run-dependency-gates-check.ts +54 -0
  23. package/src/dependency-gates/types.ts +19 -0
  24. package/src/docs/check-length.sh +117 -0
  25. package/src/docs/check-links.sh +245 -0
  26. package/src/docs/check-prose.sh +657 -0
  27. package/src/docs/check-readmes.sh +102 -0
  28. package/src/docs/check-skill.sh +413 -0
  29. package/src/docs/check-vision.sh +34 -0
  30. package/src/find-install-cache-path.ts +16 -0
  31. package/src/format-commit-lines.ts +3 -0
  32. package/src/make-check-runner.ts +40 -0
  33. package/src/parse-added-lines.ts +112 -0
  34. package/src/parse-chex-args.ts +76 -0
  35. package/src/parse-lane-limits.ts +59 -0
  36. package/src/parse-pushed-refs.ts +29 -0
  37. package/src/parse-shell-words.ts +520 -0
  38. package/src/pick-merge-blobs-command.ts +152 -0
  39. package/src/read-branch-base.ts +31 -0
  40. package/src/read-branch-files.ts +59 -0
  41. package/src/read-pushed-ranges.ts +47 -0
  42. package/src/read-pushed-refs-input.ts +18 -0
  43. package/src/read-range-commits.ts +30 -0
  44. package/src/run-chex.ts +62 -0
  45. package/src/run-commits-check.ts +178 -0
  46. package/src/run-init.ts +129 -0
  47. package/src/run-local-paths-check.ts +185 -0
  48. package/src/run-program.ts +38 -0
  49. package/src/run-test-timeouts-check.ts +498 -0
  50. package/src/run-uncommitted-check.ts +82 -0
  51. package/src/run-workflows-check.ts +610 -0
  52. package/src/shell-escape-parsers.ts +197 -0
  53. package/src/write-text-file.ts +7 -0
@@ -0,0 +1,59 @@
1
+ import { readBranchBase } from './read-branch-base.ts';
2
+ import type { RunProgram } from './run-program.ts';
3
+ import { runProgram } from './run-program.ts';
4
+
5
+ interface ReadBranchFilesOptions {
6
+ readonly cwd: string;
7
+ readonly env: Readonly<Record<string, string | undefined>>;
8
+ readonly runProgram: RunProgram;
9
+
10
+ // Lists the paths the branch deletes too, which a check that compares the disk with HEAD needs.
11
+ readonly withDeleted?: boolean;
12
+ }
13
+
14
+ // lefthook's {push_files} lists all of history for a branch with no upstream, so the pre-push
15
+ // baseline reads its file list from here.
16
+ export function readBranchFiles(options: Readonly<ReadBranchFilesOptions>): string[] {
17
+ const base = readBranchBase(options);
18
+
19
+ const diff = options.runProgram(
20
+ [
21
+ 'git',
22
+ 'diff',
23
+ '--name-only',
24
+ '-z',
25
+ '--no-renames',
26
+ ...(options.withDeleted === true ? [] : ['--diff-filter=d']),
27
+ base,
28
+ 'HEAD',
29
+ ],
30
+ { cwd: options.cwd, env: options.env },
31
+ );
32
+
33
+ if (diff.exitCode !== 0) {
34
+ throw new Error(`git diff failed: ${diff.stderr.trim()}`);
35
+ }
36
+
37
+ // -z prints each path as it is, where a newline list would quote a path with a byte past ASCII.
38
+ return diff.stdout.split('\0').filter((path) => path !== '');
39
+ }
40
+
41
+ function runCLI(): void {
42
+ try {
43
+ const files = readBranchFiles({ cwd: process.cwd(), env: process.env, runProgram });
44
+
45
+ if (files.length > 0) {
46
+ process.stdout.write(`${files.join('\n')}\n`);
47
+ }
48
+ } catch (error) {
49
+ process.stderr.write(
50
+ `read-branch-files: ${error instanceof Error ? error.message : String(error)}\n`,
51
+ );
52
+
53
+ process.exit(1);
54
+ }
55
+ }
56
+
57
+ if (import.meta.main) {
58
+ runCLI();
59
+ }
@@ -0,0 +1,47 @@
1
+ import { parsePushedRefs } from './parse-pushed-refs.ts';
2
+ import type { ReadBranchBaseOptions } from './read-branch-base.ts';
3
+ import { readBranchBase, readDefaultBranch } from './read-branch-base.ts';
4
+ import type { CommitRange } from './read-range-commits.ts';
5
+
6
+ export interface PushedRangesOptions extends ReadBranchBaseOptions {
7
+ // The lines git hands a pre-push hook on stdin, or null outside a push.
8
+ readonly pushedRefs: string | null;
9
+ }
10
+
11
+ export function readPushedRanges(options: Readonly<PushedRangesOptions>): CommitRange[] {
12
+ if (options.pushedRefs === null) {
13
+ const base = readBranchBase(options);
14
+
15
+ return [[`${base}..HEAD`]];
16
+ }
17
+
18
+ const refs = parsePushedRefs(options.pushedRefs);
19
+
20
+ if (refs.length === 0) {
21
+ return [];
22
+ }
23
+
24
+ const defaultBranch = readDefaultBranch(options);
25
+
26
+ if (!probeCommitPresent(options, defaultBranch)) {
27
+ throw new Error(
28
+ `cannot find origin's default branch: ${defaultBranch} is not a commit (is the remote fetched?)`,
29
+ );
30
+ }
31
+
32
+ return refs.map((ref) =>
33
+ ref.remoteSha !== null && probeCommitPresent(options, ref.remoteSha)
34
+ ? [ref.localSha, `^${defaultBranch}`, `^${ref.remoteSha}`]
35
+ : [ref.localSha, `^${defaultBranch}`],
36
+ );
37
+ }
38
+
39
+ // A remote ref that another clone moved points at a commit this clone may lack.
40
+ function probeCommitPresent(options: Readonly<ReadBranchBaseOptions>, revision: string): boolean {
41
+ const result = options.runProgram(['git', 'cat-file', '-e', `${revision}^{commit}`], {
42
+ cwd: options.cwd,
43
+ env: options.env,
44
+ });
45
+
46
+ return result.exitCode === 0;
47
+ }
@@ -0,0 +1,18 @@
1
+ import { parseArgs } from 'node:util';
2
+
3
+ export type ReadStdin = () => Promise<string>;
4
+
5
+ // Only a hook that passes `--pushed-refs` hands over stdin; a manual run leaves stdin a terminal,
6
+ // where a read waits for input.
7
+ export function readPushedRefsInput(
8
+ args: readonly string[],
9
+ readStdin: ReadStdin,
10
+ ): Promise<string | null> {
11
+ const parsed = parseArgs({
12
+ args: [...args],
13
+ options: { 'pushed-refs': { type: 'boolean' } },
14
+ strict: true,
15
+ });
16
+
17
+ return parsed.values['pushed-refs'] === true ? readStdin() : Promise.resolve(null);
18
+ }
@@ -0,0 +1,30 @@
1
+ import type { RunProgram } from './run-program.ts';
2
+
3
+ // The revisions of one `git rev-list` call, such as `['<local sha>', '^origin/main']`.
4
+ export type CommitRange = readonly string[];
5
+
6
+ export interface ReadRangeCommitsOptions {
7
+ readonly cwd: string;
8
+ readonly env: Readonly<Record<string, string | undefined>>;
9
+ readonly runProgram: RunProgram;
10
+ }
11
+
12
+ // Two ranges can share a commit, such as two branches that start from the same one, and git log
13
+ // cannot read a union of ranges, so each range is listed apart and each commit kept once.
14
+ export function readRangeCommits(
15
+ options: Readonly<ReadRangeCommitsOptions>,
16
+ ranges: readonly CommitRange[],
17
+ ): string[] {
18
+ const commits = ranges.flatMap((range) => {
19
+ const argv = ['git', 'rev-list', ...range];
20
+ const result = options.runProgram(argv, { cwd: options.cwd, env: options.env });
21
+
22
+ if (result.exitCode !== 0) {
23
+ throw new Error(`${argv.join(' ')} failed: ${result.stderr.trim()}`);
24
+ }
25
+
26
+ return result.stdout.split('\n').filter((line) => line !== '');
27
+ });
28
+
29
+ return [...new Set(commits)];
30
+ }
@@ -0,0 +1,62 @@
1
+ import { match } from 'ts-pattern';
2
+ import type { CheckName } from './check-names.ts';
3
+ import { chexUsage, parseChexArgs } from './parse-chex-args.ts';
4
+
5
+ export interface ChexDeps {
6
+ readonly runCheck: (name: CheckName, args: readonly string[]) => Promise<number>;
7
+ readonly runInit: () => Promise<number>;
8
+ readonly writeOutput: (text: string) => void;
9
+ readonly writeError: (text: string) => void;
10
+ }
11
+
12
+ // Runs every named check, even after one bounces, so the output names each check that fails.
13
+ // Returns the highest exit code of the checks, and 2 for a refused request.
14
+ export function runChex(argv: readonly string[], deps: ChexDeps): Promise<number> {
15
+ return match(parseChexArgs(argv))
16
+ .with({ kind: 'refused' }, (refusal) => {
17
+ deps.writeError(`chex: ${refusal.message}\n${chexUsage}\n`);
18
+
19
+ return Promise.resolve(2);
20
+ })
21
+ .with({ kind: 'init' }, () => deps.runInit())
22
+ .with({ kind: 'checks' }, (request) => runChecks(request.names, request.args, deps))
23
+ .exhaustive();
24
+ }
25
+
26
+ async function runChecks(
27
+ names: readonly CheckName[],
28
+ args: readonly string[],
29
+ deps: ChexDeps,
30
+ ): Promise<number> {
31
+ let exitCode = 0;
32
+
33
+ for (const name of names) {
34
+ const checkExitCode = await runOneCheck(name, args, deps);
35
+
36
+ deps.writeOutput(`${checkExitCode === 0 ? 'cleared' : 'bounced'}: ${name}\n`);
37
+
38
+ exitCode = Math.max(exitCode, checkExitCode);
39
+ }
40
+
41
+ return exitCode;
42
+ }
43
+
44
+ // A check that throws, such as a shell script on a host without bash, cannot run, and so does a
45
+ // script that exits with a shell's own code, such as 127 for a missing perl: each counts as exit 2.
46
+ async function runOneCheck(
47
+ name: CheckName,
48
+ args: readonly string[],
49
+ deps: ChexDeps,
50
+ ): Promise<number> {
51
+ try {
52
+ const exitCode = await deps.runCheck(name, args);
53
+
54
+ return exitCode === 0 || exitCode === 1 ? exitCode : 2;
55
+ } catch (error) {
56
+ deps.writeError(
57
+ `chex: ${name} cannot run: ${error instanceof Error ? error.message : String(error)}\n`,
58
+ );
59
+
60
+ return 2;
61
+ }
62
+ }
@@ -0,0 +1,178 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { formatCommitLines } from './format-commit-lines.ts';
3
+ import { readPushedRanges } from './read-pushed-ranges.ts';
4
+ import { readPushedRefsInput } from './read-pushed-refs-input.ts';
5
+ import type { CommitRange } from './read-range-commits.ts';
6
+ import { readRangeCommits } from './read-range-commits.ts';
7
+ import type { ProgramResult, RunProgram } from './run-program.ts';
8
+ import { runProgram } from './run-program.ts';
9
+
10
+ export interface CommitsCheckOptions {
11
+ readonly cwd: string;
12
+
13
+ readonly ranges: readonly CommitRange[];
14
+
15
+ readonly env: Readonly<Record<string, string | undefined>>;
16
+
17
+ readonly commitlint: string;
18
+ readonly config: string;
19
+
20
+ readonly runProgram: RunProgram;
21
+ }
22
+
23
+ // --config replaces any commitlint config the repo keeps, so every repo that runs the check lints
24
+ // with the same rules.
25
+ export function runCommitsCheck(options: Readonly<CommitsCheckOptions>): string[] {
26
+ return readBranchCommits(options).flatMap((commit) => {
27
+ const result = options.runProgram(
28
+ [options.commitlint, '--config', options.config, '--color=false'],
29
+ { cwd: options.cwd, env: options.env, stdin: commit.message },
30
+ );
31
+
32
+ return parseCommitlintOutput(commit.hash, result);
33
+ });
34
+ }
35
+
36
+ interface Commit {
37
+ readonly hash: string;
38
+ readonly message: string;
39
+ }
40
+
41
+ // A merge commit carries the message git wrote, so the check leaves it out.
42
+ function readBranchCommits(options: Readonly<CommitsCheckOptions>): Commit[] {
43
+ const commits = readRangeCommits(options, options.ranges);
44
+
45
+ // git log reads HEAD when stdin names no commit.
46
+ if (commits.length === 0) {
47
+ return [];
48
+ }
49
+
50
+ const result = options.runProgram(
51
+ ['git', 'log', '--no-merges', '-z', '--format=%h%n%B', '--no-walk=unsorted', '--stdin'],
52
+ { cwd: options.cwd, env: options.env, stdin: formatCommitLines(commits) },
53
+ );
54
+
55
+ if (result.exitCode !== 0) {
56
+ throw new Error(`git log failed: ${result.stderr.trim()}`);
57
+ }
58
+
59
+ return result.stdout
60
+ .split('\0')
61
+ .filter((entry) => entry !== '')
62
+ .map((entry) => {
63
+ const newline = entry.indexOf('\n');
64
+
65
+ return { hash: entry.slice(0, newline), message: entry.slice(newline + 1) };
66
+ });
67
+ }
68
+
69
+ function parseCommitlintOutput(hash: string, result: ProgramResult): string[] {
70
+ // The default formatter: `✖ subject may not be empty [subject-empty]`. The summary line,
71
+ // `✖ found 1 problems, 0 warnings`, ends with no rule name.
72
+ const problemLine = /^✖\s+(?<message>.+ \[[\w-]+\])$/u;
73
+
74
+ // commitlint exits 1 when it reports a problem, and with other codes when it cannot run, such as
75
+ // 9 for a config with no rules.
76
+ if (result.exitCode > 1) {
77
+ throw new Error(`commitlint failed: ${result.stderr.trim() || result.stdout.trim()}`);
78
+ }
79
+
80
+ const problems = result.stdout.split('\n').flatMap((line) => {
81
+ const message = problemLine.exec(line)?.groups?.['message'];
82
+
83
+ return message === undefined ? [] : [`commit ${hash}: ${message}`];
84
+ });
85
+
86
+ // An exit of 1 with no problem it could read is a crash, such as a config it cannot load.
87
+ if (result.exitCode === 1 && problems.length === 0) {
88
+ throw new Error(`commitlint failed: ${result.stderr.trim()}`);
89
+ }
90
+
91
+ return problems;
92
+ }
93
+
94
+ // Returns the exit code: 0 when clean or skipped, 1 with a problem, and 2 when the check cannot run.
95
+ export async function runCommitsCommand(args: readonly string[]): Promise<number> {
96
+ const cwd = process.cwd();
97
+
98
+ const commitlint = Bun.which('commitlint', {
99
+ PATH: `${join(cwd, 'node_modules/.bin')}:${process.env['PATH'] ?? ''}`,
100
+ });
101
+
102
+ if (commitlint === null) {
103
+ writeSkipNotice(
104
+ 'the repo does not install commitlint; add @commitlint/cli and @zgeoff/commitlint-config as devDependencies to run it',
105
+ );
106
+
107
+ return 0;
108
+ }
109
+
110
+ if (!(await probeConfigInstalled(cwd))) {
111
+ writeSkipNotice(
112
+ 'the repo does not install @zgeoff/commitlint-config; add it as a devDependency to run it',
113
+ );
114
+
115
+ return 0;
116
+ }
117
+
118
+ try {
119
+ // An installed package that still fails to resolve, such as one whose export names a missing
120
+ // file, is broken, so it throws here rather than skip.
121
+ const config = Bun.resolveSync('@zgeoff/commitlint-config', cwd);
122
+
123
+ const ranges = readPushedRanges({
124
+ cwd,
125
+ env: process.env,
126
+ pushedRefs: await readPushedRefsInput(args, () => Bun.stdin.text()),
127
+ runProgram,
128
+ });
129
+
130
+ const problems = runCommitsCheck({
131
+ cwd,
132
+ ranges,
133
+ env: process.env,
134
+ commitlint,
135
+ config,
136
+ runProgram,
137
+ });
138
+
139
+ for (const problem of problems) {
140
+ process.stdout.write(`${problem}\n`);
141
+ }
142
+
143
+ return problems.length === 0 ? 0 : 1;
144
+ } catch (error) {
145
+ process.stderr.write(`commits: ${error instanceof Error ? error.message : String(error)}\n`);
146
+
147
+ return 2;
148
+ }
149
+ }
150
+
151
+ // Bun's resolver throws the same error for a package that is absent and for one it cannot read, so
152
+ // the check looks for the package's package.json in each node_modules that resolution walks.
153
+ async function probeConfigInstalled(cwd: string): Promise<boolean> {
154
+ const found = await Promise.all(
155
+ collectAncestorFolders(cwd).map((folder) =>
156
+ Bun.file(join(folder, 'node_modules/@zgeoff/commitlint-config/package.json')).exists(),
157
+ ),
158
+ );
159
+
160
+ return found.includes(true);
161
+ }
162
+
163
+ function collectAncestorFolders(folder: string): string[] {
164
+ const parent = dirname(folder);
165
+
166
+ return parent === folder ? [folder] : [folder, ...collectAncestorFolders(parent)];
167
+ }
168
+
169
+ function writeSkipNotice(reason: string): void {
170
+ // GitHub Actions shows a line that starts with ::notice:: on the run's summary.
171
+ const prefix = process.env['GITHUB_ACTIONS'] === 'true' ? '::notice::' : '';
172
+
173
+ process.stdout.write(`${prefix}commits: skipped, because ${reason}\n`);
174
+ }
175
+
176
+ if (import.meta.main) {
177
+ process.exitCode = await runCommitsCommand(process.argv.slice(2));
178
+ }
@@ -0,0 +1,129 @@
1
+ import { join } from 'node:path';
2
+ import { match } from 'ts-pattern';
3
+ import type { InitWrite } from './build-init-plan.ts';
4
+ import { buildInitPlan } from './build-init-plan.ts';
5
+
6
+ export interface InitDeps {
7
+ readonly repoRoot: string;
8
+
9
+ // Returns undefined for a file that does not exist.
10
+ readonly readText: (path: string) => Promise<string | undefined>;
11
+
12
+ readonly writeText: (path: string, text: string) => Promise<void>;
13
+
14
+ readonly renameFile: (from: string, to: string) => Promise<void>;
15
+
16
+ // A missing file is no error.
17
+ readonly removeFile: (path: string) => Promise<void>;
18
+
19
+ readonly writeOutput: (text: string) => void;
20
+
21
+ readonly writeError: (text: string) => void;
22
+ }
23
+
24
+ export async function runInit(deps: InitDeps): Promise<number> {
25
+ const packageText = await deps.readText(join(deps.repoRoot, 'package.json'));
26
+ const lefthookText = await deps.readText(join(deps.repoRoot, 'lefthook.yml'));
27
+
28
+ const plan = buildInitPlan(packageText, lefthookText);
29
+
30
+ return match(plan)
31
+ .with({ kind: 'refused' }, (refusal) => {
32
+ deps.writeError(refusal.reasons.map((reason) => `chex init: ${reason}\n`).join(''));
33
+
34
+ return Promise.resolve(1);
35
+ })
36
+ .with({ kind: 'apply' }, async (apply) => {
37
+ const originals = { 'package.json': packageText, 'lefthook.yml': lefthookText };
38
+
39
+ const failure = await writeInitFiles(apply.writes, originals, deps);
40
+
41
+ if (failure !== null) {
42
+ deps.writeError(`chex init: ${failure}, so both files stay as they were\n`);
43
+
44
+ return 1;
45
+ }
46
+
47
+ deps.writeOutput(apply.notes.map((note) => `chex init: ${note}\n`).join(''));
48
+
49
+ return 0;
50
+ })
51
+ .exhaustive();
52
+ }
53
+
54
+ type InitOriginals = Readonly<Record<InitWrite['file'], string | undefined>>;
55
+
56
+ // Writes each file in full beside its target before any target changes, then renames each into
57
+ // place, so a failed write never touches a target. Returns the failure, after it puts back each
58
+ // target already replaced; null when every file is in place.
59
+ async function writeInitFiles(
60
+ writes: readonly InitWrite[],
61
+ originals: InitOriginals,
62
+ deps: InitDeps,
63
+ ): Promise<string | null> {
64
+ const staged: InitWrite[] = [];
65
+
66
+ for (const write of writes) {
67
+ const failure = await settleEffect(deps.writeText(toStagedPath(deps, write.file), write.text));
68
+
69
+ if (failure !== null) {
70
+ await removeStagedFiles([...staged, write], deps);
71
+
72
+ return `could not write ${write.file}: ${failure}`;
73
+ }
74
+
75
+ staged.push(write);
76
+ }
77
+
78
+ const replaced: InitWrite[] = [];
79
+
80
+ for (const write of staged) {
81
+ const failure = await settleEffect(
82
+ deps.renameFile(toStagedPath(deps, write.file), join(deps.repoRoot, write.file)),
83
+ );
84
+
85
+ if (failure !== null) {
86
+ await removeStagedFiles(staged.slice(replaced.length), deps);
87
+ await restoreFiles(replaced, originals, deps);
88
+
89
+ return `could not replace ${write.file}: ${failure}`;
90
+ }
91
+
92
+ replaced.push(write);
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ // Returns the effect's failure as its message, or null when it succeeds.
99
+ async function settleEffect(effect: Promise<void>): Promise<string | null> {
100
+ try {
101
+ await effect;
102
+
103
+ return null;
104
+ } catch (error) {
105
+ return error instanceof Error ? error.message : String(error);
106
+ }
107
+ }
108
+
109
+ function toStagedPath(deps: InitDeps, file: InitWrite['file']): string {
110
+ return join(deps.repoRoot, `${file}.chex-init`);
111
+ }
112
+
113
+ // A staged file that cannot go stays behind; the targets are what init keeps whole.
114
+ async function removeStagedFiles(writes: readonly InitWrite[], deps: InitDeps): Promise<void> {
115
+ await Promise.allSettled(writes.map((write) => deps.removeFile(toStagedPath(deps, write.file))));
116
+ }
117
+
118
+ async function restoreFiles(
119
+ replaced: readonly InitWrite[],
120
+ originals: InitOriginals,
121
+ deps: InitDeps,
122
+ ): Promise<void> {
123
+ for (const write of replaced) {
124
+ const path = join(deps.repoRoot, write.file);
125
+ const original = originals[write.file];
126
+
127
+ await (original === undefined ? deps.removeFile(path) : deps.writeText(path, original));
128
+ }
129
+ }