agentslint 0.1.0__py3-none-any.whl

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.
agentslint/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Static referential-integrity checks for agent instruction files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import version
6
+
7
+ __version__ = version("agentslint")
agentslint/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from agentslint.cli import main
4
+
5
+ raise SystemExit(main())
agentslint/cli.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+
8
+ from agentslint import __version__
9
+ from agentslint.config import ConfigError
10
+ from agentslint.diagnostics import render
11
+ from agentslint.discovery import build_workspace
12
+ from agentslint.engine import check_workspace
13
+ from agentslint.models import Severity
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(prog="agentslint")
18
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
19
+ subparsers = parser.add_subparsers(dest="command", required=True)
20
+ check = subparsers.add_parser("check", help="validate agent instruction references")
21
+ check.add_argument("path", nargs="?", default=".", type=Path)
22
+ check.add_argument("--format", choices=("text", "github", "json"), default="text")
23
+ check.add_argument("--strict", action="store_true")
24
+ check.add_argument("--config", type=Path)
25
+ return parser
26
+
27
+
28
+ def main(*, argv: Sequence[str] | None = None) -> int:
29
+ args = build_parser().parse_args(argv)
30
+ try:
31
+ workspace = build_workspace(target=args.path, explicit_config=args.config)
32
+ diagnostics = check_workspace(workspace=workspace)
33
+ except ConfigError as exc:
34
+ print(f"agentslint: configuration error: {exc}", file=sys.stderr)
35
+ return 2
36
+ except Exception as exc: # pragma: no cover - final CLI safety boundary
37
+ print(f"agentslint: internal error: {exc}", file=sys.stderr)
38
+ return 2
39
+
40
+ output = render(
41
+ diagnostics=diagnostics,
42
+ output_format=args.format,
43
+ files_checked=len(workspace.instruction_files),
44
+ )
45
+ if output:
46
+ print(output)
47
+ if any(item.severity is Severity.ERROR for item in diagnostics):
48
+ return 1
49
+ if args.strict and any(item.severity is Severity.WARNING for item in diagnostics):
50
+ return 1
51
+ return 0
agentslint/config.py ADDED
@@ -0,0 +1,251 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from agentslint.models import Severity
9
+
10
+ DEFAULT_FILES = ("AGENTS.md", "**/AGENTS.md")
11
+ DEFAULT_EXCLUDES = (".git/**", ".venv/**", "**/.venv/**", "node_modules/**", "**/node_modules/**")
12
+ DEFAULT_MANAGED_TOOLS = frozenset(
13
+ {
14
+ "black",
15
+ "dbt",
16
+ "eslint",
17
+ "jest",
18
+ "playwright",
19
+ "prettier",
20
+ "pytest",
21
+ "ruff",
22
+ "shellcheck",
23
+ "sqlfluff",
24
+ "ty",
25
+ }
26
+ )
27
+ DEFAULT_TOOL_ALIASES = {
28
+ "dbt": "dbt-core",
29
+ "py.test": "pytest",
30
+ }
31
+ ADVISORY_RULES = frozenset(
32
+ {"command-unsupported", "scope-ambiguous", "shell-syntax", "unsupported-template"}
33
+ )
34
+ KNOWN_KEYS = {
35
+ "files",
36
+ "exclude",
37
+ "additional-managed-tools",
38
+ "disabled-managed-tools",
39
+ "tool-aliases",
40
+ "dbt-manifests",
41
+ "untracked-roots",
42
+ "ignore",
43
+ "severity",
44
+ }
45
+
46
+
47
+ class ConfigError(ValueError):
48
+ pass
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Ignore:
53
+ file: Path
54
+ rule: str
55
+ reference: str
56
+ reason: str
57
+ line: int | None = None
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Config:
62
+ files: tuple[str, ...] = DEFAULT_FILES
63
+ excludes: tuple[str, ...] = DEFAULT_EXCLUDES
64
+ managed_tools: frozenset[str] = DEFAULT_MANAGED_TOOLS
65
+ tool_aliases: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_TOOL_ALIASES))
66
+ dbt_manifests: tuple[Path, ...] = ()
67
+ untracked_roots: tuple[Path, ...] = ()
68
+ ignores: tuple[Ignore, ...] = ()
69
+ severity: dict[str, Severity] = field(default_factory=dict)
70
+ source: Path | None = None
71
+
72
+
73
+ def discover_config(*, root: Path, explicit: Path | None = None) -> Config:
74
+ if explicit is not None:
75
+ path = explicit if explicit.is_absolute() else (Path.cwd() / explicit)
76
+ path = path.resolve()
77
+ if not path.is_file():
78
+ raise ConfigError(f"configuration file does not exist: {explicit}")
79
+ return _load_config(path=path, explicit=True)
80
+
81
+ standalone = root / "agentslint.toml"
82
+ pyproject = root / "pyproject.toml"
83
+ has_standalone = standalone.is_file()
84
+ pyproject_data = _read_toml(path=pyproject) if pyproject.is_file() else {}
85
+ has_pyproject = isinstance(_nested(value=pyproject_data, keys=("tool", "agentslint")), dict)
86
+ if has_standalone and has_pyproject:
87
+ raise ConfigError("both agentslint.toml and [tool.agentslint] in pyproject.toml were found")
88
+ if has_standalone:
89
+ return _load_config(path=standalone, explicit=False)
90
+ if has_pyproject:
91
+ return _parse_config(
92
+ raw=_nested(value=pyproject_data, keys=("tool", "agentslint")),
93
+ source=pyproject,
94
+ )
95
+ return Config()
96
+
97
+
98
+ def _load_config(*, path: Path, explicit: bool) -> Config:
99
+ data = _read_toml(path=path)
100
+ embedded = _nested(value=data, keys=("tool", "agentslint"))
101
+ if isinstance(embedded, dict):
102
+ section = embedded
103
+ elif path.name == "pyproject.toml":
104
+ if explicit:
105
+ raise ConfigError(f"{path} has no [tool.agentslint] table")
106
+ return Config()
107
+ else:
108
+ section = data
109
+ if not isinstance(section, dict):
110
+ raise ConfigError(f"agentslint configuration in {path} must be a table")
111
+ return _parse_config(raw=section, source=path)
112
+
113
+
114
+ def _read_toml(*, path: Path) -> dict[str, Any]:
115
+ try:
116
+ with path.open("rb") as handle:
117
+ value = tomllib.load(handle)
118
+ except (OSError, tomllib.TOMLDecodeError) as exc:
119
+ raise ConfigError(f"cannot read configuration {path}: {exc}") from exc
120
+ if not isinstance(value, dict):
121
+ raise ConfigError(f"configuration {path} must contain a TOML table")
122
+ return value
123
+
124
+
125
+ def _nested(*, value: dict[str, Any], keys: tuple[str, ...]) -> Any:
126
+ current: Any = value
127
+ for key in keys:
128
+ if not isinstance(current, dict):
129
+ return None
130
+ current = current.get(key)
131
+ return current
132
+
133
+
134
+ def _parse_config(*, raw: dict[str, Any], source: Path) -> Config:
135
+ unknown = sorted(set(raw) - KNOWN_KEYS)
136
+ if unknown:
137
+ raise ConfigError(f"unknown configuration key(s): {', '.join(unknown)}")
138
+
139
+ files = _string_tuple(value=raw.get("files", DEFAULT_FILES), key="files")
140
+ excludes = _string_tuple(value=raw.get("exclude", DEFAULT_EXCLUDES), key="exclude")
141
+ additional = set(
142
+ _string_tuple(value=raw.get("additional-managed-tools", ()), key="additional-managed-tools")
143
+ )
144
+ disabled = set(
145
+ _string_tuple(value=raw.get("disabled-managed-tools", ()), key="disabled-managed-tools")
146
+ )
147
+ overlap = additional & disabled
148
+ if overlap:
149
+ raise ConfigError(
150
+ "managed tools cannot be both added and disabled: " + ", ".join(sorted(overlap))
151
+ )
152
+ managed = (set(DEFAULT_MANAGED_TOOLS) | additional) - disabled
153
+
154
+ aliases = dict(DEFAULT_TOOL_ALIASES)
155
+ aliases.update(_string_mapping(value=raw.get("tool-aliases", {}), key="tool-aliases"))
156
+
157
+ manifests = tuple(
158
+ Path(item)
159
+ for item in _string_tuple(value=raw.get("dbt-manifests", ()), key="dbt-manifests")
160
+ )
161
+ untracked = tuple(
162
+ Path(item)
163
+ for item in _string_tuple(value=raw.get("untracked-roots", ()), key="untracked-roots")
164
+ )
165
+ ignores = _parse_ignores(value=raw.get("ignore", ()))
166
+ severity = _parse_severity(value=raw.get("severity", {}))
167
+ if not files:
168
+ raise ConfigError("files must contain at least one instruction filename or pattern")
169
+
170
+ return Config(
171
+ files=files,
172
+ excludes=excludes,
173
+ managed_tools=frozenset(managed),
174
+ tool_aliases=aliases,
175
+ dbt_manifests=manifests,
176
+ untracked_roots=untracked,
177
+ ignores=ignores,
178
+ severity=severity,
179
+ source=source,
180
+ )
181
+
182
+
183
+ def _string_tuple(*, value: Any, key: str) -> tuple[str, ...]:
184
+ if not isinstance(value, list | tuple) or not all(
185
+ isinstance(item, str) and item for item in value
186
+ ):
187
+ raise ConfigError(f"{key} must be an array of non-empty strings")
188
+ return tuple(value)
189
+
190
+
191
+ def _string_mapping(*, value: Any, key: str) -> dict[str, str]:
192
+ if not isinstance(value, dict) or not all(
193
+ isinstance(name, str) and name and isinstance(target, str) and target
194
+ for name, target in value.items()
195
+ ):
196
+ raise ConfigError(f"{key} must be a table of non-empty string values")
197
+ return dict(value)
198
+
199
+
200
+ def _parse_ignores(*, value: Any) -> tuple[Ignore, ...]:
201
+ if not isinstance(value, list | tuple):
202
+ raise ConfigError("ignore must be an array of tables")
203
+ result: list[Ignore] = []
204
+ required = {"file", "rule", "reference", "reason"}
205
+ allowed = required | {"line"}
206
+ for index, item in enumerate(value):
207
+ if not isinstance(item, dict):
208
+ raise ConfigError(f"ignore[{index}] must be a table")
209
+ if not all(isinstance(key, str) for key in item):
210
+ raise ConfigError(f"ignore[{index}] keys must be strings")
211
+ item_keys = {str(key) for key in item}
212
+ missing = required - item_keys
213
+ unknown = item_keys - allowed
214
+ if missing or unknown:
215
+ details = []
216
+ if missing:
217
+ details.append("missing " + ", ".join(sorted(missing)))
218
+ if unknown:
219
+ details.append("unknown " + ", ".join(sorted(unknown)))
220
+ raise ConfigError(f"ignore[{index}] is invalid: {'; '.join(details)}")
221
+ if not all(isinstance(item[key], str) and item[key] for key in required):
222
+ raise ConfigError(f"ignore[{index}] fields must be non-empty strings")
223
+ line = item.get("line")
224
+ if line is not None and (not isinstance(line, int) or isinstance(line, bool) or line < 1):
225
+ raise ConfigError(f"ignore[{index}].line must be a positive integer")
226
+ result.append(
227
+ Ignore(
228
+ file=Path(item["file"]),
229
+ rule=item["rule"],
230
+ reference=item["reference"],
231
+ reason=item["reason"],
232
+ line=line,
233
+ )
234
+ )
235
+ return tuple(result)
236
+
237
+
238
+ def _parse_severity(*, value: Any) -> dict[str, Severity]:
239
+ if not isinstance(value, dict):
240
+ raise ConfigError("severity must be a table")
241
+ result: dict[str, Severity] = {}
242
+ for rule, raw_severity in value.items():
243
+ if not isinstance(rule, str) or not isinstance(raw_severity, str):
244
+ raise ConfigError("severity must map rule names to strings")
245
+ if rule not in ADVISORY_RULES:
246
+ raise ConfigError(f"severity cannot be overridden for non-advisory rule: {rule}")
247
+ try:
248
+ result[rule] = Severity(raw_severity)
249
+ except ValueError as exc:
250
+ raise ConfigError(f"invalid severity for {rule}: {raw_severity}") from exc
251
+ return result
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterable
5
+
6
+ from agentslint.models import Diagnostic, Severity
7
+
8
+
9
+ def render_text(*, diagnostics: Iterable[Diagnostic], files_checked: int) -> str:
10
+ values = tuple(diagnostics)
11
+ lines = []
12
+ for diagnostic in sorted(values, key=Diagnostic.sort_key):
13
+ location = f"{diagnostic.document.as_posix()}:{diagnostic.line}"
14
+ if diagnostic.column is not None:
15
+ location += f":{diagnostic.column}"
16
+ lines.append(
17
+ f"{location}: {diagnostic.severity.value}[{diagnostic.rule}]: {diagnostic.message}"
18
+ )
19
+ if lines:
20
+ lines.append("")
21
+ lines.append(_text_summary(diagnostics=values, files_checked=files_checked))
22
+ return "\n".join(lines)
23
+
24
+
25
+ def render_github(*, diagnostics: Iterable[Diagnostic]) -> str:
26
+ lines = []
27
+ for diagnostic in sorted(diagnostics, key=Diagnostic.sort_key):
28
+ properties = [
29
+ f"file={_escape_property(value=diagnostic.document.as_posix())}",
30
+ f"line={diagnostic.line}",
31
+ f"title={_escape_property(value=f'agentslint/{diagnostic.rule}')}",
32
+ ]
33
+ if diagnostic.column is not None:
34
+ properties.append(f"col={diagnostic.column}")
35
+ lines.append(
36
+ f"::{diagnostic.severity.value} {','.join(properties)}::"
37
+ f"{_escape_message(value=diagnostic.message)}"
38
+ )
39
+ return "\n".join(lines)
40
+
41
+
42
+ def render_json(*, diagnostics: Iterable[Diagnostic], files_checked: int) -> str:
43
+ diagnostics = tuple(diagnostics)
44
+ values = []
45
+ for diagnostic in sorted(diagnostics, key=Diagnostic.sort_key):
46
+ values.append(
47
+ {
48
+ "file": diagnostic.document.as_posix(),
49
+ "line": diagnostic.line,
50
+ "column": diagnostic.column,
51
+ "rule": diagnostic.rule,
52
+ "severity": diagnostic.severity.value,
53
+ "message": diagnostic.message,
54
+ "reference": diagnostic.reference,
55
+ }
56
+ )
57
+ errors, warnings = _severity_counts(diagnostics=diagnostics)
58
+ return json.dumps(
59
+ {
60
+ "schema_version": 1,
61
+ "summary": {
62
+ "files_checked": files_checked,
63
+ "errors": errors,
64
+ "warnings": warnings,
65
+ },
66
+ "diagnostics": values,
67
+ },
68
+ indent=2,
69
+ )
70
+
71
+
72
+ def render(*, diagnostics: Iterable[Diagnostic], output_format: str, files_checked: int) -> str:
73
+ if output_format == "json":
74
+ return render_json(diagnostics=diagnostics, files_checked=files_checked)
75
+ if output_format == "github":
76
+ return render_github(diagnostics=diagnostics)
77
+ return render_text(diagnostics=diagnostics, files_checked=files_checked)
78
+
79
+
80
+ def _text_summary(*, diagnostics: Iterable[Diagnostic], files_checked: int) -> str:
81
+ errors, warnings = _severity_counts(diagnostics=diagnostics)
82
+ file_label = "instruction file" if files_checked == 1 else "instruction files"
83
+ prefix = f"agentslint: checked {files_checked} {file_label}; "
84
+ if not errors and not warnings:
85
+ return prefix + "no issues found"
86
+ counts = []
87
+ if errors:
88
+ counts.append(f"{errors} {'error' if errors == 1 else 'errors'}")
89
+ if warnings:
90
+ counts.append(f"{warnings} {'warning' if warnings == 1 else 'warnings'}")
91
+ return prefix + ", ".join(counts)
92
+
93
+
94
+ def _severity_counts(*, diagnostics: Iterable[Diagnostic]) -> tuple[int, int]:
95
+ errors = 0
96
+ warnings = 0
97
+ for diagnostic in diagnostics:
98
+ if diagnostic.severity is Severity.ERROR:
99
+ errors += 1
100
+ else:
101
+ warnings += 1
102
+ return errors, warnings
103
+
104
+
105
+ def _escape_property(*, value: str) -> str:
106
+ return (
107
+ value.replace("%", "%25")
108
+ .replace("\r", "%0D")
109
+ .replace("\n", "%0A")
110
+ .replace(":", "%3A")
111
+ .replace(",", "%2C")
112
+ )
113
+
114
+
115
+ def _escape_message(*, value: str) -> str:
116
+ return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ from fnmatch import fnmatchcase
6
+ from pathlib import Path
7
+
8
+ from agentslint.config import Config, ConfigError, discover_config
9
+ from agentslint.workspace import Workspace
10
+
11
+
12
+ def build_workspace(*, target: Path, explicit_config: Path | None = None) -> Workspace:
13
+ requested = target.expanduser()
14
+ if not requested.exists():
15
+ raise ConfigError(f"check path does not exist: {target}")
16
+ requested = requested.resolve()
17
+ start = requested.parent if requested.is_file() else requested
18
+ git_root = _git_root(start=start)
19
+ root = git_root or start
20
+ config = discover_config(root=root, explicit=explicit_config)
21
+ tracked = _git_paths(root=root) if git_root else None
22
+ git_backed = tracked is not None
23
+ if tracked is None:
24
+ tracked = _walk_paths(root=root, config=config)
25
+
26
+ existing = {path for path in tracked if (root / path).exists()}
27
+ existing.update(
28
+ parent for path in tuple(existing) for parent in path.parents if parent != Path(".")
29
+ )
30
+ for configured_root in config.untracked_roots:
31
+ absolute = (root / configured_root).resolve()
32
+ try:
33
+ absolute.relative_to(root)
34
+ except ValueError as exc:
35
+ raise ConfigError(f"untracked root escapes repository: {configured_root}") from exc
36
+ if absolute.exists():
37
+ if absolute.is_file():
38
+ existing.add(absolute.relative_to(root))
39
+ else:
40
+ existing.update(
41
+ path.relative_to(root)
42
+ for path in absolute.rglob("*")
43
+ if path.is_file() or path.is_dir()
44
+ )
45
+
46
+ selected = _instruction_files(
47
+ root=root, requested=requested, tracked=tracked, existing=existing, config=config
48
+ )
49
+ return Workspace(
50
+ root=root,
51
+ target=requested,
52
+ config=config,
53
+ tracked_paths=frozenset(tracked),
54
+ existing_paths=frozenset(existing),
55
+ instruction_files=tuple(sorted(selected)),
56
+ git_backed=git_backed,
57
+ )
58
+
59
+
60
+ def _git_root(*, start: Path) -> Path | None:
61
+ try:
62
+ result = subprocess.run(
63
+ ["git", "rev-parse", "--show-toplevel"],
64
+ cwd=start,
65
+ check=True,
66
+ stdout=subprocess.PIPE,
67
+ stderr=subprocess.DEVNULL,
68
+ text=True,
69
+ timeout=5,
70
+ )
71
+ except (OSError, subprocess.SubprocessError):
72
+ return None
73
+ return Path(result.stdout.strip()).resolve()
74
+
75
+
76
+ def _git_paths(*, root: Path) -> set[Path] | None:
77
+ try:
78
+ result = subprocess.run(
79
+ ["git", "ls-files", "-z", "--cached"],
80
+ cwd=root,
81
+ check=True,
82
+ stdout=subprocess.PIPE,
83
+ stderr=subprocess.DEVNULL,
84
+ timeout=10,
85
+ )
86
+ except (OSError, subprocess.SubprocessError):
87
+ return None
88
+ return {
89
+ Path(raw.decode("utf-8", errors="surrogateescape"))
90
+ for raw in result.stdout.split(b"\0")
91
+ if raw
92
+ }
93
+
94
+
95
+ def _walk_paths(*, root: Path, config: Config) -> set[Path]:
96
+ result: set[Path] = set()
97
+ for current, directories, files in os.walk(root, topdown=True, followlinks=False):
98
+ current_path = Path(current)
99
+ kept_directories = []
100
+ for name in directories:
101
+ relative = (current_path / name).relative_to(root)
102
+ if _excluded_tree(path=relative, config=config):
103
+ continue
104
+ result.add(relative)
105
+ kept_directories.append(name)
106
+ directories[:] = kept_directories
107
+ for name in files:
108
+ relative = (current_path / name).relative_to(root)
109
+ if not _excluded(path=relative, config=config):
110
+ result.add(relative)
111
+ return result
112
+
113
+
114
+ def _instruction_files(
115
+ *,
116
+ root: Path,
117
+ requested: Path,
118
+ tracked: set[Path],
119
+ existing: set[Path],
120
+ config: Config,
121
+ ) -> set[Path]:
122
+ if requested.is_file():
123
+ relative = requested.relative_to(root)
124
+ if relative not in tracked:
125
+ raise ConfigError(f"instruction file is not tracked: {relative}")
126
+ if not _is_instruction(path=relative, config=config):
127
+ raise ConfigError(f"file does not match configured instruction names: {relative}")
128
+ return {relative}
129
+
130
+ boundary = requested.relative_to(root)
131
+ selected = {
132
+ path
133
+ for path in tracked & existing
134
+ if _is_instruction(path=path, config=config)
135
+ and not _excluded(path=path, config=config)
136
+ and _beneath(path=path, boundary=boundary)
137
+ }
138
+ return selected
139
+
140
+
141
+ def _is_instruction(*, path: Path, config: Config) -> bool:
142
+ value = path.as_posix()
143
+ return any(
144
+ value == pattern or path.name == pattern or fnmatchcase(value, pattern)
145
+ for pattern in config.files
146
+ )
147
+
148
+
149
+ def _excluded(*, path: Path, config: Config) -> bool:
150
+ value = path.as_posix()
151
+ return any(fnmatchcase(value, pattern) for pattern in config.excludes)
152
+
153
+
154
+ def _excluded_tree(*, path: Path, config: Config) -> bool:
155
+ return _excluded(path=path, config=config) or _excluded(
156
+ path=path / "__agentslint_descendant__",
157
+ config=config,
158
+ )
159
+
160
+
161
+ def _beneath(*, path: Path, boundary: Path) -> bool:
162
+ if boundary == Path("."):
163
+ return True
164
+ try:
165
+ path.relative_to(boundary)
166
+ except ValueError:
167
+ return False
168
+ return True