@zgeoff/chex 0.0.1 → 0.0.3

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 (54) 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-checks-profile.ts +57 -0
  42. package/src/read-pushed-ranges.ts +47 -0
  43. package/src/read-pushed-refs-input.ts +18 -0
  44. package/src/read-range-commits.ts +30 -0
  45. package/src/run-chex.ts +62 -0
  46. package/src/run-commits-check.ts +178 -0
  47. package/src/run-init.ts +129 -0
  48. package/src/run-local-paths-check.ts +185 -0
  49. package/src/run-program.ts +38 -0
  50. package/src/run-test-timeouts-check.ts +498 -0
  51. package/src/run-uncommitted-check.ts +82 -0
  52. package/src/run-workflows-check.ts +613 -0
  53. package/src/shell-escape-parsers.ts +197 -0
  54. package/src/write-text-file.ts +7 -0
package/README.md CHANGED
@@ -1,4 +1,97 @@
1
- # @zgeoff/chex
1
+ <div align="center">
2
+ <h1>@zgeoff/chex</h1>
2
3
 
3
- Run a repo's pre-push checks from one versioned baseline. Not released yet: this version only
4
- reserves the name.
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 skill` 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.1",
4
- "description": "Run a repo's pre-push checks from one versioned baseline",
3
+ "version": "0.0.3",
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
- "README.md"
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 skill';
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
+ });