familiar-cli 0.5.0__tar.gz → 0.5.2__tar.gz
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.
- {familiar_cli-0.5.0/src/familiar_cli.egg-info → familiar_cli-0.5.2}/PKG-INFO +1 -1
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/pyproject.toml +1 -1
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/_plugins.py +7 -7
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/agents.py +13 -13
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/cli.py +47 -38
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/lint.py +32 -44
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/render.py +73 -35
- {familiar_cli-0.5.0 → familiar_cli-0.5.2/src/familiar_cli.egg-info}/PKG-INFO +1 -1
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/tests/test_cli.py +91 -6
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/tests/test_integration.py +0 -3
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/tests/test_lint.py +72 -5
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/tests/test_render.py +154 -2
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/LICENSE +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/readme.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/setup.cfg +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/__init__.py +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/core.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/data.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/docs.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/frontend.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/infra.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/python.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/rust.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/sec.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/__noop__.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/add-ci.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/add-tests.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/audit.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/bootstrap-python.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/code-review.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/explain.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/implement-feature.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/infra-change.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/performance.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/refactor.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/release.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/security-review.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/node/github-ci.yml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/python/github-ci.yml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/python/pyproject.toml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/py.typed +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/SOURCES.txt +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/entry_points.txt +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/requires.txt +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/top_level.txt +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.2}/tests/test_agents.py +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""shared plugin loading utilities."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -14,16 +14,16 @@ def load_plugins(
|
|
|
14
14
|
label: str,
|
|
15
15
|
invalid_msg: str = "validation failed",
|
|
16
16
|
) -> list[Any]:
|
|
17
|
-
"""
|
|
17
|
+
"""load plugins from entry points with validation.
|
|
18
18
|
|
|
19
19
|
Args:
|
|
20
|
-
group:
|
|
21
|
-
validate:
|
|
22
|
-
label:
|
|
23
|
-
invalid_msg:
|
|
20
|
+
group: entry point group name.
|
|
21
|
+
validate: predicate that returns True for valid loaded objects.
|
|
22
|
+
label: human-readable label for warning messages (e.g. "linter plugin").
|
|
23
|
+
invalid_msg: message when validation fails.
|
|
24
24
|
|
|
25
25
|
Returns:
|
|
26
|
-
|
|
26
|
+
list of loaded and validated plugin objects.
|
|
27
27
|
"""
|
|
28
28
|
results: list[Any] = []
|
|
29
29
|
for ep in entry_points(group=group):
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""agent implementations for familiar."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -11,7 +11,7 @@ from ._plugins import load_plugins
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class Agent(ABC):
|
|
14
|
-
"""
|
|
14
|
+
"""base class for AI coding agents."""
|
|
15
15
|
|
|
16
16
|
name: str
|
|
17
17
|
output_file: str
|
|
@@ -24,24 +24,24 @@ class Agent(ABC):
|
|
|
24
24
|
def run(
|
|
25
25
|
self, repo_root: Path, prompt: str, headless: bool, auto: bool = False
|
|
26
26
|
) -> int:
|
|
27
|
-
"""
|
|
27
|
+
"""run the agent with the given prompt."""
|
|
28
28
|
|
|
29
29
|
def supports_skills(self) -> bool:
|
|
30
|
-
"""
|
|
30
|
+
"""whether this agent supports reusable skill files."""
|
|
31
31
|
return self.skill_dir is not None
|
|
32
32
|
|
|
33
33
|
def skill_path(self, repo_root: Path, skill_name: str) -> Path:
|
|
34
|
-
"""
|
|
34
|
+
"""build the skill path for a given skill name."""
|
|
35
35
|
if self.skill_dir is None:
|
|
36
36
|
raise ValueError(f"agent does not support skills: {self.name}")
|
|
37
37
|
return repo_root / self.skill_dir / skill_name / self.skill_file
|
|
38
38
|
|
|
39
39
|
def supports_subagents(self) -> bool:
|
|
40
|
-
"""
|
|
40
|
+
"""whether this agent supports reusable subagent files."""
|
|
41
41
|
return self.subagent_dir is not None
|
|
42
42
|
|
|
43
43
|
def subagent_path(self, repo_root: Path, subagent_name: str) -> Path:
|
|
44
|
-
"""
|
|
44
|
+
"""build the subagent path for a given subagent name."""
|
|
45
45
|
if self.subagent_dir is None:
|
|
46
46
|
raise ValueError(f"agent does not support subagents: {self.name}")
|
|
47
47
|
return repo_root / self.subagent_dir / subagent_name / self.subagent_file
|
|
@@ -91,11 +91,11 @@ class ClaudeAgent(Agent):
|
|
|
91
91
|
|
|
92
92
|
|
|
93
93
|
def load_agents() -> dict[str, Agent]:
|
|
94
|
-
"""
|
|
94
|
+
"""load all registered agent plugins via entry points.
|
|
95
95
|
|
|
96
96
|
Returns:
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
dictionary mapping agent names to Agent instances.
|
|
98
|
+
plugins that fail to load are skipped with a warning.
|
|
99
99
|
"""
|
|
100
100
|
plugins = load_plugins(
|
|
101
101
|
"familiar.agents",
|
|
@@ -120,9 +120,9 @@ _agents_cache: dict[str, Agent] | None = None
|
|
|
120
120
|
|
|
121
121
|
|
|
122
122
|
def get_agents() -> dict[str, Agent]:
|
|
123
|
-
"""
|
|
123
|
+
"""get all available agents.
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
returns a cached dictionary of agent name -> Agent instance.
|
|
126
126
|
"""
|
|
127
127
|
global _agents_cache
|
|
128
128
|
if _agents_cache is None:
|
|
@@ -131,7 +131,7 @@ def get_agents() -> dict[str, Agent]:
|
|
|
131
131
|
|
|
132
132
|
|
|
133
133
|
def get_agent(name: str) -> Agent:
|
|
134
|
-
"""
|
|
134
|
+
"""get an agent by name.
|
|
135
135
|
|
|
136
136
|
Raises:
|
|
137
137
|
KeyError: if the agent name is not recognized.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""command-line interface for familiar."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -14,7 +14,7 @@ import warnings
|
|
|
14
14
|
from importlib.metadata import version
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
-
from .agents import get_agents, get_agent
|
|
17
|
+
from .agents import Agent, get_agents, get_agent
|
|
18
18
|
from .lint import lint_all
|
|
19
19
|
from .render import (
|
|
20
20
|
NotFoundError,
|
|
@@ -24,7 +24,6 @@ from .render import (
|
|
|
24
24
|
render_invocation,
|
|
25
25
|
)
|
|
26
26
|
|
|
27
|
-
# exit codes
|
|
28
27
|
EXIT_SUCCESS = 0
|
|
29
28
|
EXIT_ERROR = 1 # general error (agent failed, etc.)
|
|
30
29
|
EXIT_USAGE = 2 # usage error (bad args, missing files, etc.)
|
|
@@ -43,14 +42,35 @@ class CliError(Exception):
|
|
|
43
42
|
|
|
44
43
|
|
|
45
44
|
def _agent_hint() -> str:
|
|
46
|
-
"""
|
|
45
|
+
"""return a hint string listing valid agent names."""
|
|
47
46
|
return f"valid agents: {', '.join(get_agents().keys())}"
|
|
48
47
|
|
|
49
48
|
|
|
49
|
+
def _resolve_agent(name: str) -> "Agent":
|
|
50
|
+
"""look up an agent by name, raising CliError on unknown names."""
|
|
51
|
+
try:
|
|
52
|
+
return get_agent(name)
|
|
53
|
+
except KeyError as e:
|
|
54
|
+
raise CliError(str(e), hint=_agent_hint())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _write_file(path: Path, content: str, label: str = "") -> None:
|
|
58
|
+
"""write content to path, creating parent dirs. wraps OSError as CliError."""
|
|
59
|
+
prefix = f"{label} " if label else ""
|
|
60
|
+
try:
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
path.write_text(content, encoding="utf-8")
|
|
63
|
+
except OSError as e:
|
|
64
|
+
raise CliError(
|
|
65
|
+
f"cannot write {prefix}{path}: {e}",
|
|
66
|
+
hint="check file permissions and disk space",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
50
70
|
def find_repo_root(start: Path) -> Path:
|
|
51
|
-
"""
|
|
71
|
+
"""find the repository root by looking for .git directory.
|
|
52
72
|
|
|
53
|
-
|
|
73
|
+
walks up from start directory. falls back to start directory itself
|
|
54
74
|
if no .git is found, allowing use outside of git repositories.
|
|
55
75
|
"""
|
|
56
76
|
cur = start.resolve()
|
|
@@ -61,9 +81,9 @@ def find_repo_root(start: Path) -> Path:
|
|
|
61
81
|
|
|
62
82
|
|
|
63
83
|
def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
|
|
64
|
-
"""
|
|
84
|
+
"""best-effort removal of a git worktree."""
|
|
65
85
|
try:
|
|
66
|
-
subprocess.run(
|
|
86
|
+
result = subprocess.run(
|
|
67
87
|
[
|
|
68
88
|
"git",
|
|
69
89
|
"-C",
|
|
@@ -75,13 +95,18 @@ def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
|
|
|
75
95
|
],
|
|
76
96
|
capture_output=True,
|
|
77
97
|
)
|
|
98
|
+
if result.returncode != 0:
|
|
99
|
+
stderr = result.stderr.decode(errors="replace").strip()
|
|
100
|
+
warnings.warn(
|
|
101
|
+
f"failed to remove worktree {worktree_path}: {stderr}",
|
|
102
|
+
stacklevel=2,
|
|
103
|
+
)
|
|
78
104
|
except FileNotFoundError:
|
|
79
105
|
pass
|
|
80
106
|
|
|
81
107
|
|
|
82
108
|
def create_worktree(repo_root: Path) -> Path:
|
|
83
|
-
"""
|
|
84
|
-
# Ensure it's a git repo
|
|
109
|
+
"""create a temporary git worktree for the repository."""
|
|
85
110
|
try:
|
|
86
111
|
subprocess.run(
|
|
87
112
|
["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
|
|
@@ -95,7 +120,7 @@ def create_worktree(repo_root: Path) -> Path:
|
|
|
95
120
|
|
|
96
121
|
tmpdir = tempfile.mkdtemp(prefix="familiar-")
|
|
97
122
|
# git worktree add requires the directory to NOT exist.
|
|
98
|
-
# mkdtemp creates it.
|
|
123
|
+
# mkdtemp creates it. let's remove it and let git create it.
|
|
99
124
|
os.rmdir(tmpdir)
|
|
100
125
|
|
|
101
126
|
try:
|
|
@@ -120,33 +145,27 @@ def create_worktree(repo_root: Path) -> Path:
|
|
|
120
145
|
|
|
121
146
|
|
|
122
147
|
def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
raise CliError(str(e), hint=_agent_hint())
|
|
127
|
-
(repo_root / agent.output_file).write_text(system.strip() + "\n", encoding="utf-8")
|
|
148
|
+
agent = _resolve_agent(agent_name)
|
|
149
|
+
dest = repo_root / agent.output_file
|
|
150
|
+
_write_file(dest, system.strip() + "\n")
|
|
128
151
|
|
|
129
152
|
|
|
130
153
|
def write_skill(
|
|
131
154
|
repo_root: Path, agent_name: str, invocation: str, skill_name: str, prompt: str
|
|
132
155
|
) -> Path:
|
|
133
|
-
"""
|
|
156
|
+
"""write a reusable skill file for supported agents."""
|
|
134
157
|
if not _VALID_NAME.match(skill_name):
|
|
135
158
|
raise CliError(
|
|
136
159
|
f"invalid skill name: {skill_name}",
|
|
137
160
|
hint="use lowercase letters, numbers, underscore, or hyphen",
|
|
138
161
|
)
|
|
139
162
|
|
|
140
|
-
|
|
141
|
-
agent = get_agent(agent_name)
|
|
142
|
-
except KeyError as e:
|
|
143
|
-
raise CliError(str(e), hint=_agent_hint())
|
|
163
|
+
agent = _resolve_agent(agent_name)
|
|
144
164
|
|
|
145
165
|
if not agent.supports_skills():
|
|
146
166
|
raise CliError(f"agent does not support skills: {agent_name}")
|
|
147
167
|
|
|
148
168
|
skill_path = agent.skill_path(repo_root, skill_name)
|
|
149
|
-
skill_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
169
|
content = (
|
|
151
170
|
f"# {skill_name}\n\n"
|
|
152
171
|
"use this skill when this recurring task appears in this repository.\n\n"
|
|
@@ -156,7 +175,7 @@ def write_skill(
|
|
|
156
175
|
"## instructions\n\n"
|
|
157
176
|
f"{prompt.strip()}\n"
|
|
158
177
|
)
|
|
159
|
-
skill_path
|
|
178
|
+
_write_file(skill_path, content, label="skill")
|
|
160
179
|
return skill_path
|
|
161
180
|
|
|
162
181
|
|
|
@@ -167,23 +186,19 @@ def write_subagent(
|
|
|
167
186
|
subagent_name: str,
|
|
168
187
|
system: str,
|
|
169
188
|
) -> Path:
|
|
170
|
-
"""
|
|
189
|
+
"""write a reusable subagent file for supported agents."""
|
|
171
190
|
if not _VALID_NAME.match(subagent_name):
|
|
172
191
|
raise CliError(
|
|
173
192
|
f"invalid subagent name: {subagent_name}",
|
|
174
193
|
hint="use lowercase letters, numbers, underscore, or hyphen",
|
|
175
194
|
)
|
|
176
195
|
|
|
177
|
-
|
|
178
|
-
agent = get_agent(agent_name)
|
|
179
|
-
except KeyError as e:
|
|
180
|
-
raise CliError(str(e), hint=_agent_hint())
|
|
196
|
+
agent = _resolve_agent(agent_name)
|
|
181
197
|
|
|
182
198
|
if not agent.supports_subagents():
|
|
183
199
|
raise CliError(f"agent does not support subagents: {agent_name}")
|
|
184
200
|
|
|
185
201
|
subagent_path = agent.subagent_path(repo_root, subagent_name)
|
|
186
|
-
subagent_path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
202
|
profile = ", ".join(conjurings)
|
|
188
203
|
content = (
|
|
189
204
|
f"# {subagent_name}\n\n"
|
|
@@ -194,7 +209,7 @@ def write_subagent(
|
|
|
194
209
|
"## instructions\n\n"
|
|
195
210
|
f"{system.strip()}\n"
|
|
196
211
|
)
|
|
197
|
-
subagent_path
|
|
212
|
+
_write_file(subagent_path, content, label="subagent")
|
|
198
213
|
return subagent_path
|
|
199
214
|
|
|
200
215
|
|
|
@@ -214,10 +229,7 @@ def parse_kv(pairs: list[str]) -> dict[str, str]:
|
|
|
214
229
|
def run_agent(
|
|
215
230
|
repo_root: Path, agent_name: str, prompt: str, headless: bool, auto: bool = False
|
|
216
231
|
) -> int:
|
|
217
|
-
|
|
218
|
-
agent = get_agent(agent_name)
|
|
219
|
-
except KeyError as e:
|
|
220
|
-
raise CliError(str(e), hint=_agent_hint())
|
|
232
|
+
agent = _resolve_agent(agent_name)
|
|
221
233
|
try:
|
|
222
234
|
return agent.run(repo_root, prompt, headless, auto=auto)
|
|
223
235
|
except FileNotFoundError:
|
|
@@ -277,7 +289,7 @@ def cmd_invoke(args: argparse.Namespace) -> int:
|
|
|
277
289
|
target_dir = create_worktree(repo_root)
|
|
278
290
|
print(f"created worktree at {target_dir}")
|
|
279
291
|
|
|
280
|
-
#
|
|
292
|
+
# best-effort copy of instruction file into the worktree
|
|
281
293
|
try:
|
|
282
294
|
agent = get_agent(args.agent)
|
|
283
295
|
instr_file = repo_root / agent.output_file
|
|
@@ -318,7 +330,6 @@ def cmd_list(args: argparse.Namespace) -> int:
|
|
|
318
330
|
repo_root = find_repo_root(Path(args.into or os.getcwd()))
|
|
319
331
|
|
|
320
332
|
if args.kind is None:
|
|
321
|
-
# list all
|
|
322
333
|
conjurings = list_items(repo_root, "conjurings")
|
|
323
334
|
invocations = list_items(repo_root, "invocations")
|
|
324
335
|
snippets = list_snippets(repo_root)
|
|
@@ -349,7 +360,6 @@ def cmd_lint(args: argparse.Namespace) -> int:
|
|
|
349
360
|
|
|
350
361
|
messages = lint_all(repo_root)
|
|
351
362
|
|
|
352
|
-
# Filter by level if requested
|
|
353
363
|
if args.errors_only:
|
|
354
364
|
messages = [m for m in messages if m.level == "error"]
|
|
355
365
|
|
|
@@ -357,7 +367,6 @@ def cmd_lint(args: argparse.Namespace) -> int:
|
|
|
357
367
|
print("all checks passed")
|
|
358
368
|
return EXIT_SUCCESS
|
|
359
369
|
|
|
360
|
-
# Group by level for output
|
|
361
370
|
errors = [m for m in messages if m.level == "error"]
|
|
362
371
|
warnings = [m for m in messages if m.level == "warning"]
|
|
363
372
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""linting for familiar conjurings and invocations."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -14,7 +14,7 @@ from .render import _SNIPPET_INCLUDE, NotFoundError, list_items, load_snippet, l
|
|
|
14
14
|
|
|
15
15
|
@dataclass
|
|
16
16
|
class LintMessage:
|
|
17
|
-
"""
|
|
17
|
+
"""a lint message."""
|
|
18
18
|
|
|
19
19
|
level: Literal["error", "warning"]
|
|
20
20
|
file: str
|
|
@@ -28,17 +28,15 @@ class LintMessage:
|
|
|
28
28
|
return f"{self.level}: {loc}: {self.message}"
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
# Regex patterns for placeholders
|
|
32
31
|
_POSITIONAL_PLACEHOLDER = re.compile(r"\$(\d+|ARGUMENTS)")
|
|
33
32
|
_NAMED_PLACEHOLDER = re.compile(r"\{\{(\w+)\}\}")
|
|
34
33
|
|
|
35
|
-
#
|
|
36
|
-
# Accept various task verbs as first line
|
|
34
|
+
# accept various task verbs as first line
|
|
37
35
|
_TASK_LINE = re.compile(
|
|
38
36
|
r"^(task|explain|review|analyze|check|audit|describe|create|generate|refactor|bootstrap|implement|add|fix)(\s|:)",
|
|
39
37
|
re.IGNORECASE,
|
|
40
38
|
)
|
|
41
|
-
#
|
|
39
|
+
# accept inputs, input, arguments as input sections (with or without ## heading)
|
|
42
40
|
_INPUTS_SECTION = re.compile(
|
|
43
41
|
r"^(##\s+)?(inputs?|arguments?)(\s*\([^)]+\))?:?\s*$", re.IGNORECASE | re.MULTILINE
|
|
44
42
|
)
|
|
@@ -48,10 +46,10 @@ _OUTPUT_SECTION = re.compile(
|
|
|
48
46
|
|
|
49
47
|
|
|
50
48
|
def lint_template(content: str, name: str) -> list[LintMessage]:
|
|
51
|
-
"""
|
|
49
|
+
"""lint a template (conjuring) file.
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
-
|
|
51
|
+
templates should:
|
|
52
|
+
- start with a markdown heading
|
|
55
53
|
"""
|
|
56
54
|
messages: list[LintMessage] = []
|
|
57
55
|
lines = content.split("\n")
|
|
@@ -82,13 +80,13 @@ def lint_template(content: str, name: str) -> list[LintMessage]:
|
|
|
82
80
|
|
|
83
81
|
|
|
84
82
|
def lint_invocation(content: str, name: str) -> list[LintMessage]:
|
|
85
|
-
"""
|
|
83
|
+
"""lint an invocation file.
|
|
86
84
|
|
|
87
|
-
|
|
88
|
-
-
|
|
89
|
-
-
|
|
90
|
-
-
|
|
91
|
-
-
|
|
85
|
+
invocations should:
|
|
86
|
+
- start with a task: line (or similar verb)
|
|
87
|
+
- have an inputs section (warning if missing)
|
|
88
|
+
- have an output section (warning if missing)
|
|
89
|
+
- document all placeholders used
|
|
92
90
|
"""
|
|
93
91
|
messages: list[LintMessage] = []
|
|
94
92
|
lines = content.split("\n")
|
|
@@ -104,7 +102,6 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
|
|
|
104
102
|
)
|
|
105
103
|
return messages
|
|
106
104
|
|
|
107
|
-
# Check first line is a task line
|
|
108
105
|
first_line = lines[0].strip()
|
|
109
106
|
if not _TASK_LINE.match(first_line):
|
|
110
107
|
messages.append(
|
|
@@ -116,7 +113,6 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
|
|
|
116
113
|
)
|
|
117
114
|
)
|
|
118
115
|
|
|
119
|
-
# Check for inputs section
|
|
120
116
|
if not _INPUTS_SECTION.search(content):
|
|
121
117
|
messages.append(
|
|
122
118
|
LintMessage(
|
|
@@ -127,7 +123,6 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
|
|
|
127
123
|
)
|
|
128
124
|
)
|
|
129
125
|
|
|
130
|
-
# Check for output section
|
|
131
126
|
if not _OUTPUT_SECTION.search(content):
|
|
132
127
|
messages.append(
|
|
133
128
|
LintMessage(
|
|
@@ -138,16 +133,14 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
|
|
|
138
133
|
)
|
|
139
134
|
)
|
|
140
135
|
|
|
141
|
-
# Find all placeholders and check if they're documented
|
|
142
136
|
positional = set(_POSITIONAL_PLACEHOLDER.findall(content))
|
|
143
137
|
named = set(_NAMED_PLACEHOLDER.findall(content))
|
|
144
138
|
|
|
145
|
-
#
|
|
146
|
-
# Prefer checking the inputs section if it exists
|
|
139
|
+
# loose check: prefer the inputs section if it exists, else the whole file
|
|
147
140
|
inputs_match = _INPUTS_SECTION.search(content)
|
|
148
141
|
if inputs_match:
|
|
149
142
|
start = inputs_match.end()
|
|
150
|
-
#
|
|
143
|
+
# look ahead for the next markdown heading or end of file
|
|
151
144
|
next_heading = re.search(r"^#", content[start:], re.MULTILINE)
|
|
152
145
|
search_area = (
|
|
153
146
|
content[start : start + next_heading.start()]
|
|
@@ -158,10 +151,14 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
|
|
|
158
151
|
search_area = content.lower()
|
|
159
152
|
|
|
160
153
|
for placeholder in named:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
)
|
|
154
|
+
tag = f"{{{{{placeholder.lower()}}}}}"
|
|
155
|
+
stripped = search_area.replace(tag, "")
|
|
156
|
+
# accept the placeholder as documented if its bare name appears as a
|
|
157
|
+
# standalone word (after stripping {{…}} syntax) OR if the {{…}} tag
|
|
158
|
+
# itself appears in the inputs section (the common "- {{name}}: …"
|
|
159
|
+
# documentation pattern).
|
|
160
|
+
bare_match = re.search(r"\b" + re.escape(placeholder.lower()) + r"\b", stripped)
|
|
161
|
+
if not bare_match and tag not in search_area:
|
|
165
162
|
messages.append(
|
|
166
163
|
LintMessage(
|
|
167
164
|
level="warning",
|
|
@@ -171,7 +168,6 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
|
|
|
171
168
|
)
|
|
172
169
|
)
|
|
173
170
|
|
|
174
|
-
# Check for undocumented positional args
|
|
175
171
|
for p in positional:
|
|
176
172
|
if p == "ARGUMENTS":
|
|
177
173
|
continue
|
|
@@ -189,18 +185,17 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
|
|
|
189
185
|
return messages
|
|
190
186
|
|
|
191
187
|
|
|
192
|
-
# Type alias for linter functions
|
|
193
188
|
LinterFunc = Callable[[str, str], list[LintMessage]]
|
|
194
189
|
|
|
195
190
|
|
|
196
191
|
def load_linters(kind: Literal["conjurings", "invocations"]) -> list[LinterFunc]:
|
|
197
|
-
"""
|
|
192
|
+
"""load linter plugins for the given kind.
|
|
198
193
|
|
|
199
194
|
Args:
|
|
200
|
-
kind:
|
|
195
|
+
kind: either "conjurings" or "invocations".
|
|
201
196
|
|
|
202
197
|
Returns:
|
|
203
|
-
|
|
198
|
+
list of linter functions from plugins.
|
|
204
199
|
"""
|
|
205
200
|
return load_plugins(
|
|
206
201
|
f"familiar.linters.{kind}",
|
|
@@ -213,7 +208,7 @@ def load_linters(kind: Literal["conjurings", "invocations"]) -> list[LinterFunc]
|
|
|
213
208
|
def lint_snippet_references(
|
|
214
209
|
repo_root: Path, content: str, name: str
|
|
215
210
|
) -> list[LintMessage]:
|
|
216
|
-
"""
|
|
211
|
+
"""check that all snippet includes reference existing snippets."""
|
|
217
212
|
messages: list[LintMessage] = []
|
|
218
213
|
for i, line in enumerate(content.split("\n"), 1):
|
|
219
214
|
for m in _SNIPPET_INCLUDE.finditer(line):
|
|
@@ -238,7 +233,7 @@ def lint_collection(
|
|
|
238
233
|
builtin_linter: LinterFunc,
|
|
239
234
|
plugin_linters: list[LinterFunc],
|
|
240
235
|
) -> list[LintMessage]:
|
|
241
|
-
"""
|
|
236
|
+
"""lint a collection of items (conjurings or invocations)."""
|
|
242
237
|
messages: list[LintMessage] = []
|
|
243
238
|
for name, _, is_local in list_items(repo_root, kind):
|
|
244
239
|
try:
|
|
@@ -248,11 +243,8 @@ def lint_collection(
|
|
|
248
243
|
if is_local
|
|
249
244
|
else f"(builtin) {kind}/{name}.md"
|
|
250
245
|
)
|
|
251
|
-
# Built-in linter
|
|
252
246
|
messages.extend(builtin_linter(content, prefix))
|
|
253
|
-
# Snippet reference validation
|
|
254
247
|
messages.extend(lint_snippet_references(repo_root, content, prefix))
|
|
255
|
-
# Plugin linters
|
|
256
248
|
for linter in plugin_linters:
|
|
257
249
|
try:
|
|
258
250
|
messages.extend(linter(content, prefix))
|
|
@@ -265,7 +257,7 @@ def lint_collection(
|
|
|
265
257
|
message=f"plugin linter failed: {e}",
|
|
266
258
|
)
|
|
267
259
|
)
|
|
268
|
-
except
|
|
260
|
+
except NotFoundError as e:
|
|
269
261
|
messages.append(
|
|
270
262
|
LintMessage(
|
|
271
263
|
level="error",
|
|
@@ -278,24 +270,20 @@ def lint_collection(
|
|
|
278
270
|
|
|
279
271
|
|
|
280
272
|
def lint_all(repo_root: Path) -> list[LintMessage]:
|
|
281
|
-
"""
|
|
273
|
+
"""lint all conjurings and invocations.
|
|
282
274
|
|
|
283
|
-
|
|
275
|
+
runs built-in linters and any plugin linters registered via entry points.
|
|
284
276
|
|
|
285
|
-
|
|
277
|
+
returns a list of lint messages (errors and warnings).
|
|
286
278
|
"""
|
|
287
279
|
messages: list[LintMessage] = []
|
|
288
280
|
|
|
289
|
-
# Load plugin linters
|
|
290
281
|
conjuring_linters = load_linters("conjurings")
|
|
291
282
|
invocation_linters = load_linters("invocations")
|
|
292
283
|
|
|
293
|
-
# Lint conjurings
|
|
294
284
|
messages.extend(
|
|
295
285
|
lint_collection(repo_root, "conjurings", lint_template, conjuring_linters)
|
|
296
286
|
)
|
|
297
|
-
|
|
298
|
-
# Lint invocations
|
|
299
287
|
messages.extend(
|
|
300
288
|
lint_collection(repo_root, "invocations", lint_invocation, invocation_linters)
|
|
301
289
|
)
|