claude-dev-env 8.25.0 → 8.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/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -82
- package/hooks/blocking/code_rules_enforcer.py +0 -6
- package/hooks/blocking/code_rules_imports_logging.py +1 -120
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +0 -16
- package/hooks/hooks_constants/blocking_check_limits.py +0 -13
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +0 -6
- package/hooks/hooks_constants/local_identity.py +7 -120
- package/hooks/hooks_constants/test_local_identity.py +7 -43
- package/package.json +1 -1
- package/rules/shell-invocation.md +1 -1
- package/scripts/repository_checks/config/constants.py +0 -5
- package/docs/nas-ssh-invocation.md +0 -107
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +0 -266
- package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +0 -87
- package/hooks/blocking/test_code_rules_logging_printf_tokens.py +0 -134
- package/rules/nas-ssh-invocation.md +0 -29
package/docs/CODE_RULES.md
CHANGED
|
@@ -31,7 +31,7 @@ A keep marker is the one comment that may be added and kept: a comment that open
|
|
|
31
31
|
|
|
32
32
|
The staged policy lint reports each of these through `code_rules_enforcer.py` and names the specific breach; exact patterns and exemption lists live in that module:
|
|
33
33
|
|
|
34
|
-
no new comments · imports at top · logging format args (`log_*("...", arg)`) · no
|
|
34
|
+
no new comments · imports at top · logging format args (`log_*("...", arg)`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`; `/migrations/`; Workflow registries: path contains any of these substrings — `/workflow/`, `_tab.py`, `/states.py`, or `/modules.py`, each matching independently as a substring, so `pkg/states.py` qualifies while a top-level `states.py` follows the standard `config/` rule; test files — path or filename matches `test_`, `_test.`, `.spec.`, `conftest`, or `/tests/`) · no hardcoded user home paths · guarded `sys.path.insert` · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`, a parameter typed bare `object` whose body reads `param.attribute`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …) · known pytest fixture parameters a test function declares but never references (drop the unused parameter — pytest still pays its setup cost) · JavaScript/TypeScript boolean declarations (`const`/`let`/`var` bound to a boolean literal or negation) and `@param {boolean}` JSDoc names prefixed `is`/`has`/`should`/`can`/`was`/`did` (camelCase forms) · banned identifiers as `.mjs`/`.js` declaration names (`result`, `data`, `ctx`, `msg`, …), scoped to changed lines · in test files, banned identifiers fire on changed lines, and pytest-collectable `test_*` functions need a return annotation · unused module-level imports and unsorted import blocks belong to ruff (F401, isort I001) · a `hooks/blocking/` command classifier anchors its multi-word command regex to the command start (`^`/`\A`) or tokenizes the first word (`shlex.split`), never matching a command as a bare substring
|
|
35
35
|
|
|
36
36
|
Test files follow the comment policy above; other test-specific exemptions are listed here. The one annotation the test-file exemption does NOT cover is a known pytest builtin fixture parameter: `tmp_path`, `monkeypatch`, `capsys`, `capfd`, `caplog`, `request`, and `tmp_path_factory` each have a single documented injected type, so the gate requires that annotation (`tmp_path: Path`) even inside a test file. The same set of fixtures is also subject to a use check: a pytest-collected test function that declares one of these parameters and never references it in its body fails the gate, because pytest materializes the fixture's setup (the temp directory, the monkeypatch context, the output capture) on every run whether or not the body reads the value — drop the unused parameter. A parameter counts as referenced when its name is read, augmented-assigned, or deleted anywhere in the body, including inside a nested function or comprehension. Only pytest-collectable functions are inspected — those at module top level or defined directly in a class body; a function nested inside another function's body is a local helper pytest never collects, so its fixture-named parameter is exempt. A `@pytest.fixture`-decorated function is exempt from the use check, since injecting one fixture into another purely to order its setup is intentional. Ordinary test parameters stay exempt from both checks.
|
|
37
37
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Boolean naming-prefix
|
|
1
|
+
"""Boolean naming-prefix checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import sys
|
|
@@ -21,12 +21,8 @@ from code_rules_shared import ( # noqa: E402
|
|
|
21
21
|
is_workflow_registry_file,
|
|
22
22
|
)
|
|
23
23
|
|
|
24
|
-
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
25
|
-
MAX_IGNORED_MUST_CHECK_RETURN_ISSUES,
|
|
26
|
-
)
|
|
27
24
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
28
25
|
ALL_BOOLEAN_NAME_PREFIXES,
|
|
29
|
-
ALL_MUST_CHECK_RETURN_FUNCTION_NAMES,
|
|
30
26
|
ALL_SELF_AND_CLS_PARAMETER_NAMES,
|
|
31
27
|
UPPER_SNAKE_CONSTANT_PATTERN,
|
|
32
28
|
)
|
|
@@ -271,80 +267,3 @@ def _called_terminal_name(call_node: ast.Call) -> str | None:
|
|
|
271
267
|
if isinstance(callee, ast.Attribute):
|
|
272
268
|
return callee.attr
|
|
273
269
|
return None
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def check_ignored_must_check_return(
|
|
277
|
-
content: str,
|
|
278
|
-
file_path: str,
|
|
279
|
-
all_changed_lines: set[int] | None = None,
|
|
280
|
-
defer_scope_to_caller: bool = False,
|
|
281
|
-
) -> list[str]:
|
|
282
|
-
"""Flag bare-expression calls whose discarded return is the only failure signal.
|
|
283
|
-
|
|
284
|
-
Functions in ``ALL_MUST_CHECK_RETURN_FUNCTION_NAMES`` report success or failure
|
|
285
|
-
solely through their return value. A bare-statement call discards that value,
|
|
286
|
-
so the caller silently proceeds on failure. Bare ``ast.Expr`` calls are flagged,
|
|
287
|
-
including a bare ``await``-wrapped call (``await find_and_click(...)`` as a
|
|
288
|
-
statement); an assigned or branched-on call is exempt.
|
|
289
|
-
|
|
290
|
-
The caller passes the reconstructed full file as *content* so ``ast.parse``
|
|
291
|
-
sees a complete module rather than an Edit's ``new_string`` fragment, which is
|
|
292
|
-
rarely valid standalone Python (a bare ``await find_and_click(...)`` line is a
|
|
293
|
-
SyntaxError on its own). Findings are then scoped to *all_changed_lines* so an
|
|
294
|
-
Edit blocks on the discarded return it just introduced while a pre-existing
|
|
295
|
-
violation on an untouched line does not block the edit.
|
|
296
|
-
|
|
297
|
-
Args:
|
|
298
|
-
content: The source text to inspect — the reconstructed full file on an
|
|
299
|
-
Edit so the parse succeeds.
|
|
300
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
301
|
-
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
302
|
-
None to treat the whole file as in scope. When provided, a violation
|
|
303
|
-
blocks only when the bare call's line intersects the changed lines.
|
|
304
|
-
defer_scope_to_caller: When True, return every violation so the
|
|
305
|
-
commit/push gate's ``split_violations_by_scope`` can scope by added
|
|
306
|
-
line.
|
|
307
|
-
|
|
308
|
-
Returns:
|
|
309
|
-
One issue per discarded must-check return, scoped to the changed lines
|
|
310
|
-
unless *defer_scope_to_caller* is True or *all_changed_lines* is None. When
|
|
311
|
-
*defer_scope_to_caller* is True every violation is returned uncapped so the
|
|
312
|
-
gate can scope by added line and apply its own ceiling; otherwise the
|
|
313
|
-
terminal result is capped at the module limit.
|
|
314
|
-
"""
|
|
315
|
-
if is_test_file(file_path):
|
|
316
|
-
return []
|
|
317
|
-
try:
|
|
318
|
-
tree = ast.parse(content)
|
|
319
|
-
except SyntaxError:
|
|
320
|
-
return []
|
|
321
|
-
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
322
|
-
for each_node in ast.walk(tree):
|
|
323
|
-
if not isinstance(each_node, ast.Expr):
|
|
324
|
-
continue
|
|
325
|
-
expression_value = each_node.value
|
|
326
|
-
call_node = (
|
|
327
|
-
expression_value.value
|
|
328
|
-
if isinstance(expression_value, ast.Await)
|
|
329
|
-
else expression_value
|
|
330
|
-
)
|
|
331
|
-
if not isinstance(call_node, ast.Call):
|
|
332
|
-
continue
|
|
333
|
-
called_name = _called_terminal_name(call_node)
|
|
334
|
-
if called_name is None or called_name not in ALL_MUST_CHECK_RETURN_FUNCTION_NAMES:
|
|
335
|
-
continue
|
|
336
|
-
end_line_number = each_node.end_lineno or each_node.lineno
|
|
337
|
-
line_span = range(each_node.lineno, end_line_number + 1)
|
|
338
|
-
message = (
|
|
339
|
-
f"Line {each_node.lineno}: return value of {called_name}() is discarded - "
|
|
340
|
-
"assign and check it (the boolean/outcome is the only failure signal)"
|
|
341
|
-
)
|
|
342
|
-
all_violations_in_walk_order.append((line_span, message))
|
|
343
|
-
scoped_issues = _scope_violations_to_changed_lines(
|
|
344
|
-
all_violations_in_walk_order,
|
|
345
|
-
all_changed_lines,
|
|
346
|
-
defer_scope_to_caller,
|
|
347
|
-
)
|
|
348
|
-
if defer_scope_to_caller:
|
|
349
|
-
return scoped_issues
|
|
350
|
-
return scoped_issues[:MAX_IGNORED_MUST_CHECK_RETURN_ISSUES]
|
|
@@ -46,7 +46,6 @@ from code_rules_blast_radius import ( # noqa: E402
|
|
|
46
46
|
)
|
|
47
47
|
from code_rules_boolean_mustcheck import ( # noqa: E402
|
|
48
48
|
check_boolean_naming,
|
|
49
|
-
check_ignored_must_check_return,
|
|
50
49
|
)
|
|
51
50
|
from code_rules_command_dispatch import ( # noqa: E402
|
|
52
51
|
check_unanchored_command_dispatch,
|
|
@@ -86,7 +85,6 @@ from code_rules_imports_logging import ( # noqa: E402
|
|
|
86
85
|
check_library_print,
|
|
87
86
|
check_logging_adjacent_string_literals,
|
|
88
87
|
check_logging_fstrings,
|
|
89
|
-
check_logging_printf_tokens,
|
|
90
88
|
check_naive_datetime_construction,
|
|
91
89
|
check_windows_api_none,
|
|
92
90
|
)
|
|
@@ -329,7 +327,6 @@ def _python_comment_and_logging_issues(context: _ValidationContext) -> list[str]
|
|
|
329
327
|
all_issues.extend(check_comment_changes(old_content, content, file_path))
|
|
330
328
|
all_issues.extend(check_imports_at_top(content))
|
|
331
329
|
all_issues.extend(check_logging_fstrings(content))
|
|
332
|
-
all_issues.extend(check_logging_printf_tokens(content, file_path))
|
|
333
330
|
all_issues.extend(check_logging_adjacent_string_literals(content, file_path))
|
|
334
331
|
all_issues.extend(check_windows_api_none(content))
|
|
335
332
|
all_issues.extend(check_naive_datetime_construction(content, file_path))
|
|
@@ -450,9 +447,6 @@ def _python_boolean_and_test_assertion_issues(context: _ValidationContext) -> li
|
|
|
450
447
|
)
|
|
451
448
|
changed, defer = context.all_changed_lines, context.defer_scope_to_caller
|
|
452
449
|
all_issues = check_boolean_naming(effective_content, file_path, changed, defer)
|
|
453
|
-
all_issues.extend(
|
|
454
|
-
check_ignored_must_check_return(effective_content, file_path, changed, defer)
|
|
455
|
-
)
|
|
456
450
|
all_issues.extend(check_skip_decorators_in_tests(content, file_path))
|
|
457
451
|
all_issues.extend(
|
|
458
452
|
check_tests_use_isolated_filesystem_paths(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Import and logging convention checks: check_imports_at_top, check_logging_fstrings,
|
|
1
|
+
"""Import and logging convention checks: check_imports_at_top, check_logging_fstrings, check_logging_adjacent_string_literals, check_windows_api_none, check_naive_datetime_construction, check_e2e_test_naming, check_js_resume_task_enumeration_coverage, check_js_returns_object_schemaless_branch, check_js_sibling_return_object_key_drift, check_js_bare_flag_return_directive, and check_library_print."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import re
|
|
@@ -25,7 +25,6 @@ from code_rules_shared import ( # noqa: E402
|
|
|
25
25
|
|
|
26
26
|
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
27
27
|
ALL_ALWAYS_NAIVE_DATETIME_CONSTRUCTORS,
|
|
28
|
-
ALL_FORMAT_LOGGER_FUNCTION_NAMES,
|
|
29
28
|
DATETIME_CLASS_ATTRIBUTE_NAME,
|
|
30
29
|
FROMTIMESTAMP_POSITIONAL_TIMEZONE_ARGUMENT_COUNT,
|
|
31
30
|
MAX_E2E_TEST_NAMING_ISSUES,
|
|
@@ -35,7 +34,6 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
35
34
|
MAX_JS_SIBLING_RETURN_OBJECT_KEY_DRIFT_ISSUES,
|
|
36
35
|
MAX_LOGGING_ADJACENT_LITERAL_ISSUES,
|
|
37
36
|
MAX_LOGGING_FSTRING_ISSUES,
|
|
38
|
-
MAX_LOGGING_PRINTF_TOKEN_ISSUES,
|
|
39
37
|
MAX_NAIVE_DATETIME_ISSUES,
|
|
40
38
|
MAX_WINDOWS_API_NONE_ISSUES,
|
|
41
39
|
MINIMUM_RESUME_TASK_ENUMERATION_ITEMS,
|
|
@@ -87,9 +85,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
87
85
|
JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN,
|
|
88
86
|
LOGGING_FSTRING_PATTERN,
|
|
89
87
|
LOGGING_HELPER_FUNCTION_NAME_PATTERN,
|
|
90
|
-
LOGGING_PRINTF_TOKEN_PATTERN,
|
|
91
88
|
LOGGING_RECEIVER_NAME_PATTERN,
|
|
92
|
-
MINIMUM_FORMAT_LOGGER_ARGUMENT_COUNT,
|
|
93
89
|
NOT_INSIDE_TYPE_CHECKING_BLOCK,
|
|
94
90
|
RESUME_TASK_ENUMERATION_PATTERN,
|
|
95
91
|
RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
|
|
@@ -280,121 +276,6 @@ def check_logging_fstrings(content: str) -> list[str]:
|
|
|
280
276
|
return issues
|
|
281
277
|
|
|
282
278
|
|
|
283
|
-
def _format_logger_names_imported(tree: ast.Module) -> set[str]:
|
|
284
|
-
"""Return the local names bound to automation_logging log helpers.
|
|
285
|
-
|
|
286
|
-
Scans every ``from ... import ...`` statement for an import whose module
|
|
287
|
-
path contains ``automation_logging`` and collects the local binding of each
|
|
288
|
-
imported ``log_*`` helper (the alias when ``as`` is present, otherwise the
|
|
289
|
-
imported name). Only these names identify a str.format-logger call; a
|
|
290
|
-
``log_*`` helper from any other module is not collected, because a
|
|
291
|
-
``%``-style logger formats its tokens correctly.
|
|
292
|
-
|
|
293
|
-
Args:
|
|
294
|
-
tree: The parsed module to scan for logger imports.
|
|
295
|
-
|
|
296
|
-
Returns:
|
|
297
|
-
The set of local names bound to automation_logging log helpers.
|
|
298
|
-
"""
|
|
299
|
-
bound_names: set[str] = set()
|
|
300
|
-
for each_node in ast.walk(tree):
|
|
301
|
-
if not isinstance(each_node, ast.ImportFrom):
|
|
302
|
-
continue
|
|
303
|
-
if each_node.module is None or "automation_logging" not in each_node.module:
|
|
304
|
-
continue
|
|
305
|
-
for each_alias in each_node.names:
|
|
306
|
-
if each_alias.name in ALL_FORMAT_LOGGER_FUNCTION_NAMES:
|
|
307
|
-
bound_names.add(each_alias.asname or each_alias.name)
|
|
308
|
-
return bound_names
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
def _printf_token_log_call_line(
|
|
312
|
-
node: ast.AST, all_format_logger_names: set[str]
|
|
313
|
-
) -> int | None:
|
|
314
|
-
"""Return the line of a format-logger call carrying a printf token, else None.
|
|
315
|
-
|
|
316
|
-
Args:
|
|
317
|
-
node: The AST node to inspect.
|
|
318
|
-
all_format_logger_names: Local names bound to automation_logging log
|
|
319
|
-
helpers.
|
|
320
|
-
|
|
321
|
-
Returns:
|
|
322
|
-
The 1-based line number when ``node`` is a bare-name call to a format
|
|
323
|
-
logger that has at least one format argument after the message and
|
|
324
|
-
whose first string-literal argument carries a printf token; otherwise
|
|
325
|
-
None. A call with only the message and no format arguments never runs
|
|
326
|
-
``.format(*args)``, so its token prints intact and is not flagged.
|
|
327
|
-
"""
|
|
328
|
-
if not isinstance(node, ast.Call):
|
|
329
|
-
return None
|
|
330
|
-
function_reference = node.func
|
|
331
|
-
if (
|
|
332
|
-
not isinstance(function_reference, ast.Name)
|
|
333
|
-
or function_reference.id not in all_format_logger_names
|
|
334
|
-
):
|
|
335
|
-
return None
|
|
336
|
-
if len(node.args) < MINIMUM_FORMAT_LOGGER_ARGUMENT_COUNT:
|
|
337
|
-
return None
|
|
338
|
-
message_argument = node.args[0]
|
|
339
|
-
if not isinstance(message_argument, ast.Constant) or not isinstance(
|
|
340
|
-
message_argument.value, str
|
|
341
|
-
):
|
|
342
|
-
return None
|
|
343
|
-
if not LOGGING_PRINTF_TOKEN_PATTERN.search(message_argument.value):
|
|
344
|
-
return None
|
|
345
|
-
return node.lineno
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
def check_logging_printf_tokens(content: str, file_path: str) -> list[str]:
|
|
349
|
-
"""Flag printf tokens in a str.format-logger (automation_logging) message.
|
|
350
|
-
|
|
351
|
-
The ``shared_utils.automation_logging`` helpers (``log_info``, ``log_error``,
|
|
352
|
-
...) format with ``str.format`` (``{}`` placeholders) only when format
|
|
353
|
-
arguments follow the message (``message.format(*args) if args else
|
|
354
|
-
message``), so a printf-style token such as ``%s`` in the message literal is
|
|
355
|
-
never substituted: the format arguments are dropped and the literal token
|
|
356
|
-
prints. The check fires only in a file that imports one of those helpers
|
|
357
|
-
from an ``automation_logging`` module, and only for a bare-name call to such
|
|
358
|
-
a helper that passes at least one format argument after the message and
|
|
359
|
-
whose first argument is a string literal carrying a token. A call with only
|
|
360
|
-
the message and no format arguments never runs ``.format``, so its token
|
|
361
|
-
prints intact and is left alone. An attribute call (``logger.info``) or a
|
|
362
|
-
``log_*`` helper from any other module formats ``%``-tokens correctly and is
|
|
363
|
-
left alone. Test files are exempt so a test may exercise the malformed shape.
|
|
364
|
-
|
|
365
|
-
Args:
|
|
366
|
-
content: The Python source under validation.
|
|
367
|
-
file_path: The destination path, used to skip test files and non-Python.
|
|
368
|
-
|
|
369
|
-
Returns:
|
|
370
|
-
One issue line per offending call, capped at the configured maximum.
|
|
371
|
-
"""
|
|
372
|
-
if is_test_file(file_path):
|
|
373
|
-
return []
|
|
374
|
-
if get_file_extension(file_path) not in ALL_PYTHON_EXTENSIONS:
|
|
375
|
-
return []
|
|
376
|
-
try:
|
|
377
|
-
tree = ast.parse(content)
|
|
378
|
-
except SyntaxError:
|
|
379
|
-
return []
|
|
380
|
-
all_format_logger_names = _format_logger_names_imported(tree)
|
|
381
|
-
if not all_format_logger_names:
|
|
382
|
-
return []
|
|
383
|
-
issues: list[str] = []
|
|
384
|
-
for each_node in ast.walk(tree):
|
|
385
|
-
offending_line = _printf_token_log_call_line(each_node, all_format_logger_names)
|
|
386
|
-
if offending_line is None:
|
|
387
|
-
continue
|
|
388
|
-
issues.append(
|
|
389
|
-
f"Line {offending_line}: printf token in a str.format logger "
|
|
390
|
-
"message - the automation_logging helpers format with str.format; "
|
|
391
|
-
"use {} placeholders (the %-arguments are silently dropped)"
|
|
392
|
-
)
|
|
393
|
-
if len(issues) >= MAX_LOGGING_PRINTF_TOKEN_ISSUES:
|
|
394
|
-
break
|
|
395
|
-
return issues
|
|
396
|
-
|
|
397
|
-
|
|
398
279
|
def _logging_call_receiver_name(receiver_node: ast.expr) -> str | None:
|
|
399
280
|
"""Return the trailing identifier of a logging-call receiver expression, else None.
|
|
400
281
|
|
|
@@ -49,7 +49,6 @@ ALL_SCOPE_AWARE_RULE_NAMES = frozenset(
|
|
|
49
49
|
"check_docstring_runon_sentence",
|
|
50
50
|
"check_docstring_prose_wall_without_illustration",
|
|
51
51
|
"check_boolean_naming",
|
|
52
|
-
"check_ignored_must_check_return",
|
|
53
52
|
"check_tests_use_isolated_filesystem_paths",
|
|
54
53
|
"check_return_annotations",
|
|
55
54
|
"check_function_length",
|
|
@@ -70,7 +69,6 @@ NARROW_EDIT_ACCEPTED_RULE_NAMES = frozenset(
|
|
|
70
69
|
"check_join_separator_string_magic",
|
|
71
70
|
"check_banned_noun_word_boundary",
|
|
72
71
|
"check_boolean_naming",
|
|
73
|
-
"check_ignored_must_check_return",
|
|
74
72
|
}
|
|
75
73
|
)
|
|
76
74
|
|
|
@@ -80,7 +78,6 @@ EXPECTED_RULE_TEXT_BY_NAME = {
|
|
|
80
78
|
"check_join_separator_string_magic": "string separator",
|
|
81
79
|
"check_banned_noun_word_boundary": "Identifier",
|
|
82
80
|
"check_boolean_naming": "Boolean",
|
|
83
|
-
"check_ignored_must_check_return": "return value",
|
|
84
81
|
}
|
|
85
82
|
|
|
86
83
|
|
|
@@ -139,19 +136,6 @@ ALL_REQUIRED_RULE_FIXTURES = (
|
|
|
139
136
|
expected_marker="ready",
|
|
140
137
|
rule_name="check_boolean_naming",
|
|
141
138
|
),
|
|
142
|
-
NarrowEditFixture(
|
|
143
|
-
old_fragment=(
|
|
144
|
-
"def submit_form() -> None:\n"
|
|
145
|
-
" if find_and_click('#submit'):\n"
|
|
146
|
-
" return\n"
|
|
147
|
-
),
|
|
148
|
-
new_fragment=(
|
|
149
|
-
"def submit_form() -> None:\n"
|
|
150
|
-
" find_and_click('#submit')\n"
|
|
151
|
-
),
|
|
152
|
-
expected_marker="find_and_click",
|
|
153
|
-
rule_name="check_ignored_must_check_return",
|
|
154
|
-
),
|
|
155
139
|
)
|
|
156
140
|
|
|
157
141
|
|
|
@@ -22,12 +22,10 @@ MAX_DOCSTRING_FORMAT_ISSUES: int = 5
|
|
|
22
22
|
MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES: int = 5
|
|
23
23
|
MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES: int = 5
|
|
24
24
|
MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH: int = 2
|
|
25
|
-
MAX_IGNORED_MUST_CHECK_RETURN_ISSUES: int = 5
|
|
26
25
|
MAX_TYPE_ESCAPE_HATCH_ISSUES: int = 5
|
|
27
26
|
MAX_THIN_WRAPPER_ISSUES: int = 1
|
|
28
27
|
MAX_ZERO_PAYLOAD_ALIAS_ISSUES: int = 3
|
|
29
28
|
MAX_LOGGING_FSTRING_ISSUES: int = 3
|
|
30
|
-
MAX_LOGGING_PRINTF_TOKEN_ISSUES: int = 3
|
|
31
29
|
MAX_LOGGING_ADJACENT_LITERAL_ISSUES: int = 3
|
|
32
30
|
MAX_CONFIG_DUPLICATE_PATH_ANCHOR_ISSUES: int = 3
|
|
33
31
|
MAX_WINDOWS_API_NONE_ISSUES: int = 3
|
|
@@ -204,17 +202,6 @@ DOCSTRING_REFERENCE_MARKER_WINDOW: int = 2
|
|
|
204
202
|
ALL_GENERIC_CHECK_NAME_TOKENS: frozenset[str] = frozenset(
|
|
205
203
|
{"check", "checks", "test", "tests", "in", "for", "and", "the"}
|
|
206
204
|
)
|
|
207
|
-
ALL_FORMAT_LOGGER_FUNCTION_NAMES: frozenset[str] = frozenset(
|
|
208
|
-
{
|
|
209
|
-
"log_debug",
|
|
210
|
-
"log_info",
|
|
211
|
-
"log_ok",
|
|
212
|
-
"log_error",
|
|
213
|
-
"log_warning",
|
|
214
|
-
"log_batch",
|
|
215
|
-
"log_background",
|
|
216
|
-
}
|
|
217
|
-
)
|
|
218
205
|
DOCSTRING_RUNON_SENTENCE_WORD_LIMIT: int = 30
|
|
219
206
|
MAX_DOCSTRING_RUNON_SENTENCE_ISSUES: int = 5
|
|
220
207
|
ALL_DOCSTRING_RUNON_JOINER_MARKERS: tuple[str, ...] = ("—", " -- ", ";")
|
|
@@ -72,8 +72,6 @@ ALL_POLARITY_ANTONYM_TOKEN_PAIRS: tuple[tuple[str, str], ...] = (
|
|
|
72
72
|
)
|
|
73
73
|
POLARITY_TOKEN_BOUNDARY_PATTERN: str = r"(?:^|_)%s(?:_|$)"
|
|
74
74
|
|
|
75
|
-
ALL_MUST_CHECK_RETURN_FUNCTION_NAMES: frozenset[str] = frozenset({"find_and_click", "write_outcome"})
|
|
76
|
-
|
|
77
75
|
DOCSTRING_ARG_ENTRY_PATTERN: re.Pattern[str] = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*[:(]")
|
|
78
76
|
DOCSTRING_PLURAL_FAMILY_STOP_PATTERN: re.Pattern[str] = re.compile(
|
|
79
77
|
r"\bthe\s+([a-z][a-z]+)\s+stops\b"
|
|
@@ -211,14 +209,10 @@ LOGGING_FSTRING_PATTERN = re.compile(
|
|
|
211
209
|
r'|(?:logger|logging|log)\.(?:debug|info|warning|error|critical|exception))'
|
|
212
210
|
r'\s*\(\s*(?:[rR][fF]|[fF][rR]?)["\']'
|
|
213
211
|
)
|
|
214
|
-
LOGGING_PRINTF_TOKEN_PATTERN: re.Pattern[str] = re.compile(
|
|
215
|
-
r"(?<!%)%[#0\- +]?[0-9.*]*[sdrixfgeEcoX](?![a-zA-Z])"
|
|
216
|
-
)
|
|
217
212
|
ADJACENT_STRING_LITERAL_PATTERN: re.Pattern[str] = re.compile(
|
|
218
213
|
r'(?<!["\'])[rRbBfFuU]{0,2}(?:"(?:[^"\\\n]|\\.)*"|\'(?:[^\'\\\n]|\\.)*\')'
|
|
219
214
|
r'\s*[rRbBfFuU]{0,2}(?:"(?:[^"\\\n]|\\.)*"|\'(?:[^\'\\\n]|\\.)*\')(?!["\'])'
|
|
220
215
|
)
|
|
221
|
-
MINIMUM_FORMAT_LOGGER_ARGUMENT_COUNT = 2
|
|
222
216
|
ALL_LOGGING_CALL_METHOD_NAMES: frozenset[str] = frozenset(
|
|
223
217
|
{"debug", "info", "warning", "error", "critical", "exception"}
|
|
224
218
|
)
|
|
@@ -1,19 +1,17 @@
|
|
|
1
|
-
"""Resolve local identity values for
|
|
1
|
+
"""Resolve local identity values for the PII hooks.
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
``
|
|
8
|
-
|
|
3
|
+
The hooks ship into ``~/.claude/`` and cannot read a repository file, so they
|
|
4
|
+
read machine values from the environment or a git-ignored file in the Claude
|
|
5
|
+
home directory. This module supplies the NAS host the PII scanner allowlists
|
|
6
|
+
and the owner/repo slug set used by ``pii_exempt_repository_slugs``
|
|
7
|
+
(``CLAUDE_PII_EXEMPT_REPOS`` / ``pii_exempt_repositories``). The committed NAS
|
|
8
|
+
host default is a placeholder.
|
|
9
9
|
|
|
10
10
|
::
|
|
11
11
|
|
|
12
12
|
CLAUDE_NAS_HOST set to a host -> nas_host() returns that host
|
|
13
|
-
CLAUDE_NAS_SSH_PORT set to 2200 -> nas_ssh_port() returns 2200
|
|
14
13
|
CLAUDE_PII_EXEMPT_REPOS="Owner/repo" -> {"owner/repo"}
|
|
15
14
|
(env unset, no file) -> nas_host() == "nas.example.local"
|
|
16
|
-
nas_ssh_port() == 22
|
|
17
15
|
pii_exempt_repository_slugs() == frozenset()
|
|
18
16
|
|
|
19
17
|
Each value comes from its environment variable, then
|
|
@@ -32,19 +30,11 @@ PII_EXEMPT_REPOS_ENV_VAR = "CLAUDE_PII_EXEMPT_REPOS"
|
|
|
32
30
|
PII_EXEMPT_REPOS_JSON_KEY = "pii_exempt_repositories"
|
|
33
31
|
PII_EXEMPT_REPOS_SEPARATOR = ","
|
|
34
32
|
PII_ALLOWLISTED_VALUES_JSON_KEY = "pii_allowlisted_values"
|
|
35
|
-
NAS_SSH_USER_ENV_VAR = "CLAUDE_NAS_SSH_USER"
|
|
36
|
-
NAS_SSH_PORT_ENV_VAR = "CLAUDE_NAS_SSH_PORT"
|
|
37
33
|
NAS_JSON_KEY = "nas"
|
|
38
34
|
NAS_HOST_JSON_KEY = "host"
|
|
39
|
-
NAS_SSH_USER_JSON_KEY = "ssh_user"
|
|
40
|
-
NAS_SSH_PORT_JSON_KEY = "ssh_port"
|
|
41
35
|
CLAUDE_HOME_DIRECTORY_NAME = ".claude"
|
|
42
36
|
LOCAL_IDENTITY_FILE_NAME = "local-identity.json"
|
|
43
37
|
PLACEHOLDER_NAS_HOST = "nas.example.local"
|
|
44
|
-
PLACEHOLDER_NAS_SSH_USER = "operator"
|
|
45
|
-
PLACEHOLDER_NAS_SSH_PORT = 22
|
|
46
|
-
OPENSSH_INVOCATION_EXAMPLE_BINARY = '"/c/Windows/System32/OpenSSH/ssh.exe"'
|
|
47
|
-
NAS_SSH_RULE_REFERENCE = "~/.claude/rules/nas-ssh-invocation.md"
|
|
48
38
|
|
|
49
39
|
|
|
50
40
|
def _local_identity_file_path() -> Path:
|
|
@@ -178,106 +168,3 @@ def nas_host() -> str:
|
|
|
178
168
|
if isinstance(stored_host, str) and stored_host:
|
|
179
169
|
return stored_host
|
|
180
170
|
return PLACEHOLDER_NAS_HOST
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def nas_ssh_user() -> str:
|
|
184
|
-
"""Return the ssh user the enforcer expects for the NAS.
|
|
185
|
-
|
|
186
|
-
::
|
|
187
|
-
|
|
188
|
-
CLAUDE_NAS_SSH_USER set to a user -> that user
|
|
189
|
-
(env unset, no file) -> "operator"
|
|
190
|
-
|
|
191
|
-
The environment variable wins, then the git-ignored file, then the
|
|
192
|
-
placeholder default.
|
|
193
|
-
|
|
194
|
-
Returns:
|
|
195
|
-
The ssh login name for the NAS.
|
|
196
|
-
"""
|
|
197
|
-
user_from_environment = os.environ.get(NAS_SSH_USER_ENV_VAR)
|
|
198
|
-
if user_from_environment:
|
|
199
|
-
return user_from_environment
|
|
200
|
-
stored_user = _nas_section_from_local_file().get(NAS_SSH_USER_JSON_KEY)
|
|
201
|
-
if isinstance(stored_user, str) and stored_user:
|
|
202
|
-
return stored_user
|
|
203
|
-
return PLACEHOLDER_NAS_SSH_USER
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def nas_ssh_port() -> int:
|
|
207
|
-
"""Return the ssh port the enforcer expects for the NAS.
|
|
208
|
-
|
|
209
|
-
::
|
|
210
|
-
|
|
211
|
-
CLAUDE_NAS_SSH_PORT set to 2200 -> 2200
|
|
212
|
-
(env unset, no file) -> 22
|
|
213
|
-
|
|
214
|
-
The environment variable wins, then the git-ignored file, then the
|
|
215
|
-
placeholder default.
|
|
216
|
-
|
|
217
|
-
Returns:
|
|
218
|
-
The ssh port number for the NAS.
|
|
219
|
-
"""
|
|
220
|
-
port_from_environment = os.environ.get(NAS_SSH_PORT_ENV_VAR)
|
|
221
|
-
if port_from_environment and port_from_environment.isdigit():
|
|
222
|
-
return int(port_from_environment)
|
|
223
|
-
stored_port = _nas_section_from_local_file().get(NAS_SSH_PORT_JSON_KEY)
|
|
224
|
-
if isinstance(stored_port, int):
|
|
225
|
-
return stored_port
|
|
226
|
-
return PLACEHOLDER_NAS_SSH_PORT
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def _openssh_invocation_example() -> str:
|
|
230
|
-
return (
|
|
231
|
-
f"{OPENSSH_INVOCATION_EXAMPLE_BINARY} -o BatchMode=yes -o ConnectTimeout=10 "
|
|
232
|
-
f'-p {nas_ssh_port()} {nas_ssh_user()}@{nas_host()} "<cmd>"'
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def bare_ssh_binary_deny_message() -> str:
|
|
237
|
-
"""Return the deny message for a bare ssh-family word aimed at the NAS.
|
|
238
|
-
|
|
239
|
-
::
|
|
240
|
-
|
|
241
|
-
ssh -p 22 operator@nas.example.local "ls" -> this message text
|
|
242
|
-
(the text quotes the resolved host, user, and port)
|
|
243
|
-
|
|
244
|
-
Names the Git Bash MSYS-ssh hang and points at the Windows OpenSSH binary
|
|
245
|
-
with batch mode on.
|
|
246
|
-
|
|
247
|
-
Returns:
|
|
248
|
-
The full deny-message text for the bare ssh-family case.
|
|
249
|
-
"""
|
|
250
|
-
return (
|
|
251
|
-
f"BLOCKED [nas-ssh-binary]: Git Bash's MSYS ssh reads ~/.ssh/id_ed25519 as "
|
|
252
|
-
f"world-readable through its ACL mapping, rejects the key as bad permissions, "
|
|
253
|
-
f"and falls back to an interactive password prompt that hangs unattended "
|
|
254
|
-
f"sessions against the NAS at {nas_host()}.\n\n"
|
|
255
|
-
f"Use the Windows OpenSSH binary, which authenticates the key without prompting:\n"
|
|
256
|
-
f" {_openssh_invocation_example()}\n\n"
|
|
257
|
-
f"See {NAS_SSH_RULE_REFERENCE} for full guidance."
|
|
258
|
-
)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
def missing_batch_mode_deny_message() -> str:
|
|
262
|
-
"""Return the deny message for the OpenSSH binary without batch mode.
|
|
263
|
-
|
|
264
|
-
::
|
|
265
|
-
|
|
266
|
-
"..ssh.exe" -p 22 operator@nas.example.local "ls" -> this message text
|
|
267
|
-
(the text quotes the resolved host, user, and port)
|
|
268
|
-
|
|
269
|
-
Names the interactive-prompt hang and asks for ``-o BatchMode=yes`` so a key
|
|
270
|
-
failure exits loudly.
|
|
271
|
-
|
|
272
|
-
Returns:
|
|
273
|
-
The full deny-message text for the missing-batch-mode case.
|
|
274
|
-
"""
|
|
275
|
-
return (
|
|
276
|
-
f"BLOCKED [nas-ssh-binary]: this NAS ssh command uses the Windows OpenSSH binary "
|
|
277
|
-
f"but omits -o BatchMode=yes, so an authentication regression falls back to an "
|
|
278
|
-
f"interactive password prompt that hangs unattended sessions against the NAS at "
|
|
279
|
-
f"{nas_host()}.\n\n"
|
|
280
|
-
f"Add -o BatchMode=yes so a key failure exits loudly rather than prompting:\n"
|
|
281
|
-
f" {_openssh_invocation_example()}\n\n"
|
|
282
|
-
f"See {NAS_SSH_RULE_REFERENCE} for full guidance."
|
|
283
|
-
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests for the
|
|
1
|
+
"""Tests for the local-identity loader used by the PII hooks."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -14,7 +14,7 @@ except ImportError:
|
|
|
14
14
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
15
15
|
from hooks_constants import local_identity
|
|
16
16
|
|
|
17
|
-
ALL_NAS_ENV_VARS = ("CLAUDE_NAS_HOST",
|
|
17
|
+
ALL_NAS_ENV_VARS = ("CLAUDE_NAS_HOST",)
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _clear_nas_env() -> None:
|
|
@@ -23,30 +23,20 @@ def _clear_nas_env() -> None:
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class TestNasValuesFromEnvironment:
|
|
26
|
-
def
|
|
27
|
-
with patch.dict(
|
|
28
|
-
os.environ,
|
|
29
|
-
{
|
|
30
|
-
"CLAUDE_NAS_HOST": "10.0.0.5",
|
|
31
|
-
"CLAUDE_NAS_SSH_USER": "tester",
|
|
32
|
-
"CLAUDE_NAS_SSH_PORT": "2200",
|
|
33
|
-
},
|
|
34
|
-
clear=False,
|
|
35
|
-
):
|
|
26
|
+
def should_read_host_from_the_environment(self) -> None:
|
|
27
|
+
with patch.dict(os.environ, {"CLAUDE_NAS_HOST": "10.0.0.5"}, clear=False):
|
|
36
28
|
assert local_identity.nas_host() == "10.0.0.5"
|
|
37
|
-
assert local_identity.nas_ssh_user() == "tester"
|
|
38
|
-
assert local_identity.nas_ssh_port() == 2200
|
|
39
29
|
|
|
40
30
|
|
|
41
31
|
class TestNasValuesFromLocalFile:
|
|
42
|
-
def
|
|
32
|
+
def should_read_host_from_the_local_identity_file(
|
|
43
33
|
self, tmp_path: Path
|
|
44
34
|
) -> None:
|
|
45
35
|
claude_home = tmp_path / ".claude"
|
|
46
36
|
claude_home.mkdir()
|
|
47
37
|
(claude_home / "local-identity.json").write_text(
|
|
48
38
|
json.dumps(
|
|
49
|
-
{"nas": {"host": "10.1.1.9"
|
|
39
|
+
{"nas": {"host": "10.1.1.9"}}
|
|
50
40
|
),
|
|
51
41
|
encoding="utf-8",
|
|
52
42
|
)
|
|
@@ -54,18 +44,14 @@ class TestNasValuesFromLocalFile:
|
|
|
54
44
|
_clear_nas_env()
|
|
55
45
|
with patch.object(Path, "home", return_value=tmp_path):
|
|
56
46
|
assert local_identity.nas_host() == "10.1.1.9"
|
|
57
|
-
assert local_identity.nas_ssh_user() == "fileuser"
|
|
58
|
-
assert local_identity.nas_ssh_port() == 2222
|
|
59
47
|
|
|
60
48
|
|
|
61
49
|
class TestNasValuesPlaceholderDefault:
|
|
62
|
-
def
|
|
50
|
+
def should_return_placeholder_when_no_env_and_no_file(self, tmp_path: Path) -> None:
|
|
63
51
|
with patch.dict(os.environ, {}, clear=False):
|
|
64
52
|
_clear_nas_env()
|
|
65
53
|
with patch.object(Path, "home", return_value=tmp_path):
|
|
66
54
|
assert local_identity.nas_host() == "nas.example.local"
|
|
67
|
-
assert local_identity.nas_ssh_user() == "operator"
|
|
68
|
-
assert local_identity.nas_ssh_port() == 22
|
|
69
55
|
|
|
70
56
|
|
|
71
57
|
class TestPiiExemptRepositorySlugs:
|
|
@@ -234,25 +220,3 @@ class TestLocalIdentityPathOverride:
|
|
|
234
220
|
), patch.object(Path, "home", return_value=tmp_path):
|
|
235
221
|
_clear_nas_env()
|
|
236
222
|
assert local_identity.nas_host() == "10.9.9.9"
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
class TestDenyMessagesQuoteTheResolvedHost:
|
|
240
|
-
def should_include_the_resolved_host_and_port_in_the_bare_binary_message(
|
|
241
|
-
self,
|
|
242
|
-
) -> None:
|
|
243
|
-
with patch.dict(
|
|
244
|
-
os.environ,
|
|
245
|
-
{"CLAUDE_NAS_HOST": "10.0.0.5", "CLAUDE_NAS_SSH_PORT": "2200"},
|
|
246
|
-
clear=False,
|
|
247
|
-
):
|
|
248
|
-
os.environ.pop("CLAUDE_NAS_SSH_USER", None)
|
|
249
|
-
message = local_identity.bare_ssh_binary_deny_message()
|
|
250
|
-
assert "10.0.0.5" in message
|
|
251
|
-
assert "-p 2200" in message
|
|
252
|
-
assert "BatchMode=yes" in message
|
|
253
|
-
|
|
254
|
-
def should_include_the_resolved_host_in_the_missing_batch_mode_message(self) -> None:
|
|
255
|
-
with patch.dict(os.environ, {"CLAUDE_NAS_HOST": "10.0.0.5"}, clear=False):
|
|
256
|
-
message = local_identity.missing_batch_mode_deny_message()
|
|
257
|
-
assert "10.0.0.5" in message
|
|
258
|
-
assert "BatchMode=yes" in message
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ Two constraints govern every shell command an agent issues: which shell runs it,
|
|
|
6
6
|
|
|
7
7
|
Every Bash-tool shell command on Windows uses `pwsh`: `pwsh -NoProfile -File '<script>.ps1' <args>` for scripts, `pwsh -NoProfile -Command "..."` (or a literal `@'...'@` here-string) for inline work, or the built-in `PowerShell` tool for pure-PowerShell workflows (it supports `run_in_background`). Never wrap a script path in `-Command "& '...'"` — `-File` keeps `permissions.allow` matching. The `&` call operator is fine for invoking an executable at a path (`& '<venv>\Scripts\python.exe' script.py`).
|
|
8
8
|
|
|
9
|
-
The mandate covers the shell a command runs through
|
|
9
|
+
The mandate covers the shell a command runs through. A direct interpreter invocation, such as a `python` call on a repo script, conforms as written.
|
|
10
10
|
|
|
11
11
|
Keep `powershell`, `powershell.exe`, `cmd /c`, and `bash -c` out of the `settings.json` permission rules. `Audit-ShellPolicy.ps1` reports those forms and `Migrate-ShellPolicy.ps1` rewrites them to `pwsh`. Both ship in the claude-dev-env repo at `packages/claude-dev-env/scripts/` and run on demand, not as a live gate.
|
|
12
12
|
|
|
@@ -147,10 +147,5 @@ ALL_TRACKED_SECRET_EXACT_EXEMPTIONS: frozenset[tuple[str, str, str]] = frozenset
|
|
|
147
147
|
"home-path",
|
|
148
148
|
"5d956228802fbd4a65af8fa7fb4183f5a9f8793dbe218877aefe887b905f726d",
|
|
149
149
|
),
|
|
150
|
-
(
|
|
151
|
-
"shared_utils/theme_assets/contracts/evidence/theme_editor_asset_resolution_master.oneui9.v1.json",
|
|
152
|
-
"home-path",
|
|
153
|
-
"ec69fe88e2f9d3f1ed3504532c7484120591cbec7ae6fda017b0f9ca57a3fc25",
|
|
154
|
-
),
|
|
155
150
|
)
|
|
156
151
|
)
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
# Running Commands on the NAS
|
|
2
|
-
|
|
3
|
-
Full detail behind the always-on `rules/nas-ssh-invocation.md` kernel.
|
|
4
|
-
|
|
5
|
-
This file covers how to run a command there at all. What the NAS runs, where the automations are
|
|
6
|
-
deployed, and how to verify a change against them belong with the project that owns those
|
|
7
|
-
automations, not here.
|
|
8
|
-
|
|
9
|
-
## The call
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
python <runner-path> <command-script.sh> <private-key-path>
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
Two arguments, both paths. The first is a file holding a bash script; the second is the private key.
|
|
16
|
-
|
|
17
|
-
The runner is `nas_ssh_key.py`. It ships with the automation project it serves, so read its path from
|
|
18
|
-
that project's own reference rather than assuming a location.
|
|
19
|
-
|
|
20
|
-
The host, ssh port and ssh user are constants inside the runner, so no command you write names them.
|
|
21
|
-
The same three values are recorded under the `nas` key in `~/.claude/local-identity.json`. Read them
|
|
22
|
-
from there when something needs them, and keep them out of anything committed or posted.
|
|
23
|
-
|
|
24
|
-
Write the script file with the Write tool. Keeping the commands in a file has a second benefit: a
|
|
25
|
-
destructive word such as `rm -rf` inside the file never appears in a Bash tool command string, so the
|
|
26
|
-
harness permission prompt never fires on it and an unattended run keeps going.
|
|
27
|
-
|
|
28
|
-
The script runs through `bash -s` in a single shell. Variables, `cd`, and `source` all carry from one
|
|
29
|
-
line to the next. Standard output, standard error and the exit code all come back.
|
|
30
|
-
|
|
31
|
-
## Why not ssh, scp or sftp
|
|
32
|
-
|
|
33
|
-
The runner loads the key with paramiko and signs inside the Python process. The command-line clients
|
|
34
|
-
check the key file's permissions first and refuse to load a key whose permissions they do not like.
|
|
35
|
-
Git Bash's `ssh` then falls back to an interactive password prompt, which hangs a run with nobody to
|
|
36
|
-
answer it.
|
|
37
|
-
|
|
38
|
-
To move a file onto the NAS, write it from inside the script:
|
|
39
|
-
|
|
40
|
-
```bash
|
|
41
|
-
cat > /tmp/thing.conf <<'EOF'
|
|
42
|
-
contents here
|
|
43
|
-
EOF
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
The quoted `'EOF'` stops the shell expanding anything in the body.
|
|
47
|
-
|
|
48
|
-
## The key
|
|
49
|
-
|
|
50
|
-
Use the ops key under `~/.claude/keys`. It is readable as-is and the NAS accepts it.
|
|
51
|
-
|
|
52
|
-
Keys under `~/.ssh` are for other jobs and none of them works here. The default one carries a
|
|
53
|
-
passphrase, so paramiko cannot load it unattended; the rest are for other hosts.
|
|
54
|
-
|
|
55
|
-
## Writing a script that touches the automations
|
|
56
|
-
|
|
57
|
-
Do not reach for the system interpreter. It carries neither pytest nor the libraries the automations
|
|
58
|
-
import, so anything checked against it proves nothing.
|
|
59
|
-
|
|
60
|
-
Each automation project records its own runtime — the interpreter, the virtual environment, the
|
|
61
|
-
import root, and the deploy path. Read that project's reference and activate what it names before
|
|
62
|
-
running anything.
|
|
63
|
-
|
|
64
|
-
## Verifying a change before it ships
|
|
65
|
-
|
|
66
|
-
The NAS is a Linux box and this machine is Windows, so two classes of difference show up only there:
|
|
67
|
-
syntax newer than the NAS interpreter accepts, and tests that assert Windows paths.
|
|
68
|
-
|
|
69
|
-
**Always separate a break in the code from a platform-only one.** Run the same test file at the branch head
|
|
70
|
-
and at the commit the work started from. Identical failures at both mean the environment, not the
|
|
71
|
-
change. Report both numbers rather than the head alone.
|
|
72
|
-
|
|
73
|
-
## The /tmp limit
|
|
74
|
-
|
|
75
|
-
`/tmp` is a small tmpfs. A full clone of a repository that carries binary assets fills it, the clone
|
|
76
|
-
dies partway through, and the disk stays full for whatever runs next.
|
|
77
|
-
|
|
78
|
-
Fetch shallow and sparse instead:
|
|
79
|
-
|
|
80
|
-
```bash
|
|
81
|
-
d=$(mktemp -d /tmp/work.XXXXXX)
|
|
82
|
-
cd "$d"
|
|
83
|
-
git init --quiet
|
|
84
|
-
git remote add origin <repository-url>
|
|
85
|
-
git sparse-checkout init --cone
|
|
86
|
-
git sparse-checkout set <subdirectory>
|
|
87
|
-
git fetch --quiet --depth 1 origin <sha>
|
|
88
|
-
git checkout --quiet FETCH_HEAD
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
One subdirectory at one commit lands tens of megabytes rather than the whole history. Clean up at the
|
|
92
|
-
end and print `df -h /tmp` so the next run knows the state:
|
|
93
|
-
|
|
94
|
-
```bash
|
|
95
|
-
find /tmp -maxdepth 1 -name 'work.*' -prune -exec rm -rf {} +
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
## When something fails
|
|
99
|
-
|
|
100
|
-
| What you see | What it means |
|
|
101
|
-
|---|---|
|
|
102
|
-
| `PermissionError: [Errno 13]` on the key | The key file is not readable by this account. |
|
|
103
|
-
| `PasswordRequiredException: Private key file is encrypted` | That key has a passphrase and cannot be used unattended. |
|
|
104
|
-
| `Authentication (publickey) failed` | The transport works and the NAS does not accept that key. |
|
|
105
|
-
| `Load key ...: Permission denied` from `ssh.exe` | A command-line client is being used. Use the runner. |
|
|
106
|
-
| `No space left on device` | `/tmp` is full. Clean it, then fetch shallow and sparse. |
|
|
107
|
-
| `No module named pytest` | The system interpreter is being used. Activate the project's environment first. |
|
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
"""Tests for check_ignored_must_check_return — discarded must-check outcomes.
|
|
2
|
-
|
|
3
|
-
A bare-statement call to a function in ALL_MUST_CHECK_RETURN_FUNCTION_NAMES
|
|
4
|
-
discards the only failure signal it produces. An assigned or branched-on
|
|
5
|
-
call is exempt; only bare ``ast.Expr`` calls are flagged.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import sys
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from types import SimpleNamespace
|
|
13
|
-
|
|
14
|
-
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
15
|
-
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
16
|
-
if _BLOCKING_DIRECTORY not in sys.path:
|
|
17
|
-
sys.path.insert(0, _BLOCKING_DIRECTORY)
|
|
18
|
-
if _HOOKS_DIRECTORY not in sys.path:
|
|
19
|
-
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
20
|
-
|
|
21
|
-
from code_rules_boolean_mustcheck import ( # noqa: E402
|
|
22
|
-
MAX_IGNORED_MUST_CHECK_RETURN_ISSUES,
|
|
23
|
-
)
|
|
24
|
-
from code_rules_boolean_mustcheck import ( # noqa: E402
|
|
25
|
-
check_ignored_must_check_return as _check_ignored_must_check_return,
|
|
26
|
-
)
|
|
27
|
-
from code_rules_enforcer import ( # noqa: E402
|
|
28
|
-
validate_content_for_full_gate as _validate_content,
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
code_rules_enforcer = SimpleNamespace(
|
|
32
|
-
MAX_IGNORED_MUST_CHECK_RETURN_ISSUES=MAX_IGNORED_MUST_CHECK_RETURN_ISSUES,
|
|
33
|
-
check_ignored_must_check_return=_check_ignored_must_check_return,
|
|
34
|
-
validate_content=_validate_content,
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def check_ignored_must_check_return(content: str, file_path: str) -> list[str]:
|
|
39
|
-
return code_rules_enforcer.check_ignored_must_check_return(content, file_path)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
43
|
-
return code_rules_enforcer.validate_content(content, file_path, old_content)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
PRODUCTION_FILE_PATH = "/project/src/clicker.py"
|
|
47
|
-
TEST_FILE_PATH = "/project/src/test_clicker.py"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_should_flag_bare_find_and_click_call() -> None:
|
|
51
|
-
source = "def step() -> None:\n find_and_click('#submit')\n"
|
|
52
|
-
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
53
|
-
assert any("find_and_click" in each for each in issues), (
|
|
54
|
-
f"Expected discarded-return flag for find_and_click, got: {issues!r}"
|
|
55
|
-
)
|
|
56
|
-
assert len(issues) == 1
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_should_flag_bare_write_outcome_call() -> None:
|
|
60
|
-
source = "def step() -> None:\n write_outcome('done')\n"
|
|
61
|
-
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
62
|
-
assert any("write_outcome" in each for each in issues), (
|
|
63
|
-
f"Expected discarded-return flag for write_outcome, got: {issues!r}"
|
|
64
|
-
)
|
|
65
|
-
assert len(issues) == 1
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def test_should_flag_attribute_call_with_must_check_name() -> None:
|
|
69
|
-
source = "def step() -> None:\n self.find_and_click('#submit')\n"
|
|
70
|
-
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
71
|
-
assert len(issues) == 1, f"Attribute call terminal name must be resolved, got: {issues!r}"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def test_should_not_flag_assigned_find_and_click() -> None:
|
|
75
|
-
source = "def step() -> None:\n clicked = find_and_click('#submit')\n print(clicked)\n"
|
|
76
|
-
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
77
|
-
assert issues == [], f"Assigned call must not be flagged, got: {issues!r}"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def test_should_not_flag_branched_find_and_click() -> None:
|
|
81
|
-
source = "def step() -> None:\n if find_and_click('#submit'):\n pass\n"
|
|
82
|
-
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
83
|
-
assert issues == [], f"Branched-on call must not be flagged, got: {issues!r}"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def test_should_flag_bare_awaited_find_and_click_call() -> None:
|
|
87
|
-
source = "async def step() -> None:\n await find_and_click('#x')\n"
|
|
88
|
-
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
89
|
-
assert any("find_and_click" in each for each in issues), (
|
|
90
|
-
f"Expected discarded-return flag for awaited find_and_click, got: {issues!r}"
|
|
91
|
-
)
|
|
92
|
-
assert len(issues) == 1
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def test_should_not_flag_assigned_awaited_find_and_click() -> None:
|
|
96
|
-
source = (
|
|
97
|
-
"async def step() -> None:\n"
|
|
98
|
-
" clicked = await find_and_click('#x')\n"
|
|
99
|
-
" print(clicked)\n"
|
|
100
|
-
)
|
|
101
|
-
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
102
|
-
assert issues == [], f"Assigned awaited call must not be flagged, got: {issues!r}"
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def test_should_not_flag_branched_awaited_find_and_click() -> None:
|
|
106
|
-
source = "async def step() -> None:\n if await find_and_click('#x'):\n pass\n"
|
|
107
|
-
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
108
|
-
assert issues == [], f"Branched-on awaited call must not be flagged, got: {issues!r}"
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def test_should_not_flag_unrelated_bare_call() -> None:
|
|
112
|
-
source = "def step() -> None:\n print('hello')\n"
|
|
113
|
-
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
114
|
-
assert issues == [], f"Unrelated call must not be flagged, got: {issues!r}"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def test_should_skip_test_file() -> None:
|
|
118
|
-
source = "def step() -> None:\n find_and_click('#submit')\n"
|
|
119
|
-
issues = check_ignored_must_check_return(source, TEST_FILE_PATH)
|
|
120
|
-
assert issues == [], f"Test files exempt, got: {issues!r}"
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def test_should_handle_syntax_error_gracefully() -> None:
|
|
124
|
-
issues = check_ignored_must_check_return("def step(\n", PRODUCTION_FILE_PATH)
|
|
125
|
-
assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def test_validate_content_surfaces_discarded_return() -> None:
|
|
129
|
-
source = "def step() -> None:\n find_and_click('#submit')\n"
|
|
130
|
-
issues = validate_content(source, PRODUCTION_FILE_PATH, old_content="")
|
|
131
|
-
matching_issues = [each for each in issues if "find_and_click" in each]
|
|
132
|
-
assert matching_issues, (
|
|
133
|
-
f"Expected validate_content to surface the discarded-return issue, got: {issues!r}"
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
EDIT_FULL_MODULE_SOURCE = (
|
|
138
|
-
"async def step() -> None:\n"
|
|
139
|
-
" await find_and_click('#x')\n"
|
|
140
|
-
)
|
|
141
|
-
AWAITED_CALL_LINE_NUMBER = 2
|
|
142
|
-
UNCHANGED_LINE_NUMBER = 1
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def test_should_flag_when_changed_line_covers_the_bare_await() -> None:
|
|
146
|
-
all_changed_lines = {AWAITED_CALL_LINE_NUMBER}
|
|
147
|
-
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
148
|
-
EDIT_FULL_MODULE_SOURCE,
|
|
149
|
-
PRODUCTION_FILE_PATH,
|
|
150
|
-
all_changed_lines,
|
|
151
|
-
False,
|
|
152
|
-
)
|
|
153
|
-
assert len(issues) == 1, (
|
|
154
|
-
f"An Edit touching the bare await line must surface exactly one issue, got: {issues!r}"
|
|
155
|
-
)
|
|
156
|
-
assert "find_and_click" in issues[0]
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def test_should_not_flag_when_changed_line_excludes_the_bare_await() -> None:
|
|
160
|
-
all_changed_lines = {UNCHANGED_LINE_NUMBER}
|
|
161
|
-
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
162
|
-
EDIT_FULL_MODULE_SOURCE,
|
|
163
|
-
PRODUCTION_FILE_PATH,
|
|
164
|
-
all_changed_lines,
|
|
165
|
-
False,
|
|
166
|
-
)
|
|
167
|
-
assert issues == [], (
|
|
168
|
-
f"A pre-existing violation on an unedited line must not block the edit, got: {issues!r}"
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
PRE_EXISTING_BARE_CALL_COUNT = 5
|
|
173
|
-
EDITED_BARE_CALL_LINE_NUMBER = PRE_EXISTING_BARE_CALL_COUNT + 2
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def _build_module_with_pre_existing_violations_before_the_edit() -> str:
|
|
177
|
-
all_signature_lines = ["async def step() -> None:"]
|
|
178
|
-
all_pre_existing_call_lines = [
|
|
179
|
-
f" await find_and_click('#x{each_index}')"
|
|
180
|
-
for each_index in range(PRE_EXISTING_BARE_CALL_COUNT)
|
|
181
|
-
]
|
|
182
|
-
edited_call_line = " await find_and_click('#edited')"
|
|
183
|
-
all_lines = all_signature_lines + all_pre_existing_call_lines + [edited_call_line]
|
|
184
|
-
return "\n".join(all_lines) + "\n"
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def test_should_flag_edited_line_even_when_cap_worth_of_violations_precede_it() -> None:
|
|
188
|
-
source = _build_module_with_pre_existing_violations_before_the_edit()
|
|
189
|
-
all_changed_lines = {EDITED_BARE_CALL_LINE_NUMBER}
|
|
190
|
-
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
191
|
-
source,
|
|
192
|
-
PRODUCTION_FILE_PATH,
|
|
193
|
-
all_changed_lines,
|
|
194
|
-
False,
|
|
195
|
-
)
|
|
196
|
-
assert len(issues) == 1, (
|
|
197
|
-
"Collecting every violation before scoping must surface the edited-line "
|
|
198
|
-
f"violation even with a cap's worth of earlier out-of-scope calls, got: {issues!r}"
|
|
199
|
-
)
|
|
200
|
-
assert f"Line {EDITED_BARE_CALL_LINE_NUMBER}:" in issues[0], (
|
|
201
|
-
f"The single issue must name the edited line {EDITED_BARE_CALL_LINE_NUMBER}, got: {issues!r}"
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def _build_module_with_more_than_cap_bare_calls() -> tuple[str, int]:
|
|
206
|
-
bare_call_count = code_rules_enforcer.MAX_IGNORED_MUST_CHECK_RETURN_ISSUES + 3
|
|
207
|
-
all_signature_lines = ["async def step() -> None:"]
|
|
208
|
-
all_call_lines = [
|
|
209
|
-
f" await find_and_click('#x{each_index}')"
|
|
210
|
-
for each_index in range(bare_call_count)
|
|
211
|
-
]
|
|
212
|
-
source = "\n".join(all_signature_lines + all_call_lines) + "\n"
|
|
213
|
-
return source, bare_call_count
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def test_deferred_scope_returns_every_violation_uncapped() -> None:
|
|
217
|
-
source, bare_call_count = _build_module_with_more_than_cap_bare_calls()
|
|
218
|
-
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
219
|
-
source,
|
|
220
|
-
PRODUCTION_FILE_PATH,
|
|
221
|
-
None,
|
|
222
|
-
True,
|
|
223
|
-
)
|
|
224
|
-
assert len(issues) == bare_call_count, (
|
|
225
|
-
"With defer_scope_to_caller=True the gate must see every violation uncapped "
|
|
226
|
-
f"so it can scope by added line, got: {issues!r}"
|
|
227
|
-
)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def test_terminal_scope_caps_violations_at_the_module_limit() -> None:
|
|
231
|
-
source, _ = _build_module_with_more_than_cap_bare_calls()
|
|
232
|
-
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
233
|
-
source,
|
|
234
|
-
PRODUCTION_FILE_PATH,
|
|
235
|
-
None,
|
|
236
|
-
False,
|
|
237
|
-
)
|
|
238
|
-
assert len(issues) == code_rules_enforcer.MAX_IGNORED_MUST_CHECK_RETURN_ISSUES, (
|
|
239
|
-
"The terminal hook path with all_changed_lines=None must cap at the module "
|
|
240
|
-
f"limit, got: {issues!r}"
|
|
241
|
-
)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
WRAPPED_CALL_OPEN_PAREN_LINE_NUMBER = 2
|
|
245
|
-
WRAPPED_CALL_ARGUMENT_LINE_NUMBER = 3
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
def test_should_flag_when_changed_line_covers_a_later_line_of_a_wrapped_call() -> None:
|
|
249
|
-
source = (
|
|
250
|
-
"def step() -> None:\n"
|
|
251
|
-
" find_and_click(\n"
|
|
252
|
-
" '#submit',\n"
|
|
253
|
-
" )\n"
|
|
254
|
-
)
|
|
255
|
-
all_changed_lines = {WRAPPED_CALL_ARGUMENT_LINE_NUMBER}
|
|
256
|
-
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
257
|
-
source,
|
|
258
|
-
PRODUCTION_FILE_PATH,
|
|
259
|
-
all_changed_lines,
|
|
260
|
-
False,
|
|
261
|
-
)
|
|
262
|
-
assert len(issues) == 1, (
|
|
263
|
-
"Editing a later line of a multi-line bare must-check call must still flag it "
|
|
264
|
-
f"because the violation span covers the whole call, got: {issues!r}"
|
|
265
|
-
)
|
|
266
|
-
assert "find_and_click" in issues[0]
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
"""Behavior tests for the code_rules_boolean_mustcheck code-rules check module."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import sys
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from types import SimpleNamespace
|
|
8
|
-
|
|
9
|
-
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
10
|
-
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
11
|
-
if _BLOCKING_DIRECTORY not in sys.path:
|
|
12
|
-
sys.path.insert(0, _BLOCKING_DIRECTORY)
|
|
13
|
-
if _HOOKS_DIRECTORY not in sys.path:
|
|
14
|
-
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
15
|
-
|
|
16
|
-
from code_rules_boolean_mustcheck import ( # noqa: E402
|
|
17
|
-
check_ignored_must_check_return,
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
code_rules_enforcer = SimpleNamespace(
|
|
21
|
-
check_ignored_must_check_return=check_ignored_must_check_return,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def test_ignored_must_check_return_flags_bare_awaited_call() -> None:
|
|
26
|
-
"""A bare ``await find_and_click(...)`` statement discards its only failure signal.
|
|
27
|
-
|
|
28
|
-
The curated must-check functions are async, so the common real call site is a
|
|
29
|
-
bare ``await``-wrapped call. Unwrapping ``ast.Await`` before the Call check is
|
|
30
|
-
required for this case to be flagged.
|
|
31
|
-
"""
|
|
32
|
-
source = "async def step() -> None:\n await find_and_click('#x')\n"
|
|
33
|
-
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
34
|
-
source, "/project/src/clicker.py"
|
|
35
|
-
)
|
|
36
|
-
assert any("find_and_click" in each_issue for each_issue in issues), (
|
|
37
|
-
f"a bare awaited must-check call must be flagged; got: {issues!r}"
|
|
38
|
-
)
|
|
39
|
-
assert len(issues) == 1
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_ignored_must_check_return_exempts_consumed_awaited_call() -> None:
|
|
43
|
-
"""An assigned or branched-on awaited must-check call consumes its outcome."""
|
|
44
|
-
assigned = "async def step() -> None:\n clicked = await find_and_click('#x')\n print(clicked)\n"
|
|
45
|
-
branched = "async def step() -> None:\n if await find_and_click('#x'):\n pass\n"
|
|
46
|
-
assert (
|
|
47
|
-
code_rules_enforcer.check_ignored_must_check_return(assigned, "/project/src/clicker.py")
|
|
48
|
-
== []
|
|
49
|
-
)
|
|
50
|
-
assert (
|
|
51
|
-
code_rules_enforcer.check_ignored_must_check_return(branched, "/project/src/clicker.py")
|
|
52
|
-
== []
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_ignored_must_check_return_flags_edited_line_past_a_cap_of_earlier_violations() -> None:
|
|
57
|
-
"""The cap must apply after scoping so the edited-line violation is never dropped.
|
|
58
|
-
|
|
59
|
-
Collecting only a cap's worth of violations in ``ast.walk`` order, then scoping,
|
|
60
|
-
fills the cap with earlier out-of-scope calls and discards the edited-line one —
|
|
61
|
-
the very violation the scoped enforcer exists to block. Every violation must be
|
|
62
|
-
collected before scoping so the edited line survives the diff filter.
|
|
63
|
-
"""
|
|
64
|
-
pre_existing_call_count = 5
|
|
65
|
-
edited_call_line_number = pre_existing_call_count + 2
|
|
66
|
-
all_pre_existing_call_lines = [
|
|
67
|
-
f" await find_and_click('#x{each_index}')"
|
|
68
|
-
for each_index in range(pre_existing_call_count)
|
|
69
|
-
]
|
|
70
|
-
all_lines = (
|
|
71
|
-
["async def step() -> None:"]
|
|
72
|
-
+ all_pre_existing_call_lines
|
|
73
|
-
+ [" await find_and_click('#edited')"]
|
|
74
|
-
)
|
|
75
|
-
source = "\n".join(all_lines) + "\n"
|
|
76
|
-
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
77
|
-
source,
|
|
78
|
-
"/project/src/clicker.py",
|
|
79
|
-
{edited_call_line_number},
|
|
80
|
-
False,
|
|
81
|
-
)
|
|
82
|
-
assert len(issues) == 1, (
|
|
83
|
-
f"the edited-line violation must survive a cap's worth of earlier calls; got: {issues!r}"
|
|
84
|
-
)
|
|
85
|
-
assert f"Line {edited_call_line_number}:" in issues[0], (
|
|
86
|
-
f"the single issue must name the edited line {edited_call_line_number}; got: {issues!r}"
|
|
87
|
-
)
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
"""Tests for the printf-token check on str.format-logger (automation_logging) calls."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import importlib.util
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from types import ModuleType
|
|
8
|
-
|
|
9
|
-
ENFORCER_FILENAME = "code_rules_enforcer.py"
|
|
10
|
-
ENFORCER_MODULE_NAME = "code_rules_enforcer_printf_under_test"
|
|
11
|
-
PRODUCTION_FILE_PATH = "shared_utils/web_automation/sample.py"
|
|
12
|
-
TEST_FILE_PATH = "shared_utils/web_automation/tests/test_sample.py"
|
|
13
|
-
FORMAT_LOGGER_IMPORT = (
|
|
14
|
-
"from shared_utils.automation_logging import log_error, log_info, log_debug\n"
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def load_enforcer_module() -> ModuleType:
|
|
19
|
-
enforcer_path = Path(__file__).parent / ENFORCER_FILENAME
|
|
20
|
-
module_spec = importlib.util.spec_from_file_location(ENFORCER_MODULE_NAME, enforcer_path)
|
|
21
|
-
assert module_spec is not None
|
|
22
|
-
assert module_spec.loader is not None
|
|
23
|
-
enforcer_module = importlib.util.module_from_spec(module_spec)
|
|
24
|
-
module_spec.loader.exec_module(enforcer_module)
|
|
25
|
-
return enforcer_module
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
enforcer = load_enforcer_module()
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def test_should_flag_percent_s_in_format_logger_call() -> None:
|
|
32
|
-
source = FORMAT_LOGGER_IMPORT + 'log_error("Skipping %s after error: %s", name, err)\n'
|
|
33
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
34
|
-
assert len(issues) == 1
|
|
35
|
-
assert "printf token" in issues[0]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def test_should_flag_percent_d_in_format_logger_call() -> None:
|
|
39
|
-
source = FORMAT_LOGGER_IMPORT + 'log_debug("attempt %d of %d", index, total)\n'
|
|
40
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
41
|
-
assert len(issues) == 1
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_should_allow_brace_placeholders_in_format_logger_call() -> None:
|
|
45
|
-
source = FORMAT_LOGGER_IMPORT + 'log_info("Processing {} of {}", index, total)\n'
|
|
46
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
47
|
-
assert issues == []
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_should_ignore_percent_token_without_format_logger_import() -> None:
|
|
51
|
-
source = 'log_error("Skipping %s", name)\n'
|
|
52
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
53
|
-
assert issues == []
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_should_ignore_attribute_style_logger_call() -> None:
|
|
57
|
-
source = FORMAT_LOGGER_IMPORT + 'logger.info("delivered %s", message_id)\n'
|
|
58
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
59
|
-
assert issues == []
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def test_should_inspect_message_literal_not_argument_value() -> None:
|
|
63
|
-
source = FORMAT_LOGGER_IMPORT + 'log_info("status: {}", "100%s done")\n'
|
|
64
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
65
|
-
assert issues == []
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def test_should_exempt_test_files() -> None:
|
|
69
|
-
source = FORMAT_LOGGER_IMPORT + 'log_error("Skipping %s", name)\n'
|
|
70
|
-
issues = enforcer.check_logging_printf_tokens(source, TEST_FILE_PATH)
|
|
71
|
-
assert issues == []
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def test_should_resolve_aliased_format_logger_import() -> None:
|
|
75
|
-
source = (
|
|
76
|
-
"from shared_utils.automation_logging import log_error as report_error\n"
|
|
77
|
-
+ 'report_error("failed %s", name)\n'
|
|
78
|
-
)
|
|
79
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
80
|
-
assert len(issues) == 1
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def test_should_allow_token_bearing_message_with_no_format_args() -> None:
|
|
84
|
-
source = FORMAT_LOGGER_IMPORT + 'log_info("Use %s for string substitution")\n'
|
|
85
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
86
|
-
assert issues == []
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def test_should_allow_documentation_style_token_message_with_no_args() -> None:
|
|
90
|
-
source = FORMAT_LOGGER_IMPORT + 'log_warning("avoid %s-style tokens here")\n'
|
|
91
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
92
|
-
assert issues == []
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def test_should_allow_percent_adjacent_to_word_in_format_message() -> None:
|
|
96
|
-
source = FORMAT_LOGGER_IMPORT + 'log_info("memory 80%free now", host)\n'
|
|
97
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
98
|
-
assert issues == []
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def test_should_allow_doubled_percent_in_format_message() -> None:
|
|
102
|
-
source = FORMAT_LOGGER_IMPORT + 'log_info("100%% done", host)\n'
|
|
103
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
104
|
-
assert issues == []
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def test_should_allow_trailing_percent_in_format_message() -> None:
|
|
108
|
-
source = FORMAT_LOGGER_IMPORT + 'log_info("100% done", host)\n'
|
|
109
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
110
|
-
assert issues == []
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def test_should_flag_width_token_in_format_logger_call() -> None:
|
|
114
|
-
source = FORMAT_LOGGER_IMPORT + 'log_error("item %5d", index)\n'
|
|
115
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
116
|
-
assert len(issues) == 1
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def test_should_flag_precision_token_in_format_logger_call() -> None:
|
|
120
|
-
source = FORMAT_LOGGER_IMPORT + 'log_error("took %0.2f sec", elapsed)\n'
|
|
121
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
122
|
-
assert len(issues) == 1
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def test_should_flag_float_general_token_in_format_logger_call() -> None:
|
|
126
|
-
source = FORMAT_LOGGER_IMPORT + 'log_error("ratio %g", ratio)\n'
|
|
127
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
128
|
-
assert len(issues) == 1
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def test_should_flag_scientific_token_in_format_logger_call() -> None:
|
|
132
|
-
source = FORMAT_LOGGER_IMPORT + 'log_error("value %e", measurement)\n'
|
|
133
|
-
issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
|
|
134
|
-
assert len(issues) == 1
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# Running Commands on the NAS
|
|
2
|
-
|
|
3
|
-
Reach the NAS through the `nas_ssh_key.py` runner, never through `ssh`, `scp` or `sftp`:
|
|
4
|
-
|
|
5
|
-
```
|
|
6
|
-
python <runner-path> <command-script.sh> <private-key-path>
|
|
7
|
-
```
|
|
8
|
-
|
|
9
|
-
The first argument is a **path to a file** holding a bash script, not a command string. Write that
|
|
10
|
-
file with the Write tool. The whole script runs in one shell, so variables, `cd` and `source` carry
|
|
11
|
-
from line to line.
|
|
12
|
-
|
|
13
|
-
The runner ships with the automation project it serves; read its path from that project's own
|
|
14
|
-
reference. Use the ops key under `~/.claude/keys` — keys under `~/.ssh` either carry a passphrase,
|
|
15
|
-
which cannot be answered unattended, or belong to other hosts.
|
|
16
|
-
|
|
17
|
-
The runner loads the key with paramiko and signs in the same process. The command-line clients check
|
|
18
|
-
the key file's permissions first and refuse it, and Git Bash's `ssh` then falls back to a password
|
|
19
|
-
prompt that hangs an unattended run. No hook denies an ssh-family word aimed at the NAS, so keep to
|
|
20
|
-
the runner yourself. When the `System32/OpenSSH` binary is unavoidable, pass `-o BatchMode=yes` so it
|
|
21
|
-
fails fast on a refused key.
|
|
22
|
-
|
|
23
|
-
Host, ssh port and ssh user are constants inside the runner and are also under the `nas` key in
|
|
24
|
-
`~/.claude/local-identity.json`. Keep all three out of anything committed or posted.
|
|
25
|
-
|
|
26
|
-
To copy a file, write it inside the script with a quoted heredoc rather than reaching for `scp`.
|
|
27
|
-
|
|
28
|
-
The `/tmp` size limit, how to tell a break in the code from a platform-only one, and a failure-to-cause
|
|
29
|
-
table: `@~/.claude/docs/nas-ssh-invocation.md`.
|