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.
Files changed (110) hide show
  1. package/bin/clud-bug.js +10 -1353
  2. package/dist/cli/agents-md.d.ts +16 -0
  3. package/dist/cli/agents-md.d.ts.map +1 -0
  4. package/dist/cli/agents-md.js +226 -0
  5. package/dist/cli/agents-md.js.map +1 -0
  6. package/dist/cli/audit.d.ts +13 -0
  7. package/dist/cli/audit.d.ts.map +1 -0
  8. package/dist/cli/audit.js +90 -0
  9. package/dist/cli/audit.js.map +1 -0
  10. package/dist/cli/branch-protection.d.ts +57 -0
  11. package/dist/cli/branch-protection.d.ts.map +1 -0
  12. package/dist/cli/branch-protection.js +118 -0
  13. package/dist/cli/branch-protection.js.map +1 -0
  14. package/dist/cli/edit-workflow.d.ts +18 -0
  15. package/dist/cli/edit-workflow.d.ts.map +1 -0
  16. package/dist/cli/edit-workflow.js +43 -0
  17. package/dist/cli/edit-workflow.js.map +1 -0
  18. package/dist/cli/index.d.ts +8 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +18 -0
  21. package/dist/cli/index.js.map +1 -0
  22. package/dist/cli/main.d.ts +3 -0
  23. package/dist/cli/main.d.ts.map +1 -0
  24. package/dist/cli/main.js +1336 -0
  25. package/dist/cli/main.js.map +1 -0
  26. package/dist/cli/skill-usage.d.ts +109 -0
  27. package/dist/cli/skill-usage.d.ts.map +1 -0
  28. package/dist/cli/skill-usage.js +380 -0
  29. package/dist/cli/skill-usage.js.map +1 -0
  30. package/dist/cli/skills.d.ts +56 -0
  31. package/dist/cli/skills.d.ts.map +1 -0
  32. package/dist/cli/skills.js +292 -0
  33. package/dist/cli/skills.js.map +1 -0
  34. package/dist/cli/update.d.ts +29 -0
  35. package/dist/cli/update.d.ts.map +1 -0
  36. package/dist/cli/update.js +186 -0
  37. package/dist/cli/update.js.map +1 -0
  38. package/dist/cli/usage.d.ts +142 -0
  39. package/dist/cli/usage.d.ts.map +1 -0
  40. package/dist/cli/usage.js +348 -0
  41. package/dist/cli/usage.js.map +1 -0
  42. package/dist/core/audit.d.ts +8 -0
  43. package/dist/core/audit.d.ts.map +1 -0
  44. package/dist/core/audit.js +47 -0
  45. package/dist/core/audit.js.map +1 -0
  46. package/dist/core/detect.d.ts +77 -0
  47. package/dist/core/detect.d.ts.map +1 -0
  48. package/dist/core/detect.js +262 -0
  49. package/dist/core/detect.js.map +1 -0
  50. package/dist/core/index.d.ts +11 -0
  51. package/dist/core/index.d.ts.map +1 -0
  52. package/dist/core/index.js +31 -0
  53. package/dist/core/index.js.map +1 -0
  54. package/dist/core/prompt-builder.d.ts +164 -0
  55. package/dist/core/prompt-builder.d.ts.map +1 -0
  56. package/dist/core/prompt-builder.js +419 -0
  57. package/dist/core/prompt-builder.js.map +1 -0
  58. package/dist/core/prompts.d.ts +9 -0
  59. package/dist/core/prompts.d.ts.map +1 -0
  60. package/dist/core/prompts.js +401 -0
  61. package/dist/core/prompts.js.map +1 -0
  62. package/dist/core/render-review.d.ts +6 -0
  63. package/dist/core/render-review.d.ts.map +1 -0
  64. package/dist/core/render-review.js +219 -0
  65. package/dist/core/render-review.js.map +1 -0
  66. package/dist/core/render.d.ts +13 -0
  67. package/dist/core/render.d.ts.map +1 -0
  68. package/dist/core/render.js +80 -0
  69. package/dist/core/render.js.map +1 -0
  70. package/dist/core/review-schema-zod.d.ts +240 -0
  71. package/dist/core/review-schema-zod.d.ts.map +1 -0
  72. package/dist/core/review-schema-zod.js +218 -0
  73. package/dist/core/review-schema-zod.js.map +1 -0
  74. package/dist/core/review-schema.d.ts +42 -0
  75. package/dist/core/review-schema.d.ts.map +1 -0
  76. package/dist/core/review-schema.js +156 -0
  77. package/dist/core/review-schema.js.map +1 -0
  78. package/dist/core/review-writeback.d.ts +139 -0
  79. package/dist/core/review-writeback.d.ts.map +1 -0
  80. package/dist/core/review-writeback.js +313 -0
  81. package/dist/core/review-writeback.js.map +1 -0
  82. package/dist/core/skills.d.ts +122 -0
  83. package/dist/core/skills.d.ts.map +1 -0
  84. package/dist/core/skills.js +636 -0
  85. package/dist/core/skills.js.map +1 -0
  86. package/package.json +30 -4
  87. package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
  88. package/{lib/audit.js → src/cli/audit.ts} +37 -44
  89. package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
  90. package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
  91. package/src/cli/index.ts +101 -0
  92. package/src/cli/main.ts +1376 -0
  93. package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
  94. package/src/cli/skills.ts +386 -0
  95. package/{lib/update.js → src/cli/update.ts} +68 -27
  96. package/{lib/usage.js → src/cli/usage.ts} +167 -76
  97. package/src/core/audit.ts +53 -0
  98. package/{lib/detect.js → src/core/detect.ts} +100 -47
  99. package/src/core/index.ts +155 -0
  100. package/src/core/prompt-builder.ts +561 -0
  101. package/{lib/prompts.js → src/core/prompts.ts} +16 -2
  102. package/{lib/render-review.js → src/core/render-review.ts} +57 -25
  103. package/{lib/render.js → src/core/render.ts} +36 -10
  104. package/src/core/review-schema-zod.ts +262 -0
  105. package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
  106. package/src/core/review-writeback.ts +446 -0
  107. package/{lib/skills.js → src/core/skills.ts} +339 -342
  108. package/templates/workflow-py.yml.tmpl +2 -2
  109. package/templates/workflow-ts.yml.tmpl +2 -2
  110. 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