@modusensus/dsh-mneme 0.12.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 +51 -20
- package/bin/dsh-mneme-choro.mjs +9 -1
- package/docs/CONFIGURATION.md +47 -6
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +788 -30
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +4627 -1409
- package/lib/config.js +166 -1
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/dream/decisions.js +95 -6
- package/lib/dream/sleep.js +760 -19
- package/lib/dream/triage.js +139 -0
- package/lib/dream.js +168 -21
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +22 -11
- package/lib/index.js +123 -3
- package/lib/inject.js +203 -20
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +98 -4
- package/lib/peak-hours.js +57 -2
- package/lib/semantic.js +347 -60
- package/lib/service.js +268 -69
- package/lib/settings.js +128 -15
- package/lib/store.js +585 -26
- package/lib/summarize.js +38 -11
- package/lib/tools.js +35 -4
- package/lib/user-settings.js +128 -32
- package/lib/vector-index.js +76 -6
- package/lib/wiki/claims.js +267 -0
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +282 -2
- package/lib/wiki/render.js +74 -0
- package/lib/wiki/t0.js +43 -6
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +145 -32
- package/package.json +39 -1
- package/scripts/release-docs.mjs +7 -1
- package/scripts/release-notes.mjs +4 -1
- package/src/api-standalone.js +4 -1
- package/src/api.js +788 -30
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +166 -1
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/dream/decisions.js +95 -6
- package/src/dream/sleep.js +760 -19
- package/src/dream/triage.js +139 -0
- package/src/dream.js +168 -21
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +22 -11
- package/src/index.js +123 -3
- package/src/inject.js +203 -20
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +98 -4
- package/src/peak-hours.js +57 -2
- package/src/semantic.js +347 -60
- package/src/service.js +268 -69
- package/src/settings.js +128 -15
- package/src/store.js +585 -26
- package/src/summarize.js +38 -11
- package/src/tools.js +35 -4
- package/src/user-settings.js +128 -32
- package/src/vector-index.js +76 -6
- package/src/wiki/claims.js +267 -0
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +282 -2
- package/src/wiki/render.js +74 -0
- package/src/wiki/t0.js +43 -6
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +145 -32
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +385 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +1051 -185
- package/test/conflict-freeze.test.js +45 -1
- package/test/conflict-ledger-enforce.test.js +452 -0
- package/test/conflict-ledger.test.js +242 -0
- package/test/continuity.test.js +8 -1
- package/test/contrast.test.js +124 -12
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/dream-baseline-clamp.test.js +174 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/graph-api.test.js +124 -0
- package/test/hot-memory.test.js +48 -0
- package/test/image-memory.test.js +171 -0
- package/test/inject-parent-gate.test.js +6 -3
- package/test/inject.test.js +59 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/panel-chart-scale.test.js +90 -0
- package/test/panel-tokens.test.js +245 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +7 -7
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/release-docs.test.js +20 -0
- package/test/release-notes.test.js +25 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/security-doc.test.js +10 -0
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +635 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +463 -1
- package/test/wiki-claims.test.js +194 -0
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-provenance.test.js +202 -0
- package/test/wiki-queries.test.js +128 -0
- package/test/wiki-t0.test.js +77 -1
- package/test/wiki-t1-jobs.test.js +125 -0
- package/test/wiki-t1.test.js +226 -1
|
@@ -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,118 @@
|
|
|
1
|
+
// dsh-mneme/test/wiki-cli-hint.test.js
|
|
2
|
+
// issue #607 的回归锁:提示里那条命令必须真能粘贴就跑。
|
|
3
|
+
//
|
|
4
|
+
// 这条 issue 的病因不是「文案不好看」,是**给了一条跑不起来的命令**:插件装在
|
|
5
|
+
// profile 依赖里,npm 只为全局安装把 shim 放进 PATH,用户照抄 dsh-mneme-choro
|
|
6
|
+
// 必然 command not found。所以真正要锁的不是字符串长相,而是三件事:
|
|
7
|
+
// 1) 推导出的入口在这个仓库里真实存在(下面的 existsSync 与实跑验证);
|
|
8
|
+
// 2) 推导不依赖调用方在第几层目录(src/ 与 lib/ 同层、src/wiki/ 在第二层、
|
|
9
|
+
// bin/ 在第一层)——写死层数会悄悄算错,而算错的表现正是又给一条跑不起来的命令;
|
|
10
|
+
// 3) 找不到入口时返回 null 让调用方降级,而不是硬编一个假路径。
|
|
11
|
+
//
|
|
12
|
+
// 引号部分单独锁:安装路径带空格是常态(Windows 的 C:\Program Files\),
|
|
13
|
+
// POSIX 路径带单引号也必须转义成 '\'',否则「带引号的命令」照样跑不起来。
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
|
|
22
|
+
import { choroCliCommand, choroCliCommandFrom, choroCliEntry } from "../src/wiki/cli-hint.js";
|
|
23
|
+
|
|
24
|
+
const ENTRY_TAIL = join("bin", "dsh-mneme-choro.mjs");
|
|
25
|
+
|
|
26
|
+
test("从包内任意深度都能推出同一个 CLI 入口(不按固定层数)", () => {
|
|
27
|
+
// 四个不同深度、且真实存在于本仓的模块位置;写死 dirname 次数的实现只会有
|
|
28
|
+
// 其中一部分对,这条断言就会红。
|
|
29
|
+
const froms = [
|
|
30
|
+
new URL("../package.json", import.meta.url), // test/ -> 1 层
|
|
31
|
+
new URL("../src/tools.js", import.meta.url), // src/ -> 1 层
|
|
32
|
+
new URL("../src/wiki/t1.js", import.meta.url), // src/wiki/ -> 2 层
|
|
33
|
+
new URL("../bin/dsh-mneme-choro.mjs", import.meta.url) // bin/ -> 0 层
|
|
34
|
+
];
|
|
35
|
+
const entries = froms.map((u) => choroCliEntry(u.href));
|
|
36
|
+
for (const [i, entry] of entries.entries()) {
|
|
37
|
+
assert.equal(typeof entry, "string", "caller #" + i + " must resolve an entry");
|
|
38
|
+
assert.ok(existsSync(entry), "caller #" + i + " resolved a path that does not exist: " + entry);
|
|
39
|
+
assert.ok(entry.endsWith(ENTRY_TAIL), entry);
|
|
40
|
+
}
|
|
41
|
+
// 四条路径必须收敛到同一个入口,否则「同一份代码在不同文件里给出不同命令」
|
|
42
|
+
assert.equal(new Set(entries).size, 1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("推出来的入口是这个包自己的 bin(不是同名外来目录)", () => {
|
|
46
|
+
const entry = choroCliEntry(new URL("../src/wiki/t1.js", import.meta.url).href);
|
|
47
|
+
assert.ok(entry);
|
|
48
|
+
// 包根必须同时有 bin/ 与 package.json —— 这正是推导的两个条件,反过来验一遍
|
|
49
|
+
const pkgRoot = entry.slice(0, entry.length - ENTRY_TAIL.length - 1);
|
|
50
|
+
assert.ok(existsSync(join(pkgRoot, "package.json")), pkgRoot);
|
|
51
|
+
assert.equal(join(pkgRoot, ENTRY_TAIL), entry);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("源码树被裁剪(找不到 bin 或 package.json)时返回 null,不硬编假路径", () => {
|
|
55
|
+
const dir = mkdtempSync(join(tmpdir(), "mneme-cli-hint-"));
|
|
56
|
+
try {
|
|
57
|
+
// 只有 bin/ 没有 package.json:可能是别人的同名 bin,不能认
|
|
58
|
+
mkdirSync(join(dir, "bin"), { recursive: true });
|
|
59
|
+
writeFileSync(join(dir, "bin", "dsh-mneme-choro.mjs"), "");
|
|
60
|
+
assert.equal(choroCliEntry(pathToFileURL(join(dir, "bin", "dsh-mneme-choro.mjs")).href), null);
|
|
61
|
+
|
|
62
|
+
// 补齐 package.json 就该认出来(证明上一条的红不是「永远返回 null」)
|
|
63
|
+
writeFileSync(join(dir, "package.json"), "{}");
|
|
64
|
+
assert.equal(
|
|
65
|
+
choroCliEntry(pathToFileURL(join(dir, "bin", "dsh-mneme-choro.mjs")).href),
|
|
66
|
+
join(dir, "bin", "dsh-mneme-choro.mjs")
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// 只有 package.json 没有 bin/:`files` 少了 bin 的裁剪树,同样不能认
|
|
70
|
+
const bare = mkdtempSync(join(tmpdir(), "mneme-cli-hint-bare-"));
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(join(bare, "package.json"), "{}");
|
|
73
|
+
mkdirSync(join(bare, "src"), { recursive: true });
|
|
74
|
+
assert.equal(choroCliEntry(pathToFileURL(join(bare, "src", "tools.js")).href), null);
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(bare, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("命令带引号:Windows 双引号,POSIX 单引号(路径含空格与单引号都要能跑)", () => {
|
|
84
|
+
// Windows:安装路径带空格是常态,不加引号那条命令在 cmd 里会被拆成两段
|
|
85
|
+
assert.equal(
|
|
86
|
+
choroCliCommand("C:\\Program Files\\mneme\\bin\\dsh-mneme-choro.mjs", "<repo>", "win32"),
|
|
87
|
+
'node "C:\\Program Files\\mneme\\bin\\dsh-mneme-choro.mjs" <repo> --apply'
|
|
88
|
+
);
|
|
89
|
+
// POSIX:无空格时也加引号(统一形态,便于用户照着改)
|
|
90
|
+
assert.equal(
|
|
91
|
+
choroCliCommand("/opt/mneme/bin/dsh-mneme-choro.mjs", "<repo>", "linux"),
|
|
92
|
+
"node '/opt/mneme/bin/dsh-mneme-choro.mjs' <repo> --apply"
|
|
93
|
+
);
|
|
94
|
+
// POSIX:路径里有单引号,必须转成 '\'',否则 shell 把命令截断在引号处
|
|
95
|
+
assert.equal(
|
|
96
|
+
choroCliCommand("/opt/it's here/bin/dsh-mneme-choro.mjs", "<repo>", "linux"),
|
|
97
|
+
"node '/opt/it'\\''s here/bin/dsh-mneme-choro.mjs' <repo> --apply"
|
|
98
|
+
);
|
|
99
|
+
// 占位符原样保留(面板给 <仓库路径>,工具报错给 <repo>),不被引号逻辑吞掉
|
|
100
|
+
assert.equal(
|
|
101
|
+
choroCliCommandFrom(new URL("../src/tools.js", import.meta.url).href, "<仓库路径>", "win32").endsWith(" <仓库路径> --apply"),
|
|
102
|
+
true
|
|
103
|
+
);
|
|
104
|
+
// 入口缺失时整体返回 null —— 调用方据此降级,绝不输出半条命令
|
|
105
|
+
assert.equal(choroCliCommand(null, "<repo>", "win32"), null);
|
|
106
|
+
assert.equal(choroCliCommandFrom("not a url", "<repo>", "win32"), null);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("真的能跑:用推导出的入口执行一次 --help(issue 的验收就是「用户能跑起来」)", () => {
|
|
110
|
+
const entry = choroCliEntry(new URL("../src/wiki/t1.js", import.meta.url).href);
|
|
111
|
+
assert.ok(entry);
|
|
112
|
+
// 用真实入口起一次进程:issue 的验收标准就是「用户照着这条命令能跑起来」,
|
|
113
|
+
const res = spawnSync(process.execPath, [entry, "--help"], { encoding: "utf8", timeout: 30000 });
|
|
114
|
+
assert.equal(res.status, 0, entry + " --help failed: " + (res.stderr || res.stdout));
|
|
115
|
+
assert.match(res.stdout, /--apply/);
|
|
116
|
+
});
|
|
117
|
+
// 用真实入口起一次进程:issue 的验收标准就是「用户照着这条命令能跑起来」,
|
|
118
|
+
// 字符串层面的断言证明不了这件事。
|
|
@@ -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
|
+
});
|
|
@@ -165,6 +165,13 @@ test("moduleMap:模块、页面状态与跨模块边同源返回", () => {
|
|
|
165
165
|
assert.deepEqual(slugs, ["root", "src"]);
|
|
166
166
|
const src = modules.find((m) => m.slug === "src");
|
|
167
167
|
assert.equal(src.page_status, "skeleton");
|
|
168
|
+
// 面板从地志页导入知识点时读的是 mod.relPath(契约 §3.6);SQL 行里只有
|
|
169
|
+
// page_rel_path,缺这层映射会让「选完模块点导入」永远提示请先选模块(#607)。
|
|
170
|
+
// 同时锁住原 snake_case 字段还在——choro_module_map 的 render 与 CLI 都在用。
|
|
171
|
+
assert.equal(typeof src.relPath, "string", "relPath 必须与 page_rel_path 同值");
|
|
172
|
+
assert.equal(src.relPath, src.page_rel_path);
|
|
173
|
+
assert.ok(src.relPath.endsWith(".md"), "页相对路径是磁盘上真实存在的 .md");
|
|
174
|
+
assert.equal(typeof src.file_count, "number", "file_count 原字段必须保留(choro_module_map 的 render 在吃)");
|
|
168
175
|
// fixture 的引用全在 src 模块内部(a→b→b2),left-pad 是外部依赖,root 只有
|
|
169
176
|
// package.json 没有代码——跨模块边应为空集。跨模块聚erge本身由 wiki-store 的
|
|
170
177
|
// 整仓替换用例与 t0 的 moduleEdges 排序覆盖。
|
|
@@ -268,3 +275,124 @@ test("outline:模块名 + 文件数 + 页状态 + 页相对路径,不读正
|
|
|
268
275
|
rmTempBestEffort(root);
|
|
269
276
|
}
|
|
270
277
|
});
|
|
278
|
+
// --- claim 粒度失效(Grounded Claims):DB 索引 × git diff 的对账 --------------
|
|
279
|
+
|
|
280
|
+
test("claimDrift:基线 == HEAD 说 clean:true,改了锚点所在行 → 只有那页那条进 atRisk", () => {
|
|
281
|
+
const { root, store, q } = setup();
|
|
282
|
+
try {
|
|
283
|
+
const repo = q.resolveRepo(undefined);
|
|
284
|
+
const page = store.choroPageBySlug(repo.id, "src");
|
|
285
|
+
store.saveChoroPageClaims(page.id, [
|
|
286
|
+
{ statement: "a 依赖 b", kind: "architecture", sourcePath: "src/a.js", startLine: 3, endLine: 3, createdAt: FIXED_NOW() },
|
|
287
|
+
{ statement: "b2 只是常量", kind: "architecture", sourcePath: "src/b2.js", startLine: 1, endLine: 1, createdAt: FIXED_NOW() },
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
const same = q.claimDrift(repo.id);
|
|
291
|
+
assert.equal(same.clean, true, "基线就是 HEAD:确实没变,可以说没事");
|
|
292
|
+
assert.equal(same.anchorStale, false);
|
|
293
|
+
assert.equal(same.totals.claims, 2);
|
|
294
|
+
assert.equal(same.totals.atRiskClaims, 0);
|
|
295
|
+
|
|
296
|
+
// 改掉 a.js 第 3 行(claim 的锚点)并提交 → 基线落后 HEAD。
|
|
297
|
+
writeFileSync(join(root, "src", "a.js"), 'import { b } from "./b.js";\nimport h from "left-pad";\nexport const a = () => b() + 1;\n', "utf8");
|
|
298
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-am", "change a", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
|
|
299
|
+
|
|
300
|
+
const drift = q.claimDrift(repo.id);
|
|
301
|
+
assert.equal(drift.clean, false);
|
|
302
|
+
assert.notEqual(drift.baseline, drift.head, "基线落后了");
|
|
303
|
+
assert.equal(drift.totals.claims, 2);
|
|
304
|
+
assert.equal(drift.totals.atRiskClaims, 1, "只有锚在 a.js 第 3 行那条受影响");
|
|
305
|
+
const pageRisk = drift.pages.find((p) => p.slug === "src");
|
|
306
|
+
assert.equal(pageRisk.atRisk.length, 1);
|
|
307
|
+
assert.equal(pageRisk.atRisk[0].reason, "touched");
|
|
308
|
+
assert.equal(pageRisk.atRisk[0].sourcePath, "src/a.js");
|
|
309
|
+
// 排序契约:有风险的页排最前(面板直接顺着读,不再排一遍)。
|
|
310
|
+
assert.equal(drift.pages[0].slug, "src");
|
|
311
|
+
} finally {
|
|
312
|
+
store.close();
|
|
313
|
+
rmTempBestEffort(root);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("claimDrift:基线认不出时说 anchorStale,不说「都还准」也不说「全失效」", () => {
|
|
318
|
+
const { root, store, q } = setup();
|
|
319
|
+
try {
|
|
320
|
+
const repo = q.resolveRepo(undefined);
|
|
321
|
+
// force-push / 浅克隆的形态:基线 sha 合法但对象不在库里的任何一棵树上。
|
|
322
|
+
store.db.prepare("UPDATE choro_repos SET git_baseline = ? WHERE id = ?").run("0123456789abcdef0123456789abcdef01234567", repo.id);
|
|
323
|
+
const drift = q.claimDrift(repo.id);
|
|
324
|
+
assert.equal(drift.clean, false);
|
|
325
|
+
assert.equal(drift.anchorStale, true);
|
|
326
|
+
assert.equal(drift.reason, "diff-failed");
|
|
327
|
+
assert.equal(drift.totals.atRiskClaims, 0, "算不出来不等于有风险——一条都不许判");
|
|
328
|
+
} finally {
|
|
329
|
+
store.close();
|
|
330
|
+
rmTempBestEffort(root);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("claimDrift:验证过却没记下基线(verified_at 有、verified_baseline 空)→ 判不了,不回退全仓基线", () => {
|
|
335
|
+
const { root, store, q } = setup();
|
|
336
|
+
try {
|
|
337
|
+
const repo = q.resolveRepo(undefined);
|
|
338
|
+
const page = store.choroPageBySlug(repo.id, "src");
|
|
339
|
+
store.saveChoroPageClaims(page.id, [
|
|
340
|
+
{ statement: "a 依赖 b", kind: "architecture", sourcePath: "src/a.js", startLine: 3, endLine: 3, createdAt: FIXED_NOW() },
|
|
341
|
+
]);
|
|
342
|
+
// 后半段的验证没留下凭据:T1 写了 verified_at,却没写 verified_baseline。
|
|
343
|
+
store.db.prepare("UPDATE choro_pages SET verified_at = ?, verified_baseline = NULL WHERE id = ?").run(FIXED_NOW(), page.id);
|
|
344
|
+
|
|
345
|
+
const drift = q.claimDrift(repo.id);
|
|
346
|
+
const p = drift.pages.find((x) => x.slug === "src");
|
|
347
|
+
// 退到全仓基线会把「富化之后才发生」的改动当成「富化之前就有」而放过——漏报。
|
|
348
|
+
// 不知道起点就不装作知道:diffBaseline 必须为 null,且这一页判 baselineUnknown。
|
|
349
|
+
assert.equal(p.diffBaseline, null, "没有验证点就不许回退全仓基线");
|
|
350
|
+
assert.equal(p.baselineApprox, false, "这不是「老数据退而求其次」,别标成约等于");
|
|
351
|
+
assert.equal(p.baselineUnknown, true);
|
|
352
|
+
assert.equal(drift.totals.baselineUnknownPages, 1);
|
|
353
|
+
// 基线 == HEAD 时 clean 仍可为真:一条提交都没发生过,锚点不可能被碰到,
|
|
354
|
+
// 「验证点没记下」在这里不影响结论(影响的是「变了之后能不能判」)。
|
|
355
|
+
assert.equal(drift.clean, true, "没变就是没变,不必因为起点没记下而说不知道");
|
|
356
|
+
|
|
357
|
+
// 有提交之后就不一样了:起点认不出的页不许被那些提交的改动「顺带放过」。
|
|
358
|
+
writeFileSync(join(root, "src", "a.js"), 'import { b } from "./b.js";\nexport const a = () => b() + 1;\n', "utf8");
|
|
359
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-am", "change a", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
|
|
360
|
+
const after = q.claimDrift(repo.id);
|
|
361
|
+
assert.equal(after.clean, false, "有判不了的页,就不能说干净");
|
|
362
|
+
assert.equal(after.totals.baselineUnknownPages, 1);
|
|
363
|
+
assert.equal(after.totals.atRiskClaims, 0, "判不了不等于有风险,一条都不许判");
|
|
364
|
+
} finally {
|
|
365
|
+
store.close();
|
|
366
|
+
rmTempBestEffort(root);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("claimDrift:repoAtHead 也不能掩盖 inconsistentPages(有语义段却没 claims 的页)", () => {
|
|
371
|
+
const { root, store, q } = setup();
|
|
372
|
+
try {
|
|
373
|
+
const repo = q.resolveRepo(undefined);
|
|
374
|
+
// 造一次「T1 落盘不完整」:页里有语义段,库里一条 claims 都没有。
|
|
375
|
+
// 仓库基线仍等于 HEAD,旧实现在这里会短路成 clean:true——把残局说成没事。
|
|
376
|
+
const pagePath = join(root, ".chorography", "modules", "src.md");
|
|
377
|
+
writeFileSync(pagePath, readFileSync(pagePath, "utf8") + "\n<!-- CHORO:T1:START -->\n## 架构\n\na.js 是入口。\n<!-- CHORO:T1:END -->\n", "utf8");
|
|
378
|
+
|
|
379
|
+
const drift = q.claimDrift(repo.id);
|
|
380
|
+
assert.equal(drift.baseline, drift.head, "前提:仓库基线就是 HEAD(走短路分支)");
|
|
381
|
+
assert.equal(drift.totals.inconsistentPages, 1, "有语义段、零 claims 的页要被数出来");
|
|
382
|
+
assert.equal(drift.totals.atRiskClaims, 0, "它一条结论都没有,谈不上有风险");
|
|
383
|
+
assert.equal(drift.clean, false, "残局不许被说成「全部干净」");
|
|
384
|
+
} finally {
|
|
385
|
+
store.close();
|
|
386
|
+
rmTempBestEffort(root);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("claimDrift:未知仓库返回 null(调用方据此 404,不是空清单)", () => {
|
|
391
|
+
const { root, store, q } = setup();
|
|
392
|
+
try {
|
|
393
|
+
assert.equal(q.claimDrift(999999), null);
|
|
394
|
+
} finally {
|
|
395
|
+
store.close();
|
|
396
|
+
rmTempBestEffort(root);
|
|
397
|
+
}
|
|
398
|
+
});
|