@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.
@@ -49,26 +49,19 @@ Provide the subagent with:
49
49
  - the changed file list
50
50
  - the changed diff context
51
51
 
52
- ## Test Coverage Analysis
53
-
54
- Add a test coverage analysis results section to the PR description:
55
-
56
- 1. For each changed path from `gh pr diff <pr> --name-only`, run `/nt-skillz:vitest-coverage <file>`.
57
- 2. Ignore only runs that print a `SKIP:` line.
58
- 3. If every run prints a `SKIP:` line, use one fenced `text` block containing `No changed TypeScript source files.` as the coverage content.
59
- 4. Otherwise, build the PR coverage block in this exact shape:
60
- - `<!-- nt-skillz-coverage:start -->`
61
- - `## Coverage`
62
- - one `### \`<file>\`` heading for each non-`SKIP:` file
63
- - one fenced `text` block containing the exact raw `/nt-skillz:vitest-coverage <file>` output directly under that file heading
64
- - `<!-- nt-skillz-coverage:end -->`
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
- return typeof result === "object" && result !== null && "data" in result ? result.data : result;
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(/\n|;/)
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 match = text.match(/<dont-stop-status>([\s\S]*?)<\/dont-stop-status>/i);
45
- if (!match)
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 = match[1];
48
- const stateMatch = body.match(/^\s*state\s*:\s*(continue|completion-requested|blocked-requested)\s*$/im);
49
- const justificationMatch = body.match(/^\s*justification\s*:\s*(.+)\s*$/im);
50
- const reasonMatch = body.match(/^\s*reason\s*:\s*(.+)\s*$/im);
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: stateMatch?.[1] ?? "continue",
53
- justification: justificationMatch?.[1]?.trim() ?? "",
54
- reason: reasonMatch?.[1]?.trim() ?? "",
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
- for (let index = messages.length - 1; index >= 0; index -= 1) {
67
- const entry = messages[index];
68
- if (entry.info?.role !== "assistant")
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 || "none";
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 || typeof properties !== "object")
157
+ if (!isRecord(properties))
120
158
  return undefined;
121
159
  const info = properties.info;
122
- return typeof info?.id === "string" ? info.id : undefined;
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 || typeof properties !== "object")
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 OpencodeSkillzPlugin: (input: PluginInput) => Promise<import("./types.js").PluginHooks>;
3
- export default OpencodeSkillzPlugin;
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 __dirname = path.dirname(fileURLToPath(import.meta.url));
5
- const pluginRoot = path.resolve(__dirname, "..");
6
- export const OpencodeSkillzPlugin = async (input) => createPluginRegistry(input, pluginRoot);
7
- export default OpencodeSkillzPlugin;
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.replace(/\$ARGUMENTS/g, "all relevant current work in this session");
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 = typeof rawAgent.meta.extends === "string" ? rawAgent.meta.extends.trim() : "";
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
- const parentAgentName = typeof rawAgent.meta.extends === "string" ? rawAgent.meta.extends.trim() : "";
36
- if (parentAgentName && rawAgents[parentAgentName]?.body) {
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
- if (typeof rawAgent.meta.description === "string")
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().replace(/_/g, "-");
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
- if (composedTemplate) {
25
- template = [template, `In addition you must adhere to the following:\n\n${composedTemplate}`]
26
- .filter(Boolean)
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 template.trim();
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: { [LINT_TOOL_NAME]: lintTool },
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 = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
5
- if (!match)
6
- return { meta: {}, body: content };
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
- let value = line.slice(separatorIndex + 1).trim().replace(/^['\"]|['\"]$/g, "");
17
- if (value === "true")
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 { meta, body: match[2] };
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(".md"))
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.replace(/\.md$/, "");
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
+ }
@@ -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;
@@ -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.14",
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: true,
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', 'never', { ts: 'never', tsx: 'never', js: 'never', json: 'always' }],
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',