Repository navigation
Expand file tree
/
Copy pathMakefile
More file actions
823 lines (752 loc) · 47.5 KB
/
Copy pathMakefile
File metadata and controls
823 lines (752 loc) · 47.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
PROJECT = imboy
PROJECT_DESCRIPTION = 基于Erlang的Cowboy、Flutter和PostgreSQL计算的一款聊天软件。
PROJECT_VERSION = $(shell cat VERSION)
export PROJECT_VERSION
# --- 环境配置选择(IMBOYENV 驱动) ---
# IMBOYENV=dev → relxdev.config + sys.dev.config
# IMBOYENV=pro → relxpro.config + sys.pro.config
# 不存在对应文件时回退默认 relx.config / sys.config
# 环境变量 IMBOY_* 仍由 imboy_env.erl 在运行时覆盖(优先级更高)
_RELX_SRC := relx.config
_SYS_RUNTIME_SRC := config/sys.config
# sys.config 本体不入仓(alpha.42 起),裸 make(无 IMBOYENV)回退 example 模板
# 兜底——与 Dockerfile 构建期 cp example 的口径一致;否则每次裸 make 都打印
# cp: cannot stat 噪音(非致命但干扰判断)。
ifeq ($(wildcard config/sys.config),)
_SYS_RUNTIME_SRC := config/sys.config.example
endif
ifneq ($(IMBOYENV),)
ifneq ($(wildcard relx$(IMBOYENV).config),)
_RELX_SRC := relx$(IMBOYENV).config
endif
ifneq ($(wildcard config/sys.$(IMBOYENV).config),)
_SYS_RUNTIME_SRC := config/sys.$(IMBOYENV).config
endif
endif
RELX_CONFIG = $(CURDIR)/$(_RELX_SRC)
$(shell mkdir -p config && cp $(_SYS_RUNTIME_SRC) config/sys.runtime.config)
# --- LLM 密钥注入(补齐 `IMBOYENV=local make run` 这条路径) ---
# 背景:LLM 密钥不写进 config,而是以 {env, <<"VAR">>} 占位,由
# imboy_llm_registry:resolve_env/1 在**运行时**经 os:getenv/1 取值
# (见 src/lib/imboy_llm_registry.erl)。所以密钥必须在**节点进程的环境里**。
# scripts/start_node.sh 会加载 .env/.env.local,但 erlang.mk 的 `run` 目标
# (`run:: all` → relx console)**不经过那个脚本** —— 于是按 AGENTS.md 里最常写的
# `IMBOYENV=local make run` 起服务时,密钥永远不到位。症状极隐蔽:
# 服务起得来、ecron 作业也在跑,但每次 AI 都落 failed/provider_unavailable,
# 没有任何一处报「密钥没读到」。
# 这里把三个已知的 LLM 密钥名从 .env.local(优先)/ .env 导出。
# 只导出这三个具名变量,**不**整体 include .env —— 后者会把 .env 里可能含 $ 的
# 密钥值交给 make 展开,有把密钥改写的风险。
# 值为空时不导出,避免用空值覆盖调用方已 export 的密钥。
_LLM_ENV_SRCS := .env.local .env
# 取第一个非空定义(.env.local 优先于 .env)
_llm_key = $(shell for f in $(_LLM_ENV_SRCS); do \
[ -f $$f ] || continue; \
v=$$(grep -h '^$(1)=' $$f 2>/dev/null | head -1 | cut -d= -f2-); \
if [ -n "$$v" ]; then printf '%s' "$$v"; break; fi; \
done)
_BIGMODEL_KEY := $(call _llm_key,BIGMODEL_API_KEY)
_ARK_KEY := $(call _llm_key,ARK_API_KEY)
_BAILIAN_KEY := $(call _llm_key,BAILIAN_API_KEY)
ifneq ($(_BIGMODEL_KEY),)
export BIGMODEL_API_KEY := $(_BIGMODEL_KEY)
endif
ifneq ($(_ARK_KEY),)
export ARK_API_KEY := $(_ARK_KEY)
endif
ifneq ($(_BAILIAN_KEY),)
export BAILIAN_API_KEY := $(_BAILIAN_KEY)
endif
include include/deps.mk
# Web / HTTP
DEPS = ranch cowlib cowboy gun
# Utility
DEPS += erlware_commons jose gen_smtp throttle
DEPS += goldrush jsone # goldrush: lager transitive dep, pin version explicitly
# Database / Cache / ID
DEPS += epgsql pooler erlang_migrate depcache syn ecron uid
# Ops / Observability
DEPS += telemetry lager observer_cli recon redbug
## OpenTelemetry(对接 Uptrace trace.imboy.pub;版本与传递依赖定义见 include/deps.mk)
DEPS += opentelemetry_api opentelemetry opentelemetry_exporter grpcbox tls_certificate_check chatterbox ctx acceptor_pool gproc ssl_verify_fun
# Template / Captcha
DEPS += simple_captcha erlydtl
# Payment(同工作区本地纯 Erlang 第三方支付库)
DEPS += erlang_pay
# 开发期热重载:sync 会扫描源码目录自动重编译并热加载改动的模块。
# DEPS 会被 erlang.mk 写进 ebin/imboy.app 的 applications,随 release 发布
# 并在节点启动时自动 start —— 生产机上任意 .erl 写入即升级为任意代码执行。
# 全仓零代码引用 sync:*,因此只在本地开发挂载;
# 生产打包走 scripts/imboy-deploy.sh 的 `IMBOYENV=pro make rel`,天然不含。
ifeq ($(IMBOYENV),local)
DEPS += sync
endif
LOCAL_DEPS = mnesia sasl ssl inets eunit crypto public_key
BUILD_DEPS = relx gpb bbmustache
DEP_PLUGINS = cowboy
TEST_DEPS += meck
SP = 4
DOC_DEPS = edown
EDOC_OPTS = {doclet, edown_doclet}
# 独占命名 gen_server 套件排除(合并后 main 全量 62 cancelled 定性,见
# /private/tmp/imboy-release-gate/CI-00/cancelled-analysis.md):这些套件
# 多 fixture 抢占 {local, Name} 单实例,全量并发下互踩必 cancel(历史上
# 从未在全量真正通过)。单跑不受影响:make eunit-local t=<模块名>。
# 注意需同时过滤 src 模块条目——eunit 运行 {module, X} 会自动附带 X_tests。
#
# 2026-09-01 收窄:elib_metric/imboy_router 系 5 套件已恢复参与全量——
# elib_metric_tests 改让位模式(terminate_child 停应用实例+restart_child
# 复原),router 系实测与应用实例共存无冲突。
# 2026-09-01 清空:plugin 族 4 套件同款让位模式改造后恢复全量
# (imboy_plugin_loader/imboy_plugin_sup/imboy_plugin_sup_metrics 挂
# imboy_sup 下 permanent child,测试 setup terminate_child 让位、cleanup
# restart_child 复原;loader/priv_plugins/sup_metrics 三个套件同理)。
# 注意需同时过滤 src 模块条目——eunit 运行 {module, X} 会自动附带 X_tests;
# 套件恢复后源模块条目一并移除。Excl 机制保留,未来再有独占套件时使用。
# 2026-09-30 F-12 独占隔离:elib_tsid_tests / elib_tsid_guard_tests /
# elib_tsid_bootstrap_harness_tests 三个套件操纵 VM 级 TSID 全局状态
# (elib_tsid:reset_for_test + 测试配置 init/自建 guard),与在役 app guard
# 共存会清掉/改写全局 runtime——全量轨道实证(20260930T031736Z-e36a1549
# logs/0035):中段执行后下游 app 套件 capacity_exhausted(lead_ms=33305) /
# generator_not_registered 级联失败,而三套件单跑全绿、受害套件单跑全绿
# (friend_repo_tests 13/13 隔离复跑 RC=0)。Excl 排除出全量 VM,覆盖由
# 专项单跑承载(make eunit-local t=<模块名>,Gate G1/G3/G5 证据线)。
# 全量 EUNIT_MODS 对存在 src 模块的套件只列源模块名,eunit:test({module,
# elib_tsid}) 再自动附带 elib_tsid_tests;因此 Excl 必须写源模块名,只有
# 无同名 src 模块的 bootstrap_harness 直接写测试模块名。
# 注意:t= 单跑路径绝不能套用同一过滤器——否则被排除套件的单跑会变成
# 空模块列表的静默假绿(eunit:test([]) → ok)。故 ifdef t 分流:
# 单跑直通原样运行,仅全量轨道(无 t)应用 Excl。
# 2026-10-01 AG31 隔离库验收组排除:agent_grant/recovery/run/tool_authorizer
# 四套件设计为专项验收门口径(文件头「运行前置」:AG31_PG_* env 注入的
# 隔离一次性 PG,非共享库;env 缺失显式 FAIL 禁止 skip 的铁律)——全量
# 轨道无该环境必然红 4 个(run3/run4 实证),排除后由 AG31 门运行环境
# 承载(t= 单跑仍可用:导出 env 后 IMBOYENV=local make eunit t=<模块>)。
# 2026-10-01 补(run10 实证):仅排除 test 侧 *_tests 名防不住伴随探测路径——
# agent_grant_pg/agent_run_pg 的 src 生产模块存在时,erlang.mk 的 EUNIT_MODS
# 会把 test 侧同名 *_tests filter-out(防重复),eunit:test 转而以 src 模块
# agent_grant_pg 为入口、EUnit 自动伴随执行 agent_grant_pg_tests(AG31 env
# 检查照样触发 → cancelled)。故须把 src 侧入口名一并排除(两文件均无
# 自带 test_() 单测,已验证,无旁伤)。
ifdef t
EUNIT_TEST_SPEC = (fun() -> lists:append([$1]) end)()
else
EUNIT_TEST_SPEC = (fun() -> Excl = [elib_tsid, elib_tsid_guard, elib_tsid_bootstrap_harness_tests, agent_grant_pg, agent_run_pg, agent_grant_pg_tests, agent_recovery_pg_tests, agent_run_pg_tests, agent_tool_authorizer_pg_tests], Mods = lists:append([$1]), [M || M <- Mods, not lists:member(M, Excl)] end)()
endif
# BUILD-00R 后端物理裁剪接线:生成器按 manifest 产出未被选中 feature 的专属
# 模块清单(include/generated/imboy_product_features_erlc.mk),erlang.mk 原生
# ERLC_EXCLUDE 使其不参与编译;.app 的 {modules, []} 由 erlang.mk 以实际编译
# 产物回填,被排除模块自动退出 .app 与 release 包。
# 本赋值必须在 include erlang.mk 之前——erlang.mk 的 ERLC_EXCLUDE ?= 先行
# 定义空值后,后置 ?= 不再生效。
-include include/generated/imboy_product_features_erlc.mk
ERLC_EXCLUDE ?= $(IMBOY_FEATURE_ERLC_EXCLUDE)
# WH-02(2026-09-28):ebin「beam 缺失但 .app 新」死态自愈守卫。erlang.mk 的
# ebin/imboy.app:: 规则只按「src 比 .app 新」($?) 驱动补编:并发会话/中断造成
# 个别 beam 文件缺失而 .app 仍新时,make 判定一切最新 → 不补编 → eunit 报
# *** test module not found ***(本日实证两例:adm_appeal_handler、
# rtc_room_handler——后者 20:38 新增 src,全量重编产出后又被并发会话移走,
# 期间 make 全程判定最新、零补编动作)。erlang.mk vendored 不动;利用
# test-build 是双冒号目标、本规则写在 include 之前即先执行:守卫发现任一
# (未被 ERLC_EXCLUDE 排除的)src 模块缺 beam 就删 ebin/$(PROJECT).app,
# 令随后 .app 目标以 $?=全量 走 -DTEST=1 干净重编(罕见路径触发一次约 4 分钟
# 全量重编,正确性优先)。.app 本就缺失时无需守卫——原生路径即全量重编。
# TEST/非TEST 模式失步由 WH-01T 的标记机制负责,本守卫只管存在性,单一职责。
# ERLC_EXCLUDE_PATHS 取下方覆写(按 basename 递归过滤),配方延迟求值,
# 执行期已定义。
test-build:: beam-presence-guard
.PHONY: beam-presence-guard
beam-presence-guard:
@if [ -f ebin/$(PROJECT).app ]; then \
for f in $$(find src -name '*.erl'); do \
case " $(ERLC_EXCLUDE_PATHS) " in *" $$f "*) continue ;; esac; \
if [ ! -f "ebin/$$(basename "$$f" .erl).beam" ]; then \
echo "== WH-02: ebin beam missing for $$f, force full -DTEST=1 recompile =="; \
rm -f ebin/$(PROJECT).app; break; \
fi; \
done; \
fi
include erlang.mk
# 本仓源码位于 src/<子目录>/*.erl(erlang.mk 递归 find),erlang.mk 默认的
# ERLC_EXCLUDE_PATHS 只生成平铺 src/<mod>.erl,filter-out 永不命中——按本仓
# 实际布局覆写为递归解析(BUILD-00R)。递归定义:ERLC_EXCLUDE 由 .mk 注入。
#
# EB-10 修掉 ADR-0007 §Decision 登记的那处**深度盲区**:原覆写是
# `src/$(m).erl src/*/$(m).erl src/*/*/$(m).erl`——只到 3 层,而纵切单元的
# 实际深度是 4 层(src/features/<bc>/<layer>/<sub>/<mod>.erl,如
# src/features/enterprise_business/application/asset/eb_asset_app.erl)。
# 后果**不是**「多编译几个模块」而是 release 组装期硬失败:深层模块逃过本
# filter ⇒ 仍进 .app modules;而 prune 钩子按 `$(ERLC_EXCLUDE)`(模块名)删
# ebin/*.beam ⇒ .app 声明的模块没有 beam ⇒ relx/systools 报
# `{module_not_found,imboy,eb_asset_app}`(2026-09-14 EB-10 三档矩阵实证,
# 见 scripts/enterprise_business_feature_matrix.sh 的 neither 档)。
# 改为在 erlang.mk 自己的**递归** ERL_FILES 上按 basename 过滤,与目录深度解耦:
# 模块名在 Erlang 里全局唯一,`%/$(m).erl` 不会误伤其他模块。
ERLC_EXCLUDE_PATHS = $(foreach m,$(ERLC_EXCLUDE),$(filter %/$(m).erl,$(ERL_FILES)))
# BUILD-00R 物理裁剪装配钩子:erlang.mk 的 ERLC_EXCLUDE 只保证被排除模块
# 不进 .app modules(relx 按整目录拷贝 ebin,不读 modules),因此必须在
# relx 组装前清掉 ebin 中的陈旧 beam,并删除同版本号的既有 release 目录
# (同一 VERSION 下 preset 切换会互相残留文件)。挂在 relx-rel 的前置条件
# 上获得确定性顺序:rel-deps → app → prune → relx 组装。
# 注意:base-only 装配会删除 ebin 中被排除模块的 beam;切回 full 口径后
# 如需完整 beam 集,touch 对应源文件再 make app 即可重编译恢复。
relx-rel: imboy-prune-excluded-beams
imboy-prune-excluded-beams:
@rm -f $(addprefix ebin/,$(addsuffix .beam,$(ERLC_EXCLUDE)))
@rm -f $(addprefix $(PROJECT_BEAM_CACHE_DIR)/ebin-app/,$(addsuffix .beam,$(ERLC_EXCLUDE)))
@rm -f $(addprefix $(PROJECT_BEAM_CACHE_DIR)/ebin-test/,$(addsuffix .beam,$(ERLC_EXCLUDE)))
@if [ -n "$(strip $(ERLC_EXCLUDE))" ]; then rm -rf "_rel/$(PROJECT)/lib/$(PROJECT)-$(PROJECT_VERSION)"; fi
@echo " PRUNE excluded=$$(echo $(ERLC_EXCLUDE) | wc -w | tr -d ' ') ebin_moment_left=$$(ls ebin/*moment*.beam 2>/dev/null | wc -l | tr -d ' ')"
.PHONY: imboy-prune-excluded-beams
# WH-01:禁用 erlang.mk 的 beam-cache 槽位交换(beam-cache-restore-app/test 以
# mv 在 app/test 双槽间搬 ebin,保留陈旧 mtime,跨 preset/跨套件反复以旧 beam
# 污染编译与 release——BUILD-00R 矩阵与 WH-01 测试均实证)。覆写为空操作:
# ebin 单一真源,代价是 app/test 切换时全量重编译(确定性优先)。
#
# 2026-09-28 后记(WH-01R 试验后回滚):曾尝试把 restore 改为"清标记+清 ebin
# 强制确定性重编",单跑 eunit 正确;但与常驻 `make run` dev server 共存时会
# 互踩(app↔test 模式互斥,单 ebin 目录无法同时服务两侧,反复互相清空重编,
# 实证 beam 消失又重现)。已回滚为空操作。遗留两个已知问题待 V1.3 决策:
# ① 空操作覆写下 eunit 直接跑 app 模式 beam,ifdef(TEST) 导出的函数(如
# billing_logic:validate_plan/4 等)全部 undef,产生假失败(一轮 47F 实证);
# ② 根本解法需架构决策:恢复双槽(带 mtime 修正)或 test 构建独立 ebin 目录。
# WH-01S 修复(2026-09-28 当日):问题①已由下方 restore-test 的
# 「touch .test + 清 ebin」落点修复(guard 时机在编译前;只动 test 侧,
# 避免与常驻 make run dev server 互踩——当日
# 47 例假失败经 -DTEST=1 批量单模块验证全转绿,见工作区
# .Codex/runs/crossplan-v12-20260927T000125Z-b7fd6733/notes/test-diagnosis-20260928/)。
# WH-01T 补全(2026-09-28 傍晚,plain `make eunit` 17:26 全量失败实证):
# ① restore-app 空操作把 erlang.mk 原生语义里「app 侧清标记」一并丢了——
# make run/make compile 后标记残留,下一次 test-build 因标记存在而跳过
# beam-cache-restore-test,ebin 陈旧口径直接带病参赛。恢复为仅删标记
# (不搬槽位,不触碰 ebin,仍不干扰常驻 dev server)。
# ② restore-test 只 touch 标记+清 beam,未删 ebin/$(PROJECT).app:app 目标
# 因 .app 比 src 新而跳过 -DTEST=1 重编 → ebin 空壳 → mass undef /
# meck {undefined_module,…} / "test module not found"(同 eunit-local
# A1c 注释的「补删 ebin/imboy.app」,此处让 plain eunit / ct 同享该修复)。
# 删 .app 必须先于清 beam:中途被打断时只要 .app 已删,下次 eunit 即因
# .app 缺失全量重建自愈;反之「无 beam 但 .app 在」是不会自愈的死态。
beam-cache-restore-app:
$(verbose) rm -f $(ERLANG_MK_TMP)/$(PROJECT).test
beam-cache-restore-test:
$(verbose) touch $(ERLANG_MK_TMP)/$(PROJECT).test
$(verbose) rm -f ebin/$(PROJECT).app
$(verbose) rm -f ebin/*.beam
define compile_proto.erl
[begin
gpb_compile:file(F, [
{i, "src"},
{include_as_lib, true},
{module_name_suffix, "_pb"},
{o_hrl, "./include"},
{o_erl, "./src"},
maps,
strings_as_binaries,
{type_specs, true},
{maps_unset_optional, omitted},
{maps_oneof, flat}
])
end || F <- string:tokens("$1", " ")],
halt().
endef
include include/tpl.mk
include include/cli.mk
APP_VERSION = $(shell cat $(RELX_OUTPUT_DIR)/$(RELX_REL_NAME)/version)
# Dialyzer — EUnit test beams land in ebin/ after `make eunit`; scan only non-test beams.
DIALYZER_EBIN_BEAMS = $(filter-out %_tests.beam, $(wildcard ebin/*.beam))
DIALYZER_DIRS = $(DIALYZER_EBIN_BEAMS) deps
DIALYZER_OPTS = -Wunmatched_returns --plt $(DIALYZER_PLT) -I $(CURDIR)/include $(DIALYZER_DIRS)
DIALYZER_WARNINGS ?= 50
# Compile flags
ERLC_COMPILE_OPTS = +'{parse_transform, lager_transform}' +nowarn_unused_function
ERLC_OPTS += $(ERLC_COMPILE_OPTS)
TEST_ERLC_OPTS += $(ERLC_COMPILE_OPTS)
# Common Test
CT_CONFIG ?= config/sys.config
TEST_HTTP_PORT ?= 0
CT_CONFIG_BASE = $(patsubst %.config,%,$(CT_CONFIG))
CT_CONFIG_BASE_ABS = $(abspath $(CT_CONFIG_BASE))
CT_ERL_ARGS = -config $(CT_CONFIG_BASE_ABS) -eval 'application:load(imboy)' -eval 'application:set_env(imboy, env, test)' -eval 'application:set_env(imboy, http_port, $(TEST_HTTP_PORT))' -eval 'application:set_env(imboy, dsync_enabled, false)'
CT_OPTS ?=
CT_OPTS += -erl_args $(CT_ERL_ARGS)
# REST API black-box tests. The runner owns an isolated scratch database and
# lets Cowboy bind an ephemeral port; reports and redacted evidence stay under
# the ignored .reports/ directory.
#
# The scratch provider (imboy_pg18 container) listens on 127.0.0.1:4323. The
# workspace .env sets IMBOY_PG_PORT for the dev database — a DIFFERENT server
# — so this target pins the REST provider port (REST_* outranks IMBOY_* in the
# runner). Override for a provider elsewhere: make rest-api-test REST_PG_PORT=5433.
# Credentials come from the environment only: source .env first
# (REST_PG_PASSWORD / IMBOY_PG_PASSWORD).
REST_PG_PORT ?= 4323
.PHONY: rest-api-test rest-contract-check
rest-api-test:
@REST_PG_PORT='$(REST_PG_PORT)' bash scripts/run_rest_api_tests.sh
rest-contract-check:
@bash scripts/check_rest_contract_coverage.sh
# Feature smoke
FEATURE_SMOKE_BASE_URL ?=
FEATURE_SMOKE_PUBLIC_PATH ?= /v1/app/features
FEATURE_SMOKE_ADMIN_PATH ?= /adm/admin/config/features
FEATURE_SMOKE_ADMIN_HEADER ?=
FEATURE_SMOKE_FORBIDDEN_HEADER ?=
FEATURE_SMOKE_EXPECTS ?=
FEATURE_SMOKE_TIMEOUT ?= 15
FEATURE_SMOKE_INSECURE ?= 0
FEATURE_SMOKE_SHOW_BODY ?= 0
.PHONY: feature-smoke
feature-smoke:
@if [ -z "$(strip $(FEATURE_SMOKE_BASE_URL))" ]; then \
echo "FEATURE_SMOKE_BASE_URL is required."; \
echo "Example:"; \
echo " make feature-smoke FEATURE_SMOKE_BASE_URL=https://dev.imboy.pub FEATURE_SMOKE_EXPECTS='core=true moment=false'"; \
exit 1; \
fi
@set -e; \
cmd="bash ./scripts/run_feature_flag_smoke.sh --base-url '$(FEATURE_SMOKE_BASE_URL)' --public-path '$(FEATURE_SMOKE_PUBLIC_PATH)' --admin-path '$(FEATURE_SMOKE_ADMIN_PATH)' --timeout '$(FEATURE_SMOKE_TIMEOUT)'"; \
if [ "$(FEATURE_SMOKE_INSECURE)" = "1" ]; then cmd="$$cmd --insecure"; fi; \
if [ "$(FEATURE_SMOKE_SHOW_BODY)" = "1" ]; then cmd="$$cmd --show-body"; fi; \
if [ -n "$(strip $(FEATURE_SMOKE_ADMIN_HEADER))" ]; then cmd="$$cmd --admin-header '$(FEATURE_SMOKE_ADMIN_HEADER)'"; fi; \
if [ -n "$(strip $(FEATURE_SMOKE_FORBIDDEN_HEADER))" ]; then cmd="$$cmd --forbidden-header '$(FEATURE_SMOKE_FORBIDDEN_HEADER)'"; fi; \
for item in $(FEATURE_SMOKE_EXPECTS); do cmd="$$cmd --expect $$item"; done; \
echo "$$cmd"; \
eval "$$cmd"
# Tier-0 冒烟:make smoke | make smoke-c2c | make smoke-ws | make smoke-ctl
# 8 步应用层冒烟链(Golden Gates §4.3,Golden Install 与 CI 共用):
# make smoke-8step
SMOKE_FROM ?= 1000000051
SMOKE_TO ?= 1000000056
.PHONY: smoke smoke-c2c smoke-ws smoke-ctl smoke-8step golden-install golden-upgrade
smoke-c2c:
@./scripts/smoke/c2c_smoke.sh $(SMOKE_FROM) $(SMOKE_TO)
smoke-ws:
@./scripts/smoke/c2c_ws_smoke.sh $(SMOKE_FROM) $(SMOKE_TO)
smoke-ctl:
@./scripts/smoke/ctl_smoke.sh
smoke: smoke-c2c smoke-ws smoke-ctl
@echo "=== all Tier-0 smoke PASS ==="
smoke-8step:
@SMOKE_C2C_FROM=$(SMOKE_FROM) SMOKE_C2C_TO=$(SMOKE_TO) bash ./scripts/smoke_8step.sh
# Golden Install 金安装门禁(Golden Gates §4,P2-G1):cleanroom 全流程 + 计时
# 断言 + restart 幂等。参数见 scripts/golden_install.sh --help,例:
# make golden-install GOLDEN_ARGS="--image-ref ghcr.io/…@sha256:… --git-ref v1.0.0-alpha.46 --profile ci …"
golden-install:
@bash ./scripts/golden_install.sh $(GOLDEN_ARGS)
# Golden Upgrade 升级门禁(Golden Gates §5,P2-U1):安装 vN → 产生真实数据 →
# 升级 vN+1(candidate digest)→ auto_migrate 观察 + 8 步链 + 数据保留断言。
# 参数见 scripts/golden_upgrade.sh --help,例:
# make golden-upgrade GOLDEN_ARGS="--from v1.0.0-alpha.45 --to-image ghcr.io/…@sha256:… --to-ref v1.0.0-alpha.46 --profile ci …"
golden-upgrade:
@bash ./scripts/golden_upgrade.sh $(GOLDEN_ARGS)
# CLI: make ctl ARGS="node status"
CTL_NODE ?= imboy@127.0.0.1
.PHONY: ctl
ctl:
@IMBOY_CTL_NODE=$(CTL_NODE) escript scripts/imboy_ctl $(ARGS)
# 代码质量: lint-erlang / format / format-check / xref-strict
ERLFMT_FILES := 'src/*.erl' 'src/**/*.erl' 'include/*.hrl' 'src/*.hrl' 'src/**/*.hrl'
.PHONY: lint-erlang format format-check xref-strict
lint-erlang:
@elvis rock
format:
@erlfmt --write $(ERLFMT_FILES)
format-check:
@erlfmt --check $(ERLFMT_FILES)
xref-strict: xref
@echo "xref strict mode done"
# 安全门禁: security-gate(CI 硬门本地可跑)
.PHONY: security-gate
.PHONY: contract-export contract-check contract-regen
security-gate:
@echo "=== 服务端零密码学守护 (ADR 07 §6.3 / 08 §4) ==="
@bash scripts/check_server_zero_crypto.sh
@echo "=== 模块边界守护 (Handler→Logic→DS→Repo 单向依赖) ==="
@bash scripts/check_module_boundaries.sh
@echo "=== 纵切架构守护 (Feature Slice 九铁律 / ADR-0007) ==="
@bash scripts/check_feature_architecture.sh
@echo "=== 安全门禁全部通过 ==="
# 纵切架构门禁(ADR-0007 / docs/architecture/feature-slice-rules.md):
# 纵切单元结构、facade 只进 application、domain 纯净、Org 贯穿、跨单元依赖方向、
# Product/Plugin 隔离、假 Plugin 检测。与 check_module_boundaries.sh 互补——
# 后者管旧四层(handler→logic→ds→repo)边界,本目标管 Feature Slice 纵切纪律。
# 管辖 src/features|products|plugins 及对 src/lib 的反向检查;存量 legacy 不迁。
.PHONY: arch-check arch-check-self-test
arch-check: ## 校验 Feature Slice 九铁律(ADR-0007)
@bash scripts/check_feature_architecture.sh
arch-check-self-test: ## 门禁自身金丝雀自检(10 条必须全触发)
@bash scripts/check_feature_architecture.sh --self-test
# 迁移文件门禁(ADR-0002):命名格式 / up-down 成对 / 版本号唯一 / up 非空 /
# 注释头版本自洽;编号断档仅告警不阻断。
.PHONY: migrations-check
migrations-check: ## 校验 priv/migrations/ 命名与 up-down 配对(ADR-0002)
@bash scripts/check_migrations.sh
# 客服域迁移门(CP-ASSET-01):先跑静态门(复用 scripts/check_migrations.sh:
# 版本号唯一 / up-down 成对 / 命名非空),再连真库校验"客服域迁移已全部应用"——
# 对比 priv/migrations 里 customer_service 迁移与目标库 schema_migrations /
# schema_migrations_history;静态违规 / 空库 / 脏库(dirty 或缺中间版本)/
# foreign 版本 / 空 oracle 均非零退出。自测:
# scripts/test/customer_service_migration_gate_test.sh(静态四负例 fixture +
# 库状态四负例 + 固定名 imboy_cp12_gate01 正例)。
# 用法:make cs-migration-gate PGDATABASE=... [PGHOST=... PGPORT=... PGUSER=... PGPASSWORD=...]
.PHONY: cs-migration-gate
cs-migration-gate: ## 客服域迁移是否已全部落库(连真库;PGDATABASE=...)
@bash scripts/customer_service_migration_gate.sh
# 跨仓 Widget 资产配对门(CP-ASSET-04):后端 frame 资产常量 ↔ imboyadmin
# 产物名的一致性检查;配对关系单源登记在 priv/cs_widget_asset_pairing.json。
# admin 仓默认取同父目录 ../imboyadmin,CI/工作树布局用 ADMIN_REPO_DIR 指定;
# admin 不可达时 exit 3(SKIPPED,"没核对"不冒充"一致")。
.PHONY: widget-asset-pairing-check
widget-asset-pairing-check: ## CS widget 资产名后端↔admin 配对一致(ADMIN_REPO_DIR=...)
@bash scripts/check_widget_asset_pairing.sh
.PHONY: terminology-check
terminology-check: app ## 校验 priv/terminology/*.json 的结构、profile 与通用概念键
@erl -noinput -boot no_dot_erlang -pa imboy/ebin -pa ebin \
-eval 'ok = product_terminology:validate_all(), halt().'
# 真 PostgreSQL 行为 harness 门(GZAPP:默认工作区鉴权 / 归档强交接 / 建企模板 /
# 成员有权 Workspace / 群与频道的双授权源)。三支 harness 都是真库行为矩阵,
# 没有它们这条证据面就只是"手工可复跑"。
# 前置:目标库必须迁移到与本仓 priv/migrations 一致的 head(脚本会自检并
# 拒绝在 schema 来源不符的库上跑),且集群已铺齐所需扩展。
# 用法: make pg-harness-check PGDATABASE=scratch_gzapp_fix_verify PGPORT=4323
.PHONY: pg-harness-check
pg-harness-check: ## 跑三支真库行为 harness(需已迁移的同源 PG)
@bash scripts/run_pg_behavior_harnesses.sh
# 跨仓特性产物一致性(GZAPP-C1 回归守卫,ADR-0007 特性机制)。
# 清单 → 11 份产物分落三仓;任一仓产物与清单不一致即非零退出。
# 需要 imboyapp / imboyadmin 与 imboy 同父目录(或用 APP_DIR/ADMIN_DIR 指定)。
# 兄弟仓缺失时输出显式 SKIPPED 且退出码 3("没核对"≠"一致",避免假绿)。
.PHONY: feature-cross-repo-check
feature-cross-repo-check: ## 校验三仓特性产物与 config/product-feature-manifest.json 一致
@python3 scripts/check_product_feature_cross_repo.py \
--app-dir "$${APP_DIR:-../imboyapp}" \
--admin-dir "$${ADMIN_DIR:-../imboyadmin}"
# 定时作业门禁(ecron):模板真源非空 + 无 {jobs,...} 回归 + crontab 规格合法
# + MFA 目标模块/入口真实存在(改名或删模块的连带检查);逐机运行配置
# (sys.local/pro/dev,均被 .gitignore)与模板的漂移默认只告警——
# 发布前自查用 `bash scripts/check_cron_config.sh --strict`。
.PHONY: cron-check
cron-check: ## 校验 ecron 定时作业配置(模板真源硬门 + 逐机漂移告警)
@bash scripts/check_cron_config.sh
# 墨芽 AI 回课启用前置:指定的 teaching_ai_llm_provider 在本环境 llm_providers
# 里是否存在、是否满足 vision、key 是否可用(四类可证明「开了也一定不工作」
# 的配置为硬门);视频开关配对与 ecron worker 为告警。
# 不带参数扫全部存在的 config;也可 `bash scripts/check_moya_ai_config.sh <config>`。
.PHONY: moya-ai-check
moya-ai-check: ## 校验墨芽 AI 回课启用前置(provider 名/vision/key 硬门 + 配对告警)
@bash scripts/check_moya_ai_config.sh
# 密钥可用性体检:上面的 config 门禁对 api_key 只能告警(密钥走 {env, Var},
# 只存在于进程环境里,配置文件里看不到值)。本目标另起独立临时节点、加载
# .env.local 后真的发一次纯文本 ping,把「配置全绿但一触发就 provider_unavailable」
# 这一类漏检补上。不重启、不动运行中的服务;`--no-ping` 可离线只验被读到。
.PHONY: moya-ai-key-check
moya-ai-key-check: ## 校验墨芽 AI 密钥真能认证(独立临时节点 + 纯文本 ping)
@bash scripts/check_moya_ai_key.sh
# P3-C2 API 契约门禁(Golden Gates §2.3 C2 / §2.2 契约变更流程)
# 真源:src/imboy_router.erl + priv/migrations CHECK 约束 + include/error_code.hrl
# 合法变更:改后端真源后,同一 PR 内 make contract-export 并提交 .contract/api_contract.json
contract-export: ## 导出 API 契约产物 .contract/api_contract.json(确定性输出)
@python3 scripts/contract_gate.py export
contract-check: ## 契约校验:落仓产物 vs 真源自检 + admin/flutter 枚举 diff + EntityId 规则(漂移非零退出)
@python3 scripts/contract_gate.py check \
$(if $(ADMIN_DIR),--admin $(ADMIN_DIR)) \
$(if $(FLUTTER_DIR),--flutter $(FLUTTER_DIR))
# P3-C3 契约物一行重生成(Golden Gates §2.3 C3;SOP 见 CONTRIBUTING「契约变更 SOP」)
# ① .contract/api_contract.json(复用 contract-export,本仓内)
# ② imboyapp/lib/config/error_code.dart(跨仓调用并排仓生成器;并排 ../imboyapp
# 是本地布局假设——CI 与他人独立 clone 不保证存在,缺仓 WARN 跳过不失败)
IMBOYAPP_DIR ?= ../imboyapp
contract-regen: contract-export ## 一行重生成全部契约物:api_contract.json + imboyapp error_code.dart
@if [ -f $(IMBOYAPP_DIR)/scripts/generate_error_code.dart ]; then \
echo "=== regen imboyapp/lib/config/error_code.dart($(IMBOYAPP_DIR))==="; \
cd $(IMBOYAPP_DIR) && dart run scripts/generate_error_code.dart; \
else \
echo "WARN: $(IMBOYAPP_DIR)/scripts/generate_error_code.dart 不存在(并排仓是本地布局假设,CI/独立 clone 可能分离)"; \
echo "WARN: 跳过 error_code.dart 重生成;请在 imboyapp 仓内执行 dart run scripts/generate_error_code.dart"; \
fi
# E2EE 完整验证套件(一键可审计)
.PHONY: e2ee-verify
e2ee-verify: security-gate
@echo ""
@echo "=== E2EE 安全验证套件 (S2-S21 全守护) ==="
@echo "--- 编译测试模块 ---"
@mkdir -p test
@erlc -o ebin -pa ebin $$(ls -d deps/*/ebin | sed 's/^/-pa /') -I include -DTEST src/repo/msg_store_repo.erl
@erlc -o test -pa ebin $$(ls -d deps/*/ebin | sed 's/^/-pa /') -I include \
$$(find test -name "*e2ee*" -o -name "*olm*" -o -name "imboy_codec_tests.erl" \
-o -name "message_ds_tests.erl" -o -name "push_notification_logic_tests.erl" \
-o -name "msg_store_jsonb_roundtrip_tests.erl" -o -name "device_revocation_tests.erl" \
| grep '\.erl$$')
@echo "--- 运行 E2EE 测试模块 ---"
@erl -noshell -pa ebin -pa test $$(ls -d deps/*/ebin | sed 's/^/-pa /') -eval 'Modules = [e2ee_handler_tests,e2ee_handler_capability_tests,e2ee_handler_device_binding_tests,olm_handler_tests,olm_handler_claim_throttle_tests,e2ee_logic_tests,e2ee_backup_logic_tests,e2ee_recovery_logic_tests,e2ee_trust_logic_tests,group_e2ee_logic_tests,olm_identity_logic_tests,olm_otk_lifecycle_tests,e2ee_v3_passthrough_contract_tests,e2ee_c2g_passthrough_contract_tests,e2ee_sender_device_envelope_tests,e2ee_offline_sender_did_tests,e2ee_otk_claim_idempotency_tests,e2ee_otk_target_throttle_tests,e2ee_batch_claim_idempotency_tests,e2ee_otk_count_tests,e2ee_claimant_scope_drift_tests,e2ee_presign_mime_binding_tests,e2ee_otk_exhaustion_metric_tests,e2ee_fallback_signature_tests,e2ee_otk_metric_exposition_tests,e2ee_metrics_ip_gate_tests,e2ee_throttle_scope_config_tests,e2ee_kt_merkle_tests,device_revocation_tests,e2ee_backup_repo_tests,olm_identity_repo_tests,msg_store_jsonb_roundtrip_tests,e2ee_error_code_tests,elib_cipher_e2ee_v2_tests,olm_otk_cleanup_worker_tests,imboy_codec_tests,message_ds_tests,push_notification_logic_tests,e2ee_error_privacy_tests], case eunit:test(Modules, []) of ok -> io:format("~n=== E2EE verify ALL PASSED ===~n"), halt(0); error -> io:format("~n=== E2EE verify FAILED ===~n"), halt(1) end.'
.PHONY: clear_beam
clear_beam:
@find . -path ./deps -prune -o -name '*.beam' -print -delete
@echo "clear_beam done"
## 启动 API 文档服务器(需要 Docker)
.PHONY: docs-serve docs-stop
docs-serve:
docker compose -f docs/api-sandbox/docker-compose.yml up -d
@echo "API Docs: http://localhost:8080"
docs-stop:
docker compose -f docs/api-sandbox/docker-compose.yml down
# EUnit(本地真 PG):注入 config/sys.local 使 pg_conf 可读并起全量 imboy app,
# 让 ?TEST_WITH_APP / ?TEST_WITH_DB 类用例真连本地 imboy_v1 跑;否则纯 make eunit
# 无 -config 时 eunit_runner:ensure_config_loaded/0 硬失败 {missing_config, pg_conf},
# 一大批 ?TEST_WITH_APP setup 被 cancelled。
# 用法: make eunit-local # 全量
# make eunit-local t=elib_uri_tests # 单模块
# 前置: 本地 imboy_v1 schema 须已应用到最新迁移,否则 imboy_app:start/2 的
# imboy_migrate:migrate/0 会 {out_of_order, ...} 使 app 启动失败。
# worktree 兼容:code:lib_dir/priv_dir 的 lib 位注册要求 code path 中 ebin 的
# 父目录名与 app 同名(imboy/ebin)。git worktree 根名是 rg-*,-pa ebin 注册不了
# lib 位 → code:priv_dir(imboy) bad_name → imboy app 启动即崩
# (ensure_dev_rsa_keypair / imboy_migrate 均依赖 priv_dir),?TEST_WITH_DB 全
# skip。补 -pa imboy/ebin:worktree 内 `ln -s . imboy` 后 lib 位可解析;主树无
# imboy/ 子目录,该 -pa 指向不存在目录被 erl 静默忽略,零副作用。
# R2-1(2026-09-29):手工 `ln -s . imboy` 在新 worktree 里必然缺席(F-R1 实证:
# eb_tenant_handler_tests 因 {terminology_priv_dir_error,bad_name} 全套件
# no_pool),改为 eunit-local 目标内自动创建/自动清理(rm -f imboy 在 rc 捕获后
# 立即执行,成功失败都清,不落 git status)。application:load 救不了 lib 位:
# OTP 的 code:lib_dir(App) 扫描的是 code path 上「名为 <App> 的目录下的 ebin」,
# 与 app 是否已 load 无关(实证:load ok 后 lib_dir 仍 bad_name)。
.PHONY: eunit-local
# EUNIT_CONFIG 可覆盖配置文件(默认 config/sys.local):CI 无 sys.local.config,
# 物化 sys.config 后以 EUNIT_CONFIG=config/sys 传同口径全量(见 backend-ci.yml)。
EUNIT_CONFIG ?= config/sys.local
# relay 配置副本按调用唯一命名(:= 存下 $$,recipe 里由 shell 展开为本次
# 进程组 PID):固定名 config/sys.eunit-relay.config 会让并发 eunit-local
# 互相覆写端口——2026-10-04 实证:两个会话并发跑 eunit,后写方写入的
# http 端口被先写方的 VM 读走 → eaddrinuse → app 起不来 → elib_tsid
# 未注册,连锁失败 19 例。
EUNIT_RELAY_CONF := config/sys.eunit-relay.$$$$
EUNIT_PROCESS_LIMIT ?= 32768
EUNIT_ERL ?= erl -noinput -boot no_dot_erlang -kernel start_distribution false +P $(EUNIT_PROCESS_LIMIT) +Q 1024
eunit-local:
@# A1c:erlang.mk beam-cache 防失步(CP-TD-A02)。L3 门顺序是 make compile →
@# eunit-local:当 .erlang.mk/imboy.test 标记与 ebin 内 beam 的 TEST/非TEST 模式
@# 失步时(历史运行中断/缓存残留),test-build 因 ebin/imboy.app 比 src 新
@# ($? 为空)而跳过 -DTEST=1 重编 → 全部 -ifdef(TEST) 导出缺失 → 依赖内部
@# 函数白盒导出的用例大面积 undef(13:56Z 门 38/40 失败根因)。删标记+清缓存,
@# 强制 beam-cache-restore-test 走「现 ebin 入缓存→clean-app→带 -DTEST=1
@# 干净重编」的确定性路径;erlang.mk 本体 vendored 不动,只在门入口加固。
@rm -f .erlang.mk/$(PROJECT).test
@rm -rf .erlang.mk/beam-cache/$(PROJECT)
@# A1c 补全(2026-09-28 15:13Z 门全 cancel 实证):覆写版 beam-cache-restore-test
@# 当年只 touch 标记+清 beam,丢了 erlang.mk 原生路径里对 ebin/imboy.app 的
@# 删除 → test-build 跳过 -DTEST=1 重编 → ebin 空 beam、用例全 cancel。
@# 该缺口已由 WH-01T 在 beam-cache-restore-test 覆写处修复(见上方 Makefile
@# restore-app/restore-test,删 .app 先于清 beam);此处三连删保留为
@# eunit-local 门入口的纵深防御(显式硬复位,不依赖覆写语义)。
@rm -f ebin/imboy.app
@# A1c:PG 接入走 fork-per-connection 本地中继(test/common/pg_relay.py)。
@# 证据(evidence/CP-TD-A02 run1-4):长命 eunit VM 的新建 TCP 连接会被
@# com.docker.backend @127.0.0.1:<pg端口> 按调用方进程楔死——持续 econnrefused
@# 不恢复,而同刻 pg_isready/全新进程连接正常(防火墙关闭、无过滤器,已排除
@# 并发/累计连接数与 PG 上限成因);首例拒绝恒定出现在首轮全链迁移重 I/O 后。
@# 中继让触达 <目标端口> 的 connect 全部来自 fork 出的全新子进程,按进程楔死
@# 机制无法命中;eunit VM 只连 127.0.0.1:$(EUNIT_RELAY_PORT)。
@# 生成 <EUNIT_CONFIG>.config 的端口替换副本($(EUNIT_RELAY_CONF).config,
@# 不落 git;文件名按调用唯一,防并发 eunit 互相覆写);
@# 无 python3 或配置里没有目标端口时自动退回直连,行为与旧版一致。
@if command -v python3 >/dev/null 2>&1 && [ "$(EUNIT_USE_RELAY)" != "0" ] && [ -f "$(EUNIT_CONFIG).config" ] \
&& grep -q "$(EUNIT_RELAY_TARGET)" "$(EUNIT_CONFIG).config"; then \
sed -e "s/$(EUNIT_RELAY_TARGET)/$(EUNIT_RELAY_PORT)/g" \
-e "s/{http_port, 9800}/{http_port, $(EUNIT_HTTP_PORT)}/" \
-e "s/{http_port_adm, 9706}/{http_port_adm, $(EUNIT_HTTP_ADM_PORT)}/" \
"$(EUNIT_CONFIG).config" \
> $(EUNIT_RELAY_CONF).config; \
echo "== EUNIT PG RELAY: 127.0.0.1:$(EUNIT_RELAY_PORT) -> 127.0.0.1:$(EUNIT_RELAY_TARGET) (http $(EUNIT_HTTP_PORT)/adm $(EUNIT_HTTP_ADM_PORT)) =="; \
( while true; do python3 test/common/pg_relay.py $(EUNIT_RELAY_PORT) $(EUNIT_RELAY_TARGET); sleep 1; done ) & \
relay_pid=$$!; \
sleep 1; \
ln -sfn . imboy; \
IMBOYENV=local $(MAKE) eunit IMBOY_EUNIT_INNER=1 $(if $(t),t=$(t)) ERL="$(EUNIT_ERL)" \
EUNIT_ERL_OPTS="-config $(EUNIT_RELAY_CONF) -pa imboy/ebin -pa ebin -pa test"; \
rc=$$?; \
rm -f imboy; \
pkill -P $$relay_pid 2>/dev/null; \
kill $$relay_pid 2>/dev/null; \
pkill -f "pg_relay.py $(EUNIT_RELAY_PORT)" 2>/dev/null; \
rm -f $(EUNIT_RELAY_CONF).config; \
exit $$rc; \
else \
sed -e "s/{http_port, 9800}/{http_port, $(EUNIT_HTTP_PORT)}/" \
-e "s/{http_port_adm, 9706}/{http_port_adm, $(EUNIT_HTTP_ADM_PORT)}/" \
"$(EUNIT_CONFIG).config" \
> $(EUNIT_RELAY_CONF).config; \
echo "== EUNIT PG DIRECT: $(EUNIT_RELAY_TARGET) (http $(EUNIT_HTTP_PORT)/adm $(EUNIT_HTTP_ADM_PORT), EUNIT_USE_RELAY=0) =="; \
ln -sfn . imboy; \
IMBOYENV=local $(MAKE) eunit IMBOY_EUNIT_INNER=1 $(if $(t),t=$(t)) ERL="$(EUNIT_ERL)" \
EUNIT_ERL_OPTS="-config $(EUNIT_RELAY_CONF) -pa imboy/ebin -pa ebin -pa test"; \
rc=$$?; \
rm -f imboy; \
rm -f $(EUNIT_RELAY_CONF).config; \
exit $$rc; \
fi
# A1c:eunit-local 的 PG 中继端口。目标端口取本地 pg_conf 实际端口(4323);
# 中继监听端口避开常用段。inttest_marker_db 的 <PREFIX>_PG_* env 旋钮与
# ORG08_PGPORT 亦应指向中继端口(由门运行环境导出)。
EUNIT_RELAY_PORT ?= 15432
EUNIT_RELAY_TARGET ?= 4323
# A1c:eunit VM 的 HTTP/adm 监听改用私有端口——主树 agent 的 imboy 实例
# (RTC 后端等)会间歇性绑定同款 9800/9706,撞 port → imboy 启动
# {listener_start_failed,http,eaddrinuse} → 整段 app 套件连锁 cancel
# (run12/14/15 实证)。
EUNIT_HTTP_PORT ?= 19980
EUNIT_HTTP_ADM_PORT ?= 19970
# WH-03(2026-09-28):plain `IMBOYENV=local make eunit` 与 eunit-local 同轨。
# 现状(2026-09-28 final4 全量实证):plain eunit 走 erlang.mk 原生路径,
# EUNIT_ERL_OPTS 无 -config;且 erlang.mk 的 `-pa test -pa ebin` 因 -pa 后者优先,
# ebin 的 src/lib/eunit_runner.beam 压过 test/common 同名模块,其 eunit_setup
# 内部 ensure_config_loaded/0 在 pg_conf 缺失时硬失败 → 68 个 DB 测试文件、
# 551 处 missing_config、586 个 context setup failed 全量 cancel——这正是原始
# 报障「大面积 cancel」的主体。eunit-local 已备齐全部解法(-config 注入 +
# PG 中继 + 私有 HTTP 端口 + A1c/WH-01T/WH-02 确定性重建),此处把用户习惯
# 命令直接委派给它:单冒号规则在本文件(include erlang.mk 之后)后定义即覆盖
# erlang.mk 的 eunit(前置并集、配方取后定义),仅 IMBOYENV=local 且非
# eunit-local 内层调用时生效;eunit-local 的内层 $(MAKE) eunit 已带
# IMBOY_EUNIT_INNER=1 防递归。非 local 环境(CI 显式构造的调用)不受影响。
ifeq ($(IMBOYENV),local)
ifeq ($(IMBOY_EUNIT_INNER),)
eunit: eunit-local
$(verbose) :
endif
endif
# ==================== Gradualizer(本地快检 + CI 宽网基线) ====================
# 职责: pre-push 变更快检 + CI 全仓宽网扫描;分层阻塞门禁由 eqWAlizer 承担
GRADUALIZER_DIR := tools/gradualizer
GRADUALIZER ?= $(GRADUALIZER_DIR)/bin/gradualizer
GRADUALIZER_REF ?= 23533d7eb7541d8a146a507e837fdfff6499a202
GRADUALIZE_BUDGET ?= 0
# gpb 生成代码不参与门禁;elib_str 触发 Gradualizer 崩溃(pick_value none() bug,待上报上游)
# 注意:elib_log 已修复 lager:log metadata 误报,重新纳入检查(否则调用方会报 internal_log undefined)
# barrel_mcp_* 为 vendored 代码(LICENSE.barrel_mcp / VENDOR.md):不参与本仓门禁,
# 避免 patch 上游;其报错另受 gradualizer 未解析 barrel_mcp.hrl 的伪影影响
GRADUALIZE_EXCLUDE := src/imboy_pb.erl src/lib/elib_str.erl \
src/mcp/barrel_mcp_protocol.erl src/mcp/barrel_mcp_registry.erl \
src/mcp/barrel_mcp_session.erl src/mcp/barrel_mcp_tasks.erl \
src/mcp/barrel_mcp_schema.erl src/mcp/barrel_mcp_uri_template.erl
GRADUALIZER_OPTS ?= -pa ebin $(addprefix -pa ,$(wildcard deps/*/ebin)) \
-I $(CURDIR)/include --no_color --fmt_location brief
# OTP 29 把 match_alias_pats 警告升级为错误,上游未适配,构建时压制
GRADUALIZER_ERLC_OPTS = -I include -I src -pa ebin +debug_info +nowarn_match_alias_pats
.PHONY: gradualizer-setup
gradualizer-setup: ## 拉取并构建 Gradualizer escript(pin 版本)
@test -x $(GRADUALIZER) || { \
git clone https://github.com/josefs/Gradualizer.git $(GRADUALIZER_DIR) && \
git -C $(GRADUALIZER_DIR) checkout $(GRADUALIZER_REF) && \
git -C $(GRADUALIZER_DIR) rev-parse HEAD > $(GRADUALIZER_DIR)/PINNED && \
$(MAKE) -C $(GRADUALIZER_DIR) escript ERLC_OPTS="$(GRADUALIZER_ERLC_OPTS)"; }
@echo "✅ Gradualizer ready ($(GRADUALIZER_REF))"
.PHONY: gradualize
gradualize: ## 单文件检查: make gradualize FILE=src/lib/elib_cnv.erl
@test -n "$(FILE)" || { echo "用法: make gradualize FILE=<path.erl>"; exit 1; }
@$(GRADUALIZER) $(GRADUALIZER_OPTS) $(FILE)
.PHONY: gradualize-layer
gradualize-layer: ## 分层门禁(单发模式,用于转绿层): make gradualize-layer LAYER=lib
@test -n "$(LAYER)" || { echo "用法: make gradualize-layer LAYER=lib|repo|ds|logic|api"; exit 1; }
$(GRADUALIZER) $(GRADUALIZER_OPTS) \
$(filter-out $(GRADUALIZE_EXCLUDE),$(wildcard src/$(LAYER)/*.erl))
.PHONY: gradualize-audit
gradualize-audit: ## 全仓逐模块审计(预算制,基线期): make gradualize-audit
@mkdir -p .gradualizer/logs; rm -f .gradualizer/logs/*.log; fail=0; total=0; \
for f in $(filter-out $(GRADUALIZE_EXCLUDE),$(wildcard src/*.erl src/*/*.erl)); do \
case "$$f" in *_tests.erl) continue;; esac; \
total=$$((total+1)); \
log=.gradualizer/logs/$$(basename $$f .erl).log; \
if ! $(GRADUALIZER) $(GRADUALIZER_OPTS) $$f > $$log 2>&1; then \
fail=$$((fail+1)); echo "❌ $$f"; \
fi; \
done; \
echo "== gradualize-audit: modules=$$total failing=$$fail budget=$(GRADUALIZE_BUDGET)"; \
echo "gradualizer_failing $$fail" > .gradualizer/metrics.txt; \
test $$fail -le $(GRADUALIZE_BUDGET)
.PHONY: gradualize-baseline
gradualize-baseline: ## 从最近一次 gradualize-audit 日志重建 pre-push ratchet 基线(入仓,只准减不准增)
@test -d .gradualizer/logs || { echo "先跑 make gradualize-audit GRADUALIZE_BUDGET=9999"; exit 1; }
@grep -lE '^src/.*\.erl:[0-9]' .gradualizer/logs/*.log 2>/dev/null \
| xargs -n1 basename | sed 's/\.log$$//' | sort > .gradualizer-baseline.txt
@echo "✅ .gradualizer-baseline.txt: $$(wc -l < .gradualizer-baseline.txt | tr -d ' ') 个存量失败模块"
# ==================== ELP / eqWAlizer(CI 分层阻塞门禁 + IDE) ====================
# 项目结构描述在 .elp.toml(入仓);.elp/elp-repo 不入仓,由 elp-setup 拉取
ELP ?= elp
ELP_REPO_DIR := .elp/elp-repo
ELP_REPO_REF ?= c3708e6a7cc627c5323ef066a2bdfd8d1ba987e5
EQWALIZER_SUPPORT := $(ELP_REPO_DIR)/eqwalizer/eqwalizer_support
EQWALIZE_BUDGET ?= 0
.PHONY: elp-setup
elp-setup: ## 校验 elp + JVM,拉取 eqwalizer_support;校验 .elp.toml 与实际目录一致
@command -v $(ELP) >/dev/null || { echo "❌ 未找到 elp: brew install erlang-language-platform"; exit 1; }
@command -v java >/dev/null || { echo "❌ eqWAlizer 需要 JVM 17+"; exit 1; }
@if [ ! -d "$(EQWALIZER_SUPPORT)" ]; then \
mkdir -p .elp && \
git clone --filter=blob:none --sparse \
https://github.com/WhatsApp/erlang-language-platform.git $(ELP_REPO_DIR) && \
git -C $(ELP_REPO_DIR) sparse-checkout set eqwalizer/eqwalizer_support && \
git -C $(ELP_REPO_DIR) checkout $(ELP_REPO_REF) && \
git -C $(ELP_REPO_DIR) rev-parse HEAD > .elp/PINNED; \
fi
@actual=$$(ls -d src/*/ | xargs -n1 basename | sort); \
configured=$$(grep -o 'src/[a-z][a-z]*' .elp.toml | cut -d/ -f2 | sort -u); \
[ "$$actual" = "$$configured" ] || \
echo "⚠️ .elp.toml 的 src_dirs 与实际 src/ 子目录不一致,请同步"
@echo "✅ ELP ready"
.PHONY: eqwalize
eqwalize: ## 单模块检查: make eqwalize MOD=msg_c2c_logic
@test -n "$(MOD)" || { echo "用法: make eqwalize MOD=<module>"; exit 1; }
@out=$$($(ELP) eqwalize $(MOD) 2>&1); rc=$$?; echo "$$out"; \
if [ $$rc -ne 0 ]; then exit $$rc; fi; \
echo "$$out" | grep -q "^error:" && exit 1 || true
.PHONY: eqwalize-layer
eqwalize-layer: ## 分层检查(预算制): make eqwalize-layer LAYER=lib
@test -n "$(LAYER)" || { echo "用法: make eqwalize-layer LAYER=lib|repo|ds|logic|api"; exit 1; }
@mkdir -p .elp/logs; fail=0; total=0; \
for f in src/$(LAYER)/*.erl; do \
m=$$(basename $$f .erl); \
case "$$m" in *_tests) continue;; esac; \
total=$$((total+1)); \
if $(ELP) eqwalize $$m > .elp/logs/$$m.log 2>&1; then \
grep -q "^error:" .elp/logs/$$m.log && { fail=$$((fail+1)); echo "❌ $$m"; } || true; \
else \
fail=$$((fail+1)); echo "❌ $$m (crash, 详见 .elp/logs/$$m.log)"; \
fi; \
done; \
echo "== eqwalize-layer: layer=$(LAYER) modules=$$total failing=$$fail budget=$(EQWALIZE_BUDGET)"; \
test $$fail -le $(EQWALIZE_BUDGET)
.PHONY: eqwalize-all
eqwalize-all: ## 全量检查(CI 用;解析输出判定,退出码不可信)
@$(ELP) eqwalize-all 2>&1 | tee .elp/eqwalize-all.log; \
grep -q "^error:" .elp/eqwalize-all.log && exit 1 || true
# compile: erlang.mk 无原生 compile target(构建入口是 app);
# 历史文档/README 大量引用 make compile,加别名使其直接可用。
.PHONY: compile
compile: app
# ==================== Dialyzer 递减基线门(CI-00) ====================
# make dialyze-check:全量 Dialyzer 分析(dialyze 语义下存量告警 exit 2,此处
# 吞掉退出码)+ scripts/check_dialyzer_baseline.sh 对照 dialyzer.baseline 裁决:
# 基线外新增 1 条告警即红;存量减少不红(提示可人工收紧基线)。棘轮只准减不准增。
# 产物 dialyze-last.log 供基线脚本解析与 CI 摘要。
.PHONY: dialyze-check
dialyze-check:
@$(MAKE) dialyze DIALYZER_OPTS="$(DIALYZER_OPTS)" > dialyze-last.log 2>&1 || true
@bash scripts/check_dialyzer_baseline.sh dialyze-last.log
# L3 gate extension point (l3-gate.sh probes dialyze-local first):
# ratchet semantics per CI-00/TD-04A (0 NEW fingerprints = green; literal warn-0 unreachable, 477 legacy)
dialyze-local: dialyze-check
# ==================== 依赖版本审计 / 定点升级 ====================
# mod-check:审计 include/deps.mk 全部依赖的上游最新版本
# (git 走 ls-remote tags,hex 走 hex.pm API;并发度可 MOD_CHECK_JOBS=16 调)
# mod-up:定点升级,如 make mod-up MOD="cowboy cowlib"(默认升到最新稳定版)
# 或 make mod-up MOD="cowboy=2.15.0"(指定版本)。
# make mod-up MOD=all:构建图内全部依赖升到最新稳定版
# (已最新/pin分支 自动跳过;大版本升级含在内,注意编译+测试验证)。
# 只改 deps.mk,可 git 回滚;升级后需 rm -rf deps/<name> 并清
# .erlang.mk/dep_built/<name> 再 make(erlang.mk 构建戳记不会因删目录失效)。
.PHONY: mod-check
mod-check: ## 依赖审计: 检查 deps.mk 中哪些依赖可升级
@bash scripts/mod_check.sh check
.PHONY: mod-up
mod-up: ## 依赖升级: make mod-up MOD="name[=ver] ..." 或 MOD=all(图内全部)
@test -n "$(MOD)" || { echo "用法: make mod-up MOD=\"name[=ver] ...\" 例如 make mod-up MOD=\"cowboy cowlib=2.17.0\" 或 make mod-up MOD=all"; exit 1; }
@bash scripts/mod_check.sh up $(MOD)