familiar-cli 0.6.0__tar.gz → 0.6.1__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.6.0 → familiar_cli-0.6.1}/PKG-INFO +2 -2
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/pyproject.toml +2 -2
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/lint.py +222 -14
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/render.py +20 -4
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar_cli.egg-info/PKG-INFO +2 -2
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar_cli.egg-info/requires.txt +1 -1
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/tests/test_lint.py +557 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/tests/test_render.py +52 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/LICENSE +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/readme.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/setup.cfg +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/__init__.py +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/_plugins.py +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/agents.py +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/cli.py +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/core.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/data.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/docs.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/frontend.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/infra.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/python.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/rust.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/sec.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/__noop__.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/add-ci.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/add-tests.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/audit.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/bootstrap-python.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/code-review.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/explain.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/implement-feature.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/infra-change.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/performance.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/refactor.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/release.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/security-review.md +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/node/github-ci.yml +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/python/github-ci.yml +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/python/pyproject.toml +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/py.typed +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar_cli.egg-info/SOURCES.txt +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar_cli.egg-info/entry_points.txt +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar_cli.egg-info/top_level.txt +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/tests/test_agents.py +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/tests/test_cli.py +0 -0
- {familiar_cli-0.6.0 → familiar_cli-0.6.1}/tests/test_integration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: familiar-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: compose and invoke ai agent prompts from reusable templates
|
|
5
5
|
Author-email: cyberwitchery lab <contact@cyberwitchery.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,7 +20,7 @@ Requires-Python: >=3.10
|
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
License-File: LICENSE
|
|
22
22
|
Provides-Extra: dev
|
|
23
|
-
Requires-Dist: ruff==0.16.
|
|
23
|
+
Requires-Dist: ruff==0.16.5; extra == "dev"
|
|
24
24
|
Requires-Dist: mypy; extra == "dev"
|
|
25
25
|
Requires-Dist: pytest; extra == "dev"
|
|
26
26
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "familiar-cli"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.1"
|
|
4
4
|
description = "compose and invoke ai agent prompts from reusable templates"
|
|
5
5
|
readme = "readme.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -36,7 +36,7 @@ claude = "familiar.agents:ClaudeAgent"
|
|
|
36
36
|
package-dir = {"" = "src"}
|
|
37
37
|
|
|
38
38
|
[project.optional-dependencies]
|
|
39
|
-
dev = ["ruff==0.16.
|
|
39
|
+
dev = ["ruff==0.16.5", "mypy", "pytest", "pytest-cov", "bandit"]
|
|
40
40
|
|
|
41
41
|
[tool.setuptools.package-data]
|
|
42
42
|
"familiar.data.conjurings" = ["*.md"]
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
-
from collections.abc import Callable
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
7
|
from dataclasses import dataclass
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Literal
|
|
@@ -47,11 +47,220 @@ _TASK_LINE = re.compile(
|
|
|
47
47
|
)
|
|
48
48
|
# accept inputs, input, arguments as input sections (with or without ## heading)
|
|
49
49
|
_INPUTS_SECTION = re.compile(
|
|
50
|
-
r"^(##\s+)?(inputs?|arguments?)(\s*\([^)]+\))?:?\s*$", re.IGNORECASE
|
|
50
|
+
r"^(##\s+)?(inputs?|arguments?)(\s*\([^)]+\))?:?\s*$", re.IGNORECASE
|
|
51
51
|
)
|
|
52
|
-
_OUTPUT_SECTION = re.compile(
|
|
53
|
-
|
|
52
|
+
_OUTPUT_SECTION = re.compile(r"^(##\s+)?(outputs?|deliverables?):?\s*$", re.IGNORECASE)
|
|
53
|
+
_FENCE = re.compile(r"^ {0,3}(?P<marker>`{3,}|~{3,})(?P<info>.*)$")
|
|
54
|
+
_LIST_ITEM = re.compile(r"^( {0,3})([-+*]|\d{1,9})([.)]?)( +|$)")
|
|
55
|
+
_ATX_HEADING = re.compile(r"^ {0,3}#{1,6}(?: |$)")
|
|
56
|
+
|
|
57
|
+
_QUOTE = "quote"
|
|
58
|
+
_ITEM = "item"
|
|
59
|
+
|
|
60
|
+
_HTML_BLOCK_NAMES = (
|
|
61
|
+
"address|article|aside|base|basefont|blockquote|body|caption|center|col|"
|
|
62
|
+
"colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|"
|
|
63
|
+
"form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|"
|
|
64
|
+
"link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|"
|
|
65
|
+
"section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul"
|
|
54
66
|
)
|
|
67
|
+
_HTML_ATTRIBUTE = (
|
|
68
|
+
r"(?:\s+[a-zA-Z_:][a-zA-Z0-9:._-]*"
|
|
69
|
+
r"(?:\s*=\s*(?:[^\"'=<>`\x00-\x20]+|'[^']*'|\"[^\"]*\"))?)"
|
|
70
|
+
)
|
|
71
|
+
_HTML_TAG = (
|
|
72
|
+
rf"<[A-Za-z][A-Za-z0-9-]*{_HTML_ATTRIBUTE}*\s*/?>|</[A-Za-z][A-Za-z0-9-]*\s*>"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# commonmark's seven html block types in order; a ``None`` end condition means
|
|
76
|
+
# the block runs to the next blank line
|
|
77
|
+
_HTML_BLOCKS: tuple[tuple[re.Pattern[str], re.Pattern[str] | None], ...] = (
|
|
78
|
+
(
|
|
79
|
+
re.compile(r"^ {0,3}<(?:script|pre|style|textarea)(?=\s|>|$)", re.IGNORECASE),
|
|
80
|
+
re.compile(r"</(?:script|pre|style|textarea)>", re.IGNORECASE),
|
|
81
|
+
),
|
|
82
|
+
(re.compile(r"^ {0,3}<!--"), re.compile(r"-->")),
|
|
83
|
+
(re.compile(r"^ {0,3}<\?"), re.compile(r"\?>")),
|
|
84
|
+
(re.compile(r"^ {0,3}<![A-Za-z]"), re.compile(r">")),
|
|
85
|
+
(re.compile(r"^ {0,3}<!\[CDATA\["), re.compile(r"\]\]>")),
|
|
86
|
+
(
|
|
87
|
+
re.compile(rf"^ {{0,3}}</?(?:{_HTML_BLOCK_NAMES})(?=\s|/?>|$)", re.IGNORECASE),
|
|
88
|
+
None,
|
|
89
|
+
),
|
|
90
|
+
(re.compile(rf"^ {{0,3}}(?:{_HTML_TAG})[ \t]*$"), None),
|
|
91
|
+
)
|
|
92
|
+
# type 7, any complete tag alone on a line, may not interrupt a paragraph
|
|
93
|
+
_HTML_LOOSE_TAG = 6
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _match_container(line: str, col: int, container: tuple[str, int]) -> int | None:
|
|
97
|
+
"""column just past ``container``'s prefix on ``line``, or ``None`` if absent."""
|
|
98
|
+
kind, width = container
|
|
99
|
+
stop = col + (3 if kind == _QUOTE else width)
|
|
100
|
+
end = col
|
|
101
|
+
while end < len(line) and line[end] == " " and end < stop:
|
|
102
|
+
end += 1
|
|
103
|
+
if kind == _ITEM:
|
|
104
|
+
return end if end - col == width else None
|
|
105
|
+
if end >= len(line) or line[end] != ">":
|
|
106
|
+
return None
|
|
107
|
+
end += 1
|
|
108
|
+
return end + 1 if end < len(line) and line[end] == " " else end
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _opens_item(line: str, col: int, interrupting: bool) -> int | None:
|
|
112
|
+
"""content width of the list item starting at ``col``, or ``None`` if none does."""
|
|
113
|
+
item = _LIST_ITEM.match(line[col:])
|
|
114
|
+
if item is None or (item.group(2).isdigit() and not item.group(3)):
|
|
115
|
+
return None
|
|
116
|
+
content = line[col + item.end() :]
|
|
117
|
+
if interrupting and (
|
|
118
|
+
not content.strip() or item.group(2) not in ("-", "+", "*", "1")
|
|
119
|
+
):
|
|
120
|
+
return None
|
|
121
|
+
pad = len(item.group(4))
|
|
122
|
+
marker = len(item.group(1)) + len(item.group(2)) + len(item.group(3))
|
|
123
|
+
return marker + (pad if 1 <= pad <= 4 and content else 1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _open_containers(
|
|
127
|
+
line: str, col: int, stack: list[tuple[str, int]], interrupting: bool
|
|
128
|
+
) -> int:
|
|
129
|
+
"""push every container ``line`` opens at ``col``, returning the content column."""
|
|
130
|
+
while True:
|
|
131
|
+
quoted = _match_container(line, col, (_QUOTE, 0))
|
|
132
|
+
if quoted is not None:
|
|
133
|
+
stack.append((_QUOTE, 0))
|
|
134
|
+
col = quoted
|
|
135
|
+
else:
|
|
136
|
+
width = _opens_item(line, col, interrupting)
|
|
137
|
+
if width is None:
|
|
138
|
+
return col
|
|
139
|
+
stack.append((_ITEM, width))
|
|
140
|
+
col += width
|
|
141
|
+
interrupting = False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _closes(rest: str, fence: str) -> bool:
|
|
145
|
+
match = _FENCE.match(rest)
|
|
146
|
+
return (
|
|
147
|
+
match is not None
|
|
148
|
+
and match.group("marker")[0] == fence[0]
|
|
149
|
+
and len(match.group("marker")) >= len(fence)
|
|
150
|
+
and not match.group("info").strip()
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _opens(rest: str) -> str | None:
|
|
155
|
+
match = _FENCE.match(rest)
|
|
156
|
+
if match is None:
|
|
157
|
+
return None
|
|
158
|
+
marker = match.group("marker")
|
|
159
|
+
if marker[0] == "`" and "`" in match.group("info"):
|
|
160
|
+
return None
|
|
161
|
+
return marker
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _opens_html(rest: str, paragraph: bool) -> int | None:
|
|
165
|
+
"""index into ``_HTML_BLOCKS`` of the html block ``rest`` opens, else ``None``."""
|
|
166
|
+
for kind, (start, _) in enumerate(_HTML_BLOCKS):
|
|
167
|
+
if start.match(rest):
|
|
168
|
+
return None if kind == _HTML_LOOSE_TAG and paragraph else kind
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _unfenced_lines(text: str) -> Iterator[tuple[int, str]]:
|
|
173
|
+
"""yield ``(offset, line)`` for every line of ``text`` outside a fenced code block.
|
|
174
|
+
|
|
175
|
+
fence indentation is measured from the content column of the block quotes and
|
|
176
|
+
list items open around it. an unclosed fence runs to the end of its container,
|
|
177
|
+
or to the end of ``text`` at the top level. html blocks are tracked the same
|
|
178
|
+
way and swallow any fence marker inside them.
|
|
179
|
+
"""
|
|
180
|
+
stack: list[tuple[str, int]] = []
|
|
181
|
+
fence: str | None = None
|
|
182
|
+
html: int | None = None
|
|
183
|
+
paragraph = False
|
|
184
|
+
offset = 0
|
|
185
|
+
for line in text.split("\n"):
|
|
186
|
+
expanded = line.expandtabs(4)
|
|
187
|
+
blank = not expanded.strip()
|
|
188
|
+
|
|
189
|
+
col = 0
|
|
190
|
+
depth = 0
|
|
191
|
+
for container in stack:
|
|
192
|
+
if blank:
|
|
193
|
+
if container[0] == _QUOTE:
|
|
194
|
+
break
|
|
195
|
+
else:
|
|
196
|
+
nxt = _match_container(expanded, col, container)
|
|
197
|
+
if nxt is None:
|
|
198
|
+
break
|
|
199
|
+
col = nxt
|
|
200
|
+
depth += 1
|
|
201
|
+
|
|
202
|
+
if fence is not None:
|
|
203
|
+
if depth == len(stack):
|
|
204
|
+
if _closes(expanded[col:], fence):
|
|
205
|
+
fence = None
|
|
206
|
+
offset += len(line) + 1
|
|
207
|
+
continue
|
|
208
|
+
fence = None
|
|
209
|
+
|
|
210
|
+
if html is not None:
|
|
211
|
+
end = _HTML_BLOCKS[html][1]
|
|
212
|
+
inner_blank = not expanded[col:].strip()
|
|
213
|
+
if depth < len(stack) or (
|
|
214
|
+
inner_blank and (end is None or (blank and stack))
|
|
215
|
+
):
|
|
216
|
+
html = None
|
|
217
|
+
else:
|
|
218
|
+
if end is not None and end.search(expanded[col:]):
|
|
219
|
+
html = None
|
|
220
|
+
yield offset, line
|
|
221
|
+
offset += len(line) + 1
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
del stack[depth:]
|
|
225
|
+
rest = ""
|
|
226
|
+
opened: int | None = None
|
|
227
|
+
if not blank:
|
|
228
|
+
rest = expanded[_open_containers(expanded, col, stack, paragraph) :]
|
|
229
|
+
fence = _opens(rest)
|
|
230
|
+
if fence is not None:
|
|
231
|
+
paragraph = False
|
|
232
|
+
offset += len(line) + 1
|
|
233
|
+
continue
|
|
234
|
+
opened = _opens_html(rest, paragraph)
|
|
235
|
+
if opened is not None:
|
|
236
|
+
end = _HTML_BLOCKS[opened][1]
|
|
237
|
+
html = None if end is not None and end.search(rest) else opened
|
|
238
|
+
paragraph = (
|
|
239
|
+
opened is None
|
|
240
|
+
and bool(rest.strip())
|
|
241
|
+
and not _ATX_HEADING.match(rest)
|
|
242
|
+
and (paragraph or not rest.startswith(" "))
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
yield offset, line
|
|
246
|
+
offset += len(line) + 1
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _next_heading_offset(text: str) -> int | None:
|
|
250
|
+
"""offset of the first line starting with ``#`` outside a fenced code block."""
|
|
251
|
+
for offset, line in _unfenced_lines(text):
|
|
252
|
+
if line.startswith("#"):
|
|
253
|
+
return offset
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _section_end_offset(pattern: re.Pattern[str], text: str) -> int | None:
|
|
258
|
+
"""offset just past the first line matching ``pattern`` outside a fenced block."""
|
|
259
|
+
for offset, line in _unfenced_lines(text):
|
|
260
|
+
match = pattern.match(line)
|
|
261
|
+
if match:
|
|
262
|
+
return offset + match.end()
|
|
263
|
+
return None
|
|
55
264
|
|
|
56
265
|
|
|
57
266
|
def lint_template(content: str, name: str) -> list[LintMessage]:
|
|
@@ -95,14 +304,12 @@ def _check_placeholder_docs(
|
|
|
95
304
|
messages: list[LintMessage] = []
|
|
96
305
|
|
|
97
306
|
# loose check: prefer the inputs section if it exists, else the whole file
|
|
98
|
-
|
|
99
|
-
if
|
|
100
|
-
|
|
101
|
-
# look ahead for the next markdown heading or end of file
|
|
102
|
-
next_heading = re.search(r"^#", doc_content[start:], re.MULTILINE)
|
|
307
|
+
start = _section_end_offset(_INPUTS_SECTION, doc_content)
|
|
308
|
+
if start is not None:
|
|
309
|
+
next_heading = _next_heading_offset(doc_content[start:])
|
|
103
310
|
search_area = (
|
|
104
|
-
doc_content[start : start + next_heading
|
|
105
|
-
if next_heading
|
|
311
|
+
doc_content[start : start + next_heading]
|
|
312
|
+
if next_heading is not None
|
|
106
313
|
else doc_content[start:]
|
|
107
314
|
).lower()
|
|
108
315
|
else:
|
|
@@ -129,7 +336,8 @@ def _check_placeholder_docs(
|
|
|
129
336
|
for p in positional:
|
|
130
337
|
if p == "ARGUMENTS":
|
|
131
338
|
continue
|
|
132
|
-
|
|
339
|
+
ref = rf"\${p}(?!\d)"
|
|
340
|
+
pattern = rf"(\w+[:\s]+{ref}|{ref}\s+[`\w]|{ref}\s*\()"
|
|
133
341
|
if not re.search(pattern, search_area):
|
|
134
342
|
messages.append(
|
|
135
343
|
LintMessage(
|
|
@@ -177,7 +385,7 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
|
|
|
177
385
|
)
|
|
178
386
|
)
|
|
179
387
|
|
|
180
|
-
if
|
|
388
|
+
if _section_end_offset(_INPUTS_SECTION, content) is None:
|
|
181
389
|
messages.append(
|
|
182
390
|
LintMessage(
|
|
183
391
|
level="warning",
|
|
@@ -187,7 +395,7 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
|
|
|
187
395
|
)
|
|
188
396
|
)
|
|
189
397
|
|
|
190
|
-
if
|
|
398
|
+
if _section_end_offset(_OUTPUT_SECTION, content) is None:
|
|
191
399
|
messages.append(
|
|
192
400
|
LintMessage(
|
|
193
401
|
level="warning",
|
|
@@ -22,12 +22,26 @@ class NotFoundError(Exception):
|
|
|
22
22
|
"""raised when a conjuring, invocation, or snippet is not found."""
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def _override_present(override: Path, label: str) -> bool:
|
|
26
|
+
"""report whether anything sits at ``override``, readable or not."""
|
|
27
|
+
try:
|
|
28
|
+
override.lstat()
|
|
29
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
30
|
+
return False
|
|
31
|
+
except OSError as e:
|
|
32
|
+
raise NotFoundError(f"cannot read {label}: {e}")
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
25
36
|
def _safe_read_text(path: Path, label: str) -> str:
|
|
26
37
|
"""read a UTF-8 text file, converting I/O errors to :class:`NotFoundError`."""
|
|
27
38
|
try:
|
|
28
39
|
return path.read_text(encoding="utf-8")
|
|
40
|
+
# PermissionError is an OSError; keep it first for its specific message.
|
|
29
41
|
except PermissionError:
|
|
30
42
|
raise NotFoundError(f"cannot read {label}: permission denied on {path}")
|
|
43
|
+
except OSError as e:
|
|
44
|
+
raise NotFoundError(f"cannot read {label}: {e}")
|
|
31
45
|
except UnicodeDecodeError:
|
|
32
46
|
raise NotFoundError(f"cannot read {label}: {path} is not valid UTF-8")
|
|
33
47
|
|
|
@@ -37,8 +51,9 @@ def load_text(repo_root: Path, kind: str, name: str) -> str:
|
|
|
37
51
|
if not _VALID_NAME.match(name):
|
|
38
52
|
raise NotFoundError(f"invalid {kind.rstrip('s')} name: {name}")
|
|
39
53
|
override = repo_root / ".familiar" / kind / f"{name}.md"
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
label = f"{kind.rstrip('s')} '{name}'"
|
|
55
|
+
if _override_present(override, label):
|
|
56
|
+
return _safe_read_text(override, label)
|
|
42
57
|
pkg = f"familiar.data.{kind}"
|
|
43
58
|
try:
|
|
44
59
|
return (resources.files(pkg) / f"{name}.md").read_text(encoding="utf-8")
|
|
@@ -57,8 +72,9 @@ def load_snippet(repo_root: Path, path: str) -> str:
|
|
|
57
72
|
raise NotFoundError(f"invalid snippet path: {path}")
|
|
58
73
|
|
|
59
74
|
override = repo_root / ".familiar" / "snippets" / path
|
|
60
|
-
|
|
61
|
-
|
|
75
|
+
label = f"snippet '{path}'"
|
|
76
|
+
if _override_present(override, label):
|
|
77
|
+
return _safe_read_text(override, label)
|
|
62
78
|
|
|
63
79
|
try:
|
|
64
80
|
ref: Traversable = resources.files("familiar.data.snippets")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: familiar-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: compose and invoke ai agent prompts from reusable templates
|
|
5
5
|
Author-email: cyberwitchery lab <contact@cyberwitchery.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,7 +20,7 @@ Requires-Python: >=3.10
|
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
License-File: LICENSE
|
|
22
22
|
Provides-Extra: dev
|
|
23
|
-
Requires-Dist: ruff==0.16.
|
|
23
|
+
Requires-Dist: ruff==0.16.5; extra == "dev"
|
|
24
24
|
Requires-Dist: mypy; extra == "dev"
|
|
25
25
|
Requires-Dist: pytest; extra == "dev"
|
|
26
26
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -8,6 +8,7 @@ import pytest
|
|
|
8
8
|
|
|
9
9
|
from familiar.lint import (
|
|
10
10
|
LintMessage,
|
|
11
|
+
_unfenced_lines,
|
|
11
12
|
lint_all,
|
|
12
13
|
lint_collection,
|
|
13
14
|
lint_invocation,
|
|
@@ -219,6 +220,562 @@ output
|
|
|
219
220
|
placeholder_warnings = [m for m in messages if "{{spec}}" in m.message]
|
|
220
221
|
assert placeholder_warnings == []
|
|
221
222
|
|
|
223
|
+
def test_fenced_example_does_not_end_inputs_section(self):
|
|
224
|
+
content = """task: do something with {{target}}
|
|
225
|
+
|
|
226
|
+
## inputs
|
|
227
|
+
|
|
228
|
+
```sh
|
|
229
|
+
# run it like this
|
|
230
|
+
familiar invoke thing
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
- {{target}}: the file to operate on
|
|
234
|
+
|
|
235
|
+
## output
|
|
236
|
+
|
|
237
|
+
- results
|
|
238
|
+
"""
|
|
239
|
+
messages = lint_invocation(content, "test.md")
|
|
240
|
+
placeholder_warnings = [m for m in messages if "{{target}}" in m.message]
|
|
241
|
+
assert placeholder_warnings == []
|
|
242
|
+
|
|
243
|
+
def test_tilde_fenced_example_does_not_end_inputs_section(self):
|
|
244
|
+
content = """task: do something with {{target}}
|
|
245
|
+
|
|
246
|
+
## inputs
|
|
247
|
+
|
|
248
|
+
~~~
|
|
249
|
+
# run it like this
|
|
250
|
+
~~~
|
|
251
|
+
|
|
252
|
+
- {{target}}: the file to operate on
|
|
253
|
+
|
|
254
|
+
## output
|
|
255
|
+
|
|
256
|
+
- results
|
|
257
|
+
"""
|
|
258
|
+
messages = lint_invocation(content, "test.md")
|
|
259
|
+
placeholder_warnings = [m for m in messages if "{{target}}" in m.message]
|
|
260
|
+
assert placeholder_warnings == []
|
|
261
|
+
|
|
262
|
+
def test_heading_after_fence_still_ends_inputs_section(self):
|
|
263
|
+
content = """task: do something with {{target}}
|
|
264
|
+
|
|
265
|
+
## inputs
|
|
266
|
+
|
|
267
|
+
```sh
|
|
268
|
+
# run it like this
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
- other: something else
|
|
272
|
+
|
|
273
|
+
## notes
|
|
274
|
+
|
|
275
|
+
- {{target}} is described here, outside the inputs section
|
|
276
|
+
|
|
277
|
+
## output
|
|
278
|
+
|
|
279
|
+
- results
|
|
280
|
+
"""
|
|
281
|
+
messages = lint_invocation(content, "test.md")
|
|
282
|
+
placeholder_warnings = [m for m in messages if "{{target}}" in m.message]
|
|
283
|
+
assert len(placeholder_warnings) == 1
|
|
284
|
+
assert placeholder_warnings[0].level == "warning"
|
|
285
|
+
|
|
286
|
+
def test_unclosed_fence_runs_to_end_of_file(self):
|
|
287
|
+
content = """task: do something with {{target}}
|
|
288
|
+
|
|
289
|
+
## inputs
|
|
290
|
+
|
|
291
|
+
```sh
|
|
292
|
+
# run it like this
|
|
293
|
+
|
|
294
|
+
## output
|
|
295
|
+
|
|
296
|
+
- {{target}}: the file to operate on
|
|
297
|
+
"""
|
|
298
|
+
messages = lint_invocation(content, "test.md")
|
|
299
|
+
placeholder_warnings = [m for m in messages if "{{target}}" in m.message]
|
|
300
|
+
assert placeholder_warnings == []
|
|
301
|
+
|
|
302
|
+
def test_inline_code_line_does_not_open_a_fence(self):
|
|
303
|
+
content = """task: do something with {{target}}
|
|
304
|
+
|
|
305
|
+
## inputs
|
|
306
|
+
|
|
307
|
+
```familiar lint``` validates these files before you commit.
|
|
308
|
+
|
|
309
|
+
- ctx: the surrounding context
|
|
310
|
+
|
|
311
|
+
## output
|
|
312
|
+
|
|
313
|
+
- results mentioning {{target}}
|
|
314
|
+
"""
|
|
315
|
+
messages = lint_invocation(content, "test.md")
|
|
316
|
+
placeholder_warnings = [m for m in messages if "{{target}}" in m.message]
|
|
317
|
+
assert len(placeholder_warnings) == 1
|
|
318
|
+
assert placeholder_warnings[0].level == "warning"
|
|
319
|
+
|
|
320
|
+
def test_backtick_fence_does_not_close_a_tilde_fence(self):
|
|
321
|
+
content = """task: do something with {{target}}
|
|
322
|
+
|
|
323
|
+
## inputs
|
|
324
|
+
|
|
325
|
+
~~~
|
|
326
|
+
```
|
|
327
|
+
# run it like this
|
|
328
|
+
~~~
|
|
329
|
+
|
|
330
|
+
- {{target}}: the file to operate on
|
|
331
|
+
|
|
332
|
+
## output
|
|
333
|
+
|
|
334
|
+
- results
|
|
335
|
+
"""
|
|
336
|
+
messages = lint_invocation(content, "test.md")
|
|
337
|
+
placeholder_warnings = [m for m in messages if "{{target}}" in m.message]
|
|
338
|
+
assert placeholder_warnings == []
|
|
339
|
+
|
|
340
|
+
def test_shorter_fence_does_not_close_a_longer_fence(self):
|
|
341
|
+
content = """task: do something with {{target}}
|
|
342
|
+
|
|
343
|
+
## inputs
|
|
344
|
+
|
|
345
|
+
````
|
|
346
|
+
```
|
|
347
|
+
# run it like this
|
|
348
|
+
````
|
|
349
|
+
|
|
350
|
+
- {{target}}: the file to operate on
|
|
351
|
+
|
|
352
|
+
## output
|
|
353
|
+
|
|
354
|
+
- results
|
|
355
|
+
"""
|
|
356
|
+
messages = lint_invocation(content, "test.md")
|
|
357
|
+
placeholder_warnings = [m for m in messages if "{{target}}" in m.message]
|
|
358
|
+
assert placeholder_warnings == []
|
|
359
|
+
|
|
360
|
+
def test_fence_with_info_string_does_not_close_a_fence(self):
|
|
361
|
+
content = """task: do something with {{target}}
|
|
362
|
+
|
|
363
|
+
## inputs
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
```python
|
|
367
|
+
# run it like this
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
- {{target}}: the file to operate on
|
|
371
|
+
|
|
372
|
+
## output
|
|
373
|
+
|
|
374
|
+
- results
|
|
375
|
+
"""
|
|
376
|
+
messages = lint_invocation(content, "test.md")
|
|
377
|
+
placeholder_warnings = [m for m in messages if "{{target}}" in m.message]
|
|
378
|
+
assert placeholder_warnings == []
|
|
379
|
+
|
|
380
|
+
def test_fenced_inputs_heading_does_not_count_as_the_section(self):
|
|
381
|
+
content = """task: show how to write an invocation
|
|
382
|
+
|
|
383
|
+
here is the shape of one:
|
|
384
|
+
|
|
385
|
+
```md
|
|
386
|
+
## inputs
|
|
387
|
+
|
|
388
|
+
- {{thing}}: a thing
|
|
389
|
+
|
|
390
|
+
## output
|
|
391
|
+
|
|
392
|
+
- results
|
|
393
|
+
```
|
|
394
|
+
"""
|
|
395
|
+
messages = lint_invocation(content, "test.md")
|
|
396
|
+
section_warnings = [m for m in messages if "section" in m.message]
|
|
397
|
+
assert len(section_warnings) == 2
|
|
398
|
+
assert all(m.level == "warning" for m in section_warnings)
|
|
399
|
+
assert any("'inputs' section" in m.message for m in section_warnings)
|
|
400
|
+
assert any("'output' or 'deliverables'" in m.message for m in section_warnings)
|
|
401
|
+
|
|
402
|
+
def test_tilde_fenced_output_heading_does_not_count_as_the_section(self):
|
|
403
|
+
content = """task: show how to write an invocation
|
|
404
|
+
|
|
405
|
+
## inputs
|
|
406
|
+
|
|
407
|
+
- ctx: the surrounding context
|
|
408
|
+
|
|
409
|
+
~~~md
|
|
410
|
+
## output
|
|
411
|
+
|
|
412
|
+
- results
|
|
413
|
+
~~~
|
|
414
|
+
"""
|
|
415
|
+
messages = lint_invocation(content, "test.md")
|
|
416
|
+
output_warnings = [m for m in messages if "output" in m.message.lower()]
|
|
417
|
+
assert len(output_warnings) == 1
|
|
418
|
+
assert output_warnings[0].level == "warning"
|
|
419
|
+
|
|
420
|
+
def test_fenced_inputs_heading_does_not_anchor_placeholder_search(self):
|
|
421
|
+
content = """task: do something with {{target}}
|
|
422
|
+
|
|
423
|
+
an example of what to write:
|
|
424
|
+
|
|
425
|
+
```md
|
|
426
|
+
## inputs
|
|
427
|
+
|
|
428
|
+
- example: a placeholder
|
|
429
|
+
|
|
430
|
+
## output
|
|
431
|
+
|
|
432
|
+
- example results
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
## inputs
|
|
436
|
+
|
|
437
|
+
- {{target}}: the file to operate on
|
|
438
|
+
|
|
439
|
+
## output
|
|
440
|
+
|
|
441
|
+
- results
|
|
442
|
+
"""
|
|
443
|
+
messages = lint_invocation(content, "test.md")
|
|
444
|
+
assert messages == []
|
|
445
|
+
|
|
446
|
+
def test_positional_placeholder_not_matched_as_prefix(self):
|
|
447
|
+
content = """task: process $1 and $10
|
|
448
|
+
|
|
449
|
+
## inputs
|
|
450
|
+
|
|
451
|
+
- ctx: $10 the tenth argument
|
|
452
|
+
|
|
453
|
+
## output
|
|
454
|
+
|
|
455
|
+
- results
|
|
456
|
+
"""
|
|
457
|
+
messages = lint_invocation(content, "test.md")
|
|
458
|
+
assert [m for m in messages if "'$10'" in m.message] == []
|
|
459
|
+
first_warnings = [m for m in messages if "'$1'" in m.message]
|
|
460
|
+
assert len(first_warnings) == 1
|
|
461
|
+
assert first_warnings[0].level == "warning"
|
|
462
|
+
|
|
463
|
+
def test_positional_placeholder_documented_with_trailing_punctuation(self):
|
|
464
|
+
for doc in [
|
|
465
|
+
"- ctx: $1",
|
|
466
|
+
"- ctx: $1, the first argument",
|
|
467
|
+
"- $1 (required): the first argument",
|
|
468
|
+
]:
|
|
469
|
+
content = (
|
|
470
|
+
f"task: process $1\n\n## inputs\n\n{doc}\n\n## output\n\n- results\n"
|
|
471
|
+
)
|
|
472
|
+
messages = lint_invocation(content, "test.md")
|
|
473
|
+
warnings = [m for m in messages if "may not be documented" in m.message]
|
|
474
|
+
assert warnings == [], f"failed for: {doc}"
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class TestContainerAwareFences:
|
|
478
|
+
"""tests for fences nested in list items and block quotes."""
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def _unfenced(text):
|
|
482
|
+
return [line for _, line in _unfenced_lines(text)]
|
|
483
|
+
|
|
484
|
+
def test_bullet_nested_fence_with_indented_closer_does_not_swallow_the_file(self):
|
|
485
|
+
content = """task: do something with {{target}}
|
|
486
|
+
|
|
487
|
+
- write the invocation like this:
|
|
488
|
+
|
|
489
|
+
```md
|
|
490
|
+
## inputs
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
## inputs
|
|
494
|
+
|
|
495
|
+
- {{target}}: the file to operate on
|
|
496
|
+
"""
|
|
497
|
+
messages = lint_invocation(content, "test.md")
|
|
498
|
+
assert [m for m in messages if "'inputs' section" in m.message] == []
|
|
499
|
+
|
|
500
|
+
def test_fence_in_a_list_item(self):
|
|
501
|
+
text = "- item\n\n ```\n ## inputs\n ```\n\n## real\n"
|
|
502
|
+
assert self._unfenced(text) == ["- item", "", "", "## real", ""]
|
|
503
|
+
|
|
504
|
+
def test_fence_in_a_block_quote(self):
|
|
505
|
+
text = "> quoted\n>\n> ```\n> ## inputs\n> ```\n\n## real\n"
|
|
506
|
+
assert self._unfenced(text) == ["> quoted", ">", "", "## real", ""]
|
|
507
|
+
|
|
508
|
+
def test_fence_in_a_list_item_inside_a_block_quote(self):
|
|
509
|
+
text = "> - item\n>\n> ```\n> ## inputs\n> ```\n>\n## real\n"
|
|
510
|
+
assert self._unfenced(text) == ["> - item", ">", ">", "## real", ""]
|
|
511
|
+
|
|
512
|
+
def test_unclosed_fence_still_runs_to_the_end_of_the_file(self):
|
|
513
|
+
text = "intro\n\n```\n## inputs\n\n## output\n"
|
|
514
|
+
assert self._unfenced(text) == ["intro", ""]
|
|
515
|
+
|
|
516
|
+
def test_fence_ends_when_its_container_ends(self):
|
|
517
|
+
text = "- item\n\n ```\n code\n## real\n"
|
|
518
|
+
assert self._unfenced(text) == ["- item", "", "## real", ""]
|
|
519
|
+
|
|
520
|
+
def test_blank_line_does_not_end_a_fence_in_a_list_item(self):
|
|
521
|
+
text = "- item\n\n ```\n code\n\n ## inputs\n ```\n\n## real\n"
|
|
522
|
+
assert self._unfenced(text) == ["- item", "", "", "## real", ""]
|
|
523
|
+
|
|
524
|
+
def test_blank_line_ends_a_fence_in_a_block_quote(self):
|
|
525
|
+
text = "> ```\n> code\n\n## real\n"
|
|
526
|
+
assert self._unfenced(text) == ["", "## real", ""]
|
|
527
|
+
|
|
528
|
+
def test_fence_marker_past_the_item_content_column_is_not_a_fence(self):
|
|
529
|
+
text = "- item\n\n ```\n\n## real\n"
|
|
530
|
+
assert self._unfenced(text) == ["- item", "", " ```", "", "## real", ""]
|
|
531
|
+
|
|
532
|
+
def test_top_level_closer_indented_four_spaces_does_not_close(self):
|
|
533
|
+
text = " ```\n code\n ```\n## swallowed\n"
|
|
534
|
+
assert self._unfenced(text) == []
|
|
535
|
+
|
|
536
|
+
def test_tab_indented_fence_under_a_bullet(self):
|
|
537
|
+
text = "-\ta\n\n\t```\n\t## inputs\n\t```\n\n## real\n"
|
|
538
|
+
assert self._unfenced(text) == ["-\ta", "", "", "## real", ""]
|
|
539
|
+
|
|
540
|
+
def test_ordered_marker_mid_paragraph_opens_no_container(self):
|
|
541
|
+
text = "prose\n2) not a list\n ```\n## real\n"
|
|
542
|
+
assert self._unfenced(text) == [
|
|
543
|
+
"prose",
|
|
544
|
+
"2) not a list",
|
|
545
|
+
" ```",
|
|
546
|
+
"## real",
|
|
547
|
+
"",
|
|
548
|
+
]
|
|
549
|
+
|
|
550
|
+
def test_outdented_closer_ends_the_item_and_opens_a_new_fence(self):
|
|
551
|
+
text = "- item\n\n ```\n code\n```\n## swallowed\n"
|
|
552
|
+
assert self._unfenced(text) == ["- item", ""]
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class TestHtmlBlocks:
|
|
556
|
+
"""tests for fence markers swallowed by a commonmark html block."""
|
|
557
|
+
|
|
558
|
+
@staticmethod
|
|
559
|
+
def _unfenced(text):
|
|
560
|
+
return [line for _, line in _unfenced_lines(text)]
|
|
561
|
+
|
|
562
|
+
def test_details_wrapping_an_example_does_not_swallow_the_file(self):
|
|
563
|
+
content = """task: do something with {{target}}
|
|
564
|
+
|
|
565
|
+
<details>
|
|
566
|
+
<summary>example</summary>
|
|
567
|
+
- ```md
|
|
568
|
+
## inputs
|
|
569
|
+
```
|
|
570
|
+
</details>
|
|
571
|
+
|
|
572
|
+
## inputs
|
|
573
|
+
|
|
574
|
+
- {{target}}: the file to operate on
|
|
575
|
+
|
|
576
|
+
## output
|
|
577
|
+
|
|
578
|
+
- the rewritten file
|
|
579
|
+
"""
|
|
580
|
+
assert lint_invocation(content, "test.md") == []
|
|
581
|
+
|
|
582
|
+
def test_bullet_fence_under_an_open_tag_is_not_a_fence(self):
|
|
583
|
+
text = "<div>\n- ```\n## inputs\n```\n"
|
|
584
|
+
assert self._unfenced(text) == ["<div>", "- ```", "## inputs", "```", ""]
|
|
585
|
+
|
|
586
|
+
def test_blank_line_between_the_tag_and_the_fence_leaves_a_fence(self):
|
|
587
|
+
text = "<div>\n\n```\n## inputs\n```\n\n## real\n"
|
|
588
|
+
assert self._unfenced(text) == ["<div>", "", "", "## real", ""]
|
|
589
|
+
|
|
590
|
+
def test_open_tag_interrupts_a_paragraph(self):
|
|
591
|
+
text = "text\n<div>\n```\n## inputs\n```\n## real\n"
|
|
592
|
+
assert self._unfenced(text) == [
|
|
593
|
+
"text",
|
|
594
|
+
"<div>",
|
|
595
|
+
"```",
|
|
596
|
+
"## inputs",
|
|
597
|
+
"```",
|
|
598
|
+
"## real",
|
|
599
|
+
"",
|
|
600
|
+
]
|
|
601
|
+
|
|
602
|
+
def test_html_block_ends_with_its_container(self):
|
|
603
|
+
text = "- <div>\n ```\n## real\n```\n"
|
|
604
|
+
assert self._unfenced(text) == ["- <div>", " ```", "## real"]
|
|
605
|
+
|
|
606
|
+
def test_html_block_in_a_list_item(self):
|
|
607
|
+
text = "- <details>\n ```\n ## inputs\n\n## real\n"
|
|
608
|
+
assert self._unfenced(text) == [
|
|
609
|
+
"- <details>",
|
|
610
|
+
" ```",
|
|
611
|
+
" ## inputs",
|
|
612
|
+
"",
|
|
613
|
+
"## real",
|
|
614
|
+
"",
|
|
615
|
+
]
|
|
616
|
+
|
|
617
|
+
def test_html_block_in_a_block_quote(self):
|
|
618
|
+
text = "> <div>\n> ```\n> ## inputs\n\n## real\n"
|
|
619
|
+
assert self._unfenced(text) == [
|
|
620
|
+
"> <div>",
|
|
621
|
+
"> ```",
|
|
622
|
+
"> ## inputs",
|
|
623
|
+
"",
|
|
624
|
+
"## real",
|
|
625
|
+
"",
|
|
626
|
+
]
|
|
627
|
+
|
|
628
|
+
def test_script_runs_to_its_closing_tag(self):
|
|
629
|
+
text = "<script>\n```\n## inputs\n</script>\n```\n## real\n```\n## after\n"
|
|
630
|
+
assert self._unfenced(text) == [
|
|
631
|
+
"<script>",
|
|
632
|
+
"```",
|
|
633
|
+
"## inputs",
|
|
634
|
+
"</script>",
|
|
635
|
+
"## after",
|
|
636
|
+
"",
|
|
637
|
+
]
|
|
638
|
+
|
|
639
|
+
def test_uppercase_script_runs_to_its_closing_tag(self):
|
|
640
|
+
text = "<PRE>\n```\n## inputs\n</PRE>\n```\n## real\n```\n## after\n"
|
|
641
|
+
assert self._unfenced(text) == [
|
|
642
|
+
"<PRE>",
|
|
643
|
+
"```",
|
|
644
|
+
"## inputs",
|
|
645
|
+
"</PRE>",
|
|
646
|
+
"## after",
|
|
647
|
+
"",
|
|
648
|
+
]
|
|
649
|
+
|
|
650
|
+
def test_script_closed_on_its_own_line_leaves_a_fence(self):
|
|
651
|
+
text = "<script>x</script>\n```\n## inputs\n```\n## real\n"
|
|
652
|
+
assert self._unfenced(text) == ["<script>x</script>", "## real", ""]
|
|
653
|
+
|
|
654
|
+
def test_comment_runs_to_its_terminator(self):
|
|
655
|
+
text = "<!--\n```\n-->\n```\n## real\n```\n## after\n"
|
|
656
|
+
assert self._unfenced(text) == ["<!--", "```", "-->", "## after", ""]
|
|
657
|
+
|
|
658
|
+
def test_comment_closed_on_its_own_line_leaves_a_fence(self):
|
|
659
|
+
text = "<!-- c -->\n```\n## inputs\n```\n## real\n"
|
|
660
|
+
assert self._unfenced(text) == ["<!-- c -->", "## real", ""]
|
|
661
|
+
|
|
662
|
+
def test_processing_instruction_runs_to_its_terminator(self):
|
|
663
|
+
text = "<?php\n```\n?>\n```\n## real\n```\n## after\n"
|
|
664
|
+
assert self._unfenced(text) == ["<?php", "```", "?>", "## after", ""]
|
|
665
|
+
|
|
666
|
+
def test_declaration_runs_to_its_terminator(self):
|
|
667
|
+
text = "<!DOCTYPE\n```\n## inputs\nfoo>\n```\n## real\n```\n## after\n"
|
|
668
|
+
assert self._unfenced(text) == [
|
|
669
|
+
"<!DOCTYPE",
|
|
670
|
+
"```",
|
|
671
|
+
"## inputs",
|
|
672
|
+
"foo>",
|
|
673
|
+
"## after",
|
|
674
|
+
"",
|
|
675
|
+
]
|
|
676
|
+
|
|
677
|
+
def test_declaration_closed_on_its_own_line_leaves_a_fence(self):
|
|
678
|
+
text = "<!DOCTYPE html>\n```\n## inputs\n```\n## real\n"
|
|
679
|
+
assert self._unfenced(text) == ["<!DOCTYPE html>", "## real", ""]
|
|
680
|
+
|
|
681
|
+
def test_cdata_runs_to_its_terminator(self):
|
|
682
|
+
text = "<![CDATA[\n```\n]]>\n```\n## real\n```\n## after\n"
|
|
683
|
+
assert self._unfenced(text) == ["<![CDATA[", "```", "]]>", "## after", ""]
|
|
684
|
+
|
|
685
|
+
def test_empty_quote_line_ends_a_block_in_a_quote(self):
|
|
686
|
+
text = "> <details>\n>\n> ```\n> ## inputs\n> ```\n\n## real\n"
|
|
687
|
+
assert self._unfenced(text) == ["> <details>", ">", "", "## real", ""]
|
|
688
|
+
|
|
689
|
+
def test_blank_line_does_not_end_a_terminated_block(self):
|
|
690
|
+
text = "<pre>\n\n```\n## inputs\n```\n</pre>\n## real\n"
|
|
691
|
+
assert self._unfenced(text) == [
|
|
692
|
+
"<pre>",
|
|
693
|
+
"",
|
|
694
|
+
"```",
|
|
695
|
+
"## inputs",
|
|
696
|
+
"```",
|
|
697
|
+
"</pre>",
|
|
698
|
+
"## real",
|
|
699
|
+
"",
|
|
700
|
+
]
|
|
701
|
+
|
|
702
|
+
def test_blank_line_ends_a_terminated_block_in_a_list_item(self):
|
|
703
|
+
text = "- <pre>\n\n ```\n ## inputs\n ```\n\n## real\n"
|
|
704
|
+
assert self._unfenced(text) == ["- <pre>", "", "", "## real", ""]
|
|
705
|
+
|
|
706
|
+
def test_uppercase_tag_interrupts_a_paragraph(self):
|
|
707
|
+
text = "text\n<DIV>\n```\n## inputs\n```\n## real\n"
|
|
708
|
+
assert self._unfenced(text) == [
|
|
709
|
+
"text",
|
|
710
|
+
"<DIV>",
|
|
711
|
+
"```",
|
|
712
|
+
"## inputs",
|
|
713
|
+
"```",
|
|
714
|
+
"## real",
|
|
715
|
+
"",
|
|
716
|
+
]
|
|
717
|
+
|
|
718
|
+
def test_indented_tag_interrupts_a_paragraph(self):
|
|
719
|
+
text = "text\n <div>\n```\n## inputs\n```\n## real\n"
|
|
720
|
+
assert self._unfenced(text) == [
|
|
721
|
+
"text",
|
|
722
|
+
" <div>",
|
|
723
|
+
"```",
|
|
724
|
+
"## inputs",
|
|
725
|
+
"```",
|
|
726
|
+
"## real",
|
|
727
|
+
"",
|
|
728
|
+
]
|
|
729
|
+
|
|
730
|
+
def test_closing_tag_interrupts_a_paragraph(self):
|
|
731
|
+
text = "text\n</div>\n```\n## inputs\n```\n## real\n"
|
|
732
|
+
assert self._unfenced(text) == [
|
|
733
|
+
"text",
|
|
734
|
+
"</div>",
|
|
735
|
+
"```",
|
|
736
|
+
"## inputs",
|
|
737
|
+
"```",
|
|
738
|
+
"## real",
|
|
739
|
+
"",
|
|
740
|
+
]
|
|
741
|
+
|
|
742
|
+
def test_tag_extending_a_block_name_does_not_interrupt_a_paragraph(self):
|
|
743
|
+
text = "text\n<divider>\n```\n## inputs\n```\n## real\n"
|
|
744
|
+
assert self._unfenced(text) == ["text", "<divider>", "## real", ""]
|
|
745
|
+
|
|
746
|
+
def test_tag_with_trailing_text_does_not_open_a_block(self):
|
|
747
|
+
text = "<span>i</span> trailing\n```\n## inputs\n```\n\n## real\n"
|
|
748
|
+
assert self._unfenced(text) == ["<span>i</span> trailing", "", "## real", ""]
|
|
749
|
+
|
|
750
|
+
def test_tag_alone_after_a_closed_block_opens_a_block(self):
|
|
751
|
+
text = "<pre>x</pre>\n<span>\n```\n## inputs\n```\n\n## real\n"
|
|
752
|
+
assert self._unfenced(text) == [
|
|
753
|
+
"<pre>x</pre>",
|
|
754
|
+
"<span>",
|
|
755
|
+
"```",
|
|
756
|
+
"## inputs",
|
|
757
|
+
"```",
|
|
758
|
+
"",
|
|
759
|
+
"## real",
|
|
760
|
+
"",
|
|
761
|
+
]
|
|
762
|
+
|
|
763
|
+
def test_unknown_tag_alone_on_a_line_opens_a_block(self):
|
|
764
|
+
text = "<x-tag>\n```\n## inputs\n```\n\n## real\n"
|
|
765
|
+
assert self._unfenced(text) == [
|
|
766
|
+
"<x-tag>",
|
|
767
|
+
"```",
|
|
768
|
+
"## inputs",
|
|
769
|
+
"```",
|
|
770
|
+
"",
|
|
771
|
+
"## real",
|
|
772
|
+
"",
|
|
773
|
+
]
|
|
774
|
+
|
|
775
|
+
def test_unknown_tag_does_not_interrupt_a_paragraph(self):
|
|
776
|
+
text = "text\n<x-tag>\n```\n## inputs\n```\n## real\n"
|
|
777
|
+
assert self._unfenced(text) == ["text", "<x-tag>", "## real", ""]
|
|
778
|
+
|
|
222
779
|
|
|
223
780
|
class TestLintAll:
|
|
224
781
|
"""tests for linting all conjurings and invocations."""
|
|
@@ -141,6 +141,32 @@ class TestLoadText:
|
|
|
141
141
|
with pytest.raises(NotFoundError, match="not valid UTF-8"):
|
|
142
142
|
load_text(tmp_path, "conjurings", "binary")
|
|
143
143
|
|
|
144
|
+
def test_directory_override_raises(self, tmp_path):
|
|
145
|
+
override_dir = tmp_path / ".familiar" / "conjurings"
|
|
146
|
+
(override_dir / "nested.md").mkdir(parents=True)
|
|
147
|
+
with pytest.raises(NotFoundError, match="cannot read conjuring 'nested'"):
|
|
148
|
+
load_text(tmp_path, "conjurings", "nested")
|
|
149
|
+
|
|
150
|
+
def test_overlong_name_override_raises(self, tmp_path):
|
|
151
|
+
override_dir = tmp_path / ".familiar" / "conjurings"
|
|
152
|
+
override_dir.mkdir(parents=True)
|
|
153
|
+
with pytest.raises(NotFoundError, match="cannot read conjuring"):
|
|
154
|
+
load_text(tmp_path, "conjurings", "a" * 300)
|
|
155
|
+
|
|
156
|
+
def test_symlink_loop_override_raises(self, tmp_path):
|
|
157
|
+
override_dir = tmp_path / ".familiar" / "conjurings"
|
|
158
|
+
override_dir.mkdir(parents=True)
|
|
159
|
+
(override_dir / "python.md").symlink_to("python.md")
|
|
160
|
+
with pytest.raises(NotFoundError, match="cannot read conjuring 'python'"):
|
|
161
|
+
load_text(tmp_path, "conjurings", "python")
|
|
162
|
+
|
|
163
|
+
def test_dangling_symlink_override_raises(self, tmp_path):
|
|
164
|
+
override_dir = tmp_path / ".familiar" / "conjurings"
|
|
165
|
+
override_dir.mkdir(parents=True)
|
|
166
|
+
(override_dir / "python.md").symlink_to(tmp_path / "gone.md")
|
|
167
|
+
with pytest.raises(NotFoundError, match="cannot read conjuring 'python'"):
|
|
168
|
+
load_text(tmp_path, "conjurings", "python")
|
|
169
|
+
|
|
144
170
|
|
|
145
171
|
class TestComposeSystem:
|
|
146
172
|
"""tests for composing system prompts."""
|
|
@@ -434,6 +460,32 @@ class TestLoadSnippet:
|
|
|
434
460
|
with pytest.raises(NotFoundError, match="not valid UTF-8"):
|
|
435
461
|
load_snippet(tmp_path, "test/binary.txt")
|
|
436
462
|
|
|
463
|
+
def test_directory_override_raises(self, tmp_path):
|
|
464
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
465
|
+
(snippet_dir / "nested").mkdir(parents=True)
|
|
466
|
+
with pytest.raises(NotFoundError, match="cannot read snippet 'test/nested'"):
|
|
467
|
+
load_snippet(tmp_path, "test/nested")
|
|
468
|
+
|
|
469
|
+
def test_overlong_name_override_raises(self, tmp_path):
|
|
470
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
471
|
+
snippet_dir.mkdir(parents=True)
|
|
472
|
+
with pytest.raises(NotFoundError, match="cannot read snippet"):
|
|
473
|
+
load_snippet(tmp_path, "test/" + "a" * 300)
|
|
474
|
+
|
|
475
|
+
def test_symlink_loop_override_raises(self, tmp_path):
|
|
476
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "python"
|
|
477
|
+
snippet_dir.mkdir(parents=True)
|
|
478
|
+
(snippet_dir / "pyproject.toml").symlink_to("pyproject.toml")
|
|
479
|
+
with pytest.raises(NotFoundError, match="cannot read snippet 'python/"):
|
|
480
|
+
load_snippet(tmp_path, "python/pyproject.toml")
|
|
481
|
+
|
|
482
|
+
def test_dangling_symlink_override_raises(self, tmp_path):
|
|
483
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "python"
|
|
484
|
+
snippet_dir.mkdir(parents=True)
|
|
485
|
+
(snippet_dir / "pyproject.toml").symlink_to(tmp_path / "gone.toml")
|
|
486
|
+
with pytest.raises(NotFoundError, match="cannot read snippet 'python/"):
|
|
487
|
+
load_snippet(tmp_path, "python/pyproject.toml")
|
|
488
|
+
|
|
437
489
|
|
|
438
490
|
class TestResolveIncludes:
|
|
439
491
|
"""tests for snippet include resolution."""
|
|
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
|
|
File without changes
|
|
File without changes
|
{familiar_cli-0.6.0 → familiar_cli-0.6.1}/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
|
|
File without changes
|