clud-bug 0.6.34 → 0.7.0-rc.11

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 (153) hide show
  1. package/README.md +20 -0
  2. package/bin/clud-bug.js +10 -1353
  3. package/data/canonical-v1.json +37 -0
  4. package/dist/cli/agents-md.d.ts +16 -0
  5. package/dist/cli/agents-md.d.ts.map +1 -0
  6. package/dist/cli/agents-md.js +226 -0
  7. package/dist/cli/agents-md.js.map +1 -0
  8. package/dist/cli/audit.d.ts +13 -0
  9. package/dist/cli/audit.d.ts.map +1 -0
  10. package/dist/cli/audit.js +90 -0
  11. package/dist/cli/audit.js.map +1 -0
  12. package/dist/cli/branch-protection.d.ts +57 -0
  13. package/dist/cli/branch-protection.d.ts.map +1 -0
  14. package/dist/cli/branch-protection.js +118 -0
  15. package/dist/cli/branch-protection.js.map +1 -0
  16. package/dist/cli/configure-github.d.ts +58 -0
  17. package/dist/cli/configure-github.d.ts.map +1 -0
  18. package/dist/cli/configure-github.js +242 -0
  19. package/dist/cli/configure-github.js.map +1 -0
  20. package/dist/cli/edit-workflow.d.ts +18 -0
  21. package/dist/cli/edit-workflow.d.ts.map +1 -0
  22. package/dist/cli/edit-workflow.js +43 -0
  23. package/dist/cli/edit-workflow.js.map +1 -0
  24. package/dist/cli/index.d.ts +9 -0
  25. package/dist/cli/index.d.ts.map +1 -0
  26. package/dist/cli/index.js +19 -0
  27. package/dist/cli/index.js.map +1 -0
  28. package/dist/cli/main.d.ts +3 -0
  29. package/dist/cli/main.d.ts.map +1 -0
  30. package/dist/cli/main.js +2067 -0
  31. package/dist/cli/main.js.map +1 -0
  32. package/dist/cli/skill-usage.d.ts +109 -0
  33. package/dist/cli/skill-usage.d.ts.map +1 -0
  34. package/dist/cli/skill-usage.js +380 -0
  35. package/dist/cli/skill-usage.js.map +1 -0
  36. package/dist/cli/skills.d.ts +56 -0
  37. package/dist/cli/skills.d.ts.map +1 -0
  38. package/dist/cli/skills.js +292 -0
  39. package/dist/cli/skills.js.map +1 -0
  40. package/dist/cli/update.d.ts +29 -0
  41. package/dist/cli/update.d.ts.map +1 -0
  42. package/dist/cli/update.js +205 -0
  43. package/dist/cli/update.js.map +1 -0
  44. package/dist/cli/usage.d.ts +142 -0
  45. package/dist/cli/usage.d.ts.map +1 -0
  46. package/dist/cli/usage.js +348 -0
  47. package/dist/cli/usage.js.map +1 -0
  48. package/dist/core/audit.d.ts +8 -0
  49. package/dist/core/audit.d.ts.map +1 -0
  50. package/dist/core/audit.js +47 -0
  51. package/dist/core/audit.js.map +1 -0
  52. package/dist/core/auto-resolve.d.ts +235 -0
  53. package/dist/core/auto-resolve.d.ts.map +1 -0
  54. package/dist/core/auto-resolve.js +293 -0
  55. package/dist/core/auto-resolve.js.map +1 -0
  56. package/dist/core/configure-github.d.ts +179 -0
  57. package/dist/core/configure-github.d.ts.map +1 -0
  58. package/dist/core/configure-github.js +289 -0
  59. package/dist/core/configure-github.js.map +1 -0
  60. package/dist/core/detect.d.ts +77 -0
  61. package/dist/core/detect.d.ts.map +1 -0
  62. package/dist/core/detect.js +262 -0
  63. package/dist/core/detect.js.map +1 -0
  64. package/dist/core/diff-findings.d.ts +96 -0
  65. package/dist/core/diff-findings.d.ts.map +1 -0
  66. package/dist/core/diff-findings.js +261 -0
  67. package/dist/core/diff-findings.js.map +1 -0
  68. package/dist/core/formal-review.d.ts +79 -0
  69. package/dist/core/formal-review.d.ts.map +1 -0
  70. package/dist/core/formal-review.js +71 -0
  71. package/dist/core/formal-review.js.map +1 -0
  72. package/dist/core/index.d.ts +17 -0
  73. package/dist/core/index.d.ts.map +1 -0
  74. package/dist/core/index.js +66 -0
  75. package/dist/core/index.js.map +1 -0
  76. package/dist/core/inline-threads.d.ts +160 -0
  77. package/dist/core/inline-threads.d.ts.map +1 -0
  78. package/dist/core/inline-threads.js +369 -0
  79. package/dist/core/inline-threads.js.map +1 -0
  80. package/dist/core/prompt-builder.d.ts +164 -0
  81. package/dist/core/prompt-builder.d.ts.map +1 -0
  82. package/dist/core/prompt-builder.js +419 -0
  83. package/dist/core/prompt-builder.js.map +1 -0
  84. package/dist/core/prompts.d.ts +9 -0
  85. package/dist/core/prompts.d.ts.map +1 -0
  86. package/dist/core/prompts.js +401 -0
  87. package/dist/core/prompts.js.map +1 -0
  88. package/dist/core/render-review.d.ts +6 -0
  89. package/dist/core/render-review.d.ts.map +1 -0
  90. package/dist/core/render-review.js +219 -0
  91. package/dist/core/render-review.js.map +1 -0
  92. package/dist/core/render.d.ts +13 -0
  93. package/dist/core/render.d.ts.map +1 -0
  94. package/dist/core/render.js +62 -0
  95. package/dist/core/render.js.map +1 -0
  96. package/dist/core/resolve-verifier.d.ts +46 -0
  97. package/dist/core/resolve-verifier.d.ts.map +1 -0
  98. package/dist/core/resolve-verifier.js +187 -0
  99. package/dist/core/resolve-verifier.js.map +1 -0
  100. package/dist/core/review-schema-zod.d.ts +240 -0
  101. package/dist/core/review-schema-zod.d.ts.map +1 -0
  102. package/dist/core/review-schema-zod.js +218 -0
  103. package/dist/core/review-schema-zod.js.map +1 -0
  104. package/dist/core/review-schema.d.ts +42 -0
  105. package/dist/core/review-schema.d.ts.map +1 -0
  106. package/dist/core/review-schema.js +156 -0
  107. package/dist/core/review-schema.js.map +1 -0
  108. package/dist/core/review-writeback.d.ts +193 -0
  109. package/dist/core/review-writeback.d.ts.map +1 -0
  110. package/dist/core/review-writeback.js +364 -0
  111. package/dist/core/review-writeback.js.map +1 -0
  112. package/dist/core/skills.d.ts +147 -0
  113. package/dist/core/skills.d.ts.map +1 -0
  114. package/dist/core/skills.js +726 -0
  115. package/dist/core/skills.js.map +1 -0
  116. package/dist/core/version.d.ts +2 -0
  117. package/dist/core/version.d.ts.map +1 -0
  118. package/dist/core/version.js +4 -0
  119. package/dist/core/version.js.map +1 -0
  120. package/package.json +37 -5
  121. package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
  122. package/{lib/audit.js → src/cli/audit.ts} +37 -44
  123. package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
  124. package/src/cli/configure-github.ts +329 -0
  125. package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
  126. package/src/cli/index.ts +107 -0
  127. package/src/cli/main.ts +2204 -0
  128. package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
  129. package/src/cli/skills.ts +386 -0
  130. package/{lib/update.js → src/cli/update.ts} +87 -27
  131. package/{lib/usage.js → src/cli/usage.ts} +167 -76
  132. package/src/core/audit.ts +53 -0
  133. package/src/core/auto-resolve.ts +488 -0
  134. package/src/core/configure-github.ts +497 -0
  135. package/{lib/detect.js → src/core/detect.ts} +100 -47
  136. package/src/core/diff-findings.ts +323 -0
  137. package/src/core/formal-review.ts +168 -0
  138. package/src/core/index.ts +250 -0
  139. package/src/core/inline-threads.ts +471 -0
  140. package/src/core/prompt-builder.ts +561 -0
  141. package/{lib/prompts.js → src/core/prompts.ts} +16 -2
  142. package/{lib/render-review.js → src/core/render-review.ts} +57 -25
  143. package/{lib/render.js → src/core/render.ts} +37 -17
  144. package/src/core/resolve-verifier.ts +228 -0
  145. package/src/core/review-schema-zod.ts +262 -0
  146. package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
  147. package/src/core/review-writeback.ts +565 -0
  148. package/{lib/skills.js → src/core/skills.ts} +456 -342
  149. package/src/core/version.ts +3 -0
  150. package/templates/clud-bug-review.md.tmpl +115 -0
  151. package/templates/workflow-py.yml.tmpl +80 -3
  152. package/templates/workflow-ts.yml.tmpl +80 -3
  153. package/templates/workflow.yml.tmpl +175 -8
@@ -15,21 +15,52 @@
15
15
  // rendered shape is wrong). Centralising the markdown shape here means a
16
16
  // future format tweak edits one function rather than the prompt.
17
17
 
18
- const SEVERITY_EMOJI = { critical: '🔴', minor: '🟡', preexisting: '🟣' };
19
- const SEVERITY_LABEL = {
18
+ import type {
19
+ DedicatedSection,
20
+ FindingSeverity,
21
+ PerSkillScanItem,
22
+ ReviewData,
23
+ ReviewFinding,
24
+ ReviewSummaryCounts,
25
+ } from './review-schema.js';
26
+
27
+ // Emoji constants: use explicit Unicode escape literals (`\u{HHHHH}`) so
28
+ // every step of the TS→JS toolchain — tsc, vitest's transformer, the
29
+ // publisher's tarball — emits the same byte sequence regardless of
30
+ // editor encoding settings. Per SPEC §6 byte-identical contract:
31
+ // \u{1F534} = 🔴 (red circle, critical / "important")
32
+ // \u{1F7E1} = 🟡 (yellow circle, minor / "nit")
33
+ // \u{1F7E3} = 🟣 (purple circle, pre-existing)
34
+ // \u{1F41B} = 🐛 (bug, H2 anchor for `## 🐛 Clud Bug review`)
35
+ const SEVERITY_EMOJI: Record<FindingSeverity, string> = {
36
+ critical: '\u{1F534}',
37
+ minor: '\u{1F7E1}',
38
+ preexisting: '\u{1F7E3}',
39
+ };
40
+ const SEVERITY_LABEL: Record<FindingSeverity, string> = {
20
41
  critical: 'important',
21
42
  minor: 'nit',
22
43
  preexisting: 'pre-existing',
23
44
  };
45
+ // SEVERITY_LABEL is retained for callers that import the constant table
46
+ // (the JS version exported it implicitly via module scope; keep the
47
+ // export so future renderer extensions can reuse it).
48
+ export { SEVERITY_LABEL };
49
+
50
+ // Renderer input type — schema-aligned but defensively typed. The renderer
51
+ // is the last line of defense against malformed JSON, so it accepts an
52
+ // "unknown-ish" shape and degrades gracefully rather than throwing on
53
+ // missing fields.
54
+ type RenderReviewInput = Partial<ReviewData> & Record<string, unknown>;
24
55
 
25
56
  // Render the full summary comment markdown. `data` is the parsed JSON
26
- // matching the schema (see schema.js). Returns a string suitable for
27
- // `gh pr comment --body`.
28
- export function renderReview(data) {
57
+ // matching the schema (see review-schema.ts). Returns a string suitable
58
+ // for `gh pr comment --body`.
59
+ export function renderReview(data: RenderReviewInput | null | undefined): string {
29
60
  if (!data || typeof data !== 'object') {
30
61
  throw new TypeError('renderReview: data must be an object');
31
62
  }
32
- const out = [];
63
+ const out: string[] = [];
33
64
  out.push(renderHeader(data));
34
65
  out.push('');
35
66
  out.push(renderStatusLine(data.summary_counts));
@@ -77,9 +108,9 @@ export function renderReview(data) {
77
108
  return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
78
109
  }
79
110
 
80
- function renderHeader(data) {
111
+ function renderHeader(data: RenderReviewInput): string {
81
112
  const verdict = data.status_header;
82
- const base = '## 🐛 Clud Bug review';
113
+ const base = '## \u{1F41B} Clud Bug review';
83
114
  if (verdict === 'critical findings') return `${base} — critical findings`;
84
115
  if (verdict === 'clean') return `${base} — clean`;
85
116
  // 'bare' (non-strict-mode default) OR an unexpected verdict — render
@@ -87,7 +118,7 @@ function renderHeader(data) {
87
118
  return base;
88
119
  }
89
120
 
90
- function renderStatusLine(counts) {
121
+ function renderStatusLine(counts: ReviewSummaryCounts | undefined): string {
91
122
  const c = sanitizeCounts(counts);
92
123
  return `**This round:** ${c.critical} critical · ${c.minor} minor · ${c.resolved_from_prior} resolved from prior · ${c.still_open} still open`;
93
124
  }
@@ -95,13 +126,13 @@ function renderStatusLine(counts) {
95
126
  // Severity-emoji stats header. Counts pre-existing in 🟣 even though
96
127
  // it's not in summary_counts (the prompt counts pre-existing separately
97
128
  // in preexisting_findings.length).
98
- function renderStatsHeader(counts) {
129
+ function renderStatsHeader(counts: ReviewSummaryCounts | undefined): string {
99
130
  const c = sanitizeCounts(counts);
100
- return `Found: ${c.critical} 🔴 / ${c.minor} 🟡 / ${c.preexisting} 🟣`;
131
+ return `Found: ${c.critical} \u{1F534} / ${c.minor} \u{1F7E1} / ${c.preexisting} \u{1F7E3}`;
101
132
  }
102
133
 
103
- function renderPerSkillScan(scan) {
104
- const out = ['### Per-skill scan'];
134
+ function renderPerSkillScan(scan: PerSkillScanItem[] | undefined): string[] {
135
+ const out: string[] = ['### Per-skill scan'];
105
136
  if (!Array.isArray(scan) || scan.length === 0) {
106
137
  out.push('- (no skills loaded — review proceeded against the baseline.)');
107
138
  return out;
@@ -116,14 +147,14 @@ function renderPerSkillScan(scan) {
116
147
  return out;
117
148
  }
118
149
 
119
- function renderDedicatedSection(section) {
150
+ function renderDedicatedSection(section: DedicatedSection | undefined): string[] {
120
151
  if (!section || typeof section !== 'object') return [];
121
152
  const name = String(section.section_name || '').trim();
122
153
  const skill = String(section.skill || '').trim();
123
154
  const header = skill && name
124
155
  ? `### ${name} [${skill}]`
125
156
  : `### ${name || skill || 'Dedicated section'}`;
126
- const out = [header, ''];
157
+ const out: string[] = [header, ''];
127
158
  if (Array.isArray(section.findings) && section.findings.length > 0) {
128
159
  // Dedicated-section findings use the same emoji-prefix block.
129
160
  // Default severity for dedicated sections is "critical" — they're
@@ -135,9 +166,10 @@ function renderDedicatedSection(section) {
135
166
  return out;
136
167
  }
137
168
 
138
- function renderFindings(findings, severity) {
139
- const emoji = SEVERITY_EMOJI[severity] || '🔴';
140
- const out = [];
169
+ function renderFindings(findings: ReviewFinding[] | undefined, severity: FindingSeverity): string[] {
170
+ const emoji = SEVERITY_EMOJI[severity] || SEVERITY_EMOJI.critical;
171
+ const out: string[] = [];
172
+ if (!Array.isArray(findings)) return out;
141
173
  for (const f of findings) {
142
174
  if (!f || typeof f !== 'object') continue;
143
175
  const skill = String(f.skill || '').trim();
@@ -163,7 +195,7 @@ function renderFindings(findings, severity) {
163
195
  return out;
164
196
  }
165
197
 
166
- function renderSkillsReferenced(skills) {
198
+ function renderSkillsReferenced(skills: string[] | undefined): string {
167
199
  if (!Array.isArray(skills) || skills.length === 0) {
168
200
  return 'Skills referenced: [none] — no installed skill applied to this diff.';
169
201
  }
@@ -172,12 +204,12 @@ function renderSkillsReferenced(skills) {
172
204
 
173
205
  // --- helpers ---
174
206
 
175
- function nonEmpty(arr) {
207
+ function nonEmpty<T>(arr: T[] | undefined): arr is T[] {
176
208
  return Array.isArray(arr) && arr.length > 0;
177
209
  }
178
210
 
179
- function sanitizeCounts(counts) {
180
- const c = counts && typeof counts === 'object' ? counts : {};
211
+ function sanitizeCounts(counts: ReviewSummaryCounts | undefined): ReviewSummaryCounts {
212
+ const c = counts && typeof counts === 'object' ? counts : ({} as Partial<ReviewSummaryCounts>);
181
213
  return {
182
214
  critical: numOrZero(c.critical),
183
215
  minor: numOrZero(c.minor),
@@ -187,18 +219,18 @@ function sanitizeCounts(counts) {
187
219
  };
188
220
  }
189
221
 
190
- function numOrZero(v) {
222
+ function numOrZero(v: unknown): number {
191
223
  const n = Number(v);
192
224
  return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
193
225
  }
194
226
 
195
- function locationAnchor(f) {
227
+ function locationAnchor(f: ReviewFinding): string | null {
196
228
  const file = String(f.file || '').trim();
197
229
  if (!file) return null;
198
230
  const line = Number(f.line);
199
231
  return Number.isFinite(line) && line > 0 ? `${file}:${line}` : file;
200
232
  }
201
233
 
202
- function stripTrailingPunctuation(s) {
234
+ function stripTrailingPunctuation(s: string): string {
203
235
  return s.replace(/[.!?]+$/, '');
204
236
  }
@@ -1,21 +1,32 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { readFileSync } from 'node:fs';
3
- import { fileURLToPath } from 'node:url';
4
- import { dirname, join } from 'node:path';
5
2
  import { serializedReviewSchema } from './review-schema.js';
3
+ import { PKG_VERSION } from './version.js';
6
4
 
7
5
  const PLACEHOLDER_RE = /\{\{([A-Z_]+)\}\}/g;
8
6
 
9
- // CLUD_BUG_VERSION (0.0.O / v0.6.22) read from this package's
10
- // package.json at module-load time. The rendered workflow uses
7
+ // CLUD_BUG_VERSION is baked at build time by scripts/gen-version.mjs into
8
+ // src/core/version.ts (a git-ignored file, regenerated on every `npm run
9
+ // build` via the "prebuild" hook). The rendered workflow uses
11
10
  // `npx --yes clud-bug@<CLUD_BUG_VERSION>` in the post-step that renders
12
11
  // structured output to markdown. Pinning to the version that ran
13
12
  // `clud-bug init` guarantees the renderer's output shape matches the
14
13
  // prompt's expectations.
15
- const __dirname = dirname(fileURLToPath(import.meta.url));
16
- const PKG_VERSION = JSON.parse(
17
- readFileSync(join(__dirname, '..', 'package.json'), 'utf8'),
18
- ).version;
14
+ //
15
+ // History (v0.7.0-rc.5): previously this module did
16
+ // `readFileSync(package.json)` at module-load. Under Next.js bundling in
17
+ // clud-bug-app, `import.meta.url` resolved to `.next/server/...` not the
18
+ // node_modules layout, so the fs call threw ENOENT (Phase 5.1 hotfix in
19
+ // clud-bug-app PR #50 worked around with `serverExternalPackages`).
20
+ // Codegen eliminates the runtime fs lookup entirely.
21
+
22
+ // Public shape of the DEFAULTS map. Tests assert presence of CCA_VERSION
23
+ // and CLUD_BUG_VERSION; REVIEW_SCHEMA is the serialized schema string the
24
+ // templates inject via the {{REVIEW_SCHEMA}} placeholder.
25
+ export interface RenderDefaults {
26
+ CCA_VERSION: string;
27
+ CLUD_BUG_VERSION: string;
28
+ REVIEW_SCHEMA: string;
29
+ }
19
30
 
20
31
  // Default values for substitution tokens that every template uses.
21
32
  // Callers can override per-render by passing the same key in `vars`.
@@ -26,19 +37,26 @@ const PKG_VERSION = JSON.parse(
26
37
  // workflows mid-cycle. Bumping the pin requires a clud-bug release, which
27
38
  // makes the upgrade visible + lets users opt out by pinning a different
28
39
  // version in their own forked workflow.
29
- export const DEFAULTS = {
40
+ export const DEFAULTS: RenderDefaults = {
30
41
  CCA_VERSION: 'v1.0.133',
31
42
  CLUD_BUG_VERSION: PKG_VERSION,
32
43
  REVIEW_SCHEMA: serializedReviewSchema(),
33
44
  };
34
45
 
46
+ // Caller-supplied substitution vars: any extra placeholders the template
47
+ // uses (REVIEW_PROMPT, PROJECT_DESCRIPTION, etc.). Values may be strings,
48
+ // numbers, or anything String()-coercible. We accept a wider unknown type
49
+ // because the JS callers freely pass numbers, arrays, etc., and the
50
+ // String(value) coercion below handles them uniformly.
51
+ export type RenderVars = Partial<RenderDefaults> & Record<string, unknown>;
52
+
35
53
  // Multi-line value substitution preserves YAML/Markdown indentation by
36
54
  // applying the placeholder line's leading whitespace to every
37
55
  // continuation line. Single-line values pass through unchanged so
38
56
  // existing tokens (CCA_VERSION, PROJECT_DESCRIPTION) keep current behavior.
39
- export function render(template, vars) {
40
- const merged = { ...DEFAULTS, ...vars };
41
- return template.replace(PLACEHOLDER_RE, (match, key, offset) => {
57
+ export function render(template: string, vars: RenderVars): string {
58
+ const merged: Record<string, unknown> = { ...DEFAULTS, ...vars };
59
+ return template.replace(PLACEHOLDER_RE, (_match, key: string, offset: number) => {
42
60
  if (!(key in merged)) {
43
61
  throw new Error(`Missing template variable: ${key}`);
44
62
  }
@@ -48,7 +66,7 @@ export function render(template, vars) {
48
66
  }
49
67
  const lineStart = template.lastIndexOf('\n', offset - 1) + 1;
50
68
  const leadingWhitespaceMatch = template.slice(lineStart, offset).match(/^(\s*)/);
51
- const indent = leadingWhitespaceMatch ? leadingWhitespaceMatch[1] : '';
69
+ const indent = leadingWhitespaceMatch ? (leadingWhitespaceMatch[1] ?? '') : '';
52
70
  return value
53
71
  .split('\n')
54
72
  .map((line, i) => (i === 0 || line === '' ? line : indent + line))
@@ -56,12 +74,12 @@ export function render(template, vars) {
56
74
  });
57
75
  }
58
76
 
59
- export async function renderFile(path, vars) {
77
+ export async function renderFile(path: string, vars: RenderVars): Promise<string> {
60
78
  const tmpl = await readFile(path, 'utf8');
61
79
  return render(tmpl, vars);
62
80
  }
63
81
 
64
- export function pickTemplate(languages) {
82
+ export function pickTemplate(languages: string[]): string {
65
83
  if (languages.includes('typescript') || languages.includes('javascript')) {
66
84
  return 'workflow-ts.yml.tmpl';
67
85
  }
@@ -74,7 +92,9 @@ export function pickTemplate(languages) {
74
92
  // Map a pickTemplate() filename to the language key that `reviewPrompt`
75
93
  // accepts. Keeps the mapping in one place so callers don't repeat the
76
94
  // switch when computing the REVIEW_PROMPT token.
77
- export function templateLanguage(tmplName) {
95
+ export type TemplateLanguage = 'ts' | 'py' | 'generic';
96
+
97
+ export function templateLanguage(tmplName: string): TemplateLanguage {
78
98
  if (tmplName === 'workflow-ts.yml.tmpl') return 'ts';
79
99
  if (tmplName === 'workflow-py.yml.tmpl') return 'py';
80
100
  return 'generic';
@@ -0,0 +1,228 @@
1
+ // Wave 5b — D.2.6 per-thread fix-verifier prompt + response parser.
2
+ //
3
+ // On every fix-push, the CLI verb asks Claude DIRECTLY for each prior
4
+ // thread: "the prior reviewer said X. Here is the BEFORE / AFTER /
5
+ // DIFF. Did the new commit address the original finding?" Verdicts:
6
+ //
7
+ // ADDRESSED — fix unambiguously resolves the original concern
8
+ // NOT_ADDRESSED — original concern still applies
9
+ // UNCERTAIN — can't tell; routes through human review
10
+ //
11
+ // This module is PURE — prompt builder + response parser. The actual
12
+ // Anthropic Messages API call lives in `src/cli/main.ts::runResolveThreads`
13
+ // (raw `fetch()` — no `@anthropic-ai/sdk` dep so the npm package stays
14
+ // dep-light). Tests inject mocked verifier outcomes via the
15
+ // `runAutoResolve` `verifier` callback in `./auto-resolve`.
16
+ //
17
+ // Ported from `clud-bug-app/lib/resolve-verifier.ts:162-260`. Differences:
18
+ // - No `@ai-sdk/anthropic` / `zod` dependency. Response parsing is
19
+ // hand-rolled JSON extraction with manual validation.
20
+ // - DROP `verifySingleFinding` (App-side IO entry point). The CLI
21
+ // verb owns the API call directly.
22
+ // - DROP `aggregateMultiPassVerdicts` — OSS is single-pass.
23
+
24
+ import type { PriorFinding, VerifyOutcome } from './auto-resolve.js';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // System prompt — hard-coded, NOT derived from user content (anti-injection)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export const VERIFIER_SYSTEM = [
31
+ 'You are a code-review fix verifier. Your only job is to decide whether a',
32
+ 'specific code change addressed a specific prior reviewer concern.',
33
+ '',
34
+ 'You are NOT writing a new code review. You are NOT looking for new issues.',
35
+ 'You are NOT judging style. You are answering one question: did the change',
36
+ 'between BEFORE and AFTER resolve the concern raised in the prior finding?',
37
+ '',
38
+ 'Your verdict MUST be exactly one of: ADDRESSED, NOT_ADDRESSED, UNCERTAIN.',
39
+ '',
40
+ 'Rules:',
41
+ '- ADDRESSED: the AFTER code unambiguously resolves the original concern.',
42
+ ' Partial fixes that leave the core problem unsolved are NOT ADDRESSED.',
43
+ '- NOT_ADDRESSED: the AFTER code still has the original problem, OR the',
44
+ ' change is unrelated to the concern, OR the change made the problem worse.',
45
+ '- UNCERTAIN: you cannot tell from the BEFORE / AFTER alone. Use this when',
46
+ ' the context is too narrow, the change is subtle and could go either way,',
47
+ ' or the concern depends on code you cannot see. Never guess — say',
48
+ ' UNCERTAIN when you would be guessing.',
49
+ '',
50
+ 'Treat the prior finding as a black box: it might be wrong, but you are not',
51
+ 'judging the finding. You are judging whether the new code resolves what it',
52
+ 'said. If the finding asked for X and the change does X, mark ADDRESSED even',
53
+ 'if X is unnecessary.',
54
+ '',
55
+ 'Reply with a JSON object on a single line, no markdown fence, no surrounding',
56
+ 'prose. Shape:',
57
+ ' {"verdict":"ADDRESSED"|"NOT_ADDRESSED"|"UNCERTAIN","rationale":"<one sentence>"}',
58
+ 'Rationale MUST be one sentence, max 500 characters.',
59
+ ].join('\n');
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Input shape
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export interface VerifySingleFindingInput {
66
+ finding: PriorFinding;
67
+ /** Code at the finding's anchor BEFORE the fix-push. Empty when deleted. */
68
+ codeBefore: string;
69
+ /** Code at the finding's anchor AFTER the fix-push. Empty when deleted. */
70
+ codeAfter: string;
71
+ /** Optional: unified-diff hunk at the anchor. */
72
+ diffAtAnchor?: string;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Prompt builder — pure (no env, no Date.now, no timestamps)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Builds the user-facing prompt body.
81
+ *
82
+ * Prompt-injection defense:
83
+ * - System prompt is module-scope, NOT derived from user content.
84
+ * - User content (finding body, code, diff) is wrapped in fence-
85
+ * delimited blocks with clear labels. The model knows the
86
+ * boundaries.
87
+ * - Output is parsed with `parseVerifierResponse` which validates
88
+ * the verdict against a fixed enum + caps rationale at 500 chars.
89
+ * A model coerced to emit "ADDRESSED!" outside the JSON shape
90
+ * gets routed to UNCERTAIN+api-error (fail-closed).
91
+ */
92
+ export function buildVerifierPrompt(input: VerifySingleFindingInput): string {
93
+ const f = input.finding;
94
+ const anchor = f.line !== undefined ? `${f.file}:${f.line}` : f.file;
95
+ const sev = f.severity === 'critical' ? '🔴 critical' : '🟡 minor';
96
+
97
+ const parts: string[] = [];
98
+ parts.push(
99
+ `A prior code review (skill: \`${f.skill}\`, severity: ${sev}) flagged \`${anchor}\` with this finding:`,
100
+ );
101
+ parts.push('');
102
+ parts.push('```');
103
+ parts.push('PRIOR FINDING:');
104
+ parts.push(f.body);
105
+ parts.push('```');
106
+ parts.push('');
107
+ parts.push(
108
+ 'The PR author has since pushed a new commit. Here is the code BEFORE and AFTER:',
109
+ );
110
+ parts.push('');
111
+ parts.push('```');
112
+ parts.push('BEFORE:');
113
+ parts.push(input.codeBefore || '(empty — file did not exist or was empty)');
114
+ parts.push('```');
115
+ parts.push('');
116
+ parts.push('```');
117
+ parts.push('AFTER:');
118
+ parts.push(input.codeAfter || '(empty — file was deleted or emptied)');
119
+ parts.push('```');
120
+
121
+ if (input.diffAtAnchor) {
122
+ parts.push('');
123
+ parts.push('```');
124
+ parts.push('DIFF AT ANCHOR:');
125
+ parts.push(input.diffAtAnchor);
126
+ parts.push('```');
127
+ }
128
+
129
+ parts.push('');
130
+ parts.push('Did this change ADDRESS the original finding?');
131
+ parts.push(
132
+ 'Reply with the JSON object only — single line, no markdown fence, no prose.',
133
+ );
134
+
135
+ return parts.join('\n');
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Response parser — extracts `{verdict, rationale}` from the model text
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * Parses the model's text response into a `VerifyOutcome`. Fail-closed:
144
+ * any malformed input → UNCERTAIN+api-error with the failure mode in
145
+ * the rationale, so the caller's auto-resolve rules route through
146
+ * human review (never silently ADDRESSED).
147
+ *
148
+ * Tolerates:
149
+ * - Leading/trailing whitespace
150
+ * - The model wrapping the JSON in ```json fences (strips them)
151
+ * - Extra fields (ignored)
152
+ *
153
+ * Rejects:
154
+ * - Empty / non-string input
155
+ * - Non-JSON / malformed JSON
156
+ * - Missing or non-string `verdict`
157
+ * - `verdict` not in the allowed enum
158
+ * - Missing or empty `rationale`
159
+ * - `rationale` longer than 500 chars (caps at 500 in the outcome)
160
+ */
161
+ export function parseVerifierResponse(text: unknown): VerifyOutcome {
162
+ if (typeof text !== 'string' || !text.trim()) {
163
+ return {
164
+ verdict: 'UNCERTAIN',
165
+ source: 'api-error',
166
+ rationale: 'verifier returned empty or non-string response',
167
+ };
168
+ }
169
+
170
+ // Strip optional ```json … ``` or ``` … ``` fence.
171
+ let body = text.trim();
172
+ const fenced = body.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
173
+ if (fenced) body = fenced[1]!.trim();
174
+
175
+ let parsed: unknown;
176
+ try {
177
+ parsed = JSON.parse(body);
178
+ } catch (err) {
179
+ return {
180
+ verdict: 'UNCERTAIN',
181
+ source: 'api-error',
182
+ rationale: `verifier response was not valid JSON: ${
183
+ err instanceof Error ? err.message : String(err)
184
+ }`,
185
+ };
186
+ }
187
+
188
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
189
+ return {
190
+ verdict: 'UNCERTAIN',
191
+ source: 'api-error',
192
+ rationale: 'verifier response was not a JSON object',
193
+ };
194
+ }
195
+
196
+ const obj = parsed as Record<string, unknown>;
197
+ const verdict = obj.verdict;
198
+ const rationale = obj.rationale;
199
+
200
+ if (
201
+ verdict !== 'ADDRESSED' &&
202
+ verdict !== 'NOT_ADDRESSED' &&
203
+ verdict !== 'UNCERTAIN'
204
+ ) {
205
+ return {
206
+ verdict: 'UNCERTAIN',
207
+ source: 'api-error',
208
+ rationale: `verifier emitted unknown verdict: ${JSON.stringify(verdict)}`,
209
+ };
210
+ }
211
+
212
+ if (typeof rationale !== 'string' || !rationale.trim()) {
213
+ return {
214
+ verdict: 'UNCERTAIN',
215
+ source: 'api-error',
216
+ rationale: 'verifier response missing rationale',
217
+ };
218
+ }
219
+
220
+ // Cap rationale at 500 chars per the system prompt's contract.
221
+ const trimmed = rationale.trim().slice(0, 500);
222
+
223
+ return {
224
+ verdict,
225
+ rationale: trimmed,
226
+ source: 'model',
227
+ };
228
+ }