claude-dev-env 1.75.0 → 1.77.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/_shared/pr-loop/scripts/code_rules_gate.py +60 -5
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +2 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
- package/hooks/blocking/code_rules_docstrings.py +378 -0
- package/hooks/blocking/code_rules_duplicate_body.py +378 -26
- package/hooks/blocking/code_rules_enforcer.py +48 -5
- package/hooks/blocking/code_rules_imports_logging.py +679 -1
- package/hooks/blocking/code_rules_shared.py +8 -5
- package/hooks/blocking/code_rules_test_assertions.py +6 -7
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
- package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
- package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
- package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
- package/hooks/blocking/test_verification_verdict_store.py +66 -1
- package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
- package/hooks/blocking/verification_verdict_store.py +19 -5
- package/hooks/blocking/verifier_verdict_minter.py +18 -15
- package/hooks/hooks_constants/blocking_check_limits.py +56 -0
- package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +76 -1
- package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/claude-md-orphan-file.md +7 -8
- package/rules/docstring-prose-matches-implementation.md +5 -1
- package/rules/package-inventory-stale-entry.md +16 -0
- package/rules/plain-illustrative-docstrings.md +56 -0
- package/skills/anthropic-plan/CLAUDE.md +1 -1
- package/skills/anthropic-plan/SKILL.md +15 -2
- package/skills/autoconverge/workflow/converge.contract.test.mjs +12 -19
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +71 -0
- package/skills/autoconverge/workflow/converge.mjs +86 -110
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +42 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
"""Imports-at-top, logging f-string, win32gui None, E2E spec naming, file-length advisory, and library-print checks."""
|
|
1
|
+
"""Imports-at-top, import-block-sorted, logging f-string, win32gui None, E2E spec naming, JS resume-task enumeration coverage, file-length advisory, and library-print checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
|
+
import json
|
|
4
5
|
import re
|
|
6
|
+
import subprocess
|
|
5
7
|
import sys
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
|
|
@@ -16,6 +18,7 @@ from code_rules_path_utils import ( # noqa: E402
|
|
|
16
18
|
is_config_file,
|
|
17
19
|
)
|
|
18
20
|
from code_rules_shared import ( # noqa: E402
|
|
21
|
+
_scope_violations_to_changed_lines,
|
|
19
22
|
get_file_extension,
|
|
20
23
|
is_hook_infrastructure,
|
|
21
24
|
is_spec_file,
|
|
@@ -23,18 +26,48 @@ from code_rules_shared import ( # noqa: E402
|
|
|
23
26
|
)
|
|
24
27
|
|
|
25
28
|
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
29
|
+
ALL_FORMAT_LOGGER_FUNCTION_NAMES,
|
|
30
|
+
ALL_IMPORT_BLOCK_SORT_RUFF_COMMAND_PREFIX,
|
|
31
|
+
ALL_RUFF_STANDALONE_CONFIG_FILENAMES,
|
|
32
|
+
IMPORT_BLOCK_SORT_RUFF_TIMEOUT_SECONDS,
|
|
33
|
+
IMPORT_BLOCK_SORT_RULE_CODE,
|
|
26
34
|
MAX_E2E_TEST_NAMING_ISSUES,
|
|
35
|
+
MAX_IMPORT_BLOCK_SORT_ISSUES,
|
|
36
|
+
MAX_JS_RESUME_TASK_ENUMERATION_ISSUES,
|
|
27
37
|
MAX_LOGGING_FSTRING_ISSUES,
|
|
38
|
+
MAX_LOGGING_PRINTF_TOKEN_ISSUES,
|
|
28
39
|
MAX_WINDOWS_API_NONE_ISSUES,
|
|
40
|
+
MINIMUM_RESUME_TASK_ENUMERATION_ITEMS,
|
|
41
|
+
RUFF_PYPROJECT_CONFIG_FILENAME,
|
|
42
|
+
RUFF_PYPROJECT_TOOL_TABLE_MARKER,
|
|
43
|
+
RUFF_STDIN_ENCODING,
|
|
29
44
|
)
|
|
30
45
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
31
46
|
ADVISORY_LINE_THRESHOLD_HARD,
|
|
32
47
|
ADVISORY_LINE_THRESHOLD_SOFT,
|
|
33
48
|
ALL_CLI_FILE_PATH_MARKERS,
|
|
34
49
|
ALL_IMPORT_STATEMENT_PREFIXES,
|
|
50
|
+
ALL_JAVASCRIPT_EXTENSIONS,
|
|
51
|
+
ALL_JAVASCRIPT_REGEX_PRECEDING_CHARACTERS,
|
|
52
|
+
ALL_JAVASCRIPT_REGEX_PRECEDING_KEYWORDS,
|
|
53
|
+
ALL_JAVASCRIPT_STRING_DELIMITERS,
|
|
35
54
|
ALL_PYTHON_EXTENSIONS,
|
|
55
|
+
ENUMERATION_LEADING_CONJUNCTION_PATTERN,
|
|
56
|
+
ENUMERATION_LIST_ITEM_SEPARATOR_PATTERN,
|
|
57
|
+
ENUMERATION_TASK_ITEM_PATTERN,
|
|
58
|
+
HYPHENATED_TASK_ITEM_PATTERN,
|
|
59
|
+
JAVASCRIPT_BLOCK_COMMENT_CLOSER,
|
|
60
|
+
JAVASCRIPT_BLOCK_COMMENT_OPENER,
|
|
61
|
+
JAVASCRIPT_LINE_COMMENT_OPENER,
|
|
62
|
+
JAVASCRIPT_REGEX_DELIMITER,
|
|
63
|
+
JAVASCRIPT_STRING_ESCAPE_CHARACTER,
|
|
36
64
|
LOGGING_FSTRING_PATTERN,
|
|
65
|
+
LOGGING_PRINTF_TOKEN_PATTERN,
|
|
66
|
+
MINIMUM_FORMAT_LOGGER_ARGUMENT_COUNT,
|
|
37
67
|
NOT_INSIDE_TYPE_CHECKING_BLOCK,
|
|
68
|
+
RESUME_TASK_ENUMERATION_PATTERN,
|
|
69
|
+
SPAWN_AGENT_WITH_JSDOC_PATTERN,
|
|
70
|
+
TASK_DISPATCH_NAME_PATTERN,
|
|
38
71
|
TRIPLE_DOUBLE_QUOTE_DELIMITER,
|
|
39
72
|
TRIPLE_QUOTE_PARITY_DIVISOR,
|
|
40
73
|
TRIPLE_SINGLE_QUOTE_DELIMITER,
|
|
@@ -129,6 +162,232 @@ def check_imports_at_top(content: str) -> list[str]:
|
|
|
129
162
|
return issues
|
|
130
163
|
|
|
131
164
|
|
|
165
|
+
def check_import_block_sorted(
|
|
166
|
+
content: str,
|
|
167
|
+
file_path: str,
|
|
168
|
+
all_changed_lines: set[int] | None = None,
|
|
169
|
+
defer_scope_to_caller: bool = False,
|
|
170
|
+
) -> list[str]:
|
|
171
|
+
"""Flag a Python import block left un-sorted per isort rules (ruff I001).
|
|
172
|
+
|
|
173
|
+
ruff/isort treats a contiguous run of ``import`` and ``from`` statements as
|
|
174
|
+
one sortable unit. An edit that re-touches such a block pulls the whole block
|
|
175
|
+
into the gate's scope, so a pre-existing mis-ordering in an untouched line of
|
|
176
|
+
that block becomes a quality-gate failure (``check.ps1`` runs ruff over
|
|
177
|
+
``hooks/blocking``). This catches that drift at Write/Edit time by delegating
|
|
178
|
+
to ruff itself, the same sorter the quality gate uses, so the two surfaces
|
|
179
|
+
never disagree.
|
|
180
|
+
|
|
181
|
+
The check resolves *file_path* to an absolute path and runs ruff against
|
|
182
|
+
*content* on stdin under that path, so ruff walks parents to find the repo's
|
|
183
|
+
``[tool.ruff]`` config. Without a discoverable config ruff falls back to
|
|
184
|
+
default isort first-party grouping, which orders sibling imports differently
|
|
185
|
+
and produces false positives, so the check fails open when no ruff config is
|
|
186
|
+
found above the file. Non-Python files and test files are exempt. When ruff
|
|
187
|
+
is not installed, times out, or returns output the parser cannot read, the
|
|
188
|
+
check also fails open and reports nothing — a missing or slow tool never
|
|
189
|
+
blocks a write.
|
|
190
|
+
|
|
191
|
+
An I001 finding covers the whole sortable block, so each finding carries a
|
|
192
|
+
span from the block's anchor line (``location.row``) through its last line
|
|
193
|
+
(``end_location.row``) scoped through the shared
|
|
194
|
+
``_scope_violations_to_changed_lines`` helper. On a terminal diff-scoped Edit
|
|
195
|
+
a finding is returned when any line in that block span is among
|
|
196
|
+
``all_changed_lines``, so an edit touching any line of an unsorted block
|
|
197
|
+
blocks while an edit far from the block does not. On a new-file or full-file
|
|
198
|
+
write (``all_changed_lines`` None) every finding is in scope;
|
|
199
|
+
``defer_scope_to_caller`` returns every finding so the commit/push gate
|
|
200
|
+
scopes by added line through its own ``Line N:`` partitioning.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
content: The full file content the Write or Edit would leave on disk.
|
|
204
|
+
file_path: The destination path, used both to gate by extension and to
|
|
205
|
+
give ruff the filename it needs for config discovery.
|
|
206
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
207
|
+
None to treat the whole file as in scope. When provided, a finding
|
|
208
|
+
blocks only when its block-anchor line is among the changed lines.
|
|
209
|
+
defer_scope_to_caller: When True, return every finding so the commit/push
|
|
210
|
+
gate's ``Line N:`` partitioning scopes by added line.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
One issue string per detected I001 finding, capped at
|
|
214
|
+
``MAX_IMPORT_BLOCK_SORT_ISSUES``; an empty list when the block sorts
|
|
215
|
+
cleanly, every finding falls outside the changed lines, or the check
|
|
216
|
+
fails open.
|
|
217
|
+
"""
|
|
218
|
+
if get_file_extension(file_path) not in ALL_PYTHON_EXTENSIONS:
|
|
219
|
+
return []
|
|
220
|
+
if is_test_file(file_path):
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
absolute_file_path = Path(file_path).resolve()
|
|
224
|
+
if not _ruff_config_discoverable(absolute_file_path):
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
ruff_findings = _run_ruff_import_sort_check(content, str(absolute_file_path))
|
|
228
|
+
if ruff_findings is None:
|
|
229
|
+
return []
|
|
230
|
+
|
|
231
|
+
span_end_line_offset = 1
|
|
232
|
+
all_violations_in_finding_order: list[tuple[range, str]] = []
|
|
233
|
+
for each_finding in ruff_findings:
|
|
234
|
+
if each_finding.get("code") != IMPORT_BLOCK_SORT_RULE_CODE:
|
|
235
|
+
continue
|
|
236
|
+
line_number = _finding_line_number(each_finding.get("location"))
|
|
237
|
+
block_end_line_number = _finding_block_end_line_number(
|
|
238
|
+
each_finding.get("end_location"), line_number
|
|
239
|
+
)
|
|
240
|
+
span_range = range(line_number, block_end_line_number + span_end_line_offset)
|
|
241
|
+
message = (
|
|
242
|
+
f"Line {line_number}: Import block is un-sorted (ruff I001) - "
|
|
243
|
+
f"run 'ruff check --fix' to sort the block before writing"
|
|
244
|
+
)
|
|
245
|
+
all_violations_in_finding_order.append((span_range, message))
|
|
246
|
+
if len(all_violations_in_finding_order) >= MAX_IMPORT_BLOCK_SORT_ISSUES:
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
return _scope_violations_to_changed_lines(
|
|
250
|
+
all_violations_in_finding_order,
|
|
251
|
+
all_changed_lines,
|
|
252
|
+
defer_scope_to_caller,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _run_ruff_import_sort_check(content: str, file_path: str) -> list[dict[str, object]] | None:
|
|
257
|
+
"""Run ruff's I001 check over *content* and return its parsed JSON findings.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
content: The file content fed to ruff on stdin.
|
|
261
|
+
file_path: The filename ruff is told the stdin content belongs to, so it
|
|
262
|
+
discovers the surrounding ``[tool.ruff]`` config.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The list of ruff finding dictionaries, or ``None`` when ruff is absent,
|
|
266
|
+
times out, or emits output that is not the expected JSON list.
|
|
267
|
+
"""
|
|
268
|
+
ruff_command = [
|
|
269
|
+
*ALL_IMPORT_BLOCK_SORT_RUFF_COMMAND_PREFIX,
|
|
270
|
+
"--stdin-filename",
|
|
271
|
+
file_path,
|
|
272
|
+
"-",
|
|
273
|
+
]
|
|
274
|
+
try:
|
|
275
|
+
completed_process = subprocess.run(
|
|
276
|
+
ruff_command,
|
|
277
|
+
input=content.encode(RUFF_STDIN_ENCODING),
|
|
278
|
+
capture_output=True,
|
|
279
|
+
timeout=IMPORT_BLOCK_SORT_RUFF_TIMEOUT_SECONDS,
|
|
280
|
+
check=False,
|
|
281
|
+
)
|
|
282
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
ruff_stdout = completed_process.stdout.decode(RUFF_STDIN_ENCODING)
|
|
287
|
+
except UnicodeDecodeError:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
parsed_findings = json.loads(ruff_stdout)
|
|
292
|
+
except json.JSONDecodeError:
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
if not isinstance(parsed_findings, list):
|
|
296
|
+
return None
|
|
297
|
+
return [each_finding for each_finding in parsed_findings if isinstance(each_finding, dict)]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _ruff_config_discoverable(absolute_file_path: Path) -> bool:
|
|
301
|
+
"""Return whether a ruff config sits above *absolute_file_path*.
|
|
302
|
+
|
|
303
|
+
Walks the file's parent directories looking for a standalone ``ruff.toml`` /
|
|
304
|
+
``.ruff.toml`` or a ``pyproject.toml`` that declares a ``[tool.ruff`` table.
|
|
305
|
+
ruff uses this same nearest-ancestor walk to resolve its settings; mirroring
|
|
306
|
+
it lets the check confirm ruff will see the project's isort configuration
|
|
307
|
+
rather than its built-in defaults before trusting an I001 finding.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
absolute_file_path: The resolved path of the file under validation.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
True when a ruff config is found in the file's directory or any ancestor;
|
|
314
|
+
False when the walk reaches the filesystem root without one.
|
|
315
|
+
"""
|
|
316
|
+
for each_directory in absolute_file_path.parents:
|
|
317
|
+
for each_standalone_name in ALL_RUFF_STANDALONE_CONFIG_FILENAMES:
|
|
318
|
+
if (each_directory / each_standalone_name).is_file():
|
|
319
|
+
return True
|
|
320
|
+
pyproject_path = each_directory / RUFF_PYPROJECT_CONFIG_FILENAME
|
|
321
|
+
if _pyproject_declares_ruff_table(pyproject_path):
|
|
322
|
+
return True
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _pyproject_declares_ruff_table(pyproject_path: Path) -> bool:
|
|
327
|
+
"""Return whether *pyproject_path* exists and declares a ``[tool.ruff`` table.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
pyproject_path: The candidate ``pyproject.toml`` path to inspect.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
True when the file exists and its text contains the ``[tool.ruff`` table
|
|
334
|
+
marker; False when the file is absent or unreadable.
|
|
335
|
+
"""
|
|
336
|
+
if not pyproject_path.is_file():
|
|
337
|
+
return False
|
|
338
|
+
try:
|
|
339
|
+
pyproject_text = pyproject_path.read_text(encoding=RUFF_STDIN_ENCODING)
|
|
340
|
+
except OSError:
|
|
341
|
+
return False
|
|
342
|
+
return RUFF_PYPROJECT_TOOL_TABLE_MARKER in pyproject_text
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _finding_line_number(location: object) -> int:
|
|
346
|
+
"""Return the source line a ruff finding's location anchors to.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
location: The ``location`` value from one ruff JSON finding, expected to
|
|
350
|
+
be a mapping carrying an integer ``row``; any other shape yields a
|
|
351
|
+
zero line number.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
The ``row`` value as an integer, or a zero line number when the location
|
|
355
|
+
is absent or carries no readable row.
|
|
356
|
+
"""
|
|
357
|
+
if not isinstance(location, dict):
|
|
358
|
+
return 0
|
|
359
|
+
row = location.get("row")
|
|
360
|
+
if not isinstance(row, int):
|
|
361
|
+
return 0
|
|
362
|
+
return row
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _finding_block_end_line_number(end_location: object, anchor_line_number: int) -> int:
|
|
366
|
+
"""Return the last source line a ruff finding's block spans.
|
|
367
|
+
|
|
368
|
+
ruff anchors an I001 finding at its block's first line and records the
|
|
369
|
+
block's last line in ``end_location.row``, so the two together bound the
|
|
370
|
+
whole sortable run. The end row falls back to the anchor line when the
|
|
371
|
+
``end_location`` is absent or carries no readable row, or when it would land
|
|
372
|
+
above the anchor — keeping the span to at least the anchor line itself.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
end_location: The ``end_location`` value from one ruff JSON finding,
|
|
376
|
+
expected to be a mapping carrying an integer ``row``.
|
|
377
|
+
anchor_line_number: The finding's block-anchor line, used as the floor
|
|
378
|
+
for the returned end line.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
The block's last line as an integer, never below *anchor_line_number*.
|
|
382
|
+
"""
|
|
383
|
+
if not isinstance(end_location, dict):
|
|
384
|
+
return anchor_line_number
|
|
385
|
+
row = end_location.get("row")
|
|
386
|
+
if not isinstance(row, int) or row < anchor_line_number:
|
|
387
|
+
return anchor_line_number
|
|
388
|
+
return row
|
|
389
|
+
|
|
390
|
+
|
|
132
391
|
def _update_triple_quote_state_for_line(
|
|
133
392
|
line_text: str, current_delimiter: str | None
|
|
134
393
|
) -> str | None:
|
|
@@ -183,6 +442,121 @@ def check_logging_fstrings(content: str) -> list[str]:
|
|
|
183
442
|
return issues
|
|
184
443
|
|
|
185
444
|
|
|
445
|
+
def _format_logger_names_imported(tree: ast.Module) -> set[str]:
|
|
446
|
+
"""Return the local names bound to automation_logging log helpers.
|
|
447
|
+
|
|
448
|
+
Scans every ``from ... import ...`` statement for an import whose module
|
|
449
|
+
path contains ``automation_logging`` and collects the local binding of each
|
|
450
|
+
imported ``log_*`` helper (the alias when ``as`` is present, otherwise the
|
|
451
|
+
imported name). Only these names identify a str.format-logger call; a
|
|
452
|
+
``log_*`` helper from any other module is not collected, because a
|
|
453
|
+
``%``-style logger formats its tokens correctly.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
tree: The parsed module to scan for logger imports.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
The set of local names bound to automation_logging log helpers.
|
|
460
|
+
"""
|
|
461
|
+
bound_names: set[str] = set()
|
|
462
|
+
for each_node in ast.walk(tree):
|
|
463
|
+
if not isinstance(each_node, ast.ImportFrom):
|
|
464
|
+
continue
|
|
465
|
+
if each_node.module is None or "automation_logging" not in each_node.module:
|
|
466
|
+
continue
|
|
467
|
+
for each_alias in each_node.names:
|
|
468
|
+
if each_alias.name in ALL_FORMAT_LOGGER_FUNCTION_NAMES:
|
|
469
|
+
bound_names.add(each_alias.asname or each_alias.name)
|
|
470
|
+
return bound_names
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _printf_token_log_call_line(
|
|
474
|
+
node: ast.AST, all_format_logger_names: set[str]
|
|
475
|
+
) -> int | None:
|
|
476
|
+
"""Return the line of a format-logger call carrying a printf token, else None.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
node: The AST node to inspect.
|
|
480
|
+
all_format_logger_names: Local names bound to automation_logging log
|
|
481
|
+
helpers.
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
The 1-based line number when ``node`` is a bare-name call to a format
|
|
485
|
+
logger that has at least one format argument after the message and
|
|
486
|
+
whose first string-literal argument carries a printf token; otherwise
|
|
487
|
+
None. A call with only the message and no format arguments never runs
|
|
488
|
+
``.format(*args)``, so its token prints intact and is not flagged.
|
|
489
|
+
"""
|
|
490
|
+
if not isinstance(node, ast.Call):
|
|
491
|
+
return None
|
|
492
|
+
function_reference = node.func
|
|
493
|
+
if (
|
|
494
|
+
not isinstance(function_reference, ast.Name)
|
|
495
|
+
or function_reference.id not in all_format_logger_names
|
|
496
|
+
):
|
|
497
|
+
return None
|
|
498
|
+
if len(node.args) < MINIMUM_FORMAT_LOGGER_ARGUMENT_COUNT:
|
|
499
|
+
return None
|
|
500
|
+
message_argument = node.args[0]
|
|
501
|
+
if not isinstance(message_argument, ast.Constant) or not isinstance(
|
|
502
|
+
message_argument.value, str
|
|
503
|
+
):
|
|
504
|
+
return None
|
|
505
|
+
if not LOGGING_PRINTF_TOKEN_PATTERN.search(message_argument.value):
|
|
506
|
+
return None
|
|
507
|
+
return node.lineno
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def check_logging_printf_tokens(content: str, file_path: str) -> list[str]:
|
|
511
|
+
"""Flag printf tokens in a str.format-logger (automation_logging) message.
|
|
512
|
+
|
|
513
|
+
The ``shared_utils.automation_logging`` helpers (``log_info``, ``log_error``,
|
|
514
|
+
...) format with ``str.format`` (``{}`` placeholders) only when format
|
|
515
|
+
arguments follow the message (``message.format(*args) if args else
|
|
516
|
+
message``), so a printf-style token such as ``%s`` in the message literal is
|
|
517
|
+
never substituted: the format arguments are dropped and the literal token
|
|
518
|
+
prints. The check fires only in a file that imports one of those helpers
|
|
519
|
+
from an ``automation_logging`` module, and only for a bare-name call to such
|
|
520
|
+
a helper that passes at least one format argument after the message and
|
|
521
|
+
whose first argument is a string literal carrying a token. A call with only
|
|
522
|
+
the message and no format arguments never runs ``.format``, so its token
|
|
523
|
+
prints intact and is left alone. An attribute call (``logger.info``) or a
|
|
524
|
+
``log_*`` helper from any other module formats ``%``-tokens correctly and is
|
|
525
|
+
left alone. Test files are exempt so a test may exercise the malformed shape.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
content: The Python source under validation.
|
|
529
|
+
file_path: The destination path, used to skip test files and non-Python.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
One issue line per offending call, capped at the configured maximum.
|
|
533
|
+
"""
|
|
534
|
+
if is_test_file(file_path):
|
|
535
|
+
return []
|
|
536
|
+
if get_file_extension(file_path) not in ALL_PYTHON_EXTENSIONS:
|
|
537
|
+
return []
|
|
538
|
+
try:
|
|
539
|
+
tree = ast.parse(content)
|
|
540
|
+
except SyntaxError:
|
|
541
|
+
return []
|
|
542
|
+
all_format_logger_names = _format_logger_names_imported(tree)
|
|
543
|
+
if not all_format_logger_names:
|
|
544
|
+
return []
|
|
545
|
+
issues: list[str] = []
|
|
546
|
+
for each_node in ast.walk(tree):
|
|
547
|
+
offending_line = _printf_token_log_call_line(each_node, all_format_logger_names)
|
|
548
|
+
if offending_line is None:
|
|
549
|
+
continue
|
|
550
|
+
issues.append(
|
|
551
|
+
f"Line {offending_line}: printf token in a str.format logger "
|
|
552
|
+
"message - the automation_logging helpers format with str.format; "
|
|
553
|
+
"use {} placeholders (the %-arguments are silently dropped)"
|
|
554
|
+
)
|
|
555
|
+
if len(issues) >= MAX_LOGGING_PRINTF_TOKEN_ISSUES:
|
|
556
|
+
break
|
|
557
|
+
return issues
|
|
558
|
+
|
|
559
|
+
|
|
186
560
|
def advise_file_line_count(content: str, file_path: str) -> None:
|
|
187
561
|
"""Emit non-blocking stderr advisories when a file crosses size smell thresholds.
|
|
188
562
|
|
|
@@ -241,6 +615,310 @@ def check_e2e_test_naming(content: str, file_path: str) -> list[str]:
|
|
|
241
615
|
return issues
|
|
242
616
|
|
|
243
617
|
|
|
618
|
+
class _JavaScriptRegionScanner:
|
|
619
|
+
"""Walks JavaScript source one character at a time, tracking string,
|
|
620
|
+
comment, and regex regions so a caller can tell code from non-code.
|
|
621
|
+
|
|
622
|
+
The scanner holds the region the current character sits in. ``consume``
|
|
623
|
+
advances the state for one character and returns whether that character is
|
|
624
|
+
structural code (``True``) or part of a string, comment, or regex literal
|
|
625
|
+
(``False``).
|
|
626
|
+
"""
|
|
627
|
+
|
|
628
|
+
def __init__(self) -> None:
|
|
629
|
+
self._active_string_delimiter: str | None = None
|
|
630
|
+
self._is_inside_line_comment = False
|
|
631
|
+
self._is_inside_block_comment = False
|
|
632
|
+
self._is_inside_regex_literal = False
|
|
633
|
+
self._previous_code_character = "\n"
|
|
634
|
+
self._previous_code_word = ""
|
|
635
|
+
self._is_building_code_word = False
|
|
636
|
+
|
|
637
|
+
def consume(self, character: str, source: str, index: int) -> bool:
|
|
638
|
+
if self._active_string_delimiter is not None:
|
|
639
|
+
return self._consume_inside_string(character, source, index)
|
|
640
|
+
if self._is_inside_line_comment:
|
|
641
|
+
return self._consume_inside_line_comment(character)
|
|
642
|
+
if self._is_inside_block_comment:
|
|
643
|
+
return self._consume_inside_block_comment(source, index)
|
|
644
|
+
if self._is_inside_regex_literal:
|
|
645
|
+
return self._consume_inside_regex(character, source, index)
|
|
646
|
+
return self._consume_inside_code(character, source, index)
|
|
647
|
+
|
|
648
|
+
def _consume_inside_string(self, character: str, source: str, index: int) -> bool:
|
|
649
|
+
if character == self._active_string_delimiter and not _is_escaped(source, index):
|
|
650
|
+
self._active_string_delimiter = None
|
|
651
|
+
return False
|
|
652
|
+
|
|
653
|
+
def _consume_inside_line_comment(self, character: str) -> bool:
|
|
654
|
+
if character == "\n":
|
|
655
|
+
self._is_inside_line_comment = False
|
|
656
|
+
return False
|
|
657
|
+
|
|
658
|
+
def _consume_inside_block_comment(self, source: str, index: int) -> bool:
|
|
659
|
+
if source[index - 1 : index + 1] == JAVASCRIPT_BLOCK_COMMENT_CLOSER:
|
|
660
|
+
self._is_inside_block_comment = False
|
|
661
|
+
return False
|
|
662
|
+
|
|
663
|
+
def _consume_inside_regex(self, character: str, source: str, index: int) -> bool:
|
|
664
|
+
if character == JAVASCRIPT_REGEX_DELIMITER and not _is_escaped(source, index):
|
|
665
|
+
self._is_inside_regex_literal = False
|
|
666
|
+
return False
|
|
667
|
+
|
|
668
|
+
def _consume_inside_code(self, character: str, source: str, index: int) -> bool:
|
|
669
|
+
if character in ALL_JAVASCRIPT_STRING_DELIMITERS:
|
|
670
|
+
self._active_string_delimiter = character
|
|
671
|
+
return False
|
|
672
|
+
if source.startswith(JAVASCRIPT_LINE_COMMENT_OPENER, index):
|
|
673
|
+
self._is_inside_line_comment = True
|
|
674
|
+
return False
|
|
675
|
+
if source.startswith(JAVASCRIPT_BLOCK_COMMENT_OPENER, index):
|
|
676
|
+
self._is_inside_block_comment = True
|
|
677
|
+
return False
|
|
678
|
+
if character == JAVASCRIPT_REGEX_DELIMITER and self._is_regex_start():
|
|
679
|
+
self._is_inside_regex_literal = True
|
|
680
|
+
return False
|
|
681
|
+
self._track_preceding_word(character)
|
|
682
|
+
if not character.isspace():
|
|
683
|
+
self._previous_code_character = character
|
|
684
|
+
return True
|
|
685
|
+
|
|
686
|
+
def _track_preceding_word(self, character: str) -> None:
|
|
687
|
+
if character.isalnum() or character == "_":
|
|
688
|
+
if not self._is_building_code_word:
|
|
689
|
+
self._previous_code_word = ""
|
|
690
|
+
self._is_building_code_word = True
|
|
691
|
+
self._previous_code_word += character
|
|
692
|
+
return
|
|
693
|
+
if not character.isspace():
|
|
694
|
+
self._previous_code_word = ""
|
|
695
|
+
self._is_building_code_word = False
|
|
696
|
+
|
|
697
|
+
def _is_regex_start(self) -> bool:
|
|
698
|
+
if self._previous_code_character in ALL_JAVASCRIPT_REGEX_PRECEDING_CHARACTERS:
|
|
699
|
+
return True
|
|
700
|
+
return self._previous_code_word in ALL_JAVASCRIPT_REGEX_PRECEDING_KEYWORDS
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _is_escaped(source: str, index: int) -> bool:
|
|
704
|
+
backslash_run_length = 0
|
|
705
|
+
scan_index = index - 1
|
|
706
|
+
while scan_index >= 0 and source[scan_index] == JAVASCRIPT_STRING_ESCAPE_CHARACTER:
|
|
707
|
+
backslash_run_length += 1
|
|
708
|
+
scan_index -= 1
|
|
709
|
+
return backslash_run_length % 2 == 1
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _code_position_flags(source: str) -> list[bool]:
|
|
713
|
+
"""Return one flag per character: ``True`` for structural code, ``False`` for
|
|
714
|
+
a character inside a string literal, comment, or regex literal.
|
|
715
|
+
|
|
716
|
+
A backslash escapes the next character inside a string literal, so a delimiter
|
|
717
|
+
preceded by an odd run of backslashes stays inside the literal and a delimiter
|
|
718
|
+
preceded by an even run (including zero) closes it. A ``/`` opens a regex
|
|
719
|
+
literal only when it sits at an expression position: either the previous
|
|
720
|
+
structural character is a punctuation/operator that precedes an expression, or
|
|
721
|
+
the preceding identifier token is an expression-introducing keyword (``return``,
|
|
722
|
+
``typeof``, ``case``, ``in``, ``of``, ``do``, ``else``, ``void``, ``delete``,
|
|
723
|
+
``instanceof``, ``new``, ``yield``, ``await``, ``throw``). After a value it is
|
|
724
|
+
the division operator and is left intact.
|
|
725
|
+
"""
|
|
726
|
+
region = _JavaScriptRegionScanner()
|
|
727
|
+
return [
|
|
728
|
+
region.consume(each_character, source, each_index)
|
|
729
|
+
for each_index, each_character in enumerate(source)
|
|
730
|
+
]
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _blank_non_code_regions(body_text: str) -> str:
|
|
734
|
+
"""Replace every non-code region in JavaScript source with spaces.
|
|
735
|
+
|
|
736
|
+
A non-code region is a string literal (single-, double-, or backtick-quoted),
|
|
737
|
+
a ``//`` line comment, a ``/* */`` block comment, or a ``/.../`` regex
|
|
738
|
+
literal. Each is blanked to spaces, newlines preserved, so the returned text
|
|
739
|
+
has the same length and line structure as the input while carrying only
|
|
740
|
+
structural code characters.
|
|
741
|
+
|
|
742
|
+
Blanking comments and regex literals keeps a stray brace inside either from
|
|
743
|
+
skewing the brace-depth scan that bounds the resume body, and blanking string
|
|
744
|
+
literals keeps a brace inside a prompt string from skewing it the same way.
|
|
745
|
+
"""
|
|
746
|
+
code_flags = _code_position_flags(body_text)
|
|
747
|
+
rendered_characters = [
|
|
748
|
+
each_character if (code_flags[each_index] or each_character == "\n") else " "
|
|
749
|
+
for each_index, each_character in enumerate(body_text)
|
|
750
|
+
]
|
|
751
|
+
return "".join(rendered_characters)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _dispatched_task_names(resume_body: str) -> set[str]:
|
|
755
|
+
"""Return the task names the resume body dispatches on in code position.
|
|
756
|
+
|
|
757
|
+
A real dispatch branch (``task === 'fix-verify'``) has its ``task`` keyword in
|
|
758
|
+
structural code; a ``task === '<name>'`` mention inside an agent-prompt string,
|
|
759
|
+
a comment, or a regex literal is prose and its keyword sits in a non-code
|
|
760
|
+
region. Only a match whose ``task`` keyword is at a code position counts, so a
|
|
761
|
+
prose mention is never read as a dispatch branch.
|
|
762
|
+
"""
|
|
763
|
+
code_flags = _code_position_flags(resume_body)
|
|
764
|
+
dispatched_tasks: set[str] = set()
|
|
765
|
+
for each_match in TASK_DISPATCH_NAME_PATTERN.finditer(resume_body):
|
|
766
|
+
if code_flags[each_match.start()]:
|
|
767
|
+
dispatched_tasks.add(each_match.group("task"))
|
|
768
|
+
return dispatched_tasks
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _balanced_brace_body(content: str, from_index: int) -> str | None:
|
|
772
|
+
"""Return the brace-balanced span of ``content`` starting at the first ``{``.
|
|
773
|
+
|
|
774
|
+
Braces inside string literals, comments, and regex literals do not count
|
|
775
|
+
toward the depth: the scan walks a blanked copy where those regions are
|
|
776
|
+
spaces, so only structural braces move the depth. The returned span is a slice
|
|
777
|
+
of the original ``content`` so the caller reads real source for the dispatch
|
|
778
|
+
scan.
|
|
779
|
+
"""
|
|
780
|
+
blanked_content = _blank_non_code_regions(content)
|
|
781
|
+
opening_index = blanked_content.find("{", from_index)
|
|
782
|
+
if opening_index == -1:
|
|
783
|
+
return None
|
|
784
|
+
depth = 0
|
|
785
|
+
for each_position in range(opening_index, len(blanked_content)):
|
|
786
|
+
character = blanked_content[each_position]
|
|
787
|
+
if character == "{":
|
|
788
|
+
depth += 1
|
|
789
|
+
elif character == "}":
|
|
790
|
+
depth -= 1
|
|
791
|
+
if depth == 0:
|
|
792
|
+
return content[opening_index : each_position + 1]
|
|
793
|
+
return content[opening_index:]
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _resume_function_body(content: str, role: str) -> str | None:
|
|
797
|
+
header_pattern = re.compile(
|
|
798
|
+
r"(?:async\s+)?function\s+resume" + re.escape(role) + r"Agent\s*\("
|
|
799
|
+
)
|
|
800
|
+
header_match = header_pattern.search(_blank_non_code_regions(content))
|
|
801
|
+
if header_match is None:
|
|
802
|
+
return None
|
|
803
|
+
bounded_content = content[: _next_top_level_function_index(content, header_match.end())]
|
|
804
|
+
return _balanced_brace_body(bounded_content, header_match.end())
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _next_top_level_function_index(content: str, from_index: int) -> int:
|
|
808
|
+
blanked_content = _blank_non_code_regions(content)
|
|
809
|
+
next_function_match = re.compile(
|
|
810
|
+
r"^(?:async\s+)?function\s+\w+", re.MULTILINE
|
|
811
|
+
).search(blanked_content, from_index)
|
|
812
|
+
if next_function_match is None:
|
|
813
|
+
return len(content)
|
|
814
|
+
return next_function_match.start()
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _task_list_from_enumeration_text(
|
|
818
|
+
enumeration_text: str, all_dispatched_tasks: frozenset[str]
|
|
819
|
+
) -> set[str] | None:
|
|
820
|
+
enumerated_items = ENUMERATION_LIST_ITEM_SEPARATOR_PATTERN.split(enumeration_text)
|
|
821
|
+
task_items = {_strip_leading_conjunction(each_item) for each_item in enumerated_items}
|
|
822
|
+
if not all(ENUMERATION_TASK_ITEM_PATTERN.match(each_item) for each_item in task_items):
|
|
823
|
+
return None
|
|
824
|
+
if not any(HYPHENATED_TASK_ITEM_PATTERN.match(each_item) for each_item in task_items):
|
|
825
|
+
return None
|
|
826
|
+
if len(task_items) < MINIMUM_RESUME_TASK_ENUMERATION_ITEMS and not (task_items & all_dispatched_tasks):
|
|
827
|
+
return None
|
|
828
|
+
return task_items
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _strip_leading_conjunction(enumeration_item: str) -> str:
|
|
832
|
+
stripped_item = enumeration_item.strip()
|
|
833
|
+
return ENUMERATION_LEADING_CONJUNCTION_PATTERN.sub("", stripped_item).strip()
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _enumeration_task_names(
|
|
837
|
+
jsdoc_text: str, all_dispatched_tasks: frozenset[str]
|
|
838
|
+
) -> set[str] | None:
|
|
839
|
+
for each_enumeration_match in RESUME_TASK_ENUMERATION_PATTERN.finditer(jsdoc_text):
|
|
840
|
+
enumeration_text = each_enumeration_match.group("enumeration").replace("*", " ")
|
|
841
|
+
task_items = _task_list_from_enumeration_text(enumeration_text, all_dispatched_tasks)
|
|
842
|
+
if task_items is not None:
|
|
843
|
+
return task_items
|
|
844
|
+
return None
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def check_js_resume_task_enumeration_coverage(
|
|
848
|
+
content: str, file_path: str
|
|
849
|
+
) -> list[str]:
|
|
850
|
+
"""Flag a spawn JSDoc whose resume enumeration omits a dispatched task.
|
|
851
|
+
|
|
852
|
+
The drift this catches: a ``spawn<Role>Agent`` function carries a JSDoc that
|
|
853
|
+
enumerates the resume tasks of its sibling ``resume<Role>Agent`` in a
|
|
854
|
+
parenthetical ``resume (repair-verify, hardening-verify)`` list, while the
|
|
855
|
+
``resume<Role>Agent`` body dispatches on a ``task === '<name>'`` branch the
|
|
856
|
+
enumeration never names. A reader who trusts the spawn JSDoc to list every
|
|
857
|
+
step that runs on the session misses the omitted task. This is the JS/.mjs
|
|
858
|
+
slice of Category O6 docstring-prose-vs-implementation drift; the Python
|
|
859
|
+
enforcer's AST docstring checks never inspect JavaScript source.
|
|
860
|
+
|
|
861
|
+
A spawn JSDoc binds to its sibling resume function by the shared ``<Role>``
|
|
862
|
+
token (``spawnVerifierAgent`` pairs with ``resumeVerifierAgent``). The check
|
|
863
|
+
reads the sibling resume body's dispatched task set first, then scans each
|
|
864
|
+
standalone-word ``resume (...)`` parenthetical in the JSDoc (a longer word
|
|
865
|
+
ending in ``resume``, such as ``presume (...)``, does not introduce an
|
|
866
|
+
enumeration) and binds to the first that reads as a task list: every comma- or
|
|
867
|
+
``and``-delimited item (including the Oxford-comma ``a, b, and c`` form, where
|
|
868
|
+
the trailing ``and`` is stripped from the final item) is a single
|
|
869
|
+
dispatch-shaped task token (no embedded spaces, so a descriptive phrase such as
|
|
870
|
+
``re-establishing the session`` is not a task list, and a later real task list
|
|
871
|
+
still binds when an earlier descriptive parenthetical precedes it) and at least
|
|
872
|
+
one item is hyphenated, proving the prose is a resume-task enumeration
|
|
873
|
+
describing the sibling rather than incidental prose. A multi-item parenthetical
|
|
874
|
+
that clears those gates is a task list. A single-item parenthetical clears them
|
|
875
|
+
only when its lone token is itself one of the sibling's dispatched tasks, so a
|
|
876
|
+
descriptive single hyphenated word such as ``re-entry`` or ``fast-forward`` —
|
|
877
|
+
which names no dispatched task — stays descriptive prose rather than a
|
|
878
|
+
one-item task list. This keeps the enumerated token set a superset of the
|
|
879
|
+
sibling's dispatched task set. The resume body is read up to the next
|
|
880
|
+
top-level ``function`` declaration, and only a ``task === '<name>'`` whose
|
|
881
|
+
``task`` keyword sits in structural code counts as a dispatch branch: a
|
|
882
|
+
``task === '<name>'`` mention inside a string literal (any quote flavor), a
|
|
883
|
+
comment, or a regex literal is prose and is skipped. Each
|
|
884
|
+
``task === '<name>'`` branch name the resume body dispatches on that the
|
|
885
|
+
enumeration omits is flagged.
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
content: The source text to inspect.
|
|
889
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
890
|
+
|
|
891
|
+
Returns:
|
|
892
|
+
One issue per dispatched task name the spawn enumeration omits, capped at
|
|
893
|
+
the module limit.
|
|
894
|
+
"""
|
|
895
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
896
|
+
return []
|
|
897
|
+
if get_file_extension(file_path) not in ALL_JAVASCRIPT_EXTENSIONS:
|
|
898
|
+
return []
|
|
899
|
+
issues: list[str] = []
|
|
900
|
+
for each_spawn_match in SPAWN_AGENT_WITH_JSDOC_PATTERN.finditer(content):
|
|
901
|
+
role = each_spawn_match.group("role")
|
|
902
|
+
resume_body = _resume_function_body(content, role)
|
|
903
|
+
if resume_body is None:
|
|
904
|
+
continue
|
|
905
|
+
dispatched_tasks = _dispatched_task_names(resume_body)
|
|
906
|
+
enumerated_tasks = _enumeration_task_names(
|
|
907
|
+
each_spawn_match.group("jsdoc"), frozenset(dispatched_tasks)
|
|
908
|
+
)
|
|
909
|
+
if not enumerated_tasks:
|
|
910
|
+
continue
|
|
911
|
+
for each_task in sorted(dispatched_tasks - enumerated_tasks):
|
|
912
|
+
issues.append(
|
|
913
|
+
f"spawn{role}Agent() JSDoc resume enumeration omits the "
|
|
914
|
+
f"'{each_task}' task that resume{role}Agent dispatches on — add it "
|
|
915
|
+
"to the parenthetical (Category O6 docstring-vs-implementation drift)"
|
|
916
|
+
)
|
|
917
|
+
if len(issues) >= MAX_JS_RESUME_TASK_ENUMERATION_ISSUES:
|
|
918
|
+
return issues[:MAX_JS_RESUME_TASK_ENUMERATION_ISSUES]
|
|
919
|
+
return issues[:MAX_JS_RESUME_TASK_ENUMERATION_ISSUES]
|
|
920
|
+
|
|
921
|
+
|
|
244
922
|
def _is_cli_entry_point(file_path: str) -> bool:
|
|
245
923
|
path_lower = file_path.lower().replace("\\", "/")
|
|
246
924
|
return any(marker.replace("\\", "/") in path_lower for marker in ALL_CLI_FILE_PATH_MARKERS)
|