From c19e93d08e7b59ca82353b9ca5e1799d6faae699 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 12 Nov 2026 04:15:39 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(choro):=20=E4=BB=93=E5=BA=93=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E4=B8=8E=E4=BD=8D=E7=BD=AE=E5=88=86=E5=AE=B6=E2=80=94?= =?UTF-8?q?=E2=80=94portable=5Fkey=20+=20=E6=AF=8F=E6=9C=BA=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E8=A1=A8=EF=BC=8C=E5=85=B1=E4=BA=AB=E5=BA=93=E5=9C=B0?= =?UTF-8?q?=E5=BF=97=E5=8F=AF=E8=B7=A8=E7=AB=AF=E5=A4=8D=E7=94=A8=EF=BC=88?= =?UTF-8?q?#581=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh-mneme/CHANGELOG.md | 3 + dsh-mneme/lib/api.js | 6 +- dsh-mneme/lib/choro-command.js | 9 ++- dsh-mneme/lib/store.js | 87 +++++++++++++++++++++++---- dsh-mneme/lib/wiki/portable.js | 88 ++++++++++++++++++++++++++++ dsh-mneme/lib/wiki/queries.js | 69 ++++++++++++++++------ dsh-mneme/lib/wiki/t0.js | 7 ++- dsh-mneme/lib/wiki/t1-jobs.js | 8 ++- dsh-mneme/lib/wiki/t1.js | 13 +++- dsh-mneme/src/api.js | 6 +- dsh-mneme/src/choro-command.js | 9 ++- dsh-mneme/src/store.js | 87 +++++++++++++++++++++++---- dsh-mneme/src/wiki/portable.js | 88 ++++++++++++++++++++++++++++ dsh-mneme/src/wiki/queries.js | 69 ++++++++++++++++------ dsh-mneme/src/wiki/t0.js | 7 ++- dsh-mneme/src/wiki/t1-jobs.js | 8 ++- dsh-mneme/src/wiki/t1.js | 13 +++- dsh-mneme/test/wiki-api.test.js | 16 ++--- dsh-mneme/test/wiki-portable.test.js | 45 ++++++++++++++ dsh-mneme/test/wiki-queries.test.js | 44 ++++++++++++-- dsh-mneme/test/wiki-store.test.js | 79 +++++++++++++++++++++++++ dsh-mneme/test/wiki-t1.test.js | 38 +++++++++++- dsh-mneme/test/wiki-tools.test.js | 6 +- 23 files changed, 711 insertions(+), 94 deletions(-) create mode 100644 dsh-mneme/lib/wiki/portable.js create mode 100644 dsh-mneme/src/wiki/portable.js create mode 100644 dsh-mneme/test/wiki-portable.test.js diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index f60e00e1..3d6059ef 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +- **仓库地志的身份与位置分家:`portable_key` + 每机位置表,双系统 / 双机共享同一个 memory.db 时地志第一次可以跨端复用(#581)**:mneme 把「库共享」做到了(`memoryDir` + WAL + busy_timeout),chorography 是唯一一处因为记了本机绝对路径而破坏这个前提的功能——Windows 端登记的行,macOS 端拼出来的路径不存在;再跑一次 T0 又多出第二行,从此不带 `repo` 参数的调用命中 repo-ambiguous;T1 的逐字符严格匹配更意味着**每端都必须先各自跑一次 T0**。修法按 issue 的最小方案落地,三个概念各就各位:**身份** = `choro_repos.portable_key`(新列,幂等迁移),取 `remote.origin.url` 归一化——协议头 / 尾斜杠 / 尾 `.git` / 大小写剥平,scp 形态(`git@host:path`)的冒号换 `/`,**userinfo 整体剥掉**(token 不进键:泄进库里是一回事,令牌轮换后同一仓库算出不同键是另一回事);无 remote 退化到**根提交哈希**(`git:`,跨副本恒定且与「碰巧同名的别的仓库」天然区分——issue 提的「退化为仓库名」不能用,目录名与 package name 重合率太高,第二次登记会吃掉别人的行,实现期在测试夹具上当场撞到)。**位置** = 新表 `choro_repo_paths(repo_key, host_id, root_path, last_seen_at)`,`host_id = os.hostname()`,T0/T1 登记时 upsert。**读侧**(`queries.js` 的 `localRootPath`)按当前 host 取本机路径、取不到回退身份行的 root_path——单机库两个值恒等、行为逐字节不变;本机没登记过时落进既有的 page-missing / unreadable 失败口径,不会拿别的机器的路径读出错误内容。**T1 的登记校验**从「root_path 逐字符相同」改成「本机路径命中**或**身份已登记」——同一仓库在另一台机器登记过,本机直接认领那一行富化,消灭「每端先各跑一次 T0」;认领同时落本机位置行。写路径(`/choro/run`、`/choro/t1` 后台任务、`/choro` 命令的过期判定)同样改吃本机根。**两个刻意的取舍**:① 同键登记收敛到同一行,但 root_path 的 UPDATE 前先查目标值是否被别的行占着(被占保留原值、位置走位置表)——升格期已跨端登记出的重复行**不合并**(页面与 claims 挂在 repo_id 上,机器间合并行等于改写归属),强行改会撞 `idx_choro_repos_root` 直接抛;② 升格存量行的键留 NULL,由它自己的机器重跑 T0/T1 时补上,不猜。已知边界:kb 导入的 choro_page source 是导入当时的绝对路径快照,跨端复用时会失配,另账处理。回归测试 +10(URL 归一化形态表含凭据剥离 / 无 remote 退化链与恒定性 / 旧库补列补表且重开幂等 / 位置表同键同机覆写 / 同键跨端登记收敛同一行且 created_at 不动 / 升格期 root 被占不强改 / 读侧三 host 各解析各的 / **T1 跨端认领端到端**:克隆检出直接富化身份行并登记本机位置),另把四个「同库登记两个相同夹具」的既有用例显式区分开(无 remote 键是根提交哈希,同秒提交的相同内容会同键收敛——这本身就是退化链设计的防误并性质)。变异自证 3 例(删 `upsertChoroRepoPath`/`choroRepoPathFor` 的 return 出口 → store 用例 3 红;读侧 `localRootPath` 去掉位置行解析 → queries 用例红;T1 去掉 byKey 认领 → 端到端用例红),恢复后全绿。 + +## [0.14.0-beta.3] - 2026-11-12 ## [0.14.0-beta.3] - 2026-11-12 - **情绪页白屏(beta.1 起的回归,beta.2 仍在):点开「情感」页整块面板变空白,且此后每次打开都空白**。根因是 `lib/client.js` 的 `AffectivePanel` 里一行 `formatRelativeTime(it.lastSeen)` 漏传第二个参数 `t`——该函数内部要调 `t("memory.time.now")`,缺参就抛 `t is not a function`,而这行跑在 render 里,React 整棵树卸载,日志里什么都不留。引入提交是 #619(`244da66d`),`v0.14.0-beta.1` 与 `v0.14.0-beta.2` 都带它;只有库里存在带 `lastSeen` 的情绪边时才触发,所以不是每个用户都会遇到。修法是补上 `, t`,并加一条静态断言:扫出所有 `formatRelativeTime(` 调用点,逐个做括号配对、数顶层逗号,实参不足 2 个就红。全库只有这一处漏传(`formatDate`/`formatDateShort` 本来只收一个参数,扫描器报的几处是误报)。变异自证:撤掉修复后断言变红并直接指出 `:8168 (argc=1)`。 diff --git a/dsh-mneme/lib/api.js b/dsh-mneme/lib/api.js index b050eab8..72c0be2d 100644 --- a/dsh-mneme/lib/api.js +++ b/dsh-mneme/lib/api.js @@ -3457,7 +3457,9 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = }); return; } - repoRoot = repo.root_path; + // 本机可用的根(#581):身份行上的 root_path 可能是别的机器最近一次 + // 登记的位置,直接拿去跑 T0 会 no-such-dir。 + repoRoot = choro.localRootForRepo(repo); } try { @@ -3533,6 +3535,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } const started = choro.startT1Job({ repo, + // 本机根(#581):身份行的 root_path 可能是别的机器登记的位置。 + rootPath: typeof choro.localRootForRepo === "function" ? choro.localRootForRepo(repo) : null, module: body && typeof body.module === "string" && body.module.trim() !== "" ? body.module.trim() : null, retryFailed: body && typeof body === "object" ? body.retryFailed === true : false }); diff --git a/dsh-mneme/lib/choro-command.js b/dsh-mneme/lib/choro-command.js index 8b375ae7..4c1a54b2 100644 --- a/dsh-mneme/lib/choro-command.js +++ b/dsh-mneme/lib/choro-command.js @@ -33,9 +33,12 @@ function dateOnly(iso) { * 几十毫秒起步(P29)。这里是用户主动敲的,付得起一次 rev-parse。 * 三种结局分开报:一致 / 不一致 / 读不到 git——不能把「读不到」说成「没过期」。 */ -function staleness(repo) { +function staleness(repo, localRoot) { const baseline = String(repo.git_baseline ?? "").trim(); - const head = gitHead(repo.root_path); + // 本机根(#581):repo 由 repoByCwd 解析(身份行),root_path 可能是别的机器 + // 最近一次登记的位置;调用方经 queries.localRootForRepo 解析本机路径。旧装配 + // 没有这个函数时退回行的 root_path(单机语义不变)。 + const head = gitHead(localRoot ?? repo.root_path); if (!head) return { state: "no-git", baseline, head: null }; if (baseline && baseline === head) return { state: "fresh", baseline, head }; return { state: "stale", baseline, head }; @@ -81,7 +84,7 @@ export function createChoroHandler({ choro, config, language = () => "zh" } = {} const listed = block ? block.split("\n").filter((line) => line.startsWith("- ")).length : 0; const rest = Math.max(0, outline.modules.length - listed); if (rest > 0) lines.push(S.truncated(rest)); - const head = staleness(repo); + const head = staleness(repo, typeof choro.localRootForRepo === "function" ? choro.localRootForRepo(repo) : null); if (head.state === "fresh") lines.push(S.fresh(shortSha(head.head))); else if (head.state === "stale") lines.push(S.stale(shortSha(head.baseline), shortSha(head.head), cli)); else lines.push(S.noGit); diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 29a7e978..5e51f196 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -611,10 +611,24 @@ CREATE TABLE IF NOT EXISTS choro_repos ( plan_fingerprint TEXT, -- 上次成功 run 的 plan.yaml 内容 sha256 git_baseline TEXT, -- 上次成功 run 的 HEAD created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + updated_at TEXT NOT NULL, + portable_key TEXT -- 可移植身份(#581):remote URL 归一化,无 remote 退化仓库名 ); CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_repos_root ON choro_repos(root_path); +-- 每机一份的位置表(#581):身份与位置分家——memory.db 可以跨端共享,工作 +-- 副本的位置不能。host_id 取 os.hostname()(src/wiki/portable.js);同一身份 +-- 同一台机器只有一行,T0/T1 登记时 upsert。读侧按当前 host 取本机路径,取 +-- 不到回退 choro_repos.root_path(行上的 root_path 从此是「最近一次登记的 +-- 位置」,单机库两个值恒等,行为不变)。 +CREATE TABLE IF NOT EXISTS choro_repo_paths ( + repo_key TEXT NOT NULL, + host_id TEXT NOT NULL, + root_path TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + PRIMARY KEY (repo_key, host_id) +); + -- 模块划分与依赖边。fingerprint = 模块文件集 + 边集的内容 sha256:幂等语义靠 -- replaceChoroGraph 的整仓替换,不靠逐行 diff,所以本表没有「上次见过的划分」 -- 之外的账要记。 @@ -1339,6 +1353,10 @@ export function createStore(path) { // 向量本体列),存量库经 PRAGMA 检查补列。 addColumn("choro_vectors", "vector", "ALTER TABLE choro_vectors ADD COLUMN vector TEXT"); addColumn("choro_vectors", "source_generated_at", "ALTER TABLE choro_vectors ADD COLUMN source_generated_at TEXT"); + // chorography 跨端身份(#581):存量行的 portable_key 留 NULL——键只在它 + // 自己的机器上重跑 T0/T1 时补上,不猜(remote URL 拿不到就是拿不到)。 + // choro_repo_paths 建表走 SCHEMA 的 CREATE TABLE IF NOT EXISTS,老库重开自动补。 + addColumn("choro_repos", "portable_key", "ALTER TABLE choro_repos ADD COLUMN portable_key TEXT"); // v0.8.0 A1(issue #17)scope 隔离存储层:四列全部可空、不带 DEFAULT——存量行 // 零重写,读侧把 NULL 视为未标注(= 全局可见)。是否落值由 scopeEnabled 门控 @@ -5211,19 +5229,60 @@ export function createStore(path) { return db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath) ?? undefined; } - // 登记或刷新仓库行。created_at 只在首次插入时落(ON CONFLICT 不碰它); - // plan_fingerprint / git_baseline 永远反映上次成功 run——失败 run 不写这两格 - // (红线 5「失败不推进 state」,库侧与 state.json 同一口径)。 - function upsertChoroRepo(rootPath, name, planFingerprint, gitBaseline, updatedAt) { + // 位置行 upsert(#581):同一身份同一台机器只一行,root_path 与 last_seen_at + // 反映最近一次登记。repo_key 为空(无 remote 且名字算不出)就不落——位置表 + // 只为可移植身份服务,没有身份的行留在 choro_repos.root_path 的旧语义里。 + function upsertChoroRepoPath(repoKey, hostId, rootPath, nowIso) { + if (!repoKey || !hostId) return; db.prepare( - `INSERT INTO choro_repos (root_path, name, plan_fingerprint, git_baseline, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(root_path) DO UPDATE SET - name = excluded.name, - plan_fingerprint = excluded.plan_fingerprint, - git_baseline = excluded.git_baseline, - updated_at = excluded.updated_at` - ).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, updatedAt); + `INSERT INTO choro_repo_paths (repo_key, host_id, root_path, last_seen_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(repo_key, host_id) DO UPDATE SET + root_path = excluded.root_path, + last_seen_at = excluded.last_seen_at` + ).run(repoKey, hostId, rootPath, nowIso); + } + + function choroRepoPathFor(repoKey, hostId) { + if (!repoKey || !hostId) return null; + return db.prepare("SELECT * FROM choro_repo_paths WHERE repo_key = ? AND host_id = ?").get(repoKey, hostId) ?? null; + } + + // 登记或刷新仓库行。created_at 只在首次插入时落;plan_fingerprint / git_baseline + // 永远反映上次成功 run——失败 run 不写这两格(红线 5「失败不推进 state」,库侧 + // 与 state.json 同一口径)。 + // + // 身份(#581):同一 portable_key 的行**优先**收敛(键的唯一性靠它保证——若 + // 让 root_path 命中优先,升格期的跨端重复行会在各自机器上把同一个键 adoption + // 到两行上,键就废了);本机路径命中只在没有同键行时兜底(无键仓库的旧口径)。 + // 收敛到的行上 root_path 更新为「最近一次登记的位置」,读侧经 choroRepoPathFor + // 回退它——单机库两个值恒等、行为不变。**升级前已跨端登记出的重复行不合并**: + // 页面与 claims 挂在 repo_id 上,机器间合并行等于改写归属;所以 UPDATE + // root_path 前先查目标值是否被别的行占着(被占就保留本行原值,位置走位置表) + // ——否则升格期登记会撞 idx_choro_repos_root 直接抛。portable_key 缺席(无 + // remote 且名字算不出)时退回旧的 root_path 冲突口径,存量语义不变。 + function upsertChoroRepo(rootPath, name, planFingerprint, gitBaseline, updatedAt, portableKey = null) { + let existing = portableKey + ? (db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined) + : undefined; + if (!existing) existing = choroRepoByRoot(rootPath); + if (existing) { + const rootTaken = db.prepare("SELECT id FROM choro_repos WHERE root_path = ? AND id != ?") + .get(rootPath, existing.id); + const nextRoot = rootTaken ? existing.root_path : rootPath; + db.prepare( + `UPDATE choro_repos SET root_path = ?, name = ?, plan_fingerprint = ?, git_baseline = ?, updated_at = ? + WHERE id = ?` + ).run(nextRoot, name, planFingerprint, gitBaseline, updatedAt, existing.id); + if (portableKey && existing.portable_key !== portableKey) { + db.prepare("UPDATE choro_repos SET portable_key = ? WHERE id = ?").run(portableKey, existing.id); + } + return choroRepoByRoot(nextRoot); + } + db.prepare( + `INSERT INTO choro_repos (root_path, name, plan_fingerprint, git_baseline, created_at, updated_at, portable_key) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, updatedAt, portableKey); return choroRepoByRoot(rootPath); } @@ -5865,6 +5924,8 @@ export function createStore(path) { // chorography(仓库地志):M0 只写不读,消费面 M1 接(见上面注释块)。 choroRepoByRoot, upsertChoroRepo, + upsertChoroRepoPath, + choroRepoPathFor, replaceChoroGraph, choroPageBySlug, recordChoroPageFailure, diff --git a/dsh-mneme/lib/wiki/portable.js b/dsh-mneme/lib/wiki/portable.js new file mode 100644 index 00000000..5eccbf11 --- /dev/null +++ b/dsh-mneme/lib/wiki/portable.js @@ -0,0 +1,88 @@ +// 仓库身份的可移植键(#581):choro_repos.root_path 是本机绝对路径,双系统 / +// 双机共享同一个 memory.db 时它把 per-host 状态写进了 shared 状态——Windows 端 +// 登记的行,macOS 端拼出来的路径不存在,再跑一次 T0 又多出第二行(从此不带 +// repo 参数的调用命中 repo-ambiguous)。身份与位置从此分家:身份 = portable_key +// (remote URL 归一化;无 remote 时退化为仓库名),位置 = choro_repo_paths +// 每机一行(host_id = os.hostname())。 +import { execFileSync } from "node:child_process"; +import os from "node:os"; + +/** + * 当前宿主的稳定 id。跨端共享库时每台机器各占一行位置;同一台机器双系统 + * (Windows / macOS 各有 hostname)天然是两行,与「两份工作副本」一一对应。 + */ +export function hostId() { + return os.hostname(); +} + +/** + * remote URL → 归一化身份串。目标形态:`host/path`(全小写)。 + * - 协议头(https:// / ssh:// / git://)剥掉; + * - 尾部 `/` 与 `.git` 剥掉; + * - scp 形态 `git@host:path` 的冒号换成 `/`——判据是冒号在最后一个 `@` 之后、 + * 第一个 `/` 之前;https 的 userinfo(`user:token@host`)冒号在 `@` 之前, + * **不能动**:token 换成 `/` 既毁匹配、还会把凭据的形状带进键里; + * - userinfo(含 token)整体剥掉:凭据不进键——泄进库里是一回事,令牌轮换 + * 后同一仓库算出不同键是另一回事,两个都不可接受。 + */ +export function normalizeGitUrl(rawUrl) { + let s = String(rawUrl ?? "").trim(); + if (!s) return null; + s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); + s = s.replace(/\/+$/, ""); + s = s.replace(/\.git$/i, ""); + const firstSlash = s.indexOf("/"); + const authority = firstSlash === -1 ? s : s.slice(0, firstSlash); + const at = authority.lastIndexOf("@"); + const colon = authority.indexOf(":", at + 1); + if (colon !== -1) { + s = authority.slice(0, colon) + "/" + authority.slice(colon + 1) + + (firstSlash === -1 ? "" : s.slice(firstSlash)); + } + const at2 = s.lastIndexOf("@"); + const hostStart = at2 === -1 ? 0 : at2 + 1; + const out = s.slice(hostStart).toLowerCase(); + return out || null; +} + +function gitRemoteUrl(repoRoot) { + try { + // stderr 接管:无 remote 时 git 写 stderr,不能让它漏到 CLI 界面上。 + const out = execFileSync("git", ["config", "--get", "remote.origin.url"], { + cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + }); + return String(out).trim() || null; + } catch { + return null; + } +} + +/** 根提交哈希:同一仓库历史的第一个提交,跨副本恒定(与 HEAD 无关)。 */ +function gitRootCommit(repoRoot) { + try { + const out = execFileSync("git", ["rev-list", "--max-parents=0", "HEAD"], { + cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + }); + const lines = String(out).trim().split("\n").filter(Boolean); + // 多根(嫁接历史)取字典序最小:同一仓库在任何副本上必须算出同一个键。 + return lines.length ? lines.sort()[0] : null; + } catch { + return null; + } +} + +/** + * 仓库的可移植身份。优先 remote.origin.url 归一化;无 remote 退化到根提交 + * 哈希(`git:`)——它跨副本恒定,又与「碰巧同名的别的仓库」天然区分。 + * issue #581 提的「退化为仓库名」不能用:目录名与 package.json name 的重合率 + * 极高(docs / scripts / 同一模板起家的一批仓库),拿它作键会把两个不相干的 + * 仓库收敛成一行,第二次登记就吃掉别人的页。两级都拿不到(非 git 目录)返回 + * null:那行退回 root_path 的旧语义,不硬造身份。 + */ +export function portableKeyFor(rootPath) { + const key = normalizeGitUrl(gitRemoteUrl(rootPath)); + if (key) return key; + const rootCommit = gitRootCommit(rootPath); + if (rootCommit) return `git:${rootCommit}`; + return null; +} diff --git a/dsh-mneme/lib/wiki/queries.js b/dsh-mneme/lib/wiki/queries.js index 1f6ac4a3..6eb07cd9 100644 --- a/dsh-mneme/lib/wiki/queries.js +++ b/dsh-mneme/lib/wiki/queries.js @@ -14,6 +14,7 @@ import { join, resolve, sep } from "node:path"; import { gitHead } from "./t0.js"; import { loadPlanFromRepo } from "./plan.js"; import { TEMPLATE_VERSION, T1_SECTION_START } from "./render.js"; +import { hostId as defaultHostId } from "./portable.js"; // claim 失效判定的纯函数(借 openwiki 的 Grounded Claims):解析 diff + 逐条比对 // 三列证据。放独立模块是因为它零 DB、零 IO(除 diffSince 那一次 git),可单测。 import { diffSince, summarizeClaims } from "./claims.js"; @@ -48,13 +49,30 @@ function cosine(a, b) { return denom === 0 ? 0 : dot / denom; } -export function createChoroQueries({ store }) { +export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) { const db = store.db; + // 本机路径解析(#581):身份行(choro_repos)上的 root_path 是「最近一次登记 + // 的位置」,未必是本机的——跨端共享库时可能是另一台机器的路径。当前机器登记 + // 过(choro_repo_paths 有本 host 的行)就用本机路径;没登记过退回行上的 + // root_path。单机库两个值恒等,行为不变;跨端库在本机还没跑过 T0/T1 时拿不到 + // 本机路径,文件访问落进既有的 page-missing / unreadable 失败口径(wiki-tools + // 的共享库用例锁着那个形状)——路径形状跨 OS 互不为前缀,不会拿别的机器的 + // 路径读出错误内容。 + const localRootPath = (row) => { + const key = row?.portable_key; + if (key) { + const p = db.prepare("SELECT root_path FROM choro_repo_paths WHERE repo_key = ? AND host_id = ?") + .get(key, hostIdFn()); + if (p && p.root_path) return p.root_path; + } + return row?.root_path ?? ""; + }; + /** 已登记仓库(含模块/页面计数,面板仓库选择器与工具的 repo 解析共用)。 */ function listRepos() { const rows = db.prepare( - `SELECT r.id, r.root_path, r.name, r.plan_fingerprint, r.git_baseline, r.updated_at, + `SELECT r.id, r.root_path, r.portable_key, r.name, r.plan_fingerprint, r.git_baseline, r.updated_at, (SELECT COUNT(*) FROM choro_modules m WHERE m.repo_id = r.id) AS module_count, (SELECT COUNT(*) FROM choro_pages p JOIN choro_modules m2 ON p.module_id = m2.id WHERE m2.repo_id = r.id) AS page_count, @@ -86,7 +104,7 @@ export function createChoroQueries({ store }) { * 新基线说话,不能再拿旧行算一遍)。混用会得到自相矛盾的回执。 */ function repoFromRow(row) { - const rootPath = String(row.root_path ?? ""); + const rootPath = localRootPath(row); return { id: row.id, name: row.name, @@ -127,7 +145,7 @@ export function createChoroQueries({ store }) { } function repoFacts(row) { - const rootPath = String(row.root_path ?? ""); + const rootPath = localRootPath(row); // git 优先:代码变了是更硬的「该重跑」信号,plan 变了才退而求其次——两者同时 // 成立时面板只说一句话,说「git」比说「plan」更贴近用户看到的现象(他刚改了码)。 const head = rootPath === "" ? null : gitHead(rootPath); @@ -230,8 +248,8 @@ export function createChoroQueries({ store }) { WHERE m.repo_id = ? AND m.slug = ?` ).get(repoId, String(slug)); if (!row) return null; - const repo = db.prepare("SELECT root_path, name FROM choro_repos WHERE id = ?").get(repoId); - const abs = join(repo.root_path, row.rel_path); + const repo = db.prepare("SELECT root_path, portable_key, name FROM choro_repos WHERE id = ?").get(repoId); + const abs = join(localRootPath(repo), row.rel_path); let content; try { content = readFileSync(abs, "utf8"); @@ -270,10 +288,11 @@ export function createChoroQueries({ store }) { WHERE m.repo_id = ? AND (? IS NULL OR m.slug = ?) ORDER BY m.slug` ).all(repoId, moduleSlug, moduleSlug); - const repo = db.prepare("SELECT root_path FROM choro_repos WHERE id = ?").get(repoId); + const repo = db.prepare("SELECT root_path, portable_key FROM choro_repos WHERE id = ?").get(repoId); const hits = []; + const searchRoot = localRootPath(repo); for (const page of pages) { - const abs = join(repo.root_path, page.rel_path); + const abs = join(searchRoot, page.rel_path); let text; try { text = readFileSync(abs, "utf8"); @@ -334,13 +353,22 @@ export function createChoroQueries({ store }) { const cmp = (p) => (process.platform === "win32" ? p.toLowerCase() : p); const target = cmp(normalized); const rows = db.prepare("SELECT * FROM choro_repos ORDER BY id").all(); + // 候选根(#581):除了身份行的 root_path(「最近一次登记的位置」可能是别的 + // 机器的),本机在位置表登记过的路径也参与前缀匹配。位置行**只认当前 + // host**——两台同名盘符的 Windows(都是 E:\repo)不能互相认领对方的目录。 + const byKey = new Map(rows.filter((r) => r.portable_key).map((r) => [r.portable_key, r])); + const candidates = rows.map((row) => ({ root: String(row.root_path ?? ""), row })); + for (const p of db.prepare("SELECT repo_key, root_path FROM choro_repo_paths WHERE host_id = ?").all(hostIdFn())) { + const row = byKey.get(p.repo_key); + if (row) candidates.push({ root: String(p.root_path ?? ""), row }); + } let best = null; - for (const row of rows) { - const root = cmp(String(row.root_path ?? "")); - if (root === "") continue; - const hit = target === root || target.startsWith(root.endsWith(sep) ? root : root + sep); + for (const { root, row } of candidates) { + const croot = cmp(root); + if (croot === "") continue; + const hit = target === croot || target.startsWith(croot.endsWith(sep) ? croot : croot + sep); if (!hit) continue; - if (!best || root.length > cmp(String(best.root_path)).length) best = row; + if (!best || croot.length > cmp(String(best.root_path)).length) best = row; } return best ?? null; } @@ -352,7 +380,7 @@ export function createChoroQueries({ store }) { */ function outline(repoId) { const repo = db.prepare( - "SELECT id, root_path, name, git_baseline, plan_fingerprint, updated_at FROM choro_repos WHERE id = ?" + "SELECT id, root_path, portable_key, name, git_baseline, plan_fingerprint, updated_at FROM choro_repos WHERE id = ?" ).get(repoId); if (!repo) return null; const rows = db.prepare( @@ -379,7 +407,7 @@ export function createChoroQueries({ store }) { repo: { id: repo.id, name: repo.name, - rootPath: repo.root_path, + rootPath: localRootPath(repo), gitBaseline: repo.git_baseline, updatedAt: repo.updated_at, }, @@ -409,9 +437,9 @@ export function createChoroQueries({ store }) { * - 起点 == HEAD → 那组真没变,clean */ function claimDrift(repoId) { - const repo = db.prepare("SELECT id, root_path, name, git_baseline FROM choro_repos WHERE id = ?").get(repoId); + const repo = db.prepare("SELECT id, root_path, portable_key, name, git_baseline FROM choro_repos WHERE id = ?").get(repoId); if (!repo) return null; - const rootPath = String(repo.root_path ?? ""); + const rootPath = localRootPath(repo); const rows = db.prepare( `SELECT c.id, c.statement, c.kind, c.source_path, c.start_line, c.end_line, m.slug AS page_slug, m.title AS page_title, p.rel_path @@ -545,7 +573,7 @@ export function createChoroQueries({ store }) { FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id WHERE m.repo_id = ? ORDER BY m.slug` ).all(repoId); - const repo = db.prepare("SELECT root_path FROM choro_repos WHERE id = ?").get(repoId); + const repo = db.prepare("SELECT root_path, portable_key FROM choro_repos WHERE id = ?").get(repoId); const embedOne = embedder.embedSingle ? (t) => embedder.embedSingle(t) : (t) => embedder.embed(t); const rows = store.getChoroPageVectors(repoId); const bySlug = new Map(rows.map((r) => [r.slug, r])); @@ -561,7 +589,7 @@ export function createChoroQueries({ store }) { const fresh = !dimDrift && row && row.source_generated_at === page.generated_at && row.vector; if (fresh) { result.skipped++; continue; } if (row && row.vector) result.stale++; - const abs = join(repo.root_path, page.rel_path); + const abs = join(localRootPath(repo), page.rel_path); if (!existsSync(abs)) continue; let text; try { @@ -636,5 +664,8 @@ export function createChoroQueries({ store }) { listRepos, listRepoFacts, repoFacts, repoFactsById, repoFromRow, listRepoRow, repoByRoot, resolveRepo, moduleMap, readPage, search, repoByCwd, outline, claimDrift, embedPageVectors, searchSemantic, embedQueryText, semanticTopN, + // 写路径(/choro/run、T1 后台任务)拿本机可用的仓库根:resolveRepo 回的是 + // 原始行,root_path 可能是别的机器最近一次登记的位置(#581)。 + localRootForRepo: localRootPath, }; } diff --git a/dsh-mneme/lib/wiki/t0.js b/dsh-mneme/lib/wiki/t0.js index 24325ec7..307653a8 100644 --- a/dsh-mneme/lib/wiki/t0.js +++ b/dsh-mneme/lib/wiki/t0.js @@ -18,6 +18,7 @@ import { readRootPackage, workspacePrefixes, compileInclude, } from "./scan.js"; import { partitionModules } from "./partition.js"; +import { portableKeyFor, hostId } from "./portable.js"; import { renderSkeletonPage, renderIndex, extractHumanBlocks, injectHumanBlocks, TEMPLATE_VERSION, T1_SECTION_START, @@ -258,7 +259,11 @@ export function runChoroT0({ repoRoot, store = null, apply = false, guardT1Loss writeFileSync(indexAbs, indexContent, "utf8"); bytes += Buffer.byteLength(indexContent, "utf8"); - const repoRow = store.upsertChoroRepo(rootPath, repoName, loaded.fingerprint, gitBaseline, generatedAt); + // 身份与位置一起登记(#581):身份键从 remote URL 归一化(无 remote 退化根提交 + // 哈希),位置表按当前 host 落一行。T1 的登记校验与读侧的本机路径解析都吃这两样。 + const portableKey = portableKeyFor(rootPath); + const repoRow = store.upsertChoroRepo(rootPath, repoName, loaded.fingerprint, gitBaseline, generatedAt, portableKey); + store.upsertChoroRepoPath(portableKey, hostId(), rootPath, generatedAt); store.replaceChoroGraph(repoRow.id, { edges: moduleEdges, modules: modules.map((m) => ({ diff --git a/dsh-mneme/lib/wiki/t1-jobs.js b/dsh-mneme/lib/wiki/t1-jobs.js index 707d2d55..253fdbec 100644 --- a/dsh-mneme/lib/wiki/t1-jobs.js +++ b/dsh-mneme/lib/wiki/t1-jobs.js @@ -117,8 +117,12 @@ export function createT1Jobs({ * 起一个后台 T1。返回 {started:true, jobId} 或 {started:false, code, jobId?}—— * 路由据此分叉 200 / 409,本层不碰 HTTP。 */ - function start({ repo = null, module = null, retryFailed = false } = {}) { - const rootPath = typeof repo?.root_path === "string" ? repo.root_path : ""; + function start({ repo = null, module = null, retryFailed = false, rootPath: rootPathOverride = null } = {}) { + // rootPath 覆写(#581):repo 是身份行,root_path 可能是别的机器最近一次登 + // 记的位置;调用方(/choro/t1)经 queries.localRootForRepo 传本机根。 + const rootPath = typeof rootPathOverride === "string" && rootPathOverride !== "" + ? rootPathOverride + : (typeof repo?.root_path === "string" ? repo.root_path : ""); if (rootPath === "") return { started: false, code: "repo-invalid" }; const repoKey = String(repo.id ?? rootPath); const existing = runningByRepo.get(repoKey); diff --git a/dsh-mneme/lib/wiki/t1.js b/dsh-mneme/lib/wiki/t1.js index d7161caa..8ece1390 100644 --- a/dsh-mneme/lib/wiki/t1.js +++ b/dsh-mneme/lib/wiki/t1.js @@ -28,6 +28,7 @@ import { FM_VERIFIED_AT, FM_VERIFIED_BASELINE, setFrontmatterKeys, } from "./render.js"; import { gitHead } from "./t0.js"; +import { portableKeyFor, hostId } from "./portable.js"; // issue #607:拒绝文案里的 CLI 命令要给绝对路径入口(profile 装的插件没有全局 // shim,光写 `dsh-mneme-choro` 用户照抄只会 command not found)。 import { choroCliCommandFrom } from "./cli-hint.js"; @@ -323,10 +324,20 @@ export function createT1({ store, runSession = null, limits = DEFAULT_LIMITS, no async function runT1({ repoRoot, model = DEFAULT_T1_MODEL, moduleSlug = null, retryFailed = false, dryRun = false, dshExe = null, limits: limitsOverride = null }) { const cap = { ...limits, ...limitsOverride }; const rootPath = realpathSync(resolve(repoRoot)); - const repo = store.db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath); + // 登记校验(#581):本机路径没登记时,按可移植身份认领——同一仓库已在别的 + // 机器登记过(共享库),本机直接富化那一行,消灭「每端都必须先各自跑一次 + // T0」。认领成功的同时把本机路径落进位置表,读侧从此能按 host 解析到它。 + const portableKey = portableKeyFor(rootPath); + let repo = store.db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath); + if (!repo && portableKey) { + repo = store.db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined; + } if (!repo) { throw new T1Refusal("not-registered", `${rootPath} 还没有登记:先跑一次 T0(${T1_CLI ?? "dsh-mneme-choro --apply"})`); } + if (portableKey) { + store.upsertChoroRepoPath(portableKey, hostId(), rootPath, now()); + } // 选模块:skeleton 必跑;failed 只在 retryFailed 时;enriched 跳过(重富化留待显式 --module)。 const pages = store.db.prepare( diff --git a/dsh-mneme/src/api.js b/dsh-mneme/src/api.js index b050eab8..72c0be2d 100644 --- a/dsh-mneme/src/api.js +++ b/dsh-mneme/src/api.js @@ -3457,7 +3457,9 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = }); return; } - repoRoot = repo.root_path; + // 本机可用的根(#581):身份行上的 root_path 可能是别的机器最近一次 + // 登记的位置,直接拿去跑 T0 会 no-such-dir。 + repoRoot = choro.localRootForRepo(repo); } try { @@ -3533,6 +3535,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } const started = choro.startT1Job({ repo, + // 本机根(#581):身份行的 root_path 可能是别的机器登记的位置。 + rootPath: typeof choro.localRootForRepo === "function" ? choro.localRootForRepo(repo) : null, module: body && typeof body.module === "string" && body.module.trim() !== "" ? body.module.trim() : null, retryFailed: body && typeof body === "object" ? body.retryFailed === true : false }); diff --git a/dsh-mneme/src/choro-command.js b/dsh-mneme/src/choro-command.js index 8b375ae7..4c1a54b2 100644 --- a/dsh-mneme/src/choro-command.js +++ b/dsh-mneme/src/choro-command.js @@ -33,9 +33,12 @@ function dateOnly(iso) { * 几十毫秒起步(P29)。这里是用户主动敲的,付得起一次 rev-parse。 * 三种结局分开报:一致 / 不一致 / 读不到 git——不能把「读不到」说成「没过期」。 */ -function staleness(repo) { +function staleness(repo, localRoot) { const baseline = String(repo.git_baseline ?? "").trim(); - const head = gitHead(repo.root_path); + // 本机根(#581):repo 由 repoByCwd 解析(身份行),root_path 可能是别的机器 + // 最近一次登记的位置;调用方经 queries.localRootForRepo 解析本机路径。旧装配 + // 没有这个函数时退回行的 root_path(单机语义不变)。 + const head = gitHead(localRoot ?? repo.root_path); if (!head) return { state: "no-git", baseline, head: null }; if (baseline && baseline === head) return { state: "fresh", baseline, head }; return { state: "stale", baseline, head }; @@ -81,7 +84,7 @@ export function createChoroHandler({ choro, config, language = () => "zh" } = {} const listed = block ? block.split("\n").filter((line) => line.startsWith("- ")).length : 0; const rest = Math.max(0, outline.modules.length - listed); if (rest > 0) lines.push(S.truncated(rest)); - const head = staleness(repo); + const head = staleness(repo, typeof choro.localRootForRepo === "function" ? choro.localRootForRepo(repo) : null); if (head.state === "fresh") lines.push(S.fresh(shortSha(head.head))); else if (head.state === "stale") lines.push(S.stale(shortSha(head.baseline), shortSha(head.head), cli)); else lines.push(S.noGit); diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 29a7e978..5e51f196 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -611,10 +611,24 @@ CREATE TABLE IF NOT EXISTS choro_repos ( plan_fingerprint TEXT, -- 上次成功 run 的 plan.yaml 内容 sha256 git_baseline TEXT, -- 上次成功 run 的 HEAD created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + updated_at TEXT NOT NULL, + portable_key TEXT -- 可移植身份(#581):remote URL 归一化,无 remote 退化仓库名 ); CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_repos_root ON choro_repos(root_path); +-- 每机一份的位置表(#581):身份与位置分家——memory.db 可以跨端共享,工作 +-- 副本的位置不能。host_id 取 os.hostname()(src/wiki/portable.js);同一身份 +-- 同一台机器只有一行,T0/T1 登记时 upsert。读侧按当前 host 取本机路径,取 +-- 不到回退 choro_repos.root_path(行上的 root_path 从此是「最近一次登记的 +-- 位置」,单机库两个值恒等,行为不变)。 +CREATE TABLE IF NOT EXISTS choro_repo_paths ( + repo_key TEXT NOT NULL, + host_id TEXT NOT NULL, + root_path TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + PRIMARY KEY (repo_key, host_id) +); + -- 模块划分与依赖边。fingerprint = 模块文件集 + 边集的内容 sha256:幂等语义靠 -- replaceChoroGraph 的整仓替换,不靠逐行 diff,所以本表没有「上次见过的划分」 -- 之外的账要记。 @@ -1339,6 +1353,10 @@ export function createStore(path) { // 向量本体列),存量库经 PRAGMA 检查补列。 addColumn("choro_vectors", "vector", "ALTER TABLE choro_vectors ADD COLUMN vector TEXT"); addColumn("choro_vectors", "source_generated_at", "ALTER TABLE choro_vectors ADD COLUMN source_generated_at TEXT"); + // chorography 跨端身份(#581):存量行的 portable_key 留 NULL——键只在它 + // 自己的机器上重跑 T0/T1 时补上,不猜(remote URL 拿不到就是拿不到)。 + // choro_repo_paths 建表走 SCHEMA 的 CREATE TABLE IF NOT EXISTS,老库重开自动补。 + addColumn("choro_repos", "portable_key", "ALTER TABLE choro_repos ADD COLUMN portable_key TEXT"); // v0.8.0 A1(issue #17)scope 隔离存储层:四列全部可空、不带 DEFAULT——存量行 // 零重写,读侧把 NULL 视为未标注(= 全局可见)。是否落值由 scopeEnabled 门控 @@ -5211,19 +5229,60 @@ export function createStore(path) { return db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath) ?? undefined; } - // 登记或刷新仓库行。created_at 只在首次插入时落(ON CONFLICT 不碰它); - // plan_fingerprint / git_baseline 永远反映上次成功 run——失败 run 不写这两格 - // (红线 5「失败不推进 state」,库侧与 state.json 同一口径)。 - function upsertChoroRepo(rootPath, name, planFingerprint, gitBaseline, updatedAt) { + // 位置行 upsert(#581):同一身份同一台机器只一行,root_path 与 last_seen_at + // 反映最近一次登记。repo_key 为空(无 remote 且名字算不出)就不落——位置表 + // 只为可移植身份服务,没有身份的行留在 choro_repos.root_path 的旧语义里。 + function upsertChoroRepoPath(repoKey, hostId, rootPath, nowIso) { + if (!repoKey || !hostId) return; db.prepare( - `INSERT INTO choro_repos (root_path, name, plan_fingerprint, git_baseline, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(root_path) DO UPDATE SET - name = excluded.name, - plan_fingerprint = excluded.plan_fingerprint, - git_baseline = excluded.git_baseline, - updated_at = excluded.updated_at` - ).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, updatedAt); + `INSERT INTO choro_repo_paths (repo_key, host_id, root_path, last_seen_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(repo_key, host_id) DO UPDATE SET + root_path = excluded.root_path, + last_seen_at = excluded.last_seen_at` + ).run(repoKey, hostId, rootPath, nowIso); + } + + function choroRepoPathFor(repoKey, hostId) { + if (!repoKey || !hostId) return null; + return db.prepare("SELECT * FROM choro_repo_paths WHERE repo_key = ? AND host_id = ?").get(repoKey, hostId) ?? null; + } + + // 登记或刷新仓库行。created_at 只在首次插入时落;plan_fingerprint / git_baseline + // 永远反映上次成功 run——失败 run 不写这两格(红线 5「失败不推进 state」,库侧 + // 与 state.json 同一口径)。 + // + // 身份(#581):同一 portable_key 的行**优先**收敛(键的唯一性靠它保证——若 + // 让 root_path 命中优先,升格期的跨端重复行会在各自机器上把同一个键 adoption + // 到两行上,键就废了);本机路径命中只在没有同键行时兜底(无键仓库的旧口径)。 + // 收敛到的行上 root_path 更新为「最近一次登记的位置」,读侧经 choroRepoPathFor + // 回退它——单机库两个值恒等、行为不变。**升级前已跨端登记出的重复行不合并**: + // 页面与 claims 挂在 repo_id 上,机器间合并行等于改写归属;所以 UPDATE + // root_path 前先查目标值是否被别的行占着(被占就保留本行原值,位置走位置表) + // ——否则升格期登记会撞 idx_choro_repos_root 直接抛。portable_key 缺席(无 + // remote 且名字算不出)时退回旧的 root_path 冲突口径,存量语义不变。 + function upsertChoroRepo(rootPath, name, planFingerprint, gitBaseline, updatedAt, portableKey = null) { + let existing = portableKey + ? (db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined) + : undefined; + if (!existing) existing = choroRepoByRoot(rootPath); + if (existing) { + const rootTaken = db.prepare("SELECT id FROM choro_repos WHERE root_path = ? AND id != ?") + .get(rootPath, existing.id); + const nextRoot = rootTaken ? existing.root_path : rootPath; + db.prepare( + `UPDATE choro_repos SET root_path = ?, name = ?, plan_fingerprint = ?, git_baseline = ?, updated_at = ? + WHERE id = ?` + ).run(nextRoot, name, planFingerprint, gitBaseline, updatedAt, existing.id); + if (portableKey && existing.portable_key !== portableKey) { + db.prepare("UPDATE choro_repos SET portable_key = ? WHERE id = ?").run(portableKey, existing.id); + } + return choroRepoByRoot(nextRoot); + } + db.prepare( + `INSERT INTO choro_repos (root_path, name, plan_fingerprint, git_baseline, created_at, updated_at, portable_key) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, updatedAt, portableKey); return choroRepoByRoot(rootPath); } @@ -5865,6 +5924,8 @@ export function createStore(path) { // chorography(仓库地志):M0 只写不读,消费面 M1 接(见上面注释块)。 choroRepoByRoot, upsertChoroRepo, + upsertChoroRepoPath, + choroRepoPathFor, replaceChoroGraph, choroPageBySlug, recordChoroPageFailure, diff --git a/dsh-mneme/src/wiki/portable.js b/dsh-mneme/src/wiki/portable.js new file mode 100644 index 00000000..5eccbf11 --- /dev/null +++ b/dsh-mneme/src/wiki/portable.js @@ -0,0 +1,88 @@ +// 仓库身份的可移植键(#581):choro_repos.root_path 是本机绝对路径,双系统 / +// 双机共享同一个 memory.db 时它把 per-host 状态写进了 shared 状态——Windows 端 +// 登记的行,macOS 端拼出来的路径不存在,再跑一次 T0 又多出第二行(从此不带 +// repo 参数的调用命中 repo-ambiguous)。身份与位置从此分家:身份 = portable_key +// (remote URL 归一化;无 remote 时退化为仓库名),位置 = choro_repo_paths +// 每机一行(host_id = os.hostname())。 +import { execFileSync } from "node:child_process"; +import os from "node:os"; + +/** + * 当前宿主的稳定 id。跨端共享库时每台机器各占一行位置;同一台机器双系统 + * (Windows / macOS 各有 hostname)天然是两行,与「两份工作副本」一一对应。 + */ +export function hostId() { + return os.hostname(); +} + +/** + * remote URL → 归一化身份串。目标形态:`host/path`(全小写)。 + * - 协议头(https:// / ssh:// / git://)剥掉; + * - 尾部 `/` 与 `.git` 剥掉; + * - scp 形态 `git@host:path` 的冒号换成 `/`——判据是冒号在最后一个 `@` 之后、 + * 第一个 `/` 之前;https 的 userinfo(`user:token@host`)冒号在 `@` 之前, + * **不能动**:token 换成 `/` 既毁匹配、还会把凭据的形状带进键里; + * - userinfo(含 token)整体剥掉:凭据不进键——泄进库里是一回事,令牌轮换 + * 后同一仓库算出不同键是另一回事,两个都不可接受。 + */ +export function normalizeGitUrl(rawUrl) { + let s = String(rawUrl ?? "").trim(); + if (!s) return null; + s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); + s = s.replace(/\/+$/, ""); + s = s.replace(/\.git$/i, ""); + const firstSlash = s.indexOf("/"); + const authority = firstSlash === -1 ? s : s.slice(0, firstSlash); + const at = authority.lastIndexOf("@"); + const colon = authority.indexOf(":", at + 1); + if (colon !== -1) { + s = authority.slice(0, colon) + "/" + authority.slice(colon + 1) + + (firstSlash === -1 ? "" : s.slice(firstSlash)); + } + const at2 = s.lastIndexOf("@"); + const hostStart = at2 === -1 ? 0 : at2 + 1; + const out = s.slice(hostStart).toLowerCase(); + return out || null; +} + +function gitRemoteUrl(repoRoot) { + try { + // stderr 接管:无 remote 时 git 写 stderr,不能让它漏到 CLI 界面上。 + const out = execFileSync("git", ["config", "--get", "remote.origin.url"], { + cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + }); + return String(out).trim() || null; + } catch { + return null; + } +} + +/** 根提交哈希:同一仓库历史的第一个提交,跨副本恒定(与 HEAD 无关)。 */ +function gitRootCommit(repoRoot) { + try { + const out = execFileSync("git", ["rev-list", "--max-parents=0", "HEAD"], { + cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + }); + const lines = String(out).trim().split("\n").filter(Boolean); + // 多根(嫁接历史)取字典序最小:同一仓库在任何副本上必须算出同一个键。 + return lines.length ? lines.sort()[0] : null; + } catch { + return null; + } +} + +/** + * 仓库的可移植身份。优先 remote.origin.url 归一化;无 remote 退化到根提交 + * 哈希(`git:`)——它跨副本恒定,又与「碰巧同名的别的仓库」天然区分。 + * issue #581 提的「退化为仓库名」不能用:目录名与 package.json name 的重合率 + * 极高(docs / scripts / 同一模板起家的一批仓库),拿它作键会把两个不相干的 + * 仓库收敛成一行,第二次登记就吃掉别人的页。两级都拿不到(非 git 目录)返回 + * null:那行退回 root_path 的旧语义,不硬造身份。 + */ +export function portableKeyFor(rootPath) { + const key = normalizeGitUrl(gitRemoteUrl(rootPath)); + if (key) return key; + const rootCommit = gitRootCommit(rootPath); + if (rootCommit) return `git:${rootCommit}`; + return null; +} diff --git a/dsh-mneme/src/wiki/queries.js b/dsh-mneme/src/wiki/queries.js index 1f6ac4a3..6eb07cd9 100644 --- a/dsh-mneme/src/wiki/queries.js +++ b/dsh-mneme/src/wiki/queries.js @@ -14,6 +14,7 @@ import { join, resolve, sep } from "node:path"; import { gitHead } from "./t0.js"; import { loadPlanFromRepo } from "./plan.js"; import { TEMPLATE_VERSION, T1_SECTION_START } from "./render.js"; +import { hostId as defaultHostId } from "./portable.js"; // claim 失效判定的纯函数(借 openwiki 的 Grounded Claims):解析 diff + 逐条比对 // 三列证据。放独立模块是因为它零 DB、零 IO(除 diffSince 那一次 git),可单测。 import { diffSince, summarizeClaims } from "./claims.js"; @@ -48,13 +49,30 @@ function cosine(a, b) { return denom === 0 ? 0 : dot / denom; } -export function createChoroQueries({ store }) { +export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) { const db = store.db; + // 本机路径解析(#581):身份行(choro_repos)上的 root_path 是「最近一次登记 + // 的位置」,未必是本机的——跨端共享库时可能是另一台机器的路径。当前机器登记 + // 过(choro_repo_paths 有本 host 的行)就用本机路径;没登记过退回行上的 + // root_path。单机库两个值恒等,行为不变;跨端库在本机还没跑过 T0/T1 时拿不到 + // 本机路径,文件访问落进既有的 page-missing / unreadable 失败口径(wiki-tools + // 的共享库用例锁着那个形状)——路径形状跨 OS 互不为前缀,不会拿别的机器的 + // 路径读出错误内容。 + const localRootPath = (row) => { + const key = row?.portable_key; + if (key) { + const p = db.prepare("SELECT root_path FROM choro_repo_paths WHERE repo_key = ? AND host_id = ?") + .get(key, hostIdFn()); + if (p && p.root_path) return p.root_path; + } + return row?.root_path ?? ""; + }; + /** 已登记仓库(含模块/页面计数,面板仓库选择器与工具的 repo 解析共用)。 */ function listRepos() { const rows = db.prepare( - `SELECT r.id, r.root_path, r.name, r.plan_fingerprint, r.git_baseline, r.updated_at, + `SELECT r.id, r.root_path, r.portable_key, r.name, r.plan_fingerprint, r.git_baseline, r.updated_at, (SELECT COUNT(*) FROM choro_modules m WHERE m.repo_id = r.id) AS module_count, (SELECT COUNT(*) FROM choro_pages p JOIN choro_modules m2 ON p.module_id = m2.id WHERE m2.repo_id = r.id) AS page_count, @@ -86,7 +104,7 @@ export function createChoroQueries({ store }) { * 新基线说话,不能再拿旧行算一遍)。混用会得到自相矛盾的回执。 */ function repoFromRow(row) { - const rootPath = String(row.root_path ?? ""); + const rootPath = localRootPath(row); return { id: row.id, name: row.name, @@ -127,7 +145,7 @@ export function createChoroQueries({ store }) { } function repoFacts(row) { - const rootPath = String(row.root_path ?? ""); + const rootPath = localRootPath(row); // git 优先:代码变了是更硬的「该重跑」信号,plan 变了才退而求其次——两者同时 // 成立时面板只说一句话,说「git」比说「plan」更贴近用户看到的现象(他刚改了码)。 const head = rootPath === "" ? null : gitHead(rootPath); @@ -230,8 +248,8 @@ export function createChoroQueries({ store }) { WHERE m.repo_id = ? AND m.slug = ?` ).get(repoId, String(slug)); if (!row) return null; - const repo = db.prepare("SELECT root_path, name FROM choro_repos WHERE id = ?").get(repoId); - const abs = join(repo.root_path, row.rel_path); + const repo = db.prepare("SELECT root_path, portable_key, name FROM choro_repos WHERE id = ?").get(repoId); + const abs = join(localRootPath(repo), row.rel_path); let content; try { content = readFileSync(abs, "utf8"); @@ -270,10 +288,11 @@ export function createChoroQueries({ store }) { WHERE m.repo_id = ? AND (? IS NULL OR m.slug = ?) ORDER BY m.slug` ).all(repoId, moduleSlug, moduleSlug); - const repo = db.prepare("SELECT root_path FROM choro_repos WHERE id = ?").get(repoId); + const repo = db.prepare("SELECT root_path, portable_key FROM choro_repos WHERE id = ?").get(repoId); const hits = []; + const searchRoot = localRootPath(repo); for (const page of pages) { - const abs = join(repo.root_path, page.rel_path); + const abs = join(searchRoot, page.rel_path); let text; try { text = readFileSync(abs, "utf8"); @@ -334,13 +353,22 @@ export function createChoroQueries({ store }) { const cmp = (p) => (process.platform === "win32" ? p.toLowerCase() : p); const target = cmp(normalized); const rows = db.prepare("SELECT * FROM choro_repos ORDER BY id").all(); + // 候选根(#581):除了身份行的 root_path(「最近一次登记的位置」可能是别的 + // 机器的),本机在位置表登记过的路径也参与前缀匹配。位置行**只认当前 + // host**——两台同名盘符的 Windows(都是 E:\repo)不能互相认领对方的目录。 + const byKey = new Map(rows.filter((r) => r.portable_key).map((r) => [r.portable_key, r])); + const candidates = rows.map((row) => ({ root: String(row.root_path ?? ""), row })); + for (const p of db.prepare("SELECT repo_key, root_path FROM choro_repo_paths WHERE host_id = ?").all(hostIdFn())) { + const row = byKey.get(p.repo_key); + if (row) candidates.push({ root: String(p.root_path ?? ""), row }); + } let best = null; - for (const row of rows) { - const root = cmp(String(row.root_path ?? "")); - if (root === "") continue; - const hit = target === root || target.startsWith(root.endsWith(sep) ? root : root + sep); + for (const { root, row } of candidates) { + const croot = cmp(root); + if (croot === "") continue; + const hit = target === croot || target.startsWith(croot.endsWith(sep) ? croot : croot + sep); if (!hit) continue; - if (!best || root.length > cmp(String(best.root_path)).length) best = row; + if (!best || croot.length > cmp(String(best.root_path)).length) best = row; } return best ?? null; } @@ -352,7 +380,7 @@ export function createChoroQueries({ store }) { */ function outline(repoId) { const repo = db.prepare( - "SELECT id, root_path, name, git_baseline, plan_fingerprint, updated_at FROM choro_repos WHERE id = ?" + "SELECT id, root_path, portable_key, name, git_baseline, plan_fingerprint, updated_at FROM choro_repos WHERE id = ?" ).get(repoId); if (!repo) return null; const rows = db.prepare( @@ -379,7 +407,7 @@ export function createChoroQueries({ store }) { repo: { id: repo.id, name: repo.name, - rootPath: repo.root_path, + rootPath: localRootPath(repo), gitBaseline: repo.git_baseline, updatedAt: repo.updated_at, }, @@ -409,9 +437,9 @@ export function createChoroQueries({ store }) { * - 起点 == HEAD → 那组真没变,clean */ function claimDrift(repoId) { - const repo = db.prepare("SELECT id, root_path, name, git_baseline FROM choro_repos WHERE id = ?").get(repoId); + const repo = db.prepare("SELECT id, root_path, portable_key, name, git_baseline FROM choro_repos WHERE id = ?").get(repoId); if (!repo) return null; - const rootPath = String(repo.root_path ?? ""); + const rootPath = localRootPath(repo); const rows = db.prepare( `SELECT c.id, c.statement, c.kind, c.source_path, c.start_line, c.end_line, m.slug AS page_slug, m.title AS page_title, p.rel_path @@ -545,7 +573,7 @@ export function createChoroQueries({ store }) { FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id WHERE m.repo_id = ? ORDER BY m.slug` ).all(repoId); - const repo = db.prepare("SELECT root_path FROM choro_repos WHERE id = ?").get(repoId); + const repo = db.prepare("SELECT root_path, portable_key FROM choro_repos WHERE id = ?").get(repoId); const embedOne = embedder.embedSingle ? (t) => embedder.embedSingle(t) : (t) => embedder.embed(t); const rows = store.getChoroPageVectors(repoId); const bySlug = new Map(rows.map((r) => [r.slug, r])); @@ -561,7 +589,7 @@ export function createChoroQueries({ store }) { const fresh = !dimDrift && row && row.source_generated_at === page.generated_at && row.vector; if (fresh) { result.skipped++; continue; } if (row && row.vector) result.stale++; - const abs = join(repo.root_path, page.rel_path); + const abs = join(localRootPath(repo), page.rel_path); if (!existsSync(abs)) continue; let text; try { @@ -636,5 +664,8 @@ export function createChoroQueries({ store }) { listRepos, listRepoFacts, repoFacts, repoFactsById, repoFromRow, listRepoRow, repoByRoot, resolveRepo, moduleMap, readPage, search, repoByCwd, outline, claimDrift, embedPageVectors, searchSemantic, embedQueryText, semanticTopN, + // 写路径(/choro/run、T1 后台任务)拿本机可用的仓库根:resolveRepo 回的是 + // 原始行,root_path 可能是别的机器最近一次登记的位置(#581)。 + localRootForRepo: localRootPath, }; } diff --git a/dsh-mneme/src/wiki/t0.js b/dsh-mneme/src/wiki/t0.js index 24325ec7..307653a8 100644 --- a/dsh-mneme/src/wiki/t0.js +++ b/dsh-mneme/src/wiki/t0.js @@ -18,6 +18,7 @@ import { readRootPackage, workspacePrefixes, compileInclude, } from "./scan.js"; import { partitionModules } from "./partition.js"; +import { portableKeyFor, hostId } from "./portable.js"; import { renderSkeletonPage, renderIndex, extractHumanBlocks, injectHumanBlocks, TEMPLATE_VERSION, T1_SECTION_START, @@ -258,7 +259,11 @@ export function runChoroT0({ repoRoot, store = null, apply = false, guardT1Loss writeFileSync(indexAbs, indexContent, "utf8"); bytes += Buffer.byteLength(indexContent, "utf8"); - const repoRow = store.upsertChoroRepo(rootPath, repoName, loaded.fingerprint, gitBaseline, generatedAt); + // 身份与位置一起登记(#581):身份键从 remote URL 归一化(无 remote 退化根提交 + // 哈希),位置表按当前 host 落一行。T1 的登记校验与读侧的本机路径解析都吃这两样。 + const portableKey = portableKeyFor(rootPath); + const repoRow = store.upsertChoroRepo(rootPath, repoName, loaded.fingerprint, gitBaseline, generatedAt, portableKey); + store.upsertChoroRepoPath(portableKey, hostId(), rootPath, generatedAt); store.replaceChoroGraph(repoRow.id, { edges: moduleEdges, modules: modules.map((m) => ({ diff --git a/dsh-mneme/src/wiki/t1-jobs.js b/dsh-mneme/src/wiki/t1-jobs.js index 707d2d55..253fdbec 100644 --- a/dsh-mneme/src/wiki/t1-jobs.js +++ b/dsh-mneme/src/wiki/t1-jobs.js @@ -117,8 +117,12 @@ export function createT1Jobs({ * 起一个后台 T1。返回 {started:true, jobId} 或 {started:false, code, jobId?}—— * 路由据此分叉 200 / 409,本层不碰 HTTP。 */ - function start({ repo = null, module = null, retryFailed = false } = {}) { - const rootPath = typeof repo?.root_path === "string" ? repo.root_path : ""; + function start({ repo = null, module = null, retryFailed = false, rootPath: rootPathOverride = null } = {}) { + // rootPath 覆写(#581):repo 是身份行,root_path 可能是别的机器最近一次登 + // 记的位置;调用方(/choro/t1)经 queries.localRootForRepo 传本机根。 + const rootPath = typeof rootPathOverride === "string" && rootPathOverride !== "" + ? rootPathOverride + : (typeof repo?.root_path === "string" ? repo.root_path : ""); if (rootPath === "") return { started: false, code: "repo-invalid" }; const repoKey = String(repo.id ?? rootPath); const existing = runningByRepo.get(repoKey); diff --git a/dsh-mneme/src/wiki/t1.js b/dsh-mneme/src/wiki/t1.js index d7161caa..8ece1390 100644 --- a/dsh-mneme/src/wiki/t1.js +++ b/dsh-mneme/src/wiki/t1.js @@ -28,6 +28,7 @@ import { FM_VERIFIED_AT, FM_VERIFIED_BASELINE, setFrontmatterKeys, } from "./render.js"; import { gitHead } from "./t0.js"; +import { portableKeyFor, hostId } from "./portable.js"; // issue #607:拒绝文案里的 CLI 命令要给绝对路径入口(profile 装的插件没有全局 // shim,光写 `dsh-mneme-choro` 用户照抄只会 command not found)。 import { choroCliCommandFrom } from "./cli-hint.js"; @@ -323,10 +324,20 @@ export function createT1({ store, runSession = null, limits = DEFAULT_LIMITS, no async function runT1({ repoRoot, model = DEFAULT_T1_MODEL, moduleSlug = null, retryFailed = false, dryRun = false, dshExe = null, limits: limitsOverride = null }) { const cap = { ...limits, ...limitsOverride }; const rootPath = realpathSync(resolve(repoRoot)); - const repo = store.db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath); + // 登记校验(#581):本机路径没登记时,按可移植身份认领——同一仓库已在别的 + // 机器登记过(共享库),本机直接富化那一行,消灭「每端都必须先各自跑一次 + // T0」。认领成功的同时把本机路径落进位置表,读侧从此能按 host 解析到它。 + const portableKey = portableKeyFor(rootPath); + let repo = store.db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath); + if (!repo && portableKey) { + repo = store.db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined; + } if (!repo) { throw new T1Refusal("not-registered", `${rootPath} 还没有登记:先跑一次 T0(${T1_CLI ?? "dsh-mneme-choro --apply"})`); } + if (portableKey) { + store.upsertChoroRepoPath(portableKey, hostId(), rootPath, now()); + } // 选模块:skeleton 必跑;failed 只在 retryFailed 时;enriched 跳过(重富化留待显式 --module)。 const pages = store.db.prepare( diff --git a/dsh-mneme/test/wiki-api.test.js b/dsh-mneme/test/wiki-api.test.js index 193eba2a..41aabdcb 100644 --- a/dsh-mneme/test/wiki-api.test.js +++ b/dsh-mneme/test/wiki-api.test.js @@ -62,11 +62,13 @@ async function invokePost(route, path, body) { return { status: res.statusCode, body: JSON.parse(res.body || "{}") }; } -function makeRepo(store) { +// mark:往初始提交里掺区分串(#581 后无 remote 仓库按根提交哈希作身份键—— +// 同一秒提交的相同内容会同键收敛成一行,往同一个库登记两个仓库的用例必须区分开)。 +function makeRepo(store, mark = "") { const root = mkdtempSync(join(tmpdir(), "choro-api-")); writeFileSync(join(root, "package.json"), JSON.stringify({ name: "apifix", scripts: { test: "node --test" } }, null, 2) + "\n", "utf8"); mkdirSync(join(root, "src"), { recursive: true }); - writeFileSync(join(root, "src", "a.js"), 'import { b } from "./b.js";\nexport const a = () => b();\n', "utf8"); + writeFileSync(join(root, "src", "a.js"), `import { b } from "./b.js";\nexport const a = () => b();\n// ${mark}\n`, "utf8"); writeFileSync(join(root, "src", "b.js"), "export const b = () => 1;\n", "utf8"); execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" }); execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" }); @@ -147,7 +149,7 @@ test("repo 解析失败各归各位:未知 → 404;歧义 → 400 带清单 assert.equal(notFound.status, 404); assert.equal(notFound.body.error, "repo-not-found"); - const root2 = makeRepo(store); + const root2 = makeRepo(store, "second"); try { const ambiguous = await invoke(handler(routes, "/api/dsh-mneme/choro/modules"), "/api/dsh-mneme/choro/modules"); assert.equal(ambiguous.status, 400); @@ -505,8 +507,8 @@ function countingChoro(store, { throwOnRun = false } = {}) { } // 造一个「代码已变」的仓库:登记时的 git_baseline 与当前 HEAD 不同。 -function driftRepo(store) { - const root = makeRepo(store); +function driftRepo(store, mark = "") { + const root = makeRepo(store, mark); writeFileSync(join(root, "src", "c.js"), "export const c = () => 2;\n", "utf8"); execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" }); execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "drift", "--no-gpg-sign"], { cwd: root, stdio: "pipe" }); @@ -553,7 +555,7 @@ test("/choro/repos 自动重跑:开关开 + ?repo= 点名且 git 过期 → test("/choro/repos 自动重跑:不 stale 或没点名 ?repo= 时都不刷(一次面板加载不能扫全库)", async () => { const store = createStore(":memory:"); - const cleanRoot = makeRepo(store); + const cleanRoot = makeRepo(store, "clean"); try { const { calls, choro } = countingChoro(store); const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro }); @@ -563,7 +565,7 @@ test("/choro/repos 自动重跑:不 stale 或没点名 ?repo= 时都不刷( assert.equal(calls.length, 0, "staleReason 为 null → 没有刷的理由"); // 造第二个过期仓库,但请求不带 ?repo=:列表路径一个都不许刷。 - const drifted = driftRepo(store); + const drifted = driftRepo(store, "drifted"); try { const listed = await invoke(handler(routes, plain), plain); assert.equal(listed.body.repos.length, 2); diff --git a/dsh-mneme/test/wiki-portable.test.js b/dsh-mneme/test/wiki-portable.test.js new file mode 100644 index 00000000..6ef71bf2 --- /dev/null +++ b/dsh-mneme/test/wiki-portable.test.js @@ -0,0 +1,45 @@ +// 可移植身份(#581)的纯函数回归锁:URL 归一化的形态表 + 无 remote 的退化链。 +// 这层是「身份与位置分家」的地基——归一化漏一种形态,同一仓库在两端就算出两个 +// 键,分家就白做了。 +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { normalizeGitUrl, portableKeyFor, hostId } from "../src/wiki/portable.js"; + +test("normalizeGitUrl:SSH / HTTPS / scp / 大小写归一到同一身份,凭据不进键", () => { + const expected = "github.com/slow-stack/mneme"; + assert.equal(normalizeGitUrl("git@github.com:slow-stack/mneme.git"), expected); + assert.equal(normalizeGitUrl("ssh://git@github.com/slow-stack/mneme.git"), expected); + assert.equal(normalizeGitUrl("https://github.com/slow-stack/mneme"), expected); + assert.equal(normalizeGitUrl("https://github.com/slow-stack/mneme/"), expected); + assert.equal(normalizeGitUrl("HTTPS://GitHub.com/Slow-Stack/Mneme.GIT"), expected); + // token 在 userinfo 里:不进键。泄进库里是一回事,令牌轮换后同一仓库算出 + // 不同键、跨端匹配失效是另一回事,两个都不可接受(#581 的评审预判)。 + assert.equal(normalizeGitUrl("https://user:ghp_token@github.com/slow-stack/mneme.git"), expected); + assert.equal(normalizeGitUrl(""), null); + assert.equal(normalizeGitUrl(null), null); +}); + +test("portableKeyFor:有 remote 用归一化 URL,无 remote 退化根提交哈希(同名仓库不撞键)", () => { + const root = mkdtempSync(join(tmpdir(), "choro-portable-")); + try { + writeFileSync(join(root, "package.json"), "{}\n", "utf8"); + execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" }); + execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" }); + execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init", "--no-gpg-sign"], { cwd: root, stdio: "pipe" }); + // 无 remote:退化根提交哈希——跨副本恒定,且与「碰巧同名的别的仓库」天然 + // 区分(issue 提的仓库名退化不能用:重名率太高,第二次登记会吃掉别人的行)。 + const k1 = portableKeyFor(root); + assert.match(k1, /^git:[0-9a-f]{40}$/, "无 remote 退化 git:<根提交>"); + assert.equal(portableKeyFor(root), k1, "同仓库重复计算恒定"); + execFileSync("git", ["remote", "add", "origin", "git@github.com:slow-stack/mneme.git"], { cwd: root, stdio: "pipe" }); + assert.equal(portableKeyFor(root), "github.com/slow-stack/mneme", "有 remote 时 URL 赢"); + assert.equal(typeof hostId(), "string"); + assert.ok(hostId().length > 0, "hostId 必须非空(位置表的主键一半)"); + } finally { + try { rmSync(root, { recursive: true, force: true }); } catch { /* 留给系统清理 */ } + } +}); diff --git a/dsh-mneme/test/wiki-queries.test.js b/dsh-mneme/test/wiki-queries.test.js index 85d5d91f..afdc5590 100644 --- a/dsh-mneme/test/wiki-queries.test.js +++ b/dsh-mneme/test/wiki-queries.test.js @@ -14,13 +14,16 @@ import { createChoroQueries } from "../src/wiki/queries.js"; const FIXED_NOW = () => "2026-11-05T00:00:00.000Z"; -function makeFixture() { +// mark:往文件里掺一个区分串。#581 之后无 remote 的仓库按根提交哈希作身份键, +// 同一秒提交的两个内容相同的夹具会算出同一个哈希——真仓库不会(同内容 + 同秒 +// 的概率趋零),但测试里往同一个库登记两个夹具的用例必须显式区分开。 +function makeFixture(mark = "") { const root = mkdtempSync(join(tmpdir(), "choro-queries-")); writeFileSync(join(root, "package.json"), JSON.stringify({ name: "qfix", scripts: { test: "node --test" } }, null, 2) + "\n", "utf8"); mkdirSync(join(root, "src"), { recursive: true }); writeFileSync(join(root, "src", "a.js"), 'import { b } from "./b.js";\nimport h from "left-pad";\nexport const a = () => b();\n', "utf8"); writeFileSync(join(root, "src", "b.js"), 'import { b2 } from "./b2.js";\nexport const b = () => b2();\n', "utf8"); - writeFileSync(join(root, "src", "b2.js"), "export const b2 = () => 1;\n", "utf8"); + writeFileSync(join(root, "src", "b2.js"), "export const b2 = () => 1;\n" + mark, "utf8"); execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" }); execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" }); execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init", "--no-gpg-sign"], { cwd: root, stdio: "pipe" }); @@ -140,8 +143,9 @@ test("listRepos / resolveRepo:单仓自动收敛,多仓歧义要显式给键 assert.equal(auto.id, repos[0].id, "只有一个仓库时不带键也能解析"); assert.ok(q.resolveRepo("qfix").id, "按显示名解析"); assert.equal(q.resolveRepo("no-such-repo"), null); - // 造第二个仓库让解析变歧义 - const root2 = makeFixture(); + // 造第二个仓库让解析变歧义(mark 区分:无 remote 夹具按根提交哈希作键, + // 同一秒提交的相同内容会同键收敛成一行,歧义就造不出来了) + const root2 = makeFixture("second-repo-mark"); try { runChoroT0({ repoRoot: root2, store, apply: true, now: FIXED_NOW }); const amb = q.resolveRepo(undefined); @@ -239,7 +243,7 @@ test("repoByCwd:祖先前缀命中、最长 root 胜出、cwd 不存在返回 assert.equal(q.repoByCwd(""), null); assert.equal(q.repoByCwd(null), null); // 嵌套仓库:外层先登记,内层后登记 → 内层会话必须被内层抢走(最长前缀) - nested = makeFixture(); + nested = makeFixture("inner-repo-mark"); runChoroT0({ repoRoot: nested, store, apply: true, now: FIXED_NOW }); const inner = q.repoByCwd(join(nested, "src")); assert.equal(inner.root_path, nested, "嵌套时取最深那层,否则内层会话被外层仓库抢走"); @@ -396,3 +400,33 @@ test("claimDrift:未知仓库返回 null(调用方据此 404,不是空清 rmTempBestEffort(root); } }); + +// ---- #581 本机路径解析:身份行 vs 位置行 ---- + +test("共享库跨端:读侧按当前 host 的位置行解析本机根,未登记的机器回退身份行(#581)", () => { + const root = makeFixture(); + // T0 前挂 remote:身份键从 URL 归一化,两台机器的同一仓库算出同一个键 + execFileSync("git", ["remote", "add", "origin", "git@github.com:slow-stack/demo.git"], { cwd: root, stdio: "pipe" }); + const store = createStore(":memory:"); + try { + runChoroT0({ repoRoot: root, store, apply: true, now: FIXED_NOW }); + const row = store.db.prepare("SELECT id, root_path, portable_key FROM choro_repos").get(); + assert.equal(row.portable_key, "github.com/slow-stack/demo", "T0 登记时把身份键算出来落库"); + // 另一台机器(hostB)共享这个库,并在本机路径登记过自己的一份工作副本 + store.upsertChoroRepoPath(row.portable_key, "hostB", "/Volumes/demo", FIXED_NOW()); + const qB = createChoroQueries({ store, hostId: () => "hostB" }); + const factsB = qB.repoFromRow(store.db.prepare("SELECT * FROM choro_repos WHERE id = ?").get(row.id)); + assert.equal(factsB.rootPath, "/Volumes/demo", "hostB 解析到自己的位置行"); + // 没登记过位置行的 hostC:回退身份行 root_path——文件访问落进既有的 + // page-missing 口径(拿不到本机路径,不能拿别的机器的路径读出错误内容) + const qC = createChoroQueries({ store, hostId: () => "hostC" }); + const factsC = qC.repoFromRow(store.db.prepare("SELECT * FROM choro_repos WHERE id = ?").get(row.id)); + assert.equal(factsC.rootPath, row.root_path, "回退身份行的 root_path"); + // 本机(跑过 T0 的这台,默认 hostId):行为与升级前完全一致 + const qA = createChoroQueries({ store }); + assert.equal(qA.repoFromRow(store.db.prepare("SELECT * FROM choro_repos WHERE id = ?").get(row.id)).rootPath, root); + } finally { + store.close(); + rmTempBestEffort(root); + } +}); diff --git a/dsh-mneme/test/wiki-store.test.js b/dsh-mneme/test/wiki-store.test.js index aeda59ae..b4c4391e 100644 --- a/dsh-mneme/test/wiki-store.test.js +++ b/dsh-mneme/test/wiki-store.test.js @@ -188,3 +188,82 @@ test("claims 经 replaceChoroGraph 落库(M2 的路径,M0 只验形状)", assert.equal(claim.start_line, 25); store.close(); }); + +// ---- #581 可移植身份:迁移、位置表、跨端收敛 ---- + +test("旧库形态补 choro_repos.portable_key 列与 choro_repo_paths 表,重开幂等(#581)", async () => { + const dir = mkdtempSync(join(tmpdir(), "choro-legacy-")); + const dbPath = join(dir, "memory.db"); + try { + // 手工建 0.14.x 形态(无 portable_key、无位置表)+ 一行存量登记 + const legacy = new DatabaseSync(dbPath); + legacy.exec(`CREATE TABLE choro_repos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, root_path TEXT NOT NULL, name TEXT NOT NULL, + plan_fingerprint TEXT, git_baseline TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`); + legacy.exec("CREATE UNIQUE INDEX idx_choro_repos_root ON choro_repos(root_path)"); + legacy.prepare("INSERT INTO choro_repos (root_path, name, created_at, updated_at) VALUES (?,?,?,?)") + .run("D:\legacy\repo", "legacy", "2026-11-01T00:00:00.000Z", "2026-11-01T00:00:00.000Z"); + legacy.close(); + + const store = createStore(dbPath); + const cols = store.db.prepare("PRAGMA table_info(choro_repos)").all().map((c) => c.name); + assert.ok(cols.includes("portable_key"), "portable_key 列补齐"); + assert.ok(store.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='choro_repo_paths'").get(), + "位置表补建(CREATE IF NOT EXISTS 走 SCHEMA)"); + // 存量行的键留 NULL:remote 拿不到就是拿不到,不猜(防「编一个看起来能匹配的键」) + assert.equal(store.db.prepare("SELECT portable_key FROM choro_repos").get().portable_key, null); + store.close(); + // 幂等:重开不重复加列、不动已落盘的行 + const again = createStore(dbPath); + assert.equal(again.db.prepare("PRAGMA table_info(choro_repos)").all().filter((c) => c.name === "portable_key").length, 1); + assert.equal(again.db.prepare("SELECT COUNT(*) AS n FROM choro_repos").get().n, 1); + again.close(); + } finally { + await rmTempEventually(dir); + } +}); + +test("位置表 upsert:同身份同机一行覆写,跨机各一行;空键不查不抛(#581)", () => { + const store = openMemory(); + store.upsertChoroRepoPath("github.com/x/y", "hostA", "D:\\repo", "2026-11-10T00:00:00.000Z"); + store.upsertChoroRepoPath("github.com/x/y", "hostB", "/Volumes/repo", "2026-11-10T00:00:00.000Z"); + store.upsertChoroRepoPath("github.com/x/y", "hostA", "E:\\repo2", "2026-11-11T00:00:00.000Z"); + assert.equal(store.db.prepare("SELECT COUNT(*) AS n FROM choro_repo_paths").get().n, 2, "同键同机只一行"); + assert.equal(store.choroRepoPathFor("github.com/x/y", "hostA").root_path, "E:\\repo2", "同机覆写取最近"); + assert.equal(store.choroRepoPathFor("github.com/x/y", "hostA").last_seen_at, "2026-11-11T00:00:00.000Z"); + assert.equal(store.choroRepoPathFor("github.com/x/y", "hostB").root_path, "/Volumes/repo"); + assert.equal(store.choroRepoPathFor("github.com/x/y", "hostC"), null); + assert.equal(store.choroRepoPathFor(null, "hostA"), null, "空键返回 null 而不是抛"); + store.close(); +}); + +test("upsertChoroRepo:同一 portable_key 跨端登记收敛同一行(#581)", () => { + const store = openMemory(); + const r1 = store.upsertChoroRepo("D:\\repo", "x", null, "aa11", "2026-11-10T00:00:00.000Z", "github.com/x/y"); + store.upsertChoroRepoPath("github.com/x/y", "hostA", "D:\\repo", "2026-11-10T00:00:00.000Z"); + // 另一台机器登记同一仓库(不同 root):收敛到同一行(身份行),root_path 更新 + // 为「最近一次登记的位置」——单机库永远只有一台机器在登记,看不到这个变化。 + const r2 = store.upsertChoroRepo("/Volumes/repo", "x", null, "bb22", "2026-11-11T00:00:00.000Z", "github.com/x/y"); + assert.equal(r2.id, r1.id, "同一身份一行"); + assert.equal(r2.root_path, "/Volumes/repo"); + assert.equal(store.db.prepare("SELECT COUNT(*) AS n FROM choro_repos").get().n, 1); + // created_at 不被跨端登记改写(首次插入时刻的语义保留) + assert.equal(store.db.prepare("SELECT created_at FROM choro_repos").get().created_at, "2026-11-10T00:00:00.000Z"); + store.close(); +}); + +test("upsertChoroRepo:升格期 root 被别的行占着时不强改 root(否则撞 UNIQUE 直接抛)(#581)", () => { + const store = openMemory(); + // 升级前跨端登记出的重复行现场:Windows 行(占 D:\repo,键 NULL)+ Mac 行(占 + // /Volumes/repo,键已补)。此时 Windows 机器带着键 X 来登记 D:\repo—— + // byKey 命中 Mac 行,若强行把它的 root 改成 D:\repo 就撞 idx_choro_repos_root。 + store.upsertChoroRepo("D:\\repo", "x", null, "aa11", "2026-11-09T00:00:00.000Z", null); + store.upsertChoroRepo("/Volumes/repo", "x", null, "bb22", "2026-11-10T00:00:00.000Z", "github.com/x/y"); + const r = store.upsertChoroRepo("D:\\repo", "x", null, "cc33", "2026-11-11T00:00:00.000Z", "github.com/x/y"); + assert.equal(r.root_path, "/Volumes/repo", "root 被占时保留本行原值,位置走位置表"); + assert.equal(store.db.prepare("SELECT COUNT(*) AS n FROM choro_repos").get().n, 2, "重复行不合并(页面挂在 repo_id 上)"); + // 位置行的写入是调用方(t0/t1)的职责——upsert 之后照契约落一行,读侧才解析得到。 + store.upsertChoroRepoPath("github.com/x/y", "hostA", "D:\\repo", "2026-11-11T00:00:00.000Z"); + assert.equal(store.choroRepoPathFor("github.com/x/y", "hostA")?.root_path, "D:\\repo", "本机位置照记"); + store.close(); +}); diff --git a/dsh-mneme/test/wiki-t1.test.js b/dsh-mneme/test/wiki-t1.test.js index a509a8cd..1b5aeb7f 100644 --- a/dsh-mneme/test/wiki-t1.test.js +++ b/dsh-mneme/test/wiki-t1.test.js @@ -6,7 +6,7 @@ // claims 双落(表 + evidence/.json)、未登记仓库拒绝。 import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync, existsSync, cpSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileSync } from "node:child_process"; @@ -14,6 +14,7 @@ import { EventEmitter } from "node:events"; import { runChoroT0 } from "../src/wiki/t0.js"; import { createStore } from "../src/store.js"; import { createT1, T1Refusal, T1_SECTION_START, T1_SECTION_END, makeHeadlessRunner, locateDshExe, resolveCliJs } from "../src/wiki/t1.js"; +import { hostId } from "../src/wiki/portable.js"; const FIXED_NOW = () => "2026-11-05T12:00:00.000Z"; @@ -458,3 +459,38 @@ test("--dsh-exe 只影响这一轮:跑完 env 还原", async () => { rmTempBestEffort(root); } }); + +// ---- #581 跨端认领 ---- + +test("跨端认领:本机路径未登记但身份已登记(另一台机器跑过 T0)→ T1 直接富化那一行", async () => { + const root = makeFixture(); + // 挂 remote:两台机器的同一仓库算出同一个身份键(fixture 无 remote 时键是根 + // 提交哈希,克隆体与原件也同键——这里用 remote 走最常用的那条路)。 + execFileSync("git", ["remote", "add", "origin", "https://github.com/slow-stack/t1fix.git"], { cwd: root, stdio: "pipe" }); + const store = createStore(":memory:"); + let clone = null; + try { + runChoroT0({ repoRoot: root, store, apply: true, now: FIXED_NOW }); + const row = store.db.prepare("SELECT id, root_path, portable_key FROM choro_repos").get(); + assert.equal(row.portable_key, "github.com/slow-stack/t1fix"); + + // 模拟另一台机器:把整个检出(含 .chorography)搬到新路径——共享盘/换机场景 + // 的等价物。新路径没登记过(byRoot 落空),身份却已在库上。 + clone = mkdtempSync(join(tmpdir(), "choro-t1-hostb-")); + cpSync(root, clone, { recursive: true }); + + const t1 = createT1({ store, runSession: async () => ({ ok: true, text: GOOD_OUTPUT }), now: FIXED_NOW }); + const receipt = await t1.runT1({ repoRoot: clone, moduleSlug: "src" }); + assert.equal(receipt.enriched.length, 1, "跨端认领成功,pages 富化在身份行上"); + assert.equal(receipt.failed.length, 0); + // 认领同时把本机路径落进位置表:读侧从此按 host 解析到它 + assert.equal(store.choroRepoPathFor(row.portable_key, hostId())?.root_path, realpathSync(clone), + "认领必须登记本机位置"); + // 页面文件写在克隆体自己的 .chorography 里(真源纪律:正文永远读本机磁盘) + assert.ok(readFileSync(join(clone, ".chorography", "modules", "src.md"), "utf8").includes(T1_SECTION_START)); + } finally { + store.close(); + rmTempBestEffort(root); + if (clone) rmTempBestEffort(clone); + } +}); diff --git a/dsh-mneme/test/wiki-tools.test.js b/dsh-mneme/test/wiki-tools.test.js index 73aad90e..c615a740 100644 --- a/dsh-mneme/test/wiki-tools.test.js +++ b/dsh-mneme/test/wiki-tools.test.js @@ -138,9 +138,13 @@ test("读页错误区分未知 slug、缺失文件和不可读文件", async (t) test("共享库中本机不可达的仓库仍有模块图,读页和检索报告文件访问失败", async (t) => { const { call, store } = await setup(t, { chorographyEnabled: true }, true); const root = makeRepo(t, store); - const [{ id }] = createChoroQueries({ store }).listRepos(); + const [{ id, portable_key: key }] = createChoroQueries({ store }).listRepos(); + // 模拟「本机不可达」(#581 之后两处都要动):身份行的 root_path 指向一个不 + // 存在的检出,且删掉本机的位置行——位置行如果还在,读侧会正确地解析回本机 + // 真路径(那正是修复要给的行为),「不可达」就造不出来了。 store.db.prepare("UPDATE choro_repos SET root_path = ? WHERE id = ?") .run(join(root, "unavailable-checkout"), id); + store.db.prepare("DELETE FROM choro_repo_paths WHERE repo_key = ?").run(key); const repo = String(id); const map = await call("choro_module_map", { repo }); From 314b2b6b93b6883fbac82591ef500e274ba2bb1e Mon Sep 17 00:00:00 2001 From: modusensus Date: Fri, 13 Nov 2026 02:19:28 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(store):=20=E4=BF=AE=E6=AD=A3=20portabl?= =?UTF-8?q?e=5Fkey=20=E7=9A=84=E8=BA=AB=E4=BB=BD=E9=94=AE=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E2=80=94=E2=80=94=E9=80=80=E5=8C=96=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E6=98=AF=E6=A0=B9=E6=8F=90=E4=BA=A4=E5=93=88=E5=B8=8C=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E4=BB=93=E5=BA=93=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh-mneme/lib/store.js | 6 +++--- dsh-mneme/lib/wiki/portable.js | 2 +- dsh-mneme/src/store.js | 6 +++--- dsh-mneme/src/wiki/portable.js | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 5e51f196..afed299c 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -612,7 +612,7 @@ CREATE TABLE IF NOT EXISTS choro_repos ( git_baseline TEXT, -- 上次成功 run 的 HEAD created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - portable_key TEXT -- 可移植身份(#581):remote URL 归一化,无 remote 退化仓库名 + portable_key TEXT -- 可移植身份(#581):remote URL 归一化,无 remote 退化根提交哈希(git:) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_repos_root ON choro_repos(root_path); @@ -5230,7 +5230,7 @@ export function createStore(path) { } // 位置行 upsert(#581):同一身份同一台机器只一行,root_path 与 last_seen_at - // 反映最近一次登记。repo_key 为空(无 remote 且名字算不出)就不落——位置表 + // 反映最近一次登记。repo_key 为空(无 remote 且连根提交都读不到,非 git 目录)就不落——位置表 // 只为可移植身份服务,没有身份的行留在 choro_repos.root_path 的旧语义里。 function upsertChoroRepoPath(repoKey, hostId, rootPath, nowIso) { if (!repoKey || !hostId) return; @@ -5260,7 +5260,7 @@ export function createStore(path) { // 页面与 claims 挂在 repo_id 上,机器间合并行等于改写归属;所以 UPDATE // root_path 前先查目标值是否被别的行占着(被占就保留本行原值,位置走位置表) // ——否则升格期登记会撞 idx_choro_repos_root 直接抛。portable_key 缺席(无 - // remote 且名字算不出)时退回旧的 root_path 冲突口径,存量语义不变。 + // remote 且读不到根提交,非 git 目录)时退回旧的 root_path 冲突口径,存量语义不变。 function upsertChoroRepo(rootPath, name, planFingerprint, gitBaseline, updatedAt, portableKey = null) { let existing = portableKey ? (db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined) diff --git a/dsh-mneme/lib/wiki/portable.js b/dsh-mneme/lib/wiki/portable.js index 5eccbf11..3e36e957 100644 --- a/dsh-mneme/lib/wiki/portable.js +++ b/dsh-mneme/lib/wiki/portable.js @@ -2,7 +2,7 @@ // 双机共享同一个 memory.db 时它把 per-host 状态写进了 shared 状态——Windows 端 // 登记的行,macOS 端拼出来的路径不存在,再跑一次 T0 又多出第二行(从此不带 // repo 参数的调用命中 repo-ambiguous)。身份与位置从此分家:身份 = portable_key -// (remote URL 归一化;无 remote 时退化为仓库名),位置 = choro_repo_paths +// (remote URL 归一化;无 remote 时退化为根提交哈希),位置 = choro_repo_paths // 每机一行(host_id = os.hostname())。 import { execFileSync } from "node:child_process"; import os from "node:os"; diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 5e51f196..afed299c 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -612,7 +612,7 @@ CREATE TABLE IF NOT EXISTS choro_repos ( git_baseline TEXT, -- 上次成功 run 的 HEAD created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - portable_key TEXT -- 可移植身份(#581):remote URL 归一化,无 remote 退化仓库名 + portable_key TEXT -- 可移植身份(#581):remote URL 归一化,无 remote 退化根提交哈希(git:) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_repos_root ON choro_repos(root_path); @@ -5230,7 +5230,7 @@ export function createStore(path) { } // 位置行 upsert(#581):同一身份同一台机器只一行,root_path 与 last_seen_at - // 反映最近一次登记。repo_key 为空(无 remote 且名字算不出)就不落——位置表 + // 反映最近一次登记。repo_key 为空(无 remote 且连根提交都读不到,非 git 目录)就不落——位置表 // 只为可移植身份服务,没有身份的行留在 choro_repos.root_path 的旧语义里。 function upsertChoroRepoPath(repoKey, hostId, rootPath, nowIso) { if (!repoKey || !hostId) return; @@ -5260,7 +5260,7 @@ export function createStore(path) { // 页面与 claims 挂在 repo_id 上,机器间合并行等于改写归属;所以 UPDATE // root_path 前先查目标值是否被别的行占着(被占就保留本行原值,位置走位置表) // ——否则升格期登记会撞 idx_choro_repos_root 直接抛。portable_key 缺席(无 - // remote 且名字算不出)时退回旧的 root_path 冲突口径,存量语义不变。 + // remote 且读不到根提交,非 git 目录)时退回旧的 root_path 冲突口径,存量语义不变。 function upsertChoroRepo(rootPath, name, planFingerprint, gitBaseline, updatedAt, portableKey = null) { let existing = portableKey ? (db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined) diff --git a/dsh-mneme/src/wiki/portable.js b/dsh-mneme/src/wiki/portable.js index 5eccbf11..3e36e957 100644 --- a/dsh-mneme/src/wiki/portable.js +++ b/dsh-mneme/src/wiki/portable.js @@ -2,7 +2,7 @@ // 双机共享同一个 memory.db 时它把 per-host 状态写进了 shared 状态——Windows 端 // 登记的行,macOS 端拼出来的路径不存在,再跑一次 T0 又多出第二行(从此不带 // repo 参数的调用命中 repo-ambiguous)。身份与位置从此分家:身份 = portable_key -// (remote URL 归一化;无 remote 时退化为仓库名),位置 = choro_repo_paths +// (remote URL 归一化;无 remote 时退化为根提交哈希),位置 = choro_repo_paths // 每机一行(host_id = os.hostname())。 import { execFileSync } from "node:child_process"; import os from "node:os"; From 9737ceb0683a6aba653ae30f390e9c89a59c4635 Mon Sep 17 00:00:00 2001 From: modusensus Date: Fri, 13 Nov 2026 11:01:17 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(choro):=20=E8=BA=AB=E4=BB=BD=E4=BC=98?= =?UTF-8?q?=E5=85=88=E2=80=94=E2=80=94=E8=B7=AF=E5=BE=84=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E6=A0=B8=E9=AA=8C=E8=BA=AB=E4=BB=BD=E3=80=81=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E7=B4=A2=E5=BC=95=E3=80=81URL=20=E7=AB=AF?= =?UTF-8?q?=E5=8F=A3=E4=B8=8E=20scp=20=E5=88=86=E9=9A=94=E7=AC=A6=E5=88=86?= =?UTF-8?q?=E5=BC=80=EF=BC=88PR=20#679=20=E5=85=AD=E6=9D=A1=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh-mneme/CHANGELOG.md | 2 +- dsh-mneme/lib/store.js | 48 ++++++++++++++++++++--- dsh-mneme/lib/wiki/portable.js | 56 +++++++++++++++++---------- dsh-mneme/lib/wiki/queries.js | 45 ++++++++++++++++------ dsh-mneme/lib/wiki/t1.js | 19 +++++++--- dsh-mneme/src/store.js | 48 ++++++++++++++++++++--- dsh-mneme/src/wiki/portable.js | 56 +++++++++++++++++---------- dsh-mneme/src/wiki/queries.js | 45 ++++++++++++++++------ dsh-mneme/src/wiki/t1.js | 19 +++++++--- dsh-mneme/test/wiki-portable.test.js | 15 ++++++++ dsh-mneme/test/wiki-queries.test.js | 57 +++++++++++++++++++++++++++- dsh-mneme/test/wiki-store.test.js | 34 +++++++++++++++++ dsh-mneme/test/wiki-t1.test.js | 28 ++++++++++++++ 13 files changed, 386 insertions(+), 86 deletions(-) diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 273091be..5a7d27aa 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -- **仓库地志的身份与位置分家:`portable_key` + 每机位置表,双系统 / 双机共享同一个 memory.db 时地志第一次可以跨端复用(#581)**:mneme 把「库共享」做到了(`memoryDir` + WAL + busy_timeout),chorography 是唯一一处因为记了本机绝对路径而破坏这个前提的功能——Windows 端登记的行,macOS 端拼出来的路径不存在;再跑一次 T0 又多出第二行,从此不带 `repo` 参数的调用命中 repo-ambiguous;T1 的逐字符严格匹配更意味着**每端都必须先各自跑一次 T0**。修法按 issue 的最小方案落地,三个概念各就各位:**身份** = `choro_repos.portable_key`(新列,幂等迁移),取 `remote.origin.url` 归一化——协议头 / 尾斜杠 / 尾 `.git` / 大小写剥平,scp 形态(`git@host:path`)的冒号换 `/`,**userinfo 整体剥掉**(token 不进键:泄进库里是一回事,令牌轮换后同一仓库算出不同键是另一回事);无 remote 退化到**根提交哈希**(`git:`,跨副本恒定且与「碰巧同名的别的仓库」天然区分——issue 提的「退化为仓库名」不能用,目录名与 package name 重合率太高,第二次登记会吃掉别人的行,实现期在测试夹具上当场撞到)。**位置** = 新表 `choro_repo_paths(repo_key, host_id, root_path, last_seen_at)`,`host_id = os.hostname()`,T0/T1 登记时 upsert。**读侧**(`queries.js` 的 `localRootPath`)按当前 host 取本机路径、取不到回退身份行的 root_path——单机库两个值恒等、行为逐字节不变;本机没登记过时落进既有的 page-missing / unreadable 失败口径,不会拿别的机器的路径读出错误内容。**T1 的登记校验**从「root_path 逐字符相同」改成「本机路径命中**或**身份已登记」——同一仓库在另一台机器登记过,本机直接认领那一行富化,消灭「每端先各跑一次 T0」;认领同时落本机位置行。写路径(`/choro/run`、`/choro/t1` 后台任务、`/choro` 命令的过期判定)同样改吃本机根。**两个刻意的取舍**:① 同键登记收敛到同一行,但 root_path 的 UPDATE 前先查目标值是否被别的行占着(被占保留原值、位置走位置表)——升格期已跨端登记出的重复行**不合并**(页面与 claims 挂在 repo_id 上,机器间合并行等于改写归属),强行改会撞 `idx_choro_repos_root` 直接抛;② 升格存量行的键留 NULL,由它自己的机器重跑 T0/T1 时补上,不猜。已知边界:kb 导入的 choro_page source 是导入当时的绝对路径快照,跨端复用时会失配,另账处理。回归测试 +10(URL 归一化形态表含凭据剥离 / 无 remote 退化链与恒定性 / 旧库补列补表且重开幂等 / 位置表同键同机覆写 / 同键跨端登记收敛同一行且 created_at 不动 / 升格期 root 被占不强改 / 读侧三 host 各解析各的 / **T1 跨端认领端到端**:克隆检出直接富化身份行并登记本机位置),另把四个「同库登记两个相同夹具」的既有用例显式区分开(无 remote 键是根提交哈希,同秒提交的相同内容会同键收敛——这本身就是退化链设计的防误并性质)。变异自证 3 例(删 `upsertChoroRepoPath`/`choroRepoPathFor` 的 return 出口 → store 用例 3 红;读侧 `localRootPath` 去掉位置行解析 → queries 用例红;T1 去掉 byKey 认领 → 端到端用例红),恢复后全绿。 +- **仓库地志的身份与位置分家:`portable_key` + 每机位置表,双系统 / 双机共享同一个 memory.db 时地志第一次可以跨端复用(#581)**:mneme 把「库共享」做到了(`memoryDir` + WAL + busy_timeout),chorography 是唯一一处因为记了本机绝对路径而破坏这个前提的功能——Windows 端登记的行,macOS 端拼出来的路径不存在;再跑一次 T0 又多出第二行,从此不带 `repo` 参数的调用命中 repo-ambiguous;T1 的逐字符严格匹配更意味着**每端都必须先各自跑一次 T0**。修法按 issue 的最小方案落地,三个概念各就各位:**身份** = `choro_repos.portable_key`(新列,幂等迁移),取 `remote.origin.url` 归一化——协议头 / 尾斜杠 / 尾 `.git` / 大小写剥平,scp 形态(`git@host:path`)的冒号换 `/`,**userinfo 整体剥掉**(token 不进键:泄进库里是一回事,令牌轮换后同一仓库算出不同键是另一回事);无 remote 退化到**根提交哈希**(`git:`,跨副本恒定且与「碰巧同名的别的仓库」天然区分——issue 提的「退化为仓库名」不能用,目录名与 package name 重合率太高,第二次登记会吃掉别人的行,实现期在测试夹具上当场撞到)。**位置** = 新表 `choro_repo_paths(repo_key, host_id, root_path, last_seen_at)`,`host_id = os.hostname()`,T0/T1 登记时 upsert。**读侧**(`queries.js` 的 `localRootPath`)按当前 host 取本机路径、取不到回退身份行的 root_path——单机库两个值恒等、行为逐字节不变;本机没登记过时落进既有的 page-missing / unreadable 失败口径,不会拿别的机器的路径读出错误内容。**T1 的登记校验**从「root_path 逐字符相同」改成「本机路径命中**或**身份已登记」——同一仓库在另一台机器登记过,本机直接认领那一行富化,消灭「每端先各跑一次 T0」;认领同时落本机位置行。写路径(`/choro/run`、`/choro/t1` 后台任务、`/choro` 命令的过期判定)同样改吃本机根。**两个刻意的取舍**:① 同键登记收敛到同一行,但 root_path 的 UPDATE 前先查目标值是否被别的行占着(被占保留原值、位置走位置表)——升格期已跨端登记出的重复行**不合并**(页面与 claims 挂在 repo_id 上,机器间合并行等于改写归属),强行改会撞 `idx_choro_repos_root` 直接抛;② 升格存量行的键留 NULL,由它自己的机器重跑 T0/T1 时补上,不猜。已知边界:kb 导入的 choro_page source 是导入当时的绝对路径快照,跨端复用时会失配,另账处理。回归测试 +10(URL 归一化形态表含凭据剥离 / 无 remote 退化链与恒定性 / 旧库补列补表且重开幂等 / 位置表同键同机覆写 / 同键跨端登记收敛同一行且 created_at 不动 / 升格期 root 被占不强改 / 读侧三 host 各解析各的 / **T1 跨端认领端到端**:克隆检出直接富化身份行并登记本机位置),另把四个「同库登记两个相同夹具」的既有用例显式区分开(无 remote 键是根提交哈希,同秒提交的相同内容会同键收敛——这本身就是退化链设计的防误并性质)。变异自证 3 例(删 `upsertChoroRepoPath`/`choroRepoPathFor` 的 return 出口 → store 用例 3 红;读侧 `localRootPath` 去掉位置行解析 → queries 用例红;T1 去掉 byKey 认领 → 端到端用例红),恢复后全绿。**评审补丁(PR #679,六条 Major 全部成立)**:① **路径回退要核验身份**——两台主机的同一条绝对路径可能各自检出不同仓库,`upsertChoroRepo` / T1 的登记校验、读侧 `localRootPath` 都改成「身份优先,路径回退只接受无键存量行或键一致的行」;核验用 `portableKeyFor`(一次 git 读取,按「键+路径」记忆化),不符时读侧返回空串并按 `page-missing` / 不可读上报,绝不 `join("", rel)` 去读进程 CWD;`claimDrift` 的 `hasT1` 同样加了空根守卫。② **身份唯一**:`portable_key` 加部分唯一索引(`WHERE portable_key IS NOT NULL`),插入撞唯一键时回读同一身份行改走更新——并发登记不再产生重复行;索引建失败(存量重复键)退成非唯一索引,不让插件起不来。③ **URL 解析分清端口与 scp 分隔符**:带协议头时 authority 的冒号是端口(默认端口 ssh 22 / https 443 / git 9418 剥掉,其余保留),只有 scp 形态的冒号才是路径分隔符;④ **userinfo 只在 authority 段内剥**,仓库路径里的 `@`(`repo@stable`)不再把主机与路径一起切掉。⑤ `repoByCwd` 的最长前缀改为比较候选自身路径长度(原实现拿身份行里那条外地路径比长度,内层本机路径永远赢不了)。新增回归 5 条(端口 vs 路径段 / 路径里的 @ / 同路径另一身份拒绝且原行不变 / 回退目录身份不符报 page-missing / 前缀比较用候选长度),变异自证 6 例逐条变红。 ## [0.14.0-beta.3] - 2026-11-12 - **品牌视觉换新:README 头图与插件列表图标**。三处 README(根、`dsh-mneme/`、`mcp/`)顶部改用 `images/mneme-banner-hero.webp`(1672×941,282 KiB)——原图 2.1 MiB 的 PNG 会让 `images/` 目录体积翻倍。根目录用相对路径,两个子目录用 raw 绝对地址:npm 详情页不解析相对图片路径,`../images/...` 在 npmjs.com 上必然裂图。 diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 1085dc8d..f3adbe91 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -1390,6 +1390,16 @@ export function createStore(path) { // 自己的机器上重跑 T0/T1 时补上,不猜(remote URL 拿不到就是拿不到)。 // choro_repo_paths 建表走 SCHEMA 的 CREATE TABLE IF NOT EXISTS,老库重开自动补。 addColumn("choro_repos", "portable_key", "ALTER TABLE choro_repos ADD COLUMN portable_key TEXT"); + // 身份唯一(PR #679 评审):同一 portable_key 只许一行,否则并发登记会各自插入、 + // 之后任意取其中一行。部分索引(键非空才约束)保证升格前的 NULL 行不受影响。 + // 建索引可能因存量重复键失败(本功能尚未发布,概率趋零,但不能因此让插件起不来) + // ——失败时退成非唯一索引,upsert 的「插入撞唯一键→回读再更新」分支只在索引建成 + // 时生效,退化的库上并发仍可能重复,这是启动可用性与约束强度之间的取舍。 + try { + db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_repos_portable ON choro_repos(portable_key) WHERE portable_key IS NOT NULL;"); + } catch { + db.exec("CREATE INDEX IF NOT EXISTS idx_choro_repos_portable ON choro_repos(portable_key);"); + } // v0.8.0 A1(issue #17)scope 隔离存储层:四列全部可空、不带 DEFAULT——存量行 // 零重写,读侧把 NULL 视为未标注(= 全局可见)。是否落值由 scopeEnabled 门控 @@ -5482,7 +5492,20 @@ export function createStore(path) { let existing = portableKey ? (db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined) : undefined; - if (!existing) existing = choroRepoByRoot(rootPath); + if (!existing) { + // 路径回退必须校验身份(PR #679 评审):两台主机的同一条绝对路径可能各自 + // 检出不同仓库。若该路径已属另一身份的行,拿它复用会把对方的名字、基线、 + // 键一起改掉,随后 T0 还会用本仓库的图替换对方的模块与 claims。无键的存量 + // 行(升格前登记)仍允许按路径认领——那时候键还不存在,路径就是唯一线索。 + const byRoot = choroRepoByRoot(rootPath); + if (byRoot && portableKey && byRoot.portable_key != null && byRoot.portable_key !== portableKey) { + throw new Error( + `choro repo path conflict: ${rootPath} is already registered as ${byRoot.portable_key}; ` + + `refusing to re-register it as ${portableKey}` + ); + } + existing = byRoot; + } if (existing) { const rootTaken = db.prepare("SELECT id FROM choro_repos WHERE root_path = ? AND id != ?") .get(rootPath, existing.id); @@ -5496,10 +5519,25 @@ export function createStore(path) { } return choroRepoByRoot(nextRoot); } - db.prepare( - `INSERT INTO choro_repos (root_path, name, plan_fingerprint, git_baseline, created_at, updated_at, portable_key) - VALUES (?, ?, ?, ?, ?, ?, ?)` - ).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, updatedAt, portableKey); + try { + db.prepare( + `INSERT INTO choro_repos (root_path, name, plan_fingerprint, git_baseline, created_at, updated_at, portable_key) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, updatedAt, portableKey); + } catch (err) { + // 并发登记(PR #679 评审):两个进程可能都先查到「无记录」再各自插入。 + // portable_key 的唯一索引会让后到者失败,此时重新按身份取行并走更新, + // 而不是把第二个写入变成用户可见的失败。 + const message = String(err?.message ?? err); + if (!portableKey || !/UNIQUE constraint failed: choro_repos\.portable_key/i.test(message)) throw err; + const raced = db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey); + if (!raced) throw err; + db.prepare( + `UPDATE choro_repos SET root_path = ?, name = ?, plan_fingerprint = ?, git_baseline = ?, updated_at = ? + WHERE id = ?` + ).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, raced.id); + return choroRepoByRoot(rootPath); + } return choroRepoByRoot(rootPath); } diff --git a/dsh-mneme/lib/wiki/portable.js b/dsh-mneme/lib/wiki/portable.js index 3e36e957..ea225390 100644 --- a/dsh-mneme/lib/wiki/portable.js +++ b/dsh-mneme/lib/wiki/portable.js @@ -19,29 +19,45 @@ export function hostId() { * remote URL → 归一化身份串。目标形态:`host/path`(全小写)。 * - 协议头(https:// / ssh:// / git://)剥掉; * - 尾部 `/` 与 `.git` 剥掉; - * - scp 形态 `git@host:path` 的冒号换成 `/`——判据是冒号在最后一个 `@` 之后、 - * 第一个 `/` 之前;https 的 userinfo(`user:token@host`)冒号在 `@` 之前, - * **不能动**:token 换成 `/` 既毁匹配、还会把凭据的形状带进键里; - * - userinfo(含 token)整体剥掉:凭据不进键——泄进库里是一回事,令牌轮换 - * 后同一仓库算出不同键是另一回事,两个都不可接受。 + * - **URL 与 scp 分开解析**(PR #679 评审):带 `://` 的形态里 authority 的冒号 + * 是**端口**(`ssh://host:2222/team/repo` 的 2222 不是路径段),只有 scp 形态 + * (`git@host:path`,无协议头)的冒号才是路径分隔符。混着处理会把 + * `ssh://host/2222/team/repo` 与 `ssh://host:2222/team/repo` 算成同一个身份。 + * 默认端口(ssh 22 / https 443 / git 9418)剥掉,其余端口保留——不同端口的 + * 两个服务本来就可能承载不同仓库; + * - userinfo(含 token)**只在 authority 段内**剥掉:凭据不进键(泄进库里是 + * 一回事,令牌轮换后同一仓库算出不同键是另一回事),但仓库路径里的 `@` + * (`team/repo@stable`)不是 userinfo,不能拿全串的 lastIndexOf 去切。 */ export function normalizeGitUrl(rawUrl) { - let s = String(rawUrl ?? "").trim(); - if (!s) return null; - s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); - s = s.replace(/\/+$/, ""); - s = s.replace(/\.git$/i, ""); - const firstSlash = s.indexOf("/"); - const authority = firstSlash === -1 ? s : s.slice(0, firstSlash); - const at = authority.lastIndexOf("@"); - const colon = authority.indexOf(":", at + 1); - if (colon !== -1) { - s = authority.slice(0, colon) + "/" + authority.slice(colon + 1) - + (firstSlash === -1 ? "" : s.slice(firstSlash)); + const raw = String(rawUrl ?? "").trim(); + if (!raw) return null; + const schemeMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(raw); + if (schemeMatch) { + const scheme = schemeMatch[1].toLowerCase(); + let s = raw.slice(schemeMatch[0].length).replace(/\/+$/, "").replace(/\.git$/i, ""); + const slash = s.indexOf("/"); + let authority = slash === -1 ? s : s.slice(0, slash); + const path = slash === -1 ? "" : s.slice(slash); + const at = authority.lastIndexOf("@"); + if (at !== -1) authority = authority.slice(at + 1); + const portMatch = /:(\d+)$/.exec(authority); + if (portMatch) { + const defaultPort = { ssh: "22", "git+ssh": "22", https: "443", http: "80", git: "9418" }[scheme]; + if (portMatch[1] === defaultPort) authority = authority.slice(0, portMatch.index); + } + const out = (authority + path).toLowerCase(); + return out || null; } - const at2 = s.lastIndexOf("@"); - const hostStart = at2 === -1 ? 0 : at2 + 1; - const out = s.slice(hostStart).toLowerCase(); + // scp 形态:`[user@]host:path`;没有协议头,冒号是路径分隔符。 + let s = raw.replace(/\/+$/, "").replace(/\.git$/i, ""); + const at = s.indexOf("@"); + if (at !== -1) s = s.slice(at + 1); + const colon = s.indexOf(":"); + if (colon === -1) return s.toLowerCase() || null; + const host = s.slice(0, colon); + const path = s.slice(colon + 1); + const out = `${host}/${path}`.toLowerCase(); return out || null; } diff --git a/dsh-mneme/lib/wiki/queries.js b/dsh-mneme/lib/wiki/queries.js index 6eb07cd9..415ad6e2 100644 --- a/dsh-mneme/lib/wiki/queries.js +++ b/dsh-mneme/lib/wiki/queries.js @@ -14,7 +14,7 @@ import { join, resolve, sep } from "node:path"; import { gitHead } from "./t0.js"; import { loadPlanFromRepo } from "./plan.js"; import { TEMPLATE_VERSION, T1_SECTION_START } from "./render.js"; -import { hostId as defaultHostId } from "./portable.js"; +import { hostId as defaultHostId, portableKeyFor } from "./portable.js"; // claim 失效判定的纯函数(借 openwiki 的 Grounded Claims):解析 diff + 逐条比对 // 三列证据。放独立模块是因为它零 DB、零 IO(除 diffSince 那一次 git),可单测。 import { diffSince, summarizeClaims } from "./claims.js"; @@ -54,19 +54,29 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) // 本机路径解析(#581):身份行(choro_repos)上的 root_path 是「最近一次登记 // 的位置」,未必是本机的——跨端共享库时可能是另一台机器的路径。当前机器登记 - // 过(choro_repo_paths 有本 host 的行)就用本机路径;没登记过退回行上的 - // root_path。单机库两个值恒等,行为不变;跨端库在本机还没跑过 T0/T1 时拿不到 - // 本机路径,文件访问落进既有的 page-missing / unreadable 失败口径(wiki-tools - // 的共享库用例锁着那个形状)——路径形状跨 OS 互不为前缀,不会拿别的机器的 - // 路径读出错误内容。 + // 过(choro_repo_paths 有本 host 的行)就用本机路径。 + // + // **没有本机位置行时要核验回退目录的身份**(PR #679 评审):两台主机的同一条 + // 绝对路径可能各自检出不同仓库。只凭「路径存在」就回退,会把对方仓库的页面当成本 + // 仓库的内容读出来,写路径还会往错的地方落页。核验用 portableKeyFor(一次 git + // 读取,结果按 键+路径 记忆化——回退只在「本机还没跑过 T0/T1」时发生,常态零成本)。 + // 核验不过时返回空串:调用方按「本机不可达」处理,绝不拼相对路径去读 CWD。 + const fallbackVerified = new Map(); const localRootPath = (row) => { const key = row?.portable_key; + const rawRoot = String(row?.root_path ?? ""); if (key) { const p = db.prepare("SELECT root_path FROM choro_repo_paths WHERE repo_key = ? AND host_id = ?") .get(key, hostIdFn()); if (p && p.root_path) return p.root_path; + if (!rawRoot) return ""; + const cacheKey = `${key}\u0000${rawRoot}`; + if (fallbackVerified.has(cacheKey)) return fallbackVerified.get(cacheKey); + const verified = portableKeyFor(rawRoot) === key ? rawRoot : ""; + fallbackVerified.set(cacheKey, verified); + return verified; } - return row?.root_path ?? ""; + return rawRoot; }; /** 已登记仓库(含模块/页面计数,面板仓库选择器与工具的 repo 解析共用)。 */ @@ -249,7 +259,11 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) ).get(repoId, String(slug)); if (!row) return null; const repo = db.prepare("SELECT root_path, portable_key, name FROM choro_repos WHERE id = ?").get(repoId); - const abs = join(localRootPath(repo), row.rel_path); + const repoRoot = localRootPath(repo); + // 空串 = 本机既没有位置行、回退目录的身份也对不上(PR #679 评审): + // 必须当「页在本机不可达」,不能拿 join("", rel) 去读进程 CWD 里的巧合路径。 + if (!repoRoot) return { error: "page-missing", slug: row.slug, relPath: row.rel_path }; + const abs = join(repoRoot, row.rel_path); let content; try { content = readFileSync(abs, "utf8"); @@ -292,6 +306,8 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) const hits = []; const searchRoot = localRootPath(repo); for (const page of pages) { + // 空串 = 本机不可达(身份核验没过):按「这一页读不到」上报,不拼相对路径。 + if (!searchRoot) { onUnreadablePage?.(page.slug); continue; } const abs = join(searchRoot, page.rel_path); let text; try { @@ -363,12 +379,16 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) if (row) candidates.push({ root: String(p.root_path ?? ""), row }); } let best = null; + // 最长前缀必须比较**候选自己的路径长度**(PR #679 评审):本机候选来自位置表 + // 时,best 的 root_path 仍是身份行里那条(可能是另一台机器很长的路径),拿它 + // 比长度会让短的内层本机路径永远赢不了,会话被外层仓库抢走。 + let bestLen = 0; for (const { root, row } of candidates) { const croot = cmp(root); if (croot === "") continue; const hit = target === croot || target.startsWith(croot.endsWith(sep) ? croot : croot + sep); if (!hit) continue; - if (!best || croot.length > cmp(String(best.root_path)).length) best = row; + if (!best || croot.length > bestLen) { best = row; bestLen = croot.length; } } return best ?? null; } @@ -475,7 +495,8 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) const pages = pageRows.map((p) => { let hasT1 = false; try { - hasT1 = readFileSync(join(rootPath, p.rel_path), "utf8").includes(T1_SECTION_START); + // rootPath 为空 = 本机不可达(身份核验没过):不拼相对路径读 CWD。 + hasT1 = rootPath !== "" && readFileSync(join(rootPath, p.rel_path), "utf8").includes(T1_SECTION_START); } catch { hasT1 = false; } const verifiedBaseline = String(p.verified_baseline ?? "").trim() || null; const verifiedAt = p.verified_at ?? null; @@ -589,7 +610,9 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) const fresh = !dimDrift && row && row.source_generated_at === page.generated_at && row.vector; if (fresh) { result.skipped++; continue; } if (row && row.vector) result.stale++; - const abs = join(localRootPath(repo), page.rel_path); + const repoRoot = localRootPath(repo); + if (!repoRoot) continue; // 本机不可达:不嵌、不报错(与 existsSync 缺页同口径) + const abs = join(repoRoot, page.rel_path); if (!existsSync(abs)) continue; let text; try { diff --git a/dsh-mneme/lib/wiki/t1.js b/dsh-mneme/lib/wiki/t1.js index 8ece1390..37f765f2 100644 --- a/dsh-mneme/lib/wiki/t1.js +++ b/dsh-mneme/lib/wiki/t1.js @@ -324,13 +324,20 @@ export function createT1({ store, runSession = null, limits = DEFAULT_LIMITS, no async function runT1({ repoRoot, model = DEFAULT_T1_MODEL, moduleSlug = null, retryFailed = false, dryRun = false, dshExe = null, limits: limitsOverride = null }) { const cap = { ...limits, ...limitsOverride }; const rootPath = realpathSync(resolve(repoRoot)); - // 登记校验(#581):本机路径没登记时,按可移植身份认领——同一仓库已在别的 - // 机器登记过(共享库),本机直接富化那一行,消灭「每端都必须先各自跑一次 - // T0」。认领成功的同时把本机路径落进位置表,读侧从此能按 host 解析到它。 + // 登记校验(#581):**身份优先**(PR #679 评审)——共享库里另一台主机可能把 + // 同一条绝对路径登记成了别的仓库;按路径先查会命中那一行,随后 T1 读的是本机 + // 仓库的文件,却把 claims 与页状态写进对方的 repo.id。所以先按身份认领,路径 + // 回退只接受「无键的存量行」或「键一致的行」。认领成功的同时把本机路径落进 + // 位置表,读侧从此能按 host 解析到它。 const portableKey = portableKeyFor(rootPath); - let repo = store.db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath); - if (!repo && portableKey) { - repo = store.db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined; + let repo = portableKey + ? (store.db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined) + : undefined; + if (!repo) { + const byPath = store.db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath); + if (byPath && (!portableKey || byPath.portable_key == null || byPath.portable_key === portableKey)) { + repo = byPath; + } } if (!repo) { throw new T1Refusal("not-registered", `${rootPath} 还没有登记:先跑一次 T0(${T1_CLI ?? "dsh-mneme-choro --apply"})`); diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 1085dc8d..f3adbe91 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -1390,6 +1390,16 @@ export function createStore(path) { // 自己的机器上重跑 T0/T1 时补上,不猜(remote URL 拿不到就是拿不到)。 // choro_repo_paths 建表走 SCHEMA 的 CREATE TABLE IF NOT EXISTS,老库重开自动补。 addColumn("choro_repos", "portable_key", "ALTER TABLE choro_repos ADD COLUMN portable_key TEXT"); + // 身份唯一(PR #679 评审):同一 portable_key 只许一行,否则并发登记会各自插入、 + // 之后任意取其中一行。部分索引(键非空才约束)保证升格前的 NULL 行不受影响。 + // 建索引可能因存量重复键失败(本功能尚未发布,概率趋零,但不能因此让插件起不来) + // ——失败时退成非唯一索引,upsert 的「插入撞唯一键→回读再更新」分支只在索引建成 + // 时生效,退化的库上并发仍可能重复,这是启动可用性与约束强度之间的取舍。 + try { + db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_repos_portable ON choro_repos(portable_key) WHERE portable_key IS NOT NULL;"); + } catch { + db.exec("CREATE INDEX IF NOT EXISTS idx_choro_repos_portable ON choro_repos(portable_key);"); + } // v0.8.0 A1(issue #17)scope 隔离存储层:四列全部可空、不带 DEFAULT——存量行 // 零重写,读侧把 NULL 视为未标注(= 全局可见)。是否落值由 scopeEnabled 门控 @@ -5482,7 +5492,20 @@ export function createStore(path) { let existing = portableKey ? (db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined) : undefined; - if (!existing) existing = choroRepoByRoot(rootPath); + if (!existing) { + // 路径回退必须校验身份(PR #679 评审):两台主机的同一条绝对路径可能各自 + // 检出不同仓库。若该路径已属另一身份的行,拿它复用会把对方的名字、基线、 + // 键一起改掉,随后 T0 还会用本仓库的图替换对方的模块与 claims。无键的存量 + // 行(升格前登记)仍允许按路径认领——那时候键还不存在,路径就是唯一线索。 + const byRoot = choroRepoByRoot(rootPath); + if (byRoot && portableKey && byRoot.portable_key != null && byRoot.portable_key !== portableKey) { + throw new Error( + `choro repo path conflict: ${rootPath} is already registered as ${byRoot.portable_key}; ` + + `refusing to re-register it as ${portableKey}` + ); + } + existing = byRoot; + } if (existing) { const rootTaken = db.prepare("SELECT id FROM choro_repos WHERE root_path = ? AND id != ?") .get(rootPath, existing.id); @@ -5496,10 +5519,25 @@ export function createStore(path) { } return choroRepoByRoot(nextRoot); } - db.prepare( - `INSERT INTO choro_repos (root_path, name, plan_fingerprint, git_baseline, created_at, updated_at, portable_key) - VALUES (?, ?, ?, ?, ?, ?, ?)` - ).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, updatedAt, portableKey); + try { + db.prepare( + `INSERT INTO choro_repos (root_path, name, plan_fingerprint, git_baseline, created_at, updated_at, portable_key) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, updatedAt, portableKey); + } catch (err) { + // 并发登记(PR #679 评审):两个进程可能都先查到「无记录」再各自插入。 + // portable_key 的唯一索引会让后到者失败,此时重新按身份取行并走更新, + // 而不是把第二个写入变成用户可见的失败。 + const message = String(err?.message ?? err); + if (!portableKey || !/UNIQUE constraint failed: choro_repos\.portable_key/i.test(message)) throw err; + const raced = db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey); + if (!raced) throw err; + db.prepare( + `UPDATE choro_repos SET root_path = ?, name = ?, plan_fingerprint = ?, git_baseline = ?, updated_at = ? + WHERE id = ?` + ).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, raced.id); + return choroRepoByRoot(rootPath); + } return choroRepoByRoot(rootPath); } diff --git a/dsh-mneme/src/wiki/portable.js b/dsh-mneme/src/wiki/portable.js index 3e36e957..ea225390 100644 --- a/dsh-mneme/src/wiki/portable.js +++ b/dsh-mneme/src/wiki/portable.js @@ -19,29 +19,45 @@ export function hostId() { * remote URL → 归一化身份串。目标形态:`host/path`(全小写)。 * - 协议头(https:// / ssh:// / git://)剥掉; * - 尾部 `/` 与 `.git` 剥掉; - * - scp 形态 `git@host:path` 的冒号换成 `/`——判据是冒号在最后一个 `@` 之后、 - * 第一个 `/` 之前;https 的 userinfo(`user:token@host`)冒号在 `@` 之前, - * **不能动**:token 换成 `/` 既毁匹配、还会把凭据的形状带进键里; - * - userinfo(含 token)整体剥掉:凭据不进键——泄进库里是一回事,令牌轮换 - * 后同一仓库算出不同键是另一回事,两个都不可接受。 + * - **URL 与 scp 分开解析**(PR #679 评审):带 `://` 的形态里 authority 的冒号 + * 是**端口**(`ssh://host:2222/team/repo` 的 2222 不是路径段),只有 scp 形态 + * (`git@host:path`,无协议头)的冒号才是路径分隔符。混着处理会把 + * `ssh://host/2222/team/repo` 与 `ssh://host:2222/team/repo` 算成同一个身份。 + * 默认端口(ssh 22 / https 443 / git 9418)剥掉,其余端口保留——不同端口的 + * 两个服务本来就可能承载不同仓库; + * - userinfo(含 token)**只在 authority 段内**剥掉:凭据不进键(泄进库里是 + * 一回事,令牌轮换后同一仓库算出不同键是另一回事),但仓库路径里的 `@` + * (`team/repo@stable`)不是 userinfo,不能拿全串的 lastIndexOf 去切。 */ export function normalizeGitUrl(rawUrl) { - let s = String(rawUrl ?? "").trim(); - if (!s) return null; - s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); - s = s.replace(/\/+$/, ""); - s = s.replace(/\.git$/i, ""); - const firstSlash = s.indexOf("/"); - const authority = firstSlash === -1 ? s : s.slice(0, firstSlash); - const at = authority.lastIndexOf("@"); - const colon = authority.indexOf(":", at + 1); - if (colon !== -1) { - s = authority.slice(0, colon) + "/" + authority.slice(colon + 1) - + (firstSlash === -1 ? "" : s.slice(firstSlash)); + const raw = String(rawUrl ?? "").trim(); + if (!raw) return null; + const schemeMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(raw); + if (schemeMatch) { + const scheme = schemeMatch[1].toLowerCase(); + let s = raw.slice(schemeMatch[0].length).replace(/\/+$/, "").replace(/\.git$/i, ""); + const slash = s.indexOf("/"); + let authority = slash === -1 ? s : s.slice(0, slash); + const path = slash === -1 ? "" : s.slice(slash); + const at = authority.lastIndexOf("@"); + if (at !== -1) authority = authority.slice(at + 1); + const portMatch = /:(\d+)$/.exec(authority); + if (portMatch) { + const defaultPort = { ssh: "22", "git+ssh": "22", https: "443", http: "80", git: "9418" }[scheme]; + if (portMatch[1] === defaultPort) authority = authority.slice(0, portMatch.index); + } + const out = (authority + path).toLowerCase(); + return out || null; } - const at2 = s.lastIndexOf("@"); - const hostStart = at2 === -1 ? 0 : at2 + 1; - const out = s.slice(hostStart).toLowerCase(); + // scp 形态:`[user@]host:path`;没有协议头,冒号是路径分隔符。 + let s = raw.replace(/\/+$/, "").replace(/\.git$/i, ""); + const at = s.indexOf("@"); + if (at !== -1) s = s.slice(at + 1); + const colon = s.indexOf(":"); + if (colon === -1) return s.toLowerCase() || null; + const host = s.slice(0, colon); + const path = s.slice(colon + 1); + const out = `${host}/${path}`.toLowerCase(); return out || null; } diff --git a/dsh-mneme/src/wiki/queries.js b/dsh-mneme/src/wiki/queries.js index 6eb07cd9..415ad6e2 100644 --- a/dsh-mneme/src/wiki/queries.js +++ b/dsh-mneme/src/wiki/queries.js @@ -14,7 +14,7 @@ import { join, resolve, sep } from "node:path"; import { gitHead } from "./t0.js"; import { loadPlanFromRepo } from "./plan.js"; import { TEMPLATE_VERSION, T1_SECTION_START } from "./render.js"; -import { hostId as defaultHostId } from "./portable.js"; +import { hostId as defaultHostId, portableKeyFor } from "./portable.js"; // claim 失效判定的纯函数(借 openwiki 的 Grounded Claims):解析 diff + 逐条比对 // 三列证据。放独立模块是因为它零 DB、零 IO(除 diffSince 那一次 git),可单测。 import { diffSince, summarizeClaims } from "./claims.js"; @@ -54,19 +54,29 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) // 本机路径解析(#581):身份行(choro_repos)上的 root_path 是「最近一次登记 // 的位置」,未必是本机的——跨端共享库时可能是另一台机器的路径。当前机器登记 - // 过(choro_repo_paths 有本 host 的行)就用本机路径;没登记过退回行上的 - // root_path。单机库两个值恒等,行为不变;跨端库在本机还没跑过 T0/T1 时拿不到 - // 本机路径,文件访问落进既有的 page-missing / unreadable 失败口径(wiki-tools - // 的共享库用例锁着那个形状)——路径形状跨 OS 互不为前缀,不会拿别的机器的 - // 路径读出错误内容。 + // 过(choro_repo_paths 有本 host 的行)就用本机路径。 + // + // **没有本机位置行时要核验回退目录的身份**(PR #679 评审):两台主机的同一条 + // 绝对路径可能各自检出不同仓库。只凭「路径存在」就回退,会把对方仓库的页面当成本 + // 仓库的内容读出来,写路径还会往错的地方落页。核验用 portableKeyFor(一次 git + // 读取,结果按 键+路径 记忆化——回退只在「本机还没跑过 T0/T1」时发生,常态零成本)。 + // 核验不过时返回空串:调用方按「本机不可达」处理,绝不拼相对路径去读 CWD。 + const fallbackVerified = new Map(); const localRootPath = (row) => { const key = row?.portable_key; + const rawRoot = String(row?.root_path ?? ""); if (key) { const p = db.prepare("SELECT root_path FROM choro_repo_paths WHERE repo_key = ? AND host_id = ?") .get(key, hostIdFn()); if (p && p.root_path) return p.root_path; + if (!rawRoot) return ""; + const cacheKey = `${key}\u0000${rawRoot}`; + if (fallbackVerified.has(cacheKey)) return fallbackVerified.get(cacheKey); + const verified = portableKeyFor(rawRoot) === key ? rawRoot : ""; + fallbackVerified.set(cacheKey, verified); + return verified; } - return row?.root_path ?? ""; + return rawRoot; }; /** 已登记仓库(含模块/页面计数,面板仓库选择器与工具的 repo 解析共用)。 */ @@ -249,7 +259,11 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) ).get(repoId, String(slug)); if (!row) return null; const repo = db.prepare("SELECT root_path, portable_key, name FROM choro_repos WHERE id = ?").get(repoId); - const abs = join(localRootPath(repo), row.rel_path); + const repoRoot = localRootPath(repo); + // 空串 = 本机既没有位置行、回退目录的身份也对不上(PR #679 评审): + // 必须当「页在本机不可达」,不能拿 join("", rel) 去读进程 CWD 里的巧合路径。 + if (!repoRoot) return { error: "page-missing", slug: row.slug, relPath: row.rel_path }; + const abs = join(repoRoot, row.rel_path); let content; try { content = readFileSync(abs, "utf8"); @@ -292,6 +306,8 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) const hits = []; const searchRoot = localRootPath(repo); for (const page of pages) { + // 空串 = 本机不可达(身份核验没过):按「这一页读不到」上报,不拼相对路径。 + if (!searchRoot) { onUnreadablePage?.(page.slug); continue; } const abs = join(searchRoot, page.rel_path); let text; try { @@ -363,12 +379,16 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) if (row) candidates.push({ root: String(p.root_path ?? ""), row }); } let best = null; + // 最长前缀必须比较**候选自己的路径长度**(PR #679 评审):本机候选来自位置表 + // 时,best 的 root_path 仍是身份行里那条(可能是另一台机器很长的路径),拿它 + // 比长度会让短的内层本机路径永远赢不了,会话被外层仓库抢走。 + let bestLen = 0; for (const { root, row } of candidates) { const croot = cmp(root); if (croot === "") continue; const hit = target === croot || target.startsWith(croot.endsWith(sep) ? croot : croot + sep); if (!hit) continue; - if (!best || croot.length > cmp(String(best.root_path)).length) best = row; + if (!best || croot.length > bestLen) { best = row; bestLen = croot.length; } } return best ?? null; } @@ -475,7 +495,8 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) const pages = pageRows.map((p) => { let hasT1 = false; try { - hasT1 = readFileSync(join(rootPath, p.rel_path), "utf8").includes(T1_SECTION_START); + // rootPath 为空 = 本机不可达(身份核验没过):不拼相对路径读 CWD。 + hasT1 = rootPath !== "" && readFileSync(join(rootPath, p.rel_path), "utf8").includes(T1_SECTION_START); } catch { hasT1 = false; } const verifiedBaseline = String(p.verified_baseline ?? "").trim() || null; const verifiedAt = p.verified_at ?? null; @@ -589,7 +610,9 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) const fresh = !dimDrift && row && row.source_generated_at === page.generated_at && row.vector; if (fresh) { result.skipped++; continue; } if (row && row.vector) result.stale++; - const abs = join(localRootPath(repo), page.rel_path); + const repoRoot = localRootPath(repo); + if (!repoRoot) continue; // 本机不可达:不嵌、不报错(与 existsSync 缺页同口径) + const abs = join(repoRoot, page.rel_path); if (!existsSync(abs)) continue; let text; try { diff --git a/dsh-mneme/src/wiki/t1.js b/dsh-mneme/src/wiki/t1.js index 8ece1390..37f765f2 100644 --- a/dsh-mneme/src/wiki/t1.js +++ b/dsh-mneme/src/wiki/t1.js @@ -324,13 +324,20 @@ export function createT1({ store, runSession = null, limits = DEFAULT_LIMITS, no async function runT1({ repoRoot, model = DEFAULT_T1_MODEL, moduleSlug = null, retryFailed = false, dryRun = false, dshExe = null, limits: limitsOverride = null }) { const cap = { ...limits, ...limitsOverride }; const rootPath = realpathSync(resolve(repoRoot)); - // 登记校验(#581):本机路径没登记时,按可移植身份认领——同一仓库已在别的 - // 机器登记过(共享库),本机直接富化那一行,消灭「每端都必须先各自跑一次 - // T0」。认领成功的同时把本机路径落进位置表,读侧从此能按 host 解析到它。 + // 登记校验(#581):**身份优先**(PR #679 评审)——共享库里另一台主机可能把 + // 同一条绝对路径登记成了别的仓库;按路径先查会命中那一行,随后 T1 读的是本机 + // 仓库的文件,却把 claims 与页状态写进对方的 repo.id。所以先按身份认领,路径 + // 回退只接受「无键的存量行」或「键一致的行」。认领成功的同时把本机路径落进 + // 位置表,读侧从此能按 host 解析到它。 const portableKey = portableKeyFor(rootPath); - let repo = store.db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath); - if (!repo && portableKey) { - repo = store.db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined; + let repo = portableKey + ? (store.db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined) + : undefined; + if (!repo) { + const byPath = store.db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath); + if (byPath && (!portableKey || byPath.portable_key == null || byPath.portable_key === portableKey)) { + repo = byPath; + } } if (!repo) { throw new T1Refusal("not-registered", `${rootPath} 还没有登记:先跑一次 T0(${T1_CLI ?? "dsh-mneme-choro --apply"})`); diff --git a/dsh-mneme/test/wiki-portable.test.js b/dsh-mneme/test/wiki-portable.test.js index 6ef71bf2..fd2c3bf9 100644 --- a/dsh-mneme/test/wiki-portable.test.js +++ b/dsh-mneme/test/wiki-portable.test.js @@ -23,6 +23,21 @@ test("normalizeGitUrl:SSH / HTTPS / scp / 大小写归一到同一身份,凭 assert.equal(normalizeGitUrl(null), null); }); +test("normalizeGitUrl:URL 端口与 scp 路径分隔符分开处理,路径里的 @ 不被当 userinfo(PR #679)", () => { + // 带协议头时 authority 的冒号是端口;只有 scp 形态的冒号才是路径分隔符。 + assert.equal(normalizeGitUrl("ssh://git@example.org:2222/team/repo.git"), "example.org:2222/team/repo"); + assert.equal(normalizeGitUrl("ssh://git@example.org/2222/team/repo.git"), "example.org/2222/team/repo"); + assert.notEqual( + normalizeGitUrl("ssh://git@example.org:2222/team/repo.git"), + normalizeGitUrl("ssh://git@example.org/2222/team/repo.git")); + // 默认端口剥掉:ssh 22 与省略端口的写法必须同一身份。 + assert.equal(normalizeGitUrl("ssh://git@example.org:22/team/repo.git"), "example.org/team/repo"); + assert.equal(normalizeGitUrl("https://example.org:443/team/repo.git"), "example.org/team/repo"); + // 路径里的 @ 不是 userinfo:仓库名带 @stable 时不能把主机与路径一起切掉。 + assert.equal(normalizeGitUrl("ssh://git@host-a/team/repo@stable.git"), "host-a/team/repo@stable"); + assert.equal(normalizeGitUrl("git@host-b:team/repo@stable.git"), "host-b/team/repo@stable"); +}); + test("portableKeyFor:有 remote 用归一化 URL,无 remote 退化根提交哈希(同名仓库不撞键)", () => { const root = mkdtempSync(join(tmpdir(), "choro-portable-")); try { diff --git a/dsh-mneme/test/wiki-queries.test.js b/dsh-mneme/test/wiki-queries.test.js index afdc5590..db7cf9a8 100644 --- a/dsh-mneme/test/wiki-queries.test.js +++ b/dsh-mneme/test/wiki-queries.test.js @@ -4,7 +4,7 @@ // 盘上缺页如实报 page-missing、检索分节与行号、repo 解析的歧义处理。 import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync, unlinkSync, readFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, unlinkSync, readFileSync, cpSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileSync } from "node:child_process"; @@ -430,3 +430,58 @@ test("共享库跨端:读侧按当前 host 的位置行解析本机根,未 rmTempBestEffort(root); } }); + +// ---- PR #679 评审:路径回退要核验身份、最长前缀比较候选自身长度 ---- + +test("回退到身份行路径前核验目录身份:同路径的另一个仓库不能被当成本仓库(#679)", () => { + const rootA = makeFixture(); + execFileSync("git", ["remote", "add", "origin", "git@github.com:slow-stack/repo-a.git"], { cwd: rootA, stdio: "pipe" }); + const store = createStore(":memory:"); + let rootB = null; + try { + runChoroT0({ repoRoot: rootA, store, apply: true, now: FIXED_NOW }); + const row = store.db.prepare("SELECT id, root_path FROM choro_repos").get(); + // 另一个仓库 B(不同 remote),把 A 的 .chorography 复制过去——这样「路径存在 + // 且页面可读」,只有身份核验能挡住误读。 + rootB = makeFixture("repo-b-mark"); + execFileSync("git", ["remote", "add", "origin", "git@github.com:slow-stack/repo-b.git"], { cwd: rootB, stdio: "pipe" }); + cpSync(join(rootA, ".chorography"), join(rootB, ".chorography"), { recursive: true }); + // 模拟「身份行指向另一台机器登记的同一条路径,本机没有位置行」 + store.db.prepare("UPDATE choro_repos SET root_path = ? WHERE id = ?").run(rootB, row.id); + store.db.prepare("DELETE FROM choro_repo_paths").run(); + const q = createChoroQueries({ store, hostId: () => "host-without-position" }); + assert.equal(q.readPage(row.id, "src").error, "page-missing", + "身份不符时必须报本机不可达,不能读 B 的页面当成 A 的内容"); + const page = q.readPage(row.id, "src"); + assert.equal(page.content, undefined, "不许把 B 的正文交出去"); + } finally { + store.close(); + rmTempBestEffort(rootA); + if (rootB) rmTempBestEffort(rootB); + } +}); + +test("repoByCwd 的最长前缀比较候选自己的路径长度,而不是身份行里那条外地路径(#679)", () => { + const base = mkdtempSync(join(tmpdir(), "choro-prefix-")); + const inner = join(base, "nested"); + const cwd = join(inner, "src"); + mkdirSync(cwd, { recursive: true }); + const store = createStore(":memory:"); + try { + // 外层身份行留一条**很长**的外地路径;本机位置行指向 base。 + const longForeign = "D:\\" + "foreign-very-long-segment\\".repeat(20) + "outer"; + const outer = store.upsertChoroRepo(longForeign, "outer", null, "aa", "2026-11-10T00:00:00.000Z", "github.com/x/outer"); + store.upsertChoroRepoPath("github.com/x/outer", "hostA", base, "2026-11-10T00:00:00.000Z"); + // 内层身份行路径很短;本机位置行指向 inner(比 base 长)。 + store.upsertChoroRepo("D:\foreign\inner", "inner", null, "bb", "2026-11-10T00:00:00.000Z", "github.com/x/inner"); + store.upsertChoroRepoPath("github.com/x/inner", "hostA", inner, "2026-11-10T00:00:00.000Z"); + const q = createChoroQueries({ store, hostId: () => "hostA" }); + const hit = q.repoByCwd(cwd); + assert.ok(hit, "cwd 必须命中某个仓库"); + assert.equal(hit.id, store.db.prepare("SELECT id FROM choro_repos WHERE name='inner'").get().id, + "内层本机路径更长,必须赢过外层的身份行长路径"); + } finally { + store.close(); + rmTempBestEffort(base); + } +}); diff --git a/dsh-mneme/test/wiki-store.test.js b/dsh-mneme/test/wiki-store.test.js index b4c4391e..0ae68143 100644 --- a/dsh-mneme/test/wiki-store.test.js +++ b/dsh-mneme/test/wiki-store.test.js @@ -267,3 +267,37 @@ test("upsertChoroRepo:升格期 root 被别的行占着时不强改 root(否 assert.equal(store.choroRepoPathFor("github.com/x/y", "hostA")?.root_path, "D:\\repo", "本机位置照记"); store.close(); }); + +// ---- PR #679 评审:身份唯一 + 路径回退必须校验身份 ---- + +test("同一路径被另一身份占用时拒绝复用该行(否则会把对方的名字/基线/页一起改掉)", () => { + const store = openMemory(); + store.upsertChoroRepo("D:\work\repo", "a", null, "aa11", "2026-11-10T00:00:00.000Z", "github.com/a/a"); + assert.throws( + () => store.upsertChoroRepo("D:\work\repo", "b", null, "bb22", "2026-11-11T00:00:00.000Z", "github.com/b/b"), + /choro repo path conflict/); + const rows = store.db.prepare("SELECT name, portable_key, git_baseline FROM choro_repos").all(); + assert.equal(rows.length, 1, "不许把原行改成另一身份"); + assert.equal(rows[0].portable_key, "github.com/a/a"); + assert.equal(rows[0].name, "a"); + assert.equal(rows[0].git_baseline, "aa11"); + store.close(); +}); + +test("无键的存量行仍允许按路径认领(升格语义不变)", () => { + const store = openMemory(); + store.upsertChoroRepo("D:\legacy", "old", null, "aa", "2026-11-09T00:00:00.000Z", null); + const r = store.upsertChoroRepo("D:\legacy", "new", null, "bb", "2026-11-11T00:00:00.000Z", "github.com/x/y"); + assert.equal(r.portable_key, "github.com/x/y", "存量行被补上身份键"); + assert.equal(store.db.prepare("SELECT COUNT(*) AS n FROM choro_repos").get().n, 1); + store.close(); +}); + +test("portable_key 有唯一约束(部分索引)", () => { + const store = openMemory(); + const idx = store.db.prepare("SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_choro_repos_portable'").get(); + assert.ok(idx, "索引必须存在"); + assert.match(idx.sql, /UNIQUE/i, "同名身份不许两行"); + assert.match(idx.sql, /WHERE portable_key IS NOT NULL/i, "存量 NULL 行不受约束"); + store.close(); +}); diff --git a/dsh-mneme/test/wiki-t1.test.js b/dsh-mneme/test/wiki-t1.test.js index 1b5aeb7f..2cb5bb20 100644 --- a/dsh-mneme/test/wiki-t1.test.js +++ b/dsh-mneme/test/wiki-t1.test.js @@ -494,3 +494,31 @@ test("跨端认领:本机路径未登记但身份已登记(另一台机器 if (clone) rmTempBestEffort(clone); } }); + +// ---- PR #679 评审:路径被另一身份占用时不许认领 ---- + +test("T1 身份优先:同一条路径登记的是另一个仓库时拒绝(否则读 B 的页、写 A 的 claims)", async () => { + const rootA = makeFixture(); + execFileSync("git", ["remote", "add", "origin", "git@github.com:slow-stack/t1-a.git"], { cwd: rootA, stdio: "pipe" }); + const store = createStore(":memory:"); + let rootB = null; + try { + runChoroT0({ repoRoot: rootA, store, apply: true, now: FIXED_NOW }); + rootB = makeFixture(); + execFileSync("git", ["remote", "add", "origin", "git@github.com:slow-stack/t1-b.git"], { cwd: rootB, stdio: "pipe" }); + // 模拟共享库里的路径撞位:A 的身份行恰好写着 B 的本机路径 + store.db.prepare("UPDATE choro_repos SET root_path = ?").run(realpathSync(rootB)); + const t1 = createT1({ store, runSession: async () => ({ ok: true, text: GOOD_OUTPUT }), now: FIXED_NOW }); + await assert.rejects( + () => t1.runT1({ repoRoot: rootB, moduleSlug: "src" }), + (err) => err instanceof T1Refusal && err.code === "not-registered", + "路径命中但身份不符:必须拒绝,不能把 claims 写进 A 的行"); + // 拒绝发生在写库之前:A 的页状态没被 B 的富化推进 + const st = store.db.prepare("SELECT status FROM choro_pages LIMIT 1").get(); + assert.equal(st.status, "skeleton"); + } finally { + store.close(); + rmTempBestEffort(rootA); + if (rootB) rmTempBestEffort(rootB); + } +});