semlint-cli 0.1.6 → 0.1.9

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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "SEMLINT_COUPLING_004",
3
+ "title": "Check for unwanted hidden coupling",
4
+ "severity_default": "warn",
5
+ "include_globs": [],
6
+ "exclude_globs": [],
7
+ "prompt": "Flag any coupling that is not obvious at first glance. (hidden/implicit coupling, and forms of connascence)"
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "SEMLINT_NAMING_001",
3
+ "title": "Ambient naming convention consistency",
4
+ "severity_default": "warn",
5
+ "include_globs": [],
6
+ "exclude_globs": [],
7
+ "prompt": "Verify naming is consistent with the ambient naming conventions already used in surrounding code. Both in term of semantic and casing."
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "SEMLINT_PATTERN_002",
3
+ "title": "Ambient pattern is respected",
4
+ "severity_default": "warn",
5
+ "include_globs": [],
6
+ "exclude_globs": [],
7
+ "prompt": "Any change that breaks an established local pattern should be flagged by default. Consistency with adjacent implementations is the enforced invariant."
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "SEMLINT_SWE_003",
3
+ "title": "Obvious SWE mistakes",
4
+ "severity_default": "warn",
5
+ "include_globs": [],
6
+ "exclude_globs": [],
7
+ "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."
8
+ }
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,
@@ -43,6 +51,7 @@ pnpm check
43
51
  - `--head <ref>`: head git ref for explicit ref-to-ref diff
44
52
  - `--fail-on <error|warn|never>`: failure threshold (default `error`)
45
53
  - `--batch`: run all selected rules in one backend call
54
+ - `--yes` / `-y`: auto-accept diff file confirmation (useful for CI)
46
55
  - `--debug`: enable debug logs to stderr
47
56
  - `init --force`: overwrite an existing `semlint.json`
48
57
 
@@ -55,6 +64,9 @@ Default diff behavior (without `--base`/`--head`) uses your local branch state:
55
64
 
56
65
  If you pass `--base` or `--head`, Semlint uses explicit `git diff <base> <head>` mode.
57
66
 
67
+ Before backend execution, Semlint shows the included and excluded diff files and asks for confirmation by default.
68
+ Use `--yes` / `-y` to skip this prompt.
69
+
58
70
  ## Exit codes
59
71
 
60
72
  - `0`: no blocking diagnostics
@@ -84,8 +96,14 @@ Unknown fields are ignored.
84
96
  "execution": {
85
97
  "batch": false
86
98
  },
99
+ "security": {
100
+ "secret_guard": true,
101
+ "allow_patterns": [],
102
+ "allow_files": [],
103
+ "ignore_files": [".gitignore", ".cursorignore", ".semlintignore", ".cursoringore"]
104
+ },
87
105
  "rules": {
88
- "disable": ["SEMLINT_EXAMPLE_001"],
106
+ "disable": [],
89
107
  "severity_overrides": {
90
108
  "SEMLINT_API_001": "error"
91
109
  }
@@ -116,11 +134,11 @@ This creates `./semlint.json` and auto-detects installed coding agent CLIs in th
116
134
 
117
135
  If no known CLI is detected, Semlint falls back to `cursor-cli` + executable `cursor`.
118
136
 
119
- 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.
137
+ Use `semlint init --force` to overwrite an existing config file. Init also creates `.semlint/rules/` and copies the bundled Semlint rule files into it.
120
138
 
121
139
  ## Rule files
122
140
 
123
- Rule JSON files are loaded from `.semlint/rules/`. Run `semlint init` to create this folder and an example rule you can edit.
141
+ Rule JSON files are loaded from `.semlint/rules/`. Run `semlint init` to create this folder and copy bundled rules into it.
124
142
 
125
143
  Required fields:
126
144
 
@@ -187,6 +205,43 @@ If parsing fails, Semlint retries once with appended instruction:
187
205
 
188
206
  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
207
 
208
+ ## Secret guard
209
+
210
+ Semlint uses a fail-closed secret guard before any backend call:
211
+
212
+ - Filters diff chunks using path ignore rules from `.gitignore`, `.cursorignore`, `.semlintignore`
213
+ - Applies additional built-in sensitive path deny patterns (`.env*`, key files, secrets/credentials folders)
214
+ - Scans added diff lines for high-signal secret keywords and token prefixes (password/token/api key/private key/JWT/provider key prefixes)
215
+ - If any potential secrets are found, Semlint exits with code `2` and sends nothing to the backend
216
+
217
+ Config:
218
+
219
+ ```json
220
+ {
221
+ "security": {
222
+ "secret_guard": true,
223
+ "allow_patterns": [],
224
+ "allow_files": [],
225
+ "ignore_files": [".gitignore", ".cursorignore", ".semlintignore", ".cursoringore"]
226
+ }
227
+ }
228
+ ```
229
+
230
+ - `secret_guard`: enable/disable secret blocking (default `true`)
231
+ - `allow_patterns`: regex list to suppress known-safe fixtures from triggering the guard
232
+ - `allow_files`: file glob allowlist to skip secret scanning for known-safe files (example: `["src/test-fixtures/**"]`)
233
+ - `ignore_files`: ignore files Semlint reads for path-level filtering (default: `.gitignore`, `.cursorignore`, `.semlintignore`, `.cursoringore`)
234
+
235
+ Ignore file patterns are evaluated with standard gitignore semantics (including basename matching and `!` negation).
236
+
237
+ ## Security responsibility model
238
+
239
+ Security in Semlint is shared, and the repository owner remains responsible for secure setup:
240
+
241
+ - **Diff-level (your responsibility):** carefully configure ignore files and Semlint security settings so sensitive files/content never enter the analyzed diff.
242
+ - **Semlint guard (best-effort control):** Semlint filters diff paths and scans added lines, but this is not a complete prevention system.
243
+ - **Agent/runtime level (handled by your backend tool):** after Semlint sends a prompt to your configured CLI, what the agent can do is controlled by the native agent/backend configuration.
244
+
190
245
  ## Prompt files
191
246
 
192
247
  Core system prompts are externalized under `prompts/` so prompt behavior is easy to inspect and iterate:
package/dist/cli.js CHANGED
@@ -7,18 +7,22 @@ Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const picocolors_1 = __importDefault(require("picocolors"));
8
8
  const init_1 = require("./init");
9
9
  const main_1 = require("./main");
10
+ const security_1 = require("./security");
10
11
  const HELP_TEXT = [
11
12
  "Usage:",
12
- " semlint check [--backend <name>] [--model <name>] [--config <path>] [--format <text|json>] [--base <ref>] [--head <ref>] [--fail-on <error|warn|never>] [--batch] [--debug]",
13
- " semlint init [--force]",
14
- " semlint --help",
13
+ " semlint-cli check [--backend <name>] [--model <name>] [--config <path>] [--format <text|json>] [--base <ref>] [--head <ref>] [--fail-on <error|warn|never>] [--batch] [--yes|-y] [--debug]",
14
+ " semlint-cli init [--force]",
15
+ " semlint-cli security",
16
+ " semlint-cli --help",
15
17
  "",
16
18
  "Commands:",
17
19
  " check Run semantic lint rules against your git diff",
18
20
  " init Create semlint.json, .semlint/rules/, and an example rule to edit",
21
+ " security Show security responsibility guidance",
19
22
  "",
20
23
  "Options:",
21
- " -h, --help Show this help text"
24
+ " -h, --help Show this help text",
25
+ " -y, --yes Auto-accept diff file confirmation"
22
26
  ].join("\n");
23
27
  class HelpRequestedError extends Error {
24
28
  constructor() {
@@ -45,7 +49,7 @@ function parseArgs(argv) {
45
49
  throw new HelpRequestedError();
46
50
  }
47
51
  const [command, ...rest] = argv;
48
- if (!command || (command !== "check" && command !== "init")) {
52
+ if (!command || (command !== "check" && command !== "init" && command !== "security")) {
49
53
  throw new Error(HELP_TEXT);
50
54
  }
51
55
  if (command === "init") {
@@ -62,12 +66,25 @@ function parseArgs(argv) {
62
66
  }
63
67
  return options;
64
68
  }
69
+ if (command === "security") {
70
+ if (rest.length > 0) {
71
+ throw new Error(`Unknown flag for security: ${rest[0]}`);
72
+ }
73
+ return {
74
+ command: "security",
75
+ debug: false
76
+ };
77
+ }
65
78
  const options = {
66
79
  command: "check",
67
80
  debug: false
68
81
  };
69
82
  for (let i = 0; i < rest.length; i += 1) {
70
83
  const token = rest[i];
84
+ if (token === "--yes" || token === "-y") {
85
+ options.autoAccept = true;
86
+ continue;
87
+ }
71
88
  if (token === "--debug") {
72
89
  options.debug = true;
73
90
  continue;
@@ -121,7 +138,11 @@ function parseArgs(argv) {
121
138
  async function main() {
122
139
  try {
123
140
  const options = parseArgs(process.argv.slice(2));
124
- const exitCode = options.command === "init" ? (0, init_1.scaffoldConfig)(options.force) : await (0, main_1.runSemlint)(options);
141
+ const exitCode = options.command === "init"
142
+ ? (0, init_1.scaffoldConfig)(options.force)
143
+ : options.command === "security"
144
+ ? (0, security_1.printSecurityGuide)()
145
+ : await (0, main_1.runSemlint)(options);
125
146
  process.exitCode = exitCode;
126
147
  }
127
148
  catch (error) {
package/dist/config.js CHANGED
@@ -20,7 +20,15 @@ const DEFAULTS = {
20
20
  batchMode: false,
21
21
  rulesDisable: [],
22
22
  severityOverrides: {},
23
- backendConfigs: {}
23
+ rulesIncludeGlobs: [],
24
+ rulesExcludeGlobs: [],
25
+ backendConfigs: {},
26
+ security: {
27
+ secretGuard: true,
28
+ allowPatterns: [],
29
+ ignoreFiles: [".gitignore", ".cursorignore", ".semlintignore"],
30
+ allowFiles: []
31
+ }
24
32
  };
25
33
  function readJsonIfExists(filePath) {
26
34
  if (!node_fs_1.default.existsSync(filePath)) {
@@ -81,6 +89,63 @@ function sanitizeBackendConfigs(value) {
81
89
  return [[name, { executable, args: normalizedArgs, model: model && model !== "" ? model : undefined }]];
82
90
  }));
83
91
  }
92
+ function sanitizeAllowPatterns(value) {
93
+ if (!Array.isArray(value)) {
94
+ return [];
95
+ }
96
+ return value.flatMap((candidate) => {
97
+ if (typeof candidate !== "string" || candidate.trim() === "") {
98
+ return [];
99
+ }
100
+ try {
101
+ new RegExp(candidate);
102
+ return [candidate];
103
+ }
104
+ catch {
105
+ return [];
106
+ }
107
+ });
108
+ }
109
+ function sanitizeIgnoreFiles(value) {
110
+ if (!Array.isArray(value)) {
111
+ return [...DEFAULTS.security.ignoreFiles];
112
+ }
113
+ const normalized = value.flatMap((candidate) => {
114
+ if (typeof candidate !== "string") {
115
+ return [];
116
+ }
117
+ const trimmed = candidate.trim();
118
+ if (trimmed === "") {
119
+ return [];
120
+ }
121
+ return [trimmed];
122
+ });
123
+ return normalized.length > 0 ? normalized : [...DEFAULTS.security.ignoreFiles];
124
+ }
125
+ function sanitizeAllowFiles(value) {
126
+ if (!Array.isArray(value)) {
127
+ return [];
128
+ }
129
+ return value.flatMap((candidate) => {
130
+ if (typeof candidate !== "string") {
131
+ return [];
132
+ }
133
+ const trimmed = candidate.trim();
134
+ return trimmed === "" ? [] : [trimmed];
135
+ });
136
+ }
137
+ function sanitizeGlobList(value) {
138
+ if (!Array.isArray(value)) {
139
+ return [];
140
+ }
141
+ return value.flatMap((candidate) => {
142
+ if (typeof candidate !== "string") {
143
+ return [];
144
+ }
145
+ const trimmed = candidate.trim();
146
+ return trimmed === "" ? [] : [trimmed];
147
+ });
148
+ }
84
149
  function ensureSelectedBackendIsConfigured(backend, backendConfigs) {
85
150
  if (!(backend in backendConfigs)) {
86
151
  throw new Error(`Backend "${backend}" is not configured. Add it under backends.${backend} with executable and args (including "{prompt}") in semlint.json.`);
@@ -113,6 +178,16 @@ function loadEffectiveConfig(options) {
113
178
  ? fileConfig.rules?.disable.filter((item) => typeof item === "string")
114
179
  : DEFAULTS.rulesDisable,
115
180
  severityOverrides: sanitizeSeverityOverrides((fileConfig.rules?.severity_overrides ?? undefined)),
116
- backendConfigs
181
+ rulesIncludeGlobs: sanitizeGlobList(fileConfig.rules?.include_globs),
182
+ rulesExcludeGlobs: sanitizeGlobList(fileConfig.rules?.exclude_globs),
183
+ backendConfigs,
184
+ security: {
185
+ secretGuard: typeof fileConfig.security?.secret_guard === "boolean"
186
+ ? fileConfig.security.secret_guard
187
+ : DEFAULTS.security.secretGuard,
188
+ allowPatterns: sanitizeAllowPatterns(fileConfig.security?.allow_patterns),
189
+ ignoreFiles: sanitizeIgnoreFiles(fileConfig.security?.ignore_files),
190
+ allowFiles: sanitizeAllowFiles(fileConfig.security?.allow_files)
191
+ }
117
192
  };
118
193
  }
package/dist/diff.js ADDED
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseDiffGitHeader = parseDiffGitHeader;
4
+ exports.splitDiffIntoFileChunks = splitDiffIntoFileChunks;
5
+ function unquoteDiffPath(raw) {
6
+ if (raw.startsWith("\"") && raw.endsWith("\"") && raw.length >= 2) {
7
+ return raw.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
8
+ }
9
+ return raw;
10
+ }
11
+ function parseDiffGitHeader(line) {
12
+ const match = line.match(/^diff --git (?:"a\/((?:[^"\\]|\\.)+)"|a\/(\S+)) (?:"b\/((?:[^"\\]|\\.)+)"|b\/(\S+))$/);
13
+ if (!match) {
14
+ return undefined;
15
+ }
16
+ const aRaw = match[1] ?? match[2];
17
+ const bRaw = match[3] ?? match[4];
18
+ if (!aRaw || !bRaw) {
19
+ return undefined;
20
+ }
21
+ return {
22
+ aPath: unquoteDiffPath(aRaw),
23
+ bPath: unquoteDiffPath(bRaw)
24
+ };
25
+ }
26
+ function splitDiffIntoFileChunks(diff) {
27
+ const lines = diff.split("\n");
28
+ const chunks = [];
29
+ let currentLines = [];
30
+ let currentFile = "";
31
+ const flush = () => {
32
+ if (currentLines.length === 0) {
33
+ return;
34
+ }
35
+ chunks.push({
36
+ file: currentFile,
37
+ chunk: currentLines.join("\n")
38
+ });
39
+ currentLines = [];
40
+ currentFile = "";
41
+ };
42
+ for (const line of lines) {
43
+ if (line.startsWith("diff --git ")) {
44
+ flush();
45
+ const parsed = parseDiffGitHeader(line);
46
+ if (parsed) {
47
+ currentFile = parsed.bPath;
48
+ }
49
+ }
50
+ currentLines.push(line);
51
+ }
52
+ flush();
53
+ return chunks;
54
+ }
package/dist/dispatch.js CHANGED
@@ -24,7 +24,7 @@ async function runBatchDispatch(input) {
24
24
  let backendErrors = 0;
25
25
  (0, utils_1.debugLog)(config.debug, `Running ${rules.length} rule(s) in batch mode`);
26
26
  const combinedDiff = rules
27
- .map((rule) => (0, filter_1.buildScopedDiff)(rule, diff, changedFiles))
27
+ .map((rule) => (0, filter_1.buildScopedDiff)(rule, diff, changedFiles, config.rulesIncludeGlobs, config.rulesExcludeGlobs))
28
28
  .filter((chunk) => chunk.trim() !== "")
29
29
  .join("\n");
30
30
  const batchPrompt = buildBatchPrompt(rules, combinedDiff || diff);
@@ -68,7 +68,7 @@ async function runParallelDispatch(input) {
68
68
  const runResults = await Promise.all(rules.map(async (rule) => {
69
69
  const ruleStartedAt = Date.now();
70
70
  (0, utils_1.debugLog)(config.debug, `Rule ${rule.id}: started`);
71
- const scopedDiff = (0, filter_1.buildScopedDiff)(rule, diff, changedFiles);
71
+ const scopedDiff = (0, filter_1.buildScopedDiff)(rule, diff, changedFiles, config.rulesIncludeGlobs, config.rulesExcludeGlobs);
72
72
  const prompt = (0, filter_1.buildRulePrompt)(rule, scopedDiff);
73
73
  try {
74
74
  const result = await backend.runRule({
package/dist/filter.js CHANGED
@@ -8,29 +8,9 @@ exports.getRuleCandidateFiles = getRuleCandidateFiles;
8
8
  exports.shouldRunRule = shouldRunRule;
9
9
  exports.buildScopedDiff = buildScopedDiff;
10
10
  exports.buildRulePrompt = buildRulePrompt;
11
+ const diff_1 = require("./diff");
11
12
  const picomatch_1 = __importDefault(require("picomatch"));
12
13
  const prompts_1 = require("./prompts");
13
- function unquoteDiffPath(raw) {
14
- if (raw.startsWith("\"") && raw.endsWith("\"") && raw.length >= 2) {
15
- return raw.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
16
- }
17
- return raw;
18
- }
19
- function parseDiffGitHeader(line) {
20
- const match = line.match(/^diff --git (?:"a\/((?:[^"\\]|\\.)+)"|a\/(\S+)) (?:"b\/((?:[^"\\]|\\.)+)"|b\/(\S+))$/);
21
- if (!match) {
22
- return undefined;
23
- }
24
- const aRaw = match[1] ?? match[2];
25
- const bRaw = match[3] ?? match[4];
26
- if (!aRaw || !bRaw) {
27
- return undefined;
28
- }
29
- return {
30
- aPath: unquoteDiffPath(aRaw),
31
- bPath: unquoteDiffPath(bRaw)
32
- };
33
- }
34
14
  function extractChangedFilesFromDiff(diff) {
35
15
  const files = new Set();
36
16
  const lines = diff.split("\n");
@@ -38,7 +18,7 @@ function extractChangedFilesFromDiff(diff) {
38
18
  if (!line.startsWith("diff --git ")) {
39
19
  continue;
40
20
  }
41
- const parsed = parseDiffGitHeader(line);
21
+ const parsed = (0, diff_1.parseDiffGitHeader)(line);
42
22
  if (!parsed) {
43
23
  continue;
44
24
  }
@@ -63,10 +43,18 @@ function matchesAnyRegex(diff, regexes) {
63
43
  }
64
44
  return false;
65
45
  }
66
- function getRuleCandidateFiles(rule, changedFiles) {
46
+ function resolveRuleGlobs(ruleGlobs, globalGlobs) {
47
+ if (ruleGlobs === undefined) {
48
+ return globalGlobs;
49
+ }
50
+ return ruleGlobs;
51
+ }
52
+ function getRuleCandidateFiles(rule, changedFiles, globalIncludeGlobs = [], globalExcludeGlobs = []) {
67
53
  let fileCandidates = changedFiles;
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;
54
+ const effectiveIncludeGlobs = resolveRuleGlobs(rule.include_globs, globalIncludeGlobs);
55
+ const effectiveExcludeGlobs = resolveRuleGlobs(rule.exclude_globs, globalExcludeGlobs);
56
+ const includeMatcher = effectiveIncludeGlobs.length > 0 ? (0, picomatch_1.default)(effectiveIncludeGlobs) : null;
57
+ const excludeMatcher = effectiveExcludeGlobs.length > 0 ? (0, picomatch_1.default)(effectiveExcludeGlobs) : null;
70
58
  if (includeMatcher) {
71
59
  fileCandidates = changedFiles.filter((filePath) => includeMatcher(filePath));
72
60
  if (fileCandidates.length === 0) {
@@ -78,8 +66,8 @@ function getRuleCandidateFiles(rule, changedFiles) {
78
66
  }
79
67
  return fileCandidates;
80
68
  }
81
- function shouldRunRule(rule, changedFiles, diff) {
82
- const fileCandidates = getRuleCandidateFiles(rule, changedFiles);
69
+ function shouldRunRule(rule, changedFiles, diff, globalIncludeGlobs = [], globalExcludeGlobs = []) {
70
+ const fileCandidates = getRuleCandidateFiles(rule, changedFiles, globalIncludeGlobs, globalExcludeGlobs);
83
71
  if (fileCandidates.length === 0) {
84
72
  return false;
85
73
  }
@@ -88,41 +76,12 @@ function shouldRunRule(rule, changedFiles, diff) {
88
76
  }
89
77
  return true;
90
78
  }
91
- function splitDiffIntoFileChunks(diff) {
92
- const lines = diff.split("\n");
93
- const chunks = [];
94
- let currentLines = [];
95
- let currentFile = "";
96
- const flush = () => {
97
- if (currentLines.length === 0) {
98
- return;
99
- }
100
- chunks.push({
101
- file: currentFile,
102
- chunk: currentLines.join("\n")
103
- });
104
- currentLines = [];
105
- currentFile = "";
106
- };
107
- for (const line of lines) {
108
- if (line.startsWith("diff --git ")) {
109
- flush();
110
- const parsed = parseDiffGitHeader(line);
111
- if (parsed) {
112
- currentFile = parsed.bPath;
113
- }
114
- }
115
- currentLines.push(line);
116
- }
117
- flush();
118
- return chunks;
119
- }
120
- function buildScopedDiff(rule, fullDiff, changedFiles) {
121
- const candidateFiles = new Set(getRuleCandidateFiles(rule, changedFiles));
79
+ function buildScopedDiff(rule, fullDiff, changedFiles, globalIncludeGlobs = [], globalExcludeGlobs = []) {
80
+ const candidateFiles = new Set(getRuleCandidateFiles(rule, changedFiles, globalIncludeGlobs, globalExcludeGlobs));
122
81
  if (candidateFiles.size === 0) {
123
82
  return fullDiff;
124
83
  }
125
- const chunks = splitDiffIntoFileChunks(fullDiff);
84
+ const chunks = (0, diff_1.splitDiffIntoFileChunks)(fullDiff);
126
85
  const scoped = chunks
127
86
  .filter((chunk) => chunk.file !== "" && candidateFiles.has(chunk.file))
128
87
  .map((chunk) => chunk.chunk)
@@ -0,0 +1,42 @@
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
+ const strict_1 = __importDefault(require("node:assert/strict"));
7
+ const node_test_1 = __importDefault(require("node:test"));
8
+ const filter_1 = require("./filter");
9
+ function makeRule(overrides = {}) {
10
+ return {
11
+ id: "SEMLINT_TEST_001",
12
+ title: "Test rule",
13
+ severity_default: "warn",
14
+ prompt: "Test",
15
+ sourcePath: "/tmp/SEMLINT_TEST_001.json",
16
+ effectiveSeverity: "warn",
17
+ ...overrides
18
+ };
19
+ }
20
+ (0, node_test_1.default)("getRuleCandidateFiles inherits global include/exclude globs", () => {
21
+ const changedFiles = ["src/a.ts", "src/a.test.ts", "docs/readme.md"];
22
+ const rule = makeRule();
23
+ const candidates = (0, filter_1.getRuleCandidateFiles)(rule, changedFiles, ["src/**/*.ts"], ["**/*.test.ts", "**/*.spec.ts"]);
24
+ strict_1.default.deepEqual(candidates, ["src/a.ts"]);
25
+ });
26
+ (0, node_test_1.default)("getRuleCandidateFiles lets empty rule globs disable global filters", () => {
27
+ const changedFiles = ["src/a.ts", "src/a.test.ts", "docs/readme.md"];
28
+ const rule = makeRule({
29
+ include_globs: [],
30
+ exclude_globs: []
31
+ });
32
+ const candidates = (0, filter_1.getRuleCandidateFiles)(rule, changedFiles, ["src/**/*.ts"], ["**/*.test.ts", "**/*.spec.ts"]);
33
+ strict_1.default.deepEqual(candidates, changedFiles);
34
+ });
35
+ (0, node_test_1.default)("getRuleCandidateFiles uses explicit rule globs instead of globals", () => {
36
+ const changedFiles = ["src/a.ts", "docs/readme.md"];
37
+ const rule = makeRule({
38
+ include_globs: ["docs/**/*.md"]
39
+ });
40
+ const candidates = (0, filter_1.getRuleCandidateFiles)(rule, changedFiles, ["src/**/*.ts"], []);
41
+ strict_1.default.deepEqual(candidates, ["docs/readme.md"]);
42
+ });
package/dist/init.js CHANGED
@@ -87,8 +87,16 @@ function scaffoldConfig(force = false) {
87
87
  execution: {
88
88
  batch: false
89
89
  },
90
+ security: {
91
+ secret_guard: true,
92
+ allow_patterns: [],
93
+ ignore_files: [".gitignore", ".cursorignore", ".semlintignore"],
94
+ allow_files: []
95
+ },
90
96
  rules: {
91
97
  disable: [],
98
+ include_globs: ["src/**/*.ts"],
99
+ exclude_globs: [],
92
100
  severity_overrides: {}
93
101
  },
94
102
  backends: {
@@ -102,21 +110,32 @@ function scaffoldConfig(force = false) {
102
110
  node_fs_1.default.writeFileSync(targetPath, `${JSON.stringify(scaffold, null, 2)}\n`, "utf8");
103
111
  process.stdout.write(picocolors_1.default.green(`Created ${targetPath}\n`));
104
112
  process.stdout.write(picocolors_1.default.cyan(`Backend setup: ${detected.backend} (${detected.reason})\n`));
113
+ process.stderr.write(`${picocolors_1.default.bgRed(picocolors_1.default.white(picocolors_1.default.bold(" SECURITY WARNING ")))}
114
+ ${picocolors_1.default.red("Semlint can only filter and scan diff content before invoking your backend. You must configure ignore files and security settings for your repository.")}
115
+ ${picocolors_1.default.red("During backend execution, security is handled by your agent/backend native configuration.")}
116
+ ${picocolors_1.default.yellow("Review semlint.json security.*, ignore files, and backend/org security settings before use.\n")}`);
105
117
  const rulesDir = node_path_1.default.join(process.cwd(), ".semlint", "rules");
106
118
  if (!node_fs_1.default.existsSync(rulesDir)) {
107
119
  node_fs_1.default.mkdirSync(rulesDir, { recursive: true });
108
120
  process.stdout.write(picocolors_1.default.green(`Created ${node_path_1.default.join(".semlint", "rules")}/\n`));
109
121
  }
110
- const exampleRulePath = node_path_1.default.join(rulesDir, "SEMLINT_EXAMPLE_001.json");
111
- if (!node_fs_1.default.existsSync(exampleRulePath)) {
112
- const exampleRule = {
113
- id: "SEMLINT_EXAMPLE_001",
114
- title: "My first rule",
115
- severity_default: "warn",
116
- prompt: "Describe what the agent should check in the changed code. Example: flag when new functions lack JSDoc, or when error handling is missing."
117
- };
118
- node_fs_1.default.writeFileSync(exampleRulePath, `${JSON.stringify(exampleRule, null, 2)}\n`, "utf8");
119
- 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`));
122
+ const bundledRulesDir = node_path_1.default.resolve(__dirname, "..", ".semlint", "rules");
123
+ if (!node_fs_1.default.existsSync(bundledRulesDir) || !node_fs_1.default.statSync(bundledRulesDir).isDirectory()) {
124
+ process.stderr.write(picocolors_1.default.yellow(`No bundled rules found at ${bundledRulesDir}. Add rule files manually under ${node_path_1.default.join(".semlint", "rules")}.\n`));
125
+ return 0;
126
+ }
127
+ const bundledRules = node_fs_1.default
128
+ .readdirSync(bundledRulesDir)
129
+ .filter((name) => name.endsWith(".json"))
130
+ .sort((a, b) => a.localeCompare(b));
131
+ for (const fileName of bundledRules) {
132
+ const source = node_path_1.default.join(bundledRulesDir, fileName);
133
+ const target = node_path_1.default.join(rulesDir, fileName);
134
+ if (!force && node_fs_1.default.existsSync(target)) {
135
+ continue;
136
+ }
137
+ node_fs_1.default.copyFileSync(source, target);
138
+ process.stdout.write(picocolors_1.default.green(`Copied ${node_path_1.default.join(".semlint", "rules", fileName)}\n`));
120
139
  }
121
140
  return 0;
122
141
  }
package/dist/main.js CHANGED
@@ -7,6 +7,8 @@ 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 promises_1 = __importDefault(require("node:readline/promises"));
11
+ const node_process_1 = require("node:process");
10
12
  const package_json_1 = require("../package.json");
11
13
  const backend_1 = require("./backend");
12
14
  const config_1 = require("./config");
@@ -16,6 +18,7 @@ const filter_1 = require("./filter");
16
18
  const git_1 = require("./git");
17
19
  const reporter_1 = require("./reporter");
18
20
  const rules_1 = require("./rules");
21
+ const secrets_1 = require("./secrets");
19
22
  const utils_1 = require("./utils");
20
23
  function timed(enabled, label, action) {
21
24
  const startedAt = Date.now();
@@ -29,6 +32,64 @@ async function timedAsync(enabled, label, action) {
29
32
  (0, utils_1.debugLog)(enabled, `${label} in ${Date.now() - startedAt}ms`);
30
33
  return result;
31
34
  }
35
+ function formatFileList(title, files) {
36
+ if (files.length === 0) {
37
+ return `${title} (0):\n (none)\n`;
38
+ }
39
+ return `${title} (${files.length}):\n${files.map((file) => ` - ${file}`).join("\n")}\n`;
40
+ }
41
+ async function confirmDiffPreview(includedFiles, excludedFiles, autoAccept) {
42
+ const boxWidth = 80;
43
+ const boxBorder = picocolors_1.default.dim(`+${"-".repeat(boxWidth - 2)}+`);
44
+ // INFO : https://patorjk.com/software/taag/#p=display&f=Terrace&t=Semlint&x=none&v=4&h=4&w=80&we=false
45
+ const semlintAscii = [
46
+ " ░██████ ░██ ░██ ░██ ",
47
+ " ░██ ░██ ░██ ░██ ",
48
+ "░██ ░███████ ░█████████████ ░██ ░██░████████ ░████████ ",
49
+ " ░████████ ░██ ░██ ░██ ░██ ░██ ░██ ░██░██ ░██ ░██ ",
50
+ " ░██ ░█████████ ░██ ░██ ░██ ░██ ░██░██ ░██ ░██ ",
51
+ " ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██░██ ░██ ░██ ",
52
+ " ░██████ ░███████ ░██ ░██ ░██ ░██ ░██░██ ░██ ░████ ",
53
+ " ",
54
+ " "
55
+ ];
56
+ const boxLine = (text = "", style = (value) => value) => {
57
+ const visibleText = text.length > boxWidth - 4 ? `${text.slice(0, boxWidth - 7)}...` : text;
58
+ const padded = visibleText.padEnd(boxWidth - 4, " ");
59
+ return `${picocolors_1.default.dim("|")} ${style(padded)} ${picocolors_1.default.dim("|")}\n`;
60
+ };
61
+ process.stdout.write("\n");
62
+ process.stdout.write(`${picocolors_1.default.cyan(semlintAscii.join("\n"))}\n\n`);
63
+ process.stdout.write(`${picocolors_1.default.dim(`Semlint v${package_json_1.version} (alpha) - Use at your own risk.`)}\n`);
64
+ process.stdout.write(`${boxBorder}\n`);
65
+ process.stdout.write(boxLine("Diff preview", (value) => picocolors_1.default.bold(value)));
66
+ process.stdout.write(boxLine("Review which files are included before sending this diff to your agent.", picocolors_1.default.dim));
67
+ process.stdout.write(boxLine());
68
+ process.stdout.write(boxLine("! Security warning", (value) => picocolors_1.default.red(picocolors_1.default.bold(value))));
69
+ process.stdout.write(boxLine("Run `semlint-cli security` to review security guidance.", picocolors_1.default.dim));
70
+ process.stdout.write(`${boxBorder}\n\n`);
71
+ process.stdout.write(formatFileList(picocolors_1.default.bold("Included files"), includedFiles));
72
+ process.stdout.write("\n");
73
+ process.stdout.write(formatFileList(picocolors_1.default.bold("Excluded files"), excludedFiles));
74
+ process.stdout.write("\n");
75
+ if (autoAccept) {
76
+ process.stdout.write(picocolors_1.default.dim("Auto-accepted with --yes.\n\n"));
77
+ return true;
78
+ }
79
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
80
+ process.stderr.write(picocolors_1.default.red("Diff confirmation is required by default. Re-run with --yes (-y) to auto-accept in non-interactive environments.\n"));
81
+ return false;
82
+ }
83
+ const rl = promises_1.default.createInterface({ input: node_process_1.stdin, output: node_process_1.stdout });
84
+ try {
85
+ const answer = await rl.question(picocolors_1.default.yellow("Proceed with Semlint analysis? [y/N] "));
86
+ const normalized = answer.trim().toLowerCase();
87
+ return normalized === "y" || normalized === "yes";
88
+ }
89
+ finally {
90
+ rl.close();
91
+ }
92
+ }
32
93
  async function runSemlint(options) {
33
94
  const startedAt = Date.now();
34
95
  let spinner = null;
@@ -39,17 +100,42 @@ async function runSemlint(options) {
39
100
  (0, utils_1.debugLog)(config.debug, `Loaded ${rules.length} rule(s)`);
40
101
  (0, utils_1.debugLog)(config.debug, `Rule IDs: ${rules.map((rule) => rule.id).join(", ")}`);
41
102
  const useLocalBranchDiff = !options.base && !options.head;
42
- const diff = await timedAsync(config.debug, "Computed git diff", () => useLocalBranchDiff ? (0, git_1.getLocalBranchDiff)() : (0, git_1.getGitDiff)(config.base, config.head));
103
+ const repoRoot = await timedAsync(config.debug, "Resolved git repo root", () => (0, git_1.getRepoRoot)());
104
+ const scanRoot = repoRoot ?? process.cwd();
105
+ (0, utils_1.debugLog)(config.debug, `Using diff/ignore scan root: ${scanRoot}`);
106
+ const rawDiff = await timedAsync(config.debug, "Computed git diff", () => useLocalBranchDiff ? (0, git_1.getLocalBranchDiff)() : (0, git_1.getGitDiff)(config.base, config.head));
107
+ const { filteredDiff: diff, excludedFiles } = timed(config.debug, "Filtered diff by ignore rules", () => (0, secrets_1.filterDiffByIgnoreRules)(rawDiff, scanRoot, config.security.ignoreFiles));
108
+ if (excludedFiles.length > 0) {
109
+ (0, utils_1.debugLog)(config.debug, `Excluded ${excludedFiles.length} file(s) by ignore/security rules: ${excludedFiles.join(", ")}`);
110
+ }
111
+ if (config.security.secretGuard) {
112
+ const findings = timed(config.debug, "Scanned diff for secrets", () => (0, secrets_1.scanDiffForSecrets)(diff, config.security.allowPatterns, config.security.allowFiles));
113
+ if (findings.length > 0) {
114
+ process.stderr.write(picocolors_1.default.red("Secret guard blocked analysis: potential secrets were detected in the diff. Nothing was sent to the backend.\n"));
115
+ process.stderr.write("Allow a known-safe file by adding a glob to security.allow_files in semlint.json (example: \"allow_files\": [\"src/my-sensitive-file.ts\"]).\n");
116
+ findings.slice(0, 20).forEach((finding) => {
117
+ process.stderr.write(` ${finding.file}:${finding.line} ${finding.kind} sample=${finding.redactedSample}\n`);
118
+ });
119
+ if (findings.length > 20) {
120
+ process.stderr.write(` ...and ${findings.length - 20} more finding(s)\n`);
121
+ }
122
+ return 2;
123
+ }
124
+ }
43
125
  const changedFiles = timed(config.debug, "Parsed changed files from diff", () => (0, filter_1.extractChangedFilesFromDiff)(diff));
126
+ const confirmed = await timedAsync(config.debug, "User confirmation", () => confirmDiffPreview(changedFiles, excludedFiles, options.autoAccept));
127
+ if (!confirmed) {
128
+ process.stderr.write("Aborted by user.\n");
129
+ return 2;
130
+ }
44
131
  (0, utils_1.debugLog)(config.debug, useLocalBranchDiff
45
132
  ? "Using local branch diff (staged + unstaged + untracked only)"
46
133
  : `Using explicit ref diff (${config.base}..${config.head})`);
47
134
  (0, utils_1.debugLog)(config.debug, `Detected ${changedFiles.length} changed file(s)`);
48
- const repoRoot = await timedAsync(config.debug, "Resolved git repo root", () => (0, git_1.getRepoRoot)());
49
135
  const backend = timed(config.debug, "Initialized backend runner", () => (0, backend_1.createBackendRunner)(config));
50
136
  const runnableRules = rules.filter((rule) => {
51
137
  const filterStartedAt = Date.now();
52
- const shouldRun = (0, filter_1.shouldRunRule)(rule, changedFiles, diff);
138
+ const shouldRun = (0, filter_1.shouldRunRule)(rule, changedFiles, diff, config.rulesIncludeGlobs, config.rulesExcludeGlobs);
53
139
  (0, utils_1.debugLog)(config.debug, `Rule ${rule.id}: filter check in ${Date.now() - filterStartedAt}ms`);
54
140
  if (!shouldRun) {
55
141
  (0, utils_1.debugLog)(config.debug, `Skipping rule ${rule.id}: filters did not match`);
@@ -0,0 +1,160 @@
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.filterDiffByIgnoreRules = filterDiffByIgnoreRules;
7
+ exports.scanDiffForSecrets = scanDiffForSecrets;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const ignore_1 = __importDefault(require("ignore"));
11
+ const picomatch_1 = __importDefault(require("picomatch"));
12
+ const diff_1 = require("./diff");
13
+ const BUILTIN_SENSITIVE_GLOBS = [
14
+ ".env",
15
+ "*.env",
16
+ ".env.*",
17
+ "*.env.*",
18
+ "*.pem",
19
+ "*.key",
20
+ "id_rsa",
21
+ "id_rsa.*",
22
+ "secrets/",
23
+ "credentials/",
24
+ "*credentials*.json"
25
+ ];
26
+ const SECRET_KEYWORDS = [
27
+ "password",
28
+ "passwd",
29
+ "secret",
30
+ "token",
31
+ "api_key",
32
+ "apikey",
33
+ "x-api-key",
34
+ "access_token",
35
+ "refresh_token",
36
+ "id_token",
37
+ "client_secret",
38
+ "auth_token",
39
+ "authorization",
40
+ "bearer ",
41
+ "private_key",
42
+ "private key",
43
+ "certificate",
44
+ "cert",
45
+ "connectionstring",
46
+ "database_url",
47
+ "postgres://",
48
+ "mongodb+srv://",
49
+ "mysql://",
50
+ "redis://",
51
+ "sk-",
52
+ "sk_",
53
+ "ghp_",
54
+ "github_pat_",
55
+ "xoxb-",
56
+ "xoxp-",
57
+ "akia",
58
+ "-----begin",
59
+ "key"
60
+ ];
61
+ function readIgnorePatterns(cwd, ignoreFiles) {
62
+ return ignoreFiles.flatMap((fileName) => {
63
+ const filePath = node_path_1.default.join(cwd, fileName);
64
+ if (!node_fs_1.default.existsSync(filePath)) {
65
+ return [];
66
+ }
67
+ return node_fs_1.default
68
+ .readFileSync(filePath, "utf8")
69
+ .split("\n")
70
+ .map((line) => line.trim())
71
+ .filter((line) => line !== "" && !line.startsWith("#"));
72
+ });
73
+ }
74
+ function createIgnoreMatcher(patterns) {
75
+ const matcher = (0, ignore_1.default)().add(patterns);
76
+ return (filePath) => matcher.ignores(filePath);
77
+ }
78
+ function redactSample(sample) {
79
+ const compact = sample.trim();
80
+ if (compact.length <= 6) {
81
+ return "***";
82
+ }
83
+ return `${compact.slice(0, 2)}***${compact.slice(-2)}`;
84
+ }
85
+ function parseAllowMatchers(patterns) {
86
+ return patterns.flatMap((pattern) => {
87
+ try {
88
+ return [new RegExp(pattern)];
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ });
94
+ }
95
+ function filterDiffByIgnoreRules(diff, cwd, ignoreFiles) {
96
+ const ignorePatterns = [...readIgnorePatterns(cwd, ignoreFiles), ...BUILTIN_SENSITIVE_GLOBS];
97
+ const ignoreMatcher = createIgnoreMatcher(ignorePatterns);
98
+ const chunks = (0, diff_1.splitDiffIntoFileChunks)(diff);
99
+ const excludedFiles = chunks
100
+ .filter((chunk) => chunk.file !== "" && ignoreMatcher(chunk.file))
101
+ .map((chunk) => chunk.file);
102
+ const filteredDiff = chunks
103
+ .filter((chunk) => chunk.file === "" || !ignoreMatcher(chunk.file))
104
+ .map((chunk) => chunk.chunk)
105
+ .join("\n");
106
+ return {
107
+ filteredDiff,
108
+ excludedFiles: Array.from(new Set(excludedFiles)).sort((a, b) => a.localeCompare(b))
109
+ };
110
+ }
111
+ function scanDiffForSecrets(diff, allowPatterns, allowFiles) {
112
+ const findings = [];
113
+ const allowMatchers = parseAllowMatchers(allowPatterns);
114
+ const allowFileMatcher = allowFiles.length > 0 ? (0, picomatch_1.default)(allowFiles, { dot: true }) : undefined;
115
+ const lines = diff.split("\n");
116
+ let currentFile = "(unknown)";
117
+ let newLine = 1;
118
+ for (const line of lines) {
119
+ if (line.startsWith("diff --git ")) {
120
+ const parsed = (0, diff_1.parseDiffGitHeader)(line);
121
+ if (parsed) {
122
+ currentFile = parsed.bPath;
123
+ }
124
+ continue;
125
+ }
126
+ const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
127
+ if (hunk) {
128
+ newLine = Number(hunk[1]);
129
+ continue;
130
+ }
131
+ if (line.startsWith("+") && !line.startsWith("+++")) {
132
+ const fileAllowed = allowFileMatcher?.(currentFile) ?? false;
133
+ if (fileAllowed) {
134
+ newLine += 1;
135
+ continue;
136
+ }
137
+ const added = line.slice(1);
138
+ const allowed = allowMatchers.some((matcher) => matcher.test(added));
139
+ if (!allowed) {
140
+ const lowered = added.toLowerCase();
141
+ const matchedKeyword = SECRET_KEYWORDS.find((keyword) => lowered.includes(keyword));
142
+ if (matchedKeyword) {
143
+ findings.push({
144
+ file: currentFile,
145
+ line: newLine,
146
+ kind: `keyword:${matchedKeyword}`,
147
+ redactedSample: redactSample(added)
148
+ });
149
+ }
150
+ }
151
+ newLine += 1;
152
+ continue;
153
+ }
154
+ if (line.startsWith(" ")) {
155
+ newLine += 1;
156
+ continue;
157
+ }
158
+ }
159
+ return findings;
160
+ }
@@ -0,0 +1,157 @@
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
+ const strict_1 = __importDefault(require("node:assert/strict"));
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_os_1 = __importDefault(require("node:os"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const node_test_1 = __importDefault(require("node:test"));
11
+ const secrets_1 = require("./secrets");
12
+ (0, node_test_1.default)("filterDiffByIgnoreRules aggregates ignore files", () => {
13
+ const cwd = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "semlint-ignore-"));
14
+ try {
15
+ node_fs_1.default.writeFileSync(node_path_1.default.join(cwd, ".cursorignore"), "cursor-only/**\n", "utf8");
16
+ node_fs_1.default.writeFileSync(node_path_1.default.join(cwd, ".semlintignore"), "semlint-only/**\n", "utf8");
17
+ node_fs_1.default.writeFileSync(node_path_1.default.join(cwd, ".cursoringore"), "typo-ignore/**\n", "utf8");
18
+ const diff = [
19
+ "diff --git a/cursor-only/a.ts b/cursor-only/a.ts",
20
+ "--- a/cursor-only/a.ts",
21
+ "+++ b/cursor-only/a.ts",
22
+ "@@ -0,0 +1 @@",
23
+ "+const a = 1;",
24
+ "diff --git a/semlint-only/b.ts b/semlint-only/b.ts",
25
+ "--- a/semlint-only/b.ts",
26
+ "+++ b/semlint-only/b.ts",
27
+ "@@ -0,0 +1 @@",
28
+ "+const b = 1;",
29
+ "diff --git a/typo-ignore/c.ts b/typo-ignore/c.ts",
30
+ "--- a/typo-ignore/c.ts",
31
+ "+++ b/typo-ignore/c.ts",
32
+ "@@ -0,0 +1 @@",
33
+ "+const c = 1;",
34
+ "diff --git a/src/safe.ts b/src/safe.ts",
35
+ "--- a/src/safe.ts",
36
+ "+++ b/src/safe.ts",
37
+ "@@ -0,0 +1 @@",
38
+ "+const safe = 1;"
39
+ ].join("\n");
40
+ const result = (0, secrets_1.filterDiffByIgnoreRules)(diff, cwd, [
41
+ ".cursorignore",
42
+ ".semlintignore",
43
+ ".cursoringore"
44
+ ]);
45
+ strict_1.default.deepEqual(result.excludedFiles, [
46
+ "cursor-only/a.ts",
47
+ "semlint-only/b.ts",
48
+ "typo-ignore/c.ts"
49
+ ]);
50
+ strict_1.default.match(result.filteredDiff, /src\/safe\.ts/);
51
+ strict_1.default.doesNotMatch(result.filteredDiff, /cursor-only\/a\.ts/);
52
+ strict_1.default.doesNotMatch(result.filteredDiff, /semlint-only\/b\.ts/);
53
+ strict_1.default.doesNotMatch(result.filteredDiff, /typo-ignore\/c\.ts/);
54
+ }
55
+ finally {
56
+ node_fs_1.default.rmSync(cwd, { recursive: true, force: true });
57
+ }
58
+ });
59
+ (0, node_test_1.default)("scanDiffForSecrets flags keyword matches on added lines", () => {
60
+ const diff = [
61
+ "diff --git a/src/test2.ts b/src/test2.ts",
62
+ "--- a/src/test2.ts",
63
+ "+++ b/src/test2.ts",
64
+ "@@ -0,0 +4 @@",
65
+ '+const payload = { "PASSWORD": "password", "API_KEY": "api-key" };'
66
+ ].join("\n");
67
+ const findings = (0, secrets_1.scanDiffForSecrets)(diff, [], []);
68
+ strict_1.default.ok(findings.length >= 1);
69
+ strict_1.default.equal(findings[0].file, "src/test2.ts");
70
+ strict_1.default.equal(findings[0].line, 4);
71
+ strict_1.default.match(findings[0].kind, /^keyword:/);
72
+ });
73
+ (0, node_test_1.default)("scanDiffForSecrets skips files listed in allow_files", () => {
74
+ const diff = [
75
+ "diff --git a/src/test2.ts b/src/test2.ts",
76
+ "--- a/src/test2.ts",
77
+ "+++ b/src/test2.ts",
78
+ "@@ -0,0 +1 @@",
79
+ '+const password = "should-not-block-when-allowed";'
80
+ ].join("\n");
81
+ const findings = (0, secrets_1.scanDiffForSecrets)(diff, [], ["src/test2.ts"]);
82
+ strict_1.default.equal(findings.length, 0);
83
+ });
84
+ (0, node_test_1.default)("filterDiffByIgnoreRules matches basename ignores in nested paths", () => {
85
+ const cwd = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "semlint-ignore-basename-"));
86
+ try {
87
+ node_fs_1.default.writeFileSync(node_path_1.default.join(cwd, ".semlintignore"), ".env\n*.env\n", "utf8");
88
+ const diff = [
89
+ "diff --git a/src/secret.env b/src/secret.env",
90
+ "--- a/src/secret.env",
91
+ "+++ b/src/secret.env",
92
+ "@@ -0,0 +1 @@",
93
+ "+API_KEY=abc",
94
+ "diff --git a/src/safe.ts b/src/safe.ts",
95
+ "--- a/src/safe.ts",
96
+ "+++ b/src/safe.ts",
97
+ "@@ -0,0 +1 @@",
98
+ "+const safe = true;"
99
+ ].join("\n");
100
+ const result = (0, secrets_1.filterDiffByIgnoreRules)(diff, cwd, [".semlintignore"]);
101
+ strict_1.default.deepEqual(result.excludedFiles, ["src/secret.env"]);
102
+ strict_1.default.match(result.filteredDiff, /src\/safe\.ts/);
103
+ strict_1.default.doesNotMatch(result.filteredDiff, /src\/secret\.env/);
104
+ }
105
+ finally {
106
+ node_fs_1.default.rmSync(cwd, { recursive: true, force: true });
107
+ }
108
+ });
109
+ (0, node_test_1.default)("filterDiffByIgnoreRules supports gitignore negation patterns", () => {
110
+ const cwd = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "semlint-ignore-negation-"));
111
+ try {
112
+ node_fs_1.default.writeFileSync(node_path_1.default.join(cwd, ".semlintignore"), "*.env\n!src/public.env\n", "utf8");
113
+ const diff = [
114
+ "diff --git a/src/secret.env b/src/secret.env",
115
+ "--- a/src/secret.env",
116
+ "+++ b/src/secret.env",
117
+ "@@ -0,0 +1 @@",
118
+ "+API_KEY=blocked",
119
+ "diff --git a/src/public.env b/src/public.env",
120
+ "--- a/src/public.env",
121
+ "+++ b/src/public.env",
122
+ "@@ -0,0 +1 @@",
123
+ "+SAFE=true"
124
+ ].join("\n");
125
+ const result = (0, secrets_1.filterDiffByIgnoreRules)(diff, cwd, [".semlintignore"]);
126
+ strict_1.default.deepEqual(result.excludedFiles, ["src/secret.env"]);
127
+ strict_1.default.match(result.filteredDiff, /src\/public\.env/);
128
+ strict_1.default.doesNotMatch(result.filteredDiff, /src\/secret\.env/);
129
+ }
130
+ finally {
131
+ node_fs_1.default.rmSync(cwd, { recursive: true, force: true });
132
+ }
133
+ });
134
+ (0, node_test_1.default)("filterDiffByIgnoreRules excludes *.env by builtin sensitive globs", () => {
135
+ const cwd = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "semlint-ignore-builtin-env-"));
136
+ try {
137
+ const diff = [
138
+ "diff --git a/src/secret.env b/src/secret.env",
139
+ "--- a/src/secret.env",
140
+ "+++ b/src/secret.env",
141
+ "@@ -0,0 +1 @@",
142
+ "+API_KEY=blocked",
143
+ "diff --git a/src/safe.ts b/src/safe.ts",
144
+ "--- a/src/safe.ts",
145
+ "+++ b/src/safe.ts",
146
+ "@@ -0,0 +1 @@",
147
+ "+const safe = true;"
148
+ ].join("\n");
149
+ const result = (0, secrets_1.filterDiffByIgnoreRules)(diff, cwd, [".semlintignore"]);
150
+ strict_1.default.deepEqual(result.excludedFiles, ["src/secret.env"]);
151
+ strict_1.default.match(result.filteredDiff, /src\/safe\.ts/);
152
+ strict_1.default.doesNotMatch(result.filteredDiff, /src\/secret\.env/);
153
+ }
154
+ finally {
155
+ node_fs_1.default.rmSync(cwd, { recursive: true, force: true });
156
+ }
157
+ });
@@ -0,0 +1,26 @@
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.printSecurityGuide = printSecurityGuide;
7
+ const picocolors_1 = __importDefault(require("picocolors"));
8
+ const SECURITY_TEXT = [
9
+ picocolors_1.default.bold("Semlint security guide"),
10
+ "",
11
+ "Semlint applies security controls before backend execution:",
12
+ "- It filters diff paths using ignore files and built-in sensitive globs.",
13
+ "- It scans added lines for high-signal secret patterns.",
14
+ "- It blocks backend execution when potential secrets are found.",
15
+ "",
16
+ "Your responsibilities:",
17
+ "- Keep `.gitignore`, `.cursorignore`, `.semlintignore` (and configured `security.ignore_files`) up to date.",
18
+ "- Tune `security.allow_patterns` and `security.allow_files` only for known-safe cases.",
19
+ "- Review your agent native access and security policy.",
20
+ "",
21
+ "More details: README.md#security-responsibility-model"
22
+ ].join("\n");
23
+ function printSecurityGuide() {
24
+ process.stdout.write(`${SECURITY_TEXT}\n`);
25
+ return 0;
26
+ }
package/dist/test2.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ const a = {
3
+ "PASSWORD": "password",
4
+ "API_KEY": "api-key"
5
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "semlint-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.9",
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",
@@ -10,11 +10,12 @@
10
10
  "files": [
11
11
  "dist",
12
12
  "prompts",
13
- "rules",
13
+ ".semlint/rules",
14
14
  "README.md"
15
15
  ],
16
16
  "scripts": {
17
17
  "build": "tsc -p tsconfig.json",
18
+ "test": "pnpm run build && node --test \"dist/*.test.js\"",
18
19
  "prepublishOnly": "pnpm run build",
19
20
  "check": "node dist/cli.js check",
20
21
  "start": "node dist/cli.js check",
@@ -41,6 +42,7 @@
41
42
  },
42
43
  "packageManager": "pnpm@10.29.2",
43
44
  "dependencies": {
45
+ "ignore": "^7.0.5",
44
46
  "nanospinner": "^1.2.2",
45
47
  "picocolors": "^1.1.1",
46
48
  "picomatch": "^4.0.3"