@nt-ai-lab/opencode-skillz 0.3.14 → 0.4.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/AGENTS.md +31 -0
- package/agents/default.md +4 -0
- package/agents/tdd.md +1 -0
- package/commands/plan.md +135 -45
- package/commands/resolve-pr-feedback.md +138 -0
- package/commands/review-pr.md +13 -20
- package/dist/commands/dont-stop/hooks.js +69 -35
- package/dist/git-workflow-gates.d.ts +21 -0
- package/dist/git-workflow-gates.js +103 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -4
- package/dist/plugin-registry/agents.js +36 -25
- package/dist/plugin-registry/commands.js +13 -10
- package/dist/plugin-registry/index.js +32 -1
- package/dist/plugin-registry/markdown.js +26 -11
- 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/infra/lint/review.d.ts +19 -0
- package/dist/tools/infra/lint/review.js +53 -0
- 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/source-control/changed-files.d.ts +20 -0
- package/dist/tools/infra/source-control/changed-files.js +78 -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/infra/vitest-coverage/review.d.ts +50 -0
- package/dist/tools/infra/vitest-coverage/review.js +298 -0
- 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 -13
- package/dist/tools/lint.js +56 -16
- 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 +10 -3
- package/scripts/check-tools-folder-boundary.mjs +78 -0
- package/scripts/install-git-hooks.mjs +54 -0
- package/scripts/lint-ts.mjs +32 -7
- package/scripts/living-architecture-eslint.config.mjs +4 -2
- package/scripts/no-generic-names-eslint-rule.mjs +2 -24
- package/scripts/no-generic-names-eslint-rule.mjs.d.ts +28 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { childProcessCommandRunner, resolvePullRequestChangedFiles, } from "../source-control/changed-files.js";
|
|
6
|
+
import { createVitestCoverageCommandSpec, } from "./command.js";
|
|
7
|
+
export const VITEST_COVERAGE_TOOL_NAME = "nt_skillz_vitest_coverage";
|
|
8
|
+
class CoverageUsageError extends Error {
|
|
9
|
+
}
|
|
10
|
+
class CoverageSummaryReadError extends Error {
|
|
11
|
+
}
|
|
12
|
+
function normalizeOptionalText(value) {
|
|
13
|
+
if (typeof value !== "string") {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const trimmedValue = value.trim();
|
|
17
|
+
if (!trimmedValue) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
return trimmedValue;
|
|
21
|
+
}
|
|
22
|
+
function normalizeMode(value) {
|
|
23
|
+
const normalizedMode = normalizeOptionalText(value);
|
|
24
|
+
if (!normalizedMode) {
|
|
25
|
+
return "files";
|
|
26
|
+
}
|
|
27
|
+
if (normalizedMode === "pr-review") {
|
|
28
|
+
return "pr-review";
|
|
29
|
+
}
|
|
30
|
+
if (normalizedMode === "files") {
|
|
31
|
+
return "files";
|
|
32
|
+
}
|
|
33
|
+
throw new CoverageUsageError(`Expected coverage mode to be 'files' or 'pr-review'. Got ${normalizedMode}.`);
|
|
34
|
+
}
|
|
35
|
+
function isTypeScriptPath(filePath) {
|
|
36
|
+
return filePath.endsWith(".ts") || filePath.endsWith(".tsx");
|
|
37
|
+
}
|
|
38
|
+
function isExcludedTypeScriptPath(filePath) {
|
|
39
|
+
return (filePath.endsWith(".spec.ts")
|
|
40
|
+
|| filePath.endsWith(".spec.tsx")
|
|
41
|
+
|| filePath.endsWith(".test.ts")
|
|
42
|
+
|| filePath.endsWith(".test.tsx")
|
|
43
|
+
|| filePath.endsWith(".d.ts")
|
|
44
|
+
|| filePath.endsWith(".config.ts")
|
|
45
|
+
|| filePath.endsWith(".config.tsx")
|
|
46
|
+
|| filePath.includes("/fixtures/")
|
|
47
|
+
|| filePath.includes("/__fixtures__/"));
|
|
48
|
+
}
|
|
49
|
+
function isCoverableSourcePath(repositoryRoot, filePath) {
|
|
50
|
+
if (!isTypeScriptPath(filePath)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (isExcludedTypeScriptPath(filePath)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const absoluteFilePath = path.resolve(repositoryRoot, filePath);
|
|
57
|
+
return fs.existsSync(absoluteFilePath) && fs.statSync(absoluteFilePath).isFile();
|
|
58
|
+
}
|
|
59
|
+
function normalizeFileList(filePaths) {
|
|
60
|
+
if (!filePaths) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
return [...new Set(filePaths.map((filePath) => filePath.trim()).filter(Boolean))];
|
|
64
|
+
}
|
|
65
|
+
function resolveCoverageTargets(request, repositoryRoot, commandRunner) {
|
|
66
|
+
const mode = normalizeMode(request.mode);
|
|
67
|
+
if (mode === "pr-review") {
|
|
68
|
+
return resolvePullRequestChangedFiles({
|
|
69
|
+
repositoryRoot,
|
|
70
|
+
pullRequest: request.pullRequest,
|
|
71
|
+
base: request.base,
|
|
72
|
+
head: request.head,
|
|
73
|
+
}, commandRunner).filter((filePath) => isCoverableSourcePath(repositoryRoot, filePath));
|
|
74
|
+
}
|
|
75
|
+
return normalizeFileList(request.files).filter((filePath) => isCoverableSourcePath(repositoryRoot, filePath));
|
|
76
|
+
}
|
|
77
|
+
function findPackageRootFromDirectory(repositoryRoot, directoryPath) {
|
|
78
|
+
if (fs.existsSync(path.join(directoryPath, "package.json"))) {
|
|
79
|
+
return directoryPath;
|
|
80
|
+
}
|
|
81
|
+
if (directoryPath === repositoryRoot) {
|
|
82
|
+
throw new CoverageUsageError(`Expected package.json ancestor for ${directoryPath}.`);
|
|
83
|
+
}
|
|
84
|
+
const parentDirectoryPath = path.dirname(directoryPath);
|
|
85
|
+
if (parentDirectoryPath === directoryPath) {
|
|
86
|
+
throw new CoverageUsageError(`Expected package.json ancestor for ${directoryPath}.`);
|
|
87
|
+
}
|
|
88
|
+
return findPackageRootFromDirectory(repositoryRoot, parentDirectoryPath);
|
|
89
|
+
}
|
|
90
|
+
function runCoverageCommand(commandSpec, environment) {
|
|
91
|
+
const commandResult = environment.commandRunner.run(commandSpec.executable, commandSpec.commandArguments, commandSpec.workingDirectory);
|
|
92
|
+
const output = [commandResult.stdout, commandResult.stderr].filter(Boolean).join("\n").trim();
|
|
93
|
+
return {
|
|
94
|
+
status: commandResult.status,
|
|
95
|
+
output,
|
|
96
|
+
errorMessage: commandResult.errorMessage,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function isRecord(value) {
|
|
100
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
101
|
+
}
|
|
102
|
+
function readNumber(record, key) {
|
|
103
|
+
const value = record[key];
|
|
104
|
+
if (typeof value === "number") {
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
throw new CoverageSummaryReadError(`Expected numeric coverage field '${key}'.`);
|
|
108
|
+
}
|
|
109
|
+
function readMetric(record, key) {
|
|
110
|
+
const value = record[key];
|
|
111
|
+
if (!isRecord(value)) {
|
|
112
|
+
throw new CoverageSummaryReadError(`Expected coverage metric '${key}'.`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
total: readNumber(value, "total"),
|
|
116
|
+
covered: readNumber(value, "covered"),
|
|
117
|
+
skipped: readNumber(value, "skipped"),
|
|
118
|
+
pct: readNumber(value, "pct"),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function readFileCoverageSummary(value) {
|
|
122
|
+
if (!isRecord(value)) {
|
|
123
|
+
throw new CoverageSummaryReadError("Expected file coverage summary object.");
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
lines: readMetric(value, "lines"),
|
|
127
|
+
statements: readMetric(value, "statements"),
|
|
128
|
+
functions: readMetric(value, "functions"),
|
|
129
|
+
branches: readMetric(value, "branches"),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function normalizeCoverageKey(packageRoot, coverageKey) {
|
|
133
|
+
if (path.isAbsolute(coverageKey)) {
|
|
134
|
+
return path.resolve(coverageKey);
|
|
135
|
+
}
|
|
136
|
+
return path.resolve(packageRoot, coverageKey);
|
|
137
|
+
}
|
|
138
|
+
function readCoverageSummary(packageRoot, reportsDirectory, packageRelativeFilePath) {
|
|
139
|
+
const summaryPath = path.join(reportsDirectory, "coverage-summary.json");
|
|
140
|
+
if (!fs.existsSync(summaryPath)) {
|
|
141
|
+
throw new CoverageSummaryReadError(`Expected coverage summary at ${summaryPath}.`);
|
|
142
|
+
}
|
|
143
|
+
const parsedSummary = JSON.parse(fs.readFileSync(summaryPath, "utf8"));
|
|
144
|
+
if (!isRecord(parsedSummary)) {
|
|
145
|
+
throw new CoverageSummaryReadError("Expected coverage summary JSON object.");
|
|
146
|
+
}
|
|
147
|
+
const expectedFilePath = path.resolve(packageRoot, packageRelativeFilePath);
|
|
148
|
+
const matchingCoverageEntry = Object.entries(parsedSummary).find(([coverageKey]) => normalizeCoverageKey(packageRoot, coverageKey) === expectedFilePath);
|
|
149
|
+
if (!matchingCoverageEntry) {
|
|
150
|
+
throw new CoverageSummaryReadError(`Expected coverage summary row for ${packageRelativeFilePath}.`);
|
|
151
|
+
}
|
|
152
|
+
return readFileCoverageSummary(matchingCoverageEntry[1]);
|
|
153
|
+
}
|
|
154
|
+
function hasCompleteCoverage(summary) {
|
|
155
|
+
return summary.statements.pct === 100
|
|
156
|
+
&& summary.branches.pct === 100
|
|
157
|
+
&& summary.functions.pct === 100
|
|
158
|
+
&& summary.lines.pct === 100;
|
|
159
|
+
}
|
|
160
|
+
function formatCoverageErrorMessage(error) {
|
|
161
|
+
if (error instanceof Error) {
|
|
162
|
+
return error.message;
|
|
163
|
+
}
|
|
164
|
+
return `Expected coverage error message. Got ${String(error)}.`;
|
|
165
|
+
}
|
|
166
|
+
function executeCoverageWithReports(repositoryRoot, filePath, packageRoot, packageRelativeFilePath, environment) {
|
|
167
|
+
const reportsDirectory = environment.temporaryDirectoryCreator(path.join(os.tmpdir(), "nt-skillz-coverage-"));
|
|
168
|
+
try {
|
|
169
|
+
const commandSpec = createVitestCoverageCommandSpec({
|
|
170
|
+
repositoryRoot,
|
|
171
|
+
filePath,
|
|
172
|
+
packageRoot,
|
|
173
|
+
packageRelativeFilePath,
|
|
174
|
+
reportsDirectory,
|
|
175
|
+
});
|
|
176
|
+
const commandResult = runCoverageCommand(commandSpec, environment);
|
|
177
|
+
if (commandResult.errorMessage) {
|
|
178
|
+
return {
|
|
179
|
+
status: "errored",
|
|
180
|
+
filePath,
|
|
181
|
+
message: commandResult.errorMessage,
|
|
182
|
+
commandOutput: commandResult.output,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const summary = readCoverageSummary(commandSpec.coverageSummaryRoot, reportsDirectory, commandSpec.coverageSummaryFilePath);
|
|
186
|
+
if (commandResult.status === 0 && hasCompleteCoverage(summary)) {
|
|
187
|
+
return {
|
|
188
|
+
status: "passed",
|
|
189
|
+
filePath,
|
|
190
|
+
summary,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
status: "failed",
|
|
195
|
+
filePath,
|
|
196
|
+
summary,
|
|
197
|
+
commandOutput: commandResult.output,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
const message = formatCoverageErrorMessage(error);
|
|
202
|
+
return {
|
|
203
|
+
status: "errored",
|
|
204
|
+
filePath,
|
|
205
|
+
message,
|
|
206
|
+
commandOutput: "",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
environment.temporaryDirectoryRemover(reportsDirectory);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function executeFileCoverage(repositoryRoot, filePath, environment) {
|
|
214
|
+
try {
|
|
215
|
+
const absoluteFilePath = path.resolve(repositoryRoot, filePath);
|
|
216
|
+
const packageRoot = findPackageRootFromDirectory(repositoryRoot, path.dirname(absoluteFilePath));
|
|
217
|
+
const packageRelativeFilePath = path.relative(packageRoot, absoluteFilePath);
|
|
218
|
+
return executeCoverageWithReports(repositoryRoot, filePath, packageRoot, packageRelativeFilePath, environment);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
const message = formatCoverageErrorMessage(error);
|
|
222
|
+
return {
|
|
223
|
+
status: "errored",
|
|
224
|
+
filePath,
|
|
225
|
+
message,
|
|
226
|
+
commandOutput: "",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function formatPercent(metric) {
|
|
231
|
+
return `${metric.pct}%`;
|
|
232
|
+
}
|
|
233
|
+
function formatCoverageRow(result) {
|
|
234
|
+
if (result.status === "errored") {
|
|
235
|
+
return `| \`${result.filePath}\` | error | error | error | error | ERROR |`;
|
|
236
|
+
}
|
|
237
|
+
const status = result.status === "passed" ? "PASS" : "FAIL";
|
|
238
|
+
return `| \`${result.filePath}\` | ${formatPercent(result.summary.statements)} | ${formatPercent(result.summary.branches)} | ${formatPercent(result.summary.functions)} | ${formatPercent(result.summary.lines)} | ${status} |`;
|
|
239
|
+
}
|
|
240
|
+
function formatCoverageDetails(result) {
|
|
241
|
+
if (result.status === "passed") {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
const detailContent = result.status === "failed" ? result.commandOutput : [result.message, result.commandOutput].filter(Boolean).join("\n");
|
|
245
|
+
if (!detailContent) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
return [
|
|
249
|
+
`<details><summary>Coverage output for \`${result.filePath}\`</summary>`,
|
|
250
|
+
"",
|
|
251
|
+
"```text",
|
|
252
|
+
detailContent,
|
|
253
|
+
"```",
|
|
254
|
+
"",
|
|
255
|
+
"</details>",
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
function formatCoverageMarkdown(results) {
|
|
259
|
+
if (results.length === 0) {
|
|
260
|
+
return [
|
|
261
|
+
"<!-- nt-skillz-coverage:start -->",
|
|
262
|
+
"## Coverage",
|
|
263
|
+
"",
|
|
264
|
+
"No changed TypeScript source files.",
|
|
265
|
+
"<!-- nt-skillz-coverage:end -->",
|
|
266
|
+
].join("\n");
|
|
267
|
+
}
|
|
268
|
+
const tableRows = results.map(formatCoverageRow);
|
|
269
|
+
const detailRows = results.flatMap(formatCoverageDetails);
|
|
270
|
+
const lines = [
|
|
271
|
+
"<!-- nt-skillz-coverage:start -->",
|
|
272
|
+
"## Coverage",
|
|
273
|
+
"",
|
|
274
|
+
"| File | Statements | Branches | Functions | Lines | Status |",
|
|
275
|
+
"| --- | ---: | ---: | ---: | ---: | --- |",
|
|
276
|
+
...tableRows,
|
|
277
|
+
];
|
|
278
|
+
if (detailRows.length > 0) {
|
|
279
|
+
return [...lines, "", ...detailRows, "<!-- nt-skillz-coverage:end -->"].join("\n");
|
|
280
|
+
}
|
|
281
|
+
return [...lines, "<!-- nt-skillz-coverage:end -->"].join("\n");
|
|
282
|
+
}
|
|
283
|
+
export async function runVitestCoverageReview(request, environment = {
|
|
284
|
+
commandRunner: childProcessCommandRunner,
|
|
285
|
+
temporaryDirectoryCreator: fs.mkdtempSync,
|
|
286
|
+
temporaryDirectoryRemover: (directoryPath) => fs.rmSync(directoryPath, {
|
|
287
|
+
recursive: true,
|
|
288
|
+
force: true,
|
|
289
|
+
}),
|
|
290
|
+
}) {
|
|
291
|
+
const repositoryRoot = path.resolve(request.repositoryRoot ?? process.cwd());
|
|
292
|
+
const coverageTargets = resolveCoverageTargets(request, repositoryRoot, environment.commandRunner);
|
|
293
|
+
const results = coverageTargets.map((filePath) => executeFileCoverage(repositoryRoot, filePath, environment));
|
|
294
|
+
return {
|
|
295
|
+
markdown: formatCoverageMarkdown(results),
|
|
296
|
+
results,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export function createRepository(sourceFilePath) {
|
|
5
|
+
const repositoryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nt-skillz-test-repo-"));
|
|
6
|
+
const sourceAbsolutePath = path.join(repositoryRoot, sourceFilePath);
|
|
7
|
+
const vitestBinaryPath = path.join(repositoryRoot, "node_modules", ".bin", "vitest");
|
|
8
|
+
fs.mkdirSync(path.dirname(sourceAbsolutePath), { recursive: true });
|
|
9
|
+
fs.mkdirSync(path.dirname(vitestBinaryPath), { recursive: true });
|
|
10
|
+
fs.writeFileSync(path.join(repositoryRoot, "package.json"), "{}");
|
|
11
|
+
fs.writeFileSync(sourceAbsolutePath, "export const answer = 42\n");
|
|
12
|
+
fs.writeFileSync(vitestBinaryPath, "");
|
|
13
|
+
return repositoryRoot;
|
|
14
|
+
}
|
|
15
|
+
export function createRepositoryWithoutVitest(sourceFilePath) {
|
|
16
|
+
const repositoryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nt-skillz-test-repo-"));
|
|
17
|
+
const sourceAbsolutePath = path.join(repositoryRoot, sourceFilePath);
|
|
18
|
+
fs.mkdirSync(path.dirname(sourceAbsolutePath), { recursive: true });
|
|
19
|
+
fs.writeFileSync(path.join(repositoryRoot, "package.json"), "{}");
|
|
20
|
+
fs.writeFileSync(sourceAbsolutePath, "export const answer = 42\n");
|
|
21
|
+
return repositoryRoot;
|
|
22
|
+
}
|
|
23
|
+
export function createRepositoryWithoutPackage(sourceFilePath) {
|
|
24
|
+
const repositoryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nt-skillz-test-repo-"));
|
|
25
|
+
const sourceAbsolutePath = path.join(repositoryRoot, sourceFilePath);
|
|
26
|
+
fs.mkdirSync(path.dirname(sourceAbsolutePath), { recursive: true });
|
|
27
|
+
fs.writeFileSync(sourceAbsolutePath, "export const answer = 42\n");
|
|
28
|
+
return repositoryRoot;
|
|
29
|
+
}
|
|
30
|
+
function createCoverageMetric(percent) {
|
|
31
|
+
return {
|
|
32
|
+
total: 1,
|
|
33
|
+
covered: percent === 100 ? 1 : 0,
|
|
34
|
+
skipped: 0,
|
|
35
|
+
pct: percent,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function writeCoverageSummary(reportsDirectory, filePath, percent) {
|
|
39
|
+
fs.mkdirSync(reportsDirectory, { recursive: true });
|
|
40
|
+
fs.writeFileSync(path.join(reportsDirectory, "coverage-summary.json"), JSON.stringify({
|
|
41
|
+
total: {
|
|
42
|
+
lines: createCoverageMetric(percent),
|
|
43
|
+
statements: createCoverageMetric(percent),
|
|
44
|
+
functions: createCoverageMetric(percent),
|
|
45
|
+
branches: createCoverageMetric(percent),
|
|
46
|
+
},
|
|
47
|
+
[filePath]: {
|
|
48
|
+
lines: createCoverageMetric(percent),
|
|
49
|
+
statements: createCoverageMetric(percent),
|
|
50
|
+
functions: createCoverageMetric(percent),
|
|
51
|
+
branches: createCoverageMetric(percent),
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
export function createCoverageCommandRunner(percent, capturedRuns, commandOutput = "coverage output") {
|
|
56
|
+
return {
|
|
57
|
+
run(executable, commandArguments, workingDirectory) {
|
|
58
|
+
capturedRuns.push({
|
|
59
|
+
executable,
|
|
60
|
+
commandArguments,
|
|
61
|
+
workingDirectory,
|
|
62
|
+
});
|
|
63
|
+
const reportsDirectoryArgument = commandArguments.find((commandArgument) => commandArgument.startsWith("--coverage.reportsDirectory="));
|
|
64
|
+
if (reportsDirectoryArgument === undefined) {
|
|
65
|
+
return {
|
|
66
|
+
status: 1,
|
|
67
|
+
stdout: "",
|
|
68
|
+
stderr: "missing reports directory",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const reportsDirectory = reportsDirectoryArgument.replace("--coverage.reportsDirectory=", "");
|
|
72
|
+
const coverageIncludeArgument = commandArguments.find((commandArgument) => commandArgument.startsWith("--coverage.include="));
|
|
73
|
+
if (coverageIncludeArgument === undefined) {
|
|
74
|
+
return {
|
|
75
|
+
status: 1,
|
|
76
|
+
stdout: "",
|
|
77
|
+
stderr: "missing coverage include",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const coveredFilePath = coverageIncludeArgument.replace("--coverage.include=", "");
|
|
81
|
+
writeCoverageSummary(reportsDirectory, path.join(workingDirectory, coveredFilePath), percent);
|
|
82
|
+
return {
|
|
83
|
+
status: percent === 100 ? 0 : 1,
|
|
84
|
+
stdout: commandOutput,
|
|
85
|
+
stderr: "",
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function createPullRequestCoverageCommandRunner(changedFiles, percent) {
|
|
91
|
+
return {
|
|
92
|
+
run(executable, commandArguments, workingDirectory) {
|
|
93
|
+
if (executable === "gh") {
|
|
94
|
+
return {
|
|
95
|
+
status: 0,
|
|
96
|
+
stdout: changedFiles.join("\n"),
|
|
97
|
+
stderr: "",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return createCoverageCommandRunner(percent, []).run(executable, commandArguments, workingDirectory);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export function createCoverageSummaryCommandRunner(summaryJson) {
|
|
105
|
+
return {
|
|
106
|
+
run(_executable, commandArguments) {
|
|
107
|
+
const reportsDirectoryArgument = commandArguments.find((commandArgument) => commandArgument.startsWith("--coverage.reportsDirectory="));
|
|
108
|
+
if (reportsDirectoryArgument !== undefined) {
|
|
109
|
+
const reportsDirectory = reportsDirectoryArgument.replace("--coverage.reportsDirectory=", "");
|
|
110
|
+
fs.mkdirSync(reportsDirectory, { recursive: true });
|
|
111
|
+
fs.writeFileSync(path.join(reportsDirectory, "coverage-summary.json"), summaryJson);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
status: 0,
|
|
115
|
+
stdout: "",
|
|
116
|
+
stderr: "",
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export function createCoverageErrorCommandRunner() {
|
|
122
|
+
return {
|
|
123
|
+
run() {
|
|
124
|
+
return {
|
|
125
|
+
status: null,
|
|
126
|
+
stdout: "coverage stdout",
|
|
127
|
+
stderr: "coverage stderr",
|
|
128
|
+
errorMessage: "spawn failed",
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export function installFailingVitestBinary(repositoryRoot) {
|
|
134
|
+
const vitestBinaryPath = path.join(repositoryRoot, "node_modules", ".bin", "vitest");
|
|
135
|
+
const scriptContent = [
|
|
136
|
+
"#!/usr/bin/env node",
|
|
137
|
+
"const fs = require('node:fs')",
|
|
138
|
+
"const path = require('node:path')",
|
|
139
|
+
"const filePath = path.join(process.cwd(), process.argv[3])",
|
|
140
|
+
"const reportsArgument = process.argv.find((argument) => argument.startsWith('--coverage.reportsDirectory='))",
|
|
141
|
+
"const reportsDirectory = reportsArgument.replace('--coverage.reportsDirectory=', '')",
|
|
142
|
+
"const metric = { total: 1, covered: 0, skipped: 0, pct: 50 }",
|
|
143
|
+
"fs.mkdirSync(reportsDirectory, { recursive: true })",
|
|
144
|
+
"fs.writeFileSync(path.join(reportsDirectory, 'coverage-summary.json'), JSON.stringify({ total: { lines: metric, statements: metric, functions: metric, branches: metric }, [filePath]: { lines: metric, statements: metric, functions: metric, branches: metric } }))",
|
|
145
|
+
"process.stdout.write('tool coverage output')",
|
|
146
|
+
"process.exit(1)",
|
|
147
|
+
].join("\n");
|
|
148
|
+
fs.rmSync(vitestBinaryPath, { force: true });
|
|
149
|
+
fs.writeFileSync(vitestBinaryPath, scriptContent, { mode: 0o755 });
|
|
150
|
+
}
|
|
151
|
+
export function removeDirectory(directoryPath) {
|
|
152
|
+
fs.rmSync(directoryPath, {
|
|
153
|
+
recursive: true,
|
|
154
|
+
force: true,
|
|
155
|
+
});
|
|
156
|
+
}
|
package/dist/tools/lint.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ToolDefinition } from "@opencode-ai/plugin";
|
|
1
2
|
interface PortableLintRequest {
|
|
2
3
|
repositoryRoot?: string;
|
|
3
4
|
files?: string[];
|
|
@@ -9,19 +10,8 @@ interface PortableLintOutcome {
|
|
|
9
10
|
output: string;
|
|
10
11
|
}
|
|
11
12
|
export declare const LINT_TOOL_NAME = "nt_skillz_lint";
|
|
13
|
+
export declare function prependLintFailureGuidance(formattedOutput: string, errorCount: number, lintFailureGuidance: string): string;
|
|
12
14
|
export declare function runPortableLint(request: PortableLintRequest): Promise<PortableLintOutcome>;
|
|
13
15
|
export declare function runPortableLintFromCommandLine(commandLineArguments: string[]): Promise<number>;
|
|
14
|
-
export declare const lintTool:
|
|
15
|
-
description: string;
|
|
16
|
-
args: {
|
|
17
|
-
files: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
18
|
-
base: import("zod").ZodOptional<import("zod").ZodString>;
|
|
19
|
-
head: import("zod").ZodOptional<import("zod").ZodString>;
|
|
20
|
-
};
|
|
21
|
-
execute(args: {
|
|
22
|
-
files?: string[] | undefined;
|
|
23
|
-
base?: string | undefined;
|
|
24
|
-
head?: string | undefined;
|
|
25
|
-
}, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
26
|
-
};
|
|
16
|
+
export declare const lintTool: ToolDefinition;
|
|
27
17
|
export {};
|
package/dist/tools/lint.js
CHANGED
|
@@ -5,7 +5,10 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { ESLint } from "eslint";
|
|
8
|
-
import { tool } from "@opencode-ai/plugin";
|
|
8
|
+
import { tool, } from "@opencode-ai/plugin";
|
|
9
|
+
import { createLintFailureGuidance } from "./infra/lint/guidance.js";
|
|
10
|
+
import { childProcessCommandRunner } from "./infra/source-control/changed-files.js";
|
|
11
|
+
import { runPrReviewLint } from "./infra/lint/review.js";
|
|
9
12
|
class UsageError extends Error {
|
|
10
13
|
constructor(message) {
|
|
11
14
|
super(message);
|
|
@@ -31,6 +34,14 @@ const toolDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
|
31
34
|
const toolRepositoryRoot = path.resolve(toolDirectory, "..", "..");
|
|
32
35
|
const eslintConfigPath = path.join(toolRepositoryRoot, "scripts", "living-architecture-eslint.config.mjs");
|
|
33
36
|
const gitBinaryPath = "/usr/bin/git";
|
|
37
|
+
const helpText = [
|
|
38
|
+
"Usage: ./scripts/lint-ts.sh [--repo PATH] [--base REF] [--head REF] [file ...]",
|
|
39
|
+
"",
|
|
40
|
+
"Examples:",
|
|
41
|
+
" ./scripts/lint-ts.sh --repo ../living-architecture --base origin/main",
|
|
42
|
+
" ./scripts/lint-ts.sh --repo ../living-architecture packages/example/src/example.ts",
|
|
43
|
+
" ./scripts/lint-ts.sh src/example.ts",
|
|
44
|
+
].join("\n");
|
|
34
45
|
export const LINT_TOOL_NAME = "nt_skillz_lint";
|
|
35
46
|
function normalizeOptionalText(value) {
|
|
36
47
|
if (typeof value !== "string") {
|
|
@@ -42,6 +53,16 @@ function normalizeOptionalText(value) {
|
|
|
42
53
|
}
|
|
43
54
|
return trimmedValue;
|
|
44
55
|
}
|
|
56
|
+
function normalizeToolMode(value) {
|
|
57
|
+
const normalizedValue = normalizeOptionalText(value);
|
|
58
|
+
if (!normalizedValue) {
|
|
59
|
+
return "files";
|
|
60
|
+
}
|
|
61
|
+
if (normalizedValue === "files" || normalizedValue === "pr-review") {
|
|
62
|
+
return normalizedValue;
|
|
63
|
+
}
|
|
64
|
+
throw new UsageError(`Expected lint mode to be 'files' or 'pr-review'. Got ${normalizedValue}.`);
|
|
65
|
+
}
|
|
45
66
|
function resolveDirectory(directoryPath) {
|
|
46
67
|
const absoluteDirectoryPath = path.resolve(directoryPath);
|
|
47
68
|
if (!fs.existsSync(absoluteDirectoryPath)) {
|
|
@@ -77,8 +98,8 @@ function runGitCommand(repositoryRoot, gitArguments) {
|
|
|
77
98
|
if (gitResult.status === 0) {
|
|
78
99
|
return gitResult.stdout;
|
|
79
100
|
}
|
|
80
|
-
const
|
|
81
|
-
throw new GitCommandError(`Expected git command to succeed. Got ${
|
|
101
|
+
const failureOutput = gitResult.stderr.trim() || gitResult.stdout.trim() || `exit status ${gitResult.status}`;
|
|
102
|
+
throw new GitCommandError(`Expected git command to succeed. Got ${failureOutput}.`);
|
|
82
103
|
}
|
|
83
104
|
function readChangedTypeScriptFiles(repositoryRoot, baseReference, headReference) {
|
|
84
105
|
const diffRange = `${baseReference}...${headReference}`;
|
|
@@ -134,6 +155,15 @@ function createLintTitle(filePaths, baseReference) {
|
|
|
134
155
|
function removeAnsiEscapeSequences(value) {
|
|
135
156
|
return value.replaceAll(ansiEscapeSequencePattern, "");
|
|
136
157
|
}
|
|
158
|
+
export function prependLintFailureGuidance(formattedOutput, errorCount, lintFailureGuidance) {
|
|
159
|
+
if (errorCount === 0) {
|
|
160
|
+
return formattedOutput;
|
|
161
|
+
}
|
|
162
|
+
if (!formattedOutput) {
|
|
163
|
+
return lintFailureGuidance;
|
|
164
|
+
}
|
|
165
|
+
return [lintFailureGuidance, "", formattedOutput].join("\n");
|
|
166
|
+
}
|
|
137
167
|
async function runEslint(repositoryRoot, lintTargets) {
|
|
138
168
|
const previousLintRepositoryRoot = process.env.NT_SKILLZ_LINT_REPO_ROOT;
|
|
139
169
|
process.env.NT_SKILLZ_LINT_REPO_ROOT = repositoryRoot;
|
|
@@ -150,7 +180,7 @@ async function runEslint(repositoryRoot, lintTargets) {
|
|
|
150
180
|
const errorCount = lintResults.reduce((count, lintResult) => count + lintResult.errorCount + lintResult.fatalErrorCount, 0);
|
|
151
181
|
return {
|
|
152
182
|
exitCode: errorCount > 0 ? 1 : 0,
|
|
153
|
-
output: formattedOutput,
|
|
183
|
+
output: prependLintFailureGuidance(formattedOutput, errorCount, createLintFailureGuidance(lintResults)),
|
|
154
184
|
};
|
|
155
185
|
}
|
|
156
186
|
finally {
|
|
@@ -176,17 +206,6 @@ function parsePortableLintCommandLine(commandLineArguments) {
|
|
|
176
206
|
},
|
|
177
207
|
allowPositionals: true,
|
|
178
208
|
});
|
|
179
|
-
if (parsedArguments.values.help) {
|
|
180
|
-
process.stdout.write([
|
|
181
|
-
"Usage: ./scripts/lint-ts.sh [--repo PATH] [--base REF] [--head REF] [file ...]",
|
|
182
|
-
"",
|
|
183
|
-
"Examples:",
|
|
184
|
-
" ./scripts/lint-ts.sh --repo ../living-architecture --base origin/main",
|
|
185
|
-
" ./scripts/lint-ts.sh --repo ../living-architecture packages/example/src/example.ts",
|
|
186
|
-
" ./scripts/lint-ts.sh src/example.ts",
|
|
187
|
-
].join("\n") + "\n");
|
|
188
|
-
process.exit(0);
|
|
189
|
-
}
|
|
190
209
|
return {
|
|
191
210
|
repositoryRoot: resolveDirectory(parsedArguments.values.repo ?? process.cwd()),
|
|
192
211
|
files: normalizeFilePaths(parsedArguments.positionals),
|
|
@@ -211,6 +230,10 @@ export async function runPortableLint(request) {
|
|
|
211
230
|
return runEslint(repositoryRoot, lintTargets);
|
|
212
231
|
}
|
|
213
232
|
export async function runPortableLintFromCommandLine(commandLineArguments) {
|
|
233
|
+
if (commandLineArguments.includes("--help") || commandLineArguments.includes("-h")) {
|
|
234
|
+
process.stdout.write(`${helpText}\n`);
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
214
237
|
const request = parsePortableLintCommandLine(commandLineArguments);
|
|
215
238
|
const outcome = await runPortableLint(request);
|
|
216
239
|
if (outcome.output) {
|
|
@@ -221,11 +244,28 @@ export async function runPortableLintFromCommandLine(commandLineArguments) {
|
|
|
221
244
|
export const lintTool = tool({
|
|
222
245
|
description: "Run bundled TypeScript lint rules against current project files.",
|
|
223
246
|
args: {
|
|
247
|
+
mode: tool.schema.string().optional().describe("Use 'pr-review' to lint changed pull request TypeScript files."),
|
|
248
|
+
pullRequest: tool.schema.string().optional().describe("Pull request number or URL for pr-review mode."),
|
|
224
249
|
files: tool.schema.array(tool.schema.string()).optional().describe("Relative .ts or .tsx file paths to lint."),
|
|
225
250
|
base: tool.schema.string().optional().describe("Base git reference for PR-style changed-file linting."),
|
|
226
251
|
head: tool.schema.string().optional().describe("Optional head git reference used with base."),
|
|
227
252
|
},
|
|
228
253
|
async execute(request, context) {
|
|
254
|
+
const mode = normalizeToolMode(request.mode);
|
|
255
|
+
if (mode === "pr-review") {
|
|
256
|
+
context.metadata({ title: "Lint pull request TypeScript changes" });
|
|
257
|
+
return {
|
|
258
|
+
output: await runPrReviewLint({
|
|
259
|
+
repositoryRoot: context.worktree,
|
|
260
|
+
pullRequest: normalizeOptionalText(request.pullRequest),
|
|
261
|
+
base: normalizeOptionalText(request.base),
|
|
262
|
+
head: normalizeOptionalText(request.head),
|
|
263
|
+
}, {
|
|
264
|
+
commandRunner: childProcessCommandRunner,
|
|
265
|
+
lintRunner: runPortableLint,
|
|
266
|
+
}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
229
269
|
const filePaths = normalizeFilePaths(request.files);
|
|
230
270
|
const baseReference = normalizeOptionalText(request.base);
|
|
231
271
|
const headReference = normalizeOptionalText(request.head);
|
|
@@ -238,7 +278,7 @@ export const lintTool = tool({
|
|
|
238
278
|
head: headReference,
|
|
239
279
|
});
|
|
240
280
|
if (outcome.exitCode !== 0) {
|
|
241
|
-
throw new LintExecutionError(outcome.output
|
|
281
|
+
throw new LintExecutionError(outcome.output);
|
|
242
282
|
}
|
|
243
283
|
return {
|
|
244
284
|
output: outcome.output || "Lint passed.",
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ToolDefinition } from "@opencode-ai/plugin";
|
|
2
|
+
import { PULL_REQUEST_FEEDBACK_TOOL_NAME, type PullRequestFeedbackDependencies } from "./infra/pull-request/feedback.js";
|
|
3
|
+
export declare function createPullRequestFeedbackTool(dependencies?: PullRequestFeedbackDependencies): ToolDefinition;
|
|
4
|
+
export declare const pullRequestFeedbackTool: ToolDefinition;
|
|
5
|
+
export { PULL_REQUEST_FEEDBACK_TOOL_NAME };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { tool, } from "@opencode-ai/plugin";
|
|
2
|
+
import { PULL_REQUEST_FEEDBACK_TOOL_NAME, readPullRequestFeedback, } from "./infra/pull-request/feedback.js";
|
|
3
|
+
export function createPullRequestFeedbackTool(dependencies = {}) {
|
|
4
|
+
return tool({
|
|
5
|
+
description: "Fetch unresolved GitHub pull request review feedback with diff hunks and local code excerpts.",
|
|
6
|
+
args: {
|
|
7
|
+
pullRequestNumber: tool.schema.string().describe("Pull request number from GitHub."),
|
|
8
|
+
pullRequestUrl: tool.schema.string().describe("Full GitHub pull request URL."),
|
|
9
|
+
},
|
|
10
|
+
async execute(request, context) {
|
|
11
|
+
context.metadata({ title: "Fetch unresolved PR feedback" });
|
|
12
|
+
return {
|
|
13
|
+
output: readPullRequestFeedback({
|
|
14
|
+
repositoryRoot: context.worktree,
|
|
15
|
+
pullRequestNumber: request.pullRequestNumber,
|
|
16
|
+
pullRequestUrl: request.pullRequestUrl,
|
|
17
|
+
}, dependencies),
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export const pullRequestFeedbackTool = createPullRequestFeedbackTool();
|
|
23
|
+
export { PULL_REQUEST_FEEDBACK_TOOL_NAME };
|