jackherd 1.12.6 → 1.13.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 +14 -0
- package/INSTALL.md +1 -1
- package/README.md +37 -19
- package/dist/config.d.ts +32 -11
- package/dist/config.js +38 -82
- 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 +2 -0
- package/kit/workflow/skills/start/SKILL.md +4 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +14 -11
- package/src/check/command.ts +121 -35
- package/src/check/plan-structure-check.ts +34 -1
- package/src/check/plan.ts +127 -44
- package/src/check/report.ts +67 -2
- package/src/check/result.ts +13 -3
- package/src/check/roots.ts +38 -10
- package/src/check/schema.ts +8 -3
- package/src/check/settings.ts +18 -1
- package/src/check/skill-budget-defaults.ts +3 -2
- 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/your-turn.ts +6 -5
- 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/newest-first.ts +8 -0
- package/src/watch/source-statuses.ts +5 -2
|
@@ -20,24 +20,23 @@ 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
|
-
State plainly that herdr is only needed to run parallel lanes with `/orchestrate`. `/plan`, `/implement` and `jh herd` work without it.
|
|
40
|
-
|
|
41
40
|
## 3. Offer the first step
|
|
42
41
|
|
|
43
42
|
Ask through `AskUserQuestion`, in {{kit.responseLanguage}}, with these options (the first one recommended):
|
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": [
|
|
@@ -900,8 +906,5 @@
|
|
|
900
906
|
"additionalProperties": false
|
|
901
907
|
}
|
|
902
908
|
},
|
|
903
|
-
"required": [
|
|
904
|
-
"models"
|
|
905
|
-
],
|
|
906
909
|
"additionalProperties": false
|
|
907
910
|
}
|
package/src/check/command.ts
CHANGED
|
@@ -2,25 +2,34 @@ 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'
|
|
11
13
|
import { parallelArgument, parallelReportLines } from './parallel-report'
|
|
12
|
-
import { type CheckPlan, type Filesystem, isSlow, planChecks } from './plan'
|
|
14
|
+
import { type CheckPlan, type CheckStack, checkStackOf, type Filesystem, isSlow, planChecks } from './plan'
|
|
13
15
|
import { planStructureChecks } from './plan-structure-check'
|
|
14
16
|
import { publicContentCheck } from './public-content-check'
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
import {
|
|
18
|
+
artifactResult,
|
|
19
|
+
formatPlanHeader,
|
|
20
|
+
formatResults,
|
|
21
|
+
skippedSlowResult,
|
|
22
|
+
summarize,
|
|
23
|
+
testCommandResult,
|
|
24
|
+
untestedResult,
|
|
25
|
+
} from './report'
|
|
26
|
+
import { type CheckResult, RUN_MODE, type RunMode, runModeOf } from './result'
|
|
27
|
+
import { isWorkspaceManifest, kitCopyPaths, WORKSPACE_LIST_FILE, WORKSPACE_MANIFEST } from './roots'
|
|
28
|
+
import { type CheckSettings, CONFIG_PATH, parseCheckSettings, stackOf } from './settings'
|
|
18
29
|
import { skillBudgetCheck } from './skill-budgets'
|
|
19
30
|
|
|
20
31
|
type CheckTarget = { root: string; configPath: string }
|
|
21
32
|
|
|
22
|
-
const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
|
|
23
|
-
|
|
24
33
|
/**
|
|
25
34
|
* Where the checks run and which config they read: the nearest project with `.jackherd/config.json`, else the
|
|
26
35
|
* repository's top level with `~/.jackherd/config.json` (a global install in a repository without the kit).
|
|
@@ -53,11 +62,44 @@ const contentAt =
|
|
|
53
62
|
return result.status === 0 ? result.stdout : null
|
|
54
63
|
}
|
|
55
64
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
/** A file that is there but cannot be read or parsed; its message names the path. */
|
|
66
|
+
export class UnreadableFileError extends Error {}
|
|
67
|
+
|
|
68
|
+
const isMissingFile = (error: unknown): boolean => error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
|
69
|
+
|
|
70
|
+
const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A JSON file's parsed content; null only when the file is absent. A file that is there but cannot be read
|
|
74
|
+
* or parsed throws `UnreadableFileError`, so a broken manifest is reported instead of reading as no manifest.
|
|
75
|
+
*/
|
|
76
|
+
export const readJsonIfPresent = (path: string): unknown => {
|
|
77
|
+
let text: string
|
|
78
|
+
try {
|
|
79
|
+
text = readFileSync(path, 'utf8')
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (isMissingFile(error)) return null
|
|
82
|
+
throw new UnreadableFileError(`${path} cannot be read: ${messageOf(error)}`)
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(text)
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new UnreadableFileError(`${path} is not valid JSON: ${messageOf(error)}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const filesystemAt = (root: string, stack: CheckStack): Filesystem => {
|
|
92
|
+
const holdsAny = (dir: string, names: readonly string[]) => names.some((name) => existsSync(join(root, dir, name)))
|
|
93
|
+
return {
|
|
94
|
+
isProjectRoot: (dir) => holdsAny(dir, stack.rootMarkers),
|
|
95
|
+
isWorkspaceRoot: (dir) =>
|
|
96
|
+
existsSync(join(root, dir, WORKSPACE_LIST_FILE)) ||
|
|
97
|
+
isWorkspaceManifest(readJsonIfPresent(join(root, dir, WORKSPACE_MANIFEST))),
|
|
98
|
+
hasTypeConfig: (dir) => holdsAny(dir, stack.typeMarkers),
|
|
99
|
+
hasTests: (dir) =>
|
|
100
|
+
existsSync(join(root, dir)) && readdirSync(join(root, dir)).some((entry) => stack.testFile.test(entry)),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
61
103
|
|
|
62
104
|
const print = (lines: readonly string[]): void => {
|
|
63
105
|
for (const line of lines) console.log(line)
|
|
@@ -67,7 +109,7 @@ const loadSettings = (configPath: string, root: string): CheckSettings | string
|
|
|
67
109
|
try {
|
|
68
110
|
return parseCheckSettings(JSON.parse(readFileSync(configPath, 'utf8')), readRepositoryFacts(root))
|
|
69
111
|
} catch (error) {
|
|
70
|
-
return `${configPath} is not valid: ${
|
|
112
|
+
return `${configPath} is not valid: ${messageOf(error)}`
|
|
71
113
|
}
|
|
72
114
|
}
|
|
73
115
|
|
|
@@ -99,18 +141,76 @@ const changedFiles = async (root: string): Promise<ChangedFile[] | string> => {
|
|
|
99
141
|
const executePlan = async (
|
|
100
142
|
plan: CheckPlan,
|
|
101
143
|
root: string,
|
|
102
|
-
|
|
144
|
+
mode: RunMode,
|
|
103
145
|
ownChecks: readonly (CheckResult | null)[],
|
|
104
146
|
): Promise<CheckResult[]> => {
|
|
105
147
|
const [slow, quick] = [plan.checks.filter(isSlow), plan.checks.filter((check) => !isSlow(check))]
|
|
106
148
|
return [
|
|
107
149
|
artifactResult(plan.artifacts),
|
|
108
|
-
...ownChecks.filter((result) => result !== null),
|
|
150
|
+
...[...ownChecks, untestedResult(plan.untestedSources, mode)].filter((result) => result !== null),
|
|
109
151
|
...(await Promise.all(quick.map((check) => runCheck(check, root)))),
|
|
110
|
-
...(
|
|
152
|
+
...(mode === RUN_MODE.quick
|
|
153
|
+
? [skippedSlowResult()]
|
|
154
|
+
: await Promise.all(slow.map((check) => runCheck(check, root)))),
|
|
111
155
|
]
|
|
112
156
|
}
|
|
113
157
|
|
|
158
|
+
/** Where a board's plans are read: this checkout, the main checkout and every lane worktree below it (`root` alone outside git). */
|
|
159
|
+
const planRootsOf = (root: string, worktreeDirectory: string): string[] => {
|
|
160
|
+
try {
|
|
161
|
+
return [...new Set([resolve(root), ...planRootsIn(mainCheckout(root), worktreeDirectory)])]
|
|
162
|
+
} catch {
|
|
163
|
+
return [resolve(root)]
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The checks jackherd runs itself beside the configured ones: public content, deferral arrows, skill budgets, plan structure. */
|
|
168
|
+
const ownChecksOf = (root: string, paths: readonly string[], settings: CheckSettings, home: string) => [
|
|
169
|
+
publicContentCheck(root, paths, settings.check.publicContent, home),
|
|
170
|
+
deferralArrowCheck(root, paths, settings.planDirectory),
|
|
171
|
+
skillBudgetCheck(root, settings.check.skillBudgets),
|
|
172
|
+
...planStructureChecks({
|
|
173
|
+
root,
|
|
174
|
+
paths,
|
|
175
|
+
planDirectory: settings.planDirectory,
|
|
176
|
+
language: settings.planLanguage,
|
|
177
|
+
previousOf: contentAt(root, 'HEAD'),
|
|
178
|
+
baseOf: contentAt(root, baseRefOf(settings)),
|
|
179
|
+
planRoots: planRootsOf(root, settings.worktreeDirectory),
|
|
180
|
+
}),
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Plans the checks for the changed files, prints the plan's header and runs every check. A manifest that is
|
|
185
|
+
* there but cannot be read comes back as a message instead of a result list.
|
|
186
|
+
*/
|
|
187
|
+
const checkChanges = async (
|
|
188
|
+
files: readonly ChangedFile[],
|
|
189
|
+
root: string,
|
|
190
|
+
settings: CheckSettings,
|
|
191
|
+
mode: RunMode,
|
|
192
|
+
home: string,
|
|
193
|
+
): Promise<CheckResult[] | string> => {
|
|
194
|
+
const stack = checkStackOf(stackOf(settings.stack))
|
|
195
|
+
let plan: CheckPlan
|
|
196
|
+
try {
|
|
197
|
+
const kitCopies = kitCopyPaths(readJsonIfPresent(join(root, TOOL_DIRECTORY, MANIFEST_FILE)))
|
|
198
|
+
plan = planChecks(
|
|
199
|
+
files,
|
|
200
|
+
{ commands: settings.commands, check: settings.check, stack, kitCopies },
|
|
201
|
+
filesystemAt(root, stack),
|
|
202
|
+
)
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error instanceof UnreadableFileError) return error.message
|
|
205
|
+
throw error
|
|
206
|
+
}
|
|
207
|
+
print(formatPlanHeader(files, plan, settings.stack))
|
|
208
|
+
return executePlan(plan, root, mode, [
|
|
209
|
+
testCommandResult(settings.commands.test, settings.stack),
|
|
210
|
+
...ownChecksOf(root, plan.paths, settings, home),
|
|
211
|
+
])
|
|
212
|
+
}
|
|
213
|
+
|
|
114
214
|
/**
|
|
115
215
|
* Runs the configured checks over the files `git status` reports. Exit code 1 when any check fails
|
|
116
216
|
* or the configuration cannot be read. With `--parallel <plan>` it runs no check and prints the plan's waves and critical path.
|
|
@@ -139,25 +239,11 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
|
|
|
139
239
|
console.log('Nothing changed.')
|
|
140
240
|
return 0
|
|
141
241
|
}
|
|
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
|
-
])
|
|
242
|
+
const results = await checkChanges(files, root, settings, runModeOf(args), home)
|
|
243
|
+
if (typeof results === 'string') {
|
|
244
|
+
console.error(results)
|
|
245
|
+
return 1
|
|
246
|
+
}
|
|
161
247
|
print(formatResults(results))
|
|
162
248
|
const summary = summarize(results)
|
|
163
249
|
console.log(`\n${summary.line}`)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
2
|
+
import { dirname, join, posix } from 'node:path'
|
|
3
|
+
import { boardPlansOf, isInsideRepository } from '../../kit/hooks/hooks/shared/board-lanes'
|
|
4
|
+
import { planCopiesIn } from '../../kit/hooks/hooks/shared/boards'
|
|
5
|
+
import { codesOfPlans } from '../../kit/hooks/hooks/shared/plan-codes'
|
|
3
6
|
import {
|
|
4
7
|
boardRowsWithoutPhase,
|
|
5
8
|
boardRowsWithProse,
|
|
@@ -25,6 +28,7 @@ import {
|
|
|
25
28
|
type ShortNameFinding,
|
|
26
29
|
shortNameFindings,
|
|
27
30
|
} from '../plan-format'
|
|
31
|
+
import { boardRowsWithBareCodes, type RowWithBareCode } from '../plan-format/bare-codes'
|
|
28
32
|
import { changedPlansOf } from './deferral-arrows'
|
|
29
33
|
import { CHECK_STATUS, type CheckResult } from './result'
|
|
30
34
|
|
|
@@ -42,6 +46,8 @@ export type PlanCheckInput = {
|
|
|
42
46
|
language: PlanLanguage
|
|
43
47
|
previousOf: ContentAt
|
|
44
48
|
baseOf: ContentAt
|
|
49
|
+
/** Where a board's plans are read, `root` when absent: the checkout, the main checkout and the lane worktrees. */
|
|
50
|
+
planRoots?: readonly string[]
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
type Changed = {
|
|
@@ -116,6 +122,16 @@ const proseReport: Report<RowWithProse> = {
|
|
|
116
122
|
clean: 'every changed lane row names the phase only',
|
|
117
123
|
}
|
|
118
124
|
|
|
125
|
+
const bareCodeReport: Report<RowWithBareCode> = {
|
|
126
|
+
name: 'Board rows with a bare plan code',
|
|
127
|
+
noun: 'board',
|
|
128
|
+
rule: 'changed waiting rows say what a plan code is',
|
|
129
|
+
unmet: CHECK_STATUS.warn,
|
|
130
|
+
text: ({ path, line, code }) =>
|
|
131
|
+
`${path}:${line} plan code ${code} without its words: say what it is, the code at most in brackets after it`,
|
|
132
|
+
clean: 'no changed waiting row names a plan code bare',
|
|
133
|
+
}
|
|
134
|
+
|
|
119
135
|
const mergedReport: Report<MergedPhaseWithOpenBoxes> = {
|
|
120
136
|
name: 'Merged phases with open boxes',
|
|
121
137
|
noun: 'plan',
|
|
@@ -175,6 +191,20 @@ const rowsOf = ({ path, current, previousLines }: Changed, language: PlanLanguag
|
|
|
175
191
|
const proseOf = ({ path, current, previousLines }: Changed, language: PlanLanguage): Found<RowWithProse>[] =>
|
|
176
192
|
boardRowsWithProse(current, language, previousLines).map(withPath(path))
|
|
177
193
|
|
|
194
|
+
/** Every readable copy of the plans the board links, in each plan root; a path that leaves the repository is skipped. */
|
|
195
|
+
const planTextsOf = (roots: readonly string[], { path, current }: Changed, language: PlanLanguage): string[] =>
|
|
196
|
+
boardPlansOf(current, posix.normalize(dirname(path)), language)
|
|
197
|
+
.filter(isInsideRepository)
|
|
198
|
+
.flatMap((plan) => planCopiesIn(roots, plan))
|
|
199
|
+
|
|
200
|
+
const bareCodesOfBoard = (roots: readonly string[], file: Changed, language: PlanLanguage): Found<RowWithBareCode>[] =>
|
|
201
|
+
boardRowsWithBareCodes(
|
|
202
|
+
file.current,
|
|
203
|
+
language,
|
|
204
|
+
file.previousLines,
|
|
205
|
+
codesOfPlans(planTextsOf(roots, file, language), language),
|
|
206
|
+
).map(withPath(file.path))
|
|
207
|
+
|
|
178
208
|
const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<MergedPhaseWithOpenBoxes>[] =>
|
|
179
209
|
base ? mergedPhasesWithOpenBoxes(current, base, language).map(withPath(path)) : []
|
|
180
210
|
|
|
@@ -202,6 +232,7 @@ const changedContents = ({ root, previousOf, baseOf, language }: PlanCheckInput,
|
|
|
202
232
|
* - a phase the change added (against `HEAD`) with tasks and without a review gate fails;
|
|
203
233
|
* - a lane row the change added or edited that names no phase warns, and so does one whose phases cell is longer
|
|
204
234
|
* than `PHASE_CELL_LIMIT` or records a side order of the user;
|
|
235
|
+
* - a waiting row the change added or edited whose item cell names a plan code of the board's plans bare warns;
|
|
205
236
|
* - a phase whose tasks are all ticked on the base branch and that still has open boxes without `→ P<n>` warns;
|
|
206
237
|
* - a phase of a plan with a dependency table that has no row in it warns;
|
|
207
238
|
* - an open task the change added or edited without a short name, or with one over `SHORT_NAME_LIMIT`, warns, and so
|
|
@@ -222,6 +253,7 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
|
222
253
|
const gates = changed.flatMap((file) => gatesOf(file, language))
|
|
223
254
|
const rows = boards.flatMap((file) => rowsOf(file, language))
|
|
224
255
|
const prose = boards.flatMap((file) => proseOf(file, language))
|
|
256
|
+
const bareCodes = boards.flatMap((file) => bareCodesOfBoard(input.planRoots ?? [input.root], file, language))
|
|
225
257
|
const merged = plans.flatMap((file) => mergedOf(file, language))
|
|
226
258
|
const unlisted = unlistedPerPlan.flat()
|
|
227
259
|
const shortNames = plans.flatMap((file) => shortNamesOf(file, language))
|
|
@@ -233,6 +265,7 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
|
233
265
|
? [
|
|
234
266
|
findingsResult(rowReport, rows, boards.length, durationMs),
|
|
235
267
|
findingsResult(proseReport, prose, boards.length, durationMs),
|
|
268
|
+
findingsResult(bareCodeReport, bareCodes, boards.length, durationMs),
|
|
236
269
|
]
|
|
237
270
|
: []),
|
|
238
271
|
...(plans.length > 0 ? [findingsResult(mergedReport, merged, plans.length, durationMs)] : []),
|
package/src/check/plan.ts
CHANGED
|
@@ -1,33 +1,58 @@
|
|
|
1
1
|
import type { JackherdConfig } from '../config'
|
|
2
|
+
import { STACKS, type Stack, type StackProfile } from '../config/detect'
|
|
2
3
|
import { agentTextReviewers } from './agent-text-reviewers'
|
|
3
4
|
import { type ChangedFile, generatorOwnedFiles, stagedBuildArtifacts } from './changes'
|
|
4
5
|
import { tokenize } from './command-line'
|
|
5
|
-
import {
|
|
6
|
+
import { matchesAnyGlob } from './glob'
|
|
7
|
+
import { CHECK_KIND, type CheckKind } from './result'
|
|
8
|
+
import { isUnder, looseDirectories, packageRootsFor } from './roots'
|
|
6
9
|
import { projectChecksFor, publicContentReviewers, reviewersFor } from './routing'
|
|
7
|
-
import type { CheckConfig } from './schema'
|
|
10
|
+
import type { CheckConfig, ProjectCheck } from './schema'
|
|
8
11
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
export type CheckKind = (typeof CHECK_KIND)[keyof typeof CHECK_KIND]
|
|
12
|
+
export { CHECK_KIND, type CheckKind } from './result'
|
|
12
13
|
|
|
13
14
|
export type PlannedCheck = {
|
|
14
15
|
name: string
|
|
15
16
|
/** What the report shows; `argv` is what runs. */
|
|
16
17
|
command: string
|
|
17
18
|
argv: string[]
|
|
18
|
-
/** Relative to the
|
|
19
|
+
/** Relative to the repository root `.`. */
|
|
19
20
|
cwd: string
|
|
20
21
|
kind: CheckKind
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export type PackageCommands = Pick<JackherdConfig['commands'], 'test' | 'typecheck'>
|
|
24
25
|
|
|
26
|
+
/** What the plan asks of the disk, by the markers of the stack (`checkStackOf`). */
|
|
25
27
|
export type Filesystem = {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
/** Whether the directory holds the stack's root marker (`package.json`, `composer.json`). */
|
|
29
|
+
isProjectRoot: (dir: string) => boolean
|
|
30
|
+
/** Whether the directory only gathers workspaces, so a test from there would run the whole monorepo. */
|
|
31
|
+
isWorkspaceRoot: (dir: string) => boolean
|
|
32
|
+
/** Whether the directory holds a type marker of the stack (`tsconfig.json`, `phpstan.neon`). */
|
|
33
|
+
hasTypeConfig: (dir: string) => boolean
|
|
28
34
|
hasTests: (dir: string) => boolean
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
/** The stack as the check reads it; without a detected stack, every stack's markers and patterns together. */
|
|
38
|
+
export type CheckStack = {
|
|
39
|
+
rootMarkers: string[]
|
|
40
|
+
typeMarkers: string[]
|
|
41
|
+
testFile: RegExp
|
|
42
|
+
sourceFile: RegExp
|
|
43
|
+
/** Globs the lint covers; none means every changed file. */
|
|
44
|
+
lintGlobs: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What the plan reads from the settings: the test and type commands, the `check` block and the stack's patterns. */
|
|
48
|
+
export type PlanInputs = {
|
|
49
|
+
commands: PackageCommands
|
|
50
|
+
check: CheckConfig
|
|
51
|
+
stack: Pick<CheckStack, 'lintGlobs' | 'sourceFile'>
|
|
52
|
+
/** Repository paths of the kit's copies (`.jackherd/lock.json`); their tests run where the kit keeps the source. */
|
|
53
|
+
kitCopies: readonly string[]
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
export type CheckPlan = {
|
|
32
57
|
paths: string[]
|
|
33
58
|
roots: string[]
|
|
@@ -36,26 +61,58 @@ export type CheckPlan = {
|
|
|
36
61
|
artifacts: string[]
|
|
37
62
|
/** Fast checks first, then the ones that scale with the package; the runner starts each group together. */
|
|
38
63
|
checks: PlannedCheck[]
|
|
64
|
+
/** The changed source files of the stack no planned test reaches, kit copies aside; a check without a test is no green. */
|
|
65
|
+
untestedSources: string[]
|
|
39
66
|
}
|
|
40
67
|
|
|
41
68
|
export const isSlow = (check: PlannedCheck): boolean =>
|
|
42
69
|
check.kind === CHECK_KIND.types || check.kind === CHECK_KIND.tests
|
|
43
70
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
71
|
+
const unique = (values: readonly string[]): string[] => [...new Set(values)]
|
|
72
|
+
|
|
73
|
+
const anyOf = (patterns: readonly RegExp[]): RegExp =>
|
|
74
|
+
new RegExp(unique(patterns.map((pattern) => `(?:${pattern.source})`)).join('|'))
|
|
75
|
+
|
|
76
|
+
const lintGlobsOf = (profiles: readonly StackProfile[]): string[] =>
|
|
77
|
+
profiles.some((profile) => profile.lintGlobs.length === 0)
|
|
78
|
+
? []
|
|
79
|
+
: unique(profiles.flatMap((profile) => profile.lintGlobs))
|
|
80
|
+
|
|
81
|
+
export const checkStackOf = (stack: Stack | null): CheckStack => {
|
|
82
|
+
const profiles = stack === null ? Object.values(STACKS) : [STACKS[stack]]
|
|
83
|
+
return {
|
|
84
|
+
rootMarkers: unique(profiles.map((profile) => profile.rootMarker)),
|
|
85
|
+
typeMarkers: unique(profiles.flatMap((profile) => profile.typeMarkers)),
|
|
86
|
+
testFile: anyOf(profiles.map((profile) => profile.testFilePattern)),
|
|
87
|
+
sourceFile: anyOf(profiles.map((profile) => profile.sourceFilePattern)),
|
|
88
|
+
lintGlobs: lintGlobsOf(profiles),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const lintedPaths = (paths: readonly string[], lintGlobs: readonly string[]): string[] =>
|
|
93
|
+
lintGlobs.length === 0 ? [...paths] : paths.filter((path) => matchesAnyGlob(path, lintGlobs))
|
|
94
|
+
|
|
95
|
+
/** No lint without a `lintFilesCommand`, none is guessed; none either when no changed file is the stack's to lint. */
|
|
96
|
+
const lintChecks = (command: string | undefined, paths: readonly string[]): PlannedCheck[] =>
|
|
97
|
+
command === undefined || paths.length === 0
|
|
98
|
+
? []
|
|
99
|
+
: [
|
|
100
|
+
{
|
|
101
|
+
name: 'Lint & unused imports',
|
|
102
|
+
command: `${command} (${paths.length} files)`,
|
|
103
|
+
argv: [...tokenize(command), ...paths],
|
|
104
|
+
cwd: '.',
|
|
105
|
+
kind: CHECK_KIND.lint,
|
|
106
|
+
},
|
|
107
|
+
]
|
|
51
108
|
|
|
52
|
-
const projectChecks = (
|
|
53
|
-
|
|
109
|
+
const projectChecks = (fired: readonly ProjectCheck[]): PlannedCheck[] =>
|
|
110
|
+
fired.map(({ name, command, kind }) => ({
|
|
54
111
|
name,
|
|
55
112
|
command,
|
|
56
113
|
argv: tokenize(command),
|
|
57
114
|
cwd: '.',
|
|
58
|
-
kind: CHECK_KIND.project,
|
|
115
|
+
kind: kind ?? CHECK_KIND.project,
|
|
59
116
|
}))
|
|
60
117
|
|
|
61
118
|
const inDirectory = (kind: CheckKind, label: string, command: string, dir: string): PlannedCheck => ({
|
|
@@ -66,48 +123,74 @@ const inDirectory = (kind: CheckKind, label: string, command: string, dir: strin
|
|
|
66
123
|
kind,
|
|
67
124
|
})
|
|
68
125
|
|
|
126
|
+
/** No type check without `commands.typecheck`, no test without `commands.test`: none is guessed. */
|
|
69
127
|
const typeChecks = (
|
|
70
128
|
roots: readonly string[],
|
|
71
|
-
command: string,
|
|
72
|
-
|
|
73
|
-
): PlannedCheck[] =>
|
|
129
|
+
command: string | undefined,
|
|
130
|
+
hasTypeConfig: Filesystem['hasTypeConfig'],
|
|
131
|
+
): PlannedCheck[] =>
|
|
132
|
+
command === undefined
|
|
133
|
+
? []
|
|
134
|
+
: roots.filter(hasTypeConfig).map((root) => inDirectory(CHECK_KIND.types, 'Types', command, root))
|
|
135
|
+
|
|
136
|
+
const testChecks = (dirs: readonly string[], command: string | undefined): PlannedCheck[] =>
|
|
137
|
+
command === undefined ? [] : dirs.map((dir) => inDirectory(CHECK_KIND.tests, 'Tests', command, dir))
|
|
138
|
+
|
|
139
|
+
/** What reaches a changed file: a test run from a directory above it, or a fired project check of kind tests whose globs (or no globs) match it. */
|
|
140
|
+
type TestReach = { dirs: readonly string[]; checks: readonly ProjectCheck[] }
|
|
141
|
+
|
|
142
|
+
const isReached = (path: string, { dirs, checks }: TestReach): boolean =>
|
|
143
|
+
dirs.some((dir) => isUnder(path, dir)) ||
|
|
144
|
+
checks.some(({ globs }) => globs === undefined || matchesAnyGlob(path, globs))
|
|
145
|
+
|
|
146
|
+
/** Each changed source file on its own: one test elsewhere in the change does not cover it. */
|
|
147
|
+
const untestedSourcesOf = (
|
|
148
|
+
paths: readonly string[],
|
|
149
|
+
reach: TestReach,
|
|
150
|
+
{ sourceFile, kitCopies }: Pick<PlanInputs['stack'], 'sourceFile'> & Pick<PlanInputs, 'kitCopies'>,
|
|
151
|
+
): string[] => paths.filter((path) => sourceFile.test(path) && !kitCopies.includes(path) && !isReached(path, reach))
|
|
74
152
|
|
|
75
|
-
|
|
76
|
-
|
|
153
|
+
/** The reviewers a set of changed paths calls for, each named once: routed, public content and agent texts. */
|
|
154
|
+
export const reviewersOf = (paths: readonly string[], config: CheckConfig): string[] => [
|
|
155
|
+
...new Set([
|
|
156
|
+
...reviewersFor(paths, config.reviewerRoutes),
|
|
157
|
+
...publicContentReviewers(paths, config.publicContent),
|
|
158
|
+
...agentTextReviewers(paths),
|
|
159
|
+
]),
|
|
160
|
+
]
|
|
77
161
|
|
|
78
162
|
/**
|
|
79
163
|
* Decides, without touching the disk or running anything, what a set of changed files calls for:
|
|
80
|
-
* which package roots to type-check and test, which project checks fire, which reviewers to start
|
|
164
|
+
* which package roots to type-check and test, which project checks fire, which reviewers to start,
|
|
165
|
+
* and which changed source files no test covers. A root that gathers workspaces is never tested.
|
|
81
166
|
*/
|
|
82
167
|
export const planChecks = (
|
|
83
168
|
files: readonly ChangedFile[],
|
|
84
|
-
commands:
|
|
85
|
-
config: CheckConfig,
|
|
169
|
+
{ commands, check: config, stack, kitCopies }: PlanInputs,
|
|
86
170
|
filesystem: Filesystem,
|
|
87
171
|
): CheckPlan => {
|
|
88
172
|
const paths = files.map((file) => file.path)
|
|
89
|
-
const roots = packageRootsFor(paths, filesystem.
|
|
90
|
-
const
|
|
173
|
+
const roots = packageRootsFor(paths, filesystem.isProjectRoot).filter((root) => !filesystem.isWorkspaceRoot(root))
|
|
174
|
+
const loose = config.looseTests ? looseDirectories(paths, roots, filesystem.hasTests) : []
|
|
175
|
+
const fired = projectChecksFor(paths, config.checks)
|
|
176
|
+
const testDirs = commands.test === undefined ? [] : [...roots, ...loose]
|
|
177
|
+
const checks =
|
|
178
|
+
paths.length === 0
|
|
179
|
+
? []
|
|
180
|
+
: [
|
|
181
|
+
...lintChecks(config.lintFilesCommand, lintedPaths(paths, stack.lintGlobs)),
|
|
182
|
+
...projectChecks(fired),
|
|
183
|
+
...typeChecks(roots, commands.typecheck, filesystem.hasTypeConfig),
|
|
184
|
+
...testChecks(testDirs, commands.test),
|
|
185
|
+
]
|
|
186
|
+
const reach: TestReach = { dirs: testDirs, checks: fired.filter((check) => check.kind === CHECK_KIND.tests) }
|
|
91
187
|
return {
|
|
92
188
|
paths,
|
|
93
189
|
roots,
|
|
94
|
-
reviewers:
|
|
95
|
-
...new Set([
|
|
96
|
-
...reviewersFor(paths, config.reviewerRoutes),
|
|
97
|
-
...publicContentReviewers(paths, config.publicContent),
|
|
98
|
-
...agentTextReviewers(paths),
|
|
99
|
-
]),
|
|
100
|
-
],
|
|
190
|
+
reviewers: reviewersOf(paths, config),
|
|
101
191
|
generated: generatorOwnedFiles(paths, config.generatorOwned),
|
|
102
192
|
artifacts: stagedBuildArtifacts(files, config.buildArtifacts),
|
|
103
|
-
checks
|
|
104
|
-
|
|
105
|
-
? []
|
|
106
|
-
: [
|
|
107
|
-
lintCheck(config, paths),
|
|
108
|
-
...projectChecks(config, paths),
|
|
109
|
-
...typeChecks(roots, commands.typecheck, filesystem.hasTsconfig),
|
|
110
|
-
...testChecks([...roots, ...looseDirs], commands.test),
|
|
111
|
-
],
|
|
193
|
+
checks,
|
|
194
|
+
untestedSources: untestedSourcesOf(paths, reach, { sourceFile: stack.sourceFile, kitCopies }),
|
|
112
195
|
}
|
|
113
196
|
}
|