Ver código fonte

2.11.1 P1 收口: 修安装器布局耦合+PS5.1读JSON编码+打包器模块目录收录; 新增R6/R7门与版本表覆盖门; 开箱验证5/5

zhouyang.xie 1 semana atrás
pai
commit
57fb5fa5d1

+ 2 - 1
app_common/common/app_common_guanlan/version.py

@@ -66,7 +66,8 @@ HISTORY: tuple[dict, ...] = (
               '三种写法全部照旧, 全仓约 120 处引用无需一次性改完; '
               '④ config_audit 的「配置取用口豁免」由按旧路径判改为**按取用口所在文件**判(位置无关), '
               '以后 paths.py 再搬也不会误报 R6。'
-              '验证: 旧路径导入与安装根实测正确(P.ROOT/P.RAW_ROOT/P.store/P.config 全对, 模块身份同一); '
+                            '⚠ P1 附带逮到并修掉的**入口耦合缺陷**: 安装器原先按 src/version.py 的**字面量**读包版本,该文件变转发壳后读不到 ⇒ 回落到读 dist-manifest.json,而 PS 5.1 的 Get-Content 默认按 ANSI 解码无 BOM 的 UTF-8 ⇒ 中文乱码、ConvertFrom-Json 抛异常 ⇒ **安装中断**(开箱验证从 5/5 掉到 0)。修法: install.ps1 与 install.sh 改为**按候选路径解析**(公共层新路径优先,旧路径兜底),且 JSON 一律按 UTF-8 读;并新增两条门防复发 —— R6「入口与布局解耦」(安装器必须能在当前布局下解析出版本)与 R7「入口读 JSON 必须指定 UTF-8」,都并入 module_边界审计。 '
+'验证: 旧路径导入与安装根实测正确(P.ROOT/P.RAW_ROOT/P.store/P.config 全对, 模块身份同一); '
               'raw_scan --check rc=0、rebuild_all --dry-run rc=0(23 步计划不变)、三份交付文档重渲通过; '
               'guanlan.py check、config_audit、pages_audit、chain_gap、version_log、detail_deps、'
               'check_portability、module_boundary_audit 全 rc=0。'),

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 1
docs/版本记录.md


+ 14 - 5
install.ps1

@@ -21,14 +21,23 @@ Set-Location $PSScriptRoot
 function Say($m){ Write-Host $m }
 
 Say "== 0/7 安装前检查: 是否已安装及版本差异 (用户令 2026-09-17)"
-# 版本单一真源 = src\version.py; 已装记录 = <安装根>\install-info.json (安装脚本写)
+# 版本单一真源 = 公共层的 version.py(模块化重构后它在 app_common\common\app_common_guanlan\);
+# ★2026-09-22 布局无关化: 原先只按 "src\version.py" 读字面量 —— 该路径现在是**兼容转发壳**(无 VERSION 行),
+#   于是回落到读 dist-manifest.json, 而 PS 5.1 的 Get-Content 默认按 ANSI 解码无 BOM 的 UTF-8 ⇒ 中文乱码、
+#   ConvertFrom-Json 抛异常、安装中断。现在: ①按候选路径逐个找版本文件(新路径优先);
+#   ②JSON 一律 -Encoding UTF8 读。
 $pkgVer = ""
-$verLine = Select-String -Path "src\version.py" -Pattern "^VERSION\s*=\s*'([^']+)'" -ErrorAction SilentlyContinue | Select-Object -First 1
-if ($verLine) { $pkgVer = $verLine.Matches[0].Groups[1].Value }
-elseif (Test-Path "dist-manifest.json") { $pkgVer = (Get-Content "dist-manifest.json" -Raw | ConvertFrom-Json).version }
+foreach ($vf in @("app_common\common\app_common_guanlan\version.py", "src\version.py")) {
+  if (-not (Test-Path $vf)) { continue }
+  $verLine = Select-String -Path $vf -Pattern "^VERSION\s*=\s*'([^']+)'" -ErrorAction SilentlyContinue | Select-Object -First 1
+  if ($verLine) { $pkgVer = $verLine.Matches[0].Groups[1].Value; break }
+}
+if (-not $pkgVer -and (Test-Path "dist-manifest.json")) {
+  $pkgVer = (Get-Content "dist-manifest.json" -Raw -Encoding UTF8 | ConvertFrom-Json).version
+}
 $prev = $null
 if (Test-Path "install-info.json") {
-  try { $prev = Get-Content "install-info.json" -Raw | ConvertFrom-Json } catch { $prev = $null }
+  try { $prev = Get-Content "install-info.json" -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $prev = $null }
 }
 $prevVer = if ($prev) { [string]$prev.version } else { "" }
 if (-not $prev) {

+ 8 - 1
install.sh

@@ -23,7 +23,14 @@ say "== 观澜 v2 安装 · $DISTRO · $ARCH"
 
 # 0) 安装前检查: 是否已安装及版本 (用户令 2026-09-17)
 say "== 0/7 安装前检查: 是否已安装及版本差异"
-PKG_VER=$(sed -n "s/^VERSION *= *'\([^']*\)'.*/\1/p" src/version.py 2>/dev/null | head -1)
+# 版本单一真源 = 公共层的 version.py(模块化重构后位于 app_common/common/app_common_guanlan/);
+# ★2026-09-22 布局无关化: 新路径优先, src/version.py 现在只是兼容转发壳(无 VERSION 行)。
+PKG_VER=""
+for _vf in app_common/common/app_common_guanlan/version.py src/version.py; do
+  [ -f "$_vf" ] || continue
+  PKG_VER=$(sed -n "s/^VERSION *= *'\([^']*\)'.*/\1/p" "$_vf" 2>/dev/null | head -1)
+  [ -n "$PKG_VER" ] && break
+done
 [ -n "$PKG_VER" ] || PKG_VER=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' dist-manifest.json 2>/dev/null | head -1)
 PREV_VER=""; PREV_AT=""; PREV_MODE=""
 if [ -f install-info.json ]; then

+ 180 - 151
scripts/module_boundary_audit.py

@@ -1,151 +1,180 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""模块边界审计(用户令 2026-09-22:按算法/数据接入管理/前端/后端等模块重构,组件化、高内聚低耦合、可插拔)。
-
-## 它查什么(每条都由 `configs/modules.yaml` 驱动)
-
-| 规则 | 内容 | 退出码 |
-|---|---|---|
-| R1 结构 | 每个登记模块目录含 `common/<pkg>/__init__.py`、`common/<pkg>/api.py`、`README.md` | 5 |
-| R2 边界 | 模块之间**只许经对方 `api.py`**:`app_x/**/*.py` 里 import 另一个 `app_y` 的实现包(非 `api`)即违规 | 6 |
-| R3 依赖方向 | 只许 import `allow` 里列出的模块;反向依赖/成环即违规 | 7 |
-| R4 公共层纯净 | `app_common/**` 不得 import 任何其它业务模块 | 7 |
-| R5 接口可用 | 每个 `api.py` 的 `_TARGETS` 转发目标(既有实现的文件路径)必须真实存在 | 8 |
-
-另外**统计迁移进度**(不算失败):`src/**`、`scripts/**` 里还有多少件尚未迁进模块目录(P1–P7 的度量)。
-
-## 用法
-    python scripts/module_boundary_audit.py            # 审计(rc=0 通过)
-    python scripts/module_boundary_audit.py --brief    # 只打结论与进度
-退出码: 0 通过 · 5 结构缺失 · 6 边界违规 · 7 依赖违规 · 8 接口目标缺失
-"""
-from __future__ import annotations
-
-import ast
-import pathlib
-import sys
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-
-from src import paths as P                                                  # noqa: E402
-
-
-def load_registry() -> dict:
-    import yaml
-    return yaml.safe_load(P.config('modules.yaml').read_text(encoding='utf-8'))
-
-
-def module_pkg(name: str) -> str:
-    return f'{name}_guanlan'
-
-
-def _imports_of(path: pathlib.Path) -> list[str]:
-    """→ 该文件里所有 import 的模块名(`import a.b` 与 `from a.b import c` 都算 a.b)。"""
-    try:
-        tree = ast.parse(path.read_text(encoding='utf-8'))
-    except SyntaxError:
-        return []
-    out = []
-    for n in ast.walk(tree):
-        if isinstance(n, ast.Import):
-            out += [a.name for a in n.names]
-        elif isinstance(n, ast.ImportFrom) and n.module:
-            out.append(n.module)
-    return out
-
-
-def audit(verbose: bool = True):
-    reg = load_registry()
-    mods = {m['name']: m for m in reg['modules']}
-    problems: list[tuple[str, str]] = []          # (规则, 说明)
-    notes: list[str] = []
-
-    # R1 结构
-    for name, m in mods.items():
-        base = ROOT / name
-        for rel in (f'common/__init__.py', f'common/{module_pkg(name)}/__init__.py',
-                    f'common/{module_pkg(name)}/api.py', 'README.md'):
-            if not (base / rel).is_file():
-                problems.append(('R1', f'{name}/ 缺 {rel}'))
-    # R5 接口目标存在
-    for name in mods:
-        api = ROOT / name / 'common' / module_pkg(name) / 'api.py'
-        if not api.is_file():
-            continue
-        try:
-            tree = ast.parse(api.read_text(encoding='utf-8'))
-        except SyntaxError as e:
-            problems.append(('R1', f'{name}/api.py 语法错误: {e}'))
-            continue
-        tg = {}
-        for n in tree.body:
-            if isinstance(n, ast.Assign) and getattr(n.targets[0], 'id', '') == '_TARGETS':
-                try:
-                    tg = ast.literal_eval(n.value)
-                except Exception:  # noqa: BLE001
-                    tg = {}
-        for pub, target in tg.items():
-            rel = pathlib.Path(*target.split('.'))
-            if not ((ROOT / rel).with_suffix('.py').is_file() or (ROOT / rel / '__init__.py').is_file()):
-                problems.append(('R5', f'{name}.api.{pub} → {target} 的转发目标不存在'))
-
-    # R2/R3/R4 边界与依赖
-    for name, m in mods.items():
-        allow = set(m.get('allow') or [])
-        for f in (ROOT / name).rglob('*.py'):
-            for imp in _imports_of(f):
-                parts = imp.split('.')
-                if parts[0] not in mods or parts[0] == name:
-                    continue
-                other = parts[0]
-                # 只许 app_<other>.common.app_<other>_guanlan.api
-                ok_face = (len(parts) >= 4 and parts[1] == 'common'
-                           and parts[3] == 'api' and parts[2] == module_pkg(other))
-                if not ok_face:
-                    problems.append(('R2', f'{f.relative_to(ROOT).as_posix()} 直接 import {imp}'
-                                           f'(应走 {other}.common.{module_pkg(other)}.api)'))
-                if other not in allow:
-                    problems.append(('R3', f'{f.relative_to(ROOT).as_posix()} import {other}'
-                                           f',但 {name} 的 allow 未包含它'))
-                if name == 'app_common':
-                    problems.append(('R4', f'{f.relative_to(ROOT).as_posix()} 公共层依赖了业务模块 {other}'))
-
-    # 迁移进度(不算失败)
-    legacy = [p for p in list((ROOT / 'src').rglob('*.py')) + list((ROOT / 'scripts').rglob('*.py'))
-              if p.is_file()]
-    notes.append(f'尚未迁入模块目录的既有实现件: src/** {len(list((ROOT / "src").rglob("*.py")))} 件 · '
-                 f'scripts/** {len(list((ROOT / "scripts").rglob("*.py")))} 件(迁移进度按 docs/重构方案_模块化_v0.1.md 的 P1–P7 推进)')
-
-    rc = 0
-    if any(p[0] in ('R1',) for p in problems):
-        rc = 5
-    if any(p[0] == 'R2' for p in problems):
-        rc = rc or 6
-    if any(p[0] in ('R3', 'R4') for p in problems):
-        rc = rc or 7
-    if any(p[0] == 'R5' for p in problems):
-        rc = rc or 8
-    if verbose:
-        print('模块边界审计 · 登记模块 %d 个(%s)' % (len(mods), '、'.join(mods)))
-        for n in notes:
-            print('  [i]', n)
-        if problems:
-            for rule, msg in problems:
-                print('  [X] %s %s' % (rule, msg))
-        else:
-            print('  [OK] 结构齐全 · 模块间只经 api 调用 · 依赖方向合规 · 公共层纯净 · 接口目标全在位')
-    return rc, problems, notes, mods
-
-
-def main() -> int:
-    brief = '--brief' in sys.argv
-    rc, problems, notes, mods = audit(verbose=not brief)
-    if brief:
-        print('[OK] module_boundary_audit rc=0(登记 %d 模块)' % len(mods) if rc == 0
-              else '[X] module_boundary_audit rc=%d(%d 条违规)' % (rc, len(problems)))
-    return rc
-
-
-if __name__ == '__main__':
-    raise SystemExit(main())
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""模块边界审计(用户令 2026-09-22:按算法/数据接入管理/前端/后端等模块重构,组件化、高内聚低耦合、可插拔)。
+
+## 它查什么(每条都由 `configs/modules.yaml` 驱动)
+
+| 规则 | 内容 | 退出码 |
+|---|---|---|
+| R1 结构 | 每个登记模块目录含 `common/<pkg>/__init__.py`、`common/<pkg>/api.py`、`README.md` | 5 |
+| R2 边界 | 模块之间**只许经对方 `api.py`**:`app_x/**/*.py` 里 import 另一个 `app_y` 的实现包(非 `api`)即违规 | 6 |
+| R3 依赖方向 | 只许 import `allow` 里列出的模块;反向依赖/成环即违规 | 7 |
+| R4 公共层纯净 | `app_common/**` 不得 import 任何其它业务模块 | 7 |
+| R5 接口可用 | 每个 `api.py` 的 `_TARGETS` 转发目标(既有实现的文件路径)必须真实存在 | 8 |
+
+另外**统计迁移进度**(不算失败):`src/**`、`scripts/**` 里还有多少件尚未迁进模块目录(P1–P7 的度量)。
+
+## 用法
+    python scripts/module_boundary_audit.py            # 审计(rc=0 通过)
+    python scripts/module_boundary_audit.py --brief    # 只打结论与进度
+退出码: 0 通过 · 5 结构缺失 · 6 边界违规 · 7 依赖违规 · 8 接口目标缺失
+"""
+from __future__ import annotations
+
+import ast
+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
+
+
+def load_registry() -> dict:
+    import yaml
+    return yaml.safe_load(P.config('modules.yaml').read_text(encoding='utf-8'))
+
+
+def module_pkg(name: str) -> str:
+    return f'{name}_guanlan'
+
+
+def _imports_of(path: pathlib.Path) -> list[str]:
+    """→ 该文件里所有 import 的模块名(`import a.b` 与 `from a.b import c` 都算 a.b)。"""
+    try:
+        tree = ast.parse(path.read_text(encoding='utf-8'))
+    except SyntaxError:
+        return []
+    out = []
+    for n in ast.walk(tree):
+        if isinstance(n, ast.Import):
+            out += [a.name for a in n.names]
+        elif isinstance(n, ast.ImportFrom) and n.module:
+            out.append(n.module)
+    return out
+
+
+def audit(verbose: bool = True):
+    reg = load_registry()
+    mods = {m['name']: m for m in reg['modules']}
+    problems: list[tuple[str, str]] = []          # (规则, 说明)
+    notes: list[str] = []
+
+    # R1 结构
+    for name, m in mods.items():
+        base = ROOT / name
+        for rel in (f'common/__init__.py', f'common/{module_pkg(name)}/__init__.py',
+                    f'common/{module_pkg(name)}/api.py', 'README.md'):
+            if not (base / rel).is_file():
+                problems.append(('R1', f'{name}/ 缺 {rel}'))
+    # R5 接口目标存在
+    for name in mods:
+        api = ROOT / name / 'common' / module_pkg(name) / 'api.py'
+        if not api.is_file():
+            continue
+        try:
+            tree = ast.parse(api.read_text(encoding='utf-8'))
+        except SyntaxError as e:
+            problems.append(('R1', f'{name}/api.py 语法错误: {e}'))
+            continue
+        tg = {}
+        for n in tree.body:
+            if isinstance(n, ast.Assign) and getattr(n.targets[0], 'id', '') == '_TARGETS':
+                try:
+                    tg = ast.literal_eval(n.value)
+                except Exception:  # noqa: BLE001
+                    tg = {}
+        for pub, target in tg.items():
+            rel = pathlib.Path(*target.split('.'))
+            if not ((ROOT / rel).with_suffix('.py').is_file() or (ROOT / rel / '__init__.py').is_file()):
+                problems.append(('R5', f'{name}.api.{pub} → {target} 的转发目标不存在'))
+
+    # R2/R3/R4 边界与依赖
+    for name, m in mods.items():
+        allow = set(m.get('allow') or [])
+        for f in (ROOT / name).rglob('*.py'):
+            for imp in _imports_of(f):
+                parts = imp.split('.')
+                if parts[0] not in mods or parts[0] == name:
+                    continue
+                other = parts[0]
+                # 只许 app_<other>.common.app_<other>_guanlan.api
+                ok_face = (len(parts) >= 4 and parts[1] == 'common'
+                           and parts[3] == 'api' and parts[2] == module_pkg(other))
+                if not ok_face:
+                    problems.append(('R2', f'{f.relative_to(ROOT).as_posix()} 直接 import {imp}'
+                                           f'(应走 {other}.common.{module_pkg(other)}.api)'))
+                if other not in allow:
+                    problems.append(('R3', f'{f.relative_to(ROOT).as_posix()} import {other}'
+                                           f',但 {name} 的 allow 未包含它'))
+                if name == 'app_common':
+                    problems.append(('R4', f'{f.relative_to(ROOT).as_posix()} 公共层依赖了业务模块 {other}'))
+
+    # R6 入口与布局解耦: 安装器必须能在当前布局下解析出包版本
+    _ver_cands = ['app_common/common/app_common_guanlan/version.py', 'src/version.py']
+    _found = None
+    for _c in _ver_cands:
+        _f = ROOT / _c
+        if _f.is_file() and re.search(r"^VERSION\s*=\s*'([^']+)'", _f.read_text(encoding='utf-8'), re.M):
+            _found = (_c, re.search(r"^VERSION\s*=\s*'([^']+)'", _f.read_text(encoding='utf-8'), re.M).group(1))
+            break
+    if not _found:
+        problems.append(('R6', f'当前布局下没有任何候选版本文件含 `VERSION = \'…\'` 字面量(候选: {_ver_cands})'
+                               ' ⇒ install.ps1/install.sh 会读不到包版本'))
+    else:
+        _ps1 = (ROOT / 'install.ps1').read_text(encoding='utf-8') if (ROOT / 'install.ps1').is_file() else ''
+        _sh = (ROOT / 'install.sh').read_text(encoding='utf-8') if (ROOT / 'install.sh').is_file() else ''
+        if _ps1 and 'app_common' not in _ps1:
+            problems.append(('R6', 'install.ps1 未包含新布局的候选路径(app_common/...)⇒ 平台件搬家后会读不到版本'))
+        if _sh and 'app_common' not in _sh:
+            problems.append(('R6', 'install.sh 未包含新布局的候选路径(app_common/...)⇒ 平台件搬家后会读不到版本'))
+
+    # R7 入口读 JSON 必须指定 UTF-8(PS 5.1 默认 ANSI)
+    _ps1f = ROOT / 'install.ps1'
+    if _ps1f.is_file():
+        for _i, _ln in enumerate(_ps1f.read_text(encoding='utf-8').splitlines(), 1):
+            if _ln.strip().startswith('#'):
+                continue          # 注释里提到 Get-Content/.json 不算
+            if 'Get-Content' in _ln and '.json' in _ln and '-Encoding' not in _ln:
+                problems.append(('R7', f'install.ps1:{_i} 读 JSON 未指定 -Encoding UTF8 ⇒ PS 5.1 会按 ANSI 解码中文'))
+
+    # 迁移进度(不算失败)
+    legacy = [p for p in list((ROOT / 'src').rglob('*.py')) + list((ROOT / 'scripts').rglob('*.py'))
+              if p.is_file()]
+    notes.append(f'尚未迁入模块目录的既有实现件: src/** {len(list((ROOT / "src").rglob("*.py")))} 件 · '
+                 f'scripts/** {len(list((ROOT / "scripts").rglob("*.py")))} 件(迁移进度按 docs/重构方案_模块化_v0.1.md 的 P1–P7 推进)')
+
+    rc = 0
+    if any(p[0] in ('R1',) for p in problems):
+        rc = 5
+    if any(p[0] == 'R2' for p in problems):
+        rc = rc or 6
+    if any(p[0] in ('R3', 'R4', 'R6', 'R7') for p in problems):
+        rc = rc or 7
+    if any(p[0] == 'R5' for p in problems):
+        rc = rc or 8
+    if verbose:
+        print('模块边界审计 · 登记模块 %d 个(%s)' % (len(mods), '、'.join(mods)))
+        for n in notes:
+            print('  [i]', n)
+        if problems:
+            for rule, msg in problems:
+                print('  [X] %s %s' % (rule, msg))
+        else:
+            print('  [OK] 结构齐全 · 模块间只经 api 调用 · 依赖方向合规 · 公共层纯净 · 接口目标全在位')
+    return rc, problems, notes, mods
+
+
+def main() -> int:
+    brief = '--brief' in sys.argv
+    rc, problems, notes, mods = audit(verbose=not brief)
+    if brief:
+        print('[OK] module_boundary_audit rc=0(登记 %d 模块)' % len(mods) if rc == 0
+              else '[X] module_boundary_audit rc=%d(%d 条违规)' % (rc, len(problems)))
+    return rc
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 18 - 1
scripts/pack_dist.py

@@ -84,9 +84,26 @@ import zipfile
 ROOT = pathlib.Path(__file__).resolve().parents[1]
 sys.path.insert(0, str(ROOT))
 from src import version as V       # noqa: E402  ★ 版本号与包名的**唯一真源** (用户令 2026-09-17)
+from src import paths as P         # noqa: E402  配置取用口(模块登记表也从这里读)
+
+
+def module_dirs() -> list[str]:
+    """模块目录清单 = `configs/modules.yaml` 的 modules(用户令 2026-09-22 模块化重构)。
+
+    ★为什么不写死: 2.11.1 打包时实逮 —— 收录清单是白名单, 模块目录 `app_*` 不在其中 ⇒
+    包内没有公共层, 而 install.ps1 已按新布局找 `app_common/.../version.py` ⇒ 入口引用闭合检查报
+    "引用的文件包里没有", 包直接卡住。改成读登记表后, 以后加/改模块(例如 app_ETL 改名)无需再动打包器。
+    """
+    try:
+        import yaml
+        reg = yaml.safe_load(P.config('modules.yaml').read_text(encoding='utf-8'))
+        return [m['name'] for m in (reg.get('modules') or [])]
+    except Exception:                                    # 登记表缺失/损坏时保守回退(不猜目录名)
+        return []
+
 
 INCLUDE_DIRS = ['src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs',
-                'wheels', 'vendor']
+                'wheels', 'vendor'] + module_dirs()
 PRODUCTS_DIR = 'outputs'          # 产物仓: **默认不进包** (用户令 2026-09-17); 要带用 --with-products
 # ★ 不打包"修复记录/临时/脚手架"类目录 (2026-09-12 用户令): 本包已纳入 git 管理, 变更历史由版本库承载,
 #   不必在交付物里再带一份 `_修复记录_<日期>\`。原先这里含 `_修复记录_20260911` (该目录的**内容**已并入

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff