phase3: nullfix.py batch script — bang/late/param-nullable

Automated null-safety fixes driven by dart analyze (no corruption):
- unchecked_use_of_nullable_value: insert '!' on receiver (property/method/[]/op)
- not_initialized field/var: mark 'late'
- missing_default_value_for_parameter: nullable param
Errors: 2374(peak) -> 907
This commit is contained in:
2026-07-25 18:13:01 +08:00
parent 8f3d3509ea
commit fce670664b
99 changed files with 1013 additions and 838 deletions

175
tools/nullfix.py Normal file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
nullfix.py — dart analyze 驱动的空安全批量修复(保守、带语法损坏自检)。
处理的规则(全部由 analyzer 位置驱动,逐轮迭代):
1. unchecked_use_of_nullable_value -> 在接收者后插 '!'(属性/方法/[]/运算符)
2. not_initialized_non_nullable_instance_field / _variable
-> 字段/变量改为可空(在类型后插 '?'),跳过 return/this./关键词行
3. missing_default_value_for_parameter
-> 参数改为可空(类型后插 '?'),跳过 field-formal this.x
不处理(需语义判断): argument_type_not_assignable / invalid_assignment
(这两类留给后续:多为 nullable 传给非空形参,常伴随 1/2 解决后自动减少)
每轮结束检测 expected_token / missing_identifier语法损坏有则中止。
用法: python3 tools/nullfix.py [file] [--max-rounds N]
"""
import re, subprocess, sys, os
ROUNDS = 40
args=[]
i=0
while i<len(sys.argv):
a=sys.argv[i]
if a=='--max-rounds': ROUNDS=int(sys.argv[i+1]); i+=2; continue
if not a.endswith('nullfix.py'): args.append(a)
i+=1
TARGET = args[0] if args else None
ERR = re.compile(r'\s+error\s+-\s+([\w/.\-]+):(\d+):(\d+)\s+-\s+(.*?)\s+-\s+([a-z_]+)\s*$')
PROP = re.compile(r"The property '([^']+)'")
METH = re.compile(r"The method '([^']+)'")
OPR = re.compile(r"The operator '([^']+)'")
NAME = re.compile(r"'([^']+)'")
KEYWORDS = {'return','throw','await','yield','break','continue','assert','new','const',
'final','var','late','switch','case','default','if','else','for','while',
'do','try','catch','finally','in','is','as','super','this'}
def analyze():
out = subprocess.run(['dart','analyze','lib'], capture_output=True, text=True).stdout
errs=[]; corruption=0
for ln in out.splitlines():
if 'expected_token' in ln or 'missing_identifier' in ln:
corruption += 1
m = ERR.match(ln)
if m:
p,l,c,msg,r=m.groups(); errs.append((p,int(l),int(c),msg,r))
if TARGET:
t=TARGET.replace('lib/','')
errs=[e for e in errs if e[0].endswith(t)]
return errs, corruption, out
def classify_use(msg):
m=PROP.search(msg)
if m: return (m.group(1),'prop')
m=METH.search(msg)
if m: return (None,'index') if m.group(1)=='[]' else (m.group(1),'meth')
m=OPR.search(msg)
if m: return (m.group(1),'op')
return None
def find_name_col(line, name, col0):
"""在 line 上找 name 的出现,返回最接近 col0 的起始 index找不到 None。"""
best=None
for mm in re.finditer(r'(?<![\w])'+re.escape(name)+r'(?!\w)', line):
d=abs(mm.start()-col0)
if best is None or d<best[0]: best=(d,mm.start())
return best[1] if best else None
def insert_bang(line, col0, name, kind):
if kind in ('prop','meth') and name:
pat = re.compile(r'(?<!!)\.' + re.escape(name) + r'\b')
best=None
for mm in pat.finditer(line):
d=abs(mm.start()-col0)
if best is None or d<best[0]: best=(d,mm)
if not best: return None
idx=best[1].start(); j=idx-1
if j<0: return None
if line[j]=='!': return None
if not (line[j].isalnum() or line[j] in '_)?]'): return None
return line[:idx]+'!'+line[idx:]
if kind=='index':
best=None
for i,ch in enumerate(line):
if ch=='[':
d=abs(i-col0)
if best is None or d<best[0]: best=(d,i)
if not best: return None
idx=best[1]; j=idx-1
if j<0 or line[j]=='!': return None
if line[j].isalnum() or line[j] in '_)?]': return line[:idx]+'!'+line[idx:]
return None
if kind=='op':
for mm in re.finditer(re.escape(name), line):
idx=mm.start(); j=idx-1
while j>=0 and line[j]==' ': j-=1
if j<0 or line[j]=='!': continue
if line[j].isalnum() or line[j] in '_)?]': return line[:j+1]+'!'+line[j+1:]
return None
return None
def insert_late(line):
stripped=line.strip()
if not stripped or stripped.startswith('late '): return None
head=stripped.split(';')[0]
if '?' in head or '=' in head: return None # 已可空/已初始化
indent=line[:len(line)-len(stripped)]
if stripped.startswith('static '): return indent+'static late '+stripped[len('static '):]
if stripped.startswith('external '): return indent+'external late '+stripped[len('external '):]
return indent+'late '+stripped
def insert_nullable_before_name(line, name, col0):
"""'Type name' 改为 'Type? name'。跳过 field-formal(this.)、关键词行。"""
stripped=line.strip()
first=stripped.split(' ')[0] if stripped else ''
if first.rstrip(')').rstrip('(') in KEYWORDS: return None
idx=find_name_col(line, name, col0)
if idx is None: return None
# name 前一个非空字符
j=idx-1
while j>=0 and line[j] in ' \t': j-=1
if j<0: return None
if line[j]=='.': return None # field-formal this.name
if line[j]=='?': return None # 已可空
# j 应是类型末尾(字母/>/)/]
if not (line[j].isalnum() or line[j] in '_>]?)'): return None
return line[:j+1]+'?'+line[j+1:]
def apply_round(errs):
changed=0; files={}
def get(path):
if path not in files:
files[path]=open('lib/'+path).read().split('\n')
return files[path]
def flush():
for p,ls in files.items():
open('lib/'+p,'w').write('\n'.join(ls))
for path,line,col,msg,rule in errs:
if not os.path.exists('lib/'+path): continue
L=get(path)
if line>len(L): continue
old=L[line-1]; new=old
if rule=='unchecked_use_of_nullable_value':
cls=classify_use(msg)
if cls: new=insert_bang(old, col-1, cls[0], cls[1])
elif rule=='missing_default_value_for_parameter':
nm=NAME.search(msg)
if nm: new=insert_nullable_before_name(old, nm.group(1), col-1)
elif rule in ('not_initialized_non_nullable_instance_field','not_initialized_non_nullable_variable'):
new=insert_late(old)
if new and new!=old:
L[line-1]=new; changed+=1
flush()
return changed
rounds=0
while rounds<ROUNDS:
errs,corruption,_=analyze()
if corruption:
print(f'!! 检测到 {corruption} 处语法损坏,中止。请 git diff 检查。'); sys.exit(2)
if not errs: print('无错误,完成。'); break
c=apply_round(errs)
rounds+=1
print(f'{rounds} 轮:修复 {c} 处(剩余 error {len(errs)}')
if c==0:
print('本轮无新增修复,剩余需人工/IDE 处理:')
from collections import Counter
cnt=Counter(e[4] for e in errs)
for r,n in cnt.most_common(8): print(f' {n:4d} {r}')
break
errs,corruption,_=analyze()
print(f'\n=== 完成。剩余 error: {len(errs)} | 语法损坏: {corruption} ===')