familiar-cli 0.5.1__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.1/src/familiar_cli.egg-info → familiar_cli-0.5.2}/PKG-INFO +1 -1
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/pyproject.toml +1 -1
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/_plugins.py +7 -7
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/agents.py +13 -13
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/cli.py +12 -17
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/lint.py +31 -43
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/render.py +50 -31
- {familiar_cli-0.5.1 → familiar_cli-0.5.2/src/familiar_cli.egg-info}/PKG-INFO +1 -1
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/tests/test_cli.py +0 -6
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/tests/test_integration.py +0 -3
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/tests/test_lint.py +28 -5
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/tests/test_render.py +79 -2
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/LICENSE +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/readme.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/setup.cfg +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/__init__.py +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/core.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/data.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/docs.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/frontend.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/infra.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/python.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/rust.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/sec.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/__noop__.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/add-ci.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/add-tests.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/audit.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/bootstrap-python.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/code-review.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/explain.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/implement-feature.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/infra-change.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/performance.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/refactor.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/release.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/security-review.md +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/node/github-ci.yml +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/python/github-ci.yml +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/python/pyproject.toml +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/py.typed +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/SOURCES.txt +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/entry_points.txt +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/requires.txt +0 -0
- {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/top_level.txt +0 -0
- {familiar_cli-0.5.1 → 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
|
|
|
@@ -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,12 +42,12 @@ 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
|
|
|
50
49
|
def _resolve_agent(name: str) -> "Agent":
|
|
51
|
-
"""
|
|
50
|
+
"""look up an agent by name, raising CliError on unknown names."""
|
|
52
51
|
try:
|
|
53
52
|
return get_agent(name)
|
|
54
53
|
except KeyError as e:
|
|
@@ -56,7 +55,7 @@ def _resolve_agent(name: str) -> "Agent":
|
|
|
56
55
|
|
|
57
56
|
|
|
58
57
|
def _write_file(path: Path, content: str, label: str = "") -> None:
|
|
59
|
-
"""
|
|
58
|
+
"""write content to path, creating parent dirs. wraps OSError as CliError."""
|
|
60
59
|
prefix = f"{label} " if label else ""
|
|
61
60
|
try:
|
|
62
61
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -69,9 +68,9 @@ def _write_file(path: Path, content: str, label: str = "") -> None:
|
|
|
69
68
|
|
|
70
69
|
|
|
71
70
|
def find_repo_root(start: Path) -> Path:
|
|
72
|
-
"""
|
|
71
|
+
"""find the repository root by looking for .git directory.
|
|
73
72
|
|
|
74
|
-
|
|
73
|
+
walks up from start directory. falls back to start directory itself
|
|
75
74
|
if no .git is found, allowing use outside of git repositories.
|
|
76
75
|
"""
|
|
77
76
|
cur = start.resolve()
|
|
@@ -82,7 +81,7 @@ def find_repo_root(start: Path) -> Path:
|
|
|
82
81
|
|
|
83
82
|
|
|
84
83
|
def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
|
|
85
|
-
"""
|
|
84
|
+
"""best-effort removal of a git worktree."""
|
|
86
85
|
try:
|
|
87
86
|
result = subprocess.run(
|
|
88
87
|
[
|
|
@@ -107,8 +106,7 @@ def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
|
|
|
107
106
|
|
|
108
107
|
|
|
109
108
|
def create_worktree(repo_root: Path) -> Path:
|
|
110
|
-
"""
|
|
111
|
-
# Ensure it's a git repo
|
|
109
|
+
"""create a temporary git worktree for the repository."""
|
|
112
110
|
try:
|
|
113
111
|
subprocess.run(
|
|
114
112
|
["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
|
|
@@ -122,7 +120,7 @@ def create_worktree(repo_root: Path) -> Path:
|
|
|
122
120
|
|
|
123
121
|
tmpdir = tempfile.mkdtemp(prefix="familiar-")
|
|
124
122
|
# git worktree add requires the directory to NOT exist.
|
|
125
|
-
# mkdtemp creates it.
|
|
123
|
+
# mkdtemp creates it. let's remove it and let git create it.
|
|
126
124
|
os.rmdir(tmpdir)
|
|
127
125
|
|
|
128
126
|
try:
|
|
@@ -155,7 +153,7 @@ def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
|
|
|
155
153
|
def write_skill(
|
|
156
154
|
repo_root: Path, agent_name: str, invocation: str, skill_name: str, prompt: str
|
|
157
155
|
) -> Path:
|
|
158
|
-
"""
|
|
156
|
+
"""write a reusable skill file for supported agents."""
|
|
159
157
|
if not _VALID_NAME.match(skill_name):
|
|
160
158
|
raise CliError(
|
|
161
159
|
f"invalid skill name: {skill_name}",
|
|
@@ -188,7 +186,7 @@ def write_subagent(
|
|
|
188
186
|
subagent_name: str,
|
|
189
187
|
system: str,
|
|
190
188
|
) -> Path:
|
|
191
|
-
"""
|
|
189
|
+
"""write a reusable subagent file for supported agents."""
|
|
192
190
|
if not _VALID_NAME.match(subagent_name):
|
|
193
191
|
raise CliError(
|
|
194
192
|
f"invalid subagent name: {subagent_name}",
|
|
@@ -291,7 +289,7 @@ def cmd_invoke(args: argparse.Namespace) -> int:
|
|
|
291
289
|
target_dir = create_worktree(repo_root)
|
|
292
290
|
print(f"created worktree at {target_dir}")
|
|
293
291
|
|
|
294
|
-
#
|
|
292
|
+
# best-effort copy of instruction file into the worktree
|
|
295
293
|
try:
|
|
296
294
|
agent = get_agent(args.agent)
|
|
297
295
|
instr_file = repo_root / agent.output_file
|
|
@@ -332,7 +330,6 @@ def cmd_list(args: argparse.Namespace) -> int:
|
|
|
332
330
|
repo_root = find_repo_root(Path(args.into or os.getcwd()))
|
|
333
331
|
|
|
334
332
|
if args.kind is None:
|
|
335
|
-
# list all
|
|
336
333
|
conjurings = list_items(repo_root, "conjurings")
|
|
337
334
|
invocations = list_items(repo_root, "invocations")
|
|
338
335
|
snippets = list_snippets(repo_root)
|
|
@@ -363,7 +360,6 @@ def cmd_lint(args: argparse.Namespace) -> int:
|
|
|
363
360
|
|
|
364
361
|
messages = lint_all(repo_root)
|
|
365
362
|
|
|
366
|
-
# Filter by level if requested
|
|
367
363
|
if args.errors_only:
|
|
368
364
|
messages = [m for m in messages if m.level == "error"]
|
|
369
365
|
|
|
@@ -371,7 +367,6 @@ def cmd_lint(args: argparse.Namespace) -> int:
|
|
|
371
367
|
print("all checks passed")
|
|
372
368
|
return EXIT_SUCCESS
|
|
373
369
|
|
|
374
|
-
# Group by level for output
|
|
375
370
|
errors = [m for m in messages if m.level == "error"]
|
|
376
371
|
warnings = [m for m in messages if m.level == "warning"]
|
|
377
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))
|
|
@@ -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
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""render system and user prompts from conjurings and invocations."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -15,14 +15,15 @@ from pathlib import Path
|
|
|
15
15
|
_VALID_NAME = re.compile(r"^[a-z0-9_-]+$")
|
|
16
16
|
_VALID_SNIPPET_PATH = re.compile(r"^[a-zA-Z0-9_.-]+(/[a-zA-Z0-9_.-]+)+$")
|
|
17
17
|
_SNIPPET_INCLUDE = re.compile(r"{{>\s*snippet:([^}]+?)\s*}}")
|
|
18
|
+
_MAX_INCLUDE_DEPTH = 50
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
class NotFoundError(Exception):
|
|
21
|
-
"""
|
|
22
|
+
"""raised when a conjuring, invocation, or snippet is not found."""
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def _safe_read_text(path: Path, label: str) -> str:
|
|
25
|
-
"""
|
|
26
|
+
"""read a UTF-8 text file, converting I/O errors to :class:`NotFoundError`."""
|
|
26
27
|
try:
|
|
27
28
|
return path.read_text(encoding="utf-8")
|
|
28
29
|
except PermissionError:
|
|
@@ -32,7 +33,7 @@ def _safe_read_text(path: Path, label: str) -> str:
|
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
def load_text(repo_root: Path, kind: str, name: str) -> str:
|
|
35
|
-
"""
|
|
36
|
+
"""load a conjuring or invocation; local overrides in .familiar override package data."""
|
|
36
37
|
if not _VALID_NAME.match(name):
|
|
37
38
|
raise NotFoundError(f"invalid {kind.rstrip('s')} name: {name}")
|
|
38
39
|
override = repo_root / ".familiar" / kind / f"{name}.md"
|
|
@@ -48,7 +49,7 @@ def load_text(repo_root: Path, kind: str, name: str) -> str:
|
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
def load_snippet(repo_root: Path, path: str) -> str:
|
|
51
|
-
"""
|
|
52
|
+
"""load a snippet by path; local overrides in .familiar/snippets/ win."""
|
|
52
53
|
path = path.strip()
|
|
53
54
|
if ".." in path.split("/"):
|
|
54
55
|
raise NotFoundError(f"invalid snippet path: {path}")
|
|
@@ -70,16 +71,33 @@ def load_snippet(repo_root: Path, path: str) -> str:
|
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
def resolve_includes(repo_root: Path, text: str) -> str:
|
|
73
|
-
"""
|
|
74
|
+
"""resolve {{> snippet:path}} includes, expanding nested includes recursively.
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
a snippet may include other snippets. include cycles (self-includes or
|
|
77
|
+
a -> b -> a) raise :class:`NotFoundError` naming the chain; a max-depth
|
|
78
|
+
backstop guards against pathologically deep nesting.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def expand(text: str, chain: list[str]) -> str:
|
|
82
|
+
def repl(m: re.Match[str]) -> str:
|
|
83
|
+
path = m.group(1).strip()
|
|
84
|
+
if path in chain:
|
|
85
|
+
trail = " -> ".join([*chain, path])
|
|
86
|
+
raise NotFoundError(f"snippet include cycle: {trail}")
|
|
87
|
+
if len(chain) >= _MAX_INCLUDE_DEPTH:
|
|
88
|
+
trail = " -> ".join([*chain, path])
|
|
89
|
+
raise NotFoundError(
|
|
90
|
+
f"snippet include depth exceeded ({_MAX_INCLUDE_DEPTH}): {trail}"
|
|
91
|
+
)
|
|
92
|
+
return expand(load_snippet(repo_root, path), [*chain, path])
|
|
93
|
+
|
|
94
|
+
return _SNIPPET_INCLUDE.sub(repl, text)
|
|
77
95
|
|
|
78
|
-
return
|
|
96
|
+
return expand(text, [])
|
|
79
97
|
|
|
80
98
|
|
|
81
99
|
def substitute(text: str, args: list[str], kv: dict[str, str]) -> str:
|
|
82
|
-
"""
|
|
100
|
+
"""substitute $1, $2, ... $ARGUMENTS and {{key}} placeholders in a single pass."""
|
|
83
101
|
missing: list[str] = []
|
|
84
102
|
|
|
85
103
|
def repl(m: re.Match[str]) -> str:
|
|
@@ -96,13 +114,16 @@ def substitute(text: str, args: list[str], kv: dict[str, str]) -> str:
|
|
|
96
114
|
return ""
|
|
97
115
|
|
|
98
116
|
if named_ident:
|
|
99
|
-
|
|
117
|
+
if named_ident in kv:
|
|
118
|
+
return kv[named_ident]
|
|
119
|
+
missing.append(m.group(0))
|
|
120
|
+
return m.group(0)
|
|
100
121
|
|
|
101
122
|
return m.group(0)
|
|
102
123
|
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
124
|
+
# combined regex for both types of placeholders:
|
|
125
|
+
# group 1: \$(ARGUMENTS|\d+)
|
|
126
|
+
# group 2: {{(\w+)}}
|
|
106
127
|
pattern = re.compile(r"\$(ARGUMENTS|\d+)|{{(\w+)}}")
|
|
107
128
|
text = pattern.sub(repl, text)
|
|
108
129
|
|
|
@@ -112,7 +133,7 @@ def substitute(text: str, args: list[str], kv: dict[str, str]) -> str:
|
|
|
112
133
|
|
|
113
134
|
|
|
114
135
|
def _walk_traversable(root: Traversable, prefix: str = "") -> list[tuple[str, str]]:
|
|
115
|
-
"""
|
|
136
|
+
"""recursively walk a Traversable, returning (relative_path, first_line) pairs."""
|
|
116
137
|
items: list[tuple[str, str]] = []
|
|
117
138
|
try:
|
|
118
139
|
for item in sorted(root.iterdir(), key=lambda x: x.name):
|
|
@@ -139,23 +160,22 @@ def _list_resources(
|
|
|
139
160
|
recursive: bool = False,
|
|
140
161
|
suffix: str = "",
|
|
141
162
|
) -> list[tuple[str, str, bool]]:
|
|
142
|
-
"""
|
|
163
|
+
"""list resources from package data and local overrides.
|
|
143
164
|
|
|
144
|
-
|
|
165
|
+
items from local ``.familiar/{local_subdir}/`` override package builtins.
|
|
145
166
|
|
|
146
167
|
Args:
|
|
147
|
-
repo_root:
|
|
148
|
-
pkg:
|
|
149
|
-
local_subdir:
|
|
150
|
-
recursive:
|
|
151
|
-
suffix:
|
|
168
|
+
repo_root: repository root path.
|
|
169
|
+
pkg: package name to scan for builtins.
|
|
170
|
+
local_subdir: subdirectory name under ``.familiar/`` for local overrides.
|
|
171
|
+
recursive: if True, scan recursively (for nested resources like snippets).
|
|
172
|
+
suffix: file suffix filter (e.g. ``".md"``). empty string matches all files.
|
|
152
173
|
|
|
153
174
|
Returns:
|
|
154
|
-
|
|
175
|
+
sorted list of ``(key, first_line, is_local)`` tuples.
|
|
155
176
|
"""
|
|
156
177
|
items: dict[str, tuple[str, bool]] = {}
|
|
157
178
|
|
|
158
|
-
# built-in resources from package data
|
|
159
179
|
try:
|
|
160
180
|
pkg_root = resources.files(pkg)
|
|
161
181
|
if recursive:
|
|
@@ -177,7 +197,6 @@ def _list_resources(
|
|
|
177
197
|
except (FileNotFoundError, TypeError, ModuleNotFoundError):
|
|
178
198
|
pass
|
|
179
199
|
|
|
180
|
-
# local overrides
|
|
181
200
|
local_dir = repo_root / ".familiar" / local_subdir
|
|
182
201
|
if local_dir.is_dir():
|
|
183
202
|
files = (
|
|
@@ -201,9 +220,9 @@ def _list_resources(
|
|
|
201
220
|
|
|
202
221
|
|
|
203
222
|
def list_snippets(repo_root: Path) -> list[tuple[str, str, bool]]:
|
|
204
|
-
"""
|
|
223
|
+
"""list available snippets.
|
|
205
224
|
|
|
206
|
-
|
|
225
|
+
returns list of (path, first_line, is_local) tuples, sorted by path.
|
|
207
226
|
"""
|
|
208
227
|
return _list_resources(
|
|
209
228
|
repo_root, "familiar.data.snippets", "snippets", recursive=True
|
|
@@ -211,15 +230,15 @@ def list_snippets(repo_root: Path) -> list[tuple[str, str, bool]]:
|
|
|
211
230
|
|
|
212
231
|
|
|
213
232
|
def list_items(repo_root: Path, kind: str) -> list[tuple[str, str, bool]]:
|
|
214
|
-
"""
|
|
233
|
+
"""list available conjurings or invocations.
|
|
215
234
|
|
|
216
|
-
|
|
235
|
+
returns list of (name, first_line, is_local) tuples, sorted by name.
|
|
217
236
|
"""
|
|
218
237
|
return _list_resources(repo_root, f"familiar.data.{kind}", kind, suffix=".md")
|
|
219
238
|
|
|
220
239
|
|
|
221
240
|
def compose_system(repo_root: Path, conjurings: list[str]) -> str:
|
|
222
|
-
"""
|
|
241
|
+
"""compose system instructions from core + selected conjurings."""
|
|
223
242
|
core = load_text(repo_root, "conjurings", "core").strip()
|
|
224
243
|
parts: list[str] = [core]
|
|
225
244
|
for name in conjurings:
|
|
@@ -230,7 +249,7 @@ def compose_system(repo_root: Path, conjurings: list[str]) -> str:
|
|
|
230
249
|
def render_invocation(
|
|
231
250
|
repo_root: Path, invocation: str, args: list[str], kv: dict[str, str]
|
|
232
251
|
) -> str:
|
|
233
|
-
"""
|
|
252
|
+
"""render an invocation with snippet inclusion and argument substitution."""
|
|
234
253
|
inv = load_text(repo_root, "invocations", invocation).strip()
|
|
235
254
|
inv = resolve_includes(repo_root, inv)
|
|
236
255
|
return substitute(inv, args, kv)
|
|
@@ -321,7 +321,6 @@ class TestCmdInvoke:
|
|
|
321
321
|
)
|
|
322
322
|
result = cmd_invoke(args)
|
|
323
323
|
assert result == 0
|
|
324
|
-
# check that the prompt is the rendered invocation
|
|
325
324
|
call_args = mock_run.call_args
|
|
326
325
|
prompt = call_args[0][2]
|
|
327
326
|
assert "explain" in prompt.lower()
|
|
@@ -344,7 +343,6 @@ class TestCmdInvoke:
|
|
|
344
343
|
cmd_invoke(args)
|
|
345
344
|
|
|
346
345
|
def test_kv_args_passed(self, tmp_path):
|
|
347
|
-
# create custom invocation that uses kv
|
|
348
346
|
inv_dir = tmp_path / ".familiar" / "invocations"
|
|
349
347
|
inv_dir.mkdir(parents=True)
|
|
350
348
|
(inv_dir / "custom.md").write_text("value is {{mykey}}")
|
|
@@ -410,7 +408,6 @@ class TestCmdInvoke:
|
|
|
410
408
|
) as mock_wt:
|
|
411
409
|
with patch("familiar.cli.run_agent", return_value=0) as mock_run:
|
|
412
410
|
with patch("shutil.copy2") as mock_copy:
|
|
413
|
-
# Create instruction file to be copied
|
|
414
411
|
(tmp_path / "CLAUDE.md").write_text("instr")
|
|
415
412
|
|
|
416
413
|
args = argparse.Namespace(
|
|
@@ -429,9 +426,7 @@ class TestCmdInvoke:
|
|
|
429
426
|
result = cmd_invoke(args)
|
|
430
427
|
assert result == 0
|
|
431
428
|
mock_wt.assert_called_once()
|
|
432
|
-
# Check that run_agent was called with the worktree path
|
|
433
429
|
assert mock_run.call_args[0][0] == tmp_path / "wt"
|
|
434
|
-
# Check that instruction file was copied
|
|
435
430
|
mock_copy.assert_called_once()
|
|
436
431
|
|
|
437
432
|
def test_invoke_worktree_cleaned_up_on_error(self, tmp_path):
|
|
@@ -571,7 +566,6 @@ class TestCmdList:
|
|
|
571
566
|
)
|
|
572
567
|
cmd_list(args)
|
|
573
568
|
captured = capsys.readouterr()
|
|
574
|
-
# verbose mode includes first line after colon
|
|
575
569
|
assert ":" in captured.out
|
|
576
570
|
|
|
577
571
|
def test_list_local_marked(self, tmp_path, capsys):
|
|
@@ -223,7 +223,6 @@ class TestListIntegration:
|
|
|
223
223
|
text=True,
|
|
224
224
|
)
|
|
225
225
|
assert result.returncode == 0
|
|
226
|
-
# verbose includes description after colon
|
|
227
226
|
assert ":" in result.stdout
|
|
228
227
|
|
|
229
228
|
def test_list_with_local_override(self, tmp_path):
|
|
@@ -267,7 +266,6 @@ class TestSnippetIntegration:
|
|
|
267
266
|
from familiar.render import render_invocation
|
|
268
267
|
|
|
269
268
|
result = render_invocation(tmp_path, "bootstrap-python", ["myapp", "cli"], {})
|
|
270
|
-
# The snippet content should be inlined
|
|
271
269
|
assert "[project]" in result
|
|
272
270
|
assert "pyproject.toml" not in result or "snippet:" not in result
|
|
273
271
|
|
|
@@ -276,7 +274,6 @@ class TestSnippetIntegration:
|
|
|
276
274
|
from familiar.render import render_invocation
|
|
277
275
|
|
|
278
276
|
result = render_invocation(tmp_path, "add-ci", ["github", "python"], {})
|
|
279
|
-
# Should contain inlined workflow, not the snippet directive
|
|
280
277
|
assert "{{> snippet:" not in result
|
|
281
278
|
assert "actions/checkout" in result
|
|
282
279
|
|
|
@@ -177,12 +177,38 @@ output
|
|
|
177
177
|
placeholder_warnings = [m for m in messages if "{{myarg}}" in m.message]
|
|
178
178
|
assert placeholder_warnings == []
|
|
179
179
|
|
|
180
|
+
def test_placeholder_substring_in_other_word_warns(self):
|
|
181
|
+
content = """task: process {{name}}
|
|
182
|
+
|
|
183
|
+
inputs
|
|
184
|
+
- filename (required): the file to process
|
|
185
|
+
|
|
186
|
+
output
|
|
187
|
+
- results
|
|
188
|
+
"""
|
|
189
|
+
messages = lint_invocation(content, "test.md")
|
|
190
|
+
placeholder_warnings = [m for m in messages if "{{name}}" in m.message]
|
|
191
|
+
assert len(placeholder_warnings) == 1
|
|
192
|
+
assert placeholder_warnings[0].level == "warning"
|
|
193
|
+
|
|
194
|
+
def test_placeholder_tag_in_inputs_section_no_warning(self):
|
|
195
|
+
content = """task: implement {{spec}}
|
|
196
|
+
|
|
197
|
+
inputs
|
|
198
|
+
- {{spec}} (required): feature specification
|
|
199
|
+
|
|
200
|
+
output
|
|
201
|
+
- results
|
|
202
|
+
"""
|
|
203
|
+
messages = lint_invocation(content, "test.md")
|
|
204
|
+
placeholder_warnings = [m for m in messages if "{{spec}}" in m.message]
|
|
205
|
+
assert placeholder_warnings == []
|
|
206
|
+
|
|
180
207
|
|
|
181
208
|
class TestLintAll:
|
|
182
209
|
"""tests for linting all conjurings and invocations."""
|
|
183
210
|
|
|
184
211
|
def test_lint_all_builtins(self, tmp_path):
|
|
185
|
-
# should lint all built-ins without errors
|
|
186
212
|
messages = lint_all(tmp_path)
|
|
187
213
|
errors = [m for m in messages if m.level == "error"]
|
|
188
214
|
assert errors == [], f"Unexpected errors: {errors}"
|
|
@@ -255,13 +281,12 @@ class TestLinterPlugins:
|
|
|
255
281
|
mock_ep.name = "test"
|
|
256
282
|
mock_ep.load.return_value = mock_linter
|
|
257
283
|
|
|
258
|
-
# Create a local template to lint
|
|
259
284
|
templates = tmp_path / ".familiar" / "conjurings"
|
|
260
285
|
templates.mkdir(parents=True)
|
|
261
286
|
(templates / "mytemplate.md").write_text("# test")
|
|
262
287
|
|
|
263
288
|
with patch("familiar._plugins.entry_points") as mock_entry_points:
|
|
264
|
-
#
|
|
289
|
+
# return the mock for conjurings, empty for invocations
|
|
265
290
|
def ep_side_effect(group):
|
|
266
291
|
if group == "familiar.linters.conjurings":
|
|
267
292
|
return [mock_ep]
|
|
@@ -270,7 +295,6 @@ class TestLinterPlugins:
|
|
|
270
295
|
mock_entry_points.side_effect = ep_side_effect
|
|
271
296
|
lint_all(tmp_path)
|
|
272
297
|
|
|
273
|
-
# Should have been called for the local template
|
|
274
298
|
local_calls = [c for c in calls if "mytemplate" in c[1]]
|
|
275
299
|
assert len(local_calls) == 1
|
|
276
300
|
|
|
@@ -284,7 +308,6 @@ class TestLinterPlugins:
|
|
|
284
308
|
mock_ep.name = "bad"
|
|
285
309
|
mock_ep.load.return_value = bad_linter
|
|
286
310
|
|
|
287
|
-
# Create a local template
|
|
288
311
|
templates = tmp_path / ".familiar" / "conjurings"
|
|
289
312
|
templates.mkdir(parents=True)
|
|
290
313
|
(templates / "test.md").write_text("# test")
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
7
|
from familiar.render import (
|
|
8
|
+
_MAX_INCLUDE_DEPTH,
|
|
8
9
|
NotFoundError,
|
|
9
10
|
compose_system,
|
|
10
11
|
list_items,
|
|
@@ -62,6 +63,27 @@ class TestSubstitute:
|
|
|
62
63
|
result = substitute(text, ["first", "second"], {})
|
|
63
64
|
assert result == "second before first"
|
|
64
65
|
|
|
66
|
+
def test_missing_named_warns(self, capsys):
|
|
67
|
+
text = "spec: {{spec}}"
|
|
68
|
+
result = substitute(text, [], {})
|
|
69
|
+
assert result == "spec: {{spec}}"
|
|
70
|
+
captured = capsys.readouterr()
|
|
71
|
+
assert "warning: missing arguments: {{spec}}" in captured.err
|
|
72
|
+
|
|
73
|
+
def test_present_named_no_warning(self, capsys):
|
|
74
|
+
text = "spec: {{spec}}"
|
|
75
|
+
result = substitute(text, [], {"spec": "caching"})
|
|
76
|
+
assert result == "spec: caching"
|
|
77
|
+
captured = capsys.readouterr()
|
|
78
|
+
assert captured.err == ""
|
|
79
|
+
|
|
80
|
+
def test_missing_mixed_reports_both(self, capsys):
|
|
81
|
+
text = "$1 and {{spec}} and $2"
|
|
82
|
+
result = substitute(text, ["only_one"], {})
|
|
83
|
+
assert result == "only_one and {{spec}} and "
|
|
84
|
+
captured = capsys.readouterr()
|
|
85
|
+
assert "warning: missing arguments: {{spec}}, $2" in captured.err
|
|
86
|
+
|
|
65
87
|
|
|
66
88
|
class TestLoadText:
|
|
67
89
|
"""tests for loading conjurings and invocations."""
|
|
@@ -129,7 +151,6 @@ class TestComposeSystem:
|
|
|
129
151
|
assert "python" in system.lower()
|
|
130
152
|
|
|
131
153
|
def test_compose_order(self, tmp_path):
|
|
132
|
-
# create local overrides to control content
|
|
133
154
|
templates = tmp_path / ".familiar" / "conjurings"
|
|
134
155
|
templates.mkdir(parents=True)
|
|
135
156
|
(templates / "core.md").write_text("CORE")
|
|
@@ -137,7 +158,6 @@ class TestComposeSystem:
|
|
|
137
158
|
(templates / "second.md").write_text("SECOND")
|
|
138
159
|
|
|
139
160
|
system = compose_system(tmp_path, ["first", "second"])
|
|
140
|
-
# verify order: core, then conjurings in order
|
|
141
161
|
assert system == "CORE\n\nFIRST\n\nSECOND"
|
|
142
162
|
|
|
143
163
|
def test_compose_core_only(self, tmp_path):
|
|
@@ -417,6 +437,63 @@ class TestResolveIncludes:
|
|
|
417
437
|
result = resolve_includes(tmp_path, text)
|
|
418
438
|
assert result == "content"
|
|
419
439
|
|
|
440
|
+
def test_nested_include_resolves(self, tmp_path):
|
|
441
|
+
"""a snippet that includes another snippet is expanded fully."""
|
|
442
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
443
|
+
snippet_dir.mkdir(parents=True)
|
|
444
|
+
(snippet_dir / "outer.txt").write_text("outer[{{> snippet:test/inner.txt}}]")
|
|
445
|
+
(snippet_dir / "inner.txt").write_text("INNER")
|
|
446
|
+
|
|
447
|
+
result = resolve_includes(tmp_path, "{{> snippet:test/outer.txt}}")
|
|
448
|
+
assert result == "outer[INNER]"
|
|
449
|
+
assert "{{> snippet" not in result
|
|
450
|
+
|
|
451
|
+
def test_deeply_nested_include_resolves(self, tmp_path):
|
|
452
|
+
"""nested includes resolve through several levels."""
|
|
453
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
454
|
+
snippet_dir.mkdir(parents=True)
|
|
455
|
+
(snippet_dir / "a.txt").write_text("a({{> snippet:test/b.txt}})")
|
|
456
|
+
(snippet_dir / "b.txt").write_text("b({{> snippet:test/c.txt}})")
|
|
457
|
+
(snippet_dir / "c.txt").write_text("c")
|
|
458
|
+
|
|
459
|
+
result = resolve_includes(tmp_path, "{{> snippet:test/a.txt}}")
|
|
460
|
+
assert result == "a(b(c))"
|
|
461
|
+
|
|
462
|
+
def test_self_include_raises_cycle(self, tmp_path):
|
|
463
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
464
|
+
snippet_dir.mkdir(parents=True)
|
|
465
|
+
(snippet_dir / "loop.txt").write_text("{{> snippet:test/loop.txt}}")
|
|
466
|
+
|
|
467
|
+
with pytest.raises(NotFoundError, match="snippet include cycle") as exc:
|
|
468
|
+
resolve_includes(tmp_path, "{{> snippet:test/loop.txt}}")
|
|
469
|
+
assert "test/loop.txt -> test/loop.txt" in str(exc.value)
|
|
470
|
+
|
|
471
|
+
def test_mutual_include_raises_cycle(self, tmp_path):
|
|
472
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
473
|
+
snippet_dir.mkdir(parents=True)
|
|
474
|
+
(snippet_dir / "a.txt").write_text("{{> snippet:test/b.txt}}")
|
|
475
|
+
(snippet_dir / "b.txt").write_text("{{> snippet:test/a.txt}}")
|
|
476
|
+
|
|
477
|
+
with pytest.raises(NotFoundError, match="snippet include cycle") as exc:
|
|
478
|
+
resolve_includes(tmp_path, "{{> snippet:test/a.txt}}")
|
|
479
|
+
assert "test/a.txt" in str(exc.value)
|
|
480
|
+
assert "test/b.txt" in str(exc.value)
|
|
481
|
+
|
|
482
|
+
def test_include_depth_backstop(self, tmp_path):
|
|
483
|
+
"""an over-deep acyclic chain raises rather than recursing without bound."""
|
|
484
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
485
|
+
snippet_dir.mkdir(parents=True)
|
|
486
|
+
chain_len = _MAX_INCLUDE_DEPTH + 2
|
|
487
|
+
for i in range(chain_len):
|
|
488
|
+
if i < chain_len - 1:
|
|
489
|
+
body = "{{> snippet:test/s" + str(i + 1) + ".txt}}"
|
|
490
|
+
else:
|
|
491
|
+
body = "end"
|
|
492
|
+
(snippet_dir / f"s{i}.txt").write_text(body)
|
|
493
|
+
|
|
494
|
+
with pytest.raises(NotFoundError, match="depth exceeded"):
|
|
495
|
+
resolve_includes(tmp_path, "{{> snippet:test/s0.txt}}")
|
|
496
|
+
|
|
420
497
|
|
|
421
498
|
class TestListSnippets:
|
|
422
499
|
"""tests for listing snippets."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/implement-feature.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|