221 lines
8.7 KiB
Python
221 lines
8.7 KiB
Python
#!/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_arg_bang(line, col0):
|
||
"""在参数值末尾插 '!'(仅限简单值:标识符/成员/调用/下标)。"""
|
||
depth=0; end=None
|
||
for i in range(col0, len(line)):
|
||
ch=line[i]
|
||
if ch in '([{': depth+=1
|
||
elif ch in ')]}':
|
||
if depth==0: end=i; break
|
||
depth-=1
|
||
elif ch==',' and depth==0: end=i; break
|
||
if end is None: end=len(line)
|
||
# 值的实际末尾(跳过空白)
|
||
j=end-1
|
||
while j>=col0 and line[j] in ' \t': j-=1
|
||
if j<col0: return None
|
||
if line[j]=='!': return None
|
||
val=line[col0:j+1].strip()
|
||
if not val: return None
|
||
if val[0] in '0123456789"\'': return None # 字面量跳过
|
||
if val in ('true','false','null'): return None
|
||
if not re.fullmatch(r'[\w.\[\]()]+', val): return None # 含运算符等跳过
|
||
return line[:j+1]+'!'+line[j+1:]
|
||
|
||
def make_field_nullable(lines, name):
|
||
"""找字段声明 'Type name;' 并在类型后加 '?'。返回 (行索引, 新行) 或 (None,None)。"""
|
||
pat = re.compile(r'^(\s+(?:final\s+|const\s+)?(?:static\s+)?)(.+)\s+'+re.escape(name)+r';\s*$')
|
||
for i,L in enumerate(lines):
|
||
m=pat.match(L)
|
||
if not m: continue
|
||
mod, typ = m.group(1), m.group(2).rstrip()
|
||
if '?' in typ or '=' in L: continue
|
||
return i, f"{mod}{typ}? {name};"
|
||
return None, 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:
|
||
pname=nm.group(1)
|
||
# field-formal this.x ? 则把字段改可空(跨行)
|
||
if ('this.'+pname) in old:
|
||
fl=get(path)
|
||
fi, fnew = make_field_nullable(fl, pname)
|
||
if fi is not None and fl[fi]!=fnew:
|
||
fl[fi]=fnew; changed+=1
|
||
else:
|
||
new=insert_nullable_before_name(old, pname, col-1)
|
||
elif rule in ('not_initialized_non_nullable_instance_field','not_initialized_non_nullable_variable'):
|
||
new=insert_late(old)
|
||
elif rule in ('argument_type_not_assignable','invalid_assignment'):
|
||
new=insert_arg_bang(old, col-1)
|
||
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} ===')
|