Repository navigation
Read-position run: judge calls and member/offset reads at every position they are written #1050
Description
Parent issue for the read-position run, from the #895 triage and the walker run's split. It covers #935, #936 and #949. The architect's design notes are collapsed below; they include root causes R1–R7, witness tables, slice file lists and risks.
Root causes, in short
- Existence, arity, inaccessible and on-null checks. These checks see only the call that the statement kind spells (
checkable_calls). A call nested anywhere else is never judged, and itsArgValue::Callcarries no span (R1, R2). - Member and offset reads. These are judged only in
AssignandReturnvalues (R3). The lowering already scans every expression in those positions and drops the data (R4). - Headers. Header conditions reach the IR only as escape data (R5).
- First-class callables. These are refused by design (R6).
print(expr);. This lowers to aBarrier(R7).
Sibling default-surface FPs found while witnessing (fixed first, in S0b–S0d)
- A property created through a by-ref argument,
settype,preg_match, or a destructure pattern, and then read, reportsproperty.undefined. - A
defined('C::K')guard does not dischargeclass-const.*. - A dead
elseifcondition is judged. - A destructure's call source is not a checkable call: [$a, $b] = f($bad) skips the argument families #297: a call in a destructure is not checkable.
Decisions
- D1 (diagnostic column): the read's own span, with one conscious fp-gate reseed. ADR-level; awaits the owner.
- D2 (
@): silence at the default floor, with a strict twin left open. - D3 (conditional positions): env-independent families judge there; value-dependent families decline.
- D4: headers are in this run.
- D5 (S5, by-ref oracle for method and ctor arguments): decide after S3's A/B.
ADR
ADR-0104 "A read is judged at every position it is written, through its carrier", written with S1. ADR-level; awaits the owner.
Slices
- S0b — property-creating argument,
&and destructure positions are write obstacles (steins-syntax: argument, reference and destructure positions that can create a property count as writes for property.undefined (#1050 S0b) #1054; private gate identical) - S0c —
defined('C::K')folds against class-constant reach and visibility (steins-infer: defined('C::K') folds against the class-constant reach and the scope's visibility (#1050 S0c) #1053; private gate identical) - S0d — A destructure's call source is not a checkable call: [$a, $b] = f($bad) skips the argument families #297, a destructure call is checkable (steins-infer: a call in a destructure statement is checkable (#297, #1050 S0d) #1052; private gate +9 phpdoc, +1 possibly, all true: caller-seeded descents now reach destructure sources)
- S1 — IR:
Stmt.read_positionswith carriers (byte-identicalcheck), plus ADR-0104 (steins-syntax: a statement lists each read its expressions write, at the read's own span with the construct that carries it (#1050 S1, ADR-0104) #1062; first-class callables are their own kindCallRef; everything past?->is Conditional; append bases are writes; phpunit peak RSS about +3%) - S2 — The existence and arity families judge only statement-position calls: a call nested in an argument, operand, condition or first-class callable is skipped #935, nested calls are judged at every position; FCC existence;
print(steins-infer: a nested call is judged at every position it is written, and a first-class callable is an existence site (#935, #1050 S2) #1066; stored guards; private +7 proof TPs pinned) - S3 — class-const.undefined and class-const.inaccessible fire only when the fetch is a whole assignment or return value #936/Offset reads are judged only as a whole assignment or return value: echo, operands, arguments and nested reads are skipped #949, member and offset reads are judged at every position (steins-infer: member, offset and class-constant reads are judged at every position, at the read's own span (#936, #949, #1050 S3) #1069; private reseed +51 possibly at nested positions, 4 proof class-const TPs)
- S4 — headers carry read positions; a dead
elseifcondition is not judged (steins-infer: reads in control-flow headers are judged where the walk evaluates them, and a condition after a decided-true one is dead (#935, #936, #949, #1050 S4) #1077; private +12 possibly in headers) - S5 — optional, per D5 — deferred (see comment)
Architect's design notes
Read-position run — tables (master d543c7c, PHP 8.5.11)
Base binary: scratchpad/i1048-base/target/release/steins (steins --version → revision d543c7c,
built from the clean worktree i1048-base/wt). Probes: scratchpad/fable-rp-probes/w*/*.php;
PHP witnesses by scratchpad/fable-rp-phpwit.sh <file> (one process per function, handler installed).
Every "today" cell below is that binary's output on check --no-cache (default; strict adds nothing
for these ids except untyped.*). All line numbers are d543c7c.
0. Root causes (verified)
| # | Mechanism | Where | Effect |
|---|---|---|---|
| R1 | The existence/arity/inaccessible-method/on-null checks iterate checkable_calls, which returns only the CallExpr the statement kind spells: Call, Assign.call, Return.call, PropAssign.value_call, Echo's operand calls |
crates/steins-infer/src/descent/call_sweep.rs:16-24; consumer crates/steins-infer/src/stmt_calls.rs:74-200 |
a call nested in an argument, operand, array element, new argument, throw/exit operand, ctor arg, named-arg value, literal spread, instanceof lhs, condition, foreach subject, static initializer is never judged (#935 s2–s11, s13–s20, s25, s29; w0d th/th2/ex/ar/ne/inst/spread/named) |
| R2 | A nested call survives only as ArgValue::Call(String, Vec<ArgValue>) / ArgValue::MethodCall{callee,args,named} — no span, no callee_ref, no arg spans, no positional_only |
crates/steins-syntax/src/ast.rs:1403,1423; built at lower_expr.rs:559-600, 606-614 |
even a consumer that descended ArgValue could not emit at the call or run arity |
| R3 | Member/offset reads are judged by pattern-matching Assign{value: X} / Return{value: X} for X ∈ {OffsetRead, PropFetch, ClassConst, Coalesce} (+ Destructure.source) |
crates/steins-infer/src/stmt_checks.rs:45-47, 70-71, 81-82, 103-119 |
echo, operand, argument, cast, interpolation, comparison, array element, ?: lhs, nested level, compound read-half are silent (#936 c2–c8, c12–c20, c23; props p2,p3,p8–p11,p13–p15,p20,p22,p25,p28? (p28 return $c->nope is reported); #949 r2–r5, r10–r19, r24–r28, r33, r34, r36) |
| R4 | The lowering already walks every expression of the four entry-env statement kinds for string_contexts (lower_stmt.rs:750-779, 796-841), guards (lower_guards.rs:28-50), invalidated (lower_stmt.rs:1983-2034), runs, hoisted matches (lower_stmt.rs:1164-1191) — but none of these records read positions with spans |
— | the data exists at lowering; it is dropped |
| R5 | Header expressions (if/elseif/while/for/do-while cond, foreach subject, match/switch subject) are lowered to CondExpr/CondOperand; a call that is the operand survives as CondOperand::Other{call}/CondExpr::Call (ast.rs:2853-2857, 2931), used for escape/sweep only (branch.rs:97-105) |
lower_stmt.rs:974-990 (Foreach.subject: Option<String>), branch.rs:62-93 |
header calls/reads are never judged; a decided-true if marks only bodies dead (branch.rs:84-93), not later conditions |
| R6 | First-class callables: named_call has no PartialApplication arm (lower_stmt.rs:850-872), and both ladders refuse the shape on purpose: leg (l) absence.rs:67-72, leg (d) absence.rs:1117-1122 |
— | $c = missing_fn(...), $f = $c->gone(...), C::gone(...) silent; PHP throws at creation (witnessed s10, s11, m4, m8) |
| R7 | print(expr) as a statement falls to _ => Barrier (lower_stmt.rs:1851) |
— | env cleared after every print (w0d pr: dumpType($x) → unknown) and the operand's calls unjudged |
1. PHP semantics table: carriers (witnessed, PHP 8.5.11, handler installed)
| Carrier (innermost construct around the read) | offset $a['nope'] |
property $c->nope |
private const A::P / undefined A::NOPE |
call nope_fn() / $c->gone() |
|---|---|---|---|---|
Plain operand (+, ., ===, !, -, cast, interpolation, echo, print, return, = rhs, array element/key, new arg, throw/exit operand, instanceof lhs, yield, match subject, ?: lhs) |
warns (r2–r5, r10–r16, r24–r27, r33, r36) | warns (p2, p8, p9, p13–p15, p22, p25) | Error (c2–c5, c9, c12–c20, c23) | Error (every silent row of #935) |
Argument, by-value callee (strlen, strtoupper) |
warns (r28, r42) | warns (p3) | Error (c12) | Error (s2, s7, s13, s14) |
Argument, by-ref callee (byref(&$v), sort, settype, preg_match) |
silent — key created (r9; r31 is a later TypeError) | silent — property created, deprecation only (p7, h1, h7, h8) | n/a (not an lvalue) | Error (a call is always evaluated) |
isset/empty/?? lhs/unset/??= lhs |
silent (r6, r7, r8, r20, r23) | silent (p4, p5, p6, p26 private, p27 private) | Error — A::NOPE ?? 1, A::P ?? 1 throw; isset(A::X) is a compile error |
n/a |
?? final arm |
today's ladder (r43 silent when lhs settles) | same | Error | Error |
&&/` |
/?:` rhs, ternary arm, under an undecided test |
may run (r34 warns) | may run | |
…under a decided guard (function_exists, defined, method_exists, property_exists, class_exists, version cmp) |
dead arm silent (r29, r30 by isset) |
dead arm silent (p12, p24) | dead arm silent (c10, c11) | dead arm silent (s21–s24, m10, m11) |
@ operand |
warns, and the handler runs (error_reporting()=4437 inside it; r32, p23) |
same | n/a | n/a |
compound lhs (++, .=, +=) |
warns (r17–r19; A7 "whitelist-eligible") | warns + deprecation (q17, q18) — silenced project-wide by the write obstacle | n/a | n/a |
write lhs, & operand, foreach/destructure target |
silent | silent (deprecation only) | n/a | n/a |
first-class callable f(...) / $o->m(...) / C::m(...) |
n/a | n/a | n/a | Error at creation (s10, s11, m4, m8) |
same statement, base passed by-ref before the read (f2($a) . $a['nope']) |
silent (r41) | silent | n/a | n/a |
same statement, read before the by-ref pass ($a['nope'] . f2($a)) |
warns (r40) | warns | n/a | n/a |
header: if/while/match subject/foreach subject |
warns (r14, r37, r12, r22) | warns (p13, p20) | Error (c14, c15) | Error (s3, s8, s25) |
static $x = f() |
— | — | — | Error (s9) |
| closure / arrow body | own scope (r39 reports via the closure's own trace) | own scope | own scope | own scope (s27 reported today) |
2. The rule (full table, by family class × position class)
Family classes:
- E (env-independent):
call.undefined-function,call.undefined-method(receiver class from the entry store; a test cannot change an allocation's class),call.inaccessible-method,phpdoc.undefined-method(declared lane; conservative without in-statement narrowing),call.too-few-arguments,call.unknown-named-argument,call.printf-too-few-arguments(literal format only),class-const.undefined,class-const.inaccessible,property.inaccessible. - V (env/value-dependent):
offset.missing,offset.on-unsupported,offset.undeclared,offset.maybe-missing,property.undefined,property.maybe-undefined,property.on-non-object,call.on-null,call.on-non-object,preg.invalid-pattern(folds the pattern from env). - Not positional: type families (reach nested calls through the value-lane descent already — s28 reports at 33:31), dumps/asserts, discarded-call (statement only), descents/summaries, escape/sweep (
escape_nested_callsalready recurses).
| Position class | E | V |
|---|---|---|
Plain, entry-env statement kinds (expression stmt incl. throw/exit/print, return, echo, <?=) |
judge | judge, unless the read's base name has an InvalidatedVar entry in this statement that is opaque or not provably by-value (by_value_survivors) — decline (covers r40/r41 soundly, loses r40) |
| Argument position (the read is an argument) | judge (a call) | judge iff arg_is_by_value(callee, pos); unresolved/method/ctor callee → decline (A7 unchanged; S5 extends the oracle) |
Conditional (ternary arms, ?: rhs, &&/` |
/and/orrhs,??` rhs non-final) |
|
isset/empty/?? lhs/unset/??= lhs operand |
n/a | silence for offsets and properties; not a carrier for class constants (Error) — judge E there |
@ operand |
judge (an Error is not suppressible) | owner D2; recommend silence at the default floor |
| compound lhs read-half | n/a | offsets: judge (A7); properties: judged but the write obstacle silences by construction — row documented, no code |
write / & / foreach / destructure target |
n/a | silence |
| first-class callable | existence only (call.undefined-function/-method, class.undefined already covers the class); not arity/type/descent/no-effect/dumps |
n/a |
hoisted structured match arm |
the arm's own trace (today) | same |
refused (unstructured) match arm, switch case body that is Opaque |
silence (first-match: not a position the walk can claim is reached) | silence |
| closure/arrow/anonymous-class/class body | own scope | own scope |
static $x = <expr> |
judge (scope is poisoned anyway) | decline (poisoned) |
header: if cond, while cond, match/switch subject, foreach subject (first evaluation = entry env) |
judge at evaluation (S4) | judge at evaluation (first iteration is in the env the walker holds) |
header: elseif cond |
judge at evaluation; a cond after a decided-true earlier branch is dead (fixes w0b e1 for constant.undefined/class.undefined too) |
decline (conditional) |
header: for cond (after init), do-while cond (after body) |
judge at evaluation with the env the walker has there (S4) | decline in S4 (sound; precision later) |
| declaration constant expressions (param default, property default, const/enum initializer) | out of this run (own root: evaluated at declaration/first use, no walk) | n/a |
| attribute arguments | never (evaluated only on reflection) | never |
Double-judging guard: the lowering emits every position (uniform, testable); the walk builds
judged_calls = checkable_calls ∪ positions deduped by CallExpr.span, and check_read_positions
iterates positions only (the Assign/Return arms are deleted), so one mechanism remains.
3. Per-issue witness tables (today = d543c7c)
#935 functions (w935/w935.php)
| fn | PHP | today | after |
|---|---|---|---|
s1 nope_fn(); |
Error | 4:23 | same |
s2 id(nope_fn()), s7 id(two(1)), s13 echo strlen(nope_fn()), s14 return_me(nope_fn()), s15 return id(two(1)), s16 return two(1)+1, s4 nope_fn()+1, s5 [nope_fn()], s29 [two(1)], s17 (string)nope_fn(), s18 "a".nope_fn(), s20 nope_fn() ?? 1 |
Error / ArgumentCountError | silent | S2 reports at the nested call |
s19 nope_fn() ? 1 : 2 (cond is Plain) |
Error | silent | S2 |
s21–s24 (function_exists/isset guards) |
ok | silent | silent (dead extent / conditional) |
s3 if (nope_fn()), s8 foreach (nope_fn() as …), s25 while (nope_fn()) |
Error | silent | S4 |
s9 static $x = nope_fn(); |
Error | silent | S2 (static initializer row) |
s10 missing_fn(...), s11 (new X)->nope(...) |
Error at creation | silent | S2 (FCC row) |
s12 echo nope_fn(), s26 match arm, s27 arrow body, s28 type nested |
— | 16:29, 31:54, 32:37, 33:31 reported | unchanged (dedupe) |
w0d: print(nope_fn()), throw new E(1) (ctor arity), throw nope_fn(), exit(nope_fn()), [nope_fn() => 1], new E(nope_fn(), 2), nope_fn() instanceof E, strlen(...[nope_fn()]), two(b: nope_fn(), a: 1) |
Error | silent (print also clears the env: 3:67 debug.type: unknown) |
S2 |
#935 methods (w935/w935m.php)
| fn | PHP | today | after |
|---|---|---|---|
m1 $c->gone();, m5 echo $c->gone() |
Error | 3:37, 7:42 | same |
m2 strlen($c->gone()), m3 [$c->gone()], m7 $c->gone()+1, m12 (new C)->gone()+1 |
Error | silent | S2 |
m6 if ($c->gone()) |
Error | silent | S4 |
m4 $c->gone(...), m8 C::gone(...) |
Error at creation | silent | S2 (FCC) |
m9 $c->m(1, 2) (extra args), m10/m11 method_exists guards |
ok | silent | silent (m10/m11: the guard folds No → dead extent; pin) |
#936 class constants (w936/w936.php) — today only c1 (3:22) and c21 (24:24)
| rows | PHP | after |
|---|---|---|
c2 echo A::NOPE, c3 1 === A::NOPE, c4 +1, c5 [A::NOPE], c8 ?? 1, c9 === null, c12 strlen(A::NOPE), c13 concat, c16 ? : cond, c17 cast, c18 array key, c23 !A::NOPE |
Error | S3 |
c6 echo A::P, c19 A::P + 1, c20 A::P === 2 |
Error (private) | S3 (class-const.inaccessible) |
c7 $s === Suit::Clubs (return operand, comparison) |
Error | S3 |
c14 if (A::NOPE), c15 match (A::NOPE) |
Error | S4 |
c10 defined('A::NOPE') ? A::NOPE : 1, c11 defined(…) && A::NOPE |
ok | silent, via S0c (today defined('A::X') folds Maybe, see w0c) |
c22 echo A::class |
ok | silent (::class excluded at collection) |
#936 properties (w936pr/reads.php) — today p1 (3:38), p16 (17:38), p28 return $c->nope (25:44)
| rows | PHP | after |
|---|---|---|
p2 echo, p8 interpolation, p9 +1, p14 cast, p15 […], p22 $c->nope['k'] (outer read), p25 === null |
warns | S3 |
p10 echo $c->priv, p11 $c->priv + 1 |
Error (private) | S3 (property.inaccessible) |
p3 strlen($c->nope) |
warns | S3 if arg_is_by_value (builtin: yes) — but see S0b: with the conservative obstacle, nope passed as an argument anywhere becomes an obstacle, so this row is silence until S5 |
p4 isset, p5 ??, p6 empty, p26 $c->priv ?? 1, p27 isset($c->priv) |
ok | silent (carrier) |
p12 property_exists(…) ? :, p24 isset(…) && … |
ok | silent (decided guard / conditional) |
p13 if ($c->nope), p20 foreach ($c->nope …) |
warns | S4 |
p23 @$c->nope |
warns, handler runs | owner D2 |
#949 offsets (w949r/reads.php) — today only r1 (3:40)
| rows | PHP | after |
|---|---|---|
r2 echo, r3 strlen((string)…), r4 $a["n"]["zz"] (nested level: base $a["n"] resolves through the same fact), r5 1 + …, r10 concat, r11 interpolation, r13 cast, r15 ? : cond, r16 […], r24 -, r25 !, r26 === 1, r27 return … + 1, r33 $a["nope"]["deeper"] (outer level), r36 ?: 1 lhs |
warns | S3 |
r17 ++, r18 .=, r19 += |
warns (A7) | S3 (compound read-half: the OffsetWrite/Barrier statement kinds carry the position) |
r28 strlen($a["nope"]), r42 |
warns | S3 (arg_is_by_value builtin) |
r9 byref($a["nope"]), r31 sort($a["nope"]) |
silent / TypeError | silent (by-ref carrier) |
r6 isset, r7 ??, r8 empty, r20 ??=, r21 &, r23 unset, r35 array_key_exists |
ok | silent |
r29, r30 (decided isset guards), r34 count($a) > 0 && $a["nope"] (undecided) |
ok / warns | silent (conditional, V family) — r34 is a known false negative, recorded |
r40 $a["nope"] . f2($a) / r41 f2($a) . $a["nope"] |
warns / ok | both silent (same-statement by-ref rule); r40 recorded as a false negative |
r12 match (…), r14 if, r22 foreach, r37 while |
warns | S4 |
r32 @$a["nope"] |
warns, handler runs | owner D2 |
r43 $a["k"] ?? $a["nope"] |
ok | silent (today's coalesce_arm_settles) |
Sibling holes found while witnessing (all default-surface, all on d543c7c)
| probe | PHP | Steins today | root | slice |
|---|---|---|---|---|
w0a/holes.php h1 byref($c->viaref); $x = $c->viaref; |
silent (property created by ref) | property.undefined 4:56 — FP |
property_writes collects assignment/inc/foreach lvalues only (lower_decl.rs:224-238); call-argument positions, &$o->p operands and destructure targets (tree.rs:783-796 does not recurse into array patterns) are not writes |
S0b |
h7 settype($c->st, "int"); $w = $c->st;, h8 preg_match(…, $c->pm); $w = $c->pm; |
silent | FP 9:61, 10:69 | same | S0b |
w0c/defined.php d4 if (defined('A::P')) { return A::P; } (P private) |
ok — defined() returns false for an inaccessible constant |
class-const.inaccessible 6:52 — FP |
constant_defined_verdict (existence.rs:333-346) knows only global constants; a C::K spelling answers Maybe |
S0c |
d5 if (defined('A::NOPE')) { $x = A::NOPE; … }, d6 if (!defined('A::NOPE')) return 0; $x = A::NOPE; |
ok | class-const.undefined 7:48, 8:61 — FP |
same | S0c |
w0b/elseif.php e1 if (true) {…} elseif (NOPE_CONST) {…} |
ok (cond never evaluated) | constant.undefined 2:53 — FP |
walk_if marks only bodies dead (branch.rs:84-93); elseifs: Vec<(CondExpr, Vec<Stmt>)> carries no cond span |
S4 (needs the header record) |
w0d/misc.php pr print(nope_fn()); dumpType($x) |
Error | debug.type: unknown 3:67 (env cleared), call unjudged |
print → Barrier (lower_stmt.rs:1851) |
S2 |
#297 [$a, $b] = f($bad) |
— | call not in checkable_calls |
call_sweep.rs:16-24 lacks Destructure |
S0d |
4. Slices
| Slice | Issue(s) | Files | Depends on | Parallel with |
|---|---|---|---|---|
| S0 (no PR) | baselines | public strict+default JSON, effect-diff --set-baseline, the three dry-run transforms, cargo xtask nsrt, cargo xtask perf corpus/guzzle__guzzle --no-php --bless, private fp-gate under lockf with --deadline |
— | — |
| S0b | new issue: property-creating argument/reference/destructure positions are write obstacles | crates/steins-syntax/src/lower_decl.rs (collector arms for call-argument property fetches, & operands, foreach by-ref targets), crates/steins-syntax/src/tree.rs (push_lvalue recurses into Array/List/LegacyArray patterns), tests crates/steins-infer/tests/it/member_absence.rs; no shard type change ((HashSet<String>, bool) keeps its type, so no SCHEMA_VERSION bump; verify shard.rs:149,470) |
— | S0c, S0d, S1 |
| S0c | new issue: defined('C::K') folds against the class-constant reach and the scope's visibility |
crates/steins-infer/src/existence.rs (constant_defined_verdict gains a :: arm: No when absent from enumerate_const_reach with a clear A2i/A2ii, or declared but private_invisible/protected-blocked from w.scope's class; Yes when declared, accessible and unconditionally declared; else Maybe), tests existence_guard.rs, reference_suite_pins.rs, inaccessible_members.rs |
— | S0b, S0d, S1 |
| S0d | #297 | crates/steins-infer/src/descent/call_sweep.rs:16-24 adds Destructure { call: Some(c), .. }; tests destructure_source_read.rs, arity.rs; the walker arm (walk.rs:661-665) ignores the summary, so parity with Assign is the only change |
— | S0b, S0c, S1 |
| S1 | IR | crates/steins-syntax/src/ast.rs (ReadPosition { kind: Call(CallExpr) | Offset{base,key} | Prop{var,prop} | ClassConst(sc,name), span, carrier: Carrier }, Carrier::{Plain, Argument{callee: Option<NameRef>, position}, Conditional, IssetLike, Suppressed, Write, CompoundRead, FirstClassCallable}, Stmt.read_positions), new crates/steins-syntax/src/lower_read.rs (one CST scan over the four entry-env statement kinds + Statement::Static; stops at function-likes/class-likes and at a hoisted structured match; mirrors scan_string_contexts's boundary), lower_stmt.rs central fill, docs/internal-spec/trace-ir.md, syntax tests (new read_positions.rs). Byte-identical check output (no consumer). Trace payloads need no SCHEMA_VERSION bump (decoded past the gate; analyzer-version hash refuses old generations) |
— | S0b, S0c, S0d |
| S2 | #935 (+ FCC, print, static, ctor/throw/exit/named/spread positions) |
crates/steins-infer/src/stmt_calls.rs (iterate judged_calls, E/V split per position class), absence.rs (legs (l) :67-72 and (d) :1117-1122 flip: an FCC is an existence site; the comment goes), arity.rs (FCC stays declined — the shape is argument-less), crates/steins-syntax/src/lower_stmt.rs:1851 (print → Echo-like with its calls and invalidation), tests undefined_function.rs, undefined_method.rs, arity.rs, inaccessible_members.rs, on_non_object.rs, reference_suite_pins.rs, changelog changed/ |
S1 | S3 (different functions in absence.rs; otherwise disjoint) |
| S3 | #936, #949 | crates/steins-infer/src/stmt_checks.rs (iterate positions; delete the Assign/Return arms), offsets.rs (span = the read; doc block §A7 rewrite), absence.rs (property/const doc), inaccessible.rs, non_object.rs, tests offset_family.rs, shape_strict_leg.rs, shape_reads.rs, member_absence.rs, inaccessible_members.rs, on_non_object.rs, coalesce_value.rs, destructure_source_read.rs, changelog |
S1 (and D1) | S2 |
| S4 | headers (#935 s3/s8/s25, m6; #936 c14/c15; #949 r12/r14/r22/r37; the dead-elseif-cond FP) |
ast.rs (Header { span, read_positions } on If.cond/each elseif, While, For, DoWhile, Foreach.subject, Match.subject, switch-true labels), lower_stmt.rs (fill), branch.rs (walk_if judges each cond's positions where it evaluates it and mark_dead_spans the conds after a decided-true one; walk_match subject), loops.rs (while/for/do-while/foreach), tests branch_analysis.rs, loop_bodies.rs, match_switch.rs, constant_undefined.rs, class_undefined.rs |
S1, S2, S3 | — |
| S5 (optional, decide after S3's A/B) | by-ref evidence for method/static/ctor arguments; unlocks the "declined" argument rows for properties/offsets, ADR-0070 survival through method calls, and #947 d/e/g | ast.rs (InvalidatedVar.sites carries a Callee-shaped site), lower_stmt.rs:1983-2034, by_value.rs (arg_is_by_value resolves an exact-receiver method / named ctor through resolve_call_target with the store), walk.rs by_value_survivors callers, S0b's obstacle reads the same oracle (shard type change → SCHEMA_VERSION 26 + generation-schema.md row) |
S1–S3 | — |
Landing order: S0 → {S0b, S0c, S0d, S1} (file-disjoint, any order) → S2 ∥ S3 → S4 → (S5).
5. Expected movement and exposure
Public corpus today (head JSONs scratchpad/i1037-*-head-*.json, 2026-10-11 00:49, 10 packages):
default total 31 findings, strict 17,185; of the affected ids: default call.on-non-object 1; strict
offset.maybe-missing 2 (composer, console), call.on-non-object 1; every other affected id 0.
So the public A/B is a non-regression check; the libraries run, so new TPs are expected to be rare
and each one is witnessed on PHP. Column moves under D1 are classified "same finding, moved to the read".
Exposure (rough rg over corpus/, 6,670 files): class-constant fetches 4,988 of which 417 are
whole-assignment/return (the whitelist covers ~8%); offset reads ~6,456 of which 619 whole
(~10%); lines with a call nested in an argument ~64k; first-class callables 72; if conditions with
a call 9,121; foreach subjects with a call 214; echo/print with a call/member 63.
Private fp-gate: the only place volume shows. Run per slice with --deadline, lockf -k, and report
counts only; the fp-gate key includes the column (xtask/src/gate.rs:887), so D1 forces one reseed.
Reference suites: re-run the mago/mir fixture sets named in #935/#936/#949 per the survey memo
(~/.claude/projects/.../memory/steins-reference-suite-survey.md); every fixture that flips lands as a
row in reference_suite_pins.rs.
6. Soundness risks, by slice
- S0b: recall loss for property names ever passed as an argument (conservative obstacle). Measured:
property.undefinedfires 0 times on the public corpus; private count before/after. - S0c: a
Yesfold marks the!defined()branch dead — only when the class and constant are unconditionally declared and the dam is clear (A2i/A2ii legs reused). Risk otherwise nil (today isMaybe). - S1: lowering cost — a sixth per-statement CST scan in a crate that is 45% of wall time (
docs/agents/profiling.md); measurecargo xtask perf corpus/guzzle__guzzle --no-php; if cold analyze moves >3%, merge the scan intoscan_string_contexts(same boundary) before S2. - S2: (a) volume — nested positions are ~10× statement positions, so any extension the analyzing PHP lacks (
mb_*,ctype_*) now fires ~10× morecall.undefined-function; same posture as today (ADR-0049 A6/A2ii,[runtime]declarations), different volume — the private corpus decides whether a[runtime]nudge belongs in the message; (b) conditional positions under an undecided test are judged (D3) — the same rule as anifbody; the guard sweep's dead extents andmark_dead_operandmust be consulted per position; (c) FCC: arity/type/descent/no-effect must never see one — tests pin every consumer ofcheckable_calls; (d) dedupe by span, orecho nope_fn()reports twice. - S3: (a) a Conditional position must decline for V families even when the test does not mention the place (simplest sound rule; r34 is a recorded false negative); (b) the same-statement by-ref rule reads
stmt.invalidatedthroughby_value_survivors— the opaque entry (method call anywhere in the statement) declines every V read of that base; (c) D1 moves every existing finding's column; (d)property.inaccessibleatisset/??/emptyis silent in PHP (p26, p27) whileclass-const.inaccessibleis an Error there (A::P ?? 1) — the carrier table is per family, not per construct. - S4: judging a header against the entry env is sound only for the first evaluation (
while), the entry (if,match/switchsubject,foreachsubject), after init (for) and after the body (do-while); each walker already holds that env;elseifconds are Conditional for V. - S5: a wrong by-value answer for a method would let a by-ref created key/property be reported — the oracle refuses anything but an exact-receiver, non-overridable resolution (
final/private or exact allocation with a complete descendant closure).
Activity
zonuexe commentedon Oct 11, 2026
Owner decision 2026-10-11: D1 adopted — a member/offset read's finding sits at the read's own span (ADR-0104); the column move gets one conscious fp-gate reseed.
steins-syntax: an argument position adds named property writes only, …
steins-infer: defined('C::K') answers No or Yes for a non-public cons…
steins-infer: a call that is a destructure's source is a checkable ca…
steins-syntax: argument, & and destructure positions that name a prop…
steins-syntax: an argument position adds named property writes only, …
steins-infer: defined('C::K') folds against the class-constant reach …
steins-infer: defined('C::K') answers No or Yes for a non-public cons…
steins-infer: defined('C::K') folds against the class-constant reach …
steins-infer: defined('C::K') answers No or Yes for a non-public cons…
steins-syntax: argument, & and destructure positions that name a prop…
steins-syntax: an argument position adds named property writes only, …
steins-syntax: argument, & and destructure positions that name a prop…
steins-syntax: an argument position adds named property writes only, …
steins-syntax: a statement lists every call, offset, property and cla…
44 remaining items
steins-infer: a call is judged at every position it is written, exist…
docs: ADR-0049 names a first-class callable an existence site, and AD…
steins-infer: a guard assigned before its connective, or stored in a …
steins-infer: a stored guard keeps its polarity, joins by shape, reac…
docs: ADR-0104 records the stored-guard rules, the PHPUnit RSS figure…
zonuexe commentedon Oct 14, 2026
S5 (by-ref evidence for method/static/ctor arguments) is deferred per D5 after S3's A/B: on the public corpus the method/ctor argument declines cost no finding, and on the private corpus S3's +51/+4 movement came from other positions. Revisit if a corpus shows a by-ref-method decline hiding a real finding.
steins-syntax: the alternatives after the first of a match arm or a s…
steins-infer: branch conditions, loop headers and switch subjects car…
docs: ADR-0104 records the S4 header amendment, and the trace IR spec…
steins-syntax: the alternatives after the first of a match arm or a s…
steins-infer: branch conditions, loop headers and switch subjects car…
docs: ADR-0104 records the S4 header amendment, and the trace IR spec…
steins-syntax: the alternatives after the first of a match arm or a s…
zonuexe commentedon Oct 14, 2026
Run complete
&and destructure positions are write obstaclesdefined('C::K')folds against class-constant reach and visibilityStmt.read_positionsIR with carriers, ADR-0104 (40-byte positions, phpunit RSS +2.6%)printThe owner decision D1 (findings at the read's own span) landed with one conscious reseed of the private ledger.
Follow-ups filed during the run: #1065 (polynomial residue, including
newchains), #1067 (anonymous-class constructor arguments; static inaccessible at conditional positions), #1068 (by-ref alias across statements and implicit__toString), #1072 (guards do not reach closures in the guarded arm), #1079 (optional-key narrowing byempty()/=== []/?? null).