@nt-ai-lab/opencode-skillz 0.3.4 → 0.3.6
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 +18 -0
- package/dist/plugin-registry/index.js +2 -0
- package/dist/tools/lint.d.ts +27 -0
- package/dist/tools/lint.js +249 -0
- package/dist/types.d.ts +2 -0
- package/package.json +16 -3
- package/scripts/lint-ts.mjs +10 -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,18 @@
|
|
|
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` with only the `files` argument for the changed `.ts` or `.tsx` file or files from that step.
|
|
14
|
+
|
|
15
|
+
- do not pass `base` or `head` during normal implementation work
|
|
16
|
+
- all lint errors on new code must be addressed before continuing
|
|
17
|
+
- if the lint fails on existing code, ignore the error unless it is very close to the new code
|
|
18
|
+
- 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,27 @@
|
|
|
1
|
+
interface PortableLintRequest {
|
|
2
|
+
repositoryRoot?: string;
|
|
3
|
+
files?: string[];
|
|
4
|
+
base?: string;
|
|
5
|
+
head?: string;
|
|
6
|
+
}
|
|
7
|
+
interface PortableLintOutcome {
|
|
8
|
+
exitCode: number;
|
|
9
|
+
output: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const LINT_TOOL_NAME = "nt_skillz_lint";
|
|
12
|
+
export declare function runPortableLint(request: PortableLintRequest): Promise<PortableLintOutcome>;
|
|
13
|
+
export declare function runPortableLintFromCommandLine(commandLineArguments: string[]): Promise<number>;
|
|
14
|
+
export declare const lintTool: {
|
|
15
|
+
description: string;
|
|
16
|
+
args: {
|
|
17
|
+
files: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
18
|
+
base: import("zod").ZodOptional<import("zod").ZodString>;
|
|
19
|
+
head: import("zod").ZodOptional<import("zod").ZodString>;
|
|
20
|
+
};
|
|
21
|
+
execute(args: {
|
|
22
|
+
files?: string[] | undefined;
|
|
23
|
+
base?: string | undefined;
|
|
24
|
+
head?: string | undefined;
|
|
25
|
+
}, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
26
|
+
};
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,249 @@
|
|
|
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
|
+
import { ESLint } from "eslint";
|
|
8
|
+
import { tool } from "@opencode-ai/plugin";
|
|
9
|
+
class UsageError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
class GitCommandError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
class MissingLocalDependencyError extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
class LintExecutionError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const toolDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const toolRepositoryRoot = path.resolve(toolDirectory, "..", "..");
|
|
31
|
+
const eslintCliPath = path.join(toolRepositoryRoot, "node_modules", "eslint", "bin", "eslint.js");
|
|
32
|
+
const eslintConfigPath = path.join(toolRepositoryRoot, "scripts", "living-architecture-eslint.config.mjs");
|
|
33
|
+
const gitBinaryPath = "/usr/bin/git";
|
|
34
|
+
export const LINT_TOOL_NAME = "nt_skillz_lint";
|
|
35
|
+
function normalizeOptionalText(value) {
|
|
36
|
+
if (typeof value !== "string") {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const trimmedValue = value.trim();
|
|
40
|
+
if (!trimmedValue) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return trimmedValue;
|
|
44
|
+
}
|
|
45
|
+
function resolveDirectory(directoryPath) {
|
|
46
|
+
const absoluteDirectoryPath = path.resolve(directoryPath);
|
|
47
|
+
if (!fs.existsSync(absoluteDirectoryPath)) {
|
|
48
|
+
throw new UsageError(`Expected repository path to exist. Got ${absoluteDirectoryPath}.`);
|
|
49
|
+
}
|
|
50
|
+
if (!fs.statSync(absoluteDirectoryPath).isDirectory()) {
|
|
51
|
+
throw new UsageError(`Expected repository path to be a directory. Got ${absoluteDirectoryPath}.`);
|
|
52
|
+
}
|
|
53
|
+
return absoluteDirectoryPath;
|
|
54
|
+
}
|
|
55
|
+
function ensureSupportedFilePath(filePath) {
|
|
56
|
+
if (filePath.endsWith(".ts") || filePath.endsWith(".tsx")) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
throw new UsageError(`Expected a TypeScript file path ending in .ts or .tsx. Got ${filePath}.`);
|
|
60
|
+
}
|
|
61
|
+
function normalizeFilePaths(filePaths) {
|
|
62
|
+
const normalizedFilePaths = (filePaths ?? []).map((filePath) => filePath.trim()).filter(Boolean);
|
|
63
|
+
normalizedFilePaths.forEach(ensureSupportedFilePath);
|
|
64
|
+
return normalizedFilePaths;
|
|
65
|
+
}
|
|
66
|
+
function ensureLocalDependencies() {
|
|
67
|
+
if (fs.existsSync(eslintCliPath)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
throw new MissingLocalDependencyError(`Expected ESLint dependencies to be installed in ${toolRepositoryRoot}.`);
|
|
71
|
+
}
|
|
72
|
+
function runGitCommand(repositoryRoot, gitArguments) {
|
|
73
|
+
const gitResult = spawnSync(gitBinaryPath, ["-C", repositoryRoot, ...gitArguments], { encoding: "utf8" });
|
|
74
|
+
if (gitResult.error) {
|
|
75
|
+
throw new GitCommandError(`Expected git command to run. Got ${gitResult.error.message}.`);
|
|
76
|
+
}
|
|
77
|
+
if (gitResult.status === 0) {
|
|
78
|
+
return gitResult.stdout;
|
|
79
|
+
}
|
|
80
|
+
const errorOutput = gitResult.stderr.trim() || gitResult.stdout.trim() || "git command failed";
|
|
81
|
+
throw new GitCommandError(`Expected git command to succeed. Got ${errorOutput}.`);
|
|
82
|
+
}
|
|
83
|
+
function readChangedTypeScriptFiles(repositoryRoot, baseReference, headReference) {
|
|
84
|
+
const diffRange = `${baseReference}...${headReference}`;
|
|
85
|
+
const output = runGitCommand(repositoryRoot, [
|
|
86
|
+
"diff",
|
|
87
|
+
"--name-only",
|
|
88
|
+
"--diff-filter=ACMR",
|
|
89
|
+
diffRange,
|
|
90
|
+
"--",
|
|
91
|
+
"*.ts",
|
|
92
|
+
"*.tsx",
|
|
93
|
+
]);
|
|
94
|
+
return output.split("\n").filter(Boolean);
|
|
95
|
+
}
|
|
96
|
+
function readTrackedAndUntrackedTypeScriptFiles(repositoryRoot) {
|
|
97
|
+
const output = runGitCommand(repositoryRoot, [
|
|
98
|
+
"ls-files",
|
|
99
|
+
"--cached",
|
|
100
|
+
"--others",
|
|
101
|
+
"--exclude-standard",
|
|
102
|
+
"--",
|
|
103
|
+
"*.ts",
|
|
104
|
+
"*.tsx",
|
|
105
|
+
]);
|
|
106
|
+
return output.split("\n").filter(Boolean);
|
|
107
|
+
}
|
|
108
|
+
function ensureValidLintRequest(baseReference, headReference, filePaths) {
|
|
109
|
+
if (baseReference && filePaths.length > 0) {
|
|
110
|
+
throw new UsageError("Expected either file paths or base reference. Got both.");
|
|
111
|
+
}
|
|
112
|
+
if (!baseReference && headReference) {
|
|
113
|
+
throw new UsageError("Expected head reference to be used together with base reference.");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function resolveLintTargets(repositoryRoot, baseReference, headReference, filePaths) {
|
|
117
|
+
if (filePaths.length > 0) {
|
|
118
|
+
return filePaths;
|
|
119
|
+
}
|
|
120
|
+
if (baseReference) {
|
|
121
|
+
return readChangedTypeScriptFiles(repositoryRoot, baseReference, headReference ?? "HEAD");
|
|
122
|
+
}
|
|
123
|
+
return readTrackedAndUntrackedTypeScriptFiles(repositoryRoot);
|
|
124
|
+
}
|
|
125
|
+
function createLintTitle(filePaths, baseReference) {
|
|
126
|
+
if (filePaths.length > 0) {
|
|
127
|
+
return `Lint ${filePaths.length} TypeScript file(s)`;
|
|
128
|
+
}
|
|
129
|
+
if (baseReference) {
|
|
130
|
+
return `Lint TypeScript changes from ${baseReference}`;
|
|
131
|
+
}
|
|
132
|
+
return "Lint current TypeScript files";
|
|
133
|
+
}
|
|
134
|
+
async function runEslint(repositoryRoot, lintTargets) {
|
|
135
|
+
const previousLintRepositoryRoot = process.env.NT_SKILLZ_LINT_REPO_ROOT;
|
|
136
|
+
process.env.NT_SKILLZ_LINT_REPO_ROOT = repositoryRoot;
|
|
137
|
+
const eslint = new ESLint({
|
|
138
|
+
cwd: repositoryRoot,
|
|
139
|
+
errorOnUnmatchedPattern: false,
|
|
140
|
+
overrideConfigFile: eslintConfigPath,
|
|
141
|
+
warnIgnored: false,
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
const lintResults = await eslint.lintFiles(lintTargets);
|
|
145
|
+
const formatter = await eslint.loadFormatter("stylish");
|
|
146
|
+
const formattedOutput = (await formatter.format(lintResults)).trim();
|
|
147
|
+
const errorCount = lintResults.reduce((count, lintResult) => count + lintResult.errorCount + lintResult.fatalErrorCount, 0);
|
|
148
|
+
return {
|
|
149
|
+
exitCode: errorCount > 0 ? 1 : 0,
|
|
150
|
+
output: formattedOutput,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
if (previousLintRepositoryRoot) {
|
|
155
|
+
process.env.NT_SKILLZ_LINT_REPO_ROOT = previousLintRepositoryRoot;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
delete process.env.NT_SKILLZ_LINT_REPO_ROOT;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function parsePortableLintCommandLine(commandLineArguments) {
|
|
163
|
+
const parsedArguments = parseArgs({
|
|
164
|
+
args: commandLineArguments,
|
|
165
|
+
options: {
|
|
166
|
+
repo: { type: "string" },
|
|
167
|
+
base: { type: "string" },
|
|
168
|
+
head: { type: "string" },
|
|
169
|
+
help: {
|
|
170
|
+
type: "boolean",
|
|
171
|
+
short: "h",
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
allowPositionals: true,
|
|
175
|
+
});
|
|
176
|
+
if (parsedArguments.values.help) {
|
|
177
|
+
process.stdout.write([
|
|
178
|
+
"Usage: ./scripts/lint-ts.sh [--repo PATH] [--base REF] [--head REF] [file ...]",
|
|
179
|
+
"",
|
|
180
|
+
"Examples:",
|
|
181
|
+
" ./scripts/lint-ts.sh --repo ../living-architecture --base origin/main",
|
|
182
|
+
" ./scripts/lint-ts.sh --repo ../living-architecture packages/example/src/example.ts",
|
|
183
|
+
" ./scripts/lint-ts.sh src/example.ts",
|
|
184
|
+
].join("\n") + "\n");
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
repositoryRoot: resolveDirectory(parsedArguments.values.repo ?? process.cwd()),
|
|
189
|
+
files: normalizeFilePaths(parsedArguments.positionals),
|
|
190
|
+
base: normalizeOptionalText(parsedArguments.values.base),
|
|
191
|
+
head: normalizeOptionalText(parsedArguments.values.head),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
export async function runPortableLint(request) {
|
|
195
|
+
ensureLocalDependencies();
|
|
196
|
+
const repositoryRoot = resolveDirectory(request.repositoryRoot ?? process.cwd());
|
|
197
|
+
const baseReference = normalizeOptionalText(request.base);
|
|
198
|
+
const headReference = normalizeOptionalText(request.head);
|
|
199
|
+
const filePaths = normalizeFilePaths(request.files);
|
|
200
|
+
ensureValidLintRequest(baseReference, headReference, filePaths);
|
|
201
|
+
const lintTargets = resolveLintTargets(repositoryRoot, baseReference, headReference, filePaths);
|
|
202
|
+
if (lintTargets.length === 0) {
|
|
203
|
+
return {
|
|
204
|
+
exitCode: 0,
|
|
205
|
+
output: "No TypeScript files matched.",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return runEslint(repositoryRoot, lintTargets);
|
|
209
|
+
}
|
|
210
|
+
export async function runPortableLintFromCommandLine(commandLineArguments) {
|
|
211
|
+
const request = parsePortableLintCommandLine(commandLineArguments);
|
|
212
|
+
const outcome = await runPortableLint(request);
|
|
213
|
+
if (outcome.output) {
|
|
214
|
+
process.stdout.write(`${outcome.output}\n`);
|
|
215
|
+
}
|
|
216
|
+
return outcome.exitCode;
|
|
217
|
+
}
|
|
218
|
+
export const lintTool = tool({
|
|
219
|
+
description: "Run bundled TypeScript lint rules against current project files.",
|
|
220
|
+
args: {
|
|
221
|
+
files: tool.schema.array(tool.schema.string()).optional().describe("Relative .ts or .tsx file paths to lint."),
|
|
222
|
+
base: tool.schema.string().optional().describe("Base git reference for PR-style changed-file linting."),
|
|
223
|
+
head: tool.schema.string().optional().describe("Optional head git reference used with base."),
|
|
224
|
+
},
|
|
225
|
+
async execute(request, context) {
|
|
226
|
+
const filePaths = normalizeFilePaths(request.files);
|
|
227
|
+
const baseReference = normalizeOptionalText(request.base);
|
|
228
|
+
const headReference = normalizeOptionalText(request.head);
|
|
229
|
+
ensureValidLintRequest(baseReference, headReference, filePaths);
|
|
230
|
+
context.metadata({ title: createLintTitle(filePaths, baseReference) });
|
|
231
|
+
const outcome = await runPortableLint({
|
|
232
|
+
repositoryRoot: context.worktree,
|
|
233
|
+
files: filePaths,
|
|
234
|
+
base: baseReference,
|
|
235
|
+
head: headReference,
|
|
236
|
+
});
|
|
237
|
+
if (outcome.exitCode !== 0) {
|
|
238
|
+
throw new LintExecutionError(outcome.output || "Lint failed.");
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
output: outcome.output || "Lint passed.",
|
|
242
|
+
metadata: {
|
|
243
|
+
base: baseReference ?? null,
|
|
244
|
+
fileCount: filePaths.length,
|
|
245
|
+
head: headReference ?? null,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
});
|
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.6",
|
|
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
|
+
"@eslint-community/eslint-plugin-eslint-comments": "^4.5.0",
|
|
28
|
+
"@eslint/js": "^9.39.0",
|
|
29
|
+
"@opencode-ai/plugin": "^1.14.28",
|
|
30
|
+
"@stylistic/eslint-plugin": "^5.6.1",
|
|
31
|
+
"@vitest/eslint-plugin": "^1.0.1",
|
|
32
|
+
"eslint": "^9.39.0",
|
|
33
|
+
"eslint-plugin-import": "^2.32.0",
|
|
34
|
+
"eslint-plugin-sonarjs": "^3.0.5",
|
|
35
|
+
"eslint-plugin-unicorn": "^62.0.0",
|
|
36
|
+
"typescript": "^5.9.3",
|
|
37
|
+
"typescript-eslint": "^8.50.1"
|
|
38
|
+
},
|
|
25
39
|
"devDependencies": {
|
|
26
|
-
"@types/node": "^24.7.2"
|
|
27
|
-
"typescript": "^5.9.3"
|
|
40
|
+
"@types/node": "^24.7.2"
|
|
28
41
|
},
|
|
29
42
|
"publishConfig": {
|
|
30
43
|
"access": "public"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
import { runPortableLintFromCommandLine } from '../dist/tools/lint.js'
|
|
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
|
|
10
|
+
}
|
|
@@ -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
|