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.
- package/.semlint/rules/SEMLINT_NAMING_001.json +12 -0
- package/.semlint/rules/SEMLINT_PATTERN_002.json +12 -0
- package/.semlint/rules/SEMLINT_SWE_003.json +12 -0
- package/README.md +44 -3
- package/dist/config.js +61 -2
- package/dist/diff.js +54 -0
- package/dist/filter.js +3 -52
- package/dist/init.js +23 -10
- package/dist/main.js +23 -2
- package/dist/secrets.js +153 -0
- package/dist/secrets.test.js +83 -0
- package/dist/test2.js +5 -0
- package/package.json +3 -2
|
@@ -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": [
|
|
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
|
|
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
|
|
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
|
|
111
|
-
if (!node_fs_1.default.existsSync(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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();
|
package/dist/secrets.js
ADDED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "semlint-cli",
|
|
3
|
-
"version": "0.1.
|
|
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",
|