claude-dev-env 1.23.1 → 1.25.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/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/bugteam/SKILL.md +111 -59
- 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
|