get-shit-done 中的 graphify 知识图谱新鲜度检测:commit_stale 三态信号与参数注入防护设计(3170)
本文基于 get-shit-done 仓库中 #3170 的 changeset 记录,完整解析 /gsd-graphify status 如何从 graphify v0.7+ 图谱的 built_at_commit 字段构建 commit 级陈旧度信号:包括 graphifyStatus() 的四字段返回契约、三态 commit_stale 的语义边界、built_at_commit 的 4–40 位十六进制校验如何阻断 git argv 参数注入,以及面向多开发者的 graphify hook install 配套方案。读完后你可以直接验证图谱是否构建于当前 HEAD,并理解该信号在 mtime 与 commit 双通道陈旧度体系中的定位与回退规则。
特性定位:两种相互正交的陈旧度信号
get-shit-done 通过 gsd-graphify 命令 对项目 .planning/graphs/ 下的知识图谱(graph.json)进行构建、查询与状态检查。在 #3170 之前,/gsd-graphify status 只有一种基于文件修改时间(mtime)的陈旧度判断:图谱文件超过 24 小时未重写即视为 stale: true。这个信号有一个盲区——CI 上几分钟前刚重建、但构建自旧检出的图谱,mtime 上完全"新鲜",其内容却可能早已落后于当前代码。
#3170 在 mtime 信号之外补上了 commit 级信号:graphify v0.7+ 在构建时会把当时的提交哈希写入 graph.json 的 built_at_commit 字段,GSD 侧的 graphifyStatus() 读取它并与当前 git HEAD 对比,从而回答一个 mtime 无法回答的问题:这张图谱是从哪个提交构建的?距离现在落后了多少个提交?
两个信号测的是不同的东西,可能给出矛盾结论(例如 CI 构建的图谱 mtime 显示 FRESH 但 commit_stale: true),因此 commands/gsd/graphify.md 明确要求同时呈现两者,交由 Agent 自行判断。
接口契约:graphifyStatus() 新增的四个字段
按 changeset(.changeset/3170-graphify-commit-staleness.md)与 docs/FEATURES.md 第 130 节 的定义,graphifyStatus() 返回以下新字段(仅对 graphify v0.7+ 图谱有效):
| 字段 | 类型 | 说明 |
|---|---|---|
built_at_commit |
string | null | 图谱构建来源的提交 SHA(输出为前 7 位短哈希) |
current_commit |
string | null | 当前 git HEAD(前 7 位短哈希) |
commits_behind |
number | null | 图谱落后 HEAD 的提交数 |
commit_stale |
boolean | null | 三态:true=陈旧,false=最新,null=信号不可用 |
其中 commit_stale 的三态语义是本特性的关键设计:
true:图谱构建提交可达且落后 HEAD 至少 1 个提交;false:图谱恰好构建于 HEAD(commits_behind === 0);null:信号不可用——pre-v0.7 图谱(无built_at_commit)、非 git 检出、或构建提交在当前仓库中不可达(如被 rebase 丢弃)。
null 不等于 false,它是明确的"我不知道"。changeset 要求所有调用方在遇到 null 时回退到既有的 mtime 信号,因此对现有用户零行为变更。
源码实现:graphifyStatus() 的完整调用链
实现位于 get-shit-done/bin/lib/graphify.cjs。整个 commit 陈旧度逻辑集中在 graphifyStatus()(第 385–442 行),可拆成四步:
1. 入口守卫与图谱读取
函数先走配置门(isGraphifyEnabled 检查 .planning/config.json 中 graphify.enabled === true),再读取 .planning/graphs/graph.json;图谱不存在时返回 { exists: false, ... },JSON 解析失败时返回 { error: 'Failed to parse graph.json' },均不涉及 commit 信号。
2. 十六进制围栏:先校验,再交给 git
// get-shit-done/bin/lib/graphify.cjs (L344-L349)
const COMMIT_HASH_RE = /^[0-9a-f]{4,40}$/i;
const rawBuilt = (graph.built_at_commit || '').toString().trim();
const builtAt = COMMIT_HASH_RE.test(rawBuilt) ? rawBuilt : null;
built_at_commit 来自外部写入的 graph.json,属于不可信输入。它唯一的用途是拼进 git rev-list --count <from>..<to> 的 argv,因此必须确保它不可能以 -- 开头的形态进入 git 参数列表——一个恶意的 graph.json 若写入 --upload-pack=…,就能向 git 注入任意选项。COMMIT_HASH_RE 强制 4–40 位纯十六进制,任何带连字符、空格或散文的值都被直接视为"字段缺失"(置 null),而非透传。源码注释原文明确了这一意图:"Strict 4-40 hex fence … so a hostile graph.json cannot smuggle a --upload-pack=… option into a git argv."
注意区分两种"字段缺失":
- 格式非法(如
--upload-pack=evil):built_at_commit输出为null,不回声该值; - 格式合法但不可达(如 rebase 后失效的幽灵哈希):
built_at_commit仍回声短哈希,commits_behind与commit_stale为null,把判断权留给调用方。
3. 读取 HEAD 与提交计数
function readGitHead(cwd) {
const r = execGit(['rev-parse', 'HEAD'], { cwd });
if (r.exitCode !== 0) return null;
return r.stdout.trim() || null;
}
function countCommitsBetween(cwd, from, to) {
const r = execGit(['rev-list', '--count', `${from}..${to}`], { cwd });
if (r.exitCode !== 0) return null;
const n = parseInt(r.stdout.trim(), 10);
return Number.isFinite(n) ? n : null;
}
从源码结构看,execGit 以参数数组形式调用(来自 shell-command-projection.cjs),不存在字符串拼接 shell 的路径;加上前置的十六进制围栏,from/to 两个引用在语法上就不可能携带选项。计数失败(任一引用不可达、非 git 仓库、git 不在 PATH)返回 null,对应 commit_stale 保持 null。
核心推导只有三行:
if (builtAt && head) {
commitsBehind = countCommitsBetween(cwd, builtAt, head);
if (commitsBehind !== null) commitStale = commitsBehind > 0;
}
4. 返回结构
return {
exists: true,
last_build: stat.mtime.toISOString(),
node_count: (graph.nodes || []).length,
edge_count: (graph.edges || graph.links || []).length,
hyperedge_count: (graph.hyperedges || []).length,
stale: age > STALE_MS || Boolean(autoUpdateStale), // mtime 通道(24h 窗口)
age_hours: Math.round(age / (60 * 60 * 1000)),
built_at_commit: builtAt ? builtAt.slice(0, 7) : null,
current_commit: head ? head.slice(0, 7) : null,
commits_behind: commitsBehind,
commit_stale: commitStale,
last_build_auto_update: lastBuildAutoUpdate || null,
};
两个通道并存于同一返回对象:stale 是 mtime 通道(STALE_MS = 24h,并叠加自动重建失败/进行中的状态),commit_stale 是 commit 通道,两者互不替代。
命令行用法与渲染规则
在启用 graphify 的项目中执行:
node $HOME/.claude/get-shit-done/bin/gsd-tools.cjs graphify status
或直接在 Claude Code 中运行 /gsd-graphify status。commands/gsd/graphify.md 的 Step 2b 定义了 Source commit: 行的渲染规则:
| 条件 | 渲染 |
|---|---|
commit_stale === false |
Source commit: <built_at_commit> (current) |
commit_stale === true |
Source commit: <built_at_commit> (<commits_behind> commits behind HEAD) |
commit_stale === null(提交不可达/无 git) |
Source commit: <built_at_commit> (freshness unknown) |
built_at_commit === null(pre-v0.7 图谱) |
整行省略,不渲染 "Source commit: unknown" |
docs/CONFIGURATION.md 的 "Commit-based staleness" 小节进一步强调:回答架构相关问题时应同时呈现两个信号,因为 CI 刚重建的旧检出图谱会出现 "mtime FRESH 但 commit_stale: true" 的组合。
测试证据:边界场景全覆盖
回归测试位于 tests/graphify-visualization.test.cjs(staleness describe 块,文件头标注 "Regression for #3170"),覆盖了 changeset 承诺的全部语义边界:
- 构建于 HEAD:
commits_behind=0, commit_stale=false,短哈希正确截断为 7 位; - 落后 5 个提交:先记录构建哈希、连续空提交 5 次,断言
commits_behind=5, commit_stale=true,且current_commit反映 HEAD 而非构建提交; - pre-v0.7 图谱(无
built_at_commit):四个新字段全部为null,断言注释明确 "null means 'we do not know', not 'fresh'"; - rebase 后失效的构建哈希(幽灵哈希
000…001):built_at_commit回声短哈希,commits_behind=null, commit_stale=null; - 参数注入围栏:
built_at_commit: '--upload-pack=evil'被拒绝且不回声(built_at_commit=null),恶意值永远到不了 git; - 非 git 目录:
built_at_commit仍回声(图谱字段本身可信),current_commit/commits_behind/commit_stale全为null。
测试使用 helpers.cjs 的 createTempGitProject / commitEmpty 在临时 git 仓库中构造真实提交历史,因此计数逻辑是打真实 git 验证的,而非 mock。
配套文档:graphify hook install 与多开发者场景
changeset 的另一半内容落在 docs/CONFIGURATION.md 的 "Multi-developer setup" 小节:多人同仓并行重建图谱时,graph.json 的并发写入会产生合并冲突。解决方案是在启用 graphify 后、每个 clone 里执行一次:
graphify hook install
该命令安装一个 git merge driver 对并发的 graph.json 写入做并集合并(knowledge graph 中出现无冲突标记的并集结果),并注册 post-commit 重建 hook;它写入 .gitattributes、在 .git/config 注册 graphify merge-driver。单人项目可以跳过,执行也无害。此机制随上游 graphify v0.7.0 引入,与 built_at_commit 新鲜度信号同源——v0.7 既是 commit 信号的起点,也是并行重建治理的起点。
相关配置项(同文档 Graphify Settings 表):
| 设置 | 类型 | 默认 | 说明 |
|---|---|---|---|
graphify.enabled |
boolean | false |
启用项目知识图谱,true 时 /gsd-graphify 在 .planning/graphs/ 构建并查询图谱 |
graphify.build_timeout |
number (秒) | 300 |
/gsd-graphify build 允许的最大耗时 |
graphify.auto_update |
boolean | false |
启用后由 hooks/gsd-graphify-update.sh 在默认分支提交后以分离后台进程自动重建图谱,并写 .planning/graphs/.last-build-status.json;status 中的 last_build_auto_update 字段即来源于此文件 |
小结
#3170 用一个外部字段(graphify v0.7+ 写入的 built_at_commit)补全了 GSD 图谱新鲜度检测的最后一个盲区:图谱内容与提交历史的对应关系。实现上有三处值得借鉴的克制设计——
- 三态而非布尔:
commit_stale: null把"不可知"与"已知最新"严格区分,调用方可无损回退到 mtime 通道,升级零行为变更; - 不可信输入先围栏后使用:4–40 位十六进制正则挡在 git 调用之前,使参数注入在类型层面不可能发生,且有专门的回归测试锁定;
- 双信号并存而非替换:mtime 与 commit 通道各测一件事、可同时呈现并给出矛盾时的判断指引(见 CHANGELOG.md 与 docs/RELEASE-v1.41.0.md 中的对应发布记录)。
在启用 graphify 的项目中,一次 /gsd-graphify status 即可同时看到 mtime 与 commit 两个维度的新鲜度;多人仓库再配合一次 graphify hook install,即可让并行重建不再触发 graph.json 冲突。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00