-
Notifications
You must be signed in to change notification settings - Fork 41
Expand file tree
/
Copy pathcheck-audit-lifecycle-contracts.py
More file actions
executable file
·666 lines (597 loc) · 35.1 KB
/
Copy pathcheck-audit-lifecycle-contracts.py
File metadata and controls
executable file
·666 lines (597 loc) · 35.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Daniel Radman
# SPDX-License-Identifier: MIT
"""Reconcile the create-issue audit lifecycle's prose against machine-consumed contracts.
Issue #795. Two reconciliations that a `git grep` for a sentence could never perform,
plus the measurement figure derived from the second:
read-backs The multi-line read-back enumeration carried by `scripts/issue-audit-state.py`'s
`TWO-CLASS CLI CONTRACT` docstring is compared against `_MULTILINE_READBACKS`
— the set the module's own emission machinery dispatches on — and every name
in that set is required to be a subcommand the parser actually registers.
So the guard grades the docstring against what the tool DOES, not against
its own wording, and a name in the prose that no parser choice backs is RED.
sequence Every state-owner invocation named in `step-3-6-audit.md`'s ordered
call-sequence paragraph is required to be a registered subcommand (the prose
can never name a call the tool would not accept), and the count of
unconditional calls that paragraph plus `step-4-present-create.md` jointly
mandate is reported.
figure The per-round measurement figure the suite pins, derived from `sequence`
rather than hand-transcribed — so a later addition of an unconditional call
MOVES the figure instead of leaving a stale literal behind.
FAIL CLOSED, NEVER CLEAN-ZERO. Both prose readers parse a human-editable markdown file, so
each refuses rather than reporting an EMPTY result: no candidate section, more than one
candidate section, or zero invocations extracted is a named RED breadcrumb. A rewrap or a
duplicated heading must not make a check pass vacuously and freeze the figure.
The scope of that guarantee is exactly "not empty", and no more. A DEGENERATE-but-nonzero
paragraph — one that names a single registered subcommand, or repeats one — still extracts
successfully and still reports a figure, so the zero-guard is not a proof that the
paragraph is a meaningful sequence. Distinctness deliberately is NOT required, because the
real sequence legitimately names `query-draft-binding` twice. What catches a degenerate
rewrite is the figure moving, which is why the figure is reported on the success path
rather than only on failure.
Exit 0 with a report on stdout when every reconciliation holds; exit 1 with the failing
reconciliation named on stderr otherwise.
"""
from __future__ import annotations
import argparse
import ast
import builtins
import contextlib
import importlib.util
import inspect
import io
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
IAS = REPO / "scripts" / "issue-audit-state.py"
STEP36 = REPO / "skills" / "create-issue" / "references" / "step-3-6-audit.md"
STEP4 = REPO / "skills" / "create-issue" / "references" / "step-4-present-create.md"
# The paragraph that opens the ordered call sequence. A closed anchor, not a fuzzy match:
# exactly one line must carry it, so a duplicated or renamed heading is RED rather than
# silently selecting the first hit.
_SEQUENCE_ANCHOR = "**The call sequence, in order.** The normal clean run:"
# The docstring section carrying the read-back enumeration.
_DOCSTRING_ANCHOR = "TWO-CLASS CLI CONTRACT"
# Calls the prose marks conditional on the run's shape; excluded from the unconditional
# figure by name, and the prose is required to still mark them so (checked below).
# `record-adjudication-render` belongs here for a reason worth stating: it is not merely
# skippable on a clean run, it is REFUSED there — the state owner fails it `no-records`
# when the round graded no advisory or invalid finding, and `resolve_calibration` answers
# `render=none` with no trigger for exactly that shape. Listing it in the unconditional
# ordered sequence therefore prescribed a call that cannot succeed on the clean path.
_CONDITIONAL = ("record-offer", "query-adjudication-records",
"record-adjudication-render")
class Refusal(Exception):
"""A reconciliation could not be established — never reported as a clean result."""
def _load_module():
spec = importlib.util.spec_from_file_location("_ias795", IAS)
if spec is None or spec.loader is None:
# Without this, a moved or renamed state owner surfaces as an
# `AttributeError: 'NoneType'` traceback — the one shape this file's own
# "FAIL CLOSED, NEVER CLEAN-ZERO" contract promises never to produce.
raise Refusal(f"could not load {IAS.relative_to(REPO)} as a module "
"(missing file or unloadable spec)")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _read(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except OSError as exc:
raise Refusal(f"could not read {path.relative_to(REPO)}: {exc}") from exc
def _sole_paragraph(text: str, anchor: str, where: str) -> str:
"""The single paragraph following `anchor`, or a refusal naming why not."""
lines = text.splitlines()
hits = [i for i, line in enumerate(lines) if anchor in line]
if not hits:
raise Refusal(f"{where}: no line carries the anchor {anchor!r} — the section was "
"renamed, rewrapped, or removed; refusing rather than reporting an "
"empty extraction")
if len(hits) > 1:
raise Refusal(f"{where}: {len(hits)} lines carry the anchor {anchor!r}; exactly one "
"candidate is required, so a duplicated heading cannot make this "
"check pass against the wrong paragraph")
start = hits[0] + 1
while start < len(lines) and not lines[start].strip():
start += 1
end = start
while end < len(lines) and lines[end].strip():
end += 1
body = "\n".join(lines[start:end]).strip()
if not body:
raise Refusal(f"{where}: the paragraph after {anchor!r} is empty")
return body
# The shape a state-owner subcommand name takes. Used to tell "a token that is TRYING to
# be a subcommand and is misspelled" (refuse) from "ordinary backticked prose" (skip).
_SUBCOMMAND_SHAPED = re.compile(r"\A(?:query|record|init|emit|check)-[a-z0-9-]+\Z")
def _backticked(text: str) -> list[str]:
"""Every backtick-quoted token in `text`, in document order."""
return re.findall(r"`([^`]+)`", text)
def _invocations(text: str, registered: frozenset[str], where: str) -> list[str]:
"""The registered subcommand names a prose passage invokes, in document order.
A name called twice contributes twice: the count is of invocations, not of distinct
subcommands.
"""
found = []
for token in _backticked(text):
parts = token.split()
if not parts:
continue
if parts[0] in registered:
found.append(parts[0])
elif _SUBCOMMAND_SHAPED.match(parts[0]):
# REFUSE, do not skip. Selecting only registered names would make the
# "the prose can never name a call the tool would not accept" guarantee
# vacuous: a typo (`record-covrage`) would be filtered out silently, the
# derived figure would drop by one, and the success line would still read
# "every one a registered subcommand" over prose prescribing a call argparse
# rejects. Only a token SHAPED like a state-owner subcommand trips this, so
# ordinary backticked prose (`--round`, `next_call=none`) is unaffected.
raise Refusal(
f"{where}: {parts[0]!r} is shaped like a state-owner subcommand but is "
"not registered by build_parser() — the prose names a call the tool "
"would refuse (a typo, a rename, or a removed subcommand)")
if not found:
raise Refusal(f"{where}: zero state-owner invocations extracted. A clean zero here "
"would freeze the derived figure and let the reconciliation pass "
"vacuously, so it is a refusal")
return found
def _subparser_of(parser, name):
"""The subparser registered under `name`, or None."""
for action in parser._actions: # noqa: SLF001
if isinstance(action, argparse._SubParsersAction): # noqa: SLF001
return action.choices.get(name)
return None
# The builtins a call may name and still be provably unable to reach a module-level
# save_state — every builtin function, type, and exception (`len`, `print`, `sorted`,
# `SystemExit`, `ValueError`, …). `getattr` is safe in its BARE-name value form
# `getattr(x, y)`; the dangerous form `getattr(x, y)()` is a Call-as-callee, caught by the
# indirect-dispatch arm below regardless.
_SAFE_BUILTIN_CALLS = frozenset(dir(builtins))
def _module_level_names(tree):
"""(functions_by_name, safe_leaf_names) for the module. `functions_by_name` are the
function defs the walk FOLLOWS — every module-level def PLUS every nested/closure helper
they define, indexed by name, so a bare-name call to a helper the source statically
carries is followed (proving whether it reaches save_state) rather than flagged
unresolvable. `safe_leaf_names` are names a call may reference and still reach no
followable function by name — imported names and module-level class names (a class
instantiation cannot BE the `save_state` function, e.g. `StateError(...)`). A name collision across scopes
over-approximates by keeping one body; that only ever follows MORE, which is the
fail-closed direction for an unreachability proof.
"""
functions = {}
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
functions[node.name] = node
safe = set(_SAFE_BUILTIN_CALLS)
for node in tree.body:
if isinstance(node, ast.ClassDef):
safe.add(node.name)
elif isinstance(node, ast.Import):
for alias in node.names:
safe.add((alias.asname or alias.name).split(".")[0])
elif isinstance(node, ast.ImportFrom):
for alias in node.names:
safe.add(alias.asname or alias.name)
return functions, safe
def check_readonly_complement(module, registered, report):
"""Every read-only-classified subcommand's handler must be UNABLE to reach `save_state`
through the transitive call graph of module-level functions — not merely have it absent
from the handler's own source text (issue #1040). A source-text-only check would pass a
handler that reaches save_state one hop away through a helper, the fail-open shape this
exists to prevent.
The walk's closed set is module-level functions; its complement is named and handled,
never left silent. A call target the walk cannot resolve to a module-level function — a
bare-name alias/closure/nested helper, or an indirect dispatch through a table or
getattr — makes the check FAIL CLOSED for that subcommand, reporting the unresolvable
call site rather than reporting clean. An attribute/method call reaches no module-level
function by name (module-level functions are called as bare names), so it cannot be
`save_state` and is safe. A read-only-classified handler is proved safe only when every
call on its transitive path resolved.
"""
# Analyze the PASSED module's own source (not IAS directly), so a fixture module drives
# this check exactly like check_emitting_complement — the positive controls in the test
# suite load a crafted module and expect a Refusal.
try:
source = inspect.getsource(module)
except (OSError, TypeError) as exc:
raise Refusal(f"readonly-complement: could not read the module source of "
f"{getattr(module, '__name__', module)!r}: {exc}") from exc
try:
tree = ast.parse(source)
except SyntaxError as exc:
raise Refusal(f"readonly-complement: could not parse the module source: "
f"{exc}") from exc
functions, safe = _module_level_names(tree)
parser = module.build_parser()
name_to_handler = {}
for action in parser._actions: # noqa: SLF001
if isinstance(action, argparse._SubParsersAction): # noqa: SLF001
for name, sub in action.choices.items():
fn = sub._defaults.get("func") # noqa: SLF001
name_to_handler[name] = getattr(fn, "__name__", None)
predicate = module._is_read_only # noqa: SLF001
readonly = sorted(n for n in registered if predicate(n))
if not readonly:
raise Refusal("readonly-complement: the read-only predicate selected NO registered "
"subcommand, so the critical section would wrap every command and this "
"complement check would be vacuous")
# Module-level constants (name -> value node), so a read-only handler that dispatches
# over a module-level TABLE of producers (query-boundary's `for _, produce in
# _BOUNDARY_PRODUCERS: produce(...)`) is RESOLVED through the constant rather than
# failed closed: name-reachability propagates through the constant's value into the
# producer lambdas and the module functions they name.
constants = {}
for node in tree.body:
if isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name):
constants[tgt.id] = node.value
def _targets_name(target, name):
"""True iff an assignment/for target binds `name` (a Name, or a Tuple/List of
them)."""
if isinstance(target, ast.Name):
return target.id == name
if isinstance(target, (ast.Tuple, ast.List)):
return any(_targets_name(el, name) for el in target.elts)
return False
def _local_call_resolvable(node, name):
"""A bare-name call to a LOCAL `name` is resolvable only when `name` is bound within
`node` from a source that references a module-level function or constant — then the
call target is reached through name-propagation over that source (query-boundary's
`for _, produce in _BOUNDARY_PRODUCERS` binds `produce` from a module constant). A
name bound only from a getattr()/subscript/opaque source, or not bound at all (a
parameter or callback), is unresolvable and fails the subcommand closed.
"""
for sub in ast.walk(node):
srcs = []
if isinstance(sub, ast.For) and _targets_name(sub.target, name):
srcs.append(sub.iter)
elif isinstance(sub, ast.Assign) and any(
_targets_name(t, name) for t in sub.targets):
srcs.append(sub.value)
elif isinstance(sub, ast.comprehension) and _targets_name(sub.target, name):
srcs.append(sub.iter)
for src in srcs:
for ref in ast.walk(src):
if isinstance(ref, ast.Name) and (
ref.id in functions or ref.id in constants):
return True
return False
def _analyze_node(node):
"""(module-level names referenced in Load context, has-computed-callee,
[unresolvable local-call names]) in node's whole subtree — nested defs and lambdas
included, so a helper or a producer lambda is not a blind spot. A computed callee
(getattr(...)() / table[key]()) and a bare-name call to an unresolvable local
(a getattr-aliased callable) are the two shapes the name walk cannot resolve.
"""
names = set()
has_computed = False
unresolvable = []
for sub in ast.walk(node):
if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load):
if sub.id in functions or sub.id in constants:
names.add(sub.id)
elif isinstance(sub, ast.Call):
fn = sub.func
if isinstance(fn, ast.Name):
nm = fn.id
if (nm != "save_state" and nm not in functions
and nm not in constants and nm not in safe
and not _local_call_resolvable(node, nm)):
unresolvable.append(nm)
elif not isinstance(fn, ast.Attribute):
has_computed = True
return names, has_computed, unresolvable
def analyze(subcmd, handler):
# Transitive name-reachability over module-level functions AND constants. A read-only
# handler is proved safe only when `save_state` is unreachable by name AND no
# reachable function contains a computed (unresolvable) callee. This is sound by
# name — a handler that reaches save_state through any bare-name path (a call, a
# callback passed by name, a nested def, or a producer table) names it and is
# caught; the one residue the name walk cannot see, a getattr('save_state')()
# string dispatch, is caught by the computed-callee arm.
seen = set()
queue = [handler]
while queue:
nm = queue.pop()
if nm in seen:
continue
seen.add(nm)
node = functions.get(nm)
is_func = node is not None
if node is None:
node = constants.get(nm)
if node is None:
continue
names, has_computed, unresolvable = _analyze_node(node)
if is_func and has_computed:
raise Refusal(
f"readonly-complement: read-only subcommand {subcmd!r} reaches an "
f"indirect dispatch (a computed callee) in {nm!r}; the walk cannot "
"prove that path does not reach save_state — fail closed")
if is_func and unresolvable:
raise Refusal(
f"readonly-complement: read-only subcommand {subcmd!r} makes an "
f"unresolvable call {unresolvable[0]!r}() in {nm!r} (a local bound from "
"no module-level function or constant — a getattr alias, a closure, or a "
"callback); a read-only handler is proved safe only when every call "
"resolves")
if "save_state" in names:
raise Refusal(
f"readonly-complement: read-only subcommand {subcmd!r} reaches "
f"save_state (through {nm}); the read-only predicate has misclassified "
"a mutating subcommand as read-only")
queue.extend(names - seen)
for name in readonly:
handler = name_to_handler.get(name)
if handler is None or handler not in functions:
raise Refusal(
f"readonly-complement: read-only subcommand {name!r} maps to handler "
f"{handler!r}, which is not a module-level function — the walk cannot begin, "
"so the classification is unproven")
analyze(name, handler)
report.append(f"readonly-complement: all {len(readonly)} read-only subcommands proved "
"unable to reach save_state through the module-level call graph")
def check_readbacks(module, registered, report):
"""The docstring's read-back enumeration vs. the dispatched `_MULTILINE_READBACKS`."""
doc = module.__doc__ or ""
if _DOCSTRING_ANCHOR not in doc:
raise Refusal("read-backs: the module docstring carries no "
f"{_DOCSTRING_ANCHOR!r} section")
section = doc.split(_DOCSTRING_ANCHOR, 1)[1]
named = {t for t in _backticked(section) if t in registered and t.startswith("query-")}
dispatched = set(module._MULTILINE_READBACKS) # noqa: SLF001
unbacked = sorted(dispatched - registered)
if unbacked:
raise Refusal("read-backs: _MULTILINE_READBACKS names "
f"{unbacked} which the parser does not register — the set the "
"emission machinery dispatches on must be a subset of the real "
"subcommand vocabulary")
missing = sorted(dispatched - named)
if missing:
raise Refusal("read-backs: the TWO-CLASS CLI CONTRACT docstring does not name "
f"{missing}, which _MULTILINE_READBACKS dispatches as multi-line. "
"The prose enumeration and the dispatched set must agree")
# The docstring↔dispatched comparison above is a prose reconciliation. Anchor the same
# guarantee on BEHAVIOR too, so the arm does not rest on documentation presence alone:
# every excluded subcommand must really be one the emitter refuses to append to, and
# `_emit_next_call` raises on an excluded name, which is the executable boundary this
# arm grades against; the complement direction — every NON-excluded subcommand really
# being one the emitter can serve — is `check_emitting_complement` below.
excluded = set(module._NEXT_CALL_EXCLUDED) # noqa: SLF001
if not dispatched <= excluded:
raise Refusal("read-backs: a multi-line read-back is missing from "
f"_NEXT_CALL_EXCLUDED ({sorted(dispatched - excluded)}) — a "
"multi-line answer would gain a trailing next_call= line")
for name in sorted(excluded):
try:
module._emit_next_call(name, None, None) # noqa: SLF001
except AssertionError:
continue
except Exception: # noqa: BLE001
raise Refusal(f"read-backs: _emit_next_call({name!r}) did not refuse the way "
"the exclusion predicate requires") from None
raise Refusal(f"read-backs: _emit_next_call accepted the excluded {name!r}; the "
"exclusion set and the emitter's own guard disagree")
unbacked_exclusions = sorted(excluded - registered)
if unbacked_exclusions:
raise Refusal(f"read-backs: _NEXT_CALL_EXCLUDED names {unbacked_exclusions}, which "
"the parser does not register")
report.append(f"read-backs: {len(dispatched)} multi-line read-backs, docstring "
"enumeration reconciled against the dispatched set, and every excluded "
"subcommand refused by the emitter's own guard")
def check_emitting_complement(module, registered, report):
"""Every NON-excluded subcommand must really be one the emitter can serve.
The complement direction, and the one whose absence let a reproducible crash ship: the
exclusion arm above walks `_NEXT_CALL_EXCLUDED` and confirms the emitter refuses each
member, which says nothing about the ~30 subcommands that are supposed to EMIT. The
emitter reads namespace fields off `args`, so a subcommand whose parser registers none
of them — `query-nonce`, which exists to recover the nonce and therefore takes no
`--nonce` — crashed with an `AttributeError` on the recovery path it exists for.
Driven off `registered_subcommands()` rather than a hand-list, so a subcommand added
later without one of those flags fails at the desk instead of in a run. The probe uses
an empty namespace: it asserts the emitter tolerates every field being ABSENT, which is
the structural property, not that any particular answer is produced.
"""
excluded = set(module._NEXT_CALL_EXCLUDED) # noqa: SLF001
emitting = sorted(registered - excluded)
if not emitting:
raise Refusal("emitting-complement: no subcommand emits next_call= at all — the "
"exclusion set covers the whole registered vocabulary, which would "
"turn the answer channel off entirely")
for name in emitting:
args = argparse.Namespace(slug="_probe795")
try:
# The probe's own `next_call=` line is captured, not printed: this guard's
# stdout IS its report (run.sh parses the figures out of it), so 30 probe
# lines would corrupt the surface being read.
with contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
module._emit_next_call(name, args, None) # noqa: SLF001
except Exception as exc: # noqa: BLE001
raise Refusal(
f"emitting-complement: _emit_next_call({name!r}) raised "
f"{type(exc).__name__}: {exc} on a namespace carrying no optional field. "
"The emitter must depend on no parser shape it does not itself check — "
"read each field with getattr(), or add the subcommand to "
"_NEXT_CALL_EXCLUDED") from None
report.append(f"emitting-complement: all {len(emitting)} non-excluded subcommands "
"tolerate an absent namespace field")
def check_round_defaulted(module, registered, report):
"""`_ROUND_DEFAULTED` must match the subcommands whose `--round` is actually optional.
The constant is declared as THE closed set and reads as authoritative, but nothing
consumed it: flipping a `--round` to `required=False` without adding the
`_require_named_round` call — the exact slip that would silently operate on the wrong
round — passed every gate. Reconcile it against the parser, a machine-consumed
contract, exactly as the read-back arm reconciles `_MULTILINE_READBACKS`.
Two halves, because the parser half ALONE does not close the slip this docstring
names. Set-vs-parser optionality says the flag may be omitted; it says nothing about
whether the handler then resolves the round. A member added to both the constant and
the parser's optional set, with the resolver call forgotten, passes the first half and
runs with `args.round is None` into round-keyed guards — the very outcome advertised
as closed. So the second half walks each member's handler source and requires an
actual `_require_named_round` / `_resolve_named_round` call.
"""
parser = module.build_parser()
optional_round = set()
for action in parser._actions: # noqa: SLF001
if isinstance(action, argparse._SubParsersAction): # noqa: SLF001
for name, sub in action.choices.items():
for a in sub._actions: # noqa: SLF001
if "--round" in a.option_strings and not a.required:
optional_round.add(name)
declared = set(module._ROUND_DEFAULTED) # noqa: SLF001
if declared - registered:
raise Refusal(f"round-defaulted: _ROUND_DEFAULTED names "
f"{sorted(declared - registered)}, which the parser does not register")
if declared != optional_round:
raise Refusal(
"round-defaulted: _ROUND_DEFAULTED and the parser disagree about which "
f"subcommands have an optional --round. Declared-not-optional: "
f"{sorted(declared - optional_round)}; optional-not-declared: "
f"{sorted(optional_round - declared)}. A subcommand whose --round became "
"optional without a _require_named_round call would silently operate on a "
"round the caller never named")
# Second half: each member's handler must actually resolve the round.
missing_resolver = []
for name in sorted(declared):
func = getattr(_subparser_of(parser, name), "get_default", lambda _k: None)("func")
if func is None:
raise Refusal(f"round-defaulted: {name!r} registers no handler to inspect, so "
"the resolver-call half cannot be established")
try:
source = inspect.getsource(func)
except (OSError, TypeError) as exc:
raise Refusal(f"round-defaulted: could not read {name!r}'s handler source "
f"({exc}), so the resolver-call half cannot be established") from exc
if "_require_named_round" not in source and "_resolve_named_round" not in source:
missing_resolver.append(name)
if missing_resolver:
raise Refusal(
f"round-defaulted: {missing_resolver} are in _ROUND_DEFAULTED with an optional "
"--round but their handlers call neither _require_named_round nor "
"_resolve_named_round, so an omitted --round reaches the round-keyed guards as "
"None instead of the resolved round")
report.append(f"round-defaulted: {len(declared)} state-defaulted subcommands, "
"reconciled against the parser's own required-ness AND against each "
"handler's actual resolver call")
def check_next_action_routing_totality(module, report):
"""Every `_NEXT_ACTIONS` member is routed by one of the two `next_call=` tables.
This is the reconciliation whose ABSENCE shipped the `dispatch-retry-same-arm` defect:
that token was in neither `_DISPATCH_ROUTE` nor `_ACTION_NOT_A_CALL`, so it fell through
to the resolver's generic tail and emitted `next-action-unestablished` while two shipped
sites documented `dispatch-arm-unestablished`. Both lines parsed and the suite stayed
green. A closed set whose totality is enforced only by comment is not enforced.
"""
actions = getattr(module, "_NEXT_ACTIONS", None)
if not actions:
raise Refusal("next-action-routing: scripts/issue-audit-state.py exposes no "
"non-empty _NEXT_ACTIONS to reconcile against")
routed = set(getattr(module, "_DISPATCH_ROUTE", {})) | \
set(getattr(module, "_ACTION_NOT_A_CALL", {}))
if not routed:
raise Refusal("next-action-routing: neither _DISPATCH_ROUTE nor _ACTION_NOT_A_CALL "
"could be read, so totality cannot be established")
unrouted = sorted(set(actions) - routed)
if unrouted:
raise Refusal(
f"next-action-routing: {unrouted} are _NEXT_ACTIONS members routed by neither "
"_DISPATCH_ROUTE nor _ACTION_NOT_A_CALL, so query-next-action answers them with "
"the generic next-action-unestablished tail instead of a decided next call")
stale = sorted(routed - set(actions))
if stale:
raise Refusal(
f"next-action-routing: {stale} are routed but are not _NEXT_ACTIONS members — "
"a renamed or removed answer token left a dead routing entry behind")
report.append(f"next-action-routing: all {len(actions)} _NEXT_ACTIONS members routed, "
"with no dead routing entry")
def check_flag_vocabulary(module, parser, registered, report):
"""Every member of the `next_call=` flag vocabularies is a REAL registered option.
`_CALLER_SUPPLIED_FLAGS` and `_NEXT_CALL_PATH_FLAGS` are matched by literal flag string
against operands the renderer is about to emit. A member that names no registered
option is not an error anything notices — it simply never matches, so the protection it
encodes is silently absent. That fails OPEN in the direction that matters: a
`_CALLER_SUPPLIED_FLAGS` entry stale after a flag rename stops suppressing the value,
and the renderer starts filling in an operand whose whole point was that the caller —
not the state — decides it. Reconciling against the parser is what turns a rename into
a red check instead of a quiet behavior change (issue #795 shadow review).
"""
option_strings = set()
for name in sorted(registered):
sub = _subparser_of(parser, name)
if sub is None:
continue
for action in sub._actions: # noqa: SLF001 - argparse exposes no public accessor
option_strings.update(action.option_strings)
if not option_strings:
raise Refusal("flag-vocabulary: no option strings could be read off any subparser, "
"so the vocabularies cannot be reconciled")
checked = 0
for vocab_name in ("_CALLER_SUPPLIED_FLAGS", "_NEXT_CALL_PATH_FLAGS"):
vocab = getattr(module, vocab_name, None)
if not vocab:
raise Refusal(f"flag-vocabulary: {vocab_name} is absent or empty, so it cannot "
"be reconciled against the parser")
unknown = sorted(f for f in vocab if f not in option_strings)
if unknown:
raise Refusal(
f"flag-vocabulary: {unknown} appear in {vocab_name} but are registered on no "
"subparser — the entry matches nothing, so the rendering rule it encodes is "
"silently not in force")
checked += len(vocab)
report.append(f"flag-vocabulary: all {checked} members of _CALLER_SUPPLIED_FLAGS and "
f"_NEXT_CALL_PATH_FLAGS are registered options")
def check_sequence(registered, report):
"""The ordered call sequence vs. the invocations the helper accepts. Returns the
unconditional joint count."""
seq_text = _read(STEP36)
paragraph = _sole_paragraph(seq_text, _SEQUENCE_ANCHOR, "sequence")
# `_invocations` REFUSES on a subcommand-shaped token that is not registered, so the
# "the prose can never name a call the tool would not accept" guarantee is genuinely
# enforced at extraction and a second `set(named) - registered` check here could never
# fire. (An earlier form merely SKIPPED such a token, which made the same sentence
# vacuous — a typo dropped the name, lowered the derived figure by one, and left the
# success line claiming "every one a registered subcommand" over prose prescribing a
# call argparse rejects. Skipping is selection, not validation.)
named = _invocations(paragraph, registered, "sequence")
for cond in _CONDITIONAL:
if cond in named:
raise Refusal(f"sequence: {cond!r} is conditional on the run's shape and must "
"not sit in the unconditional ordered sequence")
if f"`{cond}`" not in seq_text:
raise Refusal(f"sequence: {cond!r} is no longer named anywhere in "
"step-3-6-audit.md, so its conditional status is unstated")
# The joint scope: step-4's own mandated calls that the sequence attributes to it.
step4 = _read(STEP4)
if "query-draft-binding" not in step4:
raise Refusal("sequence: step-4-present-create.md no longer mandates the "
"query-draft-binding re-detect the sequence's joint scope counts")
report.append(f"sequence: {len(named)} unconditional invocations jointly mandated, "
"every one a registered subcommand")
return len(named)
def main():
report: list[str] = []
try:
module = _load_module()
registered = module.registered_subcommands()
check_readbacks(module, registered, report)
check_readonly_complement(module, registered, report)
check_emitting_complement(module, registered, report)
check_round_defaulted(module, registered, report)
check_next_action_routing_totality(module, report)
check_flag_vocabulary(module, module.build_parser(), registered, report)
unconditional = check_sequence(registered, report)
except Refusal as exc:
sys.stderr.write(f"check-audit-lifecycle-contracts: {exc}\n")
return 1
# The measurement figure, DERIVED — never hand-transcribed. Reported on the SUCCESS
# path so a passing suite carries the evidence rather than only a failure message.
report.append(f"unconditional_call_count={unconditional}")
report.append(f"registered_subcommand_count={len(registered)}")
for line in report:
print(line)
return 0
if __name__ == "__main__":
raise SystemExit(main())