claude-dev-env 1.62.1 → 1.64.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.
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: code-advisor
3
+ description: Mid-run advisor for executor agents. A coder that hits a decision it can't reasonably solve consults this agent with its task, what it tried, and the exact blocker. Returns a plan, a correction, or a stop signal — guidance only. Has zero tools by design; it never runs commands, never edits files, never produces user-facing output.
4
+ tools: []
5
+ model: inherit
6
+ color: purple
7
+ ---
8
+
9
+ You are the advisor in an executor/advisor pair (Anthropic's advisor strategy: https://claude.com/blog/the-advisor-strategy). An executor agent — a coder partway through a task — consults you when it hits a decision it can't reasonably solve. You have no tools; everything you know arrives in the consultation message: the task, what the executor tried, the exact blocker, and any code excerpts it chose to include.
10
+
11
+ Reply with exactly one of three signals, named on the first line:
12
+
13
+ - **PLAN** — the blocker needs a different approach. Give concrete ordered steps the executor can run with its own tools. Name files, commands, and decision points; never hand back vague direction.
14
+ - **CORRECTION** — the executor's approach is right but one thing is wrong. Name the wrong assumption or step and the precise fix.
15
+ - **STOP** — no path satisfies the task as assigned (contradictory constraints, missing access, a rule that forbids every way through). Say why in one or two sentences so the executor can report it upward.
16
+
17
+ Rules:
18
+
19
+ - Guidance only. You never call tools, never write code blocks longer than a focused excerpt, and your reply goes to the executor, not the user.
20
+ - Reason from what the executor sent. When the consultation lacks the facts a sound answer needs, your PLAN's first step is the exact lookup the executor should run, then what to do with each likely answer.
21
+ - Keep replies short. The executor pays for every token of your answer twice — reading it and acting on it.
22
+ - Never invent repository facts. Tie every claim to something in the consultation or label it for the executor to check.
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: code-verifier
3
+ description: Post-hoc verification agent for the two-phase code workflow. Spawned by the main session after coder agents finish. Runs every check itself in a fresh context — named gates, tests against recorded baselines, two-way diff-vs-task reading — and ends with a fenced verdict block the verifier_verdict_minter hook turns into the commit-gate verdict. Read and execute only; it never edits files.
4
+ tools: Read, Grep, Glob, Bash
5
+ model: inherit
6
+ color: orange
7
+ ---
8
+
9
+ You are the verifier in a two-phase code workflow: coder agents wrote changes, and you grade the result on its own terms (Claude Code best practices, fresh-context review: https://code.claude.com/docs/en/best-practices). The agent doing the work is never the one grading it — that is you, so you trust nothing you did not run or read yourself this session.
10
+
11
+ The caller gives you task texts, the diff scope, and baselines recorded before the coders ran. Treat every claim in the caller's message — and any coder summary quoted in it — as a hypothesis to test, never as a fact.
12
+
13
+ Run all three layers, in this order:
14
+
15
+ 1. **Runnable gates.** Every check the task names (its verification section), plus the universal set whether or not the caller asked: compile/syntax checks on changed files, the recorded-baseline tests scoped to the changed modules — the test files the task names plus tests that import a changed module (the failure set must match the recorded baseline exactly — no new failures, none silently fixed without explanation), imports of changed modules, and any repo commit gate. Run the full recorded suite only when the caller recorded a full-suite baseline because the surface spans multiple modules or multiple coders. Run each command yourself and keep its output.
16
+ 2. **Two-way diff-vs-task reading.** Read each coder's diff against that coder's task text. Every task item maps to a hunk that does it; every hunk maps back to a task item — a hunk with no task item is out-of-scope change, a task item with no hunk is missing work.
17
+ 3. **Negative space.** Walk the task's item list asking "where is this one?": silent deferrals, stubs, TODO markers, the smaller half of a task shipped, a sync change without its async twin.
18
+
19
+ Findings discipline:
20
+
21
+ - A finding must cite a failing command (with its output) or a named task item. No citation, no finding.
22
+ - Report gaps that affect correctness or the task's stated terms — never style preferences. Sound work produces zero findings; do not invent gaps to look thorough.
23
+ - Never edit a file. You verify; repair agents repair.
24
+ - Never execute code that drives the user's real input or screen — no live mouse moves, keystrokes, clicks, or window focus (pyautogui and its callers included). Run only the test commands the task names, scoped to the test files it names; no repo-wide test sweeps. Judge behavior equivalence by reading both versions, never by live execution of input-driving paths.
25
+
26
+ Before you write the verdict, learn the surface hash of the work tree you verified. Use the branch mode — it resolves the work tree that holds the branch automatically, so it is immune to your own cwd:
27
+
28
+ python ~/.claude/hooks/blocking/verification_verdict_store.py --manifest-hash-for-branch <branch under review>
29
+
30
+ On Windows the same file sits at %USERPROFILE%\.claude\hooks\blocking\verification_verdict_store.py; invoke it with the python on your PATH. If the caller named an explicit work-tree path rather than a branch, use the explicit-directory mode instead:
31
+
32
+ python ~/.claude/hooks/blocking/verification_verdict_store.py --manifest-hash <explicit-work-tree-dir>
33
+
34
+ The printed hash commits to every changed and untracked file's content in the verified work tree, so it names that surface no matter which directory you or the committer run from. If the CLI prints an empty-surface or wrong-work-tree error and no hash, you are pointed at a work tree with no changes versus origin/main — re-run with the branch mode to locate the correct work tree.
35
+
36
+ End your final message with exactly one fenced verdict block — the verifier_verdict_minter hook parses it, binds it to that hash, and the verified_commit_gate hook unlocks `git commit`/`git push` for any work tree whose live surface matches it:
37
+
38
+ ```verdict
39
+ {"all_pass": false, "findings": [{"check": "<gate or task item>", "detail": "<command + output, or the named task item and what is missing>"}], "manifest_sha256": "<hash the CLI printed>"}
40
+ ```
41
+
42
+ Set `all_pass` to true with an empty `findings` list only when every layer came back clean. Always include `manifest_sha256` so the verdict clears the commit regardless of which work tree the verifier or the committer ran in. Any file change after you finish moves that hash and invalidates the verdict, so you are the last step before the commit.
package/bin/install.mjs CHANGED
@@ -149,7 +149,7 @@ const INSTALL_GROUPS = {
149
149
  skills: [
150
150
  'anthropic-plan', 'everything-search',
151
151
  'pr-review-responder',
152
- 'recall', 'remember', 'task-build'
152
+ 'recall', 'remember', 'task-build', 'verified-build'
153
153
  ],
154
154
  includeDirectories: ['rules', 'docs', 'commands', 'agents', 'audit-rubrics'],
155
155
  includeAllHooks: true,
@@ -0,0 +1,554 @@
1
+ """Dead argparse-argument check: an optional CLI flag whose value is never read."""
2
+
3
+ import ast
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ _blocking_directory = str(Path(__file__).resolve().parent)
8
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
9
+ if _blocking_directory not in sys.path:
10
+ sys.path.insert(0, _blocking_directory)
11
+ if _hooks_directory not in sys.path:
12
+ sys.path.insert(0, _hooks_directory)
13
+
14
+ from code_rules_shared import ( # noqa: E402
15
+ is_migration_file,
16
+ is_test_file,
17
+ )
18
+
19
+ from hooks_constants.dead_argparse_argument_constants import ( # noqa: E402
20
+ ACTION_KEYWORD_NAME,
21
+ ADD_ARGUMENT_METHOD_NAME,
22
+ ALL_PARSE_METHOD_NAMES,
23
+ ALL_SUPPRESSED_ACTION_NAMES,
24
+ ATTRGETTER_FUNCTION_NAME,
25
+ DEAD_ARGPARSE_ARGUMENT_GUIDANCE,
26
+ DEST_KEYWORD_NAME,
27
+ DEST_WORD_JOINER,
28
+ DEST_WORD_SEPARATOR,
29
+ EXPORTED_NAMES_ATTRIBUTE,
30
+ GETATTR_FUNCTION_NAME,
31
+ GETATTR_NAME_ARGUMENT_MINIMUM,
32
+ LONG_OPTION_PREFIX,
33
+ MAX_DEAD_ARGPARSE_ARGUMENT_ISSUES,
34
+ NAMESPACE_DICT_ATTRIBUTE_NAME,
35
+ NAMESPACE_KEYWORD_NAME,
36
+ OPTION_PREFIX,
37
+ )
38
+
39
+
40
+ def _string_constant_literal(node: ast.expr) -> str | None:
41
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
42
+ return node.value
43
+ return None
44
+
45
+
46
+ def _keyword_string_literal(call_node: ast.Call, keyword_name: str) -> str | None:
47
+ """Return the string literal of a keyword argument, or None when absent."""
48
+ for each_keyword in call_node.keywords:
49
+ if each_keyword.arg == keyword_name:
50
+ return _string_constant_literal(each_keyword.value)
51
+ return None
52
+
53
+
54
+ def _keyword_value_node(call_node: ast.Call, keyword_name: str) -> ast.expr | None:
55
+ """Return the value node of a keyword argument, or None when absent."""
56
+ for each_keyword in call_node.keywords:
57
+ if each_keyword.arg == keyword_name:
58
+ return each_keyword.value
59
+ return None
60
+
61
+
62
+ def _option_string_arguments(call_node: ast.Call) -> list[str]:
63
+ """Return the leading positional string-literal arguments of an add_argument call."""
64
+ option_strings: list[str] = []
65
+ for each_argument in call_node.args:
66
+ literal_value = _string_constant_literal(each_argument)
67
+ if literal_value is None:
68
+ break
69
+ option_strings.append(literal_value)
70
+ return option_strings
71
+
72
+
73
+ def _dest_from_option_string(option_string: str) -> str:
74
+ return option_string.lstrip(OPTION_PREFIX).replace(DEST_WORD_SEPARATOR, DEST_WORD_JOINER)
75
+
76
+
77
+ def _has_keyword_argument(call_node: ast.Call, keyword_name: str) -> bool:
78
+ """Return whether the call passes a keyword argument with the given name."""
79
+ return any(each_keyword.arg == keyword_name for each_keyword in call_node.keywords)
80
+
81
+
82
+ def _argument_dest_name(call_node: ast.Call) -> str | None:
83
+ """Return the dest name an optional add_argument call declares, or None.
84
+
85
+ A ``dest=`` keyword determines the dest outright: a string-literal value
86
+ (``dest="name"``) names it, while a non-literal ``dest=`` (a variable or
87
+ expression whose value the static scan cannot resolve) makes the real dest
88
+ unknowable, so the argument is skipped (None). Without a ``dest=`` keyword the
89
+ dest derives from the first long option (``--repo`` -> ``repo``, ``--dry-run``
90
+ -> ``dry_run``) and falls back to the first short option when no long option
91
+ is present. A bare positional argument (no leading dash) is a required
92
+ positional, never an optional flag, and yields None so the caller skips it.
93
+ """
94
+ if _has_keyword_argument(call_node, DEST_KEYWORD_NAME):
95
+ return _keyword_string_literal(call_node, DEST_KEYWORD_NAME)
96
+ option_strings = _option_string_arguments(call_node)
97
+ if not option_strings:
98
+ return None
99
+ if not option_strings[0].startswith(OPTION_PREFIX):
100
+ return None
101
+ for each_option in option_strings:
102
+ if each_option.startswith(LONG_OPTION_PREFIX):
103
+ return _dest_from_option_string(each_option)
104
+ return _dest_from_option_string(option_strings[0])
105
+
106
+
107
+ def _has_suppressed_action(call_node: ast.Call) -> bool:
108
+ action_name = _keyword_string_literal(call_node, ACTION_KEYWORD_NAME)
109
+ return action_name in ALL_SUPPRESSED_ACTION_NAMES
110
+
111
+
112
+ def _is_add_argument_call(node: ast.AST) -> bool:
113
+ return (
114
+ isinstance(node, ast.Call)
115
+ and isinstance(node.func, ast.Attribute)
116
+ and node.func.attr == ADD_ARGUMENT_METHOD_NAME
117
+ )
118
+
119
+
120
+ def _argument_dest_definitions(tree: ast.Module) -> list[tuple[str, int]]:
121
+ """Return (dest_name, line) for each optional add_argument call in the module.
122
+
123
+ Positional arguments (a bare name with no leading dash) and the argparse
124
+ auto-actions ``help`` and ``version`` are excluded, since their parsed value
125
+ is never a meaningful readable dest.
126
+ """
127
+ dest_definitions: list[tuple[str, int]] = []
128
+ for each_node in ast.walk(tree):
129
+ if not _is_add_argument_call(each_node):
130
+ continue
131
+ assert isinstance(each_node, ast.Call)
132
+ if _has_suppressed_action(each_node):
133
+ continue
134
+ dest_name = _argument_dest_name(each_node)
135
+ if dest_name is None:
136
+ continue
137
+ dest_definitions.append((dest_name, each_node.lineno))
138
+ return dest_definitions
139
+
140
+
141
+ def _attribute_read_names(tree: ast.Module) -> set[str]:
142
+ """Return every attribute name read or augmented-assigned in the module.
143
+
144
+ A read in Load context (``parsed_args.repo``) names the attribute, and an
145
+ augmented assignment (``parsed_args.count += 1``) reads the attribute before
146
+ writing it, so both count as a read of the dest name.
147
+ """
148
+ read_names: set[str] = set()
149
+ for each_node in ast.walk(tree):
150
+ if isinstance(each_node, ast.Attribute) and isinstance(each_node.ctx, ast.Load):
151
+ read_names.add(each_node.attr)
152
+ if isinstance(each_node, ast.AugAssign) and isinstance(each_node.target, ast.Attribute):
153
+ read_names.add(each_node.target.attr)
154
+ return read_names
155
+
156
+
157
+ def _dynamic_read_names(tree: ast.Module) -> set[str]:
158
+ """Return literal attribute names read via ``getattr`` and ``attrgetter``.
159
+
160
+ ``getattr(namespace, "repo")`` names its attribute in the second positional
161
+ argument, while ``attrgetter("a", "b")`` names one attribute per positional
162
+ argument; each literal string contributes a read name.
163
+ """
164
+ literal_names: set[str] = set()
165
+ for each_node in ast.walk(tree):
166
+ if not isinstance(each_node, ast.Call):
167
+ continue
168
+ function_name = None
169
+ if isinstance(each_node.func, ast.Name):
170
+ function_name = each_node.func.id
171
+ elif isinstance(each_node.func, ast.Attribute):
172
+ function_name = each_node.func.attr
173
+ if function_name == GETATTR_FUNCTION_NAME:
174
+ if len(each_node.args) >= GETATTR_NAME_ARGUMENT_MINIMUM:
175
+ literal_name = _string_constant_literal(each_node.args[1])
176
+ if literal_name is not None:
177
+ literal_names.add(literal_name)
178
+ elif function_name == ATTRGETTER_FUNCTION_NAME:
179
+ for each_argument in each_node.args:
180
+ literal_name = _string_constant_literal(each_argument)
181
+ if literal_name is not None:
182
+ literal_names.add(literal_name)
183
+ return literal_names
184
+
185
+
186
+ def _exported_names(tree: ast.Module) -> set[str]:
187
+ """Return names listed in a module-level ``__all__`` literal."""
188
+ exported: set[str] = set()
189
+ for each_node in tree.body:
190
+ if not isinstance(each_node, ast.Assign):
191
+ continue
192
+ targets_all = any(
193
+ isinstance(each_target, ast.Name) and each_target.id == EXPORTED_NAMES_ATTRIBUTE
194
+ for each_target in each_node.targets
195
+ )
196
+ if not targets_all:
197
+ continue
198
+ if isinstance(each_node.value, (ast.List, ast.Tuple, ast.Set)):
199
+ for each_element in each_node.value.elts:
200
+ literal_name = _string_constant_literal(each_element)
201
+ if literal_name is not None:
202
+ exported.add(literal_name)
203
+ return exported
204
+
205
+
206
+ def _target_namespace_names(assign_target: ast.expr) -> set[str]:
207
+ """Return names an assignment target binds the namespace to.
208
+
209
+ A bare ``ast.Name`` target (``parsed = parse_args()``) binds one namespace
210
+ name. A tuple- or list-unpack target (``parsed, remaining =
211
+ parse_known_args()``) binds the namespace to its first ``ast.Name`` element,
212
+ matching the documented ``(namespace, remaining)`` return shape.
213
+ """
214
+ if isinstance(assign_target, ast.Name):
215
+ return {assign_target.id}
216
+ if isinstance(assign_target, (ast.Tuple, ast.List)):
217
+ for each_element in assign_target.elts:
218
+ if isinstance(each_element, ast.Name):
219
+ return {each_element.id}
220
+ return set()
221
+
222
+
223
+ def _is_parse_method_call(node: ast.AST | None) -> bool:
224
+ """Return whether a node is a ``parse_args``/``parse_known_args`` method call.
225
+
226
+ The call's function is an attribute access (``parser.parse_args``) whose
227
+ attribute name is one of the tracked parse methods.
228
+ """
229
+ return (
230
+ isinstance(node, ast.Call)
231
+ and isinstance(node.func, ast.Attribute)
232
+ and node.func.attr in ALL_PARSE_METHOD_NAMES
233
+ )
234
+
235
+
236
+ def _parse_result_binding_targets(node: ast.AST) -> list[ast.expr] | None:
237
+ """Return the targets a statement binds a parse-method result to, or None.
238
+
239
+ A plain ``ast.Assign`` (``parsed = parse_args()``) or an annotated
240
+ ``ast.AnnAssign`` (``parsed: Namespace = parse_args()``) whose value is a
241
+ parse-method call binds the namespace; the returned list holds its target nodes,
242
+ one for an annotated assignment and one or more for a plain assignment. A node
243
+ that is not such a binding, including an annotated assignment with no value
244
+ (``parsed: Namespace``), returns None.
245
+ """
246
+ if isinstance(node, ast.Assign):
247
+ binding_targets: list[ast.expr] = node.targets
248
+ bound_value: ast.expr | None = node.value
249
+ elif isinstance(node, ast.AnnAssign):
250
+ binding_targets = [node.target]
251
+ bound_value = node.value
252
+ else:
253
+ return None
254
+ if not _is_parse_method_call(bound_value):
255
+ return None
256
+ return binding_targets
257
+
258
+
259
+ def _parse_call_namespace_names(tree: ast.Module) -> set[str]:
260
+ """Return names bound to a plain or annotated ``parse_args``/``parse_known_args`` result."""
261
+ parse_call_names: set[str] = set()
262
+ for each_node in ast.walk(tree):
263
+ binding_targets = _parse_result_binding_targets(each_node)
264
+ if binding_targets is None:
265
+ continue
266
+ for each_target in binding_targets:
267
+ parse_call_names |= _target_namespace_names(each_target)
268
+ return parse_call_names
269
+
270
+
271
+ def _alias_namespace_names(tree: ast.Module, all_parse_call_names: set[str]) -> set[str]:
272
+ """Return names bound by re-assigning an existing namespace variable.
273
+
274
+ A simple ``alias = parsed_arguments`` re-binding aliases the namespace, and a
275
+ chain (``second = alias``) aliases it again, so the set grows to a fixed point
276
+ before it is returned.
277
+ """
278
+ all_namespace_names = set(all_parse_call_names)
279
+ keeps_growing = True
280
+ while keeps_growing:
281
+ keeps_growing = False
282
+ for each_node in ast.walk(tree):
283
+ if not isinstance(each_node, ast.Assign):
284
+ continue
285
+ if not isinstance(each_node.value, ast.Name):
286
+ continue
287
+ if each_node.value.id not in all_namespace_names:
288
+ continue
289
+ for each_target in each_node.targets:
290
+ if isinstance(each_target, ast.Name) and each_target.id not in all_namespace_names:
291
+ all_namespace_names.add(each_target.id)
292
+ keeps_growing = True
293
+ return all_namespace_names
294
+
295
+
296
+ def _namespace_keyword_argument_names(tree: ast.Module) -> set[str]:
297
+ """Return names passed as the ``namespace=`` keyword to a parse-method call.
298
+
299
+ ``parser.parse_args(namespace=options)`` populates the pre-existing
300
+ ``options`` object, so its attribute reads name dests even though the parse
301
+ result is never bound; the keyword's ``ast.Name`` value names that namespace.
302
+ """
303
+ keyword_names: set[str] = set()
304
+ for each_node in ast.walk(tree):
305
+ if not isinstance(each_node, ast.Call):
306
+ continue
307
+ function_node = each_node.func
308
+ if not isinstance(function_node, ast.Attribute):
309
+ continue
310
+ if function_node.attr not in ALL_PARSE_METHOD_NAMES:
311
+ continue
312
+ keyword_value = _keyword_value_node(each_node, NAMESPACE_KEYWORD_NAME)
313
+ if isinstance(keyword_value, ast.Name):
314
+ keyword_names.add(keyword_value.id)
315
+ return keyword_names
316
+
317
+
318
+ def _namespace_variable_names(tree: ast.Module) -> set[str]:
319
+ """Return names bound to a parse result, a ``namespace=`` object, or an alias.
320
+
321
+ A direct binding plain or annotated (``parsed = parse_args()`` or
322
+ ``parsed: Namespace = parse_args()``), the first element of a tuple-unpack of the
323
+ documented ``(namespace, remaining)`` return (``parsed, remaining =
324
+ parse_known_args()``), a ``namespace=`` keyword object
325
+ (``parse_args(namespace=options)``), and a simple re-binding chain
326
+ (``alias = parsed``) each name the same namespace.
327
+ """
328
+ seed_names = _parse_call_namespace_names(tree) | _namespace_keyword_argument_names(tree)
329
+ return _alias_namespace_names(tree, seed_names)
330
+
331
+
332
+ def _attribute_value_name_ids(tree: ast.Module) -> set[int]:
333
+ """Return ``id()`` of every Name that is the object of an attribute access.
334
+
335
+ The Name in ``namespace.repo`` is the ``.value`` of an ``ast.Attribute``, so it
336
+ is a per-attribute read rather than a use of the namespace object itself.
337
+ """
338
+ return {
339
+ id(each_node.value)
340
+ for each_node in ast.walk(tree)
341
+ if isinstance(each_node, ast.Attribute) and isinstance(each_node.value, ast.Name)
342
+ }
343
+
344
+
345
+ def _namespace_keyword_value_name_ids(tree: ast.Module) -> set[int]:
346
+ """Return ``id()`` of every Name passed as the ``namespace=`` keyword to a parse call.
347
+
348
+ ``parse_args(namespace=options)`` binds the namespace at this position rather
349
+ than consuming it, so this Name is excluded from the escape scan the way an
350
+ attribute-read object is.
351
+ """
352
+ keyword_value_ids: set[int] = set()
353
+ for each_node in ast.walk(tree):
354
+ if not isinstance(each_node, ast.Call):
355
+ continue
356
+ function_node = each_node.func
357
+ if not isinstance(function_node, ast.Attribute):
358
+ continue
359
+ if function_node.attr not in ALL_PARSE_METHOD_NAMES:
360
+ continue
361
+ keyword_value = _keyword_value_node(each_node, NAMESPACE_KEYWORD_NAME)
362
+ if isinstance(keyword_value, ast.Name):
363
+ keyword_value_ids.add(id(keyword_value))
364
+ return keyword_value_ids
365
+
366
+
367
+ def _namespace_used_as_value(tree: ast.Module, all_namespace_names: set[str]) -> bool:
368
+ """Return whether a tracked namespace Name is used as a value rather than an attribute read.
369
+
370
+ A tracked namespace Name read in Load context anywhere in the module -- passed
371
+ to a call, returned, aliased, double-star unpacked, or nested inside a container
372
+ literal -- uses the namespace object itself and hides which attributes are read.
373
+ Two positions are excluded: the object of an attribute access (``namespace.repo``
374
+ is a per-attribute read) and a Name passed as the ``namespace=`` keyword to a
375
+ parse call (a binding site, not a consumption).
376
+ """
377
+ excluded_name_ids = _attribute_value_name_ids(tree) | _namespace_keyword_value_name_ids(tree)
378
+ for each_node in ast.walk(tree):
379
+ if not isinstance(each_node, ast.Name):
380
+ continue
381
+ if not isinstance(each_node.ctx, ast.Load):
382
+ continue
383
+ if each_node.id not in all_namespace_names:
384
+ continue
385
+ if id(each_node) in excluded_name_ids:
386
+ continue
387
+ return True
388
+ return False
389
+
390
+
391
+ def _namespace_dict_accessed(tree: ast.Module, all_namespace_names: set[str]) -> bool:
392
+ """Return whether a tracked namespace exposes all attributes via ``__dict__``."""
393
+ for each_node in ast.walk(tree):
394
+ if (
395
+ isinstance(each_node, ast.Attribute)
396
+ and each_node.attr == NAMESPACE_DICT_ATTRIBUTE_NAME
397
+ and isinstance(each_node.value, ast.Name)
398
+ and each_node.value.id in all_namespace_names
399
+ ):
400
+ return True
401
+ return False
402
+
403
+
404
+ def _namespace_escapes(tree: ast.Module, all_namespace_names: set[str]) -> bool:
405
+ """Return whether a namespace is consumed in a way that hides which dests are read.
406
+
407
+ A namespace whose attributes the static scan cannot enumerate makes any single
408
+ dest unprovably dead, so the check suppresses. Each of the following is such an
409
+ escape: a ``parse_args``/``parse_known_args`` bound method referenced as an
410
+ aliased value rather than called inline; a parse result bound to an attribute-
411
+ or subscript-target the scan cannot track; a parse result consumed inline within
412
+ a larger expression rather than bound to an assignment or a bare statement; a
413
+ tracked namespace read through ``__dict__``; and a tracked namespace Name used as
414
+ a value rather than an attribute read (passed to a call, returned, aliased,
415
+ double-star unpacked, or nested inside a container literal), excluding the object
416
+ of an attribute access and a Name passed as the ``namespace=`` keyword to a parse
417
+ call.
418
+ """
419
+ if _parse_method_is_aliased(tree):
420
+ return True
421
+ if _parse_result_binds_untracked_target(tree):
422
+ return True
423
+ if _parse_call_consumed_inline(tree):
424
+ return True
425
+ if _namespace_dict_accessed(tree, all_namespace_names):
426
+ return True
427
+ return _namespace_used_as_value(tree, all_namespace_names)
428
+
429
+
430
+ def _parse_method_is_aliased(tree: ast.Module) -> bool:
431
+ """Return whether a ``parse_args``/``parse_known_args`` method is referenced uncalled.
432
+
433
+ The bound method is aliased when its attribute (``parser.parse_args``) appears
434
+ as a value rather than as the ``func`` of an enclosing call — assigned to a
435
+ name or passed around — so the resulting namespace is produced by a call the
436
+ scan cannot follow back to ``parse_args``.
437
+ """
438
+ all_called_function_ids = {
439
+ id(each_node.func) for each_node in ast.walk(tree) if isinstance(each_node, ast.Call)
440
+ }
441
+ for each_node in ast.walk(tree):
442
+ if not isinstance(each_node, ast.Attribute):
443
+ continue
444
+ if each_node.attr not in ALL_PARSE_METHOD_NAMES:
445
+ continue
446
+ if id(each_node) not in all_called_function_ids:
447
+ return True
448
+ return False
449
+
450
+
451
+ def _parse_result_binds_untracked_target(tree: ast.Module) -> bool:
452
+ """Return whether a parse-method result binds to a target the scan cannot track.
453
+
454
+ A ``parse_args``/``parse_known_args`` result bound by a plain or annotated
455
+ assignment to a plain ``ast.Name`` target, or to a tuple/list whose first
456
+ element is a plain ``ast.Name``, is tracked as a namespace variable. A result
457
+ bound to an attribute target (``self.args``) or a subscript target binds the
458
+ namespace where the scan cannot follow its attribute reads, so it escapes.
459
+ """
460
+ for each_node in ast.walk(tree):
461
+ binding_targets = _parse_result_binding_targets(each_node)
462
+ if binding_targets is None:
463
+ continue
464
+ for each_target in binding_targets:
465
+ if not _target_namespace_names(each_target):
466
+ return True
467
+ return False
468
+
469
+
470
+ def _parse_call_consumed_inline(tree: ast.Module) -> bool:
471
+ """Return whether a parse-method result is consumed inline rather than bound to a target.
472
+
473
+ A ``parse_args``/``parse_known_args`` call is statically trackable only when its
474
+ result is the value of a plain assignment (``parsed = parser.parse_args()``), an
475
+ annotated assignment (``parsed: Namespace = parser.parse_args()``), or a bare
476
+ expression statement (``parser.parse_args(namespace=options)``, whose result is
477
+ discarded after populating the keyword object). Consumed inside a larger
478
+ expression instead -- returned directly, passed to ``vars``, double-star
479
+ unpacked, or bound by a walrus -- the namespace never reaches a tracked name, so
480
+ the check suppresses.
481
+ """
482
+ statement_bound_call_ids: set[int] = set()
483
+ for each_node in ast.walk(tree):
484
+ if not isinstance(each_node, (ast.Assign, ast.AnnAssign, ast.Expr)):
485
+ continue
486
+ if _is_parse_method_call(each_node.value):
487
+ statement_bound_call_ids.add(id(each_node.value))
488
+ for each_node in ast.walk(tree):
489
+ if _is_parse_method_call(each_node) and id(each_node) not in statement_bound_call_ids:
490
+ return True
491
+ return False
492
+
493
+
494
+ def check_dead_argparse_arguments(
495
+ content: str, file_path: str, full_file_content: str | None = None
496
+ ) -> list[str]:
497
+ """Flag an optional argparse flag whose parsed value the same file never reads.
498
+
499
+ An optional ``add_argument("--flag", ...)`` is dead when its dest name never
500
+ appears as an attribute read, an augmented-assignment target, or a literal
501
+ ``getattr``/``attrgetter`` access anywhere in the file, the name is not listed
502
+ in a module-level ``__all__``, and no parsed namespace escapes static view. A
503
+ namespace escapes when a ``parse_args``/``parse_known_args`` bound method is
504
+ referenced as an aliased value rather than called inline, when a parse result
505
+ binds to an attribute- or subscript-target the scan cannot track, when a parse
506
+ result is consumed inline within a larger expression rather than bound to an
507
+ assignment or a bare statement, when a tracked namespace is read through
508
+ ``__dict__``, or when a tracked namespace Name is used as a value rather than an
509
+ attribute read (passed to a call, returned, aliased, double-star unpacked, or
510
+ nested inside a container literal), excluding the object of an attribute access
511
+ and a Name passed as the ``namespace=`` keyword to a parse call. A namespace
512
+ name is tracked when it binds a parse result, a tuple-unpacked
513
+ parse result, a ``namespace=`` keyword object, or an alias of one of these.
514
+ Positional arguments and the argparse ``help``/``version`` auto-actions are never
515
+ flagged. Whole-file analysis runs against ``full_file_content`` when supplied so
516
+ an Edit fragment is judged against the reconstructed post-edit file.
517
+
518
+ Args:
519
+ content: The new content under validation (Edit fragment or whole file).
520
+ file_path: The destination path, used for the test/migration exemptions.
521
+ full_file_content: The reconstructed post-edit whole-file content for an
522
+ Edit, or None for a Write where ``content`` is already the whole file.
523
+
524
+ Returns:
525
+ One violation message per dead optional argument, capped at the configured
526
+ maximum.
527
+ """
528
+ if is_test_file(file_path):
529
+ return []
530
+ if is_migration_file(file_path):
531
+ return []
532
+ effective_content = content if full_file_content is None else full_file_content
533
+ try:
534
+ tree = ast.parse(effective_content)
535
+ except SyntaxError:
536
+ return []
537
+ dest_definitions = _argument_dest_definitions(tree)
538
+ if not dest_definitions:
539
+ return []
540
+ all_namespace_names = _namespace_variable_names(tree)
541
+ if _namespace_escapes(tree, all_namespace_names):
542
+ return []
543
+ read_names = _attribute_read_names(tree) | _dynamic_read_names(tree) | _exported_names(tree)
544
+ issues: list[str] = []
545
+ for each_dest_definition in dest_definitions:
546
+ dest_name, dest_line = each_dest_definition
547
+ if dest_name in read_names:
548
+ continue
549
+ issues.append(
550
+ f"Line {dest_line}: CLI argument {dest_name!r} - {DEAD_ARGPARSE_ARGUMENT_GUIDANCE}"
551
+ )
552
+ if len(issues) >= MAX_DEAD_ARGPARSE_ARGUMENT_ISSUES:
553
+ return issues
554
+ return issues
@@ -52,6 +52,9 @@ from code_rules_constants_config import ( # noqa: E402
52
52
  check_constants_outside_config_advisory,
53
53
  check_file_global_constants_use_count,
54
54
  )
55
+ from code_rules_dead_argparse_argument import ( # noqa: E402
56
+ check_dead_argparse_arguments,
57
+ )
55
58
  from code_rules_dead_config_field import ( # noqa: E402
56
59
  check_dead_config_dataclass_fields,
57
60
  )
@@ -282,6 +285,9 @@ def validate_content(
282
285
  all_issues.extend(
283
286
  check_dead_dataclass_fields(content, file_path, full_file_content)
284
287
  )
288
+ all_issues.extend(
289
+ check_dead_argparse_arguments(content, file_path, full_file_content)
290
+ )
285
291
  all_issues.extend(
286
292
  check_dead_config_dataclass_fields(content, file_path, full_file_content)
287
293
  )