tspl-cli 0.0.0-stage → 0.1.1
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/README.md +127 -2
- package/bin/tspl.js +10 -0
- package/package.json +41 -4
- package/src/active-org.js +49 -0
- package/src/analysis/analyzer.js +61 -0
- package/src/analysis/extract.js +37 -0
- package/src/analysis/file-filter.js +69 -0
- package/src/analysis/git-reader.js +108 -0
- package/src/analysis/html-parser.js +46 -0
- package/src/analysis/js-parser.js +473 -0
- package/src/analysis/py-parser.js +66 -0
- package/src/analysis/repo-index.js +107 -0
- package/src/api-client.js +108 -0
- package/src/browser-tests.js +209 -0
- package/src/commands/analyze.command.js +81 -0
- package/src/commands/auth.command.js +250 -0
- package/src/commands/build.command.js +267 -0
- package/src/commands/connect.command.js +270 -0
- package/src/commands/engines.command.js +136 -0
- package/src/commands/find.command.js +107 -0
- package/src/commands/init.command.js +79 -0
- package/src/commands/link.command.js +65 -0
- package/src/commands/mcp.command.js +12 -0
- package/src/commands/member.command.js +82 -0
- package/src/commands/plan.command.js +161 -0
- package/src/commands/project.command.js +191 -0
- package/src/commands/setup.command.js +63 -0
- package/src/commands/sync.command.js +59 -0
- package/src/commands/task.command.js +475 -0
- package/src/commands/usage.command.js +105 -0
- package/src/config-store.js +255 -0
- package/src/connect/claude-skill.md +30 -0
- package/src/engines.js +218 -0
- package/src/find/graph.js +272 -0
- package/src/find/search.js +473 -0
- package/src/find/text.js +86 -0
- package/src/git-helper.js +102 -0
- package/src/index.js +91 -0
- package/src/mcp/server.js +708 -0
- package/src/project-config.js +197 -0
- package/src/project-init.js +447 -0
- package/src/providers/github.js +249 -0
- package/src/providers/index.js +83 -0
- package/src/runtime-check.js +18 -0
- package/src/setup.js +180 -0
- package/src/shared/approaches.js +44 -0
- package/src/shared/constants.js +89 -0
- package/src/shared/index.js +8 -0
- package/src/shared/workflow-progress.js +67 -0
- package/src/shared/workflow.js +381 -0
- package/src/stack-detector.js +47 -0
- package/src/task/approaches.js +53 -0
- package/src/task/branch-choice.js +69 -0
- package/src/task/brief.js +115 -0
- package/src/task/build.js +549 -0
- package/src/task/check.js +415 -0
- package/src/task/conventions.js +113 -0
- package/src/task/gate.js +88 -0
- package/src/task/hooks.js +82 -0
- package/src/task/plan-draft.js +43 -0
- package/src/task/plan-page.js +123 -0
- package/src/task/plan.js +293 -0
- package/src/task/planning-context.js +165 -0
- package/src/task/refine.js +73 -0
- package/src/workflow/agents.js +336 -0
- package/src/workflow/checkout-lock.js +92 -0
- package/src/workflow/client.js +82 -0
- package/src/workflow/controller.js +585 -0
- package/src/workflow/evidence.js +276 -0
- package/src/workflow/pr-body.js +52 -0
- package/src/workflow/process.js +82 -0
- package/src/workflow/review.js +128 -0
- package/src/workflow/runtime.js +148 -0
- package/src/workflow/wait.js +58 -0
- package/src/workspace.js +250 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Path = require('path')
|
|
5
|
+
const { TEST_CATEGORIES, checkHash } = require('../shared')
|
|
6
|
+
|
|
7
|
+
const { runProcess } = require('./process')
|
|
8
|
+
|
|
9
|
+
const OUTPUT_TAIL = 4000
|
|
10
|
+
const SECRET = /(bearer\s+)[\w.~+/=-]+|\b(gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{16,}|xox[abp]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})\b|((?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*)\S+/gi
|
|
11
|
+
// eslint-disable-next-line no-control-regex
|
|
12
|
+
const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
|
|
13
|
+
|
|
14
|
+
const sanitize = (text, replacements = []) => {
|
|
15
|
+
let out = String(text || '').replace(ANSI, '').replace(/\r/g, '')
|
|
16
|
+
replacements.filter(([from]) => from).forEach(([from, to]) => { out = out.split(from).join(to) })
|
|
17
|
+
out = out.replace(SECRET, (match, bearer, token, assignment) => (bearer ? `${bearer}[redacted]` : assignment ? `${assignment}[redacted]` : '[redacted]'))
|
|
18
|
+
return out.length > OUTPUT_TAIL ? `…${out.slice(out.length - OUTPUT_TAIL)}` : out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const git = (dir, args) => {
|
|
22
|
+
const result = require('child_process').spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' })
|
|
23
|
+
return result.status === 0 ? result.stdout.trim() : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The current commit, branch and cleanliness of each repo of a build. build = { branch, repos: [{ name, baseSha|base }] } */
|
|
27
|
+
const captureHeads = (paths, build) =>
|
|
28
|
+
(build.repos || []).map(entry => {
|
|
29
|
+
const dir = paths.get(entry.name)
|
|
30
|
+
const headSha = dir ? git(dir, ['rev-parse', 'HEAD']) : null
|
|
31
|
+
const status = dir ? git(dir, ['status', '--porcelain', '--untracked-files=all']) : null
|
|
32
|
+
return {
|
|
33
|
+
name: entry.name,
|
|
34
|
+
baseSha: entry.baseSha || entry.base || null,
|
|
35
|
+
headSha,
|
|
36
|
+
branch: dir ? git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']) : null,
|
|
37
|
+
clean: Boolean(dir) && status === '',
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const number = (text, pattern) => {
|
|
42
|
+
const all = [...String(text).matchAll(pattern)]
|
|
43
|
+
return all.length ? Number(all[all.length - 1][1]) : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const CASE_TAG = /\bTC\s*-?\s*(\d+)\b/i
|
|
47
|
+
const MEDIA = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.webm': 'video/webm', '.mp4': 'video/mp4' }
|
|
48
|
+
const MAX_FILES = 30
|
|
49
|
+
const MAX_FILE_BYTES = 10 * 1024 * 1024
|
|
50
|
+
|
|
51
|
+
/** "TC2: blank email shows an error" belongs to done-when item 2. */
|
|
52
|
+
const criterionOf = title => {
|
|
53
|
+
const match = CASE_TAG.exec(String(title || ''))
|
|
54
|
+
return match ? Number(match[1]) : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Individual test results from node's TAP report, so each done-when item can show its own pass or fail. */
|
|
58
|
+
const nodeCases = text =>
|
|
59
|
+
[...String(text).matchAll(/^\s*(not ok|ok) \d+ - (.+?)(?:\s+#\s*(SKIP|TODO)\b.*)?$/gm)]
|
|
60
|
+
.filter(match => !/\.[cm]?[jt]s$/.test(match[2].trim()))
|
|
61
|
+
.map(match => ({ title: match[2].trim(), status: match[3] ? 'skipped' : match[1] === 'ok' ? 'passed' : 'failed', criterion: criterionOf(match[2]) }))
|
|
62
|
+
.slice(0, 300)
|
|
63
|
+
|
|
64
|
+
const readPlaywright = text => {
|
|
65
|
+
const source = String(text || '')
|
|
66
|
+
const start = source.indexOf('{')
|
|
67
|
+
const end = source.lastIndexOf('}')
|
|
68
|
+
if (start === -1 || end <= start) return null
|
|
69
|
+
try {
|
|
70
|
+
const data = JSON.parse(source.slice(start, end + 1))
|
|
71
|
+
return data && data.stats ? data : null
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Specs of a Playwright JSON report with their final status and attached screenshots or videos. */
|
|
78
|
+
const playwrightCases = data => {
|
|
79
|
+
const cases = []
|
|
80
|
+
const attachments = []
|
|
81
|
+
const walk = (suite, parents) => {
|
|
82
|
+
const titles = suite.title ? [...parents, suite.title] : parents
|
|
83
|
+
;(suite.specs || []).forEach(spec => {
|
|
84
|
+
const title = [...titles, spec.title].filter(Boolean).join(' › ')
|
|
85
|
+
const results = (spec.tests || []).flatMap(item => item.results || [])
|
|
86
|
+
const last = results[results.length - 1] || {}
|
|
87
|
+
const status = last.status === 'passed' ? 'passed' : last.status === 'skipped' || !results.length ? 'skipped' : 'failed'
|
|
88
|
+
const criterion = criterionOf(title)
|
|
89
|
+
cases.push({ title, status, criterion })
|
|
90
|
+
results.forEach(result => (result.attachments || []).filter(item => item.path).forEach(item => attachments.push({ path: item.path, name: item.name, contentType: item.contentType, criterion, step: title })))
|
|
91
|
+
})
|
|
92
|
+
;(suite.suites || []).forEach(child => walk(child, titles))
|
|
93
|
+
}
|
|
94
|
+
;(data.suites || []).forEach(suite => walk(suite, []))
|
|
95
|
+
return { cases: cases.slice(0, 300), attachments }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Images and videos a check left in its artifacts folder during this run. */
|
|
99
|
+
const collectArtifacts = (dir, sinceMs) => {
|
|
100
|
+
const found = []
|
|
101
|
+
const visit = folder => {
|
|
102
|
+
let entries = []
|
|
103
|
+
try {
|
|
104
|
+
entries = Fs.readdirSync(folder, { withFileTypes: true })
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
entries.forEach(entry => {
|
|
109
|
+
const file = Path.join(folder, entry.name)
|
|
110
|
+
if (entry.isDirectory()) return visit(file)
|
|
111
|
+
const type = MEDIA[Path.extname(entry.name).toLowerCase()]
|
|
112
|
+
if (!type) return
|
|
113
|
+
const stat = Fs.statSync(file)
|
|
114
|
+
if (stat.mtimeMs + 1000 < sinceMs) return
|
|
115
|
+
const relative = Path.relative(dir, file)
|
|
116
|
+
found.push({ path: file, name: entry.name, contentType: type, criterion: criterionOf(relative), step: Path.dirname(relative) === '.' ? null : Path.dirname(relative) })
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
visit(dir)
|
|
120
|
+
return found
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Keeps the uploadable files: known media types, not too big, a bounded number. */
|
|
124
|
+
const usableAttachments = list =>
|
|
125
|
+
list
|
|
126
|
+
.filter(item => MEDIA[Path.extname(item.path).toLowerCase()] || /^(image|video)\//.test(item.contentType || ''))
|
|
127
|
+
.filter(item => {
|
|
128
|
+
try {
|
|
129
|
+
const size = Fs.statSync(item.path).size
|
|
130
|
+
return size > 0 && size <= MAX_FILE_BYTES
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.map(item => ({ ...item, contentType: MEDIA[Path.extname(item.path).toLowerCase()] || item.contentType }))
|
|
136
|
+
.slice(0, MAX_FILES)
|
|
137
|
+
|
|
138
|
+
/** Counts executed tests from a runner's own report. Returns null when the report cannot be read. */
|
|
139
|
+
const parseResults = (parser, stdout, stderr) => {
|
|
140
|
+
const text = `${stdout}\n${stderr}`
|
|
141
|
+
if (parser === 'playwright-json') {
|
|
142
|
+
const data = readPlaywright(stdout)
|
|
143
|
+
if (!data) return null
|
|
144
|
+
const stats = data.stats
|
|
145
|
+
const failed = (stats.unexpected || 0)
|
|
146
|
+
const { cases, attachments } = playwrightCases(data)
|
|
147
|
+
return { testCount: (stats.expected || 0) + failed + (stats.flaky || 0), failed, skipped: stats.skipped || 0, cases, attachments }
|
|
148
|
+
}
|
|
149
|
+
if (parser === 'node-tap') {
|
|
150
|
+
const total = number(text, /^# tests (\d+)/gm) ?? number(text, /^ℹ tests (\d+)/gm)
|
|
151
|
+
if (total === null) return null
|
|
152
|
+
const skipped = number(text, /^# skipped (\d+)/gm) ?? number(text, /^ℹ skipped (\d+)/gm) ?? 0
|
|
153
|
+
const todo = number(text, /^# todo (\d+)/gm) ?? number(text, /^ℹ todo (\d+)/gm) ?? 0
|
|
154
|
+
const cancelled = number(text, /^# cancelled (\d+)/gm) ?? number(text, /^ℹ cancelled (\d+)/gm) ?? 0
|
|
155
|
+
const failed = number(text, /^# fail (\d+)/gm) ?? number(text, /^ℹ fail (\d+)/gm) ?? 0
|
|
156
|
+
// Node reports a test file that registered no tests as one passing "test" named after the file.
|
|
157
|
+
const emptyFiles = [...text.matchAll(/^# Subtest: (\S+\.[cm]?[jt]s)\nok \d+ - \1$/gm)].length
|
|
158
|
+
return { testCount: Math.max(0, total - skipped - todo - cancelled - emptyFiles), failed: failed + cancelled, skipped, cases: nodeCases(text) }
|
|
159
|
+
}
|
|
160
|
+
if (parser === 'jest-json') {
|
|
161
|
+
const json = text.match(/\{[^{}]*"numTotalTests"\s*:\s*\d+[\s\S]*?\}(?=\s*$)/m) || text.match(/\{[\s\S]*"numTotalTests"[\s\S]*\}/)
|
|
162
|
+
if (json) {
|
|
163
|
+
try {
|
|
164
|
+
const data = JSON.parse(json[0])
|
|
165
|
+
return { testCount: (data.numPassedTests || 0) + (data.numFailedTests || 0), failed: data.numFailedTests || 0, skipped: (data.numPendingTests || 0) + (data.numTodoTests || 0) }
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// fall through to the summary line
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const line = text.match(/^Tests:\s+(.*)$/m)
|
|
171
|
+
if (!line) return null
|
|
172
|
+
const count = name => Number((line[1].match(new RegExp(`(\\d+) ${name}`)) || [0, 0])[1])
|
|
173
|
+
return { testCount: count('passed') + count('failed'), failed: count('failed'), skipped: count('skipped') + count('todo') }
|
|
174
|
+
}
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const insideRepo = (repoDir, cwd) => {
|
|
179
|
+
try {
|
|
180
|
+
const real = Fs.realpathSync(Path.resolve(repoDir, cwd))
|
|
181
|
+
const repoReal = Fs.realpathSync(repoDir)
|
|
182
|
+
return real === repoReal || real.startsWith(`${repoReal}${Path.sep}`) ? real : null
|
|
183
|
+
} catch (err) {
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const headChanged = (before, after) =>
|
|
189
|
+
!after || after.headSha !== before.headSha || after.branch !== before.branch || after.clean !== true
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Runs the approved checks against the captured heads and returns head-bound evidence.
|
|
193
|
+
* Nothing here trusts an AI report: a check passes only when it ran, exited 0 and (for tests) the runner counted tests.
|
|
194
|
+
* deps: { run, now, capture } for tests; defaults run real processes.
|
|
195
|
+
*/
|
|
196
|
+
const runChecks = async ({ snapshot, heads, paths, signal, build }, deps = {}) => {
|
|
197
|
+
const run = deps.run || runProcess
|
|
198
|
+
const now = deps.now || (() => new Date().toISOString())
|
|
199
|
+
const capture = deps.capture || captureHeads
|
|
200
|
+
const evidence = []
|
|
201
|
+
const redactions = [...paths.entries()].map(([name, dir]) => [dir, `<${name}>`])
|
|
202
|
+
|
|
203
|
+
for (const check of snapshot.checks.filter(item => item.required)) {
|
|
204
|
+
const head = heads.find(item => item.name === check.repo)
|
|
205
|
+
const dir = paths.get(check.repo)
|
|
206
|
+
const base = {
|
|
207
|
+
checkId: check.id,
|
|
208
|
+
repo: check.repo,
|
|
209
|
+
headSha: head ? head.headSha : null,
|
|
210
|
+
configHash: snapshot.configHash,
|
|
211
|
+
commandHash: checkHash(check),
|
|
212
|
+
status: 'missing',
|
|
213
|
+
exitCode: null,
|
|
214
|
+
testCount: null,
|
|
215
|
+
executed: false,
|
|
216
|
+
source: 'cli',
|
|
217
|
+
startedAt: now(),
|
|
218
|
+
finishedAt: null,
|
|
219
|
+
output: '',
|
|
220
|
+
}
|
|
221
|
+
const done = (patch, output) => evidence.push({ ...base, ...patch, finishedAt: now(), output: sanitize(output, redactions) })
|
|
222
|
+
|
|
223
|
+
if (signal && signal.aborted) { done({ status: 'cancelled' }, 'Cancelled before it started.'); continue }
|
|
224
|
+
if (!head || !dir) { done({ status: 'missing' }, `Repo ${check.repo} is not available on this machine.`); continue }
|
|
225
|
+
const current = capture(paths, { branch: build && build.branch, repos: [head] })[0]
|
|
226
|
+
if (headChanged(head, current)) {
|
|
227
|
+
done({ status: 'stale' }, current && current.clean === false ? 'The repo has uncommitted changes, so this commit is not what would be tested.' : 'The branch or commit moved since the heads were captured.')
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
if (build && build.branch && current.branch !== build.branch) { done({ status: 'stale' }, `Expected branch ${build.branch} but the repo is on ${current.branch}.`); continue }
|
|
231
|
+
const cwd = insideRepo(dir, check.cwd)
|
|
232
|
+
if (!cwd) { done({ status: 'missing' }, `Working directory ${check.cwd} does not exist inside ${check.repo}.`); continue }
|
|
233
|
+
const isTest = TEST_CATEGORIES.includes(check.category)
|
|
234
|
+
if (isTest && check.parser === 'custom' && check.attested !== true) { done({ status: 'missing' }, 'A custom test command must be attested in the project file before it counts.'); continue }
|
|
235
|
+
|
|
236
|
+
base.startedAt = now()
|
|
237
|
+
const result = await run('sh', ['-c', check.command], { cwd, timeoutMs: check.timeoutMs, signal, env: { CI: '1', NODE_TEST_CONTEXT: undefined } })
|
|
238
|
+
const after = capture(paths, { branch: build && build.branch, repos: [head] })[0]
|
|
239
|
+
const output = `${result.stdout}\n${result.stderr}${result.error ? `\n${result.error}` : ''}`.trim()
|
|
240
|
+
const patch = { executed: !result.error, exitCode: result.exitCode }
|
|
241
|
+
const startedMs = Date.parse(base.startedAt) || Date.now()
|
|
242
|
+
const files = check.artifacts ? collectArtifacts(Path.join(cwd, check.artifacts), startedMs) : []
|
|
243
|
+
if (files.length) patch.attachments = usableAttachments(files)
|
|
244
|
+
const counted = isTest && check.parser !== 'custom' ? parseResults(check.parser, result.stdout, result.stderr) : null
|
|
245
|
+
if (counted) {
|
|
246
|
+
patch.cases = counted.cases || []
|
|
247
|
+
patch.attachments = usableAttachments([...(counted.attachments || []), ...files.filter(file => !(counted.attachments || []).some(item => item.path === file.path))])
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (headChanged(head, after)) { done({ ...patch, status: 'stale' }, `The commit or worktree changed while the check ran.\n${output}`); continue }
|
|
251
|
+
if (result.cancelled) { done({ ...patch, status: 'cancelled' }, output); continue }
|
|
252
|
+
if (result.timedOut) { done({ ...patch, status: 'timed-out' }, output); continue }
|
|
253
|
+
if (result.error || result.exitCode !== 0) { done({ ...patch, status: 'failed' }, output); continue }
|
|
254
|
+
|
|
255
|
+
if (isTest && check.parser !== 'custom') {
|
|
256
|
+
if (!counted) { done({ ...patch, status: 'failed' }, `tspl could not read the ${check.parser} test results, so the check cannot count as passed.\n${output}`); continue }
|
|
257
|
+
if (counted.failed > 0) { done({ ...patch, status: 'failed', testCount: counted.testCount }, output); continue }
|
|
258
|
+
if (counted.testCount === 0) { done({ ...patch, status: 'failed', testCount: 0 }, `The check ran zero tests${counted.skipped ? ` (${counted.skipped} skipped)` : ''}; that is not evidence.\n${output}`); continue }
|
|
259
|
+
done({ ...patch, status: 'passed', testCount: counted.testCount }, output)
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
done({ ...patch, status: 'passed' }, output)
|
|
263
|
+
}
|
|
264
|
+
return evidence
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
criterionOf,
|
|
269
|
+
collectArtifacts,
|
|
270
|
+
usableAttachments,
|
|
271
|
+
OUTPUT_TAIL,
|
|
272
|
+
sanitize,
|
|
273
|
+
captureHeads,
|
|
274
|
+
parseResults,
|
|
275
|
+
runChecks,
|
|
276
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const START = '<!-- tspl:start -->'
|
|
4
|
+
const END = '<!-- tspl:end -->'
|
|
5
|
+
|
|
6
|
+
const row = (...cells) => `| ${cells.join(' | ')} |`
|
|
7
|
+
|
|
8
|
+
/** The tspl-managed section of a pull request description: what was asked, what ran on which commit, what review said. */
|
|
9
|
+
const renderPrBody = ({ task, summary, repo, head, evidence = [], criteria = [], review = null, limitations = [], ci = null }) => {
|
|
10
|
+
const lines = [
|
|
11
|
+
START,
|
|
12
|
+
`## ${task.card && task.card.ticket ? `${task.card.ticket} · ` : ''}${(task.card && task.card.what) || task.id}`,
|
|
13
|
+
'',
|
|
14
|
+
`tspl task \`${task.id || task.key}\` · repo \`${repo}\` · tested commit \`${head.headSha}\` (base \`${head.baseSha}\`)`,
|
|
15
|
+
'',
|
|
16
|
+
summary ? `${summary}\n` : '',
|
|
17
|
+
'### Acceptance',
|
|
18
|
+
...(criteria.length ? criteria.map(item => `- ${item.text}: ${item.how}`) : ['- (no done-when items)']),
|
|
19
|
+
'',
|
|
20
|
+
'### Checks run on this commit (by the tspl CLI on the developer machine)',
|
|
21
|
+
row('Check', 'Result', 'Tests'),
|
|
22
|
+
row('---', '---', '---'),
|
|
23
|
+
...evidence.map(item => row(item.checkId, item.status, item.testCount === null || item.testCount === undefined ? '-' : item.testCount)),
|
|
24
|
+
'',
|
|
25
|
+
'### Separate review',
|
|
26
|
+
review
|
|
27
|
+
? `${review.verdict === 'passed' ? 'Passed' : 'Changes requested'} by ${review.engine || 'an AI reviewer'} in a fresh session on \`${review.heads.map(head => head.headSha).join(', ')}\`.${review.findings.length ? `\n${review.findings.map(item => `- [${item.severity}] ${item.file ? `${item.file}${item.line ? `:${item.line}` : ''}: ` : ''}${item.message}`).join('\n')}` : ' No findings.'}`
|
|
28
|
+
: 'No review recorded.',
|
|
29
|
+
'',
|
|
30
|
+
'### Required CI',
|
|
31
|
+
ci || 'Reported by the provider after the PR is opened; this section does not claim it.',
|
|
32
|
+
...(limitations.length ? ['', '### Limitations', ...limitations.map(item => `- ${item}`)] : []),
|
|
33
|
+
END,
|
|
34
|
+
]
|
|
35
|
+
return lines.join('\n')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Replaces only the tspl section; text a person wrote above or below it stays. */
|
|
39
|
+
const mergeBody = (existing, managed) => {
|
|
40
|
+
const body = String(existing || '')
|
|
41
|
+
const start = body.indexOf(START)
|
|
42
|
+
const end = body.indexOf(END)
|
|
43
|
+
if (start !== -1 && end > start) return `${body.slice(0, start)}${managed}${body.slice(end + END.length)}`
|
|
44
|
+
return `${body.trimEnd()}${body.trim() ? '\n\n' : ''}${managed}\n`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = {
|
|
48
|
+
START,
|
|
49
|
+
END,
|
|
50
|
+
renderPrBody,
|
|
51
|
+
mergeBody,
|
|
52
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process')
|
|
4
|
+
|
|
5
|
+
const DEFAULT_MAX_OUTPUT = 64 * 1024
|
|
6
|
+
const KILL_GRACE_MS = 2000
|
|
7
|
+
|
|
8
|
+
const keepTail = (current, chunk, max) => {
|
|
9
|
+
const next = current + chunk
|
|
10
|
+
return next.length > max ? next.slice(next.length - max) : next
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const killGroup = (child, signal) => {
|
|
14
|
+
try {
|
|
15
|
+
process.kill(-child.pid, signal)
|
|
16
|
+
} catch (err) {
|
|
17
|
+
try {
|
|
18
|
+
child.kill(signal)
|
|
19
|
+
} catch (ignored) {
|
|
20
|
+
// already gone
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Runs a command with a timeout and cancellation. The child gets its own process group so a timeout or an abort
|
|
27
|
+
* ends everything it started. Only the last maxOutputBytes of each stream are kept.
|
|
28
|
+
* Resolves (never rejects) with { exitCode, signal, stdout, stderr, timedOut, cancelled, error }.
|
|
29
|
+
*/
|
|
30
|
+
const runProcess = (command, args = [], { cwd, timeoutMs, signal, input, maxOutputBytes = DEFAULT_MAX_OUTPUT, env } = {}) =>
|
|
31
|
+
new Promise(resolve => {
|
|
32
|
+
if (signal && signal.aborted) {
|
|
33
|
+
resolve({ exitCode: null, signal: null, stdout: '', stderr: '', timedOut: false, cancelled: true, error: null })
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
let stdout = ''
|
|
37
|
+
let stderr = ''
|
|
38
|
+
let timedOut = false
|
|
39
|
+
let cancelled = false
|
|
40
|
+
let settled = false
|
|
41
|
+
let forceTimer = null
|
|
42
|
+
const childEnv = { ...process.env, ...(env || {}) }
|
|
43
|
+
Object.keys(childEnv).forEach(key => childEnv[key] === undefined && delete childEnv[key])
|
|
44
|
+
const child = spawn(command, args, {
|
|
45
|
+
cwd,
|
|
46
|
+
env: childEnv,
|
|
47
|
+
detached: true,
|
|
48
|
+
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const stop = () => {
|
|
52
|
+
killGroup(child, 'SIGTERM')
|
|
53
|
+
forceTimer = setTimeout(() => killGroup(child, 'SIGKILL'), KILL_GRACE_MS)
|
|
54
|
+
}
|
|
55
|
+
const timer = timeoutMs ? setTimeout(() => { timedOut = true; stop() }, timeoutMs) : null
|
|
56
|
+
const onAbort = () => { cancelled = true; stop() }
|
|
57
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true })
|
|
58
|
+
|
|
59
|
+
const finish = result => {
|
|
60
|
+
if (settled) return
|
|
61
|
+
settled = true
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
clearTimeout(forceTimer)
|
|
64
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
65
|
+
killGroup(child, 'SIGKILL')
|
|
66
|
+
resolve({ stdout, stderr, timedOut, cancelled, error: null, ...result })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
child.stdout.on('data', chunk => { stdout = keepTail(stdout, chunk.toString('utf8'), maxOutputBytes) })
|
|
70
|
+
child.stderr.on('data', chunk => { stderr = keepTail(stderr, chunk.toString('utf8'), maxOutputBytes) })
|
|
71
|
+
child.on('error', err => finish({ exitCode: null, signal: null, error: err.message }))
|
|
72
|
+
child.on('close', (code, sig) => finish({ exitCode: code, signal: sig }))
|
|
73
|
+
if (input !== undefined) {
|
|
74
|
+
child.stdin.on('error', () => {})
|
|
75
|
+
child.stdin.end(input)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
DEFAULT_MAX_OUTPUT,
|
|
81
|
+
runProcess,
|
|
82
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process')
|
|
4
|
+
|
|
5
|
+
const { AgentError, checkCapability, invoke, extractJson, newSessionId, testCaseLines } = require('./agents')
|
|
6
|
+
const { captureHeads } = require('./evidence')
|
|
7
|
+
|
|
8
|
+
const MAX_DIFF = 60 * 1024
|
|
9
|
+
const SEVERITIES = ['low', 'medium', 'high', 'critical']
|
|
10
|
+
|
|
11
|
+
const REVIEW_SCHEMA = {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
verdict: { type: 'string', enum: ['passed', 'changes-requested'] },
|
|
15
|
+
heads: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, headSha: { type: 'string' } }, required: ['name', 'headSha'] } },
|
|
16
|
+
findings: {
|
|
17
|
+
type: 'array',
|
|
18
|
+
items: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: { severity: { type: 'string', enum: SEVERITIES }, repo: { type: 'string' }, file: { type: 'string' }, line: { type: 'number' }, message: { type: 'string' } },
|
|
21
|
+
required: ['severity', 'message'],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
required: ['verdict', 'heads', 'findings'],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const diffFor = (dir, head) => {
|
|
29
|
+
const result = spawnSync('git', ['-C', dir, 'diff', `${head.baseSha}...${head.headSha}`], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
|
|
30
|
+
const diff = result.status === 0 ? result.stdout : '(the diff could not be read)'
|
|
31
|
+
return diff.length > MAX_DIFF ? `${diff.slice(0, MAX_DIFF)}\n… (diff cut at ${MAX_DIFF} bytes; read the files for the rest)` : diff
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const reviewPrompt = ({ task, instructions, heads, paths, evidence }) =>
|
|
35
|
+
[
|
|
36
|
+
'You are an independent reviewer. You did not write this change. Judge it only from the task, the approved scope, the diff, the project rules and the test results below. Do NOT edit any file.',
|
|
37
|
+
'',
|
|
38
|
+
`TASK ${task.id || task.key}: ${(task.card && task.card.what) || task.summary || ''}`,
|
|
39
|
+
'TEST CASES (check that each one is really proven by a test named TC<n>, or left for a person)',
|
|
40
|
+
...testCaseLines(task.card),
|
|
41
|
+
'',
|
|
42
|
+
'APPROVED SCOPE',
|
|
43
|
+
...['edit', 'create', 'remove'].flatMap(kind => ((task.plan && task.plan[kind]) || []).map(item => ` ${kind}: ${item.file}`)),
|
|
44
|
+
instructions ? `\nPROJECT RULES\n${instructions}` : '',
|
|
45
|
+
'',
|
|
46
|
+
'TEST EVIDENCE',
|
|
47
|
+
...(evidence || []).flatMap(item => [
|
|
48
|
+
` ${item.checkId}: ${item.status}${item.testCount ? `, ${item.testCount} tests` : ''}`,
|
|
49
|
+
...(item.cases || []).filter(entry => entry.criterion).map(entry => ` ${entry.title}: ${entry.status}`),
|
|
50
|
+
]),
|
|
51
|
+
'',
|
|
52
|
+
...heads.flatMap(head => [`COMMITS ${head.name}: ${head.baseSha}..${head.headSha}`, '```diff', diffFor(paths.get(head.name), head), '```', '']),
|
|
53
|
+
'Reply with JSON only: {"verdict": "passed" | "changes-requested", "heads": [{"name": "<repo>", "headSha": "<the exact head sha above>"}], "findings": [{"severity": "low|medium|high|critical", "repo": "", "file": "", "line": 0, "message": ""}]}.',
|
|
54
|
+
'Use "passed" only when nothing blocks merging. Bugs, missing tests, scope creep and rule violations are findings.',
|
|
55
|
+
].join('\n')
|
|
56
|
+
|
|
57
|
+
const validateReview = (value, heads) => {
|
|
58
|
+
if (!value || typeof value !== 'object') throw new AgentError('invalid-output', 'The review was not a JSON object')
|
|
59
|
+
if (!['passed', 'changes-requested'].includes(value.verdict)) throw new AgentError('invalid-output', 'The review has no valid verdict')
|
|
60
|
+
if (!Array.isArray(value.heads) || !Array.isArray(value.findings)) throw new AgentError('invalid-output', 'The review needs "heads" and "findings" lists')
|
|
61
|
+
value.findings.forEach(finding => {
|
|
62
|
+
if (!finding || typeof finding.message !== 'string' || !finding.message.trim()) throw new AgentError('invalid-output', 'A finding has no message')
|
|
63
|
+
if (finding.severity && !SEVERITIES.includes(finding.severity)) throw new AgentError('invalid-output', `Unknown finding severity ${finding.severity}`)
|
|
64
|
+
})
|
|
65
|
+
heads.forEach(head => {
|
|
66
|
+
const seen = value.heads.find(item => item && item.name === head.name)
|
|
67
|
+
if (!seen || seen.headSha !== head.headSha) {
|
|
68
|
+
throw new AgentError('stale', `The review is for ${seen ? seen.headSha : 'no commit'} of ${head.name}, not the tested commit ${head.headSha}`)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
if (value.verdict === 'passed' && value.findings.some(finding => ['high', 'critical'].includes(finding.severity))) {
|
|
72
|
+
throw new AgentError('invalid-output', 'The review passed the change but lists a high-severity finding')
|
|
73
|
+
}
|
|
74
|
+
return value
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sameState = (before, after) =>
|
|
78
|
+
before.length === after.length &&
|
|
79
|
+
before.every((head, i) => head.headSha === after[i].headSha && head.branch === after[i].branch && head.clean === after[i].clean)
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Reviews the tested commits in a brand-new session. Anything but a schema-valid verdict on exactly these commits is a
|
|
83
|
+
* blocker (thrown as AgentError), never an approval. Edits made during the review invalidate it; they are reported, not undone.
|
|
84
|
+
*/
|
|
85
|
+
const reviewTask = async (context, deps = {}) => {
|
|
86
|
+
checkCapability(context.engine, 'review')
|
|
87
|
+
const build = { repos: context.heads }
|
|
88
|
+
const current = (deps.capture || captureHeads)(context.paths, build)
|
|
89
|
+
context.heads.forEach(head => {
|
|
90
|
+
const now = current.find(item => item.name === head.name)
|
|
91
|
+
if (!now || now.headSha !== head.headSha) throw new AgentError('stale', `${head.name} is no longer at the tested commit ${head.headSha}; test again before reviewing`)
|
|
92
|
+
if (!now.clean) throw new AgentError('stale', `${head.name} has uncommitted changes; the review needs a clean, committed branch`)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const sessionId = newSessionId()
|
|
96
|
+
if (context.buildSessionId && context.buildSessionId === sessionId) throw new AgentError('invalid-output', 'The review must not reuse the coding session')
|
|
97
|
+
const dirs = [...context.paths.values()]
|
|
98
|
+
const result = await invoke(context.engine, 'review', {
|
|
99
|
+
prompt: reviewPrompt(context), model: context.model, dirs, sessionId, schema: REVIEW_SCHEMA, timeoutMs: context.timeoutMs, signal: context.signal,
|
|
100
|
+
}, deps)
|
|
101
|
+
|
|
102
|
+
if (context.buildSessionId && result.sessionId === context.buildSessionId) {
|
|
103
|
+
throw new AgentError('invalid-output', 'The review ran in the coding session; it must be a separate session')
|
|
104
|
+
}
|
|
105
|
+
const after = (deps.capture || captureHeads)(context.paths, build)
|
|
106
|
+
if (!sameState(current, after)) {
|
|
107
|
+
throw new AgentError('modified-worktree', 'The reviewer changed the repository. The review does not count; nothing was discarded, so look at git status.')
|
|
108
|
+
}
|
|
109
|
+
const reply = extractJson(result.text)
|
|
110
|
+
if (!reply) throw new AgentError('invalid-output', 'The review reply had no JSON')
|
|
111
|
+
validateReview(reply, context.heads)
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
heads: context.heads.map(head => ({ name: head.name, headSha: head.headSha })),
|
|
115
|
+
verdict: reply.verdict,
|
|
116
|
+
findings: reply.findings.map(finding => ({ severity: finding.severity || 'medium', repo: finding.repo || null, file: finding.file || null, line: Number.isInteger(finding.line) ? finding.line : null, message: finding.message })),
|
|
117
|
+
engine: context.engine,
|
|
118
|
+
model: context.model || null,
|
|
119
|
+
sessionId: result.sessionId || sessionId,
|
|
120
|
+
reviewedAt: new Date().toISOString(),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
REVIEW_SCHEMA,
|
|
126
|
+
validateReview,
|
|
127
|
+
reviewTask,
|
|
128
|
+
}
|