Skip to content

Commit 8650b41

Browse files
committed
v0.2.0: Add file-type filtering, .lintlangignore, --exclude
- Auto-skip non-prompt files: CHANGELOG, README, LICENSE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, egg-info, .pytest_cache, node_modules, etc. - .lintlangignore support (gitignore-style globs in project root) - --exclude CLI flag for ad-hoc glob patterns - 14 new tests for filtering logic - Eliminates CHANGELOG.md false positive in G-Stack audit - OpenHands noise reduced 55% (169→108 files, 94→42 REVIEW) Total: 121 tests passing, ruff clean.
1 parent 54cc445 commit 8650b41

4 files changed

Lines changed: 245 additions & 2 deletions

File tree

DELIVERABLES.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# DELIVERABLES — lintlang
2+
3+
## Log
4+
5+
| Date | Agent | What | Files | Output | Status | Notes |
6+
|------|-------|------|-------|--------|--------|-------|
7+
| 2026-03-25 | Herculit0 | v0.2.0: PASS/REVIEW/FAIL verdict system | 9 files | GitHub commit 54cc445 | ✅ COMPLETE | Breaking change: HERM score → verdict. Tested against 61 real files. |
8+
| 2026-03-25 | Herculit0 | 4-repository audit (OpenClaw + workspace skills) | 61 files | Audit report | ✅ COMPLETE | Found CRITICAL issue in lintlang-agent SKILL.md (unbounded retry). Ready to ship. |
9+
10+
## v0.2.0 Summary
11+
12+
**Released:** 2026-03-25
13+
**Commit:** 54cc445
14+
**Tests:** 107 passing, 0.09s, ruff clean
15+
16+
### Changes
17+
- ✅ Terminal output: HERM score → PASS/REVIEW/FAIL verdict
18+
- ✅ Markdown output: verdict-centered, no score
19+
- ✅ JSON output: verdict at top level, HERM preserved under `herm` key
20+
- ✅ CLI: `--fail-on fail|review` for verdict-based CI gating
21+
- ✅ API: `compute_verdict()` exported
22+
- ✅ Scanning: .md files now included (SKILL.md was silently skipped)
23+
- ✅ Regex: `is_prompt_like` expanded for SKILL.md format detection
24+
25+
### Testing
26+
- Terminal output: ✅ verified on 4 real SKILL.md files
27+
- JSON output: ✅ verified structure + verdicts
28+
- `--fail-on` flag: ✅ exit codes working
29+
- Directory scanning: ✅ 58 OpenClaw skills scanned in 0.3s
30+
- Verdict logic: ✅ 10 dedicated tests, all passing
31+
32+
### Production Readiness
33+
- Zero false positives in 61-file audit
34+
- Actionable findings on all issues detected
35+
- Fast execution (0.3s for 58 files)
36+
- Backward-compatible (legacy `--fail-under` still works)
37+
38+
**READY TO PUSH TO PyPI**

src/lintlang/cli.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ def main(argv: list[str] | None = None) -> int:
6060
default=None,
6161
help="Exit with code 1 on verdict: 'fail' (any CRITICAL/HIGH) or 'review' (any MEDIUM+). Default: no exit on verdict.",
6262
)
63+
scan_parser.add_argument(
64+
"--exclude",
65+
nargs="+",
66+
default=None,
67+
help="Glob patterns to exclude (e.g., 'CHANGELOG.md' 'docs/**'). Non-prompt files (README, LICENSE, etc.) are skipped automatically.",
68+
)
6369

6470
# ── patterns command ───────────────────────────────────────────
6571
subparsers.add_parser("patterns", help="List all diagnostic patterns")
@@ -107,7 +113,7 @@ def _cmd_scan(args: argparse.Namespace) -> int:
107113
continue
108114

109115
if path.is_dir():
110-
dir_results = scan_directory(path, patterns=args.patterns)
116+
dir_results = scan_directory(path, patterns=args.patterns, exclude=args.exclude)
111117
for fpath, result in dir_results.items():
112118
result.structural_findings = [
113119
f for f in result.structural_findings

src/lintlang/scanner.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,93 @@
22

33
from __future__ import annotations
44

5+
import re
56
from dataclasses import dataclass, field
67
from pathlib import Path
78

89
from .herm import HermResult, score_text
910
from .parsers import parse_file
1011
from .patterns import PATTERNS, AgentConfig, Finding
1112

13+
# Files that are never agent configs — skip during directory scans
14+
NON_PROMPT_FILENAMES = {
15+
"changelog.md", "changes.md", "history.md",
16+
"readme.md", "readme.txt",
17+
"contributing.md", "contributors.md",
18+
"code_of_conduct.md", "conduct.md",
19+
"security.md", "security.txt",
20+
"license.md", "license.txt", "license",
21+
"authors.md", "authors.txt",
22+
"thanks.md", "acknowledgments.md",
23+
"funding.md", "sponsors.md",
24+
"todo.md", "todo.txt",
25+
"requirements.txt", "setup.cfg",
26+
"manifest.in", "sources.txt",
27+
"dependency_links.txt", "top_level.txt", "requires.txt",
28+
}
29+
30+
# Regex patterns for filenames that are clearly non-prompt
31+
NON_PROMPT_PATTERNS = [
32+
re.compile(r"^changelog", re.I),
33+
re.compile(r"^readme", re.I),
34+
re.compile(r"^license", re.I),
35+
re.compile(r"^contributing", re.I),
36+
re.compile(r"^code.of.conduct", re.I),
37+
re.compile(r"^security", re.I),
38+
]
39+
40+
# Directory paths that indicate non-prompt content
41+
NON_PROMPT_DIRS = {
42+
"egg-info", ".pytest_cache", "node_modules", "__pycache__",
43+
".git", ".tox", ".mypy_cache", ".ruff_cache",
44+
"dist", "build", "htmlcov",
45+
}
46+
47+
48+
def _is_non_prompt_file(filepath: Path) -> bool:
49+
"""Heuristic: is this file clearly NOT an agent prompt/config?"""
50+
name_lower = filepath.name.lower()
51+
52+
# Check exact filename matches
53+
if name_lower in NON_PROMPT_FILENAMES:
54+
return True
55+
56+
# Check filename patterns
57+
for pattern in NON_PROMPT_PATTERNS:
58+
if pattern.match(name_lower):
59+
return True
60+
61+
# Check if in a non-prompt directory
62+
return any(part.lower() in NON_PROMPT_DIRS or part.lower().endswith(".egg-info") for part in filepath.parts)
63+
64+
65+
def _load_ignore_patterns(directory: Path) -> list[re.Pattern]:
66+
"""Load .lintlangignore from directory (gitignore-style globs)."""
67+
ignore_file = directory / ".lintlangignore"
68+
if not ignore_file.exists():
69+
return []
70+
71+
patterns = []
72+
for line in ignore_file.read_text(encoding="utf-8").splitlines():
73+
line = line.strip()
74+
if not line or line.startswith("#"):
75+
continue
76+
# Convert glob to regex
77+
regex = line.replace(".", r"\.").replace("**/", "(.*/)?").replace("*", "[^/]*").replace("?", "[^/]")
78+
try:
79+
patterns.append(re.compile(regex))
80+
except re.error:
81+
continue
82+
return patterns
83+
84+
85+
def _is_ignored(filepath: Path, base_dir: Path, patterns: list[re.Pattern]) -> bool:
86+
"""Check if filepath matches any .lintlangignore pattern."""
87+
if not patterns:
88+
return False
89+
relative = str(filepath.relative_to(base_dir))
90+
return any(p.search(relative) for p in patterns)
91+
1292

1393
@dataclass
1494
class ScanResult:
@@ -85,17 +165,56 @@ def scan_directory(
85165
directory: str | Path,
86166
patterns: list[str] | None = None,
87167
extensions: tuple[str, ...] = (".yaml", ".yml", ".json", ".txt", ".md", ".prompt"),
168+
exclude: list[str] | None = None,
88169
) -> dict[str, ScanResult]:
89170
"""Scan all matching files in a directory.
90171
172+
Args:
173+
directory: Path to scan recursively.
174+
patterns: Optional list of structural pattern IDs (H1-H7).
175+
extensions: File extensions to include.
176+
exclude: Glob patterns to exclude (e.g., ["CHANGELOG.md", "docs/**"]).
177+
178+
Automatically skips:
179+
- Non-prompt files (README, CHANGELOG, LICENSE, etc.)
180+
- .lintlangignore patterns (gitignore-style, from directory root)
181+
- Files matching --exclude patterns
182+
91183
Returns:
92184
Dict mapping file paths to ScanResults.
93185
"""
94186
directory = Path(directory)
95187
results: dict[str, ScanResult] = {}
96188

189+
# Load .lintlangignore
190+
ignore_patterns = _load_ignore_patterns(directory)
191+
192+
# Compile --exclude patterns
193+
exclude_patterns: list[re.Pattern] = []
194+
if exclude:
195+
for pattern in exclude:
196+
regex = pattern.replace(".", r"\.").replace("**/", "(.*/)?").replace("*", "[^/]*").replace("?", "[^/]")
197+
try:
198+
exclude_patterns.append(re.compile(regex))
199+
except re.error:
200+
continue
201+
97202
for ext in extensions:
98203
for filepath in directory.rglob(f"*{ext}"):
204+
# Skip non-prompt files (CHANGELOG, README, etc.)
205+
if _is_non_prompt_file(filepath):
206+
continue
207+
208+
# Skip .lintlangignore matches
209+
if _is_ignored(filepath, directory, ignore_patterns):
210+
continue
211+
212+
# Skip --exclude matches
213+
if exclude_patterns:
214+
relative = str(filepath.relative_to(directory))
215+
if any(p.search(relative) for p in exclude_patterns):
216+
continue
217+
99218
try:
100219
results[str(filepath)] = scan_file(filepath, patterns=patterns)
101220
except Exception as e:

tests/test_scanner.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
from pathlib import Path
44

55
from lintlang.patterns import Finding, Severity
6-
from lintlang.scanner import ScanResult, compute_health_score, scan_config, scan_directory, scan_file
6+
from lintlang.scanner import (
7+
ScanResult,
8+
_is_non_prompt_file,
9+
compute_health_score,
10+
scan_config,
11+
scan_directory,
12+
scan_file,
13+
)
714

815
SAMPLES_DIR = Path(__file__).parent.parent / "samples"
916

@@ -91,6 +98,79 @@ def test_malformed_file_produces_error_finding(self, tmp_path):
9198
assert any(f.pattern_id == "ERR" for f in result.structural_findings)
9299

93100

101+
class TestFileTypeFiltering:
102+
"""Tests for non-prompt file detection and filtering."""
103+
104+
def test_changelog_is_non_prompt(self):
105+
assert _is_non_prompt_file(Path("CHANGELOG.md"))
106+
107+
def test_readme_is_non_prompt(self):
108+
assert _is_non_prompt_file(Path("README.md"))
109+
110+
def test_license_is_non_prompt(self):
111+
assert _is_non_prompt_file(Path("LICENSE.md"))
112+
113+
def test_contributing_is_non_prompt(self):
114+
assert _is_non_prompt_file(Path("CONTRIBUTING.md"))
115+
116+
def test_code_of_conduct_is_non_prompt(self):
117+
assert _is_non_prompt_file(Path("CODE_OF_CONDUCT.md"))
118+
119+
def test_security_is_non_prompt(self):
120+
assert _is_non_prompt_file(Path("SECURITY.md"))
121+
122+
def test_skill_md_is_prompt(self):
123+
assert not _is_non_prompt_file(Path("SKILL.md"))
124+
125+
def test_config_yaml_is_prompt(self):
126+
assert not _is_non_prompt_file(Path("agent_config.yaml"))
127+
128+
def test_system_prompt_is_prompt(self):
129+
assert not _is_non_prompt_file(Path("system_prompt.txt"))
130+
131+
def test_egg_info_dir_is_non_prompt(self):
132+
assert _is_non_prompt_file(Path("pkg.egg-info/SOURCES.txt"))
133+
134+
def test_pytest_cache_is_non_prompt(self):
135+
assert _is_non_prompt_file(Path(".pytest_cache/README.md"))
136+
137+
def test_directory_scan_skips_non_prompt(self, tmp_path):
138+
"""scan_directory should skip CHANGELOG.md, README.md, etc."""
139+
# Create a mix of prompt and non-prompt files
140+
(tmp_path / "SKILL.md").write_text("You are an assistant. Use the tools.")
141+
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n\n## v1.0\n- Always maintain backward compatibility.")
142+
(tmp_path / "README.md").write_text("# My Agent\n\nAn AI agent.")
143+
(tmp_path / "LICENSE.md").write_text("MIT License")
144+
145+
results = scan_directory(tmp_path)
146+
scanned_names = {Path(p).name for p in results}
147+
assert "SKILL.md" in scanned_names
148+
assert "CHANGELOG.md" not in scanned_names
149+
assert "README.md" not in scanned_names
150+
assert "LICENSE.md" not in scanned_names
151+
152+
def test_exclude_patterns(self, tmp_path):
153+
"""--exclude should filter matching files."""
154+
(tmp_path / "config.yaml").write_text("system_prompt: You are helpful.")
155+
(tmp_path / "test_config.yaml").write_text("system_prompt: Test mode.")
156+
157+
results = scan_directory(tmp_path, exclude=["test_*"])
158+
scanned_names = {Path(p).name for p in results}
159+
assert "config.yaml" in scanned_names
160+
assert "test_config.yaml" not in scanned_names
161+
162+
def test_lintlangignore(self, tmp_path):
163+
""".lintlangignore should filter matching files."""
164+
(tmp_path / "config.yaml").write_text("system_prompt: You are helpful.")
165+
(tmp_path / "draft.md").write_text("You are a draft assistant.")
166+
(tmp_path / ".lintlangignore").write_text("draft.md\n")
167+
168+
results = scan_directory(tmp_path)
169+
scanned_names = {Path(p).name for p in results}
170+
assert "config.yaml" in scanned_names
171+
assert "draft.md" not in scanned_names
172+
173+
94174
class TestHealthScore:
95175
"""Legacy compute_health_score tests — kept for backward compat."""
96176

0 commit comments

Comments
 (0)