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.
Files changed (60) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +17 -17
  4. package/dist/config.d.ts +35 -11
  5. package/dist/config.js +49 -83
  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 +4 -1
  23. package/kit/workflow/skills/orchestrate/references/events.md +6 -1
  24. package/kit/workflow/skills/orchestrate/references/plan-commit.md +2 -1
  25. package/kit/workflow/skills/start/SKILL.md +9 -6
  26. package/kit/workflow/skills/start/references/missing-commands.md +17 -0
  27. package/package.json +1 -1
  28. package/schemas/jackherd.schema.json +38 -11
  29. package/src/check/command.ts +167 -35
  30. package/src/check/file-kinds.ts +142 -0
  31. package/src/check/plan-structure-check.ts +34 -1
  32. package/src/check/plan.ts +230 -45
  33. package/src/check/report.ts +111 -3
  34. package/src/check/result.ts +13 -3
  35. package/src/check/roots.ts +47 -10
  36. package/src/check/schema.ts +20 -3
  37. package/src/check/settings.ts +25 -4
  38. package/src/check/skill-budget-defaults.ts +7 -4
  39. package/src/config/base-branch.ts +2 -0
  40. package/src/config/command.ts +32 -13
  41. package/src/config/detect.ts +242 -74
  42. package/src/config/location.ts +6 -0
  43. package/src/config/repository-facts.ts +32 -7
  44. package/src/config/repository-values.ts +162 -5
  45. package/src/config/schema.ts +34 -16
  46. package/src/lane-prompt/texts.ts +2 -2
  47. package/src/plan-format/bare-codes.ts +28 -0
  48. package/src/plans/lane-plans.ts +2 -1
  49. package/src/sync/command-context.ts +4 -2
  50. package/src/sync/herdr-plugin.ts +30 -6
  51. package/src/sync/init.ts +17 -16
  52. package/src/sync/render.ts +4 -6
  53. package/src/watch/command.ts +32 -1
  54. package/src/watch/lane-progress.ts +6 -1
  55. package/src/watch/lane-report.ts +10 -0
  56. package/src/watch/plan-missing-read.ts +47 -0
  57. package/src/watch/plan-missing.ts +43 -0
  58. package/src/watch/reference-paths.ts +1 -0
  59. package/src/watch/texts.ts +4 -0
  60. package/src/watch/watch-events.ts +7 -0
@@ -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,31 +1,69 @@
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 { hasNamedTest, isSourceFile, isTestFile, testStemsOf } from './file-kinds'
7
+ import { matchesAnyGlob } from './glob'
8
+ import { CHECK_KIND, type CheckKind } from './result'
9
+ import { isUnder, looseDirectories, nearestRootOf, packageRootOf, packageRootsFor } from './roots'
6
10
  import { projectChecksFor, publicContentReviewers, reviewersFor } from './routing'
7
- import type { CheckConfig } from './schema'
11
+ import type { CheckConfig, ProjectCheck } from './schema'
8
12
 
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]
13
+ export { CHECK_KIND, type CheckKind } from './result'
12
14
 
13
15
  export type PlannedCheck = {
14
16
  name: string
15
17
  /** What the report shows; `argv` is what runs. */
16
18
  command: string
17
19
  argv: string[]
18
- /** Relative to the workspace root. */
20
+ /** Relative to the repository root `.`. */
19
21
  cwd: string
20
22
  kind: CheckKind
21
23
  }
22
24
 
23
- export type PackageCommands = Pick<JackherdConfig['commands'], 'test' | 'typecheck'>
25
+ export type PackageCommands = Pick<JackherdConfig['commands'], 'test' | 'typecheck' | 'lint'>
24
26
 
27
+ /** What the plan asks of the disk, by the markers of the stack (`checkStackOf`). */
25
28
  export type Filesystem = {
26
- hasPackageJson: (dir: string) => boolean
27
- hasTsconfig: (dir: string) => boolean
29
+ /** Whether the directory holds the stack's root marker (`package.json`, `composer.json`). */
30
+ isProjectRoot: (dir: string) => boolean
31
+ /** Whether the directory only gathers workspaces, so a test from there would run the whole monorepo. */
32
+ isWorkspaceRoot: (dir: string) => boolean
33
+ /** Whether the directory holds a type marker of the stack (`tsconfig.json`, `phpstan.neon`). */
34
+ hasTypeConfig: (dir: string) => boolean
28
35
  hasTests: (dir: string) => boolean
36
+ /** Every file of the repository that git tracks or does not ignore; read only for the name rule. */
37
+ repositoryFiles: () => readonly string[]
38
+ }
39
+
40
+ /** The stack as the check reads it; with none or several detected, every stack's markers and patterns together. */
41
+ export type CheckStack = {
42
+ rootMarkers: string[]
43
+ typeMarkers: string[]
44
+ testFile: RegExp
45
+ sourceFile: RegExp
46
+ /** Globs the lint covers; none means every changed file. */
47
+ lintGlobs: string[]
48
+ }
49
+
50
+ /** What the check reads only where no stack is detected at all (not where several are). */
51
+ export type NoStackFallback = {
52
+ /** `check.roots` and the repository root `.`: a file no root marker holds belongs to the nearest one above it. */
53
+ roots: readonly string[]
54
+ /** `commands.lint`, run once from `.` where no `check.lintFilesCommand` is set. */
55
+ lintCommand?: string
56
+ }
57
+
58
+ /** What the plan reads from the settings: the test and type commands, the `check` block and the stack's patterns. */
59
+ export type PlanInputs = {
60
+ commands: PackageCommands
61
+ check: CheckConfig
62
+ stack: Pick<CheckStack, 'lintGlobs' | 'sourceFile'>
63
+ /** Repository paths of the kit's copies (`.jackherd/lock.json`); their tests run where the kit keeps the source. */
64
+ kitCopies: readonly string[]
65
+ /** Null where a stack is detected, or several. */
66
+ noStackFallback: NoStackFallback | null
29
67
  }
30
68
 
31
69
  export type CheckPlan = {
@@ -36,26 +74,113 @@ export type CheckPlan = {
36
74
  artifacts: string[]
37
75
  /** Fast checks first, then the ones that scale with the package; the runner starts each group together. */
38
76
  checks: PlannedCheck[]
77
+ /** The changed source files of the stack no planned test reaches, kit copies aside; a check without a test is no green. */
78
+ untestedSources: string[]
79
+ /**
80
+ * Without a detected stack, the changed source files under a root found by `NoStackFallback.roots` that no test file
81
+ * of their name covers: a run from such a root reaches every file, so the name decides, and only as a warning.
82
+ */
83
+ sourcesWithoutNamedTest: string[]
39
84
  }
40
85
 
86
+ /** The roots of a change: by root marker, and without a detected stack by `NoStackFallback.roots` for the rest. */
87
+ type ChangeRoots = { roots: string[]; fallbackRoots: string[]; fallbackPaths: string[] }
88
+
89
+ /** 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. */
90
+ type TestReach = { dirs: readonly string[]; checks: readonly ProjectCheck[] }
91
+
92
+ /** Which changed source files no test covers: the paths under marker roots by reach, the fallback paths by name. */
93
+ type Coverage = Pick<CheckPlan, 'untestedSources' | 'sourcesWithoutNamedTest'>
94
+
41
95
  export const isSlow = (check: PlannedCheck): boolean =>
42
96
  check.kind === CHECK_KIND.types || check.kind === CHECK_KIND.tests
43
97
 
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
- })
98
+ const unique = (values: readonly string[]): string[] => [...new Set(values)]
99
+
100
+ const anyOf = (patterns: readonly RegExp[]): RegExp =>
101
+ new RegExp(unique(patterns.map((pattern) => `(?:${pattern.source})`)).join('|'))
102
+
103
+ const lintGlobsOf = (profiles: readonly StackProfile[]): string[] =>
104
+ profiles.some((profile) => profile.lintGlobs.length === 0)
105
+ ? []
106
+ : unique(profiles.flatMap((profile) => profile.lintGlobs))
107
+
108
+ export const checkStackOf = (stack: Stack | null): CheckStack => {
109
+ const profiles = stack === null ? Object.values(STACKS) : [STACKS[stack]]
110
+ return {
111
+ rootMarkers: unique(profiles.map((profile) => profile.rootMarker)),
112
+ typeMarkers: unique(profiles.flatMap((profile) => profile.typeMarkers)),
113
+ testFile: anyOf(profiles.map((profile) => profile.testFilePattern)),
114
+ sourceFile: anyOf(profiles.map((profile) => profile.sourceFilePattern)),
115
+ lintGlobs: lintGlobsOf(profiles),
116
+ }
117
+ }
51
118
 
52
- const projectChecks = (config: CheckConfig, paths: readonly string[]): PlannedCheck[] =>
53
- projectChecksFor(paths, config.checks).map(({ name, command }) => ({
119
+ const lintedPaths = (paths: readonly string[], lintGlobs: readonly string[]): string[] =>
120
+ lintGlobs.length === 0 ? [...paths] : paths.filter((path) => matchesAnyGlob(path, lintGlobs))
121
+
122
+ /** A predicate from the configured globs, or the given one where none are configured. */
123
+ const globsOr =
124
+ (globs: readonly string[] | undefined, fallback: (path: string) => boolean) =>
125
+ (path: string): boolean =>
126
+ globs === undefined ? fallback(path) : matchesAnyGlob(path, globs)
127
+
128
+ /** Each path no root marker holds, with the nearest fallback root above it; paths without one are left out. */
129
+ const fallbackRootsOf = (
130
+ paths: readonly string[],
131
+ fallback: readonly string[],
132
+ isProjectRoot: Filesystem['isProjectRoot'],
133
+ ): { path: string; root: string }[] =>
134
+ paths.flatMap((path) => {
135
+ const root = packageRootOf(path, isProjectRoot) === null ? nearestRootOf(path, fallback) : null
136
+ return root === null ? [] : [{ path, root }]
137
+ })
138
+
139
+ const changeRootsOf = (
140
+ paths: readonly string[],
141
+ fallback: readonly string[],
142
+ { isProjectRoot, isWorkspaceRoot }: Pick<Filesystem, 'isProjectRoot' | 'isWorkspaceRoot'>,
143
+ ): ChangeRoots => {
144
+ const isTestable = (root: string) => !isWorkspaceRoot(root)
145
+ const fallbackPairs = fallbackRootsOf(paths, fallback, isProjectRoot)
146
+ const fallbackPaths = fallbackPairs.map(({ path }) => path)
147
+ const fallbackRoots = unique(fallbackPairs.map(({ root }) => root)).filter(isTestable)
148
+ const markerRoots = packageRootsFor(paths, isProjectRoot).filter(isTestable)
149
+ return { roots: unique([...markerRoots, ...fallbackRoots]), fallbackRoots, fallbackPaths }
150
+ }
151
+
152
+ /** No lint without a `lintFilesCommand`, none is guessed; none either when no changed file is the stack's to lint. */
153
+ const lintChecks = (command: string | undefined, paths: readonly string[]): PlannedCheck[] =>
154
+ command === undefined || paths.length === 0
155
+ ? []
156
+ : [
157
+ {
158
+ name: 'Lint & unused imports',
159
+ command: `${command} (${paths.length} files)`,
160
+ argv: [...tokenize(command), ...paths],
161
+ cwd: '.',
162
+ kind: CHECK_KIND.lint,
163
+ },
164
+ ]
165
+
166
+ /** Without a detected stack and a `lintFilesCommand`, the project's lint once from the repository root. */
167
+ const projectLint = (command: string | undefined): PlannedCheck[] =>
168
+ command === undefined ? [] : [{ name: 'Lint', command, argv: tokenize(command), cwd: '.', kind: CHECK_KIND.lint }]
169
+
170
+ const lintOf = (
171
+ lintFilesCommand: string | undefined,
172
+ noStackFallback: NoStackFallback | null,
173
+ paths: readonly string[],
174
+ ): PlannedCheck[] =>
175
+ lintFilesCommand === undefined ? projectLint(noStackFallback?.lintCommand) : lintChecks(lintFilesCommand, paths)
176
+
177
+ const projectChecks = (fired: readonly ProjectCheck[]): PlannedCheck[] =>
178
+ fired.map(({ name, command, kind }) => ({
54
179
  name,
55
180
  command,
56
181
  argv: tokenize(command),
57
182
  cwd: '.',
58
- kind: CHECK_KIND.project,
183
+ kind: kind ?? CHECK_KIND.project,
59
184
  }))
60
185
 
61
186
  const inDirectory = (kind: CheckKind, label: string, command: string, dir: string): PlannedCheck => ({
@@ -66,48 +191,108 @@ const inDirectory = (kind: CheckKind, label: string, command: string, dir: strin
66
191
  kind,
67
192
  })
68
193
 
194
+ /** No type check without `commands.typecheck`, no test without `commands.test`: none is guessed. */
69
195
  const typeChecks = (
70
196
  roots: readonly string[],
71
- command: string,
72
- hasTsconfig: Filesystem['hasTsconfig'],
73
- ): PlannedCheck[] => roots.filter(hasTsconfig).map((root) => inDirectory(CHECK_KIND.types, 'Types', command, root))
197
+ command: string | undefined,
198
+ isTypeRoot: (root: string) => boolean,
199
+ ): PlannedCheck[] =>
200
+ command === undefined
201
+ ? []
202
+ : roots.filter(isTypeRoot).map((root) => inDirectory(CHECK_KIND.types, 'Types', command, root))
203
+
204
+ const testChecks = (dirs: readonly string[], command: string | undefined): PlannedCheck[] =>
205
+ command === undefined ? [] : dirs.map((dir) => inDirectory(CHECK_KIND.tests, 'Tests', command, dir))
206
+
207
+ const isReached = (path: string, { dirs, checks }: TestReach): boolean =>
208
+ dirs.some((dir) => isUnder(path, dir)) ||
209
+ checks.some(({ globs }) => globs === undefined || matchesAnyGlob(path, globs))
210
+
211
+ /** Each changed source file on its own: one test elsewhere in the change does not cover it. */
212
+ const untestedSourcesOf = (
213
+ paths: readonly string[],
214
+ reach: TestReach,
215
+ { isSource, kitCopies }: { isSource: (path: string) => boolean; kitCopies: readonly string[] },
216
+ ): string[] => paths.filter((path) => isSource(path) && !kitCopies.includes(path) && !isReached(path, reach))
217
+
218
+ /** The source files among `paths` with no test file of their name in the repository, read only when there are any. */
219
+ const sourcesWithoutNamedTestOf = (
220
+ paths: readonly string[],
221
+ testChecks: readonly ProjectCheck[],
222
+ { check, kitCopies }: Pick<PlanInputs, 'check' | 'kitCopies'>,
223
+ repositoryFiles: Filesystem['repositoryFiles'],
224
+ ): string[] => {
225
+ const isTest = globsOr(check.testFiles, isTestFile)
226
+ const isNamedSource = globsOr(check.sourceFiles, isSourceFile)
227
+ const isSource = (path: string) => !isTest(path) && isNamedSource(path)
228
+ const candidates = untestedSourcesOf(paths, { dirs: [], checks: testChecks }, { isSource, kitCopies })
229
+ if (candidates.length === 0) return []
230
+ const testStems = testStemsOf(repositoryFiles(), isTest)
231
+ return candidates.filter((path) => !hasNamedTest(path, testStems))
232
+ }
233
+
234
+ const coverageOf = (
235
+ { paths, fallbackPaths }: { paths: readonly string[]; fallbackPaths: readonly string[] },
236
+ reach: TestReach,
237
+ { check, stack, kitCopies }: Pick<PlanInputs, 'check' | 'stack' | 'kitCopies'>,
238
+ repositoryFiles: Filesystem['repositoryFiles'],
239
+ ): Coverage => {
240
+ const markerPaths = paths.filter((path) => !fallbackPaths.includes(path))
241
+ const isSource = globsOr(check.sourceFiles, (path) => stack.sourceFile.test(path))
242
+ return {
243
+ untestedSources: untestedSourcesOf(markerPaths, reach, { isSource, kitCopies }),
244
+ sourcesWithoutNamedTest: sourcesWithoutNamedTestOf(
245
+ fallbackPaths,
246
+ reach.checks,
247
+ { check, kitCopies },
248
+ repositoryFiles,
249
+ ),
250
+ }
251
+ }
74
252
 
75
- const testChecks = (dirs: readonly string[], command: string): PlannedCheck[] =>
76
- dirs.map((dir) => inDirectory(CHECK_KIND.tests, 'Tests', command, dir))
253
+ /** The reviewers a set of changed paths calls for, each named once: routed, public content and agent texts. */
254
+ export const reviewersOf = (paths: readonly string[], config: CheckConfig): string[] => [
255
+ ...new Set([
256
+ ...reviewersFor(paths, config.reviewerRoutes),
257
+ ...publicContentReviewers(paths, config.publicContent),
258
+ ...agentTextReviewers(paths),
259
+ ]),
260
+ ]
77
261
 
78
262
  /**
79
263
  * 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.
264
+ * which package roots to type-check and test, which project checks fire, which reviewers to start,
265
+ * and which changed source files no test covers. A root that gathers workspaces is never tested. Without a
266
+ * detected stack, a file no root marker holds is tested from its `NoStackFallback.roots` entry and judged by its name.
81
267
  */
82
268
  export const planChecks = (
83
269
  files: readonly ChangedFile[],
84
- commands: PackageCommands,
85
- config: CheckConfig,
270
+ { commands, check: config, stack, kitCopies, noStackFallback }: PlanInputs,
86
271
  filesystem: Filesystem,
87
272
  ): CheckPlan => {
88
273
  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) : []
274
+ const { roots, fallbackRoots, fallbackPaths } = changeRootsOf(paths, noStackFallback?.roots ?? [], filesystem)
275
+ const isTypeRoot = (root: string) => fallbackRoots.includes(root) || filesystem.hasTypeConfig(root)
276
+ const loose = config.looseTests ? looseDirectories(paths, roots, filesystem.hasTests) : []
277
+ const fired = projectChecksFor(paths, config.checks)
278
+ const testDirs = commands.test === undefined ? [] : [...roots, ...loose]
279
+ const checks =
280
+ paths.length === 0
281
+ ? []
282
+ : [
283
+ ...lintOf(config.lintFilesCommand, noStackFallback, lintedPaths(paths, stack.lintGlobs)),
284
+ ...projectChecks(fired),
285
+ ...typeChecks(roots, commands.typecheck, isTypeRoot),
286
+ ...testChecks(testDirs, commands.test),
287
+ ]
288
+ const reach: TestReach = { dirs: testDirs, checks: fired.filter((check) => check.kind === CHECK_KIND.tests) }
91
289
  return {
92
290
  paths,
93
291
  roots,
94
- reviewers: [
95
- ...new Set([
96
- ...reviewersFor(paths, config.reviewerRoutes),
97
- ...publicContentReviewers(paths, config.publicContent),
98
- ...agentTextReviewers(paths),
99
- ]),
100
- ],
292
+ reviewers: reviewersOf(paths, config),
101
293
  generated: generatorOwnedFiles(paths, config.generatorOwned),
102
294
  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
- ],
295
+ checks,
296
+ ...coverageOf({ paths, fallbackPaths }, reach, { check: config, stack, kitCopies }, filesystem.repositoryFiles),
112
297
  }
113
298
  }
@@ -1,6 +1,9 @@
1
+ import { STACKS, type StackDetection, stackOfCommand } from '../config/detect'
2
+ import { PROJECT_CONFIG_PATH } from '../config/location'
3
+ import { CLI_NAME } from '../version/release'
1
4
  import { type ChangedFile, STATUS_COMMAND } from './changes'
2
- import type { CheckPlan } from './plan'
3
- import { CHECK_STATUS, type CheckResult, SKIP_SLOW_FLAG } from './result'
5
+ import type { CheckPlan, PackageCommands } from './plan'
6
+ import { CHECK_STATUS, type CheckResult, type CheckStatus, RUN_MODE, type RunMode, SKIP_SLOW_FLAG } from './result'
4
7
 
5
8
  const STATUS_ICON = { pass: '✅', fail: '❌', warn: '⚠️ ', skipped: '⏭️ ' } as const
6
9
 
@@ -20,10 +23,115 @@ export const skippedSlowResult = (): CheckResult => ({
20
23
  durationMs: 0,
21
24
  })
22
25
 
23
- export const formatPlanHeader = (files: readonly ChangedFile[], plan: CheckPlan): string[] => [
26
+ const plural = (count: number, noun: string): string => `${count} ${noun}${count === 1 ? '' : 's'}`
27
+
28
+ /** A full run fails on a changed source file no test covers; a quick one runs no test by design, so it warns. */
29
+ export const UNTESTED_STATUS: Record<RunMode, CheckStatus> = {
30
+ [RUN_MODE.full]: CHECK_STATUS.fail,
31
+ [RUN_MODE.quick]: CHECK_STATUS.warn,
32
+ }
33
+
34
+ /** A changed source file of the stack with no test planned, with the status `UNTESTED_STATUS` gives the run mode. */
35
+ export const untestedResult = (sources: readonly string[], mode: RunMode): CheckResult | null =>
36
+ sources.length === 0
37
+ ? null
38
+ : {
39
+ name: 'Tests',
40
+ command: 'commands.test',
41
+ status: UNTESTED_STATUS[mode],
42
+ detail: [
43
+ `no test ran for ${plural(sources.length, 'changed source file')}; set \`commands.test\` or a \`check.checks\` entry with \`kind: "tests"\``,
44
+ ...sources,
45
+ ].join('\n'),
46
+ durationMs: 0,
47
+ }
48
+
49
+ /** Without a detected stack, the changed source files no test file of their name covers: a warning in every run mode. */
50
+ export const namedTestResult = (sources: readonly string[]): CheckResult | null =>
51
+ sources.length === 0
52
+ ? null
53
+ : {
54
+ name: 'Tests by name',
55
+ command: 'check.testFiles',
56
+ status: CHECK_STATUS.warn,
57
+ detail: [
58
+ `no test file named like ${plural(sources.length, 'changed source file')}; add one, or name the test files in \`check.testFiles\``,
59
+ ...sources,
60
+ ].join('\n'),
61
+ durationMs: 0,
62
+ }
63
+
64
+ /** Without a detected stack nothing derives `commands.test`, and a check without it tests nothing, so it fails. */
65
+ export const missingTestCommandResult = (command: string | undefined, detection: StackDetection): CheckResult | null =>
66
+ command !== undefined || detection.kind !== 'none'
67
+ ? null
68
+ : {
69
+ name: 'Test command',
70
+ command: 'commands.test',
71
+ status: CHECK_STATUS.fail,
72
+ detail: `no stack detected and no \`commands.test\` set; set it in ${PROJECT_CONFIG_PATH} (\`${CLI_NAME} config test\` names the line, \`/jh-start\` proposes it from the CI)`,
73
+ durationMs: 0,
74
+ }
75
+
76
+ /** Without a detected stack a missing type or lint command is a note, not a result: not every language has the step. */
77
+ export const missingCommandNotes = (
78
+ commands: PackageCommands,
79
+ lintFilesCommand: string | undefined,
80
+ detection: StackDetection,
81
+ ): string[] => {
82
+ if (detection.kind !== 'none') return []
83
+ return [
84
+ ...(commands.typecheck === undefined ? [`${CLI_NAME}: no \`commands.typecheck\` set; no type check runs.`] : []),
85
+ ...(commands.lint === undefined && lintFilesCommand === undefined
86
+ ? [`${CLI_NAME}: neither \`check.lintFilesCommand\` nor \`commands.lint\` set; no lint runs.`]
87
+ : []),
88
+ ]
89
+ }
90
+
91
+ /** The lock files of known stacks the repository holds; none means it has not installed yet. */
92
+ const presentLockFiles = (detection: StackDetection): readonly string[] => {
93
+ if (detection.kind === 'found') return [detection.lockFile]
94
+ return detection.kind === 'ambiguous' ? detection.lockFiles : []
95
+ }
96
+
97
+ /**
98
+ * A failure when `commands.test` belongs to a known stack whose lock files the repository lacks (`bun test` beside
99
+ * only `composer.lock`): such a run finds no test and would read as green. Null for a command of no known stack,
100
+ * for no command, and in a repository without any lock file.
101
+ */
102
+ export const testCommandResult = (command: string | undefined, detection: StackDetection): CheckResult | null => {
103
+ const stack = command === undefined ? undefined : stackOfCommand(command)
104
+ const present = presentLockFiles(detection)
105
+ if (stack === undefined || present.length === 0) return null
106
+ const { lockFiles } = STACKS[stack]
107
+ if (lockFiles.some((file) => present.includes(file))) return null
108
+ return {
109
+ name: 'Test command',
110
+ command: 'commands.test',
111
+ status: CHECK_STATUS.fail,
112
+ detail: `\`${command}\` needs ${lockFiles.join(' or ')}, which is missing; set \`commands.test\` for this repository's stack (\`${CLI_NAME} config test\` names the line)`,
113
+ durationMs: 0,
114
+ }
115
+ }
116
+
117
+ /** The stack line says how the stack was decided: by lock file, by manifest alone, none, or several lock files and so every stack's markers. */
118
+ export const stackLine = (detection: StackDetection): string => {
119
+ if (detection.kind === 'found') return `Stack: ${detection.stack}`
120
+ if (detection.kind === 'manifest') return `Stack: ${detection.stack} (from ${detection.manifest}, no lock file yet)`
121
+ if (detection.kind === 'none')
122
+ return 'Stack: none detected (no lock file of a known stack, no manifest of a single stack)'
123
+ return `Stack: ambiguous (lock files of several stacks: ${detection.lockFiles.join(', ')}), checking with every stack's markers`
124
+ }
125
+
126
+ export const formatPlanHeader = (
127
+ files: readonly ChangedFile[],
128
+ plan: CheckPlan,
129
+ detection: StackDetection,
130
+ ): string[] => [
24
131
  `Changed files (${files.length}, ${files.filter((file) => file.staged).length} staged):`,
25
132
  ...files.map((file) => ` ${file.staged ? 'S' : ' '} ${file.path}`),
26
133
  '',
134
+ stackLine(detection),
27
135
  `Package roots: ${plan.roots.join(', ') || 'none'}`,
28
136
  `Reviewers: ${plan.reviewers.join(', ') || 'none'}`,
29
137
  ...(plan.generated.length > 0
@@ -1,5 +1,7 @@
1
- import type { PlannedCheck } from './plan'
2
- import { CHECK_KIND } from './plan'
1
+ /** What a planned check is; `types` and `tests` are the slow ones, and a project check may stand in for either. */
2
+ export const CHECK_KIND = { lint: 'lint', project: 'project', types: 'types', tests: 'tests' } as const
3
+
4
+ export type CheckKind = (typeof CHECK_KIND)[keyof typeof CHECK_KIND]
3
5
 
4
6
  /** `warn` reports a finding without failing the run. */
5
7
  export const CHECK_STATUS = { pass: 'pass', fail: 'fail', warn: 'warn', skipped: 'skipped' } as const
@@ -18,6 +20,14 @@ export type ProcessResult = { ok: boolean; output: string }
18
20
 
19
21
  export const SKIP_SLOW_FLAG = '--skip-slow'
20
22
 
23
+ /** `quick` leaves every type check and test out (`--skip-slow`); `full` runs them. */
24
+ export const RUN_MODE = { full: 'full', quick: 'quick' } as const
25
+
26
+ export type RunMode = (typeof RUN_MODE)[keyof typeof RUN_MODE]
27
+
28
+ export const runModeOf = (args: readonly string[]): RunMode =>
29
+ args.includes(SKIP_SLOW_FLAG) ? RUN_MODE.quick : RUN_MODE.full
30
+
21
31
  /**
22
32
  * Bun reports "there was nothing to run" in two shapes: a package whose files hold no test cases
23
33
  * summarises as `0 tests`, one with no matching files exits non-zero with `0 test files matching`.
@@ -30,7 +40,7 @@ export const lastLines = (output: string, count: number): string =>
30
40
 
31
41
  /** How a finished process reads for its check: an absent test suite is skipped, not failed. */
32
42
  export const outcomeOf = (
33
- check: PlannedCheck,
43
+ check: { kind: CheckKind },
34
44
  { ok, output }: ProcessResult,
35
45
  ): Pick<CheckResult, 'status' | 'detail'> => {
36
46
  if (check.kind === CHECK_KIND.tests && reportsNoTests(output)) {