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.
- package/.semlint/rules/SEMLINT_COUPLING_004.json +8 -0
- package/.semlint/rules/SEMLINT_NAMING_001.json +8 -0
- package/.semlint/rules/SEMLINT_PATTERN_002.json +8 -0
- package/.semlint/rules/SEMLINT_SWE_003.json +8 -0
- package/README.md +58 -3
- package/dist/cli.js +27 -6
- package/dist/config.js +77 -2
- package/dist/diff.js +54 -0
- package/dist/dispatch.js +2 -2
- package/dist/filter.js +18 -59
- package/dist/filter.test.js +42 -0
- package/dist/init.js +29 -10
- package/dist/main.js +89 -3
- package/dist/secrets.js +160 -0
- package/dist/secrets.test.js +157 -0
- package/dist/security.js +26 -0
- package/dist/test2.js +5 -0
- package/package.json +4 -2
|
@@ -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": [
|
|
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
|
|
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
|
|
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
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
69
|
-
const
|
|
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
|
|
92
|
-
const
|
|
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
|
|
111
|
-
if (!node_fs_1.default.existsSync(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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`);
|
package/dist/secrets.js
ADDED
|
@@ -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
|
+
});
|
package/dist/security.js
ADDED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "semlint-cli",
|
|
3
|
-
"version": "0.1.
|
|
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"
|