dsh-soul 0.7.2 → 0.9.0

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.
@@ -0,0 +1,552 @@
1
+ // scripts/verify-bindings.mjs — 人设工作区绑定层自检(纯函数 + 真实文件系统)
2
+ //
3
+ // 与 verify-config.mjs / verify-store.mjs / verify-history.mjs 的分工:这里只管
4
+ // lib/bindings.mjs —— 路径怎么规范化、前缀怎么匹配、坏掉的绑定表会怎样。
5
+ //
6
+ // 这个模块的失败模式**全都长得很安静**,所以断言必须打在那些安静的错误上:
7
+ //
8
+ // - 裸 startsWith 让 `D:\项目A` 命中 `D:\项目AB`(两个在磁盘上毫无关系的目录);
9
+ // - 大小写折算漏做,于是 `d:\项目a` 与 `D:\项目A` 在 Windows 上被当成两条绑定;
10
+ // - 失效人设**没有**回落到父级绑定,而是直接掉到全局(用户在子目录里静默换人);
11
+ // - 一条手改坏的行让整张绑定表打不开;
12
+ // - realpath 失败(目录已删 / 网络盘离线)把整个设置页带崩。
13
+ //
14
+ // 语义显式传入(sep / caseInsensitive),不读 process.platform:CI 在 ubuntu、开发机在
15
+ // Windows,若断言跟着平台跑,两边的「绿」就不是同一件事了。
16
+ //
17
+ // 零依赖,直接 `node scripts/verify-bindings.mjs` 运行。
18
+
19
+ import assert from 'node:assert/strict'
20
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
21
+ import { tmpdir } from 'node:os'
22
+ import { join } from 'node:path'
23
+ import {
24
+ BINDINGS_VERSION,
25
+ MAX_BINDINGS,
26
+ MAX_PATH_LENGTH,
27
+ bindingRefusal,
28
+ bindingsPathFor,
29
+ compareKey,
30
+ isAbsoluteDirPath,
31
+ matchBindings,
32
+ normalizeBindingEntry,
33
+ normalizeDirPath,
34
+ parseBindingsDocument,
35
+ pathMatches,
36
+ readBindingsFile,
37
+ resolveDirIdentity,
38
+ resolvePersonaForCwd,
39
+ sanitizeBindings,
40
+ serializeBindings,
41
+ writeBindingsFile
42
+ } from '../lib/bindings.mjs'
43
+ import { DEFAULT_CONFIG, PERSONA_NAME_MAX } from '../lib/config.mjs'
44
+ import { assertCount } from './lib/skip-report.mjs'
45
+
46
+ const root = mkdtempSync(join(tmpdir(), 'dsh-soul-bindings-'))
47
+ // 断言条数写死:新增/删除用例时数字必须一起改(见 skip-report.mjs 的约定 3)。
48
+ const EXPECTED_ASSERTIONS = 47
49
+
50
+ // Windows 语义与 POSIX 语义各一份,两套都在这台机器上跑 —— 平台相关的分支
51
+ // 因此不可能因为「我这里恰好是 Windows」而蒙混过关。
52
+ const WIN = { sep: '\\', caseInsensitive: true }
53
+ const POSIX = { sep: '/', caseInsensitive: false }
54
+
55
+ let fileSeq = 0
56
+ function freshPath(label = 'soul-bindings.json') {
57
+ fileSeq += 1
58
+ return join(root, `${fileSeq}-${label}`)
59
+ }
60
+
61
+ let passed = 0
62
+ async function check(name, fn) {
63
+ let counted
64
+ try {
65
+ counted = await fn()
66
+ } catch (error) {
67
+ // 失败时点名用例:否则只有一条孤零零的 AssertionError,看不出是哪一处在报。
68
+ error.message = `【${name}】${error.message}`
69
+ error.stack = `【${name}】${error.stack}`
70
+ throw error
71
+ }
72
+ if (counted === false) return
73
+ passed++
74
+ console.log(` ✓ ${name}`)
75
+ }
76
+
77
+ process.on('exit', () => {
78
+ try {
79
+ rmSync(root, { recursive: true, force: true })
80
+ } catch {
81
+ // 临时目录清理失败不影响结论
82
+ }
83
+ })
84
+
85
+ console.log('契约常量')
86
+
87
+ await check('契约常量与设计文档一致,且不随平台漂移', () => {
88
+ assert.equal(BINDINGS_VERSION, 1)
89
+ assert.equal(MAX_PATH_LENGTH, 512)
90
+ assert.equal(MAX_BINDINGS, 200)
91
+ })
92
+
93
+ await check('bindingsPathFor 与配置文件同目录,复用它的目录而不是重读环境变量', () => {
94
+ assert.equal(bindingsPathFor('/home/u/.dsh/soul-config.json'), '/home/u/.dsh/soul-bindings.json')
95
+ assert.equal(bindingsPathFor('C:\\Users\\u\\.dsh\\soul-config.json'), 'C:\\Users\\u\\.dsh\\soul-bindings.json')
96
+ // 测试里换一个配置路径,绑定文件必须跟着走 —— 否则断言会写到真实的 $DSH_HOME 上。
97
+ assert.equal(bindingsPathFor('/tmp/xxx/soul-config.json'), '/tmp/xxx/soul-bindings.json')
98
+ })
99
+
100
+ console.log('路径规范化')
101
+
102
+ await check('normalizeDirPath 统一分隔符并去掉尾部分隔符', () => {
103
+ assert.equal(normalizeDirPath('D:/项目A/', WIN), 'D:\\项目A')
104
+ assert.equal(normalizeDirPath('D:\\项目A\\\\子', WIN), 'D:\\项目A\\子')
105
+ assert.equal(normalizeDirPath('/home/u/proj/', POSIX), '/home/u/proj')
106
+ assert.equal(normalizeDirPath(' /home/u/proj ', POSIX), '/home/u/proj')
107
+ })
108
+
109
+ await check('normalizeDirPath 保留盘根与 POSIX 根,不把它们削成 D: 或空串', () => {
110
+ assert.equal(normalizeDirPath('D:\\', WIN), 'D:\\')
111
+ assert.equal(normalizeDirPath('D:/', WIN), 'D:\\')
112
+ assert.equal(normalizeDirPath('/', POSIX), '/')
113
+ assert.equal(normalizeDirPath('//', POSIX), '/')
114
+ })
115
+
116
+ await check('normalizeDirPath 保留 UNC 前缀,不被折叠成单斜杠', () => {
117
+ const out = normalizeDirPath('\\\\server\\share\\', WIN)
118
+ assert.ok(out.startsWith('\\\\'), `UNC 前缀必须保留,实际:${JSON.stringify(out)}`)
119
+ assert.equal(out, '\\\\server\\share')
120
+ })
121
+
122
+ await check('normalizeDirPath 对非法输入返回 null,不抛错', () => {
123
+ assert.equal(normalizeDirPath(null, WIN), null)
124
+ assert.equal(normalizeDirPath(undefined, WIN), null)
125
+ assert.equal(normalizeDirPath(42, WIN), null)
126
+ assert.equal(normalizeDirPath('', WIN), null)
127
+ assert.equal(normalizeDirPath(' ', WIN), null)
128
+ assert.equal(normalizeDirPath('D:\\项目\0A', WIN), null, 'NUL 字节必须拒绝')
129
+ })
130
+
131
+ await check('isAbsoluteDirPath 认三种绝对形态,且拒绝盘符相对路径', () => {
132
+ assert.equal(isAbsoluteDirPath('D:\\项目A'), true)
133
+ assert.equal(isAbsoluteDirPath('D:/项目A'), true)
134
+ assert.equal(isAbsoluteDirPath('/home/u'), true)
135
+ assert.equal(isAbsoluteDirPath('\\\\server\\share'), true)
136
+ // `D:项目A` 的含义取决于 D 盘当时的当前目录,不能当绝对路径
137
+ assert.equal(isAbsoluteDirPath('D:项目A'), false)
138
+ assert.equal(isAbsoluteDirPath('项目A'), false)
139
+ assert.equal(isAbsoluteDirPath('..\\项目A'), false)
140
+ assert.equal(isAbsoluteDirPath(''), false)
141
+ assert.equal(isAbsoluteDirPath(null), false)
142
+ })
143
+
144
+ await check('compareKey 只在 Windows 语义下折大小写(存原样、比折叠)', () => {
145
+ assert.equal(compareKey('D:\\项目A', WIN), 'd:\\项目a')
146
+ assert.equal(compareKey('D:\\项目A', POSIX), 'D:\\项目A')
147
+ // 展示形态不受影响 —— UI 上给用户看的必须是他磁盘上的名字
148
+ assert.equal(normalizeDirPath('D:\\项目A', WIN), 'D:\\项目A')
149
+ })
150
+
151
+ console.log('前缀匹配(整个功能最容易写错的一行)')
152
+
153
+ await check('★ 路径边界:D:\\项目A 不得命中 D:\\项目AB', () => {
154
+ assert.equal(pathMatches('D:\\项目A', 'D:\\项目A', WIN), true, '同一个目录必须命中')
155
+ assert.equal(pathMatches('D:\\项目A\\src', 'D:\\项目A', WIN), true, '子目录必须命中')
156
+ assert.equal(
157
+ pathMatches('D:\\项目AB', 'D:\\项目A', WIN),
158
+ false,
159
+ '★ 前缀必须落在路径边界上:裸 startsWith 会让 项目A 命中 项目AB'
160
+ )
161
+ assert.equal(pathMatches('D:\\项目AB\\src', 'D:\\项目A', WIN), false, '★ 同上(带子目录)')
162
+ assert.equal(pathMatches('D:\\项目A-备份', 'D:\\项目A', WIN), false, '★ 连字符同样不是边界')
163
+ })
164
+
165
+ await check('★ POSIX 下同样落在边界上', () => {
166
+ assert.equal(pathMatches('/home/u/proj/src', '/home/u/proj', POSIX), true)
167
+ assert.equal(pathMatches('/home/u/projX', '/home/u/proj', POSIX), false, '★ /home/u/projX 不是 /home/u/proj 的子目录')
168
+ assert.equal(pathMatches('/home/u/pro', '/home/u/proj', POSIX), false, '反向前缀同样不成立')
169
+ })
170
+
171
+ await check('大小写折算参与匹配,但不影响展示', () => {
172
+ assert.equal(pathMatches('d:\\项目a\\SRC', 'D:\\项目A', WIN), true)
173
+ assert.equal(pathMatches('/Home/U/Proj', '/home/u/proj', POSIX), false, 'POSIX 大小写敏感')
174
+ })
175
+
176
+ await check('盘根作前缀时不会拼出双分隔符(D:\\\\ → D:\\\\项目A 应命中)', () => {
177
+ assert.equal(pathMatches('D:\\项目A', 'D:\\', WIN), true)
178
+ assert.equal(pathMatches('/', '/', POSIX), true)
179
+ assert.equal(pathMatches('/home', '/', POSIX), true)
180
+ })
181
+
182
+ await check('pathMatches 对空串与非法输入一律返回 false,不抛错', () => {
183
+ assert.equal(pathMatches('', 'D:\\项目A', WIN), false)
184
+ assert.equal(pathMatches('D:\\项目A', '', WIN), false)
185
+ assert.equal(pathMatches(null, 'D:\\项目A', WIN), false)
186
+ assert.equal(pathMatches('D:\\项目A', undefined, WIN), false)
187
+ })
188
+
189
+ console.log('最长前缀优先')
190
+
191
+ await check('matchBindings 按具体程度从高到低返回,子绑定排在父绑定之前', () => {
192
+ const table = {
193
+ 'D:\\项目A': '资深架构师',
194
+ 'D:\\项目A\\实验性重构': '头脑风暴伙伴',
195
+ 'D:\\项目AB': '极简主义者'
196
+ }
197
+ const hits = matchBindings('D:\\项目A\\实验性重构\\src', table, WIN)
198
+ assert.deepEqual(hits.map((h) => h.dir), ['D:\\项目A\\实验性重构', 'D:\\项目A'], '最长前缀必须排在最前')
199
+ assert.equal(hits[0].persona, '头脑风暴伙伴')
200
+
201
+ // ★ 同一条匹配路径上,项目AB 的绑定不得混进来
202
+ assert.deepEqual(matchBindings('D:\\项目AB\\x', table, WIN).map((h) => h.dir), ['D:\\项目AB'])
203
+ })
204
+
205
+ await check('matchBindings 对没有任何绑定的目录返回空数组,不回落成「全都命中」', () => {
206
+ const table = { 'D:\\项目A': '资深架构师' }
207
+ assert.deepEqual(matchBindings('E:\\别的项目', table, WIN), [])
208
+ assert.deepEqual(matchBindings('', table, WIN), [])
209
+ assert.deepEqual(matchBindings('D:\\项目A', null, WIN), [], '空表不得抛错')
210
+ assert.deepEqual(matchBindings('D:\\项目A', ['D:\\项目A'], WIN), [], '数组不是绑定表')
211
+ })
212
+
213
+ await check('matchBindings 结果顺序稳定(同一份表任何时候给出同一顺序)', () => {
214
+ // 两条绑定的目录名长度相同、深度也相同 ⇒ 只能靠兜底排序定序,否则顺序取决于
215
+ // Object.keys 的插入顺序,UI 上表现为「每次刷新行顺序都在跳」。
216
+ const table = { 'D:\\b项目': 'A', 'D:\\a项目': 'B' }
217
+ const first = matchBindings('D:\\a项目\\x', table, WIN).map((h) => h.dir)
218
+ const table2 = { 'D:\\a项目': 'B', 'D:\\b项目': 'A' }
219
+ const second = matchBindings('D:\\a项目\\x', table2, WIN).map((h) => h.dir)
220
+ assert.deepEqual(first, second, '插入顺序不同的等价表必须给出同样的顺序')
221
+ })
222
+
223
+ console.log('三层解析与失效引用')
224
+
225
+ await check('resolvePersonaForCwd 命中绑定且人设存在时直接生效', () => {
226
+ const table = { 'D:\\项目A': '资深架构师' }
227
+ const hit = resolvePersonaForCwd('D:\\项目A\\src', table, {}, WIN)
228
+ assert.equal(hit.dir, 'D:\\项目A')
229
+ assert.equal(hit.persona, '资深架构师')
230
+ assert.deepEqual(hit.shadowed, [])
231
+ })
232
+
233
+ await check('★ 失效人设回落到父级绑定,而不是直接掉到全局', () => {
234
+ const table = { 'D:\\项目A': '资深架构师', 'D:\\项目A\\子': '早就删掉的预设' }
235
+ const hit = resolvePersonaForCwd('D:\\项目A\\子\\x', table, {}, WIN)
236
+ assert.ok(hit, '父级绑定可用时必须回落到它')
237
+ assert.equal(hit.persona, '资深架构师')
238
+ assert.deepEqual(hit.shadowed, [{ dir: 'D:\\项目A\\子', persona: '早就删掉的预设', reason: 'missing-persona' }],
239
+ '被跳过的更具体绑定必须如实报出来,否则用户在子目录里看到别的人设只会以为坏了')
240
+ })
241
+
242
+ await check('全部绑定都失效时返回 null(由调用方回落全局配置)', () => {
243
+ const table = { 'D:\\项目A': '不存在的人设' }
244
+ assert.equal(resolvePersonaForCwd('D:\\项目A\\x', table, {}, WIN), null)
245
+ })
246
+
247
+ await check('内置人设是合法的绑定目标(不可删但可绑)', () => {
248
+ const { BUILTIN_PERSONAS } = { BUILTIN_PERSONAS: null }
249
+ // 不 import 内置表:只用「苏格拉底式提问者」这个名字,它是内置的既定事实
250
+ const table = { 'D:\\项目A': '苏格拉底式提问者' }
251
+ const hit = resolvePersonaForCwd('D:\\项目A', table, {}, WIN)
252
+ assert.ok(hit, '内置人设必须能被绑定命中')
253
+ assert.equal(hit.persona, '苏格拉底式提问者')
254
+ assert.equal(BUILTIN_PERSONAS, null)
255
+ })
256
+
257
+ await check('用户自建人设同样可被绑定命中', () => {
258
+ const table = { 'D:\\项目A': '我的自定义人设' }
259
+ const userPersonas = { 我的自定义人设: { style: 'casual' } }
260
+ assert.equal(resolvePersonaForCwd('D:\\项目A', table, userPersonas, WIN).persona, '我的自定义人设')
261
+ })
262
+
263
+ console.log('绑定表校验(读侧一律当不可信输入)')
264
+
265
+ await check('sanitizeBindings 丢弃坏条目并计数,不因一条坏记录让整表失效', () => {
266
+ const raw = {
267
+ 'D:\\项目A': '资深架构师',
268
+ 'relative': 'X',
269
+ 'D:\\B': '',
270
+ 'D:\\C': 42,
271
+ 'D:\\D': '好'.repeat(PERSONA_NAME_MAX + 1),
272
+ 'D:\\E': '__proto__'
273
+ }
274
+ const { bindings, dropped } = sanitizeBindings(raw, WIN)
275
+ assert.deepEqual(bindings, { 'D:\\项目A': '资深架构师' }, '合法条目必须全部保留')
276
+ // 逐条对应而不是「排序后比个数」:后者在两条记录互换原因时照样通过
277
+ assert.deepEqual(
278
+ dropped.map((d) => [d.dir, d.reason]),
279
+ [
280
+ ['relative', 'not-absolute'],
281
+ ['D:\\B', 'invalid-persona'],
282
+ ['D:\\C', 'invalid-persona'],
283
+ ['D:\\D', 'invalid-persona'],
284
+ ['D:\\E', 'invalid-persona']
285
+ ]
286
+ )
287
+ })
288
+
289
+ await check('★ 拒绝原型污染键(__proto__ / constructor / prototype)', () => {
290
+ const raw = JSON.parse('{"__proto__":"资深架构师","constructor":"资深架构师","prototype":"资深架构师"}')
291
+ const { bindings, dropped } = sanitizeBindings(raw, WIN)
292
+ assert.deepEqual(Object.keys(bindings), [], '保留字一个都不能进表')
293
+ assert.equal(dropped.length, 3)
294
+ assert.ok(dropped.every((d) => d.reason === 'reserved-key'))
295
+ assert.equal(Object.getPrototypeOf(bindings), Object.prototype, '原型不得被污染')
296
+ })
297
+
298
+ await check('sanitizeBindings 把同一条绑定的两种写法归一成一条', () => {
299
+ const { bindings } = sanitizeBindings({ 'D:/项目A/': 'A', 'D:\\项目A': 'B' }, WIN)
300
+ assert.deepEqual(Object.keys(bindings), ['D:\\项目A'], '归一化后撞车的两条只留一条')
301
+ assert.equal(bindings['D:\\项目A'], 'B', '后出现的写法覆盖先出现的(同一条绑定,不是损坏)')
302
+ })
303
+
304
+ await check('sanitizeBindings 拒绝超长路径与相对路径,且不截断', () => {
305
+ const longDir = 'D:\\' + 'x'.repeat(MAX_PATH_LENGTH)
306
+ const { bindings, dropped } = sanitizeBindings({ [longDir]: '资深架构师' }, WIN)
307
+ assert.deepEqual(bindings, {}, '超长路径必须整条拒绝 —— 截断出来的路径再也命中不了任何会话')
308
+ assert.equal(dropped[0].reason, 'path-too-long')
309
+ })
310
+
311
+ await check('sanitizeBindings 对空表与非对象输入都能安全返回', () => {
312
+ assert.deepEqual(sanitizeBindings(null, WIN).bindings, {})
313
+ assert.deepEqual(sanitizeBindings(undefined, WIN).bindings, {})
314
+ assert.deepEqual(sanitizeBindings([], WIN).dropped, [{ dir: null, reason: 'not-object' }])
315
+ assert.deepEqual(sanitizeBindings('x', WIN).dropped, [{ dir: null, reason: 'not-object' }])
316
+ })
317
+
318
+ await check('条数超过上限后拒绝新增,但已存在的键仍可改绑', () => {
319
+ const raw = {}
320
+ for (let i = 0; i < MAX_BINDINGS + 3; i++) raw[`D:\\目录${i}`] = '资深架构师'
321
+ const { bindings, dropped } = sanitizeBindings(raw, WIN)
322
+ assert.equal(Object.keys(bindings).length, MAX_BINDINGS, '表必须有界')
323
+ assert.equal(dropped.filter((d) => d.reason === 'too-many').length, 3)
324
+ })
325
+
326
+ console.log('绑定文件解析')
327
+
328
+ await check('parseBindingsDocument 缺省与 null 都当空表,且不算失败', () => {
329
+ for (const value of [undefined, null]) {
330
+ const out = parseBindingsDocument(value, WIN)
331
+ assert.deepEqual(out.bindings, {})
332
+ assert.equal(out.failure, null, '「还没有绑定文件」是正常状态,不是失败')
333
+ }
334
+ })
335
+
336
+ await check('★ 版本不认识时整表忽略并给出说明,不猜测字段语义', () => {
337
+ const out = parseBindingsDocument({ version: 99, bindings: { 'D:\\项目A': '资深架构师' } }, WIN)
338
+ assert.deepEqual(out.bindings, {}, '版本不符时宁可暂时不生效,也不能解析出一堆假绑定')
339
+ assert.match(out.failure, /99/)
340
+ assert.match(out.failure, /1/)
341
+ })
342
+
343
+ await check('非对象内容与缺 version 的文档都能被安全处理', () => {
344
+ assert.match(parseBindingsDocument([1, 2], WIN).failure, /不是一个对象/)
345
+ assert.match(parseBindingsDocument('x', WIN).failure, /不是一个对象/)
346
+ assert.equal(parseBindingsDocument({ bindings: { 'D:\\项目A': '资深架构师' } }, WIN).failure, null,
347
+ 'version 字段缺席按当前版本处理(早期手写文件)')
348
+ })
349
+
350
+ await check('serializeBindings 的输出能被自己读回去(读写幂等)', () => {
351
+ const source = { 'D:\\项目A': '资深架构师', 'D:\\项目A\\子': '头脑风暴伙伴' }
352
+ const doc = serializeBindings(source)
353
+ assert.equal(doc.version, BINDINGS_VERSION)
354
+ const back = parseBindingsDocument(doc, WIN)
355
+ assert.deepEqual(back.bindings, source)
356
+ })
357
+
358
+ await check('serializeBindings 过滤保留键,且不修改调用方的对象', () => {
359
+ // 用 JSON.parse 而不是字面量:`{"__proto__": ...}` 经 JSON.parse 会成为**自有可枚举
360
+ // 属性**,而字面量里的 `__proto__:` 是设原型,根本进不了 Object.keys —— 那种写法下
361
+ // 这条断言会被自己的前置条件架空(判断力对照实测过这个漏法)。
362
+ const source = JSON.parse('{"__proto__":"X","constructor":"Y","prototype":"Z"}')
363
+ source['D:\\项目A'] = '资深架构师'
364
+ assert.deepEqual(
365
+ Object.keys(source).sort(),
366
+ ['D:\\项目A', '__proto__', 'constructor', 'prototype'].sort(),
367
+ '前置条件:三个保留键必须真的在源对象里,否则本断言没有判别力'
368
+ )
369
+
370
+ const doc = serializeBindings(source)
371
+ assert.deepEqual(Object.keys(doc.bindings), ['D:\\项目A'], '保留键一个都不能落到输出里')
372
+ assert.ok(
373
+ !Object.prototype.hasOwnProperty.call(doc.bindings, 'constructor'),
374
+ 'constructor 落在输出里会污染后续所有 `x.constructor` 读取'
375
+ )
376
+ assert.equal(Object.getPrototypeOf(doc.bindings), Object.prototype, '原型不得被污染')
377
+ assert.ok(Object.prototype.hasOwnProperty.call(source, 'D:\\项目A'), '调用方的对象不应被就地改动')
378
+ assert.equal(Object.keys(source).length, 4, '调用方的对象既不该增键也不该减键')
379
+ })
380
+
381
+ console.log('realpath 解析(符号链接 / Junction)')
382
+
383
+ await check('★ realpath 失败(目录已删 / 网络盘离线)时保留规范化形态,绝不抛错', async () => {
384
+ const boom = async () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }
385
+ const out = await resolveDirIdentity('D:\\早就不存在的目录', WIN, boom)
386
+ assert.equal(out, 'D:\\早就不存在的目录', '离线盘不该让整个设置页崩掉')
387
+ })
388
+
389
+ await check('realpath 成功时采用解析结果,保证 Junction 与真实路径同一条绑定', async () => {
390
+ const fake = async (p) => (p === 'D:\\链接' ? 'D:\\真实目录' : p)
391
+ assert.equal(await resolveDirIdentity('D:\\链接', WIN, fake), 'D:\\真实目录')
392
+ assert.equal(await resolveDirIdentity('D:\\链接\\子', WIN, fake), 'D:\\链接\\子', 'realpath 只解析它收到的整个路径')
393
+ })
394
+
395
+ await check('resolveDirIdentity 对非法输入返回 null,且不调用 realpath', async () => {
396
+ let called = 0
397
+ const spy = async (p) => { called++; return p }
398
+ assert.equal(await resolveDirIdentity('', WIN, spy), null)
399
+ assert.equal(await resolveDirIdentity(null, WIN, spy), null)
400
+ assert.equal(called, 0, '非法输入必须在校验阶段就挡掉,不产生多余 IO')
401
+ })
402
+
403
+ console.log('写入准入')
404
+
405
+ await check('bindingRefusal 拒绝相对路径、超长路径与不存在的人设', () => {
406
+ assert.match(bindingRefusal('相对\\目录', '资深架构师', {}), /绝对路径/)
407
+ assert.match(bindingRefusal('D:\\' + 'x'.repeat(MAX_PATH_LENGTH), '资深架构师', {}), /过长/)
408
+ assert.match(bindingRefusal('D:\\项目A', '并不存在的人设', {}), /不存在/)
409
+ assert.match(bindingRefusal('D:\\项目A', '', {}), /不合法/)
410
+ })
411
+
412
+ await check('★ 人设不存在时拒绝写入(typo 不该被当成「绑定生效了」)', () => {
413
+ const userPersonas = { 我的自定义人设: { style: 'casual' } }
414
+ assert.equal(bindingRefusal('D:\\项目A', '我的自定义人设', userPersonas), null, '存在的自建人设必须放行')
415
+ assert.equal(bindingRefusal('D:\\项目A', '苏格拉底式提问者', userPersonas), null, '内置人设必须放行')
416
+ const refusal = bindingRefusal('D:\\项目A', '我的自定义人设 ', userPersonas)
417
+ assert.equal(refusal, null, '名字两侧空白按 trim 后判定(sanitizePersonaName 的语义)')
418
+ assert.match(bindingRefusal('D:\\项目A', '我的自定人设', userPersonas), /不存在/, '少一个字就是不存在')
419
+ })
420
+
421
+ await check('normalizeBindingEntry 归一的是「写什么」,且与 bindingRefusal 同口径', () => {
422
+ const userPersonas = { 我的自定义人设: { style: 'casual' } }
423
+ // 键与值两侧都要归一:只归一一边,就会出现「拒绝时按 trim 后的名字判、写进表里的是原串」
424
+ assert.deepEqual(
425
+ normalizeBindingEntry(' D:/项目A/ ', ' 我的自定义人设 ', userPersonas, WIN),
426
+ { dir: 'D:\\项目A', persona: '我的自定义人设' }
427
+ )
428
+ assert.equal(normalizeBindingEntry('D:\\项目A', '并不存在的人设', userPersonas, WIN), null)
429
+ assert.equal(normalizeBindingEntry('相对路径', '资深架构师', {}, WIN), null)
430
+ assert.equal(normalizeBindingEntry('', '资深架构师', {}, WIN), null)
431
+ // ★ 归一化后的键必须能被 matchBindings 原样命中 —— 否则「写进去的」与「匹配用的」
432
+ // 就是两套形态,表现是「绑定保存成功但从没生效」。
433
+ const entry = normalizeBindingEntry(' D:/项目A/ ', '资深架构师', {}, WIN)
434
+ assert.deepEqual(
435
+ matchBindings('D:\\项目A\\src', { [entry.dir]: entry.persona }, WIN).map((hit) => hit.dir),
436
+ [entry.dir]
437
+ )
438
+ // `..` 刻意不被文本折叠(见 normalizeDirPath 注释):折错了会命中别的目录,
439
+ // 不折的后果只是这条绑定命中不了 —— 失败方向必须是安全的那一侧。
440
+ assert.equal(normalizeBindingEntry('D:/项目A/../B', '资深架构师', {}, WIN).dir, 'D:\\项目A\\..\\B')
441
+ })
442
+
443
+ console.log('真实文件系统')
444
+
445
+ await check('写入后读回得到同一张表,且文件形状与设计文档一致', async () => {
446
+ const file = freshPath()
447
+ await writeBindingsFile(file, { 'D:\\项目A': '资深架构师' })
448
+ const text = readFileSync(file, 'utf8')
449
+ const doc = JSON.parse(text)
450
+ assert.equal(doc.version, BINDINGS_VERSION)
451
+ assert.deepEqual(doc.bindings, { 'D:\\项目A': '资深架构师' })
452
+
453
+ const back = await readBindingsFile(file, WIN)
454
+ assert.deepEqual(back.bindings, { 'D:\\项目A': '资深架构师' })
455
+ assert.equal(back.failure, null)
456
+ assert.equal(back.exists, true)
457
+ })
458
+
459
+ await check('绑定文件不存在时是「首次运行」而不是失败', async () => {
460
+ const out = await readBindingsFile(freshPath('不存在.json'), WIN)
461
+ assert.deepEqual(out.bindings, {})
462
+ assert.equal(out.failure, null)
463
+ assert.equal(out.exists, false)
464
+ })
465
+
466
+ await check('★ 绑定文件损坏时移开原文件、以空表继续,并报出备份位置', async () => {
467
+ const file = freshPath('损坏.json')
468
+ writeFileSync(file, '{ 这不是合法 JSON', 'utf8')
469
+ const out = await readBindingsFile(file, WIN)
470
+ assert.deepEqual(out.bindings, {}, '损坏时必须能继续工作(绑定是环境事实,不是用户唯一的数据副本)')
471
+ assert.match(out.failure, /备份到/)
472
+ assert.ok(out.movedAside && out.movedAside.endsWith('.corrupt'), '原文件必须被挪到 .corrupt')
473
+ // 移开之后原路径不再存在 ⇒ 下一次读取回到「首次运行」的正常分支
474
+ const again = await readBindingsFile(file, WIN)
475
+ assert.equal(again.failure, null)
476
+ assert.equal(again.exists, false)
477
+ })
478
+
479
+ await check('读到的坏条目只丢弃并计数,其余绑定照常生效', async () => {
480
+ const file = freshPath('混了坏行.json')
481
+ writeFileSync(file, JSON.stringify({
482
+ version: BINDINGS_VERSION,
483
+ bindings: { 'D:\\项目A': '资深架构师', '相对路径': 'X' }
484
+ }), 'utf8')
485
+ const out = await readBindingsFile(file, WIN)
486
+ assert.deepEqual(out.bindings, { 'D:\\项目A': '资深架构师' })
487
+ assert.equal(out.dropped.length, 1)
488
+ assert.equal(out.failure, null, '个别坏条目不是文件级失败')
489
+ })
490
+
491
+ await check('写入是原子的:临时文件不会残留', async () => {
492
+ const file = freshPath('原子.json')
493
+ await writeBindingsFile(file, { 'D:\\项目A': '资深架构师' })
494
+ await writeBindingsFile(file, { 'D:\\项目A': '极简主义者' })
495
+ const { readdirSync } = await import('node:fs')
496
+ const leftover = readdirSync(root).filter((f) => f.endsWith('.tmp'))
497
+ assert.deepEqual(leftover, [], '同目录 rename 之后不应留下 .tmp')
498
+ assert.equal((await readBindingsFile(file, WIN)).bindings['D:\\项目A'], '极简主义者')
499
+ })
500
+
501
+ console.log('端到端:目录 → 人设')
502
+
503
+ await check('★ 三层解析:目录绑定胜过全局,且不碰到兄弟目录', () => {
504
+ const table = { 'D:\\项目A': '资深架构师', 'D:\\项目A\\实验性重构': '头脑风暴伙伴' }
505
+ const userPersonas = {}
506
+ const globalPersona = DEFAULT_CONFIG.style
507
+
508
+ const resolve = (cwd) => {
509
+ const hit = resolvePersonaForCwd(cwd, table, userPersonas, WIN)
510
+ return hit ? hit.persona : globalPersona
511
+ }
512
+
513
+ assert.equal(resolve('D:\\项目A'), '资深架构师')
514
+ assert.equal(resolve('D:\\项目A\\src\\deep\\nested'), '资深架构师', '子目录继承')
515
+ assert.equal(resolve('D:\\项目A\\实验性重构'), '头脑风暴伙伴', '更具体的绑定优先')
516
+ assert.equal(resolve('D:\\项目AB'), globalPersona, '★ 兄弟目录回落全局,不得被 项目A 命中')
517
+ assert.equal(resolve('E:\\无关目录'), globalPersona, '未绑定目录回落全局')
518
+ })
519
+
520
+ await check('★ 子代理场景:子代理继承父会话 cwd ⇒ 拿到同一个人设', () => {
521
+ // 宿主 childSessionMeta(parent, childDepth, isSeeded) 原样继承 parentHeader.cwd。
522
+ // 这正是「目录是主键」的理由:纯会话键下子代理会静默回落全局。
523
+ const table = { 'D:\\项目A': '资深架构师' }
524
+ const parentCwd = 'D:\\项目A'
525
+ const childCwd = parentCwd
526
+ assert.equal(
527
+ resolvePersonaForCwd(childCwd, table, {}, WIN).persona,
528
+ resolvePersonaForCwd(parentCwd, table, {}, WIN).persona
529
+ )
530
+ })
531
+
532
+ console.log('模块边界')
533
+
534
+ await check('lib/bindings.mjs 不依赖 cordis、不把路径写进提示词、不打印', () => {
535
+ const text = readFileSync(new URL('../lib/bindings.mjs', import.meta.url), 'utf8')
536
+ assert.ok(!/from\s+'cordis'/.test(text), '绑定层必须能脱离宿主被加载')
537
+ assert.ok(!/compilePrompt|systemPrompt|injectPromptToAllAgents/.test(text), '绑定只用于选人设,绝不进提示词(隐私边界)')
538
+ assert.ok(!/console\./.test(text), '库模块不打印;诊断经返回值交给调用方')
539
+ const code = text.split('\n').filter((line) => !line.trim().startsWith('//')).join('\n')
540
+ assert.ok(!/提示词|prompt/i.test(code), `只有代码不该提到提示词,实际代码里出现:${(code.match(/.*(提示词|prompt).*/i) || [])[0]}`)
541
+ })
542
+
543
+ await check('lib/bindings.mjs 复用 lib/store.mjs 的 IO 原语,不另造一套原子写', () => {
544
+ const text = readFileSync(new URL('../lib/bindings.mjs', import.meta.url), 'utf8')
545
+ assert.match(text, /import \{[^}]*readConfigFile[^}]*\} from '\.\/store\.mjs'/)
546
+ assert.match(text, /import \{[^}]*writeConfigFile[^}]*\} from '\.\/store\.mjs'/)
547
+ assert.match(text, /import \{[^}]*moveAsideConfigFile[^}]*\} from '\.\/store\.mjs'/)
548
+ assert.ok(!/writeFile\(/.test(text), '原子写只有一处实现(store.mjs),这里不得直接 writeFile')
549
+ })
550
+
551
+ if (!assertCount('verify-bindings', passed, EXPECTED_ASSERTIONS)) process.exit(1)
552
+ console.log(`\n全部通过:${passed} 项检查`)