@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/board/package.json +1 -1
  3. package/board/server/luca-version.ts +1 -1
  4. package/engine/agents/claude-options.ts +5 -1
  5. package/engine/agents/common-instructions.ts +5 -3
  6. package/engine/agents/launch-paths.ts +41 -1
  7. package/engine/agents/launch-session.ts +10 -1
  8. package/engine/agents/writer-instructions.ts +15 -2
  9. package/engine/cli/setup-config.ts +215 -0
  10. package/engine/cli/setup-junit-check.ts +134 -0
  11. package/engine/cli/setup-repo-checks.ts +263 -0
  12. package/engine/cli/setup.ts +20 -437
  13. package/engine/config/engine-config-schema.ts +57 -24
  14. package/engine/config/engine-config.ts +4 -1
  15. package/engine/config/test-commands.ts +67 -15
  16. package/engine/core/execute-action.ts +1 -0
  17. package/engine/core/execute-already-done-check.ts +55 -12
  18. package/engine/core/execute-build.ts +9 -4
  19. package/engine/core/execute-intake.ts +35 -0
  20. package/engine/core/execute-red-check.ts +23 -6
  21. package/engine/gates/case-test-runner.ts +70 -0
  22. package/engine/gates/gate-runner.ts +13 -3
  23. package/engine/gates/gate-schemas.ts +5 -0
  24. package/engine/gates/junit-runner.ts +152 -0
  25. package/engine/gates/new-tests.ts +22 -3
  26. package/engine/gates/own-files-check.ts +13 -15
  27. package/engine/gates/red-check.ts +86 -7
  28. package/engine/gates/slow-test-runner.ts +2 -0
  29. package/engine/gates/swift-test-index.ts +121 -0
  30. package/engine/gates/swift-test-names.ts +128 -0
  31. package/engine/gates/test-checks.ts +36 -15
  32. package/engine/gates/test-runner.ts +69 -64
  33. package/engine/git/git-adapter.ts +3 -0
  34. package/engine/git/git-read.ts +15 -0
  35. package/engine/guards/permission-rules.ts +5 -3
  36. package/engine/guards/sandbox-settings.ts +22 -2
  37. package/engine/guards/shell-rules.ts +3 -2
  38. package/engine/index.ts +3 -7
  39. package/engine/intake/intake-checks.ts +12 -5
  40. package/engine/intake/intake-schemas.ts +10 -1
  41. package/engine/testing/swift-junit-fixtures.ts +93 -0
  42. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.25
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.24
8
+
9
+ No changes in this release.
10
+
3
11
  ## 14.0.0-alpha.23
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.23",
3
+ "version": "14.0.0-alpha.25",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -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.23"
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 { EngineConfig } from '../config/engine-config'
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 === 'bun'
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, and the repo's common git dir (shared by its worktrees).
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 { commonGitDir, realClaudePath } from './launch-paths'
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
+ }