@nt-ai-lab/opencode-skillz 0.3.13 → 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/component-design.md +29 -0
- 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
|
@@ -87,6 +87,35 @@ Components should be put into the correct layer based on the type of logic they
|
|
|
87
87
|
|
|
88
88
|
- `/infra/{gateway}`: This sub-layer handles receiving inputs from the outside world and returning responses to the outside world, like gateways sitting at the edge of the application. It's common to see http controllers and event handlers in this layer. Examples of `{gateway}` include `http`, `event-handlers`. Everything inside this layer must live in a sub-folder to avoid the root becoming a dumping ground and hiding design issues.
|
|
89
89
|
|
|
90
|
+
#### Layer Import Rules
|
|
91
|
+
|
|
92
|
+
Layering is enforced by imports. The use-case layer is the orchestration layer. Use cases can depend on infra. Infra capability code must stay application-agnostic and must not depend on use cases.
|
|
93
|
+
|
|
94
|
+
Allowed dependencies:
|
|
95
|
+
|
|
96
|
+
| Dependency | Reason |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `domain/**` imports `domain/**` | Domain code can use other domain concepts. |
|
|
99
|
+
| `use-case/**` imports `domain/**` | Use cases orchestrate domain behavior. |
|
|
100
|
+
| `use-case/**` imports `infra/**` | Use cases orchestrate technical capabilities and domain behavior. This includes persistence, transactions, external services, logging, queues, caches, clients, and infra-owned types. |
|
|
101
|
+
| `infra/gateway/**` imports `use-case/**` | Gateways receive outside-world input and call the use case that performs the application operation. |
|
|
102
|
+
| composition root imports `use-case/**` and `infra/**` | The composition root wires runtime dependencies. It must not contain business logic. |
|
|
103
|
+
|
|
104
|
+
Forbidden dependencies:
|
|
105
|
+
|
|
106
|
+
| Dependency | Reason |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `domain/**` imports `use-case/**` | Domain rules must not know which application operations exist. |
|
|
109
|
+
| `domain/**` imports `infra/**` | Domain rules must not depend on databases, HTTP, queues, SDKs, logging, transactions, or other technical details. |
|
|
110
|
+
| `infra/persistence/**` imports `use-case/**` | Persistence is a reusable technical capability. It must not know which use cases exist. |
|
|
111
|
+
| `infra/external-service/**` imports `use-case/**` | External-service clients are reusable technical capabilities. They must not know which use cases exist. |
|
|
112
|
+
| `infra/transactions/**` imports `use-case/**` | Transaction code is a reusable technical capability. It must not know which use cases exist. |
|
|
113
|
+
| `infra/logging/**` imports `use-case/**` | Logging code is a reusable technical capability. It must not know which use cases exist. |
|
|
114
|
+
| `infra/gateway/**` imports `infra/persistence/**` directly | Gateways must not bypass use cases to load or save application state. |
|
|
115
|
+
| `infra/gateway/**` imports `infra/external-service/**` directly | Gateways must not bypass use cases to call external systems. |
|
|
116
|
+
| `infra/gateway/**` imports `domain/**` to mutate domain objects directly | Gateways must not bypass use-case orchestration. |
|
|
117
|
+
|
|
118
|
+
|
|
90
119
|
The following folder names are baned because they are generic terms that become a dumping ground or workaround for bad design. Find a more precise name or ask for help.
|
|
91
120
|
|
|
92
121
|
- `utils`
|
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',
|