clud-bug 0.6.34 → 0.7.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/clud-bug.js +10 -1353
- package/dist/cli/agents-md.d.ts +16 -0
- package/dist/cli/agents-md.d.ts.map +1 -0
- package/dist/cli/agents-md.js +226 -0
- package/dist/cli/agents-md.js.map +1 -0
- package/dist/cli/audit.d.ts +13 -0
- package/dist/cli/audit.d.ts.map +1 -0
- package/dist/cli/audit.js +90 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/branch-protection.d.ts +57 -0
- package/dist/cli/branch-protection.d.ts.map +1 -0
- package/dist/cli/branch-protection.js +118 -0
- package/dist/cli/branch-protection.js.map +1 -0
- package/dist/cli/edit-workflow.d.ts +18 -0
- package/dist/cli/edit-workflow.d.ts.map +1 -0
- package/dist/cli/edit-workflow.js +43 -0
- package/dist/cli/edit-workflow.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +18 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +1336 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/skill-usage.d.ts +109 -0
- package/dist/cli/skill-usage.d.ts.map +1 -0
- package/dist/cli/skill-usage.js +380 -0
- package/dist/cli/skill-usage.js.map +1 -0
- package/dist/cli/skills.d.ts +56 -0
- package/dist/cli/skills.d.ts.map +1 -0
- package/dist/cli/skills.js +292 -0
- package/dist/cli/skills.js.map +1 -0
- package/dist/cli/update.d.ts +29 -0
- package/dist/cli/update.d.ts.map +1 -0
- package/dist/cli/update.js +186 -0
- package/dist/cli/update.js.map +1 -0
- package/dist/cli/usage.d.ts +142 -0
- package/dist/cli/usage.d.ts.map +1 -0
- package/dist/cli/usage.js +348 -0
- package/dist/cli/usage.js.map +1 -0
- package/dist/core/audit.d.ts +8 -0
- package/dist/core/audit.d.ts.map +1 -0
- package/dist/core/audit.js +47 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/detect.d.ts +77 -0
- package/dist/core/detect.d.ts.map +1 -0
- package/dist/core/detect.js +262 -0
- package/dist/core/detect.js.map +1 -0
- package/dist/core/index.d.ts +11 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +31 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/prompt-builder.d.ts +164 -0
- package/dist/core/prompt-builder.d.ts.map +1 -0
- package/dist/core/prompt-builder.js +419 -0
- package/dist/core/prompt-builder.js.map +1 -0
- package/dist/core/prompts.d.ts +9 -0
- package/dist/core/prompts.d.ts.map +1 -0
- package/dist/core/prompts.js +401 -0
- package/dist/core/prompts.js.map +1 -0
- package/dist/core/render-review.d.ts +6 -0
- package/dist/core/render-review.d.ts.map +1 -0
- package/dist/core/render-review.js +219 -0
- package/dist/core/render-review.js.map +1 -0
- package/dist/core/render.d.ts +13 -0
- package/dist/core/render.d.ts.map +1 -0
- package/dist/core/render.js +80 -0
- package/dist/core/render.js.map +1 -0
- package/dist/core/review-schema-zod.d.ts +240 -0
- package/dist/core/review-schema-zod.d.ts.map +1 -0
- package/dist/core/review-schema-zod.js +218 -0
- package/dist/core/review-schema-zod.js.map +1 -0
- package/dist/core/review-schema.d.ts +42 -0
- package/dist/core/review-schema.d.ts.map +1 -0
- package/dist/core/review-schema.js +156 -0
- package/dist/core/review-schema.js.map +1 -0
- package/dist/core/review-writeback.d.ts +139 -0
- package/dist/core/review-writeback.d.ts.map +1 -0
- package/dist/core/review-writeback.js +313 -0
- package/dist/core/review-writeback.js.map +1 -0
- package/dist/core/skills.d.ts +122 -0
- package/dist/core/skills.d.ts.map +1 -0
- package/dist/core/skills.js +636 -0
- package/dist/core/skills.js.map +1 -0
- package/package.json +30 -4
- package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
- package/{lib/audit.js → src/cli/audit.ts} +37 -44
- package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
- package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
- package/src/cli/index.ts +101 -0
- package/src/cli/main.ts +1376 -0
- package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
- package/src/cli/skills.ts +386 -0
- package/{lib/update.js → src/cli/update.ts} +68 -27
- package/{lib/usage.js → src/cli/usage.ts} +167 -76
- package/src/core/audit.ts +53 -0
- package/{lib/detect.js → src/core/detect.ts} +100 -47
- package/src/core/index.ts +155 -0
- package/src/core/prompt-builder.ts +561 -0
- package/{lib/prompts.js → src/core/prompts.ts} +16 -2
- package/{lib/render-review.js → src/core/render-review.ts} +57 -25
- package/{lib/render.js → src/core/render.ts} +36 -10
- package/src/core/review-schema-zod.ts +262 -0
- package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
- package/src/core/review-writeback.ts +446 -0
- package/{lib/skills.js → src/core/skills.ts} +339 -342
- package/templates/workflow-py.yml.tmpl +2 -2
- package/templates/workflow-ts.yml.tmpl +2 -2
- package/templates/workflow.yml.tmpl +17 -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
|
-
|
|
19
|
-
|
|
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.
|
|
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 = '##
|
|
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}
|
|
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
|
}
|
|
@@ -12,11 +12,28 @@ const PLACEHOLDER_RE = /\{\{([A-Z_]+)\}\}/g;
|
|
|
12
12
|
// structured output to markdown. Pinning to the version that ran
|
|
13
13
|
// `clud-bug init` guarantees the renderer's output shape matches the
|
|
14
14
|
// prompt's expectations.
|
|
15
|
+
//
|
|
16
|
+
// IMPORTANT (v0.7.0 TS migration): the JS source lived at lib/render.js,
|
|
17
|
+
// so `join(__dirname, '..', 'package.json')` reached the package root
|
|
18
|
+
// from one level up. The TS port compiles to dist/core/render.js, which
|
|
19
|
+
// is TWO levels deep (dist/core/) — so we walk up TWO levels to find
|
|
20
|
+
// package.json. Without this adjustment, module-load would throw at
|
|
21
|
+
// runtime with "ENOENT dist/package.json". Verify on rebuild via:
|
|
22
|
+
// node -e "import('./dist/core/render.js').then(m => console.log(m.DEFAULTS.CLUD_BUG_VERSION))"
|
|
15
23
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const PKG_VERSION =
|
|
17
|
-
readFileSync(join(__dirname, '..', 'package.json'), 'utf8')
|
|
24
|
+
const PKG_VERSION: string = (
|
|
25
|
+
JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')) as { version: string }
|
|
18
26
|
).version;
|
|
19
27
|
|
|
28
|
+
// Public shape of the DEFAULTS map. Tests assert presence of CCA_VERSION
|
|
29
|
+
// and CLUD_BUG_VERSION; REVIEW_SCHEMA is the serialized schema string the
|
|
30
|
+
// templates inject via the {{REVIEW_SCHEMA}} placeholder.
|
|
31
|
+
export interface RenderDefaults {
|
|
32
|
+
CCA_VERSION: string;
|
|
33
|
+
CLUD_BUG_VERSION: string;
|
|
34
|
+
REVIEW_SCHEMA: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
// Default values for substitution tokens that every template uses.
|
|
21
38
|
// Callers can override per-render by passing the same key in `vars`.
|
|
22
39
|
//
|
|
@@ -26,19 +43,26 @@ const PKG_VERSION = JSON.parse(
|
|
|
26
43
|
// workflows mid-cycle. Bumping the pin requires a clud-bug release, which
|
|
27
44
|
// makes the upgrade visible + lets users opt out by pinning a different
|
|
28
45
|
// version in their own forked workflow.
|
|
29
|
-
export const DEFAULTS = {
|
|
46
|
+
export const DEFAULTS: RenderDefaults = {
|
|
30
47
|
CCA_VERSION: 'v1.0.133',
|
|
31
48
|
CLUD_BUG_VERSION: PKG_VERSION,
|
|
32
49
|
REVIEW_SCHEMA: serializedReviewSchema(),
|
|
33
50
|
};
|
|
34
51
|
|
|
52
|
+
// Caller-supplied substitution vars: any extra placeholders the template
|
|
53
|
+
// uses (REVIEW_PROMPT, PROJECT_DESCRIPTION, etc.). Values may be strings,
|
|
54
|
+
// numbers, or anything String()-coercible. We accept a wider unknown type
|
|
55
|
+
// because the JS callers freely pass numbers, arrays, etc., and the
|
|
56
|
+
// String(value) coercion below handles them uniformly.
|
|
57
|
+
export type RenderVars = Partial<RenderDefaults> & Record<string, unknown>;
|
|
58
|
+
|
|
35
59
|
// Multi-line value substitution preserves YAML/Markdown indentation by
|
|
36
60
|
// applying the placeholder line's leading whitespace to every
|
|
37
61
|
// continuation line. Single-line values pass through unchanged so
|
|
38
62
|
// 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, (
|
|
63
|
+
export function render(template: string, vars: RenderVars): string {
|
|
64
|
+
const merged: Record<string, unknown> = { ...DEFAULTS, ...vars };
|
|
65
|
+
return template.replace(PLACEHOLDER_RE, (_match, key: string, offset: number) => {
|
|
42
66
|
if (!(key in merged)) {
|
|
43
67
|
throw new Error(`Missing template variable: ${key}`);
|
|
44
68
|
}
|
|
@@ -48,7 +72,7 @@ export function render(template, vars) {
|
|
|
48
72
|
}
|
|
49
73
|
const lineStart = template.lastIndexOf('\n', offset - 1) + 1;
|
|
50
74
|
const leadingWhitespaceMatch = template.slice(lineStart, offset).match(/^(\s*)/);
|
|
51
|
-
const indent = leadingWhitespaceMatch ? leadingWhitespaceMatch[1] : '';
|
|
75
|
+
const indent = leadingWhitespaceMatch ? (leadingWhitespaceMatch[1] ?? '') : '';
|
|
52
76
|
return value
|
|
53
77
|
.split('\n')
|
|
54
78
|
.map((line, i) => (i === 0 || line === '' ? line : indent + line))
|
|
@@ -56,12 +80,12 @@ export function render(template, vars) {
|
|
|
56
80
|
});
|
|
57
81
|
}
|
|
58
82
|
|
|
59
|
-
export async function renderFile(path, vars) {
|
|
83
|
+
export async function renderFile(path: string, vars: RenderVars): Promise<string> {
|
|
60
84
|
const tmpl = await readFile(path, 'utf8');
|
|
61
85
|
return render(tmpl, vars);
|
|
62
86
|
}
|
|
63
87
|
|
|
64
|
-
export function pickTemplate(languages) {
|
|
88
|
+
export function pickTemplate(languages: string[]): string {
|
|
65
89
|
if (languages.includes('typescript') || languages.includes('javascript')) {
|
|
66
90
|
return 'workflow-ts.yml.tmpl';
|
|
67
91
|
}
|
|
@@ -74,7 +98,9 @@ export function pickTemplate(languages) {
|
|
|
74
98
|
// Map a pickTemplate() filename to the language key that `reviewPrompt`
|
|
75
99
|
// accepts. Keeps the mapping in one place so callers don't repeat the
|
|
76
100
|
// switch when computing the REVIEW_PROMPT token.
|
|
77
|
-
export
|
|
101
|
+
export type TemplateLanguage = 'ts' | 'py' | 'generic';
|
|
102
|
+
|
|
103
|
+
export function templateLanguage(tmplName: string): TemplateLanguage {
|
|
78
104
|
if (tmplName === 'workflow-ts.yml.tmpl') return 'ts';
|
|
79
105
|
if (tmplName === 'workflow-py.yml.tmpl') return 'py';
|
|
80
106
|
return 'generic';
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Zod-typed review schema for the AI-Gateway-shape consumer (clud-bug-app).
|
|
2
|
+
//
|
|
3
|
+
// The CLI runtime in `./review-schema.ts` ships a plain JSON-Schema object
|
|
4
|
+
// because that's what the Agent SDK validator expects on the
|
|
5
|
+
// `--json-schema '<JSON>'` argument. The App's runtime instead funnels the
|
|
6
|
+
// model output through the Vercel AI SDK, which derives a JSON Schema from
|
|
7
|
+
// a Zod schema. Both consumers need to agree on the WIRE shape — separate
|
|
8
|
+
// `critical_findings[] / minor_findings[] / preexisting_findings[]` arrays
|
|
9
|
+
// per SPEC §1.8.1 — but each builds its validator from a different source.
|
|
10
|
+
//
|
|
11
|
+
// This module ports the App's Zod schemas + flat-shape helpers into core
|
|
12
|
+
// so a future drift between the App's Zod and the CLI's JSON-Schema lives
|
|
13
|
+
// in one repo. The equivalence test (test/review-schema-zod.test.js)
|
|
14
|
+
// asserts the two schemas describe the same wire shape (required fields,
|
|
15
|
+
// finding-item shape) for every release.
|
|
16
|
+
//
|
|
17
|
+
// Ported from clud-bug-app/lib/review-schema.ts (commit shipped 2026-06-08).
|
|
18
|
+
// The pure helpers (`flattenFindings`, `unflattenFindings`,
|
|
19
|
+
// `deriveSummaryCounts`, `deriveSkillsReferenced`, `buildReviewFromFindings`)
|
|
20
|
+
// are byte-equivalent to the App's helpers — see test for the equivalence
|
|
21
|
+
// fixtures.
|
|
22
|
+
|
|
23
|
+
import { z } from 'zod';
|
|
24
|
+
|
|
25
|
+
// Severity buckets per SPEC §1.8.1. Only used by the internal `Finding`
|
|
26
|
+
// type — the wire `findingItemSchema` does NOT carry severity.
|
|
27
|
+
export const severityValues = ['critical', 'minor', 'preexisting'] as const;
|
|
28
|
+
export const severitySchema = z.enum(severityValues);
|
|
29
|
+
export type Severity = z.infer<typeof severitySchema>;
|
|
30
|
+
|
|
31
|
+
// Status header at the top of the review file.
|
|
32
|
+
export const statusHeaderValues = [
|
|
33
|
+
'critical findings',
|
|
34
|
+
'clean',
|
|
35
|
+
'bare',
|
|
36
|
+
] as const;
|
|
37
|
+
export const statusHeaderSchema = z.enum(statusHeaderValues);
|
|
38
|
+
export type StatusHeader = z.infer<typeof statusHeaderSchema>;
|
|
39
|
+
|
|
40
|
+
export const summaryCountsSchema = z.object({
|
|
41
|
+
critical: z.number().int().min(0),
|
|
42
|
+
minor: z.number().int().min(0),
|
|
43
|
+
preexisting: z.number().int().min(0),
|
|
44
|
+
resolved_from_prior: z.number().int().min(0),
|
|
45
|
+
still_open: z.number().int().min(0),
|
|
46
|
+
});
|
|
47
|
+
export type SummaryCounts = z.infer<typeof summaryCountsSchema>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Wire-shape finding item — NO severity field (mirrors the CLI's
|
|
51
|
+
* `FINDING_ITEM` JSON Schema in `./review-schema.ts`). Severity is implicit
|
|
52
|
+
* in which array the item lives in (`critical_findings`/`minor_findings`/
|
|
53
|
+
* `preexisting_findings`).
|
|
54
|
+
*/
|
|
55
|
+
export const findingItemSchema = z.object({
|
|
56
|
+
skill: z.string().min(1),
|
|
57
|
+
file: z.string().optional(),
|
|
58
|
+
line: z.number().int().min(1).optional(),
|
|
59
|
+
summary: z.string().min(1),
|
|
60
|
+
reasoning: z.string().optional(),
|
|
61
|
+
});
|
|
62
|
+
export type FindingItem = z.infer<typeof findingItemSchema>;
|
|
63
|
+
|
|
64
|
+
/** Per-skill scan report — one entry per loaded skill (even silent ones). */
|
|
65
|
+
export const perSkillScanItemSchema = z.object({
|
|
66
|
+
skill: z.string(),
|
|
67
|
+
outcome: z.string(),
|
|
68
|
+
});
|
|
69
|
+
export type PerSkillScanItem = z.infer<typeof perSkillScanItemSchema>;
|
|
70
|
+
|
|
71
|
+
/** Dedicated-section block for `review_mode: dedicated` skills. */
|
|
72
|
+
export const dedicatedSectionSchema = z.object({
|
|
73
|
+
section_name: z.string(),
|
|
74
|
+
skill: z.string(),
|
|
75
|
+
findings: z.array(findingItemSchema),
|
|
76
|
+
});
|
|
77
|
+
export type DedicatedSection = z.infer<typeof dedicatedSectionSchema>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Full review payload — wire shape. The model produces this; the App
|
|
81
|
+
* orchestrator immediately flattens to the internal `Finding[]` shape via
|
|
82
|
+
* `flattenFindings()` for multi-pass + aggregator work, then unflattens
|
|
83
|
+
* back via `unflattenFindings()` before writeback.
|
|
84
|
+
*/
|
|
85
|
+
export const reviewSchema = z.object({
|
|
86
|
+
status_header: statusHeaderSchema,
|
|
87
|
+
summary_counts: summaryCountsSchema,
|
|
88
|
+
per_skill_scan: z.array(perSkillScanItemSchema),
|
|
89
|
+
critical_findings: z.array(findingItemSchema),
|
|
90
|
+
minor_findings: z.array(findingItemSchema),
|
|
91
|
+
preexisting_findings: z.array(findingItemSchema),
|
|
92
|
+
dedicated_sections: z.array(dedicatedSectionSchema).optional(),
|
|
93
|
+
diagnostics: z.array(z.string()).optional(),
|
|
94
|
+
skills_referenced: z.array(z.string()),
|
|
95
|
+
last_reviewed_sha: z.string(),
|
|
96
|
+
});
|
|
97
|
+
export type Review = z.infer<typeof reviewSchema>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Internal flat-finding type used by App orchestrator, multi-pass
|
|
101
|
+
* aggregator, skill-usage telemetry, etc. Created from a wire-shape
|
|
102
|
+
* `Review` via `flattenFindings()`. Has explicit `severity` field so
|
|
103
|
+
* internal code doesn't need to track which array a finding came from.
|
|
104
|
+
*
|
|
105
|
+
* Exported from the core barrel as `ZodFinding` to disambiguate from
|
|
106
|
+
* the CLI-shape `ReviewFinding` (which never carries severity — its
|
|
107
|
+
* severity comes from the array it lives in).
|
|
108
|
+
*/
|
|
109
|
+
export type Finding = FindingItem & { severity: Severity };
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Zod schema describing the internal Finding shape (for tests + cross-check
|
|
113
|
+
* Pass 2 independentFindings). The wire equivalent is `findingItemSchema`
|
|
114
|
+
* which does NOT have severity.
|
|
115
|
+
*/
|
|
116
|
+
export const findingSchema = findingItemSchema.extend({
|
|
117
|
+
severity: severitySchema,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Flatten wire-shape `Review.critical_findings / minor_findings /
|
|
122
|
+
* preexisting_findings` into a single `Finding[]` with severity tagged.
|
|
123
|
+
* Preserves ordering: criticals first, then minors, then preexistings.
|
|
124
|
+
*/
|
|
125
|
+
export function flattenFindings(review: Review): Finding[] {
|
|
126
|
+
const out: Finding[] = [];
|
|
127
|
+
for (const f of review.critical_findings) out.push({ ...f, severity: 'critical' });
|
|
128
|
+
for (const f of review.minor_findings) out.push({ ...f, severity: 'minor' });
|
|
129
|
+
for (const f of review.preexisting_findings) out.push({ ...f, severity: 'preexisting' });
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Inverse of `flattenFindings`: split a flat `Finding[]` back into the
|
|
135
|
+
* three wire-shape arrays. Used at writeback time after multi-pass
|
|
136
|
+
* aggregation has produced the final flat list.
|
|
137
|
+
*/
|
|
138
|
+
export function unflattenFindings(findings: Finding[]): {
|
|
139
|
+
critical_findings: FindingItem[];
|
|
140
|
+
minor_findings: FindingItem[];
|
|
141
|
+
preexisting_findings: FindingItem[];
|
|
142
|
+
} {
|
|
143
|
+
const stripSeverity = (f: Finding): FindingItem => {
|
|
144
|
+
const { severity: _s, ...rest } = f;
|
|
145
|
+
return rest;
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
critical_findings: findings.filter((f) => f.severity === 'critical').map(stripSeverity),
|
|
149
|
+
minor_findings: findings.filter((f) => f.severity === 'minor').map(stripSeverity),
|
|
150
|
+
preexisting_findings: findings.filter((f) => f.severity === 'preexisting').map(stripSeverity),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Derive `summary_counts` from a flat `Finding[]` list. Canonical
|
|
156
|
+
* source-of-truth used by the orchestrator after flattening, to
|
|
157
|
+
* overwrite the model's potentially-drifted counts.
|
|
158
|
+
*/
|
|
159
|
+
export function deriveSummaryCounts(findings: Finding[]): SummaryCounts {
|
|
160
|
+
return {
|
|
161
|
+
critical: findings.filter((f) => f.severity === 'critical').length,
|
|
162
|
+
minor: findings.filter((f) => f.severity === 'minor').length,
|
|
163
|
+
preexisting: findings.filter((f) => f.severity === 'preexisting').length,
|
|
164
|
+
resolved_from_prior: 0,
|
|
165
|
+
still_open: 0,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Derive `skills_referenced` from a flat `Finding[]` list, preserving
|
|
171
|
+
* citation order (first appearance wins) and deduplicating.
|
|
172
|
+
*/
|
|
173
|
+
export function deriveSkillsReferenced(findings: Finding[]): string[] {
|
|
174
|
+
const seen = new Set<string>();
|
|
175
|
+
const out: string[] = [];
|
|
176
|
+
for (const f of findings) {
|
|
177
|
+
if (seen.has(f.skill)) continue;
|
|
178
|
+
seen.add(f.skill);
|
|
179
|
+
out.push(f.skill);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Test helper: build a wire-shape `Review` from a flat `Finding[]` list.
|
|
186
|
+
* Tests historically built reviews with a flat `findings: [...]` field; the
|
|
187
|
+
* wire shape (separate severity arrays + per_skill_scan + last_reviewed_sha
|
|
188
|
+
* required) is more verbose. This helper keeps fixtures short — pass a
|
|
189
|
+
* flat list, get back a valid wire-shape Review with derived counts and
|
|
190
|
+
* skills.
|
|
191
|
+
*
|
|
192
|
+
* Production code should NOT use this; it constructs reviews from AI output
|
|
193
|
+
* directly. This is purely for test ergonomics.
|
|
194
|
+
*/
|
|
195
|
+
export function buildReviewFromFindings(opts: {
|
|
196
|
+
findings: Finding[];
|
|
197
|
+
status_header?: StatusHeader;
|
|
198
|
+
last_reviewed_sha?: string;
|
|
199
|
+
per_skill_scan?: PerSkillScanItem[];
|
|
200
|
+
dedicated_sections?: DedicatedSection[];
|
|
201
|
+
diagnostics?: string[];
|
|
202
|
+
}): Review {
|
|
203
|
+
const split = unflattenFindings(opts.findings);
|
|
204
|
+
// Default status_header is derived from severity, NOT just emptiness.
|
|
205
|
+
// The App's original buildReviewFromFindings defaulted to
|
|
206
|
+
// 'critical findings' for ANY non-empty list, including minor-only and
|
|
207
|
+
// preexisting-only inputs. That was a bug (caught by clud-bug-review
|
|
208
|
+
// on PR #158): a review with only minor findings should be 'clean', not
|
|
209
|
+
// 'critical findings'. Fixed here on port to core.
|
|
210
|
+
//
|
|
211
|
+
// Callers that need the old behavior can pass `status_header` explicitly.
|
|
212
|
+
// Callers that want SPEC §1.8.1 semantics (the default) get the correct
|
|
213
|
+
// bucket: criticals present → 'critical findings'; else → 'clean'.
|
|
214
|
+
const hasCritical = opts.findings.some((f) => f.severity === 'critical');
|
|
215
|
+
const defaultStatus = hasCritical ? 'critical findings' : 'clean';
|
|
216
|
+
return {
|
|
217
|
+
status_header: opts.status_header ?? defaultStatus,
|
|
218
|
+
summary_counts: deriveSummaryCounts(opts.findings),
|
|
219
|
+
skills_referenced: deriveSkillsReferenced(opts.findings),
|
|
220
|
+
per_skill_scan: opts.per_skill_scan ?? [],
|
|
221
|
+
critical_findings: split.critical_findings,
|
|
222
|
+
minor_findings: split.minor_findings,
|
|
223
|
+
preexisting_findings: split.preexisting_findings,
|
|
224
|
+
...(opts.dedicated_sections !== undefined
|
|
225
|
+
? { dedicated_sections: opts.dedicated_sections }
|
|
226
|
+
: {}),
|
|
227
|
+
...(opts.diagnostics !== undefined ? { diagnostics: opts.diagnostics } : {}),
|
|
228
|
+
last_reviewed_sha: opts.last_reviewed_sha ?? '',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// D.2.5 — cross-check pass schema
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Per-finding verdict from a cross-check pass. The pass2 model echoes
|
|
238
|
+
* back Pass 1's findings by 0-indexed `pass1Index` + `agreed`/`disagreed`
|
|
239
|
+
* + rationale. The aggregator stitches these into
|
|
240
|
+
* `MultiPassReview.findings[].attributions`.
|
|
241
|
+
*
|
|
242
|
+
* Cross-check Pass 2 operates on a flat finding list (its own
|
|
243
|
+
* representation), so its independentFindings carry severity — uses the
|
|
244
|
+
* legacy `findingSchema` shape.
|
|
245
|
+
*/
|
|
246
|
+
export const crossCheckVerdictSchema = z.object({
|
|
247
|
+
pass1Index: z.number().int().min(0),
|
|
248
|
+
verdict: z.enum(['agreed', 'disagreed']),
|
|
249
|
+
rationale: z.string().optional(),
|
|
250
|
+
});
|
|
251
|
+
export type CrossCheckVerdictSchema = z.infer<typeof crossCheckVerdictSchema>;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Full cross-check response. Pass 2 outputs verdicts on Pass-1 findings
|
|
255
|
+
* plus its own independent finds (in the internal `findingSchema` shape
|
|
256
|
+
* with severity, since cross-check works on already-flattened lists).
|
|
257
|
+
*/
|
|
258
|
+
export const crossCheckSchema = z.object({
|
|
259
|
+
verdicts: z.array(crossCheckVerdictSchema),
|
|
260
|
+
independentFindings: z.array(findingSchema),
|
|
261
|
+
});
|
|
262
|
+
export type CrossCheck = z.infer<typeof crossCheckSchema>;
|
|
@@ -23,10 +23,21 @@
|
|
|
23
23
|
// keep the model from inventing fields.
|
|
24
24
|
//
|
|
25
25
|
// Bumped via deliberate edit; not derived from a TypeScript type. The
|
|
26
|
-
// rendering side (
|
|
26
|
+
// rendering side (./render-review.ts) treats unknown fields permissively
|
|
27
27
|
// — schema and renderer can drift up to one minor version safely.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// The JSON Schema shape is structurally rich (oneOf, enum, conditional
|
|
30
|
+
// required fields) and is consumed as a raw JSON object by Agent SDK
|
|
31
|
+
// validators that have their own runtime semantics. Typing it as
|
|
32
|
+
// `Record<string, unknown>` would erase the literal-property structure
|
|
33
|
+
// callers rely on for IDE navigation; typing each sub-object exactly
|
|
34
|
+
// would tightly couple every test that asserts a specific path. We use
|
|
35
|
+
// a structural alias `JSONSchemaObject` here so the export keeps its
|
|
36
|
+
// rich literal type (caller-visible field names) without forcing a
|
|
37
|
+
// schema-spec round-trip.
|
|
38
|
+
type JSONSchemaObject = Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
const FINDING_ITEM: JSONSchemaObject = {
|
|
30
41
|
type: 'object',
|
|
31
42
|
additionalProperties: false,
|
|
32
43
|
properties: {
|
|
@@ -55,7 +66,7 @@ const FINDING_ITEM = {
|
|
|
55
66
|
required: ['skill', 'summary'],
|
|
56
67
|
};
|
|
57
68
|
|
|
58
|
-
const PER_SKILL_SCAN_ITEM = {
|
|
69
|
+
const PER_SKILL_SCAN_ITEM: JSONSchemaObject = {
|
|
59
70
|
type: 'object',
|
|
60
71
|
additionalProperties: false,
|
|
61
72
|
properties: {
|
|
@@ -68,7 +79,7 @@ const PER_SKILL_SCAN_ITEM = {
|
|
|
68
79
|
required: ['skill', 'outcome'],
|
|
69
80
|
};
|
|
70
81
|
|
|
71
|
-
export const REVIEW_SCHEMA = {
|
|
82
|
+
export const REVIEW_SCHEMA: JSONSchemaObject = {
|
|
72
83
|
type: 'object',
|
|
73
84
|
additionalProperties: false,
|
|
74
85
|
properties: {
|
|
@@ -154,6 +165,58 @@ export const REVIEW_SCHEMA = {
|
|
|
154
165
|
// `--json-schema '<JSON>'` argument. Single-line (the workflow YAML uses
|
|
155
166
|
// the pipe block; single-quoted JSON inside that needs to stay flat to
|
|
156
167
|
// avoid YAML parser surprises with embedded newlines).
|
|
157
|
-
export function serializedReviewSchema() {
|
|
168
|
+
export function serializedReviewSchema(): string {
|
|
158
169
|
return JSON.stringify(REVIEW_SCHEMA);
|
|
159
170
|
}
|
|
171
|
+
|
|
172
|
+
// --- Schema-derived runtime types for renderReview() input ---
|
|
173
|
+
//
|
|
174
|
+
// The TypeScript types below mirror the JSON Schema shape above so that
|
|
175
|
+
// render-review.ts can consume the parsed JSON with structural typing.
|
|
176
|
+
// They are intentionally permissive (every field optional except where
|
|
177
|
+
// the schema's `required` list forces it) because the renderer is the
|
|
178
|
+
// last line of defense — malformed JSON should degrade rather than throw.
|
|
179
|
+
|
|
180
|
+
export type FindingSeverity = 'critical' | 'minor' | 'preexisting';
|
|
181
|
+
|
|
182
|
+
export interface ReviewFinding {
|
|
183
|
+
skill: string;
|
|
184
|
+
summary: string;
|
|
185
|
+
file?: string;
|
|
186
|
+
line?: number;
|
|
187
|
+
reasoning?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface PerSkillScanItem {
|
|
191
|
+
skill: string;
|
|
192
|
+
outcome: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface DedicatedSection {
|
|
196
|
+
section_name: string;
|
|
197
|
+
skill: string;
|
|
198
|
+
findings: ReviewFinding[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface ReviewSummaryCounts {
|
|
202
|
+
critical: number;
|
|
203
|
+
minor: number;
|
|
204
|
+
preexisting: number;
|
|
205
|
+
resolved_from_prior: number;
|
|
206
|
+
still_open: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export type ReviewStatusHeader = 'critical findings' | 'clean' | 'bare';
|
|
210
|
+
|
|
211
|
+
export interface ReviewData {
|
|
212
|
+
status_header: ReviewStatusHeader | string;
|
|
213
|
+
summary_counts: ReviewSummaryCounts;
|
|
214
|
+
per_skill_scan: PerSkillScanItem[];
|
|
215
|
+
critical_findings: ReviewFinding[];
|
|
216
|
+
minor_findings: ReviewFinding[];
|
|
217
|
+
preexisting_findings: ReviewFinding[];
|
|
218
|
+
skills_referenced: string[];
|
|
219
|
+
last_reviewed_sha: string;
|
|
220
|
+
dedicated_sections?: DedicatedSection[];
|
|
221
|
+
diagnostics?: string[];
|
|
222
|
+
}
|