Repository navigation
Conversation
MyPrototypeWhat
left a comment
There was a problem hiding this comment.
感谢提交!这个包的规则实现和测试都很扎实。我在本地拉了分支,npm run check 通过,本包报 OK;另外写了个临时脚本起 start(context),实际调用了接口。有几处想请你看一下:
需要修改
- 卦历写入接口对页面传来的 reading 基本不校验(
miniapp/node/server.mjs:283-290,写入是store.mjs:68-71的...reading原样落盘;详见行内评论)。docs/security.md要求 "Validate every request parameter"。我实测复现了四种情况:记录存得进去却删不掉、question没有截断、hexagram: null让整个卦历页读取失败、同 id 记录被一起删掉。有回环守卫,影响有限,但还是建议收紧。 - 卦库「以此卦起一卦」起出来的不是这一卦(
miniapp/client/index.html:3415,详见行内评论)。遍历 64 卦验证,只有 2 卦能起回自己。这条路径也没有走cast()的在途闸。 - 有一条单测依赖当天日期(
tests/divination.test.mjs:3170,详见行内评论)。今天(10-08)跑是 227/228。
建议(可选)
- README 的 "Data & access" 写的是 "No … path outside the package is read",第 8 行也写了 "reads nothing outside its own package"。但 store 实际会读
dataDir/readings.json,可以在 Files read 里补上。 README.md有 1721 行,而 CONTRIBUTING 希望是简短的 README。推演规则和测试过程记录(比如第 1330 行起的变异测试说明)可以挪到docs/。- 免责声明目前有三个版本:
SKILL.md:55要求「不得改写」的那句、工具返回的DISCLAIMER(divination-http.mjs:106-107)、MCP instructions(第 512 行)。Agent 会拿到互相矛盾的要求,建议统一成一个常量。 - 页面路由的
castFrom/toBoundedInteger(server.mjs:302-328)和 MCP 的callTool/toInteger(divination-http.mjs:124-133, 299-322)是两套类似的逻辑;120、1000000000和SERVER_INFO的版本号也各写了一份。可以合并成一个共用函数和一组常量。 handleMcpRequest支持批量数组(divination-http.mjs:543),但readJsonBody会拒绝数组(server.mjs:345),实测批量请求返回 400invalid_body。可以删掉这段,或者在入口放行数组。index.html:2376的回退写法el('question').trim()应该是.value.trim()。目前所有调用都传了 question,所以还没触发。
再次感谢,有问题随时交流~
| if (typeof reading.hexagram !== 'object' || typeof reading.id !== 'string') { | ||
| sendJson(response, 400, { error: 'invalid_reading' }); | ||
| return; | ||
| } | ||
| const saved = await store.save( | ||
| /** @type {any} */ (reading), | ||
| clamp(body.note, MAX_NOTE), | ||
| ); |
There was a problem hiding this comment.
这里只检查了 typeof reading.hexagram === 'object' 和 typeof reading.id === 'string',然后 store.save 会把 ...reading 原样写进 readings.json。我用脚本起了 start(context) 实测:
- id 为
'bad id!'的记录返回 201,可以存进去。但删除路由(第 228 行)只认[A-Za-z0-9-]{1,80},DELETE返回 404,页面上永远删不掉。 - 5000 字的
question没有按MAX_QUESTION = 120截断,附带的额外字段(约 40 KB)也原样落盘。 { id: 'n1', hexagram: null }也能通过(typeof null === 'object')。之后卦历页index.html:3548读entry.hexagram.name会抛错,整个列表显示「读取失败」。- 同一条 reading 存两次,会得到两条同 id 的记录,删一条会两条一起删掉。
建议二选一:
/cast时由服务端暂存生成的 reading(按 id 索引),/history只接受{ id, note };- 或者至少:用和路由一样的正则校验 id、拒绝重复 id、字段白名单、
question截断、要求hexagram是非 null 对象。
There was a problem hiding this comment.
采纳第一个选项。POST /history 现在收 { id, note },没有别的字段。
那个 id 必须命中本进程刚算出来的那一卦——/cast 算完就把结果留在一个 Map 里(保留最近 500 卦,超出挤掉最老的一卦),存卦时按 id 取。落进 readings.json 的因此永远是引擎自己的产物,页面改不动它。你演示的四种情况逐条变成断言:
- 伪造 id(
'bad id!')→ 400,且不落盘 - 整份 reading 回传(老契约)→ 400,且不落盘
- 超长 note → 截到 2000;
question是引擎那一份,长度不超过 120;额外字段一律进不来 - 同一 id 存两次 → 409,不会产生第二条同 id 的记录
hexagram: null 那条现在从根上不存在了:落盘的卦是服务端自己算的,toSummary 拿到的必然是有 name 的卦对象。
另外把 id 正则抽成一个常量,删除路由和存卦校验共用同一份——这样「写得进去」与「删得掉」不可能再分叉。
代价我写进了 README 的 Data & access:一卦要在起卦的同一次会话里存入,服务端重启后不记得它。实际使用中碰不到,因为卦只渲染在结果页上,而结果页要靠这个服务撑着。
相关测试:存卦只认 id,页面回传的整份 reading 一律不收、落盘的批注与所问都有长度上限,条目只可能来自引擎、同一 id 存两次会被挡下,删一条不会连带删另一条。
| try { | ||
| const data = await api('/cast', { | ||
| method: 'POST', | ||
| body: JSON.stringify({ method: 'numbers', question: el('question').value.trim(), upper: item.order, lower: item.order }), |
There was a problem hiding this comment.
这里传的是 upper: item.order, lower: item.order。castByNumbers 是用两个数各自除以 8 取余来定上下卦的,上下用同一个数,得到的永远是上下卦相同的纯卦。我遍历 64 卦验证,只有 2 卦能起回自己,比如 3 水雷屯 → 离为火、2 坤为地 → 兑为泽。
另外,这条路径直接调用 /cast,没有走 cast(),绕过了起卦在途闸,和 PR 说明里「三条起卦路都走 cast()」不一致。
建议服务端加一个按卦 key 起卦的方法(或者由上下卦反推出两个数),并且走 cast()。
There was a problem hiding this comment.
两条都改了。
起不回自己。 新增 castByHexagram(),取上下卦的先天数(乾一兑二离三震四巽五坎六艮七坤八)当两个数,交给 castByNumbers()。仍然走数字起卦的规矩,起卦依据那一栏照实显示:
第一数 6 除 8 余 6 → 坎卦 | 第二数 4 除 8 余 4 → 震卦 | 动爻 10 除 6 余 4 → 四爻
服务端新增 method: 'hexagram',按卦的 key(自下而上六爻)还原。遍历六十四卦逐卦验证,64/64 起得回自己——之前只有 2 卦。这条断言同时在引擎层和 HTTP 层各跑一遍。
绕开在途闸。 这个按钮现在直接调 cast({ method: 'hexagram', key: item.key, question }),于是和另外三条起卦路一样吃得到在途闸,也才有推演动画与落定的那一拍。断言把「走 cast()」与「不再自己发 /cast 请求」两条都钉住了。
| assert.equal(fu.hushen, '丙子水'); | ||
| assert.equal(fu.feishen, '辛巳火'); | ||
| assert.equal(fu.flying, '伏来克飞'); | ||
| assert.equal(fu.emerges.key, '出得来'); |
There was a problem hiding this comment.
MCP 起卦用的是 new Date()(divination-http.mjs:303),而出伏结论会随日辰变化,所以这条断言取决于跑测试的当天。今天(2026-10-08)跑的结果是 227/228,失败信息为 '出不来' !== '出得来',换了时区也一样。把日期固定到 09-29、10-01、10-03、11-15 再跑,结果都是「出得来」。
建议给 callTool / handleMcpRequest 注入一个时钟,或者在测试里用固定的 now 直接调用 buildReading。
There was a problem hiding this comment.
按你说的注入时钟。handleMcpRequest({ response, body, now }),now 缺省才取 new Date(),一路传到 callTool 和 divination_almanac。
这条断言现在传 new Date(2026, 8, 29),与日历无关。
另外补了一条反向断言,免得「注入了但没接上」也算通过:同一个 now 两次调用必须给同一卦(structuredContent 与正文都逐字相等),换一个日期解读必须真的不同。divination_almanac 也走同一个 now,历法与起卦看到的是同一个时刻。
顺带说明为什么它当初会跟着当天走:castFromParams 那条路拿到的 now 既决定卦体也决定日辰,而出伏、日辰、旺衰全都挂在日辰上。所以不是某一处结论特殊,是整条链都跟着时钟。
针对 MiniMax-AI#13 的评审意见。 需要修改: - 存卦接口不校验 reading(server.mjs)。原先 `POST /history` 收的是页面回传的整份 reading,原样落盘,是全包唯一没按 `docs/security.md`「Validate every request parameter」设防的地方。四种后果都复现过:伪造的 id 存得进去却删不掉(删除路由 只认 `[A-Za-z0-9-]`)、`question` 能放大到五千字、顺手带上的几十 KB 额外字段照单 全收、`hexagram: null` 因 `typeof null === 'object'` 过检后让卦历页整个读失败。 改收 `{ id, note }`:id 必须命中本进程刚算出来的那一卦(保留最近 500 卦),落盘的 永远是引擎自己的产物;note 是唯一自由文本,上限 2000;同一 id 存两次回 `409`。 代价是存卦须与起卦同会话——这一点已写进 README 的 Data & access。 - 卦库「以此卦起一卦」起出来的不是这一卦。原先传 `upper: item.order, lower: item.order`,同一个数上下都用,取模 8 后上下卦必然相同,64 卦里只有 2 卦 起得回自己。新增 `castByHexagram()`,取上下卦的先天数(一二三四五六七八)起卦, 仍走数字起卦的规矩,起卦依据照实显示「第一数 6 除 8 余 6 → 坎卦」。这条路径同时 改走 `cast()`,于是也吃得到在途闸。 - 一条单测依赖当天日期。`callTool` 内部走 `new Date()`,而出伏、日辰、旺衰全随日期 变,于是断言跟着跑测试的当天翻面。`handleMcpRequest` 现在接受注入的 `now`。 建议: - 免责声明收敛成一个常量 `DISCLAIMER`。原先三份措辞各不相同,而 SKILL 要求「不得 改写」的那句与工具返回的并不相同,Agent 同时拿到矛盾要求。SKILL、instructions 与每次返回现在逐字取同一常量,有断言钉住。 - 页面与 MCP 的两套起卦校验合并进 `cast-params.mjs`。`castFrom` / `toBoundedInteger` 与 `callTool` / `toInteger` 不再各自一份,上限值、起法名与提示语只有一处; `SERVER_INFO` 的版本号改为取 `PACKAGE_VERSION`,与 `plugin.json` 由断言钉住。 - 删掉 `handleMcpRequest` 的批量分支。`readJsonBody` 拒绝数组,那一支永远走不到 (实测批量请求是 400 `invalid_body`),MCP 自 2025-03-26 起也不再支持批量。 - `index.html` 里 `el('question').trim()` 改为 `.value.trim()`。元素上没有 `trim`, 取的是 `undefined.trim()`;当时没触发是因为三条起卦路都显式传了 question。 - README 的 Data & access 补上 `dataDir/readings.json` 这一项读取,并写明新的存卦 契约与 id 正则。 - README 由 1721 / 1299 行拆为 451 / 389 行,推演规则进 `docs/derivation.md`、 逐轮的开发与测试记录进 `docs/testing.md`(中英各两份)。拆分按内容锚点切, 逐行核对过原文每一非空行都在拆后的某个文件里,无内容丢失。 验证:238 项单测通过(新增 10 项,覆盖上述每一处);仓库 `npm run check` 55/55; `npm run validate` 对本包报 OK;跨平台路径审计 0 问题;UTC-8 到 UTC+14 五个时区与 C locale 下均为 238 通过 0 失败。
梅花易数起卦与解卦。时间、数字、铜钱摇卦、每日一卦四种起法,按体用生克、京房八宫、
纳支、旬空月破、爻之合与爻之刑等传统规则断吉凶,把结论落到所问的那件事上,给应期
与宜忌;内置六十四卦全文(卦辞、彖传、384 条爻辞与 384 条小象传)与干支时辰历法。
既能在页面起卦,也能由 Agent 经 MCP 端点在对话里主动起卦。
安全与边界:
- 全部计算在本机完成:不联网、不调模型、不 spawn 进程
- 入参一律校验:按 id 取卦必须匹配 `[A-Za-z0-9-]{1,80}`,写得进去的条目一定删得掉
- 存卦只收 `{ id, note }`,且那个 id 必须命中本进程刚算出来的那一卦——落进
`readings.json` 的永远是引擎自己的产物,页面塞不进任何东西;note 是唯一自由文本,
上限 2000;同一 id 存两次回 `409`
- 状态只写 dataDir,临时文件改名后落盘,坏盘另存而非删除
- 请求只认回环 Host/Origin,挡住 DNS rebinding 与跨源读取;403 不回显请求方
- 日志只记错误码,不记 error.message——fs 报错会把绝对路径与系统用户名带出去
- 页面渲染:卦历的所问与批注走 textContent,其余插值一律过 escapeHtml
平台与验证:
- 三个平台(Windows / macOS / Linux)均已实跑:安装、经 Agent 打开、四个分区走通
- 239 项单测,HTTP 层真起服务走真 socket;UTC-8 到 UTC+14 五个时区与 C locale 下
均为 239 通过 0 失败
- 路径审计 0 问题:可移植路径、Windows 保留名、UTF-8 无 BOM 无 CRLF、大小写、
import 解析、最坏 dataDir 路径 112 字符(MAX_PATH 260 内)
- 仓库 `npm run check` 55/55;`npm run validate` 对本包报 OK
- 尚未核对的一项:760px 断点以下的窄屏版式
文档分工:README 只讲这个包做什么、怎么用;推演规则在 `docs/derivation.md`,
逐轮的开发与测试记录在 `docs/testing.md`(中英各两份)。
ecafc62 to
b357734
Compare
|
三条「需要修改」都改了,三条行内评论已分别回复。六条建议也一并做了:
有一点想单独说明,因为它是这次合并逻辑时自己踩到的坑,值得 reviewer 也看一眼:摇卦由谁掷必须留在调用方,不能做成共用函数的兜底。 合并之后 验证:239 项单测通过(本轮新增 10 项,覆盖上面每一处);仓库 历史整理成了一条提交——评审后的修改与原始提交合成一条,因为后面两条本质都是在补第一条,叠着看只会更难 review。内容与评审前后的最终状态逐字一致。 |
MyPrototypeWhat
left a comment
There was a problem hiding this comment.
谢谢这么细致的一轮修改,逐条都看了,也在本地实际跑过:「以此卦起一卦」64 卦都能起回自己、也走了在途闸;伪造字段、超长文本、hexagram:null、非法 id 都被挡住;免责声明三处一致;批量数组回 400;README 拆分后 validate 0 警告。合并到最新 main 也没有冲突,npm run check 通过。剩下几处,主要都和新的「只凭 id 存卦」约定有关:
需要修改
- 同一 id 还是会出现两条,有两个来源(详见行内):
server.mjs:324-329先store.get再store.save,这是两个分开排队的任务。同一 id 并发 POST 5 次,我这边得到201,201,201,201,409,卦历里有 4 条同 id。把「已存在就不写」放进save同一个排队任务里就行。divination.mjs:1875的hash.toString(36).slice(0, 6):哈希是 7 位时会砍掉最低位,而计数器 +1 改的正好就是这一位。同一秒内连起两次同样的卦,大约 4 成会拿到同一个 id。在新约定下,后一卦会覆盖缓存里的前一卦:我复现到在「问甲事」那张卡上点存入,落盘的却是「问乙事」,而乙那张再存就是 409。改成不截断(比如padStart(7, '0'))就好了。
- 服务重启后点「存入卦历」没有任何可见反馈。
docs/runtime.md提到进程可能在两次看页之间被停掉再拉起。这时/history回 409unknown_cast(没有 message),而saveReading只调用announce(写进视觉上隐藏的 live region),按钮又复原成可点。用户看到的就是点了没反应。建议 409 带上一句说明(比如「服务已重启,这一卦无法再存,请重新起一卦」),页面像cast()那样给出可见提示。 - 有一条测试随本机时区挂掉。
tests/divination.test.mjs里的SAMPLES用的是绝对时刻,第 2055 行又按「;」数动爻条数,而有的象传里本身就带「;」。在America/Los_Angeles、Pacific/Honolulu下 #81 稳定失败。改成new Date(2026, 8, 30, 1, 20)之后,我这边各时区都是 239/239。
建议(可选)
cast-params.mjs:106掷钱点数按 1–9 收,而提示写的是 6–9;传 1–5 会落到castByCoins的普通 Error,MCP 返回里也就没有 recovery。可以直接按 6–9 校验。- MCP 这边把原始参数整个交给
castFromParams,所以 schema 里没有的sums和method:'hexagram'也会被接受:Agent 传了sums就不再自己掷,而未知起法的提示里又列出了hexagram。可以考虑 MCP 一侧coins一律自己掷,并只接受 enum 里的起法。 - Data & access 一节已经写清楚会读
readings.json,但README.md:8、:323(以及中文版:6、:284)还是「不读包外任何文件」,顺手统一一下就好。
| if (await store.get(id)) { | ||
| sendJson(response, 409, { error: 'already_saved' }); | ||
| return; | ||
| } | ||
| const note = clampText(body.note, MAX_NOTE); | ||
| const saved = await store.save(reading, note); |
There was a problem hiding this comment.
这里的 store.get 和下面的 store.save 是两个分开排队的任务。并发请求会先把各自的 get 都排完(都是 null),再各自 save。我对同一 id 并发 POST 5 次,得到 201,201,201,201,409,卦历里 4 条同 id,正好就是上面注释想避免的情况。可以把检查挪进 save 那个排队任务里,例如在 entries.unshift(entry) 前加 if (entries.some((e) => e.id === reading.id)) return null;,这里拿到 null 回 409。我本地这样改后,并发 5 次是 201,409,409,409,409,测试照样全过。
There was a problem hiding this comment.
按你说的合并了,照搬了你给的那句写法。
store.save 现在在它自己的排队任务里先查重再落盘,撞上已存在的 id 返回 null,路由据此回 409。server 侧不再单独调 store.get。
同一 id 并发 POST 五次,现在是 201,409,409,409,409,卦历里一条。测试 同一 id 存五次只有一个成,其余全挡:检查与写入必须在同一个排队任务里 直接断这五个状态码与卦历条数——并发跑,单靠串行断言是钉不住的。
| for (let index = 0; index < seed.length; index += 1) { | ||
| hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; | ||
| } | ||
| return `${stamp}-${hash.toString(36).slice(0, 6)}`; |
There was a problem hiding this comment.
hash 是 32 位,≥ 36^6 时 toString(36) 是 7 位,slice(0, 6) 会把最低位砍掉,而 idSequence +1 主要改变的正是这一位。所以同一秒内两次同样的起卦经常还是同一个 id。我统计了 3000 个不同秒数,每秒连起两次同样的卦,撞了 1228 次。现在 /cast 的缓存按 id 存,撞了以后后一卦会覆盖前一卦:甲卡点存入,落盘的是乙卡的问题。改成 hash.toString(36).padStart(7, '0')(长度仍然符合 [A-Za-z0-9-]{1,80})就不会撞了。
There was a problem hiding this comment.
改成了 hash.toString(36).padStart(7, '0')。
补零而不是截断是单射的:36 进制里 7 位数的首位不会是 0,所以补出来的 '0xxxxxx' 不可能与真正的 7 位数相同。长度仍是 [A-Za-z0-9-]{1,80}。
测试 id 不再被截断:同一秒内反复起同一卦,id 每次都不同 起了 3000 组,要求 3000 个 id 互不相同,且逐个匹配 ^[0-9]{14}-[a-z0-9]{7}$——这条同时钉住了「不再截断」与「形状仍能被路由取到」两件事。
另外原先钉形状的那条断言上限写的是 {1,6},已跟着改成 {1,7}。
| } catch (error) { | ||
| button.disabled = false; | ||
| button.textContent = '存入卦历'; | ||
| announce(error.message); | ||
| } |
There was a problem hiding this comment.
服务重启后存卦会回 409 unknown_cast。这里只 announce(写进 .sr-only 的 live region),按钮又复原成「存入卦历」,明眼用户看到的是点了没反应,再点还是一样。建议给一个可见的提示(像 cast() 那样 alert,或在按钮旁边显示文字);服务端 409 也带上 message,比如「服务已重启,这一卦无法再存,请重新起一卦」。unknown_cast 时也可以把按钮置为不可用。
There was a problem hiding this comment.
两处都改了。
服务端:409 现在带 message——unknown_cast 是「服务已重启,这一卦无法再存,请重新起一卦。」,already_saved 是「这一卦已经在卦历里了。」
页面:api() 现在把 status 与 error 一起挂在 Error 上,页面据此分两种情况处理。存卦那行多了一个看得见的提示元素(.save-status,aria-live,出错时变朱砂色),原先只 announce() 的写法已经不存在。unknown_cast 是终态,按钮直接置为「这一卦存不了了」并且不再还给用户;其余情况按钮复原并显示原因。
测试 存不下时回的话说得出口,页面上也看得见 两头都断:HTTP 层断言 409 带非空 message,页面源码层断言提示元素被挂进 DOM、样式写了两态、api() 带出了状态码与错误码,并且断言「失败时只写 live region」那段老写法已经不在了。
| const SAMPLES = () => [ | ||
| ['每日一卦', castDaily(new Date('2026-09-30T01:20:00+08:00'))], | ||
| ['时间起卦', castByTime(new Date('2026-09-30T01:20:00+08:00'))], | ||
| ['数字起卦', castByNumbers(37, 24)], | ||
| ['铜钱摇卦', castByCoins([7, 8, 7, 8, 9, 6])], | ||
| ]; |
There was a problem hiding this comment.
这里用的是绝对时刻,castByTime / castDaily 又按本机时区取时辰,所以不同时区得到的卦不同。再加上第 2055 行按「;」数动爻条数,而有的象传里本身就带「;」(比如家人九三),TZ=America/Los_Angeles 和 Pacific/Honolulu 下 #81 会稳定失败。改成 new Date(2026, 8, 30, 1, 20)(和 MCP 那条测试的修法一样)后,我这边各时区都是 239/239。
There was a problem hiding this comment.
这一条我没有只改报告的那一处,把全文件的时间依赖扫了一遍——这类问题在本包已经栽过两次,值得连根拔。
扫出来并改掉的:
- 9 处绝对时刻
new Date('…')(含SAMPLES里那两处,以及另外 7 处同类的),统一换成AT(...)构造墙上时间。本地构造器给出的是同一个「墙上时间」,不随 TZ 变。 - 7 处 MCP 调用没注入时刻。原先
handleMcpRequest支持了注入now,但这些调用一处都没用上,全走真时钟。全部补上。 - 扫遍六十四卦取「头一个真出结果的」那条测试——那段注释本身写的就是「MCP 走的是真实时钟,所以只能暴力搜索躲开日历」。现在固定了时刻,扫出来的是确定的答案。
- 动爻爻辞那条按「;」数条数的断言也改了。你说的家人九三那条象传确实带「;」,按分隔符数会数多,而那不是实现错、是判据错。改成断「只引了动的那几爻」:动爻逐条在、不动的爻一条都不在(同一卦里两条爻辞原文相同的跳过,那会让「不在」这句跟着不成立)。
防复发的三条守卫:
- 禁止
new Date('…')绝对时刻。 - 禁止裸时钟而不写
real-clock:与理由(当前只有一处用到,就是验「时间起卦用的就是今天」那条——它本就该跟当天走,钉死时刻反而没得验)。 - 每一次 MCP 调用都必须给
now,now:与简写now,都认。
验证: 九个时区跑,每种都是 248 通过 0 失败。除你点名的 LA 与 Honolulu 外,还加了 Pacific/Apia、Pacific/Chatham(UTC+12:45)、Australia/Eucla(UTC+8:45)——后两个不是整点时区,最容易藏问题。加 LC_ALL=C LANG=C 极窄 locale 一遍,同样全过。
针对 MiniMax-AI#13 第二轮评审。 需要修改: - 同一 id 还是会出现两条,两个来源。其一:server.mjs 先 `store.get` 再 `store.save`, 那是两个分开排队的任务,并发请求会各自把 `get` 排完(都得到 null)再各自 `save`, 同一 id 并发 POST 五次得到 `201,201,201,201,409` 与四条同 id 记录,而 `remove()` 按 id 过滤,删一条连带删三条。「这一 id 已经存过」现在由 `store.save` 在它自己的排队 任务里判断并落盘,两步合一,同一 id 并发五次是 `201,409,409,409,409`。 其二:`divination.mjs` 的 `hash.toString(36).slice(0, 6)`。32 位哈希转 36 进制有 6 位 也有 7 位,`slice` 把 7 位那种砍掉了最低位,而种子末尾那个递增计数改的正是最低位, 于是「同一秒、同一个卦」照旧撞车(实测 3000 组里撞 1228 次)。新约定按 id 取卦, 撞了之后后一卦会盖掉缓存里的前一卦:在「问甲事」那张卡上点存入,落盘的却是「问乙事」。 改 `padStart(7, '0')` 不再截断,3000 组 0 撞。 - 服务重启后点「存入卦历」没有任何可见反馈。409 现在带一句人话,页面上多了一处看得见的 提示(原先只有 `announce()`,写进的是 `.sr-only` 的 live region,屏幕上什么都不出现, 按钮又复原成可点)。`unknown_cast` 是终态——那一卦再也存不了——于是按钮不再还给用户。 `api()` 顺带把状态码与错误码带出来,页面才分得清「存不了了」与「已经存过了」。 - 一条测试随本机时区挂掉。这次没有只修报告的那一条:全文件扫了一遍时间依赖, 九处绝对时刻(`new Date('…')`)换成统一构造的墙上时间(`AT(...)`),另有七处 MCP 调用 没注入时刻,一并注上。原来那条扫遍六十四卦取「头一个真出结果的」测试,是当年为躲开 真时钟写的补救,现在固定了时刻,扫出来的是确定的答案。 与此同时加了三条守卫断言,让这类问题不能再悄悄回来:禁止绝对时刻、禁止没写明理由的 裸时钟、每一次 MCP 调用都必须给 `now`。另外动爻爻辞那条断言原先按「;」数条数,而 象传正文里本来就可能带「;」(家人九三),一按分隔符数就数多——改成断「只引了动的 那几爻」,动爻逐条在、不动的爻一条都不在。 建议: - 掷钱点数按 6–9 收。原来照抄通用上界收 1–9,1–5 放行后被 `castByCoins` 的普通 Error 接住,那条路没有 recovery,Agent 只拿得到「必须是 6 到 9」而拿不到该怎么做。 - MCP 按 schema 白名单收参数。原先把 `args` 整个递进 `castFromParams`,schema 之外的 键也当真:`sums` 会让本该自掷的摇卦改用 Agent 指定的点数,`method: 'hexagram'` 更是 页面卦库专用的起法。现在 `coins` 一律自掷,起法只在 MCP 对外认的那组里,报错与 `instructions` 的枚举取同一份常量。 - 页面页脚的免责说明改成逐字包含 `DISCLAIMER` 常量,断言把四处(工具返回、 instructions、SKILL、页脚)钉在一起。README 首部与 FAQ 里「不读包外任何文件」的说法 也统一了:包目录以外只读一个文件,就是 Host 建的私有 dataDir 里的卦历。 验证:248 项单测通过(本轮新增 6 项);仓库 `npm run check` 55/55;`npm run validate` 对本包报 OK;跨平台路径审计 0 问题;九个时区(UTC-8 到 UTC+14,含 UTC+12:45 的 Chatham 与 UTC+8:45 的 Eucla)加 C locale,每种组合都是 248 通过 0 失败。
|
三条「需要修改」都改了,行内评论已分别回复。三条建议也一并做了:
另外补一件评审没提但属于同一类的事:页面页脚的免责声明原先措辞与 关于时间依赖,这次是连根拔的。 你指出的是其中一处,我扫了整个测试文件,结果同类问题有 16 处:9 处绝对时刻、7 处 MCP 调用没注入时刻。其中扫遍六十四卦取「头一个真出结果的」那条,注释里写的正是「MCP 走的是真实时钟,所以只能暴力搜索躲开日历」——那本身就是当年绕开同一个坑的产物,现在有了注入时钟就一并根治了。 为防止第三次,加了三条守卫断言:禁止绝对时刻、禁止没写明理由的裸时钟、每一次 MCP 调用都必须给 验证: 248 项单测通过(本轮新增 6 项,含并发、3000 组 id 撞车、409 可见反馈、MCP 参数白名单);仓库 这一轮作为独立 commit 追加在后面,没有动上一轮的内容。 |
MyPrototypeWhat
left a comment
There was a problem hiding this comment.
谢谢这一轮的修改,我都在本地复跑过:同一 id 并发存 5 次现在是 201,409,409,409,409,卦历里只有一条;id 不截断以后,3000 组同秒同卦 0 撞;服务重启后存卦有可见提示,按钮也锁住了;测试在 LA、Honolulu、Chatham、Eucla 等 12 个时区,加上 13 个伪造日期 × 3 个时刻 × 6 个时区的组合下全部 248/248;MCP 的参数白名单、掷钱 6–9 校验也都确认了。合并到最新 main 没有冲突,npm run check 通过。这边没有需要修改的了,批准。
建议(可选)
README.zh-CN.md:284的 FAQ 还写着「不读包目录外的任何文件」,英文那句已经改了,中文这句顺手统一一下。- 存卦遇到
already_saved(比如第一次已经写入、但响应丢了,用户重试)时,页面现在显示红字并把按钮复原成可点,其实可以直接按成功处理,显示「已存入卦历」。index.html:2076注释里说的「还能改批注重试」也不太对,目前没有改批注的路由。 unknown_cast的提示固定写「服务已重启」,但缓存挤出(超过 500 卦)或 id 没起过时也会走这条,措辞可以放宽一点,比如「这一卦已不在本次会话里(服务可能重启过)」。cast-params.mjs里coinSumsFrom上面叠着两段 JSDoc,前一段关于「页面没传就自己掷六次」的描述已经过时,可以删掉。
是什么
梅花易数占卜 Mini App。时间、数字、铜钱摇卦、每日一卦四种起法,按体用生克、京房八宫、纳支、
旬空月破、爻之合与爻之刑等传统规则断吉凶,把结论落到所问的那件事上,给应期与宜忌。
内置六十四卦全文(卦辞、彖传、384 条爻辞与 384 条小象传)与干支时辰历法。
两种用法:打开页面起卦;或在对话里由 Agent 经 MCP 端点主动起卦并讲解。
包目录
plugins/weekbin/chinese-divination/,插件 IDchinese-divination,版本 1.1.0。安全与边界
字体或脚本。唯一网络行为是 MCP 端点在 Host 分配的回环地址上监听,且只接受 POST。
miniapp/client/index.html、本包 Node 载荷,以及context.dataDir/readings.json(卦历,凡碰到卦历的请求都会读它)。[A-Za-z0-9-]{1,80},于是写得进去的条目一定删得掉;起卦的起法名与数值上下界在进引擎之前就校验完。
{ id, note }。 那个 id 必须命中本进程刚算出来的那一卦(服务端保留最近500 卦),所以落进
readings.json的永远是引擎自己的产物,页面往里塞不了任何别的东西。note 是唯一自由文本,上限 2000 字。同一 id 存两次回
409——两条同 id 的记录会让删一条连带删另一条。代价是一卦要在起卦的同一次会话里存入(服务端重启后不记得它),这一点已写进
README 的 Data & access。
EPERM/EACCES/EBUSY时退避重试四次。解析不出的落盘文件被改名另存为readings.json.corrupt-<时间戳>而非删除,手写批注仍可找回。Host头,以及存在时的Origin头,都必须是回环名。Host挡 DNSrebinding(攻击者域名解析到 127.0.0.1 后浏览器视作同源),
Origin挡直接打端口的跨源简单请求。被拒时返回 403 且不回显请求方。
error.message——Node 的 fs 报错会把完整绝对路径连同操作系统用户名写进 message,而 dataDir 按契约是不透明的,那串路径不该跟着日志离开进程
(日志会被贴进 issue、传进工单、上传)。
textContent渲染,其余插值一律过escapeHtml。客户端不写 localStorage / cookie / IndexedDB。
测试环境与结果
三个平台均已实跑:Windows、macOS、Linux。 三边都装好后经 Agent 打开,页面渲染正常,四个分区
可操作,掷钱、卦历与 MCP 各条路径一并查过。
macOS 那一轮是脚本化的,每一步都记在 README:
America/New_York、UTC、Asia/Shanghai、Pacific/Kiritimati、Pacific/Apia、America/Los_Angeles、Pacific/Honolulu、Pacific/Chatham(UTC+12:45)、Australia/Eucla(UTC+8:45),另加
LC_ALL=C LANG=C极窄 locale 一遍,每种组合都是 248 通过 0 失败。当天走,一次绝对时刻在不同时区落进不同的卦),于是把两类写法禁进了测试里:禁止
new Date('…')绝对时刻(castByTime/castDaily取的是本机时区的时辰与日柱),禁止不写明理由就碰裸时钟;每一次 MCP 调用都必须显式给
now。start(context)起真服务、走真 socket,覆盖页面、全部起法、卦历增删查、MCP 全路径、
dispose幂等,以及本轮修的每一处注入面。不同的文件、每条相对 import 都能在磁盘上找到;最坏形态的 dataDir 全路径 112 字符,
离
MAX_PATH260 还远。npm run check55/55 通过;npm run validate对本包报OK(0 错误 0 警告)。两条平台相关的设计决定,由静态核查托底:路径一律用
node:path拼;无__dirname(ESM 里不存在),import.meta.url只在测试文件里以new URL(相对, import.meta.url)+fileURLToPath出现,即ESM 跨平台的正解,且测试文件在运行时载荷之外。
尚未核对的一项: 760px 断点以下的窄屏版式,三个平台都未在实机看过。收窄内容区替代不了——
那两处容器查询反应的是区块自身宽度而非视口宽度,只有真实窄视口才走得到那条路。
几个值得一提的实现选择
相同的种子,所以种子里掺一个进程内递增的计数。哈希转 36 进制有 6 位也有 7 位,这里补零而
不截断——
slice砍掉的正是计数改动的那一位,3000 组里会撞 1228 次。存卦时「这个 id 已经存过」的判断与写入在同一个排队任务里,不会因并发漏判。
现在
cast()在第一个await之前同步置位casting并锁住触发器,解锁按状态计算而不是清空
disabled——成卦解卦另有「满六次才能点」的规则,一刀切会留下一个看得见却点不动的按钮。disabled。 原先renderToss()算完可用态,末尾又无条件全部启用,两者互相覆盖,六次之后两个按钮同时可点。现在可用态集中在
syncTossButtons()。点数,悄悄替他重掷会与推演日志对不上;MCP 背后没有人替用户掷,它自己掷六次。这条岔路有
专门的测试钉住。
用户原话,由它填
topic更准。九类之外的值直接报错而不是静默忽略——静默忽略会让 Agent以为自己已经定过了。
instructions与 SKILL 里那一句逐字取同一个常量,有断言钉住。
cast-params.mjs,上限值、起法名与提示语不再各写一份。README 只讲这个包做什么、怎么用;推演规则在
docs/derivation.md,逐轮的开发与测试记录在docs/testing.md(中英各两份)。许可
MIT,见
LICENSE。