@alecsibilia/luca 14.0.0-alpha.23 → 14.0.0-alpha.25
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 +8 -0
- package/board/package.json +1 -1
- package/board/server/luca-version.ts +1 -1
- package/engine/agents/claude-options.ts +5 -1
- package/engine/agents/common-instructions.ts +5 -3
- package/engine/agents/launch-paths.ts +41 -1
- package/engine/agents/launch-session.ts +10 -1
- package/engine/agents/writer-instructions.ts +15 -2
- package/engine/cli/setup-config.ts +215 -0
- package/engine/cli/setup-junit-check.ts +134 -0
- package/engine/cli/setup-repo-checks.ts +263 -0
- package/engine/cli/setup.ts +20 -437
- package/engine/config/engine-config-schema.ts +57 -24
- package/engine/config/engine-config.ts +4 -1
- package/engine/config/test-commands.ts +67 -15
- package/engine/core/execute-action.ts +1 -0
- package/engine/core/execute-already-done-check.ts +55 -12
- package/engine/core/execute-build.ts +9 -4
- package/engine/core/execute-intake.ts +35 -0
- package/engine/core/execute-red-check.ts +23 -6
- package/engine/gates/case-test-runner.ts +70 -0
- package/engine/gates/gate-runner.ts +13 -3
- package/engine/gates/gate-schemas.ts +5 -0
- package/engine/gates/junit-runner.ts +152 -0
- package/engine/gates/new-tests.ts +22 -3
- package/engine/gates/own-files-check.ts +13 -15
- package/engine/gates/red-check.ts +86 -7
- package/engine/gates/slow-test-runner.ts +2 -0
- package/engine/gates/swift-test-index.ts +121 -0
- package/engine/gates/swift-test-names.ts +128 -0
- package/engine/gates/test-checks.ts +36 -15
- package/engine/gates/test-runner.ts +69 -64
- package/engine/git/git-adapter.ts +3 -0
- package/engine/git/git-read.ts +15 -0
- package/engine/guards/permission-rules.ts +5 -3
- package/engine/guards/sandbox-settings.ts +22 -2
- package/engine/guards/shell-rules.ts +3 -2
- package/engine/index.ts +3 -7
- package/engine/intake/intake-checks.ts +12 -5
- package/engine/intake/intake-schemas.ts +10 -1
- package/engine/testing/swift-junit-fixtures.ts +93 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/board/package.json
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The Luca version of this board, stamped when the package was packed. */
|
|
2
|
-
export const LUCA_VERSION: string = "14.0.0-alpha.
|
|
2
|
+
export const LUCA_VERSION: string = "14.0.0-alpha.25"
|
|
@@ -173,7 +173,7 @@ export const resultJsonSchema = ({
|
|
|
173
173
|
* hooks.
|
|
174
174
|
*
|
|
175
175
|
* @example
|
|
176
|
-
* const options = agentOptions({ role, may_edit_tests, cwd, model: CLAUDE_MODEL, effort: 'xhigh', claude_path, config, common_git_dir, home, env, guard_hook, luca_server: null, delivery_hook: null })
|
|
176
|
+
* const options = agentOptions({ role, may_edit_tests, cwd, model: CLAUDE_MODEL, effort: 'xhigh', claude_path, config, common_git_dir, home, user_temp_dir, env, guard_hook, luca_server: null, delivery_hook: null })
|
|
177
177
|
*/
|
|
178
178
|
export const agentOptions = ({
|
|
179
179
|
role,
|
|
@@ -185,6 +185,7 @@ export const agentOptions = ({
|
|
|
185
185
|
config,
|
|
186
186
|
common_git_dir,
|
|
187
187
|
home,
|
|
188
|
+
user_temp_dir,
|
|
188
189
|
env,
|
|
189
190
|
guard_hook,
|
|
190
191
|
luca_server,
|
|
@@ -203,6 +204,8 @@ export const agentOptions = ({
|
|
|
203
204
|
config: EngineConfig
|
|
204
205
|
common_git_dir: string
|
|
205
206
|
home: string
|
|
207
|
+
/** macOS's per-user temp folder (`darwinUserTempDir`), or `null`. */
|
|
208
|
+
user_temp_dir: string | null
|
|
206
209
|
env: Record<string, string>
|
|
207
210
|
guard_hook: HookCallback
|
|
208
211
|
/** The engine's `luca` server (`createLucaServer`), or `null` for none. */
|
|
@@ -253,6 +256,7 @@ export const agentOptions = ({
|
|
|
253
256
|
worktree: cwd,
|
|
254
257
|
common_git_dir,
|
|
255
258
|
home,
|
|
259
|
+
user_temp_dir,
|
|
256
260
|
config,
|
|
257
261
|
}),
|
|
258
262
|
env,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { AgentRole } from './role-results'
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { givesPerTestResults, type EngineConfig } from '../config/engine-config'
|
|
9
9
|
import {
|
|
10
10
|
HELPER_MODEL,
|
|
11
11
|
HELPER_TOOLS,
|
|
@@ -30,9 +30,11 @@ export const shellRules = ({
|
|
|
30
30
|
const guard = guardRoleOf({ role })
|
|
31
31
|
const { tests, others } = checkCommands({ role: guard, config })
|
|
32
32
|
const commands = [
|
|
33
|
+
// The guard lets args follow a test command just when it gives
|
|
34
|
+
// per-test results; only `bun test` takes test files among them.
|
|
33
35
|
...tests.map(({ run, results }) =>
|
|
34
|
-
results
|
|
35
|
-
? `\`${run}\` (you may add test files or options after it)`
|
|
36
|
+
givesPerTestResults(results)
|
|
37
|
+
? `\`${run}\` (you may add ${results === 'bun' ? 'test files or options' : 'options'} after it)`
|
|
36
38
|
: `\`${run}\``
|
|
37
39
|
),
|
|
38
40
|
...others.map((command) => `\`${command}\``),
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Where the Claude launcher finds things on disk: the real path of the
|
|
3
|
-
* `claude` binary,
|
|
3
|
+
* `claude` binary, the repo's common git dir (shared by its worktrees), and
|
|
4
|
+
* macOS's per-user temp folder.
|
|
4
5
|
*/
|
|
5
6
|
import { realpathSync } from 'node:fs'
|
|
7
|
+
import { isAbsolute } from 'node:path'
|
|
6
8
|
|
|
7
9
|
import { runCommand } from '../shell/run-command'
|
|
8
10
|
|
|
@@ -27,3 +29,41 @@ export const commonGitDir = async ({
|
|
|
27
29
|
}
|
|
28
30
|
return realpathSync(result.stdout.trim())
|
|
29
31
|
}
|
|
32
|
+
|
|
33
|
+
/** The command that prints macOS's per-user temp folder. */
|
|
34
|
+
export const GETCONF_USER_TEMP_DIR = ['getconf', 'DARWIN_USER_TEMP_DIR']
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The real path of macOS's per-user temp folder (`getconf
|
|
38
|
+
* DARWIN_USER_TEMP_DIR`, such as `/private/var/folders/ab/cd1234/T`), or
|
|
39
|
+
* `null` off macOS, or when `getconf` fails or names no folder there.
|
|
40
|
+
*
|
|
41
|
+
* A sandboxed writer needs it: Foundation stages every atomic write there,
|
|
42
|
+
* not in `$TMPDIR`, so `swift test` and `xcodebuild` can't rebuild without
|
|
43
|
+
* it ("unable to write manifest ... Operation not permitted").
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* const user_temp_dir = await darwinUserTempDir({ platform: process.platform, getconf: GETCONF_USER_TEMP_DIR })
|
|
47
|
+
*/
|
|
48
|
+
export const darwinUserTempDir = async ({
|
|
49
|
+
platform,
|
|
50
|
+
getconf,
|
|
51
|
+
}: {
|
|
52
|
+
platform: NodeJS.Platform
|
|
53
|
+
/** The command to ask; the launcher passes `GETCONF_USER_TEMP_DIR`. */
|
|
54
|
+
getconf: string[]
|
|
55
|
+
}): Promise<string | null> => {
|
|
56
|
+
if (platform !== 'darwin') return null
|
|
57
|
+
try {
|
|
58
|
+
const result = await runCommand({
|
|
59
|
+
cmd: getconf,
|
|
60
|
+
cwd: '/',
|
|
61
|
+
timeout_ms: 10_000,
|
|
62
|
+
})
|
|
63
|
+
const path = result.stdout.trim()
|
|
64
|
+
if (result.exit_code !== 0 || !isAbsolute(path)) return null
|
|
65
|
+
return realpathSync(path)
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -25,7 +25,12 @@ import {
|
|
|
25
25
|
} from './claude-messages'
|
|
26
26
|
import { agentEnv, agentOptions, checkLaunchModel } from './claude-options'
|
|
27
27
|
import { createInputChannel } from './input-channel'
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
commonGitDir,
|
|
30
|
+
darwinUserTempDir,
|
|
31
|
+
GETCONF_USER_TEMP_DIR,
|
|
32
|
+
realClaudePath,
|
|
33
|
+
} from './launch-paths'
|
|
29
34
|
import { createDeliveryHook, createLucaServer } from './message-tool'
|
|
30
35
|
import {
|
|
31
36
|
nextOutcome,
|
|
@@ -116,6 +121,10 @@ export const createLaunch = ({
|
|
|
116
121
|
config,
|
|
117
122
|
common_git_dir: gitDir,
|
|
118
123
|
home: realpathSync(homedir()),
|
|
124
|
+
user_temp_dir: await darwinUserTempDir({
|
|
125
|
+
platform: process.platform,
|
|
126
|
+
getconf: GETCONF_USER_TEMP_DIR,
|
|
127
|
+
}),
|
|
119
128
|
env: agentEnv({ source: process.env }),
|
|
120
129
|
guard_hook: createGuardHook({
|
|
121
130
|
role: guardRoleOf({ role }),
|
|
@@ -7,6 +7,7 @@ import { engineChores, REVIEW_FIXER, setupFiles } from './common-instructions'
|
|
|
7
7
|
import {
|
|
8
8
|
caseLimitOf,
|
|
9
9
|
featureMapPath,
|
|
10
|
+
hasJunitCommand,
|
|
10
11
|
isFeatureMapFolder,
|
|
11
12
|
slowTestCommands,
|
|
12
13
|
testCommands,
|
|
@@ -58,6 +59,18 @@ const testHelperClause = ({ config }: { config: EngineConfig }): string => {
|
|
|
58
59
|
: `, test helpers only tests use, such as fakes and fixtures (${patterns.join(', ')})`
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/** How to write a Swift test the engine can count (#672), or nothing without a junit command. */
|
|
63
|
+
const swiftTestLine = ({ config }: { config: EngineConfig }): string =>
|
|
64
|
+
hasJunitCommand({ config })
|
|
65
|
+
? "- In a .swift test file, write each test as a Swift Testing `@Test func` or an XCTest `func testSomething()`, and call `#expect`, `#require` or an `XCTAssert` function directly in its body: the engine counts them in the test's source, not as it runs.\n"
|
|
66
|
+
: ''
|
|
67
|
+
|
|
68
|
+
/** A Swift test's full name (#672), or nothing without a junit command. */
|
|
69
|
+
const swiftNameLine = ({ config }: { config: EngineConfig }): string =>
|
|
70
|
+
hasJunitCommand({ config })
|
|
71
|
+
? '\n- For a .swift test, "name" is its type names and its function, joined by " > ": `RankingTests > addsMovie()` for a Swift Testing test (keeping argument labels, such as `ranks(score:)`), and `RankingXCTests > testAddsMovie` for an XCTest one. A @Test\'s display name is not part of it.'
|
|
72
|
+
: ''
|
|
73
|
+
|
|
61
74
|
export const TEST_WRITER = ({
|
|
62
75
|
config,
|
|
63
76
|
}: {
|
|
@@ -72,11 +85,11 @@ You write the failing tests for ONE ticket, before any code exists.
|
|
|
72
85
|
${featureMapLines({ config })}- Every acceptance criterion needs at least one test, and at most ${MAX_TESTS_PER_CRITERION}: write the fewest tests that prove it. Old tests must keep passing.
|
|
73
86
|
- Map every test you add. The red check refuses a new test your criterion mapping doesn't name.
|
|
74
87
|
- Give every test a plain string-literal name (no test.each, no template names), so the engine can find each one before the module exists.
|
|
75
|
-
|
|
88
|
+
${swiftTestLine({ config })}
|
|
76
89
|
Your result (structured output):
|
|
77
90
|
- outcome: "tests_written", or "nothing_new_to_test" if the ticket truly changes no behavior (a refactor), or "already_done" if the ticket's work is already on the base branch, or "needs_setup_change" if the tests can't work without a change to a test setup file${setupFiles({ config })} (you may never change one): say which file and why in "setup_change", and the user makes the change.
|
|
78
91
|
- Answer "already_done" only when EVERY acceptance criterion is already met on the base branch and already has a test that checks it. Look with \`git log\` and \`git show\`, and read the code and tests. Then write no tests, and give your evidence: in "done_by", the commits that did the work, as { sha, title }; in "criteria", the existing tests that cover each criterion. The engine checks this evidence: each commit must be on the base branch, and each test must exist and pass there. If even one criterion is not met, or not tested, write the tests instead.
|
|
79
|
-
- criteria: for EACH criterion id (AC1, AC2, ...), the tests that check it, as { file, name }. "name" is the full name bun prints: describe names and the test name joined by " > "
|
|
92
|
+
- criteria: for EACH criterion id (AC1, AC2, ...), the tests that check it, as { file, name }. "name" is the full name bun prints: describe names and the test name joined by " > ".${swiftNameLine({ config })}
|
|
80
93
|
${environmentLine({ check: 'the red check' })}
|
|
81
94
|
- done_by: empty, unless the outcome is "already_done".
|
|
82
95
|
- finding_responses: empty, unless your prompt gives you ticket review findings (see below).
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `luca setup`'s config side: guessing the checks from `package.json`,
|
|
3
|
+
* finding what is at `.luca/config.json`, writing a starting one, and the
|
|
4
|
+
* done and to-do lines for a config that loads.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdir } from 'node:fs/promises'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
ENGINE_CONFIG_FILE,
|
|
13
|
+
EngineConfigSchema,
|
|
14
|
+
givesPerTestResults,
|
|
15
|
+
isBunTestCommand,
|
|
16
|
+
MuninnConfigSchema,
|
|
17
|
+
testCommands,
|
|
18
|
+
type EngineConfig,
|
|
19
|
+
type TestCommand,
|
|
20
|
+
} from '../config/engine-config'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The top-level and `muninn` keys of a new-style engine config, from its
|
|
24
|
+
* schema. A config with any other key (such as old Luca's `lucaVersion`) is
|
|
25
|
+
* an old-Luca config.
|
|
26
|
+
*/
|
|
27
|
+
const NEW_CONFIG_KEYS = new Set(Object.keys(EngineConfigSchema.shape))
|
|
28
|
+
const NEW_MUNINN_KEYS = new Set(Object.keys(MuninnConfigSchema.shape))
|
|
29
|
+
|
|
30
|
+
const PackageJsonSchema = z.looseObject({
|
|
31
|
+
scripts: z.record(z.string(), z.string()).default({}),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const errorText = (error: unknown): string =>
|
|
35
|
+
error instanceof Error ? error.message : String(error)
|
|
36
|
+
|
|
37
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
38
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The checks guessed from `package.json` scripts: `test` and `test:*`
|
|
42
|
+
* become test commands (a script that is a `bun test` command runs as
|
|
43
|
+
* written and is bun-readable; the rest run with `bun run` and are
|
|
44
|
+
* pass-or-fail), `type-check` or `typecheck` becomes types, and `lint`
|
|
45
|
+
* becomes lint. Pure.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* guessChecks({ scripts: { test: 'bun test', 'test:workers': 'vitest run', lint: 'eslint .' } })
|
|
49
|
+
* // { test: [{ run: 'bun test', results: 'bun' }, { run: 'bun run test:workers', results: 'pass_fail' }], lint: 'bun run lint' }
|
|
50
|
+
*/
|
|
51
|
+
export const guessChecks = ({
|
|
52
|
+
scripts,
|
|
53
|
+
}: {
|
|
54
|
+
scripts: Record<string, string>
|
|
55
|
+
}): EngineConfig['checks'] => {
|
|
56
|
+
const test: Pick<TestCommand, 'run' | 'results'>[] = Object.entries(scripts)
|
|
57
|
+
.filter(([name]) => name === 'test' || name.startsWith('test:'))
|
|
58
|
+
.toSorted(([a], [b]) => Number(b === 'test') - Number(a === 'test'))
|
|
59
|
+
.map(([name, script]) =>
|
|
60
|
+
isBunTestCommand(script)
|
|
61
|
+
? { run: script.trim(), results: 'bun' }
|
|
62
|
+
: { run: `bun run ${name}`, results: 'pass_fail' }
|
|
63
|
+
)
|
|
64
|
+
const types = ['type-check', 'typecheck'].find(
|
|
65
|
+
(name) => scripts[name] !== undefined
|
|
66
|
+
)
|
|
67
|
+
return {
|
|
68
|
+
...(test.length === 0 ? {} : { test }),
|
|
69
|
+
...(types === undefined ? {} : { types: `bun run ${types}` }),
|
|
70
|
+
...(scripts.lint === undefined ? {} : { lint: 'bun run lint' }),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The repo's `package.json` scripts; none when it has no readable one. */
|
|
75
|
+
export const readScripts = async ({
|
|
76
|
+
repo,
|
|
77
|
+
}: {
|
|
78
|
+
repo: string
|
|
79
|
+
}): Promise<Record<string, string>> => {
|
|
80
|
+
const file = Bun.file(join(repo, 'package.json'))
|
|
81
|
+
if (!(await file.exists())) return {}
|
|
82
|
+
try {
|
|
83
|
+
const parsed = PackageJsonSchema.safeParse(
|
|
84
|
+
JSON.parse(await file.text())
|
|
85
|
+
)
|
|
86
|
+
return parsed.success ? parsed.data.scripts : {}
|
|
87
|
+
} catch {
|
|
88
|
+
return {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What is at `.luca/config.json` before setup touches it. */
|
|
93
|
+
export type FoundConfig =
|
|
94
|
+
| { kind: 'missing' }
|
|
95
|
+
| { kind: 'broken'; error: string }
|
|
96
|
+
| { kind: 'old'; raw: Record<string, unknown> }
|
|
97
|
+
| { kind: 'new' }
|
|
98
|
+
|
|
99
|
+
export const findConfig = async ({
|
|
100
|
+
repo,
|
|
101
|
+
}: {
|
|
102
|
+
repo: string
|
|
103
|
+
}): Promise<FoundConfig> => {
|
|
104
|
+
const file = Bun.file(join(repo, ENGINE_CONFIG_FILE))
|
|
105
|
+
if (!(await file.exists())) return { kind: 'missing' }
|
|
106
|
+
let raw: unknown
|
|
107
|
+
try {
|
|
108
|
+
raw = JSON.parse(await file.text())
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return { kind: 'broken', error: `it isn't JSON: ${errorText(error)}` }
|
|
111
|
+
}
|
|
112
|
+
if (!isRecord(raw)) {
|
|
113
|
+
return { kind: 'broken', error: "it isn't a JSON object" }
|
|
114
|
+
}
|
|
115
|
+
const muninn = raw.muninn
|
|
116
|
+
const old =
|
|
117
|
+
Object.keys(raw).some((key) => !NEW_CONFIG_KEYS.has(key)) ||
|
|
118
|
+
(isRecord(muninn) &&
|
|
119
|
+
Object.keys(muninn).some((key) => !NEW_MUNINN_KEYS.has(key)))
|
|
120
|
+
return old ? { kind: 'old', raw } : { kind: 'new' }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A starting config: the guessed checks, and the vault if there is one. */
|
|
124
|
+
export const startingConfig = ({
|
|
125
|
+
scripts,
|
|
126
|
+
vault,
|
|
127
|
+
}: {
|
|
128
|
+
scripts: Record<string, string>
|
|
129
|
+
vault: string | null
|
|
130
|
+
}): Record<string, unknown> => ({
|
|
131
|
+
checks: guessChecks({ scripts }),
|
|
132
|
+
...(vault === null ? {} : { muninn: { vault } }),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const vaultName = (vault: unknown): string | null =>
|
|
136
|
+
typeof vault === 'string' && vault.trim() !== '' ? vault : null
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The vault an old-Luca config names in `muninn.vault`, or else in an older
|
|
140
|
+
* top-level `vault`, if any.
|
|
141
|
+
*/
|
|
142
|
+
export const oldVault = ({
|
|
143
|
+
raw,
|
|
144
|
+
}: {
|
|
145
|
+
raw: Record<string, unknown>
|
|
146
|
+
}): string | null =>
|
|
147
|
+
vaultName(isRecord(raw.muninn) ? raw.muninn.vault : undefined) ??
|
|
148
|
+
vaultName(raw.vault)
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The `name` part of a GitHub `owner/name`, a new repo's vault. Pure.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* repoName({ github_repo: 'asibilia/tmnb' }) // 'tmnb'
|
|
155
|
+
*/
|
|
156
|
+
export const repoName = ({
|
|
157
|
+
github_repo,
|
|
158
|
+
}: {
|
|
159
|
+
github_repo: string | null
|
|
160
|
+
}): string | null => vaultName(github_repo?.split('/').at(-1)?.trim() ?? null)
|
|
161
|
+
|
|
162
|
+
export const writeConfig = async ({
|
|
163
|
+
repo,
|
|
164
|
+
config,
|
|
165
|
+
}: {
|
|
166
|
+
repo: string
|
|
167
|
+
config: Record<string, unknown>
|
|
168
|
+
}) => {
|
|
169
|
+
const path = join(repo, ENGINE_CONFIG_FILE)
|
|
170
|
+
await mkdir(dirname(path), { recursive: true })
|
|
171
|
+
await Bun.write(path, `${JSON.stringify(config, null, 4)}\n`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const READABLE: Record<TestCommand['results'], string> = {
|
|
175
|
+
bun: 'bun-readable',
|
|
176
|
+
junit: 'junit-readable',
|
|
177
|
+
pass_fail: 'pass or fail',
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const describeTest = ({ run, results }: TestCommand): string =>
|
|
181
|
+
`\`${run}\` (${READABLE[results]})`
|
|
182
|
+
|
|
183
|
+
/** The done and to-do lines for a config that loads. */
|
|
184
|
+
export const reportConfig = ({
|
|
185
|
+
config,
|
|
186
|
+
}: {
|
|
187
|
+
config: EngineConfig
|
|
188
|
+
}): { done: string[]; todo: string[] } => {
|
|
189
|
+
const tests = testCommands({ config })
|
|
190
|
+
const done: string[] = []
|
|
191
|
+
const todo: string[] = []
|
|
192
|
+
if (tests.length === 0) {
|
|
193
|
+
todo.push(
|
|
194
|
+
`Add a test command to \`checks.test\` in ${ENGINE_CONFIG_FILE}. Intake refuses a run without one.`
|
|
195
|
+
)
|
|
196
|
+
} else {
|
|
197
|
+
done.push(`Test commands: ${tests.map(describeTest).join(', ')}`)
|
|
198
|
+
if (!tests.some(({ results }) => givesPerTestResults(results))) {
|
|
199
|
+
todo.push(
|
|
200
|
+
`No test command gives per-test results, so only refactor tickets can run. Add a \`bun test\` command, or one with \`"results": "junit"\`, to \`checks.test\` in ${ENGINE_CONFIG_FILE}.`
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const kind of ['types', 'lint'] as const) {
|
|
205
|
+
const command = config.checks[kind]
|
|
206
|
+
if (command === undefined) {
|
|
207
|
+
todo.push(
|
|
208
|
+
`No ${kind} check. Add \`checks.${kind}\` to ${ENGINE_CONFIG_FILE} if the repo has one.`
|
|
209
|
+
)
|
|
210
|
+
} else {
|
|
211
|
+
done.push(`The ${kind} check: \`${command}\``)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { done, todo }
|
|
215
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor's `junit` check (#668): each junit test command that runs `swift
|
|
3
|
+
* test` should pass `--parallel` (without it XCTest writes no JUnit file)
|
|
4
|
+
* and list both of SwiftPM's files in `junit_path`.
|
|
5
|
+
*/
|
|
6
|
+
import { stat } from 'node:fs/promises'
|
|
7
|
+
import { basename, dirname, join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import uniq from 'lodash/uniq'
|
|
10
|
+
|
|
11
|
+
import { ok, warning, type Found } from './doctor-checks'
|
|
12
|
+
import { readScripts } from './setup-config'
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
ENGINE_CONFIG_FILE,
|
|
16
|
+
junitCommands,
|
|
17
|
+
type EngineConfig,
|
|
18
|
+
} from '../config/engine-config'
|
|
19
|
+
import { insideWorktree } from '../guards/write-rules'
|
|
20
|
+
|
|
21
|
+
/** The shells a script may start its file with. */
|
|
22
|
+
const SHELLS = new Set(['bash', 'sh', 'zsh'])
|
|
23
|
+
|
|
24
|
+
const SWIFT_TESTING_SUFFIX = '-swift-testing.xml'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The text of the repo file at `path`, or `null` when there is no such file
|
|
28
|
+
* or `path` leaves the repo (with `..`, or an absolute path outside it).
|
|
29
|
+
*/
|
|
30
|
+
const fileText = async ({
|
|
31
|
+
repo,
|
|
32
|
+
path,
|
|
33
|
+
}: {
|
|
34
|
+
repo: string
|
|
35
|
+
path: string
|
|
36
|
+
}): Promise<string | null> => {
|
|
37
|
+
const inner = insideWorktree({ worktree: repo, path })
|
|
38
|
+
if (inner === null) return null
|
|
39
|
+
const full = join(repo, inner)
|
|
40
|
+
try {
|
|
41
|
+
return (await stat(full)).isFile() ? await Bun.file(full).text() : null
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The texts a command runs: itself; for `bun run <script>`, that script in
|
|
49
|
+
* the root `package.json`; and the repo file that script starts, directly
|
|
50
|
+
* or after `bash`, `sh` or `zsh`.
|
|
51
|
+
*/
|
|
52
|
+
const textsOf = async ({
|
|
53
|
+
repo,
|
|
54
|
+
run,
|
|
55
|
+
scripts,
|
|
56
|
+
}: {
|
|
57
|
+
repo: string
|
|
58
|
+
run: string
|
|
59
|
+
scripts: Record<string, string>
|
|
60
|
+
}): Promise<string[]> => {
|
|
61
|
+
const name = /^bun\s+run\s+(\S+)/.exec(run.trim())?.[1]
|
|
62
|
+
const script = name === undefined ? undefined : scripts[name]
|
|
63
|
+
if (script === undefined) return [run]
|
|
64
|
+
const words = script.trim().split(/\s+/)
|
|
65
|
+
const first = words[0] ?? ''
|
|
66
|
+
const path = SHELLS.has(first) ? words[1] : first
|
|
67
|
+
const file = path === undefined ? null : await fileText({ repo, path })
|
|
68
|
+
return [run, script, ...(file === null ? [] : [file])]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The other file of SwiftPM's pair for a `junit_path` entry, in the same
|
|
73
|
+
* folder: `core.xml` and `core-swift-testing.xml` go together. `null` for
|
|
74
|
+
* a path that isn't an `.xml` file.
|
|
75
|
+
*/
|
|
76
|
+
const partnerOf = (path: string): string | null => {
|
|
77
|
+
const name = basename(path)
|
|
78
|
+
const partner = name.endsWith(SWIFT_TESTING_SUFFIX)
|
|
79
|
+
? `${name.slice(0, -SWIFT_TESTING_SUFFIX.length)}.xml`
|
|
80
|
+
: name.endsWith('.xml')
|
|
81
|
+
? `${name.slice(0, -'.xml'.length)}${SWIFT_TESTING_SUFFIX}`
|
|
82
|
+
: null
|
|
83
|
+
if (partner === null) return null
|
|
84
|
+
const folder = dirname(path)
|
|
85
|
+
return folder === '.' ? partner : `${folder}/${partner}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The `junit` check for a config with junit commands: a warning for each
|
|
90
|
+
* one that runs `swift test` without `--parallel`, and for each of
|
|
91
|
+
* SwiftPM's files its `junit_path` leaves out, made into one; else OK.
|
|
92
|
+
*/
|
|
93
|
+
export const checkJunit = async ({
|
|
94
|
+
repo,
|
|
95
|
+
config,
|
|
96
|
+
}: {
|
|
97
|
+
repo: string
|
|
98
|
+
config: EngineConfig
|
|
99
|
+
}): Promise<Found> => {
|
|
100
|
+
const scripts = await readScripts({ repo })
|
|
101
|
+
const found: { detail: string; fix: string }[] = []
|
|
102
|
+
for (const { run, junit_path } of junitCommands({ config })) {
|
|
103
|
+
const texts = await textsOf({ repo, run, scripts })
|
|
104
|
+
if (!texts.some((text) => text.includes('swift test'))) continue
|
|
105
|
+
if (!texts.some((text) => text.includes('--parallel'))) {
|
|
106
|
+
found.push({
|
|
107
|
+
detail: `\`${run}\` runs swift test without --parallel, so XCTest writes no JUnit file and its results can't be read.`,
|
|
108
|
+
fix: 'Add --parallel to its swift test command.',
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
const listed = new Set(junit_path)
|
|
112
|
+
const missing = uniq(
|
|
113
|
+
junit_path.flatMap((path) => {
|
|
114
|
+
const partner = partnerOf(path)
|
|
115
|
+
return partner === null || listed.has(partner) ? [] : [partner]
|
|
116
|
+
})
|
|
117
|
+
)
|
|
118
|
+
for (const path of missing) {
|
|
119
|
+
found.push({
|
|
120
|
+
detail: `\`${run}\` runs swift test, but its junit_path doesn't list ${path}.`,
|
|
121
|
+
fix: `Add it to junit_path in ${ENGINE_CONFIG_FILE}: swift test writes XCTest results to core.xml and Swift Testing results to core-swift-testing.xml.`,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (found.length === 0) {
|
|
126
|
+
return ok(
|
|
127
|
+
'Each junit test command that runs swift test uses --parallel and lists both of its JUnit files'
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
return warning({
|
|
131
|
+
detail: found.map(({ detail }) => detail).join(' '),
|
|
132
|
+
fix: found.map(({ fix }) => fix).join(' '),
|
|
133
|
+
})
|
|
134
|
+
}
|