1- """Assert the S0 `--fix-candidates` extractor metadata on FixCandidatesSample.cs .
1+ """Assert the S0 `--fix-candidates` extractor contract at the fact level .
22
33Not a ``test_*`` (it needs the C# extractor to produce the facts, so CI runs the
4- extractor first and passes the JSON path). Encodes the Part-A extractor contract
5- at the fact level; exits non-zero on any violation.
4+ extractor first and passes the JSON paths). Exits non-zero on any violation.
65
76Usage:
87 python tests/check_fix_candidates_facts.py <fix_on.json> [<off.json>]
8+ python tests/check_fix_candidates_facts.py --additive-sections <on.json> <off.json>
99
1010 fix_on = FixCandidatesSample.cs scanned WITH --fix-candidates
1111 off = the SAME sample WITHOUT the flag (optional; asserts NO fix metadata leaks)
12+
13+ The first form checks the metadata itself on FixCandidatesSample.cs. The second
14+ checks the other half of the contract — the flag only ADDS — on
15+ tests/fixtures/fix_candidates/AdditiveSections.cs scanned with `--flow-locals`,
16+ flag on and flag off:
17+
18+ flag-on minus the S0 fields == flag-off (the whole document)
19+
20+ and refuses a pair that does not exercise every top-level section the extractor
21+ can write (read off the extractor's own envelope), so that the equality cannot
22+ be satisfied by a document with nothing in it. It was: the first form's sample
23+ has subscriptions and no other section, and a flag-on envelope that dropped
24+ `orphaned_awaitables` — every OWN053 site — passed it for as long as it existed.
1225"""
1326
1427from __future__ import annotations
1528
1629import copy
1730import json
1831import os
32+ import re
1933import sys
2034
2135sys .path .insert (0 , os .path .join (os .path .dirname (os .path .abspath (__file__ )), ".." ))
2236
23- from ownlang .ownir import OWNIR_VERSION
37+ from ownlang .ownir import OWNIR_VERSION , OwnIRError , check_facts
38+
39+ _ROOT = os .path .join (os .path .dirname (os .path .abspath (__file__ )), ".." )
40+ # The ONLY top-level field S0 adds. Everything else under --fix-candidates lives inside
41+ # `components[]` (see `_strip_additive`); a second top-level S0 field is a contract change
42+ # and belongs here, in the open.
43+ _ADDITIVE_TOP_LEVEL = ("fix_candidates_version" ,)
44+ _EXTRACTOR = os .path .join (_ROOT , "frontend" , "roslyn" , "OwnSharp.Extractor" , "Program.cs" )
2445
2546_ADDITIVE_COMPONENT_KEYS = (
2647 "qualified_name" ,
3253
3354
3455def _strip_additive (facts : dict ) -> dict :
35- """The flag-ON facts with every S0-additive field removed."""
56+ """The flag-ON facts with every S0-additive field removed — and nothing else.
57+
58+ This is the whole list of what `--fix-candidates` is allowed to change. It is an
59+ ALLOWLIST of S0's own fields, so the comparison it feeds is over the complete
60+ document: a section the flag drops, reorders or rewrites is a difference, whether
61+ or not anyone thought to name that section in a test."""
3662 f = copy .deepcopy (facts )
37- f .pop ("fix_candidates_version" , None )
63+ for k in _ADDITIVE_TOP_LEVEL :
64+ f .pop (k , None )
3865 for c in f .get ("components" , []):
3966 for k in _ADDITIVE_COMPONENT_KEYS :
4067 c .pop (k , None )
@@ -62,6 +89,111 @@ def _fixes(facts: dict, name: str) -> list[dict]:
6289 return [s ["fix" ] for s in (comp .get ("subscriptions" ) or []) if s .get ("fix" )]
6390
6491
92+ def _additivity_problem (on : dict , off : dict ) -> str | None :
93+ """`flag-on minus the S0 fields == flag-off`, or what differs."""
94+ stripped = _strip_additive (on )
95+ detail = []
96+ for k in sorted (k for k in set (stripped ) | set (off ) if stripped .get (k ) != off .get (k )):
97+ if k not in stripped :
98+ detail .append (f"`{ k } ` is in the flag-off facts and MISSING from the flag-on facts" )
99+ elif k not in off :
100+ detail .append (f"`{ k } ` appears only in the flag-on facts and is not an S0 field" )
101+ else :
102+ detail .append (f"`{ k } ` differs" )
103+ if not detail and list (stripped ) != list (off ):
104+ detail .append (f"the top-level key ORDER differs: { list (stripped )} vs { list (off )} " )
105+ if not detail :
106+ return None
107+ return ("flag-on minus the S0 fields must equal flag-off: " + "; " .join (detail )
108+ + " — --fix-candidates only adds, it never removes or changes another section" )
109+
110+
111+ def _extractor_sections () -> list [str ]:
112+ """The top-level keys the extractor's envelope can write, read from its source.
113+
114+ The envelope is built in one place as `facts["<key>"] = ...`. Reading the keys from
115+ there, instead of listing them here, is what makes a NEW section fail this check
116+ until the fixture exercises it."""
117+ with open (_EXTRACTOR , encoding = "utf-8" ) as fh :
118+ keys = re .findall (r'\bfacts\["([a-z_]+)"\]\s*=' , fh .read ())
119+ return list (dict .fromkeys (keys ))
120+
121+
122+ def _findings (facts : dict ) -> list [tuple [object , ...]] | str :
123+ try :
124+ return sorted ((f .code , f .file , f .line , f .column or 0 , f .severity , f .message )
125+ for f in check_facts (facts ))
126+ except OwnIRError as e :
127+ return f"refused: { e } "
128+
129+
130+ def additive_sections (on_path : str , off_path : str ) -> int :
131+ """`--additive-sections`: the flag only adds, over EVERY section the extractor writes."""
132+ on , off = _load (on_path ), _load (off_path )
133+ fails : list [str ] = []
134+
135+ # 1. the pair really is flag-on / flag-off, at the current version
136+ if on .get ("fix_candidates_version" ) != 1 or "fix_candidates_version" in off :
137+ fails .append ("the pair is not (flag-on, flag-off): fix_candidates_version must be 1 "
138+ "in the first document and absent from the second" )
139+ components = on .get ("components" )
140+ if not any (s .get ("fix" ) for c in (components if isinstance (components , list ) else [])
141+ for s in c .get ("subscriptions" ) or []):
142+ fails .append ("the flag-on facts carry no `fix` block: the pair does not exercise S0" )
143+ if on .get ("ownir_version" ) != OWNIR_VERSION or off .get ("ownir_version" ) != OWNIR_VERSION :
144+ fails .append (f"ownir_version must be the core's { OWNIR_VERSION } in both documents: "
145+ f"{ on .get ('ownir_version' )!r} with the flag, "
146+ f"{ off .get ('ownir_version' )!r} without" )
147+
148+ # 2. not vacuous: every section the extractor CAN write is there, and not empty
149+ sections = [k for k in _extractor_sections () if k not in _ADDITIVE_TOP_LEVEL ]
150+ readable = {"ownir_version" , "module" , "components" , "functions" } <= set (sections )
151+ if not readable :
152+ fails .append (f"could not read the extractor's envelope from { _EXTRACTOR } (found "
153+ f"{ sections } ); the envelope moved — update `_extractor_sections`" )
154+ for k in sections :
155+ v = off .get (k )
156+ if k not in off or (isinstance (v , (list , dict )) and not v ):
157+ fails .append (f"the fixture does not exercise `{ k } `: the extractor can write it, "
158+ f"and the flag-off facts have it "
159+ f"{ 'empty' if k in off else 'absent' } . "
160+ f"Extend tests/fixtures/fix_candidates/AdditiveSections.cs so the "
161+ f"additivity check covers it" )
162+ unknown = sorted (set (off ) - set (sections ))
163+ if unknown and readable :
164+ fails .append (f"the flag-off facts carry top-level keys this check did not find in the "
165+ f"extractor's envelope: { unknown } — update `_extractor_sections`" )
166+
167+ # 3. the invariant itself, over the whole document
168+ problem = _additivity_problem (on , off )
169+ if problem is not None :
170+ fails .append (problem )
171+
172+ # 4. and its consequence: the flag changes no verdict and no advisory
173+ got_on , got_off = _findings (on ), _findings (off )
174+ if got_on != got_off :
175+ said = [g if isinstance (g , str ) else f"{ len (g )} finding(s)" for g in (got_on , got_off )]
176+ fails .append (f"the reference gives different findings with the flag ({ said [0 ]} ) "
177+ f"and without it ({ said [1 ]} )" )
178+ elif isinstance (got_off , str ):
179+ fails .append (f"the reference refuses the fixture's facts: { got_off } " )
180+ else :
181+ orphans = off .get ("orphaned_awaitables" ) or []
182+ own053 = [f for f in got_off if f [0 ] == "OWN053" ]
183+ if not isinstance (orphans , list ) or len (own053 ) != len (orphans ):
184+ fails .append (f"{ len (orphans ) if isinstance (orphans , list ) else '?' } "
185+ f"orphaned_awaitables entr(ies) but { len (own053 )} OWN053 advisor(ies)" )
186+
187+ if fails :
188+ for fmsg in fails :
189+ print ("FAIL:" , fmsg , file = sys .stderr )
190+ return 1
191+ print (f"fix-candidates additivity: flag-on minus the S0 fields equals flag-off over "
192+ f"all { len (sections )} sections the extractor writes ({ ', ' .join (sections )} ); "
193+ f"the reference gives the same { len (got_off )} finding(s) with and without the flag" )
194+ return 0
195+
196+
65197def main (on_path : str , off_path : str | None ) -> int :
66198 on = _load (on_path )
67199 fails : list [str ] = []
@@ -247,7 +379,8 @@ def only_fix(name: str) -> dict | None:
247379 # Additivity, positively: strip every additive field from the flag-ON facts and
248380 # the result must EQUAL the flag-off facts (same records, same order, same old
249381 # values) -- enabling the metadata changed nothing pre-existing.
250- check (_strip_additive (on ) == off , "flag-on minus additive fields must equal flag-off" )
382+ problem = _additivity_problem (on , off )
383+ check (problem is None , problem or "" )
251384
252385 if fails :
253386 for fmsg in fails :
@@ -258,7 +391,9 @@ def only_fix(name: str) -> dict | None:
258391
259392
260393if __name__ == "__main__" :
261- if len (sys .argv ) not in (2 , 3 ):
394+ if len (sys .argv ) == 4 and sys .argv [1 ] == "--additive-sections" :
395+ raise SystemExit (additive_sections (sys .argv [2 ], sys .argv [3 ]))
396+ if len (sys .argv ) not in (2 , 3 ) or sys .argv [1 ].startswith ("--" ):
262397 print (__doc__ , file = sys .stderr )
263398 raise SystemExit (2 )
264399 raise SystemExit (main (sys .argv [1 ], sys .argv [2 ] if len (sys .argv ) == 3 else None ))
0 commit comments