semlint-cli 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ {
2
+ "id": "SEMLINT_NAMING_001",
3
+ "title": "Ambient naming convention consistency",
4
+ "severity_default": "warn",
5
+ "include_globs": ["src/**/*.ts"],
6
+ "exclude_globs": ["**/*.test.ts", "**/*.spec.ts"],
7
+ "diff_regex": [
8
+ "^[+-].*\\b(const|let|var|function|class|interface|type|enum)\\b",
9
+ "^[+-].*\\b[A-Za-z_][A-Za-z0-9_]*\\b"
10
+ ],
11
+ "prompt": "Verify naming is consistent with the ambient naming conventions already used in surrounding code. Both in term of semantic and casing."
12
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "id": "SEMLINT_PATTERN_002",
3
+ "title": "Ambient pattern is respected",
4
+ "severity_default": "warn",
5
+ "include_globs": ["src/**/*.ts"],
6
+ "exclude_globs": ["**/*.test.ts", "**/*.spec.ts"],
7
+ "diff_regex": [
8
+ "^[+-].*\\b(async|await|Promise|try|catch|throw|switch|map|filter|reduce|forEach)\\b",
9
+ "^[+-].*\\b(import|export|class|interface|type|function|return)\\b"
10
+ ],
11
+ "prompt": "Check whether ambient implementation patterns are respected. Compare new or changed code against nearby established patterns. Flag clear regressions where the proposed change deviates from consistent local patterns without obvious justification."
12
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "id": "SEMLINT_SWE_003",
3
+ "title": "Obvious SWE mistakes",
4
+ "severity_default": "warn",
5
+ "include_globs": ["src/**/*.ts"],
6
+ "exclude_globs": ["**/*.test.ts", "**/*.spec.ts"],
7
+ "diff_regex": [
8
+ "^[+-].*\\b(any|as\\s+any|TODO|FIXME|console\\.log|@ts-ignore|throw\\s+new\\s+Error|catch\\s*\\()\\b",
9
+ "^[+-].*\\b(if|else|switch|return|await|Promise|map|forEach|reduce)\\b"
10
+ ],
11
+ "prompt": "Find obvious software-engineering mistakes in the proposed change that are likely unintended. Do not nitpick style or architecture unless the issue is clearly harmful. Report only high-signal findings."
12
+ }
package/README.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Semlint CLI MVP
2
2
 
3
+ ## Motivation
4
+
5
+ Upstream instruction files (`AGENTS.md`, `CURSOR.md`, etc.) are the standard way to guide coding agents — but a [recent study (Gloaguen et al., 2026)](https://arxiv.org/abs/2602.11988) found that such context files tend to *reduce* task success rates compared to providing no context at all, while increasing inference cost by over 20%. The root cause: agents respect the instructions, but unnecessary or over-specified requirements make tasks harder, with no feedback mechanism to catch when rules are ignored or misapplied.
6
+
7
+ Semlint takes a different approach. Instead of providing guidance upfront and hoping for the best, rules are enforced *after the fact* as a lint pass on the diff — giving agents a deterministic red/green signal and closing the feedback loop.
8
+
9
+ ---
10
+
3
11
  Semlint is a deterministic semantic lint CLI that:
4
12
 
5
13
  - reads a git diff,
@@ -84,8 +92,14 @@ Unknown fields are ignored.
84
92
  "execution": {
85
93
  "batch": false
86
94
  },
95
+ "security": {
96
+ "secret_guard": true,
97
+ "allow_patterns": [],
98
+ "allow_files": [],
99
+ "ignore_files": [".gitignore", ".cursorignore", ".semlintignore", ".cursoringore"]
100
+ },
87
101
  "rules": {
88
- "disable": ["SEMLINT_EXAMPLE_001"],
102
+ "disable": [],
89
103
  "severity_overrides": {
90
104
  "SEMLINT_API_001": "error"
91
105
  }
@@ -116,11 +130,11 @@ This creates `./semlint.json` and auto-detects installed coding agent CLIs in th
116
130
 
117
131
  If no known CLI is detected, Semlint falls back to `cursor-cli` + executable `cursor`.
118
132
 
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.
133
+ Use `semlint init --force` to overwrite an existing config file. Init also creates `.semlint/rules/` and copies the bundled Semlint rule files into it.
120
134
 
121
135
  ## Rule files
122
136
 
123
- Rule JSON files are loaded from `.semlint/rules/`. Run `semlint init` to create this folder and an example rule you can edit.
137
+ Rule JSON files are loaded from `.semlint/rules/`. Run `semlint init` to create this folder and copy bundled rules into it.
124
138
 
125
139
  Required fields:
126
140
 
@@ -187,6 +201,33 @@ If parsing fails, Semlint retries once with appended instruction:
187
201
 
188
202
  If backend execution still fails and Semlint is running in an interactive terminal (TTY), it automatically performs one interactive passthrough run so you can satisfy backend setup prompts (for example auth/workspace trust), then retries machine parsing once.
189
203
 
204
+ ## Secret guard
205
+
206
+ Semlint uses a fail-closed secret guard before any backend call:
207
+
208
+ - Filters diff chunks using path ignore rules from `.gitignore`, `.cursorignore`, `.semlintignore`
209
+ - Applies additional built-in sensitive path deny patterns (`.env*`, key files, secrets/credentials folders)
210
+ - Scans added diff lines for high-signal secret keywords and token prefixes (password/token/api key/private key/JWT/provider key prefixes)
211
+ - If any potential secrets are found, Semlint exits with code `2` and sends nothing to the backend
212
+
213
+ Config:
214
+
215
+ ```json
216
+ {
217
+ "security": {
218
+ "secret_guard": true,
219
+ "allow_patterns": [],
220
+ "allow_files": [],
221
+ "ignore_files": [".gitignore", ".cursorignore", ".semlintignore", ".cursoringore"]
222
+ }
223
+ }
224
+ ```
225
+
226
+ - `secret_guard`: enable/disable secret blocking (default `true`)
227
+ - `allow_patterns`: regex list to suppress known-safe fixtures from triggering the guard
228
+ - `allow_files`: file glob allowlist to skip secret scanning for known-safe files (example: `["src/test-fixtures/**"]`)
229
+ - `ignore_files`: ignore files Semlint reads for path-level filtering (default: `.gitignore`, `.cursorignore`, `.semlintignore`, `.cursoringore`)
230
+
190
231
  ## Prompt files
191
232
 
192
233
  Core system prompts are externalized under `prompts/` so prompt behavior is easy to inspect and iterate:
package/dist/config.js CHANGED
@@ -20,7 +20,13 @@ const DEFAULTS = {
20
20
  batchMode: false,
21
21
  rulesDisable: [],
22
22
  severityOverrides: {},
23
- backendConfigs: {}
23
+ backendConfigs: {},
24
+ security: {
25
+ secretGuard: true,
26
+ allowPatterns: [],
27
+ ignoreFiles: [".gitignore", ".cursorignore", ".semlintignore"],
28
+ allowFiles: []
29
+ }
24
30
  };
25
31
  function readJsonIfExists(filePath) {
26
32
  if (!node_fs_1.default.existsSync(filePath)) {
@@ -81,6 +87,51 @@ function sanitizeBackendConfigs(value) {
81
87
  return [[name, { executable, args: normalizedArgs, model: model && model !== "" ? model : undefined }]];
82
88
  }));
83
89
  }
90
+ function sanitizeAllowPatterns(value) {
91
+ if (!Array.isArray(value)) {
92
+ return [];
93
+ }
94
+ return value.flatMap((candidate) => {
95
+ if (typeof candidate !== "string" || candidate.trim() === "") {
96
+ return [];
97
+ }
98
+ try {
99
+ new RegExp(candidate);
100
+ return [candidate];
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ });
106
+ }
107
+ function sanitizeIgnoreFiles(value) {
108
+ if (!Array.isArray(value)) {
109
+ return [...DEFAULTS.security.ignoreFiles];
110
+ }
111
+ const normalized = value.flatMap((candidate) => {
112
+ if (typeof candidate !== "string") {
113
+ return [];
114
+ }
115
+ const trimmed = candidate.trim();
116
+ if (trimmed === "") {
117
+ return [];
118
+ }
119
+ return [trimmed];
120
+ });
121
+ return normalized.length > 0 ? normalized : [...DEFAULTS.security.ignoreFiles];
122
+ }
123
+ function sanitizeAllowFiles(value) {
124
+ if (!Array.isArray(value)) {
125
+ return [];
126
+ }
127
+ return value.flatMap((candidate) => {
128
+ if (typeof candidate !== "string") {
129
+ return [];
130
+ }
131
+ const trimmed = candidate.trim();
132
+ return trimmed === "" ? [] : [trimmed];
133
+ });
134
+ }
84
135
  function ensureSelectedBackendIsConfigured(backend, backendConfigs) {
85
136
  if (!(backend in backendConfigs)) {
86
137
  throw new Error(`Backend "${backend}" is not configured. Add it under backends.${backend} with executable and args (including "{prompt}") in semlint.json.`);
@@ -113,6 +164,14 @@ function loadEffectiveConfig(options) {
113
164
  ? fileConfig.rules?.disable.filter((item) => typeof item === "string")
114
165
  : DEFAULTS.rulesDisable,
115
166
  severityOverrides: sanitizeSeverityOverrides((fileConfig.rules?.severity_overrides ?? undefined)),
116
- backendConfigs
167
+ backendConfigs,
168
+ security: {
169
+ secretGuard: typeof fileConfig.security?.secret_guard === "boolean"
170
+ ? fileConfig.security.secret_guard
171
+ : DEFAULTS.security.secretGuard,
172
+ allowPatterns: sanitizeAllowPatterns(fileConfig.security?.allow_patterns),
173
+ ignoreFiles: sanitizeIgnoreFiles(fileConfig.security?.ignore_files),
174
+ allowFiles: sanitizeAllowFiles(fileConfig.security?.allow_files)
175
+ }
117
176
  };
118
177
  }
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/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
  }
@@ -88,41 +68,12 @@ function shouldRunRule(rule, changedFiles, diff) {
88
68
  }
89
69
  return true;
90
70
  }
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
71
  function buildScopedDiff(rule, fullDiff, changedFiles) {
121
72
  const candidateFiles = new Set(getRuleCandidateFiles(rule, changedFiles));
122
73
  if (candidateFiles.size === 0) {
123
74
  return fullDiff;
124
75
  }
125
- const chunks = splitDiffIntoFileChunks(fullDiff);
76
+ const chunks = (0, diff_1.splitDiffIntoFileChunks)(fullDiff);
126
77
  const scoped = chunks
127
78
  .filter((chunk) => chunk.file !== "" && candidateFiles.has(chunk.file))
128
79
  .map((chunk) => chunk.chunk)
package/dist/init.js CHANGED
@@ -87,6 +87,12 @@ 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: [],
92
98
  severity_overrides: {}
@@ -107,16 +113,23 @@ function scaffoldConfig(force = false) {
107
113
  node_fs_1.default.mkdirSync(rulesDir, { recursive: true });
108
114
  process.stdout.write(picocolors_1.default.green(`Created ${node_path_1.default.join(".semlint", "rules")}/\n`));
109
115
  }
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`));
116
+ const bundledRulesDir = node_path_1.default.resolve(__dirname, "..", ".semlint", "rules");
117
+ if (!node_fs_1.default.existsSync(bundledRulesDir) || !node_fs_1.default.statSync(bundledRulesDir).isDirectory()) {
118
+ 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`));
119
+ return 0;
120
+ }
121
+ const bundledRules = node_fs_1.default
122
+ .readdirSync(bundledRulesDir)
123
+ .filter((name) => name.endsWith(".json"))
124
+ .sort((a, b) => a.localeCompare(b));
125
+ for (const fileName of bundledRules) {
126
+ const source = node_path_1.default.join(bundledRulesDir, fileName);
127
+ const target = node_path_1.default.join(rulesDir, fileName);
128
+ if (!force && node_fs_1.default.existsSync(target)) {
129
+ continue;
130
+ }
131
+ node_fs_1.default.copyFileSync(source, target);
132
+ process.stdout.write(picocolors_1.default.green(`Copied ${node_path_1.default.join(".semlint", "rules", fileName)}\n`));
120
133
  }
121
134
  return 0;
122
135
  }
package/dist/main.js CHANGED
@@ -16,6 +16,7 @@ const filter_1 = require("./filter");
16
16
  const git_1 = require("./git");
17
17
  const reporter_1 = require("./reporter");
18
18
  const rules_1 = require("./rules");
19
+ const secrets_1 = require("./secrets");
19
20
  const utils_1 = require("./utils");
20
21
  function timed(enabled, label, action) {
21
22
  const startedAt = Date.now();
@@ -39,13 +40,33 @@ async function runSemlint(options) {
39
40
  (0, utils_1.debugLog)(config.debug, `Loaded ${rules.length} rule(s)`);
40
41
  (0, utils_1.debugLog)(config.debug, `Rule IDs: ${rules.map((rule) => rule.id).join(", ")}`);
41
42
  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));
43
+ const repoRoot = await timedAsync(config.debug, "Resolved git repo root", () => (0, git_1.getRepoRoot)());
44
+ const scanRoot = repoRoot ?? process.cwd();
45
+ (0, utils_1.debugLog)(config.debug, `Using diff/ignore scan root: ${scanRoot}`);
46
+ const rawDiff = await timedAsync(config.debug, "Computed git diff", () => useLocalBranchDiff ? (0, git_1.getLocalBranchDiff)() : (0, git_1.getGitDiff)(config.base, config.head));
47
+ const { filteredDiff: diff, excludedFiles } = timed(config.debug, "Filtered diff by ignore rules", () => (0, secrets_1.filterDiffByIgnoreRules)(rawDiff, scanRoot, config.security.ignoreFiles));
48
+ if (excludedFiles.length > 0) {
49
+ (0, utils_1.debugLog)(config.debug, `Excluded ${excludedFiles.length} file(s) by ignore/security rules: ${excludedFiles.join(", ")}`);
50
+ }
51
+ if (config.security.secretGuard) {
52
+ const findings = timed(config.debug, "Scanned diff for secrets", () => (0, secrets_1.scanDiffForSecrets)(diff, config.security.allowPatterns, config.security.allowFiles));
53
+ if (findings.length > 0) {
54
+ 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"));
55
+ process.stderr.write("Allow a known-safe file by adding a glob to security.allow_files in semlint.json (example: \"allow_files\": [\"src/test2.ts\"]).\n");
56
+ findings.slice(0, 20).forEach((finding) => {
57
+ process.stderr.write(` ${finding.file}:${finding.line} ${finding.kind} sample=${finding.redactedSample}\n`);
58
+ });
59
+ if (findings.length > 20) {
60
+ process.stderr.write(` ...and ${findings.length - 20} more finding(s)\n`);
61
+ }
62
+ return 2;
63
+ }
64
+ }
43
65
  const changedFiles = timed(config.debug, "Parsed changed files from diff", () => (0, filter_1.extractChangedFilesFromDiff)(diff));
44
66
  (0, utils_1.debugLog)(config.debug, useLocalBranchDiff
45
67
  ? "Using local branch diff (staged + unstaged + untracked only)"
46
68
  : `Using explicit ref diff (${config.base}..${config.head})`);
47
69
  (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
70
  const backend = timed(config.debug, "Initialized backend runner", () => (0, backend_1.createBackendRunner)(config));
50
71
  const runnableRules = rules.filter((rule) => {
51
72
  const filterStartedAt = Date.now();
@@ -0,0 +1,153 @@
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 picomatch_1 = __importDefault(require("picomatch"));
11
+ const diff_1 = require("./diff");
12
+ const BUILTIN_SENSITIVE_GLOBS = [
13
+ ".env",
14
+ ".env.*",
15
+ "*.pem",
16
+ "*.key",
17
+ "id_rsa",
18
+ "id_rsa.*",
19
+ "**/secrets/**",
20
+ "**/credentials/**",
21
+ "**/*credentials*.json"
22
+ ];
23
+ const SECRET_KEYWORDS = [
24
+ "password",
25
+ "passwd",
26
+ "secret",
27
+ "token",
28
+ "api_key",
29
+ "apikey",
30
+ "x-api-key",
31
+ "access_token",
32
+ "refresh_token",
33
+ "id_token",
34
+ "client_secret",
35
+ "auth_token",
36
+ "authorization",
37
+ "bearer ",
38
+ "private_key",
39
+ "private key",
40
+ "certificate",
41
+ "cert",
42
+ "connectionstring",
43
+ "database_url",
44
+ "postgres://",
45
+ "mongodb+srv://",
46
+ "mysql://",
47
+ "redis://",
48
+ "sk-",
49
+ "sk_",
50
+ "ghp_",
51
+ "github_pat_",
52
+ "xoxb-",
53
+ "xoxp-",
54
+ "akia",
55
+ "-----begin",
56
+ "key"
57
+ ];
58
+ function readIgnorePatterns(cwd, ignoreFiles) {
59
+ return ignoreFiles.flatMap((fileName) => {
60
+ const filePath = node_path_1.default.join(cwd, fileName);
61
+ if (!node_fs_1.default.existsSync(filePath)) {
62
+ return [];
63
+ }
64
+ return node_fs_1.default
65
+ .readFileSync(filePath, "utf8")
66
+ .split("\n")
67
+ .map((line) => line.trim())
68
+ .filter((line) => line !== "" && !line.startsWith("#"));
69
+ });
70
+ }
71
+ function redactSample(sample) {
72
+ const compact = sample.trim();
73
+ if (compact.length <= 6) {
74
+ return "***";
75
+ }
76
+ return `${compact.slice(0, 2)}***${compact.slice(-2)}`;
77
+ }
78
+ function parseAllowMatchers(patterns) {
79
+ return patterns.flatMap((pattern) => {
80
+ try {
81
+ return [new RegExp(pattern)];
82
+ }
83
+ catch {
84
+ return [];
85
+ }
86
+ });
87
+ }
88
+ function filterDiffByIgnoreRules(diff, cwd, ignoreFiles) {
89
+ const ignorePatterns = [...readIgnorePatterns(cwd, ignoreFiles), ...BUILTIN_SENSITIVE_GLOBS];
90
+ const ignoreMatcher = (0, picomatch_1.default)(ignorePatterns, { dot: true });
91
+ const chunks = (0, diff_1.splitDiffIntoFileChunks)(diff);
92
+ const excludedFiles = chunks
93
+ .filter((chunk) => chunk.file !== "" && ignoreMatcher(chunk.file))
94
+ .map((chunk) => chunk.file);
95
+ const filteredDiff = chunks
96
+ .filter((chunk) => chunk.file === "" || !ignoreMatcher(chunk.file))
97
+ .map((chunk) => chunk.chunk)
98
+ .join("\n");
99
+ return {
100
+ filteredDiff,
101
+ excludedFiles: Array.from(new Set(excludedFiles)).sort((a, b) => a.localeCompare(b))
102
+ };
103
+ }
104
+ function scanDiffForSecrets(diff, allowPatterns, allowFiles) {
105
+ const findings = [];
106
+ const allowMatchers = parseAllowMatchers(allowPatterns);
107
+ const allowFileMatcher = allowFiles.length > 0 ? (0, picomatch_1.default)(allowFiles, { dot: true }) : undefined;
108
+ const lines = diff.split("\n");
109
+ let currentFile = "(unknown)";
110
+ let newLine = 1;
111
+ for (const line of lines) {
112
+ if (line.startsWith("diff --git ")) {
113
+ const parsed = (0, diff_1.parseDiffGitHeader)(line);
114
+ if (parsed) {
115
+ currentFile = parsed.bPath;
116
+ }
117
+ continue;
118
+ }
119
+ const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
120
+ if (hunk) {
121
+ newLine = Number(hunk[1]);
122
+ continue;
123
+ }
124
+ if (line.startsWith("+") && !line.startsWith("+++")) {
125
+ const fileAllowed = allowFileMatcher?.(currentFile) ?? false;
126
+ if (fileAllowed) {
127
+ newLine += 1;
128
+ continue;
129
+ }
130
+ const added = line.slice(1);
131
+ const allowed = allowMatchers.some((matcher) => matcher.test(added));
132
+ if (!allowed) {
133
+ const lowered = added.toLowerCase();
134
+ const matchedKeyword = SECRET_KEYWORDS.find((keyword) => lowered.includes(keyword));
135
+ if (matchedKeyword) {
136
+ findings.push({
137
+ file: currentFile,
138
+ line: newLine,
139
+ kind: `keyword:${matchedKeyword}`,
140
+ redactedSample: redactSample(added)
141
+ });
142
+ }
143
+ }
144
+ newLine += 1;
145
+ continue;
146
+ }
147
+ if (line.startsWith(" ")) {
148
+ newLine += 1;
149
+ continue;
150
+ }
151
+ }
152
+ return findings;
153
+ }
@@ -0,0 +1,83 @@
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
+ });
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.7",
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",