claude-dev-env 1.24.0 → 1.25.1
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 +5 -18
- package/docs/CODE_RULES.md +14 -1
- package/hooks/blocking/_gh_body_arg_utils.py +171 -13
- package/hooks/blocking/code-rules-enforcer.py +490 -15
- package/hooks/blocking/gh-body-arg-blocker.py +27 -21
- package/hooks/blocking/pr-description-enforcer.py +247 -11
- package/hooks/blocking/tdd-enforcer.py +208 -13
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +116 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +231 -0
- package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +51 -0
- package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +144 -0
- package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +102 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +76 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +112 -0
- package/hooks/blocking/test_gh_body_arg_blocker.py +229 -2
- package/hooks/blocking/test_pr_description_enforcer.py +193 -3
- package/hooks/blocking/test_tdd_enforcer.py +249 -0
- package/hooks/validators/exempt_paths.py +99 -0
- package/hooks/validators/magic_value_checks.py +126 -26
- package/hooks/validators/test_magic_value_checks.py +356 -2
- package/package.json +1 -1
- package/rules/gh-body-file.md +11 -2
- package/skills/searching-obsidian-vault/SKILL.md +131 -0
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
"""Tests for magic value detection."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
4
6
|
|
|
5
7
|
import pytest
|
|
6
8
|
|
|
7
9
|
from magic_value_checks import (
|
|
8
10
|
check_magic_values,
|
|
11
|
+
validate_file,
|
|
9
12
|
)
|
|
10
13
|
from validator_base import Violation
|
|
11
14
|
|
|
12
15
|
|
|
16
|
+
MAGIC_NUMBER_SOURCE = "x = 42\n"
|
|
17
|
+
NON_TEST_TEMPDIR_PREFIX = "src_"
|
|
18
|
+
|
|
19
|
+
|
|
13
20
|
GOOD_NAMED_CONSTANTS = '''
|
|
14
21
|
API_TIMEOUT_MS = 5000
|
|
15
22
|
HASH_DELIMITER = "__"
|
|
@@ -29,8 +36,23 @@ ALLOWED_SMALL_NUMBERS = '''
|
|
|
29
36
|
def process():
|
|
30
37
|
count = 0
|
|
31
38
|
increment = 1
|
|
32
|
-
|
|
33
|
-
return count +
|
|
39
|
+
negative = -1
|
|
40
|
+
return count + increment + negative
|
|
41
|
+
'''
|
|
42
|
+
|
|
43
|
+
ALLOWED_NEGATIVE_ONE_IN_BINARY_EXPRESSION = '''
|
|
44
|
+
def process(total):
|
|
45
|
+
return total * -1
|
|
46
|
+
'''
|
|
47
|
+
|
|
48
|
+
ALLOWED_NEGATIVE_ONE_IN_RETURN = '''
|
|
49
|
+
def process():
|
|
50
|
+
return -1
|
|
51
|
+
'''
|
|
52
|
+
|
|
53
|
+
BAD_NEGATIVE_LITERAL_TWO = '''
|
|
54
|
+
def process():
|
|
55
|
+
return total * -2
|
|
34
56
|
'''
|
|
35
57
|
|
|
36
58
|
ALLOWED_EMPTY_STRING = '''
|
|
@@ -39,6 +61,114 @@ def process():
|
|
|
39
61
|
return result
|
|
40
62
|
'''
|
|
41
63
|
|
|
64
|
+
BAD_LITERAL_TWO = '''
|
|
65
|
+
def process():
|
|
66
|
+
doubled = something * 2
|
|
67
|
+
return doubled
|
|
68
|
+
'''
|
|
69
|
+
|
|
70
|
+
BAD_LITERAL_ONE_HUNDRED = '''
|
|
71
|
+
def process():
|
|
72
|
+
percentage = fraction * 100
|
|
73
|
+
return percentage
|
|
74
|
+
'''
|
|
75
|
+
|
|
76
|
+
ALLOWED_NEGATIVE_UPPER_CONSTANT_ASSIGNMENT = '''
|
|
77
|
+
LIMIT = -100
|
|
78
|
+
OFFSET = -5
|
|
79
|
+
'''
|
|
80
|
+
|
|
81
|
+
ALLOWED_TYPED_NEGATIVE_UPPER_CONSTANT = '''
|
|
82
|
+
LIMIT: int = -100
|
|
83
|
+
OFFSET: int = -5
|
|
84
|
+
'''
|
|
85
|
+
|
|
86
|
+
DOUBLE_NEGATED_LITERAL = '''
|
|
87
|
+
def process():
|
|
88
|
+
return --5
|
|
89
|
+
'''
|
|
90
|
+
|
|
91
|
+
DICT_VALUED_CONSTANT = '''
|
|
92
|
+
SETTINGS = {"timeout": 30, "retries": 5}
|
|
93
|
+
'''
|
|
94
|
+
|
|
95
|
+
TUPLE_VALUED_CONSTANT = '''
|
|
96
|
+
RETRY_DELAYS = (2, 4, 8)
|
|
97
|
+
'''
|
|
98
|
+
|
|
99
|
+
LIST_VALUED_CONSTANT = '''
|
|
100
|
+
PORTS = [8080, 8443, 9000]
|
|
101
|
+
'''
|
|
102
|
+
|
|
103
|
+
NESTED_DICT_VALUED_CONSTANT = '''
|
|
104
|
+
CONFIG = {"db": {"port": 5432}}
|
|
105
|
+
'''
|
|
106
|
+
|
|
107
|
+
ANNOTATED_SCALAR_CONSTANT = '''
|
|
108
|
+
TIMEOUT_MS: int = 5000
|
|
109
|
+
'''
|
|
110
|
+
|
|
111
|
+
ANNOTATED_DICT_VALUED_CONSTANT = '''
|
|
112
|
+
SETTINGS: dict[str, int] = {"timeout": 30}
|
|
113
|
+
'''
|
|
114
|
+
|
|
115
|
+
NON_CONSTANT_ASSIGNMENT_IN_FUNCTION = '''
|
|
116
|
+
def configure():
|
|
117
|
+
delay = 30
|
|
118
|
+
return delay
|
|
119
|
+
'''
|
|
120
|
+
|
|
121
|
+
SCALAR_MAGIC_NUMBER_OUTSIDE_CONSTANT = '''
|
|
122
|
+
def compute():
|
|
123
|
+
return 30 + 5000
|
|
124
|
+
'''
|
|
125
|
+
|
|
126
|
+
LEADING_UNDERSCORE_TARGET = '''
|
|
127
|
+
_PRIVATE = {"timeout": 30}
|
|
128
|
+
'''
|
|
129
|
+
|
|
130
|
+
DOUBLE_UNDERSCORE_TARGET = '''
|
|
131
|
+
__PRIVATE = {"timeout": 30}
|
|
132
|
+
'''
|
|
133
|
+
|
|
134
|
+
NON_CONTAINER_RHS_CALL = '''
|
|
135
|
+
CONFIG = some_factory(30, retries=5)
|
|
136
|
+
'''
|
|
137
|
+
|
|
138
|
+
NON_CONTAINER_RHS_BINOP = '''
|
|
139
|
+
WINDOW = base + 5000
|
|
140
|
+
'''
|
|
141
|
+
|
|
142
|
+
TUPLE_TARGET_ASSIGNMENT = '''
|
|
143
|
+
A, B = 1, 30
|
|
144
|
+
'''
|
|
145
|
+
|
|
146
|
+
AUG_ASSIGN_UPPER_SNAKE = '''
|
|
147
|
+
COUNTER = 0
|
|
148
|
+
COUNTER += 30
|
|
149
|
+
'''
|
|
150
|
+
|
|
151
|
+
CLASS_BODY_UPPER_SNAKE_CONSTANT = '''
|
|
152
|
+
class Foo:
|
|
153
|
+
TIMEOUT = 30
|
|
154
|
+
'''
|
|
155
|
+
|
|
156
|
+
ANNOTATED_LITERAL_TYPE = '''
|
|
157
|
+
X: Literal[42] = 42
|
|
158
|
+
'''
|
|
159
|
+
|
|
160
|
+
TRUE_LITERAL_IN_FUNCTION = '''
|
|
161
|
+
def configure():
|
|
162
|
+
is_enabled = True
|
|
163
|
+
return is_enabled
|
|
164
|
+
'''
|
|
165
|
+
|
|
166
|
+
FALSE_LITERAL_IN_FUNCTION = '''
|
|
167
|
+
def configure():
|
|
168
|
+
is_disabled = False
|
|
169
|
+
return is_disabled
|
|
170
|
+
'''
|
|
171
|
+
|
|
42
172
|
|
|
43
173
|
class TestMagicValues:
|
|
44
174
|
def test_named_constants_pass(self) -> None:
|
|
@@ -57,7 +187,231 @@ class TestMagicValues:
|
|
|
57
187
|
violations = check_magic_values(tree, "test.py")
|
|
58
188
|
assert violations == []
|
|
59
189
|
|
|
190
|
+
def test_negative_one_allowed_in_binary_expression(self) -> None:
|
|
191
|
+
tree = ast.parse(ALLOWED_NEGATIVE_ONE_IN_BINARY_EXPRESSION)
|
|
192
|
+
violations = check_magic_values(tree, "test.py")
|
|
193
|
+
assert violations == []
|
|
194
|
+
|
|
195
|
+
def test_negative_one_allowed_in_return_expression(self) -> None:
|
|
196
|
+
tree = ast.parse(ALLOWED_NEGATIVE_ONE_IN_RETURN)
|
|
197
|
+
violations = check_magic_values(tree, "test.py")
|
|
198
|
+
assert violations == []
|
|
199
|
+
|
|
200
|
+
def test_negative_literal_two_is_flagged_with_signed_value(self) -> None:
|
|
201
|
+
tree = ast.parse(BAD_NEGATIVE_LITERAL_TWO)
|
|
202
|
+
violations = check_magic_values(tree, "test.py")
|
|
203
|
+
assert len(violations) == 1
|
|
204
|
+
assert "-2" in violations[0].message
|
|
205
|
+
|
|
60
206
|
def test_empty_string_allowed(self) -> None:
|
|
61
207
|
tree = ast.parse(ALLOWED_EMPTY_STRING)
|
|
62
208
|
violations = check_magic_values(tree, "test.py")
|
|
63
209
|
assert violations == []
|
|
210
|
+
|
|
211
|
+
def test_check_magic_values_should_flag_literal_two_in_function_body(self) -> None:
|
|
212
|
+
tree = ast.parse(BAD_LITERAL_TWO)
|
|
213
|
+
violations = check_magic_values(tree, "test.py")
|
|
214
|
+
assert len(violations) == 1
|
|
215
|
+
assert "2" in violations[0].message
|
|
216
|
+
|
|
217
|
+
def test_check_magic_values_should_flag_literal_one_hundred_in_function_body(self) -> None:
|
|
218
|
+
tree = ast.parse(BAD_LITERAL_ONE_HUNDRED)
|
|
219
|
+
violations = check_magic_values(tree, "test.py")
|
|
220
|
+
assert len(violations) == 1
|
|
221
|
+
assert "100" in violations[0].message
|
|
222
|
+
|
|
223
|
+
def test_negative_upper_constant_assignment_allowed(self) -> None:
|
|
224
|
+
tree = ast.parse(ALLOWED_NEGATIVE_UPPER_CONSTANT_ASSIGNMENT)
|
|
225
|
+
violations = check_magic_values(tree, "test.py")
|
|
226
|
+
assert violations == []
|
|
227
|
+
|
|
228
|
+
def test_typed_negative_upper_constant_allowed(self) -> None:
|
|
229
|
+
tree = ast.parse(ALLOWED_TYPED_NEGATIVE_UPPER_CONSTANT)
|
|
230
|
+
violations = check_magic_values(tree, "test.py")
|
|
231
|
+
assert violations == []
|
|
232
|
+
|
|
233
|
+
def test_double_negation_reports_collapsed_positive_value(self) -> None:
|
|
234
|
+
tree = ast.parse(DOUBLE_NEGATED_LITERAL)
|
|
235
|
+
violations = check_magic_values(tree, "test.py")
|
|
236
|
+
assert len(violations) == 1
|
|
237
|
+
assert "5" in violations[0].message
|
|
238
|
+
assert "-5" not in violations[0].message
|
|
239
|
+
|
|
240
|
+
def test_should_exempt_numbers_inside_dict_valued_constant(self) -> None:
|
|
241
|
+
tree = ast.parse(DICT_VALUED_CONSTANT)
|
|
242
|
+
violations = check_magic_values(tree, "test.py")
|
|
243
|
+
assert violations == []
|
|
244
|
+
|
|
245
|
+
def test_should_exempt_numbers_inside_tuple_valued_constant(self) -> None:
|
|
246
|
+
tree = ast.parse(TUPLE_VALUED_CONSTANT)
|
|
247
|
+
violations = check_magic_values(tree, "test.py")
|
|
248
|
+
assert violations == []
|
|
249
|
+
|
|
250
|
+
def test_should_exempt_numbers_inside_list_valued_constant(self) -> None:
|
|
251
|
+
tree = ast.parse(LIST_VALUED_CONSTANT)
|
|
252
|
+
violations = check_magic_values(tree, "test.py")
|
|
253
|
+
assert violations == []
|
|
254
|
+
|
|
255
|
+
def test_should_exempt_numbers_inside_nested_dict_valued_constant(self) -> None:
|
|
256
|
+
tree = ast.parse(NESTED_DICT_VALUED_CONSTANT)
|
|
257
|
+
violations = check_magic_values(tree, "test.py")
|
|
258
|
+
assert violations == []
|
|
259
|
+
|
|
260
|
+
def test_should_exempt_numbers_inside_annotated_upper_snake_constant(self) -> None:
|
|
261
|
+
tree = ast.parse(ANNOTATED_SCALAR_CONSTANT)
|
|
262
|
+
violations = check_magic_values(tree, "test.py")
|
|
263
|
+
assert violations == []
|
|
264
|
+
|
|
265
|
+
def test_should_exempt_numbers_inside_annotated_dict_valued_constant(self) -> None:
|
|
266
|
+
tree = ast.parse(ANNOTATED_DICT_VALUED_CONSTANT)
|
|
267
|
+
violations = check_magic_values(tree, "test.py")
|
|
268
|
+
assert violations == []
|
|
269
|
+
|
|
270
|
+
def test_should_still_flag_numbers_in_function_body_assignments(self) -> None:
|
|
271
|
+
tree = ast.parse(NON_CONSTANT_ASSIGNMENT_IN_FUNCTION)
|
|
272
|
+
violations = check_magic_values(tree, "test.py")
|
|
273
|
+
assert len(violations) == 1
|
|
274
|
+
assert "30" in violations[0].message
|
|
275
|
+
|
|
276
|
+
def test_should_still_flag_scalar_magic_number_outside_constant(self) -> None:
|
|
277
|
+
tree = ast.parse(SCALAR_MAGIC_NUMBER_OUTSIDE_CONSTANT)
|
|
278
|
+
violations = check_magic_values(tree, "test.py")
|
|
279
|
+
flagged_numbers = {violation.message for violation in violations}
|
|
280
|
+
assert any("30" in message for message in flagged_numbers)
|
|
281
|
+
assert any("5000" in message for message in flagged_numbers)
|
|
282
|
+
|
|
283
|
+
def test_should_flag_literal_under_single_leading_underscore_target(self) -> None:
|
|
284
|
+
tree = ast.parse(LEADING_UNDERSCORE_TARGET)
|
|
285
|
+
violations = check_magic_values(tree, "test.py")
|
|
286
|
+
assert any("30" in violation.message for violation in violations)
|
|
287
|
+
|
|
288
|
+
def test_should_flag_literal_under_double_leading_underscore_target(self) -> None:
|
|
289
|
+
tree = ast.parse(DOUBLE_UNDERSCORE_TARGET)
|
|
290
|
+
violations = check_magic_values(tree, "test.py")
|
|
291
|
+
assert any("30" in violation.message for violation in violations)
|
|
292
|
+
|
|
293
|
+
def test_should_flag_literal_under_non_container_call_rhs(self) -> None:
|
|
294
|
+
tree = ast.parse(NON_CONTAINER_RHS_CALL)
|
|
295
|
+
violations = check_magic_values(tree, "test.py")
|
|
296
|
+
flagged_numbers = {violation.message for violation in violations}
|
|
297
|
+
assert any("30" in message for message in flagged_numbers)
|
|
298
|
+
assert any("5" in message for message in flagged_numbers)
|
|
299
|
+
|
|
300
|
+
def test_should_flag_literal_under_non_container_binop_rhs(self) -> None:
|
|
301
|
+
tree = ast.parse(NON_CONTAINER_RHS_BINOP)
|
|
302
|
+
violations = check_magic_values(tree, "test.py")
|
|
303
|
+
assert any("5000" in violation.message for violation in violations)
|
|
304
|
+
|
|
305
|
+
def test_tuple_target_assignment_flags_literal_when_no_upper_snake_target(
|
|
306
|
+
self,
|
|
307
|
+
) -> None:
|
|
308
|
+
tree = ast.parse(TUPLE_TARGET_ASSIGNMENT)
|
|
309
|
+
violations = check_magic_values(tree, "test.py")
|
|
310
|
+
assert any("30" in violation.message for violation in violations)
|
|
311
|
+
|
|
312
|
+
def test_aug_assign_on_upper_snake_target_is_not_exempt(self) -> None:
|
|
313
|
+
tree = ast.parse(AUG_ASSIGN_UPPER_SNAKE)
|
|
314
|
+
violations = check_magic_values(tree, "test.py")
|
|
315
|
+
assert any("30" in violation.message for violation in violations)
|
|
316
|
+
|
|
317
|
+
def test_class_body_upper_snake_constant_is_exempt(self) -> None:
|
|
318
|
+
tree = ast.parse(CLASS_BODY_UPPER_SNAKE_CONSTANT)
|
|
319
|
+
violations = check_magic_values(tree, "test.py")
|
|
320
|
+
assert violations == []
|
|
321
|
+
|
|
322
|
+
def test_annotated_literal_type_flags_literal_inside_subscript_annotation(
|
|
323
|
+
self,
|
|
324
|
+
) -> None:
|
|
325
|
+
tree = ast.parse(ANNOTATED_LITERAL_TYPE)
|
|
326
|
+
violations = check_magic_values(tree, "test.py")
|
|
327
|
+
assert any("42" in violation.message for violation in violations)
|
|
328
|
+
|
|
329
|
+
def test_check_magic_values_should_not_flag_True_literal(self) -> None:
|
|
330
|
+
tree = ast.parse(TRUE_LITERAL_IN_FUNCTION)
|
|
331
|
+
violations = check_magic_values(tree, "test.py")
|
|
332
|
+
assert violations == []
|
|
333
|
+
|
|
334
|
+
def test_check_magic_values_should_not_flag_False_literal(self) -> None:
|
|
335
|
+
tree = ast.parse(FALSE_LITERAL_IN_FUNCTION)
|
|
336
|
+
violations = check_magic_values(tree, "test.py")
|
|
337
|
+
assert violations == []
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class TestValidateFilePathExemptions:
|
|
341
|
+
def test_validate_file_should_skip_test_underscore_py_files(
|
|
342
|
+
self, tmp_path: Path
|
|
343
|
+
) -> None:
|
|
344
|
+
tests_directory = tmp_path / "tests"
|
|
345
|
+
tests_directory.mkdir()
|
|
346
|
+
test_module_path = tests_directory / "test_foo.py"
|
|
347
|
+
test_module_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
|
|
348
|
+
|
|
349
|
+
assert validate_file(test_module_path) == []
|
|
350
|
+
|
|
351
|
+
def test_validate_file_should_skip_config_directory(
|
|
352
|
+
self, tmp_path: Path
|
|
353
|
+
) -> None:
|
|
354
|
+
config_directory = tmp_path / "config"
|
|
355
|
+
config_directory.mkdir()
|
|
356
|
+
constants_path = config_directory / "constants.py"
|
|
357
|
+
constants_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
|
|
358
|
+
|
|
359
|
+
assert validate_file(constants_path) == []
|
|
360
|
+
|
|
361
|
+
def test_validate_file_should_skip_settings_py(self, tmp_path: Path) -> None:
|
|
362
|
+
settings_path = tmp_path / "settings.py"
|
|
363
|
+
settings_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
|
|
364
|
+
|
|
365
|
+
assert validate_file(settings_path) == []
|
|
366
|
+
|
|
367
|
+
def test_validate_file_should_skip_uppercase_config_directory(
|
|
368
|
+
self, tmp_path: Path
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Case-insensitive match so Config/ on case-preserving filesystems skips."""
|
|
371
|
+
uppercase_config_directory = tmp_path / "Config"
|
|
372
|
+
uppercase_config_directory.mkdir()
|
|
373
|
+
constants_path = uppercase_config_directory / "constants.py"
|
|
374
|
+
constants_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
|
|
375
|
+
|
|
376
|
+
assert validate_file(constants_path) == []
|
|
377
|
+
|
|
378
|
+
def test_validate_file_should_skip_mixed_case_tests_directory(
|
|
379
|
+
self, tmp_path: Path
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Case-insensitive match so src/Tests/ short-circuits the same way src/tests/ does."""
|
|
382
|
+
mixed_case_tests_directory = tmp_path / "Tests"
|
|
383
|
+
mixed_case_tests_directory.mkdir()
|
|
384
|
+
test_module_path = mixed_case_tests_directory / "test_foo.py"
|
|
385
|
+
test_module_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
|
|
386
|
+
|
|
387
|
+
assert validate_file(test_module_path) == []
|
|
388
|
+
|
|
389
|
+
def test_validate_file_should_skip_conftest_helpers(
|
|
390
|
+
self, tmp_path: Path
|
|
391
|
+
) -> None:
|
|
392
|
+
"""Naked ``conftest`` substring matches conftest_helpers.py and similar."""
|
|
393
|
+
conftest_helpers_path = tmp_path / "conftest_helpers.py"
|
|
394
|
+
conftest_helpers_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
|
|
395
|
+
|
|
396
|
+
assert validate_file(conftest_helpers_path) == []
|
|
397
|
+
|
|
398
|
+
def test_validate_file_should_still_scan_production_py_files(self) -> None:
|
|
399
|
+
"""Use tempfile with a non-test prefix instead of the pytest tmp_path fixture.
|
|
400
|
+
|
|
401
|
+
pytest's tmp_path produces directories like ``pytest-of-<user>/pytest-N/
|
|
402
|
+
test_validate_file_should_still_scan_production_py_files0/`` whose names
|
|
403
|
+
contain ``test_`` and ``pytest-`` -- both match TEST_PATH_PATTERNS, so
|
|
404
|
+
validate_file would short-circuit via is_test_file and make this
|
|
405
|
+
regression a false green.
|
|
406
|
+
"""
|
|
407
|
+
with tempfile.TemporaryDirectory(
|
|
408
|
+
prefix=NON_TEST_TEMPDIR_PREFIX
|
|
409
|
+
) as temporary_root:
|
|
410
|
+
source_directory = Path(temporary_root) / "src"
|
|
411
|
+
source_directory.mkdir()
|
|
412
|
+
production_path = source_directory / "app.py"
|
|
413
|
+
production_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
|
|
414
|
+
|
|
415
|
+
violations = validate_file(production_path)
|
|
416
|
+
assert len(violations) == 1
|
|
417
|
+
assert "42" in violations[0].message
|
package/package.json
CHANGED
package/rules/gh-body-file.md
CHANGED
|
@@ -33,17 +33,26 @@ subprocess.run(["gh", "pr", "create", "--title", "My PR", "--body-file", body_pa
|
|
|
33
33
|
|
|
34
34
|
### PowerShell (Windows — preferred for this repo)
|
|
35
35
|
|
|
36
|
+
`Set-Content -Encoding utf8` writes UTF-8-**with-BOM** on Windows PowerShell 5.1,
|
|
37
|
+
which causes `gh` to treat the leading BOM as part of the first heading character
|
|
38
|
+
and can corrupt rendering. Use the BOM-free pattern below — it works on both
|
|
39
|
+
Windows PowerShell 5.1 and PowerShell 7+.
|
|
40
|
+
|
|
36
41
|
```powershell
|
|
37
42
|
$bodyPath = [System.IO.Path]::ChangeExtension((New-TemporaryFile).FullName, '.md')
|
|
38
|
-
@'
|
|
43
|
+
$body = @'
|
|
39
44
|
## Summary
|
|
40
45
|
|
|
41
46
|
Fixes `foo` by updating `bar`.
|
|
42
|
-
'@
|
|
47
|
+
'@
|
|
48
|
+
[IO.File]::WriteAllText($bodyPath, $body, [Text.UTF8Encoding]::new($false))
|
|
43
49
|
|
|
44
50
|
gh pr create --title "My PR" --body-file $bodyPath
|
|
45
51
|
```
|
|
46
52
|
|
|
53
|
+
On PowerShell 7+ you can alternatively use `Set-Content -Encoding utf8NoBOM`,
|
|
54
|
+
but the `[IO.File]::WriteAllText` pattern above is version-agnostic.
|
|
55
|
+
|
|
47
56
|
### Bash
|
|
48
57
|
|
|
49
58
|
Safe Bash patterns are intentionally omitted from this rule file. This repo
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: searching-obsidian-vault
|
|
3
|
+
description: >-
|
|
4
|
+
Retrieves Obsidian vault context from %USERPROFILE%/SessionLog/ via the
|
|
5
|
+
configured mcp__obsidian__* server. Searches sessions/, decisions/, and
|
|
6
|
+
Research/. Use when the user says "yesterday", "last night", "the other
|
|
7
|
+
day", "earlier today", "this morning", "a few days ago", "last week",
|
|
8
|
+
"this past week", "a while back", "recently", "recent session", "last
|
|
9
|
+
session", or "previous session"; when the user asks why existing code
|
|
10
|
+
was built a certain way or whether an approach was tried before; when
|
|
11
|
+
starting a session in a git repo whose name matches a project folder
|
|
12
|
+
under sessions/; or when the user names a specific component, decision,
|
|
13
|
+
gotcha, or prior research note.
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Obsidian Vault
|
|
17
|
+
|
|
18
|
+
## Overview
|
|
19
|
+
|
|
20
|
+
Invoke this skill when prior session history, decisions, or research from
|
|
21
|
+
the Obsidian vault would change how the current task is executed. Load it
|
|
22
|
+
on demand rather than keeping the full vault policy always-on.
|
|
23
|
+
|
|
24
|
+
The retrieval *mechanics* live in `/recall`. The
|
|
25
|
+
`vault_context_retrieved` frontmatter bookkeeping lives in `/session-log`
|
|
26
|
+
Step 2. This skill's job is to make the vault search happen at the right
|
|
27
|
+
moments so the downstream flag can be set honestly.
|
|
28
|
+
|
|
29
|
+
## Trigger Conditions
|
|
30
|
+
|
|
31
|
+
Invoke automatically when any of the following holds:
|
|
32
|
+
|
|
33
|
+
- The user says "yesterday", "last night", "the other day", "earlier
|
|
34
|
+
today", "this morning", "a few days ago", "last week", "this past
|
|
35
|
+
week", "a while back", "recently", "recent session", "last session",
|
|
36
|
+
or "previous session".
|
|
37
|
+
- The user asks why existing code was built a certain way, or whether an
|
|
38
|
+
approach was tried before.
|
|
39
|
+
- A new session starts in a git repo whose name matches a project folder
|
|
40
|
+
under `sessions/`.
|
|
41
|
+
- The user names a specific component, feature, or architectural decision
|
|
42
|
+
that a session or decision note might exist for.
|
|
43
|
+
- The user mentions "session", "decision", "gotcha", "superseded", or
|
|
44
|
+
"prior research" by name.
|
|
45
|
+
|
|
46
|
+
Skip the skill for isolated lookups with no project history (e.g. one-off
|
|
47
|
+
utility scripts, pure syntax questions, fresh repos with no
|
|
48
|
+
`sessions/[project]/` folder).
|
|
49
|
+
|
|
50
|
+
## Search Algorithm
|
|
51
|
+
|
|
52
|
+
1. **Search by frontmatter first.** Call `mcp__obsidian__search_notes` with
|
|
53
|
+
`searchFrontmatter: true` and the project name (inferred from the git
|
|
54
|
+
remote, working directory, or topic under discussion). Then search by
|
|
55
|
+
content keywords such as `blocked`, `superseded`, `decision`, `gotcha`,
|
|
56
|
+
plus task-specific terms (component names, error messages, library names).
|
|
57
|
+
|
|
58
|
+
Concrete example:
|
|
59
|
+
|
|
60
|
+
```xml
|
|
61
|
+
<invoke name="mcp__obsidian__search_notes">
|
|
62
|
+
<parameter name="query">claude-code-config</parameter>
|
|
63
|
+
<parameter name="searchFrontmatter">true</parameter>
|
|
64
|
+
</invoke>
|
|
65
|
+
|
|
66
|
+
<invoke name="mcp__obsidian__search_notes">
|
|
67
|
+
<parameter name="query">superseded decision themes-pr-stack</parameter>
|
|
68
|
+
</invoke>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
2. **Scope to the three vault folders.**
|
|
72
|
+
- `sessions/` — session reports (`type: session-report`, `project`,
|
|
73
|
+
`session`, `date`, `status`, `blocked`, `tags`).
|
|
74
|
+
- `decisions/` — decision notes (`type: decision|procedural|fact|gotcha`,
|
|
75
|
+
`project`, `date`, `status: Active|Superseded`, `tags`).
|
|
76
|
+
- `Research/` — deep research documents.
|
|
77
|
+
|
|
78
|
+
3. **Read the top 3–5 hits.** Use `mcp__obsidian__read_note` for single notes
|
|
79
|
+
or `mcp__obsidian__read_multiple_notes` for several at once. Prefer recent
|
|
80
|
+
notes over older ones, and prefer decision notes and session summaries
|
|
81
|
+
over raw research.
|
|
82
|
+
|
|
83
|
+
4. **Summarise the relevant prior context** inline before proceeding with
|
|
84
|
+
the work, so the user can see what prior history shaped the current
|
|
85
|
+
approach. Call out any decision marked `status: Superseded`.
|
|
86
|
+
|
|
87
|
+
5. **Record the outcome for `/session-log`.** Set
|
|
88
|
+
`vault_context_retrieved: true` if any `mcp__obsidian__*` read or search
|
|
89
|
+
tool was used productively (at least one relevant note surfaced and
|
|
90
|
+
informed the work). Set it to `false` if the vault was unreachable or no
|
|
91
|
+
relevant notes were found.
|
|
92
|
+
|
|
93
|
+
## Session-End Integration
|
|
94
|
+
|
|
95
|
+
At the end of substantive sessions, offer to run `/session-log`. Step 2 of
|
|
96
|
+
`/session-log` already auto-detects vault MCP calls and writes the
|
|
97
|
+
`vault_context_retrieved` field into frontmatter. This skill's contribution
|
|
98
|
+
is to ensure those MCP calls actually happen when they should, so the flag
|
|
99
|
+
ends up `true` whenever genuine prior context exists.
|
|
100
|
+
|
|
101
|
+
## Frontmatter Requirement
|
|
102
|
+
|
|
103
|
+
Any session note written via `/session-log` after invoking this skill must
|
|
104
|
+
include:
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
vault_context_retrieved: true # or false
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`true` means a `mcp__obsidian__*` read or search tool was used productively
|
|
111
|
+
during the session. `false` means the vault was unreachable or no relevant
|
|
112
|
+
notes existed.
|
|
113
|
+
|
|
114
|
+
## Vault Location
|
|
115
|
+
|
|
116
|
+
The vault lives at `%USERPROFILE%/SessionLog/` on this workspace
|
|
117
|
+
(expanding to the user's Windows profile directory). It is accessed
|
|
118
|
+
entirely through the `mcp__obsidian__*` MCP server, whose own
|
|
119
|
+
configuration holds the concrete path; `OBSIDIAN_VAULT_PATH` serves the
|
|
120
|
+
same role on systems that resolve the vault outside the MCP server. This
|
|
121
|
+
skill does not read the filesystem directly and must not hard-code a
|
|
122
|
+
user-specific path like `C:/Users/<name>/SessionLog/` in code it
|
|
123
|
+
produces — expand `%USERPROFILE%` (or read `OBSIDIAN_VAULT_PATH`) at run
|
|
124
|
+
time.
|
|
125
|
+
|
|
126
|
+
## Related Skills
|
|
127
|
+
|
|
128
|
+
- `/recall` performs the retrieval mechanics end-to-end with user-facing
|
|
129
|
+
output.
|
|
130
|
+
- `/session-log` consumes the `vault_context_retrieved` flag this skill is
|
|
131
|
+
responsible for keeping honest.
|