@nt-ai-lab/opencode-skillz 0.3.14 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +31 -0
  2. package/agents/default.md +4 -0
  3. package/agents/tdd.md +1 -0
  4. package/commands/plan.md +135 -45
  5. package/commands/resolve-pr-feedback.md +138 -0
  6. package/commands/review-pr.md +13 -20
  7. package/dist/commands/dont-stop/hooks.js +69 -35
  8. package/dist/git-workflow-gates.d.ts +21 -0
  9. package/dist/git-workflow-gates.js +103 -0
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +4 -4
  12. package/dist/plugin-registry/agents.js +36 -25
  13. package/dist/plugin-registry/commands.js +13 -10
  14. package/dist/plugin-registry/index.js +32 -1
  15. package/dist/plugin-registry/markdown.js +26 -11
  16. package/dist/tools/create-pr-tool.d.ts +4 -0
  17. package/dist/tools/create-pr-tool.js +22 -0
  18. package/dist/tools/infra/lint/guidance.d.ts +8 -0
  19. package/dist/tools/infra/lint/guidance.js +98 -0
  20. package/dist/tools/infra/lint/review.d.ts +19 -0
  21. package/dist/tools/infra/lint/review.js +53 -0
  22. package/dist/tools/infra/pull-request/create-draft-pull-request.d.ts +11 -0
  23. package/dist/tools/infra/pull-request/create-draft-pull-request.js +115 -0
  24. package/dist/tools/infra/pull-request/feedback.d.ts +14 -0
  25. package/dist/tools/infra/pull-request/feedback.js +280 -0
  26. package/dist/tools/infra/source-control/changed-files.d.ts +20 -0
  27. package/dist/tools/infra/source-control/changed-files.js +78 -0
  28. package/dist/tools/infra/vitest-coverage/command.d.ts +16 -0
  29. package/dist/tools/infra/vitest-coverage/command.js +85 -0
  30. package/dist/tools/infra/vitest-coverage/review.d.ts +50 -0
  31. package/dist/tools/infra/vitest-coverage/review.js +298 -0
  32. package/dist/tools/infra/vitest-coverage/test-support.d.ts +15 -0
  33. package/dist/tools/infra/vitest-coverage/test-support.js +156 -0
  34. package/dist/tools/lint.d.ts +3 -13
  35. package/dist/tools/lint.js +56 -16
  36. package/dist/tools/pull-request-feedback-tool.d.ts +5 -0
  37. package/dist/tools/pull-request-feedback-tool.js +23 -0
  38. package/dist/tools/vitest-coverage-tool.d.ts +4 -0
  39. package/dist/tools/vitest-coverage-tool.js +31 -0
  40. package/dist/types.d.ts +8 -0
  41. package/package.json +10 -3
  42. package/scripts/check-tools-folder-boundary.mjs +78 -0
  43. package/scripts/install-git-hooks.mjs +54 -0
  44. package/scripts/lint-ts.mjs +32 -7
  45. package/scripts/living-architecture-eslint.config.mjs +4 -2
  46. package/scripts/no-generic-names-eslint-rule.mjs +2 -24
  47. package/scripts/no-generic-names-eslint-rule.mjs.d.ts +28 -0
@@ -0,0 +1,21 @@
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 ToolExecutionInput {
11
+ tool: string;
12
+ }
13
+ export interface ToolExecutionOutput {
14
+ args: Record<string, unknown>;
15
+ }
16
+ export interface GitWorkflowGate {
17
+ beforeToolExecution(input: ToolExecutionInput, output: ToolExecutionOutput): void;
18
+ recordLintedFiles(filePaths: string[]): void;
19
+ }
20
+ export declare const directPullRequestCreationBlockedMessage: string;
21
+ export declare function createGitWorkflowGate(repositoryRoot: string, commandRunner: CommandRunner): GitWorkflowGate;
@@ -0,0 +1,103 @@
1
+ export const directPullRequestCreationBlockedMessage = [
2
+ "Direct gh pr create is banned for this workspace.",
3
+ "Use nt_skillz_create_pr instead.",
4
+ ].join("\n");
5
+ class GitWorkflowGateError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ }
9
+ }
10
+ function readBashCommandText(value) {
11
+ if (typeof value !== "string") {
12
+ throw new GitWorkflowGateError(`Expected bash command text. Got ${String(value)}.`);
13
+ }
14
+ return value.trim();
15
+ }
16
+ function readCommandFailureMessage(commandResult) {
17
+ const stderr = commandResult.stderr.trim();
18
+ if (stderr) {
19
+ return stderr;
20
+ }
21
+ const stdout = commandResult.stdout.trim();
22
+ if (stdout) {
23
+ return stdout;
24
+ }
25
+ return `exit status ${commandResult.status}`;
26
+ }
27
+ function isDirectPullRequestCreation(commandText) {
28
+ return /(?:^|[;&|()]|\s)gh\s+pr\s+create(?:\s|$|[;&|()])/.test(commandText);
29
+ }
30
+ function isGitCommit(commandText) {
31
+ return /(?:^|[;&|()]|\s)git\s+commit(?:\s|$|[;&|()])/.test(commandText);
32
+ }
33
+ function normalizeCommandOutput(commandResult, description) {
34
+ if (commandResult.errorMessage) {
35
+ throw new GitWorkflowGateError(`Expected ${description} to run. Got ${commandResult.errorMessage}.`);
36
+ }
37
+ if (commandResult.status !== 0) {
38
+ throw new GitWorkflowGateError(`Expected ${description} to succeed. Got ${readCommandFailureMessage(commandResult)}.`);
39
+ }
40
+ return commandResult.stdout.trim();
41
+ }
42
+ function isTypeScriptFilePath(filePath) {
43
+ return filePath.endsWith(".ts") || filePath.endsWith(".tsx");
44
+ }
45
+ function createUnlintedCommitMessage(filePaths) {
46
+ return [
47
+ "Commit blocked: TypeScript files changed without nt_skillz_lint validation.",
48
+ `Run nt_skillz_lint with files: ${JSON.stringify(filePaths)}`,
49
+ "Then retry the commit.",
50
+ ].join("\n");
51
+ }
52
+ export function createGitWorkflowGate(repositoryRoot, commandRunner) {
53
+ const lintedFingerprints = new Map();
54
+ function runGit(commandArguments, description) {
55
+ return normalizeCommandOutput(commandRunner.run("git", commandArguments, repositoryRoot), description);
56
+ }
57
+ function readWorkingTreeFingerprint(filePath) {
58
+ return runGit(["hash-object", "--", filePath], `working tree hash for ${filePath}`);
59
+ }
60
+ function readStagedFingerprint(filePath) {
61
+ return runGit(["rev-parse", `:${filePath}`], `staged hash for ${filePath}`);
62
+ }
63
+ function readStagedTypeScriptFilePaths() {
64
+ const output = runGit([
65
+ "diff",
66
+ "--name-only",
67
+ "--cached",
68
+ "--diff-filter=ACMR",
69
+ "--",
70
+ "*.ts",
71
+ "*.tsx",
72
+ ], "staged TypeScript file discovery");
73
+ return output.split("\n").map((filePath) => filePath.trim()).filter(Boolean);
74
+ }
75
+ function ensureStagedTypeScriptFilesAreLinted() {
76
+ const stagedFilePaths = readStagedTypeScriptFilePaths();
77
+ const unlintedFilePaths = stagedFilePaths.filter((filePath) => lintedFingerprints.get(filePath) !== readStagedFingerprint(filePath));
78
+ if (unlintedFilePaths.length === 0) {
79
+ return;
80
+ }
81
+ throw new GitWorkflowGateError(createUnlintedCommitMessage(unlintedFilePaths));
82
+ }
83
+ return {
84
+ beforeToolExecution(input, output) {
85
+ if (input.tool !== "bash") {
86
+ return;
87
+ }
88
+ const commandText = readBashCommandText(output.args.command);
89
+ if (isDirectPullRequestCreation(commandText)) {
90
+ throw new GitWorkflowGateError(directPullRequestCreationBlockedMessage);
91
+ }
92
+ if (isGitCommit(commandText)) {
93
+ ensureStagedTypeScriptFilesAreLinted();
94
+ }
95
+ },
96
+ recordLintedFiles(filePaths) {
97
+ const typeScriptFilePaths = filePaths.filter(isTypeScriptFilePath);
98
+ for (const filePath of typeScriptFilePaths) {
99
+ lintedFingerprints.set(filePath, readWorkingTreeFingerprint(filePath));
100
+ }
101
+ },
102
+ };
103
+ }
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,19 +8,48 @@ 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
+ const parentAgentName = getParentAgentName(rawAgent);
19
+ if (parentAgentName && rawAgents[parentAgentName]?.body) {
20
+ promptParts.push(rawAgents[parentAgentName].body);
21
+ }
22
+ if (rawAgent.body) {
23
+ promptParts.push(rawAgent.body);
24
+ }
25
+ }
26
+ function appendPreloadedCommandPromptParts(promptParts, commandNames, commands) {
27
+ for (const commandName of commandNames) {
28
+ const command = commands[commandName];
29
+ if (!command?.template)
30
+ continue;
31
+ promptParts.push(`[Preloaded command /${commandName}]\n${materializePreloadedTemplate(command.template)}`);
32
+ }
33
+ }
34
+ function setOptionalAgentProperties(agent, meta) {
35
+ if (typeof meta.description === "string")
36
+ agent.description = meta.description;
37
+ if (typeof meta.mode === "string")
38
+ agent.mode = meta.mode;
39
+ if (typeof meta.model === "string")
40
+ agent.model = meta.model;
41
+ if (typeof meta.color === "string")
42
+ agent.color = meta.color;
12
43
  }
13
44
  export function registerAgents(agentConfig, pluginRoot, commands) {
14
45
  const rawAgents = readMarkdownEntries(pluginRoot, "agents");
15
46
  function collectPreloadedCommands(agentName, stack = new Set()) {
16
47
  const rawAgent = rawAgents[agentName];
17
- if (!rawAgent)
18
- return [];
19
48
  if (stack.has(agentName))
20
49
  return parseCsvList(rawAgent.meta.preload_commands);
21
50
  stack.add(agentName);
22
51
  const merged = [];
23
- const parentAgentName = typeof rawAgent.meta.extends === "string" ? rawAgent.meta.extends.trim() : "";
52
+ const parentAgentName = getParentAgentName(rawAgent);
24
53
  if (parentAgentName && rawAgents[parentAgentName]) {
25
54
  merged.push(...collectPreloadedCommands(parentAgentName, stack));
26
55
  }
@@ -32,30 +61,12 @@ export function registerAgents(agentConfig, pluginRoot, commands) {
32
61
  if (agentConfig[name])
33
62
  continue;
34
63
  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
- }
64
+ appendAgentPromptParts(promptParts, rawAgents, name);
65
+ appendPreloadedCommandPromptParts(promptParts, collectPreloadedCommands(name), commands);
48
66
  const agent = {
49
67
  prompt: promptParts.join("\n\n").trim(),
50
68
  };
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;
69
+ setOptionalAgentProperties(agent, rawAgent.meta);
59
70
  agentConfig[name] = agent;
60
71
  }
61
72
  }
@@ -4,31 +4,34 @@ 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");
11
19
  const commands = {};
12
20
  function buildComposedTemplate(name, stack = new Set()) {
13
21
  const rawCommand = rawCommands[name];
14
- if (!rawCommand)
15
- return "";
16
22
  if (stack.has(name))
17
23
  return rawCommand.body;
18
24
  stack.add(name);
19
- let template = rawCommand.body;
20
25
  const composeAfterName = normalizeCommandReference(rawCommand.meta.compose_after);
21
26
  const composedCommand = rawCommands[composeAfterName];
22
27
  if (composeAfterName && composedCommand) {
23
28
  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
- }
29
+ const template = composeTemplate(rawCommand.body, composedTemplate);
30
+ stack.delete(name);
31
+ return template.trim();
29
32
  }
30
33
  stack.delete(name);
31
- return template.trim();
34
+ return rawCommand.body.trim();
32
35
  }
33
36
  for (const [name, rawCommand] of Object.entries(rawCommands)) {
34
37
  const description = typeof rawCommand.meta.description === "string" ? rawCommand.meta.description : `Run /${name}`;
@@ -1,12 +1,43 @@
1
+ import process from "node:process";
1
2
  import { createDontStopHooks } from "../commands/dont-stop/index.js";
3
+ import { createGitWorkflowGate, } from "../git-workflow-gates.js";
4
+ import { createPullRequestTool, } from "../tools/create-pr-tool.js";
2
5
  import { LINT_TOOL_NAME, lintTool, } from "../tools/lint.js";
6
+ import { PULL_REQUEST_FEEDBACK_TOOL_NAME, pullRequestFeedbackTool, } from "../tools/pull-request-feedback-tool.js";
7
+ import { VITEST_COVERAGE_TOOL_NAME, vitestCoverageTool, } from "../tools/vitest-coverage-tool.js";
3
8
  import { registerAgents } from "./agents.js";
4
9
  import { registerCommands } from "./commands.js";
10
+ import { childProcessCommandRunner } from "../tools/infra/source-control/changed-files.js";
11
+ function readLintedFilePaths(request) {
12
+ if (!Array.isArray(request.files)) {
13
+ return [];
14
+ }
15
+ return request.files.filter((filePath) => typeof filePath === "string");
16
+ }
17
+ function createGateAwareLintTool(gate) {
18
+ return {
19
+ ...lintTool,
20
+ async execute(request, context) {
21
+ const result = await lintTool.execute(request, context);
22
+ gate.recordLintedFiles(readLintedFilePaths(request));
23
+ return result;
24
+ },
25
+ };
26
+ }
5
27
  export function createPluginRegistry(input, pluginRoot) {
6
28
  const dontStopHooks = createDontStopHooks(input.client);
29
+ const gitWorkflowGate = createGitWorkflowGate(input.worktree ?? process.cwd(), childProcessCommandRunner);
7
30
  return {
8
31
  ...dontStopHooks,
9
- tool: { [LINT_TOOL_NAME]: lintTool },
32
+ tool: {
33
+ nt_skillz_create_pr: createPullRequestTool,
34
+ [LINT_TOOL_NAME]: createGateAwareLintTool(gitWorkflowGate),
35
+ [PULL_REQUEST_FEEDBACK_TOOL_NAME]: pullRequestFeedbackTool,
36
+ [VITEST_COVERAGE_TOOL_NAME]: vitestCoverageTool,
37
+ },
38
+ "tool.execute.before": async (hookInput, hookOutput) => {
39
+ gitWorkflowGate.beforeToolExecution(hookInput, hookOutput);
40
+ },
10
41
  config: async (config) => {
11
42
  config.command ??= {};
12
43
  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,4 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin";
2
+ import type { CommandRunner } from "../git-workflow-gates.js";
3
+ export declare function createPullRequestToolWithRunner(commandRunner: CommandRunner): ToolDefinition;
4
+ export declare const createPullRequestTool: ToolDefinition;
@@ -0,0 +1,22 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { createDraftPullRequest } from "./infra/pull-request/create-draft-pull-request.js";
3
+ import { childProcessCommandRunner } from "./infra/source-control/changed-files.js";
4
+ export function createPullRequestToolWithRunner(commandRunner) {
5
+ return tool({
6
+ description: "Create a validated draft pull request with required OpenCode workflow gates.",
7
+ args: {
8
+ base: tool.schema.string().describe("Base branch for the pull request."),
9
+ title: tool.schema.string().describe("Pull request title."),
10
+ problem: tool.schema.string().describe("Problem section content."),
11
+ solution: tool.schema.string().describe("Solution section content."),
12
+ acceptanceCriteria: tool.schema.string().describe("Acceptance Criteria section content."),
13
+ architectureAndSoftwareDesign: tool.schema.string().describe("Architecture and software design section content."),
14
+ },
15
+ async execute(request, context) {
16
+ return {
17
+ output: createDraftPullRequest(context.worktree, request, commandRunner),
18
+ };
19
+ },
20
+ });
21
+ }
22
+ export const createPullRequestTool = createPullRequestToolWithRunner(childProcessCommandRunner);
@@ -0,0 +1,8 @@
1
+ interface LintMessageWithRule {
2
+ ruleId: string | null;
3
+ }
4
+ interface LintResultWithMessages {
5
+ messages: readonly LintMessageWithRule[];
6
+ }
7
+ export declare function createLintFailureGuidance(lintResults: readonly LintResultWithMessages[]): string;
8
+ export {};
@@ -0,0 +1,98 @@
1
+ const genericLintFailureGuidance = [
2
+ "Lint remediation guidance:",
3
+ "- Do not sacrifice code quality or test coverage to satisfy lint rules.",
4
+ "- These rules are not objectives; they are signs that code needs to be split, simplified, or clarified.",
5
+ "- Fix the underlying design or test issue instead of deleting assertions, disabling rules, or reducing coverage.",
6
+ ];
7
+ const targetedLintGuidance = [
8
+ {
9
+ ruleIds: ["vitest/max-expects"],
10
+ guidance: [
11
+ "Do not delete required assertions or weaken expected values to reduce assertion count.",
12
+ "If several assertions describe one observable result, prefer one whole-result assertion such as `toEqual`, `toStrictEqual`, `toMatchObject`, or a domain-specific matcher.",
13
+ "If the test verifies multiple behaviours, split it into separate tests with outcome-focused names.",
14
+ "Do not combine unrelated checks into one object assertion just to satisfy this rule.",
15
+ ],
16
+ },
17
+ {
18
+ ruleIds: ["max-lines", "sonarjs/max-lines", "sonarjs/max-lines-per-function"],
19
+ guidance: [
20
+ "Do not delete required behavior, tests, setup, edge cases, or assertions to reduce line count.",
21
+ "Split by cohesive responsibility instead: production behavior, test fixture construction, assertions, adapters, or domain concepts.",
22
+ "For long spec files, extract fixture builders or split scenarios by behavior.",
23
+ "For long source files, extract named concepts that can be tested independently.",
24
+ "Do not move unrelated code into vague files such as utils, helpers, common, shared, or lib.",
25
+ ],
26
+ },
27
+ {
28
+ ruleIds: [
29
+ "complexity",
30
+ "max-depth",
31
+ "sonarjs/cognitive-complexity",
32
+ "sonarjs/cyclomatic-complexity",
33
+ "sonarjs/nested-control-flow",
34
+ ],
35
+ guidance: [
36
+ "Do not remove branches, states, error handling, or edge cases to reduce complexity.",
37
+ "Reduce complexity by making decisions easier to read without changing behavior.",
38
+ "Use guard clauses, early returns, named predicates, or discriminated unions where they clarify the code.",
39
+ "If complexity comes from multiple states, model those states directly instead of stacking conditionals.",
40
+ "Keep all existing behavior, tests, and edge cases intact.",
41
+ ],
42
+ },
43
+ {
44
+ ruleIds: [
45
+ "@typescript-eslint/no-explicit-any",
46
+ "@typescript-eslint/no-unsafe-assignment",
47
+ "@typescript-eslint/no-unsafe-call",
48
+ "@typescript-eslint/no-unsafe-member-access",
49
+ "@typescript-eslint/no-unsafe-return",
50
+ "@typescript-eslint/consistent-type-assertions",
51
+ "@typescript-eslint/no-non-null-assertion",
52
+ "sonarjs/no-return-type-any",
53
+ ],
54
+ guidance: [
55
+ "Do not replace `any` with `unknown as X`, non-null assertions, type assertions, or broader unsafe types.",
56
+ "Add precise types at the boundary where the value enters the system.",
57
+ "For external input, parse with Zod or an existing runtime validator.",
58
+ "For impossible states, change the type model instead of silencing the error.",
59
+ "Keep behavior unchanged and preserve existing validation.",
60
+ ],
61
+ },
62
+ {
63
+ ruleIds: [
64
+ "@eslint-community/eslint-comments/no-use",
65
+ "no-inline-comments",
66
+ "sonarjs/no-commented-code",
67
+ "sonarjs/no-sonar-comments",
68
+ "@typescript-eslint/ban-ts-comment",
69
+ ],
70
+ guidance: [
71
+ "Do not add code comments. They make the code noisy and are a workaround for poor code. Make code intention revealing so that comments are not necessary.",
72
+ "Do not add lint or TypeScript suppressions. The rules are non-negotiable. If the solution cannot be implemented while following the codebase rules, stop and ask for help.",
73
+ ],
74
+ },
75
+ ];
76
+ function isPresentRuleId(ruleId) {
77
+ return typeof ruleId === "string" && ruleId.length > 0;
78
+ }
79
+ function readTriggeredRuleIds(lintResults) {
80
+ return new Set(lintResults.flatMap((lintResult) => {
81
+ return lintResult.messages.map((message) => message.ruleId).filter(isPresentRuleId);
82
+ }));
83
+ }
84
+ export function createLintFailureGuidance(lintResults) {
85
+ const triggeredRuleIds = readTriggeredRuleIds(lintResults);
86
+ const specificGuidance = targetedLintGuidance
87
+ .filter((targetedGuidance) => targetedGuidance.ruleIds.some((ruleId) => triggeredRuleIds.has(ruleId)))
88
+ .flatMap((targetedGuidance) => targetedGuidance.guidance);
89
+ if (specificGuidance.length === 0) {
90
+ return genericLintFailureGuidance.join("\n");
91
+ }
92
+ return [
93
+ ...genericLintFailureGuidance,
94
+ "",
95
+ "Rule-specific guidance:",
96
+ ...specificGuidance.map((guidance) => `- ${guidance}`),
97
+ ].join("\n");
98
+ }
@@ -0,0 +1,19 @@
1
+ import { type CommandRunner } from "../source-control/changed-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 "../source-control/changed-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
+ }
@@ -0,0 +1,11 @@
1
+ import type { CommandRunner } from "../../../git-workflow-gates.js";
2
+ export declare const CREATE_PULL_REQUEST_TOOL_NAME = "nt_skillz_create_pr";
3
+ export interface CreatePullRequestRequest {
4
+ base: string;
5
+ title: string;
6
+ problem: string;
7
+ solution: string;
8
+ acceptanceCriteria: string;
9
+ architectureAndSoftwareDesign: string;
10
+ }
11
+ export declare function createDraftPullRequest(repositoryRoot: string, request: CreatePullRequestRequest, commandRunner: CommandRunner): string;