113 lines
5.1 KiB
JavaScript
113 lines
5.1 KiB
JavaScript
|
|
import { test } from 'node:test'
|
||
|
|
import assert from 'node:assert/strict'
|
||
|
|
import fs from 'node:fs'
|
||
|
|
import os from 'node:os'
|
||
|
|
import path from 'node:path'
|
||
|
|
|
||
|
|
import { allStagesDone, lintGoal, loadGoal, syncGoalCheckbox, syncGoalDone, syncGoalStage } from '../src/goal.mjs'
|
||
|
|
|
||
|
|
function tmpProject() {
|
||
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-goal-'))
|
||
|
|
}
|
||
|
|
|
||
|
|
test('loadGoal parses verify/acceptance/model directives from a JSON stage', () => {
|
||
|
|
const project = tmpProject()
|
||
|
|
const file = path.join(project, 'goal.json')
|
||
|
|
fs.writeFileSync(
|
||
|
|
file,
|
||
|
|
JSON.stringify({
|
||
|
|
title: 'T',
|
||
|
|
stages: [{ id: 's1', title: 'A', verify: 'pnpm test', acceptance: 'green', model: 'p/m', agent: 'code', variant: 'high' }],
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
const goal = loadGoal({ project, goal: 'goal.json', goalText: '' })
|
||
|
|
assert.equal(goal.stages[0].verify, 'pnpm test')
|
||
|
|
assert.equal(goal.stages[0].acceptance, 'green')
|
||
|
|
assert.equal(goal.stages[0].model, 'p/m')
|
||
|
|
assert.equal(goal.stages[0].agent, 'code')
|
||
|
|
assert.equal(goal.stages[0].variant, 'high')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('loadGoal parses ":: verify: ..." directives from markdown checkboxes', () => {
|
||
|
|
const project = tmpProject()
|
||
|
|
const file = path.join(project, 'goal.md')
|
||
|
|
fs.writeFileSync(file, '# G\n\n- [ ] One :: verify: pnpm test :: acceptance: all green\n- [ ] Two\n')
|
||
|
|
const goal = loadGoal({ project, goal: 'goal.md', goalText: '' })
|
||
|
|
assert.equal(goal.stages[0].verify, 'pnpm test')
|
||
|
|
assert.equal(goal.stages[0].acceptance, 'all green')
|
||
|
|
assert.equal(goal.stages[0].title, 'One')
|
||
|
|
assert.equal(goal.stages[1].verify, '')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('lintGoal warns when a stage has no verify command', () => {
|
||
|
|
const goal = { stages: [{ id: 's1', title: 'Rebuild the parser' }, { id: 's2', title: 'x', verify: 'true' }] }
|
||
|
|
const issues = lintGoal(goal, {})
|
||
|
|
assert.ok(issues.some((i) => i.stageId === 's1' && /no verify/.test(i.message)))
|
||
|
|
assert.ok(!issues.some((i) => i.stageId === 's2' && /no verify/.test(i.message)))
|
||
|
|
})
|
||
|
|
|
||
|
|
test('lintGoal reports duplicate ids as errors and vague titles as warnings', () => {
|
||
|
|
const goal = { stages: [{ id: 'dup', title: 'Fix stuff' }, { id: 'dup', title: 'Another thing' }] }
|
||
|
|
const issues = lintGoal(goal, { verify: 'true' })
|
||
|
|
assert.ok(issues.some((i) => i.level === 'error' && /duplicate stage id/.test(i.message)))
|
||
|
|
assert.ok(issues.some((i) => i.level === 'warn' && /vague/.test(i.message)))
|
||
|
|
})
|
||
|
|
|
||
|
|
test('syncGoalCheckbox ticks the matching markdown checkbox only', () => {
|
||
|
|
const project = tmpProject()
|
||
|
|
const file = path.join(project, 'plan.md')
|
||
|
|
fs.writeFileSync(file, '# G\n\n- [ ] First :: verify: t\n- [ ] Second\n')
|
||
|
|
const goal = { source: file, stages: [{ id: 's1', title: 'First' }, { id: 's2', title: 'Second' }] }
|
||
|
|
const res = syncGoalCheckbox(goal, goal.stages[0])
|
||
|
|
assert.equal(res.changed, true)
|
||
|
|
const text = fs.readFileSync(file, 'utf8')
|
||
|
|
assert.match(text, /- \[x\] First :: verify: t/)
|
||
|
|
assert.match(text, /- \[ \] Second/)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('syncGoalCheckbox is a no-op for non-markdown goals', () => {
|
||
|
|
const res = syncGoalCheckbox({ source: '/tmp/goal.json' }, { title: 'x' })
|
||
|
|
assert.equal(res.ok, false)
|
||
|
|
assert.match(res.reason, /not a markdown file/)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('syncGoalDone marks a JSON stage done by id and preserves formatting', () => {
|
||
|
|
const project = tmpProject()
|
||
|
|
const file = path.join(project, 'goal.json')
|
||
|
|
fs.writeFileSync(file, JSON.stringify({ title: 'G', stages: [{ id: 's1', title: 'One' }, { id: 's2', title: 'Two' }] }))
|
||
|
|
const res = syncGoalDone({ source: file }, { id: 's1', title: 'One' })
|
||
|
|
assert.equal(res.changed, true)
|
||
|
|
const saved = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||
|
|
assert.equal(saved.stages[0].done, true)
|
||
|
|
assert.equal(saved.stages[1].done, undefined)
|
||
|
|
// Idempotent: a second call reports no change.
|
||
|
|
assert.equal(syncGoalDone({ source: file }, { id: 's1', title: 'One' }).changed, false)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('syncGoalDone falls back to a title match', () => {
|
||
|
|
const project = tmpProject()
|
||
|
|
const file = path.join(project, 'goal.json')
|
||
|
|
fs.writeFileSync(file, JSON.stringify({ title: 'G', stages: [{ title: 'Only by title' }] }))
|
||
|
|
const res = syncGoalDone({ source: file }, { id: 'missing', title: 'Only by title' })
|
||
|
|
assert.equal(res.changed, true)
|
||
|
|
assert.equal(JSON.parse(fs.readFileSync(file, 'utf8')).stages[0].done, true)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('syncGoalStage dispatches by file extension', () => {
|
||
|
|
const project = tmpProject()
|
||
|
|
const md = path.join(project, 'g.md')
|
||
|
|
fs.writeFileSync(md, '# G\n\n- [ ] One\n')
|
||
|
|
assert.equal(syncGoalStage({ source: md }, { title: 'One' }).ok, true)
|
||
|
|
const json = path.join(project, 'g.json')
|
||
|
|
fs.writeFileSync(json, JSON.stringify({ stages: [{ id: 's1', title: 'One' }] }))
|
||
|
|
assert.equal(syncGoalStage({ source: json }, { id: 's1', title: 'One' }).changed, true)
|
||
|
|
assert.equal(syncGoalStage({ source: '/tmp/other.txt' }, { title: 'One' }).ok, false)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('allStagesDone requires a non-empty goal where every stage is done', () => {
|
||
|
|
assert.equal(allStagesDone({ stages: [{ done: true }, { done: true }] }), true)
|
||
|
|
assert.equal(allStagesDone({ stages: [{ done: true }, { done: false }] }), false)
|
||
|
|
assert.equal(allStagesDone({ stages: [] }), false)
|
||
|
|
assert.equal(allStagesDone({}), false)
|
||
|
|
})
|