@skitterbyte/skitterspec 22.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 (37) hide show
  1. package/README.md +14 -0
  2. package/assets/claude-md-section.md +13 -0
  3. package/assets/commands/allow-main.md +33 -0
  4. package/assets/commands/spec-skip.md +24 -0
  5. package/assets/core/env.config.json.example +2 -1
  6. package/assets/core/env.config.md +83 -2
  7. package/assets/hooks/main-guard.cjs +145 -0
  8. package/assets/hooks/review-gate.cjs +8 -3
  9. package/assets/review/page.html +132 -2
  10. package/assets/rules/offered-unblocks.md +100 -0
  11. package/assets/rules/spec-planning.md +160 -11
  12. package/assets/rules/spec-reports.md +50 -9
  13. package/assets/skills/no-spec/SKILL.md +219 -0
  14. package/assets/skills/spec/SKILL.md +135 -36
  15. package/assets/skills/spec-bug/SKILL.md +5 -0
  16. package/assets/skills/spec-cancel/SKILL.md +29 -0
  17. package/assets/skills/spec-complete/SKILL.md +25 -0
  18. package/assets/skills/spec-diff/SKILL.md +33 -1
  19. package/assets/skills/spec-init/SKILL.md +4 -2
  20. package/assets/skills/spec-next/SKILL.md +47 -2
  21. package/assets/skills/spec-reviewed/SKILL.md +3 -3
  22. package/assets/skills/spec-start/SKILL.md +14 -0
  23. package/package.json +1 -1
  24. package/src/cli.js +702 -59
  25. package/src/env/config.js +121 -4
  26. package/src/env/hooks.js +102 -33
  27. package/src/env/live.js +30 -1
  28. package/src/env/mainguard.js +240 -0
  29. package/src/env/provision.js +106 -10
  30. package/src/env/registry.js +70 -6
  31. package/src/env/resolve.js +92 -3
  32. package/src/env/review.js +324 -20
  33. package/src/env/reviewers/coderabbit.js +228 -0
  34. package/src/env/reviewers.js +517 -0
  35. package/src/env/serve.js +189 -44
  36. package/src/env/teardown.js +12 -1
  37. package/src/init.js +125 -15
@@ -0,0 +1,228 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The CodeRabbit adapter — `{ "use": "coderabbit" }` is the whole config.
5
+ *
6
+ * A bundled adapter owns two things the generic path cannot: the command line
7
+ * (this tool's flags are its own, and leaking them into skitterspec's config
8
+ * schema was rejected in the spec's Decision 2), and the parser (`cr --agent`
9
+ * emits a JSONL EVENT STREAM, not diagnostics).
10
+ *
11
+ * It also owns the one thing that makes Decision 6 honest. The generic runner
12
+ * sees an exit code and some bytes; it cannot tell a review that found nothing
13
+ * from a CLI that declined because nobody is logged in. This adapter can, and
14
+ * the difference decides whether the page reads `clean` or `did not run — not
15
+ * authenticated` to someone about to commit.
16
+ *
17
+ * SHAPE OF A BUNDLED ADAPTER, so a second one is a file rather than a refactor:
18
+ *
19
+ * command(vars) -> string the shell line, given ${scope}/${base}/…
20
+ * parse(stdout, ctx) -> { findings, malformed, said }
21
+ *
22
+ * `said` is an outcome this adapter is sure of, or null to let the generic
23
+ * rules decide. It can never make a run read as clean that the generic path
24
+ * called failed — it only ever sharpens or downgrades.
25
+ */
26
+
27
+ // Severities `cr` emits, worst first. `error` is not among them, so the generic
28
+ // `levelFor` would call every finding `confirm` — hence the explicit map.
29
+ const FLAG_SEVERITIES = ['critical', 'major']
30
+
31
+ /**
32
+ * Patterns that mean THE REVIEW DID NOT HAPPEN, as opposed to happening and
33
+ * finding nothing.
34
+ *
35
+ * WHAT WOULD FOOL THIS: the CLI's own wording, which is not a documented
36
+ * surface and will change. A pattern that stops matching costs the sharper
37
+ * sentence and nothing else — the run still reports `did not run` with whatever
38
+ * text it did have, because this list only ever runs on output the generic
39
+ * rules ALREADY decided was a failure or an `error` event. It cannot turn a
40
+ * clean review into an accusation, which is the direction that would matter.
41
+ */
42
+ const NOT_RUN = [
43
+ [/not (?:authenticated|logged in)|unauthori[sz]ed|auth(?:entication)? (?:failed|required)|cr auth login|invalid api key/i, 'not authenticated'],
44
+ [/rate limit|too many requests|quota (?:exceeded|exhausted)|429/i, 'rate limited'],
45
+ [/network|enotfound|econnrefused|etimedout|dns|offline|could not reach/i, 'no network'],
46
+ [/not supported on|unsupported platform/i, 'unsupported platform'],
47
+ ]
48
+
49
+ function classify(text) {
50
+ const s = typeof text === 'string' ? text : ''
51
+ for (const [re, label] of NOT_RUN) {
52
+ if (re.test(s)) return label
53
+ }
54
+ return null
55
+ }
56
+
57
+ /**
58
+ * The command line, per scope. The two scopes are the render's own two modes,
59
+ * so what the page shows is what was reviewed.
60
+ *
61
+ * `working` — uncommitted and untracked, which is what a phase-end render
62
+ * collects: the page is written BEFORE the commit.
63
+ * `branch` — the whole spec against its base, which is what `--branch` shows.
64
+ */
65
+ function command(vars) {
66
+ if (vars.scope === 'branch' && vars.base) {
67
+ return `cr review --agent --base ${shellQuote(vars.base)}`
68
+ }
69
+ return 'cr review --agent --uncommitted --include-untracked'
70
+ }
71
+
72
+ // The base comes from a project's own config, not from a page — but it reaches
73
+ // a shell either way, and a branch name is allowed to contain almost anything.
74
+ function shellQuote(s) {
75
+ return `'${String(s).replace(/'/g, `'\\''`)}'`
76
+ }
77
+
78
+ /**
79
+ * Read the `--agent` stream: one JSON object per line, dispatched on `type`.
80
+ *
81
+ * UNKNOWN EVENT TYPES AND UNKNOWN FIELDS ARE BOTH IGNORED, never fatal. The
82
+ * stream is additive by design, so both mean "a newer CLI than this adapter" —
83
+ * and refusing a whole review because one line carried a field we had not seen
84
+ * would turn every CodeRabbit release into a broken adapter.
85
+ */
86
+ function parse(stdout, ctx = {}) {
87
+ const findings = []
88
+ let malformed = 0
89
+ let events = 0
90
+ let errorText = null
91
+ let skipped = false
92
+
93
+ for (const raw of String(stdout == null ? '' : stdout).split('\n')) {
94
+ const line = raw.trim()
95
+ if (!line) continue
96
+ let ev
97
+ try {
98
+ ev = JSON.parse(line)
99
+ } catch {
100
+ malformed++
101
+ continue
102
+ }
103
+ if (!ev || typeof ev !== 'object' || Array.isArray(ev)) {
104
+ malformed++
105
+ continue
106
+ }
107
+ events++
108
+ switch (ev.type) {
109
+ case 'finding': {
110
+ const f = readFinding(ev)
111
+ if (f) findings.push(f)
112
+ else malformed++
113
+ break
114
+ }
115
+ case 'error':
116
+ // First one wins: what went wrong first is what explains the rest.
117
+ if (!errorText) errorText = textOf(ev.message) || textOf(ev.error) || 'the reviewer reported an error'
118
+ break
119
+ case 'status':
120
+ // Exit 0 with nothing to look at. A real outcome, and a clean one.
121
+ if (ev.status === 'review_skipped') skipped = true
122
+ break
123
+ // `review_context`, `heartbeat`, `complete` and anything added later
124
+ // carry nothing this needs. A heartbeat is a keep-alive; the runner's
125
+ // timeout is generous enough not to need it, and reaching for it would
126
+ // couple the runner to one adapter's stream.
127
+ default:
128
+ break
129
+ }
130
+ }
131
+
132
+ return { findings, malformed, said: said({ ctx, findings, events, errorText, skipped }) }
133
+ }
134
+
135
+ /**
136
+ * What this adapter is sure of, or null to let the generic rules decide.
137
+ *
138
+ * IT NEVER UPGRADES A FAILURE TO CLEAN. Every branch below either sharpens a
139
+ * failure's wording or reports one the generic path would have missed; the
140
+ * clean readings are handed back as `null` for `outcomeFor` to reach on its own
141
+ * terms. That asymmetry is the whole safety property — this file is where a
142
+ * vendor-specific guess could quietly turn "nobody is logged in" into a page
143
+ * that reads as approved.
144
+ */
145
+ function said({ ctx, findings, events, errorText, skipped }) {
146
+ const exitCode = ctx.exitCode
147
+ const stderr = ctx.stderr
148
+
149
+ // An `error` event is the tool telling us directly.
150
+ if (errorText) {
151
+ const why = classify(errorText) || classify(stderr)
152
+ return { state: 'failed', detail: why || firstLine(errorText) }
153
+ }
154
+
155
+ // A non-zero exit with a recognisable reason: say which, rather than echoing
156
+ // whatever sentence happened to be first on stderr.
157
+ if (exitCode !== 0) {
158
+ const why = classify(stderr)
159
+ if (why) return { state: 'failed', detail: why }
160
+ return null
161
+ }
162
+
163
+ // Exit 0 and the tool said it had nothing to review. Clean, and count it as
164
+ // such rather than letting an empty stream look like a truncated one.
165
+ if (skipped && !findings.length) return { state: 'clean', detail: 'nothing to review' }
166
+
167
+ // Exit 0, no findings, and NOT ONE well-formed event: the stream was not the
168
+ // stream we expect. `outcomeFor` would read this as clean when stdout was
169
+ // empty, and that is the false clean this whole feature exists against —
170
+ // cannot-tell routed to the branch that does not claim the code was read.
171
+ if (!events && !findings.length) {
172
+ const why = classify(stderr)
173
+ return { state: 'failed', detail: why || 'no review events — is `cr` authenticated?' }
174
+ }
175
+
176
+ return null
177
+ }
178
+
179
+ /**
180
+ * One finding → one rdjsonl-shaped diagnostic.
181
+ *
182
+ * THE LINE NUMBER IS THE SOFT PART, and worth naming. The documented finding
183
+ * shape is `{severity, fileName, codegenInstructions, suggestions, comment}` —
184
+ * there is NO documented line field. So every plausible spelling is read and a
185
+ * finding with none still renders; it simply loses its jump button rather than
186
+ * being dropped. A finding nobody can place is worth far more than no finding.
187
+ */
188
+ function readFinding(ev) {
189
+ const file = textOf(ev.fileName) || textOf(ev.file) || textOf(ev.path)
190
+ // `codegenInstructions` first, per the CLI's own guidance, falling back to
191
+ // the human-readable comment when it is absent.
192
+ const message = textOf(ev.codegenInstructions) || textOf(ev.comment) || textOf(ev.message)
193
+ if (!file || !message) return null
194
+ return { file, line: lineOf(ev), severity: severityOf(ev.severity), message }
195
+ }
196
+
197
+ function lineOf(ev) {
198
+ const nested = ev.range && ev.range.start ? ev.range.start.line : undefined
199
+ for (const v of [nested, ev.line, ev.startLine, ev.lineNumber, ev.start_line]) {
200
+ if (Number.isInteger(v) && v > 0) return v
201
+ }
202
+ return null
203
+ }
204
+
205
+ /**
206
+ * Map `cr`'s severity onto the one word the generic `levelFor` turns into a
207
+ * level. `critical` and `major` become `error` → `flag`; everything else,
208
+ * including a severity this build has never seen, becomes `confirm`.
209
+ *
210
+ * An unknown severity landing on `confirm` rather than `flag` is deliberate: a
211
+ * page where everything shouts is a page nobody reads, and the finding is still
212
+ * there either way.
213
+ */
214
+ function severityOf(severity) {
215
+ const s = typeof severity === 'string' ? severity.trim().toLowerCase() : ''
216
+ return FLAG_SEVERITIES.includes(s) ? 'error' : 'warning'
217
+ }
218
+
219
+ function textOf(v) {
220
+ return typeof v === 'string' && v.trim() ? v.trim() : ''
221
+ }
222
+
223
+ function firstLine(s) {
224
+ const line = String(s || '').split('\n').map((l) => l.trim()).find(Boolean)
225
+ return line ? (line.length > 200 ? line.slice(0, 197) + '…' : line) : null
226
+ }
227
+
228
+ module.exports = { command, parse, classify, readFinding, severityOf, FLAG_SEVERITIES }