@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.
- package/README.md +96 -3
- package/lefthook.yml +123 -0
- package/package.json +31 -3
- package/src/build-init-package.ts +57 -0
- package/src/build-init-plan.ts +96 -0
- package/src/build-lefthook-extends.ts +117 -0
- package/src/check-names.ts +18 -0
- package/src/cli.ts +49 -0
- package/src/collect-command-names.ts +297 -0
- package/src/collect-expression-references.ts +17 -0
- package/src/dependency-gates/collect-stale-age-gate-excludes.ts +83 -0
- package/src/dependency-gates/collect-unneeded-audit-ignores.ts +41 -0
- package/src/dependency-gates/collect-unneeded-overrides.ts +70 -0
- package/src/dependency-gates/first-party-scopes.ts +1 -0
- package/src/dependency-gates/is-record.ts +4 -0
- package/src/dependency-gates/parse-audit-advisories.ts +33 -0
- package/src/dependency-gates/parse-audit-ignore-ids.ts +15 -0
- package/src/dependency-gates/parse-fixable-packages.ts +29 -0
- package/src/dependency-gates/parse-lockfile-dependents.ts +77 -0
- package/src/dependency-gates/parse-root-manifest.ts +68 -0
- package/src/dependency-gates/read-dependency-gate-findings.ts +91 -0
- package/src/dependency-gates/run-dependency-gates-check.ts +54 -0
- package/src/dependency-gates/types.ts +19 -0
- package/src/docs/check-length.sh +117 -0
- package/src/docs/check-links.sh +245 -0
- package/src/docs/check-prose.sh +657 -0
- package/src/docs/check-readmes.sh +102 -0
- package/src/docs/check-skill.sh +413 -0
- package/src/docs/check-vision.sh +34 -0
- package/src/find-install-cache-path.ts +16 -0
- package/src/format-commit-lines.ts +3 -0
- package/src/make-check-runner.ts +40 -0
- package/src/parse-added-lines.ts +112 -0
- package/src/parse-chex-args.ts +76 -0
- package/src/parse-lane-limits.ts +59 -0
- package/src/parse-pushed-refs.ts +29 -0
- package/src/parse-shell-words.ts +520 -0
- package/src/pick-merge-blobs-command.ts +152 -0
- package/src/read-branch-base.ts +31 -0
- package/src/read-branch-files.ts +59 -0
- package/src/read-pushed-ranges.ts +47 -0
- package/src/read-pushed-refs-input.ts +18 -0
- package/src/read-range-commits.ts +30 -0
- package/src/run-chex.ts +62 -0
- package/src/run-commits-check.ts +178 -0
- package/src/run-init.ts +129 -0
- package/src/run-local-paths-check.ts +185 -0
- package/src/run-program.ts +38 -0
- package/src/run-test-timeouts-check.ts +498 -0
- package/src/run-uncommitted-check.ts +82 -0
- package/src/run-workflows-check.ts +610 -0
- package/src/shell-escape-parsers.ts +197 -0
- package/src/write-text-file.ts +7 -0
package/README.md
CHANGED
|
@@ -1,4 +1,97 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>@zgeoff/chex</h1>
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
<p>
|
|
5
|
+
<a href="https://www.npmjs.com/package/@zgeoff/chex"><img src="https://img.shields.io/npm/v/@zgeoff/chex" alt="npm version"></a>
|
|
6
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<a href="../../docs/README.md">Documentation</a> •
|
|
11
|
+
<a href="../../docs/architecture/pre-push-baseline.md">The pre-push baseline</a>
|
|
12
|
+
</p>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
Run each shared check of a zgeoff repo as one subcommand, and take the lefthook pre-push baseline
|
|
16
|
+
that calls them with one `extends` line.
|
|
17
|
+
|
|
18
|
+
Each check prints its own findings, then `cleared: <check>` when it passes or `bounced: <check>`
|
|
19
|
+
when it fails, so a run of several checks names each one that failed. To start, run `chex init`,
|
|
20
|
+
then `chex length links`.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
bun add -d @zgeoff/chex @zgeoff/rig @zgeoff/diet @commitlint/cli @zgeoff/commitlint-config
|
|
26
|
+
bun x --no-install chex init
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`chex` runs under Bun. The baseline runs the related tests through [`@zgeoff/rig`](../rig/README.md)
|
|
30
|
+
and each job under the memory cap of [`@zgeoff/diet`](../diet/README.md), so a repo that takes it
|
|
31
|
+
installs both, and `chex init` fails until it does. `chex commits` lints commit messages with
|
|
32
|
+
`@commitlint/cli` and `@zgeoff/commitlint-config`; in a repo that lacks either one, it prints a
|
|
33
|
+
notice and passes.
|
|
34
|
+
|
|
35
|
+
## Commands
|
|
36
|
+
|
|
37
|
+
`chex <check>...` runs each check in turn, even after one bounces, and exits with the highest exit
|
|
38
|
+
code among them: 0 when each clears, 1 when a check finds a fault, and 2 when a check cannot run.
|
|
39
|
+
chex also exits 2 for a check that throws or exits with any other code, such as 127 for a missing
|
|
40
|
+
program. `chex <check> <args...>` hands the arguments to that one check.
|
|
41
|
+
|
|
42
|
+
The docs checks read the repo's READMEs, its `docs/` tree and its skills:
|
|
43
|
+
|
|
44
|
+
| Check | Fails on | Arguments |
|
|
45
|
+
| --------- | ------------------------------------------------------------------------------------------------------------------- | -------------- |
|
|
46
|
+
| `prose` | banned words, process residue and over-long paragraphs | the paths |
|
|
47
|
+
| `length` | a README over its line limit, and warns on a doc over its cap | none |
|
|
48
|
+
| `links` | a relative link or anchor that does not resolve | the paths |
|
|
49
|
+
| `readmes` | a README of none of the [five kinds](../../sync/skills/docs-writing/references/readmes.md#the-five-kinds-of-readme) | none |
|
|
50
|
+
| `vision` | a missing `docs/vision.md`, or one past 60 lines | none |
|
|
51
|
+
| `skill` | a skill's frontmatter, its word limit, and a link with a line anchor | the skill dirs |
|
|
52
|
+
|
|
53
|
+
The repo checks read the branch, the pushed commits and the repo's config:
|
|
54
|
+
|
|
55
|
+
| Check | Fails on | Arguments |
|
|
56
|
+
| ------------------ | --------------------------------------------------------------------------- | --------------- |
|
|
57
|
+
| `commits` | a pushed commit whose message fails `@zgeoff/commitlint-config` | `--pushed-refs` |
|
|
58
|
+
| `local-paths` | a line a pushed commit adds that holds the home folder or the host name | `--pushed-refs` |
|
|
59
|
+
| `test-timeouts` | a `bun test` timeout above its lane's limit, in a script or a `bunfig.toml` | none |
|
|
60
|
+
| `workflows` | nothing: it warns on each fault of the shared workflow lint, and passes | the paths |
|
|
61
|
+
| `dependency-gates` | a stale age-gate exclude, an override or an audit ignore that nothing needs | none |
|
|
62
|
+
| `uncommitted` | a file the branch changes that holds a change the push does not send | none |
|
|
63
|
+
|
|
64
|
+
With no path, `prose` and `links` read every README outside `.claude/` and `.agents/`, every file
|
|
65
|
+
under `docs/`, each spike's `results.md`, and the Markdown under `.agents/skills/`,
|
|
66
|
+
`.claude/skills/`, `skills/` and `sync/skills/`. `--pushed-refs` reads the refs a push sends from
|
|
67
|
+
stdin, as a pre-push hook gets them; without it, `commits` and `local-paths` read the branch's
|
|
68
|
+
commits since its base.
|
|
69
|
+
|
|
70
|
+
Some docs checks skip files whether or not you name paths, and a path you name never brings a
|
|
71
|
+
skipped file back:
|
|
72
|
+
|
|
73
|
+
- `prose` skips the `docs-writing`, `project-docs-writing`, `skill-writing` and `deslop` skills
|
|
74
|
+
under `.agents/skills/`, `.claude/skills/`, `skills/` and `sync/skills/`, which hold the banned
|
|
75
|
+
patterns as examples.
|
|
76
|
+
- `links` skips `project-docs-writing` under `.agents/skills/` and `.claude/skills/`, and
|
|
77
|
+
`docs-writing` and `skill-writing` under `.claude/skills/` and `sync/skills/`, which link to
|
|
78
|
+
placeholder targets as examples.
|
|
79
|
+
- `prose`, `links`, `readmes` and `skill` skip each file that `.gitattributes` marks
|
|
80
|
+
`linguist-vendored`.
|
|
81
|
+
|
|
82
|
+
`chex init` writes the `extends` line into `lefthook.yml` and adds a `check:docs` script of
|
|
83
|
+
`chex prose length links` when the repo has none. A second run changes nothing.
|
|
84
|
+
|
|
85
|
+
## The pre-push baseline
|
|
86
|
+
|
|
87
|
+
A repo's `lefthook.yml` takes the baseline with one line, which `chex init` writes:
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
extends:
|
|
91
|
+
- node_modules/@zgeoff/chex/lefthook.yml
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The baseline's jobs call `chex <check>` for the repo checks, `bun run check:docs` for the docs
|
|
95
|
+
checks the repo names, `rig related` for the tests beside the changed files, and the repo's own
|
|
96
|
+
`format:check` and `typecheck` scripts.
|
|
97
|
+
[The pre-push baseline](../../docs/architecture/pre-push-baseline.md) describes each job.
|
package/lefthook.yml
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# The pre-push baseline a repo extends from its lefthook.yml, as chex init writes it:
|
|
2
|
+
#
|
|
3
|
+
# extends:
|
|
4
|
+
# - node_modules/@zgeoff/chex/lefthook.yml
|
|
5
|
+
#
|
|
6
|
+
# It checks what the branch changes, plus the whole-tree gates that fit a
|
|
7
|
+
# small host: format:check and typecheck. CI runs every whole-tree check. It
|
|
8
|
+
# also runs typecheck before a commit that changes code or config, after the
|
|
9
|
+
# repo's own pre-commit fixers. A repo adds its own jobs under
|
|
10
|
+
# pre-commit.jobs and pre-push.jobs in lefthook.yml; they run before these.
|
|
11
|
+
# Each job runs under diet's memory cap, so a run that outgrows the host fails
|
|
12
|
+
# alone, with its peak memory in the output. bun x --no-install runs the
|
|
13
|
+
# repo's own install of chex, diet and rig, and fails when one is missing
|
|
14
|
+
# rather than download a package of that name.
|
|
15
|
+
|
|
16
|
+
pre-commit:
|
|
17
|
+
# Sequential, so typecheck reads the files the fixers before it rewrote.
|
|
18
|
+
piped: true
|
|
19
|
+
jobs:
|
|
20
|
+
# lefthook skips the job when no staged file matches the glob.
|
|
21
|
+
- name: typecheck
|
|
22
|
+
glob: '*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,json,jsonc}'
|
|
23
|
+
run: bun x --no-install diet -- bun run typecheck
|
|
24
|
+
|
|
25
|
+
pre-push:
|
|
26
|
+
# Sequential, to bound memory on a small host or with parallel agents.
|
|
27
|
+
piped: true
|
|
28
|
+
jobs:
|
|
29
|
+
# The jobs after this one read the files on disk, so this one fails the
|
|
30
|
+
# push when a file the branch changes or deletes holds a change that is
|
|
31
|
+
# not committed, and names each such file.
|
|
32
|
+
- name: uncommitted
|
|
33
|
+
run: bun x --no-install diet -- bun x --no-install chex uncommitted
|
|
34
|
+
- name: format:check
|
|
35
|
+
run: bun x --no-install diet -- bun run format:check
|
|
36
|
+
# Plain oxlint, without the unused-directive check: a directive for a
|
|
37
|
+
# type-aware rule reads as unused in plain mode.
|
|
38
|
+
- name: lint
|
|
39
|
+
files: bun node_modules/@zgeoff/chex/src/read-branch-files.ts
|
|
40
|
+
glob: '*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}'
|
|
41
|
+
run:
|
|
42
|
+
bun x --no-install diet -- bun x --no-install oxlint --no-error-on-unmatched-pattern {files}
|
|
43
|
+
# Each file check reads only the files the branch changes that match its
|
|
44
|
+
# glob, so a file the branch leaves unchanged never fails a push. A check
|
|
45
|
+
# whose program the machine lacks prints a notice and passes: CI installs
|
|
46
|
+
# the program and runs the check on every pull request.
|
|
47
|
+
- name: shell
|
|
48
|
+
files: bun node_modules/@zgeoff/chex/src/read-branch-files.ts
|
|
49
|
+
glob: '*.sh'
|
|
50
|
+
run: |
|
|
51
|
+
for program in shellcheck shfmt; do
|
|
52
|
+
if ! command -v "$program" > /dev/null; then
|
|
53
|
+
echo "shell: skipped, because $program is not on the PATH; CI runs the check"
|
|
54
|
+
exit 0
|
|
55
|
+
fi
|
|
56
|
+
done
|
|
57
|
+
status=0
|
|
58
|
+
bun x --no-install diet -- shellcheck --external-sources --source-path=SCRIPTDIR "--source-path=$PWD" -- {files} || status=1
|
|
59
|
+
bun x --no-install diet -- shfmt -d -- {files} || status=1
|
|
60
|
+
exit "$status"
|
|
61
|
+
# The docs checks pick the docs they read themselves, so the glob only
|
|
62
|
+
# decides whether the job runs. The repo's check:docs script names the
|
|
63
|
+
# checks it takes, such as chex prose length links.
|
|
64
|
+
- name: docs
|
|
65
|
+
files: bun node_modules/@zgeoff/chex/src/read-branch-files.ts
|
|
66
|
+
glob: '*.md'
|
|
67
|
+
run: bun x --no-install diet -- bun run --silent check:docs
|
|
68
|
+
# GitHub runs no workflow from a subfolder of .github/workflows, and
|
|
69
|
+
# sync/workflow-callers holds the workflows that tools delivers. actionlint
|
|
70
|
+
# 1.7.12 rejects the concurrency queue key that GitHub documents, until
|
|
71
|
+
# rhysd/actionlint#654 ships; the ignore lapses on the same date as in CI.
|
|
72
|
+
# The workflow lint runs after actionlint, even when actionlint is missing
|
|
73
|
+
# or fails.
|
|
74
|
+
- name: workflows
|
|
75
|
+
files: bun node_modules/@zgeoff/chex/src/read-branch-files.ts
|
|
76
|
+
glob:
|
|
77
|
+
- '.github/workflows/*.{yml,yaml}'
|
|
78
|
+
- 'sync/workflow-callers/*.{yml,yaml}'
|
|
79
|
+
run: |
|
|
80
|
+
status=0
|
|
81
|
+
if ! command -v actionlint > /dev/null; then
|
|
82
|
+
echo "workflows: actionlint skipped, because it is not on the PATH; CI runs it"
|
|
83
|
+
elif [ "$(date -u +%Y%m%d)" -lt 20270122 ]; then
|
|
84
|
+
bun x --no-install diet -- actionlint -ignore 'unexpected key "queue" for "concurrency" section' -- {files} || status=1
|
|
85
|
+
else
|
|
86
|
+
bun x --no-install diet -- actionlint -- {files} || status=1
|
|
87
|
+
fi
|
|
88
|
+
bun x --no-install diet -- bun x --no-install chex workflows {files} || status=1
|
|
89
|
+
exit "$status"
|
|
90
|
+
# git lists the refs a push sends on the hook's stdin, and lefthook hands
|
|
91
|
+
# stdin only to a job that asks for it. lefthook 2.2.2 skips a pre-push
|
|
92
|
+
# job whose run substitutes no file when the checked-out branch has
|
|
93
|
+
# nothing to push (internal/run/controller/command/build_command.go,
|
|
94
|
+
# lines 47-49 and 71-80), which would skip a push of another branch. So
|
|
95
|
+
# each job lists package.json for {files}, inside a shell comment the
|
|
96
|
+
# check never sees: every repo that takes the baseline has the file, as
|
|
97
|
+
# it installs chex, and lefthook drops a listed path that does not exist.
|
|
98
|
+
- name: commits
|
|
99
|
+
files: echo package.json
|
|
100
|
+
use_stdin: true
|
|
101
|
+
run: 'bun x --no-install diet -- bun x --no-install chex commits --pushed-refs # {files}'
|
|
102
|
+
# package.json keeps this job from the same lefthook skip.
|
|
103
|
+
- name: local-paths
|
|
104
|
+
files: echo package.json
|
|
105
|
+
use_stdin: true
|
|
106
|
+
run: 'bun x --no-install diet -- bun x --no-install chex local-paths --pushed-refs # {files}'
|
|
107
|
+
- name: test-timeouts
|
|
108
|
+
run: bun x --no-install diet -- bun x --no-install chex test-timeouts
|
|
109
|
+
- name: dependency-gates
|
|
110
|
+
run: bun x --no-install diet -- bun x --no-install chex dependency-gates
|
|
111
|
+
- name: typecheck
|
|
112
|
+
run: bun x --no-install diet -- bun run typecheck
|
|
113
|
+
# A pre-push in a linked worktree exports GIT_DIR, and a test that runs git
|
|
114
|
+
# in a temp folder would inherit it and write to the real repository: a
|
|
115
|
+
# git init there sets core.bare=true. The tests run without git's
|
|
116
|
+
# repository variables. A bunfig.toml reaches the job too, because a change
|
|
117
|
+
# to it skips the related tests.
|
|
118
|
+
- name: test
|
|
119
|
+
files: bun node_modules/@zgeoff/chex/src/read-branch-files.ts
|
|
120
|
+
glob: '*.{ts,tsx,toml}'
|
|
121
|
+
run:
|
|
122
|
+
env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR bun x --no-install diet
|
|
123
|
+
-- bun x --no-install rig related {files}
|
package/package.json
CHANGED
|
@@ -1,17 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/chex",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Run
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Run each shared docs and repo check as one subcommand, with the lefthook pre-push baseline that calls them",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"checks",
|
|
7
|
+
"docs",
|
|
8
|
+
"lefthook",
|
|
9
|
+
"pre-push"
|
|
10
|
+
],
|
|
5
11
|
"license": "MIT",
|
|
6
12
|
"repository": {
|
|
7
13
|
"type": "git",
|
|
8
14
|
"url": "git+https://github.com/zgeoff/tools.git",
|
|
9
15
|
"directory": "packages/chex"
|
|
10
16
|
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"chex": "./src/cli.ts"
|
|
19
|
+
},
|
|
11
20
|
"files": [
|
|
12
|
-
"
|
|
21
|
+
"src",
|
|
22
|
+
"!src/**/*.test.ts",
|
|
23
|
+
"lefthook.yml"
|
|
13
24
|
],
|
|
25
|
+
"type": "module",
|
|
14
26
|
"publishConfig": {
|
|
15
27
|
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsrs"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@zgeoff/std": "0.3.1",
|
|
34
|
+
"ts-pattern": "5.9.2"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/bun": "1.4.3",
|
|
38
|
+
"tiny-invariant": "1.3.3",
|
|
39
|
+
"tsrs": "0.18.0-ts7.1.0-dev.20260929",
|
|
40
|
+
"typescript": "7.0.2"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"bun": ">=1.3"
|
|
16
44
|
}
|
|
17
45
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { isRecord } from '@zgeoff/std';
|
|
2
|
+
|
|
3
|
+
// The baseline's jobs run chex, the related tests through rig, and each job under diet's memory cap.
|
|
4
|
+
export const baselinePackages = ['@zgeoff/chex', '@zgeoff/rig', '@zgeoff/diet'] as const;
|
|
5
|
+
export const docsScript = 'chex prose length links';
|
|
6
|
+
|
|
7
|
+
export type PackageOutcome =
|
|
8
|
+
| { readonly kind: 'missing'; readonly packages: readonly string[] }
|
|
9
|
+
| { readonly kind: 'unchanged' }
|
|
10
|
+
| { readonly kind: 'changed'; readonly text: string }
|
|
11
|
+
| { readonly kind: 'refused'; readonly reason: string };
|
|
12
|
+
|
|
13
|
+
export function buildInitPackage(text: string): PackageOutcome {
|
|
14
|
+
let parsed: unknown;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(text);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return {
|
|
20
|
+
kind: 'refused',
|
|
21
|
+
reason: `package.json is not JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!isRecord(parsed)) {
|
|
26
|
+
return { kind: 'refused', reason: 'package.json is not a JSON object' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const installed = new Set([
|
|
30
|
+
...listKeys(parsed['dependencies']),
|
|
31
|
+
...listKeys(parsed['devDependencies']),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const missing = baselinePackages.filter((name) => !installed.has(name));
|
|
35
|
+
|
|
36
|
+
if (missing.length > 0) {
|
|
37
|
+
return { kind: 'missing', packages: missing };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const scripts = parsed['scripts'] ?? {};
|
|
41
|
+
|
|
42
|
+
if (!isRecord(scripts)) {
|
|
43
|
+
return { kind: 'refused', reason: 'the scripts of package.json are not a JSON object' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (scripts['check:docs'] !== undefined) {
|
|
47
|
+
return { kind: 'unchanged' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const edited = { ...parsed, scripts: { ...scripts, 'check:docs': docsScript } };
|
|
51
|
+
|
|
52
|
+
return { kind: 'changed', text: `${JSON.stringify(edited, null, 2)}\n` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function listKeys(value: unknown): string[] {
|
|
56
|
+
return isRecord(value) ? Object.keys(value) : [];
|
|
57
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { match } from 'ts-pattern';
|
|
2
|
+
import type { PackageOutcome } from './build-init-package.ts';
|
|
3
|
+
import { buildInitPackage, docsScript } from './build-init-package.ts';
|
|
4
|
+
import type { LefthookOutcome } from './build-lefthook-extends.ts';
|
|
5
|
+
import { baselinePath, buildLefthookExtends } from './build-lefthook-extends.ts';
|
|
6
|
+
|
|
7
|
+
export interface InitWrite {
|
|
8
|
+
readonly file: 'lefthook.yml' | 'package.json';
|
|
9
|
+
readonly text: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type InitPlan =
|
|
13
|
+
| { readonly kind: 'refused'; readonly reasons: readonly string[] }
|
|
14
|
+
| {
|
|
15
|
+
readonly kind: 'apply';
|
|
16
|
+
readonly writes: readonly InitWrite[];
|
|
17
|
+
readonly notes: readonly string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Plans both edits before either is written, so a refused edit leaves the repo as it was.
|
|
21
|
+
export function buildInitPlan(
|
|
22
|
+
packageText: string | undefined,
|
|
23
|
+
lefthookText: string | undefined,
|
|
24
|
+
): InitPlan {
|
|
25
|
+
if (packageText === undefined) {
|
|
26
|
+
return {
|
|
27
|
+
kind: 'refused',
|
|
28
|
+
reasons: ['the folder has no package.json; run chex init from the repo root'],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return match<PackageOutcome, InitPlan>(buildInitPackage(packageText))
|
|
33
|
+
.with({ kind: 'missing' }, (outcome) => ({
|
|
34
|
+
kind: 'refused',
|
|
35
|
+
reasons: [
|
|
36
|
+
`the baseline needs ${outcome.packages.join(', ')} installed; run bun add -d ${outcome.packages.join(' ')}`,
|
|
37
|
+
],
|
|
38
|
+
}))
|
|
39
|
+
.with({ kind: 'unchanged' }, () => buildPlanFromSteps([buildLefthookStep(lefthookText)]))
|
|
40
|
+
.with({ kind: 'changed' }, (outcome) =>
|
|
41
|
+
buildPlanFromSteps([
|
|
42
|
+
buildLefthookStep(lefthookText),
|
|
43
|
+
{
|
|
44
|
+
reasons: [],
|
|
45
|
+
writes: [{ file: 'package.json', text: outcome.text }],
|
|
46
|
+
notes: [`package.json runs "${docsScript}" as check:docs`],
|
|
47
|
+
},
|
|
48
|
+
]),
|
|
49
|
+
)
|
|
50
|
+
.with({ kind: 'refused' }, (outcome) =>
|
|
51
|
+
buildPlanFromSteps([
|
|
52
|
+
buildLefthookStep(lefthookText),
|
|
53
|
+
{ reasons: [outcome.reason], writes: [], notes: [] },
|
|
54
|
+
]),
|
|
55
|
+
)
|
|
56
|
+
.exhaustive();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface InitStep {
|
|
60
|
+
readonly reasons: readonly string[];
|
|
61
|
+
readonly writes: readonly InitWrite[];
|
|
62
|
+
readonly notes: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildPlanFromSteps(steps: readonly InitStep[]): InitPlan {
|
|
66
|
+
const reasons = steps.flatMap((step) => step.reasons);
|
|
67
|
+
|
|
68
|
+
if (reasons.length > 0) {
|
|
69
|
+
return { kind: 'refused', reasons };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const writes = steps.flatMap((step) => step.writes);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
kind: 'apply',
|
|
76
|
+
writes,
|
|
77
|
+
notes:
|
|
78
|
+
writes.length === 0
|
|
79
|
+
? ['the repo already takes the baseline; nothing to change']
|
|
80
|
+
: steps.flatMap((step) => step.notes),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const keptStep: InitStep = { reasons: [], writes: [], notes: [] };
|
|
85
|
+
|
|
86
|
+
function buildLefthookStep(lefthookText: string | undefined): InitStep {
|
|
87
|
+
return match<LefthookOutcome, InitStep>(buildLefthookExtends(lefthookText))
|
|
88
|
+
.with({ kind: 'unchanged' }, () => keptStep)
|
|
89
|
+
.with({ kind: 'changed' }, (outcome) => ({
|
|
90
|
+
reasons: [],
|
|
91
|
+
writes: [{ file: 'lefthook.yml', text: outcome.text }],
|
|
92
|
+
notes: [`lefthook.yml extends ${baselinePath}`],
|
|
93
|
+
}))
|
|
94
|
+
.with({ kind: 'refused' }, (outcome) => ({ reasons: [outcome.reason], writes: [], notes: [] }))
|
|
95
|
+
.exhaustive();
|
|
96
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { isRecord } from '@zgeoff/std';
|
|
2
|
+
|
|
3
|
+
export const baselinePath = 'node_modules/@zgeoff/chex/lefthook.yml';
|
|
4
|
+
|
|
5
|
+
export type LefthookOutcome =
|
|
6
|
+
| { readonly kind: 'unchanged' }
|
|
7
|
+
| { readonly kind: 'changed'; readonly text: string }
|
|
8
|
+
| { readonly kind: 'refused'; readonly reason: string };
|
|
9
|
+
|
|
10
|
+
const extendsLine = /^extends:[ \t]*(?:#.*)?$/mu;
|
|
11
|
+
|
|
12
|
+
// A one-line flow list, such as `extends: [.lefthook/local.yml]`, with an optional comment after it.
|
|
13
|
+
const inlineExtends = /^(?<open>extends:[ \t]*\[)(?<items>[^\]\n]*)(?<close>\][ \t]*(?:#.*)?)$/mu;
|
|
14
|
+
|
|
15
|
+
// Edits the text rather than writing parsed YAML back, so the repo's comments and layout stay.
|
|
16
|
+
export function buildLefthookExtends(text: string | undefined): LefthookOutcome {
|
|
17
|
+
if (text === undefined) {
|
|
18
|
+
return { kind: 'changed', text: `extends:\n - ${baselinePath}\n` };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const config = parseConfig(text);
|
|
22
|
+
|
|
23
|
+
if (config === undefined) {
|
|
24
|
+
return { kind: 'refused', reason: 'lefthook.yml is not a YAML mapping' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const extended = config['extends'];
|
|
28
|
+
|
|
29
|
+
if (extended === undefined) {
|
|
30
|
+
return checkEdit(config, buildFirstExtends(text));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (Array.isArray(extended) && extended.includes(baselinePath)) {
|
|
34
|
+
return { kind: 'unchanged' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const inline = inlineExtends.exec(text);
|
|
38
|
+
|
|
39
|
+
if (Array.isArray(extended) && inline !== null) {
|
|
40
|
+
return checkEdit(config, buildInlineExtends(text, inline));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const match = extendsLine.exec(text);
|
|
44
|
+
|
|
45
|
+
if (!Array.isArray(extended) || match === null) {
|
|
46
|
+
return {
|
|
47
|
+
kind: 'refused',
|
|
48
|
+
reason: `lefthook.yml writes extends in a form chex init does not edit; add ${baselinePath} to its list`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const end = match.index + match[0].length;
|
|
53
|
+
|
|
54
|
+
return checkEdit(config, `${text.slice(0, end)}\n - ${baselinePath}${text.slice(end)}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseConfig(text: string): Record<string, unknown> | undefined {
|
|
58
|
+
let parsed: unknown;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
parsed = Bun.YAML.parse(text);
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (parsed === null) {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A flow mapping or a second YAML document reads differently once a block key joins the text.
|
|
74
|
+
function checkEdit(config: Record<string, unknown>, edited: string): LefthookOutcome {
|
|
75
|
+
const editedConfig = parseConfig(edited);
|
|
76
|
+
|
|
77
|
+
if (editedConfig !== undefined) {
|
|
78
|
+
const { extends: editedExtends, ...editedRest } = editedConfig;
|
|
79
|
+
const { extends: _, ...rest } = config;
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
Array.isArray(editedExtends) &&
|
|
83
|
+
editedExtends.includes(baselinePath) &&
|
|
84
|
+
Bun.deepEquals(editedRest, rest, true)
|
|
85
|
+
) {
|
|
86
|
+
return { kind: 'changed', text: edited };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
kind: 'refused',
|
|
92
|
+
reason: `lefthook.yml is laid out in a form chex init does not edit; add an extends list that names ${baselinePath}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const documentStart = /^(?:[ \t]*(?:#.*)?\n)*---[ \t]*(?:#.*)?(?=\n|$)/u;
|
|
97
|
+
|
|
98
|
+
function buildFirstExtends(text: string): string {
|
|
99
|
+
const start = documentStart.exec(text);
|
|
100
|
+
|
|
101
|
+
if (start === null) {
|
|
102
|
+
return `extends:\n - ${baselinePath}\n\n${text}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const end = start[0].length;
|
|
106
|
+
|
|
107
|
+
return `${text.slice(0, end)}\nextends:\n - ${baselinePath}\n${text.slice(end)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildInlineExtends(text: string, inline: RegExpExecArray): string {
|
|
111
|
+
const groups = inline.groups ?? {};
|
|
112
|
+
const items = groups['items'] ?? '';
|
|
113
|
+
const listed = items.trim() === '' ? baselinePath : `${items.trimEnd()}, ${baselinePath}`;
|
|
114
|
+
const end = inline.index + inline[0].length;
|
|
115
|
+
|
|
116
|
+
return `${text.slice(0, inline.index)}${groups['open'] ?? ''}${listed}${groups['close'] ?? ''}${text.slice(end)}`;
|
|
117
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const docsCheckNames = ['prose', 'length', 'links', 'readmes', 'vision', 'skill'] as const;
|
|
2
|
+
|
|
3
|
+
// The docs checks come first, so a usage message lists them before the repo checks.
|
|
4
|
+
export const checkNames = [
|
|
5
|
+
...docsCheckNames,
|
|
6
|
+
'commits',
|
|
7
|
+
'local-paths',
|
|
8
|
+
'test-timeouts',
|
|
9
|
+
'workflows',
|
|
10
|
+
'dependency-gates',
|
|
11
|
+
'uncommitted',
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export type CheckName = (typeof checkNames)[number];
|
|
15
|
+
|
|
16
|
+
export function isCheckName(value: string): value is CheckName {
|
|
17
|
+
return checkNames.some((name) => name === value);
|
|
18
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readOptionalText } from '@zgeoff/std';
|
|
5
|
+
import { runDependencyGatesCommand } from './dependency-gates/run-dependency-gates-check.ts';
|
|
6
|
+
import { makeCheckRunner } from './make-check-runner.ts';
|
|
7
|
+
import { runChex } from './run-chex.ts';
|
|
8
|
+
import { runCommitsCommand } from './run-commits-check.ts';
|
|
9
|
+
import { runInit } from './run-init.ts';
|
|
10
|
+
import { runLocalPathsCommand } from './run-local-paths-check.ts';
|
|
11
|
+
import { runTestTimeoutsCommand } from './run-test-timeouts-check.ts';
|
|
12
|
+
import { runUncommittedCommand } from './run-uncommitted-check.ts';
|
|
13
|
+
import { runWorkflowsCommand } from './run-workflows-check.ts';
|
|
14
|
+
import { writeTextFile } from './write-text-file.ts';
|
|
15
|
+
|
|
16
|
+
process.exitCode = await runChex(process.argv.slice(2), {
|
|
17
|
+
runCheck: makeCheckRunner({
|
|
18
|
+
docsFolder: join(import.meta.dir, 'docs'),
|
|
19
|
+
runScript: (argv) => Bun.spawn([...argv], { stdio: ['inherit', 'inherit', 'inherit'] }).exited,
|
|
20
|
+
repoChecks: {
|
|
21
|
+
commits: runCommitsCommand,
|
|
22
|
+
'local-paths': runLocalPathsCommand,
|
|
23
|
+
'test-timeouts': runTestTimeoutsCommand,
|
|
24
|
+
workflows: runWorkflowsCommand,
|
|
25
|
+
'dependency-gates': runDependencyGatesCommand,
|
|
26
|
+
uncommitted: runUncommittedCommand,
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
runInit: () =>
|
|
30
|
+
runInit({
|
|
31
|
+
repoRoot: process.cwd(),
|
|
32
|
+
readText: readOptionalText,
|
|
33
|
+
writeText: writeTextFile,
|
|
34
|
+
renameFile: (from, to) => rename(from, to),
|
|
35
|
+
removeFile: (path) => rm(path, { force: true }),
|
|
36
|
+
writeOutput: (text) => {
|
|
37
|
+
process.stdout.write(text);
|
|
38
|
+
},
|
|
39
|
+
writeError: (text) => {
|
|
40
|
+
process.stderr.write(text);
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
writeOutput: (text) => {
|
|
44
|
+
process.stdout.write(text);
|
|
45
|
+
},
|
|
46
|
+
writeError: (text) => {
|
|
47
|
+
process.stderr.write(text);
|
|
48
|
+
},
|
|
49
|
+
});
|