clud-bug 0.6.34 → 0.7.0-rc.1

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 (95) 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 +8 -0
  51. package/dist/core/index.d.ts.map +1 -0
  52. package/dist/core/index.js +14 -0
  53. package/dist/core/index.js.map +1 -0
  54. package/dist/core/prompts.d.ts +9 -0
  55. package/dist/core/prompts.d.ts.map +1 -0
  56. package/dist/core/prompts.js +401 -0
  57. package/dist/core/prompts.js.map +1 -0
  58. package/dist/core/render-review.d.ts +6 -0
  59. package/dist/core/render-review.d.ts.map +1 -0
  60. package/dist/core/render-review.js +219 -0
  61. package/dist/core/render-review.js.map +1 -0
  62. package/dist/core/render.d.ts +13 -0
  63. package/dist/core/render.d.ts.map +1 -0
  64. package/dist/core/render.js +80 -0
  65. package/dist/core/render.js.map +1 -0
  66. package/dist/core/review-schema.d.ts +42 -0
  67. package/dist/core/review-schema.d.ts.map +1 -0
  68. package/dist/core/review-schema.js +156 -0
  69. package/dist/core/review-schema.js.map +1 -0
  70. package/dist/core/skills.d.ts +80 -0
  71. package/dist/core/skills.d.ts.map +1 -0
  72. package/dist/core/skills.js +510 -0
  73. package/dist/core/skills.js.map +1 -0
  74. package/package.json +27 -4
  75. package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
  76. package/{lib/audit.js → src/cli/audit.ts} +37 -44
  77. package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
  78. package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
  79. package/src/cli/index.ts +101 -0
  80. package/src/cli/main.ts +1376 -0
  81. package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
  82. package/src/cli/skills.ts +386 -0
  83. package/{lib/update.js → src/cli/update.ts} +68 -27
  84. package/{lib/usage.js → src/cli/usage.ts} +167 -76
  85. package/src/core/audit.ts +53 -0
  86. package/{lib/detect.js → src/core/detect.ts} +100 -47
  87. package/src/core/index.ts +70 -0
  88. package/{lib/prompts.js → src/core/prompts.ts} +16 -2
  89. package/{lib/render-review.js → src/core/render-review.ts} +57 -25
  90. package/{lib/render.js → src/core/render.ts} +36 -10
  91. package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
  92. package/{lib/skills.js → src/core/skills.ts} +172 -343
  93. package/templates/workflow-py.yml.tmpl +2 -2
  94. package/templates/workflow-ts.yml.tmpl +2 -2
  95. package/templates/workflow.yml.tmpl +17 -8
@@ -1,7 +1,11 @@
1
1
  import { readFile, readdir, stat } from 'node:fs/promises';
2
2
  import { join, extname } from 'node:path';
3
3
 
4
- const EXT_TO_LANG = {
4
+ // Lookup tables for the project-shape detectors. Each map is `as const` so
5
+ // downstream consumers can rely on the value types narrowing to the literal
6
+ // strings rather than `string` — `_internal.EXT_TO_LANG['.ts']` resolves to
7
+ // `'typescript'` for IDE navigation, not just `string`.
8
+ export const EXT_TO_LANG = {
5
9
  '.ts': 'typescript', '.tsx': 'typescript',
6
10
  '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
7
11
  '.py': 'python',
@@ -14,12 +18,12 @@ const EXT_TO_LANG = {
14
18
  '.cs': 'csharp',
15
19
  '.c': 'c', '.h': 'c',
16
20
  '.cpp': 'cpp', '.cc': 'cpp', '.hpp': 'cpp',
17
- };
21
+ } as const satisfies Record<string, string>;
18
22
 
19
23
  // Dependency name → search term hint passed to skills.sh.
20
24
  // Only well-known frameworks; obscure packages get filtered out so the
21
25
  // skills.sh query doesn't get drowned in noise.
22
- const DEP_TO_TERM = {
26
+ export const DEP_TO_TERM = {
23
27
  'next': 'nextjs', 'react': 'react', 'vue': 'vue', 'svelte': 'svelte',
24
28
  '@angular/core': 'angular', 'solid-js': 'solid',
25
29
  'express': 'express', 'fastify': 'fastify', 'koa': 'koa', 'hono': 'hono',
@@ -29,16 +33,45 @@ const DEP_TO_TERM = {
29
33
  'vitest': 'vitest', 'jest': 'jest', 'playwright': 'playwright',
30
34
  '@playwright/test': 'playwright',
31
35
  'typescript': 'typescript',
32
- };
36
+ } as const satisfies Record<string, string>;
33
37
 
34
- const PY_DEP_TO_TERM = {
38
+ export const PY_DEP_TO_TERM = {
35
39
  'django': 'django', 'flask': 'flask', 'fastapi': 'fastapi',
36
40
  'click': 'click', 'typer': 'typer',
37
41
  'pytest': 'pytest', 'sqlalchemy': 'sqlalchemy',
38
42
  'pydantic': 'pydantic', 'numpy': 'numpy', 'pandas': 'pandas',
39
- };
43
+ } as const satisfies Record<string, string>;
40
44
 
41
- async function fileExists(path) {
45
+ // Result of running every detector + post-processing — the data shape callers
46
+ // (bin/clud-bug.js, lib/update.js) read from. `description` is nullable
47
+ // because the README fallback may not produce anything.
48
+ export interface DetectedSignals {
49
+ name: string | null;
50
+ description: string | null;
51
+ languages: string[];
52
+ histogram: Record<string, number>;
53
+ searchTerms: string[];
54
+ primaryLanguage: string | null;
55
+ }
56
+
57
+ // Per-detector intermediate type — what each manifest-reader returns before
58
+ // we aggregate. `languages` is the languages each manifest implies (e.g.
59
+ // package.json implies ['javascript']) so we can union them in detect().
60
+ interface DetectorResult {
61
+ name: string | null;
62
+ description: string | null;
63
+ languages: string[];
64
+ terms: string[];
65
+ }
66
+
67
+ interface PackageJson {
68
+ name?: string;
69
+ description?: string;
70
+ dependencies?: Record<string, string>;
71
+ devDependencies?: Record<string, string>;
72
+ }
73
+
74
+ async function fileExists(path: string): Promise<boolean> {
42
75
  try {
43
76
  await stat(path);
44
77
  return true;
@@ -47,15 +80,15 @@ async function fileExists(path) {
47
80
  }
48
81
  }
49
82
 
50
- async function readJsonSafe(path) {
83
+ async function readJsonSafe<T = unknown>(path: string): Promise<T | null> {
51
84
  try {
52
- return JSON.parse(await readFile(path, 'utf8'));
85
+ return JSON.parse(await readFile(path, 'utf8')) as T;
53
86
  } catch {
54
87
  return null;
55
88
  }
56
89
  }
57
90
 
58
- async function readTextSafe(path) {
91
+ async function readTextSafe(path: string): Promise<string | null> {
59
92
  try {
60
93
  return await readFile(path, 'utf8');
61
94
  } catch {
@@ -63,26 +96,27 @@ async function readTextSafe(path) {
63
96
  }
64
97
  }
65
98
 
66
- async function detectFromPackageJson(root) {
67
- const pkg = await readJsonSafe(join(root, 'package.json'));
99
+ async function detectFromPackageJson(root: string): Promise<DetectorResult | null> {
100
+ const pkg = await readJsonSafe<PackageJson>(join(root, 'package.json'));
68
101
  if (!pkg) return null;
69
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
70
- const terms = new Set();
102
+ const deps: Record<string, string> = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
103
+ const terms = new Set<string>();
71
104
  for (const dep of Object.keys(deps)) {
72
- if (DEP_TO_TERM[dep]) terms.add(DEP_TO_TERM[dep]);
105
+ const term = (DEP_TO_TERM as Record<string, string>)[dep];
106
+ if (term) terms.add(term);
73
107
  }
74
108
  return {
75
- name: pkg.name,
109
+ name: pkg.name ?? null,
76
110
  description: pkg.description || null,
77
111
  languages: ['javascript', ...(deps.typescript || pkg.devDependencies?.typescript ? ['typescript'] : [])],
78
112
  terms: [...terms],
79
113
  };
80
114
  }
81
115
 
82
- async function detectFromPyproject(root) {
116
+ async function detectFromPyproject(root: string): Promise<DetectorResult | null> {
83
117
  const text = await readTextSafe(join(root, 'pyproject.toml'));
84
118
  if (!text) return null;
85
- const terms = new Set();
119
+ const terms = new Set<string>();
86
120
  for (const [dep, term] of Object.entries(PY_DEP_TO_TERM)) {
87
121
  // crude but adequate match — full TOML parse would be overkill for the
88
122
  // dependency-name lookup we actually need
@@ -91,58 +125,59 @@ async function detectFromPyproject(root) {
91
125
  const nameMatch = text.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
92
126
  const descMatch = text.match(/^\s*description\s*=\s*["']([^"']+)["']/m);
93
127
  return {
94
- name: nameMatch?.[1] || null,
95
- description: descMatch?.[1] || null,
128
+ name: nameMatch?.[1] ?? null,
129
+ description: descMatch?.[1] ?? null,
96
130
  languages: ['python'],
97
131
  terms: [...terms],
98
132
  };
99
133
  }
100
134
 
101
- async function detectFromRequirements(root) {
135
+ async function detectFromRequirements(root: string): Promise<DetectorResult | null> {
102
136
  const text = await readTextSafe(join(root, 'requirements.txt'));
103
137
  if (!text) return null;
104
- const terms = new Set();
138
+ const terms = new Set<string>();
105
139
  for (const line of text.split('\n')) {
106
- const dep = line.split(/[<>=~ #]/)[0].trim().toLowerCase();
107
- if (PY_DEP_TO_TERM[dep]) terms.add(PY_DEP_TO_TERM[dep]);
140
+ const dep = (line.split(/[<>=~ #]/)[0] ?? '').trim().toLowerCase();
141
+ const term = (PY_DEP_TO_TERM as Record<string, string>)[dep];
142
+ if (term) terms.add(term);
108
143
  }
109
144
  return { name: null, description: null, languages: ['python'], terms: [...terms] };
110
145
  }
111
146
 
112
- async function detectFromGoMod(root) {
147
+ async function detectFromGoMod(root: string): Promise<DetectorResult | null> {
113
148
  const text = await readTextSafe(join(root, 'go.mod'));
114
149
  if (!text) return null;
115
150
  const moduleMatch = text.match(/^module\s+(\S+)/m);
116
151
  return {
117
- name: moduleMatch?.[1]?.split('/').pop() || null,
152
+ name: moduleMatch?.[1]?.split('/').pop() ?? null,
118
153
  description: null,
119
154
  languages: ['go'],
120
155
  terms: [],
121
156
  };
122
157
  }
123
158
 
124
- async function detectFromCargo(root) {
159
+ async function detectFromCargo(root: string): Promise<DetectorResult | null> {
125
160
  const text = await readTextSafe(join(root, 'Cargo.toml'));
126
161
  if (!text) return null;
127
162
  const nameMatch = text.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
128
163
  const descMatch = text.match(/^\s*description\s*=\s*["']([^"']+)["']/m);
129
164
  return {
130
- name: nameMatch?.[1] || null,
131
- description: descMatch?.[1] || null,
165
+ name: nameMatch?.[1] ?? null,
166
+ description: descMatch?.[1] ?? null,
132
167
  languages: ['rust'],
133
168
  terms: [],
134
169
  };
135
170
  }
136
171
 
137
- async function detectFromGemfile(root) {
172
+ async function detectFromGemfile(root: string): Promise<DetectorResult | null> {
138
173
  const text = await readTextSafe(join(root, 'Gemfile'));
139
174
  if (!text) return null;
140
175
  return { name: null, description: null, languages: ['ruby'], terms: [] };
141
176
  }
142
177
 
143
- async function fileHistogram(root) {
144
- const counts = {};
145
- async function walk(dir, depth) {
178
+ async function fileHistogram(root: string): Promise<Record<string, number>> {
179
+ const counts: Record<string, number> = {};
180
+ async function walk(dir: string, depth: number): Promise<void> {
146
181
  if (depth > 3) return;
147
182
  let entries;
148
183
  try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
@@ -154,7 +189,7 @@ async function fileHistogram(root) {
154
189
  if (entry.isDirectory()) {
155
190
  await walk(full, depth + 1);
156
191
  } else {
157
- const lang = EXT_TO_LANG[extname(entry.name)];
192
+ const lang = (EXT_TO_LANG as Record<string, string>)[extname(entry.name)];
158
193
  if (lang) counts[lang] = (counts[lang] || 0) + 1;
159
194
  }
160
195
  }
@@ -163,7 +198,7 @@ async function fileHistogram(root) {
163
198
  return counts;
164
199
  }
165
200
 
166
- function firstParagraph(readme) {
201
+ function firstParagraph(readme: string | null): string | null {
167
202
  if (!readme) return null;
168
203
  const lines = readme.split('\n').slice(0, 200);
169
204
  const paragraphs = lines.join('\n').split(/\n\s*\n/);
@@ -174,20 +209,22 @@ function firstParagraph(readme) {
174
209
  return null;
175
210
  }
176
211
 
177
- export async function detect(root) {
212
+ export async function detect(root: string): Promise<DetectedSignals> {
178
213
  const detectors = [
179
214
  detectFromPackageJson, detectFromPyproject, detectFromRequirements,
180
215
  detectFromGoMod, detectFromCargo, detectFromGemfile,
181
216
  ];
182
- const results = (await Promise.all(detectors.map(d => d(root)))).filter(Boolean);
217
+ const results = (await Promise.all(detectors.map(d => d(root)))).filter(
218
+ (r): r is DetectorResult => r !== null,
219
+ );
183
220
  const histogram = await fileHistogram(root);
184
221
  const readme = await readTextSafe(join(root, 'README.md'))
185
222
  || await readTextSafe(join(root, 'README'));
186
223
 
187
- const languages = new Set();
188
- const terms = new Set();
189
- let name = null;
190
- let description = null;
224
+ const languages = new Set<string>();
225
+ const terms = new Set<string>();
226
+ let name: string | null = null;
227
+ let description: string | null = null;
191
228
  for (const r of results) {
192
229
  for (const lang of r.languages) languages.add(lang);
193
230
  for (const term of r.terms) terms.add(term);
@@ -206,12 +243,22 @@ export async function detect(root) {
206
243
  languages: sortedLangs,
207
244
  histogram,
208
245
  searchTerms: [...new Set([...terms, ...sortedLangs.slice(0, 2)])],
209
- primaryLanguage: sortedLangs[0] || null,
246
+ primaryLanguage: sortedLangs[0] ?? null,
210
247
  };
211
248
  }
212
249
 
213
- export function buildDescriptionLine(signals) {
214
- const parts = [];
250
+ // Input shape for buildDescriptionLine — a subset of DetectedSignals. We
251
+ // don't reuse DetectedSignals directly because the callers (templates,
252
+ // LLM-flow tests) often hand-build a subset rather than running detect().
253
+ export interface DescriptionLineSignals {
254
+ name?: string | null;
255
+ description?: string | null;
256
+ primaryLanguage?: string | null;
257
+ searchTerms?: string[];
258
+ }
259
+
260
+ export function buildDescriptionLine(signals: DescriptionLineSignals): string {
261
+ const parts: string[] = [];
215
262
  if (signals.name) parts.push(`This project is "${signals.name}".`);
216
263
  if (signals.description) {
217
264
  // v0.6.25 / issue #89: when signals.description comes from a
@@ -226,7 +273,7 @@ export function buildDescriptionLine(signals) {
226
273
  parts.push(/[.!?]$/.test(desc) ? desc : `${desc}.`);
227
274
  }
228
275
  if (signals.primaryLanguage) {
229
- const frameworks = [...new Set(signals.searchTerms)].filter(t =>
276
+ const frameworks = [...new Set(signals.searchTerms || [])].filter((t) =>
230
277
  !['typescript', 'javascript', 'python', 'go', 'rust', 'ruby'].includes(t));
231
278
  const frameworkPart = frameworks.length ? ` using ${frameworks.join(', ')}` : '';
232
279
  parts.push(`It's primarily ${signals.primaryLanguage}${frameworkPart}.`);
@@ -235,5 +282,11 @@ export function buildDescriptionLine(signals) {
235
282
  return parts.join(' ');
236
283
  }
237
284
 
238
- // Test seam: allow tests to inject the EXT_TO_LANG and DEP_TO_TERM tables
239
- export const _internal = { EXT_TO_LANG, DEP_TO_TERM, PY_DEP_TO_TERM, fileHistogram, firstParagraph };
285
+ // Architect's anti-pattern fix (Phase 2): the JS source used a single
286
+ // `export const _internal = { }` namespace as a test seam. The TS port
287
+ // promotes the table exports (EXT_TO_LANG, DEP_TO_TERM, PY_DEP_TO_TERM)
288
+ // to direct top-level exports, and exposes the two helper functions
289
+ // fileHistogram + firstParagraph as direct named exports too. Tests now
290
+ // import each symbol by name. No `_internal` re-export — that pattern is
291
+ // gone.
292
+ export { fileHistogram, firstParagraph };
@@ -0,0 +1,70 @@
1
+ // Public API surface for `clud-bug/core` (consumed via the package's
2
+ // `./core` exports map → `dist/core/index.js`).
3
+ //
4
+ // Each line re-exports one core module's public symbols. Modules are
5
+ // added incrementally as the v0.7.0 TypeScript migration converts each
6
+ // lib/* JS file.
7
+
8
+ export { reviewPrompt, type ReviewPromptOptions, type ReviewPromptLanguage } from './prompts.js';
9
+ export {
10
+ REVIEW_SCHEMA,
11
+ serializedReviewSchema,
12
+ type ReviewData,
13
+ type ReviewFinding,
14
+ type ReviewSummaryCounts,
15
+ type ReviewStatusHeader,
16
+ type FindingSeverity,
17
+ type PerSkillScanItem,
18
+ type DedicatedSection,
19
+ } from './review-schema.js';
20
+ export { renderReview, SEVERITY_LABEL } from './render-review.js';
21
+ export {
22
+ detect,
23
+ buildDescriptionLine,
24
+ EXT_TO_LANG,
25
+ DEP_TO_TERM,
26
+ PY_DEP_TO_TERM,
27
+ fileHistogram,
28
+ firstParagraph,
29
+ type DetectedSignals,
30
+ type DescriptionLineSignals,
31
+ } from './detect.js';
32
+ export {
33
+ render,
34
+ renderFile,
35
+ pickTemplate,
36
+ templateLanguage,
37
+ DEFAULTS,
38
+ type RenderDefaults,
39
+ type RenderVars,
40
+ type TemplateLanguage,
41
+ } from './render.js';
42
+ export {
43
+ durationToGitSince,
44
+ renderAuditHeader,
45
+ type AuditHeaderInput,
46
+ } from './audit.js';
47
+ export {
48
+ API_BASE,
49
+ MAX_SKILLS,
50
+ SkillsClient,
51
+ normalizeList,
52
+ rankAndCap,
53
+ readReviewMode,
54
+ readAppliesTo,
55
+ appliesToPr,
56
+ partitionByReviewMode,
57
+ extractPerSkillLine,
58
+ selectReviewHeader,
59
+ extractFirstReviewHeaderLine,
60
+ selectReviewBody,
61
+ extractStatsHeader,
62
+ isCriticalReviewHeader,
63
+ classifyPerSkillOutcome,
64
+ type SkillDescriptor,
65
+ type RankableSkill,
66
+ type AppliesToRule,
67
+ type SkillWithOptionalContent,
68
+ type PrComment,
69
+ type ReviewStatsHeader,
70
+ } from './skills.js';
@@ -6,7 +6,20 @@
6
6
  // per-section budgets, comment format updates) by editing one function
7
7
  // instead of three templates.
8
8
 
9
- const LANGUAGE_HINT_BLOCKS = {
9
+ export type ReviewPromptLanguage = 'generic' | 'ts' | 'py';
10
+
11
+ export interface ReviewPromptOptions {
12
+ projectDescription: string;
13
+ language?: ReviewPromptLanguage;
14
+ }
15
+
16
+ // Internal input type: caller may pass a malformed object (e.g. missing
17
+ // projectDescription) and we throw at runtime. Use Partial so the type
18
+ // system tracks that all fields could be missing — the explicit `undefined`
19
+ // check below recovers a meaningful error message.
20
+ type ReviewPromptInput = Partial<ReviewPromptOptions>;
21
+
22
+ const LANGUAGE_HINT_BLOCKS: Record<ReviewPromptLanguage, readonly string[]> = {
10
23
  generic: ['- Broken or missing test coverage for new code'],
11
24
  ts: [
12
25
  '- Broken or missing test coverage for new code',
@@ -33,7 +46,8 @@ const LANGUAGE_HINT_BLOCKS = {
33
46
  // - 'generic' (default): just "test coverage"
34
47
  // - 'ts': test coverage + 4 TypeScript-specific bullets
35
48
  // - 'py': 4 Python-specific bullets (replaces "test coverage")
36
- export function reviewPrompt({ projectDescription, language = 'generic' } = {}) {
49
+ export function reviewPrompt(options: ReviewPromptInput = {}): string {
50
+ const { projectDescription, language = 'generic' } = options;
37
51
  if (projectDescription === undefined) {
38
52
  throw new Error('reviewPrompt: projectDescription is required');
39
53
  }
@@ -15,21 +15,52 @@
15
15
  // rendered shape is wrong). Centralising the markdown shape here means a
16
16
  // future format tweak edits one function rather than the prompt.
17
17
 
18
- const SEVERITY_EMOJI = { critical: '🔴', minor: '🟡', preexisting: '🟣' };
19
- const SEVERITY_LABEL = {
18
+ import type {
19
+ DedicatedSection,
20
+ FindingSeverity,
21
+ PerSkillScanItem,
22
+ ReviewData,
23
+ ReviewFinding,
24
+ ReviewSummaryCounts,
25
+ } from './review-schema.js';
26
+
27
+ // Emoji constants: use explicit Unicode escape literals (`\u{HHHHH}`) so
28
+ // every step of the TS→JS toolchain — tsc, vitest's transformer, the
29
+ // publisher's tarball — emits the same byte sequence regardless of
30
+ // editor encoding settings. Per SPEC §6 byte-identical contract:
31
+ // \u{1F534} = 🔴 (red circle, critical / "important")
32
+ // \u{1F7E1} = 🟡 (yellow circle, minor / "nit")
33
+ // \u{1F7E3} = 🟣 (purple circle, pre-existing)
34
+ // \u{1F41B} = 🐛 (bug, H2 anchor for `## 🐛 Clud Bug review`)
35
+ const SEVERITY_EMOJI: Record<FindingSeverity, string> = {
36
+ critical: '\u{1F534}',
37
+ minor: '\u{1F7E1}',
38
+ preexisting: '\u{1F7E3}',
39
+ };
40
+ const SEVERITY_LABEL: Record<FindingSeverity, string> = {
20
41
  critical: 'important',
21
42
  minor: 'nit',
22
43
  preexisting: 'pre-existing',
23
44
  };
45
+ // SEVERITY_LABEL is retained for callers that import the constant table
46
+ // (the JS version exported it implicitly via module scope; keep the
47
+ // export so future renderer extensions can reuse it).
48
+ export { SEVERITY_LABEL };
49
+
50
+ // Renderer input type — schema-aligned but defensively typed. The renderer
51
+ // is the last line of defense against malformed JSON, so it accepts an
52
+ // "unknown-ish" shape and degrades gracefully rather than throwing on
53
+ // missing fields.
54
+ type RenderReviewInput = Partial<ReviewData> & Record<string, unknown>;
24
55
 
25
56
  // Render the full summary comment markdown. `data` is the parsed JSON
26
- // matching the schema (see schema.js). Returns a string suitable for
27
- // `gh pr comment --body`.
28
- export function renderReview(data) {
57
+ // matching the schema (see review-schema.ts). Returns a string suitable
58
+ // for `gh pr comment --body`.
59
+ export function renderReview(data: RenderReviewInput | null | undefined): string {
29
60
  if (!data || typeof data !== 'object') {
30
61
  throw new TypeError('renderReview: data must be an object');
31
62
  }
32
- const out = [];
63
+ const out: string[] = [];
33
64
  out.push(renderHeader(data));
34
65
  out.push('');
35
66
  out.push(renderStatusLine(data.summary_counts));
@@ -77,9 +108,9 @@ export function renderReview(data) {
77
108
  return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
78
109
  }
79
110
 
80
- function renderHeader(data) {
111
+ function renderHeader(data: RenderReviewInput): string {
81
112
  const verdict = data.status_header;
82
- const base = '## 🐛 Clud Bug review';
113
+ const base = '## \u{1F41B} Clud Bug review';
83
114
  if (verdict === 'critical findings') return `${base} — critical findings`;
84
115
  if (verdict === 'clean') return `${base} — clean`;
85
116
  // 'bare' (non-strict-mode default) OR an unexpected verdict — render
@@ -87,7 +118,7 @@ function renderHeader(data) {
87
118
  return base;
88
119
  }
89
120
 
90
- function renderStatusLine(counts) {
121
+ function renderStatusLine(counts: ReviewSummaryCounts | undefined): string {
91
122
  const c = sanitizeCounts(counts);
92
123
  return `**This round:** ${c.critical} critical · ${c.minor} minor · ${c.resolved_from_prior} resolved from prior · ${c.still_open} still open`;
93
124
  }
@@ -95,13 +126,13 @@ function renderStatusLine(counts) {
95
126
  // Severity-emoji stats header. Counts pre-existing in 🟣 even though
96
127
  // it's not in summary_counts (the prompt counts pre-existing separately
97
128
  // in preexisting_findings.length).
98
- function renderStatsHeader(counts) {
129
+ function renderStatsHeader(counts: ReviewSummaryCounts | undefined): string {
99
130
  const c = sanitizeCounts(counts);
100
- return `Found: ${c.critical} 🔴 / ${c.minor} 🟡 / ${c.preexisting} 🟣`;
131
+ return `Found: ${c.critical} \u{1F534} / ${c.minor} \u{1F7E1} / ${c.preexisting} \u{1F7E3}`;
101
132
  }
102
133
 
103
- function renderPerSkillScan(scan) {
104
- const out = ['### Per-skill scan'];
134
+ function renderPerSkillScan(scan: PerSkillScanItem[] | undefined): string[] {
135
+ const out: string[] = ['### Per-skill scan'];
105
136
  if (!Array.isArray(scan) || scan.length === 0) {
106
137
  out.push('- (no skills loaded — review proceeded against the baseline.)');
107
138
  return out;
@@ -116,14 +147,14 @@ function renderPerSkillScan(scan) {
116
147
  return out;
117
148
  }
118
149
 
119
- function renderDedicatedSection(section) {
150
+ function renderDedicatedSection(section: DedicatedSection | undefined): string[] {
120
151
  if (!section || typeof section !== 'object') return [];
121
152
  const name = String(section.section_name || '').trim();
122
153
  const skill = String(section.skill || '').trim();
123
154
  const header = skill && name
124
155
  ? `### ${name} [${skill}]`
125
156
  : `### ${name || skill || 'Dedicated section'}`;
126
- const out = [header, ''];
157
+ const out: string[] = [header, ''];
127
158
  if (Array.isArray(section.findings) && section.findings.length > 0) {
128
159
  // Dedicated-section findings use the same emoji-prefix block.
129
160
  // Default severity for dedicated sections is "critical" — they're
@@ -135,9 +166,10 @@ function renderDedicatedSection(section) {
135
166
  return out;
136
167
  }
137
168
 
138
- function renderFindings(findings, severity) {
139
- const emoji = SEVERITY_EMOJI[severity] || '🔴';
140
- const out = [];
169
+ function renderFindings(findings: ReviewFinding[] | undefined, severity: FindingSeverity): string[] {
170
+ const emoji = SEVERITY_EMOJI[severity] || SEVERITY_EMOJI.critical;
171
+ const out: string[] = [];
172
+ if (!Array.isArray(findings)) return out;
141
173
  for (const f of findings) {
142
174
  if (!f || typeof f !== 'object') continue;
143
175
  const skill = String(f.skill || '').trim();
@@ -163,7 +195,7 @@ function renderFindings(findings, severity) {
163
195
  return out;
164
196
  }
165
197
 
166
- function renderSkillsReferenced(skills) {
198
+ function renderSkillsReferenced(skills: string[] | undefined): string {
167
199
  if (!Array.isArray(skills) || skills.length === 0) {
168
200
  return 'Skills referenced: [none] — no installed skill applied to this diff.';
169
201
  }
@@ -172,12 +204,12 @@ function renderSkillsReferenced(skills) {
172
204
 
173
205
  // --- helpers ---
174
206
 
175
- function nonEmpty(arr) {
207
+ function nonEmpty<T>(arr: T[] | undefined): arr is T[] {
176
208
  return Array.isArray(arr) && arr.length > 0;
177
209
  }
178
210
 
179
- function sanitizeCounts(counts) {
180
- const c = counts && typeof counts === 'object' ? counts : {};
211
+ function sanitizeCounts(counts: ReviewSummaryCounts | undefined): ReviewSummaryCounts {
212
+ const c = counts && typeof counts === 'object' ? counts : ({} as Partial<ReviewSummaryCounts>);
181
213
  return {
182
214
  critical: numOrZero(c.critical),
183
215
  minor: numOrZero(c.minor),
@@ -187,18 +219,18 @@ function sanitizeCounts(counts) {
187
219
  };
188
220
  }
189
221
 
190
- function numOrZero(v) {
222
+ function numOrZero(v: unknown): number {
191
223
  const n = Number(v);
192
224
  return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
193
225
  }
194
226
 
195
- function locationAnchor(f) {
227
+ function locationAnchor(f: ReviewFinding): string | null {
196
228
  const file = String(f.file || '').trim();
197
229
  if (!file) return null;
198
230
  const line = Number(f.line);
199
231
  return Number.isFinite(line) && line > 0 ? `${file}:${line}` : file;
200
232
  }
201
233
 
202
- function stripTrailingPunctuation(s) {
234
+ function stripTrailingPunctuation(s: string): string {
203
235
  return s.replace(/[.!?]+$/, '');
204
236
  }
@@ -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 = JSON.parse(
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, (match, key, offset) => {
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 function templateLanguage(tmplName) {
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';