55so tests can exercise the same workflow against isolated Git histories.
66"""
77import argparse
8+ import bisect
89import hashlib
910import importlib .metadata
1011import json
1112import os
1213from pathlib import Path
14+ import re
1315import subprocess
1416import sys
1517import tempfile
1618
1719from impact_gate .baseline import build_baseline , save_baseline
1820from impact_gate .core .config import MeasureConfig
21+ import lizard
22+ from pygments .lexers import JavascriptLexer , TypeScriptLexer
23+ from pygments .token import Comment , Keyword , Name , Operator , Punctuation
1924
2025ROOT = Path (__file__ ).resolve ().parents [1 ]
2126POLICY = ROOT / '.github' / 'impact-gate'
@@ -138,6 +143,7 @@ def report(args: argparse.Namespace, policy: str) -> None:
138143 f'Baseline target: `{ args .base } `; commit: `{ meta ["head" ]} `. '
139144 f'History: latest { HISTORY_COMMITS } first-parent commits; recursively expanded '
140145 'merge observations may exceed this limit. p90/p98 are advisory; prior weight: 200.' )
146+ spans : dict = {} # Both scopes list the same functions.
141147 for scope , label in SCOPES .items ():
142148 path = args .cache_dir / f'{ scope } .json'
143149 n = read_baseline (path , meta ['head' ])
@@ -148,18 +154,167 @@ def report(args: argparse.Namespace, policy: str) -> None:
148154 '--enforcement' , 'warn' , '--format' , 'markdown' , '--warn-percentile' , '90' ,
149155 '--block-percentile' , '98' , '--curve-prior-weight' , '200' ,
150156 '--baseline-file' , str (path ), '--measure-config' , str (POLICY / f'{ scope } .yml' ))
151- publish (result )
157+ publish (mark_misread_spans (result , args .base , spans ))
158+
159+
160+ # lizard's TypeScript reader sometimes misses a function's closing brace (an
161+ # arrow callback followed by a ternary returning an object literal is one
162+ # trigger) and runs the span on into the next top-level declaration. Edits to
163+ # that neighbour are then charged to a function the PR never touched. In
164+ # formatted code nothing inside a function body starts at column 0, so a
165+ # top-level declaration strictly inside a span is the tell. Pygments decides
166+ # what is code: a column-0 `const` inside a template literal is string text.
167+ # Limit: when the misread makes lizard drop functions instead (the same
168+ # trigger inside a class method reports only an `(anonymous)` function), the
169+ # edits land on a row that has no span to check, and nothing is marked.
170+ SPAN_SUFFIXES = ('.ts' , '.tsx' , '.js' , '.jsx' )
171+ DECLARATION = re .compile (r'@|(export|import|declare|abstract|async|function|const|let|var'
172+ r'|class|interface|type|enum)\b' )
173+ # Tokens that can only continue the previous line's expression. Unary
174+ # operators (`-`, `!`) are left out: a line may begin a new statement with one.
175+ LEADING = frozenset (('|' , '&' , '.' , '?.' , '?' , ':' , '=>' , '&&' , '||' , '??' , '=' , ',' ))
176+ LOCATION = re .compile (r'^\| [^|]* \| `(?P<path>[^`:]+):(?P<name>[^`]+)` \| '
177+ r'(?:`(?P<owner>[^`]+)`|_file scope_) \|' )
178+ MISREAD = '⚠️ span may be misattributed'
179+ MISREAD_NOTE = (f'{ MISREAD } : lizard read its line span to include a later top-level '
180+ 'declaration, so edits to that code are charged to this function. Check '
181+ 'the diff before refactoring it. Scores and grades are unchanged.' )
182+
183+
184+ def declaration_lines (path : str , source : str ) -> dict [int , bool ]:
185+ """Line of each top-level declaration, mapped to whether it ends on that line."""
186+ lexer = TypeScriptLexer () if path .endswith (('.ts' , '.tsx' )) else JavascriptLexer ()
187+ starts = [0 ]
188+ for line in source .split ('\n ' )[:- 1 ]:
189+ starts .append (starts [- 1 ] + len (line ) + 1 )
190+ # Column 0 is not top level in unformatted code, so count braces too.
191+ # Brackets of every kind decide whether a declaration closes on its line.
192+ declarations , depth , nesting , open_at_end = set (), 0 , 0 , {}
193+ first , last = {}, {} # Each line's first and last code token.
194+ for offset , token , value in lexer .get_tokens_unprocessed (source ):
195+ line = bisect .bisect_right (starts , offset )
196+ if value .strip () and token not in Comment :
197+ first .setdefault (line , (token , value ))
198+ last [line ] = (token , value , offset )
199+ if (depth == 0 and starts [line - 1 ] == offset
200+ and (token in Keyword or token in Name .Decorator )
201+ and DECLARATION .match (source , offset )):
202+ declarations .add (line )
203+ elif token in Punctuation :
204+ depth = max (0 , depth + value .count ('{' ) - value .count ('}' ))
205+ nesting = max (0 , nesting + sum (map (value .count , '({[' )) - sum (map (value .count , ')}]' )))
206+ open_at_end [line ] = nesting > 0
207+ # A balanced first line still continues when it ends on an operator
208+ # (`type T =`, `=>`) or the next code line leads with one (`| 'b'`, `.next()`).
209+ code_lines = sorted (first )
210+
211+ def closes (line : int , value : str , offset : int ) -> bool :
212+ # An operator that ends its operand: the `>` of a generic the line
213+ # opened, or a postfix `!`/`++`/`--` written against that operand.
214+ text = source [starts [line - 1 ]:offset + len (value )]
215+ if value .strip ('>' ) == '' :
216+ return text .count ('<' ) >= text .count ('>' )
217+ return value in ('!' , '++' , '--' ) and re .match (r'[\w)\]]' , source [offset - 1 ]) is not None
218+
219+ def continues (line : int ) -> bool :
220+ following = code_lines [bisect .bisect_right (code_lines , line ):]
221+ token , value , offset = last .get (line , (None , '' , 0 ))
222+ trails = ((token in Operator and not closes (line , value , offset ))
223+ or value in ('=>' , ',' , '.' ))
224+ return trails or (bool (following ) and first [following [0 ]][1 ] in LEADING )
225+
226+ return {line : not open_at_end .get (line , False ) and not continues (line )
227+ for line in declarations }
228+
229+
230+ def misread_functions (revisions : list [str ], path : str ) -> set [tuple [str , str ]]:
231+ """(owner, name) of each function whose span, in any revision, swallows a declaration."""
232+ found = set ()
233+ for revision in revisions :
234+ shown = subprocess .run (['git' , 'show' , f'{ revision } :{ path } ' ], capture_output = True )
235+ if shown .returncode != 0 : # Added or deleted on one side.
236+ continue
237+ source = shown .stdout .decode ('utf-8' , errors = 'replace' )
238+ declarations = declaration_lines (path , source )
239+ for function in lizard .analyze_file .analyze_source_code (path , source ).function_list :
240+ owner , _ , name = function .name .rpartition ('::' )
241+ # lizard often ends a span on the next declaration's first line.
242+ # That boundary is harmless unless the whole declaration is there.
243+ if name != '(anonymous)' and any (
244+ function .start_line < n < function .end_line
245+ or (n == function .end_line and whole )
246+ for n , whole in declarations .items ()):
247+ found .add ((owner , name ))
248+ return found
249+
250+
251+ def mark_misread_spans (markdown : str , base : str , cache : dict | None = None ) -> str :
252+ """Label function rows whose span lizard misread. Never fails the report."""
253+ cache = {} if cache is None else cache
254+ try :
255+ if 'revisions' not in cache :
256+ cache ['revisions' ] = [run ('git' , 'merge-base' , base , 'HEAD' ), 'HEAD' ]
257+ marked = False
258+ lines = markdown .split ('\n ' )
259+ for index , line in enumerate (lines ):
260+ match = LOCATION .match (line )
261+ if not match or not match ['path' ].endswith (SPAN_SUFFIXES ):
262+ continue
263+ path = match ['path' ]
264+ if path not in cache :
265+ cache [path ] = misread_functions (cache ['revisions' ], path )
266+ if (match ['owner' ] or '' , match ['name' ]) in cache [path ]:
267+ cell = f'`{ path } :{ match ["name" ]} `'
268+ lines [index ] = line .replace (cell , f'{ cell } { MISREAD } ' , 1 )
269+ marked = True
270+ text = '\n ' .join (lines )
271+ return text + f'\n \n { MISREAD_NOTE } ' if marked else text
272+ except Exception as error : # A cosmetic label must not cost the report.
273+ return markdown + f'\n \n _Note: could not check function spans ({ describe (error )} )._'
274+
275+
276+ def pin (args : argparse .Namespace ) -> None :
277+ """Point the target ref at the commit this run's change is measured from.
278+
279+ A push measures from its own commit. A pull request checks out
280+ refs/pull/N/merge, GitHub's merge of the PR into the target branch's tip
281+ when the event fired (a re-run reuses it). The event's pull_request.base.sha
282+ is older whenever the target moved on after the PR last synced, and scoring
283+ from it counts the target's newer commits as the PR's. The merge's first
284+ parent is the tip it was built on, so HEAD^1..HEAD is exactly what the PR
285+ adds.
286+ """
287+ base = args .event_sha
288+ if args .pr_head :
289+ parents = run ('git' , 'rev-list' , '--parents' , '-n' , '1' , 'HEAD' ).split ()[1 :]
290+ if len (parents ) != 2 or parents [1 ] != args .pr_head :
291+ raise ValueError (f'HEAD is not the merge of { args .pr_head } into the target; '
292+ 'check out refs/pull/N/merge.' )
293+ base = parents [0 ]
294+ run ('git' , 'update-ref' , args .base , base )
295+ output ('sha' , base )
152296
153297
154298def main () -> int :
155299 parser = argparse .ArgumentParser (description = __doc__ )
156- parser .add_argument ('command' , choices = ('prepare' , 'report' ))
300+ parser .add_argument ('command' , choices = ('pin' , ' prepare' , 'report' ))
157301 parser .add_argument ('--base' , required = True )
158- parser .add_argument ('--cache-dir' , type = Path , required = True )
302+ parser .add_argument ('--cache-dir' , type = Path )
159303 parser .add_argument ('--refresh' , action = 'store_true' )
304+ parser .add_argument ('--event-sha' , help = 'pin: the commit the event was raised for' )
305+ parser .add_argument ('--pr-head' , help = 'pin: the pull request head, on pull_request events' )
160306 args = parser .parse_args ()
161- args .cache_dir = args .cache_dir .resolve ()
307+ if args .command == 'pin' :
308+ if not args .event_sha :
309+ parser .error ('pin requires --event-sha' )
310+ elif args .cache_dir is None :
311+ parser .error (f'{ args .command } requires --cache-dir' )
312+ else :
313+ args .cache_dir = args .cache_dir .resolve ()
162314 try :
315+ if args .command == 'pin' :
316+ pin (args )
317+ return 0
163318 head = run ('git' , 'rev-parse' , '--verify' , args .base + '^{commit}' )
164319 policy = identity ()
165320 if args .command == 'prepare' :
0 commit comments