sequant 1.12.0 → 1.13.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.
- package/README.md +10 -8
- package/dist/bin/cli.js +19 -9
- package/dist/src/commands/doctor.js +42 -20
- package/dist/src/commands/init.js +152 -65
- package/dist/src/commands/logs.js +7 -6
- package/dist/src/commands/run.d.ts +13 -1
- package/dist/src/commands/run.js +122 -32
- package/dist/src/commands/stats.js +67 -48
- package/dist/src/commands/status.js +30 -12
- package/dist/src/commands/sync.d.ts +28 -0
- package/dist/src/commands/sync.js +102 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +4 -0
- package/dist/src/lib/cli-ui.d.ts +196 -0
- package/dist/src/lib/cli-ui.js +544 -0
- package/dist/src/lib/content-analyzer.d.ts +89 -0
- package/dist/src/lib/content-analyzer.js +437 -0
- package/dist/src/lib/phase-signal.d.ts +94 -0
- package/dist/src/lib/phase-signal.js +171 -0
- package/dist/src/lib/phase-spinner.d.ts +146 -0
- package/dist/src/lib/phase-spinner.js +255 -0
- package/dist/src/lib/solve-comment-parser.d.ts +84 -0
- package/dist/src/lib/solve-comment-parser.js +200 -0
- package/dist/src/lib/stack-config.d.ts +51 -0
- package/dist/src/lib/stack-config.js +77 -0
- package/dist/src/lib/stacks.d.ts +52 -0
- package/dist/src/lib/stacks.js +173 -0
- package/dist/src/lib/templates.d.ts +2 -0
- package/dist/src/lib/templates.js +9 -2
- package/dist/src/lib/upstream/assessment.d.ts +70 -0
- package/dist/src/lib/upstream/assessment.js +385 -0
- package/dist/src/lib/upstream/index.d.ts +11 -0
- package/dist/src/lib/upstream/index.js +14 -0
- package/dist/src/lib/upstream/issues.d.ts +38 -0
- package/dist/src/lib/upstream/issues.js +267 -0
- package/dist/src/lib/upstream/relevance.d.ts +50 -0
- package/dist/src/lib/upstream/relevance.js +209 -0
- package/dist/src/lib/upstream/report.d.ts +29 -0
- package/dist/src/lib/upstream/report.js +391 -0
- package/dist/src/lib/upstream/types.d.ts +207 -0
- package/dist/src/lib/upstream/types.js +5 -0
- package/dist/src/lib/workflow/log-writer.d.ts +1 -1
- package/dist/src/lib/workflow/metrics-schema.d.ts +3 -3
- package/dist/src/lib/workflow/qa-cache.d.ts +199 -0
- package/dist/src/lib/workflow/qa-cache.js +440 -0
- package/dist/src/lib/workflow/run-log-schema.d.ts +34 -6
- package/dist/src/lib/workflow/run-log-schema.js +12 -1
- package/dist/src/lib/workflow/state-schema.d.ts +4 -4
- package/dist/src/lib/workflow/types.d.ts +4 -0
- package/package.json +6 -1
- package/templates/skills/qa/scripts/quality-checks.sh +509 -53
- package/templates/skills/solve/SKILL.md +375 -83
- package/templates/skills/spec/SKILL.md +107 -5
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub issue management for upstream assessments
|
|
3
|
+
* Handles issue creation, deduplication, and commenting
|
|
4
|
+
*
|
|
5
|
+
* Security: All gh CLI calls use spawn() with argument arrays to prevent
|
|
6
|
+
* command injection. No shell interpolation is used.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { writeFile, unlink } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { generateFindingIssue } from "./report.js";
|
|
13
|
+
/**
|
|
14
|
+
* Regex pattern for valid GitHub owner/repo names
|
|
15
|
+
* Only alphanumeric, hyphens, underscores, and dots allowed
|
|
16
|
+
*/
|
|
17
|
+
const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
18
|
+
/**
|
|
19
|
+
* Validate owner and repo names to prevent injection
|
|
20
|
+
*/
|
|
21
|
+
function validateRepoParams(owner, repo) {
|
|
22
|
+
if (!REPO_NAME_PATTERN.test(owner)) {
|
|
23
|
+
throw new Error(`Invalid owner name: "${owner}"`);
|
|
24
|
+
}
|
|
25
|
+
if (!REPO_NAME_PATTERN.test(repo)) {
|
|
26
|
+
throw new Error(`Invalid repo name: "${repo}"`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Execute a command safely using spawn with argument arrays
|
|
31
|
+
* This prevents command injection by not using shell interpolation
|
|
32
|
+
*/
|
|
33
|
+
async function execCommand(command, args) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const proc = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
36
|
+
let stdout = "";
|
|
37
|
+
let stderr = "";
|
|
38
|
+
proc.stdout.on("data", (data) => {
|
|
39
|
+
stdout += data.toString();
|
|
40
|
+
});
|
|
41
|
+
proc.stderr.on("data", (data) => {
|
|
42
|
+
stderr += data.toString();
|
|
43
|
+
});
|
|
44
|
+
proc.on("close", (code) => {
|
|
45
|
+
if (code === 0) {
|
|
46
|
+
resolve({ stdout, stderr });
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
reject(new Error(`Command failed with exit code ${code}: ${stderr}`));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
proc.on("error", (err) => {
|
|
53
|
+
reject(err);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Check if a similar upstream issue already exists
|
|
59
|
+
*/
|
|
60
|
+
export async function checkForDuplicate(title, owner = "admarble", repo = "sequant") {
|
|
61
|
+
try {
|
|
62
|
+
validateRepoParams(owner, repo);
|
|
63
|
+
// Search for existing upstream issues with similar title
|
|
64
|
+
// Extract key terms from title for search
|
|
65
|
+
const searchTerms = extractSearchTerms(title);
|
|
66
|
+
// Use spawn with argument arrays - no shell interpolation
|
|
67
|
+
const { stdout } = await execCommand("gh", [
|
|
68
|
+
"issue",
|
|
69
|
+
"list",
|
|
70
|
+
"--repo",
|
|
71
|
+
`${owner}/${repo}`,
|
|
72
|
+
"--label",
|
|
73
|
+
"upstream",
|
|
74
|
+
"--search",
|
|
75
|
+
searchTerms,
|
|
76
|
+
"--json",
|
|
77
|
+
"number,title",
|
|
78
|
+
"--limit",
|
|
79
|
+
"10",
|
|
80
|
+
]);
|
|
81
|
+
const issues = JSON.parse(stdout);
|
|
82
|
+
// Check for similarity
|
|
83
|
+
for (const issue of issues) {
|
|
84
|
+
if (isSimilarTitle(title, issue.title)) {
|
|
85
|
+
return {
|
|
86
|
+
isDuplicate: true,
|
|
87
|
+
existingIssue: issue.number,
|
|
88
|
+
existingTitle: issue.title,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { isDuplicate: false };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
// If search fails, assume no duplicate
|
|
96
|
+
console.error("Error checking for duplicates:", error);
|
|
97
|
+
return { isDuplicate: false };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Extract search terms from a title
|
|
102
|
+
* Removes common words and version info
|
|
103
|
+
*/
|
|
104
|
+
export function extractSearchTerms(title) {
|
|
105
|
+
const stopWords = [
|
|
106
|
+
"the",
|
|
107
|
+
"a",
|
|
108
|
+
"an",
|
|
109
|
+
"from",
|
|
110
|
+
"to",
|
|
111
|
+
"in",
|
|
112
|
+
"for",
|
|
113
|
+
"of",
|
|
114
|
+
"on",
|
|
115
|
+
"with",
|
|
116
|
+
"claude",
|
|
117
|
+
"code",
|
|
118
|
+
];
|
|
119
|
+
// Remove version patterns like v2.1.29
|
|
120
|
+
let cleaned = title.replace(/v?\d+\.\d+\.\d+/g, "");
|
|
121
|
+
// Remove prefixes
|
|
122
|
+
cleaned = cleaned.replace(/^(BREAKING|Deprecated|New tool|Hook change|feat|fix|chore):?\s*/i, "");
|
|
123
|
+
// Split into words and filter
|
|
124
|
+
const words = cleaned
|
|
125
|
+
.toLowerCase()
|
|
126
|
+
.split(/\s+/)
|
|
127
|
+
.filter((w) => w.length > 2 && !stopWords.includes(w));
|
|
128
|
+
// Take first 5 meaningful words
|
|
129
|
+
return words.slice(0, 5).join(" ");
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Check if two titles are similar enough to be duplicates
|
|
133
|
+
*/
|
|
134
|
+
export function isSimilarTitle(title1, title2) {
|
|
135
|
+
const terms1 = new Set(extractSearchTerms(title1)
|
|
136
|
+
.split(" ")
|
|
137
|
+
.filter((t) => t.length > 0));
|
|
138
|
+
const terms2 = new Set(extractSearchTerms(title2)
|
|
139
|
+
.split(" ")
|
|
140
|
+
.filter((t) => t.length > 0));
|
|
141
|
+
// Calculate Jaccard similarity
|
|
142
|
+
const intersection = new Set([...terms1].filter((x) => terms2.has(x)));
|
|
143
|
+
const union = new Set([...terms1, ...terms2]);
|
|
144
|
+
// Handle edge case where both are empty
|
|
145
|
+
if (union.size === 0)
|
|
146
|
+
return false;
|
|
147
|
+
const similarity = intersection.size / union.size;
|
|
148
|
+
// Consider similar if > 60% overlap
|
|
149
|
+
return similarity > 0.6;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Create a GitHub issue using a temporary file for the body
|
|
153
|
+
* This avoids any shell escaping issues with complex markdown content
|
|
154
|
+
*/
|
|
155
|
+
export async function createIssue(params, owner = "admarble", repo = "sequant") {
|
|
156
|
+
validateRepoParams(owner, repo);
|
|
157
|
+
// Write body to a temp file to avoid any escaping issues
|
|
158
|
+
const tempFile = join(tmpdir(), `gh-issue-body-${Date.now()}.md`);
|
|
159
|
+
try {
|
|
160
|
+
await writeFile(tempFile, params.body, "utf-8");
|
|
161
|
+
// Build args array
|
|
162
|
+
const args = [
|
|
163
|
+
"issue",
|
|
164
|
+
"create",
|
|
165
|
+
"--repo",
|
|
166
|
+
`${owner}/${repo}`,
|
|
167
|
+
"--title",
|
|
168
|
+
params.title,
|
|
169
|
+
"--body-file",
|
|
170
|
+
tempFile,
|
|
171
|
+
];
|
|
172
|
+
// Add labels
|
|
173
|
+
for (const label of params.labels) {
|
|
174
|
+
args.push("--label", label);
|
|
175
|
+
}
|
|
176
|
+
const { stdout } = await execCommand("gh", args);
|
|
177
|
+
// Parse issue URL from output
|
|
178
|
+
const url = stdout.trim();
|
|
179
|
+
const numberMatch = url.match(/\/issues\/(\d+)$/);
|
|
180
|
+
const number = numberMatch ? parseInt(numberMatch[1], 10) : 0;
|
|
181
|
+
return { number, url };
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
// Clean up temp file
|
|
185
|
+
try {
|
|
186
|
+
await unlink(tempFile);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Ignore cleanup errors
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Add a comment to an existing issue
|
|
195
|
+
*/
|
|
196
|
+
export async function addIssueComment(issueNumber, comment, owner = "admarble", repo = "sequant") {
|
|
197
|
+
validateRepoParams(owner, repo);
|
|
198
|
+
// Validate issue number
|
|
199
|
+
if (!Number.isInteger(issueNumber) || issueNumber < 1) {
|
|
200
|
+
throw new Error(`Invalid issue number: ${issueNumber}`);
|
|
201
|
+
}
|
|
202
|
+
// Write comment to a temp file to avoid escaping issues
|
|
203
|
+
const tempFile = join(tmpdir(), `gh-comment-${Date.now()}.md`);
|
|
204
|
+
try {
|
|
205
|
+
await writeFile(tempFile, comment, "utf-8");
|
|
206
|
+
await execCommand("gh", [
|
|
207
|
+
"issue",
|
|
208
|
+
"comment",
|
|
209
|
+
String(issueNumber),
|
|
210
|
+
"--repo",
|
|
211
|
+
`${owner}/${repo}`,
|
|
212
|
+
"--body-file",
|
|
213
|
+
tempFile,
|
|
214
|
+
]);
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
// Clean up temp file
|
|
218
|
+
try {
|
|
219
|
+
await unlink(tempFile);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// Ignore cleanup errors
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Create or link an issue for a finding
|
|
228
|
+
*/
|
|
229
|
+
export async function createOrLinkFinding(finding, version, assessmentIssueNumber, dryRun = false, owner = "admarble", repo = "sequant") {
|
|
230
|
+
// Generate issue content
|
|
231
|
+
const issueContent = generateFindingIssue(finding, version, assessmentIssueNumber);
|
|
232
|
+
// Check for duplicate
|
|
233
|
+
const duplicate = await checkForDuplicate(issueContent.title, owner, repo);
|
|
234
|
+
if (duplicate.isDuplicate && duplicate.existingIssue) {
|
|
235
|
+
// Link to existing issue
|
|
236
|
+
if (!dryRun) {
|
|
237
|
+
await addIssueComment(duplicate.existingIssue, `Also relevant in Claude Code ${version} assessment${assessmentIssueNumber ? ` (#${assessmentIssueNumber})` : ""}.`, owner, repo);
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
...finding,
|
|
241
|
+
existingIssue: duplicate.existingIssue,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// Create new issue
|
|
245
|
+
if (!dryRun) {
|
|
246
|
+
const result = await createIssue(issueContent, owner, repo);
|
|
247
|
+
return {
|
|
248
|
+
...finding,
|
|
249
|
+
issueNumber: result.number,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return finding;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Create the assessment summary issue
|
|
256
|
+
*/
|
|
257
|
+
export async function createAssessmentIssue(title, body, dryRun = false, owner = "admarble", repo = "sequant") {
|
|
258
|
+
if (dryRun) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
const result = await createIssue({
|
|
262
|
+
title,
|
|
263
|
+
body,
|
|
264
|
+
labels: ["upstream", "assessment"],
|
|
265
|
+
}, owner, repo);
|
|
266
|
+
return result.number;
|
|
267
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relevance detection for upstream release changes
|
|
3
|
+
* Matches changes against sequant's baseline to identify relevant items
|
|
4
|
+
*/
|
|
5
|
+
import type { Baseline, DetectionPatterns, Finding, FindingCategory, ImpactLevel } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Default detection patterns for categorizing changes
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_PATTERNS: DetectionPatterns;
|
|
10
|
+
/**
|
|
11
|
+
* Extract individual change items from release body
|
|
12
|
+
* Handles various markdown formats
|
|
13
|
+
*/
|
|
14
|
+
export declare function extractChanges(releaseBody: string): string[];
|
|
15
|
+
/**
|
|
16
|
+
* Check if a change matches any keywords from baseline
|
|
17
|
+
*/
|
|
18
|
+
export declare function matchKeywords(change: string, keywords: string[]): string[];
|
|
19
|
+
/**
|
|
20
|
+
* Check which detection patterns match a change
|
|
21
|
+
*/
|
|
22
|
+
export declare function matchPatterns(change: string, patterns?: DetectionPatterns): string[];
|
|
23
|
+
/**
|
|
24
|
+
* Determine the category of a change based on matched patterns
|
|
25
|
+
*/
|
|
26
|
+
export declare function categorizeChange(matchedPatterns: string[]): FindingCategory;
|
|
27
|
+
/**
|
|
28
|
+
* Determine impact level based on category and matched keywords
|
|
29
|
+
*/
|
|
30
|
+
export declare function determineImpact(category: FindingCategory, matchedKeywords: string[]): ImpactLevel;
|
|
31
|
+
/**
|
|
32
|
+
* Get affected sequant files from dependency map
|
|
33
|
+
*/
|
|
34
|
+
export declare function getImpactFiles(matchedKeywords: string[], dependencyMap: Record<string, string[]>): string[];
|
|
35
|
+
/**
|
|
36
|
+
* Generate a title for a finding
|
|
37
|
+
*/
|
|
38
|
+
export declare function generateTitle(category: FindingCategory, change: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Analyze a single change against the baseline
|
|
41
|
+
*/
|
|
42
|
+
export declare function analyzeChange(change: string, baseline: Baseline): Finding;
|
|
43
|
+
/**
|
|
44
|
+
* Analyze all changes from a release
|
|
45
|
+
*/
|
|
46
|
+
export declare function analyzeRelease(releaseBody: string, baseline: Baseline): Finding[];
|
|
47
|
+
/**
|
|
48
|
+
* Filter findings to only actionable ones (not no-action)
|
|
49
|
+
*/
|
|
50
|
+
export declare function getActionableFindings(findings: Finding[]): Finding[];
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relevance detection for upstream release changes
|
|
3
|
+
* Matches changes against sequant's baseline to identify relevant items
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Default detection patterns for categorizing changes
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_PATTERNS = {
|
|
9
|
+
newTool: /\b(added?|new|introduc(e|ing|ed))\b.*\btool\b/i,
|
|
10
|
+
deprecation: /\b(deprecat(e|ed|ing|ion)|remov(e|ed|ing)|no longer support)/i,
|
|
11
|
+
breaking: /\b(breaking|incompatible|must update|require(s|d) migration)/i,
|
|
12
|
+
hook: /\b(hook|PreToolUse|PostToolUse|pre-tool|post-tool)\b/i,
|
|
13
|
+
permission: /\b(permission|allow(ed)?|deny|denied|ask|consent|approve|reject)\b/i,
|
|
14
|
+
mcp: /\b(MCP|model context protocol|mcp server)\b/i,
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Extract individual change items from release body
|
|
18
|
+
* Handles various markdown formats
|
|
19
|
+
*/
|
|
20
|
+
export function extractChanges(releaseBody) {
|
|
21
|
+
const changes = [];
|
|
22
|
+
const lines = releaseBody.split("\n");
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
// Skip empty lines and headers
|
|
26
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
// Match bullet points (-, *, +)
|
|
30
|
+
const bulletMatch = trimmed.match(/^[-*+]\s+(.+)$/);
|
|
31
|
+
if (bulletMatch) {
|
|
32
|
+
changes.push(bulletMatch[1].trim());
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// Match numbered items
|
|
36
|
+
const numberedMatch = trimmed.match(/^\d+\.\s+(.+)$/);
|
|
37
|
+
if (numberedMatch) {
|
|
38
|
+
changes.push(numberedMatch[1].trim());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return changes;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Check if a change matches any keywords from baseline
|
|
45
|
+
*/
|
|
46
|
+
export function matchKeywords(change, keywords) {
|
|
47
|
+
const matched = [];
|
|
48
|
+
for (const keyword of keywords) {
|
|
49
|
+
// Create word boundary regex for keyword
|
|
50
|
+
const pattern = new RegExp(`\\b${escapeRegex(keyword)}\\b`, "i");
|
|
51
|
+
if (pattern.test(change)) {
|
|
52
|
+
matched.push(keyword);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return matched;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Check which detection patterns match a change
|
|
59
|
+
*/
|
|
60
|
+
export function matchPatterns(change, patterns = DEFAULT_PATTERNS) {
|
|
61
|
+
const matched = [];
|
|
62
|
+
for (const [name, pattern] of Object.entries(patterns)) {
|
|
63
|
+
if (pattern.test(change)) {
|
|
64
|
+
matched.push(name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return matched;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Determine the category of a change based on matched patterns
|
|
71
|
+
*/
|
|
72
|
+
export function categorizeChange(matchedPatterns) {
|
|
73
|
+
// Priority order: breaking > deprecation > new-tool > hook > opportunity > no-action
|
|
74
|
+
if (matchedPatterns.includes("breaking")) {
|
|
75
|
+
return "breaking";
|
|
76
|
+
}
|
|
77
|
+
if (matchedPatterns.includes("deprecation")) {
|
|
78
|
+
return "deprecation";
|
|
79
|
+
}
|
|
80
|
+
if (matchedPatterns.includes("newTool")) {
|
|
81
|
+
return "new-tool";
|
|
82
|
+
}
|
|
83
|
+
if (matchedPatterns.includes("hook")) {
|
|
84
|
+
return "hook-change";
|
|
85
|
+
}
|
|
86
|
+
// If any keywords matched but no specific pattern, it's an opportunity
|
|
87
|
+
if (matchedPatterns.length > 0) {
|
|
88
|
+
return "opportunity";
|
|
89
|
+
}
|
|
90
|
+
return "no-action";
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Determine impact level based on category and matched keywords
|
|
94
|
+
*/
|
|
95
|
+
export function determineImpact(category, matchedKeywords) {
|
|
96
|
+
// Breaking changes are always high impact
|
|
97
|
+
if (category === "breaking") {
|
|
98
|
+
return "high";
|
|
99
|
+
}
|
|
100
|
+
// Deprecations are medium to high depending on what's affected
|
|
101
|
+
if (category === "deprecation") {
|
|
102
|
+
const criticalKeywords = [
|
|
103
|
+
"hook",
|
|
104
|
+
"PreToolUse",
|
|
105
|
+
"PostToolUse",
|
|
106
|
+
"permission",
|
|
107
|
+
];
|
|
108
|
+
if (matchedKeywords.some((k) => criticalKeywords.includes(k))) {
|
|
109
|
+
return "high";
|
|
110
|
+
}
|
|
111
|
+
return "medium";
|
|
112
|
+
}
|
|
113
|
+
// Hook changes can be significant
|
|
114
|
+
if (category === "hook-change") {
|
|
115
|
+
return "medium";
|
|
116
|
+
}
|
|
117
|
+
// New tools and opportunities are lower priority
|
|
118
|
+
if (category === "new-tool" || category === "opportunity") {
|
|
119
|
+
return "low";
|
|
120
|
+
}
|
|
121
|
+
return "none";
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Get affected sequant files from dependency map
|
|
125
|
+
*/
|
|
126
|
+
export function getImpactFiles(matchedKeywords, dependencyMap) {
|
|
127
|
+
const files = new Set();
|
|
128
|
+
for (const keyword of matchedKeywords) {
|
|
129
|
+
const mappedFiles = dependencyMap[keyword];
|
|
130
|
+
if (mappedFiles) {
|
|
131
|
+
for (const file of mappedFiles) {
|
|
132
|
+
files.add(file);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return Array.from(files);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Generate a title for a finding
|
|
140
|
+
*/
|
|
141
|
+
export function generateTitle(category, change) {
|
|
142
|
+
// Truncate long changes
|
|
143
|
+
const maxLength = 80;
|
|
144
|
+
const title = change.length > maxLength ? change.slice(0, maxLength) + "..." : change;
|
|
145
|
+
// Add prefix based on category
|
|
146
|
+
switch (category) {
|
|
147
|
+
case "breaking":
|
|
148
|
+
return `BREAKING: ${title}`;
|
|
149
|
+
case "deprecation":
|
|
150
|
+
return `Deprecated: ${title}`;
|
|
151
|
+
case "new-tool":
|
|
152
|
+
return `New tool: ${title}`;
|
|
153
|
+
case "hook-change":
|
|
154
|
+
return `Hook change: ${title}`;
|
|
155
|
+
case "opportunity":
|
|
156
|
+
return title;
|
|
157
|
+
default:
|
|
158
|
+
return title;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Analyze a single change against the baseline
|
|
163
|
+
*/
|
|
164
|
+
export function analyzeChange(change, baseline) {
|
|
165
|
+
// Match keywords and patterns
|
|
166
|
+
const matchedKeywords = matchKeywords(change, baseline.keywords);
|
|
167
|
+
const matchedPatterns = matchPatterns(change);
|
|
168
|
+
// Combine for categorization (keywords count as pattern matches for opportunity detection)
|
|
169
|
+
const allMatches = [
|
|
170
|
+
...matchedPatterns,
|
|
171
|
+
...(matchedKeywords.length > 0 ? ["keywords"] : []),
|
|
172
|
+
];
|
|
173
|
+
// Categorize
|
|
174
|
+
const category = categorizeChange(allMatches);
|
|
175
|
+
// Determine impact
|
|
176
|
+
const impact = determineImpact(category, matchedKeywords);
|
|
177
|
+
// Get affected files
|
|
178
|
+
const sequantFiles = getImpactFiles(matchedKeywords, baseline.dependencyMap);
|
|
179
|
+
// Generate title
|
|
180
|
+
const title = generateTitle(category, change);
|
|
181
|
+
return {
|
|
182
|
+
category,
|
|
183
|
+
title,
|
|
184
|
+
description: change,
|
|
185
|
+
impact,
|
|
186
|
+
matchedKeywords,
|
|
187
|
+
matchedPatterns,
|
|
188
|
+
sequantFiles,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Analyze all changes from a release
|
|
193
|
+
*/
|
|
194
|
+
export function analyzeRelease(releaseBody, baseline) {
|
|
195
|
+
const changes = extractChanges(releaseBody);
|
|
196
|
+
return changes.map((change) => analyzeChange(change, baseline));
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Filter findings to only actionable ones (not no-action)
|
|
200
|
+
*/
|
|
201
|
+
export function getActionableFindings(findings) {
|
|
202
|
+
return findings.filter((f) => f.category !== "no-action");
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Escape special regex characters in a string
|
|
206
|
+
*/
|
|
207
|
+
function escapeRegex(str) {
|
|
208
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
209
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report generation for upstream assessments
|
|
3
|
+
* Creates markdown reports for GitHub issues and local files
|
|
4
|
+
*/
|
|
5
|
+
import type { AssessmentSummary, Finding, UpstreamAssessment, BatchedAssessment } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Calculate summary counts from findings
|
|
8
|
+
*/
|
|
9
|
+
export declare function calculateSummary(findings: Finding[]): AssessmentSummary;
|
|
10
|
+
/**
|
|
11
|
+
* Generate the assessment issue body
|
|
12
|
+
*/
|
|
13
|
+
export declare function generateAssessmentReport(assessment: UpstreamAssessment): string;
|
|
14
|
+
/**
|
|
15
|
+
* Generate an individual issue body for an actionable finding
|
|
16
|
+
*/
|
|
17
|
+
export declare function generateFindingIssue(finding: Finding, version: string, assessmentIssueNumber?: number): {
|
|
18
|
+
title: string;
|
|
19
|
+
body: string;
|
|
20
|
+
labels: string[];
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Generate a batched summary issue for multiple versions
|
|
24
|
+
*/
|
|
25
|
+
export declare function generateBatchedSummaryReport(batched: BatchedAssessment): string;
|
|
26
|
+
/**
|
|
27
|
+
* Generate local report markdown file
|
|
28
|
+
*/
|
|
29
|
+
export declare function generateLocalReport(assessment: UpstreamAssessment): string;
|