jackherd 1.12.7 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/INSTALL.md +1 -1
- package/README.md +35 -17
- package/dist/config.d.ts +32 -11
- package/dist/config.js +38 -82
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +7 -32
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +2 -29
- package/kit/hooks/hooks/plain-question/code-cache.ts +70 -0
- package/kit/hooks/hooks/plain-question/index.ts +80 -0
- package/kit/hooks/hooks/plain-question/run.ts +50 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +19 -0
- package/kit/hooks/hooks/shared/boards.ts +96 -0
- package/kit/hooks/hooks/shared/plan-codes.ts +68 -0
- package/kit/hooks/hooks.json +9 -0
- package/kit/review/skills/diagnose/SKILL.md +2 -2
- package/kit/review/skills/diagnose/references/feedback-loops.md +4 -13
- package/kit/rules/rules/kit/code-style.md +15 -18
- package/kit/rules/rules/kit/testing.md +7 -28
- package/kit/workflow/skills/implement/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +2 -0
- package/kit/workflow/skills/start/SKILL.md +4 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +14 -11
- package/src/check/command.ts +121 -35
- package/src/check/plan-structure-check.ts +34 -1
- package/src/check/plan.ts +127 -44
- package/src/check/report.ts +67 -2
- package/src/check/result.ts +13 -3
- package/src/check/roots.ts +38 -10
- package/src/check/schema.ts +8 -3
- package/src/check/settings.ts +18 -1
- package/src/check/skill-budget-defaults.ts +3 -2
- package/src/config/base-branch.ts +2 -0
- package/src/config/command.ts +32 -13
- package/src/config/detect.ts +242 -74
- package/src/config/location.ts +6 -0
- package/src/config/repository-facts.ts +32 -7
- package/src/config/repository-values.ts +162 -5
- package/src/config/schema.ts +34 -16
- package/src/lane-prompt/texts.ts +2 -2
- package/src/plan-format/bare-codes.ts +28 -0
- package/src/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/config/detect.ts
CHANGED
|
@@ -1,55 +1,80 @@
|
|
|
1
1
|
import { CLI_NAME } from '../version/release'
|
|
2
|
+
import { DEFAULT_BASE_BRANCH } from './base-branch'
|
|
2
3
|
import { PROJECT_CONFIG_PATH } from './location'
|
|
4
|
+
import { COMMAND_KEYS, type CommandKey } from './schema'
|
|
3
5
|
|
|
4
|
-
export const
|
|
6
|
+
export const STACK = { bun: 'bun', yarn: 'yarn', pnpm: 'pnpm', npm: 'npm', composer: 'composer' } as const
|
|
5
7
|
|
|
6
|
-
export type
|
|
8
|
+
export type Stack = (typeof STACK)[keyof typeof STACK]
|
|
9
|
+
|
|
10
|
+
/** A tool choice: the command of the first package the manifest requires. */
|
|
11
|
+
type ToolChoice = readonly (readonly [packageName: string, command: string])[]
|
|
12
|
+
|
|
13
|
+
type ConfigObject = Record<string, unknown>
|
|
14
|
+
|
|
15
|
+
/** The blocks of a config that detection fills. */
|
|
16
|
+
type ConfigParts = { commands: ConfigObject; lanes: ConfigObject; check: ConfigObject | undefined }
|
|
7
17
|
|
|
8
18
|
/** What the kit reads from a repository to fill the values its config leaves out. */
|
|
9
19
|
export type RepositoryFacts = {
|
|
10
|
-
/** The lock files of `
|
|
20
|
+
/** The lock files of `STACKS` present in the repository root. */
|
|
11
21
|
lockFiles: string[]
|
|
12
22
|
/** Branch names on `origin`, without the remote prefix. */
|
|
13
23
|
remoteBranches: string[]
|
|
24
|
+
/** The package names the root manifests of the present stacks declare; empty without a readable manifest. */
|
|
25
|
+
manifestPackages: string[]
|
|
26
|
+
/** The root markers of `STACKS` present in the repository root (`composer.json`, `package.json`). */
|
|
27
|
+
manifests: string[]
|
|
14
28
|
}
|
|
15
29
|
|
|
16
|
-
|
|
17
|
-
|
|
30
|
+
/** `manifest`: no lock file yet, but a manifest only one stack has. */
|
|
31
|
+
export type StackDetection =
|
|
32
|
+
| { kind: 'found'; stack: Stack; lockFile: string }
|
|
33
|
+
| { kind: 'manifest'; stack: Stack; manifest: string }
|
|
18
34
|
| { kind: 'none' }
|
|
19
35
|
| { kind: 'ambiguous'; lockFiles: string[] }
|
|
20
36
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
lint: string
|
|
25
|
-
format: string
|
|
26
|
-
install: string
|
|
27
|
-
dev: string
|
|
28
|
-
lintFiles: string
|
|
29
|
-
}
|
|
37
|
+
/** A detection that names one stack, by its lock file or its manifest. */
|
|
38
|
+
export const isDecided = (detection: StackDetection): detection is Extract<StackDetection, { stack: Stack }> =>
|
|
39
|
+
detection.kind === 'found' || detection.kind === 'manifest'
|
|
30
40
|
|
|
31
|
-
|
|
32
|
-
export const LOCK_FILES: Record<string, PackageManager> = {
|
|
33
|
-
'bun.lock': PACKAGE_MANAGER.bun,
|
|
34
|
-
'bun.lockb': PACKAGE_MANAGER.bun,
|
|
35
|
-
'yarn.lock': PACKAGE_MANAGER.yarn,
|
|
36
|
-
'pnpm-lock.yaml': PACKAGE_MANAGER.pnpm,
|
|
37
|
-
'package-lock.json': PACKAGE_MANAGER.npm,
|
|
38
|
-
}
|
|
41
|
+
type DerivedCommands = Record<CommandKey | 'lintFiles', string>
|
|
39
42
|
|
|
40
|
-
/** The
|
|
41
|
-
|
|
43
|
+
/** The row of one stack. A command the stack has no tool for is absent from `commands`. */
|
|
44
|
+
export type StackProfile = {
|
|
45
|
+
/** In order of preference: Bun's text lock before its binary one. */
|
|
46
|
+
lockFiles: readonly string[]
|
|
47
|
+
/** The manifest in the repository root. */
|
|
48
|
+
rootMarker: string
|
|
49
|
+
/** Whether the stack installs dependencies, jackherd among them, into `node_modules` beside the config. */
|
|
50
|
+
installsIntoNodeModules: boolean
|
|
51
|
+
/** Files whose presence says the code is typed or statically analysed. */
|
|
52
|
+
typeMarkers: readonly string[]
|
|
53
|
+
testFilePattern: RegExp
|
|
54
|
+
/** The files a test of this stack covers: its code, never its documents or manifests. */
|
|
55
|
+
sourceFilePattern: RegExp
|
|
56
|
+
/** Globs the lint covers; none means every file. */
|
|
57
|
+
lintGlobs: readonly string[]
|
|
58
|
+
commands: (manifestPackages: readonly string[]) => Partial<DerivedCommands>
|
|
59
|
+
/** The words a command of this stack starts with. */
|
|
60
|
+
commandWords: readonly string[]
|
|
61
|
+
/** The directory whose binaries a command of this stack starts with, as `vendor/bin/`; absent for none. */
|
|
62
|
+
binaryDirectory?: string
|
|
63
|
+
/** The install command that stops at the lock file instead of changing it; absent where the plain install does. */
|
|
64
|
+
frozenInstall?: string
|
|
65
|
+
/** The package names a parsed root manifest declares; absent for a stack whose tools are not named by packages. */
|
|
66
|
+
manifestPackages?: (manifest: unknown) => string[]
|
|
67
|
+
}
|
|
42
68
|
|
|
43
|
-
|
|
44
|
-
export const DEFAULT_BASE_BRANCH = 'main'
|
|
69
|
+
const NODE_TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
|
|
45
70
|
|
|
46
|
-
const
|
|
71
|
+
const NODE_SOURCE_FILE = /\.[cm]?[jt]sx?$/
|
|
47
72
|
|
|
48
73
|
/**
|
|
49
74
|
* Bun fetches a tool it lacks, so its commands name the package; the others run the repository's own binaries.
|
|
50
75
|
* Biome runs as `bunx @biomejs/biome <command>`: its package's only binary, started without naming it twice.
|
|
51
76
|
*/
|
|
52
|
-
const
|
|
77
|
+
const NODE_COMMANDS = {
|
|
53
78
|
bun: {
|
|
54
79
|
test: 'bun test',
|
|
55
80
|
typecheck: 'bunx -p typescript tsc --noEmit',
|
|
@@ -86,16 +111,115 @@ const DERIVED_COMMANDS: Record<PackageManager, DerivedCommands> = {
|
|
|
86
111
|
dev: 'npm run dev',
|
|
87
112
|
lintFiles: 'npx @biomejs/biome check --no-errors-on-unmatched',
|
|
88
113
|
},
|
|
114
|
+
} satisfies Record<Exclude<Stack, 'composer'>, DerivedCommands>
|
|
115
|
+
|
|
116
|
+
const firstTool = (choice: ToolChoice, manifestPackages: readonly string[]): string | undefined =>
|
|
117
|
+
choice.find(([packageName]) => manifestPackages.includes(packageName))?.[1]
|
|
118
|
+
|
|
119
|
+
const COMPOSER_TOOLS = {
|
|
120
|
+
test: [
|
|
121
|
+
['pestphp/pest', 'vendor/bin/pest'],
|
|
122
|
+
['phpunit/phpunit', 'vendor/bin/phpunit'],
|
|
123
|
+
],
|
|
124
|
+
typecheck: [
|
|
125
|
+
['phpstan/phpstan', 'vendor/bin/phpstan analyse --no-progress'],
|
|
126
|
+
['vimeo/psalm', 'vendor/bin/psalm --no-progress'],
|
|
127
|
+
],
|
|
128
|
+
lint: [
|
|
129
|
+
['laravel/pint', 'vendor/bin/pint --test'],
|
|
130
|
+
['friendsofphp/php-cs-fixer', 'vendor/bin/php-cs-fixer check'],
|
|
131
|
+
],
|
|
132
|
+
format: [
|
|
133
|
+
['laravel/pint', 'vendor/bin/pint'],
|
|
134
|
+
['friendsofphp/php-cs-fixer', 'vendor/bin/php-cs-fixer fix'],
|
|
135
|
+
],
|
|
136
|
+
lintFiles: [
|
|
137
|
+
['laravel/pint', 'vendor/bin/pint --test'],
|
|
138
|
+
['friendsofphp/php-cs-fixer', 'vendor/bin/php-cs-fixer check --path-mode=intersection'],
|
|
139
|
+
],
|
|
140
|
+
} satisfies Partial<Record<keyof DerivedCommands, ToolChoice>>
|
|
141
|
+
|
|
142
|
+
const COMPOSER_MANIFEST_SECTIONS = ['require', 'require-dev'] as const
|
|
143
|
+
|
|
144
|
+
const packageNamesOf = (manifest: unknown, section: string): string[] => {
|
|
145
|
+
const packages =
|
|
146
|
+
typeof manifest === 'object' && manifest !== null ? (manifest as Record<string, unknown>)[section] : null
|
|
147
|
+
return typeof packages === 'object' && packages !== null && !Array.isArray(packages) ? Object.keys(packages) : []
|
|
89
148
|
}
|
|
90
149
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
150
|
+
const composerPackages = (manifest: unknown): string[] =>
|
|
151
|
+
COMPOSER_MANIFEST_SECTIONS.flatMap((section) => packageNamesOf(manifest, section))
|
|
152
|
+
|
|
153
|
+
const composerCommands = (manifestPackages: readonly string[]): Partial<DerivedCommands> => {
|
|
154
|
+
const tools = Object.fromEntries(
|
|
155
|
+
Object.entries(COMPOSER_TOOLS).map(([key, choice]) => [key, firstTool(choice, manifestPackages)]),
|
|
156
|
+
)
|
|
157
|
+
return Object.fromEntries(
|
|
158
|
+
Object.entries({ ...tools, install: 'composer install', dev: 'composer run dev' }).filter(
|
|
159
|
+
([, command]) => command !== undefined,
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const nodeProfile = (
|
|
165
|
+
lockFiles: readonly string[],
|
|
166
|
+
commandWords: readonly string[],
|
|
167
|
+
commands: DerivedCommands,
|
|
168
|
+
frozenInstall: string,
|
|
169
|
+
): StackProfile => ({
|
|
170
|
+
lockFiles,
|
|
171
|
+
commandWords,
|
|
172
|
+
frozenInstall,
|
|
173
|
+
rootMarker: 'package.json',
|
|
174
|
+
installsIntoNodeModules: true,
|
|
175
|
+
typeMarkers: ['tsconfig.json'],
|
|
176
|
+
testFilePattern: NODE_TEST_FILE,
|
|
177
|
+
sourceFilePattern: NODE_SOURCE_FILE,
|
|
178
|
+
lintGlobs: [],
|
|
179
|
+
commands: () => commands,
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
/** One row per stack, in the order detection reports them. */
|
|
183
|
+
export const STACKS: Record<Stack, StackProfile> = {
|
|
184
|
+
bun: nodeProfile(['bun.lock', 'bun.lockb'], ['bun', 'bunx'], NODE_COMMANDS.bun, 'bun install --frozen-lockfile'),
|
|
185
|
+
yarn: nodeProfile(['yarn.lock'], ['yarn'], NODE_COMMANDS.yarn, 'yarn install --immutable'),
|
|
186
|
+
pnpm: nodeProfile(['pnpm-lock.yaml'], ['pnpm'], NODE_COMMANDS.pnpm, 'pnpm install --frozen-lockfile'),
|
|
187
|
+
npm: nodeProfile(['package-lock.json'], ['npm', 'npx'], NODE_COMMANDS.npm, 'npm ci'),
|
|
188
|
+
composer: {
|
|
189
|
+
lockFiles: ['composer.lock'],
|
|
190
|
+
commandWords: ['composer'],
|
|
191
|
+
binaryDirectory: 'vendor/bin/',
|
|
192
|
+
rootMarker: 'composer.json',
|
|
193
|
+
installsIntoNodeModules: false,
|
|
194
|
+
typeMarkers: ['phpstan.neon', 'phpstan.neon.dist', 'phpstan.dist.neon', 'psalm.xml'],
|
|
195
|
+
testFilePattern: /Test\.php$/,
|
|
196
|
+
sourceFilePattern: /\.php$/,
|
|
197
|
+
lintGlobs: ['**/*.php'],
|
|
198
|
+
commands: composerCommands,
|
|
199
|
+
manifestPackages: composerPackages,
|
|
200
|
+
},
|
|
97
201
|
}
|
|
98
202
|
|
|
203
|
+
const STACK_NAMES = Object.keys(STACKS) as Stack[]
|
|
204
|
+
|
|
205
|
+
/** The stack whose tool a command starts with (`bunx`, `composer`, `vendor/bin/pest`); undefined for a command of the project's own. */
|
|
206
|
+
export const stackOfCommand = (command: string): Stack | undefined => {
|
|
207
|
+
const [word = ''] = command.trim().split(/\s+/)
|
|
208
|
+
return STACK_NAMES.find(
|
|
209
|
+
(stack) =>
|
|
210
|
+
STACKS[stack].commandWords.includes(word) ||
|
|
211
|
+
(STACKS[stack].binaryDirectory !== undefined && word.startsWith(STACKS[stack].binaryDirectory)),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The install command of each manager that stops at the lock file instead of changing it; Composer's already does. */
|
|
216
|
+
const FROZEN_INSTALL: Record<string, string> = Object.fromEntries(
|
|
217
|
+
Object.values(STACKS).flatMap(({ commands, frozenInstall }) => {
|
|
218
|
+
const install = commands([]).install
|
|
219
|
+
return install === undefined || frozenInstall === undefined ? [] : [[install, frozenInstall]]
|
|
220
|
+
}),
|
|
221
|
+
)
|
|
222
|
+
|
|
99
223
|
/**
|
|
100
224
|
* The check that a worktree's dependencies are all there: the manager's install with the lock file frozen, fast when
|
|
101
225
|
* nothing is missing, fetching what is, failing when the lock file and `package.json` disagree. An install command the
|
|
@@ -103,35 +227,51 @@ const FROZEN_INSTALL: Record<string, string> = {
|
|
|
103
227
|
*/
|
|
104
228
|
export const frozenInstallCommand = (install: string): string => FROZEN_INSTALL[install] ?? install
|
|
105
229
|
|
|
106
|
-
/** The bun defaults of the schema, so a repository Bun runs and one detection skips agree. */
|
|
107
|
-
export const BUN_COMMANDS = DERIVED_COMMANDS.bun
|
|
108
|
-
|
|
109
|
-
type ConfigObject = Record<string, unknown>
|
|
110
|
-
|
|
111
|
-
/** The blocks of a config that detection fills. */
|
|
112
|
-
type ConfigParts = { commands: ConfigObject; lanes: ConfigObject; check: ConfigObject | undefined }
|
|
113
|
-
|
|
114
230
|
const isObject = (value: unknown): value is ConfigObject =>
|
|
115
231
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
116
232
|
|
|
117
233
|
const has = (object: ConfigObject, key: string): boolean => Object.hasOwn(object, key)
|
|
118
234
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
235
|
+
/** Without a lock file, the stack whose manifest is the only one there and is no other stack's; none where managers share it. */
|
|
236
|
+
const detectByManifest = (manifests: readonly string[]): StackDetection => {
|
|
237
|
+
const stacks = STACK_NAMES.filter((stack) => manifests.includes(STACKS[stack].rootMarker))
|
|
238
|
+
return stacks.length === 1
|
|
239
|
+
? { kind: 'manifest', stack: stacks[0], manifest: STACKS[stacks[0]].rootMarker }
|
|
240
|
+
: { kind: 'none' }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The stack by its lock files; only without any lock file by its manifest, which never names a manager it shares. */
|
|
244
|
+
export const detectStack = (lockFiles: readonly string[], manifests: readonly string[]): StackDetection => {
|
|
245
|
+
const known = STACK_NAMES.flatMap((stack) => STACKS[stack].lockFiles.filter((file) => lockFiles.includes(file)))
|
|
246
|
+
const stacks = new Set(STACK_NAMES.filter((stack) => STACKS[stack].lockFiles.some((file) => known.includes(file))))
|
|
247
|
+
if (stacks.size === 0) return detectByManifest(manifests)
|
|
248
|
+
if (stacks.size > 1) return { kind: 'ambiguous', lockFiles: known }
|
|
249
|
+
return { kind: 'found', stack: [...stacks][0], lockFile: known[0] }
|
|
125
250
|
}
|
|
126
251
|
|
|
252
|
+
/** The base branches detection chooses between; any other base is set in the config. */
|
|
253
|
+
const BASE_BRANCH_CANDIDATES = ['main', 'master'] as const
|
|
254
|
+
|
|
127
255
|
/** `main` or `master` when origin has exactly one of them; `origin/HEAD` is not asked, since it can point at a branch long deleted. */
|
|
128
256
|
export const detectBaseBranch = (remoteBranches: readonly string[]): string | null => {
|
|
129
257
|
const present = BASE_BRANCH_CANDIDATES.filter((branch) => remoteBranches.includes(branch))
|
|
130
258
|
return present.length === 1 ? present[0] : null
|
|
131
259
|
}
|
|
132
260
|
|
|
261
|
+
const UNSET_VALUES = `\`commands\`, \`lanes.lockFile\` and \`check.lintFilesCommand\` in ${PROJECT_CONFIG_PATH}`
|
|
262
|
+
|
|
133
263
|
const ambiguousNote = (lockFiles: readonly string[]): string =>
|
|
134
|
-
`${CLI_NAME}: lock files of several
|
|
264
|
+
`${CLI_NAME}: lock files of several stacks (${lockFiles.join(', ')}). Set ${UNSET_VALUES}.`
|
|
265
|
+
|
|
266
|
+
/** What manifests without a lock file fail to name: one shared by several managers, or several stacks at once. */
|
|
267
|
+
const unnamedBy = (manifests: readonly string[]): string =>
|
|
268
|
+
manifests.length === 1 ? 'package manager' : 'single stack'
|
|
269
|
+
|
|
270
|
+
/** Without a stack: either nothing known is there, or only manifests that name no single stack. */
|
|
271
|
+
const unknownStackNote = (manifests: readonly string[]): string =>
|
|
272
|
+
manifests.length === 0
|
|
273
|
+
? `${CLI_NAME}: no known stack (lock file) in this repository; set ${UNSET_VALUES}.`
|
|
274
|
+
: `${CLI_NAME}: ${manifests.join(', ')} without a lock file names no ${unnamedBy(manifests)}; set ${UNSET_VALUES}.`
|
|
135
275
|
|
|
136
276
|
const baseBranchNote = (remoteBranches: readonly string[]): string => {
|
|
137
277
|
const reason = BASE_BRANCH_CANDIDATES.every((branch) => remoteBranches.includes(branch))
|
|
@@ -140,40 +280,67 @@ const baseBranchNote = (remoteBranches: readonly string[]): string => {
|
|
|
140
280
|
return `${CLI_NAME}: ${reason}; using ${DEFAULT_BASE_BRANCH}. Set \`baseBranch\` in ${PROJECT_CONFIG_PATH}.`
|
|
141
281
|
}
|
|
142
282
|
|
|
143
|
-
|
|
283
|
+
/** What the stack of a repository sets for the values a config leaves out; empty where no stack is found. */
|
|
284
|
+
export type DetectedValues = {
|
|
285
|
+
commands: Partial<Record<CommandKey, string>>
|
|
286
|
+
lanes: { lockFile?: string }
|
|
287
|
+
check: { lintFilesCommand?: string }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A stack found by its manifest alone sets no `lanes.lockFile`: the lock file does not exist yet. */
|
|
291
|
+
const detectedValuesOf = (detection: StackDetection, manifestPackages: readonly string[]): DetectedValues => {
|
|
292
|
+
if (!isDecided(detection)) return { commands: {}, lanes: {}, check: {} }
|
|
293
|
+
const { lintFiles, ...derived } = STACKS[detection.stack].commands(manifestPackages)
|
|
294
|
+
const commands = Object.fromEntries(
|
|
295
|
+
COMMAND_KEYS.flatMap((key) => (derived[key] === undefined ? [] : [[key, derived[key]]])),
|
|
296
|
+
)
|
|
297
|
+
return {
|
|
298
|
+
commands,
|
|
299
|
+
lanes: detection.kind === 'found' ? { lockFile: detection.lockFile } : {},
|
|
300
|
+
check: lintFiles === undefined ? {} : { lintFilesCommand: lintFiles },
|
|
301
|
+
}
|
|
302
|
+
}
|
|
144
303
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
304
|
+
/** The values the repository's stack (from its lock file or manifest) would set in a config. */
|
|
305
|
+
export const detectedValues = (facts: RepositoryFacts): DetectedValues =>
|
|
306
|
+
detectedValuesOf(detectStack(facts.lockFiles, facts.manifests), facts.manifestPackages)
|
|
307
|
+
|
|
308
|
+
const missingCommandKeys = (commands: ConfigObject): CommandKey[] => COMMAND_KEYS.filter((key) => !has(commands, key))
|
|
309
|
+
|
|
310
|
+
const withStack = (raw: ConfigObject, parts: ConfigParts, detected: DetectedValues): ConfigObject => {
|
|
151
311
|
const commands = Object.fromEntries(
|
|
152
|
-
missingCommandKeys(parts.commands).
|
|
312
|
+
missingCommandKeys(parts.commands).flatMap((key) => {
|
|
313
|
+
const command = detected.commands[key]
|
|
314
|
+
return command === undefined ? [] : [[key, command]]
|
|
315
|
+
}),
|
|
153
316
|
)
|
|
154
|
-
const check = parts.check ?? {}
|
|
155
317
|
return {
|
|
156
318
|
...raw,
|
|
157
319
|
commands: { ...commands, ...parts.commands },
|
|
158
|
-
lanes: {
|
|
159
|
-
check: {
|
|
320
|
+
lanes: { ...detected.lanes, ...parts.lanes },
|
|
321
|
+
check: { ...detected.check, ...parts.check },
|
|
160
322
|
}
|
|
161
323
|
}
|
|
162
324
|
|
|
163
|
-
const
|
|
325
|
+
const needsStack = (parts: ConfigParts) =>
|
|
164
326
|
missingCommandKeys(parts.commands).length > 0 ||
|
|
165
327
|
!has(parts.lanes, 'lockFile') ||
|
|
166
328
|
!(parts.check && has(parts.check, 'lintFilesCommand'))
|
|
167
329
|
|
|
168
|
-
const
|
|
330
|
+
const fillStack = (
|
|
169
331
|
raw: ConfigObject,
|
|
170
332
|
parts: ConfigParts,
|
|
171
|
-
|
|
333
|
+
facts: RepositoryFacts,
|
|
172
334
|
): { raw: ConfigObject; notes: string[] } => {
|
|
173
|
-
if (!
|
|
174
|
-
const detection =
|
|
175
|
-
if (detection
|
|
176
|
-
|
|
335
|
+
if (!needsStack(parts)) return { raw, notes: [] }
|
|
336
|
+
const detection = detectStack(facts.lockFiles, facts.manifests)
|
|
337
|
+
if (isDecided(detection)) {
|
|
338
|
+
return { raw: withStack(raw, parts, detectedValuesOf(detection, facts.manifestPackages)), notes: [] }
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
raw,
|
|
342
|
+
notes: [detection.kind === 'ambiguous' ? ambiguousNote(detection.lockFiles) : unknownStackNote(facts.manifests)],
|
|
343
|
+
}
|
|
177
344
|
}
|
|
178
345
|
|
|
179
346
|
const fillBaseBranch = (
|
|
@@ -196,16 +363,17 @@ const configParts = (raw: ConfigObject): ConfigParts | null => {
|
|
|
196
363
|
|
|
197
364
|
/**
|
|
198
365
|
* The raw config with every value it leaves out of `commands`, `lanes.lockFile`, `check.lintFilesCommand` and
|
|
199
|
-
* `baseBranch` filled from the repository: the
|
|
200
|
-
* A value the config sets always wins
|
|
201
|
-
*
|
|
202
|
-
*
|
|
366
|
+
* `baseBranch` filled from the repository: the stack from the lock file (without one, from a manifest only one stack
|
|
367
|
+
* has), the base branch from origin. A value the config sets always wins, and a command the stack has no tool for
|
|
368
|
+
* stays out. Where the repository does not decide (no lock file and no such manifest, lock files of several stacks,
|
|
369
|
+
* both or neither of `main` and `master`) nothing is guessed and a note
|
|
370
|
+
* says so. A config of the wrong shape is passed through untouched for the schema to refuse.
|
|
203
371
|
*/
|
|
204
372
|
export const withDetected = (raw: unknown, facts: RepositoryFacts): { raw: unknown; notes: string[] } => {
|
|
205
373
|
if (!isObject(raw)) return { raw, notes: [] }
|
|
206
374
|
const parts = configParts(raw)
|
|
207
375
|
if (!parts) return { raw, notes: [] }
|
|
208
|
-
const
|
|
209
|
-
const base = fillBaseBranch(
|
|
210
|
-
return { raw: base.raw, notes: [...
|
|
376
|
+
const stack = fillStack(raw, parts, facts)
|
|
377
|
+
const base = fillBaseBranch(stack.raw, facts.remoteBranches)
|
|
378
|
+
return { raw: base.raw, notes: [...stack.notes, ...base.notes] }
|
|
211
379
|
}
|
package/src/config/location.ts
CHANGED
|
@@ -12,6 +12,12 @@ export const CONFIG_SCHEMA_REFERENCE = `../node_modules/${PACKAGE_NAME}/schemas/
|
|
|
12
12
|
/** The project's config, relative to its root; the same path below the home directory is the global config. */
|
|
13
13
|
export const PROJECT_CONFIG_PATH = `${TOOL_DIRECTORY}/${CONFIG_FILE}`
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* What stands in for a value the project config leaves unset, such as `commands.test`: where to set it. Plain text
|
|
17
|
+
* without backticks, since a kit text puts the value inside a code span.
|
|
18
|
+
*/
|
|
19
|
+
export const unsetValueText = (key: string): string => `not set: ${key} in ${PROJECT_CONFIG_PATH}`
|
|
20
|
+
|
|
15
21
|
/** Where the config lay before 0.29, read until 2.0.0 when the new one is missing; never written. */
|
|
16
22
|
export const LEGACY_CONFIG_PATH = '.claude/agent-kit.json'
|
|
17
23
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
|
-
import { existsSync } from 'node:fs'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import {
|
|
4
|
+
import { detectStack, isDecided, type RepositoryFacts, STACKS } from './detect'
|
|
5
5
|
|
|
6
6
|
const REMOTE_BRANCHES = ['for-each-ref', '--format=%(refname:lstrip=3)', 'refs/remotes/origin/']
|
|
7
7
|
|
|
@@ -12,8 +12,33 @@ const remoteBranchesOf = (root: string): string[] => {
|
|
|
12
12
|
return result.stdout.split('\n').filter((branch) => branch !== '' && branch !== 'HEAD')
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
const readJson = (path: string): unknown => {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Package names the root manifest of the detected stack declares; none without one stack, or for a missing or broken manifest. */
|
|
24
|
+
const manifestPackagesOf = (root: string, lockFiles: readonly string[], manifests: readonly string[]): string[] => {
|
|
25
|
+
const detection = detectStack(lockFiles, manifests)
|
|
26
|
+
if (!isDecided(detection)) return []
|
|
27
|
+
const { rootMarker, manifestPackages } = STACKS[detection.stack]
|
|
28
|
+
return manifestPackages ? manifestPackages(readJson(join(root, rootMarker))) : []
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The lock files, manifests, manifest packages and origin branches of the repository at `root`; reads only, never fetches. */
|
|
32
|
+
export const readRepositoryFacts = (root: string): RepositoryFacts => {
|
|
33
|
+
const present = (file: string) => existsSync(join(root, file))
|
|
34
|
+
const lockFiles = Object.values(STACKS)
|
|
35
|
+
.flatMap((stack) => stack.lockFiles)
|
|
36
|
+
.filter(present)
|
|
37
|
+
const manifests = [...new Set(Object.values(STACKS).map((stack) => stack.rootMarker))].filter(present)
|
|
38
|
+
return {
|
|
39
|
+
lockFiles,
|
|
40
|
+
manifests,
|
|
41
|
+
manifestPackages: manifestPackagesOf(root, lockFiles, manifests),
|
|
42
|
+
remoteBranches: remoteBranchesOf(root),
|
|
43
|
+
}
|
|
44
|
+
}
|