首页
/ get-shit-done 项目根目录解析模块统一:CJS 与 SDK 双运行时 `findProjectRoot` 的单一事实来源落地解析

get-shit-done 项目根目录解析模块统一:CJS 与 SDK 双运行时 `findProjectRoot` 的单一事实来源落地解析

2026-09-07 20:11:48作者:鲍丁臣Ursa

导读:本篇文章聚焦 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 正是应对之策,其核心约束可以概括为四条:

  1. 唯一一份手写事实来源(source of truth):有行为逻辑的模块放在 sdk/src/<module-name>/(TypeScript);纯数据模块放在 sdk/shared/<module-name>.manifest.json
  2. 只允许生成的产物:CJS 侧文件统一为 get-shit-done/bin/lib/<module-name>.generated.cjs,由脚本机械发射,永不手改
  3. 每个模块配 CI 新鲜度检查sdk/scripts/check-<module>-fresh.mjs 重新运行 generator,若产物与已提交版本不一致则构建失败;
  4. 禁止"手写同步对"(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.jsonsub_repos(或 planning.sub_repos)列表包含起始目录相对父级的顶层路径段 返回该父级
2 遗留 multiRepo: true config.jsonmultiRepo === 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 产出,其发射策略很有参考价值:

  1. 编译后取函数体:先构建 TS 得到 sdk/dist/project-root/index.js,用 Function.prototype.toString() 捕获 findProjectRoot 的源码字符串;
  2. CJS 闭包注入依赖:由于 ESM 版本使用的是具名解构导入(dirnameresolveseprelativeparsePathexistsSync 等),生成器在产物文件顶部写入一段 preamble,把这些依赖作为模块级常量定义,使函数体可直接以闭包变量方式引用:
    const { existsSync, readFileSync, statSync } = fs;
    const { dirname, resolve, sep, relative, parse: parsePath } = path;
    const { homedir } = os;
    const FIND_PROJECT_ROOT_MAX_DEPTH = 10;
    
  3. 注入不可编辑的 banner:产物以 "GENERATED FILE — DO NOT EDIT" 头开始,标明事实来源路径与重新生成命令;
  4. 写入固定目标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 都吞掉了两种失败模式(platformReadSyncreadFileSync 在配置缺失/不可读时的最终效果一致),所以收敛是安全的。第一个差异则是真实的行为收紧:深度超过 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-slugtemplatefrontmatterworktreeprompt-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 在该解析下是幂等的。"

七、测试证据:行为被四层测试钉死

本次统一并非"重构即信任",仓库用三层测试把行为固化下来:

  1. 单元级 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",改动期间必须保持绿色;
  2. 集成级回归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 现场,证明该行走逻辑是"承重"的;
  3. 产物新鲜度闸门: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 这一个文件出发,把漂移消灭在合并之前。

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
docsdocs
暂无描述
Markdown
899
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
525
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395