semlint-cli 0.1.4 → 0.1.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/README.md CHANGED
@@ -75,7 +75,6 @@ Unknown fields are ignored.
75
75
  ```json
76
76
  {
77
77
  "backend": "cursor-cli",
78
- "model": "auto",
79
78
  "budgets": {
80
79
  "timeout_ms": 120000
81
80
  },
@@ -93,7 +92,9 @@ Unknown fields are ignored.
93
92
  },
94
93
  "backends": {
95
94
  "cursor-cli": {
96
- "executable": "agent"
95
+ "executable": "cursor",
96
+ "model": "auto",
97
+ "args": ["agent", "{prompt}", "--model", "{model}", "--print", "--mode", "ask", "--output-format", "text"]
97
98
  }
98
99
  }
99
100
  }
@@ -138,16 +139,32 @@ Invalid rules cause runtime failure with exit code `2`.
138
139
 
139
140
  ## Backend contract
140
141
 
141
- For backend `cursor-cli`, Semlint executes:
142
+ Semlint is fully config-driven at runtime. For the selected `backend`, it executes:
142
143
 
143
- ```bash
144
- cursor agent "<prompt>" --model <model> --print --output-format text
145
- ```
144
+ - `backends.<backend>.executable` as the binary
145
+ - `backends.<backend>.model` as the backend-specific model (unless `--model` is passed)
146
+ - `backends.<backend>.args` as the argument template
147
+
148
+ `args` supports placeholder tokens:
146
149
 
147
- For `cursor-cli`, Semlint always uses `cursor agent` directly.
148
- Other backend names still resolve executables from config:
150
+ - `{prompt}`: replaced with the generated prompt
151
+ - `{model}`: replaced with the configured model
149
152
 
150
- - `backends.<backend>.executable` if provided
153
+ Placeholders are exact-match substitutions on whole args. Backends must be fully configured in `semlint.json`; there are no runtime fallbacks.
154
+
155
+ Example:
156
+
157
+ ```json
158
+ {
159
+ "backends": {
160
+ "cursor-cli": {
161
+ "executable": "cursor",
162
+ "model": "auto",
163
+ "args": ["agent", "{prompt}", "--model", "{model}", "--print", "--mode", "ask", "--output-format", "text"]
164
+ }
165
+ }
166
+ }
167
+ ```
151
168
 
152
169
  Backend stdout must be valid JSON with shape:
153
170
 
@@ -168,6 +185,17 @@ Backend stdout must be valid JSON with shape:
168
185
  If parsing fails, Semlint retries once with appended instruction:
169
186
  `Return valid JSON only.`
170
187
 
188
+ 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.
189
+
190
+ ## Prompt files
191
+
192
+ Core system prompts are externalized under `prompts/` so prompt behavior is easy to inspect and iterate:
193
+
194
+ - `prompts/common-contract.md`: shared output schema and base rules used by both modes
195
+ - `prompts/rule.md`: single-rule evaluation prompt
196
+ - `prompts/batch.md`: batch evaluation prompt
197
+ - `prompts/retry-json.md`: strict JSON retry instruction
198
+
171
199
  ## Batch mode
172
200
 
173
201
  Use batch mode to reduce cost by evaluating all runnable rules in a single backend call:
@@ -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 STRICT_JSON_RETRY_INSTRUCTION = [
6
- "Return valid JSON only.",
7
- "Do not include markdown fences.",
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 debugLog(enabled, message) {
19
- if (enabled) {
20
- process.stderr.write(`[debug] ${message}\n`);
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
- if (config.backend === "cursor-cli") {
25
- // Always use `cursor agent` directly for cursor-cli.
26
- return [{ executable: "cursor", argsPrefix: ["agent"] }];
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 [{ executable: configuredExecutable, argsPrefix: [] }];
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
- debugLog(config.debug, `${input.label}: backend attempt 1 via "${commandName}" (timeout ${input.timeoutMs}ms)`);
158
- const first = await executeBackendCommand(spec.executable, baseArgs, input.timeoutMs);
159
- debugLog(config.debug, `${input.label}: backend attempt 1 completed in ${first.elapsedMs}ms`);
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 lastError instanceof Error ? lastError : new Error(String(lastError));
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 VALID_SEVERITIES = new Set(["error", "warn", "info"]);
10
+ const utils_1 = require("./utils");
11
11
  const DEFAULTS = {
12
12
  backend: "cursor-cli",
13
13
  model: "auto",
@@ -20,9 +20,7 @@ const DEFAULTS = {
20
20
  batchMode: false,
21
21
  rulesDisable: [],
22
22
  severityOverrides: {},
23
- backendExecutables: {
24
- "cursor-cli": "cursor"
25
- }
23
+ backendConfigs: {}
26
24
  };
27
25
  function readJsonIfExists(filePath) {
28
26
  if (!node_fs_1.default.existsSync(filePath)) {
@@ -54,39 +52,51 @@ function sanitizeSeverityOverrides(value) {
54
52
  if (!value) {
55
53
  return {};
56
54
  }
57
- const out = {};
58
- for (const [ruleId, severity] of Object.entries(value)) {
59
- if (typeof severity === "string" && VALID_SEVERITIES.has(severity)) {
60
- out[ruleId] = severity;
61
- }
62
- }
63
- return out;
55
+ return Object.fromEntries(Object.entries(value).flatMap(([ruleId, severity]) => typeof severity === "string" && utils_1.VALID_SEVERITIES.has(severity)
56
+ ? [[ruleId, severity]]
57
+ : []));
64
58
  }
65
- function sanitizeBackendExecutables(value) {
66
- const out = {
67
- ...DEFAULTS.backendExecutables
68
- };
59
+ function sanitizeBackendConfigs(value) {
69
60
  if (!value) {
70
- return out;
61
+ return {};
71
62
  }
72
- for (const [name, candidate] of Object.entries(value)) {
73
- if (typeof candidate === "object" &&
74
- candidate !== null &&
75
- "executable" in candidate &&
76
- typeof candidate.executable === "string" &&
77
- candidate.executable.trim() !== "") {
78
- out[name] = candidate.executable.trim();
63
+ return Object.fromEntries(Object.entries(value).flatMap(([name, candidate]) => {
64
+ if (typeof candidate !== "object" || candidate === null) {
65
+ return [];
66
+ }
67
+ const executable = "executable" in candidate && typeof candidate.executable === "string"
68
+ ? candidate.executable.trim()
69
+ : "";
70
+ const args = "args" in candidate ? candidate.args : undefined;
71
+ const model = "model" in candidate && typeof candidate.model === "string"
72
+ ? candidate.model.trim()
73
+ : undefined;
74
+ if (!executable || !Array.isArray(args) || args.some((arg) => typeof arg !== "string")) {
75
+ return [];
79
76
  }
77
+ const normalizedArgs = args;
78
+ if (!normalizedArgs.includes("{prompt}")) {
79
+ return [];
80
+ }
81
+ return [[name, { executable, args: normalizedArgs, model: model && model !== "" ? model : undefined }]];
82
+ }));
83
+ }
84
+ function ensureSelectedBackendIsConfigured(backend, backendConfigs) {
85
+ if (!(backend in backendConfigs)) {
86
+ throw new Error(`Backend "${backend}" is not configured. Add it under backends.${backend} with executable and args (including "{prompt}") in semlint.json.`);
80
87
  }
81
- return out;
82
88
  }
83
89
  function loadEffectiveConfig(options) {
84
90
  const configPath = resolveConfigPath(options.configPath);
85
91
  const parsed = configPath ? readJsonIfExists(configPath) : undefined;
86
92
  const fileConfig = (parsed ?? {});
93
+ const backend = options.backend ?? fileConfig.backend ?? DEFAULTS.backend;
94
+ const backendConfigs = sanitizeBackendConfigs((fileConfig.backends ?? undefined));
95
+ ensureSelectedBackendIsConfigured(backend, backendConfigs);
96
+ const backendModel = backendConfigs[backend]?.model;
87
97
  return {
88
- backend: options.backend ?? fileConfig.backend ?? DEFAULTS.backend,
89
- model: options.model ?? fileConfig.model ?? DEFAULTS.model,
98
+ backend,
99
+ model: options.model ?? backendModel ?? DEFAULTS.model,
90
100
  timeoutMs: typeof fileConfig.budgets?.timeout_ms === "number"
91
101
  ? fileConfig.budgets.timeout_ms
92
102
  : DEFAULTS.timeoutMs,
@@ -103,6 +113,6 @@ function loadEffectiveConfig(options) {
103
113
  ? fileConfig.rules?.disable.filter((item) => typeof item === "string")
104
114
  : DEFAULTS.rulesDisable,
105
115
  severityOverrides: sanitizeSeverityOverrides((fileConfig.rules?.severity_overrides ?? undefined)),
106
- backendExecutables: sanitizeBackendExecutables((fileConfig.backends ?? undefined))
116
+ backendConfigs
107
117
  };
108
118
  }
@@ -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 VALID_SEVERITIES = new Set(["error", "warn", "info"]);
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
- for (const raw of diagnostics) {
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
- continue;
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
- continue;
43
+ return [];
48
44
  }
49
- const absolute = node_path_1.default.resolve(baseDir, file);
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
- continue;
49
+ return [];
55
50
  }
56
- out.push({
57
- rule_id: candidateRuleId,
58
- severity: severity,
59
- message,
60
- file,
61
- line,
62
- column: isPositiveInteger(candidate.column) ? candidate.column : undefined,
63
- end_line: isPositiveInteger(candidate.end_line) ? candidate.end_line : undefined,
64
- end_column: isPositiveInteger(candidate.end_column) ? candidate.end_column : undefined,
65
- evidence: typeof candidate.evidence === "string" ? candidate.evidence : undefined,
66
- confidence: typeof candidate.confidence === "number" ? candidate.confidence : undefined
67
- });
68
- }
69
- return out;
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,
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runBatchDispatch = runBatchDispatch;
4
+ exports.runParallelDispatch = runParallelDispatch;
5
+ const diagnostics_1 = require("./diagnostics");
6
+ const filter_1 = require("./filter");
7
+ const prompts_1 = require("./prompts");
8
+ const utils_1 = require("./utils");
9
+ function buildBatchPrompt(rules, diff) {
10
+ const ruleBlocks = rules
11
+ .map((rule) => [
12
+ `RULE_ID: ${rule.id}`,
13
+ `RULE_TITLE: ${rule.title}`,
14
+ `SEVERITY_DEFAULT: ${rule.effectiveSeverity}`,
15
+ "INSTRUCTIONS:",
16
+ rule.prompt
17
+ ].join("\n"))
18
+ .join("\n\n---\n\n");
19
+ return (0, prompts_1.renderBatchPrompt)({ ruleBlocks, diff });
20
+ }
21
+ async function runBatchDispatch(input) {
22
+ const { rules, diff, changedFiles, backend, config, repoRoot } = input;
23
+ const diagnostics = [];
24
+ let backendErrors = 0;
25
+ (0, utils_1.debugLog)(config.debug, `Running ${rules.length} rule(s) in batch mode`);
26
+ const combinedDiff = rules
27
+ .map((rule) => (0, filter_1.buildScopedDiff)(rule, diff, changedFiles))
28
+ .filter((chunk) => chunk.trim() !== "")
29
+ .join("\n");
30
+ const batchPrompt = buildBatchPrompt(rules, combinedDiff || diff);
31
+ try {
32
+ const batchResult = await backend.runPrompt({
33
+ label: "Batch",
34
+ prompt: batchPrompt,
35
+ timeoutMs: config.timeoutMs
36
+ });
37
+ const groupedByRule = batchResult.diagnostics.reduce((acc, diagnostic) => {
38
+ if (typeof diagnostic === "object" &&
39
+ diagnostic !== null &&
40
+ !Array.isArray(diagnostic) &&
41
+ typeof diagnostic.rule_id === "string") {
42
+ const ruleId = diagnostic.rule_id;
43
+ acc.set(ruleId, [...(acc.get(ruleId) ?? []), diagnostic]);
44
+ return acc;
45
+ }
46
+ (0, utils_1.debugLog)(config.debug, "Batch: dropped diagnostic without valid rule_id");
47
+ return acc;
48
+ }, new Map());
49
+ const validRuleIds = new Set(rules.map((rule) => rule.id));
50
+ Array.from(groupedByRule.keys())
51
+ .filter((ruleId) => !validRuleIds.has(ruleId))
52
+ .forEach((ruleId) => {
53
+ (0, utils_1.debugLog)(config.debug, `Batch: dropped diagnostic for unknown rule_id ${ruleId}`);
54
+ });
55
+ const normalized = rules.flatMap((rule) => (0, diagnostics_1.normalizeDiagnostics)(rule.id, groupedByRule.get(rule.id) ?? [], config.debug, repoRoot));
56
+ diagnostics.push(...normalized);
57
+ }
58
+ catch (error) {
59
+ backendErrors += 1;
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ (0, utils_1.debugLog)(config.debug, `Batch backend error: ${message}`);
62
+ }
63
+ return { diagnostics, backendErrors };
64
+ }
65
+ async function runParallelDispatch(input) {
66
+ const { rules, diff, changedFiles, backend, config, repoRoot } = input;
67
+ (0, utils_1.debugLog)(config.debug, `Running ${rules.length} rule(s) in parallel`);
68
+ const runResults = await Promise.all(rules.map(async (rule) => {
69
+ const ruleStartedAt = Date.now();
70
+ (0, utils_1.debugLog)(config.debug, `Rule ${rule.id}: started`);
71
+ const scopedDiff = (0, filter_1.buildScopedDiff)(rule, diff, changedFiles);
72
+ const prompt = (0, filter_1.buildRulePrompt)(rule, scopedDiff);
73
+ try {
74
+ const result = await backend.runRule({
75
+ ruleId: rule.id,
76
+ prompt,
77
+ timeoutMs: config.timeoutMs
78
+ });
79
+ const normalized = (0, diagnostics_1.normalizeDiagnostics)(rule.id, result.diagnostics, config.debug, repoRoot);
80
+ (0, utils_1.debugLog)(config.debug, `Rule ${rule.id}: finished in ${Date.now() - ruleStartedAt}ms`);
81
+ return { backendError: false, normalized };
82
+ }
83
+ catch (error) {
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ (0, utils_1.debugLog)(config.debug, `Backend error for rule ${rule.id}: ${message}`);
86
+ (0, utils_1.debugLog)(config.debug, `Rule ${rule.id}: finished in ${Date.now() - ruleStartedAt}ms`);
87
+ return { backendError: true, normalized: [] };
88
+ }
89
+ }));
90
+ return {
91
+ diagnostics: runResults.flatMap((result) => result.normalized),
92
+ backendErrors: runResults.filter((result) => result.backendError).length
93
+ };
94
+ }
package/dist/filter.js CHANGED
@@ -9,6 +9,7 @@ exports.shouldRunRule = shouldRunRule;
9
9
  exports.buildScopedDiff = buildScopedDiff;
10
10
  exports.buildRulePrompt = buildRulePrompt;
11
11
  const picomatch_1 = __importDefault(require("picomatch"));
12
+ const prompts_1 = require("./prompts");
12
13
  function unquoteDiffPath(raw) {
13
14
  if (raw.startsWith("\"") && raw.endsWith("\"") && raw.length >= 2) {
14
15
  return raw.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
@@ -48,9 +49,6 @@ function extractChangedFilesFromDiff(diff) {
48
49
  }
49
50
  return Array.from(files);
50
51
  }
51
- function matchesAnyGlob(filePath, globs) {
52
- return globs.some((glob) => (0, picomatch_1.default)(glob)(filePath));
53
- }
54
52
  function matchesAnyRegex(diff, regexes) {
55
53
  for (const candidate of regexes) {
56
54
  try {
@@ -67,14 +65,16 @@ function matchesAnyRegex(diff, regexes) {
67
65
  }
68
66
  function getRuleCandidateFiles(rule, changedFiles) {
69
67
  let fileCandidates = changedFiles;
70
- if (rule.include_globs && rule.include_globs.length > 0) {
71
- fileCandidates = changedFiles.filter((filePath) => matchesAnyGlob(filePath, rule.include_globs));
68
+ const includeMatcher = rule.include_globs && rule.include_globs.length > 0 ? (0, picomatch_1.default)(rule.include_globs) : null;
69
+ const excludeMatcher = rule.exclude_globs && rule.exclude_globs.length > 0 ? (0, picomatch_1.default)(rule.exclude_globs) : null;
70
+ if (includeMatcher) {
71
+ fileCandidates = changedFiles.filter((filePath) => includeMatcher(filePath));
72
72
  if (fileCandidates.length === 0) {
73
73
  return [];
74
74
  }
75
75
  }
76
- if (rule.exclude_globs && rule.exclude_globs.length > 0) {
77
- fileCandidates = fileCandidates.filter((filePath) => !matchesAnyGlob(filePath, rule.exclude_globs));
76
+ if (excludeMatcher) {
77
+ fileCandidates = fileCandidates.filter((filePath) => !excludeMatcher(filePath));
78
78
  }
79
79
  return fileCandidates;
80
80
  }
@@ -130,41 +130,11 @@ function buildScopedDiff(rule, fullDiff, changedFiles) {
130
130
  return scoped.trim() === "" ? fullDiff : scoped;
131
131
  }
132
132
  function buildRulePrompt(rule, diff) {
133
- return [
134
- "You are Semlint, an expert semantic code reviewer.",
135
- "Analyze ONLY the modified code present in the DIFF below.",
136
- "Return JSON only (no markdown, no prose, no code fences).",
137
- "Output schema:",
138
- "{",
139
- " \"diagnostics\": [",
140
- " {",
141
- " \"rule_id\": string,",
142
- " \"severity\": \"error\" | \"warn\" | \"info\",",
143
- " \"message\": string,",
144
- " \"file\": string,",
145
- " \"line\": number,",
146
- " \"column\"?: number,",
147
- " \"end_line\"?: number,",
148
- " \"end_column\"?: number,",
149
- " \"evidence\"?: string,",
150
- " \"confidence\"?: number",
151
- " }",
152
- " ]",
153
- "}",
154
- "Rules:",
155
- "- If there are no findings, return {\"diagnostics\":[]}.",
156
- "- Each diagnostic must reference a changed file from the DIFF.",
157
- "- Use the provided RULE_ID exactly in every diagnostic.",
158
- "- Keep messages concise and actionable.",
159
- "",
160
- `RULE_ID: ${rule.id}`,
161
- `RULE_TITLE: ${rule.title}`,
162
- `SEVERITY_DEFAULT: ${rule.effectiveSeverity}`,
163
- "",
164
- "INSTRUCTIONS:",
165
- rule.prompt,
166
- "",
167
- "DIFF:",
133
+ return (0, prompts_1.renderRulePrompt)({
134
+ ruleId: rule.id,
135
+ ruleTitle: rule.title,
136
+ severityDefault: rule.effectiveSeverity,
137
+ instructions: rule.prompt,
168
138
  diff
169
- ].join("\n");
139
+ });
170
140
  }
package/dist/init.js CHANGED
@@ -8,6 +8,23 @@ const picocolors_1 = __importDefault(require("picocolors"));
8
8
  const node_fs_1 = __importDefault(require("node:fs"));
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
10
  const node_child_process_1 = require("node:child_process");
11
+ const SCAFFOLD_BACKENDS = {
12
+ "cursor-cli": {
13
+ executable: "cursor",
14
+ args: ["agent", "{prompt}", "--model", "{model}", "--print", "--mode", "ask", "--output-format", "text"],
15
+ model: "auto"
16
+ },
17
+ "claude-code": {
18
+ executable: "claude",
19
+ args: ["{prompt}", "--model", "{model}", "--output-format", "json"],
20
+ model: "auto"
21
+ },
22
+ "codex-cli": {
23
+ executable: "codex",
24
+ args: ["{prompt}", "--model", "{model}"],
25
+ model: "auto"
26
+ }
27
+ };
11
28
  function commandExists(command) {
12
29
  const result = (0, node_child_process_1.spawnSync)(command, ["--version"], {
13
30
  stdio: "ignore"
@@ -34,12 +51,21 @@ function detectBackend() {
34
51
  ];
35
52
  for (const candidate of candidates) {
36
53
  if (commandExists(candidate.executable)) {
37
- return candidate;
54
+ const scaffold = SCAFFOLD_BACKENDS[candidate.backend];
55
+ return {
56
+ backend: candidate.backend,
57
+ executable: scaffold.executable,
58
+ args: scaffold.args,
59
+ model: scaffold.model,
60
+ reason: candidate.reason
61
+ };
38
62
  }
39
63
  }
40
64
  return {
41
65
  backend: "cursor-cli",
42
- executable: "cursor",
66
+ executable: SCAFFOLD_BACKENDS["cursor-cli"].executable,
67
+ args: SCAFFOLD_BACKENDS["cursor-cli"].args,
68
+ model: SCAFFOLD_BACKENDS["cursor-cli"].model,
43
69
  reason: "no known agent CLI detected, using default Cursor setup"
44
70
  };
45
71
  }
@@ -52,7 +78,6 @@ function scaffoldConfig(force = false) {
52
78
  const detected = detectBackend();
53
79
  const scaffold = {
54
80
  backend: detected.backend,
55
- model: "auto",
56
81
  budgets: {
57
82
  timeout_ms: 120000
58
83
  },
@@ -68,7 +93,9 @@ function scaffoldConfig(force = false) {
68
93
  },
69
94
  backends: {
70
95
  [detected.backend]: {
71
- executable: detected.executable
96
+ executable: detected.executable,
97
+ args: detected.args,
98
+ model: detected.model
72
99
  }
73
100
  }
74
101
  };
package/dist/main.js CHANGED
@@ -7,77 +7,28 @@ exports.runSemlint = runSemlint;
7
7
  const picocolors_1 = __importDefault(require("picocolors"));
8
8
  const nanospinner_1 = require("nanospinner");
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
+ const package_json_1 = require("../package.json");
10
11
  const backend_1 = require("./backend");
11
12
  const config_1 = require("./config");
12
13
  const diagnostics_1 = require("./diagnostics");
14
+ const dispatch_1 = require("./dispatch");
13
15
  const filter_1 = require("./filter");
14
16
  const git_1 = require("./git");
15
17
  const reporter_1 = require("./reporter");
16
18
  const rules_1 = require("./rules");
17
- const VERSION = "0.1.0";
18
- function debugLog(enabled, message) {
19
- if (enabled) {
20
- process.stderr.write(`${picocolors_1.default.gray(`[debug] ${message}`)}\n`);
21
- }
22
- }
19
+ const utils_1 = require("./utils");
23
20
  function timed(enabled, label, action) {
24
21
  const startedAt = Date.now();
25
22
  const result = action();
26
- debugLog(enabled, `${label} in ${Date.now() - startedAt}ms`);
23
+ (0, utils_1.debugLog)(enabled, `${label} in ${Date.now() - startedAt}ms`);
27
24
  return result;
28
25
  }
29
26
  async function timedAsync(enabled, label, action) {
30
27
  const startedAt = Date.now();
31
28
  const result = await action();
32
- debugLog(enabled, `${label} in ${Date.now() - startedAt}ms`);
29
+ (0, utils_1.debugLog)(enabled, `${label} in ${Date.now() - startedAt}ms`);
33
30
  return result;
34
31
  }
35
- function buildBatchPrompt(rules, diff) {
36
- const ruleBlocks = rules
37
- .map((rule) => [
38
- `RULE_ID: ${rule.id}`,
39
- `RULE_TITLE: ${rule.title}`,
40
- `SEVERITY_DEFAULT: ${rule.effectiveSeverity}`,
41
- "INSTRUCTIONS:",
42
- rule.prompt
43
- ].join("\n"))
44
- .join("\n\n---\n\n");
45
- return [
46
- "You are Semlint, an expert semantic code reviewer.",
47
- "BATCH_MODE: true",
48
- "Evaluate all rules below against the DIFF in one pass.",
49
- "Analyze ONLY the modified code present in the DIFF below.",
50
- "Return JSON only (no markdown, no prose, no code fences).",
51
- "Output schema:",
52
- "{",
53
- " \"diagnostics\": [",
54
- " {",
55
- " \"rule_id\": string,",
56
- " \"severity\": \"error\" | \"warn\" | \"info\",",
57
- " \"message\": string,",
58
- " \"file\": string,",
59
- " \"line\": number,",
60
- " \"column\"?: number,",
61
- " \"end_line\"?: number,",
62
- " \"end_column\"?: number,",
63
- " \"evidence\"?: string,",
64
- " \"confidence\"?: number",
65
- " }",
66
- " ]",
67
- "}",
68
- "Rules:",
69
- "- If there are no findings, return {\"diagnostics\":[]}.",
70
- "- Each diagnostic must reference a changed file from the DIFF.",
71
- "- rule_id must match one of the RULE_ID values listed below.",
72
- "- Keep messages concise and actionable.",
73
- "",
74
- "RULES:",
75
- ruleBlocks,
76
- "",
77
- "DIFF:",
78
- diff
79
- ].join("\n");
80
- }
81
32
  async function runSemlint(options) {
82
33
  const startedAt = Date.now();
83
34
  let spinner = null;
@@ -85,28 +36,28 @@ async function runSemlint(options) {
85
36
  const config = timed(options.debug, "Loaded effective config", () => (0, config_1.loadEffectiveConfig)(options));
86
37
  const rulesDir = node_path_1.default.join(process.cwd(), ".semlint", "rules");
87
38
  const rules = timed(config.debug, "Loaded and validated rules", () => (0, rules_1.loadRules)(rulesDir, config.rulesDisable, config.severityOverrides));
88
- debugLog(config.debug, `Loaded ${rules.length} rule(s)`);
89
- debugLog(config.debug, `Rule IDs: ${rules.map((rule) => rule.id).join(", ")}`);
39
+ (0, utils_1.debugLog)(config.debug, `Loaded ${rules.length} rule(s)`);
40
+ (0, utils_1.debugLog)(config.debug, `Rule IDs: ${rules.map((rule) => rule.id).join(", ")}`);
90
41
  const useLocalBranchDiff = !options.base && !options.head;
91
42
  const diff = await timedAsync(config.debug, "Computed git diff", () => useLocalBranchDiff ? (0, git_1.getLocalBranchDiff)() : (0, git_1.getGitDiff)(config.base, config.head));
92
43
  const changedFiles = timed(config.debug, "Parsed changed files from diff", () => (0, filter_1.extractChangedFilesFromDiff)(diff));
93
- debugLog(config.debug, useLocalBranchDiff
44
+ (0, utils_1.debugLog)(config.debug, useLocalBranchDiff
94
45
  ? "Using local branch diff (staged + unstaged + untracked only)"
95
46
  : `Using explicit ref diff (${config.base}..${config.head})`);
96
- debugLog(config.debug, `Detected ${changedFiles.length} changed file(s)`);
47
+ (0, utils_1.debugLog)(config.debug, `Detected ${changedFiles.length} changed file(s)`);
97
48
  const repoRoot = await timedAsync(config.debug, "Resolved git repo root", () => (0, git_1.getRepoRoot)());
98
49
  const backend = timed(config.debug, "Initialized backend runner", () => (0, backend_1.createBackendRunner)(config));
99
50
  const runnableRules = rules.filter((rule) => {
100
51
  const filterStartedAt = Date.now();
101
52
  const shouldRun = (0, filter_1.shouldRunRule)(rule, changedFiles, diff);
102
- debugLog(config.debug, `Rule ${rule.id}: filter check in ${Date.now() - filterStartedAt}ms`);
53
+ (0, utils_1.debugLog)(config.debug, `Rule ${rule.id}: filter check in ${Date.now() - filterStartedAt}ms`);
103
54
  if (!shouldRun) {
104
- debugLog(config.debug, `Skipping rule ${rule.id}: filters did not match`);
55
+ (0, utils_1.debugLog)(config.debug, `Skipping rule ${rule.id}: filters did not match`);
105
56
  return false;
106
57
  }
107
58
  return true;
108
59
  });
109
- const diagnostics = [];
60
+ let diagnostics = [];
110
61
  const rulesRun = runnableRules.length;
111
62
  let backendErrors = 0;
112
63
  if (config.format !== "json" && rulesRun > 0) {
@@ -120,82 +71,27 @@ async function runSemlint(options) {
120
71
  config.format !== "json" && rulesRun > 0
121
72
  ? (0, nanospinner_1.createSpinner)(`Analyzing ${changedFiles.length} file${changedFiles.length === 1 ? "" : "s"} with ${config.backend} in ${config.batchMode ? "batch" : "parallel"} mode...`).start()
122
73
  : null;
123
- if (config.batchMode && runnableRules.length > 0) {
124
- debugLog(config.debug, `Running ${runnableRules.length} rule(s) in batch mode`);
125
- const combinedDiff = timed(config.debug, "Batch: combined scoped diff build", () => runnableRules
126
- .map((rule) => (0, filter_1.buildScopedDiff)(rule, diff, changedFiles))
127
- .filter((chunk) => chunk.trim() !== "")
128
- .join("\n"));
129
- const batchPrompt = timed(config.debug, "Batch: prompt build", () => buildBatchPrompt(runnableRules, combinedDiff || diff));
130
- try {
131
- const batchResult = await timedAsync(config.debug, "Batch: backend run", () => backend.runPrompt({
132
- label: "Batch",
133
- prompt: batchPrompt,
134
- timeoutMs: config.timeoutMs
74
+ if (runnableRules.length > 0) {
75
+ const dispatchLabel = config.batchMode ? "batch" : "parallel";
76
+ const result = await timedAsync(config.debug, `Dispatch (${dispatchLabel})`, () => config.batchMode
77
+ ? (0, dispatch_1.runBatchDispatch)({
78
+ rules: runnableRules,
79
+ diff,
80
+ changedFiles,
81
+ backend,
82
+ config,
83
+ repoRoot
84
+ })
85
+ : (0, dispatch_1.runParallelDispatch)({
86
+ rules: runnableRules,
87
+ diff,
88
+ changedFiles,
89
+ backend,
90
+ config,
91
+ repoRoot
135
92
  }));
136
- const groupedByRule = new Map();
137
- for (const diagnostic of batchResult.diagnostics) {
138
- if (typeof diagnostic === "object" &&
139
- diagnostic !== null &&
140
- !Array.isArray(diagnostic) &&
141
- typeof diagnostic.rule_id === "string") {
142
- const ruleId = diagnostic.rule_id;
143
- const current = groupedByRule.get(ruleId) ?? [];
144
- current.push(diagnostic);
145
- groupedByRule.set(ruleId, current);
146
- }
147
- else {
148
- debugLog(config.debug, "Batch: dropped diagnostic without valid rule_id");
149
- }
150
- }
151
- const validRuleIds = new Set(runnableRules.map((rule) => rule.id));
152
- for (const [ruleId] of groupedByRule) {
153
- if (!validRuleIds.has(ruleId)) {
154
- debugLog(config.debug, `Batch: dropped diagnostic for unknown rule_id ${ruleId}`);
155
- }
156
- }
157
- for (const rule of runnableRules) {
158
- const normalized = timed(config.debug, `Batch: diagnostics normalization for ${rule.id}`, () => (0, diagnostics_1.normalizeDiagnostics)(rule.id, groupedByRule.get(rule.id) ?? [], config.debug, repoRoot));
159
- diagnostics.push(...normalized);
160
- }
161
- }
162
- catch (error) {
163
- backendErrors += 1;
164
- const message = error instanceof Error ? error.message : String(error);
165
- debugLog(config.debug, `Batch backend error: ${message}`);
166
- }
167
- }
168
- else {
169
- debugLog(config.debug, `Running ${runnableRules.length} rule(s) in parallel`);
170
- const runResults = await Promise.all(runnableRules.map(async (rule) => {
171
- let backendError = false;
172
- let normalized = [];
173
- const ruleStartedAt = Date.now();
174
- debugLog(config.debug, `Rule ${rule.id}: started`);
175
- const scopedDiff = timed(config.debug, `Rule ${rule.id}: scoped diff build`, () => (0, filter_1.buildScopedDiff)(rule, diff, changedFiles));
176
- const prompt = timed(config.debug, `Rule ${rule.id}: prompt build`, () => (0, filter_1.buildRulePrompt)(rule, scopedDiff));
177
- try {
178
- const result = await timedAsync(config.debug, `Rule ${rule.id}: backend run`, () => backend.runRule({
179
- ruleId: rule.id,
180
- prompt,
181
- timeoutMs: config.timeoutMs
182
- }));
183
- normalized = timed(config.debug, `Rule ${rule.id}: diagnostics normalization`, () => (0, diagnostics_1.normalizeDiagnostics)(rule.id, result.diagnostics, config.debug, repoRoot));
184
- }
185
- catch (error) {
186
- backendError = true;
187
- const message = error instanceof Error ? error.message : String(error);
188
- debugLog(config.debug, `Backend error for rule ${rule.id}: ${message}`);
189
- }
190
- debugLog(config.debug, `Rule ${rule.id}: finished in ${Date.now() - ruleStartedAt}ms`);
191
- return { backendError, normalized };
192
- }));
193
- for (const result of runResults) {
194
- if (result.backendError) {
195
- backendErrors += 1;
196
- }
197
- diagnostics.push(...result.normalized);
198
- }
93
+ diagnostics = result.diagnostics;
94
+ backendErrors = result.backendErrors;
199
95
  }
200
96
  const sorted = timed(config.debug, "Sorted diagnostics", () => (0, diagnostics_1.sortDiagnostics)(diagnostics));
201
97
  const durationMs = Date.now() - startedAt;
@@ -209,13 +105,13 @@ async function runSemlint(options) {
209
105
  }
210
106
  const outputStartedAt = Date.now();
211
107
  if (config.format === "json") {
212
- process.stdout.write(`${(0, reporter_1.formatJsonOutput)(VERSION, sorted, { rulesRun, durationMs, backendErrors })}\n`);
108
+ process.stdout.write(`${(0, reporter_1.formatJsonOutput)(package_json_1.version, sorted, { rulesRun, durationMs, backendErrors })}\n`);
213
109
  }
214
110
  else {
215
111
  process.stdout.write(`${(0, reporter_1.formatTextOutput)(sorted, { rulesRun, durationMs, backendErrors })}\n`);
216
112
  }
217
- debugLog(config.debug, `Rendered output in ${Date.now() - outputStartedAt}ms`);
218
- debugLog(config.debug, `Total run duration ${durationMs}ms`);
113
+ (0, utils_1.debugLog)(config.debug, `Rendered output in ${Date.now() - outputStartedAt}ms`);
114
+ (0, utils_1.debugLog)(config.debug, `Total run duration ${durationMs}ms`);
219
115
  if (backendErrors > 0) {
220
116
  return 2;
221
117
  }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getStrictJsonRetryInstruction = getStrictJsonRetryInstruction;
7
+ exports.renderRulePrompt = renderRulePrompt;
8
+ exports.renderBatchPrompt = renderBatchPrompt;
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const promptCache = new Map();
12
+ function readPromptFile(fileName) {
13
+ const cached = promptCache.get(fileName);
14
+ if (cached !== undefined) {
15
+ return cached;
16
+ }
17
+ const promptPath = node_path_1.default.resolve(__dirname, "..", "prompts", fileName);
18
+ const content = node_fs_1.default.readFileSync(promptPath, "utf8").trim();
19
+ promptCache.set(fileName, content);
20
+ return content;
21
+ }
22
+ function renderTemplate(template, values) {
23
+ return Object.entries(values).reduce((current, [key, value]) => current.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value), template);
24
+ }
25
+ function getStrictJsonRetryInstruction() {
26
+ return readPromptFile("retry-json.md").replace(/\s+/g, " ").trim();
27
+ }
28
+ function renderRulePrompt(values) {
29
+ const commonContract = readPromptFile("common-contract.md");
30
+ return renderTemplate(readPromptFile("rule.md"), {
31
+ COMMON_CONTRACT: commonContract,
32
+ RULE_ID: values.ruleId,
33
+ RULE_TITLE: values.ruleTitle,
34
+ SEVERITY_DEFAULT: values.severityDefault,
35
+ INSTRUCTIONS: values.instructions,
36
+ DIFF: values.diff
37
+ });
38
+ }
39
+ function renderBatchPrompt(values) {
40
+ const commonContract = readPromptFile("common-contract.md");
41
+ return renderTemplate(readPromptFile("batch.md"), {
42
+ COMMON_CONTRACT: commonContract,
43
+ RULE_BLOCKS: values.ruleBlocks,
44
+ DIFF: values.diff
45
+ });
46
+ }
package/dist/reporter.js CHANGED
@@ -31,19 +31,18 @@ function formatJsonOutput(version, diagnostics, stats) {
31
31
  return JSON.stringify(payload, null, 2);
32
32
  }
33
33
  function formatTextOutput(diagnostics, stats) {
34
- const lines = [];
35
- let currentFile = "";
36
- for (const diagnostic of diagnostics) {
37
- if (diagnostic.file !== currentFile) {
38
- if (lines.length > 0) {
39
- lines.push("");
40
- }
41
- currentFile = diagnostic.file;
42
- lines.push(picocolors_1.default.underline(currentFile));
43
- }
44
- const column = diagnostic.column ?? 1;
45
- lines.push(` ${picocolors_1.default.dim(`${diagnostic.line}:${column}`)} ${formatSeverity(diagnostic.severity)} ${picocolors_1.default.gray(diagnostic.rule_id)} ${diagnostic.message}`);
46
- }
34
+ const groupedByFile = diagnostics.reduce((acc, diagnostic) => {
35
+ acc.set(diagnostic.file, [...(acc.get(diagnostic.file) ?? []), diagnostic]);
36
+ return acc;
37
+ }, new Map());
38
+ const lines = Array.from(groupedByFile.entries()).flatMap(([file, fileDiagnostics], fileIndex) => [
39
+ ...(fileIndex > 0 ? [""] : []),
40
+ picocolors_1.default.underline(file),
41
+ ...fileDiagnostics.map((diagnostic) => {
42
+ const column = diagnostic.column ?? 1;
43
+ return ` ${picocolors_1.default.dim(`${diagnostic.line}:${column}`)} ${formatSeverity(diagnostic.severity)} ${picocolors_1.default.gray(diagnostic.rule_id)} ${diagnostic.message}`;
44
+ })
45
+ ]);
47
46
  const errors = diagnostics.filter((d) => d.severity === "error").length;
48
47
  const warnings = diagnostics.filter((d) => d.severity === "warn").length;
49
48
  const problems = diagnostics.length;
package/dist/rules.js CHANGED
@@ -6,7 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.loadRules = loadRules;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
- const VALID_SEVERITIES = new Set(["error", "warn", "info"]);
9
+ const utils_1 = require("./utils");
10
10
  function assertNonEmptyString(value, fieldName, filePath) {
11
11
  if (typeof value !== "string" || value.trim() === "") {
12
12
  throw new Error(`Invalid rule in ${filePath}: "${fieldName}" must be a non-empty string`);
@@ -28,7 +28,7 @@ function validateRuleObject(raw, filePath) {
28
28
  }
29
29
  const obj = raw;
30
30
  const severity = assertNonEmptyString(obj.severity_default, "severity_default", filePath);
31
- if (!VALID_SEVERITIES.has(severity)) {
31
+ if (!utils_1.VALID_SEVERITIES.has(severity)) {
32
32
  throw new Error(`Invalid rule in ${filePath}: "severity_default" must be one of error|warn|info`);
33
33
  }
34
34
  return {
@@ -52,10 +52,10 @@ function loadRules(rulesDir, disabledRuleIds, severityOverrides) {
52
52
  .readdirSync(rulesDir)
53
53
  .filter((name) => name.endsWith(".json"))
54
54
  .sort((a, b) => a.localeCompare(b));
55
- const seenIds = new Set();
56
55
  const disabled = new Set(disabledRuleIds);
57
- const loaded = [];
58
- for (const fileName of entries) {
56
+ const seenIds = new Set();
57
+ return entries
58
+ .map((fileName) => {
59
59
  const filePath = node_path_1.default.join(rulesDir, fileName);
60
60
  let parsed;
61
61
  try {
@@ -70,19 +70,17 @@ function loadRules(rulesDir, disabledRuleIds, severityOverrides) {
70
70
  throw new Error(`Duplicate rule id detected: ${validated.id}`);
71
71
  }
72
72
  seenIds.add(validated.id);
73
- if (disabled.has(validated.id)) {
74
- continue;
75
- }
76
73
  const overrideSeverity = severityOverrides[validated.id];
77
74
  const effectiveSeverity = overrideSeverity ?? validated.severity_default;
78
- if (!VALID_SEVERITIES.has(effectiveSeverity)) {
75
+ if (!utils_1.VALID_SEVERITIES.has(effectiveSeverity)) {
79
76
  throw new Error(`Invalid severity override for rule ${validated.id}`);
80
77
  }
81
- loaded.push({
78
+ return {
82
79
  ...validated,
83
80
  sourcePath: filePath,
84
81
  effectiveSeverity
85
- });
86
- }
87
- return loaded.sort((a, b) => a.id.localeCompare(b.id));
82
+ };
83
+ })
84
+ .filter((rule) => !disabled.has(rule.id))
85
+ .sort((a, b) => a.id.localeCompare(b.id));
88
86
  }
package/dist/utils.js ADDED
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.VALID_SEVERITIES = void 0;
7
+ exports.isPositiveInteger = isPositiveInteger;
8
+ exports.debugLog = debugLog;
9
+ const picocolors_1 = __importDefault(require("picocolors"));
10
+ exports.VALID_SEVERITIES = new Set(["error", "warn", "info"]);
11
+ function isPositiveInteger(value) {
12
+ return typeof value === "number" && Number.isInteger(value) && value >= 1;
13
+ }
14
+ function debugLog(enabled, message) {
15
+ if (enabled) {
16
+ process.stderr.write(`${picocolors_1.default.gray(`[debug] ${message}`)}\n`);
17
+ }
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "semlint-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Semantic lint CLI — runs LLM-backed rules on your git diff and returns CI-friendly exit codes",
5
5
  "type": "commonjs",
6
6
  "main": "dist/main.js",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
+ "prompts",
12
13
  "rules",
13
14
  "README.md"
14
15
  ],
@@ -0,0 +1,16 @@
1
+ You are Semlint, an expert semantic code reviewer.
2
+ BATCH_MODE: true
3
+ Evaluate all rules below against the DIFF in one pass.
4
+ Start from the modified/added code in the DIFF. You may inspect adjacent files/functions referenced by the changed code when necessary to verify patterns or behavior, but keep findings anchored to changed files and lines.
5
+ Return JSON only (no markdown, no prose, no code fences).
6
+ {{COMMON_CONTRACT}}
7
+ - rule_id must match one of the RULE_ID values listed below.
8
+ - Keep messages concise and actionable.
9
+ - Deduplicate semantically equivalent findings before returning output.
10
+ - When duplicates exist, keep a single diagnostic from the rule that semantically matches the issue best, even if that selected diagnostic has lower severity.
11
+
12
+ RULES:
13
+ {{RULE_BLOCKS}}
14
+
15
+ DIFF:
16
+ {{DIFF}}
@@ -0,0 +1,20 @@
1
+ Output schema:
2
+ {
3
+ "diagnostics": [
4
+ {
5
+ "rule_id": string,
6
+ "severity": "error" | "warn" | "info",
7
+ "message": string,
8
+ "file": string,
9
+ "line": number,
10
+ "column"?: number,
11
+ "end_line"?: number,
12
+ "end_column"?: number,
13
+ "evidence"?: string,
14
+ "confidence"?: number
15
+ }
16
+ ]
17
+ }
18
+ Rules:
19
+ - If there are no findings, return {"diagnostics":[]}.
20
+ - Each diagnostic must reference a changed file from the DIFF.
@@ -0,0 +1,5 @@
1
+ Return valid JSON only.
2
+ Do not include markdown fences.
3
+ Do not include commentary, headings, or any text before/after JSON.
4
+ The first character of your response must be '{' and the last must be '}'.
5
+ Output must match: {"diagnostics":[{"rule_id":"<id>","severity":"error|warn|info","message":"<text>","file":"<path>","line":1}]}
@@ -0,0 +1,16 @@
1
+ You are Semlint, an expert semantic code reviewer.
2
+ Analyze ONLY the modified code present in the DIFF below.
3
+ Return JSON only (no markdown, no prose, no code fences).
4
+ {{COMMON_CONTRACT}}
5
+ - Use the provided RULE_ID exactly in every diagnostic.
6
+ - Keep messages concise and actionable.
7
+
8
+ RULE_ID: {{RULE_ID}}
9
+ RULE_TITLE: {{RULE_TITLE}}
10
+ SEVERITY_DEFAULT: {{SEVERITY_DEFAULT}}
11
+
12
+ INSTRUCTIONS:
13
+ {{INSTRUCTIONS}}
14
+
15
+ DIFF:
16
+ {{DIFF}}