Files
dsh_ai1net_server/交付物/验证脚本/v-plugin.sh
T

219 lines
11 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# =============================================================================
# v-plugin.sh —— S0 尺子②:插件四判据(装配 / 运行 / 取数 / 回调)
# 规格来源:交付物/功能打包到基础插件-分步实施方案-20260926.md §3.10.1(尺子表)· §3.10.2 S0-3
# -----------------------------------------------------------------------------
# 约定
# 1. **退出码即结论**:rc=0 绿 / rc=1 红 / rc=3 **未取到**(⛔ 3 不是绿 —— 取不到就如实报,⛔ 不折算成"已知")
# 2. **只许测活体**:装配/运行 取真实 profile 与真实 boot 日志;取数/回调 **真实 import 并真实执行 apply()**
# —— ⛔ 不用 dump-config 类"不 boot 就出结果"的路子;⛔ 不用静态 grep 冒充运行结论
# 3. 落点:脚本在 交付物/验证脚本/;临时 harness 落 tmp/p39/
# 用法
# bash v-plugin.sh 装配|运行|取数|回调 [--target N] [--home H] [--profile-json F] [--log F] [--entry F]
# 英文别名:assembly|run|fetch|callback
# 靶子:默认 @dsh-client/portal-first-screen(**已有插件**,已装在开发态桌面 profile)
# -----------------------------------------------------------------------------
# 复核命令
# 装配: cat $HOME/profiles/desktop/package.json ;node createRequire().resolve(target)
# 运行: ls $HOME/logs/startup-*.log ;grep -n <target> <最新启动日志>
# 取数: node tmp/p39/_probe.mjs fetch <entry>
# 回调: node tmp/p39/_probe.mjs callback <entry>
# =============================================================================
set -u
SELF_DIR="$(cd "$(dirname "$0")" && pwd -W)"
[ -d "$SELF_DIR" ] || SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
WS="$(cd "$SELF_DIR/../.." && pwd -W)"
[ -d "$WS" ] || WS="$(cd "$SELF_DIR/../.." && pwd)"
OUT="${P39_OUT:-$WS/tmp/p39}"
PTRACE="$OUT/_probe.mjs"
NODE="${NODE:-E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe}"
PY="${PY:-E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe}"
DSH_DESKTOP_HOME="${DSH_DESKTOP_HOME:-E:/github/dsh-desktop-0.1.7rc2/apps/desktop/.desktop-build/development/home-adapt}"
TARGET="@dsh-client/portal-first-screen"
PROFILE_JSON=""
LOG_FILE=""
ENTRY=""
FACE_RAW="${1:-}"
[ -z "$FACE_RAW" ] && { echo "用法: v-plugin.sh <装配|运行|取数|回调> [--target N] [--home H]..."; exit 2; }
shift || true
while [ $# -gt 0 ]; do
case "$1" in
--target) TARGET="$2"; shift 2 ;;
--home) DSH_DESKTOP_HOME="$2"; shift 2 ;;
--profile-json) PROFILE_JSON="$2"; shift 2 ;;
--log) LOG_FILE="$2"; shift 2 ;;
--entry) ENTRY="$2"; shift 2 ;;
*) echo "⛔ 未知参数:$1"; exit 2 ;;
esac
done
case "$FACE_RAW" in
装配|assembly|a) FACE=装配 ;;
运行|run|r) FACE=运行 ;;
取数|fetch|f) FACE=取数 ;;
回调|callback|c) FACE=回调 ;;
*) echo "⛔ 未知判据面:$FACE_RAW(只许 装配|运行|取数|回调)"; exit 2 ;;
esac
[ -z "$PROFILE_JSON" ] && PROFILE_JSON="$DSH_DESKTOP_HOME/profiles/desktop/package.json"
PROFILE_DIR="$(dirname "$PROFILE_JSON")"
mkdir -p "$OUT"
green() { echo "✅ 绿(rc=0)|判据【$FACE】:$*"; exit 0; }
red() { echo "❌ 红(rc=1)|判据【$FACE】:$*"; exit 1; }
na() { echo "⚠️ 未取到(rc=3)|判据【$FACE】:$*"; exit 3; }
resolve_entry() {
"$NODE" -e 'const{createRequire}=require("module");const r=createRequire(process.argv[1]);process.stdout.write(r.resolve(process.argv[2]))' "$PROFILE_JSON" "$TARGET" 2>/dev/null
}
case "$FACE" in
# ---------------------------------------------------------------------------
装配)
[ -f "$PROFILE_JSON" ] || na "profile 不存在:$PROFILE_JSON"
echo "── 【装配】靶子=$TARGET profile=$PROFILE_JSON"
PY_OUT="$("$PY" - "$PROFILE_JSON" "$TARGET" <<'PYEOF' 2>/dev/null
import json, sys
pj, target = sys.argv[1], sys.argv[2]
d = json.load(open(pj, encoding='utf-8'))
b = ((d.get('dsh') or {}).get('profile') or {}).get('bundles') or []
print("IN_BUNDLES=%s" % ("yes" if target in b else "no"))
print("BUNDLE_COUNT=%d" % len(b))
PYEOF
)"
echo " ① 声明在 profile bundles:$(printf '%s\n' "$PY_OUT" | sed -n 's/^IN_BUNDLES=//p')(该 profile 共 $(printf '%s\n' "$PY_OUT" | sed -n 's/^BUNDLE_COUNT=//p') 个 bundle)"
printf '%s\n' "$PY_OUT" | grep -q '^IN_BUNDLES=yes' || red "① 靶子不在 dsh.profile.bundles 里 ⇒ 装配前提不成立"
RESOLVED="$(resolve_entry)"
if [ -z "$RESOLVED" ] || [ ! -f "$RESOLVED" ]; then
red "② 从 profile 目录解析不到靶子入口(createRequire.resolve 失败或文件不存在):'$RESOLVED'"
fi
echo " ② 自 profile 目录解析成功:$RESOLVED"
PKGJSON="$("$NODE" -e 'const{createRequire}=require("module");const r=createRequire(process.argv[1]);process.stdout.write(r.resolve(process.argv[2]+"/package.json"))' "$PROFILE_JSON" "$TARGET" 2>/dev/null)"
[ -f "$PKGJSON" ] || red "③ 解析不到靶子 package.json"
PKGDIR="$(dirname "$PKGJSON")"
PATCH="$(sed -n 's/.*"patch"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PKGJSON" | head -1)"
if [ -n "$PATCH" ]; then
[ -f "$PKGDIR/${PATCH#./}" ] || red "③ 声明了装载补丁 $PATCH 但文件不存在:$PKGDIR/${PATCH#./}"
echo " ③ 装载补丁在位:${PATCH#./}"
else
echo " ③ ⚠️ 该包未声明 dsh.bundle.patch(非 profile 装载形态)"
fi
echo " 入口文件:$RESOLVED ($(find "$RESOLVED" -type f -printf '%s\n' 2>/dev/null || wc -c < "$RESOLVED") B)"
green "三条装配前提(bundles 声明 / 可解析 / 产物在位)全部成立"
;;
# ---------------------------------------------------------------------------
运行)
HOME_DIR="$DSH_DESKTOP_HOME"
if [ -z "$LOG_FILE" ]; then
LOG_FILE="$(ls -1t "$HOME_DIR/logs/"startup-*.log 2>/dev/null | head -1)"
fi
[ -n "$LOG_FILE" ] && [ -f "$LOG_FILE" ] || na "无启动日志可取($HOME_DIR/logs/startup-*.log 不存在)⇒ 运行态证据只能取活体 boot,本机当前无新 boot 记录"
echo "── 【运行】靶子=$TARGET 日志=$LOG_FILE(mtime $(date -r "$LOG_FILE" -Iseconds 2>/dev/null || echo '?'))"
HITS="$(grep -n "$TARGET" "$LOG_FILE" 2>/dev/null | head -5)"
FAILBLOCK="$(grep -c 'Failed plugins' "$LOG_FILE" 2>/dev/null || echo 0)"
if [ -n "$HITS" ]; then
echo " 日志中命中靶子的失败条目:"
printf '%s\n' "$HITS" | sed 's/^/ /' | cut -c1-160
red "靶子出现在启动失败的 Failed plugins 列表里 ⇒ 运行面红"
fi
echo " 日志中无靶子的失败条目(该日志 'Failed plugins' 出现 $FAILBLOCK 次)"
echo ' ⚠️ 证据强度:**无失败条目**(弱证据,⛔ 不是「已挂载」的证明)'
echo " ⚠️ 已知坑:**启动日志只记失败条目**,插件自己的 logger.info 不在其中 ⇒「成功挂载」只能靠 RPC/浏览器级"
green "靶子未出现于启动失败面(本面为**失败面判据**:出现即红;不出现 ≠ 已挂载)"
;;
# ---------------------------------------------------------------------------
取数|回调)
if [ -z "$ENTRY" ]; then
ENTRY="$(resolve_entry)"
[ -n "$ENTRY" ] && [ -f "$ENTRY" ] || na "解析不到靶子入口 ⇒ 无法真实 import"
fi
[ -f "$ENTRY" ] || na "入口文件不存在:$ENTRY"
cat > "$PTRACE" <<'JSEOF'
import { pathToFileURL } from 'node:url';
const face = process.argv[2];
const entry = process.argv[3];
const calls = [];
function summarize(a) {
if (a === null) return 'null';
const t = typeof a;
if (t === 'string') return a.length > 70 ? JSON.stringify(a.slice(0, 70) + '…') : JSON.stringify(a);
if (t === 'function') return '<fn>';
if (t === 'number' || t === 'boolean' || t === 'undefined') return String(a);
if (t === 'object') {
if (typeof a.path === 'string') return JSON.stringify({ path: a.path, methods: a.methods === undefined ? null : a.methods });
try { const s = JSON.stringify(a); return s && s.length > 70 ? s.slice(0, 70) + '…' : String(s); } catch (e) { return '<obj>'; }
}
return String(a);
}
function mkSpy(path, depth) {
const fn = function () {};
return new Proxy(fn, {
get(t, k) {
if (typeof k === 'symbol') return undefined;
if (k === 'then') return undefined;
if (depth > 5) return undefined;
return mkSpy(path + '.' + String(k), depth + 1);
},
apply(t, th, args) { calls.push({ call: path, args: args.map(summarize) }); return mkSpy(path + '()', depth + 1); },
});
}
const out = (o) => process.stdout.write(JSON.stringify(o, null, 1) + '\n');
let mod;
try { mod = await import(pathToFileURL(entry).href); }
catch (e) { out({ face, entry, verdict: 'UNAVAILABLE', reason: 'import failed: ' + (e && e.message) }); process.exit(3); }
if (face === 'fetch') {
const checks = {
'导出 apply 是函数': typeof mod.apply === 'function',
'导出 inject 是数组': Array.isArray(mod.inject),
'inject 含 connection(E1 路由能挂的前提)': Array.isArray(mod.inject) && mod.inject.includes('connection'),
'导出 name 是非空字符串': typeof mod.name === 'string' && mod.name.length > 0,
};
const bad = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k);
out({ face, entry, exportsOnModule: Object.keys(mod), checks, verdict: bad.length ? 'RED' : 'GREEN' });
process.exit(bad.length ? 1 : 0);
}
const ctx = mkSpy('ctx', 0);
let applyErr = null;
try {
const p = Promise.resolve().then(() => mod.apply(ctx));
const to = new Promise((_, rej) => setTimeout(() => rej(new Error('timeout 8000ms')), 8000));
await Promise.race([p, to]);
} catch (e) { applyErr = (e && e.message) || String(e); }
const onCalls = calls.filter((c) => c.call === 'ctx.on');
const routeCalls = calls.filter((c) => c.call === 'ctx.connection.fetch.register');
const checks = {
'apply 可调用且未抛错': applyErr === null,
'注册了回调(ctx.on 至少一次)': onCalls.length >= 1,
};
const bad = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k);
out({ face, entry, applyError: applyErr, callCount: calls.length, onCalls, routeCalls, checks, verdict: bad.length ? 'RED' : 'GREEN' });
process.exit(bad.length ? 1 : 0);
JSEOF
echo "── 【$FACE】靶子=$TARGET entry=$ENTRY"
if [ "$FACE" = "取数" ]; then
echo ' ⚠️ 证据强度:**真实 import + 真实模块求值**(产物被真的执行了)⇒ 对「包产物能不能被宿主加载器吃下」这一面成立;'
echo ' ⛔ 本面**不含端到端 RPC 取数**(本机当前无活体实例/RPC 靶子)⇒ 端到端取数须等 S2 后在实例内补测'
else
echo ' ⚠️ 证据强度:**真实调用 apply(ctx)**(spy ctx 记录真实副作用;apply 抛错即红)'
fi
ROUT="$("$NODE" "$PTRACE" "$([ "$FACE" = "取数" ] && echo fetch || echo callback)" "$ENTRY" 2>&1)"; RC=$?
printf '%s\n' "$ROUT" | sed 's/^/ /' | cut -c1-200
case "$RC" in
0) green "靶子真实产物满足本面判据(原始输出见上,退出码 rc=0)" ;;
3) na "真实 import 未成功 ⇒ 本面未取到(⛔ 不是绿):$(printf '%s' "$ROUT" | tr '\n' ' ' | head -c 200)" ;;
*) red "本面判据不成立(rc=$RC,原始输出见上)" ;;
esac
;;
esac