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.
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/namespace.yaml +13 -2
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/instruction_files.pyi +7 -3
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/skill.pyi +1 -1
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_cli.pyi +10 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_instruction_files.pyi +16 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/AGENTS.md +2 -2
- {uncoded-1.1.0 → uncoded-1.2.0}/PKG-INFO +1 -1
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/cli.py +7 -2
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/instruction_files.py +55 -11
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/skill.py +16 -9
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_cli.py +34 -1
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_instruction_files.py +88 -5
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_skill.py +15 -15
- {uncoded-1.1.0 → uncoded-1.2.0}/.agents/skills/coherence-review/SKILL.md +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.claude/skills/coherence-review/SKILL.md +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.github/workflows/ci.yml +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.github/workflows/publish.yml +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.gitignore +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.markdownlint-cli2.yaml +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.pre-commit-config.yaml +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/docs.yaml +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/__init__.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/ast_helpers.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/body.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/cli.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/config.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/docs_map.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/extract.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/namespace_map.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/refs.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/resolver.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/stubs.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/sync.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/src/uncoded/yaml_tree.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_body.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_config.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_docs_map.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_extract.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_namespace_map.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_refs.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_skill.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_stubs.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_sync.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/.uncoded/stubs/tests/test_uncoded.pyi +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/CLAUDE.md +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/LICENSE +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/README.md +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/pyproject.toml +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/__init__.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/ast_helpers.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/body.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/coherence_review.md +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/config.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/dispatch_rule.md +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/docs_map.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/docs_rule.md +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/extract.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/namespace_map.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/refs.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/resolver.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/stubs.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/sync.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/src/uncoded/yaml_tree.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/__init__.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_body.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_config.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_docs_map.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_extract.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_namespace_map.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_refs.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_stubs.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_sync.py +0 -0
- {uncoded-1.1.0 → uncoded-1.2.0}/tests/test_uncoded.py +0 -0
- {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.
|
|
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(
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
return
|
|
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(
|
|
90
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
|
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"
|
|
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"
|
|
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
|
-
#
|
|
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
|
|
131
|
-
f"
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|