@skitterbyte/skitterspec 21.0.0 → 22.1.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.
Files changed (43) hide show
  1. package/MIGRATION.md +218 -0
  2. package/README.md +127 -6
  3. package/assets/claude-md-section.md +42 -18
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-remote-review.md +22 -0
  6. package/assets/commands/spec-skip.md +24 -0
  7. package/assets/core/env.config.json.example +6 -3
  8. package/assets/core/env.config.md +183 -25
  9. package/assets/hooks/main-guard.cjs +145 -0
  10. package/assets/hooks/review-gate.cjs +8 -3
  11. package/assets/review/page.html +1174 -101
  12. package/assets/rules/offered-unblocks.md +100 -0
  13. package/assets/rules/spec-planning.md +195 -14
  14. package/assets/rules/spec-reports.md +181 -29
  15. package/assets/skills/no-spec/SKILL.md +219 -0
  16. package/assets/skills/spec/SKILL.md +248 -21
  17. package/assets/skills/spec-bug/SKILL.md +34 -24
  18. package/assets/skills/spec-cancel/SKILL.md +29 -0
  19. package/assets/skills/spec-complete/SKILL.md +25 -0
  20. package/assets/skills/spec-diff/SKILL.md +164 -37
  21. package/assets/skills/spec-hotfix/SKILL.md +22 -19
  22. package/assets/skills/spec-init/SKILL.md +4 -2
  23. package/assets/skills/spec-next/SKILL.md +166 -58
  24. package/assets/skills/spec-review/SKILL.md +87 -0
  25. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  26. package/assets/skills/spec-start/SKILL.md +33 -0
  27. package/package.json +1 -1
  28. package/src/cli.js +1612 -172
  29. package/src/env/classify.js +87 -2
  30. package/src/env/config.js +333 -19
  31. package/src/env/hooks.js +102 -33
  32. package/src/env/live.js +124 -1
  33. package/src/env/mainguard.js +240 -0
  34. package/src/env/provision.js +106 -10
  35. package/src/env/registry.js +70 -6
  36. package/src/env/resolve.js +128 -5
  37. package/src/env/review.js +854 -29
  38. package/src/env/reviewers/coderabbit.js +228 -0
  39. package/src/env/reviewers.js +517 -0
  40. package/src/env/serve.js +474 -50
  41. package/src/env/supervise.js +8 -1
  42. package/src/env/teardown.js +12 -1
  43. package/src/init.js +185 -24
@@ -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
+ }