@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +296 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +288 -0
  4. package/README.md +283 -4
  5. package/SKILL.md +303 -0
  6. package/bin/buildbeat.js +5 -0
  7. package/bin/solobaton.js +6 -0
  8. package/docs/CAPABILITY-MATRIX.md +50 -0
  9. package/docs/CHECKS.md +326 -0
  10. package/docs/CLI-PILOT-2026-08-23.md +25 -0
  11. package/docs/CLI-STRATEGY-2026-08.md +55 -0
  12. package/docs/CLI.md +233 -0
  13. package/docs/EXECUTION-PLAN.md +487 -0
  14. package/docs/LEGACY-V1.16-MIGRATION.md +54 -0
  15. package/docs/PHASE1-PILOT-2026-08-24.md +32 -0
  16. package/docs/PHASE2-BUILDBEAT-PILOT-2026-08-25.md +75 -0
  17. package/docs/PHASE2-PILOT-2026-08-25.md +88 -0
  18. package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +42 -0
  19. package/docs/PHASE4-STABILITY-AUDIT-2026-08-25.md +33 -0
  20. package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +52 -0
  21. package/docs/RELEASING.md +117 -0
  22. package/docs/ROADMAP.md +873 -0
  23. package/example/.buildbeat/manifest.json +45 -0
  24. package/example/AGENTS.md +19 -0
  25. package/example/ARCHITECTURE.md +39 -0
  26. package/example/BUILDBEAT.md +17 -0
  27. package/example/CLAUDE.md +7 -0
  28. package/example/README.md +53 -0
  29. package/example/contracts/PROTOCOL.md +38 -0
  30. package/example/pm/NOW.md +22 -0
  31. package/example/pm/adr/ADR-0001-local-first-sqlite.md +25 -0
  32. package/example/pm/adr/README.md +7 -0
  33. package/example/pm/archive//344/270/200/346/234/237/evidence/gate1.md +5 -0
  34. package/example/pm/archive//344/270/200/346/234/237/evidence/gate2.md +5 -0
  35. package/example/pm/archive//344/270/200/346/234/237/evidence/gate3.md +5 -0
  36. package/example/pm/archive//344/270/200/346/234/237/evidence/gate4.md +5 -0
  37. package/example/pm/archive//344/270/200/346/234/237/evidence/implementation.md +5 -0
  38. package/example/pm/decisions.md +20 -0
  39. package/example/pm/status//344/272/247/345/223/201.md +20 -0
  40. package/example/pm/status//345/205/250/346/240/210.md +15 -0
  41. package/example/pm/status//346/265/213/350/257/225.md +15 -0
  42. package/example/pm//344/270/200/346/234/237-/347/234/213/346/235/277.md +97 -0
  43. package/example/standards/CODE.md +18 -0
  44. package/example/standards/DESIGN.md +34 -0
  45. package/example/standards/REVIEW.md +16 -0
  46. package/example/standards/STACK.md +31 -0
  47. package/lessons.md +119 -0
  48. package/package.json +48 -7
  49. package/src/cli.js +323 -0
  50. package/src/constants.js +199 -0
  51. package/src/doctor.js +267 -0
  52. package/src/planner.js +251 -0
  53. package/src/project.js +839 -0
  54. package/src/upgrader.js +1249 -0
  55. package/src/writer.js +534 -0
  56. package/templates/.claude/agents/reviewer.md +62 -0
  57. package/templates/AGENTS.md +64 -0
  58. package/templates/ARCHITECTURE.md +50 -0
  59. package/templates/BUILDBEAT.md +13 -0
  60. package/templates/CLAUDE.md +7 -0
  61. package/templates/contracts/PROTOCOL.md +32 -0
  62. package/templates/gitignore.template +19 -0
  63. package/templates/pm/NOW.md +26 -0
  64. package/templates/pm/adr/ADR-0000-template.md +25 -0
  65. package/templates/pm/adr/README.md +15 -0
  66. package/templates/pm/changes/README.md +44 -0
  67. package/templates/pm/decisions.md +12 -0
  68. package/templates/pm/status/README.md +32 -0
  69. package/templates/pm//345/275/223/346/234/237/347/234/213/346/235/277.md +62 -0
  70. package/templates/scripts/bus-check.sh +1850 -0
  71. package/templates/scripts/design-preview.sh +44 -0
  72. package/templates/scripts/drift-check.sh +112 -0
  73. package/templates/scripts/pre-commit.sh +74 -0
  74. package/templates/scripts/verify-status.sh +105 -0
  75. package/templates/standards/CODE.md +23 -0
  76. package/templates/standards/DESIGN.md +36 -0
  77. package/templates/standards/REVIEW.md +20 -0
  78. package/templates/standards/STACK.md +37 -0
  79. 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