diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index af4cf6c3..947980fc 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -17,6 +17,8 @@ - **知识库未启用时的空态文案指错了分组名(#668 尾巴)**。空态一直写「设置 → 记忆(mneme) → 功能 → 知识库」,但 v0.13 起 `group.ext` 是独立分组、标签是「扩展功能」(英文 `Extensions`),「知识库」只是组里的一项——照这句话去设置页找「知识库」分组会找不到。中英两版都改成「功能 → 扩展功能」并点名组内那一项,`test/client.test.js` 的文案锁同步跟上。 - **无会话身份的系统写入整片没进写入准入:三类出口在 `if (!sessionKey) return verdict` 处直接把判据跳过了(#663)**。`writeAdmission` 的判据挂在 `service.saveWithDedupe` 的汇流口上,而 `evaluate` 开头有一句「无会话身份 = 系统写入,不进预算」就 `return`——预算确实不该进,**判据却连跑都没跑**。于是 autoSummarize 的蒸馏产出、dream 的总览落盘、`bootstrapFromDirectory` 的整目录导入这三条路,密钥/PII 与空白噪声一次都没判过;`writeAdmission.enforce` 打开时它们照样无条件入库。更麻烦的是三条调用点各自把返回值丢了:`dream.js` 写总览的地方根本不看 `saveWithDedupe` 的返回,`summaryStored = true` 无条件置位(拒绝也被当成「已写入」,直接反映到运行结果与常驻条);`bootstrap.js` 解构出 `memory` 就往 `items.push({ id: memory.id })` 走,被拒时 `memory` 是 `null`,解引用抛错掉进外层逐条 catch,**拒绝理由与被拒条数一起丢**,只剩一条 warn。修法是把「会话时间线」和「判据」拆开:第 1 级判据(空白/噪声、密钥/PII)与会话身份无关,对所有写入照跑;命中且 `enforce` 时 `gate` 记 `null`(没有会话就没有预算维度)、拒绝照样生效;判据之后才 `if (!sessionKey) return judged;` 挡在预算/同话题冷却/内容哈希之前——这三样都要一条真实会话时间线做基准,硬凑一个假 `session_key` 会把真实会话的分布污染成两截,读审计的人再也分不开。`record` 的落行条件同步改成「放行档必须有会话且有 gate,拒绝档一律落行」:系统写入的放行不该留计量行(读不出分布,只撑大审计表),但**拒绝是一次真实的丢弃,必须留行**,`session_key` 记为 NULL。调用点各自补上拒绝处理:dream 只在非 `denied` 时置 `summaryStored = true`;bootstrap 解构出 `reason`、`denied` 计数与 `items.push({ action, id: null, title, reason })`,返回对象多一个 `denied` 字段——不再让 `null` 掉进 catch 把理由吃掉;standalone HTTP 那条路径的注释从「拦不到」改成「只过第 1 级」,显式 422 分支保留(落到 `toApiList([null])` 会变 500,把「被拒」伪装成「服务器出错」)。`sensitiveScanEnabled` 的文档口径一起校正:它覆盖所有写入的第 1 级,无会话身份的路径同样在内。**两处刻意没接**:`share-import.js` 走 `store.save` 而不走汇流口(`docs/SHARE.md` 把收包侧定成信任边界,要接得先改那条路,不在本件范围);`saveWithDedupe` 的 `existing` 合并分支在准入之前就 `return`,合并写入整条绕过闸门——这是另一条缺口,同样单独处理。**测试** 3,496 → **3,502**(+6):`test/write-admission.test.js` 覆盖三档系统写入(默认档不判不落行、观察档落行且 `session_key` 为 NULL / `metadata.gate === null`、拦截档 `denied` 且零行),以及 bootstrap 与 dream 两条真实出口的集成(被拒条数、`id: null`、无 summary 行、审计行带命中类型)。变异自证:把 `if (!sessionKey) return judged;` 挪回判据之前 → 4 红;`record` 恢复旧 guard → 拒绝档落行断言红;dream 改回无条件 `summaryStored = true` → 集成用例红;bootstrap 不接 `denied` 分支 → `report.denied` 与 `items[0].id` 断言红。 +- **验证基线只记 HEAD:工作区里还没提交的改动,提交之后就永远判不到了(#648)**。`verifiedBaseline` 一直是 `git rev-parse HEAD`,而 claims 的行号取自**磁盘上的内容**——工作区脏的时候,这两者不是同一棵树。机理很具体:改了某文件第 30 行但没提交,这时跑 T1,claim 按新内容算出的行号落库、基线却记成当前 HEAD;等这个改动被提交,`diffSince(HEAD, 新HEAD)` 从改动之后起算,diff 里根本看不到这一段,claim 会被判成「没被动过」,而且**这个窗口不会自愈**——改动永远落在基线之前。修法是**把「工作区状态」单独记一份**,而不是给基线换一种表示:T1 富化时用 `git status --porcelain -z --untracked-files=all` 取脏文件清单(`src/wiki/t0.js` 新增 `gitDirtyFiles`,与 `gitHead` 并列),连同 `verified_baseline` 一起落库(`choro_pages.verified_dirty_files`,幂等迁移补列)与 evidence 旁证,页面 frontmatter 只写一个布尔 `verifiedDirty`(清单太长,不该淹掉给人读的页头)。判定侧在 `src/wiki/claims.js` 的 `summarizeClaims` 里分两层:清单里**被 claim 引用**的文件判新理由 `uncommitted`(行号来自任何提交都证明不了的树),其余文件照旧走精确 diff——**不做整页降级**,一个无关脏文件不该杀掉整页的漂移检测。刻意不写 `git write-tree` / stash 式 ref:那会留下悬空对象或额外 ref,默认 2 周后被 gc 掉,等于把可解释性押在对象存活上;文件清单是纯文本,随库走。`.chorography/**` 从清单里滤掉——那是工具自己的产地(这一轮写盘本身就会让它脏),而 claims 永远不会指向它。**老数据不猜**:`verified_dirty_files` 为 NULL 的页 = 「不知道当时工作区状态」,不判 `uncommitted`、不回填,面板照旧按提交基线比对;空数组才是明确的「当时干净」,两者严格分开。展示面同步:面板的复核理由多一档中文/英文文案,页头 provenance 对脏工作区验证过的页改成「对着 {sha} 验证(当时工作区有未提交改动)」,不再冒充干净提交。回归测试 +4(脏工作区富化记清单且 claim 判 `uncommitted`、提交之后仍不自愈;只有被引用的脏文件才牵连;老数据 NULL 与空数组语义分开;旧库幂等补列),`test/wiki-provenance.test.js` 10/10。变异自证:去掉 `uncommitted` 覆盖 → 前两条红;把脏清单当空数组处理 → 老数据用例红;去掉 `.chorography` 过滤 → 清单条数断言红;迁移不补列 → 迁移用例红。 + - **跨类型同义双录:蒸馏提示词里「宁可拆成多条」把同一件事记了两遍,而三处去重各守一道类型闸(#687);实体表 75% 的实体只被提过一次、名字只按大小写敏感精确匹配(#688)**。两条 issue 报告的是同一个根病:**记忆的「类型」被当成了「对象身份」**。#687 上报者(xueyue33)给的分布很刺眼——他自己库里 `sim>=0.85` 的 81 对近重复中 **72 对跨类型**、`>=0.90` 的 7 对中 6 对跨类型;`sim>=0.88` 的 24 对里 21 对跨类型。四处夹缝逐个核过都成立:`src/lang.js` 的 `codingSummary` 明确让模型「宁可拆成多条」而 `pitfall`(现象+根因+规避)与 `constraint`(约束+来源)本就是同一结论的两种写法;`src/service.js:1480` 的 `findSessionDuplicate` 用 `store.list({ type: memory.type, source, limit: 200 })` 锁死同类型同会话;`src/dream/clustering.js:117` 的 `findPotentialConflicts` 首行 `if (memories[i].type !== memories[j].type) continue;`;dream 巩固提示词同样写着「仅合并同类型」。**我们不开常开类型闸**:本机活跃库 9,275 条带向量的记忆里跨类型 `cosine>=0.90` 共 45 对,逐对读正文后 **36 对(80%)确为同义,9 对合并会丢信息**(5 对时间序列/状态快照、2 对症状 vs 修复、1 对结论反转、1 对版本号不同)——也就是说「凡是跨类型高相似就合并」每 5 对要吞掉 1 对真互补的信息,而这类对只占活跃总量 0.97%,收益与风险不对称。落地的三层是**源头互斥 + 批内去重 + 存量一次性处置**:① `codingSummary` 提示词(中英双语)补一条互斥约束——同一结论只记一条,`pitfall` 与 `constraint` 不双写,时间序列/状态快照写进同一条;② 蒸馏落盘前做**批内**跨类型去重,只对 `pitfall↔constraint`、`history↔project` 两组生效(正是实测最集中的应用对:9 对 + 8 对),`planBatchCrossTypeDedupe(entries, { minSim })` 是纯函数——按相似度降序、每条最多被退休一次、不许链式藏行(A 取代 B 的同时 B 又取代过 C 会把 C 永久埋在链底);门槛 `BATCH_CROSS_TYPE_MIN_SIM = 0.85`(textSim,不是余弦,词面重合是这两组对的主特征)、`BATCH_CROSS_TYPE_MIN_CHARS = 24`、`BATCH_CROSS_TYPE_MIN_LEN_RATIO = 0.5`;③ 处置走 `store.supersedeChain` 只写 `superseded_by` / `superseded_at`,**保留正文更全的一条、被取代者正文零改写、不归档、不 merge**——判错时旧行仍在库里、显式搜索照旧读得到,且 `supersededIds()` 的 `JOIN ... AND w.archived=0` 让继任者一旦被归档旧行自动回到可见,等于自带回退。刻意**没有**复用 `src/dream/decisions.js` 的 `supersedeInfoFirstPair`:它走 `service.update` 会在正文尾部追加「已被取代」前缀,与「正文零改写」直接冲突。**默认开**(2026-11-13 拍板):这个开关存在的意义就是让新写入不再双录,默认关等于修了不生效,所以 `src/config.js` 给 `true`、设 `false` 是 kill switch,且关时审计行的 `batchDeduped` / `batchPairs` 两个键根本不出现——加开关之前的行为逐字节不变。两个键同时登记进 `settings.js` 的 `FEATURE_FLAG_BOOLEANS` 与 `FEATURE_FLAG_NUMBER_RANGES`,面板上能改。存量 17 对**不由代码处置**:只读脚本 `scripts/cross-type-dedupe-dryrun.mjs` 出清单(输出**禁止落在仓库内**,清单里含记忆原文),复核后由人决定、按纯 SQL 打链。 **#688 的修法只落在写入侧**。上报者库里 12,700+ 实体、38,500+ 关系、孤儿 41%、`mention_count=1` 占 72%;本机 live 库(只读打开)实测实体 **25,474**、关系 **59,715**、`mention_count=1` 占 **75%**、大小写/空白重复 262 组;而按 `entity_relations.memory_id` 真实连接口径,孤儿是 **17.9%(4,560 行)** 而不是 41%——口径不同,这里写清楚。存量**不做合并**:实体 id 是 `randomUUID()`,合并要重指 59,715 行关系且 `entity_relations` 没有唯一约束,重指过程一旦中断会造出重复边,收益(0.68% 的实体)与风险同样不对称。改的是查找键——`findEntityByName` 从 `WHERE name = ?` 改成 `lower(trim(name))` + `type` 的等价比较并为此建表达式索引(`DeepSeek-V3` 与 `DeepSeek-V4` 是两个不同对象,不剥版本号后缀;`createEntity` 落库只去首尾空白、**保留原大小写**用于展示)。类型维度不能省:实测去掉 type 会误并 95 组(`constraint` 与 `pitfall` 常同名)。另外 `entities.canonical_memory_id` 是 v0.7.1(`5b5a2aa4`)引入的预留列,**从没接通线**——`src/store.js` 里只有 DDL、行映射、`INSERT`、`UPDATE` 四处 plumbing,没有任何调用方写它或读它,25,474 行全 NULL;这次按幂等迁移删列(`PRAGMA table_info` 判断 + `ALTER TABLE ... DROP COLUMN`,宿主 SQLite 3.51.3 支持)。迁移成本实测:25,474 行旧 schema 表首次 `createStore` **396ms**(含建表达式索引),二次 24ms。 diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index d29f7510..ae4be066 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -373,10 +373,12 @@ window.__ModuleLoader__.load({ "wiki.drift.shifted": "上方有增删,行号挪了 {n}", "wiki.drift.deleted": "文件已删", "wiki.drift.unverifiable": "这条没记行号,无法定位", + "wiki.drift.uncommitted": "验证时这行还没提交,提交层面的比对看不到它", "wiki.drift.inconsistent": "正文有语义段、库里却没有结论记录(上次 T1 没写完),这页证据无法核对。", // 语义段 provenance(①):每页的结论是「对着哪个提交」读出来的。不说清这一点, // 「证据复核」就只是在和某个不明时点比——页越晚富化,误报越多。 "wiki.drift.verified": "对着 {sha} 验证", + "wiki.drift.verifiedDirty": "对着 {sha} 验证(当时工作区有未提交改动)", "wiki.drift.verifiedUnknown": "这页没记下是哪个提交验证的(早期富化没有记录),所以判不出它有没有过时。重跑一次 T1 就会记上。", "wiki.drift.verifiedApprox": "这页是早期富化的,没记下自己的验证点;下面按全仓基线 {sha} 比对(起点比实际更早,只会多报不会漏报)。", "wiki.drift.anchorStalePage": "这页记下的验证提交在 git 里已经找不到了(force-push 或浅克隆截断),这页没法比对。", @@ -1557,10 +1559,12 @@ window.__ModuleLoader__.load({ "wiki.drift.shifted": "Insertions above moved it {n} lines", "wiki.drift.deleted": "File deleted", "wiki.drift.unverifiable": "No line number recorded — cannot locate", + "wiki.drift.uncommitted": "This file was uncommitted when the claim was recorded — commit-level diffs cannot see it", "wiki.drift.inconsistent": "This page has prose in the file but no claims on record (the last T1 run did not finish), so its evidence cannot be checked.", // Claim provenance: each page's claims were read from one specific commit. Without // saying which, "evidence check" compares against an unknown point in time. "wiki.drift.verified": "Verified at {sha}", + "wiki.drift.verifiedDirty": "Verified at {sha} (working tree had uncommitted changes)", "wiki.drift.verifiedUnknown": "This page did not record which commit its claims were verified against (earlier enrichment did not store one), so staleness cannot be decided. Re-running T1 will record it.", "wiki.drift.verifiedApprox": "This page was enriched before provenance was recorded; the comparison below starts from the repository baseline {sha} (earlier than the real one, so it can over-report but never miss).", "wiki.drift.anchorStalePage": "The commit this page was verified against no longer exists in git (force-push or a shallow clone cut it off), so this page cannot be compared.", @@ -13782,13 +13786,15 @@ window.__ModuleLoader__.load({ : 0; // 提交号显示前 7 位就够认,长 sha 会把这一行挤爆(完整值仍在 title 里)。 const shortSha = (sha) => String(sha ?? "").slice(0, 7); - // 四种命中原因各自一句:混成「失效」会让只是行号挪了的结论也被喊去重写, - // 喊几次就没人信这个标记了。 + // 五种命中原因各自一句:混成「失效」会让只是行号挪了的结论也被喊去重写, + // 喊几次就没人信这个标记了。uncommitted 与「改动人」不是一回事:见 #648, + // 它说的是验证那天文件还没进提交,任何提交层面的 diff 都看不到这段。 const driftReasonText = (c) => { if (c.reason === "touched") return t("wiki.drift.touched"); if (c.reason === "shifted") return t("wiki.drift.shifted").replace("{n}", String(c.shift ?? 0)); if (c.reason === "deleted") return t("wiki.drift.deleted"); if (c.reason === "unverifiable") return t("wiki.drift.unverifiable"); + if (c.reason === "uncommitted") return t("wiki.drift.uncommitted"); return String(c.reason ?? ""); }; // 树的层级看 parent_id 真父子(#582),不看 slug 里的连字符数—— @@ -13858,7 +13864,11 @@ window.__ModuleLoader__.load({ const pageProvenance = driftDone && selectedDrift ? (selectedDrift.verifiedBaseline ? h("div", { className: "mneme-wikidriftverified", title: selectedDrift.verifiedBaseline }, - t("wiki.drift.verified").replace("{sha}", shortSha(selectedDrift.verifiedBaseline))) + // 脏工作区验证的页不能长成「对着 xxxx 验证」——那会让读者以为锚点来自一次 + // 干净提交。多一句说明,比让人事后自己发现判定不到的窗口强(#648)。 + ((Array.isArray(selectedDrift.verifiedDirtyFiles) && selectedDrift.verifiedDirtyFiles.length > 0) + ? t("wiki.drift.verifiedDirty") + : t("wiki.drift.verified")).replace("{sha}", shortSha(selectedDrift.verifiedBaseline))) // 老数据:没有逐页验证点,退到全仓基线比对。必须说清这是**保底起点**, // 不能长成「对着 xxxx 验证」——那是把近似值冒充成真值。 : (selectedDrift.baselineApprox && selectedDrift.diffBaseline diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index b1f77992..de0d8cf4 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -697,7 +697,11 @@ CREATE TABLE IF NOT EXISTS choro_pages ( -- T0 重建整页时随 T1 正文一起归 NULL。全仓 git_baseline 是 T0 的时点,拿它当 claims -- 的比对起点会把「T1 之前就发生、claim 早已算进去」的改动误报成 touched。 verified_baseline TEXT, - verified_at TEXT + verified_at TEXT, + -- 验证那一刻工作区里哪些文件还没进提交(#648,JSON 数组)。claims 的行号取自磁盘 + -- 内容,这些文件的改动在 baseline..head 的 diff 里永远看不到,只能凭这份清单判 + -- 「无法用提交核对」。NULL = 不知道当时的工作区状态(本列之前富化的老数据)。 + verified_dirty_files TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_pages_module ON choro_pages(module_id); @@ -1386,6 +1390,9 @@ export function createStore(path) { // 存量行的 NULL 就是事实(那批页富化时还没记这个),不要伪造一个提交号。 addColumn("choro_pages", "verified_baseline", "ALTER TABLE choro_pages ADD COLUMN verified_baseline TEXT"); addColumn("choro_pages", "verified_at", "ALTER TABLE choro_pages ADD COLUMN verified_at TEXT"); + // 工作区脏文件清单(#648):同样是「不知道就留 NULL」,不给老行编一个空数组—— + // 空数组的意思是「当时工作区是干净的」,与 NULL 的「没记过」必须分开。 + addColumn("choro_pages", "verified_dirty_files", "ALTER TABLE choro_pages ADD COLUMN verified_dirty_files TEXT"); // chorography 收尾档(P36):v0.11.0 的 choro_vectors 是空壳表(spec §5 漏了 // 向量本体列),存量库经 PRAGMA 检查补列。 addColumn("choro_vectors", "vector", "ALTER TABLE choro_vectors ADD COLUMN vector TEXT"); @@ -5782,13 +5789,17 @@ export function createStore(path) { // verifiedBaseline 是 T1 那一刻的 HEAD:claims 的行号取自那个提交的代码,之后判失效 // 只能从它起算(传 null 表示拿不到 HEAD——那时 diff 判不了,面板要说「不知道」, // 而不是退回全仓 git_baseline 假装知道)。 - function markChoroPageEnriched(pageId, generatedAt, verifiedBaseline = null) { + function markChoroPageEnriched(pageId, generatedAt, verifiedBaseline = null, verifiedDirtyFiles = null) { + // 脏文件清单单独一列(#648):基线记的是提交,清单记的是「提交之外还有哪些文件 + // 在那一刻参与了 claim 抽取」。null(不知道/读不出)与 [](当时确实干净)严格分开, + // 面板据此才能说清这一页是用哪棵树验证的。 + const dirtyJson = Array.isArray(verifiedDirtyFiles) ? JSON.stringify(verifiedDirtyFiles) : null; db.prepare( `UPDATE choro_pages SET status = 'enriched', generated_at = ?, fail_count = 0, - verified_baseline = ?, verified_at = ? + verified_baseline = ?, verified_at = ?, verified_dirty_files = ? WHERE id = ?` - ).run(generatedAt, verifiedBaseline, generatedAt, pageId); + ).run(generatedAt, verifiedBaseline, generatedAt, dirtyJson, pageId); } // 单页 claims 整页替换(P20 的表侧):T1 一轮产出的 claims 删旧插新;evidence/ diff --git a/dsh-mneme/lib/wiki/claims.js b/dsh-mneme/lib/wiki/claims.js index d9b86353..8eb8cb1d 100644 --- a/dsh-mneme/lib/wiki/claims.js +++ b/dsh-mneme/lib/wiki/claims.js @@ -135,6 +135,10 @@ function normPath(p) { * 锚点要重新指。刻意与 touched 分开:混成一个「失效」,面板就会 * 对着一堆只是行号错位的结论喊「要重写」,喊几次就没人信了。 * - unverifiable 所属文件改过,可这条 claim 当初就没记行号,无法定位到具体一行。 + * - uncommitted 验证那一刻这个文件还在工作区里没提交(#648)。claim 的行号取自 + * 磁盘内容而不是任何提交,提交层面的 diff 从改动之后起算,永远看不到 + * 这段——只能老实说「无法用提交核对」,不能冒充「没变」。这一档由 + * summarizeClaims 依据页面记下的脏文件清单补,不在 diff 里算。 * * 输入 diff 为 { files: Map, deleted: string[] };输出与输入等长,未受影响的 * reason 为 null(调用方自行过滤)。 @@ -174,7 +178,7 @@ export function assessClaims(claims, diff) { * 仓库级汇总:逐条判定 → 「哪几页、几条、因为什么」。 * * claims 每项需带 pageSlug;pages 每项 { slug, title, hasT1, verifiedBaseline, - * verifiedAt, diffBaseline, baselineApprox, baselineUnknown, anchorStale }。 + * verifiedAt, verifiedDirtyFiles, diffBaseline, baselineApprox, baselineUnknown, anchorStale }。 * * 为什么 pages 要带验证基线、为什么还要 diffByPage:claims 的行号是**各自那页被 * 富化的那一刻**记下的。全仓只有一个 git_baseline(T0 的时点),拿它当所有 claims @@ -192,6 +196,22 @@ export function summarizeClaims(claims, diff, { pages = [], diffByPage = null } const assessed = diffByPage ? (claims ?? []).flatMap((claim) => assessClaims([claim], diffByPage(claim.pageSlug))) : assessClaims(claims, diff); + // 脏文件上的 claim 覆盖 diff 的判定(#648):diff 比对的是两个提交,而 claim 的行号 + // 取自验证时刻的磁盘。文件当时没进提交,diff 里就必然没有这段改动——这时 diff 说 + // 「没碰过」是假阴性,必须换成 uncommitted。没有这份清单的页(老数据)不覆盖。 + const dirtyBySlug = new Map(); + for (const page of pages) { + if (Array.isArray(page.verifiedDirtyFiles) && page.verifiedDirtyFiles.length > 0) { + dirtyBySlug.set(page.slug, new Set(page.verifiedDirtyFiles.map((p) => normPath(p)))); + } + } + const judged = dirtyBySlug.size === 0 + ? assessed + : assessed.map((claim) => { + const dirty = dirtyBySlug.get(claim.pageSlug); + if (dirty && dirty.has(normPath(claim.sourcePath))) return { ...claim, reason: "uncommitted", shift: 0 }; + return claim; + }); const bySlug = new Map(); for (const page of pages) { bySlug.set(page.slug, { @@ -200,6 +220,8 @@ export function summarizeClaims(claims, diff, { pages = [], diffByPage = null } hasT1: Boolean(page.hasT1), verifiedBaseline: page.verifiedBaseline ?? null, verifiedAt: page.verifiedAt ?? null, + // 原样透传:面板要说「这一页是在含未提交改动的状态下验证的」(#648)。 + verifiedDirtyFiles: Array.isArray(page.verifiedDirtyFiles) ? page.verifiedDirtyFiles : null, // 实际拿来跑 diff 的起点:新数据就是 verifiedBaseline;老数据(本功能之前富化) // 退到全仓基线,baselineApprox 标记「这不是当真的验证点,是保底起点」。 diffBaseline: page.diffBaseline ?? page.verifiedBaseline ?? null, @@ -211,7 +233,7 @@ export function summarizeClaims(claims, diff, { pages = [], diffByPage = null } inconsistent: false, }); } - for (const claim of assessed) { + for (const claim of judged) { const slug = claim.pageSlug; if (!bySlug.has(slug)) { bySlug.set(slug, { slug, title: claim.pageTitle ?? slug, hasT1: false, claims: 0, atRisk: [], inconsistent: false }); @@ -259,6 +281,7 @@ export function summarizeClaims(claims, diff, { pages = [], diffByPage = null } shifted: count("shifted"), deleted: count("deleted"), unverifiable: count("unverifiable"), + uncommitted: count("uncommitted"), inconsistentPages: list.filter((p) => p.inconsistent).length, baselineUnknownPages: list.filter((p) => p.baselineUnknown).length, anchorStalePages: list.filter((p) => p.anchorStale).length, diff --git a/dsh-mneme/lib/wiki/queries.js b/dsh-mneme/lib/wiki/queries.js index 415ad6e2..985bdde8 100644 --- a/dsh-mneme/lib/wiki/queries.js +++ b/dsh-mneme/lib/wiki/queries.js @@ -470,7 +470,7 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) ).all(repoId); // 页面清单单算:hasT1 只能读盘(标记写在正文里,库里没有这一列);验证基线在库里。 const pageRows = db.prepare( - `SELECT m.slug, m.title, p.rel_path, p.verified_baseline, p.verified_at + `SELECT m.slug, m.title, p.rel_path, p.verified_baseline, p.verified_at, p.verified_dirty_files FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id WHERE m.repo_id = ? ORDER BY m.slug` ).all(repoId); @@ -500,6 +500,9 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) } catch { hasT1 = false; } const verifiedBaseline = String(p.verified_baseline ?? "").trim() || null; const verifiedAt = p.verified_at ?? null; + // 验证那一刻工作区里还没进提交的文件(#648)。NULL / 读不出 = 不知道当时状态, + // 不是「当时干净」;只有 [] 才是明确的干净。调用方据此把「不知道」与「没动」分开。 + const verifiedDirtyFiles = parseDirtyFiles(p.verified_dirty_files); // 老数据(本功能之前富化的页)只有全仓基线可依。退到它是**保守**方向:它早于 // 那页被富化的时刻,diff 会把「T1 之前就发生」的改动也算进来,只会多报不会漏报。 // 记 baselineApprox 是为了面板能说清「这是退而求其次的起点」,不是当真的验证点。 @@ -517,6 +520,7 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) hasT1, verifiedBaseline, verifiedAt, + verifiedDirtyFiles, diffBaseline: effective, baselineApprox: !verifyWithoutBaseline && verifiedBaseline === null && effective !== null, baselineUnknown: effective === null, @@ -583,6 +587,19 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) }; } + // 脏文件清单的读法:列里存 JSON 数组;NULL(老数据,不知道当时状态)与解析失败 + // 一律回 null——宁可说「不知道」,也不能回一个空数组冒充「当时很干净」(#648)。 + function parseDirtyFiles(raw) { + const text = String(raw ?? "").trim(); + if (text === "") return null; + try { + const parsed = JSON.parse(text); + return Array.isArray(parsed) ? parsed.map((v) => String(v)) : null; + } catch { + return null; + } + } + // --- chorography 向量(收尾档 P37–P39)--------------------------------- // 惰性物化(P37):只在 host 侧检索路径调用(embedder 由 index.js 注入); // CLI 环境没有 embedder → 向量永不物化,检索退关键词。staleness 两维: diff --git a/dsh-mneme/lib/wiki/render.js b/dsh-mneme/lib/wiki/render.js index 5affcf05..babec5b4 100644 --- a/dsh-mneme/lib/wiki/render.js +++ b/dsh-mneme/lib/wiki/render.js @@ -29,6 +29,10 @@ export const T1_SECTION_END = ""; // 同一件事,不新增语义。放在这里而不是 t1.js:frontmatter 的形状属于页面格式。 export const FM_VERIFIED_BASELINE = "verifiedBaseline"; export const FM_VERIFIED_AT = "verifiedAt"; +// ③ 工作区脏标记(#648):验证那一刻工作区有没有未提交改动。只写布尔值,具体清单 +// 落库(choro_pages.verified_dirty_files)与 evidence 旁证——页面文件是给人读的, +// 几十行路径会把 frontmatter 淹掉,而「这一页不是对着干净 HEAD 验的」一句话就够。 +export const FM_VERIFIED_DIRTY = "verifiedDirty"; // 页面生命周期档(#582):status 也是 frontmatter 键,但它 T0 / T1 都写——T0 // 起骨架时写 skeleton,T1 成功时随 verified 两键一起推进 enriched。定义在这 diff --git a/dsh-mneme/lib/wiki/t0.js b/dsh-mneme/lib/wiki/t0.js index 307653a8..a78e10ab 100644 --- a/dsh-mneme/lib/wiki/t0.js +++ b/dsh-mneme/lib/wiki/t0.js @@ -346,6 +346,57 @@ export function gitHead(repoRoot) { } } +/** + * 验证那一刻工作区里有哪些**已被 git 跟踪或未跟踪但未被忽略**的文件是脏的(#648)。 + * + * 为什么需要它:claims 的行号取自**磁盘上的内容**,而 HEAD 只描述上一次提交。工作区 + * 有未提交改动时,这两者不是同一个状态——改动被提交后 `diffSince(HEAD, 新HEAD)` 从 + * 改动之后起算,这一段永远看不到,claim 会被误判成没被动过。所以富化时必须把「当时 + * 哪些文件不在提交里」记下来,判定时这些文件上的 claim 只能判「无法用提交核对」, + * 不能冒充「没变」。 + * + * 为什么不用写一个 tree 对象:`git write-tree` / stash 式 ref 会在仓库里留下悬空对象 + * 或额外 ref,默认 2 周后被 gc 掉;靠它当基线等于把可解释性押在对象存活上。文件清单 + * 是纯文本、随页面一起落库,重装/换机都还在。 + * + * `--porcelain -z` 天然不含 .gitignore 命中的产物(除非它们本来就已被跟踪,那时它们 + * 的改动确实会影响 claim,该算脏)。`--untracked-files=all` 让未跟踪目录展开成文件, + * 否则整个目录只报一行、里面具体哪个文件脏就丢了。 + * + * 返回排好序的仓库相对路径数组(正斜杠);读不出来返回 null —— null 是「不知道」, + * 不是「干净」,调用方不得把它当空数组用。 + */ +export function gitDirtyFiles(repoRoot) { + try { + const out = execFileSync( + "git", + ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] } + ); + const records = String(out).split("\0"); + const paths = []; + const norm = (p) => String(p).replace(/\\/g, "/"); + for (let i = 0; i < records.length; i += 1) { + const record = records[i]; + if (record.length < 4 || record[2] !== " ") continue; + const xy = record.slice(0, 2); + paths.push(norm(record.slice(3))); + // rename/copy 在 -z 里把旧路径作为下一条独立记录(不带状态前缀)跟出来: + // 旧路径上的 claim 同样失去了提交层面的可比性,一起记。 + if (xy.includes("R") || xy.includes("C")) { + const from = records[i + 1]; + if (from) { + paths.push(norm(from)); + i += 1; + } + } + } + return [...new Set(paths)].sort(); + } catch { + return null; + } +} + function readState(repoRoot) { try { const raw = readFileSync(join(repoRoot, CHORO_DIR, "state.json"), "utf8"); diff --git a/dsh-mneme/lib/wiki/t1.js b/dsh-mneme/lib/wiki/t1.js index 2bca7d9c..b95d72b6 100644 --- a/dsh-mneme/lib/wiki/t1.js +++ b/dsh-mneme/lib/wiki/t1.js @@ -25,9 +25,9 @@ import { tmpdir } from "node:os"; import { realpathSync } from "node:fs"; import { extractHumanBlocks, injectHumanBlocks, T1_SECTION_START, T1_SECTION_END, - FM_VERIFIED_AT, FM_VERIFIED_BASELINE, FM_STATUS, setFrontmatterKeys, + FM_VERIFIED_AT, FM_VERIFIED_BASELINE, FM_VERIFIED_DIRTY, FM_STATUS, setFrontmatterKeys, } from "./render.js"; -import { gitHead } from "./t0.js"; +import { gitHead, gitDirtyFiles } from "./t0.js"; import { portableKeyFor, hostId } from "./portable.js"; // issue #607:拒绝文案里的 CLI 命令要给绝对路径入口(profile 装的插件没有全局 // shim,光写 `dsh-mneme-choro` 用户照抄只会 command not found)。 @@ -456,13 +456,30 @@ export function createT1({ store, runSession = null, limits = DEFAULT_LIMITS, no // 的,之后判失效只能从它起算。拿不到 HEAD(不是 git 仓库 / git 不在)就记 null, // 面板据此说「这一页哪次验证的,不知道」——不退回全仓 git_baseline,那会把 // 「T1 之前就发生、claim 早已算进去」的改动误报成 touched。 + // claims 的行号取自磁盘内容;HEAD 只描述上一次提交。工作区脏时这两者不是同一棵树, + // 必须把「当时哪些文件还没进提交」一起记下(#648)——否则改动被提交后,diff 从改动 + // 之后起算,这段判定不到的窗口永远不会自愈。 const generatedAt = now(); const verifiedBaseline = gitHead(rootPath); - enrichPageFile(pageFile, parsed.sections, { verifiedBaseline, verifiedAt: generatedAt }); - writeEvidenceFile(rootPath, slug, parsed.claims, generatedAt, verifiedBaseline); + // .chorography/** 是工具自己的产地:这一轮写盘本身就会让它变脏,把它算进清单 + // 只会让每一页都「脏」,而 claims 永远不会指向这里。滤掉才是真的「源码那棵树脏 + // 不脏」。 + const dirty = gitDirtyFiles(rootPath); + const verifiedDirtyFiles = Array.isArray(dirty) + ? dirty.filter((p) => !String(p).startsWith(".chorography/")) + : null; + enrichPageFile(pageFile, parsed.sections, { verifiedBaseline, verifiedAt: generatedAt, verifiedDirtyFiles }); + writeEvidenceFile(rootPath, slug, parsed.claims, generatedAt, verifiedBaseline, verifiedDirtyFiles); store.saveChoroPageClaims(pageRow.id, parsed.claims.map((c) => ({ ...c, createdAt: generatedAt }))); - store.markChoroPageEnriched(pageRow.id, generatedAt, verifiedBaseline); - receipt.enriched.push({ slug, sections: Object.keys(parsed.sections), claims: parsed.claims.length, verifiedBaseline }); + store.markChoroPageEnriched(pageRow.id, generatedAt, verifiedBaseline, verifiedDirtyFiles); + receipt.enriched.push({ + slug, + sections: Object.keys(parsed.sections), + claims: parsed.claims.length, + verifiedBaseline, + // 回执里给条数就够:人要知道的是「这一页不是对着干净 HEAD 验的」,不是逐条路径。 + ...(Array.isArray(verifiedDirtyFiles) ? { verifiedDirtyFiles: verifiedDirtyFiles.length } : {}), + }); } return receipt; } @@ -546,7 +563,7 @@ function parseT1Output(text) { // 顺带把「对着哪个提交验证的」写进 frontmatter(① provenance):这两个键**只有 T1 // 写**,T0 渲染一个字节都不产出。因此 T0 重跑整页重建时它们随语义段一起消失, // 与「T0 丢弃 T1 正文」是同一件事,不新增语义,也不碰 T0 的逐字节确定性。 -function enrichPageFile(pageFile, sections, { verifiedBaseline = null, verifiedAt = null } = {}) { +function enrichPageFile(pageFile, sections, { verifiedBaseline = null, verifiedAt = null, verifiedDirtyFiles = null } = {}) { let content = readFileSync(pageFile, "utf8"); // 剥旧 T1 段(含标记本身) const wrap = new RegExp(`${escapeRe(T1_SECTION_START)}[\\s\\S]*?${escapeRe(T1_SECTION_END)}\\n?`, "g"); @@ -584,6 +601,9 @@ function enrichPageFile(pageFile, sections, { verifiedBaseline = null, verifiedA const finalContent = setFrontmatterKeys(withHuman, [ [FM_VERIFIED_BASELINE, verifiedBaseline], [FM_VERIFIED_AT, verifiedAt], + // 布尔标记而不是清单:页面文件给人读,清单在库里与 evidence 里。「读不出工作区 + // 状态」(null)不写这个键——不知道就不写,别留一个 false 冒充「当时很干净」。 + [FM_VERIFIED_DIRTY, Array.isArray(verifiedDirtyFiles) ? verifiedDirtyFiles.length > 0 : null], // 文件里的 status 与库里的 choro_pages.status 同步推进(#582):此前 T1 // 成功只改库(markChoroPageEnriched),文件那行永远停在 T0 写的 skeleton // ——页面文件自身与数据库状态不一致,看文件的人会以为这页从没富化过。 @@ -601,13 +621,20 @@ function stripHumanBlocks(text) { return text.replace(re, ""); } -function writeEvidenceFile(repoRoot, slug, claims, generatedAt, verifiedBaseline = null) { +function writeEvidenceFile(repoRoot, slug, claims, generatedAt, verifiedBaseline = null, verifiedDirtyFiles = null) { const rel = `${".chorography"}/evidence/${slug}.json`; const abs = insideChoro(repoRoot, rel); mkdirp(dirname(abs)); // verifiedBaseline 与页面 frontmatter 同源同写:evidence 是给人核的旁证,光有 claims // 没有「对着哪个提交」的,事后同样无从复核。 - const body = { slug, generatedAt, verifiedBaseline, claims }; + const body = { + slug, + generatedAt, + verifiedBaseline, + // evidence 是给人核的旁证:脏文件清单放在这里,复核的人才知道当时锚点是从哪棵树读的。 + ...(Array.isArray(verifiedDirtyFiles) ? { verifiedDirtyFiles } : {}), + claims, + }; writeFileSync(abs, `${JSON.stringify(body, null, 2)}\n`, "utf8"); } diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index b1f77992..de0d8cf4 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -697,7 +697,11 @@ CREATE TABLE IF NOT EXISTS choro_pages ( -- T0 重建整页时随 T1 正文一起归 NULL。全仓 git_baseline 是 T0 的时点,拿它当 claims -- 的比对起点会把「T1 之前就发生、claim 早已算进去」的改动误报成 touched。 verified_baseline TEXT, - verified_at TEXT + verified_at TEXT, + -- 验证那一刻工作区里哪些文件还没进提交(#648,JSON 数组)。claims 的行号取自磁盘 + -- 内容,这些文件的改动在 baseline..head 的 diff 里永远看不到,只能凭这份清单判 + -- 「无法用提交核对」。NULL = 不知道当时的工作区状态(本列之前富化的老数据)。 + verified_dirty_files TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_pages_module ON choro_pages(module_id); @@ -1386,6 +1390,9 @@ export function createStore(path) { // 存量行的 NULL 就是事实(那批页富化时还没记这个),不要伪造一个提交号。 addColumn("choro_pages", "verified_baseline", "ALTER TABLE choro_pages ADD COLUMN verified_baseline TEXT"); addColumn("choro_pages", "verified_at", "ALTER TABLE choro_pages ADD COLUMN verified_at TEXT"); + // 工作区脏文件清单(#648):同样是「不知道就留 NULL」,不给老行编一个空数组—— + // 空数组的意思是「当时工作区是干净的」,与 NULL 的「没记过」必须分开。 + addColumn("choro_pages", "verified_dirty_files", "ALTER TABLE choro_pages ADD COLUMN verified_dirty_files TEXT"); // chorography 收尾档(P36):v0.11.0 的 choro_vectors 是空壳表(spec §5 漏了 // 向量本体列),存量库经 PRAGMA 检查补列。 addColumn("choro_vectors", "vector", "ALTER TABLE choro_vectors ADD COLUMN vector TEXT"); @@ -5782,13 +5789,17 @@ export function createStore(path) { // verifiedBaseline 是 T1 那一刻的 HEAD:claims 的行号取自那个提交的代码,之后判失效 // 只能从它起算(传 null 表示拿不到 HEAD——那时 diff 判不了,面板要说「不知道」, // 而不是退回全仓 git_baseline 假装知道)。 - function markChoroPageEnriched(pageId, generatedAt, verifiedBaseline = null) { + function markChoroPageEnriched(pageId, generatedAt, verifiedBaseline = null, verifiedDirtyFiles = null) { + // 脏文件清单单独一列(#648):基线记的是提交,清单记的是「提交之外还有哪些文件 + // 在那一刻参与了 claim 抽取」。null(不知道/读不出)与 [](当时确实干净)严格分开, + // 面板据此才能说清这一页是用哪棵树验证的。 + const dirtyJson = Array.isArray(verifiedDirtyFiles) ? JSON.stringify(verifiedDirtyFiles) : null; db.prepare( `UPDATE choro_pages SET status = 'enriched', generated_at = ?, fail_count = 0, - verified_baseline = ?, verified_at = ? + verified_baseline = ?, verified_at = ?, verified_dirty_files = ? WHERE id = ?` - ).run(generatedAt, verifiedBaseline, generatedAt, pageId); + ).run(generatedAt, verifiedBaseline, generatedAt, dirtyJson, pageId); } // 单页 claims 整页替换(P20 的表侧):T1 一轮产出的 claims 删旧插新;evidence/ diff --git a/dsh-mneme/src/wiki/claims.js b/dsh-mneme/src/wiki/claims.js index d9b86353..8eb8cb1d 100644 --- a/dsh-mneme/src/wiki/claims.js +++ b/dsh-mneme/src/wiki/claims.js @@ -135,6 +135,10 @@ function normPath(p) { * 锚点要重新指。刻意与 touched 分开:混成一个「失效」,面板就会 * 对着一堆只是行号错位的结论喊「要重写」,喊几次就没人信了。 * - unverifiable 所属文件改过,可这条 claim 当初就没记行号,无法定位到具体一行。 + * - uncommitted 验证那一刻这个文件还在工作区里没提交(#648)。claim 的行号取自 + * 磁盘内容而不是任何提交,提交层面的 diff 从改动之后起算,永远看不到 + * 这段——只能老实说「无法用提交核对」,不能冒充「没变」。这一档由 + * summarizeClaims 依据页面记下的脏文件清单补,不在 diff 里算。 * * 输入 diff 为 { files: Map, deleted: string[] };输出与输入等长,未受影响的 * reason 为 null(调用方自行过滤)。 @@ -174,7 +178,7 @@ export function assessClaims(claims, diff) { * 仓库级汇总:逐条判定 → 「哪几页、几条、因为什么」。 * * claims 每项需带 pageSlug;pages 每项 { slug, title, hasT1, verifiedBaseline, - * verifiedAt, diffBaseline, baselineApprox, baselineUnknown, anchorStale }。 + * verifiedAt, verifiedDirtyFiles, diffBaseline, baselineApprox, baselineUnknown, anchorStale }。 * * 为什么 pages 要带验证基线、为什么还要 diffByPage:claims 的行号是**各自那页被 * 富化的那一刻**记下的。全仓只有一个 git_baseline(T0 的时点),拿它当所有 claims @@ -192,6 +196,22 @@ export function summarizeClaims(claims, diff, { pages = [], diffByPage = null } const assessed = diffByPage ? (claims ?? []).flatMap((claim) => assessClaims([claim], diffByPage(claim.pageSlug))) : assessClaims(claims, diff); + // 脏文件上的 claim 覆盖 diff 的判定(#648):diff 比对的是两个提交,而 claim 的行号 + // 取自验证时刻的磁盘。文件当时没进提交,diff 里就必然没有这段改动——这时 diff 说 + // 「没碰过」是假阴性,必须换成 uncommitted。没有这份清单的页(老数据)不覆盖。 + const dirtyBySlug = new Map(); + for (const page of pages) { + if (Array.isArray(page.verifiedDirtyFiles) && page.verifiedDirtyFiles.length > 0) { + dirtyBySlug.set(page.slug, new Set(page.verifiedDirtyFiles.map((p) => normPath(p)))); + } + } + const judged = dirtyBySlug.size === 0 + ? assessed + : assessed.map((claim) => { + const dirty = dirtyBySlug.get(claim.pageSlug); + if (dirty && dirty.has(normPath(claim.sourcePath))) return { ...claim, reason: "uncommitted", shift: 0 }; + return claim; + }); const bySlug = new Map(); for (const page of pages) { bySlug.set(page.slug, { @@ -200,6 +220,8 @@ export function summarizeClaims(claims, diff, { pages = [], diffByPage = null } hasT1: Boolean(page.hasT1), verifiedBaseline: page.verifiedBaseline ?? null, verifiedAt: page.verifiedAt ?? null, + // 原样透传:面板要说「这一页是在含未提交改动的状态下验证的」(#648)。 + verifiedDirtyFiles: Array.isArray(page.verifiedDirtyFiles) ? page.verifiedDirtyFiles : null, // 实际拿来跑 diff 的起点:新数据就是 verifiedBaseline;老数据(本功能之前富化) // 退到全仓基线,baselineApprox 标记「这不是当真的验证点,是保底起点」。 diffBaseline: page.diffBaseline ?? page.verifiedBaseline ?? null, @@ -211,7 +233,7 @@ export function summarizeClaims(claims, diff, { pages = [], diffByPage = null } inconsistent: false, }); } - for (const claim of assessed) { + for (const claim of judged) { const slug = claim.pageSlug; if (!bySlug.has(slug)) { bySlug.set(slug, { slug, title: claim.pageTitle ?? slug, hasT1: false, claims: 0, atRisk: [], inconsistent: false }); @@ -259,6 +281,7 @@ export function summarizeClaims(claims, diff, { pages = [], diffByPage = null } shifted: count("shifted"), deleted: count("deleted"), unverifiable: count("unverifiable"), + uncommitted: count("uncommitted"), inconsistentPages: list.filter((p) => p.inconsistent).length, baselineUnknownPages: list.filter((p) => p.baselineUnknown).length, anchorStalePages: list.filter((p) => p.anchorStale).length, diff --git a/dsh-mneme/src/wiki/queries.js b/dsh-mneme/src/wiki/queries.js index 415ad6e2..985bdde8 100644 --- a/dsh-mneme/src/wiki/queries.js +++ b/dsh-mneme/src/wiki/queries.js @@ -470,7 +470,7 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) ).all(repoId); // 页面清单单算:hasT1 只能读盘(标记写在正文里,库里没有这一列);验证基线在库里。 const pageRows = db.prepare( - `SELECT m.slug, m.title, p.rel_path, p.verified_baseline, p.verified_at + `SELECT m.slug, m.title, p.rel_path, p.verified_baseline, p.verified_at, p.verified_dirty_files FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id WHERE m.repo_id = ? ORDER BY m.slug` ).all(repoId); @@ -500,6 +500,9 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) } catch { hasT1 = false; } const verifiedBaseline = String(p.verified_baseline ?? "").trim() || null; const verifiedAt = p.verified_at ?? null; + // 验证那一刻工作区里还没进提交的文件(#648)。NULL / 读不出 = 不知道当时状态, + // 不是「当时干净」;只有 [] 才是明确的干净。调用方据此把「不知道」与「没动」分开。 + const verifiedDirtyFiles = parseDirtyFiles(p.verified_dirty_files); // 老数据(本功能之前富化的页)只有全仓基线可依。退到它是**保守**方向:它早于 // 那页被富化的时刻,diff 会把「T1 之前就发生」的改动也算进来,只会多报不会漏报。 // 记 baselineApprox 是为了面板能说清「这是退而求其次的起点」,不是当真的验证点。 @@ -517,6 +520,7 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) hasT1, verifiedBaseline, verifiedAt, + verifiedDirtyFiles, diffBaseline: effective, baselineApprox: !verifyWithoutBaseline && verifiedBaseline === null && effective !== null, baselineUnknown: effective === null, @@ -583,6 +587,19 @@ export function createChoroQueries({ store, hostId: hostIdFn = defaultHostId }) }; } + // 脏文件清单的读法:列里存 JSON 数组;NULL(老数据,不知道当时状态)与解析失败 + // 一律回 null——宁可说「不知道」,也不能回一个空数组冒充「当时很干净」(#648)。 + function parseDirtyFiles(raw) { + const text = String(raw ?? "").trim(); + if (text === "") return null; + try { + const parsed = JSON.parse(text); + return Array.isArray(parsed) ? parsed.map((v) => String(v)) : null; + } catch { + return null; + } + } + // --- chorography 向量(收尾档 P37–P39)--------------------------------- // 惰性物化(P37):只在 host 侧检索路径调用(embedder 由 index.js 注入); // CLI 环境没有 embedder → 向量永不物化,检索退关键词。staleness 两维: diff --git a/dsh-mneme/src/wiki/render.js b/dsh-mneme/src/wiki/render.js index 5affcf05..babec5b4 100644 --- a/dsh-mneme/src/wiki/render.js +++ b/dsh-mneme/src/wiki/render.js @@ -29,6 +29,10 @@ export const T1_SECTION_END = ""; // 同一件事,不新增语义。放在这里而不是 t1.js:frontmatter 的形状属于页面格式。 export const FM_VERIFIED_BASELINE = "verifiedBaseline"; export const FM_VERIFIED_AT = "verifiedAt"; +// ③ 工作区脏标记(#648):验证那一刻工作区有没有未提交改动。只写布尔值,具体清单 +// 落库(choro_pages.verified_dirty_files)与 evidence 旁证——页面文件是给人读的, +// 几十行路径会把 frontmatter 淹掉,而「这一页不是对着干净 HEAD 验的」一句话就够。 +export const FM_VERIFIED_DIRTY = "verifiedDirty"; // 页面生命周期档(#582):status 也是 frontmatter 键,但它 T0 / T1 都写——T0 // 起骨架时写 skeleton,T1 成功时随 verified 两键一起推进 enriched。定义在这 diff --git a/dsh-mneme/src/wiki/t0.js b/dsh-mneme/src/wiki/t0.js index 307653a8..a78e10ab 100644 --- a/dsh-mneme/src/wiki/t0.js +++ b/dsh-mneme/src/wiki/t0.js @@ -346,6 +346,57 @@ export function gitHead(repoRoot) { } } +/** + * 验证那一刻工作区里有哪些**已被 git 跟踪或未跟踪但未被忽略**的文件是脏的(#648)。 + * + * 为什么需要它:claims 的行号取自**磁盘上的内容**,而 HEAD 只描述上一次提交。工作区 + * 有未提交改动时,这两者不是同一个状态——改动被提交后 `diffSince(HEAD, 新HEAD)` 从 + * 改动之后起算,这一段永远看不到,claim 会被误判成没被动过。所以富化时必须把「当时 + * 哪些文件不在提交里」记下来,判定时这些文件上的 claim 只能判「无法用提交核对」, + * 不能冒充「没变」。 + * + * 为什么不用写一个 tree 对象:`git write-tree` / stash 式 ref 会在仓库里留下悬空对象 + * 或额外 ref,默认 2 周后被 gc 掉;靠它当基线等于把可解释性押在对象存活上。文件清单 + * 是纯文本、随页面一起落库,重装/换机都还在。 + * + * `--porcelain -z` 天然不含 .gitignore 命中的产物(除非它们本来就已被跟踪,那时它们 + * 的改动确实会影响 claim,该算脏)。`--untracked-files=all` 让未跟踪目录展开成文件, + * 否则整个目录只报一行、里面具体哪个文件脏就丢了。 + * + * 返回排好序的仓库相对路径数组(正斜杠);读不出来返回 null —— null 是「不知道」, + * 不是「干净」,调用方不得把它当空数组用。 + */ +export function gitDirtyFiles(repoRoot) { + try { + const out = execFileSync( + "git", + ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] } + ); + const records = String(out).split("\0"); + const paths = []; + const norm = (p) => String(p).replace(/\\/g, "/"); + for (let i = 0; i < records.length; i += 1) { + const record = records[i]; + if (record.length < 4 || record[2] !== " ") continue; + const xy = record.slice(0, 2); + paths.push(norm(record.slice(3))); + // rename/copy 在 -z 里把旧路径作为下一条独立记录(不带状态前缀)跟出来: + // 旧路径上的 claim 同样失去了提交层面的可比性,一起记。 + if (xy.includes("R") || xy.includes("C")) { + const from = records[i + 1]; + if (from) { + paths.push(norm(from)); + i += 1; + } + } + } + return [...new Set(paths)].sort(); + } catch { + return null; + } +} + function readState(repoRoot) { try { const raw = readFileSync(join(repoRoot, CHORO_DIR, "state.json"), "utf8"); diff --git a/dsh-mneme/src/wiki/t1.js b/dsh-mneme/src/wiki/t1.js index 2bca7d9c..b95d72b6 100644 --- a/dsh-mneme/src/wiki/t1.js +++ b/dsh-mneme/src/wiki/t1.js @@ -25,9 +25,9 @@ import { tmpdir } from "node:os"; import { realpathSync } from "node:fs"; import { extractHumanBlocks, injectHumanBlocks, T1_SECTION_START, T1_SECTION_END, - FM_VERIFIED_AT, FM_VERIFIED_BASELINE, FM_STATUS, setFrontmatterKeys, + FM_VERIFIED_AT, FM_VERIFIED_BASELINE, FM_VERIFIED_DIRTY, FM_STATUS, setFrontmatterKeys, } from "./render.js"; -import { gitHead } from "./t0.js"; +import { gitHead, gitDirtyFiles } from "./t0.js"; import { portableKeyFor, hostId } from "./portable.js"; // issue #607:拒绝文案里的 CLI 命令要给绝对路径入口(profile 装的插件没有全局 // shim,光写 `dsh-mneme-choro` 用户照抄只会 command not found)。 @@ -456,13 +456,30 @@ export function createT1({ store, runSession = null, limits = DEFAULT_LIMITS, no // 的,之后判失效只能从它起算。拿不到 HEAD(不是 git 仓库 / git 不在)就记 null, // 面板据此说「这一页哪次验证的,不知道」——不退回全仓 git_baseline,那会把 // 「T1 之前就发生、claim 早已算进去」的改动误报成 touched。 + // claims 的行号取自磁盘内容;HEAD 只描述上一次提交。工作区脏时这两者不是同一棵树, + // 必须把「当时哪些文件还没进提交」一起记下(#648)——否则改动被提交后,diff 从改动 + // 之后起算,这段判定不到的窗口永远不会自愈。 const generatedAt = now(); const verifiedBaseline = gitHead(rootPath); - enrichPageFile(pageFile, parsed.sections, { verifiedBaseline, verifiedAt: generatedAt }); - writeEvidenceFile(rootPath, slug, parsed.claims, generatedAt, verifiedBaseline); + // .chorography/** 是工具自己的产地:这一轮写盘本身就会让它变脏,把它算进清单 + // 只会让每一页都「脏」,而 claims 永远不会指向这里。滤掉才是真的「源码那棵树脏 + // 不脏」。 + const dirty = gitDirtyFiles(rootPath); + const verifiedDirtyFiles = Array.isArray(dirty) + ? dirty.filter((p) => !String(p).startsWith(".chorography/")) + : null; + enrichPageFile(pageFile, parsed.sections, { verifiedBaseline, verifiedAt: generatedAt, verifiedDirtyFiles }); + writeEvidenceFile(rootPath, slug, parsed.claims, generatedAt, verifiedBaseline, verifiedDirtyFiles); store.saveChoroPageClaims(pageRow.id, parsed.claims.map((c) => ({ ...c, createdAt: generatedAt }))); - store.markChoroPageEnriched(pageRow.id, generatedAt, verifiedBaseline); - receipt.enriched.push({ slug, sections: Object.keys(parsed.sections), claims: parsed.claims.length, verifiedBaseline }); + store.markChoroPageEnriched(pageRow.id, generatedAt, verifiedBaseline, verifiedDirtyFiles); + receipt.enriched.push({ + slug, + sections: Object.keys(parsed.sections), + claims: parsed.claims.length, + verifiedBaseline, + // 回执里给条数就够:人要知道的是「这一页不是对着干净 HEAD 验的」,不是逐条路径。 + ...(Array.isArray(verifiedDirtyFiles) ? { verifiedDirtyFiles: verifiedDirtyFiles.length } : {}), + }); } return receipt; } @@ -546,7 +563,7 @@ function parseT1Output(text) { // 顺带把「对着哪个提交验证的」写进 frontmatter(① provenance):这两个键**只有 T1 // 写**,T0 渲染一个字节都不产出。因此 T0 重跑整页重建时它们随语义段一起消失, // 与「T0 丢弃 T1 正文」是同一件事,不新增语义,也不碰 T0 的逐字节确定性。 -function enrichPageFile(pageFile, sections, { verifiedBaseline = null, verifiedAt = null } = {}) { +function enrichPageFile(pageFile, sections, { verifiedBaseline = null, verifiedAt = null, verifiedDirtyFiles = null } = {}) { let content = readFileSync(pageFile, "utf8"); // 剥旧 T1 段(含标记本身) const wrap = new RegExp(`${escapeRe(T1_SECTION_START)}[\\s\\S]*?${escapeRe(T1_SECTION_END)}\\n?`, "g"); @@ -584,6 +601,9 @@ function enrichPageFile(pageFile, sections, { verifiedBaseline = null, verifiedA const finalContent = setFrontmatterKeys(withHuman, [ [FM_VERIFIED_BASELINE, verifiedBaseline], [FM_VERIFIED_AT, verifiedAt], + // 布尔标记而不是清单:页面文件给人读,清单在库里与 evidence 里。「读不出工作区 + // 状态」(null)不写这个键——不知道就不写,别留一个 false 冒充「当时很干净」。 + [FM_VERIFIED_DIRTY, Array.isArray(verifiedDirtyFiles) ? verifiedDirtyFiles.length > 0 : null], // 文件里的 status 与库里的 choro_pages.status 同步推进(#582):此前 T1 // 成功只改库(markChoroPageEnriched),文件那行永远停在 T0 写的 skeleton // ——页面文件自身与数据库状态不一致,看文件的人会以为这页从没富化过。 @@ -601,13 +621,20 @@ function stripHumanBlocks(text) { return text.replace(re, ""); } -function writeEvidenceFile(repoRoot, slug, claims, generatedAt, verifiedBaseline = null) { +function writeEvidenceFile(repoRoot, slug, claims, generatedAt, verifiedBaseline = null, verifiedDirtyFiles = null) { const rel = `${".chorography"}/evidence/${slug}.json`; const abs = insideChoro(repoRoot, rel); mkdirp(dirname(abs)); // verifiedBaseline 与页面 frontmatter 同源同写:evidence 是给人核的旁证,光有 claims // 没有「对着哪个提交」的,事后同样无从复核。 - const body = { slug, generatedAt, verifiedBaseline, claims }; + const body = { + slug, + generatedAt, + verifiedBaseline, + // evidence 是给人核的旁证:脏文件清单放在这里,复核的人才知道当时锚点是从哪棵树读的。 + ...(Array.isArray(verifiedDirtyFiles) ? { verifiedDirtyFiles } : {}), + claims, + }; writeFileSync(abs, `${JSON.stringify(body, null, 2)}\n`, "utf8"); } diff --git a/dsh-mneme/test/wiki-provenance.test.js b/dsh-mneme/test/wiki-provenance.test.js index d87bdc83..519348fe 100644 --- a/dsh-mneme/test/wiki-provenance.test.js +++ b/dsh-mneme/test/wiki-provenance.test.js @@ -19,6 +19,7 @@ import { createT1 } from "../src/wiki/t1.js"; import { FM_VERIFIED_AT, FM_VERIFIED_BASELINE, + FM_VERIFIED_DIRTY, readFrontmatterKey, setFrontmatterKeys, T1_SECTION_START, @@ -200,3 +201,130 @@ test("claimDrift:老数据(没有逐页基线)退到全仓基线,并标 rmTempBestEffort(root); } }); + +// --- #648:基线只有 HEAD,工作区脏时 claim 的行号来自一棵没进提交的树 ----------- + +test("T1 在脏工作区富化:记下脏文件清单,脏文件上的 claim 判 uncommitted(#648)", async () => { + const root = makeFixture(); + const store = createStore(":memory:"); + try { + runChoroT0({ repoRoot: root, store, apply: true, now: FIXED_NOW }); + // 未提交地改 a.js:GOOD_OUTPUT 的 claim 锚点(第 1 行)就落在这份磁盘内容上。 + writeFileSync(join(root, "src", "a.js"), 'import { b } from "./b.js";\nexport const a = () => 7;\n', "utf8"); + const head = gitHead(root); + const t1 = createT1({ store, runSession: async () => ({ ok: true, text: GOOD_OUTPUT }), now: FIXED_NOW }); + const receipt = await t1.runT1({ repoRoot: root, moduleSlug: "src" }); + assert.equal(receipt.enriched[0].verifiedBaseline, head, "基线仍记 HEAD:claims 的行号确实从这棵树上读的"); + assert.equal(receipt.enriched[0].verifiedDirtyFiles, 1, "回执报脏文件条数,人不用翻库"); + + const page = readFileSync(join(root, ".chorography", "modules", "src.md"), "utf8"); + assert.equal(readFrontmatterKey(page, FM_VERIFIED_DIRTY), "true", "页面文件写明这次验证不在干净 HEAD 上"); + assert.equal(readFrontmatterKey(page, FM_VERIFIED_BASELINE), head); + + const row = store.db.prepare( + "SELECT p.verified_dirty_files FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id WHERE m.slug = 'src'" + ).get(); + assert.deepEqual(JSON.parse(row.verified_dirty_files), ["src/a.js"], "清单落库(不是只写在回执里)"); + + const evidence = JSON.parse(readFileSync(join(root, ".chorography", "evidence", "src.json"), "utf8")); + assert.deepEqual(evidence.verifiedDirtyFiles, ["src/a.js"], "旁证同步:复核的人才知道锚点是从哪棵树读的"); + + const q = createChoroQueries({ store }); + const repo = q.resolveRepo(undefined); + const drift = q.claimDrift(repo.id); + assert.equal(drift.totals.claims, 1); + assert.equal(drift.totals.uncommitted, 1, "diff 会说没事,但这段改动不在任何提交里"); + assert.equal(drift.totals.atRiskClaims, 1); + assert.equal(drift.pages.find((p) => p.slug === "src").atRisk[0].reason, "uncommitted"); + assert.equal(drift.clean, false, "判不出来就不能说干净"); + + // 提交之后不会自愈:改动发生时基线已指向提交前的树,这段永远补不回来。 + commit(root, "把那次未提交改动提交掉"); + const drift2 = q.claimDrift(repo.id); + assert.equal(drift2.pages.find((p) => p.slug === "src").atRisk[0].reason, "uncommitted", "提交后仍是同一档"); + } finally { + store.close(); + rmTempBestEffort(root); + } +}); + +test("只有被 claim 引用的脏文件才判 uncommitted,其它脏文件不牵连整页(#648)", async () => { + const root = makeFixture(); + const store = createStore(":memory:"); + try { + runChoroT0({ repoRoot: root, store, apply: true, now: FIXED_NOW }); + // b.js 未提交地改过,但 GOOD_OUTPUT 只 claim 了 a.js:整页降级会让一个无关脏文件 + // 杀掉全部漂移检测,所以只有被引用的文件才算「无法用提交核对」。 + writeFileSync(join(root, "src", "b.js"), "export const b = () => 9;\n", "utf8"); + const t1 = createT1({ store, runSession: async () => ({ ok: true, text: GOOD_OUTPUT }), now: FIXED_NOW }); + await t1.runT1({ repoRoot: root, moduleSlug: "src" }); + + // 之后真的提交一次碰了 a.js 锚点第 1 行的改动:这一条必须按 diff 判 touched。 + writeFileSync(join(root, "src", "a.js"), 'export const a = () => 3;\n', "utf8"); + commit(root, "碰了 a.js 的锚点"); + + const q = createChoroQueries({ store }); + const repo = q.resolveRepo(undefined); + const drift = q.claimDrift(repo.id); + assert.equal(drift.totals.uncommitted, 0, "b.js 脏但没被 claim 引用,不该牵连"); + assert.equal(drift.pages.find((p) => p.slug === "src").atRisk[0].reason, "touched", "干净文件的判定照旧精确"); + } finally { + store.close(); + rmTempBestEffort(root); + } +}); + +test("老数据没有脏文件清单:不判 uncommitted,页上明说「不知道当时工作区状态」(#648)", () => { + const root = makeFixture(); + const store = createStore(":memory:"); + try { + runChoroT0({ repoRoot: root, store, apply: true, now: FIXED_NOW }); + const q = createChoroQueries({ store }); + const repo = q.resolveRepo(undefined); + // 本列之前富化的页:有 claims、有基线,但没有脏文件清单。 + const page = store.choroPageBySlug(repo.id, "src"); + store.saveChoroPageClaims(page.id, [ + { statement: "a 依赖 b", kind: "architecture", sourcePath: "src/a.js", startLine: 2, endLine: 2, createdAt: FIXED_NOW() }, + ]); + store.db.prepare( + "UPDATE choro_pages SET verified_baseline = ?, verified_at = ?, verified_dirty_files = NULL WHERE id = ?" + ).run(gitHead(root), FIXED_NOW(), page.id); + + const drift = q.claimDrift(repo.id); + const pageDrift = drift.pages.find((p) => p.slug === "src"); + assert.equal(pageDrift.verifiedDirtyFiles, null, "NULL = 不知道,不是「当时干净」"); + assert.equal(drift.totals.uncommitted, 0, "没有清单就不编造 uncommitted"); + assert.equal(drift.totals.atRiskClaims, 0, "没被 diff 碰到就是没事"); + + // 对照:清单存在且为空 → 明确的「当时干净」,与 NULL 不是一回事。 + store.db.prepare("UPDATE choro_pages SET verified_dirty_files = '[]' WHERE id = ?").run(page.id); + const drift2 = q.claimDrift(repo.id); + assert.deepEqual(drift2.pages.find((p) => p.slug === "src").verifiedDirtyFiles, [], "空数组是明确答案"); + } finally { + store.close(); + rmTempBestEffort(root); + } +}); + +test("老库缺 verified_dirty_files 列:打开时幂等补上,老行留 NULL(#648)", () => { + const dir = mkdtempSync(join(tmpdir(), "choro-mig-")); + const dbPath = join(dir, "m.db"); + try { + const s1 = createStore(dbPath); + s1.db.exec("ALTER TABLE choro_pages DROP COLUMN verified_dirty_files"); + assert.equal(s1.db.prepare("PRAGMA table_info(choro_pages)").all().some((c) => c.name === "verified_dirty_files"), false); + s1.close(); + + const s2 = createStore(dbPath); + const cols2 = s2.db.prepare("PRAGMA table_info(choro_pages)").all().map((c) => c.name); + assert.ok(cols2.includes("verified_dirty_files"), "老库重开补列"); + s2.close(); + + const s3 = createStore(dbPath); + const cols3 = s3.db.prepare("PRAGMA table_info(choro_pages)").all().map((c) => c.name); + assert.equal(cols3.filter((c) => c === "verified_dirty_files").length, 1, "幂等:不会重复加列"); + s3.close(); + } finally { + rmTempBestEffort(dir); + } +});