@nt-ai-lab/opencode-skillz 0.3.15 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -0
- package/agents/default.md +4 -0
- package/agents/facilitator.md +63 -0
- package/agents/tdd.md +1 -0
- package/commands/plan.md +135 -45
- package/commands/resolve-pr-feedback.md +138 -0
- package/dist/commands/dont-stop/hooks.js +4 -10
- package/dist/git-workflow-gates.d.ts +21 -0
- package/dist/git-workflow-gates.js +103 -0
- package/dist/plugin-registry/agents.js +0 -4
- package/dist/plugin-registry/commands.js +0 -2
- package/dist/plugin-registry/index.js +29 -2
- package/dist/tools/create-pr-tool.d.ts +4 -0
- package/dist/tools/create-pr-tool.js +22 -0
- package/dist/tools/infra/lint/guidance.d.ts +8 -0
- package/dist/tools/infra/lint/guidance.js +98 -0
- package/dist/tools/{lint-review.d.ts → infra/lint/review.d.ts} +1 -1
- package/dist/tools/{lint-review.js → infra/lint/review.js} +1 -1
- package/dist/tools/infra/pull-request/create-draft-pull-request.d.ts +11 -0
- package/dist/tools/infra/pull-request/create-draft-pull-request.js +115 -0
- package/dist/tools/infra/pull-request/feedback.d.ts +14 -0
- package/dist/tools/infra/pull-request/feedback.js +280 -0
- package/dist/tools/infra/vitest-coverage/command.d.ts +16 -0
- package/dist/tools/infra/vitest-coverage/command.js +85 -0
- package/dist/tools/{vitest-coverage.d.ts → infra/vitest-coverage/review.d.ts} +1 -18
- package/dist/tools/{vitest-coverage.js → infra/vitest-coverage/review.js} +15 -52
- package/dist/tools/infra/vitest-coverage/test-support.d.ts +15 -0
- package/dist/tools/infra/vitest-coverage/test-support.js +156 -0
- package/dist/tools/lint.d.ts +3 -17
- package/dist/tools/lint.js +29 -18
- package/dist/tools/pull-request-feedback-tool.d.ts +5 -0
- package/dist/tools/pull-request-feedback-tool.js +23 -0
- package/dist/tools/vitest-coverage-tool.d.ts +4 -0
- package/dist/tools/vitest-coverage-tool.js +31 -0
- package/dist/types.d.ts +8 -0
- package/package.json +4 -3
- package/scripts/check-tools-folder-boundary.mjs +78 -0
- package/scripts/install-git-hooks.mjs +42 -4
- package/scripts/lint-ts.mjs +32 -7
- package/scripts/living-architecture-eslint.config.mjs +2 -2
- package/scripts/no-generic-names-eslint-rule.mjs +2 -24
- package/scripts/no-generic-names-eslint-rule.mjs.d.ts +28 -0
- /package/dist/tools/{pull-request-files.d.ts → infra/source-control/changed-files.d.ts} +0 -0
- /package/dist/tools/{pull-request-files.js → infra/source-control/changed-files.js} +0 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { childProcessCommandRunner } from "../source-control/changed-files.js";
|
|
5
|
+
export const PULL_REQUEST_FEEDBACK_TOOL_NAME = "nt_skillz_pr_feedback";
|
|
6
|
+
const pullRequestViewSchema = z.object({
|
|
7
|
+
baseRefName: z.string(),
|
|
8
|
+
headRefName: z.string(),
|
|
9
|
+
id: z.string().min(1),
|
|
10
|
+
number: z.number().int(),
|
|
11
|
+
url: z.string().min(1),
|
|
12
|
+
});
|
|
13
|
+
const reviewThreadSchema = z.object({
|
|
14
|
+
id: z.string().min(1),
|
|
15
|
+
isOutdated: z.boolean(),
|
|
16
|
+
isResolved: z.boolean(),
|
|
17
|
+
line: z.number().int().nullable(),
|
|
18
|
+
path: z.string().min(1),
|
|
19
|
+
comments: z.object({
|
|
20
|
+
pageInfo: z.object({
|
|
21
|
+
hasNextPage: z.boolean(),
|
|
22
|
+
}),
|
|
23
|
+
nodes: z.array(z.object({
|
|
24
|
+
author: z.object({
|
|
25
|
+
login: z.string().min(1),
|
|
26
|
+
}),
|
|
27
|
+
body: z.string(),
|
|
28
|
+
createdAt: z.string().min(1),
|
|
29
|
+
diffHunk: z.string(),
|
|
30
|
+
url: z.string().min(1),
|
|
31
|
+
})),
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
const reviewThreadsResponseSchema = z.object({
|
|
35
|
+
node: z.object({
|
|
36
|
+
reviewThreads: z.object({
|
|
37
|
+
pageInfo: z.object({
|
|
38
|
+
endCursor: z.string().nullable(),
|
|
39
|
+
hasNextPage: z.boolean(),
|
|
40
|
+
}),
|
|
41
|
+
nodes: z.array(reviewThreadSchema),
|
|
42
|
+
}),
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
export class PullRequestFeedbackError extends Error {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function normalizeRequiredText(value, fieldName) {
|
|
51
|
+
const normalizedValue = value.trim();
|
|
52
|
+
if (normalizedValue) {
|
|
53
|
+
return normalizedValue;
|
|
54
|
+
}
|
|
55
|
+
throw new PullRequestFeedbackError(`Expected ${fieldName} to be non-empty. Got blank text.`);
|
|
56
|
+
}
|
|
57
|
+
function ensureSuccessfulCommand(commandResult, commandDescription) {
|
|
58
|
+
if (commandResult.errorMessage) {
|
|
59
|
+
throw new PullRequestFeedbackError(`Expected ${commandDescription} to run. Got ${commandResult.errorMessage}.`);
|
|
60
|
+
}
|
|
61
|
+
if (commandResult.status === 0) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const failureOutput = commandResult.stderr.trim() || commandResult.stdout.trim();
|
|
65
|
+
if (failureOutput) {
|
|
66
|
+
throw new PullRequestFeedbackError(`Expected ${commandDescription} to succeed. Got ${failureOutput}.`);
|
|
67
|
+
}
|
|
68
|
+
throw new PullRequestFeedbackError(`Expected ${commandDescription} to succeed. Got exit status ${commandResult.status}.`);
|
|
69
|
+
}
|
|
70
|
+
function parseJsonWithSchema(jsonText, schema, commandDescription) {
|
|
71
|
+
const parsedJson = parseJson(jsonText, commandDescription);
|
|
72
|
+
const parsedResult = schema.safeParse(parsedJson);
|
|
73
|
+
if (parsedResult.success) {
|
|
74
|
+
return parsedResult.data;
|
|
75
|
+
}
|
|
76
|
+
throw new PullRequestFeedbackError(`Expected ${commandDescription} to return valid JSON. Got ${parsedResult.error.message}.`);
|
|
77
|
+
}
|
|
78
|
+
function parseJson(jsonText, commandDescription) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(jsonText);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new PullRequestFeedbackError(`Expected ${commandDescription} to return JSON. Got malformed JSON.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function readPullRequestView(repositoryRoot, pullRequestNumber, commandRunner) {
|
|
87
|
+
const commandResult = commandRunner.run("gh", [
|
|
88
|
+
"pr",
|
|
89
|
+
"view",
|
|
90
|
+
pullRequestNumber,
|
|
91
|
+
"--json",
|
|
92
|
+
"id,number,url,headRefName,baseRefName",
|
|
93
|
+
"--jq",
|
|
94
|
+
".",
|
|
95
|
+
], repositoryRoot);
|
|
96
|
+
ensureSuccessfulCommand(commandResult, "GitHub pull request lookup");
|
|
97
|
+
return parseJsonWithSchema(commandResult.stdout, pullRequestViewSchema, "GitHub pull request lookup");
|
|
98
|
+
}
|
|
99
|
+
function createReviewThreadsQuery() {
|
|
100
|
+
return [
|
|
101
|
+
"query($pullRequestId: ID!, $threadCursor: String) {",
|
|
102
|
+
" node(id: $pullRequestId) {",
|
|
103
|
+
" ... on PullRequest {",
|
|
104
|
+
" reviewThreads(first: 100, after: $threadCursor) {",
|
|
105
|
+
" pageInfo { endCursor hasNextPage }",
|
|
106
|
+
" nodes {",
|
|
107
|
+
" id",
|
|
108
|
+
" isOutdated",
|
|
109
|
+
" isResolved",
|
|
110
|
+
" line",
|
|
111
|
+
" path",
|
|
112
|
+
" comments(first: 100) {",
|
|
113
|
+
" pageInfo { hasNextPage }",
|
|
114
|
+
" nodes {",
|
|
115
|
+
" author { login }",
|
|
116
|
+
" body",
|
|
117
|
+
" createdAt",
|
|
118
|
+
" diffHunk",
|
|
119
|
+
" url",
|
|
120
|
+
" }",
|
|
121
|
+
" }",
|
|
122
|
+
" }",
|
|
123
|
+
" }",
|
|
124
|
+
" }",
|
|
125
|
+
" }",
|
|
126
|
+
"}",
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
129
|
+
function readReviewThreadPage(repositoryRoot, pullRequestId, threadCursor, commandRunner) {
|
|
130
|
+
const commandArguments = [
|
|
131
|
+
"api",
|
|
132
|
+
"graphql",
|
|
133
|
+
"-f",
|
|
134
|
+
`pullRequestId=${pullRequestId}`,
|
|
135
|
+
"-f",
|
|
136
|
+
`query=${createReviewThreadsQuery()}`,
|
|
137
|
+
];
|
|
138
|
+
if (threadCursor) {
|
|
139
|
+
commandArguments.splice(4, 0, "-f", `threadCursor=${threadCursor}`);
|
|
140
|
+
}
|
|
141
|
+
const commandResult = commandRunner.run("gh", commandArguments, repositoryRoot);
|
|
142
|
+
ensureSuccessfulCommand(commandResult, "GitHub pull request review thread lookup");
|
|
143
|
+
return parseJsonWithSchema(commandResult.stdout, reviewThreadsResponseSchema, "GitHub pull request review thread lookup");
|
|
144
|
+
}
|
|
145
|
+
function readAllReviewThreads(repositoryRoot, pullRequestId, commandRunner, threadCursor = null, previousReviewThreads = []) {
|
|
146
|
+
const response = readReviewThreadPage(repositoryRoot, pullRequestId, threadCursor, commandRunner);
|
|
147
|
+
const reviewThreadPage = response.node.reviewThreads;
|
|
148
|
+
const reviewThreads = [...previousReviewThreads, ...reviewThreadPage.nodes];
|
|
149
|
+
if (reviewThreadPage.pageInfo.hasNextPage) {
|
|
150
|
+
if (reviewThreadPage.pageInfo.endCursor === null) {
|
|
151
|
+
throw new PullRequestFeedbackError("Expected GitHub review thread page cursor. Got null.");
|
|
152
|
+
}
|
|
153
|
+
return readAllReviewThreads(repositoryRoot, pullRequestId, commandRunner, reviewThreadPage.pageInfo.endCursor, reviewThreads);
|
|
154
|
+
}
|
|
155
|
+
return reviewThreads;
|
|
156
|
+
}
|
|
157
|
+
function ensurePullRequestMatchesRequest(pullRequestView, pullRequestNumber, pullRequestUrl) {
|
|
158
|
+
if (String(pullRequestView.number) !== pullRequestNumber) {
|
|
159
|
+
throw new PullRequestFeedbackError(`Expected GitHub PR number ${pullRequestNumber}. Got ${pullRequestView.number}.`);
|
|
160
|
+
}
|
|
161
|
+
if (pullRequestView.url !== pullRequestUrl) {
|
|
162
|
+
throw new PullRequestFeedbackError(`Expected GitHub PR URL ${pullRequestUrl}. Got ${pullRequestView.url}.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function ensureCompleteThreadComments(reviewThreads) {
|
|
166
|
+
const incompleteThread = reviewThreads.find((reviewThread) => reviewThread.comments.pageInfo.hasNextPage);
|
|
167
|
+
if (!incompleteThread) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
throw new PullRequestFeedbackError(`Expected review thread ${incompleteThread.id} to have at most 100 comments. Got more than 100 comments.`);
|
|
171
|
+
}
|
|
172
|
+
function languageForPath(filePath) {
|
|
173
|
+
const extension = path.extname(filePath);
|
|
174
|
+
if (extension === ".ts" || extension === ".tsx")
|
|
175
|
+
return "ts";
|
|
176
|
+
if (extension === ".js" || extension === ".jsx")
|
|
177
|
+
return "js";
|
|
178
|
+
if (extension === ".json")
|
|
179
|
+
return "json";
|
|
180
|
+
if (extension === ".md")
|
|
181
|
+
return "md";
|
|
182
|
+
if (extension === ".yml" || extension === ".yaml")
|
|
183
|
+
return "yaml";
|
|
184
|
+
return "text";
|
|
185
|
+
}
|
|
186
|
+
function quoteMarkdown(value) {
|
|
187
|
+
return value.split("\n").map((line) => `> ${line}`).join("\n");
|
|
188
|
+
}
|
|
189
|
+
function formatDiffHunk(diffHunk) {
|
|
190
|
+
if (diffHunk.trim()) {
|
|
191
|
+
return ["```diff", diffHunk, "```"].join("\n");
|
|
192
|
+
}
|
|
193
|
+
return "No diff hunk returned by GitHub.";
|
|
194
|
+
}
|
|
195
|
+
function resolveReviewThreadFilePath(repositoryRoot, filePath) {
|
|
196
|
+
const absoluteRepositoryRoot = path.resolve(repositoryRoot);
|
|
197
|
+
const absoluteFilePath = path.resolve(absoluteRepositoryRoot, filePath);
|
|
198
|
+
const relativeFilePath = path.relative(absoluteRepositoryRoot, absoluteFilePath);
|
|
199
|
+
if (!relativeFilePath.startsWith("..") && !path.isAbsolute(relativeFilePath)) {
|
|
200
|
+
return absoluteFilePath;
|
|
201
|
+
}
|
|
202
|
+
throw new PullRequestFeedbackError(`Expected review thread path to stay inside repository root. Got ${filePath}.`);
|
|
203
|
+
}
|
|
204
|
+
function readCodeExcerpt(repositoryRoot, filePath, lineNumber) {
|
|
205
|
+
const absoluteFilePath = resolveReviewThreadFilePath(repositoryRoot, filePath);
|
|
206
|
+
if (!fs.existsSync(absoluteFilePath)) {
|
|
207
|
+
return `Current local file not found: ${filePath}`;
|
|
208
|
+
}
|
|
209
|
+
if (lineNumber === null) {
|
|
210
|
+
return "No current line number returned by GitHub.";
|
|
211
|
+
}
|
|
212
|
+
const fileLines = fs.readFileSync(absoluteFilePath, "utf8").split("\n");
|
|
213
|
+
const startLine = Math.max(1, lineNumber - 5);
|
|
214
|
+
const endLine = Math.min(fileLines.length, lineNumber + 5);
|
|
215
|
+
const excerptLines = fileLines.slice(startLine - 1, endLine);
|
|
216
|
+
const numberedLines = excerptLines.map((line, index) => `${startLine + index}: ${line}`);
|
|
217
|
+
return [`\`\`\`${languageForPath(filePath)}`, ...numberedLines, "```"].join("\n");
|
|
218
|
+
}
|
|
219
|
+
function formatComments(reviewThread) {
|
|
220
|
+
return reviewThread.comments.nodes.map((comment, index) => [
|
|
221
|
+
`#### Comment ${index + 1}`,
|
|
222
|
+
`- Comment URL: ${comment.url}`,
|
|
223
|
+
`- Author: ${comment.author.login}`,
|
|
224
|
+
`- Created: ${comment.createdAt}`,
|
|
225
|
+
"- Full comment:",
|
|
226
|
+
quoteMarkdown(comment.body),
|
|
227
|
+
].join("\n")).join("\n\n");
|
|
228
|
+
}
|
|
229
|
+
function readLatestDiffHunk(reviewThread) {
|
|
230
|
+
const latestComment = reviewThread.comments.nodes.at(-1);
|
|
231
|
+
if (latestComment) {
|
|
232
|
+
return latestComment.diffHunk;
|
|
233
|
+
}
|
|
234
|
+
throw new PullRequestFeedbackError(`Expected review thread ${reviewThread.id} to contain at least one comment. Got 0.`);
|
|
235
|
+
}
|
|
236
|
+
function formatReviewLine(lineNumber) {
|
|
237
|
+
if (lineNumber === null) {
|
|
238
|
+
return "outdated";
|
|
239
|
+
}
|
|
240
|
+
return String(lineNumber);
|
|
241
|
+
}
|
|
242
|
+
function formatReviewThread(repositoryRoot, reviewThread) {
|
|
243
|
+
return [
|
|
244
|
+
`## Thread ${reviewThread.id}`,
|
|
245
|
+
"",
|
|
246
|
+
"### Reviewer feedback",
|
|
247
|
+
formatComments(reviewThread),
|
|
248
|
+
"",
|
|
249
|
+
"### Review context",
|
|
250
|
+
`- File: ${reviewThread.path}`,
|
|
251
|
+
`- Line: ${formatReviewLine(reviewThread.line)}`,
|
|
252
|
+
`- Outdated: ${reviewThread.isOutdated ? "yes" : "no"}`,
|
|
253
|
+
"- Diff hunk:",
|
|
254
|
+
formatDiffHunk(readLatestDiffHunk(reviewThread)),
|
|
255
|
+
"",
|
|
256
|
+
"### Current local code",
|
|
257
|
+
readCodeExcerpt(repositoryRoot, reviewThread.path, reviewThread.line),
|
|
258
|
+
].join("\n");
|
|
259
|
+
}
|
|
260
|
+
export function readPullRequestFeedback(request, dependencies = {}) {
|
|
261
|
+
const pullRequestNumber = normalizeRequiredText(request.pullRequestNumber, "pull request number");
|
|
262
|
+
const pullRequestUrl = normalizeRequiredText(request.pullRequestUrl, "pull request URL");
|
|
263
|
+
const commandRunner = dependencies.commandRunner ?? childProcessCommandRunner;
|
|
264
|
+
const pullRequestView = readPullRequestView(request.repositoryRoot, pullRequestNumber, commandRunner);
|
|
265
|
+
ensurePullRequestMatchesRequest(pullRequestView, pullRequestNumber, pullRequestUrl);
|
|
266
|
+
const reviewThreads = readAllReviewThreads(request.repositoryRoot, pullRequestView.id, commandRunner);
|
|
267
|
+
ensureCompleteThreadComments(reviewThreads);
|
|
268
|
+
const unresolvedReviewThreads = reviewThreads.filter((reviewThread) => !reviewThread.isResolved);
|
|
269
|
+
return [
|
|
270
|
+
"# Pull Request Feedback",
|
|
271
|
+
"",
|
|
272
|
+
`- PR number: ${pullRequestView.number}`,
|
|
273
|
+
`- PR URL: ${pullRequestView.url}`,
|
|
274
|
+
`- Head branch: ${pullRequestView.headRefName}`,
|
|
275
|
+
`- Base branch: ${pullRequestView.baseRefName}`,
|
|
276
|
+
`- Unresolved review threads: ${unresolvedReviewThreads.length}`,
|
|
277
|
+
"",
|
|
278
|
+
...unresolvedReviewThreads.map((reviewThread) => formatReviewThread(request.repositoryRoot, reviewThread)),
|
|
279
|
+
].join("\n");
|
|
280
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface CoverageCommandSpec {
|
|
2
|
+
executable: string;
|
|
3
|
+
commandArguments: string[];
|
|
4
|
+
workingDirectory: string;
|
|
5
|
+
coverageSummaryRoot: string;
|
|
6
|
+
coverageSummaryFilePath: string;
|
|
7
|
+
}
|
|
8
|
+
interface CoverageCommandRequest {
|
|
9
|
+
repositoryRoot: string;
|
|
10
|
+
filePath: string;
|
|
11
|
+
packageRoot: string;
|
|
12
|
+
packageRelativeFilePath: string;
|
|
13
|
+
reportsDirectory: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function createVitestCoverageCommandSpec(request: CoverageCommandRequest): CoverageCommandSpec;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
class VitestCoverageCommandResolutionError extends Error {
|
|
4
|
+
}
|
|
5
|
+
function formatMissingVitestCommandMessage(checkedBinaryPaths, checkedLockFilePaths) {
|
|
6
|
+
return [
|
|
7
|
+
"Expected Vitest binary in one of:",
|
|
8
|
+
...checkedBinaryPaths.map((binaryPath) => `- ${binaryPath}`),
|
|
9
|
+
"or workspace package manager lockfile in one of:",
|
|
10
|
+
...checkedLockFilePaths.map((lockFilePath) => `- ${lockFilePath}`),
|
|
11
|
+
].join("\n");
|
|
12
|
+
}
|
|
13
|
+
function collectVitestBinaryPathsFromDirectory(repositoryRoot, currentDirectory, checkedBinaryPaths) {
|
|
14
|
+
const binaryPath = path.join(currentDirectory, "node_modules", ".bin", "vitest");
|
|
15
|
+
const nextCheckedBinaryPaths = [...checkedBinaryPaths, binaryPath];
|
|
16
|
+
if (currentDirectory === repositoryRoot) {
|
|
17
|
+
return nextCheckedBinaryPaths;
|
|
18
|
+
}
|
|
19
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
20
|
+
return collectVitestBinaryPathsFromDirectory(repositoryRoot, parentDirectory, nextCheckedBinaryPaths);
|
|
21
|
+
}
|
|
22
|
+
function resolveVitestBinaryPath(checkedBinaryPaths) {
|
|
23
|
+
return checkedBinaryPaths.find((binaryPath) => fs.existsSync(binaryPath));
|
|
24
|
+
}
|
|
25
|
+
function createVitestArguments(coverageFilePath, reportsDirectory) {
|
|
26
|
+
return [
|
|
27
|
+
"--run",
|
|
28
|
+
"--coverage.enabled",
|
|
29
|
+
`--coverage.include=${coverageFilePath}`,
|
|
30
|
+
"--coverage.reporter=json-summary",
|
|
31
|
+
"--coverage.reporter=text",
|
|
32
|
+
`--coverage.reportsDirectory=${reportsDirectory}`,
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
function createDirectVitestCommandSpec(vitestBinaryPath, request) {
|
|
36
|
+
return {
|
|
37
|
+
executable: vitestBinaryPath,
|
|
38
|
+
commandArguments: createVitestArguments(request.packageRelativeFilePath, request.reportsDirectory),
|
|
39
|
+
workingDirectory: request.packageRoot,
|
|
40
|
+
coverageSummaryRoot: request.packageRoot,
|
|
41
|
+
coverageSummaryFilePath: request.packageRelativeFilePath,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function getCheckedLockFilePaths(repositoryRoot) {
|
|
45
|
+
return [
|
|
46
|
+
path.join(repositoryRoot, "yarn.lock"),
|
|
47
|
+
path.join(repositoryRoot, "pnpm-lock.yaml"),
|
|
48
|
+
path.join(repositoryRoot, "package-lock.json"),
|
|
49
|
+
path.join(repositoryRoot, "npm-shrinkwrap.json"),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
function createWorkspaceCommandSpec(executable, commandArguments, request) {
|
|
53
|
+
return {
|
|
54
|
+
executable,
|
|
55
|
+
commandArguments,
|
|
56
|
+
workingDirectory: request.repositoryRoot,
|
|
57
|
+
coverageSummaryRoot: request.repositoryRoot,
|
|
58
|
+
coverageSummaryFilePath: request.filePath,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function createWorkspaceVitestCommandSpec(request) {
|
|
62
|
+
const vitestArguments = createVitestArguments(request.filePath, request.reportsDirectory);
|
|
63
|
+
if (fs.existsSync(path.join(request.repositoryRoot, "yarn.lock"))) {
|
|
64
|
+
return createWorkspaceCommandSpec("yarn", ["vitest", ...vitestArguments], request);
|
|
65
|
+
}
|
|
66
|
+
if (fs.existsSync(path.join(request.repositoryRoot, "pnpm-lock.yaml"))) {
|
|
67
|
+
return createWorkspaceCommandSpec("pnpm", ["exec", "vitest", ...vitestArguments], request);
|
|
68
|
+
}
|
|
69
|
+
if (fs.existsSync(path.join(request.repositoryRoot, "package-lock.json")) || fs.existsSync(path.join(request.repositoryRoot, "npm-shrinkwrap.json"))) {
|
|
70
|
+
return createWorkspaceCommandSpec("npm", ["exec", "--", "vitest", ...vitestArguments], request);
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
export function createVitestCoverageCommandSpec(request) {
|
|
75
|
+
const checkedBinaryPaths = collectVitestBinaryPathsFromDirectory(path.resolve(request.repositoryRoot), path.resolve(request.packageRoot), []);
|
|
76
|
+
const vitestBinaryPath = resolveVitestBinaryPath(checkedBinaryPaths);
|
|
77
|
+
if (vitestBinaryPath !== undefined) {
|
|
78
|
+
return createDirectVitestCommandSpec(vitestBinaryPath, request);
|
|
79
|
+
}
|
|
80
|
+
const workspaceVitestCommandSpec = createWorkspaceVitestCommandSpec(request);
|
|
81
|
+
if (workspaceVitestCommandSpec !== undefined) {
|
|
82
|
+
return workspaceVitestCommandSpec;
|
|
83
|
+
}
|
|
84
|
+
throw new VitestCoverageCommandResolutionError(formatMissingVitestCommandMessage(checkedBinaryPaths, getCheckedLockFilePaths(request.repositoryRoot)));
|
|
85
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CommandRunner } from "
|
|
1
|
+
import { type CommandRunner } from "../source-control/changed-files.js";
|
|
2
2
|
export declare const VITEST_COVERAGE_TOOL_NAME = "nt_skillz_vitest_coverage";
|
|
3
3
|
interface CoverageRequest {
|
|
4
4
|
repositoryRoot?: string;
|
|
@@ -47,21 +47,4 @@ export declare function runVitestCoverageReview(request: CoverageRequest, enviro
|
|
|
47
47
|
markdown: string;
|
|
48
48
|
results: CoverageFileResult[];
|
|
49
49
|
}>;
|
|
50
|
-
export declare const vitestCoverageTool: {
|
|
51
|
-
description: string;
|
|
52
|
-
args: {
|
|
53
|
-
mode: import("zod").ZodOptional<import("zod").ZodString>;
|
|
54
|
-
pullRequest: import("zod").ZodOptional<import("zod").ZodString>;
|
|
55
|
-
base: import("zod").ZodOptional<import("zod").ZodString>;
|
|
56
|
-
head: import("zod").ZodOptional<import("zod").ZodString>;
|
|
57
|
-
files: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
58
|
-
};
|
|
59
|
-
execute(args: {
|
|
60
|
-
mode?: string | undefined;
|
|
61
|
-
pullRequest?: string | undefined;
|
|
62
|
-
base?: string | undefined;
|
|
63
|
-
head?: string | undefined;
|
|
64
|
-
files?: string[] | undefined;
|
|
65
|
-
}, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
66
|
-
};
|
|
67
50
|
export {};
|
|
@@ -2,8 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { childProcessCommandRunner, resolvePullRequestChangedFiles, } from "../source-control/changed-files.js";
|
|
6
|
+
import { createVitestCoverageCommandSpec, } from "./command.js";
|
|
7
7
|
export const VITEST_COVERAGE_TOOL_NAME = "nt_skillz_vitest_coverage";
|
|
8
8
|
class CoverageUsageError extends Error {
|
|
9
9
|
}
|
|
@@ -87,24 +87,8 @@ function findPackageRootFromDirectory(repositoryRoot, directoryPath) {
|
|
|
87
87
|
}
|
|
88
88
|
return findPackageRootFromDirectory(repositoryRoot, parentDirectoryPath);
|
|
89
89
|
}
|
|
90
|
-
function
|
|
91
|
-
const
|
|
92
|
-
if (fs.existsSync(binaryPath)) {
|
|
93
|
-
return binaryPath;
|
|
94
|
-
}
|
|
95
|
-
throw new CoverageUsageError(`Expected Vitest binary at ${binaryPath}.`);
|
|
96
|
-
}
|
|
97
|
-
function runCoverageCommand(packageRoot, packageRelativeFilePath, reportsDirectory, environment) {
|
|
98
|
-
const commandResult = environment.commandRunner.run(resolveVitestBinary(packageRoot), [
|
|
99
|
-
"related",
|
|
100
|
-
packageRelativeFilePath,
|
|
101
|
-
"--run",
|
|
102
|
-
"--coverage.enabled",
|
|
103
|
-
`--coverage.include=${packageRelativeFilePath}`,
|
|
104
|
-
"--coverage.reporter=json-summary",
|
|
105
|
-
"--coverage.reporter=text",
|
|
106
|
-
`--coverage.reportsDirectory=${reportsDirectory}`,
|
|
107
|
-
], packageRoot);
|
|
90
|
+
function runCoverageCommand(commandSpec, environment) {
|
|
91
|
+
const commandResult = environment.commandRunner.run(commandSpec.executable, commandSpec.commandArguments, commandSpec.workingDirectory);
|
|
108
92
|
const output = [commandResult.stdout, commandResult.stderr].filter(Boolean).join("\n").trim();
|
|
109
93
|
return {
|
|
110
94
|
status: commandResult.status,
|
|
@@ -179,10 +163,17 @@ function formatCoverageErrorMessage(error) {
|
|
|
179
163
|
}
|
|
180
164
|
return `Expected coverage error message. Got ${String(error)}.`;
|
|
181
165
|
}
|
|
182
|
-
function executeCoverageWithReports(filePath, packageRoot, packageRelativeFilePath, environment) {
|
|
166
|
+
function executeCoverageWithReports(repositoryRoot, filePath, packageRoot, packageRelativeFilePath, environment) {
|
|
183
167
|
const reportsDirectory = environment.temporaryDirectoryCreator(path.join(os.tmpdir(), "nt-skillz-coverage-"));
|
|
184
168
|
try {
|
|
185
|
-
const
|
|
169
|
+
const commandSpec = createVitestCoverageCommandSpec({
|
|
170
|
+
repositoryRoot,
|
|
171
|
+
filePath,
|
|
172
|
+
packageRoot,
|
|
173
|
+
packageRelativeFilePath,
|
|
174
|
+
reportsDirectory,
|
|
175
|
+
});
|
|
176
|
+
const commandResult = runCoverageCommand(commandSpec, environment);
|
|
186
177
|
if (commandResult.errorMessage) {
|
|
187
178
|
return {
|
|
188
179
|
status: "errored",
|
|
@@ -191,7 +182,7 @@ function executeCoverageWithReports(filePath, packageRoot, packageRelativeFilePa
|
|
|
191
182
|
commandOutput: commandResult.output,
|
|
192
183
|
};
|
|
193
184
|
}
|
|
194
|
-
const summary = readCoverageSummary(
|
|
185
|
+
const summary = readCoverageSummary(commandSpec.coverageSummaryRoot, reportsDirectory, commandSpec.coverageSummaryFilePath);
|
|
195
186
|
if (commandResult.status === 0 && hasCompleteCoverage(summary)) {
|
|
196
187
|
return {
|
|
197
188
|
status: "passed",
|
|
@@ -224,7 +215,7 @@ function executeFileCoverage(repositoryRoot, filePath, environment) {
|
|
|
224
215
|
const absoluteFilePath = path.resolve(repositoryRoot, filePath);
|
|
225
216
|
const packageRoot = findPackageRootFromDirectory(repositoryRoot, path.dirname(absoluteFilePath));
|
|
226
217
|
const packageRelativeFilePath = path.relative(packageRoot, absoluteFilePath);
|
|
227
|
-
return executeCoverageWithReports(filePath, packageRoot, packageRelativeFilePath, environment);
|
|
218
|
+
return executeCoverageWithReports(repositoryRoot, filePath, packageRoot, packageRelativeFilePath, environment);
|
|
228
219
|
}
|
|
229
220
|
catch (error) {
|
|
230
221
|
const message = formatCoverageErrorMessage(error);
|
|
@@ -305,31 +296,3 @@ export async function runVitestCoverageReview(request, environment = {
|
|
|
305
296
|
results,
|
|
306
297
|
};
|
|
307
298
|
}
|
|
308
|
-
export const vitestCoverageTool = tool({
|
|
309
|
-
description: "Run Vitest coverage for changed TypeScript source files.",
|
|
310
|
-
args: {
|
|
311
|
-
mode: tool.schema.string().optional().describe("Use 'pr-review' for pull request coverage."),
|
|
312
|
-
pullRequest: tool.schema.string().optional().describe("Pull request number or URL for pr-review mode."),
|
|
313
|
-
base: tool.schema.string().optional().describe("Base git reference for pr-review mode when no pull request is provided."),
|
|
314
|
-
head: tool.schema.string().optional().describe("Head git reference for pr-review mode when base is provided."),
|
|
315
|
-
files: tool.schema.array(tool.schema.string()).optional().describe("Repository-relative files for files mode."),
|
|
316
|
-
},
|
|
317
|
-
async execute(request, context) {
|
|
318
|
-
context.metadata({ title: "Vitest coverage review" });
|
|
319
|
-
const outcome = await runVitestCoverageReview({
|
|
320
|
-
repositoryRoot: context.worktree,
|
|
321
|
-
mode: request.mode,
|
|
322
|
-
pullRequest: request.pullRequest,
|
|
323
|
-
base: request.base,
|
|
324
|
-
head: request.head,
|
|
325
|
-
files: request.files,
|
|
326
|
-
});
|
|
327
|
-
return {
|
|
328
|
-
output: outcome.markdown,
|
|
329
|
-
metadata: {
|
|
330
|
-
fileCount: outcome.results.length,
|
|
331
|
-
failedCount: outcome.results.filter((result) => result.status !== "passed").length,
|
|
332
|
-
},
|
|
333
|
-
};
|
|
334
|
-
},
|
|
335
|
-
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CommandRunner } from "../source-control/changed-files.js";
|
|
2
|
+
export interface CapturedCoverageRun {
|
|
3
|
+
executable: string;
|
|
4
|
+
commandArguments: string[];
|
|
5
|
+
workingDirectory: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function createRepository(sourceFilePath: string): string;
|
|
8
|
+
export declare function createRepositoryWithoutVitest(sourceFilePath: string): string;
|
|
9
|
+
export declare function createRepositoryWithoutPackage(sourceFilePath: string): string;
|
|
10
|
+
export declare function createCoverageCommandRunner(percent: number, capturedRuns: CapturedCoverageRun[], commandOutput?: string): CommandRunner;
|
|
11
|
+
export declare function createPullRequestCoverageCommandRunner(changedFiles: string[], percent: number): CommandRunner;
|
|
12
|
+
export declare function createCoverageSummaryCommandRunner(summaryJson: string): CommandRunner;
|
|
13
|
+
export declare function createCoverageErrorCommandRunner(): CommandRunner;
|
|
14
|
+
export declare function installFailingVitestBinary(repositoryRoot: string): void;
|
|
15
|
+
export declare function removeDirectory(directoryPath: string): void;
|