get-shit-done 项目根目录解析模块统一:CJS 与 SDK 双运行时 `findProjectRoot` 的单一事实来源落地解析
导读:本篇文章聚焦 get-shit-done(下称 GSD)仓库中一次典型的"双运行时消除重复实现"重构——changeset
.changeset/curious-bears-zip.md(PR #3554)将 CJS 工具层与 SDK 层中两套重复的findProjectRoot实现收敛为单一共享模块。读者可以借此掌握:GSD 在多仓库(multi-repo)工作区中如何从任意子目录定位.planning/项目根的四条启发式规则、generator 代码生成 + CI 新鲜度校验如何从机制上根除 CJS↔SDK 行为漂移,以及一次真实重构中如何"规范化"两个历史遗留差异的取舍思路。
一、背景:为什么一份 67 行的函数要变成"一个源码 + 一个生成物"
GSD 同时维护两套运行入口:get-shit-done/bin/gsd-tools.cjs(CJS CLI 工具层,同步文件系统 I/O)与 sdk/ 下的 TypeScript SDK(gsd-sdk query,异步 I/O 与观测装饰器)。两者共享同一套 .planning/ 工作区语义,其中"从当前目录向上找到拥有 .planning/ 的项目根"这一能力属于典型的 Shared Module。
在本次变更落地前,这一能力存在两份重复实现:
- CJS 侧:约 67 行,位于
get-shit-done/bin/lib/core.cjs第 74–140 行附近; - SDK 侧:约 94 行,位于
sdk/src/query/helpers.ts第 497–590 行附近。
curious-bears-zip.md 记录的核心动作,正是把这两份重复实现替换为单一共享模块 sdk/src/project-root/index.ts,再通过仓库既有的 generator 模式向 CJS 侧发射一份镜像产物 get-shit-done/bin/lib/project-root.generated.cjs。该变更关闭了跟踪 issue #3553。
1.1 重复实现为什么是"结构性 bug 温床"
仓库中描述 CJS↔SDK 硬接缝(hard seam)的 ADR 文档 docs/adr/3524-cjs-sdk-hard-seam.md 给出了这一判断的实证:仓库反复出现过一类 "drift bug"(#1535、#1542、#2047/#2052、#2638/#2655、#2653/#2670、#2687/#2706、#2798/#2816、#3055/#3116、#3523),共同模式是"修复只落在一侧,另一侧没跟上"。
ADR 提出的 Shared-Module Source Policy 正是应对之策,其核心约束可以概括为四条:
- 唯一一份手写事实来源(source of truth):有行为逻辑的模块放在
sdk/src/<module-name>/(TypeScript);纯数据模块放在sdk/shared/<module-name>.manifest.json; - 只允许生成的产物:CJS 侧文件统一为
get-shit-done/bin/lib/<module-name>.generated.cjs,由脚本机械发射,永不手改; - 每个模块配 CI 新鲜度检查:
sdk/scripts/check-<module>-fresh.mjs重新运行 generator,若产物与已提交版本不一致则构建失败; - 禁止"手写同步对"(hand-synced pair):
scripts/lint-shared-module-handsync.cjs在合并前扫描,凡是不带.generated.后缀却又与 SDK 源码同名同构的 CJS 文件一律拒绝(除非显式 allow-list)。
Project-Root Resolution Module 正是在该 ADR 的 Phase 4 规划中落地的共享模块,CONTEXT.md 中也有对应的模块登记条目(见 CONTEXT.md)。
二、四启发式解析逻辑:一次自底向上的目录行走
2.1 核心算法(源码实读)
统一后的事实来源位于 sdk/src/project-root/index.ts,核心导出是 findProjectRoot(startDir: string): string。算法骨架如下(节选其主循环,完整实现见文件):
export const FIND_PROJECT_ROOT_MAX_DEPTH = 10;
export function findProjectRoot(startDir: string): string {
// 1. 若 startDir 自身就含 .planning/,它本身就是项目根(#1362 守卫)
// 2. 否则自底向上行走祖先链,对每个祖先执行 isInsideGitRepo 探测
// 3. 对每个 candidate parent,依次尝试四条启发式
// 4. 命中即返回 parent;走完仍不命中则返回 startDir 本身
...
}
四条启发式的判定顺序(从源码第 34–49 行的注释可以精确对应)为:
| 序号 | 启发式 | 判定条件 | 返回 |
|---|---|---|---|
| 0 | 自身 .planning/ 守卫(#1362) |
startDir/.planning 存在且为目录 |
原样返回 startDir |
| 1 | 父级 sub_repos 遍历 |
父级 .planning/config.json 的 sub_repos(或 planning.sub_repos)列表包含起始目录相对父级的顶层路径段 |
返回该父级 |
| 2 | 遗留 multiRepo: true |
config.json 中 multiRepo === true 且起始目录处于某个 git 仓库内 |
返回该父级 |
| 3 | .git 祖先 + 父级 .planning/ 兜底 |
父级有 .planning/,且起始目录到该候选父级之间存在 .git 祖先 |
返回该父级 |
关键实现细节同样值得留意:
- 深度上限:行走循环以
depth < FIND_PROJECT_ROOT_MAX_DEPTH(=10)为界,且parent === home时立即中止——绝不越过用户主目录向上扫描; config.json缺失或损坏不致命:读文件与JSON.parse被包在try/catch中,解析失败会静默落到第 3 条.git兜底启发式;- I/O 全部为同步调用:模块头部注释明确标注 "Sync I/O",保证 CJS 侧同步执行模型无需引入异步封装;
- 文件系统错误一律吞掉:不可读目录会在该层级终止行走,保证工具在权限异常环境下仍能给出确定性结果。
2.2 行为语义:从"最深子目录"也能回到父根
启发式 1 是子仓库场景的关键。当用户位于 workspace/child/src/utils 这类深度嵌套目录,而 workspace/.planning/config.json 声明了 sub_repos: ["child"] 时,算法会计算 relative(parent, resolvedStart) 取顶层段 child,与列表比对命中后返回 workspace。
而启发式 0(#1362 守卫)保证嵌套项目不会被误提升:如果 child 自己拥有 .planning/,那么从 child 内部任意位置出发都只会解析到 child,而不会越过它上升到外层 workspace——GSD 支持"项目套项目"的工作区组织而不互相污染。
三、Generator 模式:如何把一份 TS 变成字节一致的 CJS
统一的关键在于"不再手写第二份实现"。CJS 镜像产物由生成器 sdk/scripts/gen-project-root.mjs 产出,其发射策略很有参考价值:
- 编译后取函数体:先构建 TS 得到
sdk/dist/project-root/index.js,用Function.prototype.toString()捕获findProjectRoot的源码字符串; - CJS 闭包注入依赖:由于 ESM 版本使用的是具名解构导入(
dirname、resolve、sep、relative、parsePath、existsSync等),生成器在产物文件顶部写入一段 preamble,把这些依赖作为模块级常量定义,使函数体可直接以闭包变量方式引用:const { existsSync, readFileSync, statSync } = fs; const { dirname, resolve, sep, relative, parse: parsePath } = path; const { homedir } = os; const FIND_PROJECT_ROOT_MAX_DEPTH = 10; - 注入不可编辑的 banner:产物以 "GENERATED FILE — DO NOT EDIT" 头开始,标明事实来源路径与重新生成命令;
- 写入固定目标:
get-shit-done/bin/lib/project-root.generated.cjs,最后module.exports = { findProjectRoot }。
生成产物 get-shit-done/bin/lib/project-root.generated.cjs 与 TS 事实来源的函数体字节级一致——这正是 changeset 中 "byte-identical across runtimes" 的含义。
配套的 npm 脚本定义在 sdk/package.json:
"gen:project-root": "npm run build && node scripts/gen-project-root.mjs",
"check:project-root-fresh": "npm run build && node scripts/check-project-root-fresh.mjs"
check:project-root-fresh.mjs 即 CI 新鲜度闸门:它重新生成并比对产物,任何对生成文件的"直接手改"或"改了事实来源却不重新生成"都会在 CI 上失败。
四、本次规范化的两个 CJS↔SDK 历史差异
changeset 明确记录了在此次统一中,发现并收敛了两个预先存在的行为漂移,最终都以 SDK 行为为基准(因为 "the SDK behaviour IS the ground truth",这一立场同样写在 sdk/src/project-root/index.test.ts 头部注释中):
| 差异点 | 旧 CJS 行为 | 旧 SDK 行为 | 统一后(以 SDK 为准) |
|---|---|---|---|
| 向上行走上限 | 无界,可能一路扫到文件系统根 | FIND_PROJECT_ROOT_MAX_DEPTH = 10 |
统一为深度 10,CJS 侧收敛 |
读取 .planning/config.json |
经 platformReadSync |
原始 readFileSync |
统一为原始 readFileSync |
第二个差异在语义上是等价的:因为两边的 try/catch 都吞掉了两种失败模式(platformReadSync 与 readFileSync 在配置缺失/不可读时的最终效果一致),所以收敛是安全的。第一个差异则是真实的行为收紧:深度超过 10 的病理目录不再能发现祖先 .planning/,测试 sdk/src/project-root/index.test.ts 用 12 层深路径显式钉死了这一行为。
五、调用链:改动落到了哪些消费者身上
统一后,两边的调用方都只依赖薄薄一层 re-export,不再各自持有实现:
CJS 侧(消费生成产物):
- get-shit-done/bin/lib/core.cjs 第 28 行
const { findProjectRoot } = require('./project-root.generated.cjs'),并在模块导出表中对外 re-export; - 真正的命令行入口 get-shit-done/bin/gsd-tools.cjs 在命令分发前做多仓库守卫:除
generate-slug、template、frontmatter、worktree、prompt-budget等不触碰.planning/的纯工具命令外(见其SKIP_ROOT_RESOLUTION集合),都会先执行cwd = findProjectRoot(cwd)再进入处理器分发。
SDK 侧(消费 TS 事实来源):
- sdk/src/query/helpers.ts 直接
export { findProjectRoot } from '../project-root/index.js'; - 查询运行时上下文解析 sdk/src/query/query-runtime-context.ts 在解析
projectDir时首先调用findProjectRoot(input.projectDir),随后按--ws标志 >GSD_WORKSTREAM环境变量 >.planning/active-workstream文件 > 根.planning/的优先级解析 workstream。
这意味着:从 gsd-tools CJS CLI 还是从 gsd-sdk query 发起调用,项目根解析都走到同一份代码,两套工具在子仓库场景下的行为差异被结构性消除。
六、配置侧视角:.planning/config.json 怎么声明多仓库
解析行为的可配置入口集中在项目根 .planning/config.json。官方配置文档 docs/CONFIGURATION.md 对相关字段做了如下定义:
| 设置 | 类型 | 默认值 | 说明 |
|---|---|---|---|
planning.sub_repos |
string[] |
[] |
相对项目根的嵌套子仓库路径;设置后 GSD 感知工具会按子仓库收敛 phase 查找、路径解析与提交操作,而非把外层仓库当单体仓库处理 |
(遗留)顶层 sub_repos |
string[] |
— | 旧式扁平写法,配置模块在加载时会归一化到 planning.sub_repos 规范位置 |
(遗留)multiRepo: true |
boolean |
— | 旧式多仓库开关,触发启发式 2 |
一个最小的工作区配置示例:
{
"planning": {
"sub_repos": ["app", "docs"]
}
}
当用户在 app/ 内部运行任一 GSD 命令时,findProjectRoot 会命中启发式 1 并上行解析到拥有 .planning/ 的工作区父目录。文档给出的解析顺序与源码中的四条启发式完全一致,并额外说明:"若全部不命中则原样返回起始目录;显式 --project-dir /path/to/workspace 在该解析下是幂等的。"
七、测试证据:行为被四层测试钉死
本次统一并非"重构即信任",仓库用三层测试把行为固化下来:
- 单元级 fixture 矩阵:sdk/src/project-root/index.test.ts 覆盖全部四条启发式及边界情形——自身
.planning/守卫、sub_repos命中、深层目录上行、planning.sub_repos嵌套键、multiRepo: true、.git兜底、损坏的config.json回退、空sub_repos且无.git的不命中、以及 #1362 嵌套项目不被越级、深度 10 上限。文件头部注释强调这些是 "pinning tests",改动期间必须保持绿色; - 集成级回归:sdk/src/query/sub-repos-root.integration.test.ts 复现 issue #2623 的端到端路径:构造
sub_repos: ["app"]的工作区后,从子仓库目录执行findProjectRoot→ registry 分发init.new-milestone,断言处理器上报的project_root是父工作区且project_exists: true;对照组(不做上行解析直接以子目录分发)则稳定复现project_exists: false的 bug 现场,证明该行走逻辑是"承重"的; - 产物新鲜度闸门:CI 上
check:project-root-fresh.mjs保证生成产物与 TS 事实来源永不脱节。
另外 tests/project-root-generator.test.cjs 与 Windows 健壮性测试(tests/windows-robustness.test.cjs)也从 CJS 侧验证了生成器的跨平台可复现性。
八、小结:一次重构暴露的工程方法论
curious-bears-zip.md 表面只描述了一次"删重复代码"的变更,但它背后是仓库在 CJS/SDK 双运行时架构下总结出的一整套可复用方法论:
- 重复实现本身就是技术债的源头:只要同一份逻辑存在两份手写副本,漂移 bug(fix 只落一侧)就是必然而非偶然;
- 用"生成"取代"复制":把唯一手写源放在 TypeScript 侧,CJS 产物交给 generator 机械发射,任何一侧都无需再读另一侧的代码来"对齐";
- CI 新鲜度检查是生成模式的另一半:没有
check-*-fresh闸门,生成文件终会退化成"可以手改的普通文件"; - 行为漂移要显式裁定:合并两条实现前先盘点差异(深度上限、读取通道),逐条判定以哪侧为准并写明理由,让行为收敛有据可查。
对于任何维护"同语义双语言/双运行时"代码库的团队而言,GSD 的这次 Project-Root Resolution Module 统一都是一份值得对照的落地样本——从 sdk/src/project-root/index.ts 这一个文件出发,把漂移消灭在合并之前。
延伸阅读
- 共享模块统一架构决策:docs/adr/3524-cjs-sdk-hard-seam.md
- 该 ADR 的 PRD:docs/prd/3524-cjs-sdk-hard-seam.md
- 模块登记与接口定义:CONTEXT.md
- 多仓库解析的配置说明:docs/CONFIGURATION.md
- 事实来源实现:sdk/src/project-root/index.ts|生成产物:get-shit-done/bin/lib/project-root.generated.cjs
- 生成与校验脚本:sdk/scripts/gen-project-root.mjs、sdk/scripts/check-project-root-fresh.mjs
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 StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00