From 5a49060364a6948ec560010cd491438347838fa6 Mon Sep 17 00:00:00 2001 From: bboonnee Date: Fri, 31 Jul 2026 00:09:39 +0800 Subject: [PATCH] U-002: target-project .graphlemon/ scaffold (c_project_create) --- CLAUDE.md | 6 ++++-- docs/development-plan.md | 5 +++-- docs/sdd/05-实施约束与依赖.md | 2 +- docs/sdd/unit_matrix.json | 2 +- src/project_scaffold.py | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/test_project_scaffold.py | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 src/project_scaffold.py create mode 100644 src/test_project_scaffold.py diff --git a/CLAUDE.md b/CLAUDE.md index 76b4f51..f944e0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,9 @@ directly; anything worth reusing has already been copied and adapted into this r - `supervisor/isolation/` — staged utility library (isolate/diff/apply), not yet wired to anything - `spikes/` — one-off validation spikes (currently just A-1) - `src/` (created by U-001) — graphlemon's own backend source (schema, then U-007+ HTTP/WebSocket server). - **Not the same `src/` U-002 generates inside a target project's own repo** — those are two different - physical repos that can coexist on disk during PH5 self-target. See 05-实施约束与依赖.md §5.7. + **Not the same `src/` a target project already has in its own repo** — a target project's `src/` is + its own pre-existing source tree; graphlemon does not create or restructure it (see 05-实施约束与依赖.md + §5.7 and architecture doc §13.2 — U-002 only creates `.graphlemon/` inside a target project, corrected + 2026-07-31 after finding development-plan.md's U-002 line had cited the wrong architecture section). - Everything else (`.claude/agents/`, `skills/`, `runs/`, per-target-project scaffolding) gets created by U-002/U-003 — it doesn't exist yet, and that's expected at this stage. diff --git a/docs/development-plan.md b/docs/development-plan.md index 90af09f..8879f04 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -48,9 +48,10 @@ - 覆盖:01-M 全部实体 - 源码位置:graphlemon 仓库根目录 `src/`(见 05 节 §5.7"源码目录约定",与 U-002 在目标项目里生成的 `src/` 是两回事) -- **U-002 [Enabler]** 目标项目 Git 仓库目录骨架初始化逻辑(架构文档 §7.3 目录树:`.claude/agents/`、`.claude/agent-memory/`、`.claude/workflows/`、`skills/`、`runs/`、`src/`)+ `.graphlemon/` 子目录(架构文档 §13.2) +- **U-002 [Enabler]** 目标项目 Git 仓库目录骨架初始化逻辑:只生成 `.graphlemon/` 子目录(含 `runs//`,架构文档 §13.2——01-M `project.graphlemon_subdir_initialized` 字段本身指向的就是这节,不是 §7.3) + - **2026-07-31 订正**:development-plan.md 这行原先误引用了架构文档 §7.3 的旧目录树(`.claude/agents/`、`.claude/agent-memory/`、`.claude/workflows/`、`skills/`、顶层 `runs/`)。但 §13.1("已确认"决策,晚于 §7.3)明确写着这些是 graphlemon **平台自己仓库**的资产("graphlemon 平台自己的仓库只保留平台资产:Org/Work Graph 引擎、Skill 库、Agent 人设...不含任何目标项目的业务代码"),属于 U-003 范围,不是目标项目、不是 U-002。§7.3 是"graphlemon 只服务自己开发自己"阶段(自举模式)的旧设计,已被 §13"目标项目是独立代码空间"这个决策取代。目标项目自己的 `src/` 不需要 graphlemon 创建或规定结构("目标项目自己原有的源码目录,graphlemon 不规定具体结构,尊重目标项目原有约定")。用户确认,2026-07-31。 - 依赖:U-001(`project.graphlemon_subdir_initialized` 字段需要有实体支撑) - - Done:给定一个空 git 仓库,能生成完整目录骨架并产生一次初始化 commit;非 git 仓库时按 02-V 2.1 节的确认态流程走(不静默 `git init`) + - Done:给定一个空 git 仓库,能生成 `.graphlemon/` 子目录骨架并产生一次初始化 commit;非 git 仓库时按 02-V 2.1 节的确认态流程走(不静默 `git init`) - 覆盖:01-M `project`;03-C `c_project_create` - **U-003 [Enabler]** graphlemon 平台自身的仓库/运行时目录结构(与目标项目仓库物理分离;本地非 Git 的原始日志目录,见架构文档 §13.2) diff --git a/docs/sdd/05-实施约束与依赖.md b/docs/sdd/05-实施约束与依赖.md index 7944078..e2718da 100644 --- a/docs/sdd/05-实施约束与依赖.md +++ b/docs/sdd/05-实施约束与依赖.md @@ -68,7 +68,7 @@ - 前端是本地 Web 应用(浏览器页面),后端用 `http.server`/`socketserver` 等 stdlib 原生能力提供 HTTP API;WebSocket 若 stdlib 支持不足,实施时(U-007)需要验证并可退化为 SSE/长轮询,但仍在 stdlib 范围内解决,不引入第三方包;前端不引入 npm/构建步骤,可以是原生 HTML/CSS/JS 或参考 graphloop `graph.html`/`project.html` 的单文件 no-build 页面模式 - **例外**:这条约束只针对 graphlemon 平台自身的实现语言,不代表 graphlemon 生成/审查的目标项目代码也必须是 Python——目标项目用什么语言由目标项目自己的技术栈决定 - **影响**:development-plan.md 的 U-007(本地后端骨架)、U-008(前端骨架)、U-036(Deploy Supervisor 骨架)的 Done 标准隐含"纯 stdlib"这条约束 -- **源码目录约定(2026-07-30 补充)**:graphlemon 平台自身的后端源码(U-001 的 schema 实现、U-007 起的 HTTP/WebSocket 后端)落在 graphlemon 仓库根目录的 `src/`。这与 U-002 在**目标项目**仓库内生成的 `src/` 是两个完全不同的目录(分属两个物理仓库,见架构文档 §13.1"目标项目是独立代码空间"决策)——PH5 self-target 场景下二者会同时存在于同一台机器上,实现时不要混淆,必要时以绝对路径而非相对路径区分 +- **源码目录约定(2026-07-30 补充,2026-07-31 订正)**:graphlemon 平台自身的后端源码(U-001 的 schema 实现、U-007 起的 HTTP/WebSocket 后端)落在 graphlemon 仓库根目录的 `src/`。这与**目标项目**仓库自己已有的 `src/` 是两个完全不同的目录,分属两个物理仓库(见架构文档 §13.1"目标项目是独立代码空间"决策)——PH5 self-target 场景下二者会同时存在于同一台机器上,实现时不要混淆,必要时以绝对路径而非相对路径区分。**订正**:目标项目的 `src/` 不是 U-002 生成的——U-002 只在目标项目里创建 `.graphlemon/` 子目录(架构文档 §13.2),目标项目自己的 `src/` 是其原有源码目录,graphlemon 不创建也不规定其结构;development-plan.md 此前 U-002 那行误引用了架构文档 §7.3 的旧目录树,已于 2026-07-31 订正(§7.3 是"graphlemon 只服务自己开发自己"阶段的旧设计,已被 §13 取代,§7.3 里的 `.claude/agents/`、`skills/`、顶层 `runs/` 实际是 graphlemon 平台自己仓库的资产,属于 U-003 范围) ## 5.8 self-target 场景治理规则(`self-development-feasibility.md` 缺口 1/3,2026-07-30 回填) diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index c8a728b..45742d4 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -12,7 +12,7 @@ }, "units": [ {"id": "U-001", "phase": "PH0", "sub_phase": "PH0-S1", "name": "01-M 全部 15 实体物理 schema", "classification": "Enabler", "depends_on": [], "coverage": {"model": ["project", "org_graph_definition", "task", "work_graph_run", "work_node_execution", "artifact", "bug_record", "skill", "skill_iteration", "agent_profile", "budget_guardrail_config", "org_concurrency_config", "execution_event", "execution_log_file", "log_retention_config"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "15 张表全部建表成功,关键约束(唯一/外键/枚举)有单测覆盖"}, - {"id": "U-002", "phase": "PH0", "sub_phase": "PH0-S1", "name": "目标项目 Git 仓库目录骨架初始化", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["project"], "view": [], "controller": ["c_project_create"], "process": [], "requirement": []}, "done": "给定空 git 仓库,能生成完整目录骨架并产生初始化 commit"}, + {"id": "U-002", "phase": "PH0", "sub_phase": "PH0-S1", "name": "目标项目 Git 仓库目录骨架初始化(仅 .graphlemon/,见架构文档 §13.2;2026-07-31 订正,此前误引用 §7.3)", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["project"], "view": [], "controller": ["c_project_create"], "process": [], "requirement": []}, "done": "给定空 git 仓库,能生成 .graphlemon/ 子目录骨架并产生初始化 commit"}, {"id": "U-003", "phase": "PH0", "sub_phase": "PH0-S1", "name": "graphlemon 平台自身仓库/运行时目录结构", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "平台目录与目标项目目录物理分离,互不干扰"}, {"id": "U-004", "phase": "PH0", "sub_phase": "PH0-S2", "name": "Hermes profile 迁移到 .claude/agents/*.md", "classification": "Risk Gate", "depends_on": ["U-002"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": [], "assumption": ["A-2"]}, "done": "claude --agent planner -p 可正常启动且系统提示词正确;skills/memory/tools/model frontmatter 配置完整"}, {"id": "U-004.5", "phase": "PH0", "sub_phase": "PH0-S2", "name": "RUN_REAL_AGENT_E2E 门控测试 harness(2026-07-30 新增)", "classification": "Enabler", "depends_on": ["U-004"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "有可复用的 skip_unless_env(\"RUN_REAL_AGENT_E2E\") 装饰器/基类;默认 unittest 跳过真实 claude 子进程调用;显式开启环境变量时至少一个真实往返用例可跑通"}, diff --git a/src/project_scaffold.py b/src/project_scaffold.py new file mode 100644 index 0000000..2cbb8be --- /dev/null +++ b/src/project_scaffold.py @@ -0,0 +1,102 @@ +"""c_project_create (03-C §3.0): register/scaffold a target project. + +U-002 scope: the directory-scaffolding + DB-persistence half of c_project_create -- +no HTTP endpoint yet (that's U-007); this is the plain function U-007 will wire up. + +Target-project directory convention is architecture doc §13.2, NOT §7.3 (see +development-plan.md's 2026-07-31 correction: §7.3 describes graphlemon's own +platform repo, not a target project). c_project_create only ever creates +`.graphlemon/` inside the target folder; a target project's own `src/` and +everything else in it is left untouched. +""" + +import os +import subprocess +import uuid + +_GIT_IDENTITY = [ + "-c", "user.name=graphlemon", + "-c", "user.email=graphlemon@localhost", +] + + +class PathNotFoundError(Exception): + """PATH_NOT_FOUND -- 03-C §3.0: target_folder_path does not exist.""" + + +class PathNotWritableError(Exception): + """PATH_NOT_WRITABLE -- 03-C §3.0: no write permission on target_folder_path.""" + + +class GitInitDeclinedError(Exception): + """GIT_INIT_DECLINED -- 03-C §3.0: not a git repo and caller didn't ask to init one. + + 02-V §2.1: the UI must get explicit user confirmation before setting + init_git_if_missing=True; this function never silently runs `git init`. + """ + + +def _run_git(args, cwd): + subprocess.run(["git"] + args, cwd=cwd, check=True, capture_output=True, text=True) + + +def create_project(conn, target_folder_path, name=None, init_git_if_missing=False): + """Implements c_project_create (03-C §3.0). + + Returns {"project_id", "has_git_repo", "graphlemon_subdir_initialized"}. + Idempotent: a second call with the same target_folder_path returns the + existing row untouched (01-M 1.0 幂等约束) -- no re-validation, no repeat + side effects, matching REQ-11's failure-case wording. + """ + existing = conn.execute( + "SELECT project_id, has_git_repo, graphlemon_subdir_initialized " + "FROM project WHERE target_folder_path = ?", + (target_folder_path,), + ).fetchone() + if existing is not None: + return { + "project_id": existing[0], + "has_git_repo": bool(existing[1]), + "graphlemon_subdir_initialized": bool(existing[2]), + } + + if not os.path.isdir(target_folder_path): + raise PathNotFoundError(target_folder_path) + if not os.access(target_folder_path, os.W_OK): + raise PathNotWritableError(target_folder_path) + + has_git_repo = os.path.isdir(os.path.join(target_folder_path, ".git")) + if not has_git_repo: + if not init_git_if_missing: + raise GitInitDeclinedError(target_folder_path) + _run_git(["init"], cwd=target_folder_path) + has_git_repo = True + + graphlemon_subdir = os.path.join(target_folder_path, ".graphlemon") + os.makedirs(graphlemon_subdir, exist_ok=True) + # git doesn't track empty directories; this is only so the init commit has + # something under .graphlemon/ to record. Real content (runs//...) + # arrives once tasks actually execute (01-M 1.5 artifact.file_path). + open(os.path.join(graphlemon_subdir, ".gitkeep"), "w").close() + + _run_git(["add", ".graphlemon"], cwd=target_folder_path) + _run_git( + _GIT_IDENTITY + ["commit", "-m", "graphlemon: initialize .graphlemon/ workspace"], + cwd=target_folder_path, + ) + + project_id = str(uuid.uuid4()) + project_name = name or os.path.basename(os.path.normpath(target_folder_path)) + conn.execute( + "INSERT INTO project " + "(project_id, name, target_folder_path, has_git_repo, graphlemon_subdir_initialized, status) " + "VALUES (?, ?, ?, ?, 1, 'active')", + (project_id, project_name, target_folder_path, int(has_git_repo)), + ) + conn.commit() + + return { + "project_id": project_id, + "has_git_repo": has_git_repo, + "graphlemon_subdir_initialized": True, + } diff --git a/src/test_project_scaffold.py b/src/test_project_scaffold.py new file mode 100644 index 0000000..81490b6 --- /dev/null +++ b/src/test_project_scaffold.py @@ -0,0 +1,139 @@ +"""U-002 tests: c_project_create's directory-scaffolding + DB-persistence logic. + +Run: python3 -m unittest src.test_project_scaffold -v +""" + +import os +import shutil +import stat +import subprocess +import tempfile +import unittest + +from src import project_scaffold, schema + + +def _init_git_repo(path): + subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True) + + +class ProjectScaffoldTestBase(unittest.TestCase): + def setUp(self): + self.conn = schema.connect(":memory:") + schema.create_schema(self.conn) + self.tmpdir = tempfile.mkdtemp(prefix="graphlemon-u002-") + + def tearDown(self): + self.conn.close() + shutil.rmtree(self.tmpdir, ignore_errors=True) + + +class HappyPathTest(ProjectScaffoldTestBase): + def test_existing_git_repo_gets_graphlemon_subdir_and_init_commit(self): + _init_git_repo(self.tmpdir) + + result = project_scaffold.create_project(self.conn, self.tmpdir) + + self.assertTrue(result["has_git_repo"]) + self.assertTrue(result["graphlemon_subdir_initialized"]) + self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, ".graphlemon"))) + + log = subprocess.run( + ["git", "log", "--oneline"], cwd=self.tmpdir, check=True, capture_output=True, text=True + ) + self.assertIn("graphlemon: initialize .graphlemon/ workspace", log.stdout) + + row = self.conn.execute( + "SELECT project_id, target_folder_path, has_git_repo, graphlemon_subdir_initialized, status " + "FROM project WHERE project_id = ?", + (result["project_id"],), + ).fetchone() + self.assertIsNotNone(row) + self.assertEqual(row[1], self.tmpdir) + self.assertEqual(row[2], 1) + self.assertEqual(row[3], 1) + self.assertEqual(row[4], "active") + + def test_project_name_defaults_to_folder_name(self): + _init_git_repo(self.tmpdir) + result = project_scaffold.create_project(self.conn, self.tmpdir) + row = self.conn.execute( + "SELECT name FROM project WHERE project_id = ?", (result["project_id"],) + ).fetchone() + self.assertEqual(row[0], os.path.basename(self.tmpdir)) + + def test_explicit_name_used_when_given(self): + _init_git_repo(self.tmpdir) + result = project_scaffold.create_project(self.conn, self.tmpdir, name="Snake Game") + row = self.conn.execute( + "SELECT name FROM project WHERE project_id = ?", (result["project_id"],) + ).fetchone() + self.assertEqual(row[0], "Snake Game") + + +class NonGitRepoConfirmationFlowTest(ProjectScaffoldTestBase): + """02-V §2.1 / 03-C §3.0: never silently `git init`.""" + + def test_declines_without_confirmation_and_leaves_folder_untouched(self): + with self.assertRaises(project_scaffold.GitInitDeclinedError): + project_scaffold.create_project(self.conn, self.tmpdir, init_git_if_missing=False) + + self.assertFalse(os.path.isdir(os.path.join(self.tmpdir, ".git"))) + self.assertFalse(os.path.isdir(os.path.join(self.tmpdir, ".graphlemon"))) + count = self.conn.execute("SELECT COUNT(*) FROM project").fetchone()[0] + self.assertEqual(count, 0) + + def test_initializes_git_when_explicitly_confirmed(self): + result = project_scaffold.create_project(self.conn, self.tmpdir, init_git_if_missing=True) + + self.assertTrue(result["has_git_repo"]) + self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, ".git"))) + self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, ".graphlemon"))) + + +class IdempotentCreationTest(ProjectScaffoldTestBase): + def test_second_call_returns_existing_record_without_new_commit(self): + _init_git_repo(self.tmpdir) + first = project_scaffold.create_project(self.conn, self.tmpdir) + second = project_scaffold.create_project(self.conn, self.tmpdir) + + self.assertEqual(first["project_id"], second["project_id"]) + count = self.conn.execute("SELECT COUNT(*) FROM project").fetchone()[0] + self.assertEqual(count, 1) + + log = subprocess.run( + ["git", "log", "--oneline"], cwd=self.tmpdir, check=True, capture_output=True, text=True + ) + self.assertEqual(len(log.stdout.strip().splitlines()), 1) + + def test_idempotent_hit_skips_path_validation(self): + # REQ-11 failure case: repeat calls on an already-registered path must + # return the existing record even if the path itself is no longer + # valid, not re-validate and error. + _init_git_repo(self.tmpdir) + first = project_scaffold.create_project(self.conn, self.tmpdir) + shutil.rmtree(self.tmpdir) + + second = project_scaffold.create_project(self.conn, self.tmpdir) + self.assertEqual(first["project_id"], second["project_id"]) + + +class PathValidationErrorsTest(ProjectScaffoldTestBase): + def test_nonexistent_path_raises_path_not_found(self): + missing = os.path.join(self.tmpdir, "does-not-exist") + with self.assertRaises(project_scaffold.PathNotFoundError): + project_scaffold.create_project(self.conn, missing) + + def test_unwritable_path_raises_path_not_writable(self): + os.chmod(self.tmpdir, stat.S_IRUSR | stat.S_IXUSR) + try: + if os.access(self.tmpdir, os.W_OK): + self.skipTest("running as a user that bypasses permission bits (e.g. root)") + with self.assertRaises(project_scaffold.PathNotWritableError): + project_scaffold.create_project(self.conn, self.tmpdir) + finally: + os.chmod(self.tmpdir, stat.S_IRWXU) + + +if __name__ == "__main__": + unittest.main() -- libgit2 0.21.4