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.
- package/README.md +20 -0
- package/bin/clud-bug.js +10 -1353
- package/data/canonical-v1.json +37 -0
- package/dist/cli/agents-md.d.ts +16 -0
- package/dist/cli/agents-md.d.ts.map +1 -0
- package/dist/cli/agents-md.js +226 -0
- package/dist/cli/agents-md.js.map +1 -0
- package/dist/cli/audit.d.ts +13 -0
- package/dist/cli/audit.d.ts.map +1 -0
- package/dist/cli/audit.js +90 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/branch-protection.d.ts +57 -0
- package/dist/cli/branch-protection.d.ts.map +1 -0
- package/dist/cli/branch-protection.js +118 -0
- package/dist/cli/branch-protection.js.map +1 -0
- package/dist/cli/configure-github.d.ts +58 -0
- package/dist/cli/configure-github.d.ts.map +1 -0
- package/dist/cli/configure-github.js +242 -0
- package/dist/cli/configure-github.js.map +1 -0
- package/dist/cli/edit-workflow.d.ts +18 -0
- package/dist/cli/edit-workflow.d.ts.map +1 -0
- package/dist/cli/edit-workflow.js +43 -0
- package/dist/cli/edit-workflow.js.map +1 -0
- package/dist/cli/index.d.ts +9 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +2067 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/skill-usage.d.ts +109 -0
- package/dist/cli/skill-usage.d.ts.map +1 -0
- package/dist/cli/skill-usage.js +380 -0
- package/dist/cli/skill-usage.js.map +1 -0
- package/dist/cli/skills.d.ts +56 -0
- package/dist/cli/skills.d.ts.map +1 -0
- package/dist/cli/skills.js +292 -0
- package/dist/cli/skills.js.map +1 -0
- package/dist/cli/update.d.ts +29 -0
- package/dist/cli/update.d.ts.map +1 -0
- package/dist/cli/update.js +205 -0
- package/dist/cli/update.js.map +1 -0
- package/dist/cli/usage.d.ts +142 -0
- package/dist/cli/usage.d.ts.map +1 -0
- package/dist/cli/usage.js +348 -0
- package/dist/cli/usage.js.map +1 -0
- package/dist/core/audit.d.ts +8 -0
- package/dist/core/audit.d.ts.map +1 -0
- package/dist/core/audit.js +47 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/auto-resolve.d.ts +235 -0
- package/dist/core/auto-resolve.d.ts.map +1 -0
- package/dist/core/auto-resolve.js +293 -0
- package/dist/core/auto-resolve.js.map +1 -0
- package/dist/core/configure-github.d.ts +179 -0
- package/dist/core/configure-github.d.ts.map +1 -0
- package/dist/core/configure-github.js +289 -0
- package/dist/core/configure-github.js.map +1 -0
- package/dist/core/detect.d.ts +77 -0
- package/dist/core/detect.d.ts.map +1 -0
- package/dist/core/detect.js +262 -0
- package/dist/core/detect.js.map +1 -0
- package/dist/core/diff-findings.d.ts +96 -0
- package/dist/core/diff-findings.d.ts.map +1 -0
- package/dist/core/diff-findings.js +261 -0
- package/dist/core/diff-findings.js.map +1 -0
- package/dist/core/formal-review.d.ts +79 -0
- package/dist/core/formal-review.d.ts.map +1 -0
- package/dist/core/formal-review.js +71 -0
- package/dist/core/formal-review.js.map +1 -0
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +66 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/inline-threads.d.ts +160 -0
- package/dist/core/inline-threads.d.ts.map +1 -0
- package/dist/core/inline-threads.js +369 -0
- package/dist/core/inline-threads.js.map +1 -0
- package/dist/core/prompt-builder.d.ts +164 -0
- package/dist/core/prompt-builder.d.ts.map +1 -0
- package/dist/core/prompt-builder.js +419 -0
- package/dist/core/prompt-builder.js.map +1 -0
- package/dist/core/prompts.d.ts +9 -0
- package/dist/core/prompts.d.ts.map +1 -0
- package/dist/core/prompts.js +401 -0
- package/dist/core/prompts.js.map +1 -0
- package/dist/core/render-review.d.ts +6 -0
- package/dist/core/render-review.d.ts.map +1 -0
- package/dist/core/render-review.js +219 -0
- package/dist/core/render-review.js.map +1 -0
- package/dist/core/render.d.ts +13 -0
- package/dist/core/render.d.ts.map +1 -0
- package/dist/core/render.js +62 -0
- package/dist/core/render.js.map +1 -0
- package/dist/core/resolve-verifier.d.ts +46 -0
- package/dist/core/resolve-verifier.d.ts.map +1 -0
- package/dist/core/resolve-verifier.js +187 -0
- package/dist/core/resolve-verifier.js.map +1 -0
- package/dist/core/review-schema-zod.d.ts +240 -0
- package/dist/core/review-schema-zod.d.ts.map +1 -0
- package/dist/core/review-schema-zod.js +218 -0
- package/dist/core/review-schema-zod.js.map +1 -0
- package/dist/core/review-schema.d.ts +42 -0
- package/dist/core/review-schema.d.ts.map +1 -0
- package/dist/core/review-schema.js +156 -0
- package/dist/core/review-schema.js.map +1 -0
- package/dist/core/review-writeback.d.ts +193 -0
- package/dist/core/review-writeback.d.ts.map +1 -0
- package/dist/core/review-writeback.js +364 -0
- package/dist/core/review-writeback.js.map +1 -0
- package/dist/core/skills.d.ts +147 -0
- package/dist/core/skills.d.ts.map +1 -0
- package/dist/core/skills.js +726 -0
- package/dist/core/skills.js.map +1 -0
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.d.ts.map +1 -0
- package/dist/core/version.js +4 -0
- package/dist/core/version.js.map +1 -0
- package/package.json +37 -5
- package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
- package/{lib/audit.js → src/cli/audit.ts} +37 -44
- package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
- package/src/cli/configure-github.ts +329 -0
- package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
- package/src/cli/index.ts +107 -0
- package/src/cli/main.ts +2204 -0
- package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
- package/src/cli/skills.ts +386 -0
- package/{lib/update.js → src/cli/update.ts} +87 -27
- package/{lib/usage.js → src/cli/usage.ts} +167 -76
- package/src/core/audit.ts +53 -0
- package/src/core/auto-resolve.ts +488 -0
- package/src/core/configure-github.ts +497 -0
- package/{lib/detect.js → src/core/detect.ts} +100 -47
- package/src/core/diff-findings.ts +323 -0
- package/src/core/formal-review.ts +168 -0
- package/src/core/index.ts +250 -0
- package/src/core/inline-threads.ts +471 -0
- package/src/core/prompt-builder.ts +561 -0
- package/{lib/prompts.js → src/core/prompts.ts} +16 -2
- package/{lib/render-review.js → src/core/render-review.ts} +57 -25
- package/{lib/render.js → src/core/render.ts} +37 -17
- package/src/core/resolve-verifier.ts +228 -0
- package/src/core/review-schema-zod.ts +262 -0
- package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
- package/src/core/review-writeback.ts +565 -0
- package/{lib/skills.js → src/core/skills.ts} +456 -342
- package/src/core/version.ts +3 -0
- package/templates/clud-bug-review.md.tmpl +115 -0
- package/templates/workflow-py.yml.tmpl +80 -3
- package/templates/workflow-ts.yml.tmpl +80 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
95
|
-
description: descMatch?.[1]
|
|
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
|
-
|
|
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()
|
|
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]
|
|
131
|
-
description: descMatch?.[1]
|
|
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(
|
|
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]
|
|
246
|
+
primaryLanguage: sortedLangs[0] ?? null,
|
|
210
247
|
};
|
|
211
248
|
}
|
|
212
249
|
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
//
|
|
239
|
-
export const _internal = {
|
|
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
|
+
}
|