claude-dev-env 1.50.3 → 1.51.0
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.
- package/CLAUDE.md +0 -8
- package/_shared/pr-loop/audit-contract.md +3 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/preflight_self_heal_constants.py +28 -0
- package/_shared/pr-loop/scripts/preflight.py +18 -6
- package/_shared/pr-loop/scripts/preflight_self_heal.py +164 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +39 -0
- package/_shared/pr-loop/scripts/tests/test_preflight_self_heal.py +273 -0
- package/agents/clean-coder.md +1 -1
- package/agents/code-quality-agent.md +7 -5
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +3 -0
- package/audit-rubrics/category_rubrics/category-f-silent-failures.md +3 -0
- package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +8 -2
- package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +3 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +39 -0
- package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +40 -0
- package/audit-rubrics/prompts/category-a-api-contracts.md +11 -4
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/audit-rubrics/prompts/category-c-resource-cleanup.md +1 -1
- package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +1 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +1 -1
- package/audit-rubrics/prompts/category-f-silent-failures.md +13 -2
- package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +1 -1
- package/audit-rubrics/prompts/category-h-security-boundaries.md +1 -1
- package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/prompts/category-k-codebase-conflicts.md +15 -5
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +1 -1
- package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +1 -1
- package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +10 -3
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +74 -0
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +75 -0
- package/docs/CODE_RULES.md +24 -346
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +2 -41
- package/rules/confirm-implementation-forks.md +3 -44
- package/rules/gh-body-file.md +2 -78
- package/rules/gh-paginate.md +2 -78
- package/rules/plain-language.md +2 -41
- package/rules/prompt-workflow-context-controls.md +9 -38
- package/rules/shell-invocation-policy.md +2 -141
- package/rules/testing.md +10 -0
- package/rules/vault-context.md +3 -32
- package/rules/windows-filesystem-safe.md +3 -87
- package/scripts/sync_to_cursor/rules.py +201 -79
- package/scripts/tests/test_sync_to_cursor.py +122 -26
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +2 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +51 -4
- package/skills/auditing-claude-config/SKILL.md +6 -1
- package/skills/bugteam/CONSTRAINTS.md +1 -1
- package/skills/bugteam/PROMPTS.md +8 -6
- package/skills/bugteam/SKILL.md +5 -5
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/audit-contract.md +4 -4
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
- package/skills/bugteam/reference/team-setup.md +17 -5
- package/skills/bugteam/scripts/bugteam_preflight.py +22 -10
- package/skills/bugteam/scripts/test_bugteam_preflight.py +32 -0
- package/skills/copilot-review/SKILL.md +5 -8
- package/skills/doc-gist/SKILL.md +5 -8
- package/skills/fixbugs/SKILL.md +1 -1
- package/skills/gh-paginate/SKILL.md +84 -0
- package/skills/pr-converge/SKILL.md +28 -1
- package/skills/pr-converge/reference/per-tick.md +24 -8
- package/skills/pre-compact/SKILL.md +4 -9
- package/skills/refine/SKILL.md +8 -2
- package/skills/structure-prompt/SKILL.md +5 -10
|
@@ -13,18 +13,18 @@ from sync_to_cursor.config import MAX_RULE_BODY_LINES
|
|
|
13
13
|
def _parse_h2_sections(markdown: str) -> dict[str, str]:
|
|
14
14
|
parts = re.split(r"^## ", markdown, flags=re.MULTILINE)
|
|
15
15
|
sections: dict[str, str] = {}
|
|
16
|
-
for
|
|
17
|
-
title_line, _, body =
|
|
16
|
+
for each_part in parts[1:]:
|
|
17
|
+
title_line, _, body = each_part.partition("\n")
|
|
18
18
|
sections[title_line.strip()] = body.strip()
|
|
19
19
|
return sections
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def _filter_core_principles(body: str) -> str:
|
|
23
23
|
lines = []
|
|
24
|
-
for
|
|
25
|
-
if "readability standard" in
|
|
24
|
+
for each_line in body.splitlines():
|
|
25
|
+
if "readability standard" in each_line:
|
|
26
26
|
continue
|
|
27
|
-
lines.append(
|
|
27
|
+
lines.append(each_line)
|
|
28
28
|
return "\n".join(lines).strip()
|
|
29
29
|
|
|
30
30
|
|
|
@@ -56,14 +56,54 @@ def _strip_code_standards_blockquote(markdown: str) -> str:
|
|
|
56
56
|
return "\n".join(out).strip()
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
_merged_mapping_key_order = (
|
|
60
|
+
"code-standards",
|
|
61
|
+
"tasklings-preferences",
|
|
62
|
+
"right-sized-engineering",
|
|
63
|
+
"bdd",
|
|
64
|
+
"test-quality",
|
|
65
|
+
"research-mode",
|
|
66
|
+
"conservative-action",
|
|
67
|
+
"explore-thoroughly",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_code_standards_section_order = (
|
|
71
|
+
"COMMENT PRESERVATION (ABSOLUTE RULE)",
|
|
72
|
+
"CORE PRINCIPLES",
|
|
73
|
+
"⚡ HOOK-ENFORCED RULES",
|
|
74
|
+
"3. REUSE CONSTANTS / 4. CONFIG LOCATIONS",
|
|
75
|
+
"5. NO ABBREVIATIONS",
|
|
76
|
+
"6. COMPLETE TYPE HINTS",
|
|
77
|
+
"9. SELF-CONTAINED COMPONENTS",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
_test_quality_section_order = (
|
|
81
|
+
"Delete Useless Tests",
|
|
82
|
+
"Test Dependencies MUST FAIL",
|
|
83
|
+
"Core Testing Principles",
|
|
84
|
+
"React Testing Patterns",
|
|
85
|
+
"Test File Organization",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def merge_code_standards(all_sources: tuple[Path, ...]) -> str:
|
|
90
|
+
"""Merge the code-standards rule and CODE_RULES doc into one Cursor rule body.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
all_sources: The code-standards rule path then the CODE_RULES doc path.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The merged Cursor rule body, truncated to the maximum rule body length.
|
|
97
|
+
"""
|
|
60
98
|
code_standards_markdown = _strip_code_standards_blockquote(
|
|
61
|
-
|
|
99
|
+
all_sources[0].read_text(encoding="utf-8")
|
|
62
100
|
)
|
|
63
101
|
code_standards_markdown = "\n".join(
|
|
64
|
-
|
|
102
|
+
each_line
|
|
103
|
+
for each_line in code_standards_markdown.splitlines()
|
|
104
|
+
if not each_line.strip().startswith("- TDD ")
|
|
65
105
|
)
|
|
66
|
-
code_rules_markdown =
|
|
106
|
+
code_rules_markdown = all_sources[1].read_text(encoding="utf-8")
|
|
67
107
|
sections_by_heading = _parse_h2_sections(code_rules_markdown)
|
|
68
108
|
if not sections_by_heading:
|
|
69
109
|
pointer_fallback_note = (
|
|
@@ -72,63 +112,71 @@ def merge_code_standards(sources: tuple[Path, ...]) -> str:
|
|
|
72
112
|
)
|
|
73
113
|
chunks = [code_standards_markdown, "", pointer_fallback_note]
|
|
74
114
|
return _limit_lines("\n\n".join(chunks), MAX_RULE_BODY_LINES)
|
|
75
|
-
include_order = [
|
|
76
|
-
"COMMENT PRESERVATION (ABSOLUTE RULE)",
|
|
77
|
-
"CORE PRINCIPLES",
|
|
78
|
-
"⚡ HOOK-ENFORCED RULES",
|
|
79
|
-
"4. CONFIG LOCATIONS",
|
|
80
|
-
"5. NO ABBREVIATIONS",
|
|
81
|
-
"6. COMPLETE TYPE HINTS",
|
|
82
|
-
"9. SELF-CONTAINED COMPONENTS",
|
|
83
|
-
]
|
|
84
115
|
chunks = [
|
|
85
116
|
code_standards_markdown,
|
|
86
117
|
"",
|
|
87
118
|
"## Reference (full text: `.cursor/docs/CODE_RULES.md`)",
|
|
88
119
|
]
|
|
89
|
-
for
|
|
90
|
-
|
|
91
|
-
|
|
120
|
+
for each_title in _code_standards_section_order:
|
|
121
|
+
assert each_title in sections_by_heading, (
|
|
122
|
+
f"merge_code_standards: expected section absent from CODE_RULES.md: {each_title}"
|
|
123
|
+
)
|
|
124
|
+
body = sections_by_heading[each_title]
|
|
125
|
+
if each_title == "CORE PRINCIPLES":
|
|
92
126
|
body = _filter_core_principles(body)
|
|
93
127
|
if body:
|
|
94
|
-
chunks.append(f"## {
|
|
128
|
+
chunks.append(f"## {each_title}\n\n{body}")
|
|
95
129
|
merged = "\n\n".join(chunks)
|
|
96
130
|
return _limit_lines(merged, MAX_RULE_BODY_LINES)
|
|
97
131
|
|
|
98
132
|
|
|
99
|
-
def merge_test_quality(
|
|
100
|
-
testing
|
|
101
|
-
|
|
133
|
+
def merge_test_quality(all_sources: tuple[Path, ...]) -> str:
|
|
134
|
+
"""Merge the testing rule and TEST_QUALITY doc into one Cursor rule body.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
all_sources: The testing rule path then the TEST_QUALITY doc path.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The merged Cursor rule body, truncated to the maximum rule body length.
|
|
141
|
+
"""
|
|
142
|
+
testing = strip_leading_yaml_frontmatter(
|
|
143
|
+
all_sources[0].read_text(encoding="utf-8")
|
|
144
|
+
).strip()
|
|
145
|
+
test_quality_markdown = all_sources[1].read_text(encoding="utf-8")
|
|
102
146
|
sections_by_heading = _parse_h2_sections(test_quality_markdown)
|
|
103
|
-
include_order = [
|
|
104
|
-
"Delete Useless Tests",
|
|
105
|
-
"Test Dependencies MUST FAIL",
|
|
106
|
-
"Core Testing Principles",
|
|
107
|
-
"React Testing Patterns",
|
|
108
|
-
"Test File Organization",
|
|
109
|
-
]
|
|
110
147
|
chunks = [testing, "", "## Reference (full text: `.cursor/docs/TEST_QUALITY.md`)"]
|
|
111
|
-
for
|
|
112
|
-
|
|
148
|
+
for each_title in _test_quality_section_order:
|
|
149
|
+
assert each_title in sections_by_heading, (
|
|
150
|
+
f"merge_test_quality: expected section absent from TEST_QUALITY.md: {each_title}"
|
|
151
|
+
)
|
|
152
|
+
body = sections_by_heading[each_title]
|
|
113
153
|
if body:
|
|
114
|
-
chunks.append(f"## {
|
|
154
|
+
chunks.append(f"## {each_title}\n\n{body}")
|
|
115
155
|
merged = "\n\n".join(chunks)
|
|
116
156
|
return _limit_lines(merged, MAX_RULE_BODY_LINES)
|
|
117
157
|
|
|
118
158
|
|
|
119
159
|
def strip_anthropic_refs(text: str) -> str:
|
|
160
|
+
"""Remove Anthropic source citations and the do-not-act wrapper from rule text.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
text: The rule body to clean.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The rule body without Anthropic citation lines or the wrapper tags.
|
|
167
|
+
"""
|
|
120
168
|
out_lines: list[str] = []
|
|
121
|
-
for
|
|
122
|
-
|
|
123
|
-
if
|
|
124
|
-
"anthropic" in
|
|
125
|
-
or "claude.com" in
|
|
126
|
-
or "docs.anthropic" in
|
|
169
|
+
for each_line in text.splitlines():
|
|
170
|
+
stripped_line = each_line.strip()
|
|
171
|
+
if stripped_line.startswith("Source:") and (
|
|
172
|
+
"anthropic" in stripped_line.lower()
|
|
173
|
+
or "claude.com" in stripped_line.lower()
|
|
174
|
+
or "docs.anthropic" in stripped_line.lower()
|
|
127
175
|
):
|
|
128
176
|
continue
|
|
129
|
-
if "docs.anthropic.com" in
|
|
177
|
+
if "docs.anthropic.com" in each_line:
|
|
130
178
|
continue
|
|
131
|
-
out_lines.append(
|
|
179
|
+
out_lines.append(each_line)
|
|
132
180
|
text = "\n".join(out_lines)
|
|
133
181
|
text = re.sub(
|
|
134
182
|
r"<do_not_act_before_instructions>\s*",
|
|
@@ -149,7 +197,14 @@ def near_verbatim(text: str) -> str:
|
|
|
149
197
|
|
|
150
198
|
|
|
151
199
|
def strip_leading_yaml_frontmatter(text: str) -> str:
|
|
152
|
-
"""Remove leading `---` ... `---` block
|
|
200
|
+
"""Remove a leading `---` ... `---` block so Cursor `.mdc` keeps its own frontmatter.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
text: The rule body that may open with a Claude YAML frontmatter block.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The rule body with any leading frontmatter block removed.
|
|
207
|
+
"""
|
|
153
208
|
lines = text.splitlines()
|
|
154
209
|
if not lines or lines[0].strip() != "---":
|
|
155
210
|
return text
|
|
@@ -164,15 +219,29 @@ TransformName = Literal["verbatim", "near_verbatim", "merge_code_standards", "me
|
|
|
164
219
|
|
|
165
220
|
def apply_transform(
|
|
166
221
|
name: TransformName,
|
|
167
|
-
|
|
222
|
+
all_sources: tuple[Path, ...],
|
|
168
223
|
*,
|
|
169
224
|
strip_leading_frontmatter: bool = False,
|
|
170
225
|
) -> str:
|
|
226
|
+
"""Run the named transform over the source files and return the rule body.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
name: The transform identifier selecting how the sources combine.
|
|
230
|
+
all_sources: The source files the transform reads.
|
|
231
|
+
strip_leading_frontmatter: When True, drop a leading YAML frontmatter
|
|
232
|
+
block from the raw concatenation before a verbatim transform.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The transformed Cursor rule body.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
AssertionError: When *name* is not a recognized transform identifier.
|
|
239
|
+
"""
|
|
171
240
|
if name == "merge_code_standards":
|
|
172
|
-
return merge_code_standards(
|
|
241
|
+
return merge_code_standards(all_sources)
|
|
173
242
|
if name == "merge_test_quality":
|
|
174
|
-
return merge_test_quality(
|
|
175
|
-
raw = "\n\n".join(
|
|
243
|
+
return merge_test_quality(all_sources)
|
|
244
|
+
raw = "\n\n".join(each_source.read_text(encoding="utf-8") for each_source in all_sources)
|
|
176
245
|
if strip_leading_frontmatter:
|
|
177
246
|
raw = strip_leading_yaml_frontmatter(raw)
|
|
178
247
|
if name == "verbatim":
|
|
@@ -216,7 +285,14 @@ def _full_mdc(mapping: RuleMapping, body: str) -> str:
|
|
|
216
285
|
|
|
217
286
|
|
|
218
287
|
def _read_paths_glob(rule_file: Path) -> str | None:
|
|
219
|
-
"""Read `paths:` list from a Claude rule's YAML frontmatter
|
|
288
|
+
"""Read the `paths:` list from a Claude rule's YAML frontmatter as a Cursor glob.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
rule_file: The Claude rule file whose frontmatter may declare `paths:`.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
The comma-separated glob string, or None when the rule declares no paths.
|
|
295
|
+
"""
|
|
220
296
|
if not rule_file.is_file():
|
|
221
297
|
return None
|
|
222
298
|
lines = rule_file.read_text(encoding="utf-8").splitlines()
|
|
@@ -224,26 +300,46 @@ def _read_paths_glob(rule_file: Path) -> str | None:
|
|
|
224
300
|
return None
|
|
225
301
|
is_in_paths = False
|
|
226
302
|
all_paths: list[str] = []
|
|
227
|
-
for
|
|
228
|
-
if
|
|
303
|
+
for each_line in lines[1:]:
|
|
304
|
+
if each_line.strip() == "---":
|
|
229
305
|
break
|
|
230
|
-
if
|
|
306
|
+
if each_line.startswith("paths:"):
|
|
231
307
|
is_in_paths = True
|
|
232
308
|
continue
|
|
233
309
|
if is_in_paths:
|
|
234
|
-
if
|
|
235
|
-
|
|
236
|
-
if
|
|
237
|
-
all_paths.append(
|
|
310
|
+
if each_line.startswith(" ") or each_line.startswith("\t"):
|
|
311
|
+
stripped_path = each_line.strip().lstrip("-").strip().strip('"').strip("'")
|
|
312
|
+
if stripped_path:
|
|
313
|
+
all_paths.append(stripped_path)
|
|
238
314
|
else:
|
|
239
315
|
is_in_paths = False
|
|
240
316
|
return ",".join(all_paths) if all_paths else None
|
|
241
317
|
|
|
242
318
|
|
|
243
|
-
def
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
319
|
+
def _require_paths_glob(rule_file: Path) -> str | None:
|
|
320
|
+
"""Return the path glob for an optional rule, requiring frontmatter when it exists.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
rule_file: The Claude rule file whose frontmatter may declare `paths:`.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
The comma-separated glob string, or None when the rule file is absent.
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
AssertionError: When the rule file exists but declares no `paths:`
|
|
330
|
+
frontmatter, which would silently disable the rule in Cursor.
|
|
331
|
+
"""
|
|
332
|
+
if not rule_file.is_file():
|
|
333
|
+
return None
|
|
334
|
+
paths_glob = _read_paths_glob(rule_file)
|
|
335
|
+
assert paths_glob is not None, (
|
|
336
|
+
f"{rule_file.name}: path-scoped rule exists but declares no `paths:` frontmatter; "
|
|
337
|
+
"add a `paths:` list or remove the file"
|
|
338
|
+
)
|
|
339
|
+
return paths_glob
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _always_apply_mappings(rules_directory: Path, docs_directory: Path) -> tuple[RuleMapping, ...]:
|
|
247
343
|
return (
|
|
248
344
|
RuleMapping(
|
|
249
345
|
"code-standards",
|
|
@@ -254,16 +350,6 @@ def build_mappings(claude: Path) -> tuple[RuleMapping, ...]:
|
|
|
254
350
|
"Core code standards: naming, types, config, hook-enforced rules",
|
|
255
351
|
"merge_code_standards",
|
|
256
352
|
),
|
|
257
|
-
RuleMapping(
|
|
258
|
-
"tasklings-preferences",
|
|
259
|
-
(rules_directory / "tasklings-preferences.md",),
|
|
260
|
-
"tasklings-preferences.mdc",
|
|
261
|
-
False,
|
|
262
|
-
_read_paths_glob(rules_directory / "tasklings-preferences.md"),
|
|
263
|
-
"Tasklings: Prefer / Do / Always engineering preferences (scoped path)",
|
|
264
|
-
"verbatim",
|
|
265
|
-
True,
|
|
266
|
-
),
|
|
267
353
|
RuleMapping(
|
|
268
354
|
"right-sized-engineering",
|
|
269
355
|
(rules_directory / "right-sized-engineering.md",),
|
|
@@ -282,15 +368,6 @@ def build_mappings(claude: Path) -> tuple[RuleMapping, ...]:
|
|
|
282
368
|
"BDD: discovery-driven protocol (outside-in; Illustrate→Formulate→Automate)",
|
|
283
369
|
"verbatim",
|
|
284
370
|
),
|
|
285
|
-
RuleMapping(
|
|
286
|
-
"test-quality",
|
|
287
|
-
(rules_directory / "testing.md", docs_directory / "TEST_QUALITY.md"),
|
|
288
|
-
"test-quality.mdc",
|
|
289
|
-
False,
|
|
290
|
-
test_globs,
|
|
291
|
-
"Testing quality for test files",
|
|
292
|
-
"merge_test_quality",
|
|
293
|
-
),
|
|
294
371
|
RuleMapping(
|
|
295
372
|
"research-mode",
|
|
296
373
|
(rules_directory / "research-mode.md",),
|
|
@@ -319,3 +396,48 @@ def build_mappings(claude: Path) -> tuple[RuleMapping, ...]:
|
|
|
319
396
|
"near_verbatim",
|
|
320
397
|
),
|
|
321
398
|
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _path_scoped_mappings(rules_directory: Path, docs_directory: Path) -> tuple[RuleMapping, ...]:
|
|
402
|
+
return (
|
|
403
|
+
RuleMapping(
|
|
404
|
+
"tasklings-preferences",
|
|
405
|
+
(rules_directory / "tasklings-preferences.md",),
|
|
406
|
+
"tasklings-preferences.mdc",
|
|
407
|
+
False,
|
|
408
|
+
_require_paths_glob(rules_directory / "tasklings-preferences.md"),
|
|
409
|
+
"Tasklings: Prefer / Do / Always engineering preferences (scoped path)",
|
|
410
|
+
"verbatim",
|
|
411
|
+
True,
|
|
412
|
+
),
|
|
413
|
+
RuleMapping(
|
|
414
|
+
"test-quality",
|
|
415
|
+
(rules_directory / "testing.md", docs_directory / "TEST_QUALITY.md"),
|
|
416
|
+
"test-quality.mdc",
|
|
417
|
+
False,
|
|
418
|
+
_require_paths_glob(rules_directory / "testing.md"),
|
|
419
|
+
"Testing quality for test files",
|
|
420
|
+
"merge_test_quality",
|
|
421
|
+
),
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def build_mappings(claude: Path) -> tuple[RuleMapping, ...]:
|
|
426
|
+
"""Resolve every rule into a concrete Cursor mapping against a Claude layout.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
claude: The Claude layout root holding the `rules` and `docs` directories.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
One RuleMapping per rule, each path-scoped rule carrying a glob derived
|
|
433
|
+
from its source rule's `paths:` frontmatter.
|
|
434
|
+
"""
|
|
435
|
+
rules_directory = claude / "rules"
|
|
436
|
+
docs_directory = claude / "docs"
|
|
437
|
+
all_mappings = (
|
|
438
|
+
*_always_apply_mappings(rules_directory, docs_directory),
|
|
439
|
+
*_path_scoped_mappings(rules_directory, docs_directory),
|
|
440
|
+
)
|
|
441
|
+
mapping_by_key = {each_mapping.key: each_mapping for each_mapping in all_mappings}
|
|
442
|
+
assert set(mapping_by_key) == set(_merged_mapping_key_order)
|
|
443
|
+
return tuple(mapping_by_key[each_key] for each_key in _merged_mapping_key_order)
|
|
@@ -14,7 +14,19 @@ if str(_SCRIPTS_DIR) not in sys.path:
|
|
|
14
14
|
|
|
15
15
|
import sync_to_cursor as mod
|
|
16
16
|
from sync_to_cursor.engine import run as run_sync_to_cursor
|
|
17
|
-
from sync_to_cursor.rules import
|
|
17
|
+
from sync_to_cursor.rules import (
|
|
18
|
+
_code_standards_section_order,
|
|
19
|
+
_parse_h2_sections,
|
|
20
|
+
_read_paths_glob,
|
|
21
|
+
_test_quality_section_order,
|
|
22
|
+
build_mappings,
|
|
23
|
+
strip_leading_yaml_frontmatter,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_CLAUDE_DEV_ENV_DIR = _SCRIPTS_DIR.parent
|
|
27
|
+
_REAL_TESTING_RULE = _CLAUDE_DEV_ENV_DIR / "rules" / "testing.md"
|
|
28
|
+
_REAL_CODE_RULES_DOC = _CLAUDE_DEV_ENV_DIR / "docs" / "CODE_RULES.md"
|
|
29
|
+
_REAL_TEST_QUALITY_DOC = _CLAUDE_DEV_ENV_DIR / "docs" / "TEST_QUALITY.md"
|
|
18
30
|
|
|
19
31
|
|
|
20
32
|
def _minimal_rule_files(claude_rules: Path) -> None:
|
|
@@ -28,7 +40,10 @@ def _minimal_rule_files(claude_rules: Path) -> None:
|
|
|
28
40
|
)
|
|
29
41
|
(claude_rules / "right-sized-engineering.md").write_text("# RSE\n", encoding="utf-8")
|
|
30
42
|
(claude_rules / "bdd.md").write_text("# BDD\n", encoding="utf-8")
|
|
31
|
-
(claude_rules / "testing.md").write_text(
|
|
43
|
+
(claude_rules / "testing.md").write_text(
|
|
44
|
+
"---\npaths:\n - \"**/test_*.py\"\n---\n\n# Testing\n",
|
|
45
|
+
encoding="utf-8",
|
|
46
|
+
)
|
|
32
47
|
(claude_rules / "research-mode.md").write_text("# RM\n", encoding="utf-8")
|
|
33
48
|
(claude_rules / "conservative-action.md").write_text("# CA\n", encoding="utf-8")
|
|
34
49
|
(claude_rules / "explore-thoroughly.md").write_text("# ET\n", encoding="utf-8")
|
|
@@ -36,8 +51,12 @@ def _minimal_rule_files(claude_rules: Path) -> None:
|
|
|
36
51
|
|
|
37
52
|
def _minimal_code_rules_and_test_quality(claude_docs: Path) -> tuple[bytes, bytes]:
|
|
38
53
|
claude_docs.mkdir(parents=True, exist_ok=True)
|
|
39
|
-
cr =
|
|
40
|
-
|
|
54
|
+
cr = (
|
|
55
|
+
"\n\n".join(f"## {title}\n\nalpha" for title in _code_standards_section_order) + "\n"
|
|
56
|
+
).encode("utf-8")
|
|
57
|
+
tq = (
|
|
58
|
+
"\n\n".join(f"## {title}\n\nbeta" for title in _test_quality_section_order) + "\n"
|
|
59
|
+
).encode("utf-8")
|
|
41
60
|
(claude_docs / "CODE_RULES.md").write_bytes(cr)
|
|
42
61
|
(claude_docs / "TEST_QUALITY.md").write_bytes(tq)
|
|
43
62
|
return cr, tq
|
|
@@ -168,6 +187,18 @@ def test_check_skips_optional_mapping_when_source_missing(
|
|
|
168
187
|
assert run_sync_to_cursor(["--check"]) == 0, "--check must pass when only optional sources are missing"
|
|
169
188
|
|
|
170
189
|
|
|
190
|
+
def test_test_quality_glob_derived_from_testing_frontmatter() -> None:
|
|
191
|
+
expected_glob = _read_paths_glob(_REAL_TESTING_RULE)
|
|
192
|
+
assert expected_glob, "testing.md must declare a non-empty paths frontmatter"
|
|
193
|
+
pinned_glob = "**/test_*.py,**/*_test.py,**/*.test.*,**/*.spec.*,**/conftest.py,**/tests/**"
|
|
194
|
+
assert expected_glob == pinned_glob
|
|
195
|
+
test_quality_mapping = next(
|
|
196
|
+
mapping for mapping in build_mappings(_CLAUDE_DEV_ENV_DIR) if mapping.key == "test-quality"
|
|
197
|
+
)
|
|
198
|
+
assert test_quality_mapping.globs == expected_glob
|
|
199
|
+
assert test_quality_mapping.globs == pinned_glob
|
|
200
|
+
|
|
201
|
+
|
|
171
202
|
def test_tasklings_glob_derived_from_frontmatter(tmp_path: Path) -> None:
|
|
172
203
|
rules_directory = tmp_path / "rules"
|
|
173
204
|
rules_directory.mkdir(parents=True)
|
|
@@ -185,18 +216,7 @@ def test_merge_reference_headers_point_at_cursor_docs(tmp_path: Path) -> None:
|
|
|
185
216
|
rules_directory.mkdir(parents=True, exist_ok=True)
|
|
186
217
|
docs_directory.mkdir(parents=True, exist_ok=True)
|
|
187
218
|
(rules_directory / "code-standards.md").write_text("# CS\n", encoding="utf-8")
|
|
188
|
-
cr_body = "\n\n".join(
|
|
189
|
-
f"## {t}\n\nbody"
|
|
190
|
-
for t in [
|
|
191
|
-
"COMMENT PRESERVATION (ABSOLUTE RULE)",
|
|
192
|
-
"CORE PRINCIPLES",
|
|
193
|
-
"⚡ HOOK-ENFORCED RULES",
|
|
194
|
-
"4. CONFIG LOCATIONS",
|
|
195
|
-
"5. NO ABBREVIATIONS",
|
|
196
|
-
"6. COMPLETE TYPE HINTS",
|
|
197
|
-
"9. SELF-CONTAINED COMPONENTS",
|
|
198
|
-
]
|
|
199
|
-
)
|
|
219
|
+
cr_body = "\n\n".join(f"## {t}\n\nbody" for t in _code_standards_section_order)
|
|
200
220
|
(docs_directory / "CODE_RULES.md").write_text(cr_body, encoding="utf-8")
|
|
201
221
|
merged = mod.merge_code_standards(
|
|
202
222
|
(rules_directory / "code-standards.md", docs_directory / "CODE_RULES.md")
|
|
@@ -204,18 +224,94 @@ def test_merge_reference_headers_point_at_cursor_docs(tmp_path: Path) -> None:
|
|
|
204
224
|
assert ".cursor/docs/CODE_RULES.md" in merged
|
|
205
225
|
|
|
206
226
|
(rules_directory / "testing.md").write_text("# T\n", encoding="utf-8")
|
|
207
|
-
tq_body = "\n\n".join(
|
|
208
|
-
f"## {t}\n\nbody"
|
|
209
|
-
for t in [
|
|
210
|
-
"Delete Useless Tests",
|
|
211
|
-
"Test Dependencies MUST FAIL",
|
|
212
|
-
"Core Testing Principles",
|
|
213
|
-
"React Testing Patterns",
|
|
214
|
-
"Test File Organization",
|
|
215
|
-
]
|
|
216
|
-
)
|
|
227
|
+
tq_body = "\n\n".join(f"## {t}\n\nbody" for t in _test_quality_section_order)
|
|
217
228
|
(docs_directory / "TEST_QUALITY.md").write_text(tq_body, encoding="utf-8")
|
|
218
229
|
merged_tq = mod.merge_test_quality(
|
|
219
230
|
(rules_directory / "testing.md", docs_directory / "TEST_QUALITY.md")
|
|
220
231
|
)
|
|
221
232
|
assert ".cursor/docs/TEST_QUALITY.md" in merged_tq
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_code_standards_section_order_titles_all_exist_in_real_doc() -> None:
|
|
236
|
+
real_headings = set(_parse_h2_sections(_REAL_CODE_RULES_DOC.read_text(encoding="utf-8")))
|
|
237
|
+
missing_titles = [title for title in _code_standards_section_order if title not in real_headings]
|
|
238
|
+
assert missing_titles == [], (
|
|
239
|
+
f"_code_standards_section_order titles absent from {_REAL_CODE_RULES_DOC.name}: "
|
|
240
|
+
f"{missing_titles}"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_test_quality_section_order_titles_all_exist_in_real_doc() -> None:
|
|
245
|
+
real_headings = set(_parse_h2_sections(_REAL_TEST_QUALITY_DOC.read_text(encoding="utf-8")))
|
|
246
|
+
missing_titles = [title for title in _test_quality_section_order if title not in real_headings]
|
|
247
|
+
assert missing_titles == [], (
|
|
248
|
+
f"_test_quality_section_order titles absent from {_REAL_TEST_QUALITY_DOC.name}: "
|
|
249
|
+
f"{missing_titles}"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_merge_code_standards_raises_when_expected_section_absent(tmp_path: Path) -> None:
|
|
254
|
+
rules_directory = tmp_path / "rules"
|
|
255
|
+
docs_directory = tmp_path / "docs"
|
|
256
|
+
rules_directory.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
docs_directory.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
(rules_directory / "code-standards.md").write_text("# CS\n", encoding="utf-8")
|
|
259
|
+
present_titles = [
|
|
260
|
+
title for title in _code_standards_section_order if title != "5. NO ABBREVIATIONS"
|
|
261
|
+
]
|
|
262
|
+
cr_body = "\n\n".join(f"## {title}\n\nbody" for title in present_titles)
|
|
263
|
+
(docs_directory / "CODE_RULES.md").write_text(cr_body, encoding="utf-8")
|
|
264
|
+
with pytest.raises(AssertionError, match="5. NO ABBREVIATIONS"):
|
|
265
|
+
mod.merge_code_standards(
|
|
266
|
+
(rules_directory / "code-standards.md", docs_directory / "CODE_RULES.md")
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_build_mappings_raises_when_testing_lacks_paths_frontmatter(tmp_path: Path) -> None:
|
|
271
|
+
claude = tmp_path / ".claude"
|
|
272
|
+
_minimal_rule_files(claude / "rules")
|
|
273
|
+
_minimal_code_rules_and_test_quality(claude / "docs")
|
|
274
|
+
(claude / "rules" / "testing.md").write_text("# Testing\n", encoding="utf-8")
|
|
275
|
+
with pytest.raises(AssertionError, match="testing.md"):
|
|
276
|
+
build_mappings(claude)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_merge_test_quality_raises_when_expected_section_absent(tmp_path: Path) -> None:
|
|
280
|
+
rules_directory = tmp_path / "rules"
|
|
281
|
+
docs_directory = tmp_path / "docs"
|
|
282
|
+
rules_directory.mkdir(parents=True, exist_ok=True)
|
|
283
|
+
docs_directory.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
(rules_directory / "testing.md").write_text("# T\n", encoding="utf-8")
|
|
285
|
+
present_titles = [
|
|
286
|
+
title for title in _test_quality_section_order if title != "Core Testing Principles"
|
|
287
|
+
]
|
|
288
|
+
tq_body = "\n\n".join(f"## {title}\n\nbody" for title in present_titles)
|
|
289
|
+
(docs_directory / "TEST_QUALITY.md").write_text(tq_body, encoding="utf-8")
|
|
290
|
+
with pytest.raises(AssertionError, match="Core Testing Principles"):
|
|
291
|
+
mod.merge_test_quality(
|
|
292
|
+
(rules_directory / "testing.md", docs_directory / "TEST_QUALITY.md")
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_merge_test_quality_strips_leading_paths_frontmatter(tmp_path: Path) -> None:
|
|
297
|
+
docs_directory = tmp_path / "docs"
|
|
298
|
+
docs_directory.mkdir(parents=True, exist_ok=True)
|
|
299
|
+
test_quality_body = "\n\n".join(
|
|
300
|
+
f"## {title}\n\nbody" for title in _test_quality_section_order
|
|
301
|
+
)
|
|
302
|
+
(docs_directory / "TEST_QUALITY.md").write_text(test_quality_body, encoding="utf-8")
|
|
303
|
+
merged = mod.merge_test_quality(
|
|
304
|
+
(_REAL_TESTING_RULE, docs_directory / "TEST_QUALITY.md")
|
|
305
|
+
)
|
|
306
|
+
testing_text = _REAL_TESTING_RULE.read_text(encoding="utf-8")
|
|
307
|
+
first_content_line = next(
|
|
308
|
+
line for line in strip_leading_yaml_frontmatter(testing_text).splitlines()
|
|
309
|
+
if line.strip()
|
|
310
|
+
)
|
|
311
|
+
merged_lines = merged.splitlines()
|
|
312
|
+
assert merged_lines[0] != "---", "merged body must not open with a leaked frontmatter fence"
|
|
313
|
+
assert merged_lines[0] == first_content_line
|
|
314
|
+
body_before_reference = "\n".join(
|
|
315
|
+
merged_lines[: merged_lines.index("## Reference (full text: `.cursor/docs/TEST_QUALITY.md`)")]
|
|
316
|
+
)
|
|
317
|
+
assert "paths:" not in body_before_reference, "testing.md paths frontmatter leaked into rule body"
|
|
@@ -43,6 +43,8 @@ ALL_AUDIT_CATEGORY_ENTRIES = [
|
|
|
43
43
|
("L", "Behavior-equivalence for refactors"),
|
|
44
44
|
("M", "Producer/consumer cardinality vs collection-type contract"),
|
|
45
45
|
("N", "Test-name scenario verifier"),
|
|
46
|
+
("O", "Docstring / fixture-prose vs implementation drift"),
|
|
47
|
+
("P", "Name / regex / word-list vs behavior-contract precision"),
|
|
46
48
|
]
|
|
47
49
|
|
|
48
50
|
AUDIT_RUBRIC_REFERENCE_TEXT = (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests pinning build_audit_prompt's emitted A-
|
|
1
|
+
"""Tests pinning build_audit_prompt's emitted A-P category taxonomy."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -18,7 +18,7 @@ from skills_pr_loop_constants.path_resolver_constants import (
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
_CATEGORY_RUBRICS_DIR = _SCRIPTS_DIR.parents[3] / "audit-rubrics" / "category_rubrics"
|
|
21
|
-
_HEADING_PATTERN = re.compile(r"^# Category ([A-
|
|
21
|
+
_HEADING_PATTERN = re.compile(r"^# Category ([A-P]) — (.+)$")
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def _load_build_audit_prompt() -> ModuleType:
|
|
@@ -60,12 +60,12 @@ def _build_audit_root() -> Element:
|
|
|
60
60
|
)
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
def
|
|
63
|
+
def test_bug_categories_carry_ids_a_through_p_in_order() -> None:
|
|
64
64
|
root = _build_audit_root()
|
|
65
65
|
bug_categories = root.find("bug_categories")
|
|
66
66
|
assert bug_categories is not None
|
|
67
67
|
all_emitted_ids = [each_category.get("id") for each_category in bug_categories]
|
|
68
|
-
all_expected_ids = list("
|
|
68
|
+
all_expected_ids = list("ABCDEFGHIJKLMNOP")
|
|
69
69
|
assert all_emitted_ids == all_expected_ids
|
|
70
70
|
|
|
71
71
|
|
|
@@ -90,3 +90,50 @@ def test_rubric_reference_element_names_category_rubrics_directory() -> None:
|
|
|
90
90
|
assert rubric_reference is not None
|
|
91
91
|
assert rubric_reference.text is not None
|
|
92
92
|
assert "audit-rubrics/category_rubrics" in rubric_reference.text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_prompt_skeleton_sub_bucket_counts_match_rubric_rows() -> None:
|
|
96
|
+
"""Each prompt skeleton's numeric sub-bucket count equals its rubric's row count.
|
|
97
|
+
|
|
98
|
+
For every (letter, label) the prompts dir holds a category-<letter>- file.
|
|
99
|
+
The skeleton above the first standalone --- line states "decomposed into N
|
|
100
|
+
sub-buckets"; that N must equal the rubric's count of | <letter>N | rows,
|
|
101
|
+
and a numeric walk-instruction range (For each sub-bucket X1-Xn) must end
|
|
102
|
+
at that same row count. Skeletons with a [N] placeholder are skipped.
|
|
103
|
+
"""
|
|
104
|
+
prompts_directory = _CATEGORY_RUBRICS_DIR.parent / "prompts"
|
|
105
|
+
count_pattern = re.compile(r"decomposed into (\d+) sub-buckets")
|
|
106
|
+
for each_letter, _each_label in ALL_AUDIT_CATEGORY_ENTRIES:
|
|
107
|
+
all_prompt_matches = sorted(prompts_directory.glob(f"category-{each_letter.lower()}-*.md"))
|
|
108
|
+
assert all_prompt_matches, f"Missing prompt file for category {each_letter}"
|
|
109
|
+
all_skeleton_lines: list[str] = []
|
|
110
|
+
for each_line in all_prompt_matches[0].read_text(encoding="utf-8").splitlines():
|
|
111
|
+
if each_line == "---":
|
|
112
|
+
break
|
|
113
|
+
all_skeleton_lines.append(each_line)
|
|
114
|
+
skeleton_text = "\n".join(all_skeleton_lines)
|
|
115
|
+
each_count_match = count_pattern.search(skeleton_text)
|
|
116
|
+
if each_count_match is None:
|
|
117
|
+
assert "decomposed into [N] sub-buckets" in skeleton_text, (
|
|
118
|
+
f"Category {each_letter}: skeleton neither states a numeric "
|
|
119
|
+
"sub-bucket count nor carries the [N] placeholder"
|
|
120
|
+
)
|
|
121
|
+
continue
|
|
122
|
+
all_rubric_matches = sorted(_CATEGORY_RUBRICS_DIR.glob(f"category-{each_letter.lower()}-*.md"))
|
|
123
|
+
assert all_rubric_matches, f"Missing rubric file for category {each_letter}"
|
|
124
|
+
rubric_row_pattern = re.compile(r"^\| " + each_letter + r"\d+ \|", re.MULTILINE)
|
|
125
|
+
sub_bucket_row_count = len(rubric_row_pattern.findall(all_rubric_matches[0].read_text(encoding="utf-8")))
|
|
126
|
+
assert int(each_count_match.group(1)) == sub_bucket_row_count, (
|
|
127
|
+
f"Category {each_letter}: skeleton says {each_count_match.group(1)} sub-buckets "
|
|
128
|
+
f"but rubric has {sub_bucket_row_count} rows"
|
|
129
|
+
)
|
|
130
|
+
walk_range_pattern = re.compile(
|
|
131
|
+
rf"For each sub-bucket {each_letter}1[-–]{each_letter}(\d+)"
|
|
132
|
+
)
|
|
133
|
+
each_walk_match = walk_range_pattern.search(skeleton_text)
|
|
134
|
+
if each_walk_match is not None:
|
|
135
|
+
assert int(each_walk_match.group(1)) == sub_bucket_row_count, (
|
|
136
|
+
f"Category {each_letter}: walk instruction ends at "
|
|
137
|
+
f"{each_letter}{each_walk_match.group(1)} but rubric has "
|
|
138
|
+
f"{sub_bucket_row_count} rows"
|
|
139
|
+
)
|