jackherd 1.13.0 → 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 +5 -0
- package/README.md +17 -35
- package/dist/config.d.ts +3 -0
- package/dist/config.js +11 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +2 -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 +6 -2
- package/kit/workflow/skills/start/references/missing-commands.md +17 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +24 -0
- package/src/check/command.ts +52 -6
- package/src/check/file-kinds.ts +142 -0
- package/src/check/plan.ts +118 -16
- package/src/check/report.ts +44 -1
- package/src/check/roots.ts +9 -0
- package/src/check/schema.ts +12 -0
- package/src/check/settings.ts +7 -3
- package/src/check/skill-budget-defaults.ts +6 -4
- package/src/plans/lane-plans.ts +2 -1
- 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
package/src/check/plan.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { STACKS, type Stack, type StackProfile } from '../config/detect'
|
|
|
3
3
|
import { agentTextReviewers } from './agent-text-reviewers'
|
|
4
4
|
import { type ChangedFile, generatorOwnedFiles, stagedBuildArtifacts } from './changes'
|
|
5
5
|
import { tokenize } from './command-line'
|
|
6
|
+
import { hasNamedTest, isSourceFile, isTestFile, testStemsOf } from './file-kinds'
|
|
6
7
|
import { matchesAnyGlob } from './glob'
|
|
7
8
|
import { CHECK_KIND, type CheckKind } from './result'
|
|
8
|
-
import { isUnder, looseDirectories, packageRootsFor } from './roots'
|
|
9
|
+
import { isUnder, looseDirectories, nearestRootOf, packageRootOf, packageRootsFor } from './roots'
|
|
9
10
|
import { projectChecksFor, publicContentReviewers, reviewersFor } from './routing'
|
|
10
11
|
import type { CheckConfig, ProjectCheck } from './schema'
|
|
11
12
|
|
|
@@ -21,7 +22,7 @@ export type PlannedCheck = {
|
|
|
21
22
|
kind: CheckKind
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export type PackageCommands = Pick<JackherdConfig['commands'], 'test' | 'typecheck'>
|
|
25
|
+
export type PackageCommands = Pick<JackherdConfig['commands'], 'test' | 'typecheck' | 'lint'>
|
|
25
26
|
|
|
26
27
|
/** What the plan asks of the disk, by the markers of the stack (`checkStackOf`). */
|
|
27
28
|
export type Filesystem = {
|
|
@@ -32,9 +33,11 @@ export type Filesystem = {
|
|
|
32
33
|
/** Whether the directory holds a type marker of the stack (`tsconfig.json`, `phpstan.neon`). */
|
|
33
34
|
hasTypeConfig: (dir: string) => boolean
|
|
34
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[]
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
/** The stack as the check reads it;
|
|
40
|
+
/** The stack as the check reads it; with none or several detected, every stack's markers and patterns together. */
|
|
38
41
|
export type CheckStack = {
|
|
39
42
|
rootMarkers: string[]
|
|
40
43
|
typeMarkers: string[]
|
|
@@ -44,6 +47,14 @@ export type CheckStack = {
|
|
|
44
47
|
lintGlobs: string[]
|
|
45
48
|
}
|
|
46
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
|
+
|
|
47
58
|
/** What the plan reads from the settings: the test and type commands, the `check` block and the stack's patterns. */
|
|
48
59
|
export type PlanInputs = {
|
|
49
60
|
commands: PackageCommands
|
|
@@ -51,6 +62,8 @@ export type PlanInputs = {
|
|
|
51
62
|
stack: Pick<CheckStack, 'lintGlobs' | 'sourceFile'>
|
|
52
63
|
/** Repository paths of the kit's copies (`.jackherd/lock.json`); their tests run where the kit keeps the source. */
|
|
53
64
|
kitCopies: readonly string[]
|
|
65
|
+
/** Null where a stack is detected, or several. */
|
|
66
|
+
noStackFallback: NoStackFallback | null
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
export type CheckPlan = {
|
|
@@ -63,8 +76,22 @@ export type CheckPlan = {
|
|
|
63
76
|
checks: PlannedCheck[]
|
|
64
77
|
/** The changed source files of the stack no planned test reaches, kit copies aside; a check without a test is no green. */
|
|
65
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[]
|
|
66
84
|
}
|
|
67
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
|
+
|
|
68
95
|
export const isSlow = (check: PlannedCheck): boolean =>
|
|
69
96
|
check.kind === CHECK_KIND.types || check.kind === CHECK_KIND.tests
|
|
70
97
|
|
|
@@ -92,6 +119,36 @@ export const checkStackOf = (stack: Stack | null): CheckStack => {
|
|
|
92
119
|
const lintedPaths = (paths: readonly string[], lintGlobs: readonly string[]): string[] =>
|
|
93
120
|
lintGlobs.length === 0 ? [...paths] : paths.filter((path) => matchesAnyGlob(path, lintGlobs))
|
|
94
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
|
+
|
|
95
152
|
/** No lint without a `lintFilesCommand`, none is guessed; none either when no changed file is the stack's to lint. */
|
|
96
153
|
const lintChecks = (command: string | undefined, paths: readonly string[]): PlannedCheck[] =>
|
|
97
154
|
command === undefined || paths.length === 0
|
|
@@ -106,6 +163,17 @@ const lintChecks = (command: string | undefined, paths: readonly string[]): Plan
|
|
|
106
163
|
},
|
|
107
164
|
]
|
|
108
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
|
+
|
|
109
177
|
const projectChecks = (fired: readonly ProjectCheck[]): PlannedCheck[] =>
|
|
110
178
|
fired.map(({ name, command, kind }) => ({
|
|
111
179
|
name,
|
|
@@ -127,18 +195,15 @@ const inDirectory = (kind: CheckKind, label: string, command: string, dir: strin
|
|
|
127
195
|
const typeChecks = (
|
|
128
196
|
roots: readonly string[],
|
|
129
197
|
command: string | undefined,
|
|
130
|
-
|
|
198
|
+
isTypeRoot: (root: string) => boolean,
|
|
131
199
|
): PlannedCheck[] =>
|
|
132
200
|
command === undefined
|
|
133
201
|
? []
|
|
134
|
-
: roots.filter(
|
|
202
|
+
: roots.filter(isTypeRoot).map((root) => inDirectory(CHECK_KIND.types, 'Types', command, root))
|
|
135
203
|
|
|
136
204
|
const testChecks = (dirs: readonly string[], command: string | undefined): PlannedCheck[] =>
|
|
137
205
|
command === undefined ? [] : dirs.map((dir) => inDirectory(CHECK_KIND.tests, 'Tests', command, dir))
|
|
138
206
|
|
|
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
207
|
const isReached = (path: string, { dirs, checks }: TestReach): boolean =>
|
|
143
208
|
dirs.some((dir) => isUnder(path, dir)) ||
|
|
144
209
|
checks.some(({ globs }) => globs === undefined || matchesAnyGlob(path, globs))
|
|
@@ -147,8 +212,43 @@ const isReached = (path: string, { dirs, checks }: TestReach): boolean =>
|
|
|
147
212
|
const untestedSourcesOf = (
|
|
148
213
|
paths: readonly string[],
|
|
149
214
|
reach: TestReach,
|
|
150
|
-
{
|
|
151
|
-
): string[] => paths.filter((path) =>
|
|
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
|
+
}
|
|
152
252
|
|
|
153
253
|
/** The reviewers a set of changed paths calls for, each named once: routed, public content and agent texts. */
|
|
154
254
|
export const reviewersOf = (paths: readonly string[], config: CheckConfig): string[] => [
|
|
@@ -162,15 +262,17 @@ export const reviewersOf = (paths: readonly string[], config: CheckConfig): stri
|
|
|
162
262
|
/**
|
|
163
263
|
* Decides, without touching the disk or running anything, what a set of changed files calls for:
|
|
164
264
|
* 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.
|
|
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.
|
|
166
267
|
*/
|
|
167
268
|
export const planChecks = (
|
|
168
269
|
files: readonly ChangedFile[],
|
|
169
|
-
{ commands, check: config, stack, kitCopies }: PlanInputs,
|
|
270
|
+
{ commands, check: config, stack, kitCopies, noStackFallback }: PlanInputs,
|
|
170
271
|
filesystem: Filesystem,
|
|
171
272
|
): CheckPlan => {
|
|
172
273
|
const paths = files.map((file) => file.path)
|
|
173
|
-
const roots =
|
|
274
|
+
const { roots, fallbackRoots, fallbackPaths } = changeRootsOf(paths, noStackFallback?.roots ?? [], filesystem)
|
|
275
|
+
const isTypeRoot = (root: string) => fallbackRoots.includes(root) || filesystem.hasTypeConfig(root)
|
|
174
276
|
const loose = config.looseTests ? looseDirectories(paths, roots, filesystem.hasTests) : []
|
|
175
277
|
const fired = projectChecksFor(paths, config.checks)
|
|
176
278
|
const testDirs = commands.test === undefined ? [] : [...roots, ...loose]
|
|
@@ -178,9 +280,9 @@ export const planChecks = (
|
|
|
178
280
|
paths.length === 0
|
|
179
281
|
? []
|
|
180
282
|
: [
|
|
181
|
-
...
|
|
283
|
+
...lintOf(config.lintFilesCommand, noStackFallback, lintedPaths(paths, stack.lintGlobs)),
|
|
182
284
|
...projectChecks(fired),
|
|
183
|
-
...typeChecks(roots, commands.typecheck,
|
|
285
|
+
...typeChecks(roots, commands.typecheck, isTypeRoot),
|
|
184
286
|
...testChecks(testDirs, commands.test),
|
|
185
287
|
]
|
|
186
288
|
const reach: TestReach = { dirs: testDirs, checks: fired.filter((check) => check.kind === CHECK_KIND.tests) }
|
|
@@ -191,6 +293,6 @@ export const planChecks = (
|
|
|
191
293
|
generated: generatorOwnedFiles(paths, config.generatorOwned),
|
|
192
294
|
artifacts: stagedBuildArtifacts(files, config.buildArtifacts),
|
|
193
295
|
checks,
|
|
194
|
-
|
|
296
|
+
...coverageOf({ paths, fallbackPaths }, reach, { check: config, stack, kitCopies }, filesystem.repositoryFiles),
|
|
195
297
|
}
|
|
196
298
|
}
|
package/src/check/report.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { STACKS, type StackDetection, stackOfCommand } from '../config/detect'
|
|
2
|
+
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
2
3
|
import { CLI_NAME } from '../version/release'
|
|
3
4
|
import { type ChangedFile, STATUS_COMMAND } from './changes'
|
|
4
|
-
import type { CheckPlan } from './plan'
|
|
5
|
+
import type { CheckPlan, PackageCommands } from './plan'
|
|
5
6
|
import { CHECK_STATUS, type CheckResult, type CheckStatus, RUN_MODE, type RunMode, SKIP_SLOW_FLAG } from './result'
|
|
6
7
|
|
|
7
8
|
const STATUS_ICON = { pass: '✅', fail: '❌', warn: '⚠️ ', skipped: '⏭️ ' } as const
|
|
@@ -45,6 +46,48 @@ export const untestedResult = (sources: readonly string[], mode: RunMode): Check
|
|
|
45
46
|
durationMs: 0,
|
|
46
47
|
}
|
|
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
|
+
|
|
48
91
|
/** The lock files of known stacks the repository holds; none means it has not installed yet. */
|
|
49
92
|
const presentLockFiles = (detection: StackDetection): readonly string[] => {
|
|
50
93
|
if (detection.kind === 'found') return [detection.lockFile]
|
package/src/check/roots.ts
CHANGED
|
@@ -46,6 +46,15 @@ export const kitCopyPaths = (manifest: unknown): string[] => {
|
|
|
46
46
|
/** Whether a test run from `root` reaches `path`; from `.` it reaches every path. */
|
|
47
47
|
export const isUnder = (path: string, root: string): boolean => root === '.' || path.startsWith(`${root}/`)
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The deepest of `roots` above `path`; null when none is. Without a detected stack, a file no root marker holds
|
|
51
|
+
* belongs to the nearest `check.roots` entry, with the repository root `.` among them.
|
|
52
|
+
*/
|
|
53
|
+
export const nearestRootOf = (path: string, roots: readonly string[]): string | null =>
|
|
54
|
+
roots
|
|
55
|
+
.filter((root) => isUnder(path, root))
|
|
56
|
+
.reduce<string | null>((nearest, root) => (nearest === null || root.length > nearest.length ? root : nearest), null)
|
|
57
|
+
|
|
49
58
|
/**
|
|
50
59
|
* Directories with test files that belong to no package root, which would otherwise be tested by
|
|
51
60
|
* nobody. The repository root `.` is excluded: a test run from there walks the whole repository.
|
package/src/check/schema.ts
CHANGED
|
@@ -20,6 +20,9 @@ const glob = nonEmpty.refine((value) => !/[{}[\]]/.test(value), 'Braces and brac
|
|
|
20
20
|
|
|
21
21
|
const globs = z.array(glob).min(1)
|
|
22
22
|
|
|
23
|
+
/** A directory from the repository root as the check compares it: `./crates/core/` reads as `crates/core`. */
|
|
24
|
+
const directory = nonEmpty.transform((value) => value.replace(/^(\.\/)+/, '').replace(/\/+$/, '') || '.')
|
|
25
|
+
|
|
23
26
|
/** Staged output that belongs to a build, never to a commit. */
|
|
24
27
|
export const DEFAULT_BUILD_ARTIFACT_GLOBS = [
|
|
25
28
|
'**/tsup.config.bundled_*',
|
|
@@ -88,6 +91,15 @@ export const checkConfigSchema = z.strictObject({
|
|
|
88
91
|
skillBudgets: skillBudgets.optional(),
|
|
89
92
|
/** Also run tests in directories that hold test files but belong to no package. */
|
|
90
93
|
looseTests: z.boolean().default(false),
|
|
94
|
+
/**
|
|
95
|
+
* Package roots, as directories from the repository root, read only where no stack is detected: a changed file
|
|
96
|
+
* belongs to the nearest one above it, else to the repository root `.`.
|
|
97
|
+
*/
|
|
98
|
+
roots: z.array(directory).min(1).optional(),
|
|
99
|
+
/** The files a test covers; replaces the stack's pattern, or the rule of thumb where no stack is detected. */
|
|
100
|
+
sourceFiles: globs.optional(),
|
|
101
|
+
/** The test files; replaces the stack's pattern, or the rule of thumb where no stack is detected. */
|
|
102
|
+
testFiles: globs.optional(),
|
|
91
103
|
})
|
|
92
104
|
|
|
93
105
|
export type CheckConfig = z.infer<typeof checkConfigSchema>
|
package/src/check/settings.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from '../config/detect'
|
|
11
11
|
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
12
12
|
import type { PlanLanguage } from '../vocabulary'
|
|
13
|
-
import type { PackageCommands } from './plan'
|
|
13
|
+
import type { NoStackFallback, PackageCommands } from './plan'
|
|
14
14
|
import { type CheckConfig, checkConfigSchema } from './schema'
|
|
15
15
|
|
|
16
16
|
export const CONFIG_PATH = PROJECT_CONFIG_PATH
|
|
@@ -33,6 +33,10 @@ export type CheckSettings = {
|
|
|
33
33
|
/** The stack a detection decided, by lock file or manifest; null for none or several, where the check reads every stack's markers. */
|
|
34
34
|
export const stackOf = (detection: StackDetection): Stack | null => (isDecided(detection) ? detection.stack : null)
|
|
35
35
|
|
|
36
|
+
/** Without any detected stack: `check.roots` and the repository root `.` as fallback roots, `commands.lint` from the root. */
|
|
37
|
+
export const noStackFallbackOf = ({ stack, check, commands }: CheckSettings): NoStackFallback | null =>
|
|
38
|
+
stack.kind === 'none' ? { roots: [...(check.roots ?? []), '.'], lintCommand: commands.lint } : null
|
|
39
|
+
|
|
36
40
|
const commandsSchema = jackherdConfigSchema.shape.commands
|
|
37
41
|
const planDirectorySchema = jackherdConfigSchema.shape.planDirectory
|
|
38
42
|
const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
|
|
@@ -47,9 +51,9 @@ const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
|
|
|
47
51
|
export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckSettings => {
|
|
48
52
|
const detected = withDetected(z.record(z.string(), z.unknown()).parse(raw), facts)
|
|
49
53
|
const object = z.record(z.string(), z.unknown()).parse(detected.raw)
|
|
50
|
-
const { test, typecheck } = commandsSchema.parse(object.commands)
|
|
54
|
+
const { test, typecheck, lint } = commandsSchema.parse(object.commands)
|
|
51
55
|
return {
|
|
52
|
-
commands: { test, typecheck },
|
|
56
|
+
commands: { test, typecheck, lint },
|
|
53
57
|
check: checkConfigSchema.parse(object.check ?? {}),
|
|
54
58
|
planDirectory: planDirectorySchema.parse(object.planDirectory),
|
|
55
59
|
planLanguage: planLanguageSchema.parse(object.planLanguage),
|
|
@@ -21,13 +21,14 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
|
|
|
21
21
|
'.claude/skills/implement/SKILL.md': 2190,
|
|
22
22
|
// P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
|
|
23
23
|
// Plain words: no plan code without its words in a short question.
|
|
24
|
-
|
|
24
|
+
// Any language: a pointer in § 0 and a section in events.md for `plan missing`.
|
|
25
|
+
'.claude/skills/orchestrate/SKILL.md': 7929,
|
|
25
26
|
'.claude/skills/orchestrate/references/acceptance.md': 200,
|
|
26
27
|
'.claude/skills/orchestrate/references/close-lane.md': 250,
|
|
27
28
|
'.claude/skills/orchestrate/references/coordinator-handover.md': 551,
|
|
28
29
|
'.claude/skills/orchestrate/references/draft-review.md': 300,
|
|
29
30
|
// P33: a source entry the user must read or decide becomes a row of what waits on them, and when it closes.
|
|
30
|
-
'.claude/skills/orchestrate/references/events.md':
|
|
31
|
+
'.claude/skills/orchestrate/references/events.md': 1166,
|
|
31
32
|
'.claude/skills/orchestrate/references/herd.md': 299,
|
|
32
33
|
'.claude/skills/orchestrate/references/lane-handover.md': 280,
|
|
33
34
|
'.claude/skills/orchestrate/references/lane-session.md': 280,
|
|
@@ -40,10 +41,11 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
|
|
|
40
41
|
'.claude/skills/pre-commit/SKILL.md': 1003,
|
|
41
42
|
'.claude/skills/review/SKILL.md': 319,
|
|
42
43
|
'.claude/skills/security-audit/SKILL.md': 519,
|
|
43
|
-
|
|
44
|
+
// Any language: step 3 points to proposing the missing commands from the CI; the proposal itself is a reference.
|
|
45
|
+
'.claude/skills/start/SKILL.md': 630,
|
|
44
46
|
'.claude/skills/uncle-bob/SKILL.md': 466,
|
|
45
47
|
// P33: grows with the skill above.
|
|
46
|
-
'.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md':
|
|
48
|
+
'.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 8729,
|
|
47
49
|
'.claude/agents/devops.md': 104,
|
|
48
50
|
'.claude/agents/explore.md': 132,
|
|
49
51
|
'.claude/agents/implement.md': 303,
|
package/src/plans/lane-plans.ts
CHANGED
|
@@ -27,7 +27,8 @@ export type PlanSources = {
|
|
|
27
27
|
parsed?: PlanRound
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
/** A repository path that a link resolved above the repository root. */
|
|
31
|
+
export const isOutsideRepository = (path: string): boolean => path.startsWith('..')
|
|
31
32
|
|
|
32
33
|
/** The file as the base ref has it; null when the ref lacks it or the path leaves the repository. */
|
|
33
34
|
export const textAtBase = (base: BaseReader, path: string): string | null =>
|
package/src/watch/command.ts
CHANGED
|
@@ -113,6 +113,7 @@ import {
|
|
|
113
113
|
noTaskSignalDutyLines,
|
|
114
114
|
otherPullRequestsLine,
|
|
115
115
|
parallelDutyLines,
|
|
116
|
+
planMissingDutyLines,
|
|
116
117
|
sourceEventLines,
|
|
117
118
|
sourceFailingStatusLine,
|
|
118
119
|
watchEventLine,
|
|
@@ -136,6 +137,8 @@ import {
|
|
|
136
137
|
import { mainCheckoutDutyLine } from './main-checkout'
|
|
137
138
|
import { monitorToolAvailable } from './monitor-tool'
|
|
138
139
|
import { parseWatchOptions, type WatchOptions } from './options'
|
|
140
|
+
import type { PlanMissingEvent, PlanMissingState } from './plan-missing'
|
|
141
|
+
import { readPlanMissingEvents } from './plan-missing-read'
|
|
139
142
|
import {
|
|
140
143
|
ENSURE_TRIGGER,
|
|
141
144
|
type EnsureTrigger,
|
|
@@ -311,6 +314,8 @@ type Watching = {
|
|
|
311
314
|
laneStalls: StallState
|
|
312
315
|
/** The open artifact sends in their grace or already reported as `unlinked artifact`; persisted like the fingerprints. */
|
|
313
316
|
unlinkedArtifacts: UnlinkedState
|
|
317
|
+
/** Per working lane, the plan last reported as `plan missing on base`; persisted like the fingerprints. */
|
|
318
|
+
planMissing: PlanMissingState
|
|
314
319
|
/** The approval requests of the last poll; null before the first read, so the stored ids stay. */
|
|
315
320
|
approvalRequests: ApprovalRequestFile[] | null
|
|
316
321
|
/** The board's appointment table at the last poll and the time it was read at; null before the first read and while the board cannot be read. */
|
|
@@ -365,6 +370,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
|
|
|
365
370
|
noTaskSignals: watching.noTaskSignals,
|
|
366
371
|
laneStalls: watching.laneStalls,
|
|
367
372
|
unlinkedArtifacts: watching.unlinkedArtifacts,
|
|
373
|
+
planMissing: watching.planMissing,
|
|
368
374
|
approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
|
|
369
375
|
sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
|
|
370
376
|
appointments: watching.appointments
|
|
@@ -665,6 +671,7 @@ const reportOf = async (
|
|
|
665
671
|
...laneModelDutyLines(events, watch.texts),
|
|
666
672
|
...parallelDutyLines(events, watch.texts),
|
|
667
673
|
...noTaskSignalDutyLines(events, watch.texts),
|
|
674
|
+
...planMissingDutyLines(events, watch.texts),
|
|
668
675
|
...approvalDutyLines(events, watch.texts),
|
|
669
676
|
]
|
|
670
677
|
const status = await statusLinesOf(watch, watching, next)
|
|
@@ -1129,6 +1136,27 @@ const unlinkedArtifactEventsOf = (watch: Watch, watching: Watching): UnlinkedArt
|
|
|
1129
1136
|
}
|
|
1130
1137
|
}
|
|
1131
1138
|
|
|
1139
|
+
/**
|
|
1140
|
+
* This heartbeat's `plan missing on base` events: the working lanes whose board row, as their worktree has it, links a
|
|
1141
|
+
* plan the base ref lacks, each once; records the state in `watching`. Nothing when the boards or git cannot be read:
|
|
1142
|
+
* the next heartbeat reads again.
|
|
1143
|
+
*/
|
|
1144
|
+
const planMissingEventsOf = (watch: Watch, watching: Watching): PlanMissingEvent[] => {
|
|
1145
|
+
try {
|
|
1146
|
+
const { events, state } = readPlanMissingEvents(
|
|
1147
|
+
watch.root,
|
|
1148
|
+
watch.settings,
|
|
1149
|
+
watch.sprintFile,
|
|
1150
|
+
workingLanes(watching),
|
|
1151
|
+
watching.planMissing,
|
|
1152
|
+
)
|
|
1153
|
+
watching.planMissing = state
|
|
1154
|
+
return events
|
|
1155
|
+
} catch {
|
|
1156
|
+
return []
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1132
1160
|
/** The progress of the working lanes, with the pull requests of the last GitHub read; nothing while no lane works. */
|
|
1133
1161
|
const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Record<string, LaneProgress>> => {
|
|
1134
1162
|
const working = workingLanes(watching)
|
|
@@ -1137,13 +1165,14 @@ const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Reco
|
|
|
1137
1165
|
return progress
|
|
1138
1166
|
}
|
|
1139
1167
|
|
|
1140
|
-
/** The events a heartbeat of a report brings: the parallel-task proposals, the lanes without a task signal, the unlinked artifacts. */
|
|
1168
|
+
/** The events a heartbeat of a report brings: the parallel-task proposals, the lanes without a task signal, the unlinked artifacts, the plans missing on the base. */
|
|
1141
1169
|
const heartbeatEventsOf = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
|
|
1142
1170
|
const progress = await workingProgressOf(watch, watching)
|
|
1143
1171
|
return [
|
|
1144
1172
|
...taskParallelEventsOf(watch, watching, now),
|
|
1145
1173
|
...noTaskSignalEventsOf(watch, watching, progress, now),
|
|
1146
1174
|
...unlinkedArtifactEventsOf(watch, watching),
|
|
1175
|
+
...planMissingEventsOf(watch, watching),
|
|
1147
1176
|
]
|
|
1148
1177
|
}
|
|
1149
1178
|
|
|
@@ -1206,6 +1235,7 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
|
|
|
1206
1235
|
...taskParallelEventsOf(watch, watching, now),
|
|
1207
1236
|
...noTaskSignalEventsOf(watch, watching, progress, now),
|
|
1208
1237
|
...unlinkedArtifactEventsOf(watch, watching),
|
|
1238
|
+
...planMissingEventsOf(watch, watching),
|
|
1209
1239
|
]
|
|
1210
1240
|
for (const event of events) console.log(`${watchEventLine(event, null)}${hintFor(watch, eventReference(event))}`)
|
|
1211
1241
|
return sent
|
|
@@ -1307,6 +1337,7 @@ const initialWatching = (
|
|
|
1307
1337
|
noTaskSignals: start.state.noTaskSignals,
|
|
1308
1338
|
laneStalls: start.state.laneStalls,
|
|
1309
1339
|
unlinkedArtifacts: start.state.unlinkedArtifacts,
|
|
1340
|
+
planMissing: start.state.planMissing,
|
|
1310
1341
|
approvalRequests: null,
|
|
1311
1342
|
appointments: null,
|
|
1312
1343
|
reportQuestions: null,
|
|
@@ -48,7 +48,12 @@ export const watchedLaneProgress = async (
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** The repository path of the plan of a lane's newest row that links one; null without such a row. */
|
|
51
|
-
const lanePlanPath = (
|
|
51
|
+
export const lanePlanPath = (
|
|
52
|
+
root: string,
|
|
53
|
+
settings: PlansSettings,
|
|
54
|
+
boardFile: string,
|
|
55
|
+
lane: SprintLane,
|
|
56
|
+
): string | null => {
|
|
52
57
|
const href = lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref
|
|
53
58
|
return href ? resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href) : null
|
|
54
59
|
}
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { LaneModelEvent } from './lane-model'
|
|
|
13
13
|
import type { LaneParallelEvent } from './lane-parallel'
|
|
14
14
|
import { laneStalledLine } from './lane-stalled'
|
|
15
15
|
import { AGENT_STATUS, type LaneSnapshot, NO_AGENT } from './lane-watch'
|
|
16
|
+
import { PLAN_MISSING_EVENT, type PlanMissingEvent, planMissingLine } from './plan-missing'
|
|
16
17
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
17
18
|
import { SOURCE_FIELD } from './source-fields'
|
|
18
19
|
import { type FieldChange, lineText, type SourceEvent } from './sources'
|
|
@@ -161,6 +162,7 @@ const laneEventLine = (event: WatchEvent): string | null => {
|
|
|
161
162
|
return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
|
|
162
163
|
if (event.kind === WATCH_EVENT.noTaskSignal) return `=== ${terminalSafe(noTaskSignalLine(event))}`
|
|
163
164
|
if (event.kind === WATCH_EVENT.laneStalled) return `=== ${terminalSafe(laneStalledLine(event))}`
|
|
165
|
+
if (event.kind === WATCH_EVENT.planMissing) return `=== ${planMissingLine(event)}`
|
|
164
166
|
return null
|
|
165
167
|
}
|
|
166
168
|
|
|
@@ -339,6 +341,14 @@ export const noTaskSignalDutyLines = (events: readonly WatchEvent[], texts: Watc
|
|
|
339
341
|
.filter(isNoTaskSignal)
|
|
340
342
|
.map(({ lane }) => `${terminalSafe(lane)} ${NO_TASK_SIGNAL_EVENT}: ${texts.noTaskSignalDuty}`)
|
|
341
343
|
|
|
344
|
+
const isPlanMissing = (event: WatchEvent): event is PlanMissingEvent => event.kind === WATCH_EVENT.planMissing
|
|
345
|
+
|
|
346
|
+
/** What a lane whose plan only its worktree has asks of the coordinator (`orchestrate` § 2 step 0): one line per such lane. */
|
|
347
|
+
export const planMissingDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
|
|
348
|
+
events
|
|
349
|
+
.filter(isPlanMissing)
|
|
350
|
+
.map(({ lane }) => `${terminalSafe(lane)} ${PLAN_MISSING_EVENT}: ${texts.planMissingDuty}`)
|
|
351
|
+
|
|
342
352
|
const isApproval = (event: WatchEvent): event is ApprovalEvent => event.kind === WATCH_EVENT.approval
|
|
343
353
|
|
|
344
354
|
/**
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { parseSprintLanes } from '../plan-format'
|
|
4
|
+
import { baseReaderAt, commitOf } from '../plans/git'
|
|
5
|
+
import { isOutsideRepository } from '../plans/lane-plans'
|
|
6
|
+
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
|
|
7
|
+
import { lanePlanPath } from './lane-progress'
|
|
8
|
+
import {
|
|
9
|
+
type PlanMissingEvent,
|
|
10
|
+
type PlanMissingReadings,
|
|
11
|
+
type PlanMissingState,
|
|
12
|
+
planMissingEvents,
|
|
13
|
+
} from './plan-missing'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The plan the lane's newest linking row names in the board as its worktree has it; null without a worktree board or
|
|
17
|
+
* row, or for a link that leaves the repository, which no base ref can hold.
|
|
18
|
+
*/
|
|
19
|
+
const worktreePlanOf = (root: string, settings: PlansSettings, boardFile: string, laneName: string): string | null => {
|
|
20
|
+
const file = join(worktreeOf(root, settings, laneName), boardRepoPathOf(root, settings, boardFile))
|
|
21
|
+
if (!existsSync(file)) return null
|
|
22
|
+
const lane = parseSprintLanes(readFileSync(file, 'utf8'), settings.planLanguage).find(({ name }) => name === laneName)
|
|
23
|
+
const path = lane === undefined ? null : lanePlanPath(root, settings, boardFile, lane)
|
|
24
|
+
return path === null || isOutsideRepository(path) ? null : path
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This heartbeat's `plan missing on base` events and the state to keep, for the working lanes: the plan of each lane's
|
|
29
|
+
* row in its own worktree against the base ref, where a worktree made from the base would not find it. Nothing when
|
|
30
|
+
* the base ref does not resolve, which would make every plan look missing.
|
|
31
|
+
*/
|
|
32
|
+
export const readPlanMissingEvents = (
|
|
33
|
+
root: string,
|
|
34
|
+
settings: PlansSettings,
|
|
35
|
+
boardFile: string,
|
|
36
|
+
workingLanes: readonly string[],
|
|
37
|
+
previous: PlanMissingState,
|
|
38
|
+
): { events: PlanMissingEvent[]; state: PlanMissingState } => {
|
|
39
|
+
const baseRef = baseRefOf(settings)
|
|
40
|
+
const commit = commitOf(root, baseRef)
|
|
41
|
+
if (commit === null) return { events: [], state: previous }
|
|
42
|
+
const base = baseReaderAt(root, commit)
|
|
43
|
+
const readings: PlanMissingReadings = Object.fromEntries(
|
|
44
|
+
workingLanes.map((lane) => [lane, worktreePlanOf(root, settings, boardFile, lane)]),
|
|
45
|
+
)
|
|
46
|
+
return planMissingEvents(previous, readings, (path) => base(path) !== null, baseRef)
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import { isRecord } from '../shared/is-record'
|
|
3
|
+
|
|
4
|
+
export const PLAN_MISSING_EVENT = 'plan missing on base'
|
|
5
|
+
|
|
6
|
+
/** Per working lane: the plan its newest board row links, as its worktree has it; null without one. */
|
|
7
|
+
export type PlanMissingReadings = Readonly<Record<string, string | null>>
|
|
8
|
+
|
|
9
|
+
/** Per lane: the plan path last reported as missing on the base ref; a lane whose plan is there has no entry. */
|
|
10
|
+
export type PlanMissingState = Record<string, string>
|
|
11
|
+
|
|
12
|
+
export type PlanMissingEvent = { kind: typeof PLAN_MISSING_EVENT; lane: string; path: string; baseRef: string }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One event per working lane whose board row, as its worktree has it, links a plan the base ref lacks (`existsAtBase`
|
|
16
|
+
* answers for a repository path). A plan is reported once while it stays missing: the state clears when it reaches the
|
|
17
|
+
* base or the row links another, and a lane without a reading (not at work) keeps what it had.
|
|
18
|
+
*/
|
|
19
|
+
export const planMissingEvents = (
|
|
20
|
+
previous: PlanMissingState,
|
|
21
|
+
readings: PlanMissingReadings,
|
|
22
|
+
existsAtBase: (path: string) => boolean,
|
|
23
|
+
baseRef: string,
|
|
24
|
+
): { events: PlanMissingEvent[]; state: PlanMissingState } => {
|
|
25
|
+
const events: PlanMissingEvent[] = []
|
|
26
|
+
const state: PlanMissingState = { ...previous }
|
|
27
|
+
for (const [lane, planPath] of Object.entries(readings)) {
|
|
28
|
+
if (planPath === null || existsAtBase(planPath)) {
|
|
29
|
+
delete state[lane]
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if (previous[lane] !== planPath) events.push({ kind: PLAN_MISSING_EVENT, lane, path: planPath, baseRef })
|
|
33
|
+
state[lane] = planPath
|
|
34
|
+
}
|
|
35
|
+
return { events, state }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `lane <name> plan missing on <base ref>: <path>`. */
|
|
39
|
+
export const planMissingLine = ({ lane, path, baseRef }: PlanMissingEvent): string =>
|
|
40
|
+
`lane ${terminalSafe(lane)} plan missing on ${terminalSafe(baseRef)}: ${terminalSafe(path)}`
|
|
41
|
+
|
|
42
|
+
export const isPlanMissingState = (value: unknown): value is PlanMissingState =>
|
|
43
|
+
isRecord(value) && Object.values(value).every((path) => typeof path === 'string')
|