@haiyangbg/buildbeat 0.0.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +296 -0
- package/LICENSE +21 -0
- package/README.en.md +288 -0
- package/README.md +283 -4
- package/SKILL.md +303 -0
- package/bin/buildbeat.js +5 -0
- package/bin/solobaton.js +6 -0
- package/docs/CAPABILITY-MATRIX.md +50 -0
- package/docs/CHECKS.md +326 -0
- package/docs/CLI-PILOT-2026-08-23.md +25 -0
- package/docs/CLI-STRATEGY-2026-08.md +55 -0
- package/docs/CLI.md +233 -0
- package/docs/EXECUTION-PLAN.md +487 -0
- package/docs/LEGACY-V1.16-MIGRATION.md +54 -0
- package/docs/PHASE1-PILOT-2026-08-24.md +32 -0
- package/docs/PHASE2-BUILDBEAT-PILOT-2026-08-25.md +75 -0
- package/docs/PHASE2-PILOT-2026-08-25.md +88 -0
- package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +42 -0
- package/docs/PHASE4-STABILITY-AUDIT-2026-08-25.md +33 -0
- package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +52 -0
- package/docs/RELEASING.md +117 -0
- package/docs/ROADMAP.md +873 -0
- package/example/.buildbeat/manifest.json +45 -0
- package/example/AGENTS.md +19 -0
- package/example/ARCHITECTURE.md +39 -0
- package/example/BUILDBEAT.md +17 -0
- package/example/CLAUDE.md +7 -0
- package/example/README.md +53 -0
- package/example/contracts/PROTOCOL.md +38 -0
- package/example/pm/NOW.md +22 -0
- package/example/pm/adr/ADR-0001-local-first-sqlite.md +25 -0
- package/example/pm/adr/README.md +7 -0
- package/example/pm/archive//344/270/200/346/234/237/evidence/gate1.md +5 -0
- package/example/pm/archive//344/270/200/346/234/237/evidence/gate2.md +5 -0
- package/example/pm/archive//344/270/200/346/234/237/evidence/gate3.md +5 -0
- package/example/pm/archive//344/270/200/346/234/237/evidence/gate4.md +5 -0
- package/example/pm/archive//344/270/200/346/234/237/evidence/implementation.md +5 -0
- package/example/pm/decisions.md +20 -0
- package/example/pm/status//344/272/247/345/223/201.md +20 -0
- package/example/pm/status//345/205/250/346/240/210.md +15 -0
- package/example/pm/status//346/265/213/350/257/225.md +15 -0
- package/example/pm//344/270/200/346/234/237-/347/234/213/346/235/277.md +97 -0
- package/example/standards/CODE.md +18 -0
- package/example/standards/DESIGN.md +34 -0
- package/example/standards/REVIEW.md +16 -0
- package/example/standards/STACK.md +31 -0
- package/lessons.md +119 -0
- package/package.json +48 -7
- package/src/cli.js +323 -0
- package/src/constants.js +199 -0
- package/src/doctor.js +267 -0
- package/src/planner.js +251 -0
- package/src/project.js +839 -0
- package/src/upgrader.js +1249 -0
- package/src/writer.js +534 -0
- package/templates/.claude/agents/reviewer.md +62 -0
- package/templates/AGENTS.md +64 -0
- package/templates/ARCHITECTURE.md +50 -0
- package/templates/BUILDBEAT.md +13 -0
- package/templates/CLAUDE.md +7 -0
- package/templates/contracts/PROTOCOL.md +32 -0
- package/templates/gitignore.template +19 -0
- package/templates/pm/NOW.md +26 -0
- package/templates/pm/adr/ADR-0000-template.md +25 -0
- package/templates/pm/adr/README.md +15 -0
- package/templates/pm/changes/README.md +44 -0
- package/templates/pm/decisions.md +12 -0
- package/templates/pm/status/README.md +32 -0
- package/templates/pm//345/275/223/346/234/237/347/234/213/346/235/277.md +62 -0
- package/templates/scripts/bus-check.sh +1850 -0
- package/templates/scripts/design-preview.sh +44 -0
- package/templates/scripts/drift-check.sh +112 -0
- package/templates/scripts/pre-commit.sh +74 -0
- package/templates/scripts/verify-status.sh +105 -0
- package/templates/standards/CODE.md +23 -0
- package/templates/standards/DESIGN.md +36 -0
- package/templates/standards/REVIEW.md +20 -0
- package/templates/standards/STACK.md +37 -0
- package/templates//346/214/207/346/214/245/345/217/260.md +35 -0
|
@@ -0,0 +1,1850 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# bus-check.sh —— 协作总线 · 开工同步护栏(治信息差)。
|
|
3
|
+
# 任意域会话「开工先跑这个」;部署 / 改契约 / 跑 migration 等不可逆动作前**再跑一次**。
|
|
4
|
+
# 只读、不改任何东西;打印 当前期 / 协调层腐烂检测 / Gate与证据 / 可选 standards与ADR / 契约 / 最近拍板 / 各域状态 / 幽灵hash核验 / 在途提案 / 子仓同步 / 线上实况。
|
|
5
|
+
# 规则⑨:「线上什么版本」以本脚本打的实况为准,文档不写。
|
|
6
|
+
# --format=json:只在 stdout 输出 schema 1 JSON;不混入人类仪表盘。
|
|
7
|
+
# --strict:机器闸模式 —— finding level 为 conflict / error 时 exit 1(挂 pre-commit/CI 用,见 pre-commit.sh)。
|
|
8
|
+
# warning / unverified 保持可见但不拦。不带 --strict 时即使有 findings 仍 exit 0,只当仪表盘。
|
|
9
|
+
#
|
|
10
|
+
# 项目接入点(可选;下列同伴脚本一律**放在本脚本同一目录**,本脚本按 SDIR 找它们):
|
|
11
|
+
# 1) SUBREPOS 数组留空 = 自动发现协调层根下一/二级目录里的独立 git 子仓;也可手工列死。
|
|
12
|
+
# 2) 线上实况:提供 live-status.sh(自行调用部署平台 CLI,每行输出「名称<TAB或空格>版本」),
|
|
13
|
+
# 本脚本存在即调用、不存在则提示。跳过:BUS_CHECK_NO_LIVE=1
|
|
14
|
+
# 离线/弱网:BUS_CHECK_NO_FETCH=1 跳过 meta 仓与子仓的 git fetch(只看本地已知状态)
|
|
15
|
+
# 3) 生产漂移检测:提供 drift-check.sh + live-config.sh(见模板),
|
|
16
|
+
# 本脚本存在即调用;`--update-baseline` 会透传给它(部署/改 env 后刷基线)。
|
|
17
|
+
# 4) 工程层验证能力:改 verify-status.sh(模板含参考实现)的 SUITES 接入测试命令,
|
|
18
|
+
# 本脚本存在即调用 —— 证据分级 L3(自动化测试)靠它兜底;`--run` 真跑并记「上次全绿」。
|
|
19
|
+
# 5) 可选 standards/STACK.md 结构合法且 Confirmed 时,比对 v1 基线与 Node/lockfile/Docker 普通文件事实;
|
|
20
|
+
# 只报 drift/unverified,不改任何文件。默认最多扫 200 个相关候选,可用 BUS_STACK_MAX 调整。
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
# 协调层根 = 向上最近的含 pm/NOW.md 的目录;SDIR = 本脚本目录(相对根),脚本间互调都走它。
|
|
23
|
+
# 故本脚本放 <根>/scripts/ 或 <根>/pm/scripts/ 均可(紧凑布局见 SKILL §3),不假设固定深度。
|
|
24
|
+
_sd="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
25
|
+
ROOT="$_sd"; for _ in 1 2 3 4; do [ -f "$ROOT/pm/NOW.md" ] && break; ROOT="$(dirname "$ROOT")"; done
|
|
26
|
+
[ -f "$ROOT/pm/NOW.md" ] || ROOT="$(cd "$_sd/.." && pwd)" # 探测不到(骨架还没建) → 退回旧假设:脚本在 <根>/scripts/
|
|
27
|
+
SDIR="${_sd#"$ROOT"/}"; [ "$SDIR" = "$_sd" ] && SDIR="."
|
|
28
|
+
cd "$ROOT" || { echo "bus-check: cannot enter coordination root" >&2; exit 2; }
|
|
29
|
+
ROOT_PHYS="$(pwd -P)"
|
|
30
|
+
DRIFT_MODE=""; STRICT=0; FORMAT="human"
|
|
31
|
+
for arg in "$@"; do
|
|
32
|
+
case "$arg" in
|
|
33
|
+
--update-baseline) DRIFT_MODE="--update-baseline" ;; # 透传给漂移检测
|
|
34
|
+
--strict) STRICT=1 ;;
|
|
35
|
+
--format=json) FORMAT="json" ;;
|
|
36
|
+
*) echo "bus-check: unknown argument: $arg" >&2; exit 2 ;;
|
|
37
|
+
esac
|
|
38
|
+
done
|
|
39
|
+
|
|
40
|
+
BUS_TMP="$(mktemp -d "${TMPDIR:-/tmp}/buildbeat-bus-check.XXXXXX")" || {
|
|
41
|
+
echo "bus-check: cannot create temporary report directory" >&2
|
|
42
|
+
exit 2
|
|
43
|
+
}
|
|
44
|
+
FINDINGS_RAW="$BUS_TMP/findings.raw"
|
|
45
|
+
FINDINGS_SORTED="$BUS_TMP/findings.sorted"
|
|
46
|
+
HUMAN_OUT="$BUS_TMP/human.out"
|
|
47
|
+
: > "$FINDINGS_RAW"
|
|
48
|
+
: > "$FINDINGS_SORTED"
|
|
49
|
+
: > "$HUMAN_OUT"
|
|
50
|
+
# shellcheck disable=SC2317,SC2329 # invoked indirectly by trap
|
|
51
|
+
cleanup_bus_check() { rm -rf -- "$BUS_TMP"; }
|
|
52
|
+
trap cleanup_bus_check EXIT
|
|
53
|
+
|
|
54
|
+
# Keep the existing dashboard byte-compatible enough for people while JSON gets
|
|
55
|
+
# stdout to itself. All blocking decisions are derived from FINDINGS_RAW.
|
|
56
|
+
exec 3>&1
|
|
57
|
+
if [ "$FORMAT" = "json" ]; then exec > "$HUMAN_OUT"; fi
|
|
58
|
+
|
|
59
|
+
finding_rank() {
|
|
60
|
+
case "$1" in
|
|
61
|
+
sync.now_bloated) echo 10 ;;
|
|
62
|
+
sync.ghost_hash) echo 20 ;;
|
|
63
|
+
sync.production_drift) echo 30 ;;
|
|
64
|
+
sync.multirepo_drift) echo 35 ;;
|
|
65
|
+
sync.l3_stale) echo 40 ;;
|
|
66
|
+
sync.l3_unconfigured) echo 50 ;;
|
|
67
|
+
sync.scan_truncated) echo 60 ;;
|
|
68
|
+
sync.unverified) echo 70 ;;
|
|
69
|
+
gate.line_missing) echo 100 ;;
|
|
70
|
+
gate.invalid) echo 105 ;;
|
|
71
|
+
gate.na_without_reason) echo 110 ;;
|
|
72
|
+
gate.na_inconsistent) echo 115 ;;
|
|
73
|
+
gate.pass_untraceable) echo 120 ;;
|
|
74
|
+
evidence.missing) echo 200 ;;
|
|
75
|
+
evidence.outside_archive) echo 210 ;;
|
|
76
|
+
ref.broken) echo 300 ;;
|
|
77
|
+
standards.invalid) echo 400 ;;
|
|
78
|
+
standards.unconfirmed) echo 410 ;;
|
|
79
|
+
stack.drift) echo 420 ;;
|
|
80
|
+
stack.unverified) echo 430 ;;
|
|
81
|
+
adr.status_invalid) echo 500 ;;
|
|
82
|
+
adr.superseded_broken) echo 510 ;;
|
|
83
|
+
*) echo 900 ;;
|
|
84
|
+
esac
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
add_finding() {
|
|
88
|
+
code="$1"; level="$2"; message="$3"; path="${4:-}"
|
|
89
|
+
rank="$(finding_rank "$code")"
|
|
90
|
+
message="$(printf '%s' "$message" | tr '\t\r\n' ' ')"
|
|
91
|
+
path="$(printf '%s' "$path" | tr '\t\r\n' ' ')"
|
|
92
|
+
printf '%s\t%s\t%s\t%s\t%s\n' "$rank" "$code" "$level" "$message" "$path" >> "$FINDINGS_RAW"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Mechanical coverage boundaries share one non-blocking finding. The reason
|
|
96
|
+
# token is intentionally small and stable; details never include raw OS errors
|
|
97
|
+
# or file contents, and the path remains coordination-root relative.
|
|
98
|
+
add_scan_boundary() {
|
|
99
|
+
boundary_reason="$1"; boundary_path="$2"; boundary_detail="$3"
|
|
100
|
+
add_finding "sync.scan_truncated" "unverified" \
|
|
101
|
+
"Scan coverage is incomplete: reason=$boundary_reason; $boundary_detail." \
|
|
102
|
+
"$boundary_path"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
path_uses_symlink_component() {
|
|
106
|
+
boundary_probe="$1"
|
|
107
|
+
case "$boundary_probe" in
|
|
108
|
+
"$ROOT_PHYS"/*) ;;
|
|
109
|
+
*) return 1 ;;
|
|
110
|
+
esac
|
|
111
|
+
while [ "$boundary_probe" != "$ROOT_PHYS" ]; do
|
|
112
|
+
[ -L "$boundary_probe" ] && return 0
|
|
113
|
+
boundary_parent="$(dirname "$boundary_probe")"
|
|
114
|
+
[ "$boundary_parent" != "$boundary_probe" ] || break
|
|
115
|
+
boundary_probe="$boundary_parent"
|
|
116
|
+
done
|
|
117
|
+
return 1
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
resolve_hash() {
|
|
121
|
+
candidate_hash="$1"
|
|
122
|
+
for hash_repo in . "${SUBREPOS[@]:-}"; do
|
|
123
|
+
[ -n "$hash_repo" ] || continue
|
|
124
|
+
[ -d "$hash_repo" ] || continue
|
|
125
|
+
if [ "$hash_repo" != "." ]; then
|
|
126
|
+
path_uses_symlink_component "$ROOT_PHYS/${hash_repo#./}" && continue
|
|
127
|
+
if [ ! -r "$hash_repo" ] || [ ! -x "$hash_repo" ]; then continue; fi
|
|
128
|
+
fi
|
|
129
|
+
git -C "$hash_repo" cat-file -t "$candidate_hash" >/dev/null 2>&1 && return 0
|
|
130
|
+
done
|
|
131
|
+
return 1
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Return 0=local/hash valid, 1=broken/unsafe local/hash, 2=remote (traceable
|
|
135
|
+
# but unverified), 3=not a machine-reference token, 4=in-root symlink boundary,
|
|
136
|
+
# 5=permission boundary. Callers keep 4/5 unverified rather than calling them
|
|
137
|
+
# missing, valid, or contradictory.
|
|
138
|
+
validate_reference() {
|
|
139
|
+
ref="$1"; source_path="$2"; allow_source_relative="${3:-0}"
|
|
140
|
+
case "$ref" in
|
|
141
|
+
http://*|https://*) return 2 ;;
|
|
142
|
+
esac
|
|
143
|
+
if printf '%s' "$ref" | grep -Eq '^[0-9a-f]{7,40}$' \
|
|
144
|
+
&& printf '%s' "$ref" | grep -q '[a-f]' \
|
|
145
|
+
&& printf '%s' "$ref" | grep -q '[0-9]'; then
|
|
146
|
+
resolve_hash "$ref" && return 0
|
|
147
|
+
return 1
|
|
148
|
+
fi
|
|
149
|
+
ref="${ref%%#*}"; ref="${ref%%\?*}"
|
|
150
|
+
ref="$(printf '%s' "$ref" | sed -E 's/:[0-9]+$//')"
|
|
151
|
+
case "$ref" in
|
|
152
|
+
""|*$'\n'*|*$'\r'*|*$'\t'*|*"<"*|*"\\"*|*"*"*|*"?"*|*"["*|*"]"*|*"|"*) return 3 ;;
|
|
153
|
+
/*) return 1 ;;
|
|
154
|
+
../*|*/../*|*/..|./*|*/./*)
|
|
155
|
+
[ "$allow_source_relative" = "1" ] || return 1
|
|
156
|
+
;;
|
|
157
|
+
esac
|
|
158
|
+
[ "${#ref}" -le 240 ] || return 3
|
|
159
|
+
case "$ref" in
|
|
160
|
+
*.md|*.json|*.html|*.png|*.jpg|*.jpeg|*.svg|*.txt|*.log|*/*) ;;
|
|
161
|
+
*) return 3 ;;
|
|
162
|
+
esac
|
|
163
|
+
reference_candidates=()
|
|
164
|
+
case "$ref" in
|
|
165
|
+
pm/*|contracts/*|design/*|scripts/*|tests/*|docs/*|example/*)
|
|
166
|
+
reference_candidates+=("$ROOT_PHYS/$ref")
|
|
167
|
+
;;
|
|
168
|
+
*)
|
|
169
|
+
source_dir="$(dirname "$source_path")"
|
|
170
|
+
[ "$source_dir" = "." ] && source_dir=""
|
|
171
|
+
reference_candidates+=("$ROOT_PHYS/${source_dir:+$source_dir/}$ref")
|
|
172
|
+
reference_candidates+=("$ROOT_PHYS/$ref")
|
|
173
|
+
case "$ref" in */*) ;; *) reference_candidates+=("$ROOT_PHYS/contracts/$ref") ;; esac
|
|
174
|
+
;;
|
|
175
|
+
esac
|
|
176
|
+
for candidate in "${reference_candidates[@]}"; do
|
|
177
|
+
[ -f "$candidate" ] || continue
|
|
178
|
+
resolved="$(realpath "$candidate" 2>/dev/null)" || return 1
|
|
179
|
+
case "$resolved" in
|
|
180
|
+
"$ROOT_PHYS"/*) ;;
|
|
181
|
+
*) return 1 ;;
|
|
182
|
+
esac
|
|
183
|
+
path_uses_symlink_component "$candidate" && return 4
|
|
184
|
+
[ -r "$candidate" ] || return 5
|
|
185
|
+
return 0
|
|
186
|
+
done
|
|
187
|
+
return 1
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
reference_display_path() {
|
|
191
|
+
display_ref="$1"
|
|
192
|
+
display_ref="${display_ref%%#*}"
|
|
193
|
+
display_ref="${display_ref%%\?*}"
|
|
194
|
+
display_ref="$(printf '%s' "$display_ref" | sed -E 's/:[0-9]+$//')"
|
|
195
|
+
printf '%s\n' "$display_ref"
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
extract_backtick_tokens() {
|
|
199
|
+
# shellcheck disable=SC2016 # backticks are literal Markdown delimiters
|
|
200
|
+
printf '%s\n' "$1" | grep -oE '`[^`]+`' 2>/dev/null | sed 's/^`//; s/`$//' || true
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Return 0 when a decision reference names one existing dated table row in the
|
|
204
|
+
# canonical ledger. The explicit line keeps a passed Gate tied to one decision,
|
|
205
|
+
# not merely to the existence of decisions.md.
|
|
206
|
+
validate_decision_reference() {
|
|
207
|
+
decision_ref="$1"
|
|
208
|
+
if ! printf '%s\n' "$decision_ref" | grep -Eq '^pm/decisions\.md:[1-9][0-9]{0,6}$'; then
|
|
209
|
+
return 1
|
|
210
|
+
fi
|
|
211
|
+
decision_line_n="${decision_ref##*:}"
|
|
212
|
+
[ -f pm/decisions.md ] && [ ! -L pm/decisions.md ] && [ -r pm/decisions.md ] || return 1
|
|
213
|
+
path_uses_symlink_component "$ROOT_PHYS/pm/decisions.md" && return 1
|
|
214
|
+
decision_line="$(sed -n "${decision_line_n}p" pm/decisions.md 2>/dev/null || true)"
|
|
215
|
+
printf '%s\n' "$decision_line" \
|
|
216
|
+
| grep -Eq '^[[:space:]]*\|[[:space:]]*[0-9]{4}-[0-9]{2}-[0-9]{2}[[:space:]]*\|'
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# Call only after validate_reference succeeds. Return 0=canonical local
|
|
220
|
+
# evidence path, 1=local path outside pm/archive/<期>/evidence/, 2=hash/other.
|
|
221
|
+
evidence_location_status() {
|
|
222
|
+
evidence_ref="$1"
|
|
223
|
+
case "$evidence_ref" in
|
|
224
|
+
http://*|https://*) return 2 ;;
|
|
225
|
+
esac
|
|
226
|
+
if printf '%s' "$evidence_ref" | grep -Eq '^[0-9a-f]{7,40}$'; then
|
|
227
|
+
return 2
|
|
228
|
+
fi
|
|
229
|
+
evidence_ref="${evidence_ref%%#*}"
|
|
230
|
+
evidence_ref="${evidence_ref%%\?*}"
|
|
231
|
+
evidence_ref="$(printf '%s' "$evidence_ref" | sed -E 's/:[0-9]+$//')"
|
|
232
|
+
case "$evidence_ref" in
|
|
233
|
+
*.md|*.json|*.html|*.png|*.jpg|*.jpeg|*.svg|*.txt|*.log|*/*) ;;
|
|
234
|
+
*) return 2 ;;
|
|
235
|
+
esac
|
|
236
|
+
if printf '%s\n' "$evidence_ref" \
|
|
237
|
+
| grep -Eq '^pm/archive/[^/]+/evidence/[^/].*$'; then
|
|
238
|
+
return 0
|
|
239
|
+
fi
|
|
240
|
+
return 1
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# Best-effort positive UI detection only. A miss is never proof that the
|
|
244
|
+
# project has no UI; it simply means gate.na_inconsistent is not emitted.
|
|
245
|
+
detect_ui_signal() {
|
|
246
|
+
if [ -f standards/DESIGN.md ] && [ ! -L standards/DESIGN.md ]; then
|
|
247
|
+
return 0
|
|
248
|
+
fi
|
|
249
|
+
ui_files="$BUS_TMP/ui-candidates"
|
|
250
|
+
if ! find -P . \
|
|
251
|
+
\( -type d \( -name .claude -o -name .codex -o -name .git -o -name .buildbeat -o -name .solobaton \
|
|
252
|
+
-o -name .next -o -name .nuxt -o -name .output -o -name build -o -name coverage \
|
|
253
|
+
-o -name dist -o -name node_modules -o -name target -o -name vendor \) -prune \) -o \
|
|
254
|
+
\( -type f \( -name index.html -o -name package.json -o -name manifest.json \) \) -print0 \
|
|
255
|
+
> "$ui_files" 2>/dev/null; then
|
|
256
|
+
return 1
|
|
257
|
+
fi
|
|
258
|
+
ui_count=0
|
|
259
|
+
while IFS= read -r -d '' ui_file; do
|
|
260
|
+
ui_count=$((ui_count + 1))
|
|
261
|
+
[ "$ui_count" -le 5000 ] || return 1
|
|
262
|
+
case "${ui_file##*/}" in
|
|
263
|
+
index.html) return 0 ;;
|
|
264
|
+
package.json)
|
|
265
|
+
if grep -Eq '"(@angular/core|@sveltejs/kit|next|nuxt|react|svelte|vite|vue)"[[:space:]]*:' "$ui_file" 2>/dev/null; then
|
|
266
|
+
return 0
|
|
267
|
+
fi
|
|
268
|
+
;;
|
|
269
|
+
manifest.json)
|
|
270
|
+
if grep -Eq '"manifest_version"[[:space:]]*:[[:space:]]*[23]' "$ui_file" 2>/dev/null \
|
|
271
|
+
&& grep -Eq '"(action|browser_action|content_scripts|options_page|options_ui|page_action|side_panel)"[[:space:]]*:' "$ui_file" 2>/dev/null; then
|
|
272
|
+
return 0
|
|
273
|
+
fi
|
|
274
|
+
;;
|
|
275
|
+
esac
|
|
276
|
+
done < "$ui_files"
|
|
277
|
+
return 1
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
is_release_version_token() {
|
|
281
|
+
case "$1" in
|
|
282
|
+
*$'\n'*|*$'\r'*|*$'\t'*) return 1 ;;
|
|
283
|
+
esac
|
|
284
|
+
[ "${#1}" -le 100 ] || return 1
|
|
285
|
+
printf '%s\n' "$1" \
|
|
286
|
+
| grep -Eq '^[vV]?[0-9]+(\.[0-9]+){1,2}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
normalize_release_version() {
|
|
290
|
+
normalized_version="$1"
|
|
291
|
+
case "$normalized_version" in
|
|
292
|
+
v*|V*) normalized_version="${normalized_version#?}" ;;
|
|
293
|
+
esac
|
|
294
|
+
printf '%s\n' "$normalized_version"
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
# The first non-Unreleased H2 heading is the only CHANGELOG version source.
|
|
298
|
+
# Free-form production notes remain visible to people but are not guessed into
|
|
299
|
+
# a version; projects can adopt a canonical release heading to close coverage.
|
|
300
|
+
read_changelog_head_version() {
|
|
301
|
+
changelog_path="$1"
|
|
302
|
+
changelog_heading="$(awk '
|
|
303
|
+
/^##[[:space:]]+/ {
|
|
304
|
+
lower=tolower($0)
|
|
305
|
+
if (lower !~ /unreleased/) { print; exit }
|
|
306
|
+
}
|
|
307
|
+
' "$changelog_path" 2>/dev/null || true)"
|
|
308
|
+
[ -n "$changelog_heading" ] || return 1
|
|
309
|
+
changelog_version="$(printf '%s\n' "$changelog_heading" \
|
|
310
|
+
| sed -E 's/^##[[:space:]]+//; s/[[:space:]].*$//; s/^\[//; s/\]$//')"
|
|
311
|
+
is_release_version_token "$changelog_version" || return 1
|
|
312
|
+
printf '%s\n' "$changelog_version"
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
# A mapped contract file must expose exactly one canonical snapshot line and a
|
|
316
|
+
# single backticked release token. This validates linkage, not contract truth.
|
|
317
|
+
read_contract_snapshot_version() {
|
|
318
|
+
contract_path="$1"
|
|
319
|
+
contract_lines="$(grep -E '契约快照对应版本' "$contract_path" 2>/dev/null || true)"
|
|
320
|
+
contract_count="$(printf '%s\n' "$contract_lines" | awk 'NF { n++ } END { print n+0 }')"
|
|
321
|
+
[ "$contract_count" -eq 1 ] || return 1
|
|
322
|
+
contract_tokens="$(extract_backtick_tokens "$contract_lines")"
|
|
323
|
+
contract_token_count="$(printf '%s\n' "$contract_tokens" | awk 'NF { n++ } END { print n+0 }')"
|
|
324
|
+
[ "$contract_token_count" -eq 1 ] || return 1
|
|
325
|
+
contract_version="$contract_tokens"
|
|
326
|
+
[ -n "$contract_version" ] || return 1
|
|
327
|
+
is_release_version_token "$contract_version" || return 1
|
|
328
|
+
printf '%s\n' "$contract_version"
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
multirepo_map_value_safe() {
|
|
332
|
+
map_value="$1"; map_value_max="$2"
|
|
333
|
+
case "$map_value" in
|
|
334
|
+
*$'\n'*|*$'\r'*|*$'\t'*) return 1 ;;
|
|
335
|
+
esac
|
|
336
|
+
[ -n "$map_value" ] && [ "${#map_value}" -le "$map_value_max" ] \
|
|
337
|
+
&& ! printf '%s' "$map_value" | LC_ALL=C grep -q '[[:cntrl:]]' \
|
|
338
|
+
&& ! printf '%s\n' "$map_value" | grep -Eq '[|`"\\]|<[^>]*>'
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
multirepo_repo_path_safe() {
|
|
342
|
+
multirepo_map_value_safe "$1" 200 || return 1
|
|
343
|
+
! printf '%s\n' "$1" \
|
|
344
|
+
| grep -Eq '(^/|/$|(^|/)\.\.?(/|$)|//)'
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
# Cross-check only explicitly joined sources. contracts/PROTOCOL.md owns the
|
|
348
|
+
# expected repo inventory and source map; implicit directory/prose inference
|
|
349
|
+
# would turn unrelated version domains into false drift.
|
|
350
|
+
check_multirepo_drift() {
|
|
351
|
+
multirepo_map_path="contracts/PROTOCOL.md"
|
|
352
|
+
multirepo_map_raw="$BUS_TMP/multirepo.map.raw"
|
|
353
|
+
multirepo_records="$BUS_TMP/multirepo.records"
|
|
354
|
+
multirepo_expected="$BUS_TMP/multirepo.expected"
|
|
355
|
+
multirepo_discovered="$BUS_TMP/multirepo.discovered"
|
|
356
|
+
: > "$multirepo_map_raw"
|
|
357
|
+
: > "$multirepo_records"
|
|
358
|
+
: > "$multirepo_expected"
|
|
359
|
+
: > "$multirepo_discovered"
|
|
360
|
+
|
|
361
|
+
for multirepo_item in "${SUBREPOS[@]:-}"; do
|
|
362
|
+
[ -n "$multirepo_item" ] || continue
|
|
363
|
+
printf '%s\n' "${multirepo_item#./}" >> "$multirepo_discovered"
|
|
364
|
+
done
|
|
365
|
+
LC_ALL=C sort -u "$multirepo_discovered" -o "$multirepo_discovered"
|
|
366
|
+
|
|
367
|
+
multirepo_map_real="$(realpath "$multirepo_map_path" 2>/dev/null || true)"
|
|
368
|
+
multirepo_map_unsafe=0
|
|
369
|
+
multirepo_map_boundary=""
|
|
370
|
+
if [ -L "$multirepo_map_path" ] \
|
|
371
|
+
|| { [ -e "$multirepo_map_path" ] \
|
|
372
|
+
&& path_uses_symlink_component "$ROOT_PHYS/$multirepo_map_path"; }; then
|
|
373
|
+
multirepo_map_unsafe=1
|
|
374
|
+
multirepo_map_boundary="symlink"
|
|
375
|
+
elif [ -e "$multirepo_map_path" ] && [ ! -r "$multirepo_map_path" ]; then
|
|
376
|
+
multirepo_map_unsafe=1
|
|
377
|
+
multirepo_map_boundary="permission"
|
|
378
|
+
elif [ ! -f "$multirepo_map_path" ] || [ -z "$multirepo_map_real" ]; then
|
|
379
|
+
multirepo_map_unsafe=1
|
|
380
|
+
else
|
|
381
|
+
case "$multirepo_map_real" in
|
|
382
|
+
"$ROOT_PHYS"/*) ;;
|
|
383
|
+
*) multirepo_map_unsafe=1; multirepo_map_boundary="symlink" ;;
|
|
384
|
+
esac
|
|
385
|
+
fi
|
|
386
|
+
if [ "$multirepo_map_unsafe" -eq 1 ]; then
|
|
387
|
+
if [ -n "$multirepo_map_boundary" ]; then
|
|
388
|
+
echo " ⚠️ $multirepo_map_path 因 $multirepo_map_boundary 边界未读取"
|
|
389
|
+
add_scan_boundary "$multirepo_map_boundary" "$multirepo_map_path" \
|
|
390
|
+
"the multi-repository map was not read"
|
|
391
|
+
fi
|
|
392
|
+
if [ ! -s "$multirepo_discovered" ]; then
|
|
393
|
+
echo " · 未发现子仓;多仓漂移检查不适用"
|
|
394
|
+
return
|
|
395
|
+
fi
|
|
396
|
+
while IFS= read -r multirepo_repo; do
|
|
397
|
+
echo " ⚠️ $multirepo_repo 未在可读契约 map 中登记 —— 多仓版本关系未核验"
|
|
398
|
+
add_finding "sync.unverified" "unverified" "Discovered repo=$multirepo_repo has no readable buildbeat-multirepo-map:v1 source." "$multirepo_repo"
|
|
399
|
+
done < "$multirepo_discovered"
|
|
400
|
+
return
|
|
401
|
+
fi
|
|
402
|
+
|
|
403
|
+
multirepo_marker_count="$(grep -c '^<!-- buildbeat-multirepo-map:v1$' "$multirepo_map_path" 2>/dev/null || true)"
|
|
404
|
+
if [ "$multirepo_marker_count" -eq 0 ]; then
|
|
405
|
+
if [ ! -s "$multirepo_discovered" ]; then
|
|
406
|
+
echo " · 未发现子仓;未配置多仓 map 不产生 finding"
|
|
407
|
+
return
|
|
408
|
+
fi
|
|
409
|
+
while IFS= read -r multirepo_repo; do
|
|
410
|
+
echo " ⚠️ $multirepo_repo 未在 buildbeat-multirepo-map:v1 登记"
|
|
411
|
+
add_finding "sync.unverified" "unverified" "Discovered repo=$multirepo_repo is absent from buildbeat-multirepo-map:v1." "$multirepo_repo"
|
|
412
|
+
done < "$multirepo_discovered"
|
|
413
|
+
return
|
|
414
|
+
fi
|
|
415
|
+
|
|
416
|
+
multirepo_map_invalid=0
|
|
417
|
+
if ! awk '
|
|
418
|
+
$0 == "<!-- buildbeat-multirepo-map:v1" {
|
|
419
|
+
starts++
|
|
420
|
+
if (inside) bad=1
|
|
421
|
+
inside=1
|
|
422
|
+
next
|
|
423
|
+
}
|
|
424
|
+
inside && $0 == "-->" {
|
|
425
|
+
closes++
|
|
426
|
+
inside=0
|
|
427
|
+
next
|
|
428
|
+
}
|
|
429
|
+
inside { print }
|
|
430
|
+
END {
|
|
431
|
+
if (starts != 1 || closes != 1 || inside || bad) exit 2
|
|
432
|
+
}
|
|
433
|
+
' "$multirepo_map_path" > "$multirepo_map_raw"; then
|
|
434
|
+
multirepo_map_invalid=1
|
|
435
|
+
fi
|
|
436
|
+
|
|
437
|
+
while IFS= read -r multirepo_line || [ -n "$multirepo_line" ]; do
|
|
438
|
+
multirepo_line="${multirepo_line%$'\r'}"
|
|
439
|
+
[ -n "$multirepo_line" ] || continue
|
|
440
|
+
if ! printf '%s\n' "$multirepo_line" \
|
|
441
|
+
| grep -Eq '^repo=[^|]+\|contract=[^|]+\|deployment=[^|]+$'; then
|
|
442
|
+
multirepo_map_invalid=1
|
|
443
|
+
continue
|
|
444
|
+
fi
|
|
445
|
+
IFS='|' read -r multirepo_repo_field multirepo_contract_field multirepo_deployment_field <<< "$multirepo_line"
|
|
446
|
+
multirepo_repo="${multirepo_repo_field#repo=}"
|
|
447
|
+
multirepo_contract="${multirepo_contract_field#contract=}"
|
|
448
|
+
multirepo_deployment="${multirepo_deployment_field#deployment=}"
|
|
449
|
+
multirepo_repo_trimmed="$(printf '%s' "$multirepo_repo" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
|
450
|
+
multirepo_contract_trimmed="$(printf '%s' "$multirepo_contract" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
|
451
|
+
multirepo_deployment_trimmed="$(printf '%s' "$multirepo_deployment" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
|
452
|
+
if [ "$multirepo_repo" != "$multirepo_repo_trimmed" ] \
|
|
453
|
+
|| [ "$multirepo_contract" != "$multirepo_contract_trimmed" ] \
|
|
454
|
+
|| [ "$multirepo_deployment" != "$multirepo_deployment_trimmed" ] \
|
|
455
|
+
|| ! multirepo_repo_path_safe "$multirepo_repo" \
|
|
456
|
+
|| ! multirepo_map_value_safe "$multirepo_contract" 240 \
|
|
457
|
+
|| ! printf '%s\n' "$multirepo_contract" | grep -Eq '^contracts/[^/].*\.md$' \
|
|
458
|
+
|| ! multirepo_map_value_safe "$multirepo_deployment" 100; then
|
|
459
|
+
multirepo_map_invalid=1
|
|
460
|
+
continue
|
|
461
|
+
fi
|
|
462
|
+
printf '%s\t%s\t%s\n' "$multirepo_repo" "$multirepo_contract" "$multirepo_deployment" >> "$multirepo_records"
|
|
463
|
+
printf '%s\n' "$multirepo_repo" >> "$multirepo_expected"
|
|
464
|
+
done < "$multirepo_map_raw"
|
|
465
|
+
|
|
466
|
+
multirepo_record_count="$(awk 'NF { n++ } END { print n+0 }' "$multirepo_records")"
|
|
467
|
+
multirepo_duplicate="$(LC_ALL=C sort "$multirepo_expected" | uniq -d | head -1)"
|
|
468
|
+
if [ "$multirepo_record_count" -eq 0 ] || [ -n "$multirepo_duplicate" ]; then
|
|
469
|
+
multirepo_map_invalid=1
|
|
470
|
+
fi
|
|
471
|
+
LC_ALL=C sort -u "$multirepo_expected" -o "$multirepo_expected"
|
|
472
|
+
|
|
473
|
+
if [ "$multirepo_map_invalid" -eq 1 ]; then
|
|
474
|
+
echo " ⚠️ $multirepo_map_path 的 buildbeat-multirepo-map:v1 缺失、重复或格式无效"
|
|
475
|
+
add_finding "sync.unverified" "unverified" "The buildbeat-multirepo-map:v1 source is missing, duplicated, empty, or malformed." "$multirepo_map_path"
|
|
476
|
+
while IFS= read -r multirepo_repo; do
|
|
477
|
+
add_finding "sync.unverified" "unverified" "Discovered repo=$multirepo_repo could not be joined because the multi-repo map is invalid." "$multirepo_repo"
|
|
478
|
+
done < "$multirepo_discovered"
|
|
479
|
+
return
|
|
480
|
+
fi
|
|
481
|
+
|
|
482
|
+
while IFS= read -r multirepo_repo; do
|
|
483
|
+
grep -Fxq "$multirepo_repo" "$multirepo_expected" && continue
|
|
484
|
+
echo " ⚠️ $multirepo_repo 已发现但未在 buildbeat-multirepo-map:v1 登记"
|
|
485
|
+
add_finding "sync.unverified" "unverified" "Discovered repo=$multirepo_repo is absent from buildbeat-multirepo-map:v1." "$multirepo_repo"
|
|
486
|
+
done < "$multirepo_discovered"
|
|
487
|
+
|
|
488
|
+
while IFS=$'\t' read -r multirepo_repo multirepo_contract multirepo_deployment; do
|
|
489
|
+
[ -n "$multirepo_repo" ] || continue
|
|
490
|
+
multirepo_changelog="$multirepo_repo/CHANGELOG.md"
|
|
491
|
+
multirepo_issue=0
|
|
492
|
+
multirepo_drift=0
|
|
493
|
+
multirepo_changelog_ok=0
|
|
494
|
+
multirepo_contract_ok=0
|
|
495
|
+
multirepo_deployment_ok=0
|
|
496
|
+
multirepo_changelog_version=""
|
|
497
|
+
multirepo_contract_version=""
|
|
498
|
+
multirepo_deployment_version=""
|
|
499
|
+
|
|
500
|
+
if ! grep -Fxq "$multirepo_repo" "$multirepo_discovered"; then
|
|
501
|
+
if [ -L "$multirepo_repo" ] \
|
|
502
|
+
|| { [ -e "$multirepo_repo" ] \
|
|
503
|
+
&& path_uses_symlink_component "$ROOT_PHYS/$multirepo_repo"; }; then
|
|
504
|
+
echo " ⚠️ $multirepo_repo 在 map 中登记,但经 symlink 到达"
|
|
505
|
+
add_scan_boundary "symlink" "$multirepo_repo" \
|
|
506
|
+
"the discovered repository was not traversed"
|
|
507
|
+
elif [ -d "$multirepo_repo" ] \
|
|
508
|
+
&& { [ ! -r "$multirepo_repo" ] || [ ! -x "$multirepo_repo" ]; }; then
|
|
509
|
+
echo " ⚠️ $multirepo_repo 在 map 中登记,但当前权限不足"
|
|
510
|
+
add_scan_boundary "permission" "$multirepo_repo" \
|
|
511
|
+
"the discovered repository was not readable and searchable"
|
|
512
|
+
else
|
|
513
|
+
echo " ⚠️ $multirepo_repo 在 map 中登记,但未被既有 SUBREPOS 深度发现"
|
|
514
|
+
add_finding "sync.unverified" "unverified" "Expected repo=$multirepo_repo was not observed by the bounded SUBREPOS discovery." "$multirepo_repo"
|
|
515
|
+
fi
|
|
516
|
+
continue
|
|
517
|
+
fi
|
|
518
|
+
|
|
519
|
+
multirepo_repo_real="$(realpath "$multirepo_repo" 2>/dev/null || true)"
|
|
520
|
+
if [ -L "$multirepo_repo" ] \
|
|
521
|
+
|| path_uses_symlink_component "$ROOT_PHYS/$multirepo_repo"; then
|
|
522
|
+
echo " ⚠️ $multirepo_repo 经 symlink 到达,未跟随扫描"
|
|
523
|
+
add_scan_boundary "symlink" "$multirepo_repo" \
|
|
524
|
+
"the discovered repository was not traversed"
|
|
525
|
+
continue
|
|
526
|
+
fi
|
|
527
|
+
if [ -z "$multirepo_repo_real" ]; then
|
|
528
|
+
echo " ⚠️ $multirepo_repo 不是可安全扫描的 regular 子仓目录"
|
|
529
|
+
add_finding "sync.unverified" "unverified" "Expected repo=$multirepo_repo cannot be resolved safely." "$multirepo_repo"
|
|
530
|
+
continue
|
|
531
|
+
fi
|
|
532
|
+
if [ ! -r "$multirepo_repo" ] || [ ! -x "$multirepo_repo" ]; then
|
|
533
|
+
echo " ⚠️ $multirepo_repo 当前权限不足,未遍历"
|
|
534
|
+
add_scan_boundary "permission" "$multirepo_repo" \
|
|
535
|
+
"the discovered repository was not readable and searchable"
|
|
536
|
+
continue
|
|
537
|
+
fi
|
|
538
|
+
case "$multirepo_repo_real" in
|
|
539
|
+
"$ROOT_PHYS"/*) ;;
|
|
540
|
+
*)
|
|
541
|
+
echo " ⚠️ $multirepo_repo 解析到协调根之外"
|
|
542
|
+
add_finding "sync.unverified" "unverified" "Expected repo=$multirepo_repo resolves outside the coordination root." "$multirepo_repo"
|
|
543
|
+
continue
|
|
544
|
+
;;
|
|
545
|
+
esac
|
|
546
|
+
|
|
547
|
+
if [ -L "$multirepo_changelog" ] \
|
|
548
|
+
|| { [ -e "$multirepo_changelog" ] \
|
|
549
|
+
&& path_uses_symlink_component "$ROOT_PHYS/$multirepo_changelog"; }; then
|
|
550
|
+
echo " ⚠️ $multirepo_changelog 经 symlink 到达,未读取"
|
|
551
|
+
add_scan_boundary "symlink" "$multirepo_changelog" \
|
|
552
|
+
"the mapped CHANGELOG version source was not read"
|
|
553
|
+
multirepo_issue=1
|
|
554
|
+
elif [ -e "$multirepo_changelog" ] && [ ! -r "$multirepo_changelog" ]; then
|
|
555
|
+
echo " ⚠️ $multirepo_changelog 当前权限不足,未读取"
|
|
556
|
+
add_scan_boundary "permission" "$multirepo_changelog" \
|
|
557
|
+
"the mapped CHANGELOG version source was not readable"
|
|
558
|
+
multirepo_issue=1
|
|
559
|
+
elif [ ! -f "$multirepo_changelog" ]; then
|
|
560
|
+
echo " ⚠️ $multirepo_repo 缺少可读 regular CHANGELOG.md"
|
|
561
|
+
add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo has no readable regular CHANGELOG.md version source." "$multirepo_changelog"
|
|
562
|
+
multirepo_issue=1
|
|
563
|
+
else
|
|
564
|
+
multirepo_changelog_version="$(read_changelog_head_version "$multirepo_changelog" || true)"
|
|
565
|
+
if [ -z "$multirepo_changelog_version" ]; then
|
|
566
|
+
echo " ⚠️ $multirepo_changelog 的首个已发布 H2 不是可核对版本"
|
|
567
|
+
add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo CHANGELOG head has no parseable released version token." "$multirepo_changelog"
|
|
568
|
+
multirepo_issue=1
|
|
569
|
+
else
|
|
570
|
+
multirepo_changelog_ok=1
|
|
571
|
+
fi
|
|
572
|
+
fi
|
|
573
|
+
|
|
574
|
+
multirepo_contract_rc=0
|
|
575
|
+
validate_reference "$multirepo_contract" "$multirepo_map_path" || multirepo_contract_rc=$?
|
|
576
|
+
if [ "$multirepo_contract_rc" -ne 0 ]; then
|
|
577
|
+
echo " ⚠️ $multirepo_repo 的契约版本来源不可读:$multirepo_contract"
|
|
578
|
+
if [ "$multirepo_contract_rc" -eq 4 ]; then
|
|
579
|
+
add_scan_boundary "symlink" "$multirepo_contract" \
|
|
580
|
+
"the mapped contract version source was not read"
|
|
581
|
+
elif [ "$multirepo_contract_rc" -eq 5 ]; then
|
|
582
|
+
add_scan_boundary "permission" "$multirepo_contract" \
|
|
583
|
+
"the mapped contract version source was not readable"
|
|
584
|
+
else
|
|
585
|
+
add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo contract version source is not a readable in-root file: $multirepo_contract." "$multirepo_contract"
|
|
586
|
+
fi
|
|
587
|
+
multirepo_issue=1
|
|
588
|
+
else
|
|
589
|
+
multirepo_contract_version="$(read_contract_snapshot_version "$multirepo_contract" || true)"
|
|
590
|
+
if [ -z "$multirepo_contract_version" ]; then
|
|
591
|
+
echo " ⚠️ $multirepo_contract 缺少唯一可解析的契约快照版本"
|
|
592
|
+
add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo contract source has no unique parseable snapshot version." "$multirepo_contract"
|
|
593
|
+
multirepo_issue=1
|
|
594
|
+
else
|
|
595
|
+
multirepo_contract_ok=1
|
|
596
|
+
fi
|
|
597
|
+
fi
|
|
598
|
+
|
|
599
|
+
multirepo_baseline="$SDIR/bus-baseline.json"
|
|
600
|
+
if [ "$multirepo_deployment" != "n/a" ]; then
|
|
601
|
+
multirepo_baseline_real="$(realpath "$multirepo_baseline" 2>/dev/null || true)"
|
|
602
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
603
|
+
echo " ⚠️ $multirepo_repo 的部署基线需 jq 才能核对"
|
|
604
|
+
add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo deployment source requires jq: $multirepo_baseline#apps.$multirepo_deployment.imageTag." "$multirepo_baseline"
|
|
605
|
+
multirepo_issue=1
|
|
606
|
+
elif [ -L "$multirepo_baseline" ] \
|
|
607
|
+
|| { [ -e "$multirepo_baseline" ] \
|
|
608
|
+
&& path_uses_symlink_component "$ROOT_PHYS/$multirepo_baseline"; }; then
|
|
609
|
+
echo " ⚠️ $multirepo_repo 的部署基线经 symlink 到达,未读取"
|
|
610
|
+
add_scan_boundary "symlink" "$multirepo_baseline" \
|
|
611
|
+
"the deployment baseline was not read"
|
|
612
|
+
multirepo_issue=1
|
|
613
|
+
elif [ -e "$multirepo_baseline" ] && [ ! -r "$multirepo_baseline" ]; then
|
|
614
|
+
echo " ⚠️ $multirepo_repo 的部署基线当前权限不足,未读取"
|
|
615
|
+
add_scan_boundary "permission" "$multirepo_baseline" \
|
|
616
|
+
"the deployment baseline was not readable"
|
|
617
|
+
multirepo_issue=1
|
|
618
|
+
elif [ ! -f "$multirepo_baseline" ] || [ -z "$multirepo_baseline_real" ]; then
|
|
619
|
+
echo " ⚠️ $multirepo_repo 的部署基线不可读:$multirepo_baseline"
|
|
620
|
+
add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo deployment baseline is missing or not a regular file: $multirepo_baseline." "$multirepo_baseline"
|
|
621
|
+
multirepo_issue=1
|
|
622
|
+
else
|
|
623
|
+
case "$multirepo_baseline_real" in
|
|
624
|
+
"$ROOT_PHYS"/*)
|
|
625
|
+
multirepo_deployment_version="$(jq -er --arg app "$multirepo_deployment" \
|
|
626
|
+
'.apps[$app].imageTag | select(type == "string" and length > 0)' \
|
|
627
|
+
"$multirepo_baseline" 2>/dev/null || true)"
|
|
628
|
+
if [ -z "$multirepo_deployment_version" ] || ! is_release_version_token "$multirepo_deployment_version"; then
|
|
629
|
+
echo " ⚠️ $multirepo_baseline#apps.$multirepo_deployment.imageTag 缺少可核对版本"
|
|
630
|
+
add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo deployment baseline has no parseable imageTag at apps.$multirepo_deployment." "$multirepo_baseline"
|
|
631
|
+
multirepo_issue=1
|
|
632
|
+
else
|
|
633
|
+
multirepo_deployment_ok=1
|
|
634
|
+
fi
|
|
635
|
+
;;
|
|
636
|
+
*)
|
|
637
|
+
echo " ⚠️ $multirepo_repo 的部署基线解析到协调根之外"
|
|
638
|
+
add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo deployment baseline resolves outside the coordination root." "$multirepo_baseline"
|
|
639
|
+
multirepo_issue=1
|
|
640
|
+
;;
|
|
641
|
+
esac
|
|
642
|
+
fi
|
|
643
|
+
fi
|
|
644
|
+
|
|
645
|
+
if [ "$multirepo_changelog_ok" -eq 1 ] && [ "$multirepo_contract_ok" -eq 1 ]; then
|
|
646
|
+
multirepo_changelog_normalized="$(normalize_release_version "$multirepo_changelog_version")"
|
|
647
|
+
multirepo_contract_normalized="$(normalize_release_version "$multirepo_contract_version")"
|
|
648
|
+
[ "$multirepo_changelog_normalized" = "$multirepo_contract_normalized" ] || multirepo_drift=1
|
|
649
|
+
fi
|
|
650
|
+
if [ "$multirepo_deployment" != "n/a" ] \
|
|
651
|
+
&& [ "$multirepo_deployment_ok" -eq 1 ] \
|
|
652
|
+
&& [ "$multirepo_changelog_ok" -eq 1 ]; then
|
|
653
|
+
multirepo_deployment_normalized="$(normalize_release_version "$multirepo_deployment_version")"
|
|
654
|
+
multirepo_changelog_normalized="$(normalize_release_version "$multirepo_changelog_version")"
|
|
655
|
+
[ "$multirepo_deployment_normalized" = "$multirepo_changelog_normalized" ] || multirepo_drift=1
|
|
656
|
+
fi
|
|
657
|
+
if [ "$multirepo_deployment" != "n/a" ] \
|
|
658
|
+
&& [ "$multirepo_deployment_ok" -eq 1 ] \
|
|
659
|
+
&& [ "$multirepo_contract_ok" -eq 1 ]; then
|
|
660
|
+
multirepo_deployment_normalized="$(normalize_release_version "$multirepo_deployment_version")"
|
|
661
|
+
multirepo_contract_normalized="$(normalize_release_version "$multirepo_contract_version")"
|
|
662
|
+
[ "$multirepo_deployment_normalized" = "$multirepo_contract_normalized" ] || multirepo_drift=1
|
|
663
|
+
fi
|
|
664
|
+
|
|
665
|
+
if [ "$multirepo_drift" -eq 1 ]; then
|
|
666
|
+
multirepo_changelog_fact="${multirepo_changelog_version:-unverified}"
|
|
667
|
+
multirepo_contract_fact="${multirepo_contract_version:-unverified}"
|
|
668
|
+
multirepo_deployment_fact="n/a"
|
|
669
|
+
[ "$multirepo_deployment" = "n/a" ] \
|
|
670
|
+
|| multirepo_deployment_fact="$multirepo_baseline#apps.$multirepo_deployment.imageTag=${multirepo_deployment_version:-unverified}"
|
|
671
|
+
echo " ⚠️ $multirepo_repo 版本漂移: $multirepo_changelog=$multirepo_changelog_fact ↔ $multirepo_contract=$multirepo_contract_fact ↔ $multirepo_deployment_fact"
|
|
672
|
+
add_finding "sync.multirepo_drift" "conflict" "Version sources disagree for repo=$multirepo_repo: $multirepo_changelog=$multirepo_changelog_fact; $multirepo_contract=$multirepo_contract_fact; $multirepo_deployment_fact." "$multirepo_changelog"
|
|
673
|
+
elif [ "$multirepo_issue" -eq 0 ]; then
|
|
674
|
+
if [ "$multirepo_deployment" = "n/a" ]; then
|
|
675
|
+
echo " ✅ $multirepo_repo 多仓版本一致: CHANGELOG.md ↔ $multirepo_contract (deployment=n/a)"
|
|
676
|
+
else
|
|
677
|
+
echo " ✅ $multirepo_repo 多仓版本一致: CHANGELOG.md ↔ $multirepo_contract ↔ $multirepo_baseline#apps.$multirepo_deployment.imageTag"
|
|
678
|
+
fi
|
|
679
|
+
fi
|
|
680
|
+
done < "$multirepo_records"
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
check_stack_drift() {
|
|
684
|
+
stack_path="standards/STACK.md"
|
|
685
|
+
stack_baseline_raw="$BUS_TMP/stack.baseline.raw"
|
|
686
|
+
stack_expected_node="$BUS_TMP/stack.expected.node"
|
|
687
|
+
stack_expected_lockfile="$BUS_TMP/stack.expected.lockfile"
|
|
688
|
+
stack_expected_docker="$BUS_TMP/stack.expected.docker"
|
|
689
|
+
stack_actual_node="$BUS_TMP/stack.actual.node"
|
|
690
|
+
stack_actual_lockfile="$BUS_TMP/stack.actual.lockfile"
|
|
691
|
+
stack_actual_docker="$BUS_TMP/stack.actual.docker"
|
|
692
|
+
stack_files="$BUS_TMP/stack.files"
|
|
693
|
+
stack_find_errors="$BUS_TMP/stack.find-errors"
|
|
694
|
+
: > "$stack_expected_node"
|
|
695
|
+
: > "$stack_expected_lockfile"
|
|
696
|
+
: > "$stack_expected_docker"
|
|
697
|
+
: > "$stack_actual_node"
|
|
698
|
+
: > "$stack_actual_lockfile"
|
|
699
|
+
: > "$stack_actual_docker"
|
|
700
|
+
: > "$stack_files"
|
|
701
|
+
: > "$stack_find_errors"
|
|
702
|
+
|
|
703
|
+
# The baseline is deliberately a tiny line protocol rather than inferred
|
|
704
|
+
# prose. Repeated keys form sets; n/a is valid only as the sole value.
|
|
705
|
+
if ! awk '
|
|
706
|
+
$0 == "<!-- buildbeat-stack-baseline:v1" || $0 == "<!-- solobaton-stack-baseline:v1" {
|
|
707
|
+
starts++
|
|
708
|
+
if (inside) bad=1
|
|
709
|
+
inside=1
|
|
710
|
+
next
|
|
711
|
+
}
|
|
712
|
+
inside && $0 == "-->" {
|
|
713
|
+
closes++
|
|
714
|
+
inside=0
|
|
715
|
+
next
|
|
716
|
+
}
|
|
717
|
+
inside { print }
|
|
718
|
+
END {
|
|
719
|
+
if (starts != 1 || closes != 1 || inside || bad) exit 2
|
|
720
|
+
}
|
|
721
|
+
' "$stack_path" > "$stack_baseline_raw"; then
|
|
722
|
+
echo " ⚠️ $stack_path 缺少唯一可解析的 buildbeat-stack-baseline:v1 基线块(兼容旧 solobaton 标记)"
|
|
723
|
+
add_finding "stack.unverified" "unverified" "Confirmed STACK.md has no unique parseable v1 observable baseline." "$stack_path"
|
|
724
|
+
return
|
|
725
|
+
fi
|
|
726
|
+
|
|
727
|
+
stack_baseline_invalid=0
|
|
728
|
+
while IFS= read -r stack_line || [ -n "$stack_line" ]; do
|
|
729
|
+
stack_line="${stack_line%$'\r'}"
|
|
730
|
+
[ -n "$stack_line" ] || continue
|
|
731
|
+
case "$stack_line" in
|
|
732
|
+
nodeConstraint=*) stack_value="${stack_line#nodeConstraint=}"; stack_value_file="$stack_expected_node" ;;
|
|
733
|
+
lockfileKind=*) stack_value="${stack_line#lockfileKind=}"; stack_value_file="$stack_expected_lockfile" ;;
|
|
734
|
+
dockerFromImage=*) stack_value="${stack_line#dockerFromImage=}"; stack_value_file="$stack_expected_docker" ;;
|
|
735
|
+
*) stack_baseline_invalid=1; continue ;;
|
|
736
|
+
esac
|
|
737
|
+
stack_trimmed="$(printf '%s' "$stack_value" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
|
738
|
+
case "$stack_value" in *$'\t'*) stack_baseline_invalid=1; continue ;; esac
|
|
739
|
+
if [ -z "$stack_value" ] || [ "$stack_value" != "$stack_trimmed" ] \
|
|
740
|
+
|| [ "${#stack_value}" -gt 200 ] \
|
|
741
|
+
|| printf '%s' "$stack_value" | grep -Eq '<[^>]+>'; then
|
|
742
|
+
stack_baseline_invalid=1
|
|
743
|
+
continue
|
|
744
|
+
fi
|
|
745
|
+
printf '%s\n' "$stack_value" >> "$stack_value_file"
|
|
746
|
+
done < "$stack_baseline_raw"
|
|
747
|
+
|
|
748
|
+
for stack_expected_file in "$stack_expected_node" "$stack_expected_lockfile" "$stack_expected_docker"; do
|
|
749
|
+
LC_ALL=C sort -u "$stack_expected_file" -o "$stack_expected_file"
|
|
750
|
+
stack_expected_count="$(awk 'NF { n++ } END { print n+0 }' "$stack_expected_file")"
|
|
751
|
+
if [ "$stack_expected_count" -eq 0 ]; then
|
|
752
|
+
stack_baseline_invalid=1
|
|
753
|
+
elif grep -Fxq 'n/a' "$stack_expected_file" && [ "$stack_expected_count" -ne 1 ]; then
|
|
754
|
+
stack_baseline_invalid=1
|
|
755
|
+
fi
|
|
756
|
+
done
|
|
757
|
+
if [ "$stack_baseline_invalid" -eq 1 ]; then
|
|
758
|
+
echo " ⚠️ $stack_path 的 v1 可核对基线键值不完整或不唯一"
|
|
759
|
+
add_finding "stack.unverified" "unverified" "Confirmed STACK.md has an incomplete or ambiguous v1 observable baseline." "$stack_path"
|
|
760
|
+
return
|
|
761
|
+
fi
|
|
762
|
+
|
|
763
|
+
stack_global_issue=0
|
|
764
|
+
stack_node_issue=0
|
|
765
|
+
stack_lockfile_issue=0
|
|
766
|
+
stack_docker_issue=0
|
|
767
|
+
stack_find_failed=0
|
|
768
|
+
stack_limit_reached=0
|
|
769
|
+
stack_limit="${BUS_STACK_MAX:-200}"
|
|
770
|
+
case "$stack_limit" in
|
|
771
|
+
''|*[!0-9]*) stack_limit=200; stack_global_issue=1 ;;
|
|
772
|
+
*) [ "$stack_limit" -ge 1 ] || { stack_limit=200; stack_global_issue=1; } ;;
|
|
773
|
+
esac
|
|
774
|
+
|
|
775
|
+
if ! find -P . \
|
|
776
|
+
\( -type d \( -name .claude -o -name .codex -o -name .git -o -name .buildbeat -o -name .solobaton \
|
|
777
|
+
-o -name .next -o -name .nuxt -o -name .output -o -name build -o -name coverage \
|
|
778
|
+
-o -name dist -o -name node_modules -o -name target -o -name vendor \) -prune \) -o \
|
|
779
|
+
\( -type l -o \( -type f \( -name .nvmrc -o -name package.json \
|
|
780
|
+
-o -name package-lock.json -o -name npm-shrinkwrap.json -o -name pnpm-lock.yaml \
|
|
781
|
+
-o -name yarn.lock -o -name bun.lock -o -name bun.lockb \
|
|
782
|
+
-o -name Dockerfile -o -name 'Dockerfile.*' \) \) \) -print0 \
|
|
783
|
+
> "$stack_files" 2> "$stack_find_errors"; then
|
|
784
|
+
stack_global_issue=1
|
|
785
|
+
stack_find_failed=1
|
|
786
|
+
fi
|
|
787
|
+
if [ -s "$stack_find_errors" ]; then
|
|
788
|
+
stack_global_issue=1
|
|
789
|
+
stack_find_failed=1
|
|
790
|
+
fi
|
|
791
|
+
|
|
792
|
+
stack_scan_count=0
|
|
793
|
+
while IFS= read -r -d '' stack_file; do
|
|
794
|
+
stack_scan_count=$((stack_scan_count + 1))
|
|
795
|
+
if [ "$stack_scan_count" -gt "$stack_limit" ]; then
|
|
796
|
+
stack_global_issue=1
|
|
797
|
+
stack_limit_reached=1
|
|
798
|
+
break
|
|
799
|
+
fi
|
|
800
|
+
stack_base="${stack_file##*/}"
|
|
801
|
+
|
|
802
|
+
if [ -L "$stack_file" ]; then
|
|
803
|
+
case "$stack_base" in
|
|
804
|
+
.claude|.codex|.git|.buildbeat|.solobaton|.next|.nuxt|.output|build|coverage|dist|node_modules|target|vendor)
|
|
805
|
+
continue
|
|
806
|
+
;;
|
|
807
|
+
esac
|
|
808
|
+
if [ -d "$stack_file" ]; then
|
|
809
|
+
stack_global_issue=1
|
|
810
|
+
add_scan_boundary "symlink" "${stack_file#./}" \
|
|
811
|
+
"a directory in the STACK observation scope was not traversed"
|
|
812
|
+
else
|
|
813
|
+
case "$stack_base" in
|
|
814
|
+
.nvmrc|package.json)
|
|
815
|
+
stack_node_issue=1
|
|
816
|
+
add_scan_boundary "symlink" "${stack_file#./}" \
|
|
817
|
+
"a Node version source was not read"
|
|
818
|
+
;;
|
|
819
|
+
package-lock.json|npm-shrinkwrap.json|pnpm-lock.yaml|yarn.lock|bun.lock|bun.lockb)
|
|
820
|
+
stack_lockfile_issue=1
|
|
821
|
+
add_scan_boundary "symlink" "${stack_file#./}" \
|
|
822
|
+
"a lockfile source was not read"
|
|
823
|
+
;;
|
|
824
|
+
Dockerfile|Dockerfile.*)
|
|
825
|
+
stack_docker_issue=1
|
|
826
|
+
add_scan_boundary "symlink" "${stack_file#./}" \
|
|
827
|
+
"a Docker FROM source was not read"
|
|
828
|
+
;;
|
|
829
|
+
esac
|
|
830
|
+
fi
|
|
831
|
+
continue
|
|
832
|
+
fi
|
|
833
|
+
|
|
834
|
+
case "$stack_base" in
|
|
835
|
+
.nvmrc)
|
|
836
|
+
if [ ! -r "$stack_file" ]; then
|
|
837
|
+
stack_node_issue=1
|
|
838
|
+
add_scan_boundary "permission" "${stack_file#./}" \
|
|
839
|
+
"a Node version source was not readable"
|
|
840
|
+
continue
|
|
841
|
+
fi
|
|
842
|
+
stack_nvmrc_count="$(awk 'NF { n++ } END { print n+0 }' "$stack_file" 2>/dev/null || echo 0)"
|
|
843
|
+
if [ "$stack_nvmrc_count" -ne 1 ]; then
|
|
844
|
+
stack_node_issue=1
|
|
845
|
+
continue
|
|
846
|
+
fi
|
|
847
|
+
stack_value="$(awk 'NF { sub(/\r$/, ""); sub(/^[[:space:]]+/, ""); sub(/[[:space:]]+$/, ""); print; exit }' "$stack_file" 2>/dev/null || true)"
|
|
848
|
+
if [ -n "$stack_value" ]; then printf '%s\n' "$stack_value" >> "$stack_actual_node"
|
|
849
|
+
else stack_node_issue=1
|
|
850
|
+
fi
|
|
851
|
+
;;
|
|
852
|
+
package.json)
|
|
853
|
+
if [ ! -r "$stack_file" ] || ! command -v python3 >/dev/null 2>&1; then
|
|
854
|
+
stack_node_issue=1
|
|
855
|
+
if [ ! -r "$stack_file" ]; then
|
|
856
|
+
add_scan_boundary "permission" "${stack_file#./}" \
|
|
857
|
+
"a package manifest was not readable"
|
|
858
|
+
fi
|
|
859
|
+
continue
|
|
860
|
+
fi
|
|
861
|
+
stack_package_status=0
|
|
862
|
+
stack_value="$(python3 - "$stack_file" <<'PY'
|
|
863
|
+
import json
|
|
864
|
+
import sys
|
|
865
|
+
|
|
866
|
+
try:
|
|
867
|
+
with open(sys.argv[1], encoding="utf-8") as handle:
|
|
868
|
+
package = json.load(handle)
|
|
869
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
870
|
+
raise SystemExit(2)
|
|
871
|
+
|
|
872
|
+
engines = package.get("engines")
|
|
873
|
+
if engines is None:
|
|
874
|
+
raise SystemExit(0)
|
|
875
|
+
if not isinstance(engines, dict):
|
|
876
|
+
raise SystemExit(2)
|
|
877
|
+
value = engines.get("node")
|
|
878
|
+
if value is None:
|
|
879
|
+
raise SystemExit(0)
|
|
880
|
+
if not isinstance(value, str) or not value.strip() or "\n" in value or "\r" in value:
|
|
881
|
+
raise SystemExit(2)
|
|
882
|
+
print(value.strip())
|
|
883
|
+
PY
|
|
884
|
+
)" || stack_package_status=$?
|
|
885
|
+
if [ "$stack_package_status" -ne 0 ]; then
|
|
886
|
+
stack_node_issue=1
|
|
887
|
+
elif [ -n "$stack_value" ]; then
|
|
888
|
+
printf '%s\n' "$stack_value" >> "$stack_actual_node"
|
|
889
|
+
fi
|
|
890
|
+
;;
|
|
891
|
+
package-lock.json|npm-shrinkwrap.json|pnpm-lock.yaml|yarn.lock|bun.lock|bun.lockb)
|
|
892
|
+
if [ -r "$stack_file" ]; then printf '%s\n' "$stack_base" >> "$stack_actual_lockfile"
|
|
893
|
+
else
|
|
894
|
+
stack_lockfile_issue=1
|
|
895
|
+
add_scan_boundary "permission" "${stack_file#./}" \
|
|
896
|
+
"a lockfile source was not readable"
|
|
897
|
+
fi
|
|
898
|
+
;;
|
|
899
|
+
Dockerfile|Dockerfile.*)
|
|
900
|
+
if [ ! -r "$stack_file" ]; then
|
|
901
|
+
stack_docker_issue=1
|
|
902
|
+
add_scan_boundary "permission" "${stack_file#./}" \
|
|
903
|
+
"a Docker FROM source was not readable"
|
|
904
|
+
continue
|
|
905
|
+
fi
|
|
906
|
+
stack_from_values="$(awk '
|
|
907
|
+
/^[[:space:]]*[Ff][Rr][Oo][Mm][[:space:]]+/ {
|
|
908
|
+
line=$0
|
|
909
|
+
sub(/^[[:space:]]*[Ff][Rr][Oo][Mm][[:space:]]+/, "", line)
|
|
910
|
+
sub(/^--platform=[^[:space:]]+[[:space:]]+/, "", line)
|
|
911
|
+
split(line, fields, /[[:space:]]+/)
|
|
912
|
+
print fields[1]
|
|
913
|
+
}
|
|
914
|
+
' "$stack_file" 2>/dev/null || true)"
|
|
915
|
+
if [ -z "$stack_from_values" ]; then
|
|
916
|
+
stack_docker_issue=1
|
|
917
|
+
continue
|
|
918
|
+
fi
|
|
919
|
+
while IFS= read -r stack_value; do
|
|
920
|
+
case "$stack_value" in
|
|
921
|
+
''|*'$'*) stack_docker_issue=1 ;;
|
|
922
|
+
*)
|
|
923
|
+
if printf '%s' "$stack_value" | grep -Fq "\\"; then stack_docker_issue=1
|
|
924
|
+
else printf '%s\n' "$stack_value" >> "$stack_actual_docker"
|
|
925
|
+
fi
|
|
926
|
+
;;
|
|
927
|
+
esac
|
|
928
|
+
done <<< "$stack_from_values"
|
|
929
|
+
;;
|
|
930
|
+
esac
|
|
931
|
+
done < "$stack_files"
|
|
932
|
+
|
|
933
|
+
if [ "$stack_find_failed" -eq 1 ]; then
|
|
934
|
+
add_scan_boundary "permission" "." \
|
|
935
|
+
"filesystem traversal for STACK observations returned an unreadable or I/O boundary"
|
|
936
|
+
fi
|
|
937
|
+
if [ "$stack_limit_reached" -eq 1 ]; then
|
|
938
|
+
add_scan_boundary "limit" "." \
|
|
939
|
+
"the STACK observation scan stopped at BUS_STACK_MAX=$stack_limit"
|
|
940
|
+
fi
|
|
941
|
+
|
|
942
|
+
LC_ALL=C sort -u "$stack_actual_node" -o "$stack_actual_node"
|
|
943
|
+
LC_ALL=C sort -u "$stack_actual_lockfile" -o "$stack_actual_lockfile"
|
|
944
|
+
LC_ALL=C sort -u "$stack_actual_docker" -o "$stack_actual_docker"
|
|
945
|
+
|
|
946
|
+
stack_drift_dims=""
|
|
947
|
+
stack_unverified_dims=""
|
|
948
|
+
for stack_dimension in node lockfile docker; do
|
|
949
|
+
case "$stack_dimension" in
|
|
950
|
+
node) stack_label="Node constraints"; stack_expected="$stack_expected_node"; stack_actual="$stack_actual_node"; stack_issue="$stack_node_issue" ;;
|
|
951
|
+
lockfile) stack_label="lockfile kinds"; stack_expected="$stack_expected_lockfile"; stack_actual="$stack_actual_lockfile"; stack_issue="$stack_lockfile_issue" ;;
|
|
952
|
+
docker) stack_label="Docker FROM images"; stack_expected="$stack_expected_docker"; stack_actual="$stack_actual_docker"; stack_issue="$stack_docker_issue" ;;
|
|
953
|
+
esac
|
|
954
|
+
[ "$stack_global_issue" -eq 0 ] || stack_issue=1
|
|
955
|
+
|
|
956
|
+
stack_dimension_drift=0
|
|
957
|
+
stack_dimension_unverified=0
|
|
958
|
+
if [ -s "$stack_actual" ]; then
|
|
959
|
+
cmp -s "$stack_expected" "$stack_actual" || stack_dimension_drift=1
|
|
960
|
+
[ "$stack_issue" -eq 0 ] || stack_dimension_unverified=1
|
|
961
|
+
elif grep -Fxq 'n/a' "$stack_expected"; then
|
|
962
|
+
[ "$stack_issue" -eq 0 ] || stack_dimension_unverified=1
|
|
963
|
+
else
|
|
964
|
+
stack_dimension_unverified=1
|
|
965
|
+
fi
|
|
966
|
+
|
|
967
|
+
if [ "$stack_dimension_drift" -eq 1 ]; then
|
|
968
|
+
[ -z "$stack_drift_dims" ] || stack_drift_dims="$stack_drift_dims, "
|
|
969
|
+
stack_drift_dims="$stack_drift_dims$stack_label"
|
|
970
|
+
fi
|
|
971
|
+
if [ "$stack_dimension_unverified" -eq 1 ]; then
|
|
972
|
+
[ -z "$stack_unverified_dims" ] || stack_unverified_dims="$stack_unverified_dims, "
|
|
973
|
+
stack_unverified_dims="$stack_unverified_dims$stack_label"
|
|
974
|
+
fi
|
|
975
|
+
done
|
|
976
|
+
|
|
977
|
+
if [ -n "$stack_drift_dims" ]; then
|
|
978
|
+
echo " ❌ $stack_path 与已扫描仓库事实冲突(${stack_drift_dims})"
|
|
979
|
+
add_finding "stack.drift" "conflict" "Declared STACK baseline conflicts with observed repository facts for: $stack_drift_dims." "$stack_path"
|
|
980
|
+
fi
|
|
981
|
+
if [ -n "$stack_unverified_dims" ]; then
|
|
982
|
+
echo " ⚠️ $stack_path 的漂移检查未完整覆盖(${stack_unverified_dims})"
|
|
983
|
+
add_finding "stack.unverified" "unverified" "STACK drift coverage is incomplete for: $stack_unverified_dims." "$stack_path"
|
|
984
|
+
fi
|
|
985
|
+
if [ -z "$stack_drift_dims" ] && [ -z "$stack_unverified_dims" ]; then
|
|
986
|
+
echo " ✅ $stack_path 可核对基线与已扫描仓库事实一致"
|
|
987
|
+
fi
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
finalize_findings() {
|
|
991
|
+
tab="$(printf '\t')"
|
|
992
|
+
LC_ALL=C sort -t "$tab" -k1,1n -k5,5 -k2,2 "$FINDINGS_RAW" \
|
|
993
|
+
| awk '!seen[$0]++' > "$FINDINGS_SORTED"
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
render_json_report() {
|
|
997
|
+
awk -F '\t' -v strict="$STRICT" '
|
|
998
|
+
function esc(s) {
|
|
999
|
+
gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s)
|
|
1000
|
+
gsub(/\r/, "\\r", s); gsub(/\t/, "\\t", s)
|
|
1001
|
+
return s
|
|
1002
|
+
}
|
|
1003
|
+
{
|
|
1004
|
+
n++; code[n]=$2; level[n]=$3; message[n]=$4; path[n]=$5
|
|
1005
|
+
count[$3]++
|
|
1006
|
+
if ($3=="conflict" || $3=="error") blocked=1
|
|
1007
|
+
if ($3=="unverified" && !reason_seen[$2]++) reason[++reason_n]=$2
|
|
1008
|
+
}
|
|
1009
|
+
END {
|
|
1010
|
+
ok_value = blocked ? "false" : "true"
|
|
1011
|
+
coverage_value = reason_n ? "false" : "true"
|
|
1012
|
+
strict_enabled = strict==1 ? "true" : "false"
|
|
1013
|
+
strict_blocked = (strict==1 && blocked) ? "true" : "false"
|
|
1014
|
+
print "{"
|
|
1015
|
+
print " \"schemaVersion\": 1,"
|
|
1016
|
+
print " \"command\": \"bus-check\","
|
|
1017
|
+
printf " \"ok\": %s,\n", ok_value
|
|
1018
|
+
print " \"target\": \".\","
|
|
1019
|
+
print " \"findings\": ["
|
|
1020
|
+
for (i=1; i<=n; i++) {
|
|
1021
|
+
printf " {\"code\":\"%s\",\"level\":\"%s\",\"message\":\"%s\"", esc(code[i]), esc(level[i]), esc(message[i])
|
|
1022
|
+
if (path[i] != "") printf ",\"path\":\"%s\"", esc(path[i])
|
|
1023
|
+
suffix = ""; if (i<n) suffix = ","
|
|
1024
|
+
printf "}%s\n", suffix
|
|
1025
|
+
}
|
|
1026
|
+
print " ],"
|
|
1027
|
+
printf " \"summary\": {\"confirmed\":%d,\"warning\":%d,\"unverified\":%d,\"conflict\":%d,\"error\":%d},\n", count["confirmed"]+0, count["warning"]+0, count["unverified"]+0, count["conflict"]+0, count["error"]+0
|
|
1028
|
+
printf " \"coverage\": {\"complete\":%s,\"reasons\":[", coverage_value
|
|
1029
|
+
for (i=1; i<=reason_n; i++) {
|
|
1030
|
+
suffix = ""; if (i<reason_n) suffix = ","
|
|
1031
|
+
printf "\"%s\"%s", esc(reason[i]), suffix
|
|
1032
|
+
}
|
|
1033
|
+
print "]},"
|
|
1034
|
+
printf " \"strict\": {\"enabled\":%s,\"blocked\":%s}\n", strict_enabled, strict_blocked
|
|
1035
|
+
print "}"
|
|
1036
|
+
}
|
|
1037
|
+
' "$FINDINGS_SORTED"
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
SUBREPOS=() # 留空自动发现;或写死:SUBREPOS=("仓1" "目录/仓2")
|
|
1041
|
+
|
|
1042
|
+
echo "════════ 协作总线 开工同步 (bus-check) ════════"
|
|
1043
|
+
echo "▸ 工作区: $ROOT (协调层脚本: $SDIR/)" # 打印解析结果:根认错了(嵌套项目/骨架没建)一眼能看出,不静默
|
|
1044
|
+
echo ""
|
|
1045
|
+
|
|
1046
|
+
# 1) meta 仓是否落后远端
|
|
1047
|
+
if [ "${BUS_CHECK_NO_FETCH:-0}" = "1" ]; then
|
|
1048
|
+
add_finding "sync.unverified" "unverified" "Remote synchronization checks were skipped by BUS_CHECK_NO_FETCH=1." "."
|
|
1049
|
+
elif ! git fetch --quiet 2>/dev/null; then
|
|
1050
|
+
add_finding "sync.unverified" "unverified" "The meta-repository fetch failed; remote synchronization is not verified." "."
|
|
1051
|
+
fi
|
|
1052
|
+
LOCAL=$(git rev-parse @ 2>/dev/null || echo "?")
|
|
1053
|
+
REMOTE=$(git rev-parse '@{u}' 2>/dev/null || echo "")
|
|
1054
|
+
if [ -n "$REMOTE" ] && [ "$LOCAL" != "$REMOTE" ]; then
|
|
1055
|
+
m_behind=$(git rev-list --count 'HEAD..@{u}' 2>/dev/null || echo "?")
|
|
1056
|
+
m_ahead=$(git rev-list --count '@{u}..HEAD' 2>/dev/null || echo "?")
|
|
1057
|
+
if [ "$m_behind" != "0" ]; then
|
|
1058
|
+
echo "⚠️ meta 仓落后远端 $m_behind 个提交 —— 开工前先 git pull !"
|
|
1059
|
+
else
|
|
1060
|
+
echo "⚠️ meta 仓领先远端 $m_ahead 个提交(本地未推送)—— 记得 git push"
|
|
1061
|
+
fi
|
|
1062
|
+
else
|
|
1063
|
+
echo "✅ meta 仓与远端同步(或无上游)"
|
|
1064
|
+
fi
|
|
1065
|
+
|
|
1066
|
+
# 1.5) 子仓是否落后/领先远端
|
|
1067
|
+
if [ ${#SUBREPOS[@]} -eq 0 ]; then
|
|
1068
|
+
for g in ./*/.git ./*/*/.git; do
|
|
1069
|
+
[ -d "$g" ] || continue
|
|
1070
|
+
SUBREPOS+=("${g%/.git}")
|
|
1071
|
+
done
|
|
1072
|
+
fi
|
|
1073
|
+
echo ""
|
|
1074
|
+
echo "── 子仓 ⇄ 远端 ──"
|
|
1075
|
+
for r in "${SUBREPOS[@]:-}"; do
|
|
1076
|
+
if [ -z "$r" ] || [ ! -d "$r/.git" ]; then
|
|
1077
|
+
continue
|
|
1078
|
+
fi
|
|
1079
|
+
subrepo_display="${r#./}"
|
|
1080
|
+
if path_uses_symlink_component "$ROOT_PHYS/$subrepo_display"; then
|
|
1081
|
+
echo " ⚠️ $subrepo_display 经 symlink 到达,远端同步未检查"
|
|
1082
|
+
add_scan_boundary "symlink" "$subrepo_display" \
|
|
1083
|
+
"the discovered repository was not traversed"
|
|
1084
|
+
continue
|
|
1085
|
+
fi
|
|
1086
|
+
if [ ! -r "$r" ] || [ ! -x "$r" ]; then
|
|
1087
|
+
echo " ⚠️ $subrepo_display 当前权限不足,远端同步未检查"
|
|
1088
|
+
add_scan_boundary "permission" "$subrepo_display" \
|
|
1089
|
+
"the discovered repository was not readable and searchable"
|
|
1090
|
+
continue
|
|
1091
|
+
fi
|
|
1092
|
+
if [ "${BUS_CHECK_NO_FETCH:-0}" != "1" ] && ! git -C "$r" fetch --quiet 2>/dev/null; then
|
|
1093
|
+
add_finding "sync.unverified" "unverified" "A sub-repository fetch failed; remote synchronization is not verified." "$subrepo_display"
|
|
1094
|
+
fi
|
|
1095
|
+
if git -C "$r" rev-parse '@{u}' >/dev/null 2>&1; then
|
|
1096
|
+
ahead=$(git -C "$r" rev-list --count '@{u}..HEAD' 2>/dev/null || echo "?")
|
|
1097
|
+
behind=$(git -C "$r" rev-list --count 'HEAD..@{u}' 2>/dev/null || echo "?")
|
|
1098
|
+
head_h=$(git -C "$r" rev-parse --short HEAD 2>/dev/null || echo "?")
|
|
1099
|
+
flag="✅"; { [ "$behind" != "0" ] || [ "$ahead" != "0" ]; } && flag="⚠️ "
|
|
1100
|
+
printf " %s %-32s HEAD %s 领先 %s / 落后 %s\n" "$flag" "$subrepo_display" "$head_h" "$ahead" "$behind"
|
|
1101
|
+
else
|
|
1102
|
+
printf " · %-32s (无上游)\n" "$subrepo_display"
|
|
1103
|
+
fi
|
|
1104
|
+
done
|
|
1105
|
+
echo ""
|
|
1106
|
+
|
|
1107
|
+
# 1.6) Multi-repo version/contract/deployment joins. Only explicit map rows can
|
|
1108
|
+
# establish equality; every missing/unmapped source remains visible as unverified.
|
|
1109
|
+
echo "── 多仓版本 / 契约 / 部署基线 ──"
|
|
1110
|
+
check_multirepo_drift
|
|
1111
|
+
echo ""
|
|
1112
|
+
|
|
1113
|
+
# 1.7) 机器闸自检(一道闸的强度不超过守闸规则的强度 —— 用在跑的闸守新闸;.git/hooks 不进版本控制,克隆后闸不存在)
|
|
1114
|
+
echo "── 机器闸自检 (pre-commit) ──"
|
|
1115
|
+
gate_missing=""; gate_checked=0
|
|
1116
|
+
for r in . "${SUBREPOS[@]:-}"; do
|
|
1117
|
+
[ -n "$r" ] || continue; [ -e "$r/.git" ] || continue
|
|
1118
|
+
if [ "$r" != "." ]; then
|
|
1119
|
+
path_uses_symlink_component "$ROOT_PHYS/${r#./}" && continue
|
|
1120
|
+
if [ ! -r "$r" ] || [ ! -x "$r" ]; then continue; fi
|
|
1121
|
+
fi
|
|
1122
|
+
gate_checked=1
|
|
1123
|
+
installed=0
|
|
1124
|
+
[ -f "$r/.git/hooks/pre-commit" ] && installed=1
|
|
1125
|
+
hpath=$(git -C "$r" config core.hooksPath 2>/dev/null || true)
|
|
1126
|
+
if [ -n "$hpath" ]; then
|
|
1127
|
+
{ [ -f "$hpath/pre-commit" ] || [ -f "$r/$hpath/pre-commit" ]; } && installed=1
|
|
1128
|
+
fi
|
|
1129
|
+
[ "$installed" = 1 ] || gate_missing="$gate_missing ${r#./}"
|
|
1130
|
+
done
|
|
1131
|
+
if [ "$gate_checked" = 0 ]; then
|
|
1132
|
+
echo " (没有可检查的 git 仓 —— 无法判定)"
|
|
1133
|
+
elif [ -n "$gate_missing" ]; then
|
|
1134
|
+
echo " ⚠️ 未装 pre-commit 闸:${gate_missing# } —— 每仓装一次(装法见 $SDIR/pre-commit.sh 头部注释)"
|
|
1135
|
+
else
|
|
1136
|
+
echo " ✅ meta 仓与全部子仓均已装 pre-commit 闸"
|
|
1137
|
+
fi
|
|
1138
|
+
echo ""
|
|
1139
|
+
|
|
1140
|
+
# 2) 当前期 + 看板指针 (pm/NOW.md)
|
|
1141
|
+
echo "── 当前期 / 协调看板 (pm/NOW.md) ──"
|
|
1142
|
+
now_scan_ok=0
|
|
1143
|
+
if [ -f pm/NOW.md ]; then
|
|
1144
|
+
if [ -L pm/NOW.md ] \
|
|
1145
|
+
|| path_uses_symlink_component "$ROOT_PHYS/pm/NOW.md"; then
|
|
1146
|
+
echo " (pm/NOW.md 经 symlink 到达,未读取)"
|
|
1147
|
+
add_scan_boundary "symlink" "pm/NOW.md" \
|
|
1148
|
+
"the live coordination pointer was not read"
|
|
1149
|
+
elif [ ! -r pm/NOW.md ]; then
|
|
1150
|
+
echo " (pm/NOW.md 当前权限不足,未读取)"
|
|
1151
|
+
add_scan_boundary "permission" "pm/NOW.md" \
|
|
1152
|
+
"the live coordination pointer was not readable"
|
|
1153
|
+
else
|
|
1154
|
+
now_scan_ok=1
|
|
1155
|
+
fi
|
|
1156
|
+
fi
|
|
1157
|
+
if [ "$now_scan_ok" -eq 1 ]; then
|
|
1158
|
+
grep -m1 "^\*\*当前期" pm/NOW.md 2>/dev/null || echo " (NOW.md 无「当前期」行)"
|
|
1159
|
+
grep -m1 "本期轨道" pm/NOW.md 2>/dev/null || true
|
|
1160
|
+
grep -m1 "当期看板" pm/NOW.md 2>/dev/null || true
|
|
1161
|
+
fi
|
|
1162
|
+
echo ""
|
|
1163
|
+
|
|
1164
|
+
# 2.5) 协调层腐烂检测(仪式没有护栏 = 没有仪式;阈值可用 env 调:BUS_NOW_MAX / BUS_STATUS_MAX)
|
|
1165
|
+
echo "── 协调层腐烂检测 ──"
|
|
1166
|
+
cur_board=""
|
|
1167
|
+
board_scan_ok=0
|
|
1168
|
+
if [ ! -f pm/NOW.md ]; then
|
|
1169
|
+
echo " (pm/NOW.md 不存在 —— 无法判定,先按模板建骨架)"
|
|
1170
|
+
add_finding "ref.broken" "conflict" "pm/NOW.md is missing." "pm/NOW.md"
|
|
1171
|
+
elif [ "$now_scan_ok" -eq 0 ]; then
|
|
1172
|
+
echo " (pm/NOW.md 未读取;协调层腐烂检测保持 unverified)"
|
|
1173
|
+
else
|
|
1174
|
+
rot=0; board_note=""
|
|
1175
|
+
NOW_MAX="${BUS_NOW_MAX:-40}"; ST_MAX="${BUS_STATUS_MAX:-60}"
|
|
1176
|
+
# a) NOW 薄指针长肥(BuildBeat lessons 第 1 条:NOW 长肥 = 腐烂开端)
|
|
1177
|
+
now_lines=$(wc -l < pm/NOW.md | tr -d ' ')
|
|
1178
|
+
if [ "$now_lines" -gt "$NOW_MAX" ]; then
|
|
1179
|
+
echo " ⚠️ pm/NOW.md 已 $now_lines 行(>$NOW_MAX)—— 薄指针长肥,跑换期压缩仪式(NOW 底部 checklist)"; rot=1
|
|
1180
|
+
add_finding "sync.now_bloated" "conflict" "pm/NOW.md exceeds the configured live-file limit ($now_lines > $NOW_MAX)." "pm/NOW.md"
|
|
1181
|
+
fi
|
|
1182
|
+
# b) 当期看板存在性 + 非当期看板滞留 pm/(该 git mv 进 archive/<期>/)
|
|
1183
|
+
# shellcheck disable=SC2016 # single quotes intentionally protect the backticks in the sed pattern
|
|
1184
|
+
cur_board=$(grep -m1 "当期看板" pm/NOW.md 2>/dev/null | sed -n 's/.*`\([^`]*看板[^`]*\.md\)`.*/\1/p')
|
|
1185
|
+
cur_board=$(basename "${cur_board:-}" 2>/dev/null)
|
|
1186
|
+
case "$cur_board" in *"<"*) cur_board="";; esac # NOW 还是占位符 → 判不了
|
|
1187
|
+
if [ -z "$cur_board" ]; then
|
|
1188
|
+
board_note="(NOW 未填当期看板,看板检查跳过)"
|
|
1189
|
+
add_finding "sync.unverified" "unverified" "The current-board pointer cannot be determined from pm/NOW.md." "pm/NOW.md"
|
|
1190
|
+
else
|
|
1191
|
+
if [ ! -f "pm/$cur_board" ]; then
|
|
1192
|
+
echo " ⚠️ NOW 指向的当期看板 pm/$cur_board 不存在 —— 坏指针,先修 NOW"
|
|
1193
|
+
add_finding "ref.broken" "conflict" "The current-board pointer does not resolve." "pm/$cur_board"
|
|
1194
|
+
rot=1
|
|
1195
|
+
elif [ -L "pm/$cur_board" ] \
|
|
1196
|
+
|| path_uses_symlink_component "$ROOT_PHYS/pm/$cur_board"; then
|
|
1197
|
+
echo " ⚠️ pm/$cur_board 经 symlink 到达,未读取"
|
|
1198
|
+
add_scan_boundary "symlink" "pm/$cur_board" \
|
|
1199
|
+
"the current board was not read"
|
|
1200
|
+
rot=1
|
|
1201
|
+
elif [ ! -r "pm/$cur_board" ]; then
|
|
1202
|
+
echo " ⚠️ pm/$cur_board 当前权限不足,未读取"
|
|
1203
|
+
add_scan_boundary "permission" "pm/$cur_board" \
|
|
1204
|
+
"the current board was not readable"
|
|
1205
|
+
rot=1
|
|
1206
|
+
else
|
|
1207
|
+
board_scan_ok=1
|
|
1208
|
+
fi
|
|
1209
|
+
for b in pm/*看板*.md; do
|
|
1210
|
+
{ [ -e "$b" ] || [ -L "$b" ]; } || continue
|
|
1211
|
+
base="$(basename "$b")"
|
|
1212
|
+
[ "$base" = "$cur_board" ] || {
|
|
1213
|
+
echo " ⚠️ $base 不是当期看板还留在 pm/ —— 归档进 pm/archive/<期>/"
|
|
1214
|
+
add_finding "sync.now_bloated" "conflict" "A non-current board remains in the live pm directory." "pm/$base"
|
|
1215
|
+
rot=1
|
|
1216
|
+
}
|
|
1217
|
+
done
|
|
1218
|
+
fi
|
|
1219
|
+
# c) status 文件超长(该截断:全文快照进 archive,live 只留基线+最近一条)
|
|
1220
|
+
for f in pm/status/*.md; do
|
|
1221
|
+
{ [ -e "$f" ] || [ -L "$f" ]; } || continue
|
|
1222
|
+
base="$(basename "$f")"; [ "$base" = "README.md" ] && continue
|
|
1223
|
+
if [ -L "$f" ] || path_uses_symlink_component "$ROOT_PHYS/$f"; then
|
|
1224
|
+
echo " ⚠️ pm/status/$base 经 symlink 到达,未读取"
|
|
1225
|
+
add_scan_boundary "symlink" "pm/status/$base" \
|
|
1226
|
+
"a live status file was not read"
|
|
1227
|
+
rot=1
|
|
1228
|
+
continue
|
|
1229
|
+
fi
|
|
1230
|
+
if [ ! -r "$f" ]; then
|
|
1231
|
+
echo " ⚠️ pm/status/$base 当前权限不足,未读取"
|
|
1232
|
+
add_scan_boundary "permission" "pm/status/$base" \
|
|
1233
|
+
"a live status file was not readable"
|
|
1234
|
+
rot=1
|
|
1235
|
+
continue
|
|
1236
|
+
fi
|
|
1237
|
+
n=$(wc -l < "$f" | tr -d ' ')
|
|
1238
|
+
[ "$n" -le "$ST_MAX" ] || {
|
|
1239
|
+
echo " ⚠️ pm/status/$base 已 $n 行(>$ST_MAX)—— 换期压缩仪式该截断了"
|
|
1240
|
+
add_finding "sync.now_bloated" "conflict" "A live status file exceeds the configured limit ($n > $ST_MAX)." "pm/status/$base"
|
|
1241
|
+
rot=1
|
|
1242
|
+
}
|
|
1243
|
+
done
|
|
1244
|
+
if [ "$rot" = 0 ]; then
|
|
1245
|
+
if [ -n "$board_note" ]; then echo " ✅ NOW 薄、status 克制 $board_note"
|
|
1246
|
+
else echo " ✅ NOW 薄、看板归位、status 克制"; fi
|
|
1247
|
+
fi
|
|
1248
|
+
fi
|
|
1249
|
+
echo ""
|
|
1250
|
+
|
|
1251
|
+
# 2.6) canonical Gate / completed-WP evidence / scoped local references
|
|
1252
|
+
echo "── Gate / 完成证据 / 作用域引用 ──"
|
|
1253
|
+
board_path=""
|
|
1254
|
+
if [ "$board_scan_ok" -eq 1 ]; then
|
|
1255
|
+
board_path="pm/$cur_board"
|
|
1256
|
+
fi
|
|
1257
|
+
|
|
1258
|
+
if [ -n "$board_path" ]; then
|
|
1259
|
+
for gate_n in 1 2 3 4; do
|
|
1260
|
+
gate_lines="$(grep -nE "^[[:space:]]*- Gate${gate_n}:" "$board_path" 2>/dev/null || true)"
|
|
1261
|
+
gate_count="$(printf '%s\n' "$gate_lines" | awk 'NF { n++ } END { print n+0 }')"
|
|
1262
|
+
if [ "$gate_count" -eq 0 ]; then
|
|
1263
|
+
echo " ⚠️ Gate${gate_n} 缺少 canonical 状态行(legacy 看板可继续读,但不可机器判定)"
|
|
1264
|
+
add_finding "gate.line_missing" "warning" "Gate${gate_n} canonical state line is missing." "$board_path"
|
|
1265
|
+
continue
|
|
1266
|
+
fi
|
|
1267
|
+
if [ "$gate_count" -ne 1 ]; then
|
|
1268
|
+
echo " ❌ Gate${gate_n} 状态行出现 $gate_count 次 —— protocol 无法唯一判定"
|
|
1269
|
+
add_finding "gate.invalid" "error" "Gate${gate_n} must appear exactly once; found $gate_count lines." "$board_path"
|
|
1270
|
+
continue
|
|
1271
|
+
fi
|
|
1272
|
+
|
|
1273
|
+
gate_line="${gate_lines#*:}"
|
|
1274
|
+
if ! printf '%s\n' "$gate_line" | grep -Eq "^[[:space:]]*- Gate${gate_n}: (pending|passed|blocked|n/a)([[:space:]]*\|.*)?[[:space:]]*$"; then
|
|
1275
|
+
echo " ❌ Gate${gate_n} 状态行格式或 state 非法"
|
|
1276
|
+
add_finding "gate.invalid" "error" "Gate${gate_n} has a malformed canonical state line." "$board_path"
|
|
1277
|
+
continue
|
|
1278
|
+
fi
|
|
1279
|
+
gate_state="$(printf '%s\n' "$gate_line" | sed -E "s/^[[:space:]]*- Gate${gate_n}: (pending|passed|blocked|n\/a).*/\1/")"
|
|
1280
|
+
|
|
1281
|
+
if [ "$gate_state" = "n/a" ]; then
|
|
1282
|
+
# shellcheck disable=SC2016 # backticks are literal Markdown delimiters
|
|
1283
|
+
gate_reason="$(printf '%s\n' "$gate_line" | sed -nE 's/.*理由:[[:space:]]*`([^`]*)`.*/\1/p')"
|
|
1284
|
+
if [ -z "$gate_reason" ] || printf '%s\n' "$gate_reason" | grep -qE '<[^>]*>'; then
|
|
1285
|
+
echo " ⚠️ Gate${gate_n}=n/a 但缺少同一行的非占位理由"
|
|
1286
|
+
add_finding "gate.na_without_reason" "conflict" "Gate${gate_n} is n/a without a non-placeholder reason." "$board_path"
|
|
1287
|
+
fi
|
|
1288
|
+
if [ "$gate_n" -eq 2 ] && detect_ui_signal; then
|
|
1289
|
+
echo " ⚠️ Gate2=n/a 但仓库存在明确 UI 信号 —— 复核是否应进入 Gate2"
|
|
1290
|
+
add_finding "gate.na_inconsistent" "warning" "Gate2 is n/a even though a positive UI signal was detected." "$board_path"
|
|
1291
|
+
fi
|
|
1292
|
+
fi
|
|
1293
|
+
|
|
1294
|
+
if [ "$gate_state" = "passed" ]; then
|
|
1295
|
+
gate_ref_seen=0; gate_ref_valid=0; gate_decision_issue=0
|
|
1296
|
+
if printf '%s\n' "$gate_line" | grep -Eq '决策:'; then
|
|
1297
|
+
# shellcheck disable=SC2016 # backticks are literal Markdown delimiters
|
|
1298
|
+
gate_decision_ref="$(printf '%s\n' "$gate_line" | sed -nE 's/.*决策:[[:space:]]*`([^`]*)`.*/\1/p')"
|
|
1299
|
+
if [ -z "$gate_decision_ref" ] || ! validate_decision_reference "$gate_decision_ref"; then
|
|
1300
|
+
gate_decision_issue=1
|
|
1301
|
+
fi
|
|
1302
|
+
fi
|
|
1303
|
+
if printf '%s\n' "$gate_line" | grep -Eq '(决策|证据):[[:space:]]*`'; then
|
|
1304
|
+
while IFS= read -r gate_ref; do
|
|
1305
|
+
[ -n "$gate_ref" ] || continue
|
|
1306
|
+
gate_ref_seen=1
|
|
1307
|
+
if validate_reference "$gate_ref" "$board_path"; then
|
|
1308
|
+
gate_ref_valid=1
|
|
1309
|
+
else
|
|
1310
|
+
ref_rc=$?
|
|
1311
|
+
if [ "$ref_rc" -eq 2 ]; then
|
|
1312
|
+
add_finding "sync.unverified" "unverified" "Gate${gate_n} uses a remote reference that was not checked." "$board_path"
|
|
1313
|
+
elif [ "$ref_rc" -eq 4 ]; then
|
|
1314
|
+
echo " ⚠️ Gate${gate_n} 引用经 symlink 到达,未读取:$gate_ref"
|
|
1315
|
+
add_scan_boundary "symlink" "$(reference_display_path "$gate_ref")" \
|
|
1316
|
+
"a local reference was not followed"
|
|
1317
|
+
elif [ "$ref_rc" -eq 5 ]; then
|
|
1318
|
+
echo " ⚠️ Gate${gate_n} 引用当前权限不足,未读取:$gate_ref"
|
|
1319
|
+
add_scan_boundary "permission" "$(reference_display_path "$gate_ref")" \
|
|
1320
|
+
"a local reference was not readable"
|
|
1321
|
+
elif [ "$ref_rc" -eq 1 ]; then
|
|
1322
|
+
echo " ⚠️ Gate${gate_n} 引用无法解析:$gate_ref"
|
|
1323
|
+
add_finding "ref.broken" "conflict" "Gate${gate_n} reference does not resolve: $gate_ref" "$board_path"
|
|
1324
|
+
fi
|
|
1325
|
+
fi
|
|
1326
|
+
done < <(extract_backtick_tokens "$gate_line")
|
|
1327
|
+
fi
|
|
1328
|
+
if printf '%s\n' "$gate_line" | grep -Eq '证据:'; then
|
|
1329
|
+
# shellcheck disable=SC2016 # backticks are literal Markdown delimiters
|
|
1330
|
+
gate_evidence_ref="$(printf '%s\n' "$gate_line" | sed -nE 's/.*证据:[[:space:]]*`([^`]*)`.*/\1/p')"
|
|
1331
|
+
if [ -n "$gate_evidence_ref" ] && validate_reference "$gate_evidence_ref" "$board_path"; then
|
|
1332
|
+
evidence_location_status "$gate_evidence_ref"
|
|
1333
|
+
evidence_location_rc=$?
|
|
1334
|
+
if [ "$evidence_location_rc" -eq 1 ]; then
|
|
1335
|
+
echo " ⚠️ Gate${gate_n} 的本地证据未落在 pm/archive/<期>/evidence/"
|
|
1336
|
+
add_finding "evidence.outside_archive" "warning" "Gate${gate_n} uses a local evidence path outside pm/archive/<period>/evidence/." "$board_path"
|
|
1337
|
+
fi
|
|
1338
|
+
fi
|
|
1339
|
+
fi
|
|
1340
|
+
if [ "$gate_decision_issue" -eq 1 ]; then
|
|
1341
|
+
echo " ⚠️ Gate${gate_n}=passed 的决策引用未指向 pm/decisions.md 有效决策行"
|
|
1342
|
+
add_finding "gate.pass_untraceable" "warning" "Gate${gate_n} has a decision reference that does not identify an existing decisions.md row." "$board_path"
|
|
1343
|
+
elif [ "$gate_ref_seen" -eq 0 ] || [ "$gate_ref_valid" -eq 0 ]; then
|
|
1344
|
+
echo " ⚠️ Gate${gate_n}=passed 但没有可追溯的决策/证据引用"
|
|
1345
|
+
add_finding "gate.pass_untraceable" "warning" "Gate${gate_n} is passed without a traceable decision or evidence reference." "$board_path"
|
|
1346
|
+
fi
|
|
1347
|
+
fi
|
|
1348
|
+
done
|
|
1349
|
+
|
|
1350
|
+
while IFS=$'\t' read -r wp_title evidence_count evidence_line; do
|
|
1351
|
+
[ -n "$wp_title" ] || continue
|
|
1352
|
+
if [ "$evidence_count" -ne 1 ]; then
|
|
1353
|
+
echo " ⚠️ $wp_title 已完成但 **证据** 行数量为 $evidence_count(要求恰好 1)"
|
|
1354
|
+
add_finding "evidence.missing" "conflict" "$wp_title is complete but does not have exactly one canonical evidence line." "$board_path"
|
|
1355
|
+
continue
|
|
1356
|
+
fi
|
|
1357
|
+
if [ -z "$evidence_line" ] || printf '%s\n' "$evidence_line" | grep -qE '<[^>]*>|待补|TBD'; then
|
|
1358
|
+
echo " ⚠️ $wp_title 已完成但证据仍为空或占位符"
|
|
1359
|
+
add_finding "evidence.missing" "conflict" "$wp_title is complete but its evidence line is empty or placeholder-only." "$board_path"
|
|
1360
|
+
continue
|
|
1361
|
+
fi
|
|
1362
|
+
|
|
1363
|
+
evidence_ref_seen=0; evidence_ref_valid=0; evidence_ref_unverified=0
|
|
1364
|
+
while IFS= read -r evidence_ref; do
|
|
1365
|
+
[ -n "$evidence_ref" ] || continue
|
|
1366
|
+
if validate_reference "$evidence_ref" "$board_path"; then
|
|
1367
|
+
evidence_ref_seen=1; evidence_ref_valid=1
|
|
1368
|
+
evidence_location_status "$evidence_ref"
|
|
1369
|
+
evidence_location_rc=$?
|
|
1370
|
+
if [ "$evidence_location_rc" -eq 1 ]; then
|
|
1371
|
+
echo " ⚠️ $wp_title 的本地证据未落在 pm/archive/<期>/evidence/"
|
|
1372
|
+
add_finding "evidence.outside_archive" "warning" "$wp_title uses a local evidence path outside pm/archive/<period>/evidence/." "$board_path"
|
|
1373
|
+
fi
|
|
1374
|
+
else
|
|
1375
|
+
ref_rc=$?
|
|
1376
|
+
if [ "$ref_rc" -eq 2 ]; then
|
|
1377
|
+
evidence_ref_seen=1; evidence_ref_unverified=1
|
|
1378
|
+
add_finding "sync.unverified" "unverified" "$wp_title uses remote-only evidence that was not checked." "$board_path"
|
|
1379
|
+
elif [ "$ref_rc" -eq 4 ]; then
|
|
1380
|
+
evidence_ref_seen=1; evidence_ref_unverified=1
|
|
1381
|
+
echo " ⚠️ $wp_title 的证据引用经 symlink 到达,未读取:$evidence_ref"
|
|
1382
|
+
add_scan_boundary "symlink" "$(reference_display_path "$evidence_ref")" \
|
|
1383
|
+
"a local reference was not followed"
|
|
1384
|
+
elif [ "$ref_rc" -eq 5 ]; then
|
|
1385
|
+
evidence_ref_seen=1; evidence_ref_unverified=1
|
|
1386
|
+
echo " ⚠️ $wp_title 的证据引用当前权限不足,未读取:$evidence_ref"
|
|
1387
|
+
add_scan_boundary "permission" "$(reference_display_path "$evidence_ref")" \
|
|
1388
|
+
"a local reference was not readable"
|
|
1389
|
+
elif [ "$ref_rc" -eq 1 ]; then
|
|
1390
|
+
evidence_ref_seen=1
|
|
1391
|
+
echo " ⚠️ $wp_title 的证据引用无法解析:$evidence_ref"
|
|
1392
|
+
add_finding "ref.broken" "conflict" "$wp_title evidence reference does not resolve: $evidence_ref" "$board_path"
|
|
1393
|
+
fi
|
|
1394
|
+
fi
|
|
1395
|
+
done < <(extract_backtick_tokens "$evidence_line")
|
|
1396
|
+
if [ "$evidence_ref_seen" -eq 0 ] \
|
|
1397
|
+
|| { [ "$evidence_ref_valid" -eq 0 ] && [ "$evidence_ref_unverified" -eq 0 ]; }; then
|
|
1398
|
+
echo " ⚠️ $wp_title 已完成但证据行没有可机器核验的路径/hash/URL"
|
|
1399
|
+
add_finding "evidence.missing" "conflict" "$wp_title has no machine-verifiable evidence reference." "$board_path"
|
|
1400
|
+
fi
|
|
1401
|
+
done < <(awk '
|
|
1402
|
+
function emit() {
|
|
1403
|
+
if (in_wp && done) {
|
|
1404
|
+
gsub(/\t/, " ", title); gsub(/\t/, " ", evidence)
|
|
1405
|
+
printf "%s\t%d\t%s\n", title, evidence_count, evidence
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
/^### WP-/ { emit(); in_wp=1; done=0; evidence_count=0; evidence=""; title=$0; next }
|
|
1409
|
+
in_wp && /^#{1,3}[[:space:]]/ { emit(); in_wp=0; next }
|
|
1410
|
+
in_wp && /\*\*状态\*\*:/ && /✅完成/ { done=1 }
|
|
1411
|
+
in_wp && /\*\*证据\*\*:/ {
|
|
1412
|
+
evidence_count++
|
|
1413
|
+
evidence = evidence (evidence ? " " : "") $0
|
|
1414
|
+
}
|
|
1415
|
+
END { emit() }
|
|
1416
|
+
' "$board_path")
|
|
1417
|
+
else
|
|
1418
|
+
echo " (当期看板不可读;Gate 与完成证据检查跳过)"
|
|
1419
|
+
fi
|
|
1420
|
+
|
|
1421
|
+
ref_limit="${BUS_REF_MAX:-200}"
|
|
1422
|
+
case "$ref_limit" in ''|*[!0-9]*) ref_limit=200 ;; esac
|
|
1423
|
+
ref_scanned=0; ref_truncated=0
|
|
1424
|
+
for ref_source in pm/NOW.md "$board_path" pm/decisions.md; do
|
|
1425
|
+
if [ -z "$ref_source" ] || [ ! -f "$ref_source" ]; then continue; fi
|
|
1426
|
+
if [ -L "$ref_source" ] \
|
|
1427
|
+
|| path_uses_symlink_component "$ROOT_PHYS/$ref_source"; then
|
|
1428
|
+
echo " ⚠️ $ref_source 经 symlink 到达,作用域引用未扫描"
|
|
1429
|
+
add_scan_boundary "symlink" "$ref_source" \
|
|
1430
|
+
"the scoped-reference source was not read"
|
|
1431
|
+
continue
|
|
1432
|
+
fi
|
|
1433
|
+
if [ ! -r "$ref_source" ]; then
|
|
1434
|
+
echo " ⚠️ $ref_source 当前权限不足,作用域引用未扫描"
|
|
1435
|
+
add_scan_boundary "permission" "$ref_source" \
|
|
1436
|
+
"the scoped-reference source was not readable"
|
|
1437
|
+
continue
|
|
1438
|
+
fi
|
|
1439
|
+
ref_scan_input="$ref_source"
|
|
1440
|
+
if [ "$ref_source" = "pm/decisions.md" ]; then
|
|
1441
|
+
ref_scan_input="$BUS_TMP/decisions.scope"
|
|
1442
|
+
awk '/^\| 20[0-9][0-9]-/ { print; found++; if (found == 3) exit }' "$ref_source" > "$ref_scan_input"
|
|
1443
|
+
fi
|
|
1444
|
+
while IFS= read -r scoped_ref; do
|
|
1445
|
+
[ -n "$scoped_ref" ] || continue
|
|
1446
|
+
ref_rc=0
|
|
1447
|
+
validate_reference "$scoped_ref" "$ref_source" 1 || ref_rc=$?
|
|
1448
|
+
[ "$ref_rc" -ne 3 ] || continue
|
|
1449
|
+
ref_scanned=$((ref_scanned + 1))
|
|
1450
|
+
if [ "$ref_scanned" -gt "$ref_limit" ]; then
|
|
1451
|
+
ref_truncated=1
|
|
1452
|
+
break
|
|
1453
|
+
fi
|
|
1454
|
+
if [ "$ref_rc" -eq 0 ]; then
|
|
1455
|
+
:
|
|
1456
|
+
elif [ "$ref_rc" -eq 2 ]; then
|
|
1457
|
+
add_finding "sync.unverified" "unverified" "A scoped remote reference was not checked." "$ref_source"
|
|
1458
|
+
elif [ "$ref_rc" -eq 4 ]; then
|
|
1459
|
+
echo " ⚠️ $ref_source 中的作用域引用经 symlink 到达,未读取:$scoped_ref"
|
|
1460
|
+
add_scan_boundary "symlink" "$(reference_display_path "$scoped_ref")" \
|
|
1461
|
+
"a local reference was not followed"
|
|
1462
|
+
elif [ "$ref_rc" -eq 5 ]; then
|
|
1463
|
+
echo " ⚠️ $ref_source 中的作用域引用当前权限不足,未读取:$scoped_ref"
|
|
1464
|
+
add_scan_boundary "permission" "$(reference_display_path "$scoped_ref")" \
|
|
1465
|
+
"a local reference was not readable"
|
|
1466
|
+
elif [ "$ref_rc" -eq 1 ]; then
|
|
1467
|
+
echo " ⚠️ $ref_source 中的作用域引用无法解析:$scoped_ref"
|
|
1468
|
+
add_finding "ref.broken" "conflict" "Scoped reference does not resolve: $scoped_ref" "$ref_source"
|
|
1469
|
+
fi
|
|
1470
|
+
done < <(
|
|
1471
|
+
# shellcheck disable=SC2016 # backticks are literal Markdown delimiters
|
|
1472
|
+
grep -oE '\]\([^)]*\)|`[^`]*\.md([^`]*)?`' "$ref_scan_input" 2>/dev/null \
|
|
1473
|
+
| sed -E 's/^\]\(//; s/\)$//; s/^`//; s/`$//' \
|
|
1474
|
+
| sed -E 's/[[:space:]]+"[^"]*"$//' || true
|
|
1475
|
+
)
|
|
1476
|
+
[ "$ref_truncated" -eq 0 ] || break
|
|
1477
|
+
done
|
|
1478
|
+
if [ "$ref_truncated" -eq 1 ]; then
|
|
1479
|
+
echo " ⚠️ 作用域引用扫描达到 BUS_REF_MAX=$ref_limit,剩余范围未核验"
|
|
1480
|
+
add_scan_boundary "limit" "pm/" \
|
|
1481
|
+
"the scoped reference scan stopped at BUS_REF_MAX=$ref_limit"
|
|
1482
|
+
fi
|
|
1483
|
+
[ -n "$board_path" ] && echo " · 已检查 canonical Gate、完成证据与 $ref_scanned 条作用域引用"
|
|
1484
|
+
echo ""
|
|
1485
|
+
|
|
1486
|
+
# 2.7) 工程层验证能力(证据分级 L3「自动化测试」的前提是项目有能跑的测试;测试跑不动是比 NOW 长肥更重的腐烂)
|
|
1487
|
+
echo "── 工程层验证能力 ──"
|
|
1488
|
+
if [ -f "$SDIR/verify-status.sh" ]; then
|
|
1489
|
+
if ! bash "$SDIR/verify-status.sh" 2>/dev/null | sed 's/^/ /'; then
|
|
1490
|
+
echo " (verify-status.sh 执行失败)"
|
|
1491
|
+
add_finding "sync.unverified" "unverified" "verify-status human report failed." "$SDIR/verify-status.sh"
|
|
1492
|
+
fi
|
|
1493
|
+
verify_machine="$BUS_TMP/verify.machine"
|
|
1494
|
+
if bash "$SDIR/verify-status.sh" --format=machine > "$verify_machine" 2>/dev/null; then
|
|
1495
|
+
verify_machine_unknown=0
|
|
1496
|
+
while IFS=$'\t' read -r record code level message path; do
|
|
1497
|
+
if [ "$record" != "FINDING" ]; then
|
|
1498
|
+
[ -z "$record$code$level$message$path" ] || verify_machine_unknown=1
|
|
1499
|
+
continue
|
|
1500
|
+
fi
|
|
1501
|
+
case "$code:$level" in
|
|
1502
|
+
sync.l3_stale:warning|sync.l3_unconfigured:unverified)
|
|
1503
|
+
add_finding "$code" "$level" "$message" "$path"
|
|
1504
|
+
;;
|
|
1505
|
+
*)
|
|
1506
|
+
add_finding "sync.unverified" "unverified" "verify-status returned an unknown machine finding." "$SDIR/verify-status.sh"
|
|
1507
|
+
;;
|
|
1508
|
+
esac
|
|
1509
|
+
done < "$verify_machine"
|
|
1510
|
+
if [ "$verify_machine_unknown" -eq 1 ]; then
|
|
1511
|
+
add_finding "sync.unverified" "unverified" "verify-status did not return the expected machine protocol." "$SDIR/verify-status.sh"
|
|
1512
|
+
fi
|
|
1513
|
+
else
|
|
1514
|
+
add_finding "sync.unverified" "unverified" "verify-status machine report failed." "$SDIR/verify-status.sh"
|
|
1515
|
+
fi
|
|
1516
|
+
else
|
|
1517
|
+
echo " ⚠️ 未配置 $SDIR/verify-status.sh —— 项目验证能力未知,L3 级证据无从谈起(接入:每行输出「套件名 测试命令 上次全绿时间」)"
|
|
1518
|
+
add_finding "sync.l3_unconfigured" "unverified" "No verify-status adapter is installed." "$SDIR/verify-status.sh"
|
|
1519
|
+
fi
|
|
1520
|
+
echo ""
|
|
1521
|
+
|
|
1522
|
+
# 3) 契约快照版本
|
|
1523
|
+
echo "── 契约 (contracts/PROTOCOL.md) ──"
|
|
1524
|
+
if [ "${multirepo_map_unsafe:-1}" -eq 0 ]; then
|
|
1525
|
+
grep -m1 -E "契约快照对应版本" contracts/PROTOCOL.md 2>/dev/null || echo " (无)"
|
|
1526
|
+
else
|
|
1527
|
+
echo " (契约入口缺失或未安全读取)"
|
|
1528
|
+
fi
|
|
1529
|
+
echo ""
|
|
1530
|
+
|
|
1531
|
+
# 3.5) 可选 standards / ADR。缺失即跳过;存在才检查结构与明确状态。
|
|
1532
|
+
echo "── 可选规范 / ADR ──"
|
|
1533
|
+
standard_count=0
|
|
1534
|
+
stack_standard_status=""
|
|
1535
|
+
stack_standard_valid=0
|
|
1536
|
+
for standard_path in standards/STACK.md standards/CODE.md standards/REVIEW.md standards/DESIGN.md; do
|
|
1537
|
+
[ -f "$standard_path" ] || continue
|
|
1538
|
+
standard_count=$((standard_count + 1))
|
|
1539
|
+
if [ -L "$standard_path" ] \
|
|
1540
|
+
|| path_uses_symlink_component "$ROOT_PHYS/$standard_path"; then
|
|
1541
|
+
echo " ⚠️ $standard_path 经 symlink 到达,可选规范未读取"
|
|
1542
|
+
add_scan_boundary "symlink" "$standard_path" \
|
|
1543
|
+
"an optional standard was not read"
|
|
1544
|
+
continue
|
|
1545
|
+
fi
|
|
1546
|
+
if [ ! -r "$standard_path" ]; then
|
|
1547
|
+
echo " ⚠️ $standard_path 当前权限不足,可选规范未读取"
|
|
1548
|
+
add_scan_boundary "permission" "$standard_path" \
|
|
1549
|
+
"an optional standard was not readable"
|
|
1550
|
+
continue
|
|
1551
|
+
fi
|
|
1552
|
+
standard_name="$(basename "$standard_path" .md)"
|
|
1553
|
+
standard_invalid=0
|
|
1554
|
+
|
|
1555
|
+
optional_count="$(grep -cE '^> \*\*Optional\*\*:' "$standard_path" 2>/dev/null || true)"
|
|
1556
|
+
boundary_count="$(grep -cE '^> \*\*AI write boundary\*\*:' "$standard_path" 2>/dev/null || true)"
|
|
1557
|
+
status_lines="$(grep -E '^> \*\*Status\*\*:' "$standard_path" 2>/dev/null || true)"
|
|
1558
|
+
status_count="$(printf '%s\n' "$status_lines" | awk 'NF { n++ } END { print n+0 }')"
|
|
1559
|
+
standard_status=""
|
|
1560
|
+
if [ "$optional_count" -ne 1 ] || [ "$boundary_count" -ne 1 ] || [ "$status_count" -ne 1 ]; then
|
|
1561
|
+
standard_invalid=1
|
|
1562
|
+
elif ! printf '%s\n' "$status_lines" | grep -Eq '^> \*\*Status\*\*: (Draft|Confirmed)[[:space:]]*$'; then
|
|
1563
|
+
standard_invalid=1
|
|
1564
|
+
else
|
|
1565
|
+
standard_status="$(printf '%s\n' "$status_lines" | sed -E 's/^> \*\*Status\*\*: (Draft|Confirmed).*/\1/')"
|
|
1566
|
+
fi
|
|
1567
|
+
|
|
1568
|
+
# shellcheck disable=SC2016 # backticks are literal Markdown delimiters
|
|
1569
|
+
rule_tokens="$(grep -E '^[[:space:]]*- `[A-Z]+-(MUST|SHOULD|MAY)-' "$standard_path" 2>/dev/null \
|
|
1570
|
+
| grep -oE '`[A-Z]+-(MUST|SHOULD|MAY)-[^`]+`' | tr -d '`' || true)"
|
|
1571
|
+
valid_rules=0
|
|
1572
|
+
invalid_rule=0
|
|
1573
|
+
while IFS= read -r rule_id; do
|
|
1574
|
+
[ -n "$rule_id" ] || continue
|
|
1575
|
+
if printf '%s\n' "$rule_id" | grep -Eq "^${standard_name}-(MUST|SHOULD|MAY)-[0-9]{3}$"; then
|
|
1576
|
+
valid_rules=$((valid_rules + 1))
|
|
1577
|
+
else
|
|
1578
|
+
invalid_rule=1
|
|
1579
|
+
fi
|
|
1580
|
+
done <<< "$rule_tokens"
|
|
1581
|
+
[ "$valid_rules" -gt 0 ] || invalid_rule=1
|
|
1582
|
+
duplicate_rule="$(printf '%s\n' "$rule_tokens" | awk 'NF' | sort | uniq -d | head -1)"
|
|
1583
|
+
[ -z "$duplicate_rule" ] || invalid_rule=1
|
|
1584
|
+
[ "$invalid_rule" -eq 0 ] || standard_invalid=1
|
|
1585
|
+
|
|
1586
|
+
if [ "$standard_status" = "Confirmed" ] && grep -Eq '<[^>]+>' "$standard_path"; then
|
|
1587
|
+
standard_invalid=1
|
|
1588
|
+
fi
|
|
1589
|
+
|
|
1590
|
+
while IFS= read -r standard_ref; do
|
|
1591
|
+
[ -n "$standard_ref" ] || continue
|
|
1592
|
+
standard_ref_rc=0
|
|
1593
|
+
validate_reference "$standard_ref" "$standard_path" || standard_ref_rc=$?
|
|
1594
|
+
[ "$standard_ref_rc" -ne 1 ] || standard_invalid=1
|
|
1595
|
+
if [ "$standard_ref_rc" -eq 4 ]; then
|
|
1596
|
+
add_scan_boundary "symlink" "$(reference_display_path "$standard_ref")" \
|
|
1597
|
+
"a local reference was not followed"
|
|
1598
|
+
elif [ "$standard_ref_rc" -eq 5 ]; then
|
|
1599
|
+
add_scan_boundary "permission" "$(reference_display_path "$standard_ref")" \
|
|
1600
|
+
"a local reference was not readable"
|
|
1601
|
+
fi
|
|
1602
|
+
done < <(
|
|
1603
|
+
# shellcheck disable=SC2016 # backticks are literal Markdown delimiters
|
|
1604
|
+
grep -hoE '`[^`]+`' "$standard_path" 2>/dev/null | sed 's/^`//; s/`$//' || true
|
|
1605
|
+
)
|
|
1606
|
+
|
|
1607
|
+
if [ "$standard_invalid" -eq 1 ]; then
|
|
1608
|
+
echo " ❌ $standard_path 结构、Rule ID、已确认占位符或本地引用无效"
|
|
1609
|
+
add_finding "standards.invalid" "error" "Optional standard structure, Rule IDs, confirmed placeholders, or local references are invalid." "$standard_path"
|
|
1610
|
+
elif [ "$standard_status" = "Draft" ]; then
|
|
1611
|
+
echo " ⚠️ $standard_path 仍是 Draft(待确认)"
|
|
1612
|
+
add_finding "standards.unconfirmed" "unverified" "Optional standard is present but still marked Draft." "$standard_path"
|
|
1613
|
+
else
|
|
1614
|
+
echo " ✅ $standard_path 结构与 Confirmed 状态可解析"
|
|
1615
|
+
fi
|
|
1616
|
+
|
|
1617
|
+
if [ "$standard_name" = "STACK" ]; then
|
|
1618
|
+
stack_standard_status="$standard_status"
|
|
1619
|
+
[ "$standard_invalid" -eq 1 ] || stack_standard_valid=1
|
|
1620
|
+
fi
|
|
1621
|
+
done
|
|
1622
|
+
[ "$standard_count" -gt 0 ] || echo " · standards 未启用(缺失即跳过,零 finding)"
|
|
1623
|
+
[ "$stack_standard_valid" -eq 0 ] || [ "$stack_standard_status" != "Confirmed" ] || check_stack_drift
|
|
1624
|
+
|
|
1625
|
+
adr_count=0
|
|
1626
|
+
for adr_path in pm/adr/ADR-[0-9][0-9][0-9][0-9]-*.md; do
|
|
1627
|
+
[ -f "$adr_path" ] || continue
|
|
1628
|
+
adr_count=$((adr_count + 1))
|
|
1629
|
+
if [ -L "$adr_path" ] \
|
|
1630
|
+
|| path_uses_symlink_component "$ROOT_PHYS/$adr_path"; then
|
|
1631
|
+
echo " ⚠️ $adr_path 经 symlink 到达,ADR 未读取"
|
|
1632
|
+
add_scan_boundary "symlink" "$adr_path" "an ADR was not read"
|
|
1633
|
+
continue
|
|
1634
|
+
fi
|
|
1635
|
+
if [ ! -r "$adr_path" ]; then
|
|
1636
|
+
echo " ⚠️ $adr_path 当前权限不足,ADR 未读取"
|
|
1637
|
+
add_scan_boundary "permission" "$adr_path" "an ADR was not readable"
|
|
1638
|
+
continue
|
|
1639
|
+
fi
|
|
1640
|
+
adr_status_lines="$(grep -E '^- Status:' "$adr_path" 2>/dev/null || true)"
|
|
1641
|
+
adr_status_count="$(printf '%s\n' "$adr_status_lines" | awk 'NF { n++ } END { print n+0 }')"
|
|
1642
|
+
if [ "$adr_status_count" -ne 1 ] || ! printf '%s\n' "$adr_status_lines" | grep -Eq '^- Status: (Proposed|Accepted|Rejected|Superseded)[[:space:]]*$'; then
|
|
1643
|
+
echo " ❌ $adr_path 缺少唯一合法 Status"
|
|
1644
|
+
add_finding "adr.status_invalid" "error" "ADR must contain exactly one legal Status line." "$adr_path"
|
|
1645
|
+
fi
|
|
1646
|
+
done
|
|
1647
|
+
|
|
1648
|
+
for adr_path in pm/adr/ADR-[0-9][0-9][0-9][0-9]-*.md; do
|
|
1649
|
+
[ -f "$adr_path" ] || continue
|
|
1650
|
+
[ ! -L "$adr_path" ] || continue
|
|
1651
|
+
path_uses_symlink_component "$ROOT_PHYS/$adr_path" && continue
|
|
1652
|
+
[ -r "$adr_path" ] || continue
|
|
1653
|
+
initial_status_lines="$(grep -E '^- Status:' "$adr_path" 2>/dev/null || true)"
|
|
1654
|
+
initial_status_count="$(printf '%s\n' "$initial_status_lines" | awk 'NF { n++ } END { print n+0 }')"
|
|
1655
|
+
if [ "$initial_status_count" -ne 1 ] || ! printf '%s\n' "$initial_status_lines" | grep -Eq '^- Status: (Proposed|Accepted|Rejected|Superseded)[[:space:]]*$'; then
|
|
1656
|
+
continue
|
|
1657
|
+
fi
|
|
1658
|
+
chain_current="$adr_path"
|
|
1659
|
+
chain_seen="|$adr_path|"
|
|
1660
|
+
chain_steps=0
|
|
1661
|
+
chain_broken=0
|
|
1662
|
+
while [ "$chain_steps" -lt 100 ]; do
|
|
1663
|
+
chain_status_lines="$(grep -E '^- Status:' "$chain_current" 2>/dev/null || true)"
|
|
1664
|
+
chain_status_count="$(printf '%s\n' "$chain_status_lines" | awk 'NF { n++ } END { print n+0 }')"
|
|
1665
|
+
if [ "$chain_status_count" -ne 1 ] || ! printf '%s\n' "$chain_status_lines" | grep -Eq '^- Status: (Proposed|Accepted|Rejected|Superseded)[[:space:]]*$'; then
|
|
1666
|
+
chain_broken=1
|
|
1667
|
+
break
|
|
1668
|
+
fi
|
|
1669
|
+
chain_status="$(printf '%s\n' "$chain_status_lines" | sed -E 's/^- Status: ([A-Za-z]+).*/\1/')"
|
|
1670
|
+
[ "$chain_status" = "Superseded" ] || break
|
|
1671
|
+
|
|
1672
|
+
chain_target_lines="$(grep -E '^- Superseded by:' "$chain_current" 2>/dev/null || true)"
|
|
1673
|
+
chain_target_count="$(printf '%s\n' "$chain_target_lines" | awk 'NF { n++ } END { print n+0 }')"
|
|
1674
|
+
# shellcheck disable=SC2016 # backticks are literal Markdown delimiters
|
|
1675
|
+
if [ "$chain_target_count" -ne 1 ] || ! printf '%s\n' "$chain_target_lines" | grep -Eq '^- Superseded by: `pm/adr/ADR-[0-9]{4}-[^`]+\.md`[[:space:]]*$'; then
|
|
1676
|
+
chain_broken=1
|
|
1677
|
+
break
|
|
1678
|
+
fi
|
|
1679
|
+
# shellcheck disable=SC2016 # backticks are literal Markdown delimiters
|
|
1680
|
+
chain_target="$(printf '%s\n' "$chain_target_lines" | sed -E 's/^- Superseded by: `([^`]*)`.*/\1/')"
|
|
1681
|
+
case "$chain_seen" in
|
|
1682
|
+
*"|$chain_target|"*) chain_broken=1; break ;;
|
|
1683
|
+
esac
|
|
1684
|
+
if [ ! -f "$chain_target" ]; then
|
|
1685
|
+
chain_broken=1
|
|
1686
|
+
break
|
|
1687
|
+
fi
|
|
1688
|
+
chain_seen="${chain_seen}${chain_target}|"
|
|
1689
|
+
chain_current="$chain_target"
|
|
1690
|
+
chain_steps=$((chain_steps + 1))
|
|
1691
|
+
done
|
|
1692
|
+
[ "$chain_steps" -lt 100 ] || chain_broken=1
|
|
1693
|
+
if [ "$chain_broken" -eq 1 ]; then
|
|
1694
|
+
echo " ❌ $adr_path 的 Superseded 链缺失、成环或指向非法 ADR"
|
|
1695
|
+
add_finding "adr.superseded_broken" "conflict" "ADR supersession chain is missing, cyclic, self-referential, or targets an invalid ADR." "$adr_path"
|
|
1696
|
+
fi
|
|
1697
|
+
done
|
|
1698
|
+
[ "$adr_count" -gt 0 ] || echo " · ADR 未启用(缺失即跳过,零 finding)"
|
|
1699
|
+
echo ""
|
|
1700
|
+
|
|
1701
|
+
# 4) 最近拍板 (pm/decisions.md, 规则⑨ 决策单点)
|
|
1702
|
+
echo "── 最近拍板 (pm/decisions.md, 最新 3 条) ──"
|
|
1703
|
+
if [ -f pm/decisions.md ] && [ ! -L pm/decisions.md ] && [ -r pm/decisions.md ] \
|
|
1704
|
+
&& ! path_uses_symlink_component "$ROOT_PHYS/pm/decisions.md"; then
|
|
1705
|
+
if command -v perl >/dev/null 2>&1; then
|
|
1706
|
+
# perl -CSD -Mutf8 按「字符」截断,避免按字节截断把中文/省略号切成乱码
|
|
1707
|
+
grep -E '^\| 20[0-9]{2}-' pm/decisions.md | head -3 | perl -CSD -Mutf8 -ne 'chomp; $_ = substr($_,0,110)."…" if length() > 110; print " $_\n"'
|
|
1708
|
+
else
|
|
1709
|
+
grep -E '^\| 20[0-9]{2}-' pm/decisions.md | head -3 | sed 's/^/ /' # 无 perl:降级为不截断(截字节会把中文切成乱码)
|
|
1710
|
+
fi
|
|
1711
|
+
else
|
|
1712
|
+
echo " (pm/decisions.md 缺失或未安全读取)"
|
|
1713
|
+
fi
|
|
1714
|
+
echo ""
|
|
1715
|
+
|
|
1716
|
+
# 5) 各域状态文件最近更新
|
|
1717
|
+
echo "── 各域状态 (pm/status/) ──"
|
|
1718
|
+
if [ -d pm/status ]; then
|
|
1719
|
+
for f in pm/status/*.md; do
|
|
1720
|
+
{ [ -e "$f" ] || [ -L "$f" ]; } || continue
|
|
1721
|
+
base="$(basename "$f")"; [ "$base" = "README.md" ] && continue
|
|
1722
|
+
[ ! -L "$f" ] || continue
|
|
1723
|
+
path_uses_symlink_component "$ROOT_PHYS/$f" && continue
|
|
1724
|
+
[ -r "$f" ] || continue
|
|
1725
|
+
line=$(git log -1 --format="%h %ad %s" --date=short -- "$f" 2>/dev/null)
|
|
1726
|
+
printf " %-10s %s\n" "${base%.md}" "${line:-(未提交/未跟踪)}"
|
|
1727
|
+
done
|
|
1728
|
+
else
|
|
1729
|
+
echo " (无 pm/status/)"
|
|
1730
|
+
fi
|
|
1731
|
+
echo ""
|
|
1732
|
+
|
|
1733
|
+
# 5.5) 幽灵 hash 核验(lessons 第 11 条:status 声明的 commit 必须真实存在;「写了状态」≠「已提交」)
|
|
1734
|
+
echo "── 幽灵 hash 核验 (pm/status/) ──"
|
|
1735
|
+
if [ -d pm/status ]; then
|
|
1736
|
+
# 只认**反引号内**的 token(status 模板约定 hash 写成 `hash`,把约定变成解析规则,治误报):
|
|
1737
|
+
# 先从反引号串里**抠掉** URL 与 sha256: digest(不整段丢弃——同段混有链接和真 hash 时保住 hash);
|
|
1738
|
+
# token 须同含字母与数字(干掉 defaced 这类纯字母英文词)。
|
|
1739
|
+
# 代价:纯字母/纯数字的 7 位真 hash(各约 0.1%/3.7%)会被静默跳过——良性漏检,换不误拦。
|
|
1740
|
+
HASHES=$(for f in pm/status/*.md; do
|
|
1741
|
+
{ [ -e "$f" ] || [ -L "$f" ]; } || continue
|
|
1742
|
+
[ "$(basename "$f")" = "README.md" ] && continue
|
|
1743
|
+
[ ! -L "$f" ] || continue
|
|
1744
|
+
path_uses_symlink_component "$ROOT_PHYS/$f" && continue
|
|
1745
|
+
[ -r "$f" ] || continue
|
|
1746
|
+
# shellcheck disable=SC2016 # backticks are literal Markdown delimiters, not shell expansion
|
|
1747
|
+
grep -hoE '`[^`]*`' "$f" 2>/dev/null
|
|
1748
|
+
done | sed -E 's#[a-zA-Z][a-zA-Z0-9+.-]*://[^` ]*##g; s#sha256:[0-9a-fA-F]*##g' \
|
|
1749
|
+
| grep -hoE '\b[0-9a-f]{7,40}\b' | grep '[a-f]' | grep '[0-9]' | sort -u)
|
|
1750
|
+
ghost=0; total=0
|
|
1751
|
+
for h in $HASHES; do
|
|
1752
|
+
total=$((total+1)); found=0
|
|
1753
|
+
for r in . "${SUBREPOS[@]:-}"; do
|
|
1754
|
+
if [ -z "$r" ] || [ ! -d "$r" ]; then
|
|
1755
|
+
continue
|
|
1756
|
+
fi
|
|
1757
|
+
if [ "$r" != "." ]; then
|
|
1758
|
+
path_uses_symlink_component "$ROOT_PHYS/${r#./}" && continue
|
|
1759
|
+
if [ ! -r "$r" ] || [ ! -x "$r" ]; then continue; fi
|
|
1760
|
+
fi
|
|
1761
|
+
git -C "$r" cat-file -t "$h" >/dev/null 2>&1 && { found=1; break; }
|
|
1762
|
+
done
|
|
1763
|
+
[ "$found" = 1 ] || {
|
|
1764
|
+
echo " ⚠️ $h —— meta 仓与全部子仓查无此号(幽灵 hash:臆造/被重置/躺工作树没提交;若刚换机器,先 git pull 复核)"
|
|
1765
|
+
add_finding "sync.ghost_hash" "conflict" "Status references an unresolved Git hash: $h" "pm/status/"
|
|
1766
|
+
ghost=1
|
|
1767
|
+
}
|
|
1768
|
+
done
|
|
1769
|
+
if [ "$ghost" = 0 ] && [ "$total" = 0 ]; then echo " (status 里暂无 hash)"
|
|
1770
|
+
elif [ "$ghost" = 0 ]; then echo " ✅ $total 个 hash 全部可解析"
|
|
1771
|
+
fi
|
|
1772
|
+
else
|
|
1773
|
+
echo " (无 pm/status/)"
|
|
1774
|
+
fi
|
|
1775
|
+
echo ""
|
|
1776
|
+
|
|
1777
|
+
# 6) 在途变更提案
|
|
1778
|
+
echo "── 在途变更提案 (pm/changes/) ──"
|
|
1779
|
+
found=0
|
|
1780
|
+
for f in pm/changes/*.md; do
|
|
1781
|
+
[ -e "$f" ] || continue
|
|
1782
|
+
base="$(basename "$f")"; [ "$base" = "README.md" ] && continue
|
|
1783
|
+
echo " - $base"; found=1
|
|
1784
|
+
done
|
|
1785
|
+
[ "$found" = 0 ] && echo " (无)"
|
|
1786
|
+
echo ""
|
|
1787
|
+
|
|
1788
|
+
# 7) 线上实况(规则⑨:线上版本唯一查询口;文档里的版本号一律不作准)
|
|
1789
|
+
echo "── 线上实况 ──"
|
|
1790
|
+
if [ "${BUS_CHECK_NO_LIVE:-0}" = "1" ]; then
|
|
1791
|
+
echo " (BUS_CHECK_NO_LIVE=1 跳过;线上版本以重跑本脚本为准)"
|
|
1792
|
+
add_finding "sync.unverified" "unverified" "Live status was skipped by BUS_CHECK_NO_LIVE=1." "$SDIR/live-status.sh"
|
|
1793
|
+
elif [ -f "$SDIR/live-status.sh" ]; then
|
|
1794
|
+
if ! bash "$SDIR/live-status.sh" 2>/dev/null | sed 's/^/ /'; then
|
|
1795
|
+
echo " (live-status.sh 执行失败 —— 检查部署平台 CLI 凭据/网络)"
|
|
1796
|
+
add_finding "sync.unverified" "unverified" "Live-status adapter failed; production state is not verified." "$SDIR/live-status.sh"
|
|
1797
|
+
fi
|
|
1798
|
+
else
|
|
1799
|
+
echo " (未配置 $SDIR/live-status.sh —— 接入部署平台 CLI 后,每行输出「服务名 版本」即可;在此之前别引用任何文档里的\"当前版本\")"
|
|
1800
|
+
add_finding "sync.unverified" "unverified" "No live-status adapter is configured." "$SDIR/live-status.sh"
|
|
1801
|
+
fi
|
|
1802
|
+
echo ""
|
|
1803
|
+
|
|
1804
|
+
# 7.5) 生产漂移检测(平台侧 env/secret 指纹 + 镜像tag↔git vs scripts/bus-baseline.json)
|
|
1805
|
+
echo "── 生产漂移检测 ──"
|
|
1806
|
+
if [ -f "$SDIR/drift-check.sh" ]; then
|
|
1807
|
+
drift_output="$(bash "$SDIR/drift-check.sh" $DRIFT_MODE 2>&1)"; drc=$?
|
|
1808
|
+
printf '%s\n' "$drift_output"
|
|
1809
|
+
if [ "$drc" -eq 2 ]; then
|
|
1810
|
+
add_finding "sync.production_drift" "conflict" "The configured drift adapter confirmed production drift." "$SDIR/drift-check.sh"
|
|
1811
|
+
elif [ "$drc" -ne 0 ]; then
|
|
1812
|
+
add_finding "sync.unverified" "unverified" "The configured drift adapter failed; production drift is not verified." "$SDIR/drift-check.sh"
|
|
1813
|
+
elif printf '%s\n' "$drift_output" | grep -Eq '未配置|跳过|无法|无基线|不可判定'; then
|
|
1814
|
+
add_finding "sync.unverified" "unverified" "The drift adapter reported incomplete coverage." "$SDIR/drift-check.sh"
|
|
1815
|
+
fi
|
|
1816
|
+
else
|
|
1817
|
+
echo " (未配置 $SDIR/drift-check.sh —— 拷模板 + 接 live-config.sh 后,可检出「配置被改没部署 / 线上镜像 git 里找不到」类漂移)"
|
|
1818
|
+
add_finding "sync.unverified" "unverified" "No production-drift adapter is configured." "$SDIR/drift-check.sh"
|
|
1819
|
+
fi
|
|
1820
|
+
echo ""
|
|
1821
|
+
|
|
1822
|
+
# 8) 最近 5 个 meta-repo commit
|
|
1823
|
+
echo "── 最近提交 (meta) ──"
|
|
1824
|
+
git log --oneline -5 2>/dev/null
|
|
1825
|
+
echo ""
|
|
1826
|
+
echo "▸ 开工四步: ① git pull(含子仓) ② 读 NOW → 当期看板 → 契约 → 最近拍板 ③ 确认你域要动的不 stale ④ 不可逆动作(部署/契约/migration)前重跑本脚本"
|
|
1827
|
+
finalize_findings
|
|
1828
|
+
blocked="$(awk -F '\t' '$3=="conflict" || $3=="error" { blocked=1 } END { print blocked+0 }' "$FINDINGS_SORTED")"
|
|
1829
|
+
echo ""
|
|
1830
|
+
echo "── 结构化检查摘要 ──"
|
|
1831
|
+
if [ ! -s "$FINDINGS_SORTED" ]; then
|
|
1832
|
+
echo " (无 findings;仅代表已覆盖的仓库事实)"
|
|
1833
|
+
else
|
|
1834
|
+
while IFS=$'\t' read -r _rank code level message path; do
|
|
1835
|
+
printf ' [%s] %s%s — %s\n' "$level" "$code" "${path:+ @ $path}" "$message"
|
|
1836
|
+
done < "$FINDINGS_SORTED"
|
|
1837
|
+
fi
|
|
1838
|
+
[ "$STRICT" = 0 ] || [ "$blocked" = 0 ] || echo "⛔ strict 未过:存在 conflict / error finding"
|
|
1839
|
+
echo "════════════════════════════════════════════"
|
|
1840
|
+
|
|
1841
|
+
if [ "$FORMAT" = "json" ]; then
|
|
1842
|
+
if ! render_json_report >&3; then
|
|
1843
|
+
echo "bus-check: failed to render JSON report" >&2
|
|
1844
|
+
exit 2
|
|
1845
|
+
fi
|
|
1846
|
+
fi
|
|
1847
|
+
if [ "$STRICT" = 1 ] && [ "$blocked" -eq 1 ]; then
|
|
1848
|
+
exit 1
|
|
1849
|
+
fi
|
|
1850
|
+
exit 0
|