|
@@ -27,25 +27,32 @@ wf-patch.py — workflow.json 的安全批量字段设置器.
|
|
|
# 只校验不写
|
|
# 只校验不写
|
|
|
python spec/tools/wf-patch.py --workflow ... --set '...' --dry-run
|
|
python spec/tools/wf-patch.py --workflow ... --set '...' --dry-run
|
|
|
|
|
|
|
|
-路径语法 (proc / step 按 id 寻址, 不是下标; 只有真列表才用 [i]):
|
|
|
|
|
- p1.s2.effect step 标量字段 (effect/via/action/feature/control/kind/intent/group ...)
|
|
|
|
|
|
|
+ # 删字段 (取代手 Edit 删; 字段不存在则幂等跳过)
|
|
|
|
|
+ python spec/tools/wf-patch.py --workflow ... --unset 'p1.declarations.inputs[0].inferred'
|
|
|
|
|
+
|
|
|
|
|
+ # 只校验不写
|
|
|
|
|
+ python spec/tools/wf-patch.py --workflow ... --set '...' --dry-run
|
|
|
|
|
+
|
|
|
|
|
+路径语法 (proc / step 按 id 寻址, 不是下标; 只有真列表才用 [i]; 嵌套步 id 带点 s2.1 也支持):
|
|
|
|
|
+ p1.s2.effect step 标量字段 (effect/via/action/feature/control/kind/intent/group)
|
|
|
p1.s1.inputs[0].anchor IO 字段 (anchor/type/substance/form/name/value)
|
|
p1.s1.inputs[0].anchor IO 字段 (anchor/type/substance/form/name/value)
|
|
|
- p1.s1.outputs[0].type
|
|
|
|
|
|
|
+ p1.s2.focus step 的 focus 数组 (逗号分隔: focus=via,action,out-type-0)
|
|
|
p1.purpose procedure 头部字段 (name/purpose/category/platform/author)
|
|
p1.purpose procedure 头部字段 (name/purpose/category/platform/author)
|
|
|
|
|
+ p1.declarations.inputs[0].desc declarations 内任意字段 (通用下钻)
|
|
|
|
|
+ source.url case-level 原帖信息 (platform/author/date/url/title/excerpt)
|
|
|
p1.type_registry.场景图.extends 注册 case-specific 类型 (会自动建 type_registry 段)
|
|
p1.type_registry.场景图.extends 注册 case-specific 类型 (会自动建 type_registry 段)
|
|
|
- p1.type_registry.场景图.desc
|
|
|
|
|
|
|
|
|
|
value 特殊取值:
|
|
value 特殊取值:
|
|
|
- __null__ -> JSON null (用于 substance/form 可空)
|
|
|
|
|
|
|
+ __null__ -> JSON null (用于 substance/form/url 可空)
|
|
|
|
|
|
|
|
-不在职责内 (仍用 Write / Edit):
|
|
|
|
|
|
|
+仍用 Write / Edit 的只剩 (尽量别碰生 JSON):
|
|
|
- workflow.json 骨架的首次创建 (Phase 1.2 从 template Write)
|
|
- workflow.json 骨架的首次创建 (Phase 1.2 从 template Write)
|
|
|
- - instruction (列表套列表, 1-2 行手动 Edit 即可)
|
|
|
|
|
- - 删字段 / 删 step / 调结构
|
|
|
|
|
|
|
+ - instruction (列表套列表, 手动 Edit; 透传 directive 用 --resolve-passthrough)
|
|
|
|
|
+ 改字段/删字段/改 source 现在都走本工具, 不要再 Read→Edit 改 workflow.json (会反复重读、烧 token).
|
|
|
|
|
|
|
|
退出码:
|
|
退出码:
|
|
|
- 0 全部赋值校验通过并写入 (--dry-run 时为校验通过)
|
|
|
|
|
- 1 有赋值校验失败 (整批未写) / 路径解析失败
|
|
|
|
|
|
|
+ 0 全部校验通过并写入 (--dry-run 时为校验通过)
|
|
|
|
|
+ 1 有校验失败 (整批未写) / 路径解析失败
|
|
|
2 CLI 参数错误 / 文件不存在 / JSON 损坏
|
|
2 CLI 参数错误 / 文件不存在 / JSON 损坏
|
|
|
"""
|
|
"""
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
@@ -127,9 +134,12 @@ def _taxo_valid(dim: str, path: str) -> bool:
|
|
|
if key in _taxo_cache:
|
|
if key in _taxo_cache:
|
|
|
return _taxo_cache[key]
|
|
return _taxo_cache[key]
|
|
|
try:
|
|
try:
|
|
|
|
|
+ import os
|
|
|
|
|
+ env = os.environ.copy()
|
|
|
|
|
+ env['PYTHONIOENCODING'] = 'utf-8'
|
|
|
r = subprocess.run(
|
|
r = subprocess.run(
|
|
|
[sys.executable, str(LOOKUP), '--dim', dim, '--validate', path],
|
|
[sys.executable, str(LOOKUP), '--dim', dim, '--validate', path],
|
|
|
- capture_output=True, text=True,
|
|
|
|
|
|
|
+ capture_output=True, text=True, encoding='utf-8', errors='replace', env=env,
|
|
|
)
|
|
)
|
|
|
ok = (r.returncode == 0)
|
|
ok = (r.returncode == 0)
|
|
|
except Exception:
|
|
except Exception:
|
|
@@ -138,6 +148,7 @@ def _taxo_valid(dim: str, path: str) -> bool:
|
|
|
return ok
|
|
return ok
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+
|
|
|
def _closest(name: str, leaves) -> str:
|
|
def _closest(name: str, leaves) -> str:
|
|
|
"""给个最接近的叶子名做提示 (子串/前缀朴素匹配, 仅供报错文案)."""
|
|
"""给个最接近的叶子名做提示 (子串/前缀朴素匹配, 仅供报错文案)."""
|
|
|
cands = [lf for lf in leaves if name and (name in lf or lf in name)]
|
|
cands = [lf for lf in leaves if name and (name in lf or lf in name)]
|
|
@@ -148,12 +159,17 @@ def _closest(name: str, leaves) -> str:
|
|
|
# 字段校验 -> (ok, normalized_value, err_msg)
|
|
# 字段校验 -> (ok, normalized_value, err_msg)
|
|
|
# ===========================================================================
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
-def validate_field(field: str, value, proc: dict):
|
|
|
|
|
- # null 哨兵 (substance/form 可空)
|
|
|
|
|
|
|
+def validate_field(field: str, value, proc: dict, pending_types: set[str] = None):
|
|
|
|
|
+ # null 哨兵 (substance/form/url 可空)
|
|
|
if value == '__null__':
|
|
if value == '__null__':
|
|
|
- if field in ('substance', 'form'):
|
|
|
|
|
|
|
+ if field in ('substance', 'form', 'url'):
|
|
|
return True, None, ''
|
|
return True, None, ''
|
|
|
- return False, value, f'__null__ 只对 substance/form 有意义, {field} 不可为 null'
|
|
|
|
|
|
|
+ return False, value, f'__null__ 只对 substance/form/url 有意义, {field} 不可为 null'
|
|
|
|
|
+
|
|
|
|
|
+ # focus 是数组: 逗号分隔 → list ('via,action,out-type-0'); 空串 → []
|
|
|
|
|
+ if field == 'focus':
|
|
|
|
|
+ items = [t.strip() for t in str(value).split(',') if t.strip()]
|
|
|
|
|
+ return True, items, ''
|
|
|
|
|
|
|
|
if field == 'effect':
|
|
if field == 'effect':
|
|
|
if value in EFFECT_LEAVES:
|
|
if value in EFFECT_LEAVES:
|
|
@@ -179,6 +195,8 @@ def validate_field(field: str, value, proc: dict):
|
|
|
reg = proc.get('type_registry') or {}
|
|
reg = proc.get('type_registry') or {}
|
|
|
if value in reg:
|
|
if value in reg:
|
|
|
return True, value, ''
|
|
return True, value, ''
|
|
|
|
|
+ if pending_types and value in pending_types:
|
|
|
|
|
+ return True, value, ''
|
|
|
return False, value, (f'type={value!r} 不是 type.json 叶子, 也没在本工序 type_registry 注册. '
|
|
return False, value, (f'type={value!r} 不是 type.json 叶子, 也没在本工序 type_registry 注册. '
|
|
|
f'先 --set {proc.get("id")}.type_registry.{value}.extends=<叶子> 再用.{_closest(value, TYPE_LEAVES)}')
|
|
f'先 --set {proc.get("id")}.type_registry.{value}.extends=<叶子> 再用.{_closest(value, TYPE_LEAVES)}')
|
|
|
|
|
|
|
@@ -188,14 +206,46 @@ def validate_field(field: str, value, proc: dict):
|
|
|
return False, value, f'type_registry extends={value!r} 必须是 type.json 叶子.{_closest(value, TYPE_LEAVES)}'
|
|
return False, value, f'type_registry extends={value!r} 必须是 type.json 叶子.{_closest(value, TYPE_LEAVES)}'
|
|
|
|
|
|
|
|
if field == 'substance':
|
|
if field == 'substance':
|
|
|
- if _taxo_valid('实质', value):
|
|
|
|
|
- return True, value, ''
|
|
|
|
|
- return False, value, f'substance={value!r} 不在实质词表 (taxonomy-lookup --dim 实质 --subtree 查可用叶子)'
|
|
|
|
|
|
|
+ if isinstance(value, str):
|
|
|
|
|
+ if '+' in value:
|
|
|
|
|
+ paths = [p.strip() for p in value.split('+') if p.strip()]
|
|
|
|
|
+ else:
|
|
|
|
|
+ paths = [value.strip()]
|
|
|
|
|
+ elif isinstance(value, list):
|
|
|
|
|
+ paths = [str(p).strip() for p in value if str(p).strip()]
|
|
|
|
|
+ else:
|
|
|
|
|
+ return False, value, 'substance 必须是字符串或数组'
|
|
|
|
|
+
|
|
|
|
|
+ invalid_paths = []
|
|
|
|
|
+ for p in paths:
|
|
|
|
|
+ if not _taxo_valid('实质', p):
|
|
|
|
|
+ invalid_paths.append(p)
|
|
|
|
|
+ if invalid_paths:
|
|
|
|
|
+ return False, value, f'以下 substance 路径不在实质词表: {invalid_paths}'
|
|
|
|
|
+
|
|
|
|
|
+ norm_val = paths if (isinstance(value, list) or (isinstance(value, str) and '+' in value)) else paths[0]
|
|
|
|
|
+ return True, norm_val, ''
|
|
|
|
|
|
|
|
if field == 'form':
|
|
if field == 'form':
|
|
|
- if _taxo_valid('形式', value):
|
|
|
|
|
- return True, value, ''
|
|
|
|
|
- return False, value, f'form={value!r} 不在形式词表 (taxonomy-lookup --dim 形式 --subtree 查可用叶子)'
|
|
|
|
|
|
|
+ if isinstance(value, str):
|
|
|
|
|
+ if '+' in value:
|
|
|
|
|
+ paths = [p.strip() for p in value.split('+') if p.strip()]
|
|
|
|
|
+ else:
|
|
|
|
|
+ paths = [value.strip()]
|
|
|
|
|
+ elif isinstance(value, list):
|
|
|
|
|
+ paths = [str(p).strip() for p in value if str(p).strip()]
|
|
|
|
|
+ else:
|
|
|
|
|
+ return False, value, 'form 必须是字符串或数组'
|
|
|
|
|
+
|
|
|
|
|
+ invalid_paths = []
|
|
|
|
|
+ for p in paths:
|
|
|
|
|
+ if not _taxo_valid('形式', p):
|
|
|
|
|
+ invalid_paths.append(p)
|
|
|
|
|
+ if invalid_paths:
|
|
|
|
|
+ return False, value, f'以下 form 路径不在形式词表: {invalid_paths}'
|
|
|
|
|
+
|
|
|
|
|
+ norm_val = paths if (isinstance(value, list) or (isinstance(value, str) and '+' in value)) else paths[0]
|
|
|
|
|
+ return True, norm_val, ''
|
|
|
|
|
|
|
|
if field == 'anchor':
|
|
if field == 'anchor':
|
|
|
if re.match(r'^\s*(←|→)', str(value)):
|
|
if re.match(r'^\s*(←|→)', str(value)):
|
|
@@ -235,6 +285,35 @@ def _split_seg(seg: str):
|
|
|
return m.group(1), (int(m.group(2)) if m.group(2) is not None else None)
|
|
return m.group(1), (int(m.group(2)) if m.group(2) is not None else None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _descend(container, segs):
|
|
|
|
|
+ """沿 segs 走进 container, 返回 (parent, last_key). 中间节点必须已存在.
|
|
|
|
|
+
|
|
|
|
|
+ segs 每段可带 [i] 下标. last_key 是 dict 键 (str) 或列表下标 (int);
|
|
|
|
|
+ 设置即 parent[last_key]=value, 删除即 del parent[last_key].
|
|
|
|
|
+ 用于 source.* / declarations.* 等通用路径 (proc/step 的 id 寻址不走这里).
|
|
|
|
|
+ """
|
|
|
|
|
+ cur = container
|
|
|
|
|
+ for i, seg in enumerate(segs):
|
|
|
|
|
+ name, idx = _split_seg(seg)
|
|
|
|
|
+ last = (i == len(segs) - 1)
|
|
|
|
|
+ if last and idx is None:
|
|
|
|
|
+ if not isinstance(cur, dict):
|
|
|
|
|
+ raise PathError(f'{name!r} 的父级不是对象')
|
|
|
|
|
+ return cur, name
|
|
|
|
|
+ if not isinstance(cur, dict) or name not in cur:
|
|
|
|
|
+ raise PathError(f'路径段 {name!r} 不存在, 无法下钻')
|
|
|
|
|
+ nxt = cur[name]
|
|
|
|
|
+ if idx is not None:
|
|
|
|
|
+ if not isinstance(nxt, list) or idx >= len(nxt):
|
|
|
|
|
+ raise PathError(f'{name}[{idx}] 越界或非列表')
|
|
|
|
|
+ if last:
|
|
|
|
|
+ return nxt, idx
|
|
|
|
|
+ cur = nxt[idx]
|
|
|
|
|
+ else:
|
|
|
|
|
+ cur = nxt
|
|
|
|
|
+ raise PathError('路径为空')
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def locate(data: dict, path: str):
|
|
def locate(data: dict, path: str):
|
|
|
"""把 path 解析到目标. 返回 (parent, key, proc, field_name).
|
|
"""把 path 解析到目标. 返回 (parent, key, proc, field_name).
|
|
|
|
|
|
|
@@ -243,9 +322,16 @@ def locate(data: dict, path: str):
|
|
|
step id 可能带点 (嵌套步 s2.1) — 用最长前缀匹配消歧 (s2.1 优先于 s2).
|
|
step id 可能带点 (嵌套步 s2.1) — 用最长前缀匹配消歧 (s2.1 优先于 s2).
|
|
|
"""
|
|
"""
|
|
|
if '.' not in path:
|
|
if '.' not in path:
|
|
|
- raise PathError(f'路径太短 {path!r}, 至少 <proc>.<字段>')
|
|
|
|
|
|
|
+ raise PathError(f'路径太短 {path!r}, 至少 <proc>.<字段> 或 source.<字段>')
|
|
|
|
|
|
|
|
proc_id, remainder = path.split('.', 1)
|
|
proc_id, remainder = path.split('.', 1)
|
|
|
|
|
+
|
|
|
|
|
+ # --- source.* 分支 (case-level 原帖信息, 无 proc 上下文) ---
|
|
|
|
|
+ if proc_id == 'source':
|
|
|
|
|
+ src = data.setdefault('source', {})
|
|
|
|
|
+ parent, key = _descend(src, remainder.split('.'))
|
|
|
|
|
+ return parent, key, None, (key if isinstance(key, str) else '')
|
|
|
|
|
+
|
|
|
proc = next((p for p in (data.get('procedures') or []) if p.get('id') == proc_id), None)
|
|
proc = next((p for p in (data.get('procedures') or []) if p.get('id') == proc_id), None)
|
|
|
if proc is None:
|
|
if proc is None:
|
|
|
ids = [p.get('id') for p in (data.get('procedures') or [])]
|
|
ids = [p.get('id') for p in (data.get('procedures') or [])]
|
|
@@ -289,11 +375,9 @@ def locate(data: dict, path: str):
|
|
|
raise PathError(f'step 标量字段形如 {proc_id}.{sid}.{name2}')
|
|
raise PathError(f'step 标量字段形如 {proc_id}.{sid}.{name2}')
|
|
|
return matched, name2, proc, name2
|
|
return matched, name2, proc, name2
|
|
|
|
|
|
|
|
- # --- procedure 头部字段 (单段) ---
|
|
|
|
|
- if '.' not in remainder:
|
|
|
|
|
- return proc, remainder, proc, remainder
|
|
|
|
|
-
|
|
|
|
|
- raise PathError(f'无法解析 {path!r}: {remainder.split(".")[0]!r} 既不是 {proc_id} 的 step id, 也不是单段 proc 字段')
|
|
|
|
|
|
|
+ # --- proc 内其余路径: 头部字段 / declarations.* / return_row.* 等, 走通用下钻 ---
|
|
|
|
|
+ parent, key = _descend(proc, remainder.split('.'))
|
|
|
|
|
+ return parent, key, proc, (key if isinstance(key, str) else '')
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
# ===========================================================================
|
|
@@ -427,13 +511,34 @@ def resolve_passthrough(data: dict):
|
|
|
# ===========================================================================
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
def load_patches(args) -> list[tuple[str, str]]:
|
|
def load_patches(args) -> list[tuple[str, str]]:
|
|
|
- """汇总 --set 与 --patch 成 [(path, value), ...]."""
|
|
|
|
|
|
|
+ """汇总 --set、--patch 与 --set-file 成 [(path, value), ...]."""
|
|
|
|
|
+ def _norm(v):
|
|
|
|
|
+ if isinstance(v, str):
|
|
|
|
|
+ # 将中文全角双角/单引号自动归一化为标准半角引号,更利于 AI 生图引擎和 Prompt 语法识别
|
|
|
|
|
+ v = v.replace('“', '"').replace('”', '"').replace('‘', "'").replace('’', "'")
|
|
|
|
|
+ return v
|
|
|
|
|
+
|
|
|
out: list[tuple[str, str]] = []
|
|
out: list[tuple[str, str]] = []
|
|
|
for s in args.set or []:
|
|
for s in args.set or []:
|
|
|
if '=' not in s:
|
|
if '=' not in s:
|
|
|
raise SystemExit(f'wf-patch: --set 缺 "=" : {s!r} (形如 path=value)')
|
|
raise SystemExit(f'wf-patch: --set 缺 "=" : {s!r} (形如 path=value)')
|
|
|
path, value = s.split('=', 1) # 只切第一个 '='
|
|
path, value = s.split('=', 1) # 只切第一个 '='
|
|
|
- out.append((path.strip(), value))
|
|
|
|
|
|
|
+ out.append((path.strip(), _norm(value)))
|
|
|
|
|
+
|
|
|
|
|
+ # 🟢 新增:从外部文件读取值注入
|
|
|
|
|
+ for sf in getattr(args, 'set_file', None) or []:
|
|
|
|
|
+ if '=' not in sf:
|
|
|
|
|
+ raise SystemExit(f'wf-patch: --set-file 缺 "=" : {sf!r} (形如 path=file_path)')
|
|
|
|
|
+ path, fpath_str = sf.split('=', 1)
|
|
|
|
|
+ fpath = Path(fpath_str.strip())
|
|
|
|
|
+ if not fpath.exists():
|
|
|
|
|
+ raise SystemExit(f'wf-patch: --set-file 指定的文件不存在: {fpath_str}')
|
|
|
|
|
+ try:
|
|
|
|
|
+ value = fpath.read_text(encoding='utf-8')
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ raise SystemExit(f'wf-patch: 无法读取 --set-file 指定的文件 {fpath_str}: {e}')
|
|
|
|
|
+ out.append((path.strip(), _norm(value)))
|
|
|
|
|
+
|
|
|
if args.patch:
|
|
if args.patch:
|
|
|
if not args.patch.exists():
|
|
if not args.patch.exists():
|
|
|
raise SystemExit(f'wf-patch: --patch 文件不存在 {args.patch}')
|
|
raise SystemExit(f'wf-patch: --patch 文件不存在 {args.patch}')
|
|
@@ -442,7 +547,7 @@ def load_patches(args) -> list[tuple[str, str]]:
|
|
|
except json.JSONDecodeError as e:
|
|
except json.JSONDecodeError as e:
|
|
|
raise SystemExit(f'wf-patch: --patch 不是合法 JSON: {e}')
|
|
raise SystemExit(f'wf-patch: --patch 不是合法 JSON: {e}')
|
|
|
for it in items:
|
|
for it in items:
|
|
|
- out.append((it['path'], it['value']))
|
|
|
|
|
|
|
+ out.append((it['path'], _norm(it['value'])))
|
|
|
return out
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
@@ -456,6 +561,10 @@ def main() -> None:
|
|
|
help='单条赋值, 可重复. 只在第一个 = 处切; value 可含 = 和空格 (记得整体加引号)')
|
|
help='单条赋值, 可重复. 只在第一个 = 处切; value 可含 = 和空格 (记得整体加引号)')
|
|
|
ap.add_argument('--patch', type=Path, default=None,
|
|
ap.add_argument('--patch', type=Path, default=None,
|
|
|
help='批量赋值清单 .json: [{"path":..,"value":..}, ...]')
|
|
help='批量赋值清单 .json: [{"path":..,"value":..}, ...]')
|
|
|
|
|
+ ap.add_argument('--set-file', action='append', metavar='PATH=FILE_PATH', default=None,
|
|
|
|
|
+ help='从外部文件读取内容注入指定字段. e.g. p1.s1.outputs[0].value=_scratch/prompt.txt')
|
|
|
|
|
+ ap.add_argument('--unset', action='append', metavar='PATH', default=None,
|
|
|
|
|
+ help='删字段, 可重复. e.g. p1.declarations.inputs[0].inferred (字段不存在则跳过). 取代手 Edit 删字段')
|
|
|
ap.add_argument('--resolve-passthrough', action='store_true',
|
|
ap.add_argument('--resolve-passthrough', action='store_true',
|
|
|
help='把 anchor 为纯透传 (← sN.varname)、value 仍空/占位的 IO, 顺 anchor 从源 output 逐字抄 value. 可单独跑, 也可跟在 --set/--patch 后 (先赋值再解析). 迭代处理链式透传')
|
|
help='把 anchor 为纯透传 (← sN.varname)、value 仍空/占位的 IO, 顺 anchor 从源 output 逐字抄 value. 可单独跑, 也可跟在 --set/--patch 后 (先赋值再解析). 迭代处理链式透传')
|
|
|
ap.add_argument('--dry-run', action='store_true', help='只校验/预演, 不写')
|
|
ap.add_argument('--dry-run', action='store_true', help='只校验/预演, 不写')
|
|
@@ -472,12 +581,21 @@ def main() -> None:
|
|
|
sys.exit(2)
|
|
sys.exit(2)
|
|
|
|
|
|
|
|
patches = load_patches(args)
|
|
patches = load_patches(args)
|
|
|
- if not patches and not args.resolve_passthrough:
|
|
|
|
|
- print('wf-patch: 没有 --set / --patch / --resolve-passthrough, 啥也没干', file=sys.stderr)
|
|
|
|
|
|
|
+ unsets = args.unset or []
|
|
|
|
|
+ if not patches and not unsets and not args.resolve_passthrough:
|
|
|
|
|
+ print('wf-patch: 没有 --set / --patch / --unset / --resolve-passthrough, 啥也没干', file=sys.stderr)
|
|
|
sys.exit(2)
|
|
sys.exit(2)
|
|
|
|
|
|
|
|
- # 先全部解析 + 校验, 收集计划; 任何一条失败 -> 整批不写
|
|
|
|
|
- plan = [] # (parent, key, normalized_value, path, display)
|
|
|
|
|
|
|
+ # 解析 + 校验; 任何一条失败 -> 整批不写
|
|
|
|
|
+ pending_types = set()
|
|
|
|
|
+ for path, _ in patches:
|
|
|
|
|
+ m = re.match(r'^p\d+\.type_registry\.([^.]+)\.(extends|desc)$', path)
|
|
|
|
|
+ if m:
|
|
|
|
|
+ pending_types.add(m.group(1))
|
|
|
|
|
+
|
|
|
|
|
+ plan = [] # set: (parent, key, normalized_value, path, display)
|
|
|
|
|
+ del_plan = [] # unset: (parent, key, path)
|
|
|
|
|
+ skipped = [] # unset 跳过 (字段本就不在)
|
|
|
errors = [] # (path, msg)
|
|
errors = [] # (path, msg)
|
|
|
for path, value in patches:
|
|
for path, value in patches:
|
|
|
try:
|
|
try:
|
|
@@ -485,25 +603,44 @@ def main() -> None:
|
|
|
except PathError as e:
|
|
except PathError as e:
|
|
|
errors.append((path, str(e)))
|
|
errors.append((path, str(e)))
|
|
|
continue
|
|
continue
|
|
|
- ok, norm, msg = validate_field(field, value, proc)
|
|
|
|
|
|
|
+ ok, norm, msg = validate_field(field, value, proc, pending_types)
|
|
|
if not ok:
|
|
if not ok:
|
|
|
errors.append((path, msg))
|
|
errors.append((path, msg))
|
|
|
continue
|
|
continue
|
|
|
plan.append((parent, key, norm, path, norm if norm is not None else 'null'))
|
|
plan.append((parent, key, norm, path, norm if norm is not None else 'null'))
|
|
|
-
|
|
|
|
|
- if patches:
|
|
|
|
|
- print(f'[wf-patch] {wf.name} — {len(patches)} 条赋值, {len(plan)} 通过, {len(errors)} 失败')
|
|
|
|
|
- for _parent, _key, _norm, path, disp in plan:
|
|
|
|
|
- print(f' ✓ {path} = {disp}')
|
|
|
|
|
|
|
+ for path in unsets:
|
|
|
|
|
+ try:
|
|
|
|
|
+ parent, key, _proc, _field = locate(data, path)
|
|
|
|
|
+ except PathError as e:
|
|
|
|
|
+ errors.append((path, str(e)))
|
|
|
|
|
+ continue
|
|
|
|
|
+ present = (isinstance(parent, dict) and key in parent) or \
|
|
|
|
|
+ (isinstance(parent, list) and isinstance(key, int) and key < len(parent))
|
|
|
|
|
+ (del_plan if present else skipped).append((parent, key, path) if present else path)
|
|
|
|
|
+
|
|
|
|
|
+ if patches or unsets:
|
|
|
|
|
+ print(f'[wf-patch] {wf.name} — set {len(plan)}/{len(patches)} 通过, '
|
|
|
|
|
+ f'unset {len(del_plan)} 删/{len(skipped)} 跳过, {len(errors)} 失败')
|
|
|
|
|
+ for _p, _k, _n, path, disp in plan:
|
|
|
|
|
+ print(f' ✓ set {path} = {disp}')
|
|
|
|
|
+ for _p, _k, path in del_plan:
|
|
|
|
|
+ print(f' ✓ unset {path}')
|
|
|
|
|
+ for path in skipped:
|
|
|
|
|
+ print(f' · skip {path} (字段本就不存在)')
|
|
|
for path, msg in errors:
|
|
for path, msg in errors:
|
|
|
print(f' ✗ {path} — {msg}')
|
|
print(f' ✗ {path} — {msg}')
|
|
|
if errors:
|
|
if errors:
|
|
|
- print(f'\n有 {len(errors)} 条校验失败, 整批未写入 (修正后重跑).', file=sys.stderr)
|
|
|
|
|
|
|
+ print(f'\n有 {len(errors)} 条失败, 整批未写入 (修正后重跑).', file=sys.stderr)
|
|
|
sys.exit(1)
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
- # 赋值先落到内存 data (resolve 要看到它们); 是否持久化由 dry-run 决定
|
|
|
|
|
|
|
+ # 应用到内存 data (set 先 unset 后; resolve 要看到这些改动). 是否持久化由 dry-run 决定.
|
|
|
for parent, key, norm, _, _ in plan:
|
|
for parent, key, norm, _, _ in plan:
|
|
|
parent[key] = norm
|
|
parent[key] = norm
|
|
|
|
|
+ for parent, key, _path in sorted(del_plan, key=lambda d: -d[1] if isinstance(d[1], int) else 0):
|
|
|
|
|
+ if isinstance(parent, list):
|
|
|
|
|
+ parent.pop(key)
|
|
|
|
|
+ else:
|
|
|
|
|
+ del parent[key]
|
|
|
|
|
|
|
|
# 透传回填
|
|
# 透传回填
|
|
|
filled, warns = [], []
|
|
filled, warns = [], []
|
|
@@ -515,7 +652,7 @@ def main() -> None:
|
|
|
for w in warns:
|
|
for w in warns:
|
|
|
print(f' ⚠ {w}')
|
|
print(f' ⚠ {w}')
|
|
|
|
|
|
|
|
- n_changes = len(plan) + len(filled)
|
|
|
|
|
|
|
+ n_changes = len(plan) + len(del_plan) + len(filled)
|
|
|
if args.dry_run:
|
|
if args.dry_run:
|
|
|
print(f'\n--dry-run: 预演 {n_changes} 处改动, 未写入.')
|
|
print(f'\n--dry-run: 预演 {n_changes} 处改动, 未写入.')
|
|
|
sys.exit(0)
|
|
sys.exit(0)
|