semlint-cli 0.1.9 → 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/README.md CHANGED
@@ -57,7 +57,6 @@ pnpm check
57
57
 
58
58
  Default diff behavior (without `--base`/`--head`) uses your local branch state:
59
59
 
60
- - tracked changes across commits since merge-base,
61
60
  - staged changes,
62
61
  - unstaged changes,
63
62
  - untracked files.
@@ -67,6 +66,8 @@ If you pass `--base` or `--head`, Semlint uses explicit `git diff <base> <head>`
67
66
  Before backend execution, Semlint shows the included and excluded diff files and asks for confirmation by default.
68
67
  Use `--yes` / `-y` to skip this prompt.
69
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
+
70
71
  ## Exit codes
71
72
 
72
73
  - `0`: no blocking diagnostics
@@ -96,6 +97,11 @@ Unknown fields are ignored.
96
97
  "execution": {
97
98
  "batch": false
98
99
  },
100
+ "diff": {
101
+ "file_kinds": ["staged", "unstaged", "untracked"],
102
+ "include_globs": [],
103
+ "exclude_globs": []
104
+ },
99
105
  "security": {
100
106
  "secret_guard": true,
101
107
  "allow_patterns": [],
@@ -118,6 +124,26 @@ Unknown fields are ignored.
118
124
  }
119
125
  ```
120
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
+
121
147
  ## Config scaffolding and auto-detection
122
148
 
123
149
  Run:
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,6 +19,11 @@ 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: {},
23
29
  rulesIncludeGlobs: [],
@@ -146,6 +152,18 @@ function sanitizeGlobList(value) {
146
152
  return trimmed === "" ? [] : [trimmed];
147
153
  });
148
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
+ }
149
167
  function ensureSelectedBackendIsConfigured(backend, backendConfigs) {
150
168
  if (!(backend in backendConfigs)) {
151
169
  throw new Error(`Backend "${backend}" is not configured. Add it under backends.${backend} with executable and args (including "{prompt}") in semlint.json.`);
@@ -174,6 +192,11 @@ function loadEffectiveConfig(options) {
174
192
  (typeof fileConfig.execution?.batch === "boolean"
175
193
  ? fileConfig.execution.batch
176
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
+ },
177
200
  rulesDisable: Array.isArray(fileConfig.rules?.disable)
178
201
  ? fileConfig.rules?.disable.filter((item) => typeof item === "string")
179
202
  : DEFAULTS.rulesDisable,
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 {
@@ -40,3 +40,45 @@ function makeRule(overrides = {}) {
40
40
  const candidates = (0, filter_1.getRuleCandidateFiles)(rule, changedFiles, ["src/**/*.ts"], []);
41
41
  strict_1.default.deepEqual(candidates, ["docs/readme.md"]);
42
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 stagedResult = await runGitCommand(["diff", "--cached"]);
74
- const unstagedResult = await runGitCommand(["diff"]);
75
- const untrackedFiles = await getUntrackedFiles();
76
- const untrackedDiffChunks = [];
77
- for (const filePath of untrackedFiles) {
78
- const fileDiff = await getNoIndexDiffForFile(filePath);
79
- if (fileDiff.trim() !== "") {
80
- untrackedDiffChunks.push(fileDiff);
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 [stagedResult.stdout, unstagedResult.stdout, ...untrackedDiffChunks]
84
- .filter((chunk) => chunk !== "")
85
- .join("\n");
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
  }
@@ -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: [],
package/dist/main.js CHANGED
@@ -103,8 +103,10 @@ async function runSemlint(options) {
103
103
  const repoRoot = await timedAsync(config.debug, "Resolved git repo root", () => (0, git_1.getRepoRoot)());
104
104
  const scanRoot = repoRoot ?? process.cwd();
105
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));
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));
108
110
  if (excludedFiles.length > 0) {
109
111
  (0, utils_1.debugLog)(config.debug, `Excluded ${excludedFiles.length} file(s) by ignore/security rules: ${excludedFiles.join(", ")}`);
110
112
  }
package/dist/rules.js CHANGED
@@ -13,7 +13,7 @@ function assertNonEmptyString(value, fieldName, filePath) {
13
13
  }
14
14
  return value;
15
15
  }
16
- function assertStringArray(value, fieldName, filePath) {
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: assertStringArray(obj.include_globs, "include_globs", filePath),
40
- exclude_globs: assertStringArray(obj.exclude_globs, "exclude_globs", filePath),
41
- diff_regex: assertStringArray(obj.diff_regex, "diff_regex", filePath)
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
@@ -93,7 +93,8 @@ function parseAllowMatchers(patterns) {
93
93
  });
94
94
  }
95
95
  function filterDiffByIgnoreRules(diff, cwd, ignoreFiles) {
96
- const ignorePatterns = [...readIgnorePatterns(cwd, ignoreFiles), ...BUILTIN_SENSITIVE_GLOBS];
96
+ // Keep built-ins first so repo-specific negation patterns can carve out safe exceptions.
97
+ const ignorePatterns = [...BUILTIN_SENSITIVE_GLOBS, ...readIgnorePatterns(cwd, ignoreFiles)];
97
98
  const ignoreMatcher = createIgnoreMatcher(ignorePatterns);
98
99
  const chunks = (0, diff_1.splitDiffIntoFileChunks)(diff);
99
100
  const excludedFiles = chunks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "semlint-cli",
3
- "version": "0.1.9",
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",