@alecsibilia/luca 14.0.0-alpha.14 → 14.0.0-alpha.15
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 +4 -0
- package/board/package.json +1 -1
- package/board/server/luca-version.ts +1 -1
- package/board/server/make-rows.ts +15 -3
- package/board/server/reduce-board.ts +22 -2
- package/engine/agents/role-instructions.ts +46 -5
- package/engine/agents/role-prompts.ts +43 -8
- package/engine/agents/role-results.ts +6 -0
- package/engine/config/config-reload.ts +22 -0
- package/engine/config/engine-config.ts +135 -18
- package/engine/core/decide-build.ts +82 -7
- package/engine/core/decide-final-review.ts +21 -1
- package/engine/core/execute-build.ts +198 -28
- package/engine/core/execute-final-review.ts +10 -0
- package/engine/core/execute.ts +6 -2
- package/engine/core/loop-caps.ts +10 -2
- package/engine/core/stuck-text.ts +41 -4
- package/engine/gates/feature-map.ts +28 -0
- package/engine/gates/gate-runner.ts +271 -36
- package/engine/gates/gate-schemas.ts +12 -2
- package/engine/gates/new-tests.ts +45 -0
- package/engine/gates/red-check.ts +113 -3
- package/engine/gates/test-runner.ts +65 -2
- package/engine/git/git-adapter.ts +14 -0
- package/engine/guards/role-rules.ts +14 -5
- package/engine/index.ts +2 -0
- package/engine/journal/journal-record.ts +28 -2
- package/engine/journal/replay.ts +126 -21
- package/engine/shell/run-command.ts +21 -6
- package/engine/testing/build-fixtures.ts +16 -3
- package/engine/testing/final-review-fixtures.ts +40 -1
- package/engine/testing/practice-run.ts +1 -1
- package/package.json +1 -1
- package/skills/luca-unstick/SKILL.md +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.15"
|
|
@@ -394,6 +394,21 @@ export const describeRecord = ({
|
|
|
394
394
|
tone: 'info',
|
|
395
395
|
})
|
|
396
396
|
case 'gates_run': {
|
|
397
|
+
const failed = record.content.checks
|
|
398
|
+
.filter((check) => !check.ok)
|
|
399
|
+
.map((check) => check.name)
|
|
400
|
+
// The final review's slow tests, after round 1's lenses (#563).
|
|
401
|
+
if (record.content.target === 'final_slow') {
|
|
402
|
+
return record.content.ok
|
|
403
|
+
? event({
|
|
404
|
+
text: 'Final review: slow tests passed.',
|
|
405
|
+
tone: 'success',
|
|
406
|
+
})
|
|
407
|
+
: event({
|
|
408
|
+
text: `Final review: slow tests failed: ${failed.join(', ') || 'no detail'}.`,
|
|
409
|
+
tone: 'danger',
|
|
410
|
+
})
|
|
411
|
+
}
|
|
397
412
|
const where =
|
|
398
413
|
record.ticket === null
|
|
399
414
|
? ' on the fixes'
|
|
@@ -401,9 +416,6 @@ export const describeRecord = ({
|
|
|
401
416
|
? ' after joining'
|
|
402
417
|
: ''
|
|
403
418
|
const who = record.ticket === null ? 'Final review: ' : at
|
|
404
|
-
const failed = record.content.checks
|
|
405
|
-
.filter((check) => !check.ok)
|
|
406
|
-
.map((check) => check.name)
|
|
407
419
|
return record.content.ok
|
|
408
420
|
? event({
|
|
409
421
|
text: `${who}checks passed${where}.`,
|
|
@@ -53,7 +53,8 @@ const STUCK_REASONS: Record<string, string> = {
|
|
|
53
53
|
leftovers_found:
|
|
54
54
|
'The leftover scan found files that must not be committed.',
|
|
55
55
|
gates_failed: 'The checks failed.',
|
|
56
|
-
bad_test:
|
|
56
|
+
bad_test:
|
|
57
|
+
'A test was sent back as bad, by the implementer or because it checks nothing.',
|
|
57
58
|
changes_requested: 'The ticket review asked for changes.',
|
|
58
59
|
join_failed: "The ticket couldn't join the run branch.",
|
|
59
60
|
join_gates_failed: 'The checks failed after the ticket joined.',
|
|
@@ -437,6 +438,7 @@ const STEP_WORDS: Record<string, string> = {
|
|
|
437
438
|
run_red_check: 'running the red check',
|
|
438
439
|
run_gates: 'running the checks',
|
|
439
440
|
run_final_gates: 'running the checks',
|
|
441
|
+
run_final_slow_tests: 'running the slow tests',
|
|
440
442
|
join_run_branch: 'joining the run branch',
|
|
441
443
|
push_run_branch: 'pushing the run branch',
|
|
442
444
|
push_final_fixes: 'pushing the run branch',
|
|
@@ -1533,7 +1535,16 @@ const applyKind = ({
|
|
|
1533
1535
|
fix_round: record.content.round,
|
|
1534
1536
|
tried: withTried({
|
|
1535
1537
|
tried: review.tried,
|
|
1536
|
-
|
|
1538
|
+
// Every lens finished clean: only failed slow tests
|
|
1539
|
+
// open a fix round then (#563).
|
|
1540
|
+
line: `Fix round ${record.content.round}/${LOOP_CAP} on ${
|
|
1541
|
+
review.lenses.length > 0 &&
|
|
1542
|
+
review.lenses.every(
|
|
1543
|
+
(lens) => lens.state === 'clean'
|
|
1544
|
+
)
|
|
1545
|
+
? 'the failed slow tests'
|
|
1546
|
+
: "the lenses' findings"
|
|
1547
|
+
}`,
|
|
1537
1548
|
}),
|
|
1538
1549
|
}),
|
|
1539
1550
|
})
|
|
@@ -1807,6 +1818,15 @@ const gatesRun = ({
|
|
|
1807
1818
|
.map((check) => check.name)
|
|
1808
1819
|
.join(', ')
|
|
1809
1820
|
const afterJoin = target === 'run_branch'
|
|
1821
|
+
// The final review's slow tests, after round 1's lenses (#563).
|
|
1822
|
+
if (target === 'final_slow') {
|
|
1823
|
+
return ok
|
|
1824
|
+
? state
|
|
1825
|
+
: finalTried({
|
|
1826
|
+
state,
|
|
1827
|
+
line: `The final review's slow tests failed: ${failed || 'no detail'}`,
|
|
1828
|
+
})
|
|
1829
|
+
}
|
|
1810
1830
|
// The final review's fixes are checked on the run branch, with no ticket.
|
|
1811
1831
|
if (record.ticket === null) {
|
|
1812
1832
|
return ok
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { lensOf, type AgentRole, type LensName } from './role-results'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
caseLimitOf,
|
|
5
|
+
featureMapPath,
|
|
6
|
+
slowTestCommands,
|
|
7
|
+
testCommands,
|
|
8
|
+
type EngineConfig,
|
|
9
|
+
} from '../config/engine-config'
|
|
10
|
+
import { MAX_TESTS_PER_CRITERION } from '../core/loop-caps'
|
|
4
11
|
import {
|
|
5
12
|
checkCommands,
|
|
6
13
|
guardRoleOf,
|
|
@@ -74,6 +81,20 @@ Sometimes other tickets joined the run branch after this ticket's tests were wri
|
|
|
74
81
|
- Every criterion still needs a test. Your updated tests may pass now, since the code is there; that is fine. The engine runs every test in the gates after the implementer's turn.
|
|
75
82
|
- Answer "tests_written" with the full criterion mapping again.`
|
|
76
83
|
|
|
84
|
+
/** The test-writer's lines about the feature map (#583), or nothing without one. */
|
|
85
|
+
const featureMapLines = ({ config }: { config: EngineConfig }): string => {
|
|
86
|
+
const path = featureMapPath({ config })
|
|
87
|
+
return path === null
|
|
88
|
+
? ''
|
|
89
|
+
: `- Read ${path} first: it lists each feature and the test files that prove it.\n- Put new tests in a feature's existing test files when you can. A new test file must be listed under its feature in ${path}; you may edit that file.\n`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What the test-writer's "ONLY test files" line adds for the feature map, or nothing. */
|
|
93
|
+
const featureMapClause = ({ config }: { config: EngineConfig }): string => {
|
|
94
|
+
const path = featureMapPath({ config })
|
|
95
|
+
return path === null ? '' : ` and the feature map ${path}`
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
const TEST_WRITER = ({
|
|
78
99
|
config,
|
|
79
100
|
}: {
|
|
@@ -82,10 +103,11 @@ const TEST_WRITER = ({
|
|
|
82
103
|
|
|
83
104
|
You write the failing tests for ONE ticket, before any code exists.
|
|
84
105
|
|
|
85
|
-
- Create or edit ONLY test files (${config.test_file_patterns.join(', ')}). No other file: no implementation, no package.json, no config, no notes, and not the test setup files.
|
|
106
|
+
- Create or edit ONLY test files (${config.test_file_patterns.join(', ')})${featureMapClause({ config })}. No other file: no implementation, no package.json, no config, no notes, and not the test setup files.
|
|
86
107
|
- Your new tests must FAIL right now, because the code does not exist yet. That is expected. Do not create or stub the module under test.
|
|
87
108
|
- Test only the public behavior the ticket names, at the seams the spec's "Testing Decisions" section names. No mocks unless the spec asks. No tautological asserts.
|
|
88
|
-
- Every acceptance criterion needs at least one test. Old tests must keep passing.
|
|
109
|
+
${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.
|
|
110
|
+
- Map every test you add. The red check refuses a new test your criterion mapping doesn't name.
|
|
89
111
|
- 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.
|
|
90
112
|
|
|
91
113
|
Your result (structured output):
|
|
@@ -111,6 +133,25 @@ const testFileRule = ({
|
|
|
111
133
|
? `- You may follow renames and moves into test files (${config.test_file_patterns.join(', ')}), but never change what a test checks. Never touch a test setup file${setupFiles({ config })}.`
|
|
112
134
|
: `- Never create, edit, or delete a test file (${config.test_file_patterns.join(', ')}) or a test setup file${setupFiles({ config })}.`
|
|
113
135
|
|
|
136
|
+
/** The implementer's line about the slow tests (#563), or nothing without them. */
|
|
137
|
+
const slowLine = ({ config }: { config: EngineConfig }): string => {
|
|
138
|
+
const slow = slowTestCommands({ config }).map(({ run }) => `\`${run}\``)
|
|
139
|
+
return slow.length === 0
|
|
140
|
+
? ''
|
|
141
|
+
: ` The slow tests (${slow.join(', ')}) run at the final review, and the ticket's gates also run the slow test files it added or changed; you may run them yourself.`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The implementer's line about the fast test limit (#580), or nothing without
|
|
146
|
+
* one.
|
|
147
|
+
*/
|
|
148
|
+
const limitLine = ({ config }: { config: EngineConfig }): string => {
|
|
149
|
+
const limit = caseLimitOf({ config })
|
|
150
|
+
return limit === null
|
|
151
|
+
? ''
|
|
152
|
+
: `- Each fast test must finish within ${limit} ms. If one of the ticket's tests can't, answer "bad_test".\n`
|
|
153
|
+
}
|
|
154
|
+
|
|
114
155
|
const IMPLEMENTER = ({
|
|
115
156
|
config,
|
|
116
157
|
may_edit_tests,
|
|
@@ -128,8 +169,8 @@ You write the code that makes ONE ticket's failing tests pass. Another agent wro
|
|
|
128
169
|
]
|
|
129
170
|
.filter((command) => command !== undefined)
|
|
130
171
|
.map((command) => `\`${command}\``)
|
|
131
|
-
.join(', ')}
|
|
132
|
-
${testFileRule({ config, may_edit_tests })}
|
|
172
|
+
.join(', ')}.${slowLine({ config })}
|
|
173
|
+
${limitLine({ config })}${testFileRule({ config, may_edit_tests })}
|
|
133
174
|
- If a test is wrong (it contradicts the ticket or spec, or no correct code can pass it), don't work around it: answer outcome "bad_test" with the test and your reason.
|
|
134
175
|
- If the gates can't pass without a change to a test setup file${setupFiles({ config })}, don't work around it: answer outcome "needs_setup_change", with the file and why in "setup_change". The user makes the change.
|
|
135
176
|
- Follow the spec's Implementation Decisions. Keep the change small: no extra features, and no new dependencies unless the spec says so.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentRole, BadTest, Finding } from './role-results'
|
|
1
|
+
import type { AgentRole, BadTest, BadTestSource, Finding } from './role-results'
|
|
2
2
|
|
|
3
3
|
import type { SpecSnapshot, TicketSnapshot } from '../intake/intake-schemas'
|
|
4
4
|
import type { RejoinCause } from '../journal/journal-record'
|
|
@@ -137,11 +137,21 @@ const REFACTOR_TASK =
|
|
|
137
137
|
'Make every gate pass. You may follow renames and moves into test files, but you must not change what a test checks. ' +
|
|
138
138
|
'If a test is wrong, answer "bad_test" with your reason.'
|
|
139
139
|
|
|
140
|
-
/**
|
|
141
|
-
|
|
140
|
+
/**
|
|
141
|
+
* What a fresh test-writer is told when it replaces a bad test: the
|
|
142
|
+
* implementer sent it back, or the engine found it passes with no
|
|
143
|
+
* `expect()`, so it checks nothing (#581).
|
|
144
|
+
*/
|
|
145
|
+
const badTestSection = ({
|
|
146
|
+
bad_test,
|
|
147
|
+
by,
|
|
148
|
+
}: {
|
|
149
|
+
bad_test: BadTest
|
|
150
|
+
by: BadTestSource
|
|
151
|
+
}): string =>
|
|
142
152
|
[
|
|
143
153
|
'## A test was sent back as a bad test',
|
|
144
|
-
|
|
154
|
+
`${by === 'engine' ? 'The engine found this test checks nothing' : 'The implementer says this test is wrong'}:\n\n- File: ${bad_test.file}\n- Test: ${bad_test.name}\n- Reason: ${bad_test.reason}`,
|
|
145
155
|
'Fix or replace it so it checks what its criterion asks. Keep the other tests unless they are wrong too. ' +
|
|
146
156
|
'Answer with the full criterion mapping again: every criterion, the kept tests included.',
|
|
147
157
|
].join('\n\n')
|
|
@@ -190,6 +200,26 @@ export const prepareSection = ({ command }: { command: string }): string =>
|
|
|
190
200
|
"If the engine's prepare command fails after your turn, its output comes back to you like a failed check.",
|
|
191
201
|
].join('\n\n')
|
|
192
202
|
|
|
203
|
+
/**
|
|
204
|
+
* The section a test-writer gets when the repo has a feature map (#583): the
|
|
205
|
+
* one file besides test files it may edit. It comes after the task's "Edit
|
|
206
|
+
* test files only", so it reads as that line's exception.
|
|
207
|
+
*
|
|
208
|
+
* @param path - The map's path, as `featureMapPath` gives it.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* featureMapSection({ path: 'docs/feature-map.md' })
|
|
212
|
+
* // '## The feature map\n\nBesides test files, you may edit the feature map, `docs/feature-map.md`. ...'
|
|
213
|
+
*/
|
|
214
|
+
export const featureMapSection = ({ path }: { path: string }): string =>
|
|
215
|
+
[
|
|
216
|
+
'## The feature map',
|
|
217
|
+
`Besides test files, you may edit the feature map, \`${path}\`. ` +
|
|
218
|
+
'Read it first: it lists each feature and the test files that prove it. ' +
|
|
219
|
+
"Put new tests in a feature's existing test files when you can; " +
|
|
220
|
+
'a new test file must be listed under its feature in the map.',
|
|
221
|
+
].join('\n\n')
|
|
222
|
+
|
|
193
223
|
/**
|
|
194
224
|
* The section a test-writer or implementer (a ticket's or the final
|
|
195
225
|
* review's fixers) gets in a repo with changesets: the engine writes the
|
|
@@ -219,8 +249,8 @@ const LENS_TASK_TEXT =
|
|
|
219
249
|
|
|
220
250
|
const ROLE_TASKS: Record<AgentRole, string> = {
|
|
221
251
|
'test-writer':
|
|
222
|
-
'Write failing tests for every acceptance criterion below. Edit test files only. ' +
|
|
223
|
-
'Map
|
|
252
|
+
'Write failing tests for every acceptance criterion below: the fewest that prove each one. Edit test files only. ' +
|
|
253
|
+
'Map every test you add to the criterion it checks.',
|
|
224
254
|
implementer:
|
|
225
255
|
'Make every gate pass. Never edit a test file. ' +
|
|
226
256
|
'If a test is wrong, answer "bad_test" with your reason. ' +
|
|
@@ -259,7 +289,10 @@ export const roleTask = ({ role }: { role: AgentRole }): string =>
|
|
|
259
289
|
* @param refactor - The ticket is a refactor ticket: the implementer gets
|
|
260
290
|
* the refactor task (no new behavior; it may follow renames into tests).
|
|
261
291
|
* @param bad_test - For a fresh test-writer after a bad-test bounce: the
|
|
262
|
-
* test the implementer sent back,
|
|
292
|
+
* test the implementer sent back, or the engine found checks nothing
|
|
293
|
+
* (#581), and why.
|
|
294
|
+
* @param bad_test_by - Who sent `bad_test` back: the implementer (the
|
|
295
|
+
* default) or the engine.
|
|
263
296
|
* @param sections - More sections for the end, such as a reviewer's diff
|
|
264
297
|
* and gate results, or a review fixer's findings.
|
|
265
298
|
* @param run_notes - Notes earlier agents in the run left, oldest first,
|
|
@@ -274,6 +307,7 @@ export const rolePrompt = ({
|
|
|
274
307
|
ticket,
|
|
275
308
|
refactor,
|
|
276
309
|
bad_test,
|
|
310
|
+
bad_test_by,
|
|
277
311
|
sections,
|
|
278
312
|
run_notes,
|
|
279
313
|
}: {
|
|
@@ -282,6 +316,7 @@ export const rolePrompt = ({
|
|
|
282
316
|
ticket: TicketSnapshot
|
|
283
317
|
refactor?: boolean
|
|
284
318
|
bad_test?: BadTest | null
|
|
319
|
+
bad_test_by?: BadTestSource
|
|
285
320
|
sections?: string[]
|
|
286
321
|
run_notes?: PromptRunNote[]
|
|
287
322
|
}): string =>
|
|
@@ -303,7 +338,7 @@ export const rolePrompt = ({
|
|
|
303
338
|
'## Acceptance criteria',
|
|
304
339
|
ticket.criteria.map(({ id, text }) => `- ${id}: ${text}`).join('\n'),
|
|
305
340
|
...(role === 'test-writer' && bad_test
|
|
306
|
-
? [badTestSection({ bad_test })]
|
|
341
|
+
? [badTestSection({ bad_test, by: bad_test_by ?? 'implementer' })]
|
|
307
342
|
: []),
|
|
308
343
|
...(sections ?? []),
|
|
309
344
|
// Recalled memories (#370) are added after these by the decision step.
|
|
@@ -175,6 +175,12 @@ export const BadTestSchema = z.object({
|
|
|
175
175
|
|
|
176
176
|
export type BadTest = z.infer<typeof BadTestSchema>
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Who sent a test back as bad: the implementer, who says it is wrong, or the
|
|
180
|
+
* engine, which found a mapped test that passes with no `expect()` (#581).
|
|
181
|
+
*/
|
|
182
|
+
export type BadTestSource = 'implementer' | 'engine'
|
|
183
|
+
|
|
178
184
|
/**
|
|
179
185
|
* The implementer's result. `bad_test` sends a wrong test back;
|
|
180
186
|
* `needs_setup_change` says the gates can't pass without a change to a test
|
|
@@ -20,6 +20,8 @@ import type { EngineConfig } from './engine-config'
|
|
|
20
20
|
* one counts from the next prepare run.
|
|
21
21
|
* - `test_timeout_ms`: how long each test command may run (#542). Each run
|
|
22
22
|
* of a test command reads it then, so a new one counts from the next.
|
|
23
|
+
* - `test_case_timeout_ms`: how long one fast test may take (#580). Each run
|
|
24
|
+
* of a fast test command reads it then, so a new one counts from the next.
|
|
23
25
|
* - `leftover_allow`: more new markdown the leftover scan lets through
|
|
24
26
|
* (#520). Each scan reads it then, so a run stuck on a leftover can be
|
|
25
27
|
* unstuck by allowing the file and resuming.
|
|
@@ -29,6 +31,7 @@ export const RELOADED_FIELDS = [
|
|
|
29
31
|
'prepare_timeout_ms',
|
|
30
32
|
'prepare_concurrency',
|
|
31
33
|
'test_timeout_ms',
|
|
34
|
+
'test_case_timeout_ms',
|
|
32
35
|
'leftover_allow',
|
|
33
36
|
] as const
|
|
34
37
|
|
|
@@ -47,6 +50,10 @@ export const RELOADED_FIELDS = [
|
|
|
47
50
|
* - `test_file_patterns`: which files are tests decides the red check, the
|
|
48
51
|
* role rules, and which commits were the test-writer's.
|
|
49
52
|
* - `test_setup_files`: part of what the tests are, as `checks` is.
|
|
53
|
+
* - `feature_map`: says which test files belong to which feature (#583), as
|
|
54
|
+
* `test_file_patterns` says which files are tests: the red check holds a
|
|
55
|
+
* ticket's new test files to it, and the role rules let the test-writer
|
|
56
|
+
* edit it.
|
|
50
57
|
* - `rule_files`: the rules lens judges against them; tickets already
|
|
51
58
|
* reviewed were judged on the old ones.
|
|
52
59
|
* - `muninn`: the run's memory vault is fixed at start (`run_started.memory`).
|
|
@@ -59,6 +66,7 @@ export const FROZEN_FIELDS = [
|
|
|
59
66
|
'checks',
|
|
60
67
|
'test_file_patterns',
|
|
61
68
|
'test_setup_files',
|
|
69
|
+
'feature_map',
|
|
62
70
|
'rule_files',
|
|
63
71
|
'muninn',
|
|
64
72
|
'run_budget_tokens',
|
|
@@ -94,6 +102,11 @@ export const ConfigChangeSchema = z.discriminatedUnion('field', [
|
|
|
94
102
|
from: positiveInt,
|
|
95
103
|
to: positiveInt,
|
|
96
104
|
}),
|
|
105
|
+
z.object({
|
|
106
|
+
field: z.literal('test_case_timeout_ms'),
|
|
107
|
+
from: positiveInt,
|
|
108
|
+
to: positiveInt,
|
|
109
|
+
}),
|
|
97
110
|
z.object({
|
|
98
111
|
field: z.literal('leftover_allow'),
|
|
99
112
|
from: patterns,
|
|
@@ -140,6 +153,11 @@ export const configChanges = ({
|
|
|
140
153
|
from: current.test_timeout_ms ?? null,
|
|
141
154
|
to: next.test_timeout_ms ?? null,
|
|
142
155
|
},
|
|
156
|
+
{
|
|
157
|
+
field: 'test_case_timeout_ms',
|
|
158
|
+
from: current.test_case_timeout_ms ?? null,
|
|
159
|
+
to: next.test_case_timeout_ms ?? null,
|
|
160
|
+
},
|
|
143
161
|
{
|
|
144
162
|
field: 'leftover_allow',
|
|
145
163
|
from: current.leftover_allow ?? null,
|
|
@@ -173,6 +191,10 @@ const applyChange = ({
|
|
|
173
191
|
return change.to === null
|
|
174
192
|
? omit(config, 'test_timeout_ms')
|
|
175
193
|
: { ...config, test_timeout_ms: change.to }
|
|
194
|
+
case 'test_case_timeout_ms':
|
|
195
|
+
return change.to === null
|
|
196
|
+
? omit(config, 'test_case_timeout_ms')
|
|
197
|
+
: { ...config, test_case_timeout_ms: change.to }
|
|
176
198
|
case 'leftover_allow':
|
|
177
199
|
return change.to === null
|
|
178
200
|
? omit(config, 'leftover_allow')
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join } from 'node:path'
|
|
1
|
+
import { isAbsolute, join, normalize } from 'node:path'
|
|
2
2
|
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
|
|
@@ -34,6 +34,12 @@ const TestCommandEntrySchema = z.union([
|
|
|
34
34
|
}),
|
|
35
35
|
])
|
|
36
36
|
|
|
37
|
+
/** A test command setting: one command, or a list of them (see `testCommands`). */
|
|
38
|
+
const TestCommandsSchema = z.union([
|
|
39
|
+
z.string().min(1),
|
|
40
|
+
z.array(TestCommandEntrySchema),
|
|
41
|
+
])
|
|
42
|
+
|
|
37
43
|
/** The config's `muninn` block. */
|
|
38
44
|
export const MuninnConfigSchema = z.object({
|
|
39
45
|
/** The project's memory vault. */
|
|
@@ -49,6 +55,22 @@ export const DecisionModelConfigSchema = z.object({
|
|
|
49
55
|
model: DecisionModelIdSchema.default(DEFAULT_DECISION_MODEL),
|
|
50
56
|
})
|
|
51
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Whether a config path names a file inside the repo (#583): not absolute,
|
|
60
|
+
* not ending in `/` (a folder), and, once cleaned (`normalize`), neither the
|
|
61
|
+
* repo root nor a path that climbs out of it with `..`.
|
|
62
|
+
*/
|
|
63
|
+
const isFileInsideRepo = (path: string): boolean => {
|
|
64
|
+
const clean = normalize(path)
|
|
65
|
+
return (
|
|
66
|
+
!isAbsolute(path) &&
|
|
67
|
+
!path.endsWith('/') &&
|
|
68
|
+
clean !== '.' &&
|
|
69
|
+
clean !== '..' &&
|
|
70
|
+
!clean.startsWith('../')
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
/**
|
|
53
75
|
* The per-repo engine config: the gate commands, where tests live, the rule
|
|
54
76
|
* files for the rules lens, the project's memory vault, the decision model,
|
|
@@ -59,14 +81,19 @@ export const DecisionModelConfigSchema = z.object({
|
|
|
59
81
|
* The test command is optional here on purpose. A config without it still
|
|
60
82
|
* loads, and intake refuses the run with a clear message instead.
|
|
61
83
|
* `checks.test` is one command or a list of them (see `testCommands`); it is
|
|
62
|
-
* kept as written.
|
|
84
|
+
* kept as written. `checks.slow_test` has the same shape (see
|
|
85
|
+
* `slowTestCommands`, #563).
|
|
63
86
|
*/
|
|
64
87
|
export const EngineConfigSchema = z.object({
|
|
65
88
|
checks: z
|
|
66
89
|
.object({
|
|
67
|
-
test:
|
|
68
|
-
|
|
69
|
-
|
|
90
|
+
test: TestCommandsSchema.optional(),
|
|
91
|
+
/**
|
|
92
|
+
* The slow tests (#563): the tests `test` leaves out, run only at
|
|
93
|
+
* the final review. Same shape as `test`. Left out, nothing
|
|
94
|
+
* changes.
|
|
95
|
+
*/
|
|
96
|
+
slow_test: TestCommandsSchema.optional(),
|
|
70
97
|
types: z.string().min(1).optional(),
|
|
71
98
|
lint: z.string().min(1).optional(),
|
|
72
99
|
})
|
|
@@ -92,12 +119,33 @@ export const EngineConfigSchema = z.object({
|
|
|
92
119
|
* engine's default (`DEFAULT_COMMAND_TIMEOUT_MS`, 5 minutes).
|
|
93
120
|
*/
|
|
94
121
|
test_timeout_ms: z.number().int().positive().optional(),
|
|
122
|
+
/**
|
|
123
|
+
* How long one fast test may take, in milliseconds (#580): passed to every
|
|
124
|
+
* `bun` command of `checks.test` as `--timeout`, and checked on a ticket's
|
|
125
|
+
* own test files by their measured time. Left out, no limit.
|
|
126
|
+
*/
|
|
127
|
+
test_case_timeout_ms: z.number().int().positive().optional(),
|
|
95
128
|
/**
|
|
96
129
|
* Glob patterns for more new markdown the leftover scan lets through,
|
|
97
130
|
* beyond doc folders, READMEs, and files the repo names (#520), such as
|
|
98
131
|
* `design/*.md`; left out, none.
|
|
99
132
|
*/
|
|
100
133
|
leftover_allow: z.array(z.string().min(1)).optional(),
|
|
134
|
+
/**
|
|
135
|
+
* A markdown file listing each feature and the test files that prove it
|
|
136
|
+
* (#583), relative to the repo root: a file inside the repo, so not an
|
|
137
|
+
* absolute path, not one that climbs out with `..`, and not a folder (no
|
|
138
|
+
* trailing `/`). The red check refuses a new test file it doesn't list,
|
|
139
|
+
* and the test-writer may edit it. Left out, no map.
|
|
140
|
+
*/
|
|
141
|
+
feature_map: z
|
|
142
|
+
.string()
|
|
143
|
+
.min(1)
|
|
144
|
+
.refine(isFileInsideRepo, {
|
|
145
|
+
message:
|
|
146
|
+
'must be a file inside the repo, relative to its root: not an absolute path, not one that leaves the repo with "..", and not a folder',
|
|
147
|
+
})
|
|
148
|
+
.optional(),
|
|
101
149
|
test_file_patterns: z.array(z.string()).default(['**/*.test.ts']),
|
|
102
150
|
test_setup_files: z.array(z.string()).default([]),
|
|
103
151
|
rule_files: z.array(z.string()).default([]),
|
|
@@ -191,6 +239,19 @@ export const isBunTestCommand = (run: string): boolean =>
|
|
|
191
239
|
const defaultResults = (run: string): TestResults =>
|
|
192
240
|
isBunTestCommand(run) ? 'bun' : 'pass_fail'
|
|
193
241
|
|
|
242
|
+
/** Reads a test command setting (`checks.test` or `checks.slow_test`) into commands. */
|
|
243
|
+
const commandsOf = (value: EngineConfig['checks']['test']): TestCommand[] => {
|
|
244
|
+
if (value === undefined) return []
|
|
245
|
+
return (typeof value === 'string' ? [value] : value).map((entry) =>
|
|
246
|
+
typeof entry === 'string'
|
|
247
|
+
? { run: entry, results: defaultResults(entry) }
|
|
248
|
+
: {
|
|
249
|
+
run: entry.run,
|
|
250
|
+
results: entry.results ?? defaultResults(entry.run),
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
194
255
|
/**
|
|
195
256
|
* Every test command in the config, in order, each with its results kind.
|
|
196
257
|
* A missing `results` is `bun` for a `bun test` command, else `pass_fail`.
|
|
@@ -204,20 +265,76 @@ export const testCommands = ({
|
|
|
204
265
|
config,
|
|
205
266
|
}: {
|
|
206
267
|
config: EngineConfig
|
|
207
|
-
}): TestCommand[] =>
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
268
|
+
}): TestCommand[] => commandsOf(config.checks.test)
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Every slow test command (#563), in order, each with its results kind, like
|
|
272
|
+
* `testCommands`. Empty when the config has none.
|
|
273
|
+
*
|
|
274
|
+
* @example
|
|
275
|
+
* slowTestCommands({ config: { ...config, checks: { test: 'bun test src', slow_test: 'bun test .e2e.test' } } })
|
|
276
|
+
* // [{ run: 'bun test .e2e.test', results: 'bun' }]
|
|
277
|
+
*/
|
|
278
|
+
export const slowTestCommands = ({
|
|
279
|
+
config,
|
|
280
|
+
}: {
|
|
281
|
+
config: EngineConfig
|
|
282
|
+
}): TestCommand[] => commandsOf(config.checks.slow_test)
|
|
283
|
+
|
|
284
|
+
/** Whether the config has a slow test command (#563). */
|
|
285
|
+
export const hasSlowTests = ({ config }: { config: EngineConfig }): boolean =>
|
|
286
|
+
slowTestCommands({ config }).length > 0
|
|
287
|
+
|
|
288
|
+
/** The fast test speed limit (#580), or `null` with none. */
|
|
289
|
+
export const caseLimitOf = ({
|
|
290
|
+
config,
|
|
291
|
+
}: {
|
|
292
|
+
config: EngineConfig
|
|
293
|
+
}): number | null => config.test_case_timeout_ms ?? null
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The feature map's path (#583), in the form a worktree-relative path takes
|
|
297
|
+
* everywhere else (`normalize`, no leading `./`), or `null` with no map.
|
|
298
|
+
* `./docs/feature-map.md` and `docs//feature-map.md` are both
|
|
299
|
+
* `docs/feature-map.md`. The role rules, the permission rules, the prompts,
|
|
300
|
+
* and the red check all read the map's path here, so they agree on it
|
|
301
|
+
* whatever way the config spells it.
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* featureMapPath({ config: { ...config, feature_map: './docs/feature-map.md' } }) // 'docs/feature-map.md'
|
|
305
|
+
*/
|
|
306
|
+
export const featureMapPath = ({
|
|
307
|
+
config,
|
|
308
|
+
}: {
|
|
309
|
+
config: EngineConfig
|
|
310
|
+
}): string | null =>
|
|
311
|
+
config.feature_map === undefined
|
|
312
|
+
? null
|
|
313
|
+
: normalize(config.feature_map).replace(/^\.\//, '')
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A fast `bun` test command with the speed limit (#580): `--timeout=<n>` at
|
|
317
|
+
* its end, or the command as written with no limit.
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* fastBunCommand({ run: 'bun test src', config: { ...config, test_case_timeout_ms: 2000 } }) // 'bun test src --timeout=2000'
|
|
321
|
+
*/
|
|
322
|
+
export const fastBunCommand = ({
|
|
323
|
+
run,
|
|
324
|
+
config,
|
|
325
|
+
}: {
|
|
326
|
+
run: string
|
|
327
|
+
config: EngineConfig
|
|
328
|
+
}): string => {
|
|
329
|
+
const limit = caseLimitOf({ config })
|
|
330
|
+
return limit === null ? run : `${run} --timeout=${limit}`
|
|
218
331
|
}
|
|
219
332
|
|
|
220
|
-
/**
|
|
333
|
+
/**
|
|
334
|
+
* The test commands whose per-test results the engine reads, each with the
|
|
335
|
+
* fast test speed limit (#580) when the config has one. Used by the baseline,
|
|
336
|
+
* the red check and the already-done check; `testCommands` stays as written.
|
|
337
|
+
*/
|
|
221
338
|
export const bunTestCommands = ({
|
|
222
339
|
config,
|
|
223
340
|
}: {
|
|
@@ -225,7 +342,7 @@ export const bunTestCommands = ({
|
|
|
225
342
|
}): string[] =>
|
|
226
343
|
testCommands({ config })
|
|
227
344
|
.filter(({ results }) => results === 'bun')
|
|
228
|
-
.map(({ run }) => run)
|
|
345
|
+
.map(({ run }) => fastBunCommand({ run, config }))
|
|
229
346
|
|
|
230
347
|
export type LoadEngineConfigResult =
|
|
231
348
|
| { ok: true; config: EngineConfig }
|