clud-bug 0.7.0-rc.2 → 0.7.0-rc.21

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 (147) hide show
  1. package/README.md +20 -0
  2. package/data/canonical-v1.json +37 -0
  3. package/dist/cli/configure-github.d.ts +58 -0
  4. package/dist/cli/configure-github.d.ts.map +1 -0
  5. package/dist/cli/configure-github.js +242 -0
  6. package/dist/cli/configure-github.js.map +1 -0
  7. package/dist/cli/hooks.d.ts +51 -0
  8. package/dist/cli/hooks.d.ts.map +1 -0
  9. package/dist/cli/hooks.js +138 -0
  10. package/dist/cli/hooks.js.map +1 -0
  11. package/dist/cli/index.d.ts +1 -0
  12. package/dist/cli/index.d.ts.map +1 -1
  13. package/dist/cli/index.js +1 -0
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/main.js +949 -35
  17. package/dist/cli/main.js.map +1 -1
  18. package/dist/cli/post-check-run.d.ts +16 -0
  19. package/dist/cli/post-check-run.d.ts.map +1 -0
  20. package/dist/cli/post-check-run.js +93 -0
  21. package/dist/cli/post-check-run.js.map +1 -0
  22. package/dist/cli/review-prompt.d.ts +43 -0
  23. package/dist/cli/review-prompt.d.ts.map +1 -0
  24. package/dist/cli/review-prompt.js +287 -0
  25. package/dist/cli/review-prompt.js.map +1 -0
  26. package/dist/cli/skills.d.ts +7 -0
  27. package/dist/cli/skills.d.ts.map +1 -1
  28. package/dist/cli/skills.js +42 -2
  29. package/dist/cli/skills.js.map +1 -1
  30. package/dist/cli/update-notifier.d.ts +17 -0
  31. package/dist/cli/update-notifier.d.ts.map +1 -0
  32. package/dist/cli/update-notifier.js +88 -0
  33. package/dist/cli/update-notifier.js.map +1 -0
  34. package/dist/cli/update.d.ts.map +1 -1
  35. package/dist/cli/update.js +60 -11
  36. package/dist/cli/update.js.map +1 -1
  37. package/dist/core/auto-resolve.d.ts +235 -0
  38. package/dist/core/auto-resolve.d.ts.map +1 -0
  39. package/dist/core/auto-resolve.js +293 -0
  40. package/dist/core/auto-resolve.js.map +1 -0
  41. package/dist/core/budget-plan.d.ts +125 -0
  42. package/dist/core/budget-plan.d.ts.map +1 -0
  43. package/dist/core/budget-plan.js +211 -0
  44. package/dist/core/budget-plan.js.map +1 -0
  45. package/dist/core/check-verdict.d.ts +31 -0
  46. package/dist/core/check-verdict.d.ts.map +1 -0
  47. package/dist/core/check-verdict.js +66 -0
  48. package/dist/core/check-verdict.js.map +1 -0
  49. package/dist/core/configure-github.d.ts +179 -0
  50. package/dist/core/configure-github.d.ts.map +1 -0
  51. package/dist/core/configure-github.js +289 -0
  52. package/dist/core/configure-github.js.map +1 -0
  53. package/dist/core/design.d.ts +37 -0
  54. package/dist/core/design.d.ts.map +1 -0
  55. package/dist/core/design.js +47 -0
  56. package/dist/core/design.js.map +1 -0
  57. package/dist/core/diff-findings.d.ts +96 -0
  58. package/dist/core/diff-findings.d.ts.map +1 -0
  59. package/dist/core/diff-findings.js +261 -0
  60. package/dist/core/diff-findings.js.map +1 -0
  61. package/dist/core/formal-review.d.ts +79 -0
  62. package/dist/core/formal-review.d.ts.map +1 -0
  63. package/dist/core/formal-review.js +71 -0
  64. package/dist/core/formal-review.js.map +1 -0
  65. package/dist/core/index.d.ts +15 -2
  66. package/dist/core/index.d.ts.map +1 -1
  67. package/dist/core/index.js +53 -1
  68. package/dist/core/index.js.map +1 -1
  69. package/dist/core/inline-threads.d.ts +160 -0
  70. package/dist/core/inline-threads.d.ts.map +1 -0
  71. package/dist/core/inline-threads.js +369 -0
  72. package/dist/core/inline-threads.js.map +1 -0
  73. package/dist/core/multi-pass-aggregate.d.ts +161 -0
  74. package/dist/core/multi-pass-aggregate.d.ts.map +1 -0
  75. package/dist/core/multi-pass-aggregate.js +457 -0
  76. package/dist/core/multi-pass-aggregate.js.map +1 -0
  77. package/dist/core/plan-review.d.ts +57 -0
  78. package/dist/core/plan-review.d.ts.map +1 -0
  79. package/dist/core/plan-review.js +77 -0
  80. package/dist/core/plan-review.js.map +1 -0
  81. package/dist/core/prompt-builder.d.ts +18 -0
  82. package/dist/core/prompt-builder.d.ts.map +1 -1
  83. package/dist/core/prompt-builder.js +24 -2
  84. package/dist/core/prompt-builder.js.map +1 -1
  85. package/dist/core/render.d.ts.map +1 -1
  86. package/dist/core/render.js +1 -19
  87. package/dist/core/render.js.map +1 -1
  88. package/dist/core/resolve-verifier.d.ts +53 -0
  89. package/dist/core/resolve-verifier.d.ts.map +1 -0
  90. package/dist/core/resolve-verifier.js +204 -0
  91. package/dist/core/resolve-verifier.js.map +1 -0
  92. package/dist/core/review-context.d.ts +39 -0
  93. package/dist/core/review-context.d.ts.map +1 -0
  94. package/dist/core/review-context.js +99 -0
  95. package/dist/core/review-context.js.map +1 -0
  96. package/dist/core/review-plan.d.ts +219 -0
  97. package/dist/core/review-plan.d.ts.map +1 -0
  98. package/dist/core/review-plan.js +274 -0
  99. package/dist/core/review-plan.js.map +1 -0
  100. package/dist/core/review-writeback.d.ts +74 -0
  101. package/dist/core/review-writeback.d.ts.map +1 -1
  102. package/dist/core/review-writeback.js +71 -4
  103. package/dist/core/review-writeback.js.map +1 -1
  104. package/dist/core/skills.d.ts +25 -0
  105. package/dist/core/skills.d.ts.map +1 -1
  106. package/dist/core/skills.js +100 -4
  107. package/dist/core/skills.js.map +1 -1
  108. package/dist/core/version.d.ts +2 -0
  109. package/dist/core/version.d.ts.map +1 -0
  110. package/dist/core/version.js +4 -0
  111. package/dist/core/version.js.map +1 -0
  112. package/package.json +11 -5
  113. package/src/cli/configure-github.ts +329 -0
  114. package/src/cli/hooks.ts +155 -0
  115. package/src/cli/index.ts +6 -0
  116. package/src/cli/main.ts +1031 -37
  117. package/src/cli/post-check-run.ts +115 -0
  118. package/src/cli/review-prompt.ts +355 -0
  119. package/src/cli/skills.ts +42 -2
  120. package/src/cli/update-notifier.ts +100 -0
  121. package/src/cli/update.ts +60 -11
  122. package/src/core/auto-resolve.ts +488 -0
  123. package/src/core/budget-plan.ts +324 -0
  124. package/src/core/check-verdict.ts +93 -0
  125. package/src/core/configure-github.ts +497 -0
  126. package/src/core/design.ts +73 -0
  127. package/src/core/diff-findings.ts +323 -0
  128. package/src/core/formal-review.ts +168 -0
  129. package/src/core/index.ts +188 -0
  130. package/src/core/inline-threads.ts +471 -0
  131. package/src/core/multi-pass-aggregate.ts +591 -0
  132. package/src/core/plan-review.ts +136 -0
  133. package/src/core/prompt-builder.ts +44 -2
  134. package/src/core/render.ts +10 -16
  135. package/src/core/resolve-verifier.ts +254 -0
  136. package/src/core/review-context.ts +106 -0
  137. package/src/core/review-plan.ts +485 -0
  138. package/src/core/review-writeback.ts +161 -4
  139. package/src/core/skills.ts +128 -5
  140. package/src/core/version.ts +3 -0
  141. package/templates/clud-bug-review.md.tmpl +115 -0
  142. package/templates/skills/design/design-system-consistency.md +41 -0
  143. package/templates/skills/design/frontend-a11y.md +41 -0
  144. package/templates/skills/design/visual-polish.md +41 -0
  145. package/templates/workflow-py.yml.tmpl +92 -2
  146. package/templates/workflow-ts.yml.tmpl +92 -2
  147. package/templates/workflow.yml.tmpl +173 -2
package/src/core/index.ts CHANGED
@@ -116,7 +116,10 @@ export {
116
116
  WRITTEN_BY,
117
117
  SEVERITY_EMOJI as REVIEW_FILE_SEVERITY_EMOJI,
118
118
  type RenderReviewFileInput,
119
+ type RenderedFindingRef,
120
+ type CacheStats,
119
121
  type RenderMultiPassMarkdownInput,
122
+ type Consensus,
120
123
  type MultiPassReview,
121
124
  type UnifiedFinding,
122
125
  type PassAttribution,
@@ -124,6 +127,188 @@ export {
124
127
  type ReviewPassMode,
125
128
  type MultiPassVerdict,
126
129
  } from './review-writeback.js';
130
+ // SPEC §7.2.1 formal-review event selector. Pure rule-table half of
131
+ // clud-bug-app's `lib/formal-review.ts`; the Octokit-side IO wrapper
132
+ // (`postFormalReview`) stays App-side. v0.7.0-rc.3 adds the
133
+ // `authorAssociation` extension so a clean review on an external
134
+ // contributor's PR routes to COMMENT (not APPROVE) — the canonical
135
+ // ruleset's `required_approving_review_count: 1` floor then requires
136
+ // a human reviewer.
137
+ export {
138
+ selectReviewEvent,
139
+ type FormalReviewEvent,
140
+ type AuthorAssociation,
141
+ type SelectReviewEventInput,
142
+ } from './formal-review.js';
143
+ // SPEC §1.8.1 Resolved / Still-open block helpers. `parsePriorReviewFile`
144
+ // reads a prior `docs/reviews/PR-<n>.md`; `diffFindings` splits prior vs
145
+ // current into resolved + still-open lists that `renderReviewFile`
146
+ // emits as the §1.8.1 blocks. Identity scheme is shareable with future
147
+ // inline-thread anchoring in clud-bug-app.
148
+ export {
149
+ parsePriorReviewFile,
150
+ diffFindings,
151
+ findingIdentity,
152
+ type ParsedFinding,
153
+ type ParsedReview,
154
+ } from './diff-findings.js';
155
+ // Wave 5a — D.2.X per-finding inline review threads. Pure helpers
156
+ // (anchor detection, comment rendering, plan partitioning) + the GraphQL
157
+ // constants the CLI's `post-inline-threads` verb invokes via `gh api`.
158
+ // `findingId` returns the SHA-256[:16] hash and is distinct from the
159
+ // plain-string `findingIdentity` above (both serve different matchers).
160
+ //
161
+ // Wave 5b additions: `parseThreadBody` (inverts renderThreadBody) +
162
+ // `ADD_REPLY_MUTATION` (for posting the auto-resolve marker reply
163
+ // before resolving a thread).
164
+ export {
165
+ findingId,
166
+ parseHeadLines,
167
+ findingAnchorable,
168
+ extractAnchorContext,
169
+ renderThreadBody,
170
+ extractFindingIdFromBody,
171
+ parseThreadBody,
172
+ planInlineThreads,
173
+ REVIEW_THREADS_QUERY,
174
+ REVIEW_THREADS_STATE_QUERY,
175
+ RESOLVE_THREAD_MUTATION,
176
+ ADD_REPLY_MUTATION,
177
+ type Severity as InlineThreadSeverity,
178
+ type FindingForThread,
179
+ type DiffFile,
180
+ type InlineCommentPlan,
181
+ type PlanInlineThreadsResult,
182
+ } from './inline-threads.js';
183
+ // Wave 5b — D.2.6 auto-resolve on fix-push. Pure rule tables + config
184
+ // merge + marker rendering. The CLI's `resolve-threads` verb owns the
185
+ // Anthropic Messages call + GraphQL mutations; `runAutoResolve` is
186
+ // pure modulo the injected verifier callback.
187
+ export {
188
+ resolveAutoResolveConfig,
189
+ readAutoResolveConfigFromCludBug,
190
+ runAutoResolve,
191
+ applyResolutionRules,
192
+ renderAutoResolveMarker,
193
+ DEFAULT_AUTO_RESOLVE_CONFIG,
194
+ type AutoResolveConfig,
195
+ type PriorThread,
196
+ type PriorFinding,
197
+ type ThreadAction,
198
+ type AutoResolveInput,
199
+ type AutoResolveResult,
200
+ type VerifyOutcome,
201
+ } from './auto-resolve.js';
202
+ export {
203
+ VERIFIER_SYSTEM,
204
+ buildVerifierSystem,
205
+ buildVerifierPrompt,
206
+ parseVerifierResponse,
207
+ type VerifierOutputMode,
208
+ type VerifySingleFindingInput,
209
+ } from './resolve-verifier.js';
210
+ // SPEC §7 canonical-ruleset applier. Pure diff + idempotent-PATCH logic;
211
+ // CLI side wraps `gh api` in an Octokit-like adapter and the App passes
212
+ // its real Octokit instance. Shipped in v0.7.0-rc.4 for Marketplace prep
213
+ // (Phase 6 task #227).
214
+ export {
215
+ applyCanonicalRuleset,
216
+ loadCanonicalV1,
217
+ type CanonicalRuleset,
218
+ type OctokitLike,
219
+ type ApplyCanonicalRulesetParams,
220
+ type ApplyResult,
221
+ } from './configure-github.js';
222
+ // Wave 6b — review-planning "brain" ported from clud-bug-app/lib. Three pure
223
+ // modules: the multi-pass config resolver (`review-plan`), the Layer-1 cost
224
+ // gate (`budget-plan`), and the multi-pass aggregator (`multi-pass-aggregate`).
225
+ //
226
+ // `ReviewPassMode` is intentionally NOT re-exported here — `review-writeback`
227
+ // above already owns that name in the barrel (both declarations are the same
228
+ // `'cross-check' | 'consensus' | 'independent'` union). Likewise the
229
+ // aggregator's result types (`MultiPassReview`, `UnifiedFinding`,
230
+ // `PassAttribution`, `PassSource`) come from `review-writeback` and are NOT
231
+ // re-declared by the aggregator module — it imports them.
232
+ export {
233
+ readReviewPassesConfig,
234
+ extractSkillReviewPassesOverride,
235
+ resolveReviewPasses,
236
+ roleForPass,
237
+ anyMultiPass,
238
+ totalPassCount,
239
+ REVIEW_PASS_MODES,
240
+ MAX_PASSES,
241
+ MIN_PASSES,
242
+ BUILTIN_DEFAULT,
243
+ BUILTIN_ROLES,
244
+ type ApplyTo,
245
+ type ReviewPassesEntry,
246
+ type ReviewRoleTier,
247
+ type ReviewRole,
248
+ type ResolvedReviewPasses,
249
+ type ReviewPassesConfig,
250
+ type SkillReviewPassesFrontmatter,
251
+ type ReviewPlanSkill,
252
+ type ResolveReviewPassesInput,
253
+ type ResolveReviewPassesResult,
254
+ } from './review-plan.js';
255
+ export {
256
+ perCallCeiling,
257
+ estimateBudget,
258
+ estimateVerifierBudget,
259
+ __setModelCeilingForTests,
260
+ DEFAULT_PER_PR_CAP_USD,
261
+ DEFAULT_VERIFIER_PER_PR_CAP_USD,
262
+ type BudgetEstimateInput,
263
+ type BudgetEstimate,
264
+ type BudgetVerdict,
265
+ type VerifierBudgetInput,
266
+ type VerifierBudgetEstimate,
267
+ type VerifierBudgetVerdict,
268
+ } from './budget-plan.js';
269
+ export {
270
+ planReview,
271
+ LARGE_DIFF_THRESHOLD_BYTES,
272
+ type PlanReviewInput,
273
+ type ReviewPlan,
274
+ type ReviewTrigger,
275
+ type TierDownReason,
276
+ } from './plan-review.js';
277
+ export {
278
+ aggregatePasses,
279
+ deriveConsensus,
280
+ resolveVerdict,
281
+ shouldEscalate,
282
+ type CrossCheckVerdict,
283
+ type CrossCheckPassResult,
284
+ type AggregateInput,
285
+ type EscalationInput,
286
+ } from './multi-pass-aggregate.js';
287
+ export {
288
+ readDesignConfig,
289
+ shouldRunDesign,
290
+ BUILTIN_DESIGN_CONFIG,
291
+ type DesignConfig,
292
+ type DesignGate,
293
+ } from './design.js';
294
+ export {
295
+ readReviewContext,
296
+ extractPrContext,
297
+ fenceUntrustedContext,
298
+ EMPTY_REVIEW_CONTEXT,
299
+ MAX_REVIEW_CONTEXT_BYTES,
300
+ type ReviewContextConfig,
301
+ } from './review-context.js';
302
+ export {
303
+ deriveCheck,
304
+ normalizeVerdict,
305
+ CLUD_BUG_CHECK_NAME,
306
+ type ReviewVerdict,
307
+ type CheckConclusion,
308
+ type CheckSource,
309
+ type DerivedCheck,
310
+ type DeriveCheckInput,
311
+ } from './check-verdict.js';
127
312
  export {
128
313
  API_BASE,
129
314
  MAX_SKILLS,
@@ -133,6 +318,7 @@ export {
133
318
  readReviewMode,
134
319
  readAppliesTo,
135
320
  appliesToPr,
321
+ appliesToAuthor,
136
322
  partitionByReviewMode,
137
323
  extractPerSkillLine,
138
324
  selectReviewHeader,
@@ -152,4 +338,6 @@ export {
152
338
  type SkillFrontmatter,
153
339
  type SkillSource,
154
340
  type SkillReviewMode,
341
+ type SkillKind,
342
+ type VoiceScope,
155
343
  } from './skills.js';
@@ -0,0 +1,471 @@
1
+ // Wave 5a — D.2.X inline review threads (npm workflow parity, OSS port).
2
+ //
3
+ // This module holds the pure helpers + GraphQL operation strings that BOTH
4
+ // the npm workflow path and the hosted clud-bug-app use to attach a
5
+ // per-finding inline review thread to the PR (replacing the legacy
6
+ // "one big summary comment listing every finding" UX).
7
+ //
8
+ // The split between npm and app:
9
+ // - npm path (this package's CLI verb `post-inline-threads`) is stateless:
10
+ // it shells out to `gh api` for the REST createReview + `gh api graphql`
11
+ // for the GraphQL thread-id lookup, then EXITS. No persistent record —
12
+ // Wave 5b's auto-resolve re-queries GitHub on the next push and re-derives
13
+ // finding IDs from the comment-body markers we embed here.
14
+ // - app path keeps its Redis-backed persistence for performance (cross-
15
+ // push thread correlation without re-querying GraphQL every time). The
16
+ // pure helpers below are designed so a future Wave 5c swap can have the
17
+ // App import from this module + drop its local duplicates.
18
+ //
19
+ // Designed to share `findingId` semantics with clud-bug-app's
20
+ // `lib/inline-threads.ts:findingId` (SHA-256 of the canonical 5-tuple,
21
+ // truncated to 16 hex chars) so threads posted by one consumer stay
22
+ // matchable by the other after the App migrates.
23
+
24
+ import { createHash } from 'node:crypto';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Public types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export type Severity = 'critical' | 'minor' | 'preexisting';
31
+
32
+ /**
33
+ * Minimal shape this module needs from a review finding. Both the npm CLI
34
+ * and the App's `Finding` type satisfy this; the App also carries
35
+ * `reasoning` + nullable `file`/`line` which we tolerate via the optional
36
+ * fields below.
37
+ */
38
+ export interface FindingForThread {
39
+ severity: Severity;
40
+ skill: string;
41
+ file?: string;
42
+ line?: number;
43
+ summary: string;
44
+ reasoning?: string;
45
+ }
46
+
47
+ /**
48
+ * Per-file unified-diff entry. Matches the shape returned by `gh api
49
+ * repos/{owner}/{repo}/pulls/{n}/files` (after JSON.parse) AND
50
+ * `octokit.rest.pulls.listFiles(...).data` so the App can pass either
51
+ * directly.
52
+ */
53
+ export interface DiffFile {
54
+ filename: string;
55
+ patch?: string;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Stable finding id — SHA-256 of the canonical 5-tuple, truncated to 16 hex.
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Hashes the canonical `${file}:${line}:${severity}:${skill}:${summary[:100]}`
64
+ * tuple to 16 hex characters. Stable across consumers — the App computes
65
+ * the same hash for the same finding, so threads posted by one consumer
66
+ * stay matchable by the other.
67
+ *
68
+ * Summary is truncated to 100 chars BEFORE hashing so small wording tweaks
69
+ * on subsequent review passes don't generate a fresh id and orphan the
70
+ * prior thread.
71
+ *
72
+ * Distinct from `findingIdentity` in `./diff-findings` (which returns the
73
+ * plain unhashed string — used by the Resolved/Still-open block matcher).
74
+ * Both helpers coexist on purpose: the unhashed string is human-readable
75
+ * for in-prose matching; the hash is short + collision-safe for embedding
76
+ * in `<!-- finding-id: ... -->` comment-body markers.
77
+ */
78
+ export function findingId(finding: {
79
+ file?: string;
80
+ line?: number;
81
+ severity: Severity;
82
+ skill: string;
83
+ summary: string;
84
+ }): string {
85
+ const file = finding.file ?? '<no-file>';
86
+ const line = finding.line ?? 0;
87
+ const summary = finding.summary.slice(0, 100);
88
+ const payload = `${file}:${line}:${finding.severity}:${finding.skill}:${summary}`;
89
+ return createHash('sha256').update(payload).digest('hex').slice(0, 16);
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Diff anchor detection
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Returns the set of 1-indexed HEAD-side line numbers a unified-diff patch
98
+ * touches (added + context lines). GitHub only accepts inline review
99
+ * comments on lines that appear in the diff, so this gates whether a
100
+ * finding's `line` can become a thread.
101
+ *
102
+ * "Touched" = appears on the RIGHT side of the unified diff: added (`+`)
103
+ * lines OR unchanged context (` `) lines inside any hunk. Removed (`-`)
104
+ * lines are LEFT-side only and can't anchor inline comments.
105
+ *
106
+ * If `patch` is undefined (binary file, GitHub truncation) returns an empty
107
+ * set — the caller falls back to the summary comment for those findings.
108
+ */
109
+ export function parseHeadLines(patch: string | undefined): Set<number> {
110
+ const lines = new Set<number>();
111
+ if (!patch) return lines;
112
+
113
+ // Hunk header: @@ -oldStart,oldLines +newStart,newLines @@
114
+ // newLines defaults to 1 when omitted.
115
+ const hunkHeaderRe = /^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@/;
116
+
117
+ let newLine = 0;
118
+ let inHunk = false;
119
+ for (const rawLine of patch.split('\n')) {
120
+ const m = rawLine.match(hunkHeaderRe);
121
+ if (m) {
122
+ newLine = Number.parseInt(m[1]!, 10);
123
+ inHunk = true;
124
+ continue;
125
+ }
126
+ if (!inHunk) continue;
127
+
128
+ if (rawLine.startsWith('+') && !rawLine.startsWith('+++')) {
129
+ lines.add(newLine);
130
+ newLine++;
131
+ } else if (rawLine.startsWith('-') && !rawLine.startsWith('---')) {
132
+ // LEFT-side only — does not advance the newLine counter.
133
+ } else if (rawLine.startsWith('\\')) {
134
+ // "" — doesn't advance.
135
+ } else {
136
+ // Context line — counts as a HEAD-side line.
137
+ lines.add(newLine);
138
+ newLine++;
139
+ }
140
+ }
141
+
142
+ return lines;
143
+ }
144
+
145
+ /**
146
+ * Returns true when the finding's `(file, line)` can be anchored as an
147
+ * inline comment on the current diff. `diffFiles` is the per-file diff
148
+ * (as returned by `gh api repos/.../pulls/.../files`).
149
+ */
150
+ export function findingAnchorable(
151
+ finding: { file?: string; line?: number },
152
+ diffFiles: ReadonlyArray<DiffFile>,
153
+ ): boolean {
154
+ if (!finding.file || !finding.line) return false;
155
+ const file = diffFiles.find((f) => f.filename === finding.file);
156
+ if (!file) return false;
157
+ return parseHeadLines(file.patch).has(finding.line);
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Context extraction for the verifier (consumed in Wave 5b)
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * Builds the `{codeBefore, codeAfter, diffAtAnchor?}` triple Wave 5b's
166
+ * auto-resolve verifier consumes for a single finding, pulled out of the
167
+ * unified-diff hunk that touches the finding's line.
168
+ *
169
+ * Works entirely from the in-hand patch — no fresh API calls. The patch
170
+ * already encodes BEFORE (lines with `-` or context) and AFTER (lines with
171
+ * `+` or context) for every hunk the PR touches.
172
+ *
173
+ * Returns empty strings for `codeBefore`/`codeAfter` when the finding's
174
+ * file isn't in the diff at all. The verifier sees "unchanged" and
175
+ * typically returns NOT_ADDRESSED / UNCERTAIN, which is the correct
176
+ * behavior — an unchanged file isn't a fix.
177
+ */
178
+ export function extractAnchorContext(
179
+ finding: { file?: string; line?: number },
180
+ diffFiles: ReadonlyArray<DiffFile>,
181
+ ): { codeBefore: string; codeAfter: string; diffAtAnchor?: string } {
182
+ if (!finding.file || !finding.line) {
183
+ return { codeBefore: '', codeAfter: '' };
184
+ }
185
+ const file = diffFiles.find((f) => f.filename === finding.file);
186
+ if (!file || !file.patch) {
187
+ return { codeBefore: '', codeAfter: '' };
188
+ }
189
+
190
+ // Locate the hunk whose new-side range contains finding.line.
191
+ const hunkHeaderRe = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/;
192
+ const patchLines = file.patch.split('\n');
193
+ let currentHunkStart = -1;
194
+
195
+ for (let i = 0; i < patchLines.length; i++) {
196
+ const m = patchLines[i]!.match(hunkHeaderRe);
197
+ if (!m) continue;
198
+ const newStart = Number.parseInt(m[3]!, 10);
199
+ const newLen = m[4] !== undefined ? Number.parseInt(m[4]!, 10) : 1;
200
+ if (
201
+ finding.line >= newStart &&
202
+ finding.line < newStart + Math.max(newLen, 1)
203
+ ) {
204
+ currentHunkStart = i;
205
+ break;
206
+ }
207
+ }
208
+
209
+ if (currentHunkStart < 0) {
210
+ return { codeBefore: '', codeAfter: '' };
211
+ }
212
+
213
+ const before: string[] = [];
214
+ const after: string[] = [];
215
+ const hunkBody: string[] = [patchLines[currentHunkStart]!];
216
+
217
+ for (let i = currentHunkStart + 1; i < patchLines.length; i++) {
218
+ const line = patchLines[i]!;
219
+ if (line.match(hunkHeaderRe)) break;
220
+ hunkBody.push(line);
221
+
222
+ if (line.startsWith('+') && !line.startsWith('+++')) {
223
+ after.push(line.slice(1));
224
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
225
+ before.push(line.slice(1));
226
+ } else if (line.startsWith('\\')) {
227
+ // No-newline marker — skip.
228
+ } else if (line.length > 0) {
229
+ // Context line — appears on both sides.
230
+ const stripped = line.startsWith(' ') ? line.slice(1) : line;
231
+ before.push(stripped);
232
+ after.push(stripped);
233
+ }
234
+ }
235
+
236
+ return {
237
+ codeBefore: before.join('\n'),
238
+ codeAfter: after.join('\n'),
239
+ diffAtAnchor: hunkBody.join('\n'),
240
+ };
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Comment body rendering
245
+ // ---------------------------------------------------------------------------
246
+
247
+ /**
248
+ * Body for a single inline-thread comment. Includes a hidden HTML marker
249
+ * carrying `findingId` so Wave 5b's auto-resolve can recover the canonical
250
+ * id from the thread's first comment body without persisting state.
251
+ */
252
+ export function renderThreadBody(finding: FindingForThread): string {
253
+ const id = findingId(finding);
254
+ const severityBadge = finding.severity === 'critical' ? '🔴' : '🟡';
255
+ const header = `${severityBadge} \`${finding.skill}\``;
256
+ const body = finding.summary.trim();
257
+ const reasoning = finding.reasoning ? `\n\n${finding.reasoning.trim()}` : '';
258
+ return `<!-- finding-id: ${id} -->\n${header}\n\n${body}${reasoning}`;
259
+ }
260
+
261
+ /**
262
+ * Extracts the hashed `findingId` from a thread comment body that was
263
+ * rendered by `renderThreadBody`. Returns null when the marker is absent
264
+ * (a manual reply, or a comment posted by a different tool).
265
+ *
266
+ * Used by Wave 5b's auto-resolve to re-derive finding ids from existing
267
+ * GitHub threads on a stateless re-run.
268
+ */
269
+ export function extractFindingIdFromBody(body: string): string | null {
270
+ // Anchor to start-of-string so a user reply that quotes the marker can't be
271
+ // misattributed as bot-authored. `renderThreadBody` always places the marker
272
+ // on line 1 — anything else is suspect input.
273
+ const m = body.match(/^<!--\s*finding-id:\s*([a-f0-9]{16})\s*-->/);
274
+ return m ? m[1]! : null;
275
+ }
276
+
277
+ /**
278
+ * Wave 5b — invert `renderThreadBody` to recover the original finding
279
+ * tuple from a bot-authored thread comment. The fix-push auto-resolve
280
+ * CLI verb uses this to reconstruct `PriorFinding` from the GraphQL
281
+ * `reviewThreads` response without persistent state.
282
+ *
283
+ * Returns null when the body doesn't match the canonical shape
284
+ * `<!-- finding-id: XX -->\n🔴|🟡 \`skill\`\n\nsummary[\n\nreasoning]`.
285
+ * Same start-anchored posture as `extractFindingIdFromBody` for anti-
286
+ * injection: a user reply that quotes the marker mid-body won't be
287
+ * misattributed as bot-authored.
288
+ */
289
+ export function parseThreadBody(body: string): {
290
+ findingId: string;
291
+ severity: Severity;
292
+ skill: string;
293
+ summary: string;
294
+ reasoning?: string;
295
+ } | null {
296
+ // Marker (line 1), header (line 2: badge + backticked skill), blank,
297
+ // summary, optional blank + reasoning. The header line shape:
298
+ // `🔴 \`critical-issues-only\`` or `🟡 \`magic-numbers\``
299
+ const re =
300
+ /^<!--\s*finding-id:\s*([a-f0-9]{16})\s*-->\n(🔴|🟡)\s+`([^`]+)`\n\n([\s\S]+?)(?:\n\n([\s\S]+))?$/;
301
+ const m = body.match(re);
302
+ if (!m) return null;
303
+ const [, findingId, badge, skill, summary, reasoning] = m;
304
+ const severity: Severity = badge === '🔴' ? 'critical' : 'minor';
305
+ const out: {
306
+ findingId: string;
307
+ severity: Severity;
308
+ skill: string;
309
+ summary: string;
310
+ reasoning?: string;
311
+ } = {
312
+ findingId: findingId!,
313
+ severity,
314
+ skill: skill!,
315
+ summary: summary!.trim(),
316
+ };
317
+ if (reasoning && reasoning.trim()) out.reasoning = reasoning.trim();
318
+ return out;
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Inline-thread posting plan
323
+ // ---------------------------------------------------------------------------
324
+
325
+ /**
326
+ * One entry in the createReview `comments[]` array. Matches GitHub's REST
327
+ * shape exactly so callers can JSON.stringify directly into a `gh api` body.
328
+ */
329
+ export interface InlineCommentPlan {
330
+ path: string;
331
+ line: number;
332
+ side: 'RIGHT';
333
+ body: string;
334
+ }
335
+
336
+ export interface PlanInlineThreadsResult {
337
+ /** Comment entries for the createReview `comments[]` field. */
338
+ comments: InlineCommentPlan[];
339
+ /** Findings the diff couldn't anchor (file or line not in any hunk). */
340
+ skipped: FindingForThread[];
341
+ /** Findings filtered out because severity is 'preexisting' (informational only). */
342
+ preexisting: FindingForThread[];
343
+ }
344
+
345
+ /**
346
+ * Partitions a flat findings list into the `comments[]` body for
347
+ * `POST /repos/:owner/:repo/pulls/:n/reviews` + the findings the caller
348
+ * should surface elsewhere (summary comment for `skipped`; never for
349
+ * `preexisting`).
350
+ *
351
+ * Pure function — no I/O. The CLI verb uses this to compute the API body;
352
+ * the App can use it after Wave 5c migration to share the same partitioning.
353
+ */
354
+ export function planInlineThreads(
355
+ findings: ReadonlyArray<FindingForThread>,
356
+ diffFiles: ReadonlyArray<DiffFile>,
357
+ ): PlanInlineThreadsResult {
358
+ const comments: InlineCommentPlan[] = [];
359
+ const skipped: FindingForThread[] = [];
360
+ const preexisting: FindingForThread[] = [];
361
+
362
+ for (const f of findings) {
363
+ if (f.severity === 'preexisting') {
364
+ preexisting.push(f);
365
+ continue;
366
+ }
367
+ if (!findingAnchorable(f, diffFiles)) {
368
+ skipped.push(f);
369
+ continue;
370
+ }
371
+ comments.push({
372
+ path: f.file!,
373
+ line: f.line!,
374
+ side: 'RIGHT',
375
+ body: renderThreadBody(f),
376
+ });
377
+ }
378
+
379
+ return { comments, skipped, preexisting };
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // GraphQL operations — pinned to module scope so callers (and tests) can
384
+ // reference the same query strings as the App.
385
+ // ---------------------------------------------------------------------------
386
+
387
+ // Wave 5b update — `author { login }` added so the resolve-threads CLI
388
+ // verb can filter to bot-authored threads. Without this, the bot-filter
389
+ // reads `comments[0].author?.login` as undefined and accepts everything,
390
+ // which would feed user-authored threads (that happen to start with the
391
+ // marker) into the verifier. Additive change — Wave 5a consumers
392
+ // (post-inline-threads) didn't read `author` so they're unaffected.
393
+ export const REVIEW_THREADS_QUERY = `
394
+ query ReviewThreads($owner: String!, $repo: String!, $pr: Int!) {
395
+ repository(owner: $owner, name: $repo) {
396
+ pullRequest(number: $pr) {
397
+ reviewThreads(first: 100) {
398
+ nodes {
399
+ id
400
+ isResolved
401
+ comments(first: 100) {
402
+ nodes {
403
+ databaseId
404
+ path
405
+ line
406
+ originalLine
407
+ body
408
+ author {
409
+ login
410
+ }
411
+ }
412
+ }
413
+ }
414
+ }
415
+ }
416
+ }
417
+ }
418
+ `;
419
+
420
+ // Includes `body` (unlike the App's lighter state query, which omits it) so
421
+ // Wave 5b's auto-resolve can re-derive the `findingId` from the
422
+ // `<!-- finding-id: ... -->` marker on each thread's first comment WITHOUT
423
+ // persisting state between fix-pushes. The npm workflow is stateless; the
424
+ // body field is the substitute for Redis-backed thread-id correlation.
425
+ export const REVIEW_THREADS_STATE_QUERY = `
426
+ query ReviewThreadStates($owner: String!, $repo: String!, $pr: Int!) {
427
+ repository(owner: $owner, name: $repo) {
428
+ pullRequest(number: $pr) {
429
+ reviewThreads(first: 100) {
430
+ nodes {
431
+ id
432
+ isResolved
433
+ comments(first: 1) {
434
+ nodes {
435
+ databaseId
436
+ body
437
+ }
438
+ }
439
+ }
440
+ }
441
+ }
442
+ }
443
+ }
444
+ `;
445
+
446
+ export const RESOLVE_THREAD_MUTATION = `
447
+ mutation ResolveThread($threadId: ID!) {
448
+ resolveReviewThread(input: { threadId: $threadId }) {
449
+ thread {
450
+ isResolved
451
+ }
452
+ }
453
+ }
454
+ `;
455
+
456
+ // Wave 5b — post a reply on a review thread (used to attach the
457
+ // auto-resolve marker explaining the verifier's verdict) BEFORE
458
+ // calling `RESOLVE_THREAD_MUTATION`. The reply is visible in the PR
459
+ // timeline so reviewers can audit why the bot auto-resolved.
460
+ export const ADD_REPLY_MUTATION = `
461
+ mutation AddThreadReply($threadId: ID!, $body: String!) {
462
+ addPullRequestReviewThreadReply(
463
+ input: { pullRequestReviewThreadId: $threadId, body: $body }
464
+ ) {
465
+ comment {
466
+ id
467
+ body
468
+ }
469
+ }
470
+ }
471
+ `;