@alecsibilia/luca 14.0.0-alpha.22 → 14.0.0-alpha.24

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 (49) 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 +7 -3
  5. package/engine/agents/common-instructions.ts +5 -3
  6. package/engine/agents/writer-instructions.ts +15 -2
  7. package/engine/cli/setup-config.ts +215 -0
  8. package/engine/cli/setup-junit-check.ts +134 -0
  9. package/engine/cli/setup-repo-checks.ts +263 -0
  10. package/engine/cli/setup.ts +20 -437
  11. package/engine/config/engine-config-schema.ts +57 -24
  12. package/engine/config/engine-config.ts +4 -1
  13. package/engine/config/test-commands.ts +67 -15
  14. package/engine/core/decide-final-fix.ts +11 -4
  15. package/engine/core/decide-final-review.ts +3 -1
  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/core/final-failure-cap.ts +8 -4
  22. package/engine/core/final-review-text.ts +21 -3
  23. package/engine/core/pull-request-text.ts +11 -7
  24. package/engine/core/stuck-format.ts +14 -2
  25. package/engine/gates/case-test-runner.ts +70 -0
  26. package/engine/gates/gate-runner.ts +13 -3
  27. package/engine/gates/gate-schemas.ts +5 -0
  28. package/engine/gates/junit-runner.ts +152 -0
  29. package/engine/gates/new-tests.ts +22 -3
  30. package/engine/gates/own-files-check.ts +13 -15
  31. package/engine/gates/red-check.ts +86 -7
  32. package/engine/gates/slow-test-runner.ts +2 -0
  33. package/engine/gates/swift-test-index.ts +121 -0
  34. package/engine/gates/swift-test-names.ts +128 -0
  35. package/engine/gates/test-checks.ts +36 -15
  36. package/engine/gates/test-runner.ts +69 -64
  37. package/engine/git/git-adapter.ts +3 -0
  38. package/engine/git/git-read.ts +15 -0
  39. package/engine/guards/permission-rules.ts +5 -3
  40. package/engine/guards/shell-rules.ts +3 -2
  41. package/engine/index.ts +3 -7
  42. package/engine/intake/intake-checks.ts +12 -5
  43. package/engine/intake/intake-schemas.ts +10 -1
  44. package/engine/journal/final-review-replay.ts +3 -0
  45. package/engine/journal/final-review-round.ts +66 -4
  46. package/engine/journal/final-review-state.ts +4 -1
  47. package/engine/journal/replay.ts +1 -1
  48. package/engine/testing/swift-junit-fixtures.ts +93 -0
  49. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.24
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.23
8
+
9
+ No changes in this release.
10
+
3
11
  ## 14.0.0-alpha.22
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.22",
3
+ "version": "14.0.0-alpha.24",
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.22"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.24"
@@ -33,15 +33,19 @@ export const MODEL_NAMES = ModelNameSchema.options
33
33
  /** The model reviewers run on, and every agent the decision model doesn't route (#603). */
34
34
  export const CLAUDE_MODEL = DEFAULT_MODEL
35
35
 
36
- /** How many model turns one agent gets before its try ends. */
36
+ /**
37
+ * How many model turns one agent gets before its try ends. The integration
38
+ * lens reads how every ticket fits together on the whole branch, so it gets
39
+ * more than the other lenses.
40
+ */
37
41
  export const MAX_TURNS: Record<AgentRole, number> = {
38
42
  'test-writer': 80,
39
- implementer: 120,
43
+ implementer: 150,
40
44
  'ticket-reviewer': 60,
41
45
  'architecture-lens': 60,
42
46
  'simplification-lens': 60,
43
47
  'security-lens': 60,
44
- 'integration-lens': 60,
48
+ 'integration-lens': 100,
45
49
  'rules-lens': 60,
46
50
  learner: 40,
47
51
  }
@@ -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}\``),
@@ -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
+ }