clud-bug 0.6.34 → 0.7.0-rc.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +20 -0
  2. package/bin/clud-bug.js +10 -1353
  3. package/data/canonical-v1.json +37 -0
  4. package/dist/cli/agents-md.d.ts +16 -0
  5. package/dist/cli/agents-md.d.ts.map +1 -0
  6. package/dist/cli/agents-md.js +226 -0
  7. package/dist/cli/agents-md.js.map +1 -0
  8. package/dist/cli/audit.d.ts +13 -0
  9. package/dist/cli/audit.d.ts.map +1 -0
  10. package/dist/cli/audit.js +90 -0
  11. package/dist/cli/audit.js.map +1 -0
  12. package/dist/cli/branch-protection.d.ts +57 -0
  13. package/dist/cli/branch-protection.d.ts.map +1 -0
  14. package/dist/cli/branch-protection.js +118 -0
  15. package/dist/cli/branch-protection.js.map +1 -0
  16. package/dist/cli/configure-github.d.ts +58 -0
  17. package/dist/cli/configure-github.d.ts.map +1 -0
  18. package/dist/cli/configure-github.js +242 -0
  19. package/dist/cli/configure-github.js.map +1 -0
  20. package/dist/cli/edit-workflow.d.ts +18 -0
  21. package/dist/cli/edit-workflow.d.ts.map +1 -0
  22. package/dist/cli/edit-workflow.js +43 -0
  23. package/dist/cli/edit-workflow.js.map +1 -0
  24. package/dist/cli/index.d.ts +9 -0
  25. package/dist/cli/index.d.ts.map +1 -0
  26. package/dist/cli/index.js +19 -0
  27. package/dist/cli/index.js.map +1 -0
  28. package/dist/cli/main.d.ts +3 -0
  29. package/dist/cli/main.d.ts.map +1 -0
  30. package/dist/cli/main.js +2067 -0
  31. package/dist/cli/main.js.map +1 -0
  32. package/dist/cli/skill-usage.d.ts +109 -0
  33. package/dist/cli/skill-usage.d.ts.map +1 -0
  34. package/dist/cli/skill-usage.js +380 -0
  35. package/dist/cli/skill-usage.js.map +1 -0
  36. package/dist/cli/skills.d.ts +56 -0
  37. package/dist/cli/skills.d.ts.map +1 -0
  38. package/dist/cli/skills.js +292 -0
  39. package/dist/cli/skills.js.map +1 -0
  40. package/dist/cli/update.d.ts +29 -0
  41. package/dist/cli/update.d.ts.map +1 -0
  42. package/dist/cli/update.js +205 -0
  43. package/dist/cli/update.js.map +1 -0
  44. package/dist/cli/usage.d.ts +142 -0
  45. package/dist/cli/usage.d.ts.map +1 -0
  46. package/dist/cli/usage.js +348 -0
  47. package/dist/cli/usage.js.map +1 -0
  48. package/dist/core/audit.d.ts +8 -0
  49. package/dist/core/audit.d.ts.map +1 -0
  50. package/dist/core/audit.js +47 -0
  51. package/dist/core/audit.js.map +1 -0
  52. package/dist/core/auto-resolve.d.ts +235 -0
  53. package/dist/core/auto-resolve.d.ts.map +1 -0
  54. package/dist/core/auto-resolve.js +293 -0
  55. package/dist/core/auto-resolve.js.map +1 -0
  56. package/dist/core/configure-github.d.ts +179 -0
  57. package/dist/core/configure-github.d.ts.map +1 -0
  58. package/dist/core/configure-github.js +289 -0
  59. package/dist/core/configure-github.js.map +1 -0
  60. package/dist/core/detect.d.ts +77 -0
  61. package/dist/core/detect.d.ts.map +1 -0
  62. package/dist/core/detect.js +262 -0
  63. package/dist/core/detect.js.map +1 -0
  64. package/dist/core/diff-findings.d.ts +96 -0
  65. package/dist/core/diff-findings.d.ts.map +1 -0
  66. package/dist/core/diff-findings.js +261 -0
  67. package/dist/core/diff-findings.js.map +1 -0
  68. package/dist/core/formal-review.d.ts +79 -0
  69. package/dist/core/formal-review.d.ts.map +1 -0
  70. package/dist/core/formal-review.js +71 -0
  71. package/dist/core/formal-review.js.map +1 -0
  72. package/dist/core/index.d.ts +17 -0
  73. package/dist/core/index.d.ts.map +1 -0
  74. package/dist/core/index.js +66 -0
  75. package/dist/core/index.js.map +1 -0
  76. package/dist/core/inline-threads.d.ts +160 -0
  77. package/dist/core/inline-threads.d.ts.map +1 -0
  78. package/dist/core/inline-threads.js +369 -0
  79. package/dist/core/inline-threads.js.map +1 -0
  80. package/dist/core/prompt-builder.d.ts +164 -0
  81. package/dist/core/prompt-builder.d.ts.map +1 -0
  82. package/dist/core/prompt-builder.js +419 -0
  83. package/dist/core/prompt-builder.js.map +1 -0
  84. package/dist/core/prompts.d.ts +9 -0
  85. package/dist/core/prompts.d.ts.map +1 -0
  86. package/dist/core/prompts.js +401 -0
  87. package/dist/core/prompts.js.map +1 -0
  88. package/dist/core/render-review.d.ts +6 -0
  89. package/dist/core/render-review.d.ts.map +1 -0
  90. package/dist/core/render-review.js +219 -0
  91. package/dist/core/render-review.js.map +1 -0
  92. package/dist/core/render.d.ts +13 -0
  93. package/dist/core/render.d.ts.map +1 -0
  94. package/dist/core/render.js +62 -0
  95. package/dist/core/render.js.map +1 -0
  96. package/dist/core/resolve-verifier.d.ts +46 -0
  97. package/dist/core/resolve-verifier.d.ts.map +1 -0
  98. package/dist/core/resolve-verifier.js +187 -0
  99. package/dist/core/resolve-verifier.js.map +1 -0
  100. package/dist/core/review-schema-zod.d.ts +240 -0
  101. package/dist/core/review-schema-zod.d.ts.map +1 -0
  102. package/dist/core/review-schema-zod.js +218 -0
  103. package/dist/core/review-schema-zod.js.map +1 -0
  104. package/dist/core/review-schema.d.ts +42 -0
  105. package/dist/core/review-schema.d.ts.map +1 -0
  106. package/dist/core/review-schema.js +156 -0
  107. package/dist/core/review-schema.js.map +1 -0
  108. package/dist/core/review-writeback.d.ts +193 -0
  109. package/dist/core/review-writeback.d.ts.map +1 -0
  110. package/dist/core/review-writeback.js +364 -0
  111. package/dist/core/review-writeback.js.map +1 -0
  112. package/dist/core/skills.d.ts +147 -0
  113. package/dist/core/skills.d.ts.map +1 -0
  114. package/dist/core/skills.js +726 -0
  115. package/dist/core/skills.js.map +1 -0
  116. package/dist/core/version.d.ts +2 -0
  117. package/dist/core/version.d.ts.map +1 -0
  118. package/dist/core/version.js +4 -0
  119. package/dist/core/version.js.map +1 -0
  120. package/package.json +37 -5
  121. package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
  122. package/{lib/audit.js → src/cli/audit.ts} +37 -44
  123. package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
  124. package/src/cli/configure-github.ts +329 -0
  125. package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
  126. package/src/cli/index.ts +107 -0
  127. package/src/cli/main.ts +2204 -0
  128. package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
  129. package/src/cli/skills.ts +386 -0
  130. package/{lib/update.js → src/cli/update.ts} +87 -27
  131. package/{lib/usage.js → src/cli/usage.ts} +167 -76
  132. package/src/core/audit.ts +53 -0
  133. package/src/core/auto-resolve.ts +488 -0
  134. package/src/core/configure-github.ts +497 -0
  135. package/{lib/detect.js → src/core/detect.ts} +100 -47
  136. package/src/core/diff-findings.ts +323 -0
  137. package/src/core/formal-review.ts +168 -0
  138. package/src/core/index.ts +250 -0
  139. package/src/core/inline-threads.ts +471 -0
  140. package/src/core/prompt-builder.ts +561 -0
  141. package/{lib/prompts.js → src/core/prompts.ts} +16 -2
  142. package/{lib/render-review.js → src/core/render-review.ts} +57 -25
  143. package/{lib/render.js → src/core/render.ts} +37 -17
  144. package/src/core/resolve-verifier.ts +228 -0
  145. package/src/core/review-schema-zod.ts +262 -0
  146. package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
  147. package/src/core/review-writeback.ts +565 -0
  148. package/{lib/skills.js → src/core/skills.ts} +456 -342
  149. package/src/core/version.ts +3 -0
  150. package/templates/clud-bug-review.md.tmpl +115 -0
  151. package/templates/workflow-py.yml.tmpl +80 -3
  152. package/templates/workflow-ts.yml.tmpl +80 -3
  153. package/templates/workflow.yml.tmpl +175 -8
@@ -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,323 @@
1
+ // SPEC §1.8.1 multi-pass diff: prior `docs/reviews/PR-<n>.md` vs current
2
+ // review findings. Produces the `**Resolved this round:**` /
3
+ // `**Still open:**` lists that `renderReviewFile` consumes.
4
+ //
5
+ // Identity model
6
+ // --------------
7
+ // Each finding's identity is a stable hash of:
8
+ //
9
+ // `${file}:${line}:${severity}:${skillName}:${summary.slice(0, 100)}`
10
+ //
11
+ // A 100-character truncation on `summary` is enough to discriminate
12
+ // distinct findings without being so long that whitespace/punctuation
13
+ // drift between passes breaks identity. A severity-bucket change (e.g.
14
+ // the same skill flags the same line as 🔴 then 🟡 the next round)
15
+ // produces a DIFFERENT identity — by design — so a severity downgrade
16
+ // counts as one resolved finding + one new finding (the user gets
17
+ // credit for the fix AND can see the bot still has a concern, just at
18
+ // a lower severity).
19
+ //
20
+ // The same identity shape is what auto-fix / auto-resolve in
21
+ // clud-bug-app uses for thread anchoring; keeping it identical means a
22
+ // future per-finding cross-reference between the doc-file diff and the
23
+ // inline-thread surface is trivial.
24
+
25
+ import {
26
+ flattenFindings,
27
+ type Finding,
28
+ type Review,
29
+ } from './review-schema-zod.js';
30
+
31
+ import { SEVERITY_EMOJI } from './review-writeback.js';
32
+
33
+ /** One parsed finding from a prior `docs/reviews/PR-<n>.md`. */
34
+ export type ParsedFinding = {
35
+ /** Relative path. Falls back to '(unknown file)' when the prior file
36
+ * itself rendered the unknown-file marker; we keep it intact for
37
+ * identity stability. */
38
+ file: string;
39
+ /**
40
+ * 1-indexed line number. Zero when the prior file had no `:N` suffix
41
+ * (cross-cutting findings). Zero participates in identity as-is.
42
+ */
43
+ line: number;
44
+ severity: 'critical' | 'minor' | 'preexisting';
45
+ skillName: string;
46
+ summary: string;
47
+ };
48
+
49
+ export type ParsedReview = { findings: ParsedFinding[] };
50
+
51
+ /**
52
+ * Parse a `docs/reviews/PR-<n>.md` markdown back into structured findings.
53
+ *
54
+ * Robust to:
55
+ * - `null` / `undefined` input → returns `null`
56
+ * - empty / whitespace-only markdown → returns `null`
57
+ * - missing severity sections → those sections contribute 0
58
+ * - lines that don't match the SPEC bullet shape → silently dropped
59
+ * - mixed unknown-file markers (`(unknown file)` vs absent)
60
+ *
61
+ * Parsing strategy: walk top-down, switch active severity on each
62
+ * `### <emoji> <Label>` header (one of the three SPEC §1.8.1 buckets),
63
+ * then collect every line starting with `- **` until the next header
64
+ * (or `---` end marker). Each `- **<file>:<line>** — <skill>: <summary>`
65
+ * line is split into its 4 fields; `:<line>` is optional (cross-cutting).
66
+ *
67
+ * `(unknown file)` files are preserved verbatim — they participate in
68
+ * identity, so the same cross-cutting finding can still be diffed across
69
+ * rounds even when neither pass has a line anchor.
70
+ */
71
+ export function parsePriorReviewFile(
72
+ markdown: string | null | undefined,
73
+ ): ParsedReview | null {
74
+ if (markdown == null) return null;
75
+ if (markdown.trim() === '') return null;
76
+
77
+ const lines = markdown.split('\n');
78
+ const out: ParsedFinding[] = [];
79
+ let current: ParsedFinding['severity'] | null = null;
80
+
81
+ for (const raw of lines) {
82
+ // Strip trailing whitespace; leading indent is meaningful for
83
+ // sub-lines (Reasoning, attribution) which we deliberately ignore.
84
+ const line = raw.replace(/\s+$/, '');
85
+
86
+ // The SPEC §1.8.1 trailing `---` separator ends the findings region.
87
+ // Anything after it (the [Link to PR] line) is metadata and parsed
88
+ // by short-circuit so we don't mistake the literal `---` for a
89
+ // missing header.
90
+ if (line === '---') {
91
+ current = null;
92
+ continue;
93
+ }
94
+
95
+ // Switch active severity on every `### <emoji> <Label>` heading.
96
+ // We pattern-match on the emoji codepoint (not the label text) so a
97
+ // future SPEC tweak to "Critical" → "Blocking" doesn't break us.
98
+ if (line.startsWith('### ')) {
99
+ if (line.includes(SEVERITY_EMOJI.critical)) {
100
+ current = 'critical';
101
+ } else if (line.includes(SEVERITY_EMOJI.minor)) {
102
+ current = 'minor';
103
+ } else if (line.includes(SEVERITY_EMOJI.preexisting)) {
104
+ current = 'preexisting';
105
+ } else {
106
+ current = null;
107
+ }
108
+ continue;
109
+ }
110
+
111
+ // Resolved / Still-open blocks under SPEC §1.8.1 — these list
112
+ // findings from PRIOR rounds, NOT this-round findings. Skip them
113
+ // so a multi-round PR doesn't double-count its own history.
114
+ // (`**Resolved this round:**` / `**Still open:**` headings.)
115
+ if (line.startsWith('**Resolved this round:') || line.startsWith('**Still open:')) {
116
+ current = null;
117
+ continue;
118
+ }
119
+
120
+ if (current == null) continue;
121
+
122
+ // SPEC §1.8.1 bullet: `- **<file>:<line>** — <skill>: <summary>`
123
+ // OR `- **<file>** — <skill>: <summary>`
124
+ // OR `- **(unknown file)** — <skill>: <summary>`
125
+ //
126
+ // The multi-pass renderer prepends `[Pass N — Role · model]` to the
127
+ // bullet; we accept that prefix and discard it for parsing.
128
+ const parsed = parseFindingBullet(line);
129
+ if (parsed) {
130
+ out.push({ ...parsed, severity: current });
131
+ }
132
+ }
133
+
134
+ if (out.length === 0) return null;
135
+ return { findings: out };
136
+ }
137
+
138
+ /**
139
+ * Diff prior vs current.
140
+ *
141
+ * `resolvedFindings`: findings that appeared in `prior` but NOT in
142
+ * `current`. The PR author (or auto-fix) addressed them.
143
+ *
144
+ * `stillOpenFindings`: findings that appeared in BOTH `prior` and
145
+ * `current`. These are persistent — the PR author hasn't fixed them
146
+ * (or the bot still considers them findings post-fix-push).
147
+ *
148
+ * Findings unique to `current` (newly raised this round) appear in
149
+ * neither list — those are surfaced directly by the renderer's normal
150
+ * severity-bucket emission.
151
+ *
152
+ * Order is preserved from `prior` for stability across rounds.
153
+ */
154
+ export function diffFindings(
155
+ prior: ParsedReview | null,
156
+ current: {
157
+ critical_findings?: Array<{
158
+ skill: string;
159
+ file?: string;
160
+ line?: number;
161
+ summary: string;
162
+ }>;
163
+ minor_findings?: Array<{
164
+ skill: string;
165
+ file?: string;
166
+ line?: number;
167
+ summary: string;
168
+ }>;
169
+ preexisting_findings?: Array<{
170
+ skill: string;
171
+ file?: string;
172
+ line?: number;
173
+ summary: string;
174
+ }>;
175
+ },
176
+ ): {
177
+ resolvedFindings: ParsedFinding[];
178
+ stillOpenFindings: ParsedFinding[];
179
+ } {
180
+ if (prior === null || prior.findings.length === 0) {
181
+ return { resolvedFindings: [], stillOpenFindings: [] };
182
+ }
183
+
184
+ // Build identity set for the current round. We do not need the
185
+ // current-round ParsedFinding objects — we only need to know which
186
+ // identities are still present.
187
+ //
188
+ // Cast through `Review` shape — the schema's three arrays mirror our
189
+ // input slice exactly (skill / file / line / summary), so we can lean
190
+ // on `flattenFindings` to produce a tagged list.
191
+ const currentReview: Review = {
192
+ status_header: 'clean',
193
+ summary_counts: {
194
+ critical: 0,
195
+ minor: 0,
196
+ preexisting: 0,
197
+ resolved_from_prior: 0,
198
+ still_open: 0,
199
+ },
200
+ per_skill_scan: [],
201
+ critical_findings: current.critical_findings ?? [],
202
+ minor_findings: current.minor_findings ?? [],
203
+ preexisting_findings: current.preexisting_findings ?? [],
204
+ skills_referenced: [],
205
+ last_reviewed_sha: '',
206
+ };
207
+ const currentFlat: Finding[] = flattenFindings(currentReview);
208
+ const currentIds = new Set<string>(currentFlat.map((f) => findingIdentity({
209
+ file: f.file ?? '(unknown file)',
210
+ line: f.line ?? 0,
211
+ severity: f.severity,
212
+ skillName: f.skill,
213
+ summary: f.summary,
214
+ })));
215
+
216
+ const resolved: ParsedFinding[] = [];
217
+ const stillOpen: ParsedFinding[] = [];
218
+ for (const f of prior.findings) {
219
+ const id = findingIdentity(f);
220
+ if (currentIds.has(id)) {
221
+ stillOpen.push(f);
222
+ } else {
223
+ resolved.push(f);
224
+ }
225
+ }
226
+ return { resolvedFindings: resolved, stillOpenFindings: stillOpen };
227
+ }
228
+
229
+ /**
230
+ * Stable identity for a finding. Used for diffing prior vs current
231
+ * across review rounds AND (by intention) shareable with the
232
+ * clud-bug-app inline-thread anchor hash so future cross-feature
233
+ * surfaces (e.g. "the auto-fix that resolved this thread also resolves
234
+ * this doc-file finding") align without re-computing.
235
+ *
236
+ * Exposed for tests + downstream callers that want to align their own
237
+ * finding storage on the same scheme.
238
+ */
239
+ export function findingIdentity(f: {
240
+ file: string;
241
+ line: number;
242
+ severity: 'critical' | 'minor' | 'preexisting';
243
+ skillName: string;
244
+ summary: string;
245
+ }): string {
246
+ // Truncate summary to 100 chars to absorb whitespace/punctuation
247
+ // drift between rounds without losing discrimination between
248
+ // genuinely-distinct findings (the SPEC §1.8.1 summary line is
249
+ // user-visible so it tends to be stable; 100 chars is enough for any
250
+ // realistic distinct summary while tolerating "fix the X" → "fix X"
251
+ // drift).
252
+ const summaryPart = f.summary.slice(0, 100);
253
+ return `${f.file}:${f.line}:${f.severity}:${f.skillName}:${summaryPart}`;
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Parsing internals
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * Parse one SPEC §1.8.1 bullet line into a {file, line, skill, summary}
262
+ * tuple. Returns null for any line that doesn't match the SPEC shape.
263
+ *
264
+ * Accepts both shapes:
265
+ * `- **<file>:<line>** — <skill>: <summary>`
266
+ * `- **<file>** — <skill>: <summary>`
267
+ *
268
+ * Also tolerates the multi-pass attribution prefix (D.2.5):
269
+ * `- [Pass 1 — Role · model] **<file>:<line>** — <skill>: <summary>`
270
+ *
271
+ * Em-dash recognition: the SPEC pins U+2014 EM DASH between location
272
+ * and skill. Some downstream tools have been observed using `--` or
273
+ * regular hyphens; we accept both for resilience.
274
+ */
275
+ function parseFindingBullet(line: string): Omit<ParsedFinding, 'severity'> | null {
276
+ // Strip the leading `- ` bullet marker.
277
+ if (!line.startsWith('- ')) return null;
278
+ let rest = line.slice(2);
279
+
280
+ // Strip optional `[Pass N — ...] ` attribution prefix (D.2.5).
281
+ if (rest.startsWith('[')) {
282
+ const closeIdx = rest.indexOf('] ');
283
+ if (closeIdx === -1) return null;
284
+ rest = rest.slice(closeIdx + 2);
285
+ }
286
+
287
+ // Expect `**<location>** ` next.
288
+ if (!rest.startsWith('**')) return null;
289
+ const locEnd = rest.indexOf('**', 2);
290
+ if (locEnd === -1) return null;
291
+ const location = rest.slice(2, locEnd);
292
+ rest = rest.slice(locEnd + 2);
293
+
294
+ // Strip the location separator. SPEC pins ` — ` (U+2014 surrounded by
295
+ // spaces). We accept hyphen-minus variants as a courtesy.
296
+ // The separator may be ` — `, ` -- `, or ` - `.
297
+ const sepMatch = rest.match(/^\s+(?:—|--|-)\s+/);
298
+ if (!sepMatch) return null;
299
+ rest = rest.slice(sepMatch[0].length);
300
+
301
+ // `<skill>: <summary>` — split on the first `: `.
302
+ const sepIdx = rest.indexOf(': ');
303
+ if (sepIdx === -1) return null;
304
+ const skillName = rest.slice(0, sepIdx).trim();
305
+ const summary = rest.slice(sepIdx + 2).trim();
306
+ if (skillName === '' || summary === '') return null;
307
+
308
+ // Split location into file + optional line. Walk RIGHTWARD from the
309
+ // last `:` so file names containing colons (Windows-style, rare) on
310
+ // the LHS don't confuse us.
311
+ const colonIdx = location.lastIndexOf(':');
312
+ let file = location;
313
+ let lineNum = 0;
314
+ if (colonIdx !== -1) {
315
+ const tail = location.slice(colonIdx + 1);
316
+ if (/^\d+$/.test(tail)) {
317
+ file = location.slice(0, colonIdx);
318
+ lineNum = Number(tail);
319
+ }
320
+ }
321
+
322
+ return { file, line: lineNum, skillName, summary };
323
+ }