#!/usr/bin/env python3 # -*- coding: utf-8 -*- r"""配置审计 —— 把"配置目录/文件统一"变成机器每天能查的事 (2026-09-17 用户令 2)。 ## 查什么 (每条都能指到具体文件/代码行) R1 顶层约定 configs/ 顶层只许放 `src/paths.py::TOP_LEVEL_CONFIGS` 里的运行期单件配置 + 已登记的域目录 + README.md/registry.yaml。散件一律报出来。 R2 备份垃圾 任何 `*.bak*` `*~` `*.old` `*.orig` `*.tmp` `*.rej` 都是垃圾 (实测 configs/ 里 躺过一个 `serve.json.bak-bomfix`)。 R3 命名 配置名不得含空格/中文; 全大写视为"命名不统一"(提示级, 因为场名/机型名有历史约定)。 R4 内容绝对路径 配置**内容**里不得出现本机绝对路径 (`C:\` `D:\` `F:\` `/Users/…` `/Volumes/…` `/home/<人名>`)。 R5 引用闭合 代码里 `P.config('x')` / `P.config_dir('x')` 指向的文件/目录必须存在; 不存在且**未登记** 在 `configs/registry.yaml::known_missing` 的, 按"悬空引用"报错 (登记过的记 `i`)。 R6 手拼路径 代码里不许再拼 `"configs" / …` 字符串 (白名单: src/paths.py 自身、审计脚本、注释/文档串)。 R7 无读者 configs/ 下每个文件要么被登记表的 consumers 覆盖, 要么所在域声明了 `no_reader_ok` + 理由。 R8 场配置 `configs/farms/` 下每个文件要么是**能加载的场定义**(必需键齐), 要么在登记表里 声明为"机型/物理约束 profile"或"CSV 数据表" —— 否则就是"配了看不见"的东西。 ## 退出码 (给 check / rebuild_all 用) 0 全部通过 · 5 结构问题(散件/垃圾/未登记域/未登记文件) · 6 悬空引用 · 7 内容含本机绝对路径 · 8 手拼路径 · 9 场配置不合约定 ## 用法 python scripts/config_audit.py # 表 + 检查 python scripts/config_audit.py --list # 只打配置目录表 python scripts/config_audit.py --json """ from __future__ import annotations import argparse import fnmatch import json import os import pathlib import re import sys ROOT = pathlib.Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from src import paths as P # noqa: E402 REGISTRY = P.config('registry.yaml') CONFIG_EXTS = ('.yaml', '.yml', '.json', '.csv', '.example', '.md') JUNK = ('*.bak', '*.bak-*', '*~', '*.old', '*.orig', '*.tmp', '*.rej', '*.swp') RE_JUNK = re.compile(r'\.(?:bak|old|orig|tmp|rej|swp)(?:$|[-.])|~$') RE_BADNAME = re.compile(r'[\s]') # 空格会切断脚本参数, 一律不许 RE_CN_NAME = re.compile(r'[\u4e00-\u9fff]') # 中文文件名: 项目里大量存在, 只提示不判错 RE_ABS = re.compile(r'(?', '*') for k in known_missing} if any(fnmatch.fnmatch(wildcard, k) for k in known): res.append(('i', f'{P.rel(p)} → configs/{wildcard}', '悬空引用(已知): per-场配置未随包 (见登记表 known_missing)', 0)) else: res.append(('X', f'{P.rel(p)} → configs/{wildcard}', '代码引用的 per-场配置一个都不存在, 且未登记 ⇒ 补文件或删引用', 6)) continue seen_conf.add(rel_call) target = P.CONFIGS / rel_call if target.exists(): continue if rel_call in known_missing or any(rel_call.startswith(k.replace('<场>', '')) for k in known_missing): res.append(('i', f'{P.rel(p)} → configs/{rel_call}', f'悬空引用(已知): {known_why.get(rel_call, "见登记表 known_missing")[:80]}', 0)) else: res.append(('X', f'{P.rel(p)} → configs/{rel_call}', '代码引用的配置不存在, 且未登记在 known_missing ⇒ 补文件或删引用', 6)) for i, ln in enumerate(t.splitlines(), 1): s = ln.strip() if s.startswith('#') or p.name in ('config_audit.py',) or p == P.SRC / 'paths.py': continue if 'config-path-allow' in ln or 'portability-allow' in ln: continue if RE_HANDMADE.search(ln): res.append(('!', f'{P.rel(p)}:{i}', f'手拼 configs 路径: {s[:70]} ⇒ 改用 P.config()/P.config_dir()', 8)) # ── R7: 无读者 declared = json.dumps(reg, ensure_ascii=False) for d in reg.get('domains') or []: if (d.get('consumers') or []) or d.get('no_reader_ok'): continue res.append(('X', f'{d["dir"]}/', '未声明 consumers, 也没写 no_reader_ok + 理由', 5)) for f in cfg_files(): rel = f.relative_to(P.CONFIGS).as_posix() if '/' not in rel or f.suffix == '.md' or f.name in top_ok: continue # 逐件"有没有人提过它"太吵 (170 件里绝大多数靠域级 consumers 覆盖) —— 只查**域级声明**。 # 域级没声明 consumers 又没写 no_reader_ok 的, 由上面的域检查报出来。 _ = rel # ── R8: 场配置 from src.windscada import config as WC for f, keys in WC.foreign_farm_files(): res.append(('i', f'configs/farms/{f.name}', f'非场定义 (顶层键 {keys}) ⇒ 不参与场加载, 已登记为机型/物理约束 profile', 0)) farm_dom = next((d for d in reg.get('domains') or [] if d['dir'] == 'farms'), {}) n_prof = sum(1 for f, _ in WC.foreign_farm_files()) declared_prof = next((s for s in (farm_dom.get('subkinds') or []) if 'profile' in s.get('name', '')), {}) if declared_prof and int(declared_prof.get('present', -1)) not in (-1, n_prof): res.append(('X', 'configs/farms/', f'登记表写 profile {declared_prof.get("present")} 个, 实际 {n_prof} 个', 9)) for name, src in WC.available().items(): if src != '内置' and not (P.ROOT / src).is_file(): res.append(('X', f'configs/farms/{name}', f'available() 列出了 {src}, 但文件不在', 9)) info = dict(config_files=len(cfg_files()), domains=len(domain_dirs), top_level=sorted(top_ok), known_missing=sorted(known_missing), farm_defs=list(WC.available()), farm_profiles=n_prof) rc_map = {r[3] for r in res if r[0] not in ('OK', 'i', '?') and r[3]} return (max(rc_map) if rc_map else 0), res, info def main() -> int: ap = argparse.ArgumentParser() ap.add_argument('--list', action='store_true', help='只打配置目录表') ap.add_argument('--json', action='store_true') a = ap.parse_args() rc, res, info = audit() if a.json: print(json.dumps(dict(rc=rc, results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in res], info=info), ensure_ascii=False, indent=1)) return rc if a.list or True: print('== 配置目录约定 (configs/registry.yaml) ==') print(f' 运行期单件: {", ".join(info["top_level"])}') for d in load_registry().get('domains') or []: print(f' {d["dir"]:20s} {d.get("kind", "")[:52]}') print(f' 配置文件合计 {info["config_files"]} 件 · 域 {info["domains"]} 个 · ' f'场定义 {len(info["farm_defs"])} 个 · 机型 profile {info["farm_profiles"]} 件') if not a.list: lvl = {} for level, item, note, r in res: lvl[level] = lvl.get(level, 0) + 1 print(f'\n== 检查: 不一致 {lvl.get("X", 0)} · 待处理 {lvl.get("!", 0)} · 提示 {lvl.get("?", 0)} · ' f'已知缺口 {lvl.get("i", 0)} ==') for level, item, note, r in res: if level in ('X', '!'): print(f' [{level}] {item}: {note}') seen = set() for level, item, note, r in res: if level == 'i' and note not in seen: seen.add(note) print(f' [i] {item}: {note}') print(f'结论: {"全部符合约定" if rc == 0 else "见上"}; 退出码 {rc}') return rc if __name__ == '__main__': from src import console console.soft() sys.exit(main())