jackherd 1.12.7 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +35 -17
  4. package/dist/config.d.ts +32 -11
  5. package/dist/config.js +38 -82
  6. package/herdr-plugin/herdr-plugin.toml +1 -1
  7. package/install.sh +1 -1
  8. package/kit/hooks/hooks/merge-guard/board-settings.ts +7 -32
  9. package/kit/hooks/hooks/merge-guard/lane-plan.ts +2 -29
  10. package/kit/hooks/hooks/plain-question/code-cache.ts +70 -0
  11. package/kit/hooks/hooks/plain-question/index.ts +80 -0
  12. package/kit/hooks/hooks/plain-question/run.ts +50 -0
  13. package/kit/hooks/hooks/shared/board-lanes.ts +19 -0
  14. package/kit/hooks/hooks/shared/boards.ts +96 -0
  15. package/kit/hooks/hooks/shared/plan-codes.ts +68 -0
  16. package/kit/hooks/hooks.json +9 -0
  17. package/kit/review/skills/diagnose/SKILL.md +2 -2
  18. package/kit/review/skills/diagnose/references/feedback-loops.md +4 -13
  19. package/kit/rules/rules/kit/code-style.md +15 -18
  20. package/kit/rules/rules/kit/testing.md +7 -28
  21. package/kit/workflow/skills/implement/SKILL.md +3 -3
  22. package/kit/workflow/skills/orchestrate/SKILL.md +2 -0
  23. package/kit/workflow/skills/start/SKILL.md +4 -5
  24. package/package.json +1 -1
  25. package/schemas/jackherd.schema.json +14 -11
  26. package/src/check/command.ts +121 -35
  27. package/src/check/plan-structure-check.ts +34 -1
  28. package/src/check/plan.ts +127 -44
  29. package/src/check/report.ts +67 -2
  30. package/src/check/result.ts +13 -3
  31. package/src/check/roots.ts +38 -10
  32. package/src/check/schema.ts +8 -3
  33. package/src/check/settings.ts +18 -1
  34. package/src/check/skill-budget-defaults.ts +3 -2
  35. package/src/config/base-branch.ts +2 -0
  36. package/src/config/command.ts +32 -13
  37. package/src/config/detect.ts +242 -74
  38. package/src/config/location.ts +6 -0
  39. package/src/config/repository-facts.ts +32 -7
  40. package/src/config/repository-values.ts +162 -5
  41. package/src/config/schema.ts +34 -16
  42. package/src/lane-prompt/texts.ts +2 -2
  43. package/src/plan-format/bare-codes.ts +28 -0
  44. package/src/sync/command-context.ts +4 -2
  45. package/src/sync/herdr-plugin.ts +30 -6
  46. package/src/sync/init.ts +17 -16
  47. package/src/sync/render.ts +4 -6
@@ -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
  }
@@ -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 { findProjectRoot, globalConfigPath, locateConfigFile } from '../config/location'
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 { artifactResult, formatPlanHeader, formatResults, skippedSlowResult, summarize } from './report'
16
- import { type CheckResult, SKIP_SLOW_FLAG } from './result'
17
- import { type CheckSettings, CONFIG_PATH, parseCheckSettings } from './settings'
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
- const filesystemAt = (root: string): Filesystem => ({
57
- hasPackageJson: (dir) => existsSync(join(root, dir, 'package.json')),
58
- hasTsconfig: (dir) => existsSync(join(root, dir, 'tsconfig.json')),
59
- hasTests: (dir) => existsSync(join(root, dir)) && readdirSync(join(root, dir)).some((entry) => TEST_FILE.test(entry)),
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: ${error instanceof Error ? error.message : String(error)}`
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
- skipSlow: boolean,
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
- ...(skipSlow ? [skippedSlowResult()] : await Promise.all(slow.map((check) => runCheck(check, root)))),
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
- const plan = planChecks(files, settings.commands, settings.check, filesystemAt(root))
144
- print(formatPlanHeader(files, plan))
145
- const publicContent = publicContentCheck(root, plan.paths, settings.check.publicContent, home)
146
- const deferralArrows = deferralArrowCheck(root, plan.paths, settings.planDirectory)
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 { looseDirectories, packageRootsFor } from './roots'
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 const CHECK_KIND = { lint: 'lint', project: 'project', types: 'types', tests: 'tests' } as const
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 workspace root. */
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
- hasPackageJson: (dir: string) => boolean
27
- hasTsconfig: (dir: string) => boolean
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 lintCheck = (config: CheckConfig, paths: readonly string[]): PlannedCheck => ({
45
- name: 'Lint & unused imports',
46
- command: `${config.lintFilesCommand} (${paths.length} files)`,
47
- argv: [...tokenize(config.lintFilesCommand), ...paths],
48
- cwd: '.',
49
- kind: CHECK_KIND.lint,
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 = (config: CheckConfig, paths: readonly string[]): PlannedCheck[] =>
53
- projectChecksFor(paths, config.checks).map(({ name, command }) => ({
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
- hasTsconfig: Filesystem['hasTsconfig'],
73
- ): PlannedCheck[] => roots.filter(hasTsconfig).map((root) => inDirectory(CHECK_KIND.types, 'Types', command, root))
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
- const testChecks = (dirs: readonly string[], command: string): PlannedCheck[] =>
76
- dirs.map((dir) => inDirectory(CHECK_KIND.tests, 'Tests', command, dir))
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: PackageCommands,
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.hasPackageJson).filter((root) => root !== '.')
90
- const looseDirs = config.looseTests ? looseDirectories(paths, roots, filesystem.hasTests) : []
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
- paths.length === 0
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
  }