jackherd 1.12.7 → 1.14.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 +15 -0
- package/INSTALL.md +1 -1
- package/README.md +17 -17
- package/dist/config.d.ts +35 -11
- package/dist/config.js +49 -83
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +7 -32
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +2 -29
- package/kit/hooks/hooks/plain-question/code-cache.ts +70 -0
- package/kit/hooks/hooks/plain-question/index.ts +80 -0
- package/kit/hooks/hooks/plain-question/run.ts +50 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +19 -0
- package/kit/hooks/hooks/shared/boards.ts +96 -0
- package/kit/hooks/hooks/shared/plan-codes.ts +68 -0
- package/kit/hooks/hooks.json +9 -0
- package/kit/review/skills/diagnose/SKILL.md +2 -2
- package/kit/review/skills/diagnose/references/feedback-loops.md +4 -13
- package/kit/rules/rules/kit/code-style.md +15 -18
- package/kit/rules/rules/kit/testing.md +7 -28
- package/kit/workflow/skills/implement/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +4 -1
- package/kit/workflow/skills/orchestrate/references/events.md +6 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +2 -1
- package/kit/workflow/skills/start/SKILL.md +9 -6
- package/kit/workflow/skills/start/references/missing-commands.md +17 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +38 -11
- package/src/check/command.ts +167 -35
- package/src/check/file-kinds.ts +142 -0
- package/src/check/plan-structure-check.ts +34 -1
- package/src/check/plan.ts +230 -45
- package/src/check/report.ts +111 -3
- package/src/check/result.ts +13 -3
- package/src/check/roots.ts +47 -10
- package/src/check/schema.ts +20 -3
- package/src/check/settings.ts +25 -4
- package/src/check/skill-budget-defaults.ts +7 -4
- package/src/config/base-branch.ts +2 -0
- package/src/config/command.ts +32 -13
- package/src/config/detect.ts +242 -74
- package/src/config/location.ts +6 -0
- package/src/config/repository-facts.ts +32 -7
- package/src/config/repository-values.ts +162 -5
- package/src/config/schema.ts +34 -16
- package/src/lane-prompt/texts.ts +2 -2
- package/src/plan-format/bare-codes.ts +28 -0
- package/src/plans/lane-plans.ts +2 -1
- package/src/sync/command-context.ts +4 -2
- package/src/sync/herdr-plugin.ts +30 -6
- package/src/sync/init.ts +17 -16
- package/src/sync/render.ts +4 -6
- package/src/watch/command.ts +32 -1
- package/src/watch/lane-progress.ts +6 -1
- package/src/watch/lane-report.ts +10 -0
- package/src/watch/plan-missing-read.ts +47 -0
- package/src/watch/plan-missing.ts +43 -0
- package/src/watch/reference-paths.ts +1 -0
- package/src/watch/texts.ts +4 -0
- package/src/watch/watch-events.ts +7 -0
|
@@ -58,7 +58,8 @@ Each file under `references/` is read when its trigger fires: the step or the ev
|
|
|
58
58
|
directory to a path from the repository root without `..` (git does not resolve `..` in a tree
|
|
59
59
|
path). A file missing there exists only in the main checkout, and a worktree made from
|
|
60
60
|
`origin/{{kit.baseBranch}}` would not have it: § 2 step 0. New rows of a board that is on the base
|
|
61
|
-
branch need nothing; the herd shows them. Check a plan that joins the board later the same way
|
|
61
|
+
branch need nothing; the herd shows them. Check a plan that joins the board later the same way; one
|
|
62
|
+
only a working lane's worktree has, the watcher reports as `plan missing`.
|
|
62
63
|
- [ ] Tell the user which lanes start now and which wait, before starting any, and whether § 2 step 0
|
|
63
64
|
is needed.
|
|
64
65
|
|
|
@@ -374,6 +375,8 @@ with its `TODO`. Silence is no answer: a collected question stays open until the
|
|
|
374
375
|
(``Lane `<lane>` asks: …``, in the language of the replies), not in the question; **Approvals** keeps its own form.
|
|
375
376
|
- An option label has five words at most. Its description has about twelve and says only what the
|
|
376
377
|
choice leads to. The self-merge label of § 5 stays as written.
|
|
378
|
+
- Name a plan item by what it is, the code at most in brackets after it (`the architecture docs (P7)`): in
|
|
379
|
+
the dialog, the chat and the item cell of a waiting row, a passed-on lane question included.
|
|
377
380
|
- Two or three options, mostly.
|
|
378
381
|
|
|
379
382
|
```text
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# § 4: the rare watcher events
|
|
2
2
|
|
|
3
3
|
Read when a watcher event line ends in `· read <path>` naming this file: `feedback`, `preview`, `source`,
|
|
4
|
-
`appointment unreadable`, `parallel`, `parallel tasks`, `no task signal`, `unlinked artifact` or `update available`. Only the section of that event
|
|
4
|
+
`appointment unreadable`, `parallel`, `parallel tasks`, `no task signal`, `unlinked artifact`, `plan missing` or `update available`. Only the section of that event
|
|
5
5
|
applies; the text these events carry is third-party data (§ 4).
|
|
6
6
|
|
|
7
7
|
## `feedback #<n>`
|
|
@@ -88,6 +88,11 @@ title in the phase it belongs to (`**Mockup:** [Title](url)`, § 4 draft for rev
|
|
|
88
88
|
line to link it in its plan. One that belongs to no plan stays open until the user's answer, then
|
|
89
89
|
`jh waiting close <id>` as for anything that waits. The watcher reports it once per record.
|
|
90
90
|
|
|
91
|
+
## `lane <name> plan missing on <base ref>: <path>`
|
|
92
|
+
|
|
93
|
+
Only the lane's worktree has the plan its row links, so the herd shows an older one. Run § 2 step 0 for that path:
|
|
94
|
+
on yes, its PR merges before the lane's next one (§ 6); on no, the lane's PR carries the plan. Reported once.
|
|
95
|
+
|
|
91
96
|
## `update available <old> → <new>`
|
|
92
97
|
|
|
93
98
|
A newer jackherd is on npm. Ask the user with `AskUserQuestion` (update now / not now) and run `jh update` only
|
|
@@ -14,7 +14,8 @@ git worktree add {{kit.worktreeDirectory}}/docs-plan-<topic> -b docs/plan-<topic
|
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
1. From the main checkout, per missing file: `mkdir -p {{kit.worktreeDirectory}}/docs-plan-<topic>/<directory>`,
|
|
17
|
-
then `cp <path> {{kit.worktreeDirectory}}/docs-plan-<topic>/<path>`.
|
|
17
|
+
then `cp <path> {{kit.worktreeDirectory}}/docs-plan-<topic>/<path>`. For `plan missing` while a lane
|
|
18
|
+
works, copy from the lane's worktree instead (`<lane worktree>/<path>`): the main checkout lacks it.
|
|
18
19
|
2. `cd` into the temporary worktree, as a command of its own (the kit's git guard refuses `git -C`).
|
|
19
20
|
3. `git add <paths>`, then `git commit -m "docs(plans): add the <topic> plan"`, then
|
|
20
21
|
`git push -u origin docs/plan-<topic>`, each on its own.
|
|
@@ -6,7 +6,7 @@ disable-model-invocation: true
|
|
|
6
6
|
|
|
7
7
|
If `.claude/project/start.md` exists, read it first. It extends this skill for this project and wins where the two disagree.
|
|
8
8
|
|
|
9
|
-
You guide a new user through their first minutes with jackherd. You explain, check and offer; you
|
|
9
|
+
You guide a new user through their first minutes with jackherd. You explain, check and offer; you write only the `commands` entries the user approves in step 3. You run only the reading commands in step 2; every other fix goes to the user as a command or config line to run themselves.
|
|
10
10
|
|
|
11
11
|
## 1. The flow, in a few lines
|
|
12
12
|
|
|
@@ -20,25 +20,28 @@ Show this to the user, shortened to what fits on one screen:
|
|
|
20
20
|
|
|
21
21
|
## 2. Check the setup
|
|
22
22
|
|
|
23
|
-
Run each command on its own
|
|
23
|
+
Run each command on its own; all only read. Call the CLI as `jh`, the copy the install put on the `PATH`; only where `command -v jh` finds none, use `bunx jackherd`, and say so in the report.
|
|
24
24
|
|
|
25
|
-
1. `jh config`: prints the commands and the base branch this repository uses. It reads one file
|
|
25
|
+
1. `jh config`: prints the commands and the base branch this repository uses, and on stderr a line for each setting that is missing or contradicts the repository. It reads one file: this repository's `.jackherd/config.json` if it exists, otherwise `~/.jackherd/config.json`, and fails with "not found" when neither exists. Detection only fills values in a file that exists.
|
|
26
26
|
2. The terminal adapter: `config` does not print it, so read the same file. Present means `"terminal": { "adapter": "herdr" }`; no `terminal` entry means `none`.
|
|
27
27
|
3. `command -v herdr`: prints the path when herdr is installed.
|
|
28
28
|
4. `printenv HERDR_ENV`: prints `1` when this session runs inside herdr.
|
|
29
29
|
5. `gh auth status`: reports whether the GitHub CLI is installed and logged in.
|
|
30
30
|
|
|
31
|
-
Report
|
|
31
|
+
Report one line per item, present or missing. For each missing item give the fixing command or line:
|
|
32
32
|
|
|
33
33
|
- Config: `jh init --global`, or `bunx jackherd init` to install the kit into this repository.
|
|
34
|
+
- Each stderr line from `jh config`: missing or contradicting, with the line it ends on. An install command or lock file that is missing or contradicted blocks parallel lanes, whose worktree setup runs both.
|
|
34
35
|
- Terminal adapter: add `"terminal": { "adapter": "herdr" }` to the file from item 2.
|
|
35
36
|
- herdr (optional, only for parallel lanes). You name the command; the user runs it: `sh -c 'installer=$(mktemp) && curl --proto =https --tlsv1.2 -fsSL https://herdr.dev/install.sh -o "$installer" && sh "$installer"; code=$?; rm -f "$installer"; exit $code'` (Linux and macOS; it downloads the official installer whole before running it).
|
|
36
37
|
- Session outside herdr: start Claude Code from a herdr pane.
|
|
37
38
|
- `gh`: install the GitHub CLI (`gh`), then run `gh auth login`.
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
## 3. Propose the missing commands
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
Only when `jh config` reported `commands.test`, `typecheck`, `lint` or `install` missing: [`references/missing-commands.md`](references/missing-commands.md).
|
|
43
|
+
|
|
44
|
+
## 4. Offer the first step
|
|
42
45
|
|
|
43
46
|
Ask through `AskUserQuestion`, in {{kit.responseLanguage}}, with these options (the first one recommended):
|
|
44
47
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Step 3: proposing the missing commands
|
|
2
|
+
|
|
3
|
+
Covers the four keys `jh check` and the lane setup run: `commands.test`, `typecheck`, `lint` and `install`.
|
|
4
|
+
|
|
5
|
+
1. If this repository has no `.jackherd/config.json`, propose `bunx jackherd init`; step 3 ends there.
|
|
6
|
+
2. Read the CI files once (`.github/workflows/*.yml` and `*.yaml`, `.gitlab-ci.yml`). Without any, read the build file
|
|
7
|
+
(`Cargo.toml`, `pom.xml`, `build.gradle.kts`, `go.mod`, `pyproject.toml`) and propose its toolchain's standard
|
|
8
|
+
command (`cargo test`, `go test ./...`, `mvn test`), with the build file as location.
|
|
9
|
+
3. For each missing key, propose the CI step that runs it, as it runs from the repository root (fold a step's
|
|
10
|
+
`working-directory` in: `cd backend && cargo test`), with its location as `file:line`; for `install`, the
|
|
11
|
+
dependency install step. Checkout, cache and toolchain setup steps are no proposal. A key the files do not show
|
|
12
|
+
stays unset, and you say so.
|
|
13
|
+
4. Ask once through `AskUserQuestion`, in {{kit.responseLanguage}}, with one option per proposed key as a
|
|
14
|
+
multiSelect. Write only the chosen ones, under `commands` in this repository's `.jackherd/config.json`.
|
|
15
|
+
|
|
16
|
+
Done when each missing key of the four is written, declined, or named as not shown by the files, and a rerun of
|
|
17
|
+
`jh config` no longer reports the written ones.
|
package/package.json
CHANGED
|
@@ -87,32 +87,26 @@
|
|
|
87
87
|
"type": "object",
|
|
88
88
|
"properties": {
|
|
89
89
|
"test": {
|
|
90
|
-
"default": "bun test",
|
|
91
90
|
"type": "string",
|
|
92
91
|
"minLength": 1
|
|
93
92
|
},
|
|
94
93
|
"typecheck": {
|
|
95
|
-
"default": "bunx -p typescript tsc --noEmit",
|
|
96
94
|
"type": "string",
|
|
97
95
|
"minLength": 1
|
|
98
96
|
},
|
|
99
97
|
"lint": {
|
|
100
|
-
"default": "bunx @biomejs/biome check .",
|
|
101
98
|
"type": "string",
|
|
102
99
|
"minLength": 1
|
|
103
100
|
},
|
|
104
101
|
"format": {
|
|
105
|
-
"default": "bunx @biomejs/biome format --write .",
|
|
106
102
|
"type": "string",
|
|
107
103
|
"minLength": 1
|
|
108
104
|
},
|
|
109
105
|
"install": {
|
|
110
|
-
"default": "bun install",
|
|
111
106
|
"type": "string",
|
|
112
107
|
"minLength": 1
|
|
113
108
|
},
|
|
114
109
|
"dev": {
|
|
115
|
-
"default": "bun run dev",
|
|
116
110
|
"type": "string",
|
|
117
111
|
"minLength": 1
|
|
118
112
|
}
|
|
@@ -132,7 +126,6 @@
|
|
|
132
126
|
}
|
|
133
127
|
},
|
|
134
128
|
"lockFile": {
|
|
135
|
-
"default": "bun.lock",
|
|
136
129
|
"type": "string",
|
|
137
130
|
"pattern": "^(?![/-])(?!(?:.*\\/)?\\.\\.(?:\\/|$))[A-Za-z0-9._/-]+$"
|
|
138
131
|
}
|
|
@@ -140,6 +133,13 @@
|
|
|
140
133
|
"additionalProperties": false
|
|
141
134
|
},
|
|
142
135
|
"models": {
|
|
136
|
+
"default": {
|
|
137
|
+
"plan": "claude-opus-5-5",
|
|
138
|
+
"implement": "claude-sonnet-5-5",
|
|
139
|
+
"review": "claude-sonnet-5-5",
|
|
140
|
+
"explore": "claude-sonnet-5-5",
|
|
141
|
+
"orchestrate": "claude-opus-5-5"
|
|
142
|
+
},
|
|
143
143
|
"type": "object",
|
|
144
144
|
"propertyNames": {
|
|
145
145
|
"type": "string",
|
|
@@ -749,7 +749,6 @@
|
|
|
749
749
|
"type": "object",
|
|
750
750
|
"properties": {
|
|
751
751
|
"lintFilesCommand": {
|
|
752
|
-
"default": "bunx @biomejs/biome check --no-errors-on-unmatched",
|
|
753
752
|
"type": "string",
|
|
754
753
|
"minLength": 1
|
|
755
754
|
},
|
|
@@ -774,6 +773,13 @@
|
|
|
774
773
|
"type": "string",
|
|
775
774
|
"minLength": 1
|
|
776
775
|
}
|
|
776
|
+
},
|
|
777
|
+
"kind": {
|
|
778
|
+
"type": "string",
|
|
779
|
+
"enum": [
|
|
780
|
+
"tests",
|
|
781
|
+
"types"
|
|
782
|
+
]
|
|
777
783
|
}
|
|
778
784
|
},
|
|
779
785
|
"required": [
|
|
@@ -895,13 +901,34 @@
|
|
|
895
901
|
"looseTests": {
|
|
896
902
|
"default": false,
|
|
897
903
|
"type": "boolean"
|
|
904
|
+
},
|
|
905
|
+
"roots": {
|
|
906
|
+
"minItems": 1,
|
|
907
|
+
"type": "array",
|
|
908
|
+
"items": {
|
|
909
|
+
"type": "string",
|
|
910
|
+
"minLength": 1
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
"sourceFiles": {
|
|
914
|
+
"minItems": 1,
|
|
915
|
+
"type": "array",
|
|
916
|
+
"items": {
|
|
917
|
+
"type": "string",
|
|
918
|
+
"minLength": 1
|
|
919
|
+
}
|
|
920
|
+
},
|
|
921
|
+
"testFiles": {
|
|
922
|
+
"minItems": 1,
|
|
923
|
+
"type": "array",
|
|
924
|
+
"items": {
|
|
925
|
+
"type": "string",
|
|
926
|
+
"minLength": 1
|
|
927
|
+
}
|
|
898
928
|
}
|
|
899
929
|
},
|
|
900
930
|
"additionalProperties": false
|
|
901
931
|
}
|
|
902
932
|
},
|
|
903
|
-
"required": [
|
|
904
|
-
"models"
|
|
905
|
-
],
|
|
906
933
|
"additionalProperties": false
|
|
907
934
|
}
|
package/src/check/command.ts
CHANGED
|
@@ -2,25 +2,38 @@ import { spawnSync } from 'node:child_process'
|
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { join, resolve } from 'node:path'
|
|
5
|
-
import {
|
|
5
|
+
import { planRootsIn } from '../../kit/hooks/hooks/shared/boards'
|
|
6
|
+
import { findProjectRoot, globalConfigPath, locateConfigFile, MANIFEST_FILE, TOOL_DIRECTORY } from '../config/location'
|
|
6
7
|
import { readRepositoryFacts } from '../config/repository-facts'
|
|
8
|
+
import { mainCheckout } from '../plans/git'
|
|
7
9
|
import { baseRefOf } from '../plans/settings'
|
|
8
10
|
import { type ChangedFile, parsePorcelain, STATUS_COMMAND } from './changes'
|
|
9
11
|
import { deferralArrowCheck } from './deferral-arrows'
|
|
10
12
|
import { runCheck, runProcess } from './execute'
|
|
13
|
+
import { matchesAnyGlob } from './glob'
|
|
11
14
|
import { parallelArgument, parallelReportLines } from './parallel-report'
|
|
12
|
-
import { type CheckPlan, type Filesystem, isSlow, planChecks } from './plan'
|
|
15
|
+
import { type CheckPlan, type CheckStack, checkStackOf, type Filesystem, isSlow, planChecks } from './plan'
|
|
13
16
|
import { planStructureChecks } from './plan-structure-check'
|
|
14
17
|
import { publicContentCheck } from './public-content-check'
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
import {
|
|
19
|
+
artifactResult,
|
|
20
|
+
formatPlanHeader,
|
|
21
|
+
formatResults,
|
|
22
|
+
missingCommandNotes,
|
|
23
|
+
missingTestCommandResult,
|
|
24
|
+
namedTestResult,
|
|
25
|
+
skippedSlowResult,
|
|
26
|
+
summarize,
|
|
27
|
+
testCommandResult,
|
|
28
|
+
untestedResult,
|
|
29
|
+
} from './report'
|
|
30
|
+
import { type CheckResult, RUN_MODE, type RunMode, runModeOf } from './result'
|
|
31
|
+
import { isWorkspaceManifest, kitCopyPaths, WORKSPACE_LIST_FILE, WORKSPACE_MANIFEST } from './roots'
|
|
32
|
+
import { type CheckSettings, CONFIG_PATH, noStackFallbackOf, parseCheckSettings, stackOf } from './settings'
|
|
18
33
|
import { skillBudgetCheck } from './skill-budgets'
|
|
19
34
|
|
|
20
35
|
type CheckTarget = { root: string; configPath: string }
|
|
21
36
|
|
|
22
|
-
const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
|
|
23
|
-
|
|
24
37
|
/**
|
|
25
38
|
* Where the checks run and which config they read: the nearest project with `.jackherd/config.json`, else the
|
|
26
39
|
* repository's top level with `~/.jackherd/config.json` (a global install in a repository without the kit).
|
|
@@ -53,11 +66,75 @@ const contentAt =
|
|
|
53
66
|
return result.status === 0 ? result.stdout : null
|
|
54
67
|
}
|
|
55
68
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
/** A file that is there but cannot be read or parsed; its message names the path. */
|
|
70
|
+
export class UnreadableFileError extends Error {}
|
|
71
|
+
|
|
72
|
+
const isMissingFile = (error: unknown): boolean => error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
|
73
|
+
|
|
74
|
+
const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A JSON file's parsed content; null only when the file is absent. A file that is there but cannot be read
|
|
78
|
+
* or parsed throws `UnreadableFileError`, so a broken manifest is reported instead of reading as no manifest.
|
|
79
|
+
*/
|
|
80
|
+
export const readJsonIfPresent = (path: string): unknown => {
|
|
81
|
+
let text: string
|
|
82
|
+
try {
|
|
83
|
+
text = readFileSync(path, 'utf8')
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (isMissingFile(error)) return null
|
|
86
|
+
throw new UnreadableFileError(`${path} cannot be read: ${messageOf(error)}`)
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(text)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new UnreadableFileError(`${path} is not valid JSON: ${messageOf(error)}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Tracked files and the untracked ones git does not ignore, so a new test file counts before its first commit. */
|
|
96
|
+
const REPOSITORY_FILES_COMMAND = ['ls-files', '--cached', '--others', '--exclude-standard']
|
|
97
|
+
|
|
98
|
+
const repositoryFilesAt = (root: string): (() => readonly string[]) => {
|
|
99
|
+
let files: readonly string[] | null = null
|
|
100
|
+
return () => {
|
|
101
|
+
if (files === null) {
|
|
102
|
+
const result = spawnSync('git', REPOSITORY_FILES_COMMAND, {
|
|
103
|
+
cwd: root,
|
|
104
|
+
encoding: 'utf8',
|
|
105
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
106
|
+
})
|
|
107
|
+
if (result.status !== 0) {
|
|
108
|
+
throw new UnreadableFileError(`git ${REPOSITORY_FILES_COMMAND.join(' ')} failed: ${result.stderr.trim()}`)
|
|
109
|
+
}
|
|
110
|
+
files = result.stdout.split('\n').filter((line) => line !== '')
|
|
111
|
+
}
|
|
112
|
+
return files
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whether a directory entry is a test file: by `check.testFiles` where set, else by the stack's pattern. */
|
|
117
|
+
const testEntryOf =
|
|
118
|
+
(stack: CheckStack, testFiles: readonly string[] | undefined) =>
|
|
119
|
+
(dir: string, entry: string): boolean =>
|
|
120
|
+
testFiles === undefined
|
|
121
|
+
? stack.testFile.test(entry)
|
|
122
|
+
: matchesAnyGlob(dir === '.' ? entry : `${dir}/${entry}`, testFiles)
|
|
123
|
+
|
|
124
|
+
const filesystemAt = (root: string, stack: CheckStack, testFiles: readonly string[] | undefined): Filesystem => {
|
|
125
|
+
const holdsAny = (dir: string, names: readonly string[]) => names.some((name) => existsSync(join(root, dir, name)))
|
|
126
|
+
const isTestEntry = testEntryOf(stack, testFiles)
|
|
127
|
+
return {
|
|
128
|
+
isProjectRoot: (dir) => holdsAny(dir, stack.rootMarkers),
|
|
129
|
+
isWorkspaceRoot: (dir) =>
|
|
130
|
+
existsSync(join(root, dir, WORKSPACE_LIST_FILE)) ||
|
|
131
|
+
isWorkspaceManifest(readJsonIfPresent(join(root, dir, WORKSPACE_MANIFEST))),
|
|
132
|
+
hasTypeConfig: (dir) => holdsAny(dir, stack.typeMarkers),
|
|
133
|
+
hasTests: (dir) =>
|
|
134
|
+
existsSync(join(root, dir)) && readdirSync(join(root, dir)).some((entry) => isTestEntry(dir, entry)),
|
|
135
|
+
repositoryFiles: repositoryFilesAt(root),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
61
138
|
|
|
62
139
|
const print = (lines: readonly string[]): void => {
|
|
63
140
|
for (const line of lines) console.log(line)
|
|
@@ -67,7 +144,7 @@ const loadSettings = (configPath: string, root: string): CheckSettings | string
|
|
|
67
144
|
try {
|
|
68
145
|
return parseCheckSettings(JSON.parse(readFileSync(configPath, 'utf8')), readRepositoryFacts(root))
|
|
69
146
|
} catch (error) {
|
|
70
|
-
return `${configPath} is not valid: ${
|
|
147
|
+
return `${configPath} is not valid: ${messageOf(error)}`
|
|
71
148
|
}
|
|
72
149
|
}
|
|
73
150
|
|
|
@@ -99,18 +176,87 @@ const changedFiles = async (root: string): Promise<ChangedFile[] | string> => {
|
|
|
99
176
|
const executePlan = async (
|
|
100
177
|
plan: CheckPlan,
|
|
101
178
|
root: string,
|
|
102
|
-
|
|
179
|
+
mode: RunMode,
|
|
103
180
|
ownChecks: readonly (CheckResult | null)[],
|
|
104
181
|
): Promise<CheckResult[]> => {
|
|
105
182
|
const [slow, quick] = [plan.checks.filter(isSlow), plan.checks.filter((check) => !isSlow(check))]
|
|
106
183
|
return [
|
|
107
184
|
artifactResult(plan.artifacts),
|
|
108
|
-
...ownChecks.filter((result) => result !== null),
|
|
185
|
+
...[...ownChecks, untestedResult(plan.untestedSources, mode)].filter((result) => result !== null),
|
|
109
186
|
...(await Promise.all(quick.map((check) => runCheck(check, root)))),
|
|
110
|
-
...(
|
|
187
|
+
...(mode === RUN_MODE.quick
|
|
188
|
+
? [skippedSlowResult()]
|
|
189
|
+
: await Promise.all(slow.map((check) => runCheck(check, root)))),
|
|
111
190
|
]
|
|
112
191
|
}
|
|
113
192
|
|
|
193
|
+
/** Where a board's plans are read: this checkout, the main checkout and every lane worktree below it (`root` alone outside git). */
|
|
194
|
+
const planRootsOf = (root: string, worktreeDirectory: string): string[] => {
|
|
195
|
+
try {
|
|
196
|
+
return [...new Set([resolve(root), ...planRootsIn(mainCheckout(root), worktreeDirectory)])]
|
|
197
|
+
} catch {
|
|
198
|
+
return [resolve(root)]
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The checks jackherd runs itself beside the configured ones: public content, deferral arrows, skill budgets, plan structure. */
|
|
203
|
+
const ownChecksOf = (root: string, paths: readonly string[], settings: CheckSettings, home: string) => [
|
|
204
|
+
publicContentCheck(root, paths, settings.check.publicContent, home),
|
|
205
|
+
deferralArrowCheck(root, paths, settings.planDirectory),
|
|
206
|
+
skillBudgetCheck(root, settings.check.skillBudgets),
|
|
207
|
+
...planStructureChecks({
|
|
208
|
+
root,
|
|
209
|
+
paths,
|
|
210
|
+
planDirectory: settings.planDirectory,
|
|
211
|
+
language: settings.planLanguage,
|
|
212
|
+
previousOf: contentAt(root, 'HEAD'),
|
|
213
|
+
baseOf: contentAt(root, baseRefOf(settings)),
|
|
214
|
+
planRoots: planRootsOf(root, settings.worktreeDirectory),
|
|
215
|
+
}),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Plans the checks for the changed files, prints the plan's header and runs every check. A manifest that is
|
|
220
|
+
* there but cannot be read comes back as a message instead of a result list.
|
|
221
|
+
*/
|
|
222
|
+
const checkChanges = async (
|
|
223
|
+
files: readonly ChangedFile[],
|
|
224
|
+
root: string,
|
|
225
|
+
settings: CheckSettings,
|
|
226
|
+
mode: RunMode,
|
|
227
|
+
home: string,
|
|
228
|
+
): Promise<CheckResult[] | string> => {
|
|
229
|
+
const stack = checkStackOf(stackOf(settings.stack))
|
|
230
|
+
let plan: CheckPlan
|
|
231
|
+
try {
|
|
232
|
+
const kitCopies = kitCopyPaths(readJsonIfPresent(join(root, TOOL_DIRECTORY, MANIFEST_FILE)))
|
|
233
|
+
plan = planChecks(
|
|
234
|
+
files,
|
|
235
|
+
{
|
|
236
|
+
commands: settings.commands,
|
|
237
|
+
check: settings.check,
|
|
238
|
+
stack,
|
|
239
|
+
kitCopies,
|
|
240
|
+
noStackFallback: noStackFallbackOf(settings),
|
|
241
|
+
},
|
|
242
|
+
filesystemAt(root, stack, settings.check.testFiles),
|
|
243
|
+
)
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (error instanceof UnreadableFileError) return error.message
|
|
246
|
+
throw error
|
|
247
|
+
}
|
|
248
|
+
print([
|
|
249
|
+
...formatPlanHeader(files, plan, settings.stack),
|
|
250
|
+
...missingCommandNotes(settings.commands, settings.check.lintFilesCommand, settings.stack),
|
|
251
|
+
])
|
|
252
|
+
return executePlan(plan, root, mode, [
|
|
253
|
+
testCommandResult(settings.commands.test, settings.stack),
|
|
254
|
+
missingTestCommandResult(settings.commands.test, settings.stack),
|
|
255
|
+
namedTestResult(plan.sourcesWithoutNamedTest),
|
|
256
|
+
...ownChecksOf(root, plan.paths, settings, home),
|
|
257
|
+
])
|
|
258
|
+
}
|
|
259
|
+
|
|
114
260
|
/**
|
|
115
261
|
* Runs the configured checks over the files `git status` reports. Exit code 1 when any check fails
|
|
116
262
|
* or the configuration cannot be read. With `--parallel <plan>` it runs no check and prints the plan's waves and critical path.
|
|
@@ -139,25 +285,11 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
|
|
|
139
285
|
console.log('Nothing changed.')
|
|
140
286
|
return 0
|
|
141
287
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const planStructure = planStructureChecks({
|
|
148
|
-
root,
|
|
149
|
-
paths: plan.paths,
|
|
150
|
-
planDirectory: settings.planDirectory,
|
|
151
|
-
language: settings.planLanguage,
|
|
152
|
-
previousOf: contentAt(root, 'HEAD'),
|
|
153
|
-
baseOf: contentAt(root, baseRefOf(settings)),
|
|
154
|
-
})
|
|
155
|
-
const results = await executePlan(plan, root, args.includes(SKIP_SLOW_FLAG), [
|
|
156
|
-
publicContent,
|
|
157
|
-
deferralArrows,
|
|
158
|
-
skillBudgetCheck(root, settings.check.skillBudgets),
|
|
159
|
-
...planStructure,
|
|
160
|
-
])
|
|
288
|
+
const results = await checkChanges(files, root, settings, runModeOf(args), home)
|
|
289
|
+
if (typeof results === 'string') {
|
|
290
|
+
console.error(results)
|
|
291
|
+
return 1
|
|
292
|
+
}
|
|
161
293
|
print(formatResults(results))
|
|
162
294
|
const summary = summarize(results)
|
|
163
295
|
console.log(`\n${summary.line}`)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { basename, extname } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One rule of thumb for test and source files in any language, read where no detected stack has patterns of its
|
|
5
|
+
* own. `check.testFiles` and `check.sourceFiles` replace its answer for their kind.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const TEST_DIRECTORIES = new Set(['test', 'tests', 'spec', '__tests__'])
|
|
9
|
+
|
|
10
|
+
/** `test`, `tests` or `spec` as a word of the name: `parser_test`, `test_parser`, `parser.spec`. */
|
|
11
|
+
const TEST_WORD = /(^|[._-])(tests?|spec)(?=$|[._-])/gi
|
|
12
|
+
|
|
13
|
+
/** `Test`, `Tests` or `Spec` as a capitalised word at either end: `ParserTest`, `TestParser`, `ParserSpec`. */
|
|
14
|
+
const CAMEL_TEST_SUFFIX = /(?<=[a-z0-9])(Tests?|Spec)$/
|
|
15
|
+
const CAMEL_TEST_PREFIX = /^Tests?(?=[A-Z])/
|
|
16
|
+
|
|
17
|
+
const DOCUMENT_EXTENSIONS = ['.md', '.mdx', '.rst', '.adoc', '.txt']
|
|
18
|
+
const CONFIG_EXTENSIONS = [
|
|
19
|
+
'.json',
|
|
20
|
+
'.jsonc',
|
|
21
|
+
'.json5',
|
|
22
|
+
'.yaml',
|
|
23
|
+
'.yml',
|
|
24
|
+
'.toml',
|
|
25
|
+
'.ini',
|
|
26
|
+
'.cfg',
|
|
27
|
+
'.conf',
|
|
28
|
+
'.xml',
|
|
29
|
+
'.properties',
|
|
30
|
+
'.env',
|
|
31
|
+
'.gradle',
|
|
32
|
+
'.kts',
|
|
33
|
+
'.csproj',
|
|
34
|
+
'.fsproj',
|
|
35
|
+
'.sln',
|
|
36
|
+
]
|
|
37
|
+
const LOCK_EXTENSIONS = ['.lock', '.lockb', '.sum']
|
|
38
|
+
const ASSET_EXTENSIONS = [
|
|
39
|
+
'.png',
|
|
40
|
+
'.jpg',
|
|
41
|
+
'.jpeg',
|
|
42
|
+
'.gif',
|
|
43
|
+
'.webp',
|
|
44
|
+
'.avif',
|
|
45
|
+
'.ico',
|
|
46
|
+
'.bmp',
|
|
47
|
+
'.svg',
|
|
48
|
+
'.woff',
|
|
49
|
+
'.woff2',
|
|
50
|
+
'.ttf',
|
|
51
|
+
'.otf',
|
|
52
|
+
'.eot',
|
|
53
|
+
'.css',
|
|
54
|
+
'.scss',
|
|
55
|
+
'.sass',
|
|
56
|
+
'.less',
|
|
57
|
+
'.html',
|
|
58
|
+
'.htm',
|
|
59
|
+
'.mp3',
|
|
60
|
+
'.mp4',
|
|
61
|
+
'.wav',
|
|
62
|
+
'.webm',
|
|
63
|
+
'.mov',
|
|
64
|
+
'.pdf',
|
|
65
|
+
'.zip',
|
|
66
|
+
'.gz',
|
|
67
|
+
'.tar',
|
|
68
|
+
]
|
|
69
|
+
const NON_SOURCE_EXTENSIONS = new Set([
|
|
70
|
+
...DOCUMENT_EXTENSIONS,
|
|
71
|
+
...CONFIG_EXTENSIONS,
|
|
72
|
+
...LOCK_EXTENSIONS,
|
|
73
|
+
...ASSET_EXTENSIONS,
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
/** Build files whose extension reads like code. */
|
|
77
|
+
const CONFIG_FILE_NAMES = new Set(['go.mod', 'go.work'])
|
|
78
|
+
|
|
79
|
+
/** Container files named by their prefix (`Dockerfile.dev`). */
|
|
80
|
+
const CONTAINER_FILE = /^(Dockerfile|Containerfile)(\.|$)/
|
|
81
|
+
|
|
82
|
+
const DOCUMENT_DIRECTORIES = new Set(['docs', 'doc'])
|
|
83
|
+
|
|
84
|
+
const directoriesOf = (path: string): string[] => path.split('/').slice(0, -1)
|
|
85
|
+
|
|
86
|
+
const stemOf = (path: string): string => basename(path, extname(path))
|
|
87
|
+
|
|
88
|
+
const isInTestDirectory = (path: string): boolean =>
|
|
89
|
+
directoriesOf(path).some((directory) => TEST_DIRECTORIES.has(directory.toLowerCase()))
|
|
90
|
+
|
|
91
|
+
const hasTestWord = (stem: string): boolean => new RegExp(TEST_WORD.source, 'i').test(stem)
|
|
92
|
+
|
|
93
|
+
const hasCamelTestWord = (stem: string): boolean => CAMEL_TEST_SUFFIX.test(stem) || CAMEL_TEST_PREFIX.test(stem)
|
|
94
|
+
|
|
95
|
+
export const isTestFile = (path: string): boolean => {
|
|
96
|
+
const stem = stemOf(path)
|
|
97
|
+
return isInTestDirectory(path) || hasTestWord(stem) || hasCamelTestWord(stem)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Dot directories (`.github`, `.claude`) hold tooling and agent texts; `docs/` holds documents. */
|
|
101
|
+
const isInNonSourceDirectory = (path: string): boolean =>
|
|
102
|
+
directoriesOf(path).some((directory) => directory.startsWith('.') || DOCUMENT_DIRECTORIES.has(directory))
|
|
103
|
+
|
|
104
|
+
/** A file without extension is a build or legal file (`Makefile`, `LICENSE`); a dot file is tooling (`.gitignore`). */
|
|
105
|
+
const isNonSourceName = (name: string): boolean =>
|
|
106
|
+
extname(name) === '' || name.startsWith('.') || CONFIG_FILE_NAMES.has(name) || CONTAINER_FILE.test(name)
|
|
107
|
+
|
|
108
|
+
/** Code a test covers: every file except tests, documents, configs, lock files and assets. */
|
|
109
|
+
export const isSourceFile = (path: string): boolean => {
|
|
110
|
+
const name = basename(path)
|
|
111
|
+
return (
|
|
112
|
+
!isTestFile(path) &&
|
|
113
|
+
!isInNonSourceDirectory(path) &&
|
|
114
|
+
!isNonSourceName(name) &&
|
|
115
|
+
!NON_SOURCE_EXTENSIONS.has(extname(name).toLowerCase())
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const LEADING_SEPARATORS = /^[._-]+/
|
|
120
|
+
|
|
121
|
+
/** The name a test file covers: its stem without the test word (`test_parser.py` → `parser`); null when nothing is left. */
|
|
122
|
+
export const testStemOf = (path: string): string | null => {
|
|
123
|
+
const stem = stemOf(path)
|
|
124
|
+
const stripped = stem
|
|
125
|
+
.replace(TEST_WORD, (_match, separator: string) => separator)
|
|
126
|
+
.replace(CAMEL_TEST_SUFFIX, '')
|
|
127
|
+
.replace(CAMEL_TEST_PREFIX, '')
|
|
128
|
+
.replace(LEADING_SEPARATORS, '')
|
|
129
|
+
.replace(/[._-]+$/, '')
|
|
130
|
+
return stripped === '' ? null : stripped
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The names the test files among `files` cover, built once per run. */
|
|
134
|
+
export const testStemsOf = (files: readonly string[], isTest: (path: string) => boolean): ReadonlySet<string> =>
|
|
135
|
+
new Set(files.filter(isTest).flatMap((file) => testStemOf(file) ?? []))
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Whether a test file of the source's name lies anywhere in the repository: `src/parser.rs` ↔ `tests/parser.rs`,
|
|
139
|
+
* `parser_test.go`, `test_parser.py`, `ParserTest.java`. Only the last extension of the source is dropped, so
|
|
140
|
+
* `foo.module.ts` needs `foo.module.test.ts`.
|
|
141
|
+
*/
|
|
142
|
+
export const hasNamedTest = (source: string, testStems: ReadonlySet<string>): boolean => testStems.has(stemOf(source))
|