@skitterbyte/skitterspec 22.0.0 → 22.2.0
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 +14 -0
- package/assets/claude-md-section.md +13 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/env.config.json.example +2 -1
- package/assets/core/env.config.md +83 -2
- package/assets/hooks/main-guard.cjs +145 -0
- package/assets/hooks/review-gate.cjs +8 -3
- package/assets/review/page.html +132 -2
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +160 -11
- package/assets/rules/spec-reports.md +66 -9
- package/assets/skills/no-spec/SKILL.md +221 -0
- package/assets/skills/spec/SKILL.md +138 -37
- package/assets/skills/spec-bug/SKILL.md +8 -2
- package/assets/skills/spec-cancel/SKILL.md +29 -0
- package/assets/skills/spec-complete/SKILL.md +25 -0
- package/assets/skills/spec-diff/SKILL.md +36 -2
- package/assets/skills/spec-init/SKILL.md +4 -2
- package/assets/skills/spec-next/SKILL.md +50 -3
- package/assets/skills/spec-reviewed/SKILL.md +3 -3
- package/assets/skills/spec-start/SKILL.md +14 -0
- package/package.json +1 -1
- package/src/cli.js +749 -62
- package/src/env/config.js +121 -4
- package/src/env/hooks.js +102 -33
- package/src/env/live.js +30 -1
- package/src/env/mainguard.js +240 -0
- package/src/env/provision.js +106 -10
- package/src/env/registry.js +70 -6
- package/src/env/resolve.js +113 -4
- package/src/env/review.js +324 -20
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +189 -44
- package/src/env/teardown.js +12 -1
- package/src/init.js +125 -15
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run the project's configured code reviewers against a spec's diff, and turn
|
|
5
|
+
* what they print into CHECKS for the review page.
|
|
6
|
+
*
|
|
7
|
+
* The whole module is a seam. Skitterspec vendors no reviewer for the same
|
|
8
|
+
* reason it vendors no tracker and no commit skill: the project picks. What
|
|
9
|
+
* lives here is the contract — how a command is spawned, what it is allowed to
|
|
10
|
+
* print, and what is said about it when it does not answer.
|
|
11
|
+
*
|
|
12
|
+
* TWO THINGS IT NEVER DOES, and both are load-bearing:
|
|
13
|
+
*
|
|
14
|
+
* - **It never produces a COMMENT.** A comment is something a person asked for,
|
|
15
|
+
* and `judgeVerdict` refuses a committing verdict while one is open. Twelve
|
|
16
|
+
* machine findings are not twelve requests, so they arrive as checks, which
|
|
17
|
+
* gate nothing. A reader replying to one creates the comment, and that does
|
|
18
|
+
* gate — because now someone asked.
|
|
19
|
+
* - **It never fails the render.** A reviewer that could not run is an outcome
|
|
20
|
+
* with a reason on it, never a throw and never a non-zero exit. The page is
|
|
21
|
+
* the record of the diff; a reviewer is an opinion about it.
|
|
22
|
+
*
|
|
23
|
+
* AND ONE PLACE IT INVERTS THE USUAL RULE. `.claude/rules/negative-checks.md`
|
|
24
|
+
* routes cannot-tell to silence, and here silence is the unsafe branch: a
|
|
25
|
+
* reviewer that was rate-limited renders identically to one that read the diff
|
|
26
|
+
* and found nothing, on the page a commit decision is made from. So every
|
|
27
|
+
* configured reviewer gets an outcome whatever happened to it — a positive
|
|
28
|
+
* signal about the RUN, which is not an accusation about the code.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs')
|
|
32
|
+
const path = require('node:path')
|
|
33
|
+
const crypto = require('node:crypto')
|
|
34
|
+
const { spawn } = require('node:child_process')
|
|
35
|
+
|
|
36
|
+
const BUNDLED_ADAPTERS = { coderabbit: require('./reviewers/coderabbit.js') }
|
|
37
|
+
|
|
38
|
+
const CHECKS_VERSION = 1
|
|
39
|
+
|
|
40
|
+
// Beside the page, the notes, the pending store and the gate, under gitignored
|
|
41
|
+
// `.spec-env/`. Named once so every reader spells it the same way.
|
|
42
|
+
const CHECKS_SUFFIX = '.checks.json'
|
|
43
|
+
|
|
44
|
+
// What a reviewer's run ended as. Five states rather than two, because "it
|
|
45
|
+
// printed nothing" has at least three readings and only one of them is clean.
|
|
46
|
+
const OUTCOME_STATES = ['findings', 'clean', 'cached', 'failed', 'timeout', 'missing']
|
|
47
|
+
|
|
48
|
+
// Past this, a reviewer is printing something other than findings — a help
|
|
49
|
+
// text, an HTML error page, a stack trace — and parsing megabytes of it buys
|
|
50
|
+
// nothing. Deliberately a constant and not a config key: nobody tunes this.
|
|
51
|
+
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024
|
|
52
|
+
|
|
53
|
+
/* ==========================================================================
|
|
54
|
+
* The contract — rdjsonl in, checks out
|
|
55
|
+
* ========================================================================== */
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse rdjsonl: one JSON object per line, reviewdog's diagnostic shape.
|
|
59
|
+
*
|
|
60
|
+
* {"path":"src/a.js","range":{"start":{"line":12}},"severity":"ERROR","message":"…"}
|
|
61
|
+
*
|
|
62
|
+
* A LINE THAT DOES NOT PARSE IS COUNTED, NEVER FATAL. Reviewers prepend
|
|
63
|
+
* progress lines, trailing banners and the occasional warning to stdout, and
|
|
64
|
+
* throwing the other forty findings away over one of them would be a reviewer
|
|
65
|
+
* that works until the day its vendor adds a footer. The count travels out so
|
|
66
|
+
* the caller can say the output was not entirely findings.
|
|
67
|
+
*
|
|
68
|
+
* Pure.
|
|
69
|
+
*/
|
|
70
|
+
function parseRdjsonl(text) {
|
|
71
|
+
const findings = []
|
|
72
|
+
let malformed = 0
|
|
73
|
+
for (const raw of String(text == null ? '' : text).split('\n')) {
|
|
74
|
+
const line = raw.trim()
|
|
75
|
+
if (!line) continue
|
|
76
|
+
let obj
|
|
77
|
+
try {
|
|
78
|
+
obj = JSON.parse(line)
|
|
79
|
+
} catch {
|
|
80
|
+
malformed++
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
84
|
+
malformed++
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
const file = typeof obj.path === 'string' ? obj.path.trim() : ''
|
|
88
|
+
const message = typeof obj.message === 'string' ? obj.message.trim() : ''
|
|
89
|
+
// A diagnostic with no file or no message is not one. It is counted as
|
|
90
|
+
// malformed rather than dropped in silence, for the same reason as above.
|
|
91
|
+
if (!file || !message) {
|
|
92
|
+
malformed++
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
findings.push({ file, line: lineOf(obj), severity: obj.severity, message })
|
|
96
|
+
}
|
|
97
|
+
return { findings, malformed }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The line number a diagnostic points at, or null. Pure.
|
|
102
|
+
*
|
|
103
|
+
* rdjson nests it (`range.start.line`) and several emitters flatten it
|
|
104
|
+
* (`line`). Both are read, because refusing the flat spelling would drop a
|
|
105
|
+
* finding over a shape difference the author never chose.
|
|
106
|
+
*/
|
|
107
|
+
function lineOf(obj) {
|
|
108
|
+
const nested = obj.range && obj.range.start ? obj.range.start.line : undefined
|
|
109
|
+
for (const v of [nested, obj.line]) {
|
|
110
|
+
if (Number.isInteger(v) && v > 0) return v
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Map a reviewer's severity onto the page's three check levels. Pure.
|
|
117
|
+
*
|
|
118
|
+
* `good` is deliberately unreachable. It means "I read this and it is right",
|
|
119
|
+
* which is a thing a person says about code they have understood — a machine
|
|
120
|
+
* that emits no finding for a line has not blessed it, it has simply not
|
|
121
|
+
* spoken. Everything a reviewer does say is either a flag or something to
|
|
122
|
+
* confirm.
|
|
123
|
+
*/
|
|
124
|
+
function levelFor(severity) {
|
|
125
|
+
const s = typeof severity === 'string' ? severity.trim().toLowerCase() : ''
|
|
126
|
+
return s === 'error' ? 'flag' : 'confirm'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Turn one parsed finding into a check. Pure.
|
|
131
|
+
*
|
|
132
|
+
* `source` is what lets a reader tell CodeRabbit's opinion from Claude's on a
|
|
133
|
+
* page carrying both — the whole reason checks grew the field.
|
|
134
|
+
*/
|
|
135
|
+
function checkFrom(finding, source) {
|
|
136
|
+
return {
|
|
137
|
+
level: levelFor(finding.severity),
|
|
138
|
+
file: finding.file,
|
|
139
|
+
line: finding.line === undefined ? null : finding.line,
|
|
140
|
+
note: finding.message,
|
|
141
|
+
source,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Substitute `${…}` placeholders in a configured command. Pure.
|
|
147
|
+
*
|
|
148
|
+
* AN UNKNOWN PLACEHOLDER IS LEFT VERBATIM, not blanked. `${bse}` surviving into
|
|
149
|
+
* the command line makes the failure legible in the error the shell reports;
|
|
150
|
+
* blanking it produces a command that runs, reviews the wrong thing, and looks
|
|
151
|
+
* entirely successful.
|
|
152
|
+
*/
|
|
153
|
+
function substitute(command, vars) {
|
|
154
|
+
return String(command).replace(/\$\{(\w+)\}/g, (whole, key) =>
|
|
155
|
+
Object.prototype.hasOwnProperty.call(vars, key) && vars[key] != null ? String(vars[key]) : whole,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* ==========================================================================
|
|
160
|
+
* Outcomes — what to say about a run that produced no findings
|
|
161
|
+
* ========================================================================== */
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build the outcome for a finished run. Pure.
|
|
165
|
+
*
|
|
166
|
+
* THE BLIND SPOT, named here because this is where someone would be tempted to
|
|
167
|
+
* collapse the states: an exit code of 0 with empty stdout is, from this layer,
|
|
168
|
+
* indistinguishable between "reviewed the diff and found nothing" and "declined
|
|
169
|
+
* silently". Only an adapter knows its own tool well enough to tell those
|
|
170
|
+
* apart, so the generic path calls it `clean` and the page says WHICH reviewer
|
|
171
|
+
* said so — a reader who knows `cr` is unauthenticated can then read the line
|
|
172
|
+
* correctly, where a page with no line at all gives them nothing to read.
|
|
173
|
+
*/
|
|
174
|
+
function outcomeFor({ name, checks, exitCode, signal, stderr, malformed, timedOut, spawnError }) {
|
|
175
|
+
const detail = (s) => firstLine(s)
|
|
176
|
+
if (spawnError) {
|
|
177
|
+
const missing = spawnError.code === 'ENOENT'
|
|
178
|
+
return {
|
|
179
|
+
name,
|
|
180
|
+
state: missing ? 'missing' : 'failed',
|
|
181
|
+
detail: missing ? 'command not found' : detail(spawnError.message),
|
|
182
|
+
count: 0,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (timedOut) return { name, state: 'timeout', detail: null, count: 0 }
|
|
186
|
+
if (exitCode !== 0 || signal) {
|
|
187
|
+
return {
|
|
188
|
+
name,
|
|
189
|
+
state: 'failed',
|
|
190
|
+
// stderr first: a tool that refuses says why there, and that sentence is
|
|
191
|
+
// the entire value of this line to a reader.
|
|
192
|
+
detail: detail(stderr) || (signal ? `killed by ${signal}` : `exit ${exitCode}`),
|
|
193
|
+
count: checks.length,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Exit 0 with unparseable output is NOT clean. A tool printing a help text
|
|
197
|
+
// because its flags changed exits 0 and reviews nothing, and calling that
|
|
198
|
+
// clean is the false clean this module exists against.
|
|
199
|
+
if (!checks.length && malformed > 0) {
|
|
200
|
+
return { name, state: 'failed', detail: `printed ${malformed} unparseable line(s)`, count: 0 }
|
|
201
|
+
}
|
|
202
|
+
if (!checks.length) return { name, state: 'clean', detail: null, count: 0 }
|
|
203
|
+
return {
|
|
204
|
+
name,
|
|
205
|
+
state: 'findings',
|
|
206
|
+
detail: malformed > 0 ? `${malformed} unparseable line(s) skipped` : null,
|
|
207
|
+
count: checks.length,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function firstLine(s) {
|
|
212
|
+
if (typeof s !== 'string') return null
|
|
213
|
+
const line = s.split('\n').map((l) => l.trim()).find(Boolean)
|
|
214
|
+
return line ? (line.length > 200 ? line.slice(0, 197) + '…' : line) : null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Fold an adapter's own reading into the generic one. Pure.
|
|
219
|
+
*
|
|
220
|
+
* AN ADAPTER MAY SHARPEN OR DOWNGRADE, NEVER UPGRADE TO CLEAN OVER A FAILURE.
|
|
221
|
+
* It knows its tool's dialect — that "not authenticated" is a refusal rather
|
|
222
|
+
* than a review with no findings — and that is the one thing the generic rules
|
|
223
|
+
* cannot see. What it must not be able to do is make a run the generic path
|
|
224
|
+
* called failed read as approved, because this is exactly the layer where a
|
|
225
|
+
* vendor-specific guess would produce the false clean the whole feature exists
|
|
226
|
+
* against.
|
|
227
|
+
*
|
|
228
|
+
* So `clean` from an adapter is honoured only where the generic reading was
|
|
229
|
+
* already a clean one. The two states that are about the RUN rather than the
|
|
230
|
+
* output — a timeout and a failure to spawn — are never overridden at all: no
|
|
231
|
+
* amount of parsing tells you anything about a process that was killed or never
|
|
232
|
+
* started.
|
|
233
|
+
*/
|
|
234
|
+
function reconcile(generic, said, { timedOut, spawnError, name, count }) {
|
|
235
|
+
if (!said || typeof said !== 'object') return generic
|
|
236
|
+
if (timedOut || spawnError) return generic
|
|
237
|
+
if (said.state === 'clean' && generic.state !== 'clean' && generic.state !== 'findings') return generic
|
|
238
|
+
return {
|
|
239
|
+
name,
|
|
240
|
+
state: said.state || generic.state,
|
|
241
|
+
detail: said.detail === undefined ? generic.detail : said.detail,
|
|
242
|
+
count: said.state === 'findings' || said.state === 'clean' ? count : generic.count,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* ==========================================================================
|
|
247
|
+
* Running one
|
|
248
|
+
* ========================================================================== */
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Spawn one reviewer and read what it printed.
|
|
252
|
+
*
|
|
253
|
+
* NEVER THROWS, NEVER REJECTS. Every failure this can have — a binary that is
|
|
254
|
+
* not there, a crash, a signal, a tool that hangs — resolves to an outcome
|
|
255
|
+
* naming it. The caller is a render, and a render that cannot happen because a
|
|
256
|
+
* third-party CLI was missing is worse than no reviewer at all.
|
|
257
|
+
*
|
|
258
|
+
* `cwd` is the spec's worktree, which is what makes a reviewer's own git
|
|
259
|
+
* reading agree with the diff on the page.
|
|
260
|
+
*/
|
|
261
|
+
function runReviewer(entry, ctx) {
|
|
262
|
+
const { worktree, base, scope = 'working', spec, adapters = {}, spawnFn = spawn } = ctx
|
|
263
|
+
const vars = { scope, base: base || '', spec: spec || '', worktree: worktree || '' }
|
|
264
|
+
|
|
265
|
+
let command = entry.command
|
|
266
|
+
let parse = null
|
|
267
|
+
if (entry.use) {
|
|
268
|
+
const adapter = adapters[entry.use]
|
|
269
|
+
// An unknown adapter is this run's outcome rather than a thrown error: the
|
|
270
|
+
// config named something this build does not have, which is a fact about
|
|
271
|
+
// one reviewer and not a reason to abandon the render.
|
|
272
|
+
if (!adapter) {
|
|
273
|
+
return Promise.resolve({
|
|
274
|
+
checks: [],
|
|
275
|
+
outcome: { name: entry.name, state: 'missing', detail: `no bundled adapter named "${entry.use}"`, count: 0 },
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
command = adapter.command(vars)
|
|
279
|
+
parse = adapter.parse
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const rendered = substitute(command, vars)
|
|
283
|
+
|
|
284
|
+
return new Promise((resolve) => {
|
|
285
|
+
let child
|
|
286
|
+
try {
|
|
287
|
+
// Through a shell, deliberately: the config holds a command LINE, with
|
|
288
|
+
// pipes and quoting the project chose. It is read from a committed config
|
|
289
|
+
// in the project's own repo — the same trust level as a package script.
|
|
290
|
+
child = spawnFn('/bin/sh', ['-c', rendered], {
|
|
291
|
+
cwd: worktree,
|
|
292
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
293
|
+
// THE CHILD LEADS ITS OWN PROCESS GROUP, and the timeout below depends
|
|
294
|
+
// on it: `process.kill(-pid)` signals a group, and without `detached`
|
|
295
|
+
// that group is OUR OWN — the timeout would kill the render, the CLI
|
|
296
|
+
// and anything else sharing it. `detached` is what makes the negative
|
|
297
|
+
// pid mean "the reviewer and whatever it spawned" instead.
|
|
298
|
+
detached: true,
|
|
299
|
+
})
|
|
300
|
+
} catch (spawnError) {
|
|
301
|
+
resolve({ checks: [], outcome: outcomeFor({ name: entry.name, checks: [], spawnError }) })
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let stdout = ''
|
|
306
|
+
let stderr = ''
|
|
307
|
+
let truncated = false
|
|
308
|
+
let timedOut = false
|
|
309
|
+
let done = false
|
|
310
|
+
let exitCode = 0
|
|
311
|
+
let signal = null
|
|
312
|
+
|
|
313
|
+
const timer = setTimeout(() => {
|
|
314
|
+
timedOut = true
|
|
315
|
+
// The group, not the shell: `/bin/sh -c "cr review"` killed on its own
|
|
316
|
+
// leaves the reviewer running and holding the port/rate-limit slot.
|
|
317
|
+
try {
|
|
318
|
+
process.kill(-child.pid, 'SIGKILL')
|
|
319
|
+
} catch {
|
|
320
|
+
try {
|
|
321
|
+
child.kill('SIGKILL')
|
|
322
|
+
} catch {
|
|
323
|
+
/* already gone */
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}, entry.timeout * 1000)
|
|
327
|
+
|
|
328
|
+
const settle = (spawnError) => {
|
|
329
|
+
if (done) return
|
|
330
|
+
done = true
|
|
331
|
+
clearTimeout(timer)
|
|
332
|
+
const read = parse ? parse(stdout, { exitCode, stderr }) : parseRdjsonl(stdout)
|
|
333
|
+
const checks = read.findings.map((f) => checkFrom(f, entry.name))
|
|
334
|
+
const generic = outcomeFor({
|
|
335
|
+
name: entry.name,
|
|
336
|
+
checks,
|
|
337
|
+
exitCode,
|
|
338
|
+
signal,
|
|
339
|
+
stderr,
|
|
340
|
+
malformed: read.malformed + (truncated ? 1 : 0),
|
|
341
|
+
timedOut,
|
|
342
|
+
spawnError,
|
|
343
|
+
})
|
|
344
|
+
resolve({ checks, outcome: reconcile(generic, read.said, { timedOut, spawnError, name: entry.name, count: checks.length }) })
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
child.stdout.on('data', (d) => {
|
|
348
|
+
if (stdout.length > MAX_OUTPUT_BYTES) {
|
|
349
|
+
truncated = true
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
stdout += d
|
|
353
|
+
})
|
|
354
|
+
child.stderr.on('data', (d) => {
|
|
355
|
+
if (stderr.length > MAX_OUTPUT_BYTES) return
|
|
356
|
+
stderr += d
|
|
357
|
+
})
|
|
358
|
+
child.on('error', (err) => settle(err))
|
|
359
|
+
child.on('close', (code, sig) => {
|
|
360
|
+
exitCode = code === null ? 1 : code
|
|
361
|
+
signal = sig
|
|
362
|
+
settle(null)
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Run every configured reviewer, IN SEQUENCE.
|
|
369
|
+
*
|
|
370
|
+
* Sequential rather than parallel, and it is not laziness: these tools are rate
|
|
371
|
+
* limited per hour on the tiers this feature was built for, and two running at
|
|
372
|
+
* once is how you spend two reviews and get one when the limiter refuses the
|
|
373
|
+
* second. There is no wall-clock prize worth that.
|
|
374
|
+
*
|
|
375
|
+
* Never throws. A reviewer that fails is its own outcome, not the run's.
|
|
376
|
+
*/
|
|
377
|
+
async function runReviewers(config, ctx) {
|
|
378
|
+
const entries = (config && config.review && config.review.reviewers) || []
|
|
379
|
+
const checks = []
|
|
380
|
+
const outcomes = []
|
|
381
|
+
for (const entry of entries) {
|
|
382
|
+
let result
|
|
383
|
+
try {
|
|
384
|
+
result = await runReviewer(entry, ctx)
|
|
385
|
+
} catch (err) {
|
|
386
|
+
// Belt and braces. `runReviewer` is written not to reject; if a future
|
|
387
|
+
// edit makes it, one reviewer's bug must still not take the render down.
|
|
388
|
+
result = {
|
|
389
|
+
checks: [],
|
|
390
|
+
outcome: { name: entry.name, state: 'failed', detail: firstLine(err && err.message), count: 0 },
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
checks.push(...result.checks)
|
|
394
|
+
outcomes.push(result.outcome)
|
|
395
|
+
}
|
|
396
|
+
return { checks, outcomes }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* ==========================================================================
|
|
400
|
+
* The cache — keyed by the diff, not by time
|
|
401
|
+
* ========================================================================== */
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A stable hash of the diff a render collected. Pure.
|
|
405
|
+
*
|
|
406
|
+
* KEYED ON CONTENT, NEVER ON A CLOCK. A re-render of unchanged work must reuse
|
|
407
|
+
* findings — mid-phase `/spec-diff` runs are cheap to trigger and a review is
|
|
408
|
+
* not — and any change at all must re-run, because findings about code that has
|
|
409
|
+
* moved on are worse than no findings. Paths go in as well as patches so a pure
|
|
410
|
+
* rename is a miss.
|
|
411
|
+
*/
|
|
412
|
+
function diffHashOf(files) {
|
|
413
|
+
const h = crypto.createHash('sha256')
|
|
414
|
+
for (const f of files || []) {
|
|
415
|
+
h.update(String(f.path || ''))
|
|
416
|
+
h.update('\0')
|
|
417
|
+
h.update(String(f.patch || ''))
|
|
418
|
+
h.update('\0')
|
|
419
|
+
}
|
|
420
|
+
return h.digest('hex').slice(0, 16)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function reviewChecksPath(outPath) {
|
|
424
|
+
return outPath.replace(/\.html$/, '') + CHECKS_SUFFIX
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function emptyChecks(specFolder) {
|
|
428
|
+
return { version: CHECKS_VERSION, spec: specFolder, diffHash: null, at: null, checks: [], outcomes: [] }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Read the cache. Never throws, and reports `corrupt` rather than hiding it.
|
|
433
|
+
*
|
|
434
|
+
* AN UNREADABLE CACHE IS A MISS, never an empty result — the difference decides
|
|
435
|
+
* whether the reviewers run. Treating it as "no findings" would turn one
|
|
436
|
+
* truncated write into a page that says every reviewer is clean.
|
|
437
|
+
*/
|
|
438
|
+
function readChecks(outPath, specFolder) {
|
|
439
|
+
let raw
|
|
440
|
+
try {
|
|
441
|
+
raw = fs.readFileSync(reviewChecksPath(outPath), 'utf8')
|
|
442
|
+
} catch {
|
|
443
|
+
// Absent is the ordinary state — most reviews never write one.
|
|
444
|
+
return { cache: emptyChecks(specFolder), corrupt: false, present: false }
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const parsed = JSON.parse(raw)
|
|
448
|
+
if (!parsed || typeof parsed !== 'object' || parsed.version !== CHECKS_VERSION) {
|
|
449
|
+
return { cache: emptyChecks(specFolder), corrupt: true, present: true }
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
cache: {
|
|
453
|
+
version: CHECKS_VERSION,
|
|
454
|
+
spec: parsed.spec || specFolder,
|
|
455
|
+
diffHash: typeof parsed.diffHash === 'string' ? parsed.diffHash : null,
|
|
456
|
+
at: parsed.at || null,
|
|
457
|
+
checks: Array.isArray(parsed.checks) ? parsed.checks : [],
|
|
458
|
+
outcomes: Array.isArray(parsed.outcomes) ? parsed.outcomes : [],
|
|
459
|
+
},
|
|
460
|
+
corrupt: false,
|
|
461
|
+
present: true,
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
return { cache: emptyChecks(specFolder), corrupt: true, present: true }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function writeChecks(outPath, cache) {
|
|
469
|
+
const p = reviewChecksPath(outPath)
|
|
470
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
471
|
+
fs.writeFileSync(p, JSON.stringify(cache, null, 2) + '\n')
|
|
472
|
+
return p
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Is this cache usable for this diff? Pure.
|
|
477
|
+
*
|
|
478
|
+
* Three states collapse to two safely here, because the unsafe direction is
|
|
479
|
+
* only ever a redundant re-run: a hash that does not match, a cache written for
|
|
480
|
+
* another spec, and a cache with no hash at all are all misses.
|
|
481
|
+
*/
|
|
482
|
+
function cacheHit(cache, specFolder, diffHash) {
|
|
483
|
+
if (!cache || !cache.diffHash || !diffHash) return false
|
|
484
|
+
if (specFolder && cache.spec && cache.spec !== specFolder) return false
|
|
485
|
+
return cache.diffHash === diffHash
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Mark a set of outcomes as having come from the cache, so the page says
|
|
490
|
+
* `cached` rather than claiming the reviewers just ran. Pure.
|
|
491
|
+
*/
|
|
492
|
+
function asCached(outcomes) {
|
|
493
|
+
return (outcomes || []).map((o) => (o.state === 'findings' || o.state === 'clean' ? { ...o, state: 'cached' } : o))
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
module.exports = {
|
|
497
|
+
CHECKS_VERSION,
|
|
498
|
+
CHECKS_SUFFIX,
|
|
499
|
+
OUTCOME_STATES,
|
|
500
|
+
parseRdjsonl,
|
|
501
|
+
lineOf,
|
|
502
|
+
levelFor,
|
|
503
|
+
checkFrom,
|
|
504
|
+
substitute,
|
|
505
|
+
outcomeFor,
|
|
506
|
+
reconcile,
|
|
507
|
+
BUNDLED_ADAPTERS,
|
|
508
|
+
runReviewer,
|
|
509
|
+
runReviewers,
|
|
510
|
+
diffHashOf,
|
|
511
|
+
reviewChecksPath,
|
|
512
|
+
emptyChecks,
|
|
513
|
+
readChecks,
|
|
514
|
+
writeChecks,
|
|
515
|
+
cacheHit,
|
|
516
|
+
asCached,
|
|
517
|
+
}
|