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.
Files changed (53) hide show
  1. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/PKG-INFO +2 -2
  2. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/pyproject.toml +2 -2
  3. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/lint.py +222 -14
  4. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/render.py +20 -4
  5. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar_cli.egg-info/PKG-INFO +2 -2
  6. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar_cli.egg-info/requires.txt +1 -1
  7. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/tests/test_lint.py +557 -0
  8. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/tests/test_render.py +52 -0
  9. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/LICENSE +0 -0
  10. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/readme.md +0 -0
  11. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/setup.cfg +0 -0
  12. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/__init__.py +0 -0
  13. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/_plugins.py +0 -0
  14. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/agents.py +0 -0
  15. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/cli.py +0 -0
  16. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/core.md +0 -0
  17. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/data.md +0 -0
  18. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/docs.md +0 -0
  19. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/frontend.md +0 -0
  20. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/infra.md +0 -0
  21. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/python.md +0 -0
  22. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/rust.md +0 -0
  23. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/conjurings/sec.md +0 -0
  24. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/__noop__.md +0 -0
  25. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/add-ci.md +0 -0
  26. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/add-tests.md +0 -0
  27. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/audit.md +0 -0
  28. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/bootstrap-python.md +0 -0
  29. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
  30. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/code-review.md +0 -0
  31. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/explain.md +0 -0
  32. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/implement-feature.md +0 -0
  33. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/infra-change.md +0 -0
  34. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/performance.md +0 -0
  35. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/refactor.md +0 -0
  36. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/release.md +0 -0
  37. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/invocations/security-review.md +0 -0
  38. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/node/github-ci.yml +0 -0
  39. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
  40. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/python/github-ci.yml +0 -0
  41. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
  42. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/python/pyproject.toml +0 -0
  43. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
  44. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
  45. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
  46. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar/py.typed +0 -0
  47. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar_cli.egg-info/SOURCES.txt +0 -0
  48. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
  49. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar_cli.egg-info/entry_points.txt +0 -0
  50. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/src/familiar_cli.egg-info/top_level.txt +0 -0
  51. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/tests/test_agents.py +0 -0
  52. {familiar_cli-0.6.0 → familiar_cli-0.6.1}/tests/test_cli.py +0 -0
  53. {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.0
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.1; extra == "dev"
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.0"
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.1", "mypy", "pytest", "pytest-cov", "bandit"]
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 | re.MULTILINE
50
+ r"^(##\s+)?(inputs?|arguments?)(\s*\([^)]+\))?:?\s*$", re.IGNORECASE
51
51
  )
52
- _OUTPUT_SECTION = re.compile(
53
- r"^(##\s+)?(outputs?|deliverables?):?\s*$", re.IGNORECASE | re.MULTILINE
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
- inputs_match = _INPUTS_SECTION.search(doc_content)
99
- if inputs_match:
100
- start = inputs_match.end()
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.start()]
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
- pattern = rf"(\w+[:\s]+\${p}|\${p}\s+[`\w]|\${p}\s*\()"
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 not _INPUTS_SECTION.search(content):
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 not _OUTPUT_SECTION.search(content):
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
- if override.exists():
41
- return _safe_read_text(override, f"{kind.rstrip('s')} '{name}'")
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
- if override.exists():
61
- return _safe_read_text(override, f"snippet '{path}'")
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.0
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.1; extra == "dev"
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
 
2
2
  [dev]
3
- ruff==0.16.1
3
+ ruff==0.16.5
4
4
  mypy
5
5
  pytest
6
6
  pytest-cov
@@ -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