Files
ligent/Makefile
T
2026-10-03 14:13:21 +08:00

584 lines
32 KiB
Makefile
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
CC ?= cc
# 警告就是纪律:这一串是"改完就回不去"的那类问题(影子变量、丢 const、
# 没有原型的函数)。加进来之后新代码写错会当场报出来,而不是等哪天静态分析
# 才捞到。项目在这套 flags 下是零警告的 —— 注意这句话是**连 make test 一起算的**:
# 测试程序用的是同一套 CFLAGS,以前它们里面藏着 5 条 const 丢弃警告,只有跑
# test-unit 才会看到,光看 make 的输出是看不见的。
CFLAGS ?= -std=c99 -D_GNU_SOURCE -O2 -g -Wall -Wextra -Wno-unused-parameter \
-Wshadow -Wwrite-strings -Wstrict-prototypes -Wmissing-prototypes \
-Wold-style-definition -Wpointer-arith \
-Wformat=2 -Wno-format-nonliteral
# -Wformat=2 里值钱的是 -Wformat-security:抓"把变量当格式串"。已经抓到一个 ——
# doctor 里把 HTTPS_PROXY 的值直接喂给 field(),而代理 URL 里带 % 是常事。
# -Wno-format-nonliteral 关掉的是它的噪声面:我们自己那套包装内部转发 fmt 的
# 地方(xvasprintf / err_result / …)天然就是非字面量。可查的部分已经由
# LIGENT_PRINTF 属性在**调用点**查完了。
CPPFLAGS += -Isrc -pthread
# 版本号的 -D 也挂在 CPPFLAGS 上:这样主程序、静态目标、以及二十来条测试规则
# 全都自动带上,不用逐个改。测试程序确实需要它 —— 它们会链 version.c。
CPPFLAGS += $(VERSION_DEFS)
# -lm 是必须的:代码里调了 fabs,而 gcc 会把它折成内建函数,于是"不写 -lm
# 也能链接"是个假象 —— 换个编译器(tcc)或加 -fno-builtin 就会露出来。
# 显式写上,不要靠编译器的好意。
LDLIBS += -lcurl -lm -pthread
# make SAN=1 —— 打开 AddressSanitizer / UndefinedBehaviorSanitizer。
# 链接行也要带,否则只有目标文件被插桩、链接期运行时库没进来。
ifeq ($(SAN),1)
# 注意 override:命令行上传的变量会屏蔽 makefile 里对它的一切赋值(连 += 也算),
# 所以 `make SAN=1 CFLAGS="-O1"` 不加 override 的话会**静默地不带 sanitizer** ——
# 结果是一份没插桩却看起来「干净」的构建。这个坑踩过一次,别再踩。
# -fno-sanitize-recover=all 让报错直接中止,而不是报告完继续跑。
override CFLAGS += -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all
override LDLIBS += -fsanitize=address,undefined
endif
BIN := ligent
SRCDIR := src
BUILDDIR := build
# 对象目录带"编译器 + 是否插桩"的标签,见文件末尾"为什么对象目录要带标签"。
BUILD_TAG := $(basename $(notdir $(CC)))$(if $(filter 1,$(SAN)),-san,)
OBJDIR := $(BUILDDIR)/$(BUILD_TAG)
# 头文件也是依赖。以前没有这一条,改了 .h 之后 make 会说"无需做任何事",
# 目标文件仍然是旧布局 —— 这正是"子代理读到的字段全是垃圾值"那次事故的成因。
# 全量重编只要几秒,不值得为它引入 .d 文件那一套。
HEADERS := $(wildcard $(SRCDIR)/*.h)
# ------------------------------------------------------------------ */
# 版本号:CalVer + git 哈希,构建期算出来注入 */
# ------------------------------------------------------------------ */
#
# 源码里**不写死**版本号,只留一个回退默认值(见 src/version.h 的说明)。
# 这里取真值,用 -D 注入。算法和理由都在 tools/get-version.sh 里。
#
# 用 := 而不是 = :这段要跑三次 git,展开式变量会在每次引用时重跑一遍。
VERSION_SCRIPT := $(SRCDIR)/../tools/get-version.sh
LIGENT_VERSION := $(shell sh $(VERSION_SCRIPT) 2>/dev/null | sed -n 1p)
LIGENT_GIT_HASH := $(shell sh $(VERSION_SCRIPT) 2>/dev/null | sed -n 2p)
LIGENT_GIT_BRANCH := $(shell sh $(VERSION_SCRIPT) 2>/dev/null | sed -n 3p)
LIGENT_BUILD_DATE := $(shell sh $(VERSION_SCRIPT) 2>/dev/null | sed -n 4p)
LIGENT_BUILD_TIME := $(shell sh $(VERSION_SCRIPT) 2>/dev/null | sed -n 5p)
# 脚本跑不起来(比如没有 sh)时给个明确的默认值,别让 -D 后面空着 ——
# 空的 LIGENT_VERSION 会让预处理器把它当成"没定义",于是回退到 version.h
# 里的值。那样其实也对,但显式写出来更不容易被误读。
LIGENT_VERSION := $(if $(LIGENT_VERSION),$(LIGENT_VERSION),0.0.0-unknown)
LIGENT_GIT_HASH := $(if $(LIGENT_GIT_HASH),$(LIGENT_GIT_HASH),unknown)
LIGENT_GIT_BRANCH := $(if $(LIGENT_GIT_BRANCH),$(LIGENT_GIT_BRANCH),unknown)
LIGENT_BUILD_DATE := $(if $(LIGENT_BUILD_DATE),$(LIGENT_BUILD_DATE),unknown)
LIGENT_BUILD_TIME := $(if $(LIGENT_BUILD_TIME),$(LIGENT_BUILD_TIME),unknown)
VERSION_DEFS = -DLIGENT_VERSION='"$(LIGENT_VERSION)"' \
-DLIGENT_GIT_HASH='"$(LIGENT_GIT_HASH)"' \
-DLIGENT_GIT_BRANCH='"$(LIGENT_GIT_BRANCH)"' \
-DLIGENT_BUILD_DATE='"$(LIGENT_BUILD_DATE)"' \
-DLIGENT_BUILD_TIME='"$(LIGENT_BUILD_TIME)"'
# ------------------------------------------------------------------ */
# 版本号变了就自动清掉对象,免得 make 拿旧版本号蒙混过关 */
# ------------------------------------------------------------------ */
#
# 这是"make 看不见命令行变量变了"这个坑的第三个入口(前两个:混用编译器
# 导致链接错误、make SAN=1 不重新插桩 —— 那两个是用 build/<tag>/ 分目录解决的)。
#
# 版本号只走 -D,不进任何依赖关系,于是:
# $ make && ./ligent --version # 2027.01.01+...
# $ git tag -d v2027.01.01 # 把 tag 删了
# $ make # "无需做任何事"
# $ ./ligent --version # 还是 2027.01.01 —— **在说谎**
# 二进制看着完全正常,只是版本号是旧的。而这恰恰是 .dirty 标记想避免的那类误导。
#
# 用一个戳文件记下上次构建用的版本串,对不上就把**我们自己的**对象目录清掉。
#
# 为什么不用 `make clean`:那个会连 build/static-prefix 和 build/static-src 一起删
# (用户明确要的语义),而重建静态依赖要联网下载 + 编译好几分钟。为了一个版本号
# 字符串付那个代价不合理。所以这里只删自己的产物,精确地清。
VERSION_STAMP := $(BUILDDIR)/.version-stamp
# 戳文件的内容:版本号 + 哈希 + 构建日期。任意一项变了都要重编 ——
# 比如只切了 tag(版本号变)或只改了工作区(.dirty 出现)。
# 戳文件的内容:把**每一个**注入进二进制的字符串都算进去。任意一项变了都要
# 重编 —— 比如只切了 tag(版本号变)、只改了工作区(.dirty 出现)、只换了
# 分支(branch 变)。少算一项,那一项就会在二进制里过期而 make 毫无察觉,
# 正是这个机制要防的事。
#
# 注意 BUILD_TIME 不在这里:它每秒都在变,算进去等于每次 make 都全量重编。
# 代价是同一分钟内改了源码、二进制里的时间戳可能停在上一分钟 —— 那无所谓,
# 时间戳是"什么时候编的",不是"源码什么时候改的",而日期(BUILD_DATE)在,
# 粒度够用了。
VERSION_FINGERPRINT := $(LIGENT_VERSION)|$(LIGENT_GIT_HASH)|$(LIGENT_GIT_BRANCH)|$(LIGENT_BUILD_DATE)
# 用 $(shell) 在每个 make 里比对一次。
# 注意 $(if $(filter-out X,Y),...) 在 Y 为空时**恒为假** —— filter-out 一个
# 空列表得到空列表,什么都过滤不掉。所以这里必须显式处理"戳文件不存在"这一
# 情况(第一次构建就是它),否则戳文件永远写不出来,机制整个失效。
CURRENT_STAMP := $(shell cat $(VERSION_STAMP) 2>/dev/null)
ifeq ($(CURRENT_STAMP),)
NEED_VERSION_CLEAN := 1
else ifneq ($(CURRENT_STAMP),$(VERSION_FINGERPRINT))
NEED_VERSION_CLEAN := 1
else
NEED_VERSION_CLEAN :=
endif
ifeq ($(NEED_VERSION_CLEAN),1)
# 清掉再让 make 正常评估依赖 —— 这一步发生在**读 Makefile 的时候**,
# 所以后面的 $(OBJS) 会看到"对象不存在",自然全量重编。
# 在 $(shell) 里写文件是安全的:它只在真的需要清的时候跑一次。
$(shell rm -rf $(OBJDIR) $(BIN) && mkdir -p $(BUILDDIR) && printf '%s' '$(VERSION_FINGERPRINT)' > $(VERSION_STAMP))
endif
# $^ 里既有 .c 也有 .h(后者是为了依赖关系才加上的),真正要编译的只有 .c
SRCFILES = $(filter-out %.h,$^)
SRCS := $(wildcard $(SRCDIR)/*.c)
OBJS := $(patsubst $(SRCDIR)/%.c,$(OBJDIR)/%.o,$(SRCS))
.PHONY: all clean test test-unit test-e2e test-fuzz demo md_demo static static-check static-test check-docs analyze docker-build docker-extract docker-test docker-clean
all: $(BIN)
$(BIN): $(OBJS)
$(CC) $(CFLAGS) -o $@ $(OBJS) $(LDLIBS)
$(OBJDIR)/%.o: $(SRCDIR)/%.c $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
$(OBJDIR):
mkdir -p $(OBJDIR)
# 清掉**一切**构建产物:
# build/ 各 <编译器>[-san] 对象目录、build/static、测试可执行文件、
# e2e 的临时文件,以及 tools/build-static-deps.sh 编出来的
# static-prefix(静态库)和 static-src(源码 + 压缩包缓存)
# ligent / ligent-static
# tests/__pycache__ 跑测试时 python 生成的字节码
#
# 特意**不动** ligent.history:那不是构建产物,是你的输入历史,删了找不回来。
# (e2e 自己写的是 build/e2e.history,在 build/ 里,跟着一起清。)
clean:
rm -rf $(BUILDDIR) $(BIN) $(BIN)-static tests/__pycache__
# ------------------------------------------------------------------ */
# 静态构建:musl libc + 静态 libcurl */
# ------------------------------------------------------------------ */
#
# 目标是一个**完全静态**的可执行文件:不依赖宿主机的 libc、libcurl、
# OpenSSL。拷到任何同架构的 Linux 上就能跑。
#
# 为什么不能直接拿发行版的 libcurl.a 链接:那是针对 glibc 编的,和 musl 混在
# 一起会得到一堆 "undefined reference to __libc_start_main" 之类。必须有一份
# **用 musl 工具链编出来的**静态 libcurl。造它的办法见 tools/build-static-deps.sh。
#
# 用法:
# ./tools/build-static-deps.sh # 先把依赖编到 build/static-prefix
# make static # 然后链接
#
# 变量(都可覆盖):
# STATIC_CC 默认依次找 musl-gcc / x86_64-linux-musl-gcc
# STATIC_PREFIX 依赖所在的前缀(默认 build/static-prefix)
STATIC_CC ?= $(shell command -v musl-gcc 2>/dev/null || \
command -v x86_64-linux-musl-gcc 2>/dev/null)
STATIC_PREFIX ?= $(BUILDDIR)/static-prefix
STATIC_BIN := $(BIN)-static
# 静态目标用**独立的对象目录**:那边的 .o 是 musl 编的,和这边的 glibc 目标
# 文件混在一起会互相覆盖,症状是莫名其妙的重链接或不重链接。
STATIC_OBJDIR := $(BUILDDIR)/static
STATIC_OBJS := $(patsubst $(SRCDIR)/%.c,$(STATIC_OBJDIR)/%.o,$(SRCS))
static-check:
@test -n "$(STATIC_CC)" || { \
echo "找不到 musl 工具链。装一个,或者指定 STATIC_CC:"; \
echo " Artix/Arch : sudo pacman -S musl # 提供 musl-gcc"; \
echo " 或者用交叉工具链: make static STATIC_CC=x86_64-linux-musl-gcc"; \
exit 1; }
@test -d "$(STATIC_PREFIX)" || { \
echo "找不到静态依赖前缀 $(STATIC_PREFIX)。先跑:"; \
echo " ./tools/build-static-deps.sh"; \
exit 1; }
@test -x "$(STATIC_PREFIX)/bin/curl-config" || { \
echo "$(STATIC_PREFIX) 里没有 curl-config —— 那份 libcurl 不是在这个"; \
echo "前缀下编的,或者没编完。重跑 ./tools/build-static-deps.sh"; \
exit 1; }
$(STATIC_OBJDIR):
mkdir -p $(STATIC_OBJDIR)
# -I$(STATIC_PREFIX)/include:musl-gcc **不搜 /usr/include**,系统那份 curl 头
# 也不是给 musl 用的 —— 必须用我们这份 prefix 里的头。
STATIC_CPPFLAGS = -I$(STATIC_PREFIX)/include $(CPPFLAGS)
$(STATIC_OBJDIR)/%.o: $(SRCDIR)/%.c $(HEADERS) | $(STATIC_OBJDIR)
$(STATIC_CC) $(STATIC_CFLAGS) $(STATIC_CPPFLAGS) -c -o $@ $<
# 依赖列表交给 curl 自己报:它最清楚自己链了哪些库、什么顺序。
# 手写一串 -lssl -lcrypto -lz 是会过期的,而且顺序错了链接就失败。
static: static-check $(STATIC_OBJS)
@CURL_CONFIG="$(STATIC_PREFIX)/bin/curl-config"; \
LIBS="$$($$CURL_CONFIG --static-libs)" || exit 1; \
echo " 依赖: $$LIBS"; \
$(STATIC_CC) $(STATIC_CFLAGS) $(STATIC_CPPFLAGS) $(STATIC_LDFLAGS) \
-o $(STATIC_BIN) $(STATIC_OBJS) $$LIBS -pthread; \
echo " 产物: $(STATIC_BIN)"; \
file $(STATIC_BIN) 2>/dev/null || true; \
ldd $(STATIC_BIN) 2>&1 | head -2 || true
# -fno-pie / -no-pie:现代 gcc 默认编 PIE,而静态链接一份非 PIC 的 .a
# (我们那份 libcurl.a 就是 -fno-pie 编的)会报
# "relocation R_X86_64_32 against ... can not be used when making a PIE object"。
# 两头都关掉,省得踩。
STATIC_CFLAGS ?= $(CFLAGS) -fno-pie
STATIC_LDFLAGS ?= -static -no-pie
# ------------------------------------------------------------------ */
# 静态产物优先体积(make static STATIC_RELEASE=0 关掉) */
# ------------------------------------------------------------------ */
#
# 普通构建的取舍是"能调试",静态产物的取舍是"拷过去就能跑、越小越好",
# 所以这里单独一套开关。实测 6.23 MB → 4.85 MB(-22%),逐项收益:
#
# -s 4.92 MB 链接期 strip。**单项最大**:-g 带进来的调试信息
# (551 KB) 加上符号表,一共 1.31 MB —— 占了原产物的
# 五分之一,而静态发布件根本用不上。
# -ffunction-sections 4.91 MB 只对我们自己的 .o 生效,所以几乎没用
# -fdata-sections (收益来自后面 --gc-sections 能按函数/数据丢段;
# 依赖库的 .a 不是这么编的,见下面的"还能再小")
# -fno-*-unwind-tables 4.90 MB .eh_frame 本来 514 KB。C 代码不需要
# C++ 异常和栈回溯。
# -Os 4.86 MB 我们自己的代码优先体积
# -z noseparate-code 4.85 MB 不再为代码/数据段留页对齐空洞
# --build-id=none 少一个 note 段
# -g0 不生成调试信息(否则每个 .o 都白白大一截,
# 编译也慢,反正最后要 strip)
#
# 还能再小的地方(不在本文件范围):3.49 MB 的 .text 几乎全是 libcurl +
# OpenSSL。要动它得在 tools/build-static-deps.sh 里给依赖也加 -Os 和
# -ffunction-sections,让 --gc-sections 能真正丢进没被引用的 OpenSSL 代码。
STATIC_RELEASE ?= 1
ifeq ($(STATIC_RELEASE),1)
STATIC_CFLAGS += -Os -g0 -ffunction-sections -fdata-sections \
-fno-asynchronous-unwind-tables -fno-unwind-tables
STATIC_LDFLAGS += -Wl,--gc-sections -Wl,-z,noseparate-code \
-Wl,--build-id=none -s
endif
# ------------------------------------------------------------------
# 渲染效果演示
# ------------------------------------------------------------------
$(OBJDIR)/md_demo: tools/md_demo.c $(SRCDIR)/markdown.c $(SRCDIR)/util.c $(SRCDIR)/i18n.c | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
# 编译并直接跑起来看效果
demo: $(OBJDIR)/md_demo
@./$(OBJDIR)/md_demo
# 只编译,不运行
md_demo: $(OBJDIR)/md_demo
# ------------------------------------------------------------------
# 测试
# ------------------------------------------------------------------
#
# 测试与演示程序**复用主构建编出来的 .o**(只链它真正需要的那几个模块),而不是
# 每个测试各自把源文件重新编译一遍。
#
# 以前每条规则的依赖写的是源文件($(SRCDIR)/json.c 之类),recipe 是
# `$(CC) … -o $@ $(SRCFILES)` —— 于是同一个源文件会被编很多遍:只改 util.c 一行,
# 它会按依赖被重编 16 次,实测 `make test-unit` 要 25 秒,而真正的工作量只有
# "编一个 util.o + 重链 17 个二进制"。现在依赖写成 .o:编译一次、链接多次,
# 同样的场景降到几秒。而这些 .o 主构建本来就要用,等于没有额外成本。
#
# 代价:测试的依赖里多了一层(.o 是否存在/是否过期由主构建的规则负责),排查
# "为什么这个测试用的还是旧代码"要看 .o 的时间戳而不是源文件。$(SRCFILES) 仍然
# 有意义 —— $^ 里混着 $(HEADERS),而头文件不能上链接行。
$(OBJDIR)/test_json: tests/test_json.c $(OBJDIR)/json.o $(OBJDIR)/util.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_tools: tests/test_tools.c $(OBJDIR)/tools.o $(OBJDIR)/json.o \
$(OBJDIR)/util.o $(OBJDIR)/http.o $(OBJDIR)/spinner.o \
$(OBJDIR)/image.o $(OBJDIR)/priv.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_agent: tests/test_agent.c $(OBJDIR)/agent.o $(OBJDIR)/llm.o \
$(OBJDIR)/json.o $(OBJDIR)/util.o $(OBJDIR)/http.o \
$(OBJDIR)/tools.o $(OBJDIR)/markdown.o \
$(OBJDIR)/spinner.o $(OBJDIR)/confirm.o \
$(OBJDIR)/image.o $(OBJDIR)/capability.o \
$(OBJDIR)/archive.o \
$(OBJDIR)/priv.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_config: tests/test_config.c $(OBJDIR)/config.o $(OBJDIR)/json.o \
$(OBJDIR)/util.o $(OBJDIR)/http.o \
$(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_meta: tests/test_meta.c $(OBJDIR)/meta.o $(OBJDIR)/agent.o \
$(OBJDIR)/json.o $(OBJDIR)/util.o $(OBJDIR)/http.o \
$(OBJDIR)/llm.o $(OBJDIR)/lineedit.o \
$(OBJDIR)/tools.o $(OBJDIR)/markdown.o \
$(OBJDIR)/spinner.o $(OBJDIR)/confirm.o \
$(OBJDIR)/image.o $(OBJDIR)/capability.o \
$(OBJDIR)/archive.o \
$(OBJDIR)/priv.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_priv: tests/test_priv.c $(OBJDIR)/priv.o $(OBJDIR)/util.o \
$(OBJDIR)/http.o $(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_attach: tests/test_attach.c $(OBJDIR)/attach.o \
$(OBJDIR)/image.o $(OBJDIR)/json.o $(OBJDIR)/util.o \
$(OBJDIR)/lineedit.o $(OBJDIR)/http.o \
$(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_models: tests/test_models.c $(OBJDIR)/models.o \
$(OBJDIR)/json.o $(OBJDIR)/util.o \
$(OBJDIR)/http.o $(OBJDIR)/spinner.o $(OBJDIR)/config.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_capability: tests/test_capability.c $(OBJDIR)/capability.o \
$(OBJDIR)/json.o $(OBJDIR)/util.o \
$(OBJDIR)/http.o $(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_image: tests/test_image.c $(OBJDIR)/image.o $(OBJDIR)/json.o \
$(OBJDIR)/util.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_i18n: tests/test_i18n.c $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_spinner: tests/test_spinner.c $(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_markdown: tests/test_markdown.c $(OBJDIR)/markdown.o \
$(OBJDIR)/util.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_http: tests/test_http.c $(OBJDIR)/http.o $(OBJDIR)/util.o \
$(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_archive: tests/test_archive.c $(OBJDIR)/archive.o $(OBJDIR)/json.o \
$(OBJDIR)/util.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/test_lineedit: tests/test_lineedit.c $(OBJDIR)/lineedit.o \
$(OBJDIR)/util.o $(OBJDIR)/http.o \
$(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
$(OBJDIR)/fuzz: tests/fuzz.c $(OBJDIR)/json.o $(OBJDIR)/markdown.o \
$(OBJDIR)/util.o $(OBJDIR)/i18n.o | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
# 单元测试清单:加一个测试只要改这里 + 它自己那条依赖规则
UNIT_TESTS := test_json test_tools test_agent test_config test_markdown \
test_http test_lineedit test_archive test_meta test_image \
test_capability test_priv test_attach test_models test_spinner \
test_i18n
test-unit: $(addprefix $(OBJDIR)/,$(UNIT_TESTS)) $(OBJDIR)/md_demo $(OBJDIR)/fuzz
@export LIGENT_LANG=zh; for t in $(UNIT_TESTS); do \
case $$t in \
test_json) label="JSON" ;; \
test_tools) label="工具" ;; \
test_agent) label="agent 上下文" ;; \
test_config) label="配置" ;; \
test_markdown) label="Markdown 渲染" ;; \
test_http) label="HTTP 层" ;; \
test_lineedit) label="行编辑器" ;; \
test_archive) label="存档" ;; \
test_meta) label="meta 历史编辑" ;; \
test_image) label="图片" ;; \
test_capability) label="能力探测" ;; \
test_priv) label="提权" ;; \
test_attach) label="/include 暂存与展开" ;; \
test_models) label="模型列表解析" ;; \
test_spinner) label="等待动画" ;; \
test_i18n) label="界面文案" ;; \
*) label="$$t" ;; \
esac; \
echo "==== $$label 测试($$t)===="; \
./$(OBJDIR)/$$t || exit 1; \
echo; \
done
@echo "==== 演示程序分片自检 ===="
@./$(OBJDIR)/md_demo --compare < /dev/null
@echo
@echo "==== 颜色审计(只用 16 色内的 SGR) ===="
@./tools/check-colors.sh
@echo
@echo "==== 模糊测试(每轮固定种子,可复现) ===="
@./$(OBJDIR)/fuzz 4000
test-e2e: $(BIN)
@bash tests/run_e2e.sh
# ------------------------------------------------------------------ */
# make check-docs —— README 里写的测试数量必须和现实一致 */
# ------------------------------------------------------------------ */
#
# README 里引用了"831 单元 + 314 e2e"这样的数字。数字会随着加测试而过期,
# 而过期的数字比不写更糟 —— 它是**错的信息**,读的人没有理由怀疑它。
# (这件事就发生过:加完 JSON 的 UTF-8 测试后,README 还写着 797。)
#
# 所以把"文档说的"和"实际跑出来的"对一遍。不做成 make test 的一部分:
# 它要跑一遍单元测试来数数,比 test-unit 本身还慢。
check-docs: $(addprefix $(OBJDIR)/,$(UNIT_TESTS)) $(BIN)
@unit=0; \
for t in $(UNIT_TESTS); do \
n=$$(./$(OBJDIR)/$$t 2>/dev/null | grep -oE '^[0-9]+ 项检查' | grep -oE '^[0-9]+'); \
unit=$$((unit + $${n:-0})); \
done; \
e2e=$$(bash tests/run_e2e.sh 2>/dev/null | grep -oE '通过 [0-9]+ 项' | grep -oE '[0-9]+' | tail -1); \
unit_doc=$$(grep -oE '([0-9]+ 单元' README.md | grep -oE '[0-9]+' | head -1); \
unit_docs=$$(grep -oE '[0-9]+ 个可执行、[0-9]+ 项' README.md | grep -oE '[0-9]+ 项' | grep -oE '[0-9]+' | head -1); \
e2e_doc=$$(grep -oE '[0-9]+ e2e' README.md | grep -oE '[0-9]+' | head -1); \
bad=0; \
if [ "$$unit" != "$$unit_doc" ]; then \
echo " ✗ 单元测试:README 说 $$unit_doc,实际 $$unit"; bad=1; \
else echo " ✓ 单元测试 $$unit 项,与 README 一致"; fi; \
if [ "$$unit" != "$$unit_docs" ]; then \
echo " ✗ SAN 那节:README 说 $$unit_docs,实际 $$unit"; bad=1; \
else echo " ✓ SAN 那节的数量一致"; fi; \
if [ "$$e2e" != "$$e2e_doc" ]; then \
echo " ✗ e2e:README 说 $$e2e_doc,实际 $$e2e"; bad=1; \
else echo " ✓ e2e $$e2e 项,与 README 一致"; fi; \
if [ $$bad -eq 0 ]; then echo "文档里的数量与现实一致。"; \
else echo "README 里的数字已过期,请更新(数字对不上就是错的信息)。"; exit 1; fi
# 拿同一套 e2e 打静态产物。以前"静态构建可用"实际上只等于"它能启动" ——
# 依赖脚本的自检只做 curl_easy_init + setopt,连一个请求都不发;现在这 300
# 多项跑一遍,才真的说明它是可用的。
# 单独一个目标、不进 make test:要先编依赖,慢。
static-test: static
@BIN="$(abspath $(STATIC_BIN))" bash tests/run_e2e.sh
# ------------------------------------------------------------------ */
# 容器里构建静态产物(CI/CD 用) */
# ------------------------------------------------------------------ */
#
# podman 和 docker 命令行兼容,默认用 podman(这个项目在 rootless 环境下开发)。
# 用 CONTAINER=... 换。
CONTAINER ?= podman
CONTAINER_IMAGE ?= ligent-static
# 版本号显式传给容器:容器里没有 .git,get-version.sh 只能回退到源码 mtime,
# 那得到的是"文件日期"而不是"tag 日期"。这里在本机算好传进去。
# GIT_HASH / GIT_BRANCH 同理 —— 二进制里的分支名应当反映**源码来自哪个分支**,
# 而不是"在容器里看不到分支所以是 unknown"。$(VERSION_SCRIPT) 见上面。
CONTAINER_VERSION := $(LIGENT_VERSION)
CONTAINER_GIT_HASH := $(LIGENT_GIT_HASH)
CONTAINER_GIT_BRANCH := $(LIGENT_GIT_BRANCH)
# 传**完整时间戳**而不是只有日期:Dockerfile 里拿它当 SOURCE_DATE_EPOCH,
# 而 "2026-09-22" 被 date(1) 解析出来是那天的 00:00:00 UTC —— 于是容器产物
# 的 --version 里时间永远显示午夜。传秒数就没有这个歧义,而且仍然可复现。
# 设了 SOURCE_DATE_EPOCH 的话就沿用它(那正是"可复现构建"要求的:同一份
# 源码无论何时构建都得到同样的时间戳)。
CONTAINER_BUILD_EPOCH := $(if $(SOURCE_DATE_EPOCH),$(SOURCE_DATE_EPOCH),$(shell date -u +%s))
docker-build:
$(CONTAINER) build \
--build-arg VERSION='$(CONTAINER_VERSION)' \
--build-arg GIT_HASH='$(CONTAINER_GIT_HASH)' \
--build-arg GIT_BRANCH='$(CONTAINER_GIT_BRANCH)' \
--build-arg BUILD_EPOCH='$(CONTAINER_BUILD_EPOCH)' \
-t $(CONTAINER_IMAGE) .
# 把产物从镜像里拷出来。用 create+cp+rm 而不是 build 到本地:
# 镜像是分发单位,本地文件是给人直接用的,两者都要,但不必让 Dockerfile
# 同时干这两件事(那需要挂载点或者 --output,podman/docker 的支持还不齐)。
docker-extract: docker-build
@$(CONTAINER) rm -f $(CONTAINER_IMAGE)-tmp >/dev/null 2>&1 || true
@$(CONTAINER) create --name $(CONTAINER_IMAGE)-tmp $(CONTAINER_IMAGE) >/dev/null
@$(CONTAINER) cp $(CONTAINER_IMAGE)-tmp:/ligent $(STATIC_BIN)
@$(CONTAINER) rm -f $(CONTAINER_IMAGE)-tmp >/dev/null
@echo " 产物: $(STATIC_BIN) ($$(stat -c%s $(STATIC_BIN) 2>/dev/null || echo '?') 字节)"
@file $(STATIC_BIN) 2>/dev/null || true
# 容器产物也要过一遍 e2e —— 和 static-test 一个道理:能跑起来不等于能用。
docker-test: docker-extract
@BIN="$(abspath $(STATIC_BIN))" bash tests/run_e2e.sh
docker-clean:
-@$(CONTAINER) rmi -f $(CONTAINER_IMAGE) >/dev/null 2>&1 || true
# 对抗性模糊测试:单独一个目标,跑得久一些
# 对抗性模糊测试:单独一个目标,轮数比 test-unit 里那个底线大得多
test-fuzz: $(OBJDIR)/fuzz
@echo "==== 模糊测试(20 万轮)===="
@./$(OBJDIR)/fuzz 200000
# `make -j test` 会让单元测试与 e2e 并行,输出互相穿插、也互相抢资源 ——
# 这两个目标内部本来就是逐条跑的,没有并行的必要。
.NOTPARALLEL: test test-unit test-e2e
test: test-unit test-e2e
@echo
@echo "全部测试通过。"
# 依赖统一由 $(HEADERS) 管:每个 .o 和每个测试都以全部头文件为依赖。
# 不精确,但一共只有十几个文件、全量重编几秒钟,换来的是「永远不会漏」。
# 原先这里还有一段手写依赖表,既冗余又不完整(上次 agent.h 漏更新就是它
# 没跟上),已删。
$(OBJDIR)/reduce: tests/reduce.c $(OBJDIR)/markdown.o $(OBJDIR)/util.o \
$(OBJDIR)/i18n.o | $(OBJDIR)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS)
# ------------------------------------------------------------------ */
# make analyze —— gcc 静态分析器(-fanalyzer) */
# ------------------------------------------------------------------ */
#
# 它抓的是单靠警告看不出来的那类问题:读未初始化的值、空指针、资源泄漏。
# 第一次跑就捞出两个真 bug(attempt() 里 curl_easy_init 失败那条提前返回
# 会把未初始化的 cerr 当字符串读;POST 分支对 body==NULL 不设防)。
#
# 用 -O0 而不是平时的 -O2:开了优化之后 gcc 会内联,而 -fanalyzer 的跨函数
# 状态在遇到内联时并不可靠 —— image.c 里的 rand_suffix 在 -O1/-O2/-Os 下会
# 报一条 "leak of FILE 'f'",而 -O0 下干净,fclose 也确实就写在 fread 下一行。
# 分析器自己的分析是跨函数的,不依赖 -O 级别,所以 -O0 不会少查什么。
# gcc 文档同样建议 -fanalyzer 配 -O0。
#
# 单独一个目标、不进 make test:它比整个测试套件加起来还慢。
analyze:
@fail=0; \
for f in $(SRCS); do \
$(CC) $(CFLAGS) -O0 -fanalyzer -Werror $(CPPFLAGS) -c "$$f" -o /dev/null || fail=1; \
done; \
if [ $$fail -eq 0 ]; then \
echo "静态分析通过:无告警。"; \
else \
echo "静态分析发现问题(上面的 warning: 就是原因)。"; exit 1; \
fi
# ------------------------------------------------------------------ */
# 为什么对象目录要带标签(build/gcc、build/clang、build/gcc-san …) */
# ------------------------------------------------------------------ */
#
# make 只按"源文件比目标文件新"决定要不要重编,它**看不见命令行变量变了**。
# 于是有两类事故:
#
# 1) make CC=tcc 之后再 make CC=clang —— 只有被 touch 过的那几个 .o 是 clang
# 编的,其余还是 tcc 编的。tcc 的目标文件要 tcc 自己的运行时辅助例程
# (__floatundidf 之类),拿 clang 去链接就是一堆 undefined reference。
# 我自己的构建就是这么炸的,而且第一次还因为只用 ls 看文件大小而误判成
# "clang 编译通过"。
#
# 2) 更要命的一类:make 之后再 make SAN=1 —— 一个 .o 都不会重编。
# 链接行带着 -fsanitize=...,ASan 运行时也进了二进制,于是它**看起来**
# 是插桩过的;实际 nm 里一个 __asan/__ubsan 符号都没有,什么都不会检测。
# 这意味着"没先 make clean 的 ASan 结论全是假绿灯"。
#
# 把标签写进路径之后这两种混用都不可能发生了:配置一变,对应的目录就是空的,
# 该编的自然会全编。全量重编只要几秒,值得。
#
# 注意 STATIC_PREFIX / STATIC_OBJDIR 挂在 $(BUILDDIR) 上而不是 $(OBJDIR) 上:
# build/static-prefix 是 tools/build-static-deps.sh 写死的位置,不能被标签带跑。