semlint-cli 0.1.1 → 0.1.3

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
@@ -115,11 +115,11 @@ This creates `./semlint.json` and auto-detects installed coding agent CLIs in th
115
115
 
116
116
  If no known CLI is detected, Semlint falls back to `cursor-cli` + executable `cursor`.
117
117
 
118
- Use `semlint init --force` to overwrite an existing config file.
118
+ Use `semlint init --force` to overwrite an existing config file. Init also creates `.semlint/rules/` and a starter rule `SEMLINT_EXAMPLE_001.json` (with a placeholder title and prompt) if they do not exist.
119
119
 
120
120
  ## Rule files
121
121
 
122
- Rule JSON files are loaded from `rules/`.
122
+ Rule JSON files are loaded from `.semlint/rules/`. Run `semlint init` to create this folder and an example rule you can edit.
123
123
 
124
124
  Required fields:
125
125
 
package/dist/cli.js CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
3
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ const picocolors_1 = __importDefault(require("picocolors"));
4
8
  const init_1 = require("./init");
5
9
  const main_1 = require("./main");
6
10
  const HELP_TEXT = [
@@ -11,7 +15,7 @@ const HELP_TEXT = [
11
15
  "",
12
16
  "Commands:",
13
17
  " check Run semantic lint rules against your git diff",
14
- " init Create semlint.json with auto-detected agent backend",
18
+ " init Create semlint.json, .semlint/rules/, and an example rule to edit",
15
19
  "",
16
20
  "Options:",
17
21
  " -h, --help Show this help text"
@@ -122,8 +126,14 @@ async function main() {
122
126
  }
123
127
  catch (error) {
124
128
  const message = error instanceof Error ? error.message : String(error);
125
- process.stderr.write(`${message}\n`);
126
- process.exitCode = error instanceof HelpRequestedError ? 0 : 2;
129
+ if (error instanceof HelpRequestedError) {
130
+ process.stderr.write(`${message}\n`);
131
+ process.exitCode = 0;
132
+ }
133
+ else {
134
+ process.stderr.write(picocolors_1.default.red(`Error: ${message}\n`));
135
+ process.exitCode = 2;
136
+ }
127
137
  }
128
138
  }
129
139
  void main();
package/dist/filter.js CHANGED
@@ -131,14 +131,40 @@ function buildScopedDiff(rule, fullDiff, changedFiles) {
131
131
  }
132
132
  function buildRulePrompt(rule, diff) {
133
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
+ "",
134
160
  `RULE_ID: ${rule.id}`,
135
161
  `RULE_TITLE: ${rule.title}`,
136
162
  `SEVERITY_DEFAULT: ${rule.effectiveSeverity}`,
137
163
  "",
138
- "DIFF:",
139
- diff,
140
- "",
141
164
  "INSTRUCTIONS:",
142
- rule.prompt
165
+ rule.prompt,
166
+ "",
167
+ "DIFF:",
168
+ diff
143
169
  ].join("\n");
144
170
  }
package/dist/git.js CHANGED
@@ -36,29 +36,6 @@ async function getGitDiff(base, head) {
36
36
  const result = await runGitCommand(["diff", base, head]);
37
37
  return result.stdout;
38
38
  }
39
- function isMissingRefError(message) {
40
- return /(not a valid object name|unknown revision|bad revision|no upstream configured|no upstream branch)/i.test(message);
41
- }
42
- async function resolveLocalComparisonBase() {
43
- const candidates = ["@{upstream}", "origin/main", "main"];
44
- for (const candidate of candidates) {
45
- try {
46
- const result = await runGitCommand(["merge-base", "HEAD", candidate]);
47
- const mergeBase = result.stdout.trim();
48
- if (mergeBase !== "") {
49
- return mergeBase;
50
- }
51
- }
52
- catch (error) {
53
- const message = error instanceof Error ? error.message : String(error);
54
- if (!isMissingRefError(message)) {
55
- throw error;
56
- }
57
- continue;
58
- }
59
- }
60
- return "HEAD";
61
- }
62
39
  async function getUntrackedFiles() {
63
40
  const result = await runGitCommand(["ls-files", "--others", "--exclude-standard"]);
64
41
  return result.stdout
@@ -70,9 +47,16 @@ async function getNoIndexDiffForFile(filePath) {
70
47
  const result = await runGitCommand(["diff", "--no-index", "--", node_os_1.devNull, filePath], [0, 1]);
71
48
  return result.stdout;
72
49
  }
50
+ /**
51
+ * Returns the diff for the current branch limited to:
52
+ * - Staged changes (--cached: index vs HEAD)
53
+ * - Unstaged changes (working tree vs index)
54
+ * - Untracked files (as full-file diffs)
55
+ * Does not include already-committed changes on the branch.
56
+ */
73
57
  async function getLocalBranchDiff() {
74
- const base = await resolveLocalComparisonBase();
75
- const trackedDiff = await runGitCommand(["diff", base]);
58
+ const stagedResult = await runGitCommand(["diff", "--cached"]);
59
+ const unstagedResult = await runGitCommand(["diff"]);
76
60
  const untrackedFiles = await getUntrackedFiles();
77
61
  const untrackedDiffChunks = [];
78
62
  for (const filePath of untrackedFiles) {
@@ -81,5 +65,7 @@ async function getLocalBranchDiff() {
81
65
  untrackedDiffChunks.push(fileDiff);
82
66
  }
83
67
  }
84
- return [trackedDiff.stdout, ...untrackedDiffChunks].filter((chunk) => chunk !== "").join("\n");
68
+ return [stagedResult.stdout, unstagedResult.stdout, ...untrackedDiffChunks]
69
+ .filter((chunk) => chunk !== "")
70
+ .join("\n");
85
71
  }
package/dist/init.js CHANGED
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.scaffoldConfig = scaffoldConfig;
7
+ const picocolors_1 = __importDefault(require("picocolors"));
7
8
  const node_fs_1 = __importDefault(require("node:fs"));
8
9
  const node_path_1 = __importDefault(require("node:path"));
9
10
  const node_child_process_1 = require("node:child_process");
@@ -72,7 +73,23 @@ function scaffoldConfig(force = false) {
72
73
  }
73
74
  };
74
75
  node_fs_1.default.writeFileSync(targetPath, `${JSON.stringify(scaffold, null, 2)}\n`, "utf8");
75
- process.stdout.write(`Created ${targetPath}\n`);
76
- process.stdout.write(`Backend setup: ${detected.backend} (${detected.reason})\n`);
76
+ process.stdout.write(picocolors_1.default.green(`Created ${targetPath}\n`));
77
+ process.stdout.write(picocolors_1.default.cyan(`Backend setup: ${detected.backend} (${detected.reason})\n`));
78
+ const rulesDir = node_path_1.default.join(process.cwd(), ".semlint", "rules");
79
+ if (!node_fs_1.default.existsSync(rulesDir)) {
80
+ node_fs_1.default.mkdirSync(rulesDir, { recursive: true });
81
+ process.stdout.write(picocolors_1.default.green(`Created ${node_path_1.default.join(".semlint", "rules")}/\n`));
82
+ }
83
+ const exampleRulePath = node_path_1.default.join(rulesDir, "SEMLINT_EXAMPLE_001.json");
84
+ if (!node_fs_1.default.existsSync(exampleRulePath)) {
85
+ const exampleRule = {
86
+ id: "SEMLINT_EXAMPLE_001",
87
+ title: "My first rule",
88
+ severity_default: "warn",
89
+ prompt: "Describe what the agent should check in the changed code. Example: flag when new functions lack JSDoc, or when error handling is missing."
90
+ };
91
+ node_fs_1.default.writeFileSync(exampleRulePath, `${JSON.stringify(exampleRule, null, 2)}\n`, "utf8");
92
+ process.stdout.write(picocolors_1.default.green(`Created ${node_path_1.default.join(".semlint", "rules", "SEMLINT_EXAMPLE_001.json")} `) + picocolors_1.default.dim(`(edit the title and prompt to define your rule)\n`));
93
+ }
77
94
  return 0;
78
95
  }
package/dist/main.js CHANGED
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.runSemlint = runSemlint;
7
+ const picocolors_1 = __importDefault(require("picocolors"));
8
+ const nanospinner_1 = require("nanospinner");
7
9
  const node_path_1 = __importDefault(require("node:path"));
8
10
  const backend_1 = require("./backend");
9
11
  const config_1 = require("./config");
@@ -15,7 +17,7 @@ const rules_1 = require("./rules");
15
17
  const VERSION = "0.1.0";
16
18
  function debugLog(enabled, message) {
17
19
  if (enabled) {
18
- process.stderr.write(`[debug] ${message}\n`);
20
+ process.stderr.write(`${picocolors_1.default.gray(`[debug] ${message}`)}\n`);
19
21
  }
20
22
  }
21
23
  function timed(enabled, label, action) {
@@ -41,10 +43,33 @@ function buildBatchPrompt(rules, diff) {
41
43
  ].join("\n"))
42
44
  .join("\n\n---\n\n");
43
45
  return [
46
+ "You are Semlint, an expert semantic code reviewer.",
44
47
  "BATCH_MODE: true",
45
48
  "Evaluate all rules below against the DIFF in one pass.",
46
- "Return valid JSON only with shape {\"diagnostics\":[...]}",
47
- "Each diagnostic must include: rule_id, severity, message, file, line.",
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.",
48
73
  "",
49
74
  "RULES:",
50
75
  ruleBlocks,
@@ -55,9 +80,10 @@ function buildBatchPrompt(rules, diff) {
55
80
  }
56
81
  async function runSemlint(options) {
57
82
  const startedAt = Date.now();
83
+ let spinner = null;
58
84
  try {
59
85
  const config = timed(options.debug, "Loaded effective config", () => (0, config_1.loadEffectiveConfig)(options));
60
- const rulesDir = node_path_1.default.join(process.cwd(), "rules");
86
+ const rulesDir = node_path_1.default.join(process.cwd(), ".semlint", "rules");
61
87
  const rules = timed(config.debug, "Loaded and validated rules", () => (0, rules_1.loadRules)(rulesDir, config.rulesDisable, config.severityOverrides));
62
88
  debugLog(config.debug, `Loaded ${rules.length} rule(s)`);
63
89
  debugLog(config.debug, `Rule IDs: ${rules.map((rule) => rule.id).join(", ")}`);
@@ -65,7 +91,7 @@ async function runSemlint(options) {
65
91
  const diff = await timedAsync(config.debug, "Computed git diff", () => useLocalBranchDiff ? (0, git_1.getLocalBranchDiff)() : (0, git_1.getGitDiff)(config.base, config.head));
66
92
  const changedFiles = timed(config.debug, "Parsed changed files from diff", () => (0, filter_1.extractChangedFilesFromDiff)(diff));
67
93
  debugLog(config.debug, useLocalBranchDiff
68
- ? "Using local branch diff (tracked + staged + unstaged + untracked)"
94
+ ? "Using local branch diff (staged + unstaged + untracked only)"
69
95
  : `Using explicit ref diff (${config.base}..${config.head})`);
70
96
  debugLog(config.debug, `Detected ${changedFiles.length} changed file(s)`);
71
97
  const backend = timed(config.debug, "Initialized backend runner", () => (0, backend_1.createBackendRunner)(config));
@@ -82,6 +108,17 @@ async function runSemlint(options) {
82
108
  const diagnostics = [];
83
109
  const rulesRun = runnableRules.length;
84
110
  let backendErrors = 0;
111
+ if (config.format !== "json" && rulesRun > 0) {
112
+ process.stdout.write(`${picocolors_1.default.bold("Running rules:")}\n`);
113
+ for (const rule of runnableRules) {
114
+ process.stdout.write(` ${picocolors_1.default.cyan(rule.id)} ${picocolors_1.default.dim(rule.title)}\n`);
115
+ }
116
+ process.stdout.write("\n");
117
+ }
118
+ spinner =
119
+ config.format !== "json" && rulesRun > 0
120
+ ? (0, nanospinner_1.createSpinner)(`Analyzing ${changedFiles.length} file${changedFiles.length === 1 ? "" : "s"} with ${config.backend} in ${config.batchMode ? "batch" : "parallel"} mode...`).start()
121
+ : null;
85
122
  if (config.batchMode && runnableRules.length > 0) {
86
123
  debugLog(config.debug, `Running ${runnableRules.length} rule(s) in batch mode`);
87
124
  const combinedDiff = timed(config.debug, "Batch: combined scoped diff build", () => runnableRules
@@ -161,12 +198,20 @@ async function runSemlint(options) {
161
198
  }
162
199
  const sorted = timed(config.debug, "Sorted diagnostics", () => (0, diagnostics_1.sortDiagnostics)(diagnostics));
163
200
  const durationMs = Date.now() - startedAt;
201
+ if (spinner) {
202
+ if (backendErrors > 0) {
203
+ spinner.error({ text: "Analysis completed with backend errors" });
204
+ }
205
+ else {
206
+ spinner.success({ text: "Analysis complete" });
207
+ }
208
+ }
164
209
  const outputStartedAt = Date.now();
165
210
  if (config.format === "json") {
166
211
  process.stdout.write(`${(0, reporter_1.formatJsonOutput)(VERSION, sorted, { rulesRun, durationMs, backendErrors })}\n`);
167
212
  }
168
213
  else {
169
- process.stdout.write(`${(0, reporter_1.formatTextOutput)(sorted)}\n`);
214
+ process.stdout.write(`${(0, reporter_1.formatTextOutput)(sorted, { rulesRun, durationMs, backendErrors })}\n`);
170
215
  }
171
216
  debugLog(config.debug, `Rendered output in ${Date.now() - outputStartedAt}ms`);
172
217
  debugLog(config.debug, `Total run duration ${durationMs}ms`);
@@ -179,6 +224,9 @@ async function runSemlint(options) {
179
224
  return 0;
180
225
  }
181
226
  catch (error) {
227
+ if (spinner) {
228
+ spinner.error({ text: "Analysis failed" });
229
+ }
182
230
  const message = error instanceof Error ? error.message : String(error);
183
231
  process.stderr.write(`${message}\n`);
184
232
  return 2;
package/dist/reporter.js CHANGED
@@ -1,7 +1,20 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.formatJsonOutput = formatJsonOutput;
4
7
  exports.formatTextOutput = formatTextOutput;
8
+ const picocolors_1 = __importDefault(require("picocolors"));
9
+ function formatSeverity(severity) {
10
+ if (severity === "error")
11
+ return picocolors_1.default.red(severity);
12
+ if (severity === "warn")
13
+ return picocolors_1.default.yellow(severity);
14
+ if (severity === "info")
15
+ return picocolors_1.default.cyan(severity);
16
+ return severity;
17
+ }
5
18
  function formatJsonOutput(version, diagnostics, stats) {
6
19
  const payload = {
7
20
  tool: {
@@ -17,7 +30,7 @@ function formatJsonOutput(version, diagnostics, stats) {
17
30
  };
18
31
  return JSON.stringify(payload, null, 2);
19
32
  }
20
- function formatTextOutput(diagnostics) {
33
+ function formatTextOutput(diagnostics, stats) {
21
34
  const lines = [];
22
35
  let currentFile = "";
23
36
  for (const diagnostic of diagnostics) {
@@ -26,10 +39,10 @@ function formatTextOutput(diagnostics) {
26
39
  lines.push("");
27
40
  }
28
41
  currentFile = diagnostic.file;
29
- lines.push(currentFile);
42
+ lines.push(picocolors_1.default.underline(currentFile));
30
43
  }
31
44
  const column = diagnostic.column ?? 1;
32
- lines.push(` ${diagnostic.line}:${column} ${diagnostic.severity} ${diagnostic.rule_id} ${diagnostic.message}`);
45
+ lines.push(` ${picocolors_1.default.dim(`${diagnostic.line}:${column}`)} ${formatSeverity(diagnostic.severity)} ${picocolors_1.default.gray(diagnostic.rule_id)} ${diagnostic.message}`);
33
46
  }
34
47
  const errors = diagnostics.filter((d) => d.severity === "error").length;
35
48
  const warnings = diagnostics.filter((d) => d.severity === "warn").length;
@@ -37,6 +50,24 @@ function formatTextOutput(diagnostics) {
37
50
  if (lines.length > 0) {
38
51
  lines.push("");
39
52
  }
40
- lines.push(`✖ ${problems} problems (${errors} errors, ${warnings} warnings)`);
53
+ const summary = `✖ ${problems} problems (${errors} errors, ${warnings} warnings)`;
54
+ const timeInfo = ` in ${(stats.durationMs / 1000).toFixed(1)}s`;
55
+ if (problems === 0) {
56
+ if (stats.rulesRun === 0) {
57
+ lines.push(picocolors_1.default.green(`✔ 0 problems (no rules matched changed files)`));
58
+ }
59
+ else {
60
+ lines.push(picocolors_1.default.green(`✔ 0 problems (0 errors, 0 warnings)`));
61
+ }
62
+ }
63
+ else if (errors > 0) {
64
+ lines.push(picocolors_1.default.red(summary) + picocolors_1.default.gray(timeInfo));
65
+ }
66
+ else {
67
+ lines.push(picocolors_1.default.yellow(summary) + picocolors_1.default.gray(timeInfo));
68
+ }
69
+ if (problems === 0 && stats.durationMs > 0) {
70
+ lines[lines.length - 1] += picocolors_1.default.gray(timeInfo);
71
+ }
41
72
  return lines.join("\n");
42
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "semlint-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -40,6 +40,8 @@
40
40
  },
41
41
  "packageManager": "pnpm@10.29.2",
42
42
  "dependencies": {
43
+ "nanospinner": "^1.2.2",
44
+ "picocolors": "^1.1.1",
43
45
  "picomatch": "^4.0.3"
44
46
  },
45
47
  "devDependencies": {
@@ -1,12 +0,0 @@
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": "You are Semlint. Review ONLY the modified code in the provided DIFF and verify naming is consistent with the ambient naming conventions already used in surrounding code. Focus on identifier styles such as camelCase for variables/functions, PascalCase for classes/types/interfaces, and ALL_CAPS for constants where that convention is clearly established nearby. Flag only clear inconsistencies in newly added or renamed identifiers. Ignore untouched legacy naming unless directly impacted by the change. Return valid JSON only with shape {\"diagnostics\":[...]} and each diagnostic must include: rule_id, severity, message, file, line."
12
- }
@@ -1,12 +0,0 @@
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": "You are Semlint. Review ONLY the modified code in the provided DIFF and check whether ambient implementation patterns are respected. Compare new or changed code against nearby established patterns for control flow, async handling, error propagation, data transformation, module boundaries, and function/class structure. Flag clear regressions where the proposed change deviates from consistent local patterns without obvious justification. Ignore untouched legacy code and acceptable intentional improvements. Return valid JSON only with shape {\"diagnostics\":[...]} and each diagnostic must include: rule_id, severity, message, file, line."
12
- }
@@ -1,12 +0,0 @@
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": "You are Semlint. Review ONLY the modified code in the DIFF and find obvious software-engineering mistakes in the proposed change. Focus on clear issues such as dead code, side-effect misuse, swallowed errors, unsafe any-casts, accidental debug leftovers, contradictory conditions, and obvious maintainability hazards that are likely unintended (these are only examples, there are many more). Do not nitpick style or architecture unless the issue is clearly harmful. Report only high-signal findings tied to changed lines. Return valid JSON only with shape {\"diagnostics\":[...]} and each diagnostic must include: rule_id, severity, message, file, line."
12
- }