jackherd 1.12.7 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/INSTALL.md +1 -1
- package/README.md +17 -17
- package/dist/config.d.ts +35 -11
- package/dist/config.js +49 -83
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +7 -32
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +2 -29
- package/kit/hooks/hooks/plain-question/code-cache.ts +70 -0
- package/kit/hooks/hooks/plain-question/index.ts +80 -0
- package/kit/hooks/hooks/plain-question/run.ts +50 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +19 -0
- package/kit/hooks/hooks/shared/boards.ts +96 -0
- package/kit/hooks/hooks/shared/plan-codes.ts +68 -0
- package/kit/hooks/hooks.json +9 -0
- package/kit/review/skills/diagnose/SKILL.md +2 -2
- package/kit/review/skills/diagnose/references/feedback-loops.md +4 -13
- package/kit/rules/rules/kit/code-style.md +15 -18
- package/kit/rules/rules/kit/testing.md +7 -28
- package/kit/workflow/skills/implement/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +4 -1
- package/kit/workflow/skills/orchestrate/references/events.md +6 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +2 -1
- package/kit/workflow/skills/start/SKILL.md +9 -6
- package/kit/workflow/skills/start/references/missing-commands.md +17 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +38 -11
- package/src/check/command.ts +167 -35
- package/src/check/file-kinds.ts +142 -0
- package/src/check/plan-structure-check.ts +34 -1
- package/src/check/plan.ts +230 -45
- package/src/check/report.ts +111 -3
- package/src/check/result.ts +13 -3
- package/src/check/roots.ts +47 -10
- package/src/check/schema.ts +20 -3
- package/src/check/settings.ts +25 -4
- package/src/check/skill-budget-defaults.ts +7 -4
- package/src/config/base-branch.ts +2 -0
- package/src/config/command.ts +32 -13
- package/src/config/detect.ts +242 -74
- package/src/config/location.ts +6 -0
- package/src/config/repository-facts.ts +32 -7
- package/src/config/repository-values.ts +162 -5
- package/src/config/schema.ts +34 -16
- package/src/lane-prompt/texts.ts +2 -2
- package/src/plan-format/bare-codes.ts +28 -0
- package/src/plans/lane-plans.ts +2 -1
- package/src/sync/command-context.ts +4 -2
- package/src/sync/herdr-plugin.ts +30 -6
- package/src/sync/init.ts +17 -16
- package/src/sync/render.ts +4 -6
- package/src/watch/command.ts +32 -1
- package/src/watch/lane-progress.ts +6 -1
- package/src/watch/lane-report.ts +10 -0
- package/src/watch/plan-missing-read.ts +47 -0
- package/src/watch/plan-missing.ts +43 -0
- package/src/watch/reference-paths.ts +1 -0
- package/src/watch/texts.ts +4 -0
- package/src/watch/watch-events.ts +7 -0
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
73
|
-
): PlannedCheck[] =>
|
|
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
|
-
|
|
76
|
-
|
|
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:
|
|
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 =
|
|
90
|
-
const
|
|
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
|
-
|
|
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
|
}
|
package/src/check/report.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/check/result.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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:
|
|
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)) {
|