@modusensus/dsh-mneme 0.13.0 → 0.14.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/bin/dsh-mneme-choro.mjs +9 -1
- package/docs/CONFIGURATION.md +5 -3
- package/lib/api.js +211 -5
- package/lib/client.js +1240 -354
- package/lib/config.js +27 -0
- package/lib/dream/decisions.js +39 -1
- package/lib/dream/sleep.js +146 -6
- package/lib/dream/triage.js +139 -0
- package/lib/dream.js +123 -8
- package/lib/hot-memory.js +14 -2
- package/lib/index.js +31 -1
- package/lib/inject.js +14 -2
- package/lib/lang.js +11 -0
- package/lib/service.js +76 -4
- package/lib/settings.js +16 -1
- package/lib/store.js +238 -5
- package/lib/wiki/claims.js +267 -0
- package/lib/wiki/queries.js +168 -2
- package/lib/wiki/render.js +74 -0
- package/lib/wiki/t0.js +43 -6
- package/lib/wiki/t1.js +138 -31
- package/package.json +15 -1
- package/scripts/release-docs.mjs +7 -1
- package/scripts/release-notes.mjs +4 -1
- package/src/api.js +211 -5
- package/src/config.js +27 -0
- package/src/dream/decisions.js +39 -1
- package/src/dream/sleep.js +146 -6
- package/src/dream/triage.js +139 -0
- package/src/dream.js +123 -8
- package/src/hot-memory.js +14 -2
- package/src/index.js +31 -1
- package/src/inject.js +14 -2
- package/src/lang.js +11 -0
- package/src/service.js +76 -4
- package/src/settings.js +16 -1
- package/src/store.js +238 -5
- package/src/wiki/claims.js +267 -0
- package/src/wiki/queries.js +168 -2
- package/src/wiki/render.js +74 -0
- package/src/wiki/t0.js +43 -6
- package/src/wiki/t1.js +138 -31
- package/test/api.test.js +11 -1
- package/test/client.test.js +275 -34
- package/test/conflict-ledger-enforce.test.js +452 -0
- package/test/conflict-ledger.test.js +242 -0
- package/test/contrast.test.js +124 -12
- package/test/dream-baseline-clamp.test.js +174 -0
- package/test/graph-api.test.js +124 -0
- package/test/hot-memory.test.js +48 -0
- package/test/inject-parent-gate.test.js +6 -3
- package/test/inject.test.js +59 -0
- package/test/panel-chart-scale.test.js +90 -0
- package/test/panel-tokens.test.js +245 -0
- package/test/release-docs.test.js +20 -0
- package/test/release-notes.test.js +25 -0
- package/test/security-doc.test.js +10 -0
- package/test/sleep.test.js +37 -0
- package/test/wiki-api.test.js +110 -3
- package/test/wiki-claims.test.js +194 -0
- package/test/wiki-provenance.test.js +202 -0
- package/test/wiki-queries.test.js +121 -0
- package/test/wiki-t0.test.js +77 -1
- package/test/wiki-t1.test.js +226 -1
|
@@ -67,6 +67,19 @@ test("findSection:版本号里的点被转义过,不会当成通配", () =>
|
|
|
67
67
|
assert.equal(findSection(text, "0.8.16"), null, "点若被当通配就会误命中 0x8x16");
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
test("findSection:预发布版本号(带 -beta.N)能取到自己的小节", () => {
|
|
71
|
+
// 预发布版本号带 `-` 与额外的点。此前入口正则只认 x.y.z,release job 抽正文这步直接
|
|
72
|
+
// exit 1,预发布版根本发不出去;findSection 也只转义了点,元字符会被当语义用。
|
|
73
|
+
const text = SAMPLE.replace(
|
|
74
|
+
"## [0.8.16] - 2026-10-12",
|
|
75
|
+
"## [0.14.0-beta.1] - 2026-11-11\n\n- 预发布条目\n\n## [0.8.16] - 2026-10-12"
|
|
76
|
+
);
|
|
77
|
+
const sec = findSection(text, "0.14.0-beta.1");
|
|
78
|
+
assert.ok(sec.includes("预发布条目"), "预发布小节要能取到");
|
|
79
|
+
assert.equal(sec.includes("旧条目"), false, "不能越过下一版标题");
|
|
80
|
+
assert.equal(findSection(text, "0.14.0"), null, "0.14.0 不该误命中 0.14.0-beta.1(反之亦然)");
|
|
81
|
+
});
|
|
82
|
+
|
|
70
83
|
test("parseBullet:拆出标题与正文;非该形状返回 null 由调用方原样保留", () => {
|
|
71
84
|
assert.deepEqual(parseBullet("- **标题**:正文在这里"), { title: "标题", body: "正文在这里" });
|
|
72
85
|
assert.deepEqual(parseBullet("- **只有标题**"), { title: "只有标题", body: "" });
|
|
@@ -238,3 +251,15 @@ test("入口:版本号格式不对时直接拒绝", () => {
|
|
|
238
251
|
assert.notEqual(res.status, 0, "只接受 x.y.z");
|
|
239
252
|
assert.match(res.stderr, /usage: release-notes\.mjs/, "要说清用法,别静默退出");
|
|
240
253
|
});
|
|
254
|
+
|
|
255
|
+
test("入口:预发布版本号不被当成格式错误(否则 beta 版发不出去)", () => {
|
|
256
|
+
// 反向验证上面那条守卫没有收紧过头:0.14.0-beta.1 是合法发布版本,此前会被
|
|
257
|
+
// 「格式不对」拦下。这里走到 findSection,报的必须是「找不到小节」而不是"usage"。
|
|
258
|
+
const root = mkdtempSync(join(tmpdir(), "mneme-release-notes-"));
|
|
259
|
+
mkdirSync(join(root, "dsh-mneme"), { recursive: true });
|
|
260
|
+
writeFileSync(join(root, "dsh-mneme", "CHANGELOG.md"), SAMPLE, "utf8");
|
|
261
|
+
const res = spawnSync(process.execPath, [scriptPath, "0.14.0-beta.1"], { cwd: root, encoding: "utf8" });
|
|
262
|
+
assert.notEqual(res.status, 0, "样例 CHANGELOG 里没有这一版,应当非零退出");
|
|
263
|
+
assert.doesNotMatch(res.stderr, /usage: release-notes\.mjs/, "不许当成格式错误:" + res.stderr);
|
|
264
|
+
assert.match(res.stderr, /changelog section not found: 0\.14\.0-beta\.1/, "要报找不到小节");
|
|
265
|
+
});
|
|
@@ -34,12 +34,22 @@ function section(heading) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
test("SECURITY.md 的支持版本表跟着 package.json 走(中英各一份)", () => {
|
|
37
|
+
// 预发布版(0.14.0-beta.1)不占支持表的格:beta 不是受支持的稳定版本,把首行挪成
|
|
38
|
+
// 0.14.x 等于对外宣称 0.14 已在活跃维护。这里改成「首行必须不是 beta 的 minor」——
|
|
39
|
+
// 仍是断言,不是跳过:真有人给 beta 挪了格,这条会红。
|
|
40
|
+
const isPrerelease = version.includes("-");
|
|
37
41
|
for (const heading of ["## Supported Versions", "## 支持版本"]) {
|
|
38
42
|
const body = section(heading);
|
|
39
43
|
// 表第一行就是「当前支持」的那一行;锁它,别锁整表(历史版本行本来就该在)。
|
|
40
44
|
const rows = body.split(/\r?\n/).filter((l) => /^\|\s*[\d<]/.test(l));
|
|
41
45
|
assert.ok(rows.length >= 2, `${heading} 的版本表应至少两行,实际 ${rows.length} 行`);
|
|
42
46
|
const current = rows[0].split("|")[1].trim();
|
|
47
|
+
if (isPrerelease) {
|
|
48
|
+
assert.notEqual(current, expectedVersionRow,
|
|
49
|
+
`${heading} 的首行不该是 ${expectedVersionRow}(package.json 为预发布版 ${version})——`
|
|
50
|
+
+ "预发布版占格等于宣称它已在活跃维护");
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
43
53
|
assert.equal(current, expectedVersionRow,
|
|
44
54
|
`${heading} 的首行应写 ${expectedVersionRow}(package.json 为 ${version}),实际写了 ${current}`);
|
|
45
55
|
}
|
package/test/sleep.test.js
CHANGED
|
@@ -1416,3 +1416,40 @@ test("sleep: trait candidates pushed out by the cap are recorded as dropped", as
|
|
|
1416
1416
|
assert.ok(capped.every((d) => typeof d.target === "string" && d.target), "每条都要能看出是哪条结论被截");
|
|
1417
1417
|
store.close();
|
|
1418
1418
|
});
|
|
1419
|
+
|
|
1420
|
+
test("sleep: an affective stream failure keeps its cause on the phase", async () => {
|
|
1421
|
+
// #619 可见化 A。这条与 conflict 相位那条同源:只写 "llm failed" 的话,
|
|
1422
|
+
// 事后看 dream_runs 完全分不清是上下文超限、模型拒收、还是上游 502。
|
|
1423
|
+
// 实测出处 2026-11-10T16:09 那一轮:outcome.affective 只有
|
|
1424
|
+
// {"status":"failed","error":"llm failed"},真正的 502 原文只能去
|
|
1425
|
+
// llm_audit_logs 里捞,而那张表不是给人看面板用的。
|
|
1426
|
+
// 所以这里钉两件事:失败原因必须写进返回值的 failure;同时必须打 warn。
|
|
1427
|
+
const { service, store } = setup();
|
|
1428
|
+
service.createEntity({ name: "桉桉", type: "person" });
|
|
1429
|
+
service.createEntity({ name: "答辩", type: "concept" });
|
|
1430
|
+
const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
|
|
1431
|
+
linkSession(service, mem.id, "session-A");
|
|
1432
|
+
|
|
1433
|
+
const warnings = [];
|
|
1434
|
+
const ctx = affectiveCtx([]);
|
|
1435
|
+
ctx.logger = { warn: (line) => warnings.push(String(line)), info: () => {} };
|
|
1436
|
+
// 只让 affective 这一路失败,别的相位照常回 "[]"——否则测的是整轮,不是这一路。
|
|
1437
|
+
ctx.llm.stream = async function* (options) {
|
|
1438
|
+
if (options.purpose === "sleep-affective") {
|
|
1439
|
+
yield { type: "finish", reason: { kind: "error", failure: { code: "SERVER", message: "502: upstream fetch failed" } } };
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
yield { type: "text-delta", index: 0, text: "[]" };
|
|
1443
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
|
|
1447
|
+
const phase = result.phases.affective;
|
|
1448
|
+
assert.equal(phase.status, "failed");
|
|
1449
|
+
assert.equal(phase.error, "llm failed", "粗粒度分类保持不变,面板与审计都依赖它");
|
|
1450
|
+
assert.ok(String(phase.failure ?? "").includes("502"),
|
|
1451
|
+
`失败原因要带上游原文,实际拿到 ${JSON.stringify(phase.failure)}`);
|
|
1452
|
+
assert.ok(warnings.some((w) => w.includes("affective stream aborted or errored")),
|
|
1453
|
+
"同一份原因也要进日志,光靠返回值救不了已经过去的运行");
|
|
1454
|
+
store.close();
|
|
1455
|
+
});
|
package/test/wiki-api.test.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
7
|
import { EventEmitter } from "node:events";
|
|
8
|
-
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { execFileSync } from "node:child_process";
|
|
@@ -493,10 +493,12 @@ function countingChoro(store, { throwOnRun = false } = {}) {
|
|
|
493
493
|
calls,
|
|
494
494
|
choro: {
|
|
495
495
|
...base,
|
|
496
|
-
|
|
496
|
+
// guardT1Loss 必须原样透给真 T0:这条守卫的意义就在于「自动刷新那条路会拒绝」,
|
|
497
|
+
// 外壳把它吞掉就等于测了一个不存在的实现。
|
|
498
|
+
runT0Apply: ({ repoRoot, guardT1Loss = false }) => {
|
|
497
499
|
calls.push(repoRoot);
|
|
498
500
|
if (throwOnRun) throw new Error("磁盘只读(模拟)");
|
|
499
|
-
return runChoroT0({ repoRoot, store, apply: true });
|
|
501
|
+
return runChoroT0({ repoRoot, store, apply: true, guardT1Loss });
|
|
500
502
|
}
|
|
501
503
|
}
|
|
502
504
|
};
|
|
@@ -597,6 +599,71 @@ test("/choro/repos 自动重跑失败:读照常 200、数据在、错误进 au
|
|
|
597
599
|
}
|
|
598
600
|
});
|
|
599
601
|
|
|
602
|
+
// 自动刷新碰上已富化的仓库:不许动手。这条是「静默降级」的正对面——读页本来
|
|
603
|
+
// 只是个浏览动作,却会替用户把语义段和 claims 一起清掉,而屏幕上没有任何提示。
|
|
604
|
+
// 现在它变成一次主动拒绝,且拒绝与失败分开报(autoRefreshSkipped vs autoRefreshError)。
|
|
605
|
+
test("/choro/repos 自动重跑:仓库里有富化页时主动跳过,不动字节、不说成失败", async () => {
|
|
606
|
+
const store = createStore(":memory:");
|
|
607
|
+
const root = driftRepo(store);
|
|
608
|
+
try {
|
|
609
|
+
const repo = store.choroRepoByRoot(root);
|
|
610
|
+
const page = store.choroPageBySlug(repo.id, "src");
|
|
611
|
+
store.markChoroPageEnriched(page.id, "2026-11-01T00:00:00.000Z", null);
|
|
612
|
+
const pagePath = join(root, page.rel_path);
|
|
613
|
+
writeFileSync(pagePath, `${readFileSync(pagePath, "utf8")}\n<!-- CHORO:T1:START -->\n## 架构\n\na.js 是入口。\n<!-- CHORO:T1:END -->\n`, "utf8");
|
|
614
|
+
const before = readFileSync(pagePath, "utf8");
|
|
615
|
+
|
|
616
|
+
const { calls, choro } = countingChoro(store);
|
|
617
|
+
const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
|
|
618
|
+
const REPOS_PATH = "/api/dsh-mneme/choro/repos";
|
|
619
|
+
const res = await invoke(handler(routes, REPOS_PATH), `${REPOS_PATH}?repo=1`);
|
|
620
|
+
|
|
621
|
+
assert.equal(res.status, 200);
|
|
622
|
+
assert.equal(calls.length, 0, "有富化页时连 T0 都不该被叫起来");
|
|
623
|
+
assert.equal(res.body.autoRefreshed, false);
|
|
624
|
+
assert.equal(res.body.autoRefreshSkipped, "t1-loss", "跳过要和失败分开报");
|
|
625
|
+
assert.equal(res.body.autoRefreshError, undefined, "主动拒绝不是错误");
|
|
626
|
+
assert.match(res.body.autoRefreshSkipNote, /1 页/, "说清为什么没做");
|
|
627
|
+
assert.equal(readFileSync(pagePath, "utf8"), before, "页一字节未动");
|
|
628
|
+
assert.equal(res.body.repos[0].staleReason, "git", "没刷就照实说过期");
|
|
629
|
+
assert.equal(res.body.repos[0].enrichedPages, 1, "面板要靠这个数解释原因");
|
|
630
|
+
} finally {
|
|
631
|
+
store.close();
|
|
632
|
+
rmTempBestEffort(root);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// 库里的账和磁盘对不上时(status 还是 skeleton、页里却有 T1 段),T0 的读盘判断
|
|
637
|
+
// 会抛 would-drop-t1。到这一层仍按「主动跳过」回,绝不报成刷新失败。
|
|
638
|
+
test("/choro/page 自动重跑:T0 拒绝(would-drop-t1)时回 skipped 而非 error", async () => {
|
|
639
|
+
const store = createStore(":memory:");
|
|
640
|
+
const root = driftRepo(store);
|
|
641
|
+
try {
|
|
642
|
+
const repo = store.choroRepoByRoot(root);
|
|
643
|
+
const page = store.choroPageBySlug(repo.id, "src");
|
|
644
|
+
const pagePath = join(root, page.rel_path);
|
|
645
|
+
// 故意不改库里的 status:模拟「有人手改了 MD」这类账实不符。
|
|
646
|
+
writeFileSync(pagePath, `${readFileSync(pagePath, "utf8")}\n<!-- CHORO:T1:START -->\n## 架构\n\na.js 是入口。\n<!-- CHORO:T1:END -->\n`, "utf8");
|
|
647
|
+
const before = readFileSync(pagePath, "utf8");
|
|
648
|
+
|
|
649
|
+
const { choro } = countingChoro(store);
|
|
650
|
+
const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
|
|
651
|
+
const PAGE_PATH = "/api/dsh-mneme/choro/page";
|
|
652
|
+
const res = await invoke(handler(routes, PAGE_PATH), `${PAGE_PATH}?repo=1&slug=src`);
|
|
653
|
+
|
|
654
|
+
assert.equal(res.status, 200);
|
|
655
|
+
assert.equal(res.body.autoRefreshed, false);
|
|
656
|
+
assert.equal(res.body.autoRefreshSkipped, "t1-loss");
|
|
657
|
+
assert.equal(res.body.autoRefreshError, undefined);
|
|
658
|
+
assert.match(res.body.autoRefreshSkipNote, /富化/, "T0 那句人话原样透出去");
|
|
659
|
+
assert.equal(readFileSync(pagePath, "utf8"), before, "拒绝发生在写盘之前");
|
|
660
|
+
assert.ok(res.body.page.content.includes("CHORO:T1:START"), "页正文照常回,别把读也带坏");
|
|
661
|
+
} finally {
|
|
662
|
+
store.close();
|
|
663
|
+
rmTempBestEffort(root);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
600
667
|
// /choro/page 是面板主路径,也是「读地志页时发现代码变了」那句文案的真正时刻。
|
|
601
668
|
// 顶栏「代码已变」徽章读 state.repo.stale —— 回执里没有 repo 这份数据,徽章永远不亮。
|
|
602
669
|
test("/choro/page 回执带 repo.stale/staleReason,并在这条读路径上自动重跑", async () => {
|
|
@@ -625,4 +692,44 @@ test("/choro/page 回执带 repo.stale/staleReason,并在这条读路径上自
|
|
|
625
692
|
} finally {
|
|
626
693
|
rmTempBestEffort(root);
|
|
627
694
|
}
|
|
695
|
+
});
|
|
696
|
+
// --- /choro/claims(claim 粒度失效,Grounded Claims)-----------------------
|
|
697
|
+
// 锁的是一条态度:算不出来 ≠ 没事。基线认不出时必须 anchorStale,不许回一张空清单
|
|
698
|
+
// 让面板显示「全部结论都还准」——那是 #607 同类的假成功。
|
|
699
|
+
|
|
700
|
+
test("/choro/claims 在册且 policy open,四层闸与其余读路由一致", async () => {
|
|
701
|
+
const off = setup({});
|
|
702
|
+
const route = handler(off.routes, "/api/dsh-mneme/choro/claims");
|
|
703
|
+
assert.ok(route, "/choro/claims 在册");
|
|
704
|
+
assert.equal(route.policy, "open");
|
|
705
|
+
const a = await invoke(route, "/api/dsh-mneme/choro/claims");
|
|
706
|
+
assert.equal(a.status, 503, "queries 缺装配 → 503");
|
|
707
|
+
|
|
708
|
+
const disabled = setup({ config: { chorographyEnabled: false }, choro: createChoroQueries({ store: createStore(":memory:") }) });
|
|
709
|
+
const b = await invoke(handler(disabled.routes, "/api/dsh-mneme/choro/claims"), "/api/dsh-mneme/choro/claims");
|
|
710
|
+
assert.equal(b.status, 409, "开关关 → 409");
|
|
711
|
+
assert.equal(b.body.error, "disabled");
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("/choro/claims:快乐路径带页清单与 totals;未知 repo 404", async () => {
|
|
715
|
+
const store = createStore(":memory:");
|
|
716
|
+
const root = makeRepo(store);
|
|
717
|
+
try {
|
|
718
|
+
const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
|
|
719
|
+
const route = handler(routes, "/api/dsh-mneme/choro/claims");
|
|
720
|
+
const repoId = store.db.prepare("SELECT id FROM choro_repos LIMIT 1").get().id;
|
|
721
|
+
const r = await invoke(route, `/api/dsh-mneme/choro/claims?repo=${repoId}`);
|
|
722
|
+
assert.equal(r.status, 200);
|
|
723
|
+
assert.ok(r.body.repo && r.body.repo.id === repoId);
|
|
724
|
+
assert.equal(typeof r.body.head, "string");
|
|
725
|
+
assert.ok(Array.isArray(r.body.pages), "页清单要在——面板靠它说「这页没问题」");
|
|
726
|
+
assert.ok(r.body.totals && Number.isInteger(r.body.totals.claims));
|
|
727
|
+
assert.equal(r.body.clean, true, "刚跑完 T0,基线就是 HEAD");
|
|
728
|
+
|
|
729
|
+
const missing = await invoke(route, "/api/dsh-mneme/choro/claims?repo=999999");
|
|
730
|
+
assert.equal(missing.status, 404);
|
|
731
|
+
} finally {
|
|
732
|
+
store.close();
|
|
733
|
+
rmTempBestEffort(root);
|
|
734
|
+
}
|
|
628
735
|
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// claims 粒度的失效判定(借 openwiki 的 Grounded Claims,issue #607 后续)。
|
|
2
|
+
//
|
|
3
|
+
// 锁的是「结论失准 vs 锚点错位」这条分界线:把 shifted 混进 touched,面板就会对一堆
|
|
4
|
+
// 只是行号偏了几行的结论喊「要重写」,喊几次就没人信了——这是本模块存在的全部理由。
|
|
5
|
+
// 还锁三种「算不出来」不许冒充「没事」:基线认不出、git 目录不可读、空 diff。
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { parseDiffRanges, assessClaims, summarizeClaims, diffSince } from "../src/wiki/claims.js";
|
|
13
|
+
|
|
14
|
+
const DIFF = [
|
|
15
|
+
"diff --git a/src/a.js b/src/a.js",
|
|
16
|
+
"--- a/src/a.js",
|
|
17
|
+
"+++ b/src/a.js",
|
|
18
|
+
"@@ -7,3 +7,4 @@ export function x() {",
|
|
19
|
+
"-old",
|
|
20
|
+
"+new",
|
|
21
|
+
"+extra",
|
|
22
|
+
"diff --git a/src/gone.js b/src/gone.js",
|
|
23
|
+
"--- a/src/gone.js",
|
|
24
|
+
"+++ /dev/null",
|
|
25
|
+
"@@ -1,2 +0,0 @@",
|
|
26
|
+
"-a",
|
|
27
|
+
"-b",
|
|
28
|
+
].join("\n");
|
|
29
|
+
|
|
30
|
+
test("parseDiffRanges:hunk 头取新旧两侧坐标,/dev/null 进 deleted", () => {
|
|
31
|
+
const d = parseDiffRanges(DIFF);
|
|
32
|
+
assert.deepEqual([...d.files.keys()], ["src/a.js"]);
|
|
33
|
+
assert.deepEqual(d.files.get("src/a.js"), [{ oldStart: 7, oldCount: 3, newStart: 7, newCount: 4 }]);
|
|
34
|
+
assert.deepEqual(d.deleted, ["src/gone.js"]);
|
|
35
|
+
// `@@ -c +c @@` 不带计数时按 1 算(`+c` 与 `+c,1` 同义;只有显式 `,0` 才是纯增删)。
|
|
36
|
+
const one = parseDiffRanges("diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -3 +3,2 @@\n-a\n+b\n+c\n");
|
|
37
|
+
assert.deepEqual(one.files.get("x"), [{ oldStart: 3, oldCount: 1, newStart: 3, newCount: 2 }]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("assessClaims:touched / shifted / deleted / unverifiable 四档分开,未变的不报", () => {
|
|
41
|
+
const d = parseDiffRanges(DIFF);
|
|
42
|
+
const claims = [
|
|
43
|
+
{ id: 1, statement: "A", sourcePath: "src/a.js", startLine: 8, endLine: 9, pageSlug: "p1" },
|
|
44
|
+
{ id: 2, statement: "B", sourcePath: "src/a.js", startLine: 2, endLine: 3, pageSlug: "p1" },
|
|
45
|
+
{ id: 3, statement: "C", sourcePath: "src/gone.js", startLine: 1, endLine: 1, pageSlug: "p2" },
|
|
46
|
+
{ id: 4, statement: "D", sourcePath: "src/a.js", pageSlug: "p1" },
|
|
47
|
+
{ id: 5, statement: "E", sourcePath: "src/untouched.js", startLine: 1, endLine: 1, pageSlug: "p3" },
|
|
48
|
+
];
|
|
49
|
+
const out = assessClaims(claims, d);
|
|
50
|
+
assert.deepEqual(out.map((c) => [c.id, c.reason]), [
|
|
51
|
+
[1, "touched"],
|
|
52
|
+
[2, null],
|
|
53
|
+
[3, "deleted"],
|
|
54
|
+
[4, "unverifiable"],
|
|
55
|
+
[5, null],
|
|
56
|
+
]);
|
|
57
|
+
assert.equal(out[0].shift, 0, "相交时 shift 记的是「相交之前累计了多少行");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("assessClaims:区间没被碰但上方有增删 → shifted 带 shift,不升级成 touched", () => {
|
|
61
|
+
// 第 2 行插了一行 → 第 10 行的结论内容还在,只是行号往后挪 1。
|
|
62
|
+
const diff = parseDiffRanges("diff --git a/y b/y\n--- a/y\n+++ b/y\n@@ -2,0 +3,1 @@\n+inserted\n");
|
|
63
|
+
const [c] = assessClaims([{ id: 9, statement: "S", sourcePath: "y", startLine: 10, endLine: 11, pageSlug: "p" }], diff);
|
|
64
|
+
assert.equal(c.reason, "shifted");
|
|
65
|
+
assert.equal(c.shift, 1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("assessClaims:纯插入落在结论行区间内部也算 touched,插在首行之前只是 shifted", () => {
|
|
69
|
+
// @@ -5,0 +6,1 @@ 表示「旧第 5 行之后插入一行」。结论占旧第 4-6 行时,新行插在
|
|
70
|
+
// 第 5 与第 6 行之间——结论的原文区间被切开了,不能报「没事」。
|
|
71
|
+
const inside = parseDiffRanges("diff --git a/z b/z\n--- a/z\n+++ b/z\n@@ -5,0 +6,1 @@\n+inserted\n");
|
|
72
|
+
const [hit] = assessClaims([{ id: 1, statement: "S", sourcePath: "z", startLine: 4, endLine: 6, pageSlug: "p" }], inside);
|
|
73
|
+
assert.equal(hit.reason, "touched", "插在结论区间内部:原文被切断");
|
|
74
|
+
// 插在结论首行之前(旧第 2 行之后)只是把整个区间推下去,内容没变。
|
|
75
|
+
const above = parseDiffRanges("diff --git a/z b/z\n--- a/z\n+++ b/z\n@@ -2,0 +3,1 @@\n+inserted\n");
|
|
76
|
+
const [pushed] = assessClaims([{ id: 2, statement: "S", sourcePath: "z", startLine: 4, endLine: 6, pageSlug: "p" }], above);
|
|
77
|
+
assert.equal(pushed.reason, "shifted");
|
|
78
|
+
assert.equal(pushed.shift, 1);
|
|
79
|
+
// 单行结论(start === end)边界:插在它上面一行 → shifted(内容没动,行号后移);
|
|
80
|
+
// 插在它下面 → null(既不碰到也不移动)。把「后面插」算成 shifted 会让一堆没变的
|
|
81
|
+
// 结论被喊去复核。
|
|
82
|
+
const [singleAbove] = assessClaims([{ id: 3, statement: "S", sourcePath: "z", startLine: 4, endLine: 4, pageSlug: "p" }], above);
|
|
83
|
+
assert.equal(singleAbove.reason, "shifted", "单行结论上方插一行,内容没动只是后移");
|
|
84
|
+
assert.equal(singleAbove.shift, 1);
|
|
85
|
+
const [singleBelow] = assessClaims([{ id: 4, statement: "S", sourcePath: "z", startLine: 4, endLine: 4, pageSlug: "p" }], inside);
|
|
86
|
+
assert.equal(singleBelow.reason, null, "单行结论下方插一行,既不碰到也不移动");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("parseDiffRanges + assessClaims:git 八进制转义的中文路径要还原(否则漏判成没事)", () => {
|
|
90
|
+
// core.quotepath 默认开着,中文路径在 diff 里长这样:
|
|
91
|
+
// --- "a/\344\270\255\346\226\207.js"
|
|
92
|
+
// 只脱引号不解转义的话,归一出来的字符串永远对不上库里存的 source_path,
|
|
93
|
+
// 这些文件上的结论就再也判不成 touched——不是报错,是静默漏判。
|
|
94
|
+
const quoted = '"a/\\344\\270\\255\\346\\226\\207.js"';
|
|
95
|
+
const diff = parseDiffRanges(
|
|
96
|
+
"diff --git " + quoted + " " + quoted + "\n"
|
|
97
|
+
+ "--- " + quoted + "\n"
|
|
98
|
+
+ "+++ " + quoted + "\n"
|
|
99
|
+
+ "@@ -2,2 +2,2 @@\n-a\n+b\n"
|
|
100
|
+
);
|
|
101
|
+
assert.deepEqual([...diff.files.keys()], ["中文.js"], "八进制转义要还原成真文件名");
|
|
102
|
+
const [c] = assessClaims([{ id: 1, statement: "S", sourcePath: "中文.js", startLine: 2, endLine: 3, pageSlug: "p" }], diff);
|
|
103
|
+
assert.equal(c.reason, "touched", "中文名文件上的结论必须能判成 touched");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("diffSince:重命名按「删旧 + 增新」处理,旧路径上的结论判成 deleted", () => {
|
|
107
|
+
const root = mkdtempSync(join(tmpdir(), "choro-claims-rename-"));
|
|
108
|
+
try {
|
|
109
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
110
|
+
// 中文名一起测:rename 与 quotepath 两条转义路径都走一遍。
|
|
111
|
+
writeFileSync(join(root, "src", "旧名.js"), "export const a = 1;\nexport const b = 2;\n", "utf8");
|
|
112
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
|
|
113
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
|
|
114
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
|
|
115
|
+
const base = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
|
116
|
+
execFileSync("git", ["mv", "src/旧名.js", "src/新名.js"], { cwd: root, stdio: "pipe" });
|
|
117
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "rename", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
|
|
118
|
+
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
|
119
|
+
const d = diffSince(root, base, head);
|
|
120
|
+
assert.equal(d.ok, true);
|
|
121
|
+
// 不传 --no-renames 时 git 只输出 `rename from/to`,files 与 deleted 里都没有这条
|
|
122
|
+
// 路径,结论会被静默判成 untouched(漏判)。关掉检测后旧路径必须进 deleted。
|
|
123
|
+
assert.ok(d.deleted.includes("src/旧名.js"), "重命名后旧路径上的结论必须判成 deleted");
|
|
124
|
+
const [c] = assessClaims([{ id: 1, statement: "S", sourcePath: "src/旧名.js", startLine: 1, endLine: 2, pageSlug: "p" }], d);
|
|
125
|
+
assert.equal(c.reason, "deleted");
|
|
126
|
+
} finally {
|
|
127
|
+
for (let i = 0; i < 5; i++) {
|
|
128
|
+
try { rmSync(root, { recursive: true, force: true }); break; } catch { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("summarizeClaims:按页聚合,hasT1 却零 claims 的页单独记 inconsistent", () => {
|
|
134
|
+
const d = parseDiffRanges(DIFF);
|
|
135
|
+
const claims = [
|
|
136
|
+
{ id: 1, statement: "A", sourcePath: "src/a.js", startLine: 8, endLine: 9, pageSlug: "p1", pageTitle: "P1" },
|
|
137
|
+
{ id: 3, statement: "C", sourcePath: "src/gone.js", startLine: 1, endLine: 1, pageSlug: "p2", pageTitle: "P2" },
|
|
138
|
+
];
|
|
139
|
+
const s = summarizeClaims(claims, d, {
|
|
140
|
+
pages: [
|
|
141
|
+
{ slug: "p1", title: "P1", hasT1: true },
|
|
142
|
+
{ slug: "p2", title: "P2", hasT1: true },
|
|
143
|
+
{ slug: "p3", title: "P3", hasT1: true },
|
|
144
|
+
{ slug: "p4", title: "P4", hasT1: false },
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
assert.deepEqual(s.pages.map((p) => p.slug), ["p1", "p2", "p3", "p4"], "有风险的排前面,干净的在后");
|
|
148
|
+
assert.equal(s.totals.claims, 2);
|
|
149
|
+
assert.equal(s.totals.atRiskClaims, 2);
|
|
150
|
+
assert.equal(s.totals.atRiskPages, 2);
|
|
151
|
+
assert.equal(s.totals.inconsistentPages, 1, "p3 有 T1 段但库里零 claims:T1 落盘非原子的残局");
|
|
152
|
+
assert.equal(s.pages.find((p) => p.slug === "p4").inconsistent, false, "没 T1 段也没 claims 是正常骨架页");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("summarizeClaims:diff 为 null(算不出来)时一条都不判,交回执 honesty 给调用方", () => {
|
|
156
|
+
const claims = [{ id: 1, statement: "A", sourcePath: "src/a.js", startLine: 1, endLine: 1, pageSlug: "p1" }];
|
|
157
|
+
const s = summarizeClaims(claims, null, { pages: [{ slug: "p1", title: "P1", hasT1: true }] });
|
|
158
|
+
assert.equal(s.totals.atRiskClaims, 0, "不能把「没算」说成「有风险」");
|
|
159
|
+
assert.equal(s.totals.claims, 1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("diffSince:基线非法 / 不是 git 目录 / 真 diff 三态分开", () => {
|
|
163
|
+
const bad = diffSince(tmpdir(), "not-a-sha", "abc1234");
|
|
164
|
+
assert.equal(bad.ok, false);
|
|
165
|
+
assert.equal(bad.reason, "baseline-unreadable");
|
|
166
|
+
|
|
167
|
+
const root = mkdtempSync(join(tmpdir(), "choro-claims-"));
|
|
168
|
+
try {
|
|
169
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
170
|
+
writeFileSync(join(root, "src", "a.js"), "export const a = 1;\n", "utf8");
|
|
171
|
+
// 还没 git init:必须如实报算不出来,不许返回空 diff 冒充「没变」。
|
|
172
|
+
const notRepo = diffSince(root, "0123456", "0123456");
|
|
173
|
+
assert.equal(notRepo.ok, false);
|
|
174
|
+
assert.equal(notRepo.reason, "diff-failed");
|
|
175
|
+
|
|
176
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
|
|
177
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
|
|
178
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
|
|
179
|
+
const base = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
|
180
|
+
writeFileSync(join(root, "src", "a.js"), "export const a = 1;\nexport const b = 2;\n", "utf8");
|
|
181
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-am", "more", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
|
|
182
|
+
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
|
183
|
+
|
|
184
|
+
const ok = diffSince(root, base, head);
|
|
185
|
+
assert.equal(ok.ok, true);
|
|
186
|
+
const [c] = assessClaims([{ id: 1, statement: "A", sourcePath: "src/a.js", startLine: 2, endLine: 2, pageSlug: "p" }], ok);
|
|
187
|
+
assert.equal(c.reason, "shifted", "第 1 行后追加一行 → 第 2 行结论行号后移");
|
|
188
|
+
assert.equal(c.shift, 1);
|
|
189
|
+
} finally {
|
|
190
|
+
for (let i = 0; i < 5; i++) {
|
|
191
|
+
try { rmSync(root, { recursive: true, force: true }); break; } catch { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// ① 语义段溯源(verifiedBaseline / verifiedAt)的回归锁。
|
|
2
|
+
//
|
|
3
|
+
// 这一层要防的不是「少写一个字段」,而是两个具体的假象:
|
|
4
|
+
// 1. **T1 晚于 T0 的误报**:claims 的行号是 T1 那一刻的代码树,全仓 git_baseline 是 T0 的。
|
|
5
|
+
// 若拿全仓基线起算 diff,T1 之前就发生、claim 早已算进去的改动会被报成 touched
|
|
6
|
+
// ——面板上就是「明明没动过,却催你复核」。所以起点必须是每页自己的 verified_baseline。
|
|
7
|
+
// 2. **T0 的确定性被污染**:verified 字段只由 T1 写。T0 一旦也写时间戳,
|
|
8
|
+
// 「同状态两次 apply 逐字节相同」这条不变量就破了(测试 wiki-t0.test.js 里那条)。
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { runChoroT0, gitHead } from "../src/wiki/t0.js";
|
|
16
|
+
import { createStore } from "../src/store.js";
|
|
17
|
+
import { createChoroQueries } from "../src/wiki/queries.js";
|
|
18
|
+
import { createT1 } from "../src/wiki/t1.js";
|
|
19
|
+
import {
|
|
20
|
+
FM_VERIFIED_AT,
|
|
21
|
+
FM_VERIFIED_BASELINE,
|
|
22
|
+
readFrontmatterKey,
|
|
23
|
+
setFrontmatterKeys,
|
|
24
|
+
T1_SECTION_START,
|
|
25
|
+
} from "../src/wiki/render.js";
|
|
26
|
+
|
|
27
|
+
const FIXED_NOW = () => "2026-11-05T12:00:00.000Z";
|
|
28
|
+
|
|
29
|
+
function makeFixture() {
|
|
30
|
+
const root = mkdtempSync(join(tmpdir(), "choro-prov-"));
|
|
31
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "provfix", scripts: { test: "node --test" } }, null, 2) + "\n", "utf8");
|
|
32
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
33
|
+
writeFileSync(join(root, "src", "a.js"), 'import { b } from "./b.js";\nexport const a = () => b();\n', "utf8");
|
|
34
|
+
writeFileSync(join(root, "src", "b.js"), "export const b = () => 1;\n", "utf8");
|
|
35
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
|
|
36
|
+
commit(root, "init");
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function commit(root, message) {
|
|
41
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
|
|
42
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", message, "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
|
|
43
|
+
return gitHead(root);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function rmTempBestEffort(dir) {
|
|
47
|
+
try {
|
|
48
|
+
rmSync(dir, { recursive: true, force: true });
|
|
49
|
+
} catch {
|
|
50
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
51
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* 留给系统清理 */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const GOOD_OUTPUT = JSON.stringify({
|
|
56
|
+
architecture: "src 模块承载核心导出面,a.js 依赖 b.js。",
|
|
57
|
+
convention: "导出全部用 export const 函数式风格。",
|
|
58
|
+
techStack: "纯 ESM,无框架。",
|
|
59
|
+
setup: "npm test。",
|
|
60
|
+
claims: [{ statement: "a.js 从 b.js 导入 b", kind: "architecture", sourcePath: "src/a.js", startLine: 1, endLine: 1 }],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// --- frontmatter 读写(页面格式层,与 T0/T1 无关的纯函数)---------------------
|
|
64
|
+
|
|
65
|
+
test("setFrontmatterKeys:无则追加、有则覆盖、空值删键、无 frontmatter 原样返回", () => {
|
|
66
|
+
const page = "---\nchoro: v1\nslug: src\n---\n\n# 标题\n";
|
|
67
|
+
const added = setFrontmatterKeys(page, [[FM_VERIFIED_BASELINE, "abc1234"], [FM_VERIFIED_AT, FIXED_NOW()]]);
|
|
68
|
+
assert.equal(readFrontmatterKey(added, FM_VERIFIED_BASELINE), "abc1234");
|
|
69
|
+
assert.equal(readFrontmatterKey(added, FM_VERIFIED_AT), FIXED_NOW());
|
|
70
|
+
assert.ok(added.indexOf("---") < added.indexOf("verifiedBaseline"), "落在 frontmatter 里,不是正文");
|
|
71
|
+
|
|
72
|
+
const overwritten = setFrontmatterKeys(added, [[FM_VERIFIED_BASELINE, "def5678"]]);
|
|
73
|
+
assert.equal(readFrontmatterKey(overwritten, FM_VERIFIED_BASELINE), "def5678");
|
|
74
|
+
assert.equal(overwritten.match(/^verifiedBaseline:/gm).length, 1, "覆盖而不是重复追加");
|
|
75
|
+
|
|
76
|
+
const removed = setFrontmatterKeys(added, [[FM_VERIFIED_BASELINE, null]]);
|
|
77
|
+
assert.equal(readFrontmatterKey(removed, FM_VERIFIED_BASELINE), null);
|
|
78
|
+
assert.equal(/^verifiedBaseline:/m.test(removed), false, "删键不留空行——「没记下」要查得出来");
|
|
79
|
+
|
|
80
|
+
// 人写的坏文件(没有 frontmatter)不许被凭空造一个块出来
|
|
81
|
+
assert.equal(setFrontmatterKeys("# 纯正文\n", [[FM_VERIFIED_BASELINE, "x"]]), "# 纯正文\n");
|
|
82
|
+
assert.equal(readFrontmatterKey("# 纯正文\n", FM_VERIFIED_BASELINE), null);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("readFrontmatterKey:缩进列表项不算顶层键,带引号的值剥引号", () => {
|
|
86
|
+
const page = '---\nslug: src\nsourceFiles:\n - verifiedBaseline: 假的\nverifiedBaseline: "a:b"\n---\n';
|
|
87
|
+
assert.equal(readFrontmatterKey(page, FM_VERIFIED_BASELINE), "a:b", "只认顶层键,列表里同名的行不误命中");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// --- T0 一个字节都不产出这两个键 ---------------------------------------------
|
|
91
|
+
|
|
92
|
+
test("T0 骨架页不含 verified 键:确定性不变量不许被溯源污染", () => {
|
|
93
|
+
const root = makeFixture();
|
|
94
|
+
const store = createStore(":memory:");
|
|
95
|
+
try {
|
|
96
|
+
runChoroT0({ repoRoot: root, store, apply: true, now: FIXED_NOW });
|
|
97
|
+
const page = readFileSync(join(root, ".chorography", "modules", "src.md"), "utf8");
|
|
98
|
+
assert.equal(page.includes(FM_VERIFIED_BASELINE), false, "骨架页没有「哪次验证的」这回事");
|
|
99
|
+
assert.equal(page.includes(FM_VERIFIED_AT), false);
|
|
100
|
+
} finally {
|
|
101
|
+
store.close();
|
|
102
|
+
rmTempBestEffort(root);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// --- T1 写下那一刻的 HEAD,且 claims 起点跟着它走 -----------------------------
|
|
107
|
+
|
|
108
|
+
test("T1 富化写下 verifiedBaseline/verifiedAt,且等于那一刻的 HEAD", async () => {
|
|
109
|
+
const root = makeFixture();
|
|
110
|
+
const store = createStore(":memory:");
|
|
111
|
+
try {
|
|
112
|
+
runChoroT0({ repoRoot: root, store, apply: true, now: FIXED_NOW });
|
|
113
|
+
const head = commit(root, "t1 之前的一次提交");
|
|
114
|
+
const t1 = createT1({ store, runSession: async () => ({ ok: true, text: GOOD_OUTPUT }), now: FIXED_NOW });
|
|
115
|
+
const receipt = await t1.runT1({ repoRoot: root, moduleSlug: "src" });
|
|
116
|
+
assert.equal(receipt.enriched.length, 1);
|
|
117
|
+
assert.equal(receipt.enriched[0].verifiedBaseline, head, "回执里带得出来,人不用去翻文件");
|
|
118
|
+
|
|
119
|
+
const page = readFileSync(join(root, ".chorography", "modules", "src.md"), "utf8");
|
|
120
|
+
assert.equal(readFrontmatterKey(page, FM_VERIFIED_BASELINE), head);
|
|
121
|
+
assert.equal(readFrontmatterKey(page, FM_VERIFIED_AT), FIXED_NOW());
|
|
122
|
+
assert.ok(page.includes(T1_SECTION_START), "语义段照旧");
|
|
123
|
+
|
|
124
|
+
const row = store.db.prepare(
|
|
125
|
+
"SELECT p.verified_baseline, p.verified_at FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id WHERE m.slug = 'src'"
|
|
126
|
+
).get();
|
|
127
|
+
assert.equal(row.verified_baseline, head, "文件与库同源同写");
|
|
128
|
+
assert.equal(row.verified_at, FIXED_NOW());
|
|
129
|
+
|
|
130
|
+
const evidence = JSON.parse(readFileSync(join(root, ".chorography", "evidence", "src.json"), "utf8"));
|
|
131
|
+
assert.equal(evidence.verifiedBaseline, head, "evidence 旁证也带起点");
|
|
132
|
+
} finally {
|
|
133
|
+
store.close();
|
|
134
|
+
rmTempBestEffort(root);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("claimDrift:T1 晚于 T0 时,T1 之前的改动不误报 touched(这就是 per-page 基线的理由)", async () => {
|
|
139
|
+
const root = makeFixture();
|
|
140
|
+
const store = createStore(":memory:");
|
|
141
|
+
try {
|
|
142
|
+
runChoroT0({ repoRoot: root, store, apply: true, now: FIXED_NOW });
|
|
143
|
+
// T0 之后、T1 之前有人改了 a.js 第 1 行——claim 读的就是改完之后的这棵树。
|
|
144
|
+
writeFileSync(join(root, "src", "a.js"), 'export const a = () => 1;\n', "utf8");
|
|
145
|
+
commit(root, "T0 之后、T1 之前");
|
|
146
|
+
const t1 = createT1({ store, runSession: async () => ({ ok: true, text: GOOD_OUTPUT }), now: FIXED_NOW });
|
|
147
|
+
await t1.runT1({ repoRoot: root, moduleSlug: "src" });
|
|
148
|
+
|
|
149
|
+
const q = createChoroQueries({ store });
|
|
150
|
+
const repo = q.resolveRepo(undefined);
|
|
151
|
+
const drift = q.claimDrift(repo.id);
|
|
152
|
+
assert.equal(drift.totals.claims, 1);
|
|
153
|
+
assert.equal(drift.totals.atRiskClaims, 0, "拿全仓基线起算会在这里误报 touched(旧的 bug)");
|
|
154
|
+
assert.equal(drift.clean, true);
|
|
155
|
+
const pageDrift = drift.pages.find((p) => p.slug === "src");
|
|
156
|
+
assert.equal(pageDrift.verifiedBaseline, drift.head, "起点就是 HEAD");
|
|
157
|
+
assert.equal(pageDrift.baselineApprox, false);
|
|
158
|
+
|
|
159
|
+
// 真改了 claim 锚点所在行:这时必须报出来,别因为「起点是 HEAD」就永远说干净。
|
|
160
|
+
writeFileSync(join(root, "src", "a.js"), 'export const a = () => 2;\n', "utf8");
|
|
161
|
+
commit(root, "真的动了锚点");
|
|
162
|
+
const drift2 = q.claimDrift(repo.id);
|
|
163
|
+
assert.equal(drift2.clean, false);
|
|
164
|
+
assert.equal(drift2.totals.atRiskClaims, 1);
|
|
165
|
+
assert.equal(drift2.pages.find((p) => p.slug === "src").atRisk[0].reason, "touched");
|
|
166
|
+
} finally {
|
|
167
|
+
store.close();
|
|
168
|
+
rmTempBestEffort(root);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("claimDrift:老数据(没有逐页基线)退到全仓基线,并标明这是近似起点", () => {
|
|
173
|
+
const root = makeFixture();
|
|
174
|
+
const store = createStore(":memory:");
|
|
175
|
+
try {
|
|
176
|
+
runChoroT0({ repoRoot: root, store, apply: true, now: FIXED_NOW });
|
|
177
|
+
const q = createChoroQueries({ store });
|
|
178
|
+
const repo = q.resolveRepo(undefined);
|
|
179
|
+
// 本功能之前富化的页:库里有 claims、没有 verified_baseline(迁移加列后的老行)。
|
|
180
|
+
const page = store.choroPageBySlug(repo.id, "src");
|
|
181
|
+
store.saveChoroPageClaims(page.id, [
|
|
182
|
+
{ statement: "a 依赖 b", kind: "architecture", sourcePath: "src/a.js", startLine: 2, endLine: 2, createdAt: FIXED_NOW() },
|
|
183
|
+
]);
|
|
184
|
+
store.db.prepare("UPDATE choro_pages SET verified_baseline = NULL, verified_at = NULL WHERE id = ?").run(page.id);
|
|
185
|
+
|
|
186
|
+
const drift = q.claimDrift(repo.id);
|
|
187
|
+
const pageDrift = drift.pages.find((p) => p.slug === "src");
|
|
188
|
+
assert.equal(pageDrift.verifiedBaseline, null, "真话:这页没记过逐页基线");
|
|
189
|
+
assert.equal(pageDrift.baselineApprox, true, "退而求其次的起点要标出来");
|
|
190
|
+
assert.equal(pageDrift.baselineUnknown, false, "有了保底起点就不再是「判不了」");
|
|
191
|
+
assert.equal(pageDrift.diffBaseline, drift.baseline, "用的就是全仓基线");
|
|
192
|
+
|
|
193
|
+
// 全仓基线也缺(极老的行)→ 才轮到 baselineUnknown,一条都不判。
|
|
194
|
+
store.db.prepare("UPDATE choro_repos SET git_baseline = '' WHERE id = ?").run(repo.id);
|
|
195
|
+
const drift2 = q.claimDrift(repo.id);
|
|
196
|
+
assert.equal(drift2.pages.find((p) => p.slug === "src").baselineUnknown, true);
|
|
197
|
+
assert.equal(drift2.totals.atRiskClaims, 0, "判不了就是判不了,不冒充没事");
|
|
198
|
+
} finally {
|
|
199
|
+
store.close();
|
|
200
|
+
rmTempBestEffort(root);
|
|
201
|
+
}
|
|
202
|
+
});
|