semlint-cli 0.1.7 → 0.1.10
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 +2 -6
- package/.semlint/rules/SEMLINT_PATTERN_002.json +3 -7
- package/.semlint/rules/SEMLINT_SWE_003.json +2 -6
- package/README.md +41 -1
- package/dist/cli.js +27 -6
- package/dist/config.js +39 -0
- package/dist/dispatch.js +2 -2
- package/dist/filter.js +41 -7
- package/dist/filter.test.js +84 -0
- package/dist/git.js +37 -12
- package/dist/git.test.js +24 -0
- package/dist/init.js +11 -0
- package/dist/main.js +71 -4
- package/dist/rules.js +4 -4
- package/dist/secrets.js +13 -5
- package/dist/secrets.test.js +74 -0
- package/dist/security.js +26 -0
- package/package.json +2 -1
|
@@ -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
|
+
}
|
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
"id": "SEMLINT_NAMING_001",
|
|
3
3
|
"title": "Ambient naming convention consistency",
|
|
4
4
|
"severity_default": "warn",
|
|
5
|
-
"include_globs": [
|
|
6
|
-
"exclude_globs": [
|
|
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
|
-
],
|
|
5
|
+
"include_globs": [],
|
|
6
|
+
"exclude_globs": [],
|
|
11
7
|
"prompt": "Verify naming is consistent with the ambient naming conventions already used in surrounding code. Both in term of semantic and casing."
|
|
12
8
|
}
|
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
"id": "SEMLINT_PATTERN_002",
|
|
3
3
|
"title": "Ambient pattern is respected",
|
|
4
4
|
"severity_default": "warn",
|
|
5
|
-
"include_globs": [
|
|
6
|
-
"exclude_globs": [
|
|
7
|
-
"
|
|
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."
|
|
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."
|
|
12
8
|
}
|
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
"id": "SEMLINT_SWE_003",
|
|
3
3
|
"title": "Obvious SWE mistakes",
|
|
4
4
|
"severity_default": "warn",
|
|
5
|
-
"include_globs": [
|
|
6
|
-
"exclude_globs": [
|
|
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
|
-
],
|
|
5
|
+
"include_globs": [],
|
|
6
|
+
"exclude_globs": [],
|
|
11
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."
|
|
12
8
|
}
|
package/README.md
CHANGED
|
@@ -51,18 +51,23 @@ pnpm check
|
|
|
51
51
|
- `--head <ref>`: head git ref for explicit ref-to-ref diff
|
|
52
52
|
- `--fail-on <error|warn|never>`: failure threshold (default `error`)
|
|
53
53
|
- `--batch`: run all selected rules in one backend call
|
|
54
|
+
- `--yes` / `-y`: auto-accept diff file confirmation (useful for CI)
|
|
54
55
|
- `--debug`: enable debug logs to stderr
|
|
55
56
|
- `init --force`: overwrite an existing `semlint.json`
|
|
56
57
|
|
|
57
58
|
Default diff behavior (without `--base`/`--head`) uses your local branch state:
|
|
58
59
|
|
|
59
|
-
- tracked changes across commits since merge-base,
|
|
60
60
|
- staged changes,
|
|
61
61
|
- unstaged changes,
|
|
62
62
|
- untracked files.
|
|
63
63
|
|
|
64
64
|
If you pass `--base` or `--head`, Semlint uses explicit `git diff <base> <head>` mode.
|
|
65
65
|
|
|
66
|
+
Before backend execution, Semlint shows the included and excluded diff files and asks for confirmation by default.
|
|
67
|
+
Use `--yes` / `-y` to skip this prompt.
|
|
68
|
+
|
|
69
|
+
You can configure local diff selection and path filtering via `diff.file_kinds`, `diff.include_globs`, and `diff.exclude_globs` in `semlint.json`.
|
|
70
|
+
|
|
66
71
|
## Exit codes
|
|
67
72
|
|
|
68
73
|
- `0`: no blocking diagnostics
|
|
@@ -92,6 +97,11 @@ Unknown fields are ignored.
|
|
|
92
97
|
"execution": {
|
|
93
98
|
"batch": false
|
|
94
99
|
},
|
|
100
|
+
"diff": {
|
|
101
|
+
"file_kinds": ["staged", "unstaged", "untracked"],
|
|
102
|
+
"include_globs": [],
|
|
103
|
+
"exclude_globs": []
|
|
104
|
+
},
|
|
95
105
|
"security": {
|
|
96
106
|
"secret_guard": true,
|
|
97
107
|
"allow_patterns": [],
|
|
@@ -114,6 +124,26 @@ Unknown fields are ignored.
|
|
|
114
124
|
}
|
|
115
125
|
```
|
|
116
126
|
|
|
127
|
+
## Diff management
|
|
128
|
+
|
|
129
|
+
Use the `diff` section to control which local change kinds and file paths are included:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"diff": {
|
|
134
|
+
"file_kinds": ["staged", "unstaged", "untracked"],
|
|
135
|
+
"include_globs": ["src/**/*.ts"],
|
|
136
|
+
"exclude_globs": ["**/*.test.ts"]
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- `file_kinds`: local diff sources included by default (`staged`, `unstaged`, `untracked`)
|
|
142
|
+
- `include_globs`: optional allowlist of changed files to keep
|
|
143
|
+
- `exclude_globs`: optional denylist applied after include globs (exclude wins)
|
|
144
|
+
|
|
145
|
+
`diff.*` settings are applied before security ignore filtering and secret scanning.
|
|
146
|
+
|
|
117
147
|
## Config scaffolding and auto-detection
|
|
118
148
|
|
|
119
149
|
Run:
|
|
@@ -228,6 +258,16 @@ Config:
|
|
|
228
258
|
- `allow_files`: file glob allowlist to skip secret scanning for known-safe files (example: `["src/test-fixtures/**"]`)
|
|
229
259
|
- `ignore_files`: ignore files Semlint reads for path-level filtering (default: `.gitignore`, `.cursorignore`, `.semlintignore`, `.cursoringore`)
|
|
230
260
|
|
|
261
|
+
Ignore file patterns are evaluated with standard gitignore semantics (including basename matching and `!` negation).
|
|
262
|
+
|
|
263
|
+
## Security responsibility model
|
|
264
|
+
|
|
265
|
+
Security in Semlint is shared, and the repository owner remains responsible for secure setup:
|
|
266
|
+
|
|
267
|
+
- **Diff-level (your responsibility):** carefully configure ignore files and Semlint security settings so sensitive files/content never enter the analyzed diff.
|
|
268
|
+
- **Semlint guard (best-effort control):** Semlint filters diff paths and scans added lines, but this is not a complete prevention system.
|
|
269
|
+
- **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.
|
|
270
|
+
|
|
231
271
|
## Prompt files
|
|
232
272
|
|
|
233
273
|
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
|
@@ -8,6 +8,7 @@ exports.loadEffectiveConfig = loadEffectiveConfig;
|
|
|
8
8
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
9
|
const node_path_1 = __importDefault(require("node:path"));
|
|
10
10
|
const utils_1 = require("./utils");
|
|
11
|
+
const VALID_DIFF_FILE_KINDS = new Set(["staged", "unstaged", "untracked"]);
|
|
11
12
|
const DEFAULTS = {
|
|
12
13
|
backend: "cursor-cli",
|
|
13
14
|
model: "auto",
|
|
@@ -18,8 +19,15 @@ const DEFAULTS = {
|
|
|
18
19
|
head: "HEAD",
|
|
19
20
|
debug: false,
|
|
20
21
|
batchMode: false,
|
|
22
|
+
diff: {
|
|
23
|
+
fileKinds: ["staged", "unstaged", "untracked"],
|
|
24
|
+
includeGlobs: [],
|
|
25
|
+
excludeGlobs: []
|
|
26
|
+
},
|
|
21
27
|
rulesDisable: [],
|
|
22
28
|
severityOverrides: {},
|
|
29
|
+
rulesIncludeGlobs: [],
|
|
30
|
+
rulesExcludeGlobs: [],
|
|
23
31
|
backendConfigs: {},
|
|
24
32
|
security: {
|
|
25
33
|
secretGuard: true,
|
|
@@ -132,6 +140,30 @@ function sanitizeAllowFiles(value) {
|
|
|
132
140
|
return trimmed === "" ? [] : [trimmed];
|
|
133
141
|
});
|
|
134
142
|
}
|
|
143
|
+
function sanitizeGlobList(value) {
|
|
144
|
+
if (!Array.isArray(value)) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
return value.flatMap((candidate) => {
|
|
148
|
+
if (typeof candidate !== "string") {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
const trimmed = candidate.trim();
|
|
152
|
+
return trimmed === "" ? [] : [trimmed];
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function sanitizeDiffFileKinds(value) {
|
|
156
|
+
if (!Array.isArray(value)) {
|
|
157
|
+
return [...DEFAULTS.diff.fileKinds];
|
|
158
|
+
}
|
|
159
|
+
return value.flatMap((candidate) => {
|
|
160
|
+
if (typeof candidate !== "string") {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
const normalized = candidate.trim();
|
|
164
|
+
return VALID_DIFF_FILE_KINDS.has(normalized) ? [normalized] : [];
|
|
165
|
+
});
|
|
166
|
+
}
|
|
135
167
|
function ensureSelectedBackendIsConfigured(backend, backendConfigs) {
|
|
136
168
|
if (!(backend in backendConfigs)) {
|
|
137
169
|
throw new Error(`Backend "${backend}" is not configured. Add it under backends.${backend} with executable and args (including "{prompt}") in semlint.json.`);
|
|
@@ -160,10 +192,17 @@ function loadEffectiveConfig(options) {
|
|
|
160
192
|
(typeof fileConfig.execution?.batch === "boolean"
|
|
161
193
|
? fileConfig.execution.batch
|
|
162
194
|
: DEFAULTS.batchMode),
|
|
195
|
+
diff: {
|
|
196
|
+
fileKinds: sanitizeDiffFileKinds(fileConfig.diff?.file_kinds),
|
|
197
|
+
includeGlobs: sanitizeGlobList(fileConfig.diff?.include_globs),
|
|
198
|
+
excludeGlobs: sanitizeGlobList(fileConfig.diff?.exclude_globs)
|
|
199
|
+
},
|
|
163
200
|
rulesDisable: Array.isArray(fileConfig.rules?.disable)
|
|
164
201
|
? fileConfig.rules?.disable.filter((item) => typeof item === "string")
|
|
165
202
|
: DEFAULTS.rulesDisable,
|
|
166
203
|
severityOverrides: sanitizeSeverityOverrides((fileConfig.rules?.severity_overrides ?? undefined)),
|
|
204
|
+
rulesIncludeGlobs: sanitizeGlobList(fileConfig.rules?.include_globs),
|
|
205
|
+
rulesExcludeGlobs: sanitizeGlobList(fileConfig.rules?.exclude_globs),
|
|
167
206
|
backendConfigs,
|
|
168
207
|
security: {
|
|
169
208
|
secretGuard: typeof fileConfig.security?.secret_guard === "boolean"
|
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
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.extractChangedFilesFromDiff = extractChangedFilesFromDiff;
|
|
7
|
+
exports.filterDiffByPathGlobs = filterDiffByPathGlobs;
|
|
7
8
|
exports.getRuleCandidateFiles = getRuleCandidateFiles;
|
|
8
9
|
exports.shouldRunRule = shouldRunRule;
|
|
9
10
|
exports.buildScopedDiff = buildScopedDiff;
|
|
@@ -29,6 +30,31 @@ function extractChangedFilesFromDiff(diff) {
|
|
|
29
30
|
}
|
|
30
31
|
return Array.from(files);
|
|
31
32
|
}
|
|
33
|
+
function filterDiffByPathGlobs(diff, includeGlobs = [], excludeGlobs = []) {
|
|
34
|
+
const chunks = (0, diff_1.splitDiffIntoFileChunks)(diff);
|
|
35
|
+
const includeMatcher = includeGlobs.length > 0 ? (0, picomatch_1.default)(includeGlobs) : null;
|
|
36
|
+
const excludeMatcher = excludeGlobs.length > 0 ? (0, picomatch_1.default)(excludeGlobs) : null;
|
|
37
|
+
const excludedFiles = [];
|
|
38
|
+
const filteredDiff = chunks
|
|
39
|
+
.filter((chunk) => {
|
|
40
|
+
if (chunk.file === "") {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
const included = includeMatcher ? includeMatcher(chunk.file) : true;
|
|
44
|
+
const excluded = excludeMatcher ? excludeMatcher(chunk.file) : false;
|
|
45
|
+
const keep = included && !excluded;
|
|
46
|
+
if (!keep) {
|
|
47
|
+
excludedFiles.push(chunk.file);
|
|
48
|
+
}
|
|
49
|
+
return keep;
|
|
50
|
+
})
|
|
51
|
+
.map((chunk) => chunk.chunk)
|
|
52
|
+
.join("\n");
|
|
53
|
+
return {
|
|
54
|
+
filteredDiff,
|
|
55
|
+
excludedFiles: Array.from(new Set(excludedFiles)).sort((a, b) => a.localeCompare(b))
|
|
56
|
+
};
|
|
57
|
+
}
|
|
32
58
|
function matchesAnyRegex(diff, regexes) {
|
|
33
59
|
for (const candidate of regexes) {
|
|
34
60
|
try {
|
|
@@ -43,10 +69,18 @@ function matchesAnyRegex(diff, regexes) {
|
|
|
43
69
|
}
|
|
44
70
|
return false;
|
|
45
71
|
}
|
|
46
|
-
function
|
|
72
|
+
function resolveRuleGlobs(ruleGlobs, globalGlobs) {
|
|
73
|
+
if (ruleGlobs === undefined) {
|
|
74
|
+
return globalGlobs;
|
|
75
|
+
}
|
|
76
|
+
return ruleGlobs;
|
|
77
|
+
}
|
|
78
|
+
function getRuleCandidateFiles(rule, changedFiles, globalIncludeGlobs = [], globalExcludeGlobs = []) {
|
|
47
79
|
let fileCandidates = changedFiles;
|
|
48
|
-
const
|
|
49
|
-
const
|
|
80
|
+
const effectiveIncludeGlobs = resolveRuleGlobs(rule.include_globs, globalIncludeGlobs);
|
|
81
|
+
const effectiveExcludeGlobs = resolveRuleGlobs(rule.exclude_globs, globalExcludeGlobs);
|
|
82
|
+
const includeMatcher = effectiveIncludeGlobs.length > 0 ? (0, picomatch_1.default)(effectiveIncludeGlobs) : null;
|
|
83
|
+
const excludeMatcher = effectiveExcludeGlobs.length > 0 ? (0, picomatch_1.default)(effectiveExcludeGlobs) : null;
|
|
50
84
|
if (includeMatcher) {
|
|
51
85
|
fileCandidates = changedFiles.filter((filePath) => includeMatcher(filePath));
|
|
52
86
|
if (fileCandidates.length === 0) {
|
|
@@ -58,8 +92,8 @@ function getRuleCandidateFiles(rule, changedFiles) {
|
|
|
58
92
|
}
|
|
59
93
|
return fileCandidates;
|
|
60
94
|
}
|
|
61
|
-
function shouldRunRule(rule, changedFiles, diff) {
|
|
62
|
-
const fileCandidates = getRuleCandidateFiles(rule, changedFiles);
|
|
95
|
+
function shouldRunRule(rule, changedFiles, diff, globalIncludeGlobs = [], globalExcludeGlobs = []) {
|
|
96
|
+
const fileCandidates = getRuleCandidateFiles(rule, changedFiles, globalIncludeGlobs, globalExcludeGlobs);
|
|
63
97
|
if (fileCandidates.length === 0) {
|
|
64
98
|
return false;
|
|
65
99
|
}
|
|
@@ -68,8 +102,8 @@ function shouldRunRule(rule, changedFiles, diff) {
|
|
|
68
102
|
}
|
|
69
103
|
return true;
|
|
70
104
|
}
|
|
71
|
-
function buildScopedDiff(rule, fullDiff, changedFiles) {
|
|
72
|
-
const candidateFiles = new Set(getRuleCandidateFiles(rule, changedFiles));
|
|
105
|
+
function buildScopedDiff(rule, fullDiff, changedFiles, globalIncludeGlobs = [], globalExcludeGlobs = []) {
|
|
106
|
+
const candidateFiles = new Set(getRuleCandidateFiles(rule, changedFiles, globalIncludeGlobs, globalExcludeGlobs));
|
|
73
107
|
if (candidateFiles.size === 0) {
|
|
74
108
|
return fullDiff;
|
|
75
109
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
});
|
|
43
|
+
(0, node_test_1.default)("filterDiffByPathGlobs applies include and exclude globs", () => {
|
|
44
|
+
const diff = [
|
|
45
|
+
"diff --git a/src/a.ts b/src/a.ts",
|
|
46
|
+
"--- a/src/a.ts",
|
|
47
|
+
"+++ b/src/a.ts",
|
|
48
|
+
"@@ -0,0 +1 @@",
|
|
49
|
+
"+const a = 1;",
|
|
50
|
+
"diff --git a/src/a.test.ts b/src/a.test.ts",
|
|
51
|
+
"--- a/src/a.test.ts",
|
|
52
|
+
"+++ b/src/a.test.ts",
|
|
53
|
+
"@@ -0,0 +1 @@",
|
|
54
|
+
"+const test = 1;",
|
|
55
|
+
"diff --git a/docs/readme.md b/docs/readme.md",
|
|
56
|
+
"--- a/docs/readme.md",
|
|
57
|
+
"+++ b/docs/readme.md",
|
|
58
|
+
"@@ -0,0 +1 @@",
|
|
59
|
+
"+# docs"
|
|
60
|
+
].join("\n");
|
|
61
|
+
const result = (0, filter_1.filterDiffByPathGlobs)(diff, ["src/**/*.ts"], ["**/*.test.ts"]);
|
|
62
|
+
strict_1.default.match(result.filteredDiff, /src\/a\.ts/);
|
|
63
|
+
strict_1.default.doesNotMatch(result.filteredDiff, /src\/a\.test\.ts/);
|
|
64
|
+
strict_1.default.doesNotMatch(result.filteredDiff, /docs\/readme\.md/);
|
|
65
|
+
strict_1.default.deepEqual(result.excludedFiles, ["docs/readme.md", "src/a.test.ts"]);
|
|
66
|
+
});
|
|
67
|
+
(0, node_test_1.default)("filterDiffByPathGlobs keeps all files when include/exclude are empty", () => {
|
|
68
|
+
const diff = [
|
|
69
|
+
"diff --git a/src/a.ts b/src/a.ts",
|
|
70
|
+
"--- a/src/a.ts",
|
|
71
|
+
"+++ b/src/a.ts",
|
|
72
|
+
"@@ -0,0 +1 @@",
|
|
73
|
+
"+const a = 1;",
|
|
74
|
+
"diff --git a/docs/readme.md b/docs/readme.md",
|
|
75
|
+
"--- a/docs/readme.md",
|
|
76
|
+
"+++ b/docs/readme.md",
|
|
77
|
+
"@@ -0,0 +1 @@",
|
|
78
|
+
"+# docs"
|
|
79
|
+
].join("\n");
|
|
80
|
+
const result = (0, filter_1.filterDiffByPathGlobs)(diff, [], []);
|
|
81
|
+
strict_1.default.match(result.filteredDiff, /src\/a\.ts/);
|
|
82
|
+
strict_1.default.match(result.filteredDiff, /docs\/readme\.md/);
|
|
83
|
+
strict_1.default.deepEqual(result.excludedFiles, []);
|
|
84
|
+
});
|
package/dist/git.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.getGitDiff = getGitDiff;
|
|
4
4
|
exports.getRepoRoot = getRepoRoot;
|
|
5
5
|
exports.getLocalBranchDiff = getLocalBranchDiff;
|
|
6
|
+
exports.selectLocalBranchDiffChunks = selectLocalBranchDiffChunks;
|
|
6
7
|
const node_child_process_1 = require("node:child_process");
|
|
7
8
|
const node_os_1 = require("node:os");
|
|
8
9
|
function runGitCommand(args, okExitCodes = [0]) {
|
|
@@ -69,18 +70,42 @@ async function getNoIndexDiffForFile(filePath) {
|
|
|
69
70
|
* - Untracked files (as full-file diffs)
|
|
70
71
|
* Does not include already-committed changes on the branch.
|
|
71
72
|
*/
|
|
72
|
-
async function getLocalBranchDiff() {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
async function getLocalBranchDiff(fileKinds = ["staged", "unstaged", "untracked"]) {
|
|
74
|
+
const parts = {
|
|
75
|
+
stagedDiff: "",
|
|
76
|
+
unstagedDiff: "",
|
|
77
|
+
untrackedDiffs: []
|
|
78
|
+
};
|
|
79
|
+
if (fileKinds.includes("staged")) {
|
|
80
|
+
const stagedResult = await runGitCommand(["diff", "--cached"]);
|
|
81
|
+
parts.stagedDiff = stagedResult.stdout;
|
|
82
|
+
}
|
|
83
|
+
if (fileKinds.includes("unstaged")) {
|
|
84
|
+
const unstagedResult = await runGitCommand(["diff"]);
|
|
85
|
+
parts.unstagedDiff = unstagedResult.stdout;
|
|
86
|
+
}
|
|
87
|
+
if (fileKinds.includes("untracked")) {
|
|
88
|
+
const untrackedFiles = await getUntrackedFiles();
|
|
89
|
+
for (const filePath of untrackedFiles) {
|
|
90
|
+
const fileDiff = await getNoIndexDiffForFile(filePath);
|
|
91
|
+
if (fileDiff.trim() !== "") {
|
|
92
|
+
parts.untrackedDiffs.push(fileDiff);
|
|
93
|
+
}
|
|
81
94
|
}
|
|
82
95
|
}
|
|
83
|
-
return
|
|
84
|
-
|
|
85
|
-
|
|
96
|
+
return selectLocalBranchDiffChunks(parts, fileKinds);
|
|
97
|
+
}
|
|
98
|
+
function selectLocalBranchDiffChunks(parts, fileKinds = ["staged", "unstaged", "untracked"]) {
|
|
99
|
+
const selectedKinds = new Set(fileKinds);
|
|
100
|
+
const chunks = [];
|
|
101
|
+
if (selectedKinds.has("staged") && parts.stagedDiff !== "") {
|
|
102
|
+
chunks.push(parts.stagedDiff);
|
|
103
|
+
}
|
|
104
|
+
if (selectedKinds.has("unstaged") && parts.unstagedDiff !== "") {
|
|
105
|
+
chunks.push(parts.unstagedDiff);
|
|
106
|
+
}
|
|
107
|
+
if (selectedKinds.has("untracked")) {
|
|
108
|
+
chunks.push(...parts.untrackedDiffs.filter((chunk) => chunk !== ""));
|
|
109
|
+
}
|
|
110
|
+
return chunks.join("\n");
|
|
86
111
|
}
|
package/dist/git.test.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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 git_1 = require("./git");
|
|
9
|
+
(0, node_test_1.default)("selectLocalBranchDiffChunks includes only selected change kinds", () => {
|
|
10
|
+
const combined = (0, git_1.selectLocalBranchDiffChunks)({
|
|
11
|
+
stagedDiff: "STAGED_DIFF",
|
|
12
|
+
unstagedDiff: "UNSTAGED_DIFF",
|
|
13
|
+
untrackedDiffs: ["UNTRACKED_ONE", "", "UNTRACKED_TWO"]
|
|
14
|
+
}, ["staged", "untracked"]);
|
|
15
|
+
strict_1.default.equal(combined, ["STAGED_DIFF", "UNTRACKED_ONE", "UNTRACKED_TWO"].join("\n"));
|
|
16
|
+
});
|
|
17
|
+
(0, node_test_1.default)("selectLocalBranchDiffChunks returns empty string for empty selection", () => {
|
|
18
|
+
const combined = (0, git_1.selectLocalBranchDiffChunks)({
|
|
19
|
+
stagedDiff: "STAGED_DIFF",
|
|
20
|
+
unstagedDiff: "UNSTAGED_DIFF",
|
|
21
|
+
untrackedDiffs: ["UNTRACKED_ONE"]
|
|
22
|
+
}, []);
|
|
23
|
+
strict_1.default.equal(combined, "");
|
|
24
|
+
});
|
package/dist/init.js
CHANGED
|
@@ -87,6 +87,11 @@ function scaffoldConfig(force = false) {
|
|
|
87
87
|
execution: {
|
|
88
88
|
batch: false
|
|
89
89
|
},
|
|
90
|
+
diff: {
|
|
91
|
+
file_kinds: ["staged", "unstaged", "untracked"],
|
|
92
|
+
include_globs: ["src/**"],
|
|
93
|
+
exclude_globs: []
|
|
94
|
+
},
|
|
90
95
|
security: {
|
|
91
96
|
secret_guard: true,
|
|
92
97
|
allow_patterns: [],
|
|
@@ -95,6 +100,8 @@ function scaffoldConfig(force = false) {
|
|
|
95
100
|
},
|
|
96
101
|
rules: {
|
|
97
102
|
disable: [],
|
|
103
|
+
include_globs: ["src/**/*.ts"],
|
|
104
|
+
exclude_globs: [],
|
|
98
105
|
severity_overrides: {}
|
|
99
106
|
},
|
|
100
107
|
backends: {
|
|
@@ -108,6 +115,10 @@ function scaffoldConfig(force = false) {
|
|
|
108
115
|
node_fs_1.default.writeFileSync(targetPath, `${JSON.stringify(scaffold, null, 2)}\n`, "utf8");
|
|
109
116
|
process.stdout.write(picocolors_1.default.green(`Created ${targetPath}\n`));
|
|
110
117
|
process.stdout.write(picocolors_1.default.cyan(`Backend setup: ${detected.backend} (${detected.reason})\n`));
|
|
118
|
+
process.stderr.write(`${picocolors_1.default.bgRed(picocolors_1.default.white(picocolors_1.default.bold(" SECURITY WARNING ")))}
|
|
119
|
+
${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.")}
|
|
120
|
+
${picocolors_1.default.red("During backend execution, security is handled by your agent/backend native configuration.")}
|
|
121
|
+
${picocolors_1.default.yellow("Review semlint.json security.*, ignore files, and backend/org security settings before use.\n")}`);
|
|
111
122
|
const rulesDir = node_path_1.default.join(process.cwd(), ".semlint", "rules");
|
|
112
123
|
if (!node_fs_1.default.existsSync(rulesDir)) {
|
|
113
124
|
node_fs_1.default.mkdirSync(rulesDir, { recursive: true });
|
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");
|
|
@@ -30,6 +32,64 @@ async function timedAsync(enabled, label, action) {
|
|
|
30
32
|
(0, utils_1.debugLog)(enabled, `${label} in ${Date.now() - startedAt}ms`);
|
|
31
33
|
return result;
|
|
32
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
|
+
}
|
|
33
93
|
async function runSemlint(options) {
|
|
34
94
|
const startedAt = Date.now();
|
|
35
95
|
let spinner = null;
|
|
@@ -43,8 +103,10 @@ async function runSemlint(options) {
|
|
|
43
103
|
const repoRoot = await timedAsync(config.debug, "Resolved git repo root", () => (0, git_1.getRepoRoot)());
|
|
44
104
|
const scanRoot = repoRoot ?? process.cwd();
|
|
45
105
|
(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:
|
|
106
|
+
const rawDiff = await timedAsync(config.debug, "Computed git diff", () => useLocalBranchDiff ? (0, git_1.getLocalBranchDiff)(config.diff.fileKinds) : (0, git_1.getGitDiff)(config.base, config.head));
|
|
107
|
+
const { filteredDiff: globFilteredDiff, excludedFiles: globExcludedFiles } = timed(config.debug, "Filtered diff by configured include/exclude globs", () => (0, filter_1.filterDiffByPathGlobs)(rawDiff, config.diff.includeGlobs, config.diff.excludeGlobs));
|
|
108
|
+
const { filteredDiff: diff, excludedFiles: ignoreExcludedFiles } = timed(config.debug, "Filtered diff by ignore rules", () => (0, secrets_1.filterDiffByIgnoreRules)(globFilteredDiff, scanRoot, config.security.ignoreFiles));
|
|
109
|
+
const excludedFiles = Array.from(new Set([...globExcludedFiles, ...ignoreExcludedFiles])).sort((a, b) => a.localeCompare(b));
|
|
48
110
|
if (excludedFiles.length > 0) {
|
|
49
111
|
(0, utils_1.debugLog)(config.debug, `Excluded ${excludedFiles.length} file(s) by ignore/security rules: ${excludedFiles.join(", ")}`);
|
|
50
112
|
}
|
|
@@ -52,7 +114,7 @@ async function runSemlint(options) {
|
|
|
52
114
|
const findings = timed(config.debug, "Scanned diff for secrets", () => (0, secrets_1.scanDiffForSecrets)(diff, config.security.allowPatterns, config.security.allowFiles));
|
|
53
115
|
if (findings.length > 0) {
|
|
54
116
|
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/
|
|
117
|
+
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");
|
|
56
118
|
findings.slice(0, 20).forEach((finding) => {
|
|
57
119
|
process.stderr.write(` ${finding.file}:${finding.line} ${finding.kind} sample=${finding.redactedSample}\n`);
|
|
58
120
|
});
|
|
@@ -63,6 +125,11 @@ async function runSemlint(options) {
|
|
|
63
125
|
}
|
|
64
126
|
}
|
|
65
127
|
const changedFiles = timed(config.debug, "Parsed changed files from diff", () => (0, filter_1.extractChangedFilesFromDiff)(diff));
|
|
128
|
+
const confirmed = await timedAsync(config.debug, "User confirmation", () => confirmDiffPreview(changedFiles, excludedFiles, options.autoAccept));
|
|
129
|
+
if (!confirmed) {
|
|
130
|
+
process.stderr.write("Aborted by user.\n");
|
|
131
|
+
return 2;
|
|
132
|
+
}
|
|
66
133
|
(0, utils_1.debugLog)(config.debug, useLocalBranchDiff
|
|
67
134
|
? "Using local branch diff (staged + unstaged + untracked only)"
|
|
68
135
|
: `Using explicit ref diff (${config.base}..${config.head})`);
|
|
@@ -70,7 +137,7 @@ async function runSemlint(options) {
|
|
|
70
137
|
const backend = timed(config.debug, "Initialized backend runner", () => (0, backend_1.createBackendRunner)(config));
|
|
71
138
|
const runnableRules = rules.filter((rule) => {
|
|
72
139
|
const filterStartedAt = Date.now();
|
|
73
|
-
const shouldRun = (0, filter_1.shouldRunRule)(rule, changedFiles, diff);
|
|
140
|
+
const shouldRun = (0, filter_1.shouldRunRule)(rule, changedFiles, diff, config.rulesIncludeGlobs, config.rulesExcludeGlobs);
|
|
74
141
|
(0, utils_1.debugLog)(config.debug, `Rule ${rule.id}: filter check in ${Date.now() - filterStartedAt}ms`);
|
|
75
142
|
if (!shouldRun) {
|
|
76
143
|
(0, utils_1.debugLog)(config.debug, `Skipping rule ${rule.id}: filters did not match`);
|
package/dist/rules.js
CHANGED
|
@@ -13,7 +13,7 @@ function assertNonEmptyString(value, fieldName, filePath) {
|
|
|
13
13
|
}
|
|
14
14
|
return value;
|
|
15
15
|
}
|
|
16
|
-
function
|
|
16
|
+
function assert_string_array(value, fieldName, filePath) {
|
|
17
17
|
if (value === undefined) {
|
|
18
18
|
return undefined;
|
|
19
19
|
}
|
|
@@ -36,9 +36,9 @@ function validateRuleObject(raw, filePath) {
|
|
|
36
36
|
title: assertNonEmptyString(obj.title, "title", filePath),
|
|
37
37
|
severity_default: severity,
|
|
38
38
|
prompt: assertNonEmptyString(obj.prompt, "prompt", filePath),
|
|
39
|
-
include_globs:
|
|
40
|
-
exclude_globs:
|
|
41
|
-
diff_regex:
|
|
39
|
+
include_globs: assert_string_array(obj.include_globs, "include_globs", filePath),
|
|
40
|
+
exclude_globs: assert_string_array(obj.exclude_globs, "exclude_globs", filePath),
|
|
41
|
+
diff_regex: assert_string_array(obj.diff_regex, "diff_regex", filePath)
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
function loadRules(rulesDir, disabledRuleIds, severityOverrides) {
|
package/dist/secrets.js
CHANGED
|
@@ -7,18 +7,21 @@ exports.filterDiffByIgnoreRules = filterDiffByIgnoreRules;
|
|
|
7
7
|
exports.scanDiffForSecrets = scanDiffForSecrets;
|
|
8
8
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
9
|
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const ignore_1 = __importDefault(require("ignore"));
|
|
10
11
|
const picomatch_1 = __importDefault(require("picomatch"));
|
|
11
12
|
const diff_1 = require("./diff");
|
|
12
13
|
const BUILTIN_SENSITIVE_GLOBS = [
|
|
13
14
|
".env",
|
|
15
|
+
"*.env",
|
|
14
16
|
".env.*",
|
|
17
|
+
"*.env.*",
|
|
15
18
|
"*.pem",
|
|
16
19
|
"*.key",
|
|
17
20
|
"id_rsa",
|
|
18
21
|
"id_rsa.*",
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
+
"secrets/",
|
|
23
|
+
"credentials/",
|
|
24
|
+
"*credentials*.json"
|
|
22
25
|
];
|
|
23
26
|
const SECRET_KEYWORDS = [
|
|
24
27
|
"password",
|
|
@@ -68,6 +71,10 @@ function readIgnorePatterns(cwd, ignoreFiles) {
|
|
|
68
71
|
.filter((line) => line !== "" && !line.startsWith("#"));
|
|
69
72
|
});
|
|
70
73
|
}
|
|
74
|
+
function createIgnoreMatcher(patterns) {
|
|
75
|
+
const matcher = (0, ignore_1.default)().add(patterns);
|
|
76
|
+
return (filePath) => matcher.ignores(filePath);
|
|
77
|
+
}
|
|
71
78
|
function redactSample(sample) {
|
|
72
79
|
const compact = sample.trim();
|
|
73
80
|
if (compact.length <= 6) {
|
|
@@ -86,8 +93,9 @@ function parseAllowMatchers(patterns) {
|
|
|
86
93
|
});
|
|
87
94
|
}
|
|
88
95
|
function filterDiffByIgnoreRules(diff, cwd, ignoreFiles) {
|
|
89
|
-
|
|
90
|
-
const
|
|
96
|
+
// Keep built-ins first so repo-specific negation patterns can carve out safe exceptions.
|
|
97
|
+
const ignorePatterns = [...BUILTIN_SENSITIVE_GLOBS, ...readIgnorePatterns(cwd, ignoreFiles)];
|
|
98
|
+
const ignoreMatcher = createIgnoreMatcher(ignorePatterns);
|
|
91
99
|
const chunks = (0, diff_1.splitDiffIntoFileChunks)(diff);
|
|
92
100
|
const excludedFiles = chunks
|
|
93
101
|
.filter((chunk) => chunk.file !== "" && ignoreMatcher(chunk.file))
|
package/dist/secrets.test.js
CHANGED
|
@@ -81,3 +81,77 @@ const secrets_1 = require("./secrets");
|
|
|
81
81
|
const findings = (0, secrets_1.scanDiffForSecrets)(diff, [], ["src/test2.ts"]);
|
|
82
82
|
strict_1.default.equal(findings.length, 0);
|
|
83
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "semlint-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"packageManager": "pnpm@10.29.2",
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"ignore": "^7.0.5",
|
|
45
46
|
"nanospinner": "^1.2.2",
|
|
46
47
|
"picocolors": "^1.1.1",
|
|
47
48
|
"picomatch": "^4.0.3"
|