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 +7 -0
- agentslint/__main__.py +5 -0
- agentslint/cli.py +51 -0
- agentslint/config.py +251 -0
- agentslint/diagnostics.py +116 -0
- agentslint/discovery.py +168 -0
- agentslint/engine.py +119 -0
- agentslint/markdown.py +817 -0
- agentslint/models.py +59 -0
- agentslint/resolvers/__init__.py +4 -0
- agentslint/resolvers/base.py +40 -0
- agentslint/resolvers/dbt.py +239 -0
- agentslint/resolvers/go.py +186 -0
- agentslint/resolvers/links.py +64 -0
- agentslint/resolvers/make.py +126 -0
- agentslint/resolvers/npm.py +354 -0
- agentslint/resolvers/paths.py +228 -0
- agentslint/resolvers/project.py +72 -0
- agentslint/resolvers/pytest.py +137 -0
- agentslint/resolvers/python.py +294 -0
- agentslint/resolvers/shell.py +316 -0
- agentslint/resolvers/tools.py +202 -0
- agentslint/workspace.py +93 -0
- agentslint-0.1.0.dist-info/METADATA +154 -0
- agentslint-0.1.0.dist-info/RECORD +28 -0
- agentslint-0.1.0.dist-info/WHEEL +4 -0
- agentslint-0.1.0.dist-info/entry_points.txt +2 -0
- agentslint-0.1.0.dist-info/licenses/LICENSE +21 -0
agentslint/__init__.py
ADDED
agentslint/__main__.py
ADDED
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")
|
agentslint/discovery.py
ADDED
|
@@ -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
|