@nt-ai-lab/opencode-skillz 0.3.15 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/AGENTS.md +31 -0
  2. package/agents/default.md +4 -0
  3. package/agents/facilitator.md +63 -0
  4. package/agents/tdd.md +1 -0
  5. package/commands/plan.md +135 -45
  6. package/commands/resolve-pr-feedback.md +138 -0
  7. package/dist/commands/dont-stop/hooks.js +4 -10
  8. package/dist/git-workflow-gates.d.ts +21 -0
  9. package/dist/git-workflow-gates.js +103 -0
  10. package/dist/plugin-registry/agents.js +0 -4
  11. package/dist/plugin-registry/commands.js +0 -2
  12. package/dist/plugin-registry/index.js +29 -2
  13. package/dist/tools/create-pr-tool.d.ts +4 -0
  14. package/dist/tools/create-pr-tool.js +22 -0
  15. package/dist/tools/infra/lint/guidance.d.ts +8 -0
  16. package/dist/tools/infra/lint/guidance.js +98 -0
  17. package/dist/tools/{lint-review.d.ts → infra/lint/review.d.ts} +1 -1
  18. package/dist/tools/{lint-review.js → infra/lint/review.js} +1 -1
  19. package/dist/tools/infra/pull-request/create-draft-pull-request.d.ts +11 -0
  20. package/dist/tools/infra/pull-request/create-draft-pull-request.js +115 -0
  21. package/dist/tools/infra/pull-request/feedback.d.ts +14 -0
  22. package/dist/tools/infra/pull-request/feedback.js +280 -0
  23. package/dist/tools/infra/vitest-coverage/command.d.ts +16 -0
  24. package/dist/tools/infra/vitest-coverage/command.js +85 -0
  25. package/dist/tools/{vitest-coverage.d.ts → infra/vitest-coverage/review.d.ts} +1 -18
  26. package/dist/tools/{vitest-coverage.js → infra/vitest-coverage/review.js} +15 -52
  27. package/dist/tools/infra/vitest-coverage/test-support.d.ts +15 -0
  28. package/dist/tools/infra/vitest-coverage/test-support.js +156 -0
  29. package/dist/tools/lint.d.ts +3 -17
  30. package/dist/tools/lint.js +29 -18
  31. package/dist/tools/pull-request-feedback-tool.d.ts +5 -0
  32. package/dist/tools/pull-request-feedback-tool.js +23 -0
  33. package/dist/tools/vitest-coverage-tool.d.ts +4 -0
  34. package/dist/tools/vitest-coverage-tool.js +31 -0
  35. package/dist/types.d.ts +8 -0
  36. package/package.json +4 -3
  37. package/scripts/check-tools-folder-boundary.mjs +78 -0
  38. package/scripts/install-git-hooks.mjs +42 -4
  39. package/scripts/lint-ts.mjs +32 -7
  40. package/scripts/living-architecture-eslint.config.mjs +2 -2
  41. package/scripts/no-generic-names-eslint-rule.mjs +2 -24
  42. package/scripts/no-generic-names-eslint-rule.mjs.d.ts +28 -0
  43. /package/dist/tools/{pull-request-files.d.ts → infra/source-control/changed-files.d.ts} +0 -0
  44. /package/dist/tools/{pull-request-files.js → infra/source-control/changed-files.js} +0 -0
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export function createRepository(sourceFilePath) {
5
+ const repositoryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nt-skillz-test-repo-"));
6
+ const sourceAbsolutePath = path.join(repositoryRoot, sourceFilePath);
7
+ const vitestBinaryPath = path.join(repositoryRoot, "node_modules", ".bin", "vitest");
8
+ fs.mkdirSync(path.dirname(sourceAbsolutePath), { recursive: true });
9
+ fs.mkdirSync(path.dirname(vitestBinaryPath), { recursive: true });
10
+ fs.writeFileSync(path.join(repositoryRoot, "package.json"), "{}");
11
+ fs.writeFileSync(sourceAbsolutePath, "export const answer = 42\n");
12
+ fs.writeFileSync(vitestBinaryPath, "");
13
+ return repositoryRoot;
14
+ }
15
+ export function createRepositoryWithoutVitest(sourceFilePath) {
16
+ const repositoryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nt-skillz-test-repo-"));
17
+ const sourceAbsolutePath = path.join(repositoryRoot, sourceFilePath);
18
+ fs.mkdirSync(path.dirname(sourceAbsolutePath), { recursive: true });
19
+ fs.writeFileSync(path.join(repositoryRoot, "package.json"), "{}");
20
+ fs.writeFileSync(sourceAbsolutePath, "export const answer = 42\n");
21
+ return repositoryRoot;
22
+ }
23
+ export function createRepositoryWithoutPackage(sourceFilePath) {
24
+ const repositoryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nt-skillz-test-repo-"));
25
+ const sourceAbsolutePath = path.join(repositoryRoot, sourceFilePath);
26
+ fs.mkdirSync(path.dirname(sourceAbsolutePath), { recursive: true });
27
+ fs.writeFileSync(sourceAbsolutePath, "export const answer = 42\n");
28
+ return repositoryRoot;
29
+ }
30
+ function createCoverageMetric(percent) {
31
+ return {
32
+ total: 1,
33
+ covered: percent === 100 ? 1 : 0,
34
+ skipped: 0,
35
+ pct: percent,
36
+ };
37
+ }
38
+ function writeCoverageSummary(reportsDirectory, filePath, percent) {
39
+ fs.mkdirSync(reportsDirectory, { recursive: true });
40
+ fs.writeFileSync(path.join(reportsDirectory, "coverage-summary.json"), JSON.stringify({
41
+ total: {
42
+ lines: createCoverageMetric(percent),
43
+ statements: createCoverageMetric(percent),
44
+ functions: createCoverageMetric(percent),
45
+ branches: createCoverageMetric(percent),
46
+ },
47
+ [filePath]: {
48
+ lines: createCoverageMetric(percent),
49
+ statements: createCoverageMetric(percent),
50
+ functions: createCoverageMetric(percent),
51
+ branches: createCoverageMetric(percent),
52
+ },
53
+ }));
54
+ }
55
+ export function createCoverageCommandRunner(percent, capturedRuns, commandOutput = "coverage output") {
56
+ return {
57
+ run(executable, commandArguments, workingDirectory) {
58
+ capturedRuns.push({
59
+ executable,
60
+ commandArguments,
61
+ workingDirectory,
62
+ });
63
+ const reportsDirectoryArgument = commandArguments.find((commandArgument) => commandArgument.startsWith("--coverage.reportsDirectory="));
64
+ if (reportsDirectoryArgument === undefined) {
65
+ return {
66
+ status: 1,
67
+ stdout: "",
68
+ stderr: "missing reports directory",
69
+ };
70
+ }
71
+ const reportsDirectory = reportsDirectoryArgument.replace("--coverage.reportsDirectory=", "");
72
+ const coverageIncludeArgument = commandArguments.find((commandArgument) => commandArgument.startsWith("--coverage.include="));
73
+ if (coverageIncludeArgument === undefined) {
74
+ return {
75
+ status: 1,
76
+ stdout: "",
77
+ stderr: "missing coverage include",
78
+ };
79
+ }
80
+ const coveredFilePath = coverageIncludeArgument.replace("--coverage.include=", "");
81
+ writeCoverageSummary(reportsDirectory, path.join(workingDirectory, coveredFilePath), percent);
82
+ return {
83
+ status: percent === 100 ? 0 : 1,
84
+ stdout: commandOutput,
85
+ stderr: "",
86
+ };
87
+ },
88
+ };
89
+ }
90
+ export function createPullRequestCoverageCommandRunner(changedFiles, percent) {
91
+ return {
92
+ run(executable, commandArguments, workingDirectory) {
93
+ if (executable === "gh") {
94
+ return {
95
+ status: 0,
96
+ stdout: changedFiles.join("\n"),
97
+ stderr: "",
98
+ };
99
+ }
100
+ return createCoverageCommandRunner(percent, []).run(executable, commandArguments, workingDirectory);
101
+ },
102
+ };
103
+ }
104
+ export function createCoverageSummaryCommandRunner(summaryJson) {
105
+ return {
106
+ run(_executable, commandArguments) {
107
+ const reportsDirectoryArgument = commandArguments.find((commandArgument) => commandArgument.startsWith("--coverage.reportsDirectory="));
108
+ if (reportsDirectoryArgument !== undefined) {
109
+ const reportsDirectory = reportsDirectoryArgument.replace("--coverage.reportsDirectory=", "");
110
+ fs.mkdirSync(reportsDirectory, { recursive: true });
111
+ fs.writeFileSync(path.join(reportsDirectory, "coverage-summary.json"), summaryJson);
112
+ }
113
+ return {
114
+ status: 0,
115
+ stdout: "",
116
+ stderr: "",
117
+ };
118
+ },
119
+ };
120
+ }
121
+ export function createCoverageErrorCommandRunner() {
122
+ return {
123
+ run() {
124
+ return {
125
+ status: null,
126
+ stdout: "coverage stdout",
127
+ stderr: "coverage stderr",
128
+ errorMessage: "spawn failed",
129
+ };
130
+ },
131
+ };
132
+ }
133
+ export function installFailingVitestBinary(repositoryRoot) {
134
+ const vitestBinaryPath = path.join(repositoryRoot, "node_modules", ".bin", "vitest");
135
+ const scriptContent = [
136
+ "#!/usr/bin/env node",
137
+ "const fs = require('node:fs')",
138
+ "const path = require('node:path')",
139
+ "const filePath = path.join(process.cwd(), process.argv[3])",
140
+ "const reportsArgument = process.argv.find((argument) => argument.startsWith('--coverage.reportsDirectory='))",
141
+ "const reportsDirectory = reportsArgument.replace('--coverage.reportsDirectory=', '')",
142
+ "const metric = { total: 1, covered: 0, skipped: 0, pct: 50 }",
143
+ "fs.mkdirSync(reportsDirectory, { recursive: true })",
144
+ "fs.writeFileSync(path.join(reportsDirectory, 'coverage-summary.json'), JSON.stringify({ total: { lines: metric, statements: metric, functions: metric, branches: metric }, [filePath]: { lines: metric, statements: metric, functions: metric, branches: metric } }))",
145
+ "process.stdout.write('tool coverage output')",
146
+ "process.exit(1)",
147
+ ].join("\n");
148
+ fs.rmSync(vitestBinaryPath, { force: true });
149
+ fs.writeFileSync(vitestBinaryPath, scriptContent, { mode: 0o755 });
150
+ }
151
+ export function removeDirectory(directoryPath) {
152
+ fs.rmSync(directoryPath, {
153
+ recursive: true,
154
+ force: true,
155
+ });
156
+ }
@@ -1,3 +1,4 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin";
1
2
  interface PortableLintRequest {
2
3
  repositoryRoot?: string;
3
4
  files?: string[];
@@ -9,23 +10,8 @@ interface PortableLintOutcome {
9
10
  output: string;
10
11
  }
11
12
  export declare const LINT_TOOL_NAME = "nt_skillz_lint";
13
+ export declare function prependLintFailureGuidance(formattedOutput: string, errorCount: number, lintFailureGuidance: string): string;
12
14
  export declare function runPortableLint(request: PortableLintRequest): Promise<PortableLintOutcome>;
13
15
  export declare function runPortableLintFromCommandLine(commandLineArguments: string[]): Promise<number>;
14
- export declare const lintTool: {
15
- description: string;
16
- args: {
17
- mode: import("zod").ZodOptional<import("zod").ZodString>;
18
- pullRequest: import("zod").ZodOptional<import("zod").ZodString>;
19
- files: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
20
- base: import("zod").ZodOptional<import("zod").ZodString>;
21
- head: import("zod").ZodOptional<import("zod").ZodString>;
22
- };
23
- execute(args: {
24
- mode?: string | undefined;
25
- pullRequest?: string | undefined;
26
- files?: string[] | undefined;
27
- base?: string | undefined;
28
- head?: string | undefined;
29
- }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
30
- };
16
+ export declare const lintTool: ToolDefinition;
31
17
  export {};
@@ -5,9 +5,10 @@ import { spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parseArgs } from "node:util";
7
7
  import { ESLint } from "eslint";
8
- import { tool } from "@opencode-ai/plugin";
9
- import { childProcessCommandRunner } from "./pull-request-files.js";
10
- import { runPrReviewLint } from "./lint-review.js";
8
+ import { tool, } from "@opencode-ai/plugin";
9
+ import { createLintFailureGuidance } from "./infra/lint/guidance.js";
10
+ import { childProcessCommandRunner } from "./infra/source-control/changed-files.js";
11
+ import { runPrReviewLint } from "./infra/lint/review.js";
11
12
  class UsageError extends Error {
12
13
  constructor(message) {
13
14
  super(message);
@@ -33,6 +34,14 @@ const toolDirectory = path.dirname(fileURLToPath(import.meta.url));
33
34
  const toolRepositoryRoot = path.resolve(toolDirectory, "..", "..");
34
35
  const eslintConfigPath = path.join(toolRepositoryRoot, "scripts", "living-architecture-eslint.config.mjs");
35
36
  const gitBinaryPath = "/usr/bin/git";
37
+ const helpText = [
38
+ "Usage: ./scripts/lint-ts.sh [--repo PATH] [--base REF] [--head REF] [file ...]",
39
+ "",
40
+ "Examples:",
41
+ " ./scripts/lint-ts.sh --repo ../living-architecture --base origin/main",
42
+ " ./scripts/lint-ts.sh --repo ../living-architecture packages/example/src/example.ts",
43
+ " ./scripts/lint-ts.sh src/example.ts",
44
+ ].join("\n");
36
45
  export const LINT_TOOL_NAME = "nt_skillz_lint";
37
46
  function normalizeOptionalText(value) {
38
47
  if (typeof value !== "string") {
@@ -89,8 +98,8 @@ function runGitCommand(repositoryRoot, gitArguments) {
89
98
  if (gitResult.status === 0) {
90
99
  return gitResult.stdout;
91
100
  }
92
- const errorOutput = gitResult.stderr.trim() || gitResult.stdout.trim() || "git command failed";
93
- throw new GitCommandError(`Expected git command to succeed. Got ${errorOutput}.`);
101
+ const failureOutput = gitResult.stderr.trim() || gitResult.stdout.trim() || `exit status ${gitResult.status}`;
102
+ throw new GitCommandError(`Expected git command to succeed. Got ${failureOutput}.`);
94
103
  }
95
104
  function readChangedTypeScriptFiles(repositoryRoot, baseReference, headReference) {
96
105
  const diffRange = `${baseReference}...${headReference}`;
@@ -146,6 +155,15 @@ function createLintTitle(filePaths, baseReference) {
146
155
  function removeAnsiEscapeSequences(value) {
147
156
  return value.replaceAll(ansiEscapeSequencePattern, "");
148
157
  }
158
+ export function prependLintFailureGuidance(formattedOutput, errorCount, lintFailureGuidance) {
159
+ if (errorCount === 0) {
160
+ return formattedOutput;
161
+ }
162
+ if (!formattedOutput) {
163
+ return lintFailureGuidance;
164
+ }
165
+ return [lintFailureGuidance, "", formattedOutput].join("\n");
166
+ }
149
167
  async function runEslint(repositoryRoot, lintTargets) {
150
168
  const previousLintRepositoryRoot = process.env.NT_SKILLZ_LINT_REPO_ROOT;
151
169
  process.env.NT_SKILLZ_LINT_REPO_ROOT = repositoryRoot;
@@ -162,7 +180,7 @@ async function runEslint(repositoryRoot, lintTargets) {
162
180
  const errorCount = lintResults.reduce((count, lintResult) => count + lintResult.errorCount + lintResult.fatalErrorCount, 0);
163
181
  return {
164
182
  exitCode: errorCount > 0 ? 1 : 0,
165
- output: formattedOutput,
183
+ output: prependLintFailureGuidance(formattedOutput, errorCount, createLintFailureGuidance(lintResults)),
166
184
  };
167
185
  }
168
186
  finally {
@@ -188,17 +206,6 @@ function parsePortableLintCommandLine(commandLineArguments) {
188
206
  },
189
207
  allowPositionals: true,
190
208
  });
191
- if (parsedArguments.values.help) {
192
- process.stdout.write([
193
- "Usage: ./scripts/lint-ts.sh [--repo PATH] [--base REF] [--head REF] [file ...]",
194
- "",
195
- "Examples:",
196
- " ./scripts/lint-ts.sh --repo ../living-architecture --base origin/main",
197
- " ./scripts/lint-ts.sh --repo ../living-architecture packages/example/src/example.ts",
198
- " ./scripts/lint-ts.sh src/example.ts",
199
- ].join("\n") + "\n");
200
- process.exit(0);
201
- }
202
209
  return {
203
210
  repositoryRoot: resolveDirectory(parsedArguments.values.repo ?? process.cwd()),
204
211
  files: normalizeFilePaths(parsedArguments.positionals),
@@ -223,6 +230,10 @@ export async function runPortableLint(request) {
223
230
  return runEslint(repositoryRoot, lintTargets);
224
231
  }
225
232
  export async function runPortableLintFromCommandLine(commandLineArguments) {
233
+ if (commandLineArguments.includes("--help") || commandLineArguments.includes("-h")) {
234
+ process.stdout.write(`${helpText}\n`);
235
+ return 0;
236
+ }
226
237
  const request = parsePortableLintCommandLine(commandLineArguments);
227
238
  const outcome = await runPortableLint(request);
228
239
  if (outcome.output) {
@@ -267,7 +278,7 @@ export const lintTool = tool({
267
278
  head: headReference,
268
279
  });
269
280
  if (outcome.exitCode !== 0) {
270
- throw new LintExecutionError(outcome.output || "Lint failed.");
281
+ throw new LintExecutionError(outcome.output);
271
282
  }
272
283
  return {
273
284
  output: outcome.output || "Lint passed.",
@@ -0,0 +1,5 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin";
2
+ import { PULL_REQUEST_FEEDBACK_TOOL_NAME, type PullRequestFeedbackDependencies } from "./infra/pull-request/feedback.js";
3
+ export declare function createPullRequestFeedbackTool(dependencies?: PullRequestFeedbackDependencies): ToolDefinition;
4
+ export declare const pullRequestFeedbackTool: ToolDefinition;
5
+ export { PULL_REQUEST_FEEDBACK_TOOL_NAME };
@@ -0,0 +1,23 @@
1
+ import { tool, } from "@opencode-ai/plugin";
2
+ import { PULL_REQUEST_FEEDBACK_TOOL_NAME, readPullRequestFeedback, } from "./infra/pull-request/feedback.js";
3
+ export function createPullRequestFeedbackTool(dependencies = {}) {
4
+ return tool({
5
+ description: "Fetch unresolved GitHub pull request review feedback with diff hunks and local code excerpts.",
6
+ args: {
7
+ pullRequestNumber: tool.schema.string().describe("Pull request number from GitHub."),
8
+ pullRequestUrl: tool.schema.string().describe("Full GitHub pull request URL."),
9
+ },
10
+ async execute(request, context) {
11
+ context.metadata({ title: "Fetch unresolved PR feedback" });
12
+ return {
13
+ output: readPullRequestFeedback({
14
+ repositoryRoot: context.worktree,
15
+ pullRequestNumber: request.pullRequestNumber,
16
+ pullRequestUrl: request.pullRequestUrl,
17
+ }, dependencies),
18
+ };
19
+ },
20
+ });
21
+ }
22
+ export const pullRequestFeedbackTool = createPullRequestFeedbackTool();
23
+ export { PULL_REQUEST_FEEDBACK_TOOL_NAME };
@@ -0,0 +1,4 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin";
2
+ import { VITEST_COVERAGE_TOOL_NAME } from "./infra/vitest-coverage/review.js";
3
+ export declare const vitestCoverageTool: ToolDefinition;
4
+ export { VITEST_COVERAGE_TOOL_NAME };
@@ -0,0 +1,31 @@
1
+ import { tool, } from "@opencode-ai/plugin";
2
+ import { runVitestCoverageReview, VITEST_COVERAGE_TOOL_NAME, } from "./infra/vitest-coverage/review.js";
3
+ export const vitestCoverageTool = tool({
4
+ description: "Run Vitest coverage for changed TypeScript source files.",
5
+ args: {
6
+ mode: tool.schema.string().optional().describe("Use 'pr-review' for pull request coverage."),
7
+ pullRequest: tool.schema.string().optional().describe("Pull request number or URL for pr-review mode."),
8
+ base: tool.schema.string().optional().describe("Base git reference for pr-review mode when no pull request is provided."),
9
+ head: tool.schema.string().optional().describe("Head git reference for pr-review mode when base is provided."),
10
+ files: tool.schema.array(tool.schema.string()).optional().describe("Repository-relative files for files mode."),
11
+ },
12
+ async execute(request, context) {
13
+ context.metadata({ title: "Vitest coverage review" });
14
+ const outcome = await runVitestCoverageReview({
15
+ repositoryRoot: context.worktree,
16
+ mode: request.mode,
17
+ pullRequest: request.pullRequest,
18
+ base: request.base,
19
+ head: request.head,
20
+ files: request.files,
21
+ });
22
+ return {
23
+ output: outcome.markdown,
24
+ metadata: {
25
+ fileCount: outcome.results.length,
26
+ failedCount: outcome.results.filter((result) => result.status !== "passed").length,
27
+ },
28
+ };
29
+ },
30
+ });
31
+ export { VITEST_COVERAGE_TOOL_NAME };
package/dist/types.d.ts CHANGED
@@ -71,12 +71,20 @@ export interface OpencodeClient {
71
71
  export interface PluginHooks {
72
72
  config?: (config: PluginConfig) => Promise<void>;
73
73
  tool?: Record<string, ToolDefinition>;
74
+ "tool.execute.before"?: (input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput) => Promise<void>;
74
75
  "command.execute.before"?: (input: CommandExecuteBeforeInput, output: CommandExecuteBeforeOutput) => Promise<void>;
75
76
  "experimental.chat.system.transform"?: (input: ChatSystemTransformInput, output: ChatSystemTransformOutput) => Promise<void>;
76
77
  event?: (input: {
77
78
  event: SessionEvent;
78
79
  }) => Promise<void>;
79
80
  }
81
+ export interface ToolExecuteBeforeInput {
82
+ tool: string;
83
+ }
84
+ export interface ToolExecuteBeforeOutput {
85
+ args: Record<string, unknown>;
86
+ }
80
87
  export interface PluginInput {
81
88
  client: OpencodeClient;
89
+ worktree?: string;
82
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nt-ai-lab/opencode-skillz",
3
- "version": "0.3.15",
3
+ "version": "0.4.2",
4
4
  "description": "Bundled OpenCode commands and agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "scripts": {
15
15
  "build": "tsc -p tsconfig.json",
16
- "lint": "npm run build && node scripts/lint-ts.mjs",
16
+ "lint": "npm run build && node scripts/check-tools-folder-boundary.mjs && node scripts/lint-ts.mjs",
17
17
  "test": "vitest run",
18
18
  "coverage": "vitest run --coverage",
19
19
  "prepare": "node scripts/install-git-hooks.mjs",
@@ -38,7 +38,8 @@
38
38
  "eslint-plugin-sonarjs": "^3.0.5",
39
39
  "eslint-plugin-unicorn": "^62.0.0",
40
40
  "typescript": "^5.9.3",
41
- "typescript-eslint": "^8.50.1"
41
+ "typescript-eslint": "^8.50.1",
42
+ "zod": "^4.4.1"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@types/node": "^24.7.2",
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import process from 'node:process'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const topLevelToolFilePattern = /^src\/tools\/[^/]+\.ts$/u
7
+ const testFilePattern = /\.(?:spec|test)\.ts$/u
8
+ const pluginImportPattern = /from\s+["']@opencode-ai\/plugin["']/u
9
+ const exportedToolDefinitionPattern = /export\s+(?:const|function)\s+\w+[^\n]*:\s*ToolDefinition/u
10
+ const toolCallPattern = /\btool\s*\(/u
11
+
12
+ const boundaryFailureMessage = 'Top-level src/tools files must export a real OpenCode ToolDefinition. Move support code to src/tools/infra/<concept>/. Renaming a support file to *-tool.ts is not valid.'
13
+
14
+ export function isTopLevelToolsTypeScriptFile(repositoryRoot, filePath) {
15
+ const relativeFilePath = path.relative(repositoryRoot, filePath).split(path.sep).join('/')
16
+ return topLevelToolFilePattern.test(relativeFilePath) && !testFilePattern.test(relativeFilePath)
17
+ }
18
+
19
+ function hasRealToolDefinition(sourceText) {
20
+ return pluginImportPattern.test(sourceText)
21
+ && sourceText.includes('ToolDefinition')
22
+ && sourceText.includes('tool')
23
+ && exportedToolDefinitionPattern.test(sourceText)
24
+ && toolCallPattern.test(sourceText)
25
+ }
26
+
27
+ function readTopLevelToolsTypeScriptFiles(repositoryRoot) {
28
+ const toolsDirectory = path.join(repositoryRoot, 'src', 'tools')
29
+
30
+ if (!fs.existsSync(toolsDirectory)) {
31
+ return []
32
+ }
33
+
34
+ return fs.readdirSync(toolsDirectory, { withFileTypes: true })
35
+ .filter((directoryEntry) => directoryEntry.isFile())
36
+ .map((directoryEntry) => path.join(toolsDirectory, directoryEntry.name))
37
+ .filter((filePath) => isTopLevelToolsTypeScriptFile(repositoryRoot, filePath))
38
+ }
39
+
40
+ export function readToolsFolderBoundaryViolations(repositoryRoot) {
41
+ return readTopLevelToolsTypeScriptFiles(repositoryRoot)
42
+ .filter((filePath) => !hasRealToolDefinition(fs.readFileSync(filePath, 'utf8')))
43
+ .map((filePath) => path.relative(repositoryRoot, filePath).split(path.sep).join('/'))
44
+ }
45
+
46
+ export function formatToolsFolderBoundaryViolations(violations) {
47
+ if (violations.length === 0) {
48
+ return ''
49
+ }
50
+
51
+ return [
52
+ boundaryFailureMessage,
53
+ ...violations.map((violation) => `- ${violation}`),
54
+ ].join('\n')
55
+ }
56
+
57
+ export function runToolsFolderBoundaryCheck(repositoryRoot = process.cwd(), stderr = process.stderr) {
58
+ const violations = readToolsFolderBoundaryViolations(repositoryRoot)
59
+ const output = formatToolsFolderBoundaryViolations(violations)
60
+
61
+ if (!output) {
62
+ return 0
63
+ }
64
+
65
+ stderr.write(`${output}\n`)
66
+ return 1
67
+ }
68
+
69
+ export async function runToolsFolderBoundaryScriptMain(currentScriptPath = process.argv[1]) {
70
+ if (currentScriptPath !== fileURLToPath(import.meta.url)) {
71
+ return false
72
+ }
73
+
74
+ process.exitCode = runToolsFolderBoundaryCheck()
75
+ return true
76
+ }
77
+
78
+ await runToolsFolderBoundaryScriptMain()
@@ -1,16 +1,54 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
3
4
 
4
- const gitDirectory = path.resolve('.git')
5
- const hooksDirectory = path.join(gitDirectory, 'hooks')
6
- const preCommitHookPath = path.join(hooksDirectory, 'pre-commit')
5
+ function resolveGitDirectory(repositoryRoot) {
6
+ const gitPath = path.resolve(repositoryRoot, '.git')
7
7
 
8
- if (fs.existsSync(gitDirectory)) {
8
+ if (!fs.existsSync(gitPath)) {
9
+ return undefined
10
+ }
11
+
12
+ if (fs.statSync(gitPath).isDirectory()) {
13
+ return gitPath
14
+ }
15
+
16
+ const gitFileContent = fs.readFileSync(gitPath, 'utf8').trim()
17
+ const gitDirectoryPrefix = 'gitdir: '
18
+
19
+ if (!gitFileContent.startsWith(gitDirectoryPrefix)) {
20
+ throw new Error(`Expected .git file to start with "${gitDirectoryPrefix}". Got ${gitFileContent}.`)
21
+ }
22
+
23
+ return path.resolve(repositoryRoot, gitFileContent.slice(gitDirectoryPrefix.length))
24
+ }
25
+
26
+ export function installGitHooks(repositoryRoot = process.cwd()) {
27
+ const gitDirectory = resolveGitDirectory(repositoryRoot)
28
+
29
+ if (!gitDirectory) {
30
+ return false
31
+ }
32
+
33
+ const hooksDirectory = path.join(gitDirectory, 'hooks')
34
+ const preCommitHookPath = path.join(hooksDirectory, 'pre-commit')
9
35
  fs.mkdirSync(hooksDirectory, { recursive: true })
10
36
  fs.writeFileSync(preCommitHookPath, [
11
37
  '#!/usr/bin/env bash',
12
38
  'set -euo pipefail',
13
39
  'npm run lint',
40
+ 'npm run coverage',
14
41
  '',
15
42
  ].join('\n'), { mode: 0o755 })
43
+ return true
44
+ }
45
+
46
+ export function runInstallGitHooksScript(currentScriptPath = process.argv[1], repositoryRoot = process.cwd()) {
47
+ if (currentScriptPath !== fileURLToPath(import.meta.url)) {
48
+ return false
49
+ }
50
+
51
+ return installGitHooks(repositoryRoot)
16
52
  }
53
+
54
+ runInstallGitHooksScript()
@@ -1,10 +1,35 @@
1
1
  import process from 'node:process'
2
- import { runPortableLintFromCommandLine } from '../dist/tools/lint.js'
2
+ import { fileURLToPath } from 'node:url'
3
3
 
4
- try {
5
- process.exitCode = await runPortableLintFromCommandLine(process.argv.slice(2))
6
- } catch (error) {
7
- const message = error instanceof Error ? error.message : `Expected an error message. Got ${String(error)}.`
8
- process.stderr.write(`${message}\n`)
9
- process.exitCode = 1
4
+ async function runBundledPortableLint(commandLineArguments) {
5
+ const lintModule = await import('../dist/tools/lint.js')
6
+ return lintModule.runPortableLintFromCommandLine(commandLineArguments)
10
7
  }
8
+
9
+ export function readErrorMessage(error) {
10
+ if (error instanceof Error) {
11
+ return error.message
12
+ }
13
+
14
+ return `Expected an error message. Got ${String(error)}.`
15
+ }
16
+
17
+ export async function runLintScript(commandLineArguments, stderr = process.stderr, runPortableLint = runBundledPortableLint) {
18
+ try {
19
+ return await runPortableLint(commandLineArguments)
20
+ } catch (error) {
21
+ stderr.write(`${readErrorMessage(error)}\n`)
22
+ return 1
23
+ }
24
+ }
25
+
26
+ export async function runLintScriptMain(currentScriptPath = process.argv[1], commandLineArguments = process.argv.slice(2), runPortableLint = runBundledPortableLint) {
27
+ if (currentScriptPath !== fileURLToPath(import.meta.url)) {
28
+ return false
29
+ }
30
+
31
+ process.exitCode = await runLintScript(commandLineArguments, process.stderr, runPortableLint)
32
+ return true
33
+ }
34
+
35
+ await runLintScriptMain()
@@ -154,13 +154,13 @@ export default tseslint.config(
154
154
  sourceType: 'module',
155
155
  parserOptions: {
156
156
  projectService: {
157
- allowDefaultProject: ['src/tools/*.spec.ts', 'src/tools/*-test-support.ts', 'vitest.config.ts'],
157
+ allowDefaultProject: ['vitest.config.ts'],
158
+ maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 50,
158
159
  },
159
160
  tsconfigRootDir: lintRepositoryRoot,
160
161
  },
161
162
  },
162
163
  rules: {
163
- 'import/extensions': ['error', 'ignorePackages', { ts: 'never', tsx: 'never', js: 'always', json: 'always' }],
164
164
  'custom/no-generic-names': 'error',
165
165
  'no-warning-comments': 'off',
166
166
  'multiline-comment-style': 'off',
@@ -35,28 +35,6 @@ const isForbiddenName = (name) => {
35
35
  })
36
36
  }
37
37
 
38
- const createFilenameMessage = (filename) => {
39
- const forbiddenWord = findForbiddenWord(filename)
40
-
41
- if (!forbiddenWord) {
42
- return 'Generic filename. Use domain-specific naming.'
43
- }
44
-
45
- const guidance = forbiddenWordSuggestions[forbiddenWord]
46
- return `Generic word "${forbiddenWord}" in filename. ${guidance}`
47
- }
48
-
49
- const createClassMessage = (className) => {
50
- const forbiddenWord = findForbiddenWord(className)
51
-
52
- if (!forbiddenWord) {
53
- return `Generic class name "${className}". Use domain-specific naming.`
54
- }
55
-
56
- const guidance = forbiddenWordSuggestions[forbiddenWord]
57
- return `Generic word "${forbiddenWord}" in class "${className}". ${guidance}`
58
- }
59
-
60
38
  const noGenericNames = {
61
39
  meta: {
62
40
  type: 'problem',
@@ -80,7 +58,7 @@ const noGenericNames = {
80
58
 
81
59
  context.report({
82
60
  node: node.id,
83
- message: createClassMessage(node.id.name),
61
+ message: `Generic word "${findForbiddenWord(node.id.name)}" in class "${node.id.name}". ${forbiddenWordSuggestions[findForbiddenWord(node.id.name)]}`,
84
62
  })
85
63
  },
86
64
  Program(node) {
@@ -90,7 +68,7 @@ const noGenericNames = {
90
68
 
91
69
  context.report({
92
70
  node,
93
- message: createFilenameMessage(filename),
71
+ message: `Generic word "${findForbiddenWord(filename)}" in filename. ${forbiddenWordSuggestions[findForbiddenWord(filename)]}`,
94
72
  })
95
73
  },
96
74
  }