semlint-cli 0.1.7 → 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 +2 -6
- package/.semlint/rules/SEMLINT_PATTERN_002.json +3 -7
- package/.semlint/rules/SEMLINT_SWE_003.json +2 -6
- package/README.md +14 -0
- package/dist/cli.js +27 -6
- package/dist/config.js +16 -0
- package/dist/dispatch.js +2 -2
- package/dist/filter.js +15 -7
- package/dist/filter.test.js +42 -0
- package/dist/init.js +6 -0
- package/dist/main.js +67 -2
- package/dist/secrets.js +11 -4
- 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,6 +51,7 @@ 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
|
|
|
@@ -63,6 +64,9 @@ Default diff behavior (without `--base`/`--head`) uses your local branch state:
|
|
|
63
64
|
|
|
64
65
|
If you pass `--base` or `--head`, Semlint uses explicit `git diff <base> <head>` mode.
|
|
65
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
|
+
|
|
66
70
|
## Exit codes
|
|
67
71
|
|
|
68
72
|
- `0`: no blocking diagnostics
|
|
@@ -228,6 +232,16 @@ Config:
|
|
|
228
232
|
- `allow_files`: file glob allowlist to skip secret scanning for known-safe files (example: `["src/test-fixtures/**"]`)
|
|
229
233
|
- `ignore_files`: ignore files Semlint reads for path-level filtering (default: `.gitignore`, `.cursorignore`, `.semlintignore`, `.cursoringore`)
|
|
230
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
|
+
|
|
231
245
|
## Prompt files
|
|
232
246
|
|
|
233
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,6 +20,8 @@ const DEFAULTS = {
|
|
|
20
20
|
batchMode: false,
|
|
21
21
|
rulesDisable: [],
|
|
22
22
|
severityOverrides: {},
|
|
23
|
+
rulesIncludeGlobs: [],
|
|
24
|
+
rulesExcludeGlobs: [],
|
|
23
25
|
backendConfigs: {},
|
|
24
26
|
security: {
|
|
25
27
|
secretGuard: true,
|
|
@@ -132,6 +134,18 @@ function sanitizeAllowFiles(value) {
|
|
|
132
134
|
return trimmed === "" ? [] : [trimmed];
|
|
133
135
|
});
|
|
134
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
|
+
}
|
|
135
149
|
function ensureSelectedBackendIsConfigured(backend, backendConfigs) {
|
|
136
150
|
if (!(backend in backendConfigs)) {
|
|
137
151
|
throw new Error(`Backend "${backend}" is not configured. Add it under backends.${backend} with executable and args (including "{prompt}") in semlint.json.`);
|
|
@@ -164,6 +178,8 @@ function loadEffectiveConfig(options) {
|
|
|
164
178
|
? fileConfig.rules?.disable.filter((item) => typeof item === "string")
|
|
165
179
|
: DEFAULTS.rulesDisable,
|
|
166
180
|
severityOverrides: sanitizeSeverityOverrides((fileConfig.rules?.severity_overrides ?? undefined)),
|
|
181
|
+
rulesIncludeGlobs: sanitizeGlobList(fileConfig.rules?.include_globs),
|
|
182
|
+
rulesExcludeGlobs: sanitizeGlobList(fileConfig.rules?.exclude_globs),
|
|
167
183
|
backendConfigs,
|
|
168
184
|
security: {
|
|
169
185
|
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
|
@@ -43,10 +43,18 @@ function matchesAnyRegex(diff, regexes) {
|
|
|
43
43
|
}
|
|
44
44
|
return false;
|
|
45
45
|
}
|
|
46
|
-
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 = []) {
|
|
47
53
|
let fileCandidates = changedFiles;
|
|
48
|
-
const
|
|
49
|
-
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;
|
|
50
58
|
if (includeMatcher) {
|
|
51
59
|
fileCandidates = changedFiles.filter((filePath) => includeMatcher(filePath));
|
|
52
60
|
if (fileCandidates.length === 0) {
|
|
@@ -58,8 +66,8 @@ function getRuleCandidateFiles(rule, changedFiles) {
|
|
|
58
66
|
}
|
|
59
67
|
return fileCandidates;
|
|
60
68
|
}
|
|
61
|
-
function shouldRunRule(rule, changedFiles, diff) {
|
|
62
|
-
const fileCandidates = getRuleCandidateFiles(rule, changedFiles);
|
|
69
|
+
function shouldRunRule(rule, changedFiles, diff, globalIncludeGlobs = [], globalExcludeGlobs = []) {
|
|
70
|
+
const fileCandidates = getRuleCandidateFiles(rule, changedFiles, globalIncludeGlobs, globalExcludeGlobs);
|
|
63
71
|
if (fileCandidates.length === 0) {
|
|
64
72
|
return false;
|
|
65
73
|
}
|
|
@@ -68,8 +76,8 @@ function shouldRunRule(rule, changedFiles, diff) {
|
|
|
68
76
|
}
|
|
69
77
|
return true;
|
|
70
78
|
}
|
|
71
|
-
function buildScopedDiff(rule, fullDiff, changedFiles) {
|
|
72
|
-
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));
|
|
73
81
|
if (candidateFiles.size === 0) {
|
|
74
82
|
return fullDiff;
|
|
75
83
|
}
|
|
@@ -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
|
@@ -95,6 +95,8 @@ function scaffoldConfig(force = false) {
|
|
|
95
95
|
},
|
|
96
96
|
rules: {
|
|
97
97
|
disable: [],
|
|
98
|
+
include_globs: ["src/**/*.ts"],
|
|
99
|
+
exclude_globs: [],
|
|
98
100
|
severity_overrides: {}
|
|
99
101
|
},
|
|
100
102
|
backends: {
|
|
@@ -108,6 +110,10 @@ function scaffoldConfig(force = false) {
|
|
|
108
110
|
node_fs_1.default.writeFileSync(targetPath, `${JSON.stringify(scaffold, null, 2)}\n`, "utf8");
|
|
109
111
|
process.stdout.write(picocolors_1.default.green(`Created ${targetPath}\n`));
|
|
110
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")}`);
|
|
111
117
|
const rulesDir = node_path_1.default.join(process.cwd(), ".semlint", "rules");
|
|
112
118
|
if (!node_fs_1.default.existsSync(rulesDir)) {
|
|
113
119
|
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;
|
|
@@ -52,7 +112,7 @@ async function runSemlint(options) {
|
|
|
52
112
|
const findings = timed(config.debug, "Scanned diff for secrets", () => (0, secrets_1.scanDiffForSecrets)(diff, config.security.allowPatterns, config.security.allowFiles));
|
|
53
113
|
if (findings.length > 0) {
|
|
54
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"));
|
|
55
|
-
process.stderr.write("Allow a known-safe file by adding a glob to security.allow_files in semlint.json (example: \"allow_files\": [\"src/
|
|
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");
|
|
56
116
|
findings.slice(0, 20).forEach((finding) => {
|
|
57
117
|
process.stderr.write(` ${finding.file}:${finding.line} ${finding.kind} sample=${finding.redactedSample}\n`);
|
|
58
118
|
});
|
|
@@ -63,6 +123,11 @@ async function runSemlint(options) {
|
|
|
63
123
|
}
|
|
64
124
|
}
|
|
65
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
|
+
}
|
|
66
131
|
(0, utils_1.debugLog)(config.debug, useLocalBranchDiff
|
|
67
132
|
? "Using local branch diff (staged + unstaged + untracked only)"
|
|
68
133
|
: `Using explicit ref diff (${config.base}..${config.head})`);
|
|
@@ -70,7 +135,7 @@ async function runSemlint(options) {
|
|
|
70
135
|
const backend = timed(config.debug, "Initialized backend runner", () => (0, backend_1.createBackendRunner)(config));
|
|
71
136
|
const runnableRules = rules.filter((rule) => {
|
|
72
137
|
const filterStartedAt = Date.now();
|
|
73
|
-
const shouldRun = (0, filter_1.shouldRunRule)(rule, changedFiles, diff);
|
|
138
|
+
const shouldRun = (0, filter_1.shouldRunRule)(rule, changedFiles, diff, config.rulesIncludeGlobs, config.rulesExcludeGlobs);
|
|
74
139
|
(0, utils_1.debugLog)(config.debug, `Rule ${rule.id}: filter check in ${Date.now() - filterStartedAt}ms`);
|
|
75
140
|
if (!shouldRun) {
|
|
76
141
|
(0, utils_1.debugLog)(config.debug, `Skipping rule ${rule.id}: filters did not match`);
|
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) {
|
|
@@ -87,7 +94,7 @@ function parseAllowMatchers(patterns) {
|
|
|
87
94
|
}
|
|
88
95
|
function filterDiffByIgnoreRules(diff, cwd, ignoreFiles) {
|
|
89
96
|
const ignorePatterns = [...readIgnorePatterns(cwd, ignoreFiles), ...BUILTIN_SENSITIVE_GLOBS];
|
|
90
|
-
const ignoreMatcher = (
|
|
97
|
+
const ignoreMatcher = createIgnoreMatcher(ignorePatterns);
|
|
91
98
|
const chunks = (0, diff_1.splitDiffIntoFileChunks)(diff);
|
|
92
99
|
const excludedFiles = chunks
|
|
93
100
|
.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.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",
|
|
@@ -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"
|