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
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
// Pure skill helpers — no FS, only the network via injectable fetch.
|
|
2
|
+
//
|
|
3
|
+
// Split from lib/skills.js during the v0.7.0 TS migration. The App
|
|
4
|
+
// (clud-bug-app) consumes these for review-time skill routing without
|
|
5
|
+
// pulling node:fs into the serverless bundle. CLI-only install/update
|
|
6
|
+
// helpers live in src/cli/skills.ts.
|
|
7
|
+
//
|
|
8
|
+
// The `_internal` debug-export pattern from lib/skills.js is removed
|
|
9
|
+
// here: every helper that needed test access has been promoted to a
|
|
10
|
+
// direct named export. Constants (`MAX_SKILLS`, `API_BASE`) and the
|
|
11
|
+
// shape normaliser (`normalizeList`) are now first-class core exports.
|
|
12
|
+
export const API_BASE = 'https://skills.sh/api/v1';
|
|
13
|
+
export const MAX_SKILLS = 8;
|
|
14
|
+
export function normalizeList(data) {
|
|
15
|
+
// Tolerate either { skills: [...] } or a bare array.
|
|
16
|
+
const list = Array.isArray(data)
|
|
17
|
+
? data
|
|
18
|
+
: (data?.skills
|
|
19
|
+
|| data?.results
|
|
20
|
+
|| []);
|
|
21
|
+
return list
|
|
22
|
+
.map((item) => ({
|
|
23
|
+
source: item.source || item.repo || '',
|
|
24
|
+
name: item.name || item.slug || '',
|
|
25
|
+
description: item.description || item.summary || '',
|
|
26
|
+
installs: item.installs || item.installCount || 0,
|
|
27
|
+
}))
|
|
28
|
+
.filter((s) => s.source && s.name);
|
|
29
|
+
}
|
|
30
|
+
export class SkillsClient {
|
|
31
|
+
fetch;
|
|
32
|
+
base;
|
|
33
|
+
userAgent;
|
|
34
|
+
constructor({ fetch = globalThis.fetch, base, userAgent = 'clud-bug' } = {}) {
|
|
35
|
+
this.fetch = fetch;
|
|
36
|
+
this.base = base ?? process.env['CLUD_BUG_SKILLS_SH_BASE'] ?? API_BASE;
|
|
37
|
+
this.userAgent = userAgent;
|
|
38
|
+
}
|
|
39
|
+
async #json(path) {
|
|
40
|
+
const res = await this.fetch(`${this.base}${path}`, {
|
|
41
|
+
headers: { 'User-Agent': this.userAgent, accept: 'application/json' },
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
throw new Error(`skills.sh ${path} → ${res.status}`);
|
|
45
|
+
}
|
|
46
|
+
return res.json();
|
|
47
|
+
}
|
|
48
|
+
async search(terms) {
|
|
49
|
+
const q = terms.filter(Boolean).join(' ').trim();
|
|
50
|
+
if (!q)
|
|
51
|
+
return [];
|
|
52
|
+
const data = await this.#json(`/skills/search?q=${encodeURIComponent(q)}`);
|
|
53
|
+
return normalizeList(data);
|
|
54
|
+
}
|
|
55
|
+
async curated() {
|
|
56
|
+
const data = await this.#json('/skills/curated');
|
|
57
|
+
return normalizeList(data);
|
|
58
|
+
}
|
|
59
|
+
async getContent(source, name) {
|
|
60
|
+
const data = (await this.#json(`/skills/${encodeURIComponent(source)}/${encodeURIComponent(name)}`));
|
|
61
|
+
// The API may return content as `body`, `content`, or under `files[0].content`.
|
|
62
|
+
// Try the documented shapes in order; fail loudly if none match so we know
|
|
63
|
+
// the API contract changed.
|
|
64
|
+
if (typeof data?.content === 'string')
|
|
65
|
+
return data.content;
|
|
66
|
+
if (typeof data?.body === 'string')
|
|
67
|
+
return data.body;
|
|
68
|
+
const first = data?.files?.[0]?.content;
|
|
69
|
+
if (typeof first === 'string')
|
|
70
|
+
return first;
|
|
71
|
+
throw new Error(`skills.sh response for ${source}/${name} had no content field`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Deduplicates by source/name and caps at MAX_SKILLS, preferring curated then by install count.
|
|
75
|
+
export function rankAndCap(curated, searched, baseline, cap = MAX_SKILLS) {
|
|
76
|
+
const seen = new Set(baseline.map((b) => `local:${b.name}`));
|
|
77
|
+
const out = [...baseline];
|
|
78
|
+
const remaining = cap - baseline.length;
|
|
79
|
+
if (remaining <= 0)
|
|
80
|
+
return out.slice(0, cap);
|
|
81
|
+
const curatedSorted = [...curated].sort((a, b) => b.installs - a.installs);
|
|
82
|
+
const searchedSorted = [...searched].sort((a, b) => b.installs - a.installs);
|
|
83
|
+
for (const skill of [...curatedSorted, ...searchedSorted]) {
|
|
84
|
+
if (out.length >= cap)
|
|
85
|
+
break;
|
|
86
|
+
const key = `${skill.source}/${skill.name}`;
|
|
87
|
+
if (seen.has(key))
|
|
88
|
+
continue;
|
|
89
|
+
seen.add(key);
|
|
90
|
+
out.push({ ...skill, kind: skill.kind || 'remote' });
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
// Extract the `review_mode` field from a SKILL.md's frontmatter.
|
|
95
|
+
//
|
|
96
|
+
// Contract (from the v0.6 plan, option D-unified):
|
|
97
|
+
// - `shared` → the skill loads alongside other shared skills in ONE
|
|
98
|
+
// Claude call. Bug-finding baselines + most skills.sh
|
|
99
|
+
// contributions live here; they benefit from cross-
|
|
100
|
+
// correlation (an evidence-based finding flagged for
|
|
101
|
+
// critical-issues-only also gets the convention check).
|
|
102
|
+
// - `dedicated` → the skill gets its OWN focused Claude call. Reserved
|
|
103
|
+
// for domain-specific skills (brand voice, compliance,
|
|
104
|
+
// API-contract) where attention dilution at high skill
|
|
105
|
+
// counts is the real failure mode.
|
|
106
|
+
// - Missing field → default to `shared`. Conservative: the skill loads,
|
|
107
|
+
// no surprise per-skill API cost. Users opt skills INTO
|
|
108
|
+
// `dedicated` by authoring the field.
|
|
109
|
+
//
|
|
110
|
+
// The CLI runtime (v0.5.9) honors this via prompt restructuring inside a
|
|
111
|
+
// single claude-code-action call. The v0.6 GitHub App will use the same
|
|
112
|
+
// field to route to literal parallel API calls. Single source of truth.
|
|
113
|
+
export function readReviewMode(skillContent) {
|
|
114
|
+
if (typeof skillContent !== 'string')
|
|
115
|
+
return 'shared';
|
|
116
|
+
// Scope to the YAML frontmatter block (between the first two `---` lines).
|
|
117
|
+
// A `review_mode:` line in the body is documentation, not configuration.
|
|
118
|
+
const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
|
|
119
|
+
if (!fm)
|
|
120
|
+
return 'shared';
|
|
121
|
+
const m = fm[1].match(/^review_mode:\s*(\S+)\s*$/m);
|
|
122
|
+
if (!m)
|
|
123
|
+
return 'shared';
|
|
124
|
+
// Strip optional YAML string-quotes — `review_mode: "dedicated"` and
|
|
125
|
+
// `review_mode: 'dedicated'` are both valid YAML, but the (\S+) capture
|
|
126
|
+
// grabs the quotes too. Without this, quoted forms silently fell back
|
|
127
|
+
// to `shared` even though the author clearly meant dedicated.
|
|
128
|
+
const value = m[1].toLowerCase().replace(/^["']|["']$/g, '');
|
|
129
|
+
return value === 'dedicated' ? 'dedicated' : 'shared';
|
|
130
|
+
}
|
|
131
|
+
// 0.0.K (v0.6.21): parse the optional `applies_to:` frontmatter block.
|
|
132
|
+
//
|
|
133
|
+
// Schema:
|
|
134
|
+
// applies_to:
|
|
135
|
+
// paths:
|
|
136
|
+
// - "src/ui/**"
|
|
137
|
+
// - "lib/components/**"
|
|
138
|
+
// extensions: [".tsx", ".jsx"]
|
|
139
|
+
//
|
|
140
|
+
// Returns `{paths: string[], extensions: string[]}` if the field is
|
|
141
|
+
// present (either sub-list optional, both default to empty array), or
|
|
142
|
+
// `null` if absent. Skills without applies_to are scope-universal —
|
|
143
|
+
// the caller should treat null as "load unconditionally."
|
|
144
|
+
//
|
|
145
|
+
// Hand-rolled YAML parser scoped to this exact shape. The frontmatter
|
|
146
|
+
// is otherwise opaque (review_mode is parsed elsewhere with a similar
|
|
147
|
+
// single-key regex), so pulling in a YAML dep would be overkill.
|
|
148
|
+
export function readAppliesTo(skillContent) {
|
|
149
|
+
if (typeof skillContent !== 'string')
|
|
150
|
+
return null;
|
|
151
|
+
const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
|
|
152
|
+
if (!fm)
|
|
153
|
+
return null;
|
|
154
|
+
const block = fm[1];
|
|
155
|
+
// Anchor on `applies_to:` at start of line (the body of a SKILL.md
|
|
156
|
+
// could mention the term in prose; only the frontmatter key fires).
|
|
157
|
+
const head = block.match(/^applies_to:\s*$/m);
|
|
158
|
+
if (!head)
|
|
159
|
+
return null;
|
|
160
|
+
// Slice from after the `applies_to:` line; the block ends at the
|
|
161
|
+
// next top-level key (a line starting with a word character + `:`)
|
|
162
|
+
// OR end-of-block.
|
|
163
|
+
// `head.index` is defined here because String.prototype.match returns
|
|
164
|
+
// a RegExpMatchArray with `index` set when the regex is non-global.
|
|
165
|
+
const startIdx = head.index + head[0].length;
|
|
166
|
+
const rest = block.slice(startIdx);
|
|
167
|
+
const stop = rest.search(/^\w[\w-]*:/m);
|
|
168
|
+
const scoped = stop === -1 ? rest : rest.slice(0, stop);
|
|
169
|
+
const paths = parseYamlList(scoped, 'paths');
|
|
170
|
+
const extensions = parseYamlList(scoped, 'extensions');
|
|
171
|
+
if (paths.length === 0 && extensions.length === 0)
|
|
172
|
+
return null;
|
|
173
|
+
return { paths, extensions };
|
|
174
|
+
}
|
|
175
|
+
// Parse a YAML list under `<key>:`, handling both the inline-array
|
|
176
|
+
// form (`extensions: [".tsx", ".jsx"]`) and the block form
|
|
177
|
+
// (`paths:` followed by ` - "src/ui/**"` lines).
|
|
178
|
+
function parseYamlList(block, key) {
|
|
179
|
+
const inline = block.match(new RegExp(`^\\s{2}${key}:\\s*\\[(.*?)\\]\\s*$`, 'm'));
|
|
180
|
+
if (inline) {
|
|
181
|
+
return inline[1]
|
|
182
|
+
.split(',')
|
|
183
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
|
184
|
+
.filter(Boolean);
|
|
185
|
+
}
|
|
186
|
+
const headerRe = new RegExp(`^\\s{2}${key}:\\s*$`, 'm');
|
|
187
|
+
const head = block.match(headerRe);
|
|
188
|
+
if (!head)
|
|
189
|
+
return [];
|
|
190
|
+
const after = block.slice(head.index + head[0].length);
|
|
191
|
+
const items = [];
|
|
192
|
+
for (const line of after.split('\n')) {
|
|
193
|
+
const item = line.match(/^\s{4,}-\s*(.+?)\s*$/);
|
|
194
|
+
if (item) {
|
|
195
|
+
items.push(item[1].replace(/^["']|["']$/g, ''));
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// Anything that isn't a list item (or blank) ends the list.
|
|
199
|
+
if (line.trim() !== '' && !item)
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
return items;
|
|
203
|
+
}
|
|
204
|
+
// 0.0.K: does `prPaths` contain at least one file matching the skill's
|
|
205
|
+
// applies_to? Skills without applies_to ALWAYS apply (back-compat).
|
|
206
|
+
//
|
|
207
|
+
// `prPaths` is the list of changed files in the PR (e.g. from
|
|
208
|
+
// `gh pr diff --name-only`). Match semantics:
|
|
209
|
+
// - paths: any glob in `paths` matches any of `prPaths`
|
|
210
|
+
// - extensions: any extension in `extensions` matches any of `prPaths`
|
|
211
|
+
// - paths OR extensions (NOT AND) — a single hit is enough
|
|
212
|
+
//
|
|
213
|
+
// Skill `paths` use the minimal glob set logmind already uses
|
|
214
|
+
// (`*` matches non-slash, `**` matches across slashes, `?` single
|
|
215
|
+
// char). Anything fancier would need a real glob lib.
|
|
216
|
+
export function appliesToPr(skillContent, prPaths) {
|
|
217
|
+
const rule = readAppliesTo(skillContent);
|
|
218
|
+
if (rule === null)
|
|
219
|
+
return true; // back-compat: no rule → applies
|
|
220
|
+
if (!Array.isArray(prPaths))
|
|
221
|
+
return true; // be permissive on bad input
|
|
222
|
+
for (const path of prPaths) {
|
|
223
|
+
if (typeof path !== 'string')
|
|
224
|
+
continue;
|
|
225
|
+
for (const ext of rule.extensions) {
|
|
226
|
+
if (path.endsWith(ext))
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
for (const glob of rule.paths) {
|
|
230
|
+
if (globMatch(glob, path))
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
// Minimal glob → regex: `**` → `.*`, `*` → `[^/]*`, `?` → `.`,
|
|
237
|
+
// everything else escaped. Anchored full-string match.
|
|
238
|
+
function globMatch(glob, path) {
|
|
239
|
+
const escaped = glob
|
|
240
|
+
.replace(/([.+^${}()|[\]\\])/g, '\\$1')
|
|
241
|
+
.replace(/\*\*/g, '__DOUBLESTAR__')
|
|
242
|
+
.replace(/\*/g, '[^/]*')
|
|
243
|
+
.replace(/__DOUBLESTAR__/g, '.*')
|
|
244
|
+
.replace(/\?/g, '.');
|
|
245
|
+
return new RegExp(`^${escaped}$`).test(path);
|
|
246
|
+
}
|
|
247
|
+
// Partition a set of loaded skills into {shared, dedicated} buckets per
|
|
248
|
+
// each skill's review_mode frontmatter. Expects skills with a `content`
|
|
249
|
+
// field (SKILL.md text). Skills without content default to `shared`.
|
|
250
|
+
//
|
|
251
|
+
// Shape: input is the same skill objects produced by loadBaseline /
|
|
252
|
+
// writeSkills / listInstalled. Output is two arrays of the same shape;
|
|
253
|
+
// caller decides what to do with each bucket.
|
|
254
|
+
export function partitionByReviewMode(skills) {
|
|
255
|
+
const shared = [];
|
|
256
|
+
const dedicated = [];
|
|
257
|
+
for (const skill of skills) {
|
|
258
|
+
const mode = readReviewMode(skill?.content);
|
|
259
|
+
(mode === 'dedicated' ? dedicated : shared).push(skill);
|
|
260
|
+
}
|
|
261
|
+
return { shared, dedicated };
|
|
262
|
+
}
|
|
263
|
+
// Pull the line for `skillName` from a clud-bug review's `### Per-skill scan`
|
|
264
|
+
// block. The block format (set by the v3+ prompt) is one line per loaded skill:
|
|
265
|
+
//
|
|
266
|
+
// ### Per-skill scan
|
|
267
|
+
// - [critical-issues-only]: scanned all paths. 2 critical findings below.
|
|
268
|
+
// - [brand-voice-review]: scanned 3 microcopy changes. 1 finding (below).
|
|
269
|
+
// - [pii-and-compliance]: scanned analytics + logging. 0 findings.
|
|
270
|
+
//
|
|
271
|
+
// Returns the OUTCOME portion (everything after the `- [name]: ` prefix), with
|
|
272
|
+
// trailing whitespace stripped. Returns null if the skill isn't mentioned, the
|
|
273
|
+
// comment has no Per-skill scan block, or `comment` is empty.
|
|
274
|
+
//
|
|
275
|
+
// The brackets in the line prefix anchor the match so a partial-name collision
|
|
276
|
+
// (e.g. `brand-voice` finding `brand-voice-review`) is impossible.
|
|
277
|
+
export function extractPerSkillLine(comment, skillName) {
|
|
278
|
+
if (typeof comment !== 'string' || !comment)
|
|
279
|
+
return null;
|
|
280
|
+
if (typeof skillName !== 'string' || !skillName)
|
|
281
|
+
return null;
|
|
282
|
+
// Escape regex metacharacters in the skill name. A skill name with a `.` or
|
|
283
|
+
// `+` would otherwise alter the match. Skills are conventionally kebab-case,
|
|
284
|
+
// but defense in depth is cheap.
|
|
285
|
+
const escaped = skillName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
286
|
+
// Anchor on the bracket-prefix; tolerate optional leading whitespace and
|
|
287
|
+
// dash. The OUTCOME is everything from after `]:` to end-of-line.
|
|
288
|
+
const re = new RegExp(`^\\s*-\\s*\\[${escaped}\\]:\\s*(.+?)\\s*$`, 'm');
|
|
289
|
+
const m = comment.match(re);
|
|
290
|
+
return m ? m[1] : null;
|
|
291
|
+
}
|
|
292
|
+
// Find the latest clud-bug review header line from a list of PR comments.
|
|
293
|
+
// Source of truth for the v0.5.x strict-mode-gate header selection — the
|
|
294
|
+
// composite action shells out to node + this helper rather than parsing
|
|
295
|
+
// in bash, so the gate has unit-test coverage and the v0.6 App can reuse
|
|
296
|
+
// the same logic.
|
|
297
|
+
//
|
|
298
|
+
// Contract (called by .github/actions/strict-mode-gate/action.yml):
|
|
299
|
+
// - Walk `comments` (newest-first per gh api ?sort=created&direction=desc).
|
|
300
|
+
// - Skip comments not authored by `botLogin`.
|
|
301
|
+
// - For each remaining comment, find the FIRST line starting with the
|
|
302
|
+
// H2 sentinel `## 🐛 Clud Bug review`. If present, return that line.
|
|
303
|
+
// - Return null if no matching comment exists.
|
|
304
|
+
//
|
|
305
|
+
// Why this isn't `comments.find(c => c.body.startsWith("## 🐛 Clud Bug review"))`:
|
|
306
|
+
// claude-code-action prepends a `**Claude finished @user's task in Nm Ns**`
|
|
307
|
+
// preamble to every bot comment, so the H2 review header never appears at
|
|
308
|
+
// body position 0. The pre-v0.5.12 composite used `.body | startswith(...)`
|
|
309
|
+
// in jq and matched ZERO comments in practice — silently disabling strict
|
|
310
|
+
// mode on every install with strictMode: true. Caught when this repo
|
|
311
|
+
// dogfooded BB.3 on PR #60: bot wrote "— critical findings" header, gate
|
|
312
|
+
// passed anyway.
|
|
313
|
+
//
|
|
314
|
+
// The line-anchored extraction preserves the original "don't trip on
|
|
315
|
+
// quoted sentinels in body text" property: a comment that mentions the
|
|
316
|
+
// strict-mode header in prose (inline-code, blockquote) won't match
|
|
317
|
+
// because the quoted version isn't at start-of-line.
|
|
318
|
+
export function selectReviewHeader(comments, botLogin) {
|
|
319
|
+
if (!Array.isArray(comments))
|
|
320
|
+
return null;
|
|
321
|
+
if (typeof botLogin !== 'string' || !botLogin)
|
|
322
|
+
return null;
|
|
323
|
+
// Sort newest-first by created_at. The composite passes the result of
|
|
324
|
+
// `gh api .../comments?sort=created&direction=desc` — but GitHub's
|
|
325
|
+
// REST issue-comments endpoint ignores `direction=desc` and returns
|
|
326
|
+
// ascending (oldest first) regardless. PR #64 caught this: the gate
|
|
327
|
+
// was selecting the OLDER "— critical findings" comment instead of
|
|
328
|
+
// the newer "— clean" follow-up, so fix-push reviews that resolved
|
|
329
|
+
// critical findings still failed the gate. Explicit sort here makes
|
|
330
|
+
// selection deterministic regardless of upstream API quirks.
|
|
331
|
+
const sorted = [...comments].sort((a, b) => {
|
|
332
|
+
const ta = typeof a?.created_at === 'string' ? Date.parse(a.created_at) : 0;
|
|
333
|
+
const tb = typeof b?.created_at === 'string' ? Date.parse(b.created_at) : 0;
|
|
334
|
+
return tb - ta; // newest first
|
|
335
|
+
});
|
|
336
|
+
for (const c of sorted) {
|
|
337
|
+
if (!c || typeof c !== 'object')
|
|
338
|
+
continue;
|
|
339
|
+
const author = c.user?.login;
|
|
340
|
+
const body = c.body;
|
|
341
|
+
if (author !== botLogin || typeof body !== 'string')
|
|
342
|
+
continue;
|
|
343
|
+
const headerLine = extractFirstReviewHeaderLine(body);
|
|
344
|
+
if (headerLine)
|
|
345
|
+
return headerLine;
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
// Pull the FIRST line of `body` that starts with the H2 sentinel.
|
|
350
|
+
// Exported separately so callers can extract a header from a known body
|
|
351
|
+
// without re-running the comment filter (useful in tests + the v0.6 App).
|
|
352
|
+
export function extractFirstReviewHeaderLine(body) {
|
|
353
|
+
if (typeof body !== 'string')
|
|
354
|
+
return null;
|
|
355
|
+
const m = body.match(/^## 🐛 Clud Bug review[^\n]*/m);
|
|
356
|
+
return m ? m[0] : null;
|
|
357
|
+
}
|
|
358
|
+
// Companion to selectReviewHeader: returns the FULL BODY of the latest
|
|
359
|
+
// clud-bug review comment from `botLogin`, not just its header line.
|
|
360
|
+
// Same filter contract (line-anchored H2 sentinel, claude-code-action
|
|
361
|
+
// preamble tolerated). Used by the BB.3 per-skill check-runs step,
|
|
362
|
+
// which needs the body to extract per-skill outcome lines from the
|
|
363
|
+
// "### Per-skill scan" block — the header alone isn't enough.
|
|
364
|
+
//
|
|
365
|
+
// Returns null if no matching comment exists. The composite action
|
|
366
|
+
// treats null as "no review yet; emit no check-runs" (the same posture
|
|
367
|
+
// that pre-v0.5.12 bash code intended via the `[ -z "$LATEST" ]` branch,
|
|
368
|
+
// only now actually reachable instead of always-fires-due-to-bug).
|
|
369
|
+
//
|
|
370
|
+
// Same-bug fix as selectReviewHeader: PR #61 caught that BB.3 step 2
|
|
371
|
+
// of the composite still used the broken `.body | startswith(...)` jq
|
|
372
|
+
// filter even after step 1 was refactored, leaving per-skill check-runs
|
|
373
|
+
// silently disabled on every install with strictSkills since v0.5.10.
|
|
374
|
+
export function selectReviewBody(comments, botLogin) {
|
|
375
|
+
if (!Array.isArray(comments))
|
|
376
|
+
return null;
|
|
377
|
+
if (typeof botLogin !== 'string' || !botLogin)
|
|
378
|
+
return null;
|
|
379
|
+
// Same explicit newest-first sort as selectReviewHeader — gh api
|
|
380
|
+
// ignores direction=desc on issue-comments and returns ascending,
|
|
381
|
+
// so without this BB.3 was parsing per-skill outcomes from the
|
|
382
|
+
// OLDEST review comment, not the latest. See selectReviewHeader.
|
|
383
|
+
const sorted = [...comments].sort((a, b) => {
|
|
384
|
+
const ta = typeof a?.created_at === 'string' ? Date.parse(a.created_at) : 0;
|
|
385
|
+
const tb = typeof b?.created_at === 'string' ? Date.parse(b.created_at) : 0;
|
|
386
|
+
return tb - ta;
|
|
387
|
+
});
|
|
388
|
+
for (const c of sorted) {
|
|
389
|
+
if (!c || typeof c !== 'object')
|
|
390
|
+
continue;
|
|
391
|
+
const author = c.user?.login;
|
|
392
|
+
const body = c.body;
|
|
393
|
+
if (author !== botLogin || typeof body !== 'string')
|
|
394
|
+
continue;
|
|
395
|
+
if (extractFirstReviewHeaderLine(body))
|
|
396
|
+
return body;
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
// Extract the v0.6.5+ stats header line "Found: N 🔴 / N 🟡 / N 🟣"
|
|
401
|
+
// from a review comment body. Returns {important, nit, preExisting} when
|
|
402
|
+
// found, null otherwise. The header lets agents reading the comment on a
|
|
403
|
+
// re-review triage at a glance — on the common zero-findings case, the
|
|
404
|
+
// header IS the entire substantive payload, so an ingest can short-circuit
|
|
405
|
+
// without parsing the body.
|
|
406
|
+
//
|
|
407
|
+
// The match is intentionally permissive on whitespace around the slashes
|
|
408
|
+
// and tolerates 1+ digits for each count. Severity emoji are matched
|
|
409
|
+
// literally — a future bot revision that changes the emoji would break
|
|
410
|
+
// this parser loudly, which is the intended behavior (catches drift).
|
|
411
|
+
export function extractStatsHeader(comment) {
|
|
412
|
+
if (typeof comment !== 'string' || !comment)
|
|
413
|
+
return null;
|
|
414
|
+
const re = /Found:\s*(\d+)\s*🔴\s*\/\s*(\d+)\s*🟡\s*\/\s*(\d+)\s*🟣/u;
|
|
415
|
+
const m = comment.match(re);
|
|
416
|
+
if (!m)
|
|
417
|
+
return null;
|
|
418
|
+
return {
|
|
419
|
+
important: parseInt(m[1], 10),
|
|
420
|
+
nit: parseInt(m[2], 10),
|
|
421
|
+
preExisting: parseInt(m[3], 10),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
// Decide whether a review-header line is the strict-mode "critical findings"
|
|
425
|
+
// verdict that should fail the gate. Mirrors the v0.5.x bash predicate
|
|
426
|
+
// `grep -q "Clud Bug review — critical findings"`.
|
|
427
|
+
//
|
|
428
|
+
// Returns false for null/non-string input so a "no header found" path
|
|
429
|
+
// (selectReviewHeader returning null) safely falls through to the gate
|
|
430
|
+
// passing — which is the right posture: if the bot didn't post a review
|
|
431
|
+
// with the strict-mode header, there's nothing for the gate to fail on.
|
|
432
|
+
// "Loud failure for missing manifest" is handled upstream in the composite.
|
|
433
|
+
export function isCriticalReviewHeader(headerLine) {
|
|
434
|
+
if (typeof headerLine !== 'string')
|
|
435
|
+
return false;
|
|
436
|
+
return /Clud Bug review — critical findings/.test(headerLine);
|
|
437
|
+
}
|
|
438
|
+
// Classify a Per-skill scan outcome line into the check-run conclusion the
|
|
439
|
+
// composite action will emit for that skill. Source of truth for the BB.3
|
|
440
|
+
// gate decision — the v0.5.10 composite shells out to node + this helper
|
|
441
|
+
// rather than parsing in bash, so the gate has unit-test coverage and the
|
|
442
|
+
// v0.6 App can reuse the same classification when it routes its own
|
|
443
|
+
// parallel calls.
|
|
444
|
+
//
|
|
445
|
+
// Contract:
|
|
446
|
+
// - `null` (skill not mentioned in the review) → 'failure'
|
|
447
|
+
// - line contains "0 findings" / "0 finding" as a STANDALONE TOKEN → 'success'
|
|
448
|
+
// - line contains "n/a" as a standalone token → 'success'
|
|
449
|
+
// - empty line (bot emitted "- [name]:" with no outcome) → 'failure'
|
|
450
|
+
// - otherwise (typically "N finding" / "N findings" with N>0) → 'failure'
|
|
451
|
+
//
|
|
452
|
+
// Why null → failure (not neutral): GitHub's branch-protection contract
|
|
453
|
+
// treats `conclusion: neutral` as PASSING for required status checks —
|
|
454
|
+
// only `failure`, `cancelled`, `timed_out`, `action_required` block merge.
|
|
455
|
+
// A strictSkills entry that doesn't appear in the per-skill scan block
|
|
456
|
+
// (typo, prompt regression, mid-review race) emitting `neutral` would
|
|
457
|
+
// silently pass branch protection, defeating the gate the user opted into.
|
|
458
|
+
// Failing loud is the right posture for a gate that ships with "strict" in
|
|
459
|
+
// its name; the cost is a re-run if a bot mid-review somehow drops a skill.
|
|
460
|
+
//
|
|
461
|
+
// The "0 findings" match is anchored on a leading word boundary so "10
|
|
462
|
+
// findings" / "100 findings" don't substring-match to success — the exact
|
|
463
|
+
// bug that v0.5.10's first revision had, caught by clud-bug-review + claude-
|
|
464
|
+
// review on PR #57.
|
|
465
|
+
export function classifyPerSkillOutcome(outcomeLine) {
|
|
466
|
+
if (outcomeLine == null)
|
|
467
|
+
return 'failure';
|
|
468
|
+
const text = String(outcomeLine);
|
|
469
|
+
// HARD-FAILURE OVERRIDE: any positive finding count → failure.
|
|
470
|
+
// `\b[1-9]\d*\s+(?:\w+\s+){0,3}finding` matches "1 finding",
|
|
471
|
+
// "2 critical findings", "10 findings", "100 minor findings below".
|
|
472
|
+
// Up to 3 intermediate words allow modifiers like "critical"/"minor".
|
|
473
|
+
// The `\b[1-9]` anchor (vs `[0-9]`) excludes `0` — so this never
|
|
474
|
+
// shadows the "0 findings" success case below. Also: "10 findings"
|
|
475
|
+
// is correctly classified failure because `\b1` matches at the
|
|
476
|
+
// word boundary before `1`, then `\d*` consumes `0`.
|
|
477
|
+
if (/\b[1-9]\d*\s+(?:\w+\s+){0,3}finding/i.test(text))
|
|
478
|
+
return 'failure';
|
|
479
|
+
// SUCCESS PATTERNS — broadened in v0.5.16 to handle natural bot
|
|
480
|
+
// phrasings without enumerating every synonym. The bot's review
|
|
481
|
+
// prompt encourages canonical "0 findings" wording but variance
|
|
482
|
+
// is real (e.g. "no findings to anchor", "0 critical findings").
|
|
483
|
+
// (1) Zero-finding count, optionally with up to 3 modifier words.
|
|
484
|
+
// Matches: "0 findings", "0 critical findings", "no findings",
|
|
485
|
+
// "zero performance findings", "no findings to anchor".
|
|
486
|
+
// Does NOT match: "10 findings" (handled above), "all findings",
|
|
487
|
+
// "applied to all findings".
|
|
488
|
+
if (/\b(?:0|no|zero)\s+(?:\S+\s+){0,3}finding/i.test(text))
|
|
489
|
+
return 'success';
|
|
490
|
+
// (2) n/a — word-bounded. Matches "n/a.", "n/a — no surface here",
|
|
491
|
+
// " n/a " surrounded by anything. Excludes "diagnostics" etc.
|
|
492
|
+
if (/\bn\/a\b/i.test(text))
|
|
493
|
+
return 'success';
|
|
494
|
+
// (3) "not applicable" — explicit phrase.
|
|
495
|
+
if (/\bnot\s+applicable\b/i.test(text))
|
|
496
|
+
return 'success';
|
|
497
|
+
// (4) Checkmark (✓) as the bot's universal clean signal. Anchored on
|
|
498
|
+
// whitespace/punctuation on both sides so accidental ✓ inside
|
|
499
|
+
// other content (e.g. quoting a checkbox list) doesn't trip it.
|
|
500
|
+
// Matches "applied to all findings. ✓ all anchored." but not
|
|
501
|
+
// "see ✓item-marker in unicode" or similar.
|
|
502
|
+
if (/(?:^|\s)✓(?:\s|$|[.,;:])/.test(text))
|
|
503
|
+
return 'success';
|
|
504
|
+
// Skill-specific vocabulary like "0 pattern fights" (no `finding` word)
|
|
505
|
+
// falls through to failure here. Skill authors should prefer the
|
|
506
|
+
// canonical "0 findings" wording in their per-skill scan lines so
|
|
507
|
+
// this classifier doesn't need per-skill vocabulary knowledge.
|
|
508
|
+
return 'failure';
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Minimal YAML frontmatter parser. Handles:
|
|
512
|
+
* - `key: value` (scalar)
|
|
513
|
+
* - `key: [a, b, c]` (inline list)
|
|
514
|
+
* - `key:\n subkey: value` (one-level nesting — applies_to)
|
|
515
|
+
*
|
|
516
|
+
* Throws on malformed input; the App's `loadSkillsFromBaseRef` catches
|
|
517
|
+
* and skips the skill (a bad SKILL.md doesn't take down the whole review).
|
|
518
|
+
*
|
|
519
|
+
* Deliberately NOT a general-purpose YAML parser — SPEC §1.10 fixes the
|
|
520
|
+
* frontmatter schema to a handful of fields. If the schema grows beyond
|
|
521
|
+
* what this hand-rolled parser handles, swap to `js-yaml` — the boundary
|
|
522
|
+
* is this function.
|
|
523
|
+
*/
|
|
524
|
+
export function parseFrontmatter(raw) {
|
|
525
|
+
// Frontmatter MUST be the literal `---\n...\n---\n` at the file head.
|
|
526
|
+
// We tolerate a leading BOM and trailing whitespace.
|
|
527
|
+
const trimmed = raw.replace(/^/, '');
|
|
528
|
+
const match = trimmed.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
529
|
+
if (!match) {
|
|
530
|
+
throw new Error('missing YAML frontmatter');
|
|
531
|
+
}
|
|
532
|
+
const body = match[1] ?? '';
|
|
533
|
+
const lines = body.split(/\r?\n/);
|
|
534
|
+
const out = {};
|
|
535
|
+
let currentNested = null;
|
|
536
|
+
for (const line of lines) {
|
|
537
|
+
if (!line.trim())
|
|
538
|
+
continue;
|
|
539
|
+
// Comment line; YAML allows '#' as a comment marker at column 0.
|
|
540
|
+
if (line.trim().startsWith('#'))
|
|
541
|
+
continue;
|
|
542
|
+
// Nested-block lines start with whitespace (e.g. " paths: [...]").
|
|
543
|
+
const isIndented = /^\s/.test(line);
|
|
544
|
+
if (isIndented && currentNested) {
|
|
545
|
+
const nested = out[currentNested];
|
|
546
|
+
if (!nested)
|
|
547
|
+
continue;
|
|
548
|
+
const trimmedLine = line.trim();
|
|
549
|
+
const colon = trimmedLine.indexOf(':');
|
|
550
|
+
if (colon === -1)
|
|
551
|
+
continue;
|
|
552
|
+
const key = trimmedLine.slice(0, colon).trim();
|
|
553
|
+
const value = trimmedLine.slice(colon + 1).trim();
|
|
554
|
+
nested[key] = parseScalarOrList(value);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
// Top-level key.
|
|
558
|
+
currentNested = null;
|
|
559
|
+
const colon = line.indexOf(':');
|
|
560
|
+
if (colon === -1) {
|
|
561
|
+
throw new Error(`malformed frontmatter line: ${line}`);
|
|
562
|
+
}
|
|
563
|
+
const key = line.slice(0, colon).trim();
|
|
564
|
+
const value = line.slice(colon + 1).trim();
|
|
565
|
+
if (value === '') {
|
|
566
|
+
// Block — next indented lines populate this key as a nested map.
|
|
567
|
+
out[key] = {};
|
|
568
|
+
currentNested = key;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
out[key] = parseScalarOrList(value);
|
|
572
|
+
}
|
|
573
|
+
// Validate the SPEC-required fields are present and apply documented
|
|
574
|
+
// defaults for optional ones.
|
|
575
|
+
const name = String(out['name'] ?? '').trim();
|
|
576
|
+
if (!name)
|
|
577
|
+
throw new Error('frontmatter.name is required');
|
|
578
|
+
if (!/^[a-z][a-z0-9-]{0,62}$/.test(name)) {
|
|
579
|
+
throw new Error(`frontmatter.name is not a valid kebab-case slug: ${name}`);
|
|
580
|
+
}
|
|
581
|
+
const description = String(out['description'] ?? '').trim();
|
|
582
|
+
if (!description)
|
|
583
|
+
throw new Error('frontmatter.description is required');
|
|
584
|
+
const source = String(out['source'] ?? 'manual').trim();
|
|
585
|
+
const reviewMode = out['review_mode'] === 'dedicated' ? 'dedicated' : 'shared';
|
|
586
|
+
const appliesToRaw = out['applies_to'];
|
|
587
|
+
let appliesTo;
|
|
588
|
+
if (appliesToRaw) {
|
|
589
|
+
const paths = Array.isArray(appliesToRaw.paths)
|
|
590
|
+
? appliesToRaw.paths.map(String)
|
|
591
|
+
: undefined;
|
|
592
|
+
const extensions = Array.isArray(appliesToRaw.extensions)
|
|
593
|
+
? appliesToRaw.extensions.map(String)
|
|
594
|
+
: undefined;
|
|
595
|
+
appliesTo = {
|
|
596
|
+
...(paths !== undefined ? { paths } : {}),
|
|
597
|
+
...(extensions !== undefined ? { extensions } : {}),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
name,
|
|
602
|
+
description,
|
|
603
|
+
source,
|
|
604
|
+
review_mode: reviewMode,
|
|
605
|
+
...(appliesTo !== undefined ? { applies_to: appliesTo } : {}),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
function parseScalarOrList(value) {
|
|
609
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
610
|
+
// Inline list: [a, "b", 'c'] → ['a', 'b', 'c']
|
|
611
|
+
const inner = value.slice(1, -1).trim();
|
|
612
|
+
if (!inner)
|
|
613
|
+
return [];
|
|
614
|
+
return inner
|
|
615
|
+
.split(',')
|
|
616
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
617
|
+
.filter(Boolean);
|
|
618
|
+
}
|
|
619
|
+
// Strip surrounding quotes; YAML allows both ' and ".
|
|
620
|
+
return value.replace(/^['"]|['"]$/g, '');
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Strip the leading `---\n...\n---\n` from a SKILL.md file. Returns the
|
|
624
|
+
* markdown body (the part the LLM actually reads).
|
|
625
|
+
*
|
|
626
|
+
* Ported from `clud-bug-app/lib/skills-loader.ts:269` so callers don't have
|
|
627
|
+
* to re-implement the regex.
|
|
628
|
+
*/
|
|
629
|
+
export function stripFrontmatter(raw) {
|
|
630
|
+
const trimmed = raw.replace(/^/, '');
|
|
631
|
+
const match = trimmed.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
|
632
|
+
if (!match)
|
|
633
|
+
return trimmed;
|
|
634
|
+
return trimmed.slice(match[0].length);
|
|
635
|
+
}
|
|
636
|
+
//# sourceMappingURL=skills.js.map
|