@nt-ai-lab/opencode-skillz 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/implement.md +17 -0
- package/dist/plugin-registry/index.js +2 -0
- package/dist/tools/lint.d.ts +15 -0
- package/dist/tools/lint.js +111 -0
- package/dist/types.d.ts +2 -0
- package/package.json +15 -2
- package/scripts/lint-ts.mjs +220 -0
- package/scripts/lint-ts.sh +5 -0
- package/scripts/living-architecture-eslint.config.mjs +275 -0
- package/scripts/no-generic-names-eslint-rule.mjs +100 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Implement the requested changes or specified document with small-step lint verification
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Implement:
|
|
6
|
+
$ARGUMENTS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
Implement the required task to the highest standards possible. Do not take shortcuts, do not rush, do not jump to the easiest solution to make the code pass. Think carefully, explore options and write the code that is easiest to read, easiest change, and will not break in production.
|
|
10
|
+
|
|
11
|
+
## Linting
|
|
12
|
+
|
|
13
|
+
After each small TypeScript code change, call `nt_skillz_lint` for the changed `.ts` or `.tsx` file or files.
|
|
14
|
+
|
|
15
|
+
- all lint errors on new code must be addressed before continuing
|
|
16
|
+
- if the lint fails on existing code, ignore the error unless it is very close to the new code
|
|
17
|
+
- line-length limits do not count as existing code; if new code causes a file-length lint error, it must be fixed
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createDontStopHooks } from "../commands/dont-stop/index.js";
|
|
2
|
+
import { LINT_TOOL_NAME, lintTool, } from "../tools/lint.js";
|
|
2
3
|
import { registerAgents } from "./agents.js";
|
|
3
4
|
import { registerCommands } from "./commands.js";
|
|
4
5
|
export function createPluginRegistry(input, pluginRoot) {
|
|
5
6
|
const dontStopHooks = createDontStopHooks(input.client);
|
|
6
7
|
return {
|
|
7
8
|
...dontStopHooks,
|
|
9
|
+
tool: { [LINT_TOOL_NAME]: lintTool },
|
|
8
10
|
config: async (config) => {
|
|
9
11
|
config.command ??= {};
|
|
10
12
|
config.agent ??= {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ToolContext } from "@opencode-ai/plugin";
|
|
2
|
+
export declare const LINT_TOOL_NAME = "nt_skillz_lint";
|
|
3
|
+
export declare const lintTool: {
|
|
4
|
+
description: string;
|
|
5
|
+
args: {
|
|
6
|
+
files: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
7
|
+
base: import("zod").ZodOptional<import("zod").ZodString>;
|
|
8
|
+
head: import("zod").ZodOptional<import("zod").ZodString>;
|
|
9
|
+
};
|
|
10
|
+
execute(args: {
|
|
11
|
+
files?: string[] | undefined;
|
|
12
|
+
base?: string | undefined;
|
|
13
|
+
head?: string | undefined;
|
|
14
|
+
}, context: ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
15
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { tool, } from "@opencode-ai/plugin";
|
|
4
|
+
class InvalidLintRequestError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
class LintRunFailedError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const lintScriptPath = fileURLToPath(new URL("../../scripts/lint-ts.mjs", import.meta.url));
|
|
15
|
+
export const LINT_TOOL_NAME = "nt_skillz_lint";
|
|
16
|
+
function validateLintRequest(request) {
|
|
17
|
+
if (request.base && request.files?.length) {
|
|
18
|
+
throw new InvalidLintRequestError("Expected either file paths or base reference. Got both.");
|
|
19
|
+
}
|
|
20
|
+
if (!request.base && request.head) {
|
|
21
|
+
throw new InvalidLintRequestError("Expected head reference to be used together with base reference.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function createLintCommandArguments(request) {
|
|
25
|
+
const commandArguments = [lintScriptPath];
|
|
26
|
+
if (request.base) {
|
|
27
|
+
commandArguments.push("--base", request.base);
|
|
28
|
+
if (request.head) {
|
|
29
|
+
commandArguments.push("--head", request.head);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const filePath of request.files ?? []) {
|
|
33
|
+
commandArguments.push(filePath);
|
|
34
|
+
}
|
|
35
|
+
return commandArguments;
|
|
36
|
+
}
|
|
37
|
+
function createLintTitle(request) {
|
|
38
|
+
if (request.files?.length) {
|
|
39
|
+
return `Lint ${request.files.length} TypeScript file(s)`;
|
|
40
|
+
}
|
|
41
|
+
if (request.base) {
|
|
42
|
+
return `Lint TypeScript changes from ${request.base}`;
|
|
43
|
+
}
|
|
44
|
+
return "Lint current TypeScript files";
|
|
45
|
+
}
|
|
46
|
+
function createLintOutput(standardOutputParts, standardErrorParts) {
|
|
47
|
+
const sections = [standardOutputParts.join("").trim(), standardErrorParts.join("").trim()].filter(Boolean);
|
|
48
|
+
return sections.join("\n");
|
|
49
|
+
}
|
|
50
|
+
function normalizeExitCode(value) {
|
|
51
|
+
if (typeof value === "number") {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
function runLintCommand(request, context) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const lintCommand = spawn(process.execPath, createLintCommandArguments(request), {
|
|
59
|
+
cwd: context.worktree,
|
|
60
|
+
signal: context.abort,
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
});
|
|
63
|
+
const standardOutputParts = [];
|
|
64
|
+
const standardErrorParts = [];
|
|
65
|
+
lintCommand.stdout.on("data", (chunk) => {
|
|
66
|
+
standardOutputParts.push(chunk.toString());
|
|
67
|
+
});
|
|
68
|
+
lintCommand.stderr.on("data", (chunk) => {
|
|
69
|
+
standardErrorParts.push(chunk.toString());
|
|
70
|
+
});
|
|
71
|
+
lintCommand.on("error", (error) => {
|
|
72
|
+
reject(error);
|
|
73
|
+
});
|
|
74
|
+
lintCommand.on("close", (exitCode) => {
|
|
75
|
+
resolve({
|
|
76
|
+
exitCode: normalizeExitCode(exitCode),
|
|
77
|
+
output: createLintOutput(standardOutputParts, standardErrorParts),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function createLintFailureMessage(outcome) {
|
|
83
|
+
if (outcome.output) {
|
|
84
|
+
return outcome.output;
|
|
85
|
+
}
|
|
86
|
+
return `Lint failed with exit code ${outcome.exitCode}.`;
|
|
87
|
+
}
|
|
88
|
+
export const lintTool = tool({
|
|
89
|
+
description: "Run bundled TypeScript lint rules against current project files.",
|
|
90
|
+
args: {
|
|
91
|
+
files: tool.schema.array(tool.schema.string()).optional().describe("Relative .ts or .tsx file paths to lint."),
|
|
92
|
+
base: tool.schema.string().optional().describe("Base git reference for PR-style changed-file linting."),
|
|
93
|
+
head: tool.schema.string().optional().describe("Optional head git reference used with base."),
|
|
94
|
+
},
|
|
95
|
+
async execute(request, context) {
|
|
96
|
+
validateLintRequest(request);
|
|
97
|
+
context.metadata({ title: createLintTitle(request) });
|
|
98
|
+
const outcome = await runLintCommand(request, context);
|
|
99
|
+
if (outcome.exitCode !== 0) {
|
|
100
|
+
throw new LintRunFailedError(createLintFailureMessage(outcome));
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
output: outcome.output || "Lint passed.",
|
|
104
|
+
metadata: {
|
|
105
|
+
base: request.base ?? null,
|
|
106
|
+
fileCount: request.files?.length ?? 0,
|
|
107
|
+
head: request.head ?? null,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
});
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@opencode-ai/plugin";
|
|
1
2
|
export interface CommandDefinition {
|
|
2
3
|
description?: string;
|
|
3
4
|
template: string;
|
|
@@ -69,6 +70,7 @@ export interface OpencodeClient {
|
|
|
69
70
|
}
|
|
70
71
|
export interface PluginHooks {
|
|
71
72
|
config?: (config: PluginConfig) => Promise<void>;
|
|
73
|
+
tool?: Record<string, ToolDefinition>;
|
|
72
74
|
"command.execute.before"?: (input: CommandExecuteBeforeInput, output: CommandExecuteBeforeOutput) => Promise<void>;
|
|
73
75
|
"experimental.chat.system.transform"?: (input: ChatSystemTransformInput, output: ChatSystemTransformOutput) => Promise<void>;
|
|
74
76
|
event?: (input: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nt-ai-lab/opencode-skillz",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Bundled OpenCode commands and agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -19,12 +19,25 @@
|
|
|
19
19
|
"dist",
|
|
20
20
|
"commands",
|
|
21
21
|
"agents",
|
|
22
|
+
"scripts",
|
|
22
23
|
"AGENTS.md",
|
|
23
24
|
"README.md"
|
|
24
25
|
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@opencode-ai/plugin": "^1.14.28"
|
|
28
|
+
},
|
|
25
29
|
"devDependencies": {
|
|
30
|
+
"@eslint-community/eslint-plugin-eslint-comments": "^4.5.0",
|
|
31
|
+
"@eslint/js": "^9.39.0",
|
|
32
|
+
"@stylistic/eslint-plugin": "^5.6.1",
|
|
33
|
+
"@vitest/eslint-plugin": "^1.0.1",
|
|
26
34
|
"@types/node": "^24.7.2",
|
|
27
|
-
"
|
|
35
|
+
"eslint": "^9.39.0",
|
|
36
|
+
"eslint-plugin-import": "^2.32.0",
|
|
37
|
+
"eslint-plugin-sonarjs": "^3.0.5",
|
|
38
|
+
"eslint-plugin-unicorn": "^62.0.0",
|
|
39
|
+
"typescript": "^5.9.3",
|
|
40
|
+
"typescript-eslint": "^8.50.1"
|
|
28
41
|
},
|
|
29
42
|
"publishConfig": {
|
|
30
43
|
"access": "public"
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { parseArgs } from 'node:util'
|
|
7
|
+
|
|
8
|
+
class UsageError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class GitCommandError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class MissingLocalDependencyError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class LintExecutionError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
|
33
|
+
const toolRepositoryRoot = path.resolve(scriptDirectory, '..')
|
|
34
|
+
const eslintCliPath = path.join(toolRepositoryRoot, 'node_modules', 'eslint', 'bin', 'eslint.js')
|
|
35
|
+
const eslintConfigPath = path.join(toolRepositoryRoot, 'scripts', 'living-architecture-eslint.config.mjs')
|
|
36
|
+
|
|
37
|
+
const printUsage = () => {
|
|
38
|
+
const usage = [
|
|
39
|
+
'Usage: ./scripts/lint-ts.sh [--repo PATH] [--base REF] [--head REF] [file ...]',
|
|
40
|
+
'',
|
|
41
|
+
'Examples:',
|
|
42
|
+
' ./scripts/lint-ts.sh --repo ../living-architecture --base origin/main',
|
|
43
|
+
' ./scripts/lint-ts.sh --repo ../living-architecture packages/example/src/example.ts',
|
|
44
|
+
' ./scripts/lint-ts.sh src/example.ts',
|
|
45
|
+
].join('\n')
|
|
46
|
+
|
|
47
|
+
process.stdout.write(`${usage}\n`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parseCommandLine = () => {
|
|
51
|
+
return parseArgs({
|
|
52
|
+
options: {
|
|
53
|
+
repo: { type: 'string' },
|
|
54
|
+
base: { type: 'string' },
|
|
55
|
+
head: { type: 'string' },
|
|
56
|
+
help: { type: 'boolean', short: 'h' },
|
|
57
|
+
},
|
|
58
|
+
allowPositionals: true,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const resolveDirectory = (directoryPath) => {
|
|
63
|
+
const absoluteDirectoryPath = path.resolve(directoryPath)
|
|
64
|
+
|
|
65
|
+
if (!fs.existsSync(absoluteDirectoryPath)) {
|
|
66
|
+
throw new UsageError(`Expected repository path to exist. Got ${absoluteDirectoryPath}.`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!fs.statSync(absoluteDirectoryPath).isDirectory()) {
|
|
70
|
+
throw new UsageError(`Expected repository path to be a directory. Got ${absoluteDirectoryPath}.`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return absoluteDirectoryPath
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ensureSupportedFilePath = (filePath) => {
|
|
77
|
+
if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throw new UsageError(`Expected a TypeScript file path ending in .ts or .tsx. Got ${filePath}.`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const runGit = (repositoryRoot, args) => {
|
|
85
|
+
const gitResult = spawnSync('git', ['-C', repositoryRoot, ...args], {
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if (gitResult.error) {
|
|
90
|
+
throw new GitCommandError(`Expected git command to run. Got ${gitResult.error.message}.`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (gitResult.status === 0) {
|
|
94
|
+
return gitResult.stdout
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const errorOutput = gitResult.stderr.trim() || gitResult.stdout.trim() || 'git command failed'
|
|
98
|
+
throw new GitCommandError(`Expected git command to succeed. Got ${errorOutput}.`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const readChangedTypeScriptFiles = (repositoryRoot, baseReference, headReference) => {
|
|
102
|
+
const diffRange = `${baseReference}...${headReference}`
|
|
103
|
+
const output = runGit(repositoryRoot, [
|
|
104
|
+
'diff',
|
|
105
|
+
'--name-only',
|
|
106
|
+
'--diff-filter=ACMR',
|
|
107
|
+
diffRange,
|
|
108
|
+
'--',
|
|
109
|
+
'*.ts',
|
|
110
|
+
'*.tsx',
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
return output.split('\n').filter(Boolean)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const readTrackedAndUntrackedTypeScriptFiles = (repositoryRoot) => {
|
|
117
|
+
const output = runGit(repositoryRoot, [
|
|
118
|
+
'ls-files',
|
|
119
|
+
'--cached',
|
|
120
|
+
'--others',
|
|
121
|
+
'--exclude-standard',
|
|
122
|
+
'--',
|
|
123
|
+
'*.ts',
|
|
124
|
+
'*.tsx',
|
|
125
|
+
])
|
|
126
|
+
|
|
127
|
+
return output.split('\n').filter(Boolean)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const resolveLintTargets = (repositoryRoot, baseReference, headReference, positionalArguments) => {
|
|
131
|
+
if (positionalArguments.length > 0) {
|
|
132
|
+
positionalArguments.forEach(ensureSupportedFilePath)
|
|
133
|
+
return positionalArguments
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (baseReference) {
|
|
137
|
+
return readChangedTypeScriptFiles(repositoryRoot, baseReference, headReference)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return readTrackedAndUntrackedTypeScriptFiles(repositoryRoot)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const ensureValidArguments = (baseReference, headReference, positionalArguments) => {
|
|
144
|
+
if (baseReference && positionalArguments.length > 0) {
|
|
145
|
+
throw new UsageError('Expected either explicit file paths or --base. Got both.')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!baseReference && headReference !== 'HEAD') {
|
|
149
|
+
throw new UsageError('Expected --head to be used together with --base.')
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ensureLocalDependencies = () => {
|
|
154
|
+
if (fs.existsSync(eslintCliPath)) {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
throw new MissingLocalDependencyError(
|
|
159
|
+
`Expected ESLint dependencies to be installed in ${toolRepositoryRoot}. Run npm install in this repository.`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const runEslint = (repositoryRoot, lintTargets) => {
|
|
164
|
+
const eslintResult = spawnSync(
|
|
165
|
+
process.execPath,
|
|
166
|
+
[eslintCliPath, '--no-config-lookup', '--no-warn-ignored', '--config', eslintConfigPath, ...lintTargets],
|
|
167
|
+
{
|
|
168
|
+
cwd: repositoryRoot,
|
|
169
|
+
env: {
|
|
170
|
+
...process.env,
|
|
171
|
+
NT_SKILLZ_LINT_REPO_ROOT: repositoryRoot,
|
|
172
|
+
},
|
|
173
|
+
stdio: 'inherit',
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if (eslintResult.error) {
|
|
178
|
+
throw new LintExecutionError(`Expected ESLint to run. Got ${eslintResult.error.message}.`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (typeof eslintResult.status === 'number') {
|
|
182
|
+
return eslintResult.status
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw new LintExecutionError(`Expected ESLint to exit with a status code. Got signal ${eslintResult.signal}.`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const main = () => {
|
|
189
|
+
const parsedArguments = parseCommandLine()
|
|
190
|
+
|
|
191
|
+
if (parsedArguments.values.help) {
|
|
192
|
+
printUsage()
|
|
193
|
+
return 0
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const repositoryRoot = resolveDirectory(parsedArguments.values.repo ?? process.cwd())
|
|
197
|
+
const baseReference = parsedArguments.values.base
|
|
198
|
+
const headReference = parsedArguments.values.head ?? 'HEAD'
|
|
199
|
+
const positionalArguments = parsedArguments.positionals
|
|
200
|
+
|
|
201
|
+
ensureValidArguments(baseReference, headReference, positionalArguments)
|
|
202
|
+
ensureLocalDependencies()
|
|
203
|
+
|
|
204
|
+
const lintTargets = resolveLintTargets(repositoryRoot, baseReference, headReference, positionalArguments)
|
|
205
|
+
|
|
206
|
+
if (lintTargets.length === 0) {
|
|
207
|
+
process.stdout.write('No TypeScript files matched.\n')
|
|
208
|
+
return 0
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return runEslint(repositoryRoot, lintTargets)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
process.exitCode = main()
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const message = error instanceof Error ? error.message : `Expected an error message. Got ${String(error)}.`
|
|
218
|
+
process.stderr.write(`${message}\n`)
|
|
219
|
+
process.exitCode = 1
|
|
220
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import js from '@eslint/js'
|
|
2
|
+
import tseslint from 'typescript-eslint'
|
|
3
|
+
import eslintComments from '@eslint-community/eslint-plugin-eslint-comments/configs'
|
|
4
|
+
import importPlugin from 'eslint-plugin-import'
|
|
5
|
+
import sonarjs from 'eslint-plugin-sonarjs'
|
|
6
|
+
import stylistic from '@stylistic/eslint-plugin'
|
|
7
|
+
import unicorn from 'eslint-plugin-unicorn'
|
|
8
|
+
import vitest from '@vitest/eslint-plugin'
|
|
9
|
+
import noGenericNames from './no-generic-names-eslint-rule.mjs'
|
|
10
|
+
|
|
11
|
+
class MissingLintRepositoryRootError extends Error {
|
|
12
|
+
constructor() {
|
|
13
|
+
super('Expected NT_SKILLZ_LINT_REPO_ROOT environment variable.')
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const lintRepositoryRoot = process.env.NT_SKILLZ_LINT_REPO_ROOT
|
|
18
|
+
|
|
19
|
+
if (!lintRepositoryRoot) {
|
|
20
|
+
throw new MissingLintRepositoryRootError()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const typescriptFiles = ['**/*.ts', '**/*.tsx']
|
|
24
|
+
const testFiles = ['**/*.spec.ts', '**/*.spec.tsx', '**/*.test.ts', '**/*.test.tsx']
|
|
25
|
+
const thinLayerFiles = ['**/entrypoint/**/*.ts', '**/commands/**/*.ts', '**/queries/**/*.ts']
|
|
26
|
+
const thinLayerIgnoredFiles = ['**/*.spec.ts', '**/*.test.ts']
|
|
27
|
+
const ignoredPaths = [
|
|
28
|
+
'**/dist',
|
|
29
|
+
'**/out-tsc',
|
|
30
|
+
'**/node_modules',
|
|
31
|
+
'**/.nx',
|
|
32
|
+
'*.config.ts',
|
|
33
|
+
'*.config.mjs',
|
|
34
|
+
'*.config.js',
|
|
35
|
+
'vitest.workspace.ts',
|
|
36
|
+
'**/*.d.ts',
|
|
37
|
+
'**/test-output',
|
|
38
|
+
'**/api/generated/**',
|
|
39
|
+
'**/.vitepress/cache/**',
|
|
40
|
+
'.riviere/**',
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const noLetRule = {
|
|
44
|
+
selector: 'VariableDeclaration[kind="let"]',
|
|
45
|
+
message: 'Use const. Avoid mutation.',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const noGenericErrorRule = {
|
|
49
|
+
selector: 'NewExpression[callee.name="Error"]',
|
|
50
|
+
message: 'Use custom precise error classes instead of generic Error or fail assertions in tests.',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const noEmptyStringFallbackRule = {
|
|
54
|
+
selector: 'LogicalExpression[operator="??"][right.type="Literal"][right.value=""]',
|
|
55
|
+
message:
|
|
56
|
+
'Banned: `?? \'\'` violates fail-fast principle. Never use empty string fallback. Options: (1) Fail fast if value should exist, (2) Handle undefined explicitly without empty string, (3) Create a type that represents emptiness.',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const restrictedSyntaxRules = ['error', noLetRule, noGenericErrorRule, noEmptyStringFallbackRule]
|
|
60
|
+
|
|
61
|
+
const entrypointRestrictedSyntaxRules = [
|
|
62
|
+
'error',
|
|
63
|
+
noLetRule,
|
|
64
|
+
noGenericErrorRule,
|
|
65
|
+
{
|
|
66
|
+
selector: 'FunctionDeclaration:not([parent.type="ExportNamedDeclaration"])',
|
|
67
|
+
message: 'Entrypoints must not define private functions. Move logic to commands/, queries/, or infra/.',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
selector: 'VariableDeclarator > ArrowFunctionExpression',
|
|
71
|
+
message: 'Entrypoints must not define private arrow functions. Move logic to commands/, queries/, or infra/.',
|
|
72
|
+
},
|
|
73
|
+
noEmptyStringFallbackRule,
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
const restrictedImportPatterns = [
|
|
77
|
+
{
|
|
78
|
+
group: ['*/utils/*', '*/utils', '*/utilities'],
|
|
79
|
+
message: 'No utils folders. Use domain-specific names.',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
group: ['*/helpers/*', '*/helpers'],
|
|
83
|
+
message: 'No helpers folders. Use domain-specific names.',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
group: ['*/common/*', '*/common'],
|
|
87
|
+
message: 'No common folders. Use domain-specific names.',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
group: ['*/shared/*', '*/shared'],
|
|
91
|
+
message: 'No shared folders. Use domain-specific names.',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
group: ['*/core/*', '*/core'],
|
|
95
|
+
message: 'No core folders. Use domain-specific names.',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
group: ['*/src/lib/*', '*/src/lib', './lib/*', './lib', '../lib/*', '../lib'],
|
|
99
|
+
message: 'No lib folders in projects. Use domain-specific names.',
|
|
100
|
+
},
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
const namingConventionRules = [
|
|
104
|
+
'error',
|
|
105
|
+
{
|
|
106
|
+
selector: 'variable',
|
|
107
|
+
format: ['camelCase'],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
selector: 'variable',
|
|
111
|
+
modifiers: ['const'],
|
|
112
|
+
format: ['camelCase', 'UPPER_CASE'],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
selector: 'function',
|
|
116
|
+
format: ['camelCase', 'PascalCase'],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
selector: 'parameter',
|
|
120
|
+
format: ['camelCase'],
|
|
121
|
+
leadingUnderscore: 'allow',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
selector: 'typeLike',
|
|
125
|
+
format: ['PascalCase'],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
selector: 'enumMember',
|
|
129
|
+
format: ['PascalCase'],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
selector: 'objectLiteralProperty',
|
|
133
|
+
format: null,
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
export default tseslint.config(
|
|
138
|
+
{
|
|
139
|
+
files: typescriptFiles,
|
|
140
|
+
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
ignores: ignoredPaths,
|
|
144
|
+
},
|
|
145
|
+
eslintComments.recommended,
|
|
146
|
+
{
|
|
147
|
+
rules: {
|
|
148
|
+
'@eslint-community/eslint-comments/no-use': ['error', { allow: [] }],
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
sonarjs.configs.recommended,
|
|
152
|
+
{
|
|
153
|
+
rules: {
|
|
154
|
+
'sonarjs/void-use': 'off',
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
files: typescriptFiles,
|
|
159
|
+
plugins: {
|
|
160
|
+
'@typescript-eslint': tseslint.plugin,
|
|
161
|
+
custom: {
|
|
162
|
+
rules: {
|
|
163
|
+
'no-generic-names': noGenericNames,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
import: importPlugin,
|
|
167
|
+
},
|
|
168
|
+
languageOptions: {
|
|
169
|
+
parser: tseslint.parser,
|
|
170
|
+
ecmaVersion: 2020,
|
|
171
|
+
sourceType: 'module',
|
|
172
|
+
parserOptions: {
|
|
173
|
+
projectService: true,
|
|
174
|
+
tsconfigRootDir: lintRepositoryRoot,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
rules: {
|
|
178
|
+
'import/extensions': ['error', 'never', { ts: 'never', tsx: 'never', js: 'never', json: 'always' }],
|
|
179
|
+
'custom/no-generic-names': 'error',
|
|
180
|
+
'no-warning-comments': 'off',
|
|
181
|
+
'multiline-comment-style': 'off',
|
|
182
|
+
'capitalized-comments': 'off',
|
|
183
|
+
'no-inline-comments': 'error',
|
|
184
|
+
'spaced-comment': 'off',
|
|
185
|
+
'no-negated-condition': 'error',
|
|
186
|
+
'no-restricted-syntax': restrictedSyntaxRules,
|
|
187
|
+
'prefer-const': 'error',
|
|
188
|
+
'no-var': 'error',
|
|
189
|
+
'@typescript-eslint/no-explicit-any': 'error',
|
|
190
|
+
'@typescript-eslint/no-unsafe-assignment': 'error',
|
|
191
|
+
'@typescript-eslint/no-unsafe-member-access': 'error',
|
|
192
|
+
'@typescript-eslint/no-unsafe-call': 'error',
|
|
193
|
+
'@typescript-eslint/no-unsafe-return': 'error',
|
|
194
|
+
'@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'never' }],
|
|
195
|
+
'@typescript-eslint/no-non-null-assertion': 'error',
|
|
196
|
+
'@typescript-eslint/prefer-includes': 'error',
|
|
197
|
+
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
|
198
|
+
'@typescript-eslint/prefer-optional-chain': 'error',
|
|
199
|
+
'@typescript-eslint/await-thenable': 'error',
|
|
200
|
+
'@typescript-eslint/no-floating-promises': 'error',
|
|
201
|
+
'@typescript-eslint/no-misused-promises': 'error',
|
|
202
|
+
'import/no-duplicates': 'error',
|
|
203
|
+
'no-restricted-imports': ['error', { patterns: restrictedImportPatterns }],
|
|
204
|
+
'max-lines': ['error', { max: 400, skipBlankLines: true, skipComments: true }],
|
|
205
|
+
'max-depth': ['error', 3],
|
|
206
|
+
complexity: ['error', 12],
|
|
207
|
+
'no-restricted-globals': [
|
|
208
|
+
'error',
|
|
209
|
+
{
|
|
210
|
+
name: '__dirname',
|
|
211
|
+
message: 'Use dirname(fileURLToPath(import.meta.url)) in ESM.',
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: '__filename',
|
|
215
|
+
message: 'Use fileURLToPath(import.meta.url) in ESM.',
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
'@typescript-eslint/naming-convention': namingConventionRules,
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
files: typescriptFiles,
|
|
223
|
+
plugins: {
|
|
224
|
+
'@stylistic': stylistic,
|
|
225
|
+
},
|
|
226
|
+
rules: {
|
|
227
|
+
'@stylistic/indent': ['error', 2],
|
|
228
|
+
'@stylistic/object-curly-newline': ['error', { multiline: true, minProperties: 2 }],
|
|
229
|
+
'@stylistic/object-property-newline': ['error', { allowAllPropertiesOnSameLine: false }],
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
files: thinLayerFiles,
|
|
234
|
+
ignores: thinLayerIgnoredFiles,
|
|
235
|
+
rules: {
|
|
236
|
+
'max-lines': ['error', { max: 150, skipBlankLines: true, skipComments: true }],
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
files: ['**/entrypoint/**/*.ts'],
|
|
241
|
+
ignores: thinLayerIgnoredFiles,
|
|
242
|
+
rules: {
|
|
243
|
+
'no-restricted-syntax': entrypointRestrictedSyntaxRules,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
files: typescriptFiles,
|
|
248
|
+
plugins: {
|
|
249
|
+
unicorn,
|
|
250
|
+
},
|
|
251
|
+
rules: {
|
|
252
|
+
'unicorn/prefer-string-replace-all': 'error',
|
|
253
|
+
'unicorn/prefer-type-error': 'error',
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
files: testFiles,
|
|
258
|
+
plugins: {
|
|
259
|
+
vitest,
|
|
260
|
+
},
|
|
261
|
+
rules: {
|
|
262
|
+
'vitest/no-conditional-expect': 'error',
|
|
263
|
+
'vitest/no-conditional-in-test': 'error',
|
|
264
|
+
'vitest/prefer-strict-equal': 'error',
|
|
265
|
+
'vitest/consistent-test-it': ['error', { fn: 'it' }],
|
|
266
|
+
'vitest/consistent-test-filename': ['error', { pattern: '.*\\.spec\\.[tj]sx?$' }],
|
|
267
|
+
'vitest/max-expects': ['error', { max: 4 }],
|
|
268
|
+
'vitest/prefer-called-with': 'error',
|
|
269
|
+
'vitest/prefer-to-have-length': 'error',
|
|
270
|
+
'vitest/require-to-throw-message': 'error',
|
|
271
|
+
'vitest/prefer-spy-on': 'error',
|
|
272
|
+
'@typescript-eslint/no-unsafe-assignment': 'off',
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
const forbiddenWordSuggestions = {
|
|
4
|
+
utils: 'Use a domain-specific name that describes what it does.',
|
|
5
|
+
helpers: 'Use a purpose-specific name or fixtures for test data.',
|
|
6
|
+
helper: 'Use a purpose-specific name or fixtures for test data.',
|
|
7
|
+
service: 'Name it for the domain action it performs.',
|
|
8
|
+
services: 'Name it for the domain action it performs.',
|
|
9
|
+
manager: 'Name it for the responsibility it owns.',
|
|
10
|
+
managers: 'Name it for the responsibility it owns.',
|
|
11
|
+
processor: 'Name it for the domain work it performs.',
|
|
12
|
+
processors: 'Name it for the domain work it performs.',
|
|
13
|
+
data: 'Name it for the domain concept it represents.',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const forbiddenWords = Object.keys(forbiddenWordSuggestions)
|
|
17
|
+
const forbiddenFilenamePattern = new RegExp(
|
|
18
|
+
`(^|/|-|[a-z])(${forbiddenWords.join('|')})(-|[.]ts$|[.]tsx$|/|$)`,
|
|
19
|
+
'i',
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const findForbiddenWord = (text) => {
|
|
23
|
+
const lowercaseText = text.toLowerCase()
|
|
24
|
+
return forbiddenWords.find((forbiddenWord) => lowercaseText.includes(forbiddenWord))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isForbiddenName = (name) => {
|
|
28
|
+
if (!name) {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const lowercaseName = name.toLowerCase()
|
|
33
|
+
return forbiddenWords.some((forbiddenWord) => {
|
|
34
|
+
return lowercaseName === forbiddenWord || lowercaseName.startsWith(forbiddenWord) || lowercaseName.endsWith(forbiddenWord)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
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
|
+
const noGenericNames = {
|
|
61
|
+
meta: {
|
|
62
|
+
type: 'problem',
|
|
63
|
+
docs: {
|
|
64
|
+
description: 'Forbid generic names in filenames and class names.',
|
|
65
|
+
recommended: true,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
create(context) {
|
|
69
|
+
const filename = path.basename(context.getFilename() || '')
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
ClassDeclaration(node) {
|
|
73
|
+
if (!node.id) {
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!isForbiddenName(node.id.name)) {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
context.report({
|
|
82
|
+
node: node.id,
|
|
83
|
+
message: createClassMessage(node.id.name),
|
|
84
|
+
})
|
|
85
|
+
},
|
|
86
|
+
Program(node) {
|
|
87
|
+
if (!forbiddenFilenamePattern.test(filename)) {
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
context.report({
|
|
92
|
+
node,
|
|
93
|
+
message: createFilenameMessage(filename),
|
|
94
|
+
})
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default noGenericNames
|