@nt-ai-lab/opencode-skillz 0.3.14 → 0.3.15
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/commands/review-pr.md +13 -20
- package/dist/commands/dont-stop/hooks.js +67 -27
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -4
- package/dist/plugin-registry/agents.js +38 -23
- package/dist/plugin-registry/commands.js +13 -8
- package/dist/plugin-registry/index.js +5 -1
- package/dist/plugin-registry/markdown.js +26 -11
- package/dist/tools/lint-review.d.ts +19 -0
- package/dist/tools/lint-review.js +53 -0
- package/dist/tools/lint.d.ts +4 -0
- package/dist/tools/lint.js +29 -0
- package/dist/tools/pull-request-files.d.ts +20 -0
- package/dist/tools/pull-request-files.js +78 -0
- package/dist/tools/vitest-coverage.d.ts +67 -0
- package/dist/tools/vitest-coverage.js +335 -0
- package/package.json +8 -2
- package/scripts/install-git-hooks.mjs +16 -0
- package/scripts/living-architecture-eslint.config.mjs +4 -2
package/commands/review-pr.md
CHANGED
|
@@ -49,26 +49,19 @@ Provide the subagent with:
|
|
|
49
49
|
- the changed file list
|
|
50
50
|
- the changed diff context
|
|
51
51
|
|
|
52
|
-
##
|
|
53
|
-
|
|
54
|
-
Add
|
|
55
|
-
|
|
56
|
-
1.
|
|
57
|
-
2.
|
|
58
|
-
3.
|
|
59
|
-
4.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
5. Run `gh pr view <pr> --json body --jq '.body'` and use the returned text as the current PR body.
|
|
66
|
-
6. If the current PR body already contains both marker lines, replace only the text from `<!-- nt-skillz-coverage:start -->` through `<!-- nt-skillz-coverage:end -->` with the new coverage block.
|
|
67
|
-
7. If the current PR body does not contain both marker lines, append the new coverage block to the end of the PR body separated by two newlines.
|
|
68
|
-
8. Write the updated PR body to a temporary file.
|
|
69
|
-
9. Run `gh pr edit <pr> --body-file <temporary-file>`.
|
|
70
|
-
10. Do not summarize, interpret, or paraphrase the coverage output.
|
|
71
|
-
11. Do not modify any other part of the PR body.
|
|
52
|
+
## Tool-Generated PR Description Sections
|
|
53
|
+
|
|
54
|
+
Add generated lint and coverage sections to the PR description:
|
|
55
|
+
|
|
56
|
+
1. Invoke the `nt_skillz_lint` tool with `mode: "pr-review"` and the resolved PR identifier.
|
|
57
|
+
2. Invoke the `nt_skillz_vitest_coverage` tool with `mode: "pr-review"` and the resolved PR identifier.
|
|
58
|
+
3. Use the exact markdown returned by each tool. Do not summarize, interpret, paraphrase, or reformat tool output.
|
|
59
|
+
4. Run `gh pr view <pr> --json body --jq '.body'` and use the returned text as the current PR body.
|
|
60
|
+
5. If the current PR body already contains both marker lines for a generated section, replace only the text from the start marker through the end marker with the new tool output for that section.
|
|
61
|
+
6. If the current PR body does not contain both marker lines for a generated section, append the new tool output to the end of the PR body separated by two newlines.
|
|
62
|
+
7. Write the updated PR body to a temporary file.
|
|
63
|
+
8. Run `gh pr edit <pr> --body-file <temporary-file>`.
|
|
64
|
+
9. Do not modify any other part of the PR body.
|
|
72
65
|
|
|
73
66
|
## Basic PR checks
|
|
74
67
|
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { CLEAR_DONT_STOP_COMMAND_NAME, DONT_STOP_COMMAND_NAME, } from "./register.js";
|
|
2
2
|
import { createDontStopState } from "./state.js";
|
|
3
|
+
const statusStartMarker = "<dont-stop-status>";
|
|
4
|
+
const statusEndMarker = "</dont-stop-status>";
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
3
8
|
function unwrapResponse(result) {
|
|
4
|
-
|
|
9
|
+
if (isRecord(result) && "data" in result) {
|
|
10
|
+
return result.data;
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
5
13
|
}
|
|
6
14
|
function normalizeCriteria(value) {
|
|
7
15
|
return value
|
|
8
|
-
.split(
|
|
16
|
+
.split(/[\n;]/)
|
|
9
17
|
.map((item) => item.replace(/^\s*[-*]\s*/, "").trim())
|
|
10
18
|
.filter(Boolean);
|
|
11
19
|
}
|
|
@@ -41,43 +49,73 @@ function buildSystemInstruction(criteria) {
|
|
|
41
49
|
].join("\n");
|
|
42
50
|
}
|
|
43
51
|
function parseAssistantStatus(text) {
|
|
44
|
-
const
|
|
45
|
-
|
|
52
|
+
const lowerText = text.toLowerCase();
|
|
53
|
+
const startIndex = lowerText.indexOf(statusStartMarker);
|
|
54
|
+
const endIndex = lowerText.indexOf(statusEndMarker);
|
|
55
|
+
if (startIndex < 0 || endIndex < 0 || endIndex <= startIndex)
|
|
46
56
|
return null;
|
|
47
|
-
const body =
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
const body = text.slice(startIndex + statusStartMarker.length, endIndex);
|
|
58
|
+
const fields = Object.fromEntries(body
|
|
59
|
+
.split("\n")
|
|
60
|
+
.map((line) => line.trim())
|
|
61
|
+
.filter((line) => line.includes(":"))
|
|
62
|
+
.map((line) => [line.slice(0, line.indexOf(":")).trim(), line.slice(line.indexOf(":") + 1).trim()]));
|
|
63
|
+
const state = parseAssistantState(fields.state);
|
|
51
64
|
return {
|
|
52
|
-
state
|
|
53
|
-
justification:
|
|
54
|
-
reason:
|
|
65
|
+
state,
|
|
66
|
+
justification: readStatusField(fields, "justification"),
|
|
67
|
+
reason: readStatusField(fields, "reason"),
|
|
55
68
|
};
|
|
56
69
|
}
|
|
70
|
+
function readStatusField(fields, key) {
|
|
71
|
+
const value = fields[key];
|
|
72
|
+
if (typeof value === "string") {
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
function parseAssistantState(value) {
|
|
78
|
+
if (value === "completion-requested" || value === "blocked-requested") {
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
return "continue";
|
|
82
|
+
}
|
|
57
83
|
async function showToast(client, body) {
|
|
58
84
|
try {
|
|
59
85
|
await client.tui.showToast({ body });
|
|
60
86
|
}
|
|
61
|
-
catch {
|
|
87
|
+
catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function isMessagePart(value) {
|
|
92
|
+
return isRecord(value);
|
|
93
|
+
}
|
|
94
|
+
function isSessionMessage(value) {
|
|
95
|
+
if (!isRecord(value))
|
|
96
|
+
return false;
|
|
97
|
+
if (!("parts" in value))
|
|
98
|
+
return true;
|
|
99
|
+
return Array.isArray(value.parts) && value.parts.every(isMessagePart);
|
|
100
|
+
}
|
|
101
|
+
function readAssistantText(message) {
|
|
102
|
+
const parts = message.parts ?? [];
|
|
103
|
+
return parts
|
|
104
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
105
|
+
.map((part) => part.text)
|
|
106
|
+
.join("\n");
|
|
62
107
|
}
|
|
63
108
|
async function getLatestAssistantStatus(client, sessionID) {
|
|
64
109
|
const result = unwrapResponse(await client.session.messages({ path: { id: sessionID } }));
|
|
65
|
-
const messages = Array.isArray(result) ? result : [];
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
continue;
|
|
70
|
-
const text = (entry.parts ?? [])
|
|
71
|
-
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
72
|
-
.map((part) => part.text)
|
|
73
|
-
.join("\n");
|
|
74
|
-
return parseAssistantStatus(text);
|
|
75
|
-
}
|
|
110
|
+
const messages = Array.isArray(result) ? result.filter(isSessionMessage) : [];
|
|
111
|
+
const latestAssistantMessage = [...messages].reverse().find((entry) => entry.info?.role === "assistant");
|
|
112
|
+
if (latestAssistantMessage)
|
|
113
|
+
return parseAssistantStatus(readAssistantText(latestAssistantMessage));
|
|
76
114
|
return null;
|
|
77
115
|
}
|
|
78
116
|
function buildContinuationPrompt(criteria, status) {
|
|
79
117
|
const reportedState = status?.state ?? "missing";
|
|
80
|
-
const reason = status?.reason
|
|
118
|
+
const reason = status?.reason ?? "none";
|
|
81
119
|
return [
|
|
82
120
|
"dont-stop remains active.",
|
|
83
121
|
"",
|
|
@@ -116,16 +154,18 @@ function getDeletedSessionID(event) {
|
|
|
116
154
|
if (event.type !== "session.deleted")
|
|
117
155
|
return undefined;
|
|
118
156
|
const properties = event.properties;
|
|
119
|
-
if (!properties
|
|
157
|
+
if (!isRecord(properties))
|
|
120
158
|
return undefined;
|
|
121
159
|
const info = properties.info;
|
|
122
|
-
|
|
160
|
+
if (!isRecord(info))
|
|
161
|
+
return undefined;
|
|
162
|
+
return typeof info.id === "string" ? info.id : undefined;
|
|
123
163
|
}
|
|
124
164
|
function getIdleSessionID(event) {
|
|
125
165
|
if (event.type !== "session.idle")
|
|
126
166
|
return undefined;
|
|
127
167
|
const properties = event.properties;
|
|
128
|
-
if (!properties
|
|
168
|
+
if (!isRecord(properties))
|
|
129
169
|
return undefined;
|
|
130
170
|
const sessionID = properties.sessionID;
|
|
131
171
|
return typeof sessionID === "string" ? sessionID : undefined;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { PluginInput } from "./types.js";
|
|
2
|
-
export declare const
|
|
3
|
-
export default
|
|
2
|
+
export declare const opencodeSkillzPlugin: (input: PluginInput) => Promise<import("./types.js").PluginHooks>;
|
|
3
|
+
export default opencodeSkillzPlugin;
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { createPluginRegistry } from "./plugin-registry/index.js";
|
|
4
|
-
const
|
|
5
|
-
const pluginRoot = path.resolve(
|
|
6
|
-
export const
|
|
7
|
-
export default
|
|
4
|
+
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const pluginRoot = path.resolve(currentDirectory, "..");
|
|
6
|
+
export const opencodeSkillzPlugin = async (input) => createPluginRegistry(input, pluginRoot);
|
|
7
|
+
export default opencodeSkillzPlugin;
|
|
@@ -8,7 +8,40 @@ function parseCsvList(value) {
|
|
|
8
8
|
.filter(Boolean);
|
|
9
9
|
}
|
|
10
10
|
function materializePreloadedTemplate(template) {
|
|
11
|
-
return template.
|
|
11
|
+
return template.replaceAll("$ARGUMENTS", "all relevant current work in this session");
|
|
12
|
+
}
|
|
13
|
+
function getParentAgentName(rawAgent) {
|
|
14
|
+
return typeof rawAgent.meta.extends === "string" ? rawAgent.meta.extends.trim() : "";
|
|
15
|
+
}
|
|
16
|
+
function appendAgentPromptParts(promptParts, rawAgents, name) {
|
|
17
|
+
const rawAgent = rawAgents[name];
|
|
18
|
+
if (!rawAgent)
|
|
19
|
+
return;
|
|
20
|
+
const parentAgentName = getParentAgentName(rawAgent);
|
|
21
|
+
if (parentAgentName && rawAgents[parentAgentName]?.body) {
|
|
22
|
+
promptParts.push(rawAgents[parentAgentName].body);
|
|
23
|
+
}
|
|
24
|
+
if (rawAgent.body) {
|
|
25
|
+
promptParts.push(rawAgent.body);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function appendPreloadedCommandPromptParts(promptParts, commandNames, commands) {
|
|
29
|
+
for (const commandName of commandNames) {
|
|
30
|
+
const command = commands[commandName];
|
|
31
|
+
if (!command?.template)
|
|
32
|
+
continue;
|
|
33
|
+
promptParts.push(`[Preloaded command /${commandName}]\n${materializePreloadedTemplate(command.template)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function setOptionalAgentProperties(agent, meta) {
|
|
37
|
+
if (typeof meta.description === "string")
|
|
38
|
+
agent.description = meta.description;
|
|
39
|
+
if (typeof meta.mode === "string")
|
|
40
|
+
agent.mode = meta.mode;
|
|
41
|
+
if (typeof meta.model === "string")
|
|
42
|
+
agent.model = meta.model;
|
|
43
|
+
if (typeof meta.color === "string")
|
|
44
|
+
agent.color = meta.color;
|
|
12
45
|
}
|
|
13
46
|
export function registerAgents(agentConfig, pluginRoot, commands) {
|
|
14
47
|
const rawAgents = readMarkdownEntries(pluginRoot, "agents");
|
|
@@ -20,7 +53,7 @@ export function registerAgents(agentConfig, pluginRoot, commands) {
|
|
|
20
53
|
return parseCsvList(rawAgent.meta.preload_commands);
|
|
21
54
|
stack.add(agentName);
|
|
22
55
|
const merged = [];
|
|
23
|
-
const parentAgentName =
|
|
56
|
+
const parentAgentName = getParentAgentName(rawAgent);
|
|
24
57
|
if (parentAgentName && rawAgents[parentAgentName]) {
|
|
25
58
|
merged.push(...collectPreloadedCommands(parentAgentName, stack));
|
|
26
59
|
}
|
|
@@ -32,30 +65,12 @@ export function registerAgents(agentConfig, pluginRoot, commands) {
|
|
|
32
65
|
if (agentConfig[name])
|
|
33
66
|
continue;
|
|
34
67
|
const promptParts = [];
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
promptParts.push(rawAgents[parentAgentName].body);
|
|
38
|
-
}
|
|
39
|
-
if (rawAgent.body) {
|
|
40
|
-
promptParts.push(rawAgent.body);
|
|
41
|
-
}
|
|
42
|
-
for (const commandName of collectPreloadedCommands(name)) {
|
|
43
|
-
const command = commands[commandName];
|
|
44
|
-
if (!command?.template)
|
|
45
|
-
continue;
|
|
46
|
-
promptParts.push(`[Preloaded command /${commandName}]\n${materializePreloadedTemplate(command.template)}`);
|
|
47
|
-
}
|
|
68
|
+
appendAgentPromptParts(promptParts, rawAgents, name);
|
|
69
|
+
appendPreloadedCommandPromptParts(promptParts, collectPreloadedCommands(name), commands);
|
|
48
70
|
const agent = {
|
|
49
71
|
prompt: promptParts.join("\n\n").trim(),
|
|
50
72
|
};
|
|
51
|
-
|
|
52
|
-
agent.description = rawAgent.meta.description;
|
|
53
|
-
if (typeof rawAgent.meta.mode === "string")
|
|
54
|
-
agent.mode = rawAgent.meta.mode;
|
|
55
|
-
if (typeof rawAgent.meta.model === "string")
|
|
56
|
-
agent.model = rawAgent.meta.model;
|
|
57
|
-
if (typeof rawAgent.meta.color === "string")
|
|
58
|
-
agent.color = rawAgent.meta.color;
|
|
73
|
+
setOptionalAgentProperties(agent, rawAgent.meta);
|
|
59
74
|
agentConfig[name] = agent;
|
|
60
75
|
}
|
|
61
76
|
}
|
|
@@ -4,7 +4,15 @@ import { readMarkdownEntries } from "./markdown.js";
|
|
|
4
4
|
function normalizeCommandReference(value) {
|
|
5
5
|
if (typeof value !== "string")
|
|
6
6
|
return "";
|
|
7
|
-
return value.trim().
|
|
7
|
+
return value.trim().replaceAll("_", "-");
|
|
8
|
+
}
|
|
9
|
+
function composeTemplate(rawTemplate, composedTemplate) {
|
|
10
|
+
if (!composedTemplate) {
|
|
11
|
+
return rawTemplate;
|
|
12
|
+
}
|
|
13
|
+
return [rawTemplate, `In addition you must adhere to the following:\n\n${composedTemplate}`]
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.join("\n\n");
|
|
8
16
|
}
|
|
9
17
|
function loadMarkdownCommands(pluginRoot) {
|
|
10
18
|
const rawCommands = readMarkdownEntries(pluginRoot, "commands");
|
|
@@ -16,19 +24,16 @@ function loadMarkdownCommands(pluginRoot) {
|
|
|
16
24
|
if (stack.has(name))
|
|
17
25
|
return rawCommand.body;
|
|
18
26
|
stack.add(name);
|
|
19
|
-
let template = rawCommand.body;
|
|
20
27
|
const composeAfterName = normalizeCommandReference(rawCommand.meta.compose_after);
|
|
21
28
|
const composedCommand = rawCommands[composeAfterName];
|
|
22
29
|
if (composeAfterName && composedCommand) {
|
|
23
30
|
const composedTemplate = buildComposedTemplate(composeAfterName, stack);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.join("\n\n");
|
|
28
|
-
}
|
|
31
|
+
const template = composeTemplate(rawCommand.body, composedTemplate);
|
|
32
|
+
stack.delete(name);
|
|
33
|
+
return template.trim();
|
|
29
34
|
}
|
|
30
35
|
stack.delete(name);
|
|
31
|
-
return
|
|
36
|
+
return rawCommand.body.trim();
|
|
32
37
|
}
|
|
33
38
|
for (const [name, rawCommand] of Object.entries(rawCommands)) {
|
|
34
39
|
const description = typeof rawCommand.meta.description === "string" ? rawCommand.meta.description : `Run /${name}`;
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { createDontStopHooks } from "../commands/dont-stop/index.js";
|
|
2
2
|
import { LINT_TOOL_NAME, lintTool, } from "../tools/lint.js";
|
|
3
|
+
import { VITEST_COVERAGE_TOOL_NAME, vitestCoverageTool, } from "../tools/vitest-coverage.js";
|
|
3
4
|
import { registerAgents } from "./agents.js";
|
|
4
5
|
import { registerCommands } from "./commands.js";
|
|
5
6
|
export function createPluginRegistry(input, pluginRoot) {
|
|
6
7
|
const dontStopHooks = createDontStopHooks(input.client);
|
|
7
8
|
return {
|
|
8
9
|
...dontStopHooks,
|
|
9
|
-
tool: {
|
|
10
|
+
tool: {
|
|
11
|
+
[LINT_TOOL_NAME]: lintTool,
|
|
12
|
+
[VITEST_COVERAGE_TOOL_NAME]: vitestCoverageTool,
|
|
13
|
+
},
|
|
10
14
|
config: async (config) => {
|
|
11
15
|
config.command ??= {};
|
|
12
16
|
config.agent ??= {};
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
const frontmatterPattern = /^---\n([^]*)\n---\n?([^]*)$/;
|
|
4
|
+
const markdownExtension = ".md";
|
|
3
5
|
function extractFrontmatter(content) {
|
|
4
|
-
const match =
|
|
5
|
-
if (!match)
|
|
6
|
-
return {
|
|
6
|
+
const match = frontmatterPattern.exec(content);
|
|
7
|
+
if (!match) {
|
|
8
|
+
return {
|
|
9
|
+
meta: {},
|
|
10
|
+
body: content,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
7
13
|
const meta = {};
|
|
8
14
|
for (const rawLine of match[1].split("\n")) {
|
|
9
15
|
const line = rawLine.trim();
|
|
@@ -13,21 +19,30 @@ function extractFrontmatter(content) {
|
|
|
13
19
|
if (separatorIndex <= 0)
|
|
14
20
|
continue;
|
|
15
21
|
const key = line.slice(0, separatorIndex).trim();
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
value = true;
|
|
19
|
-
if (value === "false")
|
|
20
|
-
value = false;
|
|
22
|
+
const rawValue = stripEnclosingQuotes(line.slice(separatorIndex + 1).trim());
|
|
23
|
+
const value = rawValue === "true" || rawValue === "false" ? rawValue === "true" : rawValue;
|
|
21
24
|
meta[key] = value;
|
|
22
25
|
}
|
|
23
|
-
return {
|
|
26
|
+
return {
|
|
27
|
+
meta,
|
|
28
|
+
body: match[2],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function stripEnclosingQuotes(value) {
|
|
32
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
33
|
+
return value.slice(1, -1);
|
|
34
|
+
}
|
|
35
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
36
|
+
return value.slice(1, -1);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
24
39
|
}
|
|
25
40
|
function readMarkdownFiles(directoryPath) {
|
|
26
41
|
if (!fs.existsSync(directoryPath))
|
|
27
42
|
return [];
|
|
28
43
|
return fs
|
|
29
44
|
.readdirSync(directoryPath)
|
|
30
|
-
.filter((file) => file.endsWith(
|
|
45
|
+
.filter((file) => file.endsWith(markdownExtension))
|
|
31
46
|
.sort((left, right) => left.localeCompare(right));
|
|
32
47
|
}
|
|
33
48
|
export function readMarkdownEntries(pluginRoot, directoryName) {
|
|
@@ -35,7 +50,7 @@ export function readMarkdownEntries(pluginRoot, directoryName) {
|
|
|
35
50
|
const files = readMarkdownFiles(directoryPath);
|
|
36
51
|
const entries = {};
|
|
37
52
|
for (const file of files) {
|
|
38
|
-
const name = file.
|
|
53
|
+
const name = file.slice(0, -markdownExtension.length);
|
|
39
54
|
const fullPath = path.join(directoryPath, file);
|
|
40
55
|
const content = fs.readFileSync(fullPath, "utf8");
|
|
41
56
|
const entry = extractFrontmatter(content);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type CommandRunner } from "./pull-request-files.js";
|
|
2
|
+
export interface LintReviewRequest {
|
|
3
|
+
repositoryRoot?: string;
|
|
4
|
+
pullRequest?: string;
|
|
5
|
+
base?: string;
|
|
6
|
+
head?: string;
|
|
7
|
+
}
|
|
8
|
+
interface LintReviewEnvironment {
|
|
9
|
+
commandRunner: CommandRunner;
|
|
10
|
+
lintRunner: (request: {
|
|
11
|
+
repositoryRoot: string;
|
|
12
|
+
files: string[];
|
|
13
|
+
}) => Promise<{
|
|
14
|
+
exitCode: number;
|
|
15
|
+
output: string;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export declare function runPrReviewLint(request: LintReviewRequest, environment: LintReviewEnvironment): Promise<string>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { resolvePullRequestChangedFiles, } from "./pull-request-files.js";
|
|
4
|
+
function normalizeTypeScriptFiles(filePaths) {
|
|
5
|
+
return filePaths.filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx"));
|
|
6
|
+
}
|
|
7
|
+
function formatLintMarkdown(output, fileCount, exitCode) {
|
|
8
|
+
if (fileCount === 0) {
|
|
9
|
+
return [
|
|
10
|
+
"<!-- nt-skillz-lint:start -->",
|
|
11
|
+
"## Lint",
|
|
12
|
+
"",
|
|
13
|
+
"No changed TypeScript files.",
|
|
14
|
+
"<!-- nt-skillz-lint:end -->",
|
|
15
|
+
].join("\n");
|
|
16
|
+
}
|
|
17
|
+
if (!output) {
|
|
18
|
+
return [
|
|
19
|
+
"<!-- nt-skillz-lint:start -->",
|
|
20
|
+
"## Lint",
|
|
21
|
+
"",
|
|
22
|
+
`PASS: ${fileCount} changed TypeScript file(s).`,
|
|
23
|
+
"<!-- nt-skillz-lint:end -->",
|
|
24
|
+
].join("\n");
|
|
25
|
+
}
|
|
26
|
+
const status = exitCode === 0 ? "PASS" : "FAIL";
|
|
27
|
+
return [
|
|
28
|
+
"<!-- nt-skillz-lint:start -->",
|
|
29
|
+
"## Lint",
|
|
30
|
+
"",
|
|
31
|
+
`${status}: ${fileCount} changed TypeScript file(s).`,
|
|
32
|
+
"",
|
|
33
|
+
"```text",
|
|
34
|
+
output,
|
|
35
|
+
"```",
|
|
36
|
+
"<!-- nt-skillz-lint:end -->",
|
|
37
|
+
].join("\n");
|
|
38
|
+
}
|
|
39
|
+
export async function runPrReviewLint(request, environment) {
|
|
40
|
+
const repositoryRoot = path.resolve(request.repositoryRoot ?? process.cwd());
|
|
41
|
+
const changedFiles = resolvePullRequestChangedFiles({
|
|
42
|
+
repositoryRoot,
|
|
43
|
+
pullRequest: request.pullRequest,
|
|
44
|
+
base: request.base,
|
|
45
|
+
head: request.head,
|
|
46
|
+
}, environment.commandRunner);
|
|
47
|
+
const typeScriptFiles = normalizeTypeScriptFiles(changedFiles);
|
|
48
|
+
const outcome = await environment.lintRunner({
|
|
49
|
+
repositoryRoot,
|
|
50
|
+
files: typeScriptFiles,
|
|
51
|
+
});
|
|
52
|
+
return formatLintMarkdown(outcome.output, typeScriptFiles.length, outcome.exitCode);
|
|
53
|
+
}
|
package/dist/tools/lint.d.ts
CHANGED
|
@@ -14,11 +14,15 @@ export declare function runPortableLintFromCommandLine(commandLineArguments: str
|
|
|
14
14
|
export declare const lintTool: {
|
|
15
15
|
description: string;
|
|
16
16
|
args: {
|
|
17
|
+
mode: import("zod").ZodOptional<import("zod").ZodString>;
|
|
18
|
+
pullRequest: import("zod").ZodOptional<import("zod").ZodString>;
|
|
17
19
|
files: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
18
20
|
base: import("zod").ZodOptional<import("zod").ZodString>;
|
|
19
21
|
head: import("zod").ZodOptional<import("zod").ZodString>;
|
|
20
22
|
};
|
|
21
23
|
execute(args: {
|
|
24
|
+
mode?: string | undefined;
|
|
25
|
+
pullRequest?: string | undefined;
|
|
22
26
|
files?: string[] | undefined;
|
|
23
27
|
base?: string | undefined;
|
|
24
28
|
head?: string | undefined;
|
package/dist/tools/lint.js
CHANGED
|
@@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { ESLint } from "eslint";
|
|
8
8
|
import { tool } from "@opencode-ai/plugin";
|
|
9
|
+
import { childProcessCommandRunner } from "./pull-request-files.js";
|
|
10
|
+
import { runPrReviewLint } from "./lint-review.js";
|
|
9
11
|
class UsageError extends Error {
|
|
10
12
|
constructor(message) {
|
|
11
13
|
super(message);
|
|
@@ -42,6 +44,16 @@ function normalizeOptionalText(value) {
|
|
|
42
44
|
}
|
|
43
45
|
return trimmedValue;
|
|
44
46
|
}
|
|
47
|
+
function normalizeToolMode(value) {
|
|
48
|
+
const normalizedValue = normalizeOptionalText(value);
|
|
49
|
+
if (!normalizedValue) {
|
|
50
|
+
return "files";
|
|
51
|
+
}
|
|
52
|
+
if (normalizedValue === "files" || normalizedValue === "pr-review") {
|
|
53
|
+
return normalizedValue;
|
|
54
|
+
}
|
|
55
|
+
throw new UsageError(`Expected lint mode to be 'files' or 'pr-review'. Got ${normalizedValue}.`);
|
|
56
|
+
}
|
|
45
57
|
function resolveDirectory(directoryPath) {
|
|
46
58
|
const absoluteDirectoryPath = path.resolve(directoryPath);
|
|
47
59
|
if (!fs.existsSync(absoluteDirectoryPath)) {
|
|
@@ -221,11 +233,28 @@ export async function runPortableLintFromCommandLine(commandLineArguments) {
|
|
|
221
233
|
export const lintTool = tool({
|
|
222
234
|
description: "Run bundled TypeScript lint rules against current project files.",
|
|
223
235
|
args: {
|
|
236
|
+
mode: tool.schema.string().optional().describe("Use 'pr-review' to lint changed pull request TypeScript files."),
|
|
237
|
+
pullRequest: tool.schema.string().optional().describe("Pull request number or URL for pr-review mode."),
|
|
224
238
|
files: tool.schema.array(tool.schema.string()).optional().describe("Relative .ts or .tsx file paths to lint."),
|
|
225
239
|
base: tool.schema.string().optional().describe("Base git reference for PR-style changed-file linting."),
|
|
226
240
|
head: tool.schema.string().optional().describe("Optional head git reference used with base."),
|
|
227
241
|
},
|
|
228
242
|
async execute(request, context) {
|
|
243
|
+
const mode = normalizeToolMode(request.mode);
|
|
244
|
+
if (mode === "pr-review") {
|
|
245
|
+
context.metadata({ title: "Lint pull request TypeScript changes" });
|
|
246
|
+
return {
|
|
247
|
+
output: await runPrReviewLint({
|
|
248
|
+
repositoryRoot: context.worktree,
|
|
249
|
+
pullRequest: normalizeOptionalText(request.pullRequest),
|
|
250
|
+
base: normalizeOptionalText(request.base),
|
|
251
|
+
head: normalizeOptionalText(request.head),
|
|
252
|
+
}, {
|
|
253
|
+
commandRunner: childProcessCommandRunner,
|
|
254
|
+
lintRunner: runPortableLint,
|
|
255
|
+
}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
229
258
|
const filePaths = normalizeFilePaths(request.files);
|
|
230
259
|
const baseReference = normalizeOptionalText(request.base);
|
|
231
260
|
const headReference = normalizeOptionalText(request.head);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface CommandRunResult {
|
|
2
|
+
status: number | null;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
errorMessage?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CommandRunner {
|
|
8
|
+
run(executable: string, commandArguments: string[], workingDirectory: string): CommandRunResult;
|
|
9
|
+
}
|
|
10
|
+
export interface PullRequestChangedFileRequest {
|
|
11
|
+
repositoryRoot: string;
|
|
12
|
+
pullRequest?: string;
|
|
13
|
+
base?: string;
|
|
14
|
+
head?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class PullRequestFileResolutionError extends Error {
|
|
17
|
+
constructor(message: string);
|
|
18
|
+
}
|
|
19
|
+
export declare const childProcessCommandRunner: CommandRunner;
|
|
20
|
+
export declare function resolvePullRequestChangedFiles(request: PullRequestChangedFileRequest, commandRunner?: CommandRunner): string[];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
export class PullRequestFileResolutionError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export const childProcessCommandRunner = {
|
|
8
|
+
run(executable, commandArguments, workingDirectory) {
|
|
9
|
+
const commandResult = spawnSync(executable, commandArguments, {
|
|
10
|
+
cwd: workingDirectory,
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
});
|
|
13
|
+
if (commandResult.error) {
|
|
14
|
+
return {
|
|
15
|
+
status: commandResult.status,
|
|
16
|
+
stdout: commandResult.stdout,
|
|
17
|
+
stderr: commandResult.stderr,
|
|
18
|
+
errorMessage: commandResult.error.message,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
status: commandResult.status,
|
|
23
|
+
stdout: commandResult.stdout,
|
|
24
|
+
stderr: commandResult.stderr,
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
function normalizeOptionalText(value) {
|
|
29
|
+
if (typeof value !== "string") {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const trimmedValue = value.trim();
|
|
33
|
+
if (!trimmedValue) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return trimmedValue;
|
|
37
|
+
}
|
|
38
|
+
function ensureSuccessfulCommand(commandResult, commandDescription) {
|
|
39
|
+
if (commandResult.errorMessage) {
|
|
40
|
+
throw new PullRequestFileResolutionError(`Expected ${commandDescription} to run. Got ${commandResult.errorMessage}.`);
|
|
41
|
+
}
|
|
42
|
+
if (commandResult.status === 0) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const failureOutput = commandResult.stderr.trim() || commandResult.stdout.trim();
|
|
46
|
+
if (failureOutput) {
|
|
47
|
+
throw new PullRequestFileResolutionError(`Expected ${commandDescription} to succeed. Got ${failureOutput}.`);
|
|
48
|
+
}
|
|
49
|
+
throw new PullRequestFileResolutionError(`Expected ${commandDescription} to succeed. Got exit status ${commandResult.status}.`);
|
|
50
|
+
}
|
|
51
|
+
function splitChangedFileOutput(output) {
|
|
52
|
+
return [...new Set(output.split("\n").map((changedPath) => changedPath.trim()).filter(Boolean))];
|
|
53
|
+
}
|
|
54
|
+
function readGitHubPullRequestFiles(repositoryRoot, pullRequest, commandRunner) {
|
|
55
|
+
const commandResult = commandRunner.run("gh", ["pr", "diff", pullRequest, "--name-only"], repositoryRoot);
|
|
56
|
+
ensureSuccessfulCommand(commandResult, "GitHub pull request file discovery");
|
|
57
|
+
return splitChangedFileOutput(commandResult.stdout);
|
|
58
|
+
}
|
|
59
|
+
function readGitChangedFiles(repositoryRoot, baseReference, headReference, commandRunner) {
|
|
60
|
+
const commandResult = commandRunner.run("git", ["diff", "--name-only", "--diff-filter=ACMR", `${baseReference}...${headReference}`], repositoryRoot);
|
|
61
|
+
ensureSuccessfulCommand(commandResult, "git changed-file discovery");
|
|
62
|
+
return splitChangedFileOutput(commandResult.stdout);
|
|
63
|
+
}
|
|
64
|
+
export function resolvePullRequestChangedFiles(request, commandRunner = childProcessCommandRunner) {
|
|
65
|
+
const pullRequest = normalizeOptionalText(request.pullRequest);
|
|
66
|
+
if (pullRequest) {
|
|
67
|
+
return readGitHubPullRequestFiles(request.repositoryRoot, pullRequest, commandRunner);
|
|
68
|
+
}
|
|
69
|
+
const baseReference = normalizeOptionalText(request.base);
|
|
70
|
+
const headReference = normalizeOptionalText(request.head);
|
|
71
|
+
if (baseReference && headReference) {
|
|
72
|
+
return readGitChangedFiles(request.repositoryRoot, baseReference, headReference, commandRunner);
|
|
73
|
+
}
|
|
74
|
+
if (baseReference) {
|
|
75
|
+
return readGitChangedFiles(request.repositoryRoot, baseReference, "HEAD", commandRunner);
|
|
76
|
+
}
|
|
77
|
+
throw new PullRequestFileResolutionError("Expected pull request identifier or base reference for pr-review mode.");
|
|
78
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type CommandRunner } from "./pull-request-files.js";
|
|
2
|
+
export declare const VITEST_COVERAGE_TOOL_NAME = "nt_skillz_vitest_coverage";
|
|
3
|
+
interface CoverageRequest {
|
|
4
|
+
repositoryRoot?: string;
|
|
5
|
+
files?: string[];
|
|
6
|
+
mode?: string;
|
|
7
|
+
pullRequest?: string;
|
|
8
|
+
base?: string;
|
|
9
|
+
head?: string;
|
|
10
|
+
}
|
|
11
|
+
interface CoverageMetric {
|
|
12
|
+
total: number;
|
|
13
|
+
covered: number;
|
|
14
|
+
skipped: number;
|
|
15
|
+
pct: number;
|
|
16
|
+
}
|
|
17
|
+
interface FileCoverageSummary {
|
|
18
|
+
lines: CoverageMetric;
|
|
19
|
+
statements: CoverageMetric;
|
|
20
|
+
functions: CoverageMetric;
|
|
21
|
+
branches: CoverageMetric;
|
|
22
|
+
}
|
|
23
|
+
interface CoverageExecutionEnvironment {
|
|
24
|
+
commandRunner: CommandRunner;
|
|
25
|
+
temporaryDirectoryCreator: (prefix: string) => string;
|
|
26
|
+
temporaryDirectoryRemover: (directoryPath: string) => void;
|
|
27
|
+
}
|
|
28
|
+
interface CoveragePassed {
|
|
29
|
+
status: "passed";
|
|
30
|
+
filePath: string;
|
|
31
|
+
summary: FileCoverageSummary;
|
|
32
|
+
}
|
|
33
|
+
interface CoverageFailed {
|
|
34
|
+
status: "failed";
|
|
35
|
+
filePath: string;
|
|
36
|
+
summary: FileCoverageSummary;
|
|
37
|
+
commandOutput: string;
|
|
38
|
+
}
|
|
39
|
+
interface CoverageErrored {
|
|
40
|
+
status: "errored";
|
|
41
|
+
filePath: string;
|
|
42
|
+
message: string;
|
|
43
|
+
commandOutput: string;
|
|
44
|
+
}
|
|
45
|
+
type CoverageFileResult = CoveragePassed | CoverageFailed | CoverageErrored;
|
|
46
|
+
export declare function runVitestCoverageReview(request: CoverageRequest, environment?: CoverageExecutionEnvironment): Promise<{
|
|
47
|
+
markdown: string;
|
|
48
|
+
results: CoverageFileResult[];
|
|
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
|
+
export {};
|
|
@@ -0,0 +1,335 @@
|
|
|
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 { tool } from "@opencode-ai/plugin";
|
|
6
|
+
import { childProcessCommandRunner, resolvePullRequestChangedFiles, } from "./pull-request-files.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 resolveVitestBinary(packageRoot) {
|
|
91
|
+
const binaryPath = path.join(packageRoot, "node_modules", ".bin", "vitest");
|
|
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);
|
|
108
|
+
const output = [commandResult.stdout, commandResult.stderr].filter(Boolean).join("\n").trim();
|
|
109
|
+
return {
|
|
110
|
+
status: commandResult.status,
|
|
111
|
+
output,
|
|
112
|
+
errorMessage: commandResult.errorMessage,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function isRecord(value) {
|
|
116
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
117
|
+
}
|
|
118
|
+
function readNumber(record, key) {
|
|
119
|
+
const value = record[key];
|
|
120
|
+
if (typeof value === "number") {
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
throw new CoverageSummaryReadError(`Expected numeric coverage field '${key}'.`);
|
|
124
|
+
}
|
|
125
|
+
function readMetric(record, key) {
|
|
126
|
+
const value = record[key];
|
|
127
|
+
if (!isRecord(value)) {
|
|
128
|
+
throw new CoverageSummaryReadError(`Expected coverage metric '${key}'.`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
total: readNumber(value, "total"),
|
|
132
|
+
covered: readNumber(value, "covered"),
|
|
133
|
+
skipped: readNumber(value, "skipped"),
|
|
134
|
+
pct: readNumber(value, "pct"),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function readFileCoverageSummary(value) {
|
|
138
|
+
if (!isRecord(value)) {
|
|
139
|
+
throw new CoverageSummaryReadError("Expected file coverage summary object.");
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
lines: readMetric(value, "lines"),
|
|
143
|
+
statements: readMetric(value, "statements"),
|
|
144
|
+
functions: readMetric(value, "functions"),
|
|
145
|
+
branches: readMetric(value, "branches"),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function normalizeCoverageKey(packageRoot, coverageKey) {
|
|
149
|
+
if (path.isAbsolute(coverageKey)) {
|
|
150
|
+
return path.resolve(coverageKey);
|
|
151
|
+
}
|
|
152
|
+
return path.resolve(packageRoot, coverageKey);
|
|
153
|
+
}
|
|
154
|
+
function readCoverageSummary(packageRoot, reportsDirectory, packageRelativeFilePath) {
|
|
155
|
+
const summaryPath = path.join(reportsDirectory, "coverage-summary.json");
|
|
156
|
+
if (!fs.existsSync(summaryPath)) {
|
|
157
|
+
throw new CoverageSummaryReadError(`Expected coverage summary at ${summaryPath}.`);
|
|
158
|
+
}
|
|
159
|
+
const parsedSummary = JSON.parse(fs.readFileSync(summaryPath, "utf8"));
|
|
160
|
+
if (!isRecord(parsedSummary)) {
|
|
161
|
+
throw new CoverageSummaryReadError("Expected coverage summary JSON object.");
|
|
162
|
+
}
|
|
163
|
+
const expectedFilePath = path.resolve(packageRoot, packageRelativeFilePath);
|
|
164
|
+
const matchingCoverageEntry = Object.entries(parsedSummary).find(([coverageKey]) => normalizeCoverageKey(packageRoot, coverageKey) === expectedFilePath);
|
|
165
|
+
if (!matchingCoverageEntry) {
|
|
166
|
+
throw new CoverageSummaryReadError(`Expected coverage summary row for ${packageRelativeFilePath}.`);
|
|
167
|
+
}
|
|
168
|
+
return readFileCoverageSummary(matchingCoverageEntry[1]);
|
|
169
|
+
}
|
|
170
|
+
function hasCompleteCoverage(summary) {
|
|
171
|
+
return summary.statements.pct === 100
|
|
172
|
+
&& summary.branches.pct === 100
|
|
173
|
+
&& summary.functions.pct === 100
|
|
174
|
+
&& summary.lines.pct === 100;
|
|
175
|
+
}
|
|
176
|
+
function formatCoverageErrorMessage(error) {
|
|
177
|
+
if (error instanceof Error) {
|
|
178
|
+
return error.message;
|
|
179
|
+
}
|
|
180
|
+
return `Expected coverage error message. Got ${String(error)}.`;
|
|
181
|
+
}
|
|
182
|
+
function executeCoverageWithReports(filePath, packageRoot, packageRelativeFilePath, environment) {
|
|
183
|
+
const reportsDirectory = environment.temporaryDirectoryCreator(path.join(os.tmpdir(), "nt-skillz-coverage-"));
|
|
184
|
+
try {
|
|
185
|
+
const commandResult = runCoverageCommand(packageRoot, packageRelativeFilePath, reportsDirectory, environment);
|
|
186
|
+
if (commandResult.errorMessage) {
|
|
187
|
+
return {
|
|
188
|
+
status: "errored",
|
|
189
|
+
filePath,
|
|
190
|
+
message: commandResult.errorMessage,
|
|
191
|
+
commandOutput: commandResult.output,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const summary = readCoverageSummary(packageRoot, reportsDirectory, packageRelativeFilePath);
|
|
195
|
+
if (commandResult.status === 0 && hasCompleteCoverage(summary)) {
|
|
196
|
+
return {
|
|
197
|
+
status: "passed",
|
|
198
|
+
filePath,
|
|
199
|
+
summary,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
status: "failed",
|
|
204
|
+
filePath,
|
|
205
|
+
summary,
|
|
206
|
+
commandOutput: commandResult.output,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
const message = formatCoverageErrorMessage(error);
|
|
211
|
+
return {
|
|
212
|
+
status: "errored",
|
|
213
|
+
filePath,
|
|
214
|
+
message,
|
|
215
|
+
commandOutput: "",
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
environment.temporaryDirectoryRemover(reportsDirectory);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function executeFileCoverage(repositoryRoot, filePath, environment) {
|
|
223
|
+
try {
|
|
224
|
+
const absoluteFilePath = path.resolve(repositoryRoot, filePath);
|
|
225
|
+
const packageRoot = findPackageRootFromDirectory(repositoryRoot, path.dirname(absoluteFilePath));
|
|
226
|
+
const packageRelativeFilePath = path.relative(packageRoot, absoluteFilePath);
|
|
227
|
+
return executeCoverageWithReports(filePath, packageRoot, packageRelativeFilePath, environment);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
const message = formatCoverageErrorMessage(error);
|
|
231
|
+
return {
|
|
232
|
+
status: "errored",
|
|
233
|
+
filePath,
|
|
234
|
+
message,
|
|
235
|
+
commandOutput: "",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function formatPercent(metric) {
|
|
240
|
+
return `${metric.pct}%`;
|
|
241
|
+
}
|
|
242
|
+
function formatCoverageRow(result) {
|
|
243
|
+
if (result.status === "errored") {
|
|
244
|
+
return `| \`${result.filePath}\` | error | error | error | error | ERROR |`;
|
|
245
|
+
}
|
|
246
|
+
const status = result.status === "passed" ? "PASS" : "FAIL";
|
|
247
|
+
return `| \`${result.filePath}\` | ${formatPercent(result.summary.statements)} | ${formatPercent(result.summary.branches)} | ${formatPercent(result.summary.functions)} | ${formatPercent(result.summary.lines)} | ${status} |`;
|
|
248
|
+
}
|
|
249
|
+
function formatCoverageDetails(result) {
|
|
250
|
+
if (result.status === "passed") {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
const detailContent = result.status === "failed" ? result.commandOutput : [result.message, result.commandOutput].filter(Boolean).join("\n");
|
|
254
|
+
if (!detailContent) {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
return [
|
|
258
|
+
`<details><summary>Coverage output for \`${result.filePath}\`</summary>`,
|
|
259
|
+
"",
|
|
260
|
+
"```text",
|
|
261
|
+
detailContent,
|
|
262
|
+
"```",
|
|
263
|
+
"",
|
|
264
|
+
"</details>",
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
function formatCoverageMarkdown(results) {
|
|
268
|
+
if (results.length === 0) {
|
|
269
|
+
return [
|
|
270
|
+
"<!-- nt-skillz-coverage:start -->",
|
|
271
|
+
"## Coverage",
|
|
272
|
+
"",
|
|
273
|
+
"No changed TypeScript source files.",
|
|
274
|
+
"<!-- nt-skillz-coverage:end -->",
|
|
275
|
+
].join("\n");
|
|
276
|
+
}
|
|
277
|
+
const tableRows = results.map(formatCoverageRow);
|
|
278
|
+
const detailRows = results.flatMap(formatCoverageDetails);
|
|
279
|
+
const lines = [
|
|
280
|
+
"<!-- nt-skillz-coverage:start -->",
|
|
281
|
+
"## Coverage",
|
|
282
|
+
"",
|
|
283
|
+
"| File | Statements | Branches | Functions | Lines | Status |",
|
|
284
|
+
"| --- | ---: | ---: | ---: | ---: | --- |",
|
|
285
|
+
...tableRows,
|
|
286
|
+
];
|
|
287
|
+
if (detailRows.length > 0) {
|
|
288
|
+
return [...lines, "", ...detailRows, "<!-- nt-skillz-coverage:end -->"].join("\n");
|
|
289
|
+
}
|
|
290
|
+
return [...lines, "<!-- nt-skillz-coverage:end -->"].join("\n");
|
|
291
|
+
}
|
|
292
|
+
export async function runVitestCoverageReview(request, environment = {
|
|
293
|
+
commandRunner: childProcessCommandRunner,
|
|
294
|
+
temporaryDirectoryCreator: fs.mkdtempSync,
|
|
295
|
+
temporaryDirectoryRemover: (directoryPath) => fs.rmSync(directoryPath, {
|
|
296
|
+
recursive: true,
|
|
297
|
+
force: true,
|
|
298
|
+
}),
|
|
299
|
+
}) {
|
|
300
|
+
const repositoryRoot = path.resolve(request.repositoryRoot ?? process.cwd());
|
|
301
|
+
const coverageTargets = resolveCoverageTargets(request, repositoryRoot, environment.commandRunner);
|
|
302
|
+
const results = coverageTargets.map((filePath) => executeFileCoverage(repositoryRoot, filePath, environment));
|
|
303
|
+
return {
|
|
304
|
+
markdown: formatCoverageMarkdown(results),
|
|
305
|
+
results,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nt-ai-lab/opencode-skillz",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"description": "Bundled OpenCode commands and agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"lint": "npm run build && node scripts/lint-ts.mjs",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"coverage": "vitest run --coverage",
|
|
19
|
+
"prepare": "node scripts/install-git-hooks.mjs",
|
|
16
20
|
"prepack": "npm run build"
|
|
17
21
|
},
|
|
18
22
|
"files": [
|
|
@@ -37,7 +41,9 @@
|
|
|
37
41
|
"typescript-eslint": "^8.50.1"
|
|
38
42
|
},
|
|
39
43
|
"devDependencies": {
|
|
40
|
-
"@types/node": "^24.7.2"
|
|
44
|
+
"@types/node": "^24.7.2",
|
|
45
|
+
"@vitest/coverage-v8": "^4.1.5",
|
|
46
|
+
"vitest": "^4.1.5"
|
|
41
47
|
},
|
|
42
48
|
"publishConfig": {
|
|
43
49
|
"access": "public"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const gitDirectory = path.resolve('.git')
|
|
5
|
+
const hooksDirectory = path.join(gitDirectory, 'hooks')
|
|
6
|
+
const preCommitHookPath = path.join(hooksDirectory, 'pre-commit')
|
|
7
|
+
|
|
8
|
+
if (fs.existsSync(gitDirectory)) {
|
|
9
|
+
fs.mkdirSync(hooksDirectory, { recursive: true })
|
|
10
|
+
fs.writeFileSync(preCommitHookPath, [
|
|
11
|
+
'#!/usr/bin/env bash',
|
|
12
|
+
'set -euo pipefail',
|
|
13
|
+
'npm run lint',
|
|
14
|
+
'',
|
|
15
|
+
].join('\n'), { mode: 0o755 })
|
|
16
|
+
}
|
|
@@ -153,12 +153,14 @@ export default tseslint.config(
|
|
|
153
153
|
ecmaVersion: 2020,
|
|
154
154
|
sourceType: 'module',
|
|
155
155
|
parserOptions: {
|
|
156
|
-
projectService:
|
|
156
|
+
projectService: {
|
|
157
|
+
allowDefaultProject: ['src/tools/*.spec.ts', 'src/tools/*-test-support.ts', 'vitest.config.ts'],
|
|
158
|
+
},
|
|
157
159
|
tsconfigRootDir: lintRepositoryRoot,
|
|
158
160
|
},
|
|
159
161
|
},
|
|
160
162
|
rules: {
|
|
161
|
-
'import/extensions': ['error', '
|
|
163
|
+
'import/extensions': ['error', 'ignorePackages', { ts: 'never', tsx: 'never', js: 'always', json: 'always' }],
|
|
162
164
|
'custom/no-generic-names': 'error',
|
|
163
165
|
'no-warning-comments': 'off',
|
|
164
166
|
'multiline-comment-style': 'off',
|