semlint-cli 0.1.5 → 0.1.7
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/.semlint/rules/SEMLINT_NAMING_001.json +12 -0
- package/.semlint/rules/SEMLINT_PATTERN_002.json +12 -0
- package/.semlint/rules/SEMLINT_SWE_003.json +12 -0
- package/README.md +81 -12
- package/dist/adapters.js +27 -0
- package/dist/backend.js +103 -67
- package/dist/config.js +94 -25
- package/dist/diagnostics.js +23 -27
- package/dist/diff.js +54 -0
- package/dist/dispatch.js +94 -0
- package/dist/filter.js +16 -95
- package/dist/init.js +54 -14
- package/dist/main.js +58 -143
- package/dist/prompts.js +46 -0
- package/dist/reporter.js +12 -13
- package/dist/rules.js +11 -13
- package/dist/secrets.js +153 -0
- package/dist/secrets.test.js +83 -0
- package/dist/test2.js +5 -0
- package/dist/utils.js +18 -0
- package/package.json +4 -2
- package/prompts/batch.md +16 -0
- package/prompts/common-contract.md +20 -0
- package/prompts/retry-json.md +5 -0
- package/prompts/rule.md +16 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "SEMLINT_NAMING_001",
|
|
3
|
+
"title": "Ambient naming convention consistency",
|
|
4
|
+
"severity_default": "warn",
|
|
5
|
+
"include_globs": ["src/**/*.ts"],
|
|
6
|
+
"exclude_globs": ["**/*.test.ts", "**/*.spec.ts"],
|
|
7
|
+
"diff_regex": [
|
|
8
|
+
"^[+-].*\\b(const|let|var|function|class|interface|type|enum)\\b",
|
|
9
|
+
"^[+-].*\\b[A-Za-z_][A-Za-z0-9_]*\\b"
|
|
10
|
+
],
|
|
11
|
+
"prompt": "Verify naming is consistent with the ambient naming conventions already used in surrounding code. Both in term of semantic and casing."
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "SEMLINT_PATTERN_002",
|
|
3
|
+
"title": "Ambient pattern is respected",
|
|
4
|
+
"severity_default": "warn",
|
|
5
|
+
"include_globs": ["src/**/*.ts"],
|
|
6
|
+
"exclude_globs": ["**/*.test.ts", "**/*.spec.ts"],
|
|
7
|
+
"diff_regex": [
|
|
8
|
+
"^[+-].*\\b(async|await|Promise|try|catch|throw|switch|map|filter|reduce|forEach)\\b",
|
|
9
|
+
"^[+-].*\\b(import|export|class|interface|type|function|return)\\b"
|
|
10
|
+
],
|
|
11
|
+
"prompt": "Check whether ambient implementation patterns are respected. Compare new or changed code against nearby established patterns. Flag clear regressions where the proposed change deviates from consistent local patterns without obvious justification."
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "SEMLINT_SWE_003",
|
|
3
|
+
"title": "Obvious SWE mistakes",
|
|
4
|
+
"severity_default": "warn",
|
|
5
|
+
"include_globs": ["src/**/*.ts"],
|
|
6
|
+
"exclude_globs": ["**/*.test.ts", "**/*.spec.ts"],
|
|
7
|
+
"diff_regex": [
|
|
8
|
+
"^[+-].*\\b(any|as\\s+any|TODO|FIXME|console\\.log|@ts-ignore|throw\\s+new\\s+Error|catch\\s*\\()\\b",
|
|
9
|
+
"^[+-].*\\b(if|else|switch|return|await|Promise|map|forEach|reduce)\\b"
|
|
10
|
+
],
|
|
11
|
+
"prompt": "Find obvious software-engineering mistakes in the proposed change that are likely unintended. Do not nitpick style or architecture unless the issue is clearly harmful. Report only high-signal findings."
|
|
12
|
+
}
|
package/README.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Semlint CLI MVP
|
|
2
2
|
|
|
3
|
+
## Motivation
|
|
4
|
+
|
|
5
|
+
Upstream instruction files (`AGENTS.md`, `CURSOR.md`, etc.) are the standard way to guide coding agents — but a [recent study (Gloaguen et al., 2026)](https://arxiv.org/abs/2602.11988) found that such context files tend to *reduce* task success rates compared to providing no context at all, while increasing inference cost by over 20%. The root cause: agents respect the instructions, but unnecessary or over-specified requirements make tasks harder, with no feedback mechanism to catch when rules are ignored or misapplied.
|
|
6
|
+
|
|
7
|
+
Semlint takes a different approach. Instead of providing guidance upfront and hoping for the best, rules are enforced *after the fact* as a lint pass on the diff — giving agents a deterministic red/green signal and closing the feedback loop.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
3
11
|
Semlint is a deterministic semantic lint CLI that:
|
|
4
12
|
|
|
5
13
|
- reads a git diff,
|
|
@@ -75,7 +83,6 @@ Unknown fields are ignored.
|
|
|
75
83
|
```json
|
|
76
84
|
{
|
|
77
85
|
"backend": "cursor-cli",
|
|
78
|
-
"model": "auto",
|
|
79
86
|
"budgets": {
|
|
80
87
|
"timeout_ms": 120000
|
|
81
88
|
},
|
|
@@ -85,15 +92,23 @@ Unknown fields are ignored.
|
|
|
85
92
|
"execution": {
|
|
86
93
|
"batch": false
|
|
87
94
|
},
|
|
95
|
+
"security": {
|
|
96
|
+
"secret_guard": true,
|
|
97
|
+
"allow_patterns": [],
|
|
98
|
+
"allow_files": [],
|
|
99
|
+
"ignore_files": [".gitignore", ".cursorignore", ".semlintignore", ".cursoringore"]
|
|
100
|
+
},
|
|
88
101
|
"rules": {
|
|
89
|
-
"disable": [
|
|
102
|
+
"disable": [],
|
|
90
103
|
"severity_overrides": {
|
|
91
104
|
"SEMLINT_API_001": "error"
|
|
92
105
|
}
|
|
93
106
|
},
|
|
94
107
|
"backends": {
|
|
95
108
|
"cursor-cli": {
|
|
96
|
-
"executable": "
|
|
109
|
+
"executable": "cursor",
|
|
110
|
+
"model": "auto",
|
|
111
|
+
"args": ["agent", "{prompt}", "--model", "{model}", "--print", "--mode", "ask", "--output-format", "text"]
|
|
97
112
|
}
|
|
98
113
|
}
|
|
99
114
|
}
|
|
@@ -115,11 +130,11 @@ This creates `./semlint.json` and auto-detects installed coding agent CLIs in th
|
|
|
115
130
|
|
|
116
131
|
If no known CLI is detected, Semlint falls back to `cursor-cli` + executable `cursor`.
|
|
117
132
|
|
|
118
|
-
Use `semlint init --force` to overwrite an existing config file. Init also creates `.semlint/rules/` and
|
|
133
|
+
Use `semlint init --force` to overwrite an existing config file. Init also creates `.semlint/rules/` and copies the bundled Semlint rule files into it.
|
|
119
134
|
|
|
120
135
|
## Rule files
|
|
121
136
|
|
|
122
|
-
Rule JSON files are loaded from `.semlint/rules/`. Run `semlint init` to create this folder and
|
|
137
|
+
Rule JSON files are loaded from `.semlint/rules/`. Run `semlint init` to create this folder and copy bundled rules into it.
|
|
123
138
|
|
|
124
139
|
Required fields:
|
|
125
140
|
|
|
@@ -138,16 +153,32 @@ Invalid rules cause runtime failure with exit code `2`.
|
|
|
138
153
|
|
|
139
154
|
## Backend contract
|
|
140
155
|
|
|
141
|
-
For
|
|
156
|
+
Semlint is fully config-driven at runtime. For the selected `backend`, it executes:
|
|
142
157
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
158
|
+
- `backends.<backend>.executable` as the binary
|
|
159
|
+
- `backends.<backend>.model` as the backend-specific model (unless `--model` is passed)
|
|
160
|
+
- `backends.<backend>.args` as the argument template
|
|
161
|
+
|
|
162
|
+
`args` supports placeholder tokens:
|
|
163
|
+
|
|
164
|
+
- `{prompt}`: replaced with the generated prompt
|
|
165
|
+
- `{model}`: replaced with the configured model
|
|
146
166
|
|
|
147
|
-
|
|
148
|
-
Other backend names still resolve executables from config:
|
|
167
|
+
Placeholders are exact-match substitutions on whole args. Backends must be fully configured in `semlint.json`; there are no runtime fallbacks.
|
|
149
168
|
|
|
150
|
-
|
|
169
|
+
Example:
|
|
170
|
+
|
|
171
|
+
```json
|
|
172
|
+
{
|
|
173
|
+
"backends": {
|
|
174
|
+
"cursor-cli": {
|
|
175
|
+
"executable": "cursor",
|
|
176
|
+
"model": "auto",
|
|
177
|
+
"args": ["agent", "{prompt}", "--model", "{model}", "--print", "--mode", "ask", "--output-format", "text"]
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
151
182
|
|
|
152
183
|
Backend stdout must be valid JSON with shape:
|
|
153
184
|
|
|
@@ -168,6 +199,44 @@ Backend stdout must be valid JSON with shape:
|
|
|
168
199
|
If parsing fails, Semlint retries once with appended instruction:
|
|
169
200
|
`Return valid JSON only.`
|
|
170
201
|
|
|
202
|
+
If backend execution still fails and Semlint is running in an interactive terminal (TTY), it automatically performs one interactive passthrough run so you can satisfy backend setup prompts (for example auth/workspace trust), then retries machine parsing once.
|
|
203
|
+
|
|
204
|
+
## Secret guard
|
|
205
|
+
|
|
206
|
+
Semlint uses a fail-closed secret guard before any backend call:
|
|
207
|
+
|
|
208
|
+
- Filters diff chunks using path ignore rules from `.gitignore`, `.cursorignore`, `.semlintignore`
|
|
209
|
+
- Applies additional built-in sensitive path deny patterns (`.env*`, key files, secrets/credentials folders)
|
|
210
|
+
- Scans added diff lines for high-signal secret keywords and token prefixes (password/token/api key/private key/JWT/provider key prefixes)
|
|
211
|
+
- If any potential secrets are found, Semlint exits with code `2` and sends nothing to the backend
|
|
212
|
+
|
|
213
|
+
Config:
|
|
214
|
+
|
|
215
|
+
```json
|
|
216
|
+
{
|
|
217
|
+
"security": {
|
|
218
|
+
"secret_guard": true,
|
|
219
|
+
"allow_patterns": [],
|
|
220
|
+
"allow_files": [],
|
|
221
|
+
"ignore_files": [".gitignore", ".cursorignore", ".semlintignore", ".cursoringore"]
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- `secret_guard`: enable/disable secret blocking (default `true`)
|
|
227
|
+
- `allow_patterns`: regex list to suppress known-safe fixtures from triggering the guard
|
|
228
|
+
- `allow_files`: file glob allowlist to skip secret scanning for known-safe files (example: `["src/test-fixtures/**"]`)
|
|
229
|
+
- `ignore_files`: ignore files Semlint reads for path-level filtering (default: `.gitignore`, `.cursorignore`, `.semlintignore`, `.cursoringore`)
|
|
230
|
+
|
|
231
|
+
## Prompt files
|
|
232
|
+
|
|
233
|
+
Core system prompts are externalized under `prompts/` so prompt behavior is easy to inspect and iterate:
|
|
234
|
+
|
|
235
|
+
- `prompts/common-contract.md`: shared output schema and base rules used by both modes
|
|
236
|
+
- `prompts/rule.md`: single-rule evaluation prompt
|
|
237
|
+
- `prompts/batch.md`: batch evaluation prompt
|
|
238
|
+
- `prompts/retry-json.md`: strict JSON retry instruction
|
|
239
|
+
|
|
171
240
|
## Batch mode
|
|
172
241
|
|
|
173
242
|
Use batch mode to reduce cost by evaluating all runnable rules in a single backend call:
|
package/dist/adapters.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BUILTIN_ADAPTERS = void 0;
|
|
4
|
+
exports.BUILTIN_ADAPTERS = {
|
|
5
|
+
"cursor-cli": {
|
|
6
|
+
defaultExecutable: "cursor",
|
|
7
|
+
buildArgs: (prompt, model) => [
|
|
8
|
+
"agent",
|
|
9
|
+
prompt,
|
|
10
|
+
"--model",
|
|
11
|
+
model,
|
|
12
|
+
"--print",
|
|
13
|
+
"--mode",
|
|
14
|
+
"ask",
|
|
15
|
+
"--output-format",
|
|
16
|
+
"text"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"claude-code": {
|
|
20
|
+
defaultExecutable: "claude",
|
|
21
|
+
buildArgs: (prompt, model) => [prompt, "--model", model, "--output-format", "json"]
|
|
22
|
+
},
|
|
23
|
+
"codex-cli": {
|
|
24
|
+
defaultExecutable: "codex",
|
|
25
|
+
buildArgs: (prompt, model) => [prompt, "--model", model]
|
|
26
|
+
}
|
|
27
|
+
};
|
package/dist/backend.js
CHANGED
|
@@ -2,34 +2,39 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createBackendRunner = createBackendRunner;
|
|
4
4
|
const node_child_process_1 = require("node:child_process");
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"Do not include commentary, headings, or any text before/after JSON.",
|
|
9
|
-
"The first character of your response must be '{' and the last must be '}'.",
|
|
10
|
-
'Output must match: {"diagnostics":[{"rule_id":"<id>","severity":"error|warn|info","message":"<text>","file":"<path>","line":1}]}'
|
|
11
|
-
].join(" ");
|
|
5
|
+
const prompts_1 = require("./prompts");
|
|
6
|
+
const utils_1 = require("./utils");
|
|
7
|
+
const STRICT_JSON_RETRY_INSTRUCTION = (0, prompts_1.getStrictJsonRetryInstruction)();
|
|
12
8
|
function isEnoentError(error) {
|
|
13
9
|
return (typeof error === "object" &&
|
|
14
10
|
error !== null &&
|
|
15
11
|
"code" in error &&
|
|
16
12
|
error.code === "ENOENT");
|
|
17
13
|
}
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
14
|
+
function logBackendCommand(label, executable, args, prompt) {
|
|
15
|
+
const printableParts = [executable, ...args.map((arg) => (arg === prompt ? "<prompt-redacted>" : arg))];
|
|
16
|
+
process.stderr.write(`[semlint] ${label}: ${printableParts.join(" ")}\n`);
|
|
22
17
|
}
|
|
23
18
|
function resolveCommandSpecs(config) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
const configuredExecutable = config.backendExecutables[config.backend];
|
|
29
|
-
if (!configuredExecutable) {
|
|
30
|
-
throw new Error(`No executable configured for backend "${config.backend}". Configure it under backends.${config.backend}.executable`);
|
|
19
|
+
const configuredBackend = config.backendConfigs[config.backend];
|
|
20
|
+
if (!configuredBackend) {
|
|
21
|
+
throw new Error(`Backend "${config.backend}" is not configured. Add it under backends.${config.backend} in semlint.json (run "semlint init" to scaffold a complete config).`);
|
|
31
22
|
}
|
|
32
|
-
return [
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
executable: configuredBackend.executable,
|
|
26
|
+
args: configuredBackend.args
|
|
27
|
+
}
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
function interpolateArgs(args, prompt, model) {
|
|
31
|
+
return args.map((arg) => {
|
|
32
|
+
if (arg === "{prompt}")
|
|
33
|
+
return prompt;
|
|
34
|
+
if (arg === "{model}")
|
|
35
|
+
return model;
|
|
36
|
+
return arg;
|
|
37
|
+
});
|
|
33
38
|
}
|
|
34
39
|
function executeBackendCommand(executable, args, timeoutMs) {
|
|
35
40
|
return new Promise((resolve, reject) => {
|
|
@@ -68,6 +73,26 @@ function executeBackendCommand(executable, args, timeoutMs) {
|
|
|
68
73
|
});
|
|
69
74
|
});
|
|
70
75
|
}
|
|
76
|
+
function executeBackendCommandInteractive(executable, args) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const child = (0, node_child_process_1.spawn)(executable, args, {
|
|
79
|
+
stdio: "inherit"
|
|
80
|
+
});
|
|
81
|
+
child.on("error", (error) => {
|
|
82
|
+
reject(error);
|
|
83
|
+
});
|
|
84
|
+
child.on("close", (code) => {
|
|
85
|
+
if (code !== 0) {
|
|
86
|
+
reject(new Error(`Interactive backend command failed with code ${code}`));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
resolve();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function canUseInteractiveRecovery() {
|
|
94
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY);
|
|
95
|
+
}
|
|
71
96
|
function extractFirstJsonObject(raw) {
|
|
72
97
|
let start = -1;
|
|
73
98
|
let depth = 0;
|
|
@@ -137,62 +162,73 @@ function parseBackendResult(raw) {
|
|
|
137
162
|
}
|
|
138
163
|
function createBackendRunner(config) {
|
|
139
164
|
const commandSpecs = resolveCommandSpecs(config);
|
|
165
|
+
async function runWithRetry(spec, input) {
|
|
166
|
+
const commandName = spec.executable;
|
|
167
|
+
const firstPrompt = input.prompt;
|
|
168
|
+
const firstArgs = interpolateArgs(spec.args, firstPrompt, config.model);
|
|
169
|
+
let interactiveRecoveryAttempted = false;
|
|
170
|
+
try {
|
|
171
|
+
if (config.debug) {
|
|
172
|
+
logBackendCommand(`${input.label} attempt 1`, spec.executable, firstArgs, firstPrompt);
|
|
173
|
+
}
|
|
174
|
+
(0, utils_1.debugLog)(config.debug, `${input.label}: backend attempt 1 via "${commandName}" (timeout ${input.timeoutMs}ms)`);
|
|
175
|
+
const first = await executeBackendCommand(spec.executable, firstArgs, input.timeoutMs);
|
|
176
|
+
(0, utils_1.debugLog)(config.debug, `${input.label}: backend attempt 1 completed in ${first.elapsedMs}ms`);
|
|
177
|
+
return parseBackendResult(first.stdout.trim());
|
|
178
|
+
}
|
|
179
|
+
catch (firstError) {
|
|
180
|
+
(0, utils_1.debugLog)(config.debug, `${input.label}: backend attempt 1 failed (${firstError instanceof Error ? firstError.message : String(firstError)})`);
|
|
181
|
+
const retryPrompt = `${input.prompt}\n\n${STRICT_JSON_RETRY_INSTRUCTION}`;
|
|
182
|
+
const retryArgs = interpolateArgs(spec.args, retryPrompt, config.model);
|
|
183
|
+
try {
|
|
184
|
+
if (config.debug) {
|
|
185
|
+
logBackendCommand(`${input.label} attempt 2`, spec.executable, retryArgs, retryPrompt);
|
|
186
|
+
}
|
|
187
|
+
(0, utils_1.debugLog)(config.debug, `${input.label}: backend attempt 2 via "${commandName}" (timeout ${input.timeoutMs}ms)`);
|
|
188
|
+
const second = await executeBackendCommand(spec.executable, retryArgs, input.timeoutMs);
|
|
189
|
+
(0, utils_1.debugLog)(config.debug, `${input.label}: backend attempt 2 completed in ${second.elapsedMs}ms`);
|
|
190
|
+
return parseBackendResult(second.stdout.trim());
|
|
191
|
+
}
|
|
192
|
+
catch (secondError) {
|
|
193
|
+
(0, utils_1.debugLog)(config.debug, `${input.label}: backend attempt 2 failed (${secondError instanceof Error ? secondError.message : String(secondError)})`);
|
|
194
|
+
if (!interactiveRecoveryAttempted &&
|
|
195
|
+
!isEnoentError(firstError) &&
|
|
196
|
+
!isEnoentError(secondError) &&
|
|
197
|
+
canUseInteractiveRecovery()) {
|
|
198
|
+
interactiveRecoveryAttempted = true;
|
|
199
|
+
process.stderr.write("[semlint] Backend requires interactive setup. Switching to interactive passthrough once...\n");
|
|
200
|
+
await executeBackendCommandInteractive(spec.executable, firstArgs);
|
|
201
|
+
(0, utils_1.debugLog)(config.debug, `${input.label}: interactive setup completed; retrying backend in machine mode`);
|
|
202
|
+
const recovered = await executeBackendCommand(spec.executable, firstArgs, input.timeoutMs);
|
|
203
|
+
return parseBackendResult(recovered.stdout.trim());
|
|
204
|
+
}
|
|
205
|
+
if (isEnoentError(secondError) || isEnoentError(firstError)) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
throw firstError;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
140
212
|
return {
|
|
141
213
|
async runPrompt(input) {
|
|
142
214
|
let lastError;
|
|
143
215
|
for (const spec of commandSpecs) {
|
|
144
|
-
const commandName = [spec.executable, ...spec.argsPrefix].join(" ");
|
|
145
|
-
const baseArgs = [
|
|
146
|
-
...spec.argsPrefix,
|
|
147
|
-
input.prompt,
|
|
148
|
-
"--model",
|
|
149
|
-
config.model,
|
|
150
|
-
"--print",
|
|
151
|
-
"--mode",
|
|
152
|
-
"ask",
|
|
153
|
-
"--output-format",
|
|
154
|
-
"text"
|
|
155
|
-
];
|
|
156
216
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return parseBackendResult(first.stdout.trim());
|
|
161
|
-
}
|
|
162
|
-
catch (firstError) {
|
|
163
|
-
debugLog(config.debug, `${input.label}: backend attempt 1 failed (${firstError instanceof Error ? firstError.message : String(firstError)})`);
|
|
164
|
-
const retryPrompt = `${input.prompt}\n\n${STRICT_JSON_RETRY_INSTRUCTION}`;
|
|
165
|
-
const retryArgs = [
|
|
166
|
-
...spec.argsPrefix,
|
|
167
|
-
retryPrompt,
|
|
168
|
-
"--model",
|
|
169
|
-
config.model,
|
|
170
|
-
"--print",
|
|
171
|
-
"--mode",
|
|
172
|
-
"ask",
|
|
173
|
-
"--output-format",
|
|
174
|
-
"text"
|
|
175
|
-
];
|
|
176
|
-
try {
|
|
177
|
-
debugLog(config.debug, `${input.label}: backend attempt 2 via "${commandName}" (timeout ${input.timeoutMs}ms)`);
|
|
178
|
-
const second = await executeBackendCommand(spec.executable, retryArgs, input.timeoutMs);
|
|
179
|
-
debugLog(config.debug, `${input.label}: backend attempt 2 completed in ${second.elapsedMs}ms`);
|
|
180
|
-
return parseBackendResult(second.stdout.trim());
|
|
181
|
-
}
|
|
182
|
-
catch (secondError) {
|
|
183
|
-
debugLog(config.debug, `${input.label}: backend attempt 2 failed (${secondError instanceof Error ? secondError.message : String(secondError)})`);
|
|
184
|
-
lastError = secondError;
|
|
185
|
-
if (isEnoentError(secondError)) {
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
if (isEnoentError(firstError)) {
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
throw firstError;
|
|
217
|
+
const result = await runWithRetry(spec, input);
|
|
218
|
+
if (result) {
|
|
219
|
+
return result;
|
|
192
220
|
}
|
|
221
|
+
continue;
|
|
193
222
|
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
lastError = error;
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (lastError) {
|
|
229
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
194
230
|
}
|
|
195
|
-
throw
|
|
231
|
+
throw new Error(`No backend command could be executed for "${config.backend}"`);
|
|
196
232
|
},
|
|
197
233
|
async runRule(input) {
|
|
198
234
|
return this.runPrompt({
|
package/dist/config.js
CHANGED
|
@@ -7,7 +7,7 @@ exports.resolveConfigPath = resolveConfigPath;
|
|
|
7
7
|
exports.loadEffectiveConfig = loadEffectiveConfig;
|
|
8
8
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
9
|
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
-
const
|
|
10
|
+
const utils_1 = require("./utils");
|
|
11
11
|
const DEFAULTS = {
|
|
12
12
|
backend: "cursor-cli",
|
|
13
13
|
model: "auto",
|
|
@@ -20,8 +20,12 @@ const DEFAULTS = {
|
|
|
20
20
|
batchMode: false,
|
|
21
21
|
rulesDisable: [],
|
|
22
22
|
severityOverrides: {},
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
backendConfigs: {},
|
|
24
|
+
security: {
|
|
25
|
+
secretGuard: true,
|
|
26
|
+
allowPatterns: [],
|
|
27
|
+
ignoreFiles: [".gitignore", ".cursorignore", ".semlintignore"],
|
|
28
|
+
allowFiles: []
|
|
25
29
|
}
|
|
26
30
|
};
|
|
27
31
|
function readJsonIfExists(filePath) {
|
|
@@ -54,39 +58,96 @@ function sanitizeSeverityOverrides(value) {
|
|
|
54
58
|
if (!value) {
|
|
55
59
|
return {};
|
|
56
60
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
return Object.fromEntries(Object.entries(value).flatMap(([ruleId, severity]) => typeof severity === "string" && utils_1.VALID_SEVERITIES.has(severity)
|
|
62
|
+
? [[ruleId, severity]]
|
|
63
|
+
: []));
|
|
64
|
+
}
|
|
65
|
+
function sanitizeBackendConfigs(value) {
|
|
66
|
+
if (!value) {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
return Object.fromEntries(Object.entries(value).flatMap(([name, candidate]) => {
|
|
70
|
+
if (typeof candidate !== "object" || candidate === null) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const executable = "executable" in candidate && typeof candidate.executable === "string"
|
|
74
|
+
? candidate.executable.trim()
|
|
75
|
+
: "";
|
|
76
|
+
const args = "args" in candidate ? candidate.args : undefined;
|
|
77
|
+
const model = "model" in candidate && typeof candidate.model === "string"
|
|
78
|
+
? candidate.model.trim()
|
|
79
|
+
: undefined;
|
|
80
|
+
if (!executable || !Array.isArray(args) || args.some((arg) => typeof arg !== "string")) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const normalizedArgs = args;
|
|
84
|
+
if (!normalizedArgs.includes("{prompt}")) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
return [[name, { executable, args: normalizedArgs, model: model && model !== "" ? model : undefined }]];
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
function sanitizeAllowPatterns(value) {
|
|
91
|
+
if (!Array.isArray(value)) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
return value.flatMap((candidate) => {
|
|
95
|
+
if (typeof candidate !== "string" || candidate.trim() === "") {
|
|
96
|
+
return [];
|
|
61
97
|
}
|
|
98
|
+
try {
|
|
99
|
+
new RegExp(candidate);
|
|
100
|
+
return [candidate];
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function sanitizeIgnoreFiles(value) {
|
|
108
|
+
if (!Array.isArray(value)) {
|
|
109
|
+
return [...DEFAULTS.security.ignoreFiles];
|
|
62
110
|
}
|
|
63
|
-
|
|
111
|
+
const normalized = value.flatMap((candidate) => {
|
|
112
|
+
if (typeof candidate !== "string") {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
const trimmed = candidate.trim();
|
|
116
|
+
if (trimmed === "") {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
return [trimmed];
|
|
120
|
+
});
|
|
121
|
+
return normalized.length > 0 ? normalized : [...DEFAULTS.security.ignoreFiles];
|
|
64
122
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
};
|
|
69
|
-
if (!value) {
|
|
70
|
-
return out;
|
|
123
|
+
function sanitizeAllowFiles(value) {
|
|
124
|
+
if (!Array.isArray(value)) {
|
|
125
|
+
return [];
|
|
71
126
|
}
|
|
72
|
-
|
|
73
|
-
if (typeof candidate
|
|
74
|
-
|
|
75
|
-
"executable" in candidate &&
|
|
76
|
-
typeof candidate.executable === "string" &&
|
|
77
|
-
candidate.executable.trim() !== "") {
|
|
78
|
-
out[name] = candidate.executable.trim();
|
|
127
|
+
return value.flatMap((candidate) => {
|
|
128
|
+
if (typeof candidate !== "string") {
|
|
129
|
+
return [];
|
|
79
130
|
}
|
|
131
|
+
const trimmed = candidate.trim();
|
|
132
|
+
return trimmed === "" ? [] : [trimmed];
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
function ensureSelectedBackendIsConfigured(backend, backendConfigs) {
|
|
136
|
+
if (!(backend in backendConfigs)) {
|
|
137
|
+
throw new Error(`Backend "${backend}" is not configured. Add it under backends.${backend} with executable and args (including "{prompt}") in semlint.json.`);
|
|
80
138
|
}
|
|
81
|
-
return out;
|
|
82
139
|
}
|
|
83
140
|
function loadEffectiveConfig(options) {
|
|
84
141
|
const configPath = resolveConfigPath(options.configPath);
|
|
85
142
|
const parsed = configPath ? readJsonIfExists(configPath) : undefined;
|
|
86
143
|
const fileConfig = (parsed ?? {});
|
|
144
|
+
const backend = options.backend ?? fileConfig.backend ?? DEFAULTS.backend;
|
|
145
|
+
const backendConfigs = sanitizeBackendConfigs((fileConfig.backends ?? undefined));
|
|
146
|
+
ensureSelectedBackendIsConfigured(backend, backendConfigs);
|
|
147
|
+
const backendModel = backendConfigs[backend]?.model;
|
|
87
148
|
return {
|
|
88
|
-
backend
|
|
89
|
-
model: options.model ??
|
|
149
|
+
backend,
|
|
150
|
+
model: options.model ?? backendModel ?? DEFAULTS.model,
|
|
90
151
|
timeoutMs: typeof fileConfig.budgets?.timeout_ms === "number"
|
|
91
152
|
? fileConfig.budgets.timeout_ms
|
|
92
153
|
: DEFAULTS.timeoutMs,
|
|
@@ -103,6 +164,14 @@ function loadEffectiveConfig(options) {
|
|
|
103
164
|
? fileConfig.rules?.disable.filter((item) => typeof item === "string")
|
|
104
165
|
: DEFAULTS.rulesDisable,
|
|
105
166
|
severityOverrides: sanitizeSeverityOverrides((fileConfig.rules?.severity_overrides ?? undefined)),
|
|
106
|
-
|
|
167
|
+
backendConfigs,
|
|
168
|
+
security: {
|
|
169
|
+
secretGuard: typeof fileConfig.security?.secret_guard === "boolean"
|
|
170
|
+
? fileConfig.security.secret_guard
|
|
171
|
+
: DEFAULTS.security.secretGuard,
|
|
172
|
+
allowPatterns: sanitizeAllowPatterns(fileConfig.security?.allow_patterns),
|
|
173
|
+
ignoreFiles: sanitizeIgnoreFiles(fileConfig.security?.ignore_files),
|
|
174
|
+
allowFiles: sanitizeAllowFiles(fileConfig.security?.allow_files)
|
|
175
|
+
}
|
|
107
176
|
};
|
|
108
177
|
}
|
package/dist/diagnostics.js
CHANGED
|
@@ -8,23 +8,19 @@ exports.sortDiagnostics = sortDiagnostics;
|
|
|
8
8
|
exports.hasBlockingDiagnostic = hasBlockingDiagnostic;
|
|
9
9
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
-
const
|
|
12
|
-
function isPositiveInteger(value) {
|
|
13
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 1;
|
|
14
|
-
}
|
|
11
|
+
const utils_1 = require("./utils");
|
|
15
12
|
/**
|
|
16
13
|
* @param resolveRoot - Directory to resolve diagnostic file paths against (e.g. git repo root).
|
|
17
14
|
* Git diff paths are repo-relative; resolving from repo root ensures paths exist when running from subdirs.
|
|
18
15
|
*/
|
|
19
16
|
function normalizeDiagnostics(ruleId, diagnostics, debug, resolveRoot) {
|
|
20
|
-
const out = [];
|
|
21
17
|
const baseDir = resolveRoot && resolveRoot.length > 0 ? resolveRoot : process.cwd();
|
|
22
|
-
|
|
18
|
+
return diagnostics.flatMap((raw) => {
|
|
23
19
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
24
20
|
if (debug) {
|
|
25
21
|
process.stderr.write(`[debug] Dropped diagnostic for ${ruleId}: not an object\n`);
|
|
26
22
|
}
|
|
27
|
-
|
|
23
|
+
return [];
|
|
28
24
|
}
|
|
29
25
|
const candidate = raw;
|
|
30
26
|
const severity = candidate.severity;
|
|
@@ -39,34 +35,34 @@ function normalizeDiagnostics(ruleId, diagnostics, debug, resolveRoot) {
|
|
|
39
35
|
typeof message !== "string" ||
|
|
40
36
|
message.trim() === "" ||
|
|
41
37
|
typeof severity !== "string" ||
|
|
42
|
-
!VALID_SEVERITIES.has(severity) ||
|
|
43
|
-
!isPositiveInteger(line)) {
|
|
38
|
+
!utils_1.VALID_SEVERITIES.has(severity) ||
|
|
39
|
+
!(0, utils_1.isPositiveInteger)(line)) {
|
|
44
40
|
if (debug) {
|
|
45
41
|
process.stderr.write(`[debug] Dropped diagnostic for ${ruleId}: failed required field validation\n`);
|
|
46
42
|
}
|
|
47
|
-
|
|
43
|
+
return [];
|
|
48
44
|
}
|
|
49
|
-
|
|
50
|
-
if (!node_fs_1.default.existsSync(absolute)) {
|
|
45
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.resolve(baseDir, file))) {
|
|
51
46
|
if (debug) {
|
|
52
47
|
process.stderr.write(`[debug] Dropped diagnostic for ${ruleId}: file does not exist (${file})\n`);
|
|
53
48
|
}
|
|
54
|
-
|
|
49
|
+
return [];
|
|
55
50
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
rule_id: candidateRuleId,
|
|
54
|
+
severity: severity,
|
|
55
|
+
message,
|
|
56
|
+
file,
|
|
57
|
+
line,
|
|
58
|
+
column: (0, utils_1.isPositiveInteger)(candidate.column) ? candidate.column : undefined,
|
|
59
|
+
end_line: (0, utils_1.isPositiveInteger)(candidate.end_line) ? candidate.end_line : undefined,
|
|
60
|
+
end_column: (0, utils_1.isPositiveInteger)(candidate.end_column) ? candidate.end_column : undefined,
|
|
61
|
+
evidence: typeof candidate.evidence === "string" ? candidate.evidence : undefined,
|
|
62
|
+
confidence: typeof candidate.confidence === "number" ? candidate.confidence : undefined
|
|
63
|
+
}
|
|
64
|
+
];
|
|
65
|
+
});
|
|
70
66
|
}
|
|
71
67
|
const SEVERITY_ORDER = {
|
|
72
68
|
error: 3,
|