uncoded 1.1.0__tar.gz → 1.2.0__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 (74) hide show
  1. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/namespace.yaml +13 -2
  2. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/instruction_files.pyi +7 -3
  3. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/skill.pyi +1 -1
  4. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_cli.pyi +10 -0
  5. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_instruction_files.pyi +16 -0
  6. {uncoded-1.1.0 → uncoded-1.2.0}/AGENTS.md +2 -2
  7. {uncoded-1.1.0 → uncoded-1.2.0}/PKG-INFO +1 -1
  8. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/cli.py +7 -2
  9. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/instruction_files.py +55 -11
  10. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/skill.py +16 -9
  11. {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_cli.py +34 -1
  12. {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_instruction_files.py +88 -5
  13. {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_skill.py +15 -15
  14. {uncoded-1.1.0 → uncoded-1.2.0}/.agents/skills/coherence-review/SKILL.md +0 -0
  15. {uncoded-1.1.0 → uncoded-1.2.0}/.claude/skills/coherence-review/SKILL.md +0 -0
  16. {uncoded-1.1.0 → uncoded-1.2.0}/.github/workflows/ci.yml +0 -0
  17. {uncoded-1.1.0 → uncoded-1.2.0}/.github/workflows/publish.yml +0 -0
  18. {uncoded-1.1.0 → uncoded-1.2.0}/.gitignore +0 -0
  19. {uncoded-1.1.0 → uncoded-1.2.0}/.markdownlint-cli2.yaml +0 -0
  20. {uncoded-1.1.0 → uncoded-1.2.0}/.pre-commit-config.yaml +0 -0
  21. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/docs.yaml +0 -0
  22. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/__init__.pyi +0 -0
  23. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/ast_helpers.pyi +0 -0
  24. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/body.pyi +0 -0
  25. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/cli.pyi +0 -0
  26. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/config.pyi +0 -0
  27. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/docs_map.pyi +0 -0
  28. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/extract.pyi +0 -0
  29. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/namespace_map.pyi +0 -0
  30. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/refs.pyi +0 -0
  31. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/resolver.pyi +0 -0
  32. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/stubs.pyi +0 -0
  33. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/sync.pyi +0 -0
  34. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/yaml_tree.pyi +0 -0
  35. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_body.pyi +0 -0
  36. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_config.pyi +0 -0
  37. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_docs_map.pyi +0 -0
  38. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_extract.pyi +0 -0
  39. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_namespace_map.pyi +0 -0
  40. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_refs.pyi +0 -0
  41. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_skill.pyi +0 -0
  42. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_stubs.pyi +0 -0
  43. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_sync.pyi +0 -0
  44. {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_uncoded.pyi +0 -0
  45. {uncoded-1.1.0 → uncoded-1.2.0}/CLAUDE.md +0 -0
  46. {uncoded-1.1.0 → uncoded-1.2.0}/LICENSE +0 -0
  47. {uncoded-1.1.0 → uncoded-1.2.0}/README.md +0 -0
  48. {uncoded-1.1.0 → uncoded-1.2.0}/pyproject.toml +0 -0
  49. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/__init__.py +0 -0
  50. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/ast_helpers.py +0 -0
  51. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/body.py +0 -0
  52. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/coherence_review.md +0 -0
  53. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/config.py +0 -0
  54. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/dispatch_rule.md +0 -0
  55. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/docs_map.py +0 -0
  56. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/docs_rule.md +0 -0
  57. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/extract.py +0 -0
  58. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/namespace_map.py +0 -0
  59. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/refs.py +0 -0
  60. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/resolver.py +0 -0
  61. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/stubs.py +0 -0
  62. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/sync.py +0 -0
  63. {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/yaml_tree.py +0 -0
  64. {uncoded-1.1.0 → uncoded-1.2.0}/tests/__init__.py +0 -0
  65. {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_body.py +0 -0
  66. {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_config.py +0 -0
  67. {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_docs_map.py +0 -0
  68. {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_extract.py +0 -0
  69. {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_namespace_map.py +0 -0
  70. {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_refs.py +0 -0
  71. {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_stubs.py +0 -0
  72. {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_sync.py +0 -0
  73. {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_uncoded.py +0 -0
  74. {uncoded-1.1.0 → uncoded-1.2.0}/uv.lock +0 -0
@@ -55,14 +55,16 @@ src/:
55
55
  iter_source_files:
56
56
  extract_modules:
57
57
  instruction_files.py:
58
- MARKER_START:
59
58
  MARKER_END:
60
- MARKER_DOCS_START:
61
59
  MARKER_DOCS_END:
60
+ MARKER_START_PREFIX:
61
+ MARKER_DOCS_START_PREFIX:
62
62
  DEFAULT_INSTRUCTION_FILES:
63
63
  _CODE_SECTION_BODY:
64
+ MARKER_START:
64
65
  SECTION_CODE:
65
66
  _DOCS_SECTION_BODY:
67
+ MARKER_DOCS_START:
66
68
  SECTION_DOCS:
67
69
  _apply_section:
68
70
  sync_instruction_file:
@@ -276,6 +278,9 @@ tests/:
276
278
  test_check_returns_one_when_docs_yaml_stale:
277
279
  test_check_returns_one_when_stubs_should_be_removed:
278
280
  test_check_returns_one_when_docs_yaml_should_be_removed:
281
+ test_doc_only_does_not_write_skill:
282
+ test_doc_only_removes_preexisting_skill:
283
+ test_check_returns_one_when_skill_should_be_removed:
279
284
  test_idempotent_doc_only:
280
285
  _init_repo:
281
286
  _init_doc_repo:
@@ -415,6 +420,12 @@ tests/:
415
420
  TestSyncInstructionFileProjectRootAnchor:
416
421
  test_project_root_anchors_create_independent_of_cwd:
417
422
  test_project_root_anchors_update_of_existing_file:
423
+ TestSyncInstructionFileFingerprint:
424
+ test_reflowed_body_survives_sync:
425
+ test_reflowed_body_passes_check:
426
+ test_different_fingerprint_refreshes_section:
427
+ test_plain_marker_refreshes_once_then_stable:
428
+ test_prose_mention_of_prefix_before_section_is_ignored:
418
429
  test_namespace_map.py:
419
430
  TestBuildMap:
420
431
  test_single_file:
@@ -1,20 +1,24 @@
1
1
  # src/uncoded/instruction_files.py
2
2
 
3
+ import hashlib
4
+ import re
3
5
  from importlib.resources import files
4
6
  from pathlib import Path
5
7
  from uncoded.sync import sync_file
6
8
 
7
- MARKER_START = '<!-- uncoded:start -->'
8
9
  MARKER_END = '<!-- uncoded:end -->'
9
- MARKER_DOCS_START = '<!-- uncoded:docs:start -->'
10
10
  MARKER_DOCS_END = '<!-- uncoded:docs:end -->'
11
+ MARKER_START_PREFIX = '<!-- uncoded:start'
12
+ MARKER_DOCS_START_PREFIX = '<!-- uncoded:docs:start'
11
13
  DEFAULT_INSTRUCTION_FILES = [Path('CLAUDE.md'), Path('AGENTS.md')]
12
14
  _CODE_SECTION_BODY = (files('uncoded') / 'dispatch_rule.md').read_text(encoding='utf-8').rstrip('\n')
15
+ MARKER_START = ...
13
16
  SECTION_CODE = f'{MARKER_START}\n{_CODE_SECTION_BODY}\n{MARKER_END}\n'
14
17
  _DOCS_SECTION_BODY = (files('uncoded') / 'docs_rule.md').read_text(encoding='utf-8').rstrip('\n')
18
+ MARKER_DOCS_START = ...
15
19
  SECTION_DOCS = f'{MARKER_DOCS_START}\n{_DOCS_SECTION_BODY}\n{MARKER_DOCS_END}\n'
16
20
 
17
- def _apply_section(text: str, start: str, end: str, body: str | None) -> str:
21
+ def _apply_section(text: str, start: str, end: str, body: str | None, *, prefix: str) -> str:
18
22
  ...
19
23
 
20
24
  def sync_instruction_file(path: Path, *, code_section: str | None, docs_section: str | None, project_root: Path, check: bool) -> bool:
@@ -8,5 +8,5 @@ SKILL_OUTPUTS = ...
8
8
  LEGACY_SKILL_OUTPUTS = ...
9
9
  _SKILL_CONTENT = (files('uncoded') / 'coherence_review.md').read_text(encoding='utf-8')
10
10
 
11
- def sync_skill(*, project_root: Path, check: bool) -> bool:
11
+ def sync_skill(*, project_root: Path, check: bool, build: bool) -> bool:
12
12
  ...
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  from unittest import mock
7
7
  import pytest
8
8
  from uncoded import cli
9
+ from uncoded.instruction_files import MARKER_START
9
10
  from uncoded.skill import SKILL_OUTPUTS
10
11
 
11
12
  def _init_repo(tmp_path, monkeypatch, source_roots):
@@ -203,5 +204,14 @@ class TestSyncDocRoots:
203
204
  def test_check_returns_one_when_docs_yaml_should_be_removed(self, tmp_path, monkeypatch):
204
205
  ...
205
206
 
207
+ def test_doc_only_does_not_write_skill(self, tmp_path, monkeypatch):
208
+ ...
209
+
210
+ def test_doc_only_removes_preexisting_skill(self, tmp_path, monkeypatch):
211
+ ...
212
+
213
+ def test_check_returns_one_when_skill_should_be_removed(self, tmp_path, monkeypatch):
214
+ ...
215
+
206
216
  def test_idempotent_doc_only(self, tmp_path, monkeypatch):
207
217
  ...
@@ -91,3 +91,19 @@ class TestSyncInstructionFileProjectRootAnchor:
91
91
 
92
92
  def test_project_root_anchors_update_of_existing_file(self, tmp_path, monkeypatch):
93
93
  ...
94
+
95
+ class TestSyncInstructionFileFingerprint:
96
+ def test_reflowed_body_survives_sync(self, tmp_path):
97
+ ...
98
+
99
+ def test_reflowed_body_passes_check(self, tmp_path):
100
+ ...
101
+
102
+ def test_different_fingerprint_refreshes_section(self, tmp_path):
103
+ ...
104
+
105
+ def test_plain_marker_refreshes_once_then_stable(self, tmp_path):
106
+ ...
107
+
108
+ def test_prose_mention_of_prefix_before_section_is_ignored(self, tmp_path):
109
+ ...
@@ -64,7 +64,7 @@ uv run pytest
64
64
  uv run pytest tests/test_stubs.py --no-cov
65
65
  ```
66
66
 
67
- <!-- uncoded:start -->
67
+ <!-- uncoded:start sha256=5d50217d -->
68
68
  ## How to read and edit code in this codebase
69
69
 
70
70
  This repo uses [uncoded](https://github.com/alimanfoo/uncoded) to maintain
@@ -171,7 +171,7 @@ Edit, and grep stay correct:
171
171
  The dispatch rule turns on the search term: a symbol name → the index; a
172
172
  regex or free-text phrase → grep.
173
173
  <!-- uncoded:end -->
174
- <!-- uncoded:docs:start -->
174
+ <!-- uncoded:docs:start sha256=6a530a01 -->
175
175
  ## How to read documentation in this codebase
176
176
 
177
177
  `.uncoded/docs.yaml` is an orientation outline: it lists every Markdown file
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uncoded
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Symbol index with associated code reading tools for AI coding agent navigation
5
5
  Project-URL: Homepage, https://github.com/alimanfoo/uncoded
6
6
  Project-URL: Repository, https://github.com/alimanfoo/uncoded
@@ -117,6 +117,13 @@ def _sync(*, start: Path | None = None, check: bool = False) -> int:
117
117
  project_root=project_root,
118
118
  check=check,
119
119
  )
120
+ # The skill needs namespace.yaml and stubs to run, so it builds and
121
+ # removes with the other code artefacts.
122
+ changes += sync_skill(
123
+ project_root=project_root,
124
+ check=check,
125
+ build=bool(config.source_roots),
126
+ )
120
127
 
121
128
  # Doc artefacts — build when doc_roots configured, else remove.
122
129
  if config.doc_roots:
@@ -190,8 +197,6 @@ def _sync(*, start: Path | None = None, check: bool = False) -> int:
190
197
  check=check,
191
198
  )
192
199
 
193
- changes += sync_skill(project_root=project_root, check=check)
194
-
195
200
  if check:
196
201
  if changes:
197
202
  print(f"Index out of date: {changes} file(s) would change.")
@@ -9,37 +9,63 @@ each), or symlink one to the other (sync dedupes by inode and writes once).
9
9
  This module owns delimited sections in any such file and keeps them in sync.
10
10
  """
11
11
 
12
+ import hashlib
13
+ import re
12
14
  from importlib.resources import files
13
15
  from pathlib import Path
14
16
 
15
17
  from uncoded.sync import sync_file
16
18
 
17
- MARKER_START = "<!-- uncoded:start -->"
18
19
  MARKER_END = "<!-- uncoded:end -->"
19
- MARKER_DOCS_START = "<!-- uncoded:docs:start -->"
20
20
  MARKER_DOCS_END = "<!-- uncoded:docs:end -->"
21
+ MARKER_START_PREFIX = "<!-- uncoded:start"
22
+ MARKER_DOCS_START_PREFIX = "<!-- uncoded:docs:start"
21
23
 
22
24
  DEFAULT_INSTRUCTION_FILES = [Path("CLAUDE.md"), Path("AGENTS.md")]
23
25
 
26
+ # The opening markers carry a short sha256 stamp of uncoded's canonical
27
+ # section body, computed once at module load. On sync, the on-disk
28
+ # opening-marker line is compared to the canonical marker: a matching stamp
29
+ # means this version's wording is already planted, so the body is left alone
30
+ # and a formatter's reflow survives; a differing stamp means the wording
31
+ # changed (e.g. on upgrade) and the whole section is replaced.
24
32
  _CODE_SECTION_BODY = (
25
33
  (files("uncoded") / "dispatch_rule.md").read_text(encoding="utf-8").rstrip("\n")
26
34
  )
35
+ MARKER_START = (
36
+ f"{MARKER_START_PREFIX} sha256="
37
+ f"{hashlib.sha256(_CODE_SECTION_BODY.encode()).hexdigest()[:8]} -->"
38
+ )
27
39
  SECTION_CODE = f"{MARKER_START}\n{_CODE_SECTION_BODY}\n{MARKER_END}\n"
28
40
 
29
41
  _DOCS_SECTION_BODY = (
30
42
  (files("uncoded") / "docs_rule.md").read_text(encoding="utf-8").rstrip("\n")
31
43
  )
44
+ MARKER_DOCS_START = (
45
+ f"{MARKER_DOCS_START_PREFIX} sha256="
46
+ f"{hashlib.sha256(_DOCS_SECTION_BODY.encode()).hexdigest()[:8]} -->"
47
+ )
32
48
  SECTION_DOCS = f"{MARKER_DOCS_START}\n{_DOCS_SECTION_BODY}\n{MARKER_DOCS_END}\n"
33
49
 
34
50
 
35
- def _apply_section(text: str, start: str, end: str, body: str | None) -> str:
51
+ def _apply_section(
52
+ text: str, start: str, end: str, body: str | None, *, prefix: str
53
+ ) -> str:
36
54
  """Apply, replace, or remove the delimited section in text.
37
55
 
38
- When body is a string: replaces the section if already present,
39
- appends it otherwise. When body is None: removes the section if
40
- present, leaves text unchanged otherwise.
56
+ Locates an existing section by prefix, anchored to the start of a line,
57
+ so marker-like text in prose is not mistaken for a section opener. This
58
+ matches both old plain markers and current fingerprinted ones.
59
+
60
+ When body is a string:
61
+ - absent → append the canonical section;
62
+ - found and opening-marker line matches start → return text unchanged
63
+ (a formatter's reflow of the body is tolerated);
64
+ - found and opening-marker line differs → replace the whole section.
65
+ When body is None: remove the section if present.
41
66
  """
42
- s = text.find(start)
67
+ m = re.search(r"^" + re.escape(prefix), text, re.MULTILINE)
68
+ s = m.start() if m else -1
43
69
  e = text.find(end)
44
70
  section_found = s != -1 and e != -1 and s < e
45
71
 
@@ -51,13 +77,19 @@ def _apply_section(text: str, start: str, end: str, body: str | None) -> str:
51
77
  return before + after
52
78
  else:
53
79
  if section_found:
80
+ line_end = text.find("\n", s)
81
+ existing_opening = text[s:line_end] if line_end != -1 else text[s:]
82
+ if existing_opening == start:
83
+ # Opening-marker stamp matches — leave the text untouched so
84
+ # a formatter's reflow of the body is preserved.
85
+ return text
54
86
  before = text[:s]
55
87
  after = text[e + len(end) :].lstrip("\n")
56
88
  return before + body + after
57
89
  else:
58
90
  stripped = text.rstrip("\n")
59
- prefix = stripped + "\n\n" if stripped else ""
60
- return prefix + body
91
+ lead = stripped + "\n\n" if stripped else ""
92
+ return lead + body
61
93
 
62
94
 
63
95
  def sync_instruction_file(
@@ -86,6 +118,18 @@ def sync_instruction_file(
86
118
  if not target.exists() and code_section is None and docs_section is None:
87
119
  return False
88
120
  existing = target.read_text() if target.exists() else ""
89
- updated = _apply_section(existing, MARKER_START, MARKER_END, code_section)
90
- updated = _apply_section(updated, MARKER_DOCS_START, MARKER_DOCS_END, docs_section)
121
+ updated = _apply_section(
122
+ existing,
123
+ MARKER_START,
124
+ MARKER_END,
125
+ code_section,
126
+ prefix=MARKER_START_PREFIX,
127
+ )
128
+ updated = _apply_section(
129
+ updated,
130
+ MARKER_DOCS_START,
131
+ MARKER_DOCS_END,
132
+ docs_section,
133
+ prefix=MARKER_DOCS_START_PREFIX,
134
+ )
91
135
  return sync_file(path, updated, project_root=project_root, check=check)
@@ -18,17 +18,24 @@ LEGACY_SKILL_OUTPUTS = [
18
18
  _SKILL_CONTENT = (files("uncoded") / "coherence_review.md").read_text(encoding="utf-8")
19
19
 
20
20
 
21
- def sync_skill(*, project_root: Path, check: bool) -> bool:
22
- """Write the coherence-review skill file to all supported agent locations.
21
+ def sync_skill(*, project_root: Path, check: bool, build: bool) -> bool:
22
+ """Sync the coherence-review skill file to all supported agent locations.
23
23
 
24
- The skill files are written under ``project_root``. Log lines still
25
- name each configured path as given, so messages stay
26
- project-relative regardless of where the caller is running from.
24
+ When build is True, write the skill files under project_root.
25
+ When build is False, remove any existing skill files. In both cases,
26
+ remove legacy skill files left by older versions of uncoded.
27
27
  """
28
- results = [
29
- sync_file(path, _SKILL_CONTENT, project_root=project_root, check=check)
30
- for path in SKILL_OUTPUTS
31
- ]
28
+ results: list[bool] = []
29
+ if build:
30
+ results.extend(
31
+ sync_file(path, _SKILL_CONTENT, project_root=project_root, check=check)
32
+ for path in SKILL_OUTPUTS
33
+ )
34
+ else:
35
+ results.extend(
36
+ remove_file(path, project_root=project_root, check=check)
37
+ for path in SKILL_OUTPUTS
38
+ )
32
39
  results.extend(
33
40
  remove_file(path, project_root=project_root, check=check)
34
41
  for path in LEGACY_SKILL_OUTPUTS
@@ -14,6 +14,7 @@ from unittest import mock
14
14
  import pytest
15
15
 
16
16
  from uncoded import cli
17
+ from uncoded.instruction_files import MARKER_START
17
18
  from uncoded.skill import SKILL_OUTPUTS
18
19
 
19
20
 
@@ -93,7 +94,7 @@ class TestSyncApplyMode:
93
94
  assert cli._sync() == 0
94
95
 
95
96
  # The section is written through the symlink to AGENTS.md.
96
- assert "<!-- uncoded:start -->" in agents.read_text()
97
+ assert MARKER_START in agents.read_text()
97
98
 
98
99
  # Exactly one user-facing line for the instruction file, naming
99
100
  # the canonical AGENTS.md.
@@ -877,6 +878,8 @@ class TestSyncDocRoots:
877
878
 
878
879
  assert (tmp_path / ".uncoded" / "namespace.yaml").exists()
879
880
  assert (tmp_path / ".uncoded" / "stubs").exists()
881
+ for path in SKILL_OUTPUTS:
882
+ assert (tmp_path / path).exists()
880
883
 
881
884
  # Drop source-roots.
882
885
  (tmp_path / "pyproject.toml").write_text(
@@ -893,6 +896,8 @@ class TestSyncDocRoots:
893
896
  assert cli._sync() == 0
894
897
  assert not (tmp_path / ".uncoded" / "namespace.yaml").exists()
895
898
  assert not (tmp_path / ".uncoded" / "stubs").exists()
899
+ for path in SKILL_OUTPUTS:
900
+ assert not (tmp_path / path).exists()
896
901
 
897
902
  def test_doc_root_removal_cleans_docs_yaml(self, tmp_path, monkeypatch):
898
903
  # First sync with doc-roots; then drop them.
@@ -998,6 +1003,34 @@ class TestSyncDocRoots:
998
1003
  (tmp_path / "src").mkdir()
999
1004
  assert cli._sync(check=True) == 1
1000
1005
 
1006
+ def test_doc_only_does_not_write_skill(self, tmp_path, monkeypatch):
1007
+ _init_doc_repo(tmp_path, monkeypatch)
1008
+ assert cli._sync() == 0
1009
+ for path in SKILL_OUTPUTS:
1010
+ assert not (tmp_path / path).exists()
1011
+
1012
+ def test_doc_only_removes_preexisting_skill(self, tmp_path, monkeypatch):
1013
+ _init_doc_repo(tmp_path, monkeypatch)
1014
+ for path in SKILL_OUTPUTS:
1015
+ skill_path = tmp_path / path
1016
+ skill_path.parent.mkdir(parents=True, exist_ok=True)
1017
+ skill_path.write_text("old skill\n")
1018
+ assert cli._sync() == 0
1019
+ for path in SKILL_OUTPUTS:
1020
+ assert not (tmp_path / path).exists()
1021
+
1022
+ def test_check_returns_one_when_skill_should_be_removed(
1023
+ self, tmp_path, monkeypatch
1024
+ ):
1025
+ _init_doc_repo(tmp_path, monkeypatch)
1026
+ for path in SKILL_OUTPUTS:
1027
+ skill_path = tmp_path / path
1028
+ skill_path.parent.mkdir(parents=True, exist_ok=True)
1029
+ skill_path.write_text("old skill\n")
1030
+ assert cli._sync(check=True) == 1
1031
+ for path in SKILL_OUTPUTS:
1032
+ assert (tmp_path / path).exists()
1033
+
1001
1034
  def test_idempotent_doc_only(self, tmp_path, monkeypatch):
1002
1035
  _init_doc_repo(tmp_path, monkeypatch)
1003
1036
  cli._sync()
@@ -60,8 +60,9 @@ class TestSyncInstructionFile:
60
60
  assert SECTION_CODE in content
61
61
 
62
62
  def test_replaces_existing_code_section(self, tmp_path):
63
+ # An old plain marker (no fingerprint) is replaced with the current section.
63
64
  path = tmp_path / "CLAUDE.md"
64
- old_section = f"{MARKER_START}\nold content\n{MARKER_END}\n"
65
+ old_section = f"<!-- uncoded:start -->\nold content\n{MARKER_END}\n"
65
66
  path.write_text(f"# My Project\n\n{old_section}")
66
67
  sync_instruction_file(
67
68
  path, code_section=SECTION_CODE, docs_section=None, project_root=tmp_path
@@ -72,8 +73,9 @@ class TestSyncInstructionFile:
72
73
  assert "# My Project" in content
73
74
 
74
75
  def test_preserves_content_after_code_section(self, tmp_path):
76
+ # Content after the old section is preserved when replacing.
75
77
  path = tmp_path / "CLAUDE.md"
76
- old_section = f"{MARKER_START}\nold\n{MARKER_END}\n"
78
+ old_section = f"<!-- uncoded:start -->\nold\n{MARKER_END}\n"
77
79
  path.write_text(f"{old_section}\n## Other section\n")
78
80
  sync_instruction_file(
79
81
  path, code_section=SECTION_CODE, docs_section=None, project_root=tmp_path
@@ -124,11 +126,12 @@ class TestSyncInstructionFile:
124
126
  assert content.index(MARKER_START) < content.index(MARKER_DOCS_START)
125
127
 
126
128
  def test_both_sections_replaced_independently(self, tmp_path):
127
- # Each section is updated without disturbing the other.
129
+ # Old plain markers for both sections are each replaced without
130
+ # disturbing the other.
128
131
  path = tmp_path / "CLAUDE.md"
129
132
  path.write_text(
130
- f"# Title\n\n{MARKER_START}\nold code\n{MARKER_END}\n\n"
131
- f"{MARKER_DOCS_START}\nold docs\n{MARKER_DOCS_END}\n"
133
+ f"# Title\n\n<!-- uncoded:start -->\nold code\n{MARKER_END}\n\n"
134
+ f"<!-- uncoded:docs:start -->\nold docs\n{MARKER_DOCS_END}\n"
132
135
  )
133
136
  sync_instruction_file(
134
137
  path,
@@ -295,3 +298,83 @@ class TestSyncInstructionFileProjectRootAnchor:
295
298
  content = (tmp_path / rel).read_text()
296
299
  assert "# My Project" in content
297
300
  assert SECTION_CODE in content
301
+
302
+
303
+ class TestSyncInstructionFileFingerprint:
304
+ def test_reflowed_body_survives_sync(self, tmp_path):
305
+ # Within a version, a formatter's reflow of the section body does
306
+ # not trigger a rewrite — the opening marker fingerprint still matches.
307
+ path = tmp_path / "CLAUDE.md"
308
+ sync_instruction_file(
309
+ path, code_section=SECTION_CODE, docs_section=None, project_root=tmp_path
310
+ )
311
+ # Simulate a markdown formatter reflowing the body between the markers.
312
+ path.write_text(f"{MARKER_START}\nReflowed body.\n{MARKER_END}\n")
313
+ result = sync_instruction_file(
314
+ path, code_section=SECTION_CODE, docs_section=None, project_root=tmp_path
315
+ )
316
+ assert result is False
317
+ assert "Reflowed body." in path.read_text()
318
+
319
+ def test_reflowed_body_passes_check(self, tmp_path):
320
+ # check mode also reports no change when the opening marker matches.
321
+ path = tmp_path / "CLAUDE.md"
322
+ sync_instruction_file(
323
+ path, code_section=SECTION_CODE, docs_section=None, project_root=tmp_path
324
+ )
325
+ path.write_text(f"{MARKER_START}\nReflowed body.\n{MARKER_END}\n")
326
+ result = sync_instruction_file(
327
+ path,
328
+ code_section=SECTION_CODE,
329
+ docs_section=None,
330
+ project_root=tmp_path,
331
+ check=True,
332
+ )
333
+ assert result is False
334
+
335
+ def test_different_fingerprint_refreshes_section(self, tmp_path):
336
+ # A section from an older uncoded version (different fingerprint)
337
+ # is replaced with the current canonical section.
338
+ path = tmp_path / "CLAUDE.md"
339
+ old_marker = "<!-- uncoded:start sha256=deadbeef -->"
340
+ path.write_text(f"{old_marker}\nOld wording.\n{MARKER_END}\n")
341
+ result = sync_instruction_file(
342
+ path, code_section=SECTION_CODE, docs_section=None, project_root=tmp_path
343
+ )
344
+ assert result is True
345
+ content = path.read_text()
346
+ assert SECTION_CODE in content
347
+ assert "Old wording." not in content
348
+
349
+ def test_plain_marker_refreshes_once_then_stable(self, tmp_path):
350
+ # An old plain marker (no fingerprint) is refreshed on the first
351
+ # sync after upgrading uncoded, then stays stable.
352
+ path = tmp_path / "CLAUDE.md"
353
+ path.write_text(f"<!-- uncoded:start -->\nold body\n{MARKER_END}\n")
354
+ result = sync_instruction_file(
355
+ path, code_section=SECTION_CODE, docs_section=None, project_root=tmp_path
356
+ )
357
+ assert result is True
358
+ assert SECTION_CODE in path.read_text()
359
+ result = sync_instruction_file(
360
+ path, code_section=SECTION_CODE, docs_section=None, project_root=tmp_path
361
+ )
362
+ assert result is False
363
+
364
+ def test_prose_mention_of_prefix_before_section_is_ignored(self, tmp_path):
365
+ # A line that contains the marker prefix inside prose (not at column 0)
366
+ # must not be mistaken for the section opener. Only a line that starts
367
+ # at column 0 is a valid opener.
368
+ path = tmp_path / "CLAUDE.md"
369
+ prose = "Use `<!-- uncoded:start` markers to delimit sections.\n\n"
370
+ old_section = f"<!-- uncoded:start -->\nold body\n{MARKER_END}\n"
371
+ path.write_text(f"{prose}{old_section}")
372
+ result = sync_instruction_file(
373
+ path, code_section=SECTION_CODE, docs_section=None, project_root=tmp_path
374
+ )
375
+ # The real section (old plain marker) is replaced; the prose is preserved.
376
+ assert result is True
377
+ content = path.read_text()
378
+ assert "<!-- uncoded:start` markers" in content
379
+ assert "old body" not in content
380
+ assert SECTION_CODE in content
@@ -18,43 +18,43 @@ class TestSyncSkill:
18
18
  assert "name: uncoded-review\n" not in _SKILL_CONTENT
19
19
 
20
20
  def test_writes_skill_files(self, tmp_path):
21
- sync_skill(project_root=tmp_path, check=False)
21
+ sync_skill(project_root=tmp_path, check=False, build=True)
22
22
  for path in SKILL_OUTPUTS:
23
23
  skill_path = tmp_path / path
24
24
  assert skill_path.exists()
25
25
  assert skill_path.read_text() == _SKILL_CONTENT
26
26
 
27
27
  def test_creates_parent_directories(self, tmp_path):
28
- sync_skill(project_root=tmp_path, check=False)
28
+ sync_skill(project_root=tmp_path, check=False, build=True)
29
29
  for path in SKILL_OUTPUTS:
30
30
  assert (tmp_path / path).parent.is_dir()
31
31
 
32
32
  def test_returns_true_on_first_write(self, tmp_path):
33
- assert sync_skill(project_root=tmp_path, check=False) is True
33
+ assert sync_skill(project_root=tmp_path, check=False, build=True) is True
34
34
 
35
35
  def test_returns_false_when_already_in_sync(self, tmp_path):
36
- sync_skill(project_root=tmp_path, check=False)
37
- assert sync_skill(project_root=tmp_path, check=False) is False
36
+ sync_skill(project_root=tmp_path, check=False, build=True)
37
+ assert sync_skill(project_root=tmp_path, check=False, build=True) is False
38
38
 
39
39
  def test_idempotent(self, tmp_path):
40
- sync_skill(project_root=tmp_path, check=False)
40
+ sync_skill(project_root=tmp_path, check=False, build=True)
41
41
  mtimes = [(tmp_path / path).stat().st_mtime_ns for path in SKILL_OUTPUTS]
42
- sync_skill(project_root=tmp_path, check=False)
42
+ sync_skill(project_root=tmp_path, check=False, build=True)
43
43
  assert [
44
44
  (tmp_path / path).stat().st_mtime_ns for path in SKILL_OUTPUTS
45
45
  ] == mtimes
46
46
 
47
47
  def test_check_mode_does_not_write(self, tmp_path):
48
- sync_skill(project_root=tmp_path, check=True)
48
+ sync_skill(project_root=tmp_path, check=True, build=True)
49
49
  for path in SKILL_OUTPUTS:
50
50
  assert not (tmp_path / path).exists()
51
51
 
52
52
  def test_check_mode_reports_change_when_missing(self, tmp_path):
53
- assert sync_skill(project_root=tmp_path, check=True) is True
53
+ assert sync_skill(project_root=tmp_path, check=True, build=True) is True
54
54
 
55
55
  def test_check_mode_reports_no_change_when_in_sync(self, tmp_path):
56
- sync_skill(project_root=tmp_path, check=False)
57
- assert sync_skill(project_root=tmp_path, check=True) is False
56
+ sync_skill(project_root=tmp_path, check=False, build=True)
57
+ assert sync_skill(project_root=tmp_path, check=True, build=True) is False
58
58
 
59
59
  def test_removes_legacy_skill_files(self, tmp_path):
60
60
  for path in LEGACY_SKILL_OUTPUTS:
@@ -62,7 +62,7 @@ class TestSyncSkill:
62
62
  legacy_path.parent.mkdir(parents=True, exist_ok=True)
63
63
  legacy_path.write_text("old skill\n")
64
64
 
65
- assert sync_skill(project_root=tmp_path, check=False) is True
65
+ assert sync_skill(project_root=tmp_path, check=False, build=True) is True
66
66
 
67
67
  for path in LEGACY_SKILL_OUTPUTS:
68
68
  assert not (tmp_path / path).exists()
@@ -73,7 +73,7 @@ class TestSyncSkill:
73
73
  legacy_path.parent.mkdir(parents=True, exist_ok=True)
74
74
  legacy_path.write_text("old skill\n")
75
75
 
76
- assert sync_skill(project_root=tmp_path, check=True) is True
76
+ assert sync_skill(project_root=tmp_path, check=True, build=True) is True
77
77
 
78
78
  for path in LEGACY_SKILL_OUTPUTS:
79
79
  assert (tmp_path / path).exists()
@@ -87,7 +87,7 @@ class TestSyncSkillProjectRootAnchor:
87
87
  sub.mkdir()
88
88
  monkeypatch.chdir(sub)
89
89
 
90
- sync_skill(project_root=tmp_path, check=False)
90
+ sync_skill(project_root=tmp_path, check=False, build=True)
91
91
 
92
92
  for path in SKILL_OUTPUTS:
93
93
  assert (tmp_path / path).exists()
@@ -106,7 +106,7 @@ class TestSyncSkillProjectRootAnchor:
106
106
  legacy_path.parent.mkdir(parents=True, exist_ok=True)
107
107
  legacy_path.write_text("old skill\n")
108
108
 
109
- sync_skill(project_root=tmp_path, check=False)
109
+ sync_skill(project_root=tmp_path, check=False, build=True)
110
110
 
111
111
  for path in LEGACY_SKILL_OUTPUTS:
112
112
  assert not (tmp_path / path).exists()
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
File without changes
File without changes
File without changes
File without changes